247 lines
7.1 KiB
Go
247 lines
7.1 KiB
Go
// 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
|
|
}
|