diff --git a/CLAUDE.md b/CLAUDE.md index 344e0ea..cf557af 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,7 +37,9 @@ Two-crate workspace (`resolver = "2"`, edition 2021) plus a Tauri app: - **Storage trait** (`storage.rs`): Strategy pattern for task persistence. `FileSystemStorage` reads/writes markdown files with YAML frontmatter and JSON metadata files. - **Repository** (`repository.rs`): `TaskRepository` wraps a `Storage` impl and provides the public API for task/list CRUD, ordering, and grouping. Tests live here. -- **Config** (`config.rs`): `AppConfig` manages named workspaces with paths. Stored in platform-specific config dirs via the `directories` crate. +- **Config** (`config.rs`): `AppConfig` manages named workspaces with paths, mode (local/webdav), theme, and WebDAV URL. Stored in platform-specific config dirs via the `directories` crate. +- **Sync** (`sync.rs`): Three-way diff sync with offline queue. Auto-appends `Onyx/` to WebDAV URL. Wrapped in `tokio::time::timeout` (60s) to handle unreachable servers on Windows. +- **WebDAV** (`webdav.rs`): reqwest client with rustls-tls, 30s request timeout, 10s connect timeout. Credentials stored via `keyring` crate (feature-gated). `Zeroizing` for credential fields. Scoped keyring keys (`com.onyx.webdav.::`); auto-migrates legacy dot-separated format on load. 10MB PROPFIND response cap. ### On-disk format @@ -49,8 +51,8 @@ The GUI uses Svelte 5 runes mode (`$state`, `$derived`, `$effect`, `$props()`). - **Sliding drawer**: Left panel (lists) slides with main content as one piece via `translateX`. 80vw wide. List items show checkmark for active list and chevron on hover. - **Three-panel slide**: Main content area is 300% wide with three panels (task list, task detail, subtask detail) that slide via `translateX` using a `taskStack` array. Stack depth 0 = list, 1 = task detail, 2 = subtask detail. -- **Settings popup**: Floating overlay card with backdrop, not a sliding panel. -- **Workspace switcher**: Custom drop-up menu in drawer footer (left), settings gear (right). +- **Settings modal**: Per-workspace settings opened from workspace kebab menu. Shows WebDAV config (for webdav workspaces), sync controls, and theme selector. +- **Workspace switcher**: Custom drop-up menu in drawer footer (left), kebab menu per workspace (right) with Settings option. - **Task animations**: Grid-rows `0fr`/`1fr` trick for smooth collapse/expand. Module-level `animateInIds` Set coordinates expand-in after toggle. - **Inline editing**: Click task to edit, auto-save on blur. `debouncedSave` snapshots task before timer to prevent stale-reference errors on component destroy. - **Kebab menus**: Tasks and lists use kebab menus with custom `ConfirmDialog` component (not native `confirm()`). "Move to..." is inline in the menu (not a submenu) to avoid overflow. @@ -60,7 +62,7 @@ The GUI uses Svelte 5 runes mode (`$state`, `$derived`, `$effect`, `$props()`). ### Current state (2026-04-03) - **Phase 1** (Core + CLI): Complete -- **Phase 2** (WebDAV sync): Backend done, CLI done, GUI wired (settings auto-populates credentials) +- **Phase 2** (WebDAV sync): Complete — CLI + GUI sync working, auto-creates `Onyx/` subfolder on remote - **Phase 3** (GUI MVP): Complete - **Phase 4** (Mobile): Tauri Android cfg-gated, needs `tauri android init` + build @@ -73,7 +75,7 @@ The GUI uses Svelte 5 runes mode (`$state`, `$derived`, `$effect`, `$props()`). - Sliding lists drawer with checkmark selection - Settings popup overlay - Workspace switcher drop-up with add/remove -- Dark mode (GNOME-style neutral grays, cyan-blue accent) +- Per-workspace theme system (System default, Light, Dark, Nord, Dracula, Solarized Dark) via CSS `data-theme` attribute - Completed tasks section with animated show/hide - Due date picker/editor (DateTimePicker in new task + task detail); `has_time: bool` field tracks whether time is set - Move task between lists (inline list in kebab menu, no submenu) @@ -81,11 +83,13 @@ The GUI uses Svelte 5 runes mode (`$state`, `$derived`, `$effect`, `$props()`). - Group-by-due-date toggle per list (main panel kebab) - Delete completed tasks (main panel kebab + subtask kebab, with confirmation dialogs) - Keyboard shortcuts (Escape priority chain: settings → detail → list menu → drawer → menus) -- WebDAV setup flow (settings auto-populates URL/credentials from config + keychain) +- Setup screen with 2-step mode selection (Local Folder vs WebDAV Server), window dragging, "Open Existing Folder" option +- WebDAV setup flow with connection test, credential storage in system keychain +- WebDAV sync: auto-creates `Onyx/` subfolder on remote, 60s hard timeout, sync error display in settings - File watcher (notify crate, 500ms debounce, auto-reloads on external changes) -- Setup screen with window dragging + "Open Existing Folder" option - Sync status indicators (last-sync time + upload/download counts chip) - Push/pull/full sync mode selection (session-only, in settings) +- WorkspaceMode enum (local/webdav) with per-workspace config - Desktop packaging (Linux: AppImage + .deb; Windows: MSI) - Tauri desktop-only deps (notify, keyring) feature-gated for Android compilation - Subtask hierarchy: subtask count shown on parent tasks in list, subtask detail via three-panel slide navigation, inline add at top of subtask list (new subtasks prepend), collapsible completed subtasks section, cascade delete (parent deletion removes all subtasks with confirmation warning) diff --git a/crates/onyx-core/src/webdav.rs b/crates/onyx-core/src/webdav.rs index 86e0d3c..1fad34e 100644 --- a/crates/onyx-core/src/webdav.rs +++ b/crates/onyx-core/src/webdav.rs @@ -99,7 +99,16 @@ impl WebDavClient { return Err(Error::WebDav(format!("PROPFIND failed with status {}", status))); } - let body = resp.text().await?; + // Reject oversized responses to prevent memory exhaustion from malicious servers + const MAX_PROPFIND_BYTES: u64 = 10 * 1024 * 1024; + if resp.content_length().unwrap_or(0) > MAX_PROPFIND_BYTES { + return Err(Error::WebDav("PROPFIND response too large (>10MB)".into())); + } + let bytes = resp.bytes().await?; + if bytes.len() as u64 > MAX_PROPFIND_BYTES { + return Err(Error::WebDav("PROPFIND response too large (>10MB)".into())); + } + let body = String::from_utf8_lossy(&bytes); parse_propfind_response(&body, &self._base_url, path) } @@ -401,7 +410,7 @@ fn extract_relative_path(href: &str, base_url: &str, request_path: &str) -> Stri /// to prevent collisions when multiple accounts exist on the same server. pub fn store_credentials(domain: &str, username: &str, password: &str) -> Result<()> { let service = format!("com.onyx.webdav.{}", domain); - let scoped_service = format!("com.onyx.webdav.{}.{}", domain, username); + let scoped_service = format!("com.onyx.webdav.{}::{}", domain, username); let user_entry = keyring::Entry::new(&service, "username") .map_err(|e| Error::Credential(format!("Failed to create keyring entry: {}", e)))?; @@ -437,18 +446,29 @@ pub fn load_credentials(domain: &str) -> Result<(Zeroizing, Zeroizing Result<()> { .and_then(|e| e.get_password().ok()); if let Some(user) = &username { - let scoped_service = format!("com.onyx.webdav.{}.{}", domain, user); + let scoped_service = format!("com.onyx.webdav.{}::{}", domain, user); if let Ok(entry) = keyring::Entry::new(&scoped_service, "password") { let _ = entry.delete_credential(); } diff --git a/docs/API.md b/docs/API.md index a877d4e..0216504 100644 --- a/docs/API.md +++ b/docs/API.md @@ -318,6 +318,8 @@ let status = get_sync_status(Path::new("/home/user/tasks"))?; Credentials are stored in the platform keychain (Windows Credential Manager, macOS Keychain, Linux Secret Service). +Keyring service keys use the format `com.onyx.webdav.::` — the `::` separator prevents key collisions when usernames contain dots. On first load, credentials stored in the legacy `.`-separated format (`com.onyx.webdav..`) are automatically migrated to the scoped format and the old entries are removed. + ```rust use onyx_core::webdav::{store_credentials, load_credentials, delete_credentials}; @@ -363,6 +365,7 @@ client.delete_file("old-task.md").await?; - **Conflict resolution**: Last-write-wins using file timestamps - **Offline queue**: Pending operations are queued and replayed when connectivity returns - **Sync state**: Stored in `.syncstate.json` within the workspace directory +- **Response size cap**: PROPFIND responses are limited to 10 MB (checked via `Content-Length` header and actual body size) to prevent memory exhaustion from malicious servers ## Error Handling