diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 1d18f45..8c9a146 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -19,7 +19,7 @@ jobs: sudo apt-get update sudo apt-get install -y jq shellcheck - - name: Run validation script (CI-capable checks) + - name: Run portable validation suite run: ./scripts/validate.sh - name: Validate basic manifest fields diff --git a/.gitignore b/.gitignore index 089dbb4..f879f45 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,7 @@ deploy.sh.bak *.swp *.swo *~ + +# Python bytecode +__pycache__/ +*.pyc diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..9ce2a54 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,176 @@ +# AGENTS.md — Omarseafile operating contract + +Concise rules for coding agents working on this repository. Read this file +every session. It is the compiled project operating model; do not reconstruct +workflow from past conversations. + +## 1. Project classification + +- **BROWNFIELD**, structurally **LIGHT**, published plugin (v1.0.0). +- High-risk domains that keep full review rigor despite LIGHT structural + complexity: + - security / trust boundaries (credentials, transfer URLs, cross-origin + token isolation, secret-file creation, process lifecycle); + - Omarchy / Quickshell host integration; + - Seafile external API integration. + +## 2. Source of truth + +Priority (highest first): + +1. executable behavior + validation / tests; +2. current source code; +3. authoritative deployed plugin state; +4. exact Git / GitHub state (pushed SHA); +5. maintained project docs (README, CONTRIBUTING, SECURITY, docs/); +6. historical docs / past AI conversations (lowest). + +- The repository is canonical. +- The installed plugin (`~/.config/omarchy/plugins/roddy.seafile`) is a + **deployment target**, never a second source tree. + +## 3. Development host + +- The current Omarchy laptop is both the authoritative development machine + and the authoritative runtime-validation machine. +- Do not introduce a split-machine workflow. + +## 4. Runtime validation contract + +After **any** source/QML change that affects runtime behavior: + +```text +SOURCE CHANGE +→ ./deploy.sh +→ omarchy-restart-shell +→ verify NEW Quickshell PID != OLD PID +→ inspect logs from NEW PID only +→ exercise the real Omarseafile UI/runtime path +``` + +- Omarchy runs a long-lived Quickshell with `QS_DISABLE_FILE_WATCHER=1`, so + hot reload is **not** authoritative. +- Manual curl / backend / helper execution alone does **not** prove UI + behavior. +- Hyprland `hyprctl` cursor/key dispatch is an accepted automation mechanism + when actual GUI interaction must be proven. +- Docs-only changes require **no** runtime restart. + +## 5. Validation + +Use the actual existing commands: + +```text +./scripts/validate.sh +omarchy plugin validate . +git diff --check +./deploy.sh --check # where source/deployment parity matters +``` + +Do not invent commands that do not exist in this repository. + +## 6. Security workflow + +- Current security work lives on branch: `security/marketplace-review`. +- Marketplace issue: **#4145**. +- Do **not** without explicit approval: merge, force-push, move the v1.0.0 + tag, create a release, or modify marketplace issue #4145. +- An implementation agent's "DONE" report is **not** proof that a maintainer + finding is closed. + +## 7. Review gate + +For security / high-risk changes: + +```text +implementation +→ focused tests / static validation +→ authoritative runtime validation where applicable +→ commit + push on the dedicated branch +→ independent review of the EXACT pushed GitHub SHA +→ remediate findings +→ only then eligible for merge +``` + +- The independent reviewer must inspect code and evidence, not trust the + implementer's conclusions. + +## 8. Autonomy + +Agents may autonomously: + +- inspect / read; +- run non-destructive tests and validation; +- implement an explicitly approved, scoped change; +- fix straightforward failures inside that scope. + +Agents must stop / escalate for: + +- destructive operations; +- architecture changes; +- new dependencies / tools; +- security-policy decisions outside the approved scope; +- branch / merge / release decisions; +- real ambiguity affecting product behavior; +- unexpected secret exposure. + +- Never print real credentials or tokens. + +## 9. Model / role policy + +- Roles are **not** permanently tied to model names. +- Use economical / free coding models for mechanical implementation, tests, + validation, and straightforward fixes. +- Reserve stronger reasoning, when available, for architecture, difficult + security design, ambiguous high-risk decisions, and major independent + review. +- Single-model operation must remain possible. +- A fresh-context reviewer is acceptable when only one model is available. + +## 10. Tooling policy + +Keep what is already working: + +- OpenCode; +- RTK; +- Ponytail; +- existing native scripts and workflow. + +Default for **new** tooling: **NONE**. + +Do not introduce BMAD, Spec Kit, OpenSpec, GSD, Task Master, Beads, Serena, +SkillSpector, MCP infrastructure, memory infrastructure, or orchestration +without a demonstrated project-specific gap and explicit approval. + +Prefer native / project-existing mechanisms first. + +## 11. Anti-churn + +This is mature brownfield. Prefer the smallest change that satisfies the +requirement. + +Do not: + +- redesign working architecture during a scoped fix; +- duplicate state authorities; +- create speculative infrastructure; +- refactor unrelated code; +- add ceremony merely to match a methodology. + +## 12. External integration + +Do not assume local / static success proves: + +- Omarchy / Quickshell behavior; +- Seafile API behavior. + +External-integration claims require evidence against the relevant real +contract / runtime. + +## 13. Current security mission + +- The marketplace remediation remains the active critical mission. +- Bootstrap adoption must **not** alter application or security + implementation. +- After this contract is established, resume the security remediation from + the existing branch state. \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0746a9f..06161b7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -3,7 +3,8 @@ ## Prerequisites - Omarchy, Quickshell, and a Wayland session for runtime testing. -- `curl`, `libsecret`/`secret-tool`, `rsync`, and optionally `wl-clipboard`. +- Python 3, `curl`, `libsecret`/`secret-tool`, `coreutils`, `util-linux`, `xdg-user-dirs`, `xdg-utils`, `rsync`, and optionally `wl-clipboard`. +- The Linux helper tests also require `procps-ng` for `pgrep`. - A disposable Seafile test account/library for mutation tests. ## Development Setup diff --git a/Panel.qml b/Panel.qml index c4b9b33..7c69c7f 100644 --- a/Panel.qml +++ b/Panel.qml @@ -55,35 +55,14 @@ Panel { property var historyFileName: "" property var historyFilePath: "" property var historyRepoId: "" + property int historyGeneration: 0 // ===== SELECTION STATE ===== property var selectedItems: [] property var selectionAnchor: null - function selectionKey(item) { - if (!item) return "" - return (item.repoId || item.repoId) + ":" + (item.fullPath || item.path || item.name) + ":" + (item.type || (item.isDir ? "dir" : "file")) - } - - function isSelected(item) { - if (!item) return false - var key = item.repoId + ":" + (item.fullPath || item.path || item.name) + ":" + (item.type || (item.isDir ? "dir" : "file")) - for (var i = 0; i < root.selectedItems.length; i++) { - var sel = root.selectedItems[i] - var selKey = sel.repoId + ":" + (sel.fullPath || sel.path || sel.name) + ":" + (sel.type || (sel.isDir ? "dir" : "file")) - if (sel.repoId === item.repoId && (sel.fullPath || sel.path || sel.name) === (item.fullPath || item.path || item.name)) { - return true - } - } - return false - } - function selectionKeyForItem(item) { - if (!item) return "" - var repoId = item.repoId || "" - var path = item.fullPath || item.path || item.name || "" - var type = item.type || (item.isDir ? "dir" : "file") - return repoId + ":" + path + ":" + type + return SelectionHelper.makeKey(item) } function isItemSelected(item) { @@ -108,12 +87,13 @@ Panel { root.selectionAnchor = item } - function selectRange(item) { + function selectRange(item, visibleItems) { + var items = visibleItems || root.currentItems var anchor = root.selectionAnchor if (!anchor) { - anchor = root.currentItems.length > 0 ? root.currentItems[0] : null + anchor = items.length > 0 ? items[0] : null } - root.selectedItems = SelectionHelper.rangeSelect(root.selectedItems, anchor, item, root.currentItems) + root.selectedItems = SelectionHelper.rangeSelect(root.selectedItems, anchor, item, items) root.selectionAnchor = item } @@ -142,7 +122,7 @@ Panel { function handleBackClick() { if (root.destinationSubmitting) return if (root.showTransfers) { root.showTransfers = false } - else if (root.showHistory) { root.showHistory = false; historyLoader.sourceComponent = undefined } + else if (root.showHistory) { root.historyGeneration++; root.showHistory = false; historyLoader.sourceComponent = undefined } else if (root.showTrash) { root.showTrash = false; trashLoader.sourceComponent = undefined } else if (settingsLoader.sourceComponent) { root.closeSettings() } else { root.goBack() } @@ -157,7 +137,7 @@ Panel { if (confirmLoader.item) { root.cancelDelete(); return true } if (renameLoader.item) { root.cancelRename(); return true } if (createFolderLoader.item) { root.cancelCreateFolder(); return true } - if (historyLoader.item) { root.showHistory = false; historyLoader.sourceComponent = undefined; return true } + if (historyLoader.item) { root.historyGeneration++; root.showHistory = false; historyLoader.sourceComponent = undefined; return true } if (trashLoader.item) { root.showTrash = false; trashLoader.sourceComponent = undefined; return true } if (settingsLoader.item) { root.closeSettings(); return true } return false @@ -175,11 +155,11 @@ Panel { var validKeys = {} for (var i = 0; i < root.currentItems.length; i++) { var item = root.currentItems[i] - var key = item.repoId + ":" + (item.fullPath || item.path || item.name) + ":" + (item.type || (item.isDir ? "dir" : "file")) + var key = SelectionHelper.makeKey(item) validKeys[key] = true } root.selectedItems = root.selectedItems.filter(function(item) { - var key = item.repoId + ":" + (item.fullPath || item.path || item.name) + ":" + (item.type || (item.isDir ? "dir" : "file")) + var key = SelectionHelper.makeKey(item) return validKeys[key] === true }) } @@ -206,6 +186,9 @@ Panel { root.handleTransferCompletion(transfer) } } + function onTransferError(message) { + root.showToast(message, "error") + } } // Search state @@ -266,35 +249,12 @@ Panel { function showItemContextMenu(item, x, y) { if (!item || root.destinationMode) return - if (!root.isItemSelected(item)) root.selectOnly(item) + if (!root.currentRepo) root.clearSelection() + else if (!root.isItemSelected(item)) root.selectOnly(item) contextMenu.item = item contextMenu.isDir = item.type === "dir" + contextMenu.libraryMode = root.currentRepo === null contextMenu.selectionCount = root.selectedItems.length > 0 ? root.selectedItems.length : 1 - // Disconnect previous connections to avoid duplicates - try { contextMenu.openClicked.disconnect(root.openFile) } catch (e) {} - try { contextMenu.openClicked.disconnect(root.onItemClicked) } catch (e) {} - try { contextMenu.downloadClicked.disconnect(root.onDownloadClicked) } catch (e) {} - try { contextMenu.shareClicked.disconnect(root.pickShare) } catch (e) {} - try { contextMenu.renameClicked.disconnect(root.pickRename) } catch (e) {} - try { contextMenu.moveClicked.disconnect(root.moveItems) } catch (e) {} - try { contextMenu.copyClicked.disconnect(root.copyItems) } catch (e) {} - try { contextMenu.deleteClicked.disconnect(root.deleteItems) } catch (e) {} - try { contextMenu.historyClicked.disconnect(root.openHistory) } catch (e) {} - try { contextMenu.deleteClicked.disconnect(root.deleteItems) } catch (e) {} - - // Connect signals - if (item.type === "dir") { - contextMenu.openClicked.connect(root.onItemClicked) - } else { - contextMenu.openClicked.connect(root.openFile) - } - contextMenu.downloadClicked.connect(root.onDownloadClicked) - contextMenu.shareClicked.connect(root.pickShare) - contextMenu.renameClicked.connect(root.pickRename) - contextMenu.moveClicked.connect(root.moveItems) - contextMenu.copyClicked.connect(root.copyItems) - contextMenu.deleteClicked.connect(root.deleteItems) - contextMenu.historyClicked.connect(root.openHistory) // Parent to the keyboard-panel window's overlay: never clipped by the // file list, and rendered in the window that owns pointer/keyboard. contextMenu.parent = keyCatcher.Overlay.overlay @@ -389,16 +349,16 @@ Panel { ContextMenu { id: contextMenu bar: root.bar - onOpenClicked: function(item) { item.type === "dir" ? root.onItemClicked(item) : root.openFile(item) } - onDownloadClicked: root.downloadFile - onRenameClicked: root.pickRename - onMoveClicked: root.moveItems - onCopyClicked: root.copyItems - onShareClicked: root.pickShare - onHistoryClicked: root.openHistory + onOpenClicked: function(item) { if (item) item.type === "dir" ? root.onItemClicked(item) : root.openFile(item) } + onDownloadClicked: function(item) { root.downloadFile(item) } + onRenameClicked: function(item) { root.pickRename(item) } + onMoveClicked: function(item) { root.moveItems(item) } + onCopyClicked: function(item) { root.copyItems(item) } + onShareClicked: function(item) { root.pickShare(item) } + onHistoryClicked: function(item) { root.openHistory(item) } onDeleteClicked: function(item) { - if (item) root.pickDelete(item) - else root.deleteItems() + if (root.selectedItems.length > 1) root.deleteItems() + else if (item) root.pickDelete(item) } } @@ -406,7 +366,6 @@ Panel { id: content width: parent.width spacing: 0 - focus: true Toast { id: toast @@ -513,16 +472,18 @@ Panel { font.family: root.bar.fontFamily font.pixelSize: Style.font.caption horizontalAlignment: Text.AlignHCenter + textFormat: Text.PlainText } Text { width: parent.width - text: root.currentRepo ? root.currentRepo.name + (root.currentPath === "/" ? " /" : " / " + root.currentPath.substring(1)) : "" + text: Models.boundedDisplayText(root.currentRepo ? root.currentRepo.name + (root.currentPath === "/" ? " /" : " / " + root.currentPath.substring(1)) : "", 4096) color: Qt.darker(root.bar.foreground, 1.3) font.family: root.bar.fontFamily font.pixelSize: Style.font.caption font.bold: true elide: Text.ElideMiddle horizontalAlignment: Text.AlignHCenter + textFormat: Text.PlainText } Row { width: parent.width @@ -608,6 +569,7 @@ Panel { horizontalAlignment: Text.AlignHCenter anchors.horizontalCenter: parent.horizontalCenter topPadding: Style.space(4) + textFormat: Text.PlainText } ErrorOverlay { @@ -641,8 +603,8 @@ Panel { visible: !root.loading && root.errorMessage === "" && !root.searchActive && !root.showTransfers selectedItems: root.selectedItems selectionAnchor: root.selectionAnchor - onSelectionToggle: root.destinationMode ? function() {} : root.toggleSelection - onSelectionRange: root.destinationMode ? function() {} : root.selectRange + onSelectionToggle: root.destinationMode || !root.currentRepo ? function() {} : root.toggleSelection + onSelectionRange: root.destinationMode || !root.currentRepo ? function() {} : root.selectRange onSelectOnly: root.destinationMode ? function() {} : root.selectOnly onPositionClicked: root.positionOn onContextMenuRequested: root.showItemContextMenu @@ -753,7 +715,7 @@ Panel { bar: root.bar // Canonical item-context shape: { items: [...], isDir }. The legacy // { item } field is honored only as a safety fallback. - message: { + message: Models.boundedDisplayText((function() { var d = root.deleteItemData if (!d) return "Are you sure?" var list = d.items && d.items.length > 0 ? d.items : (d.item ? [d.item] : []) @@ -761,7 +723,7 @@ Panel { if (list.length > 1) return "Delete " + list.length + " item(s)?" var it = list[0] return "Delete " + (it.type === "dir" ? "folder" : "file") + " \"" + (it.name || "") + "\"?" - } + })(), 4096) onConfirm: function() { root.confirmDelete() } onCancel: function() { root.cancelDelete() } } @@ -805,14 +767,17 @@ Panel { var serverUrl = root.serverUrl var token = Auth.getToken() var session = root.sessionGeneration + var generation = root.historyGeneration SeafileAPI.downloadRevision(repoId, filePath, revision.commitId, function(success, data, error) { - if (session !== root.sessionGeneration) return + if (session !== root.sessionGeneration || generation !== root.historyGeneration) return if (success && typeof data === "string" && data !== "") { - TransferService.startDownload( - { name: fileName + " (rev " + revision.commitId.substring(0, 8) + ")", type: "file" }, - token, serverUrl, repoId, - root.getDownloadsDir(), filePath, data - ) + SafePath.getDownloadsDir(function(dir) { + if (session !== root.sessionGeneration || generation !== root.historyGeneration || !dir) return + TransferService.startDownload( + { name: fileName + " (rev " + revision.commitId.substring(0, 8) + ")", type: "file" }, + token, serverUrl, repoId, dir, filePath, data + ) + }) root.showHistory = false historyLoader.sourceComponent = undefined root.showToast("Downloading historical revision...") @@ -821,7 +786,7 @@ Panel { } }) } - onClose: function() { root.showHistory = false; historyLoader.sourceComponent = undefined } + onClose: function() { root.historyGeneration++; root.showHistory = false; historyLoader.sourceComponent = undefined } onError: function(message) { root.showToast(message, "error") } } } @@ -860,11 +825,17 @@ Panel { var normalized = normalizeUrl(url) if (!normalized) { root.loading = false - root.errorMessage = "Invalid URL format. Use https://domain.com or http://ip:port" + root.errorMessage = "Invalid URL format. Use https://domain.com" return } - if (normalized.startsWith("http://")) { - root.showToast("Warning: Using HTTP — credentials sent in cleartext", "error") + var policy = UrlPolicy.validateForAuth(normalized) + if (!policy.valid) { + root.loading = false + root.errorMessage = policy.error + return + } + if (policy.warning) { + root.showToast(policy.warning, "warning") } root.loading = true root.errorMessage = "" @@ -999,15 +970,6 @@ Panel { } } - function onDownloadClicked(item) { - if (item.type === "file") { - var token = Auth.getToken() - if (!token) { root.errorMessage = "Not authenticated"; return } - var fullPath = root.currentPath === "/" ? "/" + item.name : root.currentPath + "/" + item.name - TransferService.startDownload(item, token, root.serverUrl, root.currentRepo.id, getDownloadsDir(), fullPath) - } - } - function goBack() { if (root.destinationSubmitting) return root.clearSelection() @@ -1276,13 +1238,6 @@ Panel { TransferService.startUpload(localFilePath, token, root.serverUrl, root.currentRepo.id, root.currentPath, fileName) } - function getDownloadsDir() { return Quickshell.env("HOME") + "/Downloads" } - - function getCacheDir() { - var base = Quickshell.env("XDG_CACHE_HOME") || (Quickshell.env("HOME") + "/.cache") - return base + "/omarseafile" - } - function openFile(item) { if (!item || item.type !== "file") return if (!root.currentRepo) { root.errorMessage = "No library selected"; return } @@ -1297,8 +1252,16 @@ Panel { if (!root.currentRepo) { root.errorMessage = "No library selected"; return } var token = Auth.getToken() if (!token) { root.errorMessage = "Not authenticated"; return } + var session = root.sessionGeneration + var serverUrl = root.serverUrl + var repoId = root.currentRepo.id + var file = { name: item.name, type: item.type } var fullPath = root.currentPath === "/" ? "/" + item.name : root.currentPath + "/" + item.name - TransferService.startDownload(item, token, root.serverUrl, root.currentRepo.id, getDownloadsDir(), fullPath) + SafePath.getDownloadsDir(function(dir) { + if (session !== root.sessionGeneration) return + if (!dir) { root.errorMessage = "No download directory available"; return } + TransferService.startDownload(file, token, serverUrl, repoId, dir, fullPath) + }) } function handleTransferCompletion(transfer) { @@ -1323,6 +1286,7 @@ Panel { root.navigationGeneration++ root.searchGeneration++ root.connectionTestGeneration++ + root.historyGeneration++ searchDebounceTimer.stop() TransferService.logoutCleanup() Auth.clearSession().catch(function(error) { @@ -1374,7 +1338,15 @@ Panel { function clearCache() { Cache.clear() - root.showToast("Cache cleared") + SafePath.clearPersistentCache(function(result) { + if (result.complete) { + root.showToast("Cache cleared", "success") + } else if (result.protected) { + root.showToast("Memory cache cleared; active files remain", "warning") + } else { + root.showToast("Memory cache cleared; persistent cache cleanup could not complete", "warning") + } + }) } function changeServerUrl(newUrl, apply) { @@ -1383,6 +1355,11 @@ Panel { root.showToast("Invalid URL format", "error") return } + var policy = UrlPolicy.validateForAuth(normalized) + if (!policy.valid) { + root.showToast(policy.error, "error") + return + } if (apply && normalized !== root.serverUrl) { root.doLogout() root.serverUrl = normalized @@ -1464,8 +1441,8 @@ Panel { function cancelCreateFolder() { createFolderLoader.sourceComponent = undefined } function confirmCreateFolder(folderName) { - if (!folderName || folderName.trim() === "") { root.errorMessage = "Folder name cannot be empty"; return } - if (folderName !== folderName.trim()) { root.errorMessage = "Folder names cannot start or end with spaces"; return } + if (!folderName || folderName.trim() === "") { if (createFolderLoader.item) createFolderLoader.item.errorText._raw = "Folder name cannot be empty"; return } + if (folderName !== folderName.trim()) { if (createFolderLoader.item) createFolderLoader.item.errorText._raw = "Folder names cannot start or end with spaces"; return } createFolderLoader.sourceComponent = undefined var token = Auth.getToken() if (!token) { root.errorMessage = "Not authenticated"; return } @@ -1492,7 +1469,7 @@ Panel { property var renameItemData: null function pickRename(item) { - if (!item) return + if (!item || !root.currentRepo) return root.renameItemData = { items: [item], isDir: item.type === "dir" } renameLoader.sourceComponent = renameComponent } @@ -1500,11 +1477,11 @@ Panel { function cancelRename() { renameLoader.sourceComponent = undefined; root.renameItemData = null } function confirmRename(newName) { - if (!newName || newName.trim() === "") { root.errorMessage = "Name cannot be empty"; return } - if (newName !== newName.trim()) { root.errorMessage = "Names cannot start or end with spaces"; return } + if (!newName || newName.trim() === "") { if (renameLoader.item) renameLoader.item.errorText._raw = "Name cannot be empty"; return } + if (newName !== newName.trim()) { if (renameLoader.item) renameLoader.item.errorText._raw = "Names cannot start or end with spaces"; return } var d = root.renameItemData var item = d && d.items && d.items.length > 0 ? d.items[0] : null - if (!item) { cancelRename(); return } + if (!item || !root.currentRepo) { cancelRename(); return } if (newName === item.name) { cancelRename(); return } renameLoader.sourceComponent = undefined var token = Auth.getToken() @@ -1764,6 +1741,7 @@ Panel { property var shareItemData: null function pickShare(item) { + if (!item || !root.currentRepo) return var fullPath = root.currentPath === "/" ? "/" + item.name : root.currentPath + "/" + item.name root.shareItemData = { item: item, isDir: item.type === "dir", fullPath: fullPath } shareLoader.sourceComponent = shareComponent @@ -1773,6 +1751,7 @@ Panel { function openHistory(item) { if (!root.currentRepo || !item || item.type !== "file") return + root.historyGeneration++ root.historyRepoId = root.currentRepo.id root.historyFileName = item.name root.historyFilePath = root.currentPath === "/" ? "/" + item.name : root.currentPath + "/" + item.name @@ -1815,6 +1794,14 @@ Panel { if (authenticated && !hasRequiredMissing) { var token = Auth.getToken() var serverUrl = Auth.getServerUrl() + var policy = UrlPolicy.validateForAuth(serverUrl) + if (!policy.valid) { + root.errorMessage = "Stored server URL requires HTTPS. Update the server URL in Settings." + Auth.cachedToken = "" + Auth.cachedServerUrl = "" + Auth.cachedEmail = "" + return + } root.serverUrl = serverUrl SeafileAPI.setBaseUrl(serverUrl) SeafileAPI.setToken(token) diff --git a/README.md b/README.md index 89e825a..6fad266 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,11 @@ Omarseafile is an [Omarchy](https://omarchy.org) bar-widget plugin for browsing - Browse accessible Seafile libraries and folders with breadcrumbs. - Search across accessible non-encrypted libraries. -- Download files to `~/Downloads` with progress, cancellation, retry, and no-overwrite collision protection. +- Download files to the XDG user download directory (falling back to `~/Downloads`) with progress, cancellation, retry, and no-overwrite collision protection. +- **Secure download target creation**: temporary files created with exclusive O_CREAT|O_EXCL|O_NOFOLLOW on a held directory FD, mode 0600, curl writes to held FD (no pathname reopen), producer-side byte ceiling (1 GiB default) and disk-space admission check (256 MiB safety margin), automatic cleanup on failure/cancellation, symlink and clobber protection. +- **Open Local**: download to private `XDG_CACHE_HOME` (or `~/.cache`) cache, same secure creation, bounded cache (1 GiB default, recovery/eviction before use), cached file opened with xdg-open. - Upload a local file by entering its path, with progress, cancellation, manual retry, and server-side conflict protection. +- **Upload source hardening**: absolute path required, must be regular file (rejects symlinks, directories, devices, FIFOs, sockets), size precheck (1 GiB default). - Create folders, rename items, and delete files or folders. - Select multiple items with Ctrl+Click, Shift+Click, or Ctrl+A for batch actions. - Copy and move files and folders, including batch operations. @@ -26,6 +29,7 @@ Omarseafile is an [Omarchy](https://omarchy.org) bar-widget plugin for browsing - `curl` for transfers. - `libsecret` for `secret-tool` and credential storage. - `wl-clipboard` for copying share links. Sharing still works without it, but copying the link does not. +- Python 3, `coreutils` (`stat`, `realpath`), `util-linux` (`setsid`), and `xdg-user-dirs`/`xdg-utils` (`xdg-user-dir`, `xdg-open`), normally supplied by Omarchy/Arch desktop installations. On Arch/Omarchy: @@ -53,7 +57,7 @@ omarchy plugin update roddy.seafile 2. Enter the server URL, email, and password. 3. Select **Connect**. -HTTPS is recommended. HTTP URLs are accepted with a warning. The URL is normalized and validated before authentication. Auto-login can be enabled or disabled in Settings. +HTTPS is required for non-loopback servers. HTTP is accepted only for loopback addresses (localhost, 127.0.0.1). The URL is normalized and validated before authentication. Auto-login can be enabled or disabled in Settings. The session token, server URL, and account email are stored through the desktop Secret Service using `secret-tool`. The login password is not persisted. @@ -114,7 +118,7 @@ Shortcuts are contextual and are not intercepted while a text field has focus. S - Copy and Move are limited to the current source library. - Seafile CE support depends on the server's enabled APIs. In the tested CE 12.0.x environment, trash restore and revision revert are unavailable; repo-scoped search returns all matching results without pagination. - Large uploads use a single request rather than chunked or resumable upload. -- HTTPS is strongly recommended. Certificate verification uses the system trust store; TLS verification is not bypassed. +- HTTPS is required for non-loopback servers. Certificate verification uses the system trust store; TLS verification is not bypassed. - The plugin assumes Omarchy's Quickshell runtime and Wayland desktop integration. ## Troubleshooting @@ -122,7 +126,7 @@ Shortcuts are contextual and are not intercepted while a text field has focus. S | Problem | Action | | --- | --- | | Missing dependency | Install `curl`, `libsecret`, and optionally `wl-clipboard`. | -| Invalid URL | Include an `http://` or `https://` scheme and check the server address. | +| Invalid URL | Include an `https://` scheme and check the server address. HTTP is only allowed for loopback. | | Authentication failure | Check the credentials and try the Seafile web interface. | | TLS failure | Use a certificate trusted by the system; do not disable verification. | | Auto-login failure | Check that Secret Service is available and Auto-login is enabled in Settings. | @@ -164,6 +168,18 @@ secret-tool clear service seafile key user-email See [SECURITY.md](SECURITY.md) for reporting and security boundaries. In brief, credentials use Secret Service, transfer authentication avoids argv/environment exposure, temporary authorization/configuration files are restricted and cleaned up, and the plugin makes no telemetry connection. +**Transfer security (Finding 5 remediation):** +- Download targets created exclusively via held directory FD (O_DIRECTORY|O_NOFOLLOW), verified ownership and permissions, unpredictable basename, O_CREAT|O_EXCL|O_NOFOLLOW, mode 0600 +- curl writes to held file descriptor (stdout), never a pathname target +- Producer-side byte ceiling (default 1 GiB via curl --max-filesize) and disk-space admission check (fstatvfs on held dir_fd, default 256 MiB safety margin) +- Download deadlines: --max-time 30 min, --connect-timeout 10s, stall protection (--speed-limit 1 --speed-time 30s) +- Process group isolation via setsid; cancellation kills entire process tree (kill -TERM -pgid) +- Open Local cache bounded (default 1 GiB), LRU eviction on successful completion, active/temp files protected +- Upload source validation: absolute path, regular file only (rejects symlinks, directories, devices, FIFOs, sockets), size precheck (default 1 GiB) +- Cross-origin transfer URLs never receive Authorization header (same-origin check) +- Redirects disabled (--no-location) +- Helper stdout/stderr bounded (64 KiB stderr cap) + ## Project Documents - [Security policy](SECURITY.md) diff --git a/SECURITY.md b/SECURITY.md index 80627d4..199bcaa 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,7 +2,7 @@ ## Supported Versions -Until v1.0 is released, security fixes are made against the current development branch. Older published versions may not receive fixes. +Security fixes are made against the current development branch. Older published versions may not receive fixes. ## Reporting a Vulnerability @@ -14,7 +14,16 @@ Please report suspected vulnerabilities privately through the repository's GitHu - Transfer authentication and server-provided transfer URLs are kept out of process arguments and environment variables. - Temporary authorization header and curl configuration files are created with mode 0600 and removed after use, including failure and cancellation paths. - Transfer processes disable user curl configuration and do not follow redirects, so a custom authorization header is not forwarded to another origin. -- TLS certificate verification is not disabled. HTTPS is recommended; HTTP is accepted only with a warning. +- TLS certificate verification is not disabled. HTTPS is required for non-loopback servers; HTTP is accepted only for loopback (localhost, 127.0.0.1, ::1). - The plugin has no telemetry service. Network requests are made to the Seafile server configured by the user and to local desktop utilities such as `wl-copy`. +**Transfer Path Hardening (Finding 5):** +- **Secure output creation**: Download targets created via `secure_output.py` using held directory FD (O_DIRECTORY|O_NOFOLLOW), verified ownership/permissions, unpredictable basename, O_CREAT|O_EXCL|O_NOFOLLOW, mode 0600. curl writes to held FD (stdout), never a pathname. Relative unlink on failure/cancellation. +- **Byte ceiling & disk admission**: Producer-side 1 GiB default via curl --max-filesize. Disk-space admission check using fstatvfs on held dir_fd with 256 MiB safety margin. Insufficient space fails before any content write. ENOSPC during transfer triggers cleanup. +- **Deadlines**: curl --max-time 30 min, --connect-timeout 10s, stall protection (--speed-limit 1 --speed-time 30s). Process group isolation via setsid; cancellation kills entire tree (kill -TERM -pgid). +- **Open Local cache**: Private `XDG_CACHE_HOME`/omarseafile (or `~/.cache/omarseafile`). Bounded 1 GiB default with recovery before each new Open Local transfer. Active/temp files are protected from eviction. No symlink traversal during eviction. +- **Upload source hardening**: Absolute path required. Must be regular file (stat %F check). Rejects symlinks, directories, devices, FIFOs, sockets. Size precheck (1 GiB default). +- **Auth isolation**: Cross-origin transfer URLs never receive Authorization header (same-origin check via UrlPolicy.shouldAttachAuth). Redirects disabled (--no-location). +- **Output bounds**: Helper stderr capped at 64 KiB (--max-stderr-bytes). stdout bounded by curl --max-filesize. + These are implementation goals and documented behavior, not a guarantee against abrupt host/process termination or a compromised host or Seafile server. Keep Omarchy, Quickshell, Seafile, and the host system updated. diff --git a/components/BatchActionBar.qml b/components/BatchActionBar.qml index 6ea3d30..529886a 100644 --- a/components/BatchActionBar.qml +++ b/components/BatchActionBar.qml @@ -34,6 +34,7 @@ Item { font.pixelSize: Style.font.caption font.bold: true anchors.verticalCenter: parent.verticalCenter + textFormat: Text.PlainText } Item { diff --git a/components/Breadcrumbs.qml b/components/Breadcrumbs.qml index 49eae7b..43c6684 100644 --- a/components/Breadcrumbs.qml +++ b/components/Breadcrumbs.qml @@ -1,6 +1,7 @@ import QtQuick import qs.Commons import qs.Ui +import "../js" Item { id: root @@ -24,7 +25,7 @@ Item { Text { id: segmentLabel - text: modelData.name + text: Models.boundedDisplayText(modelData.name, 1024) color: index === root.path.length - 1 ? root.bar.foreground : Qt.darker(root.bar.foreground, 1.4) font.family: root.bar.fontFamily font.pixelSize: Style.font.body @@ -32,6 +33,7 @@ Item { elide: Text.ElideRight width: parent.width - (index < root.path.length - 1 ? separator.implicitWidth : 0) anchors.verticalCenter: parent.verticalCenter + textFormat: Text.PlainText MouseArea { anchors.fill: parent diff --git a/components/ConfirmDialog.qml b/components/ConfirmDialog.qml index e920041..6ff3be4 100644 --- a/components/ConfirmDialog.qml +++ b/components/ConfirmDialog.qml @@ -1,6 +1,7 @@ import QtQuick import qs.Commons import qs.Ui +import "../js" Item { id: root @@ -28,12 +29,13 @@ Item { } Text { - text: root.message + text: Models.boundedDisplayText(root.message, 4096) color: root.bar.foreground font.family: root.bar.fontFamily font.pixelSize: Style.font.body wrapMode: Text.WordWrap width: parent.width + textFormat: Text.PlainText } Row { diff --git a/components/ContextMenu.qml b/components/ContextMenu.qml index 1111f25..c7f93b5 100644 --- a/components/ContextMenu.qml +++ b/components/ContextMenu.qml @@ -9,6 +9,7 @@ Popup { property var item: null property bool isDir: false property int selectionCount: 1 + property bool libraryMode: false property QtObject bar: null signal openClicked(var item) @@ -36,7 +37,7 @@ Popup { Button { width: parent.width text: "Open" - visible: !root.batchMode && !root.isDir + visible: !root.libraryMode && !root.batchMode && !root.isDir onClicked: { root.openClicked(root.item) root.close() @@ -46,7 +47,7 @@ Popup { Button { width: parent.width text: "Open" - visible: !root.batchMode && root.isDir + visible: !root.batchMode && (root.libraryMode || root.isDir) onClicked: { root.openClicked(root.item) root.close() @@ -56,7 +57,7 @@ Popup { Button { width: parent.width text: "Download" - visible: !root.batchMode && !root.isDir + visible: !root.libraryMode && !root.batchMode && !root.isDir onClicked: { root.downloadClicked(root.item) root.close() @@ -66,7 +67,7 @@ Popup { Button { width: parent.width text: "Share" - visible: !root.batchMode + visible: !root.libraryMode && !root.batchMode onClicked: { root.shareClicked(root.item) root.close() @@ -76,7 +77,7 @@ Popup { Button { width: parent.width text: "Rename" - visible: !root.batchMode + visible: !root.libraryMode && !root.batchMode onClicked: { root.renameClicked(root.item) root.close() @@ -86,7 +87,7 @@ Popup { Button { width: parent.width text: root.batchMode ? "Move " + root.selectionCount + " items" : "Move" - visible: !root.batchMode + visible: !root.libraryMode onClicked: { root.moveClicked(root.item) root.close() @@ -96,7 +97,7 @@ Popup { Button { width: parent.width text: root.batchMode ? "Copy " + root.selectionCount + " items" : "Copy" - visible: !root.batchMode + visible: !root.libraryMode onClicked: { root.copyClicked(root.item) root.close() @@ -106,7 +107,7 @@ Popup { Button { width: parent.width text: "History" - visible: !root.batchMode && !root.isDir + visible: !root.libraryMode && !root.batchMode && !root.isDir onClicked: { root.historyClicked(root.item) root.close() @@ -116,7 +117,7 @@ Popup { Button { width: parent.width text: "Delete" - visible: true + visible: !root.libraryMode onClicked: { root.deleteClicked(root.item) root.close() diff --git a/components/CreateFolderDialog.qml b/components/CreateFolderDialog.qml index cdb0624..ad1aac3 100644 --- a/components/CreateFolderDialog.qml +++ b/components/CreateFolderDialog.qml @@ -1,6 +1,7 @@ import QtQuick import qs.Commons import qs.Ui +import "../js" Item { id: root @@ -66,6 +67,9 @@ Item { font.pixelSize: Style.font.caption visible: text !== "" wrapMode: Text.WordWrap + textFormat: Text.PlainText + text: Models.boundedDisplayText(errorText._raw, 4096) + property string _raw: "" } Row { diff --git a/components/EmptyState.qml b/components/EmptyState.qml index a231545..59c3195 100644 --- a/components/EmptyState.qml +++ b/components/EmptyState.qml @@ -1,6 +1,7 @@ import QtQuick import qs.Commons import qs.Ui +import "../js" Item { id: root @@ -28,21 +29,23 @@ Item { } Text { - text: root.title + text: Models.boundedDisplayText(root.title, 1024) color: root.bar.foreground font.family: root.bar.fontFamily font.pixelSize: Style.font.body font.bold: true anchors.horizontalCenter: parent.horizontalCenter + textFormat: Text.PlainText } Text { - text: root.subtitle + text: Models.boundedDisplayText(root.subtitle, 1024) color: Qt.darker(root.bar.foreground, 1.4) font.family: root.bar.fontFamily font.pixelSize: Style.font.caption anchors.horizontalCenter: parent.horizontalCenter visible: root.subtitle !== "" + textFormat: Text.PlainText } Button { diff --git a/components/ErrorOverlay.qml b/components/ErrorOverlay.qml index f9ac39f..6fbc9d5 100644 --- a/components/ErrorOverlay.qml +++ b/components/ErrorOverlay.qml @@ -1,6 +1,7 @@ import QtQuick import qs.Commons import qs.Ui +import "../js" Item { id: root @@ -23,21 +24,21 @@ Item { spacing: Style.space(16) Text { - text: root.message + text: Models.boundedDisplayText(root.message, 4096) color: Color.urgent font.family: root.bar ? root.bar.fontFamily : Style.font.family font.pixelSize: Style.font.body wrapMode: Text.WordWrap width: Math.min(parent.width, Style.space(340)) horizontalAlignment: Text.AlignHCenter + textFormat: Text.PlainText } Button { text: "Retry" onClicked: { - root.visible = false if (root.onRetry) root.onRetry() } } } -} \ No newline at end of file +} diff --git a/components/FileItem.qml b/components/FileItem.qml index b126419..adccdec 100644 --- a/components/FileItem.qml +++ b/components/FileItem.qml @@ -77,13 +77,14 @@ Item { Text { id: nameLabel - text: safeItem.name || "" + text: Models.boundedDisplayText(safeItem.name || "", 1024) color: root.isSelected ? Color.accent : (root.bar ? root.bar.foreground : Color.foreground) font.family: root.bar ? root.bar.fontFamily : Style.font.family font.pixelSize: Style.font.body elide: Text.ElideRight width: parent ? parent.width - icon.width - sizeLabel.width - (dateLabel.visible ? dateLabel.width : 0) - transferWidth - Style.space(36) : 0 anchors.verticalCenter: parent.verticalCenter + textFormat: Text.PlainText } Item { @@ -113,6 +114,7 @@ Item { font.family: root.bar ? root.bar.fontFamily : Style.font.family font.pixelSize: Style.font.caption anchors.verticalCenter: parent.verticalCenter + textFormat: Text.PlainText } } } @@ -127,6 +129,7 @@ Item { horizontalAlignment: Text.AlignRight anchors.verticalCenter: parent.verticalCenter visible: !root.isDownloading && !root.isUploading + textFormat: Text.PlainText } Text { @@ -140,6 +143,7 @@ Item { elide: Text.ElideRight anchors.verticalCenter: parent.verticalCenter visible: !root.isDownloading && !root.isUploading + textFormat: Text.PlainText } } @@ -159,6 +163,7 @@ MouseArea { var pos = mapToItem(Overlay.overlay, mouse.x, mouse.y) if (root.onContextMenuRequested) root.onContextMenuRequested(root.item, pos.x, pos.y) } else { + if (root.ListView.view) root.ListView.view.currentIndex = root.itemIndex // Some keyboards/layouts send Meta (Super/Cmd) where Ctrl is // intended — accept both for selection modifiers. var accel = Qt.ControlModifier | Qt.MetaModifier @@ -166,12 +171,15 @@ MouseArea { if (root.onSelectionToggle) root.onSelectionToggle(root.item) } else if (mouse.modifiers & Qt.ShiftModifier) { if (root.onSelectionRange) root.onSelectionRange(root.item) - } else if (root.isDir) { + } else { + if (root.onPositionClicked) root.onPositionClicked(root.item) + if (root.isDir) { // Plain click on a folder/library navigates into it. if (root.onItemClicked) root.onItemClicked(root.item) - } else { + } else { // Plain click on a file opens it with the default application. if (root.onOpenClicked) root.onOpenClicked(root.item) + } } } } @@ -184,4 +192,4 @@ MouseArea { } } } -} \ No newline at end of file +} diff --git a/components/FileList.qml b/components/FileList.qml index 5e96437..8b0b82c 100644 --- a/components/FileList.qml +++ b/components/FileList.qml @@ -90,7 +90,7 @@ delegate: FileItem { findTransfer: root.findTransfer transferRevision: root.transferRevision onSelectionToggle: root.onSelectionToggle - onSelectionRange: root.onSelectionRange + onSelectionRange: function(item) { root.onSelectionRange(item, root.sortedItems) } onSelectOnly: root.onSelectOnly onPositionClicked: root.onPositionClicked onContextMenuRequested: root.onContextMenuRequested diff --git a/components/HistoryPanel.qml b/components/HistoryPanel.qml index 768fc35..9d15f19 100644 --- a/components/HistoryPanel.qml +++ b/components/HistoryPanel.qml @@ -43,7 +43,7 @@ Column { spacing: Style.space(8) Text { - text: "History: " + root.fileName + text: Models.boundedDisplayText("History: " + root.fileName, 1024) color: root.bar.foreground font.family: root.bar.fontFamily font.pixelSize: Style.font.title @@ -51,6 +51,7 @@ Column { anchors.verticalCenter: parent.verticalCenter elide: Text.ElideRight width: parent.width - Style.space(24) + textFormat: Text.PlainText } } @@ -58,7 +59,7 @@ Column { ListView { id: historyList width: parent.width - height: parent.height - Style.space(40) + height: root.historyData.length === 0 ? Style.space(160) : Math.min(contentHeight, Style.space(360)) clip: true spacing: Style.space(4) model: root.historyData @@ -69,7 +70,7 @@ Column { required property var modelData property var revision: modelData - property bool isCurrent: modelData.version === 1 + property bool isCurrent: String(modelData.version) === "1" Row { id: row @@ -107,17 +108,19 @@ Column { font.bold: isCurrent elide: Text.ElideRight width: parent.width + textFormat: Text.PlainText } Text { id: descLabel - text: revision.desc || "" + text: Models.boundedDisplayText(revision.desc || "", 1024) color: Qt.darker(root.bar.foreground, 1.4) font.family: root.bar.fontFamily font.pixelSize: Style.font.caption elide: Text.ElideRight width: parent.width visible: revision.desc && revision.desc !== "" + textFormat: Text.PlainText } Text { @@ -127,6 +130,7 @@ Column { font.family: root.bar.fontFamily font.pixelSize: Style.font.caption visible: revision.revFileSize + textFormat: Text.PlainText } } diff --git a/components/LoadingIndicator.qml b/components/LoadingIndicator.qml index 24b3ecd..28f3b1a 100644 --- a/components/LoadingIndicator.qml +++ b/components/LoadingIndicator.qml @@ -53,6 +53,7 @@ Item { color: root.bar.foreground font.family: root.bar.fontFamily font.pixelSize: Style.font.body + textFormat: Text.PlainText } } } diff --git a/components/LoginDialog.qml b/components/LoginDialog.qml index c64c740..1601f50 100644 --- a/components/LoginDialog.qml +++ b/components/LoginDialog.qml @@ -59,7 +59,8 @@ Item { font.pixelSize: Style.font.caption visible: root.depErrorMessage !== "" wrapMode: Text.WordWrap - text: root.depErrorMessage + text: Models.boundedDisplayText(root.depErrorMessage, 4096) + textFormat: Text.PlainText } TextField { @@ -112,6 +113,9 @@ Item { font.pixelSize: Style.font.caption visible: text !== "" wrapMode: Text.WordWrap + textFormat: Text.PlainText + text: Models.boundedDisplayText(errorText._raw, 4096) + property string _raw: "" } Button { diff --git a/components/OfflineBanner.qml b/components/OfflineBanner.qml index cdd927b..26ea04e 100644 --- a/components/OfflineBanner.qml +++ b/components/OfflineBanner.qml @@ -1,6 +1,7 @@ import QtQuick import qs.Commons import qs.Ui +import "../js" Item { id: root @@ -19,7 +20,7 @@ Item { Text { id: text - text: root.message + text: Models.boundedDisplayText(root.message, 4096) color: Color.background font.family: root.bar ? root.bar.fontFamily : Style.font.family font.pixelSize: Style.font.body @@ -27,6 +28,7 @@ Item { anchors.centerIn: parent wrapMode: Text.WordWrap width: parent.width - Style.space(24) + textFormat: Text.PlainText } } } \ No newline at end of file diff --git a/components/ProgressBar.qml b/components/ProgressBar.qml index 31bf92d..af6adb3 100644 --- a/components/ProgressBar.qml +++ b/components/ProgressBar.qml @@ -7,7 +7,7 @@ Item { property int from: 0 property int to: 1 property real value: 0 - property color foreground: root.bar ? root.bar.foreground : Color.foreground + property color foreground: Color.foreground property color background: Util.alpha(root.foreground, 0.15) property int radius: Style.space(3) @@ -30,4 +30,4 @@ Item { NumberAnimation { duration: 150; easing.type: Easing.OutCubic } } } -} \ No newline at end of file +} diff --git a/components/RenameDialog.qml b/components/RenameDialog.qml index b90d326..429704a 100644 --- a/components/RenameDialog.qml +++ b/components/RenameDialog.qml @@ -1,6 +1,7 @@ import QtQuick import qs.Commons import qs.Ui +import "../js" Item { id: root @@ -30,11 +31,12 @@ Item { width: Math.min(parent.width, Style.space(400)) Text { - text: root.title + text: Models.boundedDisplayText(root.title, 1024) color: root.bar.foreground font.family: root.bar.fontFamily font.pixelSize: Style.font.display font.bold: true + textFormat: Text.PlainText } TextField { @@ -58,6 +60,9 @@ Item { font.pixelSize: Style.font.caption visible: text !== "" wrapMode: Text.WordWrap + textFormat: Text.PlainText + text: Models.boundedDisplayText(errorText._raw, 4096) + property string _raw: "" } Row { diff --git a/components/SearchResults.qml b/components/SearchResults.qml index 9adc091..6c443d1 100644 --- a/components/SearchResults.qml +++ b/components/SearchResults.qml @@ -37,7 +37,7 @@ ListView { Text { id: icon text: delegate.isDir ? "\uf07b" : "\uf15b" - color: delegate.bar.foreground + color: root.bar.foreground font.family: "Noto Sans" font.pixelSize: Style.font.title width: Style.space(24) @@ -52,35 +52,38 @@ ListView { Text { id: nameLabel - text: delegate.modelData.name - color: delegate.bar.foreground - font.family: delegate.bar.fontFamily + text: Models.boundedDisplayText(delegate.modelData.name, 1024) + color: root.bar.foreground + font.family: root.bar.fontFamily font.pixelSize: Style.font.body elide: Text.ElideRight width: parent.width + textFormat: Text.PlainText } Text { id: pathLabel - text: delegate.repoName + " \u2022 " + delegate.modelData.parentPath - color: Qt.darker(delegate.bar.foreground, 1.4) - font.family: delegate.bar.fontFamily + text: Models.boundedDisplayText(delegate.repoName + " \u2022 " + delegate.modelData.parentPath, 4096) + color: Qt.darker(root.bar.foreground, 1.4) + font.family: root.bar.fontFamily font.pixelSize: Style.font.caption elide: Text.ElideRight width: parent.width visible: text !== " \u2022 " + textFormat: Text.PlainText } } Text { id: sizeLabel text: delegate.isDir ? "" : Models.formatSize(delegate.modelData.size) - color: Qt.darker(delegate.bar.foreground, 1.4) - font.family: delegate.bar.fontFamily + color: Qt.darker(root.bar.foreground, 1.4) + font.family: root.bar.fontFamily font.pixelSize: Style.font.caption width: Style.space(80) horizontalAlignment: Text.AlignRight anchors.verticalCenter: parent.verticalCenter + textFormat: Text.PlainText } } @@ -114,4 +117,4 @@ ListView { ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded } -} \ No newline at end of file +} diff --git a/components/SettingsDialog.qml b/components/SettingsDialog.qml index 4432d9a..bea64f5 100644 --- a/components/SettingsDialog.qml +++ b/components/SettingsDialog.qml @@ -117,12 +117,13 @@ Item { Text { id: connectionTestResult width: parent.width - text: root.connectionTestMessage + text: Models.boundedDisplayText(root.connectionTestMessage, 4096) color: root.connectionTestSuccess ? Style.green : (root.connectionTestRunning ? Qt.darker(root.bar.foreground, 1.3) : Color.urgent) font.family: root.bar.fontFamily font.pixelSize: Style.font.caption wrapMode: Text.WordWrap visible: text !== "" + textFormat: Text.PlainText } } @@ -162,12 +163,13 @@ Item { width: Style.space(24) } Text { - text: root.accountEmail || Auth.cachedEmail || "Not signed in" + text: Models.boundedDisplayText(root.accountEmail || Auth.cachedEmail || "Not signed in", 320) color: root.bar.foreground font.family: root.bar.fontFamily font.pixelSize: Style.font.body elide: Text.ElideRight anchors.verticalCenter: parent.verticalCenter + textFormat: Text.PlainText } } } @@ -281,10 +283,11 @@ Item { Text { width: parent.width wrapMode: Text.WordWrap - text: "Omarseafile v" + root.pluginVersion + "\nSeafile client for Omarchy\n\nReport issues: https://github.com/Roddygithub/Omarseafile/issues" + text: Models.boundedDisplayText("Omarseafile v" + root.pluginVersion + "\nSeafile client for Omarchy\n\nReport issues: https://github.com/Roddygithub/Omarseafile/issues", 1024) color: Qt.darker(root.bar.foreground, 1.4) font.family: root.bar.fontFamily font.pixelSize: Style.font.caption + textFormat: Text.PlainText } } diff --git a/components/ShareDialog.qml b/components/ShareDialog.qml index 3023894..6146928 100644 --- a/components/ShareDialog.qml +++ b/components/ShareDialog.qml @@ -23,6 +23,7 @@ Item { property string errorMessage: "" property string shareUrl: "" property string shareToken: "" + property int requestGeneration: 0 // Create form state property bool showCreateForm: false @@ -50,10 +51,14 @@ Item { loadExistingLinks() } + Component.onDestruction: requestGeneration++ + function loadExistingLinks() { + var generation = ++root.requestGeneration root.loading = true root.errorMessage = "" SeafileAPI.listShareLinks(root.repoId, root.itemPath, function(success, data, error) { + if (generation !== root.requestGeneration) return root.loading = false if (success) { root.existingLinks = Array.isArray(data) ? data : [] @@ -72,6 +77,7 @@ Item { return } root.loading = true + var generation = ++root.requestGeneration root.errorMessage = "" var options = {} if (root.enablePassword && root.passwordValue) { @@ -88,6 +94,7 @@ Item { } } SeafileAPI.createShareLink(root.repoId, root.itemPath, options, function(success, data, error) { + if (generation !== root.requestGeneration) return root.loading = false if (success) { root.shareUrl = data.link @@ -104,8 +111,10 @@ Item { function deleteLink(token) { root.loading = true + var generation = ++root.requestGeneration root.errorMessage = "" SeafileAPI.deleteShareLink(token, function(success, error) { + if (generation !== root.requestGeneration) return root.loading = false if (success) { root.existingLinks = root.existingLinks.filter(function(l) { @@ -196,12 +205,13 @@ Item { } Text { - text: root.isDir ? "Folder: " + root.item.name : "File: " + root.item.name + text: root.isDir ? "Folder: " + Models.boundedDisplayText(root.item.name, 1024) : "File: " + Models.boundedDisplayText(root.item.name, 1024) color: Qt.darker(root.bar.foreground, 1.4) font.family: root.bar.fontFamily font.pixelSize: Style.font.body elide: Text.ElideRight width: parent.width + textFormat: Text.PlainText } // Loading indicator @@ -215,13 +225,14 @@ Item { // Error message Text { - text: root.errorMessage + text: Models.boundedDisplayText(root.errorMessage, 4096) color: Color.urgent font.family: root.bar.fontFamily font.pixelSize: Style.font.body visible: root.errorMessage !== "" wrapMode: Text.WordWrap width: parent.width + textFormat: Text.PlainText } // Existing links list @@ -250,13 +261,14 @@ Item { spacing: Style.space(8) Text { - text: modelData.link + text: Models.boundedDisplayText(modelData.link, 8192) color: root.bar.foreground font.family: root.bar.fontFamily font.pixelSize: Style.font.caption elide: Text.ElideRight width: parent.width - copyBtn.width - deleteBtn.width - Style.space(16) anchors.verticalCenter: parent.verticalCenter + textFormat: Text.PlainText MouseArea { anchors.fill: parent @@ -286,7 +298,7 @@ Item { } Text { - text: { + text: Models.boundedDisplayText((function() { var info = [] if (modelData.expire_date) { info.push("Expires: " + modelData.expire_date.split("T")[0]) @@ -302,11 +314,12 @@ Item { if (perms.length > 0) info.push(perms.join(", ")) } return info.join(" | ") - } + })(), 1024) color: Qt.darker(root.bar.foreground, 1.4) font.family: root.bar.fontFamily font.pixelSize: Style.font.caption visible: text !== "" + textFormat: Text.PlainText } } } @@ -523,13 +536,14 @@ Item { spacing: Style.space(8) Text { - text: root.shareUrl + text: Models.boundedDisplayText(root.shareUrl, 8192) color: root.bar.foreground font.family: root.bar.fontFamily font.pixelSize: Style.font.caption elide: Text.ElideRight width: parent.width - copyCreatedBtn.width - Style.space(8) anchors.verticalCenter: parent.verticalCenter + textFormat: Text.PlainText MouseArea { anchors.fill: parent diff --git a/components/Toast.qml b/components/Toast.qml index 01fe4a6..fa7edc0 100644 --- a/components/Toast.qml +++ b/components/Toast.qml @@ -1,6 +1,7 @@ import QtQuick import qs.Commons import qs.Ui +import "../js" Item { id: root @@ -41,7 +42,7 @@ Item { Text { id: text - text: root.message + text: Models.boundedDisplayText(root.message, 4096) color: Color.background font.family: root.bar ? root.bar.fontFamily : Style.font.family font.pixelSize: Style.font.body @@ -49,6 +50,7 @@ Item { anchors.centerIn: parent wrapMode: Text.WordWrap width: parent.width - Style.space(24) + textFormat: Text.PlainText } } diff --git a/components/ToolBar.qml b/components/ToolBar.qml index 50e7c11..ba1fb17 100644 --- a/components/ToolBar.qml +++ b/components/ToolBar.qml @@ -1,6 +1,7 @@ import QtQuick import qs.Commons import qs.Ui +import "../js" Item { id: root @@ -81,7 +82,7 @@ Item { Text { id: titleLabel - text: root.title + text: Models.boundedDisplayText(root.title, 1024) color: root.bar.foreground font.family: root.bar.fontFamily font.pixelSize: Style.font.title @@ -90,6 +91,7 @@ Item { width: Math.max(Style.space(24), row.width - row._fixedButtons - Style.space(8) * Math.max(0, row._visibleCount - 1)) anchors.verticalCenter: parent.verticalCenter visible: !root.searchActive && root.selectionCount === 0 + textFormat: Text.PlainText } BatchActionBar { @@ -227,6 +229,7 @@ Item { anchors.topMargin: Style.space(2) anchors.rightMargin: Style.space(2) z: 1 + textFormat: Text.PlainText } Rectangle { diff --git a/components/TransferItem.qml b/components/TransferItem.qml index bba1019..b72b01d 100644 --- a/components/TransferItem.qml +++ b/components/TransferItem.qml @@ -17,7 +17,8 @@ Item { implicitHeight: row.implicitHeight + Style.space(8) width: parent.width - property bool isActive: transfer.state === "pending" || transfer.state === "downloading" || transfer.state === "uploading" + property bool isCancelling: transfer.state === "cancelling" + property bool isActive: transfer.state === "pending" || transfer.state === "downloading" || transfer.state === "uploading" || transfer.state === "opening" || isCancelling property bool isCompleted: transfer.state === "completed" property bool isFailed: transfer.state === "failed" || transfer.state === "cancelled" || transfer.state === "auth_failed" @@ -49,18 +50,21 @@ Item { Text { id: nameLabel - text: root.transfer.fileName || "Unknown" + text: Models.boundedDisplayText(root.transfer.fileName || "Unknown", 1024) color: root.bar.foreground font.family: root.bar.fontFamily font.pixelSize: Style.font.body elide: Text.ElideRight width: parent.width + textFormat: Text.PlainText } Text { id: detailLabel - text: { + text: Models.boundedDisplayText((function() { if (root.isActive) { + if (root.isCancelling) return "Cancelling..." + if (root.transfer.state === "opening") return "Opening..." var parts = [] if (root.transfer.progress > 0) parts.push(Math.round(root.transfer.progress * 100) + "%") if (root.transfer.speed) parts.push(root.transfer.speed) @@ -71,13 +75,14 @@ Item { return root.transfer.error || "Failed" } return "" - } + })(), 4096) color: Qt.darker(root.bar.foreground, 1.4) font.family: root.bar.fontFamily font.pixelSize: Style.font.caption elide: Text.ElideRight width: parent.width visible: text !== "" + textFormat: Text.PlainText } } @@ -156,7 +161,7 @@ Item { ToolTip.text: "Cancel transfer" horizontalAlignment: Text.AlignHCenter anchors.horizontalCenter: parent.horizontalCenter - visible: root.isActive + visible: root.isActive && !root.isCancelling MouseArea { anchors.fill: parent cursorShape: Qt.PointingHandCursor diff --git a/components/TransferManager.qml b/components/TransferManager.qml index 76f86e1..702acfb 100644 --- a/components/TransferManager.qml +++ b/components/TransferManager.qml @@ -63,6 +63,7 @@ Column { font.pixelSize: Style.font.caption font.bold: true anchors.verticalCenter: parent.verticalCenter + textFormat: Text.PlainText } } @@ -89,15 +90,17 @@ Column { height: Style.space(28) Text { + id: completedLabel text: "Completed (" + root.completedCount + ")" color: root.bar.foreground font.family: root.bar.fontFamily font.pixelSize: Style.font.caption font.bold: true anchors.verticalCenter: parent.verticalCenter + textFormat: Text.PlainText } - Item { width: parent.width - clearCompletedBtn.width - Style.space(20); height: 1 } + Item { width: parent.width - completedLabel.width - clearCompletedBtn.width - Style.space(8); height: 1 } Text { id: clearCompletedBtn @@ -139,15 +142,17 @@ Column { height: Style.space(28) Text { + id: failedLabel text: "Failed (" + root.failedCount + ")" color: root.bar.foreground font.family: root.bar.fontFamily font.pixelSize: Style.font.caption font.bold: true anchors.verticalCenter: parent.verticalCenter + textFormat: Text.PlainText } - Item { width: parent.width - clearFailedBtn.width - Style.space(20); height: 1 } + Item { width: parent.width - failedLabel.width - clearFailedBtn.width - Style.space(8); height: 1 } Text { id: clearFailedBtn diff --git a/components/TrashPanel.qml b/components/TrashPanel.qml index 494444c..62f7302 100644 --- a/components/TrashPanel.qml +++ b/components/TrashPanel.qml @@ -56,7 +56,7 @@ Column { ListView { id: trashList width: parent.width - height: parent.height - Style.space(40) - Style.space(40) + height: root.trashData.length === 0 ? Style.space(160) : Math.min(contentHeight, Style.space(360)) clip: true spacing: Style.space(4) model: root.trashData @@ -95,20 +95,21 @@ Column { Text { id: nameLabel - text: trashItem.objName + text: Models.boundedDisplayText(trashItem.objName, 1024) color: root.bar.foreground font.family: root.bar.fontFamily font.pixelSize: Style.font.body elide: Text.ElideRight width: parent.width + textFormat: Text.PlainText } Text { id: detailLabel - text: { + text: Models.boundedDisplayText((function() { var parts = [] if (trashItem.deletedTime) { - var date = new Date(trashItem.deletedTime * 1000) + var date = new Date(trashItem.deletedTime) parts.push(date.toLocaleDateString() + " " + date.toLocaleTimeString()) } if (!isDir && trashItem.size) { @@ -116,13 +117,14 @@ Column { } parts.push(isDir ? "Folder" : "File") return parts.join(" \u2022 ") - } + })(), 1024) color: Qt.darker(root.bar.foreground, 1.4) font.family: root.bar.fontFamily font.pixelSize: Style.font.caption elide: Text.ElideRight width: parent.width visible: text !== "" + textFormat: Text.PlainText } } diff --git a/components/UploadDialog.qml b/components/UploadDialog.qml index 817b3d7..b6915e6 100644 --- a/components/UploadDialog.qml +++ b/components/UploadDialog.qml @@ -1,6 +1,7 @@ import QtQuick import qs.Commons import qs.Ui +import "../js" Item { id: root @@ -66,6 +67,9 @@ Item { font.pixelSize: Style.font.caption visible: text !== "" wrapMode: Text.WordWrap + textFormat: Text.PlainText + text: Models.boundedDisplayText(errorText._raw, 4096) + property string _raw: "" } Row { diff --git a/deploy.sh b/deploy.sh index 9061519..ba73196 100755 --- a/deploy.sh +++ b/deploy.sh @@ -28,12 +28,15 @@ echo "Target: $PLUGIN_DIR" if $DRY_RUN; then echo "Mode: DRY RUN (no changes)" echo "" - CHANGES="$(rsync -ainc --delete \ + CHANGES="$(rsync -ainc --delete --omit-dir-times \ --exclude='.git/' \ + --exclude='.agents/' \ + --exclude='.codex/' \ --exclude='docs/' \ --exclude='README.md' \ --exclude='deploy.sh' \ --exclude='.gitignore' \ + --exclude='__pycache__/' \ "$REPO_DIR/" "$PLUGIN_DIR/")" if [[ -n "$CHANGES" ]]; then printf '%s\n' "$CHANGES" @@ -47,10 +50,13 @@ else mkdir -p "$PLUGIN_DIR" rsync -av --delete \ --exclude='.git/' \ + --exclude='.agents/' \ + --exclude='.codex/' \ --exclude='docs/' \ --exclude='README.md' \ --exclude='deploy.sh' \ --exclude='.gitignore' \ + --exclude='__pycache__/' \ "$REPO_DIR/" "$PLUGIN_DIR/" echo "" echo "Deploy complete." diff --git a/js/Auth.qml b/js/Auth.qml index aea683c..58cba5e 100644 --- a/js/Auth.qml +++ b/js/Auth.qml @@ -13,6 +13,8 @@ QtObject { readonly property string keyServer: "server-url" readonly property string keyEmail: "user-email" property var _sessionMutationTail: null + readonly property string _wrapperPath: Qt.resolvedUrl("../scripts/secret_tool_wrapper.py").toString().replace(/^file:\/\//, "") + readonly property int _maxSecretBytes: 4096 function _queueSessionMutation(mutation) { var previous = root._sessionMutationTail || Promise.resolve() @@ -21,7 +23,7 @@ QtObject { return result } - // Factory: one short-lived Process per secret-tool invocation. + // Factory: one short-lived Process per secret-tool invocation with timeout. property Component procFactory: Component { Process { id: proc @@ -50,17 +52,33 @@ QtObject { // Run one command, resolve(stdoutText) on success, reject(Error) on failure. // `lookupIsSoft`: exit code 1 means "not found" and resolves with "". + // secret-tool commands are routed through secret_tool_wrapper.py for + // process-group isolation and producer-side byte ceilings. function _run(cmd, input, lookupIsSoft) { return new Promise(function(resolve, reject) { + var wrappedCmd = cmd + if (cmd.length > 0 && cmd[0] === "secret-tool") { + wrappedCmd = ["python3", root._wrapperPath, + root._maxSecretBytes, root._maxSecretBytes, + "--"].concat(cmd) + } + var timer = Qt.createQmlObject('import QtQuick; Timer { property var targetProcess: null; interval: 30000; repeat: false; onTriggered: { if (targetProcess) targetProcess.running = false } }', root) var proc = root.procFactory.createObject(root, { inputPayload: (input !== undefined && input !== null) ? input : "", onDone: function(exitCode, text) { + if (timer) { + timer.stop() + timer.destroy() + timer = null + } if (exitCode === 0) { resolve(text); return } if (lookupIsSoft && exitCode === 1) { resolve(""); return } reject(new Error(cmd.join(" ") + " failed (exit " + exitCode + ")")) } }) - proc.command = cmd + timer.targetProcess = proc + timer.start() + proc.command = wrappedCmd proc.running = true }) } diff --git a/js/HttpTransport.qml b/js/HttpTransport.qml new file mode 100644 index 0000000..3a98f5f --- /dev/null +++ b/js/HttpTransport.qml @@ -0,0 +1,235 @@ +pragma Singleton +import QtQuick +import Quickshell +import Quickshell.Io + +QtObject { + id: root + + property int connectTimeoutMs: 10000 + property int totalTimeoutMs: 30000 + property int maxCollectionItems: 1000 + property int maxStringLength: 10000 + property int maxResponseBytes: 10 * 1024 * 1024 + property int maxStderrBytes: 65536 + property int maxValidationDepth: 32 + readonly property string _transferOutputHelper: Qt.resolvedUrl("../scripts/transfer_output.py").toString().replace(/^file:\/\//, "") + + property Component _requestFactory: Component { + Process { + property var onDone: null + property var headerFilePath: "" + property var bodyFilePath: "" + stdout: StdioCollector {} + stderr: StdioCollector {} + onExited: function(exitCode, exitStatus) { + var cb = onDone + var out = stdout.text + var err = stderr.text + destroy() + if (cb) cb(exitCode, out, err) + } + } + } + + property Component _cleanupProcessFactory: Component { + Process { + onExited: destroy() + } + } + + function request(method, url, headers, body, callback) { + var finished = false + function finish(success, data, error) { + if (finished) return + finished = true + callback(success, data, error) + } + var config = { + method: method, + url: url, + headers: headers || ({}), + body: body, + timeoutMs: root.totalTimeoutMs + } + + var authHeader = config.headers ? config.headers["Authorization"] : null + var hasBody = config.body !== undefined && config.body !== null && config.body !== "" + + SafePath.getRuntimeSubdir("http", function(httpResult) { + if (!httpResult.valid) { finish(false, null, "Runtime dir unavailable: " + httpResult.error); return } + + var curlArgs = [ + "curl", "-q", "-f", "-s", "-S", + "--connect-timeout", Math.ceil(root.connectTimeoutMs / 1000).toString(), + "--max-time", Math.ceil(root.totalTimeoutMs / 1000).toString(), + "--speed-limit", "1", + "--speed-time", "30", + "--no-location", + "--max-filesize", root.maxResponseBytes.toString() + ] + + for (var h in config.headers) { + if (h.toLowerCase() !== "authorization") { + curlArgs.push("-H", h + ": " + config.headers[h]) + } + } + + if (authHeader) { + var configContent = "header = \"Authorization: " + authHeader.replace(/"/g, "\\\"") + "\"\n" + SafePath.createSecureFile("http", "curl_hdr", configContent, function(hdrResult) { + if (!hdrResult.valid) { finish(false, null, "Header file failed: " + hdrResult.error); return } + runRequest(hdrResult.path) + }) + } else { + runRequest("") + } + + function runRequest(headerFile) { + if (hasBody) { + SafePath.createSecureFile("http", "curl_body", config.body, function(bodyResult) { + if (!bodyResult.valid) { + cleanup(headerFile) + finish(false, null, "Body file failed: " + bodyResult.error); return + } + execute(headerFile, bodyResult.path, curlArgs.slice()) + }) + } else { + execute(headerFile, "", curlArgs.slice()) + } + } + + function execute(hdrFile, bodyFile, args) { + if (hdrFile) { + args.push("--config", hdrFile) + } + if (bodyFile) { + args.push("--data-binary", "@" + bodyFile) + } + args = ["setsid", "python3", root._transferOutputHelper, + root.maxStderrBytes.toString(), "--"].concat(args) + args.push("-X", config.method) + args.push(config.url) + + var proc = _requestFactory.createObject(root, { + onDone: function(exitCode, out, err) { + cleanup(hdrFile) + cleanup(bodyFile) + if (exitCode === 0) { + try { + var data = out ? JSON.parse(out) : null + var validation = validateResponse(data) + if (!validation.valid) { finish(false, null, validation.error); return } + finish(true, validation.data, null) + } catch (e) { + finish(false, null, "Invalid JSON response") + } + } else if (exitCode === 63 || exitCode === 23) { + // 63: max-filesize exceeded (curl 7.56.0+); 23: write error (older curl) + finish(false, null, "Response too large (exceeds " + root.maxResponseBytes + " bytes)") + } else { + finish(false, null, "Request failed (exit " + exitCode + "): " + (err || "unknown")) + } + } + }) + if (!proc) { + cleanup(hdrFile) + cleanup(bodyFile) + finish(false, null, "Failed to create request process") + return + } + proc.command = args + proc.running = true + } + + function cleanup(path) { + if (!path) return + var proc = root._cleanupProcessFactory.createObject(root) + if (!proc) return + proc.command = ["rm", "-f", "--", path] + proc.running = true + } + }) + } + + function get(url, headers, callback) { root.request("GET", url, headers, null, callback) } + function post(url, headers, body, callback) { root.request("POST", url, headers, body, callback) } + function put(url, headers, body, callback) { root.request("PUT", url, headers, body, callback) } + function del(url, headers, callback) { root.request("DELETE", url, headers, null, callback) } + + function validateCollection(arr, maxItems) { + if (!Array.isArray(arr)) return { valid: false, error: "Not an array" } + var limit = maxItems || root.maxCollectionItems + if (arr.length > limit) return { valid: false, error: "Collection exceeds max items (" + limit + ")" } + return { valid: true } + } + + function validateString(str, maxLen) { + if (typeof str !== "string") return { valid: false, error: "Not a string" } + var limit = maxLen || root.maxStringLength + if (str.length > limit) return { valid: false, error: "String exceeds max length" } + return { valid: true } + } + + function sanitizeCollection(arr, itemValidator, maxItems) { + var limit = maxItems || root.maxCollectionItems + var out = [] + for (var i = 0; i < Math.min(arr.length, limit); i++) { + if (itemValidator) { + var v = itemValidator(arr[i]) + if (v.valid) out.push(v.value || arr[i]) + } else { + out.push(arr[i]) + } + } + return out + } + + function validateResponse(data) { + return validateValue(data, 0) + } + + function validateValue(data, depth) { + if (depth > root.maxValidationDepth) return { valid: false, error: "Response nesting exceeds maximum depth" } + if (data === null || data === undefined) { + return { valid: true, data: null } + } + if (Array.isArray(data)) { + var collValidation = validateCollection(data) + if (!collValidation.valid) return { valid: false, error: collValidation.error } + for (var i = 0; i < data.length; i++) { + var itemValidation = validateValue(data[i], depth + 1) + if (!itemValidation.valid) return { valid: false, error: "Item " + i + ": " + itemValidation.error } + } + return { valid: true, data: data } + } + if (typeof data === "object") { + var objValidation = validateObject(data, depth + 1) + if (!objValidation.valid) return { valid: false, error: objValidation.error } + return { valid: true, data: data } + } + return { valid: true, data: data } + } + + function validateObject(obj, depth) { + if (depth > root.maxValidationDepth) return { valid: false, error: "Response nesting exceeds maximum depth" } + for (var key in obj) { + var val = obj[key] + if (typeof val === "string") { + var strValidation = validateString(val) + if (!strValidation.valid) return { valid: false, error: "Field '" + key + "': " + strValidation.error } + } else if (Array.isArray(val)) { + var collValidation = validateCollection(val) + if (!collValidation.valid) return { valid: false, error: "Field '" + key + "': " + collValidation.error } + for (var i = 0; i < val.length; i++) { + var nestedValidation = validateValue(val[i], depth + 1) + if (!nestedValidation.valid) return { valid: false, error: "Field '" + key + "[" + i + "]': " + nestedValidation.error } + } + } else if (typeof val === "object" && val !== null) { + var nestedValidation = validateObject(val, depth + 1) + if (!nestedValidation.valid) return { valid: false, error: "Field '" + key + "': " + nestedValidation.error } + } + } + return { valid: true } + } +} diff --git a/js/Models.qml b/js/Models.qml index 9dce91a..af6db48 100644 --- a/js/Models.qml +++ b/js/Models.qml @@ -72,4 +72,15 @@ QtObject { } return root.toFileUrl(parentPath) } + + // Display-text bounding: convert to string, enforce a character ceiling, + // append "…" on truncation. null/undefined → "". Never interprets HTML; + // pair with textFormat: Text.PlainText at the sink. + function boundedDisplayText(value, maxChars) { + if (value === null || value === undefined) return "" + if (maxChars <= 0) return "" + var s = String(value) + if (s.length <= maxChars) return s + return s.substring(0, maxChars - 1) + "\u2026" + } } \ No newline at end of file diff --git a/js/SafePath.qml b/js/SafePath.qml new file mode 100644 index 0000000..7737e04 --- /dev/null +++ b/js/SafePath.qml @@ -0,0 +1,430 @@ +pragma Singleton +import QtQuick +import Quickshell +import Quickshell.Io + +QtObject { + id: root + + readonly property int maxBasenameLength: 255 + readonly property int maxCacheBytes: 1073741824 // 1 GiB (fits in int32) + property var _protectedCacheNames: [] + + property Component _mkdirFactory: Component { + Process { + property var onDone: null + onExited: function(exitCode) { + var cb = onDone + destroy() + if (cb) cb(exitCode === 0) + } + } + } + + property Component _realpathFactory: Component { + Process { + property var onDone: null + stdout: StdioCollector {} + onExited: function(exitCode) { + var cb = onDone + var out = stdout.text.trim() + destroy() + if (cb) cb(exitCode === 0 ? out : null) + } + } + } + + property Component _statFactory: Component { + Process { + property var onDone: null + stdout: StdioCollector {} + onExited: function(exitCode) { + var cb = onDone + var out = stdout.text.trim() + destroy() + if (cb) cb(exitCode === 0 ? out : null) + } + } + } + + function sanitizeBasename(name) { + if (!name || typeof name !== "string") { + return { valid: false, error: "Empty filename" } + } + var trimmed = name.trim() + if (trimmed === "") { + return { valid: false, error: "Filename is whitespace only" } + } + if (trimmed === "." || trimmed === "..") { + return { valid: false, error: "Reserved filename: " + trimmed } + } + if (trimmed.indexOf("/") !== -1 || trimmed.indexOf("\\") !== -1) { + return { valid: false, error: "Path separators not allowed in filename" } + } + if (trimmed.indexOf("\0") !== -1) { + return { valid: false, error: "NUL character not allowed" } + } + for (var i = 0; i < trimmed.length; i++) { + var code = trimmed.charCodeAt(i) + if (code < 0x20 || code === 0x7F) { + return { valid: false, error: "Control characters not allowed" } + } + } + if (trimmed.length > 255) { + return { valid: false, error: "Filename exceeds maximum length of 255" } + } + return { valid: true, sanitized: trimmed } + } + + function secureJoin(baseDir, name, callback) { + validateDirectory(baseDir, function(baseResult) { + if (!baseResult.valid) { callback(baseResult); return } + var nameResult = sanitizeBasename(name) + if (!nameResult.valid) { callback(nameResult); return } + callback({ valid: true, path: baseResult.resolved + "/" + nameResult.sanitized, base: baseResult.resolved, name: nameResult.sanitized }) + }) + } + + function validateDirectory(dir, callback) { + if (!dir || typeof dir !== "string") { + callback({ valid: false, error: "Empty directory" }) + return + } + var expanded = dir + if (dir.startsWith("~")) { + var home = Quickshell.env("HOME") + if (home) expanded = home + dir.substring(1) + } + var proc = _realpathFactory.createObject(root, { + onDone: function(path) { + if (!path) { callback({ valid: false, error: "Cannot resolve directory" }); return } + callback({ valid: true, resolved: path }) + } + }) + proc.command = ["realpath", "-m", "--", expanded] + proc.running = true + } + + function getRuntimeSubdir(subdir, callback) { + if (!subdir || !/^[A-Za-z0-9_-]{1,64}$/.test(subdir)) { + callback({ valid: false, error: "Invalid runtime subdirectory" }) + return + } + var runtimeDir = Quickshell.env("XDG_RUNTIME_DIR") + if (!runtimeDir) { + callback({ valid: false, error: "XDG_RUNTIME_DIR not set" }) + return + } + var uidProc = _statFactory.createObject(root, { + onDone: function(expectedUidOutput) { + if (!expectedUidOutput || !/^\d+$/.test(expectedUidOutput)) { + callback({ valid: false, error: "Cannot determine current user UID" }) + return + } + var expectedUid = parseInt(expectedUidOutput, 10) + var proc = _statFactory.createObject(root, { + onDone: function(out) { + var parts = out ? out.split(" ") : [] + if (parts.length !== 2 || !/^\d+$/.test(parts[0]) || !/^[0-7]+$/.test(parts[1])) { + callback({ valid: false, error: "Cannot stat XDG_RUNTIME_DIR" }) + return + } + var uid = parseInt(parts[0], 10) + var perm = parseInt(parts[1], 8) + if (uid !== expectedUid) { + callback({ valid: false, error: "XDG_RUNTIME_DIR not owned by current user" }) + return + } + if (perm & 0o022) { + callback({ valid: false, error: "XDG_RUNTIME_DIR has unsafe permissions" }) + return + } + var dir = runtimeDir + "/omarseafile/" + subdir + var mk = _mkdirFactory.createObject(root, { + onDone: function(ok) { + if (!ok) { callback({ valid: false, error: "Cannot create runtime subdir" }); return } + var verify = _statFactory.createObject(root, { + onDone: function(out2) { + var parts2 = out2 ? out2.split(" ") : [] + if (parts2.length !== 2 || !/^\d+$/.test(parts2[0]) || !/^[0-7]+$/.test(parts2[1])) { + callback({ valid: false, error: "Cannot verify runtime subdir" }) + return + } + var uid2 = parseInt(parts2[0], 10) + var perm2 = parseInt(parts2[1], 8) + if (uid2 !== expectedUid || perm2 !== 0o700) { + callback({ valid: false, error: "Runtime subdir has incorrect ownership or permissions" }) + return + } + callback({ valid: true, path: dir }) + } + }) + verify.command = ["stat", "-c", "%u %a", dir] + verify.running = true + } + }) + mk.command = ["mkdir", "-p", "-m", "0700", "--", dir] + mk.running = true + } + }) + proc.command = ["stat", "-c", "%u %a", runtimeDir] + proc.running = true + } + }) + uidProc.command = ["id", "-u"] + uidProc.running = true + } + + function getCacheDir(callback) { + var cacheRoot = Quickshell.env("XDG_CACHE_HOME") + if (!cacheRoot) { + var home = Quickshell.env("HOME") + if (!home) { + callback({ valid: false, error: "No cache directory available" }) + return + } + cacheRoot = home + "/.cache" + } + if (!cacheRoot.startsWith("/")) { + callback({ valid: false, error: "XDG_CACHE_HOME must be absolute" }) + return + } + // Create the configured root on first use, then canonicalize it before + // checking ownership and permissions. Existing symlinks resolve before + // validation and cannot become Omarseafile's private directory. + var ensure = _mkdirFactory.createObject(root, { + onDone: function(ok) { + if (!ok) { callback({ valid: false, error: "Cannot create cache root" }); return } + var checkRoot = _statFactory.createObject(root, { + onDone: function(kind) { + if (kind !== "directory") { callback({ valid: false, error: "Cache root must be a directory" }); return } + var canonicalize = _realpathFactory.createObject(root, { + onDone: function(path) { + if (!path) { callback({ valid: false, error: "Cannot resolve cache directory" }); return } + root._getCacheDirAt(path, callback) + } + }) + canonicalize.command = ["realpath", "-e", "--", cacheRoot] + canonicalize.running = true + } + }) + checkRoot.command = ["stat", "-c", "%F", "--", cacheRoot] + checkRoot.running = true + } + }) + ensure.command = ["mkdir", "-p", "-m", "0700", "--", cacheRoot] + ensure.running = true + } + + function getDownloadsDir(callback) { + var home = Quickshell.env("HOME") + var proc = _realpathFactory.createObject(root, { + onDone: function(path) { + callback(path && path.startsWith("/") ? path : (home ? home + "/Downloads" : null)) + } + }) + proc.command = ["xdg-user-dir", "DOWNLOAD"] + proc.running = true + } + + function _getCacheDirAt(cacheRoot, callback) { + var uidProc = _statFactory.createObject(root, { + onDone: function(expectedUidOutput) { + if (!expectedUidOutput || !/^\d+$/.test(expectedUidOutput)) { + callback({ valid: false, error: "Cannot determine current user UID" }) + return + } + var expectedUid = parseInt(expectedUidOutput, 10) + var proc = _statFactory.createObject(root, { + onDone: function(out) { + var parts = out ? out.split(" ") : [] + if (parts.length !== 2 || !/^\d+$/.test(parts[0]) || !/^[0-7]+$/.test(parts[1])) { + callback({ valid: false, error: "Cannot stat cache directory" }) + return + } + var uid = parseInt(parts[0], 10) + var perm = parseInt(parts[1], 8) + if (uid !== expectedUid || perm & 0o022) { + callback({ valid: false, error: "Cache directory has unsafe ownership or permissions" }) + return + } + var dir = cacheRoot + "/omarseafile" + var mk = _mkdirFactory.createObject(root, { + onDone: function(ok) { + if (!ok) { callback({ valid: false, error: "Cannot create cache directory" }); return } + var verify = _statFactory.createObject(root, { + onDone: function(out2) { + var parts2 = out2 ? out2.split(" ") : [] + if (parts2.length !== 2 || !/^\d+$/.test(parts2[0]) || !/^[0-7]+$/.test(parts2[1])) { + callback({ valid: false, error: "Cannot verify cache directory" }) + return + } + if (parseInt(parts2[0], 10) !== expectedUid || parseInt(parts2[1], 8) !== 0o700) { + callback({ valid: false, error: "Cache directory has incorrect ownership or permissions" }) + return + } + callback({ valid: true, path: dir }) + } + }) + verify.command = ["stat", "-c", "%u %a", dir] + verify.running = true + } + }) + mk.command = ["mkdir", "-p", "-m", "0700", "--", dir] + mk.running = true + } + }) + proc.command = ["stat", "-c", "%u %a", cacheRoot] + proc.running = true + } + }) + uidProc.command = ["id", "-u"] + uidProc.running = true + } + + property Component _evictCacheFactory: Component { + Process { + property var onDone: null + onExited: function(exitCode) { + var cb = onDone + destroy() + if (cb) cb(exitCode === 0) + } + } + } + + function _validCacheName(name) { + return typeof name === "string" && /^[A-Za-z0-9._-]{1,128}$/.test(name) + } + + function protectCache(name) { + if (!_validCacheName(name) || root._protectedCacheNames.indexOf(name) !== -1) return + root._protectedCacheNames = root._protectedCacheNames.concat([name]) + } + + function releaseCache(name, callback) { + if (!_validCacheName(name)) { if (callback) callback(true); return } + root._protectedCacheNames = root._protectedCacheNames.filter(function(protectedName) { + return protectedName !== name + }) + getCacheDir(function(cacheResult) { + if (!cacheResult.valid) { if (callback) callback(false); return } + var proc = _evictCacheFactory.createObject(root, { + onDone: function(ok) { if (callback) callback(ok) } + }) + proc.command = ["rm", "-f", "--", cacheResult.path + "/.active_" + name] + proc.running = true + }) + } + + // Evict oldest cache files until total size <= maxCacheBytes. + // Delegates to scripts/cache_evict.py which uses a held O_DIRECTORY|O_NOFOLLOW + // directory FD, lstat semantics, and PID-backed active-download markers. + function evictCache(protectedNames, callback, maxBytes) { + if (typeof protectedNames === "function") { + callback = protectedNames + protectedNames = [] + } + protectedNames = protectedNames || [] + var effectiveProtected = root._protectedCacheNames.slice() + for (var i = 0; i < protectedNames.length; i++) { + if (effectiveProtected.indexOf(protectedNames[i]) === -1) effectiveProtected.push(protectedNames[i]) + } + getCacheDir(function(cacheResult) { + if (!cacheResult.valid) { if (callback) callback(false); return } + var scriptsBase = Qt.resolvedUrl("../scripts") + var helper = scriptsBase + "/cache_evict.py" + var evictProc = _evictCacheFactory.createObject(root, { + onDone: function(ok) { + if (callback) callback(ok) + } + }) + evictProc.command = [ + "python3", + helper.replace(/^file:\/\//, ""), + cacheResult.path, + String(maxBytes === undefined ? root.maxCacheBytes : maxBytes) + ].concat(effectiveProtected) + evictProc.running = true + }) + } + + // Clear only safe, non-active files in Omarseafile's private cache. + function clearPersistentCache(callback) { + root.evictCache([], function(ok) { + var protectedFiles = root._protectedCacheNames.length > 0 + if (callback) callback({ complete: ok && !protectedFiles, protected: protectedFiles }) + }, 0) + } + + // Atomic writer: single Python process using mkstemp for exclusive creation, + // mode 0600 enforced on the open fd, content via stdin, path via stdout + property Component _atomicTimeoutFactory: Component { + Timer { + property var targetProcess: null + interval: 30000 + repeat: false + onTriggered: { + if (targetProcess) targetProcess.running = false + } + } + } + + property Component _atomicWriterFactory: Component { + Process { + id: atomicProc + property var onDone: null + property string writeContent: "" + property var writeTimeout: null + stdinEnabled: true + stdout: StdioCollector {} + onStarted: { + atomicProc.write(writeContent) + atomicProc.stdinEnabled = false + writeTimeout = root._atomicTimeoutFactory.createObject(root, { targetProcess: atomicProc }) + writeTimeout.start() + } + onExited: function(exitCode) { + if (writeTimeout) { + writeTimeout.stop() + writeTimeout.destroy() + writeTimeout = null + } + var cb = onDone + var out = stdout.text.trim() + destroy() + if (cb) cb(exitCode === 0 ? out : null) + } + } + } + + // Creates a secure temp file atomically: single writer process using mkstemp + // dir: subdirectory under omarseafile/ (e.g., "secrets", "transfers", "cache", "http") + // prefix: filename prefix + // content: file content to write atomically via stdin + // callback(result): { valid: true, path } or { valid: false, error } + function createSecureFile(dir, prefix, content, callback) { + getRuntimeSubdir(dir, function(runtimeResult) { + if (!runtimeResult.valid) { callback({ valid: false, error: runtimeResult.error }); return } + var safePrefix = prefix.replace(/[^a-zA-Z0-9_-]/g, "_") + var proc = _atomicWriterFactory.createObject(root, { + onDone: function(path) { + if (!path) { + callback({ valid: false, error: "Atomic write failed" }) + } else { + callback({ valid: true, path: path }) + } + } + }) + var scriptsBase = Qt.resolvedUrl("../scripts") + var scriptPath = scriptsBase + "/atomic_write.py" + proc.command = [ + "python3", + scriptPath.replace(/^file:\/\//, ""), + runtimeResult.path, safePrefix + ] + proc.writeContent = content === undefined || content === null ? "" : String(content) + proc.running = true + }) + } +} diff --git a/js/SeafileAPI.qml b/js/SeafileAPI.qml index f8262a7..75bb284 100644 --- a/js/SeafileAPI.qml +++ b/js/SeafileAPI.qml @@ -15,36 +15,124 @@ QtObject { token = t } + // Defense-in-depth: reject non-loopback HTTP before any credential-bearing + // request. Covers request(), auth(), and direct HttpTransport callers. + function _authUrlPolicy() { + if (!baseUrl) return { valid: false, error: "No server URL configured" } + return UrlPolicy.validateForAuth(baseUrl) + } + + // ===== VALIDATION BOUNDS ===== + readonly property int _maxItems: 1000 + readonly property int _maxName: 1024 + readonly property int _maxPath: 4096 + readonly property int _maxId: 512 + readonly property int _maxToken: 4096 + readonly property int _maxUrl: 8192 + readonly property int _maxPermission: 128 + readonly property int _maxEmail: 320 + readonly property int _maxDescription: 4096 + + // ===== VALIDATION HELPERS ===== + + function _boundedString(value, max, allowEmpty) { + if (typeof value !== "string") return { valid: false, error: "Expected string" } + if (!allowEmpty && value.length === 0) return { valid: false, error: "String must not be empty" } + if (value.length > max) return { valid: false, error: "String exceeds max length " + max } + return { valid: true } + } + + function _optionalBoundedString(value, max) { + if (value === undefined || value === null) return { valid: true } + return _boundedString(value, max, true) + } + + function _safeBoolean(value) { + if (typeof value !== "boolean") return { valid: false, error: "Expected boolean" } + return { valid: true } + } + + function _safeNonNegativeNumber(value) { + if (typeof value !== "number" || isNaN(value)) return { valid: false, error: "Expected number" } + if (value < 0) return { valid: false, error: "Number must be non-negative" } + return { valid: true } + } + + function _safeTimestamp(value) { + if (typeof value !== "number" || isNaN(value)) return { valid: false, error: "Expected timestamp number" } + return { valid: true } + } + + function _safeArray(value, limit) { + if (!Array.isArray(value)) return { valid: false, error: "Expected array" } + var max = limit || _maxItems + if (value.length > max) return { valid: false, error: "Array exceeds max items " + max } + return { valid: true } + } + + function _hasControlChars(s) { + for (var i = 0; i < s.length; i++) { + var c = s.charCodeAt(i) + if (c < 0x20 || c === 0x7F) return true + } + return false + } + function auth(username, password, callback) { - var xhr = new XMLHttpRequest() + var policy = _authUrlPolicy() + if (!policy.valid) { + callback(false, null, policy.error) + return + } var url = baseUrl + "/api2/auth-token/" - xhr.open("POST", url, true) - xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded") - xhr.onreadystatechange = function() { - if (xhr.readyState === XMLHttpRequest.DONE) { - if (xhr.status === 200) { - try { - var response = JSON.parse(xhr.responseText) - if (!response || typeof response.token !== "string" || response.token === "") throw new Error("missing token") - callback(true, response.token, null) - } catch (e) { + HttpTransport.post(url, { "Content-Type": "application/x-www-form-urlencoded" }, + "username=" + encodeURIComponent(username) + "&password=" + encodeURIComponent(password), + function(success, data, error) { + if (success) { + if (!data || typeof data.token !== "string" || data.token === "") { callback(false, null, "Invalid server response") + return + } + if (data.token.length > _maxToken) { + callback(false, null, "Token exceeds maximum length") + return } + if (_hasControlChars(data.token)) { + callback(false, null, "Token contains invalid characters") + return + } + callback(true, data.token, null) } else { - var error = parseError(xhr) - callback(false, null, error) + callback(false, null, error || "Authentication failed") } } - } - xhr.send("username=" + encodeURIComponent(username) + "&password=" + encodeURIComponent(password)) + ) } function listLibraries(callback) { request("GET", "/api2/repos/", null, function(success, data, error) { if (success) { - if (!Array.isArray(data)) { callback(false, null, "Invalid server response"); return } - var libraries = data.map(function(repo) { - return { + var arrResult = _safeArray(data) + if (!arrResult.valid) { callback(false, null, arrResult.error); return } + var libraries = [] + for (var i = 0; i < data.length; i++) { + var repo = data[i] + if (!repo || typeof repo !== "object") { callback(false, null, "Invalid library item at index " + i); return } + var vId = _boundedString(repo.id, _maxId) + if (!vId.valid) { callback(false, null, "Library id: " + vId.error); return } + var vName = _boundedString(repo.name, _maxName) + if (!vName.valid) { callback(false, null, "Library name: " + vName.error); return } + var vSize = _safeNonNegativeNumber(repo.size) + if (!vSize.valid) { callback(false, null, "Library size: " + vSize.error); return } + var vSizeFmt = _optionalBoundedString(repo.size_formatted, _maxName) + if (!vSizeFmt.valid) { callback(false, null, "Library size_formatted: " + vSizeFmt.error); return } + var vMtime = _safeTimestamp(repo.mtime) + if (!vMtime.valid) { callback(false, null, "Library mtime: " + vMtime.error); return } + var vPerm = _boundedString(repo.permission, _maxPermission) + if (!vPerm.valid) { callback(false, null, "Library permission: " + vPerm.error); return } + var vEnc = _safeBoolean(repo.encrypted) + if (!vEnc.valid) { callback(false, null, "Library encrypted: " + vEnc.error); return } + libraries.push({ id: repo.id, name: repo.name, type: "dir", @@ -53,8 +141,8 @@ QtObject { mtime: repo.mtime, permission: repo.permission, encrypted: repo.encrypted - } - }) + }) + } callback(true, libraries, null) } else { callback(false, null, error) @@ -69,18 +157,38 @@ QtObject { } request("GET", url, null, function(success, data, error) { if (success) { - if (!Array.isArray(data)) { callback(false, null, "Invalid server response"); return } - var items = data.map(function(item) { - return { + var arrResult = _safeArray(data) + if (!arrResult.valid) { callback(false, null, arrResult.error); return } + var items = [] + for (var i = 0; i < data.length; i++) { + var item = data[i] + if (!item || typeof item !== "object") { callback(false, null, "Invalid folder item at index " + i); return } + var vType = _boundedString(item.type, 32) + if (!vType.valid) { callback(false, null, "Item type: " + vType.error); return } + var vName = _boundedString(item.name, _maxName) + if (!vName.valid) { callback(false, null, "Item name: " + vName.error); return } + var vId = _optionalBoundedString(item.id, _maxId) + if (!vId.valid) { callback(false, null, "Item id: " + vId.error); return } + var vMtime = _safeTimestamp(item.mtime) + if (!vMtime.valid) { callback(false, null, "Item mtime: " + vMtime.error); return } + var vPerm = _optionalBoundedString(item.permission, _maxPermission) + if (!vPerm.valid) { callback(false, null, "Item permission: " + vPerm.error); return } + var rawSize = (item.size === undefined || item.size === null) ? 0 : item.size + var vSize = _safeNonNegativeNumber(rawSize) + if (!vSize.valid) { callback(false, null, "Item size: " + vSize.error); return } + var rawStarred = (item.starred === undefined || item.starred === null) ? false : item.starred + var vStarred = _safeBoolean(rawStarred) + if (!vStarred.valid) { callback(false, null, "Item starred: " + vStarred.error); return } + items.push({ type: item.type, name: item.name, id: item.id, mtime: item.mtime, permission: item.permission, - size: item.size || 0, - starred: item.starred || false - } - }) + size: rawSize, + starred: rawStarred + }) + } items.sort(function(a, b) { if (a.type !== b.type) return a.type === "dir" ? -1 : 1 return a.name.localeCompare(b.name) @@ -97,6 +205,8 @@ QtObject { if (reuse) url += "&reuse=1" request("GET", url, null, function(success, data, error) { if (success) { + var vUrl = UrlPolicy.validateTransferUrl(data) + if (!vUrl.valid) { callback(false, null, "Invalid download URL: " + vUrl.error); return } callback(true, data, null) } else { callback(false, null, error) @@ -105,20 +215,15 @@ QtObject { } function createFolder(repoId, parentPath, folderName, token, callback) { - // CE 12 ignores nested mkdir paths, so create at root and synchronously - // move and rename a unique temporary folder for nested destinations. - // Using the requested name at root could relocate an existing folder - // when the server collision-renames the newly created one. + var policy = _authUrlPolicy() + if (!policy.valid) { callback(false, policy.error); return } var createName = parentPath === "/" ? folderName : "Omarseafile temporary " + Date.now() + " " + Math.random().toString(36).substring(2, 8) var fullPath = "/" + createName var url = "/api2/repos/" + repoId + "/dir/?p=" + encodeURIComponent(fullPath) - var xhr = new XMLHttpRequest() - xhr.open("POST", baseUrl + url, true) - xhr.setRequestHeader("Authorization", "Token " + token) - xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded") - xhr.onreadystatechange = function() { - if (xhr.readyState === XMLHttpRequest.DONE) { - if (xhr.status >= 200 && xhr.status < 300) { + HttpTransport.post(baseUrl + url, { "Authorization": "Token " + token, "Content-Type": "application/x-www-form-urlencoded" }, + "operation=mkdir", + function(success, data, error) { + if (success) { if (parentPath === "/") { callback(true, null) } else { @@ -133,119 +238,77 @@ QtObject { }) } } else { - callback(false, parseError(xhr)) + callback(false, error || "Create folder failed") } } - } - xhr.send("operation=mkdir") + ) } function renameFile(repoId, filePath, newName, token, callback) { + var policy = _authUrlPolicy() + if (!policy.valid) { callback(false, policy.error); return } var url = baseUrl + "/api/v2.1/repos/" + repoId + "/file/?p=" + encodeURIComponent(filePath) - var xhr = new XMLHttpRequest() - xhr.open("POST", url, true) - xhr.setRequestHeader("Authorization", "Token " + token) - xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded") - xhr.onreadystatechange = function() { - if (xhr.readyState === XMLHttpRequest.DONE) { - if (xhr.status >= 200 && xhr.status < 300) { - callback(true, null) - } else { - callback(false, parseError(xhr)) - } + HttpTransport.post(url, { "Authorization": "Token " + token, "Content-Type": "application/x-www-form-urlencoded" }, + "operation=rename&oldname=" + encodeURIComponent(filePath) + "&newname=" + encodeURIComponent(newName), + function(success, data, error) { + if (success) callback(true, null) + else callback(false, error || "Rename failed") } - } - xhr.send("operation=rename&oldname=" + encodeURIComponent(filePath) + "&newname=" + encodeURIComponent(newName)) + ) } function renameFolder(repoId, parentPath, oldName, newName, token, callback) { + var policy = _authUrlPolicy() + if (!policy.valid) { callback(false, policy.error); return } var parent = parentPath === "/" ? "" : parentPath var fullPath = parent + "/" + oldName var url = baseUrl + "/api2/repos/" + repoId + "/dir/?p=" + encodeURIComponent(fullPath) - var xhr = new XMLHttpRequest() - xhr.open("POST", url, true) - xhr.setRequestHeader("Authorization", "Token " + token) - xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded") - xhr.onreadystatechange = function() { - if (xhr.readyState === XMLHttpRequest.DONE) { - if (xhr.status >= 200 && xhr.status < 300) { - callback(true, null) - } else { - callback(false, parseError(xhr)) - } + HttpTransport.post(url, { "Authorization": "Token " + token, "Content-Type": "application/x-www-form-urlencoded" }, + "operation=rename&newname=" + encodeURIComponent(newName), + function(success, data, error) { + if (success) callback(true, null) + else callback(false, error || "Rename failed") } - } - xhr.send("operation=rename&newname=" + encodeURIComponent(newName)) + ) } function moveFile(repoId, filePath, destPath, token, callback) { + var policy = _authUrlPolicy() + if (!policy.valid) { callback(false, policy.error); return } var url = baseUrl + "/api/v2.1/repos/" + repoId + "/file/?p=" + encodeURIComponent(filePath) - var xhr = new XMLHttpRequest() - xhr.open("POST", url, true) - xhr.setRequestHeader("Authorization", "Token " + token) - xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded") - xhr.onreadystatechange = function() { - if (xhr.readyState === XMLHttpRequest.DONE) { - if (xhr.status >= 200 && xhr.status < 300) { - callback(true, null) - } else { - callback(false, parseError(xhr)) - } + HttpTransport.post(url, { "Authorization": "Token " + token, "Content-Type": "application/x-www-form-urlencoded" }, + "operation=move&dst_repo=" + encodeURIComponent(repoId) + "&dst_dir=" + encodeURIComponent(destPath), + function(success, data, error) { + if (success) callback(true, null) + else callback(false, error || "Move failed") } - } - xhr.send("operation=move&dst_repo=" + encodeURIComponent(repoId) + "&dst_dir=" + encodeURIComponent(destPath)) + ) } function deleteFile(repoId, filePath, token, callback) { + var policy = _authUrlPolicy() + if (!policy.valid) { callback(false, policy.error); return } var url = baseUrl + "/api/v2.1/repos/" + repoId + "/file/?p=" + encodeURIComponent(filePath) - var xhr = new XMLHttpRequest() - xhr.open("DELETE", url, true) - xhr.setRequestHeader("Authorization", "Token " + token) - xhr.onreadystatechange = function() { - if (xhr.readyState === XMLHttpRequest.DONE) { - if (xhr.status >= 200 && xhr.status < 300) { - callback(true, null) - } else { - callback(false, parseError(xhr)) - } - } - } - xhr.send() + HttpTransport.del(url, { "Authorization": "Token " + token }, function(success, data, error) { + if (success) callback(true, null) + else callback(false, error || "Delete failed") + }) } function deleteFolder(repoId, folderPath, token, callback) { + var policy = _authUrlPolicy() + if (!policy.valid) { callback(false, policy.error); return } var url = baseUrl + "/api2/repos/" + repoId + "/dir/?p=" + encodeURIComponent(folderPath) - var xhr = new XMLHttpRequest() - xhr.open("DELETE", url, true) - xhr.setRequestHeader("Authorization", "Token " + token) - xhr.onreadystatechange = function() { - if (xhr.readyState === XMLHttpRequest.DONE) { - if (xhr.status >= 200 && xhr.status < 300) { - callback(true, null) - } else { - callback(false, parseError(xhr)) - } - } - } - xhr.send() + HttpTransport.del(url, { "Authorization": "Token " + token }, function(success, data, error) { + if (success) callback(true, null) + else callback(false, error || "Delete failed") + }) } function moveFolder(repoId, folderName, srcParentPath, destRepoId, destParentPath, token, callback) { + var policy = _authUrlPolicy() + if (!policy.valid) { callback(false, policy.error); return } var url = baseUrl + "/api/v2.1/repos/sync-batch-move-item/" - var xhr = new XMLHttpRequest() - xhr.open("POST", url, true) - xhr.setRequestHeader("Authorization", "Token " + token) - xhr.setRequestHeader("Content-Type", "application/json") - xhr.onreadystatechange = function() { - if (xhr.readyState === XMLHttpRequest.DONE) { - if (xhr.status >= 200 && xhr.status < 300) { - if (confirmedMutation(xhr)) callback(true, null) - else callback(false, "Server did not confirm move") - } else { - callback(false, parseError(xhr)) - } - } - } var body = JSON.stringify({ src_repo_id: repoId, src_parent_dir: srcParentPath, @@ -253,29 +316,28 @@ QtObject { dst_repo_id: destRepoId, dst_parent_dir: destParentPath }) - xhr.send(body) - } - - function parseError(xhr) { - if (!xhr) return "Unknown error" - try { - if (xhr.responseText) { - var response = JSON.parse(xhr.responseText) - if (response) { - if (response.error_message) return response.error_message - if (response.errorMsg) return response.errorMsg - if (response.error) return response.error - if (response.detail) return response.detail + HttpTransport.post(url, { "Authorization": "Token " + token, "Content-Type": "application/json" }, body, + function(success, data, error) { + if (success) { + if (confirmedMutation({ responseText: JSON.stringify(data) })) callback(true, null) + else callback(false, "Server did not confirm move") + } else { + callback(false, error || "Move failed") } } - } catch (e) {} - return "HTTP " + xhr.status + (xhr.statusText ? " " + xhr.statusText : "") + ) + } + + function parseError(error) { + if (!error) return "Unknown error" + if (typeof error === "string") return error + return "Unknown error" } - function confirmedMutation(xhr) { + function confirmedMutation(response) { try { - var response = JSON.parse(xhr.responseText) - return response && response.success === true + var data = typeof response === "string" ? JSON.parse(response) : response + return data && data.success === true } catch (e) { return false } @@ -286,32 +348,19 @@ QtObject { callback(false, null, "No authentication token") return } - var xhr = new XMLHttpRequest() - var url = baseUrl + path - xhr.open(method, url, true) - xhr.setRequestHeader("Authorization", "Token " + token) - xhr.setRequestHeader("Accept", "application/json") - if (body && method !== "GET") { - xhr.setRequestHeader("Content-Type", "application/json") + var policy = _authUrlPolicy() + if (!policy.valid) { + callback(false, null, policy.error) + return } - xhr.onreadystatechange = function() { - if (xhr.readyState === XMLHttpRequest.DONE) { - if (xhr.status >= 200 && xhr.status < 300) { - var data = null - try { - data = JSON.parse(xhr.responseText) - } catch (e) { - callback(false, null, "Invalid server response") - return - } - callback(true, data, null) - } else { - var error = parseError(xhr) - callback(false, null, error) - } - } + var headers = { + "Authorization": "Token " + token, + "Accept": "application/json" } - xhr.send(body ? JSON.stringify(body) : null) + if (body && method !== "GET") { + headers["Content-Type"] = "application/json" + } + HttpTransport.request(method, baseUrl + path, headers, body ? JSON.stringify(body) : null, callback) } // ===== SHARE LINKS ===== @@ -323,9 +372,41 @@ QtObject { } request("GET", url, null, function(success, data, error) { if (success) { - if (!Array.isArray(data)) { callback(false, null, "Invalid server response"); return } - var links = data.map(function(link) { - return { + var arrResult = _safeArray(data) + if (!arrResult.valid) { callback(false, null, arrResult.error); return } + var links = [] + for (var i = 0; i < data.length; i++) { + var link = data[i] + if (!link || typeof link !== "object") { callback(false, null, "Invalid share link at index " + i); return } + var vToken = _boundedString(link.token, _maxToken) + if (!vToken.valid) { callback(false, null, "Share link token: " + vToken.error); return } + var vLink = _boundedString(link.link, _maxUrl) + if (!vLink.valid) { callback(false, null, "Share link link: " + vLink.error); return } + var vRepoId = _boundedString(link.repo_id, _maxId) + if (!vRepoId.valid) { callback(false, null, "Share link repo_id: " + vRepoId.error); return } + var vRepoName = _optionalBoundedString(link.repo_name, _maxName) + if (!vRepoName.valid) { callback(false, null, "Share link repo_name: " + vRepoName.error); return } + var vPath = _boundedString(link.path, _maxPath) + if (!vPath.valid) { callback(false, null, "Share link path: " + vPath.error); return } + var vObjName = _optionalBoundedString(link.obj_name, _maxName) + if (!vObjName.valid) { callback(false, null, "Share link obj_name: " + vObjName.error); return } + var vIsDir = _safeBoolean(link.is_dir) + if (!vIsDir.valid) { callback(false, null, "Share link is_dir: " + vIsDir.error); return } + var vViewCnt = _safeNonNegativeNumber(link.view_cnt) + if (!vViewCnt.valid) { callback(false, null, "Share link view_cnt: " + vViewCnt.error); return } + var vCtime = _safeTimestamp(link.ctime) + if (!vCtime.valid) { callback(false, null, "Share link ctime: " + vCtime.error); return } + var vExpireDate = _optionalBoundedString(link.expire_date, 64) + if (!vExpireDate.valid) { callback(false, null, "Share link expire_date: " + vExpireDate.error); return } + var vIsExpired = _safeBoolean(link.is_expired) + if (!vIsExpired.valid) { callback(false, null, "Share link is_expired: " + vIsExpired.error); return } + var rawPerms = (link.permissions === undefined || link.permissions === null) ? {} : link.permissions + if (typeof rawPerms !== "object" || rawPerms === null || Array.isArray(rawPerms)) { callback(false, null, "Share link permissions: expected object"); return } + var vPassword = _optionalBoundedString(link.password, _maxToken) + if (!vPassword.valid) { callback(false, null, "Share link password: " + vPassword.error); return } + var vCanEdit = _safeBoolean(link.can_edit) + if (!vCanEdit.valid) { callback(false, null, "Share link can_edit: " + vCanEdit.error); return } + links.push({ token: link.token, link: link.link, repo_id: link.repo_id, @@ -337,11 +418,11 @@ QtObject { ctime: link.ctime, expire_date: link.expire_date, is_expired: link.is_expired, - permissions: link.permissions || {}, + permissions: rawPerms, password: link.password || "", can_edit: link.can_edit - } - }) + }) + } callback(true, links, null) } else { callback(false, null, error) @@ -350,6 +431,8 @@ QtObject { } function createShareLink(repoId, path, options, callback) { + var policy = _authUrlPolicy() + if (!policy.valid) { callback(false, null, policy.error); return } var body = { repo_id: repoId, path: path @@ -363,94 +446,100 @@ QtObject { if (options.permissions) { body.permissions = options.permissions } - var xhr = new XMLHttpRequest() - xhr.open("POST", baseUrl + "/api/v2.1/share-links/", true) - xhr.setRequestHeader("Authorization", "Token " + token) - xhr.setRequestHeader("Content-Type", "application/json") - xhr.onreadystatechange = function() { - if (xhr.readyState === XMLHttpRequest.DONE) { - if (xhr.status >= 200 && xhr.status < 300) { - var response - try { response = JSON.parse(xhr.responseText) } catch (e) { callback(false, null, "Invalid server response"); return } - if (!response || typeof response.link !== "string" || typeof response.token !== "string") { callback(false, null, "Invalid server response"); return } + HttpTransport.post(baseUrl + "/api/v2.1/share-links/", + { "Authorization": "Token " + token, "Content-Type": "application/json" }, + JSON.stringify(body), + function(success, data, error) { + if (success) { + if (!data || typeof data !== "object") { callback(false, null, "Invalid server response"); return } + var vToken = _boundedString(data.token, _maxToken) + if (!vToken.valid) { callback(false, null, "Share link token: " + vToken.error); return } + var vLink = _boundedString(data.link, _maxUrl) + if (!vLink.valid) { callback(false, null, "Share link link: " + vLink.error); return } + var vUrl = UrlPolicy.validateTransferUrl(data.link) + if (!vUrl.valid) { callback(false, null, "Share link URL: " + vUrl.error); return } + var vRepoId = _optionalBoundedString(data.repo_id, _maxId) + if (!vRepoId.valid) { callback(false, null, "Share link repo_id: " + vRepoId.error); return } + var vRepoName = _optionalBoundedString(data.repo_name, _maxName) + if (!vRepoName.valid) { callback(false, null, "Share link repo_name: " + vRepoName.error); return } + var vPath = _optionalBoundedString(data.path, _maxPath) + if (!vPath.valid) { callback(false, null, "Share link path: " + vPath.error); return } + var vObjName = _optionalBoundedString(data.obj_name, _maxName) + if (!vObjName.valid) { callback(false, null, "Share link obj_name: " + vObjName.error); return } + var vIsDir = _safeBoolean(data.is_dir) + if (!vIsDir.valid) { callback(false, null, "Share link is_dir: " + vIsDir.error); return } + var vViewCnt = _safeNonNegativeNumber(data.view_cnt) + if (!vViewCnt.valid) { callback(false, null, "Share link view_cnt: " + vViewCnt.error); return } + var vCtime = _safeTimestamp(data.ctime) + if (!vCtime.valid) { callback(false, null, "Share link ctime: " + vCtime.error); return } + var vExpireDate = _optionalBoundedString(data.expire_date, 64) + if (!vExpireDate.valid) { callback(false, null, "Share link expire_date: " + vExpireDate.error); return } + var vIsExpired = _safeBoolean(data.is_expired) + if (!vIsExpired.valid) { callback(false, null, "Share link is_expired: " + vIsExpired.error); return } + var rawPerms = (data.permissions === undefined || data.permissions === null) ? {} : data.permissions + if (typeof rawPerms !== "object" || rawPerms === null || Array.isArray(rawPerms)) { callback(false, null, "Share link permissions: expected object"); return } + var vPassword = _optionalBoundedString(data.password, _maxToken) + if (!vPassword.valid) { callback(false, null, "Share link password: " + vPassword.error); return } + var vCanEdit = _safeBoolean(data.can_edit) + if (!vCanEdit.valid) { callback(false, null, "Share link can_edit: " + vCanEdit.error); return } callback(true, { - token: response.token, - link: response.link, - repo_id: response.repo_id, - repo_name: response.repo_name, - path: response.path, - obj_name: response.obj_name, - is_dir: response.is_dir, - view_cnt: response.view_cnt, - ctime: response.ctime, - expire_date: response.expire_date, - is_expired: response.is_expired, - permissions: response.permissions || {}, - password: response.password || "", - can_edit: response.can_edit + token: data.token, + link: data.link, + repo_id: data.repo_id, + repo_name: data.repo_name, + path: data.path, + obj_name: data.obj_name, + is_dir: data.is_dir, + view_cnt: data.view_cnt, + ctime: data.ctime, + expire_date: data.expire_date, + is_expired: data.is_expired, + permissions: rawPerms, + password: data.password || "", + can_edit: data.can_edit }, null) } else { - callback(false, null, parseError(xhr)) + callback(false, null, error || "Create share link failed") } } - } - xhr.send(JSON.stringify(body)) + ) } function deleteShareLink(shareToken, callback) { - var xhr = new XMLHttpRequest() - xhr.open("DELETE", baseUrl + "/api/v2.1/share-links/" + encodeURIComponent(shareToken) + "/", true) - xhr.setRequestHeader("Authorization", "Token " + token) - xhr.onreadystatechange = function() { - if (xhr.readyState === XMLHttpRequest.DONE) { - if (xhr.status >= 200 && xhr.status < 300) { - if (confirmedMutation(xhr)) callback(true, null) + var policy = _authUrlPolicy() + if (!policy.valid) { callback(false, policy.error); return } + HttpTransport.del(baseUrl + "/api/v2.1/share-links/" + encodeURIComponent(shareToken) + "/", + { "Authorization": "Token " + token }, + function(success, data, error) { + if (success) { + if (confirmedMutation(data)) callback(true, null) else callback(false, "Server did not confirm share-link revocation") } else { - callback(false, parseError(xhr)) + callback(false, error || "Delete share link failed") } } - } - xhr.send() + ) } // ===== COPY ===== function copyFile(repoId, filePath, dstRepoId, dstDir, newName, token, callback) { - var url = "/api/v2.1/repos/" + repoId + "/file/?p=" + encodeURIComponent(filePath) - var xhr = new XMLHttpRequest() - xhr.open("POST", baseUrl + url, true) - xhr.setRequestHeader("Authorization", "Token " + token) - xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded") - xhr.onreadystatechange = function() { - if (xhr.readyState === XMLHttpRequest.DONE) { - if (xhr.status >= 200 && xhr.status < 300) { - callback(true, null) - } else { - callback(false, parseError(xhr)) - } + var policy = _authUrlPolicy() + if (!policy.valid) { callback(false, policy.error); return } + var url = baseUrl + "/api/v2.1/repos/" + repoId + "/file/?p=" + encodeURIComponent(filePath) + HttpTransport.post(url, { "Authorization": "Token " + token, "Content-Type": "application/x-www-form-urlencoded" }, + "operation=copy&dst_repo=" + encodeURIComponent(dstRepoId) + "&dst_dir=" + encodeURIComponent(dstDir) + "&newname=" + encodeURIComponent(newName), + function(success, data, error) { + if (success) callback(true, null) + else callback(false, error || "Copy failed") } - } - var body = "operation=copy&dst_repo=" + encodeURIComponent(dstRepoId) + "&dst_dir=" + encodeURIComponent(dstDir) + "&newname=" + encodeURIComponent(newName) - xhr.send(body) + ) } function copyFolder(repoId, folderName, srcParentDir, dstRepoId, dstParentDir, token, callback) { + var policy = _authUrlPolicy() + if (!policy.valid) { callback(false, policy.error); return } var url = baseUrl + "/api/v2.1/repos/sync-batch-copy-item/" - var xhr = new XMLHttpRequest() - xhr.open("POST", url, true) - xhr.setRequestHeader("Authorization", "Token " + token) - xhr.setRequestHeader("Content-Type", "application/json") - xhr.onreadystatechange = function() { - if (xhr.readyState === XMLHttpRequest.DONE) { - if (xhr.status >= 200 && xhr.status < 300) { - if (confirmedMutation(xhr)) callback(true, null) - else callback(false, "Server did not confirm copy") - } else { - callback(false, parseError(xhr)) - } - } - } var body = JSON.stringify({ src_repo_id: repoId, src_parent_dir: srcParentDir, @@ -458,7 +547,16 @@ QtObject { dst_repo_id: dstRepoId, dst_parent_dir: dstParentDir }) - xhr.send(body) + HttpTransport.post(url, { "Authorization": "Token " + token, "Content-Type": "application/json" }, body, + function(success, data, error) { + if (success) { + if (confirmedMutation(data)) callback(true, null) + else callback(false, "Server did not confirm copy") + } else { + callback(false, error || "Copy failed") + } + } + ) } function copyItems(items, dstRepoId, dstParentDir, callback) { @@ -466,6 +564,8 @@ QtObject { callback(false, "No items to copy") return } + var policy = _authUrlPolicy() + if (!policy.valid) { callback(false, policy.error); return } var groups = {} for (var i = 0; i < items.length; i++) { @@ -495,22 +595,18 @@ QtObject { } function sendGroup(group) { - var xhr = new XMLHttpRequest() - var url = baseUrl + "/api/v2.1/repos/sync-batch-copy-item/" - xhr.open("POST", url, true) - xhr.setRequestHeader("Authorization", "Token " + token) - xhr.setRequestHeader("Content-Type", "application/json") - xhr.onreadystatechange = function() { - if (xhr.readyState === XMLHttpRequest.DONE) { - if (xhr.status >= 200 && xhr.status < 300) { - if (!confirmedMutation(xhr)) hasError = true + HttpTransport.post(baseUrl + "/api/v2.1/repos/sync-batch-copy-item/", + { "Authorization": "Token " + token, "Content-Type": "application/json" }, + JSON.stringify(group), + function(success, data, error) { + if (success) { + if (!confirmedMutation(data)) hasError = true } else { hasError = true } checkComplete() } - } - xhr.send(JSON.stringify(group)) + ) } for (var key in groups) sendGroup(groups[key]) } @@ -520,6 +616,8 @@ QtObject { callback(false, "No items to move") return } + var policy = _authUrlPolicy() + if (!policy.valid) { callback(false, policy.error); return } var groups = {} for (var i = 0; i < items.length; i++) { @@ -549,22 +647,18 @@ QtObject { } function sendGroup(group) { - var xhr = new XMLHttpRequest() - var url = baseUrl + "/api/v2.1/repos/sync-batch-move-item/" - xhr.open("POST", url, true) - xhr.setRequestHeader("Authorization", "Token " + token) - xhr.setRequestHeader("Content-Type", "application/json") - xhr.onreadystatechange = function() { - if (xhr.readyState === XMLHttpRequest.DONE) { - if (xhr.status >= 200 && xhr.status < 300) { - if (!confirmedMutation(xhr)) hasError = true + HttpTransport.post(baseUrl + "/api/v2.1/repos/sync-batch-move-item/", + { "Authorization": "Token " + token, "Content-Type": "application/json" }, + JSON.stringify(group), + function(success, data, error) { + if (success) { + if (!confirmedMutation(data)) hasError = true } else { hasError = true } checkComplete() } - } - xhr.send(JSON.stringify(group)) + ) } for (var key in groups) sendGroup(groups[key]) } @@ -612,43 +706,51 @@ QtObject { } function search(query, repoId, callback) { + var policy = _authUrlPolicy() + if (!policy.valid) { callback(false, null, policy.error); return } var url = "/api/v2.1/search-file/?q=" + encodeURIComponent(query) + "&repo_id=" + encodeURIComponent(repoId) - var xhr = new XMLHttpRequest() - xhr.open("GET", baseUrl + url, true) - xhr.setRequestHeader("Authorization", "Token " + token) - xhr.setRequestHeader("Accept", "application/json") - xhr.onreadystatechange = function() { - if (xhr.readyState === XMLHttpRequest.DONE) { - if (xhr.status >= 200 && xhr.status < 300) { + HttpTransport.get(baseUrl + url, { "Authorization": "Token " + token, "Accept": "application/json" }, + function(success, data, error) { + if (success) { try { - var response = JSON.parse(xhr.responseText) - if (!response || !Array.isArray(response.data)) throw new Error("missing data") - var results = (response.data || []).map(function(item) { - if (!item || typeof item.path !== "string") throw new Error("invalid result") + if (!data || typeof data !== "object") throw new Error("missing data") + var arrResult = _safeArray(data.data) + if (!arrResult.valid) throw new Error(arrResult.error) + var results = [] + for (var i = 0; i < data.data.length; i++) { + var item = data.data[i] + if (!item || typeof item !== "object") throw new Error("Invalid search result at index " + i) + var vPath = _boundedString(item.path, _maxPath) + if (!vPath.valid) throw new Error("Search result path: " + vPath.error) + var rawSize = (item.size === undefined || item.size === null) ? 0 : item.size + var vSize = _safeNonNegativeNumber(rawSize) + if (!vSize.valid) throw new Error("Search result size: " + vSize.error) + var vMtime = _safeTimestamp(item.mtime) + if (!vMtime.valid) throw new Error("Search result mtime: " + vMtime.error) + var vType = _optionalBoundedString(item.type, 32) + if (!vType.valid) throw new Error("Search result type: " + vType.error) var pathParts = item.path.split("/") var name = pathParts.pop() var parentPath = pathParts.join("/") || "/" - return { + results.push({ name: name, path: item.path, parentPath: parentPath, - size: item.size || 0, + size: rawSize, mtime: item.mtime, type: item.type, repoId: repoId - } - }) + }) + } callback(true, results, null) } catch (e) { callback(false, null, "Failed to parse search response") } } else { - var error = parseError(xhr) - callback(false, null, error) + callback(false, null, error || "Search failed") } } - } - xhr.send() + ) } // ===== FILE HISTORY ===== @@ -657,9 +759,38 @@ QtObject { var url = "/api2/repos/" + repoId + "/file/history/?p=" + encodeURIComponent(path) request("GET", url, null, function(success, data, error) { if (success) { - if (!data || !Array.isArray(data.commits)) { callback(false, null, "Invalid server response"); return } - var history = (data.commits || []).map(function(commit) { - return { + if (!data || typeof data !== "object") { callback(false, null, "Invalid server response"); return } + var arrResult = _safeArray(data.commits) + if (!arrResult.valid) { callback(false, null, "commits: " + arrResult.error); return } + var history = [] + for (var i = 0; i < data.commits.length; i++) { + var commit = data.commits[i] + if (!commit || typeof commit !== "object") { callback(false, null, "Invalid commit at index " + i); return } + var vId = _boundedString(commit.id, _maxId) + if (!vId.valid) { callback(false, null, "Commit id: " + vId.error); return } + var vCreatorName = _optionalBoundedString(commit.creator_name, _maxName) + if (!vCreatorName.valid) { callback(false, null, "Commit creator_name: " + vCreatorName.error); return } + var vCtime = _safeTimestamp(commit.ctime) + if (!vCtime.valid) { callback(false, null, "Commit ctime: " + vCtime.error); return } + var vDesc = _optionalBoundedString(commit.desc, _maxDescription) + if (!vDesc.valid) { callback(false, null, "Commit desc: " + vDesc.error); return } + var vRevFileSize = _safeNonNegativeNumber(commit.rev_file_size) + if (!vRevFileSize.valid) { callback(false, null, "Commit rev_file_size: " + vRevFileSize.error); return } + var vRevFileId = _optionalBoundedString(commit.rev_file_id, _maxId) + if (!vRevFileId.valid) { callback(false, null, "Commit rev_file_id: " + vRevFileId.error); return } + var vVersion = _optionalBoundedString(commit.version, 32) + if (!vVersion.valid) { callback(false, null, "Commit version: " + vVersion.error); return } + var vCreator = _optionalBoundedString(commit.creator, _maxName) + if (!vCreator.valid) { callback(false, null, "Commit creator: " + vCreator.error); return } + var vCreatorContactEmail = _optionalBoundedString(commit.creator_contact_email, _maxEmail) + if (!vCreatorContactEmail.valid) { callback(false, null, "Commit creator_contact_email: " + vCreatorContactEmail.error); return } + var vCreatorEmail = _optionalBoundedString(commit.creator_email, _maxEmail) + if (!vCreatorEmail.valid) { callback(false, null, "Commit creator_email: " + vCreatorEmail.error); return } + var vRepoId = _optionalBoundedString(commit.repo_id, _maxId) + if (!vRepoId.valid) { callback(false, null, "Commit repo_id: " + vRepoId.error); return } + var vRepoName = _optionalBoundedString(commit.repo_name, _maxName) + if (!vRepoName.valid) { callback(false, null, "Commit repo_name: " + vRepoName.error); return } + history.push({ commitId: commit.id, id: commit.id, creatorName: commit.creator_name, @@ -674,8 +805,8 @@ QtObject { repoId: commit.repo_id, repoName: commit.repo_name, creatorName: commit.creator_name - } - }) + }) + } callback(true, history, null) } else { callback(false, null, error) @@ -688,6 +819,8 @@ QtObject { request("GET", url, null, function(success, data, error) { if (success) { if (typeof data !== "string" || data === "") { callback(false, null, "Invalid server response"); return } + var vUrl = UrlPolicy.validateTransferUrl(data) + if (!vUrl.valid) { callback(false, null, "Invalid revision URL: " + vUrl.error); return } callback(true, data, null) } else { callback(false, null, error) @@ -701,18 +834,38 @@ QtObject { var url = "/api/v2.1/repos/" + repoId + "/trash/" request("GET", url, null, function(success, data, error) { if (success) { - if (!data || !Array.isArray(data.data)) { callback(false, null, "Invalid server response"); return } - var trash = (data.data || []).map(function(item) { - return { + if (!data || typeof data !== "object") { callback(false, null, "Invalid server response"); return } + var arrResult = _safeArray(data.data) + if (!arrResult.valid) { callback(false, null, "data: " + arrResult.error); return } + var trash = [] + for (var i = 0; i < data.data.length; i++) { + var item = data.data[i] + if (!item || typeof item !== "object") { callback(false, null, "Invalid trash item at index " + i); return } + var vParentDir = _optionalBoundedString(item.parent_dir, _maxPath) + if (!vParentDir.valid) { callback(false, null, "Trash parent_dir: " + vParentDir.error); return } + var vObjName = _boundedString(item.obj_name, _maxName) + if (!vObjName.valid) { callback(false, null, "Trash obj_name: " + vObjName.error); return } + var vDeletedTime = _optionalBoundedString(item.deleted_time, 64) + if (!vDeletedTime.valid) { callback(false, null, "Trash deleted_time: " + vDeletedTime.error); return } + var vCommitId = _optionalBoundedString(item.commit_id, _maxId) + if (!vCommitId.valid) { callback(false, null, "Trash commit_id: " + vCommitId.error); return } + var vIsDir = _safeBoolean(item.is_dir) + if (!vIsDir.valid) { callback(false, null, "Trash is_dir: " + vIsDir.error); return } + var rawSize = (item.size === undefined || item.size === null) ? 0 : item.size + var vSize = _safeNonNegativeNumber(rawSize) + if (!vSize.valid) { callback(false, null, "Trash size: " + vSize.error); return } + var vObjId = _optionalBoundedString(item.obj_id, _maxId) + if (!vObjId.valid) { callback(false, null, "Trash obj_id: " + vObjId.error); return } + trash.push({ parentDir: item.parent_dir, objName: item.obj_name, deletedTime: item.deleted_time, commitId: item.commit_id, isDir: item.is_dir, - size: item.size || 0, + size: rawSize, objId: item.obj_id || "" - } - }) + }) + } callback(true, trash, null) } else { callback(false, null, error) diff --git a/js/SelectionHelper.qml b/js/SelectionHelper.qml index 8a18d54..1e11cf6 100644 --- a/js/SelectionHelper.qml +++ b/js/SelectionHelper.qml @@ -8,8 +8,8 @@ QtObject { function makeKey(item) { if (!item) return "" - var repoId = item.repoId || item.parentRepoId || "" - var fullPath = item.fullPath || item.path || (item.name && item.parentPath ? item.parentPath + "/" + item.name : "") + var repoId = item.repoId || item.parentRepoId || item.id || "" + var fullPath = item.fullPath || item.path || (item.name && item.parentPath ? item.parentPath + "/" + item.name : "") || item.id || item.name || "" var type = item.type || (item.isDir ? "dir" : "file") return repoId + ":" + fullPath + ":" + type } @@ -137,4 +137,4 @@ QtObject { } return result } -} \ No newline at end of file +} diff --git a/js/TransferService.qml b/js/TransferService.qml index 437b9ba..b71b634 100644 --- a/js/TransferService.qml +++ b/js/TransferService.qml @@ -14,17 +14,43 @@ QtObject { property int maxRetryDelay: 30000 property int maxHistory: 50 + // ===== TRANSFER LIMITS ===== + property int maxTransferBytes: 1024 * 1024 * 1024 + property int maxUploadResponseBytes: 64 * 1024 + property int maxUploadBodyBytes: 1024 * 1024 * 1024 // 1 GiB + property int connectTimeoutMs: 10000 + property int totalTimeoutMs: 30 * 60 * 1000 + property int stallSpeedBytes: 1 + property int stallTimeMs: 30000 + // xdg-open may stay alive with terminal handlers; only its initial + // launch window is part of the Open Local transfer contract. + readonly property int openHandoffTimeoutMs: 1000 + readonly property int maxTransferStderrBytes: 65536 + readonly property double safetyMarginBytes: 268435456 // 256 MiB + // QML int is signed 32-bit: reservation totals must remain IEEE-754 numbers. + readonly property double _reservationPerTransfer: root.maxTransferBytes + root.safetyMarginBytes + property double _activeReservedBytes: 0 + readonly property string _transferOutputHelper: Qt.resolvedUrl("../scripts/transfer_output.py").toString().replace(/^file:\/\//, "") + readonly property string _secureFinalizeHelper: Qt.resolvedUrl("../scripts/secure_finalize.py").toString().replace(/^file:\/\//, "") + // ===== SIGNALS ===== signal transferProgressChanged(var transfer) signal transferStateChanged(var transfer) signal transferRetryStarted(var transfer) + signal transferError(string message) + + function reportError(message) { + root.transferError(message) + } // ===== PROCESS FACTORY ===== property Component downloadProcessComponent: Component { Process { property var transferRef: null + property var pgid: 0 + stdout: StdioCollector {} stderr: StdioCollector { onTextChanged: { if (transferRef && text) { @@ -33,6 +59,11 @@ QtObject { } } } + onStarted: { + // Command is launched via setsid, so processId is a dedicated + // session/group leader and is a valid PGID for group kill. + pgid = processId + } onExited: function(exitCode, exitStatus) { if (transferRef) { root.handleDownloadExited(exitCode, transferRef) @@ -41,9 +72,24 @@ QtObject { } } + property Component _statFactory: Component { + Process { + property var onDone: null + stdout: StdioCollector {} + onExited: function(exitCode) { + var cb = onDone + var out = stdout.text.trim() + destroy() + if (cb) cb(exitCode === 0 ? out : null) + } + } + } + property Component openDownloadProcessComponent: Component { Process { property var transferRef: null + property var pgid: 0 + stdout: StdioCollector {} stderr: StdioCollector { onTextChanged: { if (transferRef && text) { @@ -52,6 +98,11 @@ QtObject { } } } + onStarted: { + // Command is launched via setsid, so processId is a dedicated + // session/group leader and is a valid PGID for group kill. + pgid = processId + } onExited: function(exitCode, exitStatus) { if (transferRef) { root.handleOpenDownloadExited(exitCode, transferRef) @@ -63,6 +114,9 @@ QtObject { property Component uploadProcessComponent: Component { Process { property var transferRef: null + property var pgid: 0 + // Response is producer-side bounded by curl --max-filesize + // (maxUploadResponseBytes) before it reaches this collector. stdout: StdioCollector {} stderr: StdioCollector { onTextChanged: { @@ -72,6 +126,11 @@ QtObject { } } } + onStarted: { + // Command is launched via setsid, so processId is a dedicated + // session/group leader and is a valid PGID for group kill. + pgid = processId + } onExited: function(exitCode, exitStatus) { if (transferRef) { root.handleUploadExited(exitCode, transferRef) @@ -87,7 +146,7 @@ QtObject { var fullPath = fileItem.fullPath || fileItem.path || fileItem.name || "" for (var i = 0; i < root.transfers.length; i++) { var t = root.transfers[i] - if (t.state !== "pending" && t.state !== "downloading" && t.state !== "uploading") continue + if (t.state !== "pending" && t.state !== "downloading" && t.state !== "uploading" && t.state !== "opening" && t.state !== "cancelling") continue if (t.repoId === fileItem.repoId && t.fileName === fileItem.name && (t.fullPath === fullPath || t.fullPath === "/" + fileItem.name)) return t } return null @@ -95,7 +154,7 @@ QtObject { function getActiveTransfers() { return root.transfers.filter(function(t) { - return t.state === "pending" || t.state === "downloading" || t.state === "uploading" + return t.state === "pending" || t.state === "downloading" || t.state === "uploading" || t.state === "opening" || t.state === "cancelling" }) } @@ -143,16 +202,53 @@ QtObject { // ===== COMMON ===== - function parseError(xhr) { - try { - var response = JSON.parse(xhr.responseText) + // Defense-in-depth: reject non-loopback HTTP before token-bearing transfer requests. + function _authUrlPolicy(baseUrl) { + if (!baseUrl) return { valid: false, error: "No server URL configured" } + return UrlPolicy.validateForAuth(baseUrl) + } + + // ===== CONCURRENT DISK RESERVATION ===== + // Each download/Open Local reserves maxTransferBytes + safetyMargin bytes + // before starting its helper. The helper's fstatvfs admission subtracts + // active reservations from free space, so concurrent transfers cannot + // collectively exhaust disk. Reservations are released exactly once on the + // terminal path (success, failure, cancellation, start failure, logout). + + function _reserveTransferCapacity(transfer) { + if (transfer._reserved) return true + transfer._reserved = true + transfer._reservedBytes = root._reservationPerTransfer + root._activeReservedBytes += transfer._reservedBytes + return true + } + + function _releaseTransferCapacity(transfer) { + if (transfer._reserved) { + root._activeReservedBytes -= transfer._reservedBytes + if (root._activeReservedBytes < 0) root._activeReservedBytes = 0 + transfer._reserved = false + transfer._reservedBytes = 0 + } + } + + function _currentlyReservedBytes(transfer) { + // Bytes reserved by OTHER active transfers (excluding this transfer) so + // a new admission is checked against aggregate reservations that exist + // on the target filesystem from concurrent transfers. + return Math.max(0, root._activeReservedBytes - (transfer._reservedBytes || 0)) + } + + function parseError(response) { + if (!response) return "Unknown error" + if (typeof response === "string") return response + if (typeof response === "object") { if (response.non_field_errors) return response.non_field_errors.join(", ") if (response.detail) return response.detail if (response.error_msg) return response.error_msg - return "Error " + xhr.status - } catch (e) { - return "Error " + xhr.status + ": " + xhr.responseText + if (response.error) return response.error } + return "Unknown error" } function isRetryableError(status, errorMsg) { @@ -169,49 +265,57 @@ QtObject { return status === 401 || status === 403 } - function resolveDestPath(dir, fileName) { - return dir + "/" + fileName - } - function curlFileForm(path) { return "file=@\"" + path.replace(/\\/g, "\\\\").replace(/\"/g, "\\\"") + "\"" } - // ===== AUTH HEADER FILE MANAGEMENT ===== - - property Component _authHeaderProcessFactory: Component { - Process { - id: proc - property var onDone: null - property string inputPayload: "" - stdinEnabled: true - - onStarted: { - proc.write(inputPayload) - proc.stdinEnabled = false - } - onExited: function(exitCode, exitStatus) { - var cb = proc.onDone - proc.destroy() - if (cb) cb(exitCode) +// Validates secure_output.py helper stdout: single basename line matching exactly [A-Za-z0-9_-]+ + // plus: max 128 chars, expected prefix, no multiline, no whitespace, no path separators, no "." or ".." + function validateHelperOutput(outText, expectedPrefix) { + if (!outText) return { valid: false, error: "Empty helper output" } + var trimmed = outText.trim() + if (trimmed !== outText) return { valid: false, error: "Helper output has leading/trailing whitespace" } + if (trimmed.indexOf("\n") !== -1 || trimmed.indexOf("\r") !== -1) return { valid: false, error: "Helper output contains multiple lines" } + if (trimmed.length > 128) return { valid: false, error: "Helper output exceeds maximum length" } + if (trimmed === "" || trimmed === "." || trimmed === "..") return { valid: false, error: "Invalid basename" } + if (trimmed.indexOf("/") !== -1 || trimmed.indexOf("\\") !== -1) return { valid: false, error: "Path separators not allowed in basename" } + for (var i = 0; i < trimmed.length; i++) { + var code = trimmed.charCodeAt(i) + // Only allow A-Z (0x41-0x5A), a-z (0x61-0x7A), 0-9 (0x30-0x39), _ (0x5F), - (0x2D) + if (!((code >= 0x41 && code <= 0x5A) || (code >= 0x61 && code <= 0x7A) || (code >= 0x30 && code <= 0x39) || code === 0x5F || code === 0x2D)) { + return { valid: false, error: "Invalid character in basename" } } } + if (expectedPrefix && !trimmed.startsWith(expectedPrefix + "_")) { + return { valid: false, error: "Basename does not match expected prefix" } + } + return { valid: true, basename: trimmed } } - property Component _deleteProcessFactory: Component { - Process { - id: proc - onExited: proc.destroy() + // Secret/config temp files are created exclusively through the hardened + // SafePath.createSecureFile + scripts/atomic_write.py path (see + // createAuthHeaderFile / createCurlConfigFile below). No mktemp/sh-cat + // pathname writers remain here. + + property Component _retryTimerFactory: Component { + Timer { + property var callback: null + repeat: false + onTriggered: { + var cb = callback + destroy() + if (cb) cb() + } } } property Component _finalizeDownloadProcessFactory: Component { Process { property var transferRef: null - onExited: function(exitCode, exitStatus) { - var transfer = transferRef + onExited: function(exitCode) { + var t = transferRef destroy() - if (transfer) root.handleDownloadFinalized(exitCode, transfer) + if (t) root.handleDownloadFinalized(exitCode, t) } } } @@ -219,75 +323,49 @@ QtObject { property Component _finalizeOpenDownloadProcessFactory: Component { Process { property var transferRef: null - onExited: function(exitCode, exitStatus) { - var transfer = transferRef + onExited: function(exitCode) { + var t = transferRef destroy() - if (transfer) root.handleOpenDownloadFinalized(exitCode, transfer) + if (t) root.handleOpenDownloadFinalized(exitCode, t) } } } - property Component _retryTimerFactory: Component { - Timer { - property var callback: null - repeat: false - onTriggered: { - var cb = callback - destroy() - if (cb) cb() - } + property Component _cleanupProcessFactory: Component { + Process { + onExited: destroy() } } - function deleteFile(filePath) { - if (!filePath) return - var proc = _deleteProcessFactory.createObject(root) + function runCleanup(command) { + var proc = _cleanupProcessFactory.createObject(root) if (!proc) return - proc.command = ["rm", "-f", "--", filePath] + proc.command = command proc.running = true } + function deleteFile(filePath) { + if (!filePath) return + runCleanup(["rm", "-f", "--", filePath]) + } + function scheduleRetry(delay, callback) { var timer = _retryTimerFactory.createObject(root, { interval: delay, callback: callback }) if (timer) timer.start() } + // ===== SECURE HEADER/CONFIG FILE CREATION ===== + function createAuthHeaderFile(token, callback) { - var runtimeDir = Quickshell.env("XDG_RUNTIME_DIR") || "/tmp" - var tempFile = runtimeDir + "/seafile_auth_" + Date.now() + "_" + Math.random().toString(36).substr(2, 9) + ".txt" - var proc = _authHeaderProcessFactory.createObject(root, { - inputPayload: "Authorization: Token " + token, - onDone: function(exitCode) { - if (exitCode === 0) { - callback(tempFile) - } else { - deleteFile(tempFile) - callback(null) - } - } + SafePath.createSecureFile("secrets", "seafile_auth", "Authorization: Token " + token, function(result) { + callback(result.valid ? result.path : null) }) - if (!proc) { - callback(null) - return - } - // Create the file under a restrictive umask before any token is written. - proc.command = ["sh", "-c", "umask 077; cat > \"$1\"", "sh", tempFile] - proc.running = true } function createCurlConfigFile(url, callback) { - var runtimeDir = Quickshell.env("XDG_RUNTIME_DIR") || "/tmp" - var tempFile = runtimeDir + "/seafile_curl_" + Date.now() + "_" + Math.random().toString(36).substr(2, 9) + ".conf" - var proc = _authHeaderProcessFactory.createObject(root, { - inputPayload: "url = " + JSON.stringify(url), - onDone: function(exitCode) { - if (exitCode === 0) callback(tempFile) - else { deleteFile(tempFile); callback(null) } - } + SafePath.createSecureFile("secrets", "seafile_curl", "url = " + JSON.stringify(url), function(result) { + callback(result.valid ? result.path : null) }) - if (!proc) { callback(null); return } - proc.command = ["sh", "-c", "umask 077; cat > \"$1\"", "sh", tempFile] - proc.running = true } function cleanupAuthHeaderFile(filePath) { @@ -296,7 +374,7 @@ QtObject { function cleanupTransferAuthFile(transfer) { if (transfer.authHeaderFile) { - cleanupAuthHeaderFile(transfer.authHeaderFile) + deleteFile(transfer.authHeaderFile) transfer.authHeaderFile = undefined } } @@ -324,19 +402,34 @@ QtObject { // ===== HISTORY MANAGEMENT ===== function sanitizeForHistory(transfer) { + root._releaseTransferCapacity(transfer) transfer.token = undefined transfer.process = null transfer.downloadLink = undefined transfer.uploadLink = undefined if (transfer.authHeaderFile) { - cleanupAuthHeaderFile(transfer.authHeaderFile) + deleteFile(transfer.authHeaderFile) } transfer.authHeaderFile = undefined - cleanupTransferConfigFile(transfer) + if (transfer.curlConfigFile) { + deleteFile(transfer.curlConfigFile) + } + transfer.curlConfigFile = undefined transfer.endTime = Date.now() return transfer } + function finishCancelled(transfer) { + root.releaseOpenCache(transfer) + transfer.state = "cancelled" + root.sanitizeForHistory(transfer) + } + + function releaseOpenCache(transfer, callback) { + if (!transfer || !transfer.cacheName) { if (callback) callback(true); return } + SafePath.releaseCache(transfer.cacheName, callback) + } + function pruneHistory() { var terminal = root.transfers.filter(function(t) { return t.state === "completed" || t.state === "failed" || t.state === "cancelled" || t.state === "auth_failed" @@ -354,66 +447,90 @@ QtObject { } } + // ===== SAFE PATH RESOLUTION ===== + + function resolveDestPath(dir, fileName, callback) { + SafePath.secureJoin(dir, fileName, callback) + } + // ===== DOWNLOAD ===== function startDownload(fileItem, token, baseUrl, repoId, destDir, fullPath, downloadLink) { - var download = { - id: Date.now() + Math.random(), - type: "download", - state: "pending", - fileName: fileItem.name, - fullPath: fullPath, - destDir: destDir, - destPath: "", - tempPath: "", - repoId: repoId, - repoName: "", - token: token, - baseUrl: baseUrl, - process: null, - downloadLink: null, - progress: 0, - speed: "", - error: "", - retryCount: 0, - startTime: Date.now(), - endTime: null, - authHeaderFile: null, - curlConfigFile: null - } + SafePath.secureJoin(destDir, fileItem.name, function(destResult) { + if (!destResult.valid) { + var errTransfer = { error: destResult.error, state: "failed" } + root.reportError("Invalid destination: " + destResult.error) + return + } - root.transfers.push(download) - root.transfersChanged() - if (typeof downloadLink === "string" && downloadLink !== "") { - download.downloadLink = downloadLink - download.destPath = root.resolveDestPath(download.destDir, download.fileName) - download.tempPath = download.destPath + ".part-" + download.id - download.state = "downloading" - root.transferStateChanged(download) + var download = { + id: Date.now() + Math.random(), + type: "download", + state: "pending", + fileName: fileItem.name, + fullPath: fullPath, + destDir: destDir, + destPath: destResult.path, + tempPath: "", + repoId: repoId, + repoName: "", + token: token, + baseUrl: baseUrl, + process: null, + downloadLink: null, + progress: 0, + speed: "", + error: "", + retryCount: 0, + startTime: Date.now(), + endTime: null, + authHeaderFile: null, + curlConfigFile: null + } + + root.transfers.push(download) root.transfersChanged() - root.executeCurlDownload(download) - } else { - root.getDownloadLinkAndExecute(download) - } - return download + + if (typeof downloadLink === "string" && downloadLink !== "") { + var vUrl = UrlPolicy.validateTransferUrl(downloadLink) + if (!vUrl.valid) { + download.state = "failed" + download.error = "Invalid download URL: " + vUrl.error + root.sanitizeForHistory(download) + root.transferStateChanged(download) + root.transfersChanged() + return + } + download.downloadLink = downloadLink + download.state = "downloading" + root.transferStateChanged(download) + root.transfersChanged() + root.executeCurlDownload(download) + } else { + root.getDownloadLinkAndExecute(download) + } + return download + }) } function getDownloadLinkAndExecute(download) { if (download.state === "cancelled") return - var xhr = new XMLHttpRequest() + var policy = root._authUrlPolicy(download.baseUrl) + if (!policy.valid) { + download.state = "failed" + download.error = policy.error + root.sanitizeForHistory(download) + root.transferStateChanged(download) + root.transfersChanged() + return + } var path = download.fullPath || "/" + download.fileName var url = download.baseUrl.replace(/\/+$/, "") + "/api2/repos/" + download.repoId + "/file/?p=" + encodeURIComponent(path) + "&reuse=1" - xhr.open("GET", url, true) - xhr.setRequestHeader("Authorization", "Token " + download.token) - xhr.setRequestHeader("Accept", "application/json") - xhr.timeout = 10000 - xhr.onreadystatechange = function() { - if (xhr.readyState === XMLHttpRequest.DONE) { + HttpTransport.get(url, { "Authorization": "Token " + download.token, "Accept": "application/json" }, + function(success, data, error) { if (download.state === "cancelled") return - if (xhr.status >= 200 && xhr.status < 300) { - var link - try { link = JSON.parse(xhr.responseText) } catch (e) { link = null } - if (typeof link !== "string" || link === "") { + if (success) { + if (typeof data !== "string" || data === "") { download.state = "failed" download.error = "Invalid server response" root.sanitizeForHistory(download) @@ -421,20 +538,27 @@ QtObject { root.transfersChanged() return } - download.downloadLink = link - download.destPath = root.resolveDestPath(download.destDir, download.fileName) - download.tempPath = download.destPath + ".part-" + download.id + var vUrl = UrlPolicy.validateTransferUrl(data) + if (!vUrl.valid) { + download.state = "failed" + download.error = "Invalid download URL: " + vUrl.error + root.sanitizeForHistory(download) + root.transferStateChanged(download) + root.transfersChanged() + return + } + download.downloadLink = data download.state = "downloading" root.transferStateChanged(download) root.transfersChanged() root.executeCurlDownload(download) - } else if (root.isAuthError(xhr.status)) { + } else if (root.isAuthError(error)) { download.state = "auth_failed" download.error = "Authentication failed" root.sanitizeForHistory(download) root.transferStateChanged(download) root.transfersChanged() - } else if (root.isRetryableError(xhr.status, root.parseError(xhr)) && download.retryCount < root.maxRetries) { + } else if (root.isRetryableError(0, error) && download.retryCount < root.maxRetries) { download.retryCount++ var delay = Math.min(root.retryBaseDelay * Math.pow(2, download.retryCount - 1), root.maxRetryDelay) download.state = "pending" @@ -444,18 +568,27 @@ QtObject { scheduleRetry(delay, function() { root.getDownloadLinkAndExecute(download) }) } else { download.state = "failed" - download.error = root.parseError(xhr) + download.error = error || "Download link request failed" root.sanitizeForHistory(download) root.transferStateChanged(download) root.transfersChanged() } } - } - xhr.send() + ) } function executeCurlDownload(download) { if (download.state !== "pending" && download.state !== "downloading") return + + // Only attach auth header if transfer URL is same-origin as Seafile base + var attachAuth = UrlPolicy.shouldAttachAuth(download.downloadLink, download.baseUrl) + + if (!attachAuth) { + // Cross-origin: no auth header + executeCurlDownloadNoAuth(download) + return + } + createAuthHeaderFile(download.token, function(authHeaderFile) { if (download.state !== "pending" && download.state !== "downloading") { cleanupAuthHeaderFile(authHeaderFile) @@ -483,6 +616,7 @@ QtObject { download.curlConfigFile = curlConfigFile var curlProc = downloadProcessComponent.createObject(root) if (!curlProc) { + root._releaseTransferCapacity(download) download.state = "failed" download.error = "Failed to create download process" root.sanitizeForHistory(download) @@ -491,15 +625,31 @@ QtObject { return } curlProc.transferRef = download + root._reserveTransferCapacity(download) + var scriptsBase = Qt.resolvedUrl("../scripts") + var outputHelper = scriptsBase + "/secure_output.py" curlProc.command = [ + "setsid", "python3", + outputHelper.replace(/^file:\/\//, ""), + download.destDir, "dl", + "--max-stderr-bytes", root.maxTransferStderrBytes, + "--max-transfer-bytes", root.maxTransferBytes, + "--safety-margin", "268435456", + "--already-reserved-bytes", String(root._currentlyReservedBytes(download)), + "--", "curl", "-q", "-f", "-H", "@" + authHeaderFile, "-H", "Accept: */*", "--progress-bar", - "--output", download.tempPath, - "--config", curlConfigFile + "--config", curlConfigFile, + "--max-filesize", root.maxTransferBytes, + "--connect-timeout", Math.ceil(root.connectTimeoutMs / 1000), + "--max-time", Math.ceil(root.totalTimeoutMs / 1000), + "--speed-limit", root.stallSpeedBytes, + "--speed-time", Math.ceil(root.stallTimeMs / 1000), + "--no-location" ] download.process = curlProc curlProc.running = true @@ -507,18 +657,84 @@ QtObject { }) } + // Cross-origin download: no auth header attached + function executeCurlDownloadNoAuth(download) { + if (download.state !== "pending" && download.state !== "downloading") return + createCurlConfigFile(download.downloadLink, function(curlConfigFile) { + if (download.state !== "pending" && download.state !== "downloading") { deleteFile(curlConfigFile); return } + if (!curlConfigFile) { + download.state = "failed" + download.error = "Failed to create curl configuration" + root.sanitizeForHistory(download) + root.transferStateChanged(download) + root.transfersChanged() + return + } + download.curlConfigFile = curlConfigFile + var curlProc = downloadProcessComponent.createObject(root) + if (!curlProc) { + root._releaseTransferCapacity(download) + download.state = "failed" + download.error = "Failed to create download process" + root.sanitizeForHistory(download) + root.transferStateChanged(download) + root.transfersChanged() + return + } + curlProc.transferRef = download + root._reserveTransferCapacity(download) + var scriptsBase = Qt.resolvedUrl("../scripts") + var outputHelper = scriptsBase + "/secure_output.py" + curlProc.command = [ + "setsid", "python3", + outputHelper.replace(/^file:\/\//, ""), + download.destDir, "dl", + "--max-stderr-bytes", root.maxTransferStderrBytes, + "--max-transfer-bytes", root.maxTransferBytes, + "--safety-margin", "268435456", + "--already-reserved-bytes", String(root._currentlyReservedBytes(download)), + "--", + "curl", + "-q", + "-f", + "-H", "Accept: */*", + "--progress-bar", + "--config", curlConfigFile, + "--max-filesize", root.maxTransferBytes, + "--connect-timeout", Math.ceil(root.connectTimeoutMs / 1000), + "--max-time", Math.ceil(root.totalTimeoutMs / 1000), + "--speed-limit", root.stallSpeedBytes, + "--speed-time", Math.ceil(root.stallTimeMs / 1000), + "--no-location" + ] + download.process = curlProc + curlProc.running = true + }) + } + function handleDownloadExited(exitCode, download) { var process = download.process download.process = null + var outText = process ? process.stdout.text : "" if (process) process.destroy() cleanupTransferAuthFile(download) cleanupTransferConfigFile(download) - if (download.state === "cancelled") { + if (download.state === "cancelling") { deleteFile(download.tempPath) + root.finishCancelled(download) } else if (exitCode === 0) { - root.finalizeDownload(download) - return + var validation = root.validateHelperOutput(outText, "dl") + if (!validation.valid) { + download.state = "failed" + download.error = "Invalid helper output: " + validation.error + root.sanitizeForHistory(download) + } else { + var tempPath = download.destDir + "/" + validation.basename + download.tempPath = tempPath + root.finalizeDownload(download) + return + } } else { if (download.retryCount < root.maxRetries) { download.retryCount++ @@ -558,8 +774,9 @@ QtObject { function handleDownloadFinalized(exitCode, download) { download.process = null - if (download.state === "cancelled") { + if (download.state === "cancelling") { deleteFile(download.tempPath) + root.finishCancelled(download) } else if (exitCode === 0) { download.state = "completed" download.progress = 1.0 @@ -578,51 +795,103 @@ QtObject { // ===== UPLOAD ===== + function parseUploadStat(out) { + if (typeof out !== "string") return null + var parts = out.trim().split(":") + if (parts.length !== 2 || !/^[0-9a-fA-F]+$/.test(parts[0]) || !/^[0-9]+$/.test(parts[1])) return null + return { + regular: (parseInt(parts[0], 16) & 0xF000) === 0x8000, + size: Number(parts[1]) + } + } + function startUpload(localFilePath, token, baseUrl, repoId, destPath, fileName) { - var upload = { - id: Date.now() + Math.random(), - type: "upload", - state: "pending", - srcPath: localFilePath, - destUploadPath: destPath, - fileName: fileName, - repoId: repoId, - repoName: "", - token: token, - baseUrl: baseUrl, - process: null, - uploadLink: null, - progress: 0, - speed: "", - error: "", - retryCount: 0, - startTime: Date.now(), - endTime: null, - authHeaderFile: null, - curlConfigFile: null + // Validate upload source: absolute path, regular file, not symlink, size limit + if (!localFilePath || typeof localFilePath !== "string" || !localFilePath.startsWith("/")) { + var errTransfer = { error: "Upload source must be an absolute path", state: "failed" } + root.reportError("Invalid upload source: must be absolute path") + return } + var statProc = _statFactory.createObject(root, { + onDone: function(out) { + if (!out) { + var errTransfer = { error: "Upload source does not exist or cannot be accessed", state: "failed" } + root.reportError("Invalid upload source: " + errTransfer.error) + return + } + var statResult = root.parseUploadStat(out) + if (!statResult) { + root.reportError("Invalid upload source: file metadata could not be validated") + return + } + if (!statResult.regular) { + var errTransfer = { error: "Upload source must be a regular file (not symlink, directory, device, FIFO, or socket)", state: "failed" } + root.reportError("Invalid upload source: " + errTransfer.error) + return + } + if (statResult.size > root.maxUploadBodyBytes) { + var errTransfer = { error: "Upload source exceeds maximum size of " + root.maxUploadBodyBytes + " bytes", state: "failed" } + root.reportError("Upload too large: " + errTransfer.error) + return + } - root.transfers.push(upload) - root.transfersChanged() - root.getUploadLinkAndExecute(upload) - return upload + var nameResult = SafePath.sanitizeBasename(fileName) + if (!nameResult.valid) { + var errTransfer = { error: nameResult.error, state: "failed" } + root.reportError("Invalid filename: " + nameResult.error) + return + } + + var upload = { + id: Date.now() + Math.random(), + type: "upload", + state: "pending", + srcPath: localFilePath, + destUploadPath: destPath, + fileName: nameResult.sanitized, + repoId: repoId, + repoName: "", + token: token, + baseUrl: baseUrl, + process: null, + uploadLink: null, + progress: 0, + speed: "", + error: "", + retryCount: 0, + startTime: Date.now(), + endTime: null, + authHeaderFile: null, + curlConfigFile: null + } + + root.transfers.push(upload) + root.transfersChanged() + root.getUploadLinkAndExecute(upload) + return upload + } + }) + statProc.command = ["stat", "-c", "%f:%s", "--", localFilePath] + statProc.running = true } function getUploadLinkAndExecute(upload) { if (upload.state === "cancelled") return - var xhr = new XMLHttpRequest() + var policy = root._authUrlPolicy(upload.baseUrl) + if (!policy.valid) { + upload.state = "failed" + upload.error = policy.error + root.sanitizeForHistory(upload) + root.transferStateChanged(upload) + root.transfersChanged() + return + } var url = upload.baseUrl.replace(/\/+$/, "") + "/api2/repos/" + upload.repoId + "/upload-link/?p=" + encodeURIComponent(upload.destUploadPath) - xhr.open("GET", url, true) - xhr.setRequestHeader("Authorization", "Token " + upload.token) - xhr.setRequestHeader("Accept", "application/json") - xhr.timeout = 10000 - xhr.onreadystatechange = function() { - if (xhr.readyState === XMLHttpRequest.DONE) { + HttpTransport.get(url, { "Authorization": "Token " + upload.token, "Accept": "application/json" }, + function(success, data, error) { if (upload.state === "cancelled") return - if (xhr.status >= 200 && xhr.status < 300) { - var link - try { link = JSON.parse(xhr.responseText) } catch (e) { link = null } - if (typeof link !== "string" || link === "") { + if (success) { + if (typeof data !== "string" || data === "") { upload.state = "failed" upload.error = "Invalid server response" root.sanitizeForHistory(upload) @@ -630,18 +899,27 @@ QtObject { root.transfersChanged() return } - upload.uploadLink = link + var vUrl = UrlPolicy.validateTransferUrl(data) + if (!vUrl.valid) { + upload.state = "failed" + upload.error = "Invalid upload URL: " + vUrl.error + root.sanitizeForHistory(upload) + root.transferStateChanged(upload) + root.transfersChanged() + return + } + upload.uploadLink = data upload.state = "uploading" root.transferStateChanged(upload) root.transfersChanged() root.executeCurlUpload(upload) - } else if (root.isAuthError(xhr.status)) { + } else if (root.isAuthError(error)) { upload.state = "auth_failed" upload.error = "Authentication failed" root.sanitizeForHistory(upload) root.transferStateChanged(upload) root.transfersChanged() - } else if (root.isRetryableError(xhr.status, root.parseError(xhr)) && upload.retryCount < root.maxRetries) { + } else if (root.isRetryableError(0, error) && upload.retryCount < root.maxRetries) { upload.retryCount++ var delay = Math.min(root.retryBaseDelay * Math.pow(2, upload.retryCount - 1), root.maxRetryDelay) upload.state = "pending" @@ -651,18 +929,28 @@ QtObject { scheduleRetry(delay, function() { root.getUploadLinkAndExecute(upload) }) } else { upload.state = "failed" - upload.error = root.parseError(xhr) + upload.error = error || "Upload link request failed" root.sanitizeForHistory(upload) root.transferStateChanged(upload) root.transfersChanged() } } - } - xhr.send() + ) } function executeCurlUpload(upload) { if (upload.state !== "pending" && upload.state !== "uploading") return + + // Only attach auth header if transfer URL is same-origin as Seafile base + var uploadUrl = upload.uploadLink + (upload.uploadLink.indexOf("?") === -1 ? "?" : "&") + "ret-json=1" + var attachAuth = UrlPolicy.shouldAttachAuth(uploadUrl, upload.baseUrl) + + if (!attachAuth) { + // Cross-origin: no auth header + executeCurlUploadNoAuth(upload) + return + } + createAuthHeaderFile(upload.token, function(authHeaderFile) { if (upload.state !== "pending" && upload.state !== "uploading") { cleanupAuthHeaderFile(authHeaderFile) @@ -677,7 +965,7 @@ QtObject { return } upload.authHeaderFile = authHeaderFile - createCurlConfigFile(upload.uploadLink + (upload.uploadLink.indexOf("?") === -1 ? "?" : "&") + "ret-json=1", function(curlConfigFile) { + createCurlConfigFile(uploadUrl, function(curlConfigFile) { if (upload.state !== "pending" && upload.state !== "uploading") { deleteFile(curlConfigFile); return } if (!curlConfigFile) { upload.state = "failed" @@ -699,6 +987,8 @@ QtObject { } curlProc.transferRef = upload curlProc.command = [ + "setsid", "python3", root._transferOutputHelper, + root.maxTransferStderrBytes, "--", "curl", "-q", "-f", @@ -708,7 +998,13 @@ QtObject { "--form", root.curlFileForm(upload.srcPath), "--form-string", "parent_dir=" + upload.destUploadPath, "--form-string", "replace=0", - "--config", curlConfigFile + "--config", curlConfigFile, + "--max-filesize", root.maxUploadResponseBytes, + "--connect-timeout", Math.ceil(root.connectTimeoutMs / 1000), + "--max-time", Math.ceil(root.totalTimeoutMs / 1000), + "--speed-limit", root.stallSpeedBytes, + "--speed-time", Math.ceil(root.stallTimeMs / 1000), + "--no-location" ] upload.process = curlProc curlProc.running = true @@ -716,32 +1012,94 @@ QtObject { }) } + // Cross-origin upload: no auth header attached + function executeCurlUploadNoAuth(upload) { + if (upload.state !== "pending" && upload.state !== "uploading") return + var uploadUrl = upload.uploadLink + (upload.uploadLink.indexOf("?") === -1 ? "?" : "&") + "ret-json=1" + createCurlConfigFile(uploadUrl, function(curlConfigFile) { + if (upload.state !== "pending" && upload.state !== "uploading") { deleteFile(curlConfigFile); return } + if (!curlConfigFile) { + upload.state = "failed" + upload.error = "Failed to create curl configuration" + root.sanitizeForHistory(upload) + root.transferStateChanged(upload) + root.transfersChanged() + return + } + upload.curlConfigFile = curlConfigFile + var curlProc = uploadProcessComponent.createObject(root) + if (!curlProc) { + upload.state = "failed" + upload.error = "Failed to create upload process" + root.sanitizeForHistory(upload) + root.transferStateChanged(upload) + root.transfersChanged() + return + } + curlProc.transferRef = upload + curlProc.command = [ + "setsid", "python3", root._transferOutputHelper, + root.maxTransferStderrBytes, "--", + "curl", + "-q", + "-f", + "-H", "Accept: application/json", + "--progress-bar", + "--form", root.curlFileForm(upload.srcPath), + "--form-string", "parent_dir=" + upload.destUploadPath, + "--form-string", "replace=0", + "--config", curlConfigFile, + "--max-filesize", root.maxUploadResponseBytes, + "--connect-timeout", Math.ceil(root.connectTimeoutMs / 1000), + "--max-time", Math.ceil(root.totalTimeoutMs / 1000), + "--speed-limit", root.stallSpeedBytes, + "--speed-time", Math.ceil(root.stallTimeMs / 1000), + "--no-location" + ] + upload.process = curlProc + curlProc.running = true + }) + } + function handleUploadExited(exitCode, upload) { var process = upload.process upload.process = null cleanupTransferAuthFile(upload) cleanupTransferConfigFile(upload) - if (upload.state === "cancelled") { + if (upload.state === "cancelling") { if (process) process.destroy() + root.finishCancelled(upload) } else if (exitCode === 0) { var response try { response = JSON.parse(process ? process.stdout.text : "") } catch (e) {} if (process) process.destroy() - if (Array.isArray(response) && response.length > 0 && typeof response[0].name === "string" && response[0].name.length > 0) { - upload.fileName = response[0].name - upload.state = "completed" - upload.progress = 1.0 - upload.speed = "" - root.sanitizeForHistory(upload) - root.pruneHistory() + if (Array.isArray(response) && response.length > 0 && response.length <= 10) { + var item = response[0] + if (item && typeof item === "object" && typeof item.name === "string" && item.name.length > 0 && item.name.length <= 1024) { + upload.fileName = item.name + upload.state = "completed" + upload.progress = 1.0 + upload.speed = "" + root.sanitizeForHistory(upload) + root.pruneHistory() + } else { + upload.state = "failed" + upload.error = "Upload server response was invalid" + root.sanitizeForHistory(upload) + } } else { upload.state = "failed" upload.error = "Upload server response was invalid" root.sanitizeForHistory(upload) } + } else if (upload.state !== "cancelled" && exitCode === 63) { + if (process) process.destroy() + upload.state = "failed" + upload.error = "Upload response too large (exceeds " + root.maxUploadResponseBytes + " bytes)" + root.sanitizeForHistory(upload) } else if (upload.state !== "cancelled") { if (process) process.destroy() upload.state = "failed" @@ -752,19 +1110,32 @@ QtObject { root.transfersChanged() } - // ===== CANCEL ===== + // ===== CANCEL (with process group kill) ===== function cancelTransfer(transferId) { for (var i = 0; i < root.transfers.length; i++) { var t = root.transfers[i] if (t.id === transferId) { - t.state = "cancelled" if (t.process) { - t.process.kill() - t.process.destroy() - t.process = null + t.state = "cancelling" + try { + var pgid = t.process.pgid + if (pgid > 0) { + root.runCleanup(["kill", "-TERM", "-" + pgid]) + } else { + t.process.running = false + } + } catch (e) { + try { t.process.running = false } catch (e) {} + } + // The Process onExited handler owns terminal cleanup and release. + root.transferStateChanged(t) + root.transfersChanged() + return true } + t.state = "cancelled" if (t.type === "download" && t.tempPath) deleteFile(t.tempPath) + root.releaseOpenCache(t) cleanupTransferAuthFile(t) root.sanitizeForHistory(t) root.transferStateChanged(t) @@ -849,7 +1220,7 @@ QtObject { } } root.transfers = root.transfers.filter(function(t) { - return t.state === "pending" || t.state === "downloading" || t.state === "uploading" + return t.state === "pending" || t.state === "downloading" || t.state === "uploading" || t.state === "opening" }) root.transfersChanged() } @@ -857,22 +1228,17 @@ QtObject { // ===== OPEN FILE (DOWNLOAD TO CACHE + XDG-OPEN) ===== function startOpen(fileItem, token, baseUrl, repoId, fullPath) { - var cacheDir = Quickshell.env("XDG_CACHE_HOME") || (Quickshell.env("HOME") + "/.cache") - cacheDir = cacheDir + "/omarseafile" - // Unique cache filename per open to avoid collisions and ensure fresh content - var uniqueSuffix = Date.now() + "_" + Math.random().toString(36).substr(2, 9) - var cachePath = cacheDir + "/" + uniqueSuffix + "_" + fileItem.name - var tempPath = cachePath + ".part-" + Date.now() - var download = { id: Date.now() + Math.random(), type: "download", state: "pending", fileName: fileItem.name, fullPath: fullPath, - cacheDir: cacheDir, - cachePath: cachePath, - tempPath: tempPath, + cacheDir: "", + cachePath: "", + cacheName: "", + tempPath: "", + tempName: "", repoId: repoId, repoName: "", token: token, @@ -888,30 +1254,74 @@ QtObject { authHeaderFile: null, curlConfigFile: null } - root.transfers.push(download) root.transfersChanged() - root.getDownloadLinkAndOpen(download) - + // Recover abandoned cache entries before admitting a new persistent file. + SafePath.evictCache(function(ok) { + if (download.state !== "pending") return + if (!ok) { + download.state = "failed" + download.error = "Cache recovery could not free enough space" + root.sanitizeForHistory(download) + root.transferStateChanged(download) + root.transfersChanged() + return + } + root._startOpenAfterRecovery(download) + }) return download } + function _startOpenAfterRecovery(download) { + SafePath.getCacheDir(function(cacheResult) { + if (download.state !== "pending") return + if (!cacheResult.valid) { + download.state = "failed" + download.error = "Cache directory unavailable: " + cacheResult.error + root.sanitizeForHistory(download) + root.transferStateChanged(download) + root.transfersChanged() + return + } + SafePath.secureJoin(cacheResult.path, download.fileName, function(nameResult) { + if (download.state !== "pending") return + if (!nameResult.valid) { + download.state = "failed" + download.error = "Invalid filename: " + nameResult.error + root.sanitizeForHistory(download) + root.transferStateChanged(download) + root.transfersChanged() + return + } + var extensionMatch = /\.([A-Za-z0-9]{1,16})$/.exec(nameResult.name) + var cacheName = "open_" + Date.now() + "_" + Math.random().toString(36).substr(2, 9) + + (extensionMatch ? "." + extensionMatch[1] : "") + download.cacheDir = cacheResult.path + download.cacheName = cacheName + download.cachePath = cacheResult.path + "/" + cacheName + root.getDownloadLinkAndOpen(download) + }) + }) + } + function getDownloadLinkAndOpen(download) { if (download.state === "cancelled") return - var xhr = new XMLHttpRequest() + var policy = root._authUrlPolicy(download.baseUrl) + if (!policy.valid) { + download.state = "failed" + download.error = policy.error + root.sanitizeForHistory(download) + root.transferStateChanged(download) + root.transfersChanged() + return + } var path = download.fullPath || "/" + download.fileName var url = download.baseUrl.replace(/\/+$/, "") + "/api2/repos/" + download.repoId + "/file/?p=" + encodeURIComponent(path) + "&reuse=1" - xhr.open("GET", url, true) - xhr.setRequestHeader("Authorization", "Token " + download.token) - xhr.setRequestHeader("Accept", "application/json") - xhr.timeout = 10000 - xhr.onreadystatechange = function() { - if (xhr.readyState === XMLHttpRequest.DONE) { + HttpTransport.get(url, { "Authorization": "Token " + download.token, "Accept": "application/json" }, + function(success, data, error) { if (download.state === "cancelled") return - if (xhr.status >= 200 && xhr.status < 300) { - var link - try { link = JSON.parse(xhr.responseText) } catch (e) { link = null } - if (typeof link !== "string" || link === "") { + if (success) { + if (typeof data !== "string" || data === "") { download.state = "failed" download.error = "Invalid server response" root.sanitizeForHistory(download) @@ -919,42 +1329,60 @@ QtObject { root.transfersChanged() return } - download.downloadLink = link + var vUrl = UrlPolicy.validateTransferUrl(data) + if (!vUrl.valid) { + download.state = "failed" + download.error = "Invalid download URL: " + vUrl.error + root.sanitizeForHistory(download) + root.transferStateChanged(download) + root.transfersChanged() + return + } + download.downloadLink = data download.state = "downloading" root.transferStateChanged(download) root.transfersChanged() root.executeCurlOpenDownload(download) - } else if (root.isAuthError(xhr.status)) { + } else if (root.isAuthError(error)) { download.state = "auth_failed" download.error = "Authentication failed" root.sanitizeForHistory(download) root.transferStateChanged(download) root.transfersChanged() - } else if (root.isRetryableError(xhr.status, root.parseError(xhr)) && download.retryCount < root.maxRetries) { + } else if (root.isRetryableError(0, error) && download.retryCount < root.maxRetries) { download.retryCount++ var delay = Math.min(root.retryBaseDelay * Math.pow(2, download.retryCount - 1), root.maxRetryDelay) download.state = "pending" root.transferRetryStarted(download) root.transferStateChanged(download) root.transfersChanged() - root.scheduleRetry(delay, function() { root.getDownloadLinkAndOpen(download) }) + scheduleRetry(delay, function() { root.getDownloadLinkAndOpen(download) }) } else { download.state = "failed" - download.error = root.parseError(xhr) + download.error = error || "Download link request failed" root.sanitizeForHistory(download) root.transferStateChanged(download) root.transfersChanged() } } - } - xhr.send() + ) } function executeCurlOpenDownload(download) { if (download.state !== "pending" && download.state !== "downloading") return - root.createAuthHeaderFile(download.token, function(authHeaderFile) { + + // Only attach auth header if transfer URL is same-origin as Seafile base + var attachAuth = UrlPolicy.shouldAttachAuth(download.downloadLink, download.baseUrl) + + if (!attachAuth) { + // Cross-origin: no auth header + executeCurlOpenDownloadNoAuth(download) + return + } + + createAuthHeaderFile(download.token, function(authHeaderFile) { if (download.state !== "pending" && download.state !== "downloading") { - root.cleanupAuthHeaderFile(authHeaderFile) + cleanupAuthHeaderFile(authHeaderFile) return } if (!authHeaderFile) { @@ -966,8 +1394,8 @@ QtObject { return } download.authHeaderFile = authHeaderFile - root.createCurlConfigFile(download.downloadLink, function(curlConfigFile) { - if (download.state !== "pending" && download.state !== "downloading") { root.deleteFile(curlConfigFile); return } + createCurlConfigFile(download.downloadLink, function(curlConfigFile) { + if (download.state !== "pending" && download.state !== "downloading") { deleteFile(curlConfigFile); return } if (!curlConfigFile) { download.state = "failed" download.error = "Failed to create curl configuration" @@ -979,6 +1407,7 @@ QtObject { download.curlConfigFile = curlConfigFile var curlProc = openDownloadProcessComponent.createObject(root) if (!curlProc) { + root._releaseTransferCapacity(download) download.state = "failed" download.error = "Failed to create download process" root.sanitizeForHistory(download) @@ -987,15 +1416,32 @@ QtObject { return } curlProc.transferRef = download + root._reserveTransferCapacity(download) + var scriptsBase = Qt.resolvedUrl("../scripts") + var outputHelper = scriptsBase + "/secure_output.py" curlProc.command = [ + "setsid", "python3", + outputHelper.replace(/^file:\/\//, ""), + download.cacheDir, "dl", + "--active-marker", + "--max-stderr-bytes", root.maxTransferStderrBytes, + "--max-transfer-bytes", root.maxTransferBytes, + "--safety-margin", "268435456", + "--already-reserved-bytes", String(root._currentlyReservedBytes(download)), + "--", "curl", "-q", "-f", "-H", "@" + authHeaderFile, "-H", "Accept: */*", "--progress-bar", - "--output", download.tempPath, - "--config", curlConfigFile + "--config", curlConfigFile, + "--max-filesize", root.maxTransferBytes, + "--connect-timeout", Math.ceil(root.connectTimeoutMs / 1000), + "--max-time", Math.ceil(root.totalTimeoutMs / 1000), + "--speed-limit", root.stallSpeedBytes, + "--speed-time", Math.ceil(root.stallTimeMs / 1000), + "--no-location" ] download.process = curlProc curlProc.running = true @@ -1003,18 +1449,86 @@ QtObject { }) } + // Cross-origin open download: no auth header attached + function executeCurlOpenDownloadNoAuth(download) { + if (download.state !== "pending" && download.state !== "downloading") return + createCurlConfigFile(download.downloadLink, function(curlConfigFile) { + if (download.state !== "pending" && download.state !== "downloading") { deleteFile(curlConfigFile); return } + if (!curlConfigFile) { + download.state = "failed" + download.error = "Failed to create curl configuration" + root.sanitizeForHistory(download) + root.transferStateChanged(download) + root.transfersChanged() + return + } + download.curlConfigFile = curlConfigFile + var curlProc = openDownloadProcessComponent.createObject(root) + if (!curlProc) { + root._releaseTransferCapacity(download) + download.state = "failed" + download.error = "Failed to create download process" + root.sanitizeForHistory(download) + root.transferStateChanged(download) + root.transfersChanged() + return + } + curlProc.transferRef = download + root._reserveTransferCapacity(download) + var scriptsBase = Qt.resolvedUrl("../scripts") + var outputHelper = scriptsBase + "/secure_output.py" + curlProc.command = [ + "setsid", "python3", + outputHelper.replace(/^file:\/\//, ""), + download.cacheDir, "dl", + "--active-marker", + "--max-stderr-bytes", root.maxTransferStderrBytes, + "--max-transfer-bytes", root.maxTransferBytes, + "--safety-margin", "268435456", + "--already-reserved-bytes", String(root._currentlyReservedBytes(download)), + "--", + "curl", + "-q", + "-f", + "-H", "Accept: */*", + "--progress-bar", + "--config", curlConfigFile, + "--max-filesize", root.maxTransferBytes, + "--connect-timeout", Math.ceil(root.connectTimeoutMs / 1000), + "--max-time", Math.ceil(root.totalTimeoutMs / 1000), + "--speed-limit", root.stallSpeedBytes, + "--speed-time", Math.ceil(root.stallTimeMs / 1000), + "--no-location" + ] + download.process = curlProc + curlProc.running = true + }) + } + function handleOpenDownloadExited(exitCode, download) { var process = download.process download.process = null + var outText = process ? process.stdout.text : "" if (process) process.destroy() root.cleanupTransferAuthFile(download) root.cleanupTransferConfigFile(download) - if (download.state === "cancelled") { - root.deleteFile(download.tempPath) + if (download.state === "cancelling") { + root.cleanupOpenTemp(download) + root.finishCancelled(download) } else if (exitCode === 0) { - root.finalizeOpenDownload(download) - return + var validation = root.validateHelperOutput(outText, "dl") + if (!validation.valid) { + download.state = "failed" + download.error = "Invalid helper output: " + validation.error + root.sanitizeForHistory(download) + } else { + var tempPath = download.cacheDir + "/" + validation.basename + download.tempPath = tempPath + download.tempName = validation.basename + root.finalizeOpenDownload(download) + return + } } else { if (download.retryCount < root.maxRetries) { download.retryCount++ @@ -1040,34 +1554,43 @@ QtObject { if (!proc) { download.state = "failed" download.error = "Failed to finalize download" - root.deleteFile(download.tempPath) + root.cleanupOpenTemp(download) root.sanitizeForHistory(download) root.transferStateChanged(download) root.transfersChanged() return } proc.transferRef = download - proc.command = ["sh", "-c", "mkdir -p -m 0700 -- \"$(dirname \"$1\")\" && mv -f -- \"$1\" \"$2\" && chmod 600 -- \"$2\"", "sh", download.tempPath, download.cachePath] + proc.command = ["python3", root._secureFinalizeHelper, download.cacheDir, + download.tempName, download.cacheName] download.process = proc proc.running = true } function handleOpenDownloadFinalized(exitCode, download) { download.process = null - if (download.state === "cancelled") { - root.deleteFile(download.tempPath) + if (download.state === "cancelling") { + // Only a successful finalizer owns cachePath; a failed finalizer + // may have encountered an existing entry with the same name. + root.cleanupOpenTemp(download, exitCode === 0) + root.finishCancelled(download) } else if (exitCode === 0) { - download.state = "completed" + download.state = "opening" download.progress = 1.0 download.speed = "" download.destPath = download.cachePath - root.sanitizeForHistory(download) - root.pruneHistory() root.openCachedFile(download) + // Keep the just-opened cache path out of this eviction pass. + SafePath.evictCache([download.cacheName], function(ok) { + if (!ok) { + // Eviction failed but download succeeded; log and continue + console.warn("Cache eviction failed, continuing") + } + }) } else { download.state = "failed" download.error = "Cache file already exists or could not be finalized" - root.deleteFile(download.tempPath) + root.cleanupOpenTemp(download) root.sanitizeForHistory(download) } root.transferStateChanged(download) @@ -1077,38 +1600,104 @@ QtObject { property Component openCachedFileComponent: Component { Process { property var transferRef: null + property var handoffTimer: null + property var pgid: 0 + onStarted: { + pgid = processId + var proc = this + handoffTimer = root._retryTimerFactory.createObject(root, { + interval: root.openHandoffTimeoutMs, + callback: function() { + proc.handoffTimer = null + root.completeOpenHandoff(proc.transferRef, proc) + } + }) + if (handoffTimer) handoffTimer.start() + } onExited: function(exitCode) { var t = transferRef - destroy() - if (exitCode !== 0 && t) { - // Error surfaced by caller via transfer error state + var proc = this + if (handoffTimer) { + handoffTimer.stop() + handoffTimer.destroy() + handoffTimer = null } + destroy() + root.handleOpenCachedFileExited(exitCode, t, proc) } } } + function completeOpenHandoff(transfer, process) { + if (!transfer || transfer.state !== "opening" || transfer.process !== process) return + transfer.process = null + root.releaseOpenCache(transfer) + transfer.state = "completed" + root.sanitizeForHistory(transfer) + root.pruneHistory() + root.transferStateChanged(transfer) + root.transfersChanged() + } + + function handleOpenCachedFileExited(exitCode, transfer, process) { + if (!transfer) return + if (transfer.process === process) transfer.process = null + if (transfer.state === "cancelling") { + root.finishCancelled(transfer) + } else if (transfer.state === "opening" && exitCode === 0) { + root.releaseOpenCache(transfer) + transfer.state = "completed" + root.sanitizeForHistory(transfer) + root.pruneHistory() + } else if (transfer.state === "opening") { + root.releaseOpenCache(transfer) + transfer.state = "failed" + transfer.error = "Cached file could not be opened by the default application" + root.sanitizeForHistory(transfer) + } else { + return + } + root.transferStateChanged(transfer) + root.transfersChanged() + } + function openCachedFile(transfer) { + SafePath.protectCache(transfer.cacheName) var proc = openCachedFileComponent.createObject(root) - if (!proc) return - proc.command = ["xdg-open", transfer.cachePath] + if (!proc) { + root.releaseOpenCache(transfer) + transfer.state = "failed" + transfer.error = "Could not start the default application" + root.sanitizeForHistory(transfer) + root.transferStateChanged(transfer) + root.transfersChanged() + return + } + // Resolve the user's MIME handler, then let UWSM honor its desktop + // entry semantics (including Terminal=true) through the configured + // default terminal. Keep the path as an argv value throughout. + proc.command = ["setsid", "bash", "-c", + "mime=$(xdg-mime query filetype \"$1\") && desktop=$(xdg-mime query default \"$mime\") && exec uwsm-app -- \"$desktop\" \"$1\"", + "omarseafile-open", transfer.cachePath] proc.transferRef = transfer + transfer.process = proc proc.running = true } + function cleanupOpenTemp(download, removeCache) { + root.deleteFile(download.tempPath) + if (download.cacheDir && download.tempName) { + root.deleteFile(download.cacheDir + "/.active_" + download.tempName) + } + root.releaseOpenCache(download) + if (removeCache && download.cachePath) root.deleteFile(download.cachePath) + } + // ===== LOGOUT CLEANUP ===== function logoutCleanup() { - for (var i = 0; i < root.transfers.length; i++) { - var t = root.transfers[i] - t.state = "cancelled" - if (t.process) { - t.process.kill() - t.process.destroy() - t.process = null - } - if (t.type === "download" && t.tempPath) deleteFile(t.tempPath) - root.sanitizeForHistory(t) - } + var active = root.transfers.slice() + for (var i = 0; i < active.length; i++) root.cancelTransfer(active[i].id) root.transfers = [] root.transfersChanged() } diff --git a/js/UrlPolicy.qml b/js/UrlPolicy.qml new file mode 100644 index 0000000..ed5cee1 --- /dev/null +++ b/js/UrlPolicy.qml @@ -0,0 +1,131 @@ +pragma Singleton +import QtQuick + +QtObject { + id: root + + readonly property string loopbackHostname: "localhost" + readonly property var loopbackAddresses: ["127.0.0.1", "::1"] + + function isLoopbackHost(host) { + if (!host) return false + if (host === root.loopbackHostname) return true + for (var i = 0; i < root.loopbackAddresses.length; i++) { + if (host === root.loopbackAddresses[i]) return true + } + return false + } + + function validateForAuth(url) { + if (!url || typeof url !== "string") { + return { valid: false, error: "Empty URL" } + } + var parsed + try { + parsed = new URL(url) + } catch (e) { + return { valid: false, error: "Invalid URL format" } + } + var scheme = parsed.protocol.replace(":", "") + var host = parsed.hostname + + if (scheme === "https") { + return { valid: true } + } + if (scheme === "http" && root.isLoopbackHost(host)) { + return { valid: true, warning: "Loopback HTTP — not recommended for production" } + } + return { valid: false, error: "Cleartext HTTP not allowed for authentication. Use HTTPS or loopback (http://localhost, http://127.0.0.1)." } + } + + // Validate a server-provided URL before it becomes a transfer target. + // Rejects: non-string, empty, >8192, non-HTTPS (except loopback HTTP), + // credentials/userinfo, javascript:/file: schemes, unparseable URLs. + function validateTransferUrl(url) { + if (!url || typeof url !== "string") { + return { valid: false, error: "URL must be a non-empty string" } + } + if (url.length > 8192) { + return { valid: false, error: "URL exceeds maximum length" } + } + var parsed + try { + parsed = new URL(url) + } catch (e) { + return { valid: false, error: "URL is malformed" } + } + var scheme = parsed.protocol.replace(":", "") + var host = parsed.hostname + + // Reject javascript: and file: schemes + if (scheme === "javascript" || scheme === "file") { + return { valid: false, error: "Unsupported URL scheme: " + scheme } + } + + // HTTPS is always allowed + if (scheme === "https") { + // Reject userinfo (credentials in URL) + if (parsed.username || parsed.password) { + return { valid: false, error: "URL must not contain credentials" } + } + return { valid: true } + } + + // HTTP only allowed for loopback + if (scheme === "http" && root.isLoopbackHost(host)) { + if (parsed.username || parsed.password) { + return { valid: false, error: "URL must not contain credentials" } + } + return { valid: true, warning: "Loopback HTTP transfer" } + } + + return { valid: false, error: "Transfer URL must use HTTPS (or loopback HTTP)" } + } + + // Extract origin (scheme + hostname + port) from a URL string. + // Returns null on parse failure. + function _extractOrigin(url) { + if (!url || typeof url !== "string") return null + try { + var parsed = new URL(url) + var scheme = parsed.protocol.replace(":", "") + var host = parsed.hostname || "" + var port = parsed.port || "" + // Normalize default ports: http->80, https->443 + if (port === "" || port === "0") { + port = scheme === "https" ? "443" : "80" + } + return scheme + "://" + host.toLowerCase() + ":" + port + } catch (e) { + return null + } + } + + // Determine whether a transfer URL is same-origin as the configured Seafile base. + // Returns { sameOrigin: bool, reason: string } + function checkTransferOrigin(transferUrl, baseUrl) { + var transferOrigin = _extractOrigin(transferUrl) + var baseOrigin = _extractOrigin(baseUrl) + + if (!transferOrigin) { + return { sameOrigin: false, reason: "Transfer URL is malformed" } + } + if (!baseOrigin) { + return { sameOrigin: false, reason: "Base URL is malformed" } + } + + if (transferOrigin === baseOrigin) { + return { sameOrigin: true, reason: "Same origin" } + } + + return { sameOrigin: false, reason: "Cross-origin: " + transferOrigin + " vs " + baseOrigin } + } + + // Should the Seafile Authorization header be attached to a transfer request? + // Only when the transfer URL is same-origin as the configured Seafile base. + // This prevents credential leakage to cross-origin storage servers. + function shouldAttachAuth(transferUrl, baseUrl) { + var check = checkTransferOrigin(transferUrl, baseUrl) + return check.sameOrigin + } +} \ No newline at end of file diff --git a/js/qmldir b/js/qmldir index 548d09e..38aba2a 100644 --- a/js/qmldir +++ b/js/qmldir @@ -5,4 +5,7 @@ singleton SeafileAPI 1.0 SeafileAPI.qml singleton Models 1.0 Models.qml singleton TransferService 1.0 TransferService.qml singleton Cache 1.0 Cache.qml -singleton SelectionHelper 1.0 SelectionHelper.qml \ No newline at end of file +singleton SelectionHelper 1.0 SelectionHelper.qml +singleton UrlPolicy 1.0 UrlPolicy.qml +singleton SafePath 1.0 SafePath.qml +singleton HttpTransport 1.0 HttpTransport.qml diff --git a/scripts/atomic_write.py b/scripts/atomic_write.py new file mode 100755 index 0000000..b71a89e --- /dev/null +++ b/scripts/atomic_write.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +"""Atomic secure file writer: held dir_fd with O_CREAT|O_EXCL|O_NOFOLLOW. + +Usage: atomic_write.py