From d10a0278fb5b284018fab035f2645826ea5f86ae Mon Sep 17 00:00:00 2001 From: Jeffrey Alan Scudder Date: Mon, 30 Mar 2026 13:05:17 -0700 Subject: [PATCH] Fix chat modal close and harden lith deploy flow --- lith/README.md | 11 +- lith/deploy.fish | 74 ++++--- lith/server.mjs | 115 ++++++++-- lith/webhook.sh | 25 ++- .../public/aesthetic.computer/disks/chat.mjs | 196 ++++++++++-------- 5 files changed, 281 insertions(+), 140 deletions(-) diff --git a/lith/README.md b/lith/README.md index edc033768e..8ef546e592 100644 --- a/lith/README.md +++ b/lith/README.md @@ -17,8 +17,17 @@ Minimum required keys: - `CONTEXT=production` - `DEPLOY_SECRET=...` +Optional deploy keys: +- `DEPLOY_BRANCH=master` or `DEPLOY_BRANCH=main` +- `DEPLOY_BRANCHES=master,main` to allow multiple webhook refs + Recommended workflow: 1. Copy `.env.example` to `.env` 2. Fill in the real production values 3. Re-run `fish vault-tool.fish status` to confirm `lith/.env` is tracked -4. Deploy with `fish /workspaces/aesthetic-computer/lith/deploy.fish` +4. Push the deploy branch to GitHub. The webhook deploys pushed commits only. +5. Or deploy manually with `fish /workspaces/aesthetic-computer/lith/deploy.fish` + +Notes: +- `lith/deploy.fish` no longer rsyncs local working-tree files into production. +- Manual deploys now reset the host to the pushed git branch state, then refresh `.commit-ref`. diff --git a/lith/deploy.fish b/lith/deploy.fish index aec30ab1c5..acedee57fc 100644 --- a/lith/deploy.fish +++ b/lith/deploy.fish @@ -18,6 +18,8 @@ set DEFAULT_LITH_HOST "lith.aesthetic.computer" set DEFAULT_LITH_DROPLET_NAME "ac-lith" set TARGET_HOST $DEFAULT_LITH_HOST set TARGET_DROPLET_NAME $DEFAULT_LITH_DROPLET_NAME +set LOCAL_BRANCH (git -C $REPO_ROOT branch --show-current 2>/dev/null) +set TARGET_BRANCH $LOCAL_BRANCH if set -q LITH_HOST set TARGET_HOST $LITH_HOST @@ -27,6 +29,14 @@ if set -q LITH_DROPLET_NAME set TARGET_DROPLET_NAME $LITH_DROPLET_NAME end +if set -q DEPLOY_BRANCH + set TARGET_BRANCH $DEPLOY_BRANCH +end + +if test -z "$TARGET_BRANCH" + set TARGET_BRANCH main +end + function ssh_ok --argument host ssh -i $SSH_KEY -o StrictHostKeyChecking=no -o ConnectTimeout=10 $LITH_USER@$host "echo ok" &>/dev/null end @@ -124,30 +134,31 @@ end echo -e "$GREEN-> Connected to $TARGET_HOST.$NC" -# Sync repo (git pull on remote) -echo -e "$GREEN-> Pulling latest code...$NC" -ssh -i $SSH_KEY $LITH_USER@$TARGET_HOST "cd $REMOTE_DIR && git pull origin main" - -# Overlay local working tree changes so deploys include uncommitted routing/frontend edits. -echo -e "$GREEN-> Syncing local lith/ and system/ working tree...$NC" -rsync -az --delete \ - --exclude node_modules \ - --exclude .env \ - --exclude .DS_Store \ - "$REPO_ROOT/lith/" \ - $LITH_USER@$TARGET_HOST:$REMOTE_DIR/lith/ -rsync -az --delete \ - --exclude node_modules \ - --exclude .env \ - --exclude .DS_Store \ - --exclude .netlify \ - --exclude .commit-ref \ - "$REPO_ROOT/system/" \ - $LITH_USER@$TARGET_HOST:$REMOTE_DIR/system/ - -# Write .commit-ref AFTER rsync so it reflects the actual deployed state -echo -e "$GREEN-> Writing commit ref...$NC" -ssh -i $SSH_KEY $LITH_USER@$TARGET_HOST "cd $REMOTE_DIR && git rev-parse HEAD > system/public/.commit-ref" +# Deploy from pushed git state only. This avoids production drift from local rsync overlays. +echo -e "$GREEN-> Verifying origin/$TARGET_BRANCH...$NC" +git -C $REPO_ROOT fetch origin $TARGET_BRANCH --quiet +set ORIGIN_HEAD (git -C $REPO_ROOT rev-parse origin/$TARGET_BRANCH) + +if test "$LOCAL_BRANCH" = "$TARGET_BRANCH" + set LOCAL_HEAD (git -C $REPO_ROOT rev-parse HEAD) + if test "$LOCAL_HEAD" != "$ORIGIN_HEAD" + echo -e "$RED x Local $TARGET_BRANCH is ahead of origin/$TARGET_BRANCH.$NC" + echo -e "$YELLOW Push first. This deploy script no longer rsyncs uncommitted or unpushed code into production.$NC" + exit 1 + end +end + +echo -e "$GREEN-> Deploying branch $TARGET_BRANCH at $ORIGIN_HEAD...$NC" +ssh -i $SSH_KEY $LITH_USER@$TARGET_HOST "\ +cd $REMOTE_DIR && \ +git fetch origin $TARGET_BRANCH --quiet && \ +if git show-ref --verify --quiet refs/heads/$TARGET_BRANCH; then \ + git checkout $TARGET_BRANCH --quiet; \ +else \ + git checkout -B $TARGET_BRANCH origin/$TARGET_BRANCH --quiet; \ +fi && \ +git reset --hard origin/$TARGET_BRANCH --quiet && \ +git rev-parse HEAD > system/public/.commit-ref" # Upload env echo -e "$GREEN-> Uploading environment...$NC" @@ -158,12 +169,15 @@ scp -i $SSH_KEY $SERVICE_ENV $LITH_USER@$TARGET_HOST:$REMOTE_DIR/system/.env # Install deps echo -e "$GREEN-> Installing dependencies...$NC" -ssh -i $SSH_KEY $LITH_USER@$TARGET_HOST "cd $REMOTE_DIR/lith && npm install && cd $REMOTE_DIR/system && npm install" - -# Upload Caddyfile -echo -e "$GREEN-> Updating Caddy config...$NC" -scp -i $SSH_KEY $SCRIPT_DIR/Caddyfile $LITH_USER@$TARGET_HOST:/etc/caddy/Caddyfile -ssh -i $SSH_KEY $LITH_USER@$TARGET_HOST "systemctl reload caddy" +ssh -i $SSH_KEY $LITH_USER@$TARGET_HOST "cd $REMOTE_DIR/lith && npm install --omit=dev && cd $REMOTE_DIR/system && npm install --omit=dev" + +# Install service file + Caddy config from the deployed checkout +echo -e "$GREEN-> Updating service + Caddy config...$NC" +ssh -i $SSH_KEY $LITH_USER@$TARGET_HOST "\ +cp $REMOTE_DIR/lith/lith.service /etc/systemd/system/lith.service && \ +cp $REMOTE_DIR/lith/Caddyfile /etc/caddy/Caddyfile && \ +systemctl daemon-reload && \ +systemctl reload caddy" # Restart lith service echo -e "$GREEN-> Restarting lith...$NC" diff --git a/lith/server.mjs b/lith/server.mjs index e3affcdf64..8f3838b9d7 100644 --- a/lith/server.mjs +++ b/lith/server.mjs @@ -107,10 +107,14 @@ function recordCall(name, ms, status, path, method, error) { } } +function captureRawBody(req, _res, buf) { + if (buf?.length) req.rawBody = Buffer.from(buf); +} + // --- Body parsing --- -app.use(express.json({ limit: "50mb" })); -app.use(express.urlencoded({ extended: true, limit: "50mb" })); -app.use(express.raw({ type: "*/*", limit: "50mb" })); +app.use(express.json({ limit: "50mb", verify: captureRawBody })); +app.use(express.urlencoded({ extended: true, limit: "50mb", verify: captureRawBody })); +app.use(express.raw({ type: "*/*", limit: "50mb", verify: captureRawBody })); // --- CORS (mirrors Netlify _headers) --- app.use((req, res, next) => { @@ -206,7 +210,7 @@ function toEvent(req) { httpMethod: req.method, headers: req.headers, body, - rawBody: req.body, + rawBody: req.rawBody ?? req.body, queryStringParameters: req.query || {}, path: req.path, rawUrl: `${req.protocol}://${req.get("host")}${req.originalUrl}`, @@ -309,14 +313,51 @@ async function handleFunctionResolved(req, res) { import { execFile } from "child_process"; import { createHmac, timingSafeEqual } from "crypto"; const DEPLOY_SECRET = process.env.DEPLOY_SECRET || ""; +const DEPLOY_BRANCHES = (process.env.DEPLOY_BRANCHES || process.env.DEPLOY_BRANCH || "main,master") + .split(",") + .map((branch) => branch.trim()) + .filter(Boolean); +const DEFAULT_DEPLOY_BRANCH = DEPLOY_BRANCHES[0] || "main"; let deployInProgress = false; +let queuedDeployBranch = null; + +function normalizeDeployBranch(branch) { + if (typeof branch !== "string") return null; + const trimmed = branch.trim(); + if (!trimmed) return null; + if (!/^[A-Za-z0-9._/-]+$/.test(trimmed)) return null; + return trimmed; +} + +function branchFromRef(ref) { + if (typeof ref !== "string") return null; + const prefix = "refs/heads/"; + if (!ref.startsWith(prefix)) return null; + return normalizeDeployBranch(ref.slice(prefix.length)); +} + +function requestedDeployBranch(req) { + const fromRef = branchFromRef(req.body?.ref); + if (fromRef) return fromRef; + return ( + normalizeDeployBranch(req.query.branch) || + normalizeDeployBranch(req.headers["x-deploy-branch"]) || + DEFAULT_DEPLOY_BRANCH + ); +} function verifyDeploy(req) { // GitHub HMAC signature (webhook secret) const sig = req.headers["x-hub-signature-256"]; if (sig && DEPLOY_SECRET) { + const rawBody = Buffer.isBuffer(req.rawBody) + ? req.rawBody + : Buffer.from( + typeof req.body === "string" ? req.body : JSON.stringify(req.body ?? {}), + "utf8", + ); const hmac = createHmac("sha256", DEPLOY_SECRET) - .update(JSON.stringify(req.body)) + .update(rawBody) .digest("hex"); const expected = `sha256=${hmac}`; if (sig.length === expected.length && @@ -329,33 +370,65 @@ function verifyDeploy(req) { return plain === DEPLOY_SECRET; } +function runDeploy(branch) { + deployInProgress = true; + console.log(`[deploy] starting branch=${branch}`); + + execFile( + "/opt/ac/lith/webhook.sh", + { + timeout: 120000, + env: { ...process.env, DEPLOY_BRANCH: branch }, + }, + (err, stdout, stderr) => { + deployInProgress = false; + + if (stdout?.trim()) { + console.log(`[deploy][${branch}] ${stdout.trim()}`); + } + if (stderr?.trim()) { + console.error(`[deploy][${branch}] ${stderr.trim()}`); + } + if (err) { + console.error(`[deploy] failed for ${branch}:`, err.message); + } + + if (queuedDeployBranch) { + const nextBranch = queuedDeployBranch; + queuedDeployBranch = null; + setImmediate(() => runDeploy(nextBranch)); + } + }, + ); +} + app.post("/lith/deploy", (req, res) => { if (!DEPLOY_SECRET || !verifyDeploy(req)) { return res.status(401).send("Unauthorized"); } - // Only deploy main branch pushes (GitHub sends ref in payload) + const githubEvent = req.headers["x-github-event"]; + if (githubEvent === "ping") { + return res.send("pong"); + } + if (githubEvent && githubEvent !== "push") { + return res.send(`Ignored GitHub event: ${githubEvent}`); + } + const ref = req.body?.ref; - if (ref && ref !== "refs/heads/main") { - return res.send(`Ignored non-main push: ${ref}`); + const branch = requestedDeployBranch(req); + if (!DEPLOY_BRANCHES.includes(branch)) { + const detail = ref || branch; + return res.send(`Ignored non-deploy branch: ${detail}`); } if (deployInProgress) { - return res.status(429).send("Deploy already in progress"); + queuedDeployBranch = branch; + return res.status(202).send(`Deploy queued for ${branch}`); } - deployInProgress = true; - res.send("Deploy started"); - - // Run async — don't block the event loop or the HTTP response - execFile("/opt/ac/lith/webhook.sh", { timeout: 120000 }, (err, stdout, stderr) => { - deployInProgress = false; - if (err) { - console.error("[deploy] failed:", err.message, stderr); - } else { - console.log("[deploy]", stdout); - } - }); + runDeploy(branch); + res.status(202).send(`Deploy started for ${branch}`); }); // --- Routes --- diff --git a/lith/webhook.sh b/lith/webhook.sh index e61b8a906e..c16d2b877e 100755 --- a/lith/webhook.sh +++ b/lith/webhook.sh @@ -15,23 +15,38 @@ set -euo pipefail REMOTE_DIR="/opt/ac" LOG_TAG="[lith-deploy]" +DEPLOY_BRANCH="${DEPLOY_BRANCH:-main}" log() { echo "$LOG_TAG $*"; } +if ! [[ "$DEPLOY_BRANCH" =~ ^[A-Za-z0-9._/-]+$ ]]; then + log "invalid DEPLOY_BRANCH: $DEPLOY_BRANCH" + exit 2 +fi + cd "$REMOTE_DIR" # Record HEAD before pull OLD_HEAD=$(git rev-parse HEAD) +OLD_BRANCH=$(git branch --show-current) # Pull latest -log "pulling..." -git fetch origin main --quiet -git reset --hard origin/main --quiet +log "pulling branch $DEPLOY_BRANCH..." +git fetch origin "$DEPLOY_BRANCH" --quiet + +if git show-ref --verify --quiet "refs/heads/$DEPLOY_BRANCH"; then + git checkout "$DEPLOY_BRANCH" --quiet +else + git checkout -B "$DEPLOY_BRANCH" "origin/$DEPLOY_BRANCH" --quiet +fi + +git reset --hard "origin/$DEPLOY_BRANCH" --quiet NEW_HEAD=$(git rev-parse HEAD) +NEW_BRANCH=$(git branch --show-current) if [ "$OLD_HEAD" = "$NEW_HEAD" ]; then - log "already up to date ($NEW_HEAD)" + log "already up to date on $NEW_BRANCH ($NEW_HEAD)" exit 0 fi @@ -40,7 +55,7 @@ echo "$NEW_HEAD" > system/public/.commit-ref # Get list of changed files CHANGED=$(git diff --name-only "$OLD_HEAD" "$NEW_HEAD") -log "updated $OLD_HEAD -> $NEW_HEAD" +log "updated $OLD_BRANCH/$OLD_HEAD -> $NEW_BRANCH/$NEW_HEAD" log "changed files:" echo "$CHANGED" | sed 's/^/ /' diff --git a/system/public/aesthetic.computer/disks/chat.mjs b/system/public/aesthetic.computer/disks/chat.mjs index e88ac4d7c9..cfe03be295 100644 --- a/system/public/aesthetic.computer/disks/chat.mjs +++ b/system/public/aesthetic.computer/disks/chat.mjs @@ -209,13 +209,14 @@ let modalJustOpened = false; // Prevent closing modal on the same click that ope let draftMessage = ""; // Store draft message text persistently // 📺 YouTube preview system -let youtubePreviewCache = new Map(); // Store loaded YouTube thumbnails -let youtubeLoadQueue = new Set(); // Track which videos are being loaded -let youtubeModalOpen = false; // Track if YouTube modal is open -let youtubeModalVideoId = null; // Current video in modal -let domApi = null; // Store dom API reference for modal -let netPreload = null; // Store net.preload reference for YouTube loading -let globalYoutubeThumbCache = null; +let youtubePreviewCache = new Map(); // Store loaded YouTube thumbnails +let youtubeLoadQueue = new Set(); // Track which videos are being loaded +let youtubeModalOpen = false; // Track if YouTube modal is open +let youtubeModalVideoId = null; // Current video in modal +let youtubeModalCleanup = null; // Remove modal listeners when the overlay closes +let domApi = null; // Store dom API reference for modal +let netPreload = null; // Store net.preload reference for YouTube loading +let globalYoutubeThumbCache = null; if (typeof globalThis !== "undefined") { if (!globalThis.__acYoutubeThumbCache) { @@ -3931,11 +3932,11 @@ async function loadYoutubePreview(videoId, preload) { } } -// 📺 Open YouTube modal with embed iframe -// On iOS, we skip the embed modal and open YouTube directly -// because the iframe embed has audio issues and tap-outside-to-close doesn't work reliably -// (All iOS browsers use WebKit and have the same iframe restrictions) -function openYoutubeModal(videoId) { +// 📺 Open YouTube modal with embed iframe +// On iOS, we skip the embed modal and open YouTube directly +// because the iframe embed has audio issues and tap-outside-to-close doesn't work reliably +// (All iOS browsers use WebKit and have the same iframe restrictions) +function openYoutubeModal(videoId) { if (!domApi) return; // On iOS, open YouTube directly instead of embed (audio doesn't work, touch handling issues) @@ -3944,34 +3945,29 @@ function openYoutubeModal(videoId) { return; } - if (youtubeModalOpen) { - const overlay = typeof document !== "undefined" - ? document.getElementById("youtube-modal-overlay") - : null; - if (overlay) return; - youtubeModalOpen = false; - youtubeModalVideoId = null; - } - - youtubeModalOpen = true; - youtubeModalVideoId = videoId; - if (typeof globalThis !== "undefined") { - globalThis.acYoutubeModalState = { open: true, videoId }; - globalThis.acYoutubeModalClose = () => { - youtubeModalOpen = false; - youtubeModalVideoId = null; - if (globalThis.acYoutubeModalState) { - globalThis.acYoutubeModalState.open = false; - globalThis.acYoutubeModalState.videoId = null; - } - }; - } - - domApi.html` - -
-
- - -
-
tap outside to close
-
- - `; -} - -// 📺 Close YouTube modal -function closeYoutubeModal() { - youtubeModalOpen = false; - youtubeModalVideoId = null; - // The DOM cleanup happens via the onclick/escape handlers in the HTML -} + +
tap outside to close
+ + `; + + const overlay = typeof document !== "undefined" + ? document.getElementById("youtube-modal-overlay") + : null; + const closeButton = typeof document !== "undefined" + ? document.getElementById("youtube-modal-close") + : null; + const tapHint = typeof document !== "undefined" + ? document.getElementById("youtube-modal-tap-hint") + : null; + + if (!overlay || !closeButton) { + closeYoutubeModal(); + return; + } + + youtubeModalCleanup?.(); + + const closeFromPointer = (event) => { + event.preventDefault(); + event.stopPropagation(); + closeYoutubeModal(); + }; + + const closeFromOverlay = (event) => { + if (event.target !== overlay && event.target !== tapHint) return; + closeFromPointer(event); + }; + + overlay.addEventListener("pointerup", closeFromOverlay); + closeButton.addEventListener("pointerup", closeFromPointer); + + youtubeModalCleanup = () => { + overlay.removeEventListener("pointerup", closeFromOverlay); + closeButton.removeEventListener("pointerup", closeFromPointer); + youtubeModalCleanup = null; + }; +} + +// 📺 Close YouTube modal +function closeYoutubeModal() { + youtubeModalCleanup?.(); + youtubeModalOpen = false; + youtubeModalVideoId = null; + if (typeof document !== "undefined") { + document.getElementById("youtube-modal-overlay")?.remove(); + } + if (typeof globalThis !== "undefined") { + globalThis.acYoutubeModalClose = closeYoutubeModal; + globalThis.acCloseYoutubeModal = closeYoutubeModal; + if (globalThis.acYoutubeModalState) { + globalThis.acYoutubeModalState.open = false; + globalThis.acYoutubeModalState.videoId = null; + } + } +} function computeMessagesHeight({ text, screen, typeface }, chat, defaultTypefaceName, defaultRowHeight) { let height = 0; @@ -4838,4 +4868,4 @@ function paintR8dioPlayer($, theme) { // Volume slider removed for slim design - could add keyboard shortcuts later r8dioVolSliderBounds = null; -} \ No newline at end of file +} -- 2.51.2