| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331 |
- package handler
- import (
- "bytes"
- "context"
- "encoding/json"
- "errors"
- "io"
- "net/http"
- "net/http/httptest"
- "strings"
- "time"
- infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
- pkghttputil "github.com/Wei-Shaw/sub2api/internal/pkg/httputil"
- "github.com/Wei-Shaw/sub2api/internal/pkg/logger"
- middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
- "github.com/Wei-Shaw/sub2api/internal/service"
- "github.com/gin-gonic/gin"
- "go.uber.org/zap"
- )
- type AsyncImageHandler struct {
- tasks *service.ImageTaskService
- openAI *OpenAIGatewayHandler
- execute func(platform string, c *gin.Context)
- }
- func NewAsyncImageHandler(tasks *service.ImageTaskService, openAI *OpenAIGatewayHandler) *AsyncImageHandler {
- h := &AsyncImageHandler{tasks: tasks, openAI: openAI}
- h.execute = h.executeWithGateway
- return h
- }
- // enabled reports whether the async image task feature is available. Object
- // storage is the enablement gate: without it the endpoints are fully disabled
- // so that large base64 results never land in Redis.
- func (h *AsyncImageHandler) enabled() bool {
- return h != nil && h.tasks != nil && h.tasks.Enabled()
- }
- // pollable reports whether task lookups can be served. It is deliberately weaker
- // than enabled(): results already written to Redis stay readable after the
- // feature is switched off, so an in-flight task is never stranded.
- func (h *AsyncImageHandler) pollable() bool {
- return h != nil && h.tasks != nil && h.tasks.Pollable()
- }
- // Submit accepts the same payload as the synchronous Images endpoint and
- // returns before the upstream image generation begins.
- func (h *AsyncImageHandler) Submit(c *gin.Context) {
- if !h.enabled() {
- imageTaskJSONError(c, http.StatusNotFound, "not_found_error", "async image tasks are not enabled")
- return
- }
- apiKey, ok := middleware2.GetAPIKeyFromContext(c)
- if !ok || apiKey == nil || apiKey.UserID <= 0 || apiKey.ID <= 0 {
- imageTaskError(c, service.ErrImageTaskForbidden)
- return
- }
- platform := ""
- if apiKey.Group != nil {
- platform = apiKey.Group.Platform
- }
- if platform != service.PlatformOpenAI && platform != service.PlatformGrok {
- imageTaskJSONError(c, http.StatusNotFound, "not_found_error", "Images API is not supported for this platform")
- return
- }
- if !service.GroupAllowsImageGeneration(apiKey.Group) {
- imageTaskJSONError(c, http.StatusForbidden, "permission_error", service.ImageGenerationPermissionMessage())
- return
- }
- if h == nil || h.tasks == nil || h.execute == nil {
- imageTaskError(c, service.ErrImageTaskUnavailable)
- return
- }
- body, err := pkghttputil.ReadRequestBodyWithPrealloc(c.Request)
- if err != nil {
- if maxErr, ok := extractMaxBytesError(err); ok {
- imageTaskJSONError(c, http.StatusRequestEntityTooLarge, "invalid_request_error", buildBodyTooLargeMessage(maxErr.Limit))
- return
- }
- imageTaskJSONError(c, http.StatusBadRequest, "invalid_request_error", "Failed to read request body")
- return
- }
- if len(body) == 0 {
- imageTaskJSONError(c, http.StatusBadRequest, "invalid_request_error", "Request body is empty")
- return
- }
- if asyncImageRequestStreams(c.GetHeader("Content-Type"), body) {
- imageTaskJSONError(c, http.StatusBadRequest, "invalid_request_error", "streaming image requests cannot be submitted as asynchronous tasks")
- return
- }
- if err := h.validateRequest(c, platform, body); err != nil {
- imageTaskJSONError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
- return
- }
- if !h.checkSecurityAuditBeforeSubmit(c, apiKey, platform, body) {
- return
- }
- taskCtx, recorder, cancel := newAsyncImageContext(c, body, h.tasks.ExecutionTimeout())
- task, err := h.tasks.Create(c.Request.Context(), service.ImageTaskOwner{UserID: apiKey.UserID, APIKeyID: apiKey.ID})
- if err != nil {
- cancel()
- imageTaskError(c, err)
- return
- }
- pollURL := imageTaskPollURL(c.Request.URL.Path, task.ID)
- c.Header("Cache-Control", "no-store")
- c.Header("Location", pollURL)
- c.Header("Retry-After", "3")
- c.JSON(http.StatusAccepted, gin.H{
- "id": task.ID,
- "task_id": task.TaskID,
- "object": task.Object,
- "status": task.Status,
- "created_at": task.CreatedAt,
- "expires_at": task.ExpiresAt,
- "poll_url": pollURL,
- })
- go h.run(task.ID, platform, taskCtx, recorder, cancel)
- }
- func (h *AsyncImageHandler) checkSecurityAuditBeforeSubmit(c *gin.Context, apiKey *service.APIKey, platform string, body []byte) bool {
- if h == nil || h.openAI == nil {
- return true
- }
- subject, ok := middleware2.GetAuthSubjectFromContext(c)
- if !ok {
- imageTaskJSONError(c, http.StatusInternalServerError, "api_error", "User context not found")
- return false
- }
- model := ""
- moderationBody := body
- if platform == service.PlatformGrok {
- parsed := service.ParseGrokMediaRequest(c.GetHeader("Content-Type"), body)
- model, moderationBody = parsed.Model, parsed.ModerationBody()
- } else if h.openAI.gatewayService != nil {
- parsed, err := h.openAI.gatewayService.ParseOpenAIImagesRequest(c, body)
- if err != nil {
- imageTaskJSONError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
- return false
- }
- model, moderationBody = parsed.Model, parsed.ModerationBody()
- }
- if len(moderationBody) == 0 {
- c.Set(securityAuditCompletedContextKey, true)
- return true
- }
- reqLog := requestLogger(c, "handler.async_image.security_audit",
- zap.Int64("user_id", subject.UserID), zap.Int64("api_key_id", apiKey.ID), zap.String("model", model))
- decision := h.openAI.checkSecurityAudit(c, reqLog, apiKey, subject, service.ContentModerationProtocolOpenAIImages, model, moderationBody)
- if decision != nil && !decision.AllowNextStage {
- h.openAI.openAISecurityAuditError(c, decision)
- return false
- }
- return true
- }
- func (h *AsyncImageHandler) Get(c *gin.Context) {
- // Polling deliberately does not require the feature to be enabled, only that
- // the task store is reachable. Turning the switch off in the admin UI must not
- // strand tasks that were already accepted — their results are still in Redis
- // and their submitters are still polling.
- if !h.pollable() {
- imageTaskJSONError(c, http.StatusNotFound, "not_found_error", "async image tasks are not enabled")
- return
- }
- apiKey, ok := middleware2.GetAPIKeyFromContext(c)
- if !ok || apiKey == nil || apiKey.UserID <= 0 || apiKey.ID <= 0 {
- imageTaskError(c, service.ErrImageTaskForbidden)
- return
- }
- task, err := h.tasks.Get(c.Request.Context(), service.ImageTaskOwner{UserID: apiKey.UserID, APIKeyID: apiKey.ID}, c.Param("task_id"))
- if err != nil {
- imageTaskError(c, err)
- return
- }
- c.Header("Cache-Control", "no-store")
- if task.Status == service.ImageTaskStatusProcessing {
- c.Header("Retry-After", "3")
- }
- c.JSON(http.StatusOK, task)
- }
- func (h *AsyncImageHandler) validateRequest(c *gin.Context, platform string, body []byte) error {
- if h.openAI == nil || h.openAI.gatewayService == nil {
- return nil
- }
- if platform == service.PlatformGrok {
- parsed := service.ParseGrokMediaRequest(c.GetHeader("Content-Type"), body)
- if strings.TrimSpace(parsed.Model) == "" {
- return errors.New("model is required")
- }
- return nil
- }
- parsed, err := h.openAI.gatewayService.ParseOpenAIImagesRequest(c, body)
- if err != nil {
- return err
- }
- if parsed.Stream {
- return errors.New("streaming image requests cannot be submitted as asynchronous tasks")
- }
- return nil
- }
- func (h *AsyncImageHandler) executeWithGateway(platform string, c *gin.Context) {
- if h.openAI == nil {
- imageTaskJSONError(c, http.StatusServiceUnavailable, "api_error", "image gateway is unavailable")
- return
- }
- if platform == service.PlatformGrok {
- h.openAI.GrokImages(c)
- return
- }
- h.openAI.Images(c)
- }
- func (h *AsyncImageHandler) run(taskID, platform string, taskCtx *gin.Context, recorder *httptest.ResponseRecorder, cancel context.CancelFunc) {
- defer cancel()
- defer func() {
- if recovered := recover(); recovered != nil {
- logger.L().Error("image_task.execution_panicked", zap.String("task_id", taskID), zap.Any("panic", recovered))
- h.failTask(taskID, http.StatusInternalServerError, imageTaskErrorPayload("api_error", "image generation task panicked"))
- }
- }()
- h.execute(platform, taskCtx)
- body := bytes.TrimSpace(recorder.Body.Bytes())
- if err := taskCtx.Request.Context().Err(); err != nil && len(body) == 0 {
- h.failTask(taskID, http.StatusGatewayTimeout, imageTaskErrorPayload("timeout_error", "image generation task timed out"))
- return
- }
- statusCode := recorder.Code
- if statusCode == 0 {
- statusCode = http.StatusOK
- }
- if statusCode >= http.StatusOK && statusCode < http.StatusMultipleChoices {
- if len(body) == 0 || !json.Valid(body) {
- h.failTask(taskID, http.StatusBadGateway, imageTaskErrorPayload("api_error", "upstream returned an invalid image response"))
- return
- }
- if err := h.tasks.Complete(context.Background(), taskID, statusCode, json.RawMessage(body)); err != nil {
- logger.L().Error("image_task.complete_store_failed", zap.String("task_id", taskID), zap.Error(err))
- }
- return
- }
- h.failTask(taskID, statusCode, extractImageTaskError(body))
- }
- func (h *AsyncImageHandler) failTask(taskID string, statusCode int, taskErr json.RawMessage) {
- if err := h.tasks.Fail(context.Background(), taskID, statusCode, taskErr); err != nil {
- logger.L().Error("image_task.failure_store_failed", zap.String("task_id", taskID), zap.Error(err))
- }
- }
- func newAsyncImageContext(c *gin.Context, body []byte, timeoutDuration time.Duration) (*gin.Context, *httptest.ResponseRecorder, context.CancelFunc) {
- base := context.WithoutCancel(c.Request.Context())
- executionCtx, cancel := context.WithTimeout(base, timeoutDuration)
- request := c.Request.Clone(executionCtx)
- request.Body = io.NopCloser(bytes.NewReader(body))
- request.GetBody = func() (io.ReadCloser, error) {
- return io.NopCloser(bytes.NewReader(body)), nil
- }
- request.ContentLength = int64(len(body))
- request.URL.Path = strings.TrimSuffix(request.URL.Path, "/async")
- taskCtx := c.Copy()
- recorder := httptest.NewRecorder()
- recorderCtx, _ := gin.CreateTestContext(recorder)
- taskCtx.Writer = recorderCtx.Writer
- taskCtx.Request = request
- return taskCtx, recorder, cancel
- }
- func asyncImageRequestStreams(contentType string, body []byte) bool {
- if isMultipartImagesContentType(contentType) {
- return false
- }
- var envelope struct {
- Stream bool `json:"stream"`
- }
- return json.Unmarshal(body, &envelope) == nil && envelope.Stream
- }
- func imageTaskPollURL(submitPath, taskID string) string {
- if strings.HasPrefix(submitPath, "/v1/") {
- return "/v1/images/tasks/" + taskID
- }
- return "/images/tasks/" + taskID
- }
- func extractImageTaskError(body []byte) json.RawMessage {
- if json.Valid(body) {
- var envelope struct {
- Error json.RawMessage `json:"error"`
- }
- if json.Unmarshal(body, &envelope) == nil && len(envelope.Error) > 0 && json.Valid(envelope.Error) {
- return envelope.Error
- }
- return json.RawMessage(body)
- }
- return imageTaskErrorPayload("api_error", "image generation failed")
- }
- func imageTaskErrorPayload(errorType, message string) json.RawMessage {
- data, _ := json.Marshal(gin.H{"type": errorType, "message": message})
- return data
- }
- func imageTaskError(c *gin.Context, err error) {
- status := infraerrors.Code(err)
- code := infraerrors.Reason(err)
- message := infraerrors.Message(err)
- if status <= 0 {
- status = http.StatusInternalServerError
- }
- if strings.TrimSpace(code) == "" {
- code = "IMAGE_TASK_ERROR"
- }
- imageTaskJSONError(c, status, code, message)
- }
- func imageTaskJSONError(c *gin.Context, status int, code, message string) {
- c.Header("Cache-Control", "no-store")
- c.JSON(status, gin.H{"error": gin.H{"type": code, "code": code, "message": message}})
- }
|