This commit is contained in:
kakkokari-gtyih 2024-11-07 12:08:29 +09:00
parent 48c482f77c
commit a46b98b617
6 changed files with 196 additions and 80 deletions

View file

@ -5,7 +5,9 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import _Ajv from 'ajv';
import { type PagesRepository } from '@/models/_.js'; import { type PagesRepository } from '@/models/_.js';
import { pageBlockSchema } from '@/models/Page.js';
/** /**
* Service * 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,
};
}
}
/** /**
* . * .
*/ */

View file

@ -120,3 +120,105 @@ export class MiPage {
} }
export const pageNameSchema = { type: 'string', pattern: /^[a-zA-Z0-9_-]{1,256}$/.source } as const; 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;

View file

@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
const blockBaseSchema = { const packedBlockBaseSchema = {
type: 'object', type: 'object',
properties: { properties: {
id: { id: {
@ -17,10 +17,10 @@ const blockBaseSchema = {
}, },
} as const; } as const;
const textBlockSchema = { const packedTextBlockSchema = {
type: 'object', type: 'object',
properties: { properties: {
...blockBaseSchema.properties, ...packedBlockBaseSchema.properties,
type: { type: {
type: 'string', type: 'string',
optional: false, nullable: false, optional: false, nullable: false,
@ -33,10 +33,10 @@ const textBlockSchema = {
}, },
} as const; } as const;
const headingBlockSchema = { const packedHeadingBlockSchema = {
type: 'object', type: 'object',
properties: { properties: {
...blockBaseSchema.properties, ...packedBlockBaseSchema.properties,
type: { type: {
type: 'string', type: 'string',
optional: false, nullable: false, optional: false, nullable: false,
@ -54,10 +54,10 @@ const headingBlockSchema = {
} as const; } as const;
/** @deprecated 要素を入れ子にする必要が一旦なくなったので非推奨。headingBlockを使用すること */ /** @deprecated 要素を入れ子にする必要が一旦なくなったので非推奨。headingBlockを使用すること */
const sectionBlockSchema = { const packedSectionBlockSchema = {
type: 'object', type: 'object',
properties: { properties: {
...blockBaseSchema.properties, ...packedBlockBaseSchema.properties,
type: { type: {
type: 'string', type: 'string',
optional: false, nullable: false, optional: false, nullable: false,
@ -80,10 +80,10 @@ const sectionBlockSchema = {
}, },
} as const; } as const;
const imageBlockSchema = { const packedImageBlockSchema = {
type: 'object', type: 'object',
properties: { properties: {
...blockBaseSchema.properties, ...packedBlockBaseSchema.properties,
type: { type: {
type: 'string', type: 'string',
optional: false, nullable: false, optional: false, nullable: false,
@ -96,10 +96,10 @@ const imageBlockSchema = {
}, },
} as const; } as const;
const noteBlockSchema = { const packedNoteBlockSchema = {
type: 'object', type: 'object',
properties: { properties: {
...blockBaseSchema.properties, ...packedBlockBaseSchema.properties,
type: { type: {
type: 'string', type: 'string',
optional: false, nullable: false, optional: false, nullable: false,
@ -119,11 +119,11 @@ const noteBlockSchema = {
export const packedPageBlockSchema = { export const packedPageBlockSchema = {
type: 'object', type: 'object',
oneOf: [ oneOf: [
textBlockSchema, packedTextBlockSchema,
sectionBlockSchema, packedSectionBlockSchema,
headingBlockSchema, packedHeadingBlockSchema,
imageBlockSchema, packedImageBlockSchema,
noteBlockSchema, packedNoteBlockSchema,
], ],
} as const; } as const;

View file

@ -9,11 +9,11 @@ import type { DriveFilesRepository, PagesRepository } from '@/models/_.js';
import { IdService } from '@/core/IdService.js'; import { IdService } from '@/core/IdService.js';
import { MiPage, pageNameSchema } from '@/models/Page.js'; import { MiPage, pageNameSchema } from '@/models/Page.js';
import { Endpoint } from '@/server/api/endpoint-base.js'; import { Endpoint } from '@/server/api/endpoint-base.js';
import { PageService } from '@/core/PageService.js';
import { PageEntityService } from '@/core/entities/PageEntityService.js'; import { PageEntityService } from '@/core/entities/PageEntityService.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { ApiError } from '@/server/api/error.js'; import { ApiError } from '@/server/api/error.js';
import { MAX_PAGE_CONTENT_BYTES } from '@/const.js'; import { MAX_PAGE_CONTENT_BYTES } from '@/const.js';
import { packedPageBlockSchema } from '@/models/json-schema/page.js';
export const meta = { export const meta = {
tags: ['pages'], tags: ['pages'],
@ -51,6 +51,11 @@ export const meta = {
code: 'CONTENT_TOO_LARGE', code: 'CONTENT_TOO_LARGE',
id: '2a93fcc9-4cd7-4885-9e5b-be56ed8f4d4f', id: '2a93fcc9-4cd7-4885-9e5b-be56ed8f4d4f',
}, },
invalidParam: {
message: 'Invalid param.',
code: 'INVALID_PARAM',
id: '3d81ceae-475f-4600-b2a8-2bc116157532',
},
}, },
} as const; } as const;
@ -61,7 +66,8 @@ export const paramDef = {
name: { ...pageNameSchema, minLength: 1 }, name: { ...pageNameSchema, minLength: 1 },
summary: { type: 'string', nullable: true }, summary: { type: 'string', nullable: true },
content: { type: 'array', items: { content: { type: 'array', items: {
...packedPageBlockSchema, // misskey-jsの型生成に対応していないスキーマを使用しているため別途バリデーションする
type: 'object', additionalProperties: true,
} }, } },
variables: { type: 'array', items: { variables: { type: 'array', items: {
type: 'object', additionalProperties: true, type: 'object', additionalProperties: true,
@ -84,6 +90,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
@Inject(DI.driveFilesRepository) @Inject(DI.driveFilesRepository)
private driveFilesRepository: DriveFilesRepository, private driveFilesRepository: DriveFilesRepository,
private pageService: PageService,
private pageEntityService: PageEntityService, private pageEntityService: PageEntityService,
private idService: IdService, private idService: IdService,
) { ) {
@ -92,6 +99,15 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new ApiError(meta.errors.contentTooLarge); 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; let eyeCatchingImage = null;
if (ps.eyeCatchingImageId != null) { if (ps.eyeCatchingImageId != null) {
eyeCatchingImage = await this.driveFilesRepository.findOneBy({ eyeCatchingImage = await this.driveFilesRepository.findOneBy({

View file

@ -11,8 +11,8 @@ import { Endpoint } from '@/server/api/endpoint-base.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { ApiError } from '@/server/api/error.js'; import { ApiError } from '@/server/api/error.js';
import { MAX_PAGE_CONTENT_BYTES } from '@/const.js'; import { MAX_PAGE_CONTENT_BYTES } from '@/const.js';
import { packedPageBlockSchema } from '@/models/json-schema/page.js';
import { pageNameSchema } from '@/models/Page.js'; import { pageNameSchema } from '@/models/Page.js';
import { PageService } from '@/core/PageService.js';
export const meta = { export const meta = {
tags: ['pages'], tags: ['pages'],
@ -56,6 +56,11 @@ export const meta = {
code: 'CONTENT_TOO_LARGE', code: 'CONTENT_TOO_LARGE',
id: '2a93fcc9-4cd7-4885-9e5b-be56ed8f4d4f', id: '2a93fcc9-4cd7-4885-9e5b-be56ed8f4d4f',
}, },
invalidParam: {
message: 'Invalid param.',
code: 'INVALID_PARAM',
id: '3d81ceae-475f-4600-b2a8-2bc116157532',
},
}, },
} as const; } as const;
@ -67,7 +72,8 @@ export const paramDef = {
name: { ...pageNameSchema, minLength: 1 }, name: { ...pageNameSchema, minLength: 1 },
summary: { type: 'string', nullable: true }, summary: { type: 'string', nullable: true },
content: { type: 'array', items: { content: { type: 'array', items: {
...packedPageBlockSchema, // misskey-jsの型生成に対応していないスキーマを使用しているため別途バリデーションする
type: 'object', additionalProperties: true,
} }, } },
variables: { type: 'array', items: { variables: { type: 'array', items: {
type: 'object', additionalProperties: true, type: 'object', additionalProperties: true,
@ -89,6 +95,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
@Inject(DI.driveFilesRepository) @Inject(DI.driveFilesRepository)
private driveFilesRepository: DriveFilesRepository, private driveFilesRepository: DriveFilesRepository,
private pageService: PageService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const page = await this.pagesRepository.findOneBy({ id: ps.pageId }); const page = await this.pagesRepository.findOneBy({ id: ps.pageId });
@ -99,10 +107,21 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new ApiError(meta.errors.accessDenied); throw new ApiError(meta.errors.accessDenied);
} }
if (ps.content != null) {
if (new Blob([JSON.stringify(ps.content)]).size > MAX_PAGE_CONTENT_BYTES) { if (new Blob([JSON.stringify(ps.content)]).size > MAX_PAGE_CONTENT_BYTES) {
throw new ApiError(meta.errors.contentTooLarge); 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) { if (ps.eyeCatchingImageId != null) {
const eyeCatchingImage = await this.driveFilesRepository.findOneBy({ const eyeCatchingImage = await this.driveFilesRepository.findOneBy({
id: ps.eyeCatchingImageId, id: ps.eyeCatchingImageId,

View file

@ -23432,35 +23432,9 @@ export type operations = {
title: string; title: string;
name: string; name: string;
summary?: string | null; summary?: string | null;
content: (OneOf<[{ content: {
id?: string; [key: string]: unknown;
/** @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?: { variables?: {
[key: string]: unknown; [key: string]: unknown;
}[]; }[];
@ -23807,35 +23781,9 @@ export type operations = {
title?: string; title?: string;
name?: string; name?: string;
summary?: string | null; summary?: string | null;
content?: (OneOf<[{ content?: {
id?: string; [key: string]: unknown;
/** @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?: { variables?: {
[key: string]: unknown; [key: string]: unknown;
}[]; }[];