From 617196629770137d2dd257bc24a79b05db90ebc5 Mon Sep 17 00:00:00 2001 From: Bahodir Rajabov Date: Thu, 16 Jul 2026 02:48:05 -0700 Subject: [PATCH 1/8] ops: deploy TryPost to AWS --- .github/workflows/deploy-aws.yml | 112 ++++++++++++++ .github/workflows/tests.yml | 1 + deploy/aws/Caddyfile | 7 + deploy/aws/README.md | 38 +++++ deploy/aws/backup.sh | 17 +++ deploy/aws/compose.yaml | 70 +++++++++ deploy/aws/remote-deploy.sh | 78 ++++++++++ deploy/aws/stack.yaml | 237 ++++++++++++++++++++++++++++++ deploy/aws/trypost-backup.service | 9 ++ deploy/aws/trypost-backup.timer | 10 ++ deploy/cloudflare/worker.ts | 14 ++ deploy/cloudflare/wrangler.jsonc | 15 ++ docker/Dockerfile | 2 +- docker/entrypoint.prod.sh | 45 ++++++ docker/supervisord.prod.conf | 17 ++- 15 files changed, 668 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/deploy-aws.yml create mode 100644 deploy/aws/Caddyfile create mode 100644 deploy/aws/README.md create mode 100755 deploy/aws/backup.sh create mode 100644 deploy/aws/compose.yaml create mode 100755 deploy/aws/remote-deploy.sh create mode 100644 deploy/aws/stack.yaml create mode 100644 deploy/aws/trypost-backup.service create mode 100644 deploy/aws/trypost-backup.timer create mode 100644 deploy/cloudflare/worker.ts create mode 100644 deploy/cloudflare/wrangler.jsonc create mode 100755 docker/entrypoint.prod.sh diff --git a/.github/workflows/deploy-aws.yml b/.github/workflows/deploy-aws.yml new file mode 100644 index 000000000..6d43253a9 --- /dev/null +++ b/.github/workflows/deploy-aws.yml @@ -0,0 +1,112 @@ +name: Deploy AWS + +on: + workflow_dispatch: + push: + branches: [main] + +concurrency: + group: trypost-production + cancel-in-progress: false + +permissions: + contents: read + id-token: write + +env: + AWS_REGION: us-east-1 + AWS_ROLE_ARN: arn:aws:iam::784713213970:role/trypost-deploy + ECR_REGISTRY: 784713213970.dkr.ecr.us-east-1.amazonaws.com + ECR_REPOSITORY: trypost/app + BACKUP_BUCKET: trypost-backups-784713213970-us-east-1 + +jobs: + validation: + uses: ./.github/workflows/tests.yml + + deploy: + needs: validation + runs-on: ubuntu-24.04-arm + steps: + - uses: actions/checkout@v7 + + - uses: aws-actions/configure-aws-credentials@v6.1.0 + with: + role-to-assume: ${{ env.AWS_ROLE_ARN }} + role-session-name: trypost-${{ github.run_id }} + aws-region: ${{ env.AWS_REGION }} + + - uses: aws-actions/amazon-ecr-login@v2 + + - uses: docker/setup-buildx-action@v3 + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + file: docker/Dockerfile + target: production + platforms: linux/arm64 + push: true + tags: ${{ env.ECR_REGISTRY }}/${{ env.ECR_REPOSITORY }}:${{ github.sha }} + build-args: | + VITE_REVERB_APP_KEY=trypost-reverb-key + VITE_REVERB_HOST=trypost.aidven.com + VITE_REVERB_PORT=443 + VITE_REVERB_SCHEME=https + cache-from: type=gha,scope=trypost-production-arm64 + cache-to: type=gha,mode=max,scope=trypost-production-arm64 + + - name: Upload deployment bundle + run: | + tar -czf /tmp/trypost-deploy.tar.gz -C deploy/aws \ + compose.yaml Caddyfile backup.sh remote-deploy.sh \ + trypost-backup.service trypost-backup.timer + aws s3 cp /tmp/trypost-deploy.tar.gz \ + "s3://${BACKUP_BUCKET}/releases/${GITHUB_SHA}/deploy.tar.gz" + + - name: Resolve production host + run: | + instance_id=$(aws ec2 describe-instances \ + --filters \ + Name=tag:Name,Values=trypost-production \ + Name=instance-state-name,Values=running \ + --query 'Reservations[0].Instances[0].InstanceId' \ + --output text) + test "$instance_id" != "None" + echo "INSTANCE_ID=$instance_id" >> "$GITHUB_ENV" + + - name: Deploy + run: | + image="${ECR_REGISTRY}/${ECR_REPOSITORY}:${GITHUB_SHA}" + bundle="s3://${BACKUP_BUCKET}/releases/${GITHUB_SHA}/deploy.tar.gz" + command="aws s3 cp '$bundle' /tmp/trypost-deploy.tar.gz && tar -xzf /tmp/trypost-deploy.tar.gz -C /opt/trypost && chmod +x /opt/trypost/remote-deploy.sh /opt/trypost/backup.sh && /opt/trypost/remote-deploy.sh '$image' 'trypost/production/env' '$BACKUP_BUCKET'" + parameters=$(jq -cn --arg command "$command" '{commands: [$command]}') + command_id=$(aws ssm send-command \ + --instance-ids "$INSTANCE_ID" \ + --document-name AWS-RunShellScript \ + --parameters "$parameters" \ + --query Command.CommandId \ + --output text) + aws ssm wait command-executed --command-id "$command_id" --instance-id "$INSTANCE_ID" + aws ssm get-command-invocation \ + --command-id "$command_id" \ + --instance-id "$INSTANCE_ID" \ + --query '{Status:Status,Output:StandardOutputContent,Error:StandardErrorContent}' + + - name: Verify + run: | + test "$(curl -fsS https://trypost.aidven.com/up)" = "Application up" + command_id=$(aws ssm send-command \ + --instance-ids "$INSTANCE_ID" \ + --document-name AWS-RunShellScript \ + --parameters '{"commands":["cat /opt/trypost/REVISION"]}' \ + --query Command.CommandId \ + --output text) + aws ssm wait command-executed --command-id "$command_id" --instance-id "$INSTANCE_ID" + revision=$(aws ssm get-command-invocation \ + --command-id "$command_id" \ + --instance-id "$INSTANCE_ID" \ + --query StandardOutputContent \ + --output text | tr -d '\n') + test "$revision" = "${ECR_REGISTRY}/${ECR_REPOSITORY}:${GITHUB_SHA}" diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 497efe6d7..dfc69063e 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,6 +1,7 @@ name: Tests on: + workflow_call: push: branches: [main, develop] pull_request: diff --git a/deploy/aws/Caddyfile b/deploy/aws/Caddyfile new file mode 100644 index 000000000..3b0c53832 --- /dev/null +++ b/deploy/aws/Caddyfile @@ -0,0 +1,7 @@ +:80 { + encode zstd gzip + reverse_proxy app:80 { + header_up Host {$APP_DOMAIN} + header_up X-Forwarded-Proto https + } +} diff --git a/deploy/aws/README.md b/deploy/aws/README.md new file mode 100644 index 000000000..58f2ffc68 --- /dev/null +++ b/deploy/aws/README.md @@ -0,0 +1,38 @@ +# AWS deployment + +TryPost runs on one ARM EC2 instance with Docker Compose. The host contains the +app, Postgres, Redis, queues, scheduler, Reverb, and Caddy. AWS Systems Manager +replaces SSH, Secrets Manager stores the production environment, and S3 keeps +30 days of nightly database and uploaded-file backups. + +Production URL: `https://trypost.aidven.com` + +## Provision + +Deploy `stack.yaml` in `us-east-1` with the latest Amazon Linux 2023 ARM AMI, +the default VPC, and a public subnet. Enable CloudFormation termination +protection after creation. + +Deploy `deploy/cloudflare/wrangler.jsonc` to publish `trypost.aidven.com` and +terminate public TLS at Cloudflare. The edge Worker forwards requests to the +fixed `PublicIp` stack output. Store the production dotenv file in the +`trypost/production/env` secret. + +## Release + +The `Deploy AWS` GitHub workflow runs the existing backend and browser tests, +builds the production ARM image, tags it with the commit SHA, pushes it to ECR, +runs migrations through SSM, updates Compose, and verifies both `/up` and the +exact deployed image. + +The first release may pass database and storage backup keys to +`remote-deploy.sh`. Later releases omit them; production data stays in the +named Docker volumes. + +## Restore + +Database backups are PostgreSQL custom-format dumps under `database/` in the +backup bucket. Uploaded files are gzip-compressed archives under `storage/`. +Restoring is explicit: stop the app, restore the selected database dump with +`pg_restore`, extract the matching storage archive into `trypost_storage`, then +run the current release normally. diff --git a/deploy/aws/backup.sh b/deploy/aws/backup.sh new file mode 100755 index 000000000..5db8ba12b --- /dev/null +++ b/deploy/aws/backup.sh @@ -0,0 +1,17 @@ +#!/bin/sh + +set -eu + +cd /opt/trypost + +timestamp=$(date -u +%Y-%m-%dT%H-%M-%SZ) +compose="docker compose --env-file .image" +db_username=$(sed -n 's/^DB_USERNAME=//p' .image) +db_database=$(sed -n 's/^DB_DATABASE=//p' .image) + +$compose exec -T pgsql \ + pg_dump -U "$db_username" -d "$db_database" -Fc \ + | aws s3 cp - "s3://${BACKUP_BUCKET}/database/${timestamp}.dump" + +tar -C /var/lib/docker/volumes/trypost_storage/_data -czf - . \ + | aws s3 cp - "s3://${BACKUP_BUCKET}/storage/${timestamp}.tar.gz" diff --git a/deploy/aws/compose.yaml b/deploy/aws/compose.yaml new file mode 100644 index 000000000..f62e8495b --- /dev/null +++ b/deploy/aws/compose.yaml @@ -0,0 +1,70 @@ +name: trypost + +services: + app: + image: ${IMAGE_URI} + restart: unless-stopped + env_file: .env + volumes: + - storage:/var/www/html/storage + depends_on: + pgsql: + condition: service_healthy + redis: + condition: service_healthy + logging: &logging + driver: json-file + options: + max-size: 10m + max-file: "3" + + pgsql: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_DB: ${DB_DATABASE} + POSTGRES_USER: ${DB_USERNAME} + POSTGRES_PASSWORD: ${DB_PASSWORD} + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: [CMD-SHELL, "pg_isready -U ${DB_USERNAME} -d ${DB_DATABASE}"] + interval: 5s + timeout: 5s + retries: 20 + logging: *logging + + redis: + image: redis:7-alpine + restart: unless-stopped + command: redis-server --appendonly yes + volumes: + - redisdata:/data + healthcheck: + test: [CMD, redis-cli, ping] + interval: 5s + timeout: 3s + retries: 20 + logging: *logging + + caddy: + image: caddy:2-alpine + restart: unless-stopped + environment: + APP_DOMAIN: ${APP_DOMAIN} + ports: + - "80:80" + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - caddy-data:/data + - caddy-config:/config + depends_on: + - app + logging: *logging + +volumes: + pgdata: + redisdata: + storage: + caddy-data: + caddy-config: diff --git a/deploy/aws/remote-deploy.sh b/deploy/aws/remote-deploy.sh new file mode 100755 index 000000000..a2fa26b39 --- /dev/null +++ b/deploy/aws/remote-deploy.sh @@ -0,0 +1,78 @@ +#!/bin/bash + +set -euo pipefail + +if [ "$#" -lt 3 ]; then + echo "usage: remote-deploy.sh IMAGE_URI SECRET_ID BACKUP_BUCKET [DATABASE_BACKUP_KEY] [STORAGE_BACKUP_KEY]" >&2 + exit 1 +fi + +image_uri=$1 +secret_id=$2 +backup_bucket=$3 +database_backup_key=${4:-} +storage_backup_key=${5:-} + +cd /opt/trypost + +aws secretsmanager get-secret-value \ + --secret-id "$secret_id" \ + --query SecretString \ + --output text > .env.new +chmod 600 .env.new +mv .env.new .env + +{ + printf 'IMAGE_URI=%s\n' "$image_uri" + sed -n '/^APP_DOMAIN=/p; /^DB_DATABASE=/p; /^DB_USERNAME=/p; /^DB_PASSWORD=/p' .env +} > .image +printf 'BACKUP_BUCKET=%s\n' "$backup_bucket" > .backup +chmod 600 .image .backup + +db_username=$(sed -n 's/^DB_USERNAME=//p' .image) +db_database=$(sed -n 's/^DB_DATABASE=//p' .image) + +aws ecr get-login-password --region us-east-1 \ + | docker login --username AWS --password-stdin "${image_uri%%/*}" + +docker compose --env-file .image pull +docker compose --env-file .image up -d pgsql redis + +if [ ! -f .database-restored ] && [ -n "$database_backup_key" ]; then + aws s3 cp "s3://${backup_bucket}/${database_backup_key}" /tmp/trypost.dump + docker compose --env-file .image exec -T pgsql \ + pg_restore \ + -U "$db_username" \ + -d "$db_database" \ + --clean \ + --if-exists \ + --no-owner \ + --exit-on-error < /tmp/trypost.dump + rm /tmp/trypost.dump + touch .database-restored +fi + +if [ ! -f .storage-restored ] && [ -n "$storage_backup_key" ]; then + aws s3 cp "s3://${backup_bucket}/${storage_backup_key}" /tmp/trypost-storage.tar.gz + docker volume create trypost_storage >/dev/null + storage_volume=$(docker volume inspect trypost_storage --format '{{.Mountpoint}}') + tar -xzf /tmp/trypost-storage.tar.gz -C "$storage_volume" + rm /tmp/trypost-storage.tar.gz + touch .storage-restored +fi + +docker compose --env-file .image run --rm --no-deps --entrypoint php app \ + artisan migrate --force +docker compose --env-file .image up -d --remove-orphans +docker compose --env-file .image exec -T app curl -fsS http://127.0.0.1/up >/dev/null + +install -m 0755 backup.sh /opt/trypost/backup.sh +install -m 0644 trypost-backup.service /etc/systemd/system/trypost-backup.service +install -m 0644 trypost-backup.timer /etc/systemd/system/trypost-backup.timer +systemctl daemon-reload +systemctl enable --now trypost-backup.timer + +printf '%s\n' "$image_uri" > REVISION +docker image prune -f >/dev/null + +echo "deployed $image_uri" diff --git a/deploy/aws/stack.yaml b/deploy/aws/stack.yaml new file mode 100644 index 000000000..128fb130a --- /dev/null +++ b/deploy/aws/stack.yaml @@ -0,0 +1,237 @@ +AWSTemplateFormatVersion: "2010-09-09" +Description: Minimal single-host TryPost production stack + +Parameters: + AmiId: + Type: AWS::EC2::Image::Id + VpcId: + Type: AWS::EC2::VPC::Id + SubnetId: + Type: AWS::EC2::Subnet::Id + InstanceType: + Type: String + Default: t4g.small + AllowedValues: [t4g.small, t4g.medium] + GitHubRepository: + Type: String + Default: trypostit/trypost + +Resources: + Repository: + Type: AWS::ECR::Repository + DeletionPolicy: Retain + UpdateReplacePolicy: Retain + Properties: + RepositoryName: trypost/app + ImageScanningConfiguration: + ScanOnPush: true + LifecyclePolicy: + LifecyclePolicyText: >- + {"rules":[{"rulePriority":1,"description":"Keep 20 releases","selection":{"tagStatus":"any","countType":"imageCountMoreThan","countNumber":20},"action":{"type":"expire"}}]} + + BackupBucket: + Type: AWS::S3::Bucket + DeletionPolicy: Retain + UpdateReplacePolicy: Retain + Properties: + BucketName: !Sub trypost-backups-${AWS::AccountId}-${AWS::Region} + BucketEncryption: + ServerSideEncryptionConfiguration: + - ServerSideEncryptionByDefault: + SSEAlgorithm: AES256 + PublicAccessBlockConfiguration: + BlockPublicAcls: true + BlockPublicPolicy: true + IgnorePublicAcls: true + RestrictPublicBuckets: true + LifecycleConfiguration: + Rules: + - Id: RetainBackupsFor30Days + Status: Enabled + ExpirationInDays: 30 + NoncurrentVersionExpiration: + NoncurrentDays: 7 + VersioningConfiguration: + Status: Enabled + + AppEnvironment: + Type: AWS::SecretsManager::Secret + DeletionPolicy: Retain + UpdateReplacePolicy: Retain + Properties: + Name: trypost/production/env + Description: TryPost production dotenv file + + InstanceSecurityGroup: + Type: AWS::EC2::SecurityGroup + Properties: + GroupDescription: Public HTTP and HTTPS for TryPost + VpcId: !Ref VpcId + SecurityGroupIngress: + - IpProtocol: tcp + FromPort: 80 + ToPort: 80 + CidrIp: 0.0.0.0/0 + + InstanceRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: Allow + Principal: + Service: ec2.amazonaws.com + Action: sts:AssumeRole + ManagedPolicyArns: + - arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore + Policies: + - PolicyName: TryPostRuntime + PolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: Allow + Action: ecr:GetAuthorizationToken + Resource: "*" + - Effect: Allow + Action: + - ecr:BatchCheckLayerAvailability + - ecr:BatchGetImage + - ecr:GetDownloadUrlForLayer + Resource: !GetAtt Repository.Arn + - Effect: Allow + Action: secretsmanager:GetSecretValue + Resource: !Ref AppEnvironment + - Effect: Allow + Action: + - s3:GetObject + - s3:PutObject + - s3:AbortMultipartUpload + Resource: !Sub ${BackupBucket.Arn}/* + + InstanceProfile: + Type: AWS::IAM::InstanceProfile + Properties: + Roles: [!Ref InstanceRole] + + Instance: + Type: AWS::EC2::Instance + Properties: + ImageId: !Ref AmiId + InstanceType: !Ref InstanceType + IamInstanceProfile: !Ref InstanceProfile + SubnetId: !Ref SubnetId + SecurityGroupIds: [!Ref InstanceSecurityGroup] + MetadataOptions: + HttpEndpoint: enabled + HttpTokens: required + BlockDeviceMappings: + - DeviceName: /dev/xvda + Ebs: + DeleteOnTermination: true + Encrypted: true + VolumeSize: 60 + VolumeType: gp3 + Tags: + - Key: Name + Value: trypost-production + UserData: + Fn::Base64: !Sub | + #!/bin/bash + set -euo pipefail + dnf install -y docker jq + systemctl enable --now docker + usermod -aG docker ec2-user + mkdir -p /usr/local/lib/docker/cli-plugins /opt/trypost + curl -fsSL https://github.com/docker/compose/releases/download/v2.40.3/docker-compose-linux-aarch64 \ + -o /usr/local/lib/docker/cli-plugins/docker-compose + chmod +x /usr/local/lib/docker/cli-plugins/docker-compose + fallocate -l 2G /swapfile + chmod 600 /swapfile + mkswap /swapfile + swapon /swapfile + echo '/swapfile none swap sw 0 0' >> /etc/fstab + + ElasticIp: + Type: AWS::EC2::EIP + Properties: + Domain: vpc + InstanceId: !Ref Instance + + GitHubDeployRole: + Type: AWS::IAM::Role + Properties: + RoleName: trypost-deploy + AssumeRolePolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: Allow + Principal: + Federated: !Sub arn:aws:iam::${AWS::AccountId}:oidc-provider/token.actions.githubusercontent.com + Action: sts:AssumeRoleWithWebIdentity + Condition: + StringEquals: + token.actions.githubusercontent.com:aud: sts.amazonaws.com + StringLike: + token.actions.githubusercontent.com:sub: !Sub repo:${GitHubRepository}:ref:refs/heads/main + Policies: + - PolicyName: DeployTryPost + PolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: Allow + Action: ecr:GetAuthorizationToken + Resource: "*" + - Effect: Allow + Action: + - ecr:BatchCheckLayerAvailability + - ecr:CompleteLayerUpload + - ecr:GetDownloadUrlForLayer + - ecr:InitiateLayerUpload + - ecr:PutImage + - ecr:UploadLayerPart + Resource: !GetAtt Repository.Arn + - Effect: Allow + Action: s3:PutObject + Resource: !Sub ${BackupBucket.Arn}/releases/* + - Effect: Allow + Action: ssm:SendCommand + Resource: + - !Sub arn:aws:ssm:${AWS::Region}::document/AWS-RunShellScript + - !Sub arn:aws:ec2:${AWS::Region}:${AWS::AccountId}:instance/${Instance} + - Effect: Allow + Action: ssm:GetCommandInvocation + Resource: "*" + - Effect: Allow + Action: ec2:DescribeInstances + Resource: "*" + + InstanceStatusAlarm: + Type: AWS::CloudWatch::Alarm + Properties: + AlarmDescription: TryPost host failed an EC2 status check + Namespace: AWS/EC2 + MetricName: StatusCheckFailed + Dimensions: + - Name: InstanceId + Value: !Ref Instance + Statistic: Maximum + Period: 60 + EvaluationPeriods: 2 + Threshold: 0 + ComparisonOperator: GreaterThanThreshold + TreatMissingData: breaching + +Outputs: + PublicIp: + Value: !Ref ElasticIp + InstanceId: + Value: !Ref Instance + RepositoryUri: + Value: !GetAtt Repository.RepositoryUri + BackupBucket: + Value: !Ref BackupBucket + AppEnvironmentSecret: + Value: !Ref AppEnvironment + GitHubDeployRole: + Value: !GetAtt GitHubDeployRole.Arn diff --git a/deploy/aws/trypost-backup.service b/deploy/aws/trypost-backup.service new file mode 100644 index 000000000..a877dd7f2 --- /dev/null +++ b/deploy/aws/trypost-backup.service @@ -0,0 +1,9 @@ +[Unit] +Description=Back up TryPost database and storage +Requires=docker.service +After=docker.service + +[Service] +Type=oneshot +EnvironmentFile=/opt/trypost/.backup +ExecStart=/opt/trypost/backup.sh diff --git a/deploy/aws/trypost-backup.timer b/deploy/aws/trypost-backup.timer new file mode 100644 index 000000000..6d085363b --- /dev/null +++ b/deploy/aws/trypost-backup.timer @@ -0,0 +1,10 @@ +[Unit] +Description=Back up TryPost every night + +[Timer] +OnCalendar=*-*-* 04:00:00 UTC +Persistent=true +RandomizedDelaySec=15m + +[Install] +WantedBy=timers.target diff --git a/deploy/cloudflare/worker.ts b/deploy/cloudflare/worker.ts new file mode 100644 index 000000000..2b5586351 --- /dev/null +++ b/deploy/cloudflare/worker.ts @@ -0,0 +1,14 @@ +interface Env { + ORIGIN_IP: string; +} + +export default { + fetch(request: Request, env: Env): Promise { + const url = new URL(request.url); + url.protocol = 'http:'; + url.hostname = env.ORIGIN_IP; + url.port = '80'; + + return fetch(new Request(url, request)); + }, +} satisfies ExportedHandler; diff --git a/deploy/cloudflare/wrangler.jsonc b/deploy/cloudflare/wrangler.jsonc new file mode 100644 index 000000000..238dc70e0 --- /dev/null +++ b/deploy/cloudflare/wrangler.jsonc @@ -0,0 +1,15 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "trypost-edge", + "main": "worker.ts", + "compatibility_date": "2026-07-16", + "routes": [ + { + "pattern": "trypost.aidven.com", + "custom_domain": true + } + ], + "vars": { + "ORIGIN_IP": "52.7.162.246" + } +} diff --git a/docker/Dockerfile b/docker/Dockerfile index 3844c9d7c..c327f2830 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -183,7 +183,7 @@ FROM system-base AS production COPY docker/nginx.conf /etc/nginx/http.d/default.conf COPY docker/php.prod.ini /usr/local/etc/php/conf.d/99-trypost.ini COPY docker/supervisord.prod.conf /etc/supervisor/conf.d/supervisord.conf -COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh +COPY docker/entrypoint.prod.sh /usr/local/bin/entrypoint.sh RUN chmod +x /usr/local/bin/entrypoint.sh # Application source + generated wayfinder TS + built assets — all from diff --git a/docker/entrypoint.prod.sh b/docker/entrypoint.prod.sh new file mode 100755 index 000000000..61cf921e1 --- /dev/null +++ b/docker/entrypoint.prod.sh @@ -0,0 +1,45 @@ +#!/bin/sh + +set -eu + +cd /var/www/html + +echo "[entrypoint] waiting for postgres" +attempt=0 +until pg_isready \ + -h "${DB_HOST:-pgsql}" \ + -p "${DB_PORT:-5432}" \ + -U "${DB_USERNAME}" \ + -d "${DB_DATABASE}" >/dev/null 2>&1; do + attempt=$((attempt + 1)) + if [ "$attempt" -eq 60 ]; then + echo "[entrypoint] postgres did not become ready" >&2 + exit 1 + fi + sleep 1 +done + +mkdir -p \ + storage/app/public \ + storage/framework/cache/data \ + storage/framework/sessions \ + storage/framework/views \ + storage/logs \ + bootstrap/cache + +if [ ! -L public/storage ]; then + php artisan storage:link --force +fi + +if [ ! -f storage/oauth-private.key ]; then + php artisan passport:keys --force +fi + +php artisan config:cache +php artisan route:cache +php artisan view:cache +php artisan event:cache + +chown -R www-data:www-data storage bootstrap/cache + +exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf diff --git a/docker/supervisord.prod.conf b/docker/supervisord.prod.conf index 95e29ddb8..96ef1557c 100644 --- a/docker/supervisord.prod.conf +++ b/docker/supervisord.prod.conf @@ -36,12 +36,23 @@ stdout_logfile_maxbytes=0 stderr_logfile=/dev/stderr stderr_logfile_maxbytes=0 -[program:horizon] -command=php /var/www/html/artisan horizon +[program:queue-critical] +command=php /var/www/html/artisan queue:work redis --queue=default,posthog,broadcasts,social-linkedin,social-linkedin-page,social-x,social-tiktok,social-youtube,social-facebook,social-instagram,social-instagram-facebook,social-threads,social-pinterest,social-bluesky,social-mastodon,social-telegram,social-discord --sleep=1 --tries=1 --timeout=630 --max-time=3600 --memory=256 autostart=true autorestart=true priority=30 -stopwaitsecs=10 +stopwaitsecs=640 +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 + +[program:queue-background] +command=php /var/www/html/artisan queue:work redis --queue=ai,automations --sleep=1 --tries=1 --timeout=930 --max-time=3600 --memory=512 +autostart=true +autorestart=true +priority=30 +stopwaitsecs=940 stdout_logfile=/dev/stdout stdout_logfile_maxbytes=0 stderr_logfile=/dev/stderr From 2464fffb150341c01bf849a009aacd7fb87e98e9 Mon Sep 17 00:00:00 2001 From: Bahodir Rajabov Date: Thu, 16 Jul 2026 02:53:40 -0700 Subject: [PATCH 2/8] fix: wait for production health --- deploy/aws/remote-deploy.sh | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/deploy/aws/remote-deploy.sh b/deploy/aws/remote-deploy.sh index a2fa26b39..c67ef613e 100755 --- a/deploy/aws/remote-deploy.sh +++ b/deploy/aws/remote-deploy.sh @@ -63,8 +63,7 @@ fi docker compose --env-file .image run --rm --no-deps --entrypoint php app \ artisan migrate --force -docker compose --env-file .image up -d --remove-orphans -docker compose --env-file .image exec -T app curl -fsS http://127.0.0.1/up >/dev/null +docker compose --env-file .image up -d --remove-orphans --wait --wait-timeout 120 install -m 0755 backup.sh /opt/trypost/backup.sh install -m 0644 trypost-backup.service /etc/systemd/system/trypost-backup.service From e8273c1fe8b42468418d8d9884adf1e8cde23e0c Mon Sep 17 00:00:00 2001 From: Bahodir Rajabov Date: Thu, 16 Jul 2026 02:55:24 -0700 Subject: [PATCH 3/8] fix: make production deploy repeatable --- deploy/aws/compose.yaml | 2 ++ deploy/aws/remote-deploy.sh | 3 +-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/deploy/aws/compose.yaml b/deploy/aws/compose.yaml index f62e8495b..e04e73347 100644 --- a/deploy/aws/compose.yaml +++ b/deploy/aws/compose.yaml @@ -66,5 +66,7 @@ volumes: pgdata: redisdata: storage: + external: true + name: trypost_storage caddy-data: caddy-config: diff --git a/deploy/aws/remote-deploy.sh b/deploy/aws/remote-deploy.sh index c67ef613e..50266fbec 100755 --- a/deploy/aws/remote-deploy.sh +++ b/deploy/aws/remote-deploy.sh @@ -35,6 +35,7 @@ db_database=$(sed -n 's/^DB_DATABASE=//p' .image) aws ecr get-login-password --region us-east-1 \ | docker login --username AWS --password-stdin "${image_uri%%/*}" +docker volume create trypost_storage >/dev/null docker compose --env-file .image pull docker compose --env-file .image up -d pgsql redis @@ -54,7 +55,6 @@ fi if [ ! -f .storage-restored ] && [ -n "$storage_backup_key" ]; then aws s3 cp "s3://${backup_bucket}/${storage_backup_key}" /tmp/trypost-storage.tar.gz - docker volume create trypost_storage >/dev/null storage_volume=$(docker volume inspect trypost_storage --format '{{.Mountpoint}}') tar -xzf /tmp/trypost-storage.tar.gz -C "$storage_volume" rm /tmp/trypost-storage.tar.gz @@ -65,7 +65,6 @@ docker compose --env-file .image run --rm --no-deps --entrypoint php app \ artisan migrate --force docker compose --env-file .image up -d --remove-orphans --wait --wait-timeout 120 -install -m 0755 backup.sh /opt/trypost/backup.sh install -m 0644 trypost-backup.service /etc/systemd/system/trypost-backup.service install -m 0644 trypost-backup.timer /etc/systemd/system/trypost-backup.timer systemctl daemon-reload From 508063aee4ec78794c5bb531f1dd11e62eb5bba8 Mon Sep 17 00:00:00 2001 From: Bahodir Rajabov Date: Thu, 16 Jul 2026 02:58:27 -0700 Subject: [PATCH 4/8] refactor: use direct production TLS --- deploy/aws/Caddyfile | 7 ++----- deploy/aws/README.md | 7 +++---- deploy/aws/compose.yaml | 2 ++ deploy/aws/stack.yaml | 8 ++++++++ deploy/cloudflare/worker.ts | 14 -------------- deploy/cloudflare/wrangler.jsonc | 15 --------------- 6 files changed, 15 insertions(+), 38 deletions(-) delete mode 100644 deploy/cloudflare/worker.ts delete mode 100644 deploy/cloudflare/wrangler.jsonc diff --git a/deploy/aws/Caddyfile b/deploy/aws/Caddyfile index 3b0c53832..be73750af 100644 --- a/deploy/aws/Caddyfile +++ b/deploy/aws/Caddyfile @@ -1,7 +1,4 @@ -:80 { +{$APP_DOMAIN} { encode zstd gzip - reverse_proxy app:80 { - header_up Host {$APP_DOMAIN} - header_up X-Forwarded-Proto https - } + reverse_proxy app:80 } diff --git a/deploy/aws/README.md b/deploy/aws/README.md index 58f2ffc68..46c7c37e7 100644 --- a/deploy/aws/README.md +++ b/deploy/aws/README.md @@ -13,10 +13,9 @@ Deploy `stack.yaml` in `us-east-1` with the latest Amazon Linux 2023 ARM AMI, the default VPC, and a public subnet. Enable CloudFormation termination protection after creation. -Deploy `deploy/cloudflare/wrangler.jsonc` to publish `trypost.aidven.com` and -terminate public TLS at Cloudflare. The edge Worker forwards requests to the -fixed `PublicIp` stack output. Store the production dotenv file in the -`trypost/production/env` secret. +Create a DNS-only Cloudflare `A` record from `trypost.aidven.com` to the fixed +`PublicIp` stack output. Caddy obtains and renews the public TLS certificate. +Store the production dotenv file in the `trypost/production/env` secret. ## Release diff --git a/deploy/aws/compose.yaml b/deploy/aws/compose.yaml index e04e73347..5be906b67 100644 --- a/deploy/aws/compose.yaml +++ b/deploy/aws/compose.yaml @@ -54,6 +54,8 @@ services: APP_DOMAIN: ${APP_DOMAIN} ports: - "80:80" + - "443:443" + - "443:443/udp" volumes: - ./Caddyfile:/etc/caddy/Caddyfile:ro - caddy-data:/data diff --git a/deploy/aws/stack.yaml b/deploy/aws/stack.yaml index 128fb130a..91b54b405 100644 --- a/deploy/aws/stack.yaml +++ b/deploy/aws/stack.yaml @@ -72,6 +72,14 @@ Resources: FromPort: 80 ToPort: 80 CidrIp: 0.0.0.0/0 + - IpProtocol: tcp + FromPort: 443 + ToPort: 443 + CidrIp: 0.0.0.0/0 + - IpProtocol: udp + FromPort: 443 + ToPort: 443 + CidrIp: 0.0.0.0/0 InstanceRole: Type: AWS::IAM::Role diff --git a/deploy/cloudflare/worker.ts b/deploy/cloudflare/worker.ts deleted file mode 100644 index 2b5586351..000000000 --- a/deploy/cloudflare/worker.ts +++ /dev/null @@ -1,14 +0,0 @@ -interface Env { - ORIGIN_IP: string; -} - -export default { - fetch(request: Request, env: Env): Promise { - const url = new URL(request.url); - url.protocol = 'http:'; - url.hostname = env.ORIGIN_IP; - url.port = '80'; - - return fetch(new Request(url, request)); - }, -} satisfies ExportedHandler; diff --git a/deploy/cloudflare/wrangler.jsonc b/deploy/cloudflare/wrangler.jsonc deleted file mode 100644 index 238dc70e0..000000000 --- a/deploy/cloudflare/wrangler.jsonc +++ /dev/null @@ -1,15 +0,0 @@ -{ - "$schema": "node_modules/wrangler/config-schema.json", - "name": "trypost-edge", - "main": "worker.ts", - "compatibility_date": "2026-07-16", - "routes": [ - { - "pattern": "trypost.aidven.com", - "custom_domain": true - } - ], - "vars": { - "ORIGIN_IP": "52.7.162.246" - } -} From dd5fb7d5de74d8bed4760869f14b00ca0aa49e0d Mon Sep 17 00:00:00 2001 From: Bahodir Rajabov Date: Thu, 16 Jul 2026 03:40:18 -0700 Subject: [PATCH 5/8] docs: record Meta Instagram setup --- META_INSTAGRAM_SETUP.md | 62 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 META_INSTAGRAM_SETUP.md diff --git a/META_INSTAGRAM_SETUP.md b/META_INSTAGRAM_SETUP.md new file mode 100644 index 000000000..84af125b9 --- /dev/null +++ b/META_INSTAGRAM_SETUP.md @@ -0,0 +1,62 @@ +# Meta Instagram setup + +TryPost uses **Instagram API with Instagram Login**. The Instagram app ID is +`1307273551605418`. + +## OAuth redirect URLs + +In the [Meta Developer Dashboard](https://developers.facebook.com/apps/), open +the app and go to: + +1. **Use cases** +2. **Instagram API** +3. **Set up Instagram business login** +4. **Business login settings** +5. **OAuth settings** → **Valid OAuth Redirect URIs** + +Add the TryPost production callback: + +```text +https://trypost.aidven.com/accounts/instagram/callback +``` + +The Instagram app is shared with AGENTFATHER. Do not remove its registered +callbacks. The complete callback list used by both products is: + +```text +https://trypost.aidven.com/accounts/instagram/callback +https://api.aidven.com/connect/oauth/callback +https://local4000.inteclab.org/accounts/instagram/callback +https://local4096.inteclab.org/connect/oauth/callback +``` + +The dashboard's generated **Embed URL** is only an example and may show one of +the local callbacks. TryPost builds its own authorization URL using +`INSTAGRAM_CLIENT_REDIRECT`; Meta only requires that value to exactly match a +registered callback. + +## Application configuration + +Production must use: + +```dotenv +APP_URL=https://trypost.aidven.com +INSTAGRAM_CLIENT_ID=1307273551605418 +INSTAGRAM_CLIENT_REDIRECT=https://trypost.aidven.com/accounts/instagram/callback +``` + +Keep the Instagram app secret and webhook verify token in the deployment secret +store. Never commit them. + +## Webhook + +The webhook is configured separately from OAuth: + +```text +Callback URL: https://trypost.aidven.com/instagram/webhook +Verify token: INSTAGRAM_WEBHOOK_VERIFY_TOKEN from the production secret store +Fields: comments, messages +``` + +The OAuth callback route is defined in `routes/app.php`. The authorization URL +is built in `app/Http/Controllers/Auth/InstagramController.php`. From 978626eba8b5c8afa47d1b523a02add48a4e4710 Mon Sep 17 00:00:00 2001 From: Bahodir Rajabov Date: Mon, 20 Jul 2026 18:36:48 -0700 Subject: [PATCH 6/8] ops: move TryPost to Superclerk domain --- .github/workflows/deploy-aws.yml | 4 ++-- META_INSTAGRAM_SETUP.md | 10 +++++----- deploy/aws/README.md | 4 ++-- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/deploy-aws.yml b/.github/workflows/deploy-aws.yml index 6d43253a9..e691e16fe 100644 --- a/.github/workflows/deploy-aws.yml +++ b/.github/workflows/deploy-aws.yml @@ -51,7 +51,7 @@ jobs: tags: ${{ env.ECR_REGISTRY }}/${{ env.ECR_REPOSITORY }}:${{ github.sha }} build-args: | VITE_REVERB_APP_KEY=trypost-reverb-key - VITE_REVERB_HOST=trypost.aidven.com + VITE_REVERB_HOST=trypost.superclerk.com VITE_REVERB_PORT=443 VITE_REVERB_SCHEME=https cache-from: type=gha,scope=trypost-production-arm64 @@ -96,7 +96,7 @@ jobs: - name: Verify run: | - test "$(curl -fsS https://trypost.aidven.com/up)" = "Application up" + curl -fsS https://trypost.superclerk.com/up > /dev/null command_id=$(aws ssm send-command \ --instance-ids "$INSTANCE_ID" \ --document-name AWS-RunShellScript \ diff --git a/META_INSTAGRAM_SETUP.md b/META_INSTAGRAM_SETUP.md index 84af125b9..ef1b4ae3e 100644 --- a/META_INSTAGRAM_SETUP.md +++ b/META_INSTAGRAM_SETUP.md @@ -17,14 +17,14 @@ the app and go to: Add the TryPost production callback: ```text -https://trypost.aidven.com/accounts/instagram/callback +https://trypost.superclerk.com/accounts/instagram/callback ``` The Instagram app is shared with AGENTFATHER. Do not remove its registered callbacks. The complete callback list used by both products is: ```text -https://trypost.aidven.com/accounts/instagram/callback +https://trypost.superclerk.com/accounts/instagram/callback https://api.aidven.com/connect/oauth/callback https://local4000.inteclab.org/accounts/instagram/callback https://local4096.inteclab.org/connect/oauth/callback @@ -40,9 +40,9 @@ registered callback. Production must use: ```dotenv -APP_URL=https://trypost.aidven.com +APP_URL=https://trypost.superclerk.com INSTAGRAM_CLIENT_ID=1307273551605418 -INSTAGRAM_CLIENT_REDIRECT=https://trypost.aidven.com/accounts/instagram/callback +INSTAGRAM_CLIENT_REDIRECT=https://trypost.superclerk.com/accounts/instagram/callback ``` Keep the Instagram app secret and webhook verify token in the deployment secret @@ -53,7 +53,7 @@ store. Never commit them. The webhook is configured separately from OAuth: ```text -Callback URL: https://trypost.aidven.com/instagram/webhook +Callback URL: https://trypost.superclerk.com/instagram/webhook Verify token: INSTAGRAM_WEBHOOK_VERIFY_TOKEN from the production secret store Fields: comments, messages ``` diff --git a/deploy/aws/README.md b/deploy/aws/README.md index 46c7c37e7..876735a00 100644 --- a/deploy/aws/README.md +++ b/deploy/aws/README.md @@ -5,7 +5,7 @@ app, Postgres, Redis, queues, scheduler, Reverb, and Caddy. AWS Systems Manager replaces SSH, Secrets Manager stores the production environment, and S3 keeps 30 days of nightly database and uploaded-file backups. -Production URL: `https://trypost.aidven.com` +Production URL: `https://trypost.superclerk.com` ## Provision @@ -13,7 +13,7 @@ Deploy `stack.yaml` in `us-east-1` with the latest Amazon Linux 2023 ARM AMI, the default VPC, and a public subnet. Enable CloudFormation termination protection after creation. -Create a DNS-only Cloudflare `A` record from `trypost.aidven.com` to the fixed +Create a DNS-only Cloudflare `A` record from `trypost.superclerk.com` to the fixed `PublicIp` stack output. Caddy obtains and renews the public TLS certificate. Store the production dotenv file in the `trypost/production/env` secret. From c478c1fe9f6f3291e5cb9bc704727ce3826511ba Mon Sep 17 00:00:00 2001 From: Bahodir Rajabov Date: Mon, 20 Jul 2026 19:07:11 -0700 Subject: [PATCH 7/8] fix: support Unicode asset filenames --- .../App/Asset/StoreChunkedAssetRequest.php | 2 +- resources/js/utils/chunkedUpload.ts | 2 +- tests/Feature/AssetControllerTest.php | 23 +++++++++++++++++++ 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/app/Http/Requests/App/Asset/StoreChunkedAssetRequest.php b/app/Http/Requests/App/Asset/StoreChunkedAssetRequest.php index 3244d6826..69a4a9baf 100644 --- a/app/Http/Requests/App/Asset/StoreChunkedAssetRequest.php +++ b/app/Http/Requests/App/Asset/StoreChunkedAssetRequest.php @@ -30,7 +30,7 @@ protected function prepareForValidation(): void 'total_size' => $parsed[2] ?? null, // Lowercase the name so `ends_with` validation is effectively // case-insensitive (IMG_1234.JPG vs img_1234.jpg). - 'file_name' => strtolower((string) $this->header('X-File-Name', 'upload')), + 'file_name' => strtolower(rawurldecode((string) $this->header('X-File-Name', 'upload'))), ]); } diff --git a/resources/js/utils/chunkedUpload.ts b/resources/js/utils/chunkedUpload.ts index 0114cef2b..8e6e6a6f1 100644 --- a/resources/js/utils/chunkedUpload.ts +++ b/resources/js/utils/chunkedUpload.ts @@ -49,7 +49,7 @@ export const uploadChunked = async (options: ChunkedUploadOptions): Promise = { 'Content-Type': 'application/octet-stream', 'Content-Range': `bytes ${start}-${end - 1}/${totalSize}`, - 'X-File-Name': file.name, + 'X-File-Name': encodeURIComponent(file.name), 'X-CSRF-TOKEN': csrfToken, 'X-Requested-With': 'XMLHttpRequest', Accept: 'application/json', diff --git a/tests/Feature/AssetControllerTest.php b/tests/Feature/AssetControllerTest.php index 0db068bb1..c02959d4c 100644 --- a/tests/Feature/AssetControllerTest.php +++ b/tests/Feature/AssetControllerTest.php @@ -226,6 +226,29 @@ expect($this->workspace->getMedia('assets')->count())->toBe(1); }); +test('chunked upload accepts a percent-encoded unicode filename', function () { + $content = file_get_contents(__DIR__.'/../fixtures/1x1.png'); + $size = strlen($content); + + $response = $this->actingAs($this->user)->call( + 'POST', + route('app.assets.store-chunked'), + [], [], [], + [ + 'HTTP_CONTENT_RANGE' => 'bytes 0-'.($size - 1).'/'.$size, + 'HTTP_X_FILE_NAME' => rawurlencode('Screenshot 2026-07-15 at 3.18.01 AM.png'), + 'HTTP_ACCEPT' => 'application/json', + 'CONTENT_TYPE' => 'application/octet-stream', + ], + $content, + ); + + $response->assertSuccessful(); + $response->assertJson(['done' => true]); + expect($this->workspace->getMedia('assets')->first()->original_filename) + ->toBe('screenshot 2026-07-15 at 3.18.01 am.png'); +}); + test('chunked upload completes for a pdf document', function () { // Real %PDF magic bytes so mime_content_type detects application/pdf. $content = "%PDF-1.4\n1 0 obj<>endobj\ntrailer<>\n%%EOF\n"; From 46d1c34ea21776e91903e5081d131ccbe92a1e03 Mon Sep 17 00:00:00 2001 From: Bahodir Rajabov Date: Mon, 20 Jul 2026 20:05:09 -0700 Subject: [PATCH 8/8] fix: preserve uploaded image formats --- .env.example | 4 +- app/Models/Traits/HasMedia.php | 97 ++++----------------------- tests/Feature/AssetControllerTest.php | 16 +++-- tests/Unit/Traits/HasMediaTest.php | 20 +++--- 4 files changed, 37 insertions(+), 100 deletions(-) diff --git a/.env.example b/.env.example index 4a631e7ee..9328ed38b 100644 --- a/.env.example +++ b/.env.example @@ -49,8 +49,8 @@ QUEUE_CONNECTION=redis CACHE_STORE=redis # File Storage -# Options: local, s3, r2 (or any S3-compatible: MinIO, DigitalOcean Spaces, etc.) -FILESYSTEM_DISK=local +# Options: public, s3, r2 (or any S3-compatible: MinIO, DigitalOcean Spaces, etc.) +FILESYSTEM_DISK=public # Redis REDIS_HOST=127.0.0.1 diff --git a/app/Models/Traits/HasMedia.php b/app/Models/Traits/HasMedia.php index f2e1752d6..5a8a999ff 100644 --- a/app/Models/Traits/HasMedia.php +++ b/app/Models/Traits/HasMedia.php @@ -10,12 +10,8 @@ use App\Models\Workspace; use Illuminate\Database\Eloquent\Relations\MorphMany; use Illuminate\Http\UploadedFile; -use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; -use Intervention\Image\Drivers\Gd\Driver; -use Intervention\Image\Encoders\JpegEncoder; -use Intervention\Image\ImageManager; trait HasMedia { @@ -76,20 +72,11 @@ public function addMedia(UploadedFile $file, string $collection = 'default', arr $mimeType = $file->getMimeType(); $type = $this->getMediaType($mimeType); - - // Normalize non-JPEG still images to JPEG q100 for universal platform compatibility. - // GIF is preserved (animation kept for X/Bluesky/Mastodon). - [$normalizedBytes, $normalizedMime, $normalizedExt] = $this->normalizeImageFormat( - $file->getPathname(), - $mimeType, - $type, - $file->getClientOriginalExtension(), - ); - - $filename = Str::uuid().'.'.$normalizedExt; + $bytes = file_get_contents($file->getPathname()); + $filename = Str::uuid().'.'.$file->getClientOriginalExtension(); $path = 'medias/'.$filename; - Storage::put($path, $normalizedBytes); + Storage::put($path, $bytes); return $this->media()->create([ 'group_id' => $groupId ?? Str::uuid()->toString(), @@ -97,10 +84,10 @@ public function addMedia(UploadedFile $file, string $collection = 'default', arr 'type' => $type, 'path' => $path, 'original_filename' => $file->getClientOriginalName(), - 'mime_type' => $normalizedMime, - 'size' => strlen($normalizedBytes), + 'mime_type' => $mimeType, + 'size' => strlen($bytes), 'order' => 0, - 'meta' => array_merge($this->getMediaMetaFromBytes($normalizedBytes, $type, $meta), $meta), + 'meta' => array_merge($this->getMediaMetaFromBytes($bytes, $type), $meta), ]); } @@ -116,18 +103,11 @@ public function addMediaFromPath(string $filePath, string $originalFilename, str $mimeType = mime_content_type($filePath); $type = $this->getMediaType($mimeType); $extension = pathinfo($originalFilename, PATHINFO_EXTENSION); - - [$normalizedBytes, $normalizedMime, $normalizedExt] = $this->normalizeImageFormat( - $filePath, - $mimeType, - $type, - $extension, - ); - - $filename = Str::uuid().'.'.$normalizedExt; + $bytes = file_get_contents($filePath); + $filename = Str::uuid().'.'.$extension; $storagePath = 'medias/'.$filename; - Storage::put($storagePath, $normalizedBytes); + Storage::put($storagePath, $bytes); return $this->media()->create([ 'group_id' => $groupId ?? Str::uuid()->toString(), @@ -135,10 +115,10 @@ public function addMediaFromPath(string $filePath, string $originalFilename, str 'type' => $type, 'path' => $storagePath, 'original_filename' => $originalFilename, - 'mime_type' => $normalizedMime, - 'size' => strlen($normalizedBytes), + 'mime_type' => $mimeType, + 'size' => strlen($bytes), 'order' => 0, - 'meta' => array_merge($this->getMediaMetaFromBytes($normalizedBytes, $type, $meta), $meta), + 'meta' => array_merge($this->getMediaMetaFromBytes($bytes, $type), $meta), ]); } @@ -161,26 +141,7 @@ private function getMediaType(string $mimeType): string ?? throw new \InvalidArgumentException("Unsupported media MIME type: {$mimeType}"))->value; } - private function getMediaMeta(UploadedFile $file, string $type): array - { - $meta = []; - - if ($type === 'image') { - $imageInfo = @getimagesize($file->getPathname()); - if ($imageInfo) { - $meta['width'] = $imageInfo[0]; - $meta['height'] = $imageInfo[1]; - } - } - - return $meta; - } - - /** - * Extract width/height from raw image bytes (used after format normalization - * when we no longer have the original file path). - */ - private function getMediaMetaFromBytes(string $bytes, string $type, array $clientMeta = []): array + private function getMediaMetaFromBytes(string $bytes, string $type): array { $meta = []; @@ -194,36 +155,4 @@ private function getMediaMetaFromBytes(string $bytes, string $type, array $clien return $meta; } - - /** - * Convert PNG/WebP/HEIC/AVIF to JPEG at q100 (keeps dimensions). GIF and - * JPEG are returned untouched. Non-image types are passed through. - * - * @return array{0: string, 1: string, 2: string} [bytes, mime_type, extension] - */ - private function normalizeImageFormat(string $filePath, string $mimeType, string $type, string $originalExtension): array - { - if ($type !== 'image') { - return [file_get_contents($filePath), $mimeType, $originalExtension]; - } - - // Formats that publish safely everywhere (JPEG is universal, GIF needed for X/Bluesky/Mastodon). - if (in_array($mimeType, ['image/jpeg', 'image/jpg', 'image/gif'], true)) { - return [file_get_contents($filePath), $mimeType, $originalExtension]; - } - - try { - $manager = new ImageManager(new Driver); - $encoded = (string) $manager->decodePath($filePath)->encode(new JpegEncoder(quality: 100)); - - return [$encoded, 'image/jpeg', 'jpg']; - } catch (\Throwable $e) { - Log::warning('HasMedia: image normalization failed, storing original', [ - 'mime' => $mimeType, - 'error' => $e->getMessage(), - ]); - - return [file_get_contents($filePath), $mimeType, $originalExtension]; - } - } } diff --git a/tests/Feature/AssetControllerTest.php b/tests/Feature/AssetControllerTest.php index c02959d4c..b8622ac0b 100644 --- a/tests/Feature/AssetControllerTest.php +++ b/tests/Feature/AssetControllerTest.php @@ -90,8 +90,9 @@ $response->assertJsonCount(1, 'data'); }); -test('can upload an image asset', function () { - $file = UploadedFile::fake()->image('photo.jpg', 800, 600); +test('can upload an image asset without changing its format', function () { + $file = UploadedFile::fake()->image('photo.png', 800, 600); + $content = file_get_contents($file->getPathname()); $response = $this->actingAs($this->user) ->postJson(route('app.assets.store'), ['media' => $file]); @@ -102,8 +103,11 @@ expect($this->workspace->getMedia('assets')->count())->toBe(1); $media = $this->workspace->getMedia('assets')->first(); - expect($media->original_filename)->toBe('photo.jpg'); - expect($media->collection)->toBe('assets'); + expect($media->original_filename)->toBe('photo.png') + ->and($media->collection)->toBe('assets') + ->and($media->mime_type)->toBe('image/png') + ->and($media->path)->toEndWith('.png') + ->and(Storage::get($media->path))->toBe($content); }); test('can delete an asset', function () { @@ -224,6 +228,10 @@ $response->assertJson(['done' => true]); $response->assertJsonStructure(['done', 'id', 'path', 'url', 'type', 'mime_type', 'original_filename', 'size']); expect($this->workspace->getMedia('assets')->count())->toBe(1); + $media = $this->workspace->getMedia('assets')->first(); + expect($media->mime_type)->toBe('image/png') + ->and($media->path)->toEndWith('.png') + ->and(Storage::get($media->path))->toBe($content); }); test('chunked upload accepts a percent-encoded unicode filename', function () { diff --git a/tests/Unit/Traits/HasMediaTest.php b/tests/Unit/Traits/HasMediaTest.php index 7380fc514..f0cab5923 100644 --- a/tests/Unit/Traits/HasMediaTest.php +++ b/tests/Unit/Traits/HasMediaTest.php @@ -263,18 +263,16 @@ expect($media->isVideo())->toBeTrue(); }); -test('PNG upload is converted to JPEG at upload time', function () { +test('PNG upload preserves its original format', function () { $workspace = Workspace::factory()->create(); $file = UploadedFile::fake()->image('photo.png', 200, 150); + $content = file_get_contents($file->getPathname()); $media = $workspace->addMedia($file, 'assets'); - expect($media->mime_type)->toBe('image/jpeg') - ->and($media->path)->toEndWith('.jpg') - ->and(pathinfo($media->path, PATHINFO_EXTENSION))->toBe('jpg'); - - $bytes = Storage::get($media->path); - expect(substr($bytes, 0, 3))->toBe("\xFF\xD8\xFF"); // JPEG SOI marker + expect($media->mime_type)->toBe('image/png') + ->and($media->path)->toEndWith('.png') + ->and(Storage::get($media->path))->toBe($content); }); test('JPEG upload stays as JPEG (no-op)', function () { @@ -297,14 +295,16 @@ ->and(pathinfo($media->path, PATHINFO_EXTENSION))->toBe('gif'); }); -test('WebP upload is converted to JPEG', function () { +test('WebP upload preserves its original format', function () { $workspace = Workspace::factory()->create(); $file = UploadedFile::fake()->image('photo.webp', 200, 150); + $content = file_get_contents($file->getPathname()); $media = $workspace->addMedia($file, 'assets'); - expect($media->mime_type)->toBe('image/jpeg') - ->and(pathinfo($media->path, PATHINFO_EXTENSION))->toBe('jpg'); + expect($media->mime_type)->toBe('image/webp') + ->and($media->path)->toEndWith('.webp') + ->and(Storage::get($media->path))->toBe($content); }); test('client meta is merged into media meta', function () {