memos/server/router/api/v1/attachment_media_metadata.go

204 lines
7.2 KiB
Go

package v1
import (
"math"
"regexp"
"strconv"
"strings"
"time"
"unicode"
"github.com/pkg/errors"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/proto"
v1pb "github.com/usememos/memos/proto/gen/api/v1"
storepb "github.com/usememos/memos/proto/gen/store"
)
const maxMediaMetadataStringBytes = 256
var (
mediaLocalDateTimePattern = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?$`)
mediaUTCOffsetPattern = regexp.MustCompile(`^[+-](\d{2}):(\d{2})$`)
)
func validateClientMediaMetadata(metadata *v1pb.MediaMetadata, mimeType string) (*storepb.MediaMetadata, error) {
if metadata == nil {
return nil, nil
}
normalizedMIMEType := strings.ToLower(mimeType)
isImage := strings.HasPrefix(normalizedMIMEType, "image/")
isVideo := strings.HasPrefix(normalizedMIMEType, "video/")
if !isImage && !isVideo {
return nil, status.Errorf(codes.InvalidArgument, "media_metadata is only supported for image and video attachments")
}
if (metadata.Width == nil) != (metadata.Height == nil) {
return nil, status.Errorf(codes.InvalidArgument, "media_metadata width and height must be provided together")
}
if metadata.Width != nil && (*metadata.Width <= 0 || *metadata.Height <= 0) {
return nil, status.Errorf(codes.InvalidArgument, "media_metadata width and height must be positive")
}
switch details := metadata.Details.(type) {
case nil:
if metadata.Width == nil {
return nil, status.Errorf(codes.InvalidArgument, "media_metadata must contain at least one value")
}
case *v1pb.MediaMetadata_Photo:
if !isImage {
return nil, status.Errorf(codes.InvalidArgument, "photo metadata requires an image MIME type")
}
if proto.Size(details.Photo) == 0 {
return nil, status.Errorf(codes.InvalidArgument, "photo metadata must contain at least one value")
}
if err := validatePhotoMetadata(details.Photo); err != nil {
return nil, err
}
case *v1pb.MediaMetadata_Video:
if !isVideo {
return nil, status.Errorf(codes.InvalidArgument, "video metadata requires a video MIME type")
}
if details.Video == nil || details.Video.DurationSeconds == nil {
return nil, status.Errorf(codes.InvalidArgument, "video metadata must contain at least one value")
}
if !isFinite(*details.Video.DurationSeconds) || *details.Video.DurationSeconds < 0 {
return nil, status.Errorf(codes.InvalidArgument, "video duration_seconds must be a finite non-negative number")
}
default:
return nil, status.Errorf(codes.InvalidArgument, "unsupported media_metadata details")
}
storeMetadata := &storepb.MediaMetadata{}
if err := transcodeProto(metadata, storeMetadata); err != nil {
return nil, status.Errorf(codes.Internal, "failed to convert media_metadata: %v", err)
}
return storeMetadata, nil
}
func validatePhotoMetadata(photo *v1pb.PhotoMetadata) error {
if photo.SourceExifOrientation != nil && (*photo.SourceExifOrientation < 1 || *photo.SourceExifOrientation > 8) {
return status.Errorf(codes.InvalidArgument, "photo source_exif_orientation must be between 1 and 8")
}
for _, field := range []struct {
name string
value string
}{
{"camera_make", photo.CameraMake},
{"camera_model", photo.CameraModel},
{"lens_model", photo.LensModel},
} {
if field.value == "" {
continue
}
if len(field.value) > maxMediaMetadataStringBytes {
return status.Errorf(codes.InvalidArgument, "photo %s exceeds %d bytes", field.name, maxMediaMetadataStringBytes)
}
if strings.TrimSpace(field.value) == "" {
return status.Errorf(codes.InvalidArgument, "photo %s must not be blank", field.name)
}
if strings.ContainsFunc(field.value, unicode.IsControl) {
return status.Errorf(codes.InvalidArgument, "photo %s must not contain control characters", field.name)
}
}
for _, field := range []struct {
name string
value *float64
}{
{"f_number", photo.FNumber},
{"exposure_time_seconds", photo.ExposureTimeSeconds},
{"focal_length_mm", photo.FocalLengthMm},
} {
if field.value != nil && (!isFinite(*field.value) || *field.value <= 0) {
return status.Errorf(codes.InvalidArgument, "photo %s must be a finite positive number", field.name)
}
}
if photo.Iso != nil && *photo.Iso <= 0 {
return status.Errorf(codes.InvalidArgument, "photo iso must be positive")
}
if err := validateMediaCaptureTime(photo.CaptureTime); err != nil {
return err
}
return validateMediaLocation(photo.Location)
}
func validateMediaCaptureTime(captureTime *v1pb.MediaCaptureTime) error {
if captureTime == nil {
return nil
}
if !mediaLocalDateTimePattern.MatchString(captureTime.LocalDateTime) {
return status.Errorf(codes.InvalidArgument, "capture_time local_date_time has an invalid format")
}
parsed, err := time.Parse("2006-01-02T15:04:05", captureTime.LocalDateTime)
if err != nil || parsed.Year() == 0 {
return status.Errorf(codes.InvalidArgument, "capture_time local_date_time is invalid")
}
if captureTime.UtcOffset == nil {
return nil
}
if *captureTime.UtcOffset == "Z" {
return nil
}
match := mediaUTCOffsetPattern.FindStringSubmatch(*captureTime.UtcOffset)
if match == nil {
return status.Errorf(codes.InvalidArgument, "capture_time utc_offset has an invalid format")
}
hours, _ := strconv.Atoi(match[1])
minutes, _ := strconv.Atoi(match[2])
if hours > 14 || minutes > 59 || (hours == 14 && minutes != 0) {
return status.Errorf(codes.InvalidArgument, "capture_time utc_offset is outside the supported range")
}
return nil
}
func validateMediaLocation(location *v1pb.MediaLocation) error {
if location == nil {
return nil
}
if location.Latitude == nil || location.Longitude == nil {
return status.Errorf(codes.InvalidArgument, "location latitude and longitude must be provided together")
}
if !isFinite(*location.Latitude) || *location.Latitude < -90 || *location.Latitude > 90 {
return status.Errorf(codes.InvalidArgument, "location latitude must be between -90 and 90")
}
if !isFinite(*location.Longitude) || *location.Longitude < -180 || *location.Longitude > 180 {
return status.Errorf(codes.InvalidArgument, "location longitude must be between -180 and 180")
}
if location.AltitudeMeters != nil && !isFinite(*location.AltitudeMeters) {
return status.Errorf(codes.InvalidArgument, "location altitude_meters must be finite")
}
return nil
}
func isFinite(value float64) bool {
return !math.IsNaN(value) && !math.IsInf(value, 0)
}
// transcodeProto copies src into dst through the wire format. The API and
// store MediaMetadata message trees are declared field-for-field identical, so
// the round-trip is the entire conversion; unknown fields (e.g. from a newer
// client) are dropped rather than stored.
func transcodeProto(src, dst proto.Message) error {
data, err := proto.Marshal(src)
if err != nil {
return errors.Wrap(err, "failed to marshal source protobuf")
}
if err := (proto.UnmarshalOptions{DiscardUnknown: true}).Unmarshal(data, dst); err != nil {
return errors.Wrap(err, "failed to unmarshal destination protobuf")
}
return nil
}
func convertMediaMetadataFromStore(metadata *storepb.MediaMetadata) *v1pb.MediaMetadata {
if metadata == nil {
return nil
}
apiMetadata := &v1pb.MediaMetadata{}
if err := transcodeProto(metadata, apiMetadata); err != nil {
return nil
}
return apiMetadata
}