diff --git a/CHANGELOG.md b/CHANGELOG.md index f87fc3a2bb..7d8595174e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ### General - Feat: コンテンツの表示にログインを必須にできるように - Feat: 過去のノートを非公開化/フォロワーのみ表示可能にできるように +- Fix: センシティブな画像をアイコン・バナーに指定できないように ### Client - Enhance: Bull DashboardでRelationship Queueの状態も確認できるように diff --git a/locales/index.d.ts b/locales/index.d.ts index 440f24ac84..4ceb3b8cf7 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -5218,6 +5218,15 @@ export interface Locale extends ILocale { * 利用可能なロール */ "availableRoles": string; + /** + * センシティブなメディアは選択できません + */ + "cannotSelectSensitiveMedia": string; + /** + * 自分でセンシティブ設定を行っていないのにこのエラーが出ている場合、自動判定によりセンシティブなメディアとされている可能性があります。 + * サーバーの規則に照らして不要な場合は、ファイルのセンシティブ設定を解除してもう一度お試しください。 + */ + "cannotSelectSensitiveMediaDescription": string; "_accountSettings": { /** * コンテンツの表示にログインを必須にする diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 5d8e1a5e72..602f135a4d 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1300,6 +1300,8 @@ thisContentsAreMarkedAsSigninRequiredByAuthor: "投稿者により、表示に lockdown: "ロックダウン" pleaseSelectAccount: "アカウントを選択してください" availableRoles: "利用可能なロール" +cannotSelectSensitiveMedia: "センシティブなメディアは選択できません" +cannotSelectSensitiveMediaDescription: "自分でセンシティブ設定を行っていないのにこのエラーが出ている場合、自動判定によりセンシティブなメディアとされている可能性があります。\nサーバーの規則に照らして不要な場合は、ファイルのセンシティブ設定を解除してもう一度お試しください。" _accountSettings: requireSigninToViewContents: "コンテンツの表示にログインを必須にする" diff --git a/packages/backend/src/server/api/endpoints/i/update.ts b/packages/backend/src/server/api/endpoints/i/update.ts index 2183beac7c..c975b63e91 100644 --- a/packages/backend/src/server/api/endpoints/i/update.ts +++ b/packages/backend/src/server/api/endpoints/i/update.ts @@ -74,6 +74,18 @@ export const meta = { id: '75aedb19-2afd-4e6d-87fc-67941256fa60', }, + avatarIsSensitive: { + message: 'The file specified as an avatar is marked as sensitive.', + code: 'AVATAR_IS_SENSITIVE', + id: '71bb5e53-4742-4609-b465-36081e131208', + }, + + bannerIsSensitive: { + message: 'The file specified as a banner is marked as sensitive.', + code: 'BANNER_IS_SENSITIVE', + id: 'e148b34c-9f33-4300-93e0-7817008fb366', + }, + noSuchPage: { message: 'No such page.', code: 'NO_SUCH_PAGE', @@ -359,6 +371,7 @@ export default class extends Endpoint { // eslint- if (avatar == null || avatar.userId !== user.id) throw new ApiError(meta.errors.noSuchAvatar); if (!avatar.type.startsWith('image/')) throw new ApiError(meta.errors.avatarNotAnImage); + if (avatar.isSensitive) throw new ApiError(meta.errors.avatarIsSensitive); updates.avatarId = avatar.id; updates.avatarUrl = this.driveFileEntityService.getPublicUrl(avatar, 'avatar'); @@ -377,6 +390,7 @@ export default class extends Endpoint { // eslint- if (banner == null || banner.userId !== user.id) throw new ApiError(meta.errors.noSuchBanner); if (!banner.type.startsWith('image/')) throw new ApiError(meta.errors.bannerNotAnImage); + if (banner.isSensitive) throw new ApiError(meta.errors.bannerIsSensitive); updates.bannerId = banner.id; updates.bannerUrl = this.driveFileEntityService.getPublicUrl(banner); diff --git a/packages/frontend/src/components/MkDrive.file.vue b/packages/frontend/src/components/MkDrive.file.vue index e45c3bd9ce..9c6ebf8904 100644 --- a/packages/frontend/src/components/MkDrive.file.vue +++ b/packages/frontend/src/components/MkDrive.file.vue @@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only - + @@ -34,6 +34,7 @@ import { i18n } from '@/i18n.js'; withDefaults(defineProps<{ type?: 'file' | 'folder'; multiple: boolean; + excludeSensitive: boolean; }>(), { type: 'file', }); diff --git a/packages/frontend/src/os.ts b/packages/frontend/src/os.ts index ea1b673de9..5e05e73884 100644 --- a/packages/frontend/src/os.ts +++ b/packages/frontend/src/os.ts @@ -569,11 +569,12 @@ export async function selectUser(opts: { includeSelf?: boolean; localOnly?: bool }); } -export async function selectDriveFile(multiple: boolean): Promise { +export async function selectDriveFile(multiple: boolean, excludeSensitive: boolean): Promise { return new Promise(resolve => { const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkDriveSelectDialog.vue')), { type: 'file', multiple, + excludeSensitive, }, { done: files => { if (files) { @@ -585,11 +586,12 @@ export async function selectDriveFile(multiple: boolean): Promise { +export async function selectDriveFolder(multiple: boolean, excludeSensitive: boolean): Promise { return new Promise(resolve => { const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkDriveSelectDialog.vue')), { type: 'folder', multiple, + excludeSensitive, }, { done: folders => { if (folders) { diff --git a/packages/frontend/src/pages/settings/profile.vue b/packages/frontend/src/pages/settings/profile.vue index 561894d2b7..c2e6c7959c 100644 --- a/packages/frontend/src/pages/settings/profile.vue +++ b/packages/frontend/src/pages/settings/profile.vue @@ -135,6 +135,7 @@ import { defaultStore } from '@/store.js'; import { globalEvents } from '@/events.js'; import MkInfo from '@/components/MkInfo.vue'; import MkTextarea from '@/components/MkTextarea.vue'; +import { misskeyApi } from '@/scripts/misskey-api.js'; const $i = signinRequired(); @@ -223,7 +224,26 @@ function save() { } function changeAvatar(ev) { - selectFile(ev.currentTarget ?? ev.target, i18n.ts.avatar).then(async (file) => { + selectFile(ev.currentTarget ?? ev.target, i18n.ts.avatar, { + excludeSensitive: true, + additionalMenu: $i.avatarId ? [ + { type: 'divider' }, + { + type: 'button', + text: i18n.ts.detach, + icon: 'ti ti-circle-x', + action: () => { + os.apiWithDialog('i/update', { + avatarId: null, + }).then(() => { + $i.avatarId = null; + $i.avatarUrl = null; + globalEvents.emit('requestClearPageCache'); + }); + }, + }, + ] : undefined, + }).then(async (file) => { let originalOrCropped = file; const { canceled } = await os.confirm({ @@ -239,18 +259,43 @@ function changeAvatar(ev) { }); } - const i = await os.apiWithDialog('i/update', { + await os.apiWithDialog('i/update', { avatarId: originalOrCropped.id, + }, undefined, { + '71bb5e53-4742-4609-b465-36081e131208': { + title: i18n.ts.cannotSelectSensitiveMedia, + text: i18n.ts.cannotSelectSensitiveMediaDescription, + }, + }).then(() => { + $i.avatarId = originalOrCropped.id; + $i.avatarUrl = originalOrCropped.url; + globalEvents.emit('requestClearPageCache'); + claimAchievement('profileFilled'); }); - $i.avatarId = i.avatarId; - $i.avatarUrl = i.avatarUrl; - globalEvents.emit('requestClearPageCache'); - claimAchievement('profileFilled'); }); } function changeBanner(ev) { - selectFile(ev.currentTarget ?? ev.target, i18n.ts.banner).then(async (file) => { + selectFile(ev.currentTarget ?? ev.target, i18n.ts.banner, { + excludeSensitive: true, + additionalMenu: $i.bannerId ? [ + { type: 'divider' }, + { + type: 'button', + text: i18n.ts.detach, + icon: 'ti ti-circle-x', + action: () => { + os.apiWithDialog('i/update', { + bannerId: null, + }).then(() => { + $i.bannerId = null; + $i.bannerUrl = null; + globalEvents.emit('requestClearPageCache'); + }); + }, + }, + ] : undefined, + }).then(async (file) => { let originalOrCropped = file; const { canceled } = await os.confirm({ @@ -266,12 +311,18 @@ function changeBanner(ev) { }); } - const i = await os.apiWithDialog('i/update', { + await os.apiWithDialog('i/update', { bannerId: originalOrCropped.id, + }, undefined, { + 'e148b34c-9f33-4300-93e0-7817008fb366': { + title: i18n.ts.cannotSelectSensitiveMedia, + text: i18n.ts.cannotSelectSensitiveMediaDescription, + }, + }).then(() => { + $i.bannerId = originalOrCropped.id; + $i.bannerUrl = originalOrCropped.url; + globalEvents.emit('requestClearPageCache'); }); - $i.bannerId = i.bannerId; - $i.bannerUrl = i.bannerUrl; - globalEvents.emit('requestClearPageCache'); }); } diff --git a/packages/frontend/src/scripts/select-file.ts b/packages/frontend/src/scripts/select-file.ts index b037aa8acc..b1ae4fedfb 100644 --- a/packages/frontend/src/scripts/select-file.ts +++ b/packages/frontend/src/scripts/select-file.ts @@ -5,12 +5,20 @@ import { ref } from 'vue'; import * as Misskey from 'misskey-js'; +import type { MenuItem } from '@/types/menu.js'; import * as os from '@/os.js'; import { misskeyApi } from '@/scripts/misskey-api.js'; import { useStream } from '@/stream.js'; import { i18n } from '@/i18n.js'; import { defaultStore } from '@/store.js'; import { uploadFile } from '@/scripts/upload.js'; +import { deepMerge } from '@/scripts/merge.js'; + +type SelectFileOptions = { + multiple?: boolean; + excludeSensitive?: boolean; + additionalMenu?: MenuItem[]; +}; export function chooseFileFromPc(multiple: boolean, keepOriginal = false): Promise { return new Promise((res, rej) => { @@ -39,9 +47,9 @@ export function chooseFileFromPc(multiple: boolean, keepOriginal = false): Promi }); } -export function chooseFileFromDrive(multiple: boolean): Promise { +export function chooseFileFromDrive(multiple: boolean, excludeSensitive: boolean): Promise { return new Promise((res, rej) => { - os.selectDriveFile(multiple).then(files => { + os.selectDriveFile(multiple, excludeSensitive).then(files => { res(files); }); }); @@ -80,7 +88,16 @@ export function chooseFileFromUrl(): Promise { }); } -function select(src: HTMLElement | EventTarget | null, label: string | null, multiple: boolean): Promise { +function select(src: HTMLElement | EventTarget | null, label: string | null, options?: SelectFileOptions): Promise { + const _options = deepMerge(options ?? {}, { + multiple: false, + + /** ドライブファイル選択時のみに適用 */ + excludeSensitive: false, + + additionalMenu: [] as MenuItem[], + }); + return new Promise((res, rej) => { const keepOriginal = ref(defaultStore.state.keepOriginalUploading); @@ -94,23 +111,23 @@ function select(src: HTMLElement | EventTarget | null, label: string | null, mul }, { text: i18n.ts.upload, icon: 'ti ti-upload', - action: () => chooseFileFromPc(multiple, keepOriginal.value).then(files => res(files)), + action: () => chooseFileFromPc(_options.multiple, keepOriginal.value).then(files => res(files)), }, { text: i18n.ts.fromDrive, icon: 'ti ti-cloud', - action: () => chooseFileFromDrive(multiple).then(files => res(files)), + action: () => chooseFileFromDrive(_options.multiple, _options.excludeSensitive).then(files => res(files)), }, { text: i18n.ts.fromUrl, icon: 'ti ti-link', action: () => chooseFileFromUrl().then(file => res([file])), - }], src); + }, ...(_options.additionalMenu)], src); }); } -export function selectFile(src: HTMLElement | EventTarget | null, label: string | null = null): Promise { - return select(src, label, false).then(files => files[0]); +export function selectFile(src: HTMLElement | EventTarget | null, label: string | null = null, options?: { excludeSensitive?: boolean; additionalMenu?: MenuItem[]; }): Promise { + return select(src, label, { ...(options ? options : {}), multiple: false }).then(files => files[0]); } -export function selectFiles(src: HTMLElement | EventTarget | null, label: string | null = null): Promise { - return select(src, label, true); +export function selectFiles(src: HTMLElement | EventTarget | null, label: string | null = null, options?: { excludeSensitive?: boolean; additionalMenu?: MenuItem[]; }): Promise { + return select(src, label, { ...(options ? options : {}), multiple: true }); }