diff --git a/.dockerignore b/.dockerignore index db9024ca..8e828fdd 100644 --- a/.dockerignore +++ b/.dockerignore @@ -28,6 +28,7 @@ fly.toml public/hot docker-compose.yml docker-compose.dev.yml +render.yaml # 3. Copy over top-level .gitignore diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml deleted file mode 100644 index 5980351f..00000000 --- a/.github/workflows/docker-image.yml +++ /dev/null @@ -1,39 +0,0 @@ -name: Build and Push Docker Image - -on: - push: - branches: - - main - workflow_dispatch: - -permissions: - contents: read - packages: write - -jobs: - build: - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Log in to GitHub Container Registry - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Build and push - uses: docker/build-push-action@v5 - with: - context: . - platforms: linux/amd64 - push: true - tags: | - ghcr.io/${{ github.repository }}:latest - ghcr.io/${{ github.repository }}:${{ github.sha }} diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index d746f2d2..0d81cf87 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -1,9 +1,12 @@ -name: Pull Request CI +name: CI on: pull_request: branches: - main + push: + branches: + - main workflow_dispatch: # Allows you to run this workflow manually from the Actions tab jobs: @@ -51,6 +54,9 @@ jobs: run: php artisan key:generate - name: Execute tests + env: + DB_USERNAME: root + DB_PASSWORD: password run: php artisan test --compact --do-not-cache-result services: @@ -59,7 +65,7 @@ jobs: env: POSTGRES_USER: root POSTGRES_PASSWORD: password - POSTGRES_DB: laravel + POSTGRES_DB: laravel_test ports: - 5432:5432 options: >- diff --git a/CLAUDE.md b/CLAUDE.md index b418cb68..882c4f12 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,19 +8,29 @@ Quick check: if `php artisan test` fails with autoload errors, run the setup. ## Architecture overview -- Cloudflare DNS. Orange-checkmark reverse proxy to my Digital Ocean droplet -- Digital Ocean droplet is running Ubuntu with Docker installed -- I use `docker-compose up` to run the containers for my site. See docker-compose.yml -- I run the Laravel web server Octane and Caddy. See Caddyfile. +The site runs on Render.com from `render.yaml`. -So Cloudflare -> Digital Ocean droplet -> Caddy -> Laravel -> Postgres +``` +Internet + -> Render ingress + -> web service: FrankenPHP Octane on $PORT + -> Render Postgres on the private network +``` -The containers are: +Supporting services: -- laravel web server -- laravel queue worker -- laravel scheduler -- postgres database +- `davidhartingdotcom-worker`: runs `php artisan queue:work`. +- `davidhartingdotcom-backup-run`: Render cron job that runs database backups. +- `davidhartingdotcom-backup-clean`: Render cron job that prunes old backups. +- Cloudflare DNS points `davidharting.com` at Render. Cloudflare R2 stores public and private objects. + +Operational notes: + +- Render terminates TLS; the container listens on plain HTTP at `$PORT`. +- `render.yaml` owns service, database, worker, and cron definitions. +- Secrets are managed in Render, not committed to git. Prefer an IaC-friendly path, such as Render secret files, over long-term dashboard-only configuration. +- The web service `preDeployCommand` runs migrations and Telegram webhook registration before new web instances receive traffic. +- Historical note: this previously ran on a Digital Ocean droplet with Docker Compose. See `docs/projects/render-migration.md` for migration history. ## Commands diff --git a/Caddyfile b/Caddyfile index acd054bb..6875c9fd 100644 --- a/Caddyfile +++ b/Caddyfile @@ -6,7 +6,7 @@ } } -davidharting.com { +:{$PORT:80} { log { level {$CADDY_SERVER_LOG_LEVEL} diff --git a/Dockerfile b/Dockerfile index 97cb4fd1..d6709502 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,8 +8,9 @@ RUN apt-get update \ && echo "deb [signed-by=/usr/share/keyrings/postgresql-keyring.gpg] https://apt.postgresql.org/pub/repos/apt jammy-pgdg main" > /etc/apt/sources.list.d/pgdg.list \ && apt-get update \ && apt-get upgrade -y \ - && apt-get install -y unzip libnss3-tools procps postgresql-client-17 nodejs \ - && rm -rf /var/lib/apt/lists/* + && apt-get install -y unzip libnss3-tools procps postgresql-client-17 nodejs libcap2-bin \ + && rm -rf /var/lib/apt/lists/* \ + && setcap -r /usr/local/bin/frankenphp RUN install-php-extensions \ intl \ @@ -18,25 +19,50 @@ RUN install-php-extensions \ pdo_pgsql \ zip -RUN echo "upload_max_filesize = 25M\npost_max_size = 27M" \ - > /usr/local/etc/php/conf.d/uploads.ini - - -COPY docker-entrypoint.sh /entrypoint.sh -RUN chmod +x /entrypoint.sh +# PHP runtime config. +# +# memory_limit: the dunglas/frankenphp base image does not override php.ini, so +# by default we'd inherit PHP's compile-time default of 128M. Bumped to 256M +# to give Octane worker threads headroom for Filament exports and import +# actions. +# +# Sizing rule from FrankenPHP's performance docs: +# num_threads × memory_limit < available_memory +# FrankenPHP spawns 2 × CPU cores worker threads by default. On the Render +# starter plan (0.5 CPU, 512 MB) that's ~1 thread, so 256M fits comfortably +# alongside Caddy + Go runtime overhead. If we move to a larger Render plan, +# revisit this — on standard (1 CPU, 2 GB) the default 2 threads × 256M = 512M +# is still fine, but on pro (2 CPU, 4 GB) with 4 threads we'd use 1 GB for PHP. +# +# The worker / scheduler services run a single PHP process (queue:work / +# schedule:work) so the thread multiplier doesn't apply — 256M is strictly +# safe there. +RUN printf "memory_limit = 256M\nupload_max_filesize = 25M\npost_max_size = 27M\n" \ + > /usr/local/etc/php/conf.d/php.ini RUN mkdir -p /app/public/build COPY --from=composer:2 /usr/bin/composer /usr/bin/composer -COPY . /app -# spatie/laravel-backup expects this to be defined in the post-install script -ENV MAIL_FROM_ADDRESS=hello@davidharting.com +WORKDIR /app -RUN composer install --optimize-autoloader && php artisan optimize:clear +# Dependency layers kept ahead of COPY . so source edits don't bust the cache. +COPY composer.json composer.lock /app/ +RUN composer install --no-scripts --no-autoloader --no-dev --prefer-dist +COPY package.json package-lock.json /app/ RUN npm ci --no-audit -RUN npm run build -ENTRYPOINT ["bash", "/entrypoint.sh"] -CMD ["php", "artisan", "octane:frankenphp", "--caddyfile", "Caddyfile", "--https", "--http-redirect"] +# spatie/laravel-backup expects MAIL_FROM_ADDRESS at composer post-install time. +ENV MAIL_FROM_ADDRESS=hello@davidharting.com +# Baked into the JS bundle at Vite build time. +ENV VITE_APP_NAME=davidharting.com + +COPY . /app + +RUN composer dump-autoload --optimize \ + && composer run-script post-autoload-dump \ + && npm run build \ + && php artisan optimize:clear + +CMD ["php", "artisan", "octane:frankenphp", "--caddyfile", "Caddyfile"] diff --git a/app/Http/Middleware/TrustProxies.php b/app/Http/Middleware/TrustProxies.php index 3391630e..9d15166e 100644 --- a/app/Http/Middleware/TrustProxies.php +++ b/app/Http/Middleware/TrustProxies.php @@ -10,9 +10,14 @@ class TrustProxies extends Middleware /** * The trusted proxies for this application. * + * Render forwards public traffic through its ingress before it reaches the + * container port, so trusting the ingress path is expected on this PaaS. + * Do not trust X-Forwarded-Host; Render should preserve the real Host + * header, and accepting a forwarded host is unnecessary spoofing surface. + * * @var array|string|null */ - protected $proxies; + protected $proxies = '*'; /** * The headers that should be used to detect proxies. @@ -21,8 +26,6 @@ class TrustProxies extends Middleware */ protected $headers = Request::HEADER_X_FORWARDED_FOR | - Request::HEADER_X_FORWARDED_HOST | Request::HEADER_X_FORWARDED_PORT | - Request::HEADER_X_FORWARDED_PROTO | - Request::HEADER_X_FORWARDED_AWS_ELB; + Request::HEADER_X_FORWARDED_PROTO; } diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index d3b024c3..00000000 --- a/docker-compose.yml +++ /dev/null @@ -1,214 +0,0 @@ -volumes: - db_data: - caddy_data: - # TODO: private and public disks - -secrets: - DB_PASSWORD: - file: ./secrets/DB_PASSWORD.txt - APP_KEY: - file: ./secrets/APP_KEY.txt - MAILERSEND_ADMIN_ADDRESS: - file: ./secrets/MAILERSEND_ADMIN_ADDRESS.txt - MAILERSEND_API_KEY: - file: ./secrets/MAILERSEND_API_KEY.txt - R2_ACCESS_KEY_ID: - file: ./secrets/R2_ACCESS_KEY_ID.txt - R2_SECRET_ACCESS_KEY: - file: ./secrets/R2_SECRET_ACCESS_KEY.txt - R2_ENDPOINT: - file: ./secrets/R2_ENDPOINT.txt - R2_PRIVATE_BUCKET: - file: ./secrets/R2_PRIVATE_BUCKET.txt - NIGHTWATCH_TOKEN: - file: ./secrets/NIGHTWATCH_TOKEN.txt - TELEGRAM_TOKEN: - file: ./secrets/TELEGRAM_TOKEN.txt - TELEGRAM_DAVID_ID: - file: ./secrets/TELEGRAM_DAVID_ID.txt - ANTHROPIC_API_KEY: - file: ./secrets/ANTHROPIC_API_KEY.txt - -x-laravel-config: &laravel-config - image: ghcr.io/davidharting/davidharting.com:latest - secrets: - - DB_PASSWORD - - APP_KEY - - MAILERSEND_API_KEY - - MAILERSEND_ADMIN_ADDRESS - - R2_ACCESS_KEY_ID - - R2_SECRET_ACCESS_KEY - - R2_ENDPOINT - - R2_PRIVATE_BUCKET - - NIGHTWATCH_TOKEN - - TELEGRAM_TOKEN - - TELEGRAM_DAVID_ID - - ANTHROPIC_API_KEY - environment: - APP_DEBUG: "false" - APP_ENV: production - # APP_KEY: Injected into /run/secrets/APP_KEY - APP_NAME: davidharting.com - APP_URL: https://davidharting.com - BROADCAST_DRIVER: log - CACHE_DRIVER: database - DB_CONNECTION: pgsql - DB_DATABASE: laravel - DB_HOST: database - # DB_PASSWORD: Injected into /run/secrets/DB_PASSWORD - DB_PORT: 5432 - DB_USERNAME: laravel - FILESYSTEM_DISK_PRIVATE: r2-private - FILESYSTEM_DISK_PUBLIC: r2-public - R2_PUBLIC_BUCKET: davidhartingdotcom-public - R2_PUBLIC_URL: https://cdn.davidharting.com - LOG_CHANNEL: stack - LOG_STACK: stderr,nightwatch - LOG_DEPRECATIONS_CHANNEL: null - LOG_LEVEL: debug - NIGHTWATCH_INGEST_URI: nightwatch-agent:2407 - NIGHTWATCH_REQUEST_SAMPLE_RATE: "1" - MAIL_FROM_ADDRESS: "hello@davidharting.com" - MAIL_FROM_NAME: davidharting.com - MAIL_LOG_CHANNEL: stderr - MAIL_MAILER: mailersend - OCTANE_HTTPS: true - OCTANE_SERVER: frankenphp - QUEUE_CONNECTION: database - SESSION_DRIVER: database - SESSION_LIFETIME: 120 - VITE_APP_NAME: davidharting.com - - depends_on: - database: - condition: service_healthy - restart: unless-stopped - stdin_open: true - tty: true - - deploy: - resources: - limits: - cpus: "0.25" - memory: 256M - reservations: - cpus: "0.1" - memory: 128M - -services: - database: - image: postgres:17.2 - secrets: - - DB_PASSWORD - environment: - POSTGRES_USER: laravel - POSTGRES_PASSWORD_FILE: /run/secrets/DB_PASSWORD - POSTGRES_DB: laravel - stdin_open: true - tty: true - healthcheck: - test: ["CMD-SHELL", "pg_isready -U laravel"] - interval: 1s - timeout: 5s - retries: 15 - volumes: - - db_data:/var/lib/postgresql/data - - deploy: - resources: - limits: - cpus: "0.25" - memory: 256M - reservations: - cpus: "0.1" - memory: 128M - - migrations: - <<: *laravel-config - command: ["php", "artisan", "migrate", "--force"] - restart: no - - web: - <<: *laravel-config - ports: - - "80:80" - - "443:443" - - "443:443/udp" - command: - [ - "php", - "artisan", - "octane:frankenphp", - "--caddyfile", - "Caddyfile", - "--https", - "--http-redirect", - "--host", - "davidharting.com", - ] - depends_on: - migrations: - condition: service_completed_successfully - - volumes: - - caddy_data:/data - - healthcheck: - test: ["CMD-SHELL", "curl -f http://localhost/healthz || exit 1"] - interval: 30s - timeout: 5s - retries: 5 - cron: - <<: *laravel-config - command: ["php", "artisan", "schedule:work"] - depends_on: - migrations: - condition: service_completed_successfully - healthcheck: - test: ["CMD-SHELL", 'pgrep -fl "php artisan schedule:work"'] - interval: 30s - timeout: 5s - retries: 5 - - worker: - <<: *laravel-config - command: ["php", "artisan", "queue:work", "-v"] - depends_on: - migrations: - condition: service_completed_successfully - - healthcheck: - test: ["CMD-SHELL", 'pgrep -fl "php artisan queue:work"'] - interval: 30s - timeout: 5s - retries: 5 - - configure-telegram: - <<: *laravel-config - command: - [ - "sh", - "-c", - "php artisan nutgram:hook:set https://davidharting.com/api/telegram/webhook && php artisan nutgram:register-commands", - ] - restart: no - depends_on: - migrations: - condition: service_completed_successfully - - nightwatch-agent: - image: laravelphp/nightwatch-agent:v1 - entrypoint: ["/bin/sh", "-c"] - command: - [ - "export NIGHTWATCH_TOKEN=$(cat /run/secrets/NIGHTWATCH_TOKEN) && exec ./entrypoint.sh", - ] - secrets: - - NIGHTWATCH_TOKEN - restart: unless-stopped - healthcheck: - test: php nightwatch-status - interval: 30s - timeout: 5s - retries: 3 - start_period: 5s diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh deleted file mode 100644 index 3fd1f1f9..00000000 --- a/docker-entrypoint.sh +++ /dev/null @@ -1,9 +0,0 @@ -for secret in /run/secrets/*; do - if [ -f "$secret" ]; then - echo "Exporting secret: $(basename "$secret")" - export $(basename "$secret")=$(cat "$secret" | tr -d "\n") - fi -done - -exec "$@" - diff --git a/docs/development-setup.md b/docs/development-setup.md index 6a49c07a..c720244a 100644 --- a/docs/development-setup.md +++ b/docs/development-setup.md @@ -33,16 +33,21 @@ Start PostgreSQL: sudo service postgresql start ``` -Create database and user: +Create databases and user: ```bash sudo -u postgres psql -c "CREATE DATABASE laravel;" +sudo -u postgres psql -c "CREATE DATABASE laravel_test;" sudo -u postgres psql -c "CREATE USER root WITH PASSWORD 'password';" sudo -u postgres psql -c "GRANT ALL PRIVILEGES ON DATABASE laravel TO root;" +sudo -u postgres psql -c "GRANT ALL PRIVILEGES ON DATABASE laravel_test TO root;" sudo -u postgres psql -d laravel -c "GRANT ALL ON SCHEMA public TO root;" +sudo -u postgres psql -d laravel_test -c "GRANT ALL ON SCHEMA public TO root;" ``` -Run migrations: +`laravel` is for dev; `laravel_test` is where `php artisan test` runs (configured in `phpunit.xml`) so tests never clobber dev data. + +Run migrations on the dev database: ```bash php artisan migrate diff --git a/phpunit.xml b/phpunit.xml index 256e5a30..b5122b47 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -23,7 +23,9 @@ - + + + diff --git a/render.yaml b/render.yaml new file mode 100644 index 00000000..5c4e5c39 --- /dev/null +++ b/render.yaml @@ -0,0 +1,153 @@ +projects: + - name: davidhartingdotcom + environments: + - name: prod + databases: + - name: davidhartingdotcom-db + databaseName: laravel + user: laravel + plan: basic-256mb + postgresMajorVersion: "17" + region: ohio + diskSizeGB: 1 + storageAutoscalingEnabled: true + + envVarGroups: + - name: davidhartingdotcom-shared + envVars: + # ---- Non-secret runtime config ---- + - key: APP_NAME + value: davidharting.com + - key: APP_ENV + value: production + - key: APP_DEBUG + value: "false" + # Canonical production URL; keep this aligned with the custom domain below. + - key: APP_URL + value: https://davidharting.com + + - key: LOG_CHANNEL + value: stderr + - key: LOG_LEVEL + value: info + + - key: CACHE_DRIVER + value: database + - key: SESSION_DRIVER + value: database + - key: SESSION_LIFETIME + value: "120" + - key: SESSION_SECURE_COOKIE + value: "true" + - key: QUEUE_CONNECTION + value: database + - key: BROADCAST_DRIVER + value: log + + - key: DB_CONNECTION + value: pgsql + + - key: FILESYSTEM_DISK_PRIVATE + value: r2-private + - key: FILESYSTEM_DISK_PUBLIC + value: r2-public + - key: R2_PUBLIC_BUCKET + value: davidhartingdotcom-public + - key: R2_PUBLIC_URL + value: https://cdn.davidharting.com + + - key: MAIL_MAILER + value: mailersend + - key: MAIL_FROM_ADDRESS + value: hello@davidharting.com + - key: MAIL_FROM_NAME + value: davidharting.com + + - key: OCTANE_SERVER + value: frankenphp + - key: OCTANE_HTTPS + value: "false" + + # Secrets are mounted as /etc/secrets/secrets.env and linked to + # /app/.env by scripts/render-with-secrets.sh at runtime. + + services: + - name: davidhartingdotcom-web + type: web + runtime: docker + plan: starter + region: ohio + dockerfilePath: ./Dockerfile + autoDeployTrigger: checksPass + healthCheckPath: /healthz + domains: + - davidharting.com + renderSubdomainPolicy: disabled + # --caddyfile=Caddyfile is kept because without it Octane generates its own + # stub Caddyfile and we lose the FrankenPHP worker config, body-size limit, + # encode, and log redaction. $PORT is substituted inside the Caddyfile via + # Caddy's native env syntax (:{$PORT:80}). + dockerCommand: bash /app/scripts/render-with-secrets.sh php artisan octane:frankenphp --caddyfile=Caddyfile --host=0.0.0.0 --port=$PORT + # Migrations run once per deploy before the new version takes traffic. If + # any command fails, Render cancels the deploy with zero downtime. + # Telegram webhook registration is idempotent (setWebhook / setMyCommands + # are upserts on Telegram's side). + preDeployCommand: bash /app/scripts/render-with-secrets.sh bash /app/scripts/predeploy.sh + envVars: + - fromGroup: davidhartingdotcom-shared + - key: DATABASE_URL + fromDatabase: + name: davidhartingdotcom-db + property: connectionString + + - name: davidhartingdotcom-worker + type: worker + runtime: docker + plan: starter + region: ohio + dockerfilePath: ./Dockerfile + autoDeployTrigger: checksPass + # --tries=3 --backoff=30 makes the worker resilient to the ~60s window + # where it might boot new code against the old DB schema (preDeploy runs + # only on the web service; worker has its own independent pipeline). + dockerCommand: bash /app/scripts/render-with-secrets.sh php artisan queue:work -v --tries=3 --backoff=30 + envVars: + - fromGroup: davidhartingdotcom-shared + - key: DATABASE_URL + fromDatabase: + name: davidhartingdotcom-db + property: connectionString + + - name: davidhartingdotcom-backup-run + type: cron + runtime: docker + plan: starter + region: ohio + dockerfilePath: ./Dockerfile + autoDeployTrigger: checksPass + # Run a database-only backup at the top of every hour. + schedule: "0 * * * *" + dockerCommand: bash /app/scripts/render-with-secrets.sh php artisan backup:run --only-db + envVars: + - fromGroup: davidhartingdotcom-shared + - key: DATABASE_URL + fromDatabase: + name: davidhartingdotcom-db + property: connectionString + + - name: davidhartingdotcom-backup-clean + type: cron + runtime: docker + plan: starter + region: ohio + dockerfilePath: ./Dockerfile + autoDeployTrigger: checksPass + # Prune old backups once per day at midnight UTC. + schedule: "0 0 * * *" + dockerCommand: bash /app/scripts/render-with-secrets.sh php artisan backup:clean + envVars: + - fromGroup: davidhartingdotcom-shared + - key: DATABASE_URL + fromDatabase: + name: davidhartingdotcom-db + property: connectionString diff --git a/routes/console.php b/routes/console.php index 090cd543..e05f4c9a 100644 --- a/routes/console.php +++ b/routes/console.php @@ -2,7 +2,6 @@ use Illuminate\Foundation\Inspiring; use Illuminate\Support\Facades\Artisan; -use Illuminate\Support\Facades\Schedule; /* |-------------------------------------------------------------------------- @@ -18,6 +17,3 @@ Artisan::command('inspire', function () { $this->comment(Inspiring::quote()); })->purpose('Display an inspiring quote'); - -Schedule::command('backup:clean')->daily()->sendOutputTo('/dev/stderr'); -Schedule::command('backup:run --only-db')->hourly()->sendOutputTo('/dev/stderr'); diff --git a/routes/web.php b/routes/web.php index 0bf3ddc6..cf0b4e1c 100644 --- a/routes/web.php +++ b/routes/web.php @@ -8,7 +8,12 @@ use App\Http\Controllers\NotesIndexController; use App\Http\Controllers\PageController; use App\Http\Controllers\ProfileController; +use App\Http\Middleware\EncryptCookies; +use App\Http\Middleware\VerifyCsrfToken; +use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse; +use Illuminate\Session\Middleware\StartSession; use Illuminate\Support\Facades\Route; +use Illuminate\View\Middleware\ShareErrorsFromSession; use Spatie\MarkdownResponse\Middleware\ProvideMarkdownResponse; /* @@ -26,7 +31,13 @@ Route::get('/healthz', function () { return response('OK', 200); -}); +})->withoutMiddleware([ + EncryptCookies::class, + AddQueuedCookiesToResponse::class, + StartSession::class, + ShareErrorsFromSession::class, + VerifyCsrfToken::class, +]); Route::get('/', function () { return view('welcome'); diff --git a/scripts/predeploy.sh b/scripts/predeploy.sh new file mode 100644 index 00000000..31e580df --- /dev/null +++ b/scripts/predeploy.sh @@ -0,0 +1,6 @@ +#!/bin/bash +set -e + +php artisan migrate --force +php artisan nutgram:hook:set "$APP_URL/api/telegram/webhook" +php artisan nutgram:register-commands diff --git a/scripts/render-with-secrets.sh b/scripts/render-with-secrets.sh new file mode 100755 index 00000000..8ba2cd94 --- /dev/null +++ b/scripts/render-with-secrets.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +set -euo pipefail + +secrets_file="${RENDER_SECRETS_ENV_FILE:-/etc/secrets/secrets.env}" +laravel_env_file="${LARAVEL_ENV_FILE:-/app/.env}" + +if [[ $# -eq 0 ]]; then + echo "Usage: $0 [args...]" >&2 + exit 64 +fi + +if [[ ! -f "$secrets_file" ]]; then + echo "Missing Render secrets file: $secrets_file" >&2 + exit 66 +fi + +ln -sf "$secrets_file" "$laravel_env_file" + +exec "$@" diff --git a/tests/Feature/Http/HealthzTest.php b/tests/Feature/Http/HealthzTest.php new file mode 100644 index 00000000..3971c71d --- /dev/null +++ b/tests/Feature/Http/HealthzTest.php @@ -0,0 +1,17 @@ +assertDatabaseCount('sessions', 0); + + $response = $this->get('/healthz'); + + $response->assertOk(); + $response->assertSeeText('OK'); + expect($response->headers->get('Set-Cookie'))->toBeNull(); + $this->assertDatabaseCount('sessions', 0); + }); +}); diff --git a/tests/Unit/Http/Middleware/TrustProxiesTest.php b/tests/Unit/Http/Middleware/TrustProxiesTest.php new file mode 100644 index 00000000..ddb17e39 --- /dev/null +++ b/tests/Unit/Http/Middleware/TrustProxiesTest.php @@ -0,0 +1,29 @@ +headers->set('X-Forwarded-Proto', 'https'); + $request->headers->set('X-Forwarded-Port', '443'); + $request->headers->set('X-Forwarded-Host', 'attacker.example'); + $request->server->set('REMOTE_ADDR', '10.0.0.1'); + + $seenSecure = null; + $seenPort = null; + $seenHost = null; + + (new TrustProxies)->handle($request, function (Request $req) use (&$seenSecure, &$seenPort, &$seenHost) { + $seenSecure = $req->isSecure(); + $seenPort = $req->getPort(); + $seenHost = $req->getHost(); + + return new Response('ok'); + }); + + expect($seenSecure)->toBeTrue() + ->and($seenPort)->toBe(443) + ->and($seenHost)->toBe('davidharting.com'); +});