feat: add user management

- no more static keys, full register/login/logout flow
- add API error type
- refactor API to centralize request calls
- minor refactors re: vault options
- CSS refactor (buttons don't require classes, input styling)
feat/plugins-backend
Tomáš Mládek 2024-03-30 16:35:21 +01:00
parent 02bfe94f39
commit 05ee557d1a
13 changed files with 435 additions and 77 deletions

View File

@ -413,7 +413,7 @@ async fn main() -> Result<()> {
trust_executables: args.trust_executables,
secret,
},
public: Arc::new(Mutex::new(upend.connection()?.get_users()?.len() == 0)),
public: Arc::new(Mutex::new(upend.connection()?.get_users()?.is_empty())),
};
// Start HTTP server

View File

@ -72,22 +72,40 @@ pub struct UserPayload {
password: String,
}
#[derive(Deserialize)]
pub struct LoginQueryParams {
via: Option<String>,
}
#[post("/api/auth/login")]
pub async fn login(
state: web::Data<State>,
payload: web::Json<UserPayload>,
query: web::Query<LoginQueryParams>,
) -> Result<HttpResponse, Error> {
let conn = state.upend.connection().map_err(ErrorInternalServerError)?;
match conn.authenticate_user(&payload.username, &payload.password) {
Ok(()) => {
let token = create_token(&payload.username, &state.config.secret)?;
Ok(HttpResponse::Ok().json(json!({ "token": token })))
match query.via.as_deref() {
Some("cookie") => Ok(HttpResponse::NoContent()
.append_header((http::header::SET_COOKIE, format!("key={}; Path=/", token)))
.finish()),
_ => Ok(HttpResponse::Ok().json(json!({ "key": token }))),
}
}
Err(e) => Err(ErrorUnauthorized(e)),
}
}
#[post("/api/auth/logout")]
pub async fn logout() -> Result<HttpResponse, Error> {
Ok(HttpResponse::NoContent()
.append_header((http::header::SET_COOKIE, "key=; Path=/; Max-Age=0"))
.finish())
}
#[post("/api/auth/register")]
pub async fn register(
req: HttpRequest,
@ -108,27 +126,40 @@ pub async fn register(
}
}
#[get("/api/auth/whoami")]
pub async fn whoami(req: HttpRequest, state: web::Data<State>) -> Result<HttpResponse, Error> {
let user = check_auth(&req, &state)?;
Ok(HttpResponse::Ok().json(json!({ "user": user })))
}
fn check_auth(req: &HttpRequest, state: &State) -> Result<Option<String>, actix_web::Error> {
if *state.public.lock().unwrap() {
return Ok(None);
}
if let Some(auth_header) = req.headers().get("Authorization") {
let auth_header = auth_header.to_str().map_err(|err| {
let key = if let Some(value) = req.headers().get("Authorization") {
let value = value.to_str().map_err(|err| {
ErrorBadRequest(format!("Invalid value in Authorization header: {err:?}"))
})?;
// decode Bearer
if !auth_header.starts_with("Bearer ") {
if !value.starts_with("Bearer ") {
return Err(ErrorUnauthorized("Invalid token type."));
}
Some(value.trim_start_matches("Bearer ").to_string())
} else if let Ok(cookies) = req.cookies() {
cookies
.iter()
.find(|c| c.name() == "key")
.map(|cookie| cookie.value().to_string())
} else {
None
};
if let Some(key) = key {
let token = jsonwebtoken::decode::<JwtClaims>(
auth_header.trim_start_matches("Bearer "),
&key,
&jsonwebtoken::DecodingKey::from_secret(state.config.secret.as_ref()),
&jsonwebtoken::Validation::default(),
);
match token {
Ok(token) => Ok(Some(token.claims.user)),
Err(err) => Err(ErrorUnauthorized(format!("Invalid token: {err:?}"))),

View File

@ -47,6 +47,8 @@ where
.wrap(actix_web::middleware::Logger::default().exclude("/api/jobs"))
.service(routes::login)
.service(routes::register)
.service(routes::logout)
.service(routes::whoami)
.service(routes::get_raw)
.service(routes::head_raw)
.service(routes::get_thumbnail)

View File

@ -22,6 +22,11 @@ const dbg = debug("upend:api");
export type { AddressComponents };
export type UpendApiError = {
kind: "Unauthorized" | "HttpError" | "FetchError" | "Unknown";
error?: Error;
};
export class UpEndApi {
private instanceUrl = "";
private readonly wasmExtensions: UpEndWasmExtensions | undefined = undefined;
@ -29,15 +34,21 @@ export class UpEndApi {
private queryOnceLRU = new LRU<string, UpListing>({ max: 128 });
private inFlightRequests: { [key: string]: Promise<UpListing> | null } = {};
private key: string | undefined;
private readonly onError: ((error: UpendApiError) => void) | undefined;
constructor(config: {
instanceUrl?: string;
wasmExtensions?: UpEndWasmExtensions;
timeout?: number;
authKey?: string;
onError?: (error: UpendApiError) => void;
}) {
this.setInstanceUrl(config.instanceUrl || "http://localhost:8093");
this.wasmExtensions = config.wasmExtensions;
this.timeout = config.timeout || 30_000;
this.key = config.authKey;
this.onError = config.onError;
}
public setInstanceUrl(apiUrl: string) {
@ -53,10 +64,10 @@ export class UpEndApi {
options?: ApiFetchOptions,
): Promise<UpObject> {
dbg("Fetching Entity %s", address);
const signal = this.getAbortSignal(options);
const entityFetch = await fetch(`${this.apiUrl}/obj/${address}`, {
signal,
});
const entityFetch = await this.fetch(
`${this.apiUrl}/obj/${address}`,
options,
);
const entityResult = (await entityFetch.json()) as EntityListing;
const entityListing = new UpListing(entityResult.entries);
return entityListing.getObject(address);
@ -64,8 +75,7 @@ export class UpEndApi {
public async fetchEntry(address: string, options?: ApiFetchOptions) {
dbg("Fetching entry %s", address);
const signal = this.getAbortSignal(options);
const response = await fetch(`${this.apiUrl}/raw/${address}`, { signal });
const response = await this.fetch(`${this.apiUrl}/raw/${address}`, options);
const data = await response.json();
const listing = new UpListing({ address: data });
return listing.entries[0];
@ -82,12 +92,10 @@ export class UpEndApi {
if (!this.inFlightRequests[queryStr]) {
dbg(`Querying: ${query}`);
this.inFlightRequests[queryStr] = new Promise((resolve, reject) => {
const signal = this.getAbortSignal(options);
fetch(`${this.apiUrl}/query`, {
this.fetch(`${this.apiUrl}/query`, options, {
method: "POST",
body: queryStr,
keepalive: true,
signal,
})
.then(async (response) => {
if (!response.ok) {
@ -117,12 +125,10 @@ export class UpEndApi {
options?: ApiFetchOptions,
): Promise<PutResult> {
dbg("Putting %O", input);
const signal = this.getAbortSignal(options);
const response = await fetch(`${this.apiUrl}/obj`, {
method: "PUT",
const response = await this.fetch(`${this.apiUrl}/obj`, options, {
headers: { "Content-Type": "application/json" },
method: "PUT",
body: JSON.stringify(input),
signal,
});
return await response.json();
@ -141,12 +147,10 @@ export class UpEndApi {
url += `?provenance=${provenance}`;
}
const signal = this.getAbortSignal(options);
const response = await fetch(url, {
const response = await this.fetch(url, options, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(value),
signal,
});
return await response.json();
@ -203,10 +207,9 @@ export class UpEndApi {
xhr.send(formData);
});
} else {
const response = await fetch(`${this.apiUrl}/blob`, {
const response = await this.fetch(`${this.apiUrl}/blob`, options, {
method: "PUT",
body: formData,
signal,
});
if (!response.ok) {
@ -222,8 +225,9 @@ export class UpEndApi {
options?: ApiFetchOptions,
): Promise<void> {
dbg("Deleting entry %s", address);
const signal = this.getAbortSignal(options);
await fetch(`${this.apiUrl}/obj/${address}`, { method: "DELETE", signal });
await this.fetch(`${this.apiUrl}/obj/${address}`, options, {
method: "DELETE",
});
}
public getRaw(address: Address, preview = false) {
@ -236,26 +240,24 @@ export class UpEndApi {
options?: ApiFetchOptions,
) {
dbg("Getting %s raw (preview = %s)", address, preview);
const signal = this.getAbortSignal(options);
return await fetch(this.getRaw(address, preview), { signal });
return await this.fetch(this.getRaw(address, preview), options);
}
public async refreshVault(options?: ApiFetchOptions) {
dbg("Triggering vault refresh");
const signal = this.getAbortSignal(options);
return await fetch(`${this.apiUrl}/refresh`, { method: "POST", signal });
return await this.fetch(`${this.apiUrl}/refresh`, options, {
method: "POST",
});
}
public async nativeOpen(address: Address, options?: ApiFetchOptions) {
dbg("Opening %s natively", address);
const signal = this.getAbortSignal(options);
return fetch(`${this.apiUrl}/raw/${address}?native=1`, { signal });
return this.fetch(`${this.apiUrl}/raw/${address}?native=1`, options);
}
public async fetchRoots(options?: ApiFetchOptions): Promise<ListingResult> {
dbg("Fetching hierarchical roots...");
const signal = this.getAbortSignal(options);
const response = await fetch(`${this.apiUrl}/hier_roots`, { signal });
const response = await this.fetch(`${this.apiUrl}/hier_roots`, options);
const roots = await response.json();
dbg("Hierarchical roots: %O", roots);
return roots;
@ -263,8 +265,7 @@ export class UpEndApi {
public async fetchJobs(options?: ApiFetchOptions): Promise<IJob[]> {
// dbg("Fetching jobs...");
const signal = this.getAbortSignal(options);
const response = await fetch(`${this.apiUrl}/jobs`, { signal });
const response = await this.fetch(`${this.apiUrl}/jobs`, options);
return await response.json();
}
@ -272,8 +273,7 @@ export class UpEndApi {
options?: ApiFetchOptions,
): Promise<AttributeListingResult> {
dbg("Fetching all attributes...");
const signal = this.getAbortSignal(options);
const response = await fetch(`${this.apiUrl}/all/attributes`, { signal });
const response = await this.fetch(`${this.apiUrl}/all/attributes`, options);
const result = await response.json();
dbg("All attributes: %O", result);
return await result;
@ -281,19 +281,25 @@ export class UpEndApi {
public async fetchInfo(options?: ApiFetchOptions): Promise<VaultInfo> {
dbg("Fetching vault info...");
const signal = this.getAbortSignal(options);
const response = await fetch(`${this.apiUrl}/info`, { signal });
const response = await this.fetch(`${this.apiUrl}/info`, options);
const result = await response.json();
dbg("Vault info: %O", result);
return result;
}
public async fetchOptions(options?: ApiFetchOptions): Promise<VaultOptions> {
dbg("Fetching vault options...");
const response = await this.fetch(`${this.apiUrl}/options`, options);
const result = await response.json();
dbg("Vault options: %O", result);
return result;
}
public async fetchStoreInfo(
options?: ApiFetchOptions,
): Promise<{ [key: string]: StoreInfo }> {
dbg("Fetching store info...");
const signal = this.getAbortSignal(options);
const response = await fetch(`${this.apiUrl}/stats/store`, { signal });
const response = await this.fetch(`${this.apiUrl}/stats/store`, options);
const result = await response.json();
dbg("Store info: %O");
return await result;
@ -309,16 +315,15 @@ export class UpEndApi {
await this.wasmExtensions.init();
return this.wasmExtensions.AddressTypeConstants[input];
}
const signal = this.getAbortSignal(options);
response = await fetch(`${this.apiUrl}/address?type=${input}`, {
signal,
});
response = await this.fetch(
`${this.apiUrl}/address?type=${input}`,
options,
);
} else {
if ("urlContent" in input) {
const signal = this.getAbortSignal(options);
response = await fetch(
response = await this.fetch(
`${this.apiUrl}/address?url_content=${input.urlContent}`,
{ signal },
options,
);
} else {
throw new Error("Input cannot be empty.");
@ -352,8 +357,7 @@ export class UpEndApi {
public async getVaultOptions(
options?: ApiFetchOptions,
): Promise<VaultOptions> {
const signal = this.getAbortSignal(options);
const response = await fetch(`${this.apiUrl}/options`, { signal });
const response = await this.fetch(`${this.apiUrl}/options`, options);
return await response.json();
}
@ -369,12 +373,10 @@ export class UpEndApi {
payload["blob_mode"] = blob_mode;
}
const signal = this.getAbortSignal(apiOptions);
const response = await fetch(`${this.apiUrl}/options`, {
const response = await this.fetch(`${this.apiUrl}/options`, apiOptions, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
signal,
});
if (!response.ok) {
@ -382,6 +384,80 @@ export class UpEndApi {
}
}
public async authenticate(
credentials: {
username: string;
password: string;
},
mode: "key",
options?: ApiFetchOptions,
): Promise<{ key: string }>;
public async authenticate(
credentials: {
username: string;
password: string;
},
mode?: "cookie",
options?: ApiFetchOptions,
): Promise<void>;
public async authenticate(
credentials: {
username: string;
password: string;
},
mode: "key" | "cookie" | undefined,
options?: ApiFetchOptions,
): Promise<{ key: string } | void> {
const via = mode || "cookie";
const response = await this.fetch(
`${this.apiUrl}/auth/login?via=${via}`,
options,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(credentials),
},
);
if (!response.ok) {
throw Error(await response.text());
}
if (mode === "key") {
const data = await response.json();
if (!data.key) {
throw Error("No key returned from server.");
}
this.key = data.key;
return data.key;
}
}
public async authStatus(
options?: ApiFetchOptions,
): Promise<{ user: string } | undefined> {
const response = await this.fetch(`${this.apiUrl}/auth/whoami`, options);
return await response.json();
}
public async resetAuth(mode: "key"): Promise<void>;
public async resetAuth(
mode?: "cookie",
options?: ApiFetchOptions,
): Promise<void>;
public async resetAuth(
mode?: "key" | "cookie",
options?: ApiFetchOptions,
): Promise<void> {
if (mode === "key") {
this.key = undefined;
} else {
await this.fetch(`${this.apiUrl}/auth/logout`, options, {
method: "POST",
});
}
}
private getAbortSignal(options: ApiFetchOptions | undefined) {
const controller = options?.abortController || new AbortController();
const timeout = options?.timeout || this.timeout;
@ -390,6 +466,51 @@ export class UpEndApi {
}
return controller.signal;
}
private async fetch(
url: string,
options: ApiFetchOptions | undefined,
requestInit?: RequestInit & { headers?: Record<string, string> },
): Promise<Response> {
const signal = this.getAbortSignal(options);
const headers = requestInit?.headers || {};
if (this.key) {
headers["Authorization"] = `Bearer ${this.key}`;
}
let result: Response;
let error: UpendApiError | undefined;
try {
result = await fetch(url, {
...requestInit,
signal,
headers,
});
if (!result.ok) {
if (result.status === 401) {
error = { kind: "Unauthorized" };
} else {
error = {
kind: "HttpError",
error: new Error(
`HTTP Error ${result.status}: ${result.statusText}`,
),
};
}
}
} catch (e) {
error = { kind: "FetchError", error: e as Error };
}
if (error) {
if (this.onError) {
this.onError(error);
}
throw error;
}
return result!;
}
}
export interface ApiFetchOptions {
@ -398,6 +519,7 @@ export interface ApiFetchOptions {
}
export type VaultBlobMode = "Flat" | "Mirror" | "Incoming";
export interface VaultOptions {
blob_mode: VaultBlobMode;
}

View File

@ -97,6 +97,7 @@ export interface VaultInfo {
location: string;
version: string;
desktop: boolean;
public: boolean;
}
export interface StoreInfo {

View File

@ -1,6 +1,25 @@
import { UpEndApi } from '@upnd/upend';
import { UpEndWasmExtensionsWeb } from '@upnd/upend/wasm/web';
import wasmURL from '@upnd/wasm-web/upend_wasm_bg.wasm?url';
import { type StartStopNotifier, writable, type Writable } from 'svelte/store';
const wasm = new UpEndWasmExtensionsWeb(wasmURL);
export default new UpEndApi({ instanceUrl: '/', wasmExtensions: wasm });
const api = new UpEndApi({ instanceUrl: '/', wasmExtensions: wasm });
export default api;
export const currentUser: Writable<string | undefined> = writable(
undefined as string | undefined,
((set) => {
api.authStatus().then((result) => set(result?.user));
}) as StartStopNotifier<string | undefined>
);
export async function login(credentials: { username: string; password: string }) {
await api.authenticate(credentials);
window.location.reload();
}
export async function logout() {
await api.resetAuth();
window.location.reload();
}

View File

@ -0,0 +1,90 @@
<script lang="ts">
import { i18n } from '$lib/i18n';
import Icon from '$lib/components/utils/Icon.svelte';
import { login } from '$lib/api';
let username = '';
let password = '';
let error: string | undefined;
let authenticating = false;
async function submit() {
error = undefined;
try {
authenticating = true;
await login({ username, password });
} catch (e) {
error = (e as object).toString();
} finally {
authenticating = false;
}
}
</script>
<div class="modal-container">
<div class="modal" class:authenticating>
<h2>
<Icon name="lock" />
{$i18n.t('Authorization required')}
</h2>
<form on:submit|preventDefault={submit}>
<input placeholder={$i18n.t('Username')} type="text" bind:value={username} required />
<input placeholder={$i18n.t('Password')} type="password" bind:value={password} required />
<button type="submit"> <Icon plain name="log-in" /> {$i18n.t('Login')}</button>
</form>
{#if error}
<div class="error">{error}</div>
{/if}
</div>
</div>
<style lang="scss">
@use '$lib/styles/colors';
.modal-container {
position: fixed;
left: 0;
top: 0;
width: 100vw;
height: 100vh;
background: rgba(0, 0, 0, 0.66);
color: var(--foreground);
z-index: 9;
}
.modal {
position: fixed;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
background: var(--background);
color: var(--foreground);
border-radius: 5px;
border: 1px solid var(--foreground);
padding: 2rem;
display: flex;
flex-direction: column;
gap: 1rem;
&.authenticating {
filter: brightness(0.66);
pointer-events: none;
}
}
h2 {
text-align: center;
margin: 0 0 1rem 0;
}
form {
display: contents;
}
.error {
color: colors.$red;
text-align: center;
}
</style>

View File

@ -2,13 +2,16 @@
import { addEmitter } from '../AddModal.svelte';
import Icon from '../utils/Icon.svelte';
import { jobsEmitter } from './Jobs.svelte';
import api from '$lib/api';
import api, { currentUser, logout } from '$lib/api';
import Selector, { type SelectorValue } from '../utils/Selector.svelte';
import { i18n } from '$lib/i18n';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { vaultInfo } from '$lib/util/info';
import { slide } from 'svelte/transition';
let selector: Selector;
let userDropdown = false;
let lastSearched: SelectorValue[] = [];
@ -57,6 +60,7 @@
}
let fileInput: HTMLInputElement;
function onFileChange() {
if (fileInput.files?.length) {
addEmitter.emit('files', Array.from(fileInput.files));
@ -73,6 +77,12 @@
}
</script>
<svelte:body
on:click={() => {
userDropdown = false;
}}
/>
<div class="header">
<h1>
<a href="/">
@ -91,13 +101,31 @@
<Icon name="search" slot="prefix" />
</Selector>
</div>
<button class="button" on:click={() => addEmitter.emit('choose')}>
<button on:click={() => addEmitter.emit('choose')}>
<Icon name="upload" />
<input type="file" multiple bind:this={fileInput} on:change={onFileChange} />
</button>
<button class="button" on:click={() => rescan()} title="Rescan vault">
<button on:click={() => rescan()} title="Rescan vault">
<Icon name="refresh" />
</button>
<button
class="user"
disabled={$vaultInfo?.public}
on:click|stopPropagation={() => (userDropdown = true)}
>
<Icon name="user" />
</button>
{#if userDropdown}
<!-- svelte-ignore a11y-no-static-element-interactions a11y-click-events-have-key-events -->
<div class="user-dropdown" transition:slide on:click|stopPropagation={() => {}}>
<div class="user">
<Icon plain name="user" />
{$currentUser || '???'}
</div>
<hr />
<button on:click={() => logout()}> <Icon name="log-out" />{$i18n.t('Log out')}</button>
</div>
{/if}
</div>
<style lang="scss">
@ -141,6 +169,18 @@
}
}
.user-dropdown {
background: var(--background);
border-radius: 4px;
border: 1px solid var(--foreground);
padding: 0.5em;
position: absolute;
top: 3.5rem;
right: 0.5rem;
box-shadow: 0 0 0.5rem rgba(0, 0, 0, 0.5);
z-index: 99;
}
@media screen and (max-width: 600px) {
.name {
display: none;

View File

@ -166,7 +166,12 @@
}}
/>
<div class="icon">
<IconButton name="trash" color="#dc322f" on:click={() => removeEntity(entity)} />
<IconButton
plain
name="trash"
color="#dc322f"
on:click={() => removeEntity(entity)}
/>
</div>
{:else}
<div class="object">
@ -181,7 +186,12 @@
/>
</div>
<div class="icon">
<IconButton name="trash" color="#dc322f" on:click={() => removeEntity(entity)} />
<IconButton
plain
name="trash"
color="#dc322f"
on:click={() => removeEntity(entity)}
/>
</div>
{/if}
{:else}
@ -300,6 +310,12 @@
}
}
.icon {
display: flex;
align-items: center;
margin-left: 0.25em;
}
.add {
display: flex;
flex-direction: column;

View File

@ -22,6 +22,7 @@ select {
font-size: 2em;
}
button,
.button {
border: 1px solid var(--foreground);
border-radius: 4px;
@ -52,6 +53,23 @@ select {
}
}
input[type='text'],
input[type='password'] {
padding: 0.25em;
border: 1px solid var(--foreground-lighter);
border-radius: 4px;
background: var(--background);
color: var(--foreground);
transition: box-shadow 0.25s;
&:focus {
box-shadow: -1px -1px 2px 2px var(--primary);
outline: none;
}
}
.mark-entity::first-letter,
.mark-entity *::first-letter {
color: color.scale(color.mix(colors.$base1, colors.$red), $saturation: -33%);

View File

@ -1,9 +1,22 @@
import api from '$lib/api';
import { readable } from 'svelte/store';
import { readable, type Readable } from 'svelte/store';
import type { VaultInfo } from '@upnd/upend/types';
import type { VaultOptions } from '@upnd/upend/api';
export const vaultInfo = readable(undefined as VaultInfo | undefined, (set) => {
api.fetchInfo().then(async (info: VaultInfo) => {
set(info);
});
});
export const vaultInfo: Readable<VaultInfo | undefined> = readable(
undefined as VaultInfo | undefined,
(set) => {
api.fetchInfo().then(async (info: VaultInfo) => {
set(info);
});
}
);
export const vaultOptions: Readable<VaultOptions | undefined> = readable(
undefined as VaultOptions | undefined,
(set) => {
api.fetchOptions().then(async (options: VaultOptions) => {
set(options);
});
}
);

View File

@ -4,6 +4,17 @@
import Footer from '$lib/components/layout/Footer.svelte';
import DropPasteHandler from '$lib/components/DropPasteHandler.svelte';
import AddModal from '$lib/components/AddModal.svelte';
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { vaultInfo, vaultOptions } from '$lib/util/info';
import LoginModal from '$lib/components/LoginModal.svelte';
import { currentUser } from '$lib/api';
onMount(() => {
if ($vaultOptions && !$vaultOptions.blob_mode) {
goto('/setup');
}
});
</script>
<Header />
@ -13,4 +24,7 @@
<Footer />
<AddModal />
{#if $vaultInfo && !$vaultInfo.public && !$currentUser}
<LoginModal />
{/if}
<DropPasteHandler />>

View File

@ -157,14 +157,6 @@
}
];
fetch('/api/options')
.then((res) => res.json())
.then((options) => {
if (!options.blob_mode) {
goto('/setup');
}
});
$: updateTitle($vaultInfo?.name || $i18n.t('Home') || 'Home');
</script>