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.
- package/README.md +24 -2
- package/docs/API.md +157 -33
- package/docs/GUIDE/GUIDE_1_Initial_Setup.md +88 -6
- package/docs/GUIDE/GUIDE_2_1_The_Starting_Point.md +3 -3
- package/docs/GUIDE/GUIDE_3_Field_Transformations.md +2 -0
- package/docs/GUIDE/GUIDE_7_Hooks_Data_Management_And_Plugins.md +1 -1
- package/docs/GUIDE/GUIDE_X_Autofiltering.md +63 -0
- package/docs/GUIDE/GUIDE_X_File_Uploads.md +17 -12
- package/docs/GUIDE/GUIDE_X_Knex_Schema_And_Migrations.md +122 -2
- package/docs/GUIDE/GUIDE_X_Row_Policies.md +603 -0
- package/docs/GUIDE/GUIDE_X_SocketIO.md +15 -10
- package/docs/GUIDE/GUIDE_X_URL_Management.md +93 -254
- package/docs/GUIDE/GUIDE_X_Writing_Plugins.md +2 -0
- package/docs/GUIDE/index.md +4 -1
- package/fixing_plan.md +112 -0
- package/index.js +2 -0
- package/lib/enhanced-logger.js +1 -1
- package/package.json +6 -2
- package/plugins/core/bulk-operations-plugin.js +31 -38
- package/plugins/core/connectors/express-plugin.js +6 -7
- package/plugins/core/connectors/fastify-plugin.js +5 -1
- package/plugins/core/connectors/lib/busboy-detector.js +4 -4
- package/plugins/core/connectors/lib/connector-core.js +17 -11
- package/plugins/core/connectors/lib/transport-route-schemas.js +12 -60
- package/plugins/core/file-handling-plugin.js +126 -96
- package/plugins/core/lib/anyapi/anyapi-registry.js +21 -3
- package/plugins/core/lib/anyapi/schema-utils.js +44 -4
- package/plugins/core/lib/anyapi/utils/descriptor-helpers.js +11 -2
- package/plugins/core/lib/dbIntrospection.js +1 -1
- package/plugins/core/lib/dbTablesOperations.js +10 -3
- package/plugins/core/lib/querying/knex-cross-table-search.js +0 -7
- package/plugins/core/lib/querying/knex-json-api-transformers-querying.js +20 -16
- package/plugins/core/lib/querying/knex-pagination-helpers.js +38 -84
- package/plugins/core/lib/querying/knex-query-helpers.js +5 -6
- package/plugins/core/lib/querying/knex-relationship-includes.js +40 -46
- package/plugins/core/lib/querying/knex-window-queries.js +10 -77
- package/plugins/core/lib/querying/url-helpers.js +9 -21
- package/plugins/core/lib/querying-writing/compile-schemas.js +10 -6
- package/plugins/core/lib/querying-writing/connectors-query-parser.js +51 -0
- package/plugins/core/lib/querying-writing/database-capabilities.js +1 -1
- package/plugins/core/lib/querying-writing/database-value-normalizers.js +18 -9
- package/plugins/core/lib/querying-writing/knex-field-helpers.js +7 -3
- package/plugins/core/lib/querying-writing/relationship-contracts.js +56 -0
- package/plugins/core/lib/querying-writing/request-contracts.js +53 -4
- package/plugins/core/lib/querying-writing/resource-id-normalization.js +131 -0
- package/plugins/core/lib/querying-writing/return-record-settings.js +47 -0
- package/plugins/core/lib/querying-writing/schema-helpers.js +34 -43
- package/plugins/core/lib/querying-writing/simplified-helpers.js +4 -11
- package/plugins/core/lib/storage/canonical-storage-mapping.js +24 -3
- package/plugins/core/lib/storage/storage-adapter.js +76 -14
- package/plugins/core/lib/storage/storage-mapping.js +86 -16
- package/plugins/core/lib/writing/many-to-many-manipulations.js +8 -5
- package/plugins/core/lib/writing/relationship-processor.js +10 -1
- package/plugins/core/rest-api-anyapi-knex-plugin.js +402 -197
- package/plugins/core/rest-api-knex-plugin.js +93 -76
- package/plugins/core/rest-api-plugin-hooks/turn-scope-init-into-vars.js +9 -2
- package/plugins/core/rest-api-plugin-methods/common.js +112 -72
- package/plugins/core/rest-api-plugin-methods/delete-relationship.js +34 -15
- package/plugins/core/rest-api-plugin-methods/delete.js +8 -7
- package/plugins/core/rest-api-plugin-methods/get-related.js +22 -15
- package/plugins/core/rest-api-plugin-methods/get-relationship.js +8 -3
- package/plugins/core/rest-api-plugin-methods/get.js +7 -1
- package/plugins/core/rest-api-plugin-methods/patch-relationship.js +26 -8
- package/plugins/core/rest-api-plugin-methods/patch.js +34 -9
- package/plugins/core/rest-api-plugin-methods/post-relationship.js +35 -16
- package/plugins/core/rest-api-plugin-methods/post.js +12 -7
- package/plugins/core/rest-api-plugin-methods/put.js +35 -10
- package/plugins/core/rest-api-plugin-methods/release.js +0 -2
- package/plugins/core/rest-api-plugin.js +11 -37
- package/plugins/core/rest-api-positioning-plugin.js +1 -4
- package/plugins/core/rest-api-row-policy-plugin.js +157 -0
- package/plugins/core/socketio-plugin.js +0 -1
- package/plugins/storage/local-storage.js +45 -20
- package/plugins/storage/s3-storage.js +23 -54
- package/quickTest.js +1 -0
- package/scripts/run-docs.js +80 -84
- package/test-debug.js +1 -1
- package/tests/README.md +8 -1
- package/tests/anyapi-basic.test.js +3 -0
- package/tests/anyapi-cursor-pagination.test.js +7 -1
- package/tests/anyapi-custom-idproperty.test.js +163 -0
- package/tests/custom-id-mysql-returning-fallback.test.js +128 -0
- package/tests/db-introspection.test.js +4 -1
- package/tests/db-table-operations.test.js +25 -20
- package/tests/fastify-plugin.test.js +49 -6
- package/tests/field-setters.test.js +10 -10
- package/tests/file-handling.test.js +118 -0
- package/tests/fixtures/api-configs.js +495 -4
- package/tests/full-jsonapi-belongsto-linkage.test.js +150 -0
- package/tests/http-connectors-parity.test.js +165 -5
- package/tests/id-normalization.test.js +444 -0
- package/tests/local-storage.test.js +96 -0
- package/tests/logical-id-write-validation.test.js +151 -0
- package/tests/pagination-cursor-multifield.test.js +132 -11
- package/tests/pagination-enhanced.test.js +66 -15
- package/tests/post-commit-hooks.test.js +120 -0
- package/tests/public-exports.test.js +15 -0
- package/tests/put-create-parity.test.js +216 -0
- package/tests/relationship-endpoints.test.js +259 -0
- package/tests/return-record-settings.test.js +184 -0
- package/tests/row-policy.test.js +433 -0
- package/tests/s3-storage.test.js +38 -0
- package/tests/searchschema-merge.test.js +17 -0
- package/tests/socketio.test.js +62 -0
- package/tests/storage-mapping.test.js +56 -6
- 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
|
-
|
|
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 '${
|
|
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.
|
|
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
|
-
|
|
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) {
|
|
@@ -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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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 [
|
|
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(/[
|
|
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 =
|
|
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(
|
|
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 =
|
|
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 =
|
|
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
|
-
*
|
|
4
|
+
* Generates S3-style URLs without uploading files to Amazon S3.
|
|
5
5
|
*
|
|
6
6
|
* Usage:
|
|
7
7
|
* ```javascript
|
|
8
|
-
* import { S3Storage } from '
|
|
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
|
-
*
|
|
14
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
104
|
-
|
|
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
|
-
|
|
113
|
-
/*
|
|
114
|
-
const { DeleteObjectCommand } = require('@aws-sdk/client-s3');
|
|
84
|
+
const urlObj = new URL(url)
|
|
115
85
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
});
|
|
86
|
+
if (urlObj.pathname.length === 0) {
|
|
87
|
+
throw new Error('Invalid S3 object URL')
|
|
88
|
+
}
|
|
120
89
|
|
|
121
|
-
|
|
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({
|