Merge pull request 'Release v26.04.07.2' (#22) from dev into main
This commit was merged in pull request #22.
This commit is contained in:
@@ -428,6 +428,28 @@ const notifySecurityAlerts = ref(true);
|
||||
const savingNotifications = 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)
|
||||
const caldav = ref({
|
||||
caldav_url: "",
|
||||
@@ -1882,6 +1904,22 @@ function formatUserDate(iso: string): string {
|
||||
Notifications are blocked. Allow them in your browser site settings to re-enable.
|
||||
</p>
|
||||
</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 class="settings-section">
|
||||
|
||||
@@ -453,7 +453,8 @@ async def discuss_article(item_id: int):
|
||||
if get_buffer(conv_id) is not None:
|
||||
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
|
||||
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 = await create_conversation(uid, title=conv_title, conversation_type="chat")
|
||||
|
||||
from fabledassistant.services.rss import _fetch_full_article
|
||||
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}**"
|
||||
if content_body:
|
||||
seeded_text += f"\n\n{content_body}"
|
||||
|
||||
@@ -3,9 +3,9 @@ import logging
|
||||
|
||||
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.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__)
|
||||
|
||||
@@ -42,3 +42,13 @@ async def unsubscribe():
|
||||
return jsonify({"error": "endpoint is required"}), 400
|
||||
await delete_subscription(uid, endpoint)
|
||||
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.generate_keys()
|
||||
|
||||
private_pem = v.private_key.private_bytes(
|
||||
serialization.Encoding.PEM,
|
||||
# pywebpush expects the private key as a base64url-encoded DER blob
|
||||
# (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.NoEncryption(),
|
||||
).decode()
|
||||
)
|
||||
private_b64 = base64.urlsafe_b64encode(private_der).rstrip(b"=").decode()
|
||||
|
||||
pub_bytes = v.public_key.public_bytes(
|
||||
serialization.Encoding.X962,
|
||||
@@ -76,15 +79,40 @@ def ensure_vapid_keys() -> None:
|
||||
|
||||
# Persist so they survive container restarts.
|
||||
_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
|
||||
logger.info("Generated new VAPID keys and saved to %s", _VAPID_KEYS_FILE)
|
||||
except Exception:
|
||||
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:
|
||||
"""Upsert a push subscription by endpoint."""
|
||||
endpoint = subscription_json.get("endpoint", "")
|
||||
|
||||
Reference in New Issue
Block a user