diff --git a/dashcaddy-api/package.json b/dashcaddy-api/package.json index 9de8770..f68faba 100644 --- a/dashcaddy-api/package.json +++ b/dashcaddy-api/package.json @@ -1,6 +1,6 @@ { "name": "dashcaddy-api", - "version": "1.1.5", + "version": "1.2.0", "description": "DashCaddy API server - Dashboard backend for Docker, Caddy & DNS management", "main": "server.js", "scripts": { diff --git a/dashcaddy-api/self-updater.js b/dashcaddy-api/self-updater.js index a84ae8a..778bd7c 100644 --- a/dashcaddy-api/self-updater.js +++ b/dashcaddy-api/self-updater.js @@ -311,13 +311,25 @@ class SelfUpdater extends EventEmitter { // Delete the result file so we don't process it again await fsp.unlink(resultPath).catch(() => {}); - // Update history + // Update the matching history entry, preferring the newest pending item + // for the same target version. Fall back to the newest pending item if + // older result files lack enough metadata to match more precisely. const history = this.getUpdateHistory(); - const pending = history.find(h => h.status === 'pending'); - if (pending) { + const pendingIndex = history.findIndex( + h => h.status === 'pending' && (!result.version || h.version === result.version) + ); + const fallbackIndex = pendingIndex === -1 + ? history.findIndex(h => h.status === 'pending') + : -1; + const historyIndex = pendingIndex !== -1 ? pendingIndex : fallbackIndex; + + if (historyIndex !== -1) { + const pending = history[historyIndex]; pending.status = result.success ? 'success' : 'rolled-back'; pending.duration = result.duration; if (result.error) pending.error = result.error; + if (result.version) pending.version = result.version; + if (result.timestamp) pending.completedAt = result.timestamp; this._saveHistory(history); } diff --git a/release-test/latest.tar.gz b/release-test/latest.tar.gz new file mode 100644 index 0000000..57577ef Binary files /dev/null and b/release-test/latest.tar.gz differ diff --git a/release-test/latest.tar.gz.sha256 b/release-test/latest.tar.gz.sha256 new file mode 100644 index 0000000..424ea8f --- /dev/null +++ b/release-test/latest.tar.gz.sha256 @@ -0,0 +1 @@ +a8a670311c60172ef38098819436a6ff081e5379da9e3ab18a291c74a1ea4f5c latest.tar.gz diff --git a/release-test/version.json b/release-test/version.json new file mode 100644 index 0000000..5463cb7 --- /dev/null +++ b/release-test/version.json @@ -0,0 +1,9 @@ +{ + "version": "1.2.0", + "commit": "a216dd8", + "channel": "stable", + "url": "https://get.dashcaddy.net/release/latest.tar.gz", + "sha256": "a8a670311c60172ef38098819436a6ff081e5379da9e3ab18a291c74a1ea4f5c", + "publishedAt": "2026-05-06T23:06:32Z", + "changelog": "Release published via scripts/publish-release.sh" +} diff --git a/scripts/prepare-release.sh b/scripts/prepare-release.sh new file mode 100755 index 0000000..4dce612 --- /dev/null +++ b/scripts/prepare-release.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +STATUS_DIR="${ROOT_DIR}/status" +API_DIR="${ROOT_DIR}/dashcaddy-api" + +log() { echo "[prepare-release] $*"; } +fail() { echo "[prepare-release] ERROR: $*" >&2; exit 1; } + +[[ -f "${STATUS_DIR}/package.json" ]] || fail "Missing status/package.json" +[[ -f "${API_DIR}/package.json" ]] || fail "Missing dashcaddy-api/package.json" + +log "Building dashboard frontend..." +cd "${STATUS_DIR}" +npm run build + +log "Verifying CSP hash present in status/index.html..." +grep -q "Content-Security-Policy" "${STATUS_DIR}/index.html" || fail "Missing CSP meta tag" +grep -q "sha256-" "${STATUS_DIR}/index.html" || fail "Missing CSP script hash" +grep -q "license-topbar-version" "${STATUS_DIR}/index.html" || fail "Expected version-chip markup missing" + +log "Verifying API version metadata..." +node -e "const fs=require('fs'); const pkg=JSON.parse(fs.readFileSync('${API_DIR}/package.json','utf8')); if(!pkg.version) process.exit(1); console.log('[prepare-release] API version', pkg.version);" + +log "Release prep complete. Package from repo root after this step." diff --git a/scripts/publish-release.sh b/scripts/publish-release.sh new file mode 100755 index 0000000..496f5fb --- /dev/null +++ b/scripts/publish-release.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +API_DIR="${ROOT_DIR}/dashcaddy-api" +STATUS_DIR="${ROOT_DIR}/status" +PREP_SCRIPT="${ROOT_DIR}/scripts/prepare-release.sh" +OUT_DIR="${1:-${ROOT_DIR}/release}" +CHANNEL="${CHANNEL:-stable}" +BASE_URL="${BASE_URL:-https://get.dashcaddy.net/release}" +CHANGELOG_FILE="${CHANGELOG_FILE:-}" + +log() { echo "[publish-release] $*"; } +fail() { echo "[publish-release] ERROR: $*" >&2; exit 1; } + +[[ -x "$PREP_SCRIPT" ]] || fail "Missing prepare script: $PREP_SCRIPT" +[[ -f "${API_DIR}/package.json" ]] || fail "Missing API package.json" + +VERSION="$(node -pe "require('${API_DIR}/package.json').version")" +COMMIT="$(git -C "${ROOT_DIR}" rev-parse --short HEAD)" +TARBALL_NAME="latest.tar.gz" +VERSION_JSON="version.json" +SHA256_FILE="latest.tar.gz.sha256" + +mkdir -p "$OUT_DIR" +rm -f "${OUT_DIR:?}/${TARBALL_NAME}" "${OUT_DIR}/${VERSION_JSON}" "${OUT_DIR}/${SHA256_FILE}" + +log "Preparing release inputs..." +"$PREP_SCRIPT" + +TMP_DIR="$(mktemp -d)" +cleanup() { rm -rf "$TMP_DIR"; } +trap cleanup EXIT + +STAGE_DIR="${TMP_DIR}/dashcaddy" +mkdir -p "$STAGE_DIR" + +log "Staging API and dashboard files..." +cp -a "${API_DIR}" "$STAGE_DIR/dashcaddy-api" +cp -a "${STATUS_DIR}" "$STAGE_DIR/status" +cp -a "${ROOT_DIR}/ca" "$STAGE_DIR/ca" 2>/dev/null || true +cp -a "${ROOT_DIR}/dashcaddy-installer" "$STAGE_DIR/dashcaddy-installer" 2>/dev/null || true +cp -a "${ROOT_DIR}/README.md" "$STAGE_DIR/README.md" 2>/dev/null || true + +find "$STAGE_DIR" \( -name node_modules -o -name .git -o -name coverage -o -name .nyc_output \) -prune -exec rm -rf {} + +find "$STAGE_DIR" -type f \( -name '*.test.js' -o -name '*.spec.js' \) -delete + +log "Creating tarball..." +tar -czf "${OUT_DIR}/${TARBALL_NAME}" -C "$TMP_DIR" dashcaddy +SHA256="$(sha256sum "${OUT_DIR}/${TARBALL_NAME}" | awk '{print $1}')" +printf '%s %s\n' "$SHA256" "$TARBALL_NAME" > "${OUT_DIR}/${SHA256_FILE}" + +CHANGELOG="" +if [[ -n "$CHANGELOG_FILE" && -f "$CHANGELOG_FILE" ]]; then + CHANGELOG="$(python3 - <<'PY' "$CHANGELOG_FILE" +from pathlib import Path +import json, sys +print(json.dumps(Path(sys.argv[1]).read_text().strip())) +PY +)" +else + CHANGELOG='"Release published via scripts/publish-release.sh"' +fi + +log "Writing version manifest..." +cat > "${OUT_DIR}/${VERSION_JSON}" < path.join(__dirname, 'js', ...parts); const DIST = path.join(__dirname, 'dist'); +const INDEX_HTML = path.join(__dirname, 'index.html'); // Bundle definitions — files are concatenated in order, then minified const bundles = { @@ -64,6 +66,32 @@ const bundles = { ], }; +function updateInlineScriptCspHash() { + const html = fs.readFileSync(INDEX_HTML, 'utf8'); + const scripts = [...html.matchAll(/ diff --git a/status/js/update-management.js b/status/js/update-management.js index 2ae6e98..7695935 100644 --- a/status/js/update-management.js +++ b/status/js/update-management.js @@ -396,7 +396,7 @@ } async function dcApplyUpdate() { - if (!confirm('Apply DashCaddy update? The API container will restart.')) return; + if (!confirm('Apply DashCaddy update? The API container will restart.')) return false; dcApplyBtn.textContent = 'Updating...'; dcApplyBtn.disabled = true; dcShowStatus('Downloading and applying update...', 'info'); @@ -408,6 +408,7 @@ dcApplyBtn.textContent = 'Applied!'; // Remove notification dots document.querySelectorAll('.update-dot').forEach(d => d.remove()); + return true; } else { throw new Error(data.error || 'Update failed'); } @@ -415,6 +416,7 @@ dcShowStatus('Update failed: ' + e.message, 'error'); dcApplyBtn.textContent = 'Update Now'; dcApplyBtn.disabled = false; + throw e; } } @@ -486,7 +488,7 @@ } dcCheckBtn?.addEventListener('click', () => dcCheckForUpdate(false)); - dcApplyBtn?.addEventListener('click', dcApplyUpdate); + dcApplyBtn?.addEventListener('click', () => dcApplyUpdate().catch(() => {})); dcRollbackBtn?.addEventListener('click', dcShowRollback); checkBtn?.addEventListener('click', checkForUpdates); @@ -505,6 +507,9 @@ if (!dcLastCheck) dcCheckForUpdate(true); }); + window.dcApplyUpdate = dcApplyUpdate; + window.dcCheckForUpdate = dcCheckForUpdate; + // Non-blocking check on page load — just adds notification dot if update available setTimeout(() => dcCheckForUpdate(true), 5000); })();