]> Untitled Git - lemmy-ui.git/blob - src/server/index.tsx
Merge branch 'main' into comment-depth
[lemmy-ui.git] / src / server / index.tsx
1 import express from "express";
2 import { existsSync } from "fs";
3 import { readdir, readFile } from "fs/promises";
4 import { IncomingHttpHeaders } from "http";
5 import { Helmet } from "inferno-helmet";
6 import { matchPath, StaticRouter } from "inferno-router";
7 import { renderToString } from "inferno-server";
8 import IsomorphicCookie from "isomorphic-cookie";
9 import { GetSite, GetSiteResponse, LemmyHttp } from "lemmy-js-client";
10 import path from "path";
11 import process from "process";
12 import serialize from "serialize-javascript";
13 import sharp from "sharp";
14 import { App } from "../shared/components/app/app";
15 import { getHttpBaseExternal, getHttpBaseInternal } from "../shared/env";
16 import {
17   ILemmyConfig,
18   InitialFetchRequest,
19   IsoDataOptionalSite,
20 } from "../shared/interfaces";
21 import { routes } from "../shared/routes";
22 import { RequestState, wrapClient } from "../shared/services/HttpService";
23 import {
24   ErrorPageData,
25   favIconPngUrl,
26   favIconUrl,
27   initializeSite,
28   isAuthPath,
29 } from "../shared/utils";
30
31 const server = express();
32 const [hostname, port] = process.env["LEMMY_UI_HOST"]
33   ? process.env["LEMMY_UI_HOST"].split(":")
34   : ["0.0.0.0", "1234"];
35 const extraThemesFolder =
36   process.env["LEMMY_UI_EXTRA_THEMES_FOLDER"] || "./extra_themes";
37
38 if (!process.env["LEMMY_UI_DISABLE_CSP"] && !process.env["LEMMY_UI_DEBUG"]) {
39   server.use(function (_req, res, next) {
40     res.setHeader(
41       "Content-Security-Policy",
42       `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 *`
43     );
44     next();
45   });
46 }
47 const customHtmlHeader = process.env["LEMMY_UI_CUSTOM_HTML_HEADER"] || "";
48
49 server.use(express.json());
50 server.use(express.urlencoded({ extended: false }));
51 server.use("/static", express.static(path.resolve("./dist")));
52
53 const robotstxt = `User-Agent: *
54 Disallow: /login
55 Disallow: /settings
56 Disallow: /create_community
57 Disallow: /create_post
58 Disallow: /create_private_message
59 Disallow: /inbox
60 Disallow: /setup
61 Disallow: /admin
62 Disallow: /password_change
63 Disallow: /search/
64 `;
65
66 server.get("/service-worker.js", async (_req, res) => {
67   res.setHeader("Content-Type", "application/javascript");
68   res.sendFile(
69     path.resolve(
70       `./dist/service-worker${
71         process.env.NODE_ENV === "development" ? "-development" : ""
72       }.js`
73     )
74   );
75 });
76
77 server.get("/robots.txt", async (_req, res) => {
78   res.setHeader("content-type", "text/plain; charset=utf-8");
79   res.send(robotstxt);
80 });
81
82 server.get("/css/themes/:name", async (req, res) => {
83   res.contentType("text/css");
84   const theme = req.params.name;
85   if (!theme.endsWith(".css")) {
86     res.statusCode = 400;
87     res.send("Theme must be a css file");
88   }
89
90   const customTheme = path.resolve(`./${extraThemesFolder}/${theme}`);
91   if (existsSync(customTheme)) {
92     res.sendFile(customTheme);
93   } else {
94     const internalTheme = path.resolve(`./dist/assets/css/themes/${theme}`);
95
96     // If the theme doesn't exist, just send litely
97     if (existsSync(internalTheme)) {
98       res.sendFile(internalTheme);
99     } else {
100       res.sendFile(path.resolve("./dist/assets/css/themes/litely.css"));
101     }
102   }
103 });
104
105 async function buildThemeList(): Promise<string[]> {
106   const themes = ["darkly", "darkly-red", "litely", "litely-red"];
107   if (existsSync(extraThemesFolder)) {
108     const dirThemes = await readdir(extraThemesFolder);
109     const cssThemes = dirThemes
110       .filter(d => d.endsWith(".css"))
111       .map(d => d.replace(".css", ""));
112     themes.push(...cssThemes);
113   }
114   return themes;
115 }
116
117 server.get("/css/themelist", async (_req, res) => {
118   res.type("json");
119   res.send(JSON.stringify(await buildThemeList()));
120 });
121
122 // server.use(cookieParser());
123 server.get("/*", async (req, res) => {
124   try {
125     const activeRoute = routes.find(route => matchPath(req.path, route));
126     let auth: string | undefined = IsomorphicCookie.load("jwt", req);
127
128     const getSiteForm: GetSite = { auth };
129
130     const headers = setForwardedHeaders(req.headers);
131     const client = wrapClient(new LemmyHttp(getHttpBaseInternal(), headers));
132
133     const { path, url, query } = req;
134
135     // Get site data first
136     // This bypasses errors, so that the client can hit the error on its own,
137     // in order to remove the jwt on the browser. Necessary for wrong jwts
138     let site: GetSiteResponse | undefined = undefined;
139     const routeData: RequestState<any>[] = [];
140     let errorPageData: ErrorPageData | undefined = undefined;
141     let try_site = await client.getSite(getSiteForm);
142     if (try_site.state === "failed" && try_site.msg == "not_logged_in") {
143       console.error(
144         "Incorrect JWT token, skipping auth so frontend can remove jwt cookie"
145       );
146       getSiteForm.auth = undefined;
147       auth = undefined;
148       try_site = await client.getSite(getSiteForm);
149     }
150
151     if (!auth && isAuthPath(path)) {
152       return res.redirect("/login");
153     }
154
155     if (try_site.state === "success") {
156       site = try_site.data;
157       initializeSite(site);
158
159       if (path != "/setup" && !site.site_view.local_site.site_setup) {
160         return res.redirect("/setup");
161       }
162
163       if (site) {
164         const initialFetchReq: InitialFetchRequest = {
165           client,
166           auth,
167           path,
168           query,
169           site,
170         };
171
172         if (activeRoute?.fetchInitialData) {
173           routeData.push(
174             ...(await Promise.all([
175               ...activeRoute.fetchInitialData(initialFetchReq),
176             ]))
177           );
178         }
179       }
180     } else if (try_site.state === "failed") {
181       errorPageData = getErrorPageData(new Error(try_site.msg), site);
182     }
183
184     // Redirect to the 404 if there's an API error
185     if (routeData[0] && routeData[0].state === "failed") {
186       const error = routeData[0].msg;
187       console.error(error);
188       if (error === "instance_is_private") {
189         return res.redirect(`/signup`);
190       } else {
191         errorPageData = getErrorPageData(new Error(error), site);
192       }
193     }
194
195     const isoData: IsoDataOptionalSite = {
196       path,
197       site_res: site,
198       routeData,
199       errorPageData,
200     };
201
202     const wrapper = (
203       <StaticRouter location={url} context={isoData}>
204         <App />
205       </StaticRouter>
206     );
207
208     const root = renderToString(wrapper);
209
210     res.send(await createSsrHtml(root, isoData));
211   } catch (err) {
212     // If an error is caught here, the error page couldn't even be rendered
213     console.error(err);
214     res.statusCode = 500;
215     return res.send(
216       process.env.NODE_ENV === "development" ? err.message : "Server error"
217     );
218   }
219 });
220
221 server.listen(Number(port), hostname, () => {
222   console.log(`http://${hostname}:${port}`);
223 });
224
225 function setForwardedHeaders(headers: IncomingHttpHeaders): {
226   [key: string]: string;
227 } {
228   const out: { [key: string]: string } = {};
229   if (headers.host) {
230     out.host = headers.host;
231   }
232   const realIp = headers["x-real-ip"];
233   if (realIp) {
234     out["x-real-ip"] = realIp as string;
235   }
236   const forwardedFor = headers["x-forwarded-for"];
237   if (forwardedFor) {
238     out["x-forwarded-for"] = forwardedFor as string;
239   }
240
241   return out;
242 }
243
244 process.on("SIGINT", () => {
245   console.info("Interrupted");
246   process.exit(0);
247 });
248
249 const iconSizes = [72, 96, 144, 192, 512];
250 const defaultLogoPathDirectory = path.join(
251   process.cwd(),
252   "dist",
253   "assets",
254   "icons"
255 );
256
257 export async function generateManifestBase64({
258   my_user,
259   site_view: {
260     site,
261     local_site: { community_creation_admin_only },
262   },
263 }: GetSiteResponse) {
264   const url = getHttpBaseExternal();
265
266   const icon = site.icon ? await fetchIconPng(site.icon) : null;
267
268   const manifest = {
269     name: site.name,
270     description: site.description ?? "A link aggregator for the fediverse",
271     start_url: url,
272     scope: url,
273     display: "standalone",
274     id: "/",
275     background_color: "#222222",
276     theme_color: "#222222",
277     icons: await Promise.all(
278       iconSizes.map(async size => {
279         let src = await readFile(
280           path.join(defaultLogoPathDirectory, `icon-${size}x${size}.png`)
281         ).then(buf => buf.toString("base64"));
282
283         if (icon) {
284           src = await sharp(icon)
285             .resize(size, size)
286             .png()
287             .toBuffer()
288             .then(buf => buf.toString("base64"));
289         }
290
291         return {
292           sizes: `${size}x${size}`,
293           type: "image/png",
294           src: `data:image/png;base64,${src}`,
295           purpose: "any maskable",
296         };
297       })
298     ),
299     shortcuts: [
300       {
301         name: "Search",
302         short_name: "Search",
303         description: "Perform a search.",
304         url: "/search",
305       },
306       {
307         name: "Communities",
308         url: "/communities",
309         short_name: "Communities",
310         description: "Browse communities",
311       },
312     ]
313       .concat(
314         my_user
315           ? [
316               {
317                 name: "Create Post",
318                 url: "/create_post",
319                 short_name: "Create Post",
320                 description: "Create a post.",
321               },
322             ]
323           : []
324       )
325       .concat(
326         my_user?.local_user_view.person.admin || !community_creation_admin_only
327           ? [
328               {
329                 name: "Create Community",
330                 url: "/create_community",
331                 short_name: "Create Community",
332                 description: "Create a community",
333               },
334             ]
335           : []
336       ),
337     related_applications: [
338       {
339         platform: "f-droid",
340         url: "https://f-droid.org/packages/com.jerboa/",
341         id: "com.jerboa",
342       },
343     ],
344   };
345
346   return Buffer.from(JSON.stringify(manifest)).toString("base64");
347 }
348
349 async function fetchIconPng(iconUrl: string) {
350   return await fetch(iconUrl)
351     .then(res => res.blob())
352     .then(blob => blob.arrayBuffer());
353 }
354
355 function getErrorPageData(error: Error, site?: GetSiteResponse) {
356   const errorPageData: ErrorPageData = {};
357
358   if (site) {
359     errorPageData.error = error.message;
360   }
361
362   const adminMatrixIds = site?.admins
363     .map(({ person: { matrix_user_id } }) => matrix_user_id)
364     .filter(id => id) as string[] | undefined;
365   if (adminMatrixIds && adminMatrixIds.length > 0) {
366     errorPageData.adminMatrixIds = adminMatrixIds;
367   }
368
369   return errorPageData;
370 }
371
372 async function createSsrHtml(root: string, isoData: IsoDataOptionalSite) {
373   const site = isoData.site_res;
374   const appleTouchIcon = site?.site_view.site.icon
375     ? `data:image/png;base64,${sharp(
376         await fetchIconPng(site.site_view.site.icon)
377       )
378         .resize(180, 180)
379         .extend({
380           bottom: 20,
381           top: 20,
382           left: 20,
383           right: 20,
384           background: "#222222",
385         })
386         .png()
387         .toBuffer()
388         .then(buf => buf.toString("base64"))}`
389     : favIconPngUrl;
390
391   const erudaStr =
392     process.env["LEMMY_UI_DEBUG"] === "true"
393       ? renderToString(
394           <>
395             <script src="//cdn.jsdelivr.net/npm/eruda"></script>
396             <script>eruda.init();</script>
397           </>
398         )
399       : "";
400
401   const helmet = Helmet.renderStatic();
402
403   const config: ILemmyConfig = { wsHost: process.env.LEMMY_UI_LEMMY_WS_HOST };
404
405   return `
406   <!DOCTYPE html>
407   <html ${helmet.htmlAttributes.toString()}>
408   <head>
409   <script>window.isoData = ${serialize(isoData)}</script>
410   <script>window.lemmyConfig = ${serialize(config)}</script>
411
412   <!-- A remote debugging utility for mobile -->
413   ${erudaStr}
414
415   <!-- Custom injected script -->
416   ${customHtmlHeader}
417
418   ${helmet.title.toString()}
419   ${helmet.meta.toString()}
420
421   <!-- Required meta tags -->
422   <meta name="Description" content="Lemmy">
423   <meta charset="utf-8">
424   <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
425   <link
426      id="favicon"
427      rel="shortcut icon"
428      type="image/x-icon"
429      href=${site?.site_view.site.icon ?? favIconUrl}
430    />
431
432   <!-- Web app manifest -->
433   ${
434     site &&
435     `<link
436         rel="manifest"
437         href=${`data:application/manifest+json;base64,${await generateManifestBase64(
438           site
439         )}`}
440       />`
441   }
442   <link rel="apple-touch-icon" href=${appleTouchIcon} />
443   <link rel="apple-touch-startup-image" href=${appleTouchIcon} />
444
445   <!-- Styles -->
446   <link rel="stylesheet" type="text/css" href="/static/styles/styles.css" />
447
448   <!-- Current theme and more -->
449   ${helmet.link.toString()}
450   
451   </head>
452
453   <body ${helmet.bodyAttributes.toString()}>
454     <noscript>
455       <div class="alert alert-danger rounded-0" role="alert">
456         <b>Javascript is disabled. Actions will not work.</b>
457       </div>
458     </noscript>
459
460     <div id='root'>${root}</div>
461     <script defer src='/static/js/client.js'></script>
462   </body>
463 </html>
464 `;
465 }