// SPDX-License-Identifier: MPL-2.0 // Package media defines bounded media preparation and storage-neutral blob // interfaces. Applications retain authorization, references, lifecycle, and // presentation policy. package media import ( "bytes" "context" "crypto/sha256" "encoding/hex" "errors" "fmt" "image" _ "image/gif" "image/jpeg" "image/png" "io" "mime" "net/http" "path/filepath" "strconv" "strings" "time" "unicode/utf8" ) const ( KindImage = "image" KindAttachment = "attachment" ) var ( ErrInvalidMedia = errors.New("media: invalid media") ErrTooLarge = errors.New("media: upload exceeds its size limit") ErrNotFound = errors.New("media: object not found") ) type Limits struct { MaxBytes int64 MaxWidth int MaxHeight int MaxPixels int64 } func (limits Limits) withDefaults() Limits { if limits.MaxBytes == 0 { limits.MaxBytes = 10 << 20 } if limits.MaxWidth == 0 { limits.MaxWidth = 8192 } if limits.MaxHeight == 0 { limits.MaxHeight = 8192 } if limits.MaxPixels == 0 { limits.MaxPixels = 40_000_000 } return limits } func (limits Limits) validate() error { if limits.MaxBytes < 1024 || limits.MaxBytes > 100<<20 || limits.MaxWidth < 1 || limits.MaxWidth > 32768 || limits.MaxHeight < 1 || limits.MaxHeight > 32768 || limits.MaxPixels < 1 || limits.MaxPixels > 250_000_000 { return errors.New("media: invalid limits") } return nil } // Prepared is a sanitized, bounded object ready for durable storage. Raster // images are decoded and re-encoded so source metadata and unparsed trailing // bytes are not retained. PDFs are attachments and are never inline media. type Prepared struct { Digest [32]byte Data []byte MediaType string Kind string OriginalName string Width int Height int } func (prepared Prepared) Key() string { return hex.EncodeToString(prepared.Digest[:]) } type Object struct { Key string Size int64 MediaType string CreatedAt time.Time } type Store interface { Put(context.Context, Prepared) (Object, error) Open(context.Context, string) (io.ReadCloser, Object, error) Delete(context.Context, string) error } // Prepare reads at most the configured bound and accepts JPEG, PNG, GIF, or a // PDF attachment. Animated images are deliberately flattened to the decoded // first frame. The returned byte slice is owned by the caller. func Prepare(reader io.Reader, originalName string, limits Limits) (Prepared, error) { if reader == nil { return Prepared{}, ErrInvalidMedia } limits = limits.withDefaults() if err := limits.validate(); err != nil { return Prepared{}, err } name, err := boundedName(originalName) if err != nil { return Prepared{}, err } data, err := io.ReadAll(io.LimitReader(reader, limits.MaxBytes+1)) if err != nil { return Prepared{}, fmt.Errorf("media: read upload: %w", err) } if int64(len(data)) > limits.MaxBytes { return Prepared{}, ErrTooLarge } if len(data) == 0 { return Prepared{}, ErrInvalidMedia } detected := http.DetectContentType(data) if detected == "application/pdf" && validPDF(data) { result := Prepared{Data: append([]byte(nil), data...), MediaType: "application/pdf", Kind: KindAttachment, OriginalName: name} result.Digest = sha256.Sum256(result.Data) return result, nil } imageValue, format, err := image.Decode(bytes.NewReader(data)) if err != nil || format != "jpeg" && format != "png" && format != "gif" { return Prepared{}, ErrInvalidMedia } bounds := imageValue.Bounds() width, height := bounds.Dx(), bounds.Dy() if width < 1 || height < 1 || width > limits.MaxWidth || height > limits.MaxHeight || int64(width) > limits.MaxPixels/int64(height) { return Prepared{}, ErrTooLarge } var output bytes.Buffer mediaType := "image/png" if format == "jpeg" { mediaType = "image/jpeg" err = jpeg.Encode(&output, imageValue, &jpeg.Options{Quality: 90}) } else { err = png.Encode(&output, imageValue) } if err != nil { return Prepared{}, fmt.Errorf("media: sanitize image: %w", err) } if int64(output.Len()) > limits.MaxBytes { return Prepared{}, ErrTooLarge } result := Prepared{Data: output.Bytes(), MediaType: mediaType, Kind: KindImage, OriginalName: name, Width: width, Height: height} result.Digest = sha256.Sum256(result.Data) return result, nil } // validPDF performs a deliberately bounded structural check without trying to // render or interpret document content. It rejects header-only spoofing and // truncated uploads by requiring a supported header, terminal EOF marker, a // numeric startxref offset, and either a traditional xref table with trailer // or an xref-stream object at that offset. func validPDF(data []byte) bool { if len(data) < 32 || !bytes.HasPrefix(data, []byte("%PDF-")) { return false } headerEnd := bytes.IndexAny(data, "\r\n") if headerEnd < 8 || headerEnd > 32 { return false } header := string(bytes.TrimSpace(data[:headerEnd])) if header != "%PDF-1.0" && header != "%PDF-1.1" && header != "%PDF-1.2" && header != "%PDF-1.3" && header != "%PDF-1.4" && header != "%PDF-1.5" && header != "%PDF-1.6" && header != "%PDF-1.7" && header != "%PDF-2.0" { return false } trimmed := bytes.TrimRight(data, "\x00\t\n\f\r ") if !bytes.HasSuffix(trimmed, []byte("%%EOF")) { return false } eof := len(trimmed) - len("%%EOF") start := bytes.LastIndex(trimmed[:eof], []byte("startxref")) if start < headerEnd { return false } cursor := start + len("startxref") for cursor < eof && (trimmed[cursor] == ' ' || trimmed[cursor] == '\t' || trimmed[cursor] == '\r' || trimmed[cursor] == '\n' || trimmed[cursor] == '\f') { cursor++ } digits := cursor for cursor < eof && trimmed[cursor] >= '0' && trimmed[cursor] <= '9' && cursor-digits < 20 { cursor++ } if cursor == digits { return false } if len(bytes.TrimSpace(trimmed[cursor:eof])) != 0 { return false } offset, err := strconv.ParseInt(string(trimmed[digits:cursor]), 10, 64) if err != nil || offset < int64(headerEnd+1) || offset >= int64(start) { return false } target := trimmed[int(offset):start] if bytes.HasPrefix(target, []byte("xref")) { return bytes.Contains(target, []byte("trailer")) } lineEnd := bytes.IndexByte(target, '\n') if lineEnd < 5 || lineEnd > 80 || !bytes.Contains(target[:lineEnd], []byte(" obj")) { return false } return bytes.Contains(target, []byte("/Type /XRef")) || bytes.Contains(target, []byte("/Type/XRef")) } func Extension(mediaType string) string { switch mediaType { case "image/jpeg": return ".jpg" case "image/png": return ".png" case "application/pdf": return ".pdf" default: values, _ := mime.ExtensionsByType(mediaType) if len(values) > 0 { return values[0] } return "" } } func ValidKey(value string) bool { if len(value) != sha256.Size*2 { return false } decoded, err := hex.DecodeString(value) return err == nil && len(decoded) == sha256.Size && value == strings.ToLower(value) } func boundedName(value string) (string, error) { value = strings.TrimSpace(filepath.Base(value)) if value == "." || value == "" || !utf8.ValidString(value) || len(value) > 240 || strings.ContainsAny(value, "\x00\r\n") { return "", ErrInvalidMedia } return value, nil }