Compare commits
No commits in common. "f40485842d90d374f30f5dccaeab655b854ff0e2" and "d9d890a72147ddcd63a26a60ba92e81ad7b7445f" have entirely different histories.
f40485842d
...
d9d890a721
61 changed files with 335 additions and 18177 deletions
11
.env.example
11
.env.example
|
|
@ -21,14 +21,3 @@ 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=
|
|
||||||
16
.github/workflows/build-test-push.yml
vendored
16
.github/workflows/build-test-push.yml
vendored
|
|
@ -7,7 +7,7 @@ on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
IMAGE_NAME: batty1039.startdedicated.net:5000/go-tell
|
IMAGE_NAME: batty1039.startdedicated.net:5000/cms-demo
|
||||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|
@ -64,18 +64,6 @@ 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:
|
||||||
|
|
@ -85,8 +73,6 @@ 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
47
.github/workflows/ci.yml
vendored
|
|
@ -1,47 +0,0 @@
|
||||||
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
169
.github/workflows/deploy-rancher.yml
vendored
|
|
@ -1,169 +0,0 @@
|
||||||
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
1
.gitignore
vendored
|
|
@ -12,7 +12,6 @@ cmd/server/web/dist/*
|
||||||
.env
|
.env
|
||||||
data/
|
data/
|
||||||
*.json.tmp
|
*.json.tmp
|
||||||
deploy/local.yaml
|
|
||||||
|
|
||||||
# Node
|
# Node
|
||||||
node_modules/
|
node_modules/
|
||||||
|
|
|
||||||
13
Dockerfile
13
Dockerfile
|
|
@ -9,19 +9,18 @@ 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 go.sum ./
|
COPY go.mod ./
|
||||||
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/go-tell ./cmd/server
|
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/cms ./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 go-tell
|
&& adduser -D -u 10001 cms
|
||||||
COPY --from=backend /out/go-tell /usr/local/bin/go-tell
|
COPY --from=backend /out/cms /usr/local/bin/cms
|
||||||
USER go-tell
|
USER cms
|
||||||
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
|
||||||
|
|
@ -29,4 +28,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/go-tell"]
|
ENTRYPOINT ["/usr/local/bin/cms"]
|
||||||
97
Makefile
97
Makefile
|
|
@ -1,31 +1,12 @@
|
||||||
SHELL := /bin/sh
|
SHELL := /bin/sh
|
||||||
|
|
||||||
IMAGE ?= go-tell
|
IMAGE ?= cms-demo
|
||||||
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
|
|
||||||
|
|
||||||
# Helm receives the Go Loose credentials from the environment instead of a values
|
.PHONY: help ui backend demo production run test check docker docker-run clean
|
||||||
# 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"
|
||||||
|
|
@ -33,19 +14,8 @@ 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.
|
||||||
|
|
@ -58,12 +28,6 @@ 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
|
||||||
|
|
@ -85,11 +49,7 @@ check:
|
||||||
go test ./...
|
go test ./...
|
||||||
|
|
||||||
docker:
|
docker:
|
||||||
docker compose build
|
docker build -t $(IMAGE):$(TAG) .
|
||||||
|
|
||||||
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)
|
||||||
|
|
@ -98,54 +58,3 @@ 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
153
README.md
|
|
@ -1,158 +1,41 @@
|
||||||
# Go Tell
|
# CMS project
|
||||||
|
|
||||||
Go Tell is a small content CMS: one Go binary that serves the API and embeds
|
This repository contains a Go backend and React frontend for the CMS.
|
||||||
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`,
|
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.
|
||||||
`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.example`). The API
|
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`.
|
||||||
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`.
|
|
||||||
|
|
||||||
| Variable | Default | Purpose |
|
## Single sign-on
|
||||||
| --- | --- | --- |
|
|
||||||
| `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 |
|
|
||||||
|
|
||||||
## Authentication
|
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:
|
||||||
|
|
||||||
Go Tell accepts an editor from three sources, in this order: the demo sudo
|
- **Header present** → the request is the owner and can edit content (`canEdit: true`).
|
||||||
token, the identity-aware proxy header, and the Go Loose browser session.
|
- **Header absent** → the request is anonymous and content is read-only (`canEdit: false`).
|
||||||
Reading content is always anonymous; `PUT /api/content/:slug` answers `401`
|
|
||||||
without one of them.
|
|
||||||
|
|
||||||
### Go Loose
|
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.
|
||||||
|
|
||||||
Set `GO_LOOSE_CLIENT_ID` and `GO_LOOSE_CLIENT_SECRET` and the CMS grows a
|
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.
|
||||||
browser login:
|
|
||||||
|
|
||||||
1. `GET /api/auth/login` redirects to
|
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.
|
||||||
`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.
|
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.
|
||||||
The anonymous `GET /api/content` endpoint accepts `category=engineering` and
|
|
||||||
repeated `badge=go&badge=docker` filters; selected badges are combined with AND
|
Editing requires the SSO identity: `PUT /api/content/:slug` returns `401` without it. `GET /api/content/:slug` and the taxonomy endpoints are public.
|
||||||
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 run # build the frontend, then run the server
|
make dev # up + migrate + backend + frontend
|
||||||
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
13553
cert-manager.yaml
File diff suppressed because it is too large
Load diff
|
|
@ -7,11 +7,10 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/danyel/go-tell/internal/config"
|
"github.com/example/cms/internal/config"
|
||||||
"github.com/danyel/go-tell/internal/golooseauth"
|
"github.com/example/cms/internal/security"
|
||||||
"github.com/danyel/go-tell/internal/security"
|
"github.com/example/cms/internal/service"
|
||||||
"github.com/danyel/go-tell/internal/service"
|
"github.com/example/cms/internal/web"
|
||||||
"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
|
||||||
|
|
@ -34,10 +33,6 @@ 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()
|
||||||
|
|
@ -45,14 +40,9 @@ 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, browser, demo).WithThemeOrigin(cfg.ThemeBaseURL).Routes(cfg.AllowedOrigins),
|
Handler: web.NewHandler(content, assets, sso, demo).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)
|
||||||
|
|
|
||||||
1
cmd/server/web/dist/placeholder.txt
vendored
Normal file
1
cmd/server/web/dist/placeholder.txt
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
Frontend assets are generated by `make ui` or the CI workflow before building.
|
||||||
|
|
@ -1,88 +0,0 @@
|
||||||
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: []
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
.git/
|
|
||||||
.github/
|
|
||||||
.idea/
|
|
||||||
.vscode/
|
|
||||||
*.tgz
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
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"
|
|
||||||
|
|
@ -1,221 +0,0 @@
|
||||||
# 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.
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
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 }}
|
|
||||||
|
|
@ -1,77 +0,0 @@
|
||||||
{{- 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 }}
|
|
||||||
|
|
@ -1,134 +0,0 @@
|
||||||
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 }}
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
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 }}
|
|
||||||
|
|
@ -1,74 +0,0 @@
|
||||||
{{- 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 }}
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
{{- 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 }}
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
{{- 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 }}
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
{{- $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 }}
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
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"
|
|
||||||
|
|
@ -1,104 +0,0 @@
|
||||||
# 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: {}
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -1,103 +0,0 @@
|
||||||
# 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: {}
|
|
||||||
|
|
@ -1,107 +0,0 @@
|
||||||
# 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: {}
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -1,104 +0,0 @@
|
||||||
# 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: {}
|
|
||||||
|
|
@ -1,123 +0,0 @@
|
||||||
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
79
go.mod
|
|
@ -1,80 +1,3 @@
|
||||||
module github.com/danyel/go-tell
|
module github.com/example/cms
|
||||||
|
|
||||||
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
87
go.sum
|
|
@ -1,87 +0,0 @@
|
||||||
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=
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
// Package config carries the runtime settings for the CMS. There is no
|
// Package config carries the runtime settings for the demo 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. Editing rights come from Go Loose, from the single-sign-on
|
// a single JSON file.
|
||||||
// proxy, or from the demo profile, depending on what is configured.
|
|
||||||
package config
|
package config
|
||||||
|
|
||||||
import "os"
|
import "os"
|
||||||
|
|
@ -9,32 +8,8 @@ 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"),
|
||||||
|
|
@ -45,15 +20,6 @@ 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"),
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,261 +0,0 @@
|
||||||
// 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
|
|
||||||
}
|
|
||||||
|
|
@ -1,150 +0,0 @@
|
||||||
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()}
|
|
||||||
}
|
|
||||||
|
|
@ -33,9 +33,9 @@ func TestSSOTokenVerifierChecksExpectedToken(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSSOTokenVerifierCookieFallback(t *testing.T) {
|
func TestSSOTokenVerifierCookieFallback(t *testing.T) {
|
||||||
v := NewSSOTokenVerifier("X-SSO-Token", "", "go_tell_session")
|
v := NewSSOTokenVerifier("X-SSO-Token", "", "cms_session")
|
||||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
r.AddCookie(&http.Cookie{Name: "go_tell_session", Value: "cookie-token"})
|
r.AddCookie(&http.Cookie{Name: "cms_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")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
|
@ -63,9 +62,6 @@ 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
|
||||||
|
|
@ -210,11 +206,14 @@ 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 || fmt.Sprint(item.ID) == slug {
|
if item.Slug == slug {
|
||||||
return cloneContent(item), nil
|
return cloneContent(item), nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -233,7 +232,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 strings.TrimSpace(c.Title) == "" || strings.TrimSpace(c.Body) == "" {
|
if !validSlug(c.Slug) || 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)
|
||||||
|
|
@ -245,7 +244,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 || fmt.Sprint(s.items[i].ID) == c.Slug {
|
if s.items[i].Slug == c.Slug {
|
||||||
index = i
|
index = i
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
@ -260,14 +259,8 @@ 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
|
||||||
|
|
@ -286,99 +279,6 @@ 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 {
|
||||||
|
|
@ -569,40 +469,6 @@ 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) }
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
package web
|
package web
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
@ -9,39 +8,22 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
goloose "github.com/danyel/go-loose/client"
|
"github.com/example/cms/internal/security"
|
||||||
|
"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 an authenticated
|
// Reading content is anonymous; every mutation requires the identity asserted by
|
||||||
// editor: a Go Loose browser session, the identity asserted by the upstream
|
// the upstream SSO proxy.
|
||||||
// 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// WithThemeOrigin sets the origin the shared styling contract is served from.
|
func NewHandler(content service.IContentService, assets fs.FS, sso security.ITokenVerifier, demo ...*service.DemoAuth) *Handler {
|
||||||
//
|
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]
|
||||||
}
|
}
|
||||||
|
|
@ -53,82 +35,22 @@ 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/content", h.contentCollection)
|
mux.HandleFunc("/api/test/protected", h.protected)
|
||||||
mux.HandleFunc("/api/dev/null", h.contentCollection)
|
mux.HandleFunc("/api/content", h.contentList)
|
||||||
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)
|
||||||
response := map[string]any{"authenticated": authenticated, "canEdit": authenticated, "username": username}
|
jsonWrite(w, http.StatusOK, 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) {
|
||||||
|
|
@ -168,17 +90,6 @@ 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)
|
||||||
|
|
@ -203,32 +114,6 @@ 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)
|
||||||
|
|
@ -257,7 +142,6 @@ 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)
|
||||||
|
|
@ -293,25 +177,11 @@ 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 "))
|
||||||
|
|
@ -322,48 +192,9 @@ 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) {
|
||||||
|
|
@ -384,52 +215,9 @@ 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(body)
|
_, _ = w.Write(index)
|
||||||
}
|
|
||||||
|
|
||||||
// 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 }
|
||||||
|
|
@ -451,7 +239,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, DELETE, OPTIONS")
|
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, OPTIONS")
|
||||||
if r.Method == http.MethodOptions {
|
if r.Method == http.MethodOptions {
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -8,10 +8,8 @@ import (
|
||||||
"testing"
|
"testing"
|
||||||
"testing/fstest"
|
"testing/fstest"
|
||||||
|
|
||||||
"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/danyel/go-tell/internal/service"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type fakeContent struct{ updated bool }
|
type fakeContent struct{ updated bool }
|
||||||
|
|
@ -32,26 +30,13 @@ 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, browser ...*golooseauth.Auth) http.Handler {
|
func newTestRoutes(content *fakeContent) 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('go-tell')")},
|
"assets/index.js": &fstest.MapFile{Data: []byte("console.log('cms')")},
|
||||||
}
|
}
|
||||||
var auth *golooseauth.Auth
|
return NewHandler(content, assets, security.NewSSOTokenVerifier("X-SSO-Token", "", "")).Routes("http://localhost:5173")
|
||||||
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) {
|
||||||
|
|
@ -104,68 +89,6 @@ 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{})
|
||||||
|
|
||||||
|
|
@ -180,48 +103,4 @@ 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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,75 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
-- +goose Up
|
|
||||||
-- +goose StatementBegin
|
|
||||||
SELECT 'up SQL query';
|
|
||||||
-- +goose StatementEnd
|
|
||||||
|
|
||||||
-- +goose Down
|
|
||||||
-- +goose StatementBegin
|
|
||||||
SELECT 'down SQL query';
|
|
||||||
-- +goose StatementEnd
|
|
||||||
BIN
ui/app/assets/background.png
Normal file
BIN
ui/app/assets/background.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 254 KiB |
|
|
@ -3,24 +3,8 @@
|
||||||
<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="#120826" />
|
<meta name="theme-color" content="#1e1e2e" />
|
||||||
<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>
|
||||||
|
|
|
||||||
24
ui/app/package-lock.json
generated
24
ui/app/package-lock.json
generated
|
|
@ -1,14 +1,13 @@
|
||||||
{
|
{
|
||||||
"name": "go-tell-ui",
|
"name": "cms-ui",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "go-tell-ui",
|
"name": "cms-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"
|
||||||
|
|
@ -1267,7 +1266,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==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
|
|
@ -1396,7 +1395,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==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
|
|
@ -1562,21 +1561,6 @@
|
||||||
"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",
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,14 @@
|
||||||
{
|
{
|
||||||
"name": "go-tell-ui",
|
"name": "cms-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 --build --force"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"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"
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import {useEffect, useRef, useState, type ReactNode} from 'react'
|
import {useEffect, useRef, useState} 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,
|
||||||
|
|
@ -13,7 +12,6 @@ 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'
|
||||||
|
|
||||||
|
|
@ -83,7 +81,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 /dev/null && ls</span></div>
|
<div><span className="eyebrow">$ cd ~/backlog && 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}
|
||||||
|
|
@ -138,8 +136,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="/dev/null">Back
|
<div className="card"><p className="error" role="alert">{error ?? 'Content not found.'}</p><Link to="/content">Back
|
||||||
to /dev/null</Link></div>
|
to content</Link></div>
|
||||||
</Shell>
|
</Shell>
|
||||||
const save = async () => {
|
const save = async () => {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
|
|
@ -163,9 +161,7 @@ function ContentDetail() {
|
||||||
}
|
}
|
||||||
return <Shell>
|
return <Shell>
|
||||||
<article className="detail-page">
|
<article className="detail-page">
|
||||||
<Link className="back-link" to="/dev/null">
|
<Link className="back-link" to="/content">← All content</Link>
|
||||||
<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}
|
||||||
|
|
@ -185,7 +181,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)
|
||||||
})}><X aria-hidden="true"/></button></span>)}
|
})}>×</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
|
||||||
|
|
@ -219,49 +215,21 @@ 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 session is signed in. This page is not linked until you are.</p><Link
|
content</h1><p>Your SSO proxy asserted the owner identity for this request.</p><Link
|
||||||
className="primary-button inline-button" to="/dev/null">Browse /dev/null</Link></section>
|
className="primary-button inline-button" to="/content">Browse content</Link></section>
|
||||||
</Shell>
|
</Shell>
|
||||||
}
|
}
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
return <Routes>
|
return <Routes>
|
||||||
<Route path="/" element={<Navigate to="/dev/null" replace/>}/>
|
<Route path="/" element={<Navigate to="/content" replace/>}/>
|
||||||
<Route path="/dev/null" element={<ContentList/>}/>
|
<Route path="/protected" element={<ProtectedTest/>}/>
|
||||||
<Route path="/dev/null/:id" element={<ContentDetail/>}/>
|
<Route path="/test" element={<ProtectedTest/>}/>
|
||||||
<Route path="/etc/backlog.rc" element={<RequireAuth title="Backlog management"><BacklogRc/></RequireAuth>}/>
|
<Route path="/content" element={<ContentList/>}/>
|
||||||
<Route path="/profile" element={<RequireAuth title="Profile"><ProfilePage/></RequireAuth>}/>
|
<Route path="/content/:id" element={<ContentDetail/>}/>
|
||||||
<Route path="/content" element={<Navigate to="/dev/null" replace/>}/>
|
<Route path="*" element={<Navigate to="/content" 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>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import {type Badge, type Category, type Content, type ContentListResponse, type ContentUpdate, type NewContent} from './models'
|
import {type Badge, type Category, type Content, type ContentListResponse, type ContentUpdate} from './models'
|
||||||
import {type DemoUser, type SessionResponse} from './models'
|
import {type DemoUser, type SessionResponse} from './models'
|
||||||
export type {Badge, Category, Content, ContentListResponse, ContentUpdate, NewContent, DemoUser, SessionResponse}
|
export type {Badge, Category, Content, ContentListResponse, ContentUpdate, 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('go-tell-token')
|
const token = window.sessionStorage.getItem('cms-demo-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('You have to sign in to edit content.')
|
if (response.status === 401) throw new Error('Your SSO session is required 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,34 +140,3 @@ 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.')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,23 +1,17 @@
|
||||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react'
|
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react'
|
||||||
import { apiConfig, apiUrl, requestSession, signInWithGoogle, sudoLogin } from './api'
|
import { apiConfig, requestSession, signInWithGoogle, sudoLogin } from './api'
|
||||||
|
|
||||||
// Signing in is either the Go Loose browser login or a redirect to the
|
// There is no sign-in or sign-out: the upstream SSO proxy decides whether the
|
||||||
// identity-aware proxy, depending on what the API advertises: /api/auth/session
|
// current request carries the owner's identity. Signing in just sends the
|
||||||
// returns loginUrl and logoutUrl when Go Loose is configured. The demo profile
|
// browser to the configured Google/IdP URL.
|
||||||
// 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
|
||||||
|
|
@ -29,12 +23,6 @@ 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)
|
||||||
|
|
||||||
|
|
@ -45,19 +33,10 @@ 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)
|
||||||
setDisplayName(undefined)
|
setError(err instanceof Error ? err.message : 'Unable to check your SSO session.')
|
||||||
setEmail(undefined)
|
|
||||||
setAvatarUrl(undefined)
|
|
||||||
setError(err instanceof Error ? err.message : 'Unable to check your session.')
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
|
|
@ -65,41 +44,25 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
|
||||||
useEffect(() => { void refresh() }, [refresh])
|
useEffect(() => { void refresh() }, [refresh])
|
||||||
|
|
||||||
const signIn = useCallback(() => {
|
const signIn = useCallback(() => { signInWithGoogle() }, [])
|
||||||
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('go-tell-token', result.token)
|
window.sessionStorage.setItem('cms-demo-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(() => {
|
||||||
if (logoutUrl) {
|
window.sessionStorage.removeItem('cms-demo-token')
|
||||||
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)
|
}, [])
|
||||||
setEmail(undefined)
|
const canSignIn = apiConfig.signIn !== ''
|
||||||
setAvatarUrl(undefined)
|
|
||||||
}, [logoutUrl])
|
|
||||||
const canSignIn = apiConfig.signIn !== '' || loginUrl !== ''
|
|
||||||
const canSignOut = logoutUrl !== ''
|
|
||||||
|
|
||||||
const value = useMemo(
|
const value = useMemo(
|
||||||
() => ({ 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],
|
[loading, authenticated, canEdit, username, error, refresh, canSignIn, 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
47
ui/app/src/bananas-elements.d.ts
vendored
|
|
@ -1,47 +0,0 @@
|
||||||
/**
|
|
||||||
* 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 {}
|
|
||||||
|
|
@ -1,113 +0,0 @@
|
||||||
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>
|
|
||||||
}
|
|
||||||
|
|
@ -1,679 +0,0 @@
|
||||||
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>"{itemToDelete.title}"</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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,18 +1,15 @@
|
||||||
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={`/dev/null/${item.id}`}>{item.title}</Link></h2>
|
<h2><Link to={`/content/${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={`/dev/null/${item.id}`}>
|
<Link className="read-link" to={`/content/${item.id}`}>Read more <span aria-hidden="true">→</span></Link>
|
||||||
Read more <ArrowRight aria-hidden="true" />
|
|
||||||
</Link>
|
|
||||||
</article>
|
</article>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import {X} from 'lucide-react'
|
|
||||||
import {type Badge, type Category} from '../models'
|
import {type Badge, type Category} from '../models'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
|
@ -37,9 +36,7 @@ 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))}>
|
onClick={() => props.onBadgesChange(props.badges.filter(value => value !== badge))}>×</button></span>)}
|
||||||
<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() } }}/>
|
||||||
|
|
|
||||||
|
|
@ -1,146 +1,50 @@
|
||||||
import {useEffect, useState, type ReactNode} from 'react'
|
import {useEffect, useState, type ReactNode} from 'react'
|
||||||
import {Link, useLocation} from 'react-router-dom'
|
import {Link} from 'react-router-dom'
|
||||||
|
import {listDemoUsers} from '../api'
|
||||||
import {useAuth} from '../auth'
|
import {useAuth} from '../auth'
|
||||||
import {AccountMenu} from './AccountMenu'
|
import {type DemoUser} from '../models'
|
||||||
|
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} = useAuth()
|
const {authenticated, loading, canSignIn, signIn, username, sudo, signOut} = useAuth()
|
||||||
const location = useLocation()
|
const [sudoOpen, setSudoOpen] = useState(false)
|
||||||
|
const [helpOpen, setHelpOpen] = useState(false)
|
||||||
|
const [users, setUsers] = useState<DemoUser[]>([])
|
||||||
|
const [dark, setDark] = useState(() => window.localStorage.getItem('urpi-theme') !== 'light')
|
||||||
|
|
||||||
// No React state for the palette. The control is a custom element that mutates
|
useEffect(() => { void listDemoUsers().then(setUsers) }, [])
|
||||||
// `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(() => {
|
||||||
// Establish a palette on first load. Without this the page starts with no
|
document.documentElement.dataset.theme = dark ? 'dark' : 'light'
|
||||||
// data-theme at all, so the contract matches no rule and the application renders
|
window.localStorage.setItem('urpi-theme', dark ? 'dark' : 'light')
|
||||||
// on the stylesheet's fallbacks. Those fallbacks happen to be cyberpunk, which is
|
}, [dark])
|
||||||
// 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
|
|
||||||
}
|
|
||||||
|
|
||||||
const onChange = (event: Event) => {
|
return <main className="shell">
|
||||||
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">
|
||||||
<div className="brand-group">
|
<Link className="brand" to="/content"><span className="brand-prompt">~/</span> Urpi's backlog
|
||||||
<Link className="brand" to="/dev/null">
|
<span className="brand-cursor">_</span><small>linux · go · java · ideas</small></Link>
|
||||||
<span className="brand-prompt">~/</span> Urpi'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>}
|
||||||
The palette and appearance controls, injected rather than hand-written.
|
<button className="theme-button" type="button" aria-label="Demo users"
|
||||||
They live in a shadow root, so this application's own CSS cannot restyle
|
onClick={() => setHelpOpen(value => !value)}>?</button>
|
||||||
them, and they read the palettes the service publishes rather than a list
|
{!loading && !authenticated && users.length > 0 &&
|
||||||
duplicated here.
|
<button className="theme-button" type="button" onClick={() => setSudoOpen(true)}>sudo</button>}
|
||||||
*/}
|
{!loading && authenticated && users.length > 0 &&
|
||||||
<ThemeDropdown />
|
<button className="theme-button" type="button" onClick={signOut}>logout</button>}
|
||||||
<bananas-appearance-toggle title="Appearance" />
|
{!loading && !authenticated && canSignIn &&
|
||||||
|
<button className="google-button session-button" type="button" onClick={signIn}>
|
||||||
<AccountMenu/>
|
<span aria-hidden="true">G</span> Sign in with Google</button>}
|
||||||
|
<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>
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
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 = {
|
||||||
|
|
@ -47,12 +46,9 @@ 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 type="button" className="terminal-close" aria-label="Close sudo terminal" onClick={onClose}>×</button>
|
||||||
<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>
|
||||||
|
|
|
||||||
|
|
@ -10,16 +10,6 @@ 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 }
|
||||||
|
|
|
||||||
|
|
@ -12,18 +12,7 @@ export type Content = {
|
||||||
publishedAt?: string
|
publishedAt?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ContentUpdate = Pick<Content, 'title' | 'summary' | 'body' | 'status' | 'badges'> & { category?: string }
|
export type ContentUpdate = Pick<Content, 'title' | 'summary' | 'body' | 'status' | 'badges'>
|
||||||
|
|
||||||
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[]
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,2 @@
|
||||||
export type {Badge, Category, Content, ContentListResponse, ContentUpdate, NewContent} from './content'
|
export type {Badge, Category, Content, ContentListResponse, ContentUpdate} from './content'
|
||||||
export type {DemoUser, SessionResponse, User} from './auth'
|
export type {DemoUser, SessionResponse, User} from './auth'
|
||||||
|
|
|
||||||
|
|
@ -1,416 +1,203 @@
|
||||||
/* ==========================================================================
|
|
||||||
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, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
|
||||||
color: var(--bn-text, #cdd6f4);
|
color: #cdd6f4;
|
||||||
background: var(--bn-bg, #120826);
|
background: #1e1e2e;
|
||||||
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%, var(--bn-accent, #2e1053) 0, transparent 38rem), var(--bn-bg, #120826);
|
background: radial-gradient(circle at 85% -10%, #302b50 0, transparent 38rem), #1e1e2e;
|
||||||
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: 1140px; margin: auto; padding: 0 28px 72px; }
|
.shell { min-height: 100vh; max-width: 1080px; margin: auto; padding: 0 28px 72px; }
|
||||||
|
.header { height: 94px; display: flex; align-items: center; justify-content: space-between; }
|
||||||
/* Header & Nav */
|
.header-actions { display: flex; align-items: center; gap: 8px; }
|
||||||
.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 { position: relative; color: #cdd6f4; font-size: 1.15rem; font-weight: 750; text-decoration: none; letter-spacing: -.02em; }
|
||||||
.brand-group { display: flex; align-items: center; gap: 28px; }
|
.brand:hover { color: #cba6f7; }
|
||||||
.brand { position: relative; color: var(--bn-text, #f5e0e8); font-size: 1.15rem; font-weight: 750; text-decoration: none; letter-spacing: -.02em; }
|
.brand-prompt { color: #a6e3a1; font-family: ui-monospace, SFMono-Regular, monospace; }
|
||||||
.brand:hover { color: var(--bn-secondary, #00f0ff); }
|
.brand-cursor { color: #f9e2af; font-family: ui-monospace, SFMono-Regular, monospace; animation: blink 1.2s steps(2, start) infinite; }
|
||||||
.brand-prompt { color: var(--bn-secondary, #00f0ff); font-family: ui-monospace, SFMono-Regular, monospace; }
|
.brand small { display: block; margin: 5px 0 0 22px; color: #7f849c; font-size: .68rem; font-weight: 600; letter-spacing: .08em; text-transform: uppercase; }
|
||||||
.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; }
|
||||||
.header-nav { display: flex; align-items: center; gap: 8px; }
|
.text-button:hover { border-color: #89b4fa; color: #cdd6f4; }
|
||||||
.nav-tab {
|
.theme-button { border: 1px solid #45475a; border-radius: 8px; background: #313244; color: #bac2de; padding: 8px 12px; }
|
||||||
font-family: ui-monospace, SFMono-Regular, monospace;
|
.theme-button:hover { border-color: #cba6f7; color: #f5e0e8; }
|
||||||
font-size: 0.85rem;
|
.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-weight: 600;
|
.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; }
|
||||||
padding: 6px 14px;
|
.google-button:hover { background: #45475a; border-color: #89b4fa; }
|
||||||
border-radius: 6px;
|
.google-button span { color: #89b4fa; font-weight: 800; }
|
||||||
text-decoration: none;
|
.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; }
|
||||||
color: var(--bn-muted, #a6adc8);
|
h1 { margin: 10px 0 12px; color: #f5e0e8; font-size: clamp(1.7rem, 4vw, 2.4rem); letter-spacing: -.04em; }
|
||||||
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: var(--bn-muted, #a6adc8); }
|
.muted { color: #a6adc8; }
|
||||||
.small { font-size: .875rem; }
|
.small { font-size: .875rem; }
|
||||||
.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; }
|
.eyebrow { color: #cba6f7; font-size: .75rem; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; }
|
||||||
|
.secondary-button { width: 100%; min-height: 48px; margin-top: 20px; border: 0; border-radius: 9px; background: #45475a; color: #cdd6f4; cursor: pointer; font-weight: 650; }
|
||||||
/* Buttons */
|
.error { padding: 10px 12px; border-radius: 8px; color: #f38ba8; background: #3b2634; font-size: .9rem; }
|
||||||
.primary-button, .secondary-button, .danger-button {
|
.profile { margin-top: 24px; padding: 14px; border-radius: 8px; background: #313244; color: #a6adc8; }
|
||||||
border: 0;
|
.content-page, .detail-page { padding-top: 5vh; }
|
||||||
border-radius: 8px;
|
.page-heading { display: flex; justify-content: space-between; align-items: end; margin-bottom: 28px; }
|
||||||
min-height: 40px;
|
.page-heading h1 { color: #cdd6f4; }
|
||||||
padding: 0 18px;
|
.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; }
|
||||||
cursor: pointer;
|
.filters label { display: grid; gap: 7px; color: #bac2de; font-size: .85rem; font-weight: 650; }
|
||||||
font-weight: 650;
|
.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; }
|
||||||
text-decoration: none;
|
.filters select:focus, .badge-input:focus-within { outline: 2px solid #89b4fa; outline-offset: 1px; }
|
||||||
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: var(--bn-text, #cdd6f4); background: transparent; font: inherit; }
|
.badge-input input { min-width: 180px; flex: 1; border: 0; outline: 0; padding: 5px; color: #cdd6f4; background: transparent; font: inherit; }
|
||||||
.badge-input input::placeholder { color: var(--bn-subtle, #7f849c); }
|
.badge-input input::placeholder { color: #7f849c; }
|
||||||
.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, .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 button {
|
.filter-pill button { border: 0; padding: 0 2px; color: #cba6f7; background: transparent; cursor: pointer; font-size: 1rem; line-height: 1; }
|
||||||
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: 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 { padding: 28px; background: #242438; border: 1px solid #45475a; border-radius: 14px; box-shadow: 0 12px 34px #11111b33; }
|
||||||
.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:hover { border-color: #585b70; box-shadow: 0 15px 40px #11111b55; }
|
||||||
.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: var(--bn-text, #f5e0e8); text-decoration: none; }
|
.content-card h2 a { color: #f5e0e8; text-decoration: none; }
|
||||||
.content-card h2 a:hover, .read-link:hover { color: var(--bn-secondary, #00f0ff); }
|
.content-card h2 a:hover, .read-link:hover { color: #89b4fa; }
|
||||||
.content-card p { color: var(--bn-muted, #a6adc8); margin: 0 0 18px; }
|
.content-card p { color: #a6adc8; margin: 0 0 18px; }
|
||||||
.content-card .content-meta { margin-top: -8px; color: var(--bn-subtle, #7f849c); font-size: .82rem; }
|
.content-card .content-meta { margin-top: -8px; color: #7f849c; font-size: .82rem; }
|
||||||
.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; }
|
.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; }
|
||||||
.read-link, .back-link { color: var(--bn-secondary, #00f0ff); font-weight: 650; text-decoration: none; }
|
.read-link, .back-link { color: #89b4fa; 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: 24px; }
|
.back-link { display: inline-block; margin-bottom: 32px; }
|
||||||
.detail-heading { position: relative; padding-bottom: 26px; border-bottom: 1px solid var(--bn-border, #3d2366); }
|
.detail-heading { position: relative; padding-bottom: 26px; border-bottom: 1px solid #45475a; }
|
||||||
.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: var(--bn-text, #bac2de); }
|
.body-copy { white-space: pre-wrap; line-height: 1.8; padding-top: 28px; color: #bac2de; }
|
||||||
|
.editor { display: grid; gap: 18px; }
|
||||||
/* CRUD /etc/backlog.rc Styles */
|
.editor label { display: grid; gap: 7px; color: #bac2de; font-size: .9rem; font-weight: 650; }
|
||||||
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 16px; margin-bottom: 24px; }
|
.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; }
|
||||||
.stat-card { background: var(--bn-surface, #1b1035); border: 1px solid var(--bn-border, #3d2366); border-radius: 12px; padding: 18px 22px; }
|
.editor input:focus, .editor textarea:focus, .editor select:focus { outline: 2px solid #89b4fa; outline-offset: 1px; }
|
||||||
.stat-label { font-size: 0.8rem; font-weight: 600; color: var(--bn-muted, #a6adc8); text-transform: uppercase; letter-spacing: 0.05em; }
|
.editor textarea { resize: vertical; }
|
||||||
.stat-value { font-size: 1.8rem; font-weight: 800; margin-top: 4px; color: var(--bn-text, #f5e0e8); }
|
.actions { display: flex; gap: 10px; margin-top: 4px; }
|
||||||
.stat-published .stat-value { color: var(--bn-secondary, #00f0ff); }
|
.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-draft .stat-value { color: var(--bn-warning, #ffd166); }
|
.primary-button { background: #cba6f7; color: #1e1e2e; }
|
||||||
.stat-archived .stat-value { color: var(--bn-muted, #a6adc8); }
|
.primary-button:hover { background: #d8b9ff; }
|
||||||
|
.primary-button:disabled { opacity: .6; cursor: wait; }
|
||||||
.table-toolbar { display: flex; justify-content: space-between; align-items: center; gap: 16px; margin-bottom: 16px; flex-wrap: wrap; }
|
.actions .secondary-button { margin: 0; background: #45475a; color: #cdd6f4; }
|
||||||
.search-box { flex: 1; min-width: 250px; }
|
.inline-button { margin-top: 22px; }
|
||||||
.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); }
|
.protected-card { text-align: center; }
|
||||||
.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: 50;
|
z-index: 20;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
background: rgba(0, 0, 0, 0.75);
|
background: #000000b8;
|
||||||
backdrop-filter: blur(5px);
|
backdrop-filter: blur(3px);
|
||||||
}
|
}
|
||||||
.terminal-window {
|
.terminal-window {
|
||||||
width: min(100%, 650px);
|
width: min(100%, 620px);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid color-mix(in srgb, var(--bn-accent) 88%, transparent);
|
border: 1px solid #4d4d4d;
|
||||||
border-radius: 12px;
|
border-radius: 8px;
|
||||||
background: var(--bn-surface-sunken, #140929);
|
background: #000;
|
||||||
color: var(--bn-inverted, #fff);
|
color: #fff;
|
||||||
box-shadow: 0 24px 90px rgba(0, 0, 0, 0.7), 0 0 30px rgba(255, 42, 133, 0.2);
|
box-shadow: 0 24px 90px #000;
|
||||||
|
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: 12px 18px;
|
padding: 9px 14px;
|
||||||
background: var(--bn-surface-raised, #200f3d);
|
color: #fff;
|
||||||
border-bottom: 1px solid var(--bn-border, #3d2366);
|
background: #252525;
|
||||||
font-family: ui-monospace, SFMono-Regular, monospace;
|
border-bottom: 1px solid #454545;
|
||||||
font-size: 0.85rem;
|
font-size: .82rem;
|
||||||
font-weight: 700;
|
|
||||||
color: var(--bn-secondary, #00f0ff);
|
|
||||||
}
|
}
|
||||||
.terminal-close {
|
.terminal-close {
|
||||||
border: 0;
|
border: 0;
|
||||||
color: var(--bn-muted, #a6adc8);
|
color: #fff;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: inline-flex;
|
font-size: 1.35rem;
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
/* Sized from the button rather than from a glyph's em box, so the icon and the
|
.terminal-prompt { color: #fff; font-weight: 700; }
|
||||||
× it replaced occupy the same space and the titlebar does not shift. */
|
.terminal-body input {
|
||||||
.terminal-close svg { width: 1.15rem; height: 1.15rem; }
|
min-width: 0;
|
||||||
.terminal-close:hover { color: var(--bn-accent, #ff2a85); }
|
flex: 1;
|
||||||
|
border: 0;
|
||||||
.warning-callout {
|
outline: 0;
|
||||||
padding: 18px;
|
padding: 0;
|
||||||
border-radius: 8px;
|
color: #fff;
|
||||||
background: var(--bn-danger, #3d081b);
|
background: #000;
|
||||||
border: 1px solid color-mix(in srgb, var(--bn-danger) 88%, transparent);
|
font: inherit;
|
||||||
margin-bottom: 18px;
|
|
||||||
}
|
}
|
||||||
.warning-callout h3 { margin: 0 0 8px; color: var(--bn-danger, #ef233c); font-size: 1.1rem; }
|
.terminal-body input::placeholder { color: #777; }
|
||||||
.warning-callout p { margin: 0 0 6px; line-height: 1.5; color: var(--bn-text, #f5e0e8); }
|
.terminal-submit {
|
||||||
|
justify-self: start;
|
||||||
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
border: 0;
|
||||||
.form-field { display: flex; flex-direction: column; gap: 6px; }
|
padding: 0;
|
||||||
.form-field.full-width { grid-column: span 2; }
|
color: #fff;
|
||||||
.form-field span { font-size: 0.82rem; font-weight: 600; color: var(--bn-muted, #a6adc8); }
|
background: transparent;
|
||||||
.form-field input, .form-field select, .form-field textarea {
|
font: inherit;
|
||||||
border: 1px solid var(--bn-border-strong, #4a2d7a);
|
cursor: pointer;
|
||||||
border-radius: 6px;
|
|
||||||
padding: 10px 12px;
|
|
||||||
background: var(--bn-surface-sunken, #1c0e38);
|
|
||||||
color: var(--bn-text, #f5e0e8);
|
|
||||||
}
|
}
|
||||||
.form-field input:focus, .form-field select:focus, .form-field textarea:focus {
|
.terminal-submit:hover { color: #a6e3a1; }
|
||||||
outline: 2px solid var(--bn-secondary, #00f0ff);
|
.terminal-error { color: #ff6b6b; }
|
||||||
border-color: var(--bn-secondary, #00f0ff);
|
html[data-theme="light"] {
|
||||||
}
|
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; } }
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,10 @@
|
||||||
import {defineConfig, loadEnv, type Plugin} from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
function themeContract(origin: string): Plugin {
|
export default defineConfig({
|
||||||
const base = origin.replace(/\/+$/, '')
|
plugins: [react()],
|
||||||
return {
|
// Build into the repository root; the Dockerfile and `make ui` copy the result
|
||||||
name: 'go-bananas-contract',
|
// into the Go embed directory (cmd/server/web/dist).
|
||||||
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,
|
||||||
|
|
@ -37,5 +21,4 @@ export default defineConfig(({mode}) => {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue