server.go 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246
  1. package httpapi
  2. import (
  3. "context"
  4. "encoding/json"
  5. "errors"
  6. "fmt"
  7. "log"
  8. "net/http"
  9. "strconv"
  10. "strings"
  11. "github.com/go-chi/chi/v5"
  12. "github.com/go-chi/chi/v5/middleware"
  13. "viewer/internal/albums"
  14. "viewer/internal/feed"
  15. "viewer/internal/images"
  16. "viewer/internal/web"
  17. )
  18. type Server struct {
  19. albums *albums.Service
  20. feed *feed.Service
  21. images *images.Service
  22. maxUploadBytes int64
  23. }
  24. func New(albumsService *albums.Service, feedService *feed.Service, imageService *images.Service, maxUploadBytes int64) *Server {
  25. return &Server{
  26. albums: albumsService,
  27. feed: feedService,
  28. images: imageService,
  29. maxUploadBytes: maxUploadBytes,
  30. }
  31. }
  32. func (s *Server) Router() http.Handler {
  33. r := chi.NewRouter()
  34. r.Use(middleware.RequestID)
  35. r.Use(middleware.RealIP)
  36. r.Use(middleware.Recoverer)
  37. r.Use(middleware.Logger)
  38. r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) {
  39. w.Header().Set("Content-Type", "application/json")
  40. _, _ = w.Write([]byte(`{"status":"ok"}`))
  41. })
  42. r.Route("/api", func(r chi.Router) {
  43. r.Post("/albums", s.createAlbum)
  44. r.Post("/albums/{albumId}/upload", s.uploadAlbumSource)
  45. r.Get("/albums", s.listAlbums)
  46. r.Post("/albums/{albumId}/finalize", s.finalizeAlbum)
  47. r.Get("/albums/{albumId}", s.getAlbum)
  48. r.Get("/feed", s.getFeed)
  49. r.Get("/image/{albumId}/{index}", s.getImage)
  50. })
  51. staticHandler := web.Handler()
  52. r.NotFound(func(w http.ResponseWriter, r *http.Request) {
  53. if strings.HasPrefix(r.URL.Path, "/api/") {
  54. writeError(w, http.StatusNotFound, "NOT_FOUND", "resource not found")
  55. return
  56. }
  57. staticHandler.ServeHTTP(w, r)
  58. })
  59. r.Get("/*", func(w http.ResponseWriter, r *http.Request) {
  60. staticHandler.ServeHTTP(w, r)
  61. })
  62. return r
  63. }
  64. type createAlbumRequest struct {
  65. Filename string `json:"filename"`
  66. SizeBytes int64 `json:"sizeBytes"`
  67. }
  68. func (s *Server) createAlbum(w http.ResponseWriter, r *http.Request) {
  69. var req createAlbumRequest
  70. if err := jsonBody(r, &req); err != nil {
  71. writeError(w, http.StatusBadRequest, "INVALID_REQUEST", err.Error())
  72. return
  73. }
  74. res, err := s.albums.CreateUpload(r.Context(), req.Filename, req.SizeBytes)
  75. if err != nil {
  76. writeError(w, http.StatusBadRequest, "INVALID_REQUEST", err.Error())
  77. return
  78. }
  79. writeJSON(w, http.StatusOK, map[string]any{
  80. "albumId": res.AlbumID,
  81. "upload": map[string]any{
  82. "method": "PUT",
  83. "url": res.URL,
  84. "headers": res.Headers,
  85. },
  86. })
  87. }
  88. func (s *Server) uploadAlbumSource(w http.ResponseWriter, r *http.Request) {
  89. if s.maxUploadBytes > 0 {
  90. r.Body = http.MaxBytesReader(w, r.Body, s.maxUploadBytes)
  91. }
  92. albumID := chi.URLParam(r, "albumId")
  93. file, header, err := r.FormFile("file")
  94. if err != nil {
  95. writeError(w, http.StatusBadRequest, "INVALID_REQUEST", "missing file form field")
  96. return
  97. }
  98. defer file.Close()
  99. if err := s.albums.UploadSource(r.Context(), albumID, header.Filename, file); err != nil {
  100. writeError(w, http.StatusInternalServerError, "INTERNAL", err.Error())
  101. return
  102. }
  103. writeJSON(w, http.StatusOK, map[string]any{"status": "UPLOADED"})
  104. }
  105. func (s *Server) finalizeAlbum(w http.ResponseWriter, r *http.Request) {
  106. albumID := chi.URLParam(r, "albumId")
  107. idx, err := s.albums.Finalize(r.Context(), albumID)
  108. if err != nil {
  109. status := http.StatusInternalServerError
  110. code := "INDEXING_FAILED"
  111. if strings.Contains(err.Error(), "not found") {
  112. status = http.StatusNotFound
  113. code = "NOT_FOUND"
  114. }
  115. if errors.Is(err, albums.ErrNoValidImages) {
  116. status = http.StatusUnprocessableEntity
  117. }
  118. writeError(w, status, code, err.Error())
  119. return
  120. }
  121. writeJSON(w, http.StatusOK, map[string]any{
  122. "status": "READY",
  123. "photoCount": idx.PhotoCount,
  124. })
  125. }
  126. func (s *Server) getAlbum(w http.ResponseWriter, r *http.Request) {
  127. albumID := chi.URLParam(r, "albumId")
  128. idx, err := s.albums.GetAlbum(r.Context(), albumID)
  129. if err != nil {
  130. writeError(w, http.StatusNotFound, "NOT_FOUND", "album not found")
  131. return
  132. }
  133. writeJSON(w, http.StatusOK, idx)
  134. }
  135. func (s *Server) listAlbums(w http.ResponseWriter, r *http.Request) {
  136. albumsList, err := s.albums.ListAlbums(r.Context())
  137. if err != nil {
  138. writeError(w, http.StatusInternalServerError, "INTERNAL", err.Error())
  139. return
  140. }
  141. writeJSON(w, http.StatusOK, map[string]any{"albums": albumsList})
  142. }
  143. func (s *Server) getFeed(w http.ResponseWriter, r *http.Request) {
  144. limit := 80
  145. if raw := r.URL.Query().Get("limit"); raw != "" {
  146. n, err := strconv.Atoi(raw)
  147. if err != nil {
  148. writeError(w, http.StatusBadRequest, "INVALID_REQUEST", "invalid limit")
  149. return
  150. }
  151. limit = n
  152. }
  153. resp, err := s.feed.Build(
  154. r.Context(),
  155. limit,
  156. r.URL.Query().Get("cursor"),
  157. r.URL.Query().Get("seed"),
  158. )
  159. if err != nil {
  160. writeError(w, http.StatusBadRequest, "INVALID_REQUEST", err.Error())
  161. return
  162. }
  163. writeJSON(w, http.StatusOK, resp)
  164. }
  165. func (s *Server) getImage(w http.ResponseWriter, r *http.Request) {
  166. albumID := chi.URLParam(r, "albumId")
  167. idxRaw := chi.URLParam(r, "index")
  168. idx, err := strconv.Atoi(idxRaw)
  169. if err != nil {
  170. writeError(w, http.StatusBadRequest, "INVALID_REQUEST", "invalid image index")
  171. return
  172. }
  173. mode := r.URL.Query().Get("mode")
  174. if mode == "" {
  175. mode = "viewer"
  176. }
  177. wallWidth := 480
  178. if raw := r.URL.Query().Get("w"); raw != "" {
  179. n, err := strconv.Atoi(raw)
  180. if err == nil {
  181. wallWidth = n
  182. }
  183. }
  184. result, err := s.images.GetImage(r.Context(), albumID, idx, mode, wallWidth)
  185. if err != nil {
  186. status := http.StatusInternalServerError
  187. code := "INTERNAL"
  188. if strings.Contains(err.Error(), "out of range") || strings.Contains(err.Error(), "not found") {
  189. status = http.StatusNotFound
  190. code = "NOT_FOUND"
  191. }
  192. writeError(w, status, code, err.Error())
  193. return
  194. }
  195. w.Header().Set("Content-Type", result.ContentType)
  196. w.Header().Set("Cache-Control", "public, max-age=86400")
  197. w.WriteHeader(http.StatusOK)
  198. if _, err := w.Write(result.Bytes); err != nil {
  199. log.Printf("write image response failed: %v", err)
  200. }
  201. }
  202. func jsonBody(r *http.Request, out any) error {
  203. if r.Body == nil {
  204. return fmt.Errorf("missing body")
  205. }
  206. defer r.Body.Close()
  207. dec := json.NewDecoder(r.Body)
  208. dec.DisallowUnknownFields()
  209. if err := dec.Decode(out); err != nil {
  210. return fmt.Errorf("invalid body: %w", err)
  211. }
  212. return nil
  213. }
  214. func Warmup(ctx context.Context, albumsService *albums.Service) {
  215. if err := albumsService.RefreshFromStorage(ctx); err != nil {
  216. log.Printf("album cache warmup skipped: %v", err)
  217. }
  218. }