diff options
Diffstat (limited to 'packages/backend/src/server/ActivityPubServerService.ts')
| -rw-r--r-- | packages/backend/src/server/ActivityPubServerService.ts | 631 |
1 files changed, 631 insertions, 0 deletions
diff --git a/packages/backend/src/server/ActivityPubServerService.ts b/packages/backend/src/server/ActivityPubServerService.ts new file mode 100644 index 0000000000..bdd2e97508 --- /dev/null +++ b/packages/backend/src/server/ActivityPubServerService.ts @@ -0,0 +1,631 @@ +import { Inject, Injectable } from '@nestjs/common'; +import fastifyAccepts from '@fastify/accepts'; +import httpSignature from '@peertube/http-signature'; +import { Brackets, In, IsNull, LessThan, Not } from 'typeorm'; +import accepts from 'accepts'; +import vary from 'vary'; +import { DI } from '@/di-symbols.js'; +import type { FollowingsRepository, NotesRepository, EmojisRepository, NoteReactionsRepository, UserProfilesRepository, UserNotePiningsRepository, UsersRepository } from '@/models/index.js'; +import * as url from '@/misc/prelude/url.js'; +import type { Config } from '@/config.js'; +import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; +import { QueueService } from '@/core/QueueService.js'; +import type { ILocalUser, User } from '@/models/entities/User.js'; +import { UserKeypairStoreService } from '@/core/UserKeypairStoreService.js'; +import type { Following } from '@/models/entities/Following.js'; +import { countIf } from '@/misc/prelude/array.js'; +import type { Note } from '@/models/entities/Note.js'; +import { QueryService } from '@/core/QueryService.js'; +import { UtilityService } from '@/core/UtilityService.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { bindThis } from '@/decorators.js'; +import type { FastifyInstance, FastifyRequest, FastifyReply, FastifyPluginOptions } from 'fastify'; +import type { FindOptionsWhere } from 'typeorm'; + +const ACTIVITY_JSON = 'application/activity+json; charset=utf-8'; +const LD_JSON = 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"; charset=utf-8'; + +@Injectable() +export class ActivityPubServerService { + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.userProfilesRepository) + private userProfilesRepository: UserProfilesRepository, + + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + + @Inject(DI.noteReactionsRepository) + private noteReactionsRepository: NoteReactionsRepository, + + @Inject(DI.emojisRepository) + private emojisRepository: EmojisRepository, + + @Inject(DI.userNotePiningsRepository) + private userNotePiningsRepository: UserNotePiningsRepository, + + @Inject(DI.followingsRepository) + private followingsRepository: FollowingsRepository, + + private utilityService: UtilityService, + private userEntityService: UserEntityService, + private apRendererService: ApRendererService, + private queueService: QueueService, + private userKeypairStoreService: UserKeypairStoreService, + private queryService: QueryService, + ) { + //this.createServer = this.createServer.bind(this); + } + + @bindThis + private setResponseType(request: FastifyRequest, reply: FastifyReply): void { + const accept = request.accepts().type([ACTIVITY_JSON, LD_JSON]); + if (accept === LD_JSON) { + reply.type(LD_JSON); + } else { + reply.type(ACTIVITY_JSON); + } + } + + /** + * Pack Create<Note> or Announce Activity + * @param note Note + */ + @bindThis + private async packActivity(note: Note): Promise<any> { + if (note.renoteId && note.text == null && !note.hasPoll && (note.fileIds == null || note.fileIds.length === 0)) { + const renote = await this.notesRepository.findOneByOrFail({ id: note.renoteId }); + return this.apRendererService.renderAnnounce(renote.uri ? renote.uri : `${this.config.url}/notes/${renote.id}`, note); + } + + return this.apRendererService.renderCreate(await this.apRendererService.renderNote(note, false), note); + } + + @bindThis + private inbox(request: FastifyRequest, reply: FastifyReply) { + let signature; + + try { + signature = httpSignature.parseRequest(request.raw, { 'headers': [] }); + } catch (e) { + reply.code(401); + return; + } + + this.queueService.inbox(request.body, signature); + + reply.code(202); + } + + @bindThis + private async followers( + request: FastifyRequest<{ Params: { user: string; }; Querystring: { cursor?: string; page?: string; }; }>, + reply: FastifyReply, + ) { + const userId = request.params.user; + + const cursor = request.query.cursor; + if (cursor != null && typeof cursor !== 'string') { + reply.code(400); + return; + } + + const page = request.query.page === 'true'; + + const user = await this.usersRepository.findOneBy({ + id: userId, + host: IsNull(), + }); + + if (user == null) { + reply.code(404); + return; + } + + //#region Check ff visibility + const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id }); + + if (profile.ffVisibility === 'private') { + reply.code(403); + reply.header('Cache-Control', 'public, max-age=30'); + return; + } else if (profile.ffVisibility === 'followers') { + reply.code(403); + reply.header('Cache-Control', 'public, max-age=30'); + return; + } + //#endregion + + const limit = 10; + const partOf = `${this.config.url}/users/${userId}/followers`; + + if (page) { + const query = { + followeeId: user.id, + } as FindOptionsWhere<Following>; + + // カーソルが指定されている場合 + if (cursor) { + query.id = LessThan(cursor); + } + + // Get followers + const followings = await this.followingsRepository.find({ + where: query, + take: limit + 1, + order: { id: -1 }, + }); + + // 「次のページ」があるかどうか + const inStock = followings.length === limit + 1; + if (inStock) followings.pop(); + + const renderedFollowers = await Promise.all(followings.map(following => this.apRendererService.renderFollowUser(following.followerId))); + const rendered = this.apRendererService.renderOrderedCollectionPage( + `${partOf}?${url.query({ + page: 'true', + cursor, + })}`, + user.followersCount, renderedFollowers, partOf, + undefined, + inStock ? `${partOf}?${url.query({ + page: 'true', + cursor: followings[followings.length - 1].id, + })}` : undefined, + ); + + this.setResponseType(request, reply); + return (this.apRendererService.renderActivity(rendered)); + } else { + // index page + const rendered = this.apRendererService.renderOrderedCollection(partOf, user.followersCount, `${partOf}?page=true`); + reply.header('Cache-Control', 'public, max-age=180'); + this.setResponseType(request, reply); + return (this.apRendererService.renderActivity(rendered)); + } + } + + @bindThis + private async following( + request: FastifyRequest<{ Params: { user: string; }; Querystring: { cursor?: string; page?: string; }; }>, + reply: FastifyReply, + ) { + const userId = request.params.user; + + const cursor = request.query.cursor; + if (cursor != null && typeof cursor !== 'string') { + reply.code(400); + return; + } + + const page = request.query.page === 'true'; + + const user = await this.usersRepository.findOneBy({ + id: userId, + host: IsNull(), + }); + + if (user == null) { + reply.code(404); + return; + } + + //#region Check ff visibility + const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id }); + + if (profile.ffVisibility === 'private') { + reply.code(403); + reply.header('Cache-Control', 'public, max-age=30'); + return; + } else if (profile.ffVisibility === 'followers') { + reply.code(403); + reply.header('Cache-Control', 'public, max-age=30'); + return; + } + //#endregion + + const limit = 10; + const partOf = `${this.config.url}/users/${userId}/following`; + + if (page) { + const query = { + followerId: user.id, + } as FindOptionsWhere<Following>; + + // カーソルが指定されている場合 + if (cursor) { + query.id = LessThan(cursor); + } + + // Get followings + const followings = await this.followingsRepository.find({ + where: query, + take: limit + 1, + order: { id: -1 }, + }); + + // 「次のページ」があるかどうか + const inStock = followings.length === limit + 1; + if (inStock) followings.pop(); + + const renderedFollowees = await Promise.all(followings.map(following => this.apRendererService.renderFollowUser(following.followeeId))); + const rendered = this.apRendererService.renderOrderedCollectionPage( + `${partOf}?${url.query({ + page: 'true', + cursor, + })}`, + user.followingCount, renderedFollowees, partOf, + undefined, + inStock ? `${partOf}?${url.query({ + page: 'true', + cursor: followings[followings.length - 1].id, + })}` : undefined, + ); + + this.setResponseType(request, reply); + return (this.apRendererService.renderActivity(rendered)); + } else { + // index page + const rendered = this.apRendererService.renderOrderedCollection(partOf, user.followingCount, `${partOf}?page=true`); + reply.header('Cache-Control', 'public, max-age=180'); + this.setResponseType(request, reply); + return (this.apRendererService.renderActivity(rendered)); + } + } + + @bindThis + private async featured(request: FastifyRequest<{ Params: { user: string; }; }>, reply: FastifyReply) { + const userId = request.params.user; + + const user = await this.usersRepository.findOneBy({ + id: userId, + host: IsNull(), + }); + + if (user == null) { + reply.code(404); + return; + } + + const pinings = await this.userNotePiningsRepository.find({ + where: { userId: user.id }, + order: { id: 'DESC' }, + }); + + const pinnedNotes = await Promise.all(pinings.map(pining => + this.notesRepository.findOneByOrFail({ id: pining.noteId }))); + + const renderedNotes = await Promise.all(pinnedNotes.map(note => this.apRendererService.renderNote(note))); + + const rendered = this.apRendererService.renderOrderedCollection( + `${this.config.url}/users/${userId}/collections/featured`, + renderedNotes.length, undefined, undefined, renderedNotes, + ); + + reply.header('Cache-Control', 'public, max-age=180'); + this.setResponseType(request, reply); + return (this.apRendererService.renderActivity(rendered)); + } + + @bindThis + private async outbox( + request: FastifyRequest<{ + Params: { user: string; }; + Querystring: { since_id?: string; until_id?: string; page?: string; }; + }>, + reply: FastifyReply, + ) { + const userId = request.params.user; + + const sinceId = request.query.since_id; + if (sinceId != null && typeof sinceId !== 'string') { + reply.code(400); + return; + } + + const untilId = request.query.until_id; + if (untilId != null && typeof untilId !== 'string') { + reply.code(400); + return; + } + + const page = request.query.page === 'true'; + + if (countIf(x => x != null, [sinceId, untilId]) > 1) { + reply.code(400); + return; + } + + const user = await this.usersRepository.findOneBy({ + id: userId, + host: IsNull(), + }); + + if (user == null) { + reply.code(404); + return; + } + + const limit = 20; + const partOf = `${this.config.url}/users/${userId}/outbox`; + + if (page) { + const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), sinceId, untilId) + .andWhere('note.userId = :userId', { userId: user.id }) + .andWhere(new Brackets(qb => { qb + .where('note.visibility = \'public\'') + .orWhere('note.visibility = \'home\''); + })) + .andWhere('note.localOnly = FALSE'); + + const notes = await query.take(limit).getMany(); + + if (sinceId) notes.reverse(); + + const activities = await Promise.all(notes.map(note => this.packActivity(note))); + const rendered = this.apRendererService.renderOrderedCollectionPage( + `${partOf}?${url.query({ + page: 'true', + since_id: sinceId, + until_id: untilId, + })}`, + user.notesCount, activities, partOf, + notes.length ? `${partOf}?${url.query({ + page: 'true', + since_id: notes[0].id, + })}` : undefined, + notes.length ? `${partOf}?${url.query({ + page: 'true', + until_id: notes[notes.length - 1].id, + })}` : undefined, + ); + + this.setResponseType(request, reply); + return (this.apRendererService.renderActivity(rendered)); + } else { + // index page + const rendered = this.apRendererService.renderOrderedCollection(partOf, user.notesCount, + `${partOf}?page=true`, + `${partOf}?page=true&since_id=000000000000000000000000`, + ); + reply.header('Cache-Control', 'public, max-age=180'); + this.setResponseType(request, reply); + return (this.apRendererService.renderActivity(rendered)); + } + } + + @bindThis + private async userInfo(request: FastifyRequest, reply: FastifyReply, user: User | null) { + if (user == null) { + reply.code(404); + return; + } + + reply.header('Cache-Control', 'public, max-age=180'); + this.setResponseType(request, reply); + return (this.apRendererService.renderActivity(await this.apRendererService.renderPerson(user as ILocalUser))); + } + + @bindThis + public createServer(fastify: FastifyInstance, options: FastifyPluginOptions, done: (err?: Error) => void) { + fastify.addConstraintStrategy({ + name: 'apOrHtml', + storage() { + const store = {}; + return { + get(key) { + return store[key] ?? null; + }, + set(key, value) { + store[key] = value; + }, + }; + }, + deriveConstraint(request, ctx) { + const accepted = accepts(request).type(['html', ACTIVITY_JSON, LD_JSON]); + const isAp = typeof accepted === 'string' && !accepted.match(/html/); + return isAp ? 'ap' : 'html'; + }, + }); + + fastify.register(fastifyAccepts); + fastify.addContentTypeParser('application/activity+json', { parseAs: 'string' }, fastify.getDefaultJsonParser('ignore', 'ignore')); + fastify.addContentTypeParser('application/ld+json', { parseAs: 'string' }, fastify.getDefaultJsonParser('ignore', 'ignore')); + + //#region Routing + // inbox (limit: 64kb) + fastify.post('/inbox', { bodyLimit: 1024 * 64 }, async (request, reply) => await this.inbox(request, reply)); + fastify.post('/users/:user/inbox', { bodyLimit: 1024 * 64 }, async (request, reply) => await this.inbox(request, reply)); + + // note + fastify.get<{ Params: { note: string; } }>('/notes/:note', { constraints: { apOrHtml: 'ap' } }, async (request, reply) => { + vary(reply.raw, 'Accept'); + + const note = await this.notesRepository.findOneBy({ + id: request.params.note, + visibility: In(['public', 'home']), + localOnly: false, + }); + + if (note == null) { + reply.code(404); + return; + } + + // リモートだったらリダイレクト + if (note.userHost != null) { + if (note.uri == null || this.utilityService.isSelfHost(note.userHost)) { + reply.code(500); + return; + } + reply.redirect(note.uri); + return; + } + + reply.header('Cache-Control', 'public, max-age=180'); + this.setResponseType(request, reply); + return (this.apRendererService.renderActivity(await this.apRendererService.renderNote(note, false))); + }); + + // note activity + fastify.get<{ Params: { note: string; } }>('/notes/:note/activity', async (request, reply) => { + vary(reply.raw, 'Accept'); + + const note = await this.notesRepository.findOneBy({ + id: request.params.note, + userHost: IsNull(), + visibility: In(['public', 'home']), + localOnly: false, + }); + + if (note == null) { + reply.code(404); + return; + } + + reply.header('Cache-Control', 'public, max-age=180'); + this.setResponseType(request, reply); + return (this.apRendererService.renderActivity(await this.packActivity(note))); + }); + + // outbox + fastify.get<{ + Params: { user: string; }; + Querystring: { since_id?: string; until_id?: string; page?: string; }; + }>('/users/:user/outbox', async (request, reply) => await this.outbox(request, reply)); + + // followers + fastify.get<{ + Params: { user: string; }; + Querystring: { cursor?: string; page?: string; }; + }>('/users/:user/followers', async (request, reply) => await this.followers(request, reply)); + + // following + fastify.get<{ + Params: { user: string; }; + Querystring: { cursor?: string; page?: string; }; + }>('/users/:user/following', async (request, reply) => await this.following(request, reply)); + + // featured + fastify.get<{ Params: { user: string; }; }>('/users/:user/collections/featured', async (request, reply) => await this.featured(request, reply)); + + // publickey + fastify.get<{ Params: { user: string; } }>('/users/:user/publickey', async (request, reply) => { + const userId = request.params.user; + + const user = await this.usersRepository.findOneBy({ + id: userId, + host: IsNull(), + }); + + if (user == null) { + reply.code(404); + return; + } + + const keypair = await this.userKeypairStoreService.getUserKeypair(user.id); + + if (this.userEntityService.isLocalUser(user)) { + reply.header('Cache-Control', 'public, max-age=180'); + this.setResponseType(request, reply); + return (this.apRendererService.renderActivity(this.apRendererService.renderKey(user, keypair))); + } else { + reply.code(400); + } + }); + + fastify.get<{ Params: { user: string; } }>('/users/:user', { constraints: { apOrHtml: 'ap' } }, async (request, reply) => { + const userId = request.params.user; + + const user = await this.usersRepository.findOneBy({ + id: userId, + host: IsNull(), + isSuspended: false, + }); + + return await this.userInfo(request, reply, user); + }); + + fastify.get<{ Params: { user: string; } }>('/@:user', { constraints: { apOrHtml: 'ap' } }, async (request, reply) => { + const user = await this.usersRepository.findOneBy({ + usernameLower: request.params.user.toLowerCase(), + host: IsNull(), + isSuspended: false, + }); + + return await this.userInfo(request, reply, user); + }); + //#endregion + + // emoji + fastify.get<{ Params: { emoji: string; } }>('/emojis/:emoji', async (request, reply) => { + const emoji = await this.emojisRepository.findOneBy({ + host: IsNull(), + name: request.params.emoji, + }); + + if (emoji == null) { + reply.code(404); + return; + } + + reply.header('Cache-Control', 'public, max-age=180'); + this.setResponseType(request, reply); + return (this.apRendererService.renderActivity(await this.apRendererService.renderEmoji(emoji))); + }); + + // like + fastify.get<{ Params: { like: string; } }>('/likes/:like', async (request, reply) => { + const reaction = await this.noteReactionsRepository.findOneBy({ id: request.params.like }); + + if (reaction == null) { + reply.code(404); + return; + } + + const note = await this.notesRepository.findOneBy({ id: reaction.noteId }); + + if (note == null) { + reply.code(404); + return; + } + + reply.header('Cache-Control', 'public, max-age=180'); + this.setResponseType(request, reply); + return (this.apRendererService.renderActivity(await this.apRendererService.renderLike(reaction, note))); + }); + + // follow + fastify.get<{ Params: { follower: string; followee: string; } }>('/follows/:follower/:followee', async (request, reply) => { + // This may be used before the follow is completed, so we do not + // check if the following exists. + + const [follower, followee] = await Promise.all([ + this.usersRepository.findOneBy({ + id: request.params.follower, + host: IsNull(), + }), + this.usersRepository.findOneBy({ + id: request.params.followee, + host: Not(IsNull()), + }), + ]); + + if (follower == null || followee == null) { + reply.code(404); + return; + } + + reply.header('Cache-Control', 'public, max-age=180'); + this.setResponseType(request, reply); + return (this.apRendererService.renderActivity(this.apRendererService.renderFollow(follower, followee))); + }); + + done(); + } +} |