big update: added reactions, groups, media share
CI / Go (build + vet + test) (push) Canceled after 0s
CI / Web (build + lint) (push) Canceled after 0s

This commit is contained in:
vantablackTea
2026-09-27 21:59:48 +03:00
parent ae24bdd0e0
commit 9d03ea4c51
41 changed files with 2548 additions and 172 deletions
+95
View File
@@ -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] == '\\')
}
+19
View File
@@ -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
}
+73
View File
@@ -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)
}
}
}
+82
View File
@@ -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
}
+73
View File
@@ -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")
}
}