package attach import ( "context" "fmt" "log/slog" "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" // S3BuildTag is the Go build tag that links the gocloud S3 driver // (s3blob, imported only by s3_on.go). List it under build.tags in summer.yaml so // summer build passes it to go build; a raw go build needs -tags summercms_s3. const S3BuildTag = "summercms_s3" var ( prefixMu sync.RWMutex publicPathPrefix = defaultPublicPathPrefix publicURLBase string ) func setPublicPathPrefix(prefix string) { prefixMu.Lock() publicPathPrefix = prefix prefixMu.Unlock() } func setPublicURLBase(base string) { prefixMu.Lock() publicURLBase = base prefixMu.Unlock() } // PublicURLBase is storage.uploads.public_url as recorded by OpenBucket: an // absolute http or https base (a public bucket or a CDN) that file and // thumbnail URLs start with instead of PublicPathPrefix. Empty when unset. func PublicURLBase() string { prefixMu.RLock() defer prefixMu.RUnlock() return publicURLBase } // parsePublicURL validates storage.uploads.public_url: empty means unset, // anything else must be an absolute http or https URL with a host and // without user info, query or fragment. func parsePublicURL(raw string) (*url.URL, error) { value := strings.TrimSpace(raw) if value == "" { return nil, nil } u, err := url.Parse(value) bad := err != nil || u.Host == "" || u.User != nil || u.RawQuery != "" || u.ForceQuery || u.Fragment != "" || (!strings.EqualFold(u.Scheme, "http") && !strings.EqualFold(u.Scheme, "https")) if bad { return nil, fmt.Errorf("attach: storage.uploads.public_url must be an absolute http or https URL such as https://cdn.example.com/uploads, without user info, query or fragment (set SUMMER_STORAGE__UPLOADS__PUBLIC_URL)") } return u, nil } // 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 } } // checkS3Driver refuses an s3:// bucket URL when the binary was built // without S3BuildTag, naming the tag and where to set it. func checkS3Driver(bucketURL string) error { if s3Enabled { return nil } scheme, _, ok := strings.Cut(bucketURL, "://") if !ok || !strings.EqualFold(scheme, "s3") { return nil } return fmt.Errorf("attach: storage.uploads.bucket_url uses s3:// but this binary was built without the %s build tag; "+ "list %s under build.tags in summer.yaml and rebuild with summer build (a raw go build needs -tags %s, or GOFLAGS=-tags=%s)", S3BuildTag, S3BuildTag, S3BuildTag, S3BuildTag) } // OpenBucket opens storage.uploads.bucket_url (file://, mem://, or s3:// in a // binary built with S3BuildTag) and records // storage.uploads.public_path_prefix and storage.uploads.public_url. An // empty bucket_url or a malformed public_url fails boot; a set public_url // logs one warning saying the bucket behind it must hold only public files // and that Store refuses protected uploads (ErrProtectedNeedsPrivateBucket). 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 } if err := checkS3Driver(bucketURL); err != nil { return nil, err } publicURL, err := parsePublicURL(cfg.String("storage.uploads.public_url")) 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) if publicURL == nil { setPublicURLBase("") return bucket, nil } setPublicURLBase(publicURL.String()) slog.Default().WarnContext(ctx, "attach: storage.uploads.public_url is set; every file and thumbnail URL points at this host, "+ "which serves any key without the is_public check, so the bucket behind it must hold only public files: "+ "protected (is_public false) uploads are refused", slog.String("host", publicURL.Host)) 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) }