feat(backend): 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)
This commit is contained in:
parent
02bfe94f39
commit
3f5188df0d
13 changed files with 435 additions and 77 deletions
|
@ -413,7 +413,7 @@ async fn main() -> Result<()> {
|
||||||
trust_executables: args.trust_executables,
|
trust_executables: args.trust_executables,
|
||||||
secret,
|
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
|
// Start HTTP server
|
||||||
|
|
|
@ -72,22 +72,40 @@ pub struct UserPayload {
|
||||||
password: String,
|
password: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct LoginQueryParams {
|
||||||
|
via: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[post("/api/auth/login")]
|
#[post("/api/auth/login")]
|
||||||
pub async fn login(
|
pub async fn login(
|
||||||
state: web::Data<State>,
|
state: web::Data<State>,
|
||||||
payload: web::Json<UserPayload>,
|
payload: web::Json<UserPayload>,
|
||||||
|
query: web::Query<LoginQueryParams>,
|
||||||
) -> Result<HttpResponse, Error> {
|
) -> Result<HttpResponse, Error> {
|
||||||
let conn = state.upend.connection().map_err(ErrorInternalServerError)?;
|
let conn = state.upend.connection().map_err(ErrorInternalServerError)?;
|
||||||
|
|
||||||
match conn.authenticate_user(&payload.username, &payload.password) {
|
match conn.authenticate_user(&payload.username, &payload.password) {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
let token = create_token(&payload.username, &state.config.secret)?;
|
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)),
|
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")]
|
#[post("/api/auth/register")]
|
||||||
pub async fn register(
|
pub async fn register(
|
||||||
req: HttpRequest,
|
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> {
|
fn check_auth(req: &HttpRequest, state: &State) -> Result<Option<String>, actix_web::Error> {
|
||||||
if *state.public.lock().unwrap() {
|
if *state.public.lock().unwrap() {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(auth_header) = req.headers().get("Authorization") {
|
let key = if let Some(value) = req.headers().get("Authorization") {
|
||||||
let auth_header = auth_header.to_str().map_err(|err| {
|
let value = value.to_str().map_err(|err| {
|
||||||
ErrorBadRequest(format!("Invalid value in Authorization header: {err:?}"))
|
ErrorBadRequest(format!("Invalid value in Authorization header: {err:?}"))
|
||||||
})?;
|
})?;
|
||||||
|
if !value.starts_with("Bearer ") {
|
||||||
// decode Bearer
|
|
||||||
if !auth_header.starts_with("Bearer ") {
|
|
||||||
return Err(ErrorUnauthorized("Invalid token type."));
|
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>(
|
let token = jsonwebtoken::decode::<JwtClaims>(
|
||||||
auth_header.trim_start_matches("Bearer "),
|
&key,
|
||||||
&jsonwebtoken::DecodingKey::from_secret(state.config.secret.as_ref()),
|
&jsonwebtoken::DecodingKey::from_secret(state.config.secret.as_ref()),
|
||||||
&jsonwebtoken::Validation::default(),
|
&jsonwebtoken::Validation::default(),
|
||||||
);
|
);
|
||||||
|
|
||||||
match token {
|
match token {
|
||||||
Ok(token) => Ok(Some(token.claims.user)),
|
Ok(token) => Ok(Some(token.claims.user)),
|
||||||
Err(err) => Err(ErrorUnauthorized(format!("Invalid token: {err:?}"))),
|
Err(err) => Err(ErrorUnauthorized(format!("Invalid token: {err:?}"))),
|
||||||
|
|
|
@ -47,6 +47,8 @@ where
|
||||||
.wrap(actix_web::middleware::Logger::default().exclude("/api/jobs"))
|
.wrap(actix_web::middleware::Logger::default().exclude("/api/jobs"))
|
||||||
.service(routes::login)
|
.service(routes::login)
|
||||||
.service(routes::register)
|
.service(routes::register)
|
||||||
|
.service(routes::logout)
|
||||||
|
.service(routes::whoami)
|
||||||
.service(routes::get_raw)
|
.service(routes::get_raw)
|
||||||
.service(routes::head_raw)
|
.service(routes::head_raw)
|
||||||
.service(routes::get_thumbnail)
|
.service(routes::get_thumbnail)
|
||||||
|
|
|
@ -22,6 +22,11 @@ const dbg = debug("upend:api");
|
||||||
|
|
||||||
export type { AddressComponents };
|
export type { AddressComponents };
|
||||||
|
|
||||||
|
export type UpendApiError = {
|
||||||
|
kind: "Unauthorized" | "HttpError" | "FetchError" | "Unknown";
|
||||||
|
error?: Error;
|
||||||
|
};
|
||||||
|
|
||||||
export class UpEndApi {
|
export class UpEndApi {
|
||||||
private instanceUrl = "";
|
private instanceUrl = "";
|
||||||
private readonly wasmExtensions: UpEndWasmExtensions | undefined = undefined;
|
private readonly wasmExtensions: UpEndWasmExtensions | undefined = undefined;
|
||||||
|
@ -29,15 +34,21 @@ export class UpEndApi {
|
||||||
|
|
||||||
private queryOnceLRU = new LRU<string, UpListing>({ max: 128 });
|
private queryOnceLRU = new LRU<string, UpListing>({ max: 128 });
|
||||||
private inFlightRequests: { [key: string]: Promise<UpListing> | null } = {};
|
private inFlightRequests: { [key: string]: Promise<UpListing> | null } = {};
|
||||||
|
private key: string | undefined;
|
||||||
|
private readonly onError: ((error: UpendApiError) => void) | undefined;
|
||||||
|
|
||||||
constructor(config: {
|
constructor(config: {
|
||||||
instanceUrl?: string;
|
instanceUrl?: string;
|
||||||
wasmExtensions?: UpEndWasmExtensions;
|
wasmExtensions?: UpEndWasmExtensions;
|
||||||
timeout?: number;
|
timeout?: number;
|
||||||
|
authKey?: string;
|
||||||
|
onError?: (error: UpendApiError) => void;
|
||||||
}) {
|
}) {
|
||||||
this.setInstanceUrl(config.instanceUrl || "http://localhost:8093");
|
this.setInstanceUrl(config.instanceUrl || "http://localhost:8093");
|
||||||
this.wasmExtensions = config.wasmExtensions;
|
this.wasmExtensions = config.wasmExtensions;
|
||||||
this.timeout = config.timeout || 30_000;
|
this.timeout = config.timeout || 30_000;
|
||||||
|
this.key = config.authKey;
|
||||||
|
this.onError = config.onError;
|
||||||
}
|
}
|
||||||
|
|
||||||
public setInstanceUrl(apiUrl: string) {
|
public setInstanceUrl(apiUrl: string) {
|
||||||
|
@ -53,10 +64,10 @@ export class UpEndApi {
|
||||||
options?: ApiFetchOptions,
|
options?: ApiFetchOptions,
|
||||||
): Promise<UpObject> {
|
): Promise<UpObject> {
|
||||||
dbg("Fetching Entity %s", address);
|
dbg("Fetching Entity %s", address);
|
||||||
const signal = this.getAbortSignal(options);
|
const entityFetch = await this.fetch(
|
||||||
const entityFetch = await fetch(`${this.apiUrl}/obj/${address}`, {
|
`${this.apiUrl}/obj/${address}`,
|
||||||
signal,
|
options,
|
||||||
});
|
);
|
||||||
const entityResult = (await entityFetch.json()) as EntityListing;
|
const entityResult = (await entityFetch.json()) as EntityListing;
|
||||||
const entityListing = new UpListing(entityResult.entries);
|
const entityListing = new UpListing(entityResult.entries);
|
||||||
return entityListing.getObject(address);
|
return entityListing.getObject(address);
|
||||||
|
@ -64,8 +75,7 @@ export class UpEndApi {
|
||||||
|
|
||||||
public async fetchEntry(address: string, options?: ApiFetchOptions) {
|
public async fetchEntry(address: string, options?: ApiFetchOptions) {
|
||||||
dbg("Fetching entry %s", address);
|
dbg("Fetching entry %s", address);
|
||||||
const signal = this.getAbortSignal(options);
|
const response = await this.fetch(`${this.apiUrl}/raw/${address}`, options);
|
||||||
const response = await fetch(`${this.apiUrl}/raw/${address}`, { signal });
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
const listing = new UpListing({ address: data });
|
const listing = new UpListing({ address: data });
|
||||||
return listing.entries[0];
|
return listing.entries[0];
|
||||||
|
@ -82,12 +92,10 @@ export class UpEndApi {
|
||||||
if (!this.inFlightRequests[queryStr]) {
|
if (!this.inFlightRequests[queryStr]) {
|
||||||
dbg(`Querying: ${query}`);
|
dbg(`Querying: ${query}`);
|
||||||
this.inFlightRequests[queryStr] = new Promise((resolve, reject) => {
|
this.inFlightRequests[queryStr] = new Promise((resolve, reject) => {
|
||||||
const signal = this.getAbortSignal(options);
|
this.fetch(`${this.apiUrl}/query`, options, {
|
||||||
fetch(`${this.apiUrl}/query`, {
|
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: queryStr,
|
body: queryStr,
|
||||||
keepalive: true,
|
keepalive: true,
|
||||||
signal,
|
|
||||||
})
|
})
|
||||||
.then(async (response) => {
|
.then(async (response) => {
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
@ -117,12 +125,10 @@ export class UpEndApi {
|
||||||
options?: ApiFetchOptions,
|
options?: ApiFetchOptions,
|
||||||
): Promise<PutResult> {
|
): Promise<PutResult> {
|
||||||
dbg("Putting %O", input);
|
dbg("Putting %O", input);
|
||||||
const signal = this.getAbortSignal(options);
|
const response = await this.fetch(`${this.apiUrl}/obj`, options, {
|
||||||
const response = await fetch(`${this.apiUrl}/obj`, {
|
|
||||||
method: "PUT",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
|
method: "PUT",
|
||||||
body: JSON.stringify(input),
|
body: JSON.stringify(input),
|
||||||
signal,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return await response.json();
|
return await response.json();
|
||||||
|
@ -141,12 +147,10 @@ export class UpEndApi {
|
||||||
url += `?provenance=${provenance}`;
|
url += `?provenance=${provenance}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const signal = this.getAbortSignal(options);
|
const response = await this.fetch(url, options, {
|
||||||
const response = await fetch(url, {
|
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(value),
|
body: JSON.stringify(value),
|
||||||
signal,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return await response.json();
|
return await response.json();
|
||||||
|
@ -203,10 +207,9 @@ export class UpEndApi {
|
||||||
xhr.send(formData);
|
xhr.send(formData);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const response = await fetch(`${this.apiUrl}/blob`, {
|
const response = await this.fetch(`${this.apiUrl}/blob`, options, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: formData,
|
body: formData,
|
||||||
signal,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
@ -222,8 +225,9 @@ export class UpEndApi {
|
||||||
options?: ApiFetchOptions,
|
options?: ApiFetchOptions,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
dbg("Deleting entry %s", address);
|
dbg("Deleting entry %s", address);
|
||||||
const signal = this.getAbortSignal(options);
|
await this.fetch(`${this.apiUrl}/obj/${address}`, options, {
|
||||||
await fetch(`${this.apiUrl}/obj/${address}`, { method: "DELETE", signal });
|
method: "DELETE",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public getRaw(address: Address, preview = false) {
|
public getRaw(address: Address, preview = false) {
|
||||||
|
@ -236,26 +240,24 @@ export class UpEndApi {
|
||||||
options?: ApiFetchOptions,
|
options?: ApiFetchOptions,
|
||||||
) {
|
) {
|
||||||
dbg("Getting %s raw (preview = %s)", address, preview);
|
dbg("Getting %s raw (preview = %s)", address, preview);
|
||||||
const signal = this.getAbortSignal(options);
|
return await this.fetch(this.getRaw(address, preview), options);
|
||||||
return await fetch(this.getRaw(address, preview), { signal });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async refreshVault(options?: ApiFetchOptions) {
|
public async refreshVault(options?: ApiFetchOptions) {
|
||||||
dbg("Triggering vault refresh");
|
dbg("Triggering vault refresh");
|
||||||
const signal = this.getAbortSignal(options);
|
return await this.fetch(`${this.apiUrl}/refresh`, options, {
|
||||||
return await fetch(`${this.apiUrl}/refresh`, { method: "POST", signal });
|
method: "POST",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async nativeOpen(address: Address, options?: ApiFetchOptions) {
|
public async nativeOpen(address: Address, options?: ApiFetchOptions) {
|
||||||
dbg("Opening %s natively", address);
|
dbg("Opening %s natively", address);
|
||||||
const signal = this.getAbortSignal(options);
|
return this.fetch(`${this.apiUrl}/raw/${address}?native=1`, options);
|
||||||
return fetch(`${this.apiUrl}/raw/${address}?native=1`, { signal });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async fetchRoots(options?: ApiFetchOptions): Promise<ListingResult> {
|
public async fetchRoots(options?: ApiFetchOptions): Promise<ListingResult> {
|
||||||
dbg("Fetching hierarchical roots...");
|
dbg("Fetching hierarchical roots...");
|
||||||
const signal = this.getAbortSignal(options);
|
const response = await this.fetch(`${this.apiUrl}/hier_roots`, options);
|
||||||
const response = await fetch(`${this.apiUrl}/hier_roots`, { signal });
|
|
||||||
const roots = await response.json();
|
const roots = await response.json();
|
||||||
dbg("Hierarchical roots: %O", roots);
|
dbg("Hierarchical roots: %O", roots);
|
||||||
return roots;
|
return roots;
|
||||||
|
@ -263,8 +265,7 @@ export class UpEndApi {
|
||||||
|
|
||||||
public async fetchJobs(options?: ApiFetchOptions): Promise<IJob[]> {
|
public async fetchJobs(options?: ApiFetchOptions): Promise<IJob[]> {
|
||||||
// dbg("Fetching jobs...");
|
// dbg("Fetching jobs...");
|
||||||
const signal = this.getAbortSignal(options);
|
const response = await this.fetch(`${this.apiUrl}/jobs`, options);
|
||||||
const response = await fetch(`${this.apiUrl}/jobs`, { signal });
|
|
||||||
return await response.json();
|
return await response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -272,8 +273,7 @@ export class UpEndApi {
|
||||||
options?: ApiFetchOptions,
|
options?: ApiFetchOptions,
|
||||||
): Promise<AttributeListingResult> {
|
): Promise<AttributeListingResult> {
|
||||||
dbg("Fetching all attributes...");
|
dbg("Fetching all attributes...");
|
||||||
const signal = this.getAbortSignal(options);
|
const response = await this.fetch(`${this.apiUrl}/all/attributes`, options);
|
||||||
const response = await fetch(`${this.apiUrl}/all/attributes`, { signal });
|
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
dbg("All attributes: %O", result);
|
dbg("All attributes: %O", result);
|
||||||
return await result;
|
return await result;
|
||||||
|
@ -281,19 +281,25 @@ export class UpEndApi {
|
||||||
|
|
||||||
public async fetchInfo(options?: ApiFetchOptions): Promise<VaultInfo> {
|
public async fetchInfo(options?: ApiFetchOptions): Promise<VaultInfo> {
|
||||||
dbg("Fetching vault info...");
|
dbg("Fetching vault info...");
|
||||||
const signal = this.getAbortSignal(options);
|
const response = await this.fetch(`${this.apiUrl}/info`, options);
|
||||||
const response = await fetch(`${this.apiUrl}/info`, { signal });
|
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
dbg("Vault info: %O", result);
|
dbg("Vault info: %O", result);
|
||||||
return 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(
|
public async fetchStoreInfo(
|
||||||
options?: ApiFetchOptions,
|
options?: ApiFetchOptions,
|
||||||
): Promise<{ [key: string]: StoreInfo }> {
|
): Promise<{ [key: string]: StoreInfo }> {
|
||||||
dbg("Fetching store info...");
|
dbg("Fetching store info...");
|
||||||
const signal = this.getAbortSignal(options);
|
const response = await this.fetch(`${this.apiUrl}/stats/store`, options);
|
||||||
const response = await fetch(`${this.apiUrl}/stats/store`, { signal });
|
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
dbg("Store info: %O");
|
dbg("Store info: %O");
|
||||||
return await result;
|
return await result;
|
||||||
|
@ -309,16 +315,15 @@ export class UpEndApi {
|
||||||
await this.wasmExtensions.init();
|
await this.wasmExtensions.init();
|
||||||
return this.wasmExtensions.AddressTypeConstants[input];
|
return this.wasmExtensions.AddressTypeConstants[input];
|
||||||
}
|
}
|
||||||
const signal = this.getAbortSignal(options);
|
response = await this.fetch(
|
||||||
response = await fetch(`${this.apiUrl}/address?type=${input}`, {
|
`${this.apiUrl}/address?type=${input}`,
|
||||||
signal,
|
options,
|
||||||
});
|
);
|
||||||
} else {
|
} else {
|
||||||
if ("urlContent" in input) {
|
if ("urlContent" in input) {
|
||||||
const signal = this.getAbortSignal(options);
|
response = await this.fetch(
|
||||||
response = await fetch(
|
|
||||||
`${this.apiUrl}/address?url_content=${input.urlContent}`,
|
`${this.apiUrl}/address?url_content=${input.urlContent}`,
|
||||||
{ signal },
|
options,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
throw new Error("Input cannot be empty.");
|
throw new Error("Input cannot be empty.");
|
||||||
|
@ -352,8 +357,7 @@ export class UpEndApi {
|
||||||
public async getVaultOptions(
|
public async getVaultOptions(
|
||||||
options?: ApiFetchOptions,
|
options?: ApiFetchOptions,
|
||||||
): Promise<VaultOptions> {
|
): Promise<VaultOptions> {
|
||||||
const signal = this.getAbortSignal(options);
|
const response = await this.fetch(`${this.apiUrl}/options`, options);
|
||||||
const response = await fetch(`${this.apiUrl}/options`, { signal });
|
|
||||||
return await response.json();
|
return await response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -369,12 +373,10 @@ export class UpEndApi {
|
||||||
payload["blob_mode"] = blob_mode;
|
payload["blob_mode"] = blob_mode;
|
||||||
}
|
}
|
||||||
|
|
||||||
const signal = this.getAbortSignal(apiOptions);
|
const response = await this.fetch(`${this.apiUrl}/options`, apiOptions, {
|
||||||
const response = await fetch(`${this.apiUrl}/options`, {
|
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
signal,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
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) {
|
private getAbortSignal(options: ApiFetchOptions | undefined) {
|
||||||
const controller = options?.abortController || new AbortController();
|
const controller = options?.abortController || new AbortController();
|
||||||
const timeout = options?.timeout || this.timeout;
|
const timeout = options?.timeout || this.timeout;
|
||||||
|
@ -390,6 +466,51 @@ export class UpEndApi {
|
||||||
}
|
}
|
||||||
return controller.signal;
|
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 {
|
export interface ApiFetchOptions {
|
||||||
|
@ -398,6 +519,7 @@ export interface ApiFetchOptions {
|
||||||
}
|
}
|
||||||
|
|
||||||
export type VaultBlobMode = "Flat" | "Mirror" | "Incoming";
|
export type VaultBlobMode = "Flat" | "Mirror" | "Incoming";
|
||||||
|
|
||||||
export interface VaultOptions {
|
export interface VaultOptions {
|
||||||
blob_mode: VaultBlobMode;
|
blob_mode: VaultBlobMode;
|
||||||
}
|
}
|
||||||
|
|
|
@ -97,6 +97,7 @@ export interface VaultInfo {
|
||||||
location: string;
|
location: string;
|
||||||
version: string;
|
version: string;
|
||||||
desktop: boolean;
|
desktop: boolean;
|
||||||
|
public: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StoreInfo {
|
export interface StoreInfo {
|
||||||
|
|
|
@ -1,6 +1,25 @@
|
||||||
import { UpEndApi } from '@upnd/upend';
|
import { UpEndApi } from '@upnd/upend';
|
||||||
import { UpEndWasmExtensionsWeb } from '@upnd/upend/wasm/web';
|
import { UpEndWasmExtensionsWeb } from '@upnd/upend/wasm/web';
|
||||||
import wasmURL from '@upnd/wasm-web/upend_wasm_bg.wasm?url';
|
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);
|
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();
|
||||||
|
}
|
||||||
|
|
90
webui/src/lib/components/LoginModal.svelte
Normal file
90
webui/src/lib/components/LoginModal.svelte
Normal 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>
|
|
@ -2,13 +2,16 @@
|
||||||
import { addEmitter } from '../AddModal.svelte';
|
import { addEmitter } from '../AddModal.svelte';
|
||||||
import Icon from '../utils/Icon.svelte';
|
import Icon from '../utils/Icon.svelte';
|
||||||
import { jobsEmitter } from './Jobs.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 Selector, { type SelectorValue } from '../utils/Selector.svelte';
|
||||||
import { i18n } from '$lib/i18n';
|
import { i18n } from '$lib/i18n';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
import { vaultInfo } from '$lib/util/info';
|
||||||
|
import { slide } from 'svelte/transition';
|
||||||
|
|
||||||
let selector: Selector;
|
let selector: Selector;
|
||||||
|
let userDropdown = false;
|
||||||
|
|
||||||
let lastSearched: SelectorValue[] = [];
|
let lastSearched: SelectorValue[] = [];
|
||||||
|
|
||||||
|
@ -57,6 +60,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
let fileInput: HTMLInputElement;
|
let fileInput: HTMLInputElement;
|
||||||
|
|
||||||
function onFileChange() {
|
function onFileChange() {
|
||||||
if (fileInput.files?.length) {
|
if (fileInput.files?.length) {
|
||||||
addEmitter.emit('files', Array.from(fileInput.files));
|
addEmitter.emit('files', Array.from(fileInput.files));
|
||||||
|
@ -73,6 +77,12 @@
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<svelte:body
|
||||||
|
on:click={() => {
|
||||||
|
userDropdown = false;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<h1>
|
<h1>
|
||||||
<a href="/">
|
<a href="/">
|
||||||
|
@ -91,13 +101,31 @@
|
||||||
<Icon name="search" slot="prefix" />
|
<Icon name="search" slot="prefix" />
|
||||||
</Selector>
|
</Selector>
|
||||||
</div>
|
</div>
|
||||||
<button class="button" on:click={() => addEmitter.emit('choose')}>
|
<button on:click={() => addEmitter.emit('choose')}>
|
||||||
<Icon name="upload" />
|
<Icon name="upload" />
|
||||||
<input type="file" multiple bind:this={fileInput} on:change={onFileChange} />
|
<input type="file" multiple bind:this={fileInput} on:change={onFileChange} />
|
||||||
</button>
|
</button>
|
||||||
<button class="button" on:click={() => rescan()} title="Rescan vault">
|
<button on:click={() => rescan()} title="Rescan vault">
|
||||||
<Icon name="refresh" />
|
<Icon name="refresh" />
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
<style lang="scss">
|
<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) {
|
@media screen and (max-width: 600px) {
|
||||||
.name {
|
.name {
|
||||||
display: none;
|
display: none;
|
||||||
|
|
|
@ -166,7 +166,12 @@
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div class="icon">
|
<div class="icon">
|
||||||
<IconButton name="trash" color="#dc322f" on:click={() => removeEntity(entity)} />
|
<IconButton
|
||||||
|
plain
|
||||||
|
name="trash"
|
||||||
|
color="#dc322f"
|
||||||
|
on:click={() => removeEntity(entity)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="object">
|
<div class="object">
|
||||||
|
@ -181,7 +186,12 @@
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="icon">
|
<div class="icon">
|
||||||
<IconButton name="trash" color="#dc322f" on:click={() => removeEntity(entity)} />
|
<IconButton
|
||||||
|
plain
|
||||||
|
name="trash"
|
||||||
|
color="#dc322f"
|
||||||
|
on:click={() => removeEntity(entity)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
|
@ -300,6 +310,12 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-left: 0.25em;
|
||||||
|
}
|
||||||
|
|
||||||
.add {
|
.add {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
|
@ -22,6 +22,7 @@ select {
|
||||||
font-size: 2em;
|
font-size: 2em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
.button {
|
.button {
|
||||||
border: 1px solid var(--foreground);
|
border: 1px solid var(--foreground);
|
||||||
border-radius: 4px;
|
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,
|
||||||
.mark-entity *::first-letter {
|
.mark-entity *::first-letter {
|
||||||
color: color.scale(color.mix(colors.$base1, colors.$red), $saturation: -33%);
|
color: color.scale(color.mix(colors.$base1, colors.$red), $saturation: -33%);
|
||||||
|
|
|
@ -1,9 +1,22 @@
|
||||||
import api from '$lib/api';
|
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 { VaultInfo } from '@upnd/upend/types';
|
||||||
|
import type { VaultOptions } from '@upnd/upend/api';
|
||||||
|
|
||||||
export const vaultInfo = readable(undefined as VaultInfo | undefined, (set) => {
|
export const vaultInfo: Readable<VaultInfo | undefined> = readable(
|
||||||
api.fetchInfo().then(async (info: VaultInfo) => {
|
undefined as VaultInfo | undefined,
|
||||||
set(info);
|
(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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
|
@ -4,6 +4,17 @@
|
||||||
import Footer from '$lib/components/layout/Footer.svelte';
|
import Footer from '$lib/components/layout/Footer.svelte';
|
||||||
import DropPasteHandler from '$lib/components/DropPasteHandler.svelte';
|
import DropPasteHandler from '$lib/components/DropPasteHandler.svelte';
|
||||||
import AddModal from '$lib/components/AddModal.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>
|
</script>
|
||||||
|
|
||||||
<Header />
|
<Header />
|
||||||
|
@ -13,4 +24,7 @@
|
||||||
<Footer />
|
<Footer />
|
||||||
|
|
||||||
<AddModal />
|
<AddModal />
|
||||||
|
{#if $vaultInfo && !$vaultInfo.public && !$currentUser}
|
||||||
|
<LoginModal />
|
||||||
|
{/if}
|
||||||
<DropPasteHandler />>
|
<DropPasteHandler />>
|
||||||
|
|
|
@ -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');
|
$: updateTitle($vaultInfo?.name || $i18n.t('Home') || 'Home');
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue