diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..419535f --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,77 @@ +# Push to main → rsync this repo into arkieproject/backend on ark-library-backend-1, rebuild api. +# +# Gitea repo secrets (Settings → Actions → Secrets): +# DEPLOY_SSH_KEY — PEM private key for ec2-user@ark-library-backend-1 (no passphrase) +# +# Optional secrets (override defaults): +# DEPLOY_HOST — SSH host (default ark-library-backend-1; use IP/hostname if the runner has no ~/.ssh/config alias) +# DEPLOY_USER — default ec2-user +# REMOTE_REPO — default /home/ec2-user/arkieproject +# +# Runner must reach the host (Tailscale, VPC, or public IP). Add the matching public key to authorized_keys on the server. + +name: Deploy API + +on: + push: + branches: [main] + workflow_dispatch: + +env: + DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} + DEPLOY_USER: ${{ secrets.DEPLOY_USER }} + REMOTE_REPO: ${{ secrets.REMOTE_REPO }} + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Resolve deploy target + run: | + echo "DEPLOY_HOST=${DEPLOY_HOST:-ark-library-backend-1}" >> "$GITHUB_ENV" + echo "DEPLOY_USER=${DEPLOY_USER:-ec2-user}" >> "$GITHUB_ENV" + echo "REMOTE_REPO=${REMOTE_REPO:-/home/ec2-user/arkieproject}" >> "$GITHUB_ENV" + + - name: Configure SSH + env: + DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }} + run: | + if [[ -z "${DEPLOY_SSH_KEY}" ]]; then + echo "Missing repository secret DEPLOY_SSH_KEY" >&2 + exit 1 + fi + install -d -m 700 ~/.ssh + printf '%s\n' "${DEPLOY_SSH_KEY}" > ~/.ssh/deploy_key + chmod 600 ~/.ssh/deploy_key + ssh-keyscan -H "${DEPLOY_HOST}" >> ~/.ssh/known_hosts 2>/dev/null || true + + - name: Rsync backend sources + run: | + REMOTE_BACKEND="${REMOTE_REPO}/backend/" + rsync -avz --delete \ + --exclude '.git' \ + --exclude 'uploads' \ + --exclude '.env' \ + --exclude '.env.*' \ + --exclude '.DS_Store' \ + -e "ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=accept-new -o ConnectTimeout=30" \ + ./ "${DEPLOY_USER}@${DEPLOY_HOST}:${REMOTE_BACKEND}" + + - name: Rebuild and restart API container + run: | + ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=accept-new -o ConnectTimeout=30 \ + "${DEPLOY_USER}@${DEPLOY_HOST}" bash -s <&2 + exit 1 + fi + DC='sudo docker compose -f deploy/docker-compose.admin.yml --env-file .env' + \${DC} build api + \${DC} up -d --no-deps api + \${DC} ps api + REMOTE diff --git a/cmd/server/main.go b/cmd/server/main.go index ebb5bc9..2be9fd6 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -73,6 +73,7 @@ func main() { AWSRegion: cfg.AWSRegion, S3Prefix: cfg.S3UploadPrefix, S3PublicBase: cfg.S3PublicBase, + S3ObjectACL: cfg.S3ObjectACL, } r := chi.NewRouter() diff --git a/internal/config/config.go b/internal/config/config.go index 9a9792e..d203eeb 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -24,6 +24,8 @@ type Config struct { AWSRegion string S3UploadPrefix string S3PublicBase string // optional CDN / website endpoint base URL for returned object URLs + // S3ObjectACL: optional canned ACL on PutObject (e.g. public-read). Empty = omit ACL (required for Bucket owner enforced buckets). + S3ObjectACL string } func Load() Config { @@ -74,6 +76,7 @@ func Load() Config { } s3Prefix = strings.Trim(s3Prefix, "/") s3Public := strings.TrimSpace(os.Getenv("S3_PUBLIC_BASE_URL")) + s3ObjectACL := strings.TrimSpace(os.Getenv("S3_OBJECT_ACL")) return Config{ Addr: addr, DatabaseURL: db, @@ -89,6 +92,7 @@ func Load() Config { AWSRegion: awsRegion, S3UploadPrefix: s3Prefix, S3PublicBase: s3Public, + S3ObjectACL: s3ObjectACL, } } diff --git a/internal/handlers/upload.go b/internal/handlers/upload.go index a748071..e91006a 100644 --- a/internal/handlers/upload.go +++ b/internal/handlers/upload.go @@ -11,6 +11,7 @@ import ( "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/aws/aws-sdk-go-v2/service/s3/types" "github.com/google/uuid" ) @@ -25,6 +26,7 @@ type UploadDeps struct { AWSRegion string S3Prefix string // e.g. "uploads" (no leading/trailing slashes) S3PublicBase string // optional, e.g. https://cdn.example.com — else virtual-hosted S3 URL + S3ObjectACL string // optional canned ACL, e.g. public-read (bucket must allow ACLs) } func UploadFile(d UploadDeps) http.HandlerFunc { @@ -71,12 +73,16 @@ func UploadFile(d UploadDeps) http.HandlerFunc { } key := pfx + "/" + name ctx := r.Context() - _, err := d.S3.PutObject(ctx, &s3.PutObjectInput{ + put := &s3.PutObjectInput{ Bucket: aws.String(d.S3Bucket), Key: aws.String(key), Body: bytes.NewReader(data), ContentType: aws.String(ct), - }) + } + if acl, ok := s3PutObjectCannedACL(d.S3ObjectACL); ok { + put.ACL = acl + } + _, err := d.S3.PutObject(ctx, put) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return @@ -105,6 +111,28 @@ func UploadFile(d UploadDeps) http.HandlerFunc { } } +// s3PutObjectCannedACL maps env S3_OBJECT_ACL to SDK enum; unknown values are ignored. +func s3PutObjectCannedACL(raw string) (types.ObjectCannedACL, bool) { + switch strings.TrimSpace(strings.ToLower(raw)) { + case "private": + return types.ObjectCannedACLPrivate, true + case "public-read": + return types.ObjectCannedACLPublicRead, true + case "public-read-write": + return types.ObjectCannedACLPublicReadWrite, true + case "authenticated-read": + return types.ObjectCannedACLAuthenticatedRead, true + case "bucket-owner-full-control": + return types.ObjectCannedACLBucketOwnerFullControl, true + case "bucket-owner-read": + return types.ObjectCannedACLBucketOwnerRead, true + case "aws-exec-read": + return types.ObjectCannedACLAwsExecRead, true + default: + return "", false + } +} + func publicObjectURL(base, bucket, region, key string) string { base = strings.TrimSpace(base) if base != "" {