mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-26 00:03:46 +00:00
Merge pull request #84 from warmbly/release-frontend-assets
Ship the dashboard and admin as production release images
This commit is contained in:
@@ -128,6 +128,9 @@ jobs:
|
||||
- name: Lint
|
||||
run: pnpm lint
|
||||
|
||||
- name: Typecheck
|
||||
run: pnpm typecheck
|
||||
|
||||
- name: Run tests
|
||||
run: pnpm test:run
|
||||
|
||||
@@ -336,6 +339,34 @@ jobs:
|
||||
ARCHS=arm64 ONLY_ACTIVE_ARCH=YES \
|
||||
CODE_SIGNING_ALLOWED=NO
|
||||
|
||||
# Validate the web/admin production Dockerfiles (build + nginx config check)
|
||||
# without pushing, so a broken image can't reach a release tag unnoticed. The
|
||||
# release workflow is the only other place these build, and only on tags.
|
||||
frontend-images:
|
||||
name: Frontend Image (${{ matrix.service }})
|
||||
needs: changes
|
||||
if: needs.changes.outputs.web == 'true' || needs.changes.outputs.admin == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
service: [web, admin]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build image (no push)
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ./${{ matrix.service }}
|
||||
file: ./${{ matrix.service }}/Dockerfile
|
||||
push: false
|
||||
platforms: linux/amd64
|
||||
cache-from: type=gha,scope=ci-${{ matrix.service }}
|
||||
cache-to: type=gha,mode=max,scope=ci-${{ matrix.service }}
|
||||
|
||||
security:
|
||||
name: Security Scan
|
||||
runs-on: ubuntu-latest
|
||||
@@ -354,7 +385,7 @@ jobs:
|
||||
ci-status:
|
||||
name: CI Status
|
||||
runs-on: ubuntu-latest
|
||||
needs: [changes, go-ci, web-ci, admin-ci, site-ci, make-ci, rust-ci, elixir-ci, ios-ci, security]
|
||||
needs: [changes, go-ci, web-ci, admin-ci, site-ci, make-ci, rust-ci, elixir-ci, ios-ci, frontend-images, security]
|
||||
if: always()
|
||||
steps:
|
||||
- name: Check CI status
|
||||
@@ -364,6 +395,7 @@ jobs:
|
||||
[[ "${{ needs.admin-ci.result }}" == "failure" ]] || \
|
||||
[[ "${{ needs.site-ci.result }}" == "failure" ]] || \
|
||||
[[ "${{ needs.make-ci.result }}" == "failure" ]] || \
|
||||
[[ "${{ needs.frontend-images.result }}" == "failure" ]] || \
|
||||
[[ "${{ needs.rust-ci.result }}" == "failure" ]] || \
|
||||
[[ "${{ needs.elixir-ci.result }}" == "failure" ]] || \
|
||||
[[ "${{ needs.ios-ci.result }}" == "failure" ]] || \
|
||||
|
||||
@@ -69,6 +69,48 @@ jobs:
|
||||
cache-from: type=gha,scope=${{ matrix.service }}
|
||||
cache-to: type=gha,mode=max,scope=${{ matrix.service }}
|
||||
|
||||
# Frontends (web dashboard, admin panel): the pnpm build runs once on the
|
||||
# build platform and its static output is served from nginx, so a single
|
||||
# buildx build produces both arches without a per-arch rebuild.
|
||||
build-frontend:
|
||||
name: Build ${{ matrix.service }}
|
||||
needs: validate-tag
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
service: [web, admin]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ./${{ matrix.service }}
|
||||
file: ./${{ matrix.service }}/Dockerfile
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.IMAGE_PREFIX }}/${{ matrix.service }}:${{ github.ref_name }}
|
||||
${{ env.IMAGE_PREFIX }}/${{ matrix.service }}:v${{ needs.validate-tag.outputs.minor }}
|
||||
${{ env.IMAGE_PREFIX }}/${{ matrix.service }}:v${{ needs.validate-tag.outputs.major }}
|
||||
${{ env.IMAGE_PREFIX }}/${{ matrix.service }}:prod
|
||||
platforms: linux/amd64,linux/arm64
|
||||
cache-from: type=gha,scope=${{ matrix.service }}
|
||||
cache-to: type=gha,mode=max,scope=${{ matrix.service }}
|
||||
|
||||
# Rust (tracking) and Elixir (realtime) have no cross-compiler; an emulated
|
||||
# arm64 build under QEMU runs for an hour or more. Build each arch on a
|
||||
# native runner and merge the digests into one manifest (the
|
||||
@@ -170,7 +212,7 @@ jobs:
|
||||
|
||||
create-release:
|
||||
name: Create GitHub Release
|
||||
needs: [validate-tag, build-go, merge-native]
|
||||
needs: [validate-tag, build-go, build-frontend, merge-native]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -220,6 +262,8 @@ jobs:
|
||||
| Worker | `${{ env.IMAGE_PREFIX }}/worker:${{ github.ref_name }}` |
|
||||
| Tracking | `${{ env.IMAGE_PREFIX }}/tracking:${{ github.ref_name }}` |
|
||||
| Realtime | `${{ env.IMAGE_PREFIX }}/realtime:${{ github.ref_name }}` |
|
||||
| Dashboard (web) | `${{ env.IMAGE_PREFIX }}/web:${{ github.ref_name }}` |
|
||||
| Admin | `${{ env.IMAGE_PREFIX }}/admin:${{ github.ref_name }}` |
|
||||
|
||||
## Deployment
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
.env
|
||||
.env.*
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
@@ -0,0 +1,24 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# Stage 1: build the static admin panel once on the build platform. The output
|
||||
# is plain JS/CSS/HTML (arch-independent), so both target arches reuse it and
|
||||
# the heavy pnpm build runs only once.
|
||||
FROM --platform=$BUILDPLATFORM node:22-alpine AS build
|
||||
WORKDIR /app
|
||||
RUN corepack enable && corepack prepare pnpm@11.9.0 --activate
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
COPY . .
|
||||
# No VITE_* is baked here on purpose: URLs come from the runtime config below,
|
||||
# so this one image works for any deployment.
|
||||
RUN pnpm build
|
||||
|
||||
# Stage 2: serve the built SPA from nginx with a history fallback. The
|
||||
# entrypoint renders /config.js from container env at startup.
|
||||
FROM nginx:1.27-alpine
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
RUN nginx -t
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
COPY docker-entrypoint.sh /docker-entrypoint.d/40-warmbly-config.sh
|
||||
RUN chmod +x /docker-entrypoint.d/40-warmbly-config.sh
|
||||
EXPOSE 80
|
||||
@@ -0,0 +1,13 @@
|
||||
#!/bin/sh
|
||||
# Render the runtime config from container env so a single built image serves
|
||||
# any deployment. Runs before nginx starts (nginx /docker-entrypoint.d hook).
|
||||
set -eu
|
||||
|
||||
cat > /usr/share/nginx/html/config.js <<EOF
|
||||
window.__WARMBLY_ENV__ = {
|
||||
API_URL: "${WARMBLY_API_URL:-}",
|
||||
DASHBOARD_URL: "${WARMBLY_DASHBOARD_URL:-}",
|
||||
ENV_LABEL: "${WARMBLY_ENV_LABEL:-}",
|
||||
TURNSTILE_KEY: "${WARMBLY_TURNSTILE_KEY:-}"
|
||||
};
|
||||
EOF
|
||||
@@ -19,6 +19,11 @@
|
||||
|
||||
<meta name="apple-mobile-web-app-title" content="Admin · Warmbly" />
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
|
||||
<!-- Runtime config: the production image overwrites this with values from
|
||||
container env so one build serves any deployment. Empty in dev. Loads
|
||||
before the app module so window.__WARMBLY_ENV__ is set first. -->
|
||||
<script src="/config.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# SPA history fallback: unknown paths serve the app shell so client-side
|
||||
# routing works on hard reloads and deep links.
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Never cache the shell or the runtime config, so a redeploy or an env
|
||||
# change is picked up on the next load. The hashed assets they point to are
|
||||
# cached immutably below.
|
||||
location = /index.html { add_header Cache-Control "no-store"; }
|
||||
location = /config.js { add_header Cache-Control "no-store"; }
|
||||
|
||||
location /assets/ {
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// Dev/default stub. In a production image the container entrypoint overwrites
|
||||
// this file with values from environment variables. Empty here so the build's
|
||||
// baked VITE_* values are used during local development.
|
||||
window.__WARMBLY_ENV__ = window.__WARMBLY_ENV__ || {};
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
import { useCallback, useEffect, useRef, type ComponentProps } from "react";
|
||||
import Turnstile, { type BoundTurnstileObject } from "react-turnstile";
|
||||
import { TURNSTILE_KEY } from "@/lib/env";
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
@@ -101,7 +102,7 @@ export function TurnstileModal({ visible, onToken, onError }: Props) {
|
||||
|
||||
const turnstileProps = {
|
||||
ref: turnstileRef,
|
||||
sitekey: import.meta.env.VITE_TURNSTILE_KEY!,
|
||||
sitekey: TURNSTILE_KEY,
|
||||
onVerify: handleVerify,
|
||||
onExpire: () => {
|
||||
tokenRef.current = "";
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
// Environment helpers shared across the admin app. Centralized so the
|
||||
// API base URL and the "PROD / STAGING / DEV" pill always agree.
|
||||
//
|
||||
// These read the container-injected runtime config first (so one built image
|
||||
// works for any deployment), then the value Vite baked at build time.
|
||||
import { runtimeEnv } from "./runtimeConfig";
|
||||
|
||||
export const API_URL: string = import.meta.env.VITE_API_URL ?? "http://localhost:8080";
|
||||
export const API_URL: string = runtimeEnv("API_URL", import.meta.env.VITE_API_URL, "http://localhost:8080");
|
||||
|
||||
export const DASHBOARD_URL: string =
|
||||
import.meta.env.VITE_DASHBOARD_URL ?? "http://localhost:5173";
|
||||
export const DASHBOARD_URL: string = runtimeEnv("DASHBOARD_URL", import.meta.env.VITE_DASHBOARD_URL, "http://localhost:5173");
|
||||
|
||||
export const TURNSTILE_KEY: string = runtimeEnv("TURNSTILE_KEY", import.meta.env.VITE_TURNSTILE_KEY);
|
||||
|
||||
export type EnvLabel = "production" | "staging" | "development";
|
||||
|
||||
const RAW_ENV_LABEL = (import.meta.env.VITE_ENV_LABEL as string | undefined)?.toLowerCase();
|
||||
const RAW_ENV_LABEL = runtimeEnv("ENV_LABEL", import.meta.env.VITE_ENV_LABEL as string | undefined).toLowerCase();
|
||||
|
||||
export const ENV_LABEL: EnvLabel =
|
||||
RAW_ENV_LABEL === "production" || RAW_ENV_LABEL === "prod"
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
// Runtime configuration override.
|
||||
//
|
||||
// Vite bakes `import.meta.env.VITE_*` at build time, which would tie one built
|
||||
// image to one deployment's URLs. To ship a single reusable image, the
|
||||
// production container serves `/config.js` (generated from container env at
|
||||
// startup) that sets `window.__WARMBLY_ENV__`. `runtimeEnv` prefers that value,
|
||||
// then the value Vite baked at build time, then a fallback. In dev the
|
||||
// `public/config.js` stub leaves the window value empty, so the baked value is
|
||||
// used and behavior is unchanged.
|
||||
|
||||
type RuntimeEnv = Record<string, string | undefined>;
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__WARMBLY_ENV__?: RuntimeEnv;
|
||||
}
|
||||
}
|
||||
|
||||
function fromWindow(key: string): string | undefined {
|
||||
if (typeof window === "undefined") return undefined;
|
||||
const value = window.__WARMBLY_ENV__?.[key];
|
||||
// Ignore empties and any unsubstituted "${VAR}" placeholder.
|
||||
if (!value || value.startsWith("${")) return undefined;
|
||||
return value;
|
||||
}
|
||||
|
||||
// runtimeEnv returns the container-injected value, else the build-time value,
|
||||
// else the fallback.
|
||||
export function runtimeEnv(key: string, buildTime?: string, fallback = ""): string {
|
||||
return fromWindow(key) ?? (buildTime ? buildTime : undefined) ?? fallback;
|
||||
}
|
||||
+22
-34
@@ -247,45 +247,35 @@ services:
|
||||
postgres: { condition: service_healthy }
|
||||
redis: { condition: service_healthy }
|
||||
|
||||
# Production build of the dashboard (static SPA served by nginx). URLs come
|
||||
# from the WARMBLY_* env at container start via /config.js, so the same image
|
||||
# the release publishes to GHCR is what runs here. For hot-reload development
|
||||
# use `make app` / docker-compose.dev.yml instead.
|
||||
web:
|
||||
image: node:22-alpine
|
||||
working_dir: /app
|
||||
volumes:
|
||||
- ./web:/app
|
||||
- web_node_modules:/app/node_modules
|
||||
ports: ["5173:5173"]
|
||||
command: >
|
||||
sh -c '
|
||||
until corepack enable; do echo "corepack retry..."; sleep 2; done;
|
||||
pnpm install || { echo "pnpm install failed — fix package.json then restart web"; exit 1; };
|
||||
pnpm dev --host
|
||||
'
|
||||
build:
|
||||
context: ./web
|
||||
dockerfile: Dockerfile
|
||||
ports: ["5173:80"]
|
||||
environment:
|
||||
VITE_APP_URL: ${APP_URL:-http://${PUBLIC_HOST:-localhost}:5173}
|
||||
VITE_API_URL: ${API_PUBLIC_URL:-http://${PUBLIC_HOST:-localhost}:8080}
|
||||
VITE_TRACKING_DOMAIN: ${TRACKING_DOMAIN:-${PUBLIC_HOST:-localhost}:3000}
|
||||
# Captcha is off server-side (CAPTCHA_PROVIDER=none); the dev bypass token
|
||||
# keeps the widget satisfied without a Cloudflare key.
|
||||
VITE_TURNSTILE_KEY: "1x00000000000000000000AA"
|
||||
VITE_TURNSTILE_BYPASS_TOKEN: "warmbly-local-turnstile-bypass"
|
||||
WARMBLY_API_URL: ${API_PUBLIC_URL:-http://${PUBLIC_HOST:-localhost}:8080}
|
||||
WARMBLY_APP_URL: ${APP_URL:-http://${PUBLIC_HOST:-localhost}:5173}
|
||||
WARMBLY_TRACKING_DOMAIN: ${TRACKING_DOMAIN:-${PUBLIC_HOST:-localhost}:3000}
|
||||
# Captcha is off server-side (CAPTCHA_PROVIDER=none); this Cloudflare test
|
||||
# key keeps the widget satisfied without a real key.
|
||||
WARMBLY_TURNSTILE_KEY: "1x00000000000000000000AA"
|
||||
depends_on:
|
||||
backend: { condition: service_healthy }
|
||||
|
||||
admin:
|
||||
image: node:22-alpine
|
||||
working_dir: /app
|
||||
volumes:
|
||||
- ./admin:/app
|
||||
- admin_node_modules:/app/node_modules
|
||||
ports: ["5174:5174"]
|
||||
command: >
|
||||
sh -c '
|
||||
until corepack enable; do echo "corepack retry..."; sleep 2; done;
|
||||
pnpm install || { echo "pnpm install failed — fix package.json then restart admin"; exit 1; };
|
||||
pnpm dev --host --port 5174
|
||||
'
|
||||
build:
|
||||
context: ./admin
|
||||
dockerfile: Dockerfile
|
||||
ports: ["5174:80"]
|
||||
environment:
|
||||
VITE_API_URL: ${API_PUBLIC_URL:-http://${PUBLIC_HOST:-localhost}:8080}
|
||||
WARMBLY_API_URL: ${API_PUBLIC_URL:-http://${PUBLIC_HOST:-localhost}:8080}
|
||||
WARMBLY_DASHBOARD_URL: ${APP_URL:-http://${PUBLIC_HOST:-localhost}:5173}
|
||||
WARMBLY_ENV_LABEL: ${ENV_LABEL:-development}
|
||||
WARMBLY_TURNSTILE_KEY: "1x00000000000000000000AA"
|
||||
depends_on:
|
||||
backend: { condition: service_healthy }
|
||||
|
||||
@@ -309,5 +299,3 @@ volumes:
|
||||
redis_data:
|
||||
nats_data:
|
||||
blobs:
|
||||
web_node_modules:
|
||||
admin_node_modules:
|
||||
|
||||
@@ -46,18 +46,23 @@ Each subsystem is an env-var switch. No-cloud defaults on the left:
|
||||
|
||||
Every service already binds to `0.0.0.0` via the Docker port mappings, so it
|
||||
listens on all interfaces. The app's URLs default to `localhost`, so to use it
|
||||
from another machine set one variable in `.env` — the API URL, CORS origins,
|
||||
websocket URL, tracking domain, and the frontend `VITE_*` URLs all derive from it:
|
||||
from another machine set one variable in `.env`, and the API URL, CORS origins,
|
||||
websocket URL, and tracking domain all derive from it:
|
||||
|
||||
```bash
|
||||
PUBLIC_HOST=192.168.1.50 # your machine's LAN IP, or a domain
|
||||
```
|
||||
|
||||
Then open `http://192.168.1.50:5173`. For a public domain over HTTPS, run a
|
||||
reverse proxy (Caddy or nginx) in front terminating TLS, point `PUBLIC_HOST` (or
|
||||
the individual `APP_URL` / `CORS_ALLOW_ORIGINS` / `WEBSOCKET_URL` /
|
||||
`API_PUBLIC_URL` / `TRACKING_DOMAIN` vars) at your domain, and serve the
|
||||
dashboard and admin as static builds rather than the Vite dev servers.
|
||||
Then open `http://192.168.1.50:5173`. The dashboard (`web`) and admin panel
|
||||
(`admin`) run as production static builds served by nginx, not Vite dev servers.
|
||||
Their URLs are injected at container start from `WARMBLY_*` env (rendered into a
|
||||
`/config.js`), so the published image is deployment-agnostic: compose derives
|
||||
`WARMBLY_API_URL` / `WARMBLY_APP_URL` / `WARMBLY_TRACKING_DOMAIN` from
|
||||
`PUBLIC_HOST` and `API_PUBLIC_URL`, and the same image runs anywhere. For a
|
||||
public domain over HTTPS, run a reverse proxy (Caddy or nginx) in front
|
||||
terminating TLS and point `PUBLIC_HOST` (or the individual `APP_URL` /
|
||||
`CORS_ALLOW_ORIGINS` / `WEBSOCKET_URL` / `API_PUBLIC_URL` / `TRACKING_DOMAIN`
|
||||
vars) at your domain.
|
||||
|
||||
## Scaling
|
||||
|
||||
@@ -194,7 +199,7 @@ ghcr.io/<owner>/warmbly/worker:vX
|
||||
ghcr.io/<owner>/warmbly/worker:prod
|
||||
```
|
||||
|
||||
And the same set of tags for `backend`, `consumer`, `tracking`, `realtime`. The `build-push.yml` workflow on `main` publishes `:{sha}` and `:dev`.
|
||||
And the same set of tags for `backend`, `consumer`, `tracking`, `realtime`, and the two frontends `web` (dashboard) and `admin`. The `build-push.yml` workflow on `main` publishes `:{sha}` and `:dev`.
|
||||
|
||||
For self-hosters: this works out of the box on any fork as long as you have `packages: write` permission enabled in repo settings.
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
.env
|
||||
.env.*
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
@@ -0,0 +1,24 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# Stage 1: build the static dashboard once on the build platform. The output is
|
||||
# plain JS/CSS/HTML (arch-independent), so both target arches reuse it and the
|
||||
# heavy pnpm build runs only once.
|
||||
FROM --platform=$BUILDPLATFORM node:22-alpine AS build
|
||||
WORKDIR /app
|
||||
RUN corepack enable && corepack prepare pnpm@11.9.0 --activate
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
COPY . .
|
||||
# No VITE_* is baked here on purpose: URLs come from the runtime config below,
|
||||
# so this one image works for any deployment.
|
||||
RUN pnpm build
|
||||
|
||||
# Stage 2: serve the built SPA from nginx with a history fallback. The
|
||||
# entrypoint renders /config.js from container env at startup.
|
||||
FROM nginx:1.27-alpine
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
RUN nginx -t
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
COPY docker-entrypoint.sh /docker-entrypoint.d/40-warmbly-config.sh
|
||||
RUN chmod +x /docker-entrypoint.d/40-warmbly-config.sh
|
||||
EXPOSE 80
|
||||
@@ -0,0 +1,13 @@
|
||||
#!/bin/sh
|
||||
# Render the runtime config from container env so a single built image serves
|
||||
# any deployment. Runs before nginx starts (nginx /docker-entrypoint.d hook).
|
||||
set -eu
|
||||
|
||||
cat > /usr/share/nginx/html/config.js <<EOF
|
||||
window.__WARMBLY_ENV__ = {
|
||||
API_URL: "${WARMBLY_API_URL:-}",
|
||||
APP_URL: "${WARMBLY_APP_URL:-}",
|
||||
TRACKING_DOMAIN: "${WARMBLY_TRACKING_DOMAIN:-}",
|
||||
TURNSTILE_KEY: "${WARMBLY_TURNSTILE_KEY:-}"
|
||||
};
|
||||
EOF
|
||||
@@ -12,6 +12,10 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Warmbly</title>
|
||||
<meta name="apple-mobile-web-app-title" content="Warmbly" />
|
||||
<!-- Runtime config: the production image overwrites this with values from
|
||||
container env so one build serves any deployment. Empty in dev. Loads
|
||||
before the app module so window.__WARMBLY_ENV__ is set first. -->
|
||||
<script src="/config.js"></script>
|
||||
<style>
|
||||
/* Prevent a flash of unstyled content: paint the app background
|
||||
immediately and keep #root hidden until the stylesheet has applied
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# SPA history fallback: unknown paths serve the app shell so client-side
|
||||
# routing works on hard reloads and deep links.
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Never cache the shell or the runtime config, so a redeploy or an env
|
||||
# change is picked up on the next load. The hashed assets they point to are
|
||||
# cached immutably below.
|
||||
location = /index.html { add_header Cache-Control "no-store"; }
|
||||
location = /config.js { add_header Cache-Control "no-store"; }
|
||||
|
||||
location /assets/ {
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// Dev/default stub. In a production image the container entrypoint overwrites
|
||||
// this file with values from environment variables. Empty here so the build's
|
||||
// baked VITE_* values are used during local development.
|
||||
window.__WARMBLY_ENV__ = window.__WARMBLY_ENV__ || {};
|
||||
@@ -21,7 +21,7 @@ import useRegister from "@/lib/api/hooks/auth/useRegister";
|
||||
import useRegisterConfirm from "@/lib/api/hooks/auth/useRegisterConfirm";
|
||||
import { saveTokens } from "@/lib/auth";
|
||||
import getUser from "@/lib/api/client/auth/getUser";
|
||||
import { WEBSITE_URL } from "@/lib/information";
|
||||
import { WEBSITE_URL, TURNSTILE_KEY } from "@/lib/information";
|
||||
import type { AppError } from "@/lib/api/client/normalizeError";
|
||||
import buildError from "@/lib/helper/buildError";
|
||||
import * as Sentry from "@sentry/react";
|
||||
@@ -566,7 +566,7 @@ export default function LoginPage() {
|
||||
|
||||
{!turnstileBypassToken && (
|
||||
<Turnstile
|
||||
sitekey={import.meta.env.VITE_TURNSTILE_KEY!}
|
||||
sitekey={TURNSTILE_KEY}
|
||||
execution="execute"
|
||||
onLoad={(_widgetId, bound) => {
|
||||
turnstileRef.current = bound;
|
||||
|
||||
@@ -23,6 +23,10 @@ interface ConditionalAttrs {
|
||||
uid: string; // transient, not serialized — flags a just-inserted chip to open once
|
||||
}
|
||||
|
||||
// The serialized parts of a conditional (everything but the transient uid),
|
||||
// used by the token build/parse helpers.
|
||||
type ConditionalParts = Pick<ConditionalAttrs, "expr" | "thenText" | "elseText">;
|
||||
|
||||
const DEFAULT_ATTRS: ConditionalAttrs = { expr: ".Company", thenText: "", elseText: "", uid: "" };
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
@@ -34,7 +38,7 @@ declare module "@tiptap/core" {
|
||||
}
|
||||
|
||||
// buildConditionalToken assembles the literal template text from the attrs.
|
||||
function buildConditionalToken(a: ConditionalAttrs): string {
|
||||
function buildConditionalToken(a: ConditionalParts): string {
|
||||
const expr = a.expr.trim() || ".Company";
|
||||
const then = a.thenText ?? "";
|
||||
if (a.elseText && a.elseText.trim()) {
|
||||
@@ -45,7 +49,7 @@ function buildConditionalToken(a: ConditionalAttrs): string {
|
||||
|
||||
// parseConditionalToken reverses buildConditionalToken so a saved/pasted
|
||||
// conditional round-trips back into the builder.
|
||||
function parseConditionalToken(token: string): ConditionalAttrs | null {
|
||||
function parseConditionalToken(token: string): ConditionalParts | null {
|
||||
const m = token.match(/^\{\{\s*if\s+([\s\S]*?)\s*\}\}([\s\S]*?)(?:\{\{\s*else\s*\}\}([\s\S]*?))?\{\{\s*end\s*\}\}$/);
|
||||
if (!m) return null;
|
||||
return { expr: m[1].trim(), thenText: m[2] ?? "", elseText: m[3] ?? "" };
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect } from "react";
|
||||
import * as TurnstileObj from "react-turnstile";
|
||||
import { TURNSTILE_KEY } from "@/lib/information";
|
||||
|
||||
interface Props {
|
||||
setToken: (token: string) => void;
|
||||
@@ -19,7 +20,7 @@ export default function Turnstile({setToken}: Props) {
|
||||
|
||||
return <>
|
||||
<TurnstileObj.default
|
||||
sitekey={import.meta.env.VITE_TURNSTILE_KEY!}
|
||||
sitekey={TURNSTILE_KEY}
|
||||
onVerify={(token: string) => setToken(token)}
|
||||
onExpire={() => setToken("")}
|
||||
theme={"light"}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useRef, useCallback } from "react";
|
||||
import Turnstile from "react-turnstile";
|
||||
import { TURNSTILE_KEY } from "@/lib/information";
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
@@ -57,7 +58,7 @@ export function TurnstileModal({ visible, onToken }: Props) {
|
||||
// to allow it without disabling TS for the whole call.
|
||||
const turnstileProps = {
|
||||
ref: turnstileRef,
|
||||
sitekey: import.meta.env.VITE_TURNSTILE_KEY!,
|
||||
sitekey: TURNSTILE_KEY,
|
||||
onVerify: handleVerify,
|
||||
onExpire: () => { tokenRef.current = ""; turnstileRef.current?.reset(); },
|
||||
size: "invisible" as const,
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { runtimeEnv } from "./runtimeConfig";
|
||||
|
||||
export const WEBSITE_URL = "https://warmbly.com";
|
||||
export const APP_URL = import.meta.env.VITE_APP_URL!;
|
||||
export const API_URL = import.meta.env.VITE_API_URL!;
|
||||
// These read the container-injected runtime config first (so one built image
|
||||
// works for any deployment), then the value Vite baked at build time.
|
||||
export const APP_URL = runtimeEnv("APP_URL", import.meta.env.VITE_APP_URL);
|
||||
export const API_URL = runtimeEnv("API_URL", import.meta.env.VITE_API_URL);
|
||||
// The whole dashboard talks to the versioned API. VITE_API_URL is a bare origin
|
||||
// (no path), so this is the single place the /v1 prefix is applied.
|
||||
export const API_BASE_URL = `${API_URL}/v1`;
|
||||
export const TRACKING_DOMAIN = import.meta.env.VITE_TRACKING_DOMAIN!;
|
||||
export const TRACKING_DOMAIN = runtimeEnv("TRACKING_DOMAIN", import.meta.env.VITE_TRACKING_DOMAIN);
|
||||
export const TURNSTILE_KEY = runtimeEnv("TURNSTILE_KEY", import.meta.env.VITE_TURNSTILE_KEY);
|
||||
export const HUMAN_VERIFICATION_FAIL = "We couldn’t verify you’re human. Please try the security check again or reload the page.";
|
||||
export const PASSWORD_FAIL = "The password must be at least 8 characters long and contain both uppercase and lowercase letters, as well as a number."
|
||||
export const TOKEN_KEY = "auth_token";
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
// Runtime configuration override.
|
||||
//
|
||||
// Vite bakes `import.meta.env.VITE_*` at build time, which would tie one built
|
||||
// image to one deployment's URLs. To ship a single reusable image, the
|
||||
// production container serves `/config.js` (generated from container env at
|
||||
// startup) that sets `window.__WARMBLY_ENV__`. `runtimeEnv` prefers that value,
|
||||
// then the value Vite baked at build time, then a fallback. In dev the
|
||||
// `public/config.js` stub leaves the window value empty, so the baked value is
|
||||
// used and behavior is unchanged.
|
||||
|
||||
type RuntimeEnv = Record<string, string | undefined>;
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__WARMBLY_ENV__?: RuntimeEnv;
|
||||
}
|
||||
}
|
||||
|
||||
function fromWindow(key: string): string | undefined {
|
||||
if (typeof window === "undefined") return undefined;
|
||||
const value = window.__WARMBLY_ENV__?.[key];
|
||||
// Ignore empties and any unsubstituted "${VAR}" placeholder.
|
||||
if (!value || value.startsWith("${")) return undefined;
|
||||
return value;
|
||||
}
|
||||
|
||||
// runtimeEnv returns the container-injected value, else the build-time value,
|
||||
// else the fallback.
|
||||
export function runtimeEnv(key: string, buildTime?: string, fallback = ""): string {
|
||||
return fromWindow(key) ?? (buildTime ? buildTime : undefined) ?? fallback;
|
||||
}
|
||||
Reference in New Issue
Block a user