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.
341 lines
10 KiB
341 lines
10 KiB
import {
|
|
bool,
|
|
clearSessionCookie,
|
|
fail,
|
|
getSessionToken,
|
|
int,
|
|
ok,
|
|
optionalText,
|
|
requireString,
|
|
sessionCookie,
|
|
stringArray,
|
|
text
|
|
} from './http.mjs';
|
|
import {
|
|
createRecord,
|
|
fileUrl,
|
|
firstRecord,
|
|
getRecord,
|
|
listRecords,
|
|
pbPublic,
|
|
pbUser,
|
|
updateRecord,
|
|
updateRecordForm
|
|
} from './pocketbase.mjs';
|
|
|
|
export const ROLES = ['user', 'limited', 'moderator', 'admin'];
|
|
export const STATUSES = ['active', 'limited', 'blocked', 'deleted'];
|
|
export const INTENTS = ['dating', 'friends', 'long_term', 'casual', 'unsure'];
|
|
export const VISIBILITIES = ['visible', 'hidden', 'paused'];
|
|
export const LIKE_STATES = ['like', 'pass'];
|
|
export const REPORT_REASONS = ['fake', 'abuse', 'spam', 'harassment', 'underage', 'other'];
|
|
export const REPORT_PRIORITIES = ['low', 'normal', 'high', 'urgent'];
|
|
export const MODERATION_ACTIONS = ['dismiss', 'warn', 'restrict', 'hide_profile', 'ban_demo_user'];
|
|
|
|
export async function authenticate(identity, password) {
|
|
return pbPublic('/api/collections/dating_users/auth-with-password', {
|
|
method: 'POST',
|
|
body: { identity, password }
|
|
});
|
|
}
|
|
|
|
export async function registerUser(input) {
|
|
const email = requireString(input.email, 'email', 200).toLowerCase();
|
|
const password = requireString(input.password, 'password');
|
|
const passwordConfirm = text(input.passwordConfirm || input.confirmPassword || input.password);
|
|
const displayName = requireString(input.displayName, 'displayName', 80);
|
|
if (password.length < 8) fail(400, 'weak_password', 'Password must contain at least 8 characters.');
|
|
if (password !== passwordConfirm) fail(400, 'password_mismatch', 'Passwords do not match.');
|
|
if (!bool(input.adultConfirmed) && !bool(input.adultVerified)) {
|
|
fail(400, 'adult_confirmation_required', 'Adult confirmation is required.');
|
|
}
|
|
|
|
await createRecord('dating_users', {
|
|
email,
|
|
password,
|
|
passwordConfirm,
|
|
displayName,
|
|
role: 'user',
|
|
status: 'active',
|
|
adultVerified: true,
|
|
emailVisibility: false,
|
|
verified: true
|
|
});
|
|
return authenticate(email, password);
|
|
}
|
|
|
|
export async function currentSession(request) {
|
|
const token = getSessionToken(request);
|
|
if (!token) return null;
|
|
try {
|
|
const auth = await pbUser('/api/collections/dating_users/auth-refresh', token, {
|
|
method: 'POST'
|
|
});
|
|
return {
|
|
token: auth.token,
|
|
user: userView(auth.record),
|
|
cookie: sessionCookie(auth.token)
|
|
};
|
|
} catch {
|
|
return {
|
|
expired: true,
|
|
cookie: clearSessionCookie()
|
|
};
|
|
}
|
|
}
|
|
|
|
export async function requireSession(request) {
|
|
const session = await currentSession(request);
|
|
if (!session || session.expired) fail(401, 'session_required', 'Authentication required.');
|
|
if (session.user.status === 'deleted') fail(403, 'account_deleted', 'Account is deleted.');
|
|
return session;
|
|
}
|
|
|
|
export function withSession(response, session) {
|
|
if (session?.cookie) {
|
|
return { ...response, cookies: [...(response.cookies || []), session.cookie] };
|
|
}
|
|
return response;
|
|
}
|
|
|
|
export function userView(record) {
|
|
return {
|
|
id: record.id,
|
|
email: record.email,
|
|
displayName: record.displayName || record.name || '',
|
|
role: ROLES.includes(record.role) ? record.role : 'user',
|
|
status: STATUSES.includes(record.status) ? record.status : 'active',
|
|
adultVerified: Boolean(record.adultVerified),
|
|
verified: Boolean(record.verified),
|
|
created: record.created,
|
|
updated: record.updated,
|
|
lastLoginAt: record.lastLoginAt || ''
|
|
};
|
|
}
|
|
|
|
export function profileView(record) {
|
|
if (!record) return null;
|
|
const photos = Array.isArray(record.photos) ? record.photos : record.photos ? [record.photos] : [];
|
|
return {
|
|
id: record.id,
|
|
userId: relationId(record.user),
|
|
displayName: record.displayName || '',
|
|
age: Number(record.age || 0),
|
|
bio: record.bio || '',
|
|
interests: Array.isArray(record.interests) ? record.interests : [],
|
|
intent: record.intent || 'unsure',
|
|
approxLocation: record.approxLocation || '',
|
|
visibility: record.visibility || 'hidden',
|
|
photos,
|
|
primaryPhoto: record.primaryPhoto || photos[0] || '',
|
|
photoUrls: photos.map((filename) => ({
|
|
filename,
|
|
original: fileUrl('dating_profiles', record.id, filename),
|
|
thumb: fileUrl('dating_profiles', record.id, filename, '120x120'),
|
|
card: fileUrl('dating_profiles', record.id, filename, '400x600')
|
|
})),
|
|
completed: Boolean(record.completed),
|
|
publishedAt: record.publishedAt || '',
|
|
created: record.created,
|
|
updated: record.updated
|
|
};
|
|
}
|
|
|
|
export function matchView(record, profiles = []) {
|
|
return {
|
|
id: record.id,
|
|
userIds: relationIds(record.users),
|
|
state: record.state || 'active',
|
|
expiresAt: record.expiresAt || '',
|
|
metadata: record.metadata || {},
|
|
profiles,
|
|
created: record.created,
|
|
updated: record.updated
|
|
};
|
|
}
|
|
|
|
export function messageView(record) {
|
|
return {
|
|
id: record.id,
|
|
matchId: relationId(record.match),
|
|
senderId: relationId(record.sender),
|
|
body: record.body || '',
|
|
state: record.state || 'sent',
|
|
clientNonce: record.clientNonce || '',
|
|
deliveredAt: record.deliveredAt || '',
|
|
metadata: record.metadata || {},
|
|
created: record.created,
|
|
updated: record.updated
|
|
};
|
|
}
|
|
|
|
export function reportView(record) {
|
|
return {
|
|
id: record.id,
|
|
reporterId: relationId(record.reporter),
|
|
targetUserId: relationId(record.targetUser),
|
|
targetMessageId: relationId(record.targetMessage),
|
|
reason: record.reason,
|
|
details: record.details || '',
|
|
state: record.state || 'open',
|
|
priority: record.priority || 'normal',
|
|
resolvedAt: record.resolvedAt || '',
|
|
resolverId: relationId(record.resolver),
|
|
created: record.created,
|
|
updated: record.updated
|
|
};
|
|
}
|
|
|
|
export async function getProfileByUser(userId) {
|
|
return firstRecord('dating_profiles', `user = "${escapeFilter(userId)}"`);
|
|
}
|
|
|
|
export async function requireProfile(userId) {
|
|
const profile = await getProfileByUser(userId);
|
|
if (!profile) fail(409, 'profile_required', 'Profile must exist before this operation.');
|
|
return profile;
|
|
}
|
|
|
|
export async function upsertProfile(user, input) {
|
|
checkPermission(user, 'profile:update:self');
|
|
const existing = await getProfileByUser(user.id);
|
|
const payload = profilePayload(input, user, existing);
|
|
if (existing) return updateRecord('dating_profiles', existing.id, payload);
|
|
return createRecord('dating_profiles', { ...payload, user: user.id });
|
|
}
|
|
|
|
export function profilePayload(input, user, existing) {
|
|
const displayName = optionalText(input.displayName, 80) || existing?.displayName || user.displayName;
|
|
if (!displayName) fail(400, 'missing_display_name', 'displayName is required.');
|
|
const age = int(input.age ?? existing?.age, 0, { min: 18, max: 120 });
|
|
if (!age) fail(400, 'invalid_age', 'age must be an integer between 18 and 120.');
|
|
const intent = enumValue(input.intent || existing?.intent || 'unsure', INTENTS, 'intent');
|
|
const visibility = enumValue(input.visibility || existing?.visibility || 'hidden', VISIBILITIES, 'visibility');
|
|
const completed = bool(input.completed, Boolean(existing?.completed));
|
|
return {
|
|
displayName,
|
|
age,
|
|
bio: optionalText(input.bio, 500),
|
|
interests: stringArray(input.interests, 30, 60),
|
|
intent,
|
|
approxLocation: optionalText(input.approxLocation, 120),
|
|
visibility,
|
|
completed,
|
|
publishedAt: completed && visibility === 'visible' ? new Date().toISOString() : existing?.publishedAt || ''
|
|
};
|
|
}
|
|
|
|
export function checkPermission(user, action) {
|
|
if (!user) fail(401, 'session_required', 'Authentication required.');
|
|
if (user.role === 'admin') return true;
|
|
if (user.status === 'blocked' || user.status === 'deleted') {
|
|
fail(403, 'account_restricted', 'Account cannot perform this action.');
|
|
}
|
|
if (action.startsWith('moderation:') || action === 'profile:photo:moderate') {
|
|
if (user.role === 'moderator') return true;
|
|
fail(403, 'permission_denied', 'Permission denied.');
|
|
}
|
|
if (user.role === 'moderator') {
|
|
if (['discover:view', 'safety:block', 'safety:report', 'devtools:view'].includes(action)) return true;
|
|
if (action.startsWith('profile:')) return true;
|
|
}
|
|
if (user.status === 'limited' || user.role === 'limited') {
|
|
if (['profile:create', 'profile:update:self', 'profile:photo:delete:self'].includes(action)) return true;
|
|
if (['safety:block', 'safety:report', 'devtools:view'].includes(action)) return true;
|
|
fail(403, 'permission_denied', 'Permission denied.');
|
|
}
|
|
if (
|
|
[
|
|
'profile:create',
|
|
'profile:update:self',
|
|
'profile:photo:add',
|
|
'profile:photo:delete:self',
|
|
'profile:photo:reorder',
|
|
'discover:view',
|
|
'match:like',
|
|
'chat:send',
|
|
'safety:block',
|
|
'safety:report',
|
|
'devtools:view'
|
|
].includes(action)
|
|
) {
|
|
return true;
|
|
}
|
|
fail(403, 'permission_denied', 'Permission denied.');
|
|
}
|
|
|
|
export async function assertMatchMember(matchId, userId) {
|
|
const record = await getRecord('dating_matches', matchId);
|
|
const ids = relationIds(record.users);
|
|
if (!ids.includes(userId)) fail(404, 'match_not_found', 'Match not found.');
|
|
return record;
|
|
}
|
|
|
|
export async function createAudit(event, actorId, data = {}) {
|
|
try {
|
|
await createRecord('dating_audit_events', {
|
|
actor: actorId || '',
|
|
targetUser: data.targetUser || '',
|
|
report: data.report || '',
|
|
event,
|
|
module: data.module || 'dating',
|
|
traceId: data.traceId || '',
|
|
data: data.data || {}
|
|
});
|
|
} catch {
|
|
// Audit must not break the user flow in the local demo server.
|
|
}
|
|
}
|
|
|
|
export function relationId(value) {
|
|
if (Array.isArray(value)) return String(value[0] || '');
|
|
return value ? String(value) : '';
|
|
}
|
|
|
|
export function relationIds(value) {
|
|
if (!Array.isArray(value)) return value ? [String(value)] : [];
|
|
return value.map(String);
|
|
}
|
|
|
|
export function escapeFilter(value) {
|
|
return String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
}
|
|
|
|
export function enumValue(value, allowed, field) {
|
|
const next = text(value);
|
|
if (!allowed.includes(next)) fail(400, 'invalid_enum', `${field} has an invalid value.`, { field, allowed });
|
|
return next;
|
|
}
|
|
|
|
export function sessionPayload(session) {
|
|
return {
|
|
authenticated: true,
|
|
user: session.user
|
|
};
|
|
}
|
|
|
|
export function sessionResponse(session) {
|
|
return withSession(ok(sessionPayload(session)), session);
|
|
}
|
|
|
|
export async function listProfilesByUserIds(userIds) {
|
|
const profiles = [];
|
|
for (const userId of userIds) {
|
|
const profile = await getProfileByUser(userId);
|
|
if (profile) profiles.push(profileView(profile));
|
|
}
|
|
return profiles;
|
|
}
|
|
|
|
export async function allRecords(collection, options = {}) {
|
|
const perPage = options.perPage || 100;
|
|
const first = await listRecords(collection, { ...options, page: 1, perPage });
|
|
const items = [...(first.items || [])];
|
|
const totalPages = first.totalPages || 1;
|
|
for (let page = 2; page <= totalPages; page += 1) {
|
|
const next = await listRecords(collection, { ...options, page, perPage });
|
|
items.push(...(next.items || []));
|
|
}
|
|
return items;
|
|
}
|