Files
memind/page-data-session-store.mjs
T
john 6b0c633a75 feat(page-data): complete Phase 4-5, ops UI, and publish integration
Add visitor roles, row-level scope, owner ops APIs, MySQL policy index,
Turnstile captcha, browser client SDK, publish-panel dataset binding,
acceptance tests, and usage documentation.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 14:52:49 +08:00

111 lines
2.9 KiB
JavaScript

import crypto from 'node:crypto';
export function createPageDataSessionStore(options = {}) {
const ttlMs = Number(options.ttlMs ?? 30 * 60 * 1000);
const sessions = new Map();
function hashToken(token) {
return crypto.createHash('sha256').update(String(token)).digest('hex');
}
function issue({ pageId, ownerUserId, publicationId, accessMode }) {
const token = crypto.randomBytes(32).toString('base64url');
const tokenHash = hashToken(token);
const expiresAt = Date.now() + ttlMs;
sessions.set(tokenHash, {
pageId,
ownerUserId,
publicationId,
accessMode,
sessionId: crypto.randomUUID(),
expiresAt,
});
return {
token,
expiresAt,
sessionId: sessions.get(tokenHash).sessionId,
};
}
function verify(token) {
const session = sessions.get(hashToken(token));
if (!session) return null;
if (session.expiresAt <= Date.now()) {
sessions.delete(hashToken(token));
return null;
}
return session;
}
function revoke(token) {
return sessions.delete(hashToken(token));
}
function revokeByPageId(pageId) {
let count = 0;
for (const [key, session] of sessions.entries()) {
if (session.pageId === pageId) {
sessions.delete(key);
count += 1;
}
}
return count;
}
function listByPageId(pageId) {
const now = Date.now();
const active = [];
for (const session of sessions.values()) {
if (session.pageId !== pageId) continue;
if (session.expiresAt <= now) continue;
active.push({
sessionId: session.sessionId,
pageId: session.pageId,
ownerUserId: session.ownerUserId,
accessMode: session.accessMode,
expiresAt: session.expiresAt,
});
}
return active;
}
function clearExpired() {
const now = Date.now();
for (const [key, session] of sessions.entries()) {
if (session.expiresAt <= now) sessions.delete(key);
}
}
return { issue, verify, revoke, revokeByPageId, listByPageId, clearExpired };
}
export function createPageDataRateLimiter(options = {}) {
const windowMs = Number(options.windowMs ?? 60_000);
const maxRequests = Number(options.maxRequests ?? 30);
const buckets = new Map();
function check(key) {
const now = Date.now();
const bucket = buckets.get(key) ?? { count: 0, resetAt: now + windowMs };
if (now >= bucket.resetAt) {
bucket.count = 0;
bucket.resetAt = now + windowMs;
}
bucket.count += 1;
buckets.set(key, bucket);
if (bucket.count > maxRequests) {
throw Object.assign(new Error('请求过于频繁,请稍后再试'), {
code: 'rate_limited',
status: 429,
});
}
return { remaining: Math.max(0, maxRequests - bucket.count), resetAt: bucket.resetAt };
}
return { check };
}
export function hashClientMeta(value) {
return crypto.createHash('sha256').update(String(value ?? '')).digest('hex').slice(0, 16);
}