feat: add image upload functionality for articles

- Implemented image upload API in the article service, allowing users to upload images associated with articles.
- Added validation for image size and type, ensuring only supported formats (PNG, JPG, GIF, WebP) are accepted.
- Created a new Aliyun object storage client to handle image storage and retrieval.
- Updated the article handler to include an endpoint for image uploads.
- Enhanced error handling for image upload failures and added relevant error messages.
- Introduced public URL generation for stored images, allowing access via signed tokens.
- Updated localization files to include new messages related to image upload functionality.
- Added tests for image upload and retrieval processes.
This commit is contained in:
2026-04-05 22:10:05 +08:00
parent 1401b50556
commit 94f7186cce
19 changed files with 1033 additions and 14 deletions
+17
View File
@@ -25,9 +25,26 @@ object_storage:
endpoint: localhost:9000
bucket: geo-private
use_ssl: false
public_base_url: ""
region: ""
# Prefer environment override for secrets:
# export OBJECT_STORAGE_ACCESS_KEY=minioadmin
# export OBJECT_STORAGE_SECRET_KEY=minioadmin
# MinIO example:
# provider: minio
# endpoint: localhost:9000
# bucket: geo-private
# use_ssl: false
#
# Aliyun OSS example:
# provider: aliyun
# endpoint: https://oss-cn-hangzhou.aliyuncs.com
# bucket: your-bucket
# region: cn-hangzhou
# use_ssl: true
# public_base_url: https://your-bucket.oss-cn-hangzhou.aliyuncs.com
# export OBJECT_STORAGE_ACCESS_KEY=your-access-key
# export OBJECT_STORAGE_SECRET_KEY=your-secret-key
jwt:
secret: "your-local-secret"
+2
View File
@@ -22,6 +22,7 @@ require (
)
require (
github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible // indirect
github.com/bytedance/sonic v1.9.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
@@ -78,6 +79,7 @@ require (
golang.org/x/sync v0.10.0 // indirect
golang.org/x/sys v0.28.0 // indirect
golang.org/x/text v0.21.0 // indirect
golang.org/x/time v0.5.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20240827150818-7e3bb234dfed // indirect
google.golang.org/grpc v1.66.0 // indirect
google.golang.org/protobuf v1.34.2 // indirect
+4
View File
@@ -2,6 +2,8 @@ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMT
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/alicebob/miniredis/v2 v2.37.0 h1:RheObYW32G1aiJIj81XVt78ZHJpHonHLHW7OLIshq68=
github.com/alicebob/miniredis/v2 v2.37.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible h1:8psS8a+wKfiLt1iVDX79F7Y6wUM49Lcha2FMXt4UM8g=
github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible/go.mod h1:T/Aws4fEfogEE9v+HPhhw+CntffsBHJ8nXQCwKr0/g8=
github.com/avast/retry-go v3.0.0+incompatible/go.mod h1:XtSnn+n/sHqQIpZ10K1qAevBhOOCWBLXXy3hyiqqBrY=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
@@ -234,6 +236,8 @@ golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
+22
View File
@@ -181,6 +181,12 @@ func applyEnvOverrides(cfg *Config) {
if publicBaseURL, ok := lookupNonEmptyEnv("OBJECT_STORAGE_PUBLIC_BASE_URL"); ok {
cfg.ObjectStorage.PublicBaseURL = publicBaseURL
}
if region, ok := lookupNonEmptyEnv("OBJECT_STORAGE_REGION"); ok {
cfg.ObjectStorage.Region = region
}
if useSSL, ok := lookupBoolEnv("OBJECT_STORAGE_USE_SSL"); ok {
cfg.ObjectStorage.UseSSL = useSSL
}
if apiKey, ok := lookupFirstNonEmptyEnv("SILICONFLOW_API_KEY", "RETRIEVAL_API_KEY", "EMBEDDING_API_KEY", "RERANKER_API_KEY"); ok {
cfg.Retrieval.APIKey = apiKey
}
@@ -217,3 +223,19 @@ func lookupNonEmptyEnv(key string) (string, bool) {
return value, true
}
func lookupBoolEnv(key string) (bool, bool) {
value, ok := os.LookupEnv(key)
if !ok {
return false, false
}
switch strings.ToLower(strings.TrimSpace(value)) {
case "1", "true", "yes", "on":
return true, true
case "0", "false", "no", "off":
return false, true
default:
return false, false
}
}
@@ -0,0 +1,213 @@
package objectstorage
import (
"bytes"
"context"
"fmt"
"io"
"strings"
"github.com/aliyun/aliyun-oss-go-sdk/oss"
"go.uber.org/zap"
"github.com/geo-platform/tenant-api/internal/shared/config"
"github.com/geo-platform/tenant-api/internal/shared/middleware"
)
type aliyunClient struct {
client *oss.Client
bucket string
logger *zap.Logger
cfg config.ObjectStorageConfig
}
func NewAliyunClient(cfg config.ObjectStorageConfig, logger *zap.Logger) Client {
endpoint := strings.TrimSpace(cfg.Endpoint)
accessKey := strings.TrimSpace(cfg.AccessKey)
secretKey := strings.TrimSpace(cfg.SecretKey)
bucket := strings.TrimSpace(cfg.Bucket)
region := strings.TrimSpace(cfg.Region)
if endpoint == "" || accessKey == "" || secretKey == "" || bucket == "" || region == "" {
return disabledClient{reason: "missing object_storage aliyun endpoint/access_key/secret_key/bucket/region"}
}
endpointURL, err := parseURLWithScheme(endpoint, boolScheme(cfg.UseSSL))
if err != nil {
return disabledClient{reason: fmt.Sprintf("parse aliyun endpoint failed: %v", err)}
}
client, err := oss.New(
endpointURL.String(),
accessKey,
secretKey,
oss.AuthVersion(oss.AuthV4),
oss.Region(region),
)
if err != nil {
return disabledClient{reason: fmt.Sprintf("init aliyun oss client failed: %v", err)}
}
return &aliyunClient{
client: client,
bucket: bucket,
logger: logger,
cfg: cfg,
}
}
func (c *aliyunClient) Validate() error {
if c == nil || c.client == nil {
return fmt.Errorf("%w: aliyun oss client is not initialized", ErrNotConfigured)
}
if strings.TrimSpace(c.bucket) == "" {
return fmt.Errorf("%w: aliyun bucket is empty", ErrNotConfigured)
}
if strings.TrimSpace(c.cfg.Region) == "" {
return fmt.Errorf("%w: aliyun region is empty", ErrNotConfigured)
}
return nil
}
func (c *aliyunClient) PutBytes(ctx context.Context, objectKey string, content []byte, contentType string) error {
if err := c.Validate(); err != nil {
return err
}
if strings.TrimSpace(objectKey) == "" {
return fmt.Errorf("object key is required")
}
exists, err := c.client.IsBucketExist(c.bucket)
if err != nil {
c.logError(ctx, "aliyun bucket exists check failed",
zap.String("bucket", c.bucket),
zap.String("object_key", objectKey),
zap.Error(err),
)
return fmt.Errorf("check aliyun bucket: %w", err)
}
if !exists {
if err := c.client.CreateBucket(c.bucket); err != nil {
c.logError(ctx, "aliyun create bucket failed",
zap.String("bucket", c.bucket),
zap.String("object_key", objectKey),
zap.Error(err),
)
return fmt.Errorf("create aliyun bucket: %w", err)
}
}
bucket, err := c.client.Bucket(c.bucket)
if err != nil {
c.logError(ctx, "aliyun get bucket failed",
zap.String("bucket", c.bucket),
zap.String("object_key", objectKey),
zap.Error(err),
)
return fmt.Errorf("get aliyun bucket: %w", err)
}
if err := bucket.PutObject(objectKey, bytes.NewReader(content), oss.ContentType(contentType)); err != nil {
c.logError(ctx, "aliyun put object failed",
zap.String("bucket", c.bucket),
zap.String("object_key", objectKey),
zap.Int("content_size", len(content)),
zap.Error(err),
)
return fmt.Errorf("put aliyun object: %w", err)
}
return nil
}
func (c *aliyunClient) GetBytes(ctx context.Context, objectKey string) ([]byte, error) {
if err := c.Validate(); err != nil {
return nil, err
}
bucket, err := c.client.Bucket(c.bucket)
if err != nil {
c.logError(ctx, "aliyun get bucket failed",
zap.String("bucket", c.bucket),
zap.String("object_key", objectKey),
zap.Error(err),
)
return nil, fmt.Errorf("get aliyun bucket: %w", err)
}
reader, err := bucket.GetObject(objectKey)
if err != nil {
c.logError(ctx, "aliyun get object failed",
zap.String("bucket", c.bucket),
zap.String("object_key", objectKey),
zap.Error(err),
)
return nil, fmt.Errorf("get aliyun object: %w", err)
}
defer reader.Close()
data, err := io.ReadAll(reader)
if err != nil {
c.logError(ctx, "aliyun read object failed",
zap.String("bucket", c.bucket),
zap.String("object_key", objectKey),
zap.Error(err),
)
return nil, fmt.Errorf("read aliyun object: %w", err)
}
return data, nil
}
func (c *aliyunClient) Delete(ctx context.Context, objectKey string) error {
if err := c.Validate(); err != nil {
return err
}
if strings.TrimSpace(objectKey) == "" {
return nil
}
bucket, err := c.client.Bucket(c.bucket)
if err != nil {
c.logError(ctx, "aliyun get bucket failed",
zap.String("bucket", c.bucket),
zap.String("object_key", objectKey),
zap.Error(err),
)
return fmt.Errorf("get aliyun bucket: %w", err)
}
if err := bucket.DeleteObject(objectKey); err != nil {
c.logError(ctx, "aliyun delete object failed",
zap.String("bucket", c.bucket),
zap.String("object_key", objectKey),
zap.Error(err),
)
return fmt.Errorf("delete aliyun object: %w", err)
}
return nil
}
func (c *aliyunClient) PublicURL(objectKey string) (string, error) {
if err := c.Validate(); err != nil {
return "", err
}
return buildPublicURL(c.cfg, objectKey, true)
}
func (c *aliyunClient) logError(ctx context.Context, msg string, fields ...zap.Field) {
if c == nil || c.logger == nil {
return
}
if requestID := middleware.RequestIDFromContext(ctx); requestID != "" {
fields = append(fields, zap.String("request_id", requestID))
}
c.logger.Error(msg, fields...)
}
func boolScheme(useSSL bool) string {
if useSSL {
return "https"
}
return "http"
}
@@ -18,6 +18,7 @@ type Client interface {
PutBytes(ctx context.Context, objectKey string, content []byte, contentType string) error
GetBytes(ctx context.Context, objectKey string) ([]byte, error)
Delete(ctx context.Context, objectKey string) error
PublicURL(objectKey string) (string, error)
}
func New(cfg config.ObjectStorageConfig, logger *zap.Logger) Client {
@@ -27,6 +28,8 @@ func New(cfg config.ObjectStorageConfig, logger *zap.Logger) Client {
return disabledClient{reason: "object storage is disabled"}
case "minio":
return NewMinIOClient(cfg, logger)
case "aliyun", "aliyun_oss", "aliyun-oss", "oss":
return NewAliyunClient(cfg, logger)
default:
return disabledClient{reason: fmt.Sprintf("unsupported object storage provider %q", cfg.Provider)}
}
@@ -54,3 +57,7 @@ func (c disabledClient) GetBytes(context.Context, string) ([]byte, error) {
func (c disabledClient) Delete(context.Context, string) error {
return c.Validate()
}
func (c disabledClient) PublicURL(string) (string, error) {
return "", c.Validate()
}
@@ -20,6 +20,7 @@ type minioClient struct {
bucket string
region string
logger *zap.Logger
cfg config.ObjectStorageConfig
}
func NewMinIOClient(cfg config.ObjectStorageConfig, logger *zap.Logger) Client {
@@ -45,6 +46,7 @@ func NewMinIOClient(cfg config.ObjectStorageConfig, logger *zap.Logger) Client {
bucket: bucket,
region: strings.TrimSpace(cfg.Region),
logger: logger,
cfg: cfg,
}
}
@@ -153,6 +155,13 @@ func (c *minioClient) Delete(ctx context.Context, objectKey string) error {
return nil
}
func (c *minioClient) PublicURL(objectKey string) (string, error) {
if err := c.Validate(); err != nil {
return "", err
}
return buildPublicURL(c.cfg, objectKey, false)
}
func (c *minioClient) logError(ctx context.Context, msg string, fields ...zap.Field) {
if c == nil || c.logger == nil {
return
+116
View File
@@ -0,0 +1,116 @@
package objectstorage
import (
"fmt"
"net/url"
"strings"
"github.com/geo-platform/tenant-api/internal/shared/config"
)
func buildPublicURL(cfg config.ObjectStorageConfig, objectKey string, bucketInHost bool) (string, error) {
objectKey = strings.TrimSpace(objectKey)
if objectKey == "" {
return "", fmt.Errorf("object key is required")
}
defaultScheme := "http"
if cfg.UseSSL {
defaultScheme = "https"
}
if publicBaseURL := strings.TrimSpace(cfg.PublicBaseURL); publicBaseURL != "" {
baseURL, err := parseURLWithScheme(publicBaseURL, defaultScheme)
if err != nil {
return "", fmt.Errorf("parse public_base_url: %w", err)
}
baseURL.Path = joinEscapedPath(baseURL.Path, objectKey)
baseURL.RawPath = ""
return baseURL.String(), nil
}
endpoint := strings.TrimSpace(cfg.Endpoint)
bucket := strings.TrimSpace(cfg.Bucket)
if endpoint == "" || bucket == "" {
return "", fmt.Errorf("%w: object storage endpoint or bucket is missing", ErrNotConfigured)
}
endpointURL, err := parseURLWithScheme(endpoint, defaultScheme)
if err != nil {
return "", fmt.Errorf("parse object storage endpoint: %w", err)
}
if bucketInHost {
hostname := endpointURL.Hostname()
if hostname == "" {
return "", fmt.Errorf("invalid object storage endpoint host")
}
host := hostname
if !strings.HasPrefix(host, bucket+".") {
host = bucket + "." + host
}
if port := endpointURL.Port(); port != "" {
host += ":" + port
}
endpointURL.Host = host
endpointURL.Path = joinEscapedPath("", objectKey)
endpointURL.RawPath = ""
return endpointURL.String(), nil
}
endpointURL.Path = joinEscapedPath(endpointURL.Path, bucket, objectKey)
endpointURL.RawPath = ""
return endpointURL.String(), nil
}
func parseURLWithScheme(raw string, defaultScheme string) (*url.URL, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, fmt.Errorf("url is empty")
}
if !strings.Contains(raw, "://") {
raw = defaultScheme + "://" + raw
}
parsed, err := url.Parse(raw)
if err != nil {
return nil, err
}
if parsed.Host == "" {
return nil, fmt.Errorf("url host is empty")
}
return parsed, nil
}
func joinEscapedPath(basePath string, extra ...string) string {
segments := make([]string, 0)
appendPath := func(value string) {
value = strings.TrimSpace(value)
value = strings.Trim(value, "/")
if value == "" {
return
}
for _, segment := range strings.Split(value, "/") {
segment = strings.TrimSpace(segment)
if segment == "" {
continue
}
segments = append(segments, url.PathEscape(segment))
}
}
appendPath(basePath)
for _, value := range extra {
appendPath(value)
}
if len(segments) == 0 {
return "/"
}
return "/" + strings.Join(segments, "/")
}
+119 -4
View File
@@ -3,26 +3,43 @@ package app
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/geo-platform/tenant-api/internal/shared/auditlog"
"github.com/geo-platform/tenant-api/internal/shared/auth"
"github.com/geo-platform/tenant-api/internal/shared/contentstats"
"github.com/geo-platform/tenant-api/internal/shared/middleware"
"github.com/geo-platform/tenant-api/internal/shared/objectstorage"
"github.com/geo-platform/tenant-api/internal/shared/response"
)
type ArticleService struct {
pool *pgxpool.Pool
auditLogs *auditlog.AsyncWriter
pool *pgxpool.Pool
auditLogs *auditlog.AsyncWriter
objectStorage objectstorage.Client
}
func NewArticleService(pool *pgxpool.Pool, auditLogs *auditlog.AsyncWriter) *ArticleService {
return &ArticleService{pool: pool, auditLogs: auditLogs}
func NewArticleService(pool *pgxpool.Pool, auditLogs *auditlog.AsyncWriter, objectStorage objectstorage.Client) *ArticleService {
return &ArticleService{pool: pool, auditLogs: auditLogs, objectStorage: objectStorage}
}
const maxArticleImageSizeBytes = 10 * 1024 * 1024
var supportedArticleImageExtensions = map[string]string{
"image/gif": "gif",
"image/jpeg": "jpg",
"image/png": "png",
"image/webp": "webp",
}
type ArticleListParams struct {
@@ -303,6 +320,13 @@ type UpdateArticleRequest struct {
Platforms []string `json:"platforms"`
}
type ArticleImageUploadResponse struct {
URL string `json:"url"`
ObjectKey string `json:"object_key"`
ContentType string `json:"content_type"`
SizeBytes int64 `json:"size_bytes"`
}
func (s *ArticleService) Update(ctx context.Context, id int64, req UpdateArticleRequest) (*ArticleDetailResponse, error) {
actor := auth.MustActor(ctx)
@@ -383,6 +407,54 @@ func (s *ArticleService) Update(ctx context.Context, id int64, req UpdateArticle
return s.Detail(ctx, id)
}
func (s *ArticleService) UploadImage(
ctx context.Context,
articleID int64,
fileName string,
content []byte,
) (*ArticleImageUploadResponse, error) {
actor := auth.MustActor(ctx)
if err := s.objectStorage.Validate(); err != nil {
return nil, response.ErrServiceUnavailable(50303, "object_storage_unavailable", "object storage is not configured")
}
if len(content) == 0 {
return nil, response.ErrBadRequest(40016, "invalid_image", "image file is required")
}
if len(content) > maxArticleImageSizeBytes {
return nil, response.ErrBadRequest(40017, "article_image_too_large", "image size cannot exceed 10 MB")
}
contentType := http.DetectContentType(content)
resolvedExt, ok := supportedArticleImageExtensions[contentType]
if !ok {
return nil, response.ErrBadRequest(40018, "article_image_type_not_supported", "only PNG, JPG, GIF, and WebP images are supported")
}
generateStatus, err := s.getEditableArticleStatus(ctx, articleID, actor.TenantID)
if err != nil {
return nil, err
}
if generateStatus != "completed" {
return nil, response.ErrConflict(40912, "article_not_editable", "only completed articles can be edited")
}
objectKey := buildArticleImageObjectKey(actor.TenantID, articleID, fileName, resolvedExt)
if err := s.objectStorage.PutBytes(ctx, objectKey, content, contentType); err != nil {
if errors.Is(err, objectstorage.ErrNotConfigured) {
return nil, response.ErrServiceUnavailable(50303, "object_storage_unavailable", "object storage is not configured")
}
return nil, response.ErrInternal(50013, "article_image_upload_failed", "failed to upload article image")
}
return &ArticleImageUploadResponse{
URL: "",
ObjectKey: objectKey,
ContentType: contentType,
SizeBytes: int64(len(content)),
}, nil
}
type VersionItem struct {
ID int64 `json:"id"`
VersionNo int `json:"version_no"`
@@ -466,6 +538,49 @@ func countArticleWords(input string) int {
return contentstats.CountWords(input)
}
func (s *ArticleService) getEditableArticleStatus(ctx context.Context, articleID int64, tenantID int64) (string, error) {
var generateStatus string
if err := s.pool.QueryRow(ctx, `
SELECT generate_status
FROM articles
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL
`, articleID, tenantID).Scan(&generateStatus); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return "", response.ErrNotFound(40411, "article_not_found", "article not found")
}
return "", response.ErrInternal(50013, "article_lookup_failed", "failed to query article state")
}
return generateStatus, nil
}
func buildArticleImageObjectKey(tenantID int64, articleID int64, fileName string, fallbackExt string) string {
now := time.Now().UTC()
extension := normalizeArticleImageExtension(fileName, fallbackExt)
return fmt.Sprintf(
"tenants/%d/articles/%d/images/%04d/%02d/%02d/%s.%s",
tenantID,
articleID,
now.Year(),
int(now.Month()),
now.Day(),
uuid.NewString(),
extension,
)
}
func normalizeArticleImageExtension(fileName string, fallbackExt string) string {
extension := strings.TrimPrefix(strings.ToLower(filepath.Ext(strings.TrimSpace(fileName))), ".")
if extension != "" {
switch extension {
case "jpg", "jpeg":
return "jpg"
case "png", "gif", "webp":
return extension
}
}
return fallbackExt
}
func resolveWordCount(markdown *string, stored int) int {
if markdown != nil {
if counted := countArticleWords(*markdown); counted > 0 {
@@ -2,6 +2,7 @@ package transport
import (
"encoding/json"
"io"
"net/http"
"strconv"
"time"
@@ -19,6 +20,7 @@ type ArticleHandler struct {
promptGenerateSvc *app.PromptRuleGenerationService
streams *stream.GenerationHub
streamEnabled bool
assets *AssetHandler
}
func NewArticleHandler(a *bootstrap.App) *ArticleHandler {
@@ -36,7 +38,7 @@ func NewArticleHandler(a *bootstrap.App) *ArticleHandler {
)
return &ArticleHandler{
svc: app.NewArticleService(a.DB, a.AuditLogs),
svc: app.NewArticleService(a.DB, a.AuditLogs, a.ObjectStorage),
promptGenerateSvc: app.NewPromptRuleGenerationService(
a.DB,
a.LLM,
@@ -47,6 +49,7 @@ func NewArticleHandler(a *bootstrap.App) *ArticleHandler {
),
streams: a.GenerationStreams,
streamEnabled: a.Config.Generation.StreamEnabled,
assets: NewAssetHandler(a),
}
}
@@ -171,6 +174,42 @@ func (h *ArticleHandler) Update(c *gin.Context) {
response.Success(c, data)
}
func (h *ArticleHandler) UploadImage(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
response.Error(c, response.ErrBadRequest(40001, "invalid_id", "article id must be a number"))
return
}
fileHeader, err := c.FormFile("file")
if err != nil {
response.Error(c, response.ErrBadRequest(40016, "invalid_image", "image file is required"))
return
}
file, err := fileHeader.Open()
if err != nil {
response.Error(c, response.ErrBadRequest(40016, "invalid_image", "image file cannot be opened"))
return
}
defer file.Close()
content, err := io.ReadAll(file)
if err != nil {
response.Error(c, response.ErrBadRequest(40016, "invalid_image", "image file cannot be read"))
return
}
data, err := h.svc.UploadImage(c.Request.Context(), id, fileHeader.Filename, content)
if err != nil {
response.Error(c, err)
return
}
data.URL = h.assets.BuildArticleImageURL(data.ObjectKey)
response.SuccessWithStatus(c, http.StatusCreated, data)
}
func (h *ArticleHandler) Stream(c *gin.Context) {
if !h.streamEnabled {
response.Error(c, response.ErrNotFound(40412, "generation_stream_disabled", "generation stream is disabled"))
@@ -0,0 +1,82 @@
package transport
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"fmt"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"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
}
content, err := h.app.ObjectStorage.GetBytes(c.Request.Context(), objectKey)
if err != nil || len(content) == 0 {
c.AbortWithStatus(http.StatusNotFound)
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 (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
}
@@ -12,6 +12,9 @@ func RegisterRoutes(app *bootstrap.App) {
authAPI.POST("/login", authHandler.Login)
authAPI.POST("/refresh", authHandler.Refresh)
publicAssets := NewAssetHandler(app)
app.Engine.GET("/api/public/assets/:token", publicAssets.Serve)
pluginHandler := NewMediaHandler(app)
callbacks := app.Engine.Group("/api/callback/plugin")
callbacks.POST("/bind", pluginHandler.PluginBindCallback)
@@ -48,6 +51,7 @@ func RegisterRoutes(app *bootstrap.App) {
artHandler := NewArticleHandler(app)
articles.GET("", artHandler.List)
articles.POST("/generate-from-rule", artHandler.GenerateFromRule)
articles.POST("/:id/images", artHandler.UploadImage)
articles.GET("/:id", artHandler.Detail)
articles.PUT("/:id", artHandler.Update)
articles.GET("/:id/stream", artHandler.Stream)