Add channel webfinger support
Uses non-standard `grp` schema
This commit is contained in:
parent
a08f1a990a
commit
dc31a8f16c
5 changed files with 196 additions and 55 deletions
packages/backend/src
14
packages/backend/src/misc/grp.ts
Normal file
14
packages/backend/src/misc/grp.ts
Normal file
|
@ -0,0 +1,14 @@
|
|||
export type Grp = {
|
||||
name: string;
|
||||
host: string | null;
|
||||
};
|
||||
|
||||
export function parse(grp: string): Grp {
|
||||
if (grp.startsWith('+')) grp = grp.substr(1);
|
||||
const split = grp.split('@', 2);
|
||||
return { name: split[0], host: split[1] || null };
|
||||
}
|
||||
|
||||
export function toString(grp: Grp): string {
|
||||
return grp.host == null ? grp.name : `${grp.name}@${grp.host}`;
|
||||
}
|
68
packages/backend/src/remote/resolve-channel.ts
Normal file
68
packages/backend/src/remote/resolve-channel.ts
Normal file
|
@ -0,0 +1,68 @@
|
|||
import { URL } from 'node:url';
|
||||
import webFinger from './webfinger.js';
|
||||
import config from '@/config/index.js';
|
||||
import { createGroup, updateGroup } from './activitypub/models/group.js';
|
||||
import { remoteLogger } from './logger.js';
|
||||
import chalk from 'chalk';
|
||||
import { Channel } from '@/models/entities/channel.js';
|
||||
import { Channels } from '@/models/index.js';
|
||||
import { toPuny } from '@/misc/convert-host.js';
|
||||
import { IsNull } from 'typeorm';
|
||||
|
||||
const logger = remoteLogger.createSubLogger('resolve');
|
||||
|
||||
export async function resolveChannel(channelName: string, host: string | null): Promise<Channel> {
|
||||
const nameLower = channelName.toLowerCase();
|
||||
|
||||
if (host == null) {
|
||||
logger.info(`return local channel: ${nameLower}`);
|
||||
return await Channels.findOneBy({ nameLower, host: IsNull() }).then(u => {
|
||||
if (u == null) {
|
||||
throw new Error('channel not found');
|
||||
} else {
|
||||
return u;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
host = toPuny(host);
|
||||
|
||||
if (config.host === host) {
|
||||
logger.info(`return local channel: ${nameLower}`);
|
||||
return await Channels.findOneBy({ nameLower, host: IsNull() }).then(u => {
|
||||
if (u == null) {
|
||||
throw new Error('channel not found');
|
||||
} else {
|
||||
return u;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const channel = await Channels.findOneBy({ nameLower, host }) | null;
|
||||
|
||||
const identifier = `${nameLower}@${host}`;
|
||||
|
||||
if (channel == null) {
|
||||
const self = await resolveSelf(identifier);
|
||||
|
||||
logger.succ(`return new remote channel: ${chalk.magenta(nameLower)}`);
|
||||
return await createGroup(self.href);
|
||||
}
|
||||
|
||||
logger.info(`return existing remote user: ${nameLower}`);
|
||||
return channel;
|
||||
}
|
||||
|
||||
async function resolveSelf(nameLower: string) {
|
||||
logger.info(`WebFinger for ${chalk.yellow(nameLower)}`);
|
||||
const finger = await webFinger(nameLower, 'grp').catch(e => {
|
||||
logger.error(`Failed to WebFinger for ${chalk.yellow(nameLower)}: ${ e.statusCode || e.message }`);
|
||||
throw new Error(`Failed to WebFinger for ${nameLower}: ${ e.statusCode || e.message }`);
|
||||
});
|
||||
const self = finger.links.find(link => link.rel != null && link.rel.toLowerCase() === 'self');
|
||||
if (!self) {
|
||||
logger.error(`Failed to WebFinger for ${chalk.yellow(nameLower)}: self link not found`);
|
||||
throw new Error('self link not found');
|
||||
}
|
||||
return self;
|
||||
}
|
|
@ -9,7 +9,7 @@ import { Users } from '@/models/index.js';
|
|||
import { toPuny } from '@/misc/convert-host.js';
|
||||
import { IsNull } from 'typeorm';
|
||||
|
||||
const logger = remoteLogger.createSubLogger('resolve-user');
|
||||
const logger = remoteLogger.createSubLogger('resolve');
|
||||
|
||||
export async function resolveUser(username: string, host: string | null): Promise<User> {
|
||||
const usernameLower = username.toLowerCase();
|
||||
|
@ -98,7 +98,7 @@ export async function resolveUser(username: string, host: string | null): Promis
|
|||
|
||||
async function resolveSelf(acctLower: string) {
|
||||
logger.info(`WebFinger for ${chalk.yellow(acctLower)}`);
|
||||
const finger = await webFinger(acctLower).catch(e => {
|
||||
const finger = await webFinger(acctLower, 'acct').catch(e => {
|
||||
logger.error(`Failed to WebFinger for ${chalk.yellow(acctLower)}: ${ e.statusCode || e.message }`);
|
||||
throw new Error(`Failed to WebFinger for ${acctLower}: ${ e.statusCode || e.message }`);
|
||||
});
|
||||
|
|
|
@ -12,13 +12,13 @@ type IWebFinger = {
|
|||
subject: string;
|
||||
};
|
||||
|
||||
export default async function(query: string): Promise<IWebFinger> {
|
||||
const url = genUrl(query);
|
||||
export default async function(query: string, schema: string): Promise<IWebFinger> {
|
||||
const url = genUrl(query, schema);
|
||||
|
||||
return await getJson(url, 'application/jrd+json, application/json') as IWebFinger;
|
||||
}
|
||||
|
||||
function genUrl(query: string) {
|
||||
function genUrl(query: string, schema: string) {
|
||||
if (query.match(/^https?:\/\//)) {
|
||||
const u = new URL(query);
|
||||
return `${u.protocol}//${u.hostname}/.well-known/webfinger?` + urlQuery({ resource: query });
|
||||
|
@ -27,7 +27,7 @@ function genUrl(query: string) {
|
|||
const m = query.match(/^([^@]+)@(.*)/);
|
||||
if (m) {
|
||||
const hostname = m[2];
|
||||
return `https://${hostname}/.well-known/webfinger?` + urlQuery({ resource: `acct:${query}` });
|
||||
return `https://${hostname}/.well-known/webfinger?` + urlQuery({ resource: `${schema}:${query}` });
|
||||
}
|
||||
|
||||
throw new Error(`Invalid query (${query})`);
|
||||
|
|
|
@ -2,10 +2,12 @@ import Router from '@koa/router';
|
|||
|
||||
import config from '@/config/index.js';
|
||||
import * as Acct from '@/misc/acct.js';
|
||||
import * as Grp from '@/misc/grp.js';
|
||||
import { links } from './nodeinfo.js';
|
||||
import { escapeAttribute, escapeValue } from '@/prelude/xml.js';
|
||||
import { Users } from '@/models/index.js';
|
||||
import { Users, Channels } from '@/models/index.js';
|
||||
import { User } from '@/models/entities/user.js';
|
||||
import { Channel } from '@/models/entities/channel.js';
|
||||
import { FindOptionsWhere, IsNull } from 'typeorm';
|
||||
|
||||
// Init router
|
||||
|
@ -66,75 +68,132 @@ router.get('/.well-known/change-password', async ctx => {
|
|||
});
|
||||
*/
|
||||
|
||||
router.get(webFingerPath, async ctx => {
|
||||
const fromId = (id: User['id']): FindOptionsWhere<User> => ({
|
||||
const lookupUserById = (id: string) => {
|
||||
const query = {
|
||||
id,
|
||||
host: IsNull(),
|
||||
isSuspended: false,
|
||||
});
|
||||
} as FindOptionsWhere<User>;
|
||||
return lookupUser(query);
|
||||
};
|
||||
|
||||
const generateQuery = (resource: string): FindOptionsWhere<User> | 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 lookupUserByName = (resource: string) => {
|
||||
const acct = Acct.parse(resource);
|
||||
if (acct.host && acct.host !== config.host.toLowerCase()) {
|
||||
return 422;
|
||||
};
|
||||
const query = {
|
||||
usernameLower: acct.username,
|
||||
host: IsNull(),
|
||||
isSuspended: false,
|
||||
} as FindOptionsWhere<User>;
|
||||
return lookupUser(query);
|
||||
};
|
||||
|
||||
const fromAcct = (acct: Acct.Acct): FindOptionsWhere<User> | number =>
|
||||
!acct.host || acct.host === config.host.toLowerCase() ? {
|
||||
usernameLower: acct.username,
|
||||
host: IsNull(),
|
||||
isSuspended: false,
|
||||
} : 422;
|
||||
const lookupUser = async (query: FindOptionsWhere<User>) => {
|
||||
const user = await Users.findOneBy(query);
|
||||
|
||||
if (user == null) {
|
||||
return 404;
|
||||
}
|
||||
|
||||
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}`,
|
||||
};
|
||||
|
||||
return { subject, links: [self, profilePage, subscribe] };
|
||||
};
|
||||
|
||||
|
||||
const lookupChannelById = (id: string) => {
|
||||
const query = {
|
||||
id,
|
||||
host: IsNull(),
|
||||
} as FindOptionsWhere<Channel>;
|
||||
return lookupChannel(query);
|
||||
};
|
||||
|
||||
const lookupChannelByName = (resource: string) => {
|
||||
const grp = Grp.parse(resource);
|
||||
if (grp.host && grp.host !== config.host.toLowerCase()) {
|
||||
return 422;
|
||||
};
|
||||
const query = {
|
||||
slug: grp.name,
|
||||
host: IsNull(),
|
||||
} as FindOptionsWhere<Channel>;
|
||||
return lookupChannel(query);
|
||||
};
|
||||
|
||||
const lookupChannel = async (query: FindOptionsWhere<Channel>) => {
|
||||
const channel = await Channels.findOneBy(query);
|
||||
|
||||
if (channel == null) {
|
||||
return 404;
|
||||
}
|
||||
|
||||
const subject = `grp:${channel.slug}@${config.host}`;
|
||||
const self = {
|
||||
rel: 'self',
|
||||
type: 'application/activity+json',
|
||||
href: `${config.url}/channel/${channel.id}`,
|
||||
};
|
||||
const profilePage = {
|
||||
rel: 'http://webfinger.net/rel/profile-page',
|
||||
type: 'text/html',
|
||||
href: `${config.url}/+${channel.slug}`,
|
||||
};
|
||||
|
||||
return { subject, links: [self, profilePage] };
|
||||
};
|
||||
|
||||
router.get(webFingerPath, async ctx => {
|
||||
if (typeof ctx.query.resource !== 'string') {
|
||||
ctx.status = 400;
|
||||
return;
|
||||
}
|
||||
|
||||
const query = generateQuery(ctx.query.resource.toLowerCase());
|
||||
const resource = ctx.query.resource.toLowerCase();
|
||||
|
||||
if (typeof query === 'number') {
|
||||
ctx.status = query;
|
||||
return;
|
||||
let elements: object | number;
|
||||
if (resource.startsWith(`${config.url.toLowerCase()}/users/`) || resource.startsWith(`${config.url.toLowerCase()}/@`)) {
|
||||
elements = await lookupUserById(resource.split('/').pop()!);
|
||||
} else if (resource.startsWith('acct:')) {
|
||||
elements = await lookupUserByName(resource.slice('acct:'.length));
|
||||
} else if (resource.startsWith(`${config.url.toLowerCase()}/channel/`) || resource.startsWith(`${config.url.toLowerCase()}/+`)) {
|
||||
elements = await lookupChannelById(resource.split('/').pop()!);
|
||||
} else if (resource.startsWith('grp:')) {
|
||||
elements = await lookupChannelByName(resource.slice('grp:'.length));
|
||||
} else {
|
||||
elements = 400;
|
||||
}
|
||||
|
||||
const user = await Users.findOneBy(query);
|
||||
|
||||
if (user == null) {
|
||||
ctx.status = 404;
|
||||
if (typeof elements === 'number') {
|
||||
ctx.status = elements;
|
||||
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 });
|
||||
{ element: 'Subject', value: elements.subject },
|
||||
...elements.links.map((link) => ({
|
||||
element: 'Link', attributes: link,
|
||||
})));
|
||||
ctx.type = xrd;
|
||||
} else {
|
||||
ctx.body = {
|
||||
subject,
|
||||
links: [self, profilePage, subscribe],
|
||||
};
|
||||
ctx.body = elements;
|
||||
ctx.type = jrd;
|
||||
}
|
||||
|
||||
|
|
Loading…
Add table
Reference in a new issue