Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 58e5c6bc60 | |||
| d3170e5545 | |||
| 814f44c3fb | |||
| 1d0cf4828b |
@@ -428,6 +428,28 @@ const notifySecurityAlerts = ref(true);
|
|||||||
const savingNotifications = ref(false);
|
const savingNotifications = ref(false);
|
||||||
const notificationsSaved = ref(false);
|
const notificationsSaved = ref(false);
|
||||||
|
|
||||||
|
// VAPID key reset (admin)
|
||||||
|
const vapidResetting = ref(false);
|
||||||
|
const vapidResetMsg = ref("");
|
||||||
|
const vapidResetError = ref(false);
|
||||||
|
|
||||||
|
async function resetVapidKeys() {
|
||||||
|
if (!confirm("This will regenerate VAPID keys and clear all push subscriptions. You will need to re-enable notifications afterwards. Continue?")) return;
|
||||||
|
vapidResetting.value = true;
|
||||||
|
vapidResetMsg.value = "";
|
||||||
|
vapidResetError.value = false;
|
||||||
|
try {
|
||||||
|
await apiPost("/api/push/reset-vapid", {});
|
||||||
|
vapidResetMsg.value = "Keys regenerated. Please re-enable notifications in this browser.";
|
||||||
|
pushStore.checkSubscription();
|
||||||
|
} catch {
|
||||||
|
vapidResetError.value = true;
|
||||||
|
vapidResetMsg.value = "Reset failed — check server logs.";
|
||||||
|
} finally {
|
||||||
|
vapidResetting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// CalDAV settings (per-user)
|
// CalDAV settings (per-user)
|
||||||
const caldav = ref({
|
const caldav = ref({
|
||||||
caldav_url: "",
|
caldav_url: "",
|
||||||
@@ -1882,6 +1904,22 @@ function formatUserDate(iso: string): string {
|
|||||||
Notifications are blocked. Allow them in your browser site settings to re-enable.
|
Notifications are blocked. Allow them in your browser site settings to re-enable.
|
||||||
</p>
|
</p>
|
||||||
</template>
|
</template>
|
||||||
|
<template v-if="authStore.isAdmin">
|
||||||
|
<div class="field-divider" style="margin: 1rem 0;" />
|
||||||
|
<p class="section-desc">
|
||||||
|
If push notifications fail due to a corrupted or misformatted VAPID key, regenerate
|
||||||
|
the key pair here. All existing subscriptions will be cleared — you will need to
|
||||||
|
re-enable notifications in this browser afterwards.
|
||||||
|
</p>
|
||||||
|
<div class="actions" style="margin-top: 0.5rem;">
|
||||||
|
<button class="btn-danger" :disabled="vapidResetting" @click="resetVapidKeys">
|
||||||
|
{{ vapidResetting ? 'Regenerating…' : 'Regenerate VAPID Keys' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="vapidResetMsg" :class="vapidResetError ? 'field-error' : 'field-hint'" style="margin-top: 0.5rem;">
|
||||||
|
{{ vapidResetMsg }}
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="settings-section">
|
<section class="settings-section">
|
||||||
|
|||||||
@@ -453,7 +453,8 @@ async def discuss_article(item_id: int):
|
|||||||
if get_buffer(conv_id) is not None:
|
if get_buffer(conv_id) is not None:
|
||||||
return jsonify({"error": "Generation already in progress"}), 409
|
return jsonify({"error": "Generation already in progress"}), 409
|
||||||
|
|
||||||
article_content = item.content or ""
|
from fabledassistant.services.rss import _fetch_full_article
|
||||||
|
article_content = await _fetch_full_article(item.url) or item.content or ""
|
||||||
|
|
||||||
# Store synthetic assistant message with read_article tool result
|
# Store synthetic assistant message with read_article tool result
|
||||||
synthetic_tool_calls = [{
|
synthetic_tool_calls = [{
|
||||||
|
|||||||
@@ -533,8 +533,9 @@ async def create_conversation_from_article(item_id: int):
|
|||||||
conv_title = (item.title or "Article discussion")[:80]
|
conv_title = (item.title or "Article discussion")[:80]
|
||||||
conv = await create_conversation(uid, title=conv_title, conversation_type="chat")
|
conv = await create_conversation(uid, title=conv_title, conversation_type="chat")
|
||||||
|
|
||||||
|
from fabledassistant.services.rss import _fetch_full_article
|
||||||
source = feed_title or "News"
|
source = feed_title or "News"
|
||||||
content_body = (item.content or "").strip()
|
content_body = (await _fetch_full_article(item.url) if item.url else None) or (item.content or "").strip()
|
||||||
seeded_text = f"**{source}**\n\n**{item.title}**"
|
seeded_text = f"**{source}**\n\n**{item.title}**"
|
||||||
if content_body:
|
if content_body:
|
||||||
seeded_text += f"\n\n{content_body}"
|
seeded_text += f"\n\n{content_body}"
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ import logging
|
|||||||
|
|
||||||
from quart import Blueprint, jsonify, request
|
from quart import Blueprint, jsonify, request
|
||||||
|
|
||||||
from fabledassistant.auth import login_required, get_current_user_id
|
from fabledassistant.auth import login_required, get_current_user_id, admin_required
|
||||||
from fabledassistant.config import Config
|
from fabledassistant.config import Config
|
||||||
from fabledassistant.services.push import delete_subscription, save_subscription, vapid_enabled
|
from fabledassistant.services.push import delete_subscription, regenerate_vapid_keys, save_subscription, vapid_enabled
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -42,3 +42,13 @@ async def unsubscribe():
|
|||||||
return jsonify({"error": "endpoint is required"}), 400
|
return jsonify({"error": "endpoint is required"}), 400
|
||||||
await delete_subscription(uid, endpoint)
|
await delete_subscription(uid, endpoint)
|
||||||
return "", 204
|
return "", 204
|
||||||
|
|
||||||
|
|
||||||
|
@push_bp.route("/reset-vapid", methods=["POST"])
|
||||||
|
@admin_required
|
||||||
|
async def reset_vapid():
|
||||||
|
"""Regenerate VAPID keys and clear all push subscriptions."""
|
||||||
|
ok = await regenerate_vapid_keys()
|
||||||
|
if ok:
|
||||||
|
return jsonify({"publicKey": Config.VAPID_PUBLIC_KEY}), 200
|
||||||
|
return jsonify({"error": "Key regeneration failed"}), 500
|
||||||
|
|||||||
@@ -62,11 +62,14 @@ def ensure_vapid_keys() -> None:
|
|||||||
v = Vapid01()
|
v = Vapid01()
|
||||||
v.generate_keys()
|
v.generate_keys()
|
||||||
|
|
||||||
private_pem = v.private_key.private_bytes(
|
# pywebpush expects the private key as a base64url-encoded DER blob
|
||||||
serialization.Encoding.PEM,
|
# (passed to Vapid.from_string → from_der), NOT a PEM string.
|
||||||
|
private_der = v.private_key.private_bytes(
|
||||||
|
serialization.Encoding.DER,
|
||||||
serialization.PrivateFormat.TraditionalOpenSSL,
|
serialization.PrivateFormat.TraditionalOpenSSL,
|
||||||
serialization.NoEncryption(),
|
serialization.NoEncryption(),
|
||||||
).decode()
|
)
|
||||||
|
private_b64 = base64.urlsafe_b64encode(private_der).rstrip(b"=").decode()
|
||||||
|
|
||||||
pub_bytes = v.public_key.public_bytes(
|
pub_bytes = v.public_key.public_bytes(
|
||||||
serialization.Encoding.X962,
|
serialization.Encoding.X962,
|
||||||
@@ -76,15 +79,40 @@ def ensure_vapid_keys() -> None:
|
|||||||
|
|
||||||
# Persist so they survive container restarts.
|
# Persist so they survive container restarts.
|
||||||
_VAPID_KEYS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
_VAPID_KEYS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||||
_VAPID_KEYS_FILE.write_text(json.dumps({"private_key": private_pem, "public_key": public_b64}))
|
_VAPID_KEYS_FILE.write_text(json.dumps({"private_key": private_b64, "public_key": public_b64}))
|
||||||
|
|
||||||
Config.VAPID_PRIVATE_KEY = private_pem
|
Config.VAPID_PRIVATE_KEY = private_b64
|
||||||
Config.VAPID_PUBLIC_KEY = public_b64
|
Config.VAPID_PUBLIC_KEY = public_b64
|
||||||
logger.info("Generated new VAPID keys and saved to %s", _VAPID_KEYS_FILE)
|
logger.info("Generated new VAPID keys and saved to %s", _VAPID_KEYS_FILE)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("Failed to generate VAPID keys — push notifications will be unavailable", exc_info=True)
|
logger.warning("Failed to generate VAPID keys — push notifications will be unavailable", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
|
async def regenerate_vapid_keys() -> bool:
|
||||||
|
"""Delete existing VAPID keys, clear all push subscriptions, and generate a fresh pair.
|
||||||
|
|
||||||
|
All existing browser subscriptions are invalidated when keys rotate, so they
|
||||||
|
must be cleared — users will need to re-enable notifications.
|
||||||
|
"""
|
||||||
|
if _VAPID_KEYS_FILE.exists():
|
||||||
|
_VAPID_KEYS_FILE.unlink()
|
||||||
|
Config.VAPID_PRIVATE_KEY = ""
|
||||||
|
Config.VAPID_PUBLIC_KEY = ""
|
||||||
|
|
||||||
|
# Clear all push subscriptions — they are bound to the old public key.
|
||||||
|
async with async_session() as session:
|
||||||
|
await session.execute(delete(PushSubscription))
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
ensure_vapid_keys()
|
||||||
|
enabled = vapid_enabled()
|
||||||
|
if enabled:
|
||||||
|
logger.info("VAPID keys regenerated successfully")
|
||||||
|
else:
|
||||||
|
logger.error("VAPID key regeneration failed")
|
||||||
|
return enabled
|
||||||
|
|
||||||
|
|
||||||
async def save_subscription(user_id: int, subscription_json: dict, user_agent: str | None = None) -> PushSubscription:
|
async def save_subscription(user_id: int, subscription_json: dict, user_agent: str | None = None) -> PushSubscription:
|
||||||
"""Upsert a push subscription by endpoint."""
|
"""Upsert a push subscription by endpoint."""
|
||||||
endpoint = subscription_json.get("endpoint", "")
|
endpoint = subscription_json.get("endpoint", "")
|
||||||
|
|||||||
Reference in New Issue
Block a user