go-places/api/internal/service/image.go

358 lines
12 KiB
Go
Raw Normal View History

2026-10-07 15:14:50 +02:00
package service
import (
"bytes"
"context"
"fmt"
"io"
"path"
"strings"
"time"
"github.com/google/uuid"
"go-places-api/internal/analysis"
"go-places-api/internal/blobstore"
"go-places-api/internal/domain"
"go-places-api/internal/events"
)
// ImageUpload is one picture submitted by a client.
type ImageUpload struct {
// Filename is the client-supplied name, used only to pick a storage
// extension. It never becomes part of the storage path.
Filename string
// ContentType is the declared MIME type.
ContentType string
// Caption is the description the user attached to this picture.
Caption string
// Body streams the encoded picture.
Body io.Reader
// Coordinate overrides the location. When nil the EXIF GPS tag is used,
// and failing that the place's own coordinate applies.
Coordinate *domain.Coordinate
}
// UploadResult reports what happened to one picture when a batch is submitted.
// Pictures are processed independently so one bad file does not discard the
// good ones.
type UploadResult struct {
Image domain.Image
// AnalysisNote explains a degraded analysis, if any.
AnalysisNote string
// Err is the reason this picture was rejected.
Err error
}
// AddImage stores one picture against a place, analyses it to produce badges,
// and links it to other pictures taken at the same place.
//
// The picture is analysed before its bytes are committed: if the payload is not
// a decodable image there is no reason to keep it. Analysis failures are
// recorded on the record rather than discarding an otherwise valid upload,
// because the picture is the valuable part and the badges can be recomputed
// with Reanalyze.
func (s *Service) AddImage(ctx context.Context, author domain.User, placeID string, up ImageUpload) (domain.Image, error) {
img, _, err := s.addImage(ctx, author, placeID, up)
return img, err
}
// UploadPictures attaches several pictures to a place in one call. Each picture
// is independent, so failures are reported per picture instead of failing the
// whole batch.
func (s *Service) UploadPictures(ctx context.Context, author domain.User, placeID string, uploads []ImageUpload) ([]UploadResult, error) {
if strings.TrimSpace(placeID) == "" {
return nil, fmt.Errorf("%w: place id is required", ErrInvalidInput)
}
if len(uploads) == 0 {
return nil, fmt.Errorf("%w: no pictures were supplied", ErrInvalidInput)
}
if author.ID == "" {
return nil, fmt.Errorf("%w: an authenticated author is required", ErrInvalidInput)
}
results := make([]UploadResult, 0, len(uploads))
for _, up := range uploads {
img, note, err := s.addImage(ctx, author, placeID, up)
results = append(results, UploadResult{Image: img, AnalysisNote: note, Err: err})
if err != nil {
s.logger.Warn("rejected upload", "placeId", placeID, "filename", up.Filename, "error", err)
}
}
return results, nil
}
// addImage is the shared implementation behind AddImage and UploadPictures.
func (s *Service) addImage(ctx context.Context, author domain.User, placeID string, up ImageUpload) (domain.Image, string, error) {
if strings.TrimSpace(placeID) == "" {
return domain.Image{}, "", fmt.Errorf("%w: place id is required", ErrInvalidInput)
}
if author.ID == "" {
return domain.Image{}, "", fmt.Errorf("%w: an authenticated author is required", ErrInvalidInput)
}
if up.Body == nil {
return domain.Image{}, "", fmt.Errorf("%w: the picture is empty", ErrInvalidInput)
}
if !s.cfg.AllowsContentType(up.ContentType) {
return domain.Image{}, "", fmt.Errorf("%w: %q is not an accepted image type",
ErrUnsupportedType, up.ContentType)
}
// Buffer one byte past the limit so an oversized payload is detectable
// without trusting the client's Content-Length.
var buf bytes.Buffer
read, err := io.Copy(&buf, io.LimitReader(up.Body, s.cfg.MaxUploadBytes+1))
if err != nil {
return domain.Image{}, "", fmt.Errorf("%w: the picture could not be read: %v", ErrInvalidInput, err)
}
if read == 0 {
return domain.Image{}, "", fmt.Errorf("%w: the picture is empty", ErrInvalidInput)
}
if read > s.cfg.MaxUploadBytes {
return domain.Image{}, "", fmt.Errorf("%w: the picture exceeds the %d byte limit",
ErrTooLarge, s.cfg.MaxUploadBytes)
}
data := buf.Bytes()
// Analysing first means an undecodable payload never reaches the store.
result, err := s.analyze.Analyze(ctx, analysis.Input{
ContentType: up.ContentType,
Bytes: data,
})
if err != nil {
return domain.Image{}, "", fmt.Errorf("%w: %v", ErrInvalidInput, err)
}
place, err := s.store.GetPlace(ctx, placeID)
if err != nil {
return domain.Image{}, "", mapStoreError(err, "place")
}
if err := s.store.UpsertUser(ctx, author); err != nil {
return domain.Image{}, "", mapStoreError(err, "user")
}
ext := extensionFor(up.Filename, up.ContentType)
key := path.Join(author.ID, time.Now().UTC().Format("2006/01"), uuid.NewString()+ext)
written, err := s.blobs.Put(ctx, key, bytes.NewReader(data), s.cfg.MaxUploadBytes)
if err != nil {
if isTooLarge(err) {
return domain.Image{}, "", fmt.Errorf("%w: %v", ErrTooLarge, err)
}
return domain.Image{}, "", fmt.Errorf("service: store picture: %w", err)
}
// Precedence for a picture's location: an explicit override, then the EXIF
// GPS tag, then the place's own coordinate.
coordinate := effectiveCoordinate(domain.Image{Coordinate: up.Coordinate}, place.Coordinate)
if up.Coordinate == nil && result.Metadata.Coordinate != nil {
coordinate = result.Metadata.Coordinate
}
img := domain.Image{
PlaceID: place.ID,
StorageKey: key,
Caption: strings.TrimSpace(up.Caption),
ContentType: up.ContentType,
ByteSize: written,
Width: result.Width,
Height: result.Height,
Coordinate: coordinate,
TakenAt: result.Metadata.TakenAt,
CameraMake: result.Metadata.Make,
CameraModel: result.Metadata.Model,
Badges: result.Badges,
AnalysisStatus: domain.AnalysisCompleted,
AnalysisError: result.Note,
}
saved, err := s.store.CreateImage(ctx, img)
if err != nil {
// Never leave an orphaned blob behind a failed insert.
if delErr := s.blobs.Delete(ctx, key); delErr != nil {
s.logger.Warn("failed to roll back image blob", "key", key, "error", delErr)
}
return domain.Image{}, "", mapStoreError(err, "image")
}
saved.URL = s.ImageURL(saved.StorageKey)
s.publish(ctx, author, events.TopicImageAnalyzed, map[string]any{
"imageId": saved.ID,
"placeId": place.ID,
"badges": badgeLabels(saved.Badges),
"degraded": result.Note != "",
})
// Linking is separate from accepting the upload, so a failure here is logged
// rather than reported as an upload error.
if err := s.linkAfterUpload(ctx, saved); err != nil {
s.logger.Warn("failed to link new picture", "imageId", saved.ID, "error", err)
}
return saved, result.Note, nil
}
// Reanalyze runs the analysis pipeline again over a stored picture. This is how
// badges are recovered after a failure or refreshed after the analyzer changes.
func (s *Service) Reanalyze(ctx context.Context, imageID string) (domain.Image, error) {
img, err := s.store.GetImage(ctx, imageID)
if err != nil {
return domain.Image{}, mapStoreError(err, "image")
}
if img.StorageKey == "" {
return domain.Image{}, fmt.Errorf("%w: the picture has no stored bytes", ErrInvalidInput)
}
rc, err := s.blobs.Get(ctx, img.StorageKey)
if err != nil {
return domain.Image{}, fmt.Errorf("service: read stored picture: %w", err)
}
defer rc.Close()
// Bound the read by the configured limit even though the blob was written
// under it, so a corrupted store cannot exhaust memory.
var buf bytes.Buffer
if _, err := io.Copy(&buf, io.LimitReader(rc, s.cfg.MaxUploadBytes)); err != nil {
return domain.Image{}, fmt.Errorf("service: buffer stored picture: %w", err)
}
result, err := s.analyze.Analyze(ctx, analysis.Input{
ContentType: img.ContentType,
Bytes: buf.Bytes(),
})
if err != nil {
if setErr := s.store.SetAnalysisState(ctx, img.ID, domain.AnalysisFailed, err.Error()); setErr != nil {
s.logger.Warn("failed to record analysis failure", "imageId", img.ID, "error", setErr)
}
return domain.Image{}, fmt.Errorf("%w: %v", ErrInvalidInput, err)
}
if err := s.store.ReplaceBadges(ctx, img.ID, result.Badges); err != nil {
return domain.Image{}, mapStoreError(err, "badge")
}
status := domain.AnalysisCompleted
if result.Note != "" {
// Understood, but only partially, which is worth recording so the UI
// can say the badge set may be incomplete.
status = domain.AnalysisFailed
}
if err := s.store.SetAnalysisState(ctx, img.ID, status, result.Note); err != nil {
return domain.Image{}, mapStoreError(err, "image")
}
updated, err := s.store.GetImage(ctx, img.ID)
if err != nil {
return domain.Image{}, mapStoreError(err, "image")
}
updated.URL = s.ImageURL(updated.StorageKey)
return updated, nil
}
// OpenImage returns a reader over a picture's stored bytes for the media route.
func (s *Service) OpenImage(ctx context.Context, imageID string) (domain.Image, io.ReadCloser, error) {
img, err := s.store.GetImage(ctx, imageID)
if err != nil {
return domain.Image{}, nil, mapStoreError(err, "image")
}
rc, err := s.blobs.Get(ctx, img.StorageKey)
if err != nil {
return domain.Image{}, nil, fmt.Errorf("service: open picture: %w", err)
}
return img, rc, nil
}
// ImageByID loads one picture, with its public URL filled in.
func (s *Service) ImageByID(ctx context.Context, id string) (domain.Image, error) {
img, err := s.store.GetImage(ctx, id)
if err != nil {
return domain.Image{}, mapStoreError(err, "image")
}
img.URL = s.ImageURL(img.StorageKey)
return img, nil
}
// ImageByStorageKey looks a picture up by its storage key, which is what the
// media route uses to recover the declared content type and size.
func (s *Service) ImageByStorageKey(ctx context.Context, key string) (domain.Image, error) {
img, err := s.store.GetImageByStorageKey(ctx, key)
if err != nil {
return domain.Image{}, mapStoreError(err, "image")
}
img.URL = s.ImageURL(img.StorageKey)
return img, nil
}
// ContentTypeByStorageKey returns the content type a picture was stored as, or
// an empty string when the key is unknown.
func (s *Service) ContentTypeByStorageKey(ctx context.Context, key string) (string, error) {
img, err := s.store.GetImageByStorageKey(ctx, key)
if err != nil {
return "", mapStoreError(err, "image")
}
return img.ContentType, nil
}
// ImageSize returns the stored byte size for a storage key, or zero when the
// key is unknown.
func (s *Service) ImageSize(ctx context.Context, key string) (int64, error) {
img, err := s.store.GetImageByStorageKey(ctx, key)
if err != nil {
return 0, mapStoreError(err, "image")
}
return img.ByteSize, nil
}
// extensionFor picks a storage extension from the filename, falling back to one
// implied by the content type.
func extensionFor(filename, contentType string) string {
if ext := strings.ToLower(path.Ext(filename)); validExtension(ext) {
return ext
}
switch strings.ToLower(strings.TrimSpace(strings.Split(contentType, ";")[0])) {
case "image/jpeg":
return ".jpg"
case "image/png":
return ".png"
case "image/webp":
return ".webp"
case "image/gif":
return ".gif"
default:
return ".img"
}
}
// validExtension keeps a client-supplied extension from becoming a path
// fragment or an unexpected suffix.
func validExtension(ext string) bool {
switch ext {
case ".jpg", ".jpeg", ".png", ".webp", ".gif":
return true
default:
return false
}
}
// badgeLabels reduces a badge set to a distinct list of labels for the event
// payload. The local and remote analyzers can each contribute a label for the
// same subject, so duplicates are collapsed rather than published twice.
func badgeLabels(badges []domain.Badge) []string {
out := make([]string, 0, len(badges))
seen := make(map[string]struct{}, len(badges))
for _, b := range badges {
label := strings.TrimSpace(b.Label)
if label == "" {
continue
}
if _, dup := seen[label]; dup {
continue
}
seen[label] = struct{}{}
out = append(out, label)
}
return out
}
func isTooLarge(err error) bool {
return err != nil && strings.Contains(err.Error(), blobstore.ErrTooLarge.Error())
}