diff --git a/.eslintignore b/.eslintignore index 26ddcb55..9e14b5a5 100644 --- a/.eslintignore +++ b/.eslintignore @@ -2,6 +2,6 @@ generate_translations.js webpack.config.js src/api_tests **/*.png -**/*.svg **/*.css -**/*.scss \ No newline at end of file +**/*.scss +**/*.svg diff --git a/package.json b/package.json index 62993df6..43b8883b 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,7 @@ "isomorphic-cookie": "^1.2.4", "jwt-decode": "^3.1.2", "lemmy-js-client": "0.17.2-rc.17", + "lodash": "^4.17.21", "markdown-it": "^13.0.1", "markdown-it-container": "^3.0.0", "markdown-it-emoji": "^2.0.2", @@ -77,6 +78,7 @@ "sass": "^1.62.1", "sass-loader": "^13.2.2", "serialize-javascript": "^6.0.1", + "service-worker-webpack": "^1.0.0", "sharp": "^0.32.1", "tippy.js": "^6.3.7", "toastify-js": "^1.12.0", @@ -112,7 +114,6 @@ "prettier-plugin-organize-imports": "^3.2.2", "prettier-plugin-packagejson": "^2.4.3", "rimraf": "^5.0.0", - "service-worker-webpack": "^1.0.0", "sortpack": "^2.3.4", "style-loader": "^3.3.2", "terser": "^5.17.3", diff --git a/src/server/index.tsx b/src/server/index.tsx index 9d91f14d..a93595e0 100644 --- a/src/server/index.tsx +++ b/src/server/index.tsx @@ -16,10 +16,16 @@ import { getHttpBase, getHttpBaseInternal } from "../shared/env"; import { ILemmyConfig, InitialFetchRequest, - IsoData, + IsoDataOptionalSite, } from "../shared/interfaces"; import { routes } from "../shared/routes"; -import { favIconPngUrl, favIconUrl, initializeSite } from "../shared/utils"; +import { + ErrorPageData, + favIconPngUrl, + favIconUrl, + initializeSite, + isAuthPath, +} from "../shared/utils"; const server = express(); const [hostname, port] = process.env["LEMMY_UI_HOST"] @@ -70,6 +76,7 @@ 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"); } @@ -109,44 +116,58 @@ server.get("/css/themelist", async (_req, res) => { server.get("/*", async (req, res) => { try { const activeRoute = routes.find(route => matchPath(req.path, route)); - const context = {} as any; let auth: string | undefined = IsomorphicCookie.load("jwt", req); const getSiteForm: GetSite = { auth }; - const promises: Promise[] = []; - const headers = setForwardedHeaders(req.headers); const client = 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 try_site: any = await client.getSite(getSiteForm); - if (try_site.error == "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); + let site: GetSiteResponse | undefined = undefined; + let routeData: any[] = []; + let errorPageData: ErrorPageData | undefined; + try { + let try_site: any = await client.getSite(getSiteForm); + if (try_site.error == "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)) { + res.redirect("/login"); + return; + } + + site = try_site; + initializeSite(site); + + if (site) { + const initialFetchReq: InitialFetchRequest = { + client, + auth, + path, + query, + site, + }; + + if (activeRoute?.fetchInitialData) { + routeData = await Promise.all([ + ...activeRoute.fetchInitialData(initialFetchReq), + ]); + } + } + } catch (error) { + errorPageData = getErrorPageData(error, site); } - const site: GetSiteResponse = try_site; - initializeSite(site); - - const initialFetchReq: InitialFetchRequest = { - client, - auth, - path: req.path, - query: req.query, - site, - }; - - if (activeRoute?.fetchInitialData) { - promises.push(...activeRoute.fetchInitialData(initialFetchReq)); - } - - const routeData = await Promise.all(promises); // Redirect to the 404 if there's an API error if (routeData[0] && routeData[0].error) { @@ -155,112 +176,33 @@ server.get("/*", async (req, res) => { if (error === "instance_is_private") { return res.redirect(`/signup`); } else { - return res.send(`404: ${removeAuthParam(error)}`); + errorPageData = getErrorPageData(error, site); } } - const isoData: IsoData = { - path: req.path, + const isoData: IsoDataOptionalSite = { + path, site_res: site, routeData, + errorPageData, }; const wrapper = ( - + ); - if (context.url) { - return res.redirect(context.url); - } - const eruda = ( - <> - - - - ); - - const erudaStr = process.env["LEMMY_UI_DEBUG"] ? renderToString(eruda) : ""; const root = renderToString(wrapper); - const helmet = Helmet.renderStatic(); - const config: ILemmyConfig = { wsHost: process.env.LEMMY_UI_LEMMY_WS_HOST }; - - 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; - - res.send(` - - - - - - - - ${erudaStr} - - - ${customHtmlHeader} - - ${helmet.title.toString()} - ${helmet.meta.toString()} - - - - - - - - - - - - - - - - - ${helmet.link.toString()} - - - - - - -
${root}
- - - -`); + 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" + ); } }); @@ -292,16 +234,6 @@ process.on("SIGINT", () => { process.exit(0); }); -function removeAuthParam(err: any): string { - return removeParam(err.toString(), "auth"); -} - -function removeParam(url: string, parameter: string): string { - return url - .replace(new RegExp("[?&]" + parameter + "=[^&#]*(#.*)?$"), "$1") - .replace(new RegExp("([?&])" + parameter + "=[^&]*&"), "$1"); -} - const iconSizes = [72, 96, 128, 144, 152, 192, 384, 512]; const defaultLogoPathDirectory = path.join( process.cwd(), @@ -361,3 +293,114 @@ async function fetchIconPng(iconUrl: string) { .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 eruda = ( + <> + + + + ); + + const erudaStr = process.env["LEMMY_UI_DEBUG"] ? renderToString(eruda) : ""; + + 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()} + + + + + + +
${root}
+ + + +`; +} diff --git a/src/shared/components/app/app.tsx b/src/shared/components/app/app.tsx index 624d6e52..9e6e9bdf 100644 --- a/src/shared/components/app/app.tsx +++ b/src/shared/components/app/app.tsx @@ -1,36 +1,57 @@ import { Component } from "inferno"; import { Provider } from "inferno-i18next-dess"; import { Route, Switch } from "inferno-router"; +import { IsoDataOptionalSite } from "shared/interfaces"; import { i18n } from "../../i18next"; import { routes } from "../../routes"; -import { setIsoData } from "../../utils"; +import { isAuthPath, setIsoData } from "../../utils"; +import AuthGuard from "../common/auth-guard"; +import ErrorGuard from "../common/error-guard"; +import { ErrorPage } from "./error-page"; import { Footer } from "./footer"; import { Navbar } from "./navbar"; -import { NoMatch } from "./no-match"; import "./styles.scss"; import { Theme } from "./theme"; export class App extends Component { - private isoData = setIsoData(this.context); + private isoData: IsoDataOptionalSite = setIsoData(this.context); constructor(props: any, context: any) { super(props, context); } render() { - let siteRes = this.isoData.site_res; - let siteView = siteRes.site_view; + const siteRes = this.isoData.site_res; + const siteView = siteRes?.site_view; return ( <>
- + {siteView && ( + + )}
- {routes.map(({ path, component }) => ( - + {routes.map(({ path, component: RouteComponent }) => ( + ( + + {RouteComponent && + (isAuthPath(path ?? "") ? ( + + + + ) : ( + + ))} + + )} + /> ))} - +
diff --git a/src/shared/components/app/error-page.tsx b/src/shared/components/app/error-page.tsx new file mode 100644 index 00000000..e19323f9 --- /dev/null +++ b/src/shared/components/app/error-page.tsx @@ -0,0 +1,69 @@ +import { Component } from "inferno"; +import { T } from "inferno-i18next-dess"; +import { Link } from "inferno-router"; +import { i18n } from "../../i18next"; +import { IsoDataOptionalSite } from "../../interfaces"; +import { setIsoData } from "../../utils"; + +export class ErrorPage extends Component { + private isoData: IsoDataOptionalSite = setIsoData(this.context); + + constructor(props: any, context: any) { + super(props, context); + } + + render() { + const { errorPageData } = this.isoData; + + return ( +
+

+ {errorPageData + ? i18n.t("error_page_title") + : i18n.t("not_found_page_title")} +

+ {errorPageData ? ( + + ### + ## + + ) : ( +

{i18n.t("not_found_page_message")}

+ )} + {!errorPageData && ( + + {i18n.t("not_found_return_home_button")} + + )} + {errorPageData?.adminMatrixIds && + errorPageData.adminMatrixIds.length > 0 && ( + <> +
+ {i18n.t("error_page_admin_matrix", { + instance: + this.isoData.site_res?.site_view.site.name ?? + "this instance", + })} +
+
    + {errorPageData.adminMatrixIds.map(matrixId => ( +
  • + {matrixId} +
  • + ))} +
+ + )} + {errorPageData?.error && ( + + ### + + )} +
+ ); + } +} diff --git a/src/shared/components/app/footer.tsx b/src/shared/components/app/footer.tsx index bbe49982..bd66165e 100644 --- a/src/shared/components/app/footer.tsx +++ b/src/shared/components/app/footer.tsx @@ -6,7 +6,7 @@ import { docsUrl, joinLemmyUrl, repoUrl } from "../../utils"; import { VERSION } from "../../version"; interface FooterProps { - site: GetSiteResponse; + site?: GetSiteResponse; } export class Footer extends Component { @@ -19,27 +19,27 @@ export class Footer extends Component {
); @@ -443,6 +464,23 @@ export class Navbar extends Component { return amAdmin() || moderatesS; } + handleToggleExpandNavbar(i: Navbar) { + i.setState({ expanded: !i.state.expanded }); + } + + handleHideExpandNavbar(i: Navbar) { + i.setState({ expanded: false, showDropdown: false }); + } + + handleLogoutClick(i: Navbar) { + i.setState({ showDropdown: false, expanded: false }); + UserService.Instance.logout(); + } + + handleToggleDropdown(i: Navbar) { + i.setState({ showDropdown: !i.state.showDropdown }); + } + parseMessage(msg: any) { let op = wsUserOp(msg); console.log(msg); diff --git a/src/shared/components/app/no-match.tsx b/src/shared/components/app/no-match.tsx deleted file mode 100644 index 6781e351..00000000 --- a/src/shared/components/app/no-match.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { NoOptionI18nKeys } from "i18next"; -import { Component } from "inferno"; -import { i18n } from "../../i18next"; - -export class NoMatch extends Component { - private errCode = new URLSearchParams(this.props.location.search).get( - "err" - ) as NoOptionI18nKeys; - - constructor(props: any, context: any) { - super(props, context); - } - - render() { - return ( -
-

404

- {this.errCode && ( -

- {i18n.t("code")}: {i18n.t(this.errCode)} -

- )} -
- ); - } -} diff --git a/src/shared/components/common/auth-guard.tsx b/src/shared/components/common/auth-guard.tsx new file mode 100644 index 00000000..e79a541e --- /dev/null +++ b/src/shared/components/common/auth-guard.tsx @@ -0,0 +1,13 @@ +import { InfernoNode } from "inferno"; +import { Redirect } from "inferno-router"; +import { UserService } from "../../services"; + +function AuthGuard(props: { children?: InfernoNode }) { + if (!UserService.Instance.myUserInfo) { + return ; + } else { + return props.children; + } +} + +export default AuthGuard; diff --git a/src/shared/components/common/error-guard.tsx b/src/shared/components/common/error-guard.tsx new file mode 100644 index 00000000..30121541 --- /dev/null +++ b/src/shared/components/common/error-guard.tsx @@ -0,0 +1,24 @@ +import { Component } from "inferno"; +import { setIsoData } from "../../utils"; +import { ErrorPage } from "../app/error-page"; + +class ErrorGuard extends Component { + private isoData = setIsoData(this.context); + + constructor(props: any, context: any) { + super(props, context); + } + + render() { + const errorPageData = this.isoData.errorPageData; + const siteRes = this.isoData.site_res; + + if (errorPageData || !siteRes) { + return ; + } else { + return this.props.children; + } + } +} + +export default ErrorGuard; diff --git a/src/shared/components/community/create-community.tsx b/src/shared/components/community/create-community.tsx index 81c62531..36503568 100644 --- a/src/shared/components/community/create-community.tsx +++ b/src/shared/components/community/create-community.tsx @@ -1,9 +1,7 @@ import { Component } from "inferno"; -import { Redirect } from "inferno-router"; import { CommunityView, GetSiteResponse } from "lemmy-js-client"; import { Subscription } from "rxjs"; import { i18n } from "../../i18next"; -import { UserService } from "../../services/UserService"; import { enableNsfw, isBrowser, @@ -50,7 +48,6 @@ export class CreateCommunity extends Component { render() { return (
- {!UserService.Instance.myUserInfo && } { this.handleSortChange = this.handleSortChange.bind(this); this.handlePageChange = this.handlePageChange.bind(this); - if (!UserService.Instance.myUserInfo && isBrowser()) { - toast(i18n.t("not_logged_in"), "danger"); - this.context.router.history.push(`/login`); - } - this.parseMessage = this.parseMessage.bind(this); this.subscription = wsSubscribe(this.parseMessage); diff --git a/src/shared/components/person/registration-applications.tsx b/src/shared/components/person/registration-applications.tsx index 18167412..39b65900 100644 --- a/src/shared/components/person/registration-applications.tsx +++ b/src/shared/components/person/registration-applications.tsx @@ -59,11 +59,6 @@ export class RegistrationApplications extends Component< this.handlePageChange = this.handlePageChange.bind(this); - if (!UserService.Instance.myUserInfo && isBrowser()) { - toast(i18n.t("not_logged_in"), "danger"); - this.context.router.history.push(`/login`); - } - this.parseMessage = this.parseMessage.bind(this); this.subscription = wsSubscribe(this.parseMessage); diff --git a/src/shared/components/person/reports.tsx b/src/shared/components/person/reports.tsx index 0af56b53..3c00f545 100644 --- a/src/shared/components/person/reports.tsx +++ b/src/shared/components/person/reports.tsx @@ -96,11 +96,6 @@ export class Reports extends Component { this.handlePageChange = this.handlePageChange.bind(this); - if (!UserService.Instance.myUserInfo && isBrowser()) { - toast(i18n.t("not_logged_in"), "danger"); - this.context.router.history.push(`/login`); - } - this.parseMessage = this.parseMessage.bind(this); this.subscription = wsSubscribe(this.parseMessage); diff --git a/src/shared/components/person/settings.tsx b/src/shared/components/person/settings.tsx index 95b25901..477cd6d8 100644 --- a/src/shared/components/person/settings.tsx +++ b/src/shared/components/person/settings.tsx @@ -1231,9 +1231,6 @@ export class Settings extends Component { toast(i18n.t(msg.error), "danger"); return; } else if (op == UserOperation.SaveUserSettings) { - let data = wsJsonToRes(msg); - UserService.Instance.login(data); - location.reload(); this.setState({ saveUserSettingsLoading: false }); toast(i18n.t("saved")); window.scrollTo(0, 0); diff --git a/src/shared/components/post/create-post.tsx b/src/shared/components/post/create-post.tsx index 2d0ba7e0..d8a2ccef 100644 --- a/src/shared/components/post/create-post.tsx +++ b/src/shared/components/post/create-post.tsx @@ -1,5 +1,4 @@ import { Component } from "inferno"; -import { Redirect } from "inferno-router"; import { RouteComponentProps } from "inferno-router/dist/Route"; import { GetCommunity, @@ -13,7 +12,7 @@ import { import { Subscription } from "rxjs"; import { InitialFetchRequest, PostFormParams } from "shared/interfaces"; import { i18n } from "../../i18next"; -import { UserService, WebSocketService } from "../../services"; +import { WebSocketService } from "../../services"; import { Choice, QueryParams, @@ -145,7 +144,6 @@ export class CreatePost extends Component< return (
- {!UserService.Instance.myUserInfo && } & + Pick>; + export interface ILemmyConfig { wsHost?: string; } diff --git a/src/shared/utils.ts b/src/shared/utils.ts index 2e18e2b8..5648df00 100644 --- a/src/shared/utils.ts +++ b/src/shared/utils.ts @@ -105,6 +105,11 @@ export type ThemeColor = | "gray" | "gray-dark"; +export interface ErrorPageData { + error?: string; + adminMatrixIds?: string[]; +} + let customEmojis: EmojiMartCategory[] = []; export let customEmojisLookup: Map = new Map< string, @@ -1260,16 +1265,7 @@ export function isBrowser() { export function setIsoData(context: any): IsoData { // If its the browser, you need to deserialize the data from the window if (isBrowser()) { - let json = window.isoData; - let routeData = json.routeData; - let site_res = json.site_res; - - let isoData: IsoData = { - path: json.path, - site_res, - routeData, - }; - return isoData; + return window.isoData; } else return context.router.staticContext; } @@ -1391,10 +1387,12 @@ export function personSelectName({ return local ? pName : `${hostname(actor_id)}/${pName}`; } -export function initializeSite(site: GetSiteResponse) { - UserService.Instance.myUserInfo = site.my_user; +export function initializeSite(site?: GetSiteResponse) { + UserService.Instance.myUserInfo = site?.my_user; i18n.changeLanguage(getLanguages()[0]); - setupEmojiDataModel(site.custom_emojis); + if (site) { + setupEmojiDataModel(site.custom_emojis); + } setupMarkdown(); } diff --git a/yarn.lock b/yarn.lock index 9432cde1..7bd1fe2a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5553,7 +5553,7 @@ lodash@^3.10.1: resolved "https://registry.yarnpkg.com/lodash/-/lodash-3.10.1.tgz#5bf45e8e49ba4189e17d482789dfd15bd140b7b6" integrity sha512-9mDDwqVIma6OZX79ZlDACZl8sBm0TEnkf99zV3iMA4GzkIT/9hiqP5mY0HoT1iNLCrKc/R1HByV+yJfRWVJryQ== -lodash@^4.17.20: +lodash@^4.17.20, lodash@^4.17.21: version "4.17.21" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==