Compare commits

...

5 commits

Author SHA1 Message Date
sam
f0ae648492
feat(frontend): force log out page 2024-12-02 16:32:13 +01:00
sam
54be457a47
chore(frontend): add docs to RequestArgs 2024-12-02 16:31:48 +01:00
sam
b47ed7b699
rate limit tweaks
the /users/{id} prefix contains most API routes so it's not a good idea
to put a single rate limit on *all* of them combined. the rate limiter
will now ignore the /users/{id} prefix *if* there's a second {id}
parameter in the URL.

also, X-RateLimit-Bucket is no longer hashed, so it can be directly
decoded by clients to get the actual bucket name. i'm not sure if this
will actually be useful, but it's nice to have the option.
2024-12-02 16:13:56 +01:00
sam
02e2b230bf
feat(frontend): actual error page 2024-12-02 15:24:09 +01:00
sam
f3bb2d5d01
fix(frontend): add autocomplete=off tags to most inputs 2024-12-02 15:06:17 +01:00
16 changed files with 143 additions and 19 deletions

View file

@ -15,6 +15,9 @@ public partial class InternalController(DatabaseContext db) : ControllerBase
[GeneratedRegex(@"(\{\w+\})")] [GeneratedRegex(@"(\{\w+\})")]
private static partial Regex PathVarRegex(); private static partial Regex PathVarRegex();
[GeneratedRegex(@"\{id\}")]
private static partial Regex IdCountRegex();
private static string GetCleanedTemplate(string template) private static string GetCleanedTemplate(string template)
{ {
if (template.StartsWith("api/v2")) if (template.StartsWith("api/v2"))
@ -22,8 +25,19 @@ public partial class InternalController(DatabaseContext db) : ControllerBase
template = PathVarRegex() template = PathVarRegex()
.Replace(template, "{id}") // Replace all path variables (almost always IDs) with `{id}` .Replace(template, "{id}") // Replace all path variables (almost always IDs) with `{id}`
.Replace("@me", "{id}"); // Also replace hardcoded `@me` with `{id}` .Replace("@me", "{id}"); // Also replace hardcoded `@me` with `{id}`
// If there's at least one path parameter, we only return the *first* part of the path.
if (template.Contains("{id}")) if (template.Contains("{id}"))
{
// However, if the path starts with /users/{id} *and* there's another path parameter (such as a member ID)
// we ignore the leading /users/{id}. This is because a lot of routes are scoped by user, but should have
// separate rate limits from other user-scoped routes.
if (template.StartsWith("/users/{id}/") && IdCountRegex().Count(template) >= 2)
template = template["/users/{id}".Length..];
return template.Split("{id}")[0] + "{id}"; return template.Split("{id}")[0] + "{id}";
}
return template; return template;
} }

View file

@ -6,11 +6,31 @@ import log from "$lib/log";
export type Method = "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; export type Method = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
/**
* Optional arguments for a request. `load` and `action` functions should always pass `fetch` and `cookies`.
*/
export type RequestArgs = { export type RequestArgs = {
/**
* The token for this request. Where possible, `cookies` should be passed instead.
* Will override `cookies` if both are passed.
*/
token?: string; token?: string;
/**
* Whether this request is to an internal endpoint.
* Internal requests bypass the rate limiter and are prefixed with /api/internal/ rather than /api/v2/.
*/
isInternal?: boolean; isInternal?: boolean;
/**
* The body for this request, which will be serialized to JSON. Should be a plain JS object.
*/
body?: any; body?: any;
/**
* The fetch function to use. Should be passed in loader and action functions, but can be safely ignored for client-side requests.
*/
fetch?: typeof fetch; fetch?: typeof fetch;
/**
* The cookies object to try to get the token from. Can only be passed in loader and action functions.
*/
cookies?: Cookies; cookies?: Cookies;
}; };
@ -19,7 +39,7 @@ export type RequestArgs = {
* @param method The HTTP method for this request * @param method The HTTP method for this request
* @param path The path for this request, without the /api/v2 prefix, starting with a slash. * @param path The path for this request, without the /api/v2 prefix, starting with a slash.
* @param args Optional arguments to the request function. * @param args Optional arguments to the request function.
* @returns A Promise object. * @returns A Response object.
*/ */
export async function baseRequest( export async function baseRequest(
method: Method, method: Method,
@ -29,7 +49,7 @@ export async function baseRequest(
const token = args.token ?? args.cookies?.get(TOKEN_COOKIE_NAME); const token = args.token ?? args.cookies?.get(TOKEN_COOKIE_NAME);
const fetchFn = args.fetch ?? fetch; const fetchFn = args.fetch ?? fetch;
const url = `${PUBLIC_API_BASE}/${args.isInternal ? "internal" : "v2"}${path}`; const url = `/${args.isInternal ? "internal" : "v2"}${path}`;
log.debug("Sending request to %s %s", method, url); log.debug("Sending request to %s %s", method, url);
@ -38,7 +58,7 @@ export async function baseRequest(
...(token ? { Authorization: token } : {}), ...(token ? { Authorization: token } : {}),
}; };
return await fetchFn(url, { return await fetchFn(PUBLIC_API_BASE + url, {
method, method,
headers, headers,
body: args.body ? JSON.stringify(args.body) : undefined, body: args.body ? JSON.stringify(args.body) : undefined,

View file

@ -65,7 +65,7 @@
onclick={() => move(index, false)} onclick={() => move(index, false)}
/> />
<InputGroupText>{$t("editor.field-name")}</InputGroupText> <InputGroupText>{$t("editor.field-name")}</InputGroupText>
<input class="form-control" bind:value={name} /> <input class="form-control" bind:value={name} autocomplete="off" />
<IconButton <IconButton
color="danger" color="danger"
icon="trash3" icon="trash3"
@ -88,6 +88,7 @@
class="form-control" class="form-control"
bind:value={newEntry} bind:value={newEntry}
placeholder={$t("editor.new-entry")} placeholder={$t("editor.new-entry")}
autocomplete="off"
/> />
<IconButton type="submit" color="success" icon="plus" tooltip={$t("editor.add-entry")} /> <IconButton type="submit" color="success" icon="plus" tooltip={$t("editor.add-entry")} />
</form> </form>

View file

@ -40,7 +40,7 @@
tooltip={$t("editor.move-entry-down")} tooltip={$t("editor.move-entry-down")}
onclick={() => moveValue(index, false)} onclick={() => moveValue(index, false)}
/> />
<input type="text" class="form-control" bind:value={value.value} /> <input type="text" class="form-control" bind:value={value.value} autocomplete="off" />
<ButtonDropdown> <ButtonDropdown>
<span use:tippy={{ content: status.tooltip }}> <span use:tippy={{ content: status.tooltip }}>
<DropdownToggle color="secondary" caret> <DropdownToggle color="secondary" caret>

View file

@ -56,6 +56,7 @@
class="form-control" class="form-control"
bind:value={newFieldName} bind:value={newFieldName}
placeholder={$t("editor.field-name")} placeholder={$t("editor.field-name")}
autocomplete="off"
/> />
<IconButton type="submit" color="success" icon="plus" tooltip={$t("editor.add-field")} /> <IconButton type="submit" color="success" icon="plus" tooltip={$t("editor.add-field")} />
</form> </form>

View file

@ -50,7 +50,7 @@
tooltip={$t("editor.move-entry-down")} tooltip={$t("editor.move-entry-down")}
onclick={() => moveValue(index, true)} onclick={() => moveValue(index, true)}
/> />
<input type="text" class="form-control" bind:value={value.value} /> <input type="text" class="form-control" bind:value={value.value} autocomplete="off" />
<ButtonDropdown> <ButtonDropdown>
<span use:tippy={{ content: status.tooltip }}> <span use:tippy={{ content: status.tooltip }}>
<DropdownToggle color="secondary" caret> <DropdownToggle color="secondary" caret>
@ -88,6 +88,7 @@
type="text" type="text"
class="form-control" class="form-control"
bind:value={value.display_text} bind:value={value.display_text}
autocomplete="off"
/> />
<IconButton id="display-help" icon="question" tooltip="Help" color="secondary" /> <IconButton id="display-help" icon="question" tooltip="Help" color="secondary" />
<!-- TODO: remove children={false} once sveltestrap is updated <!-- TODO: remove children={false} once sveltestrap is updated

View file

@ -60,6 +60,6 @@
{/each} {/each}
<form class="input-group m-1" onsubmit={addEntry}> <form class="input-group m-1" onsubmit={addEntry}>
<input type="text" class="form-control" bind:value={newEntry} /> <input type="text" class="form-control" bind:value={newEntry} autocomplete="off" />
<IconButton type="submit" color="success" icon="plus" tooltip={$t("editor.add-entry")} /> <IconButton type="submit" color="success" icon="plus" tooltip={$t("editor.add-entry")} />
</form> </form>

View file

@ -73,7 +73,14 @@
"extra-info-header": "Extra error information", "extra-info-header": "Extra error information",
"noscript-title": "This page requires JavaScript", "noscript-title": "This page requires JavaScript",
"noscript-info": "This page requires JavaScript to function correctly. Some buttons may not work, or the page may not work at all.", "noscript-info": "This page requires JavaScript to function correctly. Some buttons may not work, or the page may not work at all.",
"noscript-short": "Requires JavaScript" "noscript-short": "Requires JavaScript",
"404-description": "The page you were trying to visit was not found. If you're sure the page should exist, check your address bar for any typos.",
"back-to-profile-button": "Go back to your profile",
"back-to-main-page-button": "Go back to the main page",
"back-to-prev-page-button": "Go back to the previous page",
"400-description": "Something went wrong with your request. This error should never land you on this page, so it's probably a bug.",
"500-description": "Something went wrong on the server. Please try again later.",
"unknown-status-description": "Something went wrong, but we're not sure what. Please try again."
}, },
"settings": { "settings": {
"general-information-tab": "General information", "general-information-tab": "General information",
@ -103,7 +110,9 @@
"username-update-success": "Successfully changed your username!", "username-update-success": "Successfully changed your username!",
"create-member-title": "Create a new member", "create-member-title": "Create a new member",
"create-member-name-label": "Member name", "create-member-name-label": "Member name",
"auth-remove-method": "Remove" "auth-remove-method": "Remove",
"force-log-out-warning": "Make sure you're still able to log in before using this!",
"force-log-out-confirmation": "Are you sure you want to log out from all devices? If you just want to log out from this device, click the \"Log out\" button on your settings page."
}, },
"yes": "Yes", "yes": "Yes",
"no": "No", "no": "No",

View file

@ -0,0 +1,44 @@
<script lang="ts">
import { page } from "$app/stores";
import { t } from "$lib/i18n";
import type { LayoutData } from "./$types";
let error = $derived($page.error!);
type Props = { data: LayoutData };
let { data }: Props = $props();
</script>
<svelte:head>
<title>{$t("title.an-error-occurred")} • pronouns.cc</title>
</svelte:head>
<div class="container">
<h3>{$t("title.an-error-occurred")}</h3>
<p>
<strong>{$page.status}</strong>: {error.message}
</p>
<p>
{#if $page.status === 400}
{$t("error.400-description")}
{:else if $page.status === 404}
{$t("error.404-description")}
{:else if $page.status === 500}
{$t("error.500-description")}
{:else}
{$t("error.unknown-status-description")}
{/if}
</p>
<div class="btn-group">
{#if data.meUser}
<a class="btn btn-primary" href="/@{data.meUser.username}">
{$t("error.back-to-profile-button")}
</a>
{:else}
<a class="btn btn-primary" href="/">{$t("error.back-to-main-page-button")}</a>
{/if}
<button class="btn btn-secondary" type="button" onclick={() => history.back()}>
{$t("error.back-to-prev-page-button")}
</button>
</div>
</div>

View file

@ -23,7 +23,13 @@
<form method="POST" action="?/changeUsername"> <form method="POST" action="?/changeUsername">
<FormGroup class="mb-3"> <FormGroup class="mb-3">
<InputGroup class="m-1 mt-3 w-md-75"> <InputGroup class="m-1 mt-3 w-md-75">
<Input type="text" value={data.user.username} name="username" required /> <Input
type="text"
value={data.user.username}
name="username"
required
autocomplete="off"
/>
<Button type="submit" color="secondary">{$t("settings.change-username-button")}</Button> <Button type="submit" color="secondary">{$t("settings.change-username-button")}</Button>
</InputGroup> </InputGroup>
</FormGroup> </FormGroup>

View file

@ -0,0 +1,11 @@
import { fastRequest } from "$api";
import { clearToken } from "$lib";
import { redirect } from "@sveltejs/kit";
export const actions = {
default: async ({ fetch, cookies }) => {
await fastRequest("POST", "/auth/force-log-out", { isInternal: true, fetch, cookies }, true);
clearToken(cookies);
redirect(303, "/");
},
};

View file

@ -0,0 +1,14 @@
<script lang="ts">
import { t } from "$lib/i18n";
</script>
<h3>{$t("settings.force-log-out-title")}</h3>
<p>
{$t("settings.force-log-out-confirmation")}
<strong>{$t("settings.force-log-out-warning")}</strong>
</p>
<form method="POST">
<button type="submit" class="btn btn-danger">{$t("settings.force-log-out-button")}</button>
</form>

View file

@ -84,7 +84,13 @@
<h4>{$t("edit-profile.member-name")}</h4> <h4>{$t("edit-profile.member-name")}</h4>
<form method="POST" action="?/changeName" class="mb-3"> <form method="POST" action="?/changeName" class="mb-3">
<InputGroup> <InputGroup>
<input name="name" class="form-control" type="text" value={data.member.name} /> <input
name="name"
class="form-control"
type="text"
value={data.member.name}
autocomplete="off"
/>
<button type="submit" class="btn btn-primary"> <button type="submit" class="btn btn-primary">
{$t("change")} {$t("change")}
</button> </button>
@ -99,6 +105,7 @@
name="display-name" name="display-name"
placeholder={data.member.name} placeholder={data.member.name}
value={data.member.display_name !== data.member.name ? data.member.display_name : null} value={data.member.display_name !== data.member.name ? data.member.display_name : null}
autocomplete="off"
/> />
<button class="btn btn-primary" type="submit">{$t("change")}</button> <button class="btn btn-primary" type="submit">{$t("change")}</button>
</InputGroup> </InputGroup>

View file

@ -16,7 +16,7 @@
<form method="POST"> <form method="POST">
<div class="my-3"> <div class="my-3">
<label class="form-label" for="name">{$t("settings.create-member-name-label")}</label> <label class="form-label" for="name">{$t("settings.create-member-name-label")}</label>
<input class="form-control" type="text" id="name" name="name" required /> <input class="form-control" type="text" id="name" name="name" required autocomplete="off" />
</div> </div>
<button class="btn btn-primary" type="submit">{$t("profile.create-member-button")}</button> <button class="btn btn-primary" type="submit">{$t("profile.create-member-button")}</button>
</form> </form>

View file

@ -114,6 +114,7 @@
name="display-name" name="display-name"
placeholder={data.user.username} placeholder={data.user.username}
value={data.user.display_name} value={data.user.display_name}
autocomplete="off"
/> />
<button class="btn btn-primary" type="submit">{$t("change")}</button> <button class="btn btn-primary" type="submit">{$t("change")}</button>
</InputGroup> </InputGroup>
@ -136,6 +137,7 @@
class="form-control" class="form-control"
value={data.user.member_title} value={data.user.member_title}
placeholder={$t("profile.default-members-header")} placeholder={$t("profile.default-members-header")}
autocomplete="off"
/> />
<p class="text-muted mt-1"> <p class="text-muted mt-1">
<Icon name="info-circle-fill" aria-hidden /> <Icon name="info-circle-fill" aria-hidden />

View file

@ -1,7 +1,6 @@
package main package main
import ( import (
"crypto/sha256"
"encoding/hex" "encoding/hex"
"fmt" "fmt"
"net/http" "net/http"
@ -91,12 +90,7 @@ func getReset(w http.ResponseWriter) int64 {
} }
func requestBucket(method, template string) string { func requestBucket(method, template string) string {
hasher := sha256.New() return hex.EncodeToString([]byte(method + "-" + template))
_, err := hasher.Write([]byte(method + "-" + template))
if err != nil {
panic(err)
}
return hex.EncodeToString(hasher.Sum(nil))
} }
func (l *Limiter) globalLimiter(user string) *httprate.RateLimiter { func (l *Limiter) globalLimiter(user string) *httprate.RateLimiter {