Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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)
}
27 changes: 26 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,31 @@ export async function makeDir(scope: FsScope, parentPath: string, name: string):
return canonicalTarget
}

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
}
Comment on lines +138 to +160

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

interface InternalTransfer {
Expand Down Expand Up @@ -93,7 +94,8 @@ export class RcloneDownloadManager extends EventEmitter {
speedBytesPerSec: 0,
activeSegments: 0,
segments: input.segments,
status: 'queued'
status: 'queued',
direction: input.direction ?? 'download'
}
Comment on lines +98 to 101
this.transfers.set(id, {
progress,
Expand Down
130 changes: 130 additions & 0 deletions src/server/routes/uploads.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
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
segments: number
jobRemote: string
}

export async function expandUpload(
client: RcloneClient,
manager: RcloneDownloadManager,
scope: FsScope,
input: ExpandUploadInput
): Promise<TransferProgress[]> {
const { resolved, isDir, size, remoteDir, segments, jobRemote } = input

if (isDir) {
const dirBaseName = safeBaseName(basename(resolved))
let files
try {
files = await listFilesRecursive(scope, 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',
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',
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)
const jobRemote = `_ul-${randomUUID()}`
await client.cloneRemote(session.remoteName(), jobRemote)
Comment on lines +114 to +119

return expandUpload(client, manager, scope, {
resolved,
isDir: stats.isDirectory(),
size: stats.isDirectory() ? 0 : stats.size,
remoteDir,
segments: input.segments,
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
7 changes: 7 additions & 0 deletions src/shared/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ export interface DownloadEnqueueInput {
segments: number
}

export interface UploadEnqueueInput {
localPath: string
remoteDir: string
segments: number
}

export interface ConnectionProfileMeta {
id: string
name: string
Expand Down Expand Up @@ -83,6 +89,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
1 change: 1 addition & 0 deletions src/shared/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export interface TransferProgress {
status: TransferStatus
canceling?: boolean
error?: string
direction?: 'download' | 'upload'
}

export type DownloadEvent =
Expand Down
Loading