Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
112 changes: 112 additions & 0 deletions .github/workflows/deploy-aws.yml
Original file line number Diff line number Diff line change
@@ -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.superclerk.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: |
curl -fsS https://trypost.superclerk.com/up > /dev/null
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}"
1 change: 1 addition & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
name: Tests

on:
workflow_call:
push:
branches: [main, develop]
pull_request:
Expand Down
62 changes: 62 additions & 0 deletions META_INSTAGRAM_SETUP.md
Original file line number Diff line number Diff line change
@@ -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.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.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
```

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.superclerk.com
INSTAGRAM_CLIENT_ID=1307273551605418
INSTAGRAM_CLIENT_REDIRECT=https://trypost.superclerk.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.superclerk.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`.
2 changes: 1 addition & 1 deletion app/Http/Requests/App/Asset/StoreChunkedAssetRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'))),
]);
}

Expand Down
97 changes: 13 additions & 84 deletions app/Models/Traits/HasMedia.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down Expand Up @@ -76,31 +72,22 @@ 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(),
'collection' => $collection,
'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),
]);
}

Expand All @@ -116,29 +103,22 @@ 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(),
'collection' => $collection,
'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),
]);
}

Expand All @@ -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 = [];

Expand All @@ -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];
}
}
}
4 changes: 4 additions & 0 deletions deploy/aws/Caddyfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{$APP_DOMAIN} {
encode zstd gzip
reverse_proxy app:80
}
37 changes: 37 additions & 0 deletions deploy/aws/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# 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.superclerk.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.

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.

## 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.
Loading