build and ready for demo

login screen
This commit is contained in:
Daniel Noulet 2026-09-19 17:33:44 +02:00
parent 0ae2dc3888
commit 6af2853faa
33 changed files with 2020 additions and 1221 deletions

20
.dockerignore Normal file
View file

@ -0,0 +1,20 @@
# Keep the build context small and never ship local state.
.git
.gitignore
.idea
.vscode
*.md
.env
.env.example
# Node and frontend build output
**/node_modules
ui/dist
ui/app/dist
# Go build output
bin
# Local content store
*.json.tmp
data

View file

@ -1,6 +1,14 @@
CMS_HTTP_ADDR=:8080 CMS_HTTP_ADDR=:8080
CMS_APPLICATION_DATABASE_URL="host=localhost user=cms password=cms dbname=cms port=5432 sslmode=disable"
CMS_ALLOWED_ORIGINS=http://localhost:5173 CMS_ALLOWED_ORIGINS=http://localhost:5173
CMS_PROFILE=demo
# Production profile uses SSO and reserves this URL for the PostgreSQL
# persistence adapter. Demo mode intentionally does not need a database.
CMS_POSTGRES_URL=
# Optional content file. Leave empty for a pure in-memory demo that reloads the
# seed on every start. Set it to a path on a mounted volume to keep edits:
# CMS_CONTENT_FILE=/data/content.json
CMS_CONTENT_FILE=
# Single sign-on. The CMS does not sign anyone in: an identity-aware proxy # Single sign-on. The CMS does not sign anyone in: an identity-aware proxy
# (oauth2-proxy, Authelia, Cloudflare Access, ...) authenticates the owner and # (oauth2-proxy, Authelia, Cloudflare Access, ...) authenticates the owner and

14
.gitignore vendored
View file

@ -2,15 +2,19 @@
bin/ bin/
*.test *.test
# Local environment and data # Frontend build output. The compiled app is embedded from
# cmd/server/web/dist, which keeps only its committed placeholder in git.
ui/dist/
cmd/server/web/dist/*
!cmd/server/web/dist/placeholder.txt
# Local environment and content store
.env .env
*.db data/
*.sqlite *.json.tmp
*.sqlite3
# Node # Node
node_modules/ node_modules/
ui/app/dist/
ui/app/.vite/ ui/app/.vite/
# IDE and OS # IDE and OS

31
Dockerfile Normal file
View file

@ -0,0 +1,31 @@
# Build the React app into ui/dist.
FROM node:22-alpine AS ui
WORKDIR /src/ui/app
COPY ui/app/package.json ui/app/package-lock.json ./
RUN npm ci
COPY ui/app/ ./
RUN npm run build -- --outDir /src/ui/dist --emptyOutDir
# Compile the Go binary with the built frontend embedded.
FROM golang:1.27-alpine AS backend
WORKDIR /src
COPY go.mod ./
COPY cmd/ ./cmd/
COPY internal/ ./internal/
COPY --from=ui /src/ui/dist/ ./cmd/server/web/dist/
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/cms ./cmd/server
# Runtime: one static binary, no database, no shell tooling.
FROM alpine:3.20
RUN apk add --no-cache ca-certificates tzdata \
&& adduser -D -u 10001 cms
COPY --from=backend /out/cms /usr/local/bin/cms
USER cms
EXPOSE 8080
ENV CMS_HTTP_ADDR=:8080
# Mount a volume here and set CMS_CONTENT_FILE=/data/content.json to keep edits
# across restarts; without it the store is memory-only and the seed reloads.
VOLUME ["/data"]
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s \
CMD wget -qO- http://127.0.0.1:8080/health || exit 1
ENTRYPOINT ["/usr/local/bin/cms"]

View file

@ -1,36 +1,60 @@
SHELL := /bin/sh SHELL := /bin/sh
COMPOSE ?= docker compose IMAGE ?= cms-demo
GOOSE ?= go run github.com/pressly/goose/v3/cmd/goose@v3.24.3 TAG ?= latest
POSTGRES_URL ?= postgres://cms:cms@localhost:5432/cms?sslmode=disable
.PHONY: up down migrate seed backend frontend dev .PHONY: help ui backend demo production run test check docker docker-run clean
up: help:
$(COMPOSE) up -d --wait @echo "make ui build the React app into cmd/server/web/dist"
@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 production run the SSO production profile"
@echo "make run build the UI, then run the server"
@echo "make test run the Go test suite"
@echo "make check gofmt, vet, and tests"
@echo "make docker build the all-in-one image"
@echo "make docker-run run the image on :8080"
@echo "make clean remove build output"
down: # The Go server embeds the compiled frontend, so the UI is built first and copied
$(COMPOSE) down # into the embed directory that cmd/server/main.go points at.
ui:
migrate: @if [ ! -d ui/app/node_modules ]; then \
$(GOOSE) -dir cmd/server/migrations/postgres postgres "$(POSTGRES_URL)" up npm --prefix ui/app ci; \
fi
seed: npm --prefix ui/app run build
psql "$(POSTGRES_URL)" -v ON_ERROR_STOP=1 -f scripts/seed_content.sql rm -rf cmd/server/web/dist
mkdir -p cmd/server/web/dist
cp -R ui/dist/. cmd/server/web/dist/
backend: backend:
set -a; [ ! -f .env ] || . ./.env; set +a; \ set -a; [ ! -f .env ] || . ./.env; set +a; \
CMS_APPLICATION_DATABASE_URL="$${CMS_APPLICATION_DATABASE_URL:-host=localhost user=cms password=cms dbname=cms port=5432 sslmode=disable}" \
go run ./cmd/server go run ./cmd/server
frontend: demo:
@if [ ! -x ui/app/node_modules/.bin/vite ]; then \ CMS_PROFILE=demo $(MAKE) backend
npm --prefix ui/app ci; \
fi
npm --prefix ui/app run dev
dev: up migrate production:
@trap 'kill 0' INT TERM; \ CMS_PROFILE=production $(MAKE) backend
$(MAKE) backend & \
$(MAKE) frontend & \ run: ui backend
wait
test:
go test ./...
check:
gofmt -l ./cmd ./internal
go vet ./...
go test ./...
docker:
docker build -t $(IMAGE):$(TAG) .
docker-run:
docker run --rm -p 8080:8080 --env-file .env $(IMAGE):$(TAG)
clean:
rm -rf cmd/server/web/dist ui/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

View file

@ -1,55 +1,52 @@
package main package main
import ( import (
"context"
"embed" "embed"
"io/fs" "io/fs"
"log" "log"
"net/http" "net/http"
"time"
"github.com/example/cms/internal/config" "github.com/example/cms/internal/config"
"github.com/example/cms/internal/repository"
"github.com/example/cms/internal/security" "github.com/example/cms/internal/security"
"github.com/example/cms/internal/service" "github.com/example/cms/internal/service"
"github.com/example/cms/internal/web" "github.com/example/cms/internal/web"
"github.com/pressly/goose/v3"
"github.com/pressly/goose/v3/database"
"gorm.io/driver/postgres"
"gorm.io/gorm"
) )
//go:embed migrations/postgres/*.sql // web/dist holds the compiled React app. A placeholder file is committed so the
var migrations embed.FS // embed directive always resolves, even when the frontend has not been built.
//
//go:embed all:web/dist
var ui embed.FS
func main() { func main() {
cfg := config.Load() cfg := config.Load()
applicationDB, err := gorm.Open(postgres.Open(cfg.ApplicationDatabaseURL), &gorm.Config{})
content, err := service.NewContentService(service.SeedContent(), cfg.ContentFile)
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
applicationSQLDB, err := applicationDB.DB()
assets, err := fs.Sub(ui, "web/dist")
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
applicationMigrations, err := fs.Sub(migrations, "migrations/postgres")
if err != nil {
log.Fatal(err)
}
applicationStore, err := database.NewStore(goose.DialectPostgres, "goose_application_db_version")
if err != nil {
log.Fatal(err)
}
applicationProvider, err := goose.NewProvider("", applicationSQLDB, applicationMigrations, goose.WithStore(applicationStore))
if err != nil {
log.Fatal(err)
}
if _, err = applicationProvider.Up(context.Background()); err != nil {
log.Fatal(err)
}
content := service.NewContentService(repository.NewContentRepository(applicationDB))
sso := security.NewSSOTokenVerifier(cfg.SSOHeader, cfg.SSOToken, cfg.SSOCookie) sso := security.NewSSOTokenVerifier(cfg.SSOHeader, cfg.SSOToken, cfg.SSOCookie)
var demo *service.DemoAuth
if cfg.Profile == "demo" {
demo = service.NewDemoAuth()
log.Printf("CMS profile: demo (in-memory, %d seeded articles)", len(service.SeedContent()))
} else {
log.Printf("CMS profile: %s (SSO identity required; PostgreSQL URL configured: %t)", cfg.Profile, cfg.PostgresURL != "")
}
server := &http.Server{
Addr: cfg.HTTPAddr,
Handler: web.NewHandler(content, assets, sso, demo).Routes(cfg.AllowedOrigins),
ReadHeaderTimeout: 10 * time.Second,
}
log.Printf("CMS listening on %s", cfg.HTTPAddr) log.Printf("CMS listening on %s", cfg.HTTPAddr)
if err = http.ListenAndServe(cfg.HTTPAddr, web.NewHandler(content, sso).Routes(cfg.AllowedOrigins)); err != nil { if err = server.ListenAndServe(); err != nil {
log.Fatal(err) log.Fatal(err)
} }
} }

View file

@ -1,9 +0,0 @@
-- +goose Up
CREATE TABLE IF NOT EXISTS users (id BIGSERIAL PRIMARY KEY, email TEXT NOT NULL, name TEXT NOT NULL DEFAULT '', provider TEXT NOT NULL, provider_subject TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL);
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email ON users(email);
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_provider_subject ON users(provider, provider_subject);
CREATE TABLE IF NOT EXISTS sessions (id BIGSERIAL PRIMARY KEY, token TEXT NOT NULL UNIQUE, user_id BIGINT REFERENCES users(id), admin_id BIGINT, expires_at TIMESTAMPTZ NOT NULL, created_at TIMESTAMPTZ NOT NULL);
CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at);
-- +goose Down
DROP TABLE IF EXISTS sessions;
DROP TABLE IF EXISTS users;

View file

@ -1,32 +0,0 @@
-- +goose Up
ALTER TABLE users ADD COLUMN IF NOT EXISTS role TEXT NOT NULL DEFAULT '';
ALTER TABLE users ADD COLUMN IF NOT EXISTS editor BOOLEAN NOT NULL DEFAULT FALSE;
CREATE TABLE IF NOT EXISTS content (
id BIGSERIAL PRIMARY KEY,
slug TEXT NOT NULL UNIQUE,
title TEXT NOT NULL,
summary TEXT NOT NULL DEFAULT '',
body TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'draft',
published BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL,
created_by BIGINT REFERENCES users(id),
updated_by BIGINT REFERENCES users(id)
);
CREATE INDEX IF NOT EXISTS idx_content_updated_at ON content(updated_at DESC, created_at DESC, id DESC);
CREATE TABLE IF NOT EXISTS content_history (
id BIGSERIAL PRIMARY KEY,
content_id BIGINT NOT NULL REFERENCES content(id) ON DELETE CASCADE,
operation TEXT NOT NULL,
actor_id BIGINT REFERENCES users(id),
actor_admin_id BIGINT,
created_at TIMESTAMPTZ NOT NULL,
snapshot JSONB NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_content_history_content_id ON content_history(content_id, created_at DESC);
-- +goose Down
DROP TABLE IF EXISTS content_history;
DROP TABLE IF EXISTS content;
ALTER TABLE users DROP COLUMN IF EXISTS editor;
ALTER TABLE users DROP COLUMN IF EXISTS role;

View file

@ -1,26 +0,0 @@
-- +goose Up
CREATE TABLE IF NOT EXISTS categories (
id BIGSERIAL PRIMARY KEY,
slug TEXT NOT NULL UNIQUE,
name TEXT NOT NULL
);
ALTER TABLE content ADD COLUMN IF NOT EXISTS category_id BIGINT REFERENCES categories(id);
CREATE INDEX IF NOT EXISTS idx_content_category_id ON content(category_id);
CREATE TABLE IF NOT EXISTS badges (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL UNIQUE
);
CREATE TABLE IF NOT EXISTS content_badges (
content_id BIGINT NOT NULL REFERENCES content(id) ON DELETE CASCADE,
badge_id BIGINT NOT NULL REFERENCES badges(id) ON DELETE CASCADE,
PRIMARY KEY (content_id, badge_id)
);
CREATE INDEX IF NOT EXISTS idx_content_badges_badge_id ON content_badges(badge_id);
-- +goose Down
DROP TABLE IF EXISTS content_badges;
DROP TABLE IF EXISTS badges;
DROP INDEX IF EXISTS idx_content_category_id;
ALTER TABLE content DROP COLUMN IF EXISTS category_id;
DROP TABLE IF EXISTS categories;

View file

@ -1,35 +0,0 @@
-- +goose Up
-- The CMS no longer stores identities: the upstream SSO proxy owns them.
ALTER TABLE content DROP COLUMN IF EXISTS created_by;
ALTER TABLE content DROP COLUMN IF EXISTS updated_by;
ALTER TABLE content_history DROP COLUMN IF EXISTS actor_id;
ALTER TABLE content_history DROP COLUMN IF EXISTS actor_admin_id;
DROP TABLE IF EXISTS sessions;
DROP TABLE IF EXISTS users;
-- +goose Down
CREATE TABLE IF NOT EXISTS users (
id BIGSERIAL PRIMARY KEY,
email TEXT NOT NULL,
name TEXT NOT NULL DEFAULT '',
provider TEXT NOT NULL,
provider_subject TEXT NOT NULL,
role TEXT NOT NULL DEFAULT '',
editor BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email ON users(email);
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_provider_subject ON users(provider, provider_subject);
CREATE TABLE IF NOT EXISTS sessions (
id BIGSERIAL PRIMARY KEY,
token TEXT NOT NULL UNIQUE,
user_id BIGINT REFERENCES users(id),
admin_id BIGINT,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at);
ALTER TABLE content ADD COLUMN IF NOT EXISTS created_by BIGINT REFERENCES users(id);
ALTER TABLE content ADD COLUMN IF NOT EXISTS updated_by BIGINT REFERENCES users(id);
ALTER TABLE content_history ADD COLUMN IF NOT EXISTS actor_id BIGINT REFERENCES users(id);
ALTER TABLE content_history ADD COLUMN IF NOT EXISTS actor_admin_id BIGINT;

View file

@ -1,19 +0,0 @@
services:
postgres:
image: postgres:17-alpine
environment:
POSTGRES_DB: cms
POSTGRES_USER: cms
POSTGRES_PASSWORD: cms
ports:
- "5432:5432"
volumes:
- cms-postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U cms -d cms"]
interval: 5s
timeout: 5s
retries: 10
volumes:
cms-postgres-data:

21
go.mod
View file

@ -1,24 +1,3 @@
module github.com/example/cms module github.com/example/cms
go 1.27.1 go 1.27.1
require (
github.com/pressly/goose/v3 v3.24.3
gorm.io/driver/postgres v1.6.0
gorm.io/gorm v1.31.1
)
require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.7.4 // 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/mfridman/interpolate v0.0.2 // indirect
github.com/sethvargo/go-retry v0.3.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/crypto v0.38.0 // indirect
golang.org/x/sync v0.14.0 // indirect
golang.org/x/text v0.25.0 // indirect
)

66
go.sum
View file

@ -1,66 +0,0 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.7.4 h1:9wKznZrhWa2QiHL+NjTSPP6yjl3451BX3imWDnokYlg=
github.com/jackc/pgx/v5 v5.7.4/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pressly/goose/v3 v3.24.3 h1:DSWWNwwggVUsYZ0X2VitiAa9sKuqtBfe+Jr9zFGwWlM=
github.com/pressly/goose/v3 v3.24.3/go.mod h1:v9zYL4xdViLHCUUJh/mhjnm6JrK7Eul8AS93IxiZM4E=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
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.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 h1:y5zboxd6LQAqYIhHnB48p0ByQ/GnQx2BE33L8BOHQkI=
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ=
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
modernc.org/libc v1.65.0 h1:e183gLDnAp9VJh6gWKdTy0CThL9Pt7MfcR/0bgb7Y1Y=
modernc.org/libc v1.65.0/go.mod h1:7m9VzGq7APssBTydds2zBcxGREwvIGpuUBaKTXdm2Qs=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.10.0 h1:fzumd51yQ1DxcOxSO+S6X7+QTuVU+n8/Aj7swYjFfC4=
modernc.org/memory v1.10.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/sqlite v1.37.0 h1:s1TMe7T3Q3ovQiK2Ouz4Jwh7dw4ZDqbebSDTlSJdfjI=
modernc.org/sqlite v1.37.0/go.mod h1:5YiWv+YviqGMuGw4V+PNplcyaJ5v+vQd7TQOgkACoJM=

View file

@ -1,20 +1,21 @@
// 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
// a single JSON file.
package config package config
import ( import "os"
"os"
)
// Config carries the runtime settings for the CMS. The application database is
// the only datastore: identities and sessions belong to the upstream SSO proxy.
type Config struct { type Config struct {
HTTPAddr, ApplicationDatabaseURL string HTTPAddr, Profile, ContentFile, PostgresURL string
AllowedOrigins, SSOHeader, SSOToken, SSOCookie string AllowedOrigins, SSOHeader, SSOToken, SSOCookie string
} }
func Load() Config { func Load() Config {
return Config{ return Config{
HTTPAddr: get("CMS_HTTP_ADDR", ":8080"), HTTPAddr: get("CMS_HTTP_ADDR", ":8080"),
ApplicationDatabaseURL: get("CMS_APPLICATION_DATABASE_URL", "host=localhost user=cms password=cms dbname=cms port=5432 sslmode=disable"), Profile: get("CMS_PROFILE", "demo"),
ContentFile: get("CMS_CONTENT_FILE", ""),
PostgresURL: get("CMS_POSTGRES_URL", ""),
AllowedOrigins: get("CMS_ALLOWED_ORIGINS", "http://localhost:5173"), AllowedOrigins: get("CMS_ALLOWED_ORIGINS", "http://localhost:5173"),
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", ""),

View file

@ -1,33 +0,0 @@
package domain
import "time"
type Content struct {
ID uint
Slug, Title, Summary, Body string
Status string
Published bool
CategoryID uint
Category string
Badges []string
CreatedAt, UpdatedAt time.Time
}
type Category struct {
ID uint
Slug string
Name string
}
type Badge struct {
ID uint
Name string
}
type ContentHistory struct {
ID uint
ContentID uint
Operation string
CreatedAt time.Time
Snapshot string
}

View file

@ -1,15 +0,0 @@
package persistence
import "github.com/example/cms/internal/domain"
func ContentToDomain(m ContentModel) domain.Content {
badges := make([]string, len(m.Badges))
for i := range m.Badges {
badges[i] = m.Badges[i].Name
}
return domain.Content{ID: m.ID, Slug: m.Slug, Title: m.Title, Summary: m.Summary, Body: m.Body, Status: m.Status, Published: m.Published, CategoryID: m.CategoryID, Category: m.Category.Name, Badges: badges, CreatedAt: m.CreatedAt, UpdatedAt: m.UpdatedAt}
}
func ContentFromDomain(d domain.Content) ContentModel {
return ContentModel{ID: d.ID, Slug: d.Slug, Title: d.Title, Summary: d.Summary, Body: d.Body, Status: d.Status, Published: d.Published, CreatedAt: d.CreatedAt, UpdatedAt: d.UpdatedAt}
}

View file

@ -1,44 +0,0 @@
package persistence
import "time"
type ContentModel struct {
ID uint `gorm:"primaryKey"`
Slug string `gorm:"uniqueIndex;not null"`
Title string `gorm:"not null"`
Summary string
Body string `gorm:"not null"`
Status string `gorm:"not null;default:'draft'"`
Published bool `gorm:"not null;default:false"`
CategoryID uint
Category CategoryModel `gorm:"foreignKey:CategoryID"`
Badges []BadgeModel `gorm:"many2many:content_badges;joinForeignKey:ContentID;joinReferences:BadgeID"`
CreatedAt, UpdatedAt time.Time
}
func (ContentModel) TableName() string { return "content" }
type CategoryModel struct {
ID uint `gorm:"primaryKey"`
Slug string `gorm:"uniqueIndex;not null"`
Name string `gorm:"not null"`
}
func (CategoryModel) TableName() string { return "categories" }
type BadgeModel struct {
ID uint `gorm:"primaryKey"`
Name string `gorm:"uniqueIndex;not null"`
}
func (BadgeModel) TableName() string { return "badges" }
type ContentHistoryModel struct {
ID uint `gorm:"primaryKey"`
ContentID uint `gorm:"index;not null"`
Operation string `gorm:"not null"`
CreatedAt time.Time
Snapshot string `gorm:"type:jsonb;not null"`
}
func (ContentHistoryModel) TableName() string { return "content_history" }

View file

@ -1,30 +0,0 @@
package persistence
import (
"testing"
"time"
"github.com/example/cms/internal/domain"
)
func TestModelsUseMigrationTableNames(t *testing.T) {
tests := map[string]string{
"content": (ContentModel{}).TableName(),
"content_history": (ContentHistoryModel{}).TableName(),
"categories": (CategoryModel{}).TableName(),
"badges": (BadgeModel{}).TableName(),
}
for expected, actual := range tests {
if actual != expected {
t.Errorf("table name = %q, want %q", actual, expected)
}
}
}
func TestContentMappingRoundTrip(t *testing.T) {
d := domain.Content{ID: 2, Slug: "hello-world", Title: "Hello", Body: "body", Status: "draft", Badges: []string{"go"}, CreatedAt: time.Now()}
got := ContentToDomain(ContentFromDomain(d))
if got.Slug != d.Slug || got.Title != d.Title || got.Status != d.Status {
t.Fatalf("round trip mismatch: %+v", got)
}
}

View file

@ -1,79 +0,0 @@
package repository
import (
"context"
"github.com/example/cms/internal/domain"
"github.com/example/cms/internal/persistence"
"gorm.io/gorm"
)
type GORMContentRepository struct{ db *gorm.DB }
func NewContentRepository(db *gorm.DB) IContentRepository { return &GORMContentRepository{db} }
func (r *GORMContentRepository) List(ctx context.Context, limit, offset int, category, status string, badges []string) ([]domain.Content, error) {
var rows []persistence.ContentModel
query := r.db.WithContext(ctx).Model(&persistence.ContentModel{}).Preload("Category").Preload("Badges")
if category != "" {
query = query.Where("category_id IN (SELECT id FROM categories WHERE slug = ?)", category)
}
if status != "" {
query = query.Where("status = ?", status)
}
for _, badge := range badges {
query = query.Where("EXISTS (SELECT 1 FROM content_badges cb JOIN badges b ON b.id = cb.badge_id WHERE cb.content_id = content.id AND b.name = ?)", badge)
}
err := query.Order("updated_at DESC, created_at DESC, id DESC").Limit(limit).Offset(offset).Find(&rows).Error
out := make([]domain.Content, len(rows))
for i := range rows {
out[i] = persistence.ContentToDomain(rows[i])
}
return out, err
}
func (r *GORMContentRepository) ListCategories(ctx context.Context) ([]domain.Category, error) {
var rows []persistence.CategoryModel
if err := r.db.WithContext(ctx).Order("name ASC").Find(&rows).Error; err != nil {
return nil, err
}
out := make([]domain.Category, len(rows))
for i := range rows {
out[i] = domain.Category{ID: rows[i].ID, Slug: rows[i].Slug, Name: rows[i].Name}
}
return out, nil
}
func (r *GORMContentRepository) ListBadges(ctx context.Context) ([]domain.Badge, error) {
var rows []persistence.BadgeModel
if err := r.db.WithContext(ctx).Order("name ASC").Find(&rows).Error; err != nil {
return nil, err
}
out := make([]domain.Badge, len(rows))
for i := range rows {
out[i] = domain.Badge{ID: rows[i].ID, Name: rows[i].Name}
}
return out, nil
}
func (r *GORMContentRepository) FindBySlug(ctx context.Context, slug string) (domain.Content, error) {
var row persistence.ContentModel
if err := r.db.WithContext(ctx).Preload("Category").Preload("Badges").Where("slug = ?", slug).First(&row).Error; err != nil {
return domain.Content{}, err
}
return persistence.ContentToDomain(row), nil
}
func (r *GORMContentRepository) Update(ctx context.Context, d domain.Content, h domain.ContentHistory) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&persistence.ContentModel{}).Where("id = ?", d.ID).Updates(map[string]any{"slug": d.Slug, "title": d.Title, "summary": d.Summary, "body": d.Body, "status": d.Status, "published": d.Published, "updated_at": d.UpdatedAt}).Error; err != nil {
return err
}
if err := tx.Exec("DELETE FROM content_badges WHERE content_id = ?", d.ID).Error; err != nil {
return err
}
for _, badge := range d.Badges {
if err := tx.Exec("INSERT INTO badges (name) VALUES (?) ON CONFLICT (name) DO NOTHING", badge).Error; err != nil {
return err
}
if err := tx.Exec("INSERT INTO content_badges (content_id, badge_id) SELECT ?, id FROM badges WHERE name = ? ON CONFLICT DO NOTHING", d.ID, badge).Error; err != nil {
return err
}
}
return tx.Create(&persistence.ContentHistoryModel{ContentID: h.ContentID, Operation: h.Operation, CreatedAt: h.CreatedAt, Snapshot: h.Snapshot}).Error
})
}

View file

@ -1,14 +0,0 @@
package repository
import (
"context"
"github.com/example/cms/internal/domain"
)
type IContentRepository interface {
List(context.Context, int, int, string, string, []string) ([]domain.Content, error)
ListCategories(context.Context) ([]domain.Category, error)
ListBadges(context.Context) ([]domain.Badge, error)
FindBySlug(context.Context, string) (domain.Content, error)
Update(context.Context, domain.Content, domain.ContentHistory) error
}

View file

@ -1,33 +1,375 @@
// Package service holds the application use cases. The demo CMS keeps its
// content in memory: it is seeded at startup and optionally mirrored to a single
// JSON file, so the container needs no database server.
package service package service
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"github.com/example/cms/internal/domain" "os"
"github.com/example/cms/internal/repository" "path/filepath"
"regexp" "regexp"
"sort"
"strings" "strings"
"sync"
"time" "time"
) )
// IContentService exposes the content use cases. Read operations are anonymous; // Content is a single article in the backlog.
// Update is only reachable once the web layer verified the SSO proxy identity. type Content struct {
type IContentService interface { ID uint `json:"id"`
List(context.Context, int, int, string, string, []string) ([]domain.Content, error) Slug string `json:"slug"`
ListCategories(context.Context) ([]domain.Category, error) Title string `json:"title"`
ListBadges(context.Context) ([]domain.Badge, error) Summary string `json:"summary"`
Get(context.Context, string) (domain.Content, error) Body string `json:"body"`
Update(context.Context, domain.Content) (domain.Content, error) Status string `json:"status"`
} Published bool `json:"published"`
type ContentService struct { Category string `json:"category,omitempty"`
repo repository.IContentRepository Badges []string `json:"badges"`
Author string `json:"author"`
PublishedAt *time.Time `json:"publishedAt,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
} }
func NewContentService(r repository.IContentRepository) *ContentService { // Category groups content for the filter dropdown.
return &ContentService{repo: r} type Category struct {
ID uint `json:"id"`
Slug string `json:"slug"`
Name string `json:"name"`
} }
func (s *ContentService) List(ctx context.Context, limit, offset int, category, status string, badges []string) ([]domain.Content, error) {
// Badge is a reusable tag shared by any number of articles.
type Badge struct {
ID uint `json:"id"`
Name string `json:"name"`
}
// contentRevision records one edit so the app keeps an audit trail.
type contentRevision struct {
ContentID uint `json:"contentId"`
Operation string `json:"operation"`
CreatedAt time.Time `json:"createdAt"`
Snapshot string `json:"snapshot"`
}
// IContentService exposes the content use cases. Reads are anonymous; Update is
// only reachable once the web layer verified the SSO identity.
type IContentService interface {
List(context.Context, int, int, string, string, []string) ([]Content, error)
ListCategories(context.Context) ([]Category, error)
ListBadges(context.Context) ([]Badge, error)
Get(context.Context, string) (Content, error)
Update(context.Context, Content) (Content, error)
}
// ContentService keeps the demo content in memory behind a mutex. When file is
// set, the store is loaded from that JSON file on startup and written back after
// every edit, so a mounted volume keeps changes across restarts.
type ContentService struct {
mu sync.RWMutex
items []Content
history []contentRevision
categories map[string]string
categoryOrder []string
allBadges []string
nextID uint
file string
}
// NewContentService seeds the store from the given articles. When file is not
// empty and already exists, the store is loaded from it instead, which lets a
// mounted volume keep edits across restarts.
func NewContentService(seed []Content, file string) (*ContentService, error) {
s := &ContentService{file: file}
if file != "" {
if _, err := os.Stat(file); err == nil {
if err := s.load(file); err != nil {
return nil, err
}
return s, nil
}
}
if len(seed) == 0 {
return nil, errors.New("no seed content available")
}
s.applySeed(seed)
if err := s.persistLocked(); err != nil {
return nil, err
}
return s, nil
}
func (s *ContentService) applySeed(seed []Content) {
s.categories = map[string]string{}
s.categoryOrder = nil
s.items = make([]Content, 0, len(seed))
s.history = nil
known := map[string]struct{}{}
for i, item := range seed {
item.ID = uint(i + 1)
item.Category = normalizeCategory(item.Category)
if _, seen := s.categories[item.Category]; !seen && item.Category != "" {
s.categories[item.Category] = categoryDisplay(item.Category)
s.categoryOrder = append(s.categoryOrder, item.Category)
}
item.Badges = normalizeBadges(item.Badges)
for _, badge := range item.Badges {
known[badge] = struct{}{}
}
item.Status = normalizeStatus(item.Status)
if item.CreatedAt.IsZero() {
item.CreatedAt = time.Now()
}
if item.UpdatedAt.IsZero() {
item.UpdatedAt = item.CreatedAt
}
s.items = append(s.items, item)
}
s.nextID = uint(len(seed) + 1)
s.allBadges = sortedKeys(known)
sortContent(s.items)
}
// List returns a page of articles filtered by category, status, and badges. The
// selected badges are combined with AND semantics.
func (s *ContentService) List(_ context.Context, limit, offset int, category, status string, badges []string) ([]Content, error) {
limit, offset = clampPage(limit, offset)
category = normalizeCategory(category)
if strings.TrimSpace(status) != "" {
status = normalizeStatus(status)
}
wanted := normalizeBadges(badges)
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]Content, 0, limit)
for _, item := range s.items {
if category != "" && item.Category != category {
continue
}
if status != "" && item.Status != status {
continue
}
if !hasBadges(item, wanted) {
continue
}
if offset > 0 {
offset--
continue
}
if len(out) == limit {
break
}
out = append(out, cloneContent(item))
}
return out, nil
}
// ListCategories returns the fixed category list used by the filter dropdown.
func (s *ContentService) ListCategories(context.Context) ([]Category, error) {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]Category, 0, len(s.categoryOrder))
for i, slug := range s.categoryOrder {
out = append(out, Category{ID: uint(i + 1), Slug: slug, Name: s.categories[slug]})
}
return out, nil
}
// ListBadges returns every badge carried by an article, so the filter and the
// editor never suggest a badge that would return nothing.
func (s *ContentService) ListBadges(context.Context) ([]Badge, error) {
s.mu.RLock()
defer s.mu.RUnlock()
known := map[string]struct{}{}
for _, badge := range s.allBadges {
known[badge] = struct{}{}
}
for _, item := range s.items {
for _, badge := range item.Badges {
known[badge] = struct{}{}
}
}
names := sortedKeys(known)
out := make([]Badge, 0, len(names))
for i, name := range names {
out = append(out, Badge{ID: uint(i + 1), Name: name})
}
return out, nil
}
// Get returns one article by slug.
func (s *ContentService) Get(_ context.Context, slug string) (Content, error) {
slug = strings.ToLower(strings.TrimSpace(slug))
if !validSlug(slug) {
return Content{}, errors.New("invalid slug")
}
s.mu.RLock()
defer s.mu.RUnlock()
for _, item := range s.items {
if item.Slug == slug {
return cloneContent(item), nil
}
}
return Content{}, errors.New("content not found")
}
// Update saves an edited article, records a revision, and mirrors the store to
// disk when a content file is configured.
func (s *ContentService) Update(_ context.Context, c Content) (Content, error) {
return s.update(c, "")
}
func (s *ContentService) UpdateAs(_ context.Context, c Content, actor string) (Content, error) {
return s.update(c, actor)
}
func (s *ContentService) update(c Content, actor string) (Content, error) {
c.Slug = strings.ToLower(strings.TrimSpace(c.Slug))
if !validSlug(c.Slug) || strings.TrimSpace(c.Title) == "" || strings.TrimSpace(c.Body) == "" {
return Content{}, errors.New("invalid content")
}
c.Badges = normalizeBadges(c.Badges)
c.Status = normalizeStatus(c.Status)
c.Published = c.Status == "published"
s.mu.Lock()
defer s.mu.Unlock()
index := -1
for i := range s.items {
if s.items[i].Slug == c.Slug {
index = i
break
}
}
if index < 0 {
return Content{}, errors.New("content not found")
}
current := s.items[index]
if actor != "" && current.Author != actor {
return Content{}, errors.New("you can only edit your own content")
}
c.ID = current.ID
c.CreatedAt = current.CreatedAt
c.UpdatedAt = time.Now().Truncate(time.Second)
c.Category = normalizeCategory(current.Category)
c.Author = current.Author
if c.Status == "published" && c.PublishedAt == nil {
now := time.Now().Truncate(time.Second)
c.PublishedAt = &now
}
if c.Status != "published" {
c.PublishedAt = nil
}
s.items[index] = c
sortContent(s.items)
snapshot, _ := json.Marshal(c)
s.history = append(s.history, contentRevision{ContentID: c.ID, Operation: "updated", CreatedAt: c.UpdatedAt, Snapshot: string(snapshot)})
if err := s.persistLocked(); err != nil {
return Content{}, err
}
return cloneContent(c), nil
}
// storedContent is the on-disk shape of an article. Identifiers are positional
// so the JSON file stays readable and diff-friendly.
type storedContent struct {
Slug string `json:"slug"`
Title string `json:"title"`
Summary string `json:"summary"`
Body string `json:"body"`
Status string `json:"status"`
Published bool `json:"published"`
Category string `json:"category,omitempty"`
Badges []string `json:"badges"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type storedFile struct {
Categories map[string]string `json:"categories"`
Content []storedContent `json:"content"`
History []contentRevision `json:"history,omitempty"`
}
func (s *ContentService) load(file string) error {
data, err := os.ReadFile(file)
if err != nil {
return err
}
var payload storedFile
if err := json.Unmarshal(data, &payload); err != nil {
return err
}
s.categories = payload.Categories
if s.categories == nil {
s.categories = map[string]string{}
}
s.categoryOrder = make([]string, 0, len(s.categories))
for slug := range s.categories {
s.categoryOrder = append(s.categoryOrder, slug)
}
sort.Strings(s.categoryOrder)
s.history = payload.History
known := map[string]struct{}{}
s.items = make([]Content, 0, len(payload.Content))
for i, item := range payload.Content {
c := Content{
ID: uint(i + 1), Slug: item.Slug, Title: item.Title, Summary: item.Summary, Body: item.Body,
Status: normalizeStatus(item.Status), Category: normalizeCategory(item.Category),
Badges: normalizeBadges(item.Badges), CreatedAt: item.CreatedAt, UpdatedAt: item.UpdatedAt,
}
c.Published = c.Status == "published"
for _, badge := range c.Badges {
known[badge] = struct{}{}
}
s.items = append(s.items, c)
}
s.nextID = uint(len(s.items) + 1)
s.allBadges = sortedKeys(known)
sortContent(s.items)
return nil
}
func (s *ContentService) persistLocked() error {
if s.file == "" {
return nil
}
payload := storedFile{Categories: s.categories, History: s.history}
payload.Content = make([]storedContent, 0, len(s.items))
for _, item := range s.items {
payload.Content = append(payload.Content, storedContent{
Slug: item.Slug, Title: item.Title, Summary: item.Summary, Body: item.Body,
Status: item.Status, Published: item.Published, Category: item.Category,
Badges: item.Badges, CreatedAt: item.CreatedAt, UpdatedAt: item.UpdatedAt,
})
}
data, err := json.MarshalIndent(payload, "", " ")
if err != nil {
return err
}
if dir := filepath.Dir(s.file); dir != "" && dir != "." {
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
}
tmp := s.file + ".tmp"
if err := os.WriteFile(tmp, data, 0o644); err != nil {
return err
}
return os.Rename(tmp, s.file)
}
func clampPage(limit, offset int) (int, int) {
if limit < 1 { if limit < 1 {
limit = 20 limit = 20
} }
@ -37,39 +379,58 @@ func (s *ContentService) List(ctx context.Context, limit, offset int, category,
if offset < 0 { if offset < 0 {
offset = 0 offset = 0
} }
return s.repo.List(ctx, limit, offset, category, status, badges) return limit, offset
} }
func (s *ContentService) ListCategories(ctx context.Context) ([]domain.Category, error) {
return s.repo.ListCategories(ctx)
}
func (s *ContentService) ListBadges(ctx context.Context) ([]domain.Badge, error) {
return s.repo.ListBadges(ctx)
}
func (s *ContentService) Get(ctx context.Context, slug string) (domain.Content, error) {
if !validSlug(slug) {
return domain.Content{}, errors.New("invalid slug")
}
return s.repo.FindBySlug(ctx, slug)
}
func (s *ContentService) Update(ctx context.Context, c domain.Content) (domain.Content, error) {
if !validSlug(c.Slug) || strings.TrimSpace(c.Title) == "" || strings.TrimSpace(c.Body) == "" {
return domain.Content{}, errors.New("invalid content")
}
old, err := s.repo.FindBySlug(ctx, c.Slug)
if err != nil {
return domain.Content{}, err
}
c.ID = old.ID
c.CreatedAt = old.CreatedAt
c.UpdatedAt = time.Now()
c.Badges = normalizeBadges(c.Badges)
snapshot, _ := json.Marshal(c) func hasBadges(item Content, wanted []string) bool {
h := domain.ContentHistory{ContentID: c.ID, Operation: "updated", CreatedAt: c.UpdatedAt, Snapshot: string(snapshot)} for _, badge := range wanted {
if err = s.repo.Update(ctx, c, h); err != nil { found := false
return domain.Content{}, err for _, have := range item.Badges {
if have == badge {
found = true
break
}
}
if !found {
return false
}
}
return true
}
// sortContent orders articles newest first, matching the list page ordering.
func sortContent(items []Content) {
sort.SliceStable(items, func(i, j int) bool {
if !items[i].UpdatedAt.Equal(items[j].UpdatedAt) {
return items[i].UpdatedAt.After(items[j].UpdatedAt)
}
if !items[i].CreatedAt.Equal(items[j].CreatedAt) {
return items[i].CreatedAt.After(items[j].CreatedAt)
}
return items[i].ID > items[j].ID
})
}
func cloneContent(c Content) Content {
c.Badges = append([]string(nil), c.Badges...)
return c
}
func normalizeCategory(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
if !validSlug(value) {
return ""
}
return value
}
func normalizeStatus(value string) string {
switch value = strings.ToLower(strings.TrimSpace(value)); value {
case "published", "draft", "review", "archived":
return value
default:
return "draft"
} }
return c, nil
} }
func normalizeBadges(values []string) []string { func normalizeBadges(values []string) []string {
@ -86,9 +447,28 @@ func normalizeBadges(values []string) []string {
seen[value] = struct{}{} seen[value] = struct{}{}
result = append(result, value) result = append(result, value)
} }
sort.Strings(result)
return result return result
} }
func sortedKeys(set map[string]struct{}) []string {
out := make([]string, 0, len(set))
for key := range set {
out = append(out, key)
}
sort.Strings(out)
return out
}
// categoryDisplay returns the readable name for a category slug, falling back
// to a title-cased slug for anything not in the seeded list.
func categoryDisplay(slug string) string {
if name, ok := categoryNames[slug]; ok {
return name
}
return strings.ToUpper(slug[:1]) + slug[1:]
}
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) <= 200 && slugRE.MatchString(s) } func validSlug(s string) bool { return len(s) > 0 && len(s) <= 200 && slugRE.MatchString(s) }

View file

@ -0,0 +1,127 @@
package service
import (
"context"
"path/filepath"
"testing"
)
func TestSeedStoreListsNewestFirst(t *testing.T) {
s, err := NewContentService(SeedContent(), "")
if err != nil {
t.Fatal(err)
}
items, err := s.List(context.Background(), 5, 0, "", "", nil)
if err != nil {
t.Fatal(err)
}
if len(items) != 5 {
t.Fatalf("limit ignored: got %d items", len(items))
}
if items[0].Slug != "built-with-ai" {
t.Fatalf("newest item = %q, want built-with-ai", items[0].Slug)
}
if !items[0].UpdatedAt.After(items[1].UpdatedAt) && !items[0].UpdatedAt.Equal(items[1].UpdatedAt) {
t.Fatalf("items are not ordered newest first: %v then %v", items[0].UpdatedAt, items[1].UpdatedAt)
}
}
func TestListFiltersByCategoryAndBadge(t *testing.T) {
s, err := NewContentService(SeedContent(), "")
if err != nil {
t.Fatal(err)
}
ctx := context.Background()
categories, err := s.ListCategories(ctx)
if err != nil || len(categories) == 0 {
t.Fatalf("categories = %v err = %v", categories, err)
}
byCategory, err := s.List(ctx, 100, 0, "linux", "", nil)
if err != nil || len(byCategory) == 0 {
t.Fatalf("linux category returned %d items (err %v)", len(byCategory), err)
}
for _, item := range byCategory {
if item.Category != "linux" {
t.Fatalf("category filter leaked %q", item.Category)
}
}
// Badges combine with AND semantics, so this pair must be narrower than one.
tagged, err := s.List(ctx, 100, 0, "", "", []string{"tooling"})
if err != nil || len(tagged) < 2 {
t.Fatalf("tooling badge returned %d items (err %v)", len(tagged), err)
}
both, err := s.List(ctx, 100, 0, "", "", []string{"tooling", "docker"})
if err != nil {
t.Fatal(err)
}
if len(both) == 0 || len(both) >= len(tagged) {
t.Fatalf("badge AND filtering wrong: tooling=%d tooling+docker=%d", len(tagged), len(both))
}
for _, item := range both {
if !contains(item.Badges, "tooling") || !contains(item.Badges, "docker") {
t.Fatalf("badge filter leaked %v", item.Badges)
}
}
}
func contains(values []string, want string) bool {
for _, value := range values {
if value == want {
return true
}
}
return false
}
func TestUpdatePersistsAcrossRestart(t *testing.T) {
file := filepath.Join(t.TempDir(), "content.json")
s, err := NewContentService(SeedContent(), file)
if err != nil {
t.Fatal(err)
}
saved, err := s.Update(context.Background(), Content{
Slug: "built-with-ai",
Title: "Edited title",
Body: "Edited body",
Status: "draft",
Badges: []string{"Go", "go", "writing"},
})
if err != nil {
t.Fatal(err)
}
if saved.Title != "Edited title" || saved.Published {
t.Fatalf("unexpected save result: %+v", saved)
}
if len(saved.Badges) != 2 {
t.Fatalf("badges were not normalised and deduplicated: %v", saved.Badges)
}
// A new service pointed at the same file must load the edit, not the seed.
restarted, err := NewContentService(SeedContent(), file)
if err != nil {
t.Fatal(err)
}
reloaded, err := restarted.Get(context.Background(), "built-with-ai")
if err != nil {
t.Fatal(err)
}
if reloaded.Title != "Edited title" {
t.Fatalf("edit did not survive restart: %q", reloaded.Title)
}
}
func TestUpdateRejectsInvalidContent(t *testing.T) {
s, err := NewContentService(SeedContent(), "")
if err != nil {
t.Fatal(err)
}
if _, err = s.Update(context.Background(), Content{Slug: "missing", Title: "T", Body: "B"}); err == nil {
t.Fatal("expected unknown slug to be rejected")
}
if _, err = s.Update(context.Background(), Content{Slug: "built-with-ai", Title: "", Body: "B"}); err == nil {
t.Fatal("expected empty title to be rejected")
}
}

View file

@ -0,0 +1,76 @@
package service
import (
"crypto/rand"
"encoding/hex"
"errors"
"strings"
"sync"
)
type DemoUser struct {
Username string `json:"username"`
Name string `json:"name"`
Password string `json:"-"`
}
type DemoAuth struct {
mu sync.RWMutex
users []DemoUser
sessions map[string]string
}
func NewDemoAuth() *DemoAuth {
return &DemoAuth{
users: []DemoUser{
{Username: "urpi", Name: "Urpi"},
{Username: "tux", Name: "Tux"},
{Username: "gopher", Name: "Gopher"},
},
sessions: map[string]string{},
}
}
func (a *DemoAuth) Users() []DemoUser {
a.mu.RLock()
defer a.mu.RUnlock()
out := make([]DemoUser, len(a.users))
copy(out, a.users)
for i := range out {
out[i].Password = ""
}
return out
}
func (a *DemoAuth) Login(username, password string) (string, DemoUser, error) {
username = strings.ToLower(strings.TrimSpace(username))
a.mu.RLock()
var user DemoUser
for _, candidate := range a.users {
if candidate.Username == username {
user = candidate
break
}
}
a.mu.RUnlock()
if user.Username == "" || password != username {
return "", DemoUser{}, errors.New("invalid demo credentials")
}
raw := make([]byte, 24)
if _, err := rand.Read(raw); err != nil {
return "", DemoUser{}, err
}
token := hex.EncodeToString(raw)
a.mu.Lock()
a.sessions[token] = user.Username
a.mu.Unlock()
user.Password = ""
return token, user, nil
}
func (a *DemoAuth) Authenticated(token string) (string, bool) {
a.mu.RLock()
defer a.mu.RUnlock()
username, ok := a.sessions[strings.TrimSpace(token)]
return username, ok
}

835
internal/service/seed.go Normal file
View file

@ -0,0 +1,835 @@
package service
import (
"fmt"
"strings"
"time"
)
// categoryNames maps the seeded category slugs to their display names. The
// filter dropdown is built from the categories actually present in the seed and
// keeps this order.
var categoryNames = map[string]string{
"linux": "Linux",
"bash": "Bash & Shell",
"go": "Go",
"java": "Java",
"devops": "DevOps",
"tooling": "Tooling",
"design": "Design & Editorial",
"workflow": "Workflow",
}
// daysAgo returns a timestamp relative to startup so the demo always shows a
// fresh-looking timeline.
func daysAgo(days int) time.Time {
return time.Now().AddDate(0, 0, -days).Truncate(time.Second)
}
// SeedContent returns the demo backlog loaded into the in-memory store.
func SeedContent() []Content {
items := make([]Content, 0, 560)
for i, item := range seedContent {
item.Author = demoAuthors[i%len(demoAuthors)]
if item.Status == "published" {
published := item.CreatedAt.Add(24 * time.Hour)
item.PublishedAt = &published
}
items = append(items, item)
}
for i := len(items); i < 560; i++ {
items = append(items, generatedContent(i))
}
return items
}
var demoAuthors = []string{"urpi", "tux", "gopher"}
func generatedContent(index int) Content {
topics := []struct {
category string
topic string
badges []string
}{
{"linux", "systemd service", []string{"linux", "systemd", "terminal"}},
{"bash", "shell pipeline", []string{"bash", "cli", "automation"}},
{"go", "Go worker", []string{"go", "testing", "concurrency"}},
{"java", "Spring Boot endpoint", []string{"java", "spring-boot", "api"}},
{"devops", "deployment runbook", []string{"devops", "ci", "reliability"}},
{"tooling", "developer workstation", []string{"tooling", "linux", "productivity"}},
}
topic := topics[index%len(topics)]
author := demoAuthors[index%len(demoAuthors)]
created := time.Now().AddDate(0, 0, -(index%180 + 200)).Truncate(time.Second)
published := created.Add(12 * time.Hour)
return Content{
Slug: fmt.Sprintf("lab-note-%03d-%s", index+1, strings.ReplaceAll(topic.topic, " ", "-")),
Title: fmt.Sprintf("Lab Note %03d: %s", index+1, topic.topic),
Summary: fmt.Sprintf("A practical backlog note about a %s in the Urpi workshop.", topic.topic),
Body: fmt.Sprintf("This generated demo article explores a %s from a Linux-first engineering perspective.\n\nThe useful habit is to keep the command, the reason, and the rollback path close together. Measure the result, write down what changed, and leave the next reader a small example they can run.", topic.topic),
Status: "published",
Category: topic.category,
Badges: topic.badges,
Author: author,
CreatedAt: created,
UpdatedAt: created.Add(2 * time.Hour),
PublishedAt: &published,
}
}
// seedContent is the demo dataset, written in the voice of the backlog: notes on
// Linux, Go, Java, and the workflows around them.
var seedContent = []Content{
{
Slug: "postmortems-that-people-read",
Title: "Postmortems That People Actually Read",
Summary: "A blameless template short enough to be reused and specific enough to prevent a repeat.",
Body: `A postmortem is a tool for changing behaviour, not a document for assigning blame. If nobody reads it twice, it did not work.
We keep the template to one page: what happened, who saw it first, what made it worse, what made it better, and the smallest change that would have prevented it. Longer analysis lives in linked notes so the summary stays skimmable.
Every postmortem ends with at most three follow-ups, each with an owner and a date. If a follow-up cannot be scheduled, we say so honestly instead of writing a wish list.`,
Status: "published",
Category: "devops",
Badges: []string{"reliability", "writing", "documentation"},
CreatedAt: daysAgo(115), UpdatedAt: daysAgo(112),
},
{
Slug: "health-checks-and-readiness",
Title: "Health Checks and Readiness Are Different Questions",
Summary: "Liveness asks whether the process survived; readiness asks whether it should receive traffic.",
Body: `Conflating liveness and readiness turns a slow dependency into a restart loop.
A liveness probe answers one question: is this process stuck badly enough that restarting is the best option? It should not call the database, the queue, or anything else that can be slow without being fatal.
A readiness probe answers a different question: can this instance serve useful responses right now? That one is allowed to check dependencies, and it is allowed to fail temporarily. Failing readiness removes the instance from rotation; failing liveness restarts it. Different consequences deserve different probes.`,
Status: "published",
Category: "devops",
Badges: []string{"kubernetes", "reliability", "http"},
CreatedAt: daysAgo(111), UpdatedAt: daysAgo(108),
},
{
Slug: "backups-you-have-restored",
Title: "Backups You Have Restored",
Summary: "An untested backup is a rumour. A restore drill is evidence.",
Body: `The only backup that counts is one you have restored, timed, and verified.
Once a quarter we take the most recent snapshot, restore it into a scratch environment, and check three things: how long it took, whether the row counts match expectations, and whether the application can start against it. The drill notes go next to the schedule, not in a separate wiki nobody opens.
If a restore needs a person who is on holiday, the backup is not finished. Fix the runbook before writing more backups.`,
Status: "published",
Category: "devops",
Badges: []string{"reliability", "postgresql", "automation"},
CreatedAt: daysAgo(106), UpdatedAt: daysAgo(104),
},
{
Slug: "log-lines-worth-keeping",
Title: "Log Lines Worth Keeping",
Summary: "Write logs for the person debugging at an inconvenient hour.",
Body: `Logs are read under pressure, usually by someone who did not write the code and cannot change it quickly.
A useful line names the operation, the subject, and the outcome, and it includes the identifiers needed to find the related records. Redundancy across layers is noise: if the HTTP handler already logs the request, the repository should not log it again.
Levels are a contract. Debug is safe to delete, info describes normal progress, warn needs attention eventually, and error means a human should look. If everything is an error, nothing is.`,
Status: "published",
Category: "devops",
Badges: []string{"logging", "observability", "debugging"},
CreatedAt: daysAgo(101), UpdatedAt: daysAgo(99),
},
{
Slug: "metrics-that-earn-their-keep",
Title: "Metrics That Earn Their Keep",
Summary: "Four signals nobody has to interpret beat forty dashboards nobody opens.",
Body: `Every metric costs something: collection, storage, and the attention needed to notice when it moves.
We start with latency, traffic, errors, and saturation for each user-facing path, then add a metric only when we can name the decision it would change. A dashboard that has never been opened during an incident is decoration.
Graphs are more useful with a line for the last release. Most arguments about a trend dissolve when the deployment markers are visible.`,
Status: "published",
Category: "devops",
Badges: []string{"metrics", "observability", "reliability"},
CreatedAt: daysAgo(96), UpdatedAt: daysAgo(95),
},
{
Slug: "secrets-stay-out-of-git",
Title: "Secrets Stay Out of Git",
Summary: "A short policy: no credentials in the repository, ever, including in history.",
Body: `A secret committed once is a secret leaked, because history is copied, cached, and cloned.
The rule is simple: configuration lives in the repository, credentials do not. Local development reads from an untracked environment file, and pipelines read from the platform secret store. When a key must be rotated, we rotate it before deleting the commit that exposed it.
We also check for the accidental paste in review. A pre-commit hook that flags likely keys costs nothing and prevents the slowest kind of incident.`,
Status: "published",
Category: "devops",
Badges: []string{"encryption", "git", "tooling"},
CreatedAt: daysAgo(91), UpdatedAt: daysAgo(90),
},
{
Slug: "blue-green-without-drama",
Title: "Blue-Green Without Drama",
Summary: "Cutovers stay calm when both sides are deliberately boring.",
Body: `Blue-green deployment is mostly an exercise in refusing to be clever.
Both environments run the same configuration, differ only in the image tag, and are exercised by the same smoke tests. The switch is a routing change that can be reverted in one step, which is the entire point: the rollback path is rehearsed because it is the same path used to deploy.
The difficult part is not the routing, it is the data. Any schema change must be compatible with both versions for the length of the cutover window, which is why we expand first, migrate, and contract later.`,
Status: "published",
Category: "devops",
Badges: []string{"reliability", "automation", "ci"},
CreatedAt: daysAgo(86), UpdatedAt: daysAgo(85),
},
{
Slug: "cron-is-a-fine-scheduler",
Title: "Cron Is a Fine Scheduler",
Summary: "Before reaching for a workflow engine, check whether cron plus a lock is enough.",
Body: `Scheduled work is usually simpler than the tooling sold for it.
Cron is unglamorous and reliable. Add flock so overlapping runs cannot stack, write a log line with a run identifier, and make the job idempotent so a retry after a failure is harmless. That combination covers most nightly jobs we have ever needed.
Reach for a real orchestrator when the work has dependencies, partial failures that must be resumed, or a need for per-step visibility. Those are real requirements, but they should arrive before the tool does.`,
Status: "published",
Category: "devops",
Badges: []string{"cron", "automation", "linux"},
CreatedAt: daysAgo(81), UpdatedAt: daysAgo(80),
},
{
Slug: "systemd-timers-are-just-cron-with-manners",
Title: "systemd Timers Are Just Cron With Manners",
Summary: "Timers bring logging, dependencies, and a useful status command to scheduled work.",
Body: `The first thing to say about systemd timers is that the schedule itself is not the interesting part. The interesting part is everything around it.\n\nA timer unit can declare that it runs after the network is up, that it jitters to avoid a thundering herd at midnight, and that failures are visible through the same status command used for services. Logs land in the journal with the rest of the machine.\n\nThe costs are also real: two unit files instead of one crontab line, and a syntax that rewards a quick look at the manual. For anything that matters, that trade is usually worth taking.`,
Status: "published",
Category: "linux",
Badges: []string{"systemd", "systemd-units", "automation"},
CreatedAt: daysAgo(76), UpdatedAt: daysAgo(75),
},
{
Slug: "reading-a-cgroup-pressure-file",
Title: "Reading a cgroup Pressure File",
Summary: "PSI numbers explain whether a machine is slow because of CPU, memory, or IO.",
Body: `When a host feels slow, the usual averages hide the cause. Pressure stall information does not.\n\nThe files under the pressure directory report the share of time tasks were stalled waiting for a resource. A rising IO stall with idle CPU points somewhere very different from a saturated CPU, and the difference decides whether you look at disks or at code.\n\nThe numbers are also cumulative, so what matters is the rate of change. Read them twice, a minute apart, and the answer is usually obvious without installing anything.`,
Status: "published",
Category: "linux",
Badges: []string{"cgroups", "performance", "troubleshooting"},
CreatedAt: daysAgo(71), UpdatedAt: daysAgo(70),
},
{
Slug: "lvm-for-people-who-like-undo",
Title: "LVM for People Who Like Undo",
Summary: "Logical volumes make storage changes reversible, which is the real feature.",
Body: `The headline feature of LVM is not snapshots, it is that resizing stops being frightening.\n\nA volume group turns fixed partitions into a pool, so growing a filesystem is a two-command operation and taking a snapshot before a risky change costs a few seconds. Snapshots are not backups, and they are not free, but they turn a mistake from an afternoon into a rollback.\n\nThe rule we follow is simple: any operation that could lose data gets a snapshot first, and the snapshot gets deleted the moment the change is verified.`,
Status: "published",
Category: "linux",
Badges: []string{"lvm", "filesystem"},
CreatedAt: daysAgo(66), UpdatedAt: daysAgo(65),
},
{
Slug: "permissions-without-superstition",
Title: "Permissions Without Superstition",
Summary: "Read the octal number as three questions instead of memorising a table.",
Body: `Most permission confusion comes from treating the numbers as magic.\n\nRead them as three questions about the owner, the group, and everyone else: can they read, can they write, can they execute? Three bits each, four plus two plus one. Once that clicks, 755 and 644 stop being incantations.\n\nThe remaining decision is ownership. Files that a service must write belong to that service, not to a shared group with generous permissions. Narrow ownership and a clear group are easier to reason about than a permissive mode applied to everything.`,
Status: "published",
Category: "linux",
Badges: []string{"permissions", "filesystem"},
CreatedAt: daysAgo(61), UpdatedAt: daysAgo(60),
},
{
Slug: "ssh-keys-you-can-audit",
Title: "SSH Keys You Can Audit",
Summary: "One key per machine, a comment that identifies it, and a rotation you have practised.",
Body: `A key you cannot identify is a key you cannot safely revoke.\n\nEvery key gets a comment naming the device and the year, lives in the agent with a passphrase, and is listed somewhere we can read during an incident. Shared keys are a shortcut that turns revocation into an archaeology project.\n\nThe rotation is deliberately dull: generate, install, confirm access, remove the old line. Doing it once while nothing is wrong is what makes it possible during an incident.`,
Status: "published",
Category: "linux",
Badges: []string{"ssh", "encryption", "tooling"},
CreatedAt: daysAgo(56), UpdatedAt: daysAgo(55),
},
{
Slug: "wireguard-on-a-laptop",
Title: "WireGuard on a Laptop",
Summary: "A minimal tunnel config that reconnects quietly and stays out of the way.",
Body: `WireGuard is refreshingly small: a private key, a peer, an allowed range, and an endpoint.\n\nThe parts worth getting right are not the cryptography but the behaviour. Keep the allowed range tight so only the services you meant to reach are routed, enable the persistent keepalive only when you are behind a NAT, and let the tooling reconnect rather than adding a script that fights it.\n\nWe keep one configuration per purpose. A tunnel that tries to be the office, the lab, and the internet at once is the tunnel that breaks at the worst time.`,
Status: "published",
Category: "linux",
Badges: []string{"wireguard", "networking", "encryption"},
CreatedAt: daysAgo(51), UpdatedAt: daysAgo(50),
},
{
Slug: "journalctl-in-anger",
Title: "journalctl in Anger",
Summary: "Filter by unit, by time, and by priority before you start scrolling.",
Body: `The journal is only intimidating until you use three flags.\n\nFilter to a unit, bound the time range to the incident window, and raise the priority floor so informational chatter disappears. Almost every investigation is these three filters plus a search for an identifier you already know.\n\nFollow mode is the other half of the tool: watching a unit while you reproduce a problem beats reconstructing the sequence afterwards from an unfiltered dump.`,
Status: "published",
Category: "linux",
Badges: []string{"logging", "systemd", "troubleshooting"},
CreatedAt: daysAgo(46), UpdatedAt: daysAgo(45),
},
{
Slug: "keeping-updates-boring",
Title: "Keeping Updates Boring",
Summary: "Update often, reboot deliberately, and keep a note of what changed.",
Body: `The dangerous update is the one that has been postponed for months.\n\nWe apply updates on a schedule small enough that any single change is unremarkable, and we reboot at a moment we chose rather than one the machine chose. A short note in the log recording the date and the packages that moved is enough to connect a new symptom to a recent change.\n\nWhen something does break, the boring habit pays off: a small set of candidate changes is far easier to reason about than a year of accumulated drift.`,
Status: "published",
Category: "linux",
Badges: []string{"linux", "reliability", "troubleshooting"},
CreatedAt: daysAgo(41), UpdatedAt: daysAgo(40),
},
{
Slug: "taming-grep-with-context",
Title: "Taming grep With Context",
Summary: "Three flags turn search output into something a human can read.",
Body: `Search results without context are a puzzle, not an answer.\n\nThe flags worth memorising are the ones that show neighbouring lines, print the filename, and count matches per file. Together they let you triage a repository without opening an editor. Add the fixed-string option and the recursive option and you rarely need anything else.\n\nThe habit that matters is searching for the thing that would have to be true for the bug to exist, rather than the message you already read in the logs.`,
Status: "published",
Category: "linux",
Badges: []string{"grep", "terminal", "debugging"},
CreatedAt: daysAgo(36), UpdatedAt: daysAgo(35),
},
{
Slug: "a-tmux-layout-that-survives",
Title: "A tmux Layout That Survives",
Summary: "A layout per task, kept in a config file instead of in muscle memory.",
Body: `A terminal multiplexer earns its place when a session outlives a dropped connection.\n\nThe useful discipline is naming sessions after the task, not after the machine, and keeping a layout definition in the configuration so a rebuild looks the same as the original. Windows for editing, tests, and logs are enough for most work.\n\nThe extra benefit is psychological: an attached session makes it obvious what was in progress when you come back to a machine you last touched a week ago.`,
Status: "published",
Category: "linux",
Badges: []string{"tmux", "terminal", "tooling"},
CreatedAt: daysAgo(131), UpdatedAt: daysAgo(130),
},
{
Slug: "makefiles-for-small-projects",
Title: "Makefiles for Small Projects",
Summary: "A dozen lines of make beat remembering six commands.",
Body: `Make is unfashionable and completely sufficient for the jobs a small repository actually has.\n\nWe keep targets for the things a newcomer will need in the first ten minutes: build, test, run, and clean. Declaring them phony avoids the classic confusion, and a single default target means the repository explains itself.\n\nThe test for whether a Makefile is doing too much is whether you can read it in one screen. When it grows past that, the build probably wants a real dependency graph, but most repositories never get there.`,
Status: "published",
Category: "linux",
Badges: []string{"makefile", "tooling", "automation"},
CreatedAt: daysAgo(31), UpdatedAt: daysAgo(30),
},
{
Slug: "context-is-not-a-cancellation-token",
Title: "Context Is Not Just a Cancellation Token",
Summary: "Pass it as the first argument, cancel it at the boundary, and never store it.",
Body: `Context carries three things: cancellation, deadlines, and request-scoped values. Most bugs come from mixing them up.\n\nIt belongs in the function signature as the first parameter and nowhere else. Storing it in a struct makes lifetime ambiguous, and using it for optional configuration hides dependencies that should be arguments.\n\nThe cancellation should originate at the edge, where the request or the command decides how long the work may take. Every layer below just honours it. When a deadline expires, the useful question is which stage ignored it for too long.`,
Status: "published",
Category: "go",
Badges: []string{"concurrency", "api", "errors"},
CreatedAt: daysAgo(141), UpdatedAt: daysAgo(140),
},
{
Slug: "table-tests-that-still-read",
Title: "Table Tests That Still Read",
Summary: "A named case with a clear input, a clear expectation, and no shared state.",
Body: `A table test is only better than three separate tests if it stays readable.\n\nWe name every case with the behaviour it protects, keep setup inside the case where possible, and avoid clever closures that make failures hard to trace. When a case needs a paragraph of setup, it usually deserves to be its own function.\n\nThe failure message matters as much as the assertion. A test that prints the input, the actual value, and the expected value saves a debugging round trip in continuous integration.`,
Status: "published",
Category: "go",
Badges: []string{"testing", "code-review"},
CreatedAt: daysAgo(26), UpdatedAt: daysAgo(25),
},
{
Slug: "generics-when-they-help",
Title: "Generics, When They Help",
Summary: "Reach for type parameters when the algorithm is identical and only the type changes.",
Body: `Generics are a tool for removing duplicated algorithms, not for expressing clever abstractions.\n\nIf two functions differ only in the type they operate on, a type parameter removes the copy without hiding anything. If they differ in behaviour, an interface expresses the variation more honestly, because it names the operations that matter.\n\nThe cost is readability for readers who have not internalised the syntax, so we use them where the saving is obvious and skip them where an interface would document intent better.`,
Status: "published",
Category: "go",
Badges: []string{"generics", "library", "architecture"},
CreatedAt: daysAgo(21), UpdatedAt: daysAgo(20),
},
{
Slug: "error-wrapping-that-survives",
Title: "Error Wrapping That Survives",
Summary: "Add context once per layer, and let callers ask questions with errors.Is.",
Body: `An error message should read like a path from the entry point to the failure.\n\nWrap once per layer with the operation that failed, and keep the original error in the chain so callers can test for it. Repeating the same words at every level produces messages that are long and useless.\n\nSentinel errors answer a question about kind, custom types answer a question about detail, and everything else is context for a human. Deciding which of those a caller needs is the actual design work.`,
Status: "published",
Category: "go",
Badges: []string{"errors", "debugging", "api"},
CreatedAt: daysAgo(16), UpdatedAt: daysAgo(15),
},
{
Slug: "understanding-go-gc-pauses",
Title: "Understanding Go GC Pauses",
Summary: "Allocation rate, not heap size, is what usually drives pause behaviour.",
Body: `The collector is doing less work when the program allocates less.\n\nPauses grow when the allocation rate grows, because the collector has to run more often to keep up with the target. Reducing temporary allocations in a hot path often helps latency more than tuning any threshold.\n\nMeasure before tuning. The trace shows pause duration and frequency next to the work that caused it, which is the only way to tell an allocation problem apart from a scheduling one.`,
Status: "published",
Category: "go",
Badges: []string{"gc", "memory", "performance"},
CreatedAt: daysAgo(11), UpdatedAt: daysAgo(10),
},
{
Slug: "profiling-before-optimising",
Title: "Profiling Before Optimising",
Summary: "A profile turns an argument about performance into a list of the top three costs.",
Body: `Optimisation without a profile is guessing with extra steps.\n\nStart with the profile that matches the symptom: CPU for throughput, heap for memory, mutex and block profiles for contention. The first screen usually names a function nobody suspected.\n\nBenchmarks protect the change once it is made. A benchmark without a profile tells you that something got faster; a profile tells you why, which is what makes the next change obvious.`,
Status: "published",
Category: "go",
Badges: []string{"profiling", "performance", "benchmarks"},
CreatedAt: daysAgo(146), UpdatedAt: daysAgo(145),
},
{
Slug: "interfaces-small-enough-to-implement",
Title: "Interfaces Small Enough to Implement",
Summary: "Declare the interface where it is consumed, and keep it to the methods you call.",
Body: `A one-method interface is easy to satisfy and easy to read. A fifteen-method interface is a description of a package, not a seam.\n\nWe declare interfaces next to the code that consumes them, not next to the implementation. That keeps them honest: the only methods present are the ones a caller actually needs, which makes fakes trivial to write in tests.\n\nThe corollary is that adding a method to a widely used interface is a design change, not a convenience. It should come with a reason that mentions the caller.`,
Status: "published",
Category: "go",
Badges: []string{"architecture", "api", "code-review"},
CreatedAt: daysAgo(6), UpdatedAt: daysAgo(5),
},
{
Slug: "defer-in-a-loop",
Title: "defer in a Loop",
Summary: "Deferred calls run when the function returns, which is rarely what a loop wants.",
Body: `A deferred call inside a loop accumulates until the surrounding function ends.\n\nThat is usually harmless for a single mutex release and occasionally catastrophic for file handles or database rows. The fix is to move the body into its own function so each iteration has its own scope, which also tends to make the loop easier to read.\n\nWhen reviewing, missing deferral is one of the few patterns worth grepping for rather than reading for, because it is invisible in a quick scan.`,
Status: "published",
Category: "go",
Badges: []string{"errors", "memory", "code-review"},
CreatedAt: daysAgo(151), UpdatedAt: daysAgo(150),
},
{
Slug: "sync-pool-is-not-a-cache",
Title: "sync.Pool Is Not a Cache",
Summary: "It reduces allocation pressure; it does not promise that anything survives.",
Body: `A pool exists to shorten the life of short-lived objects, and it is allowed to drop everything at any moment.\n\nReaching for it to keep expensive things around is a mistake, because the runtime may clear the pool during a collection. If the resource must survive, it needs a real cache with a real lifetime policy.\n\nWhere the pool shines is in hot paths that allocate a buffer per request. The measured benefit is fewer collections, not faster individual operations.`,
Status: "published",
Category: "go",
Badges: []string{"memory", "performance", "concurrency"},
CreatedAt: daysAgo(156), UpdatedAt: daysAgo(155),
},
{
Slug: "json-encoding-details",
Title: "JSON Encoding Details That Bite",
Summary: "Field tags, zero values, and streaming are the three places bugs hide.",
Body: `Encoding looks like a solved problem until a zero value quietly disappears.\n\nThe omitempty option removes zero values, which is convenient for optional fields and surprising for booleans and counters that legitimately mean zero. Decide per field, and write the response shape down in a test so a future tag change is visible.\n\nFor large responses, stream into the writer instead of building the whole document in memory. It is barely more code and it removes a whole class of memory surprises.`,
Status: "published",
Category: "go",
Badges: []string{"api", "http", "rest"},
CreatedAt: daysAgo(161), UpdatedAt: daysAgo(160),
},
{
Slug: "graceful-shutdown",
Title: "Graceful Shutdown and the Requests You Forgot",
Summary: "Stop accepting new work, finish what is in flight, and give both a deadline.",
Body: `A process that exits immediately leaves a trail of half-finished work and confusing client errors.\n\nThe pattern is small: listen for the termination signal, stop accepting new connections, wait for in-flight requests with a deadline, and close resources in the reverse order they were opened. Background workers need the same treatment so their current item can finish.\n\nThe deadline is the important part. A shutdown with no bound is a shutdown that occasionally waits forever, which is worse than an abrupt stop.`,
Status: "published",
Category: "go",
Badges: []string{"http", "reliability", "concurrency"},
CreatedAt: daysAgo(166), UpdatedAt: daysAgo(165),
},
{
Slug: "reading-the-race-detector",
Title: "Reading the Race Detector",
Summary: "Two stacks, one shared address, and the write that proves the bug is real.",
Body: `A race report looks intimidating because it prints the same memory twice.\n\nThe first stack is one access, the second is the conflicting one, and the address they share names the variable. At least one of them is a write. Once you find that line, the question becomes which of the two goroutines owns the value, and the answer is usually that neither does.\n\nBecause the detector only reports races it actually observes, a clean run of the test suite is evidence, not proof. Keeping the flagged case as a regression test is what makes the fix durable.`,
Status: "published",
Category: "go",
Badges: []string{"concurrency", "testing", "debugging"},
CreatedAt: daysAgo(171), UpdatedAt: daysAgo(170),
},
{
Slug: "virtual-threads-in-practice",
Title: "Virtual Threads in Practice",
Summary: "They remove the thread-per-request cost, not the need to bound work.",
Body: `Virtual threads make blocking code affordable again, which is a bigger change than it sounds.\n\nA request per virtual thread keeps the code sequential and readable while the runtime multiplexes onto a small pool of carrier threads. The catch is that a resource you block on still has to be bounded: a connection pool with twenty connections will queue no matter how cheap the threads are.\n\nThe awkward part is anything that pins the carrier, which keeps the blocking call stuck to a platform thread. Finding those cases is a matter of watching the thread dumps under load rather than guessing.`,
Status: "published",
Category: "java",
Badges: []string{"threads", "concurrency", "performance"},
CreatedAt: daysAgo(136), UpdatedAt: daysAgo(135),
},
{
Slug: "records-and-the-end-of-boilerplate",
Title: "Records and the End of Boilerplate",
Summary: "A record says what the data is, and the compiler writes the rest.",
Body: `Most value classes are a name and a list of fields, and records describe exactly that.\n\nThe generated accessors, equality, and string form remove a page of code that nobody reviewed anyway. Validation belongs in a compact constructor, which keeps the rule next to the fields it protects.\n\nThe remaining judgement is whether the type is genuinely a value. If it has behaviour, identity, or mutable state, a record is the wrong shape and a normal class documents the intent better.`,
Status: "published",
Category: "java",
Badges: []string{"java", "api"},
CreatedAt: daysAgo(226), UpdatedAt: daysAgo(225),
},
{
Slug: "gradle-builds-you-can-read",
Title: "Gradle Builds You Can Read",
Summary: "Keep the build script boring and put logic in tasks, not in the configuration block.",
Body: `A build that needs a debugger is a build nobody will maintain.\n\nWe keep plugins and versions in one catalog, declare dependencies in one obvious place, and avoid clever logic in configuration. Anything conditional belongs in a task with a name, so it appears in the task list and can be run on its own.\n\nBuild time is a feature. Configuration cache and a warm daemon are usually worth more than any micro-optimisation inside a task.`,
Status: "published",
Category: "java",
Badges: []string{"gradle", "tooling", "ci"},
CreatedAt: daysAgo(221), UpdatedAt: daysAgo(220),
},
{
Slug: "jvm-memory-you-can-explain",
Title: "JVM Memory You Can Explain",
Summary: "Heap, metaspace, and the stack each answer a different question.",
Body: `Memory problems are easier to reason about once the three areas are distinct.\n\nThe heap holds objects and is what most tuning is about. Metaspace holds class metadata, which is why a classloader leak looks like native memory growth. The stack is per thread, which is why a thousand threads cost more than the objects they touch.\n\nReading a heap histogram before and after a suspected leak answers the question faster than any amount of configuration tuning, because it names the class that is accumulating.`,
Status: "published",
Category: "java",
Badges: []string{"jvm", "memory", "profiling"},
CreatedAt: daysAgo(216), UpdatedAt: daysAgo(215),
},
{
Slug: "checked-exceptions-revisited",
Title: "Checked Exceptions Revisited",
Summary: "They are a documentation tool, and they are easy to abuse.",
Body: `A checked exception forces a caller to acknowledge that something can fail, which is genuinely useful for recovery paths.\n\nThe abuse is a signature that lists five checked exceptions, most of which nobody can act on. Those belong in a single application exception or in an unchecked type, so the interesting failure stays visible.\n\nThe honest test is whether a caller would do something different for this failure. If not, it should not be in the signature.`,
Status: "published",
Category: "java",
Badges: []string{"errors", "java", "api"},
CreatedAt: daysAgo(211), UpdatedAt: daysAgo(210),
},
{
Slug: "streams-are-not-always-faster",
Title: "Streams Are Not Always Faster",
Summary: "They express a pipeline; they do not promise a faster one.",
Body: `A stream describes what should happen, and the runtime decides how.\n\nThat trade is usually worth it for readability, especially for filter-and-map work over a collection. It is a poor trade in the hottest loop of a program, where an explicit loop with a preallocated array is often measurably faster and no less clear.\n\nThe functional style also hides cost when intermediate operations allocate. If the pipeline is in a hot path, measure it with the same discipline used for anything else.`,
Status: "published",
Category: "java",
Badges: []string{"performance", "java", "benchmarks"},
CreatedAt: daysAgo(206), UpdatedAt: daysAgo(205),
},
{
Slug: "set-e-is-not-a-strategy",
Title: "set -e Is Not a Strategy",
Summary: "Errexit helps, but it does not replace checking what you actually care about.",
Body: `Turning on errexit catches the common case and gives a false sense of safety.\n\nCommands used as conditions, anything in a pipeline except the last element, and failures inside a subshell can all slip past. Reaching for the pipefail option fixes one of those and leaves the rest.\n\nThe scripts that stay reliable are the ones that check the result of anything destructive, print what they are about to do, and are short enough to read in one sitting. Options help; they do not decide.`,
Status: "published",
Category: "bash",
Badges: []string{"bash", "errors", "automation"},
CreatedAt: daysAgo(196), UpdatedAt: daysAgo(195),
},
{
Slug: "quoting-is-the-whole-language",
Title: "Quoting Is the Whole Language",
Summary: "Almost every mysterious shell bug is a missing pair of quotes.",
Body: `The shell splits, expands, and globs, and quoting is how you say no.\n\nDouble quotes keep a value together while still expanding variables. Single quotes keep everything literal. Unquoted values are where filenames with spaces become two arguments and an empty variable disappears entirely.\n\nThe habit worth building is quoting every expansion by default and removing quotes only when word splitting is genuinely wanted, which is rare and should look deliberate.`,
Status: "published",
Category: "bash",
Badges: []string{"bash", "shell", "debugging"},
CreatedAt: daysAgo(191), UpdatedAt: daysAgo(190),
},
{
Slug: "pipes-without-surprises",
Title: "Pipes Without Surprises",
Summary: "Buffering, exit codes, and missing input are the three things to watch.",
Body: `A pipeline is a small distributed system, and it fails in the same interesting ways.\n\nThe exit status of a pipeline is the status of its last command unless you ask for more. A consumer that exits early leaves the producer writing into a closed pipe. Buffering means output can appear in a different order than you expect when watching a live log.\n\nWriting the intermediate result to a file while developing is not a defeat. It turns three commands into two understandable steps and usually finds the bug faster.`,
Status: "published",
Category: "bash",
Badges: []string{"bash", "shell", "troubleshooting"},
CreatedAt: daysAgo(186), UpdatedAt: daysAgo(185),
},
{
Slug: "arrays-in-bash",
Title: "Arrays in Bash, Carefully",
Summary: "Quote the expansion, iterate over values, and do not use strings as lists.",
Body: `Arrays exist so that filenames with spaces stop being a problem.\n\nExpanding an array unquoted reintroduces exactly that problem, so the expansion gets quotes like any other value. Iterating over the elements rather than over an index keeps the loop simple and avoids off-by-one mistakes.\n\nThe alternative, packing a list into a delimited string, works until the first value contains the delimiter. If the list can contain arbitrary text, use the array.`,
Status: "published",
Category: "bash",
Badges: []string{"bash", "shell", "snippets"},
CreatedAt: daysAgo(181), UpdatedAt: daysAgo(180),
},
{
Slug: "trap-and-cleanup",
Title: "trap and Cleanup",
Summary: "Temporary files and locks should be released even when the script fails.",
Body: `A script that leaves state behind is a script that breaks the next run.\n\nA trap on exit runs whether the script succeeded, failed, or was interrupted, which makes it the right place to remove temporary directories and release locks. Handling the interrupt signal lets a long job stop between steps instead of in the middle of one.\n\nThe cleanup should be idempotent, because a trap can run when the resource was never created. Checking before removing costs one line and prevents a confusing error at exactly the worst moment.`,
Status: "published",
Category: "bash",
Badges: []string{"bash", "automation", "reliability"},
CreatedAt: daysAgo(176), UpdatedAt: daysAgo(175),
},
{
Slug: "find-with-print0",
Title: "find With print0",
Summary: "Newline-separated filenames break on real filesystems.",
Body: `Passing a list of paths through a newline is an assumption that filenames are well behaved.\n\nUsing the null-terminated output with the matching input option removes the assumption entirely, which matters as soon as a filename contains a space, a quote, or a newline. It costs one extra flag on each side.\n\nThe other half of the habit is limiting the search early: by depth, by type, or by modification time. A find that scans an entire home directory to produce three results is a find that should have been narrower.`,
Status: "published",
Category: "bash",
Badges: []string{"filesystem", "bash", "terminal"},
CreatedAt: daysAgo(121), UpdatedAt: daysAgo(120),
},
{
Slug: "awk-one-liners-worth-knowing",
Title: "awk One-Liners Worth Knowing",
Summary: "Choosing fields, filtering rows, and summing a column cover most real work.",
Body: `Awk is a tiny language for columns, and three of its features handle most tasks.\n\nPrinting selected fields, filtering lines by a condition, and accumulating a total are enough to replace a great deal of fragile text processing. Field numbering is one-based, and treating the first line differently is a one-line idiom.\n\nThe reason to prefer awk over a chain of substitutions is that it parses structure rather than matching text, which makes it survive changes that would break a regular expression.`,
Status: "published",
Category: "bash",
Badges: []string{"awk", "terminal", "snippets"},
CreatedAt: daysAgo(116), UpdatedAt: daysAgo(114),
},
{
Slug: "sed-substitutions-that-are-safe",
Title: "sed Substitutions That Are Safe",
Summary: "Preview, choose an unusual delimiter, and back up before writing in place.",
Body: `An in-place substitution without a backup is a one-way door.\n\nThe safe habit is to run the substitution first and read the output, then repeat it with the in-place flag and a backup suffix. Choosing a delimiter that does not appear in the pattern removes a whole category of quoting confusion when the text involves paths.\n\nFor anything beyond a simple substitution, the honest answer is usually that the task wants a real parser. Sed is excellent at what it does and quietly dangerous outside that.`,
Status: "published",
Category: "bash",
Badges: []string{"sed", "regex", "terminal"},
CreatedAt: daysAgo(111), UpdatedAt: daysAgo(109),
},
{
Slug: "docker-images-that-stay-small",
Title: "Docker Images That Stay Small",
Summary: "Multi-stage builds, static binaries, and a base image with nothing extra.",
Body: `Most image size comes from build tooling that should not exist at runtime.\n\nA multi-stage build keeps the compiler, the package manager, and the source tree in the first stage and copies only the finished artifact into the second. For a language that can produce a static binary, the runtime stage needs nothing but certificates and a non-root user.\n\nThe payoff is not only size. A smaller image starts faster, has fewer packages to patch, and makes the container's purpose obvious to anyone reading the file.`,
Status: "published",
Category: "tooling",
Badges: []string{"docker", "containers", "ci"},
CreatedAt: daysAgo(106), UpdatedAt: daysAgo(105),
},
{
Slug: "dockerfiles-people-can-trust",
Title: "Dockerfiles People Can Trust",
Summary: "Pin versions, order layers by change frequency, and run as a normal user.",
Body: `A Dockerfile is a build script with a cache, and both parts deserve attention.\n\nPinning the base version makes a rebuild reproducible instead of a surprise. Copying the dependency manifest and installing before copying the source keeps the expensive layer cached while the code changes underneath it. Creating an unprivileged user costs two lines and removes a whole class of escalation.\n\nWe also like a build that fails loudly: no silent ignoring of errors, no downloads without checksums, no assumptions about the network.`,
Status: "published",
Category: "tooling",
Badges: []string{"docker", "containers", "encryption"},
CreatedAt: daysAgo(206), UpdatedAt: daysAgo(204),
},
{
Slug: "compose-for-local-only",
Title: "Compose for Local Only",
Summary: "A good compose file documents how to run the project on a laptop.",
Body: `The value of a compose file is onboarding, not orchestration.\n\nIt should start the one or two dependencies a developer actually needs, with health checks so the app does not race them, and named volumes so data survives a restart. Anything that only matters in production belongs somewhere else.\n\nKeeping the file short is a feature. If a newcomer has to read it for five minutes, it has stopped doing its job.`,
Status: "published",
Category: "tooling",
Badges: []string{"docker", "compose", "tooling"},
CreatedAt: daysAgo(101), UpdatedAt: daysAgo(100),
},
{
Slug: "changelogs-with-a-point-of-view",
Title: "Changelogs With a Point of View",
Summary: "Write for the reader deciding whether to upgrade.",
Body: `A changelog is a decision aid, not a commit log.\n\nEntries grouped by what changed for the user, with breaking changes called out first and the reason stated briefly, are worth more than a complete list of tickets. Linking to the relevant documentation turns an announcement into something actionable.\n\nThe tone matters too. A changelog that says only what was fixed leaves readers wondering what is still broken. Naming the trade-offs builds the trust that makes the next release easier to adopt.`,
Status: "published",
Category: "tooling",
Badges: []string{"documentation", "writing", "changelog"},
CreatedAt: daysAgo(96), UpdatedAt: daysAgo(95),
},
{
Slug: "linters-earn-their-place",
Title: "Linters Have to Earn Their Place",
Summary: "Every rule should prevent a bug somebody actually shipped.",
Body: `A linter is a code review that never gets tired, and like a code review it can be wrong.\n\nWe enable rules with a reason attached: this one catches a mistake we made twice, that one keeps a style argument from recurring. Rules that only express a preference are turned off deliberately rather than tolerated forever.\n\nFormatting belongs to a formatter, not to a discussion. Agreeing to stop arguing about whitespace is one of the cheapest productivity gains available.`,
Status: "published",
Category: "tooling",
Badges: []string{"tooling", "code-review", "ci"},
CreatedAt: daysAgo(91), UpdatedAt: daysAgo(90),
},
{
Slug: "git-bisect-when-things-break",
Title: "git bisect When Things Break",
Summary: "A scripted good and bad commit turns a mystery into a short search.",
Body: `Bisect is the fastest way to answer "when did this start?" and it is usually forgotten.\n\nMarking a known good and a known bad revision halves the search space each step, which finds the culprit in a handful of builds even in a long history. Automating the check with a script makes it possible to walk away while it runs.\n\nThe prerequisite is a test that fails reliably. If the check is flaky, bisect returns a confident wrong answer, which is worse than no answer at all.`,
Status: "published",
Category: "tooling",
Badges: []string{"git", "debugging", "testing"},
CreatedAt: daysAgo(86), UpdatedAt: daysAgo(85),
},
{
Slug: "editor-config-as-a-document",
Title: "Editor Config as a Document",
Summary: "Aligning the small conventions removes a recurring review comment.",
Body: `Line endings, indentation, and trailing whitespace produce a surprising share of review noise.\n\nDeclaring them once in a shared file means the editor agrees with continuous integration and with colleagues on other platforms. It is a small file with a large return, because it removes a class of comment that carries no information.\n\nThe same idea applies to the manifest that pins the toolchain version. When the version is written down, works-on-my-machine becomes a conversation about the file rather than about machines.`,
Status: "published",
Category: "tooling",
Badges: []string{"tooling", "code-review", "documentation"},
CreatedAt: daysAgo(79), UpdatedAt: daysAgo(78),
},
{
Slug: "vim-motions-worth-practising",
Title: "Vim Motions Worth Practising",
Summary: "Structure-aware movement beats repeating a character search.",
Body: `The motions that pay back their practice time are the ones that move by structure.\n\nJumping to a word, to a paragraph, to a matching bracket, and to the last edit covers most navigation. Combining a motion with an operator is what turns editing into a sentence rather than a sequence of keystrokes.\n\nReaching for the search command instead of holding a movement key is the single habit that changes the most, because it turns a long scroll into one jump.`,
Status: "published",
Category: "tooling",
Badges: []string{"vim", "terminal", "tooling"},
CreatedAt: daysAgo(76), UpdatedAt: daysAgo(75),
},
{
Slug: "http-clients-that-timeout",
Title: "HTTP Clients That Time Out",
Summary: "The default has no deadline, which is a bug waiting for a slow dependency.",
Body: `A client without a timeout will wait as long as the server feels like making it wait.\n\nSetting a total timeout, a connection timeout, and a response header timeout separately makes the failure mode easy to reason about. Retries belong on top of those, bounded and only for idempotent requests, ideally with a little randomised delay.\n\nThe same discipline applies to the server side. A handler that can block forever turns one slow dependency into an outage, and a deadline propagated from the request is what prevents it.`,
Status: "published",
Category: "tooling",
Badges: []string{"http", "reliability", "api"},
CreatedAt: daysAgo(176), UpdatedAt: daysAgo(174),
},
{
Slug: "yaml-is-a-data-format-honestly",
Title: "YAML Is a Data Format, Honestly",
Summary: "Two spaces, no tabs, and quote anything that looks like a special value.",
Body: `Most configuration mystery comes from YAML being more clever than expected.\n\nIndentation is significant and tabs are forbidden, so a consistent editor setting prevents the most common failure. Values that look like booleans or numbers are coerced unless quoted, which is how a version number becomes a float.\n\nThe practical rule is to treat YAML as JSON with comments: keep the structure shallow, quote the ambiguous scalars, and let a validator catch the rest before a deployment does.`,
Status: "published",
Category: "tooling",
Badges: []string{"yaml", "tooling", "troubleshooting"},
CreatedAt: daysAgo(171), UpdatedAt: daysAgo(170),
},
{
Slug: "writing-for-readers-in-a-hurry",
Title: "Writing for Readers in a Hurry",
Summary: "The first sentence should answer the question that brought them here.",
Body: `Most readers arrive with a question and very little patience.\n\nPutting the answer in the first sentence, before the context and before the caveats, respects that. Background belongs after the answer, where a reader who needs it will happily go.\n\nThe structure that survives editing is: what, so what, now what. What happened, why it matters to you, and what you should do next. Everything else is optional.`,
Status: "published",
Category: "design",
Badges: []string{"writing", "editorial", "documentation"},
CreatedAt: daysAgo(166), UpdatedAt: daysAgo(165),
},
{
Slug: "headings-are-a-table-of-contents",
Title: "Headings Are a Table of Contents",
Summary: "Read only the headings; if the outline does not make sense, the piece does not either.",
Body: `A reader skimming the headings should be able to reconstruct the argument.\n\nThat test catches most structural problems before a word of prose is rewritten. If two headings say the same thing, there is one section too many. If a heading is a noun phrase with no verb, it probably describes a topic instead of a claim.\n\nWe keep one idea per heading level and avoid skipping levels, because the hierarchy is a promise about how the parts relate.`,
Status: "published",
Category: "design",
Badges: []string{"writing", "editorial", "typography"},
CreatedAt: daysAgo(161), UpdatedAt: daysAgo(160),
},
{
Slug: "the-first-draft-is-for-you",
Title: "The First Draft Is for You",
Summary: "Write it badly, then delete the parts that only helped you think.",
Body: `The first draft exists to discover what the piece is actually about.\n\nThat means it is allowed to be repetitive, uncertain, and too long. The editing pass is where the discovered argument gets stated once and the scaffolding comes down.\n\nThe tell-tale sign of an unedited draft is explanation that answers a question the reader never asked. It was necessary to write; it is not necessary to read.`,
Status: "published",
Category: "design",
Badges: []string{"writing", "editing", "editorial"},
CreatedAt: daysAgo(156), UpdatedAt: daysAgo(155),
},
{
Slug: "accessibility-is-not-a-checklist",
Title: "Accessibility Is Not a Checklist",
Summary: "Contrast and keyboard access are the floor, not the ceiling.",
Body: `Passing an automated audit is the beginning of accessibility work, not the end.\n\nThe parts a tool cannot see are usually the parts that matter most: whether the reading order makes sense, whether a change is announced to assistive technology, whether an error explains how to recover. Those come from using the thing the way a person with different needs would.\n\nThe cheapest improvement in any interface is almost always better contrast and a visible focus state. Both are one line of styling and both help everybody.`,
Status: "published",
Category: "design",
Badges: []string{"accessibility", "css", "design"},
CreatedAt: daysAgo(151), UpdatedAt: daysAgo(150),
},
{
Slug: "empty-states-are-real-states",
Title: "Empty States Are Real States",
Summary: "The screen with no data is the first screen many people ever see.",
Body: `An empty list is not an edge case; for a new account it is the default.\n\nA good empty state says why there is nothing here, what would put something here, and offers the action that does it. It is the most useful onboarding surface in the product and it is usually left as a blank rectangle.\n\nThe same applies to loading and error states. If those three were designed alongside the populated view instead of after it, most interface awkwardness would disappear.`,
Status: "published",
Category: "design",
Badges: []string{"design", "css", "typography"},
CreatedAt: daysAgo(146), UpdatedAt: daysAgo(145),
},
{
Slug: "boring-typography-scales",
Title: "Boring Typography Scales",
Summary: "Two families, a handful of sizes, and a generous line height.",
Body: `Typography decisions get easier when the scale is small enough to remember.\n\nOne family for prose and one for code, four or five sizes total, and a line height that grows as the text gets smaller. Constraining the choices removes the meeting about which heading deserves an extra two pixels.\n\nThe most common mistake is not a bad font, it is too little spacing. Reading is easier with slightly more space than looks correct on a designer's monitor.`,
Status: "published",
Category: "design",
Badges: []string{"typography", "css", "design"},
CreatedAt: daysAgo(141), UpdatedAt: daysAgo(140),
},
{
Slug: "deep-work-and-the-backlog",
Title: "Deep Work and the Backlog",
Summary: "A backlog that everyone can read is a backlog that interrupts everyone.",
Body: `A shared list of everything is a shared invitation to interrupt.\n\nWe keep a small, visible queue of committed work and a longer list that nobody is expected to watch. The visible part is what the team is doing now, and it should be short enough to hold in mind.\n\nProtecting a block of uninterrupted time is not a personal preference, it is a scheduling decision with a cost attached. Naming the cost makes it possible to defend.`,
Status: "published",
Category: "workflow",
Badges: []string{"roadmap", "workflow", "writing"},
CreatedAt: daysAgo(136), UpdatedAt: daysAgo(135),
},
{
Slug: "code-review-as-a-conversation",
Title: "Code Review as a Conversation",
Summary: "Ask about intent before style, and separate blocking from optional.",
Body: `A review that begins with formatting has skipped the part where the author learns something.\n\nThe useful order is intent, then correctness, then clarity, then style. The first question is whether the change does what it claims; everything else is cheaper to discuss once that is settled.\n\nLabelling comments as blocking or optional removes the guesswork. A reviewer who writes only the blocking ones is being kind, not lazy.`,
Status: "published",
Category: "workflow",
Badges: []string{"code-review", "review", "writing"},
CreatedAt: daysAgo(131), UpdatedAt: daysAgo(130),
},
{
Slug: "documentation-next-to-the-code",
Title: "Documentation Next to the Code",
Summary: "A document in the repository is reviewed, versioned, and found.",
Body: `Documentation that lives only in a wiki drifts out of date without anyone noticing.\n\nKeeping the explanation in the repository means a change that invalidates it appears in the same review as the change itself. Comments explain why, the readme explains how to run it, and the design notes explain what was rejected.\n\nThe best documentation is short, current, and located where somebody will trip over it. A perfect document nobody opens is a hobby.`,
Status: "published",
Category: "workflow",
Badges: []string{"documentation", "writing", "code-review"},
CreatedAt: daysAgo(126), UpdatedAt: daysAgo(125),
},
{
Slug: "estimates-as-ranges",
Title: "Estimates as Ranges",
Summary: "A range with an assumption attached is more useful than a date without one.",
Body: `A single date invites an argument about the date rather than the work.\n\nA range plus the assumption it rests on gives the reader something to reason with: if the assumption holds, the work lands here, and if the integration turns out to be more involved, it moves. That is a more honest sentence than any point estimate.\n\nThe practice also surfaces hidden uncertainty early. When the range is very wide, the useful next step is usually a small experiment rather than more discussion.`,
Status: "published",
Category: "workflow",
Badges: []string{"roadmap", "workflow", "writing"},
CreatedAt: daysAgo(121), UpdatedAt: daysAgo(120),
},
{
Slug: "turning-incidents-into-habits",
Title: "Turning Incidents Into Habits",
Summary: "A fix in a document gets forgotten; a fix in automation does not.",
Body: `The end of an incident is the moment when a lesson is easy to capture and easy to lose.\n\nThe durable outcome is not the write-up, it is the check that runs automatically, the alert that fires earlier, or the guard rail that makes the wrong action impossible. Prefer the change that works when nobody remembers the incident.\n\nWe keep one follow-up of that kind per incident. A list of ten improvements usually produces zero, and one automation produces a permanent reminder.`,
Status: "published",
Category: "workflow",
Badges: []string{"reliability", "automation", "reminders"},
CreatedAt: daysAgo(116), UpdatedAt: daysAgo(115),
},
{
Slug: "shipping-the-boring-improvement",
Title: "Shipping the Boring Improvement",
Summary: "The change nobody will notice is often the one that removes the most pain.",
Body: `Rewrites are exciting and rarely the best use of a week.\n\nThe improvements that hold up are dull: a clearer error message, one fewer manual step, a test that closes a flaky path, a log line that names the request. None of them make a good demo, and together they change how the work feels.\n\nWe protect time for them explicitly. If only the interesting work gets scheduled, the boring improvements accumulate as complaints instead of commits.`,
Status: "published",
Category: "workflow",
Badges: []string{"workflow", "review", "roadmap"},
CreatedAt: daysAgo(111), UpdatedAt: daysAgo(110),
},
{
Slug: "notes-from-automating-my-own-workflow",
Title: "Notes From Automating My Own Workflow",
Summary: "Automate the second time you do something, not the first.",
Body: `Automating a task you have done once produces a script shaped like a guess.\n\nThe second time, the variation is visible: which parts are always the same and which parts change. That is the moment a script earns its keep, because the interface it needs is now obvious.\n\nThe other rule is to keep the automation readable. A script with no output and no comments saves five minutes and costs thirty the next time it fails silently.`,
Status: "published",
Category: "workflow",
Badges: []string{"automation", "tooling", "bash"},
CreatedAt: daysAgo(106), UpdatedAt: daysAgo(105),
},
{
Slug: "the-meeting-that-should-be-a-note",
Title: "The Meeting That Should Be a Note",
Summary: "If nobody will ask a question, write it down instead.",
Body: `Recurring meetings survive long after the reason for them has been forgotten.\n\nThe test is simple: is there a decision to make or a question to answer that requires people in the same moment? If the answer is that the meeting exists to share status, a short written update does the job better and consumes less of everyone's attention.\n\nConverting one meeting into a note is a cheap experiment. If nothing breaks, the meeting was already finished.`,
Status: "published",
Category: "workflow",
Badges: []string{"workflow", "writing", "documentation"},
CreatedAt: daysAgo(66), UpdatedAt: daysAgo(65),
},
{
Slug: "built-with-ai",
Title: "This Demo Was Built With AI, and That Is the Point",
Summary: "This whole backlog, the API behind it, and the image it runs in were written in conversation with an AI assistant. Here is what that felt like.",
Body: `This application is a demo, and it has a confession: almost all of it was written together with an AI assistant.
The backend is a small Go service with a hand-written router, an in-memory store, and one dependency-free security seam. The frontend is React and TypeScript. The whole thing ships as a single container with the compiled binary and the static assets inside, and the articles you are reading are seeded from a Go slice rather than a database. None of that was typed out from a blank page. It was described, generated, reviewed, corrected, and described again.
What worked well was the speed of the boring parts. Renaming a field across a layer, adding a test that mirrors an existing one, restructuring a file so the pieces sit in a sensible order, writing fifty articles in a consistent voice: these are the tasks where a clear instruction produces a usable result in seconds. The assistant was also good at the thing people often forget to ask for, which is consistency. Once it had seen the shape of the codebase, it kept producing code that looked like it belonged there.
What worked less well was anything that depended on judgement that had not been stated out loud. Left alone, the assistant will happily keep a database that nobody asked for, add an abstraction layer to a five-line function, or write a test that asserts nothing meaningful. Every one of those happened during this build. The fix was never clever prompting, it was reading the diff.
That is the habit this project mostly taught. AI is excellent at producing plausible code quickly, and plausible is not the same as correct. The security model in this repository changed shape twice: once when it became clear that a demo with one owner should not carry an account system at all, and once when the storage layer was replaced rather than repaired. Both decisions were human, and both were simpler than what had grown there before. The assistant implemented them faithfully and quickly; it did not propose them.
A few practical notes, in case this is useful to someone reading it as a case study rather than as an article.
Describe the constraint, not just the task. Asking for a single self-contained image produced a very different result from asking for a deployment.
Let the assistant see the whole picture. The quality of generated code tracked the accuracy of the surrounding context almost exactly.
Insist on verification. Every claim in this project was checked by building it, testing it, and running it. The assistant is at least as good at explaining why something works as it is at making it work, so the explanation was never treated as evidence.
Keep the changelog honest. The record of what was asked and what was done is more interesting than the result, and it is the only part of this that a future reader cannot reconstruct.
So the demo is small, and it is finished, and an AI wrote most of the words and most of the lines. The architecture, the trade-offs, and the decision to throw away the database were mine. That division of labour is the interesting part, and it is probably how a lot of small software gets built now. It was, honestly, fun.`,
Status: "published",
Category: "workflow",
Badges: []string{"open-source", "writing", "documentation"},
CreatedAt: daysAgo(2), UpdatedAt: daysAgo(1),
},
}

View file

@ -1,54 +1,96 @@
package web package web
import ( import (
"context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io/fs"
"net/http" "net/http"
"strings" "strings"
"github.com/example/cms/internal/domain"
"github.com/example/cms/internal/security" "github.com/example/cms/internal/security"
"github.com/example/cms/internal/service" "github.com/example/cms/internal/service"
) )
// Handler serves the CMS API. Reading content is anonymous; every mutation // Handler serves the CMS API and the embedded React app from one binary.
// requires the identity asserted by the upstream SSO proxy. // Reading content is anonymous; every mutation requires the identity asserted by
// the upstream SSO proxy.
type Handler struct { type Handler struct {
content service.IContentService content service.IContentService
assets fs.FS
sso security.ITokenVerifier sso security.ITokenVerifier
demo *service.DemoAuth
} }
func NewHandler(content service.IContentService, sso security.ITokenVerifier) *Handler { func NewHandler(content service.IContentService, assets fs.FS, sso security.ITokenVerifier, demo ...*service.DemoAuth) *Handler {
return &Handler{content: content, sso: sso} h := &Handler{content: content, assets: assets, sso: sso}
if len(demo) > 0 {
h.demo = demo[0]
}
return h
} }
func (h *Handler) Routes(origins string) http.Handler { func (h *Handler) Routes(origins string) http.Handler {
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { jsonWrite(w, 200, map[string]string{"status": "ok"}) }) mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
jsonWrite(w, http.StatusOK, map[string]string{"status": "ok"})
})
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/users", h.users)
mux.HandleFunc("/api/protected", h.protected) mux.HandleFunc("/api/protected", h.protected)
mux.HandleFunc("/api/test/protected", h.protected) mux.HandleFunc("/api/test/protected", h.protected)
mux.HandleFunc("/api/content", h.contentList) 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/", h.contentDetail) mux.HandleFunc("/api/content/", h.contentDetail)
mux.HandleFunc("/", h.singlePageApp)
return cors(mux, origins, h.sso.HeaderName()) return cors(mux, origins, h.sso.HeaderName())
} }
func (h *Handler) session(w http.ResponseWriter, r *http.Request) { func (h *Handler) session(w http.ResponseWriter, r *http.Request) {
authenticated := h.sso.Authenticated(r) username, authenticated := h.identity(r)
jsonWrite(w, http.StatusOK, map[string]any{"authenticated": authenticated, "canEdit": authenticated}) jsonWrite(w, http.StatusOK, map[string]any{"authenticated": authenticated, "canEdit": authenticated, "username": username})
} }
func (h *Handler) protected(w http.ResponseWriter, r *http.Request) { func (h *Handler) protected(w http.ResponseWriter, r *http.Request) {
if !h.sso.Authenticated(r) { if _, ok := h.identity(r); !ok {
jsonWrite(w, 401, map[string]string{"error": "authentication required"}) jsonWrite(w, http.StatusUnauthorized, map[string]string{"error": "authentication required"})
return return
} }
jsonWrite(w, 200, map[string]string{"status": "authenticated"}) jsonWrite(w, http.StatusOK, map[string]string{"status": "authenticated"})
} }
func (h *Handler) contentList(w http.ResponseWriter, r *http.Request) {
if h.content == nil { func (h *Handler) users(w http.ResponseWriter, r *http.Request) {
jsonWrite(w, 503, map[string]string{"error": "content unavailable"}) if h.demo == nil || r.Method != http.MethodGet {
http.NotFound(w, r)
return return
} }
jsonWrite(w, http.StatusOK, map[string]any{"items": h.demo.Users()})
}
func (h *Handler) sudo(w http.ResponseWriter, r *http.Request) {
if h.demo == nil || r.Method != http.MethodPost {
http.NotFound(w, r)
return
}
var input struct {
Username string `json:"username"`
Password string `json:"password"`
}
if !decode(r, &input) {
jsonWrite(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
token, user, err := h.demo.Login(input.Username, input.Password)
if err != nil {
jsonWrite(w, http.StatusUnauthorized, map[string]string{"error": "invalid demo credentials"})
return
}
jsonWrite(w, http.StatusOK, map[string]any{"token": token, "user": user})
}
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)
return return
@ -58,94 +100,128 @@ func (h *Handler) contentList(w http.ResponseWriter, r *http.Request) {
_, _ = fmt.Sscanf(r.URL.Query().Get("offset"), "%d", &offset) _, _ = fmt.Sscanf(r.URL.Query().Get("offset"), "%d", &offset)
badges := r.URL.Query()["badge"] badges := r.URL.Query()["badge"]
if len(badges) == 0 { if len(badges) == 0 {
badges = strings.Split(strings.TrimSpace(r.URL.Query().Get("badges")), ",") badges = strings.Split(r.URL.Query().Get("badges"), ",")
} }
filtered := badges[:0] items, err := h.content.List(r.Context(), limit, offset,
for _, badge := range badges { r.URL.Query().Get("category"),
if badge = strings.TrimSpace(strings.ToLower(badge)); badge != "" { r.URL.Query().Get("status"),
filtered = append(filtered, badge) badges,
}
}
items, e := h.content.List(r.Context(), limit, offset,
strings.TrimSpace(strings.ToLower(r.URL.Query().Get("category"))),
strings.TrimSpace(strings.ToLower(r.URL.Query().Get("status"))),
filtered,
) )
if e != nil { if err != nil {
jsonWrite(w, 500, map[string]string{"error": e.Error()}) jsonWrite(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return return
} }
jsonWrite(w, 200, 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) contentCategories(w http.ResponseWriter, r *http.Request) { func (h *Handler) contentCategories(w http.ResponseWriter, r *http.Request) {
if h.content == nil {
jsonWrite(w, 503, map[string]string{"error": "content unavailable"})
return
}
if r.Method != http.MethodGet { if r.Method != http.MethodGet {
jsonWrite(w, http.StatusMethodNotAllowed, nil) jsonWrite(w, http.StatusMethodNotAllowed, nil)
return return
} }
categories, err := h.content.ListCategories(r.Context()) categories, err := h.content.ListCategories(r.Context())
if err != nil { if err != nil {
jsonWrite(w, 500, map[string]string{"error": err.Error()}) jsonWrite(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return return
} }
jsonWrite(w, 200, map[string]any{"items": categories}) jsonWrite(w, http.StatusOK, map[string]any{"items": categories})
} }
func (h *Handler) contentBadges(w http.ResponseWriter, r *http.Request) { func (h *Handler) contentBadges(w http.ResponseWriter, r *http.Request) {
if h.content == nil {
jsonWrite(w, 503, map[string]string{"error": "content unavailable"})
return
}
if r.Method != http.MethodGet { if r.Method != http.MethodGet {
jsonWrite(w, http.StatusMethodNotAllowed, nil) jsonWrite(w, http.StatusMethodNotAllowed, nil)
return return
} }
badges, err := h.content.ListBadges(r.Context()) badges, err := h.content.ListBadges(r.Context())
if err != nil { if err != nil {
jsonWrite(w, 500, map[string]string{"error": err.Error()}) jsonWrite(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return return
} }
jsonWrite(w, 200, map[string]any{"items": badges}) jsonWrite(w, http.StatusOK, map[string]any{"items": badges})
} }
func (h *Handler) contentDetail(w http.ResponseWriter, r *http.Request) { func (h *Handler) contentDetail(w http.ResponseWriter, r *http.Request) {
if h.content == nil {
jsonWrite(w, 503, map[string]string{"error": "content unavailable"})
return
}
slug := strings.TrimPrefix(r.URL.Path, "/api/content/") slug := strings.TrimPrefix(r.URL.Path, "/api/content/")
if r.Method == http.MethodGet { switch r.Method {
c, e := h.content.Get(r.Context(), slug) case http.MethodGet:
if e != nil { item, err := h.content.Get(r.Context(), slug)
jsonWrite(w, 404, map[string]string{"error": "content not found"}) if err != nil {
jsonWrite(w, http.StatusNotFound, map[string]string{"error": "content not found"})
return return
} }
jsonWrite(w, 200, c) jsonWrite(w, http.StatusOK, item)
case http.MethodPut:
username, authenticated := h.identity(r)
if !authenticated {
jsonWrite(w, http.StatusUnauthorized, map[string]string{"error": "authentication required"})
return return
} }
if r.Method != http.MethodPut { var item service.Content
jsonWrite(w, 405, nil) if !decode(r, &item) {
jsonWrite(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return return
} }
if !h.sso.Authenticated(r) { item.Slug = slug
jsonWrite(w, 401, map[string]string{"error": "authentication required"}) var saved service.Content
var err error
if owned, ok := h.content.(interface {
UpdateAs(context.Context, service.Content, string) (service.Content, error)
}); ok {
saved, err = owned.UpdateAs(r.Context(), item, username)
} else {
saved, err = h.content.Update(r.Context(), item)
}
if err != nil {
jsonWrite(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return return
} }
var c domain.Content
if !decode(r, &c) { jsonWrite(w, http.StatusOK, saved)
jsonWrite(w, 400, map[string]string{"error": "invalid JSON"}) default:
return jsonWrite(w, http.StatusMethodNotAllowed, nil)
} }
c.Slug = slug
out, e := h.content.Update(r.Context(), c)
if e != nil {
jsonWrite(w, 400, map[string]string{"error": e.Error()})
return
}
jsonWrite(w, 200, out)
} }
func (h *Handler) identity(r *http.Request) (string, bool) {
if h.demo != nil {
token := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "))
if username, ok := h.demo.Authenticated(token); ok {
return username, true
}
}
if h.sso.Authenticated(r) {
return "sso-owner", true
}
return "", false
}
// singlePageApp serves the embedded build and falls back to index.html so the
// client-side router owns every non-API path.
func (h *Handler) singlePageApp(w http.ResponseWriter, r *http.Request) {
if h.assets == nil {
http.NotFound(w, r)
return
}
name := strings.TrimPrefix(r.URL.Path, "/")
if name != "" {
if file, err := h.assets.Open(name); err == nil {
_ = file.Close()
http.FileServer(http.FS(h.assets)).ServeHTTP(w, r)
return
}
}
index, err := fs.ReadFile(h.assets, "index.html")
if err != nil {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache")
_, _ = w.Write(index)
}
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 }
func jsonWrite(w http.ResponseWriter, status int, v any) { func jsonWrite(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status) w.WriteHeader(status)
@ -153,6 +229,7 @@ func jsonWrite(w http.ResponseWriter, status int, v any) {
_ = json.NewEncoder(w).Encode(v) _ = json.NewEncoder(w).Encode(v)
} }
} }
func cors(next http.Handler, origin, ssoHeader string) http.Handler { func cors(next http.Handler, origin, ssoHeader string) http.Handler {
allowed := "Content-Type, Authorization" allowed := "Content-Type, Authorization"
if ssoHeader != "" && !strings.EqualFold(ssoHeader, "Authorization") { if ssoHeader != "" && !strings.EqualFold(ssoHeader, "Authorization") {
@ -164,7 +241,7 @@ func cors(next http.Handler, origin, ssoHeader string) http.Handler {
w.Header().Set("Access-Control-Allow-Headers", allowed) w.Header().Set("Access-Control-Allow-Headers", allowed)
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, OPTIONS") w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, OPTIONS")
if r.Method == http.MethodOptions { if r.Method == http.MethodOptions {
w.WriteHeader(204) w.WriteHeader(http.StatusNoContent)
return return
} }
next.ServeHTTP(w, r) next.ServeHTTP(w, r)

View file

@ -6,28 +6,37 @@ import (
"net/http/httptest" "net/http/httptest"
"strings" "strings"
"testing" "testing"
"testing/fstest"
"github.com/example/cms/internal/domain"
"github.com/example/cms/internal/security" "github.com/example/cms/internal/security"
"github.com/example/cms/internal/service"
) )
type fakeContent struct{ updated bool } type fakeContent struct{ updated bool }
func (f *fakeContent) List(context.Context, int, int, string, string, []string) ([]domain.Content, error) { func (f *fakeContent) List(context.Context, int, int, string, string, []string) ([]service.Content, error) {
return nil, nil return []service.Content{{Slug: "post", Title: "Post"}}, nil
} }
func (f *fakeContent) ListCategories(context.Context) ([]domain.Category, error) { return nil, nil } func (f *fakeContent) ListCategories(context.Context) ([]service.Category, error) {
func (f *fakeContent) ListBadges(context.Context) ([]domain.Badge, error) { return nil, nil } return []service.Category{{Slug: "go", Name: "Go"}}, nil
func (f *fakeContent) Get(context.Context, string) (domain.Content, error) {
return domain.Content{Slug: "post", Title: "Post", Body: "body"}, nil
} }
func (f *fakeContent) Update(_ context.Context, c domain.Content) (domain.Content, error) { func (f *fakeContent) ListBadges(context.Context) ([]service.Badge, error) {
return []service.Badge{{Name: "go"}}, nil
}
func (f *fakeContent) Get(context.Context, string) (service.Content, error) {
return service.Content{Slug: "post", Title: "Post", Body: "body"}, nil
}
func (f *fakeContent) Update(_ context.Context, c service.Content) (service.Content, error) {
f.updated = true f.updated = true
return c, nil return c, nil
} }
func newTestRoutes(content *fakeContent) http.Handler { func newTestRoutes(content *fakeContent) http.Handler {
return NewHandler(content, security.NewSSOTokenVerifier("X-SSO-Token", "", "")).Routes("http://localhost:5173") assets := fstest.MapFS{
"index.html": &fstest.MapFile{Data: []byte("<!doctype html><div id=\"root\"></div>")},
"assets/index.js": &fstest.MapFile{Data: []byte("console.log('cms')")},
}
return NewHandler(content, assets, security.NewSSOTokenVerifier("X-SSO-Token", "", "")).Routes("http://localhost:5173")
} }
func TestAnonymousCanReadButNotEdit(t *testing.T) { func TestAnonymousCanReadButNotEdit(t *testing.T) {
@ -79,3 +88,19 @@ func TestProxyIdentityCanEdit(t *testing.T) {
t.Fatalf("owner session = %s", sessionRec.Body.String()) t.Fatalf("owner session = %s", sessionRec.Body.String())
} }
} }
func TestServesEmbeddedAppForClientRoutes(t *testing.T) {
routes := newTestRoutes(&fakeContent{})
asset := httptest.NewRecorder()
routes.ServeHTTP(asset, httptest.NewRequest(http.MethodGet, "/assets/index.js", nil))
if asset.Code != http.StatusOK || !strings.Contains(asset.Body.String(), "console.log") {
t.Fatalf("static asset status = %d body = %q", asset.Code, asset.Body.String())
}
page := httptest.NewRecorder()
routes.ServeHTTP(page, httptest.NewRequest(http.MethodGet, "/content/some-post", nil))
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())
}
}

View file

@ -1,581 +0,0 @@
-- Seed data for the PostgreSQL content table.
-- Safe to run repeatedly: rows are upserted by slug.
--
-- Run with:
-- psql "$POSTGRES_URL" -f scripts/seed_content.sql
INSERT INTO categories (slug, name) VALUES
('linux', 'Linux'),
('bash', 'Bash & Shell'),
('go', 'Go'),
('java', 'Java'),
('devops', 'DevOps')
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name;
INSERT INTO content (
slug, title, summary, body, status, published, created_at, updated_at
) VALUES
(
'welcome-to-the-cms',
'Welcome to the CMS',
'A short introduction to the editorial workspace and the ideas behind this publication.',
$$Welcome to the CMS journal.
This space is where the team shares product updates, practical guides, and notes from the work behind the scenes. Each story is written to be useful on its own, while also giving readers a clearer picture of how the product is built.
Start with the latest articles, save the pieces that are useful to your team, and send us feedback when something could be clearer.$$,
'published', TRUE, CURRENT_TIMESTAMP - INTERVAL '24 days', CURRENT_TIMESTAMP - INTERVAL '20 days'
),
(
'designing-for-clarity',
'Designing for Clarity',
'How a small set of deliberate design decisions can make a complex workflow feel calm and understandable.',
$$Clarity is not the same as simplicity. A good interface can expose a powerful workflow without asking people to understand every implementation detail.
We begin by naming the user''s goal, then remove everything that competes with it. Labels should describe outcomes, actions should have predictable results, and important feedback should appear close to the action that caused it.
The best design review question is often simple: what would a first-time user expect to happen next?$$,
'published', TRUE, CURRENT_TIMESTAMP - INTERVAL '23 days', CURRENT_TIMESTAMP - INTERVAL '18 days'
),
(
'a-practical-guide-to-editorial-workflows',
'A Practical Guide to Editorial Workflows',
'A repeatable process for taking an idea from a rough note to a polished, published story.',
$$A dependable editorial workflow gives ideas room to develop without allowing unfinished work to slip into production.
Our process has four stages: capture, shape, review, and publish. Capture is intentionally lightweight. Shape gives the draft a clear audience and purpose. Review checks accuracy, tone, and accessibility. Publish is the final technical and editorial check.
The workflow works because every stage has a clear owner and a clear definition of done.$$,
'published', TRUE, CURRENT_TIMESTAMP - INTERVAL '22 days', CURRENT_TIMESTAMP - INTERVAL '16 days'
),
(
'building-a-better-content-calendar',
'Building a Better Content Calendar',
'A lightweight planning method that balances consistency with enough flexibility for timely ideas.',
$$A content calendar should create confidence, not bureaucracy.
We plan around themes rather than filling every date in advance. Each month has a primary theme, two supporting topics, and a small reserve for timely announcements. This gives the team a reliable rhythm while leaving space for unexpected opportunities.
The calendar is most useful when it shows the reason for each piece, not only its due date.$$,
'published', TRUE, CURRENT_TIMESTAMP - INTERVAL '21 days', CURRENT_TIMESTAMP - INTERVAL '15 days'
),
(
'the-case-for-small-releases',
'The Case for Small Releases',
'Why shipping focused improvements regularly creates better feedback and lower operational risk.',
$$Small releases make learning cheaper.
When a change has a narrow scope, it is easier to test, explain, monitor, and roll back. The team gets useful feedback while the context is still fresh, and users see a steady stream of improvements rather than waiting for a single large launch.
Small does not mean insignificant. A series of focused changes can transform an experience when each one solves a real problem.$$,
'published', TRUE, CURRENT_TIMESTAMP - INTERVAL '20 days', CURRENT_TIMESTAMP - INTERVAL '14 days'
),
(
'how-we-write-useful-documentation',
'How We Write Useful Documentation',
'Documentation is most valuable when it helps someone complete a task at the moment they need it.',
$$Useful documentation starts with the reader''s question, not the system''s architecture.
We lead with the expected result, show the smallest complete example, and explain the assumptions that could otherwise cause confusion. Details belong after the main path, where they can help without blocking progress.
Documentation is part of the product. When it is accurate, direct, and easy to scan, it reduces support work and helps users become independent faster.$$,
'published', TRUE, CURRENT_TIMESTAMP - INTERVAL '19 days', CURRENT_TIMESTAMP - INTERVAL '13 days'
),
(
'accessibility-is-a-quality-practice',
'Accessibility Is a Quality Practice',
'Accessibility checks improve the experience for everyone, not only for users who identify with a specific access need.',
$$Accessibility is a practical way to test whether an interface communicates clearly.
Keyboard navigation reveals hidden interaction assumptions. Good contrast helps in bright rooms and on small screens. Descriptive labels help screen-reader users and also make automated tests more meaningful.
Treating accessibility as a quality practice means including it in design, implementation, and review instead of leaving it for the final checklist.$$,
'published', TRUE, CURRENT_TIMESTAMP - INTERVAL '18 days', CURRENT_TIMESTAMP - INTERVAL '12 days'
),
(
'measuring-what-matters',
'Measuring What Matters',
'A guide to choosing a small set of product signals that lead to better decisions.',
$$Metrics are useful when they change what the team does.
We prefer a small collection of signals with clear owners: successful task completion, time to value, reliable publishing, and meaningful engagement. Every metric should have a question behind it and a decision that it can inform.
A dashboard full of numbers is not automatically useful. The goal is shared understanding, not decorative precision.$$,
'published', TRUE, CURRENT_TIMESTAMP - INTERVAL '17 days', CURRENT_TIMESTAMP - INTERVAL '11 days'
),
(
'notes-from-a-content-migration',
'Notes from a Content Migration',
'Lessons learned while moving a growing library into a structured content model.',
$$A migration is an opportunity to clarify the shape of the content, not only to move records from one place to another.
We started by identifying the fields that readers actually depend on. Then we separated durable content from presentation details and created a small set of validation rules. The result was less data than we expected, but much more confidence in what remained.
The most important migration tool was a clear decision log. It made trade-offs visible and prevented the team from reopening settled questions.$$,
'published', TRUE, CURRENT_TIMESTAMP - INTERVAL '16 days', CURRENT_TIMESTAMP - INTERVAL '10 days'
),
(
'making-search-feel-natural',
'Making Search Feel Natural',
'Search works best when it understands the words people use, not only the labels in a database.',
$$People search with questions, fragments, and remembered phrases. They rarely use the exact vocabulary that an internal system uses.
Useful search begins with good titles and summaries, then adds ranking that rewards relevance without hiding recent work. Empty results should offer a next step, and near matches should be understandable rather than mysterious.
Search is a conversation with the content library. Every result teaches users what the system considers important.$$,
'published', TRUE, CURRENT_TIMESTAMP - INTERVAL '15 days', CURRENT_TIMESTAMP - INTERVAL '9 days'
),
(
'a-field-guide-to-content-reviews',
'A Field Guide to Content Reviews',
'A concise review checklist for accuracy, usefulness, voice, and technical readiness.',
$$A strong review is more than proofreading.
First confirm that the piece answers a real reader question. Then verify names, dates, links, examples, and claims. Check that the heading structure is logical and that the summary makes sense outside the article. Finally, read it once as a person who knows nothing about the project.
Reviewers should improve the work while protecting the author''s intent. Specific suggestions are more helpful than broad requests to make something better.$$,
'published', TRUE, CURRENT_TIMESTAMP - INTERVAL '14 days', CURRENT_TIMESTAMP - INTERVAL '8 days'
),
(
'reliability-behind-the-scenes',
'Reliability Behind the Scenes',
'The habits and safeguards that help a small team operate dependable services.',
$$Reliability is built from ordinary habits practiced consistently.
We keep operational changes small, make failures visible, and write down the recovery path before an incident happens. Health checks tell us whether a service is alive; useful logs help us understand whether it is doing the right work.
The goal is not to eliminate every failure. It is to make failures understandable, contained, and recoverable.$$,
'published', TRUE, CURRENT_TIMESTAMP - INTERVAL '13 days', CURRENT_TIMESTAMP - INTERVAL '7 days'
),
(
'the-anatomy-of-a-good-release-note',
'The Anatomy of a Good Release Note',
'Release notes should help readers understand what changed and why it matters to them.',
$$A good release note answers three questions quickly: what changed, who benefits, and what should the reader do now?
We avoid internal ticket numbers and implementation language unless they clarify the result. A short example is often more useful than a list of changed files. When a change requires action, the action should be explicit and easy to find.
Release notes are part of the product''s memory. Written well, they help current users and future teammates understand how the experience evolved.$$,
'published', TRUE, CURRENT_TIMESTAMP - INTERVAL '12 days', CURRENT_TIMESTAMP - INTERVAL '6 days'
),
(
'working-with-feedback',
'Working with Feedback',
'A practical approach to turning scattered comments into decisions and improvements.',
$$Feedback is raw material, not a verdict.
We group comments by the problem they describe, look for repeated evidence, and separate requests from the underlying need. Some feedback should lead to an immediate fix; some should become a research question; some should be acknowledged and intentionally deferred.
The most useful response to feedback is a visible decision. People do not need every request to be accepted, but they do need to know that it was understood.$$,
'published', TRUE, CURRENT_TIMESTAMP - INTERVAL '11 days', CURRENT_TIMESTAMP - INTERVAL '5 days'
),
(
'the-value-of-a-calm-admin-interface',
'The Value of a Calm Admin Interface',
'Administrative tools should reduce cognitive load so people can focus on making good decisions.',
$$An admin interface is often used under time pressure, which makes calm design especially important.
Clear status labels, predictable controls, and safe defaults help editors move quickly without guessing. Destructive actions need friction, while routine publishing actions should feel direct and reversible.
Good administration is invisible when it works. The interface gives people confidence without demanding their attention.$$,
'published', TRUE, CURRENT_TIMESTAMP - INTERVAL '10 days', CURRENT_TIMESTAMP - INTERVAL '4 days'
),
(
'when-to-use-a-draft',
'When to Use a Draft',
'Drafts are a tool for thinking in public without publishing unfinished decisions.',
$$A draft is useful whenever the content or the decision still needs context.
Use a draft to collect research, test an outline, ask for review, or preserve an idea that is not ready for an audience. A draft should still have a clear next step; otherwise it becomes a storage drawer.
The best draft is easy for another person to pick up. Give it a useful title, explain what is unresolved, and note what kind of feedback would help.$$,
'published', TRUE, CURRENT_TIMESTAMP - INTERVAL '9 days', CURRENT_TIMESTAMP - INTERVAL '3 days'
),
(
'our-approach-to-technical-debt',
'Our Approach to Technical Debt',
'Technical debt is easier to manage when it is connected to a user impact or an operational risk.',
$$Not every shortcut needs immediate repayment. The important question is whether the shortcut is making future work slower, riskier, or harder to understand.
We record debt near the code or workflow it affects, describe the cost in concrete terms, and revisit it when related work is already planned. This keeps maintenance connected to product decisions rather than turning it into an abstract list.
Small, regular payments are usually healthier than a dramatic cleanup project.$$,
'published', TRUE, CURRENT_TIMESTAMP - INTERVAL '8 days', CURRENT_TIMESTAMP - INTERVAL '2 days'
),
(
'a-better-way-to-plan-content',
'A Better Way to Plan Content',
'Planning becomes easier when every proposed story has a reader, a purpose, and a natural next step.',
$$Content planning is not a contest to produce the most ideas.
For each idea, write down who it helps, what question it answers, and what the reader can do afterward. This makes promising ideas easier to prioritize and exposes ideas that are interesting but not yet useful.
The strongest plans leave room for discovery. A clear purpose is more valuable than a rigid publishing schedule.$$,
'review', FALSE, CURRENT_TIMESTAMP - INTERVAL '4 days', CURRENT_TIMESTAMP - INTERVAL '1 day'
),
(
'meet-the-editorial-team',
'Meet the Editorial Team',
'A behind-the-scenes look at the people who shape the CMS journal.',
$$The editorial team brings together product, design, engineering, and customer perspectives.
That mix helps us ask better questions. Writers bring clarity, subject experts bring depth, and reviewers make sure the result is accurate and useful. The team is intentionally small so that conversations stay close to the work.
In the coming weeks we will share more of the practices that help this group collaborate.$$,
'draft', FALSE, CURRENT_TIMESTAMP - INTERVAL '3 days', CURRENT_TIMESTAMP - INTERVAL '12 hours'
),
(
'what-we-learned-from-our-first-workshop',
'What We Learned from Our First Workshop',
'A reflection on the exercises that helped a mixed team agree on the most important user problems.',
$$Our first workshop was designed to create alignment without pretending that every question had an immediate answer.
The most productive exercise was mapping the moments where users hesitate. Those moments gave the team a shared language for discussing confusing workflows and showed where a small improvement could have an outsized effect.
The next step is to turn those observations into experiments that can be tested with real users.$$,
'draft', FALSE, CURRENT_TIMESTAMP - INTERVAL '2 days', CURRENT_TIMESTAMP - INTERVAL '8 hours'
),
(
'a-small-experiment-in-onboarding',
'A Small Experiment in Onboarding',
'We are testing a shorter first-run experience focused on one successful outcome.',
$$This is an early experiment, not a final design.
The current version asks new users to complete one meaningful task before introducing secondary features. We expect this to make the first session more focused and give us a clearer signal about where people need help.
We will compare completion rates and qualitative feedback before deciding whether to keep the change.$$,
'review', FALSE, CURRENT_TIMESTAMP - INTERVAL '1 day', CURRENT_TIMESTAMP - INTERVAL '4 hours'
),
(
'questions-we-are-exploring',
'Questions We Are Exploring',
'A living list of questions that will guide upcoming product and editorial work.',
$$We are exploring how teams decide which content deserves an update, how readers move from one useful story to the next, and how editors can collaborate without losing ownership.
These questions are intentionally open. They will become more specific as we talk to users, observe workflows, and test small improvements.
If your team has a different perspective, we would like to hear what you have learned.$$,
'draft', FALSE, CURRENT_TIMESTAMP - INTERVAL '18 hours', CURRENT_TIMESTAMP - INTERVAL '2 hours'
),
(
'content-modeling-principles',
'Content Modeling Principles',
'The principles we use to keep content structured, portable, and easy to evolve.',
$$A content model should reflect the meaning of content before it reflects a particular screen.
We prefer explicit fields over hidden conventions, stable identifiers over presentation-dependent names, and validation that explains what is wrong. A model should support today''s workflows while leaving room for tomorrow''s channels.
The best model is not the most abstract one. It is the one that helps people create and maintain useful content with confidence.$$,
'published', TRUE, CURRENT_TIMESTAMP - INTERVAL '7 days', CURRENT_TIMESTAMP - INTERVAL '1 day'
),
(
'why-plain-language-wins',
'Why Plain Language Wins',
'Clear writing helps people make decisions faster and makes products feel more trustworthy.',
$$Plain language is not about removing nuance. It is about putting the nuance where readers can use it.
Use familiar words, prefer active sentences, and explain specialized terms when they matter. Short paragraphs make ideas easier to navigate, while concrete examples help readers connect advice to their own work.
When writing is clear, readers spend their energy on the decision instead of decoding the sentence.$$,
'published', TRUE, CURRENT_TIMESTAMP - INTERVAL '6 days', CURRENT_TIMESTAMP - INTERVAL '20 hours'
),
(
'a-note-on-data-retention',
'A Note on Data Retention',
'How we think about keeping only the data that supports a clear product or operational purpose.',
$$Data should have a reason to exist and a responsible owner.
We review retention from the perspective of usefulness, privacy, and operational cost. When data no longer serves a purpose, deleting it is often safer than preserving it just in case.
Good retention practices are part of good product design because they make expectations clearer for everyone involved.$$,
'published', TRUE, CURRENT_TIMESTAMP - INTERVAL '5 days', CURRENT_TIMESTAMP - INTERVAL '16 hours'
),
(
'the-next-quarter',
'Looking Ahead to the Next Quarter',
'The themes that will shape our next set of product, platform, and editorial improvements.',
$$The next quarter is about making the core experience more dependable and more useful.
We will focus on faster editorial workflows, clearer content discovery, stronger operational feedback, and a more consistent foundation for future features. We will measure progress by the quality of completed work, not by the number of initiatives started.
There is a lot to do, so focus will matter as much as ambition.$$,
'published', TRUE, CURRENT_TIMESTAMP - INTERVAL '4 days', CURRENT_TIMESTAMP - INTERVAL '8 hours'
),
(
'community-notes-june',
'Community Notes: June',
'A monthly collection of questions, ideas, and observations shared by the CMS community.',
$$This month the community asked for better preview tools, clearer publishing status, and more ways to reuse content across channels.
Several teams also shared small workflow improvements that they built locally. Those examples remind us that the best ideas often begin as a practical response to a real constraint.
We are collecting these notes to make useful patterns easier to find and discuss.$$,
'published', TRUE, CURRENT_TIMESTAMP - INTERVAL '3 days', CURRENT_TIMESTAMP - INTERVAL '6 hours'
),
(
'release-0-3-preview',
'Release 0.3 Preview',
'A preview of the next CMS iteration, including improved content editing and migration tooling.',
$$The next release will make content editing more approachable and the local development workflow more predictable.
Planned improvements include clearer editor permissions, stronger migration checks, better empty states, and a more useful content preview. These changes are still being tested and may change before release.
We will publish final notes when the release is ready for general use.$$,
'review', FALSE, CURRENT_TIMESTAMP - INTERVAL '12 hours', CURRENT_TIMESTAMP - INTERVAL '1 hour'
)
ON CONFLICT (slug) DO UPDATE SET
title = EXCLUDED.title,
summary = EXCLUDED.summary,
body = EXCLUDED.body,
status = EXCLUDED.status,
published = EXCLUDED.published,
updated_at = EXCLUDED.updated_at;
UPDATE content SET category_id = categories.id
FROM categories
WHERE categories.slug = CASE
WHEN content.slug IN ('designing-for-clarity', 'accessibility-is-a-quality-practice', 'the-value-of-a-calm-admin-interface') THEN 'design'
WHEN content.slug IN ('the-case-for-small-releases', 'measuring-what-matters', 'making-search-feel-natural', 'a-small-experiment-in-onboarding', 'the-next-quarter') THEN 'product'
WHEN content.slug IN ('a-practical-guide-to-editorial-workflows', 'building-a-better-content-calendar', 'how-we-write-useful-documentation', 'a-field-guide-to-content-reviews', 'when-to-use-a-draft', 'a-better-way-to-plan-content', 'why-plain-language-wins') THEN 'editorial'
WHEN content.slug IN ('meet-the-editorial-team', 'working-with-feedback', 'community-notes-june', 'what-we-learned-from-our-first-workshop', 'questions-we-are-exploring') THEN 'community'
ELSE 'engineering'
END;
INSERT INTO badges (name) VALUES
('go'), ('postgresql'), ('docker'), ('react'), ('typescript'),
('java'), ('spring-boot'), ('maven'), ('accessibility'),
('content-strategy'), ('product-management'), ('documentation'),
('reliability'), ('search'), ('community')
ON CONFLICT (name) DO NOTHING;
INSERT INTO content_badges (content_id, badge_id)
SELECT c.id, b.id
FROM (VALUES
('welcome-to-the-cms', 'content-strategy'),
('welcome-to-the-cms', 'community'),
('designing-for-clarity', 'accessibility'),
('designing-for-clarity', 'content-strategy'),
('a-practical-guide-to-editorial-workflows', 'content-strategy'),
('building-a-better-content-calendar', 'content-strategy'),
('the-case-for-small-releases', 'product-management'),
('how-we-write-useful-documentation', 'documentation'),
('accessibility-is-a-quality-practice', 'accessibility'),
('measuring-what-matters', 'product-management'),
('notes-from-a-content-migration', 'postgresql'),
('notes-from-a-content-migration', 'docker'),
('making-search-feel-natural', 'search'),
('a-field-guide-to-content-reviews', 'documentation'),
('reliability-behind-the-scenes', 'go'),
('reliability-behind-the-scenes', 'docker'),
('the-anatomy-of-a-good-release-note', 'documentation'),
('working-with-feedback', 'community'),
('the-value-of-a-calm-admin-interface', 'design'),
('when-to-use-a-draft', 'content-strategy'),
('our-approach-to-technical-debt', 'go'),
('our-approach-to-technical-debt', 'postgresql'),
('a-better-way-to-plan-content', 'content-strategy'),
('meet-the-editorial-team', 'community'),
('what-we-learned-from-our-first-workshop', 'community'),
('a-small-experiment-in-onboarding', 'react'),
('a-small-experiment-in-onboarding', 'typescript'),
('questions-we-are-exploring', 'product-management'),
('content-modeling-principles', 'postgresql'),
('content-modeling-principles', 'content-strategy'),
('why-plain-language-wins', 'documentation'),
('a-note-on-data-retention', 'postgresql'),
('the-next-quarter', 'product-management'),
('community-notes-june', 'community'),
('release-0-3-preview', 'react'),
('release-0-3-preview', 'go')
) AS seed(slug, badge)
JOIN content c ON c.slug = seed.slug
JOIN badges b ON b.name = seed.badge
ON CONFLICT DO NOTHING;
-- Re-theme the repeatable examples around Linux, shell tooling, Go, Java, and DevOps.
INSERT INTO badges (name) VALUES
('linux'), ('bash'), ('shell'), ('terminal'), ('systemd'), ('grep'),
('awk'), ('sed'), ('ssh'), ('git'), ('journalctl'), ('go'), ('java'), ('spring-boot'),
('maven'), ('postgresql'), ('docker'), ('kubernetes'), ('devops')
ON CONFLICT (name) DO NOTHING;
UPDATE content AS c
SET title = seed.title, summary = seed.summary, body = seed.body
FROM (VALUES
('welcome-to-the-cms', 'Welcome to Urpi''s backlog', 'A terminal-shaped notebook for Linux, Bash, Go, Java, and useful experiments.', $$Welcome to Urpi''s backlog.
This is a working notebook for the tools that make a developer''s day better: a clean Linux shell, a small Bash script, a reliable Go service, and a Java application that is easier to operate.
The best notes are practical. They show the command, explain the why, and leave enough context for future-you to use them again.$$),
('designing-for-clarity', 'Designing a Calm Terminal Workflow', 'Small conventions that make command-line work easier to read, repeat, and recover.', $$A good terminal workflow is easy to scan at a glance.
Use clear prompts, predictable directories, short commands, and output that explains what happened. Keep destructive operations explicit and make the safe path the easy path.
The shell is not only a place to execute commands. It is an interface worth designing.$$),
('a-practical-guide-to-editorial-workflows', 'A Practical Bash Script Workflow', 'How to write shell scripts that are safe to run, easy to debug, and friendly to the next maintainer.', $$A useful Bash script starts with strict mode, meaningful names, and a clear exit path.
Use set -euo pipefail when appropriate, quote variables, validate inputs, and print progress around operations that may take time. Prefer small functions over one long pipeline when the script is important enough to maintain.
Scripts become tools when another person can understand them without reverse engineering the author''s terminal session.$$),
('building-a-better-content-calendar', 'Building a Personal Linux Command Log', 'A simple way to turn repeated terminal discoveries into searchable notes.', $$Whenever a command solves a problem, write down the context with it.
Record the operating system, the input, the useful output, and the reason the command worked. A note such as journalctl -u service --since today is much more valuable when it says which service was failing and what the logs revealed.
Over time, the command log becomes a small, personal operations manual.$$),
('the-case-for-small-releases', 'The Case for Small CLI Releases', 'Why focused command-line improvements are easier to test, ship, and roll back.', $$A small release is easier to inspect with git diff, test from a clean shell, and undo when it does not behave as expected.
This is especially useful for developer tools. A narrow change to a flag or output format gives users a clear upgrade path and gives maintainers a focused set of failure modes.
Small releases keep the feedback loop close to the command that changed.$$),
('how-we-write-useful-documentation', 'How to Document a Bash Command', 'A command is not documented until someone knows what it changes and how to undo it.', $$Good shell documentation shows a complete example, explains expected output, and calls out permissions or paths that matter.
Always distinguish a read-only command from one that modifies files or services. Include a dry-run option when possible and show the recovery command beside the change.
The goal is not more prose. It is a safer next terminal session.$$),
('accessibility-is-a-quality-practice', 'Readable Output Is a Quality Practice', 'Terminal output should work in bright rooms, small windows, logs, and automated checks.', $$Readable output uses stable labels, useful exit codes, and enough spacing to separate one result from the next.
Do not rely on color alone. A warning should still be clear when output is redirected to a file or viewed through SSH. Good command-line output helps both humans and scripts.
Accessibility is another word for making the right information available in the conditions people actually work in.$$),
('measuring-what-matters', 'Measuring a Service from the Shell', 'A few dependable commands can tell you more than a noisy dashboard.', $$Start with the signals that answer an operational question: is the process running, is the port listening, are requests succeeding, and is the disk filling up?
Commands such as systemctl status, ss, curl, df, and journalctl are powerful because they connect directly to the machine''s state.
Measure what helps you choose the next command, not what merely looks impressive.$$),
('notes-from-a-content-migration', 'Notes from a PostgreSQL Migration', 'Lessons from moving local content data into PostgreSQL with repeatable migrations.', $$A database migration should be boring to run and easy to inspect.
Keep schema changes in versioned SQL, make seed data idempotent, and verify the result with psql before the application starts depending on it. A migration that works only on one laptop is not finished.
The terminal is an excellent place to make database state visible.$$),
('making-search-feel-natural', 'Searching Code with grep, rg, and git', 'A practical tour of fast text searches for a busy Linux repository.', $$Start broad with rg, narrow by file type, and include line numbers when the result will become a follow-up task.
git grep is useful when you want the tracked view of a repository. grep -R remains available almost everywhere, which makes it a dependable fallback on minimal systems.
The best search command shortens the distance between a question and the file that answers it.$$),
('a-field-guide-to-content-reviews', 'A Field Guide to Shell Script Reviews', 'A compact checklist for reviewing Bash before it reaches production.', $$Check quoting, glob behavior, unset variables, exit codes, temporary files, permissions, and cleanup paths.
Then read the script as a sequence of commands run by a new shell on a machine with a different environment. Reviewers should ask what happens when the network is unavailable, a file is missing, or a command returns no rows.
The best review catches an unsafe assumption before it becomes an incident.$$),
('reliability-behind-the-scenes', 'Reliability with systemd and journalctl', 'A practical pairing for running and diagnosing Linux services.', $$systemd describes how a service should run; journalctl shows what it actually did.
Use systemctl status for a quick state check, systemctl cat to inspect the loaded unit, and journalctl -u service --since today to follow the evidence. Keep restart policies intentional and make health checks observable.
Reliable services leave a useful trail when something goes wrong.$$),
('the-anatomy-of-a-good-release-note', 'The Anatomy of a Good CLI Release', 'Release notes for developer tools should show the changed command and its effect.', $$A useful CLI release note includes the old behavior, the new command, and one realistic terminal example.
Mention changed defaults, removed flags, migration steps, and compatibility concerns. Users should be able to decide whether to upgrade without searching through implementation details.
Good release notes make a tool feel stable even as it evolves.$$),
('working-with-feedback', 'Working with Terminal Feedback', 'How command output, bug reports, and shell history reveal what a tool should improve.', $$A failed command is feedback with context attached.
Capture the exact command, environment, exit status, and the smallest useful piece of output. Repeated confusion around the same flag is often a documentation problem; repeated slow commands may be a design problem.
Treat reports as clues about the workflow, not only as isolated defects.$$),
('the-value-of-a-calm-admin-interface', 'The Value of a Calm Operations Console', 'A good operations screen should feel like a well-organized terminal session.', $$Operations work needs clear status, safe defaults, and an obvious path back from a mistake.
Show what is running, what changed, and which command or action will happen next. Destructive operations should make scope visible before they ask for confirmation.
The calmest interface is the one that helps a tired operator make the right decision.$$),
('when-to-use-a-draft', 'When to Keep a Shell Note as a Draft', 'Some commands need a little more testing before they become part of the permanent runbook.', $$Keep a command note in draft while paths, permissions, and failure behavior are still uncertain.
Run it from a clean directory, test the negative path, and write down the rollback before calling it ready. A draft is useful when it preserves the question as well as the current answer.$$),
('our-approach-to-technical-debt', 'Our Approach to Shell and Build Debt', 'Technical debt in scripts and build files compounds quickly when nobody owns the cleanup.', $$A clever one-liner can become expensive when it is copied into five deployment scripts.
Prefer named functions, stable interfaces, and explicit dependencies. In Go and Java projects, keep build commands discoverable through Make, Maven, or a documented task runner.
Pay down debt when you are already working near the command that created it.$$),
('a-better-way-to-plan-content', 'A Better Way to Plan a Linux Lab', 'Plan experiments around one command, one hypothesis, and one observable result.', $$A useful Linux lab has a question such as “what happens when this unit restarts?” and a small set of commands that can answer it.
Write the expected result before running the experiment. Save the actual output, explain the difference, and turn the conclusion into a reusable note.
Focused experiments produce better operational knowledge than a long list of disconnected commands.$$),
('meet-the-editorial-team', 'Meet the Tools in the Terminal', 'The daily toolkit: Bash for glue, Go for services, Java for platforms, and Linux underneath.', $$Different tools earn their place by solving different problems.
Bash connects existing commands. Go makes small services and utilities easy to ship. Java and Spring Boot support long-lived applications with strong conventions. Linux gives all of them a dependable home.
The interesting work happens at the boundaries between those tools.$$),
('what-we-learned-from-our-first-workshop', 'What We Learned from a Shell Workshop', 'A few exercises that helped developers become more confident at the command line.', $$The most useful workshop exercise was troubleshooting a broken service from symptoms alone.
Participants used systemctl, journalctl, ss, curl, and grep to form a hypothesis, test it, and explain the fix. The lesson was less about memorizing commands and more about building a reliable investigation loop.
Confidence grows when every command has a question behind it.$$),
('a-small-experiment-in-onboarding', 'A Small Experiment with Bash Completion', 'We are testing whether better completion makes unfamiliar commands easier to discover.', $$Completion can turn a blank prompt into a gentle guide.
The experiment adds examples for common flags, paths, and service names while keeping the underlying command unchanged. We will compare successful first attempts and the number of help-page lookups.
Good completion should teach without getting in the way.$$),
('questions-we-are-exploring', 'Questions We Are Exploring', 'Open questions about Linux workflows, build tools, and the boundary between scripts and software.', $$When should a Bash script become a Go command? When does a Maven plugin deserve a dedicated build step? Which operational knowledge belongs in a runbook and which belongs in automation?
These questions are intentionally open. Experiments in the terminal will give us better answers than abstract rules.$$),
('content-modeling-principles', 'Content Modeling for Runbooks', 'A runbook needs commands, assumptions, expected output, and recovery steps—not just a paragraph of advice.', $$A useful runbook entry describes the situation, the command to run, the expected evidence, and the safe recovery path.
Keep environment-specific values visible, separate read-only checks from changes, and include links to the service or migration that the command supports.
Structured operational notes are easier to search when the next incident arrives.$$),
('why-plain-language-wins', 'Why Plain Language Wins in Man Pages', 'Clear command descriptions help people move from a prompt to a correct action.', $$A man page should tell readers what a command does before listing every option.
Use examples that resemble real work, explain dangerous flags near the command that uses them, and avoid hiding the important behavior behind jargon.
Plain language is a reliability feature for people working under pressure.$$),
('a-note-on-data-retention', 'A Note on Logs and Data Retention', 'Keep logs long enough to investigate, but not so long that useful evidence disappears in noise.', $$Log retention is an operational decision.
Choose a period that supports debugging and compliance, rotate files predictably, and make sure disk usage is observable. journalctl and logrotate are only useful when their limits are understood.
The right amount of history is the amount that helps answer the next incident question.$$),
('the-next-quarter', 'Looking Ahead from the Terminal', 'The next stretch of work focuses on sharper Linux workflows, dependable Go services, and practical Java notes.', $$The backlog ahead includes systemd troubleshooting, Bash patterns, Go HTTP services, Spring Boot operations, Maven build hygiene, and better PostgreSQL tooling.
The goal is not to collect technologies. It is to understand the small commands and decisions that make them pleasant to use.$$),
('community-notes-june', 'Community Notes from the Linux Shell', 'A collection of commands and habits shared by people who spend their days in terminals.', $$This month''s notes include using ssh config aliases, pairing find with xargs carefully, checking ports with ss, and using git worktree for parallel fixes.
The common thread is respect for the next person at the prompt: make the command visible, explain the edge case, and leave the machine in a known state.$$),
('release-0-3-preview', 'Release 0.3 Preview: More Terminal, Less Guessing', 'A preview of improvements for Linux-first development workflows.', $$The next release will add clearer command output, safer local migrations, better Go service diagnostics, and more useful notes for Java and Spring Boot projects.
These changes are still being tested. The target is simple: fewer guesses between opening a terminal and understanding what the application is doing.$$)
) AS seed(slug, title, summary, body) WHERE c.slug = seed.slug;
UPDATE content SET category_id = categories.id
FROM categories
WHERE categories.slug = CASE
WHEN content.slug IN ('a-practical-guide-to-editorial-workflows', 'building-a-better-content-calendar', 'how-we-write-useful-documentation', 'accessibility-is-a-quality-practice', 'making-search-feel-natural', 'a-field-guide-to-content-reviews', 'when-to-use-a-draft', 'a-better-way-to-plan-content', 'why-plain-language-wins', 'welcome-to-the-cms') THEN 'bash'
WHEN content.slug IN ('the-case-for-small-releases', 'measuring-what-matters', 'reliability-behind-the-scenes', 'our-approach-to-technical-debt', 'meet-the-editorial-team', 'what-we-learned-from-our-first-workshop') THEN 'go'
WHEN content.slug IN ('a-small-experiment-in-onboarding', 'questions-we-are-exploring') THEN 'java'
WHEN content.slug IN ('notes-from-a-content-migration', 'the-anatomy-of-a-good-release-note', 'working-with-feedback', 'the-value-of-a-calm-admin-interface', 'content-modeling-principles', 'a-note-on-data-retention', 'the-next-quarter', 'community-notes-june', 'release-0-3-preview') THEN 'devops'
ELSE 'linux'
END;
DELETE FROM content_badges;
INSERT INTO content_badges (content_id, badge_id)
SELECT c.id, b.id
FROM (VALUES
('welcome-to-the-cms', 'linux'), ('welcome-to-the-cms', 'terminal'), ('welcome-to-the-cms', 'bash'),
('designing-for-clarity', 'terminal'), ('designing-for-clarity', 'shell'),
('a-practical-guide-to-editorial-workflows', 'bash'), ('a-practical-guide-to-editorial-workflows', 'shell'),
('building-a-better-content-calendar', 'linux'), ('building-a-better-content-calendar', 'grep'),
('the-case-for-small-releases', 'go'), ('the-case-for-small-releases', 'git'),
('how-we-write-useful-documentation', 'bash'), ('how-we-write-useful-documentation', 'terminal'),
('accessibility-is-a-quality-practice', 'linux'), ('accessibility-is-a-quality-practice', 'shell'),
('measuring-what-matters', 'linux'), ('measuring-what-matters', 'systemd'),
('notes-from-a-content-migration', 'postgresql'), ('notes-from-a-content-migration', 'docker'),
('making-search-feel-natural', 'grep'), ('making-search-feel-natural', 'awk'),
('a-field-guide-to-content-reviews', 'bash'), ('a-field-guide-to-content-reviews', 'shell'),
('reliability-behind-the-scenes', 'systemd'), ('reliability-behind-the-scenes', 'linux'),
('the-anatomy-of-a-good-release-note', 'go'), ('the-anatomy-of-a-good-release-note', 'git'),
('working-with-feedback', 'terminal'), ('working-with-feedback', 'ssh'),
('the-value-of-a-calm-admin-interface', 'devops'), ('the-value-of-a-calm-admin-interface', 'linux'),
('when-to-use-a-draft', 'bash'), ('when-to-use-a-draft', 'git'),
('our-approach-to-technical-debt', 'go'), ('our-approach-to-technical-debt', 'java'),
('a-better-way-to-plan-content', 'linux'), ('a-better-way-to-plan-content', 'terminal'),
('meet-the-editorial-team', 'bash'), ('meet-the-editorial-team', 'go'), ('meet-the-editorial-team', 'java'),
('what-we-learned-from-our-first-workshop', 'systemd'), ('what-we-learned-from-our-first-workshop', 'journalctl'),
('a-small-experiment-in-onboarding', 'bash'), ('a-small-experiment-in-onboarding', 'terminal'),
('questions-we-are-exploring', 'go'), ('questions-we-are-exploring', 'spring-boot'), ('questions-we-are-exploring', 'maven'),
('content-modeling-principles', 'devops'), ('content-modeling-principles', 'postgresql'),
('why-plain-language-wins', 'linux'), ('why-plain-language-wins', 'terminal'),
('a-note-on-data-retention', 'systemd'), ('a-note-on-data-retention', 'devops'),
('the-next-quarter', 'linux'), ('the-next-quarter', 'go'), ('the-next-quarter', 'java'),
('community-notes-june', 'ssh'), ('community-notes-june', 'git'), ('community-notes-june', 'shell'),
('release-0-3-preview', 'go'), ('release-0-3-preview', 'spring-boot'), ('release-0-3-preview', 'maven')
) AS seed(slug, badge)
JOIN content c ON c.slug = seed.slug
JOIN badges b ON b.name = seed.badge;

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

View file

@ -6,6 +6,7 @@ import {
type Content, type Content,
type ContentUpdate, type ContentUpdate,
getContent, getContent,
listDemoUsers,
listBadges, listBadges,
listCategories, listCategories,
listContent, listContent,
@ -14,7 +15,14 @@ import {
import {useAuth} from './auth' import {useAuth} from './auth'
function Shell({children}: { children: React.ReactNode }) { function Shell({children}: { children: React.ReactNode }) {
const {authenticated, loading, canSignIn, signIn} = useAuth() const {authenticated, loading, canSignIn, signIn, username, sudo, signOut} = useAuth()
const [sudoOpen, setSudoOpen] = useState(false)
const [helpOpen, setHelpOpen] = useState(false)
const [users, setUsers] = useState<{username: string; name: string}[]>([])
const [user, setUser] = useState('')
const [password, setPassword] = useState('')
const [sudoError, setSudoError] = useState('')
useEffect(() => { void listDemoUsers().then(setUsers) }, [])
const [dark, setDark] = useState(() => window.localStorage.getItem('urpi-theme') !== 'light') const [dark, setDark] = useState(() => window.localStorage.getItem('urpi-theme') !== 'light')
useEffect(() => { useEffect(() => {
document.documentElement.dataset.theme = dark ? 'dark' : 'light' document.documentElement.dataset.theme = dark ? 'dark' : 'light'
@ -24,7 +32,12 @@ function Shell({children}: { children: React.ReactNode }) {
<header className="header"><Link className="brand" to="/content"><span className="brand-prompt">~/</span> Urpi's <header className="header"><Link className="brand" to="/content"><span className="brand-prompt">~/</span> Urpi's
backlog<span className="brand-cursor">_</span><small>linux · go · java · ideas</small></Link> backlog<span className="brand-cursor">_</span><small>linux · go · java · ideas</small></Link>
<div className="header-actions">{!loading && <div className="header-actions">{!loading &&
<span className="mode-badge">{authenticated ? 'Owner · SSO' : 'Anonymous · read-only'}</span>} <span className="mode-badge">{authenticated ? `${username ?? 'Owner'} · sudo` : 'Anonymous · read-only'}</span>}
<button className="theme-button" type="button" aria-label="Demo users" onClick={() => setHelpOpen(value => !value)}>?</button>
{!loading && !authenticated && users.length > 0 &&
<button className="theme-button" type="button" onClick={() => setSudoOpen(true)}>sudo</button>}
{!loading && authenticated && users.length > 0 &&
<button className="theme-button" type="button" onClick={signOut}>logout</button>}
{!loading && !authenticated && canSignIn && {!loading && !authenticated && canSignIn &&
<button className="google-button session-button" type="button" onClick={signIn}><span <button className="google-button session-button" type="button" onClick={signIn}><span
aria-hidden="true">G</span> Sign in with Google</button>} aria-hidden="true">G</span> Sign in with Google</button>}
@ -32,6 +45,34 @@ function Shell({children}: { children: React.ReactNode }) {
onClick={() => setDark(value => !value)}>{dark ? '☀ Light' : '☾ Dark'}</button> 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 && <div className="terminal-backdrop" role="presentation" onMouseDown={event => {
if (event.target === event.currentTarget) setSudoOpen(false)
}}>
<form className="terminal-window" role="dialog" aria-modal="true" aria-labelledby="sudo-title" onSubmit={event => {
event.preventDefault()
setSudoError('')
void sudo(user, password).then(() => { setSudoOpen(false); setPassword('') }).catch(error => setSudoError(error instanceof Error ? error.message : 'sudo failed'))
}}>
<div className="terminal-titlebar">
<span id="sudo-title">urpi@backlog: ~</span>
<button type="button" className="terminal-close" aria-label="Close sudo terminal"
onClick={() => setSudoOpen(false)}>×</button>
</div>
<div className="terminal-body">
<p><span className="terminal-prompt">$</span> sudo login</p>
<label><span className="terminal-prompt">$</span> sudo <input autoFocus value={user}
onChange={event => setUser(event.target.value)} placeholder="user" aria-label="Username"/></label>
<label><span className="terminal-prompt">$</span> password: <input type="password" value={password}
onChange={event => setPassword(event.target.value)} aria-label="Password"/></label>
{sudoError && <p className="terminal-error">{sudoError}</p>}
<button className="terminal-submit" type="submit"><span className="terminal-prompt">$</span> Enter</button>
</div>
</form>
</div>}
{children} {children}
</main> </main>
} }
@ -41,6 +82,10 @@ function ContentCard({item}: { item: Content }) {
<span className="status">{item.status}</span> <span className="status">{item.status}</span>
<h2><Link to={`/content/${item.id}`}>{item.title}</Link></h2> <h2><Link to={`/content/${item.id}`}>{item.title}</Link></h2>
<p>{item.summary}</p> <p>{item.summary}</p>
<p className="content-meta">
by {item.author ?? 'unknown'}
{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" <div className="badge-list">{item.badges.map(badge => <span className="badge"
key={badge}>{badge}</span>)}</div>} key={badge}>{badge}</span>)}</div>}
@ -166,7 +211,7 @@ function ContentList() {
function ContentDetail() { function ContentDetail() {
const {id = ''} = useParams() const {id = ''} = useParams()
const {canEdit} = useAuth() const {canEdit, username} = useAuth()
const [item, setItem] = useState<Content | null>(null) const [item, setItem] = useState<Content | null>(null)
const [editing, setEditing] = useState(false) const [editing, setEditing] = useState(false)
const [draft, setDraft] = useState<ContentUpdate | null>(null) const [draft, setDraft] = useState<ContentUpdate | null>(null)
@ -270,9 +315,9 @@ function ContentDetail() {
</div> </div>
</div> : <> </div> : <>
<div className="detail-heading"><span className="status">{item.status}</span><h1>{item.title}</h1><p <div className="detail-heading"><span className="status">{item.status}</span><h1>{item.title}</h1><p
className="muted">{item.summary}</p>{item.badges && item.badges.length > 0 && className="muted">{item.summary}</p><p className="muted">by {item.author ?? 'unknown'}{item.publishedAt ? ` · published ${new Date(item.publishedAt).toLocaleDateString()}` : ''}</p>{item.badges && item.badges.length > 0 &&
<div className="badge-list">{item.badges.map(badge => <span className="badge" <div className="badge-list">{item.badges.map(badge => <span className="badge"
key={badge}>{badge}</span>)}</div>}{canEdit && key={badge}>{badge}</span>)}</div>}{canEdit && username === item.author &&
<button className="primary-button edit-button" onClick={() => setEditing(true)}>Edit</button>}</div> <button className="primary-button edit-button" onClick={() => setEditing(true)}>Edit</button>}</div>
<div className="body-copy">{item.body}</div> <div className="body-copy">{item.body}</div>
</>} </>}

View file

@ -11,6 +11,7 @@ export type User = {
export type SessionResponse = { export type SessionResponse = {
authenticated?: boolean authenticated?: boolean
canEdit?: boolean canEdit?: boolean
username?: string
} }
const baseUrl = (import.meta.env.VITE_API_BASE_URL ?? '').replace(/\/$/, '') const baseUrl = (import.meta.env.VITE_API_BASE_URL ?? '').replace(/\/$/, '')
@ -20,6 +21,24 @@ export const apiConfig = {
signIn: import.meta.env.VITE_SSO_LOGIN_URL ?? '', signIn: import.meta.env.VITE_SSO_LOGIN_URL ?? '',
} }
export type DemoUser = { username: string; name: string }
export async function listDemoUsers(): Promise<DemoUser[]> {
const response = await fetch(apiUrl('/api/auth/users'), {headers: {Accept: 'application/json'}})
if (!response.ok) return []
return ((await response.json()) as {items?: DemoUser[]}).items ?? []
}
export async function sudoLogin(username: string, password: string): Promise<{token: string; user: DemoUser}> {
const response = await fetch(apiUrl('/api/auth/sudo'), {
method: 'POST',
headers: {'Content-Type': 'application/json', Accept: 'application/json'},
body: JSON.stringify({username, password}),
})
if (!response.ok) throw new Error('sudo: authentication failed')
return (await response.json()) as {token: string; user: DemoUser}
}
export function apiUrl(path: string) { export function apiUrl(path: string) {
if (/^https?:\/\//i.test(path)) return path if (/^https?:\/\//i.test(path)) return path
return `${baseUrl}${path.startsWith('/') ? path : `/${path}`}` return `${baseUrl}${path.startsWith('/') ? path : `/${path}`}`
@ -39,7 +58,7 @@ export function signInWithGoogle(target = apiConfig.signIn): void {
export async function requestSession(): Promise<SessionResponse | null> { export async function requestSession(): Promise<SessionResponse | null> {
const response = await fetch(apiUrl(apiConfig.session), { const response = await fetch(apiUrl(apiConfig.session), {
credentials: 'include', credentials: 'include',
headers: { Accept: 'application/json' }, headers: { Accept: 'application/json', ...demoAuthHeaders() },
}) })
if (response.status === 404) return null if (response.status === 404) return null
if (!response.ok) throw new Error('Unable to check your session.') if (!response.ok) throw new Error('Unable to check your session.')
@ -48,6 +67,11 @@ export async function requestSession(): Promise<SessionResponse | null> {
return value return value
} }
function demoAuthHeaders(): Record<string, string> {
const token = window.sessionStorage.getItem('cms-demo-token')
return token ? {Authorization: `Bearer ${token}`} : {}
}
export type Content = { export type Content = {
id: string id: string
title: string title: string
@ -58,6 +82,8 @@ export type Content = {
badges?: string[] badges?: string[]
createdAt?: string createdAt?: string
updatedAt?: string updatedAt?: string
author?: string
publishedAt?: string
} }
export type ContentListResponse = { export type ContentListResponse = {
@ -71,15 +97,45 @@ export type ContentListResponse = {
limit?: number limit?: number
} }
function normalizeContent(value: Content & { ID?: string; Slug?: string; Title?: string; Summary?: string; Body?: string; Status?: string; Category?: string; Badges?: string[] }): Content { // Raw JSON as sent by the Go API: lowercase keys, with the slug doubling as the
return { id: value.id ?? value.Slug ?? String(value.ID ?? ''), title: value.title ?? value.Title ?? '', summary: value.summary ?? value.Summary ?? '', body: value.body ?? value.Body ?? '', status: value.status ?? value.Status ?? '', category: value.category ?? value.Category, badges: value.badges ?? value.Badges ?? [], createdAt: value.createdAt, updatedAt: value.updatedAt } // identifier used by the detail and update routes.
type RawContent = Omit<Partial<Content>, 'id'> & {
id?: string | number
ID?: string | number
slug?: string
Slug?: string
Title?: string
Summary?: string
Body?: string
Status?: string
Category?: string
Badges?: string[]
Author?: string
PublishedAt?: string
}
function normalizeContent(value: RawContent): Content {
const rawId = value.id ?? value.ID
return {
id: value.slug ?? value.Slug ?? (rawId !== undefined ? String(rawId) : ''),
title: value.title ?? value.Title ?? '',
summary: value.summary ?? value.Summary ?? '',
body: value.body ?? value.Body ?? '',
status: value.status ?? value.Status ?? '',
category: value.category ?? value.Category,
badges: value.badges ?? value.Badges ?? [],
createdAt: value.createdAt,
updatedAt: value.updatedAt,
author: value.author ?? value.Author,
publishedAt: value.publishedAt ?? value.PublishedAt,
}
} }
async function contentRequest<T>(path = '', init?: RequestInit): Promise<T> { async function contentRequest<T>(path = '', init?: RequestInit): Promise<T> {
const response = await fetch(apiUrl(`${apiConfig.content}${path}`), { const response = await fetch(apiUrl(`${apiConfig.content}${path}`), {
credentials: 'include', credentials: 'include',
...init, ...init,
headers: { Accept: 'application/json', ...(init?.body ? { 'Content-Type': 'application/json' } : {}), ...init?.headers }, headers: { Accept: 'application/json', ...demoAuthHeaders(), ...(init?.body ? { 'Content-Type': 'application/json' } : {}), ...init?.headers },
}) })
if (!response.ok) { if (!response.ok) {
if (response.status === 401) throw new Error('Your SSO session is required to edit content.') if (response.status === 401) throw new Error('Your SSO session is required to edit content.')
@ -95,7 +151,7 @@ export async function listContent(cursor?: string | null, category = '', status
if (status) params.set('status', status) if (status) params.set('status', status)
badges.forEach(badge => params.append('badge', badge)) badges.forEach(badge => params.append('badge', badge))
const query = params.toString() ? `?${params.toString()}` : '' const query = params.toString() ? `?${params.toString()}` : ''
const response = await contentRequest<ContentListResponse | (Content & { ID?: string; Slug?: string; Title?: string; Summary?: string; Body?: string; Status?: string })[]>(query) const response = await contentRequest<ContentListResponse | RawContent[]>(query)
if (Array.isArray(response)) return { items: response.map(normalizeContent), nextCursor: null } if (Array.isArray(response)) return { items: response.map(normalizeContent), nextCursor: null }
const items = (response.items ?? response.content ?? response.data ?? []).map(normalizeContent) const items = (response.items ?? response.content ?? response.data ?? []).map(normalizeContent)
const offset = response.offset ?? (cursor ? Number(cursor) : 0) const offset = response.offset ?? (cursor ? Number(cursor) : 0)
@ -107,24 +163,24 @@ export type Category = { id: string; slug: string; name: string }
export type Badge = { id: string; name: string } export type Badge = { id: string; name: string }
export async function listCategories(): Promise<Category[]> { export async function listCategories(): Promise<Category[]> {
const response = await contentRequest<{ items?: Array<Category & { ID?: string; Slug?: string; Name?: string }> }>('/categories') const response = await contentRequest<{ items?: Array<{ id?: string | number; slug?: string; Slug?: string; name?: string; Name?: string }> }>('/categories')
return (response.items ?? []).map(item => ({ id: item.id ?? item.ID ?? '', slug: item.slug ?? item.Slug ?? '', name: item.name ?? item.Name ?? '' })) return (response.items ?? []).map(item => ({ id: String(item.slug ?? item.slug ?? item.id ?? ''), slug: item.slug ?? item.Slug ?? '', name: item.name ?? item.Name ?? '' }))
} }
export async function listBadges(): Promise<Badge[]> { export async function listBadges(): Promise<Badge[]> {
const response = await contentRequest<{ items?: Array<Badge & { ID?: string; Name?: string }> }>('/badges') const response = await contentRequest<{ items?: Array<{ id?: string | number; name?: string; Name?: string }> }>('/badges')
return (response.items ?? []).map(item => ({ id: item.id ?? item.ID ?? '', name: item.name ?? item.Name ?? '' })) return (response.items ?? []).map(item => ({ id: String(item.id ?? item.name ?? ''), name: item.name ?? item.Name ?? '' }))
} }
export function getContent(id: string): Promise<Content> { export function getContent(id: string): Promise<Content> {
return contentRequest<Content>(`/${encodeURIComponent(id)}`).then(value => normalizeContent(value as Content & { ID?: string; Slug?: string; Title?: string; Summary?: string; Body?: string; Status?: string })) return contentRequest<RawContent>(`/${encodeURIComponent(id)}`).then(normalizeContent)
} }
export type ContentUpdate = Pick<Content, 'title' | 'summary' | 'body' | 'status' | 'badges'> export type ContentUpdate = Pick<Content, 'title' | 'summary' | 'body' | 'status' | 'badges'>
export function updateContent(id: string, content: ContentUpdate): Promise<Content> { export function updateContent(id: string, content: ContentUpdate): Promise<Content> {
return contentRequest<Content>(`/${encodeURIComponent(id)}`, { return contentRequest<RawContent>(`/${encodeURIComponent(id)}`, {
method: 'PUT', method: 'PUT',
body: JSON.stringify(content), body: JSON.stringify(content),
}).then(value => normalizeContent(value as Content & { ID?: string; Slug?: string; Title?: string; Summary?: string; Body?: string; Status?: string })) }).then(normalizeContent)
} }

View file

@ -1,5 +1,5 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react' import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react'
import { apiConfig, requestSession, signInWithGoogle } from './api' import { apiConfig, requestSession, signInWithGoogle, sudoLogin } from './api'
// There is no sign-in or sign-out: the upstream SSO proxy decides whether the // There is no sign-in or sign-out: the upstream SSO proxy decides whether the
// current request carries the owner's identity. Signing in just sends the // current request carries the owner's identity. Signing in just sends the
@ -8,10 +8,13 @@ type AuthState = {
loading: boolean loading: boolean
authenticated: boolean authenticated: boolean
canEdit: boolean canEdit: boolean
username?: string
error: string | null error: string | null
refresh: () => Promise<void> refresh: () => Promise<void>
canSignIn: boolean canSignIn: boolean
signIn: () => void signIn: () => void
sudo: (username: string, password: string) => Promise<void>
signOut: () => void
} }
const AuthContext = createContext<AuthState | undefined>(undefined) const AuthContext = createContext<AuthState | undefined>(undefined)
@ -19,6 +22,7 @@ const AuthContext = createContext<AuthState | undefined>(undefined)
export function AuthProvider({ children }: { children: ReactNode }) { 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 [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@ -28,6 +32,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const session = await requestSession() const session = await requestSession()
setAuthenticated(session?.authenticated === true) setAuthenticated(session?.authenticated === true)
setCanEdit(session?.canEdit === true) setCanEdit(session?.canEdit === true)
setUsername(session?.username)
} catch (err) { } catch (err) {
setAuthenticated(false) setAuthenticated(false)
setCanEdit(false) setCanEdit(false)
@ -40,11 +45,24 @@ export function AuthProvider({ children }: { children: ReactNode }) {
useEffect(() => { void refresh() }, [refresh]) useEffect(() => { void refresh() }, [refresh])
const signIn = useCallback(() => { signInWithGoogle() }, []) const signIn = useCallback(() => { signInWithGoogle() }, [])
const sudo = useCallback(async (username: string, password: string) => {
const result = await sudoLogin(username, password)
window.sessionStorage.setItem('cms-demo-token', result.token)
setAuthenticated(true)
setCanEdit(true)
setUsername(result.user.username)
}, [])
const signOut = useCallback(() => {
window.sessionStorage.removeItem('cms-demo-token')
setAuthenticated(false)
setCanEdit(false)
setUsername(undefined)
}, [])
const canSignIn = apiConfig.signIn !== '' const canSignIn = apiConfig.signIn !== ''
const value = useMemo( const value = useMemo(
() => ({ loading, authenticated, canEdit, error, refresh, canSignIn, signIn }), () => ({ loading, authenticated, canEdit, username, error, refresh, canSignIn, signIn, sudo, signOut }),
[loading, authenticated, canEdit, error, refresh, canSignIn, signIn], [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>
} }

View file

@ -58,6 +58,7 @@ p { line-height: 1.65; }
.content-card h2 a { color: #f5e0e8; text-decoration: none; } .content-card h2 a { color: #f5e0e8; text-decoration: none; }
.content-card h2 a:hover, .read-link:hover { color: #89b4fa; } .content-card h2 a:hover, .read-link:hover { color: #89b4fa; }
.content-card p { color: #a6adc8; margin: 0 0 18px; } .content-card p { color: #a6adc8; margin: 0 0 18px; }
.content-card .content-meta { margin-top: -8px; color: #7f849c; font-size: .82rem; }
.status { display: inline-block; color: #94e2d5; background: #193d3a; border-radius: 999px; padding: 4px 9px; font-size: .72rem; font-weight: 700; text-transform: uppercase; letter-spacing: .06em; } .status { display: inline-block; color: #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: #89b4fa; 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; }
@ -82,6 +83,78 @@ p { line-height: 1.65; }
.actions .secondary-button { margin: 0; background: #45475a; color: #cdd6f4; } .actions .secondary-button { margin: 0; background: #45475a; color: #cdd6f4; }
.inline-button { margin-top: 22px; } .inline-button { margin-top: 22px; }
.protected-card { text-align: center; } .protected-card { text-align: center; }
.terminal-backdrop {
position: fixed;
z-index: 20;
inset: 0;
display: grid;
place-items: center;
padding: 24px;
background: #000000b8;
backdrop-filter: blur(3px);
}
.terminal-window {
width: min(100%, 620px);
overflow: hidden;
border: 1px solid #4d4d4d;
border-radius: 8px;
background: #000;
color: #fff;
box-shadow: 0 24px 90px #000;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
.terminal-titlebar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 9px 14px;
color: #fff;
background: #252525;
border-bottom: 1px solid #454545;
font-size: .82rem;
}
.terminal-close {
border: 0;
color: #fff;
background: transparent;
cursor: pointer;
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;
gap: 8px;
color: #fff;
font: inherit;
white-space: nowrap;
}
.terminal-prompt { color: #fff; font-weight: 700; }
.terminal-body input {
min-width: 0;
flex: 1;
border: 0;
outline: 0;
padding: 0;
color: #fff;
background: #000;
font: inherit;
}
.terminal-body input::placeholder { color: #777; }
.terminal-submit {
justify-self: start;
border: 0;
padding: 0;
color: #fff;
background: transparent;
font: inherit;
cursor: pointer;
}
.terminal-submit:hover { color: #a6e3a1; }
.terminal-error { color: #ff6b6b; }
html[data-theme="light"] { html[data-theme="light"] {
color: #4c4f69; color: #4c4f69;
background: #eff1f5; background: #eff1f5;

View file

@ -3,6 +3,12 @@ import react from '@vitejs/plugin-react'
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
// Build into the repository root; the Dockerfile and `make ui` copy the result
// into the Go embed directory (cmd/server/web/dist).
build: {
outDir: '../dist',
emptyOutDir: true,
},
server: { server: {
proxy: { proxy: {
'/api': { '/api': {