refactor(10.2-01): nest framework packages under modules

- Move remaining beach packages and embedded admin assets\n- Rewrite framework, example, build, and gate paths
This commit is contained in:
Jakub Zych
2026-09-28 02:21:02 +02:00
parent ac1f6d14f4
commit 5e50b166ef
277 changed files with 303 additions and 303 deletions

View File

@@ -0,0 +1,10 @@
package attach
import (
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/compass"
)
func backpackApp(cfg *compass.Config) *backpack.App {
return backpack.New(cfg)
}

View File

@@ -0,0 +1,95 @@
package attach
import (
"context"
"fmt"
"net/url"
"strings"
"sync"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/compass"
"gocloud.dev/blob"
_ "gocloud.dev/blob/fileblob"
_ "gocloud.dev/blob/memblob"
)
const defaultPublicPathPrefix = "/storage/uploads"
var (
prefixMu sync.RWMutex
publicPathPrefix = defaultPublicPathPrefix
)
func setPublicPathPrefix(prefix string) {
prefixMu.Lock()
publicPathPrefix = prefix
prefixMu.Unlock()
}
// PublicPathPrefix is the URL prefix prepended to partition+filename.
func PublicPathPrefix() string {
prefixMu.RLock()
defer prefixMu.RUnlock()
return publicPathPrefix
}
func normalizeBucketURL(raw string) (string, error) {
u, err := url.Parse(raw)
if err != nil {
return "", fmt.Errorf("attach: bucket_url: %w", err)
}
switch strings.ToLower(u.Scheme) {
case "mem", "memory":
return "mem://", nil
case "fileblob":
u.Scheme = "file"
fallthrough
case "file":
q := u.Query()
if q.Get("create_dir") == "" {
q.Set("create_dir", "true")
u.RawQuery = q.Encode()
}
return u.String(), nil
default:
return raw, nil
}
}
// OpenBucket opens storage.uploads.bucket_url (file:// or mem://) and
// records storage.uploads.public_path_prefix. An empty bucket_url fails boot.
func OpenBucket(ctx context.Context, cfg *compass.Config) (*blob.Bucket, error) {
if cfg == nil {
return nil, fmt.Errorf("attach: config is nil")
}
raw := strings.TrimSpace(cfg.String("storage.uploads.bucket_url"))
if raw == "" {
return nil, fmt.Errorf("attach: storage.uploads.bucket_url is empty (set SUMMER_STORAGE__UPLOADS__BUCKET_URL)")
}
bucketURL, err := normalizeBucketURL(raw)
if err != nil {
return nil, err
}
bucket, err := blob.OpenBucket(ctx, bucketURL)
if err != nil {
return nil, fmt.Errorf("attach: open bucket %q: %w", raw, err)
}
prefix := strings.TrimSpace(cfg.String("storage.uploads.public_path_prefix"))
if prefix == "" {
prefix = defaultPublicPathPrefix
}
setPublicPathPrefix(prefix)
return bucket, nil
}
// Publish stores the opened bucket once on the app, matching lagoon.Publish.
func Publish(app *backpack.App, bucket *blob.Bucket) error {
if app == nil {
return fmt.Errorf("attach: app is nil")
}
if bucket == nil {
return fmt.Errorf("attach: bucket is nil")
}
return app.Publish(bucket)
}

View File

@@ -0,0 +1,91 @@
package attach
import (
"os"
"path/filepath"
"strings"
"testing"
"git.golem15.com/golem15/summercms/modules/compass"
"gocloud.dev/blob"
"gocloud.dev/blob/memblob"
)
func TestOpenBucketRequiresURL(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "storage.yaml"), []byte("uploads:\n bucket_url: \"\"\n"), 0o644); err != nil {
t.Fatal(err)
}
cfg, err := compass.Open(compass.Options{
Dir: dir,
Env: "development",
Environ: []string{"SUMMER_ENV=development"},
})
if err != nil {
t.Fatal(err)
}
_, err = OpenBucket(t.Context(), cfg)
if err == nil || !strings.Contains(err.Error(), "bucket_url") {
t.Fatalf("got %v, want bucket_url error", err)
}
}
func TestOpenBucketOpensMem(t *testing.T) {
dir := t.TempDir()
body := "uploads:\n bucket_url: \"mem://\"\n public_path_prefix: \"/storage/uploads\"\n"
if err := os.WriteFile(filepath.Join(dir, "storage.yaml"), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
cfg, err := compass.Open(compass.Options{
Dir: dir,
Env: "development",
Environ: []string{"SUMMER_ENV=development"},
})
if err != nil {
t.Fatal(err)
}
bucket, err := OpenBucket(t.Context(), cfg)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = bucket.Close() })
if PublicPathPrefix() != "/storage/uploads" {
t.Fatalf("prefix = %q", PublicPathPrefix())
}
ctx := t.Context()
if err := bucket.WriteAll(ctx, "abc/123/xyz/probe.jpg", []byte("hi"), &blob.WriterOptions{}); err != nil {
t.Fatal(err)
}
got, err := bucket.ReadAll(ctx, "abc/123/xyz/probe.jpg")
if err != nil {
t.Fatal(err)
}
if string(got) != "hi" {
t.Fatalf("read = %q", got)
}
}
func TestPublishStoresBucket(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: attach-test\n"), 0o644); err != nil {
t.Fatal(err)
}
cfg, err := compass.Open(compass.Options{
Dir: dir,
Env: "development",
Environ: []string{"SUMMER_ENV=development"},
})
if err != nil {
t.Fatal(err)
}
app := backpackApp(cfg)
bucket := memblob.OpenBucket(nil)
t.Cleanup(func() { _ = bucket.Close() })
if err := Publish(app, bucket); err != nil {
t.Fatal(err)
}
got, ok := app.Lookup[*blob.Bucket]()
if !ok || got != bucket {
t.Fatal("Publish must store the same *blob.Bucket")
}
}

View File

@@ -0,0 +1,191 @@
package attach
import (
"context"
"errors"
"fmt"
"io"
"strconv"
"strings"
"time"
"gocloud.dev/blob"
"gocloud.dev/gcerrors"
"gorm.io/gorm"
)
// Owner is implemented by models that own system_files rows. MorphName
// must return the PHP class string so cutover-copied attachment_type
// values keep matching.
type Owner interface {
MorphName() string
}
// File is Winter's system_files row. AttachmentID is a string because
// Winter stores the morph FK as a string, never an integer.
type File struct {
ID uint `gorm:"column:id;primaryKey"`
DiskName string `gorm:"column:disk_name"`
FileName string `gorm:"column:file_name"`
FileSize int64 `gorm:"column:file_size"`
ContentType string `gorm:"column:content_type"`
Title *string `gorm:"column:title"`
Description *string `gorm:"column:description"`
Field string `gorm:"column:field"`
AttachmentID string `gorm:"column:attachment_id"`
AttachmentType string `gorm:"column:attachment_type"`
// Pointer so GORM can distinguish unset (nil → DEFAULT TRUE) from
// explicit false. A non-pointer bool with default:true cannot persist
// false because false is the zero value GORM replaces with the default.
IsPublic *bool `gorm:"column:is_public;not null;default:true"`
SortOrder int `gorm:"column:sort_order"`
Metadata *string `gorm:"column:metadata"`
CreatedAt time.Time `gorm:"column:created_at"`
UpdatedAt time.Time `gorm:"column:updated_at"`
}
func (File) TableName() string { return "system_files" }
// Public reports Winter's is_public flag. A nil pointer is treated as true,
// matching the SQL DEFAULT TRUE and File::create() behaviour.
func (f File) Public() bool {
if f.IsPublic == nil {
return true
}
return *f.IsPublic
}
func fileIsPublic(ctx context.Context, db *gorm.DB, filename string) (bool, error) {
if db == nil || filename == "" {
return false, nil
}
var f File
q := db.WithContext(ctx).Select("is_public")
var err error
if id, ok := thumbFileID(filename); ok {
err = q.First(&f, id).Error
} else {
err = q.Where("disk_name = ?", filename).First(&f).Error
}
if errors.Is(err, gorm.ErrRecordNotFound) {
return false, nil
}
if err != nil {
return false, err
}
return f.Public(), nil
}
func thumbFileID(name string) (uint, bool) {
if !strings.HasPrefix(name, "thumb_") {
return 0, false
}
idStr, _, ok := strings.Cut(strings.TrimPrefix(name, "thumb_"), "_")
if !ok || idStr == "" {
return 0, false
}
n, err := strconv.ParseUint(idStr, 10, 64)
if err != nil || n == 0 {
return 0, false
}
return uint(n), true
}
var all []any
// Register appends models so schema tooling can see File without a plugin registry.
func Register(models ...any) {
all = append(all, models...)
}
// All returns every model registered in this package.
func All() []any {
return all
}
func init() {
Register(&File{})
}
func blobKeysFor(f File) []string {
part := PartitionDirectory(f.DiskName)
return []string{
part + f.DiskName,
part + fmt.Sprintf("thumb_%d_", f.ID),
}
}
// DeleteForOwner removes system_files rows for owner inside tx.
//
// Two-phase contract (GORM has no post-commit hook):
// 1. Inside tx this function SELECTs disk_name values, DELETEs the rows,
// and invokes afterCommit with the blob keys so the caller can record
// them. afterCommit must not delete blobs — a rollback cannot restore
// bytes.
// 2. After the top-level Unscoped().Delete(...) returns without error
// (the transaction has committed), the caller passes those keys to
// DeleteKeys to remove originals and thumbs from the bucket.
//
// Soft-deleting an owner must not call this helper: rows and blobs stay.
func DeleteForOwner(tx *gorm.DB, owner Owner, ownerID string, afterCommit func(blobKeys []string) error) error {
if tx == nil {
return fmt.Errorf("attach: delete tx is nil")
}
if owner == nil {
return fmt.Errorf("attach: delete owner is nil")
}
morph := owner.MorphName()
var files []File
if err := tx.Where("attachment_type = ? AND attachment_id = ?", morph, ownerID).Find(&files).Error; err != nil {
return err
}
var keys []string
for _, f := range files {
keys = append(keys, blobKeysFor(f)...)
}
if err := tx.Where("attachment_type = ? AND attachment_id = ?", morph, ownerID).Delete(&File{}).Error; err != nil {
return err
}
if afterCommit != nil {
return afterCommit(keys)
}
return nil
}
// DeleteKeys removes blob objects after a committed force-delete.
// Keys that end in '_' are treated as List prefixes (thumb_<id>_).
func DeleteKeys(ctx context.Context, bucket *blob.Bucket, keys []string) error {
if bucket == nil {
return fmt.Errorf("attach: bucket is nil")
}
for _, key := range keys {
if strings.HasSuffix(key, "_") || strings.HasSuffix(key, "/") {
iter := bucket.List(&blob.ListOptions{Prefix: key})
for {
obj, err := iter.Next(ctx)
if err == io.EOF {
break
}
if err != nil {
return fmt.Errorf("attach: list %q: %w", key, err)
}
if err := deleteKey(ctx, bucket, obj.Key); err != nil {
return err
}
}
continue
}
if err := deleteKey(ctx, bucket, key); err != nil {
return err
}
}
return nil
}
func deleteKey(ctx context.Context, bucket *blob.Bucket, key string) error {
err := bucket.Delete(ctx, key)
if err == nil || gcerrors.Code(err) == gcerrors.NotFound {
return nil
}
return fmt.Errorf("attach: delete %q: %w", key, err)
}

View File

@@ -0,0 +1,102 @@
package attach
import (
"reflect"
"strings"
"testing"
"gocloud.dev/blob/memblob"
)
func TestFileTableName(t *testing.T) {
if got := (File{}).TableName(); got != "system_files" {
t.Fatalf("TableName = %q, want system_files", got)
}
}
func TestFileIsPublicDefaultsTrue(t *testing.T) {
unset := File{}
if !unset.Public() {
t.Fatal("unset IsPublic must be public")
}
f, ok := reflect.TypeOf(File{}).FieldByName("IsPublic")
if !ok {
t.Fatal("missing IsPublic")
}
tag := f.Tag.Get("gorm")
if !strings.Contains(tag, "default:true") {
t.Fatalf("gorm tag = %q, want default:true", tag)
}
priv := false
if (File{IsPublic: &priv}).Public() {
t.Fatal("explicit false must not be public")
}
pub := true
if !(File{IsPublic: &pub}).Public() {
t.Fatal("explicit true must be public")
}
}
func TestThumbFileID(t *testing.T) {
id, ok := thumbFileID("thumb_42_200_200_0_0_crop.jpg")
if !ok || id != 42 {
t.Fatalf("thumb id = %d ok=%v, want 42 true", id, ok)
}
if _, ok := thumbFileID("abc123xyz.jpg"); ok {
t.Fatal("original disk_name must not parse as a thumb id")
}
if _, ok := thumbFileID("thumb_x_200_200_0_0_crop.jpg"); ok {
t.Fatal("non-numeric thumb id must be rejected")
}
}
func TestFileSelfRegisters(t *testing.T) {
for _, m := range All() {
switch m.(type) {
case *File, File:
return
}
}
t.Fatal("File must self-register via attach.Register")
}
// WR-05 pin: the force-delete prefix is thumb_<id>_ with its trailing
// underscore, so ID 4 cannot match thumbs of ID 40/41/42 that share a
// partition. "thumb_4_" is not a string prefix of "thumb_42_".
func TestDeleteKeysThumbPrefixIsIDDelimited(t *testing.T) {
ctx := t.Context()
bucket := memblob.OpenBucket(nil)
defer bucket.Close()
const disk = "abc123xyz000.jpg"
part := PartitionDirectory(disk)
doomed := []string{
part + disk,
part + ThumbFilename(4, 200, 200, 0, 0, "auto", "jpg"),
part + ThumbFilename(4, 50, 50, 0, 0, "crop", "jpg"),
}
survivors := []string{
part + "abc123xyz111.jpg",
part + ThumbFilename(40, 200, 200, 0, 0, "auto", "jpg"),
part + ThumbFilename(41, 200, 200, 0, 0, "auto", "jpg"),
part + ThumbFilename(42, 4, 4, 0, 0, "auto", "jpg"),
part + ThumbFilename(14, 200, 200, 0, 0, "auto", "jpg"),
}
for _, key := range append(append([]string{}, doomed...), survivors...) {
if err := bucket.WriteAll(ctx, key, []byte("x"), nil); err != nil {
t.Fatal(err)
}
}
if err := DeleteKeys(ctx, bucket, blobKeysFor(File{ID: 4, DiskName: disk})); err != nil {
t.Fatal(err)
}
for _, key := range doomed {
if ok, err := bucket.Exists(ctx, key); err != nil || ok {
t.Fatalf("%s must be deleted (exists=%v err=%v)", key, ok, err)
}
}
for _, key := range survivors {
if ok, err := bucket.Exists(ctx, key); err != nil || !ok {
t.Fatalf("%s must survive deleting file 4 (exists=%v err=%v)", key, ok, err)
}
}
}

View File

@@ -0,0 +1,303 @@
package attach_test
import (
"context"
"database/sql"
"fmt"
"net/http"
"net/http/httptest"
"strconv"
"testing"
"time"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
_ "github.com/jackc/pgx/v5/stdlib"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/modules/postgres"
"gocloud.dev/blob"
"gocloud.dev/blob/memblob"
"gorm.io/gorm"
)
type lifecycleOwner struct {
ID uint `gorm:"column:id;primaryKey"`
Name string `gorm:"column:name"`
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at"`
}
func (lifecycleOwner) TableName() string { return "attach_lifecycle_owners" }
func (lifecycleOwner) MorphName() string {
return `Golem15\Fonoteka\Models\Album`
}
func TestFileLifecycle(t *testing.T) {
if testing.Short() {
t.Skip("requires testcontainers postgres")
}
ctx := t.Context()
gdb := attachGorm(t)
if err := lagoon.Migrate(gdb, nil); err != nil {
t.Fatal(err)
}
if err := gdb.Exec(`
CREATE TABLE attach_lifecycle_owners (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
deleted_at TIMESTAMPTZ
)`).Error; err != nil {
t.Fatal(err)
}
bucket := memblob.OpenBucket(nil)
t.Cleanup(func() { _ = bucket.Close() })
owner := lifecycleOwner{Name: "album"}
if err := gdb.Create(&owner).Error; err != nil {
t.Fatal(err)
}
isPublic := true
file := attach.File{
DiskName: "abc123xyz.jpg",
FileName: "cover.jpg",
FileSize: 12,
ContentType: "image/jpeg",
Field: "photos",
AttachmentID: strconv.FormatUint(uint64(owner.ID), 10),
AttachmentType: owner.MorphName(),
IsPublic: &isPublic,
}
if err := gdb.Create(&file).Error; err != nil {
t.Fatal(err)
}
origKey := attach.BlobKey(file.DiskName)
thumbKey := attach.PartitionDirectory(file.DiskName) + attach.ThumbFilename(file.ID, 200, 200, 0, 0, "crop", "jpg")
if err := bucket.WriteAll(ctx, origKey, []byte("original"), &blob.WriterOptions{ContentType: "image/jpeg"}); err != nil {
t.Fatal(err)
}
if err := bucket.WriteAll(ctx, thumbKey, []byte("thumb"), &blob.WriterOptions{ContentType: "image/jpeg"}); err != nil {
t.Fatal(err)
}
if err := gdb.Delete(&owner).Error; err != nil {
t.Fatal(err)
}
var softOwner lifecycleOwner
if err := gdb.Unscoped().First(&softOwner, owner.ID).Error; err != nil {
t.Fatal(err)
}
if !softOwner.DeletedAt.Valid {
t.Fatal("soft-delete must set deleted_at")
}
assertFileRow(t, gdb, file.ID, true)
assertBlob(t, ctx, bucket, origKey, true)
assertBlob(t, ctx, bucket, thumbKey, true)
if err := gdb.Transaction(func(tx *gorm.DB) error {
return attach.DeleteForOwner(tx, owner, file.AttachmentID, func(keys []string) error {
assertBlob(t, ctx, bucket, origKey, true)
assertBlob(t, ctx, bucket, thumbKey, true)
return fmt.Errorf("rollback after collecting keys")
})
}); err == nil {
t.Fatal("expected rollback")
}
assertFileRow(t, gdb, file.ID, true)
assertBlob(t, ctx, bucket, origKey, true)
assertBlob(t, ctx, bucket, thumbKey, true)
var pending []string
if err := gdb.Transaction(func(tx *gorm.DB) error {
if err := tx.Unscoped().Delete(&owner).Error; err != nil {
return err
}
return attach.DeleteForOwner(tx, owner, file.AttachmentID, func(keys []string) error {
pending = append([]string(nil), keys...)
var n int64
if err := tx.Model(&attach.File{}).Where("id = ?", file.ID).Count(&n).Error; err != nil {
return err
}
if n != 0 {
return fmt.Errorf("system_files row must be gone inside the force-delete transaction")
}
exists, err := bucket.Exists(ctx, origKey)
if err != nil {
return err
}
if !exists {
return fmt.Errorf("blob must still exist before commit")
}
return nil
})
}); err != nil {
t.Fatal(err)
}
assertFileRow(t, gdb, file.ID, false)
assertBlob(t, ctx, bucket, origKey, true)
if err := attach.DeleteKeys(ctx, bucket, pending); err != nil {
t.Fatal(err)
}
assertBlob(t, ctx, bucket, origKey, false)
assertBlob(t, ctx, bucket, thumbKey, false)
}
func TestFileCreateDefaultsIsPublic(t *testing.T) {
if testing.Short() {
t.Skip("requires testcontainers postgres")
}
gdb := attachGorm(t)
if err := lagoon.Migrate(gdb, nil); err != nil {
t.Fatal(err)
}
file := attach.File{
DiskName: "abc123xyz.jpg",
FileName: "cover.jpg",
FileSize: 1,
ContentType: "image/jpeg",
}
if err := gdb.Create(&file).Error; err != nil {
t.Fatal(err)
}
var got attach.File
if err := gdb.First(&got, file.ID).Error; err != nil {
t.Fatal(err)
}
if !got.Public() {
t.Fatalf("Create without IsPublic stored %v, want true", got.IsPublic)
}
priv := false
hidden := attach.File{
DiskName: "def456uvw.jpg",
FileName: "secret.jpg",
FileSize: 1,
ContentType: "image/jpeg",
IsPublic: &priv,
}
if err := gdb.Create(&hidden).Error; err != nil {
t.Fatal(err)
}
var gotHidden attach.File
if err := gdb.First(&gotHidden, hidden.ID).Error; err != nil {
t.Fatal(err)
}
if gotHidden.Public() {
t.Fatal("explicit is_public=false must persist")
}
}
func TestStaticHandlerPublicLooksUpIsPublic(t *testing.T) {
if testing.Short() {
t.Skip("requires testcontainers postgres")
}
ctx := t.Context()
gdb := attachGorm(t)
if err := lagoon.Migrate(gdb, nil); err != nil {
t.Fatal(err)
}
bucket := memblob.OpenBucket(nil)
t.Cleanup(func() { _ = bucket.Close() })
pub := true
priv := false
publicFile := attach.File{DiskName: "abc123xyz.jpg", FileName: "cover.jpg", FileSize: 1, ContentType: "image/jpeg", IsPublic: &pub}
privateFile := attach.File{DiskName: "def456uvw.jpg", FileName: "secret.jpg", FileSize: 1, ContentType: "image/jpeg", IsPublic: &priv}
if err := gdb.Create(&publicFile).Error; err != nil {
t.Fatal(err)
}
if err := gdb.Create(&privateFile).Error; err != nil {
t.Fatal(err)
}
for _, f := range []attach.File{publicFile, privateFile} {
if err := bucket.WriteAll(ctx, attach.BlobKey(f.DiskName), []byte(f.FileName), &blob.WriterOptions{ContentType: "image/jpeg"}); err != nil {
t.Fatal(err)
}
thumbKey := attach.PartitionDirectory(f.DiskName) + attach.ThumbFilename(f.ID, 50, 50, 0, 0, "crop", "jpg")
if err := bucket.WriteAll(ctx, thumbKey, []byte("thumb"), &blob.WriterOptions{ContentType: "image/jpeg"}); err != nil {
t.Fatal(err)
}
}
h := attach.StaticHandlerPublic(bucket, "/storage/uploads", gdb)
get := func(path string) int {
t.Helper()
rr := httptest.NewRecorder()
h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, path, nil))
return rr.Code
}
if code := get("/storage/uploads/abc/123/xyz/abc123xyz.jpg"); code != http.StatusOK {
t.Fatalf("public original status = %d, want 200", code)
}
if code := get("/storage/uploads/abc/123/xyz/" + attach.ThumbFilename(publicFile.ID, 50, 50, 0, 0, "crop", "jpg")); code != http.StatusOK {
t.Fatalf("public thumb status = %d, want 200", code)
}
if code := get("/storage/uploads/def/456/uvw/def456uvw.jpg"); code != http.StatusNotFound {
t.Fatalf("private original status = %d, want 404", code)
}
if code := get("/storage/uploads/def/456/uvw/" + attach.ThumbFilename(privateFile.ID, 50, 50, 0, 0, "crop", "jpg")); code != http.StatusNotFound {
t.Fatalf("private thumb status = %d, want 404", code)
}
if code := get("/storage/uploads/abc/123/xyz/missing.jpg"); code != http.StatusNotFound {
t.Fatalf("unknown disk_name status = %d, want 404", code)
}
}
func assertFileRow(t *testing.T, gdb *gorm.DB, id uint, want bool) {
t.Helper()
var n int64
if err := gdb.Model(&attach.File{}).Where("id = ?", id).Count(&n).Error; err != nil {
t.Fatal(err)
}
if got := n > 0; got != want {
t.Fatalf("system_files id %d exists=%v, want %v", id, got, want)
}
}
func assertBlob(t *testing.T, ctx context.Context, bucket *blob.Bucket, key string, want bool) {
t.Helper()
got, err := bucket.Exists(ctx, key)
if err != nil {
t.Fatal(err)
}
if got != want {
t.Fatalf("blob %q exists=%v, want %v", key, got, want)
}
}
func attachGorm(t *testing.T) *gorm.DB {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
ctr, err := postgres.Run(ctx,
"postgres:16-alpine",
postgres.WithDatabase("attach"),
postgres.WithUsername("attach"),
postgres.WithPassword("attach"),
postgres.BasicWaitStrategies(),
testcontainers.WithEnv(map[string]string{
"POSTGRES_INITDB_ARGS": "--locale-provider=icu --icu-locale=pl-PL --encoding=UTF8",
}),
)
if err != nil {
t.Fatalf("postgres: %v", err)
}
t.Cleanup(func() { _ = testcontainers.TerminateContainer(ctr) })
dsn, err := ctr.ConnectionString(ctx, "sslmode=disable")
if err != nil {
t.Fatal(err)
}
db, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Close() })
if err := db.PingContext(ctx); err != nil {
t.Fatal(err)
}
gdb, err := lagoon.Use(ctx, db)
if err != nil {
t.Fatalf("lagoon.Use: %v", err)
}
return gdb
}

View File

@@ -0,0 +1,49 @@
package attach
import (
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
// Migrations creates Winter's system_files table. Folded from
// modules/system/database/migrations/2013_10_01_000002_Db_System_Files.php
// and 2025_04_10_000031_Db_Add_System_Files_Metadata.php.
var Migrations = []*gormigrate.Migration{
{
ID: "202609180001_create_system_files",
Migrate: func(tx *gorm.DB) error {
stmts := []string{
`CREATE TABLE system_files (
id SERIAL PRIMARY KEY,
disk_name TEXT NOT NULL,
file_name TEXT NOT NULL,
file_size INTEGER NOT NULL,
content_type TEXT NOT NULL,
title TEXT,
description TEXT,
field TEXT,
attachment_id TEXT,
attachment_type TEXT,
is_public BOOLEAN NOT NULL DEFAULT TRUE,
sort_order INTEGER,
metadata TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)`,
`CREATE INDEX system_files_field_index ON system_files (field)`,
`CREATE INDEX system_files_attachment_id_index ON system_files (attachment_id)`,
`CREATE INDEX system_files_attachment_type_index ON system_files (attachment_type)`,
`CREATE INDEX system_files_attachment_lookup_index ON system_files (attachment_type, attachment_id, field)`,
}
for _, stmt := range stmts {
if err := tx.Exec(stmt).Error; err != nil {
return err
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
return tx.Exec("DROP TABLE IF EXISTS system_files").Error
},
},
}

View File

@@ -0,0 +1,125 @@
package attach
import (
"context"
"io"
"net/http"
"path"
"strings"
"gocloud.dev/blob"
"gorm.io/gorm"
)
const defaultStaticContentType = "application/octet-stream"
// StaticHandler serves GET prefix/<partition>/<filename> from bucket.
// filename is the original disk_name or a thumb_* sibling stored in the
// original's partition. The blob key is the validated 4-segment path;
// unvalidated request segments never reach NewReader (T-05-13).
//
// This is the public-disk handler: it does not consult system_files.is_public.
// Protected files must not be stored in this bucket (Winter uses a second
// disk). To 404 is_public=false rows, mount StaticHandlerPublic instead.
// Do not mount the ungated handler on the app origin — same-origin
// Content-Type from uploads is XSS-relevant.
func StaticHandler(bucket *blob.Bucket, prefix string) http.Handler {
return servePublicBlobs(bucket, prefix, nil)
}
// StaticHandlerPublic is StaticHandler plus an is_public gate. Missing
// rows and is_public=false both 404. The lookup runs before NewReader.
func StaticHandlerPublic(bucket *blob.Bucket, prefix string, db *gorm.DB) http.Handler {
return servePublicBlobs(bucket, prefix, func(ctx context.Context, filename string) (bool, error) {
return fileIsPublic(ctx, db, filename)
})
}
func servePublicBlobs(bucket *blob.Bucket, prefix string, allow func(context.Context, string) (bool, error)) http.Handler {
prefix = strings.TrimSuffix(prefix, "/")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if bucket == nil {
http.NotFound(w, r)
return
}
rel, ok := stripStaticPrefix(r.URL.Path, prefix)
if !ok {
http.NotFound(w, r)
return
}
key, ok := parsePublicBlobPath(rel)
if !ok {
http.NotFound(w, r)
return
}
if allow != nil {
ok, err := allow(r.Context(), path.Base(key))
if err != nil || !ok {
http.NotFound(w, r)
return
}
}
reader, err := bucket.NewReader(r.Context(), key, nil)
if err != nil {
http.NotFound(w, r)
return
}
defer reader.Close()
ct := reader.ContentType()
if ct == "" {
ct = defaultStaticContentType
}
w.Header().Set("Content-Type", ct)
if r.Method == http.MethodHead {
return
}
_, _ = io.Copy(w, reader)
})
}
func stripStaticPrefix(path, prefix string) (string, bool) {
if prefix == "" {
return strings.TrimPrefix(path, "/"), true
}
if path == prefix {
return "", false
}
if strings.HasPrefix(path, prefix+"/") {
return path[len(prefix)+1:], true
}
return "", false
}
// parsePublicBlobPath accepts exactly 3 partition groups plus a filename
// and returns that path as the blob key. Originals must live in
// PartitionDirectory(filename); thumb_* names are stored beside the
// original, so their partition is not derived from the thumb filename.
// Rejects "..", empty segments, extra slashes, and mismatched original
// partitions.
func parsePublicBlobPath(p string) (key string, ok bool) {
if p == "" || strings.Contains(p, "\\") || strings.Contains(p, "..") || strings.Contains(p, "//") {
return "", false
}
parts := strings.Split(p, "/")
if len(parts) != 4 {
return "", false
}
for _, part := range parts {
if part == "" || part == "." || part == ".." {
return "", false
}
}
filename := parts[3]
got := strings.Join(parts[:3], "/")
if !strings.HasPrefix(filename, "thumb_") {
want := strings.TrimSuffix(PartitionDirectory(filename), "/")
if got != want {
return "", false
}
}
return got + "/" + filename, true
}

View File

@@ -0,0 +1,174 @@
package attach
import (
"context"
"io"
"net/http"
"net/http/httptest"
"testing"
"gocloud.dev/blob"
"gocloud.dev/blob/memblob"
)
func TestStaticHandler(t *testing.T) {
ctx := t.Context()
bucket := memblob.OpenBucket(nil)
t.Cleanup(func() { _ = bucket.Close() })
diskName := "abc123xyz.jpg"
key := BlobKey(diskName)
body := []byte("cover-bytes")
if err := bucket.WriteAll(ctx, key, body, &blob.WriterOptions{ContentType: "image/jpeg"}); err != nil {
t.Fatal(err)
}
h := StaticHandler(bucket, "/storage/uploads")
srv := httptest.NewServer(h)
t.Cleanup(srv.Close)
res, err := http.Get(srv.URL + "/storage/uploads/abc/123/xyz/abc123xyz.jpg")
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Fatalf("status = %d", res.StatusCode)
}
if ct := res.Header.Get("Content-Type"); ct != "image/jpeg" {
t.Fatalf("Content-Type = %q, want image/jpeg", ct)
}
got, err := io.ReadAll(res.Body)
if err != nil {
t.Fatal(err)
}
if string(got) != string(body) {
t.Fatalf("body = %q", got)
}
missing, err := http.Get(srv.URL + "/storage/uploads/mis/sin/g.j/missing.jpg")
if err != nil {
t.Fatal(err)
}
defer missing.Body.Close()
if missing.StatusCode != http.StatusNotFound {
t.Fatalf("missing status = %d, want 404", missing.StatusCode)
}
for _, path := range []string{
"/storage/uploads/abc/123/xyz/../abc123xyz.jpg",
"/storage/uploads/abc/123/xyz//abc123xyz.jpg",
"/storage/uploads/foo/bar/baz/abc123xyz.jpg",
"/storage/uploads/abc/123/xyz/abc123xyz.jpg/extra",
"/storage/uploads",
} {
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, path, nil)
h.ServeHTTP(rr, req)
if rr.Code != http.StatusNotFound {
t.Fatalf("path %q status = %d, want 404", path, rr.Code)
}
}
}
func TestStaticHandlerServesThumbURL(t *testing.T) {
ctx := t.Context()
bucket := memblob.OpenBucket(nil)
t.Cleanup(func() { _ = bucket.Close() })
f := &File{ID: 42, DiskName: "abc123xyz.jpg"}
if err := bucket.WriteAll(ctx, BlobKey(f.DiskName), testJPEG(t), &blob.WriterOptions{ContentType: "image/jpeg"}); err != nil {
t.Fatal(err)
}
thumbURL, err := f.Thumb(ctx, bucket, 200, 200, "crop")
if err != nil {
t.Fatal(err)
}
const wantURL = "/storage/uploads/abc/123/xyz/thumb_42_200_200_0_0_crop.jpg"
if thumbURL != wantURL {
t.Fatalf("Thumb URL = %q, want %q", thumbURL, wantURL)
}
h := StaticHandler(bucket, "/storage/uploads")
srv := httptest.NewServer(h)
t.Cleanup(srv.Close)
res, err := http.Get(srv.URL + thumbURL)
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Fatalf("thumb GET status = %d, want 200", res.StatusCode)
}
body, err := io.ReadAll(res.Body)
if err != nil {
t.Fatal(err)
}
if len(body) == 0 {
t.Fatal("thumb body is empty")
}
mismatch := httptest.NewRecorder()
h.ServeHTTP(mismatch, httptest.NewRequest(http.MethodGet, "/storage/uploads/foo/bar/baz/abc123xyz.jpg", nil))
if mismatch.Code != http.StatusNotFound {
t.Fatalf("mismatched original partition status = %d, want 404", mismatch.Code)
}
}
func TestStaticHandlerPublicGate(t *testing.T) {
ctx := t.Context()
bucket := memblob.OpenBucket(nil)
t.Cleanup(func() { _ = bucket.Close() })
diskName := "abc123xyz.jpg"
body := []byte("cover-bytes")
if err := bucket.WriteAll(ctx, BlobKey(diskName), body, &blob.WriterOptions{ContentType: "image/jpeg"}); err != nil {
t.Fatal(err)
}
thumbName := ThumbFilename(42, 200, 200, 0, 0, "crop", "jpg")
thumbKey := PartitionDirectory(diskName) + thumbName
if err := bucket.WriteAll(ctx, thumbKey, []byte("thumb-bytes"), &blob.WriterOptions{ContentType: "image/jpeg"}); err != nil {
t.Fatal(err)
}
var seen []string
deny := servePublicBlobs(bucket, "/storage/uploads", func(_ context.Context, name string) (bool, error) {
seen = append(seen, name)
return false, nil
})
for _, path := range []string{
"/storage/uploads/abc/123/xyz/abc123xyz.jpg",
"/storage/uploads/abc/123/xyz/" + thumbName,
} {
rr := httptest.NewRecorder()
deny.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, path, nil))
if rr.Code != http.StatusNotFound {
t.Fatalf("private %s status = %d, want 404", path, rr.Code)
}
}
if len(seen) != 2 || seen[0] != diskName || seen[1] != thumbName {
t.Fatalf("allow saw %v, want [%s %s] before NewReader", seen, diskName, thumbName)
}
allow := servePublicBlobs(bucket, "/storage/uploads", func(_ context.Context, name string) (bool, error) {
return name == diskName, nil
})
ok := httptest.NewRecorder()
allow.ServeHTTP(ok, httptest.NewRequest(http.MethodGet, "/storage/uploads/abc/123/xyz/abc123xyz.jpg", nil))
if ok.Code != http.StatusOK {
t.Fatalf("public original status = %d, want 200", ok.Code)
}
blocked := httptest.NewRecorder()
allow.ServeHTTP(blocked, httptest.NewRequest(http.MethodGet, "/storage/uploads/abc/123/xyz/"+thumbName, nil))
if blocked.Code != http.StatusNotFound {
t.Fatalf("denied thumb status = %d, want 404", blocked.Code)
}
nilDB := StaticHandlerPublic(bucket, "/storage/uploads", nil)
missing := httptest.NewRecorder()
nilDB.ServeHTTP(missing, httptest.NewRequest(http.MethodGet, "/storage/uploads/abc/123/xyz/abc123xyz.jpg", nil))
if missing.Code != http.StatusNotFound {
t.Fatalf("nil db status = %d, want 404", missing.Code)
}
}

View File

@@ -0,0 +1,177 @@
package attach
import (
"context"
"fmt"
"image"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"io"
"path"
"regexp"
"strings"
"github.com/disintegration/imaging"
"gocloud.dev/blob"
)
const (
maxThumbEdge = 4096
maxThumbSourceBytes = 32 << 20
maxThumbSourcePixels = 4096 * 4096
)
// thumbToken is the alphabet allowed for the mode and extension segments of a
// thumb filename. Both are interpolated into a blob key, which fileblob maps
// to a filesystem path, so separators and dots must never reach it.
var thumbToken = regexp.MustCompile(`^[a-z0-9]+$`)
// ThumbFilename is Winter File::getThumbFilename: thumb_<id>_<w>_<h>_<ox>_<oy>_<mode>.<ext>.
// A mode or ext outside [a-z0-9]+ is coerced to "auto" / "jpg" so the result
// is always a single safe path element; File.Thumb rejects such input instead.
func ThumbFilename(id uint, w, h int, offsetX, offsetY int, mode, ext string) string {
if !thumbToken.MatchString(mode) {
mode = "auto"
}
if !thumbToken.MatchString(ext) {
ext = "jpg"
}
return fmt.Sprintf("thumb_%d_%d_%d_%d_%d_%s.%s", id, w, h, offsetX, offsetY, mode, ext)
}
// PartitionDirectory is Winter File::getPartitionDirectory: first 9 chars of
// disk_name split into 3 groups of 3, joined by '/', with a trailing slash.
func PartitionDirectory(diskName string) string {
var groups []string
for i := 0; i < len(diskName) && len(groups) < 3; i += 3 {
end := i + 3
if end > len(diskName) {
end = len(diskName)
}
groups = append(groups, diskName[i:end])
}
return strings.Join(groups, "/") + "/"
}
// BlobKey is the Winter on-disk key for an original file: partition + disk_name.
func BlobKey(diskName string) string {
return PartitionDirectory(diskName) + diskName
}
func fileExt(diskName string) string {
ext := strings.TrimPrefix(path.Ext(diskName), ".")
if ext == "" {
return "jpg"
}
return strings.ToLower(ext)
}
func publicURL(key string) string {
prefix := strings.TrimRight(PublicPathPrefix(), "/")
key = strings.TrimLeft(key, "/")
if prefix == "" {
return "/" + key
}
return prefix + "/" + key
}
func defaultResizeImage(src image.Image, w, h int, mode string) image.Image {
switch strings.ToLower(mode) {
case "crop":
return imaging.Fill(src, w, h, imaging.Center, imaging.Lanczos)
case "exact":
return imaging.Resize(src, w, h, imaging.Lanczos)
default:
return imaging.Fit(src, w, h, imaging.Lanczos)
}
}
var resizeImage = defaultResizeImage
func defaultEncodeImage(w io.Writer, img image.Image, ext string) error {
format := imaging.JPEG
switch strings.ToLower(ext) {
case "png":
format = imaging.PNG
case "gif":
format = imaging.GIF
}
return imaging.Encode(w, img, format)
}
var encodeImage = defaultEncodeImage
// Thumb returns the public URL of a lazily generated thumbnail. The second
// call for the same dimensions hits the existing blob and does not resize.
func (f *File) Thumb(ctx context.Context, bucket *blob.Bucket, w, h int, mode string) (string, error) {
if f == nil {
return "", fmt.Errorf("attach: file is nil")
}
if bucket == nil {
return "", fmt.Errorf("attach: bucket is nil")
}
if mode == "" {
mode = "auto"
}
mode = strings.ToLower(mode)
if !thumbToken.MatchString(mode) {
return "", fmt.Errorf("attach: invalid thumb mode %q", mode)
}
if w <= 0 || h <= 0 || w > maxThumbEdge || h > maxThumbEdge {
return "", fmt.Errorf("attach: thumb size %dx%d is out of range", w, h)
}
ext := fileExt(f.DiskName)
if !thumbToken.MatchString(ext) {
return "", fmt.Errorf("attach: invalid thumb extension %q", ext)
}
thumbName := ThumbFilename(f.ID, w, h, 0, 0, mode, ext)
part := PartitionDirectory(f.DiskName)
thumbKey := part + thumbName
exists, err := bucket.Exists(ctx, thumbKey)
if err != nil {
return "", fmt.Errorf("attach: thumb exists: %w", err)
}
if exists {
return publicURL(thumbKey), nil
}
origKey := part + f.DiskName
r, err := bucket.NewReader(ctx, origKey, nil)
if err != nil {
return "", fmt.Errorf("attach: read original: %w", err)
}
src, _, err := image.Decode(io.LimitReader(r, maxThumbSourceBytes))
closeErr := r.Close()
if err != nil {
return "", fmt.Errorf("attach: decode original: %w", err)
}
if closeErr != nil {
return "", closeErr
}
bounds := src.Bounds()
if int64(bounds.Dx())*int64(bounds.Dy()) > maxThumbSourcePixels {
return "", fmt.Errorf("attach: original image is too large")
}
resized := resizeImage(src, w, h, mode)
contentType := "image/jpeg"
switch ext {
case "png":
contentType = "image/png"
case "gif":
contentType = "image/gif"
}
wr, err := bucket.NewWriter(ctx, thumbKey, &blob.WriterOptions{ContentType: contentType})
if err != nil {
return "", fmt.Errorf("attach: thumb writer: %w", err)
}
encErr := encodeImage(wr, resized, ext)
closeErr = wr.Close()
if encErr != nil || closeErr != nil {
_ = deleteKey(ctx, bucket, thumbKey)
if encErr != nil {
return "", encErr
}
return "", closeErr
}
return publicURL(thumbKey), nil
}

View File

@@ -0,0 +1,186 @@
package attach
import (
"bytes"
"errors"
"image"
"image/color"
"image/jpeg"
"io"
"strings"
"testing"
"gocloud.dev/blob"
"gocloud.dev/blob/memblob"
)
func TestThumbFilename(t *testing.T) {
got := ThumbFilename(42, 200, 200, 0, 0, "crop", "jpg")
const want = "thumb_42_200_200_0_0_crop.jpg"
if got != want {
t.Fatalf("ThumbFilename = %q, want %q", got, want)
}
}
func TestThumbFilenameRejectsUnsafeTokens(t *testing.T) {
for _, tc := range []struct{ mode, ext string }{
{"../../secret", "jpg"},
{"auto", "../jpg"},
{"a/b", "jpg"},
{"auto", "j.pg"},
{"", ""},
{"Crop", "JPG"},
} {
got := ThumbFilename(42, 200, 200, 0, 0, tc.mode, tc.ext)
if strings.ContainsAny(got, `/\`) || strings.Contains(got, "..") {
t.Fatalf("mode=%q ext=%q produced unsafe name %q", tc.mode, tc.ext, got)
}
if !strings.HasPrefix(got, "thumb_42_200_200_0_0_") {
t.Fatalf("mode=%q ext=%q name %q lost its prefix", tc.mode, tc.ext, got)
}
}
if got := ThumbFilename(42, 200, 200, 0, 0, "../../secret", "jpg"); got != "thumb_42_200_200_0_0_auto.jpg" {
t.Fatalf("unsafe mode coerced to %q", got)
}
}
func TestFileThumbRejectsOutOfRangeSize(t *testing.T) {
bucket := memblob.OpenBucket(nil)
defer bucket.Close()
f := &File{ID: 1, DiskName: "abc123xyz.jpg"}
for _, tc := range []struct{ w, h int }{
{0, 200},
{200, 0},
{-1, -1},
{maxThumbEdge + 1, 200},
{200, maxThumbEdge + 1},
{100000, 100000},
} {
if _, err := f.Thumb(t.Context(), bucket, tc.w, tc.h, "crop"); err == nil {
t.Fatalf("size %dx%d must be rejected", tc.w, tc.h)
}
}
iter := bucket.List(nil)
if obj, err := iter.Next(t.Context()); err != io.EOF {
t.Fatalf("rejected sizes must not touch the bucket, found %v (err %v)", obj, err)
}
}
func TestFileThumbRejectsTraversalMode(t *testing.T) {
bucket := memblob.OpenBucket(nil)
defer bucket.Close()
f := &File{ID: 42, DiskName: "abc123xyz789.jpg"}
for _, mode := range []string{"../../secret", "a/b", "auto.jpg", "cr op"} {
if _, err := f.Thumb(t.Context(), bucket, 200, 200, mode); err == nil {
t.Fatalf("mode %q must be rejected", mode)
}
}
iter := bucket.List(nil)
if obj, err := iter.Next(t.Context()); err != io.EOF {
t.Fatalf("rejected modes must write nothing, found %v (err %v)", obj, err)
}
}
func TestPartitionDirectory(t *testing.T) {
got := PartitionDirectory("abc123xyz.jpg")
const want = "abc/123/xyz/"
if got != want {
t.Fatalf("PartitionDirectory = %q, want %q", got, want)
}
}
func TestFileThumbResizesOnce(t *testing.T) {
ctx := t.Context()
bucket := memblob.OpenBucket(nil)
t.Cleanup(func() { _ = bucket.Close() })
f := &File{ID: 42, DiskName: "abc123xyz.jpg", FileName: "cover.jpg"}
origKey := PartitionDirectory(f.DiskName) + f.DiskName
if origKey == "abc123xyz.jpg" || origKey == "" {
// PartitionDirectory still stubbed — write under the Winter key the
// GREEN implementation will look up so this test stays the behavior spec.
origKey = "abc/123/xyz/" + f.DiskName
}
if err := bucket.WriteAll(ctx, origKey, testJPEG(t), &blob.WriterOptions{ContentType: "image/jpeg"}); err != nil {
t.Fatal(err)
}
var n int
orig := resizeImage
resizeImage = func(src image.Image, w, h int, mode string) image.Image {
n++
return orig(src, w, h, mode)
}
t.Cleanup(func() { resizeImage = orig })
url1, err := f.Thumb(ctx, bucket, 200, 200, "crop")
if err != nil {
t.Fatalf("first Thumb: %v", err)
}
url2, err := f.Thumb(ctx, bucket, 200, 200, "crop")
if err != nil {
t.Fatalf("second Thumb: %v", err)
}
if n != 1 {
t.Fatalf("resize calls = %d, want 1", n)
}
want := "/storage/uploads/abc/123/xyz/thumb_42_200_200_0_0_crop.jpg"
if url1 != want || url2 != want {
t.Fatalf("urls = %q %q, want %q", url1, url2, want)
}
}
func TestFileThumbEncodeFailureDoesNotCache(t *testing.T) {
ctx := t.Context()
bucket := memblob.OpenBucket(nil)
t.Cleanup(func() { _ = bucket.Close() })
f := &File{ID: 7, DiskName: "abc123xyz.jpg"}
if err := bucket.WriteAll(ctx, BlobKey(f.DiskName), testJPEG(t), &blob.WriterOptions{ContentType: "image/jpeg"}); err != nil {
t.Fatal(err)
}
orig := encodeImage
encodeImage = func(w io.Writer, img image.Image, ext string) error {
_, _ = w.Write([]byte("partial"))
return errors.New("encode boom")
}
t.Cleanup(func() { encodeImage = orig })
if _, err := f.Thumb(ctx, bucket, 200, 200, "crop"); err == nil {
t.Fatal("encode failure must surface")
}
thumbKey := PartitionDirectory(f.DiskName) + ThumbFilename(f.ID, 200, 200, 0, 0, "crop", "jpg")
exists, err := bucket.Exists(ctx, thumbKey)
if err != nil {
t.Fatal(err)
}
if exists {
t.Fatal("failed encode must not leave a cached thumb blob")
}
encodeImage = orig
url, err := f.Thumb(ctx, bucket, 200, 200, "crop")
if err != nil {
t.Fatalf("retry after failed encode: %v", err)
}
want := "/storage/uploads/abc/123/xyz/thumb_7_200_200_0_0_crop.jpg"
if url != want {
t.Fatalf("retry url = %q, want %q", url, want)
}
}
func testJPEG(t *testing.T) []byte {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, 8, 8))
for y := 0; y < 8; y++ {
for x := 0; x < 8; x++ {
img.Set(x, y, color.RGBA{R: 200, A: 255})
}
}
var buf bytes.Buffer
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 90}); err != nil {
t.Fatal(err)
}
return buf.Bytes()
}