From 4d4ffa160a8d2ca58f457cf5bcd47866fba68821 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sat, 9 May 2026 11:37:22 +0100 Subject: [PATCH] Add history-ui workspace and supporting server endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Splits history-ui out of asch/fix-everything into its own branch off main. Includes the Svelte workspace, the three dedicated server endpoints (list_vaults, fetch_vault_history, fetch_document_versions), SPA asset embedding via rust-embed, and the matching TS mirror types in sync-client. Note: this branch will not compile against main on its own โ€” the new endpoints depend on database/error/config additions that live on asch/fix-everything. Intended to be merged once asch/fix-everything lands. Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/history-ui/index.html | 13 + frontend/history-ui/package.json | 16 + frontend/history-ui/src/App.svelte | 78 ++ frontend/history-ui/src/app.css | 101 +++ .../src/components/ActivityFeed.svelte | 346 +++++++++ .../src/components/ConfirmDialog.svelte | 167 ++++ .../src/components/Dashboard.svelte | 508 ++++++++++++ .../history-ui/src/components/DiffView.svelte | 288 +++++++ .../src/components/DocumentDetail.svelte | 729 ++++++++++++++++++ .../history-ui/src/components/FileTree.svelte | 124 +++ .../history-ui/src/components/Header.svelte | 144 ++++ .../history-ui/src/components/Login.svelte | 176 +++++ .../src/components/TimeSlider.svelte | 191 +++++ .../src/components/ToastContainer.svelte | 80 ++ .../src/components/VaultPicker.svelte | 198 +++++ frontend/history-ui/src/lib/api.ts | 146 ++++ frontend/history-ui/src/lib/stores.svelte.ts | 290 +++++++ .../history-ui/src/lib/types/ClientCursors.ts | 8 + .../src/lib/types/CreateDocumentVersion.ts | 7 + .../src/lib/types/CursorPositionFromClient.ts | 6 + .../src/lib/types/CursorPositionFromServer.ts | 4 + .../history-ui/src/lib/types/CursorSpan.ts | 3 + .../src/lib/types/DocumentUpdateResponse.ts | 10 + .../src/lib/types/DocumentVersion.ts | 12 + .../types/DocumentVersionWithoutContent.ts | 16 + .../src/lib/types/DocumentWithCursors.ts | 9 + .../lib/types/FetchLatestDocumentsResponse.ts | 13 + .../src/lib/types/ListVaultsResponse.ts | 11 + .../history-ui/src/lib/types/PingResponse.ts | 25 + .../src/lib/types/SerializedError.ts | 7 + .../lib/types/UpdateTextDocumentVersion.ts | 7 + .../src/lib/types/VaultHistoryResponse.ts | 10 + .../history-ui/src/lib/types/VaultInfo.ts | 10 + .../src/lib/types/WebSocketClientMessage.ts | 7 + .../src/lib/types/WebSocketHandshake.ts | 7 + .../src/lib/types/WebSocketServerMessage.ts | 7 + .../src/lib/types/WebSocketVaultUpdate.ts | 4 + frontend/history-ui/src/lib/view-types.ts | 22 + frontend/history-ui/src/main.ts | 7 + frontend/history-ui/svelte.config.js | 5 + frontend/history-ui/tsconfig.json | 16 + frontend/history-ui/vite.config.ts | 15 + frontend/package.json | 3 +- .../src/services/types/ListVaultsResponse.ts | 11 + .../services/types/VaultHistoryResponse.ts | 10 + .../src/services/types/VaultInfo.ts | 10 + sync-server/Cargo.toml | 2 + sync-server/build.rs | 13 +- sync-server/src/server.rs | 28 +- .../src/server/fetch_document_versions.rs | 42 + sync-server/src/server/fetch_vault_history.rs | 70 ++ sync-server/src/server/index.rs | 80 +- sync-server/src/server/list_vaults.rs | 82 ++ 53 files changed, 4174 insertions(+), 20 deletions(-) create mode 100644 frontend/history-ui/index.html create mode 100644 frontend/history-ui/package.json create mode 100644 frontend/history-ui/src/App.svelte create mode 100644 frontend/history-ui/src/app.css create mode 100644 frontend/history-ui/src/components/ActivityFeed.svelte create mode 100644 frontend/history-ui/src/components/ConfirmDialog.svelte create mode 100644 frontend/history-ui/src/components/Dashboard.svelte create mode 100644 frontend/history-ui/src/components/DiffView.svelte create mode 100644 frontend/history-ui/src/components/DocumentDetail.svelte create mode 100644 frontend/history-ui/src/components/FileTree.svelte create mode 100644 frontend/history-ui/src/components/Header.svelte create mode 100644 frontend/history-ui/src/components/Login.svelte create mode 100644 frontend/history-ui/src/components/TimeSlider.svelte create mode 100644 frontend/history-ui/src/components/ToastContainer.svelte create mode 100644 frontend/history-ui/src/components/VaultPicker.svelte create mode 100644 frontend/history-ui/src/lib/api.ts create mode 100644 frontend/history-ui/src/lib/stores.svelte.ts create mode 100644 frontend/history-ui/src/lib/types/ClientCursors.ts create mode 100644 frontend/history-ui/src/lib/types/CreateDocumentVersion.ts create mode 100644 frontend/history-ui/src/lib/types/CursorPositionFromClient.ts create mode 100644 frontend/history-ui/src/lib/types/CursorPositionFromServer.ts create mode 100644 frontend/history-ui/src/lib/types/CursorSpan.ts create mode 100644 frontend/history-ui/src/lib/types/DocumentUpdateResponse.ts create mode 100644 frontend/history-ui/src/lib/types/DocumentVersion.ts create mode 100644 frontend/history-ui/src/lib/types/DocumentVersionWithoutContent.ts create mode 100644 frontend/history-ui/src/lib/types/DocumentWithCursors.ts create mode 100644 frontend/history-ui/src/lib/types/FetchLatestDocumentsResponse.ts create mode 100644 frontend/history-ui/src/lib/types/ListVaultsResponse.ts create mode 100644 frontend/history-ui/src/lib/types/PingResponse.ts create mode 100644 frontend/history-ui/src/lib/types/SerializedError.ts create mode 100644 frontend/history-ui/src/lib/types/UpdateTextDocumentVersion.ts create mode 100644 frontend/history-ui/src/lib/types/VaultHistoryResponse.ts create mode 100644 frontend/history-ui/src/lib/types/VaultInfo.ts create mode 100644 frontend/history-ui/src/lib/types/WebSocketClientMessage.ts create mode 100644 frontend/history-ui/src/lib/types/WebSocketHandshake.ts create mode 100644 frontend/history-ui/src/lib/types/WebSocketServerMessage.ts create mode 100644 frontend/history-ui/src/lib/types/WebSocketVaultUpdate.ts create mode 100644 frontend/history-ui/src/lib/view-types.ts create mode 100644 frontend/history-ui/src/main.ts create mode 100644 frontend/history-ui/svelte.config.js create mode 100644 frontend/history-ui/tsconfig.json create mode 100644 frontend/history-ui/vite.config.ts create mode 100644 frontend/sync-client/src/services/types/ListVaultsResponse.ts create mode 100644 frontend/sync-client/src/services/types/VaultHistoryResponse.ts create mode 100644 frontend/sync-client/src/services/types/VaultInfo.ts create mode 100644 sync-server/src/server/fetch_document_versions.rs create mode 100644 sync-server/src/server/fetch_vault_history.rs create mode 100644 sync-server/src/server/list_vaults.rs diff --git a/frontend/history-ui/index.html b/frontend/history-ui/index.html new file mode 100644 index 00000000..cde20e90 --- /dev/null +++ b/frontend/history-ui/index.html @@ -0,0 +1,13 @@ + + + + + + VaultLink2 + + + +
+ + + diff --git a/frontend/history-ui/package.json b/frontend/history-ui/package.json new file mode 100644 index 00000000..000dbdb0 --- /dev/null +++ b/frontend/history-ui/package.json @@ -0,0 +1,16 @@ +{ + "name": "history-ui", + "version": "0.14.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite dev --host 0.0.0.0", + "build": "vite build", + "test": "echo 'no tests yet'" + }, + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "^5.0.0", + "svelte": "^5.0.0", + "vite": "^6.0.0" + } +} diff --git a/frontend/history-ui/src/App.svelte b/frontend/history-ui/src/App.svelte new file mode 100644 index 00000000..37b4cd34 --- /dev/null +++ b/frontend/history-ui/src/App.svelte @@ -0,0 +1,78 @@ + + +{#if restoring} +
+
+
+{:else if !auth.token} + +{:else if !auth.isAuthenticated} + +{:else} + +{/if} + + + + diff --git a/frontend/history-ui/src/app.css b/frontend/history-ui/src/app.css new file mode 100644 index 00000000..ff3e6a9c --- /dev/null +++ b/frontend/history-ui/src/app.css @@ -0,0 +1,101 @@ +:root { + --bg: #0d1117; + --bg-secondary: #161b22; + --bg-tertiary: #21262d; + --bg-hover: #30363d; + --border: #30363d; + --border-light: #21262d; + --text: #e6edf3; + --text-muted: #8b949e; + --text-subtle: #6e7681; + --accent: #58a6ff; + --accent-hover: #79c0ff; + --green: #3fb950; + --green-bg: rgba(63, 185, 80, 0.15); + --red: #f85149; + --red-bg: rgba(248, 81, 73, 0.15); + --orange: #d29922; + --orange-bg: rgba(210, 153, 34, 0.15); + --purple: #bc8cff; + --purple-bg: rgba(188, 140, 255, 0.15); + --blue: #58a6ff; + --blue-bg: rgba(88, 166, 255, 0.15); + --mono: "SF Mono", "Fira Code", "Fira Mono", Menlo, Consolas, monospace; + --sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Noto Sans, Helvetica, Arial, sans-serif; + --radius: 6px; + --radius-sm: 4px; + --shadow: 0 1px 3px rgba(0, 0, 0, 0.3); +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html, body, #app { + height: 100%; + width: 100%; + overflow: hidden; +} + +body { + font-family: var(--sans); + font-size: 14px; + line-height: 1.5; + color: var(--text); + background: var(--bg); + -webkit-font-smoothing: antialiased; +} + +button { + font-family: inherit; + font-size: inherit; + cursor: pointer; + border: none; + background: none; + color: inherit; +} + +input { + font-family: inherit; + font-size: inherit; + color: inherit; + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 8px 12px; + outline: none; + transition: border-color 0.15s; +} + +input:focus { + border-color: var(--accent); +} + +a { + color: var(--accent); + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} + +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: var(--bg-tertiary); + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--bg-hover); +} diff --git a/frontend/history-ui/src/components/ActivityFeed.svelte b/frontend/history-ui/src/components/ActivityFeed.svelte new file mode 100644 index 00000000..b20991e2 --- /dev/null +++ b/frontend/history-ui/src/components/ActivityFeed.svelte @@ -0,0 +1,346 @@ + + +
+ {#if loading && versions.length === 0} +
Loading activity...
+ {:else if versions.length === 0} +
+ No activity yet. Documents will appear here as sync clients + make changes. +
+ {:else} + {#each grouped as group} +
+
{group.date}
+
+ {#each group.items as event} +
+ + +
+ {/each} +
+
+ {/each} + + {#if hasMore} +
+ +
+ {/if} + {/if} +
+ + diff --git a/frontend/history-ui/src/components/ConfirmDialog.svelte b/frontend/history-ui/src/components/ConfirmDialog.svelte new file mode 100644 index 00000000..e91f790a --- /dev/null +++ b/frontend/history-ui/src/components/ConfirmDialog.svelte @@ -0,0 +1,167 @@ + + + + + + + + diff --git a/frontend/history-ui/src/components/Dashboard.svelte b/frontend/history-ui/src/components/Dashboard.svelte new file mode 100644 index 00000000..8cf89677 --- /dev/null +++ b/frontend/history-ui/src/components/Dashboard.svelte @@ -0,0 +1,508 @@ + + +
+
+ +
+ + + + +
+ {#if maxUpdateId > 0} +
+ { + timeSliderValue = v; + }} + /> +
+ {/if} + + {#if selectedDocumentId} + nav.goHome()} + onRestore={handleRefresh} + /> + {:else} +
+ + +
+ + {#if activeTab === "activity"} + { + timeSliderValue = id >= maxUpdateId ? null : id; + }} + /> + {:else} +
+ {#each latestDocuments + .filter((d) => showDeleted || !d.isDeleted) + .sort((a, b) => b.vaultUpdateId - a.vaultUpdateId) as doc} + + {/each} +
+ {/if} + {/if} +
+
+
+ + diff --git a/frontend/history-ui/src/components/DiffView.svelte b/frontend/history-ui/src/components/DiffView.svelte new file mode 100644 index 00000000..be97952c --- /dev/null +++ b/frontend/history-ui/src/components/DiffView.svelte @@ -0,0 +1,288 @@ + + +
+
+ {oldLabel} + + {newLabel} + + +{stats.added} + -{stats.removed} + +
+
+ {#each diffLines as line} +
+ + {line.oldLineNo ?? ""} + + + {line.newLineNo ?? ""} + + + {#if line.type === "add"}+{:else if line.type === "remove"}-{:else} {/if} + + {line.content} +
+ {/each} +
+
+ + diff --git a/frontend/history-ui/src/components/DocumentDetail.svelte b/frontend/history-ui/src/components/DocumentDetail.svelte new file mode 100644 index 00000000..e4de2de8 --- /dev/null +++ b/frontend/history-ui/src/components/DocumentDetail.svelte @@ -0,0 +1,729 @@ + + +
+ +
+ +
+
+ + {currentPath} + + {#if isDeleted} + Deleted + {:else} + Active + {/if} +
+
+ + {documentId.substring(0, 8)}... + + {#if latest} + · + {versions.length} version{versions.length !== 1 ? "s" : ""} + · + Last by {latest.userId} + {/if} +
+
+
+ + {#if loading} +
Loading versions...
+ {:else} + +
+
+ {#if selectedVersion} +
+ + +
+ + Viewing v#{selectedVersion.vaultUpdateId} + · + {relativeTime(selectedVersion.updatedDate)} + +
+ +
+ {#if loadingContent} +
Loading content...
+ {:else if activeTab === "diff" && diffOldContent !== null && diffNewContent !== null} + + {:else if activeTab === "preview"} + {#if isTextFile(selectedVersion.relativePath) || fileExtension(selectedVersion.relativePath) === ""} +
{loadedContent ?? ""}
+ {:else if isImageFile(selectedVersion.relativePath) && loadedContentBytes} +
+ {selectedVersion.relativePath} +
+ {:else} +
+
๐Ÿ“ฆ
+
Binary file
+
+ {formatBytes(selectedVersion.contentSize)} +
+
+ {/if} + {/if} +
+ {/if} +
+ + +
+
Version History
+
+ {#each [...versionEvents].reverse() as event, i} + {@const v = event.version} + {@const isSelected = selectedVersion?.vaultUpdateId === v.vaultUpdateId} +
+ + {#if event.previousPath} +
+ {event.previousPath} → {v.relativePath} +
+ {/if} +
+ {#if i < versionEvents.length - 1} + + {/if} + {#if v !== latest} + + {/if} +
+
+ {/each} +
+
+
+ {/if} +
+ +{#if showRestoreDialog && restoreTarget} + { + showRestoreDialog = false; + restoreTarget = null; + }} + /> +{/if} + + diff --git a/frontend/history-ui/src/components/FileTree.svelte b/frontend/history-ui/src/components/FileTree.svelte new file mode 100644 index 00000000..a1a99d4c --- /dev/null +++ b/frontend/history-ui/src/components/FileTree.svelte @@ -0,0 +1,124 @@ + + +{#if node.isFolder && depth === 0} + {#each node.children as child} + + {/each} +{:else if node.isFolder} +
+ + {#if isExpanded(node.path)} + {#each node.children as child} + + {/each} + {/if} +
+{:else} + +{/if} + + diff --git a/frontend/history-ui/src/components/Header.svelte b/frontend/history-ui/src/components/Header.svelte new file mode 100644 index 00000000..8e635224 --- /dev/null +++ b/frontend/history-ui/src/components/Header.svelte @@ -0,0 +1,144 @@ + + +
+
+ + + + + + VaultLink + / + {vaultId} +
+ +
+ v{serverVersion} + + {#if auth.availableVaults.length > 1} + + {/if} + +
+
+ + diff --git a/frontend/history-ui/src/components/Login.svelte b/frontend/history-ui/src/components/Login.svelte new file mode 100644 index 00000000..8d331966 --- /dev/null +++ b/frontend/history-ui/src/components/Login.svelte @@ -0,0 +1,176 @@ + + + + + diff --git a/frontend/history-ui/src/components/TimeSlider.svelte b/frontend/history-ui/src/components/TimeSlider.svelte new file mode 100644 index 00000000..0bdc3abf --- /dev/null +++ b/frontend/history-ui/src/components/TimeSlider.svelte @@ -0,0 +1,191 @@ + + +
+
+ + + + + Time Travel +
+ +
+ +
+ +
+ {#if isNow} + Now + {:else if currentVersion} + + #{value} + · + {relativeTime(currentVersion.updatedDate)} + + {:else} + #{value} + {/if} +
+ + {#if !isNow} + + {/if} +
+ + diff --git a/frontend/history-ui/src/components/ToastContainer.svelte b/frontend/history-ui/src/components/ToastContainer.svelte new file mode 100644 index 00000000..39ab1705 --- /dev/null +++ b/frontend/history-ui/src/components/ToastContainer.svelte @@ -0,0 +1,80 @@ + + +{#if toasts.items.length > 0} +
+ {#each toasts.items as toast (toast.id)} +
+ {toast.message} + +
+ {/each} +
+{/if} + + diff --git a/frontend/history-ui/src/components/VaultPicker.svelte b/frontend/history-ui/src/components/VaultPicker.svelte new file mode 100644 index 00000000..8ca82737 --- /dev/null +++ b/frontend/history-ui/src/components/VaultPicker.svelte @@ -0,0 +1,198 @@ + + +
+
+
+ +
+ + {#if auth.availableVaults.length === 0} +
+

No vaults found

+

+ Vaults are created when a sync client first connects. +

+
+ {:else} +
    + {#each auth.availableVaults as vault} +
  • + +
  • + {/each} +
+ {/if} +
+
+ + diff --git a/frontend/history-ui/src/lib/api.ts b/frontend/history-ui/src/lib/api.ts new file mode 100644 index 00000000..eefc594d --- /dev/null +++ b/frontend/history-ui/src/lib/api.ts @@ -0,0 +1,146 @@ +import type { DocumentUpdateResponse } from "./types/DocumentUpdateResponse"; +import type { DocumentVersion } from "./types/DocumentVersion"; +import type { DocumentVersionWithoutContent } from "./types/DocumentVersionWithoutContent"; +import type { FetchLatestDocumentsResponse } from "./types/FetchLatestDocumentsResponse"; +import type { ListVaultsResponse } from "./types/ListVaultsResponse"; +import type { PingResponse } from "./types/PingResponse"; +import type { VaultHistoryResponse } from "./types/VaultHistoryResponse"; + +async function fetchJsonWithToken( + path: string, + token: string, + init?: RequestInit +): Promise { + const response = await fetch(path, { + ...init, + headers: { + Authorization: `Bearer ${token}`, + "device-id": "history-ui", + ...init?.headers + } + }); + if (!response.ok) { + const body = await response.text(); + throw new Error(`HTTP ${response.status}: ${body}`); + } + return response.json() as Promise; +} + +export async function listVaults(token: string): Promise { + return fetchJsonWithToken("/vaults", token); +} + +export class ApiClient { + constructor( + private vaultId: string, + private token: string + ) {} + + private get baseUrl(): string { + return `/vaults/${encodeURIComponent(this.vaultId)}`; + } + + private async fetchJson(path: string, init?: RequestInit): Promise { + return fetchJsonWithToken(path, this.token, init); + } + + async ping(): Promise { + return this.fetchJson(`${this.baseUrl}/ping`); + } + + async fetchLatestDocuments(): Promise { + return this.fetchJson(`${this.baseUrl}/documents`); + } + + async fetchDocumentVersions( + documentId: string + ): Promise { + return this.fetchJson( + `${this.baseUrl}/documents/${documentId}/versions` + ); + } + + async fetchDocumentVersion( + documentId: string, + vaultUpdateId: number + ): Promise { + return this.fetchJson( + `${this.baseUrl}/documents/${documentId}/versions/${vaultUpdateId}` + ); + } + + async fetchDocumentVersionContent( + documentId: string, + vaultUpdateId: number + ): Promise { + const response = await fetch( + `${this.baseUrl}/documents/${documentId}/versions/${vaultUpdateId}/content`, + { + headers: { + Authorization: `Bearer ${this.token}`, + "device-id": "history-ui" + } + } + ); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + return response.arrayBuffer(); + } + + async fetchVaultHistory( + limit?: number, + beforeUpdateId?: number + ): Promise { + const params = new URLSearchParams(); + if (limit !== undefined) params.set("limit", String(limit)); + if (beforeUpdateId !== undefined) + params.set("before_update_id", String(beforeUpdateId)); + const qs = params.toString(); + return this.fetchJson(`${this.baseUrl}/history${qs ? `?${qs}` : ""}`); + } + + /** + * Upload a new version of an existing (non-deleted) document. The + * server treats this like any other edit โ€” server-side merging, + * path dedupe, and broadcast still apply. Used by the UI to restore + * an old version by re-submitting its bytes on top of the latest. + */ + async updateBinaryDocument( + documentId: string, + parentVersionId: number, + relativePath: string, + content: ArrayBuffer + ): Promise { + const form = new FormData(); + form.append("parent_version_id", String(parentVersionId)); + form.append("relative_path", relativePath); + form.append("content", new Blob([content])); + return this.fetchJson( + `${this.baseUrl}/documents/${documentId}/binary`, + { method: "PUT", body: form } + ); + } + + /** + * Create a new document. Used by the UI to restore a deleted + * document: `update_document` short-circuits on `is_deleted`, so + * resurrection has to go through `create_document` โ€” which detects + * an existing doc at the same path, merges or dedupes as needed, + * and returns the resulting version. + */ + async createDocument( + lastSeenVaultUpdateId: number, + relativePath: string, + content: ArrayBuffer + ): Promise { + const form = new FormData(); + form.append("last_seen_vault_update_id", String(lastSeenVaultUpdateId)); + form.append("relative_path", relativePath); + form.append("content", new Blob([content])); + return this.fetchJson(`${this.baseUrl}/documents`, { + method: "POST", + body: form + }); + } +} diff --git a/frontend/history-ui/src/lib/stores.svelte.ts b/frontend/history-ui/src/lib/stores.svelte.ts new file mode 100644 index 00000000..16ee4a30 --- /dev/null +++ b/frontend/history-ui/src/lib/stores.svelte.ts @@ -0,0 +1,290 @@ +import { ApiClient } from "./api"; +import type { DocumentVersionWithoutContent } from "./types/DocumentVersionWithoutContent"; +import type { VaultInfo } from "./types/VaultInfo"; +import type { VersionEvent, ActionType, TreeNode } from "./view-types"; + +class AuthStore { + token = $state(""); + userName = $state(""); + vaultId = $state(""); + serverVersion = $state(""); + availableVaults = $state([]); + isAuthenticated = $state(false); + api = $state(null); + + authenticate(token: string, userName: string, vaults: VaultInfo[]) { + this.token = token; + this.userName = userName; + this.availableVaults = vaults; + sessionStorage.setItem("vaultlink_token", token); + } + + selectVault(vaultId: string) { + this.vaultId = vaultId; + this.isAuthenticated = true; + this.api = new ApiClient(vaultId, this.token); + sessionStorage.setItem("vaultlink_vault", vaultId); + } + + deselectVault() { + this.vaultId = ""; + this.isAuthenticated = false; + this.api = null; + sessionStorage.removeItem("vaultlink_vault"); + } + + logout() { + this.token = ""; + this.userName = ""; + this.vaultId = ""; + this.serverVersion = ""; + this.availableVaults = []; + this.isAuthenticated = false; + this.api = null; + sessionStorage.removeItem("vaultlink_token"); + sessionStorage.removeItem("vaultlink_vault"); + } + + tryRestore(): { token: string; vaultId?: string } | null { + const token = sessionStorage.getItem("vaultlink_token"); + if (!token) return null; + const vaultId = sessionStorage.getItem("vaultlink_vault") ?? undefined; + return { token, vaultId }; + } +} + +export const auth = new AuthStore(); + +// Navigation +export type View = + | { kind: "dashboard" } + | { kind: "document"; documentId: string }; + +class NavStore { + current = $state({ kind: "dashboard" }); + + goto(view: View) { + this.current = view; + } + + goHome() { + this.current = { kind: "dashboard" }; + } +} + +export const nav = new NavStore(); + +// Toasts +interface Toast { + id: number; + message: string; + type: "success" | "error" | "info"; +} + +class ToastStore { + items = $state([]); + private nextId = 0; + + add(message: string, type: Toast["type"] = "info") { + const id = this.nextId++; + this.items.push({ id, message, type }); + setTimeout(() => this.dismiss(id), 5000); + } + + dismiss(id: number) { + this.items = this.items.filter((t) => t.id !== id); + } +} + +export const toasts = new ToastStore(); + +// Utilities + +export function inferAction( + version: DocumentVersionWithoutContent, + previousVersion?: DocumentVersionWithoutContent +): ActionType { + if (version.isDeleted) return "deleted"; + if (!previousVersion) return "created"; + if (previousVersion.isDeleted && !version.isDeleted) return "restored"; + if (previousVersion.relativePath !== version.relativePath) return "renamed"; + return "updated"; +} + +export function enrichVersions( + versions: DocumentVersionWithoutContent[] +): VersionEvent[] { + // versions should be sorted by vaultUpdateId ascending + const sorted = [...versions].sort( + (a, b) => a.vaultUpdateId - b.vaultUpdateId + ); + const byDoc = new Map(); + for (const v of sorted) { + let arr = byDoc.get(v.documentId); + if (!arr) { + arr = []; + byDoc.set(v.documentId, arr); + } + arr.push(v); + } + + return sorted.map((v) => { + const docVersions = byDoc.get(v.documentId)!; + const idx = docVersions.indexOf(v); + const prev = idx > 0 ? docVersions[idx - 1] : undefined; + const action = inferAction(v, prev); + return { + ...v, + action, + previousPath: action === "renamed" ? prev?.relativePath : undefined + }; + }); +} + +export function buildTree( + documents: DocumentVersionWithoutContent[], + showDeleted: boolean +): TreeNode { + const root: TreeNode = { + name: "", + path: "", + isFolder: true, + children: [] + }; + + const filtered = showDeleted + ? documents + : documents.filter((d) => !d.isDeleted); + + for (const doc of filtered) { + const parts = doc.relativePath.split("/"); + let current = root; + + for (let i = 0; i < parts.length; i++) { + const part = parts[i]; + const isFile = i === parts.length - 1; + const path = parts.slice(0, i + 1).join("/"); + + if (isFile) { + current.children.push({ + name: part, + path, + isFolder: false, + children: [], + document: doc, + isDeleted: doc.isDeleted + }); + } else { + let folder = current.children.find( + (c) => c.isFolder && c.name === part + ); + if (!folder) { + folder = { + name: part, + path, + isFolder: true, + children: [] + }; + current.children.push(folder); + } + current = folder; + } + } + } + + sortTree(root); + return root; +} + +function sortTree(node: TreeNode) { + node.children.sort((a, b) => { + if (a.isFolder !== b.isFolder) return a.isFolder ? -1 : 1; + return a.name.localeCompare(b.name); + }); + for (const child of node.children) { + if (child.isFolder) sortTree(child); + } +} + +export function relativeTime(dateStr: string): string { + const date = new Date(dateStr); + const now = Date.now(); + const diff = now - date.getTime(); + const seconds = Math.floor(diff / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + + if (seconds < 60) return "just now"; + if (minutes < 60) return `${minutes}m ago`; + if (hours < 24) return `${hours}h ago`; + if (days < 7) return `${days}d ago`; + return date.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: days > 365 ? "numeric" : undefined + }); +} + +export function absoluteTime(dateStr: string): string { + return new Date(dateStr).toLocaleString(); +} + +export function formatBytes(bytes: number): string { + if (bytes === 0) return "0 B"; + const k = 1024; + const sizes = ["B", "KB", "MB", "GB"]; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`; +} + +export function fileExtension(path: string): string { + const dot = path.lastIndexOf("."); + return dot > -1 ? path.substring(dot + 1).toLowerCase() : ""; +} + +export function isTextFile(path: string): boolean { + const textExts = new Set([ + "md", + "txt", + "json", + "yaml", + "yml", + "toml", + "xml", + "html", + "css", + "js", + "ts", + "svelte", + "rs", + "py", + "sh", + "bash", + "zsh", + "csv", + "svg", + "log", + "conf", + "cfg", + "ini", + "env", + "gitignore", + "editorconfig" + ]); + return textExts.has(fileExtension(path)); +} + +export function isImageFile(path: string): boolean { + const imageExts = new Set([ + "png", + "jpg", + "jpeg", + "gif", + "webp", + "svg", + "ico", + "bmp" + ]); + return imageExts.has(fileExtension(path)); +} diff --git a/frontend/history-ui/src/lib/types/ClientCursors.ts b/frontend/history-ui/src/lib/types/ClientCursors.ts new file mode 100644 index 00000000..14298431 --- /dev/null +++ b/frontend/history-ui/src/lib/types/ClientCursors.ts @@ -0,0 +1,8 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { DocumentWithCursors } from "./DocumentWithCursors"; + +export type ClientCursors = { + userName: string; + deviceId: string; + documentsWithCursors: Array; +}; diff --git a/frontend/history-ui/src/lib/types/CreateDocumentVersion.ts b/frontend/history-ui/src/lib/types/CreateDocumentVersion.ts new file mode 100644 index 00000000..389d8e88 --- /dev/null +++ b/frontend/history-ui/src/lib/types/CreateDocumentVersion.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type CreateDocumentVersion = { + relative_path: string; + last_seen_vault_update_id: number; + content: Array; +}; diff --git a/frontend/history-ui/src/lib/types/CursorPositionFromClient.ts b/frontend/history-ui/src/lib/types/CursorPositionFromClient.ts new file mode 100644 index 00000000..5846843e --- /dev/null +++ b/frontend/history-ui/src/lib/types/CursorPositionFromClient.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { DocumentWithCursors } from "./DocumentWithCursors"; + +export type CursorPositionFromClient = { + documentsWithCursors: Array; +}; diff --git a/frontend/history-ui/src/lib/types/CursorPositionFromServer.ts b/frontend/history-ui/src/lib/types/CursorPositionFromServer.ts new file mode 100644 index 00000000..3a72c706 --- /dev/null +++ b/frontend/history-ui/src/lib/types/CursorPositionFromServer.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ClientCursors } from "./ClientCursors"; + +export type CursorPositionFromServer = { clients: Array }; diff --git a/frontend/history-ui/src/lib/types/CursorSpan.ts b/frontend/history-ui/src/lib/types/CursorSpan.ts new file mode 100644 index 00000000..916019ce --- /dev/null +++ b/frontend/history-ui/src/lib/types/CursorSpan.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type CursorSpan = { start: number; end: number }; diff --git a/frontend/history-ui/src/lib/types/DocumentUpdateResponse.ts b/frontend/history-ui/src/lib/types/DocumentUpdateResponse.ts new file mode 100644 index 00000000..dd7eadda --- /dev/null +++ b/frontend/history-ui/src/lib/types/DocumentUpdateResponse.ts @@ -0,0 +1,10 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { DocumentVersion } from "./DocumentVersion"; +import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutContent"; + +/** + * Response to a create/update document request. + */ +export type DocumentUpdateResponse = + | ({ type: "FastForwardUpdate" } & DocumentVersionWithoutContent) + | ({ type: "MergingUpdate" } & DocumentVersion); diff --git a/frontend/history-ui/src/lib/types/DocumentVersion.ts b/frontend/history-ui/src/lib/types/DocumentVersion.ts new file mode 100644 index 00000000..50a6c591 --- /dev/null +++ b/frontend/history-ui/src/lib/types/DocumentVersion.ts @@ -0,0 +1,12 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type DocumentVersion = { + vaultUpdateId: number; + documentId: string; + relativePath: string; + updatedDate: string; + contentBase64: string; + isDeleted: boolean; + userId: string; + deviceId: string; +}; diff --git a/frontend/history-ui/src/lib/types/DocumentVersionWithoutContent.ts b/frontend/history-ui/src/lib/types/DocumentVersionWithoutContent.ts new file mode 100644 index 00000000..e3ed828a --- /dev/null +++ b/frontend/history-ui/src/lib/types/DocumentVersionWithoutContent.ts @@ -0,0 +1,16 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type DocumentVersionWithoutContent = { + vaultUpdateId: number; + documentId: string; + relativePath: string; + updatedDate: string; + isDeleted: boolean; + userId: string; + deviceId: string; + contentSize: number; + /** + * True iff this is the first version of the document + */ + isNewFile: boolean; +}; diff --git a/frontend/history-ui/src/lib/types/DocumentWithCursors.ts b/frontend/history-ui/src/lib/types/DocumentWithCursors.ts new file mode 100644 index 00000000..ca6a2155 --- /dev/null +++ b/frontend/history-ui/src/lib/types/DocumentWithCursors.ts @@ -0,0 +1,9 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { CursorSpan } from "./CursorSpan"; + +export type DocumentWithCursors = { + vaultUpdateId: number | null; + documentId: string; + relativePath: string; + cursors: Array; +}; diff --git a/frontend/history-ui/src/lib/types/FetchLatestDocumentsResponse.ts b/frontend/history-ui/src/lib/types/FetchLatestDocumentsResponse.ts new file mode 100644 index 00000000..141c2565 --- /dev/null +++ b/frontend/history-ui/src/lib/types/FetchLatestDocumentsResponse.ts @@ -0,0 +1,13 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutContent"; + +/** + * Response to a fetch latest documents request. + */ +export type FetchLatestDocumentsResponse = { + latestDocuments: Array; + /** + * The update ID of the latest document in the response. + */ + lastUpdateId: bigint; +}; diff --git a/frontend/history-ui/src/lib/types/ListVaultsResponse.ts b/frontend/history-ui/src/lib/types/ListVaultsResponse.ts new file mode 100644 index 00000000..604ad958 --- /dev/null +++ b/frontend/history-ui/src/lib/types/ListVaultsResponse.ts @@ -0,0 +1,11 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { VaultInfo } from "./VaultInfo"; + +/** + * Response to listing vaults accessible to the authenticated user. + */ +export type ListVaultsResponse = { + vaults: Array; + hasMore: boolean; + userName: string; +}; diff --git a/frontend/history-ui/src/lib/types/PingResponse.ts b/frontend/history-ui/src/lib/types/PingResponse.ts new file mode 100644 index 00000000..7e5ac4f8 --- /dev/null +++ b/frontend/history-ui/src/lib/types/PingResponse.ts @@ -0,0 +1,25 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Response to a ping request. + */ +export type PingResponse = { + /** + * Semantic version of the server. + */ + serverVersion: string; + /** + * Whether the client is authenticated based on the sent Authorization + * header. + */ + isAuthenticated: boolean; + /** + * List of file extensions that are allowed to be merged. + */ + mergeableFileExtensions: Array; + /** + * API version ensuring backwards & forwards compatibility between the client + * and server. + */ + supportedApiVersion: number; +}; diff --git a/frontend/history-ui/src/lib/types/SerializedError.ts b/frontend/history-ui/src/lib/types/SerializedError.ts new file mode 100644 index 00000000..354305f6 --- /dev/null +++ b/frontend/history-ui/src/lib/types/SerializedError.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type SerializedError = { + errorType: string; + message: string; + causes: Array; +}; diff --git a/frontend/history-ui/src/lib/types/UpdateTextDocumentVersion.ts b/frontend/history-ui/src/lib/types/UpdateTextDocumentVersion.ts new file mode 100644 index 00000000..5a1978eb --- /dev/null +++ b/frontend/history-ui/src/lib/types/UpdateTextDocumentVersion.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type UpdateTextDocumentVersion = { + parentVersionId: number; + relativePath: string | null; + content: Array; +}; diff --git a/frontend/history-ui/src/lib/types/VaultHistoryResponse.ts b/frontend/history-ui/src/lib/types/VaultHistoryResponse.ts new file mode 100644 index 00000000..e69366f0 --- /dev/null +++ b/frontend/history-ui/src/lib/types/VaultHistoryResponse.ts @@ -0,0 +1,10 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutContent"; + +/** + * Response to a vault history request (paginated). + */ +export type VaultHistoryResponse = { + versions: Array; + hasMore: boolean; +}; diff --git a/frontend/history-ui/src/lib/types/VaultInfo.ts b/frontend/history-ui/src/lib/types/VaultInfo.ts new file mode 100644 index 00000000..3f630ae9 --- /dev/null +++ b/frontend/history-ui/src/lib/types/VaultInfo.ts @@ -0,0 +1,10 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Summary of a single vault returned by the list-vaults endpoint. + */ +export type VaultInfo = { + name: string; + documentCount: number; + createdAt: string | null; +}; diff --git a/frontend/history-ui/src/lib/types/WebSocketClientMessage.ts b/frontend/history-ui/src/lib/types/WebSocketClientMessage.ts new file mode 100644 index 00000000..9608f3af --- /dev/null +++ b/frontend/history-ui/src/lib/types/WebSocketClientMessage.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { CursorPositionFromClient } from "./CursorPositionFromClient"; +import type { WebSocketHandshake } from "./WebSocketHandshake"; + +export type WebSocketClientMessage = + | ({ type: "handshake" } & WebSocketHandshake) + | ({ type: "cursorPositions" } & CursorPositionFromClient); diff --git a/frontend/history-ui/src/lib/types/WebSocketHandshake.ts b/frontend/history-ui/src/lib/types/WebSocketHandshake.ts new file mode 100644 index 00000000..8e51a121 --- /dev/null +++ b/frontend/history-ui/src/lib/types/WebSocketHandshake.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type WebSocketHandshake = { + token: string; + deviceId: string; + lastSeenVaultUpdateId: number | null; +}; diff --git a/frontend/history-ui/src/lib/types/WebSocketServerMessage.ts b/frontend/history-ui/src/lib/types/WebSocketServerMessage.ts new file mode 100644 index 00000000..fd250b7b --- /dev/null +++ b/frontend/history-ui/src/lib/types/WebSocketServerMessage.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { CursorPositionFromServer } from "./CursorPositionFromServer"; +import type { WebSocketVaultUpdate } from "./WebSocketVaultUpdate"; + +export type WebSocketServerMessage = + | ({ type: "vaultUpdate" } & WebSocketVaultUpdate) + | ({ type: "cursorPositions" } & CursorPositionFromServer); diff --git a/frontend/history-ui/src/lib/types/WebSocketVaultUpdate.ts b/frontend/history-ui/src/lib/types/WebSocketVaultUpdate.ts new file mode 100644 index 00000000..94d70c0a --- /dev/null +++ b/frontend/history-ui/src/lib/types/WebSocketVaultUpdate.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutContent"; + +export type WebSocketVaultUpdate = { document: DocumentVersionWithoutContent }; diff --git a/frontend/history-ui/src/lib/view-types.ts b/frontend/history-ui/src/lib/view-types.ts new file mode 100644 index 00000000..8b8cb0ae --- /dev/null +++ b/frontend/history-ui/src/lib/view-types.ts @@ -0,0 +1,22 @@ +import type { DocumentVersionWithoutContent } from "./types/DocumentVersionWithoutContent"; + +export type ActionType = + | "created" + | "updated" + | "renamed" + | "deleted" + | "restored"; + +export interface VersionEvent extends DocumentVersionWithoutContent { + action: ActionType; + previousPath?: string; +} + +export interface TreeNode { + name: string; + path: string; + isFolder: boolean; + children: TreeNode[]; + document?: DocumentVersionWithoutContent; + isDeleted?: boolean; +} diff --git a/frontend/history-ui/src/main.ts b/frontend/history-ui/src/main.ts new file mode 100644 index 00000000..c72cabd0 --- /dev/null +++ b/frontend/history-ui/src/main.ts @@ -0,0 +1,7 @@ +import { mount } from "svelte"; +import App from "./App.svelte"; +import "./app.css"; + +const app = mount(App, { target: document.getElementById("app")! }); + +export default app; diff --git a/frontend/history-ui/svelte.config.js b/frontend/history-ui/svelte.config.js new file mode 100644 index 00000000..76a68bfc --- /dev/null +++ b/frontend/history-ui/svelte.config.js @@ -0,0 +1,5 @@ +import { vitePreprocess } from "@sveltejs/vite-plugin-svelte"; + +export default { + preprocess: vitePreprocess() +}; diff --git a/frontend/history-ui/tsconfig.json b/frontend/history-ui/tsconfig.json new file mode 100644 index 00000000..216dc140 --- /dev/null +++ b/frontend/history-ui/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "types": ["svelte"] + }, + "include": ["src/**/*", "src/**/*.svelte"] +} diff --git a/frontend/history-ui/vite.config.ts b/frontend/history-ui/vite.config.ts new file mode 100644 index 00000000..18f6be82 --- /dev/null +++ b/frontend/history-ui/vite.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from "vite"; +import { svelte } from "@sveltejs/vite-plugin-svelte"; + +export default defineConfig({ + plugins: [svelte()], + build: { + outDir: "dist", + emptyOutDir: true + }, + server: { + proxy: { + "/vaults": "http://localhost:3010" + } + } +}); diff --git a/frontend/package.json b/frontend/package.json index 0dd9057d..b535c940 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,7 +6,8 @@ "obsidian-plugin", "test-client", "deterministic-tests", - "local-client-cli" + "local-client-cli", + "history-ui" ], "prettier": { "trailingComma": "none", diff --git a/frontend/sync-client/src/services/types/ListVaultsResponse.ts b/frontend/sync-client/src/services/types/ListVaultsResponse.ts new file mode 100644 index 00000000..babad2d5 --- /dev/null +++ b/frontend/sync-client/src/services/types/ListVaultsResponse.ts @@ -0,0 +1,11 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { VaultInfo } from "./VaultInfo"; + +/** + * Response to listing vaults accessible to the authenticated user. + */ +export interface ListVaultsResponse { + vaults: VaultInfo[]; + hasMore: boolean; + userName: string; +} diff --git a/frontend/sync-client/src/services/types/VaultHistoryResponse.ts b/frontend/sync-client/src/services/types/VaultHistoryResponse.ts new file mode 100644 index 00000000..35531010 --- /dev/null +++ b/frontend/sync-client/src/services/types/VaultHistoryResponse.ts @@ -0,0 +1,10 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutContent"; + +/** + * Response to a vault history request (paginated). + */ +export interface VaultHistoryResponse { + versions: DocumentVersionWithoutContent[]; + hasMore: boolean; +} diff --git a/frontend/sync-client/src/services/types/VaultInfo.ts b/frontend/sync-client/src/services/types/VaultInfo.ts new file mode 100644 index 00000000..20d6811c --- /dev/null +++ b/frontend/sync-client/src/services/types/VaultInfo.ts @@ -0,0 +1,10 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Summary of a single vault returned by the list-vaults endpoint. + */ +export interface VaultInfo { + name: string; + documentCount: number; + createdAt: string | null; +} diff --git a/sync-server/Cargo.toml b/sync-server/Cargo.toml index fac06efa..98f97c53 100644 --- a/sync-server/Cargo.toml +++ b/sync-server/Cargo.toml @@ -34,6 +34,8 @@ bimap = "0.6.3" ts-rs = { version = "10.1", features = ["uuid-impl", "chrono-impl"] } base64 = "0.22.1" reconcile-text = { version = "0.8.0", features = ["serde"] } +rust-embed = "8.5" +mime_guess = "2.0" [profile.release] codegen-units = 1 diff --git a/sync-server/build.rs b/sync-server/build.rs index d5068697..53bd111b 100644 --- a/sync-server/build.rs +++ b/sync-server/build.rs @@ -1,5 +1,16 @@ -// generated by `sqlx migrate build-script` fn main() { // trigger recompilation when a new migration is added println!("cargo:rerun-if-changed=migrations"); + + // Ensure the history-ui dist directory exists so rust-embed can compile + // even when the frontend hasn't been built yet. + let dist_path = std::path::Path::new("../frontend/history-ui/dist"); + if !dist_path.exists() { + std::fs::create_dir_all(dist_path).expect("Failed to create history-ui dist directory"); + std::fs::write( + dist_path.join("index.html"), + "

Run npm run build -w history-ui first.

", + ) + .expect("Failed to write placeholder index.html"); + } } diff --git a/sync-server/src/server.rs b/sync-server/src/server.rs index 01b09cf6..65557df8 100644 --- a/sync-server/src/server.rs +++ b/sync-server/src/server.rs @@ -4,23 +4,25 @@ mod delete_document; mod device_id_header; mod fetch_document_version; mod fetch_document_version_content; +mod fetch_document_versions; mod fetch_latest_document_version; mod fetch_latest_documents; +mod fetch_vault_history; mod index; +mod list_vaults; mod ping; mod requests; mod responses; mod update_document; mod websocket; -use anyhow::{Context as _, Result, anyhow}; +use anyhow::{Context as _, Result}; use auth::auth_middleware; use axum::{ Router, extract::{DefaultBodyLimit, Request}, http::{self, HeaderValue, Method}, middleware, - response::IntoResponse, routing::{IntoMakeService, delete, get, post, put}, }; use device_id_header::DEVICE_ID_HEADER_NAME; @@ -41,7 +43,6 @@ use tracing::{Level, info_span}; use crate::{ app_state::AppState, config::{Config, server_config::ServerConfig}, - errors::{client_error, not_found_error}, }; pub async fn create_server(config: Config) -> Result<()> { @@ -54,8 +55,11 @@ pub async fn create_server(config: Config) -> Result<()> { let app = Router::new() .nest("/", get_authed_routes(app_state.clone())) .route("/", get(index::index)) + .route("/assets/*path", get(index::spa_assets)) + .route("/vaults", get(list_vaults::list_vaults)) .route("/vaults/:vault_id/ping", get(ping::ping)) .route("/vaults/:vault_id/ws", get(websocket::websocket_handler)) + .fallback(index::spa_fallback) .layer(DefaultBodyLimit::disable()) .layer(RequestBodyLimitLayer::new( app_state.config.server.max_body_size_mb * 1024 * 1024, @@ -91,8 +95,6 @@ pub async fn create_server(config: Config) -> Result<()> { .on_failure(DefaultOnFailure::new().level(Level::ERROR)), ) .with_state(app_state) - .fallback(handle_404) - .fallback(handle_405) .into_make_service(); start_server(app, &server_config).await @@ -120,6 +122,10 @@ fn get_authed_routes(app_state: AppState) -> Router { "/vaults/:vault_id/documents/:document_id/text", put(update_document::update_text), ) + .route( + "/vaults/:vault_id/documents/:document_id/versions", + get(fetch_document_versions::fetch_document_versions), + ) .route( "/vaults/:vault_id/documents/:document_id/versions/:vault_update_id", get(fetch_document_version::fetch_document_version), @@ -132,6 +138,10 @@ fn get_authed_routes(app_state: AppState) -> Router { "/vaults/:vault_id/documents/:document_id", delete(delete_document::delete_document), ) + .route( + "/vaults/:vault_id/history", + get(fetch_vault_history::fetch_vault_history), + ) .layer(middleware::from_fn_with_state(app_state, auth_middleware)) } @@ -178,11 +188,3 @@ async fn shutdown_signal() { () = terminate => {}, } } - -async fn handle_404() -> impl IntoResponse { - not_found_error(anyhow!("Page not found")) -} - -async fn handle_405() -> impl IntoResponse { - client_error(anyhow!("Method not allowed")) -} diff --git a/sync-server/src/server/fetch_document_versions.rs b/sync-server/src/server/fetch_document_versions.rs new file mode 100644 index 00000000..46d0e073 --- /dev/null +++ b/sync-server/src/server/fetch_document_versions.rs @@ -0,0 +1,42 @@ +use axum::{ + Json, + extract::{Path, State}, +}; +use log::debug; +use serde::Deserialize; + +use crate::{ + app_state::{ + AppState, + database::models::{DocumentId, DocumentVersionWithoutContent, VaultId}, + }, + errors::{SyncServerError, server_error}, + utils::normalize::normalize, +}; + +#[derive(Deserialize)] +pub struct FetchDocumentVersionsPathParams { + #[serde(deserialize_with = "normalize")] + vault_id: VaultId, + + document_id: DocumentId, +} + +#[axum::debug_handler] +pub async fn fetch_document_versions( + Path(FetchDocumentVersionsPathParams { + vault_id, + document_id, + }): Path, + State(state): State, +) -> Result>, SyncServerError> { + debug!("Fetching all versions for document `{document_id}` in vault `{vault_id}`"); + + let versions = state + .database + .get_document_versions(&vault_id, &document_id, None) + .await + .map_err(server_error)?; + + Ok(Json(versions)) +} diff --git a/sync-server/src/server/fetch_vault_history.rs b/sync-server/src/server/fetch_vault_history.rs new file mode 100644 index 00000000..42cceaa6 --- /dev/null +++ b/sync-server/src/server/fetch_vault_history.rs @@ -0,0 +1,70 @@ +use axum::{ + Json, + extract::{Path, Query, State}, +}; +use log::debug; +use serde::Deserialize; + +use super::responses::VaultHistoryResponse; +use crate::{ + app_state::{ + AppState, + database::models::{VaultId, VaultUpdateId}, + }, + errors::{SyncServerError, client_error, server_error}, + utils::normalize::normalize, +}; + +const DEFAULT_LIMIT: i64 = 50; +const MAX_LIMIT: i64 = 500; + +#[derive(Deserialize)] +pub struct FetchVaultHistoryPathParams { + #[serde(deserialize_with = "normalize")] + vault_id: VaultId, +} + +#[derive(Deserialize)] +pub struct QueryParams { + limit: Option, + before_update_id: Option, +} + +#[axum::debug_handler] +pub async fn fetch_vault_history( + Path(FetchVaultHistoryPathParams { vault_id }): Path, + Query(QueryParams { + limit, + before_update_id, + }): Query, + State(state): State, +) -> Result, SyncServerError> { + if let Some(id) = before_update_id + && id <= 0 + { + return Err(client_error(anyhow::anyhow!( + "before_update_id must be a positive integer" + ))); + } + + let limit = limit.unwrap_or(DEFAULT_LIMIT).clamp(1, MAX_LIMIT); + + debug!( + "Fetching vault history for vault `{vault_id}` (limit={limit}, before={before_update_id:?})" + ); + + // Fetch one extra row to determine if there are more results + let mut versions = state + .database + .get_vault_history(&vault_id, limit + 1, before_update_id, None) + .await + .map_err(server_error)?; + + #[allow(clippy::cast_sign_loss)] // limit is clamped to [1, 500] above + let has_more = versions.len() > limit as usize; + if has_more { + versions.pop(); + } + + Ok(Json(VaultHistoryResponse { versions, has_more })) +} diff --git a/sync-server/src/server/index.rs b/sync-server/src/server/index.rs index 64b053f7..ca8f38ff 100644 --- a/sync-server/src/server/index.rs +++ b/sync-server/src/server/index.rs @@ -1,7 +1,77 @@ -use axum::response::{Html, IntoResponse}; +use axum::{ + body::Body, + extract::{Path, State}, + http::{StatusCode, header}, + response::{Html, IntoResponse, Response}, +}; +use log::warn; +use rust_embed::Embed; -pub async fn index() -> impl IntoResponse { - const HTML_CONTENT: &str = include_str!("./assets/index.html"); - let html_content = HTML_CONTENT; - Html(html_content) +use crate::app_state::AppState; + +#[derive(Embed)] +#[folder = "../frontend/history-ui/dist/"] +struct HistoryUiAssets; + +pub async fn index(State(_state): State) -> impl IntoResponse { + if let Some(content) = HistoryUiAssets::get("index.html") { + Html( + std::str::from_utf8(content.data.as_ref()) + .inspect_err(|e| warn!("Embedded index.html is not valid UTF-8: {e}")) + .unwrap_or("

VaultLink

") + .to_owned(), + ) + .into_response() + } else { + warn!("No embedded index.html found โ€” history UI may not have been built"); + Html("

VaultLink server

".to_owned()).into_response() + } +} + +pub async fn spa_assets(Path(path): Path) -> impl IntoResponse { + // The route is /assets/*path so path is relative to assets/. + // The embedded files include the assets/ prefix from the dist directory. + let full_path = format!("assets/{path}"); + if let Some(content) = HistoryUiAssets::get(&full_path) { + let mime = mime_guess::from_path(&full_path).first_or_octet_stream(); + return Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, mime.as_ref()) + .body(Body::from(content.data.to_vec())) + .unwrap_or_else(|_| { + Response::builder() + .status(StatusCode::INTERNAL_SERVER_ERROR) + .body(Body::empty()) + .unwrap_or_else(|_| Response::new(Body::empty())) + }); + } + + // Asset paths must match an embedded file โ€” no SPA fallback. + // Serving index.html here would return 200 with text/html for missing + // .css/.js files, causing the browser to silently ignore the content. + Response::builder() + .status(StatusCode::NOT_FOUND) + .body(Body::from("Not found")) + .unwrap_or_else(|_| Response::new(Body::from("Not found"))) +} + +/// SPA fallback for production: serves index.html for client-side routes +/// (e.g. `/documents/123`). +pub async fn spa_fallback() -> impl IntoResponse { + match HistoryUiAssets::get("index.html") { + Some(content) => Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "text/html") + .body(Body::from(content.data.to_vec())) + .unwrap_or_else(|_| { + Response::builder() + .status(StatusCode::INTERNAL_SERVER_ERROR) + .body(Body::empty()) + .unwrap_or_else(|_| Response::new(Body::empty())) + }), + None => Response::builder() + .status(StatusCode::NOT_FOUND) + .body(Body::from("Not found")) + .unwrap_or_else(|_| Response::new(Body::from("Not found"))), + } } diff --git a/sync-server/src/server/list_vaults.rs b/sync-server/src/server/list_vaults.rs new file mode 100644 index 00000000..7ef23405 --- /dev/null +++ b/sync-server/src/server/list_vaults.rs @@ -0,0 +1,82 @@ +use axum::{ + Json, + extract::{Query, State}, +}; +use axum_extra::{ + TypedHeader, + headers::{Authorization, authorization::Bearer}, +}; +use log::debug; +use serde::Deserialize; + +use super::{ + auth::authenticate, + responses::{ListVaultsResponse, VaultInfo}, +}; +use crate::{ + app_state::AppState, + config::user_config::{AllowListedVaults, VaultAccess}, + errors::{SyncServerError, server_error, unauthenticated_error}, +}; + +const DEFAULT_LIMIT: usize = 50; +const MAX_LIMIT: usize = 200; + +#[derive(Deserialize)] +pub struct QueryParams { + limit: Option, + after: Option, +} + +#[axum::debug_handler] +pub async fn list_vaults( + auth_header: Option>>, + Query(QueryParams { limit, after }): Query, + State(state): State, +) -> Result, SyncServerError> { + let auth_header = auth_header + .ok_or_else(|| unauthenticated_error(anyhow::anyhow!("Missing Authorization header")))?; + + let user = authenticate(&state, auth_header.token().trim())?; + + debug!("User `{}` listing accessible vaults", user.name); + + let existing_vaults = state.database.list_vaults().await.map_err(server_error)?; + + let mut accessible: Vec = match user.vault_access { + VaultAccess::AllowAccessToAll => existing_vaults, + VaultAccess::AllowList(AllowListedVaults { ref allowed }) => existing_vaults + .into_iter() + .filter(|v| allowed.contains(v)) + .collect(), + }; + + // Cursor-based pagination: skip vaults up to and including `after` + if let Some(ref cursor) = after { + accessible.retain(|v| v.as_str() > cursor.as_str()); + } + + let limit = limit.unwrap_or(DEFAULT_LIMIT).clamp(1, MAX_LIMIT); + let has_more = accessible.len() > limit; + accessible.truncate(limit); + + let mut vaults = Vec::with_capacity(accessible.len()); + for name in accessible { + let stats = state + .database + .get_vault_stats(&name) + .await + .map_err(server_error)?; + vaults.push(VaultInfo { + name, + document_count: stats.document_count, + created_at: stats.created_at, + }); + } + + Ok(Json(ListVaultsResponse { + vaults, + has_more, + user_name: user.name, + })) +}