pub-relay (#6341)
* pub-relay * relay actorをApplicationにする * Disable koa-compress * Homeはリレーに送らない * Disable debug * UI * cleanupなど
This commit is contained in:
		
							parent
							
								
									be183206e6
								
							
						
					
					
						commit
						145389768d
					
				
					 27 changed files with 510 additions and 12 deletions
				
			
		|  | @ -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: "拒否済み" | ||||
|  |  | |||
							
								
								
									
										18
									
								
								migration/1589023282116-pubRelay.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								migration/1589023282116-pubRelay.ts
									
										
									
									
									
										Normal 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); | ||||
|     } | ||||
| 
 | ||||
| } | ||||
|  | @ -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'), | ||||
|  |  | |||
							
								
								
									
										93
									
								
								src/client/pages/instance/relays.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										93
									
								
								src/client/pages/instance/relays.vue
									
										
									
									
									
										Normal 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> | ||||
|  | @ -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') }, | ||||
|  |  | |||
|  | @ -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
									
								
							
							
						
						
									
										36
									
								
								src/misc/gen-key-pair.ts
									
										
									
									
									
										Normal 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 | ||||
| 		} | ||||
| 	}); | ||||
| } | ||||
							
								
								
									
										19
									
								
								src/models/entities/relay.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								src/models/entities/relay.ts
									
										
									
									
									
										Normal 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'; | ||||
| } | ||||
|  | @ -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); | ||||
|  |  | |||
							
								
								
									
										6
									
								
								src/models/repositories/relay.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								src/models/repositories/relay.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,6 @@ | |||
| import { EntityRepository, Repository } from 'typeorm'; | ||||
| import { Relay } from '../entities/relay'; | ||||
| 
 | ||||
| @EntityRepository(Relay) | ||||
| export class RelayRepository extends Repository<Relay> { | ||||
| } | ||||
|  | @ -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.`; | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
|  |  | |||
|  | @ -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`; | ||||
| }; | ||||
|  |  | |||
|  | @ -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`; | ||||
| }; | ||||
|  |  | |||
|  | @ -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; | ||||
|  |  | |||
							
								
								
									
										14
									
								
								src/remote/activitypub/renderer/follow-relay.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								src/remote/activitypub/renderer/follow-relay.ts
									
										
									
									
									
										Normal 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; | ||||
| } | ||||
|  | @ -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; | ||||
| }; | ||||
|  |  | |||
|  | @ -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`, | ||||
|  |  | |||
							
								
								
									
										24
									
								
								src/server/api/endpoints/admin/relays/add.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								src/server/api/endpoints/admin/relays/add.ts
									
										
									
									
									
										Normal 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); | ||||
| }); | ||||
							
								
								
									
										20
									
								
								src/server/api/endpoints/admin/relays/list.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								src/server/api/endpoints/admin/relays/list.ts
									
										
									
									
									
										Normal 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(); | ||||
| }); | ||||
							
								
								
									
										24
									
								
								src/server/api/endpoints/admin/relays/remove.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								src/server/api/endpoints/admin/relays/remove.ts
									
										
									
									
									
										Normal 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); | ||||
| }); | ||||
							
								
								
									
										59
									
								
								src/services/create-system-user.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										59
									
								
								src/services/create-system-user.ts
									
										
									
									
									
										Normal 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; | ||||
| } | ||||
|  | @ -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); | ||||
| } | ||||
|  |  | |||
|  | @ -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); | ||||
| 	} | ||||
| } | ||||
|  |  | |||
|  | @ -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(); | ||||
| 			})(); | ||||
| 		} | ||||
|  |  | |||
|  | @ -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
 | ||||
|  |  | |||
|  | @ -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
									
								
							
							
						
						
									
										96
									
								
								src/services/relay.ts
									
										
									
									
									
										Normal 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); | ||||
| 	} | ||||
| } | ||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue