export Backend: - Add email verification code generation and verification endpoints - Add profile picture upload with S3 storage and image processing - Add deposit_required field to users with 48h advance booking requirement - Add loyalty stamps that accumulate on completed bookings - Auto-transition bookings: confirmed → in_progress → completed - Add booking cancellation handler with no-show detection - Add ICS calendar file download endpoint for bookings - Sync bookings to CalDAV on confirmation Frontend: - Add schedule page route - Add avatar and image-cropper UI components - Update shadcn-svelte components (button, dialog) - Add "Add to Calendar" button in booking modal Database: - Add verification_codes table - Add profile_pic_url, loyalty_stamps, deposits_required to users - Various schema updates
205 lines
4.9 KiB
Go
205 lines
4.9 KiB
Go
//go:build dev
|
|
// +build dev
|
|
|
|
package s3
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"os"
|
|
|
|
"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"
|
|
)
|
|
|
|
var Client Uploader
|
|
|
|
type Uploader interface {
|
|
Upload(ctx context.Context, bucket, key string, body io.Reader) error
|
|
Download(ctx context.Context, bucket, key string, w io.Writer) error
|
|
Delete(ctx context.Context, bucket, key string) error
|
|
GetURL(ctx context.Context, bucket, key string) (string, error)
|
|
}
|
|
|
|
type S3Client struct {
|
|
client *s3.Client
|
|
bucket string
|
|
publicURL string
|
|
}
|
|
|
|
func Connect() error {
|
|
// Check for RUSTFS_* vars first (matching compose.yml), fall back to S3_* vars
|
|
endpoint := os.Getenv("RUSTFS_ENDPOINT")
|
|
if endpoint == "" {
|
|
endpoint = os.Getenv("S3_ENDPOINT")
|
|
}
|
|
if endpoint == "" {
|
|
// Default: localhost for bare metal dev, use rustfs:9000 for docker
|
|
endpoint = "http://localhost:9000"
|
|
}
|
|
|
|
accessKey := os.Getenv("RUSTFS_ACCESS_KEY")
|
|
if accessKey == "" {
|
|
accessKey = os.Getenv("S3_ACCESS_KEY")
|
|
}
|
|
if accessKey == "" {
|
|
accessKey = "minioadmin"
|
|
}
|
|
|
|
secretKey := os.Getenv("RUSTFS_SECRET_KEY")
|
|
if secretKey == "" {
|
|
secretKey = os.Getenv("S3_SECRET_KEY")
|
|
}
|
|
if secretKey == "" {
|
|
secretKey = "minioadmin"
|
|
}
|
|
|
|
bucket := os.Getenv("RUSTFS_BUCKET")
|
|
if bucket == "" {
|
|
bucket = os.Getenv("S3_BUCKET")
|
|
}
|
|
if bucket == "" {
|
|
bucket = "crussell"
|
|
}
|
|
|
|
profilePicsBucket := os.Getenv("S3_PROFILE_PICS_BUCKET")
|
|
if profilePicsBucket == "" {
|
|
profilePicsBucket = "crussell-profile-pics"
|
|
}
|
|
|
|
region := os.Getenv("AWS_REGION")
|
|
if region == "" {
|
|
region = "eu-west-2"
|
|
}
|
|
|
|
publicURL := os.Getenv("S3_PUBLIC_URL")
|
|
if publicURL == "" {
|
|
publicURL = endpoint
|
|
}
|
|
|
|
awsCfg, err := awsconfig.LoadDefaultConfig(context.Background(),
|
|
awsconfig.WithRegion(region),
|
|
awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(
|
|
accessKey,
|
|
secretKey,
|
|
"",
|
|
)),
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to load AWS config: %w", err)
|
|
}
|
|
|
|
Client = &S3Client{
|
|
client: s3.NewFromConfig(awsCfg, func(o *s3.Options) {
|
|
o.BaseEndpoint = aws.String(endpoint)
|
|
o.UsePathStyle = true
|
|
}),
|
|
bucket: bucket,
|
|
publicURL: publicURL,
|
|
}
|
|
|
|
// Create bucket if it doesn't exist
|
|
ctx := context.Background()
|
|
s3Client := s3.NewFromConfig(awsCfg, func(o *s3.Options) {
|
|
o.BaseEndpoint = aws.String(endpoint)
|
|
o.UsePathStyle = true
|
|
})
|
|
_, err = s3Client.CreateBucket(ctx, &s3.CreateBucketInput{
|
|
Bucket: aws.String(bucket),
|
|
})
|
|
if err != nil {
|
|
log.Printf("Bucket creation: %v (may already exist)", err)
|
|
}
|
|
|
|
// Set bucket policy for public read access
|
|
policy := fmt.Sprintf(`{
|
|
"Version": "2012-10-17",
|
|
"Statement": [{
|
|
"Sid": "PublicReadGetObject",
|
|
"Effect": "Allow",
|
|
"Principal": "*",
|
|
"Action": "s3:GetObject",
|
|
"Resource": "arn:aws:s3:::%s/*"
|
|
}]
|
|
}`, bucket)
|
|
_, err = s3Client.PutBucketPolicy(ctx, &s3.PutBucketPolicyInput{
|
|
Bucket: aws.String(bucket),
|
|
Policy: aws.String(policy),
|
|
})
|
|
if err != nil {
|
|
log.Printf("Bucket policy: %v (may already exist)", err)
|
|
}
|
|
|
|
// Create profile pics bucket if it doesn't exist
|
|
if profilePicsBucket != bucket {
|
|
_, err = s3Client.CreateBucket(ctx, &s3.CreateBucketInput{
|
|
Bucket: aws.String(profilePicsBucket),
|
|
})
|
|
if err != nil {
|
|
log.Printf("Profile pics bucket creation: %v (may already exist)", err)
|
|
}
|
|
|
|
// Set bucket policy for public read access
|
|
profilePolicy := fmt.Sprintf(`{
|
|
"Version": "2012-10-17",
|
|
"Statement": [{
|
|
"Sid": "PublicReadGetObject",
|
|
"Effect": "Allow",
|
|
"Principal": "*",
|
|
"Action": "s3:GetObject",
|
|
"Resource": "arn:aws:s3:::%s/*"
|
|
}]
|
|
}`, profilePicsBucket)
|
|
_, err = s3Client.PutBucketPolicy(ctx, &s3.PutBucketPolicyInput{
|
|
Bucket: aws.String(profilePicsBucket),
|
|
Policy: aws.String(profilePolicy),
|
|
})
|
|
if err != nil {
|
|
log.Printf("Profile pics bucket policy: %v (may already exist)", err)
|
|
}
|
|
}
|
|
|
|
log.Printf("Connected to local S3 (Rustfs): bucket=%s, endpoint=%s", bucket, endpoint)
|
|
return nil
|
|
}
|
|
|
|
func (s *S3Client) Upload(ctx context.Context, bucket, key string, body io.Reader) error {
|
|
_, err := s.client.PutObject(ctx, &s3.PutObjectInput{
|
|
Bucket: aws.String(bucket),
|
|
Key: aws.String(key),
|
|
Body: body,
|
|
ContentType: aws.String("image/jpeg"),
|
|
})
|
|
return err
|
|
}
|
|
|
|
func (s *S3Client) Download(ctx context.Context, bucket, key string, w io.Writer) error {
|
|
result, err := s.client.GetObject(ctx, &s3.GetObjectInput{
|
|
Bucket: aws.String(bucket),
|
|
Key: aws.String(key),
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer result.Body.Close()
|
|
|
|
_, err = io.Copy(w, result.Body)
|
|
return err
|
|
}
|
|
|
|
func (s *S3Client) Delete(ctx context.Context, bucket, key string) error {
|
|
_, err := s.client.DeleteObject(ctx, &s3.DeleteObjectInput{
|
|
Bucket: aws.String(bucket),
|
|
Key: aws.String(key),
|
|
})
|
|
return err
|
|
}
|
|
|
|
func (s *S3Client) GetURL(ctx context.Context, bucket, key string) (string, error) {
|
|
return fmt.Sprintf("%s/%s/%s", s.publicURL, bucket, key), nil
|
|
}
|