big update: added reactions, groups, media share
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
package media
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// Local stores objects as files under a base directory.
|
||||
type Local struct {
|
||||
dir string
|
||||
}
|
||||
|
||||
// NewLocal creates a Local store rooted at dir, creating it if needed.
|
||||
func NewLocal(dir string) (*Local, error) {
|
||||
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Local{dir: dir}, nil
|
||||
}
|
||||
|
||||
func (l *Local) resolve(key string) (string, error) {
|
||||
clean := filepath.Clean(filepath.Join(l.dir, key))
|
||||
if !hasPrefix(clean, l.dir) {
|
||||
return "", errors.New("media: invalid storage key")
|
||||
}
|
||||
return clean, nil
|
||||
}
|
||||
|
||||
// Save writes the stream to disk atomically under the base directory.
|
||||
func (l *Local) Save(_ context.Context, key string, r io.Reader) error {
|
||||
path, err := l.resolve(key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp, err := os.CreateTemp(filepath.Dir(path), ".upload-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(tmp.Name())
|
||||
|
||||
if _, err := io.Copy(tmp, r); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp.Name(), path)
|
||||
}
|
||||
|
||||
// Open returns a reader for the object or ErrNotFound.
|
||||
func (l *Local) Open(_ context.Context, key string) (io.ReadCloser, error) {
|
||||
path, err := l.resolve(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// Delete removes the object; missing objects are not an error.
|
||||
func (l *Local) Delete(_ context.Context, key string) error {
|
||||
path, err := l.resolve(key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func hasPrefix(p, dir string) bool {
|
||||
rel, err := filepath.Rel(dir, p)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return rel != ".." && !startsWithDotDot(rel)
|
||||
}
|
||||
|
||||
func startsWithDotDot(rel string) bool {
|
||||
return len(rel) >= 3 && rel[0] == '.' && rel[1] == '.' && (rel[2] == '/' || rel[2] == '\\')
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// Package media provides file storage and image thumbnail generation
|
||||
// for attachments shared in conversations.
|
||||
package media
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
)
|
||||
|
||||
// ErrNotFound is returned when a stored object does not exist.
|
||||
var ErrNotFound = errors.New("media: not found")
|
||||
|
||||
// Storage is an S3-like object store for attachment bytes.
|
||||
type Storage interface {
|
||||
Save(ctx context.Context, key string, r io.Reader) error
|
||||
Open(ctx context.Context, key string) (io.ReadCloser, error)
|
||||
Delete(ctx context.Context, key string) error
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package media
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLocalSaveOpenDelete(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, err := NewLocal(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("NewLocal: %v", err)
|
||||
}
|
||||
|
||||
payload := []byte("hello attachment")
|
||||
if err := store.Save(ctx, "a/b/file.txt", bytes.NewReader(payload)); err != nil {
|
||||
t.Fatalf("Save: %v", err)
|
||||
}
|
||||
|
||||
rc, err := store.Open(ctx, "a/b/file.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
got, err := io.ReadAll(rc)
|
||||
rc.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("ReadAll: %v", err)
|
||||
}
|
||||
if !bytes.Equal(got, payload) {
|
||||
t.Fatalf("content = %q, want %q", got, payload)
|
||||
}
|
||||
|
||||
if err := store.Delete(ctx, "a/b/file.txt"); err != nil {
|
||||
t.Fatalf("Delete: %v", err)
|
||||
}
|
||||
if err := store.Delete(ctx, "a/b/file.txt"); err != nil {
|
||||
t.Fatalf("Delete idempotent: %v", err)
|
||||
}
|
||||
if _, err := store.Open(ctx, "a/b/file.txt"); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("Open after delete = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalPathTraversalRejected(t *testing.T) {
|
||||
store, err := NewLocal(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("NewLocal: %v", err)
|
||||
}
|
||||
err = store.Save(context.Background(), "../../etc/evil", strings.NewReader("x"))
|
||||
if err == nil {
|
||||
t.Fatal("Save with traversal key = nil, want error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsImage(t *testing.T) {
|
||||
cases := map[string]bool{
|
||||
"image/png": true,
|
||||
"image/jpeg": true,
|
||||
"image/gif": true,
|
||||
"image/webp": true,
|
||||
"application/pdf": false,
|
||||
"text/plain": false,
|
||||
}
|
||||
for ct, want := range cases {
|
||||
if got := IsImage(ct); got != want {
|
||||
t.Errorf("IsImage(%q) = %v, want %v", ct, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package media
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"image"
|
||||
_ "image/gif"
|
||||
"image/jpeg"
|
||||
_ "image/png"
|
||||
|
||||
"golang.org/x/image/draw"
|
||||
"golang.org/x/image/webp"
|
||||
)
|
||||
|
||||
const maxUploadSize = 25 << 20 // 25 MiB
|
||||
|
||||
// IsImage reports whether the content type is a decodable image format.
|
||||
func IsImage(contentType string) bool {
|
||||
switch contentType {
|
||||
case "image/jpeg", "image/png", "image/gif", "image/webp":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Generate returns a JPEG thumbnail of at most maxDim on its larger side.
|
||||
// It returns nil for non-images or when the source is already small enough.
|
||||
func Generate(ctx context.Context, r *bytes.Reader, contentType string, maxDim int) ([]byte, error) {
|
||||
if !IsImage(contentType) {
|
||||
return nil, nil
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
src, err := decodeImage(r, contentType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bounds := src.Bounds()
|
||||
w, h := bounds.Dx(), bounds.Dy()
|
||||
if w <= maxDim && h <= maxDim {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
scale := float64(maxDim) / float64(max(w, h))
|
||||
nw, nh := int(float64(w)*scale), int(float64(h)*scale)
|
||||
if nw < 1 {
|
||||
nw = 1
|
||||
}
|
||||
if nh < 1 {
|
||||
nh = 1
|
||||
}
|
||||
|
||||
dst := image.NewRGBA(image.Rect(0, 0, nw, nh))
|
||||
draw.BiLinear.Scale(dst, dst.Bounds(), src, bounds, draw.Over, nil)
|
||||
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := jpeg.Encode(&buf, dst, &jpeg.Options{Quality: 80}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func decodeImage(r *bytes.Reader, contentType string) (image.Image, error) {
|
||||
if contentType == "image/webp" {
|
||||
return webp.Decode(r)
|
||||
}
|
||||
img, _, err := image.Decode(r)
|
||||
return img, err
|
||||
}
|
||||
|
||||
func max(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package media
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func testPNG(t *testing.T, w, h int) []byte {
|
||||
t.Helper()
|
||||
img := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||
for x := 0; x < w; x++ {
|
||||
for y := 0; y < h; y++ {
|
||||
img.Set(x, y, color.RGBA{R: uint8(x % 256), G: uint8(y % 256), B: 120, A: 255})
|
||||
}
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, img); err != nil {
|
||||
t.Fatalf("png.Encode: %v", err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func TestGenerateSkipsSmallImage(t *testing.T) {
|
||||
data := testPNG(t, 64, 64)
|
||||
out, err := Generate(context.Background(), bytes.NewReader(data), "image/png", 256)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate: %v", err)
|
||||
}
|
||||
if out != nil {
|
||||
t.Fatalf("small image produced thumbnail, want nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateResizesLargeImage(t *testing.T) {
|
||||
data := testPNG(t, 512, 512)
|
||||
out, err := Generate(context.Background(), bytes.NewReader(data), "image/png", 256)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate: %v", err)
|
||||
}
|
||||
if out == nil {
|
||||
t.Fatal("large image produced nil thumbnail")
|
||||
}
|
||||
img, err := jpeg.Decode(bytes.NewReader(out))
|
||||
if err != nil {
|
||||
t.Fatalf("thumbnail is not valid JPEG: %v", err)
|
||||
}
|
||||
b := img.Bounds()
|
||||
if b.Dx() > 256 || b.Dy() > 256 {
|
||||
t.Fatalf("thumbnail size = %dx%d, want <= 256x256", b.Dx(), b.Dy())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateNonImageReturnsNil(t *testing.T) {
|
||||
out, err := Generate(context.Background(), bytes.NewReader([]byte("plain")), "text/plain", 256)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate: %v", err)
|
||||
}
|
||||
if out != nil {
|
||||
t.Fatal("non-image produced thumbnail, want nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateCorruptImageFails(t *testing.T) {
|
||||
_, err := Generate(context.Background(), bytes.NewReader([]byte("not an image")), "image/png", 256)
|
||||
if err == nil {
|
||||
t.Fatal("corrupt image: Generate = nil error, want error")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user