From 145389768d434c46bd24662488294eead7d3addb Mon Sep 17 00:00:00 2001
From: MeiMei <30769358+mei23@users.noreply.github.com>
Date: Sun, 10 May 2020 18:42:31 +0900
Subject: pub-relay (#6341)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* pub-relay
* relay actorをApplicationにする
* Disable koa-compress
* Homeはリレーに送らない
* Disable debug
* UI
* cleanupなど
---
src/client/app.vue | 9 ++-
src/client/pages/instance/relays.vue | 93 ++++++++++++++++++++++++
src/client/router.ts | 1 +
src/db/postgre.ts | 2 +
src/misc/gen-key-pair.ts | 36 ++++++++++
src/models/entities/relay.ts | 19 +++++
src/models/index.ts | 2 +
src/models/repositories/relay.ts | 6 ++
src/queue/processors/inbox.ts | 10 ++-
src/remote/activitypub/kernel/accept/follow.ts | 7 ++
src/remote/activitypub/kernel/reject/follow.ts | 7 ++
src/remote/activitypub/misc/ld-signature.ts | 1 +
src/remote/activitypub/renderer/follow-relay.ts | 14 ++++
src/remote/activitypub/renderer/index.ts | 49 ++++++++++++-
src/remote/activitypub/renderer/person.ts | 3 +-
src/server/api/endpoints/admin/relays/add.ts | 24 +++++++
src/server/api/endpoints/admin/relays/list.ts | 20 ++++++
src/server/api/endpoints/admin/relays/remove.ts | 24 +++++++
src/services/create-system-user.ts | 59 +++++++++++++++
src/services/i/pin.ts | 2 +
src/services/i/update.ts | 2 +
src/services/note/create.ts | 5 ++
src/services/note/delete.ts | 2 +
src/services/note/polls/update.ts | 2 +
src/services/relay.ts | 96 +++++++++++++++++++++++++
25 files changed, 483 insertions(+), 12 deletions(-)
create mode 100644 src/client/pages/instance/relays.vue
create mode 100644 src/misc/gen-key-pair.ts
create mode 100644 src/models/entities/relay.ts
create mode 100644 src/models/repositories/relay.ts
create mode 100644 src/remote/activitypub/renderer/follow-relay.ts
create mode 100644 src/server/api/endpoints/admin/relays/add.ts
create mode 100644 src/server/api/endpoints/admin/relays/list.ts
create mode 100644 src/server/api/endpoints/admin/relays/remove.ts
create mode 100644 src/services/create-system-user.ts
create mode 100644 src/services/relay.ts
(limited to 'src')
diff --git a/src/client/app.vue b/src/client/app.vue
index 170ba9365d..5e7396205b 100644
--- a/src/client/app.vue
+++ b/src/client/app.vue
@@ -132,7 +132,7 @@
+
+
diff --git a/src/client/router.ts b/src/client/router.ts
index e997d2db99..cf98c57bd7 100644
--- a/src/client/router.ts
+++ b/src/client/router.ts
@@ -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') },
diff --git a/src/db/postgre.ts b/src/db/postgre.ts
index 9e3eb3f7d6..81fb92f684 100644
--- a/src/db/postgre.ts
+++ b/src/db/postgre.ts
@@ -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
];
diff --git a/src/misc/gen-key-pair.ts b/src/misc/gen-key-pair.ts
new file mode 100644
index 0000000000..d4a8fa7534
--- /dev/null
+++ b/src/misc/gen-key-pair.ts
@@ -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
+ }
+ });
+}
diff --git a/src/models/entities/relay.ts b/src/models/entities/relay.ts
new file mode 100644
index 0000000000..4c82ccb125
--- /dev/null
+++ b/src/models/entities/relay.ts
@@ -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';
+}
diff --git a/src/models/index.ts b/src/models/index.ts
index c3b329f4f8..e1389e7353 100644
--- a/src/models/index.ts
+++ b/src/models/index.ts
@@ -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);
diff --git a/src/models/repositories/relay.ts b/src/models/repositories/relay.ts
new file mode 100644
index 0000000000..601bb5eb39
--- /dev/null
+++ b/src/models/repositories/relay.ts
@@ -0,0 +1,6 @@
+import { EntityRepository, Repository } from 'typeorm';
+import { Relay } from '../entities/relay';
+
+@EntityRepository(Relay)
+export class RelayRepository extends Repository {
+}
diff --git a/src/queue/processors/inbox.ts b/src/queue/processors/inbox.ts
index f37f663ed5..3a0bdbe28a 100644
--- a/src/queue/processors/inbox.ts
+++ b/src/queue/processors/inbox.ts
@@ -56,12 +56,10 @@ export default async (job: Bull.Job): Promise => {
}
// 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): Promise => {
return `skip: LD-Signature user(${authUser.user.uri}) !== activity.actor(${activity.actor})`;
}
} else {
- return 'signature verification failed';
+ throw `skip: http-signature verification failed.`;
}
}
diff --git a/src/remote/activitypub/kernel/accept/follow.ts b/src/remote/activitypub/kernel/accept/follow.ts
index c067f7622a..71c1bed9de 100644
--- a/src/remote/activitypub/kernel/accept/follow.ts
+++ b/src/remote/activitypub/kernel/accept/follow.ts
@@ -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 => {
// ※ activityはこっちから投げたフォローリクエストなので、activity.actorは存在するローカルユーザーである必要がある
@@ -17,6 +18,12 @@ export default async (actor: IRemoteUser, activity: IFollow): Promise =>
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`;
};
diff --git a/src/remote/activitypub/kernel/reject/follow.ts b/src/remote/activitypub/kernel/reject/follow.ts
index 49e82c7afc..d97ced46b3 100644
--- a/src/remote/activitypub/kernel/reject/follow.ts
+++ b/src/remote/activitypub/kernel/reject/follow.ts
@@ -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 => {
// ※ activityはこっちから投げたフォローリクエストなので、activity.actorは存在するローカルユーザーである必要がある
@@ -17,6 +18,12 @@ export default async (actor: IRemoteUser, activity: IFollow): Promise =>
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`;
};
diff --git a/src/remote/activitypub/misc/ld-signature.ts b/src/remote/activitypub/misc/ld-signature.ts
index d61b430f7a..070e39edfb 100644
--- a/src/remote/activitypub/misc/ld-signature.ts
+++ b/src/remote/activitypub/misc/ld-signature.ts
@@ -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;
diff --git a/src/remote/activitypub/renderer/follow-relay.ts b/src/remote/activitypub/renderer/follow-relay.ts
new file mode 100644
index 0000000000..58bc0c90c3
--- /dev/null
+++ b/src/remote/activitypub/renderer/follow-relay.ts
@@ -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;
+}
diff --git a/src/remote/activitypub/renderer/index.ts b/src/remote/activitypub/renderer/index.ts
index 63447b0c43..e84a7d90ac 100644
--- a/src/remote/activitypub/renderer/index.ts
+++ b/src/remote/activitypub/renderer/index.ts
@@ -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 => {
+ 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;
+};
diff --git a/src/remote/activitypub/renderer/person.ts b/src/remote/activitypub/renderer/person.ts
index 56ff10319a..bc8a462d2e 100644
--- a/src/remote/activitypub/renderer/person.ts
+++ b/src/remote/activitypub/renderer/person.ts
@@ -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`,
diff --git a/src/server/api/endpoints/admin/relays/add.ts b/src/server/api/endpoints/admin/relays/add.ts
new file mode 100644
index 0000000000..3ea6bcc73b
--- /dev/null
+++ b/src/server/api/endpoints/admin/relays/add.ts
@@ -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);
+});
diff --git a/src/server/api/endpoints/admin/relays/list.ts b/src/server/api/endpoints/admin/relays/list.ts
new file mode 100644
index 0000000000..3b132f73b3
--- /dev/null
+++ b/src/server/api/endpoints/admin/relays/list.ts
@@ -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();
+});
diff --git a/src/server/api/endpoints/admin/relays/remove.ts b/src/server/api/endpoints/admin/relays/remove.ts
new file mode 100644
index 0000000000..df95e0329a
--- /dev/null
+++ b/src/server/api/endpoints/admin/relays/remove.ts
@@ -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);
+});
diff --git a/src/services/create-system-user.ts b/src/services/create-system-user.ts
new file mode 100644
index 0000000000..7f59efb448
--- /dev/null
+++ b/src/services/create-system-user.ts
@@ -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;
+}
diff --git a/src/services/i/pin.ts b/src/services/i/pin.ts
index 9fd7263ffc..fcddc50636 100644
--- a/src/services/i/pin.ts
+++ b/src/services/i/pin.ts
@@ -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);
}
diff --git a/src/services/i/update.ts b/src/services/i/update.ts
index ae72e91345..8d40b08a85 100644
--- a/src/services/i/update.ts
+++ b/src/services/i/update.ts
@@ -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);
}
}
diff --git a/src/services/note/create.ts b/src/services/note/create.ts
index f506337924..60a62dcdff 100644
--- a/src/services/note/create.ts
+++ b/src/services/note/create.ts
@@ -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 {
+ 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);
+ }
+}
--
cgit v1.2.3-freya