fix: support self-hosted bark and gotify delivery

- allow full Bark urls and inline basic auth without appending /push
- permit private self-hosted Bark and Gotify endpoints in validation and dispatch
- cover custom-url and self-hosted delivery with targeted tests
This commit is contained in:
SmileQWQ
2026-05-28 19:02:48 +08:00
parent 9d4cfdc3b9
commit 88750bb00a
6 changed files with 234 additions and 31 deletions

View File

@@ -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<ReturnType<typeof getAppSettings>>,
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) {

View File

@@ -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<string, string> = {
'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 } : {})
})
})

View File

@@ -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',

View File

@@ -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,

View File

@@ -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

View File

@@ -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))")
})
})