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 (
+
+ );
+}
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 (
+