Update media handling
This commit is contained in:
20
README.md
20
README.md
@@ -6,7 +6,7 @@ Luxtools is now implemented entirely in Go. The project serves a retro-styled HT
|
|||||||
|
|
||||||
```
|
```
|
||||||
luxtools/
|
luxtools/
|
||||||
├── assets/ # Runtime asset directory served from disk (/assets/*)
|
├── media/ # Runtime media directory served from disk (/media/*)
|
||||||
├── cmd/luxtools/ # Main program entrypoint
|
├── cmd/luxtools/ # Main program entrypoint
|
||||||
├── internal/server/ # HTTP server, handlers, helpers, and tests
|
├── internal/server/ # HTTP server, handlers, helpers, and tests
|
||||||
├── web/
|
├── 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
|
- `/counter` — HTMX-powered counter snippet with server-side state
|
||||||
- `/time` — current server timestamp
|
- `/time` — current server timestamp
|
||||||
- `/static/*` — vendored assets from `web/static`
|
- `/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
|
## Testing
|
||||||
|
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
This is a demo asset served directly from the filesystem assets handler.
|
|
||||||
@@ -2,19 +2,28 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"flag"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
"path/filepath"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"luxtools/internal/server"
|
"luxtools/internal/server"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type appConfig struct {
|
||||||
|
MediaDir string `json:"media_dir"`
|
||||||
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
srv, err := server.New()
|
cfg := resolveConfig()
|
||||||
|
|
||||||
|
srv, err := server.New(server.Config{MediaDir: cfg.MediaDir})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("failed to configure server: %v", err)
|
log.Fatalf("failed to configure server: %v", err)
|
||||||
}
|
}
|
||||||
@@ -27,7 +36,11 @@ func main() {
|
|||||||
IdleTimeout: 60 * time.Second,
|
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() {
|
go func() {
|
||||||
if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
@@ -48,3 +61,59 @@ func main() {
|
|||||||
log.Println("server stopped")
|
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")
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"html"
|
"html"
|
||||||
|
"io/fs"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
webbundle "luxtools/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
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.
|
// Server hosts the Luxtools HTTP handlers.
|
||||||
type Server struct {
|
type Server struct {
|
||||||
mux *http.ServeMux
|
mux *http.ServeMux
|
||||||
indexPage []byte
|
indexPage []byte
|
||||||
staticRoot string
|
staticFS fs.FS
|
||||||
assetsRoot string
|
mediaFS http.FileSystem
|
||||||
|
|
||||||
counterMu sync.Mutex
|
counterMu sync.Mutex
|
||||||
clickCount int
|
clickCount int
|
||||||
}
|
}
|
||||||
|
|
||||||
// New constructs a configured Server ready to serve requests.
|
// New constructs a configured Server ready to serve requests.
|
||||||
func New() (*Server, error) {
|
func New(cfg Config) (*Server, error) {
|
||||||
projectRoot, err := goProjectRoot()
|
if strings.TrimSpace(cfg.MediaDir) == "" {
|
||||||
if err != nil {
|
return nil, fmt.Errorf("media directory is required")
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
indexPath := filepath.Join(projectRoot, "web", "templates", "index.html")
|
mediaDir := filepath.Clean(cfg.MediaDir)
|
||||||
staticDir := filepath.Join(projectRoot, "web", "static")
|
|
||||||
assetsDir := filepath.Join(projectRoot, "assets")
|
|
||||||
|
|
||||||
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to read index template: %w", err)
|
return nil, fmt.Errorf("failed to read index template: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := os.Stat(staticDir); err != nil {
|
staticFS, err := fs.Sub(webbundle.Content, "static")
|
||||||
return nil, fmt.Errorf("static directory check failed: %w", err)
|
if err != nil {
|
||||||
}
|
return nil, fmt.Errorf("failed to prepare static assets: %w", err)
|
||||||
if _, err := os.Stat(assetsDir); err != nil {
|
|
||||||
return nil, fmt.Errorf("assets directory check failed: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
s := &Server{
|
s := &Server{
|
||||||
mux: http.NewServeMux(),
|
mux: http.NewServeMux(),
|
||||||
indexPage: indexPage,
|
indexPage: indexPage,
|
||||||
staticRoot: staticDir,
|
staticFS: staticFS,
|
||||||
assetsRoot: assetsDir,
|
mediaFS: http.Dir(mediaDir),
|
||||||
}
|
}
|
||||||
|
|
||||||
s.registerRoutes()
|
s.registerRoutes()
|
||||||
@@ -82,8 +92,11 @@ func (s *Server) Address() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) registerRoutes() {
|
func (s *Server) registerRoutes() {
|
||||||
s.mux.Handle("/static/", http.StripPrefix("/static/", allowGetHead(http.FileServer(http.Dir(s.staticRoot)))))
|
staticHandler := http.FileServer(http.FS(s.staticFS))
|
||||||
s.mux.Handle("/assets/", http.StripPrefix("/assets/", allowGetHead(http.FileServer(http.Dir(s.assetsRoot)))))
|
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)
|
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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package server
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -28,10 +30,7 @@ func TestRenderTime(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCounterEndpoint(t *testing.T) {
|
func TestCounterEndpoint(t *testing.T) {
|
||||||
srv, err := New()
|
srv := mustNewServer(t, nil)
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to create server: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/counter", nil)
|
req := httptest.NewRequest(http.MethodPost, "/counter", nil)
|
||||||
resp := httptest.NewRecorder()
|
resp := httptest.NewRecorder()
|
||||||
@@ -49,10 +48,7 @@ func TestCounterEndpoint(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestNotFound(t *testing.T) {
|
func TestNotFound(t *testing.T) {
|
||||||
srv, err := New()
|
srv := mustNewServer(t, nil)
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to create server: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/unknown", nil)
|
req := httptest.NewRequest(http.MethodGet, "/unknown", nil)
|
||||||
resp := httptest.NewRecorder()
|
resp := httptest.NewRecorder()
|
||||||
@@ -66,3 +62,68 @@ func TestNotFound(t *testing.T) {
|
|||||||
t.Fatalf("expected not found message, got %q", resp.Body.String())
|
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
|
||||||
|
}
|
||||||
|
|||||||
1
media/demo.txt
Normal file
1
media/demo.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
This is a demo media file served from the configurable media handler.
|
||||||
8
web/embed.go
Normal file
8
web/embed.go
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user