Init
This commit is contained in:
189
internal/server/server.go
Normal file
189
internal/server/server.go
Normal file
@@ -0,0 +1,189 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html"
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
webbundle "estus-shots/web"
|
||||
)
|
||||
|
||||
const (
|
||||
address = ":5000"
|
||||
htmlContentType = "text/html; charset=utf-8"
|
||||
notFoundTemplate = `
|
||||
<div class="tui-window">
|
||||
<fieldset class="tui-fieldset">
|
||||
<legend>Error</legend>
|
||||
<p>Route %s not found.</p>
|
||||
</fieldset>
|
||||
</div>
|
||||
`
|
||||
)
|
||||
|
||||
// Config contains the runtime options for the HTTP server.
|
||||
type Config struct {
|
||||
MediaDir string
|
||||
}
|
||||
|
||||
// Server hosts the Estus Shots HTTP handlers.
|
||||
type Server struct {
|
||||
mux *http.ServeMux
|
||||
templates *template.Template
|
||||
staticFS fs.FS
|
||||
mediaFS http.FileSystem
|
||||
|
||||
counterMu sync.Mutex
|
||||
clickCount int
|
||||
}
|
||||
|
||||
// New constructs a configured Server ready to serve requests.
|
||||
func New(cfg Config) (*Server, error) {
|
||||
if strings.TrimSpace(cfg.MediaDir) == "" {
|
||||
return nil, fmt.Errorf("media directory is required")
|
||||
}
|
||||
|
||||
mediaDir := filepath.Clean(cfg.MediaDir)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// Parse all templates
|
||||
tmpl, err := template.ParseFS(webbundle.Content, "templates/*.html")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse templates: %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(),
|
||||
templates: tmpl,
|
||||
staticFS: staticFS,
|
||||
mediaFS: http.Dir(mediaDir),
|
||||
}
|
||||
|
||||
s.registerRoutes()
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Router exposes the configured http.Handler for the server.
|
||||
func (s *Server) Router() http.Handler {
|
||||
return s.mux
|
||||
}
|
||||
|
||||
// Address returns the default listen address for the server.
|
||||
func (s *Server) Address() string {
|
||||
return address
|
||||
}
|
||||
|
||||
func (s *Server) registerRoutes() {
|
||||
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)
|
||||
}
|
||||
|
||||
func (s *Server) handleRequest(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/":
|
||||
data := DefaultMenuBar()
|
||||
s.renderTemplate(w, http.StatusOK, "index.html", data)
|
||||
case "/admin":
|
||||
data := AdminMenuBar()
|
||||
data.Title = "Admin Panel"
|
||||
s.renderTemplate(w, http.StatusOK, "admin.html", data)
|
||||
case "/counter":
|
||||
s.handleCounter(w, r)
|
||||
case "/time":
|
||||
s.sendHTMLString(w, http.StatusOK, renderTime(time.Now()))
|
||||
default:
|
||||
s.sendHTMLString(w, http.StatusNotFound, fmt.Sprintf(notFoundTemplate, html.EscapeString(r.URL.Path)))
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleCounter(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodPost || r.Method == http.MethodPut {
|
||||
s.counterMu.Lock()
|
||||
s.clickCount++
|
||||
s.counterMu.Unlock()
|
||||
}
|
||||
|
||||
s.counterMu.Lock()
|
||||
count := s.clickCount
|
||||
s.counterMu.Unlock()
|
||||
|
||||
s.sendHTMLString(w, http.StatusOK, renderCounter(count))
|
||||
}
|
||||
|
||||
func (s *Server) renderTemplate(w http.ResponseWriter, status int, name string, data interface{}) {
|
||||
w.Header().Set("Content-Type", htmlContentType)
|
||||
w.WriteHeader(status)
|
||||
|
||||
if err := s.templates.ExecuteTemplate(w, name, data); err != nil {
|
||||
log.Printf("template execution failed: %v", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) sendHTML(w http.ResponseWriter, status int, body []byte) {
|
||||
w.Header().Set("Content-Type", htmlContentType)
|
||||
w.WriteHeader(status)
|
||||
if len(body) > 0 {
|
||||
if _, err := w.Write(body); err != nil {
|
||||
log.Printf("write response failed: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) sendHTMLString(w http.ResponseWriter, status int, body string) {
|
||||
s.sendHTML(w, status, []byte(body))
|
||||
}
|
||||
|
||||
func renderCounter(count int) string {
|
||||
return fmt.Sprintf(`
|
||||
<div id="counter" class="tui-panel tui-panel-inline">
|
||||
<p><strong>Clicks:</strong> %d</p>
|
||||
<button class="tui-button" hx-post="/counter" hx-target="#counter" hx-swap="outerHTML">
|
||||
Increment
|
||||
</button>
|
||||
</div>
|
||||
`, count)
|
||||
}
|
||||
|
||||
func renderTime(now time.Time) string {
|
||||
return fmt.Sprintf(`
|
||||
<div id="server-time" class="tui-panel tui-panel-inline">
|
||||
<p><strong>Server time:</strong> %s</p>
|
||||
</div>
|
||||
`, now.Format("2006-01-02 15:04:05"))
|
||||
}
|
||||
|
||||
func allowGetHead(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet, http.MethodHead:
|
||||
next.ServeHTTP(w, r)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
})
|
||||
}
|
||||
129
internal/server/server_test.go
Normal file
129
internal/server/server_test.go
Normal file
@@ -0,0 +1,129 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestRenderCounter(t *testing.T) {
|
||||
html := renderCounter(7)
|
||||
if !strings.Contains(html, "Clicks:") {
|
||||
t.Fatalf("expected counter markup to include label")
|
||||
}
|
||||
if !strings.Contains(html, "> 7<") {
|
||||
t.Fatalf("expected counter value in markup, got %q", html)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderTime(t *testing.T) {
|
||||
now := time.Date(2024, time.August, 1, 12, 34, 56, 0, time.UTC)
|
||||
html := renderTime(now)
|
||||
expected := "2024-08-01 12:34:56"
|
||||
if !strings.Contains(html, expected) {
|
||||
t.Fatalf("expected formatted timestamp %s in markup", expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCounterEndpoint(t *testing.T) {
|
||||
srv := mustNewServer(t, nil)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/counter", nil)
|
||||
resp := httptest.NewRecorder()
|
||||
|
||||
srv.Router().ServeHTTP(resp, req)
|
||||
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d", resp.Code)
|
||||
}
|
||||
|
||||
body := resp.Body.String()
|
||||
if !strings.Contains(body, "> 1<") {
|
||||
t.Fatalf("expected counter to increment to 1, body: %q", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotFound(t *testing.T) {
|
||||
srv := mustNewServer(t, nil)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/unknown", nil)
|
||||
resp := httptest.NewRecorder()
|
||||
|
||||
srv.Router().ServeHTTP(resp, req)
|
||||
|
||||
if resp.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d", resp.Code)
|
||||
}
|
||||
if !strings.Contains(resp.Body.String(), "Route /unknown not found.") {
|
||||
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
|
||||
}
|
||||
84
internal/server/template_data.go
Normal file
84
internal/server/template_data.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package server
|
||||
|
||||
// This file defines the data structures and helper functions for the template system.
|
||||
//
|
||||
// The template system uses Go's html/template package to provide:
|
||||
// - Dynamic menu bars that can be customized per page
|
||||
// - Template inheritance via layout.html
|
||||
// - Type-safe data passing via structs
|
||||
//
|
||||
// To create a new page with a custom menu:
|
||||
// 1. Create a PageData struct with your menu configuration
|
||||
// 2. Call s.renderTemplate(w, status, "yourpage.html", data)
|
||||
// 3. In yourpage.html, use {{template "layout" .}} and define content blocks
|
||||
//
|
||||
// See TEMPLATE_GUIDE.md for detailed examples and usage patterns.
|
||||
|
||||
// PageData holds the data passed to page templates.
|
||||
type PageData struct {
|
||||
Title string
|
||||
MenuGroups []MenuGroup
|
||||
ShowClock bool
|
||||
}
|
||||
|
||||
// MenuGroup represents a dropdown menu in the navbar.
|
||||
type MenuGroup struct {
|
||||
Label string
|
||||
Items []MenuItem
|
||||
}
|
||||
|
||||
// MenuItem represents a single menu item.
|
||||
type MenuItem struct {
|
||||
Label string
|
||||
URL string
|
||||
IsDivider bool
|
||||
}
|
||||
|
||||
// DefaultMenuBar returns the standard menu configuration.
|
||||
func DefaultMenuBar() PageData {
|
||||
return PageData{
|
||||
ShowClock: true,
|
||||
MenuGroups: []MenuGroup{
|
||||
{
|
||||
Label: "File",
|
||||
Items: []MenuItem{
|
||||
{Label: "New", URL: "#!"},
|
||||
{Label: "Open", URL: "#!"},
|
||||
{Label: "Save", URL: "#!"},
|
||||
{Label: "Save As", URL: "#!"},
|
||||
{IsDivider: true},
|
||||
{Label: "Exit", URL: "#!"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Label: "Edit",
|
||||
Items: []MenuItem{
|
||||
{Label: "Cut", URL: "#!"},
|
||||
{Label: "Copy", URL: "#!"},
|
||||
{Label: "Paste", URL: "#!"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Label: "Help",
|
||||
Items: []MenuItem{
|
||||
{Label: "Documentation", URL: "#!"},
|
||||
{Label: "About", URL: "#!"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// AdminMenuBar returns a menu configuration with admin options.
|
||||
func AdminMenuBar() PageData {
|
||||
data := DefaultMenuBar()
|
||||
data.MenuGroups = append(data.MenuGroups, MenuGroup{
|
||||
Label: "Admin",
|
||||
Items: []MenuItem{
|
||||
{Label: "Users", URL: "/admin/users"},
|
||||
{Label: "Settings", URL: "/admin/settings"},
|
||||
{Label: "Logs", URL: "/admin/logs"},
|
||||
},
|
||||
})
|
||||
return data
|
||||
}
|
||||
Reference in New Issue
Block a user