Fixed 267U.pre2
This commit is contained in:
@@ -0,0 +1,546 @@
|
||||
/**
|
||||
* Web Client Server
|
||||
*/ import { dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { readFileSync } from "node:fs";
|
||||
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 { createBullBoard } from "@bull-board/api";
|
||||
import { BullAdapter } from "@bull-board/api/bullAdapter.js";
|
||||
import { KoaAdapter } from "@bull-board/koa";
|
||||
import { In, IsNull } from "typeorm";
|
||||
import { fetchMeta, metaToPugArgs } from "../../misc/fetch-meta.js";
|
||||
import config from "../../config/index.js";
|
||||
import { Users, Notes, UserProfiles, Pages, Channels, Clips, GalleryPosts } from "../../models/index.js";
|
||||
import * as Acct from "../../misc/acct.js";
|
||||
import { getNoteSummary } from "../../misc/get-note-summary.js";
|
||||
import { queues } from "../../queue/queues.js";
|
||||
import { genOpenapiSpec } from "../api/openapi/gen-spec.js";
|
||||
import { urlPreviewHandler } from "./url-preview.js";
|
||||
import { manifestHandler } from "./manifest.js";
|
||||
import packFeed from "./feed.js";
|
||||
import { MINUTE, DAY } from "../../const.js";
|
||||
const _filename = fileURLToPath(import.meta.url);
|
||||
const _dirname = dirname(_filename);
|
||||
const staticAssets = `${_dirname}/../../../assets/`;
|
||||
const clientAssets = `${_dirname}/../../../../client/assets/`;
|
||||
const assets = `${_dirname}/../../../../../built/_client_dist_/`;
|
||||
const swAssets = `${_dirname}/../../../../../built/_sw_dist_/`;
|
||||
// Init app
|
||||
const app = new Koa();
|
||||
//#region Bull Dashboard
|
||||
const bullBoardPath = "/queue";
|
||||
// Authenticate
|
||||
app.use(async (ctx, next)=>{
|
||||
const url = decodeURI(ctx.path);
|
||||
if (url === bullBoardPath || url.startsWith(`${bullBoardPath}/`)) {
|
||||
if (!url.startsWith(bullBoardPath + '/static/')) {
|
||||
ctx.set('Cache-Control', 'private, max-age=0, must-revalidate');
|
||||
}
|
||||
const token = ctx.cookies.get("token");
|
||||
if (token == null) {
|
||||
ctx.status = 401;
|
||||
return;
|
||||
}
|
||||
const user = await Users.findOneBy({
|
||||
token
|
||||
});
|
||||
if (user == null || !(user.isAdmin || user.isModerator)) {
|
||||
ctx.status = 403;
|
||||
return;
|
||||
}
|
||||
}
|
||||
await next();
|
||||
});
|
||||
const serverAdapter = new KoaAdapter();
|
||||
createBullBoard({
|
||||
queues: queues.map((q)=>new BullAdapter(q)),
|
||||
serverAdapter
|
||||
});
|
||||
serverAdapter.setBasePath(bullBoardPath);
|
||||
const bullBoardMiddleware = serverAdapter.registerPlugin();
|
||||
app.use(async (ctx, next)=>{
|
||||
const url = decodeURI(ctx.path);
|
||||
if (url === bullBoardPath || url.startsWith(`${bullBoardPath}/`)) {
|
||||
await bullBoardMiddleware(ctx, next);
|
||||
return;
|
||||
}
|
||||
await next();
|
||||
});
|
||||
//#endregion
|
||||
// Init renderer
|
||||
app.use(views(`${_dirname}/views`, {
|
||||
extension: "pug",
|
||||
options: {
|
||||
version: config.version,
|
||||
getClientEntry: ()=>process.env.NODE_ENV === "production" ? config.clientEntry : JSON.parse(readFileSync(`${_dirname}/../../../../../built/_client_dist_/manifest.json`, "utf-8"))["src/init.ts"],
|
||||
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
|
||||
const router = new Router();
|
||||
//#region static assets
|
||||
router.get("/static-assets/{*splat}", async (ctx)=>{
|
||||
await send(ctx, ctx.path.replace("/static-assets/", ""), {
|
||||
root: staticAssets,
|
||||
maxage: 7 * DAY
|
||||
});
|
||||
});
|
||||
router.get("/client-assets/{*splat}", async (ctx)=>{
|
||||
await send(ctx, ctx.path.replace("/client-assets/", ""), {
|
||||
root: clientAssets,
|
||||
maxage: 7 * DAY
|
||||
});
|
||||
});
|
||||
router.get("/assets/{*splat}", async (ctx)=>{
|
||||
await send(ctx, ctx.path.replace("/assets/", ""), {
|
||||
root: assets,
|
||||
maxage: 7 * DAY
|
||||
});
|
||||
});
|
||||
// Apple touch icon
|
||||
router.get("/apple-touch-icon.png", async (ctx)=>{
|
||||
await send(ctx, "/apple-touch-icon.png", {
|
||||
root: staticAssets
|
||||
});
|
||||
});
|
||||
router.get("/twemoji/{*splat}", async (ctx)=>{
|
||||
const path = ctx.path.replace("/twemoji/", "");
|
||||
if (!path.match(/^[0-9a-f-]+\.svg$/)) {
|
||||
ctx.status = 404;
|
||||
return;
|
||||
}
|
||||
ctx.set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'");
|
||||
await send(ctx, path, {
|
||||
root: `${_dirname}../../../../../../.yarn/unplugged/@discordapp-twemoji-npm-16.0.1-50d71ee905/node_modules/@discordapp/twemoji/dist/svg/`,
|
||||
maxage: 30 * DAY
|
||||
});
|
||||
});
|
||||
router.get("/twemoji-badge/{*splat}", async (ctx)=>{
|
||||
const path = ctx.path.replace("/twemoji-badge/", "");
|
||||
if (!path.match(/^[0-9a-f-]+\.png$/)) {
|
||||
ctx.status = 404;
|
||||
return;
|
||||
}
|
||||
const mask = await sharp(`${_dirname}../../../../../../.yarn/unplugged/@discordapp-twemoji-npm-16.0.1-50d71ee905/node_modules/@discordapp/twemoji/dist/svg/${path.replace(".png", "")}.svg`, {
|
||||
density: 1000
|
||||
}).resize(488, 488).greyscale().normalise().linear(1.75, -128 * 1.75 + 128) // 1.75x contrast
|
||||
.flatten({
|
||||
background: "#000"
|
||||
}).extend({
|
||||
top: 12,
|
||||
bottom: 12,
|
||||
left: 12,
|
||||
right: 12,
|
||||
background: "#000"
|
||||
}).toColorspace("b-w").png().toBuffer();
|
||||
const buffer = await sharp({
|
||||
create: {
|
||||
width: 512,
|
||||
height: 512,
|
||||
channels: 4,
|
||||
background: {
|
||||
r: 0,
|
||||
g: 0,
|
||||
b: 0,
|
||||
alpha: 0
|
||||
}
|
||||
}
|
||||
}).pipelineColorspace("b-w").boolean(mask, "eor").resize(96, 96).png().toBuffer();
|
||||
ctx.set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'");
|
||||
ctx.set("Cache-Control", "max-age=2592000");
|
||||
ctx.set("Content-Type", "image/png");
|
||||
ctx.body = buffer;
|
||||
});
|
||||
// ServiceWorker
|
||||
router.get("/sw.js", async (ctx)=>{
|
||||
await send(ctx, "/sw.js", {
|
||||
root: swAssets,
|
||||
maxage: 10 * MINUTE
|
||||
});
|
||||
});
|
||||
router.get("/sw.js.map", async (ctx)=>{
|
||||
await send(ctx, "/sw.js.map", {
|
||||
root: swAssets,
|
||||
maxage: 10 * MINUTE
|
||||
});
|
||||
});
|
||||
// Manifest
|
||||
router.get("/manifest.json", manifestHandler);
|
||||
router.get("/robots.txt", async (ctx)=>{
|
||||
await send(ctx, "/robots.txt", {
|
||||
root: staticAssets
|
||||
});
|
||||
});
|
||||
//#endregion
|
||||
// Docs
|
||||
router.get("/api-doc", async (ctx)=>{
|
||||
await send(ctx, "/redoc.html", {
|
||||
root: staticAssets
|
||||
});
|
||||
});
|
||||
// URL preview endpoint
|
||||
router.get("/url", urlPreviewHandler);
|
||||
router.get("/api.json", async (ctx)=>{
|
||||
ctx.body = genOpenapiSpec();
|
||||
});
|
||||
const getFeed = async (acct, threadDepth, historyCount, noteInTitle, noRenotes, noReplies)=>{
|
||||
const meta = await fetchMeta();
|
||||
if (meta.privateMode) {
|
||||
return;
|
||||
}
|
||||
const { username, host } = Acct.parse(acct);
|
||||
const user = await Users.findOneBy({
|
||||
usernameLower: username.toLowerCase(),
|
||||
host: host ?? IsNull(),
|
||||
isSuspended: false,
|
||||
isLocked: false
|
||||
});
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
let thread = parseInt(threadDepth, 10);
|
||||
if (isNaN(thread) || thread < 0 || thread > 30) {
|
||||
thread = 3;
|
||||
}
|
||||
let history = parseInt(historyCount, 10);
|
||||
//cant be 0 here or it will get all posts
|
||||
if (isNaN(history) || history <= 0 || history > 30) {
|
||||
history = 20;
|
||||
}
|
||||
return user && await packFeed(user, thread, history, !isNaN(noteInTitle), isNaN(noRenotes), isNaN(noReplies));
|
||||
};
|
||||
// As the /@user[.json|.rss|.atom]/sub endpoint is complicated, we will use a regex to switch between them.
|
||||
const reUser = new RegExp("^/@(?<user>[^/]+?)(?:.(?<feed>json|rss|atom)(?:\\?[^/]*)?)?(?:/(?<sub>[^/]+))?$");
|
||||
router.get(reUser, async (ctx, next)=>{
|
||||
const groups = reUser.exec(ctx.originalUrl)?.groups;
|
||||
if (!groups) {
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
ctx.params = groups;
|
||||
//console.log(ctx, ctx.params, ctx.query);
|
||||
if (groups.feed) {
|
||||
if (groups.sub) {
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
switch(groups.feed){
|
||||
case "json":
|
||||
await jsonFeed(ctx, next);
|
||||
break;
|
||||
case "rss":
|
||||
await rssFeed(ctx, next);
|
||||
break;
|
||||
case "atom":
|
||||
await atomFeed(ctx, next);
|
||||
break;
|
||||
}
|
||||
return;
|
||||
}
|
||||
await userPage(ctx, next);
|
||||
});
|
||||
// Atom
|
||||
const atomFeed = async (ctx)=>{
|
||||
const feed = await getFeed(ctx.params.user, ctx.query.thread, ctx.query.history, ctx.query.noteintitle, ctx.query.norenotes, ctx.query.noreplies);
|
||||
if (feed) {
|
||||
ctx.set("Content-Type", "application/atom+xml; charset=utf-8");
|
||||
ctx.body = feed.atom1();
|
||||
} else {
|
||||
ctx.status = 404;
|
||||
}
|
||||
};
|
||||
// RSS
|
||||
const rssFeed = async (ctx)=>{
|
||||
const feed = await getFeed(ctx.params.user, ctx.query.thread, ctx.query.history, ctx.query.noteintitle, ctx.query.norenotes, ctx.query.noreplies);
|
||||
if (feed) {
|
||||
ctx.set("Content-Type", "application/rss+xml; charset=utf-8");
|
||||
ctx.body = feed.rss2();
|
||||
} else {
|
||||
ctx.status = 404;
|
||||
}
|
||||
};
|
||||
// JSON
|
||||
const jsonFeed = async (ctx)=>{
|
||||
const feed = await getFeed(ctx.params.user, ctx.query.thread, ctx.query.history, ctx.query.noteintitle, ctx.query.norenotes, ctx.query.noreplies);
|
||||
if (feed) {
|
||||
ctx.set("Content-Type", "application/json; charset=utf-8");
|
||||
ctx.body = feed.json1();
|
||||
} else {
|
||||
ctx.status = 404;
|
||||
}
|
||||
};
|
||||
//#region SSR (for crawlers)
|
||||
// User
|
||||
const userPage = async (ctx, next)=>{
|
||||
const userParam = ctx.params.user;
|
||||
const subParam = ctx.params.sub;
|
||||
const { username, host } = Acct.parse(userParam);
|
||||
const user = await Users.findOneBy({
|
||||
usernameLower: username.toLowerCase(),
|
||||
host: host ?? IsNull(),
|
||||
isSuspended: false
|
||||
});
|
||||
if (user === null) {
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
const profile = await UserProfiles.findOneByOrFail({
|
||||
userId: user.id
|
||||
});
|
||||
const meta = await fetchMeta();
|
||||
const me = profile.fields ? profile.fields.filter((filed)=>filed.value?.match(/^https?:/)).map((field)=>field.value) : [];
|
||||
const userDetail = {
|
||||
...metaToPugArgs(meta),
|
||||
user,
|
||||
profile,
|
||||
me,
|
||||
avatarUrl: await Users.getAvatarUrl(user),
|
||||
sub: subParam
|
||||
};
|
||||
await ctx.render("user", userDetail);
|
||||
ctx.set("Cache-Control", "public, max-age=15");
|
||||
};
|
||||
router.get("/users/:user", async (ctx)=>{
|
||||
const user = await Users.findOneBy({
|
||||
id: ctx.params.user,
|
||||
host: IsNull(),
|
||||
isSuspended: false
|
||||
});
|
||||
if (user == null) {
|
||||
ctx.status = 404;
|
||||
return;
|
||||
}
|
||||
ctx.redirect(`/@${user.username}${user.host == null ? "" : `@${user.host}`}`);
|
||||
});
|
||||
// Note
|
||||
router.get("/notes/:note", async (ctx, next)=>{
|
||||
const note = await Notes.findOneBy({
|
||||
id: ctx.params.note,
|
||||
visibility: In([
|
||||
"public",
|
||||
"home"
|
||||
])
|
||||
});
|
||||
try {
|
||||
if (note) {
|
||||
await Notes.increment({
|
||||
id: note.id
|
||||
}, "viewCount", 1);
|
||||
note.viewCount += 1;
|
||||
const _note = await Notes.pack(note);
|
||||
const profile = await UserProfiles.findOneByOrFail({
|
||||
userId: note.userId
|
||||
});
|
||||
const meta = await fetchMeta();
|
||||
await ctx.render("note", {
|
||||
...metaToPugArgs(meta),
|
||||
note: _note,
|
||||
profile,
|
||||
avatarUrl: await Users.getAvatarUrl(await Users.findOneByOrFail({
|
||||
id: note.userId
|
||||
})),
|
||||
// TODO: Let locale changeable by instance setting
|
||||
summary: getNoteSummary(_note)
|
||||
});
|
||||
ctx.set("Cache-Control", "public, max-age=15");
|
||||
ctx.set("Content-Security-Policy", "default-src 'self' 'unsafe-inline'; img-src *; media-src *; frame-ancestors *");
|
||||
return;
|
||||
}
|
||||
} catch {}
|
||||
await next();
|
||||
});
|
||||
router.get("/posts/:note", async (ctx, next)=>{
|
||||
const note = await Notes.findOneBy({
|
||||
id: ctx.params.note,
|
||||
visibility: In([
|
||||
"public",
|
||||
"home"
|
||||
])
|
||||
});
|
||||
if (note) {
|
||||
await Notes.increment({
|
||||
id: note.id
|
||||
}, "viewCount", 1);
|
||||
note.viewCount += 1;
|
||||
const _note = await Notes.pack(note);
|
||||
const profile = await UserProfiles.findOneByOrFail({
|
||||
userId: note.userId
|
||||
});
|
||||
const meta = await fetchMeta();
|
||||
await ctx.render("note", {
|
||||
...metaToPugArgs(meta),
|
||||
note: _note,
|
||||
profile,
|
||||
avatarUrl: await Users.getAvatarUrl(await Users.findOneByOrFail({
|
||||
id: note.userId
|
||||
})),
|
||||
// TODO: Let locale changeable by instance setting
|
||||
summary: getNoteSummary(_note)
|
||||
});
|
||||
ctx.set("Cache-Control", "public, max-age=15");
|
||||
return;
|
||||
}
|
||||
await next();
|
||||
});
|
||||
// Page
|
||||
router.get("/@:user/pages/:page", async (ctx, next)=>{
|
||||
const { username, host } = Acct.parse(ctx.params.user);
|
||||
const user = await Users.findOneBy({
|
||||
usernameLower: username.toLowerCase(),
|
||||
host: host ?? IsNull()
|
||||
});
|
||||
if (user == null) return;
|
||||
const page = await Pages.findOneBy({
|
||||
name: ctx.params.page,
|
||||
userId: user.id
|
||||
});
|
||||
if (page) {
|
||||
const _page = await Pages.pack(page);
|
||||
const profile = await UserProfiles.findOneByOrFail({
|
||||
userId: page.userId
|
||||
});
|
||||
const meta = await fetchMeta();
|
||||
await ctx.render("page", {
|
||||
...metaToPugArgs(meta),
|
||||
page: _page,
|
||||
profile,
|
||||
avatarUrl: await Users.getAvatarUrl(await Users.findOneByOrFail({
|
||||
id: page.userId
|
||||
}))
|
||||
});
|
||||
if ([
|
||||
"public"
|
||||
].includes(page.visibility)) {
|
||||
ctx.set("Cache-Control", "public, max-age=15");
|
||||
} else {
|
||||
ctx.set("Cache-Control", "private, max-age=0, must-revalidate");
|
||||
}
|
||||
return;
|
||||
}
|
||||
await next();
|
||||
});
|
||||
// Clip
|
||||
// TODO: handling of private clips
|
||||
router.get("/clips/:clip", async (ctx, next)=>{
|
||||
const clip = await Clips.findOneBy({
|
||||
id: ctx.params.clip
|
||||
});
|
||||
if (clip) {
|
||||
const _clip = await Clips.pack(clip);
|
||||
const profile = await UserProfiles.findOneByOrFail({
|
||||
userId: clip.userId
|
||||
});
|
||||
const meta = await fetchMeta();
|
||||
await ctx.render("clip", {
|
||||
...metaToPugArgs(meta),
|
||||
clip: _clip,
|
||||
profile,
|
||||
avatarUrl: await Users.getAvatarUrl(await Users.findOneByOrFail({
|
||||
id: clip.userId
|
||||
}))
|
||||
});
|
||||
ctx.set("Cache-Control", "public, max-age=15");
|
||||
return;
|
||||
}
|
||||
await next();
|
||||
});
|
||||
// Gallery post
|
||||
router.get("/gallery/:post", async (ctx, next)=>{
|
||||
const post = await GalleryPosts.findOneBy({
|
||||
id: ctx.params.post
|
||||
});
|
||||
if (post) {
|
||||
const _post = await GalleryPosts.pack(post);
|
||||
const profile = await UserProfiles.findOneByOrFail({
|
||||
userId: post.userId
|
||||
});
|
||||
const meta = await fetchMeta();
|
||||
await ctx.render("gallery-post", {
|
||||
...metaToPugArgs(meta),
|
||||
post: _post,
|
||||
profile,
|
||||
avatarUrl: await Users.getAvatarUrl(await Users.findOneByOrFail({
|
||||
id: post.userId
|
||||
}))
|
||||
});
|
||||
ctx.set("Cache-Control", "public, max-age=15");
|
||||
return;
|
||||
}
|
||||
await next();
|
||||
});
|
||||
// Channel
|
||||
router.get("/channels/:channel", async (ctx, next)=>{
|
||||
const channel = await Channels.findOneBy({
|
||||
id: ctx.params.channel
|
||||
});
|
||||
if (channel) {
|
||||
const _channel = await Channels.pack(channel);
|
||||
const meta = await fetchMeta();
|
||||
await ctx.render("channel", {
|
||||
...metaToPugArgs(meta),
|
||||
channel: _channel
|
||||
});
|
||||
ctx.set("Cache-Control", "public, max-age=15");
|
||||
return;
|
||||
}
|
||||
await next();
|
||||
});
|
||||
//#endregion
|
||||
router.get("/bios", async (ctx)=>{
|
||||
await ctx.render("bios", {
|
||||
version: config.version
|
||||
});
|
||||
});
|
||||
router.get("/cli", async (ctx)=>{
|
||||
await ctx.render("cli", {
|
||||
version: config.version
|
||||
});
|
||||
});
|
||||
const override = (source, target, depth = 0)=>[
|
||||
undefined,
|
||||
...target.split("/").filter((x)=>x),
|
||||
...source.split("/").filter((x)=>x).splice(depth)
|
||||
].join("/");
|
||||
router.get("/flush", async (ctx)=>{
|
||||
await ctx.render("flush");
|
||||
});
|
||||
// If a non-WebSocket request comes in to streaming and base html is returned with cache, the path will be cached by Proxy, etc. and it will be wrong.
|
||||
router.get("/streaming", async (ctx)=>{
|
||||
ctx.status = 503;
|
||||
ctx.set("Cache-Control", "private, max-age=0");
|
||||
});
|
||||
router.get("/api/v1/streaming", async (ctx)=>{
|
||||
ctx.status = 503;
|
||||
ctx.set("Cache-Control", "private, max-age=0");
|
||||
});
|
||||
// Render base html for all requests
|
||||
router.get("/", async (ctx)=>{
|
||||
const meta = await fetchMeta();
|
||||
await ctx.render("base", {
|
||||
...metaToPugArgs(meta)
|
||||
});
|
||||
ctx.set("Cache-Control", "public, max-age=3");
|
||||
});
|
||||
router.get("{*splat}", async (ctx)=>{
|
||||
const meta = await fetchMeta();
|
||||
await ctx.render("base", {
|
||||
...metaToPugArgs(meta)
|
||||
});
|
||||
ctx.set("Cache-Control", "public, max-age=3");
|
||||
});
|
||||
// Register router
|
||||
app.use(router.routes());
|
||||
export default app;
|
||||
Reference in New Issue
Block a user