Move Spaces FromEnv into blob; Upload takes Object; drop logSpaces.

This commit is contained in:
2026-08-21 23:48:18 -07:00
parent 3eb75cfd80
commit a36bc723cc
4 changed files with 45 additions and 39 deletions
+1 -22
View File
@@ -25,9 +25,7 @@ func main() {
st := openStore() st := openStore()
defer st.Close() defer st.Close()
uploader := spacesUploader() uploader := blob.FromEnv()
logSpaces(uploader)
handler := newHandler(st, uploader) handler := newHandler(st, uploader)
run(&http.Server{Addr: listenAddr(), Handler: handler}) run(&http.Server{Addr: listenAddr(), Handler: handler})
} }
@@ -45,14 +43,6 @@ func openStore() *store.Store {
return st return st
} }
func logSpaces(uploader blob.Uploader) {
if uploader.Enabled() {
log.Printf("avatars: digitalocean spaces")
return
}
log.Printf("avatars: uploads disabled (set SPACES_* to enable)")
}
func newHandler(st *store.Store, uploader blob.Uploader) http.Handler { func newHandler(st *store.Store, uploader blob.Uploader) http.Handler {
srv, err := web.New(st, st.SessionStore(), plumber.TemplateFS, plumber.StaticFS, web.Config{ srv, err := web.New(st, st.SessionStore(), plumber.TemplateFS, plumber.StaticFS, web.Config{
AdminUsername: os.Getenv("ADMIN_USERNAME"), AdminUsername: os.Getenv("ADMIN_USERNAME"),
@@ -93,17 +83,6 @@ func run(httpSrv *http.Server) {
} }
} }
func spacesUploader() blob.Uploader {
return blob.NewSpaces(blob.SpacesConfig{
Key: os.Getenv("SPACES_KEY"),
Secret: os.Getenv("SPACES_SECRET"),
Region: os.Getenv("SPACES_REGION"),
Bucket: os.Getenv("SPACES_BUCKET"),
Endpoint: os.Getenv("SPACES_ENDPOINT"),
CDNBase: os.Getenv("SPACES_CDN_BASE"),
})
}
// listenAddr prefers PORT (App Platform / PaaS), then LISTEN, then :8080. // listenAddr prefers PORT (App Platform / PaaS), then LISTEN, then :8080.
func listenAddr() string { func listenAddr() string {
if p := strings.TrimSpace(os.Getenv("PORT")); p != "" { if p := strings.TrimSpace(os.Getenv("PORT")); p != "" {
+29 -8
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"fmt" "fmt"
"io" "io"
"os"
"strings" "strings"
"github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/aws"
@@ -15,7 +16,15 @@ import (
// Uploader stores public avatar objects. // Uploader stores public avatar objects.
type Uploader interface { type Uploader interface {
Enabled() bool Enabled() bool
Upload(ctx context.Context, key string, body io.Reader, contentType string, size int64) (publicURL string, err error) Upload(ctx context.Context, obj Object) (publicURL string, err error)
}
// Object is a file to upload to object storage.
type Object struct {
Key string
Body io.Reader
ContentType string
Size int64
} }
// Disabled is a no-op uploader used when Spaces is not configured. // Disabled is a no-op uploader used when Spaces is not configured.
@@ -38,10 +47,22 @@ type spaces struct {
func (Disabled) Enabled() bool { return false } func (Disabled) Enabled() bool { return false }
func (Disabled) Upload(context.Context, string, io.Reader, string, int64) (string, error) { func (Disabled) Upload(context.Context, Object) (string, error) {
return "", fmt.Errorf("avatar uploads are not configured") return "", fmt.Errorf("avatar uploads are not configured")
} }
// FromEnv builds an Uploader from SPACES_* environment variables.
func FromEnv() Uploader {
return NewSpaces(SpacesConfig{
Key: os.Getenv("SPACES_KEY"),
Secret: os.Getenv("SPACES_SECRET"),
Region: os.Getenv("SPACES_REGION"),
Bucket: os.Getenv("SPACES_BUCKET"),
Endpoint: os.Getenv("SPACES_ENDPOINT"),
CDNBase: os.Getenv("SPACES_CDN_BASE"),
})
}
// NewSpaces returns an Uploader when required env is present; otherwise Disabled. // NewSpaces returns an Uploader when required env is present; otherwise Disabled.
func NewSpaces(cfg SpacesConfig) Uploader { func NewSpaces(cfg SpacesConfig) Uploader {
cfg.Key = strings.TrimSpace(cfg.Key) cfg.Key = strings.TrimSpace(cfg.Key)
@@ -63,17 +84,17 @@ func NewSpaces(cfg SpacesConfig) Uploader {
func (s *spaces) Enabled() bool { return true } func (s *spaces) Enabled() bool { return true }
func (s *spaces) Upload(ctx context.Context, key string, body io.Reader, contentType string, size int64) (string, error) { func (s *spaces) Upload(ctx context.Context, obj Object) (string, error) {
key = strings.TrimPrefix(key, "/") key := strings.TrimPrefix(obj.Key, "/")
input := &s3.PutObjectInput{ input := &s3.PutObjectInput{
Bucket: aws.String(s.cfg.Bucket), Bucket: aws.String(s.cfg.Bucket),
Key: aws.String(key), Key: aws.String(key),
Body: body, Body: obj.Body,
ContentType: aws.String(contentType), ContentType: aws.String(obj.ContentType),
ACL: types.ObjectCannedACLPublicRead, ACL: types.ObjectCannedACLPublicRead,
} }
if size > 0 { if obj.Size > 0 {
input.ContentLength = aws.Int64(size) input.ContentLength = aws.Int64(obj.Size)
} }
if _, err := s.client.PutObject(ctx, input); err != nil { if _, err := s.client.PutObject(ctx, input); err != nil {
return "", err return "", err
+7 -1
View File
@@ -8,6 +8,7 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
"plumber/internal/blob"
"plumber/internal/geo" "plumber/internal/geo"
"plumber/internal/store" "plumber/internal/store"
) )
@@ -74,7 +75,12 @@ func (s *Server) handleProfile(w http.ResponseWriter, r *http.Request) {
} }
key := path.Join("avatars", u.ID, uuid.NewString()+ext) key := path.Join("avatars", u.ID, uuid.NewString()+ext)
limited := io.LimitReader(file, (2<<20)+1) limited := io.LimitReader(file, (2<<20)+1)
url, upErr := s.cfg.Blob.Upload(r.Context(), key, limited, contentType, hdr.Size) url, upErr := s.cfg.Blob.Upload(r.Context(), blob.Object{
Key: key,
Body: limited,
ContentType: contentType,
Size: hdr.Size,
})
if upErr != nil { if upErr != nil {
s.renderProfile(w, r, u, "Could not upload avatar. Try again later.", state) s.renderProfile(w, r, u, "Could not upload avatar. Try again later.", state)
return return
+4 -4
View File
@@ -3,7 +3,6 @@ package web
import ( import (
"bytes" "bytes"
"context" "context"
"io"
"mime/multipart" "mime/multipart"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
@@ -13,6 +12,7 @@ import (
"github.com/alexedwards/scs/v2" "github.com/alexedwards/scs/v2"
"plumber" "plumber"
"plumber/internal/blob"
) )
func newTestServer(t *testing.T) (*Server, *memDB, scs.Store) { func newTestServer(t *testing.T) (*Server, *memDB, scs.Store) {
@@ -296,10 +296,10 @@ type fakeBlob struct {
func (f *fakeBlob) Enabled() bool { return true } func (f *fakeBlob) Enabled() bool { return true }
func (f *fakeBlob) Upload(_ context.Context, key string, _ io.Reader, _ string, _ int64) (string, error) { func (f *fakeBlob) Upload(_ context.Context, obj blob.Object) (string, error) {
f.calls++ f.calls++
f.last = key f.last = obj.Key
return "https://cdn.example.com/" + key, nil return "https://cdn.example.com/" + obj.Key, nil
} }
func TestProfilePageAndState(t *testing.T) { func TestProfilePageAndState(t *testing.T) {