From a46b98b617247bff48fcb4714faa89db1d3ccd09 Mon Sep 17 00:00:00 2001
From: kakkokari-gtyih <67428053+kakkokari-gtyih@users.noreply.github.com>
Date: Thu, 7 Nov 2024 12:08:29 +0900
Subject: [PATCH] fix

---
 packages/backend/src/core/PageService.ts      |  31 ++++++
 packages/backend/src/models/Page.ts           | 102 ++++++++++++++++++
 .../backend/src/models/json-schema/page.ts    |  32 +++---
 .../src/server/api/endpoints/pages/create.ts  |  20 +++-
 .../src/server/api/endpoints/pages/update.ts  |  27 ++++-
 packages/misskey-js/src/autogen/types.ts      |  64 ++---------
 6 files changed, 196 insertions(+), 80 deletions(-)

diff --git a/packages/backend/src/core/PageService.ts b/packages/backend/src/core/PageService.ts
index 62ab351efd..f004da2993 100644
--- a/packages/backend/src/core/PageService.ts
+++ b/packages/backend/src/core/PageService.ts
@@ -5,7 +5,9 @@
 
 import { Inject, Injectable } from '@nestjs/common';
 import { DI } from '@/di-symbols.js';
+import _Ajv from 'ajv';
 import { type PagesRepository } from '@/models/_.js';
+import { pageBlockSchema } from '@/models/Page.js';
 
 /**
  * ページ関係のService
@@ -18,6 +20,35 @@ export class PageService {
 	) {
 	}
 
+	/**
+	 * ページのコンテンツを検証する.
+	 * @param content コンテンツ
+	 */
+	public validatePageContent(content: unknown[]) {
+		const Ajv = _Ajv.default;
+		const ajv = new Ajv({
+			useDefaults: true,
+		});
+		ajv.addFormat('misskey:id', /^[a-zA-Z0-9]+$/);
+		const validator = ajv.compile({
+			type: 'array',
+			items: pageBlockSchema,
+		});
+		const valid = validator(content);
+
+		if (valid) {
+			return {
+				valid: true,
+				errors: [],
+			};
+		} else {
+			return {
+				valid: false,
+				errors: validator.errors,
+			};
+		}
+	}
+
 	/**
 	 * 人気のあるページ一覧を取得する.
 	 */
diff --git a/packages/backend/src/models/Page.ts b/packages/backend/src/models/Page.ts
index 40a23acb95..003fcf4794 100644
--- a/packages/backend/src/models/Page.ts
+++ b/packages/backend/src/models/Page.ts
@@ -120,3 +120,105 @@ export class MiPage {
 }
 
 export const pageNameSchema = { type: 'string', pattern: /^[a-zA-Z0-9_-]{1,256}$/.source } as const;
+
+const blockBaseSchema = {
+	type: 'object',
+	properties: {
+		id: { type: 'string', nullable: false },
+		type: { type: 'string', nullable: false },
+	},
+	required: ['id', 'type'],
+} as const;
+
+const textBlockSchema = {
+	type: 'object',
+	properties: {
+		...blockBaseSchema.properties,
+		type: { type: 'string', nullable: false, enum: ['text'] },
+		text: { type: 'string', nullable: false },
+	},
+	required: [
+		...blockBaseSchema.required,
+		'text',
+	],
+} as const;
+
+const headingBlockSchema = {
+	type: 'object',
+	properties: {
+		...blockBaseSchema.properties,
+		type: { type: 'string', nullable: false, enum: ['heading'] },
+		level: { type: 'number', nullable: false },
+		text: { type: 'string', nullable: false },
+	},
+	required: [
+		...blockBaseSchema.required,
+		'level',
+		'text',
+	],
+} as const;
+
+const imageBlockSchema = {
+	type: 'object',
+	properties: {
+		...blockBaseSchema.properties,
+		type: { type: 'string', nullable: false, enum: ['image'] },
+		fileId: { type: 'string', nullable: true },
+	},
+	required: [
+		...blockBaseSchema.required,
+		'fileId',
+	],
+} as const;
+
+const noteBlockSchema = {
+	type: 'object',
+	properties: {
+		...blockBaseSchema.properties,
+		type: { type: 'string', nullable: false, enum: ['note']},
+		detailed: { type: 'boolean', nullable: false },
+		note: { type: 'string', format: 'misskey:id', nullable: true },
+	},
+	required: [
+		...blockBaseSchema.required,
+		'detailed',
+	],
+} as const;
+
+/** @deprecated 要素を入れ子にする必要が(一旦)なくなったので非推奨。headingBlockを使用すること */
+const sectionBlockSchema = {
+	type: 'object',
+	properties: {
+		...blockBaseSchema.properties,
+		type: { type: 'string', nullable: false, enum: ['section'] },
+		title: { type: 'string', nullable: false },
+		children: {
+			type: 'array', nullable: false,
+			items: {
+				oneOf: [
+					textBlockSchema,
+					{ $ref: '#' },
+					headingBlockSchema,
+					imageBlockSchema,
+					noteBlockSchema,
+				],
+			},
+		},
+	},
+	required: [
+		...blockBaseSchema.required,
+		'title',
+		'children',
+	],
+} as const;
+
+export const pageBlockSchema = {
+	type: 'object',
+	oneOf: [
+		textBlockSchema,
+		sectionBlockSchema,
+		headingBlockSchema,
+		imageBlockSchema,
+		noteBlockSchema,
+	],
+} as const;
diff --git a/packages/backend/src/models/json-schema/page.ts b/packages/backend/src/models/json-schema/page.ts
index 3ae15a157f..0fe1ca5901 100644
--- a/packages/backend/src/models/json-schema/page.ts
+++ b/packages/backend/src/models/json-schema/page.ts
@@ -3,7 +3,7 @@
  * SPDX-License-Identifier: AGPL-3.0-only
  */
 
-const blockBaseSchema = {
+const packedBlockBaseSchema = {
 	type: 'object',
 	properties: {
 		id: {
@@ -17,10 +17,10 @@ const blockBaseSchema = {
 	},
 } as const;
 
-const textBlockSchema = {
+const packedTextBlockSchema = {
 	type: 'object',
 	properties: {
-		...blockBaseSchema.properties,
+		...packedBlockBaseSchema.properties,
 		type: {
 			type: 'string',
 			optional: false, nullable: false,
@@ -33,10 +33,10 @@ const textBlockSchema = {
 	},
 } as const;
 
-const headingBlockSchema = {
+const packedHeadingBlockSchema = {
 	type: 'object',
 	properties: {
-		...blockBaseSchema.properties,
+		...packedBlockBaseSchema.properties,
 		type: {
 			type: 'string',
 			optional: false, nullable: false,
@@ -54,10 +54,10 @@ const headingBlockSchema = {
 } as const;
 
 /** @deprecated 要素を入れ子にする必要が(一旦)なくなったので非推奨。headingBlockを使用すること */
-const sectionBlockSchema = {
+const packedSectionBlockSchema = {
 	type: 'object',
 	properties: {
-		...blockBaseSchema.properties,
+		...packedBlockBaseSchema.properties,
 		type: {
 			type: 'string',
 			optional: false, nullable: false,
@@ -80,10 +80,10 @@ const sectionBlockSchema = {
 	},
 } as const;
 
-const imageBlockSchema = {
+const packedImageBlockSchema = {
 	type: 'object',
 	properties: {
-		...blockBaseSchema.properties,
+		...packedBlockBaseSchema.properties,
 		type: {
 			type: 'string',
 			optional: false, nullable: false,
@@ -96,10 +96,10 @@ const imageBlockSchema = {
 	},
 } as const;
 
-const noteBlockSchema = {
+const packedNoteBlockSchema = {
 	type: 'object',
 	properties: {
-		...blockBaseSchema.properties,
+		...packedBlockBaseSchema.properties,
 		type: {
 			type: 'string',
 			optional: false, nullable: false,
@@ -119,11 +119,11 @@ const noteBlockSchema = {
 export const packedPageBlockSchema = {
 	type: 'object',
 	oneOf: [
-		textBlockSchema,
-		sectionBlockSchema,
-		headingBlockSchema,
-		imageBlockSchema,
-		noteBlockSchema,
+		packedTextBlockSchema,
+		packedSectionBlockSchema,
+		packedHeadingBlockSchema,
+		packedImageBlockSchema,
+		packedNoteBlockSchema,
 	],
 } 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 afdb3cd4a6..69afd6226f 100644
--- a/packages/backend/src/server/api/endpoints/pages/create.ts
+++ b/packages/backend/src/server/api/endpoints/pages/create.ts
@@ -9,11 +9,11 @@ import type { DriveFilesRepository, PagesRepository } from '@/models/_.js';
 import { IdService } from '@/core/IdService.js';
 import { MiPage, pageNameSchema } from '@/models/Page.js';
 import { Endpoint } from '@/server/api/endpoint-base.js';
+import { PageService } from '@/core/PageService.js';
 import { PageEntityService } from '@/core/entities/PageEntityService.js';
 import { DI } from '@/di-symbols.js';
 import { ApiError } from '@/server/api/error.js';
 import { MAX_PAGE_CONTENT_BYTES } from '@/const.js';
-import { packedPageBlockSchema } from '@/models/json-schema/page.js';
 
 export const meta = {
 	tags: ['pages'],
@@ -51,6 +51,11 @@ export const meta = {
 			code: 'CONTENT_TOO_LARGE',
 			id: '2a93fcc9-4cd7-4885-9e5b-be56ed8f4d4f',
 		},
+		invalidParam: {
+			message: 'Invalid param.',
+			code: 'INVALID_PARAM',
+			id: '3d81ceae-475f-4600-b2a8-2bc116157532',
+		},
 	},
 } as const;
 
@@ -61,7 +66,8 @@ export const paramDef = {
 		name: { ...pageNameSchema, minLength: 1 },
 		summary: { type: 'string', nullable: true },
 		content: { type: 'array', items: {
-			...packedPageBlockSchema,
+			// misskey-jsの型生成に対応していないスキーマを使用しているため別途バリデーションする
+			type: 'object', additionalProperties: true,
 		} },
 		variables: { type: 'array', items: {
 			type: 'object', additionalProperties: true,
@@ -84,6 +90,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 		@Inject(DI.driveFilesRepository)
 		private driveFilesRepository: DriveFilesRepository,
 
+		private pageService: PageService,
 		private pageEntityService: PageEntityService,
 		private idService: IdService,
 	) {
@@ -92,6 +99,15 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 				throw new ApiError(meta.errors.contentTooLarge);
 			}
 
+			const validateResult = this.pageService.validatePageContent(ps.content);
+			if (!validateResult.valid) {
+				const errors = validateResult.errors!;
+				throw new ApiError(meta.errors.invalidParam, {
+					param: errors[0].schemaPath,
+					reason: errors[0].message,
+				});
+			}
+
 			let eyeCatchingImage = null;
 			if (ps.eyeCatchingImageId != null) {
 				eyeCatchingImage = await this.driveFilesRepository.findOneBy({
diff --git a/packages/backend/src/server/api/endpoints/pages/update.ts b/packages/backend/src/server/api/endpoints/pages/update.ts
index 393be2b32e..6747521d65 100644
--- a/packages/backend/src/server/api/endpoints/pages/update.ts
+++ b/packages/backend/src/server/api/endpoints/pages/update.ts
@@ -11,8 +11,8 @@ import { Endpoint } from '@/server/api/endpoint-base.js';
 import { DI } from '@/di-symbols.js';
 import { ApiError } from '@/server/api/error.js';
 import { MAX_PAGE_CONTENT_BYTES } from '@/const.js';
-import { packedPageBlockSchema } from '@/models/json-schema/page.js';
 import { pageNameSchema } from '@/models/Page.js';
+import { PageService } from '@/core/PageService.js';
 
 export const meta = {
 	tags: ['pages'],
@@ -56,6 +56,11 @@ export const meta = {
 			code: 'CONTENT_TOO_LARGE',
 			id: '2a93fcc9-4cd7-4885-9e5b-be56ed8f4d4f',
 		},
+		invalidParam: {
+			message: 'Invalid param.',
+			code: 'INVALID_PARAM',
+			id: '3d81ceae-475f-4600-b2a8-2bc116157532',
+		},
 	},
 } as const;
 
@@ -67,7 +72,8 @@ export const paramDef = {
 		name: { ...pageNameSchema, minLength: 1 },
 		summary: { type: 'string', nullable: true },
 		content: { type: 'array', items: {
-			...packedPageBlockSchema,
+			// misskey-jsの型生成に対応していないスキーマを使用しているため別途バリデーションする
+			type: 'object', additionalProperties: true,
 		} },
 		variables: { type: 'array', items: {
 			type: 'object', additionalProperties: true,
@@ -89,6 +95,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 
 		@Inject(DI.driveFilesRepository)
 		private driveFilesRepository: DriveFilesRepository,
+
+		private pageService: PageService,
 	) {
 		super(meta, paramDef, async (ps, me) => {
 			const page = await this.pagesRepository.findOneBy({ id: ps.pageId });
@@ -99,8 +107,19 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 				throw new ApiError(meta.errors.accessDenied);
 			}
 
-			if (new Blob([JSON.stringify(ps.content)]).size > MAX_PAGE_CONTENT_BYTES) {
-				throw new ApiError(meta.errors.contentTooLarge);
+			if (ps.content != null) {
+				if (new Blob([JSON.stringify(ps.content)]).size > MAX_PAGE_CONTENT_BYTES) {
+					throw new ApiError(meta.errors.contentTooLarge);
+				}
+
+				const validateResult = this.pageService.validatePageContent(ps.content);
+				if (!validateResult.valid) {
+					const errors = validateResult.errors!;
+					throw new ApiError(meta.errors.invalidParam, {
+						param: errors[0].schemaPath,
+						reason: errors[0].message,
+					});
+				}
 			}
 
 			if (ps.eyeCatchingImageId != null) {
diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts
index f23d7ee22b..158cf907ef 100644
--- a/packages/misskey-js/src/autogen/types.ts
+++ b/packages/misskey-js/src/autogen/types.ts
@@ -23432,35 +23432,9 @@ export type operations = {
           title: string;
           name: string;
           summary?: string | null;
-          content: (OneOf<[{
-              id?: string;
-              /** @enum {string} */
-              type?: 'text';
-              text?: string;
-            }, {
-              id?: string;
-              /** @enum {string} */
-              type?: 'section';
-              title?: string;
-              children?: components['schemas']['PageBlock'][];
-            }, {
-              id?: string;
-              /** @enum {string} */
-              type?: 'heading';
-              level?: number;
-              text?: string;
-            }, {
-              id?: string;
-              /** @enum {string} */
-              type?: 'image';
-              fileId?: string | null;
-            }, {
-              id?: string;
-              /** @enum {string} */
-              type?: 'note';
-              detailed?: boolean;
-              note?: string | null;
-            }]>)[];
+          content: {
+              [key: string]: unknown;
+            }[];
           variables?: {
               [key: string]: unknown;
             }[];
@@ -23807,35 +23781,9 @@ export type operations = {
           title?: string;
           name?: string;
           summary?: string | null;
-          content?: (OneOf<[{
-              id?: string;
-              /** @enum {string} */
-              type?: 'text';
-              text?: string;
-            }, {
-              id?: string;
-              /** @enum {string} */
-              type?: 'section';
-              title?: string;
-              children?: components['schemas']['PageBlock'][];
-            }, {
-              id?: string;
-              /** @enum {string} */
-              type?: 'heading';
-              level?: number;
-              text?: string;
-            }, {
-              id?: string;
-              /** @enum {string} */
-              type?: 'image';
-              fileId?: string | null;
-            }, {
-              id?: string;
-              /** @enum {string} */
-              type?: 'note';
-              detailed?: boolean;
-              note?: string | null;
-            }]>)[];
+          content?: {
+              [key: string]: unknown;
+            }[];
           variables?: {
               [key: string]: unknown;
             }[];