diff --git a/src/assets/css/themes/_variables.darkly.scss b/src/assets/css/themes/_variables.darkly.scss index e3680f8c..56ccc3cf 100644 --- a/src/assets/css/themes/_variables.darkly.scss +++ b/src/assets/css/themes/_variables.darkly.scss @@ -1,15 +1,12 @@ // Colors $white: #fff; -$gray-100: #f8f9fa; $gray-200: #ebebeb; $gray-300: #dee2e6; -$gray-400: #ced4da; $gray-500: #adb5bd; $gray-600: #888; $gray-700: #444; $gray-800: #303030; $gray-900: #222; -$black: #000; // Writing these maps is necessary for Bootstrap theming: // https://getbootstrap.com/docs/4.6/getting-started/introduction/ @@ -22,7 +19,6 @@ $grays: ( ); $blue: #375a7f; -$indigo: #6610f2; $red: #e74c3c; $yellow: #f39c12; $green: #00bc8c; diff --git a/src/assets/css/themes/_variables.litely.scss b/src/assets/css/themes/_variables.litely.scss index 6706c3ee..2fc0fe3d 100644 --- a/src/assets/css/themes/_variables.litely.scss +++ b/src/assets/css/themes/_variables.litely.scss @@ -1,10 +1,6 @@ // Colors -$white: #fff; $gray-100: #f8f9fa; $gray-200: #e9ecef; -$gray-300: #dee2e6; -$gray-400: #ced4da; -$gray-500: #adb5bd; $gray-600: #6c757d; $gray-700: #495057; $gray-800: #343a40; diff --git a/src/server/handlers/catch-all-handler.tsx b/src/server/handlers/catch-all-handler.tsx new file mode 100644 index 00000000..eb847dc7 --- /dev/null +++ b/src/server/handlers/catch-all-handler.tsx @@ -0,0 +1,116 @@ +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, + RouteData, +} from "../../shared/interfaces"; +import { routes } from "../../shared/routes"; +import { + FailedRequestState, + 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; + let routeData: RouteData = {}; + 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 && activeRoute?.fetchInitialData) { + const initialFetchReq: InitialFetchRequest = { + client, + auth, + path, + query, + site, + }; + + routeData = await activeRoute.fetchInitialData(initialFetchReq); + } + } else if (try_site.state === "failed") { + errorPageData = getErrorPageData(new Error(try_site.msg), site); + } + + const error = Object.values(routeData).find( + res => res.state === "failed" + ) as FailedRequestState | undefined; + + // Redirect to the 404 if there's an API error + if (error) { + console.error(error.msg); + if (error.msg === "instance_is_private") { + return res.redirect(`/signup`); + } else { + errorPageData = getErrorPageData(new Error(error.msg), site); + } + } + + const isoData: IsoDataOptionalSite = { + path, + site_res: site, + routeData, + errorPageData, + }; + + const wrapper = ( + + + + ); + + 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.ts b/src/server/handlers/robots-handler.ts new file mode 100644 index 00000000..7271095c --- /dev/null +++ b/src/server/handlers/robots-handler.ts @@ -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.ts b/src/server/handlers/service-worker-handler.ts new file mode 100644 index 00000000..15c6b3fb --- /dev/null +++ b/src/server/handlers/service-worker-handler.ts @@ -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") + .sendFile( + path.resolve( + `./dist/service-worker${ + process.env.NODE_ENV === "development" ? "-development" : "" + }.js` + ) + ); +}; diff --git a/src/server/handlers/theme-handler.ts b/src/server/handlers/theme-handler.ts new file mode 100644 index 00000000..9f1046d7 --- /dev/null +++ b/src/server/handlers/theme-handler.ts @@ -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.ts b/src/server/handlers/themes-list-handler.ts new file mode 100644 index 00000000..f36497e4 --- /dev/null +++ b/src/server/handlers/themes-list-handler.ts @@ -0,0 +1,6 @@ +import type { Response } from "express"; +import { buildThemeList } from "../utils/build-themes-list"; + +export default async ({ res }: { res: Response }) => { + res.type("json").send(JSON.stringify(await buildThemeList())); +}; diff --git a/src/server/index.tsx b/src/server/index.tsx index 4cff98b5..f109fc11 100644 --- a/src/server/index.tsx +++ b/src/server/index.tsx @@ -1,463 +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, - RouteData, -} from "../shared/interfaces"; -import { routes } from "../shared/routes"; -import { FailedRequestState, 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 { - 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; - let routeData: RouteData = {}; - 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 && activeRoute?.fetchInitialData) { - const initialFetchReq: InitialFetchRequest = { - client, - auth, - path, - query, - site, - }; - - routeData = await activeRoute.fetchInitialData(initialFetchReq); - } - } else if (try_site.state === "failed") { - errorPageData = getErrorPageData(new Error(try_site.msg), site); - } - - const error = Object.values(routeData).find( - res => res.state === "failed" - ) as FailedRequestState | undefined; - - // Redirect to the 404 if there's an API error - if (error) { - console.error(error.msg); - if (error.msg === "instance_is_private") { - return res.redirect(`/signup`); - } else { - errorPageData = getErrorPageData(new Error(error.msg), site); - } - } - - const isoData: IsoDataOptionalSite = { - path, - site_res: site, - routeData, - errorPageData, - }; - - const wrapper = ( - - - - ); - - 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( - <> - - - > - ) - : ""; - - const helmet = Helmet.renderStatic(); - - const config: ILemmyConfig = { wsHost: process.env.LEMMY_UI_LEMMY_WS_HOST }; - - return ` - - - - - - - - ${erudaStr} - - - ${customHtmlHeader} - - ${helmet.title.toString()} - ${helmet.meta.toString()} - - - - - - - - - ${ - site && - `` - } - - - - - - - - ${helmet.link.toString()} - - - - - - - Javascript is disabled. Actions will not work. - - - - ${root} - - - -`; -} diff --git a/src/server/middleware/set-default-csp.ts b/src/server/middleware/set-default-csp.ts new file mode 100644 index 00000000..a3ee5261 --- /dev/null +++ b/src/server/middleware/set-default-csp.ts @@ -0,0 +1,10 @@ +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 00000000..73dc53bc --- /dev/null +++ b/src/server/utils/build-themes-list.ts @@ -0,0 +1,18 @@ +import { existsSync } from "fs"; +import { readdir } from "fs/promises"; + +const extraThemesFolder = + process.env["LEMMY_UI_EXTRA_THEMES_FOLDER"] || "./extra_themes"; + +const themes = ["darkly", "darkly-red", "litely", "litely-red"]; + +export async function buildThemeList(): Promise { + 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 00000000..5cc38d7d --- /dev/null +++ b/src/server/utils/create-ssr-html.tsx @@ -0,0 +1,109 @@ +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( + <> + + + > + ) + : ""; + + const helmet = Helmet.renderStatic(); + + const config: ILemmyConfig = { wsHost: process.env.LEMMY_UI_LEMMY_WS_HOST }; + + return ` + + + + + + + + ${erudaStr} + + + ${customHtmlHeader} + + ${helmet.title.toString()} + ${helmet.meta.toString()} + + + + + + + + + ${ + site && + `` + } + + + + + + + + ${helmet.link.toString()} + + + + + + + Javascript is disabled. Actions will not work. + + + + ${root} + + + + `; +} diff --git a/src/server/utils/fetch-icon-png.ts b/src/server/utils/fetch-icon-png.ts new file mode 100644 index 00000000..12b09e70 --- /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 00000000..e89b1559 --- /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 00000000..3c82372f --- /dev/null +++ b/src/server/utils/get-error-page-data.ts @@ -0,0 +1,20 @@ +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 00000000..386bd7db --- /dev/null +++ b/src/server/utils/set-forwarded-headers.ts @@ -0,0 +1,25 @@ +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; +}