[feature] Add HTTP header permission section to frontend (#2893)

* [feature] Add HTTP header filter section to frontend

* tweak naming a bit
This commit is contained in:
tobi 2024-05-05 13:47:22 +02:00 committed by GitHub
parent 35b1c54bde
commit 6171dcbe51
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 986 additions and 68 deletions

View file

@ -165,6 +165,7 @@
middlewares = append(middlewares, []gin.HandlerFunc{ middlewares = append(middlewares, []gin.HandlerFunc{
middleware.Logger(config.GetLogClientIP()), middleware.Logger(config.GetLogClientIP()),
middleware.HeaderFilter(&state),
middleware.UserAgent(), middleware.UserAgent(),
middleware.CORS(), middleware.CORS(),
middleware.ExtraHeaders(), middleware.ExtraHeaders(),

View file

@ -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! 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) ## 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. When `advanced-header-filter-mode` is set to `""`, i.e. an empty string, all request header filtering will be disabled.

View file

@ -163,4 +163,23 @@ advanced-sender-multiplier: 2
# Example: ["s3.example.org", "some-bucket-name.s3.example.org"] # Example: ["s3.example.org", "some-bucket-name.s3.example.org"]
# Default: [] # Default: []
advanced-csp-extra-uris: [] 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: ""
``` ```

View file

@ -1090,6 +1090,8 @@ advanced-csp-extra-uris: []
# #
# "allow" -- only requests that are explicitly allowed by header filters # "allow" -- only requests that are explicitly allowed by header filters
# will be accepted (unless they are also explicitly blocked). # 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. # "" -- request header filtering disabled.
# #

View file

@ -52,7 +52,7 @@ func (m *Module) getHeaderFilter(c *gin.Context, get func(context.Context, strin
return return
} }
filterID, errWithCode := apiutil.ParseID(c.Param("ID")) filterID, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey))
if errWithCode != nil { if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return return
@ -167,7 +167,7 @@ func (m *Module) deleteHeaderFilter(c *gin.Context, delete func(context.Context,
return return
} }
filterID, errWithCode := apiutil.ParseID(c.Param("ID")) filterID, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey))
if errWithCode != nil { if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
return return

View file

@ -92,5 +92,5 @@ func (m *Module) HeaderFilterAllowDELETE(c *gin.Context) {
// '500': // '500':
// description: internal server error // description: internal server error
func (m *Module) HeaderFilterBlockDELETE(c *gin.Context) { func (m *Module) HeaderFilterBlockDELETE(c *gin.Context) {
m.deleteHeaderFilter(c, m.processor.Admin().DeleteAllowHeaderFilter) m.deleteHeaderFilter(c, m.processor.Admin().DeleteBlockHeaderFilter)
} }

View file

@ -118,6 +118,7 @@ nav:
- "admin/signups.md" - "admin/signups.md"
- "admin/federation_modes.md" - "admin/federation_modes.md"
- "admin/domain_blocks.md" - "admin/domain_blocks.md"
- "admin/request_filtering_modes.md"
- "admin/robots.md" - "admin/robots.md"
- "admin/cli.md" - "admin/cli.md"
- "admin/backup_and_restore.md" - "admin/backup_and_restore.md"

View file

@ -146,6 +146,7 @@ func InitTestConfig() {
AdvancedRateLimitRequests: 0, // disabled AdvancedRateLimitRequests: 0, // disabled
AdvancedThrottlingMultiplier: 0, // disabled AdvancedThrottlingMultiplier: 0, // disabled
AdvancedSenderMultiplier: 0, // 1 sender only, regardless of CPU AdvancedSenderMultiplier: 0, // 1 sender only, regardless of CPU
AdvancedHeaderFilterMode: config.RequestHeaderFilterModeBlock,
SoftwareVersion: "0.0.0-testrig", SoftwareVersion: "0.0.0-testrig",

View file

@ -17,10 +17,9 @@
along with this program. If not, see <http://www.gnu.org/licenses/>. 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 { store } from "../../redux/store";
import React, { ReactNode } from "react";
import React from "react";
import Login from "./login"; import Login from "./login";
import Loading from "../loading"; import Loading from "../loading";
@ -30,18 +29,20 @@ import { NoArg } from "../../lib/types/query";
export function Authorization({ App }) { export function Authorization({ App }) {
const { loginState, expectingRedirect } = store.getState().oauth; const { loginState, expectingRedirect } = store.getState().oauth;
const skip = (loginState == "none" || loginState == "logout" || expectingRedirect); const skip = (loginState == "none" || loginState == "logout" || expectingRedirect);
const [ logoutQuery ] = useLogoutMutation();
const { const {
isLoading, isLoading,
isFetching,
isSuccess, isSuccess,
data: account, data: account,
error, error,
} = useVerifyCredentialsQuery(NoArg, { skip: skip }); } = useVerifyCredentialsQuery(NoArg, { skip: skip });
let showLogin = true; let showLogin = true;
let content: React.JSX.Element | null = null; let content: ReactNode;
if (isLoading) { if (isLoading || isFetching) {
showLogin = false; showLogin = false;
let loadingInfo = ""; let loadingInfo = "";
@ -56,7 +57,15 @@ export function Authorization({ App }) {
<Loading /> {loadingInfo} <Loading /> {loadingInfo}
</div> </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 = ( content = (
<div> <div>
<Error error={error} /> <Error error={error} />

View file

@ -34,7 +34,7 @@ export interface TextInputProps extends React.DetailedHTMLProps<
React.InputHTMLAttributes<HTMLInputElement>, React.InputHTMLAttributes<HTMLInputElement>,
HTMLInputElement HTMLInputElement
> { > {
label?: string; label?: ReactNode;
field: TextFormInputHook; field: TextFormInputHook;
} }
@ -60,7 +60,7 @@ export interface TextAreaProps extends React.DetailedHTMLProps<
React.TextareaHTMLAttributes<HTMLTextAreaElement>, React.TextareaHTMLAttributes<HTMLTextAreaElement>,
HTMLTextAreaElement HTMLTextAreaElement
> { > {
label?: string; label?: ReactNode;
field: TextFormInputHook; field: TextFormInputHook;
} }
@ -86,7 +86,7 @@ export interface FileInputProps extends React.DetailedHTMLProps<
React.InputHTMLAttributes<HTMLInputElement>, React.InputHTMLAttributes<HTMLInputElement>,
HTMLInputElement HTMLInputElement
> { > {
label?: string; label?: ReactNode;
field: FileFormInputHook; field: FileFormInputHook;
} }
@ -133,7 +133,7 @@ export interface SelectProps extends React.DetailedHTMLProps<
React.SelectHTMLAttributes<HTMLSelectElement>, React.SelectHTMLAttributes<HTMLSelectElement>,
HTMLSelectElement HTMLSelectElement
> { > {
label?: string; label?: ReactNode;
field: TextFormInputHook; field: TextFormInputHook;
children?: ReactNode; children?: ReactNode;
options: React.JSX.Element; options: React.JSX.Element;
@ -164,7 +164,7 @@ export interface RadioGroupProps extends React.DetailedHTMLProps<
React.InputHTMLAttributes<HTMLInputElement>, React.InputHTMLAttributes<HTMLInputElement>,
HTMLInputElement HTMLInputElement
> { > {
label?: string; label?: ReactNode;
field: RadioFormInputHook; field: RadioFormInputHook;
} }

View file

@ -33,7 +33,7 @@ export interface PageableListProps<T> {
isFetching: boolean; isFetching: boolean;
isError: boolean; isError: boolean;
error: FetchBaseQueryError | SerializedError | undefined; error: FetchBaseQueryError | SerializedError | undefined;
emptyMessage: string; emptyMessage: ReactNode;
prevNextLinks?: Links | null | undefined; prevNextLinks?: Links | null | undefined;
} }

View file

@ -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,
};

View file

@ -217,6 +217,7 @@ export const {
useMediaCleanupMutation, useMediaCleanupMutation,
useInstanceKeysExpireMutation, useInstanceKeysExpireMutation,
useGetAccountQuery, useGetAccountQuery,
useLazyGetAccountQuery,
useActionAccountMutation, useActionAccountMutation,
useSearchAccountsQuery, useSearchAccountsQuery,
useLazySearchAccountsQuery, useLazySearchAccountsQuery,

View file

@ -139,6 +139,8 @@ export const gtsApi = createApi({
"Reports", "Reports",
"Account", "Account",
"InstanceRules", "InstanceRules",
"HTTPHeaderAllows",
"HTTPHeaderBlocks",
], ],
endpoints: (build) => ({ endpoints: (build) => ({
instanceV1: build.query<InstanceV1, void>({ instanceV1: build.query<InstanceV1, void>({

View file

@ -18,11 +18,10 @@
*/ */
import typia from "typia"; import typia from "typia";
import { PermType } from "./perm";
export const validateDomainPerms = typia.createValidate<DomainPerm[]>(); export const validateDomainPerms = typia.createValidate<DomainPerm[]>();
export type PermType = "block" | "allow";
/** /**
* A single domain permission entry (block or allow). * A single domain permission entry (block or allow).
*/ */

View file

@ -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;
}

View file

@ -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";

View file

@ -60,7 +60,6 @@ ul li::before {
& > form { & > form {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.5rem;
margin: 1rem 0; margin: 1rem 0;
h1, h2, h3, h4, h5 { h1, h2, h3, h4, h5 {
@ -1192,6 +1191,13 @@ button.with-padding {
margin-bottom: 0; margin-bottom: 0;
} }
.action-buttons {
display: flex;
gap: 0.5rem;
align-items: center;
}
}
.info-list { .info-list {
border: 0.1rem solid $gray1; border: 0.1rem solid $gray1;
display: flex; display: flex;
@ -1229,13 +1235,6 @@ button.with-padding {
} }
} }
.action-buttons {
display: flex;
gap: 0.5rem;
align-items: center;
}
}
.instance-rules { .instance-rules {
list-style-position: inside; list-style-position: inside;
margin: 0; 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) { @media screen and (orientation: portrait) {
.reports .report .byline { .reports .report .byline {
grid-template-columns: 1fr; grid-template-columns: 1fr;

View file

@ -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&nbsp;
<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&nbsp;
<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>
);
}

View file

@ -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}
/>
);
}

View file

@ -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>
);
}

View file

@ -36,6 +36,10 @@ import { useHasPermission } from "../../lib/navigation/util";
* - /settings/admin/actions * - /settings/admin/actions
* - /settings/admin/actions/media * - /settings/admin/actions/media
* - /settings/admin/actions/keys * - /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() { export default function AdminMenu() {
const permissions = ["admin"]; const permissions = ["admin"];
@ -54,6 +58,7 @@ export default function AdminMenu() {
<AdminInstanceMenu /> <AdminInstanceMenu />
<AdminEmojisMenu /> <AdminEmojisMenu />
<AdminActionsMenu /> <AdminActionsMenu />
<AdminHTTPHeaderPermissionsMenu />
</MenuItem> </MenuItem>
); );
} }
@ -127,3 +132,25 @@ function AdminEmojisMenu() {
</MenuItem> </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>
);
}

View file

@ -29,15 +29,17 @@ import Keys from "./actions/keys";
import EmojiOverview from "./emoji/local/overview"; import EmojiOverview from "./emoji/local/overview";
import EmojiDetail from "./emoji/local/detail"; import EmojiDetail from "./emoji/local/detail";
import RemoteEmoji from "./emoji/remote"; import RemoteEmoji from "./emoji/remote";
import HeaderPermsOverview from "./http-header-permissions/overview";
import HeaderPermDetail from "./http-header-permissions/detail";
/* /*
EXPORTED COMPONENTS EXPORTED COMPONENTS
*/ */
/** /**
* - /settings/instance/settings * - /settings/admin/instance/settings
* - /settings/instance/rules * - /settings/admin/instance/rules
* - /settings/instance/rules/:ruleId * - /settings/admin/instance/rules/:ruleId
* - /settings/admin/emojis * - /settings/admin/emojis
* - /settings/admin/emojis/local * - /settings/admin/emojis/local
* - /settings/admin/emojis/local/:emojiId * - /settings/admin/emojis/local/:emojiId
@ -45,6 +47,10 @@ import RemoteEmoji from "./emoji/remote";
* - /settings/admin/actions * - /settings/admin/actions
* - /settings/admin/actions/media * - /settings/admin/actions/media
* - /settings/admin/actions/keys * - /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() { export default function AdminRouter() {
const parentUrl = useBaseUrl(); const parentUrl = useBaseUrl();
@ -57,6 +63,7 @@ export default function AdminRouter() {
<AdminInstanceRouter /> <AdminInstanceRouter />
<AdminEmojisRouter /> <AdminEmojisRouter />
<AdminActionsRouter /> <AdminActionsRouter />
<AdminHTTPHeaderPermissionsRouter />
</Router> </Router>
</BaseUrlContext.Provider> </BaseUrlContext.Provider>
); );
@ -125,9 +132,9 @@ function AdminActionsRouter() {
} }
/** /**
* - /settings/instance/settings * - /settings/admin/instance/settings
* - /settings/instance/rules * - /settings/admin/instance/rules
* - /settings/instance/rules/:ruleId * - /settings/admin/instance/rules/:ruleId
*/ */
function AdminInstanceRouter() { function AdminInstanceRouter() {
const parentUrl = useBaseUrl(); const parentUrl = useBaseUrl();
@ -149,3 +156,29 @@ function AdminInstanceRouter() {
</BaseUrlContext.Provider> </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>
);
}

View file

@ -53,7 +53,7 @@ export default function AccountsPending() {
itemToEntry={itemToEntry} itemToEntry={itemToEntry}
isError={searchRes.isError} isError={searchRes.isError}
error={searchRes.error} error={searchRes.error}
emptyMessage="No pending account sign-ups." emptyMessage={<b>No pending account sign-ups.</b>}
/> />
</div> </div>
); );

View file

@ -166,7 +166,7 @@ export function AccountSearchForm() {
itemToEntry={itemToEntry} itemToEntry={itemToEntry}
isError={searchRes.isError} isError={searchRes.isError}
error={searchRes.error} 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} prevNextLinks={searchRes.data?.links}
/> />
</> </>

View file

@ -34,10 +34,11 @@ import MutationButton from "../../../components/form/mutation-button";
import { useDomainAllowsQuery, useDomainBlocksQuery } from "../../../lib/query/admin/domain-permissions/get"; import { useDomainAllowsQuery, useDomainBlocksQuery } from "../../../lib/query/admin/domain-permissions/get";
import { useAddDomainAllowMutation, useAddDomainBlockMutation, useRemoveDomainAllowMutation, useRemoveDomainBlockMutation } from "../../../lib/query/admin/domain-permissions/update"; 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 { NoArg } from "../../../lib/types/query";
import { Error } from "../../../components/error"; import { Error } from "../../../components/error";
import { useBaseUrl } from "../../../lib/navigation/util"; import { useBaseUrl } from "../../../lib/navigation/util";
import { PermType } from "../../../lib/types/perm";
export default function DomainPermDetail() { export default function DomainPermDetail() {
const baseUrl = useBaseUrl(); const baseUrl = useBaseUrl();

View file

@ -26,8 +26,9 @@ import { useTextInput } from "../../../lib/form";
import { TextInput } from "../../../components/form/inputs"; import { TextInput } from "../../../components/form/inputs";
import Loading from "../../../components/loading"; import Loading from "../../../components/loading";
import { useDomainAllowsQuery, useDomainBlocksQuery } from "../../../lib/query/admin/domain-permissions/get"; 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 { NoArg } from "../../../lib/types/query";
import { PermType } from "../../../lib/types/perm";
export default function DomainPermissionsOverview() { export default function DomainPermissionsOverview() {
// Parse perm type from routing params. // Parse perm type from routing params.