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:
10
modules/lagoon/attach/app_test.go
Normal file
10
modules/lagoon/attach/app_test.go
Normal 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)
|
||||
}
|
||||
95
modules/lagoon/attach/bucket.go
Normal file
95
modules/lagoon/attach/bucket.go
Normal 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)
|
||||
}
|
||||
91
modules/lagoon/attach/bucket_test.go
Normal file
91
modules/lagoon/attach/bucket_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
191
modules/lagoon/attach/file.go
Normal file
191
modules/lagoon/attach/file.go
Normal 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)
|
||||
}
|
||||
102
modules/lagoon/attach/file_test.go
Normal file
102
modules/lagoon/attach/file_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
303
modules/lagoon/attach/lifecycle_test.go
Normal file
303
modules/lagoon/attach/lifecycle_test.go
Normal 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
|
||||
}
|
||||
49
modules/lagoon/attach/migrations.go
Normal file
49
modules/lagoon/attach/migrations.go
Normal 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
|
||||
},
|
||||
},
|
||||
}
|
||||
125
modules/lagoon/attach/static.go
Normal file
125
modules/lagoon/attach/static.go
Normal 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
|
||||
}
|
||||
174
modules/lagoon/attach/static_test.go
Normal file
174
modules/lagoon/attach/static_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
177
modules/lagoon/attach/thumb.go
Normal file
177
modules/lagoon/attach/thumb.go
Normal 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
|
||||
}
|
||||
186
modules/lagoon/attach/thumb_test.go
Normal file
186
modules/lagoon/attach/thumb_test.go
Normal 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()
|
||||
}
|
||||
Reference in New Issue
Block a user