feat: implement shared-album multi-user photo album
Backend (Go/Gin/pgx): env config, pgx pool with retries, Garage S3 client (path-style, region garage), embedded golang-migrate migrations, JWT auth with refresh rotation, albums CRUD, members/roles/invites with no-escalation matrix, photo upload + async imaging/goexif WebP variants, cursor pagination, presigned URLs, likes/comments, SSE hub (album + personal), notifications, share links. Frontend (SvelteKit 2 + Svelte 5 + Tailwind v4): API wrapper with 401 refresh, auth store, all screens, Uploader/PhotoGrid/Lightbox/Comments, SSE client with reconnect + REST catch-up, three themes + system via data-theme. Deploy without Docker: Makefile builds the frontend into backend/static and embeds it (go:embed) into a single statically linked binary; systemd unit and nginx SSE example included. Use gen2brain/webp (-tags nodynamic) instead of cgo kolesa-team/go-webp to keep CGO_ENABLED=0 static builds; design.md/proposal.md updated accordingly.
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"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"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3/types"
|
||||
"github.com/aws/smithy-go"
|
||||
|
||||
"shared-album/internal/config"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
s3 *s3.Client
|
||||
presign *s3.PresignClient
|
||||
bucket string
|
||||
presignTTL time.Duration
|
||||
}
|
||||
|
||||
func New(ctx context.Context, cfg config.S3) (*Client, error) {
|
||||
awsCfg, err := awsconfig.LoadDefaultConfig(ctx,
|
||||
awsconfig.WithRegion(cfg.Region),
|
||||
awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(
|
||||
cfg.AccessKey, cfg.SecretKey, "",
|
||||
)),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load aws config: %w", err)
|
||||
}
|
||||
|
||||
client := s3.NewFromConfig(awsCfg, func(o *s3.Options) {
|
||||
o.BaseEndpoint = aws.String(cfg.Endpoint)
|
||||
o.UsePathStyle = cfg.UsePathStyle
|
||||
})
|
||||
|
||||
return &Client{
|
||||
s3: client,
|
||||
presign: s3.NewPresignClient(client),
|
||||
bucket: cfg.Bucket,
|
||||
presignTTL: cfg.PresignTTL,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Client) Bucket() string { return c.bucket }
|
||||
|
||||
func (c *Client) EnsureBucket(ctx context.Context) error {
|
||||
_, err := c.s3.HeadBucket(ctx, &s3.HeadBucketInput{Bucket: aws.String(c.bucket)})
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
var nf *types.NotFound
|
||||
if !errors.As(err, &nf) && !isNoSuchBucket(err) {
|
||||
return fmt.Errorf("head bucket: %w", err)
|
||||
}
|
||||
if _, err := c.s3.CreateBucket(ctx, &s3.CreateBucketInput{Bucket: aws.String(c.bucket)}); err != nil {
|
||||
return fmt.Errorf("create bucket: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) Ping(ctx context.Context) error {
|
||||
_, err := c.s3.HeadBucket(ctx, &s3.HeadBucketInput{Bucket: aws.String(c.bucket)})
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *Client) Put(ctx context.Context, key string, body io.Reader, size int64, contentType string) error {
|
||||
in := &s3.PutObjectInput{
|
||||
Bucket: aws.String(c.bucket),
|
||||
Key: aws.String(key),
|
||||
Body: body,
|
||||
ContentType: aws.String(contentType),
|
||||
}
|
||||
if size >= 0 {
|
||||
in.ContentLength = aws.Int64(size)
|
||||
}
|
||||
if _, err := c.s3.PutObject(ctx, in); err != nil {
|
||||
return fmt.Errorf("put object %s: %w", key, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) Delete(ctx context.Context, keys ...string) error {
|
||||
if len(keys) == 0 {
|
||||
return nil
|
||||
}
|
||||
objs := make([]types.ObjectIdentifier, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
objs = append(objs, types.ObjectIdentifier{Key: aws.String(k)})
|
||||
}
|
||||
if _, err := c.s3.DeleteObjects(ctx, &s3.DeleteObjectsInput{
|
||||
Bucket: aws.String(c.bucket),
|
||||
Delete: &types.Delete{Objects: objs, Quiet: aws.Bool(true)},
|
||||
}); err != nil {
|
||||
return fmt.Errorf("delete objects: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) DeletePrefix(ctx context.Context, prefix string) error {
|
||||
var keys []string
|
||||
paginator := s3.NewListObjectsV2Paginator(c.s3, &s3.ListObjectsV2Input{
|
||||
Bucket: aws.String(c.bucket),
|
||||
Prefix: aws.String(prefix),
|
||||
})
|
||||
for paginator.HasMorePages() {
|
||||
page, err := paginator.NextPage(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list objects: %w", err)
|
||||
}
|
||||
for _, o := range page.Contents {
|
||||
keys = append(keys, aws.ToString(o.Key))
|
||||
}
|
||||
}
|
||||
return c.Delete(ctx, keys...)
|
||||
}
|
||||
|
||||
func (c *Client) PresignGet(ctx context.Context, key string) (string, error) {
|
||||
out, err := c.presign.PresignGetObject(ctx, &s3.GetObjectInput{
|
||||
Bucket: aws.String(c.bucket),
|
||||
Key: aws.String(key),
|
||||
}, s3.WithPresignExpires(c.presignTTL))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("presign %s: %w", key, err)
|
||||
}
|
||||
return out.URL, nil
|
||||
}
|
||||
|
||||
func isNoSuchBucket(err error) bool {
|
||||
var apiErr smithy.APIError
|
||||
if errors.As(err, &apiErr) {
|
||||
code := apiErr.ErrorCode()
|
||||
return strings.EqualFold(code, "NoSuchBucket") || strings.EqualFold(code, "NotFound")
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user