summaryrefslogtreecommitdiff
path: root/packages/backend/src/server/ActivityPubServerService.ts
diff options
context:
space:
mode:
Diffstat (limited to 'packages/backend/src/server/ActivityPubServerService.ts')
-rw-r--r--packages/backend/src/server/ActivityPubServerService.ts631
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();
+ }
+}