diff --git a/charts/helm/checkmate/Chart.yaml b/charts/helm/checkmate/Chart.yaml index 97f320e68..c9433f690 100644 --- a/charts/helm/checkmate/Chart.yaml +++ b/charts/helm/checkmate/Chart.yaml @@ -2,5 +2,5 @@ apiVersion: v2 name: checkmate-chart description: A Helm chart for Checkmate App type: application -version: 0.1.0 -appVersion: "2.3" +version: 0.2.0 +appVersion: "v3.8.1" # "v" prefix: published image tags are v-prefixed (ghcr.io/.../checkmate-*:v3.8.1) diff --git a/charts/helm/checkmate/INSTALLATION.md b/charts/helm/checkmate/INSTALLATION.md index 06cf9f7b9..6bc1f3ecd 100644 --- a/charts/helm/checkmate/INSTALLATION.md +++ b/charts/helm/checkmate/INSTALLATION.md @@ -19,12 +19,11 @@ cd checkmate/charts/helm/checkmate ### 2. Customize values.yaml Edit `values.yaml` to update: -- `client.ingress.host` and `server.ingress.host` with your domain names -- `server.protocol` (usually http or https) +- `client.ingress.host` and `api.ingress.host` with your domain names +- `api.protocol` (usually http or https) - **If upgrading**: Migrate persistence settings from flat structure to nested: - Old: `persistence.mongodbSize` → New: `persistence.mongo.size` - - Old: `persistence.redisSize` → New: `persistence.redis.size` - - Add: `persistence.mongo.storageClass` and `persistence.redis.storageClass` (leave empty for default) + - Add: `persistence.mongo.storageClass` (leave empty for default) - Secrets under the `secrets` section (`JWT_SECRET`, email credentials, API keys, etc.) — replace all change_me values - **For TLS/HTTPS**: Configure ingress TLS settings (see section below) @@ -32,7 +31,7 @@ Edit `values.yaml` to update: ```bash helm install checkmate ./charts/helm/checkmate ``` -This will deploy the client, server, MongoDB, and Redis components. +This will deploy the client, api, and MongoDB components. ### 4. Verify the deployment Check pods and services: @@ -43,6 +42,137 @@ kubectl get svc Once all pods are `Running` and `Ready`, you can access Checkmate via the configured ingress hosts. +## Upgrading from a previous release + +This release renames the `server.*` value block to `api.*` and drops the (unused) bundled Redis. +Read this before running `helm upgrade` on an existing install. + +### `server.*` → `api.*` (values rename) + +The value block formerly named `server` is now `api` (`server.image` → `api.image`, +`server.ingress.host` → `api.ingress.host`, `server.protocol` → `api.protocol`, `server.resources`, +`server.affinity`, `server.tolerations`, `server.ingress.*`, etc.). + +**Your existing `server.*` overrides keep working** — the chart merges any legacy `server.*` values on +top of the `api.*` defaults (legacy wins where set), so an unchanged values file upgrades cleanly. The +shim is transitional; migrate your overrides to `api.*` at your convenience and drop the `server:` +block. If you set **both** `server.*` and `api.*` for the same field, the legacy `server.*` value wins — +don't mix them. + +### Resource renames cause a replace, not a rename + +The Kubernetes objects are renamed alongside the values: + +| Old object | New object | +| --- | --- | +| Deployment/Service `checkmate-server` | `checkmate-api` | +| Ingress `checkmate-server-ingress` | `checkmate-api-ingress` | +| ConfigMap `checkmate-server-nginx-cm` | `checkmate-client-nginx-cm` | + +Helm deletes the old objects and creates the new ones on upgrade. Expect a brief API restart and a +**new ClusterIP** for the API Service — update anything outside the chart that referenced the +`checkmate-server` Service by name. MongoDB data (its PVC) is untouched. + +### Redis removed + +The bundled Redis StatefulSet/Service and the `redis.*` / `persistence.redis.*` values are gone — the +scheduler runs in-process and never used them. Default installs (`redis.enabled: false`) are +unaffected. If you had explicitly set `redis.enabled: true`, **the Redis StatefulSet and its PVC are +deleted on upgrade** — back up anything you stored there first. Leftover `redis:` / `persistence.redis:` +keys in your values file are harmless (ignored). + +### New secret defaults + +`NODE_ENV: production`, `LOG_LEVEL: info`, and `TOKEN_TTL: 99d` are now part of the default `secrets` +block and merge into your install unless you override them. Set them explicitly if you relied on +different values (e.g. a non-production `NODE_ENV`). + +### Image tags follow the chart's `appVersion` + +Each tier's `image` value is now the repository **without a tag**; the tag defaults to the chart's +`appVersion`, so the client, api, worker, and mongodb images all move together when you upgrade the +chart. You can still pin per tier: + +```yaml +api: + image: ghcr.io/bluewave-labs/checkmate-backend # repo only + tag: "" # empty → Chart.appVersion; set e.g. "v3.1.0" to pin, or "latest" for a dev tag +``` + +- `.tag` overrides the version for that tier. The `worker` tier inherits `api.tag` (and + `api.image`) unless you set `worker.tag` / `worker.image`. +- If you put a **full `repo:tag`** in `image` (e.g. a legacy `server.image: …:v3.2.0` override), it is + used verbatim and the `tag` field is ignored — so existing pinned values keep working unchanged. + +**Prefer a pinned tag over `latest`.** With the default `imagePullPolicy: IfNotPresent`, `latest` is +cached per node and never refreshed, and `helm rollback` can't move a floating tag — pin a version (or +let it default to `appVersion`) for reproducible, rollback-able deploys. + +## Scaling: the worker tier & autoscaling + +By default the chart runs a single all-in-one API pod that both schedules **and** processes monitoring +jobs (`worker.enabled: false`). For larger deployments you can split processing onto a dedicated, +horizontally-scalable worker tier. + +### Topology + +| `worker.enabled` | API (`checkmate-api`) | Worker (`checkmate-worker`) | +| --- | --- | --- | +| `false` (default) | schedules **and** processes jobs (`QUEUE_PRIMARY_PROCESSES=true`) | not deployed | +| `true` | schedules + serves the API only (`QUEUE_PRIMARY_PROCESSES=false`, derived) | processes all jobs | + +`QUEUE_PRIMARY_PROCESSES` is **derived** from `worker.enabled` — you never set it directly, so the API +and worker tier can never both process (or both ignore) the queue. + +### Enabling the worker tier + +```yaml +worker: + enabled: true + replicas: 2 # used only when autoscaling.enabled = false +``` + +The worker takes **no inbound traffic** (no Service): the kubelet probes pod IPs directly on +`HEALTH_PORT` (`/livez`, `/readyz`), and graceful shutdown drains in-flight jobs before exit. + +> **Invariant:** `worker.terminationGracePeriodSeconds` (default `30`) **must stay greater than the +> server drain timeout (25s)**. Lower it and Kubernetes SIGKILLs workers mid-drain, losing in-flight +> checks. + +> **Caveat:** with the worker tier enabled the API no longer processes jobs, so if **all** workers are +> down nothing processes. The `autoscaling.minReplicaCount: 1` floor plus liveness restarts mitigate +> this; full failover to the API would need leader election (not supported). + +### Autoscaling with KEDA + +Backlog-driven autoscaling is **off by default** and requires the +[KEDA operator](https://keda.sh/) installed in-cluster. Enable it with: + +```yaml +worker: + enabled: true + autoscaling: + enabled: true + minReplicaCount: 1 # processing floor — never 0, or checks silently stall + maxReplicaCount: 10 + backlogPerReplica: 50 # target due-job backlog per worker + mongo: + dbName: uptime_db # MUST match the database in DB_CONNECTION_STRING +``` + +A KEDA `ScaledObject` queries MongoDB directly for the due-check backlog and scales the worker +Deployment between `minReplicaCount` and `maxReplicaCount`. When autoscaling is on, the Deployment +omits `replicas` so it doesn't fight the HPA KEDA creates. Make sure `autoscaling.mongo.dbName` +matches the database name in your `DB_CONNECTION_STRING`, or the scaler counts the wrong DB (always 0 +backlog → never scales up). + +#### Fallback: Prometheus-adapter HPA (not shipped) + +If your KEDA/Mongo versions reject the `$$NOW`/`$toLong` query, or you already run Prometheus, scrape +the worker's `/metrics` (`checkmate_worker_due_backlog`), expose it via prometheus-adapter, and target +it with a standard `HorizontalPodAutoscaler`. The manifest is left to the operator; the app computes +`now` in code on this path, so the query coercion is unnecessary. + ## Enabling TLS/HTTPS with cert-manager If you have [cert-manager](https://cert-manager.io/) installed in your cluster, you can enable automatic TLS certificate provisioning using Let's Encrypt or other certificate issuers. @@ -68,7 +198,7 @@ client: enabled: true secretName: checkmate-client-tls -server: +api: protocol: https ingress: enabled: true @@ -78,7 +208,7 @@ server: cert-manager.io/cluster-issuer: "letsencrypt-prod" tls: enabled: true - secretName: checkmate-server-tls + secretName: checkmate-api-tls ``` ### Alternative: Using --set flags @@ -88,13 +218,13 @@ You can also enable TLS during installation using Helm's `--set` flags: ```bash helm install checkmate ./charts/helm/checkmate \ --set client.protocol=https \ - --set server.protocol=https \ + --set api.protocol=https \ --set client.ingress.annotations."cert-manager\.io/cluster-issuer"="letsencrypt-prod" \ --set client.ingress.tls.enabled=true \ --set client.ingress.tls.secretName=checkmate-client-tls \ - --set server.ingress.annotations."cert-manager\.io/cluster-issuer"="letsencrypt-prod" \ - --set server.ingress.tls.enabled=true \ - --set server.ingress.tls.secretName=checkmate-server-tls + --set api.ingress.annotations."cert-manager\.io/cluster-issuer"="letsencrypt-prod" \ + --set api.ingress.tls.enabled=true \ + --set api.ingress.tls.secretName=checkmate-api-tls ``` ### Verification @@ -107,7 +237,7 @@ kubectl get certificates # Check certificate details kubectl describe certificate checkmate-client-tls -kubectl describe certificate checkmate-server-tls +kubectl describe certificate checkmate-api-tls # Verify the secrets were created kubectl get secrets | grep checkmate-tls diff --git a/charts/helm/checkmate/templates/_helpers.tpl b/charts/helm/checkmate/templates/_helpers.tpl new file mode 100644 index 000000000..69a1b259b --- /dev/null +++ b/charts/helm/checkmate/templates/_helpers.tpl @@ -0,0 +1,35 @@ +{{- /* +Back-compat shim: the `server.*` value block was renamed to `api.*`. Existing installs that +still pass a legacy `server:` block keep working — this returns the effective API values with any +legacy `server.*` overrides merged on top of the `api.*` defaults (legacy wins where set). + +New installs have no `server:` key, so this is a no-op deepCopy of `.Values.api`. Consume it as: + {{- $api := include "checkmate.api" . | fromYaml -}} +then reference `$api.*` instead of `.Values.api.*`. +*/}} +{{- define "checkmate.api" -}} +{{- $api := deepCopy .Values.api -}} +{{- with .Values.server -}} +{{- $api = mergeOverwrite $api (deepCopy .) -}} +{{- end -}} +{{- $api | toYaml -}} +{{- end -}} + +{{- /* +Resolve a full image reference from a repo + optional tag. `appVersion` is the single source of +truth for tags: bumping Chart.appVersion moves every tier at once. Call as: + {{ include "checkmate.image" (dict "image" $repo "tag" $tag "root" $) }} + +If `image` already carries a tag (a ":" in its final path segment) it is used verbatim — this keeps +legacy `server.image: repo:tag` overrides working and lets anyone pin a full ref (e.g. tag "latest"). +A ":" only in the registry segment (a registry port like "reg:5000/x") is ignored, so a tag is still +appended. Otherwise the tag is `tag` if set, else the chart's appVersion. +*/}} +{{- define "checkmate.image" -}} +{{- $lastSegment := .image | splitList "/" | last -}} +{{- if contains ":" $lastSegment -}} +{{- .image -}} +{{- else -}} +{{- printf "%s:%s" .image (.tag | default .root.Chart.AppVersion) -}} +{{- end -}} +{{- end -}} diff --git a/charts/helm/checkmate/templates/api/deployment.yaml b/charts/helm/checkmate/templates/api/deployment.yaml new file mode 100644 index 000000000..6469d3bdd --- /dev/null +++ b/charts/helm/checkmate/templates/api/deployment.yaml @@ -0,0 +1,65 @@ +{{- $api := include "checkmate.api" . | fromYaml -}} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: checkmate-api +spec: + replicas: 1 # single scheduler — seeds/reconciles the jobs collection + selector: + matchLabels: + app: checkmate-api + template: + metadata: + labels: + app: checkmate-api + spec: + {{- with $api.affinity }} + affinity: + {{- tpl ( . | toYaml) $ | nindent 8 }} + {{- end }} + {{- with $api.tolerations }} + tolerations: + {{- tpl ( . | toYaml) $ | nindent 8 }} + {{- end }} + containers: + - name: api + image: {{ include "checkmate.image" (dict "image" $api.image "tag" $api.tag "root" $) }} + imagePullPolicy: {{ $api.imagePullPolicy }} + ports: + - name: http + containerPort: {{ $api.port }} + envFrom: + - secretRef: + name: checkmate-secrets + # Probes hit the Express /api/v1/health endpoint — proves the HTTP server is responsive. + # NOTE: this does NOT detect a wedged job loop when queuePrimaryProcesses=true (the API is + # also a worker). Worker-aware health for that mode is a follow-up — see worker-health-checks.md §7.5. + livenessProbe: + httpGet: + path: /api/v1/health + port: http + initialDelaySeconds: {{ $api.probes.liveness.initialDelaySeconds }} + periodSeconds: {{ $api.probes.liveness.periodSeconds }} + timeoutSeconds: {{ $api.probes.liveness.timeoutSeconds }} + failureThreshold: {{ $api.probes.liveness.failureThreshold }} + readinessProbe: + httpGet: + path: /api/v1/health + port: http + initialDelaySeconds: {{ $api.probes.readiness.initialDelaySeconds }} + periodSeconds: {{ $api.probes.readiness.periodSeconds }} + timeoutSeconds: {{ $api.probes.readiness.timeoutSeconds }} + failureThreshold: {{ $api.probes.readiness.failureThreshold }} + {{- with $api.resources }} + resources: + {{- tpl ( . | toYaml) $ | nindent 12 }} + {{- end }} + env: + - name: CLIENT_HOST + value: "{{ .Values.client.protocol }}://{{ .Values.client.ingress.host }}" + - name: QUEUE_MODE + value: "primary" + # Derived: when a worker tier is deployed the API only schedules (false); otherwise it is + # all-in-one and processes jobs itself (true). Keeps the two from ever competing for jobs. + - name: QUEUE_PRIMARY_PROCESSES + value: {{ not .Values.worker.enabled | quote }} diff --git a/charts/helm/checkmate/templates/server-ingress.yaml b/charts/helm/checkmate/templates/api/ingress.yaml similarity index 61% rename from charts/helm/checkmate/templates/server-ingress.yaml rename to charts/helm/checkmate/templates/api/ingress.yaml index 34c9ac0dc..ae60889f2 100644 --- a/charts/helm/checkmate/templates/server-ingress.yaml +++ b/charts/helm/checkmate/templates/api/ingress.yaml @@ -1,11 +1,12 @@ -{{- if .Values.server.ingress.enabled }} +{{- $api := include "checkmate.api" . | fromYaml -}} +{{- if $api.ingress.enabled }} apiVersion: networking.k8s.io/v1 kind: Ingress metadata: - name: checkmate-server-ingress - {{- if .Values.server.ingress.annotations }} + name: checkmate-api-ingress + {{- if $api.ingress.annotations }} annotations: - {{- range $key, $value := .Values.server.ingress.annotations }} + {{- range $key, $value := $api.ingress.annotations }} {{ $key }}: {{ $value | quote }} {{- end }} {{- end }} @@ -17,22 +18,22 @@ metadata: #nginx.ingress.kubernetes.io/cors-allow-headers: "DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization" #nginx.ingress.kubernetes.io/cors-allow-credentials: "true"*/}} spec: - ingressClassName: {{ .Values.server.ingress.className }} - {{- if .Values.server.ingress.tls.enabled }} + ingressClassName: {{ $api.ingress.className }} + {{- if $api.ingress.tls.enabled }} tls: - hosts: - - {{ .Values.server.ingress.host }} - secretName: {{ default (printf "%s-server-tls" .Release.Name) .Values.server.ingress.tls.secretName }} + - {{ $api.ingress.host }} + secretName: {{ default (printf "%s-api-tls" .Release.Name) $api.ingress.tls.secretName }} {{- end }} rules: - - host: {{ .Values.server.ingress.host }} + - host: {{ $api.ingress.host }} http: paths: - path: /api/v1 pathType: Prefix backend: service: - name: checkmate-server + name: checkmate-api port: - number: {{ .Values.server.port }} + number: {{ $api.port }} {{- end }} diff --git a/charts/helm/checkmate/templates/api/service.yaml b/charts/helm/checkmate/templates/api/service.yaml new file mode 100644 index 000000000..dfea901c4 --- /dev/null +++ b/charts/helm/checkmate/templates/api/service.yaml @@ -0,0 +1,10 @@ +{{- $api := include "checkmate.api" . | fromYaml -}} +apiVersion: v1 +kind: Service +metadata: + name: checkmate-api +spec: + selector: + app: checkmate-api + ports: + - port: {{ $api.port }} diff --git a/charts/helm/checkmate/templates/client-deployment.yaml b/charts/helm/checkmate/templates/client/deployment.yaml similarity index 76% rename from charts/helm/checkmate/templates/client-deployment.yaml rename to charts/helm/checkmate/templates/client/deployment.yaml index 381631ef5..a98e6a648 100644 --- a/charts/helm/checkmate/templates/client-deployment.yaml +++ b/charts/helm/checkmate/templates/client/deployment.yaml @@ -1,3 +1,4 @@ +{{- $api := include "checkmate.api" . | fromYaml -}} apiVersion: apps/v1 kind: Deployment metadata: @@ -22,12 +23,13 @@ spec: {{- end }} containers: - name: client - image: {{ .Values.client.image }} + image: {{ include "checkmate.image" (dict "image" .Values.client.image "tag" .Values.client.tag "root" $) }} + imagePullPolicy: {{ .Values.client.imagePullPolicy }} ports: - containerPort: {{ .Values.client.port }} env: - name: UPTIME_APP_API_BASE_URL - value: "{{ .Values.server.protocol }}://{{ .Values.server.ingress.host }}/api/v1" + value: "{{ $api.protocol }}://{{ $api.ingress.host }}/api/v1" - name: UPTIME_APP_CLIENT_HOST value: "{{ .Values.client.protocol }}://{{ .Values.client.ingress.host }}" volumeMounts: @@ -41,4 +43,4 @@ spec: volumes: - name: config-volume configMap: - name: checkmate-server-nginx-cm + name: checkmate-client-nginx-cm diff --git a/charts/helm/checkmate/templates/client-ingress.yaml b/charts/helm/checkmate/templates/client/ingress.yaml similarity index 100% rename from charts/helm/checkmate/templates/client-ingress.yaml rename to charts/helm/checkmate/templates/client/ingress.yaml diff --git a/charts/helm/checkmate/templates/server-nginx-cm.yaml b/charts/helm/checkmate/templates/client/nginx-cm.yaml similarity index 81% rename from charts/helm/checkmate/templates/server-nginx-cm.yaml rename to charts/helm/checkmate/templates/client/nginx-cm.yaml index 84da55299..0d23aac0a 100644 --- a/charts/helm/checkmate/templates/server-nginx-cm.yaml +++ b/charts/helm/checkmate/templates/client/nginx-cm.yaml @@ -1,7 +1,8 @@ +{{- $api := include "checkmate.api" . | fromYaml -}} apiVersion: v1 -kind: ConfigMap +kind: ConfigMap metadata: - name: checkmate-server-nginx-cm + name: checkmate-client-nginx-cm data: default.conf: | server { @@ -22,7 +23,7 @@ data: } # location /api/ { - # proxy_pass http://{{ .Values.server.ingress.host }}:5000/api/; + # proxy_pass http://{{ $api.ingress.host }}:5000/api/; # proxy_http_version 1.1; # proxy_set_header Host $host; # proxy_set_header X-Real-IP $remote_addr; @@ -31,7 +32,7 @@ data: # } location /api-docs/ { - proxy_pass http://{{ .Values.server.ingress.host }}:5000/api-docs/; + proxy_pass http://{{ $api.ingress.host }}:5000/api-docs/; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; diff --git a/charts/helm/checkmate/templates/client-service.yaml b/charts/helm/checkmate/templates/client/service.yaml similarity index 100% rename from charts/helm/checkmate/templates/client-service.yaml rename to charts/helm/checkmate/templates/client/service.yaml diff --git a/charts/helm/checkmate/templates/mongodb-service.yaml b/charts/helm/checkmate/templates/db/mongodb-service.yaml similarity index 100% rename from charts/helm/checkmate/templates/mongodb-service.yaml rename to charts/helm/checkmate/templates/db/mongodb-service.yaml diff --git a/charts/helm/checkmate/templates/mongodb-statefulsets.yaml b/charts/helm/checkmate/templates/db/mongodb-statefulsets.yaml similarity index 75% rename from charts/helm/checkmate/templates/mongodb-statefulsets.yaml rename to charts/helm/checkmate/templates/db/mongodb-statefulsets.yaml index 8149b77e2..16c228c2b 100644 --- a/charts/helm/checkmate/templates/mongodb-statefulsets.yaml +++ b/charts/helm/checkmate/templates/db/mongodb-statefulsets.yaml @@ -22,7 +22,8 @@ spec: {{- end }} containers: - name: mongodb - image: {{ .Values.mongodb.image }} + image: {{ include "checkmate.image" (dict "image" .Values.mongodb.image "tag" .Values.mongodb.tag "root" $) }} + imagePullPolicy: {{ .Values.mongodb.imagePullPolicy }} ports: - containerPort: {{ .Values.mongodb.port }} command: ["mongod", "--quiet", "--bind_ip_all"] @@ -37,7 +38,9 @@ spec: - metadata: name: checkmate-mongo-persistent-storage spec: - storageClassName: {{ .Values.persistence.mongo.storageClass | quote }} + {{- with .Values.persistence.mongo.storageClass }} + storageClassName: {{ . | quote }} {{/* omitted when empty so the cluster's default StorageClass applies */}} + {{- end }} accessModes: ["ReadWriteOnce"] resources: requests: diff --git a/charts/helm/checkmate/templates/prechecks.yaml b/charts/helm/checkmate/templates/prechecks.yaml index e2b9ec5c2..cc9f6b9a1 100644 --- a/charts/helm/checkmate/templates/prechecks.yaml +++ b/charts/helm/checkmate/templates/prechecks.yaml @@ -1,14 +1,15 @@ +{{- $api := include "checkmate.api" . | fromYaml -}} {{- if eq .Values.client.ingress.host "change_me" }} {{- fail "client.ingress.host must be overridden and not set to 'change_me'" }} {{- end }} -{{- if eq .Values.server.ingress.host "change_me" }} - {{- fail "server.ingress.host must be overridden and not set to 'change_me'" }} +{{- if eq $api.ingress.host "change_me" }} + {{- fail "api.ingress.host (or legacy server.ingress.host) must be overridden and not set to 'change_me'" }} {{- end }} -{{- $serverProtocol := .Values.server.protocol }} -{{- if not (or (eq $serverProtocol "http") (eq $serverProtocol "https")) }} - {{- fail "server.protocol must be either 'http' or 'https'" }} +{{- $apiProtocol := $api.protocol }} +{{- if not (or (eq $apiProtocol "http") (eq $apiProtocol "https")) }} + {{- fail "api.protocol must be either 'http' or 'https'" }} {{- end }} {{- $clientProtocol := .Values.client.protocol }} @@ -21,28 +22,33 @@ {{- fail "client.ingress.tls.enabled is true but client.protocol is not 'https'. Set client.protocol: https to avoid mixed content." }} {{- end }} -{{- if and .Values.server.ingress.tls.enabled (ne $serverProtocol "https") }} - {{- fail "server.ingress.tls.enabled is true but server.protocol is not 'https'. Set server.protocol: https to ensure correct API base URL." }} +{{- if and $api.ingress.tls.enabled (ne $apiProtocol "https") }} + {{- fail "api.ingress.tls.enabled is true but api.protocol is not 'https'. Set api.protocol: https to ensure correct API base URL." }} {{- end }} {{/* If client runs on https, API must also be https to avoid mixed content */}} -{{- if and (eq $clientProtocol "https") (ne $serverProtocol "https") }} - {{- fail "client.protocol is 'https' but server.protocol is not. Set server.protocol: https to prevent browser mixed-content issues." }} +{{- if and (eq $clientProtocol "https") (ne $apiProtocol "https") }} + {{- fail "client.protocol is 'https' but api.protocol is not. Set api.protocol: https to prevent browser mixed-content issues." }} {{- end }} {{/* Fail early if TLS enabled without cert-manager annotations (cluster-issuer or issuer) */}} {{- $cAnn := .Values.client.ingress.annotations | default dict }} -{{- $sAnn := .Values.server.ingress.annotations | default dict }} +{{- $aAnn := $api.ingress.annotations | default dict }} {{- $clientHasIssuer := or (hasKey $cAnn "cert-manager.io/cluster-issuer") (hasKey $cAnn "cert-manager.io/issuer") }} -{{- $serverHasIssuer := or (hasKey $sAnn "cert-manager.io/cluster-issuer") (hasKey $sAnn "cert-manager.io/issuer") }} +{{- $apiHasIssuer := or (hasKey $aAnn "cert-manager.io/cluster-issuer") (hasKey $aAnn "cert-manager.io/issuer") }} {{- if and .Values.client.ingress.tls.enabled (not $clientHasIssuer) }} {{- fail "client.ingress.tls.enabled is true but no cert-manager issuer annotation found. Add 'cert-manager.io/cluster-issuer' or 'cert-manager.io/issuer'." }} {{- end }} -{{- if and .Values.server.ingress.tls.enabled (not $serverHasIssuer) }} - {{- fail "server.ingress.tls.enabled is true but no cert-manager issuer annotation found. Add 'cert-manager.io/cluster-issuer' or 'cert-manager.io/issuer'." }} +{{- if and $api.ingress.tls.enabled (not $apiHasIssuer) }} + {{- fail "api.ingress.tls.enabled is true but no cert-manager issuer annotation found. Add 'cert-manager.io/cluster-issuer' or 'cert-manager.io/issuer'." }} {{- end }} -{{/* Secret name can be omitted; we default to -client|server-tls in templates */}} +{{/* Worker grace period must exceed the server drain timeout (25s) or workers are SIGKILLed mid-drain */}} +{{- if and .Values.worker.enabled (le (int .Values.worker.terminationGracePeriodSeconds) 25) }} + {{- fail "worker.terminationGracePeriodSeconds must be greater than 25 (the server drain timeout) or workers are killed mid-drain" }} +{{- end }} + +{{/* Secret name can be omitted; we default to -client|api-tls in templates */}} diff --git a/charts/helm/checkmate/templates/redis-service.yaml b/charts/helm/checkmate/templates/redis-service.yaml deleted file mode 100644 index 2720350da..000000000 --- a/charts/helm/checkmate/templates/redis-service.yaml +++ /dev/null @@ -1,11 +0,0 @@ -{{- if .Values.redis.enabled }} -apiVersion: v1 -kind: Service -metadata: - name: checkmate-redis -spec: - selector: - app: checkmate-redis - ports: - - port: {{ .Values.redis.port }} -{{- end }} diff --git a/charts/helm/checkmate/templates/redis-statefulsets.yaml b/charts/helm/checkmate/templates/redis-statefulsets.yaml deleted file mode 100644 index acf7c85a8..000000000 --- a/charts/helm/checkmate/templates/redis-statefulsets.yaml +++ /dev/null @@ -1,33 +0,0 @@ -{{- if .Values.redis.enabled }} -apiVersion: apps/v1 -kind: StatefulSet -metadata: - name: checkmate-redis -spec: - replicas: 1 - selector: - matchLabels: - app: checkmate-redis - template: - metadata: - labels: - app: checkmate-redis - spec: - containers: - - name: redis - image: {{ .Values.redis.image }} - ports: - - containerPort: {{ .Values.redis.port }} - volumeMounts: - - name: redis-data - mountPath: /data - volumeClaimTemplates: - - metadata: - name: checkmate-redis-persistent-storage - spec: - storageClassName: {{ .Values.persistence.redis.storageClass | quote }} - accessModes: ["ReadWriteOnce"] - resources: - requests: - storage: {{ .Values.persistence.redis.size | default "1Gi" | quote }} -{{- end }} \ No newline at end of file diff --git a/charts/helm/checkmate/templates/secrets.yaml b/charts/helm/checkmate/templates/secrets.yaml index af104e7d1..9c99395f4 100644 --- a/charts/helm/checkmate/templates/secrets.yaml +++ b/charts/helm/checkmate/templates/secrets.yaml @@ -4,6 +4,14 @@ {{- fail "secrets.JWT_SECRET must be overridden and cannot be 'change_me'" }} {{- end }} +{{- /* + The bundled Mongo service ("checkmate-mongodb") lives in .Release.Namespace. App pods reach it + by bare name, but the KEDA operator runs in its own namespace and a bare name is NXDOMAIN there, + so worker autoscaling fails to query the backlog. Qualify the in-cluster host to its FQDN here so + the one connection string resolves from every namespace. External/already-qualified hosts are not + matched (the char after the host name is "." for an FQDN, which the pattern excludes). +*/}} +{{- $mongoFqdn := printf "checkmate-mongodb.%s.svc.cluster.local" .Release.Namespace }} apiVersion: v1 kind: Secret metadata: @@ -11,5 +19,9 @@ metadata: type: Opaque stringData: {{- range $key, $value := $secrets }} +{{- if eq $key "DB_CONNECTION_STRING" }} + {{ $key }}: {{ regexReplaceAll "(//|@)checkmate-mongodb([:/,]|$)" $value (printf "${1}%s${2}" $mongoFqdn) | quote }} +{{- else }} {{ $key }}: {{ $value | quote }} +{{- end }} {{- end }} \ No newline at end of file diff --git a/charts/helm/checkmate/templates/server-deployment.yaml b/charts/helm/checkmate/templates/server-deployment.yaml deleted file mode 100644 index 393964ccd..000000000 --- a/charts/helm/checkmate/templates/server-deployment.yaml +++ /dev/null @@ -1,37 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: checkmate-server -spec: - replicas: 1 - selector: - matchLabels: - app: checkmate-server - template: - metadata: - labels: - app: checkmate-server - spec: - {{- with .Values.server.affinity }} - affinity: - {{- tpl ( . | toYaml) $ | nindent 8 }} - {{- end }} - {{- with .Values.server.tolerations }} - tolerations: - {{- tpl ( . | toYaml) $ | nindent 8 }} - {{- end }} - containers: - - name: server - image: {{ .Values.server.image }} - ports: - - containerPort: {{ .Values.server.port }} - envFrom: - - secretRef: - name: checkmate-secrets - {{- with .Values.server.resources }} - resources: - {{- tpl ( . | toYaml) $ | nindent 12 }} - {{- end }} - env: - - name: CLIENT_HOST - value: "{{ .Values.client.protocol }}://{{ .Values.client.ingress.host }}" diff --git a/charts/helm/checkmate/templates/server-service.yaml b/charts/helm/checkmate/templates/server-service.yaml deleted file mode 100644 index d8a7fbe86..000000000 --- a/charts/helm/checkmate/templates/server-service.yaml +++ /dev/null @@ -1,9 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - name: checkmate-server -spec: - selector: - app: checkmate-server - ports: - - port: {{ .Values.server.port }} diff --git a/charts/helm/checkmate/templates/worker/deployment.yaml b/charts/helm/checkmate/templates/worker/deployment.yaml new file mode 100644 index 000000000..0b0849455 --- /dev/null +++ b/charts/helm/checkmate/templates/worker/deployment.yaml @@ -0,0 +1,66 @@ +{{- $api := include "checkmate.api" . | fromYaml -}} +{{- if .Values.worker.enabled }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: checkmate-worker +spec: + {{- if not .Values.worker.autoscaling.enabled }} + replicas: {{ .Values.worker.replicas }} # omitted when autoscaling owns the replica count + {{- end }} + selector: + matchLabels: + app: checkmate-worker + template: + metadata: + labels: + app: checkmate-worker + spec: + terminationGracePeriodSeconds: {{ .Values.worker.terminationGracePeriodSeconds }} + {{- with .Values.worker.affinity }} + affinity: + {{- tpl ( . | toYaml) $ | nindent 8 }} + {{- end }} + {{- with .Values.worker.tolerations }} + tolerations: + {{- tpl ( . | toYaml) $ | nindent 8 }} + {{- end }} + containers: + - name: worker + image: {{ include "checkmate.image" (dict "image" (.Values.worker.image | default $api.image) "tag" (.Values.worker.tag | default $api.tag) "root" $) }} + imagePullPolicy: {{ .Values.worker.imagePullPolicy | default $api.imagePullPolicy }} + ports: + - name: health + containerPort: {{ .Values.worker.healthPort }} + envFrom: + - secretRef: + name: checkmate-secrets # DB_CONNECTION_STRING (+ JWT_SECRET/CLIENT_HOST for validateEnv) + env: + # Secret default CLIENT_HOST is the non-URL "change_me"; override like the API so validateEnv passes. + - name: CLIENT_HOST + value: "{{ .Values.client.protocol }}://{{ .Values.client.ingress.host }}" + - name: QUEUE_MODE + value: "worker" + - name: HEALTH_PORT + value: {{ .Values.worker.healthPort | quote }} + livenessProbe: + httpGet: + path: /livez + port: health + initialDelaySeconds: {{ .Values.worker.probes.liveness.initialDelaySeconds }} + periodSeconds: {{ .Values.worker.probes.liveness.periodSeconds }} + timeoutSeconds: {{ .Values.worker.probes.liveness.timeoutSeconds }} + failureThreshold: {{ .Values.worker.probes.liveness.failureThreshold }} + readinessProbe: + httpGet: + path: /readyz + port: health + initialDelaySeconds: {{ .Values.worker.probes.readiness.initialDelaySeconds }} + periodSeconds: {{ .Values.worker.probes.readiness.periodSeconds }} + timeoutSeconds: {{ .Values.worker.probes.readiness.timeoutSeconds }} + failureThreshold: {{ .Values.worker.probes.readiness.failureThreshold }} + {{- with .Values.worker.resources }} + resources: + {{- tpl ( . | toYaml) $ | nindent 12 }} + {{- end }} +{{- end }} diff --git a/charts/helm/checkmate/templates/worker/scaledobject.yaml b/charts/helm/checkmate/templates/worker/scaledobject.yaml new file mode 100644 index 000000000..09fc9a8a3 --- /dev/null +++ b/charts/helm/checkmate/templates/worker/scaledobject.yaml @@ -0,0 +1,30 @@ +{{- if and .Values.worker.enabled .Values.worker.autoscaling.enabled }} +apiVersion: keda.sh/v1alpha1 +kind: ScaledObject +metadata: + name: checkmate-worker +spec: + scaleTargetRef: + name: checkmate-worker + minReplicaCount: {{ .Values.worker.autoscaling.minReplicaCount }} # floor: never 0 — a cold gap stops checks + maxReplicaCount: {{ .Values.worker.autoscaling.maxReplicaCount }} + cooldownPeriod: {{ .Values.worker.autoscaling.cooldownPeriod }} + advanced: + horizontalPodAutoscalerConfig: + behavior: + scaleDown: + stabilizationWindowSeconds: {{ .Values.worker.autoscaling.stabilizationWindowSeconds }} + triggers: + - type: mongodb + metadata: + dbName: {{ .Values.worker.autoscaling.mongo.dbName | quote }} + collection: {{ .Values.worker.autoscaling.mongo.collection | quote }} + # The Phase 2 countDueBacklog predicate, time-relativized with $$NOW (epoch-ms via $toLong): + query: >- + { "type": "check", "isActive": true, + "$expr": { "$lte": ["$nextScheduledAt", { "$toLong": "$$NOW" }] }, + "$or": [ { "lockedUntil": null }, + { "$expr": { "$lt": ["$lockedUntil", { "$toLong": "$$NOW" }] } } ] } + queryValue: {{ .Values.worker.autoscaling.backlogPerReplica | quote }} + connectionStringFromEnv: DB_CONNECTION_STRING # resolved from the worker's checkmate-secrets +{{- end }} diff --git a/charts/helm/checkmate/values.yaml b/charts/helm/checkmate/values.yaml index 9a586dd54..b2126fe20 100644 --- a/charts/helm/checkmate/values.yaml +++ b/charts/helm/checkmate/values.yaml @@ -1,5 +1,7 @@ client: - image: ghcr.io/bluewave-labs/checkmate-client:v3.2.0 + image: ghcr.io/bluewave-labs/checkmate-client + tag: "" # defaults to Chart.appVersion; set a full repo:tag in `image` to pin (e.g. a :latest dev tag) + imagePullPolicy: IfNotPresent # IfNotPresent so kind-loaded :latest images are used (not pulled) port: 80 protocol: http ingress: @@ -17,10 +19,24 @@ client: # a cert-manager issuer annotation (e.g. cert-manager.io/cluster-issuer: "letsencrypt-prod"). # The secret will be automatically created by cert-manager when using the cert-manager.io/cluster-issuer annotation -server: - image: ghcr.io/bluewave-labs/checkmate-backend:v3.2.0 +api: + image: ghcr.io/bluewave-labs/checkmate-backend + tag: "" # defaults to Chart.appVersion; set a full repo:tag in `image` to pin (e.g. a :latest dev tag) + imagePullPolicy: IfNotPresent # IfNotPresent so kind-loaded :latest images are used (not pulled) port: 52345 protocol: http + # Liveness/readiness probes hit GET /api/v1/health on the api port (proves the HTTP server is up). + probes: + liveness: + initialDelaySeconds: 10 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 3 + readiness: + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 3 ingress: enabled: true host: change_me @@ -31,40 +47,62 @@ server: # cert-manager.io/cluster-issuer: "letsencrypt-prod" tls: enabled: false - # secretName: {{ .Release.Name }}-server-tls # Optional; defaults to -server-tls if omitted - # Note: when enabling TLS, also set server.protocol: https and add + # secretName: {{ .Release.Name }}-api-tls # Optional; defaults to -api-tls if omitted + # Note: when enabling TLS, also set api.protocol: https and add # a cert-manager issuer annotation (e.g. cert-manager.io/cluster-issuer: "letsencrypt-prod"). # The secret will be automatically created by cert-manager when using the cert-manager.io/cluster-issuer annotation -redis: - enabled: false - image: redis:7.2 - port: 6379 - mongodb: - image: ghcr.io/bluewave-labs/checkmate-mongo:v3.2.0 + image: ghcr.io/bluewave-labs/checkmate-mongo + tag: "" # defaults to Chart.appVersion; set a full repo:tag in `image` to pin + imagePullPolicy: IfNotPresent # IfNotPresent so kind-loaded :latest images are used (not pulled) port: 27017 +worker: + # Off by default; existing installs are unchanged. When true, a dedicated worker processes all + # jobs and the API stops processing (QUEUE_PRIMARY_PROCESSES is derived as false). When false, the + # API is all-in-one and processes jobs itself. + enabled: false + image: "" # defaults to api.image when empty, same as API image + tag: "" # defaults to api.tag (-> Chart.appVersion) when empty + imagePullPolicy: "" # defaults to api.imagePullPolicy when empty + replicas: 1 # used only when autoscaling.enabled = false + healthPort: 52346 # HEALTH_PORT; serves /livez /readyz /metrics + terminationGracePeriodSeconds: 30 # MUST stay > the server drain timeout (25s) or workers are killed mid-drain + resources: {} + affinity: {} + tolerations: {} + probes: + liveness: # /livez — failureThreshold>=3 so one slow tick never restarts mid-job + initialDelaySeconds: 10 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 3 + readiness: # /readyz — flips to 503 on drain + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 3 + autoscaling: + enabled: false # requires the KEDA operator installed in-cluster + minReplicaCount: 1 # processing floor — never 0 (a cold gap silently stops checks) + maxReplicaCount: 10 + backlogPerReplica: 50 # KEDA queryValue: target due-backlog per worker + cooldownPeriod: 300 # seconds before scaling an idle target down + stabilizationWindowSeconds: 300 # damp the interval-boundary herd + mongo: + dbName: uptime_db # must match DB_CONNECTION_STRING's database + collection: jobs + secrets: JWT_SECRET: change_me -# REFRESH_TOKEN_SECRET: change_me -# SYSTEM_EMAIL_ADDRESS: test@example.com -# SYSTEM_EMAIL_PASSWORD: change_me -# SYSTEM_EMAIL_HOST: smtp.example.com -# SYSTEM_EMAIL_PORT: "587" -# PAGESPEED_API_KEY: change_me - DB_CONNECTION_STRING: mongodb://checkmate-mongodb.namespace.svc:27017/uptime_db + DB_CONNECTION_STRING: mongodb://checkmate-mongodb:27017/uptime_db CLIENT_HOST: change_me -# REDIS_HOST: redis -# REDIS_PORT: "6379" -# DB_TYPE: MongoDB -# TOKEN_TTL: 99d -# REFRESH_TOKEN_TTL: 99d + NODE_ENV: production + LOG_LEVEL: info + TOKEN_TTL: 99d persistence: mongo: size: 5Gi storageClass: "" - redis: - size: 1Gi - storageClass: "" \ No newline at end of file diff --git a/client/package-lock.json b/client/package-lock.json index 260b40bd3..c7d59b06c 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -8,6 +8,7 @@ "name": "client", "version": "3.9.0", "dependencies": { + "@csstools/css-tokenizer": "^3.0.4", "@emotion/react": "^11.13.3", "@emotion/styled": "^11.13.0", "@hello-pangea/dnd": "^18.0.0", @@ -303,6 +304,25 @@ "node": ">=6.9.0" } }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@ctrl/tinycolor": { "version": "4.2.0", "license": "MIT", diff --git a/client/package.json b/client/package.json index 1e7cc494e..685b37db1 100644 --- a/client/package.json +++ b/client/package.json @@ -17,6 +17,7 @@ "**/*": "prettier --write --ignore-unknown" }, "dependencies": { + "@csstools/css-tokenizer": "^3.0.4", "@emotion/react": "^11.13.3", "@emotion/styled": "^11.13.0", "@hello-pangea/dnd": "^18.0.0", diff --git a/client/src/Components/inputs/TextInput.tsx b/client/src/Components/inputs/TextInput.tsx index 13277be72..230e7650b 100644 --- a/client/src/Components/inputs/TextInput.tsx +++ b/client/src/Components/inputs/TextInput.tsx @@ -22,7 +22,7 @@ export const TextInput = forwardRef(function T width: "100%", "& .MuiOutlinedInput-root": { borderRadius: theme.shape.borderRadius, - height: 34, + height: props.multiline ? "auto" : 34, fontSize: typographyLevels.base, overflow: "hidden", }, diff --git a/client/src/Pages/StatusPage/Create/index.tsx b/client/src/Pages/StatusPage/Create/index.tsx index 8a7b48010..8dd119b72 100644 --- a/client/src/Pages/StatusPage/Create/index.tsx +++ b/client/src/Pages/StatusPage/Create/index.tsx @@ -162,6 +162,7 @@ const CreateStatusPage = () => { fd.append("showUptimePercentage", String(data.showUptimePercentage)); fd.append("showAdminLoginLink", String(data.showAdminLoginLink)); fd.append("showInfrastructure", String(data.showInfrastructure)); + fd.append("customCSS", data.customCSS ?? ""); if (data.theme) fd.append("theme", data.theme); if (data.themeMode) fd.append("themeMode", data.themeMode); @@ -530,6 +531,33 @@ const CreateStatusPage = () => { } /> )} + {showStep(1) && ( + ( + + )} + /> + } + /> + )} {showStep(1) && ( > = { refined: { @@ -63,6 +66,11 @@ const THEME_CONFIGS: Record> = { HeroSlot: EditorialHero, overallStatusOptions: { allUpKey: "pages.statusPages.editorial.allUp" }, }, + minimal: { + createStyles: minimalStyles, + HeaderSlot: RefinedHeader, + HeroSlot: RefinedHero, + }, }; const StatusPageView = () => { @@ -121,17 +129,9 @@ const StatusPageView = () => { ); } - const themeConfig = THEME_CONFIGS[resolveStatusPageTheme(statusPage.theme)]; - const themedRenderer = ( - - ); - // Public route: render directly on the viewport, themed background covers everything. if (isPublic) { + const themeConfig = THEME_CONFIGS[resolveStatusPageTheme(statusPage.theme)]; return ( { timezone={statusPage.timezone} paintBody > - {themedRenderer} + ); } const publicUrl = getStatusPagePublicUrl(statusPage); + const previewUrl = getStatusPagePreviewUrl(statusPage); return ( { timezone={statusPage.timezone} transparent > - {themedRenderer} + + + ); diff --git a/client/src/Pages/StatusPage/Status/themes/BrowserFrame.tsx b/client/src/Pages/StatusPage/Status/themes/BrowserFrame.tsx index b893aa85c..183e1fdea 100644 --- a/client/src/Pages/StatusPage/Status/themes/BrowserFrame.tsx +++ b/client/src/Pages/StatusPage/Status/themes/BrowserFrame.tsx @@ -64,7 +64,13 @@ export const BrowserFrame = ({ url, children }: Props) => { {url} - {children} + + {children} + ); }; diff --git a/client/src/Pages/StatusPage/Status/themes/minimal/styles.ts b/client/src/Pages/StatusPage/Status/themes/minimal/styles.ts new file mode 100644 index 000000000..27370c0ad --- /dev/null +++ b/client/src/Pages/StatusPage/Status/themes/minimal/styles.ts @@ -0,0 +1,66 @@ +import type { SxProps, Theme } from "@mui/material/styles"; +import type { StatusPageThemeTokens } from "../tokens"; +import type { OverallTone } from "../shared/overallStatus"; +import { refinedStyles, type RefinedStyles } from "../refined/styles"; + +type FlatSx = Exclude, readonly unknown[] | ((theme: Theme) => unknown)>; + +const extend = (base: SxProps, overrides: SxProps): SxProps => [ + base as FlatSx, + overrides as FlatSx, +]; + +export const minimalStyles = ( + tokens: StatusPageThemeTokens, + isDark: boolean +): RefinedStyles => { + const base = refinedStyles(tokens, isDark); + + return { + ...base, + page: extend(base.page, { + maxWidth: 820, + p: { xs: "32px 16px 64px", md: "48px 20px 72px" }, + }), + top: extend(base.top, { + mb: "44px", + pb: "16px", + borderBottom: `1px solid ${tokens.border}`, + }), + logoMono: extend(base.logoMono, { + borderRadius: "50%", + background: tokens.text, + }), + hero: extend(base.hero, { + alignItems: "center", + background: "transparent", + border: 0, + boxShadow: "none", + p: 0, + mb: "40px", + }), + heroRow: extend(base.heroRow, { justifyContent: "center" }), + heroTitle: extend(base.heroTitle, { + flex: "initial", + fontSize: { xs: 22, md: 28 }, + fontWeight: 650, + textAlign: "center", + }), + heroIcon: (_tone: OverallTone) => ({ display: "none" }), + heroSub: extend(base.heroSub, { textAlign: "center", mt: "4px" }), + chartSwitch: extend(base.chartSwitch, { borderRadius: tokens.radius }), + card: extend(base.card, { + boxShadow: "none", + transition: "none", + "&:hover": { transform: "none", boxShadow: "none" }, + }), + cardRow: extend(base.cardRow, { p: "14px 16px" }), + badge: (tone) => + extend(base.badge(tone), { + background: "transparent", + p: 0, + }), + gauge: extend(base.gauge, { borderRadius: tokens.radius }), + footer: extend(base.footer, { mt: "48px", fontSize: 11 }), + }; +}; diff --git a/client/src/Pages/StatusPage/Status/themes/shared/BaseStatusPage.tsx b/client/src/Pages/StatusPage/Status/themes/shared/BaseStatusPage.tsx index a6214567d..1dfc0bd1b 100644 --- a/client/src/Pages/StatusPage/Status/themes/shared/BaseStatusPage.tsx +++ b/client/src/Pages/StatusPage/Status/themes/shared/BaseStatusPage.tsx @@ -102,6 +102,7 @@ export const BaseStatusPage = ({ statusPage, monitors, config }: Props) => { return ( + {statusPage.customCSS && } = { refined, modern, bold, editorial, + minimal, }; diff --git a/client/src/Types/StatusPage.ts b/client/src/Types/StatusPage.ts index 822ab7345..bbe1f41f2 100644 --- a/client/src/Types/StatusPage.ts +++ b/client/src/Types/StatusPage.ts @@ -21,7 +21,13 @@ export const getMonitorTypeLabel = ( return key ? t(key) : type; }; -export const STATUS_PAGE_THEMES = ["refined", "modern", "bold", "editorial"] as const; +export const STATUS_PAGE_THEMES = [ + "refined", + "modern", + "bold", + "editorial", + "minimal", +] as const; export type StatusPageTheme = (typeof STATUS_PAGE_THEMES)[number]; export const DEFAULT_STATUS_PAGE_THEME: StatusPageTheme = "refined"; diff --git a/client/src/Utils/customCss.ts b/client/src/Utils/customCss.ts new file mode 100644 index 000000000..02a267daa --- /dev/null +++ b/client/src/Utils/customCss.ts @@ -0,0 +1,16 @@ +import { isTokenString, isTokenURL, tokenize } from "@csstools/css-tokenizer"; + +// Mirrors the server check (server/src/utils/customCss.ts) so the form rejects +// the same CSS the API would. +const EXTERNAL_TARGET = /^\s*(?:https?:|\/\/)/i; + +export const cssReferencesExternalResource = (css?: string | null): boolean => { + if (typeof css !== "string" || css === "") { + return false; + } + + return tokenize({ css }).some( + (token) => + (isTokenURL(token) || isTokenString(token)) && EXTERNAL_TARGET.test(token[4].value) + ); +}; diff --git a/client/src/Utils/statusPageUrl.ts b/client/src/Utils/statusPageUrl.ts index ff19f87c6..d5628d6bc 100644 --- a/client/src/Utils/statusPageUrl.ts +++ b/client/src/Utils/statusPageUrl.ts @@ -70,6 +70,16 @@ export const getStatusPagePublicUrl = ( return `${PUBLIC_STATUS_PAGE_PREFIX}/${statusPage.url}`; }; +// Same-origin URL for the admin preview iframe; a custom domain would be cross +// origin and blocked by the admin document's CSP frame-src. +export const getStatusPagePreviewUrl = (statusPage: Pick): string => { + if (typeof window !== "undefined") { + return `${window.location.origin}${PUBLIC_STATUS_PAGE_PREFIX}/${statusPage.url}`; + } + + return `${PUBLIC_STATUS_PAGE_PREFIX}/${statusPage.url}`; +}; + export const buildStatusPageApiPath = (options: { url?: string; useCustomDomain?: boolean; diff --git a/client/src/Validation/statusPage.ts b/client/src/Validation/statusPage.ts index 271699765..eddde8ab7 100644 --- a/client/src/Validation/statusPage.ts +++ b/client/src/Validation/statusPage.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import type { FieldPath } from "react-hook-form"; import { STATUS_PAGE_THEMES, STATUS_PAGE_THEME_MODES } from "@/Types/StatusPage"; +import { cssReferencesExternalResource } from "@/Utils/customCss"; // Wizard step a field is validated on. Attached inline to each field below so // the grouping lives next to the field definition; unannotated fields default @@ -58,7 +59,14 @@ export const statusPageSchema = z.object({ showUptimePercentage: z.boolean().register(statusPageStepRegistry, { step: 1 }), showAdminLoginLink: z.boolean().register(statusPageStepRegistry, { step: 1 }), showInfrastructure: z.boolean().register(statusPageStepRegistry, { step: 1 }), - customCSS: z.string().optional().register(statusPageStepRegistry, { step: 1 }), + customCSS: z + .string() + .max(100000, "Custom CSS must be at most 100000 characters") + .refine((css) => !cssReferencesExternalResource(css), { + message: "Custom CSS cannot reference external URLs or use @import", + }) + .optional() + .register(statusPageStepRegistry, { step: 1 }), theme: z .enum(STATUS_PAGE_THEMES) .optional() diff --git a/client/src/locales/en.json b/client/src/locales/en.json index e4bbc0f69..c320c5767 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -1585,6 +1585,10 @@ "editorial": { "name": "Editorial", "description": "Serif headings and hairline dividers. Distinctive and calm." + }, + "minimal": { + "name": "Minimal", + "description": "Flat, focused, and compact with only the essentials." } } }, @@ -1594,6 +1598,16 @@ "light": "Light only", "dark": "Dark only" }, + "customCSS": { + "title": "Custom CSS", + "description": "Add custom CSS to fine-tune the look of your public status page.", + "option": { + "customCSS": { + "label": "Custom CSS", + "placeholder": "footer { display: none; }" + } + } + }, "features": { "title": "Features", "description": "Configure what information is displayed on your status page.", @@ -1616,6 +1630,9 @@ "header": { "title": "Status pages", "description": "Publish public pages that show real-time uptime and incident history to your customers and stakeholders." + }, + "preview": { + "title": "Status page preview" } }, "uptime": { diff --git a/docker/.gitignore b/docker/.gitignore index 76567d514..4c3f83657 100755 --- a/docker/.gitignore +++ b/docker/.gitignore @@ -2,14 +2,10 @@ !quickstart.sh !build_images.sh dev/mongo/data/* -dev/redis/data/* dist/mongo/data/* -dist/redis/data/* prod/mongo/data/* -prod/redis/data/* dist/docker-compose-test.yaml /dist-mono/mongo/data/* -/dist-mono/redis/data/* /dist-arm/mongo/data/* *.env diff --git a/docker/coolify/build_images.bat b/docker/coolify/build_images.bat index 60fc9aa4a..b940039b6 100644 --- a/docker/coolify/build_images.bat +++ b/docker/coolify/build_images.bat @@ -12,14 +12,11 @@ set dockerfiles[0]=.\docker\coolify\client.Dockerfile set services[1]=bluewaveuptime/uptime_database_mongo set dockerfiles[1]=.\docker\coolify\mongoDB.Dockerfile -set services[2]=bluewaveuptime/uptime_redis -set dockerfiles[2]=.\docker\coolify\redis.Dockerfile - -set services[3]=bluewaveuptime/uptime_server -set dockerfiles[3]=.\docker\coolify\server.Dockerfile +set services[2]=bluewaveuptime/uptime_server +set dockerfiles[2]=.\docker\coolify\server.Dockerfile REM Loop through each service and build the corresponding image -for /L %%i in (0,1,2,3) do ( +for /L %%i in (0,1,2) do ( set service=!services[%%i]! set dockerfile=!dockerfiles[%%i]! diff --git a/docker/coolify/build_images.sh b/docker/coolify/build_images.sh index 04dfe324c..1d29ae5b1 100755 --- a/docker/coolify/build_images.sh +++ b/docker/coolify/build_images.sh @@ -7,7 +7,6 @@ cd ../../ declare -A services=( ["bluewaveuptime/uptime_client"]="./docker/coolify/client.Dockerfile" ["bluewaveuptime/uptime_database_mongo"]="./docker/coolify/mongoDB.Dockerfile" - ["bluewaveuptime/uptime_redis"]="./docker/coolify/redis.Dockerfile" ["bluewaveuptime/uptime_server"]="./docker/coolify/server.Dockerfile" ) diff --git a/docker/coolify/redis.Dockerfile b/docker/coolify/redis.Dockerfile deleted file mode 100755 index af68ec61e..000000000 --- a/docker/coolify/redis.Dockerfile +++ /dev/null @@ -1,2 +0,0 @@ -FROM redis -EXPOSE 6379 \ No newline at end of file diff --git a/docker/dev/docker-compose.custom-ca-example.yaml b/docker/dev/docker-compose.custom-ca-example.yaml index a5d7e2f43..e1f185ae9 100644 --- a/docker/dev/docker-compose.custom-ca-example.yaml +++ b/docker/dev/docker-compose.custom-ca-example.yaml @@ -19,7 +19,6 @@ services: # Mount the certs directory (read-only for security) - ./certs:/certs:ro depends_on: - - redis - mongodb # Optional: Add healthcheck to ensure the server starts properly healthcheck: diff --git a/docker/dist-arm/docker-compose.yaml b/docker/dist-arm/docker-compose.yaml index b9aba057c..49f81fe9a 100644 --- a/docker/dist-arm/docker-compose.yaml +++ b/docker/dist-arm/docker-compose.yaml @@ -9,7 +9,6 @@ services: - UPTIME_APP_API_BASE_URL=http://localhost:52345/api/v1 - UPTIME_APP_CLIENT_HOST=http://localhost - DB_CONNECTION_STRING=mongodb://mongodb:27017/uptime_db - - REDIS_URL=redis://redis:6379 - CLIENT_HOST=http://localhost - JWT_SECRET=my_secret depends_on: diff --git a/server/openapi.json b/server/openapi.json index d0bbe45dc..4b710b639 100644 --- a/server/openapi.json +++ b/server/openapi.json @@ -10520,6 +10520,10 @@ "showInfrastructure": { "type": "boolean" }, + "customCSS": { + "type": "string", + "maxLength": 100000 + }, "removeLogo": { "anyOf": [ { @@ -10734,6 +10738,10 @@ "showInfrastructure": { "type": "boolean" }, + "customCSS": { + "type": "string", + "maxLength": 100000 + }, "removeLogo": { "anyOf": [ { diff --git a/server/package-lock.json b/server/package-lock.json index 61dd35875..9255f9889 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "license": "ISC", "dependencies": { + "@csstools/css-tokenizer": "^3.0.4", "@grpc/grpc-js": "^1.14.3", "@grpc/proto-loader": "^0.8.0", "axios": "^1.15.2", diff --git a/server/package.json b/server/package.json index b893dc29b..f44dd7031 100755 --- a/server/package.json +++ b/server/package.json @@ -31,6 +31,7 @@ "node": ">=20" }, "dependencies": { + "@csstools/css-tokenizer": "^3.0.4", "@grpc/grpc-js": "^1.14.3", "@grpc/proto-loader": "^0.8.0", "axios": "^1.15.2", diff --git a/server/src/api/middleware/rateLimiter.ts b/server/src/api/middleware/rateLimiter.ts index 99655149d..839f9377d 100644 --- a/server/src/api/middleware/rateLimiter.ts +++ b/server/src/api/middleware/rateLimiter.ts @@ -1,12 +1,14 @@ import rateLimit from "express-rate-limit"; -export const generalApiLimiter = rateLimit({ - windowMs: 60 * 1000, - limit: 600, - standardHeaders: true, - legacyHeaders: false, - ipv6Subnet: 64, -}); +export const generalApiLimiter = (devMode: boolean) => + rateLimit({ + windowMs: 60 * 1000, + limit: 600, + standardHeaders: true, + legacyHeaders: false, + ipv6Subnet: 64, + skip: () => devMode, + }); export const authApiLimiter = rateLimit({ windowMs: 60 * 1000, diff --git a/server/src/api/middleware/statusPageDocumentCsp.ts b/server/src/api/middleware/statusPageDocumentCsp.ts new file mode 100644 index 000000000..c5175b81d --- /dev/null +++ b/server/src/api/middleware/statusPageDocumentCsp.ts @@ -0,0 +1,27 @@ +import type { NextFunction, Request, Response } from "express"; +import { normalizeStatusPageDomain } from "@/utils/statusPageDomain.js"; + +const PUBLIC_STATUS_PAGE_DOCUMENT_PREFIX = "/status/public"; + +// Browsers enforce the intersection of all CSP headers, so this only tightens +// the status page on top of the global helmet policy, blocking external images, +// fonts, and stylesheets from custom CSS while keeping the app's Google Fonts. +const STATUS_PAGE_CSP = [ + "img-src 'self' data:", + "font-src 'self' data: https://fonts.gstatic.com", + "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com", +].join("; "); + +// A status page document is served on the public path or on a custom domain +// (any host other than the app's own). Mirrors the client isCustomDomainHost. +export const createStatusPageDocumentCsp = (clientHost: string) => { + const appHostname = normalizeStatusPageDomain(clientHost); + + return (req: Request, res: Response, next: NextFunction) => { + const onCustomDomain = appHostname !== null && normalizeStatusPageDomain(req.hostname) !== appHostname; + if (req.path.startsWith(PUBLIC_STATUS_PAGE_DOCUMENT_PREFIX) || onCustomDomain) { + res.append("Content-Security-Policy", STATUS_PAGE_CSP); + } + return next(); + }; +}; diff --git a/server/src/api/routes/statusPageRoute.ts b/server/src/api/routes/statusPageRoute.ts index eab3aecfe..816c7212a 100644 --- a/server/src/api/routes/statusPageRoute.ts +++ b/server/src/api/routes/statusPageRoute.ts @@ -1,5 +1,6 @@ import { IStatusPageController } from "@/api/controllers/statusPageController.js"; import { RequestHandler, Router } from "express"; +import { isAllowed } from "@/api/middleware/isAllowed.js"; import { imageUpload } from "@/api/middleware/upload.js"; class StatusPageRoutes { @@ -15,12 +16,12 @@ class StatusPageRoutes { initRoutes(verifyJWT: RequestHandler, verifyStatusPageAccess: RequestHandler) { this.router.get("/team", verifyJWT, this.statusPageController.getStatusPagesByTeamId); - this.router.post("/", imageUpload.single("logo"), verifyJWT, this.statusPageController.createStatusPage); - this.router.put("/:id", imageUpload.single("logo"), verifyJWT, this.statusPageController.updateStatusPage); + this.router.post("/", imageUpload.single("logo"), verifyJWT, isAllowed(["admin", "superadmin"]), this.statusPageController.createStatusPage); + this.router.put("/:id", imageUpload.single("logo"), verifyJWT, isAllowed(["admin", "superadmin"]), this.statusPageController.updateStatusPage); this.router.get("/resolve", this.statusPageController.resolveStatusPageByDomain); this.router.get("/:url", verifyStatusPageAccess, this.statusPageController.getStatusPageByUrl); - this.router.delete("/:id", verifyJWT, this.statusPageController.deleteStatusPage); + this.router.delete("/:id", verifyJWT, isAllowed(["admin", "superadmin"]), this.statusPageController.deleteStatusPage); } getRouter() { diff --git a/server/src/api/validation/statusPageValidation.ts b/server/src/api/validation/statusPageValidation.ts index 2fa24be45..a650129cd 100644 --- a/server/src/api/validation/statusPageValidation.ts +++ b/server/src/api/validation/statusPageValidation.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { booleanCoercion, dnsHostnameRegex } from "./shared.js"; import { StatusPageTypes, StatusPageThemes, StatusPageThemeModes } from "@/domain/status-pages/status-page.type.js"; import { normalizeStatusPageDomain } from "@/utils/statusPageDomain.js"; +import { cssReferencesExternalResource } from "@/utils/customCss.js"; //**************************************** // Status Page Validations @@ -48,6 +49,13 @@ export const createStatusPageBodyValidation = z showUptimePercentage: booleanCoercion, showAdminLoginLink: booleanCoercion.optional(), showInfrastructure: booleanCoercion.optional(), + customCSS: z + .string() + .max(100000, "Custom CSS must be at most 100000 characters") + .refine((css) => !cssReferencesExternalResource(css), { + message: "Custom CSS cannot reference external URLs or use @import", + }) + .optional(), removeLogo: z.union([z.literal("true"), z.literal("false")]).optional(), theme: z.enum(StatusPageThemes).optional(), themeMode: z.enum(StatusPageThemeModes).optional(), diff --git a/server/src/app.ts b/server/src/app.ts index 94a122137..2ed1c2f01 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -13,6 +13,7 @@ import { InitializedControllers } from "@/config/controllers.js"; import { EnvConfig } from "@/domain/app-settings/app-settings.service.js"; import { createStatusPageCorsOrigin } from "@/api/middleware/statusPageCorsOrigin.js"; import { isPublicStatusPageApiPath } from "@/api/middleware/statusPagePublicApiPath.js"; +import { createStatusPageDocumentCsp } from "@/api/middleware/statusPageDocumentCsp.js"; import { ApiServices } from "@/config/services.api.js"; export const createApp = ({ @@ -38,7 +39,8 @@ export const createApp = ({ }; const publicStatusPageCorsOrigin = createStatusPageCorsOrigin(allowedOrigin, services.statusPagesRepository); - app.use(generalApiLimiter); + const devMode = envSettings.nodeEnv === "development"; + app.use(generalApiLimiter(devMode)); app.use((req, res, next) => { const corsOptions = isPublicStatusPageApiPath(req.method, req.path) @@ -48,6 +50,8 @@ export const createApp = ({ return cors(corsOptions)(req, res, next); }); + app.use(createStatusPageDocumentCsp(allowedOrigin)); + app.use(express.static(frontendPath)); app.use(express.json()); diff --git a/server/src/domain/status-pages/status-page.type.ts b/server/src/domain/status-pages/status-page.type.ts index db08b7398..254a411e1 100644 --- a/server/src/domain/status-pages/status-page.type.ts +++ b/server/src/domain/status-pages/status-page.type.ts @@ -1,7 +1,7 @@ export const StatusPageTypes = ["uptime", "infrastructure"] as const; export type StatusPageType = (typeof StatusPageTypes)[number]; -export const StatusPageThemes = ["refined", "modern", "bold", "editorial"] as const; +export const StatusPageThemes = ["refined", "modern", "bold", "editorial", "minimal"] as const; export type StatusPageTheme = (typeof StatusPageThemes)[number]; export const DEFAULT_STATUS_PAGE_THEME: StatusPageTheme = "refined"; diff --git a/server/src/utils/customCss.ts b/server/src/utils/customCss.ts new file mode 100644 index 000000000..3f38a0bd5 --- /dev/null +++ b/server/src/utils/customCss.ts @@ -0,0 +1,14 @@ +import { isTokenString, isTokenURL, tokenize } from "@csstools/css-tokenizer"; + +// Absolute http(s) or protocol-relative target; data: and relative URLs stay allowed. +const EXTERNAL_TARGET = /^\s*(?:https?:|\/\/)/i; + +// Tokenizing resolves comments, escapes, and line continuations so obfuscated +// targets can't hide. url-tokens cover url(); string-tokens cover @import and image-set(). +export const cssReferencesExternalResource = (css?: string | null): boolean => { + if (typeof css !== "string" || css === "") { + return false; + } + + return tokenize({ css }).some((token) => (isTokenURL(token) || isTokenString(token)) && EXTERNAL_TARGET.test(token[4].value)); +}; diff --git a/server/test/unit/middleware/statusPageDocumentCsp.test.ts b/server/test/unit/middleware/statusPageDocumentCsp.test.ts new file mode 100644 index 000000000..ab0c20abd --- /dev/null +++ b/server/test/unit/middleware/statusPageDocumentCsp.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it, jest } from "@jest/globals"; +import type { NextFunction, Request, Response } from "express"; +import { createStatusPageDocumentCsp } from "../../../src/api/middleware/statusPageDocumentCsp.ts"; + +const makeRes = (): Response => { + const res = {} as Response; + res.append = jest.fn().mockReturnValue(res) as unknown as Response["append"]; + return res; +}; + +const cspMiddleware = createStatusPageDocumentCsp("http://localhost:10001"); + +const run = (req: Partial) => { + const res = makeRes(); + const next = jest.fn() as unknown as NextFunction; + cspMiddleware(req as Request, res, next); + return { res, next }; +}; + +const appendedValue = (res: Response): string => ((res.append as jest.Mock).mock.calls[0] as [string, string])[1]; + +describe("statusPageDocumentCsp", () => { + it("appends a tightened CSP on the public status page path", () => { + const { res, next } = run({ path: "/status/public/my-status-page", hostname: "localhost" }); + + expect(res.append).toHaveBeenCalledTimes(1); + const value = appendedValue(res); + expect(value).toContain("img-src 'self' data:"); + expect(value).toContain("font-src 'self' data: https://fonts.gstatic.com"); + expect(value).toContain("style-src 'self' 'unsafe-inline' https://fonts.googleapis.com"); + expect(next).toHaveBeenCalledTimes(1); + }); + + it("does not restrict default-src or connect-src so the page can still reach the API", () => { + const { res } = run({ path: "/status/public/my-status-page", hostname: "localhost" }); + + const value = appendedValue(res); + expect(value).not.toContain("default-src"); + expect(value).not.toContain("connect-src"); + }); + + it("appends the CSP on a custom domain document at the host root", () => { + const { res, next } = run({ path: "/", hostname: "status.example.com" }); + + expect(res.append).toHaveBeenCalledTimes(1); + expect(appendedValue(res)).toContain("img-src 'self' data:"); + expect(next).toHaveBeenCalledTimes(1); + }); + + it("leaves the app's own non-public routes untouched", () => { + const { res, next } = run({ path: "/status/create", hostname: "localhost" }); + + expect(res.append).not.toHaveBeenCalled(); + expect(next).toHaveBeenCalledTimes(1); + }); +}); diff --git a/server/test/unit/utils/customCss.test.ts b/server/test/unit/utils/customCss.test.ts new file mode 100644 index 000000000..bbdac7118 --- /dev/null +++ b/server/test/unit/utils/customCss.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "@jest/globals"; +import { cssReferencesExternalResource } from "../../../src/utils/customCss.ts"; + +describe("cssReferencesExternalResource", () => { + it("returns false for empty or non-string input", () => { + expect(cssReferencesExternalResource("")).toBe(false); + expect(cssReferencesExternalResource(null)).toBe(false); + expect(cssReferencesExternalResource(undefined)).toBe(false); + }); + + it("allows benign CSS", () => { + expect(cssReferencesExternalResource("li{outline:3px dashed red}footer{display:none}")).toBe(false); + }); + + it("allows data: and relative urls", () => { + expect(cssReferencesExternalResource("li{background:url(data:image/png;base64,AAAA)}")).toBe(false); + expect(cssReferencesExternalResource("li{background:url(/assets/logo.png)}")).toBe(false); + expect(cssReferencesExternalResource("li{background:url(logo.png)}")).toBe(false); + }); + + it("allows data: SVG that embeds the SVG namespace URL", () => { + const css = `li{background:url("data:image/svg+xml,")}`; + expect(cssReferencesExternalResource(css)).toBe(false); + }); + + it("flags @import", () => { + expect(cssReferencesExternalResource('@import url("https://evil.example/x.css");')).toBe(true); + expect(cssReferencesExternalResource('@import "https://evil.example/x.css";')).toBe(true); + }); + + it("flags external url()", () => { + expect(cssReferencesExternalResource('body{background:url("https://evil.example/x")}')).toBe(true); + expect(cssReferencesExternalResource("body{background:url(http://evil.example/x)}")).toBe(true); + }); + + it("flags protocol-relative url()", () => { + expect(cssReferencesExternalResource("body{background:url(//evil.example/x)}")).toBe(true); + }); + + it("flags external string-form image functions", () => { + expect(cssReferencesExternalResource('body{background:image-set("https://evil.example/x" 1x)}')).toBe(true); + expect(cssReferencesExternalResource('body{background:-webkit-image-set("//evil.example/x" 1x)}')).toBe(true); + expect(cssReferencesExternalResource('body{background-image:image("https://evil.example/x")}')).toBe(true); + }); + + it("flags url() obfuscated with a trailing parse error", () => { + expect(cssReferencesExternalResource("body{background:url(http://evil.example/x) !!!}")).toBe(true); + }); + + it("flags references obfuscated with inline comments", () => { + expect(cssReferencesExternalResource('body{background:/*c*/url("http://evil.example/x")}')).toBe(true); + expect(cssReferencesExternalResource('@import/*c*/"http://evil.example/x";')).toBe(true); + expect(cssReferencesExternalResource('body{background:image-set(/*c*/"http://evil.example/x" 1x)}')).toBe(true); + }); + + it("allows a comment that makes url() a bad token the browser never fetches", () => { + expect(cssReferencesExternalResource('body{background:url(/*c*/ "http://evil.example/x")}')).toBe(false); + }); + + it("flags url() obfuscated with CSS escapes", () => { + expect(cssReferencesExternalResource("body{background:\\75 rl(http://evil.example/x)}")).toBe(true); + expect(cssReferencesExternalResource('\\40 import "http://evil.example/x";')).toBe(true); + }); + + it("flags a scheme split by a line continuation", () => { + expect(cssReferencesExternalResource('body{background:url("htt\\\nps://evil.example/x")}')).toBe(true); + expect(cssReferencesExternalResource('body{background:url("htt\\\r\nps://evil.example/x")}')).toBe(true); + }); + + it("flags a scheme split by a hex escape", () => { + expect(cssReferencesExternalResource('body{background:url("\\68 ttps://evil.example/x")}')).toBe(true); + }); +}); diff --git a/server/test/unit/validation/statusPageValidation.test.ts b/server/test/unit/validation/statusPageValidation.test.ts new file mode 100644 index 000000000..a2d659c34 --- /dev/null +++ b/server/test/unit/validation/statusPageValidation.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "@jest/globals"; +import { createStatusPageBodyValidation } from "../../../src/api/validation/statusPageValidation.ts"; + +const baseCreateBody = (overrides: Record = {}) => ({ + type: "uptime", + companyName: "Test Co", + url: "my-status-page", + monitors: ["0123456789abcdef01234567"], + isPublished: true, + showUptimePercentage: true, + ...overrides, +}); + +describe("createStatusPageBodyValidation", () => { + it("accepts a valid body without customCSS", () => { + const result = createStatusPageBodyValidation.safeParse(baseCreateBody()); + expect(result.success).toBe(true); + }); + + it("accepts and preserves customCSS", () => { + const customCSS = ".status-page > footer { display: none; }"; + const result = createStatusPageBodyValidation.safeParse(baseCreateBody({ customCSS })); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.customCSS).toBe(customCSS); + } + }); + + it("accepts an empty customCSS so it can be cleared", () => { + const result = createStatusPageBodyValidation.safeParse(baseCreateBody({ customCSS: "" })); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.customCSS).toBe(""); + } + }); + + it("rejects customCSS over the maximum length", () => { + const result = createStatusPageBodyValidation.safeParse(baseCreateBody({ customCSS: "a".repeat(100001) })); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues).toEqual( + expect.arrayContaining([expect.objectContaining({ message: "Custom CSS must be at most 100000 characters", path: ["customCSS"] })]) + ); + } + }); + + it("rejects a non-string customCSS", () => { + const result = createStatusPageBodyValidation.safeParse(baseCreateBody({ customCSS: 42 })); + expect(result.success).toBe(false); + }); + + it("rejects customCSS that references an external resource", () => { + const result = createStatusPageBodyValidation.safeParse(baseCreateBody({ customCSS: 'body{background:url("https://evil.example/beacon")}' })); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ message: "Custom CSS cannot reference external URLs or use @import", path: ["customCSS"] }), + ]) + ); + } + }); +});