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 (conditionally public).
43 mux.HandleFunc("POST /api/upload", s.requireAuth(h.HandleUpload))
44 mux.HandleFunc("GET /api/images", s.requireAuthOrPublic(h.HandleListImages))
45 mux.HandleFunc("GET /api/images/{id}", s.requireAuthOrPublic(h.HandleGetImage))
46 mux.HandleFunc("GET /api/images/{id}/download", s.requireAuthOrPublic(h.HandleDownload))
47 mux.HandleFunc("GET /api/images/{id}/thumbnail", s.requireAuthOrPublic(h.HandleThumbnail))
48 mux.HandleFunc("POST /api/images/{id}/tags", s.requireAuth(h.HandleAddTags))
49 mux.HandleFunc("DELETE /api/images/{id}/tags", s.requireAuth(h.HandleRemoveTags))
50
51 // Tags (authenticated).
52 mux.HandleFunc("GET /api/tags/suggest", s.requireAuth(h.HandleTagSuggest))
53
54 // Users (authenticated).
55 mux.HandleFunc("GET /api/users/me", s.requireAuth(h.HandleGetMe))
56 mux.HandleFunc("POST /api/users/me/avatar", s.requireAuth(h.HandleUploadAvatar))
57 mux.HandleFunc("GET /api/users/{id}/avatar", s.requireAuth(h.HandleServeAvatar))
58
59 // SPA — serve index.html for all non-API routes (client-side routing).
60 mux.Handle("/", s.spaHandler(webFS))
61
62 return s, nil
63}
64
65type Server struct {
66 cfg config.Config
67 mux *http.ServeMux
68 auth *auth.SessionManager
69}
70
71func (s *Server) Start(ctx context.Context) error {
72 addr := s.cfg.Server.Host + ":" + itoa(s.cfg.Server.Port)
73 srv := &http.Server{Addr: addr, Handler: s.mux}
74
75 go func() {
76 <-ctx.Done()
77 srv.Shutdown(context.Background())
78 }()
79
80 return srv.ListenAndServe()
81}
82
83func itoa(n int) string {
84 if n == 0 {
85 return "0"
86 }
87 var buf [20]byte
88 i := len(buf)
89 for n > 0 {
90 i--
91 buf[i] = byte('0' + n%10)
92 n /= 10
93 }
94 return string(buf[i:])
95}
96
97// spaHandler serves static files for SPA client-side routing.
98// For routes that don't match an existing file (e.g. /browse, /image/123),
99// it falls back to serving index.html so the SPA router can handle them.
100func (s *Server) spaHandler(webFS fs.FS) http.HandlerFunc {
101 fsHandler := http.FileServer(http.FS(webFS))
102 return func(w http.ResponseWriter, r *http.Request) {
103 // API routes should not be served by the SPA handler.
104 if strings.HasPrefix(r.URL.Path, "/api/") {
105 http.NotFound(w, r)
106 return
107 }
108
109 // Check if the requested file exists in the embedded FS.
110 path := strings.TrimPrefix(r.URL.Path, "/")
111 if _, err := fs.Stat(webFS, path); err != nil {
112 // File doesn't exist — serve index.html for SPA routing.
113 r.URL.Path = "/"
114 fsHandler.ServeHTTP(w, r)
115 return
116 }
117
118 // File exists — serve it directly.
119 fsHandler.ServeHTTP(w, r)
120 }
121}
122
123func handleHealth(w http.ResponseWriter, r *http.Request) {
124 w.Header().Set("Content-Type", "application/json")
125 w.Write([]byte(`{"status":"ok"}`))
126}