diff --git a/.config/ci.yml b/.config/ci.yml index 311a98d8fb..790c4704fa 100644 --- a/.config/ci.yml +++ b/.config/ci.yml @@ -263,12 +263,17 @@ checkActivityPubGetSignature: false # # default: false # disableQueryTruncation: false -# Log settings -# logging: -# sql: -# # Outputs query parameters during SQL execution to the log. -# # default: false -# enableQueryParamLogging: false -# # Disable query truncation. If set to true, the full text of the query will be output to the log. -# # default: false -# disableQueryTruncation: false +# Settings for the activity logger, which records inbound activities to the database. +# Disabled by default due to the large volume of data it saves. +#activityLogging: + # Log activities to the database (default: false) + #enabled: false + + # Save the activity before processing, then update later with the results. + # This has the advantage of capturing activities that cause a hard-crash, but doubles the number of queries used. + # Default: false + #preSave: false + + # How long to save each log entry before deleting it. + # Default: 2592000000 (1 week) + #maxAge: 2592000000 diff --git a/.config/cypress-devcontainer.yml b/.config/cypress-devcontainer.yml index 391bc9998c..9a6f9769e6 100644 --- a/.config/cypress-devcontainer.yml +++ b/.config/cypress-devcontainer.yml @@ -272,3 +272,18 @@ allowedPrivateNetworks: [ # # Disable query truncation. If set to true, the full text of the query will be output to the log. # # default: false # disableQueryTruncation: false + +# Settings for the activity logger, which records inbound activities to the database. +# Disabled by default due to the large volume of data it saves. +#activityLogging: + # Log activities to the database (default: false) + #enabled: false + + # Save the activity before processing, then update later with the results. + # This has the advantage of capturing activities that cause a hard-crash, but doubles the number of queries used. + # Default: false + #preSave: false + + # How long to save each log entry before deleting it. + # Default: 2592000000 (1 week) + #maxAge: 2592000000 diff --git a/.config/docker_example.yml b/.config/docker_example.yml index 1e03e902bf..2d088547ba 100644 --- a/.config/docker_example.yml +++ b/.config/docker_example.yml @@ -345,3 +345,18 @@ checkActivityPubGetSignature: false # # Disable query truncation. If set to true, the full text of the query will be output to the log. # # default: false # disableQueryTruncation: false + +# Settings for the activity logger, which records inbound activities to the database. +# Disabled by default due to the large volume of data it saves. +#activityLogging: + # Log activities to the database (default: false) + #enabled: false + + # Save the activity before processing, then update later with the results. + # This has the advantage of capturing activities that cause a hard-crash, but doubles the number of queries used. + # Default: false + #preSave: false + + # How long to save each log entry before deleting it. + # Default: 2592000000 (1 week) + #maxAge: 2592000000 diff --git a/.config/example.yml b/.config/example.yml index 7d4cd0c659..7bca8642be 100644 --- a/.config/example.yml +++ b/.config/example.yml @@ -383,3 +383,18 @@ checkActivityPubGetSignature: false # # Disable query truncation. If set to true, the full text of the query will be output to the log. # # default: false # disableQueryTruncation: false + +# Settings for the activity logger, which records inbound activities to the database. +# Disabled by default due to the large volume of data it saves. +#activityLogging: + # Log activities to the database (default: false) + #enabled: false + + # Save the activity before processing, then update later with the results. + # This has the advantage of capturing activities that cause a hard-crash, but doubles the number of queries used. + # Default: false + #preSave: false + + # How long to save each log entry before deleting it. + # Default: 2592000000 (1 week) + #maxAge: 2592000000 diff --git a/packages/backend/migration/1731565470048-add-activity-log.js b/packages/backend/migration/1731565470048-add-activity-log.js new file mode 100644 index 0000000000..19c6b336af --- /dev/null +++ b/packages/backend/migration/1731565470048-add-activity-log.js @@ -0,0 +1,28 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class AddActivityLog1731565470048 { + name = 'AddActivityLog1731565470048' + + async up(queryRunner) { + await queryRunner.query(`CREATE TABLE "activity_context" ("md5" text NOT NULL, "json" jsonb NOT NULL, CONSTRAINT "PK_activity_context" PRIMARY KEY ("md5"))`); + await queryRunner.query(`CREATE INDEX "IDK_activity_context_md5" ON "activity_context" ("md5") `); + await queryRunner.query(`CREATE TABLE "activity_log" ("id" character varying(32) NOT NULL, "at" TIMESTAMP WITH TIME ZONE NOT NULL, "key_id" text NOT NULL, "host" text NOT NULL, "verified" boolean NOT NULL, "accepted" boolean NOT NULL, "result" text NOT NULL, "activity" jsonb NOT NULL, "context_hash" text, "auth_user_id" character varying(32), CONSTRAINT "PK_activity_log" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE INDEX "IDX_activity_log_at" ON "activity_log" ("at") `); + await queryRunner.query(`CREATE INDEX "IDX_activity_log_host" ON "activity_log" ("host") `); + await queryRunner.query(`ALTER TABLE "activity_log" ADD CONSTRAINT "FK_activity_log_context_hash" FOREIGN KEY ("context_hash") REFERENCES "activity_context"("md5") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "activity_log" ADD CONSTRAINT "FK_activity_log_auth_user_id" FOREIGN KEY ("auth_user_id") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "activity_log" DROP CONSTRAINT "FK_activity_log_auth_user_id"`); + await queryRunner.query(`ALTER TABLE "activity_log" DROP CONSTRAINT "FK_activity_log_context_hash"`); + await queryRunner.query(`DROP INDEX "public"."IDX_activity_log_host"`); + await queryRunner.query(`DROP INDEX "public"."IDX_activity_log_at"`); + await queryRunner.query(`DROP TABLE "activity_log"`); + await queryRunner.query(`DROP INDEX "public"."IDK_activity_context_md5"`); + await queryRunner.query(`DROP TABLE "activity_context"`); + } +} diff --git a/packages/backend/migration/1731909785724-activity-log-timing.js b/packages/backend/migration/1731909785724-activity-log-timing.js new file mode 100644 index 0000000000..8b72fb8972 --- /dev/null +++ b/packages/backend/migration/1731909785724-activity-log-timing.js @@ -0,0 +1,19 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class ActivityLogTiming1731909785724 { + name = 'ActivityLogTiming1731909785724' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "activity_log" ADD "duration" double precision NOT NULL DEFAULT '0'`); + await queryRunner.query(`ALTER TABLE "activity_log" ALTER COLUMN "result" DROP NOT NULL`); + } + + async down(queryRunner) { + await queryRunner.query(`UPDATE "activity_log" SET "result" = 'not processed' WHERE "result" IS NULL`); + await queryRunner.query(`ALTER TABLE "activity_log" ALTER COLUMN "result" SET NOT NULL`); + await queryRunner.query(`ALTER TABLE "activity_log" DROP COLUMN "duration"`); + } +} diff --git a/packages/backend/migration/1731910422761-rename-activity-log-indexes.js b/packages/backend/migration/1731910422761-rename-activity-log-indexes.js new file mode 100644 index 0000000000..82d5a796e9 --- /dev/null +++ b/packages/backend/migration/1731910422761-rename-activity-log-indexes.js @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class RenameActivityLogIndexes1731910422761 { + name = 'RenameActivityLogIndexes1731910422761' + + async up(queryRunner) { + await queryRunner.query(`DROP INDEX "public"."IDK_activity_context_md5"`); + } + + async down(queryRunner) { + await queryRunner.query(`CREATE INDEX "IDK_activity_context_md5" ON "activity_context" ("md5") `); + } +} diff --git a/packages/backend/migration/1731935047347-nullable-activity-log-duration.js b/packages/backend/migration/1731935047347-nullable-activity-log-duration.js new file mode 100644 index 0000000000..2acbd2bca5 --- /dev/null +++ b/packages/backend/migration/1731935047347-nullable-activity-log-duration.js @@ -0,0 +1,20 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class NullableActivityLogDuration1731935047347 { + name = 'NullableActivityLogDuration1731935047347' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "activity_log" ALTER COLUMN "duration" DROP NOT NULL`); + await queryRunner.query(`ALTER TABLE "activity_log" ALTER COLUMN "duration" DROP DEFAULT`); + await queryRunner.query(`UPDATE "activity_log" SET "duration" = NULL WHERE "duration" = 0`); + } + + async down(queryRunner) { + await queryRunner.query(`UPDATE "activity_log" SET "duration" = 0 WHERE "duration" IS NULL`); + await queryRunner.query(`ALTER TABLE "activity_log" ALTER COLUMN "duration" SET DEFAULT '0'`); + await queryRunner.query(`ALTER TABLE "activity_log" ALTER COLUMN "duration" SET NOT NULL`); + } +} diff --git a/packages/backend/migration/1733756280460-rename_activity_log_to_ap_inbox_log.js b/packages/backend/migration/1733756280460-rename_activity_log_to_ap_inbox_log.js new file mode 100644 index 0000000000..ad25135188 --- /dev/null +++ b/packages/backend/migration/1733756280460-rename_activity_log_to_ap_inbox_log.js @@ -0,0 +1,32 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class RenameActivityLogToApInboxLog1733756280460 { + name = 'RenameActivityLogToApInboxLog1733756280460' + + async up(queryRunner) { + await queryRunner.query(`ALTER INDEX "IDX_activity_log_at" RENAME TO "IDX_ap_inbox_log_at"`); + await queryRunner.query(`ALTER INDEX "IDX_activity_log_host" RENAME TO "IDX_ap_inbox_log_host"`); + await queryRunner.query(`ALTER TABLE "activity_log" RENAME CONSTRAINT "PK_activity_log" TO "PK_ap_inbox_log"`); + await queryRunner.query(`ALTER TABLE "activity_log" RENAME CONSTRAINT "FK_activity_log_context_hash" TO "FK_ap_inbox_log_context_hash"`); + await queryRunner.query(`ALTER TABLE "activity_log" RENAME CONSTRAINT "FK_activity_log_auth_user_id" TO "FK_ap_inbox_log_auth_user_id"`); + await queryRunner.query(`ALTER TABLE "activity_log" RENAME TO "ap_inbox_log"`); + + await queryRunner.query(`ALTER TABLE "activity_context" RENAME CONSTRAINT "PK_activity_context" TO "PK_ap_context"`); + await queryRunner.query(`ALTER TABLE "activity_context" RENAME TO "ap_context"`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "ap_context" RENAME TO "activity_context"`); + await queryRunner.query(`ALTER TABLE "activity_context" RENAME CONSTRAINT "PK_ap_context" TO "PK_activity_context"`); + + await queryRunner.query(`ALTER TABLE "ap_inbox_log" RENAME TO "activity_log"`); + await queryRunner.query(`ALTER TABLE "activity_log" RENAME CONSTRAINT "FK_ap_inbox_log_auth_user_id" TO "FK_activity_log_auth_user_id"`); + await queryRunner.query(`ALTER TABLE "activity_log" RENAME CONSTRAINT "FK_ap_inbox_log_context_hash" TO "FK_activity_log_context_hash"`); + await queryRunner.query(`ALTER TABLE "activity_log" RENAME CONSTRAINT "PK_ap_inbox_log" TO "PK_activity_log"`); + await queryRunner.query(`ALTER INDEX "IDX_ap_inbox_log_host" RENAME TO "IDX_activity_log_host"`); + await queryRunner.query(`ALTER INDEX "IDX_ap_inbox_log_at" RENAME TO "IDX_activity_log_at"`); + } +} diff --git a/packages/backend/migration/1738293576355-create_ap_fetch_log.js b/packages/backend/migration/1738293576355-create_ap_fetch_log.js new file mode 100644 index 0000000000..4371f50b4a --- /dev/null +++ b/packages/backend/migration/1738293576355-create_ap_fetch_log.js @@ -0,0 +1,19 @@ +export class CreateApFetchLog1738293576355 { + name = 'CreateApFetchLog1738293576355' + + async up(queryRunner) { + await queryRunner.query(`CREATE TABLE "ap_fetch_log" ("id" character varying(32) NOT NULL, "at" TIMESTAMP WITH TIME ZONE NOT NULL, "duration" double precision, "host" text NOT NULL, "request_uri" text NOT NULL, "object_uri" text, "accepted" boolean, "result" text, "object" jsonb, "context_hash" text, CONSTRAINT "PK_ap_fetch_log" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE INDEX "IDX_ap_fetch_log_at" ON "ap_fetch_log" ("at") `); + await queryRunner.query(`CREATE INDEX "IDX_ap_fetch_log_host" ON "ap_fetch_log" ("host") `); + await queryRunner.query(`CREATE INDEX "IDX_ap_fetch_log_object_uri" ON "ap_fetch_log" ("object_uri") `); + await queryRunner.query(`ALTER TABLE "ap_fetch_log" ADD CONSTRAINT "FK_ap_fetch_log_context_hash" FOREIGN KEY ("context_hash") REFERENCES "ap_context"("md5") ON DELETE CASCADE ON UPDATE NO ACTION`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "ap_fetch_log" DROP CONSTRAINT "FK_ap_fetch_log_context_hash"`); + await queryRunner.query(`DROP INDEX "public"."IDX_ap_fetch_log_object_uri"`); + await queryRunner.query(`DROP INDEX "public"."IDX_ap_fetch_log_host"`); + await queryRunner.query(`DROP INDEX "public"."IDX_ap_fetch_log_at"`); + await queryRunner.query(`DROP TABLE "ap_fetch_log"`); + } +} diff --git a/packages/backend/src/boot/common.ts b/packages/backend/src/boot/common.ts index ad59a55688..2f97980e9a 100644 --- a/packages/backend/src/boot/common.ts +++ b/packages/backend/src/boot/common.ts @@ -13,6 +13,7 @@ import { ServerStatsService } from '@/daemons/ServerStatsService.js'; import { ServerService } from '@/server/ServerService.js'; import { MainModule } from '@/MainModule.js'; import { envOption } from '@/env.js'; +import { ApLogCleanupService } from '@/daemons/ApLogCleanupService.js'; export async function server() { const app = await NestFactory.createApplicationContext(MainModule, { @@ -28,6 +29,7 @@ export async function server() { if (!envOption.noDaemons) { app.get(QueueStatsService).start(); app.get(ServerStatsService).start(); + app.get(ApLogCleanupService).start(); } return app; diff --git a/packages/backend/src/config.ts b/packages/backend/src/config.ts index d35befdc2b..24f3c472a4 100644 --- a/packages/backend/src/config.ts +++ b/packages/backend/src/config.ts @@ -129,6 +129,12 @@ type Source = { enableQueryParamLogging? : boolean, } } + + activityLogging?: { + enabled?: boolean; + preSave?: boolean; + maxAge?: number; + }; }; export type Config = { @@ -238,6 +244,12 @@ export type Config = { pidFile: string; filePermissionBits?: string; + + activityLogging: { + enabled: boolean; + preSave: boolean; + maxAge: number; + }; }; export type FulltextSearchProvider = 'sqlLike' | 'sqlPgroonga' | 'meilisearch'; @@ -380,6 +392,11 @@ export function loadConfig(): Config { pidFile: config.pidFile, filePermissionBits: config.filePermissionBits, logging: config.logging, + activityLogging: { + enabled: config.activityLogging?.enabled ?? false, + preSave: config.activityLogging?.preSave ?? false, + maxAge: config.activityLogging?.maxAge ?? (1000 * 60 * 60 * 24 * 30), + }, }; } @@ -531,4 +548,5 @@ function applyEnvOverrides(config: Source) { _apply_top(['import', ['downloadTimeout', 'maxFileSize']]); _apply_top([['signToActivityPubGet', 'checkActivityPubGetSignature', 'setupPassword']]); _apply_top(['logging', 'sql', ['disableQueryTruncation', 'enableQueryParamLogging']]); + _apply_top(['activityLogging', ['enabled', 'preSave', 'maxAge']]); } diff --git a/packages/backend/src/core/ApLogService.ts b/packages/backend/src/core/ApLogService.ts new file mode 100644 index 0000000000..096ec21de7 --- /dev/null +++ b/packages/backend/src/core/ApLogService.ts @@ -0,0 +1,207 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { createHash } from 'crypto'; +import { Inject, Injectable } from '@nestjs/common'; +import { In, LessThan } from 'typeorm'; +import { DI } from '@/di-symbols.js'; +import { SkApFetchLog, SkApInboxLog, SkApContext } from '@/models/_.js'; +import type { ApContextsRepository, ApFetchLogsRepository, ApInboxLogsRepository } from '@/models/_.js'; +import type { Config } from '@/config.js'; +import { JsonValue } from '@/misc/json-value.js'; +import { UtilityService } from '@/core/UtilityService.js'; +import { IdService } from '@/core/IdService.js'; +import { IActivity, IObject } from './activitypub/type.js'; + +@Injectable() +export class ApLogService { + constructor( + @Inject(DI.config) + private readonly config: Config, + + @Inject(DI.apContextsRepository) + private apContextsRepository: ApContextsRepository, + + @Inject(DI.apInboxLogsRepository) + private readonly apInboxLogsRepository: ApInboxLogsRepository, + + @Inject(DI.apFetchLogsRepository) + private readonly apFetchLogsRepository: ApFetchLogsRepository, + + private readonly utilityService: UtilityService, + private readonly idService: IdService, + ) {} + + /** + * Creates an inbox log from an activity, and saves it if pre-save is enabled. + */ + public async createInboxLog(data: Partial & { + activity: IActivity, + keyId: string, + }): Promise { + const { object: activity, context, contextHash } = extractObjectContext(data.activity); + const host = this.utilityService.extractDbHost(data.keyId); + + const log = new SkApInboxLog({ + id: this.idService.gen(), + at: new Date(), + verified: false, + accepted: false, + host, + ...data, + activity, + context, + contextHash, + }); + + if (this.config.activityLogging.preSave) { + await this.saveInboxLog(log); + } + + return log; + } + + /** + * Saves or finalizes an inbox log. + */ + public async saveInboxLog(log: SkApInboxLog): Promise { + if (log.context) { + await this.saveContext(log.context); + } + + // Will be UPDATE with preSave, and INSERT without. + await this.apInboxLogsRepository.upsert(log, ['id']); + return log; + } + + /** + * Creates a fetch log from an activity, and saves it if pre-save is enabled. + */ + public async createFetchLog(data: Partial & { + requestUri: string + host: string, + }): Promise { + const log = new SkApFetchLog({ + id: this.idService.gen(), + at: new Date(), + accepted: false, + ...data, + }); + + if (this.config.activityLogging.preSave) { + await this.saveFetchLog(log); + } + + return log; + } + + /** + * Saves or finalizes a fetch log. + */ + public async saveFetchLog(log: SkApFetchLog): Promise { + if (log.context) { + await this.saveContext(log.context); + } + + // Will be UPDATE with preSave, and INSERT without. + await this.apFetchLogsRepository.upsert(log, ['id']); + return log; + } + + private async saveContext(context: SkApContext): Promise { + // https://stackoverflow.com/a/47064558 + await this.apContextsRepository + .createQueryBuilder('activity_context') + .insert() + .into(SkApContext) + .values(context) + .orIgnore('md5') + .execute(); + } + + /** + * Deletes all logged copies of an object or objects + * @param objectUris URIs / AP IDs of the objects to delete + */ + public async deleteObjectLogs(objectUris: string | string[]): Promise { + if (Array.isArray(objectUris)) { + const logsDeleted = await this.apFetchLogsRepository.delete({ + objectUri: In(objectUris), + }); + return logsDeleted.affected ?? 0; + } else { + const logsDeleted = await this.apFetchLogsRepository.delete({ + objectUri: objectUris, + }); + return logsDeleted.affected ?? 0; + } + } + + /** + * Deletes all expired AP logs and garbage-collects the AP context cache. + * Returns the total number of deleted rows. + */ + public async deleteExpiredLogs(): Promise { + // This is the date in UTC of the oldest log to KEEP + const oldestAllowed = new Date(Date.now() - this.config.activityLogging.maxAge); + + // Delete all logs older than the threshold. + const inboxDeleted = await this.deleteExpiredInboxLogs(oldestAllowed); + const fetchDeleted = await this.deleteExpiredFetchLogs(oldestAllowed); + + return inboxDeleted + fetchDeleted; + } + + private async deleteExpiredInboxLogs(oldestAllowed: Date): Promise { + const { affected } = await this.apInboxLogsRepository.delete({ + at: LessThan(oldestAllowed), + }); + + return affected ?? 0; + } + + private async deleteExpiredFetchLogs(oldestAllowed: Date): Promise { + const { affected } = await this.apFetchLogsRepository.delete({ + at: LessThan(oldestAllowed), + }); + + return affected ?? 0; + } +} + +export function extractObjectContext(input: T) { + const object = Object.assign({}, input, { '@context': undefined }) as Omit; + const { context, contextHash } = parseContext(input['@context']); + + return { object, context, contextHash }; +} + +export function parseContext(input: JsonValue | undefined): { contextHash: string | null, context: SkApContext | null } { + // Empty contexts are excluded for easier querying + if (input == null) { + return { + contextHash: null, + context: null, + }; + } + + const contextHash = createHash('md5').update(JSON.stringify(input)).digest('base64'); + const context = new SkApContext({ + md5: contextHash, + json: input, + }); + return { contextHash, context }; +} + +export function calculateDurationSince(startTime: bigint): number { + // Calculate the processing time with correct rounding and decimals. + // 1. Truncate nanoseconds to microseconds + // 2. Scale to 1/10 millisecond ticks. + // 3. Round to nearest tick. + // 4. Sale to milliseconds + // Example: 123,456,789 ns -> 123,456 us -> 12,345.6 ticks -> 12,346 ticks -> 123.46 ms + const endTime = process.hrtime.bigint(); + return Math.round(Number((endTime - startTime) / 1000n) / 10) / 100; +} diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts index 8c9f419c44..47be6967d7 100644 --- a/packages/backend/src/core/CoreModule.ts +++ b/packages/backend/src/core/CoreModule.ts @@ -157,6 +157,7 @@ import { QueueService } from './QueueService.js'; import { LoggerService } from './LoggerService.js'; import { SponsorsService } from './SponsorsService.js'; import type { Provider } from '@nestjs/common'; +import { ApLogService } from '@/core/ApLogService.js'; //#region 文字列ベースでのinjection用(循環参照対応のため) const $LoggerService: Provider = { provide: 'LoggerService', useExisting: LoggerService }; @@ -166,6 +167,7 @@ const $AccountMoveService: Provider = { provide: 'AccountMoveService', useExisti const $AccountUpdateService: Provider = { provide: 'AccountUpdateService', useExisting: AccountUpdateService }; const $AnnouncementService: Provider = { provide: 'AnnouncementService', useExisting: AnnouncementService }; const $AntennaService: Provider = { provide: 'AntennaService', useExisting: AntennaService }; +const $ApLogService: Provider = { provide: 'ApLogService', useExisting: ApLogService }; const $AppLockService: Provider = { provide: 'AppLockService', useExisting: AppLockService }; const $AchievementService: Provider = { provide: 'AchievementService', useExisting: AchievementService }; const $AvatarDecorationService: Provider = { provide: 'AvatarDecorationService', useExisting: AvatarDecorationService }; @@ -322,6 +324,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp AccountUpdateService, AnnouncementService, AntennaService, + ApLogService, AppLockService, AchievementService, AvatarDecorationService, @@ -474,6 +477,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp $AccountUpdateService, $AnnouncementService, $AntennaService, + $ApLogService, $AppLockService, $AchievementService, $AvatarDecorationService, @@ -627,6 +631,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp AccountUpdateService, AnnouncementService, AntennaService, + ApLogService, AppLockService, AchievementService, AvatarDecorationService, @@ -778,6 +783,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp $AccountUpdateService, $AnnouncementService, $AntennaService, + $ApLogService, $AppLockService, $AchievementService, $AvatarDecorationService, diff --git a/packages/backend/src/core/NoteDeleteService.ts b/packages/backend/src/core/NoteDeleteService.ts index b51a3143c9..cb258a4f5a 100644 --- a/packages/backend/src/core/NoteDeleteService.ts +++ b/packages/backend/src/core/NoteDeleteService.ts @@ -24,9 +24,14 @@ import { SearchService } from '@/core/SearchService.js'; import { ModerationLogService } from '@/core/ModerationLogService.js'; import { isQuote, isRenote } from '@/misc/is-renote.js'; import { LatestNoteService } from '@/core/LatestNoteService.js'; +import { ApLogService } from '@/core/ApLogService.js'; +import Logger from '@/logger.js'; +import { LoggerService } from './LoggerService.js'; @Injectable() export class NoteDeleteService { + private readonly logger: Logger; + constructor( @Inject(DI.config) private config: Config, @@ -55,7 +60,11 @@ export class NoteDeleteService { private perUserNotesChart: PerUserNotesChart, private instanceChart: InstanceChart, private latestNoteService: LatestNoteService, - ) {} + private readonly apLogService: ApLogService, + loggerService: LoggerService, + ) { + this.logger = loggerService.getLogger('note-delete-service'); + } /** * 投稿を削除します。 @@ -156,6 +165,11 @@ export class NoteDeleteService { note: note, }); } + + if (note.uri) { + this.apLogService.deleteObjectLogs(note.uri) + .catch(err => this.logger.error(err, `Failed to delete AP logs for note '${note.uri}'`)); + } } @bindThis diff --git a/packages/backend/src/core/activitypub/ApResolverService.ts b/packages/backend/src/core/activitypub/ApResolverService.ts index a0c3a4846c..410803609c 100644 --- a/packages/backend/src/core/activitypub/ApResolverService.ts +++ b/packages/backend/src/core/activitypub/ApResolverService.ts @@ -7,7 +7,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { IsNull, Not } from 'typeorm'; import type { MiLocalUser, MiRemoteUser } from '@/models/User.js'; import { InstanceActorService } from '@/core/InstanceActorService.js'; -import type { NotesRepository, PollsRepository, NoteReactionsRepository, UsersRepository, FollowRequestsRepository, MiMeta } from '@/models/_.js'; +import type { NotesRepository, PollsRepository, NoteReactionsRepository, UsersRepository, FollowRequestsRepository, MiMeta, SkApFetchLog } from '@/models/_.js'; import type { Config } from '@/config.js'; import { HttpRequestService } from '@/core/HttpRequestService.js'; import { DI } from '@/di-symbols.js'; @@ -17,7 +17,8 @@ import { LoggerService } from '@/core/LoggerService.js'; import type Logger from '@/logger.js'; import { fromTuple } from '@/misc/from-tuple.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; -import { isCollectionOrOrderedCollection } from './type.js'; +import { ApLogService, calculateDurationSince, extractObjectContext } from '@/core/ApLogService.js'; +import { getNullableApId, isCollectionOrOrderedCollection } from './type.js'; import { ApDbResolverService } from './ApDbResolverService.js'; import { ApRendererService } from './ApRendererService.js'; import { ApRequestService } from './ApRequestService.js'; @@ -43,6 +44,7 @@ export class Resolver { private apRendererService: ApRendererService, private apDbResolverService: ApDbResolverService, private loggerService: LoggerService, + private readonly apLogService: ApLogService, private recursionLimit = 256, ) { this.history = new Set(); @@ -81,6 +83,44 @@ export class Resolver { return value; } + const host = this.utilityService.extractDbHost(value); + if (this.config.activityLogging.enabled && !this.utilityService.isSelfHost(host)) { + return await this._resolveLogged(value, host); + } else { + return await this._resolve(value, host); + } + } + + private async _resolveLogged(requestUri: string, host: string): Promise { + const startTime = process.hrtime.bigint(); + + const log = await this.apLogService.createFetchLog({ + host: host, + requestUri, + }); + + try { + const result = await this._resolve(requestUri, host, log); + + log.accepted = true; + log.result = 'ok'; + + return result; + } catch (err) { + log.accepted = false; + log.result = String(err); + + throw err; + } finally { + log.duration = calculateDurationSince(startTime); + + // Save or finalize asynchronously + this.apLogService.saveFetchLog(log) + .catch(err => this.logger.error('Failed to record AP object fetch:', err)); + } + } + + private async _resolve(value: string, host: string, log?: SkApFetchLog): Promise { if (value.includes('#')) { // URLs with fragment parts cannot be resolved correctly because // the fragment part does not get transmitted over HTTP(S). @@ -98,7 +138,6 @@ export class Resolver { this.history.add(value); - const host = this.utilityService.extractDbHost(value); if (this.utilityService.isSelfHost(host)) { return await this.resolveLocal(value); } @@ -115,6 +154,20 @@ export class Resolver { ? await this.apRequestService.signedGet(value, this.user) as IObject : await this.httpRequestService.getActivityJson(value)) as IObject; + if (log) { + const { object: objectOnly, context, contextHash } = extractObjectContext(object); + const objectUri = getNullableApId(object); + + if (objectUri) { + log.objectUri = objectUri; + log.host = this.utilityService.extractDbHost(objectUri); + } + + log.object = objectOnly; + log.context = context; + log.contextHash = contextHash; + } + if ( Array.isArray(object['@context']) ? !(object['@context'] as unknown[]).includes('https://www.w3.org/ns/activitystreams') : @@ -232,6 +285,7 @@ export class ApResolverService { private apRendererService: ApRendererService, private apDbResolverService: ApDbResolverService, private loggerService: LoggerService, + private readonly apLogService: ApLogService, ) { } @@ -252,6 +306,7 @@ export class ApResolverService { this.apRendererService, this.apDbResolverService, this.loggerService, + this.apLogService, ); } } diff --git a/packages/backend/src/daemons/ApLogCleanupService.ts b/packages/backend/src/daemons/ApLogCleanupService.ts new file mode 100644 index 0000000000..61f76b4e2c --- /dev/null +++ b/packages/backend/src/daemons/ApLogCleanupService.ts @@ -0,0 +1,62 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable, type OnApplicationShutdown } from '@nestjs/common'; +import { bindThis } from '@/decorators.js'; +import { LoggerService } from '@/core/LoggerService.js'; +import Logger from '@/logger.js'; +import { ApLogService } from '@/core/ApLogService.js'; + +// 10 minutes +export const scanInterval = 1000 * 60 * 10; + +@Injectable() +export class ApLogCleanupService implements OnApplicationShutdown { + private readonly logger: Logger; + private scanTimer: NodeJS.Timeout | null = null; + + constructor( + private readonly apLogService: ApLogService, + loggerService: LoggerService, + ) { + this.logger = loggerService.getLogger('activity-log-cleanup'); + } + + @bindThis + public async start(): Promise { + // Just in case start() gets called multiple times. + this.dispose(); + + // Prune at startup, in case the server was rebooted during the interval. + // noinspection ES6MissingAwait + this.tick(); + + // Prune on a regular interval for the lifetime of the server. + this.scanTimer = setInterval(this.tick, scanInterval); + } + + @bindThis + private async tick(): Promise { + try { + const affected = await this.apLogService.deleteExpiredLogs(); + this.logger.info(`Activity Log cleanup complete; removed ${affected} expired logs.`); + } catch (err) { + this.logger.error('Activity Log cleanup failed:', err as Error); + } + } + + @bindThis + public onApplicationShutdown(): void { + this.dispose(); + } + + @bindThis + public dispose(): void { + if (this.scanTimer) { + clearInterval(this.scanTimer); + this.scanTimer = null; + } + } +} diff --git a/packages/backend/src/daemons/DaemonModule.ts b/packages/backend/src/daemons/DaemonModule.ts index a67907e6dd..ea71875f19 100644 --- a/packages/backend/src/daemons/DaemonModule.ts +++ b/packages/backend/src/daemons/DaemonModule.ts @@ -8,6 +8,7 @@ import { CoreModule } from '@/core/CoreModule.js'; import { GlobalModule } from '@/GlobalModule.js'; import { QueueStatsService } from './QueueStatsService.js'; import { ServerStatsService } from './ServerStatsService.js'; +import { ApLogCleanupService } from './ApLogCleanupService.js'; @Module({ imports: [ @@ -17,10 +18,12 @@ import { ServerStatsService } from './ServerStatsService.js'; providers: [ QueueStatsService, ServerStatsService, + ApLogCleanupService, ], exports: [ QueueStatsService, ServerStatsService, + ApLogCleanupService, ], }) export class DaemonModule {} diff --git a/packages/backend/src/di-symbols.ts b/packages/backend/src/di-symbols.ts index 296cc4815b..9f4ef5e2e9 100644 --- a/packages/backend/src/di-symbols.ts +++ b/packages/backend/src/di-symbols.ts @@ -22,6 +22,9 @@ export const DI = { appsRepository: Symbol('appsRepository'), avatarDecorationsRepository: Symbol('avatarDecorationsRepository'), latestNotesRepository: Symbol('latestNotesRepository'), + apContextsRepository: Symbol('apContextsRepository'), + apFetchLogsRepository: Symbol('apFetchLogsRepository'), + apInboxLogsRepository: Symbol('apInboxLogsRepository'), noteFavoritesRepository: Symbol('noteFavoritesRepository'), noteThreadMutingsRepository: Symbol('noteThreadMutingsRepository'), noteReactionsRepository: Symbol('noteReactionsRepository'), diff --git a/packages/backend/src/models/RepositoryModule.ts b/packages/backend/src/models/RepositoryModule.ts index 3a1158a42a..78510ba588 100644 --- a/packages/backend/src/models/RepositoryModule.ts +++ b/packages/backend/src/models/RepositoryModule.ts @@ -80,7 +80,10 @@ import { MiUserPublickey, MiUserSecurityKey, MiWebhook, - NoteEdit + NoteEdit, + SkApContext, + SkApFetchLog, + SkApInboxLog, } from './_.js'; import type { DataSource } from 'typeorm'; @@ -126,6 +129,24 @@ const $latestNotesRepository: Provider = { inject: [DI.db], }; +const $apContextRepository: Provider = { + provide: DI.apContextsRepository, + useFactory: (db: DataSource) => db.getRepository(SkApContext).extend(miRepository as MiRepository), + inject: [DI.db], +}; + +const $apFetchLogsRepository: Provider = { + provide: DI.apFetchLogsRepository, + useFactory: (db: DataSource) => db.getRepository(SkApFetchLog).extend(miRepository as MiRepository), + inject: [DI.db], +}; + +const $apInboxLogsRepository: Provider = { + provide: DI.apInboxLogsRepository, + useFactory: (db: DataSource) => db.getRepository(SkApInboxLog).extend(miRepository as MiRepository), + inject: [DI.db], +}; + const $noteFavoritesRepository: Provider = { provide: DI.noteFavoritesRepository, useFactory: (db: DataSource) => db.getRepository(MiNoteFavorite).extend(miRepository as MiRepository), @@ -526,6 +547,9 @@ const $noteScheduleRepository: Provider = { $appsRepository, $avatarDecorationsRepository, $latestNotesRepository, + $apContextRepository, + $apFetchLogsRepository, + $apInboxLogsRepository, $noteFavoritesRepository, $noteThreadMutingsRepository, $noteReactionsRepository, @@ -600,6 +624,9 @@ const $noteScheduleRepository: Provider = { $appsRepository, $avatarDecorationsRepository, $latestNotesRepository, + $apContextRepository, + $apFetchLogsRepository, + $apInboxLogsRepository, $noteFavoritesRepository, $noteThreadMutingsRepository, $noteReactionsRepository, diff --git a/packages/backend/src/models/SkApContext.ts b/packages/backend/src/models/SkApContext.ts new file mode 100644 index 0000000000..ff4c6d6fbf --- /dev/null +++ b/packages/backend/src/models/SkApContext.ts @@ -0,0 +1,25 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Column, PrimaryColumn, Entity } from 'typeorm'; + +@Entity('ap_context') +export class SkApContext { + @PrimaryColumn('text', { + primaryKeyConstraintName: 'PK_ap_context', + }) + public md5: string; + + @Column('jsonb') + // https://github.com/typeorm/typeorm/issues/8559 + // eslint-disable-next-line @typescript-eslint/no-explicit-any + public json: any; + + constructor(data?: Partial) { + if (data) { + Object.assign(this, data); + } + } +} diff --git a/packages/backend/src/models/SkApFetchLog.ts b/packages/backend/src/models/SkApFetchLog.ts new file mode 100644 index 0000000000..1e7d861b6c --- /dev/null +++ b/packages/backend/src/models/SkApFetchLog.ts @@ -0,0 +1,89 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Column, Index, JoinColumn, ManyToOne, PrimaryColumn, Entity } from 'typeorm'; +import { SkApContext } from '@/models/SkApContext.js'; +import { id } from './util/id.js'; + +/** + * Records objects fetched via AP + */ +@Entity('ap_fetch_log') +export class SkApFetchLog { + @PrimaryColumn({ + ...id(), + primaryKeyConstraintName: 'PK_ap_fetch_log', + }) + public id: string; + + @Index('IDX_ap_fetch_log_at') + @Column('timestamptz') + public at: Date; + + /** + * Processing duration in milliseconds + */ + @Column('double precision', { nullable: true }) + public duration: number | null = null; + + /** + * DB hostname extracted from responseUri, or requestUri if fetch is incomplete + */ + @Index('IDX_ap_fetch_log_host') + @Column('text') + public host: string; + + /** + * Original requested URI + */ + @Column('text', { + name: 'request_uri', + }) + public requestUri: string; + + /** + * Canonical URI / object ID, taken from the final payload + */ + @Column('text', { + name: 'object_uri', + nullable: true, + }) + @Index('IDX_ap_fetch_log_object_uri') + public objectUri: string | null = null; + + @Column('boolean', { nullable: true }) + public accepted: boolean | null = null; + + @Column('text', { nullable: true }) + public result: string | null = null; + + @Column('jsonb', { nullable: true }) + // https://github.com/typeorm/typeorm/issues/8559 + // eslint-disable-next-line @typescript-eslint/no-explicit-any + public object: any | null = null; + + @Column({ + type: 'text', + name: 'context_hash', + nullable: true, + }) + public contextHash: string | null; + + @ManyToOne(() => SkApContext, { + onDelete: 'CASCADE', + nullable: true, + }) + @JoinColumn({ + name: 'context_hash', + foreignKeyConstraintName: 'FK_ap_fetch_log_context_hash', + }) + public context: SkApContext | null; + + constructor(data?: Partial) { + if (data) { + Object.assign(this, data); + } + } +} diff --git a/packages/backend/src/models/SkApInboxLog.ts b/packages/backend/src/models/SkApInboxLog.ts new file mode 100644 index 0000000000..867094405c --- /dev/null +++ b/packages/backend/src/models/SkApInboxLog.ts @@ -0,0 +1,109 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Column, Entity, Index, JoinColumn, ManyToOne, PrimaryColumn } from 'typeorm'; +import { SkApContext } from '@/models/SkApContext.js'; +import { MiUser } from '@/models/_.js'; +import { id } from './util/id.js'; + +/** + * Records activities received in the inbox + */ +@Entity('ap_inbox_log') +export class SkApInboxLog { + @PrimaryColumn({ + ...id(), + primaryKeyConstraintName: 'PK_ap_inbox_log', + }) + public id: string; + + @Index('IDX_ap_inbox_log_at') + @Column('timestamptz') + public at: Date; + + /** + * Processing duration in milliseconds + */ + @Column('double precision', { nullable: true }) + public duration: number | null = null; + + /** + * Key ID that was used to sign this request. + * Untrusted unless verified is true. + */ + @Column({ + type: 'text', + name: 'key_id', + }) + public keyId: string; + + /** + * Instance that the activity was sent from. + * Untrusted unless verified is true. + */ + @Index('IDX_ap_inbox_log_host') + @Column('text') + public host: string; + + @Column('boolean') + public verified: boolean; + + @Column('boolean') + public accepted: boolean; + + @Column('text', { nullable: true }) + public result: string | null = null; + + @Column('jsonb') + // https://github.com/typeorm/typeorm/issues/8559 + // eslint-disable-next-line @typescript-eslint/no-explicit-any + public activity: any; + + @Column({ + type: 'text', + name: 'context_hash', + nullable: true, + }) + public contextHash: string | null; + + @ManyToOne(() => SkApContext, { + onDelete: 'CASCADE', + nullable: true, + }) + @JoinColumn({ + name: 'context_hash', + foreignKeyConstraintName: 'FK_ap_inbox_log_context_hash', + }) + public context: SkApContext | null; + + /** + * ID of the user who signed this request. + */ + @Column({ + ...id(), + name: 'auth_user_id', + nullable: true, + }) + public authUserId: string | null; + + /** + * User who signed this request. + */ + @ManyToOne(() => MiUser, { + onDelete: 'CASCADE', + nullable: true, + }) + @JoinColumn({ + name: 'auth_user_id', + foreignKeyConstraintName: 'FK_ap_inbox_log_auth_user_id', + }) + public authUser: MiUser | null; + + constructor(data?: Partial) { + if (data) { + Object.assign(this, data); + } + } +} diff --git a/packages/backend/src/models/_.ts b/packages/backend/src/models/_.ts index 9a4ebfc90f..4bd6e78ef4 100644 --- a/packages/backend/src/models/_.ts +++ b/packages/backend/src/models/_.ts @@ -82,6 +82,9 @@ import { NoteEdit } from '@/models/NoteEdit.js'; import { MiBubbleGameRecord } from '@/models/BubbleGameRecord.js'; import { MiReversiGame } from '@/models/ReversiGame.js'; import { MiNoteSchedule } from '@/models/NoteSchedule.js'; +import { SkApInboxLog } from '@/models/SkApInboxLog.js'; +import { SkApFetchLog } from '@/models/SkApFetchLog.js'; +import { SkApContext } from '@/models/SkApContext.js'; import type { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity.js'; export interface MiRepository { @@ -129,6 +132,9 @@ export const miRepository = { export { SkLatestNote, + SkApContext, + SkApFetchLog, + SkApInboxLog, MiAbuseUserReport, MiAbuseReportNotificationRecipient, MiAccessToken, @@ -229,6 +235,9 @@ export type HashtagsRepository = Repository & MiRepository export type InstancesRepository = Repository & MiRepository; export type MetasRepository = Repository & MiRepository; export type LatestNotesRepository = Repository & MiRepository; +export type ApContextsRepository = Repository & MiRepository; +export type ApFetchLogsRepository = Repository & MiRepository; +export type ApInboxLogsRepository = Repository & MiRepository; export type ModerationLogsRepository = Repository & MiRepository; export type MutingsRepository = Repository & MiRepository; export type RenoteMutingsRepository = Repository & MiRepository; diff --git a/packages/backend/src/postgres.ts b/packages/backend/src/postgres.ts index 98405052c6..1a5fdc8412 100644 --- a/packages/backend/src/postgres.ts +++ b/packages/backend/src/postgres.ts @@ -85,6 +85,9 @@ import { Config } from '@/config.js'; import MisskeyLogger from '@/logger.js'; import { bindThis } from '@/decorators.js'; import { SkLatestNote } from '@/models/LatestNote.js'; +import { SkApContext } from '@/models/SkApContext.js'; +import { SkApFetchLog } from '@/models/SkApFetchLog.js'; +import { SkApInboxLog } from '@/models/SkApInboxLog.js'; pg.types.setTypeParser(20, Number); @@ -171,6 +174,9 @@ class MyCustomLogger implements Logger { export const entities = [ SkLatestNote, + SkApContext, + SkApFetchLog, + SkApInboxLog, MiAnnouncement, MiAnnouncementRead, MiMeta, diff --git a/packages/backend/src/queue/processors/DeleteAccountProcessorService.ts b/packages/backend/src/queue/processors/DeleteAccountProcessorService.ts index e350b97f53..66bed72f18 100644 --- a/packages/backend/src/queue/processors/DeleteAccountProcessorService.ts +++ b/packages/backend/src/queue/processors/DeleteAccountProcessorService.ts @@ -15,6 +15,7 @@ import type { MiNoteReaction } from '@/models/NoteReaction.js'; import { EmailService } from '@/core/EmailService.js'; import { bindThis } from '@/decorators.js'; import { SearchService } from '@/core/SearchService.js'; +import { ApLogService } from '@/core/ApLogService.js'; import { ReactionService } from '@/core/ReactionService.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; import type * as Bull from 'bullmq'; @@ -45,6 +46,7 @@ export class DeleteAccountProcessorService { private queueLoggerService: QueueLoggerService, private searchService: SearchService, private reactionService: ReactionService, + private readonly apLogService: ApLogService, ) { this.logger = this.queueLoggerService.logger.createSubLogger('delete-account'); } @@ -84,6 +86,13 @@ export class DeleteAccountProcessorService { for (const note of notes) { await this.searchService.unindexNote(note); } + + // Delete note AP logs + const noteUris = notes.map(n => n.uri).filter(u => !!u) as string[]; + if (noteUris.length > 0) { + await this.apLogService.deleteObjectLogs(noteUris) + .catch(err => this.logger.error(err, `Failed to delete AP logs for notes of user '${user.uri ?? user.id}'`)); + } } this.logger.succ('All of notes deleted'); @@ -149,6 +158,13 @@ export class DeleteAccountProcessorService { this.logger.succ('All of files deleted'); } + { // Delete actor logs + if (user.uri) { + await this.apLogService.deleteObjectLogs(user.uri) + .catch(err => this.logger.error(err, `Failed to delete AP logs for user '${user.uri}'`)); + } + } + { // Send email notification const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id }); if (profile.email && profile.emailVerified) { diff --git a/packages/backend/src/queue/processors/InboxProcessorService.ts b/packages/backend/src/queue/processors/InboxProcessorService.ts index 87d4bf52fa..35a0bf095d 100644 --- a/packages/backend/src/queue/processors/InboxProcessorService.ts +++ b/packages/backend/src/queue/processors/InboxProcessorService.ts @@ -29,6 +29,9 @@ import { CollapsedQueue } from '@/misc/collapsed-queue.js'; import { MiNote } from '@/models/Note.js'; import { MiMeta } from '@/models/Meta.js'; import { DI } from '@/di-symbols.js'; +import { SkApInboxLog } from '@/models/_.js'; +import type { Config } from '@/config.js'; +import { ApLogService, calculateDurationSince } from '@/core/ApLogService.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; import type { InboxJobData } from '../types.js'; @@ -46,6 +49,9 @@ export class InboxProcessorService implements OnApplicationShutdown { @Inject(DI.meta) private meta: MiMeta, + @Inject(DI.config) + private config: Config, + private utilityService: UtilityService, private apInboxService: ApInboxService, private federatedInstanceService: FederatedInstanceService, @@ -57,6 +63,7 @@ export class InboxProcessorService implements OnApplicationShutdown { private apRequestChart: ApRequestChart, private federationChart: FederationChart, private queueLoggerService: QueueLoggerService, + private readonly apLogService: ApLogService, ) { this.logger = this.queueLoggerService.logger.createSubLogger('inbox'); this.updateInstanceQueue = new CollapsedQueue(process.env.NODE_ENV !== 'test' ? 60 * 1000 * 5 : 0, this.collapseUpdateInstanceJobs, this.performUpdateInstance); @@ -64,6 +71,41 @@ export class InboxProcessorService implements OnApplicationShutdown { @bindThis public async process(job: Bull.Job): Promise { + if (this.config.activityLogging.enabled) { + return await this._processLogged(job); + } else { + return await this._process(job); + } + } + + private async _processLogged(job: Bull.Job): Promise { + const startTime = process.hrtime.bigint(); + const activity = job.data.activity; + const keyId = job.data.signature.keyId; + const log = await this.apLogService.createInboxLog({ activity, keyId }); + + try { + const result = await this._process(job, log); + + log.accepted = result.startsWith('ok'); + log.result = result; + + return result; + } catch (err) { + log.accepted = false; + log.result = String(err); + + throw err; + } finally { + log.duration = calculateDurationSince(startTime); + + // Save or finalize asynchronously + this.apLogService.saveInboxLog(log) + .catch(err => this.logger.error('Failed to record AP activity:', err)); + } + } + + private async _process(job: Bull.Job, log?: SkApInboxLog): Promise { const signature = job.data.signature; // HTTP-signature let activity = job.data.activity; @@ -197,6 +239,13 @@ export class InboxProcessorService implements OnApplicationShutdown { delete activity.id; } + // Record verified user in log + if (log) { + log.verified = true; + log.authUser = authUser.user; + log.authUserId = authUser.user.id; + } + this.apRequestChart.inbox(); this.federationChart.inbox(authUser.user.host); diff --git a/packages/backend/test/misc/mock-resolver.ts b/packages/backend/test/misc/mock-resolver.ts index c8f3db8aac..791b04e6e2 100644 --- a/packages/backend/test/misc/mock-resolver.ts +++ b/packages/backend/test/misc/mock-resolver.ts @@ -23,6 +23,7 @@ import type { PollsRepository, UsersRepository, } from '@/models/_.js'; +import { ApLogService } from '@/core/ApLogService.js'; type MockResponse = { type: string; @@ -49,6 +50,7 @@ export class MockResolver extends Resolver { {} as ApRendererService, {} as ApDbResolverService, loggerService, + {} as ApLogService, ); }