mirror of
https://activitypub.software/TransFem-org/Sharkey
synced 2024-11-25 15:35:12 +00:00
Fastify (#9106)
* wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * fix * Update SignupApiService.ts * wip * wip * Update ClientServerService.ts * wip * wip * wip * Update WellKnownServerService.ts * wip * wip * update des * wip * Update ApiServerService.ts * wip * update deps * Update WellKnownServerService.ts * wip * update deps * Update ApiCallService.ts * Update ApiCallService.ts * Update ApiServerService.ts
This commit is contained in:
parent
2db9f6efe7
commit
3a7182bfb5
40 changed files with 1651 additions and 1977 deletions
|
@ -21,20 +21,19 @@
|
||||||
"@tensorflow/tfjs-node": "4.1.0"
|
"@tensorflow/tfjs-node": "4.1.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@bull-board/api": "4.3.1",
|
|
||||||
"@bull-board/koa": "4.3.1",
|
|
||||||
"@bull-board/ui": "4.3.1",
|
|
||||||
"@discordapp/twemoji": "14.0.2",
|
"@discordapp/twemoji": "14.0.2",
|
||||||
"@elastic/elasticsearch": "7.17.0",
|
"@fastify/accepts": "4.0.1",
|
||||||
"@koa/cors": "3.3.0",
|
"@fastify/cors": "8.2.0",
|
||||||
"@koa/multer": "3.0.0",
|
"@fastify/multipart": "7.3.0",
|
||||||
"@koa/router": "9.0.1",
|
"@fastify/static": "6.5.0",
|
||||||
|
"@fastify/view": "7.1.2",
|
||||||
"@nestjs/common": "9.2.0",
|
"@nestjs/common": "9.2.0",
|
||||||
"@nestjs/core": "9.2.0",
|
"@nestjs/core": "9.2.0",
|
||||||
"@nestjs/testing": "9.2.0",
|
"@nestjs/testing": "9.2.0",
|
||||||
"@peertube/http-signature": "1.7.0",
|
"@peertube/http-signature": "1.7.0",
|
||||||
"@sinonjs/fake-timers": "10.0.0",
|
"@sinonjs/fake-timers": "10.0.0",
|
||||||
"@syuilo/aiscript": "0.11.1",
|
"@syuilo/aiscript": "0.11.1",
|
||||||
|
"accepts": "^1.3.8",
|
||||||
"ajv": "8.11.2",
|
"ajv": "8.11.2",
|
||||||
"archiver": "5.3.1",
|
"archiver": "5.3.1",
|
||||||
"autobind-decorator": "2.4.0",
|
"autobind-decorator": "2.4.0",
|
||||||
|
@ -54,6 +53,7 @@
|
||||||
"date-fns": "2.29.3",
|
"date-fns": "2.29.3",
|
||||||
"deep-email-validator": "0.1.21",
|
"deep-email-validator": "0.1.21",
|
||||||
"escape-regexp": "0.0.1",
|
"escape-regexp": "0.0.1",
|
||||||
|
"fastify": "4.10.0",
|
||||||
"feed": "4.2.2",
|
"feed": "4.2.2",
|
||||||
"file-type": "18.0.0",
|
"file-type": "18.0.0",
|
||||||
"fluent-ffmpeg": "2.1.2",
|
"fluent-ffmpeg": "2.1.2",
|
||||||
|
@ -69,20 +69,10 @@
|
||||||
"json5-loader": "4.0.1",
|
"json5-loader": "4.0.1",
|
||||||
"jsonld": "8.1.0",
|
"jsonld": "8.1.0",
|
||||||
"jsrsasign": "10.6.1",
|
"jsrsasign": "10.6.1",
|
||||||
"koa": "2.13.4",
|
|
||||||
"koa-bodyparser": "4.3.0",
|
|
||||||
"koa-favicon": "2.1.0",
|
|
||||||
"koa-json-body": "5.3.0",
|
|
||||||
"koa-logger": "3.2.1",
|
|
||||||
"koa-mount": "4.0.0",
|
|
||||||
"koa-send": "5.0.1",
|
|
||||||
"koa-slow": "2.1.0",
|
|
||||||
"koa-views": "7.0.2",
|
|
||||||
"mfm-js": "0.23.0",
|
"mfm-js": "0.23.0",
|
||||||
"mime-types": "2.1.35",
|
"mime-types": "2.1.35",
|
||||||
"misskey-js": "0.0.14",
|
"misskey-js": "0.0.14",
|
||||||
"ms": "3.0.0-canary.1",
|
"ms": "3.0.0-canary.1",
|
||||||
"multer": "1.4.4",
|
|
||||||
"nested-property": "4.0.0",
|
"nested-property": "4.0.0",
|
||||||
"node-fetch": "3.3.0",
|
"node-fetch": "3.3.0",
|
||||||
"nodemailer": "6.8.0",
|
"nodemailer": "6.8.0",
|
||||||
|
@ -129,6 +119,7 @@
|
||||||
"ulid": "2.3.0",
|
"ulid": "2.3.0",
|
||||||
"unzipper": "0.10.11",
|
"unzipper": "0.10.11",
|
||||||
"uuid": "9.0.0",
|
"uuid": "9.0.0",
|
||||||
|
"vary": "1.1.2",
|
||||||
"web-push": "3.5.0",
|
"web-push": "3.5.0",
|
||||||
"websocket": "1.0.34",
|
"websocket": "1.0.34",
|
||||||
"ws": "8.11.0",
|
"ws": "8.11.0",
|
||||||
|
@ -138,6 +129,7 @@
|
||||||
"@redocly/openapi-core": "1.0.0-beta.114",
|
"@redocly/openapi-core": "1.0.0-beta.114",
|
||||||
"@swc/core": "1.3.20",
|
"@swc/core": "1.3.20",
|
||||||
"@swc/jest": "0.2.23",
|
"@swc/jest": "0.2.23",
|
||||||
|
"@types/accepts": "1.3.5",
|
||||||
"@types/archiver": "5.3.1",
|
"@types/archiver": "5.3.1",
|
||||||
"@types/bcryptjs": "2.4.2",
|
"@types/bcryptjs": "2.4.2",
|
||||||
"@types/bull": "4.10.0",
|
"@types/bull": "4.10.0",
|
||||||
|
@ -149,17 +141,6 @@
|
||||||
"@types/jsdom": "20.0.1",
|
"@types/jsdom": "20.0.1",
|
||||||
"@types/jsonld": "1.5.8",
|
"@types/jsonld": "1.5.8",
|
||||||
"@types/jsrsasign": "10.5.4",
|
"@types/jsrsasign": "10.5.4",
|
||||||
"@types/koa": "2.13.5",
|
|
||||||
"@types/koa-bodyparser": "4.3.8",
|
|
||||||
"@types/koa-cors": "0.0.2",
|
|
||||||
"@types/koa-favicon": "2.0.21",
|
|
||||||
"@types/koa-logger": "3.1.2",
|
|
||||||
"@types/koa-mount": "4.0.1",
|
|
||||||
"@types/koa-send": "4.1.3",
|
|
||||||
"@types/koa-views": "7.0.0",
|
|
||||||
"@types/koa__cors": "3.3.0",
|
|
||||||
"@types/koa__multer": "2.0.4",
|
|
||||||
"@types/koa__router": "8.0.11",
|
|
||||||
"@types/mime-types": "2.1.1",
|
"@types/mime-types": "2.1.1",
|
||||||
"@types/node": "18.11.9",
|
"@types/node": "18.11.9",
|
||||||
"@types/node-fetch": "3.0.3",
|
"@types/node-fetch": "3.0.3",
|
||||||
|
@ -182,6 +163,7 @@
|
||||||
"@types/tmp": "0.2.3",
|
"@types/tmp": "0.2.3",
|
||||||
"@types/unzipper": "0.10.5",
|
"@types/unzipper": "0.10.5",
|
||||||
"@types/uuid": "8.3.4",
|
"@types/uuid": "8.3.4",
|
||||||
|
"@types/vary": "1.1.0",
|
||||||
"@types/web-push": "3.3.2",
|
"@types/web-push": "3.3.2",
|
||||||
"@types/websocket": "1.0.5",
|
"@types/websocket": "1.0.5",
|
||||||
"@types/ws": "8.5.3",
|
"@types/ws": "8.5.3",
|
||||||
|
|
15
packages/backend/src/@types/koa-json-body.d.ts
vendored
15
packages/backend/src/@types/koa-json-body.d.ts
vendored
|
@ -1,15 +0,0 @@
|
||||||
declare module 'koa-json-body' {
|
|
||||||
import type { Middleware } from 'koa';
|
|
||||||
|
|
||||||
interface IKoaJsonBodyOptions {
|
|
||||||
strict: boolean;
|
|
||||||
limit: string;
|
|
||||||
fallback: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function koaJsonBody(opt?: IKoaJsonBodyOptions): Middleware;
|
|
||||||
|
|
||||||
namespace koaJsonBody {} // Hack
|
|
||||||
|
|
||||||
export = koaJsonBody;
|
|
||||||
}
|
|
14
packages/backend/src/@types/koa-slow.d.ts
vendored
14
packages/backend/src/@types/koa-slow.d.ts
vendored
|
@ -1,14 +0,0 @@
|
||||||
declare module 'koa-slow' {
|
|
||||||
import type { Middleware } from 'koa';
|
|
||||||
|
|
||||||
interface ISlowOptions {
|
|
||||||
url?: RegExp;
|
|
||||||
delay?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function slow(options?: ISlowOptions): Middleware;
|
|
||||||
|
|
||||||
namespace slow {} // Hack
|
|
||||||
|
|
||||||
export = slow;
|
|
||||||
}
|
|
|
@ -52,6 +52,7 @@ if (!envOption.quiet) {
|
||||||
process.on('uncaughtException', err => {
|
process.on('uncaughtException', err => {
|
||||||
try {
|
try {
|
||||||
logger.error(err);
|
logger.error(err);
|
||||||
|
console.trace(err);
|
||||||
} catch { }
|
} catch { }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -45,9 +45,13 @@ export class CaptchaService {
|
||||||
return await res.json() as CaptchaResponse;
|
return await res.json() as CaptchaResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async verifyRecaptcha(secret: string, response: string): Promise<void> {
|
public async verifyRecaptcha(secret: string, response: string | null | undefined): Promise<void> {
|
||||||
const result = await this.getCaptchaResponse('https://www.recaptcha.net/recaptcha/api/siteverify', secret, response).catch(e => {
|
if (response == null) {
|
||||||
throw `recaptcha-request-failed: ${e}`;
|
throw 'recaptcha-failed: no response provided';
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.getCaptchaResponse('https://www.recaptcha.net/recaptcha/api/siteverify', secret, response).catch(err => {
|
||||||
|
throw `recaptcha-request-failed: ${err}`;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.success !== true) {
|
if (result.success !== true) {
|
||||||
|
@ -56,9 +60,13 @@ export class CaptchaService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async verifyHcaptcha(secret: string, response: string): Promise<void> {
|
public async verifyHcaptcha(secret: string, response: string | null | undefined): Promise<void> {
|
||||||
const result = await this.getCaptchaResponse('https://hcaptcha.com/siteverify', secret, response).catch(e => {
|
if (response == null) {
|
||||||
throw `hcaptcha-request-failed: ${e}`;
|
throw 'hcaptcha-failed: no response provided';
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.getCaptchaResponse('https://hcaptcha.com/siteverify', secret, response).catch(err => {
|
||||||
|
throw `hcaptcha-request-failed: ${err}`;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.success !== true) {
|
if (result.success !== true) {
|
||||||
|
@ -67,9 +75,13 @@ export class CaptchaService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async verifyTurnstile(secret: string, response: string): Promise<void> {
|
public async verifyTurnstile(secret: string, response: string | null | undefined): Promise<void> {
|
||||||
const result = await this.getCaptchaResponse('https://challenges.cloudflare.com/turnstile/v0/siteverify', secret, response).catch(e => {
|
if (response == null) {
|
||||||
throw `turnstile-request-failed: ${e}`;
|
throw 'turnstile-failed: no response provided';
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.getCaptchaResponse('https://challenges.cloudflare.com/turnstile/v0/siteverify', secret, response).catch(err => {
|
||||||
|
throw `turnstile-request-failed: ${err}`;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.success !== true) {
|
if (result.success !== true) {
|
||||||
|
|
|
@ -674,7 +674,7 @@ export class ApRendererService {
|
||||||
* @param last URL of last page (optional)
|
* @param last URL of last page (optional)
|
||||||
* @param orderedItems attached objects (optional)
|
* @param orderedItems attached objects (optional)
|
||||||
*/
|
*/
|
||||||
public renderOrderedCollection(id: string | null, totalItems: any, first?: string, last?: string, orderedItems?: Record<string, unknown>[]) {
|
public renderOrderedCollection(id: string | null, totalItems: any, first?: string, last?: string, orderedItems?: IObject[]) {
|
||||||
const page: any = {
|
const page: any = {
|
||||||
id,
|
id,
|
||||||
type: 'OrderedCollection',
|
type: 'OrderedCollection',
|
||||||
|
|
|
@ -6,7 +6,6 @@ const envOption = {
|
||||||
verbose: false,
|
verbose: false,
|
||||||
withLogTime: false,
|
withLogTime: false,
|
||||||
quiet: false,
|
quiet: false,
|
||||||
slow: false,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const key of Object.keys(envOption) as (keyof typeof envOption)[]) {
|
for (const key of Object.keys(envOption) as (keyof typeof envOption)[]) {
|
||||||
|
|
|
@ -18,7 +18,7 @@ export function createTempDir(): Promise<[string, () => void]> {
|
||||||
(e, path, cleanup) => {
|
(e, path, cleanup) => {
|
||||||
if (e) return rej(e);
|
if (e) return rej(e);
|
||||||
res([path, cleanup]);
|
res([path, cleanup]);
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
11
packages/backend/src/misc/fastify-reply-error.ts
Normal file
11
packages/backend/src/misc/fastify-reply-error.ts
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
// https://www.fastify.io/docs/latest/Reference/Reply/#async-await-and-promises
|
||||||
|
export class FastifyReplyError extends Error {
|
||||||
|
public message: string;
|
||||||
|
public statusCode: number;
|
||||||
|
|
||||||
|
constructor(statusCode: number, message: string) {
|
||||||
|
super(message);
|
||||||
|
this.message = message;
|
||||||
|
this.statusCode = statusCode;
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,8 +1,9 @@
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import Router from '@koa/router';
|
import { FastifyInstance, FastifyRequest, FastifyReply, FastifyPluginOptions } from 'fastify';
|
||||||
import json from 'koa-json-body';
|
import fastifyAccepts from '@fastify/accepts';
|
||||||
import httpSignature from '@peertube/http-signature';
|
import httpSignature from '@peertube/http-signature';
|
||||||
import { Brackets, In, IsNull, LessThan, Not } from 'typeorm';
|
import { Brackets, In, IsNull, LessThan, Not } from 'typeorm';
|
||||||
|
import accepts from 'accepts';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
import type { FollowingsRepository, NotesRepository, EmojisRepository, NoteReactionsRepository, UserProfilesRepository, UserNotePiningsRepository, UsersRepository } from '@/models/index.js';
|
import type { FollowingsRepository, NotesRepository, EmojisRepository, NoteReactionsRepository, UserProfilesRepository, UserNotePiningsRepository, UsersRepository } from '@/models/index.js';
|
||||||
import * as url from '@/misc/prelude/url.js';
|
import * as url from '@/misc/prelude/url.js';
|
||||||
|
@ -56,14 +57,15 @@ export class ActivityPubServerService {
|
||||||
private userKeypairStoreService: UserKeypairStoreService,
|
private userKeypairStoreService: UserKeypairStoreService,
|
||||||
private queryService: QueryService,
|
private queryService: QueryService,
|
||||||
) {
|
) {
|
||||||
|
this.createServer = this.createServer.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
private setResponseType(ctx: Router.RouterContext) {
|
private setResponseType(request: FastifyRequest, reply: FastifyReply): void {
|
||||||
const accept = ctx.accepts(ACTIVITY_JSON, LD_JSON);
|
const accept = request.accepts().type([ACTIVITY_JSON, LD_JSON]);
|
||||||
if (accept === LD_JSON) {
|
if (accept === LD_JSON) {
|
||||||
ctx.response.type = LD_JSON;
|
reply.type(LD_JSON);
|
||||||
} else {
|
} else {
|
||||||
ctx.response.type = ACTIVITY_JSON;
|
reply.type(ACTIVITY_JSON);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -80,31 +82,34 @@ export class ActivityPubServerService {
|
||||||
return this.apRendererService.renderCreate(await this.apRendererService.renderNote(note, false), note);
|
return this.apRendererService.renderCreate(await this.apRendererService.renderNote(note, false), note);
|
||||||
}
|
}
|
||||||
|
|
||||||
private inbox(ctx: Router.RouterContext) {
|
private inbox(request: FastifyRequest, reply: FastifyReply) {
|
||||||
let signature;
|
let signature;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
signature = httpSignature.parseRequest(ctx.req, { 'headers': [] });
|
signature = httpSignature.parseRequest(request.raw, { 'headers': [] });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ctx.status = 401;
|
reply.code(401);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.queueService.inbox(ctx.request.body, signature);
|
this.queueService.inbox(request.body, signature);
|
||||||
|
|
||||||
ctx.status = 202;
|
reply.code(202);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async followers(ctx: Router.RouterContext) {
|
private async followers(
|
||||||
const userId = ctx.params.user;
|
request: FastifyRequest<{ Params: { user: string; }; Querystring: { cursor?: string; page?: string; }; }>,
|
||||||
|
reply: FastifyReply,
|
||||||
|
) {
|
||||||
|
const userId = request.params.user;
|
||||||
|
|
||||||
const cursor = ctx.request.query.cursor;
|
const cursor = request.query.cursor;
|
||||||
if (cursor != null && typeof cursor !== 'string') {
|
if (cursor != null && typeof cursor !== 'string') {
|
||||||
ctx.status = 400;
|
reply.code(400);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const page = ctx.request.query.page === 'true';
|
const page = request.query.page === 'true';
|
||||||
|
|
||||||
const user = await this.usersRepository.findOneBy({
|
const user = await this.usersRepository.findOneBy({
|
||||||
id: userId,
|
id: userId,
|
||||||
|
@ -112,7 +117,7 @@ export class ActivityPubServerService {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (user == null) {
|
if (user == null) {
|
||||||
ctx.status = 404;
|
reply.code(404);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -120,12 +125,12 @@ export class ActivityPubServerService {
|
||||||
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id });
|
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id });
|
||||||
|
|
||||||
if (profile.ffVisibility === 'private') {
|
if (profile.ffVisibility === 'private') {
|
||||||
ctx.status = 403;
|
reply.code(403);
|
||||||
ctx.set('Cache-Control', 'public, max-age=30');
|
reply.header('Cache-Control', 'public, max-age=30');
|
||||||
return;
|
return;
|
||||||
} else if (profile.ffVisibility === 'followers') {
|
} else if (profile.ffVisibility === 'followers') {
|
||||||
ctx.status = 403;
|
reply.code(403);
|
||||||
ctx.set('Cache-Control', 'public, max-age=30');
|
reply.header('Cache-Control', 'public, max-age=30');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
//#endregion
|
//#endregion
|
||||||
|
@ -168,27 +173,30 @@ export class ActivityPubServerService {
|
||||||
})}` : undefined,
|
})}` : undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
ctx.body = this.apRendererService.renderActivity(rendered);
|
this.setResponseType(request, reply);
|
||||||
this.setResponseType(ctx);
|
return (this.apRendererService.renderActivity(rendered));
|
||||||
} else {
|
} else {
|
||||||
// index page
|
// index page
|
||||||
const rendered = this.apRendererService.renderOrderedCollection(partOf, user.followersCount, `${partOf}?page=true`);
|
const rendered = this.apRendererService.renderOrderedCollection(partOf, user.followersCount, `${partOf}?page=true`);
|
||||||
ctx.body = this.apRendererService.renderActivity(rendered);
|
reply.header('Cache-Control', 'public, max-age=180');
|
||||||
ctx.set('Cache-Control', 'public, max-age=180');
|
this.setResponseType(request, reply);
|
||||||
this.setResponseType(ctx);
|
return (this.apRendererService.renderActivity(rendered));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async following(ctx: Router.RouterContext) {
|
private async following(
|
||||||
const userId = ctx.params.user;
|
request: FastifyRequest<{ Params: { user: string; }; Querystring: { cursor?: string; page?: string; }; }>,
|
||||||
|
reply: FastifyReply,
|
||||||
|
) {
|
||||||
|
const userId = request.params.user;
|
||||||
|
|
||||||
const cursor = ctx.request.query.cursor;
|
const cursor = request.query.cursor;
|
||||||
if (cursor != null && typeof cursor !== 'string') {
|
if (cursor != null && typeof cursor !== 'string') {
|
||||||
ctx.status = 400;
|
reply.code(400);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const page = ctx.request.query.page === 'true';
|
const page = request.query.page === 'true';
|
||||||
|
|
||||||
const user = await this.usersRepository.findOneBy({
|
const user = await this.usersRepository.findOneBy({
|
||||||
id: userId,
|
id: userId,
|
||||||
|
@ -196,7 +204,7 @@ export class ActivityPubServerService {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (user == null) {
|
if (user == null) {
|
||||||
ctx.status = 404;
|
reply.code(404);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -204,12 +212,12 @@ export class ActivityPubServerService {
|
||||||
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id });
|
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id });
|
||||||
|
|
||||||
if (profile.ffVisibility === 'private') {
|
if (profile.ffVisibility === 'private') {
|
||||||
ctx.status = 403;
|
reply.code(403);
|
||||||
ctx.set('Cache-Control', 'public, max-age=30');
|
reply.header('Cache-Control', 'public, max-age=30');
|
||||||
return;
|
return;
|
||||||
} else if (profile.ffVisibility === 'followers') {
|
} else if (profile.ffVisibility === 'followers') {
|
||||||
ctx.status = 403;
|
reply.code(403);
|
||||||
ctx.set('Cache-Control', 'public, max-age=30');
|
reply.header('Cache-Control', 'public, max-age=30');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
//#endregion
|
//#endregion
|
||||||
|
@ -252,19 +260,19 @@ export class ActivityPubServerService {
|
||||||
})}` : undefined,
|
})}` : undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
ctx.body = this.apRendererService.renderActivity(rendered);
|
this.setResponseType(request, reply);
|
||||||
this.setResponseType(ctx);
|
return (this.apRendererService.renderActivity(rendered));
|
||||||
} else {
|
} else {
|
||||||
// index page
|
// index page
|
||||||
const rendered = this.apRendererService.renderOrderedCollection(partOf, user.followingCount, `${partOf}?page=true`);
|
const rendered = this.apRendererService.renderOrderedCollection(partOf, user.followingCount, `${partOf}?page=true`);
|
||||||
ctx.body = this.apRendererService.renderActivity(rendered);
|
reply.header('Cache-Control', 'public, max-age=180');
|
||||||
ctx.set('Cache-Control', 'public, max-age=180');
|
this.setResponseType(request, reply);
|
||||||
this.setResponseType(ctx);
|
return (this.apRendererService.renderActivity(rendered));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async featured(ctx: Router.RouterContext) {
|
private async featured(request: FastifyRequest<{ Params: { user: string; }; }>, reply: FastifyReply) {
|
||||||
const userId = ctx.params.user;
|
const userId = request.params.user;
|
||||||
|
|
||||||
const user = await this.usersRepository.findOneBy({
|
const user = await this.usersRepository.findOneBy({
|
||||||
id: userId,
|
id: userId,
|
||||||
|
@ -272,7 +280,7 @@ export class ActivityPubServerService {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (user == null) {
|
if (user == null) {
|
||||||
ctx.status = 404;
|
reply.code(404);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -291,30 +299,36 @@ export class ActivityPubServerService {
|
||||||
renderedNotes.length, undefined, undefined, renderedNotes,
|
renderedNotes.length, undefined, undefined, renderedNotes,
|
||||||
);
|
);
|
||||||
|
|
||||||
ctx.body = this.apRendererService.renderActivity(rendered);
|
reply.header('Cache-Control', 'public, max-age=180');
|
||||||
ctx.set('Cache-Control', 'public, max-age=180');
|
this.setResponseType(request, reply);
|
||||||
this.setResponseType(ctx);
|
return (this.apRendererService.renderActivity(rendered));
|
||||||
}
|
}
|
||||||
|
|
||||||
private async outbox(ctx: Router.RouterContext) {
|
private async outbox(
|
||||||
const userId = ctx.params.user;
|
request: FastifyRequest<{
|
||||||
|
Params: { user: string; };
|
||||||
|
Querystring: { since_id?: string; until_id?: string; page?: string; };
|
||||||
|
}>,
|
||||||
|
reply: FastifyReply,
|
||||||
|
) {
|
||||||
|
const userId = request.params.user;
|
||||||
|
|
||||||
const sinceId = ctx.request.query.since_id;
|
const sinceId = request.query.since_id;
|
||||||
if (sinceId != null && typeof sinceId !== 'string') {
|
if (sinceId != null && typeof sinceId !== 'string') {
|
||||||
ctx.status = 400;
|
reply.code(400);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const untilId = ctx.request.query.until_id;
|
const untilId = request.query.until_id;
|
||||||
if (untilId != null && typeof untilId !== 'string') {
|
if (untilId != null && typeof untilId !== 'string') {
|
||||||
ctx.status = 400;
|
reply.code(400);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const page = ctx.request.query.page === 'true';
|
const page = request.query.page === 'true';
|
||||||
|
|
||||||
if (countIf(x => x != null, [sinceId, untilId]) > 1) {
|
if (countIf(x => x != null, [sinceId, untilId]) > 1) {
|
||||||
ctx.status = 400;
|
reply.code(400);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -324,7 +338,7 @@ export class ActivityPubServerService {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (user == null) {
|
if (user == null) {
|
||||||
ctx.status = 404;
|
reply.code(404);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -362,110 +376,130 @@ export class ActivityPubServerService {
|
||||||
})}` : undefined,
|
})}` : undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
ctx.body = this.apRendererService.renderActivity(rendered);
|
this.setResponseType(request, reply);
|
||||||
this.setResponseType(ctx);
|
return (this.apRendererService.renderActivity(rendered));
|
||||||
} else {
|
} else {
|
||||||
// index page
|
// index page
|
||||||
const rendered = this.apRendererService.renderOrderedCollection(partOf, user.notesCount,
|
const rendered = this.apRendererService.renderOrderedCollection(partOf, user.notesCount,
|
||||||
`${partOf}?page=true`,
|
`${partOf}?page=true`,
|
||||||
`${partOf}?page=true&since_id=000000000000000000000000`,
|
`${partOf}?page=true&since_id=000000000000000000000000`,
|
||||||
);
|
);
|
||||||
ctx.body = this.apRendererService.renderActivity(rendered);
|
reply.header('Cache-Control', 'public, max-age=180');
|
||||||
ctx.set('Cache-Control', 'public, max-age=180');
|
this.setResponseType(request, reply);
|
||||||
this.setResponseType(ctx);
|
return (this.apRendererService.renderActivity(rendered));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async userInfo(ctx: Router.RouterContext, user: User | null) {
|
private async userInfo(request: FastifyRequest, reply: FastifyReply, user: User | null) {
|
||||||
if (user == null) {
|
if (user == null) {
|
||||||
ctx.status = 404;
|
reply.code(404);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.body = this.apRendererService.renderActivity(await this.apRendererService.renderPerson(user as ILocalUser));
|
reply.header('Cache-Control', 'public, max-age=180');
|
||||||
ctx.set('Cache-Control', 'public, max-age=180');
|
this.setResponseType(request, reply);
|
||||||
this.setResponseType(ctx);
|
return (this.apRendererService.renderActivity(await this.apRendererService.renderPerson(user as ILocalUser)));
|
||||||
}
|
}
|
||||||
|
|
||||||
public createRouter() {
|
public createServer(fastify: FastifyInstance, options: FastifyPluginOptions, done: (err?: Error) => void) {
|
||||||
// Init router
|
fastify.addConstraintStrategy({
|
||||||
const router = new Router();
|
name: 'apOrHtml',
|
||||||
|
storage() {
|
||||||
|
const store = {};
|
||||||
|
return {
|
||||||
|
get(key) {
|
||||||
|
return store[key] ?? null;
|
||||||
|
},
|
||||||
|
set(key, value) {
|
||||||
|
store[key] = value;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
deriveConstraint(request, ctx) {
|
||||||
|
const accepted = accepts(request).type(['html', ACTIVITY_JSON, LD_JSON]);
|
||||||
|
const isAp = typeof accepted === 'string' && !accepted.match(/html/);
|
||||||
|
return isAp ? 'ap' : 'html';
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.register(fastifyAccepts);
|
||||||
|
|
||||||
//#region Routing
|
//#region Routing
|
||||||
function isActivityPubReq(ctx: Router.RouterContext) {
|
|
||||||
ctx.response.vary('Accept');
|
|
||||||
const accepted = ctx.accepts('html', ACTIVITY_JSON, LD_JSON);
|
|
||||||
return typeof accepted === 'string' && !accepted.match(/html/);
|
|
||||||
}
|
|
||||||
|
|
||||||
// inbox
|
// inbox
|
||||||
router.post('/inbox', json(), ctx => this.inbox(ctx));
|
fastify.post('/inbox', async (request, reply) => await this.inbox(request, reply));
|
||||||
router.post('/users/:user/inbox', json(), ctx => this.inbox(ctx));
|
fastify.post('/users/:user/inbox', async (request, reply) => await this.inbox(request, reply));
|
||||||
|
|
||||||
// note
|
// note
|
||||||
router.get('/notes/:note', async (ctx, next) => {
|
fastify.get<{ Params: { note: string; } }>('/notes/:note', { constraints: { apOrHtml: 'ap' } }, async (request, reply) => {
|
||||||
if (!isActivityPubReq(ctx)) return await next();
|
|
||||||
|
|
||||||
const note = await this.notesRepository.findOneBy({
|
const note = await this.notesRepository.findOneBy({
|
||||||
id: ctx.params.note,
|
id: request.params.note,
|
||||||
visibility: In(['public' as const, 'home' as const]),
|
visibility: In(['public' as const, 'home' as const]),
|
||||||
localOnly: false,
|
localOnly: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (note == null) {
|
if (note == null) {
|
||||||
ctx.status = 404;
|
reply.code(404);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// リモートだったらリダイレクト
|
// リモートだったらリダイレクト
|
||||||
if (note.userHost != null) {
|
if (note.userHost != null) {
|
||||||
if (note.uri == null || this.utilityService.isSelfHost(note.userHost)) {
|
if (note.uri == null || this.utilityService.isSelfHost(note.userHost)) {
|
||||||
ctx.status = 500;
|
reply.code(500);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ctx.redirect(note.uri);
|
reply.redirect(note.uri);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.body = this.apRendererService.renderActivity(await this.apRendererService.renderNote(note, false));
|
reply.header('Cache-Control', 'public, max-age=180');
|
||||||
ctx.set('Cache-Control', 'public, max-age=180');
|
this.setResponseType(request, reply);
|
||||||
this.setResponseType(ctx);
|
return (this.apRendererService.renderActivity(await this.apRendererService.renderNote(note, false)));
|
||||||
});
|
});
|
||||||
|
|
||||||
// note activity
|
// note activity
|
||||||
router.get('/notes/:note/activity', async ctx => {
|
fastify.get<{ Params: { note: string; } }>('/notes/:note/activity', async (request, reply) => {
|
||||||
const note = await this.notesRepository.findOneBy({
|
const note = await this.notesRepository.findOneBy({
|
||||||
id: ctx.params.note,
|
id: request.params.note,
|
||||||
userHost: IsNull(),
|
userHost: IsNull(),
|
||||||
visibility: In(['public' as const, 'home' as const]),
|
visibility: In(['public' as const, 'home' as const]),
|
||||||
localOnly: false,
|
localOnly: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (note == null) {
|
if (note == null) {
|
||||||
ctx.status = 404;
|
reply.code(404);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.body = this.apRendererService.renderActivity(await this.packActivity(note));
|
reply.header('Cache-Control', 'public, max-age=180');
|
||||||
ctx.set('Cache-Control', 'public, max-age=180');
|
this.setResponseType(request, reply);
|
||||||
this.setResponseType(ctx);
|
return (this.apRendererService.renderActivity(await this.packActivity(note)));
|
||||||
});
|
});
|
||||||
|
|
||||||
// outbox
|
// outbox
|
||||||
router.get('/users/:user/outbox', (ctx) => this.outbox(ctx));
|
fastify.get<{
|
||||||
|
Params: { user: string; };
|
||||||
|
Querystring: { since_id?: string; until_id?: string; page?: string; };
|
||||||
|
}>('/users/:user/outbox', async (request, reply) => await this.outbox(request, reply));
|
||||||
|
|
||||||
// followers
|
// followers
|
||||||
router.get('/users/:user/followers', (ctx) => this.followers(ctx));
|
fastify.get<{
|
||||||
|
Params: { user: string; };
|
||||||
|
Querystring: { cursor?: string; page?: string; };
|
||||||
|
}>('/users/:user/followers', async (request, reply) => await this.followers(request, reply));
|
||||||
|
|
||||||
// following
|
// following
|
||||||
router.get('/users/:user/following', (ctx) => this.following(ctx));
|
fastify.get<{
|
||||||
|
Params: { user: string; };
|
||||||
|
Querystring: { cursor?: string; page?: string; };
|
||||||
|
}>('/users/:user/following', async (request, reply) => await this.following(request, reply));
|
||||||
|
|
||||||
// featured
|
// featured
|
||||||
router.get('/users/:user/collections/featured', (ctx) => this.featured(ctx));
|
fastify.get<{ Params: { user: string; }; }>('/users/:user/collections/featured', async (request, reply) => await this.featured(request, reply));
|
||||||
|
|
||||||
// publickey
|
// publickey
|
||||||
router.get('/users/:user/publickey', async ctx => {
|
fastify.get<{ Params: { user: string; } }>('/users/:user/publickey', async (request, reply) => {
|
||||||
const userId = ctx.params.user;
|
const userId = request.params.user;
|
||||||
|
|
||||||
const user = await this.usersRepository.findOneBy({
|
const user = await this.usersRepository.findOneBy({
|
||||||
id: userId,
|
id: userId,
|
||||||
|
@ -473,25 +507,23 @@ export class ActivityPubServerService {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (user == null) {
|
if (user == null) {
|
||||||
ctx.status = 404;
|
reply.code(404);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const keypair = await this.userKeypairStoreService.getUserKeypair(user.id);
|
const keypair = await this.userKeypairStoreService.getUserKeypair(user.id);
|
||||||
|
|
||||||
if (this.userEntityService.isLocalUser(user)) {
|
if (this.userEntityService.isLocalUser(user)) {
|
||||||
ctx.body = this.apRendererService.renderActivity(this.apRendererService.renderKey(user, keypair));
|
reply.header('Cache-Control', 'public, max-age=180');
|
||||||
ctx.set('Cache-Control', 'public, max-age=180');
|
this.setResponseType(request, reply);
|
||||||
this.setResponseType(ctx);
|
return (this.apRendererService.renderActivity(this.apRendererService.renderKey(user, keypair)));
|
||||||
} else {
|
} else {
|
||||||
ctx.status = 400;
|
reply.code(400);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/users/:user', async (ctx, next) => {
|
fastify.get<{ Params: { user: string; } }>('/users/:user', { constraints: { apOrHtml: 'ap' } }, async (request, reply) => {
|
||||||
if (!isActivityPubReq(ctx)) return await next();
|
const userId = request.params.user;
|
||||||
|
|
||||||
const userId = ctx.params.user;
|
|
||||||
|
|
||||||
const user = await this.usersRepository.findOneBy({
|
const user = await this.usersRepository.findOneBy({
|
||||||
id: userId,
|
id: userId,
|
||||||
|
@ -499,86 +531,84 @@ export class ActivityPubServerService {
|
||||||
isSuspended: false,
|
isSuspended: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.userInfo(ctx, user);
|
return await this.userInfo(request, reply, user);
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/@:user', async (ctx, next) => {
|
fastify.get<{ Params: { user: string; } }>('/@:user', { constraints: { apOrHtml: 'ap' } }, async (request, reply) => {
|
||||||
if (!isActivityPubReq(ctx)) return await next();
|
|
||||||
|
|
||||||
const user = await this.usersRepository.findOneBy({
|
const user = await this.usersRepository.findOneBy({
|
||||||
usernameLower: ctx.params.user.toLowerCase(),
|
usernameLower: request.params.user.toLowerCase(),
|
||||||
host: IsNull(),
|
host: IsNull(),
|
||||||
isSuspended: false,
|
isSuspended: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.userInfo(ctx, user);
|
return await this.userInfo(request, reply, user);
|
||||||
});
|
});
|
||||||
//#endregion
|
//#endregion
|
||||||
|
|
||||||
// emoji
|
// emoji
|
||||||
router.get('/emojis/:emoji', async ctx => {
|
fastify.get<{ Params: { emoji: string; } }>('/emojis/:emoji', async (request, reply) => {
|
||||||
const emoji = await this.emojisRepository.findOneBy({
|
const emoji = await this.emojisRepository.findOneBy({
|
||||||
host: IsNull(),
|
host: IsNull(),
|
||||||
name: ctx.params.emoji,
|
name: request.params.emoji,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (emoji == null) {
|
if (emoji == null) {
|
||||||
ctx.status = 404;
|
reply.code(404);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.body = this.apRendererService.renderActivity(await this.apRendererService.renderEmoji(emoji));
|
reply.header('Cache-Control', 'public, max-age=180');
|
||||||
ctx.set('Cache-Control', 'public, max-age=180');
|
this.setResponseType(request, reply);
|
||||||
this.setResponseType(ctx);
|
return (this.apRendererService.renderActivity(await this.apRendererService.renderEmoji(emoji)));
|
||||||
});
|
});
|
||||||
|
|
||||||
// like
|
// like
|
||||||
router.get('/likes/:like', async ctx => {
|
fastify.get<{ Params: { like: string; } }>('/likes/:like', async (request, reply) => {
|
||||||
const reaction = await this.noteReactionsRepository.findOneBy({ id: ctx.params.like });
|
const reaction = await this.noteReactionsRepository.findOneBy({ id: request.params.like });
|
||||||
|
|
||||||
if (reaction == null) {
|
if (reaction == null) {
|
||||||
ctx.status = 404;
|
reply.code(404);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const note = await this.notesRepository.findOneBy({ id: reaction.noteId });
|
const note = await this.notesRepository.findOneBy({ id: reaction.noteId });
|
||||||
|
|
||||||
if (note == null) {
|
if (note == null) {
|
||||||
ctx.status = 404;
|
reply.code(404);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.body = this.apRendererService.renderActivity(await this.apRendererService.renderLike(reaction, note));
|
reply.header('Cache-Control', 'public, max-age=180');
|
||||||
ctx.set('Cache-Control', 'public, max-age=180');
|
this.setResponseType(request, reply);
|
||||||
this.setResponseType(ctx);
|
return (this.apRendererService.renderActivity(await this.apRendererService.renderLike(reaction, note)));
|
||||||
});
|
});
|
||||||
|
|
||||||
// follow
|
// follow
|
||||||
router.get('/follows/:follower/:followee', async ctx => {
|
fastify.get<{ Params: { follower: string; followee: string; } }>('/follows/:follower/:followee', async (request, reply) => {
|
||||||
// This may be used before the follow is completed, so we do not
|
// This may be used before the follow is completed, so we do not
|
||||||
// check if the following exists.
|
// check if the following exists.
|
||||||
|
|
||||||
const [follower, followee] = await Promise.all([
|
const [follower, followee] = await Promise.all([
|
||||||
this.usersRepository.findOneBy({
|
this.usersRepository.findOneBy({
|
||||||
id: ctx.params.follower,
|
id: request.params.follower,
|
||||||
host: IsNull(),
|
host: IsNull(),
|
||||||
}),
|
}),
|
||||||
this.usersRepository.findOneBy({
|
this.usersRepository.findOneBy({
|
||||||
id: ctx.params.followee,
|
id: request.params.followee,
|
||||||
host: Not(IsNull()),
|
host: Not(IsNull()),
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (follower == null || followee == null) {
|
if (follower == null || followee == null) {
|
||||||
ctx.status = 404;
|
reply.code(404);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.body = this.apRendererService.renderActivity(this.apRendererService.renderFollow(follower, followee));
|
reply.header('Cache-Control', 'public, max-age=180');
|
||||||
ctx.set('Cache-Control', 'public, max-age=180');
|
this.setResponseType(request, reply);
|
||||||
this.setResponseType(ctx);
|
return (this.apRendererService.renderActivity(this.apRendererService.renderFollow(follower, followee)));
|
||||||
});
|
});
|
||||||
|
|
||||||
return router;
|
done();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,10 +2,8 @@ import * as fs from 'node:fs';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { dirname } from 'node:path';
|
import { dirname } from 'node:path';
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import Koa from 'koa';
|
import { FastifyInstance, FastifyRequest, FastifyReply, FastifyPluginOptions } from 'fastify';
|
||||||
import cors from '@koa/cors';
|
import fastifyStatic from '@fastify/static';
|
||||||
import Router from '@koa/router';
|
|
||||||
import send from 'koa-send';
|
|
||||||
import rename from 'rename';
|
import rename from 'rename';
|
||||||
import type { Config } from '@/config.js';
|
import type { Config } from '@/config.js';
|
||||||
import type { DriveFilesRepository } from '@/models/index.js';
|
import type { DriveFilesRepository } from '@/models/index.js';
|
||||||
|
@ -46,45 +44,44 @@ export class FileServerService {
|
||||||
private loggerService: LoggerService,
|
private loggerService: LoggerService,
|
||||||
) {
|
) {
|
||||||
this.logger = this.loggerService.getLogger('server', 'gray', false);
|
this.logger = this.loggerService.getLogger('server', 'gray', false);
|
||||||
|
|
||||||
|
this.createServer = this.createServer.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
public commonReadableHandlerGenerator(ctx: Koa.Context) {
|
public commonReadableHandlerGenerator(reply: FastifyReply) {
|
||||||
return (e: Error): void => {
|
return (err: Error): void => {
|
||||||
this.logger.error(e);
|
this.logger.error(err);
|
||||||
ctx.status = 500;
|
reply.code(500);
|
||||||
ctx.set('Cache-Control', 'max-age=300');
|
reply.header('Cache-Control', 'max-age=300');
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public createServer() {
|
public createServer(fastify: FastifyInstance, options: FastifyPluginOptions, done: (err?: Error) => void) {
|
||||||
const app = new Koa();
|
fastify.addHook('onRequest', (request, reply, done) => {
|
||||||
app.use(cors());
|
reply.header('Content-Security-Policy', 'default-src \'none\'; img-src \'self\'; media-src \'self\'; style-src \'unsafe-inline\'');
|
||||||
app.use(async (ctx, next) => {
|
done();
|
||||||
ctx.set('Content-Security-Policy', 'default-src \'none\'; img-src \'self\'; media-src \'self\'; style-src \'unsafe-inline\'');
|
|
||||||
await next();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Init router
|
fastify.register(fastifyStatic, {
|
||||||
const router = new Router();
|
root: _dirname,
|
||||||
|
serve: false,
|
||||||
|
});
|
||||||
|
|
||||||
router.get('/app-default.jpg', ctx => {
|
fastify.get('/app-default.jpg', (request, reply) => {
|
||||||
const file = fs.createReadStream(`${_dirname}/assets/dummy.png`);
|
const file = fs.createReadStream(`${_dirname}/assets/dummy.png`);
|
||||||
ctx.body = file;
|
reply.header('Content-Type', 'image/jpeg');
|
||||||
ctx.set('Content-Type', 'image/jpeg');
|
reply.header('Cache-Control', 'max-age=31536000, immutable');
|
||||||
ctx.set('Cache-Control', 'max-age=31536000, immutable');
|
return reply.send(file);
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/:key', ctx => this.sendDriveFile(ctx));
|
fastify.get<{ Params: { key: string; } }>('/:key', async (request, reply) => await this.sendDriveFile(request, reply));
|
||||||
router.get('/:key/(.*)', ctx => this.sendDriveFile(ctx));
|
fastify.get<{ Params: { key: string; } }>('/:key/*', async (request, reply) => await this.sendDriveFile(request, reply));
|
||||||
|
|
||||||
// Register router
|
done();
|
||||||
app.use(router.routes());
|
|
||||||
|
|
||||||
return app;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async sendDriveFile(ctx: Koa.Context) {
|
private async sendDriveFile(request: FastifyRequest<{ Params: { key: string; } }>, reply: FastifyReply) {
|
||||||
const key = ctx.params.key;
|
const key = request.params.key;
|
||||||
|
|
||||||
// Fetch drive file
|
// Fetch drive file
|
||||||
const file = await this.driveFilesRepository.createQueryBuilder('file')
|
const file = await this.driveFilesRepository.createQueryBuilder('file')
|
||||||
|
@ -94,10 +91,9 @@ export class FileServerService {
|
||||||
.getOne();
|
.getOne();
|
||||||
|
|
||||||
if (file == null) {
|
if (file == null) {
|
||||||
ctx.status = 404;
|
reply.code(404);
|
||||||
ctx.set('Cache-Control', 'max-age=86400');
|
reply.header('Cache-Control', 'max-age=86400');
|
||||||
await send(ctx as any, '/dummy.png', { root: assets });
|
return reply.sendFile('/dummy.png', assets);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const isThumbnail = file.thumbnailAccessKey === key;
|
const isThumbnail = file.thumbnailAccessKey === key;
|
||||||
|
@ -135,18 +131,18 @@ export class FileServerService {
|
||||||
};
|
};
|
||||||
|
|
||||||
const image = await convertFile();
|
const image = await convertFile();
|
||||||
ctx.body = image.data;
|
reply.header('Content-Type', FILE_TYPE_BROWSERSAFE.includes(image.type) ? image.type : 'application/octet-stream');
|
||||||
ctx.set('Content-Type', FILE_TYPE_BROWSERSAFE.includes(image.type) ? image.type : 'application/octet-stream');
|
reply.header('Cache-Control', 'max-age=31536000, immutable');
|
||||||
ctx.set('Cache-Control', 'max-age=31536000, immutable');
|
return image.data;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error(`${err}`);
|
this.logger.error(`${err}`);
|
||||||
|
|
||||||
if (err instanceof StatusError && err.isClientError) {
|
if (err instanceof StatusError && err.isClientError) {
|
||||||
ctx.status = err.statusCode;
|
reply.code(err.statusCode);
|
||||||
ctx.set('Cache-Control', 'max-age=86400');
|
reply.header('Cache-Control', 'max-age=86400');
|
||||||
} else {
|
} else {
|
||||||
ctx.status = 500;
|
reply.code(500);
|
||||||
ctx.set('Cache-Control', 'max-age=300');
|
reply.header('Cache-Control', 'max-age=300');
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
cleanup();
|
cleanup();
|
||||||
|
@ -154,8 +150,8 @@ export class FileServerService {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.status = 204;
|
reply.code(204);
|
||||||
ctx.set('Cache-Control', 'max-age=86400');
|
reply.header('Cache-Control', 'max-age=86400');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -166,18 +162,17 @@ export class FileServerService {
|
||||||
extname: ext ? `.${ext}` : undefined,
|
extname: ext ? `.${ext}` : undefined,
|
||||||
}).toString();
|
}).toString();
|
||||||
|
|
||||||
ctx.body = this.internalStorageService.read(key);
|
reply.header('Content-Type', FILE_TYPE_BROWSERSAFE.includes(mime) ? mime : 'application/octet-stream');
|
||||||
ctx.set('Content-Type', FILE_TYPE_BROWSERSAFE.includes(mime) ? mime : 'application/octet-stream');
|
reply.header('Cache-Control', 'max-age=31536000, immutable');
|
||||||
ctx.set('Cache-Control', 'max-age=31536000, immutable');
|
reply.header('Content-Disposition', contentDisposition('inline', filename));
|
||||||
ctx.set('Content-Disposition', contentDisposition('inline', filename));
|
return this.internalStorageService.read(key);
|
||||||
} else {
|
} else {
|
||||||
const readable = this.internalStorageService.read(file.accessKey!);
|
const readable = this.internalStorageService.read(file.accessKey!);
|
||||||
readable.on('error', this.commonReadableHandlerGenerator(ctx));
|
readable.on('error', this.commonReadableHandlerGenerator(reply));
|
||||||
ctx.body = readable;
|
reply.header('Content-Type', FILE_TYPE_BROWSERSAFE.includes(file.type) ? file.type : 'application/octet-stream');
|
||||||
ctx.set('Content-Type', FILE_TYPE_BROWSERSAFE.includes(file.type) ? file.type : 'application/octet-stream');
|
reply.header('Cache-Control', 'max-age=31536000, immutable');
|
||||||
ctx.set('Cache-Control', 'max-age=31536000, immutable');
|
reply.header('Content-Disposition', contentDisposition('inline', file.name));
|
||||||
ctx.set('Content-Disposition', contentDisposition('inline', file.name));
|
return readable;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,8 +1,6 @@
|
||||||
import * as fs from 'node:fs';
|
import * as fs from 'node:fs';
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import Koa from 'koa';
|
import { FastifyInstance, FastifyPluginOptions, FastifyReply, FastifyRequest } from 'fastify';
|
||||||
import cors from '@koa/cors';
|
|
||||||
import Router from '@koa/router';
|
|
||||||
import sharp from 'sharp';
|
import sharp from 'sharp';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
import type { Config } from '@/config.js';
|
import type { Config } from '@/config.js';
|
||||||
|
@ -31,32 +29,29 @@ export class MediaProxyServerService {
|
||||||
private loggerService: LoggerService,
|
private loggerService: LoggerService,
|
||||||
) {
|
) {
|
||||||
this.logger = this.loggerService.getLogger('server', 'gray', false);
|
this.logger = this.loggerService.getLogger('server', 'gray', false);
|
||||||
|
|
||||||
|
this.createServer = this.createServer.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
public createServer() {
|
public createServer(fastify: FastifyInstance, options: FastifyPluginOptions, done: (err?: Error) => void) {
|
||||||
const app = new Koa();
|
fastify.addHook('onRequest', (request, reply, done) => {
|
||||||
app.use(cors());
|
reply.header('Content-Security-Policy', 'default-src \'none\'; img-src \'self\'; media-src \'self\'; style-src \'unsafe-inline\'');
|
||||||
app.use(async (ctx, next) => {
|
done();
|
||||||
ctx.set('Content-Security-Policy', 'default-src \'none\'; img-src \'self\'; media-src \'self\'; style-src \'unsafe-inline\'');
|
|
||||||
await next();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Init router
|
fastify.get<{
|
||||||
const router = new Router();
|
Params: { url: string; };
|
||||||
|
Querystring: { url?: string; };
|
||||||
|
}>('/:url*', async (request, reply) => await this.handler(request, reply));
|
||||||
|
|
||||||
router.get('/:url*', ctx => this.handler(ctx));
|
done();
|
||||||
|
|
||||||
// Register router
|
|
||||||
app.use(router.routes());
|
|
||||||
|
|
||||||
return app;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handler(ctx: Koa.Context) {
|
private async handler(request: FastifyRequest<{ Params: { url: string; }; Querystring: { url?: string; }; }>, reply: FastifyReply) {
|
||||||
const url = 'url' in ctx.query ? ctx.query.url : 'https://' + ctx.params.url;
|
const url = 'url' in request.query ? request.query.url : 'https://' + request.params.url;
|
||||||
|
|
||||||
if (typeof url !== 'string') {
|
if (typeof url !== 'string') {
|
||||||
ctx.status = 400;
|
reply.code(400);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -71,11 +66,11 @@ export class MediaProxyServerService {
|
||||||
|
|
||||||
let image: IImage;
|
let image: IImage;
|
||||||
|
|
||||||
if ('static' in ctx.query && isConvertibleImage) {
|
if ('static' in request.query && isConvertibleImage) {
|
||||||
image = await this.imageProcessingService.convertToWebp(path, 498, 280);
|
image = await this.imageProcessingService.convertToWebp(path, 498, 280);
|
||||||
} else if ('preview' in ctx.query && isConvertibleImage) {
|
} else if ('preview' in request.query && isConvertibleImage) {
|
||||||
image = await this.imageProcessingService.convertToWebp(path, 200, 200);
|
image = await this.imageProcessingService.convertToWebp(path, 200, 200);
|
||||||
} else if ('badge' in ctx.query) {
|
} else if ('badge' in request.query) {
|
||||||
if (!isConvertibleImage) {
|
if (!isConvertibleImage) {
|
||||||
// 画像でないなら404でお茶を濁す
|
// 画像でないなら404でお茶を濁す
|
||||||
throw new StatusError('Unexpected mime', 404);
|
throw new StatusError('Unexpected mime', 404);
|
||||||
|
@ -122,16 +117,16 @@ export class MediaProxyServerService {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.set('Content-Type', image.type);
|
reply.header('Content-Type', image.type);
|
||||||
ctx.set('Cache-Control', 'max-age=31536000, immutable');
|
reply.header('Cache-Control', 'max-age=31536000, immutable');
|
||||||
ctx.body = image.data;
|
return image.data;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error(`${err}`);
|
this.logger.error(`${err}`);
|
||||||
|
|
||||||
if (err instanceof StatusError && (err.statusCode === 302 || err.isClientError)) {
|
if (err instanceof StatusError && (err.statusCode === 302 || err.isClientError)) {
|
||||||
ctx.status = err.statusCode;
|
reply.code(err.statusCode);
|
||||||
} else {
|
} else {
|
||||||
ctx.status = 500;
|
reply.code(500);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
cleanup();
|
cleanup();
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import Router from '@koa/router';
|
import { FastifyInstance, FastifyPluginOptions, FastifyReply, FastifyRequest } from 'fastify';
|
||||||
import { IsNull, MoreThan } from 'typeorm';
|
import { IsNull, MoreThan } from 'typeorm';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
import type { NotesRepository, UsersRepository } from '@/models/index.js';
|
import type { NotesRepository, UsersRepository } from '@/models/index.js';
|
||||||
|
@ -27,6 +27,7 @@ export class NodeinfoServerService {
|
||||||
private userEntityService: UserEntityService,
|
private userEntityService: UserEntityService,
|
||||||
private metaService: MetaService,
|
private metaService: MetaService,
|
||||||
) {
|
) {
|
||||||
|
this.createServer = this.createServer.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
public getLinks() {
|
public getLinks() {
|
||||||
|
@ -39,9 +40,7 @@ export class NodeinfoServerService {
|
||||||
}];
|
}];
|
||||||
}
|
}
|
||||||
|
|
||||||
public createRouter() {
|
public createServer(fastify: FastifyInstance, options: FastifyPluginOptions, done: (err?: Error) => void) {
|
||||||
const router = new Router();
|
|
||||||
|
|
||||||
const nodeinfo2 = async () => {
|
const nodeinfo2 = async () => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const [
|
const [
|
||||||
|
@ -108,22 +107,22 @@ export class NodeinfoServerService {
|
||||||
|
|
||||||
const cache = new Cache<Awaited<ReturnType<typeof nodeinfo2>>>(1000 * 60 * 10);
|
const cache = new Cache<Awaited<ReturnType<typeof nodeinfo2>>>(1000 * 60 * 10);
|
||||||
|
|
||||||
router.get(nodeinfo2_1path, async ctx => {
|
fastify.get(nodeinfo2_1path, async (request, reply) => {
|
||||||
const base = await cache.fetch(null, () => nodeinfo2());
|
const base = await cache.fetch(null, () => nodeinfo2());
|
||||||
|
|
||||||
ctx.body = { version: '2.1', ...base };
|
reply.header('Cache-Control', 'public, max-age=600');
|
||||||
ctx.set('Cache-Control', 'public, max-age=600');
|
return { version: '2.1', ...base };
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get(nodeinfo2_0path, async ctx => {
|
fastify.get(nodeinfo2_0path, async (request, reply) => {
|
||||||
const base = await cache.fetch(null, () => nodeinfo2());
|
const base = await cache.fetch(null, () => nodeinfo2());
|
||||||
|
|
||||||
delete (base as any).software.repository;
|
delete (base as any).software.repository;
|
||||||
|
|
||||||
ctx.body = { version: '2.0', ...base };
|
reply.header('Cache-Control', 'public, max-age=600');
|
||||||
ctx.set('Cache-Control', 'public, max-age=600');
|
return { version: '2.0', ...base };
|
||||||
});
|
});
|
||||||
|
|
||||||
return router;
|
done();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,11 +2,7 @@ import cluster from 'node:cluster';
|
||||||
import * as fs from 'node:fs';
|
import * as fs from 'node:fs';
|
||||||
import * as http from 'node:http';
|
import * as http from 'node:http';
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import Koa from 'koa';
|
import Fastify from 'fastify';
|
||||||
import Router from '@koa/router';
|
|
||||||
import mount from 'koa-mount';
|
|
||||||
import koaLogger from 'koa-logger';
|
|
||||||
import * as slow from 'koa-slow';
|
|
||||||
import { IsNull } from 'typeorm';
|
import { IsNull } from 'typeorm';
|
||||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||||
import type { Config } from '@/config.js';
|
import type { Config } from '@/config.js';
|
||||||
|
@ -58,47 +54,29 @@ export class ServerService {
|
||||||
}
|
}
|
||||||
|
|
||||||
public launch() {
|
public launch() {
|
||||||
// Init app
|
const fastify = Fastify({
|
||||||
const koa = new Koa();
|
trustProxy: true,
|
||||||
koa.proxy = true;
|
logger: !['production', 'test'].includes(process.env.NODE_ENV ?? ''),
|
||||||
|
});
|
||||||
if (!['production', 'test'].includes(process.env.NODE_ENV ?? '')) {
|
|
||||||
// Logger
|
|
||||||
koa.use(koaLogger(str => {
|
|
||||||
this.logger.info(str);
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Delay
|
|
||||||
if (envOption.slow) {
|
|
||||||
koa.use(slow({
|
|
||||||
delay: 3000,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// HSTS
|
// HSTS
|
||||||
// 6months (15552000sec)
|
// 6months (15552000sec)
|
||||||
if (this.config.url.startsWith('https') && !this.config.disableHsts) {
|
if (this.config.url.startsWith('https') && !this.config.disableHsts) {
|
||||||
koa.use(async (ctx, next) => {
|
fastify.addHook('onRequest', (request, reply, done) => {
|
||||||
ctx.set('strict-transport-security', 'max-age=15552000; preload');
|
reply.header('strict-transport-security', 'max-age=15552000; preload');
|
||||||
await next();
|
done();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
koa.use(mount('/api', this.apiServerService.createApiServer(koa)));
|
fastify.register(this.apiServerService.createServer, { prefix: '/api' });
|
||||||
koa.use(mount('/files', this.fileServerService.createServer()));
|
fastify.register(this.fileServerService.createServer, { prefix: '/files' });
|
||||||
koa.use(mount('/proxy', this.mediaProxyServerService.createServer()));
|
fastify.register(this.mediaProxyServerService.createServer, { prefix: '/proxy' });
|
||||||
|
fastify.register(this.activityPubServerService.createServer);
|
||||||
|
fastify.register(this.nodeinfoServerService.createServer);
|
||||||
|
fastify.register(this.wellKnownServerService.createServer);
|
||||||
|
|
||||||
// Init router
|
fastify.get<{ Params: { acct: string } }>('/avatar/@:acct', async (request, reply) => {
|
||||||
const router = new Router();
|
const { username, host } = Acct.parse(request.params.acct);
|
||||||
|
|
||||||
// Routing
|
|
||||||
router.use(this.activityPubServerService.createRouter().routes());
|
|
||||||
router.use(this.nodeinfoServerService.createRouter().routes());
|
|
||||||
router.use(this.wellKnownServerService.createRouter().routes());
|
|
||||||
|
|
||||||
router.get('/avatar/@:acct', async ctx => {
|
|
||||||
const { username, host } = Acct.parse(ctx.params.acct);
|
|
||||||
const user = await this.usersRepository.findOne({
|
const user = await this.usersRepository.findOne({
|
||||||
where: {
|
where: {
|
||||||
usernameLower: username.toLowerCase(),
|
usernameLower: username.toLowerCase(),
|
||||||
|
@ -109,28 +87,25 @@ export class ServerService {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
ctx.redirect(this.userEntityService.getAvatarUrlSync(user));
|
reply.redirect(this.userEntityService.getAvatarUrlSync(user));
|
||||||
} else {
|
} else {
|
||||||
ctx.redirect('/static-assets/user-unknown.png');
|
reply.redirect('/static-assets/user-unknown.png');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/identicon/:x', async ctx => {
|
fastify.get<{ Params: { x: string } }>('/identicon/:x', async (request, reply) => {
|
||||||
const [temp, cleanup] = await createTemp();
|
const [temp, cleanup] = await createTemp();
|
||||||
await genIdenticon(ctx.params.x, fs.createWriteStream(temp));
|
await genIdenticon(request.params.x, fs.createWriteStream(temp));
|
||||||
ctx.set('Content-Type', 'image/png');
|
reply.header('Content-Type', 'image/png');
|
||||||
ctx.body = fs.createReadStream(temp).on('close', () => cleanup());
|
return fs.createReadStream(temp).on('close', () => cleanup());
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/verify-email/:code', async ctx => {
|
fastify.get<{ Params: { code: string } }>('/verify-email/:code', async (request, reply) => {
|
||||||
const profile = await this.userProfilesRepository.findOneBy({
|
const profile = await this.userProfilesRepository.findOneBy({
|
||||||
emailVerifyCode: ctx.params.code,
|
emailVerifyCode: request.params.code,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (profile != null) {
|
if (profile != null) {
|
||||||
ctx.body = 'Verify succeeded!';
|
|
||||||
ctx.status = 200;
|
|
||||||
|
|
||||||
await this.userProfilesRepository.update({ userId: profile.userId }, {
|
await this.userProfilesRepository.update({ userId: profile.userId }, {
|
||||||
emailVerified: true,
|
emailVerified: true,
|
||||||
emailVerifyCode: null,
|
emailVerifyCode: null,
|
||||||
|
@ -140,21 +115,19 @@ export class ServerService {
|
||||||
detail: true,
|
detail: true,
|
||||||
includeSecrets: true,
|
includeSecrets: true,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
reply.code(200);
|
||||||
|
return 'Verify succeeded!';
|
||||||
} else {
|
} else {
|
||||||
ctx.status = 404;
|
reply.code(404);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Register router
|
fastify.register(this.clientServerService.createServer);
|
||||||
koa.use(router.routes());
|
|
||||||
|
|
||||||
koa.use(mount(this.clientServerService.createApp()));
|
this.streamingApiServerService.attachStreamingApi(fastify.server);
|
||||||
|
|
||||||
const server = http.createServer(koa.callback());
|
fastify.server.on('error', err => {
|
||||||
|
|
||||||
this.streamingApiServerService.attachStreamingApi(server);
|
|
||||||
|
|
||||||
server.on('error', err => {
|
|
||||||
switch ((err as any).code) {
|
switch ((err as any).code) {
|
||||||
case 'EACCES':
|
case 'EACCES':
|
||||||
this.logger.error(`You do not have permission to listen on port ${this.config.port}.`);
|
this.logger.error(`You do not have permission to listen on port ${this.config.port}.`);
|
||||||
|
@ -168,13 +141,13 @@ export class ServerService {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cluster.isWorker) {
|
if (cluster.isWorker) {
|
||||||
process.send!('listenFailed');
|
process.send!('listenFailed');
|
||||||
} else {
|
} else {
|
||||||
// disableClustering
|
// disableClustering
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
server.listen(this.config.port);
|
fastify.listen({ port: this.config.port });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import Router from '@koa/router';
|
import { FastifyInstance, FastifyPluginOptions, FastifyReply, FastifyRequest } from 'fastify';
|
||||||
import { IsNull, MoreThan } from 'typeorm';
|
import { IsNull, MoreThan } from 'typeorm';
|
||||||
|
import vary from 'vary';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
import type { UsersRepository } from '@/models/index.js';
|
import type { UsersRepository } from '@/models/index.js';
|
||||||
import type { Config } from '@/config.js';
|
import type { Config } from '@/config.js';
|
||||||
|
@ -21,11 +22,10 @@ export class WellKnownServerService {
|
||||||
|
|
||||||
private nodeinfoServerService: NodeinfoServerService,
|
private nodeinfoServerService: NodeinfoServerService,
|
||||||
) {
|
) {
|
||||||
|
this.createServer = this.createServer.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
public createRouter() {
|
public createServer(fastify: FastifyInstance, options: FastifyPluginOptions, done: (err?: Error) => void) {
|
||||||
const router = new Router();
|
|
||||||
|
|
||||||
const XRD = (...x: { element: string, value?: string, attributes?: Record<string, string> }[]) =>
|
const XRD = (...x: { element: string, value?: string, attributes?: Record<string, string> }[]) =>
|
||||||
`<?xml version="1.0" encoding="UTF-8"?><XRD xmlns="http://docs.oasis-open.org/ns/xri/xrd-1.0">${x.map(({ element, value, attributes }) =>
|
`<?xml version="1.0" encoding="UTF-8"?><XRD xmlns="http://docs.oasis-open.org/ns/xri/xrd-1.0">${x.map(({ element, value, attributes }) =>
|
||||||
`<${
|
`<${
|
||||||
|
@ -34,37 +34,35 @@ export class WellKnownServerService {
|
||||||
typeof value === 'string' ? `>${escapeValue(value)}</${element}` : '/'
|
typeof value === 'string' ? `>${escapeValue(value)}</${element}` : '/'
|
||||||
}>`).reduce((a, c) => a + c, '')}</XRD>`;
|
}>`).reduce((a, c) => a + c, '')}</XRD>`;
|
||||||
|
|
||||||
const allPath = '/.well-known/(.*)';
|
const allPath = '/.well-known/*';
|
||||||
const webFingerPath = '/.well-known/webfinger';
|
const webFingerPath = '/.well-known/webfinger';
|
||||||
const jrd = 'application/jrd+json';
|
const jrd = 'application/jrd+json';
|
||||||
const xrd = 'application/xrd+xml';
|
const xrd = 'application/xrd+xml';
|
||||||
|
|
||||||
router.use(allPath, async (ctx, next) => {
|
fastify.addHook('onRequest', (request, reply, done) => {
|
||||||
ctx.set({
|
reply.header('Access-Control-Allow-Headers', 'Accept');
|
||||||
'Access-Control-Allow-Headers': 'Accept',
|
reply.header('Access-Control-Allow-Methods', 'GET, OPTIONS');
|
||||||
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
reply.header('Access-Control-Allow-Origin', '*');
|
||||||
'Access-Control-Allow-Origin': '*',
|
reply.header('Access-Control-Expose-Headers', 'Vary');
|
||||||
'Access-Control-Expose-Headers': 'Vary',
|
done();
|
||||||
});
|
|
||||||
await next();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
router.options(allPath, async ctx => {
|
fastify.options(allPath, async (request, reply) => {
|
||||||
ctx.status = 204;
|
reply.code(204);
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/.well-known/host-meta', async ctx => {
|
fastify.get('/.well-known/host-meta', async (request, reply) => {
|
||||||
ctx.set('Content-Type', xrd);
|
reply.header('Content-Type', xrd);
|
||||||
ctx.body = XRD({ element: 'Link', attributes: {
|
return XRD({ element: 'Link', attributes: {
|
||||||
rel: 'lrdd',
|
rel: 'lrdd',
|
||||||
type: xrd,
|
type: xrd,
|
||||||
template: `${this.config.url}${webFingerPath}?resource={uri}`,
|
template: `${this.config.url}${webFingerPath}?resource={uri}`,
|
||||||
} });
|
} });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/.well-known/host-meta.json', async ctx => {
|
fastify.get('/.well-known/host-meta.json', async (request, reply) => {
|
||||||
ctx.set('Content-Type', jrd);
|
reply.header('Content-Type', jrd);
|
||||||
ctx.body = {
|
return {
|
||||||
links: [{
|
links: [{
|
||||||
rel: 'lrdd',
|
rel: 'lrdd',
|
||||||
type: jrd,
|
type: jrd,
|
||||||
|
@ -73,16 +71,16 @@ export class WellKnownServerService {
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/.well-known/nodeinfo', async ctx => {
|
fastify.get('/.well-known/nodeinfo', async (request, reply) => {
|
||||||
ctx.body = { links: this.nodeinfoServerService.getLinks() };
|
return { links: this.nodeinfoServerService.getLinks() };
|
||||||
});
|
});
|
||||||
|
|
||||||
/* TODO
|
/* TODO
|
||||||
router.get('/.well-known/change-password', async ctx => {
|
fastify.get('/.well-known/change-password', async (request, reply) => {
|
||||||
});
|
});
|
||||||
*/
|
*/
|
||||||
|
|
||||||
router.get(webFingerPath, async ctx => {
|
fastify.get<{ Querystring: { resource: string } }>(webFingerPath, async (request, reply) => {
|
||||||
const fromId = (id: User['id']): FindOptionsWhere<User> => ({
|
const fromId = (id: User['id']): FindOptionsWhere<User> => ({
|
||||||
id,
|
id,
|
||||||
host: IsNull(),
|
host: IsNull(),
|
||||||
|
@ -104,22 +102,22 @@ router.get('/.well-known/change-password', async ctx => {
|
||||||
isSuspended: false,
|
isSuspended: false,
|
||||||
} : 422;
|
} : 422;
|
||||||
|
|
||||||
if (typeof ctx.query.resource !== 'string') {
|
if (typeof request.query.resource !== 'string') {
|
||||||
ctx.status = 400;
|
reply.code(400);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const query = generateQuery(ctx.query.resource.toLowerCase());
|
const query = generateQuery(request.query.resource.toLowerCase());
|
||||||
|
|
||||||
if (typeof query === 'number') {
|
if (typeof query === 'number') {
|
||||||
ctx.status = query;
|
reply.code(query);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await this.usersRepository.findOneBy(query);
|
const user = await this.usersRepository.findOneBy(query);
|
||||||
|
|
||||||
if (user == null) {
|
if (user == null) {
|
||||||
ctx.status = 404;
|
reply.code(404);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -139,30 +137,25 @@ router.get('/.well-known/change-password', async ctx => {
|
||||||
template: `${this.config.url}/authorize-follow?acct={uri}`,
|
template: `${this.config.url}/authorize-follow?acct={uri}`,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (ctx.accepts(jrd, xrd) === xrd) {
|
vary(reply.raw, 'Accept');
|
||||||
ctx.body = XRD(
|
reply.header('Cache-Control', 'public, max-age=180');
|
||||||
|
|
||||||
|
if (request.accepts().type([jrd, xrd]) === xrd) {
|
||||||
|
reply.type(xrd);
|
||||||
|
return XRD(
|
||||||
{ element: 'Subject', value: subject },
|
{ element: 'Subject', value: subject },
|
||||||
{ element: 'Link', attributes: self },
|
{ element: 'Link', attributes: self },
|
||||||
{ element: 'Link', attributes: profilePage },
|
{ element: 'Link', attributes: profilePage },
|
||||||
{ element: 'Link', attributes: subscribe });
|
{ element: 'Link', attributes: subscribe });
|
||||||
ctx.type = xrd;
|
|
||||||
} else {
|
} else {
|
||||||
ctx.body = {
|
reply.type(jrd);
|
||||||
|
return {
|
||||||
subject,
|
subject,
|
||||||
links: [self, profilePage, subscribe],
|
links: [self, profilePage, subscribe],
|
||||||
};
|
};
|
||||||
ctx.type = jrd;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.vary('Accept');
|
|
||||||
ctx.set('Cache-Control', 'public, max-age=180');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Return 404 for other .well-known
|
done();
|
||||||
router.all(allPath, async ctx => {
|
|
||||||
ctx.status = 404;
|
|
||||||
});
|
|
||||||
|
|
||||||
return router;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,19 +1,25 @@
|
||||||
import { performance } from 'perf_hooks';
|
import { performance } from 'perf_hooks';
|
||||||
|
import { pipeline } from 'node:stream';
|
||||||
|
import * as fs from 'node:fs';
|
||||||
|
import { promisify } from 'node:util';
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { FastifyRequest, FastifyReply } from 'fastify';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
import { getIpHash } from '@/misc/get-ip-hash.js';
|
import { getIpHash } from '@/misc/get-ip-hash.js';
|
||||||
import type { CacheableLocalUser, User } from '@/models/entities/User.js';
|
import type { CacheableLocalUser, ILocalUser, User } from '@/models/entities/User.js';
|
||||||
import type { AccessToken } from '@/models/entities/AccessToken.js';
|
import type { AccessToken } from '@/models/entities/AccessToken.js';
|
||||||
import type Logger from '@/logger.js';
|
import type Logger from '@/logger.js';
|
||||||
import type { UserIpsRepository } from '@/models/index.js';
|
import type { UserIpsRepository } from '@/models/index.js';
|
||||||
import { MetaService } from '@/core/MetaService.js';
|
import { MetaService } from '@/core/MetaService.js';
|
||||||
|
import { createTemp } from '@/misc/create-temp.js';
|
||||||
import { ApiError } from './error.js';
|
import { ApiError } from './error.js';
|
||||||
import { RateLimiterService } from './RateLimiterService.js';
|
import { RateLimiterService } from './RateLimiterService.js';
|
||||||
import { ApiLoggerService } from './ApiLoggerService.js';
|
import { ApiLoggerService } from './ApiLoggerService.js';
|
||||||
import { AuthenticateService, AuthenticationError } from './AuthenticateService.js';
|
import { AuthenticateService, AuthenticationError } from './AuthenticateService.js';
|
||||||
import type { OnApplicationShutdown } from '@nestjs/common';
|
import type { OnApplicationShutdown } from '@nestjs/common';
|
||||||
import type { IEndpointMeta, IEndpoint } from './endpoints.js';
|
import type { IEndpointMeta, IEndpoint } from './endpoints.js';
|
||||||
import type Koa from 'koa';
|
|
||||||
|
const pump = promisify(pipeline);
|
||||||
|
|
||||||
const accessDenied = {
|
const accessDenied = {
|
||||||
message: 'Access denied.',
|
message: 'Access denied.',
|
||||||
|
@ -44,92 +50,149 @@ export class ApiCallService implements OnApplicationShutdown {
|
||||||
}, 1000 * 60 * 60);
|
}, 1000 * 60 * 60);
|
||||||
}
|
}
|
||||||
|
|
||||||
public handleRequest(endpoint: IEndpoint, exec: any, ctx: Koa.Context) {
|
public handleRequest(
|
||||||
return new Promise<void>((res) => {
|
endpoint: IEndpoint & { exec: any },
|
||||||
const body = ctx.is('multipart/form-data')
|
request: FastifyRequest<{ Body: Record<string, unknown>, Querystring: Record<string, unknown> }>,
|
||||||
? (ctx.request as any).body
|
reply: FastifyReply,
|
||||||
: ctx.method === 'GET'
|
) {
|
||||||
? ctx.query
|
const body = request.method === 'GET'
|
||||||
: ctx.request.body;
|
? request.query
|
||||||
|
: request.body;
|
||||||
const reply = (x?: any, y?: ApiError) => {
|
|
||||||
if (x == null) {
|
const token = body['i'];
|
||||||
ctx.status = 204;
|
if (token != null && typeof token !== 'string') {
|
||||||
} else if (typeof x === 'number' && y) {
|
reply.code(400);
|
||||||
ctx.status = x;
|
return;
|
||||||
ctx.body = {
|
}
|
||||||
error: {
|
this.authenticateService.authenticate(token).then(([user, app]) => {
|
||||||
message: y!.message,
|
this.call(endpoint, user, app, body, null, request).then((res) => {
|
||||||
code: y!.code,
|
if (request.method === 'GET' && endpoint.meta.cacheSec && !body['i'] && !user) {
|
||||||
id: y!.id,
|
reply.header('Cache-Control', `public, max-age=${endpoint.meta.cacheSec}`);
|
||||||
kind: y!.kind,
|
|
||||||
...(y!.info ? { info: y!.info } : {}),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
// 文字列を返す場合は、JSON.stringify通さないとJSONと認識されない
|
|
||||||
ctx.body = typeof x === 'string' ? JSON.stringify(x) : x;
|
|
||||||
}
|
|
||||||
res();
|
|
||||||
};
|
|
||||||
|
|
||||||
// Authentication
|
|
||||||
this.authenticateService.authenticate(body['i']).then(([user, app]) => {
|
|
||||||
// API invoking
|
|
||||||
this.call(endpoint, exec, user, app, body, ctx).then((res: any) => {
|
|
||||||
if (ctx.method === 'GET' && endpoint.meta.cacheSec && !body['i'] && !user) {
|
|
||||||
ctx.set('Cache-Control', `public, max-age=${endpoint.meta.cacheSec}`);
|
|
||||||
}
|
|
||||||
reply(res);
|
|
||||||
}).catch((e: ApiError) => {
|
|
||||||
reply(e.httpStatusCode ? e.httpStatusCode : e.kind === 'client' ? 400 : 500, e);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Log IP
|
|
||||||
if (user) {
|
|
||||||
this.metaService.fetch().then(meta => {
|
|
||||||
if (!meta.enableIpLogging) return;
|
|
||||||
const ip = ctx.ip;
|
|
||||||
const ips = this.userIpHistories.get(user.id);
|
|
||||||
if (ips == null || !ips.has(ip)) {
|
|
||||||
if (ips == null) {
|
|
||||||
this.userIpHistories.set(user.id, new Set([ip]));
|
|
||||||
} else {
|
|
||||||
ips.add(ip);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
this.userIpsRepository.createQueryBuilder().insert().values({
|
|
||||||
createdAt: new Date(),
|
|
||||||
userId: user.id,
|
|
||||||
ip: ip,
|
|
||||||
}).orIgnore(true).execute();
|
|
||||||
} catch {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}).catch(e => {
|
|
||||||
if (e instanceof AuthenticationError) {
|
|
||||||
reply(403, new ApiError({
|
|
||||||
message: 'Authentication failed. Please ensure your token is correct.',
|
|
||||||
code: 'AUTHENTICATION_FAILED',
|
|
||||||
id: 'b0a7f5f8-dc2f-4171-b91f-de88ad238e14',
|
|
||||||
}));
|
|
||||||
} else {
|
|
||||||
reply(500, new ApiError());
|
|
||||||
}
|
}
|
||||||
|
this.send(reply, res);
|
||||||
|
}).catch((err: ApiError) => {
|
||||||
|
this.send(reply, err.httpStatusCode ? err.httpStatusCode : err.kind === 'client' ? 400 : 500, err);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
this.logIp(request, user);
|
||||||
|
}
|
||||||
|
}).catch(err => {
|
||||||
|
if (err instanceof AuthenticationError) {
|
||||||
|
this.send(reply, 403, new ApiError({
|
||||||
|
message: 'Authentication failed. Please ensure your token is correct.',
|
||||||
|
code: 'AUTHENTICATION_FAILED',
|
||||||
|
id: 'b0a7f5f8-dc2f-4171-b91f-de88ad238e14',
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
this.send(reply, 500, new ApiError());
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async handleMultipartRequest(
|
||||||
|
endpoint: IEndpoint & { exec: any },
|
||||||
|
request: FastifyRequest<{ Body: Record<string, unknown>, Querystring: Record<string, unknown> }>,
|
||||||
|
reply: FastifyReply,
|
||||||
|
) {
|
||||||
|
const multipartData = await request.file();
|
||||||
|
if (multipartData == null) {
|
||||||
|
reply.code(400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [path] = await createTemp();
|
||||||
|
await pump(multipartData.file, fs.createWriteStream(path));
|
||||||
|
|
||||||
|
const fields = {} as Record<string, string | undefined>;
|
||||||
|
for (const [k, v] of Object.entries(multipartData.fields)) {
|
||||||
|
fields[k] = v.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = fields['i'];
|
||||||
|
if (token != null && typeof token !== 'string') {
|
||||||
|
reply.code(400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.authenticateService.authenticate(token).then(([user, app]) => {
|
||||||
|
this.call(endpoint, user, app, fields, {
|
||||||
|
name: multipartData.filename,
|
||||||
|
path: path,
|
||||||
|
}, request).then((res) => {
|
||||||
|
this.send(reply, res);
|
||||||
|
}).catch((err: ApiError) => {
|
||||||
|
this.send(reply, err.httpStatusCode ? err.httpStatusCode : err.kind === 'client' ? 400 : 500, err);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
this.logIp(request, user);
|
||||||
|
}
|
||||||
|
}).catch(err => {
|
||||||
|
if (err instanceof AuthenticationError) {
|
||||||
|
this.send(reply, 403, new ApiError({
|
||||||
|
message: 'Authentication failed. Please ensure your token is correct.',
|
||||||
|
code: 'AUTHENTICATION_FAILED',
|
||||||
|
id: 'b0a7f5f8-dc2f-4171-b91f-de88ad238e14',
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
this.send(reply, 500, new ApiError());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private send(reply: FastifyReply, x?: any, y?: ApiError) {
|
||||||
|
if (x == null) {
|
||||||
|
reply.code(204);
|
||||||
|
} else if (typeof x === 'number' && y) {
|
||||||
|
reply.code(x);
|
||||||
|
reply.send({
|
||||||
|
error: {
|
||||||
|
message: y!.message,
|
||||||
|
code: y!.code,
|
||||||
|
id: y!.id,
|
||||||
|
kind: y!.kind,
|
||||||
|
...(y!.info ? { info: y!.info } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// 文字列を返す場合は、JSON.stringify通さないとJSONと認識されない
|
||||||
|
reply.send(typeof x === 'string' ? JSON.stringify(x) : x);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async logIp(request: FastifyRequest, user: ILocalUser) {
|
||||||
|
const meta = await this.metaService.fetch();
|
||||||
|
if (!meta.enableIpLogging) return;
|
||||||
|
const ip = request.ip;
|
||||||
|
const ips = this.userIpHistories.get(user.id);
|
||||||
|
if (ips == null || !ips.has(ip)) {
|
||||||
|
if (ips == null) {
|
||||||
|
this.userIpHistories.set(user.id, new Set([ip]));
|
||||||
|
} else {
|
||||||
|
ips.add(ip);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.userIpsRepository.createQueryBuilder().insert().values({
|
||||||
|
createdAt: new Date(),
|
||||||
|
userId: user.id,
|
||||||
|
ip: ip,
|
||||||
|
}).orIgnore(true).execute();
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async call(
|
private async call(
|
||||||
ep: IEndpoint,
|
ep: IEndpoint & { exec: any },
|
||||||
exec: any,
|
|
||||||
user: CacheableLocalUser | null | undefined,
|
user: CacheableLocalUser | null | undefined,
|
||||||
token: AccessToken | null | undefined,
|
token: AccessToken | null | undefined,
|
||||||
data: any,
|
data: any,
|
||||||
ctx?: Koa.Context,
|
file: {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
} | null,
|
||||||
|
request: FastifyRequest<{ Body: Record<string, unknown>, Querystring: Record<string, unknown> }>,
|
||||||
) {
|
) {
|
||||||
const isSecure = user != null && token == null;
|
const isSecure = user != null && token == null;
|
||||||
const isModerator = user != null && (user.isModerator || user.isAdmin);
|
const isModerator = user != null && (user.isModerator || user.isAdmin);
|
||||||
|
@ -144,7 +207,7 @@ export class ApiCallService implements OnApplicationShutdown {
|
||||||
if (user) {
|
if (user) {
|
||||||
limitActor = user.id;
|
limitActor = user.id;
|
||||||
} else {
|
} else {
|
||||||
limitActor = getIpHash(ctx!.ip);
|
limitActor = getIpHash(request.ip);
|
||||||
}
|
}
|
||||||
|
|
||||||
const limit = Object.assign({}, ep.meta.limit);
|
const limit = Object.assign({}, ep.meta.limit);
|
||||||
|
@ -154,7 +217,7 @@ export class ApiCallService implements OnApplicationShutdown {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rate limit
|
// Rate limit
|
||||||
await this.rateLimiterService.limit(limit as IEndpointMeta['limit'] & { key: NonNullable<string> }, limitActor).catch(e => {
|
await this.rateLimiterService.limit(limit as IEndpointMeta['limit'] & { key: NonNullable<string> }, limitActor).catch(err => {
|
||||||
throw new ApiError({
|
throw new ApiError({
|
||||||
message: 'Rate limit exceeded. Please try again later.',
|
message: 'Rate limit exceeded. Please try again later.',
|
||||||
code: 'RATE_LIMIT_EXCEEDED',
|
code: 'RATE_LIMIT_EXCEEDED',
|
||||||
|
@ -199,7 +262,7 @@ export class ApiCallService implements OnApplicationShutdown {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cast non JSON input
|
// Cast non JSON input
|
||||||
if ((ep.meta.requireFile || ctx?.method === 'GET') && ep.params.properties) {
|
if ((ep.meta.requireFile || request.method === 'GET') && ep.params.properties) {
|
||||||
for (const k of Object.keys(ep.params.properties)) {
|
for (const k of Object.keys(ep.params.properties)) {
|
||||||
const param = ep.params.properties![k];
|
const param = ep.params.properties![k];
|
||||||
if (['boolean', 'number', 'integer'].includes(param.type ?? '') && typeof data[k] === 'string') {
|
if (['boolean', 'number', 'integer'].includes(param.type ?? '') && typeof data[k] === 'string') {
|
||||||
|
@ -221,7 +284,7 @@ export class ApiCallService implements OnApplicationShutdown {
|
||||||
|
|
||||||
// API invoking
|
// API invoking
|
||||||
const before = performance.now();
|
const before = performance.now();
|
||||||
return await exec(data, user, token, ctx?.file, ctx?.ip, ctx?.headers).catch((err: Error) => {
|
return await ep.exec(data, user, token, file, request.ip, request.headers).catch((err: Error) => {
|
||||||
if (err instanceof ApiError) {
|
if (err instanceof ApiError) {
|
||||||
throw err;
|
throw err;
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -1,15 +1,13 @@
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import Koa from 'koa';
|
import { FastifyInstance, FastifyPluginOptions } from 'fastify';
|
||||||
import Router from '@koa/router';
|
import cors from '@fastify/cors';
|
||||||
import multer from '@koa/multer';
|
import multipart from '@fastify/multipart';
|
||||||
import bodyParser from 'koa-bodyparser';
|
import { ModuleRef, repl } from '@nestjs/core';
|
||||||
import cors from '@koa/cors';
|
|
||||||
import { ModuleRef } from '@nestjs/core';
|
|
||||||
import type { Config } from '@/config.js';
|
import type { Config } from '@/config.js';
|
||||||
import type { UsersRepository, InstancesRepository, AccessTokensRepository } from '@/models/index.js';
|
import type { UsersRepository, InstancesRepository, AccessTokensRepository } from '@/models/index.js';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||||
import endpoints from './endpoints.js';
|
import endpoints, { IEndpoint } from './endpoints.js';
|
||||||
import { ApiCallService } from './ApiCallService.js';
|
import { ApiCallService } from './ApiCallService.js';
|
||||||
import { SignupApiService } from './SignupApiService.js';
|
import { SignupApiService } from './SignupApiService.js';
|
||||||
import { SigninApiService } from './SigninApiService.js';
|
import { SigninApiService } from './SigninApiService.js';
|
||||||
|
@ -42,92 +40,107 @@ export class ApiServerService {
|
||||||
private discordServerService: DiscordServerService,
|
private discordServerService: DiscordServerService,
|
||||||
private twitterServerService: TwitterServerService,
|
private twitterServerService: TwitterServerService,
|
||||||
) {
|
) {
|
||||||
|
this.createServer = this.createServer.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
public createApiServer() {
|
public createServer(fastify: FastifyInstance, options: FastifyPluginOptions, done: (err?: Error) => void) {
|
||||||
const handlers: Record<string, any> = {};
|
fastify.register(cors, {
|
||||||
|
|
||||||
for (const endpoint of endpoints) {
|
|
||||||
handlers[endpoint.name] = this.moduleRef.get('ep:' + endpoint.name, { strict: false }).exec;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Init app
|
|
||||||
const apiServer = new Koa();
|
|
||||||
|
|
||||||
apiServer.use(cors({
|
|
||||||
origin: '*',
|
origin: '*',
|
||||||
}));
|
|
||||||
|
|
||||||
// No caching
|
|
||||||
apiServer.use(async (ctx, next) => {
|
|
||||||
ctx.set('Cache-Control', 'private, max-age=0, must-revalidate');
|
|
||||||
await next();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
apiServer.use(bodyParser({
|
fastify.register(multipart, {
|
||||||
// リクエストが multipart/form-data でない限りはJSONだと見なす
|
|
||||||
detectJSON: ctx => !ctx.is('multipart/form-data'),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Init multer instance
|
|
||||||
const upload = multer({
|
|
||||||
storage: multer.diskStorage({}),
|
|
||||||
limits: {
|
limits: {
|
||||||
fileSize: this.config.maxFileSize ?? 262144000,
|
fileSize: this.config.maxFileSize ?? 262144000,
|
||||||
files: 1,
|
files: 1,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Init router
|
// Prevent cache
|
||||||
const router = new Router();
|
fastify.addHook('onRequest', (request, reply, done) => {
|
||||||
|
reply.header('Cache-Control', 'private, max-age=0, must-revalidate');
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* Register endpoint handlers
|
|
||||||
*/
|
|
||||||
for (const endpoint of endpoints) {
|
for (const endpoint of endpoints) {
|
||||||
|
const ep = {
|
||||||
|
name: endpoint.name,
|
||||||
|
meta: endpoint.meta,
|
||||||
|
params: endpoint.params,
|
||||||
|
exec: this.moduleRef.get('ep:' + endpoint.name, { strict: false }).exec,
|
||||||
|
};
|
||||||
|
|
||||||
if (endpoint.meta.requireFile) {
|
if (endpoint.meta.requireFile) {
|
||||||
router.post(`/${endpoint.name}`, upload.single('file'), this.apiCallService.handleRequest.bind(this.apiCallService, endpoint, handlers[endpoint.name]));
|
fastify.all<{
|
||||||
} else {
|
Params: { endpoint: string; },
|
||||||
// 後方互換性のため
|
Body: Record<string, unknown>,
|
||||||
if (endpoint.name.includes('-')) {
|
Querystring: Record<string, unknown>,
|
||||||
router.post(`/${endpoint.name.replace(/-/g, '_')}`, this.apiCallService.handleRequest.bind(this.apiCallService, endpoint, handlers[endpoint.name]));
|
}>('/' + endpoint.name, (request, reply) => {
|
||||||
|
if (request.method === 'GET' && !endpoint.meta.allowGet) {
|
||||||
if (endpoint.meta.allowGet) {
|
reply.code(405);
|
||||||
router.get(`/${endpoint.name.replace(/-/g, '_')}`, this.apiCallService.handleRequest.bind(this.apiCallService, endpoint, handlers[endpoint.name]));
|
return;
|
||||||
} else {
|
|
||||||
router.get(`/${endpoint.name.replace(/-/g, '_')}`, async ctx => { ctx.status = 405; });
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
this.apiCallService.handleMultipartRequest(ep, request, reply);
|
||||||
router.post(`/${endpoint.name}`, this.apiCallService.handleRequest.bind(this.apiCallService, endpoint, handlers[endpoint.name]));
|
});
|
||||||
|
} else {
|
||||||
if (endpoint.meta.allowGet) {
|
fastify.all<{
|
||||||
router.get(`/${endpoint.name}`, this.apiCallService.handleRequest.bind(this.apiCallService, endpoint, handlers[endpoint.name]));
|
Params: { endpoint: string; },
|
||||||
} else {
|
Body: Record<string, unknown>,
|
||||||
router.get(`/${endpoint.name}`, async ctx => { ctx.status = 405; });
|
Querystring: Record<string, unknown>,
|
||||||
}
|
}>('/' + endpoint.name, (request, reply) => {
|
||||||
|
if (request.method === 'GET' && !endpoint.meta.allowGet) {
|
||||||
|
reply.code(405);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.apiCallService.handleRequest(ep, request, reply);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
router.post('/signup', ctx => this.signupApiServiceService.signup(ctx));
|
fastify.post<{
|
||||||
router.post('/signin', ctx => this.signinApiServiceService.signin(ctx));
|
Body: {
|
||||||
router.post('/signup-pending', ctx => this.signupApiServiceService.signupPending(ctx));
|
username: string;
|
||||||
|
password: string;
|
||||||
|
host?: string;
|
||||||
|
invitationCode?: string;
|
||||||
|
emailAddress?: string;
|
||||||
|
'hcaptcha-response'?: string;
|
||||||
|
'g-recaptcha-response'?: string;
|
||||||
|
'turnstile-response'?: string;
|
||||||
|
}
|
||||||
|
}>('/signup', (request, reply) => this.signupApiServiceService.signup(request, reply));
|
||||||
|
|
||||||
router.use(this.discordServerService.create().routes());
|
fastify.post<{
|
||||||
router.use(this.githubServerService.create().routes());
|
Body: {
|
||||||
router.use(this.twitterServerService.create().routes());
|
username: string;
|
||||||
|
password: string;
|
||||||
|
token?: string;
|
||||||
|
signature?: string;
|
||||||
|
authenticatorData?: string;
|
||||||
|
clientDataJSON?: string;
|
||||||
|
credentialId?: string;
|
||||||
|
challengeId?: string;
|
||||||
|
};
|
||||||
|
}>('/signin', (request, reply) => this.signinApiServiceService.signin(request, reply));
|
||||||
|
|
||||||
router.get('/v1/instance/peers', async ctx => {
|
fastify.post<{ Body: { code: string; } }>('/signup-pending', (request, reply) => this.signupApiServiceService.signupPending(request, reply));
|
||||||
|
|
||||||
|
fastify.register(this.discordServerService.create);
|
||||||
|
fastify.register(this.githubServerService.create);
|
||||||
|
fastify.register(this.twitterServerService.create);
|
||||||
|
|
||||||
|
fastify.get('/v1/instance/peers', async (request, reply) => {
|
||||||
const instances = await this.instancesRepository.find({
|
const instances = await this.instancesRepository.find({
|
||||||
select: ['host'],
|
select: ['host'],
|
||||||
});
|
});
|
||||||
|
|
||||||
ctx.body = instances.map(instance => instance.host);
|
return instances.map(instance => instance.host);
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/miauth/:session/check', async ctx => {
|
fastify.post<{ Params: { session: string; } }>('/miauth/:session/check', async (request, reply) => {
|
||||||
const token = await this.accessTokensRepository.findOneBy({
|
const token = await this.accessTokensRepository.findOneBy({
|
||||||
session: ctx.params.session,
|
session: request.params.session,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (token && token.session != null && !token.fetched) {
|
if (token && token.session != null && !token.fetched) {
|
||||||
|
@ -135,26 +148,18 @@ export class ApiServerService {
|
||||||
fetched: true,
|
fetched: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
ctx.body = {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
token: token.token,
|
token: token.token,
|
||||||
user: await this.userEntityService.pack(token.userId, null, { detail: true }),
|
user: await this.userEntityService.pack(token.userId, null, { detail: true }),
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
ctx.body = {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Return 404 for unknown API
|
done();
|
||||||
router.all('(.*)', async ctx => {
|
|
||||||
ctx.status = 404;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Register router
|
|
||||||
apiServer.use(router.routes());
|
|
||||||
|
|
||||||
return apiServer;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -34,7 +34,7 @@ export class AuthenticateService {
|
||||||
this.appCache = new Cache<App>(Infinity);
|
this.appCache = new Cache<App>(Infinity);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async authenticate(token: string | null): Promise<[CacheableLocalUser | null | undefined, AccessToken | null | undefined]> {
|
public async authenticate(token: string | null | undefined): Promise<[CacheableLocalUser | null | undefined, AccessToken | null | undefined]> {
|
||||||
if (token == null) {
|
if (token == null) {
|
||||||
return [null, null];
|
return [null, null];
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,6 +3,7 @@ import { Inject, Injectable } from '@nestjs/common';
|
||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
import * as speakeasy from 'speakeasy';
|
import * as speakeasy from 'speakeasy';
|
||||||
import { IsNull } from 'typeorm';
|
import { IsNull } from 'typeorm';
|
||||||
|
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
import type { UserSecurityKeysRepository, SigninsRepository, UserProfilesRepository, AttestationChallengesRepository, UsersRepository } from '@/models/index.js';
|
import type { UserSecurityKeysRepository, SigninsRepository, UserProfilesRepository, AttestationChallengesRepository, UsersRepository } from '@/models/index.js';
|
||||||
import type { Config } from '@/config.js';
|
import type { Config } from '@/config.js';
|
||||||
|
@ -12,7 +13,6 @@ import { IdService } from '@/core/IdService.js';
|
||||||
import { TwoFactorAuthenticationService } from '@/core/TwoFactorAuthenticationService.js';
|
import { TwoFactorAuthenticationService } from '@/core/TwoFactorAuthenticationService.js';
|
||||||
import { RateLimiterService } from './RateLimiterService.js';
|
import { RateLimiterService } from './RateLimiterService.js';
|
||||||
import { SigninService } from './SigninService.js';
|
import { SigninService } from './SigninService.js';
|
||||||
import type Koa from 'koa';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SigninApiService {
|
export class SigninApiService {
|
||||||
|
@ -42,47 +42,60 @@ export class SigninApiService {
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
public async signin(ctx: Koa.Context) {
|
public async signin(
|
||||||
ctx.set('Access-Control-Allow-Origin', this.config.url);
|
request: FastifyRequest<{
|
||||||
ctx.set('Access-Control-Allow-Credentials', 'true');
|
Body: {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
token?: string;
|
||||||
|
signature?: string;
|
||||||
|
authenticatorData?: string;
|
||||||
|
clientDataJSON?: string;
|
||||||
|
credentialId?: string;
|
||||||
|
challengeId?: string;
|
||||||
|
};
|
||||||
|
}>,
|
||||||
|
reply: FastifyReply,
|
||||||
|
) {
|
||||||
|
reply.header('Access-Control-Allow-Origin', this.config.url);
|
||||||
|
reply.header('Access-Control-Allow-Credentials', 'true');
|
||||||
|
|
||||||
const body = ctx.request.body as any;
|
const body = request.body;
|
||||||
const username = body['username'];
|
const username = body['username'];
|
||||||
const password = body['password'];
|
const password = body['password'];
|
||||||
const token = body['token'];
|
const token = body['token'];
|
||||||
|
|
||||||
function error(status: number, error: { id: string }) {
|
function error(status: number, error: { id: string }) {
|
||||||
ctx.status = status;
|
reply.code(status);
|
||||||
ctx.body = { error };
|
return { error };
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// not more than 1 attempt per second and not more than 10 attempts per hour
|
// not more than 1 attempt per second and not more than 10 attempts per hour
|
||||||
await this.rateLimiterService.limit({ key: 'signin', duration: 60 * 60 * 1000, max: 10, minInterval: 1000 }, getIpHash(ctx.ip));
|
await this.rateLimiterService.limit({ key: 'signin', duration: 60 * 60 * 1000, max: 10, minInterval: 1000 }, getIpHash(request.ip));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
ctx.status = 429;
|
reply.code(429);
|
||||||
ctx.body = {
|
return {
|
||||||
error: {
|
error: {
|
||||||
message: 'Too many failed attempts to sign in. Try again later.',
|
message: 'Too many failed attempts to sign in. Try again later.',
|
||||||
code: 'TOO_MANY_AUTHENTICATION_FAILURES',
|
code: 'TOO_MANY_AUTHENTICATION_FAILURES',
|
||||||
id: '22d05606-fbcf-421a-a2db-b32610dcfd1b',
|
id: '22d05606-fbcf-421a-a2db-b32610dcfd1b',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof username !== 'string') {
|
if (typeof username !== 'string') {
|
||||||
ctx.status = 400;
|
reply.code(400);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof password !== 'string') {
|
if (typeof password !== 'string') {
|
||||||
ctx.status = 400;
|
reply.code(400);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (token != null && typeof token !== 'string') {
|
if (token != null && typeof token !== 'string') {
|
||||||
ctx.status = 400;
|
reply.code(400);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -93,17 +106,15 @@ export class SigninApiService {
|
||||||
}) as ILocalUser;
|
}) as ILocalUser;
|
||||||
|
|
||||||
if (user == null) {
|
if (user == null) {
|
||||||
error(404, {
|
return error(404, {
|
||||||
id: '6cc579cc-885d-43d8-95c2-b8c7fc963280',
|
id: '6cc579cc-885d-43d8-95c2-b8c7fc963280',
|
||||||
});
|
});
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user.isSuspended) {
|
if (user.isSuspended) {
|
||||||
error(403, {
|
return error(403, {
|
||||||
id: 'e03a5f46-d309-4865-9b69-56282d94e1eb',
|
id: 'e03a5f46-d309-4865-9b69-56282d94e1eb',
|
||||||
});
|
});
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id });
|
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id });
|
||||||
|
@ -117,32 +128,29 @@ export class SigninApiService {
|
||||||
id: this.idService.genId(),
|
id: this.idService.genId(),
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
ip: ctx.ip,
|
ip: request.ip,
|
||||||
headers: ctx.headers,
|
headers: request.headers,
|
||||||
success: false,
|
success: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
error(status ?? 500, failure ?? { id: '4e30e80c-e338-45a0-8c8f-44455efa3b76' });
|
return error(status ?? 500, failure ?? { id: '4e30e80c-e338-45a0-8c8f-44455efa3b76' });
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!profile.twoFactorEnabled) {
|
if (!profile.twoFactorEnabled) {
|
||||||
if (same) {
|
if (same) {
|
||||||
this.signinService.signin(ctx, user);
|
return this.signinService.signin(request, reply, user);
|
||||||
return;
|
|
||||||
} else {
|
} else {
|
||||||
await fail(403, {
|
return await fail(403, {
|
||||||
id: '932c904e-9460-45b7-9ce6-7ed33be7eb2c',
|
id: '932c904e-9460-45b7-9ce6-7ed33be7eb2c',
|
||||||
});
|
});
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (token) {
|
if (token) {
|
||||||
if (!same) {
|
if (!same) {
|
||||||
await fail(403, {
|
return await fail(403, {
|
||||||
id: '932c904e-9460-45b7-9ce6-7ed33be7eb2c',
|
id: '932c904e-9460-45b7-9ce6-7ed33be7eb2c',
|
||||||
});
|
});
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const verified = (speakeasy as any).totp.verify({
|
const verified = (speakeasy as any).totp.verify({
|
||||||
|
@ -153,20 +161,17 @@ export class SigninApiService {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (verified) {
|
if (verified) {
|
||||||
this.signinService.signin(ctx, user);
|
return this.signinService.signin(request, reply, user);
|
||||||
return;
|
|
||||||
} else {
|
} else {
|
||||||
await fail(403, {
|
return await fail(403, {
|
||||||
id: 'cdf1235b-ac71-46d4-a3a6-84ccce48df6f',
|
id: 'cdf1235b-ac71-46d4-a3a6-84ccce48df6f',
|
||||||
});
|
});
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
} else if (body.credentialId) {
|
} else if (body.credentialId && body.clientDataJSON && body.authenticatorData && body.signature) {
|
||||||
if (!same && !profile.usePasswordLessLogin) {
|
if (!same && !profile.usePasswordLessLogin) {
|
||||||
await fail(403, {
|
return await fail(403, {
|
||||||
id: '932c904e-9460-45b7-9ce6-7ed33be7eb2c',
|
id: '932c904e-9460-45b7-9ce6-7ed33be7eb2c',
|
||||||
});
|
});
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const clientDataJSON = Buffer.from(body.clientDataJSON, 'hex');
|
const clientDataJSON = Buffer.from(body.clientDataJSON, 'hex');
|
||||||
|
@ -179,10 +184,9 @@ export class SigninApiService {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!challenge) {
|
if (!challenge) {
|
||||||
await fail(403, {
|
return await fail(403, {
|
||||||
id: '2715a88a-2125-4013-932f-aa6fe72792da',
|
id: '2715a88a-2125-4013-932f-aa6fe72792da',
|
||||||
});
|
});
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.attestationChallengesRepository.delete({
|
await this.attestationChallengesRepository.delete({
|
||||||
|
@ -191,10 +195,9 @@ export class SigninApiService {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (new Date().getTime() - challenge.createdAt.getTime() >= 5 * 60 * 1000) {
|
if (new Date().getTime() - challenge.createdAt.getTime() >= 5 * 60 * 1000) {
|
||||||
await fail(403, {
|
return await fail(403, {
|
||||||
id: '2715a88a-2125-4013-932f-aa6fe72792da',
|
id: '2715a88a-2125-4013-932f-aa6fe72792da',
|
||||||
});
|
});
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const securityKey = await this.userSecurityKeysRepository.findOneBy({
|
const securityKey = await this.userSecurityKeysRepository.findOneBy({
|
||||||
|
@ -207,10 +210,9 @@ export class SigninApiService {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!securityKey) {
|
if (!securityKey) {
|
||||||
await fail(403, {
|
return await fail(403, {
|
||||||
id: '66269679-aeaf-4474-862b-eb761197e046',
|
id: '66269679-aeaf-4474-862b-eb761197e046',
|
||||||
});
|
});
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const isValid = this.twoFactorAuthenticationService.verifySignin({
|
const isValid = this.twoFactorAuthenticationService.verifySignin({
|
||||||
|
@ -223,20 +225,17 @@ export class SigninApiService {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isValid) {
|
if (isValid) {
|
||||||
this.signinService.signin(ctx, user);
|
return this.signinService.signin(request, reply, user);
|
||||||
return;
|
|
||||||
} else {
|
} else {
|
||||||
await fail(403, {
|
return await fail(403, {
|
||||||
id: '93b86c4b-72f9-40eb-9815-798928603d1e',
|
id: '93b86c4b-72f9-40eb-9815-798928603d1e',
|
||||||
});
|
});
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (!same && !profile.usePasswordLessLogin) {
|
if (!same && !profile.usePasswordLessLogin) {
|
||||||
await fail(403, {
|
return await fail(403, {
|
||||||
id: '932c904e-9460-45b7-9ce6-7ed33be7eb2c',
|
id: '932c904e-9460-45b7-9ce6-7ed33be7eb2c',
|
||||||
});
|
});
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const keys = await this.userSecurityKeysRepository.findBy({
|
const keys = await this.userSecurityKeysRepository.findBy({
|
||||||
|
@ -244,10 +243,9 @@ export class SigninApiService {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (keys.length === 0) {
|
if (keys.length === 0) {
|
||||||
await fail(403, {
|
return await fail(403, {
|
||||||
id: 'f27fd449-9af4-4841-9249-1f989b9fa4a4',
|
id: 'f27fd449-9af4-4841-9249-1f989b9fa4a4',
|
||||||
});
|
});
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 32 byte challenge
|
// 32 byte challenge
|
||||||
|
@ -266,15 +264,14 @@ export class SigninApiService {
|
||||||
registrationChallenge: false,
|
registrationChallenge: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
ctx.body = {
|
reply.code(200);
|
||||||
|
return {
|
||||||
challenge,
|
challenge,
|
||||||
challengeId,
|
challengeId,
|
||||||
securityKeys: keys.map(key => ({
|
securityKeys: keys.map(key => ({
|
||||||
id: key.id,
|
id: key.id,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
ctx.status = 200;
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
// never get here
|
// never get here
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,13 +1,12 @@
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
import type { SigninsRepository } from '@/models/index.js';
|
import type { SigninsRepository, UsersRepository } from '@/models/index.js';
|
||||||
import type { UsersRepository } from '@/models/index.js';
|
|
||||||
import type { Config } from '@/config.js';
|
import type { Config } from '@/config.js';
|
||||||
import { IdService } from '@/core/IdService.js';
|
import { IdService } from '@/core/IdService.js';
|
||||||
import type { ILocalUser } from '@/models/entities/User.js';
|
import type { ILocalUser } from '@/models/entities/User.js';
|
||||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||||
import { SigninEntityService } from '@/core/entities/SigninEntityService.js';
|
import { SigninEntityService } from '@/core/entities/SigninEntityService.js';
|
||||||
import type Koa from 'koa';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SigninService {
|
export class SigninService {
|
||||||
|
@ -24,10 +23,25 @@ export class SigninService {
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
public signin(ctx: Koa.Context, user: ILocalUser, redirect = false) {
|
public signin(request: FastifyRequest, reply: FastifyReply, user: ILocalUser, redirect = false) {
|
||||||
|
setImmediate(async () => {
|
||||||
|
// Append signin history
|
||||||
|
const record = await this.signinsRepository.insert({
|
||||||
|
id: this.idService.genId(),
|
||||||
|
createdAt: new Date(),
|
||||||
|
userId: user.id,
|
||||||
|
ip: request.ip,
|
||||||
|
headers: request.headers,
|
||||||
|
success: true,
|
||||||
|
}).then(x => this.signinsRepository.findOneByOrFail(x.identifiers[0]));
|
||||||
|
|
||||||
|
// Publish signin event
|
||||||
|
this.globalEventService.publishMainStream(user.id, 'signin', await this.signinEntityService.pack(record));
|
||||||
|
});
|
||||||
|
|
||||||
if (redirect) {
|
if (redirect) {
|
||||||
//#region Cookie
|
//#region Cookie
|
||||||
ctx.cookies.set('igi', user.token!, {
|
reply.cookies.set('igi', user.token!, {
|
||||||
path: '/',
|
path: '/',
|
||||||
// SEE: https://github.com/koajs/koa/issues/974
|
// SEE: https://github.com/koajs/koa/issues/974
|
||||||
// When using a SSL proxy it should be configured to add the "X-Forwarded-Proto: https" header
|
// When using a SSL proxy it should be configured to add the "X-Forwarded-Proto: https" header
|
||||||
|
@ -36,29 +50,14 @@ export class SigninService {
|
||||||
});
|
});
|
||||||
//#endregion
|
//#endregion
|
||||||
|
|
||||||
ctx.redirect(this.config.url);
|
reply.redirect(this.config.url);
|
||||||
} else {
|
} else {
|
||||||
ctx.body = {
|
reply.code(200);
|
||||||
|
return {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
i: user.token,
|
i: user.token,
|
||||||
};
|
};
|
||||||
ctx.status = 200;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
(async () => {
|
|
||||||
// Append signin history
|
|
||||||
const record = await this.signinsRepository.insert({
|
|
||||||
id: this.idService.genId(),
|
|
||||||
createdAt: new Date(),
|
|
||||||
userId: user.id,
|
|
||||||
ip: ctx.ip,
|
|
||||||
headers: ctx.headers,
|
|
||||||
success: true,
|
|
||||||
}).then(x => this.signinsRepository.findOneByOrFail(x.identifiers[0]));
|
|
||||||
|
|
||||||
// Publish signin event
|
|
||||||
this.globalEventService.publishMainStream(user.id, 'signin', await this.signinEntityService.pack(record));
|
|
||||||
})();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import rndstr from 'rndstr';
|
import rndstr from 'rndstr';
|
||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
|
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
import type { RegistrationTicketsRepository, UserPendingsRepository, UserProfilesRepository, UsersRepository } from '@/models/index.js';
|
import type { RegistrationTicketsRepository, UserPendingsRepository, UserProfilesRepository, UsersRepository } from '@/models/index.js';
|
||||||
import type { Config } from '@/config.js';
|
import type { Config } from '@/config.js';
|
||||||
|
@ -11,8 +12,8 @@ import { SignupService } from '@/core/SignupService.js';
|
||||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||||
import { EmailService } from '@/core/EmailService.js';
|
import { EmailService } from '@/core/EmailService.js';
|
||||||
import { ILocalUser } from '@/models/entities/User.js';
|
import { ILocalUser } from '@/models/entities/User.js';
|
||||||
|
import { FastifyReplyError } from '@/misc/fastify-reply-error.js';
|
||||||
import { SigninService } from './SigninService.js';
|
import { SigninService } from './SigninService.js';
|
||||||
import type Koa from 'koa';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SignupApiService {
|
export class SignupApiService {
|
||||||
|
@ -42,8 +43,22 @@ export class SignupApiService {
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
public async signup(ctx: Koa.Context) {
|
public async signup(
|
||||||
const body = ctx.request.body;
|
request: FastifyRequest<{
|
||||||
|
Body: {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
host?: string;
|
||||||
|
invitationCode?: string;
|
||||||
|
emailAddress?: string;
|
||||||
|
'hcaptcha-response'?: string;
|
||||||
|
'g-recaptcha-response'?: string;
|
||||||
|
'turnstile-response'?: string;
|
||||||
|
}
|
||||||
|
}>,
|
||||||
|
reply: FastifyReply,
|
||||||
|
) {
|
||||||
|
const body = request.body;
|
||||||
|
|
||||||
const instance = await this.metaService.fetch(true);
|
const instance = await this.metaService.fetch(true);
|
||||||
|
|
||||||
|
@ -51,20 +66,20 @@ export class SignupApiService {
|
||||||
// ただしテスト時はこの機構は障害となるため無効にする
|
// ただしテスト時はこの機構は障害となるため無効にする
|
||||||
if (process.env.NODE_ENV !== 'test') {
|
if (process.env.NODE_ENV !== 'test') {
|
||||||
if (instance.enableHcaptcha && instance.hcaptchaSecretKey) {
|
if (instance.enableHcaptcha && instance.hcaptchaSecretKey) {
|
||||||
await this.captchaService.verifyHcaptcha(instance.hcaptchaSecretKey, body['hcaptcha-response']).catch(e => {
|
await this.captchaService.verifyHcaptcha(instance.hcaptchaSecretKey, body['hcaptcha-response']).catch(err => {
|
||||||
ctx.throw(400, e);
|
throw new FastifyReplyError(400, err);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (instance.enableRecaptcha && instance.recaptchaSecretKey) {
|
if (instance.enableRecaptcha && instance.recaptchaSecretKey) {
|
||||||
await this.captchaService.verifyRecaptcha(instance.recaptchaSecretKey, body['g-recaptcha-response']).catch(e => {
|
await this.captchaService.verifyRecaptcha(instance.recaptchaSecretKey, body['g-recaptcha-response']).catch(err => {
|
||||||
ctx.throw(400, e);
|
throw new FastifyReplyError(400, err);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (instance.enableTurnstile && instance.turnstileSecretKey) {
|
if (instance.enableTurnstile && instance.turnstileSecretKey) {
|
||||||
await this.captchaService.verifyTurnstile(instance.turnstileSecretKey, body['turnstile-response']).catch(e => {
|
await this.captchaService.verifyTurnstile(instance.turnstileSecretKey, body['turnstile-response']).catch(err => {
|
||||||
ctx.throw(400, e);
|
throw new FastifyReplyError(400, err);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -77,20 +92,20 @@ export class SignupApiService {
|
||||||
|
|
||||||
if (instance.emailRequiredForSignup) {
|
if (instance.emailRequiredForSignup) {
|
||||||
if (emailAddress == null || typeof emailAddress !== 'string') {
|
if (emailAddress == null || typeof emailAddress !== 'string') {
|
||||||
ctx.status = 400;
|
reply.code(400);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const available = await this.emailService.validateEmailForAccount(emailAddress);
|
const res = await this.emailService.validateEmailForAccount(emailAddress);
|
||||||
if (!available) {
|
if (!res.available) {
|
||||||
ctx.status = 400;
|
reply.code(400);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (instance.disableRegistration) {
|
if (instance.disableRegistration) {
|
||||||
if (invitationCode == null || typeof invitationCode !== 'string') {
|
if (invitationCode == null || typeof invitationCode !== 'string') {
|
||||||
ctx.status = 400;
|
reply.code(400);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -99,7 +114,7 @@ export class SignupApiService {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (ticket == null) {
|
if (ticket == null) {
|
||||||
ctx.status = 400;
|
reply.code(400);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -117,18 +132,18 @@ export class SignupApiService {
|
||||||
id: this.idService.genId(),
|
id: this.idService.genId(),
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
code,
|
code,
|
||||||
email: emailAddress,
|
email: emailAddress!,
|
||||||
username: username,
|
username: username,
|
||||||
password: hash,
|
password: hash,
|
||||||
});
|
});
|
||||||
|
|
||||||
const link = `${this.config.url}/signup-complete/${code}`;
|
const link = `${this.config.url}/signup-complete/${code}`;
|
||||||
|
|
||||||
this.emailService.sendEmail(emailAddress, 'Signup',
|
this.emailService.sendEmail(emailAddress!, 'Signup',
|
||||||
`To complete signup, please click this link:<br><a href="${link}">${link}</a>`,
|
`To complete signup, please click this link:<br><a href="${link}">${link}</a>`,
|
||||||
`To complete signup, please click this link: ${link}`);
|
`To complete signup, please click this link: ${link}`);
|
||||||
|
|
||||||
ctx.status = 204;
|
reply.code(204);
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
const { account, secret } = await this.signupService.signup({
|
const { account, secret } = await this.signupService.signup({
|
||||||
|
@ -140,17 +155,18 @@ export class SignupApiService {
|
||||||
includeSecrets: true,
|
includeSecrets: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
(res as any).token = secret;
|
return {
|
||||||
|
...res,
|
||||||
ctx.body = res;
|
token: secret,
|
||||||
} catch (e) {
|
};
|
||||||
ctx.throw(400, e);
|
} catch (err) {
|
||||||
|
throw new FastifyReplyError(400, err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async signupPending(ctx: Koa.Context) {
|
public async signupPending(request: FastifyRequest<{ Body: { code: string; } }>, reply: FastifyReply) {
|
||||||
const body = ctx.request.body;
|
const body = request.body;
|
||||||
|
|
||||||
const code = body['code'];
|
const code = body['code'];
|
||||||
|
|
||||||
|
@ -174,9 +190,9 @@ export class SignupApiService {
|
||||||
emailVerifyCode: null,
|
emailVerifyCode: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.signinService.signin(ctx, account as ILocalUser);
|
this.signinService.signin(request, reply, account as ILocalUser);
|
||||||
} catch (e) {
|
} catch (err) {
|
||||||
ctx.throw(400, e);
|
throw new FastifyReplyError(400, err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,23 +14,28 @@ ajv.addFormat('misskey:id', /^[a-zA-Z0-9]+$/);
|
||||||
|
|
||||||
export type Response = Record<string, any> | void;
|
export type Response = Record<string, any> | void;
|
||||||
|
|
||||||
|
type File = {
|
||||||
|
name: string | null;
|
||||||
|
path: string;
|
||||||
|
};
|
||||||
|
|
||||||
// TODO: paramsの型をT['params']のスキーマ定義から推論する
|
// TODO: paramsの型をT['params']のスキーマ定義から推論する
|
||||||
type executor<T extends IEndpointMeta, Ps extends Schema> =
|
type executor<T extends IEndpointMeta, Ps extends Schema> =
|
||||||
(params: SchemaType<Ps>, user: T['requireCredential'] extends true ? CacheableLocalUser : CacheableLocalUser | null, token: AccessToken | null, file?: any, cleanup?: () => any, ip?: string | null, headers?: Record<string, string> | null) =>
|
(params: SchemaType<Ps>, user: T['requireCredential'] extends true ? CacheableLocalUser : CacheableLocalUser | null, token: AccessToken | null, file?: File, cleanup?: () => any, ip?: string | null, headers?: Record<string, string> | null) =>
|
||||||
Promise<T['res'] extends undefined ? Response : SchemaType<NonNullable<T['res']>>>;
|
Promise<T['res'] extends undefined ? Response : SchemaType<NonNullable<T['res']>>>;
|
||||||
|
|
||||||
export abstract class Endpoint<T extends IEndpointMeta, Ps extends Schema> {
|
export abstract class Endpoint<T extends IEndpointMeta, Ps extends Schema> {
|
||||||
public exec: (params: any, user: T['requireCredential'] extends true ? CacheableLocalUser : CacheableLocalUser | null, token: AccessToken | null, file?: any, ip?: string | null, headers?: Record<string, string> | null) => Promise<any>;
|
public exec: (params: any, user: T['requireCredential'] extends true ? CacheableLocalUser : CacheableLocalUser | null, token: AccessToken | null, file?: File, ip?: string | null, headers?: Record<string, string> | null) => Promise<any>;
|
||||||
|
|
||||||
constructor(meta: T, paramDef: Ps, cb: executor<T, Ps>) {
|
constructor(meta: T, paramDef: Ps, cb: executor<T, Ps>) {
|
||||||
const validate = ajv.compile(paramDef);
|
const validate = ajv.compile(paramDef);
|
||||||
|
|
||||||
this.exec = (params: any, user: T['requireCredential'] extends true ? CacheableLocalUser : CacheableLocalUser | null, token: AccessToken | null, file?: any, ip?: string | null, headers?: Record<string, string> | null) => {
|
this.exec = (params: any, user: T['requireCredential'] extends true ? CacheableLocalUser : CacheableLocalUser | null, token: AccessToken | null, file?: File, ip?: string | null, headers?: Record<string, string> | null) => {
|
||||||
let cleanup: undefined | (() => void) = undefined;
|
let cleanup: undefined | (() => void) = undefined;
|
||||||
|
|
||||||
if (meta.requireFile) {
|
if (meta.requireFile) {
|
||||||
cleanup = () => {
|
cleanup = () => {
|
||||||
fs.unlink(file.path, () => {});
|
if (file) fs.unlink(file.path, () => {});
|
||||||
};
|
};
|
||||||
|
|
||||||
if (file == null) return Promise.reject(new ApiError({
|
if (file == null) return Promise.reject(new ApiError({
|
||||||
|
|
|
@ -78,8 +78,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||||
) {
|
) {
|
||||||
super(meta, paramDef, async (ps, me, _, file, cleanup, ip, headers) => {
|
super(meta, paramDef, async (ps, me, _, file, cleanup, ip, headers) => {
|
||||||
// Get 'name' parameter
|
// Get 'name' parameter
|
||||||
let name = ps.name ?? file.originalname;
|
let name = ps.name ?? file!.name ?? null;
|
||||||
if (name !== undefined && name !== null) {
|
if (name != null) {
|
||||||
name = name.trim();
|
name = name.trim();
|
||||||
if (name.length === 0) {
|
if (name.length === 0) {
|
||||||
name = null;
|
name = null;
|
||||||
|
@ -88,8 +88,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||||
} else if (!this.driveFileEntityService.validateFileName(name)) {
|
} else if (!this.driveFileEntityService.validateFileName(name)) {
|
||||||
throw new ApiError(meta.errors.invalidFileName);
|
throw new ApiError(meta.errors.invalidFileName);
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
name = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const meta = await this.metaService.fetch();
|
const meta = await this.metaService.fetch();
|
||||||
|
@ -98,7 +96,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||||
// Create file
|
// Create file
|
||||||
const driveFile = await this.driveService.addFile({
|
const driveFile = await this.driveService.addFile({
|
||||||
user: me,
|
user: me,
|
||||||
path: file.path,
|
path: file!.path,
|
||||||
name,
|
name,
|
||||||
comment: ps.comment,
|
comment: ps.comment,
|
||||||
folderId: ps.folderId,
|
folderId: ps.folderId,
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import Redis from 'ioredis';
|
import Redis from 'ioredis';
|
||||||
import Router from '@koa/router';
|
|
||||||
import { OAuth2 } from 'oauth';
|
import { OAuth2 } from 'oauth';
|
||||||
import { v4 as uuid } from 'uuid';
|
import { v4 as uuid } from 'uuid';
|
||||||
import { IsNull } from 'typeorm';
|
import { IsNull } from 'typeorm';
|
||||||
|
import { FastifyInstance, FastifyRequest, FastifyReply, FastifyPluginOptions } from 'fastify';
|
||||||
import type { Config } from '@/config.js';
|
import type { Config } from '@/config.js';
|
||||||
import type { UserProfilesRepository, UsersRepository } from '@/models/index.js';
|
import type { UserProfilesRepository, UsersRepository } from '@/models/index.js';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
|
@ -12,8 +12,8 @@ import type { ILocalUser } from '@/models/entities/User.js';
|
||||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||||
import { MetaService } from '@/core/MetaService.js';
|
import { MetaService } from '@/core/MetaService.js';
|
||||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||||
|
import { FastifyReplyError } from '@/misc/fastify-reply-error.js';
|
||||||
import { SigninService } from '../SigninService.js';
|
import { SigninService } from '../SigninService.js';
|
||||||
import type Koa from 'koa';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class DiscordServerService {
|
export class DiscordServerService {
|
||||||
|
@ -36,21 +36,18 @@ export class DiscordServerService {
|
||||||
private metaService: MetaService,
|
private metaService: MetaService,
|
||||||
private signinService: SigninService,
|
private signinService: SigninService,
|
||||||
) {
|
) {
|
||||||
|
this.create = this.create.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
public create() {
|
public create(fastify: FastifyInstance, options: FastifyPluginOptions, done: (err?: Error) => void) {
|
||||||
const router = new Router();
|
fastify.get('/disconnect/discord', async (request, reply) => {
|
||||||
|
if (!this.compareOrigin(request)) {
|
||||||
router.get('/disconnect/discord', async ctx => {
|
throw new FastifyReplyError(400, 'invalid origin');
|
||||||
if (!this.compareOrigin(ctx)) {
|
|
||||||
ctx.throw(400, 'invalid origin');
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const userToken = this.getUserToken(ctx);
|
const userToken = this.getUserToken(request);
|
||||||
if (!userToken) {
|
if (!userToken) {
|
||||||
ctx.throw(400, 'signin required');
|
throw new FastifyReplyError(400, 'signin required');
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await this.usersRepository.findOneByOrFail({
|
const user = await this.usersRepository.findOneByOrFail({
|
||||||
|
@ -66,13 +63,13 @@ export class DiscordServerService {
|
||||||
integrations: profile.integrations,
|
integrations: profile.integrations,
|
||||||
});
|
});
|
||||||
|
|
||||||
ctx.body = 'Discordの連携を解除しました :v:';
|
|
||||||
|
|
||||||
// Publish i updated event
|
// Publish i updated event
|
||||||
this.globalEventService.publishMainStream(user.id, 'meUpdated', await this.userEntityService.pack(user, user, {
|
this.globalEventService.publishMainStream(user.id, 'meUpdated', await this.userEntityService.pack(user, user, {
|
||||||
detail: true,
|
detail: true,
|
||||||
includeSecrets: true,
|
includeSecrets: true,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
return 'Discordの連携を解除しました :v:';
|
||||||
});
|
});
|
||||||
|
|
||||||
const getOAuth2 = async () => {
|
const getOAuth2 = async () => {
|
||||||
|
@ -90,16 +87,14 @@ export class DiscordServerService {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
router.get('/connect/discord', async ctx => {
|
fastify.get('/connect/discord', async (request, reply) => {
|
||||||
if (!this.compareOrigin(ctx)) {
|
if (!this.compareOrigin(request)) {
|
||||||
ctx.throw(400, 'invalid origin');
|
throw new FastifyReplyError(400, 'invalid origin');
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const userToken = this.getUserToken(ctx);
|
const userToken = this.getUserToken(request);
|
||||||
if (!userToken) {
|
if (!userToken) {
|
||||||
ctx.throw(400, 'signin required');
|
throw new FastifyReplyError(400, 'signin required');
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const params = {
|
const params = {
|
||||||
|
@ -112,10 +107,10 @@ export class DiscordServerService {
|
||||||
this.redisClient.set(userToken, JSON.stringify(params));
|
this.redisClient.set(userToken, JSON.stringify(params));
|
||||||
|
|
||||||
const oauth2 = await getOAuth2();
|
const oauth2 = await getOAuth2();
|
||||||
ctx.redirect(oauth2!.getAuthorizeUrl(params));
|
reply.redirect(oauth2!.getAuthorizeUrl(params));
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/signin/discord', async ctx => {
|
fastify.get('/signin/discord', async (request, reply) => {
|
||||||
const sessid = uuid();
|
const sessid = uuid();
|
||||||
|
|
||||||
const params = {
|
const params = {
|
||||||
|
@ -125,7 +120,7 @@ export class DiscordServerService {
|
||||||
response_type: 'code',
|
response_type: 'code',
|
||||||
};
|
};
|
||||||
|
|
||||||
ctx.cookies.set('signin_with_discord_sid', sessid, {
|
reply.cookies.set('signin_with_discord_sid', sessid, {
|
||||||
path: '/',
|
path: '/',
|
||||||
secure: this.config.url.startsWith('https'),
|
secure: this.config.url.startsWith('https'),
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
|
@ -134,27 +129,25 @@ export class DiscordServerService {
|
||||||
this.redisClient.set(sessid, JSON.stringify(params));
|
this.redisClient.set(sessid, JSON.stringify(params));
|
||||||
|
|
||||||
const oauth2 = await getOAuth2();
|
const oauth2 = await getOAuth2();
|
||||||
ctx.redirect(oauth2!.getAuthorizeUrl(params));
|
reply.redirect(oauth2!.getAuthorizeUrl(params));
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/dc/cb', async ctx => {
|
fastify.get('/dc/cb', async (request, reply) => {
|
||||||
const userToken = this.getUserToken(ctx);
|
const userToken = this.getUserToken(request);
|
||||||
|
|
||||||
const oauth2 = await getOAuth2();
|
const oauth2 = await getOAuth2();
|
||||||
|
|
||||||
if (!userToken) {
|
if (!userToken) {
|
||||||
const sessid = ctx.cookies.get('signin_with_discord_sid');
|
const sessid = request.cookies.get('signin_with_discord_sid');
|
||||||
|
|
||||||
if (!sessid) {
|
if (!sessid) {
|
||||||
ctx.throw(400, 'invalid session');
|
throw new FastifyReplyError(400, 'invalid session');
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const code = ctx.query.code;
|
const code = request.query.code;
|
||||||
|
|
||||||
if (!code || typeof code !== 'string') {
|
if (!code || typeof code !== 'string') {
|
||||||
ctx.throw(400, 'invalid session');
|
throw new FastifyReplyError(400, 'invalid session');
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const { redirect_uri, state } = await new Promise<any>((res, rej) => {
|
const { redirect_uri, state } = await new Promise<any>((res, rej) => {
|
||||||
|
@ -164,9 +157,8 @@ export class DiscordServerService {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
if (ctx.query.state !== state) {
|
if (request.query.state !== state) {
|
||||||
ctx.throw(400, 'invalid session');
|
throw new FastifyReplyError(400, 'invalid session');
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const { accessToken, refreshToken, expiresDate } = await new Promise<any>((res, rej) =>
|
const { accessToken, refreshToken, expiresDate } = await new Promise<any>((res, rej) =>
|
||||||
|
@ -192,8 +184,7 @@ export class DiscordServerService {
|
||||||
})) as Record<string, unknown>;
|
})) as Record<string, unknown>;
|
||||||
|
|
||||||
if (typeof id !== 'string' || typeof username !== 'string' || typeof discriminator !== 'string') {
|
if (typeof id !== 'string' || typeof username !== 'string' || typeof discriminator !== 'string') {
|
||||||
ctx.throw(400, 'invalid session');
|
throw new FastifyReplyError(400, 'invalid session');
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const profile = await this.userProfilesRepository.createQueryBuilder()
|
const profile = await this.userProfilesRepository.createQueryBuilder()
|
||||||
|
@ -202,8 +193,7 @@ export class DiscordServerService {
|
||||||
.getOne();
|
.getOne();
|
||||||
|
|
||||||
if (profile == null) {
|
if (profile == null) {
|
||||||
ctx.throw(404, `@${username}#${discriminator}と連携しているMisskeyアカウントはありませんでした...`);
|
throw new FastifyReplyError(404, `@${username}#${discriminator}と連携しているMisskeyアカウントはありませんでした...`);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.userProfilesRepository.update(profile.userId, {
|
await this.userProfilesRepository.update(profile.userId, {
|
||||||
|
@ -220,13 +210,12 @@ export class DiscordServerService {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
this.signinService.signin(ctx, await this.usersRepository.findOneBy({ id: profile.userId }) as ILocalUser, true);
|
return this.signinService.signin(request, reply, await this.usersRepository.findOneBy({ id: profile.userId }) as ILocalUser, true);
|
||||||
} else {
|
} else {
|
||||||
const code = ctx.query.code;
|
const code = request.query.code;
|
||||||
|
|
||||||
if (!code || typeof code !== 'string') {
|
if (!code || typeof code !== 'string') {
|
||||||
ctx.throw(400, 'invalid session');
|
throw new FastifyReplyError(400, 'invalid session');
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const { redirect_uri, state } = await new Promise<any>((res, rej) => {
|
const { redirect_uri, state } = await new Promise<any>((res, rej) => {
|
||||||
|
@ -236,9 +225,8 @@ export class DiscordServerService {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
if (ctx.query.state !== state) {
|
if (request.query.state !== state) {
|
||||||
ctx.throw(400, 'invalid session');
|
throw new FastifyReplyError(400, 'invalid session');
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const { accessToken, refreshToken, expiresDate } = await new Promise<any>((res, rej) =>
|
const { accessToken, refreshToken, expiresDate } = await new Promise<any>((res, rej) =>
|
||||||
|
@ -263,8 +251,7 @@ export class DiscordServerService {
|
||||||
'Authorization': `Bearer ${accessToken}`,
|
'Authorization': `Bearer ${accessToken}`,
|
||||||
})) as Record<string, unknown>;
|
})) as Record<string, unknown>;
|
||||||
if (typeof id !== 'string' || typeof username !== 'string' || typeof discriminator !== 'string') {
|
if (typeof id !== 'string' || typeof username !== 'string' || typeof discriminator !== 'string') {
|
||||||
ctx.throw(400, 'invalid session');
|
throw new FastifyReplyError(400, 'invalid session');
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await this.usersRepository.findOneByOrFail({
|
const user = await this.usersRepository.findOneByOrFail({
|
||||||
|
@ -288,29 +275,29 @@ export class DiscordServerService {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
ctx.body = `Discord: @${username}#${discriminator} を、Misskey: @${user.username} に接続しました!`;
|
|
||||||
|
|
||||||
// Publish i updated event
|
// Publish i updated event
|
||||||
this.globalEventService.publishMainStream(user.id, 'meUpdated', await this.userEntityService.pack(user, user, {
|
this.globalEventService.publishMainStream(user.id, 'meUpdated', await this.userEntityService.pack(user, user, {
|
||||||
detail: true,
|
detail: true,
|
||||||
includeSecrets: true,
|
includeSecrets: true,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
return `Discord: @${username}#${discriminator} を、Misskey: @${user.username} に接続しました!`;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return router;
|
done();
|
||||||
}
|
}
|
||||||
|
|
||||||
private getUserToken(ctx: Koa.BaseContext): string | null {
|
private getUserToken(request: FastifyRequest): string | null {
|
||||||
return ((ctx.headers['cookie'] ?? '').match(/igi=(\w+)/) ?? [null, null])[1];
|
return ((request.headers['cookie'] ?? '').match(/igi=(\w+)/) ?? [null, null])[1];
|
||||||
}
|
}
|
||||||
|
|
||||||
private compareOrigin(ctx: Koa.BaseContext): boolean {
|
private compareOrigin(request: FastifyRequest): boolean {
|
||||||
function normalizeUrl(url?: string): string {
|
function normalizeUrl(url?: string): string {
|
||||||
return url ? url.endsWith('/') ? url.substr(0, url.length - 1) : url : '';
|
return url ? url.endsWith('/') ? url.substr(0, url.length - 1) : url : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
const referer = ctx.headers['referer'];
|
const referer = request.headers['referer'];
|
||||||
|
|
||||||
return (normalizeUrl(referer) === normalizeUrl(this.config.url));
|
return (normalizeUrl(referer) === normalizeUrl(this.config.url));
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import Redis from 'ioredis';
|
import Redis from 'ioredis';
|
||||||
import Router from '@koa/router';
|
|
||||||
import { OAuth2 } from 'oauth';
|
import { OAuth2 } from 'oauth';
|
||||||
import { v4 as uuid } from 'uuid';
|
import { v4 as uuid } from 'uuid';
|
||||||
import { IsNull } from 'typeorm';
|
import { IsNull } from 'typeorm';
|
||||||
|
import { FastifyInstance, FastifyRequest, FastifyReply, FastifyPluginOptions } from 'fastify';
|
||||||
import type { Config } from '@/config.js';
|
import type { Config } from '@/config.js';
|
||||||
import type { UserProfilesRepository, UsersRepository } from '@/models/index.js';
|
import type { UserProfilesRepository, UsersRepository } from '@/models/index.js';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
|
@ -12,8 +12,8 @@ import type { ILocalUser } from '@/models/entities/User.js';
|
||||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||||
import { MetaService } from '@/core/MetaService.js';
|
import { MetaService } from '@/core/MetaService.js';
|
||||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||||
|
import { FastifyReplyError } from '@/misc/fastify-reply-error.js';
|
||||||
import { SigninService } from '../SigninService.js';
|
import { SigninService } from '../SigninService.js';
|
||||||
import type Koa from 'koa';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class GithubServerService {
|
export class GithubServerService {
|
||||||
|
@ -36,21 +36,18 @@ export class GithubServerService {
|
||||||
private metaService: MetaService,
|
private metaService: MetaService,
|
||||||
private signinService: SigninService,
|
private signinService: SigninService,
|
||||||
) {
|
) {
|
||||||
|
this.create = this.create.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
public create() {
|
public create(fastify: FastifyInstance, options: FastifyPluginOptions, done: (err?: Error) => void) {
|
||||||
const router = new Router();
|
fastify.get('/disconnect/github', async (request, reply) => {
|
||||||
|
if (!this.compareOrigin(request)) {
|
||||||
router.get('/disconnect/github', async ctx => {
|
throw new FastifyReplyError(400, 'invalid origin');
|
||||||
if (!this.compareOrigin(ctx)) {
|
|
||||||
ctx.throw(400, 'invalid origin');
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const userToken = this.getUserToken(ctx);
|
const userToken = this.getUserToken(request);
|
||||||
if (!userToken) {
|
if (!userToken) {
|
||||||
ctx.throw(400, 'signin required');
|
throw new FastifyReplyError(400, 'signin required');
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await this.usersRepository.findOneByOrFail({
|
const user = await this.usersRepository.findOneByOrFail({
|
||||||
|
@ -66,13 +63,13 @@ export class GithubServerService {
|
||||||
integrations: profile.integrations,
|
integrations: profile.integrations,
|
||||||
});
|
});
|
||||||
|
|
||||||
ctx.body = 'GitHubの連携を解除しました :v:';
|
|
||||||
|
|
||||||
// Publish i updated event
|
// Publish i updated event
|
||||||
this.globalEventService.publishMainStream(user.id, 'meUpdated', await this.userEntityService.pack(user, user, {
|
this.globalEventService.publishMainStream(user.id, 'meUpdated', await this.userEntityService.pack(user, user, {
|
||||||
detail: true,
|
detail: true,
|
||||||
includeSecrets: true,
|
includeSecrets: true,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
return 'GitHubの連携を解除しました :v:';
|
||||||
});
|
});
|
||||||
|
|
||||||
const getOath2 = async () => {
|
const getOath2 = async () => {
|
||||||
|
@ -90,16 +87,14 @@ export class GithubServerService {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
router.get('/connect/github', async ctx => {
|
fastify.get('/connect/github', async (request, reply) => {
|
||||||
if (!this.compareOrigin(ctx)) {
|
if (!this.compareOrigin(request)) {
|
||||||
ctx.throw(400, 'invalid origin');
|
throw new FastifyReplyError(400, 'invalid origin');
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const userToken = this.getUserToken(ctx);
|
const userToken = this.getUserToken(request);
|
||||||
if (!userToken) {
|
if (!userToken) {
|
||||||
ctx.throw(400, 'signin required');
|
throw new FastifyReplyError(400, 'signin required');
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const params = {
|
const params = {
|
||||||
|
@ -111,10 +106,10 @@ export class GithubServerService {
|
||||||
this.redisClient.set(userToken, JSON.stringify(params));
|
this.redisClient.set(userToken, JSON.stringify(params));
|
||||||
|
|
||||||
const oauth2 = await getOath2();
|
const oauth2 = await getOath2();
|
||||||
ctx.redirect(oauth2!.getAuthorizeUrl(params));
|
reply.redirect(oauth2!.getAuthorizeUrl(params));
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/signin/github', async ctx => {
|
fastify.get('/signin/github', async (request, reply) => {
|
||||||
const sessid = uuid();
|
const sessid = uuid();
|
||||||
|
|
||||||
const params = {
|
const params = {
|
||||||
|
@ -123,7 +118,7 @@ export class GithubServerService {
|
||||||
state: uuid(),
|
state: uuid(),
|
||||||
};
|
};
|
||||||
|
|
||||||
ctx.cookies.set('signin_with_github_sid', sessid, {
|
reply.cookies.set('signin_with_github_sid', sessid, {
|
||||||
path: '/',
|
path: '/',
|
||||||
secure: this.config.url.startsWith('https'),
|
secure: this.config.url.startsWith('https'),
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
|
@ -132,27 +127,25 @@ export class GithubServerService {
|
||||||
this.redisClient.set(sessid, JSON.stringify(params));
|
this.redisClient.set(sessid, JSON.stringify(params));
|
||||||
|
|
||||||
const oauth2 = await getOath2();
|
const oauth2 = await getOath2();
|
||||||
ctx.redirect(oauth2!.getAuthorizeUrl(params));
|
reply.redirect(oauth2!.getAuthorizeUrl(params));
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/gh/cb', async ctx => {
|
fastify.get('/gh/cb', async (request, reply) => {
|
||||||
const userToken = this.getUserToken(ctx);
|
const userToken = this.getUserToken(request);
|
||||||
|
|
||||||
const oauth2 = await getOath2();
|
const oauth2 = await getOath2();
|
||||||
|
|
||||||
if (!userToken) {
|
if (!userToken) {
|
||||||
const sessid = ctx.cookies.get('signin_with_github_sid');
|
const sessid = request.cookies.get('signin_with_github_sid');
|
||||||
|
|
||||||
if (!sessid) {
|
if (!sessid) {
|
||||||
ctx.throw(400, 'invalid session');
|
throw new FastifyReplyError(400, 'invalid session');
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const code = ctx.query.code;
|
const code = request.query.code;
|
||||||
|
|
||||||
if (!code || typeof code !== 'string') {
|
if (!code || typeof code !== 'string') {
|
||||||
ctx.throw(400, 'invalid session');
|
throw new FastifyReplyError(400, 'invalid session');
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const { redirect_uri, state } = await new Promise<any>((res, rej) => {
|
const { redirect_uri, state } = await new Promise<any>((res, rej) => {
|
||||||
|
@ -162,9 +155,8 @@ export class GithubServerService {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
if (ctx.query.state !== state) {
|
if (request.query.state !== state) {
|
||||||
ctx.throw(400, 'invalid session');
|
throw new FastifyReplyError(400, 'invalid session');
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const { accessToken } = await new Promise<{ accessToken: string }>((res, rej) =>
|
const { accessToken } = await new Promise<{ accessToken: string }>((res, rej) =>
|
||||||
|
@ -184,8 +176,7 @@ export class GithubServerService {
|
||||||
'Authorization': `bearer ${accessToken}`,
|
'Authorization': `bearer ${accessToken}`,
|
||||||
})) as Record<string, unknown>;
|
})) as Record<string, unknown>;
|
||||||
if (typeof login !== 'string' || typeof id !== 'string') {
|
if (typeof login !== 'string' || typeof id !== 'string') {
|
||||||
ctx.throw(400, 'invalid session');
|
throw new FastifyReplyError(400, 'invalid session');
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const link = await this.userProfilesRepository.createQueryBuilder()
|
const link = await this.userProfilesRepository.createQueryBuilder()
|
||||||
|
@ -194,17 +185,15 @@ export class GithubServerService {
|
||||||
.getOne();
|
.getOne();
|
||||||
|
|
||||||
if (link == null) {
|
if (link == null) {
|
||||||
ctx.throw(404, `@${login}と連携しているMisskeyアカウントはありませんでした...`);
|
throw new FastifyReplyError(404, `@${login}と連携しているMisskeyアカウントはありませんでした...`);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.signinService.signin(ctx, await this.usersRepository.findOneBy({ id: link.userId }) as ILocalUser, true);
|
return this.signinService.signin(request, reply, await this.usersRepository.findOneBy({ id: link.userId }) as ILocalUser, true);
|
||||||
} else {
|
} else {
|
||||||
const code = ctx.query.code;
|
const code = request.query.code;
|
||||||
|
|
||||||
if (!code || typeof code !== 'string') {
|
if (!code || typeof code !== 'string') {
|
||||||
ctx.throw(400, 'invalid session');
|
throw new FastifyReplyError(400, 'invalid session');
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const { redirect_uri, state } = await new Promise<any>((res, rej) => {
|
const { redirect_uri, state } = await new Promise<any>((res, rej) => {
|
||||||
|
@ -214,9 +203,8 @@ export class GithubServerService {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
if (ctx.query.state !== state) {
|
if (request.query.state !== state) {
|
||||||
ctx.throw(400, 'invalid session');
|
throw new FastifyReplyError(400, 'invalid session');
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const { accessToken } = await new Promise<{ accessToken: string }>((res, rej) =>
|
const { accessToken } = await new Promise<{ accessToken: string }>((res, rej) =>
|
||||||
|
@ -238,8 +226,7 @@ export class GithubServerService {
|
||||||
})) as Record<string, unknown>;
|
})) as Record<string, unknown>;
|
||||||
|
|
||||||
if (typeof login !== 'string' || typeof id !== 'string') {
|
if (typeof login !== 'string' || typeof id !== 'string') {
|
||||||
ctx.throw(400, 'invalid session');
|
throw new FastifyReplyError(400, 'invalid session');
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await this.usersRepository.findOneByOrFail({
|
const user = await this.usersRepository.findOneByOrFail({
|
||||||
|
@ -260,29 +247,29 @@ export class GithubServerService {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
ctx.body = `GitHub: @${login} を、Misskey: @${user.username} に接続しました!`;
|
|
||||||
|
|
||||||
// Publish i updated event
|
// Publish i updated event
|
||||||
this.globalEventService.publishMainStream(user.id, 'meUpdated', await this.userEntityService.pack(user, user, {
|
this.globalEventService.publishMainStream(user.id, 'meUpdated', await this.userEntityService.pack(user, user, {
|
||||||
detail: true,
|
detail: true,
|
||||||
includeSecrets: true,
|
includeSecrets: true,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
return `GitHub: @${login} を、Misskey: @${user.username} に接続しました!`;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return router;
|
done();
|
||||||
}
|
}
|
||||||
|
|
||||||
private getUserToken(ctx: Koa.BaseContext): string | null {
|
private getUserToken(request: FastifyRequest): string | null {
|
||||||
return ((ctx.headers['cookie'] ?? '').match(/igi=(\w+)/) ?? [null, null])[1];
|
return ((request.headers['cookie'] ?? '').match(/igi=(\w+)/) ?? [null, null])[1];
|
||||||
}
|
}
|
||||||
|
|
||||||
private compareOrigin(ctx: Koa.BaseContext): boolean {
|
private compareOrigin(request: FastifyRequest): boolean {
|
||||||
function normalizeUrl(url?: string): string {
|
function normalizeUrl(url?: string): string {
|
||||||
return url ? url.endsWith('/') ? url.substr(0, url.length - 1) : url : '';
|
return url ? url.endsWith('/') ? url.substr(0, url.length - 1) : url : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
const referer = ctx.headers['referer'];
|
const referer = request.headers['referer'];
|
||||||
|
|
||||||
return (normalizeUrl(referer) === normalizeUrl(this.config.url));
|
return (normalizeUrl(referer) === normalizeUrl(this.config.url));
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import Redis from 'ioredis';
|
import Redis from 'ioredis';
|
||||||
import Router from '@koa/router';
|
import { FastifyInstance, FastifyRequest, FastifyReply, FastifyPluginOptions } from 'fastify';
|
||||||
import { v4 as uuid } from 'uuid';
|
import { v4 as uuid } from 'uuid';
|
||||||
import { IsNull } from 'typeorm';
|
import { IsNull } from 'typeorm';
|
||||||
import autwh from 'autwh';
|
import autwh from 'autwh';
|
||||||
|
@ -12,8 +12,8 @@ import type { ILocalUser } from '@/models/entities/User.js';
|
||||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||||
import { MetaService } from '@/core/MetaService.js';
|
import { MetaService } from '@/core/MetaService.js';
|
||||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||||
|
import { FastifyReplyError } from '@/misc/fastify-reply-error.js';
|
||||||
import { SigninService } from '../SigninService.js';
|
import { SigninService } from '../SigninService.js';
|
||||||
import type Koa from 'koa';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TwitterServerService {
|
export class TwitterServerService {
|
||||||
|
@ -36,21 +36,18 @@ export class TwitterServerService {
|
||||||
private metaService: MetaService,
|
private metaService: MetaService,
|
||||||
private signinService: SigninService,
|
private signinService: SigninService,
|
||||||
) {
|
) {
|
||||||
|
this.create = this.create.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
public create() {
|
public create(fastify: FastifyInstance, options: FastifyPluginOptions, done: (err?: Error) => void) {
|
||||||
const router = new Router();
|
fastify.get('/disconnect/twitter', async (request, reply) => {
|
||||||
|
if (!this.compareOrigin(request)) {
|
||||||
router.get('/disconnect/twitter', async ctx => {
|
throw new FastifyReplyError(400, 'invalid origin');
|
||||||
if (!this.compareOrigin(ctx)) {
|
|
||||||
ctx.throw(400, 'invalid origin');
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const userToken = this.getUserToken(ctx);
|
const userToken = this.getUserToken(request);
|
||||||
if (userToken == null) {
|
if (userToken == null) {
|
||||||
ctx.throw(400, 'signin required');
|
throw new FastifyReplyError(400, 'signin required');
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await this.usersRepository.findOneByOrFail({
|
const user = await this.usersRepository.findOneByOrFail({
|
||||||
|
@ -66,13 +63,13 @@ export class TwitterServerService {
|
||||||
integrations: profile.integrations,
|
integrations: profile.integrations,
|
||||||
});
|
});
|
||||||
|
|
||||||
ctx.body = 'Twitterの連携を解除しました :v:';
|
|
||||||
|
|
||||||
// Publish i updated event
|
// Publish i updated event
|
||||||
this.globalEventService.publishMainStream(user.id, 'meUpdated', await this.userEntityService.pack(user, user, {
|
this.globalEventService.publishMainStream(user.id, 'meUpdated', await this.userEntityService.pack(user, user, {
|
||||||
detail: true,
|
detail: true,
|
||||||
includeSecrets: true,
|
includeSecrets: true,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
return 'Twitterの連携を解除しました :v:';
|
||||||
});
|
});
|
||||||
|
|
||||||
const getTwAuth = async () => {
|
const getTwAuth = async () => {
|
||||||
|
@ -89,25 +86,23 @@ export class TwitterServerService {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
router.get('/connect/twitter', async ctx => {
|
fastify.get('/connect/twitter', async (request, reply) => {
|
||||||
if (!this.compareOrigin(ctx)) {
|
if (!this.compareOrigin(request)) {
|
||||||
ctx.throw(400, 'invalid origin');
|
throw new FastifyReplyError(400, 'invalid origin');
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const userToken = this.getUserToken(ctx);
|
const userToken = this.getUserToken(request);
|
||||||
if (userToken == null) {
|
if (userToken == null) {
|
||||||
ctx.throw(400, 'signin required');
|
throw new FastifyReplyError(400, 'signin required');
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const twAuth = await getTwAuth();
|
const twAuth = await getTwAuth();
|
||||||
const twCtx = await twAuth!.begin();
|
const twCtx = await twAuth!.begin();
|
||||||
this.redisClient.set(userToken, JSON.stringify(twCtx));
|
this.redisClient.set(userToken, JSON.stringify(twCtx));
|
||||||
ctx.redirect(twCtx.url);
|
reply.redirect(twCtx.url);
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/signin/twitter', async ctx => {
|
fastify.get('/signin/twitter', async (request, reply) => {
|
||||||
const twAuth = await getTwAuth();
|
const twAuth = await getTwAuth();
|
||||||
const twCtx = await twAuth!.begin();
|
const twCtx = await twAuth!.begin();
|
||||||
|
|
||||||
|
@ -115,26 +110,25 @@ export class TwitterServerService {
|
||||||
|
|
||||||
this.redisClient.set(sessid, JSON.stringify(twCtx));
|
this.redisClient.set(sessid, JSON.stringify(twCtx));
|
||||||
|
|
||||||
ctx.cookies.set('signin_with_twitter_sid', sessid, {
|
reply.cookies.set('signin_with_twitter_sid', sessid, {
|
||||||
path: '/',
|
path: '/',
|
||||||
secure: this.config.url.startsWith('https'),
|
secure: this.config.url.startsWith('https'),
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
ctx.redirect(twCtx.url);
|
reply.redirect(twCtx.url);
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/tw/cb', async ctx => {
|
fastify.get('/tw/cb', async (request, reply) => {
|
||||||
const userToken = this.getUserToken(ctx);
|
const userToken = this.getUserToken(request);
|
||||||
|
|
||||||
const twAuth = await getTwAuth();
|
const twAuth = await getTwAuth();
|
||||||
|
|
||||||
if (userToken == null) {
|
if (userToken == null) {
|
||||||
const sessid = ctx.cookies.get('signin_with_twitter_sid');
|
const sessid = request.cookies.get('signin_with_twitter_sid');
|
||||||
|
|
||||||
if (sessid == null) {
|
if (sessid == null) {
|
||||||
ctx.throw(400, 'invalid session');
|
throw new FastifyReplyError(400, 'invalid session');
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const get = new Promise<any>((res, rej) => {
|
const get = new Promise<any>((res, rej) => {
|
||||||
|
@ -145,10 +139,9 @@ export class TwitterServerService {
|
||||||
|
|
||||||
const twCtx = await get;
|
const twCtx = await get;
|
||||||
|
|
||||||
const verifier = ctx.query.oauth_verifier;
|
const verifier = request.query.oauth_verifier;
|
||||||
if (!verifier || typeof verifier !== 'string') {
|
if (!verifier || typeof verifier !== 'string') {
|
||||||
ctx.throw(400, 'invalid session');
|
throw new FastifyReplyError(400, 'invalid session');
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await twAuth!.done(JSON.parse(twCtx), verifier);
|
const result = await twAuth!.done(JSON.parse(twCtx), verifier);
|
||||||
|
@ -159,17 +152,15 @@ export class TwitterServerService {
|
||||||
.getOne();
|
.getOne();
|
||||||
|
|
||||||
if (link == null) {
|
if (link == null) {
|
||||||
ctx.throw(404, `@${result.screenName}と連携しているMisskeyアカウントはありませんでした...`);
|
throw new FastifyReplyError(404, `@${result.screenName}と連携しているMisskeyアカウントはありませんでした...`);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.signinService.signin(ctx, await this.usersRepository.findOneBy({ id: link.userId }) as ILocalUser, true);
|
return this.signinService.signin(request, reply, await this.usersRepository.findOneBy({ id: link.userId }) as ILocalUser, true);
|
||||||
} else {
|
} else {
|
||||||
const verifier = ctx.query.oauth_verifier;
|
const verifier = request.query.oauth_verifier;
|
||||||
|
|
||||||
if (!verifier || typeof verifier !== 'string') {
|
if (!verifier || typeof verifier !== 'string') {
|
||||||
ctx.throw(400, 'invalid session');
|
throw new FastifyReplyError(400, 'invalid session');
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const get = new Promise<any>((res, rej) => {
|
const get = new Promise<any>((res, rej) => {
|
||||||
|
@ -201,29 +192,29 @@ export class TwitterServerService {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
ctx.body = `Twitter: @${result.screenName} を、Misskey: @${user.username} に接続しました!`;
|
|
||||||
|
|
||||||
// Publish i updated event
|
// Publish i updated event
|
||||||
this.globalEventService.publishMainStream(user.id, 'meUpdated', await this.userEntityService.pack(user, user, {
|
this.globalEventService.publishMainStream(user.id, 'meUpdated', await this.userEntityService.pack(user, user, {
|
||||||
detail: true,
|
detail: true,
|
||||||
includeSecrets: true,
|
includeSecrets: true,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
return `Twitter: @${result.screenName} を、Misskey: @${user.username} に接続しました!`;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return router;
|
done();
|
||||||
}
|
}
|
||||||
|
|
||||||
private getUserToken(ctx: Koa.BaseContext): string | null {
|
private getUserToken(request: FastifyRequest): string | null {
|
||||||
return ((ctx.headers['cookie'] ?? '').match(/igi=(\w+)/) ?? [null, null])[1];
|
return ((request.headers['cookie'] ?? '').match(/igi=(\w+)/) ?? [null, null])[1];
|
||||||
}
|
}
|
||||||
|
|
||||||
private compareOrigin(ctx: Koa.BaseContext): boolean {
|
private compareOrigin(request: FastifyRequest): boolean {
|
||||||
function normalizeUrl(url?: string): string {
|
function normalizeUrl(url?: string): string {
|
||||||
return url ? url.endsWith('/') ? url.substr(0, url.length - 1) : url : '';
|
return url ? url.endsWith('/') ? url.substr(0, url.length - 1) : url : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
const referer = ctx.headers['referer'];
|
const referer = request.headers['referer'];
|
||||||
|
|
||||||
return (normalizeUrl(referer) === normalizeUrl(this.config.url));
|
return (normalizeUrl(referer) === normalizeUrl(this.config.url));
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,16 +3,12 @@ import { fileURLToPath } from 'node:url';
|
||||||
import { PathOrFileDescriptor, readFileSync } from 'node:fs';
|
import { PathOrFileDescriptor, readFileSync } from 'node:fs';
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import ms from 'ms';
|
import ms from 'ms';
|
||||||
import Koa from 'koa';
|
|
||||||
import Router from '@koa/router';
|
|
||||||
import send from 'koa-send';
|
|
||||||
import favicon from 'koa-favicon';
|
|
||||||
import views from 'koa-views';
|
|
||||||
import sharp from 'sharp';
|
import sharp from 'sharp';
|
||||||
import { createBullBoard } from '@bull-board/api';
|
import pug from 'pug';
|
||||||
import { BullAdapter } from '@bull-board/api/bullAdapter.js';
|
|
||||||
import { KoaAdapter } from '@bull-board/koa';
|
|
||||||
import { In, IsNull } from 'typeorm';
|
import { In, IsNull } from 'typeorm';
|
||||||
|
import { FastifyInstance, FastifyPluginOptions, FastifyReply } from 'fastify';
|
||||||
|
import fastifyStatic from '@fastify/static';
|
||||||
|
import fastifyView from '@fastify/view';
|
||||||
import type { Config } from '@/config.js';
|
import type { Config } from '@/config.js';
|
||||||
import { getNoteSummary } from '@/misc/get-note-summary.js';
|
import { getNoteSummary } from '@/misc/get-note-summary.js';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
|
@ -84,9 +80,10 @@ export class ClientServerService {
|
||||||
@Inject('queue:objectStorage') public objectStorageQueue: ObjectStorageQueue,
|
@Inject('queue:objectStorage') public objectStorageQueue: ObjectStorageQueue,
|
||||||
@Inject('queue:webhookDeliver') public webhookDeliverQueue: WebhookDeliverQueue,
|
@Inject('queue:webhookDeliver') public webhookDeliverQueue: WebhookDeliverQueue,
|
||||||
) {
|
) {
|
||||||
|
this.createServer = this.createServer.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async manifestHandler(ctx: Koa.Context) {
|
private async manifestHandler(reply: FastifyReply) {
|
||||||
const res = deepClone(manifest);
|
const res = deepClone(manifest);
|
||||||
|
|
||||||
const instance = await this.metaService.fetch(true);
|
const instance = await this.metaService.fetch(true);
|
||||||
|
@ -95,27 +92,26 @@ export class ClientServerService {
|
||||||
res.name = instance.name ?? 'Misskey';
|
res.name = instance.name ?? 'Misskey';
|
||||||
if (instance.themeColor) res.theme_color = instance.themeColor;
|
if (instance.themeColor) res.theme_color = instance.themeColor;
|
||||||
|
|
||||||
ctx.set('Cache-Control', 'max-age=300');
|
reply.header('Cache-Control', 'max-age=300');
|
||||||
ctx.body = res;
|
return (res);
|
||||||
}
|
}
|
||||||
|
|
||||||
public createApp() {
|
public createServer(fastify: FastifyInstance, options: FastifyPluginOptions, done: (err?: Error) => void) {
|
||||||
const app = new Koa();
|
/* TODO
|
||||||
|
|
||||||
//#region Bull Dashboard
|
//#region Bull Dashboard
|
||||||
const bullBoardPath = '/queue';
|
const bullBoardPath = '/queue';
|
||||||
|
|
||||||
// Authenticate
|
// Authenticate
|
||||||
app.use(async (ctx, next) => {
|
app.use(async (request, reply) => {
|
||||||
if (ctx.path === bullBoardPath || ctx.path.startsWith(bullBoardPath + '/')) {
|
if (ctx.path === bullBoardPath || ctx.path.startsWith(bullBoardPath + '/')) {
|
||||||
const token = ctx.cookies.get('token');
|
const token = ctx.cookies.get('token');
|
||||||
if (token == null) {
|
if (token == null) {
|
||||||
ctx.status = 401;
|
reply.code(401);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const user = await this.usersRepository.findOneBy({ token });
|
const user = await this.usersRepository.findOneBy({ token });
|
||||||
if (user == null || !(user.isAdmin || user.isModerator)) {
|
if (user == null || !(user.isAdmin || user.isModerator)) {
|
||||||
ctx.status = 403;
|
reply.code(403);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -140,83 +136,84 @@ export class ClientServerService {
|
||||||
serverAdapter.setBasePath(bullBoardPath);
|
serverAdapter.setBasePath(bullBoardPath);
|
||||||
app.use(serverAdapter.registerPlugin());
|
app.use(serverAdapter.registerPlugin());
|
||||||
//#endregion
|
//#endregion
|
||||||
|
*/
|
||||||
|
|
||||||
// Init renderer
|
fastify.register(fastifyView, {
|
||||||
app.use(views(_dirname + '/views', {
|
root: _dirname + '/views',
|
||||||
extension: 'pug',
|
engine: {
|
||||||
options: {
|
pug: pug,
|
||||||
|
},
|
||||||
|
defaultContext: {
|
||||||
version: this.config.version,
|
version: this.config.version,
|
||||||
getClientEntry: () => process.env.NODE_ENV === 'production' ?
|
getClientEntry: () => process.env.NODE_ENV === 'production' ?
|
||||||
this.config.clientEntry :
|
this.config.clientEntry :
|
||||||
JSON.parse(readFileSync(`${_dirname}/../../../../../built/_client_dist_/manifest.json`, 'utf-8'))['src/init.ts'],
|
JSON.parse(readFileSync(`${_dirname}/../../../../../built/_client_dist_/manifest.json`, 'utf-8'))['src/init.ts'],
|
||||||
config: this.config,
|
config: this.config,
|
||||||
},
|
},
|
||||||
}));
|
|
||||||
|
|
||||||
// Serve favicon
|
|
||||||
app.use(favicon(`${_dirname}/../../../assets/favicon.ico`));
|
|
||||||
|
|
||||||
// Common request handler
|
|
||||||
app.use(async (ctx, next) => {
|
|
||||||
// IFrameの中に入れられないようにする
|
|
||||||
ctx.set('X-Frame-Options', 'DENY');
|
|
||||||
await next();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Init router
|
fastify.addHook('onRequest', (request, reply, done) => {
|
||||||
const router = new Router();
|
// クリックジャッキング防止のためiFrameの中に入れられないようにする
|
||||||
|
reply.header('X-Frame-Options', 'DENY');
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
|
||||||
//#region static assets
|
//#region static assets
|
||||||
|
|
||||||
router.get('/static-assets/(.*)', async ctx => {
|
fastify.register(fastifyStatic, {
|
||||||
await send(ctx as any, ctx.path.replace('/static-assets/', ''), {
|
root: _dirname,
|
||||||
root: staticAssets,
|
serve: false,
|
||||||
maxage: ms('7 days'),
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/client-assets/(.*)', async ctx => {
|
fastify.register(fastifyStatic, {
|
||||||
await send(ctx as any, ctx.path.replace('/client-assets/', ''), {
|
root: staticAssets,
|
||||||
root: clientAssets,
|
prefix: '/static-assets/',
|
||||||
maxage: ms('7 days'),
|
maxAge: ms('7 days'),
|
||||||
});
|
decorateReply: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/assets/(.*)', async ctx => {
|
fastify.register(fastifyStatic, {
|
||||||
await send(ctx as any, ctx.path.replace('/assets/', ''), {
|
root: clientAssets,
|
||||||
root: assets,
|
prefix: '/client-assets/',
|
||||||
maxage: ms('7 days'),
|
maxAge: ms('7 days'),
|
||||||
});
|
decorateReply: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Apple touch icon
|
fastify.register(fastifyStatic, {
|
||||||
router.get('/apple-touch-icon.png', async ctx => {
|
root: assets,
|
||||||
await send(ctx as any, '/apple-touch-icon.png', {
|
prefix: '/assets/',
|
||||||
root: staticAssets,
|
maxAge: ms('7 days'),
|
||||||
});
|
decorateReply: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/twemoji/(.*)', async ctx => {
|
fastify.get('/favicon.ico', async (request, reply) => {
|
||||||
const path = ctx.path.replace('/twemoji/', '');
|
return reply.sendFile('/favicon.ico', staticAssets);
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.get('/apple-touch-icon.png', async (request, reply) => {
|
||||||
|
return reply.sendFile('/apple-touch-icon.png', staticAssets);
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.get<{ Params: { path: string } }>('/twemoji/:path(.*)', async (request, reply) => {
|
||||||
|
const path = request.params.path;
|
||||||
|
|
||||||
if (!path.match(/^[0-9a-f-]+\.svg$/)) {
|
if (!path.match(/^[0-9a-f-]+\.svg$/)) {
|
||||||
ctx.status = 404;
|
reply.code(404);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.set('Content-Security-Policy', 'default-src \'none\'; style-src \'unsafe-inline\'');
|
reply.header('Content-Security-Policy', 'default-src \'none\'; style-src \'unsafe-inline\'');
|
||||||
|
|
||||||
await send(ctx as any, path, {
|
return await reply.sendFile(path, `${_dirname}/../../../node_modules/@discordapp/twemoji/dist/svg/`, {
|
||||||
root: `${_dirname}/../../../node_modules/@discordapp/twemoji/dist/svg/`,
|
maxAge: ms('30 days'),
|
||||||
maxage: ms('30 days'),
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/twemoji-badge/(.*)', async ctx => {
|
fastify.get<{ Params: { path: string } }>('/twemoji-badge/:path(.*)', async (request, reply) => {
|
||||||
const path = ctx.path.replace('/twemoji-badge/', '');
|
const path = request.params.path;
|
||||||
|
|
||||||
if (!path.match(/^[0-9a-f-]+\.png$/)) {
|
if (!path.match(/^[0-9a-f-]+\.png$/)) {
|
||||||
ctx.status = 404;
|
reply.code(404);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -249,44 +246,43 @@ export class ClientServerService {
|
||||||
.png()
|
.png()
|
||||||
.toBuffer();
|
.toBuffer();
|
||||||
|
|
||||||
ctx.set('Content-Security-Policy', 'default-src \'none\'; style-src \'unsafe-inline\'');
|
reply.header('Content-Security-Policy', 'default-src \'none\'; style-src \'unsafe-inline\'');
|
||||||
ctx.set('Cache-Control', 'max-age=2592000');
|
reply.header('Cache-Control', 'max-age=2592000');
|
||||||
ctx.set('Content-Type', 'image/png');
|
reply.header('Content-Type', 'image/png');
|
||||||
ctx.body = buffer;
|
return buffer;
|
||||||
});
|
});
|
||||||
|
|
||||||
// ServiceWorker
|
// ServiceWorker
|
||||||
router.get('/sw.js', async ctx => {
|
fastify.get('/sw.js', async (request, reply) => {
|
||||||
await send(ctx as any, '/sw.js', {
|
return await reply.sendFile('/sw.js', swAssets, {
|
||||||
root: swAssets,
|
maxAge: ms('10 minutes'),
|
||||||
maxage: ms('10 minutes'),
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Manifest
|
// Manifest
|
||||||
router.get('/manifest.json', ctx => this.manifestHandler(ctx));
|
fastify.get('/manifest.json', async (request, reply) => await this.manifestHandler(reply));
|
||||||
|
|
||||||
router.get('/robots.txt', async ctx => {
|
fastify.get('/robots.txt', async (request, reply) => {
|
||||||
await send(ctx as any, '/robots.txt', {
|
return await reply.sendFile('/robots.txt', staticAssets);
|
||||||
root: staticAssets,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
//#endregion
|
//#endregion
|
||||||
|
|
||||||
// Docs
|
const renderBase = async (reply: FastifyReply) => {
|
||||||
router.get('/api-doc', async ctx => {
|
const meta = await this.metaService.fetch();
|
||||||
await send(ctx as any, '/redoc.html', {
|
reply.header('Cache-Control', 'public, max-age=15');
|
||||||
root: staticAssets,
|
return await reply.view('base', {
|
||||||
|
img: meta.bannerUrl,
|
||||||
|
title: meta.name ?? 'Misskey',
|
||||||
|
instanceName: meta.name ?? 'Misskey',
|
||||||
|
desc: meta.description,
|
||||||
|
icon: meta.iconUrl,
|
||||||
|
themeColor: meta.themeColor,
|
||||||
});
|
});
|
||||||
});
|
};
|
||||||
|
|
||||||
// URL preview endpoint
|
// URL preview endpoint
|
||||||
router.get('/url', ctx => this.urlPreviewService.handle(ctx));
|
fastify.get<{ Querystring: { url: string; lang: string; } }>('/url', (request, reply) => this.urlPreviewService.handle(request, reply));
|
||||||
|
|
||||||
router.get('/api.json', async ctx => {
|
|
||||||
ctx.body = genOpenapiSpec();
|
|
||||||
});
|
|
||||||
|
|
||||||
const getFeed = async (acct: string) => {
|
const getFeed = async (acct: string) => {
|
||||||
const { username, host } = Acct.parse(acct);
|
const { username, host } = Acct.parse(acct);
|
||||||
|
@ -300,45 +296,45 @@ export class ClientServerService {
|
||||||
};
|
};
|
||||||
|
|
||||||
// Atom
|
// Atom
|
||||||
router.get('/@:user.atom', async ctx => {
|
fastify.get<{ Params: { user: string; } }>('/@:user.atom', async (request, reply) => {
|
||||||
const feed = await getFeed(ctx.params.user);
|
const feed = await getFeed(request.params.user);
|
||||||
|
|
||||||
if (feed) {
|
if (feed) {
|
||||||
ctx.set('Content-Type', 'application/atom+xml; charset=utf-8');
|
reply.header('Content-Type', 'application/atom+xml; charset=utf-8');
|
||||||
ctx.body = feed.atom1();
|
return feed.atom1();
|
||||||
} else {
|
} else {
|
||||||
ctx.status = 404;
|
reply.code(404);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// RSS
|
// RSS
|
||||||
router.get('/@:user.rss', async ctx => {
|
fastify.get<{ Params: { user: string; } }>('/@:user.rss', async (request, reply) => {
|
||||||
const feed = await getFeed(ctx.params.user);
|
const feed = await getFeed(request.params.user);
|
||||||
|
|
||||||
if (feed) {
|
if (feed) {
|
||||||
ctx.set('Content-Type', 'application/rss+xml; charset=utf-8');
|
reply.header('Content-Type', 'application/rss+xml; charset=utf-8');
|
||||||
ctx.body = feed.rss2();
|
return feed.rss2();
|
||||||
} else {
|
} else {
|
||||||
ctx.status = 404;
|
reply.code(404);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// JSON
|
// JSON
|
||||||
router.get('/@:user.json', async ctx => {
|
fastify.get<{ Params: { user: string; } }>('/@:user.json', async (request, reply) => {
|
||||||
const feed = await getFeed(ctx.params.user);
|
const feed = await getFeed(request.params.user);
|
||||||
|
|
||||||
if (feed) {
|
if (feed) {
|
||||||
ctx.set('Content-Type', 'application/json; charset=utf-8');
|
reply.header('Content-Type', 'application/json; charset=utf-8');
|
||||||
ctx.body = feed.json1();
|
return feed.json1();
|
||||||
} else {
|
} else {
|
||||||
ctx.status = 404;
|
reply.code(404);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
//#region SSR (for crawlers)
|
//#region SSR (for crawlers)
|
||||||
// User
|
// User
|
||||||
router.get(['/@:user', '/@:user/:sub'], async (ctx, next) => {
|
fastify.get<{ Params: { user: string; sub?: string; } }>('/@:user/:sub?', async (request, reply) => {
|
||||||
const { username, host } = Acct.parse(ctx.params.user);
|
const { username, host } = Acct.parse(request.params.user);
|
||||||
const user = await this.usersRepository.findOneBy({
|
const user = await this.usersRepository.findOneBy({
|
||||||
usernameLower: username.toLowerCase(),
|
usernameLower: username.toLowerCase(),
|
||||||
host: host ?? IsNull(),
|
host: host ?? IsNull(),
|
||||||
|
@ -354,41 +350,41 @@ export class ClientServerService {
|
||||||
.map(field => field.value)
|
.map(field => field.value)
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
await ctx.render('user', {
|
reply.header('Cache-Control', 'public, max-age=15');
|
||||||
|
return await reply.view('user', {
|
||||||
user, profile, me,
|
user, profile, me,
|
||||||
avatarUrl: await this.userEntityService.getAvatarUrl(user),
|
avatarUrl: await this.userEntityService.getAvatarUrl(user),
|
||||||
sub: ctx.params.sub,
|
sub: request.params.sub,
|
||||||
instanceName: meta.name ?? 'Misskey',
|
instanceName: meta.name ?? 'Misskey',
|
||||||
icon: meta.iconUrl,
|
icon: meta.iconUrl,
|
||||||
themeColor: meta.themeColor,
|
themeColor: meta.themeColor,
|
||||||
});
|
});
|
||||||
ctx.set('Cache-Control', 'public, max-age=15');
|
|
||||||
} else {
|
} else {
|
||||||
// リモートユーザーなので
|
// リモートユーザーなので
|
||||||
// モデレータがAPI経由で参照可能にするために404にはしない
|
// モデレータがAPI経由で参照可能にするために404にはしない
|
||||||
await next();
|
return await renderBase(reply);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/users/:user', async ctx => {
|
fastify.get<{ Params: { user: string; } }>('/users/:user', async (request, reply) => {
|
||||||
const user = await this.usersRepository.findOneBy({
|
const user = await this.usersRepository.findOneBy({
|
||||||
id: ctx.params.user,
|
id: request.params.user,
|
||||||
host: IsNull(),
|
host: IsNull(),
|
||||||
isSuspended: false,
|
isSuspended: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (user == null) {
|
if (user == null) {
|
||||||
ctx.status = 404;
|
reply.code(404);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.redirect(`/@${user.username}${ user.host == null ? '' : '@' + user.host}`);
|
reply.redirect(`/@${user.username}${ user.host == null ? '' : '@' + user.host}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Note
|
// Note
|
||||||
router.get('/notes/:note', async (ctx, next) => {
|
fastify.get<{ Params: { note: string; } }>('/notes/:note', async (request, reply) => {
|
||||||
const note = await this.notesRepository.findOneBy({
|
const note = await this.notesRepository.findOneBy({
|
||||||
id: ctx.params.note,
|
id: request.params.note,
|
||||||
visibility: In(['public', 'home']),
|
visibility: In(['public', 'home']),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -396,7 +392,8 @@ export class ClientServerService {
|
||||||
const _note = await this.noteEntityService.pack(note);
|
const _note = await this.noteEntityService.pack(note);
|
||||||
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: note.userId });
|
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: note.userId });
|
||||||
const meta = await this.metaService.fetch();
|
const meta = await this.metaService.fetch();
|
||||||
await ctx.render('note', {
|
reply.header('Cache-Control', 'public, max-age=15');
|
||||||
|
return await reply.view('note', {
|
||||||
note: _note,
|
note: _note,
|
||||||
profile,
|
profile,
|
||||||
avatarUrl: await this.userEntityService.getAvatarUrl(await this.usersRepository.findOneByOrFail({ id: note.userId })),
|
avatarUrl: await this.userEntityService.getAvatarUrl(await this.usersRepository.findOneByOrFail({ id: note.userId })),
|
||||||
|
@ -406,18 +403,14 @@ export class ClientServerService {
|
||||||
icon: meta.iconUrl,
|
icon: meta.iconUrl,
|
||||||
themeColor: meta.themeColor,
|
themeColor: meta.themeColor,
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
ctx.set('Cache-Control', 'public, max-age=15');
|
return await renderBase(reply);
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await next();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Page
|
// Page
|
||||||
router.get('/@:user/pages/:page', async (ctx, next) => {
|
fastify.get<{ Params: { user: string; page: string; } }>('/@:user/pages/:page', async (request, reply) => {
|
||||||
const { username, host } = Acct.parse(ctx.params.user);
|
const { username, host } = Acct.parse(request.params.user);
|
||||||
const user = await this.usersRepository.findOneBy({
|
const user = await this.usersRepository.findOneBy({
|
||||||
usernameLower: username.toLowerCase(),
|
usernameLower: username.toLowerCase(),
|
||||||
host: host ?? IsNull(),
|
host: host ?? IsNull(),
|
||||||
|
@ -426,7 +419,7 @@ export class ClientServerService {
|
||||||
if (user == null) return;
|
if (user == null) return;
|
||||||
|
|
||||||
const page = await this.pagesRepository.findOneBy({
|
const page = await this.pagesRepository.findOneBy({
|
||||||
name: ctx.params.page,
|
name: request.params.page,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -434,7 +427,12 @@ export class ClientServerService {
|
||||||
const _page = await this.pageEntityService.pack(page);
|
const _page = await this.pageEntityService.pack(page);
|
||||||
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: page.userId });
|
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: page.userId });
|
||||||
const meta = await this.metaService.fetch();
|
const meta = await this.metaService.fetch();
|
||||||
await ctx.render('page', {
|
if (['public'].includes(page.visibility)) {
|
||||||
|
reply.header('Cache-Control', 'public, max-age=15');
|
||||||
|
} else {
|
||||||
|
reply.header('Cache-Control', 'private, max-age=0, must-revalidate');
|
||||||
|
}
|
||||||
|
return await reply.view('page', {
|
||||||
page: _page,
|
page: _page,
|
||||||
profile,
|
profile,
|
||||||
avatarUrl: await this.userEntityService.getAvatarUrl(await this.usersRepository.findOneByOrFail({ id: page.userId })),
|
avatarUrl: await this.userEntityService.getAvatarUrl(await this.usersRepository.findOneByOrFail({ id: page.userId })),
|
||||||
|
@ -442,31 +440,24 @@ export class ClientServerService {
|
||||||
icon: meta.iconUrl,
|
icon: meta.iconUrl,
|
||||||
themeColor: meta.themeColor,
|
themeColor: meta.themeColor,
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
if (['public'].includes(page.visibility)) {
|
return await renderBase(reply);
|
||||||
ctx.set('Cache-Control', 'public, max-age=15');
|
|
||||||
} else {
|
|
||||||
ctx.set('Cache-Control', 'private, max-age=0, must-revalidate');
|
|
||||||
}
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await next();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Clip
|
// Clip
|
||||||
// TODO: 非publicなclipのハンドリング
|
// TODO: 非publicなclipのハンドリング
|
||||||
router.get('/clips/:clip', async (ctx, next) => {
|
fastify.get<{ Params: { clip: string; } }>('/clips/:clip', async (request, reply) => {
|
||||||
const clip = await this.clipsRepository.findOneBy({
|
const clip = await this.clipsRepository.findOneBy({
|
||||||
id: ctx.params.clip,
|
id: request.params.clip,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (clip) {
|
if (clip) {
|
||||||
const _clip = await this.clipEntityService.pack(clip);
|
const _clip = await this.clipEntityService.pack(clip);
|
||||||
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: clip.userId });
|
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: clip.userId });
|
||||||
const meta = await this.metaService.fetch();
|
const meta = await this.metaService.fetch();
|
||||||
await ctx.render('clip', {
|
reply.header('Cache-Control', 'public, max-age=15');
|
||||||
|
return await reply.view('clip', {
|
||||||
clip: _clip,
|
clip: _clip,
|
||||||
profile,
|
profile,
|
||||||
avatarUrl: await this.userEntityService.getAvatarUrl(await this.usersRepository.findOneByOrFail({ id: clip.userId })),
|
avatarUrl: await this.userEntityService.getAvatarUrl(await this.usersRepository.findOneByOrFail({ id: clip.userId })),
|
||||||
|
@ -474,24 +465,21 @@ export class ClientServerService {
|
||||||
icon: meta.iconUrl,
|
icon: meta.iconUrl,
|
||||||
themeColor: meta.themeColor,
|
themeColor: meta.themeColor,
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
ctx.set('Cache-Control', 'public, max-age=15');
|
return await renderBase(reply);
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await next();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Gallery post
|
// Gallery post
|
||||||
router.get('/gallery/:post', async (ctx, next) => {
|
fastify.get<{ Params: { post: string; } }>('/gallery/:post', async (request, reply) => {
|
||||||
const post = await this.galleryPostsRepository.findOneBy({ id: ctx.params.post });
|
const post = await this.galleryPostsRepository.findOneBy({ id: request.params.post });
|
||||||
|
|
||||||
if (post) {
|
if (post) {
|
||||||
const _post = await this.galleryPostEntityService.pack(post);
|
const _post = await this.galleryPostEntityService.pack(post);
|
||||||
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: post.userId });
|
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: post.userId });
|
||||||
const meta = await this.metaService.fetch();
|
const meta = await this.metaService.fetch();
|
||||||
await ctx.render('gallery-post', {
|
reply.header('Cache-Control', 'public, max-age=15');
|
||||||
|
return await reply.view('gallery-post', {
|
||||||
post: _post,
|
post: _post,
|
||||||
profile,
|
profile,
|
||||||
avatarUrl: await this.userEntityService.getAvatarUrl(await this.usersRepository.findOneByOrFail({ id: post.userId })),
|
avatarUrl: await this.userEntityService.getAvatarUrl(await this.usersRepository.findOneByOrFail({ id: post.userId })),
|
||||||
|
@ -499,46 +487,39 @@ export class ClientServerService {
|
||||||
icon: meta.iconUrl,
|
icon: meta.iconUrl,
|
||||||
themeColor: meta.themeColor,
|
themeColor: meta.themeColor,
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
ctx.set('Cache-Control', 'public, max-age=15');
|
return await renderBase(reply);
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await next();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Channel
|
// Channel
|
||||||
router.get('/channels/:channel', async (ctx, next) => {
|
fastify.get<{ Params: { channel: string; } }>('/channels/:channel', async (request, reply) => {
|
||||||
const channel = await this.channelsRepository.findOneBy({
|
const channel = await this.channelsRepository.findOneBy({
|
||||||
id: ctx.params.channel,
|
id: request.params.channel,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (channel) {
|
if (channel) {
|
||||||
const _channel = await this.channelEntityService.pack(channel);
|
const _channel = await this.channelEntityService.pack(channel);
|
||||||
const meta = await this.metaService.fetch();
|
const meta = await this.metaService.fetch();
|
||||||
await ctx.render('channel', {
|
reply.header('Cache-Control', 'public, max-age=15');
|
||||||
|
return await reply.view('channel', {
|
||||||
channel: _channel,
|
channel: _channel,
|
||||||
instanceName: meta.name ?? 'Misskey',
|
instanceName: meta.name ?? 'Misskey',
|
||||||
icon: meta.iconUrl,
|
icon: meta.iconUrl,
|
||||||
themeColor: meta.themeColor,
|
themeColor: meta.themeColor,
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
ctx.set('Cache-Control', 'public, max-age=15');
|
return await renderBase(reply);
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await next();
|
|
||||||
});
|
});
|
||||||
//#endregion
|
//#endregion
|
||||||
|
|
||||||
router.get('/_info_card_', async ctx => {
|
fastify.get('/_info_card_', async (request, reply) => {
|
||||||
const meta = await this.metaService.fetch(true);
|
const meta = await this.metaService.fetch(true);
|
||||||
|
|
||||||
ctx.remove('X-Frame-Options');
|
reply.removeHeader('X-Frame-Options');
|
||||||
|
|
||||||
await ctx.render('info-card', {
|
return await reply.view('info-card', {
|
||||||
version: this.config.version,
|
version: this.config.version,
|
||||||
host: this.config.host,
|
host: this.config.host,
|
||||||
meta: meta,
|
meta: meta,
|
||||||
|
@ -547,14 +528,14 @@ export class ClientServerService {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/bios', async ctx => {
|
fastify.get('/bios', async (request, reply) => {
|
||||||
await ctx.render('bios', {
|
return await reply.view('bios', {
|
||||||
version: this.config.version,
|
version: this.config.version,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/cli', async ctx => {
|
fastify.get('/cli', async (request, reply) => {
|
||||||
await ctx.render('cli', {
|
return await reply.view('cli', {
|
||||||
version: this.config.version,
|
version: this.config.version,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -562,33 +543,21 @@ export class ClientServerService {
|
||||||
const override = (source: string, target: string, depth = 0) =>
|
const override = (source: string, target: string, depth = 0) =>
|
||||||
[, ...target.split('/').filter(x => x), ...source.split('/').filter(x => x).splice(depth)].join('/');
|
[, ...target.split('/').filter(x => x), ...source.split('/').filter(x => x).splice(depth)].join('/');
|
||||||
|
|
||||||
router.get('/flush', async ctx => {
|
fastify.get('/flush', async (request, reply) => {
|
||||||
await ctx.render('flush');
|
return await reply.view('flush');
|
||||||
});
|
});
|
||||||
|
|
||||||
// streamingに非WebSocketリクエストが来た場合にbase htmlをキャシュ付きで返すと、Proxy等でそのパスがキャッシュされておかしくなる
|
// streamingに非WebSocketリクエストが来た場合にbase htmlをキャシュ付きで返すと、Proxy等でそのパスがキャッシュされておかしくなる
|
||||||
router.get('/streaming', async ctx => {
|
fastify.get('/streaming', async (request, reply) => {
|
||||||
ctx.status = 503;
|
reply.code(503);
|
||||||
ctx.set('Cache-Control', 'private, max-age=0');
|
reply.header('Cache-Control', 'private, max-age=0');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Render base html for all requests
|
// Render base html for all requests
|
||||||
router.get('(.*)', async ctx => {
|
fastify.get('*', async (request, reply) => {
|
||||||
const meta = await this.metaService.fetch();
|
return await renderBase(reply);
|
||||||
await ctx.render('base', {
|
|
||||||
img: meta.bannerUrl,
|
|
||||||
title: meta.name ?? 'Misskey',
|
|
||||||
instanceName: meta.name ?? 'Misskey',
|
|
||||||
desc: meta.description,
|
|
||||||
icon: meta.iconUrl,
|
|
||||||
themeColor: meta.themeColor,
|
|
||||||
});
|
|
||||||
ctx.set('Cache-Control', 'public, max-age=15');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Register router
|
done();
|
||||||
app.use(router.routes());
|
|
||||||
|
|
||||||
return app;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import summaly from 'summaly';
|
import summaly from 'summaly';
|
||||||
|
import { FastifyRequest, FastifyReply } from 'fastify';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
import type { UsersRepository } from '@/models/index.js';
|
import type { UsersRepository } from '@/models/index.js';
|
||||||
import type { Config } from '@/config.js';
|
import type { Config } from '@/config.js';
|
||||||
|
@ -8,7 +9,6 @@ import { HttpRequestService } from '@/core/HttpRequestService.js';
|
||||||
import type Logger from '@/logger.js';
|
import type Logger from '@/logger.js';
|
||||||
import { query } from '@/misc/prelude/url.js';
|
import { query } from '@/misc/prelude/url.js';
|
||||||
import { LoggerService } from '@/core/LoggerService.js';
|
import { LoggerService } from '@/core/LoggerService.js';
|
||||||
import type Koa from 'koa';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UrlPreviewService {
|
export class UrlPreviewService {
|
||||||
|
@ -39,16 +39,19 @@ export class UrlPreviewService {
|
||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async handle(ctx: Koa.Context) {
|
public async handle(
|
||||||
const url = ctx.query.url;
|
request: FastifyRequest<{ Querystring: { url: string; lang: string; } }>,
|
||||||
|
reply: FastifyReply,
|
||||||
|
) {
|
||||||
|
const url = request.query.url;
|
||||||
if (typeof url !== 'string') {
|
if (typeof url !== 'string') {
|
||||||
ctx.status = 400;
|
reply.code(400);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const lang = ctx.query.lang;
|
const lang = request.query.lang;
|
||||||
if (Array.isArray(lang)) {
|
if (Array.isArray(lang)) {
|
||||||
ctx.status = 400;
|
reply.code(400);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -73,14 +76,14 @@ export class UrlPreviewService {
|
||||||
summary.thumbnail = this.wrap(summary.thumbnail);
|
summary.thumbnail = this.wrap(summary.thumbnail);
|
||||||
|
|
||||||
// Cache 7days
|
// Cache 7days
|
||||||
ctx.set('Cache-Control', 'max-age=604800, immutable');
|
reply.header('Cache-Control', 'max-age=604800, immutable');
|
||||||
|
|
||||||
ctx.body = summary;
|
return summary;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.warn(`Failed to get preview of ${url}: ${err}`);
|
this.logger.warn(`Failed to get preview of ${url}: ${err}`);
|
||||||
ctx.status = 200;
|
reply.code(200);
|
||||||
ctx.set('Cache-Control', 'max-age=86400, immutable');
|
reply.header('Cache-Control', 'max-age=86400, immutable');
|
||||||
ctx.body = '{}';
|
return {};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,7 +10,7 @@ window.onload = async () => {
|
||||||
if (i) data.i = i;
|
if (i) data.i = i;
|
||||||
|
|
||||||
// Send request
|
// Send request
|
||||||
fetch(endpoint.indexOf('://') > -1 ? endpoint : `/api/${endpoint}`, {
|
window.fetch(endpoint.indexOf('://') > -1 ? endpoint : `/api/${endpoint}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
credentials: 'omit',
|
credentials: 'omit',
|
||||||
|
|
|
@ -42,7 +42,7 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await fetch(`/assets/locales/${lang}.${v}.json`);
|
const res = await window.fetch(`/assets/locales/${lang}.${v}.json`);
|
||||||
if (res.status === 200) {
|
if (res.status === 200) {
|
||||||
localStorage.setItem('lang', lang);
|
localStorage.setItem('lang', lang);
|
||||||
localStorage.setItem('locale', await res.text());
|
localStorage.setItem('locale', await res.text());
|
||||||
|
@ -290,9 +290,13 @@
|
||||||
// eslint-disable-next-line no-inner-declarations
|
// eslint-disable-next-line no-inner-declarations
|
||||||
async function checkUpdate() {
|
async function checkUpdate() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/meta', {
|
const res = await window.fetch('/api/meta', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
cache: 'no-cache'
|
cache: 'no-cache',
|
||||||
|
body: '{}',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const meta = await res.json();
|
const meta = await res.json();
|
||||||
|
|
|
@ -33,12 +33,15 @@ export async function signout() {
|
||||||
const registration = await navigator.serviceWorker.ready;
|
const registration = await navigator.serviceWorker.ready;
|
||||||
const push = await registration.pushManager.getSubscription();
|
const push = await registration.pushManager.getSubscription();
|
||||||
if (push) {
|
if (push) {
|
||||||
await fetch(`${apiUrl}/sw/unregister`, {
|
await window.fetch(`${apiUrl}/sw/unregister`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
i: $i.token,
|
i: $i.token,
|
||||||
endpoint: push.endpoint,
|
endpoint: push.endpoint,
|
||||||
}),
|
}),
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -80,32 +83,35 @@ export async function removeAccount(id: Account['id']) {
|
||||||
function fetchAccount(token: string): Promise<Account> {
|
function fetchAccount(token: string): Promise<Account> {
|
||||||
return new Promise((done, fail) => {
|
return new Promise((done, fail) => {
|
||||||
// Fetch user
|
// Fetch user
|
||||||
fetch(`${apiUrl}/i`, {
|
window.fetch(`${apiUrl}/i`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
i: token,
|
i: token,
|
||||||
}),
|
}),
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
})
|
})
|
||||||
.then(res => res.json())
|
.then(res => res.json())
|
||||||
.then(res => {
|
.then(res => {
|
||||||
if (res.error) {
|
if (res.error) {
|
||||||
if (res.error.id === 'a8c724b3-6e9c-4b46-b1a8-bc3ed6258370') {
|
if (res.error.id === 'a8c724b3-6e9c-4b46-b1a8-bc3ed6258370') {
|
||||||
showSuspendedDialog().then(() => {
|
showSuspendedDialog().then(() => {
|
||||||
signout();
|
signout();
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
alert({
|
||||||
|
type: 'error',
|
||||||
|
title: i18n.ts.failedToFetchAccountInformation,
|
||||||
|
text: JSON.stringify(res.error),
|
||||||
|
});
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
alert({
|
res.token = token;
|
||||||
type: 'error',
|
done(res);
|
||||||
title: i18n.ts.failedToFetchAccountInformation,
|
|
||||||
text: JSON.stringify(res.error),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} else {
|
})
|
||||||
res.token = token;
|
.catch(fail);
|
||||||
done(res);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(fail);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -66,7 +66,7 @@ const ok = async () => {
|
||||||
formData.append('folderId', defaultStore.state.uploadFolder);
|
formData.append('folderId', defaultStore.state.uploadFolder);
|
||||||
}
|
}
|
||||||
|
|
||||||
fetch(apiUrl + '/drive/files/create', {
|
window.fetch(apiUrl + '/drive/files/create', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData,
|
body: formData,
|
||||||
})
|
})
|
||||||
|
|
|
@ -68,7 +68,7 @@ let player = $ref({
|
||||||
let playerEnabled = $ref(false);
|
let playerEnabled = $ref(false);
|
||||||
let tweetId = $ref<string | null>(null);
|
let tweetId = $ref<string | null>(null);
|
||||||
let tweetExpanded = $ref(props.detail);
|
let tweetExpanded = $ref(props.detail);
|
||||||
const embedId = `embed${Math.random().toString().replace(/\D/,'')}`;
|
const embedId = `embed${Math.random().toString().replace(/\D/, '')}`;
|
||||||
let tweetHeight = $ref(150);
|
let tweetHeight = $ref(150);
|
||||||
|
|
||||||
const requestUrl = new URL(props.url);
|
const requestUrl = new URL(props.url);
|
||||||
|
@ -86,7 +86,7 @@ const requestLang = (lang || 'ja-JP').replace('ja-KS', 'ja-JP');
|
||||||
|
|
||||||
requestUrl.hash = '';
|
requestUrl.hash = '';
|
||||||
|
|
||||||
fetch(`/url?url=${encodeURIComponent(requestUrl.href)}&lang=${requestLang}`).then(res => {
|
window.fetch(`/url?url=${encodeURIComponent(requestUrl.href)}&lang=${requestLang}`).then(res => {
|
||||||
res.json().then(info => {
|
res.json().then(info => {
|
||||||
if (info.url == null) return;
|
if (info.url == null) return;
|
||||||
title = info.title;
|
title = info.title;
|
||||||
|
|
|
@ -39,7 +39,7 @@ const requestLang = (lang ?? 'ja-JP').replace('ja-KS', 'ja-JP');
|
||||||
|
|
||||||
const ytFetch = (): void => {
|
const ytFetch = (): void => {
|
||||||
fetching = true;
|
fetching = true;
|
||||||
fetch(`/url?url=${encodeURIComponent(requestUrl.href)}&lang=${requestLang}`).then(res => {
|
window.fetch(`/url?url=${encodeURIComponent(requestUrl.href)}&lang=${requestLang}`).then(res => {
|
||||||
res.json().then(info => {
|
res.json().then(info => {
|
||||||
if (info.url == null) return;
|
if (info.url == null) return;
|
||||||
title = info.title;
|
title = info.title;
|
||||||
|
|
|
@ -25,12 +25,12 @@ export default defineComponent({
|
||||||
props: {
|
props: {
|
||||||
block: {
|
block: {
|
||||||
type: Object as PropType<PostBlock>,
|
type: Object as PropType<PostBlock>,
|
||||||
required: true
|
required: true,
|
||||||
},
|
},
|
||||||
hpml: {
|
hpml: {
|
||||||
type: Object as PropType<Hpml>,
|
type: Object as PropType<Hpml>,
|
||||||
required: true
|
required: true,
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
@ -44,8 +44,8 @@ export default defineComponent({
|
||||||
handler() {
|
handler() {
|
||||||
this.text = this.hpml.interpolate(this.block.text);
|
this.text = this.hpml.interpolate(this.block.text);
|
||||||
},
|
},
|
||||||
deep: true
|
deep: true,
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
upload() {
|
upload() {
|
||||||
|
@ -59,14 +59,14 @@ export default defineComponent({
|
||||||
formData.append('folderId', this.$store.state.uploadFolder);
|
formData.append('folderId', this.$store.state.uploadFolder);
|
||||||
}
|
}
|
||||||
|
|
||||||
fetch(apiUrl + '/drive/files/create', {
|
window.fetch(apiUrl + '/drive/files/create', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData,
|
body: formData,
|
||||||
})
|
})
|
||||||
.then(response => response.json())
|
.then(response => response.json())
|
||||||
.then(f => {
|
.then(f => {
|
||||||
ok(f);
|
ok(f);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
os.promiseDialog(promise);
|
os.promiseDialog(promise);
|
||||||
|
@ -81,8 +81,8 @@ export default defineComponent({
|
||||||
}).then(() => {
|
}).then(() => {
|
||||||
this.posted = true;
|
this.posted = true;
|
||||||
});
|
});
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
@ -29,11 +29,14 @@ export const api = ((endpoint: string, data: Record<string, any> = {}, token?: s
|
||||||
if (token !== undefined) (data as any).i = token;
|
if (token !== undefined) (data as any).i = token;
|
||||||
|
|
||||||
// Send request
|
// Send request
|
||||||
fetch(endpoint.indexOf('://') > -1 ? endpoint : `${apiUrl}/${endpoint}`, {
|
window.fetch(endpoint.indexOf('://') > -1 ? endpoint : `${apiUrl}/${endpoint}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
credentials: 'omit',
|
credentials: 'omit',
|
||||||
cache: 'no-cache',
|
cache: 'no-cache',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
}).then(async (res) => {
|
}).then(async (res) => {
|
||||||
const body = res.status === 204 ? null : await res.json();
|
const body = res.status === 204 ? null : await res.json();
|
||||||
|
|
||||||
|
@ -63,7 +66,7 @@ export const apiGet = ((endpoint: string, data: Record<string, any> = {}) => {
|
||||||
|
|
||||||
const promise = new Promise((resolve, reject) => {
|
const promise = new Promise((resolve, reject) => {
|
||||||
// Send request
|
// Send request
|
||||||
fetch(`${apiUrl}/${endpoint}?${query}`, {
|
window.fetch(`${apiUrl}/${endpoint}?${query}`, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
credentials: 'omit',
|
credentials: 'omit',
|
||||||
cache: 'default',
|
cache: 'default',
|
||||||
|
|
|
@ -37,7 +37,7 @@ const fetching = ref(true);
|
||||||
let key = $ref(0);
|
let key = $ref(0);
|
||||||
|
|
||||||
const tick = () => {
|
const tick = () => {
|
||||||
fetch(`/api/fetch-rss?url=${props.url}`, {}).then(res => {
|
window.fetch(`/api/fetch-rss?url=${props.url}`, {}).then(res => {
|
||||||
res.json().then(feed => {
|
res.json().then(feed => {
|
||||||
if (props.shuffle) {
|
if (props.shuffle) {
|
||||||
shuffle(feed.items);
|
shuffle(feed.items);
|
||||||
|
|
|
@ -83,7 +83,7 @@ const fetching = ref(true);
|
||||||
let key = $ref(0);
|
let key = $ref(0);
|
||||||
|
|
||||||
const tick = () => {
|
const tick = () => {
|
||||||
fetch(`/api/fetch-rss?url=${widgetProps.url}`, {}).then(res => {
|
window.fetch(`/api/fetch-rss?url=${widgetProps.url}`, {}).then(res => {
|
||||||
res.json().then(feed => {
|
res.json().then(feed => {
|
||||||
if (widgetProps.shuffle) {
|
if (widgetProps.shuffle) {
|
||||||
shuffle(feed.items);
|
shuffle(feed.items);
|
||||||
|
|
|
@ -51,7 +51,7 @@ const items = ref([]);
|
||||||
const fetching = ref(true);
|
const fetching = ref(true);
|
||||||
|
|
||||||
const tick = () => {
|
const tick = () => {
|
||||||
fetch(`/api/fetch-rss?url=${widgetProps.url}`, {}).then(res => {
|
window.fetch(`/api/fetch-rss?url=${widgetProps.url}`, {}).then(res => {
|
||||||
res.json().then(feed => {
|
res.json().then(feed => {
|
||||||
items.value = feed.items;
|
items.value = feed.items;
|
||||||
fetching.value = false;
|
fetching.value = false;
|
||||||
|
|
Loading…
Reference in a new issue