From 0bdaecd281539a22743837008037793e1e6bc86c Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Mon, 13 Jul 2026 19:10:48 +0200 Subject: [PATCH] Add personal website deploy + move Gitea to a subdomain Serve the personal website (extracted to github.com/kuhyx/personal-website) at the root kuhy.duckdns.org behind the shared gitea-caddy edge, moving Gitea to gitea.kuhy.duckdns.org. - setup_gitea.sh: relocate Gitea to gitea.kuhy.duckdns.org and refactor the shared Caddyfile into per-service sites/*.caddy imports (fixes the latent bug where re-running dropped the dufs/syncyomi blocks). - setup_personal_website.sh (new): idempotent setup/status/help; migrates existing Caddy blocks to snippets, relocates Gitea, builds ~/personal-website, serves dist/ from a loopback caddy (admin off) on 127.0.0.1:8088 fronted on the root domain, reuses the DuckDNS/firewall edge. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01KG7yQj4hP8DDpasK2e4cn7 --- .../evidence/personal-website-2026-07-13.json | 63 +++ .../single_use/features/setup_gitea.sh | 22 +- .../features/setup_personal_website.sh | 379 ++++++++++++++++++ 3 files changed, 461 insertions(+), 3 deletions(-) create mode 100644 docs/superpowers/evidence/personal-website-2026-07-13.json create mode 100755 linux_configuration/scripts/single_use/features/setup_personal_website.sh diff --git a/docs/superpowers/evidence/personal-website-2026-07-13.json b/docs/superpowers/evidence/personal-website-2026-07-13.json new file mode 100644 index 00000000..0c646bc6 --- /dev/null +++ b/docs/superpowers/evidence/personal-website-2026-07-13.json @@ -0,0 +1,63 @@ +{ + "intent": "Serve a minimalistic personal website (live GitHub projects showcase + CV with live-computed job durations) at https://kuhy.duckdns.org/, moving Gitea to gitea.kuhy.duckdns.org, all behind the existing shared gitea-caddy edge.", + "scope": [ + "React 19 + Vite 6 + TypeScript SPA, extracted into its own standalone repo github.com/kuhyx/personal-website (~/personal-website); testsAndMisc keeps only the deploy/edge glue", + "New setup_personal_website.sh feature script (setup/status/help) reusing the DuckDNS/firewall/Caddy edge patterns", + "Refactor setup_gitea.sh: move GITEA_DOMAIN to gitea.kuhy.duckdns.org and switch the shared Caddyfile to a per-service sites/*.caddy import (fixes latent clobber of dufs/syncyomi blocks)", + "CV repo (~/CV): add cv.json single source of truth and render build_cv.py + the website from it", + "Non-goals: no new systemd unit (reboot survival via docker restart:unless-stopped); no DuckDNS change (wildcard already resolves)" + ], + "changes": [ + "personal_website/: src/lib/duration.ts + github.ts + markup.tsx (pure, fully tested), components hero/projects/project-card/experience-item/cv-section, data/cv.ts typed view of cv.json; mirrors bucket_catch tooling", + "Projects feed is a single client-side GET /users/kuhyx/repos?per_page=100&sort=pushed, filtering archived+forks (no per-repo fan-out); CV durations computed live from ISO start/end dates", + "setup_gitea.sh write_compose_files now mounts ./sites and writes root Caddyfile as 'import /etc/caddy/sites/*.caddy' plus sites/gitea.caddy", + "setup_personal_website.sh: preflight -> migrate existing Caddy blocks into snippets -> relocate gitea -> copy cv.json -> pnpm build -> loopback caddy container on 127.0.0.1:8088 -> website.caddy snippet -> reload -> firewall -> report; status subcommand self-diagnoses", + "The website's loopback caddy sets `admin off`: with host networking it would otherwise share the admin API (:2019) with gitea-caddy, so a `caddy reload` aimed at gitea-caddy was applied to the website caddy — it grabbed 80/443 and ran ACME, blanking the site. admin off fixes it", + "CV: cv.json authored from the LaTeX; build_cv.py datafied with latex_escape + **bold** markup renderer + bilingual compute_duration/format_duration; finished jobs pin durationPl/durationEn (Delta 1.5 years, Radioactive 1 year), only the ongoing AI Clearing computes live", + "UI refinements from review: dropped repo star counts; SVG favicon (KR monogram); Skills rendered as an aligned two-column grid; contact row shows the full email (mailto) + GitHub + LinkedIn + Discord (discord.com/users/); CV 'Download PDF' button serving /cv.pdf (staged from the CV repo, gitignored binary)", + "Projects feed curated: NEVER_SHOW drops testsAndMisc-archive; ALWAYS_SHOW force-includes ARAI + WUT_Computer_Science (archived) since they're referenced in the CV; FALLBACK_DESCRIPTIONS fill cards whose GitHub description is empty", + "Each card shows the full language mix (top 6 by bytes) via one /repos//languages call per shown repo, run in parallel; degrades to the primary language if a call is rate-limited. Trade-off: ~11 unauth GitHub calls per page load vs the 60/hr-per-IP limit", + "Extracted the SPA out of testsAndMisc into github.com/kuhyx/personal-website; setup_personal_website.sh now builds ~/personal-website and serves it from ~/personal-website-serve (renamed runtime dir); testsAndMisc retains setup_gitea.sh, setup_personal_website.sh, and this evidence" + ], + "verification": [ + { + "command": "cd personal_website && pnpm run lint && pnpm run coverage", + "result": "pass", + "evidence": "tsc + eslint clean; 38 tests pass; coverage 100% statements/branches/functions/lines (71/71, 52/52, 32/32, 70/70)" + }, + { + "command": "cd personal_website && pnpm run build", + "result": "pass", + "evidence": "vite build ok: dist/index.html + assets (js 211kB, css 2.3kB)" + }, + { + "command": "curl -s 'https://api.github.com/users/kuhyx/repos?per_page=100&sort=pushed' | jq filter", + "result": "pass", + "evidence": "9 showable (non-archived, non-fork) repos returned with name/language/stars/pushed_at fields the feed maps" + }, + { + "command": "python3 ~/CV/generic/build_cv.py", + "result": "pass", + "evidence": "cv-en.pdf (96,549 B) + cv-pl.pdf (99,582 B) build; durations computed (2 years and 1 month / 1 year and 8 months / 11 months), bold tech tags + LINK hrefs preserved" + }, + { + "command": "bash -n + shellcheck -S warning setup_gitea.sh setup_personal_website.sh; migration parser unit-tested on a mock Caddyfile", + "result": "pass", + "evidence": "both scripts syntax-OK and shellcheck-clean; parser preserves kuhy-cloud+kuhy-sync snippets, drops the old gitea-at-root block, idempotent on re-run" + }, + { + "command": "live deploy + browser verification of https://kuhy.duckdns.org/", + "result": "pass", + "evidence": "public 200 with valid production Let's Encrypt cert; hero + 9 live GitHub repos + CV render; durations June 2024-present 2 years and 1 month (live), Delta 1.5 years, Radioactive 1 year; gitea->200 at gitea.kuhy.duckdns.org, dufs 401 (auth), syncyomi 200 — no regression" + } + ], + "risks": [ + "Live `setup` moves Gitea and rewrites the shared Caddyfile; ordering (migrate snippets -> relocate gitea -> add website snippet -> single reload) is load-bearing and must not be reordered", + "Ended-job durations in the PDF change from hand-written labels (1.5 years) to computed values (1 year and 8 months) — more accurate but different wording", + "CV repo (~/CV) is a separate repo; its cv.json/build_cv.py/PDF changes must be committed there separately" + ], + "rollback": [ + "Website: docker compose -f ~/personal-website/docker-compose.yml down; rm ~/gitea/sites/website.caddy; caddy reload", + "Gitea move: revert GITEA_DOMAIN in setup_gitea.sh to kuhy.duckdns.org and re-run; validate https://kuhy.duckdns.org serves Gitea and dufs/syncyomi subdomains still respond" + ] +} diff --git a/linux_configuration/scripts/single_use/features/setup_gitea.sh b/linux_configuration/scripts/single_use/features/setup_gitea.sh index 5e1adeca..15fa42f6 100755 --- a/linux_configuration/scripts/single_use/features/setup_gitea.sh +++ b/linux_configuration/scripts/single_use/features/setup_gitea.sh @@ -31,12 +31,16 @@ SCRIPT_DIR="$(dirname "$(readlink -f "$0")")" # shellcheck source=../../lib/common.sh source "$SCRIPT_DIR/../../lib/common.sh" -readonly GITEA_DOMAIN="kuhy.duckdns.org" +readonly GITEA_DOMAIN="gitea.kuhy.duckdns.org" readonly GITEA_ADMIN_USER="kuhyx" readonly GITEA_ADMIN_EMAIL="krzysztofrudnicki0@gmail.com" readonly GITEA_DATA_DIR="${HOME}/gitea" readonly COMPOSE_FILE="${GITEA_DATA_DIR}/docker-compose.yml" readonly CADDYFILE="${GITEA_DATA_DIR}/Caddyfile" +# Per-service Caddy site blocks live here as individual *.caddy snippets so +# adding/removing a service never clobbers another's block (see the root +# Caddyfile's `import` below). Every service on this edge owns one file. +readonly SITES_DIR="${GITEA_DATA_DIR}/sites" readonly ADMIN_PASSWORD_FILE="${GITEA_DATA_DIR}/.admin_password" readonly ADMIN_TOKEN_FILE="${GITEA_DATA_DIR}/.admin_token" readonly WIREGUARD_SCRIPT="${SCRIPT_DIR}/setup_wireguard_ssh.sh" @@ -82,6 +86,7 @@ services: network_mode: host volumes: - ./Caddyfile:/etc/caddy/Caddyfile:ro + - ./sites:/etc/caddy/sites:ro - caddy-data:/data - caddy-config:/config @@ -90,12 +95,23 @@ volumes: caddy-config: EOF - cat >"$CADDYFILE" <"$CADDYFILE" <<'EOF' +# Managed by setup_gitea.sh — do not edit by hand. +# Each service on this edge owns one snippet in sites/*.caddy. +import /etc/caddy/sites/*.caddy +EOF + + cat >"${SITES_DIR}/gitea.caddy" <.caddy, EXCEPT the old root block (kuhy.duckdns.org, previously +# gitea — now repurposed for the website) and the gitea block (setup_gitea.sh +# rewrites its own). This preserves dufs (kuhy-cloud) and syncyomi (kuhy-sync) +# before setup_gitea.sh switches the root Caddyfile to `import`. Idempotent. +migrate_caddy_snippets() { + ensure_dir "$SITES_DIR" + if grep -qE '^[[:space:]]*import[[:space:]]+/etc/caddy/sites' "$CADDYFILE"; then + log_info "Caddyfile already uses per-service snippets; nothing to migrate." + return 0 + fi + + local line label body in_block=0 migrated=0 + label="" + body="" + while IFS= read -r line || [[ -n $line ]]; do + if [[ $in_block -eq 0 && $line =~ ^([^[:space:]#].*)\{[[:space:]]*$ ]]; then + label="${BASH_REMATCH[1]}" + # Trim trailing whitespace from the captured label. + label="${label%"${label##*[![:space:]]}"}" + body="${line}"$'\n' + in_block=1 + elif [[ $in_block -eq 1 ]]; then + body+="${line}"$'\n' + if [[ $line =~ ^\}[[:space:]]*$ ]]; then + in_block=0 + if [[ $label == "$WEBSITE_DOMAIN" || $label == "$GITEA_DOMAIN" ]]; then + label="" + body="" + continue + fi + local snippet="${SITES_DIR}/${label}.caddy" + if [[ ! -f $snippet ]]; then + printf '# Migrated from Caddyfile by setup_personal_website.sh\n%s' \ + "$body" >"$snippet" + log_ok "Migrated ${label} → ${snippet}" + migrated=$((migrated + 1)) + fi + label="" + body="" + fi + fi + done <"$CADDYFILE" + log_info "Migrated ${migrated} existing site block(s) into ${SITES_DIR}." +} + +# --- Phase 3: relocate Gitea to its subdomain ------------------------------- +relocate_gitea() { + log_info "Applying Gitea relocation to ${GITEA_DOMAIN} via setup_gitea.sh…" + bash "$GITEA_SCRIPT" setup + log_ok "Gitea now served at https://${GITEA_DOMAIN}/." +} + +# --- Phase 4: refresh CV data ---------------------------------------------- +build_cv_json() { + if [[ -f $CV_JSON_SRC ]]; then + cp "$CV_JSON_SRC" "${WEBSITE_SRC}/src/data/cv.json" + log_ok "Copied CV data from ${CV_JSON_SRC}." + else + log_warn "CV data ${CV_JSON_SRC} not found — using the copy committed in the website repo." + fi + # Stage the rendered PDF for the CV download button (served from /cv.pdf). + if [[ -f $CV_PDF_SRC ]]; then + ensure_dir "${WEBSITE_SRC}/public" + cp "$CV_PDF_SRC" "${WEBSITE_SRC}/public/cv.pdf" + log_ok "Copied CV PDF from ${CV_PDF_SRC}." + else + log_warn "CV PDF ${CV_PDF_SRC} not found — the Download PDF link will 404 until built." + fi +} + +# --- Phase 5: build the website -------------------------------------------- +build_website() { + log_info "Installing website dependencies and building…" + ( + cd "$WEBSITE_SRC" + pnpm install --frozen-lockfile + pnpm run build + ) + [[ -f "${WEBSITE_SRC}/dist/index.html" ]] || + die "Build did not produce ${WEBSITE_SRC}/dist/index.html." + log_ok "Website built to ${WEBSITE_SRC}/dist." +} + +# --- Phase 6: write the website's static-serve stack ------------------------ +write_website_stack() { + ensure_dir "$WEBSITE_DATA_DIR" + cat >"$WEBSITE_COMPOSE" <"$WEBSITE_INNER_CADDY" <"$WEBSITE_SNIPPET" </dev/null 2>&1; then + docker exec "$CADDY_CONTAINER" caddy reload --config /etc/caddy/Caddyfile + log_ok "Reloaded ${CADDY_CONTAINER}." + else + die "Caddy config invalid — aborting reload. Check ${SITES_DIR}/*.caddy." + fi +} + +# --- Phase 10: firewall ----------------------------------------------------- +ensure_firewall() { + log_info "Ensuring ports 80/443 are open (idempotent)…" + sudo bash "$WG_SCRIPT" allow-web +} + +# --- Phase 11: report ------------------------------------------------------- +print_report() { + cat < https://${GITEA_DOMAIN}//.git + +Acceptance test (do this on your phone): + Turn Wi-Fi OFF (use cellular) and open https://${WEBSITE_DOMAIN}/ — + it should load over HTTPS with the hero, live GitHub projects, and CV, + and be usable at phone width. +============================================================================ +EOF +} + +setup_cmd() { + print_setup_header "Personal website setup" + preflight + migrate_caddy_snippets + relocate_gitea + build_cv_json + build_website + write_website_stack + write_website_snippet + start_website + reload_caddy + ensure_firewall + print_report + log_ok "Personal website setup complete." +} + +# --- status: self-diagnose -------------------------------------------------- +status_line() { + if [[ $1 -eq 0 ]]; then + log_ok "$2" + else + log_warn "$2" + fi +} + +check_http() { + # Prints 0 to stdout if the URL returns 200, else 1. + local url="$1" code + code="$(curl -sk -o /dev/null -w '%{http_code}' --max-time 10 "$url" 2>/dev/null || echo 000)" + [[ $code == "200" ]] && echo 0 || echo 1 +} + +status_cmd() { + print_setup_header "Personal website status" + + has_cmd node && status_line 0 "node present" || status_line 1 "node missing" + has_cmd pnpm && status_line 0 "pnpm present" || status_line 1 "pnpm missing" + + [[ -f "${WEBSITE_SRC}/dist/index.html" ]] && + status_line 0 "build present (dist/index.html)" || + status_line 1 "build missing — run setup" + + if docker ps --format '{{.Names}}' | grep -qx personal-website; then + status_line 0 "personal-website container running" + else + status_line 1 "personal-website container not running" + fi + + status_line "$(check_http "http://127.0.0.1:${WEBSITE_PORT}/")" \ + "local static server (127.0.0.1:${WEBSITE_PORT})" + + [[ -f $WEBSITE_SNIPPET ]] && + status_line 0 "Caddy website snippet present" || + status_line 1 "Caddy website snippet missing" + grep -qE '^[[:space:]]*import[[:space:]]+/etc/caddy/sites' "$CADDYFILE" 2>/dev/null && + status_line 0 "root Caddyfile uses snippet imports" || + status_line 1 "root Caddyfile not migrated to imports" + + getent hosts "$WEBSITE_DOMAIN" >/dev/null && + status_line 0 "${WEBSITE_DOMAIN} resolves" || + status_line 1 "${WEBSITE_DOMAIN} does not resolve" + getent hosts "$GITEA_DOMAIN" >/dev/null && + status_line 0 "${GITEA_DOMAIN} resolves" || + status_line 1 "${GITEA_DOMAIN} does not resolve" + + status_line "$(check_http "https://${WEBSITE_DOMAIN}/")" \ + "external https://${WEBSITE_DOMAIN}/" + status_line "$(check_http "https://${GITEA_DOMAIN}/")" \ + "external https://${GITEA_DOMAIN}/" +} + +main() { + local cmd="${1:-setup}" + case "$cmd" in + setup) + setup_cmd + ;; + status) + status_cmd + ;; + help | -h | --help) + usage + ;; + *) + log_error "Unknown command: $cmd" + usage + exit 1 + ;; + esac +} + +main "$@"