fix installer
This commit is contained in:
parent
318fb51085
commit
6319da650c
31 changed files with 2391 additions and 87 deletions
143
internal/avatar/avatar.go
Normal file
143
internal/avatar/avatar.go
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
// Package avatar validates and addresses profile pictures stored in PostgreSQL.
|
||||
//
|
||||
// Images are kept as their uploaded bytes. Go Loose never re-encodes them; it
|
||||
// sniffs the format, bounds the decoded dimensions to reject decompression
|
||||
// bombs, and serves the bytes back with the sniffed content type behind an
|
||||
// unguessable key so that other applications can embed the picture with a plain
|
||||
// image tag.
|
||||
package avatar
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/gif"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
// MaxBytes bounds an uploaded picture. Base64 inflates it by 4/3, so a
|
||||
// request carrying an avatar must allow roughly MaxBytes*4/3 bytes.
|
||||
MaxBytes = 2 << 20
|
||||
// MaxDimension bounds decoded pixels so that a small upload cannot expand
|
||||
// into an unreasonable image when a client renders it.
|
||||
MaxDimension = 4096
|
||||
)
|
||||
|
||||
var (
|
||||
ErrEmpty = errors.New("the picture is empty")
|
||||
ErrTooLarge = fmt.Errorf("the picture must be %d KiB or smaller", MaxBytes/1024)
|
||||
ErrUnsupported = errors.New("the picture must be a JPEG, PNG, or GIF image")
|
||||
ErrDimensions = fmt.Errorf("the picture must not exceed %d by %d pixels", MaxDimension, MaxDimension)
|
||||
ErrEncoding = errors.New("the picture is not valid base64")
|
||||
)
|
||||
|
||||
// allowed maps the format names reported by image.DecodeConfig to content types.
|
||||
// Only the decoders registered above are accepted, so an unsupported upload
|
||||
// fails validation instead of being stored and served.
|
||||
var allowed = map[string]string{
|
||||
"jpeg": "image/jpeg",
|
||||
"png": "image/png",
|
||||
"gif": "image/gif",
|
||||
}
|
||||
|
||||
// Parse decodes a base64 payload, with or without a data URL prefix, and
|
||||
// returns the image bytes together with the sniffed content type.
|
||||
func Parse(value string) ([]byte, string, error) {
|
||||
payload, declared := trimDataURL(value)
|
||||
if payload == "" {
|
||||
return nil, "", ErrEmpty
|
||||
}
|
||||
if base64.StdEncoding.DecodedLen(len(payload)) > MaxBytes {
|
||||
return nil, "", ErrTooLarge
|
||||
}
|
||||
data, err := base64.StdEncoding.DecodeString(payload)
|
||||
if err != nil {
|
||||
return nil, "", ErrEncoding
|
||||
}
|
||||
contentType, err := Inspect(data)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if declared != "" && !strings.EqualFold(declared, contentType) {
|
||||
return nil, "", ErrUnsupported
|
||||
}
|
||||
return data, contentType, nil
|
||||
}
|
||||
|
||||
// Inspect validates raw image bytes and returns their content type.
|
||||
func Inspect(data []byte) (string, error) {
|
||||
if len(data) == 0 {
|
||||
return "", ErrEmpty
|
||||
}
|
||||
if len(data) > MaxBytes {
|
||||
return "", ErrTooLarge
|
||||
}
|
||||
decoded, format, err := image.DecodeConfig(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return "", ErrUnsupported
|
||||
}
|
||||
contentType, known := allowed[format]
|
||||
if !known {
|
||||
return "", ErrUnsupported
|
||||
}
|
||||
if decoded.Width > MaxDimension || decoded.Height > MaxDimension {
|
||||
return "", ErrDimensions
|
||||
}
|
||||
return contentType, nil
|
||||
}
|
||||
|
||||
// NewKey returns an unguessable locator for a stored picture. The key is a
|
||||
// capability-free random value: it authorizes reading a picture that is
|
||||
// intended to be public, not access to anything else about the user.
|
||||
func NewKey() (string, error) {
|
||||
value := make([]byte, 32)
|
||||
if _, err := rand.Read(value); err != nil {
|
||||
return "", fmt.Errorf("generate avatar key: %w", err)
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(value), nil
|
||||
}
|
||||
|
||||
// ValidKey reports whether a key has the shape produced by NewKey, which keeps
|
||||
// obviously malformed lookups out of the database.
|
||||
func ValidKey(key string) bool {
|
||||
if len(key) != 43 {
|
||||
return false
|
||||
}
|
||||
_, err := base64.RawURLEncoding.DecodeString(key)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// ETag returns a strong validator derived from the stored bytes so that clients
|
||||
// and proxies can cache the picture and revalidate cheaply.
|
||||
func ETag(data []byte) string {
|
||||
sum := sha256.Sum256(data)
|
||||
return `"` + hex.EncodeToString(sum[:16]) + `"`
|
||||
}
|
||||
|
||||
func trimDataURL(value string) (payload, mediaType string) {
|
||||
value = strings.TrimSpace(value)
|
||||
if !strings.HasPrefix(strings.ToLower(value), "data:") {
|
||||
return value, ""
|
||||
}
|
||||
comma := strings.IndexByte(value, ',')
|
||||
if comma < 0 {
|
||||
return "", ""
|
||||
}
|
||||
header := value[len("data:"):comma]
|
||||
parameters := strings.Split(header, ";")
|
||||
mediaType = strings.ToLower(strings.TrimSpace(parameters[0]))
|
||||
for _, parameter := range parameters[1:] {
|
||||
if strings.EqualFold(strings.TrimSpace(parameter), "base64") {
|
||||
return strings.TrimSpace(value[comma+1:]), mediaType
|
||||
}
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
172
internal/avatar/avatar_test.go
Normal file
172
internal/avatar/avatar_test.go
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
package avatar
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/gif"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func samplePNG(t *testing.T, width, height int) []byte {
|
||||
t.Helper()
|
||||
canvas := image.NewRGBA(image.Rect(0, 0, width, height))
|
||||
canvas.Set(0, 0, color.RGBA{R: 125, G: 207, B: 255, A: 255})
|
||||
buffer := &bytes.Buffer{}
|
||||
if err := png.Encode(buffer, canvas); err != nil {
|
||||
t.Fatalf("encode png: %v", err)
|
||||
}
|
||||
return buffer.Bytes()
|
||||
}
|
||||
|
||||
func encode(t *testing.T, format string, width, height int) []byte {
|
||||
t.Helper()
|
||||
canvas := image.NewRGBA(image.Rect(0, 0, width, height))
|
||||
buffer := &bytes.Buffer{}
|
||||
var err error
|
||||
switch format {
|
||||
case "jpeg":
|
||||
err = jpeg.Encode(buffer, canvas, nil)
|
||||
case "gif":
|
||||
err = gif.Encode(buffer, canvas, nil)
|
||||
default:
|
||||
err = png.Encode(buffer, canvas)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("encode %s: %v", format, err)
|
||||
}
|
||||
return buffer.Bytes()
|
||||
}
|
||||
|
||||
func TestParseAcceptsSupportedFormats(t *testing.T) {
|
||||
for _, format := range []string{"png", "jpeg", "gif"} {
|
||||
t.Run(format, func(t *testing.T) {
|
||||
raw := encode(t, format, 8, 8)
|
||||
data, contentType, err := Parse(base64.StdEncoding.EncodeToString(raw))
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() error = %v", err)
|
||||
}
|
||||
if !bytes.Equal(data, raw) {
|
||||
t.Fatal("Parse() did not return the uploaded bytes")
|
||||
}
|
||||
if contentType != "image/"+map[string]string{"png": "png", "jpeg": "jpeg", "gif": "gif"}[format] {
|
||||
t.Fatalf("content type = %q", contentType)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAcceptsDataURL(t *testing.T) {
|
||||
raw := samplePNG(t, 4, 4)
|
||||
dataURL := "data:image/png;base64," + base64.StdEncoding.EncodeToString(raw)
|
||||
data, contentType, err := Parse(dataURL)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() error = %v", err)
|
||||
}
|
||||
if len(data) != len(raw) || contentType != "image/png" {
|
||||
t.Fatalf("Parse() = %d bytes of %q", len(data), contentType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRejectsMismatchedDataURLMediaType(t *testing.T) {
|
||||
raw := samplePNG(t, 4, 4)
|
||||
dataURL := "data:image/gif;base64," + base64.StdEncoding.EncodeToString(raw)
|
||||
if _, _, err := Parse(dataURL); !errors.Is(err, ErrUnsupported) {
|
||||
t.Fatalf("error = %v, want %v", err, ErrUnsupported)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRejectsNonBase64DataURL(t *testing.T) {
|
||||
raw := samplePNG(t, 4, 4)
|
||||
dataURL := "data:image/png," + base64.StdEncoding.EncodeToString(raw)
|
||||
if _, _, err := Parse(dataURL); !errors.Is(err, ErrEmpty) {
|
||||
t.Fatalf("error = %v, want %v", err, ErrEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRejectsMalformedAndUnsupportedInput(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value string
|
||||
want error
|
||||
}{
|
||||
{name: "empty", value: " ", want: ErrEmpty},
|
||||
{name: "not base64", value: "!!!not base64!!!", want: ErrEncoding},
|
||||
{name: "plain text", value: base64.StdEncoding.EncodeToString([]byte("hello world, not an image")), want: ErrUnsupported},
|
||||
{name: "svg", value: base64.StdEncoding.EncodeToString([]byte(`<svg xmlns="http://www.w3.org/2000/svg"/>`)), want: ErrUnsupported},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if _, _, err := Parse(test.value); !errors.Is(err, test.want) {
|
||||
t.Fatalf("error = %v, want %v", err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRejectsOversizedUploads(t *testing.T) {
|
||||
huge := base64.StdEncoding.EncodeToString(make([]byte, MaxBytes+1))
|
||||
if _, _, err := Parse(huge); !errors.Is(err, ErrTooLarge) {
|
||||
t.Fatalf("error = %v, want %v", err, ErrTooLarge)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectRejectsDecompressionBombs(t *testing.T) {
|
||||
oversized := samplePNG(t, 1, MaxDimension+1)
|
||||
if _, err := Inspect(oversized); !errors.Is(err, ErrDimensions) {
|
||||
t.Fatalf("error = %v, want %v", err, ErrDimensions)
|
||||
}
|
||||
raw := samplePNG(t, MaxDimension, MaxDimension)
|
||||
if _, err := Inspect(raw); err != nil {
|
||||
t.Fatalf("Inspect() rejected the maximum size: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectRejectsEmptyBytes(t *testing.T) {
|
||||
if _, err := Inspect(nil); !errors.Is(err, ErrEmpty) {
|
||||
t.Fatalf("error = %v, want %v", err, ErrEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewKeyIsUnguessableAndValid(t *testing.T) {
|
||||
seen := make(map[string]bool)
|
||||
for range 100 {
|
||||
key, err := NewKey()
|
||||
if err != nil {
|
||||
t.Fatalf("NewKey() error = %v", err)
|
||||
}
|
||||
if !ValidKey(key) {
|
||||
t.Fatalf("NewKey() produced an invalid key %q", key)
|
||||
}
|
||||
if seen[key] {
|
||||
t.Fatal("NewKey() repeated a key")
|
||||
}
|
||||
seen[key] = true
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidKeyRejectsMalformedLocators(t *testing.T) {
|
||||
tests := []string{"", "short", "../etc/passwd", string(bytes.Repeat([]byte("a"), 44)), "a/b+c="}
|
||||
for _, test := range tests {
|
||||
if ValidKey(test) {
|
||||
t.Fatalf("ValidKey(%q) = true", test)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestETagIsStableAndContentDerived(t *testing.T) {
|
||||
first, second := samplePNG(t, 4, 4), samplePNG(t, 5, 5)
|
||||
if ETag(first) != ETag(first) {
|
||||
t.Fatal("ETag is not stable for identical content")
|
||||
}
|
||||
if ETag(first) == ETag(second) {
|
||||
t.Fatal("ETag must change when the content changes")
|
||||
}
|
||||
if tag := ETag(first); len(tag) < 3 || tag[0] != '"' || tag[len(tag)-1] != '"' {
|
||||
t.Fatalf("ETag = %q, want a quoted validator", tag)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue