From 27d43879a24b9243657af50d32d459a36b6596ec Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Wed, 26 Feb 2025 23:18:30 -0500 Subject: [PATCH 01/12] add moderation logs for many endpoints - `/admin/delete-all-files-of-a-user` - `/admin/nsfw-user` - `/admin/unnsfw-user` - `/admin/silence-user` - `/admin/unsilence-user` - `/admin/accounts/create` - `/admin/drive/clean-remote-files` - `/admin/drive/cleanup` - `/admin/emoji/set-category-bulk` - `/admin/emoji/set-license-bulk` - `/admin/emoji/set-aliases-bulk` - `/admin/emoji/add-aliases-bulk` - `/admin/emoji/remove-aliases-bulk` - `/admin/emoji/import-zip` - `/admin/federation/delete-all-files` - `/admin/federation/remove-all-following` - `/admin/promo/create` - `/admin/relay/add` - `/admin/relay/remove` --- locales/index.d.ts | 72 ++++++++++++++++- .../api/endpoints/admin/accounts/create.ts | 9 +++ .../admin/delete-all-files-of-a-user.ts | 13 ++- .../admin/drive/clean-remote-files.ts | 5 +- .../api/endpoints/admin/drive/cleanup.ts | 7 +- .../endpoints/admin/emoji/add-aliases-bulk.ts | 6 ++ .../api/endpoints/admin/emoji/import-zip.ts | 15 +++- .../admin/emoji/remove-aliases-bulk.ts | 6 ++ .../endpoints/admin/emoji/set-aliases-bulk.ts | 6 ++ .../admin/emoji/set-category-bulk.ts | 6 ++ .../endpoints/admin/emoji/set-license-bulk.ts | 6 ++ .../admin/federation/delete-all-files.ts | 8 +- .../admin/federation/remove-all-following.ts | 6 ++ .../server/api/endpoints/admin/nsfw-user.ts | 18 ++--- .../api/endpoints/admin/promo/create.ts | 14 +++- .../server/api/endpoints/admin/relays/add.ts | 6 ++ .../api/endpoints/admin/relays/remove.ts | 7 +- .../api/endpoints/admin/silence-user.ts | 27 +++++-- .../server/api/endpoints/admin/unnsfw-user.ts | 21 ++--- .../api/endpoints/admin/unsilence-user.ts | 22 +++-- packages/backend/src/types.ts | 81 +++++++++++++++++++ .../src/pages/admin/modlog.ModLog.vue | 55 +++++++++++++ packages/misskey-js/etc/misskey-js.api.md | 45 +++++++++++ packages/misskey-js/src/entities.ts | 45 +++++++++++ sharkey-locales/en-US.yml | 17 ++++ 25 files changed, 480 insertions(+), 43 deletions(-) diff --git a/locales/index.d.ts b/locales/index.d.ts index 6a2790c9af..4907754394 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -1403,7 +1403,7 @@ export interface Locale extends ILocale { */ "inputNewFileName": string; /** - * 新しいキャプションを入力してください + * Enter new alt text */ "inputNewDescription": string; /** @@ -2603,11 +2603,11 @@ export interface Locale extends ILocale { */ "description": string; /** - * キャプションを付ける + * Add alt text */ "describeFile": string; /** - * キャプションを入力 + * Enter alt text */ "enterFileDescription": string; /** @@ -4084,7 +4084,7 @@ export interface Locale extends ILocale { */ "windowRestore": string; /** - * キャプション + * Alt text */ "caption": string; /** @@ -10254,6 +10254,66 @@ export interface Locale extends ILocale { * Allowed quote posts from user */ "allowQuotesUser": string; + /** + * Cleared a user's drive files + */ + "clearUserFiles": string; + /** + * Marked user as NSFW + */ + "nsfwUser": string; + /** + * Un-marked user as NSFW + */ + "unNsfwUser": string; + /** + * Silenced user + */ + "silenceUser": string; + /** + * Un-silenced user + */ + "unSilenceUser": string; + /** + * Created an account + */ + "createAccount": string; + /** + * Cleared remote drive files + */ + "clearRemoteFiles": string; + /** + * Cleared owner-less drive files + */ + "clearOwnerlessFiles": string; + /** + * Updated custom emojis + */ + "updateCustomEmojis": string; + /** + * Imported custom emojis + */ + "importCustomEmojis": string; + /** + * Cleared an instance's drive files + */ + "clearInstanceFiles": string; + /** + * Severed follow relations with an instance + */ + "severFollowRelations": string; + /** + * Created a note promo + */ + "createPromo": string; + /** + * Added a relay + */ + "addRelay": string; + /** + * Removed a relay + */ + "removeRelay": string; }; "_fileViewer": { /** @@ -11601,6 +11661,10 @@ export interface Locale extends ILocale { * Flash */ "flash": string; + /** + * Files removed: + */ + "filesRemoved": string; "_flash": { /** * Flash Content Hidden diff --git a/packages/backend/src/server/api/endpoints/admin/accounts/create.ts b/packages/backend/src/server/api/endpoints/admin/accounts/create.ts index 5843457676..1a47f56bc6 100644 --- a/packages/backend/src/server/api/endpoints/admin/accounts/create.ts +++ b/packages/backend/src/server/api/endpoints/admin/accounts/create.ts @@ -15,6 +15,7 @@ import type { Config } from '@/config.js'; import { ApiError } from '@/server/api/error.js'; import { Packed } from '@/misc/json-schema.js'; import { RoleService } from '@/core/RoleService.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; export const meta = { tags: ['admin'], @@ -96,6 +97,7 @@ export default class extends Endpoint { // eslint- private userEntityService: UserEntityService, private signupService: SignupService, private instanceActorService: InstanceActorService, + private readonly moderationLogService: ModerationLogService, ) { super(meta, paramDef, async (ps, _me, token) => { const me = _me ? await this.usersRepository.findOneByOrFail({ id: _me.id }) : null; @@ -137,6 +139,13 @@ export default class extends Endpoint { // eslint- approved: true, }); + if (me) { + await this.moderationLogService.log(me, 'createAccount', { + userId: account.id, + userUsername: account.username, + }); + } + const res = await this.userEntityService.pack(account, account, { schema: 'MeDetailed', includeSecrets: true, diff --git a/packages/backend/src/server/api/endpoints/admin/delete-all-files-of-a-user.ts b/packages/backend/src/server/api/endpoints/admin/delete-all-files-of-a-user.ts index 747c9f48d0..8b4a450ccb 100644 --- a/packages/backend/src/server/api/endpoints/admin/delete-all-files-of-a-user.ts +++ b/packages/backend/src/server/api/endpoints/admin/delete-all-files-of-a-user.ts @@ -8,6 +8,8 @@ import { Endpoint } from '@/server/api/endpoint-base.js'; import type { DriveFilesRepository } from '@/models/_.js'; import { DriveService } from '@/core/DriveService.js'; import { DI } from '@/di-symbols.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; +import { CacheService } from '@/core/CacheService.js'; export const meta = { tags: ['admin'], @@ -30,14 +32,23 @@ export default class extends Endpoint { // eslint- constructor( @Inject(DI.driveFilesRepository) private driveFilesRepository: DriveFilesRepository, - + private readonly cacheService: CacheService, + private readonly moderationLogService: ModerationLogService, private driveService: DriveService, ) { super(meta, paramDef, async (ps, me) => { + const user = await this.cacheService.findUserById(ps.userId); const files = await this.driveFilesRepository.findBy({ userId: ps.userId, }); + await this.moderationLogService.log(me, 'clearUserFiles', { + userId: ps.userId, + userUsername: user.username, + userHost: user.host, + count: files.length, + }); + for (const file of files) { this.driveService.deleteFile(file, false, me); } diff --git a/packages/backend/src/server/api/endpoints/admin/drive/clean-remote-files.ts b/packages/backend/src/server/api/endpoints/admin/drive/clean-remote-files.ts index d420a929bd..9a7b3d5d62 100644 --- a/packages/backend/src/server/api/endpoints/admin/drive/clean-remote-files.ts +++ b/packages/backend/src/server/api/endpoints/admin/drive/clean-remote-files.ts @@ -6,6 +6,7 @@ import { Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { QueueService } from '@/core/QueueService.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; export const meta = { tags: ['admin'], @@ -25,9 +26,11 @@ export const paramDef = { export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( private queueService: QueueService, + private readonly moderationLogService: ModerationLogService, ) { super(meta, paramDef, async (ps, me) => { - this.queueService.createCleanRemoteFilesJob(); + await this.moderationLogService.log(me, 'clearRemoteFiles', {}); + await this.queueService.createCleanRemoteFilesJob(); }); } } diff --git a/packages/backend/src/server/api/endpoints/admin/drive/cleanup.ts b/packages/backend/src/server/api/endpoints/admin/drive/cleanup.ts index d612572e2e..f5d20366cf 100644 --- a/packages/backend/src/server/api/endpoints/admin/drive/cleanup.ts +++ b/packages/backend/src/server/api/endpoints/admin/drive/cleanup.ts @@ -9,6 +9,7 @@ import { Endpoint } from '@/server/api/endpoint-base.js'; import type { DriveFilesRepository } from '@/models/_.js'; import { DriveService } from '@/core/DriveService.js'; import { DI } from '@/di-symbols.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; export const meta = { tags: ['admin'], @@ -29,7 +30,7 @@ export default class extends Endpoint { // eslint- constructor( @Inject(DI.driveFilesRepository) private driveFilesRepository: DriveFilesRepository, - + private readonly moderationLogService: ModerationLogService, private driveService: DriveService, ) { super(meta, paramDef, async (ps, me) => { @@ -37,6 +38,10 @@ export default class extends Endpoint { // eslint- userId: IsNull(), }); + await this.moderationLogService.log(me, 'clearOwnerlessFiles', { + count: files.length, + }); + for (const file of files) { this.driveService.deleteFile(file); } diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/add-aliases-bulk.ts b/packages/backend/src/server/api/endpoints/admin/emoji/add-aliases-bulk.ts index f4fc79bdb3..795b579041 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/add-aliases-bulk.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/add-aliases-bulk.ts @@ -6,6 +6,7 @@ import { Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { CustomEmojiService } from '@/core/CustomEmojiService.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; export const meta = { tags: ['admin'], @@ -32,8 +33,13 @@ export const paramDef = { export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( private customEmojiService: CustomEmojiService, + private readonly moderationLogService: ModerationLogService, ) { super(meta, paramDef, async (ps, me) => { + await this.moderationLogService.log(me, 'updateCustomEmojis', { + ids: ps.ids, + addAliases: ps.aliases, + }); await this.customEmojiService.addAliasesBulk(ps.ids, ps.aliases.map(a => a.normalize('NFC'))); }); } diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/import-zip.ts b/packages/backend/src/server/api/endpoints/admin/emoji/import-zip.ts index 8e5f69c894..b3dc3978d1 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/import-zip.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/import-zip.ts @@ -3,9 +3,12 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { QueueService } from '@/core/QueueService.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; +import type { DriveFilesRepository } from '@/models/_.js'; +import { DI } from '@/di-symbols.js'; export const meta = { secure: true, @@ -25,9 +28,17 @@ export const paramDef = { export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( private queueService: QueueService, + private readonly moderationLogService: ModerationLogService, + @Inject(DI.driveFilesRepository) + private readonly driveFilesRepository: DriveFilesRepository, ) { super(meta, paramDef, async (ps, me) => { - this.queueService.createImportCustomEmojisJob(me, ps.fileId); + const file = await driveFilesRepository.findOneByOrFail({ id: ps.fileId }); + await this.moderationLogService.log(me, 'importCustomEmojis', { + fileId: file.id, + fileName: file.name, + }); + await this.queueService.createImportCustomEmojisJob(me, ps.fileId); }); } } diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/remove-aliases-bulk.ts b/packages/backend/src/server/api/endpoints/admin/emoji/remove-aliases-bulk.ts index e78620eac1..066eb1c7d9 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/remove-aliases-bulk.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/remove-aliases-bulk.ts @@ -6,6 +6,7 @@ import { Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { CustomEmojiService } from '@/core/CustomEmojiService.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; export const meta = { tags: ['admin'], @@ -32,8 +33,13 @@ export const paramDef = { export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( private customEmojiService: CustomEmojiService, + private readonly moderationLogService: ModerationLogService, ) { super(meta, paramDef, async (ps, me) => { + await this.moderationLogService.log(me, 'updateCustomEmojis', { + ids: ps.ids, + delAliases: ps.aliases, + }); await this.customEmojiService.removeAliasesBulk(ps.ids, ps.aliases.map(a => a.normalize('NFC'))); }); } diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/set-aliases-bulk.ts b/packages/backend/src/server/api/endpoints/admin/emoji/set-aliases-bulk.ts index 69fc8e0cb5..8980ef0c86 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/set-aliases-bulk.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/set-aliases-bulk.ts @@ -6,6 +6,7 @@ import { Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { CustomEmojiService } from '@/core/CustomEmojiService.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; export const meta = { tags: ['admin'], @@ -32,8 +33,13 @@ export const paramDef = { export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( private customEmojiService: CustomEmojiService, + private readonly moderationLogService: ModerationLogService, ) { super(meta, paramDef, async (ps, me) => { + await this.moderationLogService.log(me, 'updateCustomEmojis', { + ids: ps.ids, + setAliases: ps.aliases, + }); await this.customEmojiService.setAliasesBulk(ps.ids, ps.aliases.map(a => a.normalize('NFC'))); }); } diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/set-category-bulk.ts b/packages/backend/src/server/api/endpoints/admin/emoji/set-category-bulk.ts index 679a9f9c42..2510349210 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/set-category-bulk.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/set-category-bulk.ts @@ -6,6 +6,7 @@ import { Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { CustomEmojiService } from '@/core/CustomEmojiService.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; export const meta = { tags: ['admin'], @@ -34,8 +35,13 @@ export const paramDef = { export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( private customEmojiService: CustomEmojiService, + private readonly moderationLogService: ModerationLogService, ) { super(meta, paramDef, async (ps, me) => { + await this.moderationLogService.log(me, 'updateCustomEmojis', { + ids: ps.ids, + category: ps.category, + }); await this.customEmojiService.setCategoryBulk(ps.ids, ps.category?.normalize('NFC') ?? null); }); } diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/set-license-bulk.ts b/packages/backend/src/server/api/endpoints/admin/emoji/set-license-bulk.ts index 4ba99faab7..a0205ae24a 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/set-license-bulk.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/set-license-bulk.ts @@ -6,6 +6,7 @@ import { Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { CustomEmojiService } from '@/core/CustomEmojiService.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; export const meta = { tags: ['admin'], @@ -34,8 +35,13 @@ export const paramDef = { export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( private customEmojiService: CustomEmojiService, + private readonly moderationLogService: ModerationLogService, ) { super(meta, paramDef, async (ps, me) => { + await this.moderationLogService.log(me, 'updateCustomEmojis', { + ids: ps.ids, + license: ps.license, + }); await this.customEmojiService.setLicenseBulk(ps.ids, ps.license ?? null); }); } diff --git a/packages/backend/src/server/api/endpoints/admin/federation/delete-all-files.ts b/packages/backend/src/server/api/endpoints/admin/federation/delete-all-files.ts index 4a54c26009..89fd4be99c 100644 --- a/packages/backend/src/server/api/endpoints/admin/federation/delete-all-files.ts +++ b/packages/backend/src/server/api/endpoints/admin/federation/delete-all-files.ts @@ -8,6 +8,7 @@ import { Endpoint } from '@/server/api/endpoint-base.js'; import type { DriveFilesRepository } from '@/models/_.js'; import { DriveService } from '@/core/DriveService.js'; import { DI } from '@/di-symbols.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; export const meta = { tags: ['admin'], @@ -30,7 +31,7 @@ export default class extends Endpoint { // eslint- constructor( @Inject(DI.driveFilesRepository) private driveFilesRepository: DriveFilesRepository, - + private readonly moderationLogService: ModerationLogService, private driveService: DriveService, ) { super(meta, paramDef, async (ps, me) => { @@ -38,6 +39,11 @@ export default class extends Endpoint { // eslint- userHost: ps.host, }); + await this.moderationLogService.log(me, 'clearInstanceFiles', { + host: ps.host, + count: files.length, + }); + for (const file of files) { this.driveService.deleteFile(file); } diff --git a/packages/backend/src/server/api/endpoints/admin/federation/remove-all-following.ts b/packages/backend/src/server/api/endpoints/admin/federation/remove-all-following.ts index 601c898f52..e5d85e1d57 100644 --- a/packages/backend/src/server/api/endpoints/admin/federation/remove-all-following.ts +++ b/packages/backend/src/server/api/endpoints/admin/federation/remove-all-following.ts @@ -8,6 +8,7 @@ import { Endpoint } from '@/server/api/endpoint-base.js'; import type { FollowingsRepository, UsersRepository } from '@/models/_.js'; import { DI } from '@/di-symbols.js'; import { QueueService } from '@/core/QueueService.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; export const meta = { tags: ['admin'], @@ -35,6 +36,7 @@ export default class extends Endpoint { // eslint- private followingsRepository: FollowingsRepository, private queueService: QueueService, + private readonly moderationLogService: ModerationLogService, ) { super(meta, paramDef, async (ps, me) => { const followings = await this.followingsRepository.findBy([ @@ -51,6 +53,10 @@ export default class extends Endpoint { // eslint- this.usersRepository.findOneByOrFail({ id: f.followeeId }), ]).then(([from, to]) => [{ id: from.id }, { id: to.id }]))); + await this.moderationLogService.log(me, 'severFollowRelations', { + host: ps.host, + }); + this.queueService.createUnfollowJob(pairs.map(p => ({ from: p[0], to: p[1], silent: true }))); }); } diff --git a/packages/backend/src/server/api/endpoints/admin/nsfw-user.ts b/packages/backend/src/server/api/endpoints/admin/nsfw-user.ts index f64ba7f48a..194e793eda 100644 --- a/packages/backend/src/server/api/endpoints/admin/nsfw-user.ts +++ b/packages/backend/src/server/api/endpoints/admin/nsfw-user.ts @@ -5,9 +5,10 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { UsersRepository, UserProfilesRepository } from '@/models/_.js'; +import type { UserProfilesRepository } from '@/models/_.js'; import { DI } from '@/di-symbols.js'; import { CacheService } from '@/core/CacheService.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; export const meta = { tags: ['admin'], @@ -28,20 +29,19 @@ export const paramDef = { @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( - @Inject(DI.usersRepository) - private readonly usersRepository: UsersRepository, - @Inject(DI.userProfilesRepository) private readonly userProfilesRepository: UserProfilesRepository, - + private readonly moderationLogService: ModerationLogService, private readonly cacheService: CacheService, ) { super(meta, paramDef, async (ps, me) => { - const user = await this.usersRepository.findOneBy({ id: ps.userId }); + const user = await this.cacheService.findUserById(ps.userId); - if (user == null) { - throw new Error('user not found'); - } + await this.moderationLogService.log(me, 'nsfwUser', { + userId: ps.userId, + userUsername: user.username, + userHost: user.host, + }); await this.userProfilesRepository.update(user.id, { alwaysMarkNsfw: true, diff --git a/packages/backend/src/server/api/endpoints/admin/promo/create.ts b/packages/backend/src/server/api/endpoints/admin/promo/create.ts index 1d32c6cc00..69891d5454 100644 --- a/packages/backend/src/server/api/endpoints/admin/promo/create.ts +++ b/packages/backend/src/server/api/endpoints/admin/promo/create.ts @@ -8,6 +8,8 @@ import { Endpoint } from '@/server/api/endpoint-base.js'; import type { PromoNotesRepository } from '@/models/_.js'; import { GetterService } from '@/server/api/GetterService.js'; import { DI } from '@/di-symbols.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; +import { CacheService } from '@/core/CacheService.js'; import { ApiError } from '../../../error.js'; export const meta = { @@ -46,7 +48,8 @@ export default class extends Endpoint { // eslint- constructor( @Inject(DI.promoNotesRepository) private promoNotesRepository: PromoNotesRepository, - + private readonly moderationLogService: ModerationLogService, + private readonly cacheService: CacheService, private getterService: GetterService, ) { super(meta, paramDef, async (ps, me) => { @@ -61,6 +64,15 @@ export default class extends Endpoint { // eslint- throw new ApiError(meta.errors.alreadyPromoted); } + const user = await this.cacheService.findUserById(note.userId); + await this.moderationLogService.log(me, 'createPromo', { + noteId: note.id, + noteUserId: user.id, + noteUserUsername: user.username, + noteUserHost: user.host, + note: note, + }); + await this.promoNotesRepository.insert({ noteId: note.id, expiresAt: new Date(ps.expiresAt), diff --git a/packages/backend/src/server/api/endpoints/admin/relays/add.ts b/packages/backend/src/server/api/endpoints/admin/relays/add.ts index 3d7bc4567e..129f69aca9 100644 --- a/packages/backend/src/server/api/endpoints/admin/relays/add.ts +++ b/packages/backend/src/server/api/endpoints/admin/relays/add.ts @@ -8,6 +8,7 @@ import { Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { RelayService } from '@/core/RelayService.js'; import { ApiError } from '../../../error.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; export const meta = { tags: ['admin'], @@ -64,6 +65,7 @@ export const paramDef = { export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( private relayService: RelayService, + private readonly moderationLogService: ModerationLogService, ) { super(meta, paramDef, async (ps, me) => { try { @@ -72,6 +74,10 @@ export default class extends Endpoint { // eslint- throw new ApiError(meta.errors.invalidUrl); } + await this.moderationLogService.log(me, 'addRelay', { + inbox: ps.inbox, + }); + return await this.relayService.addRelay(ps.inbox); }); } diff --git a/packages/backend/src/server/api/endpoints/admin/relays/remove.ts b/packages/backend/src/server/api/endpoints/admin/relays/remove.ts index 1f6e773cd4..292f21fde9 100644 --- a/packages/backend/src/server/api/endpoints/admin/relays/remove.ts +++ b/packages/backend/src/server/api/endpoints/admin/relays/remove.ts @@ -6,6 +6,7 @@ import { Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { RelayService } from '@/core/RelayService.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; export const meta = { tags: ['admin'], @@ -27,9 +28,13 @@ export const paramDef = { export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( private relayService: RelayService, + private readonly moderationLogService: ModerationLogService, ) { super(meta, paramDef, async (ps, me) => { - return await this.relayService.removeRelay(ps.inbox); + await this.moderationLogService.log(me, 'removeRelay', { + inbox: ps.inbox, + }); + await this.relayService.removeRelay(ps.inbox); }); } } diff --git a/packages/backend/src/server/api/endpoints/admin/silence-user.ts b/packages/backend/src/server/api/endpoints/admin/silence-user.ts index 7e6045049a..eed21c6576 100644 --- a/packages/backend/src/server/api/endpoints/admin/silence-user.ts +++ b/packages/backend/src/server/api/endpoints/admin/silence-user.ts @@ -8,6 +8,9 @@ import { Endpoint } from '@/server/api/endpoint-base.js'; import type { UsersRepository } from '@/models/_.js'; import { DI } from '@/di-symbols.js'; import { RoleService } from '@/core/RoleService.js'; +import { CacheService } from '@/core/CacheService.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; export const meta = { tags: ['admin'], @@ -29,24 +32,32 @@ export const paramDef = { export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - private roleService: RoleService, + private readonly usersRepository: UsersRepository, + private readonly cacheService: CacheService, + private readonly moderationLogService: ModerationLogService, + private readonly roleService: RoleService, + private readonly globalEventService: GlobalEventService, ) { super(meta, paramDef, async (ps, me) => { - const user = await this.usersRepository.findOneBy({ id: ps.userId }); - - if (user == null) { - throw new Error('user not found'); - } + const user = await this.cacheService.findUserById(ps.userId); if (await this.roleService.isModerator(user)) { throw new Error('cannot silence moderator account'); } + await this.moderationLogService.log(me, 'silenceUser', { + userId: ps.userId, + userUsername: user.username, + userHost: user.host, + }); + await this.usersRepository.update(user.id, { isSilenced: true, }); + + this.globalEventService.publishInternalEvent(user.host == null ? 'localUserUpdated' : 'remoteUserUpdated', { + id: user.id, + }); }); } } diff --git a/packages/backend/src/server/api/endpoints/admin/unnsfw-user.ts b/packages/backend/src/server/api/endpoints/admin/unnsfw-user.ts index 26588365e1..52a0c076be 100644 --- a/packages/backend/src/server/api/endpoints/admin/unnsfw-user.ts +++ b/packages/backend/src/server/api/endpoints/admin/unnsfw-user.ts @@ -5,8 +5,10 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { UsersRepository, UserProfilesRepository } from '@/models/_.js'; +import type { UserProfilesRepository } from '@/models/_.js'; import { DI } from '@/di-symbols.js'; +import { CacheService } from '@/core/CacheService.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; export const meta = { tags: ['admin'], @@ -27,18 +29,19 @@ export const paramDef = { @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - + private readonly cacheService: CacheService, + private readonly moderationLogService: ModerationLogService, @Inject(DI.userProfilesRepository) - private userProfilesRepository: UserProfilesRepository, + private readonly userProfilesRepository: UserProfilesRepository, ) { super(meta, paramDef, async (ps, me) => { - const user = await this.usersRepository.findOneBy({ id: ps.userId }); + const user = await this.cacheService.findUserById(ps.userId); - if (user == null) { - throw new Error('user not found'); - } + await this.moderationLogService.log(me, 'unNsfwUser', { + userId: ps.userId, + userUsername: user.username, + userHost: user.host, + }); await this.userProfilesRepository.update(user.id, { alwaysMarkNsfw: false, diff --git a/packages/backend/src/server/api/endpoints/admin/unsilence-user.ts b/packages/backend/src/server/api/endpoints/admin/unsilence-user.ts index f92be0d8e0..9318943785 100644 --- a/packages/backend/src/server/api/endpoints/admin/unsilence-user.ts +++ b/packages/backend/src/server/api/endpoints/admin/unsilence-user.ts @@ -7,6 +7,9 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import type { UsersRepository } from '@/models/_.js'; import { DI } from '@/di-symbols.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; +import { CacheService } from '@/core/CacheService.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; export const meta = { tags: ['admin'], @@ -28,18 +31,27 @@ export const paramDef = { export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( @Inject(DI.usersRepository) - private usersRepository: UsersRepository, + private readonly usersRepository: UsersRepository, + private readonly cacheService: CacheService, + private readonly moderationLogService: ModerationLogService, + private readonly globalEventService: GlobalEventService, ) { super(meta, paramDef, async (ps, me) => { - const user = await this.usersRepository.findOneBy({ id: ps.userId }); + const user = await this.cacheService.findUserById(ps.userId); - if (user == null) { - throw new Error('user not found'); - } + await this.moderationLogService.log(me, 'unSilenceUser', { + userId: ps.userId, + userUsername: user.username, + userHost: user.host, + }); await this.usersRepository.update(user.id, { isSilenced: false, }); + + this.globalEventService.publishInternalEvent(user.host == null ? 'localUserUpdated' : 'remoteUserUpdated', { + id: user.id, + }); }); } } diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts index b5d982e3a5..977d6157bc 100644 --- a/packages/backend/src/types.ts +++ b/packages/backend/src/types.ts @@ -136,6 +136,21 @@ export const moderationLogTypes = [ 'rejectQuotesUser', 'acceptQuotesInstance', 'rejectQuotesInstance', + 'clearUserFiles', + 'nsfwUser', + 'unNsfwUser', + 'silenceUser', + 'unSilenceUser', + 'createAccount', + 'clearRemoteFiles', + 'clearOwnerlessFiles', + 'updateCustomEmojis', + 'importCustomEmojis', + 'clearInstanceFiles', + 'severFollowRelations', + 'createPromo', + 'addRelay', + 'removeRelay', ] as const; export type ModerationLogPayloads = { @@ -439,6 +454,72 @@ export type ModerationLogPayloads = { id: string; host: string; }; + clearUserFiles: { + userId: string; + userUsername: string; + userHost: string | null; + count: number; + }; + nsfwUser: { + userId: string; + userUsername: string; + userHost: string | null; + }; + unNsfwUser: { + userId: string; + userUsername: string; + userHost: string | null; + }; + silenceUser: { + userId: string; + userUsername: string; + userHost: string | null; + }; + unSilenceUser: { + userId: string; + userUsername: string; + userHost: string | null; + }; + createAccount: { + userId: string; + userUsername: string; + }; + clearRemoteFiles: Record; + clearOwnerlessFiles: { + count: number; + }; + updateCustomEmojis: { + ids: string[], + category?: string | null, + license?: string | null, + setAliases?: string[], + addAliases?: string[], + delAliases?: string[], + }, + importCustomEmojis: { + fileId: string, + fileName: string, + }, + clearInstanceFiles: { + host: string; + count: number; + }, + severFollowRelations: { + host: string; + }, + createPromo: { + noteId: string, + noteUserId: string; + noteUserUsername: string; + noteUserHost: string | null; + note: any; + }, + addRelay: { + inbox: string; + }, + removeRelay: { + inbox: string; + }, }; export type Serialized = { diff --git a/packages/frontend/src/pages/admin/modlog.ModLog.vue b/packages/frontend/src/pages/admin/modlog.ModLog.vue index 306a873173..a4731af47b 100644 --- a/packages/frontend/src/pages/admin/modlog.ModLog.vue +++ b/packages/frontend/src/pages/admin/modlog.ModLog.vue @@ -18,6 +18,10 @@ SPDX-License-Identifier: AGPL-3.0-only 'createAvatarDecoration', 'createSystemWebhook', 'createAbuseReportNotificationRecipient', + 'createAccount', + 'importCustomEmojis', + 'createPromo', + 'addRelay', ].includes(log.type), [$style.logYellow]: [ 'markSensitiveDriveFile', @@ -30,6 +34,11 @@ SPDX-License-Identifier: AGPL-3.0-only 'acceptRemoteInstanceReports', 'rejectQuotesUser', 'acceptQuotesUser', + 'nsfwUser', + 'unNsfwUser', + 'silenceUser', + 'unSilenceUser', + 'updateCustomEmojis', ].includes(log.type), [$style.logRed]: [ 'suspend', @@ -49,6 +58,12 @@ SPDX-License-Identifier: AGPL-3.0-only 'deletePage', 'deleteFlash', 'deleteGalleryPost', + 'clearUserFiles', + 'clearRemoteFiles', + 'clearOwnerlessFiles', + 'clearInstanceFiles', + 'severFollowRelations', + 'removeRelay', ].includes(log.type) }" >{{ i18n.ts._moderationLogTypes[log.type] }} @@ -100,6 +115,17 @@ SPDX-License-Identifier: AGPL-3.0-only : @{{ log.info.pageUserUsername }} : @{{ log.info.flashUserUsername }} : @{{ log.info.postUserUsername }} + : @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }} + : @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }} + : @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }} + : @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }} + : @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }} + : @{{ log.info.userUsername }} + : {{ log.info.fileName }} + : {{ log.info.host }} + : {{ log.info.host }} + : {{ log.info.inbox }} + : {{ log.info.inbox }} + + + + + + + + +
raw diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md index 24a5294c91..e5d4bb6143 100644 --- a/packages/misskey-js/etc/misskey-js.api.md +++ b/packages/misskey-js/etc/misskey-js.api.md @@ -2728,6 +2728,51 @@ type ModerationLog = { } | { type: 'deleteGalleryPost'; info: ModerationLogPayloads['deleteGalleryPost']; +} | { + type: 'clearUserFiles'; + info: ModerationLogPayloads['clearUserFiles']; +} | { + type: 'nsfwUser'; + info: ModerationLogPayloads['nsfwUser']; +} | { + type: 'unNsfwUser'; + info: ModerationLogPayloads['unNsfwUser']; +} | { + type: 'silenceUser'; + info: ModerationLogPayloads['silenceUser']; +} | { + type: 'unSilenceUser'; + info: ModerationLogPayloads['unSilenceUser']; +} | { + type: 'createAccount'; + info: ModerationLogPayloads['createAccount']; +} | { + type: 'clearRemoteFiles'; + info: ModerationLogPayloads['clearRemoteFiles']; +} | { + type: 'clearOwnerlessFiles'; + info: ModerationLogPayloads['clearOwnerlessFiles']; +} | { + type: 'updateCustomEmojis'; + info: ModerationLogPayloads['updateCustomEmojis']; +} | { + type: 'importCustomEmojis'; + info: ModerationLogPayloads['importCustomEmojis']; +} | { + type: 'clearInstanceFiles'; + info: ModerationLogPayloads['clearInstanceFiles']; +} | { + type: 'severFollowRelations'; + info: ModerationLogPayloads['severFollowRelations']; +} | { + type: 'createPromo'; + info: ModerationLogPayloads['createPromo']; +} | { + type: 'addRelay'; + info: ModerationLogPayloads['addRelay']; +} | { + type: 'removeRelay'; + info: ModerationLogPayloads['removeRelay']; }); // @public (undocumented) diff --git a/packages/misskey-js/src/entities.ts b/packages/misskey-js/src/entities.ts index 7b5d56f01c..3b31a6e531 100644 --- a/packages/misskey-js/src/entities.ts +++ b/packages/misskey-js/src/entities.ts @@ -228,6 +228,51 @@ export type ModerationLog = { } | { type: 'deleteGalleryPost'; info: ModerationLogPayloads['deleteGalleryPost']; +} | { + type: 'clearUserFiles'; + info: ModerationLogPayloads['clearUserFiles']; +} | { + type: 'nsfwUser'; + info: ModerationLogPayloads['nsfwUser']; +} | { + type: 'unNsfwUser'; + info: ModerationLogPayloads['unNsfwUser']; +} | { + type: 'silenceUser'; + info: ModerationLogPayloads['silenceUser']; +} | { + type: 'unSilenceUser'; + info: ModerationLogPayloads['unSilenceUser']; +} | { + type: 'createAccount'; + info: ModerationLogPayloads['createAccount']; +} | { + type: 'clearRemoteFiles'; + info: ModerationLogPayloads['clearRemoteFiles']; +} | { + type: 'clearOwnerlessFiles'; + info: ModerationLogPayloads['clearOwnerlessFiles']; +} | { + type: 'updateCustomEmojis'; + info: ModerationLogPayloads['updateCustomEmojis']; +} | { + type: 'importCustomEmojis'; + info: ModerationLogPayloads['importCustomEmojis']; +} | { + type: 'clearInstanceFiles'; + info: ModerationLogPayloads['clearInstanceFiles']; +} | { + type: 'severFollowRelations'; + info: ModerationLogPayloads['severFollowRelations']; +} | { + type: 'createPromo'; + info: ModerationLogPayloads['createPromo']; +} | { + type: 'addRelay'; + info: ModerationLogPayloads['addRelay']; +} | { + type: 'removeRelay'; + info: ModerationLogPayloads['removeRelay']; }); export type ServerStats = { diff --git a/sharkey-locales/en-US.yml b/sharkey-locales/en-US.yml index ca454d2272..7e69c9b97d 100644 --- a/sharkey-locales/en-US.yml +++ b/sharkey-locales/en-US.yml @@ -169,6 +169,7 @@ pinnedOnly: "Pinned" blockingYou: "Blocking you" warnExternalUrl: "Show warning when opening external URLs" flash: "Flash" +filesRemoved: "Files removed:" _flash: contentHidden: "Flash Content Hidden" poweredByRuffle: "Powered by Ruffle." @@ -320,6 +321,22 @@ _moderationLogTypes: acceptRemoteInstanceReports: "Accepted reports from remote instance" rejectQuotesUser: "Blocked/Stripped quote posts from user" allowQuotesUser: "Allowed quote posts from user" + clearUserFiles: "Cleared a user's drive files" + nsfwUser: "Marked user as NSFW" + unNsfwUser: "Un-marked user as NSFW" + silenceUser: "Silenced user" + unSilenceUser: "Un-silenced user" + createAccount: "Created an account" + clearRemoteFiles: "Cleared remote drive files" + clearOwnerlessFiles: "Cleared owner-less drive files" + updateCustomEmojis: "Updated custom emojis" + importCustomEmojis: "Imported custom emojis" + clearInstanceFiles: "Cleared an instance's drive files" + severFollowRelations: "Severed follow relations with an instance" + createPromo: "Created a note promo" + addRelay: "Added a relay" + removeRelay: "Removed a relay" + _mfm: uncommonFeature: "This is not a widespread feature, it may not display properly on most other fedi software, including other Misskey forks" intro: "MFM is a markup language used on Misskey, Sharkey, Firefish, Akkoma, and more that can be used in many places. Here you can view a list of all available MFM syntax." From cea77f3e2c799d062adbd78c289c7094f6ada091 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Thu, 27 Feb 2025 10:26:36 -0500 Subject: [PATCH 02/12] emit "show" event from MkLazy --- packages/frontend/src/components/global/MkLazy.vue | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/frontend/src/components/global/MkLazy.vue b/packages/frontend/src/components/global/MkLazy.vue index f35932ae77..29908f303d 100644 --- a/packages/frontend/src/components/global/MkLazy.vue +++ b/packages/frontend/src/components/global/MkLazy.vue @@ -16,10 +16,20 @@ import { nextTick, onMounted, onActivated, onBeforeUnmount, ref, shallowRef } fr const rootEl = shallowRef(); const showing = ref(false); +const emit = defineEmits<{ + (ev: 'show'): void, +}>(); + const observer = new IntersectionObserver( (entries) => { if (entries.some((entry) => entry.isIntersecting)) { showing.value = true; + + // Disconnect to avoid observer soft-leaks + observer.disconnect(); + + // Notify containing element to trigger edge logic + emit('show'); } }, ); From 9e833f724b61bfbe0f09bb09027650020016f30d Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Thu, 27 Feb 2025 10:27:00 -0500 Subject: [PATCH 03/12] add DynamicNote to encapsulate MkNote / SkNote switching logic --- .../frontend/src/components/DynamicNote.vue | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 packages/frontend/src/components/DynamicNote.vue diff --git a/packages/frontend/src/components/DynamicNote.vue b/packages/frontend/src/components/DynamicNote.vue new file mode 100644 index 0000000000..6703099591 --- /dev/null +++ b/packages/frontend/src/components/DynamicNote.vue @@ -0,0 +1,49 @@ + + + + + From 20e2a6e95aefea2a84943e4846ad9bfc26755afd Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Thu, 27 Feb 2025 10:27:48 -0500 Subject: [PATCH 04/12] add SkFetchNote to render a note by ID --- locales/index.d.ts | 4 + .../frontend/src/components/SkFetchNote.vue | 74 +++++++++++++++++++ sharkey-locales/en-US.yml | 1 + 3 files changed, 79 insertions(+) create mode 100644 packages/frontend/src/components/SkFetchNote.vue diff --git a/locales/index.d.ts b/locales/index.d.ts index 4907754394..ee99a9513d 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -11665,6 +11665,10 @@ export interface Locale extends ILocale { * Files removed: */ "filesRemoved": string; + /** + * Failed to load no + */ + "cannotLoadNote": string; "_flash": { /** * Flash Content Hidden diff --git a/packages/frontend/src/components/SkFetchNote.vue b/packages/frontend/src/components/SkFetchNote.vue new file mode 100644 index 0000000000..57577aa15b --- /dev/null +++ b/packages/frontend/src/components/SkFetchNote.vue @@ -0,0 +1,74 @@ + + + + + + + diff --git a/sharkey-locales/en-US.yml b/sharkey-locales/en-US.yml index 7e69c9b97d..5336a36340 100644 --- a/sharkey-locales/en-US.yml +++ b/sharkey-locales/en-US.yml @@ -170,6 +170,7 @@ blockingYou: "Blocking you" warnExternalUrl: "Show warning when opening external URLs" flash: "Flash" filesRemoved: "Files removed:" +cannotLoadNote: "Failed to load no" _flash: contentHidden: "Flash Content Hidden" poweredByRuffle: "Powered by Ruffle." From c44c59e9ae7ed172a6bbabf0846ffb3123ac06ab Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Thu, 27 Feb 2025 10:28:47 -0500 Subject: [PATCH 05/12] remove embedded Note from "createPromo" mod logs --- packages/backend/src/types.ts | 1 - packages/misskey-js/src/consts.ts | 12 ++++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts index 977d6157bc..530613ef4b 100644 --- a/packages/backend/src/types.ts +++ b/packages/backend/src/types.ts @@ -512,7 +512,6 @@ export type ModerationLogPayloads = { noteUserId: string; noteUserUsername: string; noteUserHost: string | null; - note: any; }, addRelay: { inbox: string; diff --git a/packages/misskey-js/src/consts.ts b/packages/misskey-js/src/consts.ts index b520c05d8e..a69ab168b8 100644 --- a/packages/misskey-js/src/consts.ts +++ b/packages/misskey-js/src/consts.ts @@ -510,4 +510,16 @@ export type ModerationLogPayloads = { id: string; host: string; }; + createPromo: { + noteId: string, + noteUserId: string; + noteUserUsername: string; + noteUserHost: string | null; + }; + addRelay: { + inbox: string; + }; + removeRelay: { + inbox: string; + }; }; From e5b8fc3c800abaca820f292e4481f7a338cde0f4 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Thu, 27 Feb 2025 10:29:03 -0500 Subject: [PATCH 06/12] add missing modlog render blocks --- locales/index.d.ts | 6 +++++- .../frontend/src/pages/admin/modlog.ModLog.vue | 15 +++++++++++++++ sharkey-locales/en-US.yml | 3 ++- 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/locales/index.d.ts b/locales/index.d.ts index ee99a9513d..25662afe6a 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -11662,9 +11662,13 @@ export interface Locale extends ILocale { */ "flash": string; /** - * Files removed: + * Files removed */ "filesRemoved": string; + /** + * File imported + */ + "fileImported": string; /** * Failed to load no */ diff --git a/packages/frontend/src/pages/admin/modlog.ModLog.vue b/packages/frontend/src/pages/admin/modlog.ModLog.vue index a4731af47b..1d60403f07 100644 --- a/packages/frontend/src/pages/admin/modlog.ModLog.vue +++ b/packages/frontend/src/pages/admin/modlog.ModLog.vue @@ -121,9 +121,11 @@ SPDX-License-Identifier: AGPL-3.0-only : @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }} : @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }} : @{{ log.info.userUsername }} + : {{ log.info.count }} : {{ log.info.fileName }} : {{ log.info.host }} : {{ log.info.host }} + : @{{ log.info.noteUserUsername }}{{ log.info.noteUserHost ? '@' + log.info.noteUserHost : '' }} : {{ log.info.inbox }} : {{ log.info.inbox }} @@ -253,6 +255,9 @@ SPDX-License-Identifier: AGPL-3.0-only +