From e3fcc926d7ea1fe17474f41972b06d2ca38e0540 Mon Sep 17 00:00:00 2001 From: BadPirate Date: Thu, 23 Apr 2026 11:40:37 +0200 Subject: [PATCH 1/4] feat(coolify): add ghost-gate wizard + analytics compose stack Adds a Coolify-targeted Ghost 6 stack that is fronted by a new `ghost-gate` service. ghost-gate is the only publicly-exposed container and serves two purposes: - On first launch it runs the MySQL app-user / GRANT / CREATE DATABASE bootstrap against the stock mysql:8 image (no init scripts, no sidecar), then serves a Tinybird setup wizard on every path. The operator pastes a workspace admin token, the wizard resolves the region + tokens via Tinybird's Token API (and publishes the Ghost analytics schema if the tracker token is missing), and prints the four TINYBIRD_* env vars to copy into Coolify. - After the TINYBIRD_* vars are set and the service is redeployed, ghost-gate switches to reverse-proxy mode: /.ghost/analytics/* -> traffic-analytics:3000 (prefix stripped) /.well-known/webfinger -> activitypub:8080 /.well-known/nodeinfo -> activitypub:8080 /.ghost/activitypub/* -> activitypub:8080 everything else -> ghost:2368 Same-origin tracker posts remove the need for CORS handling or Traefik label soup. The healthcheck reports healthy as soon as the DB bootstrap finishes so Coolify/Traefik will route ingress to the container on first launch; proxy vs. setup mode is chosen from TINYBIRD_* env vars only. Files: - coolify/docker-compose.6.yml: Ghost 6 + MySQL + traffic-analytics + ActivityPub + ghost-gate. No profiles (this stack is always analytics- on); legacy caddy/tinybird_home volumes dropped. Image pins aligned with the main compose (traffic-analytics 1.0.196, activitypub 1.2.2, mysql 8.0.44 by digest). - coolify/docker-compose.5.yml: legacy Ghost 5 template preserved for migrations from the old built-in Coolify Ghost service, documented as such in a header comment. - coolify/README.md: Coolify setup steps, routing table, and migration notes. - ghost-gate/: Express + http-proxy-middleware server, Tinybird CLI, bundled Ghost Tinybird datafiles, MIT-licensed package.json with a real express 4.21.x pin and a fresh lockfile. Dockerfile uses wget at runtime for the healthcheck; curl is installed transiently for the Tinybird installer and purged from the final image. - .dockerignore at repo root to keep local state out of build contexts. Made-with: Cursor --- .dockerignore | 4 + README.md | 7 + coolify/README.md | 50 ++ coolify/deploy_local.sh | 5 + coolify/docker-compose.5.yml | 54 ++ coolify/docker-compose.6.local.yml | 10 + coolify/docker-compose.6.yml | 196 +++++ ghost-gate/.dockerignore | 2 + ghost-gate/Dockerfile | 44 ++ ghost-gate/entrypoint.sh | 6 + ghost-gate/package-lock.json | 1146 ++++++++++++++++++++++++++++ ghost-gate/package.json | 16 + ghost-gate/server.js | 896 ++++++++++++++++++++++ 13 files changed, 2436 insertions(+) create mode 100644 .dockerignore create mode 100644 coolify/README.md create mode 100755 coolify/deploy_local.sh create mode 100644 coolify/docker-compose.5.yml create mode 100644 coolify/docker-compose.6.local.yml create mode 100644 coolify/docker-compose.6.yml create mode 100644 ghost-gate/.dockerignore create mode 100644 ghost-gate/Dockerfile create mode 100644 ghost-gate/entrypoint.sh create mode 100644 ghost-gate/package-lock.json create mode 100644 ghost-gate/package.json create mode 100644 ghost-gate/server.js 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..0863f69f 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,13 @@ Configuration to run Ghost and its services with Docker Compose +## Ghost Coolify + +Specifically designed to run Ghost 6 with tinybird analytics from [Coolify](https://coolify.io) UI. Includes [ghost-gate](ghost-gate) +service that automates the tinybird setup flow into a nice web UI, as well as handling migrations from older databases. +For more details, see [coolify/README.md](coolify/README.md). You can also launch a local version of this all in one +with `coolify/deploy_local.sh` and then opening `http://localhost:3989`. + # Copyright & License Copyright (c) 2013-2026 Ghost Foundation - Released under the [MIT license](LICENSE). diff --git a/coolify/README.md b/coolify/README.md new file mode 100644 index 00000000..762306f5 --- /dev/null +++ b/coolify/README.md @@ -0,0 +1,50 @@ +# Coolify deployment + +Two compose files live here; pick one when you create the Coolify resource. + +| File | Ghost | Notes | +| --- | --- | --- | +| `coolify/docker-compose.6.yml` | 6.x | Ghost 6, MySQL 8, Tinybird traffic analytics, ActivityPub. Fronted by `ghost-gate` (the only public service), which also runs the Tinybird setup wizard. **Requires Tinybird** — use this if you want analytics. | +| `coolify/docker-compose.5.yml` | 5.x | Legacy Ghost 5 template kept for migrations from the old Coolify Ghost service. No analytics, no ActivityPub. | + +## Quick start (Ghost 6 with analytics) + +1. **Add resource** → **Public Repository** → `https://github.com/BadPirate/ghost-docker.git` +2. **Build Pack**: `Docker Compose`. **Docker Compose Location**: `coolify/docker-compose.6.yml` +3. On the `ghost-gate` service, attach your FQDN and set the **domain port to `3989`**. No other service needs a public FQDN. +4. Fill the required env vars (`MYSQL_DATABASE`, `MAIL_FROM`, `MAIL_OPTIONS_*`). Coolify auto-generates the MySQL credentials and the `SERVICE_URL_GHOST_GATE` / `SERVICE_URL_GHOST_GATE_3989` values. +5. Deploy. `ghost-gate` comes up, bootstraps the MySQL app user + databases, and becomes healthy as soon as that finishes — Coolify/Traefik then start routing traffic to it. +6. Open the FQDN. `ghost-gate` is serving the setup page. Paste a **Tinybird workspace admin token**, click **Generate**, copy the four `TINYBIRD_*` values it shows into Coolify's env editor, and **redeploy**. On restart `ghost-gate` flips to proxy mode and Ghost becomes reachable at the same URL. + +`docker-compose.6.yml` assumes you want Tinybird analytics; without the `TINYBIRD_*` vars the wizard keeps waiting and Ghost is not served. If you don't want analytics, use `docker-compose.5.yml` (or Ghost's upstream image) instead. + +## How `ghost-gate` routes + +Once the four `TINYBIRD_*` env vars are set: + +- `/.ghost/analytics/*` → `http://traffic-analytics:3000/*` (prefix stripped — matches `caddy/snippets/TrafficAnalytics`) +- `/.well-known/webfinger`, `/.well-known/nodeinfo`, `/.ghost/activitypub/*` → `http://activitypub:8080` (path preserved) +- everything else → `http://ghost:2368` + +Because the tracker posts stay same-origin (`${SERVICE_URL_GHOST_GATE}/.ghost/analytics/...`), there is no CORS to configure and no Traefik label soup. + +Override individual upstreams with `PROXY_GHOST_UPSTREAM`, `PROXY_ANALYTICS_UPSTREAM`, `PROXY_ACTIVITYPUB_UPSTREAM` if the Docker DNS names differ in your setup. + +## Migrating from the legacy Coolify Ghost template + +1. Create the new app as above; **do not** deploy yet. +2. Copy the env vars from your old Ghost 5 app (Developer view in Coolify makes this easier). +3. On the Coolify host, copy the existing volumes into the new app's volumes: + ``` + cd /var/lib/docker/volumes + cp -r _ghost-mysql-data _ghost-mysql-data + cp -r _ghost-content-data _ghost-content-data + ``` +4. Deploy the new app. Complete the Tinybird setup (step 6 above). +5. Verify the site, then stop the old app and swap the FQDN over. + +## Gotchas + +- **Do not** paste the literal string `SERVICE_URL_GHOST` (or `SERVICE_URL_GHOST_GATE`) into `ADMIN_DOMAIN`; that is a variable name, not a URL, and Ghost will fail with `ERR_INVALID_URL`. Remove `ADMIN_DOMAIN` from the env, or set it to a full `https://…` URL if you really use a separate admin host. +- `SERVICE_URL_GHOST_GATE` must have **no trailing slash** (e.g. `https://godutch.us`). +- Set `MAIL_FROM` to a valid transactional From line, e.g. `"'Your Site' "`. Without it Ghost logs `Missing mail.from config` and uses a generated address. 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..31bb33ae --- /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: http://localhost: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..2aedb638 --- /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_3989} + SERVICE_URL_GHOST_GATE: ${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..edb5d24c --- /dev/null +++ b/ghost-gate/server.js @@ -0,0 +1,896 @@ +'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', +}; + +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); From 1077c574a28820967a32f2a70054020a49406984 Mon Sep 17 00:00:00 2001 From: BadPirate Date: Tue, 19 May 2026 13:25:11 +0200 Subject: [PATCH 2/4] warning about variable names --- coolify/docker-compose.6.local.yml | 2 +- coolify/docker-compose.6.yml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/coolify/docker-compose.6.local.yml b/coolify/docker-compose.6.local.yml index 31bb33ae..30dcbaae 100644 --- a/coolify/docker-compose.6.local.yml +++ b/coolify/docker-compose.6.local.yml @@ -3,7 +3,7 @@ services: ports: - "3989:3989" environment: - SERVICE_URL_GHOST_GATE_3989: http://localhost:3989 + SERVICE_URL_GHOST_GATE_3989: SERVICE_URL_GHOST_GATE: http://localhost:3989 MYSQL_USER: ${SERVICE_USER_MYSQL:-localuser} MYSQL_PASSWORD: ${SERVICE_PASSWORD_MYSQL:-changeme} diff --git a/coolify/docker-compose.6.yml b/coolify/docker-compose.6.yml index 2aedb638..7ade139f 100644 --- a/coolify/docker-compose.6.yml +++ b/coolify/docker-compose.6.yml @@ -9,8 +9,8 @@ services: mysql: condition: service_healthy environment: - SERVICE_URL_GHOST_GATE_3989: ${SERVICE_URL_GHOST_GATE_3989} - SERVICE_URL_GHOST_GATE: ${SERVICE_URL_GHOST_GATE} + 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 From 6058bf0633a86caa389996f1e9e66210476917e2 Mon Sep 17 00:00:00 2001 From: BadPirate Date: Tue, 19 May 2026 16:06:33 +0200 Subject: [PATCH 3/4] better readme --- README.md | 47 ++++++++++++++++++++++++------ coolify/README.md | 73 ++++++++++++++++++++++------------------------- 2 files changed, 73 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index 0863f69f..ffc1d60c 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,45 @@ -# Ghost Docker +# Ghost Docker for Coolify Configuration to run Ghost and its services with Docker Compose -## Ghost Coolify +- [docker-compose.5.yml] - Original Coolify 5 Ghost configuration available from coolify as a template (for reference / backwards compatibility) -Specifically designed to run Ghost 6 with tinybird analytics from [Coolify](https://coolify.io) UI. Includes [ghost-gate](ghost-gate) -service that automates the tinybird setup flow into a nice web UI, as well as handling migrations from older databases. -For more details, see [coolify/README.md](coolify/README.md). You can also launch a local version of this all in one -with `coolify/deploy_local.sh` and then opening `http://localhost:3989`. +- [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` -# Copyright & License +- [docker-compose.6.yml] - For use with coolify, see setup below. -Copyright (c) 2013-2026 Ghost Foundation - Released under the [MIT license](LICENSE). +## 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 index 762306f5..b0d7f60e 100644 --- a/coolify/README.md +++ b/coolify/README.md @@ -1,50 +1,45 @@ -# Coolify deployment +# Ghost Docker for Coolify -Two compose files live here; pick one when you create the Coolify resource. +Configuration to run Ghost and its services with Docker Compose -| File | Ghost | Notes | -| --- | --- | --- | -| `coolify/docker-compose.6.yml` | 6.x | Ghost 6, MySQL 8, Tinybird traffic analytics, ActivityPub. Fronted by `ghost-gate` (the only public service), which also runs the Tinybird setup wizard. **Requires Tinybird** — use this if you want analytics. | -| `coolify/docker-compose.5.yml` | 5.x | Legacy Ghost 5 template kept for migrations from the old Coolify Ghost service. No analytics, no ActivityPub. | +- [docker-compose.5.yml] - Original Coolify 5 Ghost configuration available from coolify as a template (for reference / backwards compatibility) -## Quick start (Ghost 6 with analytics) +- [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` -1. **Add resource** → **Public Repository** → `https://github.com/BadPirate/ghost-docker.git` -2. **Build Pack**: `Docker Compose`. **Docker Compose Location**: `coolify/docker-compose.6.yml` -3. On the `ghost-gate` service, attach your FQDN and set the **domain port to `3989`**. No other service needs a public FQDN. -4. Fill the required env vars (`MYSQL_DATABASE`, `MAIL_FROM`, `MAIL_OPTIONS_*`). Coolify auto-generates the MySQL credentials and the `SERVICE_URL_GHOST_GATE` / `SERVICE_URL_GHOST_GATE_3989` values. -5. Deploy. `ghost-gate` comes up, bootstraps the MySQL app user + databases, and becomes healthy as soon as that finishes — Coolify/Traefik then start routing traffic to it. -6. Open the FQDN. `ghost-gate` is serving the setup page. Paste a **Tinybird workspace admin token**, click **Generate**, copy the four `TINYBIRD_*` values it shows into Coolify's env editor, and **redeploy**. On restart `ghost-gate` flips to proxy mode and Ghost becomes reachable at the same URL. +- [docker-compose.6.yml] - Ghost 6 + Tinybird for analytics + router and wizard to ease in setup. For use with coolify, see setup below. -`docker-compose.6.yml` assumes you want Tinybird analytics; without the `TINYBIRD_*` vars the wizard keeps waiting and Ghost is not served. If you don't want analytics, use `docker-compose.5.yml` (or Ghost's upstream image) instead. +## Ghost 6 with Tinybird Analytics -## How `ghost-gate` routes +### New Install -Once the four `TINYBIRD_*` env vars are set: +Requirements: Coolify UI v4, host with at least 5GB disk space and 2GB RAM -- `/.ghost/analytics/*` → `http://traffic-analytics:3000/*` (prefix stripped — matches `caddy/snippets/TrafficAnalytics`) -- `/.well-known/webfinger`, `/.well-known/nodeinfo`, `/.ghost/activitypub/*` → `http://activitypub:8080` (path preserved) -- everything else → `http://ghost:2368` +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. -Because the tracker posts stay same-origin (`${SERVICE_URL_GHOST_GATE}/.ghost/analytics/...`), there is no CORS to configure and no Traefik label soup. +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! (?) -Override individual upstreams with `PROXY_GHOST_UPSTREAM`, `PROXY_ANALYTICS_UPSTREAM`, `PROXY_ACTIVITYPUB_UPSTREAM` if the Docker DNS names differ in your setup. +### Upgrade existing coolify ghost 5 template based install -## Migrating from the legacy Coolify Ghost template - -1. Create the new app as above; **do not** deploy yet. -2. Copy the env vars from your old Ghost 5 app (Developer view in Coolify makes this easier). -3. On the Coolify host, copy the existing volumes into the new app's volumes: - ``` - cd /var/lib/docker/volumes - cp -r _ghost-mysql-data _ghost-mysql-data - cp -r _ghost-content-data _ghost-content-data - ``` -4. Deploy the new app. Complete the Tinybird setup (step 6 above). -5. Verify the site, then stop the old app and swap the FQDN over. - -## Gotchas - -- **Do not** paste the literal string `SERVICE_URL_GHOST` (or `SERVICE_URL_GHOST_GATE`) into `ADMIN_DOMAIN`; that is a variable name, not a URL, and Ghost will fail with `ERR_INVALID_URL`. Remove `ADMIN_DOMAIN` from the env, or set it to a full `https://…` URL if you really use a separate admin host. -- `SERVICE_URL_GHOST_GATE` must have **no trailing slash** (e.g. `https://godutch.us`). -- Set `MAIL_FROM` to a valid transactional From line, e.g. `"'Your Site' "`. Without it Ghost logs `Missing mail.from config` and uses a generated address. +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 From c5621d20f7362d4e8d14657f82dca5f58c9ab12d Mon Sep 17 00:00:00 2001 From: BadPirate Date: Tue, 19 May 2026 16:12:40 +0200 Subject: [PATCH 4/4] feat(server): add new Tinybird API host configurations for eu_shared and us_east --- ghost-gate/server.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ghost-gate/server.js b/ghost-gate/server.js index edb5d24c..49799a3e 100644 --- a/ghost-gate/server.js +++ b/ghost-gate/server.js @@ -161,6 +161,8 @@ const PATH_WITH_TB = `/root/.local/bin:${process.env.PATH || '/usr/bin:/bin'}`; */ 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) {