diff --git a/docker-compose.onprem.yml b/docker-compose.onprem.yml
index 0d12687..cfe3955 100644
--- a/docker-compose.onprem.yml
+++ b/docker-compose.onprem.yml
@@ -21,6 +21,9 @@ services:
BTCPAY_STORE_ID: ${BTCPAY_STORE_ID:-}
BTCPAY_API_KEY: ${BTCPAY_API_KEY:-}
BTCPAY_WEBHOOK_SECRET: ${BTCPAY_WEBHOOK_SECRET:-}
+ VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY:-}
+ VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-}
+ VAPID_SUBJECT: ${VAPID_SUBJECT:-mailto:admin@l484.com}
volumes:
- l484-onprem-data:/app/server/data
diff --git a/docker-compose.yml b/docker-compose.yml
index b850357..773448d 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -11,7 +11,7 @@ services:
NODE_ENV: production
HOST: 0.0.0.0
PORT: 2354
- APP_MODE: public
+ APP_MODE: all
MEMBERSHIP_ENCRYPTION_KEY: ${MEMBERSHIP_ENCRYPTION_KEY:?Set a unique 64-character hex key}
ACCESS_HMAC_KEY: ${ACCESS_HMAC_KEY:?Set a unique access HMAC key}
ACCESS_CONTROLLER_TOKEN: ${ACCESS_CONTROLLER_TOKEN:-}
@@ -21,6 +21,9 @@ services:
BTCPAY_STORE_ID: ${BTCPAY_STORE_ID:-}
BTCPAY_API_KEY: ${BTCPAY_API_KEY:-}
BTCPAY_WEBHOOK_SECRET: ${BTCPAY_WEBHOOK_SECRET:-}
+ VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY:-}
+ VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-}
+ VAPID_SUBJECT: ${VAPID_SUBJECT:-mailto:admin@l484.com}
volumes:
- l484-public-data:/app/server/data
diff --git a/server/server.js b/server/server.js
index 1bf82b5..f699fb7 100644
--- a/server/server.js
+++ b/server/server.js
@@ -26,9 +26,20 @@ const btcpayStoreId = process.env.BTCPAY_STORE_ID || ''
const btcpayWebhookSecret = process.env.BTCPAY_WEBHOOK_SECRET || ''
const homeAssistantUnlockWebhookUrl = process.env.HOME_ASSISTANT_UNLOCK_WEBHOOK_URL || ''
const homeAssistantUnlockTimeoutMs = Number(process.env.HOME_ASSISTANT_UNLOCK_TIMEOUT_MS || 2500)
-const vapidPublicKey = process.env.VAPID_PUBLIC_KEY || ''
-const vapidPrivateKey = process.env.VAPID_PRIVATE_KEY || ''
-const vapidSubject = process.env.VAPID_SUBJECT || 'mailto:admin@l484.com'
+const cleanEnvValue = (value) => String(value || '').trim().replace(/^["']|["']$/g, '')
+const vapidPublicKey = cleanEnvValue(process.env.VAPID_PUBLIC_KEY)
+const vapidPrivateKey = cleanEnvValue(process.env.VAPID_PRIVATE_KEY)
+const vapidSubject = cleanEnvValue(process.env.VAPID_SUBJECT) || 'mailto:admin@l484.com'
+const isValidVapidPublicKey = (value) => {
+ try {
+ const clean = cleanEnvValue(value)
+ const padding = '='.repeat((4 - (clean.length % 4)) % 4)
+ const key = Buffer.from((clean + padding).replace(/-/g, '+').replace(/_/g, '/'), 'base64')
+ return key.length === 65 && key[0] === 4
+ } catch {
+ return false
+ }
+}
const normalizeAdminPubkey = (value) => {
const raw = String(value || '').trim().toLowerCase()
if (/^[0-9a-f]{64}$/.test(raw)) return raw
@@ -68,7 +79,7 @@ const rateBuckets = new Map()
const publicApiEnabled = () => appMode !== 'admin'
const adminApiEnabled = () => appMode !== 'public'
-if (vapidPublicKey && vapidPrivateKey) {
+if (isValidVapidPublicKey(vapidPublicKey) && vapidPrivateKey) {
webpush.setVapidDetails(vapidSubject, vapidPublicKey, vapidPrivateKey)
}
@@ -385,15 +396,16 @@ const publicAdminRequests = () => state.adminRequests.map((request) => ({
}))
const notificationStats = () => ({
- configured: Boolean(vapidPublicKey && vapidPrivateKey),
+ configured: Boolean(isValidVapidPublicKey(vapidPublicKey) && vapidPrivateKey),
subscriberCount: state.notificationSubscriptions.length,
publicKeyConfigured: Boolean(vapidPublicKey),
+ publicKeyValid: isValidVapidPublicKey(vapidPublicKey),
privateKeyConfigured: Boolean(vapidPrivateKey),
subject: vapidSubject,
})
const sendPushNotification = async ({ title, message, url = '/edit', icon = '/images/small-logo.svg', tag = 'l484-update' }) => {
- if (!vapidPublicKey || !vapidPrivateKey) {
+ if (!isValidVapidPublicKey(vapidPublicKey) || !vapidPrivateKey) {
return { success: false, reason: 'vapid_not_configured', sent: 0, failed: 0 }
}
const payload = JSON.stringify({
@@ -920,7 +932,7 @@ const handleApi = async (req, res) => {
}
if (req.method === 'GET' && url.pathname === '/api/notifications/vapid-public-key') {
- return json(res, 200, { success: true, publicKey: vapidPublicKey, configured: Boolean(vapidPublicKey && vapidPrivateKey) })
+ return json(res, 200, { success: true, publicKey: isValidVapidPublicKey(vapidPublicKey) ? vapidPublicKey : '', configured: Boolean(isValidVapidPublicKey(vapidPublicKey) && vapidPrivateKey) })
}
if (req.method === 'GET' && url.pathname === '/api/notifications/test-vapid') {
diff --git a/src/App.vue b/src/App.vue
index 17f9c74..ed3a5bb 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -3601,14 +3601,9 @@ watch(mobileMenuOpen, (open) => {
-
-
-
-
+
diff --git a/src/services/notifications.js b/src/services/notifications.js
index 29494a8..5c57c63 100644
--- a/src/services/notifications.js
+++ b/src/services/notifications.js
@@ -3,10 +3,13 @@ import { ref } from 'vue'
const permission = ref(typeof Notification === 'undefined' ? 'unsupported' : Notification.permission)
const urlBase64ToUint8Array = (base64String) => {
- const padding = '='.repeat((4 - (base64String.length % 4)) % 4)
- const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/')
+ const clean = String(base64String || '').trim().replace(/^["']|["']$/g, '')
+ const padding = '='.repeat((4 - (clean.length % 4)) % 4)
+ const base64 = (clean + padding).replace(/-/g, '+').replace(/_/g, '/')
const rawData = window.atob(base64)
- return new Uint8Array([...rawData].map((char) => char.charCodeAt(0)))
+ const key = new Uint8Array([...rawData].map((char) => char.charCodeAt(0)))
+ if (key.length !== 65 || key[0] !== 4) throw new Error('VAPID public key is not valid.')
+ return key
}
export const notificationPermission = permission