merge: upstream
This commit is contained in:
commit
4c1f6be735
132 changed files with 12167 additions and 792 deletions
|
@ -0,0 +1,24 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export class OptimizeNoteIndexForArrayColumns1705222772858 {
|
||||
name = 'OptimizeNoteIndexForArrayColumns1705222772858'
|
||||
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_796a8c03959361f97dc2be1d5c"`);
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_54ebcb6d27222913b908d56fd8"`);
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_88937d94d7443d9a99a76fa5c0"`);
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_51c063b6a133a9cb87145450f5"`);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_NOTE_FILE_IDS" ON "note" using gin ("fileIds")`)
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query(`DROP INDEX "IDX_NOTE_FILE_IDS"`)
|
||||
await queryRunner.query(`CREATE INDEX "IDX_51c063b6a133a9cb87145450f5" ON "note" ("fileIds") `);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_88937d94d7443d9a99a76fa5c0" ON "note" ("tags") `);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_54ebcb6d27222913b908d56fd8" ON "note" ("mentions") `);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_796a8c03959361f97dc2be1d5c" ON "note" ("visibleUserIds") `);
|
||||
}
|
||||
}
|
|
@ -145,7 +145,8 @@ export class DownloadService {
|
|||
const parsedIp = ipaddr.parse(ip);
|
||||
|
||||
for (const net of this.config.allowedPrivateNetworks ?? []) {
|
||||
if (parsedIp.match(ipaddr.parseCIDR(net))) {
|
||||
const cidr = ipaddr.parseCIDR(net);
|
||||
if (cidr[0].kind() === parsedIp.kind() && parsedIp.match(ipaddr.parseCIDR(net))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -494,6 +494,9 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||
data.text = data.text.slice(0, DB_MAX_NOTE_TEXT_LENGTH);
|
||||
}
|
||||
data.text = data.text.trim();
|
||||
if (data.text === '') {
|
||||
data.text = null;
|
||||
}
|
||||
} else {
|
||||
data.text = null;
|
||||
}
|
||||
|
|
|
@ -212,8 +212,8 @@ export class QueryService {
|
|||
// または 自分自身
|
||||
.orWhere('note.userId = :meId')
|
||||
// または 自分宛て
|
||||
.orWhere(':meId = ANY(note.visibleUserIds)')
|
||||
.orWhere(':meId = ANY(note.mentions)')
|
||||
.orWhere(':meIdAsList <@ note.visibleUserIds')
|
||||
.orWhere(':meIdAsList <@ note.mentions')
|
||||
.orWhere(new Brackets(qb => {
|
||||
qb
|
||||
// または フォロワー宛ての投稿であり、
|
||||
|
@ -228,7 +228,7 @@ export class QueryService {
|
|||
}));
|
||||
}));
|
||||
|
||||
q.setParameters({ meId: me.id });
|
||||
q.setParameters({ meId: me.id, meIdAsList: [me.id] });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -11,9 +11,6 @@ import { MiChannel } from './Channel.js';
|
|||
import type { MiDriveFile } from './DriveFile.js';
|
||||
|
||||
@Entity('note')
|
||||
@Index('IDX_NOTE_TAGS', { synchronize: false })
|
||||
@Index('IDX_NOTE_MENTIONS', { synchronize: false })
|
||||
@Index('IDX_NOTE_VISIBLE_USER_IDS', { synchronize: false })
|
||||
export class MiNote {
|
||||
@PrimaryColumn(id())
|
||||
public id: string;
|
||||
|
@ -139,7 +136,7 @@ export class MiNote {
|
|||
})
|
||||
public url: string | null;
|
||||
|
||||
@Index()
|
||||
@Index('IDX_NOTE_FILE_IDS', { synchronize: false })
|
||||
@Column({
|
||||
...id(),
|
||||
array: true, default: '{}',
|
||||
|
@ -151,14 +148,14 @@ export class MiNote {
|
|||
})
|
||||
public attachedFileTypes: string[];
|
||||
|
||||
@Index()
|
||||
@Index('IDX_NOTE_VISIBLE_USER_IDS', { synchronize: false })
|
||||
@Column({
|
||||
...id(),
|
||||
array: true, default: '{}',
|
||||
})
|
||||
public visibleUserIds: MiUser['id'][];
|
||||
|
||||
@Index()
|
||||
@Index('IDX_NOTE_MENTIONS', { synchronize: false })
|
||||
@Column({
|
||||
...id(),
|
||||
array: true, default: '{}',
|
||||
|
@ -180,7 +177,7 @@ export class MiNote {
|
|||
})
|
||||
public emojis: string[];
|
||||
|
||||
@Index()
|
||||
@Index('IDX_NOTE_TAGS', { synchronize: false })
|
||||
@Column('varchar', {
|
||||
length: 128, array: true, default: '{}',
|
||||
})
|
||||
|
|
|
@ -166,11 +166,35 @@ export class FileServerService {
|
|||
}
|
||||
|
||||
if (!image) {
|
||||
image = {
|
||||
data: fs.createReadStream(file.path),
|
||||
ext: file.ext,
|
||||
type: file.mime,
|
||||
};
|
||||
if (request.headers.range && file.file.size > 0) {
|
||||
const range = request.headers.range as string;
|
||||
const parts = range.replace(/bytes=/, '').split('-');
|
||||
const start = parseInt(parts[0], 10);
|
||||
let end = parts[1] ? parseInt(parts[1], 10) : file.file.size - 1;
|
||||
if (end > file.file.size) {
|
||||
end = file.file.size - 1;
|
||||
}
|
||||
const chunksize = end - start + 1;
|
||||
|
||||
image = {
|
||||
data: fs.createReadStream(file.path, {
|
||||
start,
|
||||
end,
|
||||
}),
|
||||
ext: file.ext,
|
||||
type: file.mime,
|
||||
};
|
||||
|
||||
reply.header('Content-Range', `bytes ${start}-${end}/${file.file.size}`);
|
||||
reply.header('Accept-Ranges', 'bytes');
|
||||
reply.header('Content-Length', chunksize);
|
||||
} else {
|
||||
image = {
|
||||
data: fs.createReadStream(file.path),
|
||||
ext: file.ext,
|
||||
type: file.mime,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if ('pipe' in image.data && typeof image.data.pipe === 'function') {
|
||||
|
@ -201,11 +225,54 @@ export class FileServerService {
|
|||
reply.header('Content-Type', FILE_TYPE_BROWSERSAFE.includes(file.mime) ? file.mime : 'application/octet-stream');
|
||||
reply.header('Cache-Control', 'max-age=31536000, immutable');
|
||||
reply.header('Content-Disposition', contentDisposition('inline', filename));
|
||||
|
||||
if (request.headers.range && file.file.size > 0) {
|
||||
const range = request.headers.range as string;
|
||||
const parts = range.replace(/bytes=/, '').split('-');
|
||||
const start = parseInt(parts[0], 10);
|
||||
let end = parts[1] ? parseInt(parts[1], 10) : file.file.size - 1;
|
||||
if (end > file.file.size) {
|
||||
end = file.file.size - 1;
|
||||
}
|
||||
const chunksize = end - start + 1;
|
||||
const fileStream = fs.createReadStream(file.path, {
|
||||
start,
|
||||
end,
|
||||
});
|
||||
reply.header('Content-Range', `bytes ${start}-${end}/${file.file.size}`);
|
||||
reply.header('Accept-Ranges', 'bytes');
|
||||
reply.header('Content-Length', chunksize);
|
||||
reply.code(206);
|
||||
return fileStream;
|
||||
}
|
||||
|
||||
return fs.createReadStream(file.path);
|
||||
} else {
|
||||
reply.header('Content-Type', FILE_TYPE_BROWSERSAFE.includes(file.file.type) ? file.file.type : 'application/octet-stream');
|
||||
reply.header('Cache-Control', 'max-age=31536000, immutable');
|
||||
reply.header('Content-Disposition', contentDisposition('inline', file.filename));
|
||||
|
||||
if (request.headers.range && file.file.size > 0) {
|
||||
const range = request.headers.range as string;
|
||||
const parts = range.replace(/bytes=/, '').split('-');
|
||||
const start = parseInt(parts[0], 10);
|
||||
let end = parts[1] ? parseInt(parts[1], 10) : file.file.size - 1;
|
||||
console.log(end);
|
||||
if (end > file.file.size) {
|
||||
end = file.file.size - 1;
|
||||
}
|
||||
const chunksize = end - start + 1;
|
||||
const fileStream = fs.createReadStream(file.path, {
|
||||
start,
|
||||
end,
|
||||
});
|
||||
reply.header('Content-Range', `bytes ${start}-${end}/${file.file.size}`);
|
||||
reply.header('Accept-Ranges', 'bytes');
|
||||
reply.header('Content-Length', chunksize);
|
||||
reply.code(206);
|
||||
return fileStream;
|
||||
}
|
||||
|
||||
return fs.createReadStream(file.path);
|
||||
}
|
||||
} catch (e) {
|
||||
|
@ -338,11 +405,35 @@ export class FileServerService {
|
|||
}
|
||||
|
||||
if (!image) {
|
||||
image = {
|
||||
data: fs.createReadStream(file.path),
|
||||
ext: file.ext,
|
||||
type: file.mime,
|
||||
};
|
||||
if (request.headers.range && file.file && file.file.size > 0) {
|
||||
const range = request.headers.range as string;
|
||||
const parts = range.replace(/bytes=/, '').split('-');
|
||||
const start = parseInt(parts[0], 10);
|
||||
let end = parts[1] ? parseInt(parts[1], 10) : file.file.size - 1;
|
||||
if (end > file.file.size) {
|
||||
end = file.file.size - 1;
|
||||
}
|
||||
const chunksize = end - start + 1;
|
||||
|
||||
image = {
|
||||
data: fs.createReadStream(file.path, {
|
||||
start,
|
||||
end,
|
||||
}),
|
||||
ext: file.ext,
|
||||
type: file.mime,
|
||||
};
|
||||
|
||||
reply.header('Content-Range', `bytes ${start}-${end}/${file.file.size}`);
|
||||
reply.header('Accept-Ranges', 'bytes');
|
||||
reply.header('Content-Length', chunksize);
|
||||
} else {
|
||||
image = {
|
||||
data: fs.createReadStream(file.path),
|
||||
ext: file.ext,
|
||||
type: file.mime,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if ('cleanup' in file) {
|
||||
|
|
|
@ -11,8 +11,6 @@ import { DI } from '@/di-symbols.js';
|
|||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
|
||||
export const meta = {
|
||||
tags: [],
|
||||
|
||||
allowGet: true,
|
||||
cacheSec: 60,
|
||||
|
||||
|
|
|
@ -12,8 +12,6 @@ import { DI } from '@/di-symbols.js';
|
|||
import { ApiError } from '../../error.js';
|
||||
|
||||
export const meta = {
|
||||
tags: [],
|
||||
|
||||
requireCredential: true,
|
||||
|
||||
kind: 'write:account',
|
||||
|
|
|
@ -36,7 +36,7 @@ export const paramDef = {
|
|||
untilId: { type: 'string', format: 'misskey:id' },
|
||||
folderId: { type: 'string', format: 'misskey:id', nullable: true, default: null },
|
||||
type: { type: 'string', nullable: true, pattern: /^[a-zA-Z\/\-*]+$/.toString().slice(1, -1) },
|
||||
sort: { type: 'string', nullable: true, enum: ['+createdAt', '-createdAt', '+name', '-name', '+size', '-size'] },
|
||||
sort: { type: 'string', nullable: true, enum: ['+createdAt', '-createdAt', '+name', '-name', '+size', '-size', null] },
|
||||
},
|
||||
required: [],
|
||||
} as const;
|
||||
|
|
|
@ -74,7 +74,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
}
|
||||
|
||||
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId);
|
||||
query.andWhere(':file = ANY(note.fileIds)', { file: file.id });
|
||||
query.andWhere(':file <@ note.fileIds', { file: [file.id] });
|
||||
|
||||
const notes = await query.limit(ps.limit).getMany();
|
||||
|
||||
|
|
|
@ -62,6 +62,7 @@ export const paramDef = {
|
|||
'-firstRetrievedAt',
|
||||
'+latestRequestReceivedAt',
|
||||
'-latestRequestReceivedAt',
|
||||
null,
|
||||
],
|
||||
},
|
||||
},
|
||||
|
|
|
@ -6,6 +6,7 @@
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import type { UsersRepository } from '@/models/_.js';
|
||||
import { safeForSql } from "@/misc/safe-for-sql.js";
|
||||
import { normalizeForSearch } from '@/misc/normalize-for-search.js';
|
||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
|
@ -47,8 +48,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
private userEntityService: UserEntityService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
if (!safeForSql(normalizeForSearch(ps.tag))) throw new Error('Injection');
|
||||
const query = this.usersRepository.createQueryBuilder('user')
|
||||
.where(':tag = ANY(user.tags)', { tag: normalizeForSearch(ps.tag) })
|
||||
.where(':tag <@ user.tags', { tag: [normalizeForSearch(ps.tag)] })
|
||||
.andWhere('user.isSuspended = FALSE');
|
||||
|
||||
const recent = new Date(Date.now() - (1000 * 60 * 60 * 24 * 5));
|
||||
|
|
|
@ -104,13 +104,13 @@ export const meta = {
|
|||
items: {
|
||||
type: 'string',
|
||||
enum: [
|
||||
"ble",
|
||||
"cable",
|
||||
"hybrid",
|
||||
"internal",
|
||||
"nfc",
|
||||
"smart-card",
|
||||
"usb",
|
||||
'ble',
|
||||
'cable',
|
||||
'hybrid',
|
||||
'internal',
|
||||
'nfc',
|
||||
'smart-card',
|
||||
'usb',
|
||||
],
|
||||
},
|
||||
},
|
||||
|
@ -124,8 +124,8 @@ export const meta = {
|
|||
authenticatorAttachment: {
|
||||
type: 'string',
|
||||
enum: [
|
||||
"cross-platform",
|
||||
"platform",
|
||||
'cross-platform',
|
||||
'platform',
|
||||
],
|
||||
},
|
||||
requireResidentKey: {
|
||||
|
@ -134,9 +134,9 @@ export const meta = {
|
|||
userVerification: {
|
||||
type: 'string',
|
||||
enum: [
|
||||
"discouraged",
|
||||
"preferred",
|
||||
"required",
|
||||
'discouraged',
|
||||
'preferred',
|
||||
'required',
|
||||
],
|
||||
},
|
||||
},
|
||||
|
@ -145,10 +145,11 @@ export const meta = {
|
|||
type: 'string',
|
||||
nullable: true,
|
||||
enum: [
|
||||
"direct",
|
||||
"enterprise",
|
||||
"indirect",
|
||||
"none",
|
||||
'direct',
|
||||
'enterprise',
|
||||
'indirect',
|
||||
'none',
|
||||
null,
|
||||
],
|
||||
},
|
||||
extensions: {
|
||||
|
|
|
@ -34,11 +34,10 @@ describe('api:notes/create', () => {
|
|||
.toBe(VALID);
|
||||
});
|
||||
|
||||
// TODO
|
||||
//test('null post', () => {
|
||||
// expect(v({ text: null }))
|
||||
// .toBe(INVALID);
|
||||
//});
|
||||
test('null post', () => {
|
||||
expect(v({ text: null }))
|
||||
.toBe(INVALID);
|
||||
});
|
||||
|
||||
test('0 characters post', () => {
|
||||
expect(v({ text: '' }))
|
||||
|
@ -49,6 +48,11 @@ describe('api:notes/create', () => {
|
|||
expect(v({ text: await tooLong }))
|
||||
.toBe(INVALID);
|
||||
});
|
||||
|
||||
test('whitespace-only post', () => {
|
||||
expect(v({ text: ' ' }))
|
||||
.toBe(INVALID);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cw', () => {
|
||||
|
|
|
@ -177,13 +177,32 @@ export const paramDef = {
|
|||
},
|
||||
},
|
||||
// (re)note with text, files and poll are optional
|
||||
anyOf: [
|
||||
{ required: ['text'] },
|
||||
{ required: ['renoteId'] },
|
||||
{ required: ['fileIds'] },
|
||||
{ required: ['mediaIds'] },
|
||||
{ required: ['poll'] },
|
||||
],
|
||||
if: {
|
||||
properties: {
|
||||
renoteId: {
|
||||
type: 'null',
|
||||
},
|
||||
fileIds: {
|
||||
type: 'null',
|
||||
},
|
||||
mediaIds: {
|
||||
type: 'null',
|
||||
},
|
||||
poll: {
|
||||
type: 'null',
|
||||
},
|
||||
},
|
||||
},
|
||||
then: {
|
||||
properties: {
|
||||
text: {
|
||||
type: 'string',
|
||||
minLength: 1,
|
||||
pattern: '[^\\s]+',
|
||||
},
|
||||
},
|
||||
required: ['text'],
|
||||
},
|
||||
} as const;
|
||||
|
||||
@Injectable()
|
||||
|
|
|
@ -61,9 +61,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
|
||||
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId)
|
||||
.andWhere(new Brackets(qb => {
|
||||
qb
|
||||
.where(`'{"${me.id}"}' <@ note.mentions`)
|
||||
.orWhere(`'{"${me.id}"}' <@ note.visibleUserIds`);
|
||||
qb // このmeIdAsListパラメータはqueryServiceのgenerateVisibilityQueryでセットされる
|
||||
.where(':meIdAsList <@ note.mentions')
|
||||
.orWhere(':meIdAsList <@ note.visibleUserIds');
|
||||
}))
|
||||
// Avoid scanning primary key index
|
||||
.orderBy('CONCAT(note.id)', 'DESC')
|
||||
|
|
|
@ -104,14 +104,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
try {
|
||||
if (ps.tag) {
|
||||
if (!safeForSql(normalizeForSearch(ps.tag))) throw new Error('Injection');
|
||||
query.andWhere(`'{"${normalizeForSearch(ps.tag)}"}' <@ note.tags`);
|
||||
query.andWhere(':tag <@ note.tags', { tag: [normalizeForSearch(ps.tag)] });
|
||||
} else {
|
||||
query.andWhere(new Brackets(qb => {
|
||||
for (const tags of ps.query!) {
|
||||
qb.orWhere(new Brackets(qb => {
|
||||
for (const tag of tags) {
|
||||
if (!safeForSql(normalizeForSearch(tag))) throw new Error('Injection');
|
||||
qb.andWhere(`'{"${normalizeForSearch(tag)}"}' <@ note.tags`);
|
||||
qb.andWhere(':tag <@ note.tags', { tag: [normalizeForSearch(tag)] });
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
|
|
@ -10,7 +10,7 @@ import { schemas, convertSchemaToOpenApiSchema } from './schemas.js';
|
|||
|
||||
export function genOpenapiSpec(config: Config) {
|
||||
const spec = {
|
||||
openapi: '3.0.0',
|
||||
openapi: '3.1.0',
|
||||
|
||||
info: {
|
||||
version: config.version,
|
||||
|
@ -56,7 +56,7 @@ export function genOpenapiSpec(config: Config) {
|
|||
}
|
||||
}
|
||||
|
||||
const resSchema = endpoint.meta.res ? convertSchemaToOpenApiSchema(endpoint.meta.res) : {};
|
||||
const resSchema = endpoint.meta.res ? convertSchemaToOpenApiSchema(endpoint.meta.res, 'res') : {};
|
||||
|
||||
let desc = (endpoint.meta.description ? endpoint.meta.description : 'No description provided.') + '\n\n';
|
||||
|
||||
|
@ -71,7 +71,7 @@ export function genOpenapiSpec(config: Config) {
|
|||
}
|
||||
|
||||
const requestType = endpoint.meta.requireFile ? 'multipart/form-data' : 'application/json';
|
||||
const schema = { ...endpoint.params };
|
||||
const schema = { ...convertSchemaToOpenApiSchema(endpoint.params, 'param') };
|
||||
|
||||
if (endpoint.meta.requireFile) {
|
||||
schema.properties = {
|
||||
|
@ -210,7 +210,9 @@ export function genOpenapiSpec(config: Config) {
|
|||
};
|
||||
|
||||
spec.paths['/' + endpoint.name] = {
|
||||
...(endpoint.meta.allowGet ? { get: info } : {}),
|
||||
...(endpoint.meta.allowGet ? {
|
||||
get: info,
|
||||
} : {}),
|
||||
post: info,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -6,32 +6,35 @@
|
|||
import type { Schema } from '@/misc/json-schema.js';
|
||||
import { refs } from '@/misc/json-schema.js';
|
||||
|
||||
export function convertSchemaToOpenApiSchema(schema: Schema) {
|
||||
// optional, refはスキーマ定義に含まれないので分離しておく
|
||||
export function convertSchemaToOpenApiSchema(schema: Schema, type: 'param' | 'res') {
|
||||
// optional, nullable, refはスキーマ定義に含まれないので分離しておく
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { optional, ref, ...res }: any = schema;
|
||||
const { optional, nullable, ref, ...res }: any = schema;
|
||||
|
||||
if (schema.type === 'object' && schema.properties) {
|
||||
const required = Object.entries(schema.properties).filter(([k, v]) => !v.optional).map(([k]) => k);
|
||||
if (required.length > 0) {
|
||||
if (type === 'res') {
|
||||
const required = Object.entries(schema.properties).filter(([k, v]) => !v.optional).map(([k]) => k);
|
||||
if (required.length > 0) {
|
||||
// 空配列は許可されない
|
||||
res.required = required;
|
||||
res.required = required;
|
||||
}
|
||||
}
|
||||
|
||||
for (const k of Object.keys(schema.properties)) {
|
||||
res.properties[k] = convertSchemaToOpenApiSchema(schema.properties[k]);
|
||||
res.properties[k] = convertSchemaToOpenApiSchema(schema.properties[k], type);
|
||||
}
|
||||
}
|
||||
|
||||
if (schema.type === 'array' && schema.items) {
|
||||
res.items = convertSchemaToOpenApiSchema(schema.items);
|
||||
res.items = convertSchemaToOpenApiSchema(schema.items, type);
|
||||
}
|
||||
|
||||
if (schema.anyOf) res.anyOf = schema.anyOf.map(convertSchemaToOpenApiSchema);
|
||||
if (schema.oneOf) res.oneOf = schema.oneOf.map(convertSchemaToOpenApiSchema);
|
||||
if (schema.allOf) res.allOf = schema.allOf.map(convertSchemaToOpenApiSchema);
|
||||
for (const o of ['anyOf', 'oneOf', 'allOf'] as const) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
if (o in schema) res[o] = schema[o]!.map(schema => convertSchemaToOpenApiSchema(schema, type));
|
||||
}
|
||||
|
||||
if (schema.ref) {
|
||||
if (type === 'res' && schema.ref) {
|
||||
const $ref = `#/components/schemas/${schema.ref}`;
|
||||
if (schema.nullable || schema.optional) {
|
||||
res.allOf = [{ $ref }];
|
||||
|
@ -40,6 +43,14 @@ export function convertSchemaToOpenApiSchema(schema: Schema) {
|
|||
}
|
||||
}
|
||||
|
||||
if (schema.nullable) {
|
||||
if (Array.isArray(schema.type) && !schema.type.includes('null')) {
|
||||
res.type.push('null');
|
||||
} else if (typeof schema.type === 'string') {
|
||||
res.type = [res.type, 'null'];
|
||||
}
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
|
@ -72,6 +83,6 @@ export const schemas = {
|
|||
},
|
||||
|
||||
...Object.fromEntries(
|
||||
Object.entries(refs).map(([key, schema]) => [key, convertSchemaToOpenApiSchema(schema)]),
|
||||
Object.entries(refs).map(([key, schema]) => [key, convertSchemaToOpenApiSchema(schema, 'res')]),
|
||||
),
|
||||
};
|
||||
|
|
95
packages/backend/test/e2e/drive.ts
Normal file
95
packages/backend/test/e2e/drive.ts
Normal file
|
@ -0,0 +1,95 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
|
||||
import * as assert from 'assert';
|
||||
import { MiNote } from '@/models/Note.js';
|
||||
import { api, initTestDb, makeStreamCatcher, post, signup, uploadFile } from '../utils.js';
|
||||
import type * as misskey from 'misskey-js';
|
||||
import type{ Repository } from 'typeorm'
|
||||
import type { Packed } from '@/misc/json-schema.js';
|
||||
|
||||
|
||||
describe('Drive', () => {
|
||||
let Notes: Repository<MiNote>;
|
||||
|
||||
let alice: misskey.entities.SignupResponse;
|
||||
let bob: misskey.entities.SignupResponse;
|
||||
|
||||
beforeAll(async () => {
|
||||
const connection = await initTestDb(true);
|
||||
Notes = connection.getRepository(MiNote);
|
||||
alice = await signup({ username: 'alice' });
|
||||
bob = await signup({ username: 'bob' });
|
||||
}, 1000 * 60 * 2);
|
||||
|
||||
test('ファイルURLからアップロードできる', async () => {
|
||||
// utils.js uploadUrl の処理だがAPIレスポンスも見るためここで同様の処理を書いている
|
||||
|
||||
const marker = Math.random().toString();
|
||||
|
||||
const url = 'https://raw.githubusercontent.com/misskey-dev/misskey/develop/packages/backend/test/resources/Lenna.jpg'
|
||||
|
||||
const catcher = makeStreamCatcher(
|
||||
alice,
|
||||
'main',
|
||||
(msg) => msg.type === 'urlUploadFinished' && msg.body.marker === marker,
|
||||
(msg) => msg.body.file as Packed<'DriveFile'>,
|
||||
10 * 1000);
|
||||
|
||||
const res = await api('drive/files/upload-from-url', {
|
||||
url,
|
||||
marker,
|
||||
force: true,
|
||||
}, alice);
|
||||
|
||||
const file = await catcher;
|
||||
|
||||
assert.strictEqual(res.status, 204);
|
||||
assert.strictEqual(file.name, 'Lenna.jpg');
|
||||
assert.strictEqual(file.type, 'image/jpeg');
|
||||
})
|
||||
|
||||
test('ローカルからアップロードできる', async () => {
|
||||
// APIレスポンスを直接使用するので utils.js uploadFile が通過することで成功とする
|
||||
|
||||
const res = await uploadFile(alice, { path: 'Lenna.jpg', name: 'テスト画像' });
|
||||
|
||||
assert.strictEqual(res.body?.name, 'テスト画像.jpg');
|
||||
assert.strictEqual(res.body?.type, 'image/jpeg');
|
||||
})
|
||||
|
||||
test('添付ノート一覧を取得できる', async () => {
|
||||
const ids = (await Promise.all([uploadFile(alice), uploadFile(alice), uploadFile(alice)])).map(elm => elm.body!.id)
|
||||
|
||||
const note0 = await post(alice, { fileIds: [ids[0]] });
|
||||
const note1 = await post(alice, { fileIds: [ids[0], ids[1]] });
|
||||
|
||||
const attached0 = await api('drive/files/attached-notes', { fileId: ids[0] }, alice);
|
||||
assert.strictEqual(attached0.body.length, 2);
|
||||
assert.strictEqual(attached0.body[0].id, note1.id)
|
||||
assert.strictEqual(attached0.body[1].id, note0.id)
|
||||
|
||||
const attached1 = await api('drive/files/attached-notes', { fileId: ids[1] }, alice);
|
||||
assert.strictEqual(attached1.body.length, 1);
|
||||
assert.strictEqual(attached1.body[0].id, note1.id)
|
||||
|
||||
const attached2 = await api('drive/files/attached-notes', { fileId: ids[2] }, alice);
|
||||
assert.strictEqual(attached2.body.length, 0)
|
||||
})
|
||||
|
||||
test('添付ノート一覧は他の人から見えない', async () => {
|
||||
const file = await uploadFile(alice);
|
||||
|
||||
await post(alice, { fileIds: [file.body!.id] });
|
||||
|
||||
const res = await api('drive/files/attached-notes', { fileId: file.body!.id }, bob);
|
||||
assert.strictEqual(res.status, 400);
|
||||
assert.strictEqual('error' in res.body, true);
|
||||
|
||||
})
|
||||
});
|
||||
|
|
@ -136,6 +136,19 @@ describe('Note', () => {
|
|||
assert.strictEqual(res.body.createdNote.renote.text, bobPost.text);
|
||||
});
|
||||
|
||||
test('引用renoteで空白文字のみで構成されたtextにするとレスポンスがtext: nullになる', async () => {
|
||||
const bobPost = await post(bob, {
|
||||
text: 'test',
|
||||
});
|
||||
const res = await api('/notes/create', {
|
||||
text: ' ',
|
||||
renoteId: bobPost.id,
|
||||
}, alice);
|
||||
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.strictEqual(res.body.createdNote.text, null);
|
||||
});
|
||||
|
||||
test('visibility: followersでrenoteできる', async () => {
|
||||
const createRes = await api('/notes/create', {
|
||||
text: 'test',
|
||||
|
|
|
@ -16,6 +16,7 @@ import { DEFAULT_POLICIES } from '@/core/RoleService.js';
|
|||
import { entities } from '../src/postgres.js';
|
||||
import { loadConfig } from '../src/config.js';
|
||||
import type * as misskey from 'misskey-js';
|
||||
import { Packed } from '@/misc/json-schema.js';
|
||||
|
||||
export { server as startServer, jobQueue as startJobQueue } from '@/boot/common.js';
|
||||
|
||||
|
@ -114,6 +115,20 @@ export function randomString(chars = 'abcdefghijklmnopqrstuvwxyz0123456789', len
|
|||
return randomString;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief プロミスにタイムアウト追加
|
||||
* @param p 待ち対象プロミス
|
||||
* @param timeout 待機ミリ秒
|
||||
*/
|
||||
function timeoutPromise<T>(p: Promise<T>, timeout: number): Promise<T> {
|
||||
return Promise.race([
|
||||
p,
|
||||
new Promise((reject) =>{
|
||||
setTimeout(() => { reject(new Error('timed out')); }, timeout)
|
||||
}) as never
|
||||
]);
|
||||
}
|
||||
|
||||
export const signup = async (params?: Partial<misskey.Endpoints['signup']['req']>): Promise<NonNullable<misskey.Endpoints['signup']['res']>> => {
|
||||
const q = Object.assign({
|
||||
username: randomString(),
|
||||
|
@ -320,17 +335,16 @@ export const uploadFile = async (user?: UserToken, { path, name, blob }: UploadO
|
|||
};
|
||||
};
|
||||
|
||||
export const uploadUrl = async (user: UserToken, url: string) => {
|
||||
let resolve: unknown;
|
||||
const file = new Promise(ok => resolve = ok);
|
||||
export const uploadUrl = async (user: UserToken, url: string): Promise<Packed<'DriveFile'>> => {
|
||||
const marker = Math.random().toString();
|
||||
|
||||
const ws = await connectStream(user, 'main', (msg) => {
|
||||
if (msg.type === 'urlUploadFinished' && msg.body.marker === marker) {
|
||||
ws.close();
|
||||
resolve(msg.body.file);
|
||||
}
|
||||
});
|
||||
const catcher = makeStreamCatcher(
|
||||
user,
|
||||
'main',
|
||||
(msg) => msg.type === 'urlUploadFinished' && msg.body.marker === marker,
|
||||
(msg) => msg.body.file as Packed<'DriveFile'>,
|
||||
60 * 1000
|
||||
);
|
||||
|
||||
await api('drive/files/upload-from-url', {
|
||||
url,
|
||||
|
@ -338,7 +352,7 @@ export const uploadUrl = async (user: UserToken, url: string) => {
|
|||
force: true,
|
||||
}, user);
|
||||
|
||||
return file;
|
||||
return catcher;
|
||||
};
|
||||
|
||||
export function connectStream(user: UserToken, channel: string, listener: (message: Record<string, any>) => any, params?: any): Promise<WebSocket> {
|
||||
|
@ -410,6 +424,35 @@ export const waitFire = async (user: UserToken, channel: string, trgr: () => any
|
|||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief WebSocketストリームから特定条件の通知を拾うプロミスを生成
|
||||
* @param user ユーザー認証情報
|
||||
* @param channel チャンネル
|
||||
* @param cond 条件
|
||||
* @param extractor 取り出し処理
|
||||
* @param timeout ミリ秒タイムアウト
|
||||
* @returns 時間内に正常に処理できた場合に通知からextractorを通した値を得る
|
||||
*/
|
||||
export function makeStreamCatcher<T>(
|
||||
user: UserToken,
|
||||
channel: string,
|
||||
cond: (message: Record<string, any>) => boolean,
|
||||
extractor: (message: Record<string, any>) => T,
|
||||
timeout = 60 * 1000): Promise<T> {
|
||||
let ws: WebSocket
|
||||
const p = new Promise<T>(async (resolve) => {
|
||||
ws = await connectStream(user, channel, (msg) => {
|
||||
if (cond(msg)) {
|
||||
resolve(extractor(msg))
|
||||
}
|
||||
});
|
||||
}).finally(() => {
|
||||
ws?.close();
|
||||
});
|
||||
|
||||
return timeoutPromise(p, timeout);
|
||||
}
|
||||
|
||||
export type SimpleGetResponse = {
|
||||
status: number,
|
||||
body: any | JSDOM | null,
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue