05/19 Enhance codes 4
This commit is contained in:
@@ -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
|
||||||
@@ -263,3 +263,39 @@ def import_items():
|
|||||||
)
|
)
|
||||||
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
|
||||||
|
|||||||
+38
-17
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1848,7 +1853,8 @@ 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
|
${
|
||||||
|
!s.accepted
|
||||||
? `<button class="btn-primary btn-sm" data-accept="${s.id}">Accept</button>`
|
? `<button class="btn-primary btn-sm" data-accept="${s.id}">Accept</button>`
|
||||||
: `<button class="btn-secondary btn-sm"
|
: `<button class="btn-secondary btn-sm"
|
||||||
data-view-share="${s.id}"
|
data-view-share="${s.id}"
|
||||||
@@ -2401,7 +2407,8 @@ 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
|
${
|
||||||
|
hasFields
|
||||||
? '<div class="detail-body em-detail-body"></div>'
|
? '<div class="detail-body em-detail-body"></div>'
|
||||||
: '<p class="vault-empty">Could not decrypt this item.</p>'
|
: '<p class="vault-empty">Could not decrypt this item.</p>'
|
||||||
}
|
}
|
||||||
@@ -2996,7 +3003,8 @@ 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
|
${
|
||||||
|
isFirstTime
|
||||||
? `<p style="font-size:13px;color:#374151;margin-bottom:12px;">
|
? `<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>
|
||||||
@@ -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");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user