From 3b24e641316a8972c5674935b674c71c4d2b4b6c Mon Sep 17 00:00:00 2001 From: luxick Date: Thu, 2 Oct 2025 14:01:27 +0200 Subject: [PATCH] Update media handling --- README.md | 20 ++++++++- assets/demo.txt | 1 - cmd/luxtools/main.go | 73 +++++++++++++++++++++++++++++++- internal/server/server.go | 74 ++++++++++++++++---------------- internal/server/server_test.go | 77 ++++++++++++++++++++++++++++++---- media/demo.txt | 1 + web/embed.go | 8 ++++ 7 files changed, 205 insertions(+), 49 deletions(-) delete mode 100644 assets/demo.txt create mode 100644 media/demo.txt create mode 100644 web/embed.go diff --git a/README.md b/README.md index 3d85223..ae5912a 100755 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Luxtools is now implemented entirely in Go. The project serves a retro-styled HT ``` luxtools/ -├── assets/ # Runtime asset directory served from disk (/assets/*) +├── media/ # Runtime media directory served from disk (/media/*) ├── cmd/luxtools/ # Main program entrypoint ├── internal/server/ # HTTP server, handlers, helpers, and tests ├── web/ @@ -33,7 +33,23 @@ The server listens on [http://127.0.0.1:5000](http://127.0.0.1:5000) and serves: - `/counter` — HTMX-powered counter snippet with server-side state - `/time` — current server timestamp - `/static/*` — vendored assets from `web/static` -- `/assets/*` — runtime assets from the shared `assets` directory +- `/media/*` — runtime media files served from the configured directory + +### Configuration + +The server looks for media files in the following order: + +1. A directory specified via `--media-dir` on the command line. +2. The `media_dir` value in an optional JSON config file passed with `--config`. +3. A `media` directory located alongside the binary or in the current working directory. + +Sample config file: + +```json +{ + "media_dir": "/var/lib/luxtools/media" +} +``` ## Testing diff --git a/assets/demo.txt b/assets/demo.txt deleted file mode 100644 index e8a20b7..0000000 --- a/assets/demo.txt +++ /dev/null @@ -1 +0,0 @@ -This is a demo asset served directly from the filesystem assets handler. diff --git a/cmd/luxtools/main.go b/cmd/luxtools/main.go index 2141df8..2c7a1c9 100644 --- a/cmd/luxtools/main.go +++ b/cmd/luxtools/main.go @@ -2,19 +2,28 @@ package main import ( "context" + "encoding/json" "errors" + "flag" "log" "net/http" "os" "os/signal" + "path/filepath" "syscall" "time" "luxtools/internal/server" ) +type appConfig struct { + MediaDir string `json:"media_dir"` +} + func main() { - srv, err := server.New() + cfg := resolveConfig() + + srv, err := server.New(server.Config{MediaDir: cfg.MediaDir}) if err != nil { log.Fatalf("failed to configure server: %v", err) } @@ -27,7 +36,11 @@ func main() { IdleTimeout: 60 * time.Second, } - log.Printf("Luxtools web server running on http://127.0.0.1%s", srv.Address()) + log.Printf( + "Luxtools web server running on http://127.0.0.1%s (media: %s)", + srv.Address(), + cfg.MediaDir, + ) go func() { if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { @@ -48,3 +61,59 @@ func main() { log.Println("server stopped") } } + +func resolveConfig() appConfig { + mediaDirFlag := flag.String("media-dir", "", "path to the media directory served at /media") + configPath := flag.String("config", "", "optional path to a JSON config file") + flag.Parse() + + cfg := appConfig{} + if *configPath != "" { + fileCfg, err := loadConfig(*configPath) + if err != nil { + log.Fatalf("failed to read config file: %v", err) + } + cfg = fileCfg + } + + if *mediaDirFlag != "" { + cfg.MediaDir = *mediaDirFlag + } + + if cfg.MediaDir == "" { + cfg.MediaDir = defaultMediaDir() + } + + return cfg +} + +func loadConfig(path string) (appConfig, error) { + data, err := os.ReadFile(path) + if err != nil { + return appConfig{}, err + } + + cfg := appConfig{} + if err := json.Unmarshal(data, &cfg); err != nil { + return appConfig{}, err + } + return cfg, nil +} + +func defaultMediaDir() string { + if cwd, err := os.Getwd(); err == nil { + candidate := filepath.Join(cwd, "media") + if info, err := os.Stat(candidate); err == nil && info.IsDir() { + return candidate + } + } + + if exe, err := os.Executable(); err == nil { + candidate := filepath.Join(filepath.Dir(exe), "media") + if info, err := os.Stat(candidate); err == nil && info.IsDir() { + return candidate + } + } + + return filepath.Join(".", "media") +} diff --git a/internal/server/server.go b/internal/server/server.go index 1fd4c78..418d4e9 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -1,16 +1,18 @@ package server import ( - "errors" "fmt" "html" + "io/fs" "log" "net/http" "os" "path/filepath" - "runtime" + "strings" "sync" "time" + + webbundle "luxtools/web" ) const ( @@ -26,45 +28,53 @@ const ( ` ) +// Config contains the runtime options for the HTTP server. +type Config struct { + MediaDir string +} + // Server hosts the Luxtools HTTP handlers. type Server struct { - mux *http.ServeMux - indexPage []byte - staticRoot string - assetsRoot string + mux *http.ServeMux + indexPage []byte + staticFS fs.FS + mediaFS http.FileSystem counterMu sync.Mutex clickCount int } // New constructs a configured Server ready to serve requests. -func New() (*Server, error) { - projectRoot, err := goProjectRoot() - if err != nil { - return nil, err +func New(cfg Config) (*Server, error) { + if strings.TrimSpace(cfg.MediaDir) == "" { + return nil, fmt.Errorf("media directory is required") } - indexPath := filepath.Join(projectRoot, "web", "templates", "index.html") - staticDir := filepath.Join(projectRoot, "web", "static") - assetsDir := filepath.Join(projectRoot, "assets") + mediaDir := filepath.Clean(cfg.MediaDir) - indexPage, err := os.ReadFile(indexPath) + info, err := os.Stat(mediaDir) + if err != nil { + return nil, fmt.Errorf("media directory check failed: %w", err) + } + if !info.IsDir() { + return nil, fmt.Errorf("media directory must be a directory: %s", mediaDir) + } + + indexPage, err := webbundle.Content.ReadFile("templates/index.html") if err != nil { return nil, fmt.Errorf("failed to read index template: %w", err) } - if _, err := os.Stat(staticDir); err != nil { - return nil, fmt.Errorf("static directory check failed: %w", err) - } - if _, err := os.Stat(assetsDir); err != nil { - return nil, fmt.Errorf("assets directory check failed: %w", err) + staticFS, err := fs.Sub(webbundle.Content, "static") + if err != nil { + return nil, fmt.Errorf("failed to prepare static assets: %w", err) } s := &Server{ - mux: http.NewServeMux(), - indexPage: indexPage, - staticRoot: staticDir, - assetsRoot: assetsDir, + mux: http.NewServeMux(), + indexPage: indexPage, + staticFS: staticFS, + mediaFS: http.Dir(mediaDir), } s.registerRoutes() @@ -82,8 +92,11 @@ func (s *Server) Address() string { } func (s *Server) registerRoutes() { - s.mux.Handle("/static/", http.StripPrefix("/static/", allowGetHead(http.FileServer(http.Dir(s.staticRoot))))) - s.mux.Handle("/assets/", http.StripPrefix("/assets/", allowGetHead(http.FileServer(http.Dir(s.assetsRoot))))) + staticHandler := http.FileServer(http.FS(s.staticFS)) + mediaHandler := http.FileServer(s.mediaFS) + + s.mux.Handle("/static/", http.StripPrefix("/static/", allowGetHead(staticHandler))) + s.mux.Handle("/media/", http.StripPrefix("/media/", allowGetHead(mediaHandler))) s.mux.HandleFunc("/", s.handleRequest) } @@ -157,14 +170,3 @@ func allowGetHead(next http.Handler) http.Handler { } }) } - -func goProjectRoot() (string, error) { - _, currentFile, _, ok := runtime.Caller(0) - if !ok { - return "", errors.New("unable to determine caller information") - } - - dir := filepath.Dir(currentFile) - projectRoot := filepath.Clean(filepath.Join(dir, "..", "..")) - return projectRoot, nil -} diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 13020b5..a09e614 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -3,6 +3,8 @@ package server import ( "net/http" "net/http/httptest" + "os" + "path/filepath" "strings" "testing" "time" @@ -28,10 +30,7 @@ func TestRenderTime(t *testing.T) { } func TestCounterEndpoint(t *testing.T) { - srv, err := New() - if err != nil { - t.Fatalf("failed to create server: %v", err) - } + srv := mustNewServer(t, nil) req := httptest.NewRequest(http.MethodPost, "/counter", nil) resp := httptest.NewRecorder() @@ -49,10 +48,7 @@ func TestCounterEndpoint(t *testing.T) { } func TestNotFound(t *testing.T) { - srv, err := New() - if err != nil { - t.Fatalf("failed to create server: %v", err) - } + srv := mustNewServer(t, nil) req := httptest.NewRequest(http.MethodGet, "/unknown", nil) resp := httptest.NewRecorder() @@ -66,3 +62,68 @@ func TestNotFound(t *testing.T) { t.Fatalf("expected not found message, got %q", resp.Body.String()) } } + +func TestStaticFileServing(t *testing.T) { + srv := mustNewServer(t, nil) + + req := httptest.NewRequest(http.MethodGet, "/static/lib/htmx.2.0.7.min.js", nil) + resp := httptest.NewRecorder() + + srv.Router().ServeHTTP(resp, req) + + if resp.Code != http.StatusOK { + t.Fatalf("expected static asset to load, got status %d", resp.Code) + } + if resp.Body.Len() == 0 { + t.Fatalf("expected static asset response body") + } +} + +func TestMediaServing(t *testing.T) { + var demoName string + srv := mustNewServer(t, func(dir string) { + demoName = filepath.Join(dir, "demo.txt") + if err := os.WriteFile(demoName, []byte("demo media file"), 0o644); err != nil { + t.Fatalf("failed to seed media file: %v", err) + } + }) + + req := httptest.NewRequest(http.MethodGet, "/media/demo.txt", nil) + resp := httptest.NewRecorder() + + srv.Router().ServeHTTP(resp, req) + + if resp.Code != http.StatusOK { + t.Fatalf("expected public asset to load, got status %d", resp.Code) + } + body := resp.Body.String() + if !strings.Contains(body, "demo media file") { + t.Fatalf("unexpected asset body: %q", body) + } +} + +func TestNewRequiresMediaDir(t *testing.T) { + if _, err := New(Config{}); err == nil { + t.Fatalf("expected error when media dir is empty") + } + + missing := filepath.Join(t.TempDir(), "missing") + if _, err := New(Config{MediaDir: missing}); err == nil { + t.Fatalf("expected error when media dir does not exist") + } +} + +func mustNewServer(t *testing.T, setup func(string)) *Server { + t.Helper() + + mediaDir := t.TempDir() + if setup != nil { + setup(mediaDir) + } + + srv, err := New(Config{MediaDir: mediaDir}) + if err != nil { + t.Fatalf("failed to create server: %v", err) + } + return srv +} diff --git a/media/demo.txt b/media/demo.txt new file mode 100644 index 0000000..18afc57 --- /dev/null +++ b/media/demo.txt @@ -0,0 +1 @@ +This is a demo media file served from the configurable media handler. diff --git a/web/embed.go b/web/embed.go new file mode 100644 index 0000000..175dc0a --- /dev/null +++ b/web/embed.go @@ -0,0 +1,8 @@ +package web + +import "embed" + +// Content holds the HTML templates and static assets for the web UI. +// +//go:embed templates/index.html static +var Content embed.FS