diff options
author | 2024-05-05 13:47:22 +0200 | |
---|---|---|
committer | 2024-05-05 11:47:22 +0000 | |
commit | 6171dcbe5109d7accbf44f19c20c9f4a0ee5e06f (patch) | |
tree | 9011f0050571f5a8c1c0e7bd90b74b2816dadd8a /web/source/settings | |
parent | [frontend] Do optimistic update when approving/rejecting/suspending account (... (diff) | |
download | gotosocial-6171dcbe5109d7accbf44f19c20c9f4a0ee5e06f.tar.xz |
[feature] Add HTTP header permission section to frontend (#2893)
* [feature] Add HTTP header filter section to frontend
* tweak naming a bit
Diffstat (limited to 'web/source/settings')
19 files changed, 954 insertions, 58 deletions
diff --git a/web/source/settings/components/authorization/index.tsx b/web/source/settings/components/authorization/index.tsx index 22a0d24b7..e8f4d6673 100644 --- a/web/source/settings/components/authorization/index.tsx +++ b/web/source/settings/components/authorization/index.tsx @@ -17,10 +17,9 @@ along with this program. If not, see <http://www.gnu.org/licenses/>. */ -import { useVerifyCredentialsQuery } from "../../lib/query/oauth"; +import { useLogoutMutation, useVerifyCredentialsQuery } from "../../lib/query/oauth"; import { store } from "../../redux/store"; - -import React from "react"; +import React, { ReactNode } from "react"; import Login from "./login"; import Loading from "../loading"; @@ -30,18 +29,20 @@ import { NoArg } from "../../lib/types/query"; export function Authorization({ App }) { const { loginState, expectingRedirect } = store.getState().oauth; const skip = (loginState == "none" || loginState == "logout" || expectingRedirect); + const [ logoutQuery ] = useLogoutMutation(); const { isLoading, + isFetching, isSuccess, data: account, error, } = useVerifyCredentialsQuery(NoArg, { skip: skip }); let showLogin = true; - let content: React.JSX.Element | null = null; + let content: ReactNode; - if (isLoading) { + if (isLoading || isFetching) { showLogin = false; let loadingInfo = ""; @@ -56,7 +57,15 @@ export function Authorization({ App }) { <Loading /> {loadingInfo} </div> ); - } else if (error != undefined) { + } else if (error !== undefined) { + if ("status" in error && error.status === 401) { + // 401 unauthorized was received. + // That means the token or app we + // were using is no longer valid, + // so just log the user out. + logoutQuery(NoArg); + } + content = ( <div> <Error error={error} /> diff --git a/web/source/settings/components/form/inputs.tsx b/web/source/settings/components/form/inputs.tsx index 1e0d8eaab..f82937fc1 100644 --- a/web/source/settings/components/form/inputs.tsx +++ b/web/source/settings/components/form/inputs.tsx @@ -34,7 +34,7 @@ export interface TextInputProps extends React.DetailedHTMLProps< React.InputHTMLAttributes<HTMLInputElement>, HTMLInputElement > { - label?: string; + label?: ReactNode; field: TextFormInputHook; } @@ -60,7 +60,7 @@ export interface TextAreaProps extends React.DetailedHTMLProps< React.TextareaHTMLAttributes<HTMLTextAreaElement>, HTMLTextAreaElement > { - label?: string; + label?: ReactNode; field: TextFormInputHook; } @@ -86,7 +86,7 @@ export interface FileInputProps extends React.DetailedHTMLProps< React.InputHTMLAttributes<HTMLInputElement>, HTMLInputElement > { - label?: string; + label?: ReactNode; field: FileFormInputHook; } @@ -133,7 +133,7 @@ export interface SelectProps extends React.DetailedHTMLProps< React.SelectHTMLAttributes<HTMLSelectElement>, HTMLSelectElement > { - label?: string; + label?: ReactNode; field: TextFormInputHook; children?: ReactNode; options: React.JSX.Element; @@ -164,7 +164,7 @@ export interface RadioGroupProps extends React.DetailedHTMLProps< React.InputHTMLAttributes<HTMLInputElement>, HTMLInputElement > { - label?: string; + label?: ReactNode; field: RadioFormInputHook; } diff --git a/web/source/settings/components/pageable-list.tsx b/web/source/settings/components/pageable-list.tsx index 918103ead..3571fb1cd 100644 --- a/web/source/settings/components/pageable-list.tsx +++ b/web/source/settings/components/pageable-list.tsx @@ -33,7 +33,7 @@ export interface PageableListProps<T> { isFetching: boolean; isError: boolean; error: FetchBaseQueryError | SerializedError | undefined; - emptyMessage: string; + emptyMessage: ReactNode; prevNextLinks?: Links | null | undefined; } diff --git a/web/source/settings/lib/query/admin/http-header-permissions/index.ts b/web/source/settings/lib/query/admin/http-header-permissions/index.ts new file mode 100644 index 000000000..342b9eb56 --- /dev/null +++ b/web/source/settings/lib/query/admin/http-header-permissions/index.ts @@ -0,0 +1,159 @@ +/* + GoToSocial + Copyright (C) GoToSocial Authors admin@gotosocial.org + SPDX-License-Identifier: AGPL-3.0-or-later + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +import { gtsApi } from "../../gts-api"; +import { HeaderPermission } from "../../../types/http-header-permissions"; + +const extended = gtsApi.injectEndpoints({ + endpoints: (build) => ({ + + /* HTTP HEADER ALLOWS */ + + getHeaderAllows: build.query<HeaderPermission[], void>({ + query: () => ({ + url: `/api/v1/admin/header_allows` + }), + providesTags: (res) => + res + ? [ + ...res.map(({ id }) => ({ type: "HTTPHeaderAllows" as const, id })), + { type: "HTTPHeaderAllows", id: "LIST" }, + ] + : [{ type: "HTTPHeaderAllows", id: "LIST" }], + }), + + getHeaderAllow: build.query<HeaderPermission, string>({ + query: (id) => ({ + url: `/api/v1/admin/header_allows/${id}` + }), + providesTags: (_res, _error, id) => [{ type: "HTTPHeaderAllows", id }], + }), + + postHeaderAllow: build.mutation<HeaderPermission, { header: string, regex: string }>({ + query: (formData) => ({ + method: "POST", + url: `/api/v1/admin/header_allows`, + asForm: true, + body: formData, + discardEmpty: true + }), + invalidatesTags: [{ type: "HTTPHeaderAllows", id: "LIST" }], + }), + + deleteHeaderAllow: build.mutation<HeaderPermission, string>({ + query: (id) => ({ + method: "DELETE", + url: `/api/v1/admin/header_allows/${id}` + }), + invalidatesTags: (_res, _error, id) => [{ type: "HTTPHeaderAllows", id }], + }), + + /* HTTP HEADER BLOCKS */ + + getHeaderBlocks: build.query<HeaderPermission[], void>({ + query: () => ({ + url: `/api/v1/admin/header_blocks` + }), + providesTags: (res) => + res + ? [ + ...res.map(({ id }) => ({ type: "HTTPHeaderBlocks" as const, id })), + { type: "HTTPHeaderBlocks", id: "LIST" }, + ] + : [{ type: "HTTPHeaderBlocks", id: "LIST" }], + }), + + postHeaderBlock: build.mutation<HeaderPermission, { header: string, regex: string }>({ + query: (formData) => ({ + method: "POST", + url: `/api/v1/admin/header_blocks`, + asForm: true, + body: formData, + discardEmpty: true + }), + invalidatesTags: [{ type: "HTTPHeaderBlocks", id: "LIST" }], + }), + + getHeaderBlock: build.query<HeaderPermission, string>({ + query: (id) => ({ + url: `/api/v1/admin/header_blocks/${id}` + }), + providesTags: (_res, _error, id) => [{ type: "HTTPHeaderBlocks", id }], + }), + + deleteHeaderBlock: build.mutation<HeaderPermission, string>({ + query: (id) => ({ + method: "DELETE", + url: `/api/v1/admin/header_blocks/${id}` + }), + invalidatesTags: (_res, _error, id) => [{ type: "HTTPHeaderBlocks", id }], + }), + }), +}); + +/** + * Get admin view of all HTTP header allow regexes. + */ +const useGetHeaderAllowsQuery = extended.useGetHeaderAllowsQuery; + +/** + * Get admin view of one HTTP header allow regex. + */ +const useGetHeaderAllowQuery = extended.useGetHeaderAllowQuery; + +/** + * Create a new HTTP header allow regex. + */ +const usePostHeaderAllowMutation = extended.usePostHeaderAllowMutation; + +/** + * Delete one HTTP header allow regex. + */ +const useDeleteHeaderAllowMutation = extended.useDeleteHeaderAllowMutation; + +/** + * Get admin view of all HTTP header block regexes. + */ +const useGetHeaderBlocksQuery = extended.useGetHeaderBlocksQuery; + +/** + * Get admin view of one HTTP header block regex. + */ +const useGetHeaderBlockQuery = extended.useGetHeaderBlockQuery; + +/** + * Create a new HTTP header block regex. + */ +const usePostHeaderBlockMutation = extended.usePostHeaderBlockMutation; + +/** + * Delete one HTTP header block regex. + */ +const useDeleteHeaderBlockMutation = extended.useDeleteHeaderBlockMutation; + +export { + useGetHeaderAllowsQuery, + useGetHeaderAllowQuery, + usePostHeaderAllowMutation, + useDeleteHeaderAllowMutation, + useGetHeaderBlocksQuery, + useGetHeaderBlockQuery, + usePostHeaderBlockMutation, + useDeleteHeaderBlockMutation, +}; diff --git a/web/source/settings/lib/query/admin/index.ts b/web/source/settings/lib/query/admin/index.ts index fd3e1ca1b..3a095a8a1 100644 --- a/web/source/settings/lib/query/admin/index.ts +++ b/web/source/settings/lib/query/admin/index.ts @@ -217,6 +217,7 @@ export const { useMediaCleanupMutation, useInstanceKeysExpireMutation, useGetAccountQuery, + useLazyGetAccountQuery, useActionAccountMutation, useSearchAccountsQuery, useLazySearchAccountsQuery, diff --git a/web/source/settings/lib/query/gts-api.ts b/web/source/settings/lib/query/gts-api.ts index 6e5eafeab..ef994e655 100644 --- a/web/source/settings/lib/query/gts-api.ts +++ b/web/source/settings/lib/query/gts-api.ts @@ -139,6 +139,8 @@ export const gtsApi = createApi({ "Reports", "Account", "InstanceRules", + "HTTPHeaderAllows", + "HTTPHeaderBlocks", ], endpoints: (build) => ({ instanceV1: build.query<InstanceV1, void>({ diff --git a/web/source/settings/lib/types/domain-permission.ts b/web/source/settings/lib/types/domain-permission.ts index 93df883f2..ccf7c9c57 100644 --- a/web/source/settings/lib/types/domain-permission.ts +++ b/web/source/settings/lib/types/domain-permission.ts @@ -18,11 +18,10 @@ */ import typia from "typia"; +import { PermType } from "./perm"; export const validateDomainPerms = typia.createValidate<DomainPerm[]>(); -export type PermType = "block" | "allow"; - /** * A single domain permission entry (block or allow). */ diff --git a/web/source/settings/lib/types/http-header-permissions.ts b/web/source/settings/lib/types/http-header-permissions.ts new file mode 100644 index 000000000..7e3aade27 --- /dev/null +++ b/web/source/settings/lib/types/http-header-permissions.ts @@ -0,0 +1,48 @@ +/* + GoToSocial + Copyright (C) GoToSocial Authors admin@gotosocial.org + SPDX-License-Identifier: AGPL-3.0-or-later + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +export interface HeaderPermission { + /** + * ID of this entry. + */ + id: string; + + /** + * HTTP header key to match on. + */ + header: string; + + /** + * ISO8601 timestamp when + * this entry was created. + */ + created_at: string; + + /** + * ID of the account that + * created this entry. + */ + created_by: string; + + /** + * Regular expression to match + * on when allowing/blocking. + */ + regex: string; +} diff --git a/web/source/settings/lib/types/perm.ts b/web/source/settings/lib/types/perm.ts new file mode 100644 index 000000000..2f4073c90 --- /dev/null +++ b/web/source/settings/lib/types/perm.ts @@ -0,0 +1,20 @@ +/* + GoToSocial + Copyright (C) GoToSocial Authors admin@gotosocial.org + SPDX-License-Identifier: AGPL-3.0-or-later + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +export type PermType = "block" | "allow"; diff --git a/web/source/settings/style.css b/web/source/settings/style.css index 01263c224..b73978e33 100644 --- a/web/source/settings/style.css +++ b/web/source/settings/style.css @@ -60,7 +60,6 @@ ul li::before { & > form { display: flex; flex-direction: column; - gap: 0.5rem; margin: 1rem 0; h1, h2, h3, h4, h5 { @@ -1192,47 +1191,47 @@ button.with-padding { margin-bottom: 0; } - .info-list { - border: 0.1rem solid $gray1; + .action-buttons { display: flex; - flex-direction: column; + gap: 0.5rem; + align-items: center; + } +} - .info-list-entry { - background: $list-entry-bg; - border: 0.1rem solid transparent; - padding: 0.25rem; +.info-list { + border: 0.1rem solid $gray1; + display: flex; + flex-direction: column; - &:nth-child(even) { - background: $list-entry-alternate-bg; - } + .info-list-entry { + background: $list-entry-bg; + border: 0.1rem solid transparent; + padding: 0.25rem; - display: grid; - grid-template-columns: max(20%, 10rem) 1fr; - - dt { - font-weight: bold; - } + &:nth-child(even) { + background: $list-entry-alternate-bg; + } - dd { - word-break: break-word; - } + display: grid; + grid-template-columns: max(20%, 10rem) 1fr; + + dt { + font-weight: bold; + } - dt, dd { - /* - Make sure any fa icons used in keys - or values are properly aligned. - */ - .fa { - vertical-align: middle; - } - } + dd { + word-break: break-word; } - } - .action-buttons { - display: flex; - gap: 0.5rem; - align-items: center; + dt, dd { + /* + Make sure any fa icons used in keys + or values are properly aligned. + */ + .fa { + vertical-align: middle; + } + } } } @@ -1287,6 +1286,45 @@ button.with-padding { } } +.http-header-permissions { + .list { + /* + Space this page out a bit, it + looks too tight otherwise. + */ + margin: 1rem 0; + + /* + Visually separate the key + value for + each entry, and show the value in + reasonably-sized monospace font. + */ + .entries > .entry { + display: grid; + grid-template-columns: max(20%, 10rem) 1fr; + + dt { + font-weight: bold; + } + + dd { + font-family: monospace; + font-size: large; + } + } + } +} + +.http-header-permission-details { + .info-list { + margin-top: 1rem; + + > .info-list-entry > .monospace { + font-size: large; + } + } +} + @media screen and (orientation: portrait) { .reports .report .byline { grid-template-columns: 1fr; diff --git a/web/source/settings/views/admin/http-header-permissions/create.tsx b/web/source/settings/views/admin/http-header-permissions/create.tsx new file mode 100644 index 000000000..b791ae0a9 --- /dev/null +++ b/web/source/settings/views/admin/http-header-permissions/create.tsx @@ -0,0 +1,143 @@ +/* + GoToSocial + Copyright (C) GoToSocial Authors admin@gotosocial.org + SPDX-License-Identifier: AGPL-3.0-or-later + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +import React from "react"; +import { usePostHeaderAllowMutation, usePostHeaderBlockMutation } from "../../../lib/query/admin/http-header-permissions"; +import { useTextInput } from "../../../lib/form"; +import useFormSubmit from "../../../lib/form/submit"; +import { TextInput } from "../../../components/form/inputs"; +import MutationButton from "../../../components/form/mutation-button"; +import { PermType } from "../../../lib/types/perm"; + +export default function HeaderPermCreateForm({ permType }: { permType: PermType }) { + const form = { + header: useTextInput("header", { + validator: (val: string) => { + // Technically invalid but avoid + // showing red outline when user + // hasn't entered anything yet. + if (val.length === 0) { + return ""; + } + + // Only requirement is that header + // must be less than 1024 chars. + if (val.length > 1024) { + return "header must be less than 1024 characters"; + } + + return ""; + } + }), + regex: useTextInput("regex", { + validator: (val: string) => { + // Technically invalid but avoid + // showing red outline when user + // hasn't entered anything yet. + if (val.length === 0) { + return ""; + } + + // Ensure regex compiles. + try { + new RegExp(val); + } catch (e) { + return e; + } + + return ""; + } + }), + }; + + // Use appropriate mutation for given permType. + const [ postAllowTrigger, postAllowResult ] = usePostHeaderAllowMutation(); + const [ postBlockTrigger, postBlockResult ] = usePostHeaderBlockMutation(); + + let mutationTrigger; + let mutationResult; + + if (permType === "block") { + mutationTrigger = postBlockTrigger; + mutationResult = postBlockResult; + } else { + mutationTrigger = postAllowTrigger; + mutationResult = postAllowResult; + } + + const [formSubmit, result] = useFormSubmit( + form, + [mutationTrigger, mutationResult], + { + changedOnly: false, + onFinish: ({ _data }) => { + form.header.reset(); + form.regex.reset(); + }, + }); + + return ( + <form onSubmit={formSubmit}> + <h2>Create new HTTP header {permType}</h2> + <TextInput + field={form.header} + label={ + <> + HTTP Header Name + <a + href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers" + target="_blank" + className="docslink" + rel="noreferrer" + > + Learn more about HTTP request headers (opens in a new tab) + </a> + </> + } + placeholder={"User-Agent"} + /> + <TextInput + field={form.regex} + label={ + <> + HTTP Header Value Regex + <a + href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_expressions" + target="_blank" + className="docslink" + rel="noreferrer" + > + Learn more about regular expressions (opens in a new tab) + </a> + </> + } + placeholder={"^.*Some-User-Agent.*$"} + {...{className: "monospace"}} + /> + <MutationButton + label="Save" + result={result} + disabled={ + (!form.header.value || !form.regex.value) || + (!form.header.valid || !form.regex.valid) + } + /> + </form> + ); +} diff --git a/web/source/settings/views/admin/http-header-permissions/detail.tsx b/web/source/settings/views/admin/http-header-permissions/detail.tsx new file mode 100644 index 000000000..db92dd0eb --- /dev/null +++ b/web/source/settings/views/admin/http-header-permissions/detail.tsx @@ -0,0 +1,246 @@ +/* + GoToSocial + Copyright (C) GoToSocial Authors admin@gotosocial.org + SPDX-License-Identifier: AGPL-3.0-or-later + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +import React, { useEffect, useMemo } from "react"; +import { useLocation, useParams } from "wouter"; +import { PermType } from "../../../lib/types/perm"; +import { useDeleteHeaderAllowMutation, useDeleteHeaderBlockMutation, useGetHeaderAllowQuery, useGetHeaderBlockQuery } from "../../../lib/query/admin/http-header-permissions"; +import { HeaderPermission } from "../../../lib/types/http-header-permissions"; +import { FetchBaseQueryError } from "@reduxjs/toolkit/query"; +import { SerializedError } from "@reduxjs/toolkit"; +import Loading from "../../../components/loading"; +import { Error } from "../../../components/error"; +import { useLazyGetAccountQuery } from "../../../lib/query/admin"; +import Username from "../../../components/username"; +import { useBaseUrl } from "../../../lib/navigation/util"; +import BackButton from "../../../components/back-button"; +import MutationButton from "../../../components/form/mutation-button"; + +const testString = `/* To test this properly, set "flavor" to "Golang", as that's the language GoToSocial uses for regular expressions */ + +/* Amazon crawler User-Agent example */ +Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_1) AppleWebKit/600.2.5 (KHTML\\, like Gecko) Version/8.0.2 Safari/600.2.5 (Amazonbot/0.1; +https://developer.amazon.com/support/amazonbot) + +/* Some other test strings */ +Some Test Value +Another Test Value`; + +export default function HeaderPermDetail() { + let params = useParams(); + if (params.permType !== "blocks" && params.permType !== "allows") { + throw "unrecognized perm type " + params.permType; + } + const permType = useMemo(() => { + return params.permType?.slice(0, -1) as PermType; + }, [params]); + + let permID = params.permId as string | undefined; + if (!permID) { + throw "no perm ID"; + } + + if (permType === "block") { + return <BlockDetail id={permID} />; + } else { + return <AllowDetail id={permID} />; + } +} + +function BlockDetail({ id }: { id: string }) { + return ( + <PermDeets + permType={"Block"} + {...useGetHeaderBlockQuery(id)} + /> + ); +} + +function AllowDetail({ id }: { id: string }) { + return ( + <PermDeets + permType={"Allow"} + {...useGetHeaderAllowQuery(id)} + /> + ); +} + +interface PermDeetsProps { + permType: string; + data?: HeaderPermission; + isLoading: boolean; + isFetching: boolean; + isError: boolean; + error?: FetchBaseQueryError | SerializedError; +} + +function PermDeets({ + permType, + data: perm, + isLoading: isLoadingPerm, + isFetching: isFetchingPerm, + isError: isErrorPerm, + error: errorPerm, +}: PermDeetsProps) { + const [ location ] = useLocation(); + const baseUrl = useBaseUrl(); + + // Once we've loaded the perm, trigger + // getting the account that created it. + const [ getAccount, getAccountRes ] = useLazyGetAccountQuery(); + useEffect(() => { + if (!perm) { + return; + } + getAccount(perm.created_by, true); + }, [getAccount, perm]); + + // Load the createdByAccount if possible, + // returning a username lozenge with + // a link to the account. + const createdByAccount = useMemo(() => { + const { + data: account, + isLoading: isLoadingAccount, + isFetching: isFetchingAccount, + isError: isErrorAccount, + } = getAccountRes; + + // Wait for query to finish, returning + // loading spinner in the meantime. + if (isLoadingAccount || isFetchingAccount || !perm) { + return <Loading />; + } else if (isErrorAccount || account === undefined) { + // Fall back to account ID. + return perm?.created_by; + } + + return ( + <Username + account={account} + linkTo={`~/settings/moderation/accounts/${account.id}`} + backLocation={`~${baseUrl}${location}`} + /> + ); + }, [getAccountRes, perm, baseUrl, location]); + + // Now wait til the perm itself is loaded. + if (isLoadingPerm || isFetchingPerm) { + return <Loading />; + } else if (isErrorPerm) { + return <Error error={errorPerm} />; + } else if (perm === undefined) { + throw "perm undefined"; + } + + const created = new Date(perm.created_at).toDateString(); + + // Create parameters to link to regex101 + // with this regular expression prepopulated. + const testParams = new URLSearchParams(); + testParams.set("regex", perm.regex); + testParams.set("flags", "g"); + testParams.set("testString", testString); + const regexLink = `https://regex101.com/?${testParams.toString()}`; + + return ( + <div className="http-header-permission-details"> + <h1><BackButton to={`~${baseUrl}/${permType.toLowerCase()}s`} /> HTTP Header {permType} Detail</h1> + <dl className="info-list"> + <div className="info-list-entry"> + <dt>ID</dt> + <dd>{perm.id}</dd> + </div> + <div className="info-list-entry"> + <dt>Created</dt> + <dd><time dateTime={perm.created_at}>{created}</time></dd> + </div> + <div className="info-list-entry"> + <dt>Created By</dt> + <dd>{createdByAccount}</dd> + </div> + <div className="info-list-entry"> + <dt>Header Name</dt> + <dd>{perm.header}</dd> + </div> + <div className="info-list-entry"> + <dt>Header Value Regex</dt> + <dd className="monospace">{perm.regex}</dd> + </div> + <div className="info-list-entry"> + <dt>Test This Regex</dt> + <dd> + <a + href={regexLink} + target="_blank" + rel="noreferrer" + > + <i className="fa fa-fw fa-external-link" aria-hidden="true"></i> Link to Regex101 (opens in a new tab) + </a> + </dd> + </div> + </dl> + { permType === "Block" + ? <DeleteBlock id={perm.id} /> + : <DeleteAllow id={perm.id} /> + } + </div> + ); +} + +function DeleteBlock({ id }: { id: string }) { + const [ _location, setLocation ] = useLocation(); + const baseUrl = useBaseUrl(); + const [ removeTrigger, removeResult ] = useDeleteHeaderBlockMutation(); + + return ( + <MutationButton + type="button" + onClick={() => { + removeTrigger(id); + setLocation(`~${baseUrl}/blocks`); + }} + label="Remove this block" + result={removeResult} + className="button danger" + showError={false} + disabled={false} + /> + ); +} + +function DeleteAllow({ id }: { id: string }) { + const [ _location, setLocation ] = useLocation(); + const baseUrl = useBaseUrl(); + const [ removeTrigger, removeResult ] = useDeleteHeaderAllowMutation(); + + return ( + <MutationButton + type="button" + onClick={() => { + removeTrigger(id); + setLocation(`~${baseUrl}/allows`); + }} + label="Remove this allow" + result={removeResult} + className="button danger" + showError={false} + disabled={false} + /> + ); +} diff --git a/web/source/settings/views/admin/http-header-permissions/overview.tsx b/web/source/settings/views/admin/http-header-permissions/overview.tsx new file mode 100644 index 000000000..7735e624e --- /dev/null +++ b/web/source/settings/views/admin/http-header-permissions/overview.tsx @@ -0,0 +1,169 @@ +/* + GoToSocial + Copyright (C) GoToSocial Authors admin@gotosocial.org + SPDX-License-Identifier: AGPL-3.0-or-later + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +import React, { useMemo } from "react"; +import { useGetHeaderAllowsQuery, useGetHeaderBlocksQuery } from "../../../lib/query/admin/http-header-permissions"; +import { NoArg } from "../../../lib/types/query"; +import { PageableList } from "../../../components/pageable-list"; +import { HeaderPermission } from "../../../lib/types/http-header-permissions"; +import { useLocation, useParams } from "wouter"; +import { PermType } from "../../../lib/types/perm"; +import { FetchBaseQueryError } from "@reduxjs/toolkit/query"; +import { SerializedError } from "@reduxjs/toolkit"; +import HeaderPermCreateForm from "./create"; + +export default function HeaderPermsOverview() { + const [ location, setLocation ] = useLocation(); + + // Parse perm type from routing params. + let params = useParams(); + if (params.permType !== "blocks" && params.permType !== "allows") { + throw "unrecognized perm type " + params.permType; + } + const permType = useMemo(() => { + return params.permType?.slice(0, -1) as PermType; + }, [params]); + + // Uppercase first letter of given permType. + const permTypeUpper = useMemo(() => { + return permType.charAt(0).toUpperCase() + permType.slice(1); + }, [permType]); + + // Fetch desired perms, skipping + // the ones we don't want. + const { + data: blocks, + isLoading: isLoadingBlocks, + isFetching: isFetchingBlocks, + isSuccess: isSuccessBlocks, + isError: isErrorBlocks, + error: errorBlocks + } = useGetHeaderBlocksQuery(NoArg, { skip: permType !== "block" }); + + const { + data: allows, + isLoading: isLoadingAllows, + isFetching: isFetchingAllows, + isSuccess: isSuccessAllows, + isError: isErrorAllows, + error: errorAllows + } = useGetHeaderAllowsQuery(NoArg, { skip: permType !== "allow" }); + + const itemToEntry = (perm: HeaderPermission) => { + return ( + <dl + key={perm.id} + className="entry spanlink" + onClick={() => { + // When clicking on a header perm, + // go to the detail view for perm. + setLocation(`/${permType}s/${perm.id}`, { + // Store the back location in + // history so the detail view + // can use it to return here. + state: { backLocation: location } + }); + }} + role="link" + tabIndex={0} + > + <dt>{perm.header}</dt> + <dd>{perm.regex}</dd> + </dl> + ); + }; + + const emptyMessage = ( + <div className="info"> + <i className="fa fa-fw fa-info-circle" aria-hidden="true"></i> + <b> + No HTTP header {permType}s exist yet. + You can create one using the form below. + </b> + </div> + ); + + let isLoading: boolean; + let isFetching: boolean; + let isSuccess: boolean; + let isError: boolean; + let error: FetchBaseQueryError | SerializedError | undefined; + let items: HeaderPermission[] | undefined; + + if (permType === "block") { + isLoading = isLoadingBlocks; + isFetching = isFetchingBlocks; + isSuccess = isSuccessBlocks; + isError = isErrorBlocks; + error = errorBlocks; + items = blocks; + } else { + isLoading = isLoadingAllows; + isFetching = isFetchingAllows; + isSuccess = isSuccessAllows; + isError = isErrorAllows; + error = errorAllows; + items = allows; + } + + return ( + <div className="http-header-permissions"> + <div className="form-section-docs"> + <h1>HTTP Header {permTypeUpper}s</h1> + <p> + On this page, you can view, create, and remove HTTP header {permType} entries, + <br/> + Blocks and allows have different effects depending on the value you've set + for <code>advanced-header-filter-mode</code> in your instance configuration. + <br/> + { permType === "block" && <> + <strong> + When running in <code>block</code> mode, be very careful when creating + your value regexes, as a too-broad match can cause your instance to + deny all requests, locking you out of this settings panel. + </strong> + <br/> + If you do this by accident, you can fix it by stopping your instance, + changing <code>advanced-header-filter-mode</code> to an empty string + (disabled), starting your instance again, and removing the block. + </> } + </p> + <a + href="https://docs.gotosocial.org/en/latest/admin/request_filtering_modes/" + target="_blank" + className="docslink" + rel="noreferrer" + > + Learn more about HTTP request filtering (opens in a new tab) + </a> + </div> + <PageableList + isLoading={isLoading} + isFetching={isFetching} + isSuccess={isSuccess} + isError={isError} + error={error} + items={items} + itemToEntry={itemToEntry} + emptyMessage={emptyMessage} + /> + <HeaderPermCreateForm permType={permType} /> + </div> + ); +} diff --git a/web/source/settings/views/admin/menu.tsx b/web/source/settings/views/admin/menu.tsx index 2cf5a35c2..481f51a4d 100644 --- a/web/source/settings/views/admin/menu.tsx +++ b/web/source/settings/views/admin/menu.tsx @@ -36,6 +36,10 @@ import { useHasPermission } from "../../lib/navigation/util"; * - /settings/admin/actions * - /settings/admin/actions/media * - /settings/admin/actions/keys + * - /settings/admin/http-header-permissions/blocks + * - /settings/admin/http-header-permissions/blocks/:blockId\ + * - /settings/admin/http-header-permissions/allows + * - /settings/admin/http-header-permissions/allows/:allowId */ export default function AdminMenu() { const permissions = ["admin"]; @@ -54,6 +58,7 @@ export default function AdminMenu() { <AdminInstanceMenu /> <AdminEmojisMenu /> <AdminActionsMenu /> + <AdminHTTPHeaderPermissionsMenu /> </MenuItem> ); } @@ -127,3 +132,25 @@ function AdminEmojisMenu() { </MenuItem> ); } + +function AdminHTTPHeaderPermissionsMenu() { + return ( + <MenuItem + name="HTTP Header Permissions" + itemUrl="http-header-permissions" + defaultChild="blocks" + icon="fa-hubzilla" + > + <MenuItem + name="Blocks" + itemUrl="blocks" + icon="fa-close" + /> + <MenuItem + name="Allows" + itemUrl="allows" + icon="fa-check" + /> + </MenuItem> + ); +} diff --git a/web/source/settings/views/admin/router.tsx b/web/source/settings/views/admin/router.tsx index 95d146510..68c4a5ef3 100644 --- a/web/source/settings/views/admin/router.tsx +++ b/web/source/settings/views/admin/router.tsx @@ -29,15 +29,17 @@ import Keys from "./actions/keys"; import EmojiOverview from "./emoji/local/overview"; import EmojiDetail from "./emoji/local/detail"; import RemoteEmoji from "./emoji/remote"; +import HeaderPermsOverview from "./http-header-permissions/overview"; +import HeaderPermDetail from "./http-header-permissions/detail"; /* EXPORTED COMPONENTS */ /** - * - /settings/instance/settings - * - /settings/instance/rules - * - /settings/instance/rules/:ruleId + * - /settings/admin/instance/settings + * - /settings/admin/instance/rules + * - /settings/admin/instance/rules/:ruleId * - /settings/admin/emojis * - /settings/admin/emojis/local * - /settings/admin/emojis/local/:emojiId @@ -45,6 +47,10 @@ import RemoteEmoji from "./emoji/remote"; * - /settings/admin/actions * - /settings/admin/actions/media * - /settings/admin/actions/keys + * - /settings/admin/http-header-permissions/allows + * - /settings/admin/http-header-permissions/allows/:allowId + * - /settings/admin/http-header-permissions/blocks + * - /settings/admin/http-header-permissions/blocks/:blockId */ export default function AdminRouter() { const parentUrl = useBaseUrl(); @@ -57,6 +63,7 @@ export default function AdminRouter() { <AdminInstanceRouter /> <AdminEmojisRouter /> <AdminActionsRouter /> + <AdminHTTPHeaderPermissionsRouter /> </Router> </BaseUrlContext.Provider> ); @@ -125,9 +132,9 @@ function AdminActionsRouter() { } /** - * - /settings/instance/settings - * - /settings/instance/rules - * - /settings/instance/rules/:ruleId + * - /settings/admin/instance/settings + * - /settings/admin/instance/rules + * - /settings/admin/instance/rules/:ruleId */ function AdminInstanceRouter() { const parentUrl = useBaseUrl(); @@ -149,3 +156,29 @@ function AdminInstanceRouter() { </BaseUrlContext.Provider> ); } + +/** + * - /settings/admin/http-header-permissions/blocks + * - /settings/admin/http-header-permissions/blocks/:blockId + * - /settings/admin/http-header-permissions/allows + * - /settings/admin/http-header-permissions/allows/:allowId + */ +function AdminHTTPHeaderPermissionsRouter() { + const parentUrl = useBaseUrl(); + const thisBase = "/http-header-permissions"; + const absBase = parentUrl + thisBase; + + return ( + <BaseUrlContext.Provider value={absBase}> + <Router base={thisBase}> + <ErrorBoundary> + <Switch> + <Route path="/:permType" component={HeaderPermsOverview} /> + <Route path="/:permType/:permId" component={HeaderPermDetail} /> + <Route><Redirect to="/blocks" /></Route> + </Switch> + </ErrorBoundary> + </Router> + </BaseUrlContext.Provider> + ); +} diff --git a/web/source/settings/views/moderation/accounts/pending/index.tsx b/web/source/settings/views/moderation/accounts/pending/index.tsx index b72de52bf..d47363954 100644 --- a/web/source/settings/views/moderation/accounts/pending/index.tsx +++ b/web/source/settings/views/moderation/accounts/pending/index.tsx @@ -53,7 +53,7 @@ export default function AccountsPending() { itemToEntry={itemToEntry} isError={searchRes.isError} error={searchRes.error} - emptyMessage="No pending account sign-ups." + emptyMessage={<b>No pending account sign-ups.</b>} /> </div> ); diff --git a/web/source/settings/views/moderation/accounts/search/index.tsx b/web/source/settings/views/moderation/accounts/search/index.tsx index 58e2d6505..16e89ce43 100644 --- a/web/source/settings/views/moderation/accounts/search/index.tsx +++ b/web/source/settings/views/moderation/accounts/search/index.tsx @@ -166,7 +166,7 @@ export function AccountSearchForm() { itemToEntry={itemToEntry} isError={searchRes.isError} error={searchRes.error} - emptyMessage="No accounts found that match your query" + emptyMessage={<b>No accounts found that match your query.</b>} prevNextLinks={searchRes.data?.links} /> </> diff --git a/web/source/settings/views/moderation/domain-permissions/detail.tsx b/web/source/settings/views/moderation/domain-permissions/detail.tsx index b9d439aee..47072b8cb 100644 --- a/web/source/settings/views/moderation/domain-permissions/detail.tsx +++ b/web/source/settings/views/moderation/domain-permissions/detail.tsx @@ -34,10 +34,11 @@ import MutationButton from "../../../components/form/mutation-button"; import { useDomainAllowsQuery, useDomainBlocksQuery } from "../../../lib/query/admin/domain-permissions/get"; import { useAddDomainAllowMutation, useAddDomainBlockMutation, useRemoveDomainAllowMutation, useRemoveDomainBlockMutation } from "../../../lib/query/admin/domain-permissions/update"; -import { DomainPerm, PermType } from "../../../lib/types/domain-permission"; +import { DomainPerm } from "../../../lib/types/domain-permission"; import { NoArg } from "../../../lib/types/query"; import { Error } from "../../../components/error"; import { useBaseUrl } from "../../../lib/navigation/util"; +import { PermType } from "../../../lib/types/perm"; export default function DomainPermDetail() { const baseUrl = useBaseUrl(); diff --git a/web/source/settings/views/moderation/domain-permissions/overview.tsx b/web/source/settings/views/moderation/domain-permissions/overview.tsx index fca8e348e..645c51e93 100644 --- a/web/source/settings/views/moderation/domain-permissions/overview.tsx +++ b/web/source/settings/views/moderation/domain-permissions/overview.tsx @@ -26,8 +26,9 @@ import { useTextInput } from "../../../lib/form"; import { TextInput } from "../../../components/form/inputs"; import Loading from "../../../components/loading"; import { useDomainAllowsQuery, useDomainBlocksQuery } from "../../../lib/query/admin/domain-permissions/get"; -import type { MappedDomainPerms, PermType } from "../../../lib/types/domain-permission"; +import type { MappedDomainPerms } from "../../../lib/types/domain-permission"; import { NoArg } from "../../../lib/types/query"; +import { PermType } from "../../../lib/types/perm"; export default function DomainPermissionsOverview() { // Parse perm type from routing params. |