diff --git a/internal/api/files_test.go b/internal/api/files_test.go index 06fcaea..370d070 100644 --- a/internal/api/files_test.go +++ b/internal/api/files_test.go @@ -1,6 +1,7 @@ package api import ( + "bytes" "net/http/httptest" "net/url" "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()) + } +} diff --git a/internal/api/play.go b/internal/api/play.go index 7104f79..0dbaf2e 100644 --- a/internal/api/play.go +++ b/internal/api/play.go @@ -79,6 +79,10 @@ func (s *Server) playStart(w http.ResponseWriter, r *http.Request) { jsonErr(w, 415, "not a video") 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 { fsErr(w, err) return @@ -115,6 +119,8 @@ func playErr(w http.ResponseWriter, err error) { jsonErr(w, 422, de.Msg) case strings.Contains(err.Error(), "already being re-encoded"): 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): jsonErr(w, 504, "the file took too long to read") default: @@ -132,7 +138,8 @@ func (s *Server) playStatus(w http.ResponseWriter, r *http.Request) { 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) { s.plays.Close(r.PathValue("id")) w.WriteHeader(204) diff --git a/internal/api/server.go b/internal/api/server.go index c77b030..5420e4b 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -317,6 +317,7 @@ func (s *Server) routes() *http.ServeMux { m.HandleFunc("POST /api/files/play", s.auth(s.playStart)) m.HandleFunc("GET /api/files/play/{id}", s.auth(s.playStatus)) 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/hardware", s.auth(s.hardwareReport)) 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)") return } + if in.Broken { + jsonErr(w, 404, "a link to nothing: "+in.Target) + return + } mx, _ := strconv.ParseInt(r.URL.Query().Get("max"), 10, 64) if mx > 0 && in.Size > mx { jsonErr(w, 413, "file too large") @@ -1989,7 +1994,14 @@ func (s *Server) filesWrite(w http.ResponseWriter, r *http.Request) { if !ok { 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) return } diff --git a/internal/files/files.go b/internal/files/files.go index 95ac42c..282aff8 100644 --- a/internal/files/files.go +++ b/internal/files/files.go @@ -142,7 +142,7 @@ func entryOf(dir, name string, lst os.FileInfo) Entry { } else if st.IsDir() { e.Type = "dir" // keep dir semantics for navigation; Target says it's a link } else { - e.Size = st.Size() + e.Size, e.Mtime = st.Size(), st.ModTime().UnixMilli() // the file's, not the link's } case lst.IsDir(): e.Type = "dir" diff --git a/internal/files/move_test.go b/internal/files/move_test.go index eb4f085..7433063 100644 --- a/internal/files/move_test.go +++ b/internal/files/move_test.go @@ -166,3 +166,30 @@ func TestMoveSameFilesystem(t *testing.T) { 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) + } +} diff --git a/internal/media/encode.go b/internal/media/encode.go index f20c53f..7637840 100644 --- a/internal/media/encode.go +++ b/internal/media/encode.go @@ -39,6 +39,17 @@ func qualityByID(id string) (Quality, bool) { 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 { for _, c := range Codecs { if c.ID == id { diff --git a/internal/media/encode_test.go b/internal/media/encode_test.go index 99efc02..e53423c 100644 --- a/internal/media/encode_test.go +++ b/internal/media/encode_test.go @@ -21,7 +21,7 @@ func TestFit(t *testing.T) { {"original", src("hevc", 3840, 2160, 30), "h264", 3840, 2160, 20000}, {"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 - {"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}, } { 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) } } + +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") + } +} diff --git a/internal/media/ffrun.go b/internal/media/ffrun.go index 02e3acc..754a31b 100644 --- a/internal/media/ffrun.go +++ b/internal/media/ffrun.go @@ -84,8 +84,11 @@ func (p *ffproc) end() { select { case <-p.done: case <-time.After(5 * time.Second): - _ = syscall.Kill(-p.cmd.Process.Pid, syscall.SIGKILL) - <-p.done + _ = syscall.Kill(-p.cmd.Process.Pid, syscall.SIGKILL) // a root helper ignores this; it ends on its own within its own grace + select { + case <-p.done: + case <-time.After(10 * time.Second): + } } } diff --git a/internal/media/hls.go b/internal/media/hls.go index 4a888a8..53d7eb4 100644 --- a/internal/media/hls.go +++ b/internal/media/hls.go @@ -47,6 +47,7 @@ type Stream struct { proc *ffproc finished bool failure string + gone bool // forgotten by the Streamer } // 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 != "" { return nil, errors.New(c.Error) } + if err := ValidChoice(codec, quality); err != nil { + return nil, err + } + q, _ := qualityByID(quality) if src.Video == nil { return nil, &DecodeError{Msg: "no video stream"} } - q, ok := qualityByID(quality) - if !ok { - return nil, errors.New("unknown quality " + quality) - } - if !codecKnown(codec) { - return nil, errors.New("unknown codec " + codec) + if src.Video.Width <= 0 || src.Video.Height <= 0 { + return nil, &DecodeError{Msg: "the picture's size is unknown"} } key := fmt.Sprintf("%s|%d|%.0f|%s|%s|%v", path, src.Size, src.Duration, codec, quality, root) m.mu.Lock() for _, s := range m.streams { 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() s.touch() 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.proc, s.Engine = p, r.Engine s.mu.Unlock() - if msg, ok := s.waitFirstSegment(ctx); ok { + msg, ok := s.waitFirstSegment(ctx) + if ok { 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) return s, nil - } else { - last = r.Engine + ": " + msg - log.Printf("media: stream %s on %s: %s", filepath.Base(path), r.Engine, msg) } 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) return nil, &DecodeError{Msg: last} @@ -240,9 +250,20 @@ func (m *Streamer) forget(s *Stream) { m.mu.Lock() delete(m.streams, s.ID) m.mu.Unlock() + s.mu.Lock() + s.gone = true + s.mu.Unlock() _ = 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. func (m *Streamer) Sweep(idle time.Duration) { m.mu.Lock() diff --git a/web/package.json b/web/package.json index c4b3e4c..5e2d67d 100644 --- a/web/package.json +++ b/web/package.json @@ -7,7 +7,7 @@ "dev": "vite", "build": "tsc --noEmit && vite build", "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": { "@xterm/addon-fit": "^0.10.0", diff --git a/web/scripts/test-resolve-path.mjs b/web/scripts/test-resolve-path.mjs new file mode 100644 index 0000000..48ee096 --- /dev/null +++ b/web/scripts/test-resolve-path.mjs @@ -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)`) diff --git a/web/src/Files.tsx b/web/src/Files.tsx index 1c04bfd..1f358de 100644 --- a/web/src/Files.tsx +++ b/web/src/Files.tsx @@ -7,6 +7,19 @@ import TaskOutput from './TaskOutput' import Player from './Player' 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 isImage = (n: string) => /\.(png|jpe?g|gif|webp|svg|bmp|ico|avif)$/i.test(n) const shellQuote = (s: string) => "'" + s.replace(/'/g, "'\\''") + "'" @@ -48,9 +61,9 @@ function MovePicker({ item, from, sudo, hidden, onClose, onMoved }: { item: File }, [sudo]) useEffect(() => { setPathInput(dir); load(dir) }, [dir, load]) useEscape(onClose) - const go = (p: string) => { - p = p.trim().replace(/\/+$/, '') || '/' - if (!p.startsWith('/') || p === dir) return + const go = (input: string) => { + const p = resolvePath(input, dir) + if (p === dir) return setHist((h) => [...h.slice(0, at + 1), p]); setAt(at + 1) } const up = () => { if (dir !== '/') go(dir.slice(0, dir.lastIndexOf('/')) || '/') } @@ -263,7 +276,7 @@ function Viewer({ path, sudo, onClose, onChanged }: { path: string; sudo: boolea
This browser can't play {name} as it is — the codec isn't one it decodes. Download it instead.
)} {info && text === null && !isImage(name) && !info.preview && !err && ( -
{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.'}
+
{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.'}
)} ) @@ -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 } catch (e) { 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([]) } + sessionStorage.removeItem('hamlab.files.lost') }, [path, sudo]) 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) }, []) 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 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 ---- const uploadFiles = async (files: FileList | File[]) => { const list = Array.from(files) + let done = 0 for (let i = 0; i < list.length; i++) { const f = list[i] 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 }) try { 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 } } setUpload(null) - toast(list.length === 1 ? `uploaded ${list[0].name}` : `uploaded ${list.length} files`) - load() + if (done === 1) toast(`uploaded ${list.length === 1 ? list[0].name : '1 file'}`) + 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
setOpen(null)} onChanged={() => load()} />
diff --git a/web/src/Player.tsx b/web/src/Player.tsx index 2435bab..a8607e9 100644 --- a/web/src/Player.tsx +++ b/web/src/Player.tsx @@ -39,9 +39,15 @@ export default function Player({ path, sudo, name }: { path: string; sudo: boole 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]) - // 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) } }, []) 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 () => { setErr('')