From 5b6146e348d3f589bbe0b948b31fc9f1ea54120b Mon Sep 17 00:00:00 2001 From: kakkokari-gtyih <67428053+kakkokari-gtyih@users.noreply.github.com> Date: Thu, 7 Nov 2024 11:14:47 +0900 Subject: [PATCH 01/11] =?UTF-8?q?page=E3=82=A8=E3=83=B3=E3=83=89=E3=83=9D?= =?UTF-8?q?=E3=82=A4=E3=83=B3=E3=83=88=E5=91=A8=E3=82=8A=E3=81=AE=E6=94=B9?= =?UTF-8?q?=E5=96=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - featuredでページネーションが可能に - ページブロックのバリデーションを追加 - ページslugのバリデーションを追加 - ページブロックの容量制限を追加 - 未使用プロパティscriptとvariablesは変更が効かないように --- packages/backend/src/const.ts | 1 + packages/backend/src/core/PageService.ts | 40 +++++++++ packages/backend/src/models/Page.ts | 2 + .../backend/src/models/json-schema/page.ts | 22 +++++ .../src/server/api/endpoints/pages/create.ts | 25 ++++-- .../server/api/endpoints/pages/featured.ts | 27 +++--- .../src/server/api/endpoints/pages/update.ts | 22 +++-- packages/misskey-js/etc/misskey-js.api.md | 4 + packages/misskey-js/src/autogen/endpoint.ts | 3 +- packages/misskey-js/src/autogen/entities.ts | 1 + packages/misskey-js/src/autogen/types.ts | 84 +++++++++++++++++-- 11 files changed, 195 insertions(+), 36 deletions(-) create mode 100644 packages/backend/src/core/PageService.ts diff --git a/packages/backend/src/const.ts b/packages/backend/src/const.ts index e3a61861f4..c9fb7f9022 100644 --- a/packages/backend/src/const.ts +++ b/packages/backend/src/const.ts @@ -4,6 +4,7 @@ */ export const MAX_NOTE_TEXT_LENGTH = 3000; +export const MAX_PAGE_CONTENT_BYTES = 1024 * 1024 * 1.5; // 1.5MB export const USER_ONLINE_THRESHOLD = 1000 * 60 * 10; // 10min export const USER_ACTIVE_THRESHOLD = 1000 * 60 * 60 * 24 * 3; // 3days diff --git a/packages/backend/src/core/PageService.ts b/packages/backend/src/core/PageService.ts new file mode 100644 index 0000000000..62ab351efd --- /dev/null +++ b/packages/backend/src/core/PageService.ts @@ -0,0 +1,40 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import { type PagesRepository } from '@/models/_.js'; + +/** + * ページ関係のService + */ +@Injectable() +export class PageService { + constructor( + @Inject(DI.pagesRepository) + private pagesRepository: PagesRepository, + ) { + } + + /** + * 人気のあるページ一覧を取得する. + */ + public async featured(opts?: { offset?: number, limit: number }) { + const builder = this.pagesRepository.createQueryBuilder('page') + .andWhere('page.likedCount > 0') + .andWhere('page.visibility = :visibility', { visibility: 'public' }) + .addOrderBy('page.likedCount', 'DESC') + .addOrderBy('page.updatedAt', 'DESC') + .addOrderBy('page.id', 'DESC'); + + if (opts?.offset) { + builder.skip(opts.offset); + } + + builder.take(opts?.limit ?? 10); + + return await builder.getMany(); + } +} diff --git a/packages/backend/src/models/Page.ts b/packages/backend/src/models/Page.ts index 1695bf570e..40a23acb95 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: /^[a-zA-Z0-9_-]{1,256}$/.source } as const; diff --git a/packages/backend/src/models/json-schema/page.ts b/packages/backend/src/models/json-schema/page.ts index 748d6f1245..3ae15a157f 100644 --- a/packages/backend/src/models/json-schema/page.ts +++ b/packages/backend/src/models/json-schema/page.ts @@ -33,6 +33,27 @@ const textBlockSchema = { }, } as const; +const headingBlockSchema = { + type: 'object', + properties: { + ...blockBaseSchema.properties, + type: { + type: 'string', + optional: false, nullable: false, + enum: ['heading'], + }, + level: { + type: 'number', + optional: false, nullable: false, + }, + text: { + type: 'string', + optional: false, nullable: false, + }, + }, +} as const; + +/** @deprecated 要素を入れ子にする必要が(一旦)なくなったので非推奨。headingBlockを使用すること */ const sectionBlockSchema = { type: 'object', properties: { @@ -100,6 +121,7 @@ export const packedPageBlockSchema = { oneOf: [ textBlockSchema, sectionBlockSchema, + headingBlockSchema, imageBlockSchema, noteBlockSchema, ], diff --git a/packages/backend/src/server/api/endpoints/pages/create.ts b/packages/backend/src/server/api/endpoints/pages/create.ts index fa03b0b457..afdb3cd4a6 100644 --- a/packages/backend/src/server/api/endpoints/pages/create.ts +++ b/packages/backend/src/server/api/endpoints/pages/create.ts @@ -7,11 +7,13 @@ 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'; -import { ApiError } from '../../error.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'], @@ -44,6 +46,11 @@ export const meta = { code: 'NAME_ALREADY_EXISTS', id: '4650348e-301c-499a-83c9-6aa988c66bc1', }, + contentTooLarge: { + message: 'Content is too large.', + code: 'CONTENT_TOO_LARGE', + id: '2a93fcc9-4cd7-4885-9e5b-be56ed8f4d4f', + }, }, } as const; @@ -51,10 +58,10 @@ 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, + ...packedPageBlockSchema, } }, variables: { type: 'array', items: { type: 'object', additionalProperties: true, @@ -65,7 +72,7 @@ export const paramDef = { alignCenter: { type: 'boolean', default: false }, hideTitleWhenPinned: { type: 'boolean', default: false }, }, - required: ['title', 'name', 'content', 'variables', 'script'], + required: ['title', 'name', 'content'], } as const; @Injectable() @@ -81,6 +88,10 @@ export default class extends Endpoint { // eslint- private idService: IdService, ) { super(meta, paramDef, async (ps, me) => { + if (new Blob([JSON.stringify(ps.content)]).size > MAX_PAGE_CONTENT_BYTES) { + throw new ApiError(meta.errors.contentTooLarge); + } + let eyeCatchingImage = null; if (ps.eyeCatchingImageId != null) { eyeCatchingImage = await this.driveFilesRepository.findOneBy({ @@ -109,8 +120,8 @@ export default class extends Endpoint { // eslint- name: ps.name, summary: ps.summary, content: ps.content, - variables: ps.variables, - script: ps.script, + //variables: ps.variables, もう使用されていない(動的ページ) + //script: ps.script, もう使用されていない(動的ページ) eyeCatchingImageId: eyeCatchingImage ? eyeCatchingImage.id : null, userId: me.id, visibility: 'public', diff --git a/packages/backend/src/server/api/endpoints/pages/featured.ts b/packages/backend/src/server/api/endpoints/pages/featured.ts index a47b69e56e..a9a6ea2750 100644 --- a/packages/backend/src/server/api/endpoints/pages/featured.ts +++ b/packages/backend/src/server/api/endpoints/pages/featured.ts @@ -3,11 +3,10 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Inject, Injectable } from '@nestjs/common'; -import type { PagesRepository } from '@/models/_.js'; +import { Injectable } from '@nestjs/common'; 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'; export const meta = { tags: ['pages'], @@ -27,27 +26,25 @@ export const meta = { export const paramDef = { type: 'object', - properties: {}, + properties: { + offset: { type: 'integer', minimum: 0, default: 0 }, + limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, + }, required: [], } as const; @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( - @Inject(DI.pagesRepository) - private pagesRepository: PagesRepository, - + private pageService: PageService, private pageEntityService: PageEntityService, ) { super(meta, paramDef, async (ps, me) => { - const query = this.pagesRepository.createQueryBuilder('page') - .where('page.visibility = \'public\'') - .andWhere('page.likedCount > 0') - .orderBy('page.likedCount', 'DESC'); - - const pages = await query.limit(10).getMany(); - - return await this.pageEntityService.packMany(pages, me); + const result = await this.pageService.featured({ + offset: ps.offset, + limit: ps.limit, + }); + return await this.pageEntityService.packMany(result, me); }); } } diff --git a/packages/backend/src/server/api/endpoints/pages/update.ts b/packages/backend/src/server/api/endpoints/pages/update.ts index f11bbbcb1a..393be2b32e 100644 --- a/packages/backend/src/server/api/endpoints/pages/update.ts +++ b/packages/backend/src/server/api/endpoints/pages/update.ts @@ -9,7 +9,10 @@ import { Inject, Injectable } from '@nestjs/common'; 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 { 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'; export const meta = { tags: ['pages'], @@ -48,6 +51,11 @@ export const meta = { code: 'NAME_ALREADY_EXISTS', id: '2298a392-d4a1-44c5-9ebb-ac1aeaa5a9ab', }, + contentTooLarge: { + message: 'Content is too large.', + code: 'CONTENT_TOO_LARGE', + id: '2a93fcc9-4cd7-4885-9e5b-be56ed8f4d4f', + }, }, } as const; @@ -56,10 +64,10 @@ 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, + ...packedPageBlockSchema, } }, variables: { type: 'array', items: { type: 'object', additionalProperties: true, @@ -91,6 +99,10 @@ 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.eyeCatchingImageId != null) { const eyeCatchingImage = await this.driveFilesRepository.findOneBy({ id: ps.eyeCatchingImageId, @@ -118,8 +130,8 @@ export default class extends Endpoint { // eslint- name: ps.name, summary: ps.summary === undefined ? page.summary : ps.summary, content: ps.content, - variables: ps.variables, - script: ps.script, + //variables: ps.variables, もう使用されていない(動的ページ) + //script: ps.script, もう使用されていない(動的ページ) alignCenter: ps.alignCenter, hideTitleWhenPinned: ps.hideTitleWhenPinned, font: ps.font, diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md index 061b533b72..ccf017d77a 100644 --- a/packages/misskey-js/etc/misskey-js.api.md +++ b/packages/misskey-js/etc/misskey-js.api.md @@ -1684,6 +1684,7 @@ declare namespace entities { PagesCreateRequest, PagesCreateResponse, PagesDeleteRequest, + PagesFeaturedRequest, PagesFeaturedResponse, PagesLikeRequest, PagesShowRequest, @@ -2841,6 +2842,9 @@ type PagesCreateResponse = operations['pages___create']['responses']['200']['con // @public (undocumented) type PagesDeleteRequest = operations['pages___delete']['requestBody']['content']['application/json']; +// @public (undocumented) +type PagesFeaturedRequest = operations['pages___featured']['requestBody']['content']['application/json']; + // @public (undocumented) type PagesFeaturedResponse = operations['pages___featured']['responses']['200']['content']['application/json']; diff --git a/packages/misskey-js/src/autogen/endpoint.ts b/packages/misskey-js/src/autogen/endpoint.ts index 5e6bc0a99c..699fbefa29 100644 --- a/packages/misskey-js/src/autogen/endpoint.ts +++ b/packages/misskey-js/src/autogen/endpoint.ts @@ -459,6 +459,7 @@ import type { PagesCreateRequest, PagesCreateResponse, PagesDeleteRequest, + PagesFeaturedRequest, PagesFeaturedResponse, PagesLikeRequest, PagesShowRequest, @@ -888,7 +889,7 @@ export type Endpoints = { 'page-push': { req: PagePushRequest; res: EmptyResponse }; 'pages/create': { req: PagesCreateRequest; res: PagesCreateResponse }; 'pages/delete': { req: PagesDeleteRequest; res: EmptyResponse }; - 'pages/featured': { req: EmptyRequest; res: PagesFeaturedResponse }; + 'pages/featured': { req: PagesFeaturedRequest; res: PagesFeaturedResponse }; 'pages/like': { req: PagesLikeRequest; res: EmptyResponse }; 'pages/show': { req: PagesShowRequest; res: PagesShowResponse }; 'pages/unlike': { req: PagesUnlikeRequest; res: EmptyResponse }; diff --git a/packages/misskey-js/src/autogen/entities.ts b/packages/misskey-js/src/autogen/entities.ts index f3ddf64481..d72481e38e 100644 --- a/packages/misskey-js/src/autogen/entities.ts +++ b/packages/misskey-js/src/autogen/entities.ts @@ -462,6 +462,7 @@ export type PagePushRequest = operations['page-push']['requestBody']['content'][ export type PagesCreateRequest = operations['pages___create']['requestBody']['content']['application/json']; export type PagesCreateResponse = operations['pages___create']['responses']['200']['content']['application/json']; export type PagesDeleteRequest = operations['pages___delete']['requestBody']['content']['application/json']; +export type PagesFeaturedRequest = operations['pages___featured']['requestBody']['content']['application/json']; export type PagesFeaturedResponse = operations['pages___featured']['responses']['200']['content']['application/json']; export type PagesLikeRequest = operations['pages___like']['requestBody']['content']['application/json']; export type PagesShowRequest = operations['pages___show']['requestBody']['content']['application/json']; diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts index a5333d4f93..f23d7ee22b 100644 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -4545,6 +4545,12 @@ export type components = { type: 'section'; title: string; children: components['schemas']['PageBlock'][]; + }, { + id: string; + /** @enum {string} */ + type: 'heading'; + level: number; + text: string; }, { id: string; /** @enum {string} */ @@ -23426,13 +23432,39 @@ export type operations = { title: string; name: string; summary?: string | null; - content: { + 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; + }]>)[]; + variables?: { [key: string]: unknown; }[]; - variables: { - [key: string]: unknown; - }[]; - script: string; + script?: string; /** Format: misskey:id */ eyeCatchingImageId?: string | null; /** @@ -23551,6 +23583,16 @@ export type operations = { * **Credential required**: *No* */ pages___featured: { + requestBody: { + content: { + 'application/json': { + /** @default 0 */ + offset?: number; + /** @default 10 */ + limit?: number; + }; + }; + }; responses: { /** @description OK (with results) */ 200: { @@ -23765,9 +23807,35 @@ export type operations = { title?: string; name?: string; summary?: string | null; - content?: { - [key: string]: unknown; - }[]; + 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; + }]>)[]; variables?: { [key: string]: unknown; }[]; From 6da983385d55058fa6c248b7800404c0377c64fb Mon Sep 17 00:00:00 2001 From: kakkokari-gtyih <67428053+kakkokari-gtyih@users.noreply.github.com> Date: Thu, 7 Nov 2024 11:15:44 +0900 Subject: [PATCH 02/11] =?UTF-8?q?=E3=83=9A=E3=83=BC=E3=82=B8=E3=81=AE?= =?UTF-8?q?=E3=81=8A=E3=81=99=E3=81=99=E3=82=81=E3=81=AB=E3=83=9A=E3=83=BC?= =?UTF-8?q?=E3=82=B8=E3=83=8D=E3=83=BC=E3=82=B7=E3=83=A7=E3=83=B3=E3=81=8C?= =?UTF-8?q?=E5=8A=B9=E3=81=8F=E3=82=88=E3=81=86=E3=81=AB=EF=BC=88fe?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/frontend/src/pages/pages.vue | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/frontend/src/pages/pages.vue b/packages/frontend/src/pages/pages.vue index 4ef9d3b091..71df7290ff 100644 --- a/packages/frontend/src/pages/pages.vue +++ b/packages/frontend/src/pages/pages.vue @@ -53,7 +53,8 @@ const tab = ref('featured'); const featuredPagesPagination = { endpoint: 'pages/featured' as const, - noPaging: true, + limit: 5, + offsetMode: true, }; const myPagesPagination = { endpoint: 'i/pages' as const, From 48c482f77c13ff08d2a078a2a5f351c1ddde841c Mon Sep 17 00:00:00 2001 From: kakkokari-gtyih <67428053+kakkokari-gtyih@users.noreply.github.com> Date: Thu, 7 Nov 2024 11:19:29 +0900 Subject: [PATCH 03/11] fix --- packages/backend/src/core/CoreModule.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts index 734d135648..9c17d52191 100644 --- a/packages/backend/src/core/CoreModule.ts +++ b/packages/backend/src/core/CoreModule.ts @@ -47,6 +47,7 @@ import { NoteDeleteService } from './NoteDeleteService.js'; import { NotePiningService } from './NotePiningService.js'; import { NoteReadService } from './NoteReadService.js'; import { NotificationService } from './NotificationService.js'; +import { PageService } from './PageService.js'; import { PollService } from './PollService.js'; import { PushNotificationService } from './PushNotificationService.js'; import { QueryService } from './QueryService.js'; @@ -190,6 +191,7 @@ const $NoteDeleteService: Provider = { provide: 'NoteDeleteService', useExisting const $NotePiningService: Provider = { provide: 'NotePiningService', useExisting: NotePiningService }; const $NoteReadService: Provider = { provide: 'NoteReadService', useExisting: NoteReadService }; const $NotificationService: Provider = { provide: 'NotificationService', useExisting: NotificationService }; +const $PageService: Provider = { provide: 'PageService', useExisting: PageService }; const $PollService: Provider = { provide: 'PollService', useExisting: PollService }; const $ProxyAccountService: Provider = { provide: 'ProxyAccountService', useExisting: ProxyAccountService }; const $PushNotificationService: Provider = { provide: 'PushNotificationService', useExisting: PushNotificationService }; @@ -341,6 +343,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting NotePiningService, NoteReadService, NotificationService, + PageService, PollService, ProxyAccountService, PushNotificationService, @@ -488,6 +491,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $NotePiningService, $NoteReadService, $NotificationService, + $PageService, $PollService, $ProxyAccountService, $PushNotificationService, @@ -636,6 +640,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting NotePiningService, NoteReadService, NotificationService, + PageService, PollService, ProxyAccountService, PushNotificationService, @@ -782,6 +787,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $NotePiningService, $NoteReadService, $NotificationService, + $PageService, $PollService, $ProxyAccountService, $PushNotificationService, 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 04/11] 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 { // 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; }[]; From 2aa3139dbef67f11d96cc183641d77f469d48abb Mon Sep 17 00:00:00 2001 From: kakkokari-gtyih <67428053+kakkokari-gtyih@users.noreply.github.com> Date: Thu, 7 Nov 2024 12:17:18 +0900 Subject: [PATCH 05/11] fix --- packages/backend/src/models/Page.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/backend/src/models/Page.ts b/packages/backend/src/models/Page.ts index 003fcf4794..d27d35944d 100644 --- a/packages/backend/src/models/Page.ts +++ b/packages/backend/src/models/Page.ts @@ -176,12 +176,12 @@ const noteBlockSchema = { properties: { ...blockBaseSchema.properties, type: { type: 'string', nullable: false, enum: ['note']}, - detailed: { type: 'boolean', nullable: false }, - note: { type: 'string', format: 'misskey:id', nullable: true }, + detailed: { type: 'boolean', nullable: true }, + note: { type: 'string', format: 'misskey:id', nullable: false }, }, required: [ ...blockBaseSchema.required, - 'detailed', + 'note', ], } as const; From 10f6b28790083ccfcccda754551c1137436fbee2 Mon Sep 17 00:00:00 2001 From: kakkokari-gtyih <67428053+kakkokari-gtyih@users.noreply.github.com> Date: Thu, 7 Nov 2024 12:22:40 +0900 Subject: [PATCH 06/11] add comment --- packages/backend/src/models/Page.ts | 11 +++++++++++ packages/backend/src/models/json-schema/page.ts | 7 +++++++ 2 files changed, 18 insertions(+) diff --git a/packages/backend/src/models/Page.ts b/packages/backend/src/models/Page.ts index d27d35944d..34817f2c2d 100644 --- a/packages/backend/src/models/Page.ts +++ b/packages/backend/src/models/Page.ts @@ -121,6 +121,15 @@ export class MiPage { export const pageNameSchema = { type: 'string', pattern: /^[a-zA-Z0-9_-]{1,256}$/.source } as const; +//#region ページブロックのスキーマ(バリデーション用) + +/** + * 併せてpackedPageBlockSchemaも更新すること + * (そっちはAPIの戻り型の定義なので以下の定義とは若干異なる) + * + * packages/backend/src/models/json-schema/page.ts + */ + const blockBaseSchema = { type: 'object', properties: { @@ -222,3 +231,5 @@ export const pageBlockSchema = { noteBlockSchema, ], } as const; + +//#endregion diff --git a/packages/backend/src/models/json-schema/page.ts b/packages/backend/src/models/json-schema/page.ts index 0fe1ca5901..c7a36453f4 100644 --- a/packages/backend/src/models/json-schema/page.ts +++ b/packages/backend/src/models/json-schema/page.ts @@ -3,6 +3,13 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +/** + * ページブロックのスキーマを変更したら、併せてpageBlockSchemaも更新すること + * (そっちは入力バリデーション用の定義なので以下の定義とは若干異なる) + * + * packages/backend/src/models/Page.ts + */ + const packedBlockBaseSchema = { type: 'object', properties: { From 85197f4936e53bf0286faa181d5bf25711fde0b6 Mon Sep 17 00:00:00 2001 From: kakkokari-gtyih <67428053+kakkokari-gtyih@users.noreply.github.com> Date: Thu, 7 Nov 2024 13:19:44 +0900 Subject: [PATCH 07/11] wip --- .../src/pages/page-editor/page-editor.vue | 398 +++++------------- .../src/pages/page-editor/page-editor_.vue | 384 +++++++++++++++++ packages/frontend/src/pages/page.vue | 4 - packages/frontend/src/router/definition.ts | 3 - 4 files changed, 483 insertions(+), 306 deletions(-) create mode 100644 packages/frontend/src/pages/page-editor/page-editor_.vue diff --git a/packages/frontend/src/pages/page-editor/page-editor.vue b/packages/frontend/src/pages/page-editor/page-editor.vue index ddb808390c..e8059dbecd 100644 --- a/packages/frontend/src/pages/page-editor/page-editor.vue +++ b/packages/frontend/src/pages/page-editor/page-editor.vue @@ -5,56 +5,37 @@ SPDX-License-Identifier: AGPL-3.0-only