@yunsoft/yuncms-core 0.1.5 → 0.1.7
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 +2 -0
- package/src/config.js +102 -57
- package/src/hooks.js +117 -32
- package/src/index.js +23 -7
- package/src/mail/smtp-mailer.js +58 -14
- package/src/migrations/0013-external-auth-foundation.js +35 -0
- package/src/query.js +131 -71
- package/src/redis.js +300 -0
- package/src/relation-expansion.js +511 -223
- 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/items-service.js +80 -93
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
|
+
}
|