diff --git a/locales/en-US.yml b/locales/en-US.yml index 10207d3a5e..1867609272 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -2734,6 +2734,9 @@ _moderationLogTypes: createInvitation: "Invite generated" createAd: "Ad created" deleteAd: "Ad deleted" + createMrfPolicy: "MRF policy created" + updateMrfPolicy: "MRF policy updated" + deleteMrfPolicy: "MRF policy deleted" updateAd: "Ad updated" createAvatarDecoration: "Avatar decoration created" updateAvatarDecoration: "Avatar decoration updated" diff --git a/locales/index.d.ts b/locales/index.d.ts index 0a9d09cfe3..fba8794289 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -12072,6 +12072,38 @@ export interface Locale extends ILocale { * Approvals */ "approvals": string; + /** + * Emoji suggestions + */ + "emojiSuggestions": string; + /** + * Suggest an emoji + */ + "suggestEmoji": string; + /** + * Propose a custom emoji for this server. Your image remains in your Drive while moderators review it. + */ + "emojiSuggestionDescription": string; + /** + * There are no pending emoji suggestions. + */ + "emojiSuggestionNoPending": string; + /** + * Proposed by + */ + "emojiSuggestionProposedBy": string; + /** + * Accept :{name}: and add it to this server? + */ + "emojiSuggestionConfirmAccept": ParameterizedString<"name">; + /** + * Reject the suggestion for :{name}:? The image will remain in the proposer's Drive. + */ + "emojiSuggestionConfirmReject": ParameterizedString<"name">; + /** + * Cancel your suggestion for :{name}:? The image will remain in your Drive. + */ + "emojiSuggestionConfirmCancel": ParameterizedString<"name">; /** * Open remote profile */ diff --git a/packages/backend/migration/1781706597000-MrfPolicy.js b/packages/backend/migration/1781706597000-MrfPolicy.js new file mode 100644 index 0000000000..0bcd251b7a --- /dev/null +++ b/packages/backend/migration/1781706597000-MrfPolicy.js @@ -0,0 +1,198 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class MrfPolicy1781706597000 { + name = 'MrfPolicy1781706597000' + + async up(queryRunner) { + await queryRunner.query(`CREATE TABLE "mrf_policy" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "name" character varying(256) NOT NULL, "enabled" boolean NOT NULL DEFAULT true, "priority" integer NOT NULL DEFAULT '1000', "source" text NOT NULL, "timeoutMs" integer NOT NULL DEFAULT '50', "scope" jsonb NOT NULL DEFAULT '{"activityTypes":["Create"],"objectTypes":["Note"]}', "isBuiltin" boolean NOT NULL DEFAULT false, "builtinPolicyId" character varying(128), "paramsSchema" jsonb NOT NULL DEFAULT '{}', "params" jsonb NOT NULL DEFAULT '{}', CONSTRAINT "PK_mrf_policy" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE INDEX "IDX_mrf_policy_enabled_priority" ON "mrf_policy" ("enabled", "priority")`); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_mrf_policy_builtinPolicyId" ON "mrf_policy" ("builtinPolicyId")`); + await queryRunner.query(`INSERT INTO "mrf_policy" ("id", "name", "enabled", "priority", "source", "timeoutMs", "isBuiltin", "builtinPolicyId", "paramsSchema", "params", "scope") VALUES ($1, $2, true, 10, $3, 50, true, $4, $5::jsonb, $6::jsonb, $7::jsonb)`, [ + 'mrfbuiltinkeyword001', + 'Keyword filter', + ` + policy = { + params = { + keywords = { + type = "string_array", + default = { + "https://discord.gg/ctkpaarr", + "@ap12@mastodon-japan.net", + "ctkpaarr", + }, + label = "Blocked keywords", + }, + }, + } + + function filter(ctx) + local note = mrf.activity.note(ctx.activity) + if note == nil then + return mrf.accept() + end + + local content = mrf.note.content(note) + if type(content) ~= "string" then + return mrf.accept() + end + + for _, keyword in ipairs(ctx.params.keywords) do + if string.find(content, keyword, 1, true) ~= nil then + return mrf.reject("keyword filter matched: " .. keyword) + end + end + + return mrf.accept() + end + `, + 'keyword-filter', + JSON.stringify({ + keywords: { + type: 'string_array', + default: [ + 'https://discord.gg/ctkpaarr', + '@ap12@mastodon-japan.net', + 'ctkpaarr', + ], + label: 'Blocked keywords', + }, + }), + '{}', + JSON.stringify({ + activityTypes: ['Create'], + objectTypes: ['Note'], + }), + ]); + await queryRunner.query(`INSERT INTO "mrf_policy" ("id", "name", "enabled", "priority", "source", "timeoutMs", "isBuiltin", "builtinPolicyId", "paramsSchema", "params", "scope") VALUES ($1, $2, true, 20, $3, 50, true, $4, $5::jsonb, $6::jsonb, $7::jsonb)`, [ + 'mrfbuiltinnewuserspam001', + 'New user spam mention filter', + ` + policy = { + params = { + maxFollowers = { + type = "integer", + default = 0, + label = "Maximum followers", + }, + maxFollowing = { + type = "integer", + default = 0, + label = "Maximum following", + }, + onlyTopLevelPosts = { + type = "boolean", + default = true, + label = "Only top-level posts", + }, + }, + } + + function filter(ctx) + local note = mrf.activity.note(ctx.activity) + if note == nil then + return mrf.accept() + end + + local mentions = mrf.note.mentions(note) + if #mentions == 0 then + return mrf.accept() + end + + local local_prefix = "https://" .. ctx.localHost .. "/" + local has_local_mention = false + for _, mention in ipairs(mentions) do + if type(mention.href) == "string" and string.sub(mention.href, 1, #local_prefix) == local_prefix then + has_local_mention = true + break + end + end + + if not has_local_mention then + return mrf.accept() + end + + if (ctx.actor.followersCount or 0) <= ctx.params.maxFollowers and (ctx.actor.followingCount or 0) <= ctx.params.maxFollowing and (not ctx.params.onlyTopLevelPosts or mrf.is_nil(note.inReplyTo)) then + mrf.note.remove_mentions(note) + return mrf.rewrite(ctx.activity, "stripped unsolicited local mentions from new remote actor") + end + + return mrf.accept() + end + `, + 'new-user-spam', + JSON.stringify({ + maxFollowers: { + type: 'integer', + default: 0, + label: 'Maximum followers', + }, + maxFollowing: { + type: 'integer', + default: 0, + label: 'Maximum following', + }, + onlyTopLevelPosts: { + type: 'boolean', + default: true, + label: 'Only top-level posts', + }, + }), + '{}', + JSON.stringify({ + activityTypes: ['Create'], + objectTypes: ['Note'], + }), + ]); + await queryRunner.query(`INSERT INTO "mrf_policy" ("id", "name", "enabled", "priority", "source", "timeoutMs", "isBuiltin", "builtinPolicyId", "paramsSchema", "params", "scope") VALUES ($1, $2, true, 30, $3, 50, true, $4, $5::jsonb, $6::jsonb, $7::jsonb)`, [ + 'mrfbuiltinhellthread001', + 'Hellthread mention filter', + ` + policy = { + params = { + mentionThreshold = { + type = "integer", + default = 15, + label = "Mention threshold", + }, + }, + } + + function filter(ctx) + local note = mrf.activity.note(ctx.activity) + if note == nil then + return mrf.accept() + end + + if mrf.note.mention_count(note) >= ctx.params.mentionThreshold then + mrf.note.remove_mentions(note) + return mrf.rewrite(ctx.activity, "stripped hellthread mentions") + end + + return mrf.accept() + end + `, + 'hellthread', + JSON.stringify({ + mentionThreshold: { + type: 'integer', + default: 15, + label: 'Mention threshold', + }, + }), + '{}', + JSON.stringify({ + activityTypes: ['Create'], + objectTypes: ['Note'], + }), + ]); + } + + async down(queryRunner) { + await queryRunner.query(`DROP INDEX "IDX_mrf_policy_builtinPolicyId"`); + await queryRunner.query(`DROP INDEX "IDX_mrf_policy_enabled_priority"`); + await queryRunner.query(`DROP TABLE "mrf_policy"`); + } +} diff --git a/packages/backend/migration/1786507200000-EmojiSuggestion.js b/packages/backend/migration/1786507200000-EmojiSuggestion.js new file mode 100644 index 0000000000..0e6dd4fa98 --- /dev/null +++ b/packages/backend/migration/1786507200000-EmojiSuggestion.js @@ -0,0 +1,34 @@ +/* + * SPDX-FileCopyrightText: Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * @typedef {import('typeorm').MigrationInterface} MigrationInterface + * @typedef {import('typeorm').QueryRunner} QueryRunner + */ + +/** + * @class + * @implements {MigrationInterface} + */ +export class EmojiSuggestion1786507200000 { + name = 'EmojiSuggestion1786507200000' + + /** + * @param {QueryRunner} queryRunner + */ + async up(queryRunner) { + await queryRunner.query(`CREATE TABLE "emoji_suggestion" ("id" character varying(32) NOT NULL, "userId" character varying(32) NOT NULL, "fileId" character varying(32) NOT NULL, "name" character varying(128) NOT NULL, "category" character varying(128), "aliases" character varying(128) array NOT NULL DEFAULT '{}', "license" character varying(1024), "localOnly" boolean NOT NULL DEFAULT false, "isSensitive" boolean NOT NULL DEFAULT false, CONSTRAINT "PK_emoji_suggestion" PRIMARY KEY ("id"), CONSTRAINT "FK_emoji_suggestion_user" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_emoji_suggestion_file" FOREIGN KEY ("fileId") REFERENCES "drive_file"("id") ON DELETE CASCADE ON UPDATE NO ACTION)`); + await queryRunner.query(`CREATE INDEX "IDX_emoji_suggestion_user" ON "emoji_suggestion" ("userId")`); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_emoji_suggestion_file" ON "emoji_suggestion" ("fileId")`); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_emoji_suggestion_name" ON "emoji_suggestion" ("name")`); + } + + /** + * @param {QueryRunner} queryRunner + */ + async down(queryRunner) { + await queryRunner.query(`DROP TABLE "emoji_suggestion"`); + } +} diff --git a/packages/backend/package.json b/packages/backend/package.json index 28a81b2405..db75515770 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -175,6 +175,7 @@ "ulid": "3.0.1", "uuid": "13.0.0", "vary": "1.1.2", + "wasmoon": "1.16.0", "web-push": "3.6.7", "ws": "8.19.0" }, diff --git a/packages/backend/src/core/CacheService.ts b/packages/backend/src/core/CacheService.ts index 7126f9d6f5..950ba431ee 100644 --- a/packages/backend/src/core/CacheService.ts +++ b/packages/backend/src/core/CacheService.ts @@ -789,8 +789,11 @@ export class CacheService implements OnApplicationShutdown { this.userRelationsCache.dropMany(relationKeysToClear); } - // Update the profile cache for local events only - // (isLocal may be undefined for local events, but will *always* be true for remote ones) + // Drop the cached profile. This runs for both local and remote events: + // isLocal is undefined for local events and false for remote ones, so the condition only skips + // callers that explicitly mark the event as local-only. + // For local events the quantum delete also syncs the purge to other processes; for remote events + // it is a redundant (but harmless) second purge. if (ctx.isLocal !== true) { await this.userProfileCache.delete(body.userId); } diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts index 80d4b32432..92276d822b 100644 --- a/packages/backend/src/core/CoreModule.ts +++ b/packages/backend/src/core/CoreModule.ts @@ -33,6 +33,7 @@ import { AchievementService } from './AchievementService.js'; import { AvatarDecorationService } from './AvatarDecorationService.js'; import { CaptchaService } from './CaptchaService.js'; import { CustomEmojiService } from './CustomEmojiService.js'; +import { EmojiSuggestionService } from './EmojiSuggestionService.js'; import { DeleteAccountService } from './DeleteAccountService.js'; import { DownloadService } from './DownloadService.js'; import { DriveService } from './DriveService.js'; @@ -115,6 +116,7 @@ import { ClipEntityService } from './entities/ClipEntityService.js'; import { DriveFileEntityService } from './entities/DriveFileEntityService.js'; import { DriveFolderEntityService } from './entities/DriveFolderEntityService.js'; import { EmojiEntityService } from './entities/EmojiEntityService.js'; +import { EmojiSuggestionEntityService } from './entities/EmojiSuggestionEntityService.js'; import { FollowingEntityService } from './entities/FollowingEntityService.js'; import { FollowRequestEntityService } from './entities/FollowRequestEntityService.js'; import { GalleryLikeEntityService } from './entities/GalleryLikeEntityService.js'; @@ -142,6 +144,7 @@ import { MetaEntityService } from './entities/MetaEntityService.js'; import { ApAudienceService } from './activitypub/ApAudienceService.js'; import { ApDbResolverService } from './activitypub/ApDbResolverService.js'; +import { MrfLuaPolicyService } from '@/core/activitypub/mrf/MrfLuaPolicyService.js'; import { ApDeliverManagerService } from './activitypub/ApDeliverManagerService.js'; import { ApInboxService } from './activitypub/ApInboxService.js'; import { ApLoggerService } from './activitypub/ApLoggerService.js'; @@ -175,6 +178,7 @@ const $AchievementService: Provider = { provide: 'AchievementService', useExisti const $AvatarDecorationService: Provider = { provide: 'AvatarDecorationService', useExisting: AvatarDecorationService }; const $CaptchaService: Provider = { provide: 'CaptchaService', useExisting: CaptchaService }; const $CustomEmojiService: Provider = { provide: 'CustomEmojiService', useExisting: CustomEmojiService }; +const $EmojiSuggestionService: Provider = { provide: 'EmojiSuggestionService', useExisting: EmojiSuggestionService }; const $DeleteAccountService: Provider = { provide: 'DeleteAccountService', useExisting: DeleteAccountService }; const $DownloadService: Provider = { provide: 'DownloadService', useExisting: DownloadService }; const $DriveService: Provider = { provide: 'DriveService', useExisting: DriveService }; @@ -269,6 +273,7 @@ const $ClipEntityService: Provider = { provide: 'ClipEntityService', useExisting const $DriveFileEntityService: Provider = { provide: 'DriveFileEntityService', useExisting: DriveFileEntityService }; const $DriveFolderEntityService: Provider = { provide: 'DriveFolderEntityService', useExisting: DriveFolderEntityService }; const $EmojiEntityService: Provider = { provide: 'EmojiEntityService', useExisting: EmojiEntityService }; +const $EmojiSuggestionEntityService: Provider = { provide: 'EmojiSuggestionEntityService', useExisting: EmojiSuggestionEntityService }; const $FollowingEntityService: Provider = { provide: 'FollowingEntityService', useExisting: FollowingEntityService }; const $FollowRequestEntityService: Provider = { provide: 'FollowRequestEntityService', useExisting: FollowRequestEntityService }; const $GalleryLikeEntityService: Provider = { provide: 'GalleryLikeEntityService', useExisting: GalleryLikeEntityService }; @@ -297,6 +302,10 @@ const $SystemWebhookEntityService: Provider = { provide: 'SystemWebhookEntitySer const $ApAudienceService: Provider = { provide: 'ApAudienceService', useExisting: ApAudienceService }; const $ApDbResolverService: Provider = { provide: 'ApDbResolverService', useExisting: ApDbResolverService }; +const $MrfLuaPolicyService: Provider = { + provide: MrfLuaPolicyService, + useFactory: () => new MrfLuaPolicyService(), +}; const $ApDeliverManagerService: Provider = { provide: 'ApDeliverManagerService', useExisting: ApDeliverManagerService }; const $ApInboxService: Provider = { provide: 'ApInboxService', useExisting: ApInboxService }; const $ApLoggerService: Provider = { provide: 'ApLoggerService', useExisting: ApLoggerService }; @@ -339,6 +348,7 @@ const $Imports = [ AvatarDecorationService, CaptchaService, CustomEmojiService, + EmojiSuggestionService, DeleteAccountService, DownloadService, DriveService, @@ -430,6 +440,7 @@ const $Imports = [ DriveFileEntityService, DriveFolderEntityService, EmojiEntityService, + EmojiSuggestionEntityService, FollowingEntityService, FollowRequestEntityService, GalleryLikeEntityService, @@ -494,6 +505,7 @@ const $Imports = [ $AvatarDecorationService, $CaptchaService, $CustomEmojiService, + $EmojiSuggestionService, $DeleteAccountService, $DownloadService, $DriveService, @@ -588,6 +600,7 @@ const $Imports = [ $DriveFileEntityService, $DriveFolderEntityService, $EmojiEntityService, + $EmojiSuggestionEntityService, $FollowingEntityService, $FollowRequestEntityService, $GalleryLikeEntityService, @@ -616,6 +629,7 @@ const $Imports = [ $ApAudienceService, $ApDbResolverService, + $MrfLuaPolicyService, $ApDeliverManagerService, $ApInboxService, $ApLoggerService, @@ -654,6 +668,7 @@ const $Imports = [ AvatarDecorationService, CaptchaService, CustomEmojiService, + EmojiSuggestionService, DeleteAccountService, DownloadService, DriveService, @@ -744,6 +759,7 @@ const $Imports = [ DriveFileEntityService, DriveFolderEntityService, EmojiEntityService, + EmojiSuggestionEntityService, FollowingEntityService, FollowRequestEntityService, GalleryLikeEntityService, @@ -808,6 +824,7 @@ const $Imports = [ $AvatarDecorationService, $CaptchaService, $CustomEmojiService, + $EmojiSuggestionService, $DeleteAccountService, $DownloadService, $DriveService, @@ -900,6 +917,7 @@ const $Imports = [ $DriveFileEntityService, $DriveFolderEntityService, $EmojiEntityService, + $EmojiSuggestionEntityService, $FollowingEntityService, $FollowRequestEntityService, $GalleryLikeEntityService, @@ -928,6 +946,7 @@ const $Imports = [ $ApAudienceService, $ApDbResolverService, + $MrfLuaPolicyService, $ApDeliverManagerService, $ApInboxService, $ApLoggerService, diff --git a/packages/backend/src/core/EmojiSuggestionService.ts b/packages/backend/src/core/EmojiSuggestionService.ts new file mode 100644 index 0000000000..dd9d566090 --- /dev/null +++ b/packages/backend/src/core/EmojiSuggestionService.ts @@ -0,0 +1,231 @@ +/* + * SPDX-FileCopyrightText: Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { IsNull } from 'typeorm'; +import { DI } from '@/di-symbols.js'; +import type { + DriveFilesRepository, + EmojisRepository, + EmojiSuggestionsRepository, + MiDriveFile, + MiEmoji, + MiEmojiSuggestion, + MiUser, +} from '@/models/_.js'; +import { FILE_TYPE_IMAGE } from '@/const.js'; +import { bindThis } from '@/decorators.js'; +import { IdService } from '@/core/IdService.js'; +import { CustomEmojiService } from '@/core/CustomEmojiService.js'; +import { DriveService } from '@/core/DriveService.js'; +import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js'; + +export const MAX_PENDING_EMOJI_SUGGESTIONS = 20; + +export type EmojiSuggestionError = + | 'duplicateName' + | 'duplicateSuggestion' + | 'noSuchFile' + | 'noSuchSuggestion' + | 'tooManyPendingSuggestions' + | 'unsupportedFileType'; + +export type EmojiSuggestionResult = + | { ok: true, value: T } + | { ok: false, reason: EmojiSuggestionError }; + +export type CreateEmojiSuggestionOptions = { + name: string; + fileId: string; + category: string | null; + aliases: string[]; + license: string | null; + localOnly: boolean; + isSensitive: boolean; +}; + +@Injectable() +export class EmojiSuggestionService { + constructor( + @Inject(DI.emojiSuggestionsRepository) + private readonly emojiSuggestionsRepository: EmojiSuggestionsRepository, + + @Inject(DI.driveFilesRepository) + private readonly driveFilesRepository: DriveFilesRepository, + + @Inject(DI.emojisRepository) + private readonly emojisRepository: EmojisRepository, + + private readonly customEmojiService: CustomEmojiService, + private readonly driveService: DriveService, + private readonly idService: IdService, + ) { + } + + @bindThis + public async create( + options: CreateEmojiSuggestionOptions, + user: MiUser, + ): Promise> { + const name = options.name.normalize('NFC'); + const file = await this.driveFilesRepository.findOneBy({ + id: options.fileId, + userId: user.id, + }); + if (file == null) return { ok: false, reason: 'noSuchFile' }; + if (!FILE_TYPE_IMAGE.includes(file.type)) return { ok: false, reason: 'unsupportedFileType' }; + if (await this.customEmojiService.checkDuplicate(name)) return { ok: false, reason: 'duplicateName' }; + + const [pendingCount, duplicateSuggestion] = await Promise.all([ + this.emojiSuggestionsRepository.countBy({ userId: user.id }), + this.emojiSuggestionsRepository.exists({ + where: [ + { userId: user.id, name }, + { fileId: file.id }, + ], + }), + ]); + if (pendingCount >= MAX_PENDING_EMOJI_SUGGESTIONS) return { ok: false, reason: 'tooManyPendingSuggestions' }; + if (duplicateSuggestion) return { ok: false, reason: 'duplicateSuggestion' }; + + let suggestion: MiEmojiSuggestion; + try { + suggestion = await this.emojiSuggestionsRepository.insertOne({ + id: this.idService.gen(), + userId: user.id, + fileId: file.id, + name, + category: options.category?.normalize('NFC') ?? null, + aliases: options.aliases.map(alias => alias.normalize('NFC')), + license: options.license, + localOnly: options.localOnly, + isSensitive: options.isSensitive, + }, { + relations: { + file: true, + user: true, + }, + }); + } catch (error) { + // The preflight check gives a useful early response, while the unique + // constraints close the race between simultaneous submissions. + if (isDuplicateKeyValueError(error)) return { ok: false, reason: 'duplicateSuggestion' }; + throw error; + } + + return { ok: true, value: suggestion }; + } + + @bindThis + public async accept( + suggestionId: string, + moderator: MiUser, + ): Promise> { + const suggestion = await this.emojiSuggestionsRepository.findOne({ + where: { id: suggestionId }, + relations: { file: true }, + }); + if (suggestion == null) return { ok: false, reason: 'noSuchSuggestion' }; + + // Consume the suggestion before doing any work. This makes acceptance, + // cancellation, rejection, and another acceptance mutually exclusive. + const claimed = await this.emojiSuggestionsRepository.delete({ + id: suggestion.id, + userId: suggestion.userId, + fileId: suggestion.fileId, + }); + if (claimed.affected !== 1) return { ok: false, reason: 'noSuchSuggestion' }; + + const restoreSuggestion = async () => { + await this.emojiSuggestionsRepository.insert({ + id: suggestion.id, + userId: suggestion.userId, + fileId: suggestion.fileId, + name: suggestion.name, + category: suggestion.category, + aliases: suggestion.aliases, + license: suggestion.license, + localOnly: suggestion.localOnly, + isSensitive: suggestion.isSensitive, + }); + }; + + let isDuplicate: boolean; + try { + isDuplicate = await this.customEmojiService.checkDuplicate(suggestion.name); + } catch (error) { + await restoreSuggestion(); + throw error; + } + if (isDuplicate) { + await restoreSuggestion(); + return { ok: false, reason: 'duplicateName' }; + } + + let emojiFile: MiDriveFile | undefined; + try { + // A suggestion may reference an avatar, banner, page image, or other + // shared Drive row. Give the emoji its own system-owned copy so its + // lifecycle cannot mutate or delete the proposer's original file. + emojiFile = await this.driveService.uploadFromUrl({ + url: suggestion.file.url, + user: null, + force: true, + }); + + const emoji = await this.customEmojiService.createEmoji({ + originalUrl: emojiFile.url, + publicUrl: emojiFile.webpublicUrl ?? emojiFile.url, + name: suggestion.name, + category: suggestion.category, + aliases: suggestion.aliases, + host: null, + license: suggestion.license, + isSensitive: suggestion.isSensitive, + localOnly: suggestion.localOnly, + roleIdsThatCanBeUsedThisEmojiAsReaction: [], + }, { moderator }); + + return { ok: true, value: emoji }; + } catch (error) { + if (emojiFile != null) { + // createEmoji inserts before publishing and moderation logging. If a + // post-insert hook failed, the durable result is still an acceptance. + const insertedEmoji = await this.emojisRepository.findOneBy({ + name: suggestion.name, + host: IsNull(), + originalUrl: emojiFile.url, + }); + if (insertedEmoji != null) return { ok: true, value: insertedEmoji }; + + try { + await this.driveService.deleteFile(emojiFile, false, moderator); + } finally { + await restoreSuggestion(); + } + } else { + await restoreSuggestion(); + } + + if (isDuplicateKeyValueError(error)) return { ok: false, reason: 'duplicateName' }; + throw error; + } + } + + @bindThis + public async cancel(suggestionId: string, user: MiUser): Promise { + const result = await this.emojiSuggestionsRepository.delete({ + id: suggestionId, + userId: user.id, + }); + return result.affected === 1; + } + + @bindThis + public async reject(suggestionId: string): Promise { + const result = await this.emojiSuggestionsRepository.delete(suggestionId); + return result.affected === 1; + } +} diff --git a/packages/backend/src/core/UtilityService.ts b/packages/backend/src/core/UtilityService.ts index cb599b1501..635f2a0578 100644 --- a/packages/backend/src/core/UtilityService.ts +++ b/packages/backend/src/core/UtilityService.ts @@ -253,7 +253,7 @@ export class UtilityService { @bindThis public isActiveLocalUser(user: MiLocalUser): boolean { - return !user.isDeleted && !user.isSuspended && user.approved; + return !user.isDeleted && !user.isSuspended && (user.approved || !this.meta.approvalRequiredForSignup); } @bindThis diff --git a/packages/backend/src/core/activitypub/ApRendererService.ts b/packages/backend/src/core/activitypub/ApRendererService.ts index 0d28cba47f..8a07f2327b 100644 --- a/packages/backend/src/core/activitypub/ApRendererService.ts +++ b/packages/backend/src/core/activitypub/ApRendererService.ts @@ -819,7 +819,7 @@ export class ApRendererService { const keypair = await this.userKeypairService.getUserKeypair(user.id); - activity = await this.jsonLdService.signRsaSignature2017(activity, keypair.privateKey, `${this.config.url}/users/${user.id}#main-key`); + activity = await this.jsonLdService.use().signRsaSignature2017(activity, keypair.privateKey, `${this.config.url}/users/${user.id}#main-key`); return activity; } diff --git a/packages/backend/src/core/activitypub/mrf/MrfLuaPolicyService.ts b/packages/backend/src/core/activitypub/mrf/MrfLuaPolicyService.ts new file mode 100644 index 0000000000..3e11baa195 --- /dev/null +++ b/packages/backend/src/core/activitypub/mrf/MrfLuaPolicyService.ts @@ -0,0 +1,1110 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { createHash } from 'node:crypto'; +import { LuaFactory } from 'wasmoon'; +import type { OnApplicationShutdown } from '@nestjs/common'; +import type { IActivity } from '@/core/activitypub/type.js'; + +export type MrfLuaPolicy = { + id: string; + name: string; + source: string; + /** + * Wall-clock budget for each Lua execution. wasmoon checks this across coroutine + * resumes, so time spent awaiting JS promises (mrf.lookup.* database calls) + * counts against it. Policies using lookups need a generous budget. + */ + timeoutMs?: number; + paramsSchema?: MrfLuaParamsSchema; + params?: MrfLuaParams; +}; + +export type MrfLuaPolicyContext = { + activity: IActivity; + params?: MrfLuaParams; + actor: { + uri: string; + host: string | null; + followersCount?: number; + followingCount?: number; + }; + localHost: string; + signerHost: string; + receivedAt: string; +}; + +export type MrfLuaDecision = + | { action: 'accept'; reason?: string } + | { action: 'reject'; reason: string } + | { action: 'rewrite'; activity: IActivity; reason?: string }; + +export type MrfLuaPolicyWarningCode = + | 'persistent_global_defined' + | 'persistent_global_modified'; + +export type MrfLuaPolicyWarning = { + code: MrfLuaPolicyWarningCode; + key: string; + message: string; +}; + +export type MrfLuaRunResult = { + policy: Pick; + decision: MrfLuaDecision; + durationMs: number; + warnings: MrfLuaPolicyWarning[]; +}; + +export type MrfLuaPolicyMetadata = { + paramsSchema: MrfLuaParamsSchema; + warnings: MrfLuaPolicyWarning[]; +}; + +export type MrfLuaLookupApi = { + userByUri?: (uri: string) => Promise | null>; + userByMention?: (mention: Record | string) => Promise | null>; + instanceByHost?: (host: string) => Promise | null>; + noteByUri?: (uri: string) => Promise | null>; +}; + +export type MrfLuaParamType = 'string' | 'boolean' | 'number' | 'integer' | 'string_array'; + +export type MrfLuaParamSchemaEntry = { + type: MrfLuaParamType; + default?: string | boolean | number | string[]; + label?: string; + description?: string; +}; + +export type MrfLuaParamsSchema = Record; +export type MrfLuaParams = Record; + +export type MrfLuaRunOptions = { + lookup?: MrfLuaLookupApi; +}; + +export type MrfLuaPolicyServiceOptions = { + defaultTimeoutMs: number; + defaultMemoryLimitBytes: number; + maxPreparedEnginesPerPolicy: number; + maxPreparedEngineUses: number; +}; + +const DEFAULT_OPTIONS = { + defaultTimeoutMs: 50, + defaultMemoryLimitBytes: 1024 * 1024 * 8, + maxPreparedEnginesPerPolicy: 2, + maxPreparedEngineUses: 1000, +} satisfies MrfLuaPolicyServiceOptions; + +const EXPECTED_POLICY_GLOBALS = new Set(['filter', 'mrf', 'policy']); + +const SANDBOX_PRELUDE = ` + os = nil + io = nil + package = nil + require = nil + debug = nil + dofile = nil + loadfile = nil + load = nil + collectgarbage = nil + Promise = nil + Error = nil + + local __mrf_null = rawget(_G, "null") + local __mrf_accept_fn = __mrf_accept + local __mrf_reject_fn = __mrf_reject + local __mrf_rewrite_fn = __mrf_rewrite + local __mrf_lookup_user_by_uri_fn = __mrf_lookup_user_by_uri + local __mrf_lookup_user_by_mention_fn = __mrf_lookup_user_by_mention + local __mrf_lookup_instance_by_host_fn = __mrf_lookup_instance_by_host + local __mrf_lookup_note_by_uri_fn = __mrf_lookup_note_by_uri + + null = nil + __mrf_accept = nil + __mrf_reject = nil + __mrf_rewrite = nil + __mrf_lookup_user_by_uri = nil + __mrf_lookup_user_by_mention = nil + __mrf_lookup_instance_by_host = nil + __mrf_lookup_note_by_uri = nil + + mrf = {} + + function mrf.is_nil(value) + return value == nil or value == __mrf_null + end + + function mrf.accept(reason) + return __mrf_accept_fn(reason) + end + + function mrf.reject(reason) + return __mrf_reject_fn(reason) + end + + function mrf.rewrite(activity, reason) + return __mrf_rewrite_fn(activity, reason) + end + + mrf.activity = {} + + function mrf.activity.object(activity) + if type(activity) ~= "table" then + return nil + end + return activity.object + end + + function mrf.activity.type(activity) + if type(activity) ~= "table" then + return nil + end + return activity.type + end + + function mrf.activity.actor_uri(activity) + if type(activity) ~= "table" then + return nil + end + if type(activity.actor) == "string" then + return activity.actor + end + if type(activity.actor) == "table" then + return activity.actor.id + end + return nil + end + + function mrf.activity.note(activity) + if type(activity) ~= "table" or activity.type ~= "Create" then + return nil + end + local object = activity.object + if type(object) ~= "table" or object.type ~= "Note" then + return nil + end + return object + end + + mrf.note = {} + + function mrf.note.content(note) + if type(note) ~= "table" then + return nil + end + return note.content + end + + function mrf.note.mentions(note) + local mentions = {} + if type(note) ~= "table" or type(note.tag) ~= "table" then + return mentions + end + for _, tag in ipairs(note.tag) do + if type(tag) == "table" and tag.type == "Mention" then + mentions[#mentions + 1] = tag + end + end + return mentions + end + + function mrf.note.mention_count(note) + return #mrf.note.mentions(note) + end + + function mrf.note.remove_mentions(note) + if type(note) ~= "table" or type(note.tag) ~= "table" then + return note + end + local tags = {} + for _, tag in ipairs(note.tag) do + if type(tag) ~= "table" or tag.type ~= "Mention" then + tags[#tags + 1] = tag + end + end + note.tag = tags + return note + end + + function mrf.note.mark_sensitive(note, reason) + if type(note) ~= "table" then + return note + end + note.sensitive = true + if reason ~= nil and note.summary == nil then + note.summary = reason + end + return note + end + + function mrf.note.unlist(note) + if type(note) ~= "table" then + return note + end + local public = "https://www.w3.org/ns/activitystreams#Public" + local function to_list(value) + if type(value) == "table" then + return value + end + if mrf.is_nil(value) then + return {} + end + return { value } + end + + local found = false + local kept_to = {} + for _, target in ipairs(to_list(note.to)) do + if target == public then + found = true + else + kept_to[#kept_to + 1] = target + end + end + + if not found then + return note + end + + local new_cc = {} + local has_public = false + for _, target in ipairs(to_list(note.cc)) do + if target == public then + has_public = true + end + new_cc[#new_cc + 1] = target + end + if not has_public then + new_cc[#new_cc + 1] = public + end + + if #kept_to > 0 then + note.to = kept_to + else + note.to = nil + end + note.cc = new_cc + return note + end + + function mrf.note.has_media(note) + return type(note) == "table" and type(note.attachment) == "table" and #note.attachment > 0 + end + + mrf.lookup = {} + + local function __mrf_nil_if_null(value) + if mrf.is_nil(value) then + return nil + end + return value + end + + function mrf.lookup.user_by_uri(uri) + if type(uri) ~= "string" then + return nil + end + return __mrf_nil_if_null(__mrf_lookup_user_by_uri_fn(uri):await()) + end + + function mrf.lookup.user_by_mention(mention) + if type(mention) ~= "table" and type(mention) ~= "string" then + return nil + end + return __mrf_nil_if_null(__mrf_lookup_user_by_mention_fn(mention):await()) + end + + function mrf.lookup.instance_by_host(host) + if type(host) ~= "string" then + return nil + end + return __mrf_nil_if_null(__mrf_lookup_instance_by_host_fn(host):await()) + end + + function mrf.lookup.note_by_uri(uri) + if type(uri) ~= "string" then + return nil + end + return __mrf_nil_if_null(__mrf_lookup_note_by_uri_fn(uri):await()) + end +`; + +const POLICY_ENVIRONMENT_SOURCE = ` + local __mrf_policy_env = { + mrf = mrf, + } + __mrf_policy_env._G = __mrf_policy_env + setmetatable(__mrf_policy_env, { __index = _G }) + rawset(_G, "__mrf_policy_env", __mrf_policy_env) +`; + +export class MrfLuaPolicyError extends Error { + constructor(policy: Pick, cause: unknown) { + super(`policy ${policy.id} failed: ${renderLuaError(cause)}`); + this.name = 'MrfLuaPolicyError'; + } +} + +type LuaEngine = Awaited>; + +type PreparedPolicyEngine = { + key: string; + lua: LuaEngine; + loadSnapshot: Map; + loadWarnings: MrfLuaPolicyWarning[]; + lookup?: MrfLuaLookupApi; + uses: number; +}; + +type PreparedPolicyEngineWaiter = { + resolve: (engine: PreparedPolicyEngine) => void; + reject: (error: unknown) => void; +}; + +type PreparedPolicyEnginePool = { + key: string; + policyId: string; + policy: Pick; + timeoutMs: number; + total: number; + available: PreparedPolicyEngine[]; + waiters: PreparedPolicyEngineWaiter[]; + retired: boolean; +}; + +export class MrfLuaPolicyService implements OnApplicationShutdown { + private readonly luaFactory = new LuaFactory(); + private readonly options: MrfLuaPolicyServiceOptions; + private readonly preparedEnginePools = new Map(); + + constructor(options?: Partial) { + const resolvedOptions = { + ...DEFAULT_OPTIONS, + ...options, + }; + this.options = { + ...resolvedOptions, + maxPreparedEnginesPerPolicy: normalizePositiveInteger(resolvedOptions.maxPreparedEnginesPerPolicy, DEFAULT_OPTIONS.maxPreparedEnginesPerPolicy), + maxPreparedEngineUses: normalizePositiveInteger(resolvedOptions.maxPreparedEngineUses, DEFAULT_OPTIONS.maxPreparedEngineUses), + }; + } + + public async run(policy: MrfLuaPolicy, context: MrfLuaPolicyContext, options: MrfLuaRunOptions = {}): Promise { + const start = performance.now(); + const timeoutMs = policy.timeoutMs ?? this.options.defaultTimeoutMs; + const paramsSchema = policy.paramsSchema ?? await this.extractParamsSchema(policy); + const params = this.resolveParams(paramsSchema, { + ...policy.params, + ...context.params, + }); + const clonedContext = structuredClone({ + ...context, + params, + }); + + let engine: PreparedPolicyEngine | undefined; + let reusable = false; + + try { + engine = await this.borrowPreparedPolicyEngine(policy, timeoutMs); + engine.lookup = options.lookup; + engine.uses += 1; + engine.lua.global.set('ctx', clonedContext); + + const returns = await this.runThread(engine.lua, 'return __mrf_policy_env.filter(ctx)', policy.name, timeoutMs); + const decision = this.parseDecision(returns[0], clonedContext.activity); + let warnings = this.mergeWarnings([...engine.loadWarnings]); + try { + const runtimeSnapshot = await this.capturePolicyGlobalSnapshot(engine.lua, timeoutMs); + const runtimeWarnings = this.createRuntimeWarnings(engine.loadSnapshot, runtimeSnapshot); + warnings = this.mergeWarnings([ + ...engine.loadWarnings, + ...runtimeWarnings, + ]); + reusable = runtimeWarnings.length === 0; + } catch { + // The decision is already computed; a snapshot failure must not discard it. + // Discard the engine instead, since we can no longer prove it is clean. + reusable = false; + } + + return { + policy: { + id: policy.id, + name: policy.name, + }, + decision, + durationMs: performance.now() - start, + warnings, + }; + } catch (error) { + throw new MrfLuaPolicyError(policy, error); + } finally { + if (engine != null) { + this.releasePreparedPolicyEngine(engine, reusable); + } + } + } + + public async extractParamsSchema(policy: Pick): Promise { + return (await this.extractPolicyMetadata(policy)).paramsSchema; + } + + public async extractPolicyMetadata(policy: Pick): Promise { + const timeoutMs = policy.timeoutMs ?? this.options.defaultTimeoutMs; + const lua = await this.luaFactory.createEngine({ + openStandardLibs: true, + injectObjects: true, + enableProxy: false, + traceAllocations: true, + functionTimeout: timeoutMs, + }); + + try { + lua.global.setMemoryMax(this.options.defaultMemoryLimitBytes); + lua.global.set('__mrf_accept', this.createMrfApi().accept); + lua.global.set('__mrf_reject', this.createMrfApi().reject); + lua.global.set('__mrf_rewrite', this.createMrfApi().rewrite); + lua.global.set('__mrf_lookup_user_by_uri', async () => null); + lua.global.set('__mrf_lookup_user_by_mention', async () => null); + lua.global.set('__mrf_lookup_instance_by_host', async () => null); + lua.global.set('__mrf_lookup_note_by_uri', async () => null); + await lua.doString(SANDBOX_PRELUDE); + const loadResult = await this.loadPolicySource(lua, policy.source, policy.name, timeoutMs); + const filterType = (await this.runThread(lua, 'return type(rawget(rawget(_G, "__mrf_policy_env"), "filter"))', 'mrf policy filter check', timeoutMs))[0]; + if (filterType !== 'function') { + throw new Error('policy source must define a global filter(ctx) function'); + } + const rawPolicy = await this.getPolicyEnvValue(lua, 'policy', timeoutMs); + + if (!isRecord(rawPolicy) || rawPolicy.params == null) { + return { + paramsSchema: {}, + warnings: loadResult.warnings, + }; + } + + return { + paramsSchema: this.parseParamsSchema(rawPolicy.params), + warnings: loadResult.warnings, + }; + } catch (error) { + throw new MrfLuaPolicyError(policy, error); + } finally { + lua.global.close(); + } + } + + public onApplicationShutdown(): void { + // Retaining an empty set retires every pool and closes idle engines; + // borrowed engines are closed on release because their pool is retired. + this.retainPreparedPolicyEngines([]); + } + + public retainPreparedPolicyEngines(policyIds: Iterable): void { + const retainedPolicyIds = new Set(policyIds); + for (const pool of this.preparedEnginePools.values()) { + if (retainedPolicyIds.has(pool.policyId)) continue; + this.retirePreparedPolicyEnginePool(pool); + } + } + + public resolveParams(schema: MrfLuaParamsSchema, params: MrfLuaParams = {}): MrfLuaParams { + const parsedParams = this.filterCompatibleParams(schema, params); + const resolved: MrfLuaParams = {}; + for (const [key, entry] of Object.entries(schema)) { + if (Object.hasOwn(parsedParams, key)) { + resolved[key] = parsedParams[key]; + } else if (Object.hasOwn(entry, 'default')) { + resolved[key] = structuredClone(entry.default); + } + } + + return resolved; + } + + public validateParams(schema: MrfLuaParamsSchema, params: MrfLuaParams = {}): MrfLuaParams { + const parsedParams: MrfLuaParams = {}; + for (const key of Object.keys(params)) { + if (!Object.hasOwn(schema, key)) { + throw new Error(`unknown policy parameter: ${key}`); + } + parsedParams[key] = this.parseParamValue(key, schema[key], params[key]); + } + return parsedParams; + } + + public filterCompatibleParams(schema: MrfLuaParamsSchema, params: MrfLuaParams = {}): MrfLuaParams { + const filtered: MrfLuaParams = {}; + for (const [key, value] of Object.entries(params)) { + const entry = schema[key]; + if (entry == null) continue; + try { + filtered[key] = this.parseParamValue(key, entry, value); + } catch { + // Drop stale values when a custom policy source changes its schema. + } + } + return filtered; + } + + private createMrfApi() { + return { + accept: (reason?: string) => ({ + action: 'accept', + ...(typeof reason === 'string' ? { reason } : {}), + }), + reject: (reason: string) => ({ + action: 'reject', + reason, + }), + rewrite: (activity: IActivity, reason?: string) => ({ + action: 'rewrite', + activity, + ...(typeof reason === 'string' ? { reason } : {}), + }), + }; + } + + private async runThread(lua: LuaEngine, source: string, name: string, timeoutMs: number): Promise { + // lua_newthread pushes the thread onto the parent stack; it MUST be removed + // after use or the main state's stack overflows its allocation after ~50 runs + // and corrupts the wasm heap. Mirrors wasmoon's own callByteCode() guard. + const thread = lua.global.newThread(); + const threadIndex = lua.global.getTop(); + try { + thread.loadString(source, name); + return await thread.run(0, { timeout: timeoutMs }); + } finally { + lua.global.remove(threadIndex); + } + } + + private async runString(lua: LuaEngine, source: string, name: string, timeoutMs: number): Promise { + await this.runThread(lua, source, name, timeoutMs); + } + + private async borrowPreparedPolicyEngine(policy: MrfLuaPolicy, timeoutMs: number): Promise { + const key = this.getPreparedPolicyEngineKey(policy, timeoutMs); + this.retireStalePreparedPolicyEngines(policy.id, key); + + let pool = this.preparedEnginePools.get(key); + if (pool == null) { + pool = { + key, + policyId: policy.id, + policy: { + id: policy.id, + name: policy.name, + source: policy.source, + timeoutMs: policy.timeoutMs, + }, + timeoutMs, + total: 0, + available: [], + waiters: [], + retired: false, + }; + this.preparedEnginePools.set(key, pool); + } + + const available = pool.available.pop(); + if (available != null) { + return available; + } + + if (pool.total < this.options.maxPreparedEnginesPerPolicy) { + pool.total += 1; + try { + return await this.createPreparedPolicyEngine(pool); + } catch (error) { + pool.total = Math.max(0, pool.total - 1); + this.deletePreparedPolicyEnginePoolIfIdle(pool); + throw error; + } + } + + return await new Promise((resolve, reject) => { + pool.waiters.push({ resolve, reject }); + }); + } + + private async createPreparedPolicyEngine(pool: PreparedPolicyEnginePool): Promise { + const lua = await this.luaFactory.createEngine({ + openStandardLibs: true, + injectObjects: true, + enableProxy: false, + traceAllocations: true, + functionTimeout: pool.timeoutMs, + }); + const engine: PreparedPolicyEngine = { + key: pool.key, + lua, + loadSnapshot: new Map(), + loadWarnings: [], + uses: 0, + }; + + try { + const mrfApi = this.createMrfApi(); + lua.global.setMemoryMax(this.options.defaultMemoryLimitBytes); + lua.global.set('__mrf_accept', mrfApi.accept); + lua.global.set('__mrf_reject', mrfApi.reject); + lua.global.set('__mrf_rewrite', mrfApi.rewrite); + lua.global.set('__mrf_lookup_user_by_uri', async (uri: string) => await (engine.lookup?.userByUri?.(uri) ?? null)); + lua.global.set('__mrf_lookup_user_by_mention', async (mention: Record | string) => await (engine.lookup?.userByMention?.(mention) ?? null)); + lua.global.set('__mrf_lookup_instance_by_host', async (host: string) => await (engine.lookup?.instanceByHost?.(host) ?? null)); + lua.global.set('__mrf_lookup_note_by_uri', async (uri: string) => await (engine.lookup?.noteByUri?.(uri) ?? null)); + lua.global.set('ctx', null); + await lua.doString(SANDBOX_PRELUDE); + const loadResult = await this.loadPolicySource(lua, pool.policy.source, pool.policy.name, pool.timeoutMs); + engine.loadSnapshot = loadResult.snapshot; + engine.loadWarnings = loadResult.warnings; + return engine; + } catch (error) { + lua.global.close(); + throw error; + } + } + + private releasePreparedPolicyEngine(engine: PreparedPolicyEngine, reusable: boolean): void { + const pool = this.preparedEnginePools.get(engine.key); + engine.lookup = undefined; + if (reusable) { + try { + engine.lua.global.set('ctx', null); + } catch { + reusable = false; + } + } + + if (pool == null || pool.retired || !reusable || engine.uses >= this.options.maxPreparedEngineUses) { + this.closePreparedPolicyEngine(engine); + if (pool != null) { + pool.total = Math.max(0, pool.total - 1); + this.startWaitingPreparedPolicyEngine(pool); + this.deletePreparedPolicyEnginePoolIfIdle(pool); + } + return; + } + + const waiter = pool.waiters.shift(); + if (waiter != null) { + waiter.resolve(engine); + return; + } + + pool.available.push(engine); + } + + private startWaitingPreparedPolicyEngine(pool: PreparedPolicyEnginePool): void { + const waiter = pool.waiters.shift(); + if (waiter == null) return; + if (pool.retired) { + waiter.reject(new Error('policy engine pool was retired')); + this.startWaitingPreparedPolicyEngine(pool); + return; + } + + pool.total += 1; + void this.createPreparedPolicyEngine(pool) + .then(waiter.resolve) + .catch(error => { + pool.total = Math.max(0, pool.total - 1); + waiter.reject(error); + this.startWaitingPreparedPolicyEngine(pool); + this.deletePreparedPolicyEnginePoolIfIdle(pool); + }); + } + + private retireStalePreparedPolicyEngines(policyId: string, currentKey: string): void { + for (const pool of this.preparedEnginePools.values()) { + if (pool.policyId !== policyId || pool.key === currentKey) continue; + this.retirePreparedPolicyEnginePool(pool); + } + } + + private retirePreparedPolicyEnginePool(pool: PreparedPolicyEnginePool): void { + pool.retired = true; + this.preparedEnginePools.delete(pool.key); + for (const engine of pool.available) { + this.closePreparedPolicyEngine(engine); + pool.total = Math.max(0, pool.total - 1); + } + pool.available = []; + for (const waiter of pool.waiters) { + waiter.reject(new Error('policy engine pool was retired')); + } + pool.waiters = []; + } + + private deletePreparedPolicyEnginePoolIfIdle(pool: PreparedPolicyEnginePool): void { + if (pool.total === 0 && pool.available.length === 0 && pool.waiters.length === 0 && this.preparedEnginePools.get(pool.key) === pool) { + this.preparedEnginePools.delete(pool.key); + } + } + + private closePreparedPolicyEngine(engine: PreparedPolicyEngine): void { + try { + engine.lua.global.close(); + } catch { + // Nothing useful can be done if an already-failed VM also fails to close. + } + } + + private getPreparedPolicyEngineKey(policy: MrfLuaPolicy, timeoutMs: number): string { + const hash = createHash('sha256') + .update(policy.id) + .update('\0') + .update(policy.name) + .update('\0') + .update(policy.source) + .update('\0') + .update(String(timeoutMs)) + .update('\0') + .update(String(this.options.defaultMemoryLimitBytes)) + .digest('hex'); + return `${policy.id}:${hash}`; + } + + private async loadPolicySource(lua: LuaEngine, source: string, name: string, timeoutMs: number): Promise<{ snapshot: Map; warnings: MrfLuaPolicyWarning[] }> { + await this.runString(lua, POLICY_ENVIRONMENT_SOURCE, 'mrf policy environment', timeoutMs); + const baseline = await this.capturePolicyGlobalSnapshot(lua, timeoutMs); + await this.runString(lua, this.wrapPolicySource(source), name, timeoutMs); + const snapshot = await this.capturePolicyGlobalSnapshot(lua, timeoutMs); + return { + snapshot, + warnings: this.createLoadWarnings(baseline, snapshot), + }; + } + + private wrapPolicySource(source: string): string { + return ` + local __mrf_policy_env = rawget(_G, "__mrf_policy_env") + do + local _ENV = __mrf_policy_env +${source} + end + `; + } + + private async getPolicyEnvValue(lua: LuaEngine, key: string, timeoutMs: number): Promise { + const returns = await this.runThread(lua, `return rawget(rawget(_G, "__mrf_policy_env"), ${JSON.stringify(key)})`, 'mrf policy metadata', timeoutMs); + return returns[0]; + } + + private async capturePolicyGlobalSnapshot(lua: LuaEngine, timeoutMs: number): Promise> { + const returns = await this.runThread(lua, ` + local env = rawget(_G, "__mrf_policy_env") + local result = {} + if type(env) ~= "table" then + return result + end + + local seen = {} + local function fingerprint(value, depth) + local value_type = type(value) + if value == nil then + return "nil" + end + if value_type == "string" or value_type == "number" or value_type == "boolean" then + return value_type .. ":" .. tostring(value) + end + if value_type ~= "table" then + return value_type .. ":" .. tostring(value) + end + if seen[value] then + return "table:" + end + if depth >= 3 then + return "table::" .. tostring(value) + end + + seen[value] = true + local entries = {} + local count = 0 + for item_key, item_value in pairs(value) do + count = count + 1 + if count > 64 then + entries[#entries + 1] = "..." + break + end + entries[#entries + 1] = fingerprint(item_key, depth + 1) .. "=" .. fingerprint(item_value, depth + 1) + end + table.sort(entries) + seen[value] = nil + return "table:{" .. table.concat(entries, ",") .. "}" + end + + for key, value in pairs(env) do + if type(key) == "string" and key ~= "_G" then + result[#result + 1] = { + key = key, + fingerprint = fingerprint(value, 0), + } + end + end + + return result + `, 'mrf policy globals snapshot', timeoutMs); + const entries = returns[0]; + const snapshot = new Map(); + if (!Array.isArray(entries)) { + return snapshot; + } + + for (const entry of entries) { + if (!isRecord(entry) || typeof entry.key !== 'string' || typeof entry.fingerprint !== 'string') continue; + snapshot.set(entry.key, entry.fingerprint); + } + + return new Map([...snapshot.entries()].sort(([left], [right]) => left.localeCompare(right))); + } + + private createLoadWarnings(before: Map, after: Map): MrfLuaPolicyWarning[] { + const keys = new Set([ + ...before.keys(), + ...after.keys(), + ]); + const warnings: MrfLuaPolicyWarning[] = []; + for (const key of [...keys].sort((left, right) => left.localeCompare(right))) { + if (!before.has(key)) { + if (EXPECTED_POLICY_GLOBALS.has(key)) continue; + warnings.push({ + code: 'persistent_global_defined', + key, + message: `Policy defines persistent global: ${key}`, + }); + continue; + } + if (before.get(key) !== after.get(key)) { + warnings.push({ + code: 'persistent_global_modified', + key, + message: `Policy modified persistent global while loading: ${key}`, + }); + } + } + return warnings; + } + + /** + * NOTE: the persistent-global warnings fingerprint only the policy environment + * (depth <= 3, <= 64 entries per table). Chunk-level `local` upvalues captured by + * filter() persist across pooled runs WITHOUT triggering a warning, as do + * mutations deeper than the fingerprint depth. Treat warnings as a lint, + * not an isolation guarantee. + */ + private createRuntimeWarnings(before: Map, after: Map): MrfLuaPolicyWarning[] { + const keys = new Set([ + ...before.keys(), + ...after.keys(), + ]); + return [...keys] + .sort((left, right) => left.localeCompare(right)) + .filter(key => before.get(key) !== after.get(key)) + .map(key => ({ + code: 'persistent_global_modified' as const, + key, + message: `Policy modified persistent global during execution: ${key}`, + })); + } + + private mergeWarnings(warnings: MrfLuaPolicyWarning[]): MrfLuaPolicyWarning[] { + const merged = new Map(); + for (const warning of warnings) { + merged.set(`${warning.code}:${warning.key}:${warning.message}`, warning); + } + return [...merged.values()]; + } + + private parseParamsSchema(value: unknown): MrfLuaParamsSchema { + if (!isRecord(value)) { + throw new Error('policy.params must be an object'); + } + + const schema: MrfLuaParamsSchema = {}; + for (const [key, entry] of Object.entries(value)) { + if (!/^[A-Za-z][A-Za-z0-9_]{0,63}$/.test(key)) { + throw new Error(`invalid policy parameter name: ${key}`); + } + if (!isRecord(entry)) { + throw new Error(`policy parameter ${key} must be an object`); + } + if (!isParamType(entry.type)) { + throw new Error(`policy parameter ${key} has unsupported type: ${String(entry.type)}`); + } + + const parsed: MrfLuaParamSchemaEntry = { + type: entry.type, + }; + if (Object.hasOwn(entry, 'default')) { + parsed.default = this.parseParamValue(key, parsed, entry.default) as MrfLuaParamSchemaEntry['default']; + } + if (entry.label !== undefined) { + if (typeof entry.label !== 'string' || entry.label.length > 128) { + throw new Error(`policy parameter ${key} label must be a string`); + } + parsed.label = entry.label; + } + if (entry.description !== undefined) { + if (typeof entry.description !== 'string' || entry.description.length > 512) { + throw new Error(`policy parameter ${key} description must be a string`); + } + parsed.description = entry.description; + } + + schema[key] = parsed; + } + + return schema; + } + + private parseParamValue(key: string, entry: MrfLuaParamSchemaEntry, value: unknown): unknown { + switch (entry.type) { + case 'string': + if (typeof value !== 'string') throw new Error(`policy parameter ${key} must be a string`); + return value; + case 'boolean': + if (typeof value !== 'boolean') throw new Error(`policy parameter ${key} must be a boolean`); + return value; + case 'number': + if (typeof value !== 'number' || !Number.isFinite(value)) throw new Error(`policy parameter ${key} must be a finite number`); + return value; + case 'integer': + if (typeof value !== 'number' || !Number.isInteger(value)) throw new Error(`policy parameter ${key} must be an integer`); + return value; + case 'string_array': + if (!Array.isArray(value) || value.some(item => typeof item !== 'string')) { + throw new Error(`policy parameter ${key} must be an array of strings`); + } + return [...value]; + } + } + + private parseDecision(value: unknown, originalActivity: IActivity): MrfLuaDecision { + if (!isRecord(value)) { + throw new Error('policy returned a non-object decision'); + } + + if (value.action === 'accept') { + return { + action: 'accept', + ...(typeof value.reason === 'string' ? { reason: value.reason } : {}), + }; + } + + if (value.action === 'reject') { + if (typeof value.reason !== 'string' || value.reason.length === 0) { + throw new Error('reject decision must include a reason'); + } + + return { + action: 'reject', + reason: value.reason, + }; + } + + if (value.action === 'rewrite') { + if (!isRecord(value.activity)) { + throw new Error('rewrite decision must include an activity'); + } + + const activity = this.normalizeLuaArrayShapes(value.activity, originalActivity) as unknown as IActivity; + this.assertJsonSafe(activity, 'activity'); + return { + action: 'rewrite', + activity, + ...(typeof value.reason === 'string' ? { reason: value.reason } : {}), + }; + } + + throw new Error(`unknown policy action: ${String(value.action)}`); + } + + private assertJsonSafe(value: unknown, path: string, seen = new Set()): void { + if (value === null) return; + switch (typeof value) { + case 'string': + case 'boolean': + return; + case 'number': + if (!Number.isFinite(value)) { + throw new Error(`rewrite activity contains a non-finite number at ${path}`); + } + return; + case 'object': + break; + default: + throw new Error(`rewrite activity contains a non-JSON value (${typeof value}) at ${path}`); + } + + const obj = value as object; + if (seen.has(obj)) { + throw new Error(`rewrite activity contains a cyclic reference at ${path}`); + } + seen.add(obj); + if (Array.isArray(obj)) { + for (let i = 0; i < obj.length; i++) { + this.assertJsonSafe(obj[i], `${path}[${i}]`, seen); + } + } else { + const proto = Object.getPrototypeOf(obj); + if (proto !== Object.prototype && proto !== null) { + throw new Error(`rewrite activity contains a non-plain object at ${path}`); + } + for (const [key, item] of Object.entries(obj as Record)) { + if (item === undefined) continue; + this.assertJsonSafe(item, `${path}.${key}`, seen); + } + } + seen.delete(obj); + } + + private normalizeLuaArrayShapes(value: unknown, template: unknown): unknown { + if (Array.isArray(template) && isRecord(value) && Object.keys(value).length === 0) { + return []; + } + + if (Array.isArray(value)) { + const templateArray = Array.isArray(template) ? template : []; + return value.map((item, index) => this.normalizeLuaArrayShapes(item, templateArray[index])); + } + + if (!isRecord(value) || !isRecord(template)) { + return value; + } + + for (const [key, item] of Object.entries(value)) { + value[key] = this.normalizeLuaArrayShapes(item, template[key]); + } + + return value; + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isParamType(value: unknown): value is MrfLuaParamType { + return value === 'string' || value === 'boolean' || value === 'number' || value === 'integer' || value === 'string_array'; +} + +function normalizePositiveInteger(value: number, fallback: number): number { + if (!Number.isFinite(value)) return fallback; + return Math.max(1, Math.trunc(value)); +} + +function renderLuaError(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + + return String(error); +} diff --git a/packages/backend/src/core/entities/EmojiSuggestionEntityService.ts b/packages/backend/src/core/entities/EmojiSuggestionEntityService.ts new file mode 100644 index 0000000000..cec74c766c --- /dev/null +++ b/packages/backend/src/core/entities/EmojiSuggestionEntityService.ts @@ -0,0 +1,55 @@ +/* + * SPDX-FileCopyrightText: Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable, OnModuleInit } from '@nestjs/common'; +import { ModuleRef } from '@nestjs/core'; +import type { MiEmojiSuggestion, MiUser } from '@/models/_.js'; +import type { Packed } from '@/misc/json-schema.js'; +import { bindThis } from '@/decorators.js'; +import { IdService } from '@/core/IdService.js'; +import type { UserEntityService } from './UserEntityService.js'; + +@Injectable() +export class EmojiSuggestionEntityService implements OnModuleInit { + private userEntityService: UserEntityService; + + constructor( + private readonly moduleRef: ModuleRef, + private readonly idService: IdService, + ) { + } + + @bindThis + public onModuleInit() { + this.userEntityService = this.moduleRef.get('UserEntityService'); + } + + @bindThis + public async pack( + suggestion: MiEmojiSuggestion, + me: MiUser, + ): Promise> { + return { + id: suggestion.id, + createdAt: this.idService.parse(suggestion.id).date.toISOString(), + name: suggestion.name, + category: suggestion.category, + aliases: suggestion.aliases, + license: suggestion.license, + localOnly: suggestion.localOnly, + isSensitive: suggestion.isSensitive, + url: suggestion.file.webpublicUrl ?? suggestion.file.url, + user: await this.userEntityService.pack(suggestion.user, me, { schema: 'UserLite' }), + }; + } + + @bindThis + public async packMany( + suggestions: MiEmojiSuggestion[], + me: MiUser, + ): Promise[]> { + return await Promise.all(suggestions.map(suggestion => this.pack(suggestion, me))); + } +} diff --git a/packages/backend/src/di-symbols.ts b/packages/backend/src/di-symbols.ts index dcabf48ee9..d474830796 100644 --- a/packages/backend/src/di-symbols.ts +++ b/packages/backend/src/di-symbols.ts @@ -34,6 +34,7 @@ export const DI = { apContextsRepository: Symbol('apContextsRepository'), apFetchLogsRepository: Symbol('apFetchLogsRepository'), apInboxLogsRepository: Symbol('apInboxLogsRepository'), + mrfPoliciesRepository: Symbol('mrfPoliciesRepository'), noteFavoritesRepository: Symbol('noteFavoritesRepository'), noteThreadMutingsRepository: Symbol('noteThreadMutingsRepository'), noteReactionsRepository: Symbol('noteReactionsRepository'), @@ -54,6 +55,7 @@ export const DI = { followRequestsRepository: Symbol('followRequestsRepository'), instancesRepository: Symbol('instancesRepository'), emojisRepository: Symbol('emojisRepository'), + emojiSuggestionsRepository: Symbol('emojiSuggestionsRepository'), driveFilesRepository: Symbol('driveFilesRepository'), driveFoldersRepository: Symbol('driveFoldersRepository'), metasRepository: Symbol('metasRepository'), diff --git a/packages/backend/src/misc/json-schema.ts b/packages/backend/src/misc/json-schema.ts index 8c9749ba2b..92ede82004 100644 --- a/packages/backend/src/misc/json-schema.ts +++ b/packages/backend/src/misc/json-schema.ts @@ -45,6 +45,7 @@ import { packedEmojiDetailedSchema, packedEmojiSimpleSchema, } from '@/models/json-schema/emoji.js'; +import { packedEmojiSuggestionSchema } from '@/models/json-schema/emoji-suggestion.js'; import { packedFlashSchema } from '@/models/json-schema/flash.js'; import { packedAnnouncementSchema } from '@/models/json-schema/announcement.js'; import { packedSigninSchema } from '@/models/json-schema/signin.js'; @@ -62,6 +63,7 @@ import { packedRoleSchema, } from '@/models/json-schema/role.js'; import { packedAdSchema } from '@/models/json-schema/ad.js'; +import { packedMrfPolicySchema } from '@/models/json-schema/mrf-policy.js'; import { packedReversiGameDetailedSchema, packedReversiGameLiteSchema } from '@/models/json-schema/reversi-game.js'; import { packedMetaDetailedOnlySchema, @@ -119,6 +121,7 @@ export const refs = { EmojiSimple: packedEmojiSimpleSchema, EmojiDetailed: packedEmojiDetailedSchema, EmojiDetailedAdmin: packedEmojiDetailedAdminSchema, + EmojiSuggestion: packedEmojiSuggestionSchema, Flash: packedFlashSchema, Signin: packedSigninSchema, RoleCondFormulaLogics: packedRoleCondFormulaLogicsSchema, @@ -146,6 +149,7 @@ export const refs = { ChatRoom: packedChatRoomSchema, ChatRoomInvitation: packedChatRoomInvitationSchema, ChatRoomMembership: packedChatRoomMembershipSchema, + MrfPolicy: packedMrfPolicySchema, }; export type Packed = SchemaType; diff --git a/packages/backend/src/models/EmojiSuggestion.ts b/packages/backend/src/models/EmojiSuggestion.ts new file mode 100644 index 0000000000..ab67a4cbae --- /dev/null +++ b/packages/backend/src/models/EmojiSuggestion.ts @@ -0,0 +1,70 @@ +/* + * SPDX-FileCopyrightText: Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Column, Entity, Index, JoinColumn, ManyToOne, PrimaryColumn } from 'typeorm'; +import { MiDriveFile } from '@/models/DriveFile.js'; +import { MiUser } from '@/models/User.js'; +import { id } from './util/id.js'; + +@Entity('emoji_suggestion') +export class MiEmojiSuggestion { + @PrimaryColumn(id()) + public id: string; + + @Index('IDX_emoji_suggestion_user') + @Column(id()) + public userId: MiUser['id']; + + @ManyToOne(() => MiUser, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public user: MiUser; + + @Index('IDX_emoji_suggestion_file', { unique: true }) + @Column(id()) + public fileId: MiDriveFile['id']; + + @ManyToOne(() => MiDriveFile, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public file: MiDriveFile; + + @Index('IDX_emoji_suggestion_name', { unique: true }) + @Column('varchar', { + length: 128, + }) + public name: string; + + @Column('varchar', { + length: 128, + nullable: true, + }) + public category: string | null; + + @Column('varchar', { + array: true, + length: 128, + default: '{}', + }) + public aliases: string[]; + + @Column('varchar', { + length: 1024, + nullable: true, + }) + public license: string | null; + + @Column('boolean', { + default: false, + }) + public localOnly: boolean; + + @Column('boolean', { + default: false, + }) + public isSensitive: boolean; +} diff --git a/packages/backend/src/models/MrfPolicy.ts b/packages/backend/src/models/MrfPolicy.ts new file mode 100644 index 0000000000..43ab8098da --- /dev/null +++ b/packages/backend/src/models/MrfPolicy.ts @@ -0,0 +1,117 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Column, Entity, Index, PrimaryColumn } from 'typeorm'; +import type { MrfLuaParams, MrfLuaParamsSchema } from '@/core/activitypub/mrf/MrfLuaPolicyService.js'; +import { id } from './util/id.js'; + +export type MrfPolicyScope = { + activityTypes: string[] | null; + objectTypes: string[] | null; +}; + +export const DEFAULT_MRF_POLICY_SCOPE = { + activityTypes: ['Create'], + objectTypes: ['Note'], +} satisfies MrfPolicyScope; + +export function normalizeMrfPolicyScope(scope: unknown): MrfPolicyScope { + if (!isRecord(scope)) { + return structuredClone(DEFAULT_MRF_POLICY_SCOPE); + } + + return { + activityTypes: normalizeScopeTypes(scope.activityTypes, DEFAULT_MRF_POLICY_SCOPE.activityTypes), + objectTypes: normalizeScopeTypes(scope.objectTypes, DEFAULT_MRF_POLICY_SCOPE.objectTypes), + }; +} + +function normalizeScopeTypes(value: unknown, fallback: string[] | null): string[] | null { + if (value === null) return null; + if (!Array.isArray(value)) return fallback == null ? null : [...fallback]; + + const seen = new Set(); + const types: string[] = []; + for (const item of value) { + if (typeof item !== 'string' || item.length === 0) { + return fallback == null ? null : [...fallback]; + } + if (seen.has(item)) continue; + seen.add(item); + types.push(item); + } + return types; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +@Index('IDX_mrf_policy_enabled_priority', ['enabled', 'priority']) +@Index('IDX_mrf_policy_builtinPolicyId', ['builtinPolicyId'], { unique: true }) +@Entity('mrf_policy') +export class MiMrfPolicy { + @PrimaryColumn(id()) + public id: string; + + @Column('timestamp with time zone', { + default: () => 'CURRENT_TIMESTAMP', + }) + public createdAt: Date; + + @Column('timestamp with time zone', { + default: () => 'CURRENT_TIMESTAMP', + }) + public updatedAt: Date; + + @Column('varchar', { + length: 256, + }) + public name: string; + + @Column('boolean', { + default: true, + }) + public enabled: boolean; + + @Column('integer', { + default: 1000, + }) + public priority: number; + + @Column('text') + public source: string; + + @Column('integer', { + default: 50, + }) + public timeoutMs: number; + + @Column('jsonb', { + default: DEFAULT_MRF_POLICY_SCOPE, + }) + public scope: MrfPolicyScope; + + @Column('boolean', { + default: false, + }) + public isBuiltin: boolean; + + @Column('varchar', { + length: 128, + nullable: true, + }) + public builtinPolicyId: string | null; + + @Column('jsonb', { + default: {}, + }) + public paramsSchema: MrfLuaParamsSchema; + + @Column('jsonb', { + default: {}, + }) + public params: MrfLuaParams; +} diff --git a/packages/backend/src/models/RepositoryModule.ts b/packages/backend/src/models/RepositoryModule.ts index e114654c77..5eae64b09d 100644 --- a/packages/backend/src/models/RepositoryModule.ts +++ b/packages/backend/src/models/RepositoryModule.ts @@ -27,6 +27,7 @@ import { MiDriveFile, MiDriveFolder, MiEmoji, + MiEmojiSuggestion, MiFlash, MiFlashLike, MiFollowing, @@ -37,6 +38,7 @@ import { MiInstance, MiMeta, MiModerationLog, + MiMrfPolicy, MiMuting, MiNote, MiNoteFavorite, @@ -271,6 +273,12 @@ const $emojisRepository: Provider = { inject: [DI.db], }; +const $emojiSuggestionsRepository: Provider = { + provide: DI.emojiSuggestionsRepository, + useFactory: (db: DataSource) => db.getRepository(MiEmojiSuggestion).extend(miRepository as MiRepository), + inject: [DI.db], +}; + const $driveFilesRepository: Provider = { provide: DI.driveFilesRepository, useFactory: (db: DataSource) => db.getRepository(MiDriveFile).extend(miRepository as MiRepository), @@ -391,6 +399,12 @@ const $moderationLogsRepository: Provider = { inject: [DI.db], }; +const $mrfPoliciesRepository: Provider = { + provide: DI.mrfPoliciesRepository, + useFactory: (db: DataSource) => db.getRepository(MiMrfPolicy).extend(miRepository as MiRepository), + inject: [DI.db], +}; + const $clipsRepository: Provider = { provide: DI.clipsRepository, useFactory: (db: DataSource) => db.getRepository(MiClip).extend(miRepository as MiRepository), @@ -603,6 +617,7 @@ export const repositoryProviders: Provider[] = [ $followRequestsRepository, $instancesRepository, $emojisRepository, + $emojiSuggestionsRepository, $driveFilesRepository, $driveFoldersRepository, $metasRepository, @@ -623,6 +638,7 @@ export const repositoryProviders: Provider[] = [ $galleryPostsRepository, $galleryLikesRepository, $moderationLogsRepository, + $mrfPoliciesRepository, $clipsRepository, $clipNotesRepository, $clipFavoritesRepository, diff --git a/packages/backend/src/models/_.ts b/packages/backend/src/models/_.ts index 7fc5878672..ce75ad1f8e 100644 --- a/packages/backend/src/models/_.ts +++ b/packages/backend/src/models/_.ts @@ -29,6 +29,7 @@ import { MiClipNote } from '@/models/ClipNote.js'; import { MiDriveFile } from '@/models/DriveFile.js'; import { MiDriveFolder } from '@/models/DriveFolder.js'; import { MiEmoji } from '@/models/Emoji.js'; +import { MiEmojiSuggestion } from '@/models/EmojiSuggestion.js'; import { MiFlash } from '@/models/Flash.js'; import { MiFlashLike } from '@/models/FlashLike.js'; import { MiFollowing } from '@/models/Following.js'; @@ -39,6 +40,7 @@ import { MiHashtag } from '@/models/Hashtag.js'; import { MiInstance } from '@/models/Instance.js'; import { MiMeta } from '@/models/Meta.js'; import { MiModerationLog } from '@/models/ModerationLog.js'; +import { MiMrfPolicy } from '@/models/MrfPolicy.js'; import { MiMuting } from '@/models/Muting.js'; import { MiNote } from '@/models/Note.js'; import { MiNoteFavorite } from '@/models/NoteFavorite.js'; @@ -170,6 +172,7 @@ export { MiDriveFile, MiDriveFolder, MiEmoji, + MiEmojiSuggestion, MiFollowing, MiFollowRequest, MiGalleryLike, @@ -178,6 +181,7 @@ export { MiInstance, MiMeta, MiModerationLog, + MiMrfPolicy, MiMuting, MiRenoteMuting, MiNote, @@ -250,6 +254,7 @@ export type ClipFavoritesRepository = Repository & MiRepository< export type DriveFilesRepository = Repository & MiRepository; export type DriveFoldersRepository = Repository & MiRepository; export type EmojisRepository = Repository & MiRepository; +export type EmojiSuggestionsRepository = Repository & MiRepository; export type FollowingsRepository = Repository & MiRepository; export type FollowRequestsRepository = Repository & MiRepository; export type GalleryLikesRepository = Repository & MiRepository; @@ -257,6 +262,7 @@ export type GalleryPostsRepository = Repository & MiRepository & MiRepository; export type InstancesRepository = Repository & MiRepository; export type MetasRepository = Repository & MiRepository; +export type MrfPoliciesRepository = Repository & MiRepository; export type LatestNotesRepository = Repository & MiRepository; export type ApContextsRepository = Repository & MiRepository; export type ApFetchLogsRepository = Repository & MiRepository; diff --git a/packages/backend/src/models/json-schema/emoji-suggestion.ts b/packages/backend/src/models/json-schema/emoji-suggestion.ts new file mode 100644 index 0000000000..e7368f0fbd --- /dev/null +++ b/packages/backend/src/models/json-schema/emoji-suggestion.ts @@ -0,0 +1,68 @@ +/* + * SPDX-FileCopyrightText: Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export const packedEmojiSuggestionSchema = { + type: 'object', + properties: { + id: { + type: 'string', + format: 'id', + optional: false, + nullable: false, + }, + createdAt: { + type: 'string', + format: 'date-time', + optional: false, + nullable: false, + }, + name: { + type: 'string', + optional: false, + nullable: false, + }, + category: { + type: 'string', + optional: false, + nullable: true, + }, + aliases: { + type: 'array', + optional: false, + nullable: false, + items: { + type: 'string', + optional: false, + nullable: false, + }, + }, + license: { + type: 'string', + optional: false, + nullable: true, + }, + localOnly: { + type: 'boolean', + optional: false, + nullable: false, + }, + isSensitive: { + type: 'boolean', + optional: false, + nullable: false, + }, + url: { + type: 'string', + optional: false, + nullable: false, + }, + user: { + type: 'object', + optional: false, + nullable: false, + ref: 'UserLite', + }, + }, +} as const; diff --git a/packages/backend/src/models/json-schema/mrf-policy.ts b/packages/backend/src/models/json-schema/mrf-policy.ts new file mode 100644 index 0000000000..6c6af39116 --- /dev/null +++ b/packages/backend/src/models/json-schema/mrf-policy.ts @@ -0,0 +1,47 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export const packedMrfPolicySchema = { + type: 'object', + properties: { + id: { type: 'string', optional: false, nullable: false, format: 'id' }, + createdAt: { type: 'string', optional: false, nullable: false, format: 'date-time' }, + updatedAt: { type: 'string', optional: false, nullable: false, format: 'date-time' }, + name: { type: 'string', optional: false, nullable: false }, + enabled: { type: 'boolean', optional: false, nullable: false }, + priority: { type: 'number', optional: false, nullable: false }, + source: { type: 'string', optional: false, nullable: false }, + timeoutMs: { type: 'number', optional: false, nullable: false }, + scope: { + type: 'object', + optional: false, nullable: false, + properties: { + activityTypes: { + type: 'array', optional: false, nullable: true, + items: { type: 'string', optional: false, nullable: false }, + }, + objectTypes: { + type: 'array', optional: false, nullable: true, + items: { type: 'string', optional: false, nullable: false }, + }, + }, + }, + isBuiltin: { type: 'boolean', optional: false, nullable: false }, + builtinPolicyId: { type: 'string', optional: false, nullable: true }, + paramsSchema: { type: 'object', optional: false, nullable: false }, + params: { type: 'object', optional: false, nullable: false }, + warnings: { + type: 'array', optional: true, nullable: false, + items: { + type: 'object', optional: false, nullable: false, + properties: { + code: { type: 'string', optional: false, nullable: false }, + key: { type: 'string', optional: false, nullable: false }, + message: { type: 'string', optional: false, nullable: false }, + }, + }, + }, + }, +} as const; diff --git a/packages/backend/src/postgres.ts b/packages/backend/src/postgres.ts index 1997182624..037e5ca908 100644 --- a/packages/backend/src/postgres.ts +++ b/packages/backend/src/postgres.ts @@ -33,6 +33,7 @@ import { MiClipFavorite } from '@/models/ClipFavorite.js'; import { MiDriveFile } from '@/models/DriveFile.js'; import { MiDriveFolder } from '@/models/DriveFolder.js'; import { MiEmoji } from '@/models/Emoji.js'; +import { MiEmojiSuggestion } from '@/models/EmojiSuggestion.js'; import { MiFollowing } from '@/models/Following.js'; import { MiFollowRequest } from '@/models/FollowRequest.js'; import { MiGalleryLike } from '@/models/GalleryLike.js'; @@ -41,6 +42,7 @@ import { MiHashtag } from '@/models/Hashtag.js'; import { MiInstance } from '@/models/Instance.js'; import { MiMeta } from '@/models/Meta.js'; import { MiModerationLog } from '@/models/ModerationLog.js'; +import { MiMrfPolicy } from '@/models/MrfPolicy.js'; import { MiMuting } from '@/models/Muting.js'; import { MiRenoteMuting } from '@/models/RenoteMuting.js'; import { MiNote } from '@/models/Note.js'; @@ -261,6 +263,7 @@ export const entities = [ MiPoll, MiPollVote, MiEmoji, + MiEmojiSuggestion, MiHashtag, MiSwSubscription, MiSystemAccount, @@ -269,6 +272,7 @@ export const entities = [ MiRegistrationTicket, MiSignin, MiModerationLog, + MiMrfPolicy, MiClip, MiClipNote, MiClipFavorite, diff --git a/packages/backend/src/queue/QueueProcessorModule.ts b/packages/backend/src/queue/QueueProcessorModule.ts index 76daeaae39..7e8a6ee809 100644 --- a/packages/backend/src/queue/QueueProcessorModule.ts +++ b/packages/backend/src/queue/QueueProcessorModule.ts @@ -46,6 +46,7 @@ import { ScheduleNotePostProcessorService } from './processors/ScheduleNotePostP import { CleanupApLogsProcessorService } from './processors/CleanupApLogsProcessorService.js'; import { HibernateUsersProcessorService } from './processors/HibernateUsersProcessorService.js'; import { BackgroundTaskProcessorService } from './processors/BackgroundTaskProcessorService.js'; +import { MMrfPolicyService } from './processors/MMrfPolicy.js'; /** External module dependencies */ const $Imports = [ @@ -89,6 +90,7 @@ const $Imports = [ SystemWebhookDeliverProcessorService, EndedPollNotificationProcessorService, DeliverProcessorService, + MMrfPolicyService, InboxProcessorService, AggregateRetentionProcessorService, CheckExpiredMutingsProcessorService, diff --git a/packages/backend/src/queue/processors/InboxProcessorService.ts b/packages/backend/src/queue/processors/InboxProcessorService.ts index 8fbe221360..778af2a07d 100644 --- a/packages/backend/src/queue/processors/InboxProcessorService.ts +++ b/packages/backend/src/queue/processors/InboxProcessorService.ts @@ -23,10 +23,9 @@ import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js'; import { JsonLdError, JsonLdService, Signed } from '@/core/activitypub/JsonLdService.js'; import { ApInboxService } from '@/core/activitypub/ApInboxService.js'; import { bindThis } from '@/decorators.js'; -import { MMrfAction, runMMrf } from '@/queue/processors/MMrfPolicy.js'; +import { MMrfAction, MMrfPolicyService } from '@/queue/processors/MMrfPolicy.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import type { UsersRepository } from '@/models/_.js'; -import { IdService } from '@/core/IdService.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; import { MiMeta } from '@/models/Meta.js'; import { DI } from '@/di-symbols.js'; @@ -64,7 +63,7 @@ export class InboxProcessorService { private queueLoggerService: QueueLoggerService, private readonly apLogService: ApLogService, private readonly queueService: QueueService, - private idService: IdService, + private readonly mrfPolicyService: MMrfPolicyService, ) { this.logger = this.queueLoggerService.logger.createSubLogger('inbox'); } @@ -277,10 +276,21 @@ export class InboxProcessorService { } const mmrfLogger = this.queueLoggerService.logger.createSubLogger('mmrf'); - const mMrfResponse = await runMMrf(activity, mmrfLogger, this.idService, this.apDbResolverService); + const signerHost = this.utilityService.extractDbHost(authUser.user.uri!); + const mMrfResponse = await this.mrfPolicyService.run(activity, mmrfLogger, { + actor: { + uri: authUser.user.uri!, + host: authUser.user.host, + followersCount: authUser.user.followersCount, + followingCount: authUser.user.followingCount, + }, + localHost: this.config.host, + signerHost, + receivedAt: new Date().toISOString(), + }); const rewrittenActivity = mMrfResponse.data; if (mMrfResponse.action === MMrfAction.RejectNote) { - throw new Bull.UnrecoverableError('skip: rejected by MMrf'); + throw new Bull.UnrecoverableError(`skip: rejected by MMrf${mMrfResponse.reason ? `: ${mMrfResponse.reason}` : ''}`); } // Update instance stats diff --git a/packages/backend/src/queue/processors/MMrfPolicies/HellthreadPolicy.ts b/packages/backend/src/queue/processors/MMrfPolicies/HellthreadPolicy.ts deleted file mode 100644 index 5fa854aec7..0000000000 --- a/packages/backend/src/queue/processors/MMrfPolicies/HellthreadPolicy.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { MMrfAction, MMrfPolicy, MMrfResponse } from '@/queue/processors/MMrfPolicy.js'; -import { IActivity, IObject } from '@/core/activitypub/type.js'; -import Logger from '@/logger.js'; - -export class HellthreadPolicy implements MMrfPolicy { - private logger: Logger; - - constructor(logger: Logger) { - this.logger = logger; - } - - async runPolicy(activity: IActivity): Promise { - const object: IObject = activity.object as IObject; - if (object.tag === undefined || !(object.tag instanceof Array)) { - return { - action: MMrfAction.Neutral, - data: activity, - }; - } - - const mentions = object.tag.filter(tag => tag.type === 'Mention'); - - if (mentions.length >= 15) { - this.logger.warn('Rewriting note due to hellthread, triggered by: ' + object.content); - object.tag = object.tag.filter(tag => tag.type !== 'Mention'); - activity.object = object; - - return { - action: MMrfAction.RewriteNote, - data: activity, - }; - } - - return { - action: MMrfAction.Neutral, - data: activity, - }; - } -} diff --git a/packages/backend/src/queue/processors/MMrfPolicies/KeywordFilterPolicy.ts b/packages/backend/src/queue/processors/MMrfPolicies/KeywordFilterPolicy.ts deleted file mode 100644 index ae0f93dd86..0000000000 --- a/packages/backend/src/queue/processors/MMrfPolicies/KeywordFilterPolicy.ts +++ /dev/null @@ -1,37 +0,0 @@ -import Logger from '@/logger.js'; -import { IActivity, IObject } from '@/core/activitypub/type.js'; -import { MMrfAction, MMrfPolicy, MMrfResponse } from '@/queue/processors/MMrfPolicy.js'; - -export class KeywordFilterPolicy implements MMrfPolicy { - private logger: Logger; - - constructor(logger: Logger) { - this.logger = logger; - } - - async runPolicy(activity: IActivity): Promise { - const object: IObject = activity.object as IObject; - if (object.content === undefined) { - return { - action: MMrfAction.Neutral, - data: activity, - }; - } - - const keywords = ['https://discord.gg/ctkpaarr', '@ap12@mastodon-japan.net', 'ctkpaarr']; - - if (keywords.some(keyword => object.content?.includes(keyword))) { - this.logger.warn('Rejected note due to keyword filter, triggered by: ' + object.content); - - return { - action: MMrfAction.RejectNote, - data: activity, - }; - } - - return { - action: MMrfAction.Neutral, - data: activity, - }; - } -} diff --git a/packages/backend/src/queue/processors/MMrfPolicies/NewUserSpamPolicy.ts b/packages/backend/src/queue/processors/MMrfPolicies/NewUserSpamPolicy.ts deleted file mode 100644 index 781aa2f21c..0000000000 --- a/packages/backend/src/queue/processors/MMrfPolicies/NewUserSpamPolicy.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { MMrfAction, MMrfPolicy, MMrfResponse } from '@/queue/processors/MMrfPolicy.js'; -import { IActivity, IObject } from '@/core/activitypub/type.js'; -import { ApDbResolverService } from '@/core/activitypub/ApDbResolverService.js'; -import { IdService } from '@/core/IdService.js'; -import Logger from '@/logger.js'; - -export class NewUserSpamPolicy implements MMrfPolicy { - private apDbResolverService: ApDbResolverService; - private idService: IdService; - private logger: Logger; - - constructor(apDbResolverService: ApDbResolverService, idService: IdService, logger: Logger) { - this.apDbResolverService = apDbResolverService; - this.idService = idService; - this.logger = logger; - } - - async runPolicy(activity: IActivity): Promise { - const object: IObject = activity.object as IObject; - - if (object.tag === undefined || !(object.tag instanceof Array)) { - return { - action: MMrfAction.Neutral, - data: activity, - }; - } - const objectMentions = object.tag.filter(tag => tag.type === 'Mention'); - const mentionCount = objectMentions.length; - - // Verify that the mention contains at least one local user mention - const localMention = objectMentions.some(tag => tag.href?.startsWith('https://plasmatrap.com')); - if (!localMention) { - return { - action: MMrfAction.Neutral, - data: activity, - }; - } - - // Get user reference from AP Actor - const actor = activity.actor as IObject; - const user = await this.apDbResolverService.getUserFromApId(actor); - if (user === null) { - this.logger.warn('User not found for remote actor ' + actor.url); - return { - action: MMrfAction.Neutral, - data: activity, - }; - } - - // Disallow mentions out of the blue by accounts followed by no one and with no avatar - if (user.followersCount === 0 && user.followingCount === 0 && object.inReplyTo === null) { - this.logger.warn('Rewriting note mentions, triggered by remote actor ' + user.uri + ' and note: ' + object.id); - object.tag = object.tag.filter(tag => tag.type !== 'Mention'); - activity.object = object; - - return { - action: MMrfAction.RewriteNote, - data: activity, - }; - } - - return { - action: MMrfAction.Neutral, - data: activity, - }; - } -} diff --git a/packages/backend/src/queue/processors/MMrfPolicy.ts b/packages/backend/src/queue/processors/MMrfPolicy.ts index 98670888bc..399d49b3c4 100644 --- a/packages/backend/src/queue/processors/MMrfPolicy.ts +++ b/packages/backend/src/queue/processors/MMrfPolicy.ts @@ -1,10 +1,14 @@ import { IActivity, IObject } from '@/core/activitypub/type.js'; import Logger from '@/logger.js'; -import { IdService } from '@/core/IdService.js'; +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import type { InstancesRepository, MrfPoliciesRepository, NotesRepository } from '@/models/_.js'; import { ApDbResolverService } from '@/core/activitypub/ApDbResolverService.js'; -import { KeywordFilterPolicy } from '@/queue/processors/MMrfPolicies/KeywordFilterPolicy.js'; -import { NewUserSpamPolicy } from '@/queue/processors/MMrfPolicies/NewUserSpamPolicy.js'; -import { HellthreadPolicy } from '@/queue/processors/MMrfPolicies/HellthreadPolicy.js'; +import { MrfLuaPolicyService } from '@/core/activitypub/mrf/MrfLuaPolicyService.js'; +import type { MrfLuaPolicy } from '@/core/activitypub/mrf/MrfLuaPolicyService.js'; +import { normalizeMrfPolicyScope } from '@/models/MrfPolicy.js'; +import type { MrfPolicyScope } from '@/models/MrfPolicy.js'; +import { CacheManagementService, ManagedMemorySingleCache } from '@/global/CacheManagementService.js'; export enum MMrfAction { Neutral, @@ -15,46 +19,213 @@ export enum MMrfAction { export type MMrfResponse = { action: MMrfAction; data: IActivity; + reason?: string; }; -export interface MMrfPolicy { - runPolicy(activity: IActivity): Promise; -} +export type MMrfRuntimeContext = { + actor: { + uri: string; + host: string | null; + followersCount: number; + followingCount: number; + }; + localHost: string; + signerHost: string; + receivedAt: string; +}; + +type ScopedMrfLuaPolicy = MrfLuaPolicy & { + scope: MrfPolicyScope; +}; + +@Injectable() +export class MMrfPolicyService { + private readonly policiesCache: ManagedMemorySingleCache; + + constructor( + @Inject(DI.mrfPoliciesRepository) + private readonly mrfPoliciesRepository: MrfPoliciesRepository, + + @Inject(DI.instancesRepository) + private readonly instancesRepository: InstancesRepository, + + @Inject(DI.notesRepository) + private readonly notesRepository: NotesRepository, -async function applyPolicy(policy: any, activity: IActivity): Promise { - let response = await policy.runPolicy(activity); - while (response.action === MMrfAction.RewriteNote) { - activity = response.data; - response = await policy.runPolicy(activity); + private readonly apDbResolverService: ApDbResolverService, + + private readonly mrfLuaPolicyService: MrfLuaPolicyService, + + cacheManagementService: CacheManagementService, + ) { + // 10s TTL: admin policy changes take effect within 10 seconds per worker. + this.policiesCache = cacheManagementService.createMemorySingleCache('mrfPolicies', 1000 * 10); } - return response; -} -export async function runMMrf(activity: IActivity, logger: Logger, idService: IdService, apDbResolverService: ApDbResolverService): Promise { - if (activity.type !== 'Create') { - return { action: MMrfAction.Neutral, data: activity }; + public async run(activity: IActivity, logger: Logger, context: MMrfRuntimeContext): Promise { + let mmrfActivity = activity; + const policies = await this.getEnabledPolicies(); + this.mrfLuaPolicyService.retainPreparedPolicyEngines(policies.map(policy => policy.id)); + let lookup: ReturnType | undefined; + + for (const policy of policies) { + if (!this.matchesPolicyScope(policy.scope, mmrfActivity)) { + continue; + } + + try { + lookup ??= this.createLookupApi(); + const result = await this.mrfLuaPolicyService.run(policy, { + ...context, + activity: mmrfActivity, + }, { + lookup, + }); + + if (result.decision.action === 'reject') { + logger.warn(`policy ${policy.id} rejected activity: ${result.decision.reason}`); + return { + action: MMrfAction.RejectNote, + data: mmrfActivity, + reason: `${policy.id}: ${result.decision.reason}`, + }; + } + + if (result.decision.action === 'rewrite') { + logger.info(`policy ${policy.id} rewrote activity: ${result.decision.reason ?? 'no reason provided'}`); + mmrfActivity = result.decision.activity; + } + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + logger.error(`policy ${policy.id} failed: ${reason}`); + continue; + } + } + + return { action: MMrfAction.Neutral, data: mmrfActivity }; } - const object: IObject = activity.object as IObject; - if (object.type !== 'Note') { - return { action: MMrfAction.Neutral, data: activity }; + private matchesPolicyScope(scope: MrfPolicyScope, activity: IActivity): boolean { + return this.matchesTypeSet(scope.activityTypes, this.getTypeSet(activity)) && + this.matchesTypeSet(scope.objectTypes, this.getObjectTypeSet(activity)); } - const policies = [ - new KeywordFilterPolicy(logger), - new NewUserSpamPolicy(apDbResolverService, idService, logger), - new HellthreadPolicy(logger), - ]; + private matchesTypeSet(allowedTypes: string[] | null, actualTypes: string[]): boolean { + if (allowedTypes === null) return true; + return actualTypes.some(type => allowedTypes.includes(type)); + } - let mmrfActivity = activity; + private getTypeSet(object: IObject): string[] { + if (typeof object.type === 'string') return [object.type]; + if (Array.isArray(object.type)) return object.type.filter(type => typeof type === 'string'); + return []; + } - for (const policy of policies) { - const response = await applyPolicy(policy, mmrfActivity); - if (response.action === MMrfAction.RejectNote) { - return response; - } - mmrfActivity = response.data; + private getObjectTypeSet(activity: IActivity): string[] { + const object = Array.isArray(activity.object) ? activity.object[0] : activity.object; + if (typeof object !== 'object' || object === null || Array.isArray(object)) return []; + return this.getTypeSet(object); + } + + private async getEnabledPolicies(): Promise { + return await this.policiesCache.fetch(async () => { + const policies = await this.mrfPoliciesRepository.find({ + where: { + enabled: true, + }, + order: { + priority: 'ASC', + id: 'ASC', + }, + }); + + return policies.map(policy => ({ + id: policy.id, + name: policy.name, + source: policy.source, + timeoutMs: policy.timeoutMs, + scope: normalizeMrfPolicyScope(policy.scope), + paramsSchema: policy.paramsSchema, + params: policy.params, + })); + }); } - return { action: MMrfAction.Neutral, data: mmrfActivity }; + private createLookupApi() { + const userCache = new Map | null>>(); + const instanceCache = new Map | null>>(); + const noteCache = new Map | null>>(); + + const userByUri = async (uri: string): Promise | null> => { + if (!userCache.has(uri)) { + userCache.set(uri, this.apDbResolverService.getUserFromApId(uri) + .then(user => user == null ? null : ({ + id: user.id, + uri: user.uri, + username: user.username, + host: user.host, + hasAvatar: user.avatarId != null || user.avatarUrl != null, + hasBanner: user.bannerId != null || user.bannerUrl != null, + followersCount: user.followersCount, + followingCount: user.followingCount, + updatedAt: user.updatedAt?.toISOString() ?? null, + lastFetchedAt: user.lastFetchedAt?.toISOString() ?? null, + isSuspended: user.isSuspended, + isSilenced: user.isSilenced, + isLocal: user.host == null, + }))); + } + + return await userCache.get(uri)!; + }; + + return { + userByUri, + userByMention: async (mention: Record | string): Promise | null> => { + const uri = typeof mention === 'string' ? mention : mention.href; + if (typeof uri !== 'string') return null; + return await userByUri(uri); + }, + instanceByHost: async (host: string): Promise | null> => { + if (!instanceCache.has(host)) { + instanceCache.set(host, this.instancesRepository.findOneBy({ host }) + .then(instance => instance == null ? null : ({ + host: instance.host, + softwareName: instance.softwareName, + softwareVersion: instance.softwareVersion, + isBlocked: instance.isBlocked, + isSilenced: instance.isSilenced, + isMediaSilenced: instance.isMediaSilenced, + isAllowListed: instance.isAllowListed, + moderationNote: instance.moderationNote, + }))); + } + + return await instanceCache.get(host)!; + }, + noteByUri: async (uri: string): Promise | null> => { + if (!noteCache.has(uri)) { + noteCache.set(uri, this.notesRepository.findOne({ + where: [ + { uri }, + { url: uri }, + ], + }).then(note => note == null ? null : ({ + id: note.id, + uri: note.uri, + url: note.url, + visibility: note.visibility, + localOnly: note.localOnly, + userId: note.userId, + userHost: note.userHost, + updatedAt: note.updatedAt?.toISOString() ?? null, + isReply: note.replyId != null, + isRenote: note.renoteId != null, + }))); + } + + return await noteCache.get(uri)!; + }, + }; + } } diff --git a/packages/backend/src/server/api/emoji-suggestion.ts b/packages/backend/src/server/api/emoji-suggestion.ts new file mode 100644 index 0000000000..6c0a4cb4eb --- /dev/null +++ b/packages/backend/src/server/api/emoji-suggestion.ts @@ -0,0 +1,83 @@ +/* + * SPDX-FileCopyrightText: Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import type { EmojiSuggestionError } from '@/core/EmojiSuggestionService.js'; + +export const emojiSuggestionErrors = { + noSuchFile: { + message: 'No such file.', + code: 'NO_SUCH_FILE', + id: 'd7c980e6-221b-4b60-a50e-a6f77ac8f3f9', + }, + unsupportedFileType: { + message: 'Unsupported file type.', + code: 'UNSUPPORTED_FILE_TYPE', + id: '63a9ff92-f992-4bc9-9d43-9fe1ea0ea3ec', + }, + duplicateName: { + message: 'An emoji with this name already exists.', + code: 'DUPLICATE_NAME', + id: '90a1f02e-45f9-4d19-a43e-a0e634b094ea', + }, + duplicateSuggestion: { + message: 'A matching emoji suggestion is already pending.', + code: 'DUPLICATE_SUGGESTION', + id: '7bdf044b-3753-484b-8d51-acd0ea2df264', + }, + tooManyPendingSuggestions: { + message: 'You have too many pending emoji suggestions.', + code: 'TOO_MANY_PENDING_SUGGESTIONS', + id: '16049091-cb93-45e6-a843-b9d9171bc6e5', + }, + noSuchSuggestion: { + message: 'No such emoji suggestion.', + code: 'NO_SUCH_EMOJI_SUGGESTION', + id: 'd023907b-288c-4d29-9e63-743b6ccc8320', + }, +} as const satisfies Record; + +export const emojiSuggestionParamDef = { + type: 'object', + properties: { + name: { type: 'string', maxLength: 128, pattern: '^[\\p{Letter}\\p{Number}\\p{Mark}_+-]+$' }, + fileId: { type: 'string', format: 'misskey:id' }, + category: { type: 'string', nullable: true, maxLength: 128 }, + aliases: { + type: 'array', + maxItems: 100, + items: { type: 'string', maxLength: 128 }, + }, + license: { type: 'string', nullable: true, maxLength: 1024 }, + isSensitive: { type: 'boolean' }, + localOnly: { type: 'boolean' }, + }, + required: ['name', 'fileId'], +} as const; + +export const emojiSuggestionListParamDef = { + type: 'object', + properties: { + limit: { type: 'integer', minimum: 1, maximum: 100, default: 20 }, + sinceId: { type: 'string', format: 'misskey:id' }, + untilId: { type: 'string', format: 'misskey:id' }, + }, + required: [], +} as const; + +export const emojiSuggestionListResponse = { + type: 'array', + optional: false, + nullable: false, + items: { + type: 'object', + optional: false, + nullable: false, + ref: 'EmojiSuggestion', + }, +} as const; diff --git a/packages/backend/src/server/api/endpoint-list.ts b/packages/backend/src/server/api/endpoint-list.ts index 11bc981984..f9150b9c68 100644 --- a/packages/backend/src/server/api/endpoint-list.ts +++ b/packages/backend/src/server/api/endpoint-list.ts @@ -57,6 +57,9 @@ export * as 'admin/emoji/set-aliases-bulk' from './endpoints/admin/emoji/set-ali export * as 'admin/emoji/set-category-bulk' from './endpoints/admin/emoji/set-category-bulk.js'; export * as 'admin/emoji/set-license-bulk' from './endpoints/admin/emoji/set-license-bulk.js'; export * as 'admin/emoji/update' from './endpoints/admin/emoji/update.js'; +export * as 'admin/emoji-suggestions/accept' from './endpoints/admin/emoji-suggestions/accept.js'; +export * as 'admin/emoji-suggestions/list' from './endpoints/admin/emoji-suggestions/list.js'; +export * as 'admin/emoji-suggestions/reject' from './endpoints/admin/emoji-suggestions/reject.js'; export * as 'admin/federation/delete-all-files' from './endpoints/admin/federation/delete-all-files.js'; export * as 'admin/federation/refresh-remote-instance-metadata' from './endpoints/admin/federation/refresh-remote-instance-metadata.js'; export * as 'admin/federation/remove-all-following' from './endpoints/admin/federation/remove-all-following.js'; @@ -69,6 +72,11 @@ export * as 'admin/get-user-ips' from './endpoints/admin/get-user-ips.js'; export * as 'admin/invite/create' from './endpoints/admin/invite/create.js'; export * as 'admin/invite/list' from './endpoints/admin/invite/list.js'; export * as 'admin/meta' from './endpoints/admin/meta.js'; +export * as 'admin/mrf-policies/create' from './endpoints/admin/mrf-policies/create.js'; +export * as 'admin/mrf-policies/delete' from './endpoints/admin/mrf-policies/delete.js'; +export * as 'admin/mrf-policies/list' from './endpoints/admin/mrf-policies/list.js'; +export * as 'admin/mrf-policies/test' from './endpoints/admin/mrf-policies/test.js'; +export * as 'admin/mrf-policies/update' from './endpoints/admin/mrf-policies/update.js'; export * as 'admin/nsfw-user' from './endpoints/admin/nsfw-user.js'; export * as 'admin/promo/create' from './endpoints/admin/promo/create.js'; export * as 'admin/queue/clear' from './endpoints/admin/queue/clear.js'; @@ -460,4 +468,7 @@ export * as 'chat/history' from './endpoints/chat/history.js'; export * as 'v2/admin/emoji/list' from './endpoints/v2/admin/emoji/list.js'; export * as 'admin/antennas/global' from './endpoints/admin/antennas/global.js'; export * as 'drive/files/generate-alt-text' from './endpoints/drive/files/generate-alt.js'; +export * as 'emoji-suggestions/cancel' from './endpoints/emoji-suggestions/cancel.js'; +export * as 'emoji-suggestions/create' from './endpoints/emoji-suggestions/create.js'; +export * as 'emoji-suggestions/list' from './endpoints/emoji-suggestions/list.js'; export * as 'top-posts' from './endpoints/top-posts.js'; diff --git a/packages/backend/src/server/api/endpoints/admin/emoji-suggestions/accept.ts b/packages/backend/src/server/api/endpoints/admin/emoji-suggestions/accept.ts new file mode 100644 index 0000000000..e7a6da6e84 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/emoji-suggestions/accept.ts @@ -0,0 +1,46 @@ +/* + * SPDX-FileCopyrightText: Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { EmojiSuggestionService } from '@/core/EmojiSuggestionService.js'; +import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { ApiError } from '@/server/api/error.js'; +import { emojiSuggestionErrors } from '@/server/api/emoji-suggestion.js'; + +export const meta = { + tags: ['admin', 'emoji-suggestions'], + requireCredential: true, + requireModerator: true, + kind: 'write:admin:emoji', + errors: emojiSuggestionErrors, + res: { + type: 'object', + ref: 'EmojiDetailed', + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + suggestionId: { type: 'string', format: 'misskey:id' }, + }, + required: ['suggestionId'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private readonly emojiSuggestionService: EmojiSuggestionService, + private readonly emojiEntityService: EmojiEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + const result = await this.emojiSuggestionService.accept(ps.suggestionId, me); + if (!result.ok) throw new ApiError(meta.errors[result.reason]); + + return await this.emojiEntityService.packDetailed(result.value); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/emoji-suggestions/list.ts b/packages/backend/src/server/api/endpoints/admin/emoji-suggestions/list.ts new file mode 100644 index 0000000000..bd7011dc41 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/emoji-suggestions/list.ts @@ -0,0 +1,45 @@ +/* + * SPDX-FileCopyrightText: Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import type { EmojiSuggestionsRepository } from '@/models/_.js'; +import { EmojiSuggestionEntityService } from '@/core/entities/EmojiSuggestionEntityService.js'; +import { QueryService } from '@/core/QueryService.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { emojiSuggestionListParamDef, emojiSuggestionListResponse } from '@/server/api/emoji-suggestion.js'; + +export const meta = { + tags: ['admin', 'emoji-suggestions'], + requireCredential: true, + requireModerator: true, + kind: 'read:admin:emoji', + res: emojiSuggestionListResponse, +} as const; + +export const paramDef = emojiSuggestionListParamDef; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.emojiSuggestionsRepository) + private readonly emojiSuggestionsRepository: EmojiSuggestionsRepository, + private readonly emojiSuggestionEntityService: EmojiSuggestionEntityService, + private readonly queryService: QueryService, + ) { + super(meta, paramDef, async (ps, me) => { + const query = this.queryService.makePaginationQuery( + this.emojiSuggestionsRepository.createQueryBuilder('suggestion'), + ps.sinceId, + ps.untilId, + ) + .innerJoinAndSelect('suggestion.file', 'file') + .innerJoinAndSelect('suggestion.user', 'user'); + + const suggestions = await query.limit(ps.limit).getMany(); + return await this.emojiSuggestionEntityService.packMany(suggestions, me); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/emoji-suggestions/reject.ts b/packages/backend/src/server/api/endpoints/admin/emoji-suggestions/reject.ts new file mode 100644 index 0000000000..895d487c9f --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/emoji-suggestions/reject.ts @@ -0,0 +1,37 @@ +/* + * SPDX-FileCopyrightText: Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { EmojiSuggestionService } from '@/core/EmojiSuggestionService.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { ApiError } from '@/server/api/error.js'; +import { emojiSuggestionErrors } from '@/server/api/emoji-suggestion.js'; + +export const meta = { + tags: ['admin', 'emoji-suggestions'], + requireCredential: true, + requireModerator: true, + kind: 'write:admin:emoji', + errors: emojiSuggestionErrors, +} as const; + +export const paramDef = { + type: 'object', + properties: { + suggestionId: { type: 'string', format: 'misskey:id' }, + }, + required: ['suggestionId'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor(private readonly emojiSuggestionService: EmojiSuggestionService) { + super(meta, paramDef, async (ps) => { + if (!await this.emojiSuggestionService.reject(ps.suggestionId)) { + throw new ApiError(meta.errors.noSuchSuggestion); + } + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/mrf-policies/create.ts b/packages/backend/src/server/api/endpoints/admin/mrf-policies/create.ts new file mode 100644 index 0000000000..e79f30058f --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/mrf-policies/create.ts @@ -0,0 +1,134 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DI } from '@/di-symbols.js'; +import { IdService } from '@/core/IdService.js'; +import type { MrfPoliciesRepository } from '@/models/_.js'; +import { DEFAULT_MRF_POLICY_SCOPE, normalizeMrfPolicyScope } from '@/models/MrfPolicy.js'; +import { MrfLuaPolicyService } from '@/core/activitypub/mrf/MrfLuaPolicyService.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; +import { ApiError } from '../../../error.js'; + +export const meta = { + tags: ['admin'], + requireCredential: true, + requireAdmin: true, + kind: 'write:admin:federation', + + errors: { + invalidParams: { + message: 'Invalid MRF policy params.', + code: 'INVALID_MRF_POLICY_PARAMS', + id: '33231d77-34ed-4454-83a4-1f0d456acdf8', + }, + invalidSource: { + message: 'Invalid MRF policy source.', + code: 'INVALID_MRF_POLICY_SOURCE', + id: 'c9727ba3-6c83-46ba-9d34-1cf5d3c0b4c8', + }, + }, + + res: { + type: 'object', + ref: 'MrfPolicy', + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + name: { type: 'string', minLength: 1, maxLength: 256 }, + enabled: { type: 'boolean', default: true }, + priority: { type: 'integer', default: 1000 }, + source: { type: 'string', minLength: 1 }, + timeoutMs: { type: 'integer', minimum: 1, maximum: 5000, default: 50, description: 'Wall-clock budget per execution in milliseconds. Time spent awaiting mrf.lookup.* database calls counts against this budget.' }, + scope: { + type: 'object', + description: 'Activity/object type filter. objectTypes only matches inline objects; activities whose object is a bare URI string (e.g. Announce, Like, Delete) never match a non-null objectTypes — use objectTypes: null to receive those.', + properties: { + activityTypes: { type: 'array', nullable: true, items: { type: 'string', minLength: 1, maxLength: 128 }, maxItems: 64 }, + objectTypes: { type: 'array', nullable: true, items: { type: 'string', minLength: 1, maxLength: 128 }, maxItems: 64 }, + }, + additionalProperties: false, + default: DEFAULT_MRF_POLICY_SCOPE, + }, + params: { type: 'object', additionalProperties: true, default: {} }, + }, + required: ['name', 'source'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.mrfPoliciesRepository) + private readonly mrfPoliciesRepository: MrfPoliciesRepository, + private readonly idService: IdService, + private readonly mrfLuaPolicyService: MrfLuaPolicyService, + private readonly moderationLogService: ModerationLogService, + ) { + super(meta, paramDef, async (ps, me) => { + let metadata; + try { + metadata = await this.mrfLuaPolicyService.extractPolicyMetadata({ + id: 'new-policy', + name: ps.name, + source: ps.source, + timeoutMs: ps.timeoutMs, + }); + } catch (error) { + throw new ApiError(meta.errors.invalidSource, { + reason: error instanceof Error ? error.message : String(error), + }); + } + const paramsSchema = metadata.paramsSchema; + let params; + try { + params = this.mrfLuaPolicyService.validateParams(paramsSchema, ps.params ?? {}); + } catch (error) { + throw new ApiError(meta.errors.invalidParams, { + reason: error instanceof Error ? error.message : String(error), + }); + } + + const policy = await this.mrfPoliciesRepository.insertOne({ + id: this.idService.gen(), + name: ps.name, + enabled: ps.enabled ?? true, + priority: ps.priority ?? 1000, + source: ps.source, + timeoutMs: ps.timeoutMs ?? 50, + scope: normalizeMrfPolicyScope(ps.scope ?? DEFAULT_MRF_POLICY_SCOPE), + isBuiltin: false, + builtinPolicyId: null, + paramsSchema, + params, + }); + + await this.moderationLogService.log(me, 'createMrfPolicy', { + policyId: policy.id, + policy, + }); + + return { + id: policy.id, + createdAt: policy.createdAt.toISOString(), + updatedAt: policy.updatedAt.toISOString(), + name: policy.name, + enabled: policy.enabled, + priority: policy.priority, + source: policy.source, + timeoutMs: policy.timeoutMs, + scope: policy.scope, + isBuiltin: policy.isBuiltin, + builtinPolicyId: policy.builtinPolicyId, + paramsSchema: policy.paramsSchema, + params: policy.params, + warnings: metadata.warnings, + }; + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/mrf-policies/delete.ts b/packages/backend/src/server/api/endpoints/admin/mrf-policies/delete.ts new file mode 100644 index 0000000000..7ecc53c9e5 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/mrf-policies/delete.ts @@ -0,0 +1,62 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DI } from '@/di-symbols.js'; +import type { MrfPoliciesRepository } from '@/models/_.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; +import { ApiError } from '../../../error.js'; + +export const meta = { + tags: ['admin'], + requireCredential: true, + requireAdmin: true, + kind: 'write:admin:federation', + + errors: { + noSuchPolicy: { + message: 'No such MRF policy.', + code: 'NO_SUCH_MRF_POLICY', + id: '67e53d9b-c972-49a4-86bd-15a448c4cbf0', + }, + cannotDeleteBuiltinPolicy: { + message: 'Built-in MRF policies cannot be deleted. Disable them instead.', + code: 'CANNOT_DELETE_BUILTIN_MRF_POLICY', + id: '9850e3d2-3846-471d-a3db-662650228516', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + id: { type: 'string', format: 'misskey:id' }, + }, + required: ['id'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.mrfPoliciesRepository) + private readonly mrfPoliciesRepository: MrfPoliciesRepository, + private readonly moderationLogService: ModerationLogService, + ) { + super(meta, paramDef, async (ps, me) => { + const existing = await this.mrfPoliciesRepository.findOneBy({ id: ps.id }); + if (existing == null) throw new ApiError(meta.errors.noSuchPolicy); + if (existing.isBuiltin) throw new ApiError(meta.errors.cannotDeleteBuiltinPolicy); + + const result = await this.mrfPoliciesRepository.delete(ps.id); + if (result.affected === 0) throw new ApiError(meta.errors.noSuchPolicy); + + await this.moderationLogService.log(me, 'deleteMrfPolicy', { + policyId: existing.id, + policy: existing, + }); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/mrf-policies/list.ts b/packages/backend/src/server/api/endpoints/admin/mrf-policies/list.ts new file mode 100644 index 0000000000..a47caac6bb --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/mrf-policies/list.ts @@ -0,0 +1,63 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DI } from '@/di-symbols.js'; +import type { MrfPoliciesRepository } from '@/models/_.js'; + +export const meta = { + tags: ['admin'], + requireCredential: true, + requireAdmin: true, + kind: 'write:admin:federation', + + res: { + type: 'array', + items: { + type: 'object', + ref: 'MrfPolicy', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: {}, + required: [], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.mrfPoliciesRepository) + private readonly mrfPoliciesRepository: MrfPoliciesRepository, + ) { + super(meta, paramDef, async () => { + const policies = await this.mrfPoliciesRepository.find({ + order: { + priority: 'ASC', + id: 'ASC', + }, + }); + + return policies.map(policy => ({ + id: policy.id, + createdAt: policy.createdAt.toISOString(), + updatedAt: policy.updatedAt.toISOString(), + name: policy.name, + enabled: policy.enabled, + priority: policy.priority, + source: policy.source, + timeoutMs: policy.timeoutMs, + scope: policy.scope, + isBuiltin: policy.isBuiltin, + builtinPolicyId: policy.builtinPolicyId, + paramsSchema: policy.paramsSchema, + params: policy.params, + })); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/mrf-policies/test.ts b/packages/backend/src/server/api/endpoints/admin/mrf-policies/test.ts new file mode 100644 index 0000000000..cc643df682 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/mrf-policies/test.ts @@ -0,0 +1,148 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import type { IActivity } from '@/core/activitypub/type.js'; +import { MrfLuaPolicyService } from '@/core/activitypub/mrf/MrfLuaPolicyService.js'; +import { TimeService } from '@/global/TimeService.js'; +import { ApiError } from '../../../error.js'; + +export const meta = { + tags: ['admin'], + requireCredential: true, + requireAdmin: true, + kind: 'write:admin:federation', + + errors: { + invalidParams: { + message: 'Invalid MRF policy params.', + code: 'INVALID_MRF_POLICY_PARAMS', + id: 'e0a7e055-f639-4024-ab41-293c7fa43d0a', + }, + luaFailed: { + message: 'The MRF policy failed to load or run.', + code: 'MRF_POLICY_LUA_FAILED', + id: '8e2c9b0d-51f3-4c7a-9c25-6a3f6d0e4b91', + }, + }, + + res: { + type: 'object', + properties: { + policy: { + type: 'object', optional: false, nullable: false, + properties: { + id: { type: 'string', optional: false, nullable: false }, + name: { type: 'string', optional: false, nullable: false }, + }, + }, + decision: { type: 'object', optional: false, nullable: false }, + durationMs: { type: 'number', optional: false, nullable: false }, + paramsSchema: { type: 'object', optional: false, nullable: false }, + params: { type: 'object', optional: false, nullable: false }, + warnings: { + type: 'array', optional: false, nullable: false, + items: { + type: 'object', optional: false, nullable: false, + properties: { + code: { type: 'string', optional: false, nullable: false }, + key: { type: 'string', optional: false, nullable: false }, + message: { type: 'string', optional: false, nullable: false }, + }, + }, + }, + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + source: { type: 'string', minLength: 1 }, + activity: { type: 'object', additionalProperties: true }, + actor: { type: 'object', additionalProperties: true }, + localHost: { type: 'string', default: 'example.com' }, + signerHost: { type: 'string', default: 'remote.example' }, + timeoutMs: { type: 'integer', minimum: 1, maximum: 5000, default: 50 }, + params: { type: 'object', additionalProperties: true, default: {} }, + }, + required: ['source', 'activity', 'actor'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private readonly mrfLuaPolicyService: MrfLuaPolicyService, + private readonly timeService: TimeService, + ) { + super(meta, paramDef, async (ps) => { + const policy = { + id: 'dry-run', + name: 'Dry run', + source: ps.source, + timeoutMs: ps.timeoutMs, + }; + let metadata; + try { + metadata = await this.mrfLuaPolicyService.extractPolicyMetadata(policy); + } catch (error) { + throw new ApiError(meta.errors.luaFailed, { + reason: error instanceof Error ? error.message : String(error), + }); + } + const paramsSchema = metadata.paramsSchema; + let params; + try { + params = this.mrfLuaPolicyService.validateParams(paramsSchema, ps.params ?? {}); + } catch (error) { + throw new ApiError(meta.errors.invalidParams, { + reason: error instanceof Error ? error.message : String(error), + }); + } + let result; + try { + result = await this.mrfLuaPolicyService.run({ + ...policy, + paramsSchema, + params, + }, { + activity: ps.activity as IActivity, + actor: { + uri: typeof ps.actor.uri === 'string' ? ps.actor.uri : '', + host: typeof ps.actor.host === 'string' ? ps.actor.host : null, + followersCount: typeof ps.actor.followersCount === 'number' ? ps.actor.followersCount : undefined, + followingCount: typeof ps.actor.followingCount === 'number' ? ps.actor.followingCount : undefined, + }, + localHost: ps.localHost, + signerHost: ps.signerHost, + receivedAt: this.timeService.date.toISOString(), + }); + } catch (error) { + throw new ApiError(meta.errors.luaFailed, { + reason: error instanceof Error ? error.message : String(error), + }); + } + + return { + ...result, + paramsSchema, + params, + warnings: dedupeWarnings([ + ...metadata.warnings, + ...result.warnings, + ]), + }; + }); + } +} + +function dedupeWarnings(warnings: T[]): T[] { + const deduped = new Map(); + for (const warning of warnings) { + deduped.set(`${warning.code}:${warning.key}:${warning.message}`, warning); + } + return [...deduped.values()]; +} diff --git a/packages/backend/src/server/api/endpoints/admin/mrf-policies/update.ts b/packages/backend/src/server/api/endpoints/admin/mrf-policies/update.ts new file mode 100644 index 0000000000..da88278080 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/mrf-policies/update.ts @@ -0,0 +1,170 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DI } from '@/di-symbols.js'; +import type { MrfPoliciesRepository } from '@/models/_.js'; +import type { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity.js'; +import type { MiMrfPolicy } from '@/models/MrfPolicy.js'; +import { normalizeMrfPolicyScope } from '@/models/MrfPolicy.js'; +import { MrfLuaPolicyService } from '@/core/activitypub/mrf/MrfLuaPolicyService.js'; +import type { MrfLuaPolicyWarning } from '@/core/activitypub/mrf/MrfLuaPolicyService.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; +import { TimeService } from '@/global/TimeService.js'; +import { ApiError } from '../../../error.js'; + +export const meta = { + tags: ['admin'], + requireCredential: true, + requireAdmin: true, + kind: 'write:admin:federation', + + errors: { + noSuchPolicy: { + message: 'No such MRF policy.', + code: 'NO_SUCH_MRF_POLICY', + id: '7f0b839e-527f-49d0-b56a-c5db4f7596c9', + }, + cannotModifyBuiltinPolicy: { + message: 'Built-in MRF policies cannot be modified. Create a custom copy and disable the built-in policy instead.', + code: 'CANNOT_MODIFY_BUILTIN_MRF_POLICY', + id: 'ab931093-a145-47d2-aa4f-863b62ad0625', + }, + invalidParams: { + message: 'Invalid MRF policy params.', + code: 'INVALID_MRF_POLICY_PARAMS', + id: '5e19454b-3100-4354-9dfd-f2187ec1fe14', + }, + invalidSource: { + message: 'Invalid MRF policy source.', + code: 'INVALID_MRF_POLICY_SOURCE', + id: '2f4bd3d7-2c86-4a0f-bd35-6d1f4f9b1b0a', + }, + }, + + res: { + type: 'object', + ref: 'MrfPolicy', + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + id: { type: 'string', format: 'misskey:id' }, + name: { type: 'string', minLength: 1, maxLength: 256 }, + enabled: { type: 'boolean' }, + priority: { type: 'integer' }, + source: { type: 'string', minLength: 1 }, + timeoutMs: { type: 'integer', minimum: 1, maximum: 5000, description: 'Wall-clock budget per execution in milliseconds. Time spent awaiting mrf.lookup.* database calls counts against this budget.' }, + scope: { + type: 'object', + description: 'Activity/object type filter. objectTypes only matches inline objects; activities whose object is a bare URI string (e.g. Announce, Like, Delete) never match a non-null objectTypes — use objectTypes: null to receive those.', + properties: { + activityTypes: { type: 'array', nullable: true, items: { type: 'string', minLength: 1, maxLength: 128 }, maxItems: 64 }, + objectTypes: { type: 'array', nullable: true, items: { type: 'string', minLength: 1, maxLength: 128 }, maxItems: 64 }, + }, + additionalProperties: false, + }, + params: { type: 'object', additionalProperties: true }, + }, + required: ['id'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.mrfPoliciesRepository) + private readonly mrfPoliciesRepository: MrfPoliciesRepository, + private readonly mrfLuaPolicyService: MrfLuaPolicyService, + private readonly moderationLogService: ModerationLogService, + private readonly timeService: TimeService, + ) { + super(meta, paramDef, async (ps, me) => { + const existing = await this.mrfPoliciesRepository.findOneBy({ id: ps.id }); + if (existing == null) throw new ApiError(meta.errors.noSuchPolicy); + if (existing.isBuiltin && ( + ps.name !== undefined || + ps.source !== undefined || + ps.timeoutMs !== undefined || + ps.scope !== undefined + )) { + throw new ApiError(meta.errors.cannotModifyBuiltinPolicy); + } + + let paramsSchema = existing.paramsSchema; + let params = existing.params; + let warnings: MrfLuaPolicyWarning[] = []; + if (ps.source !== undefined) { + let metadata; + try { + metadata = await this.mrfLuaPolicyService.extractPolicyMetadata({ + id: existing.id, + name: ps.name ?? existing.name, + source: ps.source, + timeoutMs: ps.timeoutMs ?? existing.timeoutMs, + }); + } catch (error) { + throw new ApiError(meta.errors.invalidSource, { + reason: error instanceof Error ? error.message : String(error), + }); + } + paramsSchema = metadata.paramsSchema; + warnings = metadata.warnings; + params = this.mrfLuaPolicyService.filterCompatibleParams(paramsSchema, params); + } + if (ps.params !== undefined) { + try { + params = this.mrfLuaPolicyService.validateParams(paramsSchema, ps.params); + } catch (error) { + throw new ApiError(meta.errors.invalidParams, { + reason: error instanceof Error ? error.message : String(error), + }); + } + } + + const updates = { + ...(ps.name !== undefined ? { name: ps.name } : {}), + ...(ps.enabled !== undefined ? { enabled: ps.enabled } : {}), + ...(ps.priority !== undefined ? { priority: ps.priority } : {}), + ...(ps.source !== undefined ? { source: ps.source } : {}), + ...(ps.timeoutMs !== undefined ? { timeoutMs: ps.timeoutMs } : {}), + ...(ps.scope !== undefined ? { scope: normalizeMrfPolicyScope(ps.scope) } : {}), + ...(ps.source !== undefined ? { paramsSchema } : {}), + ...(ps.source !== undefined || ps.params !== undefined ? { params } : {}), + updatedAt: this.timeService.date, + } satisfies Partial; + // Cast at the TypeORM boundary only: QueryDeepPartialEntity's mapped type + // cannot absorb Record jsonb columns (params/paramsSchema). + await this.mrfPoliciesRepository.update(ps.id, updates as QueryDeepPartialEntity); + + const policy = await this.mrfPoliciesRepository.findOneByOrFail({ id: ps.id }); + + await this.moderationLogService.log(me, 'updateMrfPolicy', { + policyId: policy.id, + before: existing, + after: policy, + }); + + return { + id: policy.id, + createdAt: policy.createdAt.toISOString(), + updatedAt: policy.updatedAt.toISOString(), + name: policy.name, + enabled: policy.enabled, + priority: policy.priority, + source: policy.source, + timeoutMs: policy.timeoutMs, + scope: policy.scope, + isBuiltin: policy.isBuiltin, + builtinPolicyId: policy.builtinPolicyId, + paramsSchema: policy.paramsSchema, + params: policy.params, + warnings, + }; + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/unset-user-avatar.ts b/packages/backend/src/server/api/endpoints/admin/unset-user-avatar.ts index 3466a09895..815e9057e3 100644 --- a/packages/backend/src/server/api/endpoints/admin/unset-user-avatar.ts +++ b/packages/backend/src/server/api/endpoints/admin/unset-user-avatar.ts @@ -8,6 +8,7 @@ import type { UsersRepository } from '@/models/_.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { DI } from '@/di-symbols.js'; import { ModerationLogService } from '@/core/ModerationLogService.js'; +import { InternalEventService } from '@/global/InternalEventService.js'; export const meta = { tags: ['admin'], @@ -33,6 +34,7 @@ export default class extends Endpoint { private usersRepository: UsersRepository, private moderationLogService: ModerationLogService, + private readonly internalEventService: InternalEventService, ) { super(meta, paramDef, async (ps, me) => { const user = await this.usersRepository.findOneBy({ id: ps.userId }); @@ -49,6 +51,7 @@ export default class extends Endpoint { avatarUrl: null, avatarBlurhash: null, }); + await this.internalEventService.emit(user.host == null ? 'localUserUpdated' : 'remoteUserUpdated', { id: user.id }); await this.moderationLogService.log(me, 'unsetUserAvatar', { userId: user.id, diff --git a/packages/backend/src/server/api/endpoints/admin/unset-user-banner.ts b/packages/backend/src/server/api/endpoints/admin/unset-user-banner.ts index e16dad719c..d132aefd29 100644 --- a/packages/backend/src/server/api/endpoints/admin/unset-user-banner.ts +++ b/packages/backend/src/server/api/endpoints/admin/unset-user-banner.ts @@ -8,6 +8,7 @@ import type { UsersRepository } from '@/models/_.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { DI } from '@/di-symbols.js'; import { ModerationLogService } from '@/core/ModerationLogService.js'; +import { InternalEventService } from '@/global/InternalEventService.js'; export const meta = { tags: ['admin'], @@ -33,6 +34,7 @@ export default class extends Endpoint { private usersRepository: UsersRepository, private moderationLogService: ModerationLogService, + private readonly internalEventService: InternalEventService, ) { super(meta, paramDef, async (ps, me) => { const user = await this.usersRepository.findOneBy({ id: ps.userId }); @@ -49,8 +51,9 @@ export default class extends Endpoint { bannerUrl: null, bannerBlurhash: null, }); + await this.internalEventService.emit(user.host == null ? 'localUserUpdated' : 'remoteUserUpdated', { id: user.id }); - this.moderationLogService.log(me, 'unsetUserBanner', { + await this.moderationLogService.log(me, 'unsetUserBanner', { userId: user.id, userUsername: user.username, userHost: user.host, diff --git a/packages/backend/src/server/api/endpoints/emoji-suggestions/cancel.ts b/packages/backend/src/server/api/endpoints/emoji-suggestions/cancel.ts new file mode 100644 index 0000000000..2ab87b2e18 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/emoji-suggestions/cancel.ts @@ -0,0 +1,36 @@ +/* + * SPDX-FileCopyrightText: Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { EmojiSuggestionService } from '@/core/EmojiSuggestionService.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { ApiError } from '@/server/api/error.js'; +import { emojiSuggestionErrors } from '@/server/api/emoji-suggestion.js'; + +export const meta = { + tags: ['emoji-suggestions'], + requireCredential: true, + kind: 'write:drive', + errors: emojiSuggestionErrors, +} as const; + +export const paramDef = { + type: 'object', + properties: { + suggestionId: { type: 'string', format: 'misskey:id' }, + }, + required: ['suggestionId'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor(private readonly emojiSuggestionService: EmojiSuggestionService) { + super(meta, paramDef, async (ps, me) => { + if (!await this.emojiSuggestionService.cancel(ps.suggestionId, me)) { + throw new ApiError(meta.errors.noSuchSuggestion); + } + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/emoji-suggestions/create.ts b/packages/backend/src/server/api/endpoints/emoji-suggestions/create.ts new file mode 100644 index 0000000000..02b69ee9d9 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/emoji-suggestions/create.ts @@ -0,0 +1,51 @@ +/* + * SPDX-FileCopyrightText: Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { EmojiSuggestionService } from '@/core/EmojiSuggestionService.js'; +import { EmojiSuggestionEntityService } from '@/core/entities/EmojiSuggestionEntityService.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { ApiError } from '@/server/api/error.js'; +import { emojiSuggestionErrors, emojiSuggestionParamDef } from '@/server/api/emoji-suggestion.js'; + +export const meta = { + tags: ['emoji-suggestions'], + requireCredential: true, + kind: 'write:drive', + limit: { + duration: 1000 * 60, + max: 10, + }, + errors: emojiSuggestionErrors, + res: { + type: 'object', + ref: 'EmojiSuggestion', + }, +} as const; + +export const paramDef = emojiSuggestionParamDef; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private readonly emojiSuggestionService: EmojiSuggestionService, + private readonly emojiSuggestionEntityService: EmojiSuggestionEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + const result = await this.emojiSuggestionService.create({ + name: ps.name, + fileId: ps.fileId, + category: ps.category ?? null, + aliases: ps.aliases ?? [], + license: ps.license ?? null, + isSensitive: ps.isSensitive ?? false, + localOnly: ps.localOnly ?? false, + }, me); + if (!result.ok) throw new ApiError(meta.errors[result.reason]); + + return await this.emojiSuggestionEntityService.pack(result.value, me); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/emoji-suggestions/list.ts b/packages/backend/src/server/api/endpoints/emoji-suggestions/list.ts new file mode 100644 index 0000000000..4e43f955a8 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/emoji-suggestions/list.ts @@ -0,0 +1,45 @@ +/* + * SPDX-FileCopyrightText: Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import type { EmojiSuggestionsRepository } from '@/models/_.js'; +import { EmojiSuggestionEntityService } from '@/core/entities/EmojiSuggestionEntityService.js'; +import { QueryService } from '@/core/QueryService.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { emojiSuggestionListParamDef, emojiSuggestionListResponse } from '@/server/api/emoji-suggestion.js'; + +export const meta = { + tags: ['emoji-suggestions'], + requireCredential: true, + kind: 'read:drive', + res: emojiSuggestionListResponse, +} as const; + +export const paramDef = emojiSuggestionListParamDef; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.emojiSuggestionsRepository) + private readonly emojiSuggestionsRepository: EmojiSuggestionsRepository, + private readonly emojiSuggestionEntityService: EmojiSuggestionEntityService, + private readonly queryService: QueryService, + ) { + super(meta, paramDef, async (ps, me) => { + const query = this.queryService.makePaginationQuery( + this.emojiSuggestionsRepository.createQueryBuilder('suggestion'), + ps.sinceId, + ps.untilId, + ) + .innerJoinAndSelect('suggestion.file', 'file') + .innerJoinAndSelect('suggestion.user', 'user') + .andWhere('suggestion.userId = :userId', { userId: me.id }); + + const suggestions = await query.limit(ps.limit).getMany(); + return await this.emojiSuggestionEntityService.packMany(suggestions, me); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/i/update-email.ts b/packages/backend/src/server/api/endpoints/i/update-email.ts index 77e2dd2c36..81c2f3e0fe 100644 --- a/packages/backend/src/server/api/endpoints/i/update-email.ts +++ b/packages/backend/src/server/api/endpoints/i/update-email.ts @@ -116,14 +116,6 @@ export default class extends Endpoint { // eslint- emailVerifyCode: null, }); - const iObj = await this.userEntityService.pack(me, me, { - schema: 'MeDetailed', - includeSecrets: true, - }); - - // Publish meUpdated event - await this.globalEventService.publishMainStream(me.id, 'meUpdated', iObj); - if (ps.email != null) { const code = secureRndstr(16, { chars: L_CHARS }); @@ -138,7 +130,17 @@ export default class extends Endpoint { // eslint- `To verify email, please click this link: ${link}`)); } - await this.internalEventService.emit('updateUserProfile', { userId: me.id, keys: ['emailVerifyCode'] }); + // Purge the profile cache (in all processes) *before* packing, so the response reflects the new data. + await this.internalEventService.emit('updateUserProfile', { userId: me.id, keys: ['email', 'emailVerified', 'emailVerifyCode'] }); + + const iObj = await this.userEntityService.pack(me, me, { + schema: 'MeDetailed', + includeSecrets: true, + }); + + // Publish meUpdated event + await this.globalEventService.publishMainStream(me.id, 'meUpdated', iObj); + return iObj; }); } diff --git a/packages/backend/src/server/api/endpoints/i/update.ts b/packages/backend/src/server/api/endpoints/i/update.ts index 8714768ec6..ea2081d4b0 100644 --- a/packages/backend/src/server/api/endpoints/i/update.ts +++ b/packages/backend/src/server/api/endpoints/i/update.ts @@ -623,8 +623,9 @@ export default class extends Endpoint { // eslint- profileUpdates.verifiedLinks = await verifyFieldLinks(newFields, profileUrls, this.httpRequestService); await this.userProfilesRepository.update(user.id, profileUpdates); - // Internal event purges the cache, which we immediately refill + // Internal events purge the caches (in all processes), which we immediately refill await this.internalEventService.emit('userUpdated', { id: user.id }); + await this.internalEventService.emit('updateUserProfile', { userId: user.id, keys: Object.keys(profileUpdates) as (keyof MiUserProfile)[] }); const updatedUser = await this.cacheService.findLocalUserById(user.id); const updatedProfile = await this.cacheService.userProfileCache.fetch(user.id); @@ -735,7 +736,8 @@ export default class extends Endpoint { // eslint- if (!Array.isArray(oldArray) || !Array.isArray(newArray)) { return true; } - if (oldArray.join('\0') !== newArray.join('\0')) { + // Elements are objects, so compare structurally - Array.join would render them all as "[object Object]". + if (JSON.stringify(oldArray) !== JSON.stringify(newArray)) { return true; } } diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts index d62f997ee2..2c209c1de2 100644 --- a/packages/backend/src/types.ts +++ b/packages/backend/src/types.ts @@ -126,6 +126,9 @@ export const moderationLogTypes = [ 'createAd', 'updateAd', 'deleteAd', + 'createMrfPolicy', + 'updateMrfPolicy', + 'deleteMrfPolicy', 'createAvatarDecoration', 'updateAvatarDecoration', 'deleteAvatarDecoration', @@ -383,6 +386,19 @@ export type ModerationLogPayloads = { adId: string; ad: any; }; + createMrfPolicy: { + policyId: string; + policy: any; + }; + updateMrfPolicy: { + policyId: string; + before: any; + after: any; + }; + deleteMrfPolicy: { + policyId: string; + policy: any; + }; createAvatarDecoration: { avatarDecorationId: string; avatarDecoration: any; diff --git a/packages/backend/test/unit/EmojiSuggestionService.ts b/packages/backend/test/unit/EmojiSuggestionService.ts new file mode 100644 index 0000000000..cff311be70 --- /dev/null +++ b/packages/backend/test/unit/EmojiSuggestionService.ts @@ -0,0 +1,239 @@ +/* + * SPDX-FileCopyrightText: Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +process.env.NODE_ENV = 'test'; + +import { jest } from '@jest/globals'; +import { IsNull, QueryFailedError } from 'typeorm'; +import { EmojiSuggestionService } from '@/core/EmojiSuggestionService.js'; + +describe('EmojiSuggestionService', () => { + const user = { id: '9abc000001', username: 'proposer', host: null }; + const moderator = { id: '9abc000002', username: 'moderator', host: null }; + const file = { + id: '9abc000003', + userId: user.id, + type: 'image/png', + url: 'https://example.test/original.png', + webpublicUrl: 'https://example.test/public.webp', + }; + const emojiFile = { + ...file, + id: '9abc000006', + userId: null, + url: 'https://example.test/emoji-original.png', + webpublicUrl: 'https://example.test/emoji-public.webp', + }; + const suggestion = { + id: '9abc000004', + userId: user.id, + fileId: file.id, + file, + name: 'party_blob', + category: 'Blobs', + aliases: ['party'], + license: 'CC0', + localOnly: false, + isSensitive: false, + }; + + function createService(overrides?: { + suggestions?: Record; + drive?: Record; + emojis?: Record; + customEmoji?: Record; + driveService?: Record; + }) { + const suggestions = { + countBy: jest.fn(async () => 0), + exists: jest.fn(async () => false), + insertOne: jest.fn(async () => ({ ...suggestion, user })), + insert: jest.fn(async () => ({ identifiers: [{ id: suggestion.id }] })), + findOne: jest.fn(async () => suggestion), + delete: jest.fn(async () => ({ affected: 1 })), + ...overrides?.suggestions, + }; + const drive = { + findOneBy: jest.fn(async () => file), + ...overrides?.drive, + }; + const emoji = { id: '9abc000005', name: suggestion.name, originalUrl: emojiFile.url }; + const emojis = { + findOneBy: jest.fn(async () => null), + ...overrides?.emojis, + }; + const customEmoji = { + checkDuplicate: jest.fn(async () => false), + createEmoji: jest.fn(async () => emoji), + ...overrides?.customEmoji, + }; + const driveService = { + uploadFromUrl: jest.fn(async () => emojiFile), + deleteFile: jest.fn(async () => undefined), + ...overrides?.driveService, + }; + const service = new EmojiSuggestionService( + suggestions as any, + drive as any, + emojis as any, + customEmoji as any, + driveService as any, + { gen: jest.fn(() => suggestion.id) } as any, + ); + + return { service, suggestions, drive, emojis, customEmoji, driveService, emoji }; + } + + test('submission only accepts an image owned by the proposer', async () => { + const { service, suggestions, drive } = createService({ + drive: { findOneBy: jest.fn(async () => null) }, + }); + + await expect(service.create({ + name: suggestion.name, + fileId: file.id, + category: null, + aliases: [], + license: null, + localOnly: false, + isSensitive: false, + }, user as any)).resolves.toEqual({ ok: false, reason: 'noSuchFile' }); + + expect(drive.findOneBy).toHaveBeenCalledWith({ id: file.id, userId: user.id }); + expect(suggestions.insertOne).not.toHaveBeenCalled(); + }); + + test('a simultaneous duplicate submission returns a domain error', async () => { + const driverError = Object.assign(new Error('duplicate key'), { code: '23505' }); + const duplicateError = new QueryFailedError('', [], driverError); + const { service } = createService({ + suggestions: { insertOne: jest.fn(async () => { throw duplicateError; }) }, + }); + + await expect(service.create({ + name: suggestion.name, + fileId: file.id, + category: null, + aliases: [], + license: null, + localOnly: false, + isSensitive: false, + }, user as any)).resolves.toEqual({ ok: false, reason: 'duplicateSuggestion' }); + }); + + test('acceptance consumes the suggestion and creates the emoji from a dedicated file', async () => { + const { service, suggestions, drive, customEmoji, driveService, emoji } = createService(); + + await expect(service.accept(suggestion.id, moderator as any)).resolves.toEqual({ ok: true, value: emoji }); + + expect(suggestions.delete).toHaveBeenCalledWith({ + id: suggestion.id, + userId: user.id, + fileId: file.id, + }); + expect(driveService.uploadFromUrl).toHaveBeenCalledWith({ + url: file.url, + user: null, + force: true, + }); + expect(customEmoji.createEmoji).toHaveBeenCalledWith({ + originalUrl: emojiFile.url, + publicUrl: emojiFile.webpublicUrl, + name: suggestion.name, + category: suggestion.category, + aliases: suggestion.aliases, + host: null, + license: suggestion.license, + isSensitive: suggestion.isSensitive, + localOnly: suggestion.localOnly, + roleIdsThatCanBeUsedThisEmojiAsReaction: [], + }, { moderator }); + expect(drive.findOneBy).not.toHaveBeenCalled(); + expect(suggestions.insert).not.toHaveBeenCalled(); + }); + + test('a lost suggestion claim cannot create a second emoji', async () => { + const { service, customEmoji, suggestions, driveService } = createService({ + suggestions: { delete: jest.fn(async () => ({ affected: 0 })) }, + }); + + await expect(service.accept(suggestion.id, moderator as any)).resolves.toEqual({ ok: false, reason: 'noSuchSuggestion' }); + expect(customEmoji.createEmoji).not.toHaveBeenCalled(); + expect(driveService.uploadFromUrl).not.toHaveBeenCalled(); + expect(suggestions.insert).not.toHaveBeenCalled(); + }); + + test('cancellation cannot succeed after acceptance has consumed the suggestion', async () => { + let pending = true; + let releaseCopy!: () => void; + let markCopyStarted!: () => void; + const copyStarted = new Promise(resolve => { + markCopyStarted = resolve; + }); + const copying = new Promise(resolve => { + releaseCopy = () => resolve(emojiFile); + }); + const { service } = createService({ + suggestions: { + delete: jest.fn(async () => { + if (!pending) return { affected: 0 }; + pending = false; + return { affected: 1 }; + }), + }, + driveService: { + uploadFromUrl: jest.fn(async () => { + markCopyStarted(); + return await copying; + }), + }, + }); + + const accepting = service.accept(suggestion.id, moderator as any); + await copyStarted; + await expect(service.cancel(suggestion.id, user as any)).resolves.toBe(false); + releaseCopy(); + await expect(accepting).resolves.toEqual({ ok: true, value: expect.objectContaining({ id: '9abc000005' }) }); + }); + + test('a failed emoji creation deletes its copy and restores the suggestion', async () => { + const failure = new Error('creation failed'); + const { service, suggestions, driveService } = createService({ + customEmoji: { createEmoji: jest.fn(async () => { throw failure; }) }, + }); + + await expect(service.accept(suggestion.id, moderator as any)).rejects.toBe(failure); + expect(driveService.deleteFile).toHaveBeenCalledWith(emojiFile, false, moderator); + expect(suggestions.insert).toHaveBeenCalledWith({ + id: suggestion.id, + userId: user.id, + fileId: file.id, + name: suggestion.name, + category: suggestion.category, + aliases: suggestion.aliases, + license: suggestion.license, + localOnly: suggestion.localOnly, + isSensitive: suggestion.isSensitive, + }); + }); + + test('a post-insert hook failure is treated as an accepted suggestion', async () => { + const failure = new Error('broadcast failed'); + const insertedEmoji = { id: '9abc000005', name: suggestion.name, originalUrl: emojiFile.url }; + const { service, suggestions, emojis, driveService } = createService({ + emojis: { findOneBy: jest.fn(async () => insertedEmoji) }, + customEmoji: { createEmoji: jest.fn(async () => { throw failure; }) }, + }); + + await expect(service.accept(suggestion.id, moderator as any)).resolves.toEqual({ ok: true, value: insertedEmoji }); + expect(emojis.findOneBy).toHaveBeenCalledWith({ + name: suggestion.name, + host: IsNull(), + originalUrl: emojiFile.url, + }); + expect(driveService.deleteFile).not.toHaveBeenCalled(); + expect(suggestions.insert).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/backend/test/unit/UtilityService.ts b/packages/backend/test/unit/UtilityService.ts index 2240f53cc9..9fe0097622 100644 --- a/packages/backend/test/unit/UtilityService.ts +++ b/packages/backend/test/unit/UtilityService.ts @@ -5,6 +5,7 @@ import * as assert from 'assert'; import type { MiMeta } from '@/models/_.js'; +import type { MiLocalUser } from '@/models/User.js'; import type { Config } from '@/config.js'; import type { SoftwareSuspension } from '@/models/Meta.js'; import type { MiInstance } from '@/models/Instance.js'; @@ -259,4 +260,45 @@ describe('UtilityService', () => { }); }); }); + + describe('isActiveLocalUser', () => { + const localUser = (overrides: Partial = {}) => ({ + host: null, + uri: null, + isDeleted: false, + isSuspended: false, + approved: true, + ...overrides, + }) as MiLocalUser; + + test('should be active when approved', () => { + meta.approvalRequiredForSignup = false; + expect(utilityService.isActiveLocalUser(localUser())).toBe(true); + }); + + test('should be active when unapproved but approval is not required', () => { + meta.approvalRequiredForSignup = false; + expect(utilityService.isActiveLocalUser(localUser({ approved: false }))).toBe(true); + }); + + test('should not be active when unapproved and approval is required', () => { + meta.approvalRequiredForSignup = true; + expect(utilityService.isActiveLocalUser(localUser({ approved: false }))).toBe(false); + }); + + test('should be active when approved and approval is required', () => { + meta.approvalRequiredForSignup = true; + expect(utilityService.isActiveLocalUser(localUser())).toBe(true); + }); + + test('should not be active when deleted', () => { + meta.approvalRequiredForSignup = false; + expect(utilityService.isActiveLocalUser(localUser({ isDeleted: true }))).toBe(false); + }); + + test('should not be active when suspended', () => { + meta.approvalRequiredForSignup = false; + expect(utilityService.isActiveLocalUser(localUser({ isSuspended: true }))).toBe(false); + }); + }); }); diff --git a/packages/backend/test/unit/migration/MrfPolicyMigration.ts b/packages/backend/test/unit/migration/MrfPolicyMigration.ts new file mode 100644 index 0000000000..df7fca409a --- /dev/null +++ b/packages/backend/test/unit/migration/MrfPolicyMigration.ts @@ -0,0 +1,38 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import * as assert from 'node:assert'; +import { MrfPolicy1781706597000 } from '../../../migration/1781706597000-MrfPolicy.js'; + +describe('MRF policy migration', () => { + test('seeds all built-in policies from the old hardcoded MRF set', async () => { + const queries: Array<{ sql: string; params?: unknown[] }> = []; + const migration = new MrfPolicy1781706597000(); + + await migration.up({ + query: async (sql: string, params?: unknown[]) => { + queries.push({ sql, params }); + }, + }); + + const builtinPolicyIds = queries + .flatMap(query => query.params ?? []) + .filter(param => param === 'keyword-filter' || param === 'new-user-spam' || param === 'hellthread'); + const createTable = queries.find(query => query.sql.includes('CREATE TABLE "mrf_policy"')); + const builtinInserts = queries.filter(query => query.sql.includes('INSERT INTO "mrf_policy"')); + + assert.deepStrictEqual(builtinPolicyIds, ['keyword-filter', 'new-user-spam', 'hellthread']); + assert.match(createTable?.sql ?? '', /"scope" jsonb NOT NULL DEFAULT/); + assert.doesNotMatch(createTable?.sql ?? '', /"failureMode"/); + for (const query of builtinInserts) { + assert.match(query.sql, /"scope"/); + assert.doesNotMatch(query.sql, /"failureMode"/); + assert.deepStrictEqual(JSON.parse(query.params?.[6] as string), { + activityTypes: ['Create'], + objectTypes: ['Note'], + }); + } + }); +}); diff --git a/packages/backend/test/unit/queue/processors/MMrfPolicyService.ts b/packages/backend/test/unit/queue/processors/MMrfPolicyService.ts new file mode 100644 index 0000000000..206cd24df5 --- /dev/null +++ b/packages/backend/test/unit/queue/processors/MMrfPolicyService.ts @@ -0,0 +1,218 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import * as assert from 'node:assert'; +import { MMrfAction, MMrfPolicyService } from '@/queue/processors/MMrfPolicy.js'; +import { MrfLuaPolicyService } from '@/core/activitypub/mrf/MrfLuaPolicyService.js'; +import { MemorySingleCache } from '@/misc/cache.js'; +import type { IActivity } from '@/core/activitypub/type.js'; + +const logger = { + warn: () => undefined, + info: () => undefined, + error: () => undefined, +}; + +const runtimeContext = { + actor: { + uri: 'https://remote.example/users/alice', + host: 'remote.example', + followersCount: 1, + followingCount: 1, + }, + localHost: 'local.example', + signerHost: 'remote.example', + receivedAt: '2026-06-14T00:00:00.000Z', +}; + +const keywordActivity = { + id: 'https://remote.example/activities/1', + type: 'Create', + actor: 'https://remote.example/users/alice', + object: { + id: 'https://remote.example/notes/1', + type: 'Note', + content: 'join https://discord.gg/ctkpaarr now', + tag: [], + }, +} satisfies IActivity; + +const followActivity = { + id: 'https://remote.example/activities/2', + type: 'Follow', + actor: 'https://remote.example/users/alice', + object: 'https://local.example/users/bob', +} satisfies IActivity; + +const announceActivity = { + id: 'https://remote.example/activities/3', + type: 'Announce', + actor: 'https://remote.example/users/alice', + object: 'https://remote.example/notes/1', +} satisfies IActivity; + +function createPolicyRow(overrides: Record = {}) { + return { + id: 'policy1', + name: 'Policy 1', + source: 'function filter(ctx) return mrf.accept() end', + timeoutMs: 100, + paramsSchema: {}, + params: {}, + scope: { + activityTypes: ['Create'], + objectTypes: ['Note'], + }, + ...overrides, + }; +} + +function createCacheManagementService() { + return { + createMemorySingleCache: (name: string, lifetime: number) => + new MemorySingleCache(name, { + timeService: { get now() { return Date.now(); } }, + } as any, { lifetime }), + }; +} + +function createService(policyRows: unknown[], counters: { finds: number } = { finds: 0 }) { + return new MMrfPolicyService( + { + find: async () => { + counters.finds++; + return policyRows; + }, + } as any, + { + findOneBy: async () => null, + } as any, + { + findOne: async () => null, + } as any, + { + getUserFromApId: async () => null, + } as any, + new MrfLuaPolicyService(), + createCacheManagementService() as any, + ); +} + +describe('MMrfPolicyService', () => { + test('uses DB configured policies instead of hardcoded bundled fallbacks', async () => { + const service = createService([]); + + const result = await service.run(keywordActivity, logger as any, runtimeContext); + + assert.equal(result.action, MMrfAction.Neutral); + }); + + test('skips Lua execution when a policy scope does not match the inbox activity', async () => { + const service = createService([ + createPolicyRow({ + source: 'function filter(ctx) return mrf.reject("should not run") end', + }), + ]); + + const result = await service.run(followActivity, logger as any, runtimeContext); + + assert.equal(result.action, MMrfAction.Neutral); + assert.deepStrictEqual(result.data, followActivity); + }); + + test('runs policies scoped to non-note activity types', async () => { + const service = createService([ + createPolicyRow({ + scope: { + activityTypes: ['Announce'], + objectTypes: null, + }, + source: 'function filter(ctx) return mrf.reject("blocked announce") end', + }), + ]); + + const result = await service.run(announceActivity, logger as any, runtimeContext); + + assert.equal(result.action, MMrfAction.RejectNote); + assert.match(result.reason ?? '', /blocked announce/); + }); + + test('fails open for runtime errors by default', async () => { + const service = createService([ + createPolicyRow({ + source: 'function filter(ctx) error("broken policy") end', + }), + ]); + + const result = await service.run(keywordActivity, logger as any, runtimeContext); + + assert.equal(result.action, MMrfAction.Neutral); + assert.deepStrictEqual(result.data, keywordActivity); + }); + + test('fails open for runtime errors even when a policy was configured fail-closed', async () => { + const service = createService([ + createPolicyRow({ + failureMode: 'reject', + source: 'function filter(ctx) error("broken policy") end', + }), + ]); + + const result = await service.run(keywordActivity, logger as any, runtimeContext); + + assert.equal(result.action, MMrfAction.Neutral); + assert.deepStrictEqual(result.data, keywordActivity); + }); + + test('keeps explicit policy rejections as enforcement decisions', async () => { + const service = createService([ + createPolicyRow({ + source: 'function filter(ctx) return mrf.reject("explicit rejection") end', + }), + ]); + + const result = await service.run(keywordActivity, logger as any, runtimeContext); + + assert.equal(result.action, MMrfAction.RejectNote); + assert.match(result.reason ?? '', /explicit rejection/); + }); + + test('a poisoned rewrite fails only its own policy, not the chain', async () => { + const service = createService([ + createPolicyRow({ + id: 'poisoner', + source: ` + function filter(ctx) + ctx.activity.evil = function() return 1 end + return mrf.rewrite(ctx.activity, "poisoned") + end + `, + }), + createPolicyRow({ + id: 'rejector', + source: 'function filter(ctx) return mrf.reject("caught by second policy") end', + }), + ]); + + const result = await service.run(keywordActivity, logger as any, runtimeContext); + + assert.equal(result.action, MMrfAction.RejectNote); + assert.match(result.reason ?? '', /caught by second policy/); + }); + + test('caches the enabled policy list between runs', async () => { + const counters = { finds: 0 }; + const service = createService([ + createPolicyRow({ + source: 'function filter(ctx) return mrf.accept() end', + }), + ], counters); + + await service.run(keywordActivity, logger as any, runtimeContext); + await service.run(keywordActivity, logger as any, runtimeContext); + + assert.equal(counters.finds, 1); + }); +}); diff --git a/packages/backend/test/unit/queue/processors/MrfLuaPolicyService.ts b/packages/backend/test/unit/queue/processors/MrfLuaPolicyService.ts new file mode 100644 index 0000000000..23ee686326 --- /dev/null +++ b/packages/backend/test/unit/queue/processors/MrfLuaPolicyService.ts @@ -0,0 +1,1365 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import * as assert from 'assert'; +import { jest } from '@jest/globals'; +import type { IActivity } from '@/core/activitypub/type.js'; +import { MrfLuaPolicyService } from '@/core/activitypub/mrf/MrfLuaPolicyService.js'; +import type { MrfLuaPolicy, MrfLuaPolicyServiceOptions } from '@/core/activitypub/mrf/MrfLuaPolicyService.js'; + +const baseActivity = { + id: 'https://remote.example/activities/1', + type: 'Create', + actor: 'https://remote.example/users/alice', + object: { + id: 'https://remote.example/notes/1', + type: 'Note', + content: 'hello', + tag: [], + }, +} satisfies IActivity; + +const builtinPolicyFixtures = [ + { + id: 'keyword-filter', + name: 'Keyword filter', + source: ` + policy = { + params = { + keywords = { + type = "string_array", + default = { + "https://discord.gg/ctkpaarr", + "@ap12@mastodon-japan.net", + "ctkpaarr", + }, + label = "Blocked keywords", + }, + }, + } + + function filter(ctx) + local note = mrf.activity.note(ctx.activity) + if note == nil then + return mrf.accept() + end + + local content = mrf.note.content(note) + if type(content) ~= "string" then + return mrf.accept() + end + + for _, keyword in ipairs(ctx.params.keywords) do + if string.find(content, keyword, 1, true) ~= nil then + return mrf.reject("keyword filter matched: " .. keyword) + end + end + + return mrf.accept() + end + `, + }, + { + id: 'new-user-spam', + name: 'New user spam mention filter', + source: ` + policy = { + params = { + maxFollowers = { + type = "integer", + default = 0, + label = "Maximum followers", + }, + maxFollowing = { + type = "integer", + default = 0, + label = "Maximum following", + }, + onlyTopLevelPosts = { + type = "boolean", + default = true, + label = "Only top-level posts", + }, + }, + } + + function filter(ctx) + local note = mrf.activity.note(ctx.activity) + if note == nil then + return mrf.accept() + end + + local mentions = mrf.note.mentions(note) + if #mentions == 0 then + return mrf.accept() + end + + local local_prefix = "https://" .. ctx.localHost .. "/" + local has_local_mention = false + for _, mention in ipairs(mentions) do + if type(mention.href) == "string" and string.sub(mention.href, 1, #local_prefix) == local_prefix then + has_local_mention = true + break + end + end + + if not has_local_mention then + return mrf.accept() + end + + if (ctx.actor.followersCount or 0) <= ctx.params.maxFollowers and (ctx.actor.followingCount or 0) <= ctx.params.maxFollowing and (not ctx.params.onlyTopLevelPosts or mrf.is_nil(note.inReplyTo)) then + mrf.note.remove_mentions(note) + return mrf.rewrite(ctx.activity, "stripped unsolicited local mentions from new remote actor") + end + + return mrf.accept() + end + `, + }, + { + id: 'hellthread', + name: 'Hellthread mention filter', + source: ` + policy = { + params = { + mentionThreshold = { + type = "integer", + default = 15, + label = "Mention threshold", + }, + }, + } + + function filter(ctx) + local note = mrf.activity.note(ctx.activity) + if note == nil then + return mrf.accept() + end + + if mrf.note.mention_count(note) >= ctx.params.mentionThreshold then + mrf.note.remove_mentions(note) + return mrf.rewrite(ctx.activity, "stripped hellthread mentions") + end + + return mrf.accept() + end + `, + }, +] satisfies MrfLuaPolicy[]; + +function createService(options: Partial = {}) { + return new MrfLuaPolicyService({ + defaultTimeoutMs: 100, + defaultMemoryLimitBytes: 1024 * 1024 * 4, + ...options, + }); +} + +async function runBundledPolicies(activity: IActivity, actor: { uri: string; host: string; followersCount: number; followingCount: number }) { + const service = createService(); + let currentActivity = structuredClone(activity); + const decisions: Array<{ policyId: string; action: string; activity: IActivity }> = []; + + for (const policy of builtinPolicyFixtures) { + const result = await service.run(policy, { + activity: currentActivity, + actor, + localHost: 'local.example', + signerHost: actor.host, + receivedAt: '2026-06-14T00:00:00.000Z', + }); + decisions.push({ + policyId: policy.id, + action: result.decision.action, + activity: currentActivity, + }); + + if (result.decision.action === 'rewrite') { + currentActivity = result.decision.activity; + } + + if (result.decision.action === 'reject') { + break; + } + } + + return { + activity: currentActivity, + decisions, + }; +} + +function createRealisticReplyActivity() { + return { + id: 'https://remote.example/users/alice/statuses/1/activity', + type: 'Create', + actor: 'https://remote.example/users/alice', + to: ['https://www.w3.org/ns/activitystreams#Public'], + cc: ['https://remote.example/users/alice/followers', 'https://local.example/users/bob'], + object: { + id: 'https://remote.example/users/alice/statuses/1', + type: 'Note', + to: ['https://www.w3.org/ns/activitystreams#Public'], + cc: ['https://remote.example/users/alice/followers', 'https://local.example/users/bob'], + tag: [ + { type: 'Mention', href: 'https://local.example/users/bob', name: '@bob@local.example' }, + { type: 'Hashtag', href: 'https://remote.example/tags/test', name: '#test' }, + ], + content: '

@bob hello

', + inReplyTo: 'https://local.example/users/bob/statuses/1', + published: '2026-06-14T00:00:00Z', + sensitive: false, + attachment: [], + attributedTo: 'https://remote.example/users/alice', + }, + } satisfies IActivity; +} + +function createHellthreadReplayActivity() { + const activity = createRealisticReplyActivity(); + const note = activity.object as typeof activity.object & { tag: Array> }; + note.tag = [ + ...Array.from({ length: 15 }, (_, i) => ({ + type: 'Mention', + href: `https://remote${i}.example/users/person${i}`, + name: `@person${i}@remote${i}.example`, + })), + { type: 'Hashtag', href: 'https://remote.example/tags/kept', name: '#kept' }, + ]; + return activity; +} + +function createNewUserSpamReplayActivity() { + const activity = createRealisticReplyActivity(); + const note = activity.object as { inReplyTo: string | null; tag: Array> }; + note.inReplyTo = null; + note.tag = [ + { type: 'Mention', href: 'https://local.example/users/bob', name: '@bob@local.example' }, + { type: 'Mention', href: 'https://remote.example/users/carol', name: '@carol@remote.example' }, + { type: 'Hashtag', href: 'https://remote.example/tags/kept', name: '#kept' }, + ]; + return activity; +} + +describe('MrfLuaPolicyService', () => { + test('returns an accept decision from a Lua policy', async () => { + const service = createService(); + + const result = await service.run({ + id: 'accept', + name: 'Accept Policy', + source: ` + function filter(ctx) + return mrf.accept("no changes") + end + `, + }, { + activity: baseActivity, + actor: { + uri: 'https://remote.example/users/alice', + host: 'remote.example', + }, + localHost: 'local.example', + signerHost: 'remote.example', + receivedAt: '2026-06-14T00:00:00.000Z', + }); + + assert.deepStrictEqual(result.decision, { + action: 'accept', + reason: 'no changes', + }); + assert.equal(result.policy.id, 'accept'); + }); + + test('reuses a prepared policy engine across successful runs', async () => { + const service = createService(); + const policy = { + id: 'pooled', + name: 'Pooled Policy', + source: ` + local run_count = 0 + + function filter(ctx) + run_count = run_count + 1 + return mrf.accept("run:" .. run_count) + end + `, + }; + const context = { + activity: baseActivity, + actor: { + uri: 'https://remote.example/users/alice', + host: 'remote.example', + }, + localHost: 'local.example', + signerHost: 'remote.example', + receivedAt: '2026-06-14T00:00:00.000Z', + }; + + const first = await service.run(policy, context); + const second = await service.run(policy, context); + + assert.deepStrictEqual(first.decision, { + action: 'accept', + reason: 'run:1', + }); + assert.deepStrictEqual(second.decision, { + action: 'accept', + reason: 'run:2', + }); + }); + + test('warns when policy source defines persistent globals', async () => { + const service = createService(); + + const metadata = await service.extractPolicyMetadata({ + id: 'global-source', + name: 'Global Source Policy', + source: ` + counter = 0 + + function helper() + return true + end + + function filter(ctx) + return mrf.accept() + end + `, + }); + + assert.deepStrictEqual(metadata.warnings.map(warning => ({ + code: warning.code, + key: warning.key, + })), [ + { code: 'persistent_global_defined', key: 'counter' }, + { code: 'persistent_global_defined', key: 'helper' }, + ]); + }); + + test('warns when policy source modifies the MRF global API table', async () => { + const service = createService(); + + const metadata = await service.extractPolicyMetadata({ + id: 'global-mrf-source', + name: 'Global MRF Source Policy', + source: ` + mrf.custom_helper = function() + return true + end + + function filter(ctx) + return mrf.accept() + end + `, + }); + + assert.deepStrictEqual(metadata.warnings.map(warning => ({ + code: warning.code, + key: warning.key, + })), [ + { code: 'persistent_global_modified', key: 'mrf' }, + ]); + }); + + test('warns when filter mutates persistent globals at runtime', async () => { + const service = createService(); + + const result = await service.run({ + id: 'global-runtime', + name: 'Global Runtime Policy', + source: ` + counter = 0 + + function filter(ctx) + counter = counter + 1 + mrf.custom_runtime = true + new_global = true + return mrf.accept() + end + `, + }, { + activity: baseActivity, + actor: { + uri: 'https://remote.example/users/alice', + host: 'remote.example', + }, + localHost: 'local.example', + signerHost: 'remote.example', + receivedAt: '2026-06-14T00:00:00.000Z', + }); + + assert.deepStrictEqual(result.warnings.map(warning => ({ + code: warning.code, + key: warning.key, + })), [ + { code: 'persistent_global_defined', key: 'counter' }, + { code: 'persistent_global_modified', key: 'counter' }, + { code: 'persistent_global_modified', key: 'mrf' }, + { code: 'persistent_global_modified', key: 'new_global' }, + ]); + }); + + test('discards a prepared policy engine after a runtime error', async () => { + const service = createService(); + const policy = { + id: 'pooled-error', + name: 'Pooled Error Policy', + source: ` + local run_count = 0 + + function filter(ctx) + run_count = run_count + 1 + if ctx.params.fail then + error("boom") + end + return mrf.accept("run:" .. run_count) + end + `, + paramsSchema: { + fail: { + type: 'boolean', + default: false, + }, + }, + } satisfies MrfLuaPolicy; + const context = { + activity: baseActivity, + actor: { + uri: 'https://remote.example/users/alice', + host: 'remote.example', + }, + localHost: 'local.example', + signerHost: 'remote.example', + receivedAt: '2026-06-14T00:00:00.000Z', + }; + + await assert.rejects( + () => service.run(policy, { + ...context, + params: { + fail: true, + }, + }), + /policy pooled-error failed/i, + ); + const result = await service.run(policy, context); + + assert.deepStrictEqual(result.decision, { + action: 'accept', + reason: 'run:1', + }); + }); + + test('recycles prepared policy engines after the configured use count', async () => { + const service = createService({ + maxPreparedEngineUses: 1, + }); + const policy = { + id: 'pooled-recycle', + name: 'Pooled Recycle Policy', + source: ` + local run_count = 0 + + function filter(ctx) + run_count = run_count + 1 + return mrf.accept("run:" .. run_count) + end + `, + }; + const context = { + activity: baseActivity, + actor: { + uri: 'https://remote.example/users/alice', + host: 'remote.example', + }, + localHost: 'local.example', + signerHost: 'remote.example', + receivedAt: '2026-06-14T00:00:00.000Z', + }; + + const first = await service.run(policy, context); + const second = await service.run(policy, context); + + assert.deepStrictEqual(first.decision, { + action: 'accept', + reason: 'run:1', + }); + assert.deepStrictEqual(second.decision, { + action: 'accept', + reason: 'run:1', + }); + }); + + test('prunes prepared policy engines for policies that are no longer retained', async () => { + const service = createService(); + const policy = { + id: 'pooled-pruned', + name: 'Pooled Pruned Policy', + source: ` + local run_count = 0 + + function filter(ctx) + run_count = run_count + 1 + return mrf.accept("run:" .. run_count) + end + `, + }; + const context = { + activity: baseActivity, + actor: { + uri: 'https://remote.example/users/alice', + host: 'remote.example', + }, + localHost: 'local.example', + signerHost: 'remote.example', + receivedAt: '2026-06-14T00:00:00.000Z', + }; + + const first = await service.run(policy, context); + service.retainPreparedPolicyEngines([]); + const second = await service.run(policy, context); + + assert.deepStrictEqual(first.decision, { + action: 'accept', + reason: 'run:1', + }); + assert.deepStrictEqual(second.decision, { + action: 'accept', + reason: 'run:1', + }); + }); + + test('returns a rewritten cloned activity without mutating the caller activity', async () => { + const service = createService(); + + const result = await service.run({ + id: 'rewrite', + name: 'Rewrite Policy', + source: ` + function filter(ctx) + ctx.activity.object.content = "rewritten" + return mrf.rewrite(ctx.activity, "changed content") + end + `, + }, { + activity: baseActivity, + actor: { + uri: 'https://remote.example/users/alice', + host: 'remote.example', + }, + localHost: 'local.example', + signerHost: 'remote.example', + receivedAt: '2026-06-14T00:00:00.000Z', + }); + + assert.equal(result.decision.action, 'rewrite'); + assert.equal(result.decision.reason, 'changed content'); + if (result.decision.action === 'rewrite') { + assert.equal((result.decision.activity.object as { content: string }).content, 'rewritten'); + } + assert.equal(baseActivity.object.content, 'hello'); + }); + + test('does not expose unsafe Lua standard library globals', async () => { + const service = createService(); + + const result = await service.run({ + id: 'sandbox', + name: 'Sandbox Policy', + source: ` + function filter(ctx) + if os ~= nil or io ~= nil or package ~= nil or debug ~= nil or require ~= nil or Promise ~= nil or Error ~= nil then + return mrf.reject("unsafe global exposed") + end + if __mrf_accept ~= nil or __mrf_lookup_user_by_uri ~= nil then + return mrf.reject("bridge global exposed") + end + return mrf.accept("sandboxed") + end + `, + }, { + activity: baseActivity, + actor: { + uri: 'https://remote.example/users/alice', + host: 'remote.example', + }, + localHost: 'local.example', + signerHost: 'remote.example', + receivedAt: '2026-06-14T00:00:00.000Z', + }); + + assert.deepStrictEqual(result.decision, { + action: 'accept', + reason: 'sandboxed', + }); + }); + + test('times out policies that do not yield', async () => { + const service = createService(); + + await assert.rejects( + () => service.run({ + id: 'loop', + name: 'Loop Policy', + timeoutMs: 20, + source: ` + function filter(ctx) + while true do + end + end + `, + }, { + activity: baseActivity, + actor: { + uri: 'https://remote.example/users/alice', + host: 'remote.example', + }, + localHost: 'local.example', + signerHost: 'remote.example', + receivedAt: '2026-06-14T00:00:00.000Z', + }), + /policy loop failed/i, + ); + }); + + test('times out policy source that does not yield while loading', async () => { + const service = createService(); + + await assert.rejects( + () => service.extractParamsSchema({ + id: 'load-loop', + name: 'Load Loop Policy', + timeoutMs: 20, + source: ` + while true do + end + `, + }), + /policy load-loop failed/i, + ); + }); + + test('exposes note helpers for mention counting and removal', async () => { + const service = createService(); + const activity = { + ...baseActivity, + object: { + ...baseActivity.object, + tag: [ + { type: 'Mention', href: 'https://local.example/@alice' }, + { type: 'Hashtag', name: '#test' }, + { type: 'Mention', href: 'https://local.example/@bob' }, + ], + }, + } satisfies IActivity; + + const result = await service.run({ + id: 'helpers', + name: 'Helper Policy', + source: ` + function filter(ctx) + local note = mrf.activity.note(ctx.activity) + if note == nil then + return mrf.reject("note missing") + end + if mrf.note.mention_count(note) ~= 2 then + return mrf.reject("wrong mention count") + end + mrf.note.remove_mentions(note) + return mrf.rewrite(ctx.activity, "removed mentions") + end + `, + }, { + activity, + actor: { + uri: 'https://remote.example/users/alice', + host: 'remote.example', + }, + localHost: 'local.example', + signerHost: 'remote.example', + receivedAt: '2026-06-14T00:00:00.000Z', + }); + + assert.equal(result.decision.action, 'rewrite'); + if (result.decision.action === 'rewrite') { + assert.deepStrictEqual((result.decision.activity.object as { tag: unknown[] }).tag, [ + { type: 'Hashtag', name: '#test' }, + ]); + } + }); + + test('remove_mentions keeps an empty tag collection as a JavaScript array', async () => { + const service = createService(); + const activity = { + ...baseActivity, + object: { + ...baseActivity.object, + tag: [ + { type: 'Mention', href: 'https://local.example/@alice' }, + { type: 'Mention', href: 'https://local.example/@bob' }, + ], + }, + } satisfies IActivity; + + const result = await service.run({ + id: 'remove-all-mentions', + name: 'Remove All Mentions Policy', + source: ` + function filter(ctx) + local note = mrf.activity.note(ctx.activity) + mrf.note.remove_mentions(note) + return mrf.rewrite(ctx.activity, "removed all mentions") + end + `, + }, { + activity, + actor: { + uri: 'https://remote.example/users/alice', + host: 'remote.example', + }, + localHost: 'local.example', + signerHost: 'remote.example', + receivedAt: '2026-06-14T00:00:00.000Z', + }); + + assert.equal(result.decision.action, 'rewrite'); + if (result.decision.action === 'rewrite') { + assert.deepStrictEqual((result.decision.activity.object as { tag: unknown[] }).tag, []); + } + }); + + test('exposes read-only lookup helpers to Lua policies', async () => { + const service = createService(); + + const result = await service.run({ + id: 'lookup', + name: 'Lookup Policy', + source: ` + function filter(ctx) + local user = mrf.lookup.user_by_uri("https://remote.example/users/alice") + if user == nil or user.followersCount ~= 0 then + return mrf.reject("lookup failed") + end + return mrf.accept("lookup worked") + end + `, + }, { + activity: baseActivity, + actor: { + uri: 'https://remote.example/users/alice', + host: 'remote.example', + }, + localHost: 'local.example', + signerHost: 'remote.example', + receivedAt: '2026-06-14T00:00:00.000Z', + }, { + lookup: { + userByUri: async () => ({ + uri: 'https://remote.example/users/alice', + host: 'remote.example', + followersCount: 0, + followingCount: 0, + }), + }, + }); + + assert.deepStrictEqual(result.decision, { + action: 'accept', + reason: 'lookup worked', + }); + }); + + test('normalizes lookup null results to Lua nil', async () => { + const service = createService(); + + const result = await service.run({ + id: 'lookup-null', + name: 'Lookup Null Policy', + source: ` + function filter(ctx) + local user = mrf.lookup.user_by_uri("https://remote.example/users/missing") + if user ~= nil then + return mrf.reject("lookup miss was not nil") + end + return mrf.accept("lookup miss") + end + `, + }, { + activity: baseActivity, + actor: { + uri: 'https://remote.example/users/alice', + host: 'remote.example', + }, + localHost: 'local.example', + signerHost: 'remote.example', + receivedAt: '2026-06-14T00:00:00.000Z', + }, { + lookup: { + userByUri: async () => null, + }, + }); + + assert.deepStrictEqual(result.decision, { + action: 'accept', + reason: 'lookup miss', + }); + }); + + test('exposes a helper for JavaScript null activity fields', async () => { + const service = createService(); + + const result = await service.run({ + id: 'null-helper', + name: 'Null Helper Policy', + source: ` + function filter(ctx) + local note = mrf.activity.note(ctx.activity) + if note == nil then + return mrf.reject("note missing") + end + if not mrf.is_nil(note.inReplyTo) then + return mrf.reject("null field was not considered nil") + end + return mrf.accept("null helper") + end + `, + }, { + activity: { + ...baseActivity, + object: { + ...baseActivity.object, + inReplyTo: null, + }, + }, + actor: { + uri: 'https://remote.example/users/alice', + host: 'remote.example', + }, + localHost: 'local.example', + signerHost: 'remote.example', + receivedAt: '2026-06-14T00:00:00.000Z', + }); + + assert.deepStrictEqual(result.decision, { + action: 'accept', + reason: 'null helper', + }); + }); + + test('extracts policy parameter schema from Lua metadata', async () => { + const service = createService(); + + const schema = await service.extractParamsSchema({ + id: 'params-schema', + name: 'Params Schema Policy', + source: ` + policy = { + params = { + keywords = { + type = "string_array", + default = { "spam", "scam" }, + label = "Blocked keywords", + description = "Plain substrings to reject", + }, + caseSensitive = { + type = "boolean", + default = true, + }, + }, + } + + function filter(ctx) + return mrf.accept() + end + `, + }); + + assert.deepStrictEqual(schema, { + keywords: { + type: 'string_array', + default: ['spam', 'scam'], + label: 'Blocked keywords', + description: 'Plain substrings to reject', + }, + caseSensitive: { + type: 'boolean', + default: true, + }, + }); + }); + + test('merges parameter defaults and overrides into ctx.params', async () => { + const service = createService(); + + const result = await service.run({ + id: 'params', + name: 'Params Policy', + source: ` + policy = { + params = { + threshold = { + type = "integer", + default = 15, + }, + keywords = { + type = "string_array", + default = { "default" }, + }, + }, + } + + function filter(ctx) + if ctx.params.threshold ~= 3 then + return mrf.reject("threshold override missing") + end + if ctx.params.keywords[1] ~= "default" then + return mrf.reject("keyword default missing") + end + return mrf.accept("params ok") + end + `, + params: { + threshold: 3, + }, + }, { + activity: baseActivity, + actor: { + uri: 'https://remote.example/users/alice', + host: 'remote.example', + }, + localHost: 'local.example', + signerHost: 'remote.example', + receivedAt: '2026-06-14T00:00:00.000Z', + }); + + assert.deepStrictEqual(result.decision, { + action: 'accept', + reason: 'params ok', + }); + }); + + test('filters stale stored parameters at runtime and falls back to defaults', async () => { + const service = createService(); + + const result = await service.run({ + id: 'stale-params', + name: 'Stale Params Policy', + source: ` + policy = { + params = { + threshold = { + type = "integer", + default = 15, + }, + enabled = { + type = "boolean", + default = true, + }, + }, + } + + function filter(ctx) + if ctx.params.threshold ~= 15 then + return mrf.reject("threshold default missing") + end + if ctx.params.enabled ~= true then + return mrf.reject("enabled default missing") + end + if ctx.params.removed ~= nil then + return mrf.reject("unknown parameter leaked") + end + return mrf.accept("stale params ignored") + end + `, + params: { + threshold: 'wrong type', + removed: 'old schema key', + }, + }, { + activity: baseActivity, + actor: { + uri: 'https://remote.example/users/alice', + host: 'remote.example', + }, + localHost: 'local.example', + signerHost: 'remote.example', + receivedAt: '2026-06-14T00:00:00.000Z', + }); + + assert.deepStrictEqual(result.decision, { + action: 'accept', + reason: 'stale params ignored', + }); + }); + + test('survives hundreds of pooled runs on a single engine', async () => { + const service = createService({ + maxPreparedEnginesPerPolicy: 1, + maxPreparedEngineUses: 500, + }); + const policy = { + id: 'longevity', + name: 'Longevity Policy', + source: ` + function filter(ctx) + return mrf.accept("ok") + end + `, + }; + const context = { + activity: baseActivity, + actor: { + uri: 'https://remote.example/users/alice', + host: 'remote.example', + }, + localHost: 'local.example', + signerHost: 'remote.example', + receivedAt: '2026-06-14T00:00:00.000Z', + }; + + for (let i = 0; i < 200; i++) { + const result = await service.run(policy, context); + assert.deepStrictEqual(result.decision, { + action: 'accept', + reason: 'ok', + }); + } + }); + + test('keeps the decision when the post-run snapshot fails', async () => { + const service = createService(); + const policy = { + id: 'snapshot-victim', + name: 'Snapshot Victim Policy', + source: ` + function filter(ctx) + return mrf.reject("spam detected") + end + `, + // Pre-set the params schema so run() skips extractParamsSchema (which itself + // captures globals snapshots). Without this, the one-time snapshot rejection + // below would be consumed by extractParamsSchema instead of the post-run + // snapshot this test is exercising. + paramsSchema: {}, + }; + const context = { + activity: baseActivity, + actor: { + uri: 'https://remote.example/users/alice', + host: 'remote.example', + }, + localHost: 'local.example', + signerHost: 'remote.example', + receivedAt: '2026-06-14T00:00:00.000Z', + }; + + // First run creates and pools the engine (load-time snapshots happen here). + await service.run(policy, context); + + // Second run reuses the pooled engine, so the next snapshot call is the post-run one. + const spy = jest.spyOn(service as any, 'capturePolicyGlobalSnapshot') + .mockRejectedValueOnce(new Error('snapshot boom')); + try { + const result = await service.run(policy, context); + assert.deepStrictEqual(result.decision, { + action: 'reject', + reason: 'spam detected', + }); + } finally { + spy.mockRestore(); + } + }); + + describe('bundled policies', () => { + test('keyword policy rejects matching note content', async () => { + const service = createService(); + const keywordPolicy = builtinPolicyFixtures.find(policy => policy.id === 'keyword-filter'); + assert.ok(keywordPolicy); + + const result = await service.run(keywordPolicy, { + activity: { + ...baseActivity, + object: { + ...baseActivity.object, + content: 'join https://discord.gg/ctkpaarr now', + }, + }, + actor: { + uri: 'https://remote.example/users/alice', + host: 'remote.example', + }, + localHost: 'local.example', + signerHost: 'remote.example', + receivedAt: '2026-06-14T00:00:00.000Z', + }); + + assert.equal(result.decision.action, 'reject'); + if (result.decision.action === 'reject') { + assert.match(result.decision.reason, /keyword/i); + } + }); + + test('hellthread policy strips mentions at the current threshold', async () => { + const service = createService(); + const hellthreadPolicy = builtinPolicyFixtures.find(policy => policy.id === 'hellthread'); + assert.ok(hellthreadPolicy); + const mentions = Array.from({ length: 15 }, (_, i) => ({ + type: 'Mention', + href: `https://local.example/users/${i}`, + })); + + const result = await service.run(hellthreadPolicy, { + activity: { + ...baseActivity, + object: { + ...baseActivity.object, + tag: [ + ...mentions, + { type: 'Hashtag', name: '#kept' }, + ], + }, + }, + actor: { + uri: 'https://remote.example/users/alice', + host: 'remote.example', + }, + localHost: 'local.example', + signerHost: 'remote.example', + receivedAt: '2026-06-14T00:00:00.000Z', + }); + + assert.equal(result.decision.action, 'rewrite'); + if (result.decision.action === 'rewrite') { + assert.deepStrictEqual((result.decision.activity.object as { tag: unknown[] }).tag, [ + { type: 'Hashtag', name: '#kept' }, + ]); + } + }); + + test('new-user spam policy strips unsolicited local mentions', async () => { + const service = createService(); + const newUserPolicy = builtinPolicyFixtures.find(policy => policy.id === 'new-user-spam'); + assert.ok(newUserPolicy); + + const result = await service.run(newUserPolicy, { + activity: { + ...baseActivity, + object: { + ...baseActivity.object, + inReplyTo: null, + tag: [ + { type: 'Mention', href: 'https://local.example/users/alice' }, + { type: 'Hashtag', name: '#kept' }, + ], + }, + }, + actor: { + uri: 'https://remote.example/users/spam', + host: 'remote.example', + followersCount: 0, + followingCount: 0, + }, + localHost: 'local.example', + signerHost: 'remote.example', + receivedAt: '2026-06-14T00:00:00.000Z', + }); + + assert.equal(result.decision.action, 'rewrite'); + if (result.decision.action === 'rewrite') { + assert.deepStrictEqual((result.decision.activity.object as { tag: unknown[] }).tag, [ + { type: 'Hashtag', name: '#kept' }, + ]); + } + }); + + test('realistic replay activities exercise bundled rewrite policies', async () => { + const replayActivities = [ + { + activity: createHellthreadReplayActivity(), + actor: { + uri: 'https://remote.example/users/alice', + host: 'remote.example', + followersCount: 12, + followingCount: 8, + }, + policyId: 'hellthread', + }, + { + activity: createNewUserSpamReplayActivity(), + actor: { + uri: 'https://remote.example/users/new', + host: 'remote.example', + followersCount: 0, + followingCount: 0, + }, + policyId: 'new-user-spam', + }, + ]; + + const results = await Promise.all(replayActivities.map(async replay => ({ + ...replay, + result: await runBundledPolicies(replay.activity, replay.actor), + }))); + + for (const replay of results) { + const rewriteDecision = replay.result.decisions.find(decision => decision.action === 'rewrite'); + assert.equal(rewriteDecision?.policyId, replay.policyId); + assert.deepStrictEqual((replay.result.activity.object as { tag: unknown[] }).tag, [ + { type: 'Hashtag', href: 'https://remote.example/tags/kept', name: '#kept' }, + ]); + } + }); + }); + + test('rejects rewrite decisions that contain non-JSON values', async () => { + const service = createService(); + + await assert.rejects( + () => service.run({ + id: 'poison-rewrite', + name: 'Poison Rewrite Policy', + source: ` + function filter(ctx) + ctx.activity.evil = function() return 1 end + return mrf.rewrite(ctx.activity, "poisoned") + end + `, + }, { + activity: baseActivity, + actor: { + uri: 'https://remote.example/users/alice', + host: 'remote.example', + }, + localHost: 'local.example', + signerHost: 'remote.example', + receivedAt: '2026-06-14T00:00:00.000Z', + }), + /non-JSON value/, + ); + }); + + test('rejects policy sources that do not define a filter function', async () => { + const service = createService(); + + await assert.rejects( + () => service.extractPolicyMetadata({ + id: 'no-filter', + name: 'No Filter Policy', + source: ` + policy = {} + filter = "not a function" + `, + }), + /must define a global filter/, + ); + + await assert.rejects( + () => service.extractPolicyMetadata({ + id: 'empty', + name: 'Empty Policy', + source: 'local x = 1', + }), + /must define a global filter/, + ); + }); + + test('unlist moves Public from an array to and into cc', async () => { + const service = createService(); + const result = await service.run({ + id: 'unlist-array', + name: 'Unlist Array Policy', + source: ` + function filter(ctx) + mrf.note.unlist(mrf.activity.note(ctx.activity)) + return mrf.rewrite(ctx.activity, "unlisted") + end + `, + }, { + activity: { + ...baseActivity, + object: { + ...baseActivity.object, + to: ['https://www.w3.org/ns/activitystreams#Public', 'https://remote.example/users/alice/followers'], + cc: [], + }, + }, + actor: { uri: 'https://remote.example/users/alice', host: 'remote.example' }, + localHost: 'local.example', + signerHost: 'remote.example', + receivedAt: '2026-06-14T00:00:00.000Z', + }); + + assert.equal(result.decision.action, 'rewrite'); + if (result.decision.action === 'rewrite') { + const note = result.decision.activity.object as { to: unknown; cc: unknown }; + assert.deepStrictEqual(note.to, ['https://remote.example/users/alice/followers']); + assert.deepStrictEqual(note.cc, ['https://www.w3.org/ns/activitystreams#Public']); + } + }); + + test('unlist handles a bare-string Public to field', async () => { + const service = createService(); + const result = await service.run({ + id: 'unlist-string', + name: 'Unlist String Policy', + source: ` + function filter(ctx) + mrf.note.unlist(mrf.activity.note(ctx.activity)) + return mrf.rewrite(ctx.activity, "unlisted") + end + `, + }, { + activity: { + ...baseActivity, + object: { + ...baseActivity.object, + to: 'https://www.w3.org/ns/activitystreams#Public', + }, + }, + actor: { uri: 'https://remote.example/users/alice', host: 'remote.example' }, + localHost: 'local.example', + signerHost: 'remote.example', + receivedAt: '2026-06-14T00:00:00.000Z', + }); + + assert.equal(result.decision.action, 'rewrite'); + if (result.decision.action === 'rewrite') { + const note = result.decision.activity.object as unknown as Record; + assert.equal('to' in note, false); + assert.deepStrictEqual(note.cc, ['https://www.w3.org/ns/activitystreams#Public']); + } + }); + + test('new-user spam policy ignores lookalike local hosts', async () => { + const service = createService(); + const newUserPolicy = builtinPolicyFixtures.find(policy => policy.id === 'new-user-spam'); + assert.ok(newUserPolicy); + + const result = await service.run(newUserPolicy, { + activity: { + ...baseActivity, + object: { + ...baseActivity.object, + inReplyTo: null, + tag: [ + { type: 'Mention', href: 'https://local.example.evil.com/users/alice' }, + ], + }, + }, + actor: { + uri: 'https://remote.example/users/spam', + host: 'remote.example', + followersCount: 0, + followingCount: 0, + }, + localHost: 'local.example', + signerHost: 'remote.example', + receivedAt: '2026-06-14T00:00:00.000Z', + }); + + assert.equal(result.decision.action, 'accept'); + }); +}); diff --git a/packages/backend/test/unit/server/api/MrfPolicyEndpoints.ts b/packages/backend/test/unit/server/api/MrfPolicyEndpoints.ts new file mode 100644 index 0000000000..b899cd0f1e --- /dev/null +++ b/packages/backend/test/unit/server/api/MrfPolicyEndpoints.ts @@ -0,0 +1,328 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import * as assert from 'node:assert'; +import UpdateMrfPolicyEndpoint from '@/server/api/endpoints/admin/mrf-policies/update.js'; +import DeleteMrfPolicyEndpoint from '@/server/api/endpoints/admin/mrf-policies/delete.js'; +import CreateMrfPolicyEndpoint from '@/server/api/endpoints/admin/mrf-policies/create.js'; +import { meta as listMeta } from '@/server/api/endpoints/admin/mrf-policies/list.js'; +import { ApiError } from '@/server/api/error.js'; +import { MrfLuaPolicyService } from '@/core/activitypub/mrf/MrfLuaPolicyService.js'; + +function createModerationLogService() { + const entries: Array<{ type: string; info: Record }> = []; + return { + entries, + log: async (_moderator: unknown, type: string, info: Record) => { + entries.push({ type, info }); + }, + }; +} + +function createTimeService() { + return { get date() { return new Date(); } }; +} + +function createPolicy(overrides: Record = {}) { + return { + id: 'mrftestbuiltin000000000000001', + createdAt: new Date('2026-06-14T00:00:00.000Z'), + updatedAt: new Date('2026-06-14T00:00:00.000Z'), + name: 'Built-in policy', + enabled: true, + priority: 10, + source: 'function filter(ctx) return mrf.accept() end', + timeoutMs: 50, + scope: { + activityTypes: ['Create'], + objectTypes: ['Note'], + }, + isBuiltin: true, + builtinPolicyId: 'keyword-filter', + paramsSchema: { + keywords: { + type: 'string_array', + default: ['spam'], + }, + }, + params: {}, + ...overrides, + }; +} + +function createRepository(policy = createPolicy()) { + let current = policy; + return { + findOneBy: async ({ id }: { id: string }) => id === current.id ? current : null, + findOneByOrFail: async ({ id }: { id: string }) => { + if (id !== current.id) throw new Error('not found'); + return current; + }, + update: async (id: string, updates: Record) => { + if (id === current.id) { + current = { + ...current, + ...updates, + }; + } + return { affected: id === current.id ? 1 : 0 }; + }, + delete: async (id: string) => ({ affected: id === current.id ? 1 : 0 }), + insertOne: async (policy: Record) => { + current = { + ...createPolicy({ + isBuiltin: false, + builtinPolicyId: null, + }), + ...policy, + createdAt: new Date('2026-06-14T00:00:00.000Z'), + updatedAt: new Date('2026-06-14T00:00:00.000Z'), + }; + return current; + }, + get current() { + return current; + }, + }; +} + +describe('MRF policy admin endpoints', () => { + test('requires administrator privileges to list policy source', () => { + assert.equal(listMeta.requireAdmin, true); + assert.equal('requireModerator' in listMeta, false); + }); + + test('allows disabling built-in policies without modifying their source', async () => { + const repository = createRepository(); + const endpoint = new UpdateMrfPolicyEndpoint(repository as any, new MrfLuaPolicyService(), createModerationLogService() as any, createTimeService() as any); + + const result = await endpoint.exec({ + id: repository.current.id, + enabled: false, + }, {} as any, null); + + assert.equal(result.enabled, false); + assert.equal(result.isBuiltin, true); + assert.equal(result.builtinPolicyId, 'keyword-filter'); + assert.equal(repository.current.source, 'function filter(ctx) return mrf.accept() end'); + }); + + test('allows updating built-in policy params', async () => { + const repository = createRepository(); + const endpoint = new UpdateMrfPolicyEndpoint(repository as any, new MrfLuaPolicyService(), createModerationLogService() as any, createTimeService() as any); + + const result = await endpoint.exec({ + id: repository.current.id, + params: { + keywords: ['custom'], + }, + }, {} as any, null); + + assert.deepStrictEqual(result.params, { + keywords: ['custom'], + }); + assert.equal(repository.current.source, 'function filter(ctx) return mrf.accept() end'); + }); + + test('rejects unknown built-in policy params', async () => { + const repository = createRepository(); + const endpoint = new UpdateMrfPolicyEndpoint(repository as any, new MrfLuaPolicyService(), createModerationLogService() as any, createTimeService() as any); + + await assert.rejects( + () => endpoint.exec({ + id: repository.current.id, + params: { + unknown: true, + }, + }, {} as any, null), + (error: unknown) => error instanceof ApiError && error.code === 'INVALID_MRF_POLICY_PARAMS', + ); + }); + + test('extracts params schema when creating custom policies', async () => { + const repository = createRepository(); + const endpoint = new CreateMrfPolicyEndpoint(repository as any, { + gen: () => 'mrfcustom00000000000000000001', + } as any, new MrfLuaPolicyService(), createModerationLogService() as any); + + const result = await endpoint.exec({ + name: 'Custom policy', + source: ` + policy = { + params = { + threshold = { + type = "integer", + default = 4, + }, + }, + } + + function filter(ctx) + return mrf.accept() + end + `, + params: { + threshold: 9, + }, + }, {} as any, null); + + assert.equal(result.isBuiltin, false); + assert.deepStrictEqual(result.paramsSchema, { + threshold: { + type: 'integer', + default: 4, + }, + }); + assert.deepStrictEqual(result.params, { + threshold: 9, + }); + assert.deepStrictEqual(result.scope, { + activityTypes: ['Create'], + objectTypes: ['Note'], + }); + }); + + test('returns warnings for custom policy source globals when creating policies', async () => { + const repository = createRepository(); + const endpoint = new CreateMrfPolicyEndpoint(repository as any, { + gen: () => 'mrfcustom00000000000000000002', + } as any, new MrfLuaPolicyService(), createModerationLogService() as any); + + const result = await endpoint.exec({ + name: 'Custom policy with globals', + source: ` + counter = 0 + + function filter(ctx) + return mrf.accept() + end + `, + }, {} as any, null); + + assert.deepStrictEqual(result.warnings.map((warning: { code: string; key: string }) => ({ + code: warning.code, + key: warning.key, + })), [ + { code: 'persistent_global_defined', key: 'counter' }, + ]); + }); + + test('allows custom policies to target non-note activity types', async () => { + const repository = createRepository(createPolicy({ + isBuiltin: false, + builtinPolicyId: null, + })); + const endpoint = new UpdateMrfPolicyEndpoint(repository as any, new MrfLuaPolicyService(), createModerationLogService() as any, createTimeService() as any); + + const result = await endpoint.exec({ + id: repository.current.id, + scope: { + activityTypes: ['Announce'], + objectTypes: null, + }, + }, {} as any, null); + + assert.deepStrictEqual(result.scope, { + activityTypes: ['Announce'], + objectTypes: null, + }); + assert.deepStrictEqual(repository.current.scope, { + activityTypes: ['Announce'], + objectTypes: null, + }); + }); + + test('rejects source edits for built-in policies', async () => { + const repository = createRepository(); + const endpoint = new UpdateMrfPolicyEndpoint(repository as any, new MrfLuaPolicyService(), createModerationLogService() as any, createTimeService() as any); + + await assert.rejects( + () => endpoint.exec({ + id: repository.current.id, + source: 'function filter(ctx) return mrf.reject("changed") end', + }, {} as any, null), + (error: unknown) => error instanceof ApiError && error.code === 'CANNOT_MODIFY_BUILTIN_MRF_POLICY', + ); + }); + + test('rejects scope edits for built-in policies', async () => { + const repository = createRepository(); + const endpoint = new UpdateMrfPolicyEndpoint(repository as any, new MrfLuaPolicyService(), createModerationLogService() as any, createTimeService() as any); + + await assert.rejects( + () => endpoint.exec({ + id: repository.current.id, + scope: { + activityTypes: ['Announce'], + objectTypes: null, + }, + }, {} as any, null), + (error: unknown) => error instanceof ApiError && error.code === 'CANNOT_MODIFY_BUILTIN_MRF_POLICY', + ); + }); + + test('rejects deleting built-in policies', async () => { + const repository = createRepository(); + const endpoint = new DeleteMrfPolicyEndpoint(repository as any, createModerationLogService() as any); + + await assert.rejects( + () => endpoint.exec({ + id: repository.current.id, + }, {} as any, null), + (error: unknown) => error instanceof ApiError && error.code === 'CANNOT_DELETE_BUILTIN_MRF_POLICY', + ); + }); + + test('rejects creating a policy whose source has a Lua syntax error', async () => { + const repository = createRepository(); + const endpoint = new CreateMrfPolicyEndpoint(repository as any, { + gen: () => 'mrfcustom00000000000000000003', + } as any, new MrfLuaPolicyService(), createModerationLogService() as any); + + await assert.rejects( + () => endpoint.exec({ + name: 'Broken policy', + source: 'function filter(', + }, {} as any, null), + (error: unknown) => error instanceof ApiError && error.code === 'INVALID_MRF_POLICY_SOURCE', + ); + }); + + test('rejects creating a policy without a filter function', async () => { + const repository = createRepository(); + const endpoint = new CreateMrfPolicyEndpoint(repository as any, { + gen: () => 'mrfcustom00000000000000000004', + } as any, new MrfLuaPolicyService(), createModerationLogService() as any); + + await assert.rejects( + () => endpoint.exec({ + name: 'Filterless policy', + source: 'policy = {}', + }, {} as any, null), + (error: unknown) => error instanceof ApiError && error.code === 'INVALID_MRF_POLICY_SOURCE', + ); + }); + + test('writes moderation log entries for create, update, and delete', async () => { + const repository = createRepository(createPolicy({ + isBuiltin: false, + builtinPolicyId: null, + })); + const modlog = createModerationLogService(); + const admin = { id: 'admin0000000000000000000001' } as any; + + const update = new UpdateMrfPolicyEndpoint(repository as any, new MrfLuaPolicyService(), modlog as any, createTimeService() as any); + await update.exec({ id: repository.current.id, enabled: false }, admin, null); + + const del = new DeleteMrfPolicyEndpoint(repository as any, modlog as any); + await del.exec({ id: repository.current.id }, admin, null); + + assert.deepStrictEqual(modlog.entries.map(entry => entry.type), [ + 'updateMrfPolicy', + 'deleteMrfPolicy', + ]); + assert.equal(modlog.entries[0].info.policyId, repository.current.id); + }); +}); diff --git a/packages/frontend/src/instance.ts b/packages/frontend/src/instance.ts index 4ef8654794..08ccadbaf3 100644 --- a/packages/frontend/src/instance.ts +++ b/packages/frontend/src/instance.ts @@ -73,7 +73,7 @@ async function createInstance(): Promise { fetchInstance(true).catch(err => { console.warn('Failed to refresh instance meta, using stale cache', err); }); - }, 10_1000 * Math.abs(Math.random())); + }, 10_000 * Math.abs(Math.random())); } return cached.meta; diff --git a/packages/frontend/src/pages/about.emojis.vue b/packages/frontend/src/pages/about.emojis.vue index b166dfd940..5693fd6c17 100644 --- a/packages/frontend/src/pages/about.emojis.vue +++ b/packages/frontend/src/pages/about.emojis.vue @@ -5,7 +5,10 @@ SPDX-License-Identifier: AGPL-3.0-only