json-rest-api 1.0.23 → 1.0.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/README.md +24 -2
  2. package/docs/API.md +157 -33
  3. package/docs/GUIDE/GUIDE_1_Initial_Setup.md +88 -6
  4. package/docs/GUIDE/GUIDE_2_1_The_Starting_Point.md +3 -3
  5. package/docs/GUIDE/GUIDE_3_Field_Transformations.md +2 -0
  6. package/docs/GUIDE/GUIDE_7_Hooks_Data_Management_And_Plugins.md +1 -1
  7. package/docs/GUIDE/GUIDE_X_Autofiltering.md +63 -0
  8. package/docs/GUIDE/GUIDE_X_File_Uploads.md +17 -12
  9. package/docs/GUIDE/GUIDE_X_Knex_Schema_And_Migrations.md +122 -2
  10. package/docs/GUIDE/GUIDE_X_Row_Policies.md +603 -0
  11. package/docs/GUIDE/GUIDE_X_SocketIO.md +15 -10
  12. package/docs/GUIDE/GUIDE_X_URL_Management.md +93 -254
  13. package/docs/GUIDE/GUIDE_X_Writing_Plugins.md +2 -0
  14. package/docs/GUIDE/index.md +4 -1
  15. package/fixing_plan.md +112 -0
  16. package/index.js +2 -0
  17. package/lib/enhanced-logger.js +1 -1
  18. package/package.json +6 -2
  19. package/plugins/core/bulk-operations-plugin.js +31 -38
  20. package/plugins/core/connectors/express-plugin.js +6 -7
  21. package/plugins/core/connectors/fastify-plugin.js +5 -1
  22. package/plugins/core/connectors/lib/busboy-detector.js +4 -4
  23. package/plugins/core/connectors/lib/connector-core.js +17 -11
  24. package/plugins/core/connectors/lib/transport-route-schemas.js +12 -60
  25. package/plugins/core/file-handling-plugin.js +126 -96
  26. package/plugins/core/lib/anyapi/anyapi-registry.js +21 -3
  27. package/plugins/core/lib/anyapi/schema-utils.js +44 -4
  28. package/plugins/core/lib/anyapi/utils/descriptor-helpers.js +11 -2
  29. package/plugins/core/lib/dbIntrospection.js +1 -1
  30. package/plugins/core/lib/dbTablesOperations.js +10 -3
  31. package/plugins/core/lib/querying/knex-cross-table-search.js +0 -7
  32. package/plugins/core/lib/querying/knex-json-api-transformers-querying.js +20 -16
  33. package/plugins/core/lib/querying/knex-pagination-helpers.js +38 -84
  34. package/plugins/core/lib/querying/knex-query-helpers.js +5 -6
  35. package/plugins/core/lib/querying/knex-relationship-includes.js +40 -46
  36. package/plugins/core/lib/querying/knex-window-queries.js +10 -77
  37. package/plugins/core/lib/querying/url-helpers.js +9 -21
  38. package/plugins/core/lib/querying-writing/compile-schemas.js +10 -6
  39. package/plugins/core/lib/querying-writing/connectors-query-parser.js +51 -0
  40. package/plugins/core/lib/querying-writing/database-capabilities.js +1 -1
  41. package/plugins/core/lib/querying-writing/database-value-normalizers.js +18 -9
  42. package/plugins/core/lib/querying-writing/knex-field-helpers.js +7 -3
  43. package/plugins/core/lib/querying-writing/relationship-contracts.js +56 -0
  44. package/plugins/core/lib/querying-writing/request-contracts.js +53 -4
  45. package/plugins/core/lib/querying-writing/resource-id-normalization.js +131 -0
  46. package/plugins/core/lib/querying-writing/return-record-settings.js +47 -0
  47. package/plugins/core/lib/querying-writing/schema-helpers.js +34 -43
  48. package/plugins/core/lib/querying-writing/simplified-helpers.js +4 -11
  49. package/plugins/core/lib/storage/canonical-storage-mapping.js +24 -3
  50. package/plugins/core/lib/storage/storage-adapter.js +76 -14
  51. package/plugins/core/lib/storage/storage-mapping.js +86 -16
  52. package/plugins/core/lib/writing/many-to-many-manipulations.js +8 -5
  53. package/plugins/core/lib/writing/relationship-processor.js +10 -1
  54. package/plugins/core/rest-api-anyapi-knex-plugin.js +402 -197
  55. package/plugins/core/rest-api-knex-plugin.js +93 -76
  56. package/plugins/core/rest-api-plugin-hooks/turn-scope-init-into-vars.js +9 -2
  57. package/plugins/core/rest-api-plugin-methods/common.js +112 -72
  58. package/plugins/core/rest-api-plugin-methods/delete-relationship.js +34 -15
  59. package/plugins/core/rest-api-plugin-methods/delete.js +8 -7
  60. package/plugins/core/rest-api-plugin-methods/get-related.js +22 -15
  61. package/plugins/core/rest-api-plugin-methods/get-relationship.js +8 -3
  62. package/plugins/core/rest-api-plugin-methods/get.js +7 -1
  63. package/plugins/core/rest-api-plugin-methods/patch-relationship.js +26 -8
  64. package/plugins/core/rest-api-plugin-methods/patch.js +34 -9
  65. package/plugins/core/rest-api-plugin-methods/post-relationship.js +35 -16
  66. package/plugins/core/rest-api-plugin-methods/post.js +12 -7
  67. package/plugins/core/rest-api-plugin-methods/put.js +35 -10
  68. package/plugins/core/rest-api-plugin-methods/release.js +0 -2
  69. package/plugins/core/rest-api-plugin.js +11 -37
  70. package/plugins/core/rest-api-positioning-plugin.js +1 -4
  71. package/plugins/core/rest-api-row-policy-plugin.js +157 -0
  72. package/plugins/core/socketio-plugin.js +0 -1
  73. package/plugins/storage/local-storage.js +45 -20
  74. package/plugins/storage/s3-storage.js +23 -54
  75. package/quickTest.js +1 -0
  76. package/scripts/run-docs.js +80 -84
  77. package/test-debug.js +1 -1
  78. package/tests/README.md +8 -1
  79. package/tests/anyapi-basic.test.js +3 -0
  80. package/tests/anyapi-cursor-pagination.test.js +7 -1
  81. package/tests/anyapi-custom-idproperty.test.js +163 -0
  82. package/tests/custom-id-mysql-returning-fallback.test.js +128 -0
  83. package/tests/db-introspection.test.js +4 -1
  84. package/tests/db-table-operations.test.js +25 -20
  85. package/tests/fastify-plugin.test.js +49 -6
  86. package/tests/field-setters.test.js +10 -10
  87. package/tests/file-handling.test.js +118 -0
  88. package/tests/fixtures/api-configs.js +495 -4
  89. package/tests/full-jsonapi-belongsto-linkage.test.js +150 -0
  90. package/tests/http-connectors-parity.test.js +165 -5
  91. package/tests/id-normalization.test.js +444 -0
  92. package/tests/local-storage.test.js +96 -0
  93. package/tests/logical-id-write-validation.test.js +151 -0
  94. package/tests/pagination-cursor-multifield.test.js +132 -11
  95. package/tests/pagination-enhanced.test.js +66 -15
  96. package/tests/post-commit-hooks.test.js +120 -0
  97. package/tests/public-exports.test.js +15 -0
  98. package/tests/put-create-parity.test.js +216 -0
  99. package/tests/relationship-endpoints.test.js +259 -0
  100. package/tests/return-record-settings.test.js +184 -0
  101. package/tests/row-policy.test.js +433 -0
  102. package/tests/s3-storage.test.js +38 -0
  103. package/tests/searchschema-merge.test.js +17 -0
  104. package/tests/socketio.test.js +62 -0
  105. package/tests/storage-mapping.test.js +56 -6
  106. package/tests/test-development-guide.md +5 -5
@@ -3,6 +3,10 @@ import { processRelationships } from '../lib/writing/relationship-processor.js'
3
3
  import { updateManyToManyRelationship, createPivotRecords } from '../lib/writing/many-to-many-manipulations.js'
4
4
  import { ERROR_SUBTYPES } from '../lib/querying-writing/knex-constants.js'
5
5
  import { getRequestContracts, validateRequestContractOrThrow } from '../lib/querying-writing/request-contracts.js'
6
+ import {
7
+ requireDocumentResourceId,
8
+ requireExistingResourceId
9
+ } from '../lib/querying-writing/resource-id-normalization.js'
6
10
  import {
7
11
  setupCommonRequest,
8
12
  validateCompleteReplacePayload,
@@ -11,7 +15,8 @@ import {
11
15
  applyFieldSetters,
12
16
  validatePivotResource,
13
17
  handleRecordReturnAfterWrite,
14
- handleWriteMethodError
18
+ handleWriteMethodError,
19
+ commitOwnedTransaction
15
20
  } from './common.js'
16
21
 
17
22
  /**
@@ -61,14 +66,37 @@ export default async function putMethod ({
61
66
  includeDepthLimit: vars.includeDepthLimit,
62
67
  sortableFields: vars.sortableFields
63
68
  })
69
+ const normalizedPathId = params.id === undefined
70
+ ? null
71
+ : requireExistingResourceId(params.id, {
72
+ scopeOptions,
73
+ vars,
74
+ scopeName
75
+ })
76
+
77
+ if (normalizedPathId && !context.inputRecord?.data?.id) {
78
+ context.inputRecord = {
79
+ ...context.inputRecord,
80
+ data: {
81
+ ...(context.inputRecord?.data || {}),
82
+ id: normalizedPathId
83
+ }
84
+ }
85
+ }
86
+
64
87
  context.inputRecord = validateRequestContractOrThrow(
65
88
  requestContracts.put,
66
89
  context.inputRecord,
67
90
  'PUT request body is invalid'
68
91
  )
69
- if (params.id && String(params.id) !== String(context.inputRecord.data.id)) {
92
+ const normalizedBodyId = requireDocumentResourceId(context.inputRecord.data.id, {
93
+ scopeOptions,
94
+ vars
95
+ })
96
+
97
+ if (normalizedPathId && normalizedPathId !== normalizedBodyId) {
70
98
  throw new RestApiValidationError(
71
- `ID mismatch. URL path ID '${params.id}' does not match request body ID '${context.inputRecord.data.id}'`,
99
+ `ID mismatch. URL path ID '${normalizedPathId}' does not match request body ID '${normalizedBodyId}'`,
72
100
  {
73
101
  fields: ['data.id'],
74
102
  violations: [{
@@ -79,7 +107,8 @@ export default async function putMethod ({
79
107
  }
80
108
  )
81
109
  }
82
- context.id = params.id || context.inputRecord.data.id
110
+ context.inputRecord.data.id = normalizedBodyId
111
+ context.id = normalizedPathId || normalizedBodyId
83
112
 
84
113
  // Validate that user has read access to all related resources
85
114
  // This ensures users can only create relationships to resources they can access
@@ -246,7 +275,7 @@ export default async function putMethod ({
246
275
  }
247
276
  })
248
277
  } else if (relData.length > 0) {
249
- await createPivotRecords(api, context.id, relDef, relData, context.transaction)
278
+ await createPivotRecords(api, context.id, relDef, relData, context.transaction, context)
250
279
  }
251
280
  }
252
281
 
@@ -264,11 +293,7 @@ export default async function putMethod ({
264
293
  log
265
294
  })
266
295
 
267
- // Commit transaction if we created it
268
- if (context.shouldCommit) {
269
- await context.transaction.commit()
270
- await runHooks('afterCommit')
271
- }
296
+ await commitOwnedTransaction(context, runHooks)
272
297
 
273
298
  return ret
274
299
  } catch (error) {
@@ -1,5 +1,3 @@
1
- import { ValidationError } from 'hooked-api'
2
-
3
1
  /**
4
2
  * release
5
3
  * Runs the hook that tells plugins to release resources
@@ -21,12 +21,14 @@ import addRouteMethod from './rest-api-plugin-methods/add-route.js'
21
21
  import releaseMethod from './rest-api-plugin-methods/release.js'
22
22
  import { defaultDataHelpers } from './lib/querying-writing/default-data-helpers.js'
23
23
  import { DEFAULT_QUERY_LIMIT, DEFAULT_MAX_QUERY_LIMIT, DEFAULT_INCLUDE_DEPTH_LIMIT } from './lib/querying-writing/knex-constants.js'
24
+ import { normalizeReturnRecordSetting } from './lib/querying-writing/return-record-settings.js'
24
25
 
25
26
  import getRelatedMethod from './rest-api-plugin-methods/get-related.js'
26
27
  import postRelationshipMethod from './rest-api-plugin-methods/post-relationship.js'
27
28
  import getRelationshipMethod from './rest-api-plugin-methods/get-relationship.js'
28
29
  import patchRelationshipMethod from './rest-api-plugin-methods/patch-relationship.js'
29
30
  import deleteRelationshipMethod from './rest-api-plugin-methods/delete-relationship.js'
31
+ import { defaultNormalizeResourceId } from './lib/querying-writing/resource-id-normalization.js'
30
32
 
31
33
  export const RestApiPlugin = {
32
34
  name: 'rest-api',
@@ -66,43 +68,15 @@ export const RestApiPlugin = {
66
68
  : true // Default true for better DX in programmatic API
67
69
 
68
70
  vars.idProperty = restApiOptions.idProperty || 'id'
69
-
70
- // Return full record configuration for API and Transport
71
- // Support values: 'no', 'minimal', 'full'
72
- const normalizeReturnValue = (value, defaultValue) => {
73
- if (['no', 'minimal', 'full'].includes(value)) return value
74
- return defaultValue
75
- }
76
-
77
- const optionConfigs = [
78
- {
79
- propName: 'returnRecordApi',
80
- defaultValue: 'full',
81
- },
82
- {
83
- propName: 'returnRecordTransport',
84
- defaultValue: 'no',
85
- },
86
- ]
87
-
88
- for (const config of optionConfigs) {
89
- const optionValue = restApiOptions[config.propName]
90
- let processedValue
91
-
92
- if (typeof optionValue === 'object' && optionValue !== null) {
93
- processedValue = {
94
- post: normalizeReturnValue(optionValue.post, config.defaultValue),
95
- put: normalizeReturnValue(optionValue.put, config.defaultValue),
96
- patch: normalizeReturnValue(optionValue.patch, config.defaultValue),
97
- }
98
- } else if (optionValue !== undefined) {
99
- const normalized = normalizeReturnValue(optionValue, config.defaultValue)
100
- processedValue = { post: normalized, put: normalized, patch: normalized }
101
- } else {
102
- processedValue = { post: config.defaultValue, put: config.defaultValue, patch: config.defaultValue }
103
- }
104
- vars[config.propName] = processedValue
105
- }
71
+ vars.normalizeId = typeof restApiOptions.normalizeId === 'function'
72
+ ? restApiOptions.normalizeId
73
+ : defaultNormalizeResourceId
74
+
75
+ // Return record configuration for API and Transport.
76
+ // Preferred values are 'no', 'minimal', and 'full'; booleans are normalized
77
+ // for backwards compatibility.
78
+ vars.returnRecordApi = normalizeReturnRecordSetting(restApiOptions.returnRecordApi, 'full')
79
+ vars.returnRecordTransport = normalizeReturnRecordSetting(restApiOptions.returnRecordTransport, 'no')
106
80
 
107
81
  log.debug('returnRecordApi configuration:', vars.returnRecordApi)
108
82
  log.debug('returnRecordTransport configuration:', vars.returnRecordTransport)
@@ -1,8 +1,6 @@
1
1
  import {
2
2
  calculatePosition,
3
3
  getInitialPosition,
4
- isValidPosition,
5
- assignInitialPositions,
6
4
  } from './lib/fractional-positioning.js'
7
5
  import { createStorageAdapter } from './lib/storage/storage-adapter.js'
8
6
 
@@ -167,7 +165,7 @@ export const PositioningPlugin = {
167
165
  fieldExists = true
168
166
  } else {
169
167
  // Check if it's a relationship name (as property)
170
- for (const [fieldName, fieldDef] of Object.entries(schema)) {
168
+ for (const [, fieldDef] of Object.entries(schema)) {
171
169
  if (fieldDef.as === filterField) {
172
170
  fieldExists = true
173
171
  break
@@ -419,7 +417,6 @@ export const PositioningPlugin = {
419
417
  log.debug('*** Position filter conditions', { scopeName, filterConditions })
420
418
 
421
419
  // Query existing items in the same position group
422
- const knex = api.knex.instance
423
420
  const tableName = adapter.getTableName()
424
421
  const idProperty = schemaInfo.idProperty
425
422
  const idColumn = adapter.getIdColumn()
@@ -0,0 +1,157 @@
1
+ import { createStorageAdapterUtilities } from './lib/querying/storage-adapter-utils.js'
2
+
3
+ const INLINE_POLICY_NAME = '<inline>'
4
+
5
+ function createRowPolicyError (message, code = 'REST_API_ROW_POLICY_CONTRACT') {
6
+ const error = new Error(message)
7
+ error.code = code
8
+ return error
9
+ }
10
+
11
+ function compileRowPolicy ({ scopeName, scopeOptions = {}, policies }) {
12
+ const definition = scopeOptions.rowPolicy
13
+
14
+ if (definition === undefined || definition === null || definition === false) {
15
+ return null
16
+ }
17
+
18
+ if (typeof definition === 'function') {
19
+ return {
20
+ name: INLINE_POLICY_NAME,
21
+ source: 'inline',
22
+ applyQuery: definition
23
+ }
24
+ }
25
+
26
+ if (typeof definition === 'string') {
27
+ const name = definition.trim()
28
+ const applyQuery = policies[name]
29
+
30
+ if (!name || !applyQuery) {
31
+ throw createRowPolicyError(
32
+ `Unknown row policy '${definition}' on resource '${scopeName}'.`
33
+ )
34
+ }
35
+
36
+ return {
37
+ name,
38
+ source: 'registry',
39
+ applyQuery
40
+ }
41
+ }
42
+
43
+ throw createRowPolicyError(
44
+ `Row policy for resource '${scopeName}' must be a registered policy name, a function, or false.`
45
+ )
46
+ }
47
+
48
+ export const RowPolicyPlugin = {
49
+ name: 'row-policy',
50
+ dependencies: ['rest-api', 'rest-api-knex|rest-api-anyapi-knex'],
51
+
52
+ install ({ api, addHook, vars, helpers, log, scopes, pluginOptions = {} }) {
53
+ if (!api.knex?.instance) {
54
+ throw new Error('RowPolicyPlugin requires a storage plugin with knex support (rest-api-knex or rest-api-anyapi-knex).')
55
+ }
56
+
57
+ const policies = { ...(pluginOptions.policies || {}) }
58
+ for (const [name, policy] of Object.entries(policies)) {
59
+ if (typeof policy !== 'function') {
60
+ throw createRowPolicyError(`Row policy '${name}' must be a function.`)
61
+ }
62
+ }
63
+
64
+ vars.rowPolicy = {
65
+ policies: Object.keys(policies)
66
+ }
67
+
68
+ addHook('scope:added', 'compile-row-policy', {}, ({ context }) => {
69
+ const { scopeName, scopeOptions = {} } = context
70
+ const scope = scopes[scopeName]
71
+
72
+ scope.vars.rowPolicy = compileRowPolicy({
73
+ scopeName,
74
+ scopeOptions,
75
+ policies
76
+ })
77
+ })
78
+
79
+ addHook('knexQueryFiltering', 'apply-row-policy', {}, async (hookParams) => {
80
+ const { context } = hookParams
81
+ const {
82
+ query,
83
+ scopeName,
84
+ tableName,
85
+ db,
86
+ queryPurpose = 'unspecified',
87
+ isAnyApi = false,
88
+ storageAdapter: hookStorageAdapter
89
+ } = context.knexQuery || {}
90
+
91
+ if (!query || !scopeName) return
92
+
93
+ const compiledPolicy = scopes[scopeName]?.vars?.rowPolicy
94
+ if (!compiledPolicy) return
95
+
96
+ const adapterUtils = createStorageAdapterUtilities(hookParams, {
97
+ getStorageAdapter: helpers.getStorageAdapter
98
+ })
99
+ const storageAdapter = hookStorageAdapter || adapterUtils.fetchStorageAdapter(scopeName)
100
+
101
+ const column = (field, options = {}) => {
102
+ const targetScopeName = options.scopeName || scopeName
103
+ const alias = Object.prototype.hasOwnProperty.call(options, 'alias')
104
+ ? options.alias
105
+ : adapterUtils.defaultAliasForScope(targetScopeName)
106
+ return adapterUtils.translateColumn(targetScopeName, field, alias)
107
+ }
108
+
109
+ const value = (field, rawValue, options = {}) => {
110
+ const targetScopeName = options.scopeName || scopeName
111
+ return adapterUtils.translateFilterValue(targetScopeName, field, rawValue)
112
+ }
113
+
114
+ const result = await compiledPolicy.applyQuery({
115
+ query,
116
+ context,
117
+ scopeName,
118
+ tableName,
119
+ queryPurpose,
120
+ db,
121
+ isAnyApi,
122
+ storageAdapter,
123
+ column,
124
+ value,
125
+ api
126
+ })
127
+
128
+ if (result === false) {
129
+ query.whereRaw('1 = 0')
130
+ } else if (result !== true) {
131
+ throw createRowPolicyError(
132
+ `Row policy '${compiledPolicy.name}' on resource '${scopeName}' must return true after applying its predicate, or false to deny all rows.`
133
+ )
134
+ }
135
+ })
136
+
137
+ api.rowPolicies = {
138
+ getConfig: () => ({
139
+ policies: Object.keys(policies)
140
+ }),
141
+
142
+ getScopeConfig: (scopeName) => {
143
+ const compiledPolicy = scopes[scopeName]?.vars?.rowPolicy
144
+ if (!compiledPolicy) return null
145
+
146
+ return {
147
+ policy: compiledPolicy.name,
148
+ source: compiledPolicy.source
149
+ }
150
+ }
151
+ }
152
+
153
+ log.info('Row policy plugin installed', {
154
+ policies: Object.keys(policies)
155
+ })
156
+ }
157
+ }
@@ -393,7 +393,6 @@ export const SocketIOPlugin = {
393
393
  let io
394
394
 
395
395
  api.startSocketServer = async (server, startOptions = {}) => {
396
- const transportConfig = { ...config.transport, ...startOptions.transport }
397
396
  const authConfig = { ...config.auth, ...(startOptions.auth || {}) }
398
397
  const redisConfig = startOptions.redis ?? pluginOptions.redis ?? null
399
398
 
@@ -19,6 +19,45 @@ export class LocalStorage {
19
19
  this.maxFilenameLength = options.maxFilenameLength || 255
20
20
  }
21
21
 
22
+ hasPathSegments (filename) {
23
+ return filename.includes('/') ||
24
+ filename.includes('\\') ||
25
+ path.isAbsolute(filename) ||
26
+ path.win32.isAbsolute(filename) ||
27
+ /^[A-Za-z]:/.test(filename)
28
+ }
29
+
30
+ validateCustomBasename (basename) {
31
+ if (typeof basename !== 'string') {
32
+ throw new Error('Custom nameGenerator must return a string')
33
+ }
34
+ if (basename.trim().length === 0) {
35
+ throw new Error('Custom nameGenerator must return a non-empty basename')
36
+ }
37
+ if (this.hasPathSegments(basename)) {
38
+ throw new Error('Custom nameGenerator must return a basename, not a path')
39
+ }
40
+ return basename
41
+ }
42
+
43
+ resolveStoragePath (filename) {
44
+ if (typeof filename !== 'string' || filename.trim().length === 0) {
45
+ throw new Error('Invalid file name')
46
+ }
47
+ if (this.hasPathSegments(filename)) {
48
+ throw new Error('Invalid file path')
49
+ }
50
+
51
+ const resolvedDir = path.resolve(this.directory)
52
+ const resolvedPath = path.resolve(resolvedDir, filename)
53
+ const relative = path.relative(resolvedDir, resolvedPath)
54
+ if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
55
+ throw new Error('Invalid file path')
56
+ }
57
+
58
+ return resolvedPath
59
+ }
60
+
22
61
  /**
23
62
  * Generate a safe filename
24
63
  */
@@ -61,7 +100,7 @@ export class LocalStorage {
61
100
  if (!this.nameGenerator) {
62
101
  throw new Error('nameGenerator function required for custom strategy')
63
102
  }
64
- basename = await this.nameGenerator(file)
103
+ basename = this.validateCustomBasename(await this.nameGenerator(file))
65
104
  break
66
105
 
67
106
  default:
@@ -92,7 +131,7 @@ export class LocalStorage {
92
131
  return filename
93
132
  // Remove path traversal attempts
94
133
  .replace(/\.\./g, '')
95
- .replace(/[\/\\]/g, '')
134
+ .replace(/[/\\]/g, '')
96
135
  // Remove control characters and special chars
97
136
  .replace(/[^\w\s.-]/g, '_')
98
137
  // Remove leading/trailing dots and spaces
@@ -107,7 +146,7 @@ export class LocalStorage {
107
146
  * Ensure filename is unique
108
147
  */
109
148
  async ensureUnique (filename) {
110
- const filepath = path.join(this.directory, filename)
149
+ const filepath = this.resolveStoragePath(filename)
111
150
 
112
151
  try {
113
152
  await fs.access(filepath)
@@ -120,7 +159,7 @@ export class LocalStorage {
120
159
  do {
121
160
  newFilename = `${base}_${counter}${ext}`
122
161
  counter++
123
- } while (await this.fileExists(path.join(this.directory, newFilename)))
162
+ } while (await this.fileExists(this.resolveStoragePath(newFilename)))
124
163
 
125
164
  return newFilename
126
165
  } catch (error) {
@@ -171,14 +210,7 @@ export class LocalStorage {
171
210
 
172
211
  // Generate secure filename
173
212
  const filename = await this.generateFilename(file)
174
- const filepath = path.join(this.directory, filename)
175
-
176
- // Ensure we're not writing outside our directory (defense in depth)
177
- const resolvedPath = path.resolve(filepath)
178
- const resolvedDir = path.resolve(this.directory)
179
- if (!resolvedPath.startsWith(resolvedDir)) {
180
- throw new Error('Invalid file path')
181
- }
213
+ const filepath = this.resolveStoragePath(filename)
182
214
 
183
215
  // Write file
184
216
  if (file.data) {
@@ -198,14 +230,7 @@ export class LocalStorage {
198
230
  */
199
231
  async delete (url) {
200
232
  const filename = path.basename(url)
201
- const filepath = path.join(this.directory, filename)
202
-
203
- // Security check
204
- const resolvedPath = path.resolve(filepath)
205
- const resolvedDir = path.resolve(this.directory)
206
- if (!resolvedPath.startsWith(resolvedDir)) {
207
- throw new Error('Invalid file path')
208
- }
233
+ const filepath = this.resolveStoragePath(filename)
209
234
 
210
235
  try {
211
236
  await fs.unlink(filepath)
@@ -1,18 +1,17 @@
1
1
  /**
2
- * S3 Storage Adapter
2
+ * S3 Storage Adapter (mock/demo)
3
3
  *
4
- * Stores uploaded files in Amazon S3 or S3-compatible storage.
4
+ * Generates S3-style URLs without uploading files to Amazon S3.
5
5
  *
6
6
  * Usage:
7
7
  * ```javascript
8
- * import { S3Storage } from 'jsonrestapi/lib/storage/s3-storage.js';
8
+ * import { S3Storage } from 'json-rest-api';
9
9
  *
10
10
  * const storage = new S3Storage({
11
11
  * bucket: 'my-uploads',
12
12
  * region: 'us-east-1',
13
- * accessKeyId: process.env.AWS_ACCESS_KEY_ID,
14
- * secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
15
- * prefix: 'uploads/' // optional path prefix
13
+ * prefix: 'uploads/',
14
+ * mockMode: true
16
15
  * });
17
16
  *
18
17
  * const schema = {
@@ -24,6 +23,12 @@
24
23
  import crypto from 'crypto'
25
24
  import path from 'path'
26
25
 
26
+ const REAL_S3_NOT_IMPLEMENTED_MESSAGE = 'S3Storage real S3 mode is not implemented. The included adapter only supports mockMode: true; provide a production storage adapter or add an AWS SDK-backed implementation.'
27
+
28
+ function createRealS3NotImplementedError () {
29
+ return new Error(REAL_S3_NOT_IMPLEMENTED_MESSAGE)
30
+ }
31
+
27
32
  export class S3Storage {
28
33
  constructor (options = {}) {
29
34
  this.bucket = options.bucket
@@ -35,19 +40,15 @@ export class S3Storage {
35
40
  throw new Error('S3Storage requires a bucket name')
36
41
  }
37
42
 
38
- // Note: In a real implementation, you would initialize the AWS SDK here
39
- // For this example, we'll just mock the behavior
40
43
  this.mockMode = options.mockMode !== false // Default to mock mode
41
44
 
42
45
  if (!this.mockMode) {
43
- // Real S3 initialization would go here
44
- // const { S3Client } = require('@aws-sdk/client-s3');
45
- // this.s3 = new S3Client({ region: this.region });
46
+ throw createRealS3NotImplementedError()
46
47
  }
47
48
  }
48
49
 
49
50
  /**
50
- * Upload a file to S3
51
+ * Upload a file to mock S3 storage.
51
52
  * @param {Object} file - File object from detector
52
53
  * @returns {Promise<string>} URL of the uploaded file
53
54
  */
@@ -67,58 +68,26 @@ export class S3Storage {
67
68
  return url
68
69
  }
69
70
 
70
- // Real S3 upload would go here
71
- /*
72
- const { PutObjectCommand } = require('@aws-sdk/client-s3');
73
-
74
- const command = new PutObjectCommand({
75
- Bucket: this.bucket,
76
- Key: key,
77
- Body: file.data,
78
- ContentType: file.mimetype,
79
- ACL: this.acl,
80
- Metadata: {
81
- originalName: file.filename
82
- }
83
- });
84
-
85
- await this.s3.send(command);
86
-
87
- // Return public URL
88
- if (this.acl === 'public-read') {
89
- return `https://${this.bucket}.s3.${this.region}.amazonaws.com/${key}`;
90
- } else {
91
- // For private files, you might return a signed URL
92
- return await this.getSignedUrl(key);
93
- }
94
- */
71
+ throw createRealS3NotImplementedError()
95
72
  }
96
73
 
97
74
  /**
98
- * Delete a file from S3
75
+ * Delete a file from mock S3 storage.
99
76
  * @param {string} url - URL returned by upload()
100
77
  * @returns {Promise<void>}
101
78
  */
102
79
  async delete (url) {
103
- // Extract key from URL
104
- const urlObj = new URL(url)
105
- const key = urlObj.pathname.substring(1) // Remove leading /
106
-
107
- if (this.mockMode) {
108
- // Mock mode - just simulate deletion
109
- await new Promise(resolve => setTimeout(resolve, 50))
80
+ if (!this.mockMode) {
81
+ throw createRealS3NotImplementedError()
110
82
  }
111
83
 
112
- // Real S3 delete would go here
113
- /*
114
- const { DeleteObjectCommand } = require('@aws-sdk/client-s3');
84
+ const urlObj = new URL(url)
115
85
 
116
- const command = new DeleteObjectCommand({
117
- Bucket: this.bucket,
118
- Key: key
119
- });
86
+ if (urlObj.pathname.length === 0) {
87
+ throw new Error('Invalid S3 object URL')
88
+ }
120
89
 
121
- await this.s3.send(command);
122
- */
90
+ // Mock mode - just simulate deletion
91
+ await new Promise(resolve => setTimeout(resolve, 50))
123
92
  }
124
93
  }
package/quickTest.js CHANGED
@@ -70,6 +70,7 @@ const harperResult = await api.resources.publishers.post({
70
70
  name: 'HarperCollins'
71
71
  }
72
72
  })
73
+ console.log('Created second publisher:', inspect(harperResult))
73
74
 
74
75
  // Method 3: Full JSON:API mode (standards compliant)
75
76
  const oxfordResult = await api.resources.publishers.post({