diff --git a/Todo b/Todo index 109105c..de82f9b 100644 --- a/Todo +++ b/Todo @@ -38,14 +38,9 @@ In-Progress: - Wire Series-Detail Refresh to Fix Manga-Metadata (MAJOR) -Notes from last time: - - Storage has been configured, now just need protocols - - Export/Import - - Migration - - Data-Clean + - Add Disable Auto-Completed Feature to Library + - Implement Unwired Fixes (DOCUMENT) + - Cap ReaderSettings Zoom (100) + - Fix SeriesDetail Chapter Amount (Link to Scanlator Filtering) -- MAJOR Migration - - Completed GQL Migration - - Completed Types Migration (May need Refactor) - - Completed Shared Migration - - Completed Features Migration \ No newline at end of file +Notes from last time: diff --git a/src-tauri/src/server/conf.rs b/src-tauri/src/server/conf.rs index 25da9e6..2e27e51 100644 --- a/src-tauri/src/server/conf.rs +++ b/src-tauri/src/server/conf.rs @@ -2,12 +2,12 @@ use std::path::PathBuf; const DEFAULT_SERVER_CONF: &str = r#"server.ip = "127.0.0.1" server.port = 4567 -server.webUIEnabled = false +server.webUIEnabled = true server.initialOpenInBrowserEnabled = false server.systemTrayEnabled = false server.webUIInterface = "browser" server.webUIFlavor = "WebUI" -server.webUIChannel = "stable" +server.webUIChannel = "preview" server.electronPath = "" server.debugLogsEnabled = false server.downloadAsCbz = true @@ -37,7 +37,7 @@ pub fn seed_server_conf(data_dir: &PathBuf) { let patched = patch_conf_key( patch_conf_key( - patch_conf_key(contents, "server.webUIEnabled", "false"), + patch_conf_key(contents, "server.webUIEnabled", "true"), "server.initialOpenInBrowserEnabled", "false", ), diff --git a/src/App.svelte b/src/App.svelte index 9afb13a..20263e9 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -128,7 +128,7 @@ setTimeout(() => devSplash = false, 340)} /> -{:else if !appReady && !boot.loginRequired && !boot.unsupportedMode} +{:else if !appReady && !boot.loginRequired} bypassBoot(() => { appReady = true; })} /> -{:else if boot.unsupportedMode || boot.loginRequired} +{:else if boot.loginRequired} { appReady = true; }} /> @@ -146,6 +146,11 @@ onDismiss={() => { idle = false; }} /> {/if} + {#if boot.sessionExpired} + + { boot.sessionExpired = false; }} /> + {/if} +
{#if !store.activeChapter}{/if}
diff --git a/src/api/client.ts b/src/api/client.ts index 2a935c0..b105592 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -1,5 +1,6 @@ import { store } from "@store/state.svelte"; -import { fetchAuthenticated } from "../core/auth"; +import { fetchAuthenticated, AuthRequiredError } from "../core/auth"; +import { boot } from "@store/boot.svelte"; const DEFAULT_URL = "http://127.0.0.1:4567"; @@ -43,12 +44,13 @@ async function fetchWithRetry( for (let i = 0; i < retries; i++) { if (signal?.aborted) throw new DOMException("Aborted", "AbortError"); try { - const res = await fetchAuthenticated(url, init, signal); + const res = await fetchAuthenticated(url, init, signal, boot.skipped); if (signal?.aborted) throw new DOMException("Aborted", "AbortError"); return res; } catch (e: any) { if (e?.authRequired) throw e; if (e?.name === "AbortError" || signal?.aborted) throw new DOMException("Aborted", "AbortError"); + if (e instanceof AuthRequiredError) throw e; if (i === retries - 1) throw e; await abortableSleep(delayMs * Math.pow(1.5, i), signal); } @@ -70,6 +72,15 @@ export async function gql( if (!res.ok) throw new Error(`Suwayomi HTTP ${res.status}`); const json: GQLResponse = await res.json(); if (signal?.aborted) throw new DOMException("Aborted", "AbortError"); - if (json.errors?.length) throw new Error(json.errors[0].message); + if (json.errors?.length) { + const isAuthError = json.errors.some(e => /unauthorized|unauthenticated/i.test(e.message)); + if (isAuthError && !boot.skipped) { + boot.sessionExpired = true; + boot.loginRequired = true; + boot.loginUser = store.settings.serverAuthUser ?? ""; + throw new AuthRequiredError(json.errors[0].message); + } + throw new Error(json.errors[0].message); + } return json.data; -} +} \ No newline at end of file diff --git a/src/api/mutations/tracking.ts b/src/api/mutations/tracking.ts index 9b5846b..ad7939a 100644 --- a/src/api/mutations/tracking.ts +++ b/src/api/mutations/tracking.ts @@ -110,7 +110,7 @@ export const PUSH_KOSYNC_PROGRESS = ` export const LOGIN_USER = ` mutation Login($username: String!, $password: String!) { login(input: { username: $username, password: $password }) { - accessToken refreshToken + accessToken } } `; diff --git a/src/core/auth.ts b/src/core/auth.ts index e7e670b..3ba4cdd 100644 --- a/src/core/auth.ts +++ b/src/core/auth.ts @@ -1,10 +1,29 @@ import { store, updateSettings } from "@store/state.svelte"; -export type AuthMode = "NONE" | "BASIC_AUTH" | "SIMPLE_LOGIN" | "UI_LOGIN"; +export type AuthMode = "NONE" | "BASIC_AUTH" | "UI_LOGIN"; + +export class AuthRequiredError extends Error { + constructor(msg = "Authentication required") { + super(msg); + this.name = "AuthRequiredError"; + } +} + +let _accessToken: string | null = null; + +export const uiAuth = { + getToken: () => _accessToken, + setToken: (t: string) => { _accessToken = t; }, + clearToken: () => { _accessToken = null; }, +}; export const authSession = { - clearTokens() {}, - hasSession(): boolean { return true; }, + clearTokens() { uiAuth.clearToken(); }, + hasSession(): boolean { + const mode = store.settings.serverAuthMode ?? "NONE"; + if (mode === "UI_LOGIN") return _accessToken !== null; + return true; + }, }; function getServerBase(): string { @@ -22,24 +41,72 @@ function basicHeader(user: string, pass: string): Record { return { Authorization: `Basic ${btoa(`${user}:${pass}`)}` }; } -export function fetchAuthenticated(url: string, init: RequestInit, signal?: AbortSignal): Promise { - const mode = store.settings.serverAuthMode ?? "NONE"; +function bearerHeader(token: string): Record { + return { Authorization: `Bearer ${token}` }; +} + +function gqlBody(query: string, variables?: Record): string { + return JSON.stringify({ query, ...(variables ? { variables } : {}) }); +} + +export async function fetchAuthenticated( + url: string, + init: RequestInit, + signal?: AbortSignal, + skipped = false, +): Promise { + const mode = store.settings.serverAuthMode ?? "NONE"; + const baseHeaders = (init.headers ?? {}) as Record; + if (mode === "BASIC_AUTH") { const user = store.settings.serverAuthUser?.trim() ?? ""; const pass = store.settings.serverAuthPass?.trim() ?? ""; return fetch(url, { ...init, signal, credentials: "omit", - headers: { ...(init.headers as Record ?? {}), ...(user && pass ? basicHeader(user, pass) : {}) }, + headers: { ...baseHeaders, ...(user && pass ? basicHeader(user, pass) : {}) }, }); } + + if (mode === "UI_LOGIN") { + const token = uiAuth.getToken(); + if (!token) { + if (skipped) return fetch(url, { ...init, signal, credentials: "omit", headers: baseHeaders }); + throw new AuthRequiredError(); + } + return fetch(url, { + ...init, signal, credentials: "omit", + headers: { ...baseHeaders, ...bearerHeader(token) }, + }); + } + return fetch(url, { ...init, signal, credentials: "omit" }); } +export async function loginUI(user: string, pass: string): Promise { + const res = await fetch(`${getServerBase()}/api/graphql`, { + method: "POST", credentials: "omit", + headers: { "Content-Type": "application/json" }, + body: gqlBody( + `mutation Login($username: String!, $password: String!) { + login(input: { username: $username, password: $password }) { accessToken } + }`, + { username: user, password: pass }, + ), + signal: timeoutSignal(8000), + }); + if (!res.ok) throw new Error(`Login request failed (${res.status})`); + const json = await res.json(); + const token: string | undefined = json?.data?.login?.accessToken; + if (!token) throw new Error(json?.errors?.[0]?.message ?? "Login failed"); + uiAuth.setToken(token); + updateSettings({ serverAuthMode: "UI_LOGIN" }); +} + export async function loginBasic(user: string, pass: string): Promise { const res = await fetch(`${getServerBase()}/api/graphql`, { method: "POST", credentials: "omit", headers: { "Content-Type": "application/json", ...basicHeader(user, pass) }, - body: JSON.stringify({ query: "{ __typename }" }), + body: gqlBody("{ __typename }"), signal: timeoutSignal(5000), }); if (!res.ok) throw new Error(`Authentication failed (${res.status})`); @@ -47,39 +114,37 @@ export async function loginBasic(user: string, pass: string): Promise { } export async function logout(): Promise { - updateSettings({ serverAuthPass: "" }); + uiAuth.clearToken(); + updateSettings({ serverAuthPass: "", serverAuthMode: "NONE" }); } -export async function probeServer(): Promise<"ok" | "auth_required" | "unsupported_mode" | "unreachable"> { +export async function probeServer(): Promise<"ok" | "auth_required" | "unreachable"> { const base = getServerBase(); const mode = store.settings.serverAuthMode ?? "NONE"; const s = store.settings; + + if (mode === "UI_LOGIN" && !_accessToken) return "auth_required"; + try { const headers: Record = { "Content-Type": "application/json" }; if (mode === "BASIC_AUTH") { const user = s.serverAuthUser?.trim() ?? ""; const pass = s.serverAuthPass?.trim() ?? ""; if (user && pass) Object.assign(headers, basicHeader(user, pass)); + } else if (mode === "UI_LOGIN" && _accessToken) { + Object.assign(headers, bearerHeader(_accessToken)); } + const res = await fetch(`${base}/api/graphql`, { method: "POST", credentials: "omit", headers, - body: JSON.stringify({ query: "{ __typename }" }), + body: gqlBody("{ __typename }"), signal: timeoutSignal(5000), }); + if (res.ok) return "ok"; - if (res.status === 401) { - const wwwAuth = res.headers.get("WWW-Authenticate") ?? ""; - if (/basic/i.test(wwwAuth)) { - if (mode !== "BASIC_AUTH") updateSettings({ serverAuthMode: "BASIC_AUTH" }); - return "auth_required"; - } - if (/bearer/i.test(wwwAuth)) { - if (mode !== "UI_LOGIN") updateSettings({ serverAuthMode: "UI_LOGIN" }); - } else if (mode === "NONE") { - updateSettings({ serverAuthMode: "SIMPLE_LOGIN" }); - } - return "unsupported_mode"; - } + if (res.status === 401) return "auth_required"; return "unreachable"; - } catch { return "unreachable"; } + } catch { + return "unreachable"; + } } \ No newline at end of file diff --git a/src/core/persistence/credentialVault.ts b/src/core/persistence/credentialVault.ts new file mode 100644 index 0000000..3289193 --- /dev/null +++ b/src/core/persistence/credentialVault.ts @@ -0,0 +1,88 @@ +const VAULT_KEY = "moku-credential-vault"; +const SALT_ITERATIONS = 200_000; +const KEY_USAGE: KeyUsage[] = ["encrypt", "decrypt"]; + +export interface VaultPayload { + refreshToken?: string; + basicUser?: string; + basicPass?: string; + authMode: "UI_LOGIN" | "BASIC_AUTH" | "NONE"; +} + +interface StoredVault { + salt: string; + iv: string; + data: string; +} + +function toB64(buf: ArrayBuffer): string { + return btoa(String.fromCharCode(...new Uint8Array(buf))); +} + +function fromB64(s: string): Uint8Array { + return Uint8Array.from(atob(s), (c) => c.charCodeAt(0)); +} + +async function deriveKey(pin: string, salt: Uint8Array): Promise { + const enc = new TextEncoder(); + const keyMat = await crypto.subtle.importKey("raw", enc.encode(pin), "PBKDF2", false, ["deriveKey"]); + return crypto.subtle.deriveKey( + { name: "PBKDF2", salt, iterations: SALT_ITERATIONS, hash: "SHA-256" }, + keyMat, + { name: "AES-GCM", length: 256 }, + false, + KEY_USAGE, + ); +} + +export function vaultExists(): boolean { + return !!localStorage.getItem(VAULT_KEY); +} + +export async function lockVault(pin: string, payload: VaultPayload): Promise { + const salt = crypto.getRandomValues(new Uint8Array(16)); + const iv = crypto.getRandomValues(new Uint8Array(12)); + const key = await deriveKey(pin, salt); + + const enc = new TextEncoder(); + const cipher = await crypto.subtle.encrypt( + { name: "AES-GCM", iv }, + key, + enc.encode(JSON.stringify(payload)), + ); + + localStorage.setItem(VAULT_KEY, JSON.stringify({ + salt: toB64(salt), + iv: toB64(iv), + data: toB64(cipher), + } satisfies StoredVault)); +} + +export async function unlockVault(pin: string): Promise { + const raw = localStorage.getItem(VAULT_KEY); + if (!raw) return null; + + try { + const stored = JSON.parse(raw) as StoredVault; + const key = await deriveKey(pin, fromB64(stored.salt)); + const plain = await crypto.subtle.decrypt( + { name: "AES-GCM", iv: fromB64(stored.iv) }, + key, + fromB64(stored.data), + ); + return JSON.parse(new TextDecoder().decode(plain)) as VaultPayload; + } catch { + return null; + } +} + +export function clearVault(): void { + localStorage.removeItem(VAULT_KEY); +} + +export async function rekeyVault(oldPin: string, newPin: string): Promise { + const payload = await unlockVault(oldPin); + if (!payload) return false; + await lockVault(newPin, payload); + return true; +} \ No newline at end of file diff --git a/src/core/persistence/index.ts b/src/core/persistence/index.ts index 97f6771..1b1f9d9 100644 --- a/src/core/persistence/index.ts +++ b/src/core/persistence/index.ts @@ -1,2 +1,5 @@ export { loadAllStores, persistSettings, persistLibrary, persistUpdates } from "./persist"; -export type { PersistedData } from "./persist"; \ No newline at end of file +export type { PersistedData } from "./persist"; + +export { vaultExists, lockVault, unlockVault, clearVault, rekeyVault } from "./credentialVault"; +export type { VaultPayload } from "./credentialVault"; \ No newline at end of file diff --git a/src/core/persistence/persist.ts b/src/core/persistence/persist.ts index 30feca9..6591a4e 100644 --- a/src/core/persistence/persist.ts +++ b/src/core/persistence/persist.ts @@ -153,4 +153,14 @@ export async function loadBackups(): Promise { export async function persistBackups(list: BackupEntry[]): Promise { await backupsStore.set("backupList", list); await backupsStore.save(); +} + +export async function resetAuthSettings(): Promise { + const current = await settingsStore.get("settings") ?? {}; + current.serverAuthMode = "NONE"; + current.serverAuthUser = ""; + current.serverAuthPass = ""; + await settingsStore.set("settings", current); + await settingsStore.save(); + localStorage.removeItem("moku-credential-vault"); } \ No newline at end of file diff --git a/src/features/downloads/store/downloadState.svelte.ts b/src/features/downloads/store/downloadState.svelte.ts index f16aded..67e0f8c 100644 --- a/src/features/downloads/store/downloadState.svelte.ts +++ b/src/features/downloads/store/downloadState.svelte.ts @@ -6,6 +6,7 @@ import { ENQUEUE_DOWNLOAD, REORDER_DOWNLOAD, } from "@api/mutations"; import { addToast, setActiveDownloads } from "@store/state.svelte"; +import { boot } from "@store/boot.svelte"; import type { DownloadStatus, DownloadQueueItem } from "@types/index"; import { toActiveDownloads, optimisticRemove, optimisticRemoveMany, @@ -104,6 +105,7 @@ class DownloadStore { } async poll() { + if (boot.sessionExpired) return; gql<{ downloadStatus: DownloadStatus }>(GET_DOWNLOAD_STATUS) .then((d) => this.applyStatus(d.downloadStatus)) .catch(console.error) diff --git a/src/features/settings/sections/SecuritySettings.svelte b/src/features/settings/sections/SecuritySettings.svelte index 58a4762..2342976 100644 --- a/src/features/settings/sections/SecuritySettings.svelte +++ b/src/features/settings/sections/SecuritySettings.svelte @@ -1,5 +1,5 @@ -{#if boot.unsupportedMode} +{#if boot.loginRequired}

moku

- { - store.settings.serverAuthMode === "SIMPLE_LOGIN" ? "Simple Login" : - store.settings.serverAuthMode === "UI_LOGIN" ? "UI Login" : "Unsupported Auth" - } -

{store.settings.serverUrl || "localhost:4567"}

-

- { - store.settings.serverAuthMode === "SIMPLE_LOGIN" ? "Simple Login" : - store.settings.serverAuthMode === "UI_LOGIN" ? "UI Login" : "This auth mode" - } is not supported. Switch your server to Basic Auth and update Settings → Security. -

- -
-
-{:else if boot.loginRequired} -
-
- -

moku

- Basic Auth + + {store.settings.serverAuthMode === "UI_LOGIN" ? "UI Login" : "Basic Auth"} +

{store.settings.serverUrl || "localhost:4567"}

+ {#if boot.loginError}

{boot.loginError}

{/if} +
e.key === "Enter" && handleLogin()} /> e.key === "Enter" && handleLogin()} />
+