diff --git a/app/__init__.py b/app/__init__.py index 044b884..536553a 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -195,9 +195,18 @@ def create_app(config_name: str = 'development') -> Flask: from app.models.token_blacklist import TokenBlacklist from app.models.recovery_challenge import RecoveryChallenge from app.models.totp_used_code import TotpUsedCode + from app.models.shared_item import SharedItem TokenBlacklist.cleanup_expired() RecoveryChallenge.cleanup_expired() TotpUsedCode.cleanup_expired() + # Delete expired unaccepted shares. + from datetime import datetime, timezone + SharedItem.query.filter( + SharedItem.accepted == False, + SharedItem.expires_at != None, + SharedItem.expires_at <= datetime.now(timezone.utc).replace(tzinfo=None), + ).delete() + db.session.commit() import logging logging.getLogger(__name__).debug( '[PassKeeper] token_blacklist + recovery_challenges cleanup completed' diff --git a/app/models/shared_item.py b/app/models/shared_item.py index 44959f4..db47140 100644 --- a/app/models/shared_item.py +++ b/app/models/shared_item.py @@ -48,6 +48,15 @@ class SharedItem(db.Model): accepted = db.Column(db.Boolean, default=False, nullable=False) created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc).replace(tzinfo=None), nullable=False) + # Optional expiry — NULL means the share never expires. + # Expired unaccepted shares are hidden from the inbox; accepted shares remain. + expires_at = db.Column(db.DateTime, nullable=True) + + def is_expired(self) -> bool: + """Return True if the share has an expiry and it has passed.""" + if not self.expires_at: + return False + return datetime.now(timezone.utc).replace(tzinfo=None) >= self.expires_at def to_dict(self): return { @@ -64,4 +73,6 @@ class SharedItem(db.Model): 'iv_name': self.iv_name, 'accepted': self.accepted, 'created_at': self.created_at.isoformat() if self.created_at else None, + 'expires_at': self.expires_at.isoformat() if self.expires_at else None, + 'is_expired': self.is_expired(), } diff --git a/app/routes/sharing.py b/app/routes/sharing.py index c57cfd5..6314252 100644 --- a/app/routes/sharing.py +++ b/app/routes/sharing.py @@ -1,3 +1,5 @@ +from datetime import datetime, timezone + from flask import Blueprint, request, jsonify, g from app import db, limiter, client_ip from app.models.user import User @@ -126,6 +128,21 @@ def create_share(): # Encrypted display name — encrypted with the ECDH shared secret client-side. enc_name = data.get('enc_name') or None iv_name = data.get('iv_name') or None + # Optional expiry: number of days until the share expires (None = never). + # Accepted values: 1, 7, 30, 90, None. + expires_days = data.get('expires_days') + expires_at = None + if expires_days is not None: + try: + expires_days = int(expires_days) + if expires_days > 0: + from datetime import timedelta + expires_at = ( + datetime.now(timezone.utc).replace(tzinfo=None) + + timedelta(days=expires_days) + ) + except (TypeError, ValueError): + pass if not all([item_id, recipient_email, enc_data, iv, item_name]): return jsonify({'error': 'item_id, recipient_email, enc_data, iv, item_name are required'}), 400 @@ -153,6 +170,7 @@ def create_share(): iv=iv, enc_name=enc_name, iv_name=iv_name, + expires_at=expires_at, ) db.session.add(share) db.session.flush() # populate share.id before logging @@ -201,15 +219,25 @@ def delete_share(share_id): @limiter.limit('60 per minute') @require_jwt def inbox(): - """List all items shared with the current user.""" + """List all items shared with the current user. + Expired unaccepted shares are excluded — they can no longer be acted on. + Expired accepted shares remain visible since the data was already accepted. + """ user = db.session.get(User, g.current_user_id) + now = datetime.now(timezone.utc).replace(tzinfo=None) shares = ( SharedItem.query .filter( db.or_( SharedItem.recipient_email == user.email, SharedItem.recipient_id == user.id, - ) + ), + # Exclude expired unaccepted shares. + db.or_( + SharedItem.accepted == True, + SharedItem.expires_at == None, + SharedItem.expires_at > now, + ), ) .order_by(SharedItem.created_at.desc()) .all() diff --git a/app/static/js/vault.js b/app/static/js/vault.js index 6987ed1..cef2abb 100644 --- a/app/static/js/vault.js +++ b/app/static/js/vault.js @@ -471,8 +471,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); }); @@ -540,7 +540,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": @@ -920,9 +920,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 = @@ -1072,16 +1072,16 @@ const Vault = (() => { `; hibpSection.querySelectorAll("[data-sec-edit]").forEach((btn) => { btn.addEventListener("click", () => { @@ -1577,12 +1577,21 @@ const Vault = (() => { // name for shares created before the enc_name migration. const vaultItem = _items.find((i) => i.id === s.item_id); const displayName = vaultItem?.name || s.item_name; + const expiryLabel = (() => { + if (!s.expires_at) return ""; + const daysLeft = Math.ceil( + (new Date(s.expires_at).getTime() - Date.now()) / 86400000, + ); + if (daysLeft <= 0) return " · Expired"; + if (daysLeft <= 3) return ` · Expires in ${daysLeft}d`; + return ` · Expires ${new Date(s.expires_at).toLocaleDateString()}`; + })(); return `
  • ${itemIcon(s.item_type)}
    - +
  • `; @@ -1616,14 +1625,26 @@ const Vault = (() => { } ul.innerHTML = shares .map( - (s) => ` + (s) => { + // Expiry label for pending (unaccepted) items. + const expiryLabel = (() => { + if (!s.expires_at || s.accepted) return ""; + const daysLeft = Math.ceil( + (new Date(s.expires_at).getTime() - Date.now()) / 86400000, + ); + if (daysLeft <= 3) + return ` · Expires in ${daysLeft}d`; + return ` · Expires ${new Date(s.expires_at).toLocaleDateString()}`; + })(); + return `
  • ${itemIcon(s.item_type)}
    - +
    - ${!s.accepted + ${ + !s.accepted ? `` : `` - } -
  • `, + } + `; + }, ) .join(""); @@ -1814,6 +1836,11 @@ const Vault = (() => { item_type: item.item_type, enc_name, iv_name, + expires_days: (() => { + const sel = document.getElementById("share-expiry-select"); + const v = sel ? parseInt(sel.value) : NaN; + return isNaN(v) || v === 0 ? null : v; + })(), }), }); if (!res) return; @@ -2170,9 +2197,10 @@ const Vault = (() => { `; @@ -2501,8 +2529,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)} @@ -2765,15 +2793,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}
    @@ -3282,23 +3311,29 @@ 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) => + pool.filter((item) => { + const p = item.plain || {}; + return ( item.name.toLowerCase().includes(q) || - (item.plain?.username || "").toLowerCase().includes(q) || - (item.plain?.url || "").toLowerCase().includes(q) || - (item.plain?.note_body || "").toLowerCase().includes(q) || - (item.plain?.rp_id || "").toLowerCase().includes(q), - ), + (p.username || "").toLowerCase().includes(q) || + (p.url || "").toLowerCase().includes(q) || + (p.note_body || "").toLowerCase().includes(q) || + (p.rp_id || "").toLowerCase().includes(q) || + (p.notes || "").toLowerCase().includes(q) || + (p.cardholder_name || "").toLowerCase().includes(q) || + (p.email || "").toLowerCase().includes(q) || + (p.tags || []).some((t) => t.toLowerCase().includes(q)) + ); + }), ); } @@ -3440,7 +3475,7 @@ const Vault = (() => { if ( (mode === "add" && (document.getElementById("field-type").value || "password") === - "password") || + "password") || (mode === "edit" && (item?.item_type || "password") === "password") ) { initPasswordFieldEnhancements(); @@ -3697,7 +3732,7 @@ const Vault = (() => { "X-CSRFToken": csrfToken(), }, body: JSON.stringify({ refresh_token: refreshToken }), - }).catch(() => { }); + }).catch(() => {}); VaultSession.clear(); SharingSession.clear(); sessionStorage.removeItem("access_token"); @@ -3802,11 +3837,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) { @@ -4425,4 +4460,4 @@ const Vault = (() => { } })(); -document.addEventListener("DOMContentLoaded", Vault.init); \ No newline at end of file +document.addEventListener("DOMContentLoaded", Vault.init); diff --git a/app/templates/vault/index.html b/app/templates/vault/index.html index f91f625..44bf24c 100644 --- a/app/templates/vault/index.html +++ b/app/templates/vault/index.html @@ -1156,6 +1156,16 @@ placeholder="recipient@example.com" />
    +
    + + +