Files: a sweep of the edges

Found by walking every handler and the UI with odd inputs, then a battery of requests
against the live hub. Fixed:
- A save over the 8 MiB cap was cut short in place: the file was truncated, then the
  first 8 MiB written, and the caller told 204. Now the body is read whole first and a
  body over the cap is a 413 with the file untouched.
- A link to a file showed the link's own time; now the file's, so the row reads right
  and the preview key follows the file.
- Reading a link to nothing answered 200 with an empty body (the open failed after the
  headers in root mode); now a 404 that names the target.
- A play request with an unknown codec or size was answered by inspecting the file
  first, so an audio-only file said "no video stream" instead of what was wrong; the
  choice is checked before any work, and is a 400.
- A stream that ended in failure was handed back to the next identical request for ten
  minutes; a failed one is now dropped and a fresh one made.
- A player that left while its stream was still finding a first segment made the loop
  try the next recipe; it stops. A stream closed mid-start is not started again.
- Ending a root-mode ffmpeg could wait forever: the daemon cannot signal root's
  processes, so the wait is bounded and the helper's own grace does the rest.
- A page going away (a phone closing the tab) releases its stream by a beacon, rather
  than ten minutes later by the sweeper.
- The path box resolves like a shell — relative to the folder shown, . and .. applied,
  trailing slashes dropped — in the tab and in the Move picker, with a test. A typed path
  that is a file opens it; a remembered folder that is gone falls back to / once.
- A dropped folder arrived as a nameless empty file; it is refused with a word. The
  upload toast counts what was actually uploaded.
- The viewer names a device, pipe or socket, and a dangling link, instead of calling
  them binary files.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mitchell-Johnson
2026-10-11 20:31:10 -07:00
co-authored by Claude Fable 5.1
parent d200e5dc2e
commit 1038c010fa
13 changed files with 210 additions and 26 deletions
+42
View File
@@ -1,6 +1,7 @@
package api package api
import ( import (
"bytes"
"net/http/httptest" "net/http/httptest"
"net/url" "net/url"
"os" "os"
@@ -60,3 +61,44 @@ func TestFilesReadRanges(t *testing.T) {
} }
} }
} }
// A write over the cap is refused whole: the file is not truncated first.
func TestFilesWriteCap(t *testing.T) {
p := filepath.Join(t.TempDir(), "f.txt")
if err := os.WriteFile(p, []byte("keep me"), 0o644); err != nil {
t.Fatal(err)
}
s := &Server{fsLocal: files.Local{}}
body := make([]byte, 8<<20+1)
r := httptest.NewRequest("PUT", "/api/files/write?path="+url.QueryEscape(p), bytes.NewReader(body))
w := httptest.NewRecorder()
s.filesWrite(w, r)
if w.Code != 413 {
t.Fatalf("got %d", w.Code)
}
if b, _ := os.ReadFile(p); string(b) != "keep me" {
t.Fatalf("the file was touched: %q", b)
}
r = httptest.NewRequest("PUT", "/api/files/write?path="+url.QueryEscape(p), bytes.NewReader([]byte("new")))
w = httptest.NewRecorder()
s.filesWrite(w, r)
if b, _ := os.ReadFile(p); w.Code != 204 || string(b) != "new" {
t.Fatalf("a write within the cap: %d %q", w.Code, b)
}
}
// A link to nothing is a 404, not an empty 200.
func TestFilesReadDanglingLink(t *testing.T) {
dir := t.TempDir()
l := filepath.Join(dir, "broken")
if err := os.Symlink("nowhere", l); err != nil {
t.Fatal(err)
}
s := &Server{fsLocal: files.Local{}}
r := httptest.NewRequest("GET", "/api/files/read?path="+url.QueryEscape(l), nil)
w := httptest.NewRecorder()
s.filesRead(w, r)
if w.Code != 404 {
t.Fatalf("got %d: %s", w.Code, w.Body.String())
}
}
+8 -1
View File
@@ -79,6 +79,10 @@ func (s *Server) playStart(w http.ResponseWriter, r *http.Request) {
jsonErr(w, 415, "not a video") jsonErr(w, 415, "not a video")
return return
} }
if err := media.ValidChoice(body.Codec, body.Quality); err != nil {
jsonErr(w, 400, err.Error())
return
}
if _, err := fsys.Stat(r.Context(), p); err != nil { if _, err := fsys.Stat(r.Context(), p); err != nil {
fsErr(w, err) fsErr(w, err)
return return
@@ -115,6 +119,8 @@ func playErr(w http.ResponseWriter, err error) {
jsonErr(w, 422, de.Msg) jsonErr(w, 422, de.Msg)
case strings.Contains(err.Error(), "already being re-encoded"): case strings.Contains(err.Error(), "already being re-encoded"):
jsonErr(w, 429, err.Error()) jsonErr(w, 429, err.Error())
case strings.HasPrefix(err.Error(), "unknown ") || strings.HasPrefix(err.Error(), "nothing on this machine encodes"):
jsonErr(w, 400, err.Error())
case errors.Is(err, context.DeadlineExceeded): case errors.Is(err, context.DeadlineExceeded):
jsonErr(w, 504, "the file took too long to read") jsonErr(w, 504, "the file took too long to read")
default: default:
@@ -132,7 +138,8 @@ func (s *Server) playStatus(w http.ResponseWriter, r *http.Request) {
jsonOK(w, 200, st.Status()) jsonOK(w, 200, st.Status())
} }
// playStop: DELETE /api/files/play/{id} // playStop: DELETE /api/files/play/{id}, or POST …/close — the form a page can send on its
// way out (navigator.sendBeacon), so a phone closing the tab releases the machine at once.
func (s *Server) playStop(w http.ResponseWriter, r *http.Request) { func (s *Server) playStop(w http.ResponseWriter, r *http.Request) {
s.plays.Close(r.PathValue("id")) s.plays.Close(r.PathValue("id"))
w.WriteHeader(204) w.WriteHeader(204)
+13 -1
View File
@@ -317,6 +317,7 @@ func (s *Server) routes() *http.ServeMux {
m.HandleFunc("POST /api/files/play", s.auth(s.playStart)) m.HandleFunc("POST /api/files/play", s.auth(s.playStart))
m.HandleFunc("GET /api/files/play/{id}", s.auth(s.playStatus)) m.HandleFunc("GET /api/files/play/{id}", s.auth(s.playStatus))
m.HandleFunc("DELETE /api/files/play/{id}", s.auth(s.playStop)) m.HandleFunc("DELETE /api/files/play/{id}", s.auth(s.playStop))
m.HandleFunc("POST /api/files/play/{id}/close", s.auth(s.playStop))
m.HandleFunc("GET /api/files/play/{id}/{file}", s.auth(s.playFile)) m.HandleFunc("GET /api/files/play/{id}/{file}", s.auth(s.playFile))
m.HandleFunc("GET /api/hardware", s.auth(s.hardwareReport)) m.HandleFunc("GET /api/hardware", s.auth(s.hardwareReport))
m.HandleFunc("GET /api/network", s.auth(s.networkOverview)) m.HandleFunc("GET /api/network", s.auth(s.networkOverview))
@@ -1931,6 +1932,10 @@ func (s *Server) filesRead(w http.ResponseWriter, r *http.Request) {
jsonErr(w, 400, "not a regular file (device, pipe or socket)") jsonErr(w, 400, "not a regular file (device, pipe or socket)")
return return
} }
if in.Broken {
jsonErr(w, 404, "a link to nothing: "+in.Target)
return
}
mx, _ := strconv.ParseInt(r.URL.Query().Get("max"), 10, 64) mx, _ := strconv.ParseInt(r.URL.Query().Get("max"), 10, 64)
if mx > 0 && in.Size > mx { if mx > 0 && in.Size > mx {
jsonErr(w, 413, "file too large") jsonErr(w, 413, "file too large")
@@ -1989,7 +1994,14 @@ func (s *Server) filesWrite(w http.ResponseWriter, r *http.Request) {
if !ok { if !ok {
return return
} }
if err := fsys.Write(r.Context(), p, io.LimitReader(r.Body, 8<<20)); err != nil { // Whole in memory first: the write truncates the file, so a body over the cap must be
// refused before that, not cut short after it.
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 8<<20))
if err != nil {
jsonErr(w, 413, "too large to save here (8 MiB at most)")
return
}
if err := fsys.Write(r.Context(), p, bytes.NewReader(body)); err != nil {
fsErr(w, err) fsErr(w, err)
return return
} }
+1 -1
View File
@@ -142,7 +142,7 @@ func entryOf(dir, name string, lst os.FileInfo) Entry {
} else if st.IsDir() { } else if st.IsDir() {
e.Type = "dir" // keep dir semantics for navigation; Target says it's a link e.Type = "dir" // keep dir semantics for navigation; Target says it's a link
} else { } else {
e.Size = st.Size() e.Size, e.Mtime = st.Size(), st.ModTime().UnixMilli() // the file's, not the link's
} }
case lst.IsDir(): case lst.IsDir():
e.Type = "dir" e.Type = "dir"
+27
View File
@@ -166,3 +166,30 @@ func TestMoveSameFilesystem(t *testing.T) {
t.Fatalf("onto an existing file: %v", err) t.Fatalf("onto an existing file: %v", err)
} }
} }
// A link to a file carries the file's size and time, so the row and the preview key follow
// the file; a link to a folder is a folder; a link to nothing says so.
func TestEntryOfLinks(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "f"), []byte("12345"), 0o644); err != nil {
t.Fatal(err)
}
old := time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC)
_ = os.Chtimes(filepath.Join(dir, "f"), old, old)
_ = os.Mkdir(filepath.Join(dir, "d"), 0o755)
for _, l := range [][2]string{{"lf", "f"}, {"ld", "d"}, {"lx", "nowhere"}} {
if err := os.Symlink(l[1], filepath.Join(dir, l[0])); err != nil {
t.Fatal(err)
}
}
ent := func(n string) Entry { st, _ := os.Lstat(filepath.Join(dir, n)); return entryOf(dir, n, st) }
if e := ent("lf"); e.Type != "link" || e.Size != 5 || e.Mtime != old.UnixMilli() || e.Target != "f" || e.Broken {
t.Fatalf("link to file: %+v", e)
}
if e := ent("ld"); e.Type != "dir" || e.Target != "d" {
t.Fatalf("link to dir: %+v", e)
}
if e := ent("lx"); e.Type != "link" || !e.Broken || e.Preview != "" {
t.Fatalf("dangling: %+v", e)
}
}
+11
View File
@@ -39,6 +39,17 @@ func qualityByID(id string) (Quality, bool) {
return Quality{}, false return Quality{}, false
} }
// ValidChoice says whether a codec and quality are ones on offer — checked before any work.
func ValidChoice(codec, quality string) error {
if !codecKnown(codec) {
return fmt.Errorf("unknown codec %q", codec)
}
if _, ok := qualityByID(quality); !ok {
return fmt.Errorf("unknown quality %q", quality)
}
return nil
}
func codecKnown(id string) bool { func codecKnown(id string) bool {
for _, c := range Codecs { for _, c := range Codecs {
if c.ID == id { if c.ID == id {
+10 -1
View File
@@ -21,7 +21,7 @@ func TestFit(t *testing.T) {
{"original", src("hevc", 3840, 2160, 30), "h264", 3840, 2160, 20000}, {"original", src("hevc", 3840, 2160, 30), "h264", 3840, 2160, 20000},
{"720p", src("h264", 2292, 960, 60), "h264", 1718, 720, 5000}, // odd width made even {"720p", src("h264", 2292, 960, 60), "h264", 1718, 720, 5000}, // odd width made even
{"1080p", src("h264", 1280, 720, 30), "h264", 1280, 720, 4000}, // never upscaled {"1080p", src("h264", 1280, 720, 30), "h264", 1280, 720, 4000}, // never upscaled
{"480p", src("h264", 1920, 1080, 30), "hevc", 852, 480, 1200}, // 853.3 rounds down, then even {"480p", src("h264", 1920, 1080, 30), "hevc", 852, 480, 1200}, // 853.3 rounds down, then even
{"original", src("av1", 640, 360, 24), "av1", 640, 360, 1200}, {"original", src("av1", 640, 360, 24), "av1", 640, 360, 1200},
} { } {
w, h, k := fit(q(c.q), c.in, c.codec) w, h, k := fit(q(c.q), c.in, c.codec)
@@ -117,3 +117,12 @@ func TestParseProgress(t *testing.T) {
t.Fatalf("got %v %v", s, sp) t.Fatalf("got %v %v", s, sp)
} }
} }
func TestValidChoice(t *testing.T) {
if err := ValidChoice("h264", "720p"); err != nil {
t.Fatal(err)
}
if ValidChoice("vp8", "720p") == nil || ValidChoice("h264", "9p") == nil || ValidChoice("", "") == nil {
t.Fatal("bad choices must be refused")
}
}
+5 -2
View File
@@ -84,8 +84,11 @@ func (p *ffproc) end() {
select { select {
case <-p.done: case <-p.done:
case <-time.After(5 * time.Second): case <-time.After(5 * time.Second):
_ = syscall.Kill(-p.cmd.Process.Pid, syscall.SIGKILL) _ = syscall.Kill(-p.cmd.Process.Pid, syscall.SIGKILL) // a root helper ignores this; it ends on its own within its own grace
<-p.done select {
case <-p.done:
case <-time.After(10 * time.Second):
}
} }
} }
+31 -10
View File
@@ -47,6 +47,7 @@ type Stream struct {
proc *ffproc proc *ffproc
finished bool finished bool
failure string failure string
gone bool // forgotten by the Streamer
} }
// StreamStatus is what the player asks for while the file is still going through. // StreamStatus is what the player asks for while the file is still going through.
@@ -93,20 +94,25 @@ func (m *Streamer) Open(ctx context.Context, c Caps, root bool, path, codec, qua
if c.Error != "" { if c.Error != "" {
return nil, errors.New(c.Error) return nil, errors.New(c.Error)
} }
if err := ValidChoice(codec, quality); err != nil {
return nil, err
}
q, _ := qualityByID(quality)
if src.Video == nil { if src.Video == nil {
return nil, &DecodeError{Msg: "no video stream"} return nil, &DecodeError{Msg: "no video stream"}
} }
q, ok := qualityByID(quality) if src.Video.Width <= 0 || src.Video.Height <= 0 {
if !ok { return nil, &DecodeError{Msg: "the picture's size is unknown"}
return nil, errors.New("unknown quality " + quality)
}
if !codecKnown(codec) {
return nil, errors.New("unknown codec " + codec)
} }
key := fmt.Sprintf("%s|%d|%.0f|%s|%s|%v", path, src.Size, src.Duration, codec, quality, root) key := fmt.Sprintf("%s|%d|%.0f|%s|%s|%v", path, src.Size, src.Duration, codec, quality, root)
m.mu.Lock() m.mu.Lock()
for _, s := range m.streams { for _, s := range m.streams {
if s.key == key { if s.key == key {
if s.failed() { // not the stream to hand back: let it go and make a fresh one
delete(m.streams, s.ID)
_ = os.RemoveAll(s.dir)
break
}
m.mu.Unlock() m.mu.Unlock()
s.touch() s.touch()
return s, nil return s, nil
@@ -152,15 +158,19 @@ func (m *Streamer) Open(ctx context.Context, c Caps, root bool, path, codec, qua
s.mu.Lock() s.mu.Lock()
s.proc, s.Engine = p, r.Engine s.proc, s.Engine = p, r.Engine
s.mu.Unlock() s.mu.Unlock()
if msg, ok := s.waitFirstSegment(ctx); ok { msg, ok := s.waitFirstSegment(ctx)
if ok {
go s.watch() go s.watch()
log.Printf("media: stream %s: %s → %s %s %dx%d %dk on %s", s.ID, filepath.Base(path), codec, quality, s.Width, s.Height, s.Kbps, r.Engine) log.Printf("media: stream %s: %s → %s %s %dx%d %dk on %s", s.ID, filepath.Base(path), codec, quality, s.Width, s.Height, s.Kbps, r.Engine)
return s, nil return s, nil
} else {
last = r.Engine + ": " + msg
log.Printf("media: stream %s on %s: %s", filepath.Base(path), r.Engine, msg)
} }
p.end() p.end()
if ctx.Err() != nil || s.closed() { // the caller left, or somebody closed it meanwhile: not the recipe's fault
m.forget(s)
return nil, errors.New("cancelled")
}
last = r.Engine + ": " + msg
log.Printf("media: stream %s on %s: %s", filepath.Base(path), r.Engine, msg)
} }
m.forget(s) m.forget(s)
return nil, &DecodeError{Msg: last} return nil, &DecodeError{Msg: last}
@@ -240,9 +250,20 @@ func (m *Streamer) forget(s *Stream) {
m.mu.Lock() m.mu.Lock()
delete(m.streams, s.ID) delete(m.streams, s.ID)
m.mu.Unlock() m.mu.Unlock()
s.mu.Lock()
s.gone = true
s.mu.Unlock()
_ = os.RemoveAll(s.dir) _ = os.RemoveAll(s.dir)
} }
func (s *Stream) closed() bool { s.mu.Lock(); defer s.mu.Unlock(); return s.gone }
func (s *Stream) failed() bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.finished && s.failure != ""
}
// Sweep closes what nobody has touched for a while. // Sweep closes what nobody has touched for a while.
func (m *Streamer) Sweep(idle time.Duration) { func (m *Streamer) Sweep(idle time.Duration) {
m.mu.Lock() m.mu.Lock()
+1 -1
View File
@@ -7,7 +7,7 @@
"dev": "vite", "dev": "vite",
"build": "tsc --noEmit && vite build", "build": "tsc --noEmit && vite build",
"icons": "node scripts/icons.mjs", "icons": "node scripts/icons.mjs",
"test": "node scripts/test-graphcols.mjs && node scripts/test-css-imports.mjs" "test": "node scripts/test-graphcols.mjs && node scripts/test-css-imports.mjs && node scripts/test-resolve-path.mjs"
}, },
"dependencies": { "dependencies": {
"@xterm/addon-fit": "^0.10.0", "@xterm/addon-fit": "^0.10.0",
+16
View File
@@ -0,0 +1,16 @@
// node scripts/test-resolve-path.mjs — the path box resolves like a shell: relative to here,
// . and .. applied, trailing slashes dropped, never anything but an absolute path.
import { readFileSync } from 'node:fs'
const src = readFileSync(new URL('../src/Files.tsx', import.meta.url), 'utf8')
const m = src.match(/export function resolvePath[\s\S]*?\n}\n/)
if (!m) { console.error('resolvePath not found'); process.exit(1) }
const resolvePath = new Function(m[0].replace('export function', 'return function').replace(/: string\[\]/g, '').replace(/: string/g, '') + ' ')()
const cases = [
['/tank/media', '/x', '/tank/media'], ['/tank/media/', '/x', '/tank/media'], ['', '/here', '/here'], [' ', '/here', '/here'],
['sub', '/here', '/here/sub'], ['./sub/', '/here', '/here/sub'], ['..', '/a/b', '/a'], ['../..', '/a/b', '/'], ['../../..', '/a', '/'],
['/', '/a', '/'], ['a//b', '/', '/a/b'], ['/tank/./x/../y', '/', '/tank/y'], ['sp ace #1', '/d', '/d/sp ace #1'],
]
let bad = 0
for (const [input, here, want] of cases) { const got = resolvePath(input, here); if (got !== want) { bad++; console.error(`resolvePath(${JSON.stringify(input)}, ${here}) = ${got}, want ${want}`) } }
if (bad) process.exit(1)
console.log(`resolve path: ok (${cases.length} cases)`)
+38 -8
View File
@@ -7,6 +7,19 @@ import TaskOutput from './TaskOutput'
import Player from './Player' import Player from './Player'
const join = (dir: string, name: string) => (dir === '/' ? '/' + name : dir + '/' + name) const join = (dir: string, name: string) => (dir === '/' ? '/' + name : dir + '/' + name)
// A typed path, made absolute: relative to the folder being looked at, "." and ".." applied,
// trailing slashes dropped — what a shell would make of it.
export function resolvePath(input: string, here: string): string {
const raw = input.trim()
if (!raw) return here
const parts = (raw.startsWith('/') ? [] : here.split('/')).concat(raw.split('/'))
const out: string[] = []
for (const p of parts) {
if (p === '' || p === '.') continue
if (p === '..') out.pop(); else out.push(p)
}
return '/' + out.join('/')
}
const fmtTime = (ms: number) => { const d = new Date(ms); const now = new Date(); return d.toDateString() === now.toDateString() ? d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : d.toLocaleDateString(undefined, { year: d.getFullYear() === now.getFullYear() ? undefined : 'numeric', month: 'short', day: '2-digit' }) } const fmtTime = (ms: number) => { const d = new Date(ms); const now = new Date(); return d.toDateString() === now.toDateString() ? d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : d.toLocaleDateString(undefined, { year: d.getFullYear() === now.getFullYear() ? undefined : 'numeric', month: 'short', day: '2-digit' }) }
const isImage = (n: string) => /\.(png|jpe?g|gif|webp|svg|bmp|ico|avif)$/i.test(n) const isImage = (n: string) => /\.(png|jpe?g|gif|webp|svg|bmp|ico|avif)$/i.test(n)
const shellQuote = (s: string) => "'" + s.replace(/'/g, "'\\''") + "'" const shellQuote = (s: string) => "'" + s.replace(/'/g, "'\\''") + "'"
@@ -48,9 +61,9 @@ function MovePicker({ item, from, sudo, hidden, onClose, onMoved }: { item: File
}, [sudo]) }, [sudo])
useEffect(() => { setPathInput(dir); load(dir) }, [dir, load]) useEffect(() => { setPathInput(dir); load(dir) }, [dir, load])
useEscape(onClose) useEscape(onClose)
const go = (p: string) => { const go = (input: string) => {
p = p.trim().replace(/\/+$/, '') || '/' const p = resolvePath(input, dir)
if (!p.startsWith('/') || p === dir) return if (p === dir) return
setHist((h) => [...h.slice(0, at + 1), p]); setAt(at + 1) setHist((h) => [...h.slice(0, at + 1), p]); setAt(at + 1)
} }
const up = () => { if (dir !== '/') go(dir.slice(0, dir.lastIndexOf('/')) || '/') } const up = () => { if (dir !== '/') go(dir.slice(0, dir.lastIndexOf('/')) || '/') }
@@ -263,7 +276,7 @@ function Viewer({ path, sudo, onClose, onChanged }: { path: string; sudo: boolea
<div className="notice">This browser can't play {name} as it is — the codec isn't one it decodes. Download it instead.</div> <div className="notice">This browser can't play {name} as it is — the codec isn't one it decodes. Download it instead.</div>
)} )}
{info && text === null && !isImage(name) && !info.preview && !err && ( {info && text === null && !isImage(name) && !info.preview && !err && (
<div className="notice">{info.type === 'dir' ? 'This is a directory.' : info.size > MAX_EDIT ? `Too large to edit here (${fmtBytes(info.size)}). Download it instead.` : 'Binary file. Download it instead.'}</div> <div className="notice">{info.type === 'dir' ? 'This is a directory.' : info.type === 'other' ? 'This is a device, pipe or socket — nothing to show.' : info.broken ? `This link points at nothing (${info.target}).` : info.size > MAX_EDIT ? `Too large to edit here (${fmtBytes(info.size)}). Download it instead.` : 'Binary file. Download it instead.'}</div>
)} )}
</div> </div>
) )
@@ -306,15 +319,22 @@ export default function Files({ onOpenInTerminal }: { onOpenInTerminal?: (cmd: s
sessionStorage.setItem('hamlab.files.path', p) // only a folder that opened is worth coming back to sessionStorage.setItem('hamlab.files.path', p) // only a folder that opened is worth coming back to
} catch (e) { } catch (e) {
const msg = e instanceof Error ? e.message : 'failed' const msg = e instanceof Error ? e.message : 'failed'
if (/not a directory/i.test(msg) && p !== '/') { // a file was typed: open it, from its folder
setPath(p.slice(0, p.lastIndexOf('/')) || '/'); setOpen(p); return
}
if (/no such file/i.test(msg) && p !== '/' && !sessionStorage.getItem('hamlab.files.lost')) { // gone since last time (another machine, a deleted folder)
sessionStorage.setItem('hamlab.files.lost', '1'); toast(`${p} isn't here — showing /`, 'info'); setPath('/'); return
}
setErr(msg); setEntries([]) setErr(msg); setEntries([])
} }
sessionStorage.removeItem('hamlab.files.lost')
}, [path, sudo]) }, [path, sudo])
useEffect(() => { setEntries(null); setPathInput(path); setMenu(null); load(path, sudo); sessionStorage.setItem('hamlab.files.sudo', sudo ? '1' : '0') }, [path, sudo, load]) useEffect(() => { setEntries(null); setPathInput(path); setMenu(null); load(path, sudo); sessionStorage.setItem('hamlab.files.sudo', sudo ? '1' : '0') }, [path, sudo, load])
useEffect(() => { const h = () => setMenu(null); window.addEventListener('click', h); return () => window.removeEventListener('click', h) }, []) useEffect(() => { const h = () => setMenu(null); window.addEventListener('click', h); return () => window.removeEventListener('click', h) }, [])
useEscape(() => setMenu(null), menu !== null) useEscape(() => setMenu(null), menu !== null)
const go = (p: string) => { setOpen(null); setPath(p || '/') } const go = (p: string) => { setOpen(null); setPath(resolvePath(p, path)) }
const crumbs = path === '/' ? [] : path.slice(1).split('/') const crumbs = path === '/' ? [] : path.slice(1).split('/')
const shown = (entries ?? []).filter((e) => (hidden || !e.name.startsWith('.')) && (!q || e.name.toLowerCase().includes(q.toLowerCase()))) const shown = (entries ?? []).filter((e) => (hidden || !e.name.startsWith('.')) && (!q || e.name.toLowerCase().includes(q.toLowerCase())))
@@ -348,6 +368,7 @@ export default function Files({ onOpenInTerminal }: { onOpenInTerminal?: (cmd: s
// ---- upload ---- // ---- upload ----
const uploadFiles = async (files: FileList | File[]) => { const uploadFiles = async (files: FileList | File[]) => {
const list = Array.from(files) const list = Array.from(files)
let done = 0
for (let i = 0; i < list.length; i++) { for (let i = 0; i < list.length; i++) {
const f = list[i] const f = list[i]
let overwrite = false let overwrite = false
@@ -358,13 +379,22 @@ export default function Files({ onOpenInTerminal }: { onOpenInTerminal?: (cmd: s
setUpload({ name: f.name, pct: 0, n: i + 1, of: list.length }) setUpload({ name: f.name, pct: 0, n: i + 1, of: list.length })
try { try {
await api.fileUpload(path, f, sudo, (pct) => setUpload({ name: f.name, pct, n: i + 1, of: list.length }), overwrite) await api.fileUpload(path, f, sudo, (pct) => setUpload({ name: f.name, pct, n: i + 1, of: list.length }), overwrite)
done++
} catch (e) { toast(`upload ${f.name} — ${e instanceof Error ? e.message : 'failed'}`, 'err'); break } } catch (e) { toast(`upload ${f.name} — ${e instanceof Error ? e.message : 'failed'}`, 'err'); break }
} }
setUpload(null) setUpload(null)
toast(list.length === 1 ? `uploaded ${list[0].name}` : `uploaded ${list.length} files`) if (done === 1) toast(`uploaded ${list.length === 1 ? list[0].name : '1 file'}`)
load() else if (done > 1) toast(`uploaded ${done} files`)
if (done > 0) load()
}
// A dropped folder arrives as a nameless 0-byte "file"; the browser gives no way to read it here.
const onDrop = (e: DragEvent) => {
e.preventDefault(); setDrag(false)
const items = Array.from(e.dataTransfer.items ?? [])
const files = Array.from(e.dataTransfer.files).filter((_, i) => !(items[i]?.webkitGetAsEntry?.()?.isDirectory))
if (files.length < e.dataTransfer.files.length) toast('folders can\'t be dropped — drop the files inside, or upload a zip', 'err')
if (files.length) uploadFiles(files)
} }
const onDrop = (e: DragEvent) => { e.preventDefault(); setDrag(false); if (e.dataTransfer.files.length) uploadFiles(e.dataTransfer.files) }
if (open) return <div className="files"><Viewer path={open} sudo={sudo} onClose={() => setOpen(null)} onChanged={() => load()} /></div> if (open) return <div className="files"><Viewer path={open} sudo={sudo} onClose={() => setOpen(null)} onChanged={() => load()} /></div>
+7 -1
View File
@@ -39,9 +39,15 @@ export default function Player({ path, sudo, name }: { path: string; sudo: boole
useEffect(() => { remember(QUALITY_KEY, quality) }, [quality]) useEffect(() => { remember(QUALITY_KEY, quality) }, [quality])
useEffect(() => { if (mode === 'pick' && !caps) api.playCaps(sudo).then(setCaps).catch((e) => setCaps({ codecs: [], qualities: [], error: e instanceof Error ? e.message : 'failed' })) }, [mode, caps, sudo]) useEffect(() => { if (mode === 'pick' && !caps) api.playCaps(sudo).then(setCaps).catch((e) => setCaps({ codecs: [], qualities: [], error: e instanceof Error ? e.message : 'failed' })) }, [mode, caps, sudo])
// a stream is the machine's work: let go of it when this player does // a stream is the machine's work: let go of it when this player does — and when the page
// itself goes, by a beacon, the one request a browser still sends on its way out
const stop = useCallback(() => { const s = streamRef.current; if (s) { api.playStop(s.s.id).catch(() => { /* gone already */ }); setStream(null); setStatus(null) } }, []) const stop = useCallback(() => { const s = streamRef.current; if (s) { api.playStop(s.s.id).catch(() => { /* gone already */ }); setStream(null); setStatus(null) } }, [])
useEffect(() => () => stop(), [stop]) useEffect(() => () => stop(), [stop])
useEffect(() => {
const h = () => { const s = streamRef.current; if (s) navigator.sendBeacon(apiPath(`/api/files/play/${s.s.id}/close`)) }
window.addEventListener('pagehide', h)
return () => window.removeEventListener('pagehide', h)
}, [])
const start = async () => { const start = async () => {
setErr('') setErr('')