| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246 |
- package httpapi
- import (
- "context"
- "encoding/json"
- "errors"
- "fmt"
- "log"
- "net/http"
- "strconv"
- "strings"
- "github.com/go-chi/chi/v5"
- "github.com/go-chi/chi/v5/middleware"
- "viewer/internal/albums"
- "viewer/internal/feed"
- "viewer/internal/images"
- "viewer/internal/web"
- )
- type Server struct {
- albums *albums.Service
- feed *feed.Service
- images *images.Service
- maxUploadBytes int64
- }
- func New(albumsService *albums.Service, feedService *feed.Service, imageService *images.Service, maxUploadBytes int64) *Server {
- return &Server{
- albums: albumsService,
- feed: feedService,
- images: imageService,
- maxUploadBytes: maxUploadBytes,
- }
- }
- func (s *Server) Router() http.Handler {
- r := chi.NewRouter()
- r.Use(middleware.RequestID)
- r.Use(middleware.RealIP)
- r.Use(middleware.Recoverer)
- r.Use(middleware.Logger)
- r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) {
- w.Header().Set("Content-Type", "application/json")
- _, _ = w.Write([]byte(`{"status":"ok"}`))
- })
- r.Route("/api", func(r chi.Router) {
- r.Post("/albums", s.createAlbum)
- r.Post("/albums/{albumId}/upload", s.uploadAlbumSource)
- r.Get("/albums", s.listAlbums)
- r.Post("/albums/{albumId}/finalize", s.finalizeAlbum)
- r.Get("/albums/{albumId}", s.getAlbum)
- r.Get("/feed", s.getFeed)
- r.Get("/image/{albumId}/{index}", s.getImage)
- })
- staticHandler := web.Handler()
- r.NotFound(func(w http.ResponseWriter, r *http.Request) {
- if strings.HasPrefix(r.URL.Path, "/api/") {
- writeError(w, http.StatusNotFound, "NOT_FOUND", "resource not found")
- return
- }
- staticHandler.ServeHTTP(w, r)
- })
- r.Get("/*", func(w http.ResponseWriter, r *http.Request) {
- staticHandler.ServeHTTP(w, r)
- })
- return r
- }
- type createAlbumRequest struct {
- Filename string `json:"filename"`
- SizeBytes int64 `json:"sizeBytes"`
- }
- func (s *Server) createAlbum(w http.ResponseWriter, r *http.Request) {
- var req createAlbumRequest
- if err := jsonBody(r, &req); err != nil {
- writeError(w, http.StatusBadRequest, "INVALID_REQUEST", err.Error())
- return
- }
- res, err := s.albums.CreateUpload(r.Context(), req.Filename, req.SizeBytes)
- if err != nil {
- writeError(w, http.StatusBadRequest, "INVALID_REQUEST", err.Error())
- return
- }
- writeJSON(w, http.StatusOK, map[string]any{
- "albumId": res.AlbumID,
- "upload": map[string]any{
- "method": "PUT",
- "url": res.URL,
- "headers": res.Headers,
- },
- })
- }
- func (s *Server) uploadAlbumSource(w http.ResponseWriter, r *http.Request) {
- if s.maxUploadBytes > 0 {
- r.Body = http.MaxBytesReader(w, r.Body, s.maxUploadBytes)
- }
- albumID := chi.URLParam(r, "albumId")
- file, header, err := r.FormFile("file")
- if err != nil {
- writeError(w, http.StatusBadRequest, "INVALID_REQUEST", "missing file form field")
- return
- }
- defer file.Close()
- if err := s.albums.UploadSource(r.Context(), albumID, header.Filename, file); err != nil {
- writeError(w, http.StatusInternalServerError, "INTERNAL", err.Error())
- return
- }
- writeJSON(w, http.StatusOK, map[string]any{"status": "UPLOADED"})
- }
- func (s *Server) finalizeAlbum(w http.ResponseWriter, r *http.Request) {
- albumID := chi.URLParam(r, "albumId")
- idx, err := s.albums.Finalize(r.Context(), albumID)
- if err != nil {
- status := http.StatusInternalServerError
- code := "INDEXING_FAILED"
- if strings.Contains(err.Error(), "not found") {
- status = http.StatusNotFound
- code = "NOT_FOUND"
- }
- if errors.Is(err, albums.ErrNoValidImages) {
- status = http.StatusUnprocessableEntity
- }
- writeError(w, status, code, err.Error())
- return
- }
- writeJSON(w, http.StatusOK, map[string]any{
- "status": "READY",
- "photoCount": idx.PhotoCount,
- })
- }
- func (s *Server) getAlbum(w http.ResponseWriter, r *http.Request) {
- albumID := chi.URLParam(r, "albumId")
- idx, err := s.albums.GetAlbum(r.Context(), albumID)
- if err != nil {
- writeError(w, http.StatusNotFound, "NOT_FOUND", "album not found")
- return
- }
- writeJSON(w, http.StatusOK, idx)
- }
- func (s *Server) listAlbums(w http.ResponseWriter, r *http.Request) {
- albumsList, err := s.albums.ListAlbums(r.Context())
- if err != nil {
- writeError(w, http.StatusInternalServerError, "INTERNAL", err.Error())
- return
- }
- writeJSON(w, http.StatusOK, map[string]any{"albums": albumsList})
- }
- func (s *Server) getFeed(w http.ResponseWriter, r *http.Request) {
- limit := 80
- if raw := r.URL.Query().Get("limit"); raw != "" {
- n, err := strconv.Atoi(raw)
- if err != nil {
- writeError(w, http.StatusBadRequest, "INVALID_REQUEST", "invalid limit")
- return
- }
- limit = n
- }
- resp, err := s.feed.Build(
- r.Context(),
- limit,
- r.URL.Query().Get("cursor"),
- r.URL.Query().Get("seed"),
- )
- if err != nil {
- writeError(w, http.StatusBadRequest, "INVALID_REQUEST", err.Error())
- return
- }
- writeJSON(w, http.StatusOK, resp)
- }
- func (s *Server) getImage(w http.ResponseWriter, r *http.Request) {
- albumID := chi.URLParam(r, "albumId")
- idxRaw := chi.URLParam(r, "index")
- idx, err := strconv.Atoi(idxRaw)
- if err != nil {
- writeError(w, http.StatusBadRequest, "INVALID_REQUEST", "invalid image index")
- return
- }
- mode := r.URL.Query().Get("mode")
- if mode == "" {
- mode = "viewer"
- }
- wallWidth := 480
- if raw := r.URL.Query().Get("w"); raw != "" {
- n, err := strconv.Atoi(raw)
- if err == nil {
- wallWidth = n
- }
- }
- result, err := s.images.GetImage(r.Context(), albumID, idx, mode, wallWidth)
- if err != nil {
- status := http.StatusInternalServerError
- code := "INTERNAL"
- if strings.Contains(err.Error(), "out of range") || strings.Contains(err.Error(), "not found") {
- status = http.StatusNotFound
- code = "NOT_FOUND"
- }
- writeError(w, status, code, err.Error())
- return
- }
- w.Header().Set("Content-Type", result.ContentType)
- w.Header().Set("Cache-Control", "public, max-age=86400")
- w.WriteHeader(http.StatusOK)
- if _, err := w.Write(result.Bytes); err != nil {
- log.Printf("write image response failed: %v", err)
- }
- }
- func jsonBody(r *http.Request, out any) error {
- if r.Body == nil {
- return fmt.Errorf("missing body")
- }
- defer r.Body.Close()
- dec := json.NewDecoder(r.Body)
- dec.DisallowUnknownFields()
- if err := dec.Decode(out); err != nil {
- return fmt.Errorf("invalid body: %w", err)
- }
- return nil
- }
- func Warmup(ctx context.Context, albumsService *albums.Service) {
- if err := albumsService.RefreshFromStorage(ctx); err != nil {
- log.Printf("album cache warmup skipped: %v", err)
- }
- }
|