From 88780ba9abf07bce52bd83851b1d6c9b63501f45 Mon Sep 17 00:00:00 2001 From: Alec Armbruster <alectrocute@gmail.com> Date: Fri, 16 Jun 2023 10:34:36 -0400 Subject: [PATCH] refactor server, tidy up, use handlers/middleware/utils pattern --- src/server/handlers/catch-all-handler.tsx | 115 +++++ src/server/handlers/robots-handler.tsx | 18 + .../handlers/service-worker-handler.tsx | 14 + src/server/handlers/theme-handler.tsx | 32 ++ src/server/handlers/themes-list-handler.tsx | 7 + src/server/index.tsx | 455 +----------------- src/server/middleware/set-default-csp.ts | 9 + src/server/utils/build-themes-list.ts | 17 + src/server/utils/create-ssr-html.tsx | 108 +++++ src/server/utils/fetch-icon-png.ts | 5 + src/server/utils/generate-manifest-base64.ts | 107 ++++ src/server/utils/get-error-page-data.ts | 19 + src/server/utils/set-forwarded-headers.ts | 20 + 13 files changed, 485 insertions(+), 441 deletions(-) create mode 100644 src/server/handlers/catch-all-handler.tsx create mode 100644 src/server/handlers/robots-handler.tsx create mode 100644 src/server/handlers/service-worker-handler.tsx create mode 100644 src/server/handlers/theme-handler.tsx create mode 100644 src/server/handlers/themes-list-handler.tsx create mode 100644 src/server/middleware/set-default-csp.ts create mode 100644 src/server/utils/build-themes-list.ts create mode 100644 src/server/utils/create-ssr-html.tsx create mode 100644 src/server/utils/fetch-icon-png.ts create mode 100644 src/server/utils/generate-manifest-base64.ts create mode 100644 src/server/utils/get-error-page-data.ts create mode 100644 src/server/utils/set-forwarded-headers.ts diff --git a/src/server/handlers/catch-all-handler.tsx b/src/server/handlers/catch-all-handler.tsx new file mode 100644 index 0000000..bbcb689 --- /dev/null +++ b/src/server/handlers/catch-all-handler.tsx @@ -0,0 +1,115 @@ +import type { Request, Response } from "express"; +import { StaticRouter, matchPath } from "inferno-router"; +import { renderToString } from "inferno-server"; +import IsomorphicCookie from "isomorphic-cookie"; +import { GetSite, GetSiteResponse, LemmyHttp } from "lemmy-js-client"; +import { App } from "../../shared/components/app/app"; +import { getHttpBaseInternal } from "../../shared/env"; +import { + InitialFetchRequest, + IsoDataOptionalSite, +} from "../../shared/interfaces"; +import { routes } from "../../shared/routes"; +import { RequestState, wrapClient } from "../../shared/services/HttpService"; +import { ErrorPageData, initializeSite, isAuthPath } from "../../shared/utils"; +import { createSsrHtml } from "../utils/create-ssr-html"; +import { getErrorPageData } from "../utils/get-error-page-data"; +import { setForwardedHeaders } from "../utils/set-forwarded-headers"; + +export default async (req: Request, res: Response) => { + try { + const activeRoute = routes.find(route => matchPath(req.path, route)); + let auth: string | undefined = IsomorphicCookie.load("jwt", req); + + const getSiteForm: GetSite = { auth }; + + const headers = setForwardedHeaders(req.headers); + const client = wrapClient(new LemmyHttp(getHttpBaseInternal(), headers)); + + const { path, url, query } = req; + + // Get site data first + // This bypasses errors, so that the client can hit the error on its own, + // in order to remove the jwt on the browser. Necessary for wrong jwts + let site: GetSiteResponse | undefined = undefined; + const routeData: RequestState<any>[] = []; + let errorPageData: ErrorPageData | undefined = undefined; + let try_site = await client.getSite(getSiteForm); + if (try_site.state === "failed" && try_site.msg == "not_logged_in") { + console.error( + "Incorrect JWT token, skipping auth so frontend can remove jwt cookie" + ); + getSiteForm.auth = undefined; + auth = undefined; + try_site = await client.getSite(getSiteForm); + } + + if (!auth && isAuthPath(path)) { + return res.redirect("/login"); + } + + if (try_site.state === "success") { + site = try_site.data; + initializeSite(site); + + if (path !== "/setup" && !site.site_view.local_site.site_setup) { + return res.redirect("/setup"); + } + + if (site) { + const initialFetchReq: InitialFetchRequest = { + client, + auth, + path, + query, + site, + }; + + if (activeRoute?.fetchInitialData) { + routeData.push( + ...(await Promise.all([ + ...activeRoute.fetchInitialData(initialFetchReq), + ])) + ); + } + } + } else if (try_site.state === "failed") { + errorPageData = getErrorPageData(new Error(try_site.msg), site); + } + + // Redirect to the 404 if there's an API error + if (routeData[0] && routeData[0].state === "failed") { + const error = routeData[0].msg; + console.error(error); + if (error === "instance_is_private") { + return res.redirect(`/signup`); + } else { + errorPageData = getErrorPageData(new Error(error), site); + } + } + + const isoData: IsoDataOptionalSite = { + path, + site_res: site, + routeData, + errorPageData, + }; + + const wrapper = ( + <StaticRouter location={url} context={isoData}> + <App /> + </StaticRouter> + ); + + const root = renderToString(wrapper); + + res.send(await createSsrHtml(root, isoData)); + } catch (err) { + // If an error is caught here, the error page couldn't even be rendered + console.error(err); + res.statusCode = 500; + return res.send( + process.env.NODE_ENV === "development" ? err.message : "Server error" + ); + } +}; diff --git a/src/server/handlers/robots-handler.tsx b/src/server/handlers/robots-handler.tsx new file mode 100644 index 0000000..7271095 --- /dev/null +++ b/src/server/handlers/robots-handler.tsx @@ -0,0 +1,18 @@ +import type { Response } from "express"; + +export default async ({ res }: { res: Response }) => { + res.setHeader("content-type", "text/plain; charset=utf-8"); + + res.send(`User-Agent: * + Disallow: /login + Disallow: /settings + Disallow: /create_community + Disallow: /create_post + Disallow: /create_private_message + Disallow: /inbox + Disallow: /setup + Disallow: /admin + Disallow: /password_change + Disallow: /search/ + `); +}; diff --git a/src/server/handlers/service-worker-handler.tsx b/src/server/handlers/service-worker-handler.tsx new file mode 100644 index 0000000..886aa09 --- /dev/null +++ b/src/server/handlers/service-worker-handler.tsx @@ -0,0 +1,14 @@ +import type { Response } from "express"; +import path from "path"; + +export default async ({ res }: { res: Response }) => { + res.setHeader("Content-Type", "application/javascript"); + + res.sendFile( + path.resolve( + `./dist/service-worker${ + process.env.NODE_ENV === "development" ? "-development" : "" + }.js` + ) + ); +}; diff --git a/src/server/handlers/theme-handler.tsx b/src/server/handlers/theme-handler.tsx new file mode 100644 index 0000000..9f1046d --- /dev/null +++ b/src/server/handlers/theme-handler.tsx @@ -0,0 +1,32 @@ +import type { Request, Response } from "express"; +import { existsSync } from "fs"; +import path from "path"; + +const extraThemesFolder = + process.env["LEMMY_UI_EXTRA_THEMES_FOLDER"] || "./extra_themes"; + +export default async (req: Request, res: Response) => { + res.contentType("text/css"); + + const theme = req.params.name; + + if (!theme.endsWith(".css")) { + res.statusCode = 400; + res.send("Theme must be a css file"); + } + + const customTheme = path.resolve(`./${extraThemesFolder}/${theme}`); + + if (existsSync(customTheme)) { + res.sendFile(customTheme); + } else { + const internalTheme = path.resolve(`./dist/assets/css/themes/${theme}`); + + // If the theme doesn't exist, just send litely + if (existsSync(internalTheme)) { + res.sendFile(internalTheme); + } else { + res.sendFile(path.resolve("./dist/assets/css/themes/litely.css")); + } + } +}; diff --git a/src/server/handlers/themes-list-handler.tsx b/src/server/handlers/themes-list-handler.tsx new file mode 100644 index 0000000..1a54f92 --- /dev/null +++ b/src/server/handlers/themes-list-handler.tsx @@ -0,0 +1,7 @@ +import type { Response } from "express"; +import { buildThemeList } from "../utils/build-themes-list"; + +export default async ({ res }: { res: Response }) => { + res.type("json"); + res.send(JSON.stringify(await buildThemeList())); +}; diff --git a/src/server/index.tsx b/src/server/index.tsx index 3a12ad7..f109fc1 100644 --- a/src/server/index.tsx +++ b/src/server/index.tsx @@ -1,465 +1,38 @@ import express from "express"; -import { existsSync } from "fs"; -import { readdir, readFile } from "fs/promises"; -import { IncomingHttpHeaders } from "http"; -import { Helmet } from "inferno-helmet"; -import { matchPath, StaticRouter } from "inferno-router"; -import { renderToString } from "inferno-server"; -import IsomorphicCookie from "isomorphic-cookie"; -import { GetSite, GetSiteResponse, LemmyHttp } from "lemmy-js-client"; import path from "path"; import process from "process"; -import serialize from "serialize-javascript"; -import sharp from "sharp"; -import { App } from "../shared/components/app/app"; -import { getHttpBaseExternal, getHttpBaseInternal } from "../shared/env"; -import { - ILemmyConfig, - InitialFetchRequest, - IsoDataOptionalSite, -} from "../shared/interfaces"; -import { routes } from "../shared/routes"; -import { RequestState, wrapClient } from "../shared/services/HttpService"; -import { - ErrorPageData, - favIconPngUrl, - favIconUrl, - initializeSite, - isAuthPath, -} from "../shared/utils"; +import CatchAllHandler from "./handlers/catch-all-handler"; +import RobotsHandler from "./handlers/robots-handler"; +import ServiceWorkerHandler from "./handlers/service-worker-handler"; +import ThemeHandler from "./handlers/theme-handler"; +import ThemesListHandler from "./handlers/themes-list-handler"; +import setDefaultCsp from "./middleware/set-default-csp"; const server = express(); + const [hostname, port] = process.env["LEMMY_UI_HOST"] ? process.env["LEMMY_UI_HOST"].split(":") : ["0.0.0.0", "1234"]; -const extraThemesFolder = - process.env["LEMMY_UI_EXTRA_THEMES_FOLDER"] || "./extra_themes"; - -if (!process.env["LEMMY_UI_DISABLE_CSP"] && !process.env["LEMMY_UI_DEBUG"]) { - server.use(function (_req, res, next) { - res.setHeader( - "Content-Security-Policy", - `default-src 'self'; manifest-src *; connect-src *; img-src * data:; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; form-action 'self'; base-uri 'self'; frame-src *; media-src *` - ); - next(); - }); -} -const customHtmlHeader = process.env["LEMMY_UI_CUSTOM_HTML_HEADER"] || ""; server.use(express.json()); server.use(express.urlencoded({ extended: false })); server.use("/static", express.static(path.resolve("./dist"))); -const robotstxt = `User-Agent: * -Disallow: /login -Disallow: /settings -Disallow: /create_community -Disallow: /create_post -Disallow: /create_private_message -Disallow: /inbox -Disallow: /setup -Disallow: /admin -Disallow: /password_change -Disallow: /search/ -`; - -server.get("/service-worker.js", async (_req, res) => { - res.setHeader("Content-Type", "application/javascript"); - res.sendFile( - path.resolve( - `./dist/service-worker${ - process.env.NODE_ENV === "development" ? "-development" : "" - }.js` - ) - ); -}); - -server.get("/robots.txt", async (_req, res) => { - res.setHeader("content-type", "text/plain; charset=utf-8"); - res.send(robotstxt); -}); - -server.get("/css/themes/:name", async (req, res) => { - res.contentType("text/css"); - const theme = req.params.name; - if (!theme.endsWith(".css")) { - res.statusCode = 400; - res.send("Theme must be a css file"); - } - - const customTheme = path.resolve(`./${extraThemesFolder}/${theme}`); - if (existsSync(customTheme)) { - res.sendFile(customTheme); - } else { - const internalTheme = path.resolve(`./dist/assets/css/themes/${theme}`); - - // If the theme doesn't exist, just send litely - if (existsSync(internalTheme)) { - res.sendFile(internalTheme); - } else { - res.sendFile(path.resolve("./dist/assets/css/themes/litely.css")); - } - } -}); - -async function buildThemeList(): Promise<string[]> { - const themes = ["darkly", "darkly-red", "litely", "litely-red"]; - if (existsSync(extraThemesFolder)) { - const dirThemes = await readdir(extraThemesFolder); - const cssThemes = dirThemes - .filter(d => d.endsWith(".css")) - .map(d => d.replace(".css", "")); - themes.push(...cssThemes); - } - return themes; +if (!process.env["LEMMY_UI_DISABLE_CSP"] && !process.env["LEMMY_UI_DEBUG"]) { + server.use(setDefaultCsp); } -server.get("/css/themelist", async (_req, res) => { - res.type("json"); - res.send(JSON.stringify(await buildThemeList())); -}); - -// server.use(cookieParser()); -server.get("/*", async (req, res) => { - try { - const activeRoute = routes.find(route => matchPath(req.path, route)); - let auth: string | undefined = IsomorphicCookie.load("jwt", req); - - const getSiteForm: GetSite = { auth }; - - const headers = setForwardedHeaders(req.headers); - const client = wrapClient(new LemmyHttp(getHttpBaseInternal(), headers)); - - const { path, url, query } = req; - - // Get site data first - // This bypasses errors, so that the client can hit the error on its own, - // in order to remove the jwt on the browser. Necessary for wrong jwts - let site: GetSiteResponse | undefined = undefined; - const routeData: RequestState<any>[] = []; - let errorPageData: ErrorPageData | undefined = undefined; - let try_site = await client.getSite(getSiteForm); - if (try_site.state === "failed" && try_site.msg == "not_logged_in") { - console.error( - "Incorrect JWT token, skipping auth so frontend can remove jwt cookie" - ); - getSiteForm.auth = undefined; - auth = undefined; - try_site = await client.getSite(getSiteForm); - } - - if (!auth && isAuthPath(path)) { - return res.redirect("/login"); - } - - if (try_site.state === "success") { - site = try_site.data; - initializeSite(site); - - if (path !== "/setup" && !site.site_view.local_site.site_setup) { - return res.redirect("/setup"); - } - - if (site) { - const initialFetchReq: InitialFetchRequest = { - client, - auth, - path, - query, - site, - }; - - if (activeRoute?.fetchInitialData) { - routeData.push( - ...(await Promise.all([ - ...activeRoute.fetchInitialData(initialFetchReq), - ])) - ); - } - } - } else if (try_site.state === "failed") { - errorPageData = getErrorPageData(new Error(try_site.msg), site); - } - - // Redirect to the 404 if there's an API error - if (routeData[0] && routeData[0].state === "failed") { - const error = routeData[0].msg; - console.error(error); - if (error === "instance_is_private") { - return res.redirect(`/signup`); - } else { - errorPageData = getErrorPageData(new Error(error), site); - } - } - - const isoData: IsoDataOptionalSite = { - path, - site_res: site, - routeData, - errorPageData, - }; - - const wrapper = ( - <StaticRouter location={url} context={isoData}> - <App /> - </StaticRouter> - ); - - const root = renderToString(wrapper); - - res.send(await createSsrHtml(root, isoData)); - } catch (err) { - // If an error is caught here, the error page couldn't even be rendered - console.error(err); - res.statusCode = 500; - return res.send( - process.env.NODE_ENV === "development" ? err.message : "Server error" - ); - } -}); +server.get("/robots.txt", RobotsHandler); +server.get("/service-worker.js", ServiceWorkerHandler); +server.get("/css/themes/:name", ThemeHandler); +server.get("/css/themelist", ThemesListHandler); +server.get("/*", CatchAllHandler); server.listen(Number(port), hostname, () => { console.log(`http://${hostname}:${port}`); }); -function setForwardedHeaders(headers: IncomingHttpHeaders): { - [key: string]: string; -} { - const out: { [key: string]: string } = {}; - if (headers.host) { - out.host = headers.host; - } - const realIp = headers["x-real-ip"]; - if (realIp) { - out["x-real-ip"] = realIp as string; - } - const forwardedFor = headers["x-forwarded-for"]; - if (forwardedFor) { - out["x-forwarded-for"] = forwardedFor as string; - } - - return out; -} - process.on("SIGINT", () => { console.info("Interrupted"); process.exit(0); }); - -const iconSizes = [72, 96, 144, 192, 512]; -const defaultLogoPathDirectory = path.join( - process.cwd(), - "dist", - "assets", - "icons" -); - -export async function generateManifestBase64({ - my_user, - site_view: { - site, - local_site: { community_creation_admin_only }, - }, -}: GetSiteResponse) { - const url = getHttpBaseExternal(); - - const icon = site.icon ? await fetchIconPng(site.icon) : null; - - const manifest = { - name: site.name, - description: site.description ?? "A link aggregator for the fediverse", - start_url: url, - scope: url, - display: "standalone", - id: "/", - background_color: "#222222", - theme_color: "#222222", - icons: await Promise.all( - iconSizes.map(async size => { - let src = await readFile( - path.join(defaultLogoPathDirectory, `icon-${size}x${size}.png`) - ).then(buf => buf.toString("base64")); - - if (icon) { - src = await sharp(icon) - .resize(size, size) - .png() - .toBuffer() - .then(buf => buf.toString("base64")); - } - - return { - sizes: `${size}x${size}`, - type: "image/png", - src: `data:image/png;base64,${src}`, - purpose: "any maskable", - }; - }) - ), - shortcuts: [ - { - name: "Search", - short_name: "Search", - description: "Perform a search.", - url: "/search", - }, - { - name: "Communities", - url: "/communities", - short_name: "Communities", - description: "Browse communities", - }, - ] - .concat( - my_user - ? [ - { - name: "Create Post", - url: "/create_post", - short_name: "Create Post", - description: "Create a post.", - }, - ] - : [] - ) - .concat( - my_user?.local_user_view.person.admin || !community_creation_admin_only - ? [ - { - name: "Create Community", - url: "/create_community", - short_name: "Create Community", - description: "Create a community", - }, - ] - : [] - ), - related_applications: [ - { - platform: "f-droid", - url: "https://f-droid.org/packages/com.jerboa/", - id: "com.jerboa", - }, - ], - }; - - return Buffer.from(JSON.stringify(manifest)).toString("base64"); -} - -async function fetchIconPng(iconUrl: string) { - return await fetch(iconUrl) - .then(res => res.blob()) - .then(blob => blob.arrayBuffer()); -} - -function getErrorPageData(error: Error, site?: GetSiteResponse) { - const errorPageData: ErrorPageData = {}; - - if (site) { - errorPageData.error = error.message; - } - - const adminMatrixIds = site?.admins - .map(({ person: { matrix_user_id } }) => matrix_user_id) - .filter(id => id) as string[] | undefined; - if (adminMatrixIds && adminMatrixIds.length > 0) { - errorPageData.adminMatrixIds = adminMatrixIds; - } - - return errorPageData; -} - -async function createSsrHtml(root: string, isoData: IsoDataOptionalSite) { - const site = isoData.site_res; - const appleTouchIcon = site?.site_view.site.icon - ? `data:image/png;base64,${sharp( - await fetchIconPng(site.site_view.site.icon) - ) - .resize(180, 180) - .extend({ - bottom: 20, - top: 20, - left: 20, - right: 20, - background: "#222222", - }) - .png() - .toBuffer() - .then(buf => buf.toString("base64"))}` - : favIconPngUrl; - - const erudaStr = - process.env["LEMMY_UI_DEBUG"] === "true" - ? renderToString( - <> - <script src="//cdn.jsdelivr.net/npm/eruda"></script> - <script>eruda.init();</script> - </> - ) - : ""; - - const helmet = Helmet.renderStatic(); - - const config: ILemmyConfig = { wsHost: process.env.LEMMY_UI_LEMMY_WS_HOST }; - - return ` - <!DOCTYPE html> - <html ${helmet.htmlAttributes.toString()}> - <head> - <script>window.isoData = ${serialize(isoData)}</script> - <script>window.lemmyConfig = ${serialize(config)}</script> - - <!-- A remote debugging utility for mobile --> - ${erudaStr} - - <!-- Custom injected script --> - ${customHtmlHeader} - - ${helmet.title.toString()} - ${helmet.meta.toString()} - - <!-- Required meta tags --> - <meta name="Description" content="Lemmy"> - <meta charset="utf-8"> - <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no, user-scalable=no"> - <link - id="favicon" - rel="shortcut icon" - type="image/x-icon" - href=${site?.site_view.site.icon ?? favIconUrl} - /> - - <!-- Web app manifest --> - ${ - site && - `<link - rel="manifest" - href=${`data:application/manifest+json;base64,${await generateManifestBase64( - site - )}`} - />` - } - <link rel="apple-touch-icon" href=${appleTouchIcon} /> - <link rel="apple-touch-startup-image" href=${appleTouchIcon} /> - - <!-- Styles --> - <link rel="stylesheet" type="text/css" href="/static/styles/styles.css" /> - - <!-- Current theme and more --> - ${helmet.link.toString()} - - </head> - - <body ${helmet.bodyAttributes.toString()}> - <noscript> - <div class="alert alert-danger rounded-0" role="alert"> - <b>Javascript is disabled. Actions will not work.</b> - </div> - </noscript> - - <div id='root'>${root}</div> - <script defer src='/static/js/client.js'></script> - </body> -</html> -`; -} diff --git a/src/server/middleware/set-default-csp.ts b/src/server/middleware/set-default-csp.ts new file mode 100644 index 0000000..5b6f72a --- /dev/null +++ b/src/server/middleware/set-default-csp.ts @@ -0,0 +1,9 @@ +import type { NextFunction, Response } from "express"; + +export default function ({ res, next }: { res: Response; next: NextFunction }) { + res.setHeader( + "Content-Security-Policy", + `default-src 'self'; manifest-src *; connect-src *; img-src * data:; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; form-action 'self'; base-uri 'self'; frame-src *; media-src *` + ); + next(); +} diff --git a/src/server/utils/build-themes-list.ts b/src/server/utils/build-themes-list.ts new file mode 100644 index 0000000..c6aa770 --- /dev/null +++ b/src/server/utils/build-themes-list.ts @@ -0,0 +1,17 @@ +import { existsSync } from "fs"; +import { readdir } from "fs/promises"; + +const extraThemesFolder = + process.env["LEMMY_UI_EXTRA_THEMES_FOLDER"] || "./extra_themes"; + +export async function buildThemeList(): Promise<string[]> { + const themes = ["darkly", "darkly-red", "litely", "litely-red"]; + if (existsSync(extraThemesFolder)) { + const dirThemes = await readdir(extraThemesFolder); + const cssThemes = dirThemes + .filter(d => d.endsWith(".css")) + .map(d => d.replace(".css", "")); + themes.push(...cssThemes); + } + return themes; +} diff --git a/src/server/utils/create-ssr-html.tsx b/src/server/utils/create-ssr-html.tsx new file mode 100644 index 0000000..d23c6da --- /dev/null +++ b/src/server/utils/create-ssr-html.tsx @@ -0,0 +1,108 @@ +import { Helmet } from "inferno-helmet"; +import { renderToString } from "inferno-server"; +import serialize from "serialize-javascript"; +import sharp from "sharp"; +import { ILemmyConfig, IsoDataOptionalSite } from "../../shared/interfaces"; +import { favIconPngUrl, favIconUrl } from "../../shared/utils"; +import { fetchIconPng } from "./fetch-icon-png"; +import { generateManifestBase64 } from "./generate-manifest-base64"; + +const customHtmlHeader = process.env["LEMMY_UI_CUSTOM_HTML_HEADER"] || ""; + +export async function createSsrHtml( + root: string, + isoData: IsoDataOptionalSite +) { + const site = isoData.site_res; + const appleTouchIcon = site?.site_view.site.icon + ? `data:image/png;base64,${sharp( + await fetchIconPng(site.site_view.site.icon) + ) + .resize(180, 180) + .extend({ + bottom: 20, + top: 20, + left: 20, + right: 20, + background: "#222222", + }) + .png() + .toBuffer() + .then(buf => buf.toString("base64"))}` + : favIconPngUrl; + + const erudaStr = + process.env["LEMMY_UI_DEBUG"] === "true" + ? renderToString( + <> + <script src="//cdn.jsdelivr.net/npm/eruda"></script> + <script>eruda.init();</script> + </> + ) + : ""; + + const helmet = Helmet.renderStatic(); + + const config: ILemmyConfig = { wsHost: process.env.LEMMY_UI_LEMMY_WS_HOST }; + + return ` + <!DOCTYPE html> + <html ${helmet.htmlAttributes.toString()}> + <head> + <script>window.isoData = ${serialize(isoData)}</script> + <script>window.lemmyConfig = ${serialize(config)}</script> + + <!-- A remote debugging utility for mobile --> + ${erudaStr} + + <!-- Custom injected script --> + ${customHtmlHeader} + + ${helmet.title.toString()} + ${helmet.meta.toString()} + + <!-- Required meta tags --> + <meta name="Description" content="Lemmy"> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no, user-scalable=no"> + <link + id="favicon" + rel="shortcut icon" + type="image/x-icon" + href=${site?.site_view.site.icon ?? favIconUrl} + /> + + <!-- Web app manifest --> + ${ + site && + `<link + rel="manifest" + href=${`data:application/manifest+json;base64,${await generateManifestBase64( + site + )}`} + />` + } + <link rel="apple-touch-icon" href=${appleTouchIcon} /> + <link rel="apple-touch-startup-image" href=${appleTouchIcon} /> + + <!-- Styles --> + <link rel="stylesheet" type="text/css" href="/static/styles/styles.css" /> + + <!-- Current theme and more --> + ${helmet.link.toString()} + + </head> + + <body ${helmet.bodyAttributes.toString()}> + <noscript> + <div class="alert alert-danger rounded-0" role="alert"> + <b>Javascript is disabled. Actions will not work.</b> + </div> + </noscript> + + <div id='root'>${root}</div> + <script defer src='/static/js/client.js'></script> + </body> + </html> + `; +} diff --git a/src/server/utils/fetch-icon-png.ts b/src/server/utils/fetch-icon-png.ts new file mode 100644 index 0000000..12b09e7 --- /dev/null +++ b/src/server/utils/fetch-icon-png.ts @@ -0,0 +1,5 @@ +export async function fetchIconPng(iconUrl: string) { + return await fetch(iconUrl) + .then(res => res.blob()) + .then(blob => blob.arrayBuffer()); +} diff --git a/src/server/utils/generate-manifest-base64.ts b/src/server/utils/generate-manifest-base64.ts new file mode 100644 index 0000000..e89b155 --- /dev/null +++ b/src/server/utils/generate-manifest-base64.ts @@ -0,0 +1,107 @@ +import { readFile } from "fs/promises"; +import { GetSiteResponse } from "lemmy-js-client"; +import path from "path"; +import sharp from "sharp"; +import { getHttpBaseExternal } from "../../shared/env"; +import { fetchIconPng } from "./fetch-icon-png"; + +const iconSizes = [72, 96, 144, 192, 512]; + +const defaultLogoPathDirectory = path.join( + process.cwd(), + "dist", + "assets", + "icons" +); + +export async function generateManifestBase64({ + my_user, + site_view: { + site, + local_site: { community_creation_admin_only }, + }, +}: GetSiteResponse) { + const url = getHttpBaseExternal(); + + const icon = site.icon ? await fetchIconPng(site.icon) : null; + + const manifest = { + name: site.name, + description: site.description ?? "A link aggregator for the fediverse", + start_url: url, + scope: url, + display: "standalone", + id: "/", + background_color: "#222222", + theme_color: "#222222", + icons: await Promise.all( + iconSizes.map(async size => { + let src = await readFile( + path.join(defaultLogoPathDirectory, `icon-${size}x${size}.png`) + ).then(buf => buf.toString("base64")); + + if (icon) { + src = await sharp(icon) + .resize(size, size) + .png() + .toBuffer() + .then(buf => buf.toString("base64")); + } + + return { + sizes: `${size}x${size}`, + type: "image/png", + src: `data:image/png;base64,${src}`, + purpose: "any maskable", + }; + }) + ), + shortcuts: [ + { + name: "Search", + short_name: "Search", + description: "Perform a search.", + url: "/search", + }, + { + name: "Communities", + url: "/communities", + short_name: "Communities", + description: "Browse communities", + }, + ] + .concat( + my_user + ? [ + { + name: "Create Post", + url: "/create_post", + short_name: "Create Post", + description: "Create a post.", + }, + ] + : [] + ) + .concat( + my_user?.local_user_view.person.admin || !community_creation_admin_only + ? [ + { + name: "Create Community", + url: "/create_community", + short_name: "Create Community", + description: "Create a community", + }, + ] + : [] + ), + related_applications: [ + { + platform: "f-droid", + url: "https://f-droid.org/packages/com.jerboa/", + id: "com.jerboa", + }, + ], + }; + + return Buffer.from(JSON.stringify(manifest)).toString("base64"); +} diff --git a/src/server/utils/get-error-page-data.ts b/src/server/utils/get-error-page-data.ts new file mode 100644 index 0000000..af5c811 --- /dev/null +++ b/src/server/utils/get-error-page-data.ts @@ -0,0 +1,19 @@ +import { GetSiteResponse } from "lemmy-js-client"; +import { ErrorPageData } from "../../shared/utils"; + +export function getErrorPageData(error: Error, site?: GetSiteResponse) { + const errorPageData: ErrorPageData = {}; + + if (site) { + errorPageData.error = error.message; + } + + const adminMatrixIds = site?.admins + .map(({ person: { matrix_user_id } }) => matrix_user_id) + .filter(id => id) as string[] | undefined; + if (adminMatrixIds && adminMatrixIds.length > 0) { + errorPageData.adminMatrixIds = adminMatrixIds; + } + + return errorPageData; +} diff --git a/src/server/utils/set-forwarded-headers.ts b/src/server/utils/set-forwarded-headers.ts new file mode 100644 index 0000000..d825c60 --- /dev/null +++ b/src/server/utils/set-forwarded-headers.ts @@ -0,0 +1,20 @@ +import { IncomingHttpHeaders } from "http"; + +export function setForwardedHeaders(headers: IncomingHttpHeaders): { + [key: string]: string; +} { + const out: { [key: string]: string } = {}; + if (headers.host) { + out.host = headers.host; + } + const realIp = headers["x-real-ip"]; + if (realIp) { + out["x-real-ip"] = realIp as string; + } + const forwardedFor = headers["x-forwarded-for"]; + if (forwardedFor) { + out["x-forwarded-for"] = forwardedFor as string; + } + + return out; +} -- 2.44.1