import type { Request, Response } from 'express'; import { Router } from 'express'; import SQL from 'sql-template-strings'; import md5 from 'js-md5'; import { ulid } from 'ulid'; import * as Sentry from '@sentry/node'; import type { ParsedQs } from 'qs'; import avatar from '../avatar.ts'; import { handleErrorAsync, now, isValidLink } from '../../src/helpers.ts'; import { caches } from '../../src/cache.ts'; import fs from 'fs'; import { birthdateRange, formatDate, parseDate } from '../../src/birthdate.ts'; import { socialProviders } from '../../src/socialProviders.ts'; import { downgradeToV1, upgradeToV2 } from '../profileV2.ts'; import { colours, styles } from '../../src/styling.ts'; import { normaliseUrl } from '../../src/links.ts'; import allLocales from '../../locale/locales.ts'; import type { LocaleDescription } from '../../locale/locales.ts'; import auditLog from '../audit.ts'; import crypto from '../../src/crypto.ts'; import { awsConfig, awsParams } from '../aws.ts'; import { S3, NoSuchKey } from '@aws-sdk/client-s3'; import zlib from 'zlib'; import multer from 'multer'; import type { Database } from '../db.ts'; import type { AuthenticatorRow, UserRow } from './user.ts'; import type { LinkMetadata, OpinionFormValue, Profile, ProfileV1, RelatedPerson, SaveProfilePayload, ValueOpinion, } from '../../src/profile.ts'; import type { Opinion } from '../../src/opinions.ts'; const __dirname = new URL('.', import.meta.url).pathname; interface LinkRow { url: string | null; expiresAt: number | null; favicon: string | null; faviconCache: string | null; relMe: string | null; nodeinfo: string | null; } interface ProfileRow { id: string; userId: string; locale: string; names: string; pronouns: string; description: string; birthday: string | null; links: string; flags: string; words: string; active: number; teamName: string | null; footerName: string | null; footerAreas: string | null; customFlags: string; card: string | null; credentials: string | null; credentialsLevel: number | null; credentialsName: number | null; cardDark: string | null; opinions: string; timezone: string | null; sensitive: string; markdown: number | null; events: string | null; customEvents: string | null; lastUpdate: string | null; } interface UserConnectionRow { id: string; from_profileId: string; to_userId: string; relationship: string; } const normalise = (s: string): string => decodeURIComponent(s.trim().toLowerCase()); const normaliseWithLink = (s: string): string => { return normalise(s.replace(/^@/, '').replace(new RegExp('^https://.*?/@'), '')); }; const calcAge = (birthday: string): number | null => { if (!birthday) { return null; } const now = new Date(); const birth = parseDate(birthday)!; const diff = now.getTime() - birth.getTime(); return Math.floor(diff / 1000 / 60 / 60 / 24 / 365.25); }; const providersWithLinks = Object.keys(socialProviders) .filter((p) => socialProviders[p].linkRegex !== undefined); const domains = ['https://pronouns.page', ...allLocales.map((l) => l.url)]; const verifyLinks = ( links: string[], authenticators: Pick[], username: string, linksMetadata: Record, ): Record => { const verifiedLinks: Record = {}; for (const link of links) { const linkMetadata = linksMetadata[link]; if (!linkMetadata || !linkMetadata.relMe) { continue; } for (let relMe of linkMetadata.relMe) { if (domains.filter((d) => relMe.startsWith(d)).length === 0) { continue; } try { relMe = new URL(relMe).pathname; } catch { continue; } if (!relMe.startsWith('/@') && !relMe.startsWith('/u/')) { continue; } relMe = relMe.replace(new RegExp('^/@'), '').replace(new RegExp('^/u/'), ''); if (normalise(username) === normalise(relMe)) { verifiedLinks[link] = 'relMe'; } } } for (const provider of providersWithLinks) { for (const a of authenticators) { if (a.type !== provider) { continue; } const regex = new RegExp(socialProviders[a.type].linkRegex!(JSON.parse(a.payload)), 'i'); for (const link of links) { if (link.match(regex)) { verifiedLinks[link] = provider; } } } } return verifiedLinks; }; interface ProfileProps { enabled: boolean; default_value: boolean; [property: string]: boolean; } export class ProfileOptions { default_props: ProfileProps = { enabled: true, default_value: true, }; locale_specific_props: Record = {}; constructor(query_obj: ParsedQs, locales?: Record) { if (query_obj == null) { return; } const default_props = query_obj.props; this.default_props = ProfileOptions._parse_props(default_props! as string | string[] | ParsedQs); if (locales != null) { for (const locale of Object.values(locales)) { const key = locale.code.toLowerCase(); // i'm purely paranoid here const lprops_obj = query_obj.lprops; let props; if (typeof lprops_obj === 'object') { props = (lprops_obj as ParsedQs)[key]; } else { props = query_obj[`lprops.${key}`]; } if (props != null) { this.locale_specific_props[key] = ProfileOptions._parse_props(props as string | string[] | ParsedQs); } } } else { // console.warn("ProfileOptions object was constructed without specifying locales parameter - not providing locale-specific data"); } } static _parse_props(raw_param: string | string[] | ParsedQs) { if (raw_param == null) { return { enabled: true, default_value: true, }; } let properties: string[] = []; const default_value = false; const obj: Record = {}; if (Array.isArray(raw_param)) { properties = raw_param; } else if (typeof raw_param === 'object') { return { ...raw_param, enabled: true, default_value: false, }; } else if (typeof raw_param === 'string') { switch (raw_param) { case 'none': // Don't show the locale at all return { enabled: false, default_value: false, }; case 'empty': // Include it as an object, but don't include any data return { enabled: true, default_value: false, }; case 'all': // All properties should be shown return { enabled: true, default_value: true, }; } // console.log("unrecognised: ", raw_param) properties = raw_param.split(','); } for (const property of properties) { obj[property] = !default_value; } return { ...obj, enabled: true, default_value, }; } _data_for(locale: string) { if (typeof locale !== 'string') { throw new Error('Locale must be string'); } return this.locale_specific_props[locale] ?? this.default_props; } show_at_all(locale: string): boolean { return this._data_for(locale).enabled ?? true; } prop(locale: string, property: string): boolean { if (!this.show_at_all(locale)) { return false; } // it's duplicate data getting (show_at_all also calls _data_for), // but it's not that important to optimise const data = this._data_for(locale); return data[property] ?? data.default_value ?? true; } propv(locale: string, property: string, value: () => T): T | undefined { if (this.prop(locale, property)) { return value(); } else { return undefined; } } } const fetchProfiles = async ( db: Database, username: string, self: boolean, optsArg: ProfileOptions | undefined = undefined, ): Promise>> => { const opts = optsArg ?? new ProfileOptions({}); const user = await db.get>(SQL`SELECT id FROM users WHERE usernameNorm = ${normalise(username)}`); if (!user) { return {}; } const profiles = await db.all(SQL` SELECT profiles.* FROM profiles WHERE userId = ${user.id} ORDER BY profiles.locale `); const linkAuthenticators = await db.all>(SQL` SELECT a.type, a.payload FROM authenticators a WHERE a.userId = ${user.id} AND a.type IN (`.append(providersWithLinks.map((k) => `'${k}'`).join(',')).append(SQL`) AND (a.validUntil IS NULL OR a.validUntil > ${now()}) `)); const p: Record> = {}; for (const profile of profiles) { if (!opts.show_at_all(profile.locale)) { continue; } const propv = (property: string, value: () => T): T | undefined => { return opts.propv(profile.locale, property, value); }; type LinkData = Partial<{ links: string[], linksMetadata: Record, verifiedLinks: Record }>; const link_data: LinkData = await propv('links', async () => { const links = (JSON.parse(profile.links) as string[]).filter((l) => !!normaliseUrl(l)); const linksMetadata: Record = {}; for (const link of await db.all(SQL`SELECT * FROM links WHERE url IN (`.append(links.map((k) => `'${k.replace(/'/g, '\'\'')}'`).join(',')).append(SQL`)`))) { linksMetadata[link.url!] = { favicon: link.faviconCache || link.favicon, relMe: JSON.parse(link.relMe!), nodeinfo: JSON.parse(link.nodeinfo!), }; } const verifyLinksResult = verifyLinks(links, linkAuthenticators, username, linksMetadata); return { links, linksMetadata, verifiedLinks: verifyLinksResult }; }) ?? {}; const profile_obj: Partial = { opinions: propv('opinions', () => JSON.parse(profile.opinions)), names: propv('names', () => { return JSON.parse(profile.names).map((name: ValueOpinion) => { return { pronunciation: null, ...name }; }); }), pronouns: propv('pronouns', () => JSON.parse(profile.pronouns)), description: propv('description', () => profile.description), age: propv('age', () => calcAge(profile.birthday!)), links: link_data.links, linksMetadata: link_data.linksMetadata, verifiedLinks: link_data.verifiedLinks, flags: propv('flags', () => JSON.parse(profile.flags)), customFlags: propv('flags', () => JSON.parse(profile.customFlags)), words: propv('words', () => JSON.parse(profile.words)), birthday: propv('age', () => self ? profile.birthday : undefined), timezone: propv('timezone', () => profile.timezone ? JSON.parse(profile.timezone) : null), teamName: propv('team', () => profile.teamName), footerName: propv('team', () => profile.footerName), footerAreas: propv('team', () => profile.footerAreas ? profile.footerAreas.split(',') : []), credentials: propv('credentials', () => profile.credentials ? profile.credentials.split('|') : []), credentialsLevel: propv('credentials', () => profile.credentialsLevel), credentialsName: propv('credentials', () => profile.credentialsName), card: propv('card_image', () => profile.card), cardDark: propv('card_image', () => profile.cardDark), circle: await propv('circle', () => fetchCircles(db, profile.id, user.id)), sensitive: propv('sensitive', () => JSON.parse(profile.sensitive)), markdown: propv('markdown', () => !!profile.markdown), events: propv('sensitive', () => JSON.parse(profile.events || '[]')), customEvents: propv('sensitive', () => JSON.parse(profile.customEvents || '[]')), lastUpdate: propv('lastUpdate', () => profile.lastUpdate), }; p[profile.locale] = profile_obj; } return p; }; export const profilesSnapshot = async ( db: Database, username: string, opts: ProfileOptions | undefined = undefined, ): Promise => { return JSON.stringify(await fetchProfiles(db, username, true, opts), null, 4); }; const susRegexes = fs.readFileSync(`${__dirname}/../../moderation/sus.txt`).toString('utf-8') .split('\n') .filter((x) => !!x); function* isSuspicious(profile: SaveProfilePayload) { for (let s of [ profile.description, JSON.stringify(profile.customFlags), JSON.stringify(profile.pronouns), JSON.stringify(profile.names), JSON.stringify(profile.words), JSON.stringify(profile.opinions), JSON.stringify(profile.circle), ]) { s = s.toLowerCase().replace(/\s+/g, ' '); for (const sus of susRegexes) { const m = s.match(new RegExp(sus, 'ig')); if (m) { yield `${m[0]} (${sus})`; } } } } const mildSus = [ 'fag', 'faggot', 'tranny', 'phobic', 'kys', ]; const hasAutomatedReports = async (db: Database, id: string): Promise => { return (await db.get<{ c: number }>(SQL`SELECT COUNT(*) AS c FROM reports WHERE userId = ${id} AND isAutomatic = 1`))!.c > 0; }; const usernamesToIds = async (db: Database, usernames: string[]): Promise> => { const users = await db.all>(SQL` SELECT id, usernameNorm FROM users WHERE users.usernameNorm IN (`.append(usernames.map((k) => `'${normaliseWithLink(k)}'`).join(',')).append(SQL`)`)); const idMap: Record = {}; for (const username of usernames) { for (const { id, usernameNorm } of users) { if (normaliseWithLink(username) === usernameNorm) { idMap[normaliseWithLink(username)] = id; } } } return idMap; }; const selectBestLocale = (availableLocales: string[]): string => { if (availableLocales.length === 1) { return availableLocales[0]; } if (availableLocales.includes(global.config.locale)) { return global.config.locale; } return '_'; }; const fetchCircles = async (db: Database, profileId: string, userId: string): Promise => { type QueryResult = Pick & Pick & Pick; const connections = await db.all(SQL` SELECT u.id, u.username, u.avatarSource, u.email, p.locale, c.relationship FROM user_connections c LEFT JOIN users u ON u.id = c.to_userId LEFT JOIN profiles p ON p.userId = u.id WHERE from_profileId = ${profileId} AND u.bannedReason IS NULL ORDER BY c.id `); const mentions = await findCircleMentions(db, userId); const circle: Record & { locale: string[] }> = {}; for (const connection of connections) { if (!circle.hasOwnProperty(connection.username)) { circle[connection.username] = { username: connection.username, avatar: await avatar(db, connection), circleMutual: mentions[connection.username] !== undefined, locale: [], relationship: connection.relationship, }; } circle[connection.username].locale.push(connection.locale); } return Object.values(circle).map((relatedPerson) => ({ ...relatedPerson, locale: selectBestLocale(relatedPerson.locale), })); }; const router = Router(); const fetchProfilesRoute = async (req: Request, res: Response, user: any): Promise => { const isSelf = !!req.user && req.user.username === req.params.username; const isAdmin = req.isGranted('users') || req.isGranted('community'); const opts = new ProfileOptions(req.query, req.locales); if (!user || user.bannedReason !== null && !isAdmin && !isSelf) { return res.json({ profiles: {}, }); } user.emailHash = md5(user.email); delete user.email; user.avatar = await avatar(req.db, user); user.bannedTerms = user.bannedTerms ? user.bannedTerms.split(',') : []; const profiles: Record> = await fetchProfiles(req.db, user.username, isSelf, opts); if (req.query.version !== '2') { for (const [locale, profile] of Object.entries(profiles)) { profiles[locale] = downgradeToV1(profile as Partial); } } return res.json({ ...user, profiles, }); }; router.get('/profile/get/:username', handleErrorAsync(async (req, res) => { const user = await req.db.get(SQL` SELECT users.id, users.username, users.email, users.avatarSource, users.bannedReason, users.bannedTerms, users.bannedBy, users.roles != '' AS team FROM users WHERE users.usernameNorm = ${normalise(req.params.username)} `); return await fetchProfilesRoute(req, res, user); })); router.get('/profile/get-id/:id', handleErrorAsync(async (req, res) => { const user = await req.db.get(SQL` SELECT users.id, users.username, users.email, users.avatarSource, users.bannedReason, users.bannedTerms, users.bannedBy, users.roles != '' AS team FROM users WHERE users.id = ${req.params.id} `); return await fetchProfilesRoute(req, res, user); })); router.get('/profile/versions/:username', handleErrorAsync(async (req, res) => { return res.json((await req.db.all>(SQL` SELECT profiles.locale FROM users LEFT JOIN profiles ON profiles.userId = users.id WHERE users.usernameNorm = ${normaliseWithLink(req.params.username)} `)).map((x) => x.locale)); })); const findCircleMentions = async (db: Database, userId: string) => { type QueryResult = Pick & Pick & Pick; const mentionsRaw = await db.all(SQL` SELECT u.username, p.locale, c.relationship FROM user_connections c LEFT JOIN profiles p ON p.id = c.from_profileId LEFT JOIN users u ON u.id = p.userId WHERE c.to_userId = ${userId} `); const mentionsGrouped: Record> = {}; for (const { username, locale, relationship } of mentionsRaw) { if (!mentionsGrouped.hasOwnProperty(username)) { mentionsGrouped[username] = {}; } mentionsGrouped[username][locale] = relationship; } return mentionsGrouped; }; router.get('/profile/my-circle-mentions', handleErrorAsync(async (req, res) => { if (!req.user) { return res.status(401).json({ error: 'Unauthorised' }); } return res.json(await findCircleMentions(req.db, req.user.id)); })); const cleanupBirthday = (bd: string | null): string | null => { if (!bd) { return null; } const match = bd.match(/^(\d\d\d\d)-(\d\d)-(\d\d)$/); if (!match) { return null; } const date = new Date(parseInt(match[1]), parseInt(match[2]) - 1, parseInt(match[3])); if (date < birthdateRange(global.config).min || date > birthdateRange(global.config).max) { return null; } return formatDate(date); }; const cleanupOpinions = (opinions: OpinionFormValue[]) => { const cleanOpinions: Record = {}; for (const opinion of opinions) { if (!opinion.icon || !opinion.description) { continue; } cleanOpinions[opinion.icon] = { icon: opinion.icon, description: opinion.description.substring(0, 36), colour: opinion.colour && colours.includes(opinion.colour) ? opinion.colour : undefined, style: opinion.style && styles.includes(opinion.style) ? opinion.style : undefined, }; } return cleanOpinions; }; type SaveProfile = Omit & Partial> & Pick; const saveProfile = async (req: Request, locale: string, { opinions, names, pronouns, description, birthday, timezone, links, flags, customFlags, words, sensitive, teamName, footerName, footerAreas, credentials, credentialsLevel, credentialsName, markdown = false, events = [], customEvents = [], }: SaveProfile) => { // TODO just make it a transaction... const ids = (await req.db.all(SQL`SELECT * FROM profiles WHERE userId = ${req.user!.id} AND locale = ${locale}`)).map((row) => row.id); let profileId; if (ids.length) { profileId = ids[0]; await req.db.get(SQL`UPDATE profiles SET opinions = ${JSON.stringify(opinions)}, names = ${JSON.stringify(names)}, pronouns = ${JSON.stringify(pronouns)}, description = ${description}, birthday = ${birthday}, timezone = ${JSON.stringify(timezone)}, links = ${JSON.stringify(links)}, flags = ${JSON.stringify(flags)}, customFlags = ${JSON.stringify(customFlags)}, words = ${JSON.stringify(words)}, sensitive = ${JSON.stringify(sensitive)}, markdown = ${markdown ? 1 : 0}, events = ${JSON.stringify(events)}, customEvents = ${JSON.stringify(customEvents)}, teamName = ${req.isGranted() ? teamName || null : ''}, footerName = ${req.isGranted() ? footerName || null : ''}, footerAreas = ${req.isGranted() ? footerAreas || null : ''}, credentials = ${req.isGranted() ? credentials || null : null}, credentialsLevel = ${req.isGranted() ? credentialsLevel || null : null}, credentialsName = ${req.isGranted() ? credentialsName || null : null}, card = NULL, cardDark = NULL, lastUpdate = ${ulid()} WHERE id = ${profileId} `); } else { profileId = ulid(); await req.db.get(SQL`INSERT INTO profiles (id, userId, locale, opinions, names, pronouns, description, birthday, timezone, links, flags, customFlags, words, sensitive, markdown, events, customEvents, active, teamName, footerName, footerAreas, lastUpdate) VALUES (${profileId}, ${req.user!.id}, ${locale}, ${JSON.stringify(opinions)}, ${JSON.stringify(names)}, ${JSON.stringify(pronouns)}, ${description}, ${birthday}, ${JSON.stringify(timezone)}, ${JSON.stringify(links)}, ${JSON.stringify(flags)}, ${JSON.stringify(customFlags)}, ${JSON.stringify(words)}, ${JSON.stringify(sensitive)}, ${markdown ? 1 : 0}, ${JSON.stringify(events)}, ${JSON.stringify(customEvents)}, 1, ${req.isGranted() ? teamName || null : ''}, ${req.isGranted() ? footerName || null : ''}, ${req.isGranted() ? footerAreas || null : ''}, ${ulid()} )`); } return profileId; }; router.post('/profile/save', handleErrorAsync(async (req, res) => { if (!req.user) { return res.status(401).json({ error: 'Unauthorised' }); } if (req.body.username && req.user.username !== req.body.username) { await auditLog(req, 'profile/username_mismatch', { intended: req.body.username, token: req.user.username }); return res.status(401).json({ error: 'Payload username does not match the token' }); } if (!Array.isArray(req.body.names)) { // service worker cache sends v1 requests req.body = upgradeToV2(req.body); } if (!Array.isArray(req.body.customFlags)) { // no idea WTF is happening here, but somehow we got values like {"0": ..., "1": ..., ...} req.body.customFlags = Object.values(req.body.customFlags); } const profile: SaveProfilePayload = req.body; if (profile.opinions.length > 10 || profile.names.length > 128 || profile.pronouns.length > 128 || profile.links.length > 128 || profile.customFlags.length > 128 || profile.circle.length > 16 || profile.words.filter((c) => c.values.length > 64).length > 0 || profile.sensitive.length > 16 || profile.events.length > 100 || profile.customEvents.length > 100 ) { await auditLog(req, 'profile/form_validation_failed', { ...req.body }); return res.status(400).json({ error: 'crud.validation.genericForm' }); } const opinions = cleanupOpinions(profile.opinions); const nameMaxLength = global.config.profile.editorEnabled && global.config.profile.longNames ? 256 : 32; const names = profile.names.map((p) => { return { ...p, value: p.value.substring(0, nameMaxLength) }; }); const pronouns = profile.pronouns.map((p) => { return { ...p, value: p.value.substring(0, 192) }; }); const description = profile.description.substring(0, 1024); const birthday = cleanupBirthday(profile.birthday || null); const links = profile.links.filter((x) => !!x && isValidLink(x)); const customFlags = profile.customFlags.filter((x) => x.name && (!x.link || isValidLink(x.link))).map((x) => { return { value: x.value, name: x.name.substring(0, 24), description: x.description ? x.description.substring(0, 512) : null, alt: x.alt ? x.alt.substring(0, 512) : null, link: x.link ? normaliseUrl(x.link) : null, }; }); const words = profile.words.map((c) => { return { ...c, values: c.values.map((p) => { return { ...p, value: p.value.substring(0, 32) }; }) }; }); const sensitive = profile.sensitive.filter((x) => !!x).map((x) => x.substring(0, 64)); const timezone = profile.timezone ? { tz: profile.timezone.tz, area: profile.timezone.area, loc: profile.timezone.loc, } : null; const markdown = !!profile.markdown; const events = profile.events.filter((x) => !!x && x.length < 256); const customEvents = profile.customEvents.filter((x) => { return x.name && x.name.length <= 24 && x.month && x.day && (x.comment || '').length <= 128; }); const profileId = await saveProfile(req, global.config.locale, { opinions, names, pronouns, description, birthday, timezone, links, events, customEvents, flags: profile.flags, customFlags, words, sensitive, markdown, teamName: profile.teamName, footerName: profile.footerName, footerAreas: (profile.footerAreas || []).join(','), credentials: (profile.credentials || []).join('|'), credentialsLevel: profile.credentialsLevel, credentialsName: profile.credentialsName, }); await req.db.get(SQL`DELETE FROM user_connections WHERE from_profileId = ${profileId}`); const usernameIdMap = await usernamesToIds(req.db, profile.circle.map((r) => r.username)); for (const connection of profile.circle) { const toUserId = usernameIdMap[normaliseWithLink(connection.username)]; const relationship = connection.relationship.substring(0, 64).trim(); if (toUserId === undefined || !relationship) { continue; } await req.db.get(SQL`INSERT INTO user_connections (id, from_profileId, to_userId, relationship) VALUES ( ${ulid()}, ${profileId}, ${toUserId}, ${relationship} )`); } if ((profile.propagate || []).includes('teamName')) { await req.db.get(SQL`UPDATE profiles SET teamName = ${req.isGranted() ? profile.teamName || null : ''} WHERE userId = ${req.user.id} AND teamName != '' AND teamName IS NOT NULL; `); } if ((profile.propagate || []).includes('footerName')) { await req.db.get(SQL`UPDATE profiles SET footerName = ${req.isGranted() ? profile.footerName || null : ''} WHERE userId = ${req.user.id} AND footerName != '' AND footerName IS NOT NULL; `); } if ((profile.propagate || []).includes('names')) { await req.db.get(SQL`UPDATE profiles SET names = ${JSON.stringify(profile.names)} WHERE userId = ${req.user.id}; `); } if ((profile.propagate || []).includes('flags')) { await req.db.get(SQL`UPDATE profiles SET flags = ${JSON.stringify(profile.flags)} WHERE userId = ${req.user.id}; `); } if ((profile.propagate || []).includes('customFlags')) { await req.db.get(SQL`UPDATE profiles SET customFlags = ${JSON.stringify(profile.customFlags)} WHERE userId = ${req.user.id}; `); } if ((profile.propagate || []).includes('links')) { await req.db.get(SQL`UPDATE profiles SET links = ${JSON.stringify(profile.links.filter((x) => !!x))} WHERE userId = ${req.user.id}; `); } if ((profile.propagate || []).includes('links')) { await req.db.get(SQL`UPDATE profiles SET links = ${JSON.stringify(profile.links.filter((x) => !!x))} WHERE userId = ${req.user.id}; `); } if ((profile.propagate || []).includes('birthday')) { await req.db.get(SQL`UPDATE profiles SET birthday = ${cleanupBirthday(profile.birthday || null)} WHERE userId = ${req.user.id}; `); } if ((profile.propagate || []).includes('timezone')) { await req.db.get(SQL`UPDATE profiles SET timezone = ${JSON.stringify(timezone)} WHERE userId = ${req.user.id}; `); } if ((profile.propagate || []).includes('events')) { await req.db.get(SQL`UPDATE profiles SET events = ${JSON.stringify(events)} WHERE userId = ${req.user.id}; `); } for (const url of links) { const normalizedUrl = normaliseUrl(url); if (!normalizedUrl) { continue; } await req.db.get(SQL`INSERT INTO links (url) VALUES (${normalizedUrl}) ON CONFLICT (url) DO UPDATE SET expiresAt = null`); } let sus = [...isSuspicious(profile)]; // keywords with a lot of false positives should only trigger when accompanied by at least one other keyword if (sus.length === 1 && mildSus.filter((k) => sus[0].startsWith(`${k} (`)).length > 0) { sus = []; } if (sus.length && !await hasAutomatedReports(req.db, req.user.id)) { await req.db.get(SQL` INSERT INTO reports (id, userId, reporterId, isAutomatic, comment, isHandled, snapshot) VALUES (${ulid()}, ${req.user.id}, null, 1, ${sus.join(', ')}, 0, ${await profilesSnapshot(req.db, normalise(req.user.username), new ProfileOptions({}))}); `); await auditLog(req, 'profile/triggered_report', { ...req.body }); } if (profile.teamName) { await caches.admins.invalidate(); await caches.adminsFooter.invalidate(); } await req.db.get(SQL`UPDATE users SET inactiveWarning = null WHERE id = ${req.user.id}`); await auditLog(req, 'profile/updated', { ...req.body, locale: global.config.locale }); return res.json(await fetchProfiles(req.db, req.user.username, true)); })); router.post('/profile/delete/:locale', handleErrorAsync(async (req, res) => { if (!req.user) { return res.status(400).json({ error: 'Missing user' }); } await req.db.get(SQL`DELETE FROM profiles WHERE userId = ${req.user.id} AND locale = ${req.params.locale}`); await auditLog(req, 'profile/deleted', { locale: req.params.locale }); return res.json(await fetchProfiles(req.db, req.user.username, true)); })); router.post('/profile/report/:username', handleErrorAsync(async (req, res) => { const user = await req.db.get>(SQL`SELECT id FROM users WHERE usernameNorm = ${normalise(req.params.username)}`); if (!user) { return res.status(400).json({ error: 'Missing user' }); } if (!req.body.comment) { return res.status(400).json({ error: 'Missing comment' }); } await req.db.get(SQL` INSERT INTO reports (id, userId, reporterId, isAutomatic, comment, isHandled, snapshot) VALUES (${ulid()}, ${user.id}, ${req.user!.id}, 0, ${req.body.comment}, 0, ${await profilesSnapshot(req.db, normalise(req.params.username))}); `); await auditLog(req, 'mod/reported', { userId: user.id, comment: req.body.comment, }); return res.json('OK'); })); router.post('/profile/request-card', handleErrorAsync(async (req, res) => { if (!req.user) { return res.status(400).json({ error: 'Missing user' }); } if (req.query.dark === '1') { await req.db.get(SQL` UPDATE profiles SET cardDark = '' WHERE userId=${req.user.id} AND locale=${global.config.locale} AND cardDark IS NULL `); } else { await req.db.get(SQL` UPDATE profiles SET card = '' WHERE userId=${req.user.id} AND locale=${global.config.locale} AND card IS NULL `); } await auditLog(req, 'profile/requested_card_image'); return res.json('OK'); })); router.get('/profile/has-card', handleErrorAsync(async (req, res) => { if (!req.user) { return res.status(400).json({ error: 'Missing user' }); } const card = await req.db.get(SQL` SELECT card, cardDark FROM profiles WHERE userId=${req.user.id} AND locale=${global.config.locale} `); return res.json(card); })); router.post('/profile/remove-self-circle/:username', handleErrorAsync(async (req, res) => { if (!req.user) { return res.status(401).json({ error: 'Unauthorised' }); } const user = await req.db.get>(SQL`SELECT id FROM users WHERE usernameNorm = ${normalise(req.params.username)}`); if (!user) { return res.status(400).json({ error: 'Missing user' }); } await req.db.get(SQL` DELETE FROM user_connections WHERE to_userId = ${req.user.id} AND from_profileId IN ( SELECT id FROM profiles WHERE userId = ${user.id} ) `); await auditLog(req, 'profile/removed_self_from_circle', { userId: user.id, }); return res.json('OK'); })); interface ProfileExportData { version: 1; profiles: Record; images: Record; } type ProfileExportProfile = Omit & Pick; router.get('/profile/export', handleErrorAsync(async (req: Request, res: Response) => { if (!req.user || req.user.bannedReason) { return res.status(401).json({ error: 'Unauthorised' }); } const profiles: ProfileExportData['profiles'] = {}; const customFlagIds: Set = new Set(); for (const profile of await req.db.all(SQL`SELECT * FROM profiles WHERE userId = ${req.user.id}`)) { const exportProfile: ProfileExportProfile = { names: JSON.parse(profile.names), pronouns: JSON.parse(profile.pronouns), description: profile.description, birthday: profile.birthday, links: JSON.parse(profile.links), flags: JSON.parse(profile.flags), words: JSON.parse(profile.words), customFlags: JSON.parse(profile.customFlags), opinions: JSON.parse(profile.opinions), timezone: JSON.parse(profile.timezone!), sensitive: JSON.parse(profile.sensitive), teamName: profile.teamName, footerName: profile.footerName, footerAreas: profile.footerAreas, credentials: profile.credentials, credentialsLevel: profile.credentialsLevel, credentialsName: profile.credentialsName, markdown: !!profile.markdown, events: JSON.parse(profile.events || '[]'), customEvents: JSON.parse(profile.customEvents || '[]'), }; for (const customFlag of exportProfile.customFlags) { customFlagIds.add(customFlag.value); } profiles[profile.locale] = exportProfile; } const s3 = new S3(awsConfig); const images: ProfileExportData['images'] = {}; for (const id of customFlagIds) { try { const data = await s3.getObject({ Key: `images/${id}-flag.png`, ...awsParams, }); images[id] = { flag: await data.Body!.transformToString('base64'), }; } catch (error) { Sentry.captureException(error); } } await auditLog(req, 'profile/exported', { profiles, }); const payload = Buffer.from(JSON.stringify({ version: 1, profiles, images, } satisfies ProfileExportData)).toString('base64'); const signature = crypto.sign(payload); res.setHeader('Content-disposition', `attachment; filename=pronounspage-${req.user.username}-${+new Date()}.card.gz`); res.setHeader('Content-type', 'application/gzip'); res.end(zlib.gzipSync(`${payload}\n${ signature}`)); })); router.post('/profile/import', multer({ limits: { fileSize: 10 * 1024 * 1024 } }).array('files', 1), handleErrorAsync(async (req, res) => { if (!req.user) { return res.status(401).json({ error: 'Unauthorised' }); } if (req.files.length !== 1) { return res.status(401).json({ error: 'One file expected' }); } const contentParts = zlib.gunzipSync((req.files as Express.Multer.File[])[0].buffer).toString('utf-8') .split('\n'); if (contentParts.length !== 2) { return res.status(401).json({ error: 'profile.backup.error.signature' }); } const [payload, signature] = contentParts; if (!crypto.validate(payload, signature)) { return res.status(400).json({ error: 'profile.backup.error.signature' }); } const { profiles, images } = JSON.parse(Buffer.from(payload, 'base64').toString('utf-8')) as ProfileExportData; const s3 = new S3(awsConfig); for (const [id, sizes] of Object.entries(images)) { for (const [size, content] of Object.entries(sizes)) { try { await s3.headObject({ Key: `images/${id}-${size}.png`, ...awsParams, }); continue; } catch (error) { if (!(error instanceof NoSuchKey)) { throw error; } } await s3.putObject({ Key: `images/${id}-${size}.png`, Body: Buffer.from(content, 'base64'), ContentType: 'image/png', ACL: 'public-read', ...awsParams, }); } } for (const [locale, profile] of Object.entries(profiles)) { await saveProfile(req, locale, profile); } return res.json('OK'); })); export default router;