diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..7fc908e3 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +.git +.gitignore +**/.DS_Store +**/node_modules diff --git a/README.md b/README.md index f0ee72fe..ffc1d60c 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,45 @@ -# Ghost Docker +# Ghost Docker for Coolify Configuration to run Ghost and its services with Docker Compose -# Copyright & License +- [docker-compose.5.yml] - Original Coolify 5 Ghost configuration available from coolify as a template (for reference / backwards compatibility) -Copyright (c) 2013-2026 Ghost Foundation - Released under the [MIT license](LICENSE). +- [docker-compose.6.local.yml] - Modifications that allow running ghost 6 (with tinybird analytics and wizard) locally. `docker compose up -f coolify/docker-compose.6.yml -f coolify/docker-compose.6.local.yml` + +- [docker-compose.6.yml] - For use with coolify, see setup below. + +## Ghost 6 with Tinybird Analytics + +### New Install + +Requirements: Coolify UI v4, host with at least 5GB disk space and 2GB RAM + +Primary purpose was to make it easy to configure a new / existing ghost 6 instance to run from coolify, still requires tinybird at this time, but there is a useful setup tool built in (Wizard) which also functions as a gateway after configuration. + +1. In Coolify UI Project: Add Resource +2. As Public Repository: https://github.com/BadPirate/ghost-docker.git +3. Select Build Pack: "Docker Compose" +4. Docker Compose Location: `/coolify/docker-compose.6.yml` +5. General -> Domains for wizard: https://:3989 +6. Deploy +7. After deployment, visit (From links for ghost-gate or at the URL) and configure tinybird +8. Stop deployment, paste the tinybird config variables into Environment section of your deployment and relaunch +9. Profit! (?) + +### Upgrade existing coolify ghost 5 template based install + +1. In Coolify UI Project: Add Resource +2. As Public Repository: https://github.com/BadPirate/ghost-docker.git +3. Select Build Pack: "Docker Compose" +4. Docker Compose Location: `/coolify/docker-compose.6.yml` +5. General -> Domains for wizard: https://:3989 +6. Deploy +7. Stop the deployment after it successfully opens +8. On your server, cd into docker volumes `/var/lib/docker/volumes/` find the app ID for your old version ghost blog, and the app ID for the new version, and replace new empty content with existing older content: + - `rm -rf ${NEWID}_ghost-content-data; cp -r ${OLDID}_ghost-content-data ${NEWID}_ghost-content-data` + - `rm -rf ${NEWID}_ghost-mysql-data; cp -r ${OLDID}_ghost-mysql-data ${NEWID}_ghost-mysql-data` +9. In Coolify UI, copy the environment variables (including passwords for mysql) from the old template based app and put them into your new docker compose based app +10. Deploy again, ghost-gate will migrate your database +11. After deployment, visit (From links for ghost-gate or at the URL) and configure tinybird +12. Stop deployment, paste the tinybird config variables into Environment section of your deployment and relaunch +13. Profit! (?) \ No newline at end of file diff --git a/coolify/README.md b/coolify/README.md new file mode 100644 index 00000000..b0d7f60e --- /dev/null +++ b/coolify/README.md @@ -0,0 +1,45 @@ +# Ghost Docker for Coolify + +Configuration to run Ghost and its services with Docker Compose + +- [docker-compose.5.yml] - Original Coolify 5 Ghost configuration available from coolify as a template (for reference / backwards compatibility) + +- [docker-compose.6.local.yml] - Modifications that allow running ghost 6 (with tinybird analytics and wizard) locally. `docker compose up -f coolify/docker-compose.6.yml -f coolify/docker-compose.6.local.yml` + +- [docker-compose.6.yml] - Ghost 6 + Tinybird for analytics + router and wizard to ease in setup. For use with coolify, see setup below. + +## Ghost 6 with Tinybird Analytics + +### New Install + +Requirements: Coolify UI v4, host with at least 5GB disk space and 2GB RAM + +Primary purpose was to make it easy to configure a new / existing ghost 6 instance to run from coolify, still requires tinybird at this time, but there is a useful setup tool built in (Wizard) which also functions as a gateway after configuration. + +1. In Coolify UI Project: Add Resource +2. As Public Repository: https://github.com/BadPirate/ghost-docker.git +3. Select Build Pack: "Docker Compose" +4. Docker Compose Location: `/coolify/docker-compose.6.yml` +5. General -> Domains for wizard: https://:3989 +6. Deploy +7. After deployment, visit (From links for ghost-gate or at the URL) and configure tinybird +8. Stop deployment, paste the tinybird config variables into Environment section of your deployment and relaunch +9. Profit! (?) + +### Upgrade existing coolify ghost 5 template based install + +1. In Coolify UI Project: Add Resource +2. As Public Repository: https://github.com/BadPirate/ghost-docker.git +3. Select Build Pack: "Docker Compose" +4. Docker Compose Location: `/coolify/docker-compose.6.yml` +5. General -> Domains for wizard: https://:3989 +6. Deploy +7. Stop the deployment after it successfully opens +8. On your server, cd into docker volumes `/var/lib/docker/volumes/` find the app ID for your old version ghost blog, and the app ID for the new version, and replace new empty content with existing older content: + - `rm -rf ${NEWID}_ghost-content-data; cp -r ${OLDID}_ghost-content-data ${NEWID}_ghost-content-data` + - `rm -rf ${NEWID}_ghost-mysql-data; cp -r ${OLDID}_ghost-mysql-data ${NEWID}_ghost-mysql-data` +9. In Coolify UI, copy the environment variables (including passwords for mysql) from the old template based app and put them into your new docker compose based app +10. Deploy again, ghost-gate will migrate your database +11. After deployment, visit (From links for ghost-gate or at the URL) and configure tinybird +12. Stop deployment, paste the tinybird config variables into Environment section of your deployment and relaunch +13. Profit! (?) \ No newline at end of file diff --git a/coolify/deploy_local.sh b/coolify/deploy_local.sh new file mode 100755 index 00000000..b1ac52e2 --- /dev/null +++ b/coolify/deploy_local.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash + +COOLIFY_DIR=$(dirname $0) + +docker compose --project-directory $COOLIFY_DIR/.. -f $COOLIFY_DIR/docker-compose.6.yml -f $COOLIFY_DIR/docker-compose.6.local.yml up $@ diff --git a/coolify/docker-compose.5.yml b/coolify/docker-compose.5.yml new file mode 100644 index 00000000..8526ac9f --- /dev/null +++ b/coolify/docker-compose.5.yml @@ -0,0 +1,54 @@ +--- +# Legacy Ghost 5 Coolify template, kept for migrations from the old built-in Ghost +# service in Coolify. No analytics, no ActivityPub, no ghost-gate. For a new install +# prefer coolify/docker-compose.6.yml. See coolify/README.md. +services: + ghost: + image: 'ghost:5' + volumes: + - 'ghost-content-data:/var/lib/ghost/content' + environment: + - SERVICE_URL_GHOST_2368 + - url=$SERVICE_URL_GHOST + - database__client=mysql + - database__connection__host=mysql + - database__connection__user=$SERVICE_USER_MYSQL + - database__connection__password=$SERVICE_PASSWORD_MYSQL + - 'database__connection__database=${MYSQL_DATABASE-ghost}' + - mail__transport=SMTP + - 'mail__options__auth__pass=${MAIL_OPTIONS_AUTH_PASS}' + - 'mail__options__auth__user=${MAIL_OPTIONS_AUTH_USER}' + - 'mail__options__secure=${MAIL_OPTIONS_SECURE:-true}' + - 'mail__options__port=${MAIL_OPTIONS_PORT:-465}' + - 'mail__options__service=${MAIL_OPTIONS_SERVICE:-Mailgun}' + - 'mail__options__host=${MAIL_OPTIONS_HOST}' + depends_on: + mysql: + condition: service_healthy + healthcheck: + test: + - CMD + - echo + - ok + interval: 5s + timeout: 20s + retries: 10 + mysql: + image: 'mysql:8.0' + volumes: + - 'ghost-mysql-data:/var/lib/mysql' + environment: + - 'MYSQL_USER=${SERVICE_USER_MYSQL}' + - 'MYSQL_PASSWORD=${SERVICE_PASSWORD_MYSQL}' + - 'MYSQL_DATABASE=${MYSQL_DATABASE}' + - 'MYSQL_ROOT_PASSWORD=${SERVICE_PASSWORD_MYSQLROOT}' + healthcheck: + test: + - CMD + - mysqladmin + - ping + - '-h' + - 127.0.0.1 + interval: 5s + timeout: 20s + retries: 10 diff --git a/coolify/docker-compose.6.local.yml b/coolify/docker-compose.6.local.yml new file mode 100644 index 00000000..30dcbaae --- /dev/null +++ b/coolify/docker-compose.6.local.yml @@ -0,0 +1,10 @@ +services: + ghost-gate: + ports: + - "3989:3989" + environment: + SERVICE_URL_GHOST_GATE_3989: + SERVICE_URL_GHOST_GATE: http://localhost:3989 + MYSQL_USER: ${SERVICE_USER_MYSQL:-localuser} + MYSQL_PASSWORD: ${SERVICE_PASSWORD_MYSQL:-changeme} + MYSQL_ROOT_PASSWORD: ${SERVICE_PASSWORD_MYSQLROOT:-changemeroot} diff --git a/coolify/docker-compose.6.yml b/coolify/docker-compose.6.yml new file mode 100644 index 00000000..7ade139f --- /dev/null +++ b/coolify/docker-compose.6.yml @@ -0,0 +1,196 @@ +services: + ghost-gate: + build: + context: . + dockerfile: ghost-gate/Dockerfile + args: + GHOST_VERSION: ${GHOST_VERSION:-6-alpine} + depends_on: + mysql: + condition: service_healthy + environment: + SERVICE_URL_GHOST_GATE_3989: + SERVICE_URL_GHOST_GATE: + PROXY_GHOST_UPSTREAM: http://ghost:2368 + PROXY_ANALYTICS_UPSTREAM: http://traffic-analytics:3000 + PROXY_ACTIVITYPUB_UPSTREAM: http://activitypub:8080 + MYSQL_HOST: mysql + MYSQL_PORT: "3306" + MYSQL_ROOT_PASSWORD: ${SERVICE_PASSWORD_MYSQLROOT} + MYSQL_USER: ${SERVICE_USER_MYSQL} + MYSQL_PASSWORD: ${SERVICE_PASSWORD_MYSQL} + MYSQL_MULTIPLE_DATABASES: ${MYSQL_MULTIPLE_DATABASES:-activitypub} + MAIL_OPTIONS_AUTH_PASS: ${MAIL_OPTIONS_AUTH_PASS:-mailgun_smtp_user_password} + MAIL_OPTIONS_AUTH_USER: ${MAIL_OPTIONS_AUTH_USER:-local@localhost} + MAIL_OPTIONS_PORT: ${MAIL_OPTIONS_PORT:-465} + MAIL_OPTIONS_SECURE: ${MAIL_OPTIONS_SECURE:-true} + MAIL_OPTIONS_SERVICE: ${MAIL_OPTIONS_SERVICE:-Mailgun} + MAIL_OPTIONS_HOST: ${MAIL_OPTIONS_HOST:-smtp.mailgun.org} + # ghost-gate serves the setup page until all four TINYBIRD_* vars are set; after + # that it switches to reverse-proxy mode on next start. + TINYBIRD_API_URL: ${TINYBIRD_API_URL:-} + TINYBIRD_WORKSPACE_ID: ${TINYBIRD_WORKSPACE_ID:-} + TINYBIRD_ADMIN_TOKEN: ${TINYBIRD_ADMIN_TOKEN:-} + TINYBIRD_TRACKER_TOKEN: ${TINYBIRD_TRACKER_TOKEN:-} + expose: + - "3989" + restart: unless-stopped + healthcheck: + test: + - "CMD-SHELL" + - "wget -q -S -T 3 --spider http://127.0.0.1:3989/__ghost_gate/health 2>&1 | grep -q 'HTTP/1.1 200'" + interval: 5s + timeout: 5s + retries: 24 + start_period: 30s + + ghost: + image: ghost:${GHOST_VERSION:-6-alpine} + environment: + url: ${SERVICE_URL_GHOST_GATE} + NODE_ENV: production + admin__url: ${SERVICE_URL_GHOST_GATE} + database__client: mysql + database__connection__host: mysql + database__connection__user: ${SERVICE_USER_MYSQL} + database__connection__password: ${SERVICE_PASSWORD_MYSQL} + database__connection__database: ${MYSQL_DATABASE-ghost} + mail__transport: SMTP + mail__from: ${MAIL_FROM} + mail__options__auth__pass: ${MAIL_OPTIONS_AUTH_PASS} + mail__options__auth__user: ${MAIL_OPTIONS_AUTH_USER} + mail__options__secure: ${MAIL_OPTIONS_SECURE:-true} + mail__options__port: ${MAIL_OPTIONS_PORT:-465} + mail__options__service: ${MAIL_OPTIONS_SERVICE:-Mailgun} + mail__options__host: ${MAIL_OPTIONS_HOST} + # Same-origin tracker (no CORS). ghost-gate strips /.ghost/analytics and forwards + # to traffic-analytics:3000/api/v1/page_hit. + tinybird__tracker__endpoint: ${SERVICE_URL_GHOST_GATE}/.ghost/analytics/api/v1/page_hit + labs__publicAPI: "true" + tinybird__adminToken: ${TINYBIRD_ADMIN_TOKEN} + tinybird__workspaceId: ${TINYBIRD_WORKSPACE_ID} + tinybird__tracker__datasource: analytics_events + # Admin/stats read from Tinybird API directly (not via traffic-analytics). + tinybird__stats__endpoint: ${TINYBIRD_API_URL:-https://api.tinybird.co} + volumes: + - 'ghost-content-data:/var/lib/ghost/content' + depends_on: + mysql: + condition: service_healthy + ghost-gate: + condition: service_healthy + activitypub: + condition: service_started + required: false + # Ghost redirects `/` to its public URL, so `wget --spider` follows the redirect + # out of the container. Instead, treat "server answered with any HTTP status line" + # as alive (busybox wget prints `HTTP/1.1 …` on stderr with -S even for redirects). + healthcheck: + test: + - "CMD-SHELL" + - "wget -q -S -T 3 --spider http://127.0.0.1:2368/ 2>&1 | grep -q 'HTTP/'" + interval: 10s + timeout: 5s + retries: 18 + start_period: 120s + + mysql: + image: mysql:8.0.44@sha256:f37951fc3753a6a22d6c7bf6978c5e5fefcf6f31814d98c582524f98eae52b21 + restart: always + environment: + MYSQL_USER: ${SERVICE_USER_MYSQL} + MYSQL_PASSWORD: ${SERVICE_PASSWORD_MYSQL} + MYSQL_DATABASE: ${MYSQL_DATABASE} + MYSQL_ROOT_PASSWORD: ${SERVICE_PASSWORD_MYSQLROOT} + volumes: + - 'ghost-mysql-data:/var/lib/mysql' + healthcheck: + test: mysqladmin ping -p$$MYSQL_ROOT_PASSWORD -h 127.0.0.1 + interval: 1s + start_period: 30s + start_interval: 5s + timeout: 20s + retries: 10 + + traffic-analytics: + image: ghost/traffic-analytics:1.0.196@sha256:f93abf4134db41d94d90bb0ef6e4fcc67dfd91162f6ad15423c51e3c49f9c3a7 + restart: always + depends_on: + ghost-gate: + condition: service_healthy + expose: + - "3000" + volumes: + - traffic_analytics_data:/data + environment: + NODE_ENV: production + # Behind Traefik/Coolify; client IP from X-Forwarded-For (see TryGhost/TrafficAnalytics). + TRUST_PROXY: "true" + PROXY_TARGET: ${TINYBIRD_API_URL:-https://api.tinybird.co}/v0/events + SALT_STORE_TYPE: ${SALT_STORE_TYPE:-file} + SALT_STORE_FILE_PATH: /data/salts.json + TINYBIRD_TRACKER_TOKEN: ${TINYBIRD_TRACKER_TOKEN:-} + LOG_LEVEL: debug + healthcheck: + test: + - "CMD-SHELL" + - "wget -q -S -T 3 --spider http://127.0.0.1:3000/ 2>&1 | grep -q 'HTTP/'" + interval: 10s + timeout: 5s + retries: 6 + start_period: 15s + + activitypub: + image: ghcr.io/tryghost/activitypub:1.2.2@sha256:128f0d08d872930b4ab37c9fc1fe8042fefd44622316b05f3885bd068be7cc43 + restart: always + expose: + - "8080" + volumes: + - 'ghost-content-data:/opt/activitypub/content' + environment: + NODE_ENV: production + MYSQL_HOST: mysql + MYSQL_USER: ${SERVICE_USER_MYSQL} + MYSQL_PASSWORD: ${SERVICE_PASSWORD_MYSQL} + MYSQL_DATABASE: activitypub + LOCAL_STORAGE_PATH: /opt/activitypub/content/images/activitypub + LOCAL_STORAGE_HOSTING_URL: ${SERVICE_URL_GHOST_GATE}/content/images/activitypub + depends_on: + mysql: + condition: service_healthy + activitypub-migrate: + condition: service_completed_successfully + # activitypub (alpine + BusyBox wget) has no documented health URL; accept any HTTP + # response on :8080 as "process listening". + healthcheck: + test: + - "CMD-SHELL" + - "wget -q -S -T 3 --spider http://127.0.0.1:8080/ 2>&1 | grep -q 'HTTP/'" + interval: 10s + timeout: 5s + retries: 6 + start_period: 30s + + activitypub-migrate: + image: ghcr.io/tryghost/activitypub-migrations:1.2.2@sha256:2af8a0726ac4362cdcab59c308ed612140478d43011ec8d3475bb2634b96d108 + environment: + MYSQL_DB: mysql://${SERVICE_USER_MYSQL}:${SERVICE_PASSWORD_MYSQL}@tcp(mysql:3306)/activitypub + depends_on: + mysql: + condition: service_healthy + ghost-gate: + condition: service_healthy + restart: no + # One-shot job with no HTTP server; report healthy unconditionally. Downstream + # services gate on service_completed_successfully, not service_healthy. + healthcheck: + test: ["CMD-SHELL", "true"] + interval: 10s + timeout: 3s + retries: 1 + start_period: 5s + +volumes: + ghost-content-data: + ghost-mysql-data: + traffic_analytics_data: diff --git a/ghost-gate/.dockerignore b/ghost-gate/.dockerignore new file mode 100644 index 00000000..93f13619 --- /dev/null +++ b/ghost-gate/.dockerignore @@ -0,0 +1,2 @@ +node_modules +npm-debug.log diff --git a/ghost-gate/Dockerfile b/ghost-gate/Dockerfile new file mode 100644 index 00000000..859648c3 --- /dev/null +++ b/ghost-gate/Dockerfile @@ -0,0 +1,44 @@ +# Tinybird datafiles must stay in sync with Ghost — bump GHOST_VERSION when upgrading Ghost. +ARG GHOST_VERSION=6-alpine +FROM ghost:${GHOST_VERSION} AS ghost-tinybird +USER root +RUN mkdir -p /export/tinybird && \ + cp -a /var/lib/ghost/current/core/server/data/tinybird/. /export/tinybird/ + +FROM node:22-bookworm-slim + +# wget is used at runtime by the Docker healthcheck (lightest option already present on +# most base images). curl is required transiently because the Tinybird install script +# shells out to `curl` — it is purged after the install so it does not ship in the final +# image layer. +RUN apt-get update && apt-get install -y --no-install-recommends \ + bash \ + wget \ + curl \ + jq \ + ca-certificates \ + && curl -sSf https://tinybird.co | sh \ + && apt-get purge -y curl \ + && apt-get autoremove -y \ + && rm -rf /var/lib/apt/lists/* +ENV PATH="/root/.local/bin:${PATH}" + +WORKDIR /data/tinybird +COPY --from=ghost-tinybird /export/tinybird ./ +WORKDIR /app + +COPY tinybird/tb-wrapper /usr/local/bin/tb-wrapper +COPY tinybird/getTokens.sh /usr/local/bin/get-tokens +RUN chmod +x /usr/local/bin/tb-wrapper /usr/local/bin/get-tokens + +COPY ghost-gate/package.json ghost-gate/package-lock.json ./ +RUN npm ci --omit=dev + +COPY ghost-gate/server.js ./ +COPY ghost-gate/entrypoint.sh /app/entrypoint.sh +RUN chmod +x /app/entrypoint.sh + +ENV PORT=3989 +EXPOSE 3989 + +ENTRYPOINT ["/app/entrypoint.sh"] diff --git a/ghost-gate/entrypoint.sh b/ghost-gate/entrypoint.sh new file mode 100644 index 00000000..75c97623 --- /dev/null +++ b/ghost-gate/entrypoint.sh @@ -0,0 +1,6 @@ +#!/bin/sh +# ghost-gate is always-running: server.js picks proxy vs setup mode based on whether +# the four TINYBIRD_* env vars are set. +set -e +mkdir -p /home/tinybird +exec node /app/server.js diff --git a/ghost-gate/package-lock.json b/ghost-gate/package-lock.json new file mode 100644 index 00000000..578cd3d0 --- /dev/null +++ b/ghost-gate/package-lock.json @@ -0,0 +1,1146 @@ +{ + "name": "ghost-gate", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ghost-gate", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "express": "^4.21.2", + "http-proxy-middleware": "^2.0.9", + "mysql2": "^3.11.5" + } + }, + "node_modules/@types/http-proxy": { + "version": "1.17.17", + "resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.17.tgz", + "integrity": "sha512-ED6LB+Z1AVylNTu7hdzuBqOgMnvG/ld6wGCG8wFnAzKX5uyW2K3WD52v0gnLCTK/VLpXtKckgWuyScYK6cSPaw==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/node": { + "version": "25.6.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", + "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.19.0" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/aws-ssl-profiles": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", + "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/body-parser": { + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generate-function": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", + "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", + "license": "MIT", + "dependencies": { + "is-property": "^1.0.2" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/http-proxy": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", + "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==", + "license": "MIT", + "dependencies": { + "eventemitter3": "^4.0.0", + "follow-redirects": "^1.0.0", + "requires-port": "^1.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/http-proxy-middleware": { + "version": "2.0.9", + "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.9.tgz", + "integrity": "sha512-c1IyJYLYppU574+YI7R4QyX2ystMtVXZwIdzazUIPIJsHuWNd+mho2j+bKoHftndicGj9yh+xjd+l0yj7VeT1Q==", + "license": "MIT", + "dependencies": { + "@types/http-proxy": "^1.17.8", + "http-proxy": "^1.18.1", + "is-glob": "^4.0.1", + "is-plain-obj": "^3.0.0", + "micromatch": "^4.0.2" + }, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "@types/express": "^4.17.13" + }, + "peerDependenciesMeta": { + "@types/express": { + "optional": true + } + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-plain-obj": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-3.0.0.tgz", + "integrity": "sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-property": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", + "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", + "license": "MIT" + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/lru.min": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz", + "integrity": "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==", + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=1.30.0", + "node": ">=8.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wellwelwel" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/mysql2": { + "version": "3.22.2", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.22.2.tgz", + "integrity": "sha512-snC/L6YoCJPFpozZo3p3hiOlt9ItQ7sCnLSziFLlIttEzsPhrdcPT8g21BiQ7Oqif25W4Xq1IFuBzBvoFYDf0Q==", + "license": "MIT", + "dependencies": { + "aws-ssl-profiles": "^1.1.2", + "denque": "^2.1.0", + "generate-function": "^2.3.1", + "iconv-lite": "^0.7.2", + "long": "^5.3.2", + "lru.min": "^1.1.4", + "named-placeholders": "^1.1.6", + "sql-escaper": "^1.3.3" + }, + "engines": { + "node": ">= 8.0" + }, + "peerDependencies": { + "@types/node": ">= 8" + } + }, + "node_modules/mysql2/node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/named-placeholders": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz", + "integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==", + "license": "MIT", + "dependencies": { + "lru.min": "^1.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "license": "MIT" + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/sql-escaper": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.3.3.tgz", + "integrity": "sha512-BsTCV265VpTp8tm1wyIm1xqQCS+Q9NHx2Sr+WcnUrgLrQ6yiDIvHYJV5gHxsj1lMBy2zm5twLaZao8Jd+S8JJw==", + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=2.0.0", + "node": ">=12.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/mysqljs/sql-escaper?sponsor=1" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/undici-types": { + "version": "7.19.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", + "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + } + } +} diff --git a/ghost-gate/package.json b/ghost-gate/package.json new file mode 100644 index 00000000..fd445cde --- /dev/null +++ b/ghost-gate/package.json @@ -0,0 +1,16 @@ +{ + "name": "ghost-gate", + "private": true, + "description": "Reverse-proxy + Tinybird setup wizard for Ghost on Coolify", + "main": "server.js", + "dependencies": { + "express": "^4.21.2", + "http-proxy-middleware": "^2.0.9", + "mysql2": "^3.11.5" + }, + "version": "1.0.0", + "scripts": { + "start": "node server.js" + }, + "license": "MIT" +} diff --git a/ghost-gate/server.js b/ghost-gate/server.js new file mode 100644 index 00000000..49799a3e --- /dev/null +++ b/ghost-gate/server.js @@ -0,0 +1,898 @@ +'use strict'; + +const express = require('express'); +const fs = require('fs/promises'); +const { execFile } = require('child_process'); +const util = require('util'); +const { createProxyMiddleware } = require('http-proxy-middleware'); +const mysql = require('mysql2/promise'); + +const execFileAsync = util.promisify(execFile); + +const port = Number.parseInt(process.env.PORT || '3989', 10); +const baseUrl = ( + process.env.SERVICE_URL_GHOST_GATE || + process.env.SERVICE_URL_GHOST || + `http://127.0.0.1:${port}` +).replace(/\/$/, ''); + +/** Upstreams for proxy mode. Override via env. */ +const GHOST_UPSTREAM = (process.env.PROXY_GHOST_UPSTREAM || 'http://ghost:2368').replace(/\/$/, ''); +const ANALYTICS_UPSTREAM = (process.env.PROXY_ANALYTICS_UPSTREAM || 'http://traffic-analytics:3000').replace(/\/$/, ''); +const ACTIVITYPUB_UPSTREAM = (process.env.PROXY_ACTIVITYPUB_UPSTREAM || 'http://activitypub:8080').replace(/\/$/, ''); +const ANALYTICS_PREFIX = '/.ghost/analytics'; +/** Paths routed to the ActivityPub service (forwarded as-is, no prefix strip — matches caddy/snippets/ActivityPub). */ +const ACTIVITYPUB_PREFIXES = [ + '/.ghost/activitypub', + '/.well-known/webfinger', + '/.well-known/nodeinfo', +]; + +// The browser posts analytics to `${SERVICE_URL_GHOST_GATE}/.ghost/analytics/api/v1/page_hit` +// (set on Ghost via tinybird__tracker__endpoint). ghost-gate strips the prefix and forwards +// to traffic-analytics:3000; Tinybird's own /v0/events endpoint is never contacted from +// the client, so TINYBIRD_TRACKER_ENDPOINT is intentionally absent from this list. +const TINYB_ENV_KEYS = [ + 'TINYBIRD_API_URL', + 'TINYBIRD_WORKSPACE_ID', + 'TINYBIRD_ADMIN_TOKEN', + 'TINYBIRD_TRACKER_TOKEN', +]; + +function nonEmpty(v) { + return typeof v === 'string' && v.replace(/\s+/g, '') !== ''; +} + +/** Setup is complete once the four TINYBIRD_* env vars are set. */ +function isTinybirdReady() { + return TINYB_ENV_KEYS.every((k) => nonEmpty(process.env[k])); +} + +// ------------------------------ MySQL bootstrap ------------------------------ +// Absorbed from mysql-init/mysql-app-bootstrap.sh: idempotent sync of app user +// password + CREATE DATABASE/GRANT for each comma-separated name in +// MYSQL_MULTIPLE_DATABASES. Runs once at startup against mysql:3306. +const MYSQL_CFG = { + host: process.env.MYSQL_HOST || 'mysql', + port: Number.parseInt(process.env.MYSQL_PORT || '3306', 10), + rootPassword: process.env.MYSQL_ROOT_PASSWORD || '', + user: process.env.MYSQL_USER || '', + password: process.env.MYSQL_PASSWORD || '', + multipleDatabases: process.env.MYSQL_MULTIPLE_DATABASES || '', +}; + +/** Set to true once the app user + databases + grants exist. Gates Docker healthcheck. */ +let bootstrapDone = false; +/** Last bootstrap error, reported on the /__ghost_gate/health endpoint for debugging. */ +let bootstrapError = null; + +async function connectAsRoot() { + const deadline = Date.now() + 60_000; + let lastErr = null; + while (Date.now() < deadline) { + try { + return await mysql.createConnection({ + host: MYSQL_CFG.host, + port: MYSQL_CFG.port, + user: 'root', + password: MYSQL_CFG.rootPassword, + connectTimeout: 5000, + }); + } catch (e) { + lastErr = e; + await new Promise((r) => setTimeout(r, 1000)); + } + } + throw new Error( + `mysql not reachable at ${MYSQL_CFG.host}:${MYSQL_CFG.port} after 60s: ${ + lastErr instanceof Error ? lastErr.message : String(lastErr) + }` + ); +} + +async function bootstrapMysql() { + if (!MYSQL_CFG.rootPassword) { + throw new Error('MYSQL_ROOT_PASSWORD is required'); + } + if (!MYSQL_CFG.user || !MYSQL_CFG.password) { + throw new Error('MYSQL_USER and MYSQL_PASSWORD are required'); + } + + const conn = await connectAsRoot(); + try { + // Create-or-update app user (password sync mirrors ALTER USER in the old script). + await conn.query("CREATE USER IF NOT EXISTS ?@'%' IDENTIFIED BY ?", [ + MYSQL_CFG.user, + MYSQL_CFG.password, + ]); + await conn.query("ALTER USER ?@'%' IDENTIFIED BY ?", [ + MYSQL_CFG.user, + MYSQL_CFG.password, + ]); + + const dbs = MYSQL_CFG.multipleDatabases + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + for (const db of dbs) { + const ident = mysql.escapeId(db); // wraps in backticks, escapes inner backticks + await conn.query(`CREATE DATABASE IF NOT EXISTS ${ident}`); + await conn.query(`GRANT ALL ON ${ident}.* TO ?@'%'`, [MYSQL_CFG.user]); + } + await conn.query('FLUSH PRIVILEGES'); + } finally { + try { + await conn.end(); + } catch { + /* ignore */ + } + } +} + +async function runBootstrapWithRetry() { + try { + await bootstrapMysql(); + bootstrapDone = true; + bootstrapError = null; + console.log( + `mysql bootstrap complete (${MYSQL_CFG.user}@${MYSQL_CFG.host}:${MYSQL_CFG.port}${ + MYSQL_CFG.multipleDatabases ? `, dbs=[${MYSQL_CFG.multipleDatabases}]` : '' + })` + ); + } catch (e) { + bootstrapError = e instanceof Error ? e.message : String(e); + console.error('mysql bootstrap failed:', bootstrapError); + // Leave bootstrapDone=false so Docker healthcheck keeps the service unhealthy. + // Retry every 30s so transient mysql flaps can self-heal without a restart. + setTimeout(runBootstrapWithRetry, 30_000); + } +} + +const TINYB_HOME = '/home/tinybird/.tinyb'; +const TB_DATA = '/data/tinybird'; +/** Tinybird CLI (installed in Docker image) */ +const TB = '/root/.local/bin/tb'; +const PATH_WITH_TB = `/root/.local/bin:${process.env.PATH || '/usr/bin:/bin'}`; + +/** + * Known legacy aliases for the default region (api.tinybird.co maps to gcp-europe-west3). + * Used only as a fallback when a slug does not match the cloud-prefixed shape. + * @see https://www.tinybird.co/docs/api-reference/overview#regions-and-endpoints + */ +const TINYBIRD_HOST_TO_API = { + 'gcp-europe-west3': 'https://api.tinybird.co', + eu_shared: 'https://api.tinybird.co', + us_east: 'https://api.us-east.tinybird.co', +}; + +function hostSlugToApiBaseUrl(host) { + if (!host || typeof host !== 'string') return null; + const h = host.trim(); + // Primary: derive from the documented slug shape `-`. + if (h.startsWith('gcp-')) return `https://api.${h.slice(4)}.gcp.tinybird.co`; + if (h.startsWith('aws-')) return `https://api.${h.slice(4)}.aws.tinybird.co`; + // Fallback for any known exceptions (see TINYBIRD_HOST_TO_API above). + return TINYBIRD_HOST_TO_API[h] || null; +} + +/** + * Decode Tinybird static token payload (p. plus base64 JSON payload, or JWT). + * Payload includes `host` (region slug) for matching API URL. + */ +function parseTinybirdTokenPayload(token) { + const raw = typeof token === 'string' ? token.trim() : ''; + if (!raw) return null; + + let b64Payload = null; + if (raw.startsWith('p.')) { + const rest = raw.slice(2); + const parts = rest.split('.'); + if (parts.length >= 1) b64Payload = parts[0]; + } else { + const parts = raw.split('.'); + if (parts.length === 3) b64Payload = parts[1]; + else if (parts.length === 1) b64Payload = parts[0]; + } + if (!b64Payload) return null; + + try { + const json = Buffer.from(b64Payload, 'base64url').toString('utf8'); + return JSON.parse(json); + } catch { + try { + const json = Buffer.from(b64Payload, 'base64').toString('utf8'); + return JSON.parse(json); + } catch { + return null; + } + } +} + +function isDefaultTinybirdApiUrl(url) { + const n = (url || '').trim().replace(/\/$/, '').toLowerCase(); + return n === 'https://api.tinybird.co' || n === 'http://api.tinybird.co'; +} + +/** + * If the user leaves the default global URL but the token is regional, use the URL that matches the token. + */ +function resolveApiHostForDeploy(userApiUrl, adminToken) { + const user = (userApiUrl || '').trim().replace(/\/$/, ''); + const payload = parseTinybirdTokenPayload(adminToken); + const tokenHost = + payload && typeof payload.host === 'string' ? payload.host.trim() : null; + const fromToken = tokenHost ? hostSlugToApiBaseUrl(tokenHost) : null; + + if (!user) { + if (fromToken) { + return { + resolved: fromToken, + tokenHost, + note: 'API URL taken from your admin token region.', + }; + } + return { + resolved: 'https://api.tinybird.co', + tokenHost: null, + note: null, + }; + } + + if (isDefaultTinybirdApiUrl(user) && fromToken) { + return { + resolved: fromToken, + tokenHost, + note: + 'Using regional API URL from your admin token (default api.tinybird.co does not match this workspace region).', + }; + } + + return { resolved: user, tokenHost, note: null }; +} + +function normTokenName(name) { + return typeof name === 'string' ? name.trim().toLowerCase() : ''; +} + +/** Tinybird may return the secret on alternate keys; list responses sometimes omit secrets. */ +function getTokenSecret(t) { + if (!t || typeof t !== 'object') return ''; + const s = t.token ?? t.value ?? t.secret; + return typeof s === 'string' && s.length > 0 ? s : ''; +} + +function normalizeTokensArray(data) { + if (!data || typeof data !== 'object') return []; + if (Array.isArray(data.tokens)) return data.tokens; + if (Array.isArray(data.data)) return data.data; + if (data.tokens && typeof data.tokens === 'object' && Array.isArray(data.tokens.items)) { + return data.tokens.items; + } + if (Array.isArray(data)) return data; + return []; +} + +/** True if token scopes allow appending to analytics_events (Ghost’s datasource). */ +function hasAnalyticsEventsAppendScope(t) { + const scopes = t && t.scopes; + if (!Array.isArray(scopes)) return false; + const ds = 'analytics_events'; + for (const s of scopes) { + if (typeof s === 'string') { + const u = s.toUpperCase(); + if (u.includes('DATASOURCES:APPEND') && s.includes(ds)) return true; + continue; + } + if (s && typeof s === 'object') { + const ty = String(s.type || s.scope || '').toUpperCase(); + const res = String(s.resource || ''); + if (res === ds && (ty.includes('APPEND') || ty.includes('DATASOURCES:APPEND'))) { + return true; + } + } + } + return false; +} + +/** + * Prefer name "tracker" (Ghost deploy); else scope DATASOURCES:APPEND:analytics_events. + */ +function pickTrackerSecret(tokens) { + const list = Array.isArray(tokens) ? tokens : []; + let byScope = null; + + for (const t of list) { + const secret = getTokenSecret(t); + if (!secret) continue; + const n = normTokenName(t.name); + if (n === 'tracker') return secret; + } + for (const t of list) { + const secret = getTokenSecret(t); + if (!secret) continue; + const n = normTokenName(t.name); + if (n.includes('tracker')) return secret; + } + for (const t of list) { + const secret = getTokenSecret(t); + if (!secret) continue; + if (hasAnalyticsEventsAppendScope(t)) { + byScope = secret; + break; + } + } + return byScope; +} + +/** + * Same data as `docker compose … get-tokens` / GET /v0/tokens, using a workspace admin token. + * The `tracker` token is created when Ghost’s Tinybird project is deployed (TOKEN "tracker" in + * analytics_events.datasource)—so the first lookup may fill API URL / workspace / admin only. + * @see https://www.tinybird.co/docs/api-reference/token-api + */ +async function lookupEnvFromAdminToken(adminToken) { + const raw = typeof adminToken === 'string' ? adminToken.trim() : ''; + if (!raw) { + throw new Error('Paste your workspace admin token first.'); + } + const payload = parseTinybirdTokenPayload(raw); + if (!payload || !payload.host) { + throw new Error( + 'Could not read region from this token. Use a Tinybird workspace admin token (usually starts with p.).' + ); + } + const tokenHost = String(payload.host).trim(); + const apiBase = hostSlugToApiBaseUrl(tokenHost); + if (!apiBase) { + throw new Error(`Unknown region in token: ${tokenHost}`); + } + const workspaceId = + payload.id != null && String(payload.id).trim() !== '' + ? String(payload.id).trim() + : null; + if (!workspaceId) { + throw new Error('Could not read workspace id from token payload.'); + } + + const url = `${apiBase.replace(/\/$/, '')}/v0/tokens`; + const r = await fetch(url, { + headers: { + Authorization: `Bearer ${raw}`, + Accept: 'application/json', + }, + }); + const text = await r.text(); + if (!r.ok) { + throw new Error( + `Tinybird Token API returned ${r.status}. Fill API URL, workspace ID, and tokens manually from Tinybird Cloud, or use Advanced CLI auth. ${text.slice(0, 200)}` + ); + } + let data; + try { + data = JSON.parse(text); + } catch { + throw new Error('Invalid JSON from Tinybird Token API.'); + } + const tokens = normalizeTokensArray(data); + let admin = raw; + for (const t of tokens) { + const secret = getTokenSecret(t); + if (!secret) continue; + if (normTokenName(t.name) === 'workspace admin token') { + admin = secret; + break; + } + } + + const tracker = pickTrackerSecret(tokens); + const incomplete = !tracker; + const lookupHint = incomplete + ? 'No tracker token yet—that is normal before the first analytics publish. Run step 3 (Publish), then click Look up again, or paste the tracker from Tinybird Cloud → Tokens (name: tracker).' + : ''; + + return { + TINYBIRD_API_URL: apiBase, + TINYBIRD_WORKSPACE_ID: workspaceId, + TINYBIRD_ADMIN_TOKEN: admin, + TINYBIRD_TRACKER_TOKEN: tracker || '', + TINYBIRD_LOOKUP_INCOMPLETE: incomplete, + lookupHint, + }; +} + +function parseEnvLines(text) { + const out = {}; + for (const line of text.split('\n')) { + const t = line.trim(); + if (!t) continue; + const i = t.indexOf('='); + if (i > 0) out[t.slice(0, i)] = t.slice(i + 1); + } + return out; +} + +function validateTinybShape(obj) { + return ( + obj && + typeof obj === 'object' && + typeof obj.token === 'string' && + obj.token.length > 0 && + typeof obj.host === 'string' && + obj.host.length > 0 && + typeof obj.id === 'string' && + obj.id.length > 0 + ); +} + +async function fetchEnvFromTinybird() { + await fs.copyFile(TINYB_HOME, `${TB_DATA}/.tinyb`); + const { stdout, stderr } = await execFileAsync('/usr/local/bin/get-tokens', [], { + cwd: TB_DATA, + env: { ...process.env, PATH: PATH_WITH_TB }, + maxBuffer: 10 * 1024 * 1024, + }); + if (stderr && stderr.trim()) { + console.error('get-tokens stderr:', stderr); + } + return parseEnvLines(stdout); +} + +async function deployWithUiToken(apiUrl, adminToken) { + const { stdout, stderr } = await execFileAsync( + TB, + ['--cloud', '--host', apiUrl, '--token', adminToken, 'deploy'], + { + cwd: TB_DATA, + env: { ...process.env, PATH: PATH_WITH_TB }, + maxBuffer: 10 * 1024 * 1024, + } + ); + return { stdout: stdout || '', stderr: stderr || '' }; +} + +async function deployWithTinybFile() { + const { stdout, stderr } = await execFileAsync( + '/usr/local/bin/tb-wrapper', + ['--cloud', 'deploy'], + { + cwd: TB_DATA, + env: { ...process.env, PATH: PATH_WITH_TB }, + maxBuffer: 10 * 1024 * 1024, + } + ); + return { stdout: stdout || '', stderr: stderr || '' }; +} + +function formatTinybirdEnvBlock(env) { + const a = env.TINYBIRD_API_URL || ''; + const w = env.TINYBIRD_WORKSPACE_ID || ''; + const adm = env.TINYBIRD_ADMIN_TOKEN || ''; + const tr = env.TINYBIRD_TRACKER_TOKEN || ''; + return [ + `TINYBIRD_API_URL=${a}`, + `TINYBIRD_WORKSPACE_ID=${w}`, + `TINYBIRD_ADMIN_TOKEN=${adm}`, + `TINYBIRD_TRACKER_TOKEN=${tr}`, + ].join('\n'); +} + +/** + * One-shot: resolve tokens from workspace admin token; deploy Ghost Tinybird schema if tracker missing; refetch. + * @returns {{ ok: true, env: object, envText: string, log: string } | { ok: false, error: string, log: string }} + */ +async function generateTinybirdEnvFromAdminToken(pastedAdminToken) { + const raw = typeof pastedAdminToken === 'string' ? pastedAdminToken.trim() : ''; + const lines = []; + const push = (s) => { + if (s != null && String(s).trim() !== '') lines.push(String(s)); + }; + const fail = (error) => ({ ok: false, error, log: lines.join('\n') }); + + if (!raw) { + return fail('Paste your workspace admin token.'); + } + + let env; + try { + push('Looking up workspace and tokens from Tinybird…'); + env = await lookupEnvFromAdminToken(raw); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + push(msg); + return fail(msg); + } + + if (!env.TINYBIRD_TRACKER_TOKEN) { + push('Tracker token not found — publishing Ghost analytics schema (first run can take 1–2 minutes)…'); + const { resolved, note } = resolveApiHostForDeploy( + env.TINYBIRD_API_URL, + env.TINYBIRD_ADMIN_TOKEN + ); + if (note) push(note); + try { + const { stdout, stderr } = await deployWithUiToken(resolved, env.TINYBIRD_ADMIN_TOKEN); + push(stdout); + push(stderr); + } catch (e) { + const err = /** @type {Error & { stdout?: string; stderr?: string }} */ (e); + push(err.stdout || ''); + push(err.stderr || ''); + return fail(err.message || String(e)); + } + push('Refreshing token list…'); + try { + env = await lookupEnvFromAdminToken(raw); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + push(msg); + return fail(msg); + } + } + + if (!env.TINYBIRD_TRACKER_TOKEN) { + return fail( + 'Tracker token still missing after publish. Open Tinybird Cloud → Tokens, confirm a "tracker" token exists, wait a few seconds, and try again.' + ); + } + + push('Done.'); + const envText = formatTinybirdEnvBlock(env); + return { + ok: true, + env, + envText, + log: lines.join('\n'), + }; +} + +const app = express(); +// Traefik/Coolify sits in front; forward client IP to upstreams. +app.set('trust proxy', true); + +// Docker healthcheck: healthy as soon as the DB bootstrap finished. We deliberately do +// NOT gate on proxy mode — if we did, the ingress (Traefik/Coolify) would refuse to +// route to this container on first launch, so the operator could never reach the setup +// page to complete Tinybird configuration. Downstream services that depend_on this one +// only need the DB app user to exist, which bootstrapDone guarantees. +app.get('/__ghost_gate/health', (_req, res) => { + const body = { + ok: bootstrapDone, + bootstrapDone, + bootstrapError, + proxyMode: isTinybirdReady(), + }; + return res.status(bootstrapDone ? 200 : 503).json(body); +}); + +// --- Setup UI / API (only mounted when TINYBIRD_* is incomplete) ----------------- +const gateRouter = express.Router(); +gateRouter.use(express.json({ limit: '256kb' })); + +gateRouter.get('/api/tinybird/status', async (_req, res) => { + try { + await fs.access(TINYB_HOME); + return res.json({ hasTinyb: true }); + } catch { + return res.json({ hasTinyb: false }); + } +}); + +gateRouter.post('/api/tinybird/lookup', async (req, res) => { + const adminToken = + typeof req.body?.adminToken === 'string' ? req.body.adminToken.trim() : ''; + try { + const env = await lookupEnvFromAdminToken(adminToken); + return res.json({ ok: true, ...env }); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + return res.status(400).json({ ok: false, error: msg }); + } +}); + +gateRouter.post('/api/tinybird/generate', async (req, res) => { + const adminToken = + typeof req.body?.adminToken === 'string' ? req.body.adminToken.trim() : ''; + const out = await generateTinybirdEnvFromAdminToken(adminToken); + if (!out.ok) { + const status = + /paste your workspace admin token|could not read region|workspace id|invalid json|tinybird token api returned 4/i.test( + out.error + ) + ? 400 + : 500; + return res.status(status).json({ ok: false, error: out.error, log: out.log }); + } + return res.json({ + ok: true, + log: out.log, + envText: out.envText, + TINYBIRD_API_URL: out.env.TINYBIRD_API_URL, + TINYBIRD_WORKSPACE_ID: out.env.TINYBIRD_WORKSPACE_ID, + TINYBIRD_ADMIN_TOKEN: out.env.TINYBIRD_ADMIN_TOKEN, + TINYBIRD_TRACKER_TOKEN: out.env.TINYBIRD_TRACKER_TOKEN, + }); +}); + +gateRouter.post('/api/tinybird/auth', async (req, res) => { + try { + let body = req.body; + if (typeof body.tinyb === 'string') { + body = JSON.parse(body.tinyb); + } else if (typeof body.tinyb === 'object' && body.tinyb !== null) { + body = body.tinyb; + } + if (!validateTinybShape(body)) { + return res.status(400).json({ + error: + 'Invalid auth JSON. Paste the full contents of a .tinyb file (token, host, and id).', + }); + } + await fs.mkdir('/home/tinybird', { recursive: true, mode: 0o700 }); + await fs.writeFile(TINYB_HOME, JSON.stringify(body), { mode: 0o600 }); + return res.json({ ok: true }); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + return res.status(400).json({ error: msg }); + } +}); + +gateRouter.post('/api/tinybird/deploy', async (req, res) => { + const userApiUrl = + typeof req.body?.apiUrl === 'string' ? req.body.apiUrl.trim() : ''; + const adminToken = + typeof req.body?.adminToken === 'string' ? req.body.adminToken.trim() : ''; + + if (adminToken) { + try { + const { resolved, tokenHost, note } = resolveApiHostForDeploy( + userApiUrl, + adminToken + ); + const { stdout, stderr } = await deployWithUiToken(resolved, adminToken); + return res.json({ + ok: true, + stdout, + stderr, + mode: 'ui', + resolvedApiUrl: resolved, + tokenHost: tokenHost || undefined, + note: note || undefined, + }); + } catch (e) { + const err = /** @type {Error & { stdout?: string; stderr?: string }} */ (e); + return res.status(500).json({ + ok: false, + mode: 'ui', + error: err.message || String(e), + stdout: err.stdout || '', + stderr: err.stderr || '', + }); + } + } + + try { + await fs.access(TINYB_HOME); + } catch { + return res.status(400).json({ + error: + 'Either fill API URL and workspace admin token above, or use Advanced: save a .tinyb file from the Tinybird CLI.', + }); + } + try { + const { stdout, stderr } = await deployWithTinybFile(); + return res.json({ ok: true, stdout, stderr, mode: 'tinyb' }); + } catch (e) { + const err = /** @type {Error & { stdout?: string; stderr?: string }} */ (e); + return res.status(500).json({ + ok: false, + mode: 'tinyb', + error: err.message || String(e), + stdout: err.stdout || '', + stderr: err.stderr || '', + }); + } +}); + +gateRouter.get('/api/tinybird/env', async (_req, res) => { + try { + await fs.access(TINYB_HOME); + } catch { + return res.status(400).json({ + error: + 'No saved CLI auth. Use the form in step 2 and “Load variables”, or save a .tinyb under Advanced.', + }); + } + try { + const vars = await fetchEnvFromTinybird(); + return res.json(vars); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + return res.status(500).json({ error: msg }); + } +}); + +function setupPage() { + return ` + + + + + Setup Tinybird + + + +

Setup Tinybird

+

Your stack needs TINYBIRD_API_URL, TINYBIRD_WORKSPACE_ID, TINYBIRD_ADMIN_TOKEN, and TINYBIRD_TRACKER_TOKEN. Those values are not set yet, so this page will gather them. Open Tinybird Cloud, create an account or sign in, then open the Tokens page and paste your Workspace admin token below. Click Generate—we will resolve your API URL and tokens, publish Ghost’s analytics schema if needed, then show variables you can copy into your deployment environment.

+ + + +

+ + + + + + + + +`; +} + +gateRouter.get('/tinybird_setup', (_req, res) => { + res.type('html').send(setupPage()); +}); + +// --- Mount mode ------------------------------------------------------------ +const PROXY_MODE = isTinybirdReady(); + +if (PROXY_MODE) { + // Same-origin analytics: strip `/.ghost/analytics` before forwarding to traffic-analytics:3000. + // NOTE: http-proxy-middleware v2 forwards `req.originalUrl` even when mounted under a + // prefix via `app.use(prefix, proxy)`, so Express' built-in prefix strip is ignored and + // traffic-analytics sees `/.ghost/analytics/api/...` → 404. `pathRewrite` does the strip. + app.use( + ANALYTICS_PREFIX, + createProxyMiddleware({ + target: ANALYTICS_UPSTREAM, + changeOrigin: true, + xfwd: true, + pathRewrite: { [`^${ANALYTICS_PREFIX.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`]: '' }, + logLevel: 'warn', + }) + ); + + // ActivityPub (federation): forward the path untouched — matches caddy/snippets/ActivityPub. + // http-proxy-middleware v2 uses req.originalUrl, so `/.ghost/activitypub/v1/site/` arrives + // at activitypub:8080 intact (no pathRewrite needed). + const activitypubProxy = createProxyMiddleware({ + target: ACTIVITYPUB_UPSTREAM, + changeOrigin: true, + xfwd: true, + logLevel: 'warn', + }); + for (const prefix of ACTIVITYPUB_PREFIXES) { + app.use(prefix, activitypubProxy); + } + + // Everything else proxies to Ghost. Preserve Host so Ghost sees the public URL. + app.use( + createProxyMiddleware({ + target: GHOST_UPSTREAM, + changeOrigin: false, + xfwd: true, + ws: true, + logLevel: 'warn', + }) + ); +} else { + app.use(gateRouter); + app.get('/', (_req, res) => res.redirect(302, '/tinybird_setup')); + // Catch-all: until setup is done, every request lands on the setup page. + app.use((_req, res) => res.redirect(302, '/tinybird_setup')); +} + +const server = app.listen(port, '0.0.0.0', () => { + if (PROXY_MODE) { + console.log( + `ghost-gate: proxy mode — ${ANALYTICS_PREFIX}/* -> ${ANALYTICS_UPSTREAM} (strip prefix); ` + + `${ACTIVITYPUB_PREFIXES.join(', ')} -> ${ACTIVITYPUB_UPSTREAM}; /* -> ${GHOST_UPSTREAM}` + ); + } else { + console.log(`Analytics setup required: ${baseUrl}/tinybird_setup`); + } +}); + +// Kick off MySQL bootstrap in the background so the setup UI is reachable even if the +// DB is slow to come up. Healthcheck stays unhealthy until this resolves. +runBootstrapWithRetry(); + +/** Docker sends SIGTERM on stop; close HTTP server so the process exits before stop_grace_period (avoids SIGKILL / exit 137). */ +function shutdown() { + server.close(() => { + process.exit(0); + }); + setTimeout(() => process.exit(0), 8000); +} +process.on('SIGTERM', shutdown); +process.on('SIGINT', shutdown);