* pub-relay

* relay actorをApplicationにする

* Disable koa-compress

* Homeはリレーに送らない

* Disable debug

* UI

* cleanupなど
This commit is contained in:
MeiMei 2020-05-10 18:42:31 +09:00 committed by GitHub
parent ddb7d90bd1
commit fe841db55a
27 changed files with 510 additions and 12 deletions

View File

@ -502,6 +502,10 @@ sidebar: "サイドバー"
divider: "分割線"
addItem: "項目を追加"
rooms: "ルーム"
relays: "リレー"
addRelay: "リレーの追加"
inboxUrl: "inboxのURL"
addedRelays: "追加済みのリレー"
_theme:
explore: "テーマを探す"
@ -1090,3 +1094,8 @@ _pages:
enviromentVariables: "環境変数"
pageVariables: "ページ要素"
argVariables: "入力スロット"
_relayStatus:
requesting: "承認待ち"
accepted: "承認済み"
rejected: "拒否済み"

View File

@ -0,0 +1,18 @@
import {MigrationInterface, QueryRunner} from "typeorm";
export class pubRelay1589023282116 implements MigrationInterface {
name = 'pubRelay1589023282116'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE TYPE "relay_status_enum" AS ENUM('requesting', 'accepted', 'rejected')`, undefined);
await queryRunner.query(`CREATE TABLE "relay" ("id" character varying(32) NOT NULL, "inbox" character varying(512) NOT NULL, "status" "relay_status_enum" NOT NULL, CONSTRAINT "PK_78ebc9cfddf4292633b7ba57aee" PRIMARY KEY ("id"))`, undefined);
await queryRunner.query(`CREATE UNIQUE INDEX "IDX_0d9a1738f2cf7f3b1c3334dfab" ON "relay" ("inbox") `, undefined);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX "IDX_0d9a1738f2cf7f3b1c3334dfab"`, undefined);
await queryRunner.query(`DROP TABLE "relay"`, undefined);
await queryRunner.query(`DROP TYPE "relay_status_enum"`, undefined);
}
}

View File

@ -132,7 +132,7 @@
<script lang="ts">
import Vue from 'vue';
import { faGripVertical, faChevronLeft, faHashtag, faBroadcastTower, faFireAlt, faEllipsisH, faPencilAlt, faBars, faTimes, faSearch, faUserCog, faCog, faUser, faHome, faStar, faCircle, faAt, faListUl, faPlus, faUserClock, faUsers, faTachometerAlt, faExchangeAlt, faGlobe, faChartBar, faCloud, faServer, faInfoCircle, faQuestionCircle } from '@fortawesome/free-solid-svg-icons';
import { faGripVertical, faChevronLeft, faHashtag, faBroadcastTower, faFireAlt, faEllipsisH, faPencilAlt, faBars, faTimes, faSearch, faUserCog, faCog, faUser, faHome, faStar, faCircle, faAt, faListUl, faPlus, faUserClock, faUsers, faTachometerAlt, faExchangeAlt, faGlobe, faChartBar, faCloud, faServer, faInfoCircle, faQuestionCircle, faProjectDiagram } from '@fortawesome/free-solid-svg-icons';
import { faBell, faEnvelope, faLaugh, faComments } from '@fortawesome/free-regular-svg-icons';
import { ResizeObserver } from '@juggle/resize-observer';
import { v4 as uuid } from 'uuid';
@ -169,7 +169,7 @@ export default Vue.extend({
isDesktop: window.innerWidth >= DESKTOP_THRESHOLD,
canBack: false,
wallpaper: localStorage.getItem('wallpaper') != null,
faGripVertical, faChevronLeft, faComments, faHashtag, faBroadcastTower, faFireAlt, faEllipsisH, faPencilAlt, faBars, faTimes, faBell, faSearch, faUserCog, faCog, faUser, faHome, faStar, faCircle, faAt, faEnvelope, faListUl, faPlus, faUserClock, faLaugh, faUsers, faTachometerAlt, faExchangeAlt, faGlobe, faChartBar, faCloud, faServer
faGripVertical, faChevronLeft, faComments, faHashtag, faBroadcastTower, faFireAlt, faEllipsisH, faPencilAlt, faBars, faTimes, faBell, faSearch, faUserCog, faCog, faUser, faHome, faStar, faCircle, faAt, faEnvelope, faListUl, faPlus, faUserClock, faLaugh, faUsers, faTachometerAlt, faExchangeAlt, faGlobe, faChartBar, faCloud, faServer, faProjectDiagram
};
},
@ -413,6 +413,11 @@ export default Vue.extend({
text: this.$t('federation'),
to: '/instance/federation',
icon: faGlobe,
}, {
type: 'link',
text: this.$t('relays'),
to: '/instance/relays',
icon: faProjectDiagram,
}, {
type: 'link',
text: this.$t('announcements'),

View File

@ -0,0 +1,93 @@
<template>
<div class="relaycxt">
<portal to="icon"><fa :icon="faProjectDiagram"/></portal>
<portal to="title">{{ $t('relays') }}</portal>
<section class="_card add">
<div class="_title"><fa :icon="faPlus"/> {{ $t('addRelay') }}</div>
<div class="_content">
<mk-input v-model="inbox">
<span>{{ $t('inboxUrl') }}</span>
</mk-input>
<mk-button @click="add(inbox)" primary><fa :icon="faPlus"/> {{ $t('add') }}</mk-button>
</div>
</section>
<section class="_card relays">
<div class="_title"><fa :icon="faProjectDiagram"/> {{ $t('addedRelays') }}</div>
<div class="_content relay" v-for="relay in relays" :key="relay.inbox">
<div>{{ relay.inbox }}</div>
<div>{{ $t(`_relayStatus.${relay.status}`) }}</div>
<mk-button class="button" inline @click="remove(relay.inbox)"><fa :icon="faTrashAlt"/> {{ $t('remove') }}</mk-button>
</div>
</section>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import { faPlus, faProjectDiagram } from '@fortawesome/free-solid-svg-icons';
import { faSave, faTrashAlt } from '@fortawesome/free-regular-svg-icons';
import i18n from '../../i18n';
import MkButton from '../../components/ui/button.vue';
import MkInput from '../../components/ui/input.vue';
export default Vue.extend({
i18n,
metaInfo() {
return {
title: this.$t('relays') as string
};
},
components: {
MkButton,
MkInput,
},
data() {
return {
relays: [],
inbox: '',
faPlus, faProjectDiagram, faSave, faTrashAlt
}
},
created() {
this.refresh();
},
methods: {
add(inbox: string) {
this.$root.api('admin/relays/add', {
inbox
}).then((relay: any) => {
this.refresh();
});
},
remove(inbox: string) {
this.$root.api('admin/relays/remove', {
inbox
}).then(() => {
this.refresh();
});
},
refresh() {
this.$root.api('admin/relays/list').then((relays: any) => {
this.relays = relays;
});
}
}
});
</script>
<style lang="scss" scoped>
._content.relay {
div {
margin: 0.5em 0;
}
}
</style>

View File

@ -58,6 +58,7 @@ export const router = new VueRouter({
{ path: '/instance/queue', component: page('instance/queue') },
{ path: '/instance/settings', component: page('instance/settings') },
{ path: '/instance/federation', component: page('instance/federation') },
{ path: '/instance/relays', component: page('instance/relays') },
{ path: '/instance/announcements', component: page('instance/announcements') },
{ path: '/notes/:note', name: 'note', component: page('note') },
{ path: '/tags/:tag', component: page('tag') },

View File

@ -58,6 +58,7 @@ import { AntennaNote } from '../models/entities/antenna-note';
import { PromoNote } from '../models/entities/promo-note';
import { PromoRead } from '../models/entities/promo-read';
import { program } from '../argv';
import { Relay } from '../models/entities/relay';
const sqlLogger = dbLogger.createSubLogger('sql', 'white', false);
@ -149,6 +150,7 @@ export const entities = [
PromoRead,
ReversiGame,
ReversiMatching,
Relay,
...charts as any
];

36
src/misc/gen-key-pair.ts Normal file
View File

@ -0,0 +1,36 @@
import * as crypto from 'crypto';
import * as util from 'util';
const generateKeyPair = util.promisify(crypto.generateKeyPair);
export async function genRsaKeyPair(modulusLength = 2048) {
return await generateKeyPair('rsa', {
modulusLength,
publicKeyEncoding: {
type: 'spki',
format: 'pem'
},
privateKeyEncoding: {
type: 'pkcs8',
format: 'pem',
cipher: undefined,
passphrase: undefined
}
});
}
export async function genEcKeyPair(namedCurve: 'prime256v1' | 'secp384r1' | 'secp521r1' | 'curve25519' = 'prime256v1') {
return await generateKeyPair('ec', {
namedCurve,
publicKeyEncoding: {
type: 'spki',
format: 'pem'
},
privateKeyEncoding: {
type: 'pkcs8',
format: 'pem',
cipher: undefined,
passphrase: undefined
}
});
}

View File

@ -0,0 +1,19 @@
import { PrimaryColumn, Entity, Index, Column } from 'typeorm';
import { id } from '../id';
@Entity()
export class Relay {
@PrimaryColumn(id())
public id: string;
@Index({ unique: true })
@Column('varchar', {
length: 512, nullable: false,
})
public inbox: string;
@Column('enum', {
enum: ['requesting', 'accepted', 'rejected'],
})
public status: 'requesting' | 'accepted' | 'rejected';
}

View File

@ -52,6 +52,7 @@ import { AntennaNote } from './entities/antenna-note';
import { PromoNote } from './entities/promo-note';
import { PromoRead } from './entities/promo-read';
import { EmojiRepository } from './repositories/emoji';
import { RelayRepository } from './repositories/relay';
export const Announcements = getRepository(Announcement);
export const AnnouncementReads = getRepository(AnnouncementRead);
@ -106,3 +107,4 @@ export const Antennas = getCustomRepository(AntennaRepository);
export const AntennaNotes = getRepository(AntennaNote);
export const PromoNotes = getRepository(PromoNote);
export const PromoReads = getRepository(PromoRead);
export const Relays = getCustomRepository(RelayRepository);

View File

@ -0,0 +1,6 @@
import { EntityRepository, Repository } from 'typeorm';
import { Relay } from '../entities/relay';
@EntityRepository(Relay)
export class RelayRepository extends Repository<Relay> {
}

View File

@ -56,12 +56,10 @@ export default async (job: Bull.Job<InboxJobData>): Promise<string> => {
}
// HTTP-Signatureの検証
if (!httpSignature.verifySignature(signature, authUser.key.keyPem)) {
return 'signature verification failed';
}
const httpSignatureValidated = httpSignature.verifySignature(signature, authUser.key.keyPem);
// signatureのsignerは、activity.actorと一致する必要がある
if (authUser.user.uri !== activity.actor) {
// また、signatureのsignerは、activity.actorと一致する必要がある
if (!httpSignatureValidated || authUser.user.uri !== activity.actor) {
// 一致しなくても、でもLD-Signatureがありそうならそっちも見る
if (activity.signature) {
if (activity.signature.type !== 'RsaSignature2017') {
@ -93,7 +91,7 @@ export default async (job: Bull.Job<InboxJobData>): Promise<string> => {
return `skip: LD-Signature user(${authUser.user.uri}) !== activity.actor(${activity.actor})`;
}
} else {
return 'signature verification failed';
throw `skip: http-signature verification failed.`;
}
}

View File

@ -2,6 +2,7 @@ import { IRemoteUser } from '../../../../models/entities/user';
import accept from '../../../../services/following/requests/accept';
import { IFollow } from '../../type';
import DbResolver from '../../db-resolver';
import { relayAccepted } from '../../../../services/relay';
export default async (actor: IRemoteUser, activity: IFollow): Promise<string> => {
// ※ activityはこっちから投げたフォローリクエストなので、activity.actorは存在するローカルユーザーである必要がある
@ -17,6 +18,12 @@ export default async (actor: IRemoteUser, activity: IFollow): Promise<string> =>
return `skip: follower is not a local user`;
}
// relay
const match = activity.id?.match(/follow-relay\/(\w+)/);
if (match) {
return await relayAccepted(match[1]);
}
await accept(actor, follower);
return `ok`;
};

View File

@ -2,6 +2,7 @@ import { IRemoteUser } from '../../../../models/entities/user';
import reject from '../../../../services/following/requests/reject';
import { IFollow } from '../../type';
import DbResolver from '../../db-resolver';
import { relayRejected } from '../../../../services/relay';
export default async (actor: IRemoteUser, activity: IFollow): Promise<string> => {
// ※ activityはこっちから投げたフォローリクエストなので、activity.actorは存在するローカルユーザーである必要がある
@ -17,6 +18,12 @@ export default async (actor: IRemoteUser, activity: IFollow): Promise<string> =>
return `skip: follower is not a local user`;
}
// relay
const match = activity.id?.match(/follow-relay\/(\w+)/);
if (match) {
return await relayRejected(match[1]);
}
await reject(actor, follower);
return `ok`;
};

View File

@ -70,6 +70,7 @@ export class LdSignature {
const transformedData = { ...data };
delete transformedData['signature'];
const cannonidedData = await this.normalize(transformedData);
if (this.debug) console.debug(`cannonidedData: ${cannonidedData}`);
const documentHash = this.sha256(cannonidedData);
const verifyData = `${optionsHash}${documentHash}`;
return verifyData;

View File

@ -0,0 +1,14 @@
import config from '../../../config';
import { Relay } from '../../../models/entities/relay';
import { ILocalUser } from '../../../models/entities/user';
export function renderFollowRelay(relay: Relay, relayActor: ILocalUser) {
const follow = {
id: `${config.url}/activities/follow-relay/${relay.id}`,
type: 'Follow',
actor: `${config.url}/users/${relayActor.id}`,
object: 'https://www.w3.org/ns/activitystreams#Public'
};
return follow;
}

View File

@ -1,7 +1,12 @@
import config from '../../../config';
import { v4 as uuid } from 'uuid';
import { IActivity } from '../type';
import { LdSignature } from '../misc/ld-signature';
import { ILocalUser } from '../../../models/entities/user';
import { UserKeypairs } from '../../../models';
import { ensure } from '../../../prelude/ensure';
export const renderActivity = (x: any) => {
export const renderActivity = (x: any): IActivity | null => {
if (x == null) return null;
if (x !== null && typeof x === 'object' && x.id == null) {
@ -11,8 +16,46 @@ export const renderActivity = (x: any) => {
return Object.assign({
'@context': [
'https://www.w3.org/ns/activitystreams',
'https://w3id.org/security/v1',
{ Hashtag: 'as:Hashtag' }
'https://w3id.org/security/v1'
]
}, x);
};
export const attachLdSignature = async (activity: any, user: ILocalUser): Promise<IActivity | null> => {
if (activity == null) return null;
const keypair = await UserKeypairs.findOne({
userId: user.id
}).then(ensure);
const obj = {
// as non-standards
manuallyApprovesFollowers: 'as:manuallyApprovesFollowers',
sensitive: 'as:sensitive',
Hashtag: 'as:Hashtag',
quoteUrl: 'as:quoteUrl',
// Mastodon
toot: 'http://joinmastodon.org/ns#',
Emoji: 'toot:Emoji',
featured: 'toot:featured',
// schema
schema: 'http://schema.org#',
PropertyValue: 'schema:PropertyValue',
value: 'schema:value',
// Misskey
misskey: `${config.url}/ns#`,
'_misskey_content': 'misskey:_misskey_content',
'_misskey_quote': 'misskey:_misskey_quote',
'_misskey_reaction': 'misskey:_misskey_reaction',
'_misskey_votes': 'misskey:_misskey_votes',
'_misskey_talk': 'misskey:_misskey_talk',
};
activity['@context'].push(obj);
const ldSignature = new LdSignature();
ldSignature.debug = false;
activity = await ldSignature.signRsaSignature2017(activity, keypair.privateKey, `${config.url}/users/${user.id}#main-key`);
return activity;
};

View File

@ -13,6 +13,7 @@ import { ensure } from '../../../prelude/ensure';
export async function renderPerson(user: ILocalUser) {
const id = `${config.url}/users/${user.id}`;
const isSystem = !!user.username.match(/\./);
const [avatar, banner, profile] = await Promise.all([
user.avatarId ? DriveFiles.findOne(user.avatarId) : Promise.resolve(undefined),
@ -52,7 +53,7 @@ export async function renderPerson(user: ILocalUser) {
const keypair = await UserKeypairs.findOne(user.id).then(ensure);
return {
type: user.isBot ? 'Service' : 'Person',
type: isSystem ? 'Application' : user.isBot ? 'Service' : 'Person',
id,
inbox: `${id}/inbox`,
outbox: `${id}/outbox`,

View File

@ -0,0 +1,24 @@
import $ from 'cafy';
import define from '../../../define';
import { addRelay } from '../../../../../services/relay';
export const meta = {
desc: {
'ja-JP': 'Add relay'
},
tags: ['admin'],
requireCredential: true as const,
requireModerator: true as const,
params: {
inbox: {
validator: $.str
},
},
};
export default define(meta, async (ps, user) => {
return await addRelay(ps.inbox);
});

View File

@ -0,0 +1,20 @@
import define from '../../../define';
import { listRelay } from '../../../../../services/relay';
export const meta = {
desc: {
'ja-JP': 'List relay'
},
tags: ['admin'],
requireCredential: true as const,
requireModerator: true as const,
params: {
},
};
export default define(meta, async (ps, user) => {
return await listRelay();
});

View File

@ -0,0 +1,24 @@
import $ from 'cafy';
import define from '../../../define';
import { removeRelay } from '../../../../../services/relay';
export const meta = {
desc: {
'ja-JP': 'Remove relay'
},
tags: ['admin'],
requireCredential: true as const,
requireModerator: true as const,
params: {
inbox: {
validator: $.str
},
},
};
export default define(meta, async (ps, user) => {
return await removeRelay(ps.inbox);
});

View File

@ -0,0 +1,59 @@
import * as bcrypt from 'bcryptjs';
import { v4 as uuid } from 'uuid';
import generateNativeUserToken from '../server/api/common/generate-native-user-token';
import { genRsaKeyPair } from '../misc/gen-key-pair';
import { User } from '../models/entities/user';
import { UserProfile } from '../models/entities/user-profile';
import { getConnection } from 'typeorm';
import { genId } from '../misc/gen-id';
import { UserKeypair } from '../models/entities/user-keypair';
import { UsedUsername } from '../models/entities/used-username';
export async function createSystemUser(username: string) {
const password = uuid();
// Generate hash of password
const salt = await bcrypt.genSalt(8);
const hash = await bcrypt.hash(password, salt);
// Generate secret
const secret = generateNativeUserToken();
const keyPair = await genRsaKeyPair(4096);
let account!: User;
// Start transaction
await getConnection().transaction(async transactionalEntityManager => {
account = await transactionalEntityManager.save(new User({
id: genId(),
createdAt: new Date(),
username: username,
usernameLower: username.toLowerCase(),
host: null,
token: secret,
isAdmin: false,
isLocked: true,
isBot: true,
}));
await transactionalEntityManager.save(new UserKeypair({
publicKey: keyPair.publicKey,
privateKey: keyPair.privateKey,
userId: account.id
}));
await transactionalEntityManager.save(new UserProfile({
userId: account.id,
autoAcceptFollowed: false,
password: hash,
}));
await transactionalEntityManager.save(new UsedUsername({
createdAt: new Date(),
username: username.toLowerCase(),
}));
});
return account;
}

View File

@ -9,6 +9,7 @@ import { Notes, UserNotePinings, Users } from '../../models';
import { UserNotePining } from '../../models/entities/user-note-pinings';
import { genId } from '../../misc/gen-id';
import { deliverToFollowers } from '../../remote/activitypub/deliver-manager';
import { deliverToRelays } from '../relay';
/**
* 稿
@ -87,4 +88,5 @@ export async function deliverPinnedChange(userId: User['id'], noteId: Note['id']
const content = renderActivity(isAddition ? renderAdd(user, target, item) : renderRemove(user, target, item));
deliverToFollowers(user, content);
deliverToRelays(user, content);
}

View File

@ -4,6 +4,7 @@ import { Users } from '../../models';
import { User } from '../../models/entities/user';
import { renderPerson } from '../../remote/activitypub/renderer/person';
import { deliverToFollowers } from '../../remote/activitypub/deliver-manager';
import { deliverToRelays } from '../relay';
export async function publishToFollowers(userId: User['id']) {
const user = await Users.findOne(userId);
@ -13,5 +14,6 @@ export async function publishToFollowers(userId: User['id']) {
if (Users.isLocalUser(user)) {
const content = renderActivity(renderUpdate(await renderPerson(user), user));
deliverToFollowers(user, content);
deliverToRelays(user, content);
}
}

View File

@ -31,6 +31,7 @@ import { ensure } from '../../prelude/ensure';
import { checkHitAntenna } from '../../misc/check-hit-antenna';
import { addNoteToAntenna } from '../add-note-to-antenna';
import { countSameRenotes } from '../../misc/count-same-renotes';
import { deliverToRelays } from '../relay';
type NotificationType = 'reply' | 'renote' | 'quote' | 'mention';
@ -349,6 +350,10 @@ export default async (user: User, data: Option, silent = false) => new Promise<N
dm.addFollowersRecipe();
}
if (['public'].includes(note.visibility)) {
deliverToRelays(user, noteActivity);
}
dm.execute();
})();
}

View File

@ -12,6 +12,7 @@ import { Notes, Users, Instances } from '../../models';
import { notesChart, perUserNotesChart, instanceChart } from '../chart';
import { deliverToFollowers } from '../../remote/activitypub/deliver-manager';
import { countSameRenotes } from '../../misc/count-same-renotes';
import { deliverToRelays } from '../relay';
/**
* 稿
@ -48,6 +49,7 @@ export default async function(user: User, note: Note, quiet = false) {
: renderDelete(renderTombstone(`${config.url}/notes/${note.id}`), user));
deliverToFollowers(user, content);
deliverToRelays(user, content);
}
// also deliever delete activity to cascaded notes

View File

@ -4,6 +4,7 @@ import renderNote from '../../../remote/activitypub/renderer/note';
import { Users, Notes } from '../../../models';
import { Note } from '../../../models/entities/note';
import { deliverToFollowers } from '../../../remote/activitypub/deliver-manager';
import { deliverToRelays } from '../../relay';
export async function deliverQuestionUpdate(noteId: Note['id']) {
const note = await Notes.findOne(noteId);
@ -16,5 +17,6 @@ export async function deliverQuestionUpdate(noteId: Note['id']) {
const content = renderActivity(renderUpdate(await renderNote(note, false), user));
deliverToFollowers(user, content);
deliverToRelays(user, content);
}
}

96
src/services/relay.ts Normal file
View File

@ -0,0 +1,96 @@
import { createSystemUser } from './create-system-user';
import { renderFollowRelay } from '../remote/activitypub/renderer/follow-relay';
import { renderActivity, attachLdSignature } from '../remote/activitypub/renderer';
import renderUndo from '../remote/activitypub/renderer/undo';
import { deliver } from '../queue';
import { ILocalUser } from '../models/entities/user';
import { Users, Relays } from '../models';
import { genId } from '../misc/gen-id';
const ACTOR_USERNAME = 'relay.actor' as const;
export async function getRelayActor(): Promise<ILocalUser> {
const user = await Users.findOne({
host: null,
username: ACTOR_USERNAME
});
if (user) return user as ILocalUser;
const created = await createSystemUser(ACTOR_USERNAME);
return created as ILocalUser;
}
export async function addRelay(inbox: string) {
const relay = await Relays.save({
id: genId(),
inbox,
status: 'requesting'
});
const relayActor = await getRelayActor();
const follow = await renderFollowRelay(relay, relayActor);
const activity = renderActivity(follow);
deliver(relayActor, activity, relay.inbox);
return relay;
}
export async function removeRelay(inbox: string) {
const relay = await Relays.findOne({
inbox
});
if (relay == null) {
throw 'relay not found';
}
const relayActor = await getRelayActor();
const follow = renderFollowRelay(relay, relayActor);
const undo = renderUndo(follow, relayActor);
const activity = renderActivity(undo);
deliver(relayActor, activity, relay.inbox);
await Relays.delete(relay.id);
}
export async function listRelay() {
const relays = await Relays.find();
return relays;
}
export async function relayAccepted(id: string) {
const result = await Relays.update(id, {
status: 'accepted'
});
return JSON.stringify(result);
}
export async function relayRejected(id: string) {
const result = await Relays.update(id, {
status: 'rejected'
});
return JSON.stringify(result);
}
export async function deliverToRelays(user: ILocalUser, activity: any) {
if (activity == null) return;
const relays = await Relays.find({
status: 'accepted'
});
if (relays.length === 0) return;
const relayActor = await getRelayActor();
const copy = JSON.parse(JSON.stringify(activity));
if (!copy.to) copy.to = ['https://www.w3.org/ns/activitystreams#Public'];
const signed = await attachLdSignature(copy, user);
for (const relay of relays) {
deliver(relayActor, signed, relay.inbox);
}
}