feat(05-04): implement attach File, Thumb, blob bucket, system_files migration

- Winter-exact thumb filename and 3x3 partition with lazy imaging resize
- gocloud.dev/blob fileblob/memblob; empty bucket_url fails boot
- lagoon.Migrate runs system_files before every plugin set
This commit is contained in:
Jakub Zych
2026-09-18 19:55:15 +02:00
parent 2d84ae4565
commit ba9c99246e
6 changed files with 262 additions and 16 deletions

View File

@@ -3,6 +3,9 @@ package attach
import (
"context"
"fmt"
"net/url"
"strings"
"sync"
"git.golem15.com/golem15/summercms/backpack"
"git.golem15.com/golem15/summercms/compass"
@@ -11,12 +14,82 @@ import (
_ "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) {
return nil, fmt.Errorf("attach: bucket not implemented")
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 {
return fmt.Errorf("attach: publish not implemented")
if app == nil {
return fmt.Errorf("attach: app is nil")
}
if bucket == nil {
return fmt.Errorf("attach: bucket is nil")
}
return app.Publish(bucket)
}
func PublicPathPrefix() string { return "" }

View File

@@ -1,7 +1,49 @@
package attach
import "github.com/go-gormigrate/gormigrate/v2"
import (
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
// Migrations is the framework-owned system_files set. lagoon.Migrate
// runs it before every plugin set (D-14).
var Migrations = []*gormigrate.Migration{}
// 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

@@ -4,22 +4,137 @@ import (
"context"
"fmt"
"image"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"io"
"path"
"strings"
"github.com/disintegration/imaging"
"gocloud.dev/blob"
)
// ThumbFilename is Winter File::getThumbFilename: thumb_<id>_<w>_<h>_<ox>_<oy>_<mode>.<ext>.
func ThumbFilename(id uint, w, h int, offsetX, offsetY int, mode, ext string) string {
return ""
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 {
return ""
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 encodeImage(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)
}
// 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) {
return "", fmt.Errorf("attach: thumb not implemented")
}
var resizeImage = func(src image.Image, w, h int, mode string) image.Image {
return src
if f == nil {
return "", fmt.Errorf("attach: file is nil")
}
if bucket == nil {
return "", fmt.Errorf("attach: bucket is nil")
}
if mode == "" {
mode = "auto"
}
ext := fileExt(f.DiskName)
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(r)
closeErr := r.Close()
if err != nil {
return "", fmt.Errorf("attach: decode original: %w", err)
}
if closeErr != nil {
return "", closeErr
}
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 {
return "", encErr
}
if closeErr != nil {
return "", closeErr
}
return publicURL(thumbKey), nil
}

View File

@@ -6,6 +6,7 @@ import (
"strings"
"unicode"
"git.golem15.com/golem15/summercms/lagoon/attach"
"git.golem15.com/golem15/summercms/pact"
"git.golem15.com/golem15/summercms/party"
"github.com/go-gormigrate/gormigrate/v2"
@@ -56,11 +57,19 @@ func migrator(gdb *gorm.DB, pluginID string, migrations []*gormigrate.Migration)
}, migrations), nil
}
// Migrate runs each plugin's HasMigrations set in party.Activate order.
// Migrate runs the framework-owned system_files set first, then each
// plugin's HasMigrations set in party.Activate order.
func Migrate(gdb *gorm.DB, plugins []party.Plugin) error {
if gdb == nil {
return fmt.Errorf("lagoon: gorm db is nil")
}
m, err := migrator(gdb, "summercms.attach", attach.Migrations)
if err != nil {
return err
}
if err := m.Migrate(); err != nil {
return fmt.Errorf("lagoon: migrate system_files: %w", err)
}
for _, p := range plugins {
hm, ok := p.(pact.HasMigrations)
if !ok {