From fbc6d0de54031de840c39be3a2c7c63fe522c439 Mon Sep 17 00:00:00 2001
From: syuilo <4439005+syuilo@users.noreply.github.com>
Date: Wed, 5 Feb 2025 10:39:46 +0900
Subject: [PATCH] =?UTF-8?q?enhance:=20=E3=83=9A=E3=83=BC=E3=82=B8slug?=
 =?UTF-8?q?=E3=81=AB=E4=BD=BF=E7=94=A8=E5=8F=AF=E8=83=BD=E3=81=AA=E6=96=87?=
 =?UTF-8?q?=E5=AD=97=E3=82=92=E9=99=90=E5=AE=9A=20(#15395)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

* wip

* paramの正規表現で弾くように

* apiWithDialogを使用するように

* Update CHANGELOG.md

---------

Co-authored-by: kakkokari-gtyih <67428053+kakkokari-gtyih@users.noreply.github.com>
---
 CHANGELOG.md                                  |   2 +-
 locales/index.d.ts                            |  14 +--
 locales/ja-JP.yml                             |   5 +-
 packages/backend/src/models/Page.ts           |   2 +
 .../src/server/api/endpoints/pages/create.ts  |   4 +-
 .../src/server/api/endpoints/pages/update.ts  |   5 +-
 .../src/pages/page-editor/page-editor.vue     | 113 ++++++++----------
 7 files changed, 60 insertions(+), 85 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 32b9f91a38..7f48d1c532 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -14,9 +14,9 @@
 - Playが実装されたため、ページ機能の「ソースを見る」は削除されました
 
 ### Server
+- Enhance: ページのURLに使用可能な文字を限定するように
 - Fix: 個別お知らせページのmetaタグ出力の条件が間違っていたのを修正
 
-
 ## 2025.1.0
 
 ### Note
diff --git a/locales/index.d.ts b/locales/index.d.ts
index a0540fd228..4e26d5406b 100644
--- a/locales/index.d.ts
+++ b/locales/index.d.ts
@@ -4195,7 +4195,7 @@ export interface Locale extends ILocale {
      */
     "invalidParamError": string;
     /**
-     * リクエストパラメータに問題があります。通常これはバグですが、入力した文字数が多すぎる等の可能性もあります。
+     * リクエストパラメータに問題があります。通常これはバグですが、入力した文字数が多すぎる・許可されていない文字を入力している等の可能性もあります。
      */
     "invalidParamErrorDescription": string;
     /**
@@ -9180,18 +9180,6 @@ export interface Locale extends ILocale {
          * ソースを表示中
          */
         "readPage": string;
-        /**
-         * ページを作成しました
-         */
-        "created": string;
-        /**
-         * ページを更新しました
-         */
-        "updated": string;
-        /**
-         * ページを削除しました
-         */
-        "deleted": string;
         /**
          * ページ設定
          */
diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index a578704434..13d8aec9b8 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -1044,7 +1044,7 @@ youCannotCreateAnymore: "これ以上作成することはできません。"
 cannotPerformTemporary: "一時的に利用できません"
 cannotPerformTemporaryDescription: "操作回数が制限を超過するため一時的に利用できません。しばらく時間を置いてから再度お試しください。"
 invalidParamError: "パラメータエラー"
-invalidParamErrorDescription: "リクエストパラメータに問題があります。通常これはバグですが、入力した文字数が多すぎる等の可能性もあります。"
+invalidParamErrorDescription: "リクエストパラメータに問題があります。通常これはバグですが、入力した文字数が多すぎる・許可されていない文字を入力している等の可能性もあります。"
 permissionDeniedError: "操作が拒否されました"
 permissionDeniedErrorDescription: "このアカウントにはこの操作を行うための権限がありません。"
 preset: "プリセット"
@@ -2422,9 +2422,6 @@ _pages:
   newPage: "ページの作成"
   editPage: "ページの編集"
   readPage: "ソースを表示中"
-  created: "ページを作成しました"
-  updated: "ページを更新しました"
-  deleted: "ページを削除しました"
   pageSetting: "ページ設定"
   nameAlreadyExists: "指定されたページURLは既に存在しています"
   invalidNameTitle: "不正なページURLです"
diff --git a/packages/backend/src/models/Page.ts b/packages/backend/src/models/Page.ts
index 1695bf570e..0b59e7a92c 100644
--- a/packages/backend/src/models/Page.ts
+++ b/packages/backend/src/models/Page.ts
@@ -118,3 +118,5 @@ export class MiPage {
 		}
 	}
 }
+
+export const pageNameSchema = { type: 'string', pattern: /^[^\s:\/?#\[\]@!$&'()*+,;=\\%\x00-\x20]{1,256}$/.source } as const;
diff --git a/packages/backend/src/server/api/endpoints/pages/create.ts b/packages/backend/src/server/api/endpoints/pages/create.ts
index fa03b0b457..6de5fe3d44 100644
--- a/packages/backend/src/server/api/endpoints/pages/create.ts
+++ b/packages/backend/src/server/api/endpoints/pages/create.ts
@@ -7,7 +7,7 @@ import ms from 'ms';
 import { Inject, Injectable } from '@nestjs/common';
 import type { DriveFilesRepository, PagesRepository } from '@/models/_.js';
 import { IdService } from '@/core/IdService.js';
-import { MiPage } from '@/models/Page.js';
+import { MiPage, pageNameSchema } from '@/models/Page.js';
 import { Endpoint } from '@/server/api/endpoint-base.js';
 import { PageEntityService } from '@/core/entities/PageEntityService.js';
 import { DI } from '@/di-symbols.js';
@@ -51,7 +51,7 @@ export const paramDef = {
 	type: 'object',
 	properties: {
 		title: { type: 'string' },
-		name: { type: 'string', minLength: 1 },
+		name: { ...pageNameSchema, minLength: 1 },
 		summary: { type: 'string', nullable: true },
 		content: { type: 'array', items: {
 			type: 'object', additionalProperties: true,
diff --git a/packages/backend/src/server/api/endpoints/pages/update.ts b/packages/backend/src/server/api/endpoints/pages/update.ts
index e52d9c32df..a6aeb6002e 100644
--- a/packages/backend/src/server/api/endpoints/pages/update.ts
+++ b/packages/backend/src/server/api/endpoints/pages/update.ts
@@ -10,6 +10,7 @@ import type { PagesRepository, DriveFilesRepository } from '@/models/_.js';
 import { Endpoint } from '@/server/api/endpoint-base.js';
 import { DI } from '@/di-symbols.js';
 import { ApiError } from '../../error.js';
+import { pageNameSchema } from '@/models/Page.js';
 
 export const meta = {
 	tags: ['pages'],
@@ -31,13 +32,11 @@ export const meta = {
 			code: 'NO_SUCH_PAGE',
 			id: '21149b9e-3616-4778-9592-c4ce89f5a864',
 		},
-
 		accessDenied: {
 			message: 'Access denied.',
 			code: 'ACCESS_DENIED',
 			id: '3c15cd52-3b4b-4274-967d-6456fc4f792b',
 		},
-
 		noSuchFile: {
 			message: 'No such file.',
 			code: 'NO_SUCH_FILE',
@@ -56,7 +55,7 @@ export const paramDef = {
 	properties: {
 		pageId: { type: 'string', format: 'misskey:id' },
 		title: { type: 'string' },
-		name: { type: 'string', minLength: 1 },
+		name: { ...pageNameSchema, minLength: 1 },
 		summary: { type: 'string', nullable: true },
 		content: { type: 'array', items: {
 			type: 'object', additionalProperties: true,
diff --git a/packages/frontend/src/pages/page-editor/page-editor.vue b/packages/frontend/src/pages/page-editor/page-editor.vue
index ddb808390c..c08cfebab3 100644
--- a/packages/frontend/src/pages/page-editor/page-editor.vue
+++ b/packages/frontend/src/pages/page-editor/page-editor.vue
@@ -96,7 +96,7 @@ const summary = ref<string | null>(null);
 const name = ref(Date.now().toString());
 const eyeCatchingImage = ref<Misskey.entities.DriveFile | null>(null);
 const eyeCatchingImageId = ref<string | null>(null);
-const font = ref('sans-serif');
+const font = ref<'sans-serif' | 'serif'>('sans-serif');
 const content = ref<Misskey.entities.Page['content']>([]);
 const alignCenter = ref(false);
 const hideTitleWhenPinned = ref(false);
@@ -113,7 +113,7 @@ watch(eyeCatchingImageId, async () => {
 	}
 });
 
-function getSaveOptions() {
+function getSaveOptions(): Misskey.entities.PagesCreateRequest {
 	return {
 		title: title.value.trim(),
 		name: name.value.trim(),
@@ -128,80 +128,69 @@ function getSaveOptions() {
 	};
 }
 
-function save() {
+async function save() {
 	const options = getSaveOptions();
 
-	const onError = err => {
-		if (err.id === '3d81ceae-475f-4600-b2a8-2bc116157532') {
-			if (err.info.param === 'name') {
-				os.alert({
-					type: 'error',
-					title: i18n.ts._pages.invalidNameTitle,
-					text: i18n.ts._pages.invalidNameText,
-				});
-			}
-		} else if (err.code === 'NAME_ALREADY_EXISTS') {
-			os.alert({
-				type: 'error',
-				text: i18n.ts._pages.nameAlreadyExists,
-			});
-		}
-	};
-
 	if (pageId.value) {
-		options.pageId = pageId.value;
-		misskeyApi('pages/update', options)
-			.then(page => {
-				currentName.value = name.value.trim();
-				os.alert({
-					type: 'success',
-					text: i18n.ts._pages.updated,
-				});
-			}).catch(onError);
+		const updateOptions: Misskey.entities.PagesUpdateRequest = {
+			pageId: pageId.value,
+			...options,
+		};
+
+		await os.apiWithDialog('pages/update', updateOptions, undefined, {
+			'2298a392-d4a1-44c5-9ebb-ac1aeaa5a9ab': {
+				title: i18n.ts.somethingHappened,
+				text: i18n.ts._pages.nameAlreadyExists,
+			},
+		});
+
+		currentName.value = name.value.trim();
 	} else {
-		misskeyApi('pages/create', options)
-			.then(created => {
-				pageId.value = created.id;
-				currentName.value = name.value.trim();
-				os.alert({
-					type: 'success',
-					text: i18n.ts._pages.created,
-				});
-				mainRouter.push(`/pages/edit/${pageId.value}`);
-			}).catch(onError);
+		const created = await os.apiWithDialog('pages/create', options, undefined, {
+			'4650348e-301c-499a-83c9-6aa988c66bc1': {
+				title: i18n.ts.somethingHappened,
+				text: i18n.ts._pages.nameAlreadyExists,
+			},
+		});
+
+		pageId.value = created.id;
+		currentName.value = name.value.trim();
+		mainRouter.replace(`/pages/edit/${pageId.value}`);
 	}
 }
 
-function del() {
-	os.confirm({
+async function del() {
+	if (!pageId.value) return;
+
+	const { canceled } = await os.confirm({
 		type: 'warning',
 		text: i18n.tsx.removeAreYouSure({ x: title.value.trim() }),
-	}).then(({ canceled }) => {
-		if (canceled) return;
-		misskeyApi('pages/delete', {
-			pageId: pageId.value,
-		}).then(() => {
-			os.alert({
-				type: 'success',
-				text: i18n.ts._pages.deleted,
-			});
-			mainRouter.push('/pages');
-		});
 	});
+
+	if (canceled) return;
+
+	await os.apiWithDialog('pages/delete', {
+		pageId: pageId.value,
+	});
+
+	mainRouter.replace('/pages');
 }
 
-function duplicate() {
+async function duplicate() {
 	title.value = title.value + ' - copy';
 	name.value = name.value + '-copy';
-	misskeyApi('pages/create', getSaveOptions()).then(created => {
-		pageId.value = created.id;
-		currentName.value = name.value.trim();
-		os.alert({
-			type: 'success',
-			text: i18n.ts._pages.created,
-		});
-		mainRouter.push(`/pages/edit/${pageId.value}`);
+
+	const created = await os.apiWithDialog('pages/create', getSaveOptions(), undefined, {
+		'4650348e-301c-499a-83c9-6aa988c66bc1': {
+			title: i18n.ts.somethingHappened,
+			text: i18n.ts._pages.nameAlreadyExists,
+		},
 	});
+
+	pageId.value = created.id;
+	currentName.value = name.value.trim();
+
+	mainRouter.push(`/pages/edit/${pageId.value}`);
 }
 
 async function add() {
@@ -216,7 +205,7 @@ async function add() {
 	content.value.push({ id, type });
 }
 
-function setEyeCatchingImage(img) {
+function setEyeCatchingImage(img: Event) {
 	selectFile(img.currentTarget ?? img.target, null).then(file => {
 		eyeCatchingImageId.value = file.id;
 	});