bark-server 是 [[Bark]] 的后端服务,支持通过 [[Docker]] 或 [[Cloudflare]] 自行部署。
安装
通过 [[Docker]] 安装:
docker run -dt --name bark -p 8080:8080 -v `pwd`/bark-data:/data finab/bark-server
或者直接在 [[Cloudflare]] 中通过 Worker 部署:
export default { async fetch(request, env, ctx) { return await handleRequest(request, env, ctx) }}
// 是否允许新建设备const isAllowNewDevice = true// 是否允许查询设备数量const isAllowQueryNums = true// 根路径const rootPath = '/'// BasicAuth username:passwordconst basicAuth = ''
async function handleRequest(request, env, ctx) { const {searchParams, pathname} = new URL(request.url) const handler = new Handler(env) const realPathname = pathname.replace((new RegExp('^' + rootPath.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&"))), '/')
switch (realPathname) { case "/register": { return handler.register(searchParams) } case "/ping": { return handler.ping(searchParams) } case "/healthz": { return handler.healthz(searchParams) } case "/info": { if (!util.validateBasicAuth(request)) { return new Response('Unauthorized', { status: 401, headers: { 'content-type': 'text/plain', 'WWW-Authenticate': 'Basic', } }) }
return handler.info(searchParams) } default: { const pathParts = realPathname.split('/')
if (pathParts[1]) { if (!util.validateBasicAuth(request)) { return new Response('Unauthorized', { status: 401, headers: { 'content-type': 'text/plain', 'WWW-Authenticate': 'Basic', } }) }
const contentType = request.headers.get('content-type') let requestBody = {}
try { if (contentType && contentType.includes('application/json')) { requestBody = await request.json() }else if (contentType && contentType.includes('application/x-www-form-urlencoded')){ const formData = await request.formData() formData.forEach((value, key) => {requestBody[key] = value}) }else{ searchParams.forEach((value, key) => {requestBody[key] = value})
if (pathParts.length === 3) { requestBody.body = pathParts[2] } else if (pathParts.length === 4) { requestBody.title = pathParts[2] requestBody.body = pathParts[3] } else if (pathParts.length === 5) { requestBody.title = pathParts[2] requestBody.subtitle = pathParts[3] requestBody.body = pathParts[4] } else if (pathParts.length > 5) { return new Response(JSON.stringify({ 'code': 404, 'message': `Cannot ${request.method} ${realPathname}`, 'timestamp': util.getTimestamp(), }), { status: 404, headers: { 'content-type': 'application/json', } }) } }
if (requestBody.device_keys && typeof requestBody.device_keys === 'string') { if (requestBody.device_keys.startsWith('[') || requestBody.device_keys.endsWith(']')) { requestBody.device_keys = JSON.parse(requestBody.device_keys) } else { requestBody.device_keys = (decodeURIComponent(requestBody.device_keys).trim()).split(',').map(item => item.replace(/"/g, '').trim()) }
if (typeof requestBody.device_keys === 'string') { requestBody.device_keys = [requestBody.device_keys] } } } catch (error) { return new Response(JSON.stringify({ 'code': 400, 'message': `request bind failed: ${error}`, 'timestamp': util.getTimestamp(), }), { status: 400, headers: { 'content-type': 'application/json', } }) }
if (requestBody.device_keys && requestBody.device_keys.length > 0) { return new Response(JSON.stringify({ 'code': 200, 'message': 'success', 'data': await Promise.all(requestBody.device_keys.map(async (device_key) => { if (!device_key) { return { code: 400, message: 'device key is empty', device_key: device_key, } }
const response = await handler.push({...requestBody, device_key}) const responseBody = await response.json() return { code: response.status, message: responseBody.message, device_key: device_key, } })), 'timestamp': util.getTimestamp(), }), { status: 200, headers: { 'content-type': 'application/json', } }) }
if (realPathname != '/push') { requestBody.device_key = pathParts[1] }
if (!requestBody.device_key) { return new Response(JSON.stringify({ 'code': 400, 'message': 'device key is empty', 'timestamp': util.getTimestamp(), }), { status: 400, headers: { 'content-type': 'application/json', } }) }
return handler.push(requestBody) }
return new Response(JSON.stringify({ 'code': 404, 'message': `Cannot ${request.method} ${realPathname}`, 'timestamp': util.getTimestamp(), }), { status: 404, headers: { 'content-type': 'application/json', } }) } }}
class Handler { constructor(env) { this.version = "v2.2.5" this.build = "2025-07-28 23:59:41" this.arch = "js" this.commit = "a2a278aec532a41bf023276d4e7cde6924f5dc8d"
const db = new Database(env)
this.register = async (parameters) => { const deviceToken = parameters.get('devicetoken') let key = parameters.get('key')
if (!deviceToken) { return new Response(JSON.stringify({ 'code': 400, 'message': 'device token is empty', 'timestamp': util.getTimestamp(), }), { status: 400, headers: { 'content-type': 'application/json', } }) }
if (!(key && await db.deviceTokenByKey(key))){ if (isAllowNewDevice) { key = await util.newShortUUID() } else { return new Response(JSON.stringify({ 'code': 500, 'message': "device registration failed: register disabled", }), { status: 500, headers: { 'content-type': 'application/json', } }) } }
await db.saveDeviceTokenByKey(key, deviceToken)
return new Response(JSON.stringify({ 'code': 200, 'message': 'success', 'timestamp': util.getTimestamp(), 'data': { 'key': key, 'device_key': key, 'device_token': deviceToken, }, }), { status: 200, headers: { 'content-type': 'application/json', } }) }
this.ping = async (parameters) => { return new Response(JSON.stringify({ 'code': 200, 'message': 'pong', 'timestamp': util.getTimestamp(), }), { status: 200, headers: { 'content-type': 'application/json', } }) }
this.healthz = async (parameters) => { return new Response("ok", { status: 200, headers: { 'content-type': 'text/plain', } }) }
this.info = async (parameters) => { if (isAllowQueryNums) { this.devices = await db.countAll() }
return new Response(JSON.stringify({ 'version': this.version, 'build': this.build, 'arch': this.arch, 'commit': this.commit, 'devices': this.devices, }), { status: 200, headers: { 'content-type': 'application/json', } }) }
this.push = async (parameters) => { const deviceToken = await db.deviceTokenByKey(parameters.device_key)
if (!deviceToken) { return new Response(JSON.stringify({ 'code': 400, 'message': `failed to get device token: failed to get [${parameters.device_key}] device token from database`, 'timestamp': util.getTimestamp(), }), { status: 400, headers: { 'content-type': 'application/json', } }) }
let title = parameters.title || undefined let subtitle = parameters.subtitle || undefined let body = parameters.body || undefined
try { if (title) { title = decodeURIComponent(title.replaceAll("\\+","%20")) }
if (subtitle) { subtitle = decodeURIComponent(subtitle.replaceAll("\\+","%20")) }
if (body) { body = decodeURIComponent(body.replaceAll("\\+","%20")) } } catch (error) { return new Response(JSON.stringify({ 'code': 500, 'meaasge': `url path parse failed: ${error}`, 'timestamp': util.getTimestamp(), }), { status: 500, headers: { 'content-type': 'application/json', } }) }
let sound = parameters.sound || undefined if (sound) { if (!sound.endsWith('.caf')) { sound += '.caf' } } else { sound = '1107' }
const group = parameters.group || undefined
const call = parameters.call || undefined const isArchive = parameters.isArchive || undefined const icon = parameters.icon || undefined const ciphertext = parameters.ciphertext || undefined const level = parameters.level || undefined const volume = parameters.volume || undefined const url = parameters.url || undefined const image = parameters.image || undefined const copy = parameters.copy || undefined const badge = parameters.badge || undefined const autoCopy = parameters.autoCopy || undefined const action = parameters.action || undefined const iv = parameters.iv || undefined const id = parameters.id || undefined const _delete = parameters.delete || undefined
// https://developer.apple.com/documentation/usernotifications/generating-a-remote-notification const aps = { 'aps': (_delete) ? { 'content-available': 1, 'mutable-content': 1, } : { 'alert': { 'title': title, 'subtitle': subtitle, 'body': (!title && !subtitle && !body) ? 'Empty Message' : body, 'launch-image': undefined, 'title-loc-key': undefined, 'title-loc-args': undefined, 'subtitle-loc-key': undefined, 'subtitle-loc-args': undefined, 'loc-key': undefined, 'loc-args': undefined, }, 'badge': undefined, 'sound': sound, 'thread-id': group, 'category': 'myNotificationCategory', 'content-available': undefined, 'mutable-content': 1, 'target-content-id': undefined, 'interruption-level': undefined, 'relevance-score': undefined, 'filter-criteria': undefined, 'stale-date': undefined, 'content-state': undefined, 'timestamp': undefined, 'event': undefined, 'dimissal-date': undefined, 'attributes-type': undefined, 'attributes': undefined, }, // ExtParams 'group': group, 'call': call, 'isarchive': isArchive, 'icon': icon, 'ciphertext': ciphertext, 'level': level, 'volume': volume, 'url': url, 'copy': copy, 'badge': badge, 'autocopy': autoCopy, 'action': action, 'iv': iv, 'image': image, 'id': id, 'delete': _delete, }
const headers = { 'apns-topic': undefined, 'apns-id': undefined, 'apns-collapse-id': id, 'apns-priority': undefined, 'apns-expiration': undefined, 'apns-push-type': (_delete) ? 'background' : 'alert', }
const apns = new APNs(db) const response = await apns.push(deviceToken, headers, aps)
if (response.status === 200) { return new Response(JSON.stringify({ 'code': 200, 'message': 'success', 'timestamp': util.getTimestamp(), }), { status: 200, headers: { 'content-type': 'application/json', } }) } else { let message const responseText = await response.text()
try { message = JSON.parse(responseText).reason } catch (err) { message = responseText }
return new Response(JSON.stringify({ 'code': response.status, 'message': `push failed: ${message}`, 'timestamp': util.getTimestamp(), }), { status: response.status, headers: { 'content-type': 'application/json', } }) } } }}
class APNs { constructor(db) { const generateAuthToken = async () => { const TOKEN_KEY = ` -----BEGIN PRIVATE KEY----- MIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQg4vtC3g5L5HgKGJ2+ T1eA0tOivREvEAY2g+juRXJkYL2gCgYIKoZIzj0DAQehRANCAASmOs3JkSyoGEWZ sUGxFs/4pw1rIlSV2IC19M8u3G5kq36upOwyFWj9Gi3Ejc9d3sC7+SHRqXrEAJow 8/7tRpV+ -----END PRIVATE KEY----- `
// Parse private key const privateKeyPEM = TOKEN_KEY.replace('-----BEGIN PRIVATE KEY-----', '').replace('-----END PRIVATE KEY-----', '').replace(/\s/g, '') // Decode private key const privateKeyArrayBuffer = util.base64ToArrayBuffer(privateKeyPEM) const privateKey = await crypto.subtle.importKey('pkcs8', privateKeyArrayBuffer, { name: 'ECDSA', namedCurve: 'P-256', }, false, ['sign']) const TEAM_ID = '5U8LBRXG3A' const AUTH_KEY_ID = 'LH4T9V5U4R' // Generate the JWT token const JWT_ISSUE_TIME = util.getTimestamp() const JWT_HEADER = btoa(JSON.stringify({ alg: 'ES256', kid: AUTH_KEY_ID })).replace('+', '-').replace('/', '_').replace(/=+$/, '') const JWT_CLAIMS = btoa(JSON.stringify({ iss: TEAM_ID, iat: JWT_ISSUE_TIME })).replace('+', '-').replace('/', '_').replace(/=+$/, '') const JWT_HEADER_CLAIMS = JWT_HEADER + '.' + JWT_CLAIMS // Sign const jwtArray = new TextEncoder().encode(JWT_HEADER_CLAIMS) const signature = await crypto.subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, privateKey, jwtArray) const signatureArray = new Uint8Array(signature) const JWT_SIGNED_HEADER_CLAIMS = btoa(String.fromCharCode(...signatureArray)).replace('+', '-').replace('/', '_').replace(/=+$/, '') const AUTHENTICATION_TOKEN = JWT_HEADER_CLAIMS + '.' + JWT_SIGNED_HEADER_CLAIMS
return AUTHENTICATION_TOKEN }
const getAuthToken = async () => { let authToken = await db.authorizationToken()
if (authToken) { return await authToken }
authToken = await generateAuthToken() await db.saveAuthorizationToken(authToken, util.getTimestamp())
return authToken }
this.push = async (deviceToken, headers, aps) => { const TOPIC = 'me.fin.bark' const APNS_HOST_NAME = 'api.push.apple.com' const AUTHENTICATION_TOKEN = await getAuthToken()
return await fetch(`https://${APNS_HOST_NAME}/3/device/${deviceToken}`, { method: 'POST', headers: JSON.parse(JSON.stringify({ 'apns-topic': headers['apns-topic'] || TOPIC, 'apns-id': headers['apns-id'] || undefined, 'apns-collapse-id': headers['apns-collapse-id'] || undefined, 'apns-priority': (headers['apns-priority'] > 0) ? headers['apns-priority'] : undefined, 'apns-expiration': util.getTimestamp() + 86400, 'apns-push-type': headers['apns-push-type'] || 'alert', 'authorization': `bearer ${AUTHENTICATION_TOKEN}`, 'content-type': 'application/json', })), body: JSON.stringify(aps), }) } }}
class Database { constructor(env) { const db = env.database
db.exec('CREATE TABLE IF NOT EXISTS `devices` (`id` INTEGER PRIMARY KEY, `key` VARCHAR(255) NOT NULL, `token` VARCHAR(255) NOT NULL, UNIQUE (`key`))') db.exec('CREATE TABLE IF NOT EXISTS `authorization` (`id` INTEGER PRIMARY KEY, `token` VARCHAR(255) NOT NULL, `time` VARCHAR(255) NOT NULL)')
this.countAll = async () => { const query = 'SELECT COUNT(*) as rowCount FROM `devices`' const result = await db.prepare(query).run()
return (result.results[0] || {"rowCount": -1}).rowCount }
this.deviceTokenByKey = async (key) => { const device_key = (key || '').replace(/[^a-zA-Z0-9]/g, '') || "_PLACE_HOLDER_" const query = 'SELECT `token` FROM `devices` WHERE `key` = ?' const result = await db.prepare(query).bind(device_key).run()
return (result.results[0] || {}).token }
this.saveDeviceTokenByKey = async (key, token) => { const device_token = (token || '').replace(/[^a-z0-9]/g, '') || "_PLACE_HOLDER_" const query = 'INSERT OR REPLACE INTO `devices` (`key`, `token`) VALUES (?, ?)' const result = await db.prepare(query).bind(key, device_token).run()
return result }
this.saveAuthorizationToken = async (token) => { const query = 'INSERT OR REPLACE INTO `authorization` (`id`, `token`, `time`) VALUES (1, ?, ?)' const result = await db.prepare(query).bind(token, util.getTimestamp()).run()
return result }
this.authorizationToken = async () => { const query = 'SELECT `token`, `time` FROM `authorization` WHERE `id` = 1' const result = await db.prepare(query).run()
if (result.results.length > 0) { const tokenTime = parseInt(result.results[0].time) const timeDifference = util.getTimestamp() - tokenTime
if (timeDifference <= 3000) { return result.results[0].token } }
return undefined } }}
class Util { constructor() { this.getTimestamp = () => { return Math.floor(Date.now() / 1000) }
this.base64ToArrayBuffer = (base64) => { const binaryString = atob(base64) const length = binaryString.length const buffer = new Uint8Array(length) for (let i = 0; i < length; i++) { buffer[i] = binaryString.charCodeAt(i) } return buffer }
this.newShortUUID = async () => { const uuid = crypto.randomUUID() const hashBuffer = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(uuid)) const hashArray = Array.from(new Uint8Array(hashBuffer))
return btoa(String.fromCharCode(...hashArray)).replace(/[^a-zA-Z0-9]/g, '').slice(0, 22) }
this.validateBasicAuth = (request) => { if (basicAuth) { const header = 'Basic ' + btoa(`${basicAuth}`) const authHeader = request.headers.get('Authorization') return header === authHeader }
return true } }}
const util = new Util()