pageエンドポイント周りの改善

- featuredでページネーションが可能に
- ページブロックのバリデーションを追加
- ページslugのバリデーションを追加
- ページブロックの容量制限を追加
- 未使用プロパティscriptとvariablesは変更が効かないように
This commit is contained in:
kakkokari-gtyih 2024-11-07 11:14:47 +09:00
parent a896c39dbf
commit 5b6146e348
11 changed files with 195 additions and 36 deletions

View File

@ -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

View File

@ -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();
}
}

View File

@ -118,3 +118,5 @@ export class MiPage {
}
}
}
export const pageNameSchema = { type: 'string', pattern: /^[a-zA-Z0-9_-]{1,256}$/.source } as const;

View File

@ -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,
],

View File

@ -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<typeof meta, typeof paramDef> { // 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<typeof meta, typeof paramDef> { // 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',

View File

@ -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<typeof meta, typeof paramDef> { // 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);
});
}
}

View File

@ -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<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.eyeCatchingImageId != null) {
const eyeCatchingImage = await this.driveFilesRepository.findOneBy({
id: ps.eyeCatchingImageId,
@ -118,8 +130,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // 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,

View File

@ -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'];

View File

@ -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 };

View File

@ -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'];

View File

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