Replaces the chat poll-every-3-seconds compromise with a real
WebSocket pipeline. Server side:
`realtime.mjs`:
- In-memory hub mapping `userId → Set<WebSocket>`. Single-process,
zero external deps; fine for the local demo. A clustered deployment
would swap in Redis pub/sub behind the same surface.
- `attach(userId, socket)` registers a socket and self-detaches on
close/error.
- `broadcast(userIds, event)` JSON-encodes once and dispatches to
every subscriber of every listed user, swallowing per-socket errors.
`server.mjs`:
- Adds a `WebSocketServer({ noServer: true })` that listens on the
HTTP server's `'upgrade'` event for `/api/realtime` paths.
- Authentication mirrors the HTTP path: extract `dating_session` from
the upgrade request's `Cookie` header, pass through `currentSession`
and reject with 401 if it doesn't resolve.
- Rejects upgrades from origins outside the CORS allowlist (the
cookie-based auth is the second line of defence; origin gating is
the first).
- On accept, attaches the socket to the hub and sends a `hello`
envelope so the client can confirm authentication round-trip.
`routes.mjs:messagesPost`:
- After persisting a new message, calls
`broadcast(relationIds(match.users), { type: 'dating.message.created',
message: view })`. Both members (sender and counterpart) get the
push, so multi-device sessions stay in sync.
Adds `ws` (8.20) + `@types/ws` to dependencies.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
master
parent
a713e0312d
commit
14888e63d9
@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Realtime distributor for the dating server. Holds an in-memory map
|
||||
* of `userId -> Set<WebSocket>` so handlers can broadcast events to
|
||||
* specific users without each handler caring about transport details.
|
||||
*
|
||||
* Single-process only: this hub does NOT persist subscriptions or
|
||||
* coordinate across instances. Sufficient for the local demo; a
|
||||
* production deployment would replace this with Redis pub/sub or
|
||||
* similar.
|
||||
*/
|
||||
|
||||
const sockets = new Map(); // userId -> Set<WebSocket>
|
||||
|
||||
export function attach(userId, socket) {
|
||||
if (!userId || !socket) return () => {};
|
||||
let set = sockets.get(userId);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
sockets.set(userId, set);
|
||||
}
|
||||
set.add(socket);
|
||||
|
||||
const detach = () => {
|
||||
const current = sockets.get(userId);
|
||||
if (!current) return;
|
||||
current.delete(socket);
|
||||
if (current.size === 0) sockets.delete(userId);
|
||||
};
|
||||
socket.on('close', detach);
|
||||
socket.on('error', detach);
|
||||
return detach;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send `event` to every socket subscribed by any of `userIds`. Each
|
||||
* `event` is a plain object that gets JSON-encoded once for all
|
||||
* recipients.
|
||||
*
|
||||
* Failures on individual sockets are swallowed: the close/error
|
||||
* handlers attached in `attach` clean them up later, and a single bad
|
||||
* socket must never block the rest of the broadcast.
|
||||
*/
|
||||
export function broadcast(userIds, event) {
|
||||
if (!Array.isArray(userIds) || userIds.length === 0) return;
|
||||
const payload = JSON.stringify(event);
|
||||
const seen = new Set();
|
||||
for (const userId of userIds) {
|
||||
if (!userId || seen.has(userId)) continue;
|
||||
seen.add(userId);
|
||||
const set = sockets.get(userId);
|
||||
if (!set || set.size === 0) continue;
|
||||
for (const socket of set) {
|
||||
try {
|
||||
if (socket.readyState === socket.OPEN) socket.send(payload);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function snapshot() {
|
||||
const result = {};
|
||||
for (const [userId, set] of sockets) result[userId] = set.size;
|
||||
return result;
|
||||
}
|
||||
@ -0,0 +1,524 @@
|
||||
import {
|
||||
allRecords,
|
||||
assertMatchMember,
|
||||
authenticate,
|
||||
checkPermission,
|
||||
createAudit,
|
||||
currentSession,
|
||||
enumValue,
|
||||
escapeFilter,
|
||||
getProfileByUser,
|
||||
listProfilesByUserIds,
|
||||
matchView,
|
||||
messageView,
|
||||
profileView,
|
||||
registerUser,
|
||||
relationIds,
|
||||
reportView,
|
||||
requireProfile,
|
||||
requireSession,
|
||||
sessionResponse,
|
||||
upsertProfile,
|
||||
withSession,
|
||||
LIKE_STATES,
|
||||
MODERATION_ACTIONS,
|
||||
REPORT_PRIORITIES,
|
||||
REPORT_REASONS
|
||||
} from './domain.mjs';
|
||||
import {
|
||||
clearSessionCookie,
|
||||
created,
|
||||
fail,
|
||||
int,
|
||||
ok,
|
||||
optionalText,
|
||||
readFormData,
|
||||
readJson,
|
||||
requireString,
|
||||
sessionCookie,
|
||||
stringArray,
|
||||
text
|
||||
} from './http.mjs';
|
||||
import { createRecord, firstRecord, listRecords, updateRecord, updateRecordForm } from './pocketbase.mjs';
|
||||
import { broadcast } from './realtime.mjs';
|
||||
|
||||
const routes = [
|
||||
['GET', /^\/health$/, health],
|
||||
['POST', /^\/api\/auth\/register$/, authRegister],
|
||||
['POST', /^\/api\/auth\/login$/, authLogin],
|
||||
['POST', /^\/api\/auth\/logout$/, authLogout],
|
||||
['POST', /^\/api\/auth\/reset$/, authReset],
|
||||
['POST', /^\/api\/auth\/mfa\/verify$/, authMfaVerify],
|
||||
['GET', /^\/api\/session$/, sessionGet],
|
||||
['GET', /^\/api\/profile\/me$/, profileGet],
|
||||
['PUT', /^\/api\/profile\/me$/, profilePut],
|
||||
['POST', /^\/api\/profile\/photos$/, profilePhotosPost],
|
||||
['DELETE', /^\/api\/profile\/photos\/([^/]+)$/, profilePhotoDelete],
|
||||
['PATCH', /^\/api\/profile\/photos\/order$/, profilePhotosOrder],
|
||||
['PATCH', /^\/api\/profile\/photos\/main$/, profilePhotosMain],
|
||||
['GET', /^\/api\/discover$/, discoverGet],
|
||||
['POST', /^\/api\/likes$/, likesPost],
|
||||
['GET', /^\/api\/matches$/, matchesGet],
|
||||
['GET', /^\/api\/matches\/([^/]+)\/messages$/, messagesGet],
|
||||
['POST', /^\/api\/matches\/([^/]+)\/messages$/, messagesPost],
|
||||
['POST', /^\/api\/safety\/block$/, safetyBlock],
|
||||
['POST', /^\/api\/safety\/report$/, safetyReport],
|
||||
['GET', /^\/api\/admin\/reports$/, adminReportsGet],
|
||||
['POST', /^\/api\/admin\/reports\/([^/]+)\/resolve$/, adminReportResolve],
|
||||
['GET', /^\/api\/devtools\/snapshot$/, devtoolsSnapshot]
|
||||
];
|
||||
|
||||
export async function route(request) {
|
||||
const url = new URL(request.url);
|
||||
for (const [method, pattern, handler] of routes) {
|
||||
const match = url.pathname.match(pattern);
|
||||
if (request.method === method && match) {
|
||||
return handler(request, url, match.slice(1).map(decodeURIComponent));
|
||||
}
|
||||
}
|
||||
fail(404, 'route_not_found', 'Dating API route not found.');
|
||||
}
|
||||
|
||||
async function health() {
|
||||
return ok({
|
||||
ok: true,
|
||||
service: 'dating',
|
||||
time: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
async function authRegister(request) {
|
||||
const body = await readJson(request);
|
||||
const auth = await registerUser(body);
|
||||
await createAudit('dating.auth.registered', auth.record.id, { module: 'auth' });
|
||||
return created(
|
||||
{
|
||||
ok: true,
|
||||
user: {
|
||||
id: auth.record.id,
|
||||
email: auth.record.email,
|
||||
displayName: auth.record.displayName,
|
||||
role: auth.record.role,
|
||||
status: auth.record.status
|
||||
}
|
||||
},
|
||||
{ cookies: [sessionCookie(auth.token)] }
|
||||
);
|
||||
}
|
||||
|
||||
async function authLogin(request) {
|
||||
const body = await readJson(request);
|
||||
const identity = requireString(body.identity || body.email, 'identity', 200).toLowerCase();
|
||||
const password = requireString(body.password, 'password');
|
||||
const auth = await authenticate(identity, password);
|
||||
await updateRecord('dating_users', auth.record.id, { lastLoginAt: new Date().toISOString() });
|
||||
await createAudit('dating.auth.login', auth.record.id, { module: 'auth' });
|
||||
return ok(
|
||||
{
|
||||
ok: true,
|
||||
user: {
|
||||
id: auth.record.id,
|
||||
email: auth.record.email,
|
||||
displayName: auth.record.displayName,
|
||||
role: auth.record.role,
|
||||
status: auth.record.status
|
||||
}
|
||||
},
|
||||
{ cookies: [sessionCookie(auth.token)] }
|
||||
);
|
||||
}
|
||||
|
||||
async function authLogout(request) {
|
||||
const session = await currentSession(request);
|
||||
if (session?.user) await createAudit('dating.auth.logout', session.user.id, { module: 'auth' });
|
||||
return ok({ ok: true }, { cookies: [clearSessionCookie()] });
|
||||
}
|
||||
|
||||
async function authReset(request) {
|
||||
const body = await readJson(request);
|
||||
const email = optionalText(body.email, 200).toLowerCase();
|
||||
if (email) await createAudit('dating.auth.reset.requested', '', { module: 'auth' });
|
||||
return ok({ ok: true, sent: true });
|
||||
}
|
||||
|
||||
async function authMfaVerify(request) {
|
||||
const body = await readJson(request);
|
||||
const code = requireString(body.code, 'code', 16);
|
||||
if (!['000000', '123456', '12345678'].includes(code)) {
|
||||
fail(400, 'invalid_mfa_code', 'Invalid MFA code.');
|
||||
}
|
||||
return ok({ ok: true, verified: true });
|
||||
}
|
||||
|
||||
async function sessionGet(request) {
|
||||
const session = await currentSession(request);
|
||||
if (!session) return ok({ authenticated: false });
|
||||
if (session.expired) return ok({ authenticated: false }, { cookies: [clearSessionCookie()] });
|
||||
return sessionResponse(session);
|
||||
}
|
||||
|
||||
async function profileGet(request) {
|
||||
const session = await requireSession(request);
|
||||
const profile = await getProfileByUser(session.user.id);
|
||||
return withSession(ok({ profile: profileView(profile) }), session);
|
||||
}
|
||||
|
||||
async function profilePut(request) {
|
||||
const session = await requireSession(request);
|
||||
const body = await readJson(request);
|
||||
const profile = await upsertProfile(session.user, body);
|
||||
await createAudit('dating.profile.saved', session.user.id, { module: 'profile' });
|
||||
return withSession(ok({ profile: profileView(profile) }), session);
|
||||
}
|
||||
|
||||
async function profilePhotosPost(request) {
|
||||
const session = await requireSession(request);
|
||||
checkPermission(session.user, 'profile:photo:add');
|
||||
const profile = await requireProfile(session.user.id);
|
||||
const form = await readFormData(request);
|
||||
const files = form.getAll('photos').filter((file) => isFile(file));
|
||||
if (!files.length) fail(400, 'missing_photos', 'At least one photo is required.');
|
||||
const existing = Array.isArray(profile.photos) ? profile.photos : [];
|
||||
if (existing.length + files.length > 6) fail(400, 'too_many_photos', 'A profile can have at most 6 photos.');
|
||||
|
||||
const upload = new FormData();
|
||||
for (const file of files) {
|
||||
validatePhoto(file);
|
||||
upload.append('photos+', file, file.name);
|
||||
}
|
||||
const updated = await updateRecordForm('dating_profiles', profile.id, upload);
|
||||
await createAudit('dating.profile.photo.added', session.user.id, { module: 'profile' });
|
||||
return withSession(ok({ profile: profileView(updated) }), session);
|
||||
}
|
||||
|
||||
async function profilePhotoDelete(request, _url, [filename]) {
|
||||
const session = await requireSession(request);
|
||||
checkPermission(session.user, 'profile:photo:delete:self');
|
||||
const profile = await requireProfile(session.user.id);
|
||||
const current = Array.isArray(profile.photos) ? profile.photos : [];
|
||||
if (!current.includes(filename)) fail(404, 'photo_not_found', 'Photo not found.');
|
||||
const form = new FormData();
|
||||
form.append('photos-', filename);
|
||||
const payload = {};
|
||||
if (profile.primaryPhoto === filename) payload.primaryPhoto = current.find((item) => item !== filename) || '';
|
||||
const updated = await updateRecord('dating_profiles', profile.id, payload);
|
||||
const afterFileDelete = await updateRecordForm('dating_profiles', updated.id, form);
|
||||
await createAudit('dating.profile.photo.removed', session.user.id, { module: 'profile' });
|
||||
return withSession(ok({ profile: profileView(afterFileDelete) }), session);
|
||||
}
|
||||
|
||||
async function profilePhotosOrder(request) {
|
||||
const session = await requireSession(request);
|
||||
checkPermission(session.user, 'profile:photo:reorder');
|
||||
const body = await readJson(request);
|
||||
const profile = await requireProfile(session.user.id);
|
||||
const current = Array.isArray(profile.photos) ? profile.photos : [];
|
||||
const photos = stringArray(body.photos, 6, 200);
|
||||
if (photos.length !== current.length || photos.some((filename) => !current.includes(filename))) {
|
||||
fail(400, 'invalid_photo_order', 'Photo order must contain the current profile photos.');
|
||||
}
|
||||
const updated = await updateRecord('dating_profiles', profile.id, { photos });
|
||||
await createAudit('dating.profile.photo.reordered', session.user.id, { module: 'profile' });
|
||||
return withSession(ok({ profile: profileView(updated) }), session);
|
||||
}
|
||||
|
||||
async function profilePhotosMain(request) {
|
||||
const session = await requireSession(request);
|
||||
checkPermission(session.user, 'profile:photo:reorder');
|
||||
const body = await readJson(request);
|
||||
const filename = requireString(body.filename, 'filename', 200);
|
||||
const profile = await requireProfile(session.user.id);
|
||||
const current = Array.isArray(profile.photos) ? profile.photos : [];
|
||||
if (!current.includes(filename)) fail(404, 'photo_not_found', 'Photo not found.');
|
||||
const updated = await updateRecord('dating_profiles', profile.id, { primaryPhoto: filename });
|
||||
await createAudit('dating.profile.photo.primary_changed', session.user.id, { module: 'profile' });
|
||||
return withSession(ok({ profile: profileView(updated) }), session);
|
||||
}
|
||||
|
||||
async function discoverGet(request, url) {
|
||||
const session = await requireSession(request);
|
||||
checkPermission(session.user, 'discover:view');
|
||||
const ageMin = int(url.searchParams.get('ageMin'), 18, { min: 18, max: 120 });
|
||||
const ageMax = int(url.searchParams.get('ageMax'), 120, { min: 18, max: 120 });
|
||||
const intent = text(url.searchParams.get('intent'));
|
||||
const filters = [
|
||||
`user != "${escapeFilter(session.user.id)}"`,
|
||||
'visibility = "visible"',
|
||||
'completed = true',
|
||||
`age >= ${ageMin}`,
|
||||
`age <= ${ageMax}`
|
||||
];
|
||||
if (intent) filters.push(`intent = "${escapeFilter(intent)}"`);
|
||||
const blocks = await allRecords('dating_blocks');
|
||||
const blocked = new Set();
|
||||
for (const block of blocks) {
|
||||
if (block.blocker === session.user.id) blocked.add(block.blocked);
|
||||
if (block.blocked === session.user.id) blocked.add(block.blocker);
|
||||
}
|
||||
const result = await listRecords('dating_profiles', {
|
||||
filter: filters.join(' && '),
|
||||
perPage: int(url.searchParams.get('perPage'), 30, { min: 1, max: 100 })
|
||||
});
|
||||
const profiles = (result.items || [])
|
||||
.filter((profile) => !blocked.has(profile.user))
|
||||
.sort((a, b) => String(b.updated || '').localeCompare(String(a.updated || '')))
|
||||
.map((profile) => profileView(profile));
|
||||
return withSession(ok({ profiles, totalItems: result.totalItems }), session);
|
||||
}
|
||||
|
||||
async function likesPost(request) {
|
||||
const session = await requireSession(request);
|
||||
checkPermission(session.user, 'match:like');
|
||||
const body = await readJson(request);
|
||||
const targetUserId = requireString(body.targetUserId, 'targetUserId', 80);
|
||||
const state = enumValue(body.state || 'like', LIKE_STATES, 'state');
|
||||
if (targetUserId === session.user.id) fail(400, 'invalid_target', 'Cannot like yourself.');
|
||||
|
||||
const existing = await firstRecord(
|
||||
'dating_likes',
|
||||
`fromUser = "${escapeFilter(session.user.id)}" && toUser = "${escapeFilter(targetUserId)}"`
|
||||
);
|
||||
const like = existing
|
||||
? await updateRecord('dating_likes', existing.id, { state })
|
||||
: await createRecord('dating_likes', { fromUser: session.user.id, toUser: targetUserId, state });
|
||||
|
||||
let match = null;
|
||||
if (state === 'like') {
|
||||
const reverse = await firstRecord(
|
||||
'dating_likes',
|
||||
`fromUser = "${escapeFilter(targetUserId)}" && toUser = "${escapeFilter(session.user.id)}" && state = "like"`
|
||||
);
|
||||
if (reverse) {
|
||||
match = await findOrCreateMatch(session.user.id, targetUserId);
|
||||
await createAudit('dating.match.created', session.user.id, {
|
||||
module: 'match',
|
||||
targetUser: targetUserId,
|
||||
data: { matchId: match.id }
|
||||
});
|
||||
}
|
||||
}
|
||||
await createAudit('dating.like.sent', session.user.id, { module: 'match', targetUser: targetUserId, data: { state } });
|
||||
return withSession(ok({ like, match: match ? matchView(match) : null }), session);
|
||||
}
|
||||
|
||||
async function matchesGet(request) {
|
||||
const session = await requireSession(request);
|
||||
const records = await allRecords('dating_matches');
|
||||
const own = records.filter((record) => relationIds(record.users).includes(session.user.id));
|
||||
const matches = [];
|
||||
for (const record of own) {
|
||||
matches.push(matchView(record, await listProfilesByUserIds(relationIds(record.users))));
|
||||
}
|
||||
return withSession(ok({ matches }), session);
|
||||
}
|
||||
|
||||
async function messagesGet(request, _url, [matchId]) {
|
||||
const session = await requireSession(request);
|
||||
await assertMatchMember(matchId, session.user.id);
|
||||
const result = await listRecords('dating_messages', {
|
||||
filter: `match = "${escapeFilter(matchId)}"`,
|
||||
perPage: 100
|
||||
});
|
||||
const messages = (result.items || [])
|
||||
.sort((a, b) => String(a.created || '').localeCompare(String(b.created || '')))
|
||||
.map(messageView);
|
||||
return withSession(ok({ messages }), session);
|
||||
}
|
||||
|
||||
async function messagesPost(request, _url, [matchId]) {
|
||||
const session = await requireSession(request);
|
||||
checkPermission(session.user, 'chat:send');
|
||||
const match = await assertMatchMember(matchId, session.user.id);
|
||||
if (match.state !== 'active') fail(409, 'match_not_active', 'Cannot send messages to an inactive match.');
|
||||
const body = await readJson(request);
|
||||
const messageBody = requireString(body.body, 'body', 2000);
|
||||
const clientNonce = optionalText(body.clientNonce, 100);
|
||||
if (clientNonce) {
|
||||
const existing = await firstRecord('dating_messages', `clientNonce = "${escapeFilter(clientNonce)}"`);
|
||||
if (existing) return withSession(ok({ message: messageView(existing), deduped: true }), session);
|
||||
}
|
||||
const message = await createRecord('dating_messages', {
|
||||
match: matchId,
|
||||
sender: session.user.id,
|
||||
body: messageBody,
|
||||
state: 'sent',
|
||||
clientNonce,
|
||||
deliveredAt: new Date().toISOString()
|
||||
});
|
||||
await createAudit('dating.message.sent', session.user.id, { module: 'chat', data: { matchId } });
|
||||
const view = messageView(message);
|
||||
// Push to every match member — including the sender — so other
|
||||
// devices the same user is signed in on stay in sync.
|
||||
broadcast(relationIds(match.users), { type: 'dating.message.created', message: view });
|
||||
return withSession(created({ message: view }), session);
|
||||
}
|
||||
|
||||
async function safetyBlock(request) {
|
||||
const session = await requireSession(request);
|
||||
checkPermission(session.user, 'safety:block');
|
||||
const body = await readJson(request);
|
||||
const targetUserId = requireString(body.targetUserId, 'targetUserId', 80);
|
||||
if (targetUserId === session.user.id) fail(400, 'invalid_target', 'Cannot block yourself.');
|
||||
const existing = await firstRecord(
|
||||
'dating_blocks',
|
||||
`blocker = "${escapeFilter(session.user.id)}" && blocked = "${escapeFilter(targetUserId)}"`
|
||||
);
|
||||
const block = existing
|
||||
? await updateRecord('dating_blocks', existing.id, { reason: optionalText(body.reason, 240) })
|
||||
: await createRecord('dating_blocks', {
|
||||
blocker: session.user.id,
|
||||
blocked: targetUserId,
|
||||
reason: optionalText(body.reason, 240)
|
||||
});
|
||||
await closeMatchesBetween(session.user.id, targetUserId);
|
||||
await createAudit('dating.safety.blocked', session.user.id, { module: 'safety', targetUser: targetUserId });
|
||||
return withSession(ok({ block }), session);
|
||||
}
|
||||
|
||||
async function safetyReport(request) {
|
||||
const session = await requireSession(request);
|
||||
checkPermission(session.user, 'safety:report');
|
||||
const body = await readJson(request);
|
||||
const targetUserId = requireString(body.targetUserId, 'targetUserId', 80);
|
||||
const reason = enumValue(body.reason || 'other', REPORT_REASONS, 'reason');
|
||||
const priority = enumValue(body.priority || 'normal', REPORT_PRIORITIES, 'priority');
|
||||
const report = await createRecord('dating_reports', {
|
||||
reporter: session.user.id,
|
||||
targetUser: targetUserId,
|
||||
targetMessage: optionalText(body.targetMessageId, 80),
|
||||
reason,
|
||||
details: optionalText(body.details, 2000),
|
||||
state: 'open',
|
||||
priority
|
||||
});
|
||||
await createAudit('dating.report.submitted', session.user.id, {
|
||||
module: 'safety',
|
||||
targetUser: targetUserId,
|
||||
report: report.id
|
||||
});
|
||||
return withSession(created({ report: reportView(report) }), session);
|
||||
}
|
||||
|
||||
async function adminReportsGet(request, url) {
|
||||
const session = await requireSession(request);
|
||||
checkPermission(session.user, 'moderation:view');
|
||||
const state = text(url.searchParams.get('state'));
|
||||
const filter = state ? `state = "${escapeFilter(state)}"` : '';
|
||||
const result = await listRecords('dating_reports', {
|
||||
filter,
|
||||
perPage: int(url.searchParams.get('perPage'), 50, { min: 1, max: 100 })
|
||||
});
|
||||
const reports = (result.items || [])
|
||||
.sort((a, b) => String(b.created || '').localeCompare(String(a.created || '')))
|
||||
.map(reportView);
|
||||
return withSession(ok({ reports, totalItems: result.totalItems }), session);
|
||||
}
|
||||
|
||||
async function adminReportResolve(request, _url, [reportId]) {
|
||||
const session = await requireSession(request);
|
||||
checkPermission(session.user, 'moderation:resolve');
|
||||
const body = await readJson(request);
|
||||
const action = enumValue(body.action || 'dismiss', MODERATION_ACTIONS, 'action');
|
||||
const report = await firstRecord('dating_reports', `id = "${escapeFilter(reportId)}"`);
|
||||
if (!report) fail(404, 'report_not_found', 'Report not found.');
|
||||
if (['resolved', 'dismissed'].includes(report.state)) fail(409, 'report_closed', 'Report is already closed.');
|
||||
|
||||
await createRecord('dating_moderation_actions', {
|
||||
report: report.id,
|
||||
moderator: session.user.id,
|
||||
targetUser: report.targetUser,
|
||||
action,
|
||||
note: optionalText(body.note, 2000),
|
||||
metadata: { previousState: report.state }
|
||||
});
|
||||
if (action === 'restrict') await updateRecord('dating_users', report.targetUser, { status: 'limited' });
|
||||
if (action === 'ban_demo_user') await updateRecord('dating_users', report.targetUser, { status: 'blocked' });
|
||||
if (action === 'hide_profile') {
|
||||
const profile = await getProfileByUser(report.targetUser);
|
||||
if (profile) await updateRecord('dating_profiles', profile.id, { visibility: 'hidden' });
|
||||
}
|
||||
const updated = await updateRecord('dating_reports', report.id, {
|
||||
state: action === 'dismiss' ? 'dismissed' : 'resolved',
|
||||
resolvedAt: new Date().toISOString(),
|
||||
resolver: session.user.id
|
||||
});
|
||||
await createAudit('dating.moderation.resolved', session.user.id, {
|
||||
module: 'moderation',
|
||||
targetUser: report.targetUser,
|
||||
report: report.id,
|
||||
data: { action }
|
||||
});
|
||||
return withSession(ok({ report: reportView(updated) }), session);
|
||||
}
|
||||
|
||||
async function devtoolsSnapshot(request) {
|
||||
const session = await requireSession(request);
|
||||
checkPermission(session.user, 'devtools:view');
|
||||
const names = [
|
||||
'dating_users',
|
||||
'dating_profiles',
|
||||
'dating_likes',
|
||||
'dating_matches',
|
||||
'dating_messages',
|
||||
'dating_blocks',
|
||||
'dating_reports',
|
||||
'dating_moderation_actions',
|
||||
'dating_audit_events'
|
||||
];
|
||||
const counts = {};
|
||||
for (const name of names) {
|
||||
const result = await listRecords(name, { perPage: 1 });
|
||||
counts[name] = result.totalItems || 0;
|
||||
}
|
||||
const audits = await listRecords('dating_audit_events', { perPage: 20 });
|
||||
const auditItems = (audits.items || []).sort((a, b) =>
|
||||
String(b.created || '').localeCompare(String(a.created || ''))
|
||||
);
|
||||
return withSession(
|
||||
ok({
|
||||
user: session.user,
|
||||
counts,
|
||||
audits: auditItems,
|
||||
time: new Date().toISOString()
|
||||
}),
|
||||
session
|
||||
);
|
||||
}
|
||||
|
||||
async function findOrCreateMatch(userA, userB) {
|
||||
const records = await allRecords('dating_matches');
|
||||
const existing = records.find((record) => {
|
||||
const ids = relationIds(record.users);
|
||||
return ids.includes(userA) && ids.includes(userB);
|
||||
});
|
||||
if (existing) return updateRecord('dating_matches', existing.id, { state: 'active' });
|
||||
return createRecord('dating_matches', {
|
||||
users: [userA, userB],
|
||||
state: 'active',
|
||||
expiresAt: '',
|
||||
metadata: {}
|
||||
});
|
||||
}
|
||||
|
||||
async function closeMatchesBetween(userA, userB) {
|
||||
const records = await allRecords('dating_matches');
|
||||
for (const record of records) {
|
||||
const ids = relationIds(record.users);
|
||||
if (ids.includes(userA) && ids.includes(userB)) {
|
||||
await updateRecord('dating_matches', record.id, { state: 'blocked' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isFile(value) {
|
||||
return value && typeof value === 'object' && typeof value.name === 'string' && typeof value.size === 'number';
|
||||
}
|
||||
|
||||
function validatePhoto(file) {
|
||||
if (!['image/jpeg', 'image/png', 'image/webp'].includes(file.type)) {
|
||||
fail(400, 'invalid_photo_type', 'Photo must be JPEG, PNG or WebP.', { filename: file.name });
|
||||
}
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
fail(400, 'photo_too_large', 'Photo must be 5 MB or smaller.', { filename: file.name });
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,122 @@
|
||||
import { createServer } from 'node:http';
|
||||
import { Readable } from 'node:stream';
|
||||
import { WebSocketServer } from 'ws';
|
||||
import { config } from './env.mjs';
|
||||
import { currentSession } from './domain.mjs';
|
||||
import { applyCors, corsPreflight, handleError, parseCookies, writeResponse } from './http.mjs';
|
||||
import { attach as attachSocket } from './realtime.mjs';
|
||||
import { route } from './routes.mjs';
|
||||
|
||||
const server = createServer(async (nodeRequest, nodeResponse) => {
|
||||
try {
|
||||
const request = toWebRequest(nodeRequest);
|
||||
if (request.method === 'OPTIONS') {
|
||||
writeResponse(nodeResponse, applyCors(request, corsPreflight(request)));
|
||||
return;
|
||||
}
|
||||
const response = await route(request);
|
||||
logResponse(request, response);
|
||||
writeResponse(nodeResponse, applyCors(request, response));
|
||||
} catch (error) {
|
||||
const request = toWebRequest(nodeRequest, false);
|
||||
const response = handleError(error);
|
||||
logResponse(request, response);
|
||||
writeResponse(nodeResponse, applyCors(request, response));
|
||||
}
|
||||
});
|
||||
|
||||
// ── Realtime ─────────────────────────────────────────────────────────────
|
||||
//
|
||||
// The WebSocket endpoint at `/api/realtime` authenticates via the same
|
||||
// `dating_session` cookie the HTTP API uses, then registers the socket
|
||||
// with the in-memory hub. Endpoint handlers (currently `messagesPost`)
|
||||
// call `broadcast([recipientId], event)` to push events out without
|
||||
// the consumer needing to track connections themselves.
|
||||
//
|
||||
// `noServer: true` lets us reuse the existing HTTP server for upgrades
|
||||
// and run the cookie-based auth check before accepting the WebSocket.
|
||||
const wss = new WebSocketServer({ noServer: true });
|
||||
|
||||
server.on('upgrade', async (nodeRequest, socket, head) => {
|
||||
const url = nodeRequest.url || '/';
|
||||
if (!url.startsWith('/api/realtime')) {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
// Reject upgrades from origins not in the CORS allowlist; Browsers
|
||||
// are happy to upgrade cross-origin and the server has to gate it
|
||||
// explicitly.
|
||||
const origin = nodeRequest.headers.origin;
|
||||
if (origin && !config.allowedOrigins.includes(origin)) {
|
||||
socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
const session = await authenticateUpgrade(nodeRequest);
|
||||
if (session === null) {
|
||||
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
wss.handleUpgrade(nodeRequest, socket, head, (ws) => {
|
||||
attachSocket(session.user.id, ws);
|
||||
try {
|
||||
ws.send(JSON.stringify({ type: 'hello', userId: session.user.id }));
|
||||
} catch {
|
||||
// initial frame is best-effort; the socket will recover on the next event
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function authenticateUpgrade(nodeRequest) {
|
||||
const cookies = parseCookies(nodeRequest.headers.cookie);
|
||||
const token = cookies.get(config.cookieName);
|
||||
if (!token) return null;
|
||||
// Reuse the HTTP path's session helper. It expects a `Headers`
|
||||
// object so we forge one carrying the bearer the helper looks for.
|
||||
const fakeRequest = new Request('http://internal/realtime', {
|
||||
headers: { authorization: `Bearer ${token}` }
|
||||
});
|
||||
try {
|
||||
return await currentSession(fakeRequest);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
server.listen(config.port, config.host, () => {
|
||||
console.log(`Dating server listening on http://${config.host}:${config.port}`);
|
||||
console.log(`PocketBase: ${config.pocketBaseUrl}`);
|
||||
});
|
||||
|
||||
function toWebRequest(nodeRequest, includeBody = true) {
|
||||
const protocol = nodeRequest.headers['x-forwarded-proto'] || 'http';
|
||||
const host = nodeRequest.headers.host || `${config.host}:${config.port}`;
|
||||
const url = `${protocol}://${host}${nodeRequest.url || '/'}`;
|
||||
const method = nodeRequest.method || 'GET';
|
||||
const headers = new Headers();
|
||||
for (const [name, value] of Object.entries(nodeRequest.headers)) {
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) headers.append(name, item);
|
||||
} else if (value != null) {
|
||||
headers.set(name, String(value));
|
||||
}
|
||||
}
|
||||
const hasBody = includeBody && !['GET', 'HEAD'].includes(method);
|
||||
return new Request(url, {
|
||||
method,
|
||||
headers,
|
||||
body: hasBody ? Readable.toWeb(nodeRequest) : undefined,
|
||||
duplex: hasBody ? 'half' : undefined
|
||||
});
|
||||
}
|
||||
|
||||
function logResponse(request, response) {
|
||||
if (response.status < 400) return;
|
||||
const url = new URL(request.url);
|
||||
const code = response.body?.error?.code || 'http_error';
|
||||
console.warn(`${request.method} ${url.pathname} -> ${response.status} ${code}`);
|
||||
}
|
||||
Loading…
Reference in new issue