190 lines
4.6 KiB
Go
190 lines
4.6 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|