import Router from '@koa/router'; import config from '@/config/index.js'; import * as Acct from '@/misc/acct.js'; import { links } from './nodeinfo.js'; import { escapeAttribute, escapeValue } from '@/prelude/xml.js'; import { Users } from '@/models/index.js'; import { User } from '@/models/entities/user.js'; import { FindOptionsWhere, IsNull } from 'typeorm'; // Init router const router = new Router(); const XRD = (...x: { element: string, value?: string, attributes?: Record }[]) => `${x.map(({ element, value, attributes }) => `<${ Object.entries(typeof attributes === 'object' && attributes || {}).reduce((a, [k, v]) => `${a} ${k}="${escapeAttribute(v)}"`, element) }${ typeof value === 'string' ? `>${escapeValue(value)}`).reduce((a, c) => a + c, '')}`; const allPath = '/.well-known/(.*)'; const webFingerPath = '/.well-known/webfinger'; const jrd = 'application/jrd+json'; const xrd = 'application/xrd+xml'; router.use(allPath, async (ctx, next) => { ctx.set({ 'Access-Control-Allow-Headers': 'Accept', 'Access-Control-Allow-Methods': 'GET, OPTIONS', 'Access-Control-Allow-Origin': '*', 'Access-Control-Expose-Headers': 'Vary', }); await next(); }); router.options(allPath, async ctx => { ctx.status = 204; }); router.get('/.well-known/host-meta', async ctx => { ctx.set('Content-Type', xrd); ctx.body = XRD({ element: 'Link', attributes: { rel: 'lrdd', type: xrd, template: `${config.url}${webFingerPath}?resource={uri}`, } }); }); router.get('/.well-known/host-meta.json', async ctx => { ctx.set('Content-Type', jrd); ctx.body = { links: [{ rel: 'lrdd', type: jrd, template: `${config.url}${webFingerPath}?resource={uri}`, }], }; }); router.get('/.well-known/nodeinfo', async ctx => { ctx.body = { links }; }); /* TODO router.get('/.well-known/change-password', async ctx => { }); */ router.get(webFingerPath, async ctx => { const fromId = (id: User['id']): FindOptionsWhere => ({ id, host: IsNull(), isSuspended: false, }); const generateQuery = (resource: string): FindOptionsWhere | number => resource.startsWith(`${config.url.toLowerCase()}/users/`) ? fromId(resource.split('/').pop()!) : fromAcct(Acct.parse( resource.startsWith(`${config.url.toLowerCase()}/@`) ? resource.split('/').pop()! : resource.startsWith('acct:') ? resource.slice('acct:'.length) : resource)); const fromAcct = (acct: Acct.Acct): FindOptionsWhere | number => !acct.host || acct.host === config.host.toLowerCase() ? { usernameLower: acct.username, host: IsNull(), isSuspended: false, } : 422; if (typeof ctx.query.resource !== 'string') { ctx.status = 400; return; } const query = generateQuery(ctx.query.resource.toLowerCase()); if (typeof query === 'number') { ctx.status = query; return; } const user = await Users.findOneBy(query); if (user == null) { ctx.status = 404; return; } const subject = `acct:${user.username}@${config.host}`; const self = { rel: 'self', type: 'application/activity+json', href: `${config.url}/users/${user.id}`, }; const profilePage = { rel: 'http://webfinger.net/rel/profile-page', type: 'text/html', href: `${config.url}/@${user.username}`, }; const subscribe = { rel: 'http://ostatus.org/schema/1.0/subscribe', template: `${config.url}/authorize-follow?acct={uri}`, }; if (ctx.accepts(jrd, xrd) === xrd) { ctx.body = XRD( { element: 'Subject', value: subject }, { element: 'Link', attributes: self }, { element: 'Link', attributes: profilePage }, { element: 'Link', attributes: subscribe }); ctx.type = xrd; } else { ctx.body = { subject, links: [self, profilePage, subscribe], }; ctx.type = jrd; } ctx.vary('Accept'); ctx.set('Cache-Control', 'public, max-age=180'); }); // Return 404 for other .well-known router.all(allPath, async ctx => { ctx.status = 404; }); export default router;