diff --git a/README.md b/README.md index 51f64c5..6b877f3 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,12 @@ Environment variables win if both are set. `DOWNLOAD_DIRS` powers the in-app folder picker. List each drive or folder you want to save into, for example `Movies=/mnt/movies,Backup=/mnt/backup`. In Docker, mount each of those -paths as a volume. +paths as a volume. The same folders scope which local files can be uploaded, since uploads read +from the server's local disk into the remote folder you are browsing. + +SFTP uploads run single-stream per file (rclone can't multi-thread a single-file SFTP upload). +Overall upload throughput across multiple files comes from the Concurrent setting in the +transfer queue. ## First-run setup @@ -74,11 +79,11 @@ If `APP_PASSWORD` / `APP_PASSWORD_HASH` or `appPassword` / `appPasswordHash` is - Connect over SFTP with a password or private key. - Browse remote folders with sorting, multi-select, and right-click download. - Fast parallel downloads (1 to 16 streams) with live progress and speed. +- Upload files and folders that already exist on the Siphon server's local disk into the + remote folder you are browsing. - Downloads keep running on the server even if you close the tab or your phone. - Save connections so you don't retype credentials, and pick a save folder per download. -Uploads and remote file management are out of scope for now. - ## Keep it running on boot - **Docker:** already handled (`restart: unless-stopped` in `docker-compose.yml`). diff --git a/src/server/app.ts b/src/server/app.ts index 59ab45d..07808b0 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -6,6 +6,7 @@ import { registerSystemRoutes } from './routes/system' import { registerConnectionRoutes } from './routes/connection' import { registerBrowseRoutes } from './routes/browse' import { registerDownloadRoutes } from './routes/downloads' +import { registerUploadRoutes } from './routes/uploads' import { registerProfileRoutes } from './routes/profiles' import { registerStatic } from './routes/static' @@ -17,6 +18,7 @@ export async function registerRoutes(app: FastifyInstance, ctx: RouteContext): P registerConnectionRoutes(app, ctx) registerBrowseRoutes(app, ctx) registerDownloadRoutes(app, ctx) + registerUploadRoutes(app, ctx) registerProfileRoutes(app, ctx) await registerStatic(app, ctx) } diff --git a/src/server/localFs.ts b/src/server/localFs.ts index d4675c0..7adaddf 100644 --- a/src/server/localFs.ts +++ b/src/server/localFs.ts @@ -1,4 +1,4 @@ -import { mkdir, readdir, realpath } from 'node:fs/promises' +import { mkdir, readdir, realpath, lstat } from 'node:fs/promises' import { existsSync, type Dirent } from 'node:fs' import { isAbsolute, join, relative, resolve } from 'node:path' import type { DownloadRoot } from './config' @@ -133,6 +133,32 @@ export async function makeDir(scope: FsScope, parentPath: string, name: string): return canonicalTarget } +// Caller must pass an already-resolved, confined directory; this walker only skips symlinks +// and does not itself enforce scope. +export async function listFilesRecursive( + dir: string +): Promise<{ relPath: string; size: number }[]> { + const results: { relPath: string; size: number }[] = [] + + async function walk(current: string, relPrefix: string): Promise { + 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 +} + async function isConfiguredRoot(roots: DownloadRoot[], canonical: string): Promise { for (const root of roots) { if ((await canonicalize(root.path)) === canonical) return true diff --git a/src/server/rclone/downloadManager.ts b/src/server/rclone/downloadManager.ts index 1385792..4d3125e 100644 --- a/src/server/rclone/downloadManager.ts +++ b/src/server/rclone/downloadManager.ts @@ -15,6 +15,8 @@ export interface RcloneEnqueueInput { size: number segments: number cleanupRemote?: string + direction?: 'download' | 'upload' + uploadRemoteDir?: string } interface InternalTransfer { @@ -93,7 +95,9 @@ export class RcloneDownloadManager extends EventEmitter { speedBytesPerSec: 0, activeSegments: 0, segments: input.segments, - status: 'queued' + status: 'queued', + direction: input.direction ?? 'download', + uploadRemoteDir: input.uploadRemoteDir } this.transfers.set(id, { progress, @@ -301,7 +305,8 @@ export class RcloneDownloadManager extends EventEmitter { t.progress.status = 'canceled' } else { t.progress.status = 'error' - t.progress.error = job.error || 'Download failed.' + t.progress.error = + job.error || (t.progress.direction === 'upload' ? 'Upload failed.' : 'Download failed.') } t.progress.activeSegments = 0 t.progress.speedBytesPerSec = 0 diff --git a/src/server/routes/uploads.ts b/src/server/routes/uploads.ts new file mode 100644 index 0000000..793c945 --- /dev/null +++ b/src/server/routes/uploads.ts @@ -0,0 +1,134 @@ +import { basename, isAbsolute, join, posix, relative } from 'node:path' +import { stat as fsStat } from 'node:fs/promises' +import { randomUUID } from 'node:crypto' +import type { FastifyInstance } from 'fastify' +import type { UploadEnqueueInput } from '../../shared/api' +import type { TransferProgress } from '../../shared/types' +import type { RouteContext } from '../context' +import { httpError } from '../http' +import { listFilesRecursive, resolvePath, type FsScope } from '../localFs' +import { safeBaseName, uiToRemotePath } from '../mapping' +import type { RcloneClient } from '../rclone/client' +import type { RcloneDownloadManager } from '../rclone/downloadManager' + +export interface ExpandUploadInput { + resolved: string + isDir: boolean + size: number + remoteDir: string + jobRemote: string +} + +export async function expandUpload( + client: RcloneClient, + manager: RcloneDownloadManager, + input: ExpandUploadInput +): Promise { + const { resolved, isDir, size, remoteDir, jobRemote } = input + // SFTP uploads are single-stream (see README); segments is always 1. + const segments = 1 + + if (isDir) { + const dirBaseName = safeBaseName(basename(resolved)) + let files + try { + files = await listFilesRecursive(resolved) + } catch (err) { + await client.deleteRemote(jobRemote).catch(() => undefined) + throw err + } + + if (files.length === 0) { + await client.deleteRemote(jobRemote).catch(() => undefined) + return [] + } + + return files.map((file) => { + const displayName = `${dirBaseName}/${file.relPath}` + return manager.enqueue({ + srcFs: resolved, + srcRemote: file.relPath, + dstFs: `${jobRemote}:`, + dstRemote: posix.join(remoteDir, dirBaseName, file.relPath), + displayName, + localPath: join(resolved, file.relPath), + size: file.size, + segments, + direction: 'upload', + uploadRemoteDir: remoteDir, + cleanupRemote: jobRemote + }) + }) + } + + const remoteBaseName = safeBaseName(basename(resolved)) + const parentDir = join(resolved, '..') + const localBaseName = basename(resolved) + + return [ + manager.enqueue({ + srcFs: parentDir, + srcRemote: localBaseName, + dstFs: `${jobRemote}:`, + dstRemote: posix.join(remoteDir, remoteBaseName), + displayName: remoteBaseName, + localPath: resolved, + size, + segments, + direction: 'upload', + uploadRemoteDir: remoteDir, + cleanupRemote: jobRemote + }) + ] +} + +export function registerUploadRoutes( + app: FastifyInstance, + { config, services, session }: RouteContext +): void { + const { client, manager } = services + const scope: FsScope = { roots: config.roots, confined: config.confined } + + app.post('/api/upload', async (req) => { + const input = req.body as UploadEnqueueInput + session.remoteFs() + + const resolved = await resolvePath(scope, input.localPath) + if (!resolved) throw httpError(400, 'That file or folder is not accessible.') + + const canonicalDataDir = await resolvePath( + { roots: [{ name: 'data', path: config.dataDir }], confined: false }, + config.dataDir + ) + if (canonicalDataDir && isInsideDataDir(canonicalDataDir, resolved)) { + throw httpError(400, 'That file or folder is not accessible.') + } + + let stats + try { + stats = await fsStat(resolved) + } catch { + throw httpError(404, 'File not found.') + } + + const remoteDir = uiToRemotePath(input.remoteDir) + if (remoteDir.split('/').some((seg) => seg === '..')) { + throw httpError(400, 'That destination folder is not allowed.') + } + const jobRemote = `_ul-${randomUUID()}` + await client.cloneRemote(session.remoteName(), jobRemote) + + return expandUpload(client, manager, { + resolved, + isDir: stats.isDirectory(), + size: stats.isDirectory() ? 0 : stats.size, + remoteDir, + jobRemote + }) + }) +} + +function isInsideDataDir(dataDir: string, target: string): boolean { + const rel = relative(dataDir, target) + return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel)) +} diff --git a/src/server/services.ts b/src/server/services.ts index 17f22fc..39f6e45 100644 --- a/src/server/services.ts +++ b/src/server/services.ts @@ -28,7 +28,7 @@ export async function createServices( async function removeEphemeralRemotes(client: RcloneClient): Promise { const names = await client.listRemotes().catch(() => [] as string[]) for (const name of names) { - if (name === '_session' || name.startsWith('_dl-')) { + if (name === '_session' || name.startsWith('_dl-') || name.startsWith('_ul-')) { await client.deleteRemote(name).catch(() => undefined) } } diff --git a/src/shared/api.ts b/src/shared/api.ts index b8e7aff..6ffe89b 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -22,6 +22,11 @@ export interface DownloadEnqueueInput { segments: number } +export interface UploadEnqueueInput { + localPath: string + remoteDir: string +} + export interface ConnectionProfileMeta { id: string name: string @@ -83,6 +88,7 @@ export interface SftpApi { list(dir: string): Promise stat(path: string): Promise enqueueDownload(input: DownloadEnqueueInput): Promise + enqueueUpload(input: UploadEnqueueInput): Promise cancelDownload(id: string): Promise cancelAllDownloads(): Promise clearFinishedDownloads(): Promise diff --git a/src/shared/types.ts b/src/shared/types.ts index 7cb8c2e..96bc647 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -40,6 +40,8 @@ export interface TransferProgress { status: TransferStatus canceling?: boolean error?: string + direction?: 'download' | 'upload' + uploadRemoteDir?: string } export type DownloadEvent = diff --git a/src/ui/App.tsx b/src/ui/App.tsx index c6a3538..c62970e 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { Button, Dialog, FormControl, SegmentedControl, TextInput } from '@primer/react' import type { RemoteEntry, TransferProgress } from '@shared/types' import type { ConnectionProfileMeta, SaveProfileInput } from '@shared/api' @@ -77,6 +77,7 @@ export default function App({ canChangePassword }: AppProps) { const [selectedProfileId, setSelectedProfileId] = useState('') const [rememberSecret, setRememberSecret] = useState(true) const [pickerOpen, setPickerOpen] = useState(false) + const [pickerMode, setPickerMode] = useState<'chooseDir' | 'chooseItems'>('chooseDir') const [mobileTab, setMobileTab] = useState<'files' | 'transfers'>('files') const [passwordDialogOpen, setPasswordDialogOpen] = useState(false) const [currentPassword, setCurrentPassword] = useState('') @@ -86,6 +87,20 @@ export default function App({ canChangePassword }: AppProps) { const [passwordError, setPasswordError] = useState(null) const [passwordNotice, setPasswordNotice] = useState(null) + const cwdRef = useRef(cwd) + const refreshTimerRef = useRef | null>(null) + const navigateToRef = useRef<(dir: string) => Promise>(async () => false) + + useEffect(() => { + cwdRef.current = cwd + }, [cwd]) + + useEffect(() => { + return () => { + if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current) + } + }, []) + useEffect(() => { window.api .defaultDownloadDir() @@ -111,6 +126,19 @@ export default function App({ canChangePassword }: AppProps) { next[index] = update return next }) + + if (update.direction === 'upload' && update.status === 'completed') { + const uploadDir = update.uploadRemoteDir + // Server-normalized uploadRemoteDir (uiToRemotePath) strips leading slashes; match that here. + const cwdNorm = cwdRef.current.replace(/^\/+/, '') + if (uploadDir !== undefined && uploadDir === cwdNorm) { + if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current) + refreshTimerRef.current = setTimeout(() => { + refreshTimerRef.current = null + if (cwdRef.current.replace(/^\/+/, '') === uploadDir) navigateToRef.current(cwdRef.current) + }, 600) + } + } }) }, []) @@ -136,6 +164,10 @@ export default function App({ canChangePassword }: AppProps) { } }, []) + useEffect(() => { + navigateToRef.current = navigateTo + }, [navigateTo]) + useEffect(() => { window.api .status() @@ -295,6 +327,27 @@ export default function App({ canChangePassword }: AppProps) { setSelected(new Set()) }, [entries, selected, enqueue]) + const handleUploadSelected = useCallback( + async (localPaths: string[]) => { + setPickerOpen(false) + const destDir = cwd + for (const localPath of localPaths) { + try { + const enqueued = await window.api.enqueueUpload({ + localPath, + remoteDir: destDir + }) + if (enqueued.length === 0) { + setBrowseError('That folder has no files to upload.') + } + } catch (error) { + setBrowseError(errorMessage(error)) + } + } + }, + [cwd] + ) + const handleClearFinished = useCallback(async () => { await window.api.clearFinishedDownloads() }, []) @@ -370,7 +423,10 @@ export default function App({ canChangePassword }: AppProps) { segments={segments} onSegmentsChange={setSegments} downloadDir={downloadDir} - onBrowseServer={() => setPickerOpen(true)} + onBrowseServer={() => { + setPickerMode('chooseDir') + setPickerOpen(true) + }} onDownloadDirChange={setDownloadDir} profiles={profiles} selectedProfileId={selectedProfileId} @@ -412,6 +468,10 @@ export default function App({ canChangePassword }: AppProps) { onSelectionChange={setSelected} onDownloadSelected={handleDownloadSelected} onDownloadEntry={(entry) => enqueue(entry.path)} + onUpload={() => { + setPickerMode('chooseItems') + setPickerOpen(true) + }} /> setPickerOpen(false)} - onChoose={(path) => { - setDownloadDir(path) + onChoose={(paths) => { + if (pickerMode === 'chooseItems') { + void handleUploadSelected(paths) + return + } + setDownloadDir(paths[0]) setPickerOpen(false) }} /> diff --git a/src/ui/components/FolderPicker.tsx b/src/ui/components/FolderPicker.tsx index 05c9156..32e7888 100644 --- a/src/ui/components/FolderPicker.tsx +++ b/src/ui/components/FolderPicker.tsx @@ -7,26 +7,31 @@ import { isTextInputFocused } from '../lib/keyboard' interface FolderPickerProps { initialPath: string + mode?: 'chooseDir' | 'chooseItems' onClose: () => void - onChoose: (path: string) => void + onChoose: (paths: string[]) => void } export function FolderPicker(props: FolderPickerProps) { - const { initialPath, onClose, onChoose } = props + const { initialPath, mode = 'chooseDir', onClose, onChoose } = props const [roots, setRoots] = useState([]) const [listing, setListing] = useState(null) const [loading, setLoading] = useState(false) const [error, setError] = useState(null) const [newFolder, setNewFolder] = useState(null) const [focusIndex, setFocusIndex] = useState(null) + const [checked, setChecked] = useState>(new Set()) const listRef = useRef(null) - const dirs = useMemo(() => listing?.entries.filter((entry) => entry.isDir) ?? [], [listing]) - const focusedPath = focusIndex !== null ? dirs[focusIndex]?.path : undefined + const rows = useMemo(() => listing?.entries ?? [], [listing]) + const dirs = useMemo(() => rows.filter((entry) => entry.isDir), [rows]) + const navRows = mode === 'chooseItems' ? rows : dirs + const focusedPath = focusIndex !== null ? navRows[focusIndex]?.path : undefined const load = useCallback(async (path?: string) => { setLoading(true) setError(null) + setChecked(new Set()) try { const result = await window.api.browseLocalDirs(path) setListing(result) @@ -43,33 +48,40 @@ export function FolderPicker(props: FolderPickerProps) { }, [initialPath, load]) useEffect(() => { - setFocusIndex(dirs.length > 0 ? 0 : null) - }, [dirs]) + setFocusIndex(navRows.length > 0 ? 0 : null) + }, [navRows]) useEffect(() => { - const focusDir = (index: number): void => { - const buttons = listRef.current?.querySelectorAll('button') - buttons?.[index]?.focus() + const focusRow = (index: number): void => { + const selector = mode === 'chooseItems' ? 'input[type="checkbox"]' : 'button' + const targets = listRef.current?.querySelectorAll(selector) + targets?.[index]?.focus() } const onKey = (e: KeyboardEvent): void => { if (isTextInputFocused()) return - if (dirs.length === 0) return + if (navRows.length === 0) return if (e.key === 'ArrowDown') { e.preventDefault() - const next = Math.min((focusIndex ?? -1) + 1, dirs.length - 1) + const next = Math.min((focusIndex ?? -1) + 1, navRows.length - 1) setFocusIndex(next) - focusDir(next) + focusRow(next) } else if (e.key === 'ArrowUp') { e.preventDefault() const next = Math.max((focusIndex ?? 1) - 1, 0) setFocusIndex(next) - focusDir(next) + focusRow(next) + } else if (mode === 'chooseItems' && e.key === 'Enter') { + const entry = focusIndex !== null ? navRows[focusIndex] : undefined + if (entry?.isDir) { + e.preventDefault() + load(entry.path) + } } } window.addEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey) - }, [dirs, focusIndex]) + }, [navRows, focusIndex, mode, load]) const createFolder = useCallback(async () => { if (!listing || !newFolder || !newFolder.trim()) { @@ -85,21 +97,40 @@ export function FolderPicker(props: FolderPickerProps) { } }, [listing, newFolder, load]) + const toggleChecked = useCallback((path: string) => { + setChecked((prev) => { + const next = new Set(prev) + if (next.has(path)) next.delete(path) + else next.add(path) + return next + }) + }, []) + return ( onClose()} footerButtons={[ { content: 'Cancel', onClick: () => onClose() }, - { - content: 'Use this folder', - buttonType: 'primary', - disabled: !listing, - onClick: () => { - if (listing) onChoose(listing.path) - } - } + mode === 'chooseItems' + ? { + content: + checked.size > 0 + ? `Upload ${checked.size} item${checked.size === 1 ? '' : 's'}` + : 'Upload items', + buttonType: 'primary', + disabled: checked.size === 0, + onClick: () => onChoose([...checked]) + } + : { + content: 'Use this folder', + buttonType: 'primary', + disabled: !listing, + onClick: () => { + if (listing) onChoose([listing.path]) + } + } ]} > {roots.length > 1 && ( @@ -129,13 +160,15 @@ export function FolderPicker(props: FolderPickerProps) { {listing?.path ?? '…'} - + {mode !== 'chooseItems' && ( + + )} {newFolder !== null && ( @@ -173,7 +206,36 @@ export function FolderPicker(props: FolderPickerProps) { ) : (
    {listing?.entries.map((entry) => - entry.isDir ? ( + mode === 'chooseItems' ? ( +
  • + + {entry.isDir ? ( + + ) : ( + + + + + {entry.name} + + )} +
  • + ) : entry.isDir ? (
  • -

    Files will download into this folder.

    +

    + {mode === 'chooseItems' + ? 'Checked files and folders will upload into the remote folder you are browsing.' + : 'Files will download into this folder.'} +

) } diff --git a/src/ui/components/RemoteBrowser.tsx b/src/ui/components/RemoteBrowser.tsx index 4eacff2..adb07c8 100644 --- a/src/ui/components/RemoteBrowser.tsx +++ b/src/ui/components/RemoteBrowser.tsx @@ -28,6 +28,7 @@ interface RemoteBrowserProps { onSelectionChange: (next: Set) => void onDownloadSelected: () => void onDownloadEntry: (entry: RemoteEntry) => void + onUpload: () => void } export function RemoteBrowser(props: RemoteBrowserProps) { @@ -44,7 +45,8 @@ export function RemoteBrowser(props: RemoteBrowserProps) { onRefresh, onSelectionChange, onDownloadSelected, - onDownloadEntry + onDownloadEntry, + onUpload } = props const selectedCount = entries.filter((entry) => selected.has(entry.path)).length @@ -225,6 +227,13 @@ export function RemoteBrowser(props: RemoteBrowserProps) { > Refresh +