Update VAPID key handling and improve push notification encoding
Some checks failed
CI / ci (push) Failing after 1m14s

This commit is contained in:
2026-08-25 23:50:28 +02:00
parent ab42e16eca
commit b27561c91e
5 changed files with 30 additions and 23 deletions

View File

@@ -8,3 +8,4 @@ data/
config.toml config.toml
spa/node_modules/ spa/node_modules/
spa/dist/ spa/dist/
spa/.env

View File

@@ -74,11 +74,17 @@ Copy `config.example.toml` to `config.toml` and adjust as needed.
K-Mood supports Web Push notifications (works on iOS 16.4+ when added to Home Screen, Android, and desktop browsers). No Firebase or third-party service required. K-Mood supports Web Push notifications (works on iOS 16.4+ when added to Home Screen, Android, and desktop browsers). No Firebase or third-party service required.
**1. Generate VAPID keys:** **1. Generate a VAPID private key** (base64url-encoded, 32 bytes):
```bash ```bash
openssl ecparam -genkey -name prime256v1 -noout -out vapid_private.pem python3 -c "
openssl ec -in vapid_private.pem -outform PEM 2>/dev/null | base64 import subprocess, base64
key = subprocess.check_output(
'openssl ecparam -genkey -name prime256v1 -noout 2>/dev/null | openssl ec -outform DER 2>/dev/null',
shell=True
)
print(base64.urlsafe_b64encode(key[7:39]).rstrip(b'=').decode())
"
``` ```
**2. Add to `config.toml`:** **2. Add to `config.toml`:**
@@ -86,7 +92,7 @@ openssl ec -in vapid_private.pem -outform PEM 2>/dev/null | base64
```toml ```toml
[push] [push]
enabled = true enabled = true
vapid_private_key = "<base64 output from step 1>" vapid_private_key = "<output from step 1>"
vapid_subject = "mailto:you@example.com" vapid_subject = "mailto:you@example.com"
``` ```

View File

@@ -38,5 +38,5 @@ allow_registration = true
# [push] # [push]
# enabled = true # enabled = true
# vapid_private_key = "<base64-encoded PEM private key>" # vapid_private_key = "<base64url-encoded 32-byte EC private key>"
# vapid_subject = "mailto:you@example.com" # vapid_subject = "mailto:you@example.com"

View File

@@ -12,7 +12,7 @@ use domain::user::UserId;
pub struct WebPushSender { pub struct WebPushSender {
client: IsahcWebPushClient, client: IsahcWebPushClient,
vapid_private_key: Vec<u8>, vapid_private_key: String,
vapid_subject: String, vapid_subject: String,
subscription_query: Arc<dyn PushSubscriptionQueryPort>, subscription_query: Arc<dyn PushSubscriptionQueryPort>,
} }
@@ -32,14 +32,15 @@ impl WebPushSender {
.as_deref() .as_deref()
.ok_or_else(|| DomainError::InvalidInput("vapid_subject is required".into()))?; .ok_or_else(|| DomainError::InvalidInput("vapid_subject is required".into()))?;
let decoded = base64_decode(private_key)?; VapidSignatureBuilder::from_base64_no_sub(private_key)
.map_err(|e| DomainError::InvalidInput(format!("invalid VAPID key: {e}")))?;
let client = IsahcWebPushClient::new() let client = IsahcWebPushClient::new()
.map_err(|e| DomainError::InvalidInput(format!("failed to create push client: {e}")))?; .map_err(|e| DomainError::InvalidInput(format!("failed to create push client: {e}")))?;
Ok(Self { Ok(Self {
client, client,
vapid_private_key: decoded, vapid_private_key: private_key.to_string(),
vapid_subject: subject.to_string(), vapid_subject: subject.to_string(),
subscription_query, subscription_query,
}) })
@@ -51,9 +52,7 @@ impl WebPushSender {
.as_deref() .as_deref()
.ok_or_else(|| DomainError::InvalidInput("vapid_private_key is required".into()))?; .ok_or_else(|| DomainError::InvalidInput("vapid_private_key is required".into()))?;
let decoded = base64_decode(private_key)?; let sig_builder = VapidSignatureBuilder::from_base64_no_sub(private_key)
let sig_builder = VapidSignatureBuilder::from_pem_no_sub(std::io::Cursor::new(&decoded))
.map_err(|e| DomainError::InvalidInput(format!("invalid VAPID key: {e}")))?; .map_err(|e| DomainError::InvalidInput(format!("invalid VAPID key: {e}")))?;
let public_key = sig_builder.get_public_key(); let public_key = sig_builder.get_public_key();
@@ -61,13 +60,6 @@ impl WebPushSender {
} }
} }
fn base64_decode(input: &str) -> Result<Vec<u8>, DomainError> {
use base64::Engine;
base64::engine::general_purpose::STANDARD
.decode(input)
.map_err(|e| DomainError::InvalidInput(format!("invalid base64: {e}")))
}
fn base64_url_encode(input: &[u8]) -> String { fn base64_url_encode(input: &[u8]) -> String {
use base64::Engine; use base64::Engine;
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(input) base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(input)
@@ -91,10 +83,11 @@ impl domain::ports::ReminderSenderPort for WebPushSender {
let payload_str = payload.to_string(); let payload_str = payload.to_string();
for sub in &subscriptions { for sub in &subscriptions {
let subscription_info = SubscriptionInfo::new(sub.endpoint(), sub.p256dh(), sub.auth()); let subscription_info =
SubscriptionInfo::new(sub.endpoint(), sub.p256dh(), sub.auth());
let mut sig_builder = VapidSignatureBuilder::from_pem( let mut sig_builder = VapidSignatureBuilder::from_base64(
std::io::Cursor::new(&self.vapid_private_key), &self.vapid_private_key,
&subscription_info, &subscription_info,
) )
.map_err(|e| { .map_err(|e| {

View File

@@ -13,6 +13,13 @@ function urlBase64ToUint8Array(base64String: string): Uint8Array {
return array return array
} }
function arrayBufferToBase64Url(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer)
let binary = ""
for (const b of bytes) binary += String.fromCharCode(b)
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")
}
export function usePushNotifications() { export function usePushNotifications() {
const [isSubscribed, setIsSubscribed] = useState(false) const [isSubscribed, setIsSubscribed] = useState(false)
const [isSupported, setIsSupported] = useState(false) const [isSupported, setIsSupported] = useState(false)
@@ -59,8 +66,8 @@ export function usePushNotifications() {
await push.subscribe({ await push.subscribe({
endpoint: sub.endpoint, endpoint: sub.endpoint,
p256dh: btoa(String.fromCharCode(...new Uint8Array(key))), p256dh: arrayBufferToBase64Url(key),
auth: btoa(String.fromCharCode(...new Uint8Array(auth))), auth: arrayBufferToBase64Url(auth),
}) })
}, },
onSuccess: () => setIsSubscribed(true), onSuccess: () => setIsSubscribed(true),