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:
@@ -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