You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
525 lines
19 KiB
525 lines
19 KiB
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 });
|
|
}
|
|
}
|