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 { // 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 { // 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 { // 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 { // 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; }[];