mirror of
https://gitlab.com/PronounsPage/PronounsPage.git
synced 2025-09-23 04:34:15 -04:00
1168 lines
42 KiB
TypeScript
1168 lines
42 KiB
TypeScript
import fs from 'fs';
|
|
import zlib from 'node:zlib';
|
|
|
|
import { S3, NoSuchKey } from '@aws-sdk/client-s3';
|
|
import * as Sentry from '@sentry/node';
|
|
import { Router } from 'express';
|
|
import type { Request, Response } from 'express';
|
|
import { getH3Event } from 'h3-express';
|
|
import md5 from 'js-md5';
|
|
import type { ParsedQs } from 'qs';
|
|
import SQL from 'sql-template-strings';
|
|
import { ulid } from 'ulid';
|
|
|
|
import allLocales from '../../locale/locales.ts';
|
|
import type { LocaleDescription } from '../../locale/locales.ts';
|
|
import { birthdateRange, formatDate, parseDate } from '../../src/birthdate.ts';
|
|
import { handleErrorAsync, now, isValidLink } from '../../src/helpers.ts';
|
|
import { normaliseUrl } from '../../src/links.ts';
|
|
import type { Opinion } from '../../src/opinions.ts';
|
|
import { ProfileVisibility, applyProfileVisibilityRules } from '../../src/profile.ts';
|
|
import type {
|
|
LinkMetadata,
|
|
OpinionFormValue,
|
|
Profile,
|
|
ProfileV1,
|
|
RelatedPerson,
|
|
SaveProfilePayload,
|
|
ValueOpinion,
|
|
} from '../../src/profile.ts';
|
|
import { socialProviders } from '../../src/socialProviders.ts';
|
|
import { colours, styles } from '../../src/styling.ts';
|
|
import { auditLog } from '../audit.ts';
|
|
import avatar from '../avatar.ts';
|
|
import { awsConfig, awsParams } from '../aws.ts';
|
|
import crypto from '../crypto.ts';
|
|
import type { Database } from '../db.ts';
|
|
import { rootDir } from '../paths.ts';
|
|
import { downgradeToV1, upgradeToV2 } from '../profileV2.ts';
|
|
|
|
import type { AuthenticatorRow, UserRow } from './user.ts';
|
|
|
|
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;
|
|
visibility: ProfileVisibility;
|
|
}
|
|
|
|
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<AuthenticatorRow, 'type' | 'payload'>[],
|
|
username: string,
|
|
linksMetadata: Record<string, LinkMetadata>,
|
|
): Record<string, string> => {
|
|
const verifiedLinks: Record<string, string> = {};
|
|
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<string, ProfileProps> = {};
|
|
|
|
constructor(query_obj: ParsedQs, locales?: Record<string, LocaleDescription>) {
|
|
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();
|
|
const props = query_obj[`lprops[${key}]`];
|
|
if (props !== undefined) {
|
|
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<string, boolean> = {};
|
|
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<T>(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<Record<string, Partial<Profile>>> => {
|
|
const opts = optsArg ?? new ProfileOptions({});
|
|
const user = await db.get<Pick<UserRow, 'id'>>(SQL`SELECT id FROM users WHERE usernameNorm = ${normalise(username)}`);
|
|
if (!user) {
|
|
return {};
|
|
}
|
|
|
|
const profiles = await db.all<ProfileRow>(SQL`
|
|
SELECT profiles.*
|
|
FROM profiles
|
|
WHERE userId = ${user.id}
|
|
ORDER BY profiles.locale
|
|
`);
|
|
|
|
const linkAuthenticators = await db.all<Pick<AuthenticatorRow, 'type' | 'payload'>>(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<string, Partial<Profile>> = {};
|
|
for (const profile of profiles) {
|
|
if (!opts.show_at_all(profile.locale)) {
|
|
continue;
|
|
}
|
|
const propv = <T>(property: string, value: () => T): T | undefined => {
|
|
return opts.propv(profile.locale, property, value);
|
|
};
|
|
|
|
type LinkData = Partial<{ links: string[]; linksMetadata: Record<string, LinkMetadata>; verifiedLinks: Record<string, string> }>;
|
|
const link_data: LinkData = await propv('links', async () => {
|
|
const links = (JSON.parse(profile.links) as string[]).filter((l) => !!normaliseUrl(l));
|
|
const linksMetadata: Record<string, LinkMetadata> = {};
|
|
for (const link of await db.all<LinkRow>(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<Profile> = {
|
|
id: propv('id', () => profile.id),
|
|
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', () => {
|
|
return JSON.parse(profile.customEvents || '[]').map((customEvent: Partial<CustomEvent>) => {
|
|
return { icon: 'user', ...customEvent };
|
|
});
|
|
}),
|
|
visibility: profile.visibility,
|
|
access: true,
|
|
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<string> => {
|
|
return JSON.stringify(await fetchProfiles(db, username, true, opts), null, 4);
|
|
};
|
|
|
|
const susRegexes = fs.readFileSync(`${rootDir}/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<boolean> => {
|
|
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<Record<string, string>> => {
|
|
const users = await db.all<Pick<UserRow, 'id' | 'usernameNorm'>>(SQL`
|
|
SELECT id, usernameNorm
|
|
FROM users
|
|
WHERE users.usernameNorm IN (`.append(usernames.map((k) => `'${normaliseWithLink(k)}'`).join(',')).append(SQL`)`));
|
|
|
|
const idMap: Record<string, string> = {};
|
|
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<RelatedPerson[]> => {
|
|
type QueryResult = Pick<UserRow, 'id' | 'username' | 'avatarSource' | 'email'> & Pick<ProfileRow, 'locale'> & Pick<UserConnectionRow, 'relationship'>;
|
|
const connections = await db.all<QueryResult>(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<string, Omit<RelatedPerson, 'locale'> & { 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<Response> => {
|
|
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<string, Partial<Profile | ProfileV1>> = 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<Profile>);
|
|
}
|
|
}
|
|
|
|
for (const [locale, profile] of Object.entries(profiles)) {
|
|
profiles[locale] = applyProfileVisibilityRules(req.user, profile as Partial<Profile>, true);
|
|
}
|
|
|
|
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<Pick<ProfileRow, 'locale'>>(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<UserRow, 'username'> & Pick<ProfileRow, 'locale'> & Pick<UserConnectionRow, 'relationship'>;
|
|
const mentionsRaw = await db.all<QueryResult>(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<string, Record<string, string>> = {};
|
|
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<string, Opinion & { description: string }> = {};
|
|
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<Profile, 'linksMetadata' | 'verifiedLinks' | 'footerAreas' | 'credentials' |
|
|
'card' | 'cardDark' | 'circle' | 'markdown' | 'events' | 'customEvents' | 'lastUpdate' | 'id' | 'access'>
|
|
& Partial<Pick<Profile, 'markdown' | 'events' | 'customEvents'>>
|
|
& Pick<ProfileRow, 'footerAreas' | 'credentials'>;
|
|
|
|
const saveProfile = async (req: Request, locale: string, {
|
|
opinions, names, pronouns, description, birthday, timezone, links, flags, customFlags, words, sensitive,
|
|
teamName, footerName, footerAreas, credentials, credentialsLevel, credentialsName,
|
|
visibility,
|
|
markdown = false, events = [], customEvents = [],
|
|
}: SaveProfile) => {
|
|
// TODO just make it a transaction...
|
|
const ids = (await req.db.all<ProfileRow>(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},
|
|
visibility = ${visibility},
|
|
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, visibility, 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 : ''},
|
|
${visibility},
|
|
${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 ||
|
|
!Object.values(ProfileVisibility).includes(profile.visibility)
|
|
) {
|
|
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 && profile.timezone.tz
|
|
? {
|
|
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,
|
|
visibility: profile.visibility,
|
|
});
|
|
|
|
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};
|
|
`);
|
|
}
|
|
|
|
if ((profile.propagate || []).includes('visibility')) {
|
|
await req.db.get(SQL`UPDATE profiles
|
|
SET visibility = ${profile.visibility}
|
|
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 invalidateCache('admin-list');
|
|
await invalidateCache('admin-footer');
|
|
}
|
|
|
|
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<Pick<UserRow, 'id'>>(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<Pick<UserRow, 'id'>>(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<string, ProfileExportProfile>;
|
|
images: Record<string, { flag: string }>;
|
|
}
|
|
|
|
type ProfileExportProfile = Omit<Profile, 'linksMetadata' | 'verifiedLinks' | 'footerAreas' | 'credentials' |
|
|
'card' | 'cardDark' | 'circle' | 'lastUpdate' | 'id' | 'access'>
|
|
& Pick<ProfileRow, 'footerAreas' | 'credentials'>;
|
|
|
|
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<string> = new Set();
|
|
for (const profile of await req.db.all<ProfileRow>(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 || '[]'),
|
|
visibility: profile.visibility,
|
|
};
|
|
|
|
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', handleErrorAsync(async (req, res) => {
|
|
if (!req.user) {
|
|
return res.status(401).json({ error: 'Unauthorised' });
|
|
}
|
|
|
|
const files = await readMultipartFormData(getH3Event(req));
|
|
|
|
if (!files || files.length !== 1) {
|
|
return res.status(401).json({ error: 'One file expected' });
|
|
}
|
|
|
|
const contentParts = zlib.gunzipSync(files[0].data as zlib.InputType).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;
|