Files
usememos--memos/server/router/api/v1/attachment_service_image.go
wehub-resource-sync 4cddfcf2f3
Backend Tests / Tests (other) (push) Failing after 1s
Backend Tests / Static Checks (push) Failing after 2s
Backend Tests / Tests (internal) (push) Failing after 2s
Backend Tests / Tests (server) (push) Failing after 1s
Backend Tests / Tests (store) (push) Failing after 1s
Build Canary Image / build-frontend (push) Failing after 1s
Frontend Tests / Lint (push) Failing after 1s
Build Canary Image / build-push (linux/amd64) (push) Has been skipped
Build Canary Image / build-push (linux/arm64) (push) Has been skipped
Build Canary Image / merge (push) Has been skipped
Frontend Tests / Build (push) Failing after 1s
Release Please / release-please (push) Failing after 0s
Proto Linter / Lint Protos (push) Failing after 2s
chore: import upstream snapshot with attribution
2026-07-13 12:02:24 +08:00

139 lines
4.6 KiB
Go

package v1
import (
"bytes"
"context"
"image"
"github.com/disintegration/imaging"
"github.com/pkg/errors"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"github.com/usememos/memos/internal/motionphoto"
v1pb "github.com/usememos/memos/proto/gen/api/v1"
storepb "github.com/usememos/memos/proto/gen/store"
)
func validateClientMotionMedia(motion *v1pb.MotionMedia, attachmentUID string) (*storepb.MotionMedia, error) {
if motion == nil {
return nil, nil
}
if motion.Family != v1pb.MotionMediaFamily_APPLE_LIVE_PHOTO {
return nil, status.Errorf(codes.InvalidArgument, "only Apple Live Photo motion metadata can be provided by clients")
}
if motion.Role != v1pb.MotionMediaRole_STILL && motion.Role != v1pb.MotionMediaRole_VIDEO {
return nil, status.Errorf(codes.InvalidArgument, "invalid Apple Live Photo motion role")
}
storeMotion := convertMotionMediaToStore(motion)
if storeMotion.GroupId == "" {
return nil, status.Errorf(codes.InvalidArgument, "motion media group_id is required")
}
if storeMotion.Family == storepb.MotionMediaFamily_ANDROID_MOTION_PHOTO && storeMotion.GroupId == "" {
storeMotion.GroupId = attachmentUID
}
return storeMotion, nil
}
func detectAndroidMotionMedia(blob []byte, mimeType, attachmentUID string) *storepb.MotionMedia {
if mimeType != "image/jpeg" && mimeType != "image/jpg" {
return nil
}
detection := motionphoto.DetectJPEG(blob)
if detection == nil {
return nil
}
return &storepb.MotionMedia{
Family: storepb.MotionMediaFamily_ANDROID_MOTION_PHOTO,
Role: storepb.MotionMediaRole_CONTAINER,
GroupId: attachmentUID,
PresentationTimestampUs: detection.PresentationTimestampUs,
HasEmbeddedVideo: true,
}
}
// shouldStripExif checks if the MIME type is an image format that may contain EXIF metadata.
// Returns true for formats like JPEG, TIFF, WebP, HEIC, and HEIF which commonly contain
// privacy-sensitive metadata such as GPS coordinates, camera settings, and device information.
func shouldStripExif(mimeType string) bool {
return exifCapableImageTypes[mimeType]
}
func (s *APIV1Service) acquireImageProcessingSlot(ctx context.Context) (func(), error) {
if s.imageProcessingSemaphore == nil {
return func() {}, nil
}
if err := s.imageProcessingSemaphore.Acquire(ctx, 1); err != nil {
return nil, err
}
return func() {
s.imageProcessingSemaphore.Release(1)
}, nil
}
func validateImagePixelCount(imageData []byte) error {
config, _, err := image.DecodeConfig(bytes.NewReader(imageData))
if err != nil {
// Some formats supported by imaging do not expose dimensions through
// the standard image registry. Let the full decoder handle those.
return nil //nolint:nilerr
}
if config.Width <= 0 || config.Height <= 0 {
return errors.New("invalid image dimensions")
}
if config.Width > maxImagePixels/config.Height {
return errors.Errorf("image dimensions exceed maximum of %d pixels", maxImagePixels)
}
return nil
}
// stripImageExif removes EXIF metadata from image files by decoding and re-encoding them.
// This prevents exposure of sensitive metadata such as GPS location, camera details, and timestamps.
//
// The function preserves the correct image orientation by applying EXIF orientation tags
// during decoding before stripping all metadata. Images are re-encoded with high quality
// to minimize visual degradation.
//
// Supported formats:
// - JPEG/JPG: Re-encoded as JPEG with quality 95
// - PNG: Re-encoded as PNG (lossless)
// - TIFF/WebP/HEIC/HEIF: Re-encoded as JPEG with quality 95
//
// Returns the cleaned image data without any EXIF metadata, or an error if processing fails.
func stripImageExif(imageData []byte, mimeType string) ([]byte, error) {
if err := validateImagePixelCount(imageData); err != nil {
return nil, err
}
// Decode image with automatic EXIF orientation correction.
// This ensures the image displays correctly after metadata removal.
img, err := imaging.Decode(bytes.NewReader(imageData), imaging.AutoOrientation(true))
if err != nil {
return nil, errors.Wrap(err, "failed to decode image")
}
// Re-encode the image without EXIF metadata.
var buf bytes.Buffer
var encodeErr error
if mimeType == "image/png" {
// Preserve PNG format for lossless encoding
encodeErr = imaging.Encode(&buf, img, imaging.PNG)
} else {
// For JPEG, TIFF, WebP, HEIC, HEIF - re-encode as JPEG.
// This ensures EXIF is stripped and provides good compression.
encodeErr = imaging.Encode(&buf, img, imaging.JPEG, imaging.JPEGQuality(defaultJPEGQuality))
}
if encodeErr != nil {
return nil, errors.Wrap(encodeErr, "failed to encode image")
}
return buf.Bytes(), nil
}