diff --git a/apps/api/src/routes/settings.ts b/apps/api/src/routes/settings.ts index cf3f2c1..02256d5 100644 --- a/apps/api/src/routes/settings.ts +++ b/apps/api/src/routes/settings.ts @@ -75,6 +75,15 @@ function normalizeAppriseConfigPayload( } } +function isBarkCustomServerUrl(rawUrl: string) { + try { + const parsed = new URL(rawUrl.trim()) + return parsed.pathname.replace(/\/+$/, '') !== '' + } catch { + return false + } +} + function validateSettingsPayload( settings: Awaited>, locale: AppLocale = 'zh-CN' @@ -178,30 +187,33 @@ function validateSettingsPayload( validateNotificationTargetUrl(settings.gotifyConfig.url.trim(), { label: getMessage(locale, 'settings.labels.gotifyTargetUrl'), - locale + locale, + allowPrivateHost: true }) } if (settings.barkNotificationsEnabled) { - const missingBarkFields = [ - [labels.serverUrl, settings.barkConfig.serverUrl], - [labels.deviceKey, settings.barkConfig.deviceKey] - ] - .filter(([, value]) => !String(value ?? '').trim()) - .map(([label]) => label) - - if (missingBarkFields.length) { + if (!String(settings.barkConfig.serverUrl ?? '').trim()) { throw new Error( getMessage(locale, 'api.errors.settings.barkFieldsRequired', { - fields: missingBarkFields.join(fieldSeparator) + fields: labels.serverUrl }) ) } - validateNotificationTargetUrl(settings.barkConfig.serverUrl.trim(), { + const barkUrl = validateNotificationTargetUrl(settings.barkConfig.serverUrl.trim(), { label: getMessage(locale, 'settings.labels.barkServerUrl'), - locale + locale, + allowPrivateHost: true }) + + if (!isBarkCustomServerUrl(barkUrl.toString()) && !String(settings.barkConfig.deviceKey ?? '').trim()) { + throw new Error( + getMessage(locale, 'api.errors.settings.barkFieldsRequired', { + fields: labels.deviceKey + }) + ) + } } if (settings.notifyxNotificationsEnabled) { diff --git a/apps/api/src/services/channel-notification.service.ts b/apps/api/src/services/channel-notification.service.ts index 352cb3c..4f0ea56 100644 --- a/apps/api/src/services/channel-notification.service.ts +++ b/apps/api/src/services/channel-notification.service.ts @@ -564,7 +564,8 @@ async function sendServerchanNotification( async function sendGotifyWithConfig(message: DirectNotificationMessage, config: GotifyConfigInput, locale: AppLocale = DEFAULT_APP_LOCALE) { const target = validateNotificationTargetUrl(config.url.trim(), { label: getMessage(locale, 'settings.labels.gotifyTargetUrl'), - locale + locale, + allowPrivateHost: true }) const token = config.token?.trim() if (!token) { @@ -642,16 +643,54 @@ function buildNotifyxDescription(title: string) { return `SubTracker · ${title}`.slice(0, 500) } -function resolveBarkPushUrl(serverUrl: string, locale: AppLocale = DEFAULT_APP_LOCALE) { +function isBarkCustomServerUrl(target: URL) { + return target.pathname.replace(/\/+$/, '') !== '' +} + +function buildBarkRequestTarget( + serverUrl: string, + deviceKey: string | undefined, + locale: AppLocale = DEFAULT_APP_LOCALE +) { const target = validateNotificationTargetUrl(serverUrl.trim(), { label: getMessage(locale, 'settings.labels.barkServerUrl'), - locale + locale, + allowPrivateHost: true }) - const basePath = target.pathname.replace(/\/+$/, '') - target.pathname = basePath ? `${basePath}/push` : '/push' + + const headers: Record = { + 'Content-Type': 'application/json' + } + + if (target.username) { + const username = decodeURIComponent(target.username) + const password = decodeURIComponent(target.password || '') + headers.Authorization = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}` + target.username = '' + target.password = '' + } + + if (isBarkCustomServerUrl(target)) { + target.hash = '' + return { + url: target, + headers, + includeDeviceKey: false + } + } + + if (!deviceKey?.trim()) { + throw new Error(getMessage(DEFAULT_APP_LOCALE, 'api.errors.notifications.barkDisabledOrIncomplete')) + } + + target.pathname = '/push' target.search = '' target.hash = '' - return target + return { + url: target, + headers, + includeDeviceKey: true + } } async function sendBarkWithConfig( @@ -661,19 +700,19 @@ async function sendBarkWithConfig( ) { const serverUrl = config.serverUrl?.trim() const deviceKey = config.deviceKey?.trim() - if (!serverUrl || !deviceKey) { + if (!serverUrl) { throw new Error(getMessage(DEFAULT_APP_LOCALE, 'api.errors.notifications.barkDisabledOrIncomplete')) } - const response = await fetch(resolveBarkPushUrl(serverUrl, locale), { + const requestTarget = buildBarkRequestTarget(serverUrl, deviceKey, locale) + + const response = await fetch(requestTarget.url, { method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, + headers: requestTarget.headers, body: JSON.stringify({ - device_key: deviceKey, title: message.title, body: message.text, + ...(requestTarget.includeDeviceKey ? { device_key: deviceKey } : {}), ...(config.isArchive ? { isArchive: 1 } : {}) }) }) diff --git a/apps/api/tests/integration/settings-routes.test.ts b/apps/api/tests/integration/settings-routes.test.ts index 5403f6b..ed3b457 100644 --- a/apps/api/tests/integration/settings-routes.test.ts +++ b/apps/api/tests/integration/settings-routes.test.ts @@ -300,7 +300,7 @@ describe('settings routes validation', () => { payload: { gotifyNotificationsEnabled: true, gotifyConfig: { - url: 'http://127.0.0.1:8080', + url: 'ftp://gotify.example.com', token: 'token', ignoreSsl: false } @@ -311,6 +311,28 @@ describe('settings routes validation', () => { expect(res.json().error.message).toContain('Gotify URL') }) + it('accepts a private gotify url for self-hosted deployments', async () => { + const res = await app.inject({ + method: 'PATCH', + url: '/settings', + payload: { + gotifyNotificationsEnabled: true, + gotifyConfig: { + url: 'http://192.168.50.10:8080', + token: 'token', + ignoreSsl: false + } + } + }) + + expect(res.statusCode).toBe(200) + expect(store.get('gotifyConfig')).toMatchObject({ + url: 'http://192.168.50.10:8080', + token: 'token', + ignoreSsl: false + }) + }) + it('rejects incomplete bark config when enabling bark notifications', async () => { const res = await app.inject({ method: 'PATCH', @@ -318,7 +340,7 @@ describe('settings routes validation', () => { payload: { barkNotificationsEnabled: true, barkConfig: { - serverUrl: '', + serverUrl: 'https://api.day.app', deviceKey: '' } } @@ -328,6 +350,50 @@ describe('settings routes validation', () => { expect(res.json().error.message).toContain('启用 Bark 时必须填写') }) + it('accepts bark custom url mode without a separate device key', async () => { + const res = await app.inject({ + method: 'PATCH', + url: '/settings', + payload: { + barkNotificationsEnabled: true, + barkConfig: { + serverUrl: 'https://my-bark.example/custom-key', + deviceKey: '', + isArchive: false + } + } + }) + + expect(res.statusCode).toBe(200) + expect(store.get('barkConfig')).toMatchObject({ + serverUrl: 'https://my-bark.example/custom-key', + deviceKey: '', + isArchive: false + }) + }) + + it('accepts a private bark server url for self-hosted deployments', async () => { + const res = await app.inject({ + method: 'PATCH', + url: '/settings', + payload: { + barkNotificationsEnabled: true, + barkConfig: { + serverUrl: 'http://192.168.50.11:8080', + deviceKey: 'device-key', + isArchive: false + } + } + }) + + expect(res.statusCode).toBe(200) + expect(store.get('barkConfig')).toMatchObject({ + serverUrl: 'http://192.168.50.11:8080', + deviceKey: 'device-key', + isArchive: false + }) + }) + it('rejects incomplete notifyx config when enabling notifyx notifications in english locale', async () => { const res = await app.inject({ method: 'PATCH', diff --git a/apps/api/tests/unit/channel-notification-new-channels.test.ts b/apps/api/tests/unit/channel-notification-new-channels.test.ts index 80cd1c4..0ea9a15 100644 --- a/apps/api/tests/unit/channel-notification-new-channels.test.ts +++ b/apps/api/tests/unit/channel-notification-new-channels.test.ts @@ -199,11 +199,57 @@ describe('channel notification new direct channels', () => { }) }) + it('sends bark test notifications to a custom bark url without appending /push', async () => { + mockFetchResponse({ + status: 200, + body: JSON.stringify({ code: 200, message: 'success' }) + }) + + const { sendTestBarkNotificationWithConfig } = await import('../../src/services/channel-notification.service') + await expect( + sendTestBarkNotificationWithConfig({ + serverUrl: 'https://my-bark.example/custom-key', + deviceKey: '', + isArchive: false + }) + ).resolves.toEqual({ success: true }) + + const [url, init] = channelState.fetchMock.mock.calls[0] + expect(String(url)).toBe('https://my-bark.example/custom-key') + expect(JSON.parse(String(init?.body))).toMatchObject({ + title: expect.any(String), + body: expect.any(String) + }) + expect(JSON.parse(String(init?.body))).not.toHaveProperty('device_key') + }) + + it('sends bark test notifications with basic auth when the custom server url contains credentials', async () => { + mockFetchResponse({ + status: 200, + body: JSON.stringify({ code: 200, message: 'success' }) + }) + + const { sendTestBarkNotificationWithConfig } = await import('../../src/services/channel-notification.service') + await expect( + sendTestBarkNotificationWithConfig({ + serverUrl: 'https://admin:p%40ss@my-bark.example/custom-key', + deviceKey: '', + isArchive: false + }) + ).resolves.toEqual({ success: true }) + + const [url, init] = channelState.fetchMock.mock.calls[0] + expect(String(url)).toBe('https://my-bark.example/custom-key') + expect(init?.headers).toMatchObject({ + Authorization: `Basic ${Buffer.from('admin:p@ss').toString('base64')}` + }) + }) + it('rejects bark test notifications when config is incomplete', async () => { const { sendTestBarkNotificationWithConfig } = await import('../../src/services/channel-notification.service') await expect( sendTestBarkNotificationWithConfig({ - serverUrl: '', + serverUrl: 'https://api.day.app', deviceKey: '', isArchive: false }) @@ -334,6 +380,25 @@ describe('channel notification new direct channels', () => { ).rejects.toThrow('rejected') }) + it('sends bark test notifications to a private self-hosted server url', async () => { + mockFetchResponse({ + status: 200, + body: JSON.stringify({ code: 200, message: 'success' }) + }) + + const { sendTestBarkNotificationWithConfig } = await import('../../src/services/channel-notification.service') + await expect( + sendTestBarkNotificationWithConfig({ + serverUrl: 'http://192.168.50.11:8080', + deviceKey: 'device-key', + isArchive: false + }) + ).resolves.toEqual({ success: true }) + + const [url] = channelState.fetchMock.mock.calls[0] + expect(String(url)).toBe('http://192.168.50.11:8080/push') + }) + it('marks bark dispatches as sent and skips repeated dispatches', async () => { channelState.configureSettings({ barkNotificationsEnabled: true, diff --git a/apps/web/src/pages/SettingsPage.vue b/apps/web/src/pages/SettingsPage.vue index a951f46..5ccd337 100644 --- a/apps/web/src/pages/SettingsPage.vue +++ b/apps/web/src/pages/SettingsPage.vue @@ -1219,15 +1219,27 @@ function validateBarkSettings(action: 'save' | 'test') { return true } - const missing = getMissingRequiredFields([ - [t('common.labels.serverUrl'), settingsForm.barkConfig.serverUrl], - [t('common.labels.deviceKey'), settingsForm.barkConfig.deviceKey] - ]) + const requiredFields: Array<[string, string | number | boolean | null | undefined]> = [ + [t('common.labels.serverUrl'), settingsForm.barkConfig.serverUrl] + ] + if (!isBarkCustomServerUrl(settingsForm.barkConfig.serverUrl)) { + requiredFields.push([t('common.labels.deviceKey'), settingsForm.barkConfig.deviceKey]) + } + const missing = getMissingRequiredFields(requiredFields) if (!missing.length) return true message.error(t('settings.validation.barkMissingFields', { fields: joinFieldLabels(missing) })) return false } +function isBarkCustomServerUrl(serverUrl: string) { + try { + const parsed = new URL(serverUrl.trim()) + return parsed.pathname.replace(/\/+$/, '') !== '' + } catch { + return false + } +} + function validateNotifyxSettings(action: 'save' | 'test') { if (action === 'save' && !settingsForm.notifyxNotificationsEnabled) { return true diff --git a/apps/web/tests/unit/components/settings-import-export.test.ts b/apps/web/tests/unit/components/settings-import-export.test.ts index 9abfb1d..1bd9562 100644 --- a/apps/web/tests/unit/components/settings-import-export.test.ts +++ b/apps/web/tests/unit/components/settings-import-export.test.ts @@ -96,4 +96,13 @@ describe('settings import export section', () => { expect(source).not.toContain("t('settings.labels.interfaceLocale')") expect(source).not.toContain('localeOptions') }) + + it('allows custom bark server urls without requiring a separate device key', () => { + const source = readFileSync('src/pages/SettingsPage.vue', 'utf8') + + expect(source).toContain('function isBarkCustomServerUrl') + expect(source).toContain("const parsed = new URL(serverUrl.trim())") + expect(source).toContain("parsed.pathname.replace(/\\/+$/, '') !== ''") + expect(source).toContain("if (!isBarkCustomServerUrl(settingsForm.barkConfig.serverUrl))") + }) })