/** * 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("^/@(?[^/]+?)(?:.(?json|rss|atom)(?:\\?[^/]*)?)?(?:/(?[^/]+))?$"); 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;