Compare commits
5 commits
de733a0682
...
f0ae648492
Author | SHA1 | Date | |
---|---|---|---|
f0ae648492 | |||
54be457a47 | |||
b47ed7b699 | |||
02e2b230bf | |||
f3bb2d5d01 |
16 changed files with 143 additions and 19 deletions
|
@ -15,6 +15,9 @@ public partial class InternalController(DatabaseContext db) : ControllerBase
|
|||
[GeneratedRegex(@"(\{\w+\})")]
|
||||
private static partial Regex PathVarRegex();
|
||||
|
||||
[GeneratedRegex(@"\{id\}")]
|
||||
private static partial Regex IdCountRegex();
|
||||
|
||||
private static string GetCleanedTemplate(string template)
|
||||
{
|
||||
if (template.StartsWith("api/v2"))
|
||||
|
@ -22,8 +25,19 @@ public partial class InternalController(DatabaseContext db) : ControllerBase
|
|||
template = PathVarRegex()
|
||||
.Replace(template, "{id}") // Replace all path variables (almost always IDs) 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}"))
|
||||
{
|
||||
// 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;
|
||||
}
|
||||
|
||||
|
|
|
@ -6,11 +6,31 @@ import log from "$lib/log";
|
|||
|
||||
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 = {
|
||||
/**
|
||||
* The token for this request. Where possible, `cookies` should be passed instead.
|
||||
* Will override `cookies` if both are passed.
|
||||
*/
|
||||
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;
|
||||
/**
|
||||
* The body for this request, which will be serialized to JSON. Should be a plain JS object.
|
||||
*/
|
||||
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;
|
||||
/**
|
||||
* The cookies object to try to get the token from. Can only be passed in loader and action functions.
|
||||
*/
|
||||
cookies?: Cookies;
|
||||
};
|
||||
|
||||
|
@ -19,7 +39,7 @@ export type RequestArgs = {
|
|||
* @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 args Optional arguments to the request function.
|
||||
* @returns A Promise object.
|
||||
* @returns A Response object.
|
||||
*/
|
||||
export async function baseRequest(
|
||||
method: Method,
|
||||
|
@ -29,7 +49,7 @@ export async function baseRequest(
|
|||
const token = args.token ?? args.cookies?.get(TOKEN_COOKIE_NAME);
|
||||
|
||||
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);
|
||||
|
||||
|
@ -38,7 +58,7 @@ export async function baseRequest(
|
|||
...(token ? { Authorization: token } : {}),
|
||||
};
|
||||
|
||||
return await fetchFn(url, {
|
||||
return await fetchFn(PUBLIC_API_BASE + url, {
|
||||
method,
|
||||
headers,
|
||||
body: args.body ? JSON.stringify(args.body) : undefined,
|
||||
|
|
|
@ -65,7 +65,7 @@
|
|||
onclick={() => move(index, false)}
|
||||
/>
|
||||
<InputGroupText>{$t("editor.field-name")}</InputGroupText>
|
||||
<input class="form-control" bind:value={name} />
|
||||
<input class="form-control" bind:value={name} autocomplete="off" />
|
||||
<IconButton
|
||||
color="danger"
|
||||
icon="trash3"
|
||||
|
@ -88,6 +88,7 @@
|
|||
class="form-control"
|
||||
bind:value={newEntry}
|
||||
placeholder={$t("editor.new-entry")}
|
||||
autocomplete="off"
|
||||
/>
|
||||
<IconButton type="submit" color="success" icon="plus" tooltip={$t("editor.add-entry")} />
|
||||
</form>
|
||||
|
|
|
@ -40,7 +40,7 @@
|
|||
tooltip={$t("editor.move-entry-down")}
|
||||
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>
|
||||
<span use:tippy={{ content: status.tooltip }}>
|
||||
<DropdownToggle color="secondary" caret>
|
||||
|
|
|
@ -56,6 +56,7 @@
|
|||
class="form-control"
|
||||
bind:value={newFieldName}
|
||||
placeholder={$t("editor.field-name")}
|
||||
autocomplete="off"
|
||||
/>
|
||||
<IconButton type="submit" color="success" icon="plus" tooltip={$t("editor.add-field")} />
|
||||
</form>
|
||||
|
|
|
@ -50,7 +50,7 @@
|
|||
tooltip={$t("editor.move-entry-down")}
|
||||
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>
|
||||
<span use:tippy={{ content: status.tooltip }}>
|
||||
<DropdownToggle color="secondary" caret>
|
||||
|
@ -88,6 +88,7 @@
|
|||
type="text"
|
||||
class="form-control"
|
||||
bind:value={value.display_text}
|
||||
autocomplete="off"
|
||||
/>
|
||||
<IconButton id="display-help" icon="question" tooltip="Help" color="secondary" />
|
||||
<!-- TODO: remove children={false} once sveltestrap is updated
|
||||
|
|
|
@ -60,6 +60,6 @@
|
|||
{/each}
|
||||
|
||||
<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")} />
|
||||
</form>
|
||||
|
|
|
@ -73,7 +73,14 @@
|
|||
"extra-info-header": "Extra error information",
|
||||
"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-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": {
|
||||
"general-information-tab": "General information",
|
||||
|
@ -103,7 +110,9 @@
|
|||
"username-update-success": "Successfully changed your username!",
|
||||
"create-member-title": "Create a new member",
|
||||
"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",
|
||||
"no": "No",
|
||||
|
|
44
Foxnouns.Frontend/src/routes/+error.svelte
Normal file
44
Foxnouns.Frontend/src/routes/+error.svelte
Normal 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>
|
|
@ -23,7 +23,13 @@
|
|||
<form method="POST" action="?/changeUsername">
|
||||
<FormGroup class="mb-3">
|
||||
<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>
|
||||
</InputGroup>
|
||||
</FormGroup>
|
||||
|
|
|
@ -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, "/");
|
||||
},
|
||||
};
|
|
@ -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>
|
|
@ -84,7 +84,13 @@
|
|||
<h4>{$t("edit-profile.member-name")}</h4>
|
||||
<form method="POST" action="?/changeName" class="mb-3">
|
||||
<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">
|
||||
{$t("change")}
|
||||
</button>
|
||||
|
@ -99,6 +105,7 @@
|
|||
name="display-name"
|
||||
placeholder={data.member.name}
|
||||
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>
|
||||
</InputGroup>
|
||||
|
|
|
@ -16,7 +16,7 @@
|
|||
<form method="POST">
|
||||
<div class="my-3">
|
||||
<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>
|
||||
<button class="btn btn-primary" type="submit">{$t("profile.create-member-button")}</button>
|
||||
</form>
|
||||
|
|
|
@ -114,6 +114,7 @@
|
|||
name="display-name"
|
||||
placeholder={data.user.username}
|
||||
value={data.user.display_name}
|
||||
autocomplete="off"
|
||||
/>
|
||||
<button class="btn btn-primary" type="submit">{$t("change")}</button>
|
||||
</InputGroup>
|
||||
|
@ -136,6 +137,7 @@
|
|||
class="form-control"
|
||||
value={data.user.member_title}
|
||||
placeholder={$t("profile.default-members-header")}
|
||||
autocomplete="off"
|
||||
/>
|
||||
<p class="text-muted mt-1">
|
||||
<Icon name="info-circle-fill" aria-hidden />
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
@ -91,12 +90,7 @@ func getReset(w http.ResponseWriter) int64 {
|
|||
}
|
||||
|
||||
func requestBucket(method, template string) string {
|
||||
hasher := sha256.New()
|
||||
_, err := hasher.Write([]byte(method + "-" + template))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return hex.EncodeToString(hasher.Sum(nil))
|
||||
return hex.EncodeToString([]byte(method + "-" + template))
|
||||
}
|
||||
|
||||
func (l *Limiter) globalLimiter(user string) *httprate.RateLimiter {
|
||||
|
|
Loading…
Reference in a new issue