@yunsoft/yuncms-core 0.1.3 → 0.1.6
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/package.json +1 -1
- package/src/auth/external-state.js +76 -0
- package/src/bootstrap.js +6 -0
- package/src/cache.js +85 -0
- package/src/config.js +115 -20
- package/src/context.js +5 -2
- package/src/hooks.js +117 -32
- package/src/index.js +32 -7
- package/src/mail/smtp-mailer.js +58 -14
- package/src/maintenance-state.js +89 -0
- package/src/migrations/0011-role-permission-actions.js +15 -0
- package/src/migrations/0012-files-read-filters.js +14 -0
- package/src/migrations/0013-external-auth-foundation.js +35 -0
- package/src/migrations.js +113 -3
- package/src/query.js +156 -54
- package/src/redis.js +300 -0
- package/src/relation-expansion.js +564 -142
- package/src/services/auth-service.js +40 -2
- package/src/services/core-services.js +2 -0
- package/src/services/external-auth-service.js +323 -0
- package/src/services/files-service.js +85 -7
- package/src/services/items-service.js +121 -81
- package/src/services/permissions-service.js +35 -15
- package/src/services/roles-service.js +44 -23
- package/src/services/users-service.js +27 -3
- package/src/system-permissions.js +50 -7
package/src/query.js
CHANGED
|
@@ -1,24 +1,46 @@
|
|
|
1
1
|
import { quoteIdentifier } from './identifier.js';
|
|
2
2
|
|
|
3
|
-
const QUERY_KEYS = new Set(['fields', 'filter', 'sort', 'limit', 'offset']);
|
|
3
|
+
const QUERY_KEYS = new Set(['fields', 'filter', 'sort', 'limit', 'offset', 'search', 'aggregate', 'groupBy']);
|
|
4
4
|
const FILTER_OPERATORS = new Set([
|
|
5
5
|
'_eq', '_neq', '_lt', '_lte', '_gt', '_gte',
|
|
6
6
|
'_in', '_nin', '_null', '_nnull',
|
|
7
7
|
'_contains', '_starts_with', '_ends_with',
|
|
8
8
|
]);
|
|
9
|
+
const AGGREGATE_FUNCTIONS = new Set(['count', 'countDistinct', 'sum', 'avg', 'min', 'max']);
|
|
10
|
+
const SEARCHABLE_TYPES = new Set(['string', 'text']);
|
|
9
11
|
|
|
10
|
-
|
|
12
|
+
export const QUERY_LIMITS = Object.freeze({
|
|
13
|
+
defaultLimit: 100,
|
|
14
|
+
maxLimit: 500,
|
|
15
|
+
maxFields: 100,
|
|
16
|
+
maxRelationExpansions: 20,
|
|
17
|
+
maxRelationDepth: 4,
|
|
18
|
+
maxSortFields: 20,
|
|
19
|
+
maxOffset: 1_000_000,
|
|
20
|
+
maxFilterDepth: 8,
|
|
21
|
+
maxFilterNodes: 100,
|
|
22
|
+
maxInValues: 100,
|
|
23
|
+
maxSearchLength: 200,
|
|
24
|
+
maxAggregateFields: 20,
|
|
25
|
+
maxGroupByFields: 10,
|
|
26
|
+
maxCost: 2_000,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
function queryError(message, path = null, code = 'INVALID_QUERY') {
|
|
11
30
|
const error = new Error(message);
|
|
12
|
-
error.code =
|
|
31
|
+
error.code = code;
|
|
13
32
|
if (path) error.path = path;
|
|
14
33
|
return error;
|
|
15
34
|
}
|
|
16
35
|
|
|
17
|
-
function normalizeDelimited(value, label) {
|
|
36
|
+
function normalizeDelimited(value, label, { maxItems }) {
|
|
18
37
|
if (value == null || value === '') return null;
|
|
19
38
|
const values = Array.isArray(value) ? value : String(value).split(',');
|
|
20
39
|
const normalized = values.map((item) => String(item).trim()).filter(Boolean);
|
|
21
40
|
if (normalized.length === 0) throw queryError(`${label} cannot be empty`, label);
|
|
41
|
+
if (normalized.length > maxItems) {
|
|
42
|
+
throw queryError(`${label} cannot contain more than ${maxItems} entries`, label);
|
|
43
|
+
}
|
|
22
44
|
return normalized;
|
|
23
45
|
}
|
|
24
46
|
|
|
@@ -36,36 +58,92 @@ function normalizeFilter(value) {
|
|
|
36
58
|
if (typeof value === 'string') {
|
|
37
59
|
try {
|
|
38
60
|
const parsed = JSON.parse(value);
|
|
39
|
-
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
40
|
-
throw new Error('not an object');
|
|
41
|
-
}
|
|
61
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('not an object');
|
|
42
62
|
return parsed;
|
|
43
63
|
} catch {
|
|
44
64
|
throw queryError('filter must be a valid JSON object', 'filter');
|
|
45
65
|
}
|
|
46
66
|
}
|
|
47
|
-
if (typeof value !== 'object' || Array.isArray(value))
|
|
48
|
-
throw queryError('filter must be an object', 'filter');
|
|
49
|
-
}
|
|
67
|
+
if (typeof value !== 'object' || Array.isArray(value)) throw queryError('filter must be an object', 'filter');
|
|
50
68
|
return value;
|
|
51
69
|
}
|
|
52
70
|
|
|
53
|
-
|
|
54
|
-
if (
|
|
55
|
-
|
|
71
|
+
function normalizeSearch(value, limits) {
|
|
72
|
+
if (value == null || value === '') return null;
|
|
73
|
+
if (Array.isArray(value) || typeof value === 'object') throw queryError('search must be a string', 'search');
|
|
74
|
+
const search = String(value).trim();
|
|
75
|
+
if (!search) return null;
|
|
76
|
+
if (search.length > limits.maxSearchLength) {
|
|
77
|
+
throw queryError(`search cannot exceed ${limits.maxSearchLength} characters`, 'search');
|
|
56
78
|
}
|
|
79
|
+
return search;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function normalizeAggregate(value, limits) {
|
|
83
|
+
if (value == null || value === '') return null;
|
|
84
|
+
let aggregate = value;
|
|
85
|
+
if (typeof aggregate === 'string') {
|
|
86
|
+
try { aggregate = JSON.parse(aggregate); } catch { throw queryError('aggregate must be a JSON object', 'aggregate'); }
|
|
87
|
+
}
|
|
88
|
+
if (!aggregate || typeof aggregate !== 'object' || Array.isArray(aggregate)) {
|
|
89
|
+
throw queryError('aggregate must be an object', 'aggregate');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const normalized = {};
|
|
93
|
+
let count = 0;
|
|
94
|
+
for (const [fn, rawFields] of Object.entries(aggregate)) {
|
|
95
|
+
if (!AGGREGATE_FUNCTIONS.has(fn)) throw queryError(`Unknown aggregate function: ${fn}`, `aggregate.${fn}`);
|
|
96
|
+
const fields = normalizeDelimited(rawFields, `aggregate.${fn}`, { maxItems: limits.maxAggregateFields });
|
|
97
|
+
if (!fields) throw queryError(`aggregate.${fn} cannot be empty`, `aggregate.${fn}`);
|
|
98
|
+
count += fields.length;
|
|
99
|
+
if (count > limits.maxAggregateFields) {
|
|
100
|
+
throw queryError(`aggregate cannot contain more than ${limits.maxAggregateFields} fields`, 'aggregate');
|
|
101
|
+
}
|
|
102
|
+
normalized[fn] = fields;
|
|
103
|
+
}
|
|
104
|
+
if (Object.keys(normalized).length === 0) throw queryError('aggregate cannot be empty', 'aggregate');
|
|
105
|
+
return normalized;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function parseItemsQuery(raw = {}, options = {}) {
|
|
109
|
+
const limits = { ...QUERY_LIMITS, ...options };
|
|
110
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw queryError('Query must be an object');
|
|
57
111
|
|
|
58
112
|
for (const key of Object.keys(raw)) {
|
|
59
113
|
if (!QUERY_KEYS.has(key)) throw queryError(`Unknown query parameter: ${key}`, key);
|
|
60
114
|
}
|
|
61
115
|
|
|
62
|
-
|
|
63
|
-
fields: normalizeDelimited(raw.fields, 'fields'),
|
|
116
|
+
const query = {
|
|
117
|
+
fields: normalizeDelimited(raw.fields, 'fields', { maxItems: limits.maxFields }),
|
|
64
118
|
filter: normalizeFilter(raw.filter),
|
|
65
|
-
sort: normalizeDelimited(raw.sort, 'sort'),
|
|
66
|
-
limit: normalizeInteger(raw.limit, defaultLimit, { label: 'limit', min: 1, max: maxLimit }),
|
|
67
|
-
offset: normalizeInteger(raw.offset, 0, { label: 'offset', min: 0, max:
|
|
119
|
+
sort: normalizeDelimited(raw.sort, 'sort', { maxItems: limits.maxSortFields }),
|
|
120
|
+
limit: normalizeInteger(raw.limit, limits.defaultLimit, { label: 'limit', min: 1, max: limits.maxLimit }),
|
|
121
|
+
offset: normalizeInteger(raw.offset, 0, { label: 'offset', min: 0, max: limits.maxOffset }),
|
|
122
|
+
search: normalizeSearch(raw.search, limits),
|
|
123
|
+
aggregate: normalizeAggregate(raw.aggregate, limits),
|
|
124
|
+
groupBy: normalizeDelimited(raw.groupBy, 'groupBy', { maxItems: limits.maxGroupByFields }),
|
|
68
125
|
};
|
|
126
|
+
|
|
127
|
+
if (query.groupBy && !query.aggregate) throw queryError('groupBy requires aggregate', 'groupBy');
|
|
128
|
+
return Object.freeze(query);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function queryCost(query, { relationDepth = 0, relationCount = 0 } = {}) {
|
|
132
|
+
let cost = 1 + Number(query?.limit ?? QUERY_LIMITS.defaultLimit);
|
|
133
|
+
cost += (query?.fields?.length ?? 0) * 2;
|
|
134
|
+
cost += (query?.sort?.length ?? 0) * 5;
|
|
135
|
+
cost += relationCount * 50;
|
|
136
|
+
cost += relationDepth * 100;
|
|
137
|
+
if (query?.search) cost += 100;
|
|
138
|
+
if (query?.aggregate) cost += 250 + Object.values(query.aggregate).flat().length * 25;
|
|
139
|
+
return cost;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function assertQueryCost(query, options = {}) {
|
|
143
|
+
const maxCost = options.maxCost ?? QUERY_LIMITS.maxCost;
|
|
144
|
+
const cost = queryCost(query, options);
|
|
145
|
+
if (cost > maxCost) throw queryError(`Query cost ${cost} exceeds limit ${maxCost}`, null, 'QUERY_COST_LIMIT');
|
|
146
|
+
return cost;
|
|
69
147
|
}
|
|
70
148
|
|
|
71
149
|
function resolveField(schema, field, path = field) {
|
|
@@ -76,7 +154,6 @@ function resolveField(schema, field, path = field) {
|
|
|
76
154
|
export function compileSelectFields(fields, schema) {
|
|
77
155
|
const selected = !fields || fields.includes('*') ? Object.keys(schema.fields) : fields;
|
|
78
156
|
if (selected.length === 0) throw queryError('At least one field must be selected', 'fields');
|
|
79
|
-
|
|
80
157
|
const unique = [...new Set(selected)];
|
|
81
158
|
return {
|
|
82
159
|
fields: unique,
|
|
@@ -89,7 +166,6 @@ export function compileSelectFields(fields, schema) {
|
|
|
89
166
|
|
|
90
167
|
export function compileSort(sort, schema) {
|
|
91
168
|
if (!sort) return '';
|
|
92
|
-
|
|
93
169
|
const parts = sort.map((entry, index) => {
|
|
94
170
|
const descending = entry.startsWith('-');
|
|
95
171
|
const field = descending ? entry.slice(1) : entry;
|
|
@@ -97,7 +173,6 @@ export function compileSort(sort, schema) {
|
|
|
97
173
|
resolveField(schema, field, `sort.${index}`);
|
|
98
174
|
return `${quoteIdentifier(field, 'field name')} ${descending ? 'DESC' : 'ASC'}`;
|
|
99
175
|
});
|
|
100
|
-
|
|
101
176
|
return parts.length ? ` ORDER BY ${parts.join(', ')}` : '';
|
|
102
177
|
}
|
|
103
178
|
|
|
@@ -105,9 +180,49 @@ function escapeLike(value) {
|
|
|
105
180
|
return String(value).replace(/[\\%_]/g, '\\$&');
|
|
106
181
|
}
|
|
107
182
|
|
|
108
|
-
function
|
|
109
|
-
if (!
|
|
183
|
+
export function compileSearch(search, schema) {
|
|
184
|
+
if (!search) return { sql: '', params: [] };
|
|
185
|
+
const fields = Object.entries(schema?.fields ?? {})
|
|
186
|
+
.filter(([, field]) => SEARCHABLE_TYPES.has(field.type))
|
|
187
|
+
.map(([name]) => name);
|
|
188
|
+
if (fields.length === 0) return { sql: ' WHERE 0 = 1', params: [] };
|
|
189
|
+
const needle = `%${escapeLike(search)}%`;
|
|
190
|
+
return {
|
|
191
|
+
sql: ` WHERE (${fields.map((field) => `${quoteIdentifier(field, 'field name')} LIKE ? ESCAPE '\\\\'`).join(' OR ')})`,
|
|
192
|
+
params: fields.map(() => needle),
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function compileAggregate(aggregate, groupBy, schema) {
|
|
197
|
+
if (!aggregate) return null;
|
|
198
|
+
const groups = groupBy ?? [];
|
|
199
|
+
for (const field of groups) resolveField(schema, field, `groupBy.${field}`);
|
|
200
|
+
const selections = groups.map((field) => quoteIdentifier(field, 'field name'));
|
|
201
|
+
const aliases = new Set(groups);
|
|
110
202
|
|
|
203
|
+
for (const [fn, fields] of Object.entries(aggregate)) {
|
|
204
|
+
for (const field of fields) {
|
|
205
|
+
if (field === '*' && fn !== 'count') throw queryError(`${fn} does not support *`, `aggregate.${fn}`);
|
|
206
|
+
if (field !== '*') resolveField(schema, field, `aggregate.${fn}.${field}`);
|
|
207
|
+
const column = field === '*' ? '*' : quoteIdentifier(field, 'field name');
|
|
208
|
+
const sqlFn = fn === 'countDistinct' ? 'COUNT' : fn.toUpperCase();
|
|
209
|
+
const expression = fn === 'countDistinct' ? `${sqlFn}(DISTINCT ${column})` : `${sqlFn}(${column})`;
|
|
210
|
+
const rawAlias = field === '*' ? fn : `${fn}_${field}`;
|
|
211
|
+
let alias = rawAlias;
|
|
212
|
+
let suffix = 2;
|
|
213
|
+
while (aliases.has(alias)) alias = `${rawAlias}_${suffix++}`;
|
|
214
|
+
aliases.add(alias);
|
|
215
|
+
selections.push(`${expression} AS ${quoteIdentifier(alias, 'aggregate alias')}`);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return {
|
|
219
|
+
sql: selections.join(', '),
|
|
220
|
+
groupSql: groups.length ? ` GROUP BY ${groups.map((field) => quoteIdentifier(field, 'field name')).join(', ')}` : '',
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function compileOperator(fieldSql, operator, value, path, limits) {
|
|
225
|
+
if (!FILTER_OPERATORS.has(operator)) throw queryError(`Unknown filter operator: ${operator}`, path);
|
|
111
226
|
switch (operator) {
|
|
112
227
|
case '_eq':
|
|
113
228
|
if (value === null) throw queryError('Use _null for NULL comparisons', path);
|
|
@@ -122,12 +237,10 @@ function compileOperator(fieldSql, operator, value, path) {
|
|
|
122
237
|
case '_in':
|
|
123
238
|
case '_nin': {
|
|
124
239
|
if (!Array.isArray(value)) throw queryError(`${operator} requires an array`, path);
|
|
240
|
+
if (value.length > limits.maxInValues) throw queryError(`${operator} accepts at most ${limits.maxInValues} values`, path);
|
|
125
241
|
if (value.length === 0) return { sql: operator === '_in' ? '0 = 1' : '1 = 1', params: [] };
|
|
126
242
|
const placeholders = value.map(() => '?').join(', ');
|
|
127
|
-
return {
|
|
128
|
-
sql: `${fieldSql} ${operator === '_in' ? 'IN' : 'NOT IN'} (${placeholders})`,
|
|
129
|
-
params: value,
|
|
130
|
-
};
|
|
243
|
+
return { sql: `${fieldSql} ${operator === '_in' ? 'IN' : 'NOT IN'} (${placeholders})`, params: value };
|
|
131
244
|
}
|
|
132
245
|
case '_null':
|
|
133
246
|
case '_nnull': {
|
|
@@ -135,59 +248,48 @@ function compileOperator(fieldSql, operator, value, path) {
|
|
|
135
248
|
const wantsNull = operator === '_null' ? value : !value;
|
|
136
249
|
return { sql: `${fieldSql} IS ${wantsNull ? '' : 'NOT '}NULL`, params: [] };
|
|
137
250
|
}
|
|
138
|
-
case '_contains':
|
|
139
|
-
|
|
140
|
-
case '
|
|
141
|
-
|
|
142
|
-
case '_ends_with':
|
|
143
|
-
return { sql: `${fieldSql} LIKE ? ESCAPE '\\\\'`, params: [`%${escapeLike(value)}`] };
|
|
144
|
-
default:
|
|
145
|
-
throw queryError(`Unknown filter operator: ${operator}`, path);
|
|
251
|
+
case '_contains': return { sql: `${fieldSql} LIKE ? ESCAPE '\\\\'`, params: [`%${escapeLike(value)}%`] };
|
|
252
|
+
case '_starts_with': return { sql: `${fieldSql} LIKE ? ESCAPE '\\\\'`, params: [`${escapeLike(value)}%`] };
|
|
253
|
+
case '_ends_with': return { sql: `${fieldSql} LIKE ? ESCAPE '\\\\'`, params: [`%${escapeLike(value)}`] };
|
|
254
|
+
default: throw queryError(`Unknown filter operator: ${operator}`, path);
|
|
146
255
|
}
|
|
147
256
|
}
|
|
148
257
|
|
|
149
|
-
function compileFilterObject(filter, schema, path
|
|
150
|
-
if (!filter || typeof filter !== 'object' || Array.isArray(filter))
|
|
151
|
-
|
|
152
|
-
|
|
258
|
+
function compileFilterObject(filter, schema, path, limits, state, depth) {
|
|
259
|
+
if (!filter || typeof filter !== 'object' || Array.isArray(filter)) throw queryError('Filter node must be an object', path);
|
|
260
|
+
if (depth > limits.maxFilterDepth) throw queryError(`Filter depth cannot exceed ${limits.maxFilterDepth}`, path);
|
|
261
|
+
state.nodes += 1;
|
|
262
|
+
if (state.nodes > limits.maxFilterNodes) throw queryError(`Filter cannot contain more than ${limits.maxFilterNodes} nodes`, path);
|
|
153
263
|
|
|
154
264
|
const fragments = [];
|
|
155
265
|
const params = [];
|
|
156
|
-
|
|
157
266
|
for (const [key, value] of Object.entries(filter)) {
|
|
158
267
|
if (key === '_and' || key === '_or') {
|
|
159
|
-
if (!Array.isArray(value) || value.length === 0) {
|
|
160
|
-
|
|
161
|
-
}
|
|
162
|
-
const children = value.map((child, index) =>
|
|
163
|
-
compileFilterObject(child, schema, `${path}.${key}.${index}`));
|
|
268
|
+
if (!Array.isArray(value) || value.length === 0) throw queryError(`${key} requires a non-empty array`, `${path}.${key}`);
|
|
269
|
+
const children = value.map((child, index) => compileFilterObject(child, schema, `${path}.${key}.${index}`, limits, state, depth + 1));
|
|
164
270
|
fragments.push(`(${children.map((child) => child.sql).join(key === '_and' ? ' AND ' : ' OR ')})`);
|
|
165
271
|
for (const child of children) params.push(...child.params);
|
|
166
272
|
continue;
|
|
167
273
|
}
|
|
168
|
-
|
|
169
274
|
resolveField(schema, key, `${path}.${key}`);
|
|
170
|
-
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
171
|
-
throw queryError('Field filters must be operator objects', `${path}.${key}`);
|
|
172
|
-
}
|
|
173
|
-
|
|
275
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw queryError('Field filters must be operator objects', `${path}.${key}`);
|
|
174
276
|
const fieldSql = quoteIdentifier(key, 'field name');
|
|
175
277
|
const fieldFragments = [];
|
|
176
278
|
for (const [operator, operatorValue] of Object.entries(value)) {
|
|
177
|
-
const compiled = compileOperator(fieldSql, operator, operatorValue, `${path}.${key}.${operator}
|
|
279
|
+
const compiled = compileOperator(fieldSql, operator, operatorValue, `${path}.${key}.${operator}`, limits);
|
|
178
280
|
fieldFragments.push(compiled.sql);
|
|
179
281
|
params.push(...compiled.params);
|
|
180
282
|
}
|
|
181
283
|
if (fieldFragments.length === 0) throw queryError('Field filter cannot be empty', `${path}.${key}`);
|
|
182
284
|
fragments.push(`(${fieldFragments.join(' AND ')})`);
|
|
183
285
|
}
|
|
184
|
-
|
|
185
286
|
if (fragments.length === 0) throw queryError('Filter cannot be empty', path);
|
|
186
287
|
return { sql: fragments.join(' AND '), params };
|
|
187
288
|
}
|
|
188
289
|
|
|
189
|
-
export function compileFilter(filter, schema) {
|
|
290
|
+
export function compileFilter(filter, schema, options = {}) {
|
|
190
291
|
if (!filter) return { sql: '', params: [] };
|
|
191
|
-
const
|
|
292
|
+
const limits = { ...QUERY_LIMITS, ...options };
|
|
293
|
+
const compiled = compileFilterObject(filter, schema, 'filter', limits, { nodes: 0 }, 1);
|
|
192
294
|
return { sql: ` WHERE ${compiled.sql}`, params: compiled.params };
|
|
193
295
|
}
|
package/src/redis.js
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import net from 'node:net';
|
|
3
|
+
import tls from 'node:tls';
|
|
4
|
+
|
|
5
|
+
function redisError(code, message, cause = null) {
|
|
6
|
+
const error = new Error(message, cause ? { cause } : undefined);
|
|
7
|
+
error.code = code;
|
|
8
|
+
return error;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function redactRedisUrl(value) {
|
|
12
|
+
if (!value) return null;
|
|
13
|
+
try {
|
|
14
|
+
const url = new URL(value);
|
|
15
|
+
if (url.username) url.username = '***';
|
|
16
|
+
if (url.password) url.password = '***';
|
|
17
|
+
return url.toString();
|
|
18
|
+
} catch {
|
|
19
|
+
return '<invalid-redis-url>';
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function parseRedisUrl(value) {
|
|
24
|
+
if (!value) throw redisError('INVALID_REDIS_CONFIG', 'REDIS_URL is required for Redis shared state');
|
|
25
|
+
let url;
|
|
26
|
+
try { url = new URL(value); } catch { throw redisError('INVALID_REDIS_CONFIG', 'REDIS_URL must be a valid URL'); }
|
|
27
|
+
if (!['redis:', 'rediss:'].includes(url.protocol)) {
|
|
28
|
+
throw redisError('INVALID_REDIS_CONFIG', 'REDIS_URL must use redis:// or rediss://');
|
|
29
|
+
}
|
|
30
|
+
const database = url.pathname && url.pathname !== '/' ? Number(url.pathname.slice(1)) : 0;
|
|
31
|
+
if (!Number.isInteger(database) || database < 0 || database > 15) {
|
|
32
|
+
throw redisError('INVALID_REDIS_CONFIG', 'REDIS_URL database must be an integer between 0 and 15');
|
|
33
|
+
}
|
|
34
|
+
return Object.freeze({
|
|
35
|
+
tls: url.protocol === 'rediss:',
|
|
36
|
+
host: url.hostname,
|
|
37
|
+
port: Number(url.port || 6379),
|
|
38
|
+
username: decodeURIComponent(url.username || ''),
|
|
39
|
+
password: decodeURIComponent(url.password || ''),
|
|
40
|
+
database,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function encodeCommand(args) {
|
|
45
|
+
const parts = [`*${args.length}\r\n`];
|
|
46
|
+
for (const arg of args) {
|
|
47
|
+
const value = Buffer.from(String(arg));
|
|
48
|
+
parts.push(`$${value.length}\r\n`, value, '\r\n');
|
|
49
|
+
}
|
|
50
|
+
return Buffer.concat(parts.map((part) => Buffer.isBuffer(part) ? part : Buffer.from(part)));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function readLine(buffer, offset) {
|
|
54
|
+
const end = buffer.indexOf('\r\n', offset);
|
|
55
|
+
if (end === -1) return null;
|
|
56
|
+
return { value: buffer.toString('utf8', offset, end), next: end + 2 };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function parseReply(buffer, offset = 0) {
|
|
60
|
+
if (offset >= buffer.length) return null;
|
|
61
|
+
const type = String.fromCharCode(buffer[offset]);
|
|
62
|
+
const line = readLine(buffer, offset + 1);
|
|
63
|
+
if (!line) return null;
|
|
64
|
+
|
|
65
|
+
if (type === '+' || type === '-' || type === ':') {
|
|
66
|
+
const value = type === ':' ? Number(line.value) : line.value;
|
|
67
|
+
return { value, error: type === '-', next: line.next };
|
|
68
|
+
}
|
|
69
|
+
if (type === '$') {
|
|
70
|
+
const length = Number(line.value);
|
|
71
|
+
if (length === -1) return { value: null, next: line.next };
|
|
72
|
+
const end = line.next + length;
|
|
73
|
+
if (buffer.length < end + 2) return null;
|
|
74
|
+
return { value: buffer.toString('utf8', line.next, end), next: end + 2 };
|
|
75
|
+
}
|
|
76
|
+
if (type === '*') {
|
|
77
|
+
const count = Number(line.value);
|
|
78
|
+
if (count === -1) return { value: null, next: line.next };
|
|
79
|
+
let next = line.next;
|
|
80
|
+
const value = [];
|
|
81
|
+
for (let index = 0; index < count; index += 1) {
|
|
82
|
+
const child = parseReply(buffer, next);
|
|
83
|
+
if (!child) return null;
|
|
84
|
+
if (child.error) return child;
|
|
85
|
+
value.push(child.value);
|
|
86
|
+
next = child.next;
|
|
87
|
+
}
|
|
88
|
+
return { value, next };
|
|
89
|
+
}
|
|
90
|
+
throw redisError('REDIS_PROTOCOL_ERROR', `Unsupported Redis RESP type: ${type}`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export class RedisClient {
|
|
94
|
+
constructor({ url, connectTimeoutMs = 5_000, commandTimeoutMs = 3_000, logger = console } = {}) {
|
|
95
|
+
this.config = parseRedisUrl(url);
|
|
96
|
+
this.connectTimeoutMs = connectTimeoutMs;
|
|
97
|
+
this.commandTimeoutMs = commandTimeoutMs;
|
|
98
|
+
this.logger = logger;
|
|
99
|
+
this.socket = null;
|
|
100
|
+
this.buffer = Buffer.alloc(0);
|
|
101
|
+
this.pending = [];
|
|
102
|
+
this.connecting = null;
|
|
103
|
+
this.closed = false;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async connect() {
|
|
107
|
+
if (this.socket && !this.socket.destroyed) return this;
|
|
108
|
+
if (this.connecting) return this.connecting;
|
|
109
|
+
if (this.closed) throw redisError('REDIS_CLOSED', 'Redis client is closed');
|
|
110
|
+
|
|
111
|
+
this.connecting = new Promise((resolve, reject) => {
|
|
112
|
+
const options = { host: this.config.host, port: this.config.port };
|
|
113
|
+
const socket = this.config.tls ? tls.connect(options) : net.createConnection(options);
|
|
114
|
+
const timer = setTimeout(() => {
|
|
115
|
+
socket.destroy(redisError('REDIS_CONNECT_TIMEOUT', 'Redis connection timed out'));
|
|
116
|
+
}, this.connectTimeoutMs);
|
|
117
|
+
|
|
118
|
+
const fail = (error) => {
|
|
119
|
+
clearTimeout(timer);
|
|
120
|
+
this.connecting = null;
|
|
121
|
+
reject(redisError('REDIS_CONNECT_FAILED', 'Redis connection failed', error));
|
|
122
|
+
};
|
|
123
|
+
socket.once('error', fail);
|
|
124
|
+
socket.once('connect', async () => {
|
|
125
|
+
clearTimeout(timer);
|
|
126
|
+
socket.off('error', fail);
|
|
127
|
+
this.socket = socket;
|
|
128
|
+
this.buffer = Buffer.alloc(0);
|
|
129
|
+
socket.on('data', (chunk) => this.#onData(chunk));
|
|
130
|
+
socket.on('error', (error) => this.#onSocketFailure(error));
|
|
131
|
+
socket.on('close', () => this.#onSocketFailure(redisError('REDIS_DISCONNECTED', 'Redis disconnected')));
|
|
132
|
+
try {
|
|
133
|
+
if (this.config.password) {
|
|
134
|
+
if (this.config.username) await this.command('AUTH', this.config.username, this.config.password);
|
|
135
|
+
else await this.command('AUTH', this.config.password);
|
|
136
|
+
}
|
|
137
|
+
if (this.config.database) await this.command('SELECT', this.config.database);
|
|
138
|
+
this.connecting = null;
|
|
139
|
+
resolve(this);
|
|
140
|
+
} catch (error) {
|
|
141
|
+
socket.destroy();
|
|
142
|
+
this.connecting = null;
|
|
143
|
+
reject(error);
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
return this.connecting;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
#onData(chunk) {
|
|
151
|
+
this.buffer = Buffer.concat([this.buffer, chunk]);
|
|
152
|
+
while (this.pending.length) {
|
|
153
|
+
let parsed;
|
|
154
|
+
try { parsed = parseReply(this.buffer); } catch (error) {
|
|
155
|
+
this.#onSocketFailure(error);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
if (!parsed) return;
|
|
159
|
+
this.buffer = this.buffer.subarray(parsed.next);
|
|
160
|
+
const pending = this.pending.shift();
|
|
161
|
+
clearTimeout(pending.timer);
|
|
162
|
+
if (parsed.error) pending.reject(redisError('REDIS_COMMAND_FAILED', `Redis command failed: ${parsed.value}`));
|
|
163
|
+
else pending.resolve(parsed.value);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
#onSocketFailure(error) {
|
|
168
|
+
const socket = this.socket;
|
|
169
|
+
this.socket = null;
|
|
170
|
+
if (socket && !socket.destroyed) socket.destroy();
|
|
171
|
+
const pending = this.pending.splice(0);
|
|
172
|
+
for (const request of pending) {
|
|
173
|
+
clearTimeout(request.timer);
|
|
174
|
+
request.reject(redisError('REDIS_UNAVAILABLE', 'Redis command interrupted', error));
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async command(...args) {
|
|
179
|
+
if (!this.socket || this.socket.destroyed) await this.connect();
|
|
180
|
+
return new Promise((resolve, reject) => {
|
|
181
|
+
const timer = setTimeout(() => {
|
|
182
|
+
const index = this.pending.findIndex((entry) => entry.resolve === resolve);
|
|
183
|
+
if (index >= 0) this.pending.splice(index, 1);
|
|
184
|
+
reject(redisError('REDIS_COMMAND_TIMEOUT', 'Redis command timed out'));
|
|
185
|
+
}, this.commandTimeoutMs);
|
|
186
|
+
this.pending.push({ resolve, reject, timer });
|
|
187
|
+
this.socket.write(encodeCommand(args));
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async ping() {
|
|
192
|
+
return (await this.command('PING')) === 'PONG';
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async close() {
|
|
196
|
+
this.closed = true;
|
|
197
|
+
const socket = this.socket;
|
|
198
|
+
this.socket = null;
|
|
199
|
+
if (!socket || socket.destroyed) return;
|
|
200
|
+
try { socket.end(encodeCommand(['QUIT'])); } catch {}
|
|
201
|
+
socket.destroy();
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function assertPrefix(prefix) {
|
|
206
|
+
const value = String(prefix || 'yuncms:default:');
|
|
207
|
+
if (value.length < 3 || value.length > 128 || /[\r\n\0]/.test(value)) {
|
|
208
|
+
throw redisError('INVALID_REDIS_CONFIG', 'Redis prefix must be between 3 and 128 safe characters');
|
|
209
|
+
}
|
|
210
|
+
return value.endsWith(':') ? value : `${value}:`;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export class RedisCacheStore {
|
|
214
|
+
constructor({ client, prefix = 'yuncms:default:', namespace = 'permission', ttlMs = 30_000, logger = console } = {}) {
|
|
215
|
+
if (!client?.command) throw redisError('INVALID_REDIS_CONFIG', 'RedisCacheStore requires a Redis command client');
|
|
216
|
+
this.client = client;
|
|
217
|
+
this.prefix = assertPrefix(prefix);
|
|
218
|
+
this.namespace = String(namespace);
|
|
219
|
+
this.ttlMs = ttlMs;
|
|
220
|
+
this.logger = logger;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
generationKey() { return `${this.prefix}${this.namespace}:generation`; }
|
|
224
|
+
|
|
225
|
+
async #generation() {
|
|
226
|
+
const value = await this.client.command('GET', this.generationKey());
|
|
227
|
+
return value == null ? '0' : String(value);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async #key(key) {
|
|
231
|
+
return `${this.prefix}${this.namespace}:${await this.#generation()}:${String(key)}`;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async get(key) {
|
|
235
|
+
try {
|
|
236
|
+
const raw = await this.client.command('GET', await this.#key(key));
|
|
237
|
+
if (raw == null) return undefined;
|
|
238
|
+
const parsed = JSON.parse(raw);
|
|
239
|
+
if (!parsed || parsed.v !== 1 || !Object.hasOwn(parsed, 'value')) return undefined;
|
|
240
|
+
return parsed.value;
|
|
241
|
+
} catch (error) {
|
|
242
|
+
this.logger?.warn?.('Redis cache read failed; falling back to source of truth', { code: error?.code });
|
|
243
|
+
return undefined;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async set(key, value, { ttlMs = this.ttlMs } = {}) {
|
|
248
|
+
try {
|
|
249
|
+
const payload = JSON.stringify({ v: 1, value });
|
|
250
|
+
await this.client.command('SET', await this.#key(key), payload, 'PX', ttlMs);
|
|
251
|
+
} catch (error) {
|
|
252
|
+
this.logger?.warn?.('Redis cache write failed; continuing without cache', { code: error?.code });
|
|
253
|
+
}
|
|
254
|
+
return value;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async delete(key) {
|
|
258
|
+
try { return Number(await this.client.command('DEL', await this.#key(key))) > 0; } catch { return false; }
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async clear() {
|
|
262
|
+
try {
|
|
263
|
+
await this.client.command('INCR', this.generationKey());
|
|
264
|
+
return true;
|
|
265
|
+
} catch (error) {
|
|
266
|
+
this.logger?.warn?.('Redis cache generation invalidation failed', { code: error?.code });
|
|
267
|
+
return false;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const RATE_LIMIT_SCRIPT = `
|
|
273
|
+
local count = redis.call('INCR', KEYS[1])
|
|
274
|
+
if count == 1 then redis.call('PEXPIRE', KEYS[1], ARGV[1]) end
|
|
275
|
+
local ttl = redis.call('PTTL', KEYS[1])
|
|
276
|
+
return {count, ttl}
|
|
277
|
+
`.trim();
|
|
278
|
+
|
|
279
|
+
export class RedisFixedWindowStore {
|
|
280
|
+
constructor({ client, prefix = 'yuncms:default:', logger = console } = {}) {
|
|
281
|
+
if (!client?.command) throw redisError('INVALID_REDIS_CONFIG', 'RedisFixedWindowStore requires a Redis command client');
|
|
282
|
+
this.client = client;
|
|
283
|
+
this.prefix = assertPrefix(prefix);
|
|
284
|
+
this.logger = logger;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async consume(identity, { windowMs, max, scope = 'api' } = {}) {
|
|
288
|
+
const digest = createHash('sha256').update(String(identity)).digest('hex');
|
|
289
|
+
const key = `${this.prefix}rate:${scope}:${digest}`;
|
|
290
|
+
const result = await this.client.command('EVAL', RATE_LIMIT_SCRIPT, 1, key, windowMs);
|
|
291
|
+
const count = Number(result?.[0] ?? 0);
|
|
292
|
+
const ttlMs = Math.max(1, Number(result?.[1] ?? windowMs));
|
|
293
|
+
return {
|
|
294
|
+
count,
|
|
295
|
+
remaining: Math.max(0, max - count),
|
|
296
|
+
retryAfterMs: ttlMs,
|
|
297
|
+
resetAt: Date.now() + ttlMs,
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
}
|