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()) }