05/19 Enhance codes 4

This commit is contained in:
2026-05-19 10:35:34 -04:00
parent 3cdcbbe8f7
commit abea19d9dd
4 changed files with 460 additions and 53 deletions
+196
View File
@@ -0,0 +1,196 @@
# .gitea/workflows/ci.yml
#
# PassKeeper CI pipeline — runs on every push and pull request.
#
# Jobs:
# lint-python — flake8 style + error check
# syntax-check — ast.parse all Python files (catches import-time errors)
# migration-check — verify Alembic chain has a single head, no duplicates
# js-syntax — node --check on all JS files
# build-extension — zip the extension for distribution
name: CI
on:
push:
branches: ["main", "master", "dev"]
pull_request:
branches: ["main", "master"]
jobs:
# ── Python lint ──────────────────────────────────────────────────────────────
lint-python:
name: Python lint (flake8)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: "3.12"
- name: Install flake8
run: pip install flake8
- name: Run flake8
run: |
flake8 app/ \
--max-line-length=120 \
--extend-ignore=E501,W503 \
--exclude=__pycache__,migrations \
--statistics
# ── Python syntax (ast.parse — catches broken imports fast) ─────────────────
syntax-check:
name: Python syntax check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: "3.12"
- name: Check all Python files parse cleanly
run: |
python3 - << 'EOF'
import ast, sys, pathlib
failures = []
for path in pathlib.Path('app').rglob('*.py'):
try:
ast.parse(path.read_text())
except SyntaxError as e:
failures.append(f"{path}: {e}")
for f in failures:
print(f"FAIL: {f}")
if failures:
sys.exit(1)
print(f"OK: {len(list(pathlib.Path('app').rglob('*.py')))} Python files parsed cleanly")
EOF
# ── Alembic migration chain check ───────────────────────────────────────────
migration-check:
name: Alembic migration chain
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: "3.12"
- name: Install alembic
run: pip install alembic
- name: Verify single head, linear chain, no duplicate revisions
run: |
python3 - << 'EOF'
import re, sys, glob
files = glob.glob('migrations/versions/*.py')
revisions = {}
for f in files:
content = open(f).read()
rev = re.search(r"revision = '([^']+)'", content)
down = re.search(r"down_revision = (.+)", content)
if rev:
rid = rev.group(1)
if rid in revisions:
print(f"FAIL: Duplicate revision ID {rid} in {f}")
sys.exit(1)
revisions[rid] = down.group(1).strip() if down else 'None'
all_downs = set(revisions.values())
heads = [r for r in revisions if repr(r) not in all_downs and r not in all_downs]
if len(heads) != 1:
print(f"FAIL: Expected 1 head, found {len(heads)}: {heads}")
sys.exit(1)
print(f"OK: {len(revisions)} migrations, single head: {heads[0]}")
EOF
# ── JS syntax check ──────────────────────────────────────────────────────────
js-syntax:
name: JavaScript syntax check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up Node
uses: actions/setup-node@v3
with:
node-version: "20"
- name: Check JS files parse cleanly
run: |
FAILED=0
for f in \
app/static/js/vault.js \
app/static/js/auth.js \
app/static/js/crypto.js \
app/static/js/sharing.js \
app/static/js/recover.js \
extension/popup/popup.js \
extension/background.js \
extension/background.firefox.js \
extension/content/content.js \
extension/bridge/bridge.js \
extension/shared/crypto.js; do
if [ -f "$f" ]; then
node --input-type=module < "$f" 2>/dev/null || \
node -e "new Function(require('fs').readFileSync('$f','utf8'))" 2>/dev/null || \
{ echo "FAIL: $f"; FAILED=1; }
fi
done
[ $FAILED -eq 0 ] && echo "OK: all JS files parsed cleanly"
exit $FAILED
# ── Extension build ──────────────────────────────────────────────────────────
build-extension:
name: Build extension zip
runs-on: ubuntu-latest
needs: [syntax-check, js-syntax]
steps:
- uses: actions/checkout@v3
- name: Build Chrome/Edge extension
run: |
cd extension
zip -r ../passkeeper-extension-chrome.zip . \
--exclude "*.bak" \
--exclude "manifest.firefox.json" \
--exclude "background.firefox.js"
echo "Chrome extension: $(du -sh ../passkeeper-extension-chrome.zip | cut -f1)"
- name: Build Firefox extension
run: |
cd extension
# Swap manifests for Firefox build
cp manifest.json manifest.chrome.json
cp manifest.firefox.json manifest.json
zip -r ../passkeeper-extension-firefox.zip . \
--exclude "*.bak" \
--exclude "manifest.chrome.json" \
--exclude "background.js"
mv manifest.chrome.json manifest.json
echo "Firefox extension: $(du -sh ../passkeeper-extension-firefox.zip | cut -f1)"
- name: Upload Chrome extension artifact
uses: actions/upload-artifact@v3
with:
name: passkeeper-extension-chrome
path: passkeeper-extension-chrome.zip
retention-days: 30
- name: Upload Firefox extension artifact
uses: actions/upload-artifact@v3
with:
name: passkeeper-extension-firefox
path: passkeeper-extension-firefox.zip
retention-days: 30
+37 -1
View File
@@ -262,4 +262,40 @@ def import_items():
ip_address=client_ip(), ip_address=client_ip(),
) )
db.session.commit() db.session.commit()
return jsonify({'imported': imported, 'skipped': skipped}), 200 return jsonify({'imported': imported, 'skipped': skipped}), 200
@vault_bp.route('/audit-export', methods=['POST'])
@limiter.limit('30 per minute')
@require_jwt
def audit_bulk_export():
"""
Record a client-side bulk export in the audit log.
The bulk export is built entirely in the browser (no server round-trip),
so the server calls this endpoint after the download is triggered.
Accepts a JSON body: { "item_ids": [int, ...] }
Validates that every supplied ID belongs to the current user before logging.
"""
data = request.get_json(silent=True) or {}
raw_ids = data.get('item_ids', [])
if not isinstance(raw_ids, list):
return jsonify({'error': 'item_ids must be an array'}), 400
# Validate ownership — only log IDs that belong to the current user.
valid_ids = [
item.id for item in VaultItem.query.filter(
VaultItem.id.in_(raw_ids),
VaultItem.user_id == g.current_user_id,
).all()
]
AuditLog.log(
user_id=g.current_user_id,
action='vault_item.export_selection',
resource_type='vault_item',
resource_id=None,
detail=f'Bulk exported {len(valid_ids)} selected item(s) (ids: {sorted(valid_ids)[:20]})',
ip_address=client_ip(),
)
db.session.commit()
return jsonify({'logged': len(valid_ids)}), 200
+73 -52
View File
@@ -480,8 +480,8 @@ const Vault = (() => {
_sortOrder === "folder" _sortOrder === "folder"
? Object.keys(groups).sort() ? Object.keys(groups).sort()
: ["(No folder)", ..._folders.map((f) => f.name)].filter( : ["(No folder)", ..._folders.map((f) => f.name)].filter(
(k) => groups[k], (k) => groups[k],
); );
Object.keys(groups).forEach((k) => { Object.keys(groups).forEach((k) => {
if (!keys.includes(k)) keys.push(k); if (!keys.includes(k)) keys.push(k);
}); });
@@ -549,7 +549,7 @@ const Vault = (() => {
case "card": case "card":
subText = item.plain.card_number subText = item.plain.card_number
? "•••• " + ? "•••• " +
String(item.plain.card_number).replace(/\s/g, "").slice(-4) String(item.plain.card_number).replace(/\s/g, "").slice(-4)
: ""; : "";
break; break;
case "bank": case "bank":
@@ -891,6 +891,11 @@ const Vault = (() => {
document.body.removeChild(a); document.body.removeChild(a);
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
showToast(`${selected.length} item${selected.length !== 1 ? "s" : ""} exported`); showToast(`${selected.length} item${selected.length !== 1 ? "s" : ""} exported`);
// Log the export server-side for the audit trail.
apiFetch("/api/vault/audit-export", {
method: "POST",
body: JSON.stringify({ item_ids: selected.map((i) => i.id) }),
}).catch(() => {}); // best-effort — don't block on this
_exitSelectMode(); _exitSelectMode();
} }
@@ -1125,9 +1130,9 @@ const Vault = (() => {
0, 0,
Math.round( Math.round(
100 - 100 -
(weak.length / total) * 40 - (weak.length / total) * 40 -
(reused.length / total) * 30 - (reused.length / total) * 30 -
(old.length / total) * 15, (old.length / total) * 15,
), ),
); );
const cls = const cls =
@@ -1277,16 +1282,16 @@ const Vault = (() => {
</div> </div>
<ul class="sec-item-list"> <ul class="sec-item-list">
${breached ${breached
.map((i) => { .map((i) => {
const count = const count =
hibpResults.find((r) => r.item.id === i.id)?.count || 0; hibpResults.find((r) => r.item.id === i.id)?.count || 0;
return `<li class="sec-item"> return `<li class="sec-item">
<span class="sec-item-name">${escHtml(i.name)}</span> <span class="sec-item-name">${escHtml(i.name)}</span>
<span class="sec-item-sub">${escHtml(i.plain?.username || "")} — seen ${count.toLocaleString()} time${count !== 1 ? "s" : ""} in breaches</span> <span class="sec-item-sub">${escHtml(i.plain?.username || "")} — seen ${count.toLocaleString()} time${count !== 1 ? "s" : ""} in breaches</span>
<button class="btn-secondary btn-sm" data-sec-edit="${i.id}">Change</button> <button class="btn-secondary btn-sm" data-sec-edit="${i.id}">Change</button>
</li>`; </li>`;
}) })
.join("")} .join("")}
</ul>`; </ul>`;
hibpSection.querySelectorAll("[data-sec-edit]").forEach((btn) => { hibpSection.querySelectorAll("[data-sec-edit]").forEach((btn) => {
btn.addEventListener("click", () => { btn.addEventListener("click", () => {
@@ -1848,9 +1853,10 @@ const Vault = (() => {
<span class="share-name">${escHtml(s.item_name)}</span> <span class="share-name">${escHtml(s.item_name)}</span>
<span class="share-meta">From ${escHtml(s.owner_email)}${expiryLabel}</span> <span class="share-meta">From ${escHtml(s.owner_email)}${expiryLabel}</span>
</div> </div>
${!s.accepted ${
? `<button class="btn-primary btn-sm" data-accept="${s.id}">Accept</button>` !s.accepted
: `<button class="btn-secondary btn-sm" ? `<button class="btn-primary btn-sm" data-accept="${s.id}">Accept</button>`
: `<button class="btn-secondary btn-sm"
data-view-share="${s.id}" data-view-share="${s.id}"
data-owner-key="${escHtml(s.owner_public_key || "")}" data-owner-key="${escHtml(s.owner_public_key || "")}"
data-enc="${escHtml(s.enc_data)}" data-enc="${escHtml(s.enc_data)}"
@@ -1859,7 +1865,7 @@ const Vault = (() => {
data-iv-name="${escHtml(s.iv_name || "")}" data-iv-name="${escHtml(s.iv_name || "")}"
data-name="${escHtml(s.item_name)}" data-name="${escHtml(s.item_name)}"
data-type="${escHtml(s.item_type || "")}">View</button>` data-type="${escHtml(s.item_type || "")}">View</button>`
} }
</li>`; </li>`;
}, },
) )
@@ -2401,9 +2407,10 @@ const Vault = (() => {
<span class="em-vault-chevron">▸</span> <span class="em-vault-chevron">▸</span>
</button> </button>
<div class="em-vault-item-body hidden" id="em-vault-body-${idx}"> <div class="em-vault-item-body hidden" id="em-vault-body-${idx}">
${hasFields ${
? '<div class="detail-body em-detail-body"></div>' hasFields
: '<p class="vault-empty">Could not decrypt this item.</p>' ? '<div class="detail-body em-detail-body"></div>'
: '<p class="vault-empty">Could not decrypt this item.</p>'
} }
</div> </div>
</li>`; </li>`;
@@ -2732,8 +2739,8 @@ const Vault = (() => {
const typeLabel = transports.includes("internal") const typeLabel = transports.includes("internal")
? "📱 Device" ? "📱 Device"
: transports.some((t) => ["usb", "nfc", "ble", "smart-card"].includes(t)) : transports.some((t) => ["usb", "nfc", "ble", "smart-card"].includes(t))
? "🔑 Security key" ? "🔑 Security key"
: "🔑 Passkey"; : "🔑 Passkey";
return `<div class="passkey-item" data-cred-id="${c.id}"> return `<div class="passkey-item" data-cred-id="${c.id}">
<div class="passkey-info"> <div class="passkey-info">
<span class="passkey-name">${escHtml(c.name)}</span> <span class="passkey-name">${escHtml(c.name)}</span>
@@ -2996,15 +3003,16 @@ const Vault = (() => {
<h3 style="margin:0 0 8px;font-size:16px;color:#111827;"> <h3 style="margin:0 0 8px;font-size:16px;color:#111827;">
${isFirstTime ? "🔐 MFA enabled — save your backup codes" : "🔐 New backup codes"} ${isFirstTime ? "🔐 MFA enabled — save your backup codes" : "🔐 New backup codes"}
</h3> </h3>
${isFirstTime ${
? `<p style="font-size:13px;color:#374151;margin-bottom:12px;"> isFirstTime
? `<p style="font-size:13px;color:#374151;margin-bottom:12px;">
These codes let you sign in if you lose access to your authenticator app. These codes let you sign in if you lose access to your authenticator app.
<strong>Save them now — they will not be shown again.</strong> <strong>Save them now — they will not be shown again.</strong>
</p>` </p>`
: `<p style="font-size:13px;color:#374151;margin-bottom:12px;"> : `<p style="font-size:13px;color:#374151;margin-bottom:12px;">
Your previous codes have been invalidated. Save these new codes securely. Your previous codes have been invalidated. Save these new codes securely.
</p>` </p>`
} }
<div style="background:#f9fafb;border:1px solid #e5e7eb;border-radius:8px;padding:12px;display:flex;flex-wrap:wrap;gap:6px;margin-bottom:16px;"> <div style="background:#f9fafb;border:1px solid #e5e7eb;border-radius:8px;padding:12px;display:flex;flex-wrap:wrap;gap:6px;margin-bottom:16px;">
${codesHtml} ${codesHtml}
</div> </div>
@@ -3513,14 +3521,14 @@ const Vault = (() => {
const pool = !_activeFilter const pool = !_activeFilter
? _items ? _items
: _activeFilter.type === "itemType" : _activeFilter.type === "itemType"
? _items.filter((i) => i.item_type === _activeFilter.value) ? _items.filter((i) => i.item_type === _activeFilter.value)
: _activeFilter.type === "folder" : _activeFilter.type === "folder"
? _items.filter((i) => i.folder_id === _activeFilter.value) ? _items.filter((i) => i.folder_id === _activeFilter.value)
: _activeFilter.type === "tag" : _activeFilter.type === "tag"
? _items.filter((i) => ? _items.filter((i) =>
(i.plain?.tags || []).includes(_activeFilter.value), (i.plain?.tags || []).includes(_activeFilter.value),
) )
: _items; : _items;
renderItemList( renderItemList(
pool.filter((item) => { pool.filter((item) => {
const p = item.plain || {}; const p = item.plain || {};
@@ -3682,36 +3690,49 @@ const Vault = (() => {
const history = item.plain?.password_history || []; const history = item.plain?.password_history || [];
if (history.length) { if (history.length) {
historyPanel.classList.remove("hidden"); historyPanel.classList.remove("hidden");
// Store plaintext passwords in a WeakMap keyed by button element so
// they never appear in the DOM (no data-pw attributes to scrape).
const _historyPasswords = new WeakMap();
historyPanel.innerHTML = historyPanel.innerHTML =
`<div class="pw-history-title">🕐 Previous passwords (${history.length})</div>` + `<div class="pw-history-title">🕐 Previous passwords (${history.length})</div>` +
history.map((h) => { history.map((h, idx) => {
const date = h.changed_at const date = h.changed_at
? new Date(h.changed_at).toLocaleDateString() ? new Date(h.changed_at).toLocaleDateString()
: "Unknown date"; : "Unknown date";
return `<div class="pw-history-row"> return `<div class="pw-history-row" data-hist-idx="${idx}">
<span class="pw-history-masked">••••••••</span> <span class="pw-history-masked">••••••••</span>
<span class="pw-history-date">${escHtml(date)}</span> <span class="pw-history-date">${escHtml(date)}</span>
<button class="btn-text btn-sm pw-history-reveal" data-pw="${escHtml(h.password || "")}" title="Reveal">👁</button> <button class="btn-text btn-sm pw-history-reveal" title="Reveal">👁</button>
<button class="btn-text btn-sm pw-history-restore" data-pw="${escHtml(h.password || "")}" title="Restore this password">↩ Restore</button> <button class="btn-text btn-sm pw-history-restore" title="Restore this password">↩ Restore</button>
</div>`; </div>`;
}).join(""); }).join("");
historyPanel.querySelectorAll(".pw-history-reveal").forEach((btn) => {
btn.addEventListener("click", () => { // Register passwords in the WeakMap after DOM elements exist.
const masked = btn.closest(".pw-history-row").querySelector(".pw-history-masked"); historyPanel.querySelectorAll(".pw-history-row").forEach((row) => {
const idx = parseInt(row.dataset.histIdx);
const pw = history[idx]?.password || "";
const revealBtn = row.querySelector(".pw-history-reveal");
const restoreBtn = row.querySelector(".pw-history-restore");
_historyPasswords.set(revealBtn, pw);
_historyPasswords.set(restoreBtn, pw);
revealBtn.addEventListener("click", () => {
const masked = row.querySelector(".pw-history-masked");
if (masked.textContent === "••••••••") { if (masked.textContent === "••••••••") {
masked.textContent = btn.dataset.pw; masked.textContent = _historyPasswords.get(revealBtn);
btn.textContent = "🙈"; revealBtn.textContent = "🙈";
} else { } else {
masked.textContent = "••••••••"; masked.textContent = "••••••••";
btn.textContent = "👁"; revealBtn.textContent = "👁";
} }
}); });
});
historyPanel.querySelectorAll(".pw-history-restore").forEach((btn) => { restoreBtn.addEventListener("click", () => {
btn.addEventListener("click", () => {
const pwField = document.getElementById("field-password"); const pwField = document.getElementById("field-password");
if (pwField) { if (pwField) {
pwField.value = btn.dataset.pw; pwField.value = _historyPasswords.get(restoreBtn);
pwField.dispatchEvent(new Event("input")); pwField.dispatchEvent(new Event("input"));
showToast("Password restored — click Save to apply"); showToast("Password restored — click Save to apply");
} }
@@ -3729,7 +3750,7 @@ const Vault = (() => {
if ( if (
(mode === "add" && (mode === "add" &&
(document.getElementById("field-type").value || "password") === (document.getElementById("field-type").value || "password") ===
"password") || "password") ||
(mode === "edit" && (item?.item_type || "password") === "password") (mode === "edit" && (item?.item_type || "password") === "password")
) { ) {
initPasswordFieldEnhancements(); initPasswordFieldEnhancements();
@@ -3994,7 +4015,7 @@ const Vault = (() => {
"X-CSRFToken": csrfToken(), "X-CSRFToken": csrfToken(),
}, },
body: JSON.stringify({ refresh_token: refreshToken }), body: JSON.stringify({ refresh_token: refreshToken }),
}).catch(() => { }); }).catch(() => {});
VaultSession.clear(); VaultSession.clear();
SharingSession.clear(); SharingSession.clear();
sessionStorage.removeItem("access_token"); sessionStorage.removeItem("access_token");
@@ -4099,11 +4120,11 @@ const Vault = (() => {
// Auto-clear clipboard after 30 seconds — industry-standard hygiene. // Auto-clear clipboard after 30 seconds — industry-standard hygiene.
if (_clipboardClearTimer) clearTimeout(_clipboardClearTimer); if (_clipboardClearTimer) clearTimeout(_clipboardClearTimer);
_clipboardClearTimer = setTimeout(() => { _clipboardClearTimer = setTimeout(() => {
navigator.clipboard.writeText("").catch(() => { }); navigator.clipboard.writeText("").catch(() => {});
_clipboardClearTimer = null; _clipboardClearTimer = null;
}, 30_000); }, 30_000);
}) })
.catch(() => { }); .catch(() => {});
} }
function escHtml(str) { function escHtml(str) {
@@ -4723,4 +4744,4 @@ const Vault = (() => {
} }
})(); })();
document.addEventListener("DOMContentLoaded", Vault.init); document.addEventListener("DOMContentLoaded", Vault.init);
+154
View File
@@ -0,0 +1,154 @@
#!/usr/bin/env bash
# /home/spuser/PassKeeper/scripts/restore_db.sh
#
# Restore a PassKeeper MySQL backup created by backup_db.sh.
#
# SAFETY FEATURES:
# - Requires explicit confirmation before overwriting the live database.
# - Creates a pre-restore safety dump of the current DB before touching it.
# - Verifies the backup file is a valid gzip before proceeding.
# - Stops the PassKeeper service before restore, restarts it after.
# - Runs flask db upgrade after restore to ensure the schema is current.
#
# Usage:
# bash scripts/restore_db.sh /path/to/passkeeper_20260101_020000.sql.gz
#
# Dry-run (verify only, no changes):
# DRY_RUN=1 bash scripts/restore_db.sh /path/to/backup.sql.gz
set -euo pipefail
# ── Configuration ──────────────────────────────────────────────────────────────
SCRIPT_DIR="$(dirname "$(realpath "$0")")"
PROJECT_DIR="$(realpath "$SCRIPT_DIR/..")"
ENV_FILE="$PROJECT_DIR/.env"
SERVICE_NAME="${PASSKEEPER_SERVICE:-passkeeper}"
BACKUP_DIR="${BACKUP_DIR:-/home/spuser/backups/passkeeper}"
LOG_FILE="${BACKUP_DIR}/restore.log"
DRY_RUN="${DRY_RUN:-0}"
# ── Helpers ────────────────────────────────────────────────────────────────────
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG_FILE"; }
die() { log "ERROR $*"; exit 1; }
info() { log "INFO $*"; }
# ── Argument check ─────────────────────────────────────────────────────────────
[[ $# -lt 1 ]] && die "Usage: $0 <backup_file.sql.gz>"
BACKUP_FILE="$(realpath "$1")"
[[ -f "$BACKUP_FILE" ]] || die "Backup file not found: $BACKUP_FILE"
# ── Load .env ─────────────────────────────────────────────────────────────────
[[ -f "$ENV_FILE" ]] || die ".env file not found at $ENV_FILE"
set -o allexport
# shellcheck disable=SC1090
source "$ENV_FILE"
set +o allexport
DB_HOST="${MYSQL_HOST:-127.0.0.1}"
DB_PORT="${MYSQL_PORT:-3306}"
DB_USER="${MYSQL_USER:-passkeeper}"
DB_PASS="${MYSQL_PASSWORD}"
DB_NAME="${MYSQL_DB:-passkeeper}"
mkdir -p "$BACKUP_DIR"
info "=== PassKeeper database restore ==="
info "Backup file : $BACKUP_FILE"
info "Target DB : $DB_NAME @ $DB_HOST:$DB_PORT"
info "Service : $SERVICE_NAME"
[[ "$DRY_RUN" == "1" ]] && info "MODE : DRY RUN — no changes will be made"
# ── Validate backup file ───────────────────────────────────────────────────────
info "Validating backup file integrity…"
if ! gzip -t "$BACKUP_FILE" 2>/dev/null; then
die "Backup file failed gzip integrity check: $BACKUP_FILE"
fi
BACKUP_SIZE="$(du -sh "$BACKUP_FILE" | cut -f1)"
info "Backup size : $BACKUP_SIZE — gzip OK"
# ── Confirmation ───────────────────────────────────────────────────────────────
if [[ "$DRY_RUN" != "1" ]]; then
echo ""
echo " ⚠️ WARNING: This will OVERWRITE the live database '$DB_NAME'."
echo " All current data will be replaced with the contents of:"
echo " $BACKUP_FILE"
echo ""
read -r -p " Type 'yes' to continue: " CONFIRM
[[ "$CONFIRM" == "yes" ]] || { info "Restore cancelled by user."; exit 0; }
fi
[[ "$DRY_RUN" == "1" ]] && { info "Dry run complete — backup file is valid."; exit 0; }
# ── Pre-restore safety dump ────────────────────────────────────────────────────
SAFETY_TIMESTAMP="$(date +%Y%m%d_%H%M%S)"
SAFETY_FILE="${BACKUP_DIR}/pre_restore_safety_${SAFETY_TIMESTAMP}.sql.gz"
info "Creating pre-restore safety dump → $SAFETY_FILE"
MYSQL_PWD="$DB_PASS" mysqldump \
--host="$DB_HOST" \
--port="$DB_PORT" \
--user="$DB_USER" \
--single-transaction \
--no-tablespaces \
"$DB_NAME" \
| gzip -9 > "$SAFETY_FILE" \
|| die "Safety dump failed — aborting restore. Database is unchanged."
info "Safety dump complete: $(du -sh "$SAFETY_FILE" | cut -f1)"
# ── Stop service ───────────────────────────────────────────────────────────────
info "Stopping $SERVICE_NAME"
sudo systemctl stop "$SERVICE_NAME" || die "Failed to stop $SERVICE_NAME"
info "$SERVICE_NAME stopped"
RESTORE_OK=0
# ── Restore ────────────────────────────────────────────────────────────────────
info "Restoring database from backup…"
if MYSQL_PWD="$DB_PASS" gunzip -c "$BACKUP_FILE" | mysql \
--host="$DB_HOST" \
--port="$DB_PORT" \
--user="$DB_USER" \
"$DB_NAME"; then
info "Database restore complete"
RESTORE_OK=1
else
log "ERROR Database restore failed — attempting to roll back from safety dump"
if MYSQL_PWD="$DB_PASS" gunzip -c "$SAFETY_FILE" | mysql \
--host="$DB_HOST" \
--port="$DB_PORT" \
--user="$DB_USER" \
"$DB_NAME"; then
log "WARN Rolled back to pre-restore state from $SAFETY_FILE"
else
log "ERROR Rollback also failed — database may be in an inconsistent state"
log "ERROR Manual recovery required. Safety dump: $SAFETY_FILE"
fi
fi
# ── Run migrations ─────────────────────────────────────────────────────────────
if [[ "$RESTORE_OK" == "1" ]]; then
info "Running flask db upgrade to ensure schema is current…"
cd "$PROJECT_DIR"
if source .venv/bin/activate 2>/dev/null; then
flask db upgrade && info "flask db upgrade OK" || \
log "WARN flask db upgrade failed — check migrations manually"
deactivate 2>/dev/null || true
else
log "WARN Could not activate .venv — skipping flask db upgrade"
log "WARN Run 'flask db upgrade' manually before restarting the service"
fi
fi
# ── Restart service ────────────────────────────────────────────────────────────
info "Starting $SERVICE_NAME"
sudo systemctl start "$SERVICE_NAME" && info "$SERVICE_NAME started" || \
die "$SERVICE_NAME failed to start — check 'journalctl -u $SERVICE_NAME -n 50'"
# ── Summary ────────────────────────────────────────────────────────────────────
echo ""
if [[ "$RESTORE_OK" == "1" ]]; then
info "=== Restore finished successfully ==="
info "Restored from : $BACKUP_FILE"
info "Safety dump : $SAFETY_FILE (kept for 24 h — delete manually when satisfied)"
else
die "=== Restore FAILED — see log for details: $LOG_FILE ==="
fi