From c62b65d7cdd04b67da2f8225f71d1bcbe478de20 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Wed, 7 Oct 2026 21:57:26 +0200 Subject: [PATCH] Add GET /api/v1/character/details with each image's change time Returns every character image with when it was last changed, in Unix seconds, so clients can cache the images and download only the ones that changed. --- internal/backend/characters.go | 27 +++++++++++++++++++++++++++ internal/backend/characters_test.go | 27 +++++++++++++++++++++++++++ internal/server/handlers_test.go | 10 ++++++++++ internal/server/misc_handler.go | 6 ++++++ internal/server/openapi_test.go | 2 ++ 5 files changed, 72 insertions(+) diff --git a/internal/backend/characters.go b/internal/backend/characters.go index c23f26a..0a8cd73 100644 --- a/internal/backend/characters.go +++ b/internal/backend/characters.go @@ -37,6 +37,33 @@ func (c *CharacterService) List() ([]string, error) { return characters, nil } +// CharacterDetails is a character image with the time its file was last changed, so a +// client that caches images knows when to download one again. +type CharacterDetails struct { + Name string `json:"name" doc:"File name of the character image"` + Modified int64 `json:"modified" doc:"When the image was last changed, in Unix seconds"` +} + +// ListDetails returns all character images with the time each was last changed. +func (c *CharacterService) ListDetails() ([]CharacterDetails, error) { + entries, err := fs.ReadDir(c.root.FS(), ".") + if err != nil { + return nil, err + } + characters := []CharacterDetails{} + for _, entry := range entries { + if entry.IsDir() || !isImage(entry.Name()) { + continue + } + info, err := entry.Info() + if err != nil { + return nil, err + } + characters = append(characters, CharacterDetails{Name: entry.Name(), Modified: info.ModTime().Unix()}) + } + return characters, nil +} + // Open opens the character image with the given file name. func (c *CharacterService) Open(name string) (*os.File, error) { if name == "" || name != filepath.Base(name) || !isImage(name) { diff --git a/internal/backend/characters_test.go b/internal/backend/characters_test.go index e25a076..ec73209 100644 --- a/internal/backend/characters_test.go +++ b/internal/backend/characters_test.go @@ -4,6 +4,7 @@ import ( "io" "path/filepath" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -31,6 +32,32 @@ func TestCharacterList(t *testing.T) { assert.Equal(t, []string{"Adol.png", "Aloy.JPG"}, characters) } +func TestCharacterListDetails(t *testing.T) { + c := newCharacterService(t) + changed := time.Date(2026, 10, 7, 12, 0, 0, 0, time.UTC) + require.NoError(t, c.root.Chtimes("Adol.png", changed, changed)) + + characters, err := c.ListDetails() + require.NoError(t, err) + require.Len(t, characters, 2) + assert.Equal(t, CharacterDetails{Name: "Adol.png", Modified: changed.Unix()}, characters[0]) + assert.Equal(t, "Aloy.JPG", characters[1].Name) +} + +func TestCharacterListDetailsSeesChangedImage(t *testing.T) { + c := newCharacterService(t) + before, err := c.ListDetails() + require.NoError(t, err) + + later := time.Unix(before[0].Modified, 0).Add(time.Hour) + require.NoError(t, c.root.Chtimes("Adol.png", later, later)) + + after, err := c.ListDetails() + require.NoError(t, err) + assert.Greater(t, after[0].Modified, before[0].Modified) + assert.Equal(t, before[1], after[1], "unchanged images keep their time") +} + func TestCharacterOpen(t *testing.T) { c := newCharacterService(t) diff --git a/internal/server/handlers_test.go b/internal/server/handlers_test.go index e613851..e5f8488 100644 --- a/internal/server/handlers_test.go +++ b/internal/server/handlers_test.go @@ -167,6 +167,16 @@ func TestCharacterEndpoints(t *testing.T) { require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/api/v1/character/list").Body.Bytes(), &list)) assert.Equal(t, []string{"Adol.png", "Aloy.jpg"}, list) + var details []struct { + Name string `json:"name"` + Modified int64 `json:"modified"` + } + require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/api/v1/character/details").Body.Bytes(), &details)) + require.Len(t, details, 2) + assert.Equal(t, "Adol.png", details[0].Name) + assert.Equal(t, "Aloy.jpg", details[1].Name) + assert.Positive(t, details[0].Modified) + rec := e.do(http.MethodGet, "/api/v1/character?name=Adol.png") assert.Equal(t, http.StatusOK, rec.Code) assert.Equal(t, "image/png", rec.Header().Get("Content-Type")) diff --git a/internal/server/misc_handler.go b/internal/server/misc_handler.go index ba92152..59f9a42 100644 --- a/internal/server/misc_handler.go +++ b/internal/server/misc_handler.go @@ -57,6 +57,12 @@ func (s *Server) registerMisc(a huma.API) { func(ctx context.Context, _ *struct{}) (*jsonBody[[]string], error) { return ok(s.characters.List()) }) + register(s, a, route{op: get("list-character-details", "/character/details", "characters", + "List characters with change times", + "Returns every character image with the time it was last changed, so clients can cache images and download only the changed ones.")}, + func(ctx context.Context, _ *struct{}) (*jsonBody[[]backend.CharacterDetails], error) { + return ok(s.characters.ListDetails()) + }) image := get("get-character", "/character", "characters", "Character image", "Returns a character image.", http.StatusBadRequest, http.StatusNotFound) image.Responses = fileResponses("The image", "image/png", "image/jpeg") diff --git a/internal/server/openapi_test.go b/internal/server/openapi_test.go index 495e543..d37b7ba 100644 --- a/internal/server/openapi_test.go +++ b/internal/server/openapi_test.go @@ -76,6 +76,7 @@ func TestOpenAPIDocumentsTheV1API(t *testing.T) { assert.Equal(t, []string{ "DELETE /api/v1/token", "GET /api/v1/character", + "GET /api/v1/character/details", "GET /api/v1/character/list", "GET /api/v1/download", "GET /api/v1/download/linux", @@ -207,6 +208,7 @@ func TestResponsesMatchOpenAPI(t *testing.T) { // Characters, downloads, server info. v.check(http.MethodGet, "/api/v1/character/list", "", http.StatusOK) + v.check(http.MethodGet, "/api/v1/character/details", "", http.StatusOK) v.check(http.MethodGet, "/api/v1/character?name=Aloy.jpg", "", http.StatusOK) v.check(http.MethodGet, "/api/v1/character?name=../secret.png", "", http.StatusBadRequest) v.check(http.MethodGet, "/api/v1/character?name=Missing.png", "", http.StatusNotFound)