Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
This commit was merged in pull request #68.
This commit is contained in:
@@ -83,7 +83,7 @@ func newLoginRouter(t *testing.T, issuer string) http.Handler {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, err := NewRouter(logger, "dev", trusted, flow, sessions, newTestSettings(t),
|
||||
newTestRunner(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// photoCacheControl lets the browser keep a photo for a year without
|
||||
// revalidating: a photo's files never change once stored, and a new image
|
||||
// gets a new id.
|
||||
const photoCacheControl = "private, max-age=31536000, immutable"
|
||||
|
||||
// PhotoFiles opens a cached photo's file at one of its stored sizes. An
|
||||
// unknown photo or size is an error matching fs.ErrNotExist.
|
||||
type PhotoFiles interface {
|
||||
OpenPhoto(ctx context.Context, id string, size int) (*os.File, error)
|
||||
}
|
||||
|
||||
// getPhoto answers GET /media/photos/{id}-{size}.jpg with the stored JPEG.
|
||||
func getPhoto(logger *slog.Logger, photos PhotoFiles) http.HandlerFunc {
|
||||
notFound := apiErrorHandler(logger, http.StatusNotFound, "not_found", "not found")
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
id, size, ok := parsePhotoName(chi.URLParam(r, "name"))
|
||||
if !ok {
|
||||
notFound(w, r)
|
||||
return
|
||||
}
|
||||
f, err := photos.OpenPhoto(r.Context(), id, size)
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
notFound(w, r)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
logger.ErrorContext(r.Context(), "open photo", "id", id, "size", size, "error", err)
|
||||
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
logger.ErrorContext(r.Context(), "stat photo", "id", id, "size", size, "error", err)
|
||||
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
w.Header().Set("Cache-Control", photoCacheControl)
|
||||
http.ServeContent(w, r, "", info.ModTime(), f)
|
||||
}
|
||||
}
|
||||
|
||||
// parsePhotoName splits "{id}-{size}.jpg" into its id and size, refusing
|
||||
// a size not written in its canonical decimal form.
|
||||
func parsePhotoName(name string) (id string, size int, ok bool) {
|
||||
stem, ok := strings.CutSuffix(name, ".jpg")
|
||||
if !ok {
|
||||
return "", 0, false
|
||||
}
|
||||
i := strings.LastIndexByte(stem, '-')
|
||||
if i < 0 {
|
||||
return "", 0, false
|
||||
}
|
||||
size, err := strconv.Atoi(stem[i+1:])
|
||||
if err != nil || strconv.Itoa(size) != stem[i+1:] {
|
||||
return "", 0, false
|
||||
}
|
||||
return stem[:i], size, true
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
// newMediaRouter returns a router over a photo cache whose data directory
|
||||
// holds both sizes of two photos, the id of the one with a photos row, and
|
||||
// the id of the orphan without one. Each size's file holds its size as
|
||||
// text.
|
||||
func newMediaRouter(t *testing.T) (r http.Handler, id, orphan string) {
|
||||
t.Helper()
|
||||
dataDir := t.TempDir()
|
||||
st := newTestStore(t)
|
||||
photos, err := recipes.NewPhotoCache(recipes.NewFetcher(), st, dataDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
id, orphan = store.NewID(), store.NewID()
|
||||
for _, stem := range []string{id, orphan} {
|
||||
for _, size := range []string{"400", "1200"} {
|
||||
p := filepath.Join(dataDir, "media", "photos", stem+"-"+size+".jpg")
|
||||
if err := os.WriteFile(p, []byte("jpeg "+size), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
now := store.Now().String()
|
||||
if _, err := db.New(st.Writer).CreatePhoto(t.Context(), db.CreatePhotoParams{
|
||||
ID: id,
|
||||
SourceUrl: "https://example.com/a.jpg",
|
||||
LocalPath: "media/photos/" + id + "-1200.jpg",
|
||||
Width: 1600,
|
||||
Height: 1200,
|
||||
Sha256: "sha",
|
||||
FetchedAt: now,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
r, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestRunner(t),
|
||||
photos, fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return r, id, orphan
|
||||
}
|
||||
|
||||
func TestGetPhotoServesEachSize(t *testing.T) {
|
||||
t.Parallel()
|
||||
router, id, _ := newMediaRouter(t)
|
||||
for _, size := range []string{"400", "1200"} {
|
||||
req := httptest.NewRequest(http.MethodGet, "/media/photos/"+id+"-"+size+".jpg", nil)
|
||||
req.AddCookie(validSessionCookie)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("size %s: status = %d, want 200", size, rec.Code)
|
||||
}
|
||||
if got := rec.Body.String(); got != "jpeg "+size {
|
||||
t.Errorf("size %s: body = %q", size, got)
|
||||
}
|
||||
if got := rec.Header().Get("Content-Type"); got != "image/jpeg" {
|
||||
t.Errorf("size %s: Content-Type = %q, want image/jpeg", size, got)
|
||||
}
|
||||
if got := rec.Header().Get("Cache-Control"); got != "private, max-age=31536000, immutable" {
|
||||
t.Errorf("size %s: Cache-Control = %q", size, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPhotoRequiresSession(t *testing.T) {
|
||||
t.Parallel()
|
||||
router, id, _ := newMediaRouter(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/media/photos/"+id+"-400.jpg", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want 401", rec.Code)
|
||||
}
|
||||
if got := rec.Body.String(); got != unauthenticatedJSON {
|
||||
t.Errorf("body = %s, want %s", got, unauthenticatedJSON)
|
||||
}
|
||||
if got := rec.Header().Get("Cache-Control"); got != "" {
|
||||
t.Errorf("Cache-Control = %q on a refused request", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPhotoUnknownNotFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
router, id, orphan := newMediaRouter(t)
|
||||
for _, name := range []string{
|
||||
store.NewID() + "-400.jpg", // no such photo
|
||||
orphan + "-400.jpg", // file stored, no photos row
|
||||
id + "-800.jpg", // size not stored
|
||||
orphan + "-1200.jpg",
|
||||
id + "-0400.jpg",
|
||||
id + "-+400.jpg",
|
||||
id + "-400.png",
|
||||
id + ".jpg",
|
||||
id + "-400",
|
||||
"..%2F" + id + "-400.jpg",
|
||||
"not-an-id-400.jpg",
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
req := httptest.NewRequest(http.MethodGet, "/media/photos/"+name, nil)
|
||||
req.AddCookie(validSessionCookie)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404", rec.Code)
|
||||
}
|
||||
if got := rec.Body.String(); got != notFoundJSON {
|
||||
t.Errorf("body = %s, want %s", got, notFoundJSON)
|
||||
}
|
||||
if got := rec.Header().Get("Cache-Control"); got != "" {
|
||||
t.Errorf("Cache-Control = %q on a 404", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -45,7 +45,7 @@ func newOriginRouter(t *testing.T, logs io.Writer) http.Handler {
|
||||
}
|
||||
logger := slog.New(forwarded.LogHandler(slog.NewJSONHandler(logs, nil)))
|
||||
r, err := NewRouter(logger, "dev", trusted, probeAuth{}, stubSessions{}, newTestSettings(t),
|
||||
newTestRunner(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -30,16 +30,20 @@ type RouteMounter interface {
|
||||
// only from a peer in trusted (see resolveOrigin). version is reported by
|
||||
// GET /health; auth mounts the /auth routes; sessions guards /api/v1 and
|
||||
// /media; settings serves /api/v1/settings/household; runner serves
|
||||
// POST /api/v1/jobs and GET /api/v1/jobs/{id}; assets is the web build
|
||||
// POST /api/v1/jobs and GET /api/v1/jobs/{id}; photos serves
|
||||
// GET /media/photos/{id}-{size}.jpg; assets is the web build
|
||||
// served for every other GET. /health, /auth and the web build are public.
|
||||
// Requests to /api/v1 other than GET also need the session's CSRF token in CSRFHeader.
|
||||
func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, auth RouteMounter, sessions SessionLookup, settings *household.SettingsService, runner *jobs.Runner, assets fs.FS) (http.Handler, error) {
|
||||
func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, auth RouteMounter, sessions SessionLookup, settings *household.SettingsService, runner *jobs.Runner, photos PhotoFiles, assets fs.FS) (http.Handler, error) {
|
||||
if settings == nil {
|
||||
return nil, errors.New("server: nil household settings service")
|
||||
}
|
||||
if runner == nil {
|
||||
return nil, errors.New("server: nil job runner")
|
||||
}
|
||||
if photos == nil {
|
||||
return nil, errors.New("server: nil photo files")
|
||||
}
|
||||
notFound := apiErrorHandler(logger, http.StatusNotFound, "not_found", "not found")
|
||||
methodNotAllowed := apiErrorHandler(logger, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
|
||||
|
||||
@@ -70,6 +74,7 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
|
||||
apiV1.Patch("/settings/household", patchHouseholdSettings(logger, settings))
|
||||
apiV1.Post("/jobs", postJob(logger, runner))
|
||||
apiV1.Get("/jobs/{id}", getJob(logger, runner))
|
||||
media.Get("/photos/{name}", getPhoto(logger, photos))
|
||||
r.Route("/api", func(r chi.Router) {
|
||||
r.MethodNotAllowed(methodNotAllowed)
|
||||
r.NotFound(notFound)
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
|
||||
)
|
||||
@@ -38,7 +39,7 @@ func newTestRouterWith(t *testing.T, version string, settings *household.Setting
|
||||
"index.html": {Data: []byte(fixtureIndex)},
|
||||
"assets/index-AbC123.js": {Data: []byte(fixtureAsset)},
|
||||
}
|
||||
r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, runner, assets)
|
||||
r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, runner, newTestPhotos(t), assets)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -112,11 +113,31 @@ func newTestRunner(t *testing.T) *jobs.Runner {
|
||||
return runner
|
||||
}
|
||||
|
||||
// newTestPhotos returns a photo cache over a migrated temp database and an
|
||||
// empty temp data directory.
|
||||
func newTestPhotos(t *testing.T) *recipes.PhotoCache {
|
||||
t.Helper()
|
||||
photos, err := recipes.NewPhotoCache(recipes.NewFetcher(), newTestStore(t), t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return photos
|
||||
}
|
||||
|
||||
func TestNewRouterRequiresPhotoFiles(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestRunner(t), nil, fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted nil photo files")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRouterRequiresSettings(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, nil, newTestRunner(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, nil, newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil settings service")
|
||||
}
|
||||
}
|
||||
@@ -125,7 +146,7 @@ func TestNewRouterRequiresJobRunner(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), nil, fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), nil, newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil job runner")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,7 +216,7 @@ func TestHouseholdSettingsWithRealSession(t *testing.T) {
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
router, err := NewRouter(logger, "dev", nil, stubAuth{}, sessions, household.NewSettingsService(st),
|
||||
newTestRunner(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user