Merge branch 'develop' into misskey-js
This commit is contained in:
commit
9044fa5d1a
32 changed files with 1127 additions and 357 deletions
|
|
@ -0,0 +1,15 @@
|
|||
export class fixforeignkeyreports1675053125067 {
|
||||
name = 'fixforeignkeyreports1675053125067'
|
||||
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_a9021cc2e1feb5f72d3db6e9f5" ON "abuse_user_report" ("targetUserId")`);
|
||||
await queryRunner.query(`DELETE FROM "abuse_user_report" WHERE "targetUserId" NOT IN (SELECT "id" FROM "user")`);
|
||||
await queryRunner.query(`ALTER TABLE "abuse_user_report" DROP CONSTRAINT IF EXISTS "FK_a9021cc2e1feb5f72d3db6e9f5f"`);
|
||||
await queryRunner.query(`ALTER TABLE "abuse_user_report" ADD CONSTRAINT "FK_a9021cc2e1feb5f72d3db6e9f5f" FOREIGN KEY ("targetUserId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_a9021cc2e1feb5f72d3db6e9f5"`);
|
||||
await queryRunner.query(`ALTER TABLE "abuse_user_report" DROP CONSTRAINT "FK_a9021cc2e1feb5f72d3db6e9f5f"`);
|
||||
}
|
||||
}
|
||||
|
|
@ -223,6 +223,7 @@ import * as ep___i_webhooks_update from './endpoints/i/webhooks/update.js';
|
|||
import * as ep___i_webhooks_delete from './endpoints/i/webhooks/delete.js';
|
||||
import * as ep___meta from './endpoints/meta.js';
|
||||
import * as ep___emojis from './endpoints/emojis.js';
|
||||
import * as ep___emoji from './endpoints/emoji.js';
|
||||
import * as ep___miauth_genToken from './endpoints/miauth/gen-token.js';
|
||||
import * as ep___mute_create from './endpoints/mute/create.js';
|
||||
import * as ep___mute_delete from './endpoints/mute/delete.js';
|
||||
|
|
@ -550,6 +551,7 @@ const $i_webhooks_update: Provider = { provide: 'ep:i/webhooks/update', useClass
|
|||
const $i_webhooks_delete: Provider = { provide: 'ep:i/webhooks/delete', useClass: ep___i_webhooks_delete.default };
|
||||
const $meta: Provider = { provide: 'ep:meta', useClass: ep___meta.default };
|
||||
const $emojis: Provider = { provide: 'ep:emojis', useClass: ep___emojis.default };
|
||||
const $emoji: Provider = { provide: 'ep:emoji', useClass: ep___emoji.default };
|
||||
const $miauth_genToken: Provider = { provide: 'ep:miauth/gen-token', useClass: ep___miauth_genToken.default };
|
||||
const $mute_create: Provider = { provide: 'ep:mute/create', useClass: ep___mute_create.default };
|
||||
const $mute_delete: Provider = { provide: 'ep:mute/delete', useClass: ep___mute_delete.default };
|
||||
|
|
@ -881,6 +883,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
|
|||
$i_webhooks_delete,
|
||||
$meta,
|
||||
$emojis,
|
||||
$emoji,
|
||||
$miauth_genToken,
|
||||
$mute_create,
|
||||
$mute_delete,
|
||||
|
|
@ -1206,6 +1209,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
|
|||
$i_webhooks_delete,
|
||||
$meta,
|
||||
$emojis,
|
||||
$emoji,
|
||||
$miauth_genToken,
|
||||
$mute_create,
|
||||
$mute_delete,
|
||||
|
|
|
|||
|
|
@ -223,6 +223,7 @@ import * as ep___i_webhooks_update from './endpoints/i/webhooks/update.js';
|
|||
import * as ep___i_webhooks_delete from './endpoints/i/webhooks/delete.js';
|
||||
import * as ep___meta from './endpoints/meta.js';
|
||||
import * as ep___emojis from './endpoints/emojis.js';
|
||||
import * as ep___emoji from './endpoints/emoji.js';
|
||||
import * as ep___miauth_genToken from './endpoints/miauth/gen-token.js';
|
||||
import * as ep___mute_create from './endpoints/mute/create.js';
|
||||
import * as ep___mute_delete from './endpoints/mute/delete.js';
|
||||
|
|
@ -548,6 +549,7 @@ const eps = [
|
|||
['i/webhooks/delete', ep___i_webhooks_delete],
|
||||
['meta', ep___meta],
|
||||
['emojis', ep___emojis],
|
||||
['emoji', ep___emoji],
|
||||
['miauth/gen-token', ep___miauth_genToken],
|
||||
['mute/create', ep___mute_create],
|
||||
['mute/delete', ep___mute_delete],
|
||||
|
|
|
|||
56
packages/backend/src/server/api/endpoints/emoji.ts
Normal file
56
packages/backend/src/server/api/endpoints/emoji.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import { IsNull } from 'typeorm';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type { EmojisRepository } from '@/models/index.js';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['meta'],
|
||||
|
||||
requireCredential: false,
|
||||
allowGet: true,
|
||||
cacheSec: 3600,
|
||||
|
||||
res: {
|
||||
type: 'object',
|
||||
optional: false, nullable: false,
|
||||
ref: 'EmojiDetailed',
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
required: ['name'],
|
||||
} as const;
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
@Inject(DI.emojisRepository)
|
||||
private emojisRepository: EmojisRepository,
|
||||
|
||||
private emojiEntityService: EmojiEntityService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const emoji = await this.emojisRepository.findOneOrFail({
|
||||
where: {
|
||||
name: ps.name,
|
||||
host: IsNull(),
|
||||
},
|
||||
});
|
||||
|
||||
return this.emojiEntityService.packDetailed(emoji);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -23,24 +23,7 @@ export const meta = {
|
|||
items: {
|
||||
type: 'object',
|
||||
optional: false, nullable: false,
|
||||
properties: {
|
||||
name: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
aliases: {
|
||||
type: 'array',
|
||||
optional: false, nullable: false,
|
||||
items: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
},
|
||||
category: {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
},
|
||||
},
|
||||
ref: 'EmojiSimple',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { MetaService } from '@/core/MetaService.js';
|
|||
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { RoleService } from '@/core/RoleService.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { ApiError } from '../../error.js';
|
||||
|
||||
export const meta = {
|
||||
|
|
@ -69,6 +70,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
|||
private metaService: MetaService,
|
||||
private roleService: RoleService,
|
||||
private activeUsersChart: ActiveUsersChart,
|
||||
private idService: IdService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const policies = await this.roleService.getUserPolicies(me.id);
|
||||
|
|
@ -83,7 +85,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
|||
|
||||
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'),
|
||||
ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
|
||||
.andWhere('note.createdAt > :minDate', { minDate: new Date(Date.now() - (1000 * 60 * 60 * 24 * 30)) }) // 30日前まで
|
||||
.andWhere('note.id > :minId', { minId: this.idService.genId(new Date(Date.now() - (1000 * 60 * 60 * 24 * 10))) }) // 10日前まで
|
||||
.andWhere(new Brackets(qb => {
|
||||
qb.where(`((note.userId IN (${ followingQuery.getQuery() })) OR (note.userId = :meId))`, { meId: me.id })
|
||||
.orWhere('(note.visibility = \'public\') AND (note.userHost IS NULL)');
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { MetaService } from '@/core/MetaService.js';
|
|||
import ActiveUsersChart from '@/core/chart/charts/active-users.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { RoleService } from '@/core/RoleService.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { ApiError } from '../../error.js';
|
||||
|
||||
export const meta = {
|
||||
|
|
@ -65,6 +66,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
|||
private metaService: MetaService,
|
||||
private roleService: RoleService,
|
||||
private activeUsersChart: ActiveUsersChart,
|
||||
private idService: IdService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const policies = await this.roleService.getUserPolicies(me ? me.id : null);
|
||||
|
|
@ -75,7 +77,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
|||
//#region Construct query
|
||||
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'),
|
||||
ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
|
||||
.andWhere('note.createdAt > :minDate', { minDate: new Date(Date.now() - (1000 * 60 * 60 * 24 * 30)) }) // 30日前まで
|
||||
.andWhere('note.id > :minId', { minId: this.idService.genId(new Date(Date.now() - (1000 * 60 * 60 * 24 * 10))) }) // 10日前まで
|
||||
.andWhere('(note.visibility = \'public\') AND (note.userHost IS NULL)')
|
||||
.innerJoinAndSelect('note.user', 'user')
|
||||
.leftJoinAndSelect('user.avatar', 'avatar')
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { QueryService } from '@/core/QueryService.js';
|
|||
import ActiveUsersChart from '@/core/chart/charts/active-users.js';
|
||||
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['notes'],
|
||||
|
|
@ -56,6 +57,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
|||
private noteEntityService: NoteEntityService,
|
||||
private queryService: QueryService,
|
||||
private activeUsersChart: ActiveUsersChart,
|
||||
private idService: IdService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const followees = await this.followingsRepository.createQueryBuilder('following')
|
||||
|
|
@ -66,7 +68,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
|||
//#region Construct query
|
||||
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'),
|
||||
ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
|
||||
.andWhere('note.createdAt > :minDate', { minDate: new Date(Date.now() - (1000 * 60 * 60 * 24 * 30)) }) // 30日前まで
|
||||
.andWhere('note.id > :minId', { minId: this.idService.genId(new Date(Date.now() - (1000 * 60 * 60 * 24 * 10))) }) // 10日前まで
|
||||
.innerJoinAndSelect('note.user', 'user')
|
||||
.leftJoinAndSelect('user.avatar', 'avatar')
|
||||
.leftJoinAndSelect('user.banner', 'banner')
|
||||
|
|
|
|||
|
|
@ -19,9 +19,6 @@ export class UrlPreviewService {
|
|||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
private metaService: MetaService,
|
||||
private httpRequestService: HttpRequestService,
|
||||
private loggerService: LoggerService,
|
||||
|
|
@ -51,15 +48,15 @@ export class UrlPreviewService {
|
|||
reply.code(400);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const lang = request.query.lang;
|
||||
if (Array.isArray(lang)) {
|
||||
reply.code(400);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const meta = await this.metaService.fetch();
|
||||
|
||||
|
||||
this.logger.info(meta.summalyProxy
|
||||
? `(Proxy) Getting preview of ${url}@${lang} ...`
|
||||
: `Getting preview of ${url}@${lang} ...`);
|
||||
|
|
@ -85,16 +82,16 @@ export class UrlPreviewService {
|
|||
throw new Error('unsupported schema included');
|
||||
}
|
||||
|
||||
if (summary.player?.url && !(summary.player.url.startsWith('http://') || summary.player.url.startsWith('https://'))) {
|
||||
if (summary.player.url && !(summary.player.url.startsWith('http://') || summary.player.url.startsWith('https://'))) {
|
||||
throw new Error('unsupported schema included');
|
||||
}
|
||||
|
||||
|
||||
summary.icon = this.wrap(summary.icon);
|
||||
summary.thumbnail = this.wrap(summary.thumbnail);
|
||||
|
||||
|
||||
// Cache 7days
|
||||
reply.header('Cache-Control', 'max-age=604800, immutable');
|
||||
|
||||
|
||||
return summary;
|
||||
} catch (err) {
|
||||
this.logger.warn(`Failed to get preview of ${url}: ${err}`);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
process.env.NODE_ENV = 'test';
|
||||
|
||||
import * as assert from 'assert';
|
||||
import { startServer, signup, post, api, simpleGet } from '../utils.js';
|
||||
import { startServer, channel, clip, cookie, galleryPost, signup, page, play, post, simpleGet, uploadFile } from '../utils.js';
|
||||
import type { SimpleGetResponse } from '../utils.js';
|
||||
import type { INestApplicationContext } from '@nestjs/common';
|
||||
|
||||
// Request Accept
|
||||
|
|
@ -15,189 +16,446 @@ const AP = 'application/activity+json; charset=utf-8';
|
|||
const HTML = 'text/html; charset=utf-8';
|
||||
const JSON_UTF8 = 'application/json; charset=utf-8';
|
||||
|
||||
describe('Fetch resource', () => {
|
||||
describe('Webリソース', () => {
|
||||
let app: INestApplicationContext;
|
||||
|
||||
let alice: any;
|
||||
let aliceUploadedFile: any;
|
||||
let alicesPost: any;
|
||||
let alicePage: any;
|
||||
let alicePlay: any;
|
||||
let aliceClip: any;
|
||||
let aliceGalleryPost: any;
|
||||
let aliceChannel: any;
|
||||
|
||||
type Request = {
|
||||
path: string,
|
||||
accept?: string,
|
||||
cookie?: string,
|
||||
};
|
||||
const ok = async (param: Request & {
|
||||
type?: string,
|
||||
}):Promise<SimpleGetResponse> => {
|
||||
const { path, accept, cookie, type } = param;
|
||||
const res = await simpleGet(path, accept, cookie);
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.strictEqual(res.type, type ?? HTML);
|
||||
return res;
|
||||
};
|
||||
|
||||
const notOk = async (param: Request & {
|
||||
status?: number,
|
||||
code?: string,
|
||||
}): Promise<SimpleGetResponse> => {
|
||||
const { path, accept, cookie, status, code } = param;
|
||||
const res = await simpleGet(path, accept, cookie);
|
||||
assert.notStrictEqual(res.status, 200);
|
||||
if (status != null) {
|
||||
assert.strictEqual(res.status, status);
|
||||
}
|
||||
if (code != null) {
|
||||
assert.strictEqual(res.body.error.code, code);
|
||||
}
|
||||
return res;
|
||||
};
|
||||
|
||||
const notFound = async (param: Request): Promise<SimpleGetResponse> => {
|
||||
return await notOk({
|
||||
...param,
|
||||
status: 404,
|
||||
});
|
||||
};
|
||||
|
||||
const metaTag = (res: SimpleGetResponse, key: string, superkey = 'name'): string => {
|
||||
return res.body.window.document.querySelector('meta[' + superkey + '="' + key + '"]')?.content;
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await startServer();
|
||||
alice = await signup({ username: 'alice' });
|
||||
aliceUploadedFile = await uploadFile(alice);
|
||||
alicesPost = await post(alice, {
|
||||
text: 'test',
|
||||
});
|
||||
alicePage = await page(alice, {});
|
||||
alicePlay = await play(alice, {});
|
||||
aliceClip = await clip(alice, {});
|
||||
aliceGalleryPost = await galleryPost(alice, {
|
||||
fileIds: [aliceUploadedFile.body.id],
|
||||
});
|
||||
aliceChannel = await channel(alice, {});
|
||||
}, 1000 * 60 * 2);
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
describe('Common', () => {
|
||||
test('meta', async () => {
|
||||
const res = await api('/meta', {
|
||||
});
|
||||
describe.each([
|
||||
{ path: '/', type: HTML },
|
||||
{ path: '/docs/ja-JP/about', type: HTML }, // "指定されたURLに該当するページはありませんでした。"
|
||||
// fastify-static gives charset=UTF-8 instead of utf-8 and that's okay
|
||||
{ path: '/api-doc', type: 'text/html; charset=UTF-8' },
|
||||
{ path: '/api.json', type: JSON_UTF8 },
|
||||
{ path: '/api-console', type: HTML },
|
||||
{ path: '/_info_card_', type: HTML },
|
||||
{ path: '/bios', type: HTML },
|
||||
{ path: '/cli', type: HTML },
|
||||
{ path: '/flush', type: HTML },
|
||||
{ path: '/robots.txt', type: 'text/plain; charset=UTF-8' },
|
||||
{ path: '/favicon.ico', type: 'image/vnd.microsoft.icon' },
|
||||
{ path: '/opensearch.xml', type: 'application/opensearchdescription+xml' },
|
||||
{ path: '/apple-touch-icon.png', type: 'image/png' },
|
||||
{ path: '/twemoji/2764.svg', type: 'image/svg+xml' },
|
||||
{ path: '/twemoji/2764-fe0f-200d-1f525.svg', type: 'image/svg+xml' },
|
||||
{ path: '/twemoji-badge/2764.png', type: 'image/png' },
|
||||
{ path: '/twemoji-badge/2764-fe0f-200d-1f525.png', type: 'image/png' },
|
||||
{ path: '/fluent-emoji/2764.png', type: 'image/png' },
|
||||
{ path: '/fluent-emoji/2764-fe0f-200d-1f525.png', type: 'image/png' },
|
||||
])('$path', (p) => {
|
||||
test('がGETできる。', async () => await ok({ ...p }));
|
||||
|
||||
assert.strictEqual(res.status, 200);
|
||||
});
|
||||
// 注意: Webページが200で取得できても、実際のHTMLが正しく表示できるとは限らない
|
||||
// 例えば、 /@xxx/pages/yyy に存在しないIDを渡した場合、HTTPレスポンスではエラーを区別できない
|
||||
// こういったアサーションはフロントエンドE2EやAPI Endpointのテストで担保する。
|
||||
});
|
||||
|
||||
test('GET root', async () => {
|
||||
const res = await simpleGet('/');
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.strictEqual(res.type, HTML);
|
||||
});
|
||||
describe.each([
|
||||
{ path: '/twemoji/2764.png' },
|
||||
{ path: '/twemoji/2764-fe0f-200d-1f525.png' },
|
||||
{ path: '/twemoji-badge/2764.svg' },
|
||||
{ path: '/twemoji-badge/2764-fe0f-200d-1f525.svg' },
|
||||
{ path: '/fluent-emoji/2764.svg' },
|
||||
{ path: '/fluent-emoji/2764-fe0f-200d-1f525.svg' },
|
||||
])('$path', ({ path }) => {
|
||||
test('はGETできない。', async () => await notFound({ path }));
|
||||
});
|
||||
|
||||
test('GET docs', async () => {
|
||||
const res = await simpleGet('/docs/ja-JP/about');
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.strictEqual(res.type, HTML);
|
||||
});
|
||||
describe.each([
|
||||
{ ext: 'rss', type: 'application/rss+xml; charset=utf-8' },
|
||||
{ ext: 'atom', type: 'application/atom+xml; charset=utf-8' },
|
||||
{ ext: 'json', type: 'application/json; charset=utf-8' },
|
||||
])('/@:username.$ext', ({ ext, type }) => {
|
||||
const path = (username: string): string => `/@${username}.${ext}`;
|
||||
|
||||
test('GET api-doc', async () => {
|
||||
const res = await simpleGet('/api-doc');
|
||||
assert.strictEqual(res.status, 200);
|
||||
// fastify-static gives charset=UTF-8 instead of utf-8 and that's okay
|
||||
assert.strictEqual(res.type?.toLowerCase(), HTML);
|
||||
});
|
||||
test('がGETできる。', async () => await ok({
|
||||
path: path(alice.username),
|
||||
type,
|
||||
}));
|
||||
|
||||
test('GET api.json', async () => {
|
||||
const res = await simpleGet('/api.json');
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.strictEqual(res.type, JSON_UTF8);
|
||||
});
|
||||
test('は存在しないユーザーはGETできない。', async () => await notOk({
|
||||
path: path('nonexisting'),
|
||||
status: 404,
|
||||
}));
|
||||
});
|
||||
|
||||
test('GET api/foo (存在しない)', async () => {
|
||||
const res = await simpleGet('/api/foo');
|
||||
assert.strictEqual(res.status, 404);
|
||||
assert.strictEqual(res.body.error.code, 'UNKNOWN_API_ENDPOINT');
|
||||
});
|
||||
describe.each([{ path: '/api/foo' }])('$path', ({ path }) => {
|
||||
test('はGETできない。', async () => await notOk({
|
||||
path,
|
||||
status: 404,
|
||||
code: 'UNKNOWN_API_ENDPOINT',
|
||||
}));
|
||||
});
|
||||
|
||||
test('GET api-console (client page)', async () => {
|
||||
const res = await simpleGet('/api-console');
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.strictEqual(res.type, HTML);
|
||||
});
|
||||
describe.each([{ path: '/queue' }])('$path', ({ path }) => {
|
||||
test('はadminでなければGETできない。', async () => await notOk({
|
||||
path,
|
||||
status: 500, // FIXME? 403ではない。
|
||||
}));
|
||||
|
||||
test('はadminならGETできる。', async () => await ok({
|
||||
path,
|
||||
cookie: cookie(alice),
|
||||
}));
|
||||
});
|
||||
|
||||
test('GET favicon.ico', async () => {
|
||||
const res = await simpleGet('/favicon.ico');
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.strictEqual(res.type, 'image/vnd.microsoft.icon');
|
||||
});
|
||||
|
||||
test('GET apple-touch-icon.png', async () => {
|
||||
const res = await simpleGet('/apple-touch-icon.png');
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.strictEqual(res.type, 'image/png');
|
||||
});
|
||||
|
||||
test('GET twemoji svg', async () => {
|
||||
const res = await simpleGet('/twemoji/2764.svg');
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.strictEqual(res.type, 'image/svg+xml');
|
||||
});
|
||||
|
||||
test('GET twemoji svg with hyphen', async () => {
|
||||
const res = await simpleGet('/twemoji/2764-fe0f-200d-1f525.svg');
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.strictEqual(res.type, 'image/svg+xml');
|
||||
});
|
||||
describe.each([{ path: '/streaming' }])('$path', ({ path }) => {
|
||||
test('はGETできない。', async () => await notOk({
|
||||
path,
|
||||
status: 503,
|
||||
}));
|
||||
});
|
||||
|
||||
describe('/@:username', () => {
|
||||
test('Only AP => AP', async () => {
|
||||
const res = await simpleGet(`/@${alice.username}`, ONLY_AP);
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.strictEqual(res.type, AP);
|
||||
const path = (username: string): string => `/@${username}`;
|
||||
|
||||
describe.each([
|
||||
{ accept: PREFER_HTML },
|
||||
{ accept: UNSPECIFIED },
|
||||
])('(Acceptヘッダ: $accept)', ({ accept }) => {
|
||||
test('はHTMLとしてGETできる。', async () => {
|
||||
const res = await ok({
|
||||
path: path(alice.username),
|
||||
accept,
|
||||
type: HTML,
|
||||
});
|
||||
assert.strictEqual(metaTag(res, 'misskey:user-username'), alice.username);
|
||||
assert.strictEqual(metaTag(res, 'misskey:user-id'), alice.id);
|
||||
|
||||
// TODO ogタグの検証
|
||||
// TODO profile.noCrawleの検証
|
||||
// TODO twitter:creatorの検証
|
||||
// TODO <link rel="me" ...>の検証
|
||||
});
|
||||
test('はHTMLとしてGETできる。(存在しないIDでも。)', async () => await ok({
|
||||
path: path('xxxxxxxxxx'),
|
||||
type: HTML,
|
||||
}));
|
||||
});
|
||||
|
||||
test('Prefer AP => AP', async () => {
|
||||
const res = await simpleGet(`/@${alice.username}`, PREFER_AP);
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.strictEqual(res.type, AP);
|
||||
});
|
||||
describe.each([
|
||||
{ accept: ONLY_AP },
|
||||
{ accept: PREFER_AP },
|
||||
])('(Acceptヘッダ: $accept)', ({ accept }) => {
|
||||
test('はActivityPubとしてGETできる。', async () => {
|
||||
const res = await ok({
|
||||
path: path(alice.username),
|
||||
accept,
|
||||
type: AP,
|
||||
});
|
||||
assert.strictEqual(res.body.type, 'Person');
|
||||
});
|
||||
|
||||
test('Prefer HTML => HTML', async () => {
|
||||
const res = await simpleGet(`/@${alice.username}`, PREFER_HTML);
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.strictEqual(res.type, HTML);
|
||||
test('は存在しないIDのときActivityPubとしてGETできない。', async () => await notFound({
|
||||
path: path('xxxxxxxxxx'),
|
||||
accept,
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
test('Unspecified => HTML', async () => {
|
||||
const res = await simpleGet(`/@${alice.username}`, UNSPECIFIED);
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.strictEqual(res.type, HTML);
|
||||
describe.each([
|
||||
// 実際のハンドルはフロントエンド(index.vue)で行われる
|
||||
{ sub: 'home' },
|
||||
{ sub: 'notes' },
|
||||
{ sub: 'activity' },
|
||||
{ sub: 'achievements' },
|
||||
{ sub: 'reactions' },
|
||||
{ sub: 'clips' },
|
||||
{ sub: 'pages' },
|
||||
{ sub: 'gallery' },
|
||||
])('/@:username/$sub', ({ sub }) => {
|
||||
const path = (username: string): string => `/@${username}/${sub}`;
|
||||
|
||||
test('はHTMLとしてGETできる。', async () => {
|
||||
const res = await ok({
|
||||
path: path(alice.username),
|
||||
});
|
||||
assert.strictEqual(metaTag(res, 'misskey:user-username'), alice.username);
|
||||
assert.strictEqual(metaTag(res, 'misskey:user-id'), alice.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('/@:user/pages/:page', () => {
|
||||
const path = (username: string, pagename: string): string => `/@${username}/pages/${pagename}`;
|
||||
|
||||
test('はHTMLとしてGETできる。', async () => {
|
||||
const res = await ok({
|
||||
path: path(alice.username, alicePage.name),
|
||||
});
|
||||
assert.strictEqual(metaTag(res, 'misskey:user-username'), alice.username);
|
||||
assert.strictEqual(metaTag(res, 'misskey:user-id'), alice.id);
|
||||
assert.strictEqual(metaTag(res, 'misskey:page-id'), alicePage.id);
|
||||
|
||||
// TODO ogタグの検証
|
||||
// TODO profile.noCrawleの検証
|
||||
// TODO twitter:creatorの検証
|
||||
});
|
||||
|
||||
test('はGETできる。(存在しないIDでも。)', async () => await ok({
|
||||
path: path(alice.username, 'xxxxxxxxxx'),
|
||||
}));
|
||||
});
|
||||
|
||||
describe('/users/:id', () => {
|
||||
test('Only AP => AP', async () => {
|
||||
const res = await simpleGet(`/users/${alice.id}`, ONLY_AP);
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.strictEqual(res.type, AP);
|
||||
const path = (id: string): string => `/users/${id}`;
|
||||
|
||||
describe.each([
|
||||
{ accept: PREFER_HTML },
|
||||
{ accept: UNSPECIFIED },
|
||||
])('(Acceptヘッダ: $accept)', ({ accept }) => {
|
||||
test('は/@:usernameにリダイレクトする', async () => {
|
||||
const res = await simpleGet(path(alice.id), accept);
|
||||
assert.strictEqual(res.status, 302);
|
||||
assert.strictEqual(res.location, `/@${alice.username}`);
|
||||
});
|
||||
|
||||
test('は存在しないユーザーはGETできない。', async () => await notFound({
|
||||
path: path('xxxxxxxx'),
|
||||
}));
|
||||
});
|
||||
|
||||
test('Prefer AP => AP', async () => {
|
||||
const res = await simpleGet(`/users/${alice.id}`, PREFER_AP);
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.strictEqual(res.type, AP);
|
||||
});
|
||||
describe.each([
|
||||
{ accept: ONLY_AP },
|
||||
{ accept: PREFER_AP },
|
||||
])('(Acceptヘッダ: $accept)', ({ accept }) => {
|
||||
test('はActivityPubとしてGETできる。', async () => {
|
||||
const res = await ok({
|
||||
path: path(alice.id),
|
||||
accept,
|
||||
type: AP,
|
||||
});
|
||||
assert.strictEqual(res.body.type, 'Person');
|
||||
});
|
||||
|
||||
test('Prefer HTML => Redirect to /@:username', async () => {
|
||||
const res = await simpleGet(`/users/${alice.id}`, PREFER_HTML);
|
||||
assert.strictEqual(res.status, 302);
|
||||
assert.strictEqual(res.location, `/@${alice.username}`);
|
||||
});
|
||||
|
||||
test('Undecided => HTML', async () => {
|
||||
const res = await simpleGet(`/users/${alice.id}`, UNSPECIFIED);
|
||||
assert.strictEqual(res.status, 302);
|
||||
assert.strictEqual(res.location, `/@${alice.username}`);
|
||||
test('は存在しないIDのときActivityPubとしてGETできない。', async () => await notOk({
|
||||
path: path('xxxxxxxx'),
|
||||
accept,
|
||||
status: 404,
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
describe('/users/inbox', () => {
|
||||
test('がGETできる。(POST専用だけど4xx/5xxにならずHTMLが返ってくる)', async () => await ok({
|
||||
path: '/inbox',
|
||||
}));
|
||||
|
||||
// test.todo('POSTできる?');
|
||||
});
|
||||
|
||||
describe('/users/:id/inbox', () => {
|
||||
const path = (id: string): string => `/users/${id}/inbox`;
|
||||
|
||||
test('がGETできる。(POST専用だけど4xx/5xxにならずHTMLが返ってくる)', async () => await ok({
|
||||
path: path(alice.id),
|
||||
}));
|
||||
|
||||
// test.todo('POSTできる?');
|
||||
});
|
||||
|
||||
describe('/users/:id/outbox', () => {
|
||||
const path = (id: string): string => `/users/${id}/outbox`;
|
||||
|
||||
test('がGETできる。', async () => {
|
||||
const res = await ok({
|
||||
path: path(alice.id),
|
||||
type: AP,
|
||||
});
|
||||
assert.strictEqual(res.body.type, 'OrderedCollection');
|
||||
});
|
||||
});
|
||||
|
||||
describe('/notes/:id', () => {
|
||||
test('Only AP => AP', async () => {
|
||||
const res = await simpleGet(`/notes/${alicesPost.id}`, ONLY_AP);
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.strictEqual(res.type, AP);
|
||||
const path = (noteId: string): string => `/notes/${noteId}`;
|
||||
|
||||
describe.each([
|
||||
{ accept: PREFER_HTML },
|
||||
{ accept: UNSPECIFIED },
|
||||
])('(Acceptヘッダ: $accept)', ({ accept }) => {
|
||||
test('はHTMLとしてGETできる。', async () => {
|
||||
const res = await ok({
|
||||
path: path(alicesPost.id),
|
||||
accept,
|
||||
type: HTML,
|
||||
});
|
||||
assert.strictEqual(metaTag(res, 'misskey:user-username'), alice.username);
|
||||
assert.strictEqual(metaTag(res, 'misskey:user-id'), alice.id);
|
||||
assert.strictEqual(metaTag(res, 'misskey:note-id'), alicesPost.id);
|
||||
|
||||
// TODO ogタグの検証
|
||||
// TODO profile.noCrawleの検証
|
||||
// TODO twitter:creatorの検証
|
||||
});
|
||||
|
||||
test('はHTMLとしてGETできる。(存在しないIDでも。)', async () => await ok({
|
||||
path: path('xxxxxxxxxx'),
|
||||
}));
|
||||
});
|
||||
|
||||
test('Prefer AP => AP', async () => {
|
||||
const res = await simpleGet(`/notes/${alicesPost.id}`, PREFER_AP);
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.strictEqual(res.type, AP);
|
||||
});
|
||||
describe.each([
|
||||
{ accept: ONLY_AP },
|
||||
{ accept: PREFER_AP },
|
||||
])('(Acceptヘッダ: $accept)', ({ accept }) => {
|
||||
test('はActivityPubとしてGETできる。', async () => {
|
||||
const res = await ok({
|
||||
path: path(alicesPost.id),
|
||||
accept,
|
||||
type: AP,
|
||||
});
|
||||
assert.strictEqual(res.body.type, 'Note');
|
||||
});
|
||||
|
||||
test('Prefer HTML => HTML', async () => {
|
||||
const res = await simpleGet(`/notes/${alicesPost.id}`, PREFER_HTML);
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.strictEqual(res.type, HTML);
|
||||
});
|
||||
|
||||
test('Unspecified => HTML', async () => {
|
||||
const res = await simpleGet(`/notes/${alicesPost.id}`, UNSPECIFIED);
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.strictEqual(res.type, HTML);
|
||||
test('は存在しないIDのときActivityPubとしてGETできない。', async () => await notFound({
|
||||
path: path('xxxxxxxxxx'),
|
||||
accept,
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
describe('/play/:id', () => {
|
||||
const path = (playid: string): string => `/play/${playid}`;
|
||||
|
||||
describe('Feeds', () => {
|
||||
test('RSS', async () => {
|
||||
const res = await simpleGet(`/@${alice.username}.rss`, UNSPECIFIED);
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.strictEqual(res.type, 'application/rss+xml; charset=utf-8');
|
||||
test('がGETできる。', async () => {
|
||||
const res = await ok({
|
||||
path: path(alicePlay.id),
|
||||
});
|
||||
assert.strictEqual(metaTag(res, 'misskey:user-username'), alice.username);
|
||||
assert.strictEqual(metaTag(res, 'misskey:user-id'), alice.id);
|
||||
assert.strictEqual(metaTag(res, 'misskey:flash-id'), alicePlay.id);
|
||||
|
||||
// TODO ogタグの検証
|
||||
// TODO profile.noCrawleの検証
|
||||
// TODO twitter:creatorの検証
|
||||
});
|
||||
|
||||
test('ATOM', async () => {
|
||||
const res = await simpleGet(`/@${alice.username}.atom`, UNSPECIFIED);
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.strictEqual(res.type, 'application/atom+xml; charset=utf-8');
|
||||
});
|
||||
test('がGETできる。(存在しないIDでも。)', async () => await ok({
|
||||
path: path('xxxxxxxxxx'),
|
||||
}));
|
||||
});
|
||||
|
||||
describe('/clips/:clip', () => {
|
||||
const path = (clip: string): string => `/clips/${clip}`;
|
||||
|
||||
test('JSON', async () => {
|
||||
const res = await simpleGet(`/@${alice.username}.json`, UNSPECIFIED);
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.strictEqual(res.type, 'application/json; charset=utf-8');
|
||||
test('がGETできる。', async () => {
|
||||
const res = await ok({
|
||||
path: path(aliceClip.id),
|
||||
});
|
||||
assert.strictEqual(metaTag(res, 'misskey:user-username'), alice.username);
|
||||
assert.strictEqual(metaTag(res, 'misskey:user-id'), alice.id);
|
||||
assert.strictEqual(metaTag(res, 'misskey:clip-id'), aliceClip.id);
|
||||
|
||||
// TODO ogタグの検証
|
||||
// TODO profile.noCrawleの検証
|
||||
});
|
||||
|
||||
test('がGETできる。(存在しないIDでも。)', async () => await ok({
|
||||
path: path('xxxxxxxxxx'),
|
||||
}));
|
||||
});
|
||||
|
||||
describe('/gallery/:post', () => {
|
||||
const path = (post: string): string => `/gallery/${post}`;
|
||||
|
||||
test('がGETできる。', async () => {
|
||||
const res = await ok({
|
||||
path: path(aliceGalleryPost.id),
|
||||
});
|
||||
assert.strictEqual(metaTag(res, 'misskey:user-username'), alice.username);
|
||||
assert.strictEqual(metaTag(res, 'misskey:user-id'), alice.id);
|
||||
|
||||
// FIXME: misskey:gallery-post-idみたいなmetaタグの設定がない
|
||||
// TODO profile.noCrawleの検証
|
||||
// TODO twitter:creatorの検証
|
||||
});
|
||||
|
||||
test('がGETできる。(存在しないIDでも。)', async () => await ok({
|
||||
path: path('xxxxxxxxxx'),
|
||||
}));
|
||||
});
|
||||
|
||||
describe('/channels/:channel', () => {
|
||||
const path = (channel: string): string => `/channels/${channel}`;
|
||||
|
||||
test('はGETできる。', async () => {
|
||||
const res = await ok({
|
||||
path: path(aliceChannel.id),
|
||||
});
|
||||
|
||||
// FIXME: misskey関連のmetaタグの設定がない
|
||||
// TODO ogタグの検証
|
||||
});
|
||||
|
||||
test('がGETできる。(存在しないIDでも。)', async () => await ok({
|
||||
path: path('xxxxxxxxxx'),
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { isAbsolute, basename } from 'node:path';
|
|||
import WebSocket from 'ws';
|
||||
import fetch, { Blob, File, RequestInit } from 'node-fetch';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { entities } from '../src/postgres.js';
|
||||
import { loadConfig } from '../src/config.js';
|
||||
import type * as misskey from 'misskey-js';
|
||||
|
|
@ -12,6 +13,10 @@ export { server as startServer } from '@/boot/common.js';
|
|||
const config = loadConfig();
|
||||
export const port = config.port;
|
||||
|
||||
export const cookie = (me: any): string => {
|
||||
return `token=${me.token};`;
|
||||
};
|
||||
|
||||
export const api = async (endpoint: string, params: any, me?: any) => {
|
||||
const normalized = endpoint.replace(/^\//, '');
|
||||
return await request(`api/${normalized}`, params, me);
|
||||
|
|
@ -71,6 +76,71 @@ export const react = async (user: any, note: any, reaction: string): Promise<any
|
|||
}, user);
|
||||
};
|
||||
|
||||
export const page = async (user: any, page: any = {}): Promise<any> => {
|
||||
const res = await api('pages/create', {
|
||||
alignCenter: false,
|
||||
content: [
|
||||
{
|
||||
id: '2be9a64b-5ada-43a3-85f3-ec3429551ded',
|
||||
text: 'Hello World!',
|
||||
type: 'text',
|
||||
},
|
||||
],
|
||||
eyeCatchingImageId: null,
|
||||
font: 'sans-serif',
|
||||
hideTitleWhenPinned: false,
|
||||
name: '1678594845072',
|
||||
script: '',
|
||||
summary: null,
|
||||
title: '',
|
||||
variables: [],
|
||||
...page,
|
||||
}, user);
|
||||
return res.body;
|
||||
};
|
||||
|
||||
export const play = async (user: any, play: any = {}): Promise<any> => {
|
||||
const res = await api('flash/create', {
|
||||
permissions: [],
|
||||
script: 'test',
|
||||
summary: '',
|
||||
title: 'test',
|
||||
...play,
|
||||
}, user);
|
||||
return res.body;
|
||||
};
|
||||
|
||||
export const clip = async (user: any, clip: any = {}): Promise<any> => {
|
||||
const res = await api('clips/create', {
|
||||
description: null,
|
||||
isPublic: true,
|
||||
name: 'test',
|
||||
...clip,
|
||||
}, user);
|
||||
return res.body;
|
||||
};
|
||||
|
||||
export const galleryPost = async (user: any, channel: any = {}): Promise<any> => {
|
||||
const res = await api('gallery/posts/create', {
|
||||
description: null,
|
||||
fileIds: [],
|
||||
isSensitive: false,
|
||||
title: 'test',
|
||||
...channel,
|
||||
}, user);
|
||||
return res.body;
|
||||
};
|
||||
|
||||
export const channel = async (user: any, channel: any = {}): Promise<any> => {
|
||||
const res = await api('channels/create', {
|
||||
bannerId: null,
|
||||
description: null,
|
||||
name: 'test',
|
||||
...channel,
|
||||
}, user);
|
||||
return res.body;
|
||||
};
|
||||
|
||||
interface UploadOptions {
|
||||
/** Optional, absolute path or relative from ./resources/ */
|
||||
path?: string | URL;
|
||||
|
|
@ -196,10 +266,17 @@ export const waitFire = async (user: any, channel: string, trgr: () => any, cond
|
|||
});
|
||||
};
|
||||
|
||||
export const simpleGet = async (path: string, accept = '*/*'): Promise<{ status: number, body: any, type: string | null, location: string | null }> => {
|
||||
export type SimpleGetResponse = {
|
||||
status: number,
|
||||
body: any | JSDOM | null,
|
||||
type: string | null,
|
||||
location: string | null
|
||||
};
|
||||
export const simpleGet = async (path: string, accept = '*/*', cookie: any = undefined): Promise<SimpleGetResponse> => {
|
||||
const res = await relativeFetch(path, {
|
||||
headers: {
|
||||
Accept: accept,
|
||||
Cookie: cookie,
|
||||
},
|
||||
redirect: 'manual',
|
||||
});
|
||||
|
|
@ -208,10 +285,14 @@ export const simpleGet = async (path: string, accept = '*/*'): Promise<{ status:
|
|||
'application/json; charset=utf-8',
|
||||
'application/activity+json; charset=utf-8',
|
||||
];
|
||||
const htmlTypes = [
|
||||
'text/html; charset=utf-8',
|
||||
];
|
||||
|
||||
const body = jsonTypes.includes(res.headers.get('content-type') ?? '')
|
||||
? await res.json()
|
||||
: null;
|
||||
const body =
|
||||
jsonTypes.includes(res.headers.get('content-type') ?? '') ? await res.json() :
|
||||
htmlTypes.includes(res.headers.get('content-type') ?? '') ? new JSDOM(await res.text()) :
|
||||
null;
|
||||
|
||||
return {
|
||||
status: res.status,
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@
|
|||
"@rollup/plugin-alias": "4.0.3",
|
||||
"@rollup/plugin-json": "6.0.0",
|
||||
"@rollup/pluginutils": "5.0.2",
|
||||
"@syuilo/aiscript": "0.13.0",
|
||||
"@syuilo/aiscript": "0.13.1",
|
||||
"@tabler/icons-webfont": "2.10.0",
|
||||
"@vitejs/plugin-vue": "4.0.0",
|
||||
"@vue/compiler-sfc": "3.2.47",
|
||||
|
|
@ -97,7 +97,9 @@
|
|||
"eslint-plugin-vue": "9.9.0",
|
||||
"happy-dom": "8.9.0",
|
||||
"start-server-and-test": "2.0.0",
|
||||
"summaly": "github:misskey-dev/summaly",
|
||||
"vitest": "^0.29.2",
|
||||
"vitest-fetch-mock": "^0.2.2",
|
||||
"vue-eslint-parser": "9.1.0",
|
||||
"vue-tsc": "1.2.0"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,18 @@
|
|||
<template>
|
||||
<template v-if="playerEnabled">
|
||||
<div :class="$style.player" :style="`padding: ${(player.height || 0) / (player.width || 1) * 100}% 0 0`">
|
||||
<iframe v-if="player.url.startsWith('http://') || player.url.startsWith('https://')" :class="$style.playerIframe" :src="player.url + (player.url.match(/\?/) ? '&autoplay=1&auto_play=1' : '?autoplay=1&auto_play=1')" :width="player.width || '100%'" :heigth="player.height || 250" frameborder="0" allow="autoplay; encrypted-media" allowfullscreen/>
|
||||
<template v-if="player.url && playerEnabled">
|
||||
<div
|
||||
:class="$style.player"
|
||||
:style="player.width ? `padding: ${(player.height || 0) / player.width * 100}% 0 0` : `padding: ${(player.height || 0)}px 0 0`"
|
||||
>
|
||||
<iframe
|
||||
v-if="player.url.startsWith('http://') || player.url.startsWith('https://')"
|
||||
sandbox="allow-popups allow-scripts allow-storage-access-by-user-activation allow-same-origin"
|
||||
scrolling="no"
|
||||
:allow="player.allow.join(';')"
|
||||
:class="$style.playerIframe"
|
||||
:src="player.url + (player.url.match(/\?/) ? '&autoplay=1&auto_play=1' : '?autoplay=1&auto_play=1')"
|
||||
:style="{ border: 0 }"
|
||||
></iframe>
|
||||
<span v-else>invalid url</span>
|
||||
</div>
|
||||
<div :class="$style.action">
|
||||
|
|
@ -28,7 +39,7 @@
|
|||
<header :class="$style.header">
|
||||
<h1 v-if="unknownUrl" :class="$style.title">{{ url }}</h1>
|
||||
<h1 v-else-if="fetching" :class="$style.title"><MkEllipsis/></h1>
|
||||
<h1 v-else :class="$style.title" :title="title">{{ title }}</h1>
|
||||
<h1 v-else :class="$style.title" :title="title ?? undefined">{{ title }}</h1>
|
||||
</header>
|
||||
<p v-if="unknownUrl" :class="$style.text">{{ i18n.ts.cannotLoad }}</p>
|
||||
<p v-else-if="fetching" :class="$style.text"><MkEllipsis/></p>
|
||||
|
|
@ -37,7 +48,7 @@
|
|||
<img v-if="icon" :class="$style.siteIcon" :src="icon"/>
|
||||
<p v-if="unknownUrl" :class="$style.siteName">?</p>
|
||||
<p v-else-if="fetching" :class="$style.siteName"><MkEllipsis/></p>
|
||||
<p v-else :class="$style.siteName" :title="sitename">{{ sitename }}</p>
|
||||
<p v-else :class="$style.siteName" :title="sitename ?? undefined">{{ sitename }}</p>
|
||||
</footer>
|
||||
</article>
|
||||
</component>
|
||||
|
|
@ -59,6 +70,7 @@
|
|||
|
||||
<script lang="ts" setup>
|
||||
import { defineAsyncComponent, onUnmounted } from 'vue';
|
||||
import type { summaly } from 'summaly';
|
||||
import { url as local } from '@/config';
|
||||
import { i18n } from '@/i18n';
|
||||
import * as os from '@/os';
|
||||
|
|
@ -66,6 +78,8 @@ import { deviceKind } from '@/scripts/device-kind';
|
|||
import MkButton from '@/components/MkButton.vue';
|
||||
import { versatileLang } from '@/scripts/intl-const';
|
||||
|
||||
type SummalyResult = Awaited<ReturnType<typeof summaly>>;
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
url: string;
|
||||
detail?: boolean;
|
||||
|
|
@ -91,7 +105,7 @@ let player = $ref({
|
|||
url: null,
|
||||
width: null,
|
||||
height: null,
|
||||
});
|
||||
} as SummalyResult['player']);
|
||||
let playerEnabled = $ref(false);
|
||||
let tweetId = $ref<string | null>(null);
|
||||
let tweetExpanded = $ref(props.detail);
|
||||
|
|
@ -114,11 +128,7 @@ if (requestUrl.hostname === 'music.youtube.com' && requestUrl.pathname.match('^/
|
|||
requestUrl.hash = '';
|
||||
|
||||
window.fetch(`/url?url=${encodeURIComponent(requestUrl.href)}&lang=${versatileLang}`).then(res => {
|
||||
res.json().then(info => {
|
||||
if (info.url == null) {
|
||||
unknownUrl = true;
|
||||
return;
|
||||
}
|
||||
res.json().then((info: SummalyResult) => {
|
||||
title = info.title;
|
||||
description = info.description;
|
||||
thumbnail = info.thumbnail;
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@
|
|||
<div style="text-align: center;">
|
||||
{{ i18n.ts._aboutMisskey.about }}<br><a href="https://misskey-hub.net/docs/misskey.html" target="_blank" class="_link">{{ i18n.ts.learnMore }}</a>
|
||||
</div>
|
||||
<div style="text-align: center;">
|
||||
<div v-if="$i != null" style="text-align: center;">
|
||||
<MkButton primary rounded inline @click="iLoveMisskey">I <Mfm text="$[jelly ❤]"/> #Misskey</MkButton>
|
||||
</div>
|
||||
<FormSection>
|
||||
|
|
|
|||
25
packages/frontend/src/pages/ads.vue
Normal file
25
packages/frontend/src/pages/ads.vue
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<template>
|
||||
<MkStickyContainer>
|
||||
<template #header><MkPageHeader/></template>
|
||||
|
||||
<MkSpacer :content-max="500">
|
||||
<div class="_gaps">
|
||||
<MkAd v-for="ad in instance.ads" :key="ad.id" :specify="ad"/>
|
||||
</div>
|
||||
</MkSpacer>
|
||||
</MkStickyContainer>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, watch } from 'vue';
|
||||
import * as os from '@/os';
|
||||
import { definePageMetadata } from '@/scripts/page-metadata';
|
||||
import { i18n } from '@/i18n';
|
||||
import { instance } from '@/instance';
|
||||
|
||||
definePageMetadata({
|
||||
title: i18n.ts.ads,
|
||||
icon: 'ti ti-ad',
|
||||
});
|
||||
</script>
|
||||
|
||||
|
|
@ -34,6 +34,17 @@ function menu(ev) {
|
|||
copyToClipboard(`:${props.emoji.name}:`);
|
||||
os.success();
|
||||
},
|
||||
}, {
|
||||
text: i18n.ts.info,
|
||||
icon: 'ti ti-info-circle',
|
||||
action: () => {
|
||||
os.apiGet('emoji', { name: props.emoji.name }).then(res => {
|
||||
os.alert({
|
||||
type: 'info',
|
||||
text: `License: ${res.license}`,
|
||||
});
|
||||
});
|
||||
},
|
||||
}], ev.currentTarget ?? ev.target);
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ import MkTextarea from '@/components/MkTextarea.vue';
|
|||
import MkInput from '@/components/MkInput.vue';
|
||||
import { useRouter } from '@/router';
|
||||
|
||||
const PRESET_DEFAULT = `/// @ 0.13.0
|
||||
const PRESET_DEFAULT = `/// @ 0.13.1
|
||||
|
||||
var name = ""
|
||||
|
||||
|
|
@ -51,7 +51,7 @@ Ui:render([
|
|||
])
|
||||
`;
|
||||
|
||||
const PRESET_OMIKUJI = `/// @ 0.13.0
|
||||
const PRESET_OMIKUJI = `/// @ 0.13.1
|
||||
// ユーザーごとに日替わりのおみくじのプリセット
|
||||
|
||||
// 選択肢
|
||||
|
|
@ -94,7 +94,7 @@ Ui:render([
|
|||
])
|
||||
`;
|
||||
|
||||
const PRESET_SHUFFLE = `/// @ 0.13.0
|
||||
const PRESET_SHUFFLE = `/// @ 0.13.1
|
||||
// 巻き戻し可能な文字シャッフルのプリセット
|
||||
|
||||
let string = "ペペロンチーノ"
|
||||
|
|
@ -173,7 +173,7 @@ var cursor = 0
|
|||
do()
|
||||
`;
|
||||
|
||||
const PRESET_QUIZ = `/// @ 0.13.0
|
||||
const PRESET_QUIZ = `/// @ 0.13.1
|
||||
let title = '地理クイズ'
|
||||
|
||||
let qas = [{
|
||||
|
|
@ -286,7 +286,7 @@ qaEls.push(Ui:C:container({
|
|||
Ui:render(qaEls)
|
||||
`;
|
||||
|
||||
const PRESET_TIMELINE = `/// @ 0.13.0
|
||||
const PRESET_TIMELINE = `/// @ 0.13.1
|
||||
// APIリクエストを行いローカルタイムラインを表示するプリセット
|
||||
|
||||
@fetch() {
|
||||
|
|
|
|||
|
|
@ -197,6 +197,9 @@ export const routes = [{
|
|||
}, {
|
||||
path: '/about-misskey',
|
||||
component: page(() => import('./pages/about-misskey.vue')),
|
||||
}, {
|
||||
path: '/ads',
|
||||
component: page(() => import('./pages/ads.vue')),
|
||||
}, {
|
||||
path: '/theme-editor',
|
||||
component: page(() => import('./pages/theme-editor.vue')),
|
||||
|
|
|
|||
|
|
@ -29,6 +29,11 @@ export function openInstanceMenu(ev: MouseEvent) {
|
|||
icon: 'ti ti-chart-line',
|
||||
to: '/about#charts',
|
||||
}, null, {
|
||||
type: 'link',
|
||||
text: i18n.ts.ads,
|
||||
icon: 'ti ti-ad',
|
||||
to: '/ads',
|
||||
}, {
|
||||
type: 'parent',
|
||||
text: i18n.ts.tools,
|
||||
icon: 'ti ti-tool',
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
import { vi } from 'vitest';
|
||||
import createFetchMock from 'vitest-fetch-mock';
|
||||
|
||||
const fetchMocker = createFetchMock(vi);
|
||||
fetchMocker.enableMocks();
|
||||
|
||||
// Set i18n
|
||||
import locales from '../../../locales';
|
||||
|
|
|
|||
140
packages/frontend/test/url-preview.test.ts
Normal file
140
packages/frontend/test/url-preview.test.ts
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
import { describe, test, assert, afterEach } from 'vitest';
|
||||
import { render, cleanup, type RenderResult } from '@testing-library/vue';
|
||||
import './init';
|
||||
import type { summaly } from 'summaly';
|
||||
import { directives } from '@/directives';
|
||||
import MkUrlPreview from '@/components/MkUrlPreview.vue';
|
||||
|
||||
type SummalyResult = Awaited<ReturnType<typeof summaly>>;
|
||||
|
||||
describe('MkMediaImage', () => {
|
||||
const renderPreviewBy = async (summary: Partial<SummalyResult>): Promise<RenderResult> => {
|
||||
if (!summary.player) {
|
||||
summary.player = {
|
||||
url: null,
|
||||
width: null,
|
||||
height: null,
|
||||
allow: [],
|
||||
};
|
||||
}
|
||||
|
||||
fetchMock.mockOnceIf(/^\/url?/, () => {
|
||||
return {
|
||||
status: 200,
|
||||
body: JSON.stringify(summary),
|
||||
};
|
||||
});
|
||||
|
||||
const result = render(MkUrlPreview, {
|
||||
props: { url: summary.url },
|
||||
global: { directives },
|
||||
});
|
||||
|
||||
await new Promise<void>(resolve => {
|
||||
const observer = new MutationObserver(() => {
|
||||
resolve();
|
||||
observer.disconnect();
|
||||
});
|
||||
observer.observe(result.container, { childList: true, subtree: true });
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const renderAndOpenPreview = async (summary: Partial<SummalyResult>): Promise<HTMLIFrameElement | null> => {
|
||||
const mkUrlPreview = await renderPreviewBy(summary);
|
||||
const buttons = mkUrlPreview.getAllByRole('button');
|
||||
buttons[0].click();
|
||||
// Wait for the click event to be fired
|
||||
await Promise.resolve();
|
||||
|
||||
return mkUrlPreview.container.querySelector('iframe');
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
fetchMock.resetMocks();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test('Should render the description', async () => {
|
||||
const mkUrlPreview = await renderPreviewBy({
|
||||
url: 'https://example.local',
|
||||
description: 'Mocked description',
|
||||
});
|
||||
mkUrlPreview.getByText('Mocked description');
|
||||
});
|
||||
|
||||
test('Having a player should render a button', async () => {
|
||||
const mkUrlPreview = await renderPreviewBy({
|
||||
url: 'https://example.local',
|
||||
player: {
|
||||
url: 'https://example.local/player',
|
||||
width: null,
|
||||
height: null,
|
||||
allow: [],
|
||||
},
|
||||
});
|
||||
const buttons = mkUrlPreview.getAllByRole('button');
|
||||
assert.strictEqual(buttons.length, 2, 'two buttons');
|
||||
});
|
||||
|
||||
test('Having a player should setup the iframe', async () => {
|
||||
const iframe = await renderAndOpenPreview({
|
||||
url: 'https://example.local',
|
||||
player: {
|
||||
url: 'https://example.local/player',
|
||||
width: null,
|
||||
height: null,
|
||||
allow: [],
|
||||
},
|
||||
});
|
||||
assert.exists(iframe, 'iframe should exist');
|
||||
assert.strictEqual(iframe?.src, 'https://example.local/player?autoplay=1&auto_play=1');
|
||||
assert.strictEqual(
|
||||
iframe?.sandbox.toString(),
|
||||
'allow-popups allow-scripts allow-storage-access-by-user-activation allow-same-origin',
|
||||
);
|
||||
});
|
||||
|
||||
test('Having a player with `allow` field should set permissions', async () => {
|
||||
const iframe = await renderAndOpenPreview({
|
||||
url: 'https://example.local',
|
||||
player: {
|
||||
url: 'https://example.local/player',
|
||||
width: null,
|
||||
height: null,
|
||||
allow: ['fullscreen', 'web-share'],
|
||||
},
|
||||
});
|
||||
assert.exists(iframe, 'iframe should exist');
|
||||
assert.strictEqual(iframe?.allow, 'fullscreen;web-share');
|
||||
});
|
||||
|
||||
test('Having a player width should keep the fixed aspect ratio', async () => {
|
||||
const iframe = await renderAndOpenPreview({
|
||||
url: 'https://example.local',
|
||||
player: {
|
||||
url: 'https://example.local/player',
|
||||
width: 400,
|
||||
height: 200,
|
||||
allow: [],
|
||||
},
|
||||
});
|
||||
assert.exists(iframe, 'iframe should exist');
|
||||
assert.strictEqual(iframe?.parentElement?.style.paddingTop, '50%');
|
||||
});
|
||||
|
||||
test('Having a player width should keep the fixed height', async () => {
|
||||
const iframe = await renderAndOpenPreview({
|
||||
url: 'https://example.local',
|
||||
player: {
|
||||
url: 'https://example.local/player',
|
||||
width: null,
|
||||
height: 200,
|
||||
allow: [],
|
||||
},
|
||||
});
|
||||
assert.exists(iframe, 'iframe should exist');
|
||||
assert.strictEqual(iframe?.parentElement?.style.paddingTop, '200px');
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue