Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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`).
Expand Down
2 changes: 2 additions & 0 deletions src/server/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -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)
}
28 changes: 27 additions & 1 deletion src/server/localFs.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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<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
}

async function isConfiguredRoot(roots: DownloadRoot[], canonical: string): Promise<boolean> {
for (const root of roots) {
if ((await canonicalize(root.path)) === canonical) return true
Expand Down
9 changes: 7 additions & 2 deletions src/server/rclone/downloadManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ export interface RcloneEnqueueInput {
size: number
segments: number
cleanupRemote?: string
direction?: 'download' | 'upload'
uploadRemoteDir?: string
}

interface InternalTransfer {
Expand Down Expand Up @@ -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
}
Comment on lines +98 to 101
this.transfers.set(id, {
progress,
Expand Down Expand Up @@ -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
Expand Down
134 changes: 134 additions & 0 deletions src/server/routes/uploads.ts
Original file line number Diff line number Diff line change
@@ -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<TransferProgress[]> {
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
Comment on lines +55 to +59
})
})
}

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))
}
2 changes: 1 addition & 1 deletion src/server/services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export async function createServices(
async function removeEphemeralRemotes(client: RcloneClient): Promise<void> {
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)
}
}
Expand Down
6 changes: 6 additions & 0 deletions src/shared/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ export interface DownloadEnqueueInput {
segments: number
}

export interface UploadEnqueueInput {
localPath: string
remoteDir: string
}

export interface ConnectionProfileMeta {
id: string
name: string
Expand Down Expand Up @@ -83,6 +88,7 @@ export interface SftpApi {
list(dir: string): Promise<RemoteEntry[]>
stat(path: string): Promise<RemoteStat>
enqueueDownload(input: DownloadEnqueueInput): Promise<TransferProgress[]>
enqueueUpload(input: UploadEnqueueInput): Promise<TransferProgress[]>
cancelDownload(id: string): Promise<void>
cancelAllDownloads(): Promise<void>
clearFinishedDownloads(): Promise<void>
Expand Down
2 changes: 2 additions & 0 deletions src/shared/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ export interface TransferProgress {
status: TransferStatus
canceling?: boolean
error?: string
direction?: 'download' | 'upload'
uploadRemoteDir?: string
}

export type DownloadEvent =
Expand Down
Loading