@goodandready/dsh-messenger-gateway 0.4.8 → 0.4.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/file-manager.js +53 -1
- package/lib/http.js +18 -5
- package/lib/index.js +8 -7
- package/lib/locales/index.js +11 -2
- package/lib/settings-bridge.js +47 -17
- package/lib/telegram-format.js +23 -2
- package/package.json +1 -1
package/lib/file-manager.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { realpathSync } from 'node:fs'
|
|
1
2
|
import { readdir, stat, readFile } from 'node:fs/promises'
|
|
2
|
-
import { resolve, normalize, relative, join, extname, isAbsolute } from 'node:path'
|
|
3
|
+
import { resolve, normalize, relative, join, extname, isAbsolute, sep, dirname } from 'node:path'
|
|
3
4
|
|
|
4
5
|
export function formatFileSize(bytes) {
|
|
5
6
|
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B'
|
|
@@ -9,6 +10,17 @@ export function formatFileSize(bytes) {
|
|
|
9
10
|
return `${val} ${units[i]}`
|
|
10
11
|
}
|
|
11
12
|
|
|
13
|
+
function isSubpath(parent, child) {
|
|
14
|
+
const normParent = normalize(parent)
|
|
15
|
+
const normChild = normalize(child)
|
|
16
|
+
if (process.platform === 'win32') {
|
|
17
|
+
const p = normParent.toLowerCase()
|
|
18
|
+
const c = normChild.toLowerCase()
|
|
19
|
+
return c === p || c.startsWith(p.endsWith(sep) ? p : p + sep)
|
|
20
|
+
}
|
|
21
|
+
return normChild === normParent || normChild.startsWith(normParent.endsWith(sep) ? normParent : normParent + sep)
|
|
22
|
+
}
|
|
23
|
+
|
|
12
24
|
export function resolveSafePath(baseCwd, userPath = '.') {
|
|
13
25
|
const base = resolve(normalize(baseCwd || process.cwd()))
|
|
14
26
|
const cleanUser = String(userPath || '').trim() || '.'
|
|
@@ -21,6 +33,46 @@ export function resolveSafePath(baseCwd, userPath = '.') {
|
|
|
21
33
|
err.code = 'ERR_PATH_TRAVERSAL'
|
|
22
34
|
throw err
|
|
23
35
|
}
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
const realBase = realpathSync(base)
|
|
39
|
+
try {
|
|
40
|
+
const realTarget = realpathSync(target)
|
|
41
|
+
if (!isSubpath(realBase, realTarget)) {
|
|
42
|
+
const err = new Error('Access denied: symlink resolves out of workspace')
|
|
43
|
+
err.code = 'ERR_PATH_TRAVERSAL'
|
|
44
|
+
throw err
|
|
45
|
+
}
|
|
46
|
+
} catch (targetErr) {
|
|
47
|
+
if (targetErr?.code === 'ERR_PATH_TRAVERSAL') throw targetErr
|
|
48
|
+
if (targetErr?.code === 'ENOENT') {
|
|
49
|
+
let curr = dirname(target)
|
|
50
|
+
while (curr === base || isSubpath(base, curr)) {
|
|
51
|
+
try {
|
|
52
|
+
const realCurr = realpathSync(curr)
|
|
53
|
+
if (!isSubpath(realBase, realCurr)) {
|
|
54
|
+
const err = new Error('Access denied: symlink ancestor resolves out of workspace')
|
|
55
|
+
err.code = 'ERR_PATH_TRAVERSAL'
|
|
56
|
+
throw err
|
|
57
|
+
}
|
|
58
|
+
break
|
|
59
|
+
} catch (ancestorErr) {
|
|
60
|
+
if (ancestorErr?.code === 'ERR_PATH_TRAVERSAL') throw ancestorErr
|
|
61
|
+
if (ancestorErr?.code === 'ENOENT') {
|
|
62
|
+
const parent = dirname(curr)
|
|
63
|
+
if (parent === curr) break
|
|
64
|
+
curr = parent
|
|
65
|
+
continue
|
|
66
|
+
}
|
|
67
|
+
break
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
} catch (baseErr) {
|
|
73
|
+
if (baseErr?.code === 'ERR_PATH_TRAVERSAL') throw baseErr
|
|
74
|
+
}
|
|
75
|
+
|
|
24
76
|
return { base, target, relativePath: rel || '.' }
|
|
25
77
|
}
|
|
26
78
|
|
package/lib/http.js
CHANGED
|
@@ -36,7 +36,7 @@ export function isLoopback(value) {
|
|
|
36
36
|
|| address.startsWith('::ffff:127.')
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
export function isTrustedSettingsRequest(req) {
|
|
39
|
+
export function isTrustedSettingsRequest(req, expectedSecret) {
|
|
40
40
|
const remote = req?.socket?.remoteAddress
|
|
41
41
|
const host = String(req?.headers?.host || '')
|
|
42
42
|
if (!host) return false
|
|
@@ -44,6 +44,21 @@ export function isTrustedSettingsRequest(req) {
|
|
|
44
44
|
const site = String(req?.headers?.['sec-fetch-site'] || '').toLowerCase()
|
|
45
45
|
if (site === 'cross-site') return false
|
|
46
46
|
|
|
47
|
+
const secret = String(expectedSecret || '').trim()
|
|
48
|
+
if (secret) {
|
|
49
|
+
const authHeader = req?.headers?.authorization || ''
|
|
50
|
+
const bearer = authHeader.startsWith('Bearer ') ? authHeader.slice(7).trim() : ''
|
|
51
|
+
const tokenHeader = req?.headers?.['x-webhook-secret'] || ''
|
|
52
|
+
const provided = bearer || tokenHeader
|
|
53
|
+
if (provided && timingSafeCompare(provided, secret)) {
|
|
54
|
+
return true
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (remote !== undefined && !isLoopback(remote)) {
|
|
59
|
+
return false
|
|
60
|
+
}
|
|
61
|
+
|
|
47
62
|
const origin = String(req?.headers?.origin || '')
|
|
48
63
|
if (origin) {
|
|
49
64
|
try {
|
|
@@ -67,7 +82,6 @@ export function isTrustedSettingsRequest(req) {
|
|
|
67
82
|
}
|
|
68
83
|
|
|
69
84
|
if (site === 'same-origin') {
|
|
70
|
-
if (remote !== undefined && !isLoopback(remote)) return false
|
|
71
85
|
return true
|
|
72
86
|
}
|
|
73
87
|
|
|
@@ -78,10 +92,9 @@ export function isAuthorizedMessengerRequest(req, webhooksConfig) {
|
|
|
78
92
|
const site = String(req?.headers?.['sec-fetch-site'] || '').toLowerCase()
|
|
79
93
|
if (site === 'cross-site') return false
|
|
80
94
|
|
|
81
|
-
if (isTrustedSettingsRequest(req)) return true
|
|
82
|
-
if (isLoopback(req?.socket?.remoteAddress)) return true
|
|
83
|
-
|
|
84
95
|
const secret = String(webhooksConfig?.secret || '').trim()
|
|
96
|
+
if (isTrustedSettingsRequest(req, secret)) return true
|
|
97
|
+
if (isLoopback(req?.socket?.remoteAddress)) return true
|
|
85
98
|
if (secret) {
|
|
86
99
|
const authHeader = req?.headers?.authorization || ''
|
|
87
100
|
const bearer = authHeader.startsWith('Bearer ') ? authHeader.slice(7).trim() : ''
|
package/lib/index.js
CHANGED
|
@@ -22,7 +22,8 @@ export { Config }
|
|
|
22
22
|
|
|
23
23
|
function resolveConfig(raw) {
|
|
24
24
|
// Unwrap once here: everything downstream reads plain values.
|
|
25
|
-
const
|
|
25
|
+
const plain = plainConfig(raw)
|
|
26
|
+
const cfg = plainConfig(Config(plain))
|
|
26
27
|
const home = process.env.DSH_HOME || join(homedir(), '.dsh')
|
|
27
28
|
if (!cfg.media.cacheDir) {
|
|
28
29
|
cfg.media = { ...cfg.media, cacheDir: join(home, 'messenger-gateway', 'cache') }
|
|
@@ -141,7 +142,7 @@ function isExcludedSession(sessionId, prefixes) {
|
|
|
141
142
|
}
|
|
142
143
|
|
|
143
144
|
export function apply(ctx, config) {
|
|
144
|
-
const entry = resolveConfig(structuredClone(config || {}))
|
|
145
|
+
const entry = resolveConfig(structuredClone(plainConfig(config || {})))
|
|
145
146
|
let gateway
|
|
146
147
|
let source = () => entry
|
|
147
148
|
let settingsApi
|
|
@@ -379,7 +380,7 @@ export function apply(ctx, config) {
|
|
|
379
380
|
kind: 'exact', path: '/dsh-messenger-gateway/smoke',
|
|
380
381
|
handler: async (req, res) => {
|
|
381
382
|
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' })
|
|
382
|
-
if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'forbidden' })
|
|
383
|
+
if (!isTrustedSettingsRequest(req, source().webhooks?.secret)) return writeJson(res, 403, { ok: false, error: 'forbidden' })
|
|
383
384
|
const gw = getGw()
|
|
384
385
|
if (!gw) return writeJson(res, 503, { ok: false, error: 'gateway not running' })
|
|
385
386
|
const parsed = await readJsonSafe(req, res, 65536)
|
|
@@ -456,7 +457,7 @@ export function apply(ctx, config) {
|
|
|
456
457
|
handler: async (req, res) => {
|
|
457
458
|
if (req.method === 'GET') return writeJson(res, 200, { ok: true, config: publicConfig(source()) })
|
|
458
459
|
if (req.method !== 'PUT') return writeJson(res, 405, { ok: false, error: 'GET or PUT' })
|
|
459
|
-
if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'forbidden' })
|
|
460
|
+
if (!isTrustedSettingsRequest(req, source().webhooks?.secret)) return writeJson(res, 403, { ok: false, error: 'forbidden' })
|
|
460
461
|
if (!settingsApi?.isReady?.()) return writeJson(res, 503, { ok: false, error: 'settings not ready' })
|
|
461
462
|
const parsedConfig = await readJsonSafe(req, res, 1_048_576)
|
|
462
463
|
if (!parsedConfig.ok) return
|
|
@@ -498,7 +499,7 @@ export function apply(ctx, config) {
|
|
|
498
499
|
kind: 'exact', path: '/dsh-messenger-gateway/pairing',
|
|
499
500
|
handler: async (req, res) => {
|
|
500
501
|
if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET only' })
|
|
501
|
-
if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'forbidden' })
|
|
502
|
+
if (!isTrustedSettingsRequest(req, source().webhooks?.secret)) return writeJson(res, 403, { ok: false, error: 'forbidden' })
|
|
502
503
|
const gw = getGw()
|
|
503
504
|
if (!gw) return writeJson(res, 503, { ok: false, error: 'gateway not running' })
|
|
504
505
|
writeJson(res, 200, {
|
|
@@ -513,7 +514,7 @@ export function apply(ctx, config) {
|
|
|
513
514
|
kind: 'exact', path: '/dsh-messenger-gateway/pairing/approve',
|
|
514
515
|
handler: async (req, res) => {
|
|
515
516
|
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' })
|
|
516
|
-
if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'forbidden' })
|
|
517
|
+
if (!isTrustedSettingsRequest(req, source().webhooks?.secret)) return writeJson(res, 403, { ok: false, error: 'forbidden' })
|
|
517
518
|
const gw = getGw()
|
|
518
519
|
if (!gw) return writeJson(res, 503, { ok: false, error: 'gateway not running' })
|
|
519
520
|
const parsed = await readJsonSafe(req, res, 65536)
|
|
@@ -527,7 +528,7 @@ export function apply(ctx, config) {
|
|
|
527
528
|
kind: 'exact', path: '/dsh-messenger-gateway/pairing/reject',
|
|
528
529
|
handler: async (req, res) => {
|
|
529
530
|
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' })
|
|
530
|
-
if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'forbidden' })
|
|
531
|
+
if (!isTrustedSettingsRequest(req, source().webhooks?.secret)) return writeJson(res, 403, { ok: false, error: 'forbidden' })
|
|
531
532
|
const gw = getGw()
|
|
532
533
|
if (!gw) return writeJson(res, 503, { ok: false, error: 'gateway not running' })
|
|
533
534
|
const parsed = await readJsonSafe(req, res, 65536)
|
package/lib/locales/index.js
CHANGED
|
@@ -9,11 +9,20 @@ export function getLocaleDictionary(locale = 'en') {
|
|
|
9
9
|
return en
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
export function
|
|
12
|
+
export function escapeHtml(text) {
|
|
13
|
+
return String(text ?? '')
|
|
14
|
+
.replace(/&/g, '&')
|
|
15
|
+
.replace(/</g, '<')
|
|
16
|
+
.replace(/>/g, '>')
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function t(key, params = {}, locale = 'en', options = {}) {
|
|
13
20
|
const dict = getLocaleDictionary(locale)
|
|
14
21
|
let template = dict[key] || en[key] || key
|
|
22
|
+
const escape = options.escape !== false
|
|
15
23
|
for (const [k, v] of Object.entries(params)) {
|
|
16
|
-
|
|
24
|
+
const val = escape ? escapeHtml(v) : String(v)
|
|
25
|
+
template = template.replaceAll(`{${k}}`, val)
|
|
17
26
|
}
|
|
18
27
|
return template
|
|
19
28
|
}
|
package/lib/settings-bridge.js
CHANGED
|
@@ -1,6 +1,30 @@
|
|
|
1
1
|
export function setupSettings(ctx, { namespace, Config, resolveConfig, baseConfig, sync, logger }) {
|
|
2
2
|
let settingsApi
|
|
3
|
-
let
|
|
3
|
+
let currentConfig = resolveConfig(ctx.fiber?.config ?? baseConfig ?? {})
|
|
4
|
+
let source = () => currentConfig
|
|
5
|
+
const watchers = new Set()
|
|
6
|
+
let readLive = () => null
|
|
7
|
+
|
|
8
|
+
const applyLiveConfig = (nextRaw) => {
|
|
9
|
+
const live = nextRaw ?? readLive() ?? ctx.fiber?.config ?? baseConfig
|
|
10
|
+
if (live) {
|
|
11
|
+
currentConfig = resolveConfig(live)
|
|
12
|
+
sync()
|
|
13
|
+
for (const cb of watchers) {
|
|
14
|
+
try { cb(currentConfig) } catch (err) { logger?.debug?.(`${namespace}: watcher error:`, err?.message || err) }
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const unbinds = []
|
|
20
|
+
if (typeof ctx?.on === 'function') {
|
|
21
|
+
const u1 = ctx.on('loader/volatile-update', () => applyLiveConfig())
|
|
22
|
+
if (typeof u1 === 'function') unbinds.push(u1)
|
|
23
|
+
const u2 = ctx.on('internal/update', (cfg) => applyLiveConfig(cfg))
|
|
24
|
+
if (typeof u2 === 'function') unbinds.push(u2)
|
|
25
|
+
const u3 = ctx.on('app-boot/config-reload', () => applyLiveConfig())
|
|
26
|
+
if (typeof u3 === 'function') unbinds.push(u3)
|
|
27
|
+
}
|
|
4
28
|
|
|
5
29
|
const mountSettings = (sctx) => {
|
|
6
30
|
const service = sctx?.settings || (typeof sctx?.get === 'function' ? sctx.get('settings') : null)
|
|
@@ -26,7 +50,8 @@ export function setupSettings(ctx, { namespace, Config, resolveConfig, baseConfi
|
|
|
26
50
|
const getRevision = () => {
|
|
27
51
|
try {
|
|
28
52
|
return service.describe?.()?.find?.((row) => row.ns === namespace)?.revision
|
|
29
|
-
} catch {
|
|
53
|
+
} catch (err) {
|
|
54
|
+
logger?.debug?.(`${namespace}: getRevision failed:`, err?.message || err)
|
|
30
55
|
return undefined
|
|
31
56
|
}
|
|
32
57
|
}
|
|
@@ -47,8 +72,9 @@ export function setupSettings(ctx, { namespace, Config, resolveConfig, baseConfi
|
|
|
47
72
|
return null
|
|
48
73
|
}
|
|
49
74
|
|
|
75
|
+
readLive = readLiveSettings
|
|
50
76
|
const liveVal = readLiveSettings()
|
|
51
|
-
|
|
77
|
+
if (liveVal) currentConfig = resolveConfig(liveVal)
|
|
52
78
|
source = () => currentConfig
|
|
53
79
|
|
|
54
80
|
const persist = async (next) => {
|
|
@@ -64,6 +90,9 @@ export function setupSettings(ctx, { namespace, Config, resolveConfig, baseConfi
|
|
|
64
90
|
}
|
|
65
91
|
currentConfig = resolveConfig(payload)
|
|
66
92
|
sync()
|
|
93
|
+
for (const cb of watchers) {
|
|
94
|
+
try { cb(currentConfig) } catch (err) { logger?.debug?.(`${namespace}: watcher error:`, err?.message || err) }
|
|
95
|
+
}
|
|
67
96
|
return currentConfig
|
|
68
97
|
}
|
|
69
98
|
|
|
@@ -72,27 +101,28 @@ export function setupSettings(ctx, { namespace, Config, resolveConfig, baseConfi
|
|
|
72
101
|
replace: async (next) => persist(next),
|
|
73
102
|
update: async (patch) => persist({ ...source(), ...patch }),
|
|
74
103
|
watch: (cb) => {
|
|
75
|
-
if (typeof
|
|
76
|
-
|
|
104
|
+
if (typeof cb === 'function') watchers.add(cb)
|
|
105
|
+
const unwatchService = typeof service.watch === 'function' ? service.watch(cb) : null
|
|
106
|
+
return () => {
|
|
107
|
+
watchers.delete(cb)
|
|
108
|
+
if (typeof unwatchService === 'function') unwatchService()
|
|
109
|
+
}
|
|
77
110
|
},
|
|
78
111
|
}
|
|
79
112
|
|
|
80
113
|
const onFn = typeof sctx?.on === 'function' ? sctx.on.bind(sctx) : (typeof ctx?.on === 'function' ? ctx.on.bind(ctx) : null)
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
sync()
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
})
|
|
91
|
-
: null
|
|
114
|
+
if (onFn) {
|
|
115
|
+
const d1 = onFn('settings/document-updated', (updatedNs) => {
|
|
116
|
+
if (updatedNs === namespace) applyLiveConfig()
|
|
117
|
+
})
|
|
118
|
+
if (typeof d1 === 'function') unbinds.push(d1)
|
|
119
|
+
}
|
|
92
120
|
|
|
93
121
|
if (effect) {
|
|
94
122
|
effect(() => () => {
|
|
95
|
-
|
|
123
|
+
for (const unbind of unbinds) {
|
|
124
|
+
try { unbind() } catch (err) { logger?.debug?.(`${namespace}: unbind error:`, err?.message || err) }
|
|
125
|
+
}
|
|
96
126
|
settingsApi = undefined
|
|
97
127
|
}, `${namespace}: settings`)
|
|
98
128
|
}
|
package/lib/telegram-format.js
CHANGED
|
@@ -7,12 +7,33 @@ export function escapeHtml(text) {
|
|
|
7
7
|
|
|
8
8
|
const PH = '\uE000'
|
|
9
9
|
|
|
10
|
+
const SAFE_SCHEMES = new Set(['https:', 'http:', 'tg:'])
|
|
11
|
+
|
|
12
|
+
export function isSafeUrl(rawUrl) {
|
|
13
|
+
if (!rawUrl || typeof rawUrl !== 'string') return false
|
|
14
|
+
const clean = rawUrl.replace(/&/g, '&').trim()
|
|
15
|
+
try {
|
|
16
|
+
const parsed = new URL(clean)
|
|
17
|
+
return SAFE_SCHEMES.has(parsed.protocol.toLowerCase())
|
|
18
|
+
} catch {
|
|
19
|
+
if (/^tg:\/\//i.test(clean)) return true
|
|
20
|
+
return false
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function formatSafeLink(label, url) {
|
|
25
|
+
if (!isSafeUrl(url)) {
|
|
26
|
+
return `${label} (${url})`
|
|
27
|
+
}
|
|
28
|
+
return `<a href="${url.replace(/"/g, '%22')}">${label}</a>`
|
|
29
|
+
}
|
|
30
|
+
|
|
10
31
|
// Render inline markdown (bold/italic/code/links) inside already-HTML-escaped text.
|
|
11
32
|
function renderInline(t) {
|
|
12
33
|
let s = escapeHtml(t)
|
|
13
34
|
s = s.replace(/\*\*([^*\n]+)\*\*/g, '<b>$1</b>')
|
|
14
35
|
s = s.replace(/`([^`\n]+)`/g, '<code>$1</code>')
|
|
15
|
-
s = s.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_, label, url) =>
|
|
36
|
+
s = s.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_, label, url) => formatSafeLink(label, url))
|
|
16
37
|
s = s.replace(/(^|[^*])\*([^*\n]+)\*(?!\*)/g, '$1<i>$2</i>')
|
|
17
38
|
return s
|
|
18
39
|
}
|
|
@@ -123,7 +144,7 @@ export function markdownToTelegramHtml(markdown) {
|
|
|
123
144
|
text = convertDetails(text, stash)
|
|
124
145
|
text = convertTaskLists(text, stash)
|
|
125
146
|
text = escapeHtml(text)
|
|
126
|
-
text = text.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_, label, url) =>
|
|
147
|
+
text = text.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_, label, url) => formatSafeLink(label, url))
|
|
127
148
|
text = text.replace(/\*\*([^*\n]+)\*\*/g, '<b>$1</b>')
|
|
128
149
|
text = text.replace(/__([^_\n]+)__/g, '<b>$1</b>')
|
|
129
150
|
text = text.replace(/~~([^~\n]+)~~/g, '<s>$1</s>')
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-messenger-gateway",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.10",
|
|
4
4
|
"description": "Telegram messenger bridge for DeepSeek Harness: sessions, steer, homes, inline asks, notify bridge, and optional TTS voice notes.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|