1
0
mirror of https://github.com/misskey-dev/misskey.git synced 2026-05-01 20:56:06 +02:00

fix(backend): correct invalid schema format specifying only required for anyOf (#16089)

* fix(backend): correct invalid schema format specifying only `required` for `anyOf`

* refactor(backend): make types derived from `allOf` or `anyOf` more strong
This commit is contained in:
zyoshoka
2025-05-27 08:57:09 +09:00
committed by GitHub
parent ed3a844f5d
commit d27075c5f5
16 changed files with 394 additions and 252 deletions

View File

@@ -162,14 +162,21 @@ export const meta = {
} as const;
export const paramDef = {
type: 'object',
properties: {
fileId: { type: 'string', format: 'misskey:id' },
url: { type: 'string' },
},
anyOf: [
{ required: ['fileId'] },
{ required: ['url'] },
{
type: 'object',
properties: {
fileId: { type: 'string', format: 'misskey:id' },
},
required: ['fileId'],
},
{
type: 'object',
properties: {
url: { type: 'string' },
},
required: ['url'],
},
],
} as const;
@@ -186,15 +193,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private idService: IdService,
) {
super(meta, paramDef, async (ps, me) => {
const file = ps.fileId ? await this.driveFilesRepository.findOneBy({ id: ps.fileId }) : await this.driveFilesRepository.findOne({
where: [{
url: ps.url,
}, {
thumbnailUrl: ps.url,
}, {
webpublicUrl: ps.url,
}],
});
const file = await this.driveFilesRepository.findOneBy(
'fileId' in ps
? { id: ps.fileId }
: [{ url: ps.url }, { thumbnailUrl: ps.url }, { webpublicUrl: ps.url }],
);
if (file == null) {
throw new ApiError(meta.errors.noSuchFile);

View File

@@ -37,29 +37,45 @@ export const meta = {
} as const;
export const paramDef = {
type: 'object',
properties: {
id: { type: 'string', format: 'misskey:id' },
name: { type: 'string', pattern: '^[a-zA-Z0-9_]+$' },
fileId: { type: 'string', format: 'misskey:id' },
category: {
type: 'string',
nullable: true,
description: 'Use `null` to reset the category.',
allOf: [
{
anyOf: [
{
type: 'object',
properties: {
id: { type: 'string', format: 'misskey:id' },
},
required: ['id'],
},
{
type: 'object',
properties: {
name: { type: 'string', pattern: '^[a-zA-Z0-9_]+$' },
},
required: ['name'],
},
],
},
{
type: 'object',
properties: {
fileId: { type: 'string', format: 'misskey:id' },
category: {
type: 'string',
nullable: true,
description: 'Use `null` to reset the category.',
},
aliases: { type: 'array', items: {
type: 'string',
} },
license: { type: 'string', nullable: true },
isSensitive: { type: 'boolean' },
localOnly: { type: 'boolean' },
roleIdsThatCanBeUsedThisEmojiAsReaction: { type: 'array', items: {
type: 'string',
} },
},
},
aliases: { type: 'array', items: {
type: 'string',
} },
license: { type: 'string', nullable: true },
isSensitive: { type: 'boolean' },
localOnly: { type: 'boolean' },
roleIdsThatCanBeUsedThisEmojiAsReaction: { type: 'array', items: {
type: 'string',
} },
},
anyOf: [
{ required: ['id'] },
{ required: ['name'] },
],
} as const;
@@ -78,10 +94,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (driveFile == null) throw new ApiError(meta.errors.noSuchFile);
}
// JSON schemeのanyOfの型変換がうまくいっていないらしい
const required = { id: ps.id, name: ps.name } as
| { id: MiEmoji['id']; name?: string }
| { id?: MiEmoji['id']; name: string };
const required = 'id' in ps
? { id: ps.id, name: 'name' in ps ? ps.name as string : undefined }
: { name: ps.name };
const error = await this.customEmojiService.update({
...required,

View File

@@ -43,14 +43,21 @@ export const meta = {
} as const;
export const paramDef = {
type: 'object',
properties: {
fileId: { type: 'string', format: 'misskey:id' },
url: { type: 'string' },
},
anyOf: [
{ required: ['fileId'] },
{ required: ['url'] },
{
type: 'object',
properties: {
fileId: { type: 'string', format: 'misskey:id' },
},
required: ['fileId'],
},
{
type: 'object',
properties: {
url: { type: 'string' },
},
required: ['url'],
},
],
} as const;
@@ -64,21 +71,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private roleService: RoleService,
) {
super(meta, paramDef, async (ps, me) => {
let file: MiDriveFile | null = null;
if (ps.fileId) {
file = await this.driveFilesRepository.findOneBy({ id: ps.fileId });
} else if (ps.url) {
file = await this.driveFilesRepository.findOne({
where: [{
url: ps.url,
}, {
webpublicUrl: ps.url,
}, {
thumbnailUrl: ps.url,
}],
});
}
const file = await this.driveFilesRepository.findOneBy(
'fileId' in ps
? { id: ps.fileId }
: [{ url: ps.url }, { webpublicUrl: ps.url }, { thumbnailUrl: ps.url }],
);
if (file == null) {
throw new ApiError(meta.errors.noSuchFile);

View File

@@ -15,14 +15,21 @@ export const meta = {
} as const;
export const paramDef = {
type: 'object',
properties: {
tokenId: { type: 'string', format: 'misskey:id' },
token: { type: 'string', nullable: true },
},
anyOf: [
{ required: ['tokenId'] },
{ required: ['token'] },
{
type: 'object',
properties: {
tokenId: { type: 'string', format: 'misskey:id' },
},
required: ['tokenId'],
},
{
type: 'object',
properties: {
token: { type: 'string', nullable: true },
},
required: ['token'],
},
],
} as const;
@@ -33,7 +40,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private accessTokensRepository: AccessTokensRepository,
) {
super(meta, paramDef, async (ps, me) => {
if (ps.tokenId) {
if ('tokenId' in ps) {
const tokenExist = await this.accessTokensRepository.exists({ where: { id: ps.tokenId } });
if (tokenExist) {

View File

@@ -28,38 +28,53 @@ export const meta = {
} as const;
export const paramDef = {
type: 'object',
properties: {
reply: { type: 'boolean', nullable: true, default: null },
renote: { type: 'boolean', nullable: true, default: null },
withFiles: {
type: 'boolean',
default: false,
description: 'Only show notes that have attached files.',
},
poll: { type: 'boolean', nullable: true, default: null },
sinceId: { type: 'string', format: 'misskey:id' },
untilId: { type: 'string', format: 'misskey:id' },
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
tag: { type: 'string', minLength: 1 },
query: {
type: 'array',
description: 'The outer arrays are chained with OR, the inner arrays are chained with AND.',
items: {
type: 'array',
items: {
type: 'string',
minLength: 1,
allOf: [
{
anyOf: [
{
type: 'object',
properties: {
tag: { type: 'string', minLength: 1 },
},
required: ['tag'],
},
minItems: 1,
},
minItems: 1,
{
type: 'object',
properties: {
query: {
type: 'array',
description: 'The outer arrays are chained with OR, the inner arrays are chained with AND.',
items: {
type: 'array',
items: {
type: 'string',
minLength: 1,
},
minItems: 1,
},
minItems: 1,
},
},
required: ['query'],
},
],
},
{
type: 'object',
properties: {
reply: { type: 'boolean', nullable: true, default: null },
renote: { type: 'boolean', nullable: true, default: null },
withFiles: {
type: 'boolean',
default: false,
description: 'Only show notes that have attached files.',
},
poll: { type: 'boolean', nullable: true, default: null },
sinceId: { type: 'string', format: 'misskey:id' },
untilId: { type: 'string', format: 'misskey:id' },
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
},
},
},
anyOf: [
{ required: ['tag'] },
{ required: ['query'] },
],
} as const;
@@ -87,12 +102,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (me) this.queryService.generateBlockedUserQueryForNotes(query, me);
try {
if (ps.tag) {
if ('tag' in ps) {
if (!safeForSql(normalizeForSearch(ps.tag))) throw new Error('Injection');
query.andWhere(':tag <@ note.tags', { tag: [normalizeForSearch(ps.tag)] });
} else {
query.andWhere(new Brackets(qb => {
for (const tags of ps.query!) {
for (const tags of ps.query) {
qb.orWhere(new Brackets(qb => {
for (const tag of tags) {
if (!safeForSql(normalizeForSearch(tag))) throw new Error('Injection');

View File

@@ -33,15 +33,22 @@ export const meta = {
} as const;
export const paramDef = {
type: 'object',
properties: {
pageId: { type: 'string', format: 'misskey:id' },
name: { type: 'string' },
username: { type: 'string' },
},
anyOf: [
{ required: ['pageId'] },
{ required: ['name', 'username'] },
{
type: 'object',
properties: {
pageId: { type: 'string', format: 'misskey:id' },
},
required: ['pageId'],
},
{
type: 'object',
properties: {
name: { type: 'string' },
username: { type: 'string' },
},
required: ['name', 'username'],
},
],
} as const;
@@ -59,9 +66,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
super(meta, paramDef, async (ps, me) => {
let page: MiPage | null = null;
if (ps.pageId) {
if ('pageId' in ps) {
page = await this.pagesRepository.findOneBy({ id: ps.pageId });
} else if (ps.name && ps.username) {
} else {
const author = await this.usersRepository.findOneBy({
host: IsNull(),
usernameLower: ps.username.toLowerCase(),

View File

@@ -47,23 +47,38 @@ export const meta = {
} as const;
export const paramDef = {
type: 'object',
properties: {
sinceId: { type: 'string', format: 'misskey:id' },
untilId: { type: 'string', format: 'misskey:id' },
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
userId: { type: 'string', format: 'misskey:id' },
username: { type: 'string' },
host: {
type: 'string',
nullable: true,
description: 'The local host is represented with `null`.',
allOf: [
{
anyOf: [
{
type: 'object',
properties: {
userId: { type: 'string', format: 'misskey:id' },
},
required: ['userId'],
},
{
type: 'object',
properties: {
username: { type: 'string' },
host: {
type: 'string',
nullable: true,
description: 'The local host is represented with `null`.',
},
},
required: ['username', 'host'],
},
],
},
{
type: 'object',
properties: {
sinceId: { type: 'string', format: 'misskey:id' },
untilId: { type: 'string', format: 'misskey:id' },
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
},
},
},
anyOf: [
{ required: ['userId'] },
{ required: ['username', 'host'] },
],
} as const;
@@ -85,9 +100,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private roleService: RoleService,
) {
super(meta, paramDef, async (ps, me) => {
const user = await this.usersRepository.findOneBy(ps.userId != null
const user = await this.usersRepository.findOneBy('userId' in ps
? { id: ps.userId }
: { usernameLower: ps.username!.toLowerCase(), host: this.utilityService.toPunyNullable(ps.host) ?? IsNull() });
: { usernameLower: ps.username.toLowerCase(), host: this.utilityService.toPunyNullable(ps.host) ?? IsNull() });
if (user == null) {
throw new ApiError(meta.errors.noSuchUser);

View File

@@ -54,25 +54,39 @@ export const meta = {
} as const;
export const paramDef = {
type: 'object',
properties: {
sinceId: { type: 'string', format: 'misskey:id' },
untilId: { type: 'string', format: 'misskey:id' },
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
userId: { type: 'string', format: 'misskey:id' },
username: { type: 'string' },
host: {
type: 'string',
nullable: true,
description: 'The local host is represented with `null`.',
allOf: [
{
anyOf: [
{
type: 'object',
properties: {
userId: { type: 'string', format: 'misskey:id' },
},
required: ['userId'],
},
{
type: 'object',
properties: {
username: { type: 'string' },
host: {
type: 'string',
nullable: true,
description: 'The local host is represented with `null`.',
},
},
required: ['username', 'host'],
},
],
},
{
type: 'object',
properties: {
sinceId: { type: 'string', format: 'misskey:id' },
untilId: { type: 'string', format: 'misskey:id' },
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
birthday: { ...birthdaySchema, nullable: true },
},
},
birthday: { ...birthdaySchema, nullable: true },
},
anyOf: [
{ required: ['userId'] },
{ required: ['username', 'host'] },
],
} as const;
@@ -94,9 +108,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private roleService: RoleService,
) {
super(meta, paramDef, async (ps, me) => {
const user = await this.usersRepository.findOneBy(ps.userId != null
const user = await this.usersRepository.findOneBy('userId' in ps
? { id: ps.userId }
: { usernameLower: ps.username!.toLowerCase(), host: this.utilityService.toPunyNullable(ps.host) ?? IsNull() });
: { usernameLower: ps.username.toLowerCase(), host: this.utilityService.toPunyNullable(ps.host) ?? IsNull() });
if (user == null) {
throw new ApiError(meta.errors.noSuchUser);

View File

@@ -114,7 +114,7 @@ export const paramDef = {
type: 'object',
properties: {
userId: {
anyOf: [
oneOf: [
{ type: 'string', format: 'misskey:id' },
{
type: 'array',

View File

@@ -26,17 +26,32 @@ export const meta = {
} as const;
export const paramDef = {
type: 'object',
properties: {
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
detail: { type: 'boolean', default: true },
username: { type: 'string', nullable: true },
host: { type: 'string', nullable: true },
},
anyOf: [
{ required: ['username'] },
{ required: ['host'] },
allOf: [
{
anyOf: [
{
type: 'object',
properties: {
username: { type: 'string', nullable: true },
},
required: ['username'],
},
{
type: 'object',
properties: {
host: { type: 'string', nullable: true },
},
required: ['host'],
},
],
},
{
type: 'object',
properties: {
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
detail: { type: 'boolean', default: true },
},
},
],
} as const;
@@ -47,8 +62,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
) {
super(meta, paramDef, (ps, me) => {
return this.userSearchService.searchByUsernameAndHost({
username: ps.username,
host: ps.host,
username: 'username' in ps ? ps.username : undefined,
host: 'host' in ps ? ps.host : undefined,
}, {
limit: ps.limit,
detail: ps.detail,

View File

@@ -0,0 +1,23 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
process.env.NODE_ENV = 'test';
import { getValidator } from '../../../../../test/prelude/get-api-validator.js';
import { paramDef } from './show.js';
const VALID = true;
const INVALID = false;
describe('api:users/show', () => {
describe('validation', () => {
const v = getValidator(paramDef);
test('Reject empty', () => expect(v({})).toBe(INVALID));
test('Reject host only', () => expect(v({ host: 'misskey.test' })).toBe(INVALID));
test('Accept userId only', () => expect(v({ userId: '1' })).toBe(VALID));
test('Accept username and host', () => expect(v({ username: 'alice', host: 'misskey.test' })).toBe(VALID));
});
});

View File

@@ -59,23 +59,44 @@ export const meta = {
} as const;
export const paramDef = {
type: 'object',
properties: {
userId: { type: 'string', format: 'misskey:id' },
userIds: { type: 'array', uniqueItems: true, items: {
type: 'string', format: 'misskey:id',
} },
username: { type: 'string' },
host: {
type: 'string',
nullable: true,
description: 'The local host is represented with `null`.',
allOf: [
{
anyOf: [
{
type: 'object',
properties: {
userId: { type: 'string', format: 'misskey:id' },
},
required: ['userId'],
},
{
type: 'object',
properties: {
userIds: { type: 'array', uniqueItems: true, items: {
type: 'string', format: 'misskey:id',
} },
},
required: ['userIds'],
},
{
type: 'object',
properties: {
username: { type: 'string' },
},
required: ['username'],
},
],
},
{
type: 'object',
properties: {
host: {
type: 'string',
nullable: true,
description: 'The local host is represented with `null`.',
},
},
},
},
anyOf: [
{ required: ['userId'] },
{ required: ['userIds'] },
{ required: ['username'] },
],
} as const;
@@ -102,9 +123,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
let user;
const isModerator = await this.roleService.isModerator(me);
ps.username = ps.username?.trim();
if ('username' in ps) {
ps.username = ps.username.trim();
}
if (ps.userIds) {
if ('userIds' in ps) {
if (ps.userIds.length === 0) {
return [];
}
@@ -129,7 +152,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
return _users.map(u => _userMap.get(u.id)!);
} else {
// Lookup user
if (typeof ps.host === 'string' && typeof ps.username === 'string') {
if (typeof ps.host === 'string' && 'username' in ps) {
if (this.serverSettings.ugcVisibilityForVisitor === 'local' && me == null) {
throw new ApiError(meta.errors.noSuchUser);
}
@@ -139,7 +162,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new ApiError(meta.errors.failedToResolveRemoteUser);
});
} else {
const q: FindOptionsWhere<MiUser> = ps.userId != null
const q: FindOptionsWhere<MiUser> = 'userId' in ps
? { id: ps.userId }
: { usernameLower: ps.username!.toLowerCase(), host: IsNull() };