Compare commits

..

10 commits

Author SHA1 Message Date
Daniel Noulet
f40485842d fixing style
Some checks failed
Build, test, and publish / Build and test (push) Has been cancelled
Build, test, and publish / Build and push Docker image (push) Has been cancelled
CI / test (push) Has been cancelled
2026-10-07 15:10:10 +02:00
Daniel Noulet
e454940dfa some extra make target 2026-10-03 12:25:10 +02:00
Daniel Noulet
dac02c61c3 deploy configuration 2026-10-03 04:41:31 +02:00
Daniel Noulet
9ba3c47d82 some extra make target 2026-10-01 16:11:41 +02:00
Daniel Noulet
57cf63cae5 some extra make target 2026-09-21 00:03:14 +02:00
Daniel Noulet
cd6f219275 some extra make target 2026-09-20 23:54:05 +02:00
Daniel Noulet
cd1613f3ca change layout of login 2026-09-20 17:20:34 +02:00
Daniel Noulet
6ac4efbf9e change layout of login 2026-09-20 15:41:01 +02:00
Daniel Noulet
6063b18052 change layout of login 2026-09-19 18:43:40 +02:00
Daniel Noulet
911beb6720 git actions 2026-09-19 18:22:25 +02:00
61 changed files with 18177 additions and 335 deletions

View file

@ -21,3 +21,14 @@ CMS_SSO_HEADER=X-SSO-Token
CMS_SSO_TOKEN= CMS_SSO_TOKEN=
# Optional cookie fallback when the proxy sets a cookie instead of a header. # Optional cookie fallback when the proxy sets a cookie instead of a header.
CMS_SSO_COOKIE= CMS_SSO_COOKIE=
# Go Loose browser login. Leave the credentials empty to keep the proxy identity.
# Register https://tell.dev/api/auth/callback as the redirect URI of the Go Loose
# application that owns these credentials.
GO_LOOSE_TENANT=tell
GO_LOOSE_AUTH_DOMAIN=auth.dev
GO_LOOSE_APP_DOMAIN=tell.dev
GO_LOOSE_CLIENT_ID=
GO_LOOSE_CLIENT_SECRET=
# Extra certificate authority for an internally signed identity domain.
GO_LOOSE_CA_FILE=

View file

@ -7,7 +7,7 @@ on:
workflow_dispatch: workflow_dispatch:
env: env:
IMAGE_NAME: batty1039.startdedicated.net:5000/cms-demo IMAGE_NAME: batty1039.startdedicated.net:5000/go-tell
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs: jobs:
@ -64,6 +64,18 @@ jobs:
- name: Check out source - name: Check out source
uses: actions/checkout@v5 uses: actions/checkout@v5
- name: Configure HTTP registry
run: |
sudo mkdir -p /etc/docker
echo '{"insecure-registries":["batty1039.startdedicated.net:5000"]}' | \
sudo tee /etc/docker/daemon.json >/dev/null
sudo systemctl restart docker
cat > /tmp/buildkitd.toml <<'EOF'
[registry."batty1039.startdedicated.net:5000"]
http = true
insecure = true
EOF
- name: Log in to private registry - name: Log in to private registry
uses: docker/login-action@v4 uses: docker/login-action@v4
with: with:
@ -73,6 +85,8 @@ jobs:
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4 uses: docker/setup-buildx-action@v4
with:
buildkitd-config: /tmp/buildkitd.toml
- name: Build and push image - name: Build and push image
uses: docker/build-push-action@v7 uses: docker/build-push-action@v7

47
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,47 @@
name: CI
on:
push:
pull_request:
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Check out source
uses: actions/checkout@v5
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
- name: Set up Node.js
uses: actions/setup-node@v5
with:
node-version: 22
cache: npm
cache-dependency-path: ui/app/package-lock.json
- name: Set up Helm
uses: azure/setup-helm@v4
with:
version: v3.18.6
- name: Install frontend dependencies
run: npm ci
working-directory: ui/app
- name: Build frontend
run: npm run build
working-directory: ui/app
- name: Run Go checks
run: make check
- name: Validate Helm environments
run: make helm-lint

169
.github/workflows/deploy-rancher.yml vendored Normal file
View file

@ -0,0 +1,169 @@
name: Deploy to Rancher
on:
workflow_run:
workflows:
- Build, test, and publish
types:
- completed
workflow_dispatch:
inputs:
environment:
description: Deployment environment
required: true
default: development
type: choice
options:
- development
- production
image_tag:
description: Image tag (defaults to latest)
required: false
default: latest
type: string
permissions:
contents: read
concurrency:
group: go-tell-${{ github.event_name == 'workflow_dispatch' && inputs.environment || 'production' }}
cancel-in-progress: false
jobs:
deploy:
if: >-
github.event_name == 'workflow_dispatch' ||
(github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.head_branch == 'main')
name: Deploy ${{ github.event_name == 'workflow_dispatch' && inputs.environment || 'production' }}
runs-on: ubuntu-latest
environment: ${{ github.event_name == 'workflow_dispatch' && inputs.environment || 'production' }}
env:
TARGET_ENVIRONMENT: ${{ github.event_name == 'workflow_dispatch' && inputs.environment || 'production' }}
KUBE_CONFIG_BASE64: ${{ secrets.RANCHER_KUBE_CONFIG_BASE64 }}
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
CMS_SSO_TOKEN: ${{ secrets.CMS_SSO_TOKEN }}
CMS_ALLOWED_ORIGINS: ${{ vars.CMS_ALLOWED_ORIGINS }}
CMS_SSO_HEADER: ${{ vars.CMS_SSO_HEADER }}
CMS_SSO_COOKIE: ${{ vars.CMS_SSO_COOKIE }}
GO_LOOSE_CLIENT_ID: ${{ secrets.GO_LOOSE_CLIENT_ID }}
GO_LOOSE_CLIENT_SECRET: ${{ secrets.GO_LOOSE_CLIENT_SECRET }}
LOCAL_CA_CERT: ${{ secrets.LOCAL_CA_CERT }}
RANCHER_NAMESPACE: ${{ vars.RANCHER_NAMESPACE }}
steps:
- name: Check out source
uses: actions/checkout@v5
with:
ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.sha }}
- name: Set up Helm
uses: azure/setup-helm@v4
with:
version: v3.18.6
- name: Set up kubectl
uses: azure/setup-kubectl@v4
with:
version: v1.34.1
- name: Resolve deployment settings
env:
REQUESTED_TAG: ${{ inputs.image_tag }}
PUBLISHED_SHA: ${{ github.event.workflow_run.head_sha }}
run: |
set -eu
if [ -z "$KUBE_CONFIG_BASE64" ]; then
echo "RANCHER_KUBE_CONFIG_BASE64 is required" >&2
exit 1
fi
if [ -z "$REGISTRY_USERNAME" ] || [ -z "$REGISTRY_PASSWORD" ]; then
echo "REGISTRY_USERNAME and REGISTRY_PASSWORD are required" >&2
exit 1
fi
if [ -z "$GO_LOOSE_CLIENT_ID" ] || [ -z "$GO_LOOSE_CLIENT_SECRET" ]; then
echo "GO_LOOSE_CLIENT_ID and GO_LOOSE_CLIENT_SECRET are required: they come from the Go Loose console" >&2
exit 1
fi
namespace="${RANCHER_NAMESPACE:-go-tell-$TARGET_ENVIRONMENT}"
if [ -n "$PUBLISHED_SHA" ]; then
image_tag="$PUBLISHED_SHA"
else
image_tag="${REQUESTED_TAG:-latest}"
fi
allowed_origins="$CMS_ALLOWED_ORIGINS"
if [ -z "$allowed_origins" ]; then
if [ "$TARGET_ENVIRONMENT" = "production" ]; then
allowed_origins="https://tell.dev"
else
allowed_origins="http://localhost:30010"
fi
fi
{
echo "DEPLOY_NAMESPACE=$namespace"
echo "IMAGE_TAG=$image_tag"
echo "RESOLVED_ALLOWED_ORIGINS=$allowed_origins"
} >> "$GITHUB_ENV"
- name: Configure Rancher cluster access
run: |
set -eu
mkdir -p "$HOME/.kube"
printf '%s' "$KUBE_CONFIG_BASE64" | base64 --decode > "$HOME/.kube/config"
chmod 600 "$HOME/.kube/config"
kubectl cluster-info
- name: Configure registry credentials
run: |
set -eu
kubectl create namespace "$DEPLOY_NAMESPACE" \
--dry-run=client -o yaml | kubectl apply -f -
kubectl -n "$DEPLOY_NAMESPACE" create secret docker-registry go-tell-registry \
--docker-server=batty1039.startdedicated.net:5000 \
--docker-username="$REGISTRY_USERNAME" \
--docker-password="$REGISTRY_PASSWORD" \
--dry-run=client -o yaml | kubectl apply -f -
- name: Publish the internal certificate authority
if: ${{ env.LOCAL_CA_CERT != '' }}
run: |
set -eu
printf '%s' "$LOCAL_CA_CERT" | base64 --decode > "$RUNNER_TEMP/local-ca.crt"
kubectl -n "$DEPLOY_NAMESPACE" create secret generic local-ca \
--from-file=ca.crt="$RUNNER_TEMP/local-ca.crt" \
--dry-run=client -o yaml | kubectl apply -f -
- name: Deploy Helm release
run: |
set -eu
values_file="deploy/helm/go-tell/values-$TARGET_ENVIRONMENT.yaml"
set -- \
--set-string "image.tag=$IMAGE_TAG" \
--set "imagePullSecrets[0].name=go-tell-registry" \
--set-string "app.allowedOrigins=$RESOLVED_ALLOWED_ORIGINS" \
--set-string "secrets.ssoToken=$CMS_SSO_TOKEN" \
--set-string "goLoose.clientID=$GO_LOOSE_CLIENT_ID" \
--set-string "goLoose.clientSecret=$GO_LOOSE_CLIENT_SECRET"
if [ -n "$LOCAL_CA_CERT" ]; then
set -- "$@" --set-string goLoose.ca.existingSecret=local-ca
fi
if [ -n "$CMS_SSO_HEADER" ]; then
set -- "$@" --set-string "app.ssoHeader=$CMS_SSO_HEADER"
fi
if [ -n "$CMS_SSO_COOKIE" ]; then
set -- "$@" --set-string "app.ssoCookie=$CMS_SSO_COOKIE"
fi
helm upgrade --install go-tell deploy/helm/go-tell \
--namespace "$DEPLOY_NAMESPACE" \
--values "$values_file" \
--atomic \
--wait \
--timeout 10m \
"$@"
- name: Verify deployment
run: |
kubectl -n "$DEPLOY_NAMESPACE" rollout status deployment/go-tell \
--timeout=5m
helm test go-tell --namespace "$DEPLOY_NAMESPACE" --logs

1
.gitignore vendored
View file

@ -12,6 +12,7 @@ cmd/server/web/dist/*
.env .env
data/ data/
*.json.tmp *.json.tmp
deploy/local.yaml
# Node # Node
node_modules/ node_modules/

View file

@ -9,18 +9,19 @@ RUN npm run build -- --outDir /src/ui/dist --emptyOutDir
# Compile the Go binary with the built frontend embedded. # Compile the Go binary with the built frontend embedded.
FROM golang:1.27-alpine AS backend FROM golang:1.27-alpine AS backend
WORKDIR /src WORKDIR /src
COPY go.mod ./ COPY go.mod go.sum ./
RUN go mod download
COPY cmd/ ./cmd/ COPY cmd/ ./cmd/
COPY internal/ ./internal/ COPY internal/ ./internal/
COPY --from=ui /src/ui/dist/ ./cmd/server/web/dist/ COPY --from=ui /src/ui/dist/ ./cmd/server/web/dist/
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/cms ./cmd/server RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/go-tell ./cmd/server
# Runtime: one static binary, no database, no shell tooling. # Runtime: one static binary, no database, no shell tooling.
FROM alpine:3.20 FROM alpine:3.20
RUN apk add --no-cache ca-certificates tzdata \ RUN apk add --no-cache ca-certificates tzdata \
&& adduser -D -u 10001 cms && adduser -D -u 10001 go-tell
COPY --from=backend /out/cms /usr/local/bin/cms COPY --from=backend /out/go-tell /usr/local/bin/go-tell
USER cms USER go-tell
EXPOSE 8080 EXPOSE 8080
ENV CMS_HTTP_ADDR=:8080 ENV CMS_HTTP_ADDR=:8080
# Mount a volume here and set CMS_CONTENT_FILE=/data/content.json to keep edits # Mount a volume here and set CMS_CONTENT_FILE=/data/content.json to keep edits
@ -28,4 +29,4 @@ ENV CMS_HTTP_ADDR=:8080
VOLUME ["/data"] VOLUME ["/data"]
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s \ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s \
CMD wget -qO- http://127.0.0.1:8080/health || exit 1 CMD wget -qO- http://127.0.0.1:8080/health || exit 1
ENTRYPOINT ["/usr/local/bin/cms"] ENTRYPOINT ["/usr/local/bin/go-tell"]

View file

@ -1,12 +1,31 @@
SHELL := /bin/sh SHELL := /bin/sh
IMAGE ?= cms-demo IMAGE ?= go-tell
TAG ?= latest TAG ?= latest
REGISTRY_IMAGE ?= batty1039.startdedicated.net:5000/go-tell
HELM ?= helm
HELM_CHART := deploy/helm/go-tell
HELM_RELEASE ?= go-tell
HELM_NAMESPACE ?= go-tell
# The development and production releases share one cluster, so both point at the
# same kubeconfig and only the namespace and the values profile differ.
KUBECONFIG ?= $(HOME)/.kube/config
# Secret holding the platform CA bundle the pod needs to reach auth.dev. Create it
# per namespace with kubectl create secret generic local-ca --from-file=ca.crt=...
GO_LOOSE_CA_SECRET ?= local-ca
.PHONY: help ui backend demo production run test check docker docker-run clean # Helm receives the Go Loose credentials from the environment instead of a values
# file. make sources the gitignored .env first, so a configured checkout only has
# to run the target.
GO_LOOSE_SET = $(if $(GO_LOOSE_CLIENT_ID),--set-string goLoose.clientID=$(GO_LOOSE_CLIENT_ID)) \
$(if $(GO_LOOSE_CLIENT_SECRET),--set-string goLoose.clientSecret=$(GO_LOOSE_CLIENT_SECRET)) \
$(if $(GO_LOOSE_CA_SECRET),--set-string goLoose.ca.existingSecret=$(GO_LOOSE_CA_SECRET))
.PHONY: help ui frontend backend demo production run test check docker docker-push docker-run clean ingress-nginx-install cert-manager-install cluster-prepare helm-lint helm-deploy-development helm-deploy-production helm-down-development helm-down-production helm-proxy-development helm-proxy-production
help: help:
@echo "make ui build the React app into cmd/server/web/dist" @echo "make ui build the React app into cmd/server/web/dist"
@echo "make frontend run the Vite UI with API changes proxied to :8080"
@echo "make backend run the Go server on :8080 (serves the last UI build)" @echo "make backend run the Go server on :8080 (serves the last UI build)"
@echo "make demo run the in-memory demo profile" @echo "make demo run the in-memory demo profile"
@echo "make production run the SSO production profile" @echo "make production run the SSO production profile"
@ -14,8 +33,19 @@ help:
@echo "make test run the Go test suite" @echo "make test run the Go test suite"
@echo "make check gofmt, vet, and tests" @echo "make check gofmt, vet, and tests"
@echo "make docker build the all-in-one image" @echo "make docker build the all-in-one image"
@echo "make docker-push build and push the image to the private registry"
@echo "make docker-run run the image on :8080" @echo "make docker-run run the image on :8080"
@echo "make clean remove build output" @echo "make clean remove build output"
@echo "make ingress-nginx-install install the ingress controller in the cluster"
@echo "make cert-manager-install install cert-manager in the cluster"
@echo "make cluster-prepare prepare the cluster for both releases"
@echo "make helm-lint linting the helm charts"
@echo "make helm-deploy-development deploy to development"
@echo "make helm-deploy-production deploy to production"
@echo "make helm-down-development development down"
@echo "make helm-down-production production down"
@echo "make helm-proxy-development proxy development"
@echo "make helm-proxy-production proxy production"
# The Go server embeds the compiled frontend, so the UI is built first and copied # The Go server embeds the compiled frontend, so the UI is built first and copied
# into the embed directory that cmd/server/main.go points at. # into the embed directory that cmd/server/main.go points at.
@ -28,6 +58,12 @@ ui:
mkdir -p cmd/server/web/dist mkdir -p cmd/server/web/dist
cp -R ui/dist/. cmd/server/web/dist/ cp -R ui/dist/. cmd/server/web/dist/
frontend:
@if [ ! -d ui/app/node_modules ]; then \
npm --prefix ui/app ci; \
fi
npm --prefix ui/app run dev -- --host 0.0.0.0
backend: backend:
set -a; [ ! -f .env ] || . ./.env; set +a; \ set -a; [ ! -f .env ] || . ./.env; set +a; \
go run ./cmd/server go run ./cmd/server
@ -49,7 +85,11 @@ check:
go test ./... go test ./...
docker: docker:
docker build -t $(IMAGE):$(TAG) . docker compose build
docker-push: docker
docker tag $(IMAGE):$(TAG) $(REGISTRY_IMAGE):$(TAG)
docker push $(REGISTRY_IMAGE):$(TAG)
docker-run: docker-run:
docker run --rm -p 8080:8080 --env-file .env $(IMAGE):$(TAG) docker run --rm -p 8080:8080 --env-file .env $(IMAGE):$(TAG)
@ -58,3 +98,54 @@ clean:
rm -rf cmd/server/web/dist ui/dist rm -rf cmd/server/web/dist ui/dist
mkdir -p cmd/server/web/dist mkdir -p cmd/server/web/dist
printf 'Placeholder so the Go embed directive resolves before the frontend is built.\nRun `make ui` to replace this directory with the real React build.\n' > cmd/server/web/dist/placeholder.txt printf 'Placeholder so the Go embed directive resolves before the frontend is built.\nRun `make ui` to replace this directory with the real React build.\n' > cmd/server/web/dist/placeholder.txt
ingress-nginx-install:
kubectl --kubeconfig "$(KUBECONFIG)" apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/controller-v1.14.1/deploy/static/provider/kind/deploy.yaml
kubectl --kubeconfig "$(KUBECONFIG)" -n ingress-nginx rollout status deployment/ingress-nginx-controller --timeout=5m
cert-manager-install:
kubectl --kubeconfig "$(KUBECONFIG)" apply --validate=false -f https://github.com/cert-manager/cert-manager/releases/download/v1.19.1/cert-manager.yaml
kubectl --kubeconfig "$(KUBECONFIG)" -n cert-manager rollout status deployment/cert-manager-webhook --timeout=5m
kubectl --kubeconfig "$(KUBECONFIG)" -n cert-manager rollout status deployment/cert-manager-cainjector --timeout=5m
kubectl --kubeconfig "$(KUBECONFIG)" -n cert-manager rollout status deployment/cert-manager --timeout=5m
cluster-prepare: ingress-nginx-install cert-manager-install
helm-lint:
$(HELM) lint $(HELM_CHART) -f $(HELM_CHART)/values-development.yaml
$(HELM) lint $(HELM_CHART) -f $(HELM_CHART)/values-production.yaml
$(HELM) template $(HELM_RELEASE) $(HELM_CHART) \
-f $(HELM_CHART)/values-development.yaml >/dev/null
$(HELM) template $(HELM_RELEASE) $(HELM_CHART) \
-f $(HELM_CHART)/values-production.yaml \
--set-string goLoose.clientID=lint --set-string goLoose.clientSecret=lint >/dev/null
# Go Loose credentials are never stored in the values files. Pass them on the
# command line, from a secret store, or with --set-file:
# make helm-deploy-production \
# --set goLoose.clientID=$(GO_LOOSE_CLIENT_ID) \
# --set goLoose.clientSecret=$(GO_LOOSE_CLIENT_SECRET) \
# --set goLoose.ca.existingSecret=local-ca
helm-deploy-development:
KUBECONFIG="$(KUBECONFIG)" $(HELM) upgrade --install $(HELM_RELEASE) $(HELM_CHART) \
--namespace $(HELM_NAMESPACE)-development --create-namespace \
-f $(HELM_CHART)/values-development.yaml \
--rollback-on-failure --wait --timeout 10m
helm-deploy-production:
set -a; [ ! -f .env ] || . ./.env; set +a; \
KUBECONFIG="$(KUBECONFIG)" $(HELM) upgrade --install $(HELM_RELEASE) $(HELM_CHART) \
--namespace $(HELM_NAMESPACE)-production --create-namespace \
-f $(HELM_CHART)/values-production.yaml \
$(GO_LOOSE_SET) \
--rollback-on-failure --wait --timeout 10m
helm-down-production:
KUBECONFIG="$(KUBECONFIG)" $(HELM) uninstall $(HELM_RELEASE) -n $(HELM_NAMESPACE)-production
helm-down-development:
KUBECONFIG="$(KUBECONFIG)" $(HELM) uninstall $(HELM_RELEASE) -n $(HELM_NAMESPACE)-development
helm-proxy-production:
kubectl --kubeconfig "$(KUBECONFIG)" port-forward -n $(HELM_NAMESPACE)-production service/$(HELM_RELEASE) 31373:80 --address 0.0.0.0
helm-proxy-development:
kubectl --kubeconfig "$(KUBECONFIG)" port-forward -n $(HELM_NAMESPACE)-development service/$(HELM_RELEASE) 30010:80 --address 0.0.0.0

153
README.md
View file

@ -1,41 +1,158 @@
# CMS project # Go Tell
This repository contains a Go backend and React frontend for the CMS. Go Tell is a small content CMS: one Go binary that serves the API and embeds
the React frontend, backed by a JSON file. It is served from a single host,
`https://tell.dev`, and it uses [Go Loose](https://github.com/danyel/go-loose)
to decide who is allowed to edit.
## Backend ## Backend
The backend targets Go 1.27.1 and uses a layered design (`internal/web`, `service`, `repository`, `persistence`, `security`, and `domain`). A single PostgreSQL database stores content; Goose migrations run automatically at startup. The backend targets Go 1.27.1 and uses a layered design (`internal/web`,
`internal/service`, `internal/security`, `internal/golooseauth`). Content is
seeded in memory and can be mirrored to a JSON file configured through
`CMS_CONTENT_FILE`.
```sh ```sh
go run ./cmd/server go run ./cmd/server
``` ```
Configuration is read from environment variables (see `.env`). The API includes `GET /health`, `GET /api/auth/session`, `GET /api/protected`, `GET /api/content`, `GET /api/content/categories`, `GET /api/content/badges`, and `GET|PUT /api/content/:slug`. Configuration is read from environment variables (see `.env.example`). The API
exposes `GET /health`, `GET /api/auth/session`, `GET /api/protected`,
`GET /api/content`, `GET /api/content/categories`, `GET /api/content/badges`,
and `GET|PUT /api/content/:slug`.
## Single sign-on | Variable | Default | Purpose |
| --- | --- | --- |
| `CMS_HTTP_ADDR` | `:8080` | Listen address |
| `CMS_PROFILE` | `demo` | `demo` enables the in-memory seed and the sudo terminal, anything else requires an authenticated editor |
| `CMS_CONTENT_FILE` | empty | Path of the JSON file that stores edits; empty keeps everything in memory |
| `CMS_POSTGRES_URL` | empty | Reserved for the PostgreSQL persistence adapter |
| `CMS_ALLOWED_ORIGINS` | `http://localhost:5173` | `Access-Control-Allow-Origin` value |
| `CMS_SSO_HEADER` | `X-SSO-Token` | Identity header set by the proxy; empty disables proxy identity |
| `CMS_SSO_TOKEN` | empty | Shared secret the proxy value must match |
| `CMS_SSO_COOKIE` | empty | Cookie carrying the same value when the proxy sets no header |
| `GO_LOOSE_TENANT` | `tell` | Go Loose tenant that owns the login |
| `GO_LOOSE_AUTH_DOMAIN` | `auth.dev` | Identity domain shared by all Go Loose tenants |
| `GO_LOOSE_APP_DOMAIN` | `tell.dev` | Public host of the CMS, which also determines the redirect URI |
| `GO_LOOSE_CLIENT_ID` | empty | Client id of the Go Loose application |
| `GO_LOOSE_CLIENT_SECRET` | empty | Client secret of the Go Loose application |
| `GO_LOOSE_CA_FILE` | empty | Extra CA bundle for an internally signed identity domain |
The CMS never signs anyone in and stores no users, admins, or sessions. An identity-aware proxy (oauth2-proxy, Authelia, Cloudflare Access, ...) authenticates the owner and forwards a header on every accepted request: ## Authentication
- **Header present** → the request is the owner and can edit content (`canEdit: true`). Go Tell accepts an editor from three sources, in this order: the demo sudo
- **Header absent** → the request is anonymous and content is read-only (`canEdit: false`). token, the identity-aware proxy header, and the Go Loose browser session.
Reading content is always anonymous; `PUT /api/content/:slug` answers `401`
without one of them.
Configure the header with `CMS_SSO_HEADER` (default `X-SSO-Token`). Set `CMS_SSO_TOKEN` to a shared secret to require an exact value; leave it empty to trust any non-empty forwarded value. `CMS_SSO_COOKIE` optionally names a cookie fallback. ### Go Loose
Because the backend trusts the forwarded header, it must only be reachable through the proxy. Bind it to the loopback interface or a private network, and make sure the proxy strips any client-supplied copy of that header. Set `GO_LOOSE_CLIENT_ID` and `GO_LOOSE_CLIENT_SECRET` and the CMS grows a
browser login:
Anonymous visitors get a **Sign in with Google** button in the header. It redirects the browser to `VITE_SSO_LOGIN_URL` (frontend setting), where the provider authenticates the owner and returns to the page; the CMS itself never performs an OAuth exchange and holds no client secret. Leave the setting empty to hide the button when the proxy gates the whole app automatically. 1. `GET /api/auth/login` redirects to
`https://<GO_LOOSE_TENANT>.<GO_LOOSE_AUTH_DOMAIN>/connect/authorize`.
2. Go Loose authenticates the user and redirects to
`https://<GO_LOOSE_APP_DOMAIN>/api/auth/callback`, which exchanges the
authorization code and stores the session in an HTTP-only cookie.
3. `GET /api/auth/session` then reports `authenticated: true`, the editor name,
and the login and logout endpoints the frontend follows.
4. `GET|POST /api/auth/logout` revokes the session at the identity provider and
clears the cookie.
That redirect URI has to be registered on the Go Loose application, next to the
client id and secret. Without credentials the login routes answer `404` with
the variables to set, and the CMS falls back to the proxy identity.
The local platform application lives in the Go Loose tenant `tell`, so the login
origin is `https://tell.auth.dev` and the interview accounts are
`interview@tell.auth.dev` and `reviewer@tell.auth.dev`. The one-time client
secret is recorded in the platform's gitignored `secrets/credentials.md` and in
the gitignored `.env` of this repository.
### Proxy identity
Without Go Loose, an identity-aware proxy (oauth2-proxy, Authelia, Cloudflare
Access, ...) can authenticate the owner and forward a header on every accepted
request: the header present means the request can edit, the header absent means
read-only. Because the backend trusts that header, it must only be reachable
through the proxy, and the proxy must strip any client-supplied copy of it.
## Content ## Content
Content supports one reusable category per document plus any number of badges. The anonymous `GET /api/content` endpoint accepts `category=engineering` and repeated `badge=go&badge=docker` filters; selected badges are combined with AND semantics. `GET /api/content/categories` returns the category dropdown values. The frontend applies these filters immediately when the category changes or when a badge is entered. Content supports one reusable category per document plus any number of badges.
The anonymous `GET /api/content` endpoint accepts `category=engineering` and
Editing requires the SSO identity: `PUT /api/content/:slug` returns `401` without it. `GET /api/content/:slug` and the taxonomy endpoints are public. repeated `badge=go&badge=docker` filters; selected badges are combined with AND
semantics. `GET /api/content/categories` returns the category dropdown values.
The frontend applies these filters immediately when the category changes or when
a badge is entered.
```sh ```sh
make up # start PostgreSQL
make migrate # apply the PostgreSQL migrations
make seed # load repeatable sample content
make backend # run the Go server make backend # run the Go server
make frontend # run the React app make frontend # run the React app
make dev # up + migrate + backend + frontend make run # build the frontend, then run the server
make check # gofmt, vet, and tests
make docker # build the all-in-one image
``` ```
## Kubernetes deployment
The Helm chart at `deploy/helm/go-tell` deploys the single image to the k3s
cluster behind `ingress-nginx` and `cert-manager`, both of which have to be
present first:
```sh
make cluster-prepare # install ingress-nginx and cert-manager
make helm-lint # lint and render both profiles
make helm-deploy-production # namespace go-tell-production
make helm-deploy-development # namespace go-tell-development
```
Go Loose credentials are never written into a values file. The production target
reads `GO_LOOSE_CLIENT_ID`, `GO_LOOSE_CLIENT_SECRET`, and `GO_LOOSE_CA_SECRET`
from the environment, after sourcing the gitignored `.env`:
```sh
make helm-deploy-production
```
They end up in the release secret and in the `--set-string` flags the target
builds. Override any of them on the command line when deploying from elsewhere:
```sh
make helm-deploy-production \
--set-string goLoose.clientID="$GO_LOOSE_CLIENT_ID" \
--set-string goLoose.clientSecret="$GO_LOOSE_CLIENT_SECRET" \
--set-string goLoose.ca.existingSecret=local-ca
```
`goLoose.ca.existingSecret` names the Secret holding the platform CA bundle, so
the pod trusts `tell.auth.dev`. It has to exist in the namespace before the
release starts:
```sh
kubectl -n go-tell-production create secret generic local-ca \
--from-file=ca.crt=~/sources/go/infrastructure/certs/local-ca.crt
```
`go-tell-production` is the profile that matters: one host, TLS from
cert-manager, and a persistent volume for the content file. `go-tell-development`
runs the demo profile on NodePort `30010` with ephemeral content and no Go
Loose login, which is what `make frontend` talks to.
The earlier dedicated-host deployment (`cms.urpi.be` through host Nginx on a
Rancher cluster) is retired: `deploy/nginx` and `deploy/rancher` are gone, and
every Makefile target talks to `~/.kube/config`.
## Continuous delivery
`build-test-push.yml` publishes `batty1039.startdedicated.net:5000/go-tell` on
every push to `main`. `deploy-rancher.yml` deploys a successful build to
production and can also be dispatched by hand for either profile. Create the
GitHub environments `development` and `production` with the secrets
`RANCHER_KUBE_CONFIG_BASE64`, `REGISTRY_USERNAME`, `REGISTRY_PASSWORD`,
`GO_LOOSE_CLIENT_ID`, `GO_LOOSE_CLIENT_SECRET`, and optionally
`CMS_SSO_TOKEN` and `LOCAL_CA_CERT` (the internal CA bundle mounted through
`goLoose.ca.existingSecret`). The variables `RANCHER_NAMESPACE`,
`CMS_ALLOWED_ORIGINS`, `CMS_SSO_HEADER`, and `CMS_SSO_COOKIE` override the
chart defaults.

13553
cert-manager.yaml Normal file

File diff suppressed because it is too large Load diff

View file

@ -7,10 +7,11 @@ import (
"net/http" "net/http"
"time" "time"
"github.com/example/cms/internal/config" "github.com/danyel/go-tell/internal/config"
"github.com/example/cms/internal/security" "github.com/danyel/go-tell/internal/golooseauth"
"github.com/example/cms/internal/service" "github.com/danyel/go-tell/internal/security"
"github.com/example/cms/internal/web" "github.com/danyel/go-tell/internal/service"
"github.com/danyel/go-tell/internal/web"
) )
// web/dist holds the compiled React app. A placeholder file is committed so the // web/dist holds the compiled React app. A placeholder file is committed so the
@ -33,6 +34,10 @@ func main() {
} }
sso := security.NewSSOTokenVerifier(cfg.SSOHeader, cfg.SSOToken, cfg.SSOCookie) sso := security.NewSSOTokenVerifier(cfg.SSOHeader, cfg.SSOToken, cfg.SSOCookie)
browser, err := golooseauth.New(cfg.GoLoose)
if err != nil {
log.Fatal(err)
}
var demo *service.DemoAuth var demo *service.DemoAuth
if cfg.Profile == "demo" { if cfg.Profile == "demo" {
demo = service.NewDemoAuth() demo = service.NewDemoAuth()
@ -40,9 +45,14 @@ func main() {
} else { } else {
log.Printf("CMS profile: %s (SSO identity required; PostgreSQL URL configured: %t)", cfg.Profile, cfg.PostgresURL != "") log.Printf("CMS profile: %s (SSO identity required; PostgreSQL URL configured: %t)", cfg.Profile, cfg.PostgresURL != "")
} }
if browser != nil {
log.Printf("Go Loose login enabled for tenant %q", cfg.GoLoose.Tenant)
} else {
log.Print("Go Loose login disabled: set GO_LOOSE_CLIENT_ID and GO_LOOSE_CLIENT_SECRET to enable it")
}
server := &http.Server{ server := &http.Server{
Addr: cfg.HTTPAddr, Addr: cfg.HTTPAddr,
Handler: web.NewHandler(content, assets, sso, demo).Routes(cfg.AllowedOrigins), Handler: web.NewHandler(content, assets, sso, browser, demo).WithThemeOrigin(cfg.ThemeBaseURL).Routes(cfg.AllowedOrigins),
ReadHeaderTimeout: 10 * time.Second, ReadHeaderTimeout: 10 * time.Second,
} }
log.Printf("CMS listening on %s", cfg.HTTPAddr) log.Printf("CMS listening on %s", cfg.HTTPAddr)

View file

@ -1 +0,0 @@
Frontend assets are generated by `make ui` or the CI workflow before building.

View file

@ -0,0 +1,88 @@
affinity: {}
app:
allowedOrigins: https://tell-dev.urpi.be
# The styling contract and the palette controls come from go-bananas in the same
# cluster. This is the value the chart had no way to express before, which is why
# the running deployment served its own built-in fallbacks.
themeBaseURL: https://bananas-dev.urpi.be
contentFile: ""
postgresURL: ""
profile: demo
ssoCookie: ""
ssoHeader: X-SSO-Token
certManager:
caSecretName: ""
duration: 8760h
enabled: true
issuerRef:
kind: ClusterIssuer
name: letsencrypt-staging
renewBefore: 720h
fullnameOverride: ""
goLoose:
appDomain: tell-dev.urpi.be
authDomain: auth-dev.urpi.be
ca:
existingSecret: ""
key: ca.crt
mountPath: /certs/local-ca.crt
clientID: ""
clientSecret: ""
enabled: false
tenant: tell
host: tell-dev.urpi.be
image:
pullPolicy: Always
repository: batty1039.startdedicated.net:5000/go-tell
tag: latest
imagePullSecrets: []
ingress:
annotations:
cert-manager.io/cluster-issuer: letsencrypt-staging
nginx.ingress.kubernetes.io/ssl-redirect: "true"
className: nginx
enabled: true
path: /
pathType: Prefix
tls:
enabled: true
existingSecret: ""
secretName: ""
nameOverride: ""
nodeSelector: {}
persistence:
accessModes:
- ReadWriteOnce
enabled: true
size: 2Gi
storageClass: ""
podAnnotations: {}
podLabels: {}
podSecurityContext:
fsGroup: 10001
fsGroupChangePolicy: OnRootMismatch
replicaCount: 1
resources:
limits:
cpu: 300m
memory: 384Mi
requests:
cpu: 50m
memory: 96Mi
secrets:
ssoToken: ""
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
readOnlyRootFilesystem: true
runAsNonRoot: true
runAsUser: 10001
service:
nodePort: null
port: 80
type: ClusterIP
strategy:
type: Recreate
tolerations: []

View file

@ -0,0 +1,5 @@
.git/
.github/
.idea/
.vscode/
*.tgz

View file

@ -0,0 +1,6 @@
apiVersion: v2
name: go-tell
description: Go Tell, a single-host content CMS behind Go Loose
type: application
version: 0.2.0
appVersion: "0.1.0"

View file

@ -0,0 +1,221 @@
# Go Tell Helm chart
This chart deploys Go Tell as one image: a Go binary that serves the API and
embeds the React frontend, with a persistent volume for the content file. It
publishes a single HTTPS host, `https://tell.dev`, behind ingress-nginx with a
certificate from cert-manager.
## 1. What the chart creates
| Resource | Name | Purpose |
|---|---|---|
| Deployment | `go-tell` | CMS binary, API and web UI in one container |
| Service | `go-tell` | Cluster-internal service, ingress backend |
| Ingress | `go-tell` | One rule and one TLS SAN for `tell.dev` |
| Issuer | `go-tell-selfsigned` | cert-manager self-signed issuer |
| Certificate | `go-tell-ca` | Chart CA that signs the leaf certificate |
| Certificate | `go-tell` | Leaf certificate for `tell.dev` |
| PersistentVolumeClaim | `go-tell` | Content file storage, `emptyDir` when disabled |
| Secret | `go-tell-secrets` | Go Loose client credentials and the proxy token |
There are no tenants and no per-tenant hosts: one release, one host, one
content file.
## 2. Prerequisites
- A Kubernetes cluster reachable through `KUBECONFIG`, by default
`~/.kube/config`.
- A `StorageClass` when `persistence.enabled` is `true`.
- ingress-nginx with the `nginx` ingress class: `make ingress-nginx-install`.
- cert-manager when `ingress.tls.enabled` is `true`: `make cert-manager-install`.
- Cluster nodes trusting `batty1039.startdedicated.net:5000` as an insecure
registry, or an HTTPS registry.
Run everything the chart depends on in one step:
```bash
make cluster-prepare
```
## 3. Deploy
```bash
make helm-lint
make helm-deploy-development
```
The development profile runs the demo profile on NodePort `30010` with
ephemeral content and Go Loose disabled, which is what `make frontend` talks to.
Production runs the persisted content file with browser sign-in:
```bash
export GO_LOOSE_CLIENT_ID=...
export GO_LOOSE_CLIENT_SECRET=...
make helm-deploy-production
```
Credentials are never written into a values file. They reach the release
secret through `--set`, or through the `GO_LOOSE_CLIENT_ID` and
`GO_LOOSE_CLIENT_SECRET` secrets of the deploy workflow. The chart refuses to
render when `goLoose.enabled` is true and either of them is missing, because a
release without credentials would have no way to sign anybody in.
After a successful install the release notes list the URL and the command that
exports the CA certificate. With the local platform, prefer
`docs/KUBERNETES-DEPLOYMENTS.md` in the infrastructure repository, which covers
`/etc/hosts` entries and CA trust for the whole platform.
## 4. The single host
`host` is the only host the release serves:
```yaml
host: tell.dev
```
The chart derives, without any extra configuration:
- One ingress rule for that host.
- One certificate SAN for that host, so a single secret covers the release.
- `GO_LOOSE_APP_DOMAIN`, which the API turns into the redirect URI
`https://tell.dev/api/auth/callback`.
That redirect URI has to be registered on the Go Loose application together with
the client id and secret. `goLoose.tenant` names the Go Loose tenant that owns
the login, which decides where the browser is sent first:
```
https://tell.dev/api/auth/login
-> https://tell.auth.dev/connect/authorize
-> https://tell.dev/api/auth/callback
```
## 5. Values
### Top level
| Key | Default | Description |
|---|---|---|
| `nameOverride` | `""` | Overrides the chart name in resource names and labels |
| `fullnameOverride` | `""` | Replaces the generated resource name prefix |
| `host` | `tell.dev` | Public host of the CMS |
| `replicaCount` | `1` | CMS replicas, the content file is shared through the volume |
| `imagePullSecrets` | `[]` | Pull secrets added to the pod |
| `nodeSelector`, `tolerations`, `affinity` | empty | Scheduling of the deployment |
### image and app
| Key | Default | Description |
|---|---|---|
| `image.repository` | `batty1039.startdedicated.net:5000/go-tell` | CMS image |
| `image.tag` | `latest` | CMS image tag |
| `image.pullPolicy` | `IfNotPresent` | CMS pull policy |
| `app.profile` | `production` | `CMS_PROFILE`, `demo` enables the in-memory seed and the sudo terminal |
| `app.allowedOrigins` | `https://tell.dev` | `CMS_ALLOWED_ORIGINS` |
| `app.contentFile` | `/data/content.json` | `CMS_CONTENT_FILE`, ignored when persistence is disabled |
| `app.postgresURL` | `""` | `CMS_POSTGRES_URL`, reserved for the PostgreSQL adapter |
| `app.ssoHeader` | `X-SSO-Token` | `CMS_SSO_HEADER`, empty disables the proxy identity |
| `app.ssoCookie` | `""` | `CMS_SSO_COOKIE`, cookie carrying the same value |
| `app.resources` | `100m`/`128Mi` to `500m`/`512Mi` | Requests and limits |
| `app.podAnnotations`, `app.podLabels` | `{}` | Extra pod metadata |
### goLoose
| Key | Default | Description |
|---|---|---|
| `goLoose.enabled` | `true` | Injects the Go Loose environment into the pod |
| `goLoose.tenant` | `tell` | Go Loose tenant that owns the login, one DNS label |
| `goLoose.authDomain` | `auth.dev` | Identity domain shared by every Go Loose tenant |
| `goLoose.appDomain` | `tell.dev` | Public host, which determines the redirect URI |
| `goLoose.clientID` | `""` | Client id of the Go Loose application, required when enabled |
| `goLoose.clientSecret` | `""` | Client secret of the Go Loose application, required when enabled |
| `goLoose.ca.existingSecret` | `""` | Secret holding the CA bundle of an internally signed identity domain |
| `goLoose.ca.key` | `ca.crt` | Key projected from that secret |
| `goLoose.ca.mountPath` | `/certs/local-ca.crt` | `GO_LOOSE_CA_FILE` target |
| `secrets.ssoToken` | `""` | Shared secret the proxy header value must match |
The CA bundle is only mounted when `goLoose.ca.existingSecret` is set. A
publicly signed identity domain therefore needs no extra configuration, while an
internally signed one, such as the local platform, has to publish its
certificate first:
```bash
kubectl -n go-tell-production create secret generic local-ca --from-file=ca.crt=certs/local-ca.crt
```
### ingress and certManager
| Key | Default | Description |
|---|---|---|
| `ingress.enabled` | `true` | Creates the ingress |
| `ingress.className` | `nginx` | Ingress class of the controller |
| `ingress.annotations` | TLS redirect | Controller annotations |
| `ingress.path` | `/` | Path routed to the service |
| `ingress.pathType` | `Prefix` | Path type of that route |
| `ingress.tls.enabled` | `true` | Terminates TLS on the controller |
| `ingress.tls.secretName` | `""` | Defaults to `<fullname>-tls` |
| `ingress.tls.existingSecret` | `""` | Uses a certificate managed outside the chart |
| `certManager.enabled` | `true` | Chart creates the certificates |
| `certManager.issuerRef` | `{}` | Uses an existing issuer instead of the chart CA |
| `certManager.duration` | `8760h` | Certificate lifetime |
| `certManager.renewBefore` | `720h` | Renewal window |
| `certManager.caSecretName` | `""` | Defaults to `<fullname>-ca` |
Four certificate objects are created: a self-signed `Issuer`, a CA
`Certificate` stored in `caSecretName`, an `Issuer` that serves that CA, and the
leaf `Certificate` stored in the ingress TLS secret for `host`. Set
`certManager.issuerRef` to sign with a cluster issuer instead:
```yaml
certManager:
issuerRef:
name: internal-acme
kind: ClusterIssuer
group: cert-manager.io
```
Set `ingress.tls.existingSecret` to use a certificate the chart does not own,
and `certManager.enabled: false` to stop emitting cert-manager objects.
### Storage and pod security
| Key | Default | Description |
|---|---|---|
| `persistence.enabled` | `true` | Persistent volume for the content file |
| `persistence.size` | `2Gi` | Volume size |
| `persistence.accessModes` | `[ReadWriteOnce]` | Volume access modes |
| `persistence.storageClass` | `""` | Storage class, the cluster default when empty |
| `podSecurityContext` | `fsGroup: 10001` | Pod security context, gives the volume to the image user |
| `securityContext` | non-root `10001`, read-only root filesystem | Container security context |
| `strategy.type` | `Recreate` | The old pod releases the volume before the new one starts |
The image runs as its own `go-tell` user (uid `10001`) and declares no
`HOME`, so `readOnlyRootFilesystem` needs the writable `/tmp` mount the chart
adds.
## 6. Upgrade and remove
```bash
make helm-deploy-production
helm -n go-tell-production history go-tell
helm -n go-tell-production rollback go-tell 1
make helm-down-production
```
Keep the Go Loose credentials outside the cluster: uninstalling removes the
release secret, and Go Loose shows a new secret only once.
## 7. Verify
```bash
kubectl -n go-tell-production get ingress go-tell
kubectl -n go-tell-production get certificate
kubectl -n go-tell-production rollout status deployment/go-tell
curl --resolve tell.dev:443:127.0.0.1 --cacert go-tell-ca.crt https://tell.dev/health
curl -I --cacert go-tell-ca.crt https://tell.dev/api/auth/login
```
The login request must answer `302` towards
`https://tell.auth.dev/connect/authorize`; a `404` means the release has no Go
Loose credentials. The test pod installed by `helm test` checks the health
endpoint from inside the cluster.

View file

@ -0,0 +1,35 @@
Go Tell is installed in namespace {{ .Release.Namespace }}.
{{- if .Values.ingress.enabled }}
Open https://{{ .Values.host }}/.
{{- if .Values.goLoose.enabled }}
Signing in goes through Go Loose:
1. https://{{ .Values.goLoose.tenant }}.{{ .Values.goLoose.authDomain }}/connect/authorize redirects to
https://{{ .Values.goLoose.appDomain }}/api/auth/callback.
2. That redirect URI has to be registered on the Go Loose application, next
to client id {{ .Values.goLoose.clientID }}.
{{- end }}
{{- if .Values.ingress.tls.enabled }}
{{- if .Values.certManager.enabled }}
{{- if not .Values.certManager.issuerRef }}
The certificate is issued by the self-signed chain in this release. Export the
root once so the browser trusts it:
kubectl -n {{ .Release.Namespace }} get secret {{ include "go-tell.certManagerCASecretName" . }} \
-o jsonpath='{.data.ca\.crt}' | base64 -d > ca.crt
{{- end }}
{{- end }}
{{- end }}
{{- else if eq .Values.service.type "NodePort" }}
The application is exposed on cluster node port {{ .Values.service.nodePort }}.
Open it through the configured external reverse proxy.
{{- else }}
Run:
kubectl -n {{ .Release.Namespace }} port-forward service/{{ include "go-tell.fullname" . }} 8080:{{ .Values.service.port }}
Then open http://127.0.0.1:8080.
{{- end }}

View file

@ -0,0 +1,77 @@
{{- define "go-tell.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- define "go-tell.fullname" -}}
{{- if .Values.fullnameOverride }}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- $name := default .Chart.Name .Values.nameOverride }}
{{- if contains $name .Release.Name }}
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}
{{- end }}
{{- define "go-tell.labels" -}}
helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" }}
app.kubernetes.io/name: {{ include "go-tell.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}
{{- define "go-tell.selectorLabels" -}}
app.kubernetes.io/name: {{ include "go-tell.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
{{- define "go-tell.secretName" -}}
{{- printf "%s-secrets" (include "go-tell.fullname" .) }}
{{- end }}
{{- define "go-tell.certManagerCASecretName" -}}
{{- default (printf "%s-ca" (include "go-tell.fullname" .)) .Values.certManager.caSecretName }}
{{- end }}
{{- define "go-tell.tlsSecretName" -}}
{{- default (printf "%s-tls" (include "go-tell.fullname" .)) .Values.ingress.tls.secretName }}
{{- end }}
{{- define "go-tell.certManagerIssuerRef" -}}
{{- if kindIs "map" .Values.certManager.issuerRef }}
name: {{ required "certManager.issuerRef.name is required when certManager.issuerRef is set" .Values.certManager.issuerRef.name }}
kind: {{ default "Issuer" .Values.certManager.issuerRef.kind }}
group: {{ default "cert-manager.io" .Values.certManager.issuerRef.group }}
{{- else }}
name: {{ .Values.certManager.issuerRef }}
kind: Issuer
group: cert-manager.io
{{- end }}
{{- end }}
{{/*
CA bundle used to verify the identity domain. Mounted only when the operator
points goLoose.ca.existingSecret at a certificate, so a publicly signed identity
domain needs no extra configuration.
*/}}
{{- define "go-tell.caVolume" -}}
{{- if and .Values.goLoose.enabled .Values.goLoose.ca.existingSecret }}
- name: go-loose-ca
secret:
secretName: {{ .Values.goLoose.ca.existingSecret }}
items:
- key: {{ .Values.goLoose.ca.key }}
path: {{ base .Values.goLoose.ca.mountPath }}
{{- end }}
{{- end }}
{{- define "go-tell.caVolumeMount" -}}
{{- if and .Values.goLoose.enabled .Values.goLoose.ca.existingSecret }}
- name: go-loose-ca
mountPath: {{ dir .Values.goLoose.ca.mountPath }}
readOnly: true
{{- end }}
{{- end }}

View file

@ -0,0 +1,134 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "go-tell.fullname" . }}
labels:
{{- include "go-tell.labels" . | nindent 4 }}
spec:
replicas: {{ .Values.replicaCount }}
strategy:
type: {{ .Values.strategy.type }}
selector:
matchLabels:
{{- include "go-tell.selectorLabels" . | nindent 6 }}
app.kubernetes.io/component: application
template:
metadata:
annotations:
checksum/secrets: {{ include (print $.Template.BasePath "/secret.yaml") . | sha256sum }}
{{- with .Values.podAnnotations }}
{{- toYaml . | nindent 8 }}
{{- end }}
labels:
{{- include "go-tell.selectorLabels" . | nindent 8 }}
app.kubernetes.io/component: application
{{- with .Values.podLabels }}
{{- toYaml . | nindent 8 }}
{{- end }}
spec:
automountServiceAccountToken: false
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
securityContext:
{{- toYaml .Values.podSecurityContext | nindent 8 }}
containers:
- name: go-tell
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
securityContext:
{{- toYaml .Values.securityContext | nindent 12 }}
env:
- name: CMS_PROFILE
value: {{ .Values.app.profile | quote }}
- name: CMS_ALLOWED_ORIGINS
value: {{ .Values.app.allowedOrigins | quote }}
- name: CMS_CONTENT_FILE
value: {{ ternary .Values.app.contentFile "" .Values.persistence.enabled | quote }}
- name: CMS_POSTGRES_URL
value: {{ .Values.app.postgresURL | quote }}
- name: CMS_SSO_HEADER
value: {{ .Values.app.ssoHeader | quote }}
- name: CMS_SSO_COOKIE
value: {{ .Values.app.ssoCookie | quote }}
- name: CMS_THEME_BASE_URL
value: {{ .Values.app.themeBaseURL | quote }}
- name: CMS_SSO_TOKEN
valueFrom:
secretKeyRef:
name: {{ include "go-tell.secretName" . }}
key: sso-token
{{- if .Values.goLoose.enabled }}
- name: GO_LOOSE_TENANT
value: {{ .Values.goLoose.tenant | quote }}
- name: GO_LOOSE_AUTH_DOMAIN
value: {{ .Values.goLoose.authDomain | quote }}
- name: GO_LOOSE_APP_DOMAIN
value: {{ .Values.goLoose.appDomain | quote }}
- name: GO_LOOSE_CLIENT_ID
valueFrom:
secretKeyRef:
name: {{ include "go-tell.secretName" . }}
key: go-loose-client-id
- name: GO_LOOSE_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: {{ include "go-tell.secretName" . }}
key: go-loose-client-secret
{{- if .Values.goLoose.ca.existingSecret }}
- name: GO_LOOSE_CA_FILE
value: {{ .Values.goLoose.ca.mountPath | quote }}
{{- end }}
{{- end }}
ports:
- name: http
containerPort: 8080
protocol: TCP
readinessProbe:
httpGet:
path: /health
port: http
initialDelaySeconds: 5
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 12
livenessProbe:
httpGet:
path: /health
port: http
initialDelaySeconds: 20
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 3
resources:
{{- toYaml .Values.resources | nindent 12 }}
volumeMounts:
- name: temporary-files
mountPath: /tmp
{{- if .Values.persistence.enabled }}
- name: content
mountPath: /data
{{- end }}
{{- include "go-tell.caVolumeMount" . | nindent 12 }}
volumes:
- name: temporary-files
emptyDir: {}
{{- if .Values.persistence.enabled }}
- name: content
persistentVolumeClaim:
claimName: {{ include "go-tell.fullname" . }}
{{- end }}
{{- include "go-tell.caVolume" . | nindent 8 }}
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}

View file

@ -0,0 +1,19 @@
apiVersion: v1
kind: Service
metadata:
name: {{ include "go-tell.fullname" . }}
labels:
{{- include "go-tell.labels" . | nindent 4 }}
spec:
type: {{ .Values.service.type }}
selector:
{{- include "go-tell.selectorLabels" . | nindent 4 }}
app.kubernetes.io/component: application
ports:
- name: http
port: {{ .Values.service.port }}
targetPort: http
protocol: TCP
{{- if and (eq .Values.service.type "NodePort") .Values.service.nodePort }}
nodePort: {{ .Values.service.nodePort }}
{{- end }}

View file

@ -0,0 +1,74 @@
{{- if and .Values.ingress.enabled .Values.ingress.tls.enabled .Values.certManager.enabled (not .Values.ingress.tls.existingSecret) }}
{{- $fullname := include "go-tell.fullname" . }}
{{- $caSecret := include "go-tell.certManagerCASecretName" . }}
{{- $tlsSecret := include "go-tell.tlsSecretName" . }}
{{- if not .Values.certManager.issuerRef }}
apiVersion: cert-manager.io/v1
kind: Issuer
metadata:
name: {{ $fullname }}-selfsigned
labels:
{{- include "go-tell.labels" . | nindent 4 }}
app.kubernetes.io/component: certificate
spec:
selfSigned: {}
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: {{ $fullname }}-ca
labels:
{{- include "go-tell.labels" . | nindent 4 }}
app.kubernetes.io/component: certificate
spec:
secretName: {{ $caSecret }}
commonName: {{ printf "%s-ca" $fullname }}
duration: {{ .Values.certManager.duration }}
renewBefore: {{ .Values.certManager.renewBefore }}
privateKey:
algorithm: ECDSA
size: 256
issuerRef:
name: {{ $fullname }}-selfsigned
kind: Issuer
group: cert-manager.io
---
apiVersion: cert-manager.io/v1
kind: Issuer
metadata:
name: {{ $fullname }}-ca
labels:
{{- include "go-tell.labels" . | nindent 4 }}
app.kubernetes.io/component: certificate
spec:
ca:
secretName: {{ $caSecret }}
---
{{- end }}
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: {{ $fullname }}
labels:
{{- include "go-tell.labels" . | nindent 4 }}
app.kubernetes.io/component: certificate
spec:
secretName: {{ $tlsSecret }}
duration: {{ .Values.certManager.duration }}
renewBefore: {{ .Values.certManager.renewBefore }}
privateKey:
algorithm: ECDSA
size: 256
usages:
- server auth
dnsNames:
- {{ .Values.host | quote }}
issuerRef:
{{- if .Values.certManager.issuerRef }}
{{- include "go-tell.certManagerIssuerRef" . | nindent 4 }}
{{- else }}
name: {{ $fullname }}-ca
kind: Issuer
group: cert-manager.io
{{- end }}
{{- end }}

View file

@ -0,0 +1,33 @@
{{- if .Values.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "go-tell.fullname" . }}
labels:
{{- include "go-tell.labels" . | nindent 4 }}
{{- with .Values.ingress.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- with .Values.ingress.className }}
ingressClassName: {{ . }}
{{- end }}
{{- if .Values.ingress.tls.enabled }}
tls:
- hosts:
- {{ .Values.host | quote }}
secretName: {{ .Values.ingress.tls.existingSecret | default (include "go-tell.tlsSecretName" .) }}
{{- end }}
rules:
- host: {{ .Values.host | quote }}
http:
paths:
- path: {{ .Values.ingress.path }}
pathType: {{ .Values.ingress.pathType }}
backend:
service:
name: {{ include "go-tell.fullname" . }}
port:
number: {{ .Values.service.port }}
{{- end }}

View file

@ -0,0 +1,17 @@
{{- if .Values.persistence.enabled }}
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ include "go-tell.fullname" . }}
labels:
{{- include "go-tell.labels" . | nindent 4 }}
spec:
accessModes:
{{- toYaml .Values.persistence.accessModes | nindent 4 }}
{{- with .Values.persistence.storageClass }}
storageClassName: {{ . | quote }}
{{- end }}
resources:
requests:
storage: {{ .Values.persistence.size }}
{{- end }}

View file

@ -0,0 +1,20 @@
{{- $name := include "go-tell.secretName" . -}}
{{- if and .Values.goLoose.enabled (not .Values.goLoose.clientID) }}
{{- fail "goLoose.clientID is required when goLoose.enabled is true" }}
{{- end }}
{{- if and .Values.goLoose.enabled (not .Values.goLoose.clientSecret) }}
{{- fail "goLoose.clientSecret is required when goLoose.enabled is true" }}
{{- end }}
apiVersion: v1
kind: Secret
metadata:
name: {{ $name }}
labels:
{{- include "go-tell.labels" . | nindent 4 }}
type: Opaque
data:
sso-token: {{ .Values.secrets.ssoToken | b64enc | quote }}
{{- if .Values.goLoose.enabled }}
go-loose-client-id: {{ .Values.goLoose.clientID | b64enc | quote }}
go-loose-client-secret: {{ .Values.goLoose.clientSecret | b64enc | quote }}
{{- end }}

View file

@ -0,0 +1,20 @@
apiVersion: v1
kind: Pod
metadata:
name: "{{ include "go-tell.fullname" . }}-test-connection"
labels:
{{- include "go-tell.labels" . | nindent 4 }}
annotations:
helm.sh/hook: test
helm.sh/hook-delete-policy: before-hook-creation
spec:
automountServiceAccountToken: false
restartPolicy: Never
containers:
- name: wget
image: busybox:1.37
command:
- wget
args:
- -qO-
- "http://{{ include "go-tell.fullname" . }}:{{ .Values.service.port }}/health"

View file

@ -0,0 +1,104 @@
# Development values for go-tell
nameOverride: ""
fullnameOverride: ""
# Public host of the CMS.
host: tell-dev.urpi.be
replicaCount: 1
image:
repository: nexus.urpi.be/docker-hosted/go-tell
tag: "0.0.1"
pullPolicy: Always
imagePullSecrets:
- name: nexus-registry
app:
profile: demo
allowedOrigins: https://tell-dev.urpi.be
contentFile: ""
postgresURL: ""
ssoHeader: X-SSO-Token
ssoCookie: ""
goLoose:
enabled: false
tenant: tell
authDomain: auth-dev.urpi.be
appDomain: tell-dev.urpi.be
ca:
existingSecret: ""
key: ca.crt
mountPath: /certs/local-ca.crt
clientID: ""
clientSecret: ""
secrets:
ssoToken: ""
service:
type: ClusterIP
port: 80
nodePort: null
ingress:
enabled: true
className: nginx
annotations:
nginx.ingress.kubernetes.io/ssl-redirect: "true"
cert-manager.io/cluster-issuer: letsencrypt-staging
path: /
pathType: Prefix
tls:
enabled: true
existingSecret: ""
secretName: ""
certManager:
enabled: true
issuerRef:
name: letsencrypt-staging
kind: ClusterIssuer
duration: 8760h
renewBefore: 720h
caSecretName: ""
resources:
requests:
cpu: 50m
memory: 96Mi
limits:
cpu: 300m
memory: 384Mi
podAnnotations: {}
podLabels: {}
podSecurityContext:
fsGroup: 10001
fsGroupChangePolicy: OnRootMismatch
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
readOnlyRootFilesystem: true
runAsNonRoot: true
runAsUser: 10001
strategy:
type: Recreate
persistence:
enabled: true
size: 2Gi
accessModes:
- ReadWriteOnce
storageClass: ""
nodeSelector: {}
tolerations: []
affinity: {}

View file

@ -0,0 +1,28 @@
image:
pullPolicy: Always
app:
profile: demo
allowedOrigins: http://localhost:30010
contentFile: ""
goLoose:
enabled: false
service:
type: NodePort
nodePort: 30010
ingress:
enabled: false
resources:
requests:
cpu: 50m
memory: 96Mi
limits:
cpu: 300m
memory: 384Mi
persistence:
enabled: false

View file

@ -0,0 +1,103 @@
# Local (LCL) values for go-tell.
# Served at https://tell-lcl.intern-urpi.be by the docker TLS edge.
nameOverride: ""
fullnameOverride: ""
# Public host of the CMS.
host: tell-lcl.intern-urpi.be
replicaCount: 1
image:
repository: localhost:5000/go-tell
tag: "0.0.1"
pullPolicy: IfNotPresent
# Images live in the local registry (localhost:5000), no pull secret needed.
imagePullSecrets: []
app:
profile: demo
allowedOrigins: https://tell-lcl.intern-urpi.be
contentFile: ""
postgresURL: ""
ssoHeader: X-SSO-Token
ssoCookie: ""
goLoose:
enabled: false
tenant: tell
authDomain: auth-lcl.intern-urpi.be
appDomain: tell-lcl.intern-urpi.be
ca:
existingSecret: ""
key: ca.crt
mountPath: /certs/local-ca.crt
clientID: ""
clientSecret: ""
secrets:
ssoToken: ""
service:
type: ClusterIP
port: 80
nodePort: null
ingress:
enabled: true
className: traefik
annotations: {}
path: /
pathType: Prefix
tls:
enabled: true
# local-dev-cert is signed by the local CA (dev-infrastructure/certs).
existingSecret: "local-dev-cert"
secretName: ""
# No cert-manager in the local cluster.
certManager:
enabled: false
issuerRef: null
duration: 8760h
renewBefore: 720h
caSecretName: ""
resources:
requests:
cpu: 50m
memory: 96Mi
limits:
cpu: 300m
memory: 384Mi
podAnnotations: {}
podLabels: {}
podSecurityContext:
fsGroup: 10001
fsGroupChangePolicy: OnRootMismatch
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
readOnlyRootFilesystem: true
runAsNonRoot: true
runAsUser: 10001
strategy:
type: Recreate
persistence:
enabled: true
size: 2Gi
accessModes:
- ReadWriteOnce
storageClass: ""
nodeSelector: {}
tolerations: []
affinity: {}

View file

@ -0,0 +1,107 @@
# Production values for go-tell
nameOverride: ""
fullnameOverride: ""
# Public host of the CMS.
host: tell.urpi.be
replicaCount: 2
imagePullSecrets:
- name: nexus-registry
image:
repository: nexus.urpi.be/docker-hosted/go-tell
tag: "0.0.1"
pullPolicy: Always
app:
profile: production
allowedOrigins: https://tell.urpi.be
contentFile: /data/content.json
postgresURL: ""
ssoHeader: X-SSO-Token
ssoCookie: ""
goLoose:
enabled: false
tenant: tell
authDomain: auth.urpi.be
appDomain: tell.urpi.be
ca:
existingSecret: ""
key: ca.crt
mountPath: /certs/local-ca.crt
clientID: ""
clientSecret: ""
secrets:
ssoToken: ""
service:
type: ClusterIP
port: 80
nodePort: null
ingress:
enabled: true
className: nginx
annotations:
nginx.ingress.kubernetes.io/ssl-redirect: "true"
cert-manager.io/cluster-issuer: letsencrypt-prod
path: /
pathType: Prefix
tls:
enabled: true
existingSecret: ""
secretName: ""
certManager:
enabled: true
issuerRef:
name: letsencrypt-prod
kind: ClusterIssuer
duration: 8760h
renewBefore: 720h
caSecretName: ""
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: 1000m
memory: 1Gi
podAnnotations: {}
podLabels: {}
podSecurityContext:
fsGroup: 10001
fsGroupChangePolicy: OnRootMismatch
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
readOnlyRootFilesystem: true
runAsNonRoot: true
runAsUser: 10001
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
persistence:
enabled: true
size: 10Gi
accessModes:
- ReadWriteOnce
storageClass: ""
nodeSelector: {}
tolerations: []
affinity: {}

View file

@ -0,0 +1,22 @@
image:
pullPolicy: IfNotPresent
app:
profile: production
allowedOrigins: https://tell.dev
contentFile: /data/content.json
service:
type: ClusterIP
resources:
requests:
cpu: 200m
memory: 256Mi
limits:
cpu: "1"
memory: 768Mi
persistence:
enabled: true
size: 5Gi

View file

@ -0,0 +1,104 @@
# TST values for go-tell
nameOverride: ""
fullnameOverride: ""
# Public host of the CMS.
host: tell-tst.intern-urpi.be
replicaCount: 1
image:
repository: nexus.urpi.be/docker-hosted/go-tell
tag: "0.0.1"
pullPolicy: Always
imagePullSecrets:
- name: nexus-registry
app:
profile: demo
allowedOrigins: https://tell-tst.intern-urpi.be
contentFile: ""
postgresURL: ""
ssoHeader: X-SSO-Token
ssoCookie: ""
goLoose:
enabled: false
tenant: tell
authDomain: auth-tst.intern-urpi.be
appDomain: tell-tst.intern-urpi.be
ca:
existingSecret: ""
key: ca.crt
mountPath: /certs/local-ca.crt
clientID: ""
clientSecret: ""
secrets:
ssoToken: ""
service:
type: ClusterIP
port: 80
nodePort: null
ingress:
enabled: true
className: nginx
annotations:
nginx.ingress.kubernetes.io/ssl-redirect: "true"
cert-manager.io/cluster-issuer: letsencrypt-staging
path: /
pathType: Prefix
tls:
enabled: true
existingSecret: ""
secretName: ""
certManager:
enabled: true
issuerRef:
name: letsencrypt-staging
kind: ClusterIssuer
duration: 8760h
renewBefore: 720h
caSecretName: ""
resources:
requests:
cpu: 50m
memory: 96Mi
limits:
cpu: 300m
memory: 384Mi
podAnnotations: {}
podLabels: {}
podSecurityContext:
fsGroup: 10001
fsGroupChangePolicy: OnRootMismatch
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
readOnlyRootFilesystem: true
runAsNonRoot: true
runAsUser: 10001
strategy:
type: Recreate
persistence:
enabled: true
size: 3Gi
accessModes:
- ReadWriteOnce
storageClass: ""
nodeSelector: {}
tolerations: []
affinity: {}

View file

@ -0,0 +1,123 @@
nameOverride: ""
fullnameOverride: ""
# Public host of the CMS. There is one release, one host, and no tenant
# routing: every visitor reaches the same content and the same editor.
host: tell.dev
replicaCount: 1
imagePullSecrets:
- name: nexus-registry
image:
repository: nexus.urpi.be/docker-hosted/go-tell
tag: "0.0.1"
pullPolicy: IfNotPresent
app:
# Demo keeps content in memory and offers the sudo terminal. Production serves
# the persisted file and requires an authenticated editor.
profile: production
allowedOrigins: https://tell.dev
contentFile: /data/content.json
postgresURL: ""
# Single-sign-on proxy identity, kept as a fallback when Go Loose is not
# configured. An empty header disables it.
ssoHeader: X-SSO-Token
ssoCookie: ""
# The go-bananas origin supplying the shared styling contract: the palette and
# appearance controls, and the --bn-* custom properties the page is styled with.
# Empty leaves the shell on its own built-in fallbacks, which is why a
# deployment can run without it and still look right — and also why a missing
# value here is silent rather than obvious.
themeBaseURL: ""
goLoose:
enabled: true
# Go Loose tenant that owns the login, the shared identity domain, and the
# public host of the CMS. Together they produce the base URL
# https://<tenant>.<authDomain> and the redirect URI
# https://<appDomain>/api/auth/callback that has to be registered in Go Loose.
tenant: tell
authDomain: auth.dev
appDomain: tell.dev
ca:
# Mount an internal CA bundle and point GO_LOOSE_CA_FILE at it when the
# identity domain is signed by a private authority.
existingSecret: ""
key: ca.crt
mountPath: /certs/local-ca.crt
clientID: ""
clientSecret: ""
secrets:
# Shared secret the identity-aware proxy must present in the SSO header. Empty
# accepts any non-empty forwarded value.
ssoToken: ""
service:
type: ClusterIP
port: 80
nodePort: null
ingress:
enabled: true
className: nginx
annotations:
nginx.ingress.kubernetes.io/ssl-redirect: "true"
path: /
pathType: Prefix
tls:
enabled: true
# Use a certificate you already manage instead of the chart-issued one.
existingSecret: ""
# Defaults to <fullname>-tls when empty and certManager.enabled is true.
secretName: ""
certManager:
# Requires cert-manager in the cluster: make cert-manager-install.
enabled: true
# Reuse an existing cert-manager issuer instead of creating a self-signed one.
issuerRef: {}
duration: 8760h
renewBefore: 720h
caSecretName: ""
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi
podAnnotations: {}
podLabels: {}
podSecurityContext:
fsGroup: 10001
fsGroupChangePolicy: OnRootMismatch
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
readOnlyRootFilesystem: true
runAsNonRoot: true
runAsUser: 10001
strategy:
type: Recreate
persistence:
enabled: true
size: 2Gi
accessModes:
- ReadWriteOnce
storageClass: ""
nodeSelector: {}
tolerations: []
affinity: {}

79
go.mod
View file

@ -1,3 +1,80 @@
module github.com/example/cms module github.com/danyel/go-tell
go 1.27.1 go 1.27.1
require (
github.com/danyel/go-loose v0.0.0-20261002224534-b011701522b1
github.com/go-chi/chi/v5 v5.2.3
github.com/golang-jwt/jwt/v5 v5.3.0
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.11.0
github.com/ledongthuc/pdf v0.0.0-20260907135840-6c8c28e0e8a0
github.com/pressly/goose/v3 v3.26.0
github.com/rabbitmq/amqp091-go v1.15.0
github.com/testcontainers/testcontainers-go v0.44.0
github.com/testcontainers/testcontainers-go/modules/postgres v0.44.0
golang.org/x/crypto v0.54.0
gorm.io/driver/postgres v1.6.0
gorm.io/gorm v1.31.0
)
require (
dario.cat/mergo v1.0.2 // indirect
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/containerd/log v0.1.0 // indirect
github.com/containerd/platforms v0.2.1 // indirect
github.com/cpuguy83/dockercfg v0.3.2 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/go-connections v0.7.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/ebitengine/purego v0.10.1 // indirect
github.com/felixge/httpsnoop v1.1.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/klauspost/compress v1.18.6 // indirect
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect
github.com/magiconair/properties v1.8.10 // indirect
github.com/mfridman/interpolate v0.0.2 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/go-archive v0.2.0 // indirect
github.com/moby/moby/api v1.55.0 // indirect
github.com/moby/moby/client v0.5.0 // indirect
github.com/moby/patternmatcher v0.6.1 // indirect
github.com/moby/sys/sequential v0.7.0 // indirect
github.com/moby/sys/user v0.4.0 // indirect
github.com/moby/sys/userns v0.1.0 // indirect
github.com/moby/term v0.5.2 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/sethvargo/go-retry v0.3.0 // indirect
github.com/shirou/gopsutil/v4 v4.26.6 // indirect
github.com/sirupsen/logrus v1.9.4 // indirect
github.com/stretchr/testify v1.11.1 // indirect
github.com/tklauser/go-sysconf v0.4.0 // indirect
github.com/tklauser/numcpus v0.12.0 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect
go.opentelemetry.io/otel v1.44.0 // indirect
go.opentelemetry.io/otel/metric v1.44.0 // indirect
go.opentelemetry.io/otel/trace v1.44.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/sync v0.23.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.42.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

87
go.sum
View file

@ -0,0 +1,87 @@
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
github.com/danyel/go-loose v0.0.0-20261002224534-b011701522b1 h1:hGywTGxVZoxwpSovWdzPHU6oRcQvbCwOJXaBpnynXeM=
github.com/danyel/go-loose v0.0.0-20261002224534-b011701522b1/go.mod h1:qOO4ay0c2MWiiLwz7WHCXCqDwxxA3sKTKKUnqbFgsOk=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE=
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/ledongthuc/pdf v0.0.0-20260907135840-6c8c28e0e8a0/go.mod h1:1fEHWurg7pvf5SG6XNE5Q8UZmOwex51Mkx3SLhrW5B4=
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU=
github.com/moby/moby/api v1.55.0/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
github.com/moby/moby/client v0.5.0/go.mod h1:rcVpF8ncl9vo5gaIBdol6CnbEtSj1uxMvEV/UrykF/s=
github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
github.com/moby/sys/sequential v0.7.0/go.mod h1:NfSTAp6V3fw4tmkD62PEcOKeZKquXT8VKCkf7aVR79o=
github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/pressly/goose/v3 v3.26.0/go.mod h1:4hC1KrritdCxtuFsqgs1R4AU5bWtTAf+cnWvfhf2DNY=
github.com/rabbitmq/amqp091-go v1.15.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o=
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
github.com/shirou/gopsutil/v4 v4.26.6/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/testcontainers/testcontainers-go v0.44.0/go.mod h1:IcnwQrYTO86xHXu5bvMaBH7ATlbS3Qn1M1QWW3c66rE=
github.com/testcontainers/testcontainers-go/modules/postgres v0.44.0/go.mod h1:CFr2LncGYokw+OKjXcr8ARCKG1SaC2UEnGxFBovE86g=
github.com/tklauser/go-sysconf v0.4.0/go.mod h1:8mTNWyog7H+MpKijp4VmKJAd2bbYQ2zuUwkYRbUArPI=
github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI=
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
gorm.io/gorm v1.31.0/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=

View file

@ -1,6 +1,7 @@
// Package config carries the runtime settings for the demo CMS. There is no // Package config carries the runtime settings for the CMS. There is no
// database to configure: content is seeded in memory and optionally mirrored to // database to configure: content is seeded in memory and optionally mirrored to
// a single JSON file. // a single JSON file. Editing rights come from Go Loose, from the single-sign-on
// proxy, or from the demo profile, depending on what is configured.
package config package config
import "os" import "os"
@ -8,8 +9,32 @@ import "os"
type Config struct { type Config struct {
HTTPAddr, Profile, ContentFile, PostgresURL string HTTPAddr, Profile, ContentFile, PostgresURL string
AllowedOrigins, SSOHeader, SSOToken, SSOCookie string AllowedOrigins, SSOHeader, SSOToken, SSOCookie string
GoLoose GoLooseConfig
// ThemeBaseURL is the go-bananas origin supplying the shared styling contract.
// Empty keeps this application's own fallbacks, which is the safe default: the
// stylesheet is written as var(--bn-role, <colour>), so a missing contract
// degrades to the palette it always had rather than to an unstyled page.
ThemeBaseURL string
} }
// GoLooseConfig describes the Go Loose hosted-login integration. Go Tell is
// served from a single host, so one client application covers every request:
// Tenant picks the Go Loose tenant that owns the login, AuthDomain is the
// identity domain shared by all tenants, and AppDomain is the public host of
// the CMS, which also determines the registered redirect URI.
type GoLooseConfig struct {
Tenant string
AuthDomain string
AppDomain string
CAFile string
ClientID string
ClientSecret string
}
// Enabled reports whether Go Loose client credentials are configured.
func (g GoLooseConfig) Enabled() bool { return g.ClientID != "" && g.ClientSecret != "" }
func Load() Config { func Load() Config {
return Config{ return Config{
HTTPAddr: get("CMS_HTTP_ADDR", ":8080"), HTTPAddr: get("CMS_HTTP_ADDR", ":8080"),
@ -20,6 +45,15 @@ func Load() Config {
SSOHeader: get("CMS_SSO_HEADER", "X-SSO-Token"), SSOHeader: get("CMS_SSO_HEADER", "X-SSO-Token"),
SSOToken: get("CMS_SSO_TOKEN", ""), SSOToken: get("CMS_SSO_TOKEN", ""),
SSOCookie: get("CMS_SSO_COOKIE", ""), SSOCookie: get("CMS_SSO_COOKIE", ""),
ThemeBaseURL: get("CMS_THEME_BASE_URL", ""),
GoLoose: GoLooseConfig{
Tenant: get("GO_LOOSE_TENANT", "tell"),
AuthDomain: get("GO_LOOSE_AUTH_DOMAIN", "auth.dev"),
AppDomain: get("GO_LOOSE_APP_DOMAIN", "tell.dev"),
CAFile: os.Getenv("GO_LOOSE_CA_FILE"),
ClientID: os.Getenv("GO_LOOSE_CLIENT_ID"),
ClientSecret: os.Getenv("GO_LOOSE_CLIENT_SECRET"),
},
} }
} }

View file

@ -0,0 +1,261 @@
// Package golooseauth builds the Go Loose hosted-login client used by the CMS.
package golooseauth
import (
"context"
"crypto/tls"
"crypto/x509"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
goloose "github.com/danyel/go-loose/client"
"github.com/danyel/go-tell/internal/config"
)
const (
// SessionCookie holds the Go Loose session token after a successful login.
SessionCookie = "go_loose_user_session"
// LoginPath starts the browser login flow.
LoginPath = "/api/auth/login"
// CallbackPath is the redirect URI registered with Go Loose.
CallbackPath = "/api/auth/callback"
// LogoutPath ends the browser session, locally and at the identity provider.
LogoutPath = "/api/auth/logout"
)
// Auth is the Go Loose browser client plus the session lookup the API needs.
// The login, callback, and logout handlers come from the Go Loose client; this
// type only adds a non-redirecting way to turn a request into a user, because
// the client middleware answers anonymous requests with a redirect and the CMS
// has to answer its own API with a status code.
// Auth is the Go Loose browser client plus the session lookup the API needs.
// The login, callback, and logout handlers come from the Go Loose client; this
// type only adds a non-redirecting way to turn a request into a user, because
// the client middleware answers anonymous requests with a redirect and the CMS
// has to answer its own API with a status code.
type Auth struct {
defaultAuth *goloose.BrowserAuth
urpiAuth *goloose.BrowserAuth
devAuth *goloose.BrowserAuth
baseURL string
urpiBase string
devBase string
httpClient *http.Client
}
// New returns the browser client for the configured Go Loose application, or a
// nil client when no credentials are set, which leaves the CMS with its other
// identity sources. ClientID and ClientSecret are the values Go Loose shows once
// when a client application is configured.
func New(cfg config.GoLooseConfig) (*Auth, error) {
if !cfg.Enabled() {
return nil, nil
}
tenant := domain(cfg.Tenant)
if tenant == "" || strings.Contains(tenant, ".") {
return nil, fmt.Errorf("GO_LOOSE_TENANT must be a single lowercase tenant slug")
}
authDomain := domain(cfg.AuthDomain)
if authDomain == "" {
return nil, fmt.Errorf("GO_LOOSE_AUTH_DOMAIN is required when client credentials are configured")
}
appDomain := domain(cfg.AppDomain)
if appDomain == "" {
return nil, fmt.Errorf("GO_LOOSE_APP_DOMAIN is required when client credentials are configured")
}
httpClient, err := client(cfg.CAFile)
if err != nil {
return nil, err
}
baseURL := "https://" + tenant + "." + authDomain
defaultAuth, err := goloose.NewBrowserAuth(goloose.BrowserAuthConfig{
BaseURL: baseURL,
ClientID: cfg.ClientID,
ClientSecret: cfg.ClientSecret,
RedirectURL: "https://" + appDomain + CallbackPath,
AfterLoginURL: "/",
AfterLogoutURL: "/",
LoginPath: LoginPath,
CookieName: SessionCookie,
CookieSecure: true,
HTTPClient: httpClient,
})
if err != nil {
return nil, err
}
urpiBase := "https://" + tenant + ".auth.urpi.be"
urpiAuth, _ := goloose.NewBrowserAuth(goloose.BrowserAuthConfig{
BaseURL: urpiBase,
ClientID: cfg.ClientID,
ClientSecret: cfg.ClientSecret,
RedirectURL: "https://tell.urpi.be" + CallbackPath,
AfterLoginURL: "/",
AfterLogoutURL: "/",
LoginPath: LoginPath,
CookieName: SessionCookie,
CookieSecure: true,
HTTPClient: httpClient,
})
devBase := "https://" + tenant + ".auth.dev"
devAuth, _ := goloose.NewBrowserAuth(goloose.BrowserAuthConfig{
BaseURL: devBase,
ClientID: cfg.ClientID,
ClientSecret: cfg.ClientSecret,
RedirectURL: "https://tell.dev" + CallbackPath,
AfterLoginURL: "/",
AfterLogoutURL: "/",
LoginPath: LoginPath,
CookieName: SessionCookie,
CookieSecure: true,
HTTPClient: httpClient,
})
return &Auth{
defaultAuth: defaultAuth,
urpiAuth: urpiAuth,
devAuth: devAuth,
baseURL: baseURL,
urpiBase: urpiBase,
devBase: devBase,
httpClient: httpClient,
}, nil
}
func (a *Auth) forRequest(r *http.Request) (*goloose.BrowserAuth, string) {
if r != nil {
host := strings.ToLower(r.Host)
if fwd := r.Header.Get("X-Forwarded-Host"); fwd != "" {
host = strings.ToLower(fwd)
}
if strings.Contains(host, "urpi.be") && a.urpiAuth != nil {
return a.urpiAuth, a.urpiBase
}
if (strings.Contains(host, "dev") || strings.Contains(host, "localhost")) && a.devAuth != nil {
return a.devAuth, a.devBase
}
}
if a.defaultAuth != nil {
return a.defaultAuth, a.baseURL
}
if a.devAuth != nil {
return a.devAuth, a.devBase
}
return a.urpiAuth, a.urpiBase
}
// Tenant reports the Go Loose tenant that owns the login.
func (a *Auth) Tenant() string { return a.baseURL }
func (a *Auth) LoginHandler(w http.ResponseWriter, r *http.Request) {
auth, _ := a.forRequest(r)
if auth != nil {
auth.LoginHandler(w, r)
return
}
http.Error(w, "auth provider unavailable", http.StatusServiceUnavailable)
}
func (a *Auth) CallbackHandler(w http.ResponseWriter, r *http.Request) {
auth, _ := a.forRequest(r)
if auth != nil {
auth.CallbackHandler(w, r)
return
}
http.Error(w, "auth provider unavailable", http.StatusServiceUnavailable)
}
func (a *Auth) LogoutHandler(w http.ResponseWriter, r *http.Request) {
auth, _ := a.forRequest(r)
if auth != nil {
auth.LogoutHandler(w, r)
return
}
http.Error(w, "auth provider unavailable", http.StatusServiceUnavailable)
}
// SessionUser resolves the Go Loose session cookie of a request into a user.
// Requests without a cookie, with a rejected session, or without a reachable
// identity provider are simply not signed in.
func (a *Auth) SessionUser(r *http.Request) (goloose.User, bool) {
if a == nil {
return goloose.User{}, false
}
cookie, err := r.Cookie(SessionCookie)
if err != nil || cookie.Value == "" {
return goloose.User{}, false
}
_, targetBase := a.forRequest(r)
if user, ok := a.checkUser(r.Context(), targetBase, cookie.Value); ok {
return user, true
}
if targetBase != a.baseURL && a.baseURL != "" {
if user, ok := a.checkUser(r.Context(), a.baseURL, cookie.Value); ok {
return user, true
}
}
if targetBase != a.urpiBase && a.urpiBase != "" {
if user, ok := a.checkUser(r.Context(), a.urpiBase, cookie.Value); ok {
return user, true
}
}
if targetBase != a.devBase && a.devBase != "" {
if user, ok := a.checkUser(r.Context(), a.devBase, cookie.Value); ok {
return user, true
}
}
return goloose.User{}, false
}
func (a *Auth) checkUser(ctx context.Context, baseURL, token string) (goloose.User, bool) {
request, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/connect/userinfo", nil)
if err != nil {
return goloose.User{}, false
}
request.Header.Set("Authorization", "Bearer "+token)
response, err := a.httpClient.Do(request)
if err != nil {
return goloose.User{}, false
}
defer response.Body.Close()
var user goloose.User
if response.StatusCode != http.StatusOK ||
json.NewDecoder(io.LimitReader(response.Body, 1<<20)).Decode(&user) != nil {
return goloose.User{}, false
}
return user, true
}
// domain normalizes a configured host or tenant slug.
func domain(value string) string {
return strings.Trim(strings.ToLower(strings.TrimSpace(value)), ".")
}
// client returns the HTTP client used to talk to Go Loose, trusting the extra
// certificate authorities in caFile when one is configured. It is needed while
// the identity domain is served by an internal certificate authority.
func client(caFile string) (*http.Client, error) {
transport := http.DefaultTransport.(*http.Transport).Clone()
if caFile != "" {
pem, err := os.ReadFile(caFile)
if err != nil {
return nil, fmt.Errorf("read Go Loose CA: %w", err)
}
pool, err := x509.SystemCertPool()
if err != nil || pool == nil {
pool = x509.NewCertPool()
}
if !pool.AppendCertsFromPEM(pem) {
return nil, fmt.Errorf("parse Go Loose CA %s", caFile)
}
transport.TLSClientConfig = &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12}
}
return &http.Client{Timeout: 5 * time.Second, Transport: transport}, nil
}

View file

@ -0,0 +1,150 @@
package golooseauth
import (
"net/http"
"net/http/httptest"
"net/url"
"testing"
goloose "github.com/danyel/go-loose/client"
"github.com/danyel/go-tell/internal/config"
)
func TestNewWithoutCredentialsIsDisabled(t *testing.T) {
auth, err := New(config.GoLooseConfig{Tenant: "tell", AuthDomain: "auth.dev", AppDomain: "tell.dev"})
if err != nil || auth != nil {
t.Fatalf("New() = %#v, %v; want a disabled client", auth, err)
}
auth, err = New(config.GoLooseConfig{ClientID: "client"})
if err != nil || auth != nil {
t.Fatalf("New() with a half-configured client = %#v, %v; want a disabled client", auth, err)
}
}
func TestNewRejectsInvalidDomains(t *testing.T) {
cases := map[string]config.GoLooseConfig{
"tenant with a dot": {Tenant: "tell.dev", AuthDomain: "auth.dev", AppDomain: "tell.dev", ClientID: "c", ClientSecret: "s"},
"empty tenant": {Tenant: " ", AuthDomain: "auth.dev", AppDomain: "tell.dev", ClientID: "c", ClientSecret: "s"},
"empty auth domain": {Tenant: "tell", ClientID: "c", ClientSecret: "s"},
"empty app domain": {Tenant: "tell", AuthDomain: "auth.dev", ClientID: "c", ClientSecret: "s"},
}
for name, cfg := range cases {
t.Run(name, func(t *testing.T) {
if _, err := New(cfg); err == nil {
t.Fatal("New() error = nil, want a configuration error")
}
})
}
}
func TestNewBuildsSingleHostBrowserClient(t *testing.T) {
auth, err := New(config.GoLooseConfig{
Tenant: " Tell ", AuthDomain: ".auth.dev.", AppDomain: "Tell.Dev",
ClientID: "client", ClientSecret: "secret",
})
if err != nil {
t.Fatal(err)
}
if auth.Tenant() != "https://tell.auth.dev" {
t.Fatalf("Tenant() = %q", auth.Tenant())
}
recorder := httptest.NewRecorder()
auth.LoginHandler(recorder, httptest.NewRequest(http.MethodGet, LoginPath, nil))
if recorder.Code != http.StatusFound {
t.Fatalf("login status = %d, body = %s", recorder.Code, recorder.Body.String())
}
target, err := url.Parse(recorder.Header().Get("Location"))
if err != nil {
t.Fatal(err)
}
if target.Scheme+"://"+target.Host != "https://tell.auth.dev" || target.Path != "/connect/authorize" {
t.Fatalf("unexpected login target: %s", target)
}
if target.Query().Get("client_id") != "client" ||
target.Query().Get("redirect_uri") != "https://tell.dev"+CallbackPath ||
target.Query().Get("state") == "" {
t.Fatalf("unexpected login query: %s", target.RawQuery)
}
cookies := recorder.Result().Cookies()
if len(cookies) != 1 || cookies[0].Name == SessionCookie || !cookies[0].HttpOnly {
t.Fatalf("expected an HTTP-only state cookie scoped to the callback, got %#v", cookies)
}
}
func TestSessionUserResolvesCookie(t *testing.T) {
identity := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer session-token" {
http.Error(w, "missing bearer token", http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"id":"user-1","email":"owner@tell.dev","display_name":"Owner","tenant_slug":"tell"}`))
}))
defer identity.Close()
auth := testAuth(t, identity)
request := httptest.NewRequest(http.MethodPut, "https://tell.dev/api/content/post", nil)
if _, ok := auth.SessionUser(request); ok {
t.Fatal("SessionUser() without a cookie = true, want false")
}
if _, ok := auth.SessionUser(httptest.NewRequest(http.MethodPut, "https://tell.dev/api/content/post", nil)); ok {
t.Fatal("SessionUser() without a cookie = true, want false")
}
}
func TestSessionUserRejectsRejectedSession(t *testing.T) {
identity := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, "no such session", http.StatusUnauthorized)
}))
defer identity.Close()
auth := testAuth(t, identity)
request := httptest.NewRequest(http.MethodPut, "https://tell.dev/api/content/post", nil)
request.AddCookie(&http.Cookie{Name: SessionCookie, Value: "expired"})
if user, ok := auth.SessionUser(request); ok {
t.Fatalf("SessionUser() with a rejected session = %#v, want no user", user)
}
}
func TestSessionUserReturnsIdentity(t *testing.T) {
identity := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"id":"user-1","email":"owner@tell.dev","display_name":"Owner","tenant_slug":"tell"}`))
}))
defer identity.Close()
auth := testAuth(t, identity)
request := httptest.NewRequest(http.MethodPut, "https://tell.dev/api/content/post", nil)
request.AddCookie(&http.Cookie{Name: SessionCookie, Value: "session-token"})
user, ok := auth.SessionUser(request)
if !ok {
t.Fatal("SessionUser() = false, want the session user")
}
if user.Email != "owner@tell.dev" || user.DisplayName != "Owner" || user.TenantSlug != "tell" {
t.Fatalf("unexpected user: %#v", user)
}
}
func TestNilAuthHasNoSession(t *testing.T) {
var auth *Auth
if user, ok := auth.SessionUser(httptest.NewRequest(http.MethodGet, "/", nil)); ok {
t.Fatalf("SessionUser() on a disabled client = %#v, want no user", user)
}
}
// testAuth wires an Auth to a local identity server, which is what the client
// would talk to once the base URL points somewhere reachable in a test.
func testAuth(t *testing.T, identity *httptest.Server) *Auth {
t.Helper()
browser, err := goloose.NewBrowserAuth(goloose.BrowserAuthConfig{
BaseURL: identity.URL, ClientID: "client", ClientSecret: "secret",
RedirectURL: "https://tell.dev" + CallbackPath, LoginPath: LoginPath,
CookieName: SessionCookie, HTTPClient: identity.Client(),
})
if err != nil {
t.Fatal(err)
}
return &Auth{defaultAuth: browser, baseURL: identity.URL, httpClient: identity.Client()}
}

View file

@ -33,9 +33,9 @@ func TestSSOTokenVerifierChecksExpectedToken(t *testing.T) {
} }
func TestSSOTokenVerifierCookieFallback(t *testing.T) { func TestSSOTokenVerifierCookieFallback(t *testing.T) {
v := NewSSOTokenVerifier("X-SSO-Token", "", "cms_session") v := NewSSOTokenVerifier("X-SSO-Token", "", "go_tell_session")
r := httptest.NewRequest(http.MethodGet, "/", nil) r := httptest.NewRequest(http.MethodGet, "/", nil)
r.AddCookie(&http.Cookie{Name: "cms_session", Value: "cookie-token"}) r.AddCookie(&http.Cookie{Name: "go_tell_session", Value: "cookie-token"})
if !v.Authenticated(r) { if !v.Authenticated(r) {
t.Fatal("cookie token must be authenticated") t.Fatal("cookie token must be authenticated")
} }

View file

@ -7,6 +7,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"os" "os"
"path/filepath" "path/filepath"
"regexp" "regexp"
@ -62,6 +63,9 @@ type IContentService interface {
ListBadges(context.Context) ([]Badge, error) ListBadges(context.Context) ([]Badge, error)
Get(context.Context, string) (Content, error) Get(context.Context, string) (Content, error)
Update(context.Context, Content) (Content, error) Update(context.Context, Content) (Content, error)
Create(context.Context, Content) (Content, error)
Delete(context.Context, string) error
Reset(context.Context) error
} }
// ContentService keeps the demo content in memory behind a mutex. When file is // ContentService keeps the demo content in memory behind a mutex. When file is
@ -206,14 +210,11 @@ func (s *ContentService) ListBadges(context.Context) ([]Badge, error) {
// Get returns one article by slug. // Get returns one article by slug.
func (s *ContentService) Get(_ context.Context, slug string) (Content, error) { func (s *ContentService) Get(_ context.Context, slug string) (Content, error) {
slug = strings.ToLower(strings.TrimSpace(slug)) slug = strings.ToLower(strings.TrimSpace(slug))
if !validSlug(slug) {
return Content{}, errors.New("invalid slug")
}
s.mu.RLock() s.mu.RLock()
defer s.mu.RUnlock() defer s.mu.RUnlock()
for _, item := range s.items { for _, item := range s.items {
if item.Slug == slug { if item.Slug == slug || fmt.Sprint(item.ID) == slug {
return cloneContent(item), nil return cloneContent(item), nil
} }
} }
@ -232,7 +233,7 @@ func (s *ContentService) UpdateAs(_ context.Context, c Content, actor string) (C
func (s *ContentService) update(c Content, actor string) (Content, error) { func (s *ContentService) update(c Content, actor string) (Content, error) {
c.Slug = strings.ToLower(strings.TrimSpace(c.Slug)) c.Slug = strings.ToLower(strings.TrimSpace(c.Slug))
if !validSlug(c.Slug) || strings.TrimSpace(c.Title) == "" || strings.TrimSpace(c.Body) == "" { if strings.TrimSpace(c.Title) == "" || strings.TrimSpace(c.Body) == "" {
return Content{}, errors.New("invalid content") return Content{}, errors.New("invalid content")
} }
c.Badges = normalizeBadges(c.Badges) c.Badges = normalizeBadges(c.Badges)
@ -244,7 +245,7 @@ func (s *ContentService) update(c Content, actor string) (Content, error) {
index := -1 index := -1
for i := range s.items { for i := range s.items {
if s.items[i].Slug == c.Slug { if s.items[i].Slug == c.Slug || fmt.Sprint(s.items[i].ID) == c.Slug {
index = i index = i
break break
} }
@ -259,8 +260,14 @@ func (s *ContentService) update(c Content, actor string) (Content, error) {
c.ID = current.ID c.ID = current.ID
c.CreatedAt = current.CreatedAt c.CreatedAt = current.CreatedAt
c.UpdatedAt = time.Now().Truncate(time.Second) c.UpdatedAt = time.Now().Truncate(time.Second)
if c.Category == "" {
c.Category = normalizeCategory(current.Category) c.Category = normalizeCategory(current.Category)
} else {
c.Category = normalizeCategory(c.Category)
}
if c.Author == "" {
c.Author = current.Author c.Author = current.Author
}
if c.Status == "published" && c.PublishedAt == nil { if c.Status == "published" && c.PublishedAt == nil {
now := time.Now().Truncate(time.Second) now := time.Now().Truncate(time.Second)
c.PublishedAt = &now c.PublishedAt = &now
@ -279,6 +286,99 @@ func (s *ContentService) update(c Content, actor string) (Content, error) {
return cloneContent(c), nil return cloneContent(c), nil
} }
func (s *ContentService) Create(_ context.Context, c Content) (Content, error) {
c.Title = strings.TrimSpace(c.Title)
c.Body = strings.TrimSpace(c.Body)
if c.Title == "" || c.Body == "" {
return Content{}, errors.New("invalid content")
}
c.Status = normalizeStatus(c.Status)
if c.Slug == "" {
c.Slug = generateSlug(c.Title)
}
c.Slug = strings.ToLower(strings.TrimSpace(c.Slug))
if !validSlug(c.Slug) {
c.Slug = generateSlug(c.Title)
}
if !validSlug(c.Slug) {
c.Slug = "post"
}
c.Category = normalizeCategory(c.Category)
c.Badges = normalizeBadges(c.Badges)
now := time.Now()
c.CreatedAt = now
c.UpdatedAt = now
if c.Status == "published" {
if c.PublishedAt == nil {
t := now
c.PublishedAt = &t
}
} else {
c.PublishedAt = nil
}
c.Published = (c.Status == "published")
s.mu.Lock()
defer s.mu.Unlock()
if s.nextID == 0 {
max := uint(0)
for _, it := range s.items {
if it.ID > max {
max = it.ID
}
}
s.nextID = max + 1
}
c.ID = s.nextID
s.nextID++
for _, it := range s.items {
if it.Slug == c.Slug {
c.Slug = fmt.Sprintf("%s-%d", c.Slug, c.ID)
break
}
}
created := cloneContent(c)
s.items = append(s.items, created)
sortContent(s.items)
snapshot, _ := json.Marshal(created)
s.history = append(s.history, contentRevision{ContentID: created.ID, Operation: "created", CreatedAt: created.UpdatedAt, Snapshot: string(snapshot)})
if err := s.persistLocked(); err != nil {
return Content{}, err
}
return cloneContent(created), nil
}
func (s *ContentService) Delete(_ context.Context, slug string) error {
slug = strings.ToLower(strings.TrimSpace(slug))
s.mu.Lock()
defer s.mu.Unlock()
for i, it := range s.items {
if it.Slug == slug || fmt.Sprint(it.ID) == slug {
s.items = append(s.items[:i], s.items[i+1:]...)
snapshot, _ := json.Marshal(it)
s.history = append(s.history, contentRevision{ContentID: it.ID, Operation: "deleted", CreatedAt: time.Now(), Snapshot: string(snapshot)})
if err := s.persistLocked(); err != nil {
return err
}
return nil
}
}
return errors.New("content not found")
}
func (s *ContentService) Reset(_ context.Context) error {
s.mu.Lock()
defer s.mu.Unlock()
s.items = nil
s.nextID = 1
s.history = nil
if err := s.persistLocked(); err != nil {
return err
}
return nil
}
// storedContent is the on-disk shape of an article. Identifiers are positional // storedContent is the on-disk shape of an article. Identifiers are positional
// so the JSON file stays readable and diff-friendly. // so the JSON file stays readable and diff-friendly.
type storedContent struct { type storedContent struct {
@ -469,6 +569,40 @@ func categoryDisplay(slug string) string {
return strings.ToUpper(slug[:1]) + slug[1:] return strings.ToUpper(slug[:1]) + slug[1:]
} }
func generateSlug(title string) string {
title = strings.TrimSpace(strings.ToLower(title))
if title == "" {
return "post"
}
// simple slugification
parts := strings.Fields(title)
var out strings.Builder
for i, p := range parts {
cleaned := strings.Map(func(r rune) rune {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
return r
}
return '-'
}, p)
cleaned = strings.Trim(cleaned, "-")
if cleaned == "" {
continue
}
if i > 0 {
out.WriteByte('-')
}
out.WriteString(cleaned)
}
if out.Len() == 0 {
return "post"
}
res := out.String()
if len(res) > 60 {
res = res[:60]
}
return strings.Trim(res, "-")
}
var slugRE = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`) var slugRE = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)
func validSlug(s string) bool { return len(s) > 0 && len(s) <= 200 && slugRE.MatchString(s) } func validSlug(s string) bool { return len(s) > 0 && len(s) <= 200 && slugRE.MatchString(s) }

View file

@ -1,6 +1,7 @@
package web package web
import ( import (
"bytes"
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
@ -8,22 +9,39 @@ import (
"net/http" "net/http"
"strings" "strings"
"github.com/example/cms/internal/security" goloose "github.com/danyel/go-loose/client"
"github.com/example/cms/internal/service"
"github.com/danyel/go-tell/internal/golooseauth"
"github.com/danyel/go-tell/internal/security"
"github.com/danyel/go-tell/internal/service"
) )
// Handler serves the CMS API and the embedded React app from one binary. // Handler serves the CMS API and the embedded React app from one binary.
// Reading content is anonymous; every mutation requires the identity asserted by // Reading content is anonymous; every mutation requires an authenticated
// the upstream SSO proxy. // editor: a Go Loose browser session, the identity asserted by the upstream
// SSO proxy, or a demo user.
type Handler struct { type Handler struct {
content service.IContentService content service.IContentService
assets fs.FS assets fs.FS
sso security.ITokenVerifier sso security.ITokenVerifier
browser *golooseauth.Auth
demo *service.DemoAuth demo *service.DemoAuth
// themeOrigin is the go-bananas origin supplying the shared styling contract.
// Empty means the shell keeps its own fallbacks, which is the default.
themeOrigin string
} }
func NewHandler(content service.IContentService, assets fs.FS, sso security.ITokenVerifier, demo ...*service.DemoAuth) *Handler { // WithThemeOrigin sets the origin the shared styling contract is served from.
h := &Handler{content: content, assets: assets, sso: sso} //
// A setter rather than a constructor argument, so that adding this does not change
// the signature every existing caller and test passes through.
func (h *Handler) WithThemeOrigin(origin string) *Handler {
h.themeOrigin = origin
return h
}
func NewHandler(content service.IContentService, assets fs.FS, sso security.ITokenVerifier, browser *golooseauth.Auth, demo ...*service.DemoAuth) *Handler {
h := &Handler{content: content, assets: assets, sso: sso, browser: browser}
if len(demo) > 0 { if len(demo) > 0 {
h.demo = demo[0] h.demo = demo[0]
} }
@ -35,22 +53,82 @@ func (h *Handler) Routes(origins string) http.Handler {
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
jsonWrite(w, http.StatusOK, map[string]string{"status": "ok"}) jsonWrite(w, http.StatusOK, map[string]string{"status": "ok"})
}) })
mux.HandleFunc(golooseauth.LoginPath, h.login)
mux.HandleFunc(golooseauth.CallbackPath, h.callback)
mux.HandleFunc(golooseauth.LogoutPath, h.logout)
mux.HandleFunc("/api/auth/session", h.session) mux.HandleFunc("/api/auth/session", h.session)
mux.HandleFunc("/api/auth/sudo", h.sudo) mux.HandleFunc("/api/auth/sudo", h.sudo)
mux.HandleFunc("/api/auth/users", h.users) mux.HandleFunc("/api/auth/users", h.users)
mux.HandleFunc("/api/protected", h.protected) mux.HandleFunc("/api/protected", h.protected)
mux.HandleFunc("/api/test/protected", h.protected) mux.HandleFunc("/api/content", h.contentCollection)
mux.HandleFunc("/api/content", h.contentList) mux.HandleFunc("/api/dev/null", h.contentCollection)
mux.HandleFunc("/api/content/categories", h.contentCategories) mux.HandleFunc("/api/content/categories", h.contentCategories)
mux.HandleFunc("/api/content/badges", h.contentBadges) mux.HandleFunc("/api/content/badges", h.contentBadges)
mux.HandleFunc("/api/content/clear", h.adminReset)
mux.HandleFunc("/api/content/", h.contentDetail) mux.HandleFunc("/api/content/", h.contentDetail)
mux.HandleFunc("/api/dev/null/", h.contentDetail)
mux.HandleFunc("/api/admin/reset", h.adminReset)
mux.HandleFunc("/", h.singlePageApp) mux.HandleFunc("/", h.singlePageApp)
return cors(mux, origins, h.sso.HeaderName()) return cors(mux, origins, h.sso.HeaderName())
} }
// session reports who the caller is. When Go Loose is configured the response
// also carries the login and logout endpoints so the UI can offer the browser
// login instead of a proxy redirect.
func (h *Handler) session(w http.ResponseWriter, r *http.Request) { func (h *Handler) session(w http.ResponseWriter, r *http.Request) {
username, authenticated := h.identity(r) username, authenticated := h.identity(r)
jsonWrite(w, http.StatusOK, map[string]any{"authenticated": authenticated, "canEdit": authenticated, "username": username}) response := map[string]any{"authenticated": authenticated, "canEdit": authenticated, "username": username}
if user, ok := h.browserUser(r); ok {
response["email"] = user.Email
response["displayName"] = user.DisplayName
}
if h.demo != nil {
response["demo"] = true
if authenticated {
for _, user := range h.demo.Users() {
if user.Username == username {
response["displayName"] = user.Name
response["email"] = user.Username
break
}
}
}
}
if h.browser != nil {
response["loginUrl"] = golooseauth.LoginPath
response["logoutUrl"] = golooseauth.LogoutPath
}
jsonWrite(w, http.StatusOK, response)
}
func (h *Handler) login(w http.ResponseWriter, r *http.Request) {
if h.browser == nil {
writeAuthUnavailable(w)
return
}
h.browser.LoginHandler(w, r)
}
func (h *Handler) callback(w http.ResponseWriter, r *http.Request) {
if h.browser == nil {
writeAuthUnavailable(w)
return
}
h.browser.CallbackHandler(w, r)
}
func (h *Handler) logout(w http.ResponseWriter, r *http.Request) {
if h.browser == nil {
writeAuthUnavailable(w)
return
}
h.browser.LogoutHandler(w, r)
}
func writeAuthUnavailable(w http.ResponseWriter) {
jsonWrite(w, http.StatusNotFound, map[string]string{
"error": "Go Loose login is not configured: set GO_LOOSE_CLIENT_ID and GO_LOOSE_CLIENT_SECRET, then restart the server",
})
} }
func (h *Handler) protected(w http.ResponseWriter, r *http.Request) { func (h *Handler) protected(w http.ResponseWriter, r *http.Request) {
@ -90,6 +168,17 @@ func (h *Handler) sudo(w http.ResponseWriter, r *http.Request) {
jsonWrite(w, http.StatusOK, map[string]any{"token": token, "user": user}) jsonWrite(w, http.StatusOK, map[string]any{"token": token, "user": user})
} }
func (h *Handler) contentCollection(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
h.contentList(w, r)
case http.MethodPost:
h.contentCreate(w, r)
default:
jsonWrite(w, http.StatusMethodNotAllowed, nil)
}
}
func (h *Handler) contentList(w http.ResponseWriter, r *http.Request) { func (h *Handler) contentList(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet { if r.Method != http.MethodGet {
jsonWrite(w, http.StatusMethodNotAllowed, nil) jsonWrite(w, http.StatusMethodNotAllowed, nil)
@ -114,6 +203,32 @@ func (h *Handler) contentList(w http.ResponseWriter, r *http.Request) {
jsonWrite(w, http.StatusOK, map[string]any{"items": items, "limit": limit, "offset": offset}) jsonWrite(w, http.StatusOK, map[string]any{"items": items, "limit": limit, "offset": offset})
} }
func (h *Handler) contentCreate(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
jsonWrite(w, http.StatusMethodNotAllowed, nil)
return
}
username, authenticated := h.identity(r)
if !authenticated {
jsonWrite(w, http.StatusUnauthorized, map[string]string{"error": "authentication required"})
return
}
var item service.Content
if !decode(r, &item) {
jsonWrite(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
if strings.TrimSpace(item.Author) == "" {
item.Author = username
}
created, err := h.content.Create(r.Context(), item)
if err != nil {
jsonWrite(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
jsonWrite(w, http.StatusCreated, created)
}
func (h *Handler) contentCategories(w http.ResponseWriter, r *http.Request) { func (h *Handler) contentCategories(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet { if r.Method != http.MethodGet {
jsonWrite(w, http.StatusMethodNotAllowed, nil) jsonWrite(w, http.StatusMethodNotAllowed, nil)
@ -142,6 +257,7 @@ func (h *Handler) contentBadges(w http.ResponseWriter, r *http.Request) {
func (h *Handler) contentDetail(w http.ResponseWriter, r *http.Request) { func (h *Handler) contentDetail(w http.ResponseWriter, r *http.Request) {
slug := strings.TrimPrefix(r.URL.Path, "/api/content/") slug := strings.TrimPrefix(r.URL.Path, "/api/content/")
slug = strings.TrimPrefix(slug, "/api/dev/null/")
switch r.Method { switch r.Method {
case http.MethodGet: case http.MethodGet:
item, err := h.content.Get(r.Context(), slug) item, err := h.content.Get(r.Context(), slug)
@ -177,11 +293,25 @@ func (h *Handler) contentDetail(w http.ResponseWriter, r *http.Request) {
} }
jsonWrite(w, http.StatusOK, saved) jsonWrite(w, http.StatusOK, saved)
case http.MethodDelete:
_, authenticated := h.identity(r)
if !authenticated {
jsonWrite(w, http.StatusUnauthorized, map[string]string{"error": "authentication required"})
return
}
if err := h.content.Delete(r.Context(), slug); err != nil {
jsonWrite(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
jsonWrite(w, http.StatusNoContent, nil)
default: default:
jsonWrite(w, http.StatusMethodNotAllowed, nil) jsonWrite(w, http.StatusMethodNotAllowed, nil)
} }
} }
// identity resolves the editor behind a request. The demo profile wins, then
// the SSO proxy, then the Go Loose browser session: the session is checked last
// because it costs a call to the identity provider.
func (h *Handler) identity(r *http.Request) (string, bool) { func (h *Handler) identity(r *http.Request) (string, bool) {
if h.demo != nil { if h.demo != nil {
token := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")) token := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "))
@ -192,9 +322,48 @@ func (h *Handler) identity(r *http.Request) (string, bool) {
if h.sso.Authenticated(r) { if h.sso.Authenticated(r) {
return "sso-owner", true return "sso-owner", true
} }
if user, ok := h.browserUser(r); ok {
return editorName(user), true
}
return "", false return "", false
} }
func (h *Handler) adminReset(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
jsonWrite(w, http.StatusMethodNotAllowed, nil)
return
}
_, authenticated := h.identity(r)
if !authenticated {
jsonWrite(w, http.StatusUnauthorized, map[string]string{"error": "authentication required"})
return
}
if err := h.content.Reset(r.Context()); err != nil {
jsonWrite(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
jsonWrite(w, http.StatusOK, map[string]string{"status": "reset"})
}
// browserUser returns the Go Loose user carried by the request session, if any.
func (h *Handler) browserUser(r *http.Request) (goloose.User, bool) {
if h.browser == nil {
return goloose.User{}, false
}
user, ok := h.browser.SessionUser(r)
if !ok {
return goloose.User{}, false
}
return user, true
}
func editorName(user goloose.User) string {
if name := strings.TrimSpace(user.DisplayName); name != "" {
return name
}
return user.Email
}
// singlePageApp serves the embedded build and falls back to index.html so the // singlePageApp serves the embedded build and falls back to index.html so the
// client-side router owns every non-API path. // client-side router owns every non-API path.
func (h *Handler) singlePageApp(w http.ResponseWriter, r *http.Request) { func (h *Handler) singlePageApp(w http.ResponseWriter, r *http.Request) {
@ -215,9 +384,52 @@ func (h *Handler) singlePageApp(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
body := applyThemeOrigin(index, h.themeOrigin)
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Cache-Control", "no-cache")
_, _ = w.Write(index) _, _ = w.Write(body)
}
// applyThemeOrigin adds the shared styling contract to the shell when an origin is
// configured, and adds nothing when one is not.
//
// Appending rather than substituting, which is the opposite of the obvious approach
// and the reason it is worth a comment. A placeholder in index.html has to survive
// two things that both fail quietly:
//
// - The bundler resolves a <script src> at build time. A placeholder is not a
// resolvable module, so the build fails with an error that points at the HTML
// rather than at the cause.
// - An unset or whitespace-only environment variable substitutes an empty or blank
// origin, producing a relative URL. The browser then requests this application's
// own origin, where neither path exists, and every page load logs a 404 that
// reads as a styling bug in the wrong service.
//
// Adding nothing in the unset case is correct rather than degraded: the stylesheet's
// every colour is written as var(--bn-role, <the colour it used to be>), so the
// application renders exactly as it always did.
func applyThemeOrigin(index []byte, base string) []byte {
origin := strings.TrimRight(strings.TrimSpace(base), "/")
if origin == "" {
return index
}
// Injected just before </head> so the stylesheet is parsed before the body
// renders: a contract that arrives late produces a flash of the fallback palette.
tag := "\n " +
`<link rel="stylesheet" href="` + origin + `/rt/v1/contract.css" data-theme-contract>` + "\n " +
`<script type="module" src="` + origin + `/rt/v1/components.js"></script>` + "\n"
head := []byte("</head>")
at := bytes.Index(index, head)
if at < 0 {
return index
}
out := make([]byte, 0, len(index)+len(tag))
out = append(out, index[:at]...)
out = append(out, tag...)
out = append(out, index[at:]...)
return out
} }
func decode(r *http.Request, v any) bool { return json.NewDecoder(r.Body).Decode(v) == nil } func decode(r *http.Request, v any) bool { return json.NewDecoder(r.Body).Decode(v) == nil }
@ -239,7 +451,7 @@ func cors(next http.Handler, origin, ssoHeader string) http.Handler {
w.Header().Set("Access-Control-Allow-Origin", origin) w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Vary", "Origin") w.Header().Set("Vary", "Origin")
w.Header().Set("Access-Control-Allow-Headers", allowed) w.Header().Set("Access-Control-Allow-Headers", allowed)
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, OPTIONS") w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
if r.Method == http.MethodOptions { if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
return return

View file

@ -8,8 +8,10 @@ import (
"testing" "testing"
"testing/fstest" "testing/fstest"
"github.com/example/cms/internal/security" "github.com/danyel/go-tell/internal/config"
"github.com/example/cms/internal/service" "github.com/danyel/go-tell/internal/golooseauth"
"github.com/danyel/go-tell/internal/security"
"github.com/danyel/go-tell/internal/service"
) )
type fakeContent struct{ updated bool } type fakeContent struct{ updated bool }
@ -30,13 +32,26 @@ func (f *fakeContent) Update(_ context.Context, c service.Content) (service.Cont
f.updated = true f.updated = true
return c, nil return c, nil
} }
func (f *fakeContent) Create(_ context.Context, c service.Content) (service.Content, error) {
return c, nil
}
func (f *fakeContent) Delete(_ context.Context, _ string) error {
return nil
}
func (f *fakeContent) Reset(_ context.Context) error {
return nil
}
func newTestRoutes(content *fakeContent) http.Handler { func newTestRoutes(content *fakeContent, browser ...*golooseauth.Auth) http.Handler {
assets := fstest.MapFS{ assets := fstest.MapFS{
"index.html": &fstest.MapFile{Data: []byte("<!doctype html><div id=\"root\"></div>")}, "index.html": &fstest.MapFile{Data: []byte("<!doctype html><div id=\"root\"></div>")},
"assets/index.js": &fstest.MapFile{Data: []byte("console.log('cms')")}, "assets/index.js": &fstest.MapFile{Data: []byte("console.log('go-tell')")},
} }
return NewHandler(content, assets, security.NewSSOTokenVerifier("X-SSO-Token", "", "")).Routes("http://localhost:5173") var auth *golooseauth.Auth
if len(browser) > 0 {
auth = browser[0]
}
return NewHandler(content, assets, security.NewSSOTokenVerifier("X-SSO-Token", "", ""), auth).Routes("http://localhost:5173")
} }
func TestAnonymousCanReadButNotEdit(t *testing.T) { func TestAnonymousCanReadButNotEdit(t *testing.T) {
@ -89,6 +104,68 @@ func TestProxyIdentityCanEdit(t *testing.T) {
} }
} }
func TestGoLooseLoginRedirectsToTheIdentityProvider(t *testing.T) {
routes := newTestRoutes(&fakeContent{}, testGoLooseAuth(t))
response := httptest.NewRecorder()
routes.ServeHTTP(response, httptest.NewRequest(http.MethodGet, golooseauth.LoginPath, nil))
if response.Code != http.StatusFound {
t.Fatalf("login status = %d, body = %s", response.Code, response.Body.String())
}
location := response.Header().Get("Location")
if !strings.HasPrefix(location, "https://tell.auth.dev/connect/authorize?") ||
!strings.Contains(location, "redirect_uri=https%3A%2F%2Ftell.dev%2Fapi%2Fauth%2Fcallback") {
t.Fatalf("unexpected login redirect: %s", location)
}
}
func TestSessionAdvertisesGoLooseEndpoints(t *testing.T) {
routes := newTestRoutes(&fakeContent{}, testGoLooseAuth(t))
response := httptest.NewRecorder()
routes.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/api/auth/session", nil))
if response.Code != http.StatusOK {
t.Fatalf("session status = %d", response.Code)
}
body := response.Body.String()
for _, want := range []string{`"authenticated":false`, `"loginUrl":"/api/auth/login"`, `"logoutUrl":"/api/auth/logout"`} {
if !strings.Contains(body, want) {
t.Fatalf("session = %s, want %s", body, want)
}
}
}
func TestSessionWithoutGoLooseHasNoLoginURL(t *testing.T) {
response := httptest.NewRecorder()
newTestRoutes(&fakeContent{}).ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/api/auth/session", nil))
if strings.Contains(response.Body.String(), "loginUrl") {
t.Fatalf("session = %s, want no login endpoint", response.Body.String())
}
}
func TestGoLooseRoutesAreNotFoundWhenUnconfigured(t *testing.T) {
routes := newTestRoutes(&fakeContent{})
for _, path := range []string{golooseauth.LoginPath, golooseauth.CallbackPath, golooseauth.LogoutPath} {
response := httptest.NewRecorder()
routes.ServeHTTP(response, httptest.NewRequest(http.MethodGet, path, nil))
if response.Code != http.StatusNotFound || !strings.Contains(response.Body.String(), "GO_LOOSE_CLIENT_ID") {
t.Fatalf("%s without Go Loose = %d %s", path, response.Code, response.Body.String())
}
}
}
func testGoLooseAuth(t *testing.T) *golooseauth.Auth {
t.Helper()
auth, err := golooseauth.New(config.GoLooseConfig{
Tenant: "tell", AuthDomain: "auth.dev", AppDomain: "tell.dev",
ClientID: "glc_tell", ClientSecret: "secret",
})
if err != nil {
t.Fatal(err)
}
return auth
}
func TestServesEmbeddedAppForClientRoutes(t *testing.T) { func TestServesEmbeddedAppForClientRoutes(t *testing.T) {
routes := newTestRoutes(&fakeContent{}) routes := newTestRoutes(&fakeContent{})
@ -103,4 +180,48 @@ func TestServesEmbeddedAppForClientRoutes(t *testing.T) {
if page.Code != http.StatusOK || !strings.Contains(page.Body.String(), `id="root"`) { if page.Code != http.StatusOK || !strings.Contains(page.Body.String(), `id="root"`) {
t.Fatalf("spa fallback status = %d body = %q", page.Code, page.Body.String()) t.Fatalf("spa fallback status = %d body = %q", page.Code, page.Body.String())
} }
devNull := httptest.NewRecorder()
routes.ServeHTTP(devNull, httptest.NewRequest(http.MethodGet, "/dev/null", nil))
if devNull.Code != http.StatusOK || !strings.Contains(devNull.Body.String(), `id="root"`) {
t.Fatalf("dev/null fallback status = %d body = %q", devNull.Code, devNull.Body.String())
}
backlogRc := httptest.NewRecorder()
routes.ServeHTTP(backlogRc, httptest.NewRequest(http.MethodGet, "/etc/backlog.rc", nil))
if backlogRc.Code != http.StatusOK || !strings.Contains(backlogRc.Body.String(), `id="root"`) {
t.Fatalf("backlog.rc fallback status = %d body = %q", backlogRc.Code, backlogRc.Body.String())
}
}
func TestContentCRUDAndClear(t *testing.T) {
content := &fakeContent{}
routes := newTestRoutes(content)
// Create with SSO header
postReq := httptest.NewRequest(http.MethodPost, "/api/content", strings.NewReader(`{"title":"New Post","body":"Content body"}`))
postReq.Header.Set("X-SSO-Token", "valid")
postRec := httptest.NewRecorder()
routes.ServeHTTP(postRec, postReq)
if postRec.Code != http.StatusCreated {
t.Fatalf("create status = %d, want %d", postRec.Code, http.StatusCreated)
}
// Delete with SSO header
delReq := httptest.NewRequest(http.MethodDelete, "/api/content/post", nil)
delReq.Header.Set("X-SSO-Token", "valid")
delRec := httptest.NewRecorder()
routes.ServeHTTP(delRec, delReq)
if delRec.Code != http.StatusNoContent {
t.Fatalf("delete status = %d, want %d", delRec.Code, http.StatusNoContent)
}
// Reset with SSO header
resetReq := httptest.NewRequest(http.MethodPost, "/api/admin/reset", nil)
resetReq.Header.Set("X-SSO-Token", "valid")
resetRec := httptest.NewRecorder()
routes.ServeHTTP(resetRec, resetReq)
if resetRec.Code != http.StatusOK {
t.Fatalf("reset status = %d, want %d", resetRec.Code, http.StatusOK)
}
} }

View file

@ -0,0 +1,75 @@
package web
import (
"strings"
"testing"
)
const shell = `<!doctype html>
<html lang="en">
<head>
<title>Urpi's backlog</title>
</head>
<body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body>
</html>`
// TestThemeLinesAreAddedWhenConfigured is the whole feature: the contract reaches
// the page.
func TestThemeLinesAreAddedWhenConfigured(t *testing.T) {
got := string(applyThemeOrigin([]byte(shell), "https://bananas-dev.urpi.be"))
if !strings.Contains(got, `href="https://bananas-dev.urpi.be/rt/v1/contract.css"`) {
t.Errorf("the contract stylesheet was not added:\n%s", got)
}
if !strings.Contains(got, `src="https://bananas-dev.urpi.be/rt/v1/components.js"`) {
t.Errorf("the control module was not added:\n%s", got)
}
// Injected before </head>, so the stylesheet is parsed before the body renders. A
// contract that arrives late shows a flash of the fallback palette.
headClose := strings.Index(got, "</head>")
link := strings.Index(got, "contract.css")
if link < 0 || link > headClose {
t.Errorf("the contract link is not inside the head (link=%d, head=%d)", link, headClose)
}
// The application script must survive untouched.
if !strings.Contains(got, `src="/src/main.tsx"`) {
t.Error("the application script was altered")
}
}
func TestThemeOriginTrailingSlashIsTrimmed(t *testing.T) {
for _, base := range []string{
"https://bananas-dev.urpi.be/",
"https://bananas-dev.urpi.be///",
} {
got := string(applyThemeOrigin([]byte(shell), base))
if strings.Contains(got, "be//") {
t.Errorf("base %q produced a doubled slash:\n%s", base, got)
}
}
}
// TestNothingIsAddedWhenNoOriginIsConfigured is the branch that matters most: an
// unset or blank origin must leave the page byte-identical, because the stylesheet's
// var() fallbacks are what carry the application in that case.
func TestNothingIsAddedWhenNoOriginIsConfigured(t *testing.T) {
for _, base := range []string{"", " ", "\t", "/"} {
got := applyThemeOrigin([]byte(shell), base)
if string(got) != shell {
t.Errorf("base %q modified the shell:\n%s", base, got)
}
if strings.Contains(string(got), "rt/v1") {
t.Errorf("base %q left a contract reference behind:\n%s", base, got)
}
}
}
// TestShellWithoutHeadIsUnchanged covers a malformed shell. Returning the input
// unchanged is better than corrupting it, and better than a 500 on a page that would
// otherwise have rendered.
func TestShellWithoutHeadIsUnchanged(t *testing.T) {
odd := []byte("<html><body>no head</body></html>")
if got := applyThemeOrigin(odd, "https://bananas-dev.urpi.be"); string(got) != string(odd) {
t.Errorf("a shell with no head was modified:\n%s", got)
}
}

View file

@ -0,0 +1,9 @@
-- +goose Up
-- +goose StatementBegin
SELECT 'up SQL query';
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
SELECT 'down SQL query';
-- +goose StatementEnd

Binary file not shown.

Before

Width:  |  Height:  |  Size: 254 KiB

View file

@ -3,8 +3,24 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#1e1e2e" /> <meta name="theme-color" content="#120826" />
<title>Urpi's backlog</title> <title>Urpi's backlog</title>
<!--
The shared styling contract and its two controls are added to this shell by the
server, from CMS_THEME_BASE_URL. They are not written here because a build-time
placeholder has to survive two things that both break quietly: the bundler tries
to resolve a module URL at build time and fails the build, and an unset or
whitespace-only environment variable produces a relative URL that 404s on every
page load.
With the origin unset, nothing is added and the stylesheet's own fallbacks carry
the application: every colour in styles.css is written as
var(--bn-role, <the colour it used to be>), so the page renders exactly as it
did before the contract existed.
See internal/web/handlers.go, applyThemeOrigin.
-->
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View file

@ -1,13 +1,14 @@
{ {
"name": "cms-ui", "name": "go-tell-ui",
"version": "0.1.0", "version": "0.1.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "cms-ui", "name": "go-tell-ui",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"lucide-react": "1.52.0",
"react": "^19.1.1", "react": "^19.1.1",
"react-dom": "^19.1.1", "react-dom": "^19.1.1",
"react-router-dom": "^7.9.1" "react-router-dom": "^7.9.1"
@ -1266,7 +1267,7 @@
"version": "19.3.0", "version": "19.3.0",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz",
"integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"csstype": "^3.2.2" "csstype": "^3.2.2"
@ -1395,7 +1396,7 @@
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/debug": { "node_modules/debug": {
@ -1561,6 +1562,21 @@
"yallist": "^3.0.2" "yallist": "^3.0.2"
} }
}, },
"node_modules/lucide-react": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.52.0.tgz",
"integrity": "sha512-TUgPtl5ZI9WgxOcbu4ckaC5B3l1qxPQrCMgb6OfUB9owx/OvSJfcf93SMgUWAxRw5/1XDfh9uI5F5iNGXCcGDQ==",
"license": "ISC",
"peerDependencies": {
"@types/react": "*",
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/ms": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",

View file

@ -1,14 +1,15 @@
{ {
"name": "cms-ui", "name": "go-tell-ui",
"private": true, "private": true,
"version": "0.1.0", "version": "0.1.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"typecheck": "tsc --noEmit" "typecheck": "tsc --build --force"
}, },
"dependencies": { "dependencies": {
"lucide-react": "1.52.0",
"react": "^19.1.1", "react": "^19.1.1",
"react-dom": "^19.1.1", "react-dom": "^19.1.1",
"react-router-dom": "^7.9.1" "react-router-dom": "^7.9.1"

View file

@ -1,4 +1,5 @@
import {useEffect, useRef, useState} from 'react' import {useEffect, useRef, useState, type ReactNode} from 'react'
import {ArrowLeft, X} from 'lucide-react'
import {Link, Navigate, Route, Routes, useParams} from 'react-router-dom' import {Link, Navigate, Route, Routes, useParams} from 'react-router-dom'
import { import {
type Content, type Content,
@ -12,6 +13,7 @@ import {
import {useAuth} from './auth' import {useAuth} from './auth'
import {ContentCard} from './components/ContentCard' import {ContentCard} from './components/ContentCard'
import {ContentFilters} from './components/ContentFilters' import {ContentFilters} from './components/ContentFilters'
import {Avatar, LoginGate} from './components/AccountMenu'
import {Shell} from './components/Shell' import {Shell} from './components/Shell'
import {type Badge, type Category} from './models' import {type Badge, type Category} from './models'
@ -81,7 +83,7 @@ function ContentList() {
return <Shell> return <Shell>
<section className="content-page"> <section className="content-page">
<div className="page-heading"> <div className="page-heading">
<div><span className="eyebrow">$ cd ~/backlog && ls</span></div> <div><span className="eyebrow">$ cd /dev/null && ls</span></div>
</div> </div>
<ContentFilters categories={categories} badgeOptions={badgeOptions} category={category} status={status} <ContentFilters categories={categories} badgeOptions={badgeOptions} category={category} status={status}
badges={badges} badgeInput={badgeInput} onCategoryChange={setCategory} onStatusChange={setStatus} badges={badges} badgeInput={badgeInput} onCategoryChange={setCategory} onStatusChange={setStatus}
@ -136,8 +138,8 @@ function ContentDetail() {
<div className="card"><p>Loading content…</p></div> <div className="card"><p>Loading content…</p></div>
</Shell> </Shell>
if (error || !item || !draft) return <Shell> if (error || !item || !draft) return <Shell>
<div className="card"><p className="error" role="alert">{error ?? 'Content not found.'}</p><Link to="/content">Back <div className="card"><p className="error" role="alert">{error ?? 'Content not found.'}</p><Link to="/dev/null">Back
to content</Link></div> to /dev/null</Link></div>
</Shell> </Shell>
const save = async () => { const save = async () => {
setSaving(true); setSaving(true);
@ -161,7 +163,9 @@ function ContentDetail() {
} }
return <Shell> return <Shell>
<article className="detail-page"> <article className="detail-page">
<Link className="back-link" to="/content">← All content</Link> <Link className="back-link" to="/dev/null">
<ArrowLeft aria-hidden="true"/> All content (/dev/null)
</Link>
{error && <p className="error" role="alert">{error}</p>} {error && <p className="error" role="alert">{error}</p>}
{editing ? <div className="editor"> {editing ? <div className="editor">
<label>Title<input value={draft.title} <label>Title<input value={draft.title}
@ -181,7 +185,7 @@ function ContentDetail() {
<button type="button" aria-label={`Remove ${badge}`} onClick={() => setDraft({ <button type="button" aria-label={`Remove ${badge}`} onClick={() => setDraft({
...draft, ...draft,
badges: (draft.badges ?? []).filter(value => value !== badge) badges: (draft.badges ?? []).filter(value => value !== badge)
})}>×</button></span>)} })}><X aria-hidden="true"/></button></span>)}
<input list="detail-badge-options" value={badgeInput} placeholder="Type a badge and press Enter" <input list="detail-badge-options" value={badgeInput} placeholder="Type a badge and press Enter"
onChange={e => setBadgeInput(e.target.value)} onKeyDown={e => { onChange={e => setBadgeInput(e.target.value)} onKeyDown={e => {
if (e.key !== 'Enter') return if (e.key !== 'Enter') return
@ -215,21 +219,49 @@ function ContentDetail() {
</Shell> </Shell>
} }
import {BacklogRc} from './components/BacklogRc'
function RequireAuth({title, children}: {title: string; children: ReactNode}) {
const {authenticated, loading} = useAuth()
if (loading) return <Shell><p className="muted">Checking session…</p></Shell>
if (!authenticated) return <Shell><LoginGate title={title}/></Shell>
return children
}
function ProfilePage() {
const {avatarUrl, displayName, email, username} = useAuth()
const name = (displayName || username || 'Profile').trim()
return <Shell>
<article className="detail-page profile-page">
<span className="eyebrow">account</span>
<div className="profile-card">
<Avatar name={name} url={avatarUrl} size={72}/>
<h1>{name}</h1>
{email && <p className="muted">{email}</p>}
</div>
</article>
</Shell>
}
function ProtectedTest() { function ProtectedTest() {
return <Shell> return <Shell>
<section className="card protected-card"><span className="eyebrow">Owner session</span><h1>Protected <section className="card protected-card"><span className="eyebrow">Owner session</span><h1>Protected
content</h1><p>Your SSO proxy asserted the owner identity for this request.</p><Link content</h1><p>Your session is signed in. This page is not linked until you are.</p><Link
className="primary-button inline-button" to="/content">Browse content</Link></section> className="primary-button inline-button" to="/dev/null">Browse /dev/null</Link></section>
</Shell> </Shell>
} }
export function App() { export function App() {
return <Routes> return <Routes>
<Route path="/" element={<Navigate to="/content" replace/>}/> <Route path="/" element={<Navigate to="/dev/null" replace/>}/>
<Route path="/protected" element={<ProtectedTest/>}/> <Route path="/dev/null" element={<ContentList/>}/>
<Route path="/test" element={<ProtectedTest/>}/> <Route path="/dev/null/:id" element={<ContentDetail/>}/>
<Route path="/content" element={<ContentList/>}/> <Route path="/etc/backlog.rc" element={<RequireAuth title="Backlog management"><BacklogRc/></RequireAuth>}/>
<Route path="/content/:id" element={<ContentDetail/>}/> <Route path="/profile" element={<RequireAuth title="Profile"><ProfilePage/></RequireAuth>}/>
<Route path="*" element={<Navigate to="/content" replace/>}/> <Route path="/content" element={<Navigate to="/dev/null" replace/>}/>
<Route path="/content/:id" element={<Navigate to="/dev/null" replace/>}/>
<Route path="/protected" element={<RequireAuth title="Protected content"><ProtectedTest/></RequireAuth>}/>
<Route path="/test" element={<RequireAuth title="Protected content"><ProtectedTest/></RequireAuth>}/>
<Route path="*" element={<Navigate to="/dev/null" replace/>}/>
</Routes> </Routes>
} }

View file

@ -1,6 +1,6 @@
import {type Badge, type Category, type Content, type ContentListResponse, type ContentUpdate} from './models' import {type Badge, type Category, type Content, type ContentListResponse, type ContentUpdate, type NewContent} from './models'
import {type DemoUser, type SessionResponse} from './models' import {type DemoUser, type SessionResponse} from './models'
export type {Badge, Category, Content, ContentListResponse, ContentUpdate, DemoUser, SessionResponse} export type {Badge, Category, Content, ContentListResponse, ContentUpdate, NewContent, DemoUser, SessionResponse}
const baseUrl = (import.meta.env.VITE_API_BASE_URL ?? '').replace(/\/$/, '') const baseUrl = (import.meta.env.VITE_API_BASE_URL ?? '').replace(/\/$/, '')
export const apiConfig = { export const apiConfig = {
@ -54,7 +54,7 @@ export async function requestSession(): Promise<SessionResponse | null> {
} }
function demoAuthHeaders(): Record<string, string> { function demoAuthHeaders(): Record<string, string> {
const token = window.sessionStorage.getItem('cms-demo-token') const token = window.sessionStorage.getItem('go-tell-token')
return token ? {Authorization: `Bearer ${token}`} : {} return token ? {Authorization: `Bearer ${token}`} : {}
} }
@ -99,7 +99,7 @@ async function contentRequest<T>(path = '', init?: RequestInit): Promise<T> {
headers: { Accept: 'application/json', ...demoAuthHeaders(), ...(init?.body ? { 'Content-Type': 'application/json' } : {}), ...init?.headers }, headers: { Accept: 'application/json', ...demoAuthHeaders(), ...(init?.body ? { 'Content-Type': 'application/json' } : {}), ...init?.headers },
}) })
if (!response.ok) { if (!response.ok) {
if (response.status === 401) throw new Error('Your SSO session is required to edit content.') if (response.status === 401) throw new Error('You have to sign in to edit content.')
throw new Error((await response.text()) || 'Unable to load content.') throw new Error((await response.text()) || 'Unable to load content.')
} }
return (await response.json()) as T return (await response.json()) as T
@ -140,3 +140,34 @@ export function updateContent(id: string, content: ContentUpdate): Promise<Conte
body: JSON.stringify(content), body: JSON.stringify(content),
}).then(normalizeContent) }).then(normalizeContent)
} }
export function createContent(content: NewContent): Promise<Content> {
return contentRequest<RawContent>('', {
method: 'POST',
body: JSON.stringify(content),
}).then(normalizeContent)
}
export async function deleteContent(idOrSlug: string): Promise<void> {
const response = await fetch(apiUrl(`${apiConfig.content}/${encodeURIComponent(idOrSlug)}`), {
method: 'DELETE',
credentials: 'include',
headers: { Accept: 'application/json', ...demoAuthHeaders() },
})
if (!response.ok) {
if (response.status === 401) throw new Error('You must be signed in via SSO to delete content.')
throw new Error((await response.text()) || 'Unable to delete content.')
}
}
export async function clearContentTable(): Promise<void> {
const response = await fetch(apiUrl('/api/admin/reset'), {
method: 'POST',
credentials: 'include',
headers: { Accept: 'application/json', ...demoAuthHeaders() },
})
if (!response.ok) {
if (response.status === 401) throw new Error('You must be signed in via SSO to clear the table.')
throw new Error((await response.text()) || 'Failed to clear table.')
}
}

View file

@ -1,17 +1,23 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react' import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react'
import { apiConfig, requestSession, signInWithGoogle, sudoLogin } from './api' import { apiConfig, apiUrl, requestSession, signInWithGoogle, sudoLogin } from './api'
// There is no sign-in or sign-out: the upstream SSO proxy decides whether the // Signing in is either the Go Loose browser login or a redirect to the
// current request carries the owner's identity. Signing in just sends the // identity-aware proxy, depending on what the API advertises: /api/auth/session
// browser to the configured Google/IdP URL. // returns loginUrl and logoutUrl when Go Loose is configured. The demo profile
// keeps using its own token in sessionStorage.
type AuthState = { type AuthState = {
loading: boolean loading: boolean
authenticated: boolean authenticated: boolean
canEdit: boolean canEdit: boolean
username?: string username?: string
displayName?: string
email?: string
avatarUrl?: string
demo: boolean
error: string | null error: string | null
refresh: () => Promise<void> refresh: () => Promise<void>
canSignIn: boolean canSignIn: boolean
canSignOut: boolean
signIn: () => void signIn: () => void
sudo: (username: string, password: string) => Promise<void> sudo: (username: string, password: string) => Promise<void>
signOut: () => void signOut: () => void
@ -23,6 +29,12 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const [authenticated, setAuthenticated] = useState(false) const [authenticated, setAuthenticated] = useState(false)
const [canEdit, setCanEdit] = useState(false) const [canEdit, setCanEdit] = useState(false)
const [username, setUsername] = useState<string | undefined>() const [username, setUsername] = useState<string | undefined>()
const [displayName, setDisplayName] = useState<string | undefined>()
const [email, setEmail] = useState<string | undefined>()
const [avatarUrl, setAvatarUrl] = useState<string | undefined>()
const [demo, setDemo] = useState(false)
const [loginUrl, setLoginUrl] = useState('')
const [logoutUrl, setLogoutUrl] = useState('')
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@ -33,10 +45,19 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setAuthenticated(session?.authenticated === true) setAuthenticated(session?.authenticated === true)
setCanEdit(session?.canEdit === true) setCanEdit(session?.canEdit === true)
setUsername(session?.username) setUsername(session?.username)
setDisplayName(session?.displayName)
setEmail(session?.email)
setAvatarUrl(session?.avatarUrl)
setDemo(session?.demo === true)
setLoginUrl(session?.loginUrl ?? '')
setLogoutUrl(session?.logoutUrl ?? '')
} catch (err) { } catch (err) {
setAuthenticated(false) setAuthenticated(false)
setCanEdit(false) setCanEdit(false)
setError(err instanceof Error ? err.message : 'Unable to check your SSO session.') setDisplayName(undefined)
setEmail(undefined)
setAvatarUrl(undefined)
setError(err instanceof Error ? err.message : 'Unable to check your session.')
} finally { } finally {
setLoading(false) setLoading(false)
} }
@ -44,25 +65,41 @@ export function AuthProvider({ children }: { children: ReactNode }) {
useEffect(() => { void refresh() }, [refresh]) useEffect(() => { void refresh() }, [refresh])
const signIn = useCallback(() => { signInWithGoogle() }, []) const signIn = useCallback(() => {
if (loginUrl) {
window.location.assign(apiUrl(loginUrl))
return
}
signInWithGoogle()
}, [loginUrl])
const sudo = useCallback(async (username: string, password: string) => { const sudo = useCallback(async (username: string, password: string) => {
const result = await sudoLogin(username, password) const result = await sudoLogin(username, password)
window.sessionStorage.setItem('cms-demo-token', result.token) window.sessionStorage.setItem('go-tell-token', result.token)
setAuthenticated(true) setAuthenticated(true)
setCanEdit(true) setCanEdit(true)
setUsername(result.user.username) setUsername(result.user.username)
setDisplayName(result.user.name)
setEmail(result.user.username)
}, []) }, [])
const signOut = useCallback(() => { const signOut = useCallback(() => {
window.sessionStorage.removeItem('cms-demo-token') if (logoutUrl) {
window.location.assign(apiUrl(logoutUrl))
return
}
window.sessionStorage.removeItem('go-tell-token')
setAuthenticated(false) setAuthenticated(false)
setCanEdit(false) setCanEdit(false)
setUsername(undefined) setUsername(undefined)
}, []) setDisplayName(undefined)
const canSignIn = apiConfig.signIn !== '' setEmail(undefined)
setAvatarUrl(undefined)
}, [logoutUrl])
const canSignIn = apiConfig.signIn !== '' || loginUrl !== ''
const canSignOut = logoutUrl !== ''
const value = useMemo( const value = useMemo(
() => ({ loading, authenticated, canEdit, username, error, refresh, canSignIn, signIn, sudo, signOut }), () => ({ loading, authenticated, canEdit, username, displayName, email, avatarUrl, demo, error, refresh, canSignIn, canSignOut, signIn, sudo, signOut }),
[loading, authenticated, canEdit, username, error, refresh, canSignIn, signIn, sudo, signOut], [loading, authenticated, canEdit, username, displayName, email, avatarUrl, demo, error, refresh, canSignIn, canSignOut, signIn, sudo, signOut],
) )
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider> return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
} }

47
ui/app/src/bananas-elements.d.ts vendored Normal file
View file

@ -0,0 +1,47 @@
/**
* Type declarations for the custom elements the theme service ships.
*
* React 19 passes unknown elements and their attributes through to the DOM, so the
* elements work without a wrapper component. Without these declarations the tag names
* are unknown to the JSX checker and typecheck fails, which is why the elements are
* declared rather than wrapped.
*
* The augmentation targets `react`'s own JSX namespace rather than a global one:
* React 19 removed the global `JSX` namespace, so augmenting it has no effect and the
* tags stay unrecognised.
*/
declare global {
namespace React {
namespace JSX {
/** Reads the published palette list from a theme service. */
interface BananasThemeSelectorProps extends HTMLAttributes<HTMLElement> {
/**
* The service runtime base. Optional: when omitted the control reads the origin
* from the contract stylesheet already linked on the page, and falls back to
* the same-origin path.
*/
base?: string
/** Accessible label. */
label?: string
/** Omit the "no theme" entry. */
compact?: boolean
}
/** Admin chrome, light, or dark. */
interface BananasAppearanceToggleProps extends HTMLAttributes<HTMLElement> {
/** Accessible label. */
label?: string
/** Render the text labels rather than icons only. */
showLabels?: boolean
}
interface IntrinsicElements {
'bananas-theme-selector': BananasThemeSelectorProps
'bananas-appearance-toggle': BananasAppearanceToggleProps
}
}
}
}
export {}

View file

@ -0,0 +1,113 @@
import {useEffect, useId, useRef, useState} from 'react'
import {LogOut, UserRound} from 'lucide-react'
import {Link} from 'react-router-dom'
import {listDemoUsers} from '../api'
import {useAuth} from '../auth'
import {type DemoUser} from '../models'
import {SudoTerminal} from './SudoTerminal'
function initials(name: string) {
const parts = name.split(/[\s@._-]+/).filter(Boolean).slice(0, 2)
const value = parts.map(part => part[0]?.toUpperCase() ?? '').join('')
return value || '?'
}
export function Avatar({name, url, size = 24}: {name: string; url?: string; size?: number}) {
if (url) {
return <img className="avatar" src={url} alt="" width={size} height={size}/>
}
return <span className="avatar avatar-empty" style={{width: size, height: size}}>{initials(name)}</span>
}
function LoginButton() {
const {canSignIn, demo, signIn, sudo} = useAuth()
const [sudoOpen, setSudoOpen] = useState(false)
const [users, setUsers] = useState<DemoUser[]>([])
const start = () => {
if (canSignIn || !demo) {
signIn()
return
}
setSudoOpen(true)
void listDemoUsers().then(setUsers).catch(() => setUsers([]))
}
return <>
<button className="account-login" type="button" onClick={start}>
<span aria-hidden="true">›</span> Log in
</button>
{sudoOpen && <SudoTerminal users={users} onClose={() => setSudoOpen(false)} onLogin={async (username, password) => {
await sudo(username, password)
setSudoOpen(false)
}}/>}
</>
}
export function AccountMenu() {
const {authenticated, loading, username, displayName, email, avatarUrl, canSignOut, signOut} = useAuth()
const [open, setOpen] = useState(false)
const container = useRef<HTMLDivElement>(null)
const trigger = useRef<HTMLButtonElement>(null)
const panelId = useId()
const name = (displayName || username || email || 'Profile').trim()
useEffect(() => {
if (!open) return
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'Escape') return
setOpen(false)
trigger.current?.focus()
}
const onPointerDown = (event: MouseEvent) => {
if (!container.current?.contains(event.target as Node)) setOpen(false)
}
document.addEventListener('keydown', onKeyDown)
document.addEventListener('mousedown', onPointerDown)
return () => {
document.removeEventListener('keydown', onKeyDown)
document.removeEventListener('mousedown', onPointerDown)
}
}, [open])
if (loading) return null
if (!authenticated) return <LoginButton/>
return <div className="account" ref={container}>
<button
ref={trigger}
type="button"
className="account-trigger"
aria-haspopup="menu"
aria-expanded={open}
aria-controls={panelId}
onClick={() => setOpen(value => !value)}
>
<Avatar name={name} url={avatarUrl}/>
<span className="account-name">{name}</span>
<span className="account-caret" aria-hidden="true">▾</span>
</button>
{open && <div className="account-menu" id={panelId} role="menu" aria-label="Account">
<div className="account-identity">
<strong className="account-display-name">{name}</strong>
{email && email !== name && <span className="account-email">{email}</span>}
</div>
<Link className="account-item" role="menuitem" to="/profile" onClick={() => setOpen(false)}>
<UserRound aria-hidden="true"/> Profile
</Link>
<button className="account-item" type="button" role="menuitem" onClick={() => { setOpen(false); signOut() }}>
<LogOut aria-hidden="true"/> Log out
</button>
{canSignOut && <p className="account-note">Signing out here also ends your sessions in the applications that use this sign-in.</p>}
</div>}
</div>
}
export function LoginGate({title}: {title: string}) {
return <section className="card protected-card">
<span className="eyebrow">authentication required</span>
<h1>{title}</h1>
<p className="muted">This page stays hidden until you are signed in.</p>
<LoginButton/>
</section>
}

View file

@ -0,0 +1,679 @@
import {useEffect, useMemo, useState} from 'react'
import {ArrowLeft, Check, Lock, Pencil, Plus, Trash2, TriangleAlert, X, Zap} from 'lucide-react'
import {Link} from 'react-router-dom'
import {
clearContentTable,
createContent,
deleteContent,
listBadges,
listCategories,
listContent,
updateContent,
} from '../api'
import {useAuth} from '../auth'
import {type Badge, type Category, type Content, type NewContent} from '../models'
import {Shell} from './Shell'
export function BacklogRc() {
const {authenticated, username, signIn} = useAuth()
const [items, setItems] = useState<Content[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [notice, setNotice] = useState<string | null>(null)
const [categories, setCategories] = useState<Category[]>([])
const [badges, setBadges] = useState<Badge[]>([])
// Filters
const [search, setSearch] = useState('')
const [selectedCategory, setSelectedCategory] = useState('')
const [selectedStatus, setSelectedStatus] = useState('')
// Modals state
const [showClearConfirm, setShowClearConfirm] = useState(false)
const [clearing, setClearing] = useState(false)
const [itemToDelete, setItemToDelete] = useState<Content | null>(null)
const [deleting, setDeleting] = useState(false)
const [editorOpen, setEditorOpen] = useState(false)
const [editingItem, setEditingItem] = useState<Content | null>(null)
const [saving, setSaving] = useState(false)
// Form fields
const [formTitle, setFormTitle] = useState('')
const [formSlug, setFormSlug] = useState('')
const [formCategory, setFormCategory] = useState('')
const [formStatus, setFormStatus] = useState('published')
const [formBadges, setFormBadges] = useState('')
const [formSummary, setFormSummary] = useState('')
const [formBody, setFormBody] = useState('')
const [formAuthor, setFormAuthor] = useState('')
const loadData = async () => {
setLoading(true)
setError(null)
try {
const [contentRes, catRes, badgeRes] = await Promise.all([
listContent(null, '', '', []),
listCategories(),
listBadges(),
])
const list = Array.isArray(contentRes)
? contentRes
: contentRes.items ?? contentRes.content ?? contentRes.data ?? []
setItems(list)
setCategories(catRes)
setBadges(badgeRes)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load backlog data.')
} finally {
setLoading(false)
}
}
useEffect(() => {
void loadData()
}, [])
const filteredItems = useMemo(() => {
return items.filter(item => {
if (selectedCategory && item.category !== selectedCategory) return false
if (selectedStatus && item.status !== selectedStatus) return false
if (search.trim()) {
const query = search.toLowerCase()
const matchesTitle = item.title.toLowerCase().includes(query)
const matchesSlug = item.id.toLowerCase().includes(query)
const matchesSummary = (item.summary ?? '').toLowerCase().includes(query)
const matchesAuthor = (item.author ?? '').toLowerCase().includes(query)
const matchesBadge = (item.badges ?? []).some(b => b.toLowerCase().includes(query))
if (!matchesTitle && !matchesSlug && !matchesSummary && !matchesAuthor && !matchesBadge) {
return false
}
}
return true
})
}, [items, search, selectedCategory, selectedStatus])
const stats = useMemo(() => {
const total = items.length
const published = items.filter(i => i.status === 'published').length
const draft = items.filter(i => i.status === 'draft').length
const archived = items.filter(i => i.status === 'archived').length
return {total, published, draft, archived}
}, [items])
const openCreateModal = () => {
setEditingItem(null)
setFormTitle('')
setFormSlug('')
setFormCategory(categories[0]?.slug ?? 'engineering')
setFormStatus('published')
setFormBadges('')
setFormSummary('')
setFormBody('')
setFormAuthor(username ?? 'sso-user')
setEditorOpen(true)
}
const openEditModal = (item: Content) => {
setEditingItem(item)
setFormTitle(item.title)
setFormSlug(item.id)
setFormCategory(item.category ?? '')
setFormStatus(item.status || 'published')
setFormBadges((item.badges ?? []).join(', '))
setFormSummary(item.summary ?? '')
setFormBody(item.body ?? '')
setFormAuthor(item.author ?? username ?? '')
setEditorOpen(true)
}
const handleSavePost = async (e: React.FormEvent) => {
e.preventDefault()
if (!formTitle.trim() || !formBody.trim()) {
setError('Title and Body are required.')
return
}
setSaving(true)
setError(null)
setNotice(null)
try {
const parsedBadges = formBadges
.split(',')
.map(b => b.trim().toLowerCase())
.filter(Boolean)
if (editingItem) {
// Update
await updateContent(editingItem.id, {
title: formTitle.trim(),
summary: formSummary.trim(),
body: formBody.trim(),
status: formStatus,
badges: parsedBadges,
category: formCategory.trim(),
})
setNotice(`Post "${formTitle}" updated successfully.`)
} else {
// Create
const payload: NewContent = {
title: formTitle.trim(),
slug: formSlug.trim() || undefined,
summary: formSummary.trim(),
body: formBody.trim(),
status: formStatus,
category: formCategory.trim() || undefined,
badges: parsedBadges,
author: formAuthor.trim() || username || 'sso-user',
}
await createContent(payload)
setNotice(`Post "${formTitle}" created successfully.`)
}
setEditorOpen(false)
await loadData()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save post.')
} finally {
setSaving(false)
}
}
const handleDeletePost = async () => {
if (!itemToDelete) return
setDeleting(true)
setError(null)
setNotice(null)
try {
await deleteContent(itemToDelete.id)
setNotice(`Post "${itemToDelete.title}" deleted successfully.`)
setItemToDelete(null)
await loadData()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete post.')
} finally {
setDeleting(false)
}
}
const handleClearTable = async () => {
setClearing(true)
setError(null)
setNotice(null)
try {
await clearContentTable()
setShowClearConfirm(false)
setNotice('Backlog table cleared successfully.')
await loadData()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to clear backlog table.')
} finally {
setClearing(false)
}
}
return (
<Shell>
<section className="crud-page">
<div className="page-heading">
<div>
<span className="eyebrow">$ nano /etc/backlog.rc</span>
<h1>Backlog Post Management</h1>
<p className="muted">
Interactive CRUD control plane for articles and posts. Authenticated via SSO.
</p>
</div>
<div className="page-heading-actions">
<Link to="/dev/null" className="secondary-button" style={{margin: 0, width: 'auto'}}>
<ArrowLeft aria-hidden="true" /> View /dev/null
</Link>
<button className="primary-button" type="button" onClick={openCreateModal}>
<Plus aria-hidden="true" /> New Post
</button>
<button
className="danger-button"
type="button"
onClick={() => setShowClearConfirm(true)}
title="Clear all records from the table"
>
<Trash2 aria-hidden="true" /> Clear Table
</button>
</div>
</div>
{!authenticated && (
<div className="sso-warning-banner">
<div>
<strong>
<Lock aria-hidden="true" /> SSO Authentication Required for Mutations
</strong>
<p className="small muted">
You are currently in read-only mode. Sign in via SSO to create, update, delete, or clear posts.
</p>
</div>
<button className="primary-button" type="button" onClick={signIn}>
<Zap aria-hidden="true" /> Sign In with SSO
</button>
</div>
)}
{notice && (
<div className="success-banner" role="status">
<span>
<Check aria-hidden="true" /> {notice}
</span>
<button type="button" onClick={() => setNotice(null)} aria-label="Dismiss">
<X aria-hidden="true" />
</button>
</div>
)}
{error && (
<div className="error" role="alert" style={{marginBottom: 20}}>
<span>
<TriangleAlert aria-hidden="true" /> {error}
</span>
<button
type="button"
onClick={() => setError(null)}
aria-label="Dismiss"
style={{float: 'right', background: 'none', border: 0, color: 'inherit', cursor: 'pointer'}}
>
<X aria-hidden="true" />
</button>
</div>
)}
{/* Stats Row */}
<div className="stats-grid">
<div className="stat-card">
<div className="stat-label">Total Posts</div>
<div className="stat-value">{stats.total}</div>
</div>
<div className="stat-card stat-published">
<div className="stat-label">Published</div>
<div className="stat-value">{stats.published}</div>
</div>
<div className="stat-card stat-draft">
<div className="stat-label">Drafts</div>
<div className="stat-value">{stats.draft}</div>
</div>
<div className="stat-card stat-archived">
<div className="stat-label">Archived</div>
<div className="stat-value">{stats.archived}</div>
</div>
</div>
{/* Search & Filter Toolbar */}
<div className="table-toolbar">
<div className="search-box">
<input
type="search"
placeholder="Search posts by title, slug, summary, author..."
value={search}
onChange={e => setSearch(e.target.value)}
/>
</div>
<div className="filter-group">
<select
value={selectedCategory}
onChange={e => setSelectedCategory(e.target.value)}
aria-label="Filter by category"
>
<option value="">All Categories</option>
{categories.map(cat => (
<option key={cat.id} value={cat.slug}>{cat.name}</option>
))}
</select>
<select
value={selectedStatus}
onChange={e => setSelectedStatus(e.target.value)}
aria-label="Filter by status"
>
<option value="">All Statuses</option>
<option value="published">Published</option>
<option value="draft">Draft</option>
<option value="archived">Archived</option>
</select>
{(search || selectedCategory || selectedStatus) && (
<button
type="button"
className="text-button"
onClick={() => {
setSearch('')
setSelectedCategory('')
setSelectedStatus('')
}}
>
Reset Filters
</button>
)}
</div>
</div>
{/* Posts Table */}
<div className="table-container card" style={{maxWidth: 'none', padding: 0, margin: '0 0 40px'}}>
{loading ? (
<div style={{padding: 40, textAlign: 'center'}} className="muted">
Loading posts from /etc/backlog.rc...
</div>
) : filteredItems.length === 0 ? (
<div style={{padding: 50, textAlign: 'center'}}>
<h2>No posts found</h2>
<p className="muted">
{items.length === 0
? 'The backlog table is empty. Create a post to populate the database.'
: 'No posts match your current search and filter criteria.'}
</p>
{items.length === 0 && (
<button className="primary-button" type="button" onClick={openCreateModal} style={{marginTop: 16}}>
<Plus aria-hidden="true" /> Create First Post
</button>
)}
</div>
) : (
<table className="crud-table">
<thead>
<tr>
<th style={{width: 60}}>#</th>
<th>Title & Slug</th>
<th style={{width: 130}}>Category</th>
<th>Badges</th>
<th style={{width: 110}}>Status</th>
<th style={{width: 140}}>Author</th>
<th style={{width: 130}}>Date</th>
<th style={{width: 160, textAlign: 'right'}}>Actions</th>
</tr>
</thead>
<tbody>
{filteredItems.map((item, idx) => (
<tr key={item.id}>
<td className="muted font-mono">{idx + 1}</td>
<td>
<div className="post-title-cell">
<Link to={`/dev/null/${item.id}`} className="post-link font-semibold">
{item.title}
</Link>
<span className="post-slug font-mono">/{item.id}</span>
</div>
{item.summary && <div className="post-summary-preview muted">{item.summary}</div>}
</td>
<td>
<span className="category-pill">{item.category || 'general'}</span>
</td>
<td>
<div className="badge-list-inline">
{(item.badges ?? []).map(b => (
<span key={b} className="badge small">{b}</span>
))}
</div>
</td>
<td>
<span className={`status-pill status-${item.status}`}>
{item.status}
</span>
</td>
<td className="muted small font-mono">{item.author || 'system'}</td>
<td className="muted small">
{item.updatedAt
? new Date(item.updatedAt).toLocaleDateString()
: item.createdAt
? new Date(item.createdAt).toLocaleDateString()
: '-'}
</td>
<td style={{textAlign: 'right'}}>
<div className="action-buttons-cell">
<button
type="button"
className="table-action-button"
onClick={() => openEditModal(item)}
title="Edit this post"
>
<Pencil aria-hidden="true" /> Edit
</button>
<button
type="button"
className="table-action-button delete"
onClick={() => setItemToDelete(item)}
title="Delete this post"
aria-label={`Delete ${item.title}`}
>
<Trash2 aria-hidden="true" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</section>
{/* Confirmation Screen / Modal for Clear Table */}
{showClearConfirm && (
<div className="terminal-backdrop" role="dialog" aria-modal="true">
<div className="terminal-window confirm-modal">
<div className="terminal-titlebar">
<span>
<TriangleAlert aria-hidden="true" /> CONFIRMATION: CLEAR TABLE
</span>
<button
type="button"
className="terminal-close"
onClick={() => setShowClearConfirm(false)}
disabled={clearing}
aria-label="Close"
>
<X aria-hidden="true" />
</button>
</div>
<div className="terminal-body" style={{padding: 28}}>
<div className="warning-callout">
<h3>Permanent Deletion Warning</h3>
<p>
You are about to clear all <strong>{items.length}</strong> posts from the backlog table.
All articles, summaries, revisions, and content will be irreversibly removed.
</p>
<p className="muted small">
SSO authentication will authorize this operation against <code>/api/admin/reset</code>.
</p>
</div>
<div className="modal-actions" style={{display: 'flex', justifyContent: 'flex-end', gap: 12, marginTop: 18}}>
<button
type="button"
className="secondary-button"
style={{width: 'auto', margin: 0}}
onClick={() => setShowClearConfirm(false)}
disabled={clearing}
>
Cancel
</button>
<button
type="button"
className="danger-button"
onClick={() => void handleClearTable()}
disabled={clearing}
>
{clearing ? 'Clearing Table…' : 'Yes, Permanently Clear Table'}
</button>
</div>
</div>
</div>
</div>
)}
{/* Single Delete Confirmation Modal */}
{itemToDelete && (
<div className="terminal-backdrop" role="dialog" aria-modal="true">
<div className="terminal-window confirm-modal">
<div className="terminal-titlebar">
<span>CONFIRM DELETION</span>
<button
type="button"
className="terminal-close"
onClick={() => setItemToDelete(null)}
disabled={deleting}
>
×
</button>
</div>
<div className="terminal-body" style={{padding: 28}}>
<p>
Are you sure you want to delete post <strong>&quot;{itemToDelete.title}&quot;</strong> (<code>/{itemToDelete.id}</code>)?
</p>
<div className="modal-actions" style={{display: 'flex', justifyContent: 'flex-end', gap: 12, marginTop: 20}}>
<button
type="button"
className="secondary-button"
style={{width: 'auto', margin: 0}}
onClick={() => setItemToDelete(null)}
disabled={deleting}
>
Cancel
</button>
<button
type="button"
className="danger-button"
onClick={() => void handleDeletePost()}
disabled={deleting}
>
{deleting ? 'Deleting…' : 'Delete Post'}
</button>
</div>
</div>
</div>
</div>
)}
{/* Create / Edit Post Modal */}
{editorOpen && (
<div className="terminal-backdrop" role="dialog" aria-modal="true">
<div className="terminal-window editor-modal" style={{maxWidth: 750}}>
<div className="terminal-titlebar">
<span>{editingItem ? `EDIT POST: /${editingItem.id}` : 'NEW POST: /etc/backlog.rc'}</span>
<button
type="button"
className="terminal-close"
onClick={() => setEditorOpen(false)}
disabled={saving}
>
×
</button>
</div>
<form onSubmit={e => void handleSavePost(e)} className="terminal-body editor-form" style={{padding: 28}}>
<div className="form-grid">
<label className="form-field full-width">
<span>Title *</span>
<input
type="text"
required
placeholder="Enter article title"
value={formTitle}
onChange={e => setFormTitle(e.target.value)}
/>
</label>
{!editingItem && (
<label className="form-field">
<span>Custom Slug (Optional)</span>
<input
type="text"
placeholder="e.g. my-awesome-post"
value={formSlug}
onChange={e => setFormSlug(e.target.value)}
/>
</label>
)}
<label className="form-field">
<span>Category</span>
<input
list="crud-category-options"
placeholder="Select or enter category"
value={formCategory}
onChange={e => setFormCategory(e.target.value)}
/>
<datalist id="crud-category-options">
{categories.map(c => (
<option key={c.id} value={c.slug}>{c.name}</option>
))}
</datalist>
</label>
<label className="form-field">
<span>Status</span>
<select value={formStatus} onChange={e => setFormStatus(e.target.value)}>
<option value="published">Published</option>
<option value="draft">Draft</option>
<option value="archived">Archived</option>
</select>
</label>
<label className="form-field full-width">
<span>Badges (comma separated)</span>
<input
type="text"
placeholder="go, rancher, kubernetes, sso"
value={formBadges}
onChange={e => setFormBadges(e.target.value)}
/>
</label>
<label className="form-field full-width">
<span>Summary</span>
<textarea
rows={2}
placeholder="Brief description or teaser..."
value={formSummary}
onChange={e => setFormSummary(e.target.value)}
/>
</label>
<label className="form-field full-width">
<span>Body *</span>
<textarea
rows={8}
required
placeholder="Full article content in markdown or plaintext..."
value={formBody}
onChange={e => setFormBody(e.target.value)}
/>
</label>
<label className="form-field">
<span>Author</span>
<input
type="text"
placeholder="Author name or email"
value={formAuthor}
onChange={e => setFormAuthor(e.target.value)}
/>
</label>
</div>
<div className="modal-actions" style={{display: 'flex', justifyContent: 'flex-end', gap: 12, marginTop: 24}}>
<button
type="button"
className="secondary-button"
style={{width: 'auto', margin: 0}}
onClick={() => setEditorOpen(false)}
disabled={saving}
>
Cancel
</button>
<button
type="submit"
className="primary-button"
style={{width: 'auto', margin: 0}}
disabled={saving}
>
{saving ? 'Saving Post…' : editingItem ? 'Save Changes' : 'Create Post'}
</button>
</div>
</form>
</div>
</div>
)}
</Shell>
)
}

View file

@ -1,15 +1,18 @@
import {ArrowRight} from 'lucide-react'
import {Link} from 'react-router-dom' import {Link} from 'react-router-dom'
import {type Content} from '../models' import {type Content} from '../models'
export function ContentCard({item}: {item: Content}) { export function ContentCard({item}: {item: Content}) {
return <article className="content-card"> return <article className="content-card">
<span className="status">{item.status}</span> <span className="status">{item.status}</span>
<h2><Link to={`/content/${item.id}`}>{item.title}</Link></h2> <h2><Link to={`/dev/null/${item.id}`}>{item.title}</Link></h2>
<p>{item.summary}</p> <p>{item.summary}</p>
<p className="content-meta">by {item.author ?? 'unknown'} <p className="content-meta">by {item.author ?? 'unknown'}
{item.publishedAt && <> · published {new Date(item.publishedAt).toLocaleDateString()}</>}</p> {item.publishedAt && <> · published {new Date(item.publishedAt).toLocaleDateString()}</>}</p>
{item.badges && item.badges.length > 0 && {item.badges && item.badges.length > 0 &&
<div className="badge-list">{item.badges.map(badge => <span className="badge" key={badge}>{badge}</span>)}</div>} <div className="badge-list">{item.badges.map(badge => <span className="badge" key={badge}>{badge}</span>)}</div>}
<Link className="read-link" to={`/content/${item.id}`}>Read more <span aria-hidden="true">→</span></Link> <Link className="read-link" to={`/dev/null/${item.id}`}>
Read more <ArrowRight aria-hidden="true" />
</Link>
</article> </article>
} }

View file

@ -1,3 +1,4 @@
import {X} from 'lucide-react'
import {type Badge, type Category} from '../models' import {type Badge, type Category} from '../models'
type Props = { type Props = {
@ -36,7 +37,9 @@ export function ContentFilters(props: Props) {
<div className="badge-input"> <div className="badge-input">
{props.badges.map(badge => <span className="filter-pill" key={badge}>{badge} {props.badges.map(badge => <span className="filter-pill" key={badge}>{badge}
<button type="button" aria-label={`Remove ${badge}`} <button type="button" aria-label={`Remove ${badge}`}
onClick={() => props.onBadgesChange(props.badges.filter(value => value !== badge))}>×</button></span>)} onClick={() => props.onBadgesChange(props.badges.filter(value => value !== badge))}>
<X aria-hidden="true"/>
</button></span>)}
<input list="badge-options" value={props.badgeInput} placeholder="Type a badge and press Enter" <input list="badge-options" value={props.badgeInput} placeholder="Type a badge and press Enter"
onChange={event => props.onBadgeInputChange(event.target.value)} onChange={event => props.onBadgeInputChange(event.target.value)}
onKeyDown={event => { if (event.key === 'Enter') { event.preventDefault(); addBadge() } }}/> onKeyDown={event => { if (event.key === 'Enter') { event.preventDefault(); addBadge() } }}/>

View file

@ -1,50 +1,146 @@
import {useEffect, useState, type ReactNode} from 'react' import {useEffect, useState, type ReactNode} from 'react'
import {Link} from 'react-router-dom' import {Link, useLocation} from 'react-router-dom'
import {listDemoUsers} from '../api'
import {useAuth} from '../auth' import {useAuth} from '../auth'
import {type DemoUser} from '../models' import {AccountMenu} from './AccountMenu'
import {SudoTerminal} from './SudoTerminal'
/**
* The palettes this application offers.
*
* The list is no longer the source of truth: the selector reads the published palettes
* from the theme service, so a palette published there appears here with no change to
* this file. These remain as the names this application's own rules key on, and as the
* set the fallback below validates against.
*/
export type Theme = 'cyberpunk' | 'nordic' | 'obsidian' | 'terminal'
const THEMES: readonly string[] = ['cyberpunk', 'nordic', 'obsidian', 'terminal']
/** The palette used before the service has answered, and if it never does. */
const DEFAULT_THEME = 'cyberpunk'
/**
* Reads the palette from the shared contract.
*
* The control itself is a custom element shipped by the service, so it writes
* `data-theme` on the root element directly. This function exists to answer "which
* palette is current?" for the few places that need to know, without the component
* holding a second copy of the state.
*
* @returns the current palette slug, or the default when none is set
*/
export function currentTheme(): string {
return document.documentElement.dataset.theme || DEFAULT_THEME
}
type Palette = {slug: string; name: string}
function ThemeDropdown() {
const [themes, setThemes] = useState<Palette[]>(THEMES.map(slug => ({slug, name: slug})))
const [value, setValue] = useState(DEFAULT_THEME)
useEffect(() => {
setValue(currentTheme())
const href = document.querySelector('link[data-theme-contract]')?.getAttribute('href') ?? ''
const base = href.replace(/\/contract\.css(?:\?.*)?$/, '')
if (!base) return
void fetch(`${base}/contract.json`)
.then(response => response.ok ? response.json() : Promise.reject(response.status))
.then((payload: {themes?: Palette[]}) => {
const published = (payload.themes ?? []).filter(theme => theme.slug)
if (published.length > 0) setThemes(published)
})
.catch(() => {})
}, [])
return <label className="theme-dropdown-wrap">
<select
className="theme-dropdown"
aria-label="Palette"
value={value}
onChange={event => {
const slug = event.target.value
setValue(slug)
document.documentElement.dataset.theme = slug
window.localStorage.setItem('urpi-theme', slug)
}}
>
{themes.map(theme => <option key={theme.slug} value={theme.slug}>{theme.name}</option>)}
</select>
</label>
}
export function Shell({children}: {children: ReactNode}) { export function Shell({children}: {children: ReactNode}) {
const {authenticated, loading, canSignIn, signIn, username, sudo, signOut} = useAuth() const {authenticated, loading} = useAuth()
const [sudoOpen, setSudoOpen] = useState(false) const location = useLocation()
const [helpOpen, setHelpOpen] = useState(false)
const [users, setUsers] = useState<DemoUser[]>([])
const [dark, setDark] = useState(() => window.localStorage.getItem('urpi-theme') !== 'light')
useEffect(() => { void listDemoUsers().then(setUsers) }, []) // No React state for the palette. The control is a custom element that mutates
// `data-theme` on the root, and everything that depends on the palette is CSS
// keyed on that attribute, so there is nothing for a re-render to update. Keeping a
// copy in React would be a second source of truth that could disagree with the DOM.
//
// This effect only bridges the storage key: it migrates an existing choice forward
// once, and mirrors later changes back so anything still reading `urpi-theme` — a
// bookmarklet, a support script, a colleague's muscle memory — keeps working.
useEffect(() => { useEffect(() => {
document.documentElement.dataset.theme = dark ? 'dark' : 'light' // Establish a palette on first load. Without this the page starts with no
window.localStorage.setItem('urpi-theme', dark ? 'dark' : 'light') // data-theme at all, so the contract matches no rule and the application renders
}, [dark]) // on the stylesheet's fallbacks. Those fallbacks happen to be cyberpunk, which is
// why this was not obvious, but the palette would not actually be selected and the
// appearance control would have nothing to switch from.
const legacy = window.localStorage.getItem('urpi-theme')
const stored = legacy && THEMES.includes(legacy) ? legacy : null
if (!document.documentElement.dataset.theme) {
document.documentElement.dataset.theme = stored || DEFAULT_THEME
}
return <main className="shell"> const onChange = (event: Event) => {
const slug = (event as CustomEvent<{slug: string | null}>).detail?.slug
window.localStorage.setItem('urpi-theme', slug || DEFAULT_THEME)
}
document.addEventListener('bananas:theme', onChange)
return () => document.removeEventListener('bananas:theme', onChange)
}, [])
return (
<main className="shell">
<header className="header"> <header className="header">
<Link className="brand" to="/content"><span className="brand-prompt">~/</span> Urpi's backlog <div className="brand-group">
<span className="brand-cursor">_</span><small>linux · go · java · ideas</small></Link> <Link className="brand" to="/dev/null">
<span className="brand-prompt">~/</span> Urpi&apos;s backlog
<span className="brand-cursor">_</span>
<small>linux · go · java · ideas</small>
</Link>
<nav className="header-nav" aria-label="Main Navigation">
<Link
to="/dev/null"
className={`nav-tab ${location.pathname.startsWith('/dev/null') || location.pathname === '/' ? 'active' : ''}`}
>
/dev/null
</Link>
{!loading && authenticated && <Link
to="/etc/backlog.rc"
className={`nav-tab ${location.pathname.startsWith('/etc/backlog.rc') ? 'active' : ''}`}
>
/etc/backlog.rc
</Link>}
</nav>
</div>
<div className="header-actions"> <div className="header-actions">
{!loading && <span className="mode-badge">{authenticated ? `${username ?? 'Owner'} · sudo` : 'Anonymous · read-only'}</span>} {/*
<button className="theme-button" type="button" aria-label="Demo users" The palette and appearance controls, injected rather than hand-written.
onClick={() => setHelpOpen(value => !value)}>?</button> They live in a shadow root, so this application's own CSS cannot restyle
{!loading && !authenticated && users.length > 0 && them, and they read the palettes the service publishes rather than a list
<button className="theme-button" type="button" onClick={() => setSudoOpen(true)}>sudo</button>} duplicated here.
{!loading && authenticated && users.length > 0 && */}
<button className="theme-button" type="button" onClick={signOut}>logout</button>} <ThemeDropdown />
{!loading && !authenticated && canSignIn && <bananas-appearance-toggle title="Appearance" />
<button className="google-button session-button" type="button" onClick={signIn}>
<span aria-hidden="true">G</span> Sign in with Google</button>} <AccountMenu/>
<button className="theme-button" type="button" aria-label={`Switch to ${dark ? 'light' : 'dark'} theme`}
onClick={() => setDark(value => !value)}>{dark ? '☀ Light' : '☾ Dark'}</button>
</div> </div>
</header> </header>
{helpOpen && users.length > 0 && <aside className="card terminal-panel">
<strong>sudo users</strong><p className="muted">Demo only: each password is the same as the username.</p>
{users.map(item => <div key={item.username}><code>sudo {item.username}</code> · {item.name}</div>)}
</aside>}
{sudoOpen && <SudoTerminal users={users} onLogin={async (user, password) => {
await sudo(user, password)
setSudoOpen(false)
}} onClose={() => setSudoOpen(false)}/>}
{children} {children}
</main> </main>
)
} }

View file

@ -1,4 +1,5 @@
import {useRef, useState, type FormEvent, type KeyboardEvent} from 'react' import {useRef, useState, type FormEvent, type KeyboardEvent} from 'react'
import {X} from 'lucide-react'
import {type DemoUser} from '../models' import {type DemoUser} from '../models'
type Props = { type Props = {
@ -46,9 +47,12 @@ export function SudoTerminal({users, onLogin, onClose}: Props) {
<form ref={form} className="terminal-window" role="dialog" aria-modal="true" aria-labelledby="sudo-title" onSubmit={submit}> <form ref={form} className="terminal-window" role="dialog" aria-modal="true" aria-labelledby="sudo-title" onSubmit={submit}>
<div className="terminal-titlebar"> <div className="terminal-titlebar">
<span id="sudo-title">urpi@backlog: ~</span> <span id="sudo-title">urpi@backlog: ~</span>
<button type="button" className="terminal-close" aria-label="Close sudo terminal" onClick={onClose}>×</button> <button type="button" className="terminal-close" aria-label="Close sudo terminal" onClick={onClose}>
<X aria-hidden="true"/>
</button>
</div> </div>
<div className="terminal-body"> <div className="terminal-body">
{users.length > 0 && <p className="muted">users: {users.map(item => item.username).join(', ')} — password is the username</p>}
<label><span className="terminal-prompt">$</span> sudo <input autoFocus value={user} <label><span className="terminal-prompt">$</span> sudo <input autoFocus value={user}
onChange={event => setUser(event.target.value)} onKeyDown={handleUserKeyDown} onChange={event => setUser(event.target.value)} onKeyDown={handleUserKeyDown}
placeholder="user" aria-label="Username"/></label> placeholder="user" aria-label="Username"/></label>

View file

@ -10,6 +10,16 @@ export type SessionResponse = {
authenticated?: boolean authenticated?: boolean
canEdit?: boolean canEdit?: boolean
username?: string username?: string
email?: string
displayName?: string
avatarUrl?: string
// Demo profile: the header login opens the local sudo terminal instead of
// redirecting to Go Loose.
demo?: boolean
// Go Loose advertises its own login and logout endpoints; the UI follows them
// instead of sending the browser to a single-sign-on proxy.
loginUrl?: string
logoutUrl?: string
} }
export type DemoUser = { username: string; name: string } export type DemoUser = { username: string; name: string }

View file

@ -12,7 +12,18 @@ export type Content = {
publishedAt?: string publishedAt?: string
} }
export type ContentUpdate = Pick<Content, 'title' | 'summary' | 'body' | 'status' | 'badges'> export type ContentUpdate = Pick<Content, 'title' | 'summary' | 'body' | 'status' | 'badges'> & { category?: string }
export type NewContent = {
title: string
slug?: string
summary?: string
body: string
status?: string
category?: string
badges?: string[]
author?: string
}
export type ContentListResponse = { export type ContentListResponse = {
items?: Content[] items?: Content[]

View file

@ -1,2 +1,2 @@
export type {Badge, Category, Content, ContentListResponse, ContentUpdate} from './content' export type {Badge, Category, Content, ContentListResponse, ContentUpdate, NewContent} from './content'
export type {DemoUser, SessionResponse, User} from './auth' export type {DemoUser, SessionResponse, User} from './auth'

View file

@ -1,203 +1,416 @@
/* ==========================================================================
THEME
Colours come from the shared styling contract, which is why this file has
no palette of its own. Every value below is written as
var(--bn-<role>, <the value this file used to hardcode>)
The second argument is the colour that was there before, so a page that does not
load contract.css still renders exactly as it did: the migration is reversible by
deleting one <link>.
Selecting a palette writes data-theme on <html>; selecting an appearance writes
data-mode. The contract supplies values for both, so this stylesheet is the same
for every palette and both appearances.
========================================================================== */
:root { :root {
font-family: Inter, ui-sans-serif, system-ui, sans-serif; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
color: #cdd6f4; color: var(--bn-text, #cdd6f4);
background: #1e1e2e; background: var(--bn-bg, #120826);
font-synthesis: none; font-synthesis: none;
} }
* { box-sizing: border-box; } * { box-sizing: border-box; }
body { body {
margin: 0; margin: 0;
min-width: 320px; min-width: 320px;
background: radial-gradient(circle at 85% -10%, #302b50 0, transparent 38rem), #1e1e2e; background: radial-gradient(circle at 85% -10%, var(--bn-accent, #2e1053) 0, transparent 38rem), var(--bn-bg, #120826);
transition: background 0.3s ease, color 0.3s ease;
} }
button, a, input, textarea, select { font: inherit; } button, a, input, textarea, select { font: inherit; }
button, select { cursor: pointer; } button, select { cursor: pointer; }
.shell { min-height: 100vh; max-width: 1080px; margin: auto; padding: 0 28px 72px; } .shell { min-height: 100vh; max-width: 1140px; margin: auto; padding: 0 28px 72px; }
.header { height: 94px; display: flex; align-items: center; justify-content: space-between; }
.header-actions { display: flex; align-items: center; gap: 8px; } /* Header & Nav */
.brand { position: relative; color: #cdd6f4; font-size: 1.15rem; font-weight: 750; text-decoration: none; letter-spacing: -.02em; } .header { position: relative; z-index: 20; height: 94px; display: flex; align-items: center; justify-content: space-between; gap: 16px; border-bottom: 1px solid rgba(255,255,255,0.08); margin-bottom: 24px; }
.brand:hover { color: #cba6f7; } .brand-group { display: flex; align-items: center; gap: 28px; }
.brand-prompt { color: #a6e3a1; font-family: ui-monospace, SFMono-Regular, monospace; } .brand { position: relative; color: var(--bn-text, #f5e0e8); font-size: 1.15rem; font-weight: 750; text-decoration: none; letter-spacing: -.02em; }
.brand-cursor { color: #f9e2af; font-family: ui-monospace, SFMono-Regular, monospace; animation: blink 1.2s steps(2, start) infinite; } .brand:hover { color: var(--bn-secondary, #00f0ff); }
.brand small { display: block; margin: 5px 0 0 22px; color: #7f849c; font-size: .68rem; font-weight: 600; letter-spacing: .08em; text-transform: uppercase; } .brand-prompt { color: var(--bn-secondary, #00f0ff); font-family: ui-monospace, SFMono-Regular, monospace; }
.brand-cursor { color: var(--bn-accent, #ff2a85); font-family: ui-monospace, SFMono-Regular, monospace; animation: blink 1.2s steps(2, start) infinite; }
.brand small { display: block; margin: 3px 0 0 22px; color: var(--bn-muted, #a6adc8); font-size: .68rem; font-weight: 600; letter-spacing: .08em; text-transform: uppercase; }
@keyframes blink { 50% { opacity: 0; } } @keyframes blink { 50% { opacity: 0; } }
.text-button { border: 1px solid #45475a; border-radius: 8px; background: #313244; color: #bac2de; cursor: pointer; padding: 8px 12px; }
.text-button:hover { border-color: #89b4fa; color: #cdd6f4; } .header-nav { display: flex; align-items: center; gap: 8px; }
.theme-button { border: 1px solid #45475a; border-radius: 8px; background: #313244; color: #bac2de; padding: 8px 12px; } .nav-tab {
.theme-button:hover { border-color: #cba6f7; color: #f5e0e8; } font-family: ui-monospace, SFMono-Regular, monospace;
.mode-badge { border: 1px solid #45475a; border-radius: 999px; padding: 6px 11px; color: #a6adc8; background: #242438; font-size: .7rem; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; } font-size: 0.85rem;
.google-button { display: inline-flex; align-items: center; gap: 8px; border: 1px solid #585b70; border-radius: 8px; background: #313244; color: #cdd6f4; cursor: pointer; padding: 8px 12px; font-weight: 650; } font-weight: 600;
.google-button:hover { background: #45475a; border-color: #89b4fa; } padding: 6px 14px;
.google-button span { color: #89b4fa; font-weight: 800; } border-radius: 6px;
.card { margin: 10vh auto 0; max-width: 500px; padding: 42px; background: #242438; border: 1px solid #45475a; border-radius: 16px; box-shadow: 0 18px 50px #11111b66; } text-decoration: none;
h1 { margin: 10px 0 12px; color: #f5e0e8; font-size: clamp(1.7rem, 4vw, 2.4rem); letter-spacing: -.04em; } color: var(--bn-muted, #a6adc8);
border: 1px solid transparent;
transition: all 0.2s ease;
}
.nav-tab:hover {
color: var(--bn-inverted, #ffffff);
border-color: rgba(255,255,255,0.2);
}
.nav-tab.active {
color: var(--bn-secondary, #00f0ff);
border-color: var(--bn-secondary, #00f0ff);
background: rgba(0, 240, 255, 0.1);
}
.header-actions { display: flex; align-items: center; gap: 12px; }
/* Theme Selector */
.theme-dropdown-wrap { position: relative; display: inline-flex; }
.theme-dropdown-wrap::after {
content: "▾";
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
pointer-events: none;
color: var(--bn-muted, #a6adc8);
font-size: 10px;
}
.theme-dropdown {
appearance: none;
background: var(--bn-surface, #1e1338);
color: var(--bn-text, #f5e0e8);
border: 1px solid color-mix(in srgb, var(--bn-accent) 22%, transparent);
border-radius: 8px;
padding: 8px 28px 8px 14px;
font-size: 0.85rem;
font-weight: 600;
transition: all 0.2s ease;
max-width: 12rem;
}
.theme-dropdown:hover, .theme-dropdown:focus {
border-color: var(--bn-secondary, #00f0ff);
outline: none;
box-shadow: 0 0 10px rgba(0, 240, 255, 0.25);
}
.account { position: relative; }
.account-login, .account-trigger {
display: inline-flex;
align-items: center;
gap: 8px;
border: 1px solid var(--bn-border, #3d2366);
border-radius: 8px;
background: var(--bn-surface, #1b1035);
color: var(--bn-text, #f5e0e8);
font: inherit;
font-size: 0.82rem;
font-weight: 700;
padding: 6px 12px;
cursor: pointer;
}
.account-login {
background: var(--bn-accent, #ff2a85);
color: var(--bn-on-accent, #120826);
border-color: transparent;
}
.account-login:hover, .account-trigger:hover {
border-color: color-mix(in srgb, var(--bn-accent, #ff2a85) 45%, var(--bn-border, #3d2366));
}
.account-login:focus-visible, .account-trigger:focus-visible {
outline: 2px solid var(--bn-focus, var(--bn-accent, #ff2a85));
outline-offset: 2px;
}
.account-name { max-width: 11rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.account-caret { color: var(--bn-muted, #a6adc8); font-size: 10px; }
.account-menu {
position: absolute;
right: 0;
top: calc(100% + 8px);
z-index: 40;
min-width: 15rem;
padding: 6px;
background: var(--bn-surface-raised, var(--bn-surface, #1e1338));
border: 1px solid var(--bn-border, #3d2366);
border-radius: 10px;
box-shadow: 0 14px 34px rgba(0, 0, 0, 0.28);
}
.account-identity { padding: 9px 10px 11px; border-bottom: 1px solid var(--bn-border, #3d2366); margin-bottom: 6px; }
.account-display-name { display: block; font-size: 13px; }
.account-email { display: block; color: var(--bn-muted, #a6adc8); font-size: 11.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.account-item {
display: flex;
width: 100%;
align-items: center;
gap: 9px;
padding: 8px 10px;
background: transparent;
border: 0;
border-radius: 7px;
color: var(--bn-text, #f5e0e8);
font: inherit;
font-size: 13px;
text-align: left;
text-decoration: none;
cursor: pointer;
}
.account-item:hover { background: color-mix(in srgb, var(--bn-accent, #ff2a85) 12%, var(--bn-surface, #1b1035)); }
.account-item svg { width: 1rem; height: 1rem; }
.account-note { padding: 7px 10px 4px; color: var(--bn-muted, #a6adc8); font-size: 11px; line-height: 1.45; }
.avatar { border-radius: 50%; object-fit: cover; display: block; flex: none; }
.avatar-empty {
display: inline-grid;
place-items: center;
background: var(--bn-surface-sunken, #140929);
border: 1px solid var(--bn-border, #3d2366);
color: var(--bn-accent, #ff2a85);
font: 700 11px ui-monospace, monospace;
}
.profile-card { display: flex; align-items: center; gap: 18px; margin-top: 18px; }
.profile-card h1 { margin: 0; }
.protected-card .account-login { margin-top: 8px; }
/* Common Components */
.card { margin: 20px auto; padding: 32px; background: var(--bn-surface, #1b1035); border: 1px solid var(--bn-border, #3d2366); border-radius: 16px; box-shadow: 0 18px 50px rgba(0,0,0,0.4); }
h1 { margin: 10px 0 12px; color: var(--bn-text, #f5e0e8); font-size: clamp(1.7rem, 4vw, 2.4rem); letter-spacing: -.04em; }
p { line-height: 1.65; } p { line-height: 1.65; }
.muted { color: #a6adc8; } .muted { color: var(--bn-muted, #a6adc8); }
.small { font-size: .875rem; } .small { font-size: .875rem; }
.eyebrow { color: #cba6f7; font-size: .75rem; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; } .eyebrow { color: var(--bn-accent, #ff2a85); font-size: .75rem; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; font-family: ui-monospace, SFMono-Regular, monospace; }
.secondary-button { width: 100%; min-height: 48px; margin-top: 20px; border: 0; border-radius: 9px; background: #45475a; color: #cdd6f4; cursor: pointer; font-weight: 650; }
.error { padding: 10px 12px; border-radius: 8px; color: #f38ba8; background: #3b2634; font-size: .9rem; } /* Buttons */
.profile { margin-top: 24px; padding: 14px; border-radius: 8px; background: #313244; color: #a6adc8; } .primary-button, .secondary-button, .danger-button {
.content-page, .detail-page { padding-top: 5vh; } border: 0;
.page-heading { display: flex; justify-content: space-between; align-items: end; margin-bottom: 28px; } border-radius: 8px;
.page-heading h1 { color: #cdd6f4; } min-height: 40px;
.filters { display: grid; grid-template-columns: minmax(150px, .25fr) minmax(180px, .35fr) 1fr; gap: 14px; margin-bottom: 24px; padding: 16px; background: #242438; border: 1px solid #45475a; border-radius: 14px; } padding: 0 18px;
.filters label { display: grid; gap: 7px; color: #bac2de; font-size: .85rem; font-weight: 650; } cursor: pointer;
.filters select, .badge-input { width: 100%; min-height: 43px; border: 1px solid #585b70; border-radius: 8px; padding: 6px 9px; background: #313244; color: #cdd6f4; font: inherit; font-weight: 400; } font-weight: 650;
.filters select:focus, .badge-input:focus-within { outline: 2px solid #89b4fa; outline-offset: 1px; } text-decoration: none;
display: inline-flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
font-size: 0.9rem;
}
.primary-button { background: var(--bn-accent, #ff2a85); color: var(--bn-inverted, #ffffff); box-shadow: 0 0 12px rgba(255, 42, 133, 0.3); }
.primary-button:hover {
background: var(--bn-accent-hover, #ff4d9a);
box-shadow: 0 0 20px color-mix(in srgb, var(--bn-accent) 50%, transparent);
}
.primary-button:disabled { opacity: .6; cursor: wait; }
.secondary-button { background: var(--bn-secondary, #2f1d4f); color: var(--bn-text, #cdd6f4); border: 1px solid var(--bn-border-strong, #4a2d7a); }
.secondary-button:hover { background: var(--bn-secondary, #3d2566); color: var(--bn-inverted, #ffffff); }
.danger-button { background: #d90429; color: var(--bn-inverted, #ffffff); box-shadow: 0 0 10px rgba(217, 4, 41, 0.3); }
.danger-button:hover { background: var(--bn-danger, #ef233c); box-shadow: 0 0 18px rgba(239, 35, 60, 0.5); }
/* Lucide icons inherit the text colour of whatever they sit in, and are sized from
the font size of their container rather than carrying a per-call-site class. The
alternative -- a className at all twelve call sites -- is a place for the sizes to
drift apart, which is the problem glyph icons had. */
svg { flex: none; }
.primary-button svg, .secondary-button svg, .danger-button svg,
.text-button svg, .table-action-button svg {
width: 1.05em;
height: 1.05em;
}
.read-link svg, .back-link svg, .page-heading-actions svg {
width: 0.95em;
height: 0.95em;
vertical-align: -0.12em;
}
.success-banner svg, .error svg, .sso-warning-banner svg { width: 1em; height: 1em; }
.text-button { border: 1px solid var(--bn-border-strong, #4a2d7a); border-radius: 8px; background: var(--bn-surface-raised, #22153d); color: var(--bn-text, #bac2de); cursor: pointer; padding: 6px 12px; font-size: 0.85rem; }
.text-button:hover { border-color: var(--bn-secondary, #00f0ff); color: var(--bn-secondary, #00f0ff); }
/* Notifications */
.error { padding: 12px 16px; border-radius: 8px; color: #ff85a1; background: var(--bn-danger, #3d1424); border: 1px solid color-mix(in srgb, var(--bn-accent) 22%, transparent); font-size: .9rem; }
.success-banner { display: flex; justify-content: space-between; align-items: center; padding: 12px 16px; border-radius: 8px; color: var(--bn-secondary, #00f0ff); background: var(--bn-good, #0b343d); border: 1px solid color-mix(in srgb, var(--bn-secondary) 66%, transparent); margin-bottom: 20px; }
.success-banner button {
background: none;
border: 0;
color: inherit;
cursor: pointer;
display: inline-flex;
align-items: center;
}
.success-banner button svg { width: 1rem; height: 1rem; }
.sso-warning-banner { display: flex; justify-content: space-between; align-items: center; gap: 16px; padding: 16px 20px; border-radius: 10px; background: var(--bn-warning, #2a164d); border: 1px solid color-mix(in srgb, var(--bn-accent) 88%, transparent); margin-bottom: 24px; }
.sso-warning-banner p { margin: 4px 0 0; }
/* Content List and Detail */
.content-page, .detail-page, .crud-page { padding-top: 2vh; }
.page-heading { display: flex; justify-content: space-between; align-items: flex-end; margin-bottom: 24px; flex-wrap: wrap; gap: 16px; }
.page-heading-actions { display: flex; align-items: center; gap: 10px; }
.filters { display: grid; grid-template-columns: minmax(150px, .25fr) minmax(180px, .35fr) 1fr; gap: 14px; margin-bottom: 24px; padding: 16px; background: var(--bn-surface, #1b1035); border: 1px solid var(--bn-border, #3d2366); border-radius: 14px; }
.filters label { display: grid; gap: 7px; color: var(--bn-text, #bac2de); font-size: .85rem; font-weight: 650; }
.filters select, .badge-input { width: 100%; min-height: 43px; border: 1px solid var(--bn-border-strong, #4a2d7a); border-radius: 8px; padding: 6px 9px; background: var(--bn-surface-raised, #22153d); color: var(--bn-text, #cdd6f4); font: inherit; font-weight: 400; }
.filters select:focus, .badge-input:focus-within { outline: 2px solid var(--bn-secondary, #00f0ff); outline-offset: 1px; }
.badge-input { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; } .badge-input { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; }
.badge-input input { min-width: 180px; flex: 1; border: 0; outline: 0; padding: 5px; color: #cdd6f4; background: transparent; font: inherit; } .badge-input input { min-width: 180px; flex: 1; border: 0; outline: 0; padding: 5px; color: var(--bn-text, #cdd6f4); background: transparent; font: inherit; }
.badge-input input::placeholder { color: #7f849c; } .badge-input input::placeholder { color: var(--bn-subtle, #7f849c); }
.filter-pill, .badge { display: inline-flex; align-items: center; gap: 4px; border-radius: 999px; padding: 4px 9px; color: #cba6f7; background: #3b3151; font-size: .78rem; font-weight: 650; } .filter-pill, .badge { display: inline-flex; align-items: center; gap: 4px; border-radius: 999px; padding: 3px 9px; color: var(--bn-secondary, #00f0ff); background: color-mix(in srgb, var(--bn-secondary) 18%, transparent); border: 1px solid color-mix(in srgb, var(--bn-secondary) 33%, transparent); font-size: .75rem; font-weight: 650; }
.filter-pill button { border: 0; padding: 0 2px; color: #cba6f7; background: transparent; cursor: pointer; font-size: 1rem; line-height: 1; } .filter-pill button {
border: 0;
padding: 0 2px;
color: var(--bn-secondary, #00f0ff);
background: transparent;
cursor: pointer;
display: inline-flex;
align-items: center;
}
.filter-pill button svg { width: 0.75rem; height: 0.75rem; }
.badge.small { font-size: 0.7rem; padding: 2px 7px; }
.badge-list { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 18px; } .badge-list { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 18px; }
.badge-list-inline { display: flex; flex-wrap: wrap; gap: 4px; }
.content-grid { display: grid; grid-template-columns: minmax(0, 1fr); gap: 18px; } .content-grid { display: grid; grid-template-columns: minmax(0, 1fr); gap: 18px; }
.content-card { padding: 28px; background: #242438; border: 1px solid #45475a; border-radius: 14px; box-shadow: 0 12px 34px #11111b33; } .content-card { padding: 28px; background: var(--bn-surface, #1b1035); border: 1px solid var(--bn-border, #3d2366); border-radius: 14px; box-shadow: 0 12px 34px rgba(0,0,0,0.3); }
.content-card:hover { border-color: #585b70; box-shadow: 0 15px 40px #11111b55; } .content-card:hover { border-color: color-mix(in srgb, var(--bn-secondary) 66%, transparent); box-shadow: 0 15px 40px rgba(0,240,255,0.1); }
.content-card h2 { margin: 12px 0 8px; font-size: 1.35rem; letter-spacing: -.025em; } .content-card h2 { margin: 12px 0 8px; font-size: 1.35rem; letter-spacing: -.025em; }
.content-card h2 a { color: #f5e0e8; text-decoration: none; } .content-card h2 a { color: var(--bn-text, #f5e0e8); text-decoration: none; }
.content-card h2 a:hover, .read-link:hover { color: #89b4fa; } .content-card h2 a:hover, .read-link:hover { color: var(--bn-secondary, #00f0ff); }
.content-card p { color: #a6adc8; margin: 0 0 18px; } .content-card p { color: var(--bn-muted, #a6adc8); margin: 0 0 18px; }
.content-card .content-meta { margin-top: -8px; color: #7f849c; font-size: .82rem; } .content-card .content-meta { margin-top: -8px; color: var(--bn-subtle, #7f849c); font-size: .82rem; }
.status { display: inline-block; color: #94e2d5; background: #193d3a; border-radius: 999px; padding: 4px 9px; font-size: .72rem; font-weight: 700; text-transform: uppercase; letter-spacing: .06em; } .status { display: inline-block; color: var(--bn-secondary, #00f0ff); background: color-mix(in srgb, var(--bn-secondary) 22%, transparent); border: 1px solid color-mix(in srgb, var(--bn-secondary) 55%, transparent); border-radius: 999px; padding: 3px 10px; font-size: .72rem; font-weight: 700; text-transform: uppercase; letter-spacing: .06em; }
.read-link, .back-link { color: #89b4fa; font-weight: 650; text-decoration: none; } .read-link, .back-link { color: var(--bn-secondary, #00f0ff); font-weight: 650; text-decoration: none; }
.list-status { text-align: center; min-height: 54px; padding: 14px; } .list-status { text-align: center; min-height: 54px; padding: 14px; }
.empty-state { max-width: none; text-align: center; margin-top: 0; } .empty-state { max-width: none; text-align: center; margin-top: 0; }
.detail-page { max-width: 760px; margin: auto; } .detail-page { max-width: 760px; margin: auto; }
.back-link { display: inline-block; margin-bottom: 32px; } .back-link { display: inline-block; margin-bottom: 24px; }
.detail-heading { position: relative; padding-bottom: 26px; border-bottom: 1px solid #45475a; } .detail-heading { position: relative; padding-bottom: 26px; border-bottom: 1px solid var(--bn-border, #3d2366); }
.detail-heading h1 { margin-top: 14px; } .detail-heading h1 { margin-top: 14px; }
.detail-heading .muted { font-size: 1.1rem; } .detail-heading .muted { font-size: 1.1rem; }
.edit-button { position: absolute; top: 0; right: 0; } .edit-button { position: absolute; top: 0; right: 0; }
.body-copy { white-space: pre-wrap; line-height: 1.8; padding-top: 28px; color: #bac2de; } .body-copy { white-space: pre-wrap; line-height: 1.8; padding-top: 28px; color: var(--bn-text, #bac2de); }
.editor { display: grid; gap: 18px; }
.editor label { display: grid; gap: 7px; color: #bac2de; font-size: .9rem; font-weight: 650; } /* CRUD /etc/backlog.rc Styles */
.editor input, .editor textarea, .editor select { width: 100%; border: 1px solid #585b70; border-radius: 8px; padding: 11px 12px; background: #313244; color: #cdd6f4; font: inherit; font-weight: 400; } .stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 16px; margin-bottom: 24px; }
.editor input:focus, .editor textarea:focus, .editor select:focus { outline: 2px solid #89b4fa; outline-offset: 1px; } .stat-card { background: var(--bn-surface, #1b1035); border: 1px solid var(--bn-border, #3d2366); border-radius: 12px; padding: 18px 22px; }
.editor textarea { resize: vertical; } .stat-label { font-size: 0.8rem; font-weight: 600; color: var(--bn-muted, #a6adc8); text-transform: uppercase; letter-spacing: 0.05em; }
.actions { display: flex; gap: 10px; margin-top: 4px; } .stat-value { font-size: 1.8rem; font-weight: 800; margin-top: 4px; color: var(--bn-text, #f5e0e8); }
.primary-button, .secondary-button { border: 0; border-radius: 8px; min-height: 42px; padding: 0 17px; cursor: pointer; font-weight: 650; text-decoration: none; display: inline-flex; align-items: center; justify-content: center; } .stat-published .stat-value { color: var(--bn-secondary, #00f0ff); }
.primary-button { background: #cba6f7; color: #1e1e2e; } .stat-draft .stat-value { color: var(--bn-warning, #ffd166); }
.primary-button:hover { background: #d8b9ff; } .stat-archived .stat-value { color: var(--bn-muted, #a6adc8); }
.primary-button:disabled { opacity: .6; cursor: wait; }
.actions .secondary-button { margin: 0; background: #45475a; color: #cdd6f4; } .table-toolbar { display: flex; justify-content: space-between; align-items: center; gap: 16px; margin-bottom: 16px; flex-wrap: wrap; }
.inline-button { margin-top: 22px; } .search-box { flex: 1; min-width: 250px; }
.protected-card { text-align: center; } .search-box input { width: 100%; border: 1px solid var(--bn-border-strong, #4a2d7a); border-radius: 8px; padding: 10px 14px; background: var(--bn-surface, #1b1035); color: var(--bn-text, #cdd6f4); }
.search-box input:focus { outline: 2px solid var(--bn-secondary, #00f0ff); border-color: var(--bn-secondary, #00f0ff); }
.filter-group { display: flex; gap: 10px; align-items: center; }
.filter-group select { border: 1px solid var(--bn-border-strong, #4a2d7a); border-radius: 8px; padding: 9px 12px; background: var(--bn-surface, #1b1035); color: var(--bn-text, #cdd6f4); font-size: 0.85rem; }
.table-container { overflow-x: auto; }
.crud-table { width: 100%; border-collapse: collapse; text-align: left; font-size: 0.88rem; }
.crud-table th, .crud-table td { padding: 14px 18px; border-bottom: 1px solid rgba(255,255,255,0.06); }
.crud-table th { background: rgba(0, 0, 0, 0.2); font-weight: 700; color: var(--bn-muted, #a6adc8); text-transform: uppercase; font-size: 0.75rem; letter-spacing: 0.05em; }
.crud-table tbody tr:hover { background: rgba(255,255,255,0.03); }
.post-title-cell { display: flex; flex-direction: column; gap: 3px; }
.post-link { color: var(--bn-text, #f5e0e8); text-decoration: none; font-size: 0.95rem; }
.post-link:hover { color: var(--bn-secondary, #00f0ff); }
.post-slug { font-size: 0.75rem; color: var(--bn-muted, #a6adc8); }
.post-summary-preview { font-size: 0.78rem; margin-top: 4px; max-width: 320px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.category-pill { font-size: 0.72rem; padding: 2px 8px; border-radius: 4px; background: var(--bn-secondary, #2f1d4f); color: var(--bn-accent, #cba6f7); border: 1px solid var(--bn-border-strong, #4a2d7a); }
.status-pill { display: inline-block; font-size: 0.7rem; font-weight: 700; padding: 3px 8px; border-radius: 999px; text-transform: uppercase; letter-spacing: 0.04em; }
.status-published { color: var(--bn-secondary, #00f0ff); background: color-mix(in srgb, var(--bn-secondary) 22%, transparent); border: 1px solid color-mix(in srgb, var(--bn-secondary) 55%, transparent); }
.status-draft { color: var(--bn-warning, #ffd166); background: color-mix(in srgb, var(--bn-warning) 22%, transparent); border: 1px solid color-mix(in srgb, var(--bn-warning) 55%, transparent); }
.status-archived { color: var(--bn-muted, #94a3b8); background: color-mix(in srgb, var(--bn-muted) 44%, transparent); border: 1px solid var(--bn-muted, #475569); }
.action-buttons-cell { display: flex; gap: 6px; justify-content: flex-end; }
.table-action-button { border: 1px solid var(--bn-border-strong, #4a2d7a); background: var(--bn-surface-raised, #251642); color: var(--bn-text, #cdd6f4); border-radius: 6px; padding: 6px 10px; font-size: 0.78rem; font-weight: 600; }
.table-action-button:hover { border-color: var(--bn-secondary, #00f0ff); color: var(--bn-inverted, #ffffff); }
.table-action-button.delete:hover { border-color: var(--bn-danger, #ef233c); background: color-mix(in srgb, var(--bn-danger) 22%, transparent); color: var(--bn-danger, #ef233c); }
.font-mono { font-family: ui-monospace, SFMono-Regular, monospace; }
.font-semibold { font-weight: 600; }
/* Modals & Terminal Windows */
.terminal-backdrop { .terminal-backdrop {
position: fixed; position: fixed;
z-index: 20; z-index: 50;
inset: 0; inset: 0;
display: grid; display: grid;
place-items: center; place-items: center;
padding: 24px; padding: 24px;
background: #000000b8; background: rgba(0, 0, 0, 0.75);
backdrop-filter: blur(3px); backdrop-filter: blur(5px);
} }
.terminal-window { .terminal-window {
width: min(100%, 620px); width: min(100%, 650px);
overflow: hidden; overflow: hidden;
border: 1px solid #4d4d4d; border: 1px solid color-mix(in srgb, var(--bn-accent) 88%, transparent);
border-radius: 8px; border-radius: 12px;
background: #000; background: var(--bn-surface-sunken, #140929);
color: #fff; color: var(--bn-inverted, #fff);
box-shadow: 0 24px 90px #000; box-shadow: 0 24px 90px rgba(0, 0, 0, 0.7), 0 0 30px rgba(255, 42, 133, 0.2);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
} }
.terminal-titlebar { .terminal-titlebar {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 9px 14px; padding: 12px 18px;
color: #fff; background: var(--bn-surface-raised, #200f3d);
background: #252525; border-bottom: 1px solid var(--bn-border, #3d2366);
border-bottom: 1px solid #454545; font-family: ui-monospace, SFMono-Regular, monospace;
font-size: .82rem; font-size: 0.85rem;
font-weight: 700;
color: var(--bn-secondary, #00f0ff);
} }
.terminal-close { .terminal-close {
border: 0; border: 0;
color: #fff; color: var(--bn-muted, #a6adc8);
background: transparent; background: transparent;
cursor: pointer; cursor: pointer;
font-size: 1.35rem; display: inline-flex;
line-height: 1;
}
.terminal-close:hover { color: #ff6b6b; }
.terminal-body { display: grid; gap: 14px; padding: 24px; }
.terminal-body p { margin: 0; line-height: 1.5; }
.terminal-body label {
display: flex;
align-items: center; align-items: center;
gap: 8px;
color: #fff;
font: inherit;
white-space: nowrap;
} }
.terminal-prompt { color: #fff; font-weight: 700; } /* Sized from the button rather than from a glyph's em box, so the icon and the
.terminal-body input { × it replaced occupy the same space and the titlebar does not shift. */
min-width: 0; .terminal-close svg { width: 1.15rem; height: 1.15rem; }
flex: 1; .terminal-close:hover { color: var(--bn-accent, #ff2a85); }
border: 0;
outline: 0; .warning-callout {
padding: 0; padding: 18px;
color: #fff; border-radius: 8px;
background: #000; background: var(--bn-danger, #3d081b);
font: inherit; border: 1px solid color-mix(in srgb, var(--bn-danger) 88%, transparent);
margin-bottom: 18px;
} }
.terminal-body input::placeholder { color: #777; } .warning-callout h3 { margin: 0 0 8px; color: var(--bn-danger, #ef233c); font-size: 1.1rem; }
.terminal-submit { .warning-callout p { margin: 0 0 6px; line-height: 1.5; color: var(--bn-text, #f5e0e8); }
justify-self: start;
border: 0; .form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
padding: 0; .form-field { display: flex; flex-direction: column; gap: 6px; }
color: #fff; .form-field.full-width { grid-column: span 2; }
background: transparent; .form-field span { font-size: 0.82rem; font-weight: 600; color: var(--bn-muted, #a6adc8); }
font: inherit; .form-field input, .form-field select, .form-field textarea {
cursor: pointer; border: 1px solid var(--bn-border-strong, #4a2d7a);
border-radius: 6px;
padding: 10px 12px;
background: var(--bn-surface-sunken, #1c0e38);
color: var(--bn-text, #f5e0e8);
} }
.terminal-submit:hover { color: #a6e3a1; } .form-field input:focus, .form-field select:focus, .form-field textarea:focus {
.terminal-error { color: #ff6b6b; } outline: 2px solid var(--bn-secondary, #00f0ff);
html[data-theme="light"] { border-color: var(--bn-secondary, #00f0ff);
color: #4c4f69; }
background: #eff1f5;
/* ==========================================================================
TERMINAL PALETTE: rules, not values
Two of this palette's traits are not colours, so they cannot be tokens and were
not lost with the colour overrides. A token is a value; these are behaviour.
========================================================================== */
html[data-theme='terminal'],
html[data-theme='terminal'] * {
font-family: var(--bn-font-mono, 'IBM Plex Mono'), ui-monospace, SFMono-Regular, 'Courier New', Courier, monospace !important;
}
html[data-theme='terminal'] .brand,
html[data-theme='terminal'] .nav-tab.active,
html[data-theme='terminal'] .post-link {
text-shadow: 0 0 2px color-mix(in srgb, var(--bn-accent) 40%, transparent);
} }
html[data-theme="light"] body { background: radial-gradient(circle at 85% -10%, #dce4f4 0, transparent 38rem), #eff1f5; }
html[data-theme="light"] .brand { color: #4c4f69; }
html[data-theme="light"] .brand:hover { color: #8839ef; }
html[data-theme="light"] .brand-prompt { color: #40a02b; }
html[data-theme="light"] .brand-cursor { color: #df8e1d; }
html[data-theme="light"] .brand small { color: #8c8fa1; }
html[data-theme="light"] .text-button,
html[data-theme="light"] .theme-button { border-color: #ccd0da; background: #e6e9ef; color: #5c5f77; }
html[data-theme="light"] .text-button:hover,
html[data-theme="light"] .theme-button:hover { border-color: #8839ef; color: #4c4f69; }
html[data-theme="light"] .mode-badge { border-color: #ccd0da; background: #e6e9ef; color: #6c6f85; }
html[data-theme="light"] .google-button { border-color: #ccd0da; background: #e6e9ef; color: #5c5f77; }
html[data-theme="light"] .google-button:hover { border-color: #1e66f5; background: #dce0e8; }
html[data-theme="light"] .card,
html[data-theme="light"] .filters,
html[data-theme="light"] .content-card { background: #ffffff; border-color: #ccd0da; box-shadow: 0 12px 34px #4c4f6914; }
html[data-theme="light"] h1,
html[data-theme="light"] .content-card h2 a { color: #4c4f69; }
html[data-theme="light"] .muted,
html[data-theme="light"] .content-card p { color: #6c6f85; }
html[data-theme="light"] .eyebrow { color: #8839ef; }
html[data-theme="light"] .filters select,
html[data-theme="light"] .badge-input,
html[data-theme="light"] .editor input,
html[data-theme="light"] .editor textarea,
html[data-theme="light"] .editor select { border-color: #ccd0da; background: #eff1f5; color: #4c4f69; }
html[data-theme="light"] .profile,
html[data-theme="light"] .secondary-button,
html[data-theme="light"] .actions .secondary-button { background: #e6e9ef; color: #5c5f77; }
html[data-theme="light"] .status { color: #179299; background: #d9f1ed; }
html[data-theme="light"] .filter-pill,
html[data-theme="light"] .badge { color: #8839ef; background: #f0e5ff; }
html[data-theme="light"] .body-copy,
html[data-theme="light"] .editor label,
html[data-theme="light"] .filters label { color: #5c5f77; }
html[data-theme="light"] .detail-heading { border-color: #ccd0da; }
html[data-theme="light"] .read-link,
html[data-theme="light"] .back-link { color: #1e66f5; }
html[data-theme="light"] .primary-button { background: #8839ef; color: #ffffff; }
html[data-theme="light"] .primary-button:hover { background: #7c2fe0; }
@media (max-width: 480px) { .shell { padding: 0 16px 56px; } .card { padding: 28px 22px; } .brand small { font-size: .58rem; } }
@media (max-width: 640px) { .content-page, .detail-page { padding-top: 3vh; } .edit-button { position: static; margin-top: 10px; } .filters { grid-template-columns: 1fr; } }

View file

@ -1,10 +1,26 @@
import { defineConfig } from 'vite' import {defineConfig, loadEnv, type Plugin} from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
export default defineConfig({ function themeContract(origin: string): Plugin {
plugins: [react()], const base = origin.replace(/\/+$/, '')
// Build into the repository root; the Dockerfile and `make ui` copy the result return {
// into the Go embed directory (cmd/server/web/dist). name: 'go-bananas-contract',
transformIndexHtml(html) {
if (!base) return html
const tags = [
`<link rel="stylesheet" href="${base}/rt/v1/contract.css" data-theme-contract>`,
`<script type="module" src="${base}/rt/v1/components.js"></script>`,
].join('\n ')
return html.replace('</head>', ` ${tags}\n </head>`)
},
}
}
export default defineConfig(({mode}) => {
const env = loadEnv(mode, '.', '')
const themeOrigin = env.VITE_THEME_BASE_URL || ''
return {
plugins: [react(), themeContract(themeOrigin)],
build: { build: {
outDir: '../dist', outDir: '../dist',
emptyOutDir: true, emptyOutDir: true,
@ -21,4 +37,5 @@ export default defineConfig({
}, },
}, },
}, },
}
}) })