diff --git a/locales/index.d.ts b/locales/index.d.ts index 0ae188f1f7..f4aef6170d 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -9288,6 +9288,18 @@ export interface Locale extends ILocale { * 特殊 */ "specialBlocks": string; + /** + * タイトルを入力 + */ + "inputTitleHere": string; + /** + * ここに移動 + */ + "moveToHere": string; + /** + * このブロックを削除しますか? + */ + "blockDeleteAreYouSure": string; "blocks": { /** * テキスト diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 1b59708d85..6226f393a5 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -2449,6 +2449,9 @@ _pages: contentBlocks: "コンテンツ" inputBlocks: "入力" specialBlocks: "特殊" + inputTitleHere: "タイトルを入力" + moveToHere: "ここに移動" + blockDeleteAreYouSure: "このブロックを削除しますか?" blocks: text: "テキスト" textarea: "テキストエリア" 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/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, diff --git a/packages/backend/src/core/PageService.ts b/packages/backend/src/core/PageService.ts new file mode 100644 index 0000000000..f004da2993 --- /dev/null +++ b/packages/backend/src/core/PageService.ts @@ -0,0 +1,71 @@ +/* + * 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 _Ajv from 'ajv'; +import { type PagesRepository } from '@/models/_.js'; +import { pageBlockSchema } from '@/models/Page.js'; + +/** + * ページ関係のService + */ +@Injectable() +export class PageService { + constructor( + @Inject(DI.pagesRepository) + private pagesRepository: PagesRepository, + ) { + } + + /** + * ページのコンテンツを検証する. + * @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, + }; + } + } + + /** + * 人気のあるページ一覧を取得する. + */ + 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..34817f2c2d 100644 --- a/packages/backend/src/models/Page.ts +++ b/packages/backend/src/models/Page.ts @@ -118,3 +118,118 @@ 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: { + 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: true }, + note: { type: 'string', format: 'misskey:id', nullable: false }, + }, + required: [ + ...blockBaseSchema.required, + 'note', + ], +} 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; + +//#endregion diff --git a/packages/backend/src/models/json-schema/page.ts b/packages/backend/src/models/json-schema/page.ts index 748d6f1245..c7a36453f4 100644 --- a/packages/backend/src/models/json-schema/page.ts +++ b/packages/backend/src/models/json-schema/page.ts @@ -3,7 +3,14 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -const blockBaseSchema = { +/** + * ページブロックのスキーマを変更したら、併せてpageBlockSchemaも更新すること + * (そっちは入力バリデーション用の定義なので以下の定義とは若干異なる) + * + * packages/backend/src/models/Page.ts + */ + +const packedBlockBaseSchema = { type: 'object', properties: { id: { @@ -17,10 +24,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 +40,31 @@ const textBlockSchema = { }, } as const; -const sectionBlockSchema = { +const packedHeadingBlockSchema = { type: 'object', properties: { - ...blockBaseSchema.properties, + ...packedBlockBaseSchema.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 packedSectionBlockSchema = { + type: 'object', + properties: { + ...packedBlockBaseSchema.properties, type: { type: 'string', optional: false, nullable: false, @@ -59,10 +87,10 @@ const sectionBlockSchema = { }, } as const; -const imageBlockSchema = { +const packedImageBlockSchema = { type: 'object', properties: { - ...blockBaseSchema.properties, + ...packedBlockBaseSchema.properties, type: { type: 'string', optional: false, nullable: false, @@ -75,10 +103,10 @@ const imageBlockSchema = { }, } as const; -const noteBlockSchema = { +const packedNoteBlockSchema = { type: 'object', properties: { - ...blockBaseSchema.properties, + ...packedBlockBaseSchema.properties, type: { type: 'string', optional: false, nullable: false, @@ -98,10 +126,11 @@ const noteBlockSchema = { export const packedPageBlockSchema = { type: 'object', oneOf: [ - textBlockSchema, - sectionBlockSchema, - 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 fa03b0b457..69afd6226f 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 { PageService } from '@/core/PageService.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'; export const meta = { tags: ['pages'], @@ -44,6 +46,16 @@ 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', + }, + invalidParam: { + message: 'Invalid param.', + code: 'INVALID_PARAM', + id: '3d81ceae-475f-4600-b2a8-2bc116157532', + }, }, } as const; @@ -51,9 +63,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: { + // misskey-jsの型生成に対応していないスキーマを使用しているため別途バリデーションする type: 'object', additionalProperties: true, } }, variables: { type: 'array', items: { @@ -65,7 +78,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() @@ -77,10 +90,24 @@ export default class extends Endpoint { // eslint- @Inject(DI.driveFilesRepository) private driveFilesRepository: DriveFilesRepository, + private pageService: PageService, private pageEntityService: PageEntityService, 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); + } + + 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({ @@ -109,8 +136,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..6747521d65 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 { pageNameSchema } from '@/models/Page.js'; +import { PageService } from '@/core/PageService.js'; export const meta = { tags: ['pages'], @@ -48,6 +51,16 @@ 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', + }, + invalidParam: { + message: 'Invalid param.', + code: 'INVALID_PARAM', + id: '3d81ceae-475f-4600-b2a8-2bc116157532', + }, }, } as const; @@ -56,9 +69,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: { + // misskey-jsの型生成に対応していないスキーマを使用しているため別途バリデーションする type: 'object', additionalProperties: true, } }, variables: { type: 'array', items: { @@ -81,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 }); @@ -91,6 +107,21 @@ export default class extends Endpoint { // eslint- throw new ApiError(meta.errors.accessDenied); } + 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) { const eyeCatchingImage = await this.driveFilesRepository.findOneBy({ id: ps.eyeCatchingImageId, @@ -118,8 +149,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/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue index 4a350388c2..30d88a0f4c 100644 --- a/packages/frontend/src/components/MkNoteDetailed.vue +++ b/packages/frontend/src/components/MkNoteDetailed.vue @@ -253,9 +253,11 @@ import { isEnabledUrlPreview } from '@/instance.js'; import { getAppearNote } from '@/scripts/get-appear-note.js'; import { type Keymap } from '@/scripts/hotkey.js'; +type Tab = 'replies' | 'renotes' | 'reactions'; + const props = withDefaults(defineProps<{ note: Misskey.entities.Note; - initialTab: string; + initialTab?: Tab; }>(), { initialTab: 'replies', }); @@ -339,7 +341,7 @@ provide('react', (reaction: string) => { }); }); -const tab = ref(props.initialTab); +const tab = ref(props.initialTab); const reactionTabType = ref(null); const renotesPagination = computed(() => ({ diff --git a/packages/frontend/src/components/global/MkStickyContainer.vue b/packages/frontend/src/components/global/MkStickyContainer.vue index 1aebf487bb..24f9333f74 100644 --- a/packages/frontend/src/components/global/MkStickyContainer.vue +++ b/packages/frontend/src/components/global/MkStickyContainer.vue @@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only -->