618399f86d
- Replace single Load() with a watching Store that re-reads config files
(and config.local.yaml) and fans out a ReloadEvent with a per-field diff
so consumers can decide whether the change is hot-applicable or requires
a process restart.
- Wrap llm, retrieval, vector store, and object storage clients in
Reloadable* shells so the bootstrap can swap their underlying impls when
config changes without re-instantiating handlers.
- Make jwt.Manager and ops TokenIssuer mutable under a lock so secrets and
TTLs can be rotated live; thread default plan code through a setter on
the ops AdminUserService.
- Wire ConfigStore through bootstrap and every cmd/main.go, scheduler /
worker / tenant-api / ops-api start the watcher; services and handlers
take a config.Provider so they always read current values for things
like generation.stream_enabled, scheduler dispatch, retrieval, etc.
- Switch shared/config decoding off viper to a Kratos-derived runtime
package so env placeholders (\${VAR:default}) resolve consistently and
the same source machinery powers both the loader and the watcher.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
169 lines
4.2 KiB
Go
169 lines
4.2 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 {
|
|
app *bootstrap.App
|
|
}
|
|
|
|
func NewAssetHandler(app *bootstrap.App) *AssetHandler {
|
|
return &AssetHandler{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) secret() string {
|
|
if h == nil || h.app == nil || h.app.Config() == nil {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(h.app.Config().JWT.Secret)
|
|
}
|
|
|
|
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/")
|
|
}
|