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:
@@ -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 "" }
|
||||
|
||||
@@ -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
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user