diff options
| author | Julia <julia@insertdomain.name> | 2025-05-29 00:07:38 +0000 |
|---|---|---|
| committer | Julia <julia@insertdomain.name> | 2025-05-29 00:07:38 +0000 |
| commit | 6b554c178b81f13f83a69b19d44b72b282a0c119 (patch) | |
| tree | f5537f1a56323a4dd57ba150b3cb84a2d8b5dc63 /packages/backend/src/server/ActivityPubServerService.ts | |
| parent | merge: Security fixes (!970) (diff) | |
| parent | bump version for release (diff) | |
| download | sharkey-6b554c178b81f13f83a69b19d44b72b282a0c119.tar.gz sharkey-6b554c178b81f13f83a69b19d44b72b282a0c119.tar.bz2 sharkey-6b554c178b81f13f83a69b19d44b72b282a0c119.zip | |
merge: release 2025.4.2 (!1051)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/1051
Approved-by: Hazelnoot <acomputerdog@gmail.com>
Approved-by: Marie <github@yuugi.dev>
Approved-by: Julia <julia@insertdomain.name>
Diffstat (limited to 'packages/backend/src/server/ActivityPubServerService.ts')
| -rw-r--r-- | packages/backend/src/server/ActivityPubServerService.ts | 418 |
1 files changed, 316 insertions, 102 deletions
diff --git a/packages/backend/src/server/ActivityPubServerService.ts b/packages/backend/src/server/ActivityPubServerService.ts index 10dba1660f..41beadb56d 100644 --- a/packages/backend/src/server/ActivityPubServerService.ts +++ b/packages/backend/src/server/ActivityPubServerService.ts @@ -14,7 +14,7 @@ import accepts from 'accepts'; import vary from 'vary'; import secureJson from 'secure-json-parse'; import { DI } from '@/di-symbols.js'; -import type { FollowingsRepository, NotesRepository, EmojisRepository, NoteReactionsRepository, UserProfilesRepository, UserNotePiningsRepository, UsersRepository, FollowRequestsRepository } from '@/models/_.js'; +import type { FollowingsRepository, NotesRepository, EmojisRepository, NoteReactionsRepository, UserProfilesRepository, UserNotePiningsRepository, UsersRepository, FollowRequestsRepository, MiMeta } from '@/models/_.js'; import * as url from '@/misc/prelude/url.js'; import type { Config } from '@/config.js'; import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; @@ -22,7 +22,6 @@ import { ApDbResolverService } from '@/core/activitypub/ApDbResolverService.js'; import { QueueService } from '@/core/QueueService.js'; import type { MiLocalUser, MiRemoteUser, MiUser } from '@/models/User.js'; import { UserKeypairService } from '@/core/UserKeypairService.js'; -import { InstanceActorService } from '@/core/InstanceActorService.js'; import type { MiUserPublickey } from '@/models/UserPublickey.js'; import type { MiFollowing } from '@/models/Following.js'; import { countIf } from '@/misc/prelude/array.js'; @@ -33,11 +32,13 @@ import { UserEntityService } from '@/core/entities/UserEntityService.js'; import type Logger from '@/logger.js'; import { LoggerService } from '@/core/LoggerService.js'; import { bindThis } from '@/decorators.js'; -import { IActivity } from '@/core/activitypub/type.js'; +import { IActivity, IAnnounce, ICreate } from '@/core/activitypub/type.js'; import { isQuote, isRenote } from '@/misc/is-renote.js'; import * as Acct from '@/misc/acct.js'; +import { CacheService } from '@/core/CacheService.js'; import type { FastifyInstance, FastifyRequest, FastifyReply, FastifyPluginOptions, FastifyBodyParser } from 'fastify'; import type { FindOptionsWhere } from 'typeorm'; +import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js'; 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'; @@ -51,6 +52,9 @@ export class ActivityPubServerService { @Inject(DI.config) private config: Config, + @Inject(DI.meta) + private meta: MiMeta, + @Inject(DI.usersRepository) private usersRepository: UsersRepository, @@ -77,13 +81,14 @@ export class ActivityPubServerService { private utilityService: UtilityService, private userEntityService: UserEntityService, - private instanceActorService: InstanceActorService, private apRendererService: ApRendererService, private apDbResolverService: ApDbResolverService, private queueService: QueueService, private userKeypairService: UserKeypairService, private queryService: QueryService, + private fanoutTimelineEndpointService: FanoutTimelineEndpointService, private loggerService: LoggerService, + private readonly cacheService: CacheService, ) { //this.createServer = this.createServer.bind(this); this.logger = this.loggerService.getLogger('apserv', 'pink'); @@ -106,7 +111,7 @@ export class ActivityPubServerService { * @param author Author of the note */ @bindThis - private async packActivity(note: MiNote, author: MiUser): Promise<any> { + private async packActivity(note: MiNote, author: MiUser): Promise<ICreate | IAnnounce> { if (isRenote(note) && !isQuote(note)) { const renote = await this.notesRepository.findOneByOrFail({ id: note.renoteId }); return this.apRendererService.renderAnnounce(renote.uri ? renote.uri : `${this.config.url}/notes/${renote.id}`, note); @@ -115,10 +120,55 @@ export class ActivityPubServerService { return this.apRendererService.renderCreate(await this.apRendererService.renderNote(note, author, false), note); } - @bindThis - private async shouldRefuseGetRequest(request: FastifyRequest, reply: FastifyReply, userId: string | undefined = undefined): Promise<boolean> { - if (!this.config.checkActivityPubGetSignature) return false; + /** + * Checks Authorized Fetch. + * Returns an object with two properties: + * * reject - true if the request should be ignored by the caller, false if it should be processed. + * * redact - true if the caller should redact response data, false if it should return full data. + * When "reject" is true, the HTTP status code will be automatically set to 401 unauthorized. + */ + private async checkAuthorizedFetch( + request: FastifyRequest, + reply: FastifyReply, + userId?: string, + essential?: boolean, + ): Promise<{ reject: boolean, redact: boolean }> { + // Federation disabled => reject + if (this.meta.federation === 'none') { + reply.code(401); + return { reject: true, redact: true }; + } + + // Auth fetch disabled => accept + const allowUnsignedFetch = await this.getUnsignedFetchAllowance(userId); + if (allowUnsignedFetch === 'always') { + return { reject: false, redact: false }; + } + // Valid signature => accept + const error = await this.checkSignature(request); + if (!error) { + return { reject: false, redact: false }; + } + + // Unsigned, but essential => accept redacted + if (allowUnsignedFetch === 'essential' && essential) { + return { reject: false, redact: true }; + } + + // Unsigned, not essential => reject + this.authlogger.warn(error); + reply.code(401); + return { reject: true, redact: true }; + } + + /** + * Verifies HTTP Signatures for a request. + * Returns null of success (valid signature). + * Returns a string error on validation failure. + */ + @bindThis + private async checkSignature(request: FastifyRequest): Promise<string | null> { /* this code is inspired from the `inbox` function below, and `queue/processors/InboxProcessorService` @@ -129,59 +179,33 @@ export class ActivityPubServerService { this is also inspired by FireFish's `checkFetch` */ - /* tell any caching proxy that they should not cache these - responses: we wouldn't want the proxy to return a 403 to - someone presenting a valid signature, or return a cached - response body to someone we've blocked! - */ - reply.header('Cache-Control', 'private, max-age=0, must-revalidate'); - - /* we always allow requests about our instance actor, because when - a remote instance needs to check our signature on a request we - sent, it will need to fetch information about the user that - signed it (which is our instance actor), and if we try to check - their signature on *that* request, we'll fetch *their* instance - actor... leading to an infinite recursion */ - if (userId) { - const instanceActor = await this.instanceActorService.getInstanceActor(); - - if (userId === instanceActor.id || userId === instanceActor.username) { - this.authlogger.debug(`${request.id} ${request.url} request to instance.actor, letting through`); - return false; - } - } - - let signature; + let signature: httpSignature.IParsedSignature; try { - signature = httpSignature.parseRequest(request.raw, { 'headers': ['(request-target)', 'host', 'date'], authorizationHeaderName: 'signature' }); + signature = httpSignature.parseRequest(request.raw, { + headers: ['(request-target)', 'host', 'date'], + authorizationHeaderName: 'signature', + }); } catch (e) { // not signed, or malformed signature: refuse - this.authlogger.warn(`${request.id} ${request.url} not signed, or malformed signature: refuse`); - reply.code(401); - return true; + return `${request.id} ${request.url} not signed, or malformed signature: refuse`; } const keyId = new URL(signature.keyId); const keyHost = this.utilityService.toPuny(keyId.hostname); - const logPrefix = `${request.id} ${request.url} (by ${request.headers['user-agent']}) apparently from ${keyHost}:`; + const logPrefix = `${request.id} ${request.url} (by ${request.headers['user-agent']}) claims to be from ${keyHost}:`; - if (signature.params.headers.indexOf('host') === -1 - || request.headers.host !== this.config.host) { + if (signature.params.headers.indexOf('host') === -1 || request.headers.host !== this.config.host) { // no destination host, or not us: refuse - this.authlogger.warn(`${logPrefix} no destination host, or not us: refuse`); - reply.code(401); - return true; + return `${logPrefix} no destination host, or not us: refuse`; } if (!this.utilityService.isFederationAllowedHost(keyHost)) { /* blocked instance: refuse (we don't care if the signature is good, if they even pretend to be from a blocked instance, they're out) */ - this.authlogger.warn(`${logPrefix} instance is blocked: refuse`); - reply.code(401); - return true; + return `${logPrefix} instance is blocked: refuse`; } // do we know the signer already? @@ -200,34 +224,64 @@ export class ActivityPubServerService { if (authUser?.key == null) { // we can't figure out who the signer is, or we can't get their key: refuse - this.authlogger.warn(`${logPrefix} we can't figure out who the signer is, or we can't get their key: refuse`); - reply.code(401); - return true; + return `${logPrefix} we can't figure out who the signer is, or we can't get their key: refuse`; } - let httpSignatureValidated = httpSignature.verifySignature(signature, authUser.key.keyPem); + if (authUser.user.isSuspended) { + // Signer is suspended locally + return `${logPrefix} signer is suspended: refuse`; + } + + // some fedi implementations include the query (`?foo=bar`) in the + // signature, some don't, so we have to handle both cases + function verifyWithOrWithoutQuery() { + const httpSignatureValidated = httpSignature.verifySignature(signature, authUser!.key!.keyPem); + if (httpSignatureValidated) return true; + + const requestUrl = new URL(`http://whatever${request.raw.url}`); + if (! requestUrl.search) return false; + + // verification failed, the request URL contained a query, let's try without + const semiRawRequest = request.raw; + semiRawRequest.url = requestUrl.pathname; + + // no need for try/catch, if the original request parsed, this + // one will, too + const signatureWithoutQuery = httpSignature.parseRequest(semiRawRequest, { + headers: ['(request-target)', 'host', 'date'], + authorizationHeaderName: 'signature', + }); + + return httpSignature.verifySignature(signatureWithoutQuery, authUser!.key!.keyPem); + } + + let httpSignatureValidated = verifyWithOrWithoutQuery(); // maybe they changed their key? refetch it + // TODO rate-limit this using lastFetchedAt if (!httpSignatureValidated) { authUser.key = await this.apDbResolverService.refetchPublicKeyForApId(authUser.user); if (authUser.key != null) { - httpSignatureValidated = httpSignature.verifySignature(signature, authUser.key.keyPem); + httpSignatureValidated = verifyWithOrWithoutQuery(); } } if (!httpSignatureValidated) { // bad signature: refuse - this.authlogger.info(`${logPrefix} failed to validate signature: refuse`); - reply.code(401); - return true; + return `${logPrefix} failed to validate signature: refuse`; } // all good, don't refuse - return false; + return null; } @bindThis private inbox(request: FastifyRequest, reply: FastifyReply) { + if (this.meta.federation === 'none') { + reply.code(403); + return; + } + let signature; try { @@ -299,7 +353,13 @@ export class ActivityPubServerService { request: FastifyRequest<{ Params: { user: string; }; Querystring: { cursor?: string; page?: string; }; }>, reply: FastifyReply, ) { - if (await this.shouldRefuseGetRequest(request, reply, request.params.user)) return; + if (this.meta.federation === 'none') { + reply.code(403); + return; + } + + const { reject } = await this.checkAuthorizedFetch(request, reply, request.params.user); + if (reject) return; const userId = request.params.user; @@ -326,11 +386,9 @@ export class ActivityPubServerService { if (profile.followersVisibility === 'private') { reply.code(403); - if (!this.config.checkActivityPubGetSignature) reply.header('Cache-Control', 'public, max-age=30'); return; } else if (profile.followersVisibility === 'followers') { reply.code(403); - if (!this.config.checkActivityPubGetSignature) reply.header('Cache-Control', 'public, max-age=30'); return; } //#endregion @@ -382,7 +440,6 @@ export class ActivityPubServerService { user.followersCount, `${partOf}?page=true`, ); - if (!this.config.checkActivityPubGetSignature) reply.header('Cache-Control', 'public, max-age=180'); this.setResponseType(request, reply); return (this.apRendererService.addContext(rendered)); } @@ -393,7 +450,13 @@ export class ActivityPubServerService { request: FastifyRequest<{ Params: { user: string; }; Querystring: { cursor?: string; page?: string; }; }>, reply: FastifyReply, ) { - if (await this.shouldRefuseGetRequest(request, reply, request.params.user)) return; + if (this.meta.federation === 'none') { + reply.code(403); + return; + } + + const { reject } = await this.checkAuthorizedFetch(request, reply, request.params.user); + if (reject) return; const userId = request.params.user; @@ -420,11 +483,9 @@ export class ActivityPubServerService { if (profile.followingVisibility === 'private') { reply.code(403); - if (!this.config.checkActivityPubGetSignature) reply.header('Cache-Control', 'public, max-age=30'); return; } else if (profile.followingVisibility === 'followers') { reply.code(403); - if (!this.config.checkActivityPubGetSignature) reply.header('Cache-Control', 'public, max-age=30'); return; } //#endregion @@ -476,7 +537,6 @@ export class ActivityPubServerService { user.followingCount, `${partOf}?page=true`, ); - if (!this.config.checkActivityPubGetSignature) reply.header('Cache-Control', 'public, max-age=180'); this.setResponseType(request, reply); return (this.apRendererService.addContext(rendered)); } @@ -484,7 +544,13 @@ export class ActivityPubServerService { @bindThis private async featured(request: FastifyRequest<{ Params: { user: string; }; }>, reply: FastifyReply) { - if (await this.shouldRefuseGetRequest(request, reply, request.params.user)) return; + if (this.meta.federation === 'none') { + reply.code(403); + return; + } + + const { reject } = await this.checkAuthorizedFetch(request, reply, request.params.user); + if (reject) return; const userId = request.params.user; @@ -517,7 +583,6 @@ export class ActivityPubServerService { renderedNotes, ); - if (!this.config.checkActivityPubGetSignature) reply.header('Cache-Control', 'public, max-age=180'); this.setResponseType(request, reply); return (this.apRendererService.addContext(rendered)); } @@ -530,7 +595,13 @@ export class ActivityPubServerService { }>, reply: FastifyReply, ) { - if (await this.shouldRefuseGetRequest(request, reply, request.params.user)) return; + if (this.meta.federation === 'none') { + reply.code(403); + return; + } + + const { reject } = await this.checkAuthorizedFetch(request, reply, request.params.user); + if (reject) return; const userId = request.params.user; @@ -567,16 +638,28 @@ export class ActivityPubServerService { 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.limit(limit).getMany(); + const notes = this.meta.enableFanoutTimeline ? await this.fanoutTimelineEndpointService.getMiNotes({ + sinceId: sinceId ?? null, + untilId: untilId ?? null, + limit: limit, + allowPartial: false, // Possibly true? IDK it's OK for ordered collection. + me: null, + redisTimelines: [ + `userTimeline:${user.id}`, + `userTimelineWithReplies:${user.id}`, + ], + useDbFallback: true, + ignoreAuthorFromMute: true, + excludePureRenotes: false, + noteFilter: (note) => { + if (note.visibility !== 'home' && note.visibility !== 'public') return false; + if (note.localOnly) return false; + return true; + }, + dbFallback: async (untilId, sinceId, limit) => { + return await this.getUserNotesFromDb(sinceId, untilId, limit, user.id); + }, + }) : await this.getUserNotesFromDb(sinceId ?? null, untilId ?? null, limit, user.id); if (sinceId) notes.reverse(); @@ -608,14 +691,32 @@ export class ActivityPubServerService { `${partOf}?page=true`, `${partOf}?page=true&since_id=000000000000000000000000`, ); - if (!this.config.checkActivityPubGetSignature) reply.header('Cache-Control', 'public, max-age=180'); this.setResponseType(request, reply); return (this.apRendererService.addContext(rendered)); } } @bindThis - private async userInfo(request: FastifyRequest, reply: FastifyReply, user: MiUser | null) { + private async getUserNotesFromDb(untilId: string | null, sinceId: string | null, limit: number, userId: MiUser['id']) { + return await this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), sinceId, untilId) + .andWhere('note.userId = :userId', { userId }) + .andWhere(new Brackets(qb => { + qb + .where('note.visibility = \'public\'') + .orWhere('note.visibility = \'home\''); + })) + .andWhere('note.localOnly = FALSE') + .limit(limit) + .getMany(); + } + + @bindThis + private async userInfo(request: FastifyRequest, reply: FastifyReply, user: MiUser | null, redact = false) { + if (this.meta.federation === 'none') { + reply.code(403); + return; + } + if (user == null) { reply.code(404); return; @@ -631,10 +732,12 @@ export class ActivityPubServerService { return; } - if (!this.config.checkActivityPubGetSignature) reply.header('Cache-Control', 'public, max-age=180'); - this.setResponseType(request, reply); - return (this.apRendererService.addContext(await this.apRendererService.renderPerson(user as MiLocalUser))); + + const person = redact + ? await this.apRendererService.renderPersonRedacted(user as MiLocalUser) + : await this.apRendererService.renderPerson(user as MiLocalUser); + return this.apRendererService.addContext(person); } @bindThis @@ -687,6 +790,13 @@ export class ActivityPubServerService { reply.header('Access-Control-Allow-Methods', 'GET, OPTIONS'); reply.header('Access-Control-Allow-Origin', '*'); reply.header('Access-Control-Expose-Headers', 'Vary'); + + /* tell any caching proxy that they should not cache these + responses: we wouldn't want the proxy to return a 403 to + someone presenting a valid signature, or return a cached + response body to someone we've blocked! + */ + reply.header('Cache-Control', 'private, max-age=0, must-revalidate'); done(); }); @@ -697,16 +807,22 @@ export class ActivityPubServerService { // note fastify.get<{ Params: { note: string; } }>('/notes/:note', { constraints: { apOrHtml: 'ap' } }, async (request, reply) => { - if (await this.shouldRefuseGetRequest(request, reply)) return; - vary(reply.raw, 'Accept'); + if (this.meta.federation === 'none') { + reply.code(403); + return; + } + const note = await this.notesRepository.findOneBy({ id: request.params.note, visibility: In(['public', 'home']), localOnly: false, }); + const { reject } = await this.checkAuthorizedFetch(request, reply, note?.userId); + if (reject) return; + if (note == null) { reply.code(404); return; @@ -722,7 +838,6 @@ export class ActivityPubServerService { return; } - if (!this.config.checkActivityPubGetSignature) reply.header('Cache-Control', 'public, max-age=180'); this.setResponseType(request, reply); const author = await this.usersRepository.findOneByOrFail({ id: note.userId }); @@ -731,10 +846,13 @@ export class ActivityPubServerService { // note activity fastify.get<{ Params: { note: string; } }>('/notes/:note/activity', async (request, reply) => { - if (await this.shouldRefuseGetRequest(request, reply)) return; - vary(reply.raw, 'Accept'); + if (this.meta.federation === 'none') { + reply.code(403); + return; + } + const note = await this.notesRepository.findOneBy({ id: request.params.note, userHost: IsNull(), @@ -742,18 +860,66 @@ export class ActivityPubServerService { localOnly: false, }); + const { reject } = await this.checkAuthorizedFetch(request, reply, note?.userId); + if (reject) return; + if (note == null) { reply.code(404); return; } - if (!this.config.checkActivityPubGetSignature) reply.header('Cache-Control', 'public, max-age=180'); this.setResponseType(request, reply); const author = await this.usersRepository.findOneByOrFail({ id: note.userId }); return (this.apRendererService.addContext(await this.packActivity(note, author))); }); + // replies + fastify.get<{ + Params: { note: string; }; + Querystring: { page?: unknown; until_id?: unknown; }; + }>('/notes/:note/replies', async (request, reply) => { + vary(reply.raw, 'Accept'); + this.setResponseType(request, reply); + + // Raw query to avoid fetching the while entity just to check access and get the user ID + const note = await this.notesRepository + .createQueryBuilder('note') + .andWhere({ + id: request.params.note, + userHost: IsNull(), + visibility: In(['public', 'home']), + localOnly: false, + }) + .select(['note.id', 'note.userId']) + .getRawOne<{ note_id: string, note_userId: string }>(); + + const { reject } = await this.checkAuthorizedFetch(request, reply, note?.note_userId); + if (reject) return; + + if (note == null) { + reply.code(404); + return; + } + + const untilId = request.query.until_id; + if (untilId != null && typeof(untilId) !== 'string') { + reply.code(400); + return; + } + + // If page is unset, then we just provide the outer wrapper. + // This is because the spec doesn't allow the wrapper to contain both elements *and* pages. + // We could technically do it anyway, but that may break other instances. + if (request.query.page !== 'true') { + const collection = await this.apRendererService.renderRepliesCollection(note.note_id); + return this.apRendererService.addContext(collection); + } + + const page = await this.apRendererService.renderRepliesCollectionPage(note.note_id, untilId ?? undefined); + return this.apRendererService.addContext(page); + }); + // outbox fastify.get<{ Params: { user: string; }; @@ -777,7 +943,13 @@ export class ActivityPubServerService { // publickey fastify.get<{ Params: { user: string; } }>('/users/:user/publickey', async (request, reply) => { - if (await this.shouldRefuseGetRequest(request, reply, request.params.user)) return; + if (this.meta.federation === 'none') { + reply.code(403); + return; + } + + const { reject } = await this.checkAuthorizedFetch(request, reply, request.params.user, true); + if (reject) return; const userId = request.params.user; @@ -794,7 +966,6 @@ export class ActivityPubServerService { const keypair = await this.userKeypairService.getUserKeypair(user.id); if (this.userEntityService.isLocalUser(user)) { - if (!this.config.checkActivityPubGetSignature) reply.header('Cache-Control', 'public, max-age=180'); this.setResponseType(request, reply); return (this.apRendererService.addContext(this.apRendererService.renderKey(user, keypair))); } else { @@ -804,10 +975,16 @@ export class ActivityPubServerService { }); fastify.get<{ Params: { user: string; } }>('/users/:user', { constraints: { apOrHtml: 'ap' } }, async (request, reply) => { - if (await this.shouldRefuseGetRequest(request, reply, request.params.user)) return; + const { reject, redact } = await this.checkAuthorizedFetch(request, reply, request.params.user, true); + if (reject) return; vary(reply.raw, 'Accept'); + if (this.meta.federation === 'none') { + reply.code(403); + return; + } + const userId = request.params.user; const user = await this.usersRepository.findOneBy({ @@ -815,29 +992,41 @@ export class ActivityPubServerService { isSuspended: false, }); - return await this.userInfo(request, reply, user); + return await this.userInfo(request, reply, user, redact); }); fastify.get<{ Params: { acct: string; } }>('/@:acct', { constraints: { apOrHtml: 'ap' } }, async (request, reply) => { - if (await this.shouldRefuseGetRequest(request, reply, request.params.acct)) return; - vary(reply.raw, 'Accept'); + if (this.meta.federation === 'none') { + reply.code(403); + return; + } + const acct = Acct.parse(request.params.acct); const user = await this.usersRepository.findOneBy({ - usernameLower: acct.username, + usernameLower: acct.username.toLowerCase(), host: acct.host ?? IsNull(), isSuspended: false, }); - return await this.userInfo(request, reply, user); + const { reject, redact } = await this.checkAuthorizedFetch(request, reply, user?.id, true); + if (reject) return; + + return await this.userInfo(request, reply, user, redact); }); //#endregion // emoji fastify.get<{ Params: { emoji: string; } }>('/emojis/:emoji', async (request, reply) => { - if (await this.shouldRefuseGetRequest(request, reply)) return; + if (this.meta.federation === 'none') { + reply.code(403); + return; + } + + const { reject } = await this.checkAuthorizedFetch(request, reply); + if (reject) return; const emoji = await this.emojisRepository.findOneBy({ host: IsNull(), @@ -849,17 +1038,22 @@ export class ActivityPubServerService { return; } - if (!this.config.checkActivityPubGetSignature) reply.header('Cache-Control', 'public, max-age=180'); this.setResponseType(request, reply); return (this.apRendererService.addContext(await this.apRendererService.renderEmoji(emoji))); }); // like fastify.get<{ Params: { like: string; } }>('/likes/:like', async (request, reply) => { - if (await this.shouldRefuseGetRequest(request, reply)) return; + if (this.meta.federation === 'none') { + reply.code(403); + return; + } const reaction = await this.noteReactionsRepository.findOneBy({ id: request.params.like }); + const { reject } = await this.checkAuthorizedFetch(request, reply, reaction?.userId); + if (reject) return; + if (reaction == null) { reply.code(404); return; @@ -872,14 +1066,19 @@ export class ActivityPubServerService { return; } - if (!this.config.checkActivityPubGetSignature) reply.header('Cache-Control', 'public, max-age=180'); this.setResponseType(request, reply); return (this.apRendererService.addContext(await this.apRendererService.renderLike(reaction, note))); }); // follow fastify.get<{ Params: { follower: string; followee: string; } }>('/follows/:follower/:followee', async (request, reply) => { - if (await this.shouldRefuseGetRequest(request, reply)) return; + if (this.meta.federation === 'none') { + reply.code(403); + return; + } + + const { reject } = await this.checkAuthorizedFetch(request, reply, request.params.follower); + if (reject) return; // This may be used before the follow is completed, so we do not // check if the following exists. @@ -900,14 +1099,16 @@ export class ActivityPubServerService { return; } - if (!this.config.checkActivityPubGetSignature) reply.header('Cache-Control', 'public, max-age=180'); this.setResponseType(request, reply); return (this.apRendererService.addContext(this.apRendererService.renderFollow(follower, followee))); }); // follow - fastify.get<{ Params: { followRequestId: string ; } }>('/follows/:followRequestId', async (request, reply) => { - if (await this.shouldRefuseGetRequest(request, reply)) return; + fastify.get<{ Params: { followRequestId: string; } }>('/follows/:followRequestId', async (request, reply) => { + if (this.meta.federation === 'none') { + reply.code(403); + return; + } // This may be used before the follow is completed, so we do not // check if the following exists and only check if the follow request exists. @@ -916,6 +1117,9 @@ export class ActivityPubServerService { id: request.params.followRequestId, }); + const { reject } = await this.checkAuthorizedFetch(request, reply, followRequest?.followerId); + if (reject) return; + if (followRequest == null) { reply.code(404); return; @@ -937,11 +1141,21 @@ export class ActivityPubServerService { return; } - if (!this.config.checkActivityPubGetSignature) reply.header('Cache-Control', 'public, max-age=180'); this.setResponseType(request, reply); return (this.apRendererService.addContext(this.apRendererService.renderFollow(follower, followee))); }); done(); } + + private async getUnsignedFetchAllowance(userId: string | undefined) { + const user = userId ? await this.cacheService.findLocalUserById(userId) : null; + + // User system value if there is no user, or if user has deferred the choice. + if (!user?.allowUnsignedFetch || user.allowUnsignedFetch === 'staff') { + return this.meta.allowUnsignedFetch; + } + + return user.allowUnsignedFetch; + } } |