Skip to content

Commit 6c20853

Browse files
committed
feat(cli): add HTTP file server with route-based access control
1 parent 0df207c commit 6c20853

11 files changed

Lines changed: 2162 additions & 0 deletions

File tree

.changeset/cli-serve.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
cli: minor
3+
---
4+
5+
Add HTTP file serving with streaming downloads, route-based access control, and SPA fallback.

apps/cli/src/commands/serve.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { join } from 'node:path'
2+
import { hexToUint8 } from '@siastorage/core'
3+
import { logger } from '@siastorage/logger'
4+
import { startServices } from '../daemon/entry'
5+
import { loadServeConfig } from '../serve/access'
6+
import { startHttpServer } from '../serve/handler'
7+
8+
export async function serveCommand(dataDir: string, opts: { port: string; host: string }) {
9+
const port = parseInt(opts.port, 10)
10+
const host = opts.host
11+
12+
// Bootstrap credentials from env vars if not already onboarded
13+
// (for containerized deployments like Fly/Cloudflare)
14+
await bootstrapFromEnv(dataDir)
15+
16+
// Start the daemon (DB, sync, uploads, IPC) — same as `sia daemon start`
17+
const ctx = await startServices(dataDir)
18+
19+
if (!ctx.connected) {
20+
console.warn('Warning: not connected to indexer. Files not cached locally will be unavailable.')
21+
}
22+
23+
const configPath = join(dataDir, 'serve.json')
24+
const config = loadServeConfig(configPath)
25+
26+
if (config.routes.length === 0) {
27+
console.warn(
28+
'Warning: no routes configured in serve.json. All paths will return 404.\n' +
29+
'Add routes with: sia serve routes add <path> --listing',
30+
)
31+
}
32+
33+
// Add the HTTP server on top of the daemon
34+
startHttpServer(ctx.app, { port, host }, config)
35+
36+
logger.info('serve', 'started', { pid: process.pid, port, connected: ctx.connected })
37+
}
38+
39+
async function bootstrapFromEnv(dataDir: string) {
40+
const keyHex = process.env.SIA_APP_KEY_HEX
41+
const indexerUrl = process.env.SIA_INDEXER_URL
42+
if (!keyHex || !indexerUrl) return
43+
44+
// Only bootstrap if not already onboarded — avoid overwriting on every restart
45+
const { createCliAppService } = await import('../app')
46+
const app = await createCliAppService(dataDir)
47+
try {
48+
const hasOnboarded = await app.service.settings.getHasOnboarded()
49+
if (hasOnboarded) return
50+
51+
const keyBytes = hexToUint8(keyHex)
52+
await app.service.auth.setAppKey(indexerUrl, keyBytes)
53+
await app.service.settings.setIndexerURL(indexerUrl)
54+
await app.service.settings.setHasOnboarded(true)
55+
logger.info('serve', 'credentials_bootstrapped', { indexerUrl })
56+
} finally {
57+
await app.db.execAsync('PRAGMA wal_checkpoint(TRUNCATE)')
58+
app.db.close?.()
59+
}
60+
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { join } from 'node:path'
2+
import { loadServeConfig, saveServeConfig, type RouteConfig } from '../serve/access'
3+
import { c } from '../lib/format'
4+
5+
function getConfigPath(dataDir: string): string {
6+
return join(dataDir, 'serve.json')
7+
}
8+
9+
export async function listRoutesCommand(dataDir: string) {
10+
const config = loadServeConfig(getConfigPath(dataDir))
11+
12+
if (config.routes.length === 0) {
13+
console.log(c.dim('No routes configured.'))
14+
console.log(c.dim('Add routes with: sia serve routes add <path> --listing'))
15+
return
16+
}
17+
18+
for (const route of config.routes) {
19+
const pathLabel = route.path || '(root)'
20+
const flags: string[] = []
21+
if (Array.isArray(route.listing)) {
22+
flags.push(c.green(`listed [${route.listing.join(', ')}]`))
23+
} else {
24+
flags.push(route.listing ? c.green('listed') : c.dim('unlisted'))
25+
}
26+
flags.push(route.download ? c.green('download') : c.dim('no-download'))
27+
if (route.recursive) flags.push(c.cyan('recursive'))
28+
console.log(`${pathLabel.padEnd(30)}${flags.join(' ')}`)
29+
}
30+
}
31+
32+
export async function addRouteCommand(
33+
dataDir: string,
34+
routePath: string,
35+
opts: { listing?: boolean; download?: boolean; recursive?: boolean },
36+
) {
37+
const configPath = getConfigPath(dataDir)
38+
const config = loadServeConfig(configPath)
39+
40+
const normalized = routePath.replace(/^\/+/, '').replace(/\/+$/, '')
41+
42+
const existing = config.routes.find((r) => r.path === normalized)
43+
if (existing) {
44+
if (opts.listing !== undefined) existing.listing = opts.listing
45+
if (opts.download !== undefined) existing.download = opts.download
46+
if (opts.recursive !== undefined) existing.recursive = opts.recursive
47+
saveServeConfig(configPath, config)
48+
console.log(`Updated route: ${normalized || '(root)'}`)
49+
printRoute(existing)
50+
return
51+
}
52+
53+
const route: RouteConfig = {
54+
path: normalized,
55+
listing: opts.listing ?? false,
56+
download: opts.download ?? true,
57+
recursive: opts.recursive ?? false,
58+
}
59+
60+
config.routes.push(route)
61+
saveServeConfig(configPath, config)
62+
console.log(`Added route: ${normalized || '(root)'}`)
63+
printRoute(route)
64+
}
65+
66+
export async function removeRouteCommand(dataDir: string, routePath: string) {
67+
const configPath = getConfigPath(dataDir)
68+
const config = loadServeConfig(configPath)
69+
70+
const normalized = routePath.replace(/^\/+/, '').replace(/\/+$/, '')
71+
const idx = config.routes.findIndex((r) => r.path === normalized)
72+
73+
if (idx === -1) {
74+
console.error(`Route not found: ${normalized || '(root)'}`)
75+
process.exit(1)
76+
}
77+
78+
config.routes.splice(idx, 1)
79+
saveServeConfig(configPath, config)
80+
console.log(`Removed route: ${normalized || '(root)'}`)
81+
}
82+
83+
function printRoute(route: RouteConfig) {
84+
const listingStr = Array.isArray(route.listing)
85+
? `[${route.listing.join(', ')}]`
86+
: route.listing
87+
? 'yes'
88+
: 'no'
89+
console.log(` listing: ${listingStr}`)
90+
console.log(` download: ${route.download ? 'yes' : 'no'}`)
91+
console.log(` recursive: ${route.recursive ? 'yes' : 'no'}`)
92+
}

apps/cli/src/index.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,53 @@ if (process.env.SIA_DAEMON_MODE === '1') {
246246
await completionsCommand(resolveDataDir(), shell)
247247
})
248248

249+
const serve = program
250+
.command('serve')
251+
.description('Start HTTP file server')
252+
.option('-p, --port <port>', 'Port to listen on', '3000')
253+
.option('--host <host>', 'Host to bind to', '0.0.0.0')
254+
.action(async (opts: { port: string; host: string }) => {
255+
const { serveCommand } = await import('./commands/serve')
256+
await serveCommand(resolveDataDir(), opts)
257+
})
258+
259+
const serveRoutes = serve
260+
.command('routes')
261+
.description('Manage serve route access control')
262+
.action(async () => {
263+
const { listRoutesCommand } = await import('./commands/serveRoutes')
264+
await listRoutesCommand(resolveDataDir())
265+
})
266+
267+
serveRoutes
268+
.command('add')
269+
.description('Add or update a route')
270+
.argument('<path>', 'Directory path to serve')
271+
.option('--listing', 'Enable directory listing')
272+
.option('--no-listing', 'Disable directory listing')
273+
.option('--download', 'Enable file downloads')
274+
.option('--no-download', 'Disable file downloads')
275+
.option('--recursive', 'Apply to all subdirectories')
276+
.option('--no-recursive', 'Only apply to this directory and its files')
277+
.action(
278+
async (
279+
routePath: string,
280+
opts: { listing?: boolean; download?: boolean; recursive?: boolean },
281+
) => {
282+
const { addRouteCommand } = await import('./commands/serveRoutes')
283+
await addRouteCommand(resolveDataDir(), routePath, opts)
284+
},
285+
)
286+
287+
serveRoutes
288+
.command('rm')
289+
.description('Remove a route')
290+
.argument('<path>', 'Route path to remove')
291+
.action(async (routePath: string) => {
292+
const { removeRouteCommand } = await import('./commands/serveRoutes')
293+
await removeRouteCommand(resolveDataDir(), routePath)
294+
})
295+
249296
program.parseAsync(process.argv).catch((err) => {
250297
console.error(err)
251298
process.exit(1)

apps/cli/src/serve/access.ts

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import { readFileSync, writeFileSync, existsSync } from 'fs'
2+
3+
export type RouteConfig = {
4+
path: string
5+
listing: boolean | string[]
6+
download: boolean
7+
recursive: boolean
8+
}
9+
10+
export type ServeConfig = {
11+
routes: RouteConfig[]
12+
}
13+
14+
const defaultConfig: ServeConfig = { routes: [] }
15+
16+
export function loadServeConfig(configPath: string): ServeConfig {
17+
if (!existsSync(configPath)) return defaultConfig
18+
const raw = readFileSync(configPath, 'utf-8')
19+
const parsed = JSON.parse(raw)
20+
if (!parsed || !Array.isArray(parsed.routes)) return defaultConfig
21+
return {
22+
routes: parsed.routes.map((r: Record<string, unknown>) => ({
23+
path: normalizePath(String(r.path ?? '')),
24+
listing: Array.isArray(r.listing) ? r.listing.map(String) : r.listing === true,
25+
download: r.download !== false,
26+
recursive: r.recursive === true,
27+
})),
28+
}
29+
}
30+
31+
export function saveServeConfig(configPath: string, config: ServeConfig): void {
32+
writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n')
33+
}
34+
35+
function normalizePath(p: string): string {
36+
return p.replace(/^\/+/, '').replace(/\/+$/, '')
37+
}
38+
39+
/**
40+
* Find the most specific route matching the given path.
41+
*
42+
* Non-recursive routes cover the exact path and one level of children:
43+
* route "s" matches "s", "s/file.txt" — but NOT "s/sub/" or "s/sub/file.txt"
44+
*
45+
* Recursive routes cover the path and all descendants:
46+
* route "public" (recursive) matches "public", "public/a", "public/a/b/c"
47+
*
48+
* Most specific (longest path) match always wins.
49+
*/
50+
export function findRoute(path: string, config: ServeConfig): RouteConfig | null {
51+
const normalized = normalizePath(path)
52+
let best: RouteConfig | null = null
53+
let bestLen = -1
54+
55+
for (const route of config.routes) {
56+
const rp = route.path
57+
58+
if (rp === '') {
59+
if (normalized === '') {
60+
if (bestLen < 0) {
61+
best = route
62+
bestLen = 0
63+
}
64+
} else if (route.recursive) {
65+
if (bestLen < 0) {
66+
best = route
67+
bestLen = 0
68+
}
69+
} else {
70+
if (!normalized.includes('/') && bestLen < 0) {
71+
best = route
72+
bestLen = 0
73+
}
74+
}
75+
continue
76+
}
77+
78+
if (normalized === rp) {
79+
if (rp.length > bestLen) {
80+
best = route
81+
bestLen = rp.length
82+
}
83+
} else if (normalized.startsWith(rp + '/')) {
84+
const remainder = normalized.substring(rp.length + 1)
85+
if (route.recursive) {
86+
if (rp.length > bestLen) {
87+
best = route
88+
bestLen = rp.length
89+
}
90+
} else {
91+
if (!remainder.includes('/') && rp.length > bestLen) {
92+
best = route
93+
bestLen = rp.length
94+
}
95+
}
96+
}
97+
}
98+
99+
return best
100+
}
101+
102+
/** Check if a path has any matching route (is served at all). */
103+
export function isPathServed(path: string, config: ServeConfig): boolean {
104+
return findRoute(path, config) !== null
105+
}
106+
107+
/**
108+
* Check if directory listing is allowed for this path.
109+
* - true: all items shown
110+
* - false: no listing
111+
* - string[]: only items with matching names shown (use isNameListed to filter)
112+
*/
113+
export function canList(path: string, config: ServeConfig): boolean {
114+
const route = findRoute(path, config)
115+
if (!route) return false
116+
return route.listing !== false
117+
}
118+
119+
/** Check if a specific name is included in the listing filter. */
120+
export function isNameListed(name: string, route: RouteConfig): boolean {
121+
if (route.listing === true) return true
122+
if (route.listing === false) return false
123+
return route.listing.includes(name)
124+
}
125+
126+
/** Check if file downloads are allowed for this path. */
127+
export function canDownload(path: string, config: ServeConfig): boolean {
128+
const route = findRoute(path, config)
129+
if (!route) return false
130+
return route.download
131+
}

0 commit comments

Comments
 (0)