package threads_account import ( "io" "path/filepath" "strings" app "haixun-backend/internal/library/errors" "haixun-backend/internal/library/errors/code" "haixun-backend/internal/library/storage" "haixun-backend/internal/svc" ) const maxReplyImageBytes = 8 << 20 type ReplyImageFile struct { Filename string ContentType string Body io.Reader } func stageReplyImageURL(svcCtx *svc.ServiceContext, file *ReplyImageFile) (imageURL string, cleanup func(), err error) { cleanup = func() {} if file == nil || file.Body == nil { return "", cleanup, nil } ext := strings.ToLower(filepath.Ext(strings.TrimSpace(file.Filename))) if !allowedPublishAttachmentExt(ext) { return "", cleanup, app.For(code.ThreadsAccount).InputInvalidFormat("image must be jpg, png, webp, or gif") } contentType := strings.TrimSpace(file.ContentType) if contentType != "" && !strings.HasPrefix(strings.ToLower(contentType), "image/") { return "", cleanup, app.For(code.ThreadsAccount).InputInvalidFormat("file must be an image") } if contentType == "" { switch ext { case ".jpg", ".jpeg": contentType = "image/jpeg" case ".png": contentType = "image/png" case ".webp": contentType = "image/webp" case ".gif": contentType = "image/gif" default: contentType = "application/octet-stream" } } limited := io.LimitReader(file.Body, maxReplyImageBytes+1) data, readErr := io.ReadAll(limited) if readErr != nil { return "", cleanup, app.For(code.ThreadsAccount).SysInternal("read image failed").WithCause(readErr) } if len(data) == 0 { return "", cleanup, app.For(code.ThreadsAccount).InputMissingRequired("image file is required") } if len(data) > maxReplyImageBytes { return "", cleanup, app.For(code.ThreadsAccount).InputInvalidFormat("image must be smaller than 8MB") } key, regErr := storage.DefaultEphemeralAssetStore().Register(data, contentType, 0) if regErr != nil { return "", cleanup, app.For(code.ThreadsAccount).SysInternal("stage image failed").WithCause(regErr) } base := strings.TrimSpace(svcCtx.Config.Storage.S3.PublicBaseURL) imageURL, urlErr := storage.BuildEphemeralPublicURL(base, key) if urlErr != nil { storage.DefaultEphemeralAssetStore().Delete(key) return "", cleanup, app.For(code.ThreadsAccount).InputInvalidFormat(urlErr.Error()) } if err := storage.ValidateThreadsFetchableURL(imageURL); err != nil { storage.DefaultEphemeralAssetStore().Delete(key) return "", cleanup, app.For(code.ThreadsAccount).InputInvalidFormat(err.Error()) } cleanup = func() { storage.DefaultEphemeralAssetStore().Delete(key) } return imageURL, cleanup, nil } func replyImageURLs(svcCtx *svc.ServiceContext, file *ReplyImageFile) ([]string, func(), error) { imageURL, cleanup, err := stageReplyImageURL(svcCtx, file) if err != nil || imageURL == "" { return nil, cleanup, err } return []string{imageURL}, cleanup, nil }