Add photo galleries API and frontend for public and admin viewing.
Introduces the Go photo-api service, nginx/systemd deploy wiring, and Next.js gallery/lightbox pages so event photos can be managed and browsed. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// local stores objects under root (STORAGE_PATH, default ./data/photos).
|
||||
// Deliberately disjoint from backend/uploads; nothing here is ever served
|
||||
// statically — all reads go through the access-checked files handler.
|
||||
type local struct {
|
||||
root string
|
||||
}
|
||||
|
||||
func newLocal(root string) (*local, error) {
|
||||
if err := os.MkdirAll(root, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("create storage dir %s: %w", root, err)
|
||||
}
|
||||
return &local{root: root}, nil
|
||||
}
|
||||
|
||||
// path validates the key stays inside root (keys are generated internally,
|
||||
// but defense-in-depth costs one Clean call).
|
||||
func (l *local) path(key string) (string, error) {
|
||||
clean := filepath.Clean("/" + key)
|
||||
if strings.Contains(clean, "..") {
|
||||
return "", fmt.Errorf("invalid key %q", key)
|
||||
}
|
||||
return filepath.Join(l.root, clean), nil
|
||||
}
|
||||
|
||||
func (l *local) Put(_ context.Context, key string, r io.Reader, _ int64, _ string) error {
|
||||
dst, err := l.path(key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp, err := os.CreateTemp(filepath.Dir(dst), ".upload-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(tmp.Name())
|
||||
if _, err := io.Copy(tmp, r); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp.Name(), dst)
|
||||
}
|
||||
|
||||
func (l *local) Open(_ context.Context, key string) (io.ReadCloser, int64, error) {
|
||||
p, err := l.path(key)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
f, err := os.Open(p)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
f.Close()
|
||||
return nil, 0, err
|
||||
}
|
||||
return f, info.Size(), nil
|
||||
}
|
||||
|
||||
func (l *local) Delete(_ context.Context, key string) error {
|
||||
p, err := l.path(key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = os.Remove(p)
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (l *local) PresignGet(context.Context, string, string, string, time.Duration) (string, error) {
|
||||
return "", ErrNoPresign
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
awsconfig "github.com/aws/aws-sdk-go-v2/config"
|
||||
"github.com/aws/aws-sdk-go-v2/credentials"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
|
||||
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/config"
|
||||
)
|
||||
|
||||
// s3Store targets AWS S3 or path-style compatibles (Garage/MinIO), same as
|
||||
// the backend's S3 backend. The bucket is never public: downloads use
|
||||
// short-lived presigned GETs so visibility rules keep holding on S3.
|
||||
type s3Store struct {
|
||||
client *s3.Client
|
||||
presign *s3.PresignClient
|
||||
bucket string
|
||||
}
|
||||
|
||||
func newS3(cfg config.Config) (*s3Store, error) {
|
||||
awsCfg, err := awsconfig.LoadDefaultConfig(context.Background(),
|
||||
awsconfig.WithRegion(cfg.S3Region),
|
||||
awsconfig.WithCredentialsProvider(
|
||||
credentials.NewStaticCredentialsProvider(cfg.S3AccessKeyID, cfg.S3SecretKey, "")),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("s3 config: %w", err)
|
||||
}
|
||||
client := s3.NewFromConfig(awsCfg, func(o *s3.Options) {
|
||||
o.BaseEndpoint = aws.String(cfg.S3Endpoint)
|
||||
o.UsePathStyle = cfg.S3ForcePathStyle
|
||||
})
|
||||
return &s3Store{
|
||||
client: client,
|
||||
presign: s3.NewPresignClient(client),
|
||||
bucket: cfg.S3Bucket,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *s3Store) Put(ctx context.Context, key string, r io.Reader, size int64, contentType string) error {
|
||||
_, err := s.client.PutObject(ctx, &s3.PutObjectInput{
|
||||
Bucket: aws.String(s.bucket),
|
||||
Key: aws.String(key),
|
||||
Body: r,
|
||||
ContentLength: aws.Int64(size),
|
||||
ContentType: aws.String(contentType),
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *s3Store) Open(ctx context.Context, key string) (io.ReadCloser, int64, error) {
|
||||
out, err := s.client.GetObject(ctx, &s3.GetObjectInput{
|
||||
Bucket: aws.String(s.bucket),
|
||||
Key: aws.String(key),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return out.Body, aws.ToInt64(out.ContentLength), nil
|
||||
}
|
||||
|
||||
func (s *s3Store) Delete(ctx context.Context, key string) error {
|
||||
_, err := s.client.DeleteObject(ctx, &s3.DeleteObjectInput{
|
||||
Bucket: aws.String(s.bucket),
|
||||
Key: aws.String(key),
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *s3Store) PresignGet(ctx context.Context, key, downloadFilename, contentType string, expiry time.Duration) (string, error) {
|
||||
in := &s3.GetObjectInput{
|
||||
Bucket: aws.String(s.bucket),
|
||||
Key: aws.String(key),
|
||||
}
|
||||
if contentType != "" {
|
||||
in.ResponseContentType = aws.String(contentType)
|
||||
}
|
||||
if downloadFilename != "" {
|
||||
in.ResponseContentDisposition = aws.String(fmt.Sprintf("attachment; filename=%q", downloadFilename))
|
||||
}
|
||||
req, err := s.presign.PresignGetObject(ctx, in, s3.WithPresignExpires(expiry))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return req.URL, nil
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// Package storage abstracts photo object storage. Selection follows the
|
||||
// backend's convention (backend/src/lib/storage.ts): S3 when S3_ENDPOINT
|
||||
// and S3_BUCKET are both set, local disk otherwise. Keys are identical on
|
||||
// both backends: galleries/<galleryID>/<variantDir>/<photoID>.<ext>.
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/config"
|
||||
)
|
||||
|
||||
// ErrNoPresign is returned by backends that cannot presign (local disk);
|
||||
// callers then stream the object through the API instead.
|
||||
var ErrNoPresign = errors.New("presigned URLs not supported")
|
||||
|
||||
type Storage interface {
|
||||
Put(ctx context.Context, key string, r io.Reader, size int64, contentType string) error
|
||||
Open(ctx context.Context, key string) (io.ReadCloser, int64, error)
|
||||
Delete(ctx context.Context, key string) error
|
||||
PresignGet(ctx context.Context, key, downloadFilename, contentType string, expiry time.Duration) (string, error)
|
||||
}
|
||||
|
||||
func New(cfg config.Config) (Storage, error) {
|
||||
if cfg.S3Enabled() {
|
||||
return newS3(cfg)
|
||||
}
|
||||
return newLocal(cfg.StoragePath)
|
||||
}
|
||||
Reference in New Issue
Block a user