]> Untitled Git - lemmy-ui.git/blobdiff - src/server/index.tsx
Use http client (#1081)
[lemmy-ui.git] / src / server / index.tsx
index d508dab33f28d84c92ccb1396446e136942bd5b5..43024076ebb74db9d7624a89cf354555d355d3f0 100644 (file)
@@ -1,26 +1,32 @@
-import { None, Option } from "@sniptt/monads";
-import { serialize as serializeO } from "class-transformer";
 import express from "express";
-import fs from "fs";
+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, toOption } from "lemmy-js-client";
+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 { SYMBOLS } from "../shared/components/common/symbols";
-import { httpBaseInternal } from "../shared/env";
+import { getHttpBaseExternal, getHttpBaseInternal } from "../shared/env";
 import {
   ILemmyConfig,
   InitialFetchRequest,
-  IsoData,
+  IsoDataOptionalSite,
 } from "../shared/interfaces";
 import { routes } from "../shared/routes";
-import { initializeSite } from "../shared/utils";
+import { RequestState, wrapClient } from "../shared/services/HttpService";
+import {
+  ErrorPageData,
+  favIconPngUrl,
+  favIconUrl,
+  initializeSite,
+  isAuthPath,
+} from "../shared/utils";
 
 const server = express();
 const [hostname, port] = process.env["LEMMY_UI_HOST"]
@@ -29,11 +35,11 @@ const [hostname, port] = process.env["LEMMY_UI_HOST"]
 const extraThemesFolder =
   process.env["LEMMY_UI_EXTRA_THEMES_FOLDER"] || "./extra_themes";
 
-if (!process.env["LEMMY_UI_DISABLE_CSP"]) {
+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 'none'; connect-src *; img-src * data:; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; form-action 'self'; base-uri 'self'`
+      `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();
   });
@@ -57,6 +63,17 @@ 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);
@@ -66,40 +83,30 @@ 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 (fs.existsSync(customTheme)) {
+  if (existsSync(customTheme)) {
     res.sendFile(customTheme);
   } else {
     const internalTheme = path.resolve(`./dist/assets/css/themes/${theme}`);
-    res.sendFile(internalTheme);
+
+    // 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"));
+    }
   }
 });
 
-function buildThemeList(): string[] {
-  let themes = [
-    "litera",
-    "materia",
-    "minty",
-    "solar",
-    "united",
-    "cyborg",
-    "darkly",
-    "darkly-red",
-    "journal",
-    "sketchy",
-    "vaporwave",
-    "vaporwave-dark",
-    "i386",
-    "litely",
-    "litely-red",
-    "nord",
-  ];
-  if (fs.existsSync(extraThemesFolder)) {
-    let dirThemes = fs.readdirSync(extraThemesFolder);
-    let cssThemes = dirThemes
+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);
@@ -109,138 +116,105 @@ function buildThemeList(): string[] {
 
 server.get("/css/themelist", async (_req, res) => {
   res.type("json");
-  res.send(JSON.stringify(buildThemeList()));
+  res.send(JSON.stringify(await buildThemeList()));
 });
 
 // server.use(cookieParser());
 server.get("/*", async (req, res) => {
   try {
-    const activeRoute = routes.find(route => matchPath(req.path, route)) || {};
-    const context = {} as any;
-    let auth: Option<string> = toOption(IsomorphicCookie.load("jwt", req));
-
-    let getSiteForm = new GetSite({ auth });
+    const activeRoute = routes.find(route => matchPath(req.path, route));
+    let auth: string | undefined = IsomorphicCookie.load("jwt", req);
 
-    let promises: Promise<any>[] = [];
+    const getSiteForm: GetSite = { auth };
 
-    let headers = setForwardedHeaders(req.headers);
+    const headers = setForwardedHeaders(req.headers);
+    const client = wrapClient(new LemmyHttp(getHttpBaseInternal(), headers));
 
-    let initialFetchReq: InitialFetchRequest = {
-      client: new LemmyHttp(httpBaseInternal, headers),
-      auth,
-      path: req.path,
-    };
+    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 try_site: any = await initialFetchReq.client.getSite(getSiteForm);
-    if (try_site.error == "not_logged_in") {
+    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 = None;
-      initialFetchReq.auth = None;
-      try_site = await initialFetchReq.client.getSite(getSiteForm);
+      getSiteForm.auth = undefined;
+      auth = undefined;
+      try_site = await client.getSite(getSiteForm);
     }
-    let site: GetSiteResponse = try_site;
-    initializeSite(site);
 
-    if (activeRoute.fetchInitialData) {
-      promises.push(...activeRoute.fetchInitialData(initialFetchReq));
+    if (!auth && isAuthPath(path)) {
+      return res.redirect("/login");
     }
 
-    let routeData = await Promise.all(promises);
+    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].error) {
-      let errCode = routeData[0].error;
-      console.error(errCode);
-      if (errCode == "instance_is_private") {
+    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 {
-        return res.send(`404: ${removeAuthParam(errCode)}`);
+        errorPageData = getErrorPageData(new Error(error), site);
       }
     }
 
-    let isoData: IsoData = {
-      path: req.path,
+    const isoData: IsoDataOptionalSite = {
+      path,
       site_res: site,
       routeData,
+      errorPageData,
     };
 
     const wrapper = (
-      <StaticRouter location={req.url} context={isoData}>
+      <StaticRouter location={url} context={isoData}>
         <App />
       </StaticRouter>
     );
-    if (context.url) {
-      return res.redirect(context.url);
-    }
 
-    const eruda = (
-      <>
-        <script src="//cdn.jsdelivr.net/npm/eruda"></script>
-        <script>eruda.init();</script>
-      </>
-    );
-    const erudaStr = process.env["LEMMY_UI_DEBUG"] ? renderToString(eruda) : "";
     const root = renderToString(wrapper);
-    const symbols = renderToString(SYMBOLS);
-    const helmet = Helmet.renderStatic();
-
-    const config: ILemmyConfig = { wsHost: process.env.LEMMY_WS_HOST };
-
-    res.send(`
-           <!DOCTYPE html>
-           <html ${helmet.htmlAttributes.toString()} lang="en">
-           <head>
-           <script>window.isoData = ${serializeO(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">
-
-           <!-- Web app manifest -->
-           <link rel="manifest" href="/static/assets/manifest.webmanifest">
-
-           <!-- Styles -->
-           <link rel="stylesheet" type="text/css" href="/static/styles/styles.css" />
-
-           <!-- Current theme and more -->
-           ${helmet.link.toString()}
-           
-           <!-- Icons -->
-           ${symbols}
-
-           </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>
-`);
+
+    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);
-    return res.send(`404: ${removeAuthParam(err)}`);
+    res.statusCode = 500;
+    return res.send(
+      process.env.NODE_ENV === "development" ? err.message : "Server error"
+    );
   }
 });
 
@@ -251,14 +225,17 @@ server.listen(Number(port), hostname, () => {
 function setForwardedHeaders(headers: IncomingHttpHeaders): {
   [key: string]: string;
 } {
-  let out = {
-    host: headers.host,
-  };
-  if (headers["x-real-ip"]) {
-    out["x-real-ip"] = headers["x-real-ip"];
+  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;
   }
-  if (headers["x-forwarded-for"]) {
-    out["x-forwarded-for"] = headers["x-forwarded-for"];
+  const forwardedFor = headers["x-forwarded-for"];
+  if (forwardedFor) {
+    out["x-forwarded-for"] = forwardedFor as string;
   }
 
   return out;
@@ -269,12 +246,220 @@ process.on("SIGINT", () => {
   process.exit(0);
 });
 
-function removeAuthParam(err: any): string {
-  return removeParam(err.toString(), "auth");
+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");
 }
 
-function removeParam(url: string, parameter: string): string {
-  return url
-    .replace(new RegExp("[?&]" + parameter + "=[^&#]*(#.*)?$"), "$1")
-    .replace(new RegExp("([?&])" + parameter + "=[^&]*&"), "$1");
+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">
+  <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>
+`;
 }