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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user