diff --git a/Dockerfile b/Dockerfile index 92b3f7e6..00baae14 100644 --- a/Dockerfile +++ b/Dockerfile @@ -42,6 +42,9 @@ FROM node:alpine as runner COPY --from=builder /usr/src/app/dist /app/dist COPY --from=builder /usr/src/app/node_modules /app/node_modules +RUN chown -R node:node /app + +USER node EXPOSE 1234 WORKDIR /app CMD node dist/js/server.js diff --git a/lemmy-translations b/lemmy-translations index 713ceed9..a1a19aea 160000 --- a/lemmy-translations +++ b/lemmy-translations @@ -1 +1 @@ -Subproject commit 713ceed9c7ef84deaa222e68361e670e0763cd83 +Subproject commit a1a19aea1ad7d91195775a5ccea62ccc9076a2c7 diff --git a/package.json b/package.json index bcdb20c6..5c932a96 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lemmy-ui", - "version": "0.18.1", + "version": "0.18.2-rc.1", "description": "An isomorphic UI for lemmy", "repository": "https://github.com/LemmyNet/lemmy-ui", "license": "AGPL-3.0", @@ -69,7 +69,6 @@ "jwt-decode": "^3.1.2", "lemmy-js-client": "0.18.1", "lodash.isequal": "^4.5.0", - "lodash.merge": "^4.6.2", "markdown-it": "^13.0.1", "markdown-it-container": "^3.0.0", "markdown-it-emoji": "^2.0.2", diff --git a/src/assets/symbols.svg b/src/assets/symbols.svg index 72214eaf..6e9c6ef9 100644 --- a/src/assets/symbols.svg +++ b/src/assets/symbols.svg @@ -258,5 +258,12 @@ + + + + + + + diff --git a/src/server/handlers/catch-all-handler.tsx b/src/server/handlers/catch-all-handler.tsx index 4b011045..06d38f31 100644 --- a/src/server/handlers/catch-all-handler.tsx +++ b/src/server/handlers/catch-all-handler.tsx @@ -120,7 +120,7 @@ export default async (req: Request, res: Response) => { const root = renderToString(wrapper); - res.send(await createSsrHtml(root, isoData)); + res.send(await createSsrHtml(root, isoData, res.locals.cspNonce)); } catch (err) { // If an error is caught here, the error page couldn't even be rendered console.error(err); diff --git a/src/server/handlers/robots-handler.ts b/src/server/handlers/robots-handler.ts index 80678aa0..f26ddc92 100644 --- a/src/server/handlers/robots-handler.ts +++ b/src/server/handlers/robots-handler.ts @@ -15,5 +15,6 @@ export default async ({ res }: { res: Response }) => { Disallow: /admin Disallow: /password_change Disallow: /search/ + Disallow: /modlog `); }; diff --git a/src/server/index.tsx b/src/server/index.tsx index 458d7f03..3b9352bf 100644 --- a/src/server/index.tsx +++ b/src/server/index.tsx @@ -29,7 +29,11 @@ server.use( ); server.use(setCacheControl); -if (!process.env["LEMMY_UI_DISABLE_CSP"] && !process.env["LEMMY_UI_DEBUG"]) { +if ( + !process.env["LEMMY_UI_DISABLE_CSP"] && + !process.env["LEMMY_UI_DEBUG"] && + process.env["NODE_ENV"] !== "development" +) { server.use(setDefaultCsp); } diff --git a/src/server/middleware.ts b/src/server/middleware.ts index 0420e47e..a75d49ed 100644 --- a/src/server/middleware.ts +++ b/src/server/middleware.ts @@ -1,3 +1,4 @@ +import * as crypto from "crypto"; import type { NextFunction, Request, Response } from "express"; import { hasJwtCookie } from "./utils/has-jwt-cookie"; @@ -8,9 +9,20 @@ export function setDefaultCsp({ res: Response; next: NextFunction; }) { + res.locals.cspNonce = crypto.randomBytes(16).toString("hex"); + 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 * data:` + `default-src 'self'; + manifest-src *; + connect-src *; + img-src * data:; + script-src 'self' 'nonce-${res.locals.cspNonce}'; + style-src 'self' 'unsafe-inline'; + form-action 'self'; + base-uri 'self'; + frame-src *; + media-src * data:`.replace(/\s+/g, " ") ); next(); diff --git a/src/server/utils/create-ssr-html.tsx b/src/server/utils/create-ssr-html.tsx index ba85228f..71fdb68f 100644 --- a/src/server/utils/create-ssr-html.tsx +++ b/src/server/utils/create-ssr-html.tsx @@ -4,7 +4,7 @@ import { renderToString } from "inferno-server"; import serialize from "serialize-javascript"; import sharp from "sharp"; import { favIconPngUrl, favIconUrl } from "../../shared/config"; -import { ILemmyConfig, IsoDataOptionalSite } from "../../shared/interfaces"; +import { IsoDataOptionalSite } from "../../shared/interfaces"; import { buildThemeList } from "./build-themes-list"; import { fetchIconPng } from "./fetch-icon-png"; @@ -14,7 +14,8 @@ let appleTouchIcon: string | undefined = undefined; export async function createSsrHtml( root: string, - isoData: IsoDataOptionalSite + isoData: IsoDataOptionalSite, + cspNonce: string ) { const site = isoData.site_res; @@ -22,6 +23,12 @@ export async function createSsrHtml( (await buildThemeList())[0] }.css" />`; + const customHtmlHeaderScriptTag = new RegExp(" - - + + ) : ""; const helmet = Helmet.renderStatic(); - const config: ILemmyConfig = { wsHost: process.env.LEMMY_UI_LEMMY_WS_HOST }; - return ` - - + ${erudaStr} - ${customHtmlHeader} + ${customHtmlHeaderWithNonce} ${helmet.title.toString()} ${helmet.meta.toString()} diff --git a/src/server/utils/generate-manifest-json.ts b/src/server/utils/generate-manifest-json.ts index 2f9d8b80..89e106eb 100644 --- a/src/server/utils/generate-manifest-json.ts +++ b/src/server/utils/generate-manifest-json.ts @@ -1,4 +1,3 @@ -import { getHttpBaseExternal } from "@utils/env"; import { readFile } from "fs/promises"; import { GetSiteResponse } from "lemmy-js-client"; import path from "path"; @@ -21,15 +20,13 @@ export default async function ({ local_site: { community_creation_admin_only }, }, }: GetSiteResponse) { - const url = getHttpBaseExternal(); - const icon = site.icon ? await fetchIconPng(site.icon) : null; return { name: site.name, description: site.description ?? "A link aggregator for the fediverse", - start_url: url, - scope: url, + start_url: "/", + scope: "/", display: "standalone", id: "/", background_color: "#222222", diff --git a/src/shared/components/app/theme.tsx b/src/shared/components/app/theme.tsx index 941eea2c..93f6aed3 100644 --- a/src/shared/components/app/theme.tsx +++ b/src/shared/components/app/theme.tsx @@ -21,7 +21,10 @@ export class Theme extends Component { /> ); - } else if (this.props.defaultTheme != "browser") { + } else if ( + this.props.defaultTheme != "browser" && + this.props.defaultTheme != "browser-compact" + ) { return ( { /> ); + } else if (this.props.defaultTheme == "browser-compact") { + return ( + + + + + ); } else { return ( diff --git a/src/shared/components/comment/comment-node.tsx b/src/shared/components/comment/comment-node.tsx index 6c7d5c00..7b7f29e5 100644 --- a/src/shared/components/comment/comment-node.tsx +++ b/src/shared/components/comment/comment-node.tsx @@ -114,7 +114,7 @@ interface CommentNodeProps { moderators?: CommunityModeratorView[]; admins?: PersonView[]; noBorder?: boolean; - noIndent?: boolean; + isTopLevel?: boolean; viewOnly?: boolean; locked?: boolean; markable?: boolean; @@ -292,11 +292,7 @@ export class CommentNode extends Component { mark: this.isCommentNew || this.commentView.comment.distinguished, })} > -
+
+
+ {showStrength && value && ( +
+ {I18NextService.i18n.t( + this.passwordStrength as NoOptionI18nKeys + )} +
+ )} + {showForgotLink && ( + + {I18NextService.i18n.t("forgot_password")} + + )} +
+
+ + ); + } + + get passwordStrength(): string | undefined { + const password = this.props.value; + return password + ? passwordStrength(password, passwordStrengthOptions).value + : undefined; + } + + get passwordColorClass(): string { + const strength = this.passwordStrength; + + if (strength && ["weak", "medium"].includes(strength)) { + return "text-warning"; + } else if (strength == "strong") { + return "text-success"; + } else { + return "text-danger"; + } + } +} + +export default PasswordInput; diff --git a/src/shared/components/community/communities.tsx b/src/shared/components/community/communities.tsx index 7510bb1a..4ab1bfec 100644 --- a/src/shared/components/community/communities.tsx +++ b/src/shared/components/community/communities.tsx @@ -20,6 +20,7 @@ import { ListCommunities, ListCommunitiesResponse, ListingType, + SortType, } from "lemmy-js-client"; import { InitialFetchRequest } from "../../interfaces"; import { FirstLoadService, I18NextService } from "../../services"; @@ -28,6 +29,7 @@ import { HtmlTags } from "../common/html-tags"; import { Spinner } from "../common/icon"; import { ListingTypeSelect } from "../common/listing-type-select"; import { Paginator } from "../common/paginator"; +import { SortSelect } from "../common/sort-select"; import { CommunityLink } from "./community-link"; const communityLimit = 50; @@ -45,6 +47,7 @@ interface CommunitiesState { interface CommunitiesProps { listingType: ListingType; + sort: SortType; page: number; } @@ -52,6 +55,10 @@ function getListingTypeFromQuery(listingType?: string): ListingType { return listingType ? (listingType as ListingType) : "Local"; } +function getSortTypeFromQuery(type?: string): SortType { + return type ? (type as SortType) : "TopMonth"; +} + export class Communities extends Component { private isoData = setIsoData(this.context); state: CommunitiesState = { @@ -64,6 +71,7 @@ export class Communities extends Component { constructor(props: any, context: any) { super(props, context); this.handlePageChange = this.handlePageChange.bind(this); + this.handleSortChange = this.handleSortChange.bind(this); this.handleListingTypeChange = this.handleListingTypeChange.bind(this); // Only fetch the data if coming from another route @@ -99,13 +107,13 @@ export class Communities extends Component { ); case "success": { - const { listingType, page } = this.getCommunitiesQueryParams(); + const { listingType, sort, page } = this.getCommunitiesQueryParams(); return (

{I18NextService.i18n.t("list_of_communities")}

-
+
{ onChange={this.handleListingTypeChange} />
+
+ +
{this.searchForm()}
@@ -224,10 +235,7 @@ export class Communities extends Component { searchForm() { return ( -
+
{ ); } - async updateUrl({ listingType, page }: Partial) { - const { listingType: urlListingType, page: urlPage } = - this.getCommunitiesQueryParams(); + async updateUrl({ listingType, sort, page }: Partial) { + const { + listingType: urlListingType, + sort: urlSort, + page: urlPage, + } = this.getCommunitiesQueryParams(); const queryParams: QueryParams = { listingType: listingType ?? urlListingType, + sort: sort ?? urlSort, page: (page ?? urlPage)?.toString(), }; @@ -270,6 +282,10 @@ export class Communities extends Component { this.updateUrl({ page }); } + handleSortChange(val: SortType) { + this.updateUrl({ sort: val, page: 1 }); + } + handleListingTypeChange(val: ListingType) { this.updateUrl({ listingType: val, @@ -290,7 +306,7 @@ export class Communities extends Component { } static async fetchInitialData({ - query: { listingType, page }, + query: { listingType, sort, page }, client, auth, }: InitialFetchRequest< @@ -298,7 +314,7 @@ export class Communities extends Component { >): Promise { const listCommunitiesForm: ListCommunities = { type_: getListingTypeFromQuery(listingType), - sort: "TopMonth", + sort: getSortTypeFromQuery(sort), limit: communityLimit, page: getPageFromString(page), auth: auth, @@ -314,6 +330,7 @@ export class Communities extends Component { getCommunitiesQueryParams() { return getQueryParams({ listingType: getListingTypeFromQuery, + sort: getSortTypeFromQuery, page: getPageFromString, }); } @@ -334,12 +351,12 @@ export class Communities extends Component { async refetch() { this.setState({ listCommunitiesResponse: { state: "loading" } }); - const { listingType, page } = this.getCommunitiesQueryParams(); + const { listingType, sort, page } = this.getCommunitiesQueryParams(); this.setState({ listCommunitiesResponse: await HttpService.client.listCommunities({ type_: listingType, - sort: "TopMonth", + sort: sort, limit: communityLimit, page, auth: myAuth(), diff --git a/src/shared/components/community/community.tsx b/src/shared/components/community/community.tsx index c00380ab..f89eb832 100644 --- a/src/shared/components/community/community.tsx +++ b/src/shared/components/community/community.tsx @@ -312,6 +312,7 @@ export class Community extends Component< @@ -447,7 +448,7 @@ export class Community extends Component< nodes={commentsToFlatNodes(this.state.commentsRes.data.comments)} viewType={CommentViewType.Flat} finished={this.state.finished} - noIndent + isTopLevel showContext enableDownvotes={enableDownvotes(site_res)} moderators={communityRes.moderators} diff --git a/src/shared/components/community/sidebar.tsx b/src/shared/components/community/sidebar.tsx index 55acf674..bb800342 100644 --- a/src/shared/components/community/sidebar.tsx +++ b/src/shared/components/community/sidebar.tsx @@ -636,7 +636,7 @@ export class Sidebar extends Component { i.setState({ leaveModTeamLoading: true }); i.props.onLeaveModTeam({ community_id: i.props.community_view.community.id, - person_id: 92, + person_id: myId, added: false, auth: myAuthRequired(), }); diff --git a/src/shared/components/home/home.tsx b/src/shared/components/home/home.tsx index 741dfa57..5d949f3e 100644 --- a/src/shared/components/home/home.tsx +++ b/src/shared/components/home/home.tsx @@ -718,7 +718,7 @@ export class Home extends Component { nodes={commentsToFlatNodes(comments)} viewType={CommentViewType.Flat} finished={this.state.finished} - noIndent + isTopLevel showCommunity showContext enableDownvotes={enableDownvotes(siteRes)} diff --git a/src/shared/components/home/login.tsx b/src/shared/components/home/login.tsx index 2610ff80..230a62ed 100644 --- a/src/shared/components/home/login.tsx +++ b/src/shared/components/home/login.tsx @@ -2,7 +2,6 @@ import { myAuth, setIsoData } from "@utils/app"; import { isBrowser } from "@utils/browser"; import { Location } from "history"; import { Component, linkEvent } from "inferno"; -import { NavLink } from "inferno-router"; import { RouteComponentProps } from "inferno-router/dist/Route"; import { GetSiteResponse, LoginResponse } from "lemmy-js-client"; import { I18NextService, UserService } from "../../services"; @@ -10,6 +9,7 @@ import { HttpService, RequestState } from "../../services/HttpService"; import { toast } from "../../toast"; import { HtmlTags } from "../common/html-tags"; import { Spinner } from "../common/icon"; +import PasswordInput from "../common/password-input"; interface State { loginRes: RequestState; @@ -163,28 +163,14 @@ export class Login extends Component< />
-
- -
- - - {I18NextService.i18n.t("forgot_password")} - -
+
+
{this.state.showTotp && (
@@ -223,4 +209,67 @@ export class Login extends Component<
); } + + async handleLoginSubmit(i: Login, event: any) { + event.preventDefault(); + const { password, totp_2fa_token, username_or_email } = i.state.form; + + if (username_or_email && password) { + i.setState({ loginRes: { state: "loading" } }); + + const loginRes = await HttpService.client.login({ + username_or_email, + password, + totp_2fa_token, + }); + switch (loginRes.state) { + case "failed": { + if (loginRes.msg === "missing_totp_token") { + i.setState({ showTotp: true }); + toast(I18NextService.i18n.t("enter_two_factor_code"), "info"); + } + if (loginRes.msg === "incorrect_login") { + toast(I18NextService.i18n.t("incorrect_login"), "danger"); + } + + i.setState({ loginRes: { state: "failed", msg: loginRes.msg } }); + break; + } + + case "success": { + UserService.Instance.login({ + res: loginRes.data, + }); + const site = await HttpService.client.getSite({ + auth: myAuth(), + }); + + if (site.state === "success") { + UserService.Instance.myUserInfo = site.data.my_user; + } + + i.props.history.action === "PUSH" + ? i.props.history.back() + : i.props.history.replace("/"); + + break; + } + } + } + } + + handleLoginUsernameChange(i: Login, event: any) { + i.state.form.username_or_email = event.target.value.trim(); + i.setState(i.state); + } + + handleLoginTotpChange(i: Login, event: any) { + i.state.form.totp_2fa_token = event.target.value; + i.setState(i.state); + } + + handleLoginPasswordChange(i: Login, event: any) { + i.state.form.password = event.target.value; + i.setState(i.state); + } } diff --git a/src/shared/components/home/setup.tsx b/src/shared/components/home/setup.tsx index f4bdb555..7b3d4c27 100644 --- a/src/shared/components/home/setup.tsx +++ b/src/shared/components/home/setup.tsx @@ -10,6 +10,7 @@ import { import { I18NextService, UserService } from "../../services"; import { HttpService, RequestState } from "../../services/HttpService"; import { Spinner } from "../common/icon"; +import PasswordInput from "../common/password-input"; import { SiteForm } from "./site-form"; interface State { @@ -121,41 +122,21 @@ export class Setup extends Component { />
-
- -
- -
+
+
-
- -
- -
+
+
diff --git a/src/shared/components/home/signup.tsx b/src/shared/components/home/signup.tsx index bb1e1f11..c57d545a 100644 --- a/src/shared/components/home/signup.tsx +++ b/src/shared/components/home/signup.tsx @@ -1,8 +1,6 @@ import { myAuth, setIsoData } from "@utils/app"; import { isBrowser } from "@utils/browser"; import { validEmail } from "@utils/helpers"; -import { Options, passwordStrength } from "check-password-strength"; -import { NoOptionI18nKeys } from "i18next"; import { Component, linkEvent } from "inferno"; import { T } from "inferno-i18next-dess"; import { @@ -20,33 +18,7 @@ import { toast } from "../../toast"; import { HtmlTags } from "../common/html-tags"; import { Icon, Spinner } from "../common/icon"; import { MarkdownTextArea } from "../common/markdown-textarea"; - -const passwordStrengthOptions: Options = [ - { - id: 0, - value: "very_weak", - minDiversity: 0, - minLength: 0, - }, - { - id: 1, - value: "weak", - minDiversity: 2, - minLength: 10, - }, - { - id: 2, - value: "medium", - minDiversity: 3, - minLength: 12, - }, - { - id: 3, - value: "strong", - minDiversity: 4, - minLength: 14, - }, -]; +import PasswordInput from "../common/password-input"; interface State { registerRes: RequestState; @@ -210,57 +182,26 @@ export class Signup extends Component {
-
- -
- - {this.state.form.password && ( -
- {I18NextService.i18n.t( - this.passwordStrength as NoOptionI18nKeys - )} -
- )} -
+
+
-
- -
- -
+
+
- {siteView.local_site.registration_mode == "RequireApplication" && ( + {siteView.local_site.registration_mode === "RequireApplication" && ( <>
@@ -411,25 +352,6 @@ export class Signup extends Component { ); } - get passwordStrength(): string | undefined { - const password = this.state.form.password; - return password - ? passwordStrength(password, passwordStrengthOptions).value - : undefined; - } - - get passwordColorClass(): string { - const strength = this.passwordStrength; - - if (strength && ["weak", "medium"].includes(strength)) { - return "text-warning"; - } else if (strength == "strong") { - return "text-success"; - } else { - return "text-danger"; - } - } - async handleRegisterSubmit(i: Signup, event: any) { event.preventDefault(); const { diff --git a/src/shared/components/home/site-form.tsx b/src/shared/components/home/site-form.tsx index f5d25688..25e02c86 100644 --- a/src/shared/components/home/site-form.tsx +++ b/src/shared/components/home/site-form.tsx @@ -411,6 +411,9 @@ export class SiteForm extends Component { + {this.props.themeList?.map(theme => (