import Router from '@koa/router'; import { FindOptionsWhere, IsNull } from 'typeorm'; import config from '@/config/index.js'; import * as Acct from '@/misc/acct.js'; import { escapeAttribute, escapeValue } from '@/prelude/xml.js'; import { Users } from '@/models/index.js'; import { User } from '@/models/entities/user.js'; import { links } from './nodeinfo.js'; import { oauthMeta } from './oauth.js'; // 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 }; }); function oauth(ctx) { ctx.body = oauthMeta; ctx.type = 'application/json'; ctx.set('Cache-Control', 'max-age=31536000, immutable'); } // implements RFC 8414 router.get('/.well-known/oauth-authorization-server', oauth); // From the above RFC: //> The identifiers "/.well-known/openid-configuration" [...] contain strings //> referring to the OpenID Connect family of specifications [...]. Despite the reuse //> of these identifiers that appear to be OpenID specific, their usage in this //> specification is actually referring to general OAuth 2.0 features that are not //> specific to OpenID Connect. router.get('/.well-known/openid-configuration', oauth); router.get(webFingerPath, async ctx => { const fromAcct = (acct_str: string): FindOptionsWhere | number => { const acct = Acct.parse(acct_str); if (!acct.host || acct.host === config.host.toLowerCase()) { return { usernameLower: acct.username, host: IsNull(), isSuspended: false, }; } else { return 422; } }; const generateQuery = (resource: string): FindOptionsWhere | number => { if (resource.startsWith(`${config.url.toLowerCase()}/users/`)) { return { id: resource.split('/').pop()!, host: IsNull(), isSuspended: false, }; } else if (resource.startsWith(`${config.url.toLowerCase()}/@`)) { return fromAcct(resource.split('/').pop()!); } else if (resource.startsWith("acct:")) { return fromAcct(resource.slice('acct:'.length)); } return fromAcct(resource); }; let resource = ctx.query.resource; if (typeof resource !== 'string') { ctx.status = 400; return; } resource = resource.toLowerCase(); // Mastodon sometimes only checks for the domain name, // which should be rewritten to the instance actor. if (`https://${config.host}`.toLowerCase() === resource) { resource = `acct:instance.actor@${config.host}`; } const query = generateQuery(resource); 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;