Files
geo/server/internal/tenant/transport/asset_handler.go
T
root acf7738bdd feat(tenant-api): add desktop content asset endpoint scoped by tenant
Lets desktop clients fetch tenant images/articles assets via Bearer-auth
without requiring the public asset signature, so stale-signed assets
served from existing articles still load on the desktop side.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 12:20:54 +08:00

166 lines
4.1 KiB
Go

package transport
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"fmt"
"image"
"image/jpeg"
"image/png"
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
_ "golang.org/x/image/webp"
"github.com/geo-platform/tenant-api/internal/bootstrap"
)
type AssetHandler struct {
secret string
app *bootstrap.App
}
func NewAssetHandler(app *bootstrap.App) *AssetHandler {
return &AssetHandler{
secret: strings.TrimSpace(app.Config.JWT.Secret),
app: app,
}
}
func (h *AssetHandler) BuildArticleImageURL(objectKey string) string {
token := h.signObjectKey(objectKey)
return "/api/public/assets/" + token
}
func (h *AssetHandler) Serve(c *gin.Context) {
objectKey, ok := h.parseToken(c.Param("token"))
if !ok {
c.AbortWithStatus(http.StatusNotFound)
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)
return
}
if converted, contentType, ok := convertPublicAssetFormat(content, c.Query("format")); ok {
c.Header("Cache-Control", "public, max-age=31536000, immutable")
c.Header("Content-Type", contentType)
c.Writer.WriteHeader(http.StatusOK)
_, _ = c.Writer.Write(converted)
return
}
contentType := http.DetectContentType(content)
c.Header("Cache-Control", "public, max-age=31536000, immutable")
c.Header("Content-Type", contentType)
c.Writer.WriteHeader(http.StatusOK)
_, _ = c.Writer.Write(content)
}
func convertPublicAssetFormat(content []byte, format string) ([]byte, string, bool) {
normalized := strings.ToLower(strings.TrimSpace(format))
if normalized == "" {
return nil, "", false
}
if normalized != "png" && normalized != "jpg" && normalized != "jpeg" {
return nil, "", false
}
decoded, _, err := image.Decode(bytes.NewReader(content))
if err != nil {
return nil, "", false
}
var output bytes.Buffer
if normalized == "png" {
if err := png.Encode(&output, decoded); err != nil {
return nil, "", false
}
return output.Bytes(), "image/png", true
}
if err := jpeg.Encode(&output, decoded, &jpeg.Options{Quality: 92}); err != nil {
return nil, "", false
}
return output.Bytes(), "image/jpeg", true
}
func (h *AssetHandler) signObjectKey(objectKey string) string {
encodedKey := base64.RawURLEncoding.EncodeToString([]byte(objectKey))
mac := hmac.New(sha256.New, []byte(h.secret))
_, _ = mac.Write([]byte(objectKey))
signature := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
return fmt.Sprintf("%s.%s", encodedKey, signature)
}
func (h *AssetHandler) parseToken(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 := string(objectKeyBytes)
if strings.TrimSpace(objectKey) == "" {
return "", false
}
expected := h.signObjectKey(objectKey)
if !hmac.Equal([]byte(expected), []byte(token)) {
return "", false
}
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/")
}