This commit is contained in:
+190
@@ -0,0 +1,190 @@
|
||||
// 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"
|
||||
"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" && bytes.HasPrefix(data, []byte("%PDF-")) {
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package media
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/jpeg"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPrepareReencodesRasterAndStripsTrailingData(t *testing.T) {
|
||||
var source bytes.Buffer
|
||||
value := image.NewRGBA(image.Rect(0, 0, 3, 2))
|
||||
value.Set(1, 1, color.RGBA{R: 220, G: 20, B: 50, A: 255})
|
||||
if err := jpeg.Encode(&source, value, &jpeg.Options{Quality: 95}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
source.WriteString("secret trailing metadata")
|
||||
prepared, err := Prepare(bytes.NewReader(source.Bytes()), " portrait.jpg ", Limits{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if prepared.Kind != KindImage || prepared.MediaType != "image/jpeg" || prepared.Width != 3 || prepared.Height != 2 || prepared.OriginalName != "portrait.jpg" {
|
||||
t.Fatalf("prepared=%+v", prepared)
|
||||
}
|
||||
if bytes.Contains(prepared.Data, []byte("secret trailing metadata")) || prepared.Key() == strings.Repeat("0", 64) {
|
||||
t.Fatal("image source data was not sanitized")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreparePDFIsAttachment(t *testing.T) {
|
||||
prepared, err := Prepare(strings.NewReader("%PDF-1.7\nsmall fixture"), "guide.pdf", Limits{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if prepared.Kind != KindAttachment || prepared.MediaType != "application/pdf" {
|
||||
t.Fatalf("prepared=%+v", prepared)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareRejectsActiveAndOversizedInput(t *testing.T) {
|
||||
if _, err := Prepare(strings.NewReader("<svg><script/></svg>"), "bad.svg", Limits{}); !errors.Is(err, ErrInvalidMedia) {
|
||||
t.Fatalf("svg err=%v", err)
|
||||
}
|
||||
if _, err := Prepare(strings.NewReader(strings.Repeat("x", 1025)), "large.png", Limits{MaxBytes: 1024, MaxWidth: 10, MaxHeight: 10, MaxPixels: 100}); !errors.Is(err, ErrTooLarge) {
|
||||
t.Fatalf("large err=%v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user