internal/server/server.go (view raw)
1package server
2
3import (
4 "context"
5 "io/fs"
6 "net/http"
7 "strings"
8
9 "github.com/mjensen/weimar/internal/api"
10 "github.com/mjensen/weimar/internal/auth"
11 "github.com/mjensen/weimar/internal/config"
12 "github.com/mjensen/weimar/internal/db"
13 "github.com/mjensen/weimar/internal/storage"
14)
15
16func New(cfg config.Config, webFS fs.FS, database *db.DB) (*Server, error) {
17 sm := auth.NewSessionManager(database)
18 st, err := storage.New(cfg.Storage.Path)
19 if err != nil {
20 return nil, err
21 }
22 h := api.New(database, sm, st, cfg)
23
24 mux := http.NewServeMux()
25
26 // Health check (public).
27 mux.HandleFunc("GET /api/health", handleHealth)
28
29 s := &Server{
30 cfg: cfg,
31 mux: mux,
32 auth: sm,
33 }
34
35 // Auth (public).
36 mux.HandleFunc("POST /api/auth/login", h.HandleLogin)
37 mux.HandleFunc("POST /api/auth/register", h.HandleRegister)
38
39 // Auth (authenticated).
40 mux.HandleFunc("POST /api/auth/logout", s.requireAuth(h.HandleLogout))
41
42 // Images (authenticated).
43 mux.HandleFunc("POST /api/upload", s.requireAuth(h.HandleUpload))
44 mux.HandleFunc("GET /api/images", s.requireAuth(h.HandleListImages))
45 mux.HandleFunc("GET /api/images/{id}", s.requireAuth(h.HandleGetImage))
46 mux.HandleFunc("GET /api/images/{id}/download", s.requireAuth(h.HandleDownload))
47 mux.HandleFunc("GET /api/images/{id}/thumbnail", s.requireAuth(h.HandleThumbnail))
48
49 // Tags (authenticated).
50 mux.HandleFunc("GET /api/tags/suggest", s.requireAuth(h.HandleTagSuggest))
51
52 // Users (authenticated).
53 mux.HandleFunc("GET /api/users/me", s.requireAuth(h.HandleGetMe))
54 mux.HandleFunc("POST /api/users/me/avatar", s.requireAuth(h.HandleUploadAvatar))
55 mux.HandleFunc("GET /api/users/{id}/avatar", s.requireAuth(h.HandleServeAvatar))
56
57 // SPA — serve index.html for all non-API routes (client-side routing).
58 mux.Handle("/", s.spaHandler(webFS))
59
60 return s, nil
61}
62
63type Server struct {
64 cfg config.Config
65 mux *http.ServeMux
66 auth *auth.SessionManager
67}
68
69func (s *Server) Start(ctx context.Context) error {
70 addr := s.cfg.Server.Host + ":" + itoa(s.cfg.Server.Port)
71 srv := &http.Server{Addr: addr, Handler: s.mux}
72
73 go func() {
74 <-ctx.Done()
75 srv.Shutdown(context.Background())
76 }()
77
78 return srv.ListenAndServe()
79}
80
81func itoa(n int) string {
82 if n == 0 {
83 return "0"
84 }
85 var buf [20]byte
86 i := len(buf)
87 for n > 0 {
88 i--
89 buf[i] = byte('0' + n%10)
90 n /= 10
91 }
92 return string(buf[i:])
93}
94
95// spaHandler serves static files for SPA client-side routing.
96// For routes that don't match an existing file (e.g. /browse, /image/123),
97// it falls back to serving index.html so the SPA router can handle them.
98func (s *Server) spaHandler(webFS fs.FS) http.HandlerFunc {
99 fsHandler := http.FileServer(http.FS(webFS))
100 return func(w http.ResponseWriter, r *http.Request) {
101 // API routes should not be served by the SPA handler.
102 if strings.HasPrefix(r.URL.Path, "/api/") {
103 http.NotFound(w, r)
104 return
105 }
106
107 // Check if the requested file exists in the embedded FS.
108 path := strings.TrimPrefix(r.URL.Path, "/")
109 if _, err := fs.Stat(webFS, path); err != nil {
110 // File doesn't exist — serve index.html for SPA routing.
111 r.URL.Path = "/"
112 fsHandler.ServeHTTP(w, r)
113 return
114 }
115
116 // File exists — serve it directly.
117 fsHandler.ServeHTTP(w, r)
118 }
119}
120
121func handleHealth(w http.ResponseWriter, r *http.Request) {
122 w.Header().Set("Content-Type", "application/json")
123 w.Write([]byte(`{"status":"ok"}`))
124}