diff --git a/server/internal/tenant/transport/asset_handler.go b/server/internal/tenant/transport/asset_handler.go index bcdbe2d..cadad50 100644 --- a/server/internal/tenant/transport/asset_handler.go +++ b/server/internal/tenant/transport/asset_handler.go @@ -10,6 +10,7 @@ import ( "image/jpeg" "image/png" "net/http" + "strconv" "strings" "github.com/gin-gonic/gin" @@ -42,6 +43,21 @@ func (h *AssetHandler) Serve(c *gin.Context) { return } + h.serveObject(c, objectKey) +} + +func (h *AssetHandler) ServeDesktop(c *gin.Context) { + client := MustDesktopClient(c.Request.Context()) + objectKey, ok := objectKeyFromAssetToken(c.Param("token")) + if !ok || !isDesktopClientAssetKey(objectKey, client.TenantID) { + c.AbortWithStatus(http.StatusNotFound) + return + } + + h.serveObject(c, objectKey) +} + +func (h *AssetHandler) serveObject(c *gin.Context, objectKey string) { content, err := h.app.ObjectStorage.GetBytes(c.Request.Context(), objectKey) if err != nil || len(content) == 0 { c.AbortWithStatus(http.StatusNotFound) @@ -121,3 +137,29 @@ func (h *AssetHandler) parseToken(token string) (string, bool) { return objectKey, true } + +func objectKeyFromAssetToken(token string) (string, bool) { + parts := strings.Split(token, ".") + if len(parts) != 2 { + return "", false + } + + objectKeyBytes, err := base64.RawURLEncoding.DecodeString(parts[0]) + if err != nil { + return "", false + } + objectKey := strings.TrimSpace(string(objectKeyBytes)) + if objectKey == "" { + return "", false + } + return objectKey, true +} + +func isDesktopClientAssetKey(objectKey string, tenantID int64) bool { + if tenantID <= 0 { + return false + } + tenantPrefix := "tenants/" + strconv.FormatInt(tenantID, 10) + "/" + return strings.HasPrefix(objectKey, tenantPrefix+"images/") || + strings.HasPrefix(objectKey, tenantPrefix+"articles/") +} diff --git a/server/internal/tenant/transport/asset_handler_test.go b/server/internal/tenant/transport/asset_handler_test.go new file mode 100644 index 0000000..1dd6043 --- /dev/null +++ b/server/internal/tenant/transport/asset_handler_test.go @@ -0,0 +1,107 @@ +package transport + +import ( + "context" + "encoding/base64" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + + "github.com/geo-platform/tenant-api/internal/bootstrap" + "github.com/geo-platform/tenant-api/internal/tenant/repository" +) + +type fakeAssetObjectStorage struct { + content []byte + gotKey string +} + +func (s *fakeAssetObjectStorage) Validate() error { + return nil +} + +func (s *fakeAssetObjectStorage) PutBytes(context.Context, string, []byte, string) error { + panic("unexpected call") +} + +func (s *fakeAssetObjectStorage) GetBytes(_ context.Context, objectKey string) ([]byte, error) { + s.gotKey = objectKey + return s.content, nil +} + +func (s *fakeAssetObjectStorage) Exists(context.Context, string) (bool, error) { + panic("unexpected call") +} + +func (s *fakeAssetObjectStorage) Delete(context.Context, string) error { + panic("unexpected call") +} + +func (s *fakeAssetObjectStorage) PublicURL(string) (string, error) { + panic("unexpected call") +} + +func TestAssetHandlerServeDesktop_AllowsStaleSignedTenantAsset(t *testing.T) { + gin.SetMode(gin.TestMode) + + objectKey := "tenants/7/images/test.webp" + storage := &fakeAssetObjectStorage{content: mustDecodeBase64(t, "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=")} + handler := &AssetHandler{ + secret: "current-secret", + app: &bootstrap.App{ObjectStorage: storage}, + } + staleToken := (&AssetHandler{secret: "old-secret"}).signObjectKey(objectKey) + + router := gin.New() + router.GET("/api/desktop/content/assets/:token", func(c *gin.Context) { + ctx := WithDesktopClient(c.Request.Context(), &repository.DesktopClient{TenantID: 7}) + c.Request = c.Request.WithContext(ctx) + handler.ServeDesktop(c) + }) + + resp := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/desktop/content/assets/"+staleToken+"?format=png", nil) + router.ServeHTTP(resp, req) + + assert.Equal(t, http.StatusOK, resp.Code) + assert.Equal(t, objectKey, storage.gotKey) + assert.Equal(t, "image/png", resp.Header().Get("Content-Type")) +} + +func TestAssetHandlerServeDesktop_RejectsOtherTenantAsset(t *testing.T) { + gin.SetMode(gin.TestMode) + + storage := &fakeAssetObjectStorage{content: []byte("not used")} + handler := &AssetHandler{ + secret: "current-secret", + app: &bootstrap.App{ObjectStorage: storage}, + } + token := (&AssetHandler{secret: "old-secret"}).signObjectKey("tenants/8/images/test.webp") + + router := gin.New() + router.GET("/api/desktop/content/assets/:token", func(c *gin.Context) { + ctx := WithDesktopClient(c.Request.Context(), &repository.DesktopClient{TenantID: 7}) + c.Request = c.Request.WithContext(ctx) + handler.ServeDesktop(c) + }) + + resp := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/desktop/content/assets/"+token, nil) + router.ServeHTTP(resp, req) + + assert.Equal(t, http.StatusNotFound, resp.Code) + assert.Empty(t, storage.gotKey) +} + +func mustDecodeBase64(t *testing.T, value string) []byte { + t.Helper() + + data, err := base64.StdEncoding.DecodeString(value) + if err != nil { + t.Fatalf("decode base64 fixture: %v", err) + } + return data +} diff --git a/server/internal/tenant/transport/router.go b/server/internal/tenant/transport/router.go index 43d7532..d0b2ee2 100644 --- a/server/internal/tenant/transport/router.go +++ b/server/internal/tenant/transport/router.go @@ -46,6 +46,7 @@ func RegisterRoutes(app *bootstrap.App) { desktopAuth.GET("/dispatch", desktopDispatchHandler.Dispatch) desktopAuth.GET("/accounts", desktopAccountHandler.List) desktopAuth.GET("/content/articles/:id", desktopContentHandler.Article) + desktopAuth.GET("/content/assets/:token", publicAssets.ServeDesktop) desktopAuth.GET("/publish-tasks", desktopTaskHandler.ListPublish) desktopAuth.POST("/accounts", desktopAccountHandler.Upsert) desktopAuth.PATCH("/accounts/:id", desktopAccountHandler.Patch) diff --git a/server/internal/tenant/transport/router_test.go b/server/internal/tenant/transport/router_test.go index 25fd1c7..18655ec 100644 --- a/server/internal/tenant/transport/router_test.go +++ b/server/internal/tenant/transport/router_test.go @@ -77,6 +77,7 @@ func TestDesktopClientRoutes_ClientTokenAuth(t *testing.T) { }{ {http.MethodGet, "/api/desktop/dispatch"}, {http.MethodGet, "/api/desktop/accounts"}, + {http.MethodGet, "/api/desktop/content/assets/:token"}, {http.MethodPost, "/api/desktop/clients/offline"}, {http.MethodPost, "/api/desktop/tasks/lease"}, {http.MethodPost, "/api/desktop/tasks/:id/result"},