initial commit
This commit is contained in:
commit
1253955070
75 changed files with 13972 additions and 0 deletions
357
api/internal/service/image.go
Normal file
357
api/internal/service/image.go
Normal file
|
|
@ -0,0 +1,357 @@
|
|||
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())
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue