From 6171dcbe5109d7accbf44f19c20c9f4a0ee5e06f Mon Sep 17 00:00:00 2001 From: tobi <31960611+tsmethurst@users.noreply.github.com> Date: Sun, 5 May 2024 13:47:22 +0200 Subject: [PATCH] [feature] Add HTTP header permission section to frontend (#2893) * [feature] Add HTTP header filter section to frontend * tweak naming a bit --- cmd/gotosocial/action/testrig/testrig.go | 1 + docs/admin/request_filtering_modes.md | 4 +- docs/configuration/advanced.md | 19 ++ example/config.yaml | 2 + internal/api/client/admin/headerfilter.go | 4 +- .../api/client/admin/headerfilter_delete.go | 2 +- mkdocs.yml | 1 + testrig/config.go | 1 + .../components/authorization/index.tsx | 21 +- .../settings/components/form/inputs.tsx | 10 +- .../settings/components/pageable-list.tsx | 2 +- .../admin/http-header-permissions/index.ts | 159 +++++++++++ web/source/settings/lib/query/admin/index.ts | 1 + web/source/settings/lib/query/gts-api.ts | 2 + .../settings/lib/types/domain-permission.ts | 3 +- .../lib/types/http-header-permissions.ts | 48 ++++ web/source/settings/lib/types/perm.ts | 20 ++ web/source/settings/style.css | 114 +++++--- .../admin/http-header-permissions/create.tsx | 143 ++++++++++ .../admin/http-header-permissions/detail.tsx | 246 ++++++++++++++++++ .../http-header-permissions/overview.tsx | 169 ++++++++++++ web/source/settings/views/admin/menu.tsx | 27 ++ web/source/settings/views/admin/router.tsx | 45 +++- .../moderation/accounts/pending/index.tsx | 2 +- .../moderation/accounts/search/index.tsx | 2 +- .../moderation/domain-permissions/detail.tsx | 3 +- .../domain-permissions/overview.tsx | 3 +- 27 files changed, 986 insertions(+), 68 deletions(-) create mode 100644 web/source/settings/lib/query/admin/http-header-permissions/index.ts create mode 100644 web/source/settings/lib/types/http-header-permissions.ts create mode 100644 web/source/settings/lib/types/perm.ts create mode 100644 web/source/settings/views/admin/http-header-permissions/create.tsx create mode 100644 web/source/settings/views/admin/http-header-permissions/detail.tsx create mode 100644 web/source/settings/views/admin/http-header-permissions/overview.tsx diff --git a/cmd/gotosocial/action/testrig/testrig.go b/cmd/gotosocial/action/testrig/testrig.go index 79361375b..24d41721a 100644 --- a/cmd/gotosocial/action/testrig/testrig.go +++ b/cmd/gotosocial/action/testrig/testrig.go @@ -165,6 +165,7 @@ var Start action.GTSAction = func(ctx context.Context) error { middlewares = append(middlewares, []gin.HandlerFunc{ middleware.Logger(config.GetLogClientIP()), + middleware.HeaderFilter(&state), middleware.UserAgent(), middleware.CORS(), middleware.ExtraHeaders(), diff --git a/docs/admin/request_filtering_modes.md b/docs/admin/request_filtering_modes.md index 78f5dfe77..e90a1f3a2 100644 --- a/docs/admin/request_filtering_modes.md +++ b/docs/admin/request_filtering_modes.md @@ -7,8 +7,6 @@ GoToSocial currently offers 'block', 'allow' and disabled HTTP request header fi HTTP request header filtering is also still considered "experimental". It should do what it says on the box, but it may cause bugs or edge cases to appear elsewhere, we're not sure yet! - Management via settings panel is TBA. Until then you will need to manage these directly via API endpoints. - ## Disabled header filtering mode (default) When `advanced-header-filter-mode` is set to `""`, i.e. an empty string, all request header filtering will be disabled. @@ -30,4 +28,4 @@ In allow mode, a block header filter can be used to override an existing allow f A request in allow mode will only be accepted if it is EXPLICITLY ALLOWED AND NOT EXPLICITLY BLOCKED. !!! danger - Allow filtering mode is an extremely restrictive mode that will almost certainly prevent many (legitimate) clients from being able to access your instance, including yourself. You should only enable this mode if you know exactly what you're trying to achieve. \ No newline at end of file + Allow filtering mode is an extremely restrictive mode that will almost certainly prevent many (legitimate) clients from being able to access your instance, including yourself. You should only enable this mode if you know exactly what you're trying to achieve. diff --git a/docs/configuration/advanced.md b/docs/configuration/advanced.md index b97d8a6ba..88f4aff67 100644 --- a/docs/configuration/advanced.md +++ b/docs/configuration/advanced.md @@ -163,4 +163,23 @@ advanced-sender-multiplier: 2 # Example: ["s3.example.org", "some-bucket-name.s3.example.org"] # Default: [] advanced-csp-extra-uris: [] + +# String. HTTP request header filtering mode to use for this instance. +# +# "block" -- only requests that are explicitly blocked by header filters +# will be denied (unless they are also explicitly allowed). +# +# "allow" -- only requests that are explicitly allowed by header filters +# will be accepted (unless they are also explicitly blocked). +# This mode is considered experimental and will almost certainly +# break access to your instance unless you are very careful. +# +# "" -- request header filtering disabled. +# +# For more details on block and allow modes, check the documentation at: +# https://docs.gotosocial.org/en/latest/admin/request_filtering_modes +# +# Options: ["block", "allow", ""] +# Default: "" +advanced-header-filter-mode: "" ``` diff --git a/example/config.yaml b/example/config.yaml index b5e0fe57f..6ef9f7a6c 100644 --- a/example/config.yaml +++ b/example/config.yaml @@ -1090,6 +1090,8 @@ advanced-csp-extra-uris: [] # # "allow" -- only requests that are explicitly allowed by header filters # will be accepted (unless they are also explicitly blocked). +# This mode is considered experimental and will almost certainly +# break access to your instance unless you are very careful. # # "" -- request header filtering disabled. # diff --git a/internal/api/client/admin/headerfilter.go b/internal/api/client/admin/headerfilter.go index 01bcaca16..d3dad5917 100644 --- a/internal/api/client/admin/headerfilter.go +++ b/internal/api/client/admin/headerfilter.go @@ -52,7 +52,7 @@ func (m *Module) getHeaderFilter(c *gin.Context, get func(context.Context, strin return } - filterID, errWithCode := apiutil.ParseID(c.Param("ID")) + filterID, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey)) if errWithCode != nil { apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) return @@ -167,7 +167,7 @@ func (m *Module) deleteHeaderFilter(c *gin.Context, delete func(context.Context, return } - filterID, errWithCode := apiutil.ParseID(c.Param("ID")) + filterID, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey)) if errWithCode != nil { apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) return diff --git a/internal/api/client/admin/headerfilter_delete.go b/internal/api/client/admin/headerfilter_delete.go index 806e62a04..58b1c585e 100644 --- a/internal/api/client/admin/headerfilter_delete.go +++ b/internal/api/client/admin/headerfilter_delete.go @@ -92,5 +92,5 @@ func (m *Module) HeaderFilterAllowDELETE(c *gin.Context) { // '500': // description: internal server error func (m *Module) HeaderFilterBlockDELETE(c *gin.Context) { - m.deleteHeaderFilter(c, m.processor.Admin().DeleteAllowHeaderFilter) + m.deleteHeaderFilter(c, m.processor.Admin().DeleteBlockHeaderFilter) } diff --git a/mkdocs.yml b/mkdocs.yml index 8d7ecc65a..c0132fb2f 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -118,6 +118,7 @@ nav: - "admin/signups.md" - "admin/federation_modes.md" - "admin/domain_blocks.md" + - "admin/request_filtering_modes.md" - "admin/robots.md" - "admin/cli.md" - "admin/backup_and_restore.md" diff --git a/testrig/config.go b/testrig/config.go index 2717b733e..93f3c5523 100644 --- a/testrig/config.go +++ b/testrig/config.go @@ -146,6 +146,7 @@ var testDefaults = config.Configuration{ AdvancedRateLimitRequests: 0, // disabled AdvancedThrottlingMultiplier: 0, // disabled AdvancedSenderMultiplier: 0, // 1 sender only, regardless of CPU + AdvancedHeaderFilterMode: config.RequestHeaderFilterModeBlock, SoftwareVersion: "0.0.0-testrig", 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 . */ -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 }) { {loadingInfo} ); - } 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 = (
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 > { - label?: string; + label?: ReactNode; field: TextFormInputHook; } @@ -60,7 +60,7 @@ export interface TextAreaProps extends React.DetailedHTMLProps< React.TextareaHTMLAttributes, HTMLTextAreaElement > { - label?: string; + label?: ReactNode; field: TextFormInputHook; } @@ -86,7 +86,7 @@ export interface FileInputProps extends React.DetailedHTMLProps< React.InputHTMLAttributes, HTMLInputElement > { - label?: string; + label?: ReactNode; field: FileFormInputHook; } @@ -133,7 +133,7 @@ export interface SelectProps extends React.DetailedHTMLProps< React.SelectHTMLAttributes, 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 > { - 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 { 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 . +*/ + +import { gtsApi } from "../../gts-api"; +import { HeaderPermission } from "../../../types/http-header-permissions"; + +const extended = gtsApi.injectEndpoints({ + endpoints: (build) => ({ + + /* HTTP HEADER ALLOWS */ + + getHeaderAllows: build.query({ + 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({ + query: (id) => ({ + url: `/api/v1/admin/header_allows/${id}` + }), + providesTags: (_res, _error, id) => [{ type: "HTTPHeaderAllows", id }], + }), + + postHeaderAllow: build.mutation({ + query: (formData) => ({ + method: "POST", + url: `/api/v1/admin/header_allows`, + asForm: true, + body: formData, + discardEmpty: true + }), + invalidatesTags: [{ type: "HTTPHeaderAllows", id: "LIST" }], + }), + + deleteHeaderAllow: build.mutation({ + query: (id) => ({ + method: "DELETE", + url: `/api/v1/admin/header_allows/${id}` + }), + invalidatesTags: (_res, _error, id) => [{ type: "HTTPHeaderAllows", id }], + }), + + /* HTTP HEADER BLOCKS */ + + getHeaderBlocks: build.query({ + 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({ + query: (formData) => ({ + method: "POST", + url: `/api/v1/admin/header_blocks`, + asForm: true, + body: formData, + discardEmpty: true + }), + invalidatesTags: [{ type: "HTTPHeaderBlocks", id: "LIST" }], + }), + + getHeaderBlock: build.query({ + query: (id) => ({ + url: `/api/v1/admin/header_blocks/${id}` + }), + providesTags: (_res, _error, id) => [{ type: "HTTPHeaderBlocks", id }], + }), + + deleteHeaderBlock: build.mutation({ + 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({ 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(); -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 . +*/ + +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 . +*/ + +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,43 +1191,6 @@ button.with-padding { margin-bottom: 0; } - .info-list { - border: 0.1rem solid $gray1; - display: flex; - flex-direction: column; - - .info-list-entry { - background: $list-entry-bg; - border: 0.1rem solid transparent; - padding: 0.25rem; - - &:nth-child(even) { - background: $list-entry-alternate-bg; - } - - display: grid; - grid-template-columns: max(20%, 10rem) 1fr; - - dt { - font-weight: bold; - } - - dd { - word-break: break-word; - } - - dt, dd { - /* - Make sure any fa icons used in keys - or values are properly aligned. - */ - .fa { - vertical-align: middle; - } - } - } - } - .action-buttons { display: flex; gap: 0.5rem; @@ -1236,6 +1198,43 @@ button.with-padding { } } +.info-list { + border: 0.1rem solid $gray1; + display: flex; + flex-direction: column; + + .info-list-entry { + background: $list-entry-bg; + border: 0.1rem solid transparent; + padding: 0.25rem; + + &:nth-child(even) { + background: $list-entry-alternate-bg; + } + + display: grid; + grid-template-columns: max(20%, 10rem) 1fr; + + dt { + font-weight: bold; + } + + dd { + word-break: break-word; + } + + dt, dd { + /* + Make sure any fa icons used in keys + or values are properly aligned. + */ + .fa { + vertical-align: middle; + } + } + } +} + .instance-rules { list-style-position: inside; margin: 0; @@ -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 . +*/ + +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 ( +
+

Create new HTTP header {permType}

+ + HTTP Header Name  + + Learn more about HTTP request headers (opens in a new tab) + + + } + placeholder={"User-Agent"} + /> + + HTTP Header Value Regex  + + Learn more about regular expressions (opens in a new tab) + + + } + placeholder={"^.*Some-User-Agent.*$"} + {...{className: "monospace"}} + /> + + + ); +} 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 . +*/ + +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 ; + } else { + return ; + } +} + +function BlockDetail({ id }: { id: string }) { + return ( + + ); +} + +function AllowDetail({ id }: { id: string }) { + return ( + + ); +} + +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 ; + } else if (isErrorAccount || account === undefined) { + // Fall back to account ID. + return perm?.created_by; + } + + return ( + + ); + }, [getAccountRes, perm, baseUrl, location]); + + // Now wait til the perm itself is loaded. + if (isLoadingPerm || isFetchingPerm) { + return ; + } else if (isErrorPerm) { + return ; + } 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 ( +
+

HTTP Header {permType} Detail

+
+
+
ID
+
{perm.id}
+
+
+
Created
+
+
+
+
Created By
+
{createdByAccount}
+
+
+
Header Name
+
{perm.header}
+
+
+
Header Value Regex
+
{perm.regex}
+
+ +
+ { permType === "Block" + ? + : + } +
+ ); +} + +function DeleteBlock({ id }: { id: string }) { + const [ _location, setLocation ] = useLocation(); + const baseUrl = useBaseUrl(); + const [ removeTrigger, removeResult ] = useDeleteHeaderBlockMutation(); + + return ( + { + 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 ( + { + 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 . +*/ + +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 ( +
{ + // 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} + > +
{perm.header}
+
{perm.regex}
+
+ ); + }; + + const emptyMessage = ( +
+ + + No HTTP header {permType}s exist yet. + You can create one using the form below. + +
+ ); + + 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 ( +
+
+

HTTP Header {permTypeUpper}s

+

+ On this page, you can view, create, and remove HTTP header {permType} entries, +
+ Blocks and allows have different effects depending on the value you've set + for advanced-header-filter-mode in your instance configuration. +
+ { permType === "block" && <> + + When running in block 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. + +
+ If you do this by accident, you can fix it by stopping your instance, + changing advanced-header-filter-mode to an empty string + (disabled), starting your instance again, and removing the block. + } +

+ + Learn more about HTTP request filtering (opens in a new tab) + +
+ + +
+ ); +} 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() { + ); } @@ -127,3 +132,25 @@ function AdminEmojisMenu() { ); } + +function AdminHTTPHeaderPermissionsMenu() { + return ( + + + + + ); +} 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() { + ); @@ -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() { ); } + +/** + * - /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 ( + + + + + + + + + + + + ); +} 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={No pending account sign-ups.} />
); 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={No accounts found that match your query.} 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.