358 lines
12 KiB
Go
358 lines
12 KiB
Go
|
|
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())
|
||
|
|
}
|