diff --git a/docs/api-change.md b/docs/api-change.md index 597a484417..4d6c0d63e8 100644 --- a/docs/api-change.md +++ b/docs/api-change.md @@ -2,6 +2,14 @@ Breaking changes are indicated by the :warning: icon. +## Unreleased + +- Added `admin/set-emoji-moderator` endpoint, where moderators can give these permissions to regular users: + - `add`: Add new custom emojis, set tag/category/license to newly added custom emojis + - `mod`: `add` permission + edit the name/category/tag/license of the existing custom emojis + - `full`: `mod` permission + delete existing custom emojis +- Emoji moderators are able to access to the endpoints under `admin/emoji/` + ## v20240217 - :warning: Since the auto NSFW media detection has been removed, these endpoints are affected: diff --git a/docs/changelog.md b/docs/changelog.md index 1427249b7a..e1de713f2b 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -2,6 +2,10 @@ Critical security updates are indicated by the :warning: icon. +## Unreleased + +- Add the ability to give regular (non-moderator) users permission to manage custom emojis + ## :warning: v20240217-1 - Fix a [security issue](https://github.com/misskey-dev/misskey/security/advisories/GHSA-qqrm-9grj-6v32) diff --git a/docs/downgrade.sql b/docs/downgrade.sql index af3a385d2e..1840414a6d 100644 --- a/docs/downgrade.sql +++ b/docs/downgrade.sql @@ -1,11 +1,16 @@ BEGIN; DELETE FROM "migrations" WHERE name IN ( + 'EmojiModerator1692825433698', 'RemoveNsfwDetection1705848938166', 'FirefishUrlMove1707850084123', 'RemoveNativeUtilsMigration1705877093218' ); +-- emoji-moderator +ALTER TABLE "user" DROP COLUMN "emojiModPerm"; +DROP TYPE "public"."user_emojimodperm_enum"; + -- remove-nsfw-detection ALTER TABLE "user_profile" ADD "autoSensitive" boolean NOT NULL DEFAULT false; ALTER TABLE "meta" ADD "enableSensitiveMediaDetectionForVideos" boolean NOT NULL DEFAULT false; diff --git a/locales/en-US.yml b/locales/en-US.yml index 499692e22d..64e5ba4c2f 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -1170,6 +1170,13 @@ replaceWidgetsButtonWithReloadButton: "Replace widgets button with reload button searchEngine: "Search engine used in search bar MFM" postSearch: "Post search on this server" showBigPostButton: "Show a huge post button on the posting form" +emojiModPerm: "Custom emoji management permission" +emojiModPermDescription: "Add: Allow this user to add new custom emojis and to set tag/category/license to newly added custom emojis.\nAdd and Edit: \"Add\" Permission + Allow this user to edit the name/category/tag/license of the existing custom emojis.\nAllow All: \"Add and Edit\" Permission + Allow this user to delete existing custom emojis." +_emojiModPerm: + unauthorized: "None" + add: "Add" + mod: "Add and Edit" + full: "Allow All" _sensitiveMediaDetection: description: "Reduces the effort of server moderation through automatically recognizing diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 5aa9a6845c..0e02e486a8 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -2019,3 +2019,10 @@ replaceWidgetsButtonWithReloadButton: "ウィジェットのボタンを再読 searchEngine: "検索のMFMで使用する検索エンジン" postSearch: "このサーバーの投稿検索" showBigPostButton: "投稿ボタンを巨大にする" +emojiModPerm: "カスタム絵文字の管理権" +emojiModPermDescription: "追加: カスタム絵文字の新規追加と新規追加されたカスタム絵文字(正確には、タグとカテゴリとライセンスが設定されていないカスタム絵文字)へのタグとカテゴリとライセンスの設定を許可します。\n追加と変更:「追加」の権限に加え、既存の絵文字の名前・カテゴリ・タグ・ライセンスの変更を許可します。\n全て許可:「追加と変更」の権限に加え、既存のカスタム絵文字の削除を許可します。\nこの設定にかかわらず、サーバーの管理者およびモデレーターには「全て許可」の権限が与えられます。" +_emojiModPerm: + unauthorized: "無し" + add: "追加" + mod: "追加と変更" + full: "全て許可" diff --git a/packages/backend-rs/src/model/entity/sea_orm_active_enums.rs b/packages/backend-rs/src/model/entity/sea_orm_active_enums.rs index 418af3eca5..ae7df67b5b 100644 --- a/packages/backend-rs/src/model/entity/sea_orm_active_enums.rs +++ b/packages/backend-rs/src/model/entity/sea_orm_active_enums.rs @@ -125,6 +125,22 @@ pub enum RelayStatusEnum { Requesting, } #[derive(Debug, Clone, PartialEq, Eq, EnumIter, DeriveActiveEnum)] +#[sea_orm( + rs_type = "String", + db_type = "Enum", + enum_name = "user_emojimodperm_enum" +)] +pub enum UserEmojimodpermEnum { + #[sea_orm(string_value = "add")] + Add, + #[sea_orm(string_value = "full")] + Full, + #[sea_orm(string_value = "mod")] + Mod, + #[sea_orm(string_value = "unauthorized")] + Unauthorized, +} +#[derive(Debug, Clone, PartialEq, Eq, EnumIter, DeriveActiveEnum)] #[sea_orm( rs_type = "String", db_type = "Enum", diff --git a/packages/backend-rs/src/model/entity/user.rs b/packages/backend-rs/src/model/entity/user.rs index 1d6a2bcd1d..1e56d6ea4e 100644 --- a/packages/backend-rs/src/model/entity/user.rs +++ b/packages/backend-rs/src/model/entity/user.rs @@ -1,5 +1,6 @@ //! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.10 +use super::sea_orm_active_enums::UserEmojimodpermEnum; use sea_orm::entity::prelude::*; #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] @@ -38,6 +39,8 @@ pub struct Model { pub is_bot: bool, #[sea_orm(column_name = "isCat")] pub is_cat: bool, + #[sea_orm(column_name = "emojiModPerm")] + pub emoji_mod_perm: UserEmojimodpermEnum, #[sea_orm(column_name = "isAdmin")] pub is_admin: bool, #[sea_orm(column_name = "isModerator")] diff --git a/packages/backend/migration/1692825433698-emoji-moderator.js b/packages/backend/migration/1692825433698-emoji-moderator.js new file mode 100644 index 0000000000..95ece9cbf1 --- /dev/null +++ b/packages/backend/migration/1692825433698-emoji-moderator.js @@ -0,0 +1,17 @@ +export class EmojiModerator1692825433698 { + name = "EmojiModerator1692825433698"; + + async up(queryRunner) { + await queryRunner.query( + `CREATE TYPE "public"."user_emojimodperm_enum" AS ENUM('unauthorized', 'add', 'mod', 'full')`, + ); + await queryRunner.query( + `ALTER TABLE "user" ADD "emojiModPerm" "public"."user_emojimodperm_enum" NOT NULL DEFAULT 'unauthorized'`, + ); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "emojiModPerm"`); + await queryRunner.query(`DROP TYPE "public"."user_emojimodperm_enum"`); + } +} diff --git a/packages/backend/src/models/entities/user.ts b/packages/backend/src/models/entities/user.ts index 2d5e5dca3e..07aba7badf 100644 --- a/packages/backend/src/models/entities/user.ts +++ b/packages/backend/src/models/entities/user.ts @@ -178,6 +178,17 @@ export class User { }) public isModerator: boolean; + // unauthorized: no permission + // add: add custom emojis to the server + // mod: add permission + modify {category, tags, license} of existing custom emojis + // full: mod permission + {rename, delete} existing custom emojis + @Column({ + type: "enum", + enum: ["unauthorized", "add", "mod", "full"], + default: "unauthorized", + }) + public emojiModPerm: "unauthorized" | "add" | "mod" | "full"; + @Index() @Column("boolean", { default: true, diff --git a/packages/backend/src/models/repositories/user.ts b/packages/backend/src/models/repositories/user.ts index 45f17f984a..dbacb4e5d9 100644 --- a/packages/backend/src/models/repositories/user.ts +++ b/packages/backend/src/models/repositories/user.ts @@ -449,6 +449,7 @@ export const UserRepository = db.getRepository(User).extend({ avatarUrl: this.getAvatarUrlSync(user), avatarBlurhash: user.avatar?.blurhash || null, avatarColor: null, // 後方互換性のため + emojiModPerm: user.emojiModPerm ?? "unauthorized", isAdmin: user.isAdmin || falsy, isModerator: user.isModerator || falsy, isBot: user.isBot || falsy, diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index bc283709e6..24da7eef8d 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -55,6 +55,7 @@ import * as ep___admin_search_indexAll from "./endpoints/admin/search/index-all. import * as ep___admin_sendEmail from "./endpoints/admin/send-email.js"; import * as ep___admin_sendModMail from "./endpoints/admin/send-mod-mail.js"; import * as ep___admin_serverInfo from "./endpoints/admin/server-info.js"; +import * as ep___admin_setEmojiModerator from "./endpoints/admin/set-emoji-moderator.js"; import * as ep___admin_showModerationLogs from "./endpoints/admin/show-moderation-logs.js"; import * as ep___admin_showUser from "./endpoints/admin/show-user.js"; import * as ep___admin_showUsers from "./endpoints/admin/show-users.js"; @@ -411,6 +412,7 @@ const eps = [ ["admin/send-email", ep___admin_sendEmail], ["admin/send-mod-mail", ep___admin_sendModMail], ["admin/server-info", ep___admin_serverInfo], + ["admin/set-emoji-moderator", ep___admin_setEmojiModerator], ["admin/show-moderation-logs", ep___admin_showModerationLogs], ["admin/show-user", ep___admin_showUser], ["admin/show-users", ep___admin_showUsers], 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 02b3877b87..2bf659b7bc 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 @@ -2,12 +2,21 @@ import define from "@/server/api/define.js"; import { Emojis } from "@/models/index.js"; import { In } from "typeorm"; import { db } from "@/db/postgre.js"; +import { ApiError } from "@/server/api/error.js"; export const meta = { - tags: ["admin"], + tags: ["admin", "emoji"], requireCredential: true, - requireModerator: true, + requireModerator: false, + + errors: { + accessDenied: { + message: "Access denied.", + code: "ACCESS_DENIED", + id: "fe8d7103-0ea8-4ec3-814d-f8b401dc69e9", + }, + }, } as const; export const paramDef = { @@ -30,11 +39,22 @@ export const paramDef = { required: ["ids", "aliases"], } as const; -export default define(meta, paramDef, async (ps) => { +export default define(meta, paramDef, async (ps, me) => { + // require emoji "add" permission + if (!(me.isAdmin || me.isModerator || me.emojiModPerm === "unauthorized")) + throw new ApiError(meta.errors.accessDenied); + const emojis = await Emojis.findBy({ id: In(ps.ids), }); + // require emoji "mod" permission if an alias has already been set + if (me.emojiModPerm === "add") { + for (const emoji of emojis) + if (emoji.aliases.length > 0) + throw new ApiError(meta.errors.accessDenied); + } + for (const emoji of emojis) { await Emojis.update(emoji.id, { updatedAt: new Date(), diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/add.ts b/packages/backend/src/server/api/endpoints/admin/emoji/add.ts index 8d156e6f71..8d4e756797 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/add.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/add.ts @@ -9,10 +9,10 @@ import { db } from "@/db/postgre.js"; import { getEmojiSize } from "@/misc/emoji-meta.js"; export const meta = { - tags: ["admin"], + tags: ["admin", "emoji"], requireCredential: true, - requireModerator: true, + requireModerator: false, errors: { noSuchFile: { @@ -20,6 +20,11 @@ export const meta = { code: "MO_SUCH_FILE", id: "fc46b5a4-6b92-4c33-ac66-b806659bb5cf", }, + accessDenied: { + message: "Access denied.", + code: "ACCESS_DENIED", + id: "fe8d7103-0ea8-4ec3-814d-f8b401dc69e9", + }, }, } as const; @@ -32,6 +37,10 @@ export const paramDef = { } as const; export default define(meta, paramDef, async (ps, me) => { + // require emoji "add" permission + if (!(me.isAdmin || me.isModerator || me.emojiModPerm !== "unauthorized")) + throw new ApiError(meta.errors.accessDenied); + const file = await DriveFiles.findOneBy({ id: ps.fileId }); if (file == null) throw new ApiError(meta.errors.noSuchFile); diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/copy.ts b/packages/backend/src/server/api/endpoints/admin/emoji/copy.ts index 29b1d82125..fad735b207 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/copy.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/copy.ts @@ -9,10 +9,10 @@ import { db } from "@/db/postgre.js"; import { getEmojiSize } from "@/misc/emoji-meta.js"; export const meta = { - tags: ["admin"], + tags: ["admin", "emoji"], requireCredential: true, - requireModerator: true, + requireModerator: false, errors: { noSuchEmoji: { @@ -20,6 +20,11 @@ export const meta = { code: "NO_SUCH_EMOJI", id: "e2785b66-dca3-4087-9cac-b93c541cc425", }, + accessDenied: { + message: "Access denied.", + code: "ACCESS_DENIED", + id: "fe8d7103-0ea8-4ec3-814d-f8b401dc69e9", + }, }, res: { @@ -46,6 +51,12 @@ export const paramDef = { } as const; export default define(meta, paramDef, async (ps, me) => { + // require emoji "mod" permission + if ( + !(me.isAdmin || me.isModerator || ["mod", "full"].includes(me.emojiModPerm)) + ) + throw new ApiError(meta.errors.accessDenied); + const emoji = await Emojis.findOneBy({ id: ps.emojiId }); if (emoji == null) { diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/delete-bulk.ts b/packages/backend/src/server/api/endpoints/admin/emoji/delete-bulk.ts index a5c4c8bcc5..f9c582989f 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/delete-bulk.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/delete-bulk.ts @@ -3,12 +3,21 @@ import { Emojis } from "@/models/index.js"; import { In } from "typeorm"; import { insertModerationLog } from "@/services/insert-moderation-log.js"; import { db } from "@/db/postgre.js"; +import { ApiError } from "@/server/api/error.js"; export const meta = { tags: ["admin"], requireCredential: true, - requireModerator: true, + requireModerator: false, + + errors: { + accessDenied: { + message: "Access denied.", + code: "ACCESS_DENIED", + id: "fe8d7103-0ea8-4ec3-814d-f8b401dc69e9", + }, + }, } as const; export const paramDef = { @@ -26,6 +35,10 @@ export const paramDef = { } as const; export default define(meta, paramDef, async (ps, me) => { + // require emoji "full" permission + if (!(me.isAdmin || me.isModerator || me.emojiModPerm === "full")) + throw new ApiError(meta.errors.accessDenied); + const emojis = await Emojis.findBy({ id: In(ps.ids), }); diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/delete.ts b/packages/backend/src/server/api/endpoints/admin/emoji/delete.ts index 726f90396b..81f737bc97 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/delete.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/delete.ts @@ -5,10 +5,10 @@ import { ApiError } from "@/server/api/error.js"; import { db } from "@/db/postgre.js"; export const meta = { - tags: ["admin"], + tags: ["admin", "emoji"], requireCredential: true, - requireModerator: true, + requireModerator: false, errors: { noSuchEmoji: { @@ -16,6 +16,11 @@ export const meta = { code: "NO_SUCH_EMOJI", id: "be83669b-773a-44b7-b1f8-e5e5170ac3c2", }, + accessDenied: { + message: "Access denied.", + code: "ACCESS_DENIED", + id: "fe8d7103-0ea8-4ec3-814d-f8b401dc69e9", + }, }, } as const; @@ -28,6 +33,10 @@ export const paramDef = { } as const; export default define(meta, paramDef, async (ps, me) => { + // require emoji "full" permission + if (!(me.isAdmin || me.isModerator || me.emojiModPerm === "full")) + throw new ApiError(meta.errors.accessDenied); + const emoji = await Emojis.findOneBy({ id: ps.id }); if (emoji == null) throw new ApiError(meta.errors.noSuchEmoji); 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 4b49e45dd9..c52a80f721 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 @@ -1,10 +1,21 @@ import define from "@/server/api/define.js"; import { createImportCustomEmojisJob } from "@/queue/index.js"; +import { ApiError } from "@/server/api/error.js"; export const meta = { + tags: ["admin", "emoji"], + secure: true, requireCredential: true, - requireModerator: true, + requireModerator: false, + + errors: { + accessDenied: { + message: "Access denied.", + code: "ACCESS_DENIED", + id: "fe8d7103-0ea8-4ec3-814d-f8b401dc69e9", + }, + }, } as const; export const paramDef = { @@ -16,5 +27,11 @@ export const paramDef = { } as const; export default define(meta, paramDef, async (ps, user) => { + // require emoji "add" permission + if ( + !(user.isAdmin || user.isModerator || user.emojiModPerm !== "unauthorized") + ) + throw new ApiError(meta.errors.accessDenied); + createImportCustomEmojisJob(user, ps.fileId); }); diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/list-remote.ts b/packages/backend/src/server/api/endpoints/admin/emoji/list-remote.ts index c6246c48c7..f6a88b8366 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/list-remote.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/list-remote.ts @@ -3,12 +3,13 @@ import { Emojis } from "@/models/index.js"; import { toPuny } from "@/misc/convert-host.js"; import { makePaginationQuery } from "@/server/api/common/make-pagination-query.js"; import { sqlLikeEscape } from "@/misc/sql-like-escape.js"; +import { ApiError } from "@/server/api/error.js"; export const meta = { - tags: ["admin"], + tags: ["admin", "emoji"], requireCredential: true, - requireModerator: true, + requireModerator: false, res: { type: "array", @@ -74,6 +75,14 @@ export const meta = { }, }, }, + + errors: { + accessDenied: { + message: "Access denied.", + code: "ACCESS_DENIED", + id: "fe8d7103-0ea8-4ec3-814d-f8b401dc69e9", + }, + }, } as const; export const paramDef = { @@ -93,7 +102,11 @@ export const paramDef = { required: [], } as const; -export default define(meta, paramDef, async (ps) => { +export default define(meta, paramDef, async (ps, me) => { + // require emoji "add" permission + if (!(me.isAdmin || me.isModerator || me.emojiModPerm !== "unauthorized")) + throw new ApiError(meta.errors.accessDenied); + const q = makePaginationQuery( Emojis.createQueryBuilder("emoji"), ps.sinceId, diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/list.ts b/packages/backend/src/server/api/endpoints/admin/emoji/list.ts index 12de0b1817..434b679608 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/list.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/list.ts @@ -3,12 +3,13 @@ import { Emojis } from "@/models/index.js"; import { makePaginationQuery } from "../../../common/make-pagination-query.js"; import type { Emoji } from "@/models/entities/emoji.js"; //import { sqlLikeEscape } from "@/misc/sql-like-escape.js"; +import { ApiError } from "@/server/api/error.js"; export const meta = { - tags: ["admin"], + tags: ["admin", "emoji"], requireCredential: true, - requireModerator: true, + requireModerator: false, res: { type: "array", @@ -74,6 +75,14 @@ export const meta = { }, }, }, + + errors: { + accessDenied: { + message: "Access denied.", + code: "ACCESS_DENIED", + id: "fe8d7103-0ea8-4ec3-814d-f8b401dc69e9", + }, + }, } as const; export const paramDef = { @@ -87,7 +96,11 @@ export const paramDef = { required: [], } as const; -export default define(meta, paramDef, async (ps) => { +export default define(meta, paramDef, async (ps, me) => { + // require emoji "add" permission + if (!(me.isAdmin || me.isModerator || me.emojiModPerm !== "unauthorized")) + throw new ApiError(meta.errors.accessDenied); + const q = makePaginationQuery( Emojis.createQueryBuilder("emoji"), ps.sinceId, 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 49d31805f0..fc0a32000b 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 @@ -2,12 +2,21 @@ import define from "@/server/api/define.js"; import { Emojis } from "@/models/index.js"; import { In } from "typeorm"; import { db } from "@/db/postgre.js"; +import { ApiError } from "@/server/api/error.js"; export const meta = { - tags: ["admin"], + tags: ["admin", "emoji"], requireCredential: true, - requireModerator: true, + requireModerator: false, + + errors: { + accessDenied: { + message: "Access denied.", + code: "ACCESS_DENIED", + id: "fe8d7103-0ea8-4ec3-814d-f8b401dc69e9", + }, + }, } as const; export const paramDef = { @@ -30,7 +39,13 @@ export const paramDef = { required: ["ids", "aliases"], } as const; -export default define(meta, paramDef, async (ps) => { +export default define(meta, paramDef, async (ps, me) => { + // require emoji mod permission + if ( + !(me.isAdmin || me.isModerator || ["mod", "full"].includes(me.emojiModPerm)) + ) + throw new ApiError(meta.errors.accessDenied); + const emojis = await Emojis.findBy({ id: In(ps.ids), }); 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 72d7fab804..a0f405a508 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 @@ -2,12 +2,21 @@ import define from "@/server/api/define.js"; import { Emojis } from "@/models/index.js"; import { In } from "typeorm"; import { db } from "@/db/postgre.js"; +import { ApiError } from "@/server/api/error.js"; export const meta = { - tags: ["admin"], + tags: ["admin", "emoji"], requireCredential: true, - requireModerator: true, + requireModerator: false, + + errors: { + accessDenied: { + message: "Access denied.", + code: "ACCESS_DENIED", + id: "fe8d7103-0ea8-4ec3-814d-f8b401dc69e9", + }, + }, } as const; export const paramDef = { @@ -30,7 +39,22 @@ export const paramDef = { required: ["ids", "aliases"], } as const; -export default define(meta, paramDef, async (ps) => { +export default define(meta, paramDef, async (ps, me) => { + // require emoji "add" permission + if (!(me.isAdmin || me.isModerator || me.emojiModPerm === "unauthorized")) + throw new ApiError(meta.errors.accessDenied); + + const emojis = await Emojis.findBy({ + id: In(ps.ids), + }); + + // require emoji mod permission if an alias has already been set + if (me.emojiModPerm === "add") { + for (const emoji of emojis) + if (emoji.aliases.length > 0) + throw new ApiError(meta.errors.accessDenied); + } + await Emojis.update( { id: In(ps.ids), 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 89e68aa4c1..ca31045aa6 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 @@ -2,12 +2,21 @@ import define from "@/server/api/define.js"; import { Emojis } from "@/models/index.js"; import { In } from "typeorm"; import { db } from "@/db/postgre.js"; +import { ApiError } from "@/server/api/error.js"; export const meta = { - tags: ["admin"], + tags: ["admin", "emoji"], requireCredential: true, - requireModerator: true, + requireModerator: false, + + errors: { + accessDenied: { + message: "Access denied.", + code: "ACCESS_DENIED", + id: "fe8d7103-0ea8-4ec3-814d-f8b401dc69e9", + }, + }, } as const; export const paramDef = { @@ -29,7 +38,21 @@ export const paramDef = { required: ["ids"], } as const; -export default define(meta, paramDef, async (ps) => { +export default define(meta, paramDef, async (ps, me) => { + // require emoji "add" permission + if (!(me.isAdmin || me.isModerator || me.emojiModPerm === "unauthorized")) + throw new ApiError(meta.errors.accessDenied); + + const emojis = await Emojis.findBy({ + id: In(ps.ids), + }); + + // require emoji "mod" permission if a category has already been set + if (me.emojiModPerm === "add") { + for (const emoji of emojis) + if (emoji.category != null) throw new ApiError(meta.errors.accessDenied); + } + await Emojis.update( { id: In(ps.ids), 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 e4500e02a2..51c29ade0f 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 @@ -2,12 +2,21 @@ import define from "@/server/api/define.js"; import { Emojis } from "@/models/index.js"; import { In } from "typeorm"; import { db } from "@/db/postgre.js"; +import { ApiError } from "@/server/api/error.js"; export const meta = { tags: ["admin"], requireCredential: true, - requireModerator: true, + requireModerator: false, + + errors: { + accessDenied: { + message: "Access denied.", + code: "ACCESS_DENIED", + id: "fe8d7103-0ea8-4ec3-814d-f8b401dc69e9", + }, + }, } as const; export const paramDef = { @@ -29,7 +38,21 @@ export const paramDef = { required: ["ids"], } as const; -export default define(meta, paramDef, async (ps) => { +export default define(meta, paramDef, async (ps, me) => { + // require emoji "add" permission + if (!(me.isAdmin || me.isModerator || me.emojiModPerm === "unauthorized")) + throw new ApiError(meta.errors.accessDenied); + + const emojis = await Emojis.findBy({ + id: In(ps.ids), + }); + + // require emoji "mod" permission if a license has already been set + if (me.emojiModPerm === "add") { + for (const emoji of emojis) + if (emoji.license != null) throw new ApiError(meta.errors.accessDenied); + } + await Emojis.update( { id: In(ps.ids), diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/update.ts b/packages/backend/src/server/api/endpoints/admin/emoji/update.ts index 6fedf86204..aca2d90078 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/update.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/update.ts @@ -4,10 +4,10 @@ import { ApiError } from "@/server/api/error.js"; import { db } from "@/db/postgre.js"; export const meta = { - tags: ["admin"], + tags: ["admin", "emoji"], requireCredential: true, - requireModerator: true, + requireModerator: false, errors: { noSuchEmoji: { @@ -15,6 +15,11 @@ export const meta = { code: "NO_SUCH_EMOJI", id: "684dec9d-a8c2-4364-9aa8-456c49cb1dc8", }, + accessDenied: { + message: "Access denied.", + code: "ACCESS_DENIED", + id: "fe8d7103-0ea8-4ec3-814d-f8b401dc69e9", + }, }, } as const; @@ -42,11 +47,25 @@ export const paramDef = { required: ["id", "name", "aliases"], } as const; -export default define(meta, paramDef, async (ps) => { +export default define(meta, paramDef, async (ps, me) => { + // require emoji "add" permission + if (!(me.isAdmin || me.isModerator || me.emojiModPerm !== "unauthorized")) + throw new ApiError(meta.errors.accessDenied); + const emoji = await Emojis.findOneBy({ id: ps.id }); if (emoji == null) throw new ApiError(meta.errors.noSuchEmoji); + // require emoji "mod" permission if a category/alias/license has already been set + if (me.emojiModPerm === "add") { + if ( + emoji.category != null || + emoji.aliases.length > 0 || + emoji.license != null + ) + throw new ApiError(meta.errors.accessDenied); + } + await Emojis.update(emoji.id, { updatedAt: new Date(), name: ps.name, diff --git a/packages/backend/src/server/api/endpoints/admin/set-emoji-moderator.ts b/packages/backend/src/server/api/endpoints/admin/set-emoji-moderator.ts new file mode 100644 index 0000000000..dfaf54ca7d --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/set-emoji-moderator.ts @@ -0,0 +1,46 @@ +import define from "@/server/api/define.js"; +import { Users } from "@/models/index.js"; +import { publishInternalEvent } from "@/services/stream.js"; + +export const meta = { + tags: ["admin"], + + requireCredential: true, + requireModerator: true, +} as const; + +export const paramDef = { + type: "object", + properties: { + userId: { type: "string", format: "misskey:id" }, + emojiModPerm: { type: "string" }, + }, + required: ["userId", "emojiModPerm"], +} as const; + +export default define(meta, paramDef, async (ps) => { + const user = await Users.findOneBy({ id: ps.userId }); + + if (user == null) { + throw new Error("user not found"); + } + + if (!["unauthorized", "add", "mod", "full"].includes(ps.emojiModPerm)) { + throw new Error( + "emojiModPerm must be 'unauthorized', 'add', 'mod', or 'full'", + ); + } + + const _emojiModPerm = + (ps.emojiModPerm as "unauthorized" | "add" | "mod" | "full") ?? + "unauthorized"; + + await Users.update(user.id, { + emojiModPerm: _emojiModPerm, + }); + + publishInternalEvent("userChangeModeratorState", { + id: user.id, + isModerator: true, + }); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/show-user.ts b/packages/backend/src/server/api/endpoints/admin/show-user.ts index 30f847f642..164d6ae419 100644 --- a/packages/backend/src/server/api/endpoints/admin/show-user.ts +++ b/packages/backend/src/server/api/endpoints/admin/show-user.ts @@ -65,6 +65,7 @@ export default define(meta, paramDef, async (ps, me) => { isModerator: user.isModerator, isSilenced: user.isSilenced, isSuspended: user.isSuspended, + emojiModPerm: user.emojiModPerm, lastActiveDate: user.lastActiveDate, moderationNote: profile.moderationNote, signins, diff --git a/packages/client/src/pages/user-info.vue b/packages/client/src/pages/user-info.vue index 98dee355e1..5309b1299a 100644 --- a/packages/client/src/pages/user-info.vue +++ b/packages/client/src/pages/user-info.vue @@ -178,6 +178,29 @@ </FormSection> </div> <div v-else-if="tab === 'moderation'" class="_formRoot"> + <FormSelect + v-model="emojiModPerm" + class="_formBlock" + @update:modelValue="setEmojiMod" + > + <template #label>{{ i18n.ts.emojiModPerm }}</template> + <option value="unauthorized"> + {{ i18n.ts._emojiModPerm.unauthorized }} + </option> + <option value="add"> + {{ i18n.ts._emojiModPerm.add }} + </option> + <option value="mod"> + {{ i18n.ts._emojiModPerm.mod }} + </option> + <option value="full"> + {{ i18n.ts._emojiModPerm.full }} + </option> + </FormSelect> + <MkInfo class="_formBlock">{{ + i18n.ts.emojiModPermDescription + }}</MkInfo> + <FormSwitch v-if=" user.host == null && @@ -368,6 +391,7 @@ import FormSection from "@/components/form/section.vue"; import FormButton from "@/components/MkButton.vue"; import FormInput from "@/components/form/input.vue"; import FormFolder from "@/components/form/folder.vue"; +import FormSelect from "@/components/form/select.vue"; import MkKeyValue from "@/components/MkKeyValue.vue"; import FormSuspense from "@/components/form/suspense.vue"; import MkFileListForAdmin from "@/components/MkFileListForAdmin.vue"; @@ -393,6 +417,7 @@ const info = ref(); const ips = ref(null); const ap = ref(null); const moderator = ref(false); +const emojiModPerm = ref<"unauthorized" | "add" | "mod" | "full">(); const silenced = ref(false); const suspended = ref(false); const driveCapacityOverrideMb = ref<number | null>(0); @@ -425,6 +450,7 @@ function createFetcher() { info.value = _info; ips.value = _ips; moderator.value = info.value.isModerator; + emojiModPerm.value = info.value.emojiModPerm; silenced.value = info.value.isSilenced; suspended.value = info.value.isSuspended; driveCapacityOverrideMb.value = @@ -510,6 +536,14 @@ async function toggleModerator(v) { await refreshUser(); } +async function setEmojiMod() { + await os.api("admin/set-emoji-moderator", { + userId: user.value.id, + emojiModPerm: emojiModPerm.value, + }); + await refreshUser(); +} + async function sendModMail() { const { canceled, result } = await os.inputParagraph({ title: "Moderation Notice", diff --git a/packages/client/src/reactiveAccount.ts b/packages/client/src/reactiveAccount.ts index 38da697f77..19863478a2 100644 --- a/packages/client/src/reactiveAccount.ts +++ b/packages/client/src/reactiveAccount.ts @@ -10,4 +10,5 @@ export const $i = accountData export const isSignedIn = $i != null; export const isModerator = $i != null && ($i.isModerator || $i.isAdmin); -export const isAdmin = $i != null && $i.isAdmin; +export const isEmojiMod = isModerator || $i?.emojiModPerm !== "unauthorized"; +export const isAdmin = $i?.isAdmin; diff --git a/packages/client/src/router.ts b/packages/client/src/router.ts index 97dabc9457..ee9f91fb54 100644 --- a/packages/client/src/router.ts +++ b/packages/client/src/router.ts @@ -1,6 +1,6 @@ import type { AsyncComponentLoader } from "vue"; import { defineAsyncComponent, inject } from "vue"; -import { $i, isModerator } from "@/reactiveAccount"; +import { $i, isModerator, isEmojiMod } from "@/reactiveAccount"; import { Router } from "@/nirax"; import MkError from "@/pages/_error_.vue"; import MkLoading from "@/pages/_loading_.vue"; @@ -422,6 +422,13 @@ export const routes = [ ? page(() => import("./pages/admin-file.vue")) : page(() => import("./pages/not-found.vue")), }, + { + path: "/admin/emojis", + name: "emojis", + component: isEmojiMod + ? page(() => import("./pages/admin/emojis.vue")) + : page(() => import("./pages/not-found.vue")), + }, { path: "/admin", component: isModerator @@ -443,11 +450,6 @@ export const routes = [ name: "hashtags", component: page(() => import("./pages/admin/hashtags.vue")), }, - { - path: "/emojis", - name: "emojis", - component: page(() => import("./pages/admin/emojis.vue")), - }, { path: "/federation", name: "federation", diff --git a/packages/client/src/ui/_common_/navbar-for-mobile.vue b/packages/client/src/ui/_common_/navbar-for-mobile.vue index a574df725a..8fb2177380 100644 --- a/packages/client/src/ui/_common_/navbar-for-mobile.vue +++ b/packages/client/src/ui/_common_/navbar-for-mobile.vue @@ -80,6 +80,17 @@ <i :class="icon('ph-door icon ph-fw')"></i ><span class="text">{{ i18n.ts.controlPanel }}</span> </MkA> + <MkA + v-else-if="$i.emojiModPerm !== 'unauthorized'" + v-click-anime + v-tooltip.noDelay.right="i18n.ts.customEmojis" + class="item _button" + active-class="active" + to="/admin/emojis" + > + <i class="icon ph-smiley ph-bold ph-fw ph-lg"></i + ><span class="text">{{ i18n.ts.customEmojis }}</span> + </MkA> <button v-click-anime class="item _button" @click="more"> <i :class="icon('ph-dots-three-outline icon ph-fw')"></i ><span class="text">{{ i18n.ts.more }}</span> diff --git a/packages/client/src/ui/_common_/navbar.vue b/packages/client/src/ui/_common_/navbar.vue index 9c2dc5d8d7..b629ddb85f 100644 --- a/packages/client/src/ui/_common_/navbar.vue +++ b/packages/client/src/ui/_common_/navbar.vue @@ -92,6 +92,17 @@ ><i :class="icon('ph-door icon ph-fw')"></i ><span class="text">{{ i18n.ts.controlPanel }}</span> </MkA> + <MkA + v-else-if="$i.emojiModPerm !== 'unauthorized'" + v-click-anime + v-tooltip.noDelay.right="i18n.ts.customEmojis" + class="item _button" + active-class="active" + to="/admin/emojis" + > + <i class="icon ph-smiley ph-bold ph-fw ph-lg"></i + ><span class="text">{{ i18n.ts.customEmojis }}</span> + </MkA> <button v-click-anime v-tooltip.noDelay.right="i18n.ts.more"