Files
hamlab/internal/files/files.go
T
Mitchell-JohnsonandClaude Fable 5.1 a9bcb88212 Files: play video and audio in the viewer, as the file is; reads answer Range requests
The viewer hands a video or audio file to the browser untouched — container, codec and
all — and the browser plays what it can; when it can't, the viewer says so and offers the
download. What the server adds is the part a player needs: /api/files/read now goes
through http.ServeContent for a real file of known size, so Range requests (seeking) and
HEAD work, and a download can resume. The root backend's helper takes an offset for that,
and a Seeker opens the backend once, at the range asked for, instead of streaming from
the top. Audio files are a preview kind too: their cover art is the thumbnail.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-11 16:40:42 -07:00

572 lines
15 KiB
Go

// Package files is the Files tab: browse, read, write, upload, download, mkdir,
// rename, chmod and delete anywhere on the host.
//
// Two backends share one interface. Local runs in-process as the daemon's user. Sudo
// re-executes this same binary as root (`sudo -n hamlab fs …`) for one operation at a
// time, streaming file bodies over its stdin/stdout, so root access uses exactly the
// same code and the same validation as the unprivileged path.
package files
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"os/exec"
"os/signal"
"os/user"
"path/filepath"
"sort"
"strconv"
"strings"
"syscall"
"time"
"hamlab/internal/priv"
"hamlab/internal/thumbs"
)
type Entry struct {
Name string `json:"name"`
Type string `json:"type"` // dir | file | link | other
Size int64 `json:"size"`
Mode string `json:"mode"` // "-rw-r--r--"
Perm string `json:"perm"` // "0644"
Mtime int64 `json:"mtime"`
User string `json:"user"`
Group string `json:"group"`
Target string `json:"target,omitempty"` // link target
Broken bool `json:"broken,omitempty"` // dangling link
Preview string `json:"preview,omitempty"` // "image" | "video": the Files tab can show a thumbnail of it
}
type Listing struct {
Path string `json:"path"`
Parent string `json:"parent,omitempty"`
Entries []Entry `json:"entries"`
}
type Info struct {
Entry
Path string `json:"path"`
Text bool `json:"text"` // looks like text (for the editor)
}
// FS is what the API talks to.
type FS interface {
List(ctx context.Context, path string) (Listing, error)
Stat(ctx context.Context, path string) (Info, error)
Open(ctx context.Context, path string) (io.ReadCloser, error)
OpenAt(ctx context.Context, path string, offset int64) (io.ReadCloser, error) // Open from a byte offset: a range request, a player seeking
Write(ctx context.Context, path string, r io.Reader) error
Mkdir(ctx context.Context, path string) error
Touch(ctx context.Context, path string) error
Rename(ctx context.Context, from, to string) error
Move(ctx context.Context, from, to string, progress io.Writer) error // Rename, or across filesystems a copy and a removal; long, so callers run it as a task
Delete(ctx context.Context, path string, recursive bool) error
Chmod(ctx context.Context, path string, perm os.FileMode) error
}
// ---- path rules ----
// Clean rejects relative paths and normalises the rest. Everything else on the host is
// reachable on purpose: this is a root-capable console, the gate is the login.
func Clean(p string) (string, error) {
if p == "" {
return "", errors.New("path required")
}
if !strings.HasPrefix(p, "/") {
return "", errors.New("path must be absolute")
}
c := filepath.Clean(p)
if strings.ContainsRune(c, 0) {
return "", errors.New("bad path")
}
return c, nil
}
// protected are never deleted or renamed as a whole, no matter who asks.
var protected = map[string]bool{"/": true, "/bin": true, "/boot": true, "/dev": true, "/etc": true, "/home": true, "/lib": true, "/lib64": true,
"/opt": true, "/proc": true, "/root": true, "/run": true, "/sbin": true, "/srv": true, "/sys": true, "/tmp": true, "/usr": true, "/var": true}
func ParsePerm(s string) (os.FileMode, error) {
v, err := strconv.ParseUint(strings.TrimPrefix(s, "0"), 8, 32)
if err != nil || v > 0o7777 {
return 0, errors.New("mode must be octal like 0644")
}
return os.FileMode(v), nil
}
// ---- Local ----
type Local struct{}
var uidCache, gidCache = map[uint32]string{}, map[uint32]string{}
func owner(st os.FileInfo) (string, string) {
sys, ok := st.Sys().(*syscall.Stat_t)
if !ok {
return "", ""
}
u, ok := uidCache[sys.Uid]
if !ok {
u = strconv.Itoa(int(sys.Uid))
if x, err := user.LookupId(u); err == nil {
u = x.Username
}
uidCache[sys.Uid] = u
}
g, ok := gidCache[sys.Gid]
if !ok {
g = strconv.Itoa(int(sys.Gid))
if x, err := user.LookupGroupId(g); err == nil {
g = x.Name
}
gidCache[sys.Gid] = g
}
return u, g
}
func entryOf(dir, name string, lst os.FileInfo) Entry {
e := Entry{Name: name, Size: lst.Size(), Mode: lst.Mode().String(), Perm: fmt.Sprintf("%04o", lst.Mode().Perm()), Mtime: lst.ModTime().UnixMilli()}
e.User, e.Group = owner(lst)
switch {
case lst.Mode()&os.ModeSymlink != 0:
e.Type = "link"
full := filepath.Join(dir, name)
e.Target, _ = os.Readlink(full)
if st, err := os.Stat(full); err != nil {
e.Broken = true
} else if st.IsDir() {
e.Type = "dir" // keep dir semantics for navigation; Target says it's a link
} else {
e.Size = st.Size()
}
case lst.IsDir():
e.Type = "dir"
case lst.Mode().IsRegular():
e.Type = "file"
default:
e.Type = "other"
}
if e.Type == "file" || (e.Type == "link" && !e.Broken) {
e.Preview = thumbs.Kind(name)
}
return e
}
func (Local) List(_ context.Context, path string) (Listing, error) {
ents, err := os.ReadDir(path)
if err != nil {
return Listing{}, err
}
l := Listing{Path: path, Entries: make([]Entry, 0, len(ents))}
if path != "/" {
l.Parent = filepath.Dir(path)
}
for _, d := range ents {
lst, err := d.Info() // Lstat
if err != nil {
continue
}
l.Entries = append(l.Entries, entryOf(path, d.Name(), lst))
}
sort.Slice(l.Entries, func(i, j int) bool {
a, b := l.Entries[i], l.Entries[j]
if (a.Type == "dir") != (b.Type == "dir") {
return a.Type == "dir"
}
return strings.ToLower(a.Name) < strings.ToLower(b.Name)
})
return l, nil
}
func (Local) Stat(_ context.Context, path string) (Info, error) {
lst, err := os.Lstat(path)
if err != nil {
return Info{}, err
}
in := Info{Entry: entryOf(filepath.Dir(path), filepath.Base(path), lst), Path: path}
if in.Type == "file" && in.Size <= 8<<20 {
if f, err := os.Open(path); err == nil {
buf := make([]byte, 4096)
n, _ := f.Read(buf)
f.Close()
in.Text = looksText(buf[:n])
}
}
return in, nil
}
func looksText(b []byte) bool {
if len(b) == 0 {
return true
}
ctrl := 0
for _, c := range b {
if c == 0 {
return false
}
if c < 32 && c != '\n' && c != '\r' && c != '\t' && c != '\f' && c != 0x1b {
ctrl++
}
}
return ctrl*20 < len(b)
}
// Open refuses anything that isn't a regular file: devices stream forever (/dev/zero),
// FIFOs block on open, sockets can't be read. /proc files are regular and still work.
func (Local) Open(_ context.Context, path string) (io.ReadCloser, error) {
st, err := os.Stat(path)
if err != nil {
return nil, err
}
if st.IsDir() {
return nil, errors.New("is a directory")
}
if !st.Mode().IsRegular() {
return nil, errors.New("not a regular file (device, pipe or socket)")
}
return os.Open(path)
}
func (l Local) OpenAt(ctx context.Context, path string, offset int64) (io.ReadCloser, error) {
f, err := l.Open(ctx, path)
if err != nil {
return nil, err
}
if offset > 0 {
if _, err := f.(*os.File).Seek(offset, io.SeekStart); err != nil {
f.Close()
return nil, err
}
}
return f, nil
}
// Write replaces the file's contents, keeping its mode; new files get 0644 (umask).
func (Local) Write(_ context.Context, path string, r io.Reader) error {
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
if err != nil {
return err
}
if _, err := io.Copy(f, r); err != nil {
f.Close()
return err
}
return f.Close()
}
func (Local) Mkdir(_ context.Context, path string) error { return os.Mkdir(path, 0o755) }
func (Local) Touch(_ context.Context, path string) error {
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
if err != nil {
return err
}
return f.Close()
}
func (Local) Rename(_ context.Context, from, to string) error {
if protected[from] {
return errors.New("refusing to move " + from)
}
if _, err := os.Lstat(to); err == nil {
return errors.New("destination exists")
}
return os.Rename(from, to)
}
func (Local) Delete(_ context.Context, path string, recursive bool) error {
if protected[path] {
return errors.New("refusing to delete " + path)
}
lst, err := os.Lstat(path)
if err != nil {
return err
}
if lst.IsDir() && recursive {
return os.RemoveAll(path)
}
return os.Remove(path) // fails on a non-empty dir, by design
}
func (Local) Chmod(_ context.Context, path string, perm os.FileMode) error {
return os.Chmod(path, perm)
}
// ---- Sudo: the same operations, run as root in a child hamlab ----
type Sudo struct{ Self string }
func (s Sudo) cmd(ctx context.Context, args ...string) *exec.Cmd {
full := priv.Wrap(append([]string{s.Self, "fs"}, args...)...)
return exec.CommandContext(ctx, full[0], full[1:]...)
}
func (s Sudo) runJSON(ctx context.Context, out any, args ...string) error {
c := s.cmd(ctx, args...)
var errb strings.Builder
c.Stderr = &errb
b, err := c.Output()
if err != nil {
if msg := strings.TrimSpace(errb.String()); msg != "" {
return errors.New(msg)
}
return err
}
if out == nil {
return nil
}
return json.Unmarshal(b, out)
}
func (s Sudo) List(ctx context.Context, path string) (Listing, error) {
var l Listing
err := s.runJSON(ctx, &l, "list", path)
return l, err
}
func (s Sudo) Stat(ctx context.Context, path string) (Info, error) {
var in Info
err := s.runJSON(ctx, &in, "stat", path)
return in, err
}
type cmdReader struct {
io.ReadCloser
cmd *exec.Cmd
errb *strings.Builder
}
func (r *cmdReader) Close() error {
r.ReadCloser.Close()
err := r.cmd.Wait()
if err != nil && r.errb.Len() > 0 {
return errors.New(strings.TrimSpace(r.errb.String()))
}
return err
}
func (s Sudo) Open(ctx context.Context, path string) (io.ReadCloser, error) {
return s.open(ctx, "read", path)
}
func (s Sudo) OpenAt(ctx context.Context, path string, offset int64) (io.ReadCloser, error) {
return s.open(ctx, "read", "--offset", strconv.FormatInt(offset, 10), path)
}
func (s Sudo) open(ctx context.Context, args ...string) (io.ReadCloser, error) {
c := s.cmd(ctx, args...)
errb := &strings.Builder{}
c.Stderr = errb
out, err := c.StdoutPipe()
if err != nil {
return nil, err
}
if err := c.Start(); err != nil {
return nil, err
}
// Fail early on "no such file" etc.: the helper stats before writing anything.
head := make([]byte, 1)
n, rerr := out.Read(head)
if n == 0 && rerr != nil {
_ = c.Wait()
if msg := strings.TrimSpace(errb.String()); msg != "" {
return nil, errors.New(msg)
}
if rerr != io.EOF {
return nil, rerr
}
}
return &cmdReader{ReadCloser: struct {
io.Reader
io.Closer
}{io.MultiReader(strings.NewReader(string(head[:n])), out), out}, cmd: c, errb: errb}, nil
}
func (s Sudo) Write(ctx context.Context, path string, r io.Reader) error {
c := s.cmd(ctx, "write", path)
c.Stdin = r
var errb strings.Builder
c.Stderr = &errb
if err := c.Run(); err != nil {
if msg := strings.TrimSpace(errb.String()); msg != "" {
return errors.New(msg)
}
return err
}
return nil
}
func (s Sudo) Mkdir(ctx context.Context, path string) error {
return s.runJSON(ctx, nil, "mkdir", path)
}
func (s Sudo) Touch(ctx context.Context, path string) error {
return s.runJSON(ctx, nil, "touch", path)
}
func (s Sudo) Rename(ctx context.Context, from, to string) error {
return s.runJSON(ctx, nil, "rename", from, to)
}
// Move as root: the helper does the copying and talks on stdout.
func (s Sudo) Move(ctx context.Context, from, to string, progress io.Writer) error {
c := s.cmd(ctx, "move", from, to)
c.Stdout = progress
var errb strings.Builder
c.Stderr = &errb
if err := c.Run(); err != nil {
if msg := strings.TrimSpace(errb.String()); msg != "" {
return errors.New(msg)
}
return err
}
return nil
}
// MoveCmd is the command a task runs for a long move: this binary's helper, as root when
// the backend is.
func MoveCmd(ctx context.Context, fsys FS, self, from, to string) *exec.Cmd {
if s, ok := fsys.(Sudo); ok {
return s.cmd(ctx, "move", from, to)
}
return exec.CommandContext(ctx, self, "fs", "move", from, to)
}
func (s Sudo) Delete(ctx context.Context, path string, recursive bool) error {
if recursive {
return s.runJSON(ctx, nil, "delete", "-r", path)
}
return s.runJSON(ctx, nil, "delete", path)
}
func (s Sudo) Chmod(ctx context.Context, path string, perm os.FileMode) error {
return s.runJSON(ctx, nil, "chmod", fmt.Sprintf("%04o", perm), path)
}
// Helper implements `hamlab fs <op> …` for the Sudo backend. Returns the exit code.
func Helper(args []string, stdin io.Reader, stdout io.Writer, stderr io.Writer) int {
fail := func(err error) int { fmt.Fprintln(stderr, err.Error()); return 1 }
if len(args) < 2 {
return fail(errors.New("usage: hamlab fs <list|stat|read|write|mkdir|touch|rename|move|delete|chmod> …"))
}
ctx := context.Background()
var l Local
op := args[0]
var offset int64
if op == "read" && len(args) >= 4 && args[1] == "--offset" { // read --offset N <path>
offset, _ = strconv.ParseInt(args[2], 10, 64)
args = append([]string{"read"}, args[3:]...)
}
clean := func(i int) (string, error) {
if i >= len(args) {
return "", errors.New("path required")
}
return Clean(args[i])
}
switch op {
case "list", "stat", "read", "write", "mkdir", "touch":
p, err := clean(1)
if err != nil {
return fail(err)
}
switch op {
case "list":
out, err := l.List(ctx, p)
if err != nil {
return fail(err)
}
return emit(stdout, out, stderr)
case "stat":
out, err := l.Stat(ctx, p)
if err != nil {
return fail(err)
}
return emit(stdout, out, stderr)
case "read":
f, err := l.OpenAt(ctx, p, offset)
if err != nil {
return fail(err)
}
defer f.Close()
if _, err := io.Copy(stdout, f); err != nil {
return fail(err)
}
return 0
case "write":
if err := l.Write(ctx, p, stdin); err != nil {
return fail(err)
}
case "mkdir":
if err := l.Mkdir(ctx, p); err != nil {
return fail(err)
}
case "touch":
if err := l.Touch(ctx, p); err != nil {
return fail(err)
}
}
case "rename", "move":
from, err := clean(1)
if err != nil {
return fail(err)
}
to, err := clean(2)
if err != nil {
return fail(err)
}
if op == "move" {
// A cancelled task is a SIGTERM: stop copying and take the partial copy away.
mctx, stop := signal.NotifyContext(ctx, syscall.SIGTERM, syscall.SIGINT)
defer stop()
if err := l.Move(mctx, from, to, stdout); err != nil {
return fail(err)
}
return 0
}
if err := l.Rename(ctx, from, to); err != nil {
return fail(err)
}
case "delete":
rec := false
i := 1
if args[1] == "-r" {
rec, i = true, 2
}
p, err := clean(i)
if err != nil {
return fail(err)
}
if err := l.Delete(ctx, p, rec); err != nil {
return fail(err)
}
case "chmod":
perm, err := ParsePerm(args[1])
if err != nil {
return fail(err)
}
p, err := clean(2)
if err != nil {
return fail(err)
}
if err := l.Chmod(ctx, p, perm); err != nil {
return fail(err)
}
default:
return fail(errors.New("unknown op " + op))
}
fmt.Fprintln(stdout, "{}")
return 0
}
func emit(w io.Writer, v any, stderr io.Writer) int {
if err := json.NewEncoder(w).Encode(v); err != nil {
fmt.Fprintln(stderr, err.Error())
return 1
}
return 0
}
// ModTime helper for handlers.
func Now() time.Time { return time.Now() }