mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
fix(hub,web): serialize notification settings updates
This commit is contained in:
@@ -4,6 +4,8 @@ import { dirname, join } from 'node:path'
|
||||
|
||||
import type { NotificationCopyConfig } from '../push/notificationCopy'
|
||||
|
||||
const settingsWriteQueues = new Map<string, Promise<unknown>>()
|
||||
|
||||
export interface Settings {
|
||||
machineId?: string
|
||||
machineIdConfirmedByServer?: boolean
|
||||
@@ -60,10 +62,20 @@ export async function readSettingsOrThrow(settingsFile: string): Promise<Setting
|
||||
return settings
|
||||
}
|
||||
|
||||
/**
|
||||
* Write settings to file atomically (temp file + rename)
|
||||
*/
|
||||
export async function writeSettings(settingsFile: string, settings: Settings): Promise<void> {
|
||||
async function serializeSettingsWrite<T>(settingsFile: string, operation: () => Promise<T>): Promise<T> {
|
||||
const previous = settingsWriteQueues.get(settingsFile) ?? Promise.resolve()
|
||||
const current = previous.catch(() => {}).then(operation)
|
||||
settingsWriteQueues.set(settingsFile, current)
|
||||
try {
|
||||
return await current
|
||||
} finally {
|
||||
if (settingsWriteQueues.get(settingsFile) === current) {
|
||||
settingsWriteQueues.delete(settingsFile)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function writeSettingsUnlocked(settingsFile: string, settings: Settings): Promise<void> {
|
||||
const dir = dirname(settingsFile)
|
||||
if (!existsSync(dir)) {
|
||||
await mkdir(dir, { recursive: true, mode: 0o700 })
|
||||
@@ -73,3 +85,26 @@ export async function writeSettings(settingsFile: string, settings: Settings): P
|
||||
await writeFile(tmpFile, JSON.stringify(settings, null, 2))
|
||||
await rename(tmpFile, settingsFile)
|
||||
}
|
||||
|
||||
/**
|
||||
* Write settings to file atomically (temp file + rename).
|
||||
* Writes are serialized per file so they cannot reuse the same temp path.
|
||||
*/
|
||||
export async function writeSettings(settingsFile: string, settings: Settings): Promise<void> {
|
||||
await serializeSettingsWrite(settingsFile, () => writeSettingsUnlocked(settingsFile, settings))
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize a complete settings read-modify-write operation per file.
|
||||
*/
|
||||
export async function mutateSettings<T>(
|
||||
settingsFile: string,
|
||||
mutate: (settings: Settings) => T | Promise<T>
|
||||
): Promise<T> {
|
||||
return await serializeSettingsWrite(settingsFile, async () => {
|
||||
const settings = await readSettingsOrThrow(settingsFile)
|
||||
const result = await mutate(settings)
|
||||
await writeSettingsUnlocked(settingsFile, settings)
|
||||
return result
|
||||
})
|
||||
}
|
||||
|
||||
+31
-19
@@ -9,14 +9,12 @@
|
||||
* tunnel — there is no shared-key fallback.
|
||||
*/
|
||||
|
||||
import { readSettings, writeSettings, type Settings } from '../config/settings'
|
||||
import { mutateSettings, readSettings } from '../config/settings'
|
||||
|
||||
type FetchRelayAuth = (input: string | URL | Request, init?: RequestInit) => Promise<Response>
|
||||
|
||||
async function issueRelayAuthKey(
|
||||
async function requestRelayAuthKey(
|
||||
apiDomain: string,
|
||||
settingsFile: string,
|
||||
settings: Settings | null,
|
||||
fetchRelayAuth: FetchRelayAuth
|
||||
): Promise<string> {
|
||||
const resp = await fetchRelayAuth(`https://${apiDomain}/issue`, {
|
||||
@@ -41,10 +39,6 @@ async function issueRelayAuthKey(
|
||||
if (typeof data.key !== 'string' || !data.key) {
|
||||
throw new Error(`Relay at ${apiDomain} returned an invalid key response.`)
|
||||
}
|
||||
// settings === null means the file exists but is unparseable; don't clobber it
|
||||
if (settings !== null) {
|
||||
await writeSettings(settingsFile, { ...settings, relayAuthKey: data.key })
|
||||
}
|
||||
console.log('[Tunnel] Obtained per-hub relay auth key')
|
||||
return data.key
|
||||
}
|
||||
@@ -64,7 +58,18 @@ export async function resolveRelayAuthKey(
|
||||
return settings.relayAuthKey
|
||||
}
|
||||
|
||||
return issueRelayAuthKey(apiDomain, settingsFile, settings, fetchRelayAuth)
|
||||
const issuedKey = await requestRelayAuthKey(apiDomain, fetchRelayAuth)
|
||||
// An unreadable file must never be replaced with a partial settings object.
|
||||
if (settings === null) {
|
||||
return issuedKey
|
||||
}
|
||||
return await mutateSettings(settingsFile, (latestSettings) => {
|
||||
if (latestSettings.relayAuthKey) {
|
||||
return latestSettings.relayAuthKey
|
||||
}
|
||||
latestSettings.relayAuthKey = issuedKey
|
||||
return issuedKey
|
||||
})
|
||||
}
|
||||
|
||||
export async function refreshRejectedRelayAuthKey(
|
||||
@@ -80,17 +85,24 @@ export async function refreshRejectedRelayAuthKey(
|
||||
)
|
||||
}
|
||||
|
||||
const settings = await readSettings(settingsFile)
|
||||
if (settings === null) {
|
||||
throw new Error(`Cannot refresh relay auth while ${settingsFile} is unreadable.`)
|
||||
}
|
||||
if (settings.relayAuthKey && settings.relayAuthKey !== rejectedKey) {
|
||||
return settings.relayAuthKey
|
||||
const currentKey = await mutateSettings(settingsFile, (settings) => {
|
||||
if (settings.relayAuthKey && settings.relayAuthKey !== rejectedKey) {
|
||||
return settings.relayAuthKey
|
||||
}
|
||||
delete settings.relayAuthKey
|
||||
return null
|
||||
})
|
||||
if (currentKey) {
|
||||
return currentKey
|
||||
}
|
||||
|
||||
const clearedSettings = { ...settings }
|
||||
delete clearedSettings.relayAuthKey
|
||||
await writeSettings(settingsFile, clearedSettings)
|
||||
console.warn('[Tunnel] Relay auth key rejected; requesting a replacement')
|
||||
return issueRelayAuthKey(apiDomain, settingsFile, clearedSettings, fetchRelayAuth)
|
||||
const issuedKey = await requestRelayAuthKey(apiDomain, fetchRelayAuth)
|
||||
return await mutateSettings(settingsFile, (settings) => {
|
||||
if (settings.relayAuthKey) {
|
||||
return settings.relayAuthKey
|
||||
}
|
||||
settings.relayAuthKey = issuedKey
|
||||
return issuedKey
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { Hono } from 'hono'
|
||||
import type { WebAppEnv } from '../middleware/auth'
|
||||
import { resolveRelayAuthKey } from '../../tunnel/relayAuth'
|
||||
import { createNotificationCopyRoutes } from './notificationCopy'
|
||||
|
||||
let dir: string
|
||||
@@ -105,6 +106,37 @@ describe('PUT /api/notification-copy', () => {
|
||||
expect(settings.notificationCopy).toEqual({ ready: { title: '', body: '' } })
|
||||
})
|
||||
|
||||
it('preserves a concurrent relay key update', async () => {
|
||||
let releaseIssue: () => void = () => {}
|
||||
let issueStarted: () => void = () => {}
|
||||
const started = new Promise<void>((resolve) => {
|
||||
issueStarted = resolve
|
||||
})
|
||||
const release = new Promise<void>((resolve) => {
|
||||
releaseIssue = resolve
|
||||
})
|
||||
const relayKey = resolveRelayAuthKey('relay.example.com', join(dir, 'settings.json'), async () => {
|
||||
issueStarted()
|
||||
await release
|
||||
return Response.json({ key: 'relay-key' })
|
||||
})
|
||||
await started
|
||||
|
||||
const app = await createApp('default')
|
||||
const res = await app.request('/api/notification-copy', {
|
||||
method: 'PUT',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ ready: { title: 'Hey', body: '{agentName}' } })
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
releaseIssue()
|
||||
await relayKey
|
||||
|
||||
const settings = await readSettings()
|
||||
expect(settings.relayAuthKey).toBe('relay-key')
|
||||
expect(settings.notificationCopy).toEqual({ ready: { title: 'Hey', body: '{agentName}' } })
|
||||
})
|
||||
|
||||
it('rejects title over 500 chars', async () => {
|
||||
const app = await createApp('default')
|
||||
const res = await app.request('/api/notification-copy', {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Hono } from 'hono'
|
||||
import { getSettingsFile, readSettingsOrThrow, writeSettings } from '../../config/settings'
|
||||
import { getSettingsFile, mutateSettings, readSettingsOrThrow } from '../../config/settings'
|
||||
import {
|
||||
COPY_KEYS,
|
||||
DEFAULT_COPY,
|
||||
@@ -41,8 +41,6 @@ export function createNotificationCopyRoutes(dataDir: string): Hono<WebAppEnv> {
|
||||
return c.json({ error: 'Invalid body', issues: parsed.error.flatten() }, 400)
|
||||
}
|
||||
|
||||
// Read-modify-write: preserve every other settings.json key.
|
||||
const settings = await readSettingsOrThrow(settingsFile)
|
||||
const copy: NotificationCopyConfig = {}
|
||||
for (const key of COPY_KEYS) {
|
||||
const template = parsed.data[key]
|
||||
@@ -50,8 +48,9 @@ export function createNotificationCopyRoutes(dataDir: string): Hono<WebAppEnv> {
|
||||
copy[key as CopyKey] = template
|
||||
}
|
||||
}
|
||||
settings.notificationCopy = copy
|
||||
await writeSettings(settingsFile, settings)
|
||||
await mutateSettings(settingsFile, (settings) => {
|
||||
settings.notificationCopy = copy
|
||||
})
|
||||
return c.json({
|
||||
copy,
|
||||
defaults: DEFAULT_COPY
|
||||
|
||||
@@ -944,6 +944,7 @@ export default {
|
||||
'settings.notifications.copy.resetDefault': 'Reset to default',
|
||||
'settings.notifications.copy.save': 'Save copy',
|
||||
'settings.notifications.copy.saved': 'Copy saved',
|
||||
'settings.notifications.copy.saveError': 'Failed to save notification copy',
|
||||
'settings.notifications.copy.testPushNote': 'The test push button always sends fixed copy.',
|
||||
'settings.notifications.copy.permissionRequest': 'Permission request',
|
||||
'settings.notifications.copy.ready': 'Session ready',
|
||||
|
||||
@@ -943,6 +943,7 @@ export default {
|
||||
'settings.notifications.copy.resetDefault': '恢复默认',
|
||||
'settings.notifications.copy.save': '保存文案',
|
||||
'settings.notifications.copy.saved': '文案已保存',
|
||||
'settings.notifications.copy.saveError': '保存推送文案失败',
|
||||
'settings.notifications.copy.testPushNote': '测试推送按钮始终发送固定文案。',
|
||||
'settings.notifications.copy.permissionRequest': '权限请求',
|
||||
'settings.notifications.copy.ready': '会话就绪',
|
||||
|
||||
@@ -155,6 +155,16 @@ describe('SettingsNotificationsPage', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('reports a rejected notification copy save', async () => {
|
||||
updateNotificationCopy.mockRejectedValueOnce(new Error('write failed'))
|
||||
renderPage()
|
||||
await screen.findByText('Push notification copy')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save copy' }))
|
||||
|
||||
expect(await screen.findByText('Failed to save notification copy')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('keeps copy editors collapsed and prefills defaults when opened', async () => {
|
||||
renderPage()
|
||||
const readyRow = await screen.findByRole('button', { name: /Session ready.*Ready for input/ })
|
||||
|
||||
@@ -87,6 +87,7 @@ export default function SettingsNotificationsPage() {
|
||||
const [testPushLabel, setTestPushLabel] = useState<string | null>(null)
|
||||
const [saveError, setSaveError] = useState<string | null>(null)
|
||||
const [copySaved, setCopySaved] = useState(false)
|
||||
const [copySaveError, setCopySaveError] = useState<string | null>(null)
|
||||
const [openCopyBlock, setOpenCopyBlock] = useState<CopyKey | null>(null)
|
||||
|
||||
const isAdmin = Boolean(token) && getNamespace(token) === 'default'
|
||||
@@ -142,12 +143,20 @@ export default function SettingsNotificationsPage() {
|
||||
if (!api) throw new Error('API unavailable')
|
||||
return await api.updateNotificationCopy(copy)
|
||||
},
|
||||
onMutate: () => {
|
||||
setCopySaveError(null)
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData(queryKeys.notificationCopy, data)
|
||||
setDraft(resolveEffectiveCopy(data.copy, data.defaults))
|
||||
setCopySaveError(null)
|
||||
setCopySaved(true)
|
||||
setTimeout(() => setCopySaved(false), 3000)
|
||||
},
|
||||
onError: () => {
|
||||
setCopySaved(false)
|
||||
setCopySaveError(t('settings.notifications.copy.saveError'))
|
||||
},
|
||||
})
|
||||
|
||||
const handleToggle = (key: ToggleKey) => (checked: boolean) => {
|
||||
@@ -297,7 +306,7 @@ export default function SettingsNotificationsPage() {
|
||||
})}
|
||||
<div className="flex min-h-14 items-center justify-between gap-3 px-3 py-3">
|
||||
<span className="text-xs text-[var(--app-hint)]" aria-live="polite">
|
||||
{copySaved ? t('settings.notifications.copy.saved') : ''}
|
||||
{copySaveError ?? (copySaved ? t('settings.notifications.copy.saved') : '')}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
Reference in New Issue
Block a user