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

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 });
}
}

Powered by TurnKey Linux.