Skip to content

Add file uploads: push server-side files to the browsed remote folder - #16

Merged
dentifrag merged 6 commits into
mainfrom
dentifrag-refactored-happiness
Jul 23, 2026
Merged

Add file uploads: push server-side files to the browsed remote folder#16
dentifrag merged 6 commits into
mainfrom
dentifrag-refactored-happiness

Conversation

@dentifrag

@dentifrag dentifrag commented Jul 23, 2026

Copy link
Copy Markdown
Owner

What and why

Siphon has been download-only: rclone copies files from the remote SFTP server onto the Siphon server's local disk. This adds the reverse direction: upload files and folders that already exist on the Siphon server's disk into the remote SFTP folder you are currently browsing.

Source files come from the server's local disk (reusing the existing local file picker), not from the browser/device. The destination is the remote folder shown in the browser (its cwd). Uploads are enabled by default.

How it works

Uploads reuse the existing transfer queue, SSE progress stream, cancel/clear, and concurrency controls. The transfer manager was already direction-agnostic (operations/copyfile with srcFs/dstFs), so the change is small.

Backend

  • TransferProgress / RcloneEnqueueInput gain an optional direction ('download' default), set in enqueue().
  • listFilesRecursive() walks a local dir for folder uploads and does not follow symlinks, so recursion cannot escape the configured roots.
  • New POST /api/upload (src/server/routes/uploads.ts) mirrors /api/download per item: it requires an active session, confines the source via resolvePath, rejects sources inside dataDir (so rclone.conf and keys can't be read out), clones the session remote to a per-request _ul-<uuid> job remote, and fans out one transfer per file via expandUpload(). Every remote destination path is built with path.posix.join (never string interpolation) so uploading into the remote root does not produce an absolute path.
  • Startup cleanup also sweeps _ul- remotes.

Frontend

  • FolderPicker gains a chooseItems mode (multi-select local files and folders via per-row checkboxes; folder names still navigate) alongside the unchanged chooseDir download-folder mode.
  • An Upload button in the browser toolbar opens the picker; on confirm the app uploads each selected item into the current remote folder and refreshes the listing (debounced) when uploads into that folder complete.
  • The transfer queue shows direction (up/down) and reads "Uploading" / "Downloading".

Security and scope

  • Uploads can only read from the same folders DOWNLOAD_DIRS already scopes (or the whole filesystem in open mode, same as downloads). Sources inside dataDir are refused.
  • SFTP cannot multi-thread a single-file upload, so uploads run single-stream per file; throughput across multiple files comes from the existing Concurrent setting.
  • Existing remote files at the same path are overwritten (rclone default), no prompt.

Testing

  • npm run typecheck: clean.
  • npm test: 114/114 passing, including new test/uploadRoute.test.ts (single file, nested dir, empty-dir cleanup, listing-failure cleanup, and the cwd='/' no-leading-slash case), a listFilesRecursive symlink-exclusion test, and a manager direction round-trip test.
  • npm run web:build: succeeds.
  • Accessibility pass on the new picker UI (labeled checkboxes, single selection control per file row, selection scoped per folder, direction exposed to assistive tech).

Copilot-Session: 77e1891d-c8e2-48a9-8054-4680c414391e

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds server-side uploads to complement the existing download-only workflow: users can pick local (server) files/folders and push them into the currently-browsed remote SFTP directory, reusing the existing transfer queue/progress plumbing.

Changes:

  • Add POST /api/upload and upload expansion logic (including recursive local dir expansion with symlink skipping).
  • Extend transfer progress/enqueue types with a direction field and update UI to show upload vs download.
  • Add coverage for upload route behavior, direction round-trip, and recursive local listing behavior.
Show a summary per file
File Description
test/uploadRoute.test.ts New tests for expandUpload (single file, directory fan-out, cleanup paths, root-cwd join).
test/localFs.test.ts Adds coverage for listFilesRecursive, including symlink exclusion.
test/downloadManager.test.ts Verifies direction defaulting + round-trip through list().
src/web/api.ts Adds enqueueUpload client call to /api/upload.
src/ui/index.css Styles for checkbox rows and focused file-row highlighting in picker.
src/ui/components/TransferQueue.tsx Adds direction icon + upload/download-aware status labels and segment display tweaks.
src/ui/components/RemoteBrowser.tsx Adds an Upload button hook in the toolbar.
src/ui/components/FolderPicker.tsx Adds chooseItems mode with per-row checkboxes and multi-select output.
src/ui/App.tsx Wires Upload flow end-to-end; refreshes listing when uploads into current cwd complete.
src/shared/types.ts Extends TransferProgress with optional direction.
src/shared/api.ts Adds UploadEnqueueInput and SftpApi.enqueueUpload.
src/server/services.ts Startup cleanup now also removes _ul- ephemeral remotes.
src/server/routes/uploads.ts New upload route + expandUpload implementation and dataDir exclusion guard.
src/server/rclone/downloadManager.ts Persists transfer direction (default download) on enqueue.
src/server/localFs.ts Adds listFilesRecursive for directory uploads (skips symlinks).
src/server/app.ts Registers the new upload routes.
README.md Documents upload behavior and performance characteristics.

Review details

Tip

Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

  • Files reviewed: 17/17 changed files
  • Comments generated: 1
  • Review effort level: Low

Comment thread test/localFs.test.ts
Comment on lines +133 to +136
writeFileSync(join(outsideRoot, 'secret.txt'), 'shh')
symlinkSync(join(outsideRoot, 'secret.txt'), join(uploadRoot, 'escape.txt'))
symlinkSync(outsideRoot, join(uploadRoot, 'escape-dir'))

Copilot-Session: 77e1891d-c8e2-48a9-8054-4680c414391e

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review details

  • Files reviewed: 17/17 changed files
  • Comments generated: 2
  • Review effort level: Low

Comment on lines +55 to 59
const focusRow = (index: number): void => {
const selector = mode === 'chooseItems' ? 'input[type="checkbox"]' : 'button'
const targets = listRef.current?.querySelectorAll<HTMLElement>(selector)
targets?.[index]?.focus()
}
Comment thread src/server/localFs.ts Outdated
Comment on lines +148 to +152
if (entry.isDirectory()) {
await walk(entryPath, relPath)
} else if (entry.isFile()) {
const stats = await stat(entryPath)
results.push({ relPath, size: stats.size })
…te in picker

Copilot-Session: 77e1891d-c8e2-48a9-8054-4680c414391e

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review details

  • Files reviewed: 17/17 changed files
  • Comments generated: 2
  • Review effort level: Low

Comment thread src/ui/App.tsx
Comment on lines +344 to +348
if (enqueued.length === 0) {
setBrowseError('That folder has no files to upload.')
} else {
for (const transfer of enqueued) uploadDestRef.current.set(transfer.id, destDir)
}
Comment on lines +55 to +58
size: file.size,
segments,
direction: 'upload',
cleanupRemote: jobRemote
Copilot-Session: 77e1891d-c8e2-48a9-8054-4680c414391e

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review details

  • Files reviewed: 17/17 changed files
  • Comments generated: 2
  • Review effort level: Low

Comment thread src/ui/App.tsx Outdated
Comment on lines +130 to +140
if (
update.direction === 'upload' &&
update.status === 'completed' &&
update.uploadRemoteDir === cwdRef.current
) {
if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current)
refreshTimerRef.current = setTimeout(() => {
refreshTimerRef.current = null
navigateToRef.current(cwdRef.current)
}, 600)
}
Comment thread src/server/localFs.ts
Comment on lines +136 to +159
export async function listFilesRecursive(
_scope: FsScope,
dir: string
): Promise<{ relPath: string; size: number }[]> {
const results: { relPath: string; size: number }[] = []

async function walk(current: string, relPrefix: string): Promise<void> {
const dirents = await readdir(current, { withFileTypes: true })
for (const entry of dirents) {
const relPath = relPrefix ? `${relPrefix}/${entry.name}` : entry.name
const entryPath = join(current, entry.name)
const st = await lstat(entryPath)
if (st.isSymbolicLink()) continue
if (st.isDirectory()) {
await walk(entryPath, relPath)
} else if (st.isFile()) {
results.push({ relPath, size: st.size })
}
}
}

await walk(dir, '')
return results
}
…ir walk

Copilot-Session: 77e1891d-c8e2-48a9-8054-4680c414391e

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review details

  • Files reviewed: 17/17 changed files
  • Comments generated: 3
  • Review effort level: Low

Comment thread src/ui/App.tsx Outdated
Comment on lines +130 to +141
if (
update.direction === 'upload' &&
update.status === 'completed' &&
update.uploadRemoteDir === cwdRef.current
) {
const target = update.uploadRemoteDir
if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current)
refreshTimerRef.current = setTimeout(() => {
refreshTimerRef.current = null
if (cwdRef.current === target) navigateToRef.current(target)
}, 600)
}
Comment on lines +114 to +116
const remoteDir = uiToRemotePath(input.remoteDir)
const jobRemote = `_ul-${randomUUID()}`
await client.cloneRemote(session.remoteName(), jobRemote)
Comment on lines +98 to 101
status: 'queued',
direction: input.direction ?? 'download',
uploadRemoteDir: input.uploadRemoteDir
}
…tion-aware error

Copilot-Session: 77e1891d-c8e2-48a9-8054-4680c414391e

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review details

  • Files reviewed: 17/17 changed files
  • Comments generated: 0 new
  • Review effort level: Low

@dentifrag
dentifrag marked this pull request as ready for review July 23, 2026 23:52
@dentifrag
dentifrag merged commit ff5f58a into main Jul 23, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants