diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml
new file mode 100644
index 0000000..8fc472c
--- /dev/null
+++ b/.gitea/workflows/ci.yml
@@ -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
diff --git a/app/routes/vault.py b/app/routes/vault.py
index f67c6b8..58b1e17 100644
--- a/app/routes/vault.py
+++ b/app/routes/vault.py
@@ -262,4 +262,40 @@ def import_items():
ip_address=client_ip(),
)
db.session.commit()
- return jsonify({'imported': imported, 'skipped': skipped}), 200
\ No newline at end of file
+ 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
diff --git a/app/static/js/vault.js b/app/static/js/vault.js
index eade3bd..efe2c9f 100644
--- a/app/static/js/vault.js
+++ b/app/static/js/vault.js
@@ -480,8 +480,8 @@ const Vault = (() => {
_sortOrder === "folder"
? Object.keys(groups).sort()
: ["(No folder)", ..._folders.map((f) => f.name)].filter(
- (k) => groups[k],
- );
+ (k) => groups[k],
+ );
Object.keys(groups).forEach((k) => {
if (!keys.includes(k)) keys.push(k);
});
@@ -549,7 +549,7 @@ const Vault = (() => {
case "card":
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;
case "bank":
@@ -891,6 +891,11 @@ const Vault = (() => {
document.body.removeChild(a);
URL.revokeObjectURL(url);
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();
}
@@ -1125,9 +1130,9 @@ const Vault = (() => {
0,
Math.round(
100 -
- (weak.length / total) * 40 -
- (reused.length / total) * 30 -
- (old.length / total) * 15,
+ (weak.length / total) * 40 -
+ (reused.length / total) * 30 -
+ (old.length / total) * 15,
),
);
const cls =
@@ -1277,16 +1282,16 @@ const Vault = (() => {
${breached
- .map((i) => {
- const count =
- hibpResults.find((r) => r.item.id === i.id)?.count || 0;
- return `-
+ .map((i) => {
+ const count =
+ hibpResults.find((r) => r.item.id === i.id)?.count || 0;
+ return `
-
${escHtml(i.name)}
${escHtml(i.plain?.username || "")} — seen ${count.toLocaleString()} time${count !== 1 ? "s" : ""} in breaches
`;
- })
- .join("")}
+ })
+ .join("")}
`;
hibpSection.querySelectorAll("[data-sec-edit]").forEach((btn) => {
btn.addEventListener("click", () => {
@@ -1848,9 +1853,10 @@ const Vault = (() => {
${escHtml(s.item_name)}
From ${escHtml(s.owner_email)}${expiryLabel}
- ${!s.accepted
- ? ``
- : ``
+ : ``
- }
+ }
`;
},
)
@@ -2401,9 +2407,10 @@ const Vault = (() => {
▸
- ${hasFields
- ? '
'
- : '
Could not decrypt this item.
'
+ ${
+ hasFields
+ ? '
'
+ : '
Could not decrypt this item.
'
}
`;
@@ -2732,8 +2739,8 @@ const Vault = (() => {
const typeLabel = transports.includes("internal")
? "📱 Device"
: transports.some((t) => ["usb", "nfc", "ble", "smart-card"].includes(t))
- ? "🔑 Security key"
- : "🔑 Passkey";
+ ? "🔑 Security key"
+ : "🔑 Passkey";
return `
${escHtml(c.name)}
@@ -2996,15 +3003,16 @@ const Vault = (() => {
${isFirstTime ? "🔐 MFA enabled — save your backup codes" : "🔐 New backup codes"}
- ${isFirstTime
- ? `
+ ${
+ isFirstTime
+ ? `
These codes let you sign in if you lose access to your authenticator app.
Save them now — they will not be shown again.
`
- : `
+ : `
Your previous codes have been invalidated. Save these new codes securely.
`
- }
+ }
${codesHtml}
@@ -3513,14 +3521,14 @@ const Vault = (() => {
const pool = !_activeFilter
? _items
: _activeFilter.type === "itemType"
- ? _items.filter((i) => i.item_type === _activeFilter.value)
- : _activeFilter.type === "folder"
- ? _items.filter((i) => i.folder_id === _activeFilter.value)
- : _activeFilter.type === "tag"
- ? _items.filter((i) =>
- (i.plain?.tags || []).includes(_activeFilter.value),
- )
- : _items;
+ ? _items.filter((i) => i.item_type === _activeFilter.value)
+ : _activeFilter.type === "folder"
+ ? _items.filter((i) => i.folder_id === _activeFilter.value)
+ : _activeFilter.type === "tag"
+ ? _items.filter((i) =>
+ (i.plain?.tags || []).includes(_activeFilter.value),
+ )
+ : _items;
renderItemList(
pool.filter((item) => {
const p = item.plain || {};
@@ -3682,36 +3690,49 @@ const Vault = (() => {
const history = item.plain?.password_history || [];
if (history.length) {
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 =
`
🕐 Previous passwords (${history.length})
` +
- history.map((h) => {
+ history.map((h, idx) => {
const date = h.changed_at
? new Date(h.changed_at).toLocaleDateString()
: "Unknown date";
- return `
+ return `
••••••••
${escHtml(date)}
-
-
+
+
`;
}).join("");
- historyPanel.querySelectorAll(".pw-history-reveal").forEach((btn) => {
- btn.addEventListener("click", () => {
- const masked = btn.closest(".pw-history-row").querySelector(".pw-history-masked");
+
+ // Register passwords in the WeakMap after DOM elements exist.
+ 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 === "••••••••") {
- masked.textContent = btn.dataset.pw;
- btn.textContent = "🙈";
+ masked.textContent = _historyPasswords.get(revealBtn);
+ revealBtn.textContent = "🙈";
} else {
masked.textContent = "••••••••";
- btn.textContent = "👁";
+ revealBtn.textContent = "👁";
}
});
- });
- historyPanel.querySelectorAll(".pw-history-restore").forEach((btn) => {
- btn.addEventListener("click", () => {
+
+ restoreBtn.addEventListener("click", () => {
const pwField = document.getElementById("field-password");
if (pwField) {
- pwField.value = btn.dataset.pw;
+ pwField.value = _historyPasswords.get(restoreBtn);
pwField.dispatchEvent(new Event("input"));
showToast("Password restored — click Save to apply");
}
@@ -3729,7 +3750,7 @@ const Vault = (() => {
if (
(mode === "add" &&
(document.getElementById("field-type").value || "password") ===
- "password") ||
+ "password") ||
(mode === "edit" && (item?.item_type || "password") === "password")
) {
initPasswordFieldEnhancements();
@@ -3994,7 +4015,7 @@ const Vault = (() => {
"X-CSRFToken": csrfToken(),
},
body: JSON.stringify({ refresh_token: refreshToken }),
- }).catch(() => { });
+ }).catch(() => {});
VaultSession.clear();
SharingSession.clear();
sessionStorage.removeItem("access_token");
@@ -4099,11 +4120,11 @@ const Vault = (() => {
// Auto-clear clipboard after 30 seconds — industry-standard hygiene.
if (_clipboardClearTimer) clearTimeout(_clipboardClearTimer);
_clipboardClearTimer = setTimeout(() => {
- navigator.clipboard.writeText("").catch(() => { });
+ navigator.clipboard.writeText("").catch(() => {});
_clipboardClearTimer = null;
}, 30_000);
})
- .catch(() => { });
+ .catch(() => {});
}
function escHtml(str) {
@@ -4723,4 +4744,4 @@ const Vault = (() => {
}
})();
-document.addEventListener("DOMContentLoaded", Vault.init);
\ No newline at end of file
+document.addEventListener("DOMContentLoaded", Vault.init);
diff --git a/scripts/restore_db.sh b/scripts/restore_db.sh
new file mode 100644
index 0000000..beca951
--- /dev/null
+++ b/scripts/restore_db.sh
@@ -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="$(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