@pmeweb/plugin-chat 0.0.0-stage → 1.1.0

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/src/worker.js ADDED
@@ -0,0 +1,273 @@
1
+ // The site Worker's half of the PmeWeb chat: POST /api/chat/start opens a conversation, GET
2
+ // /api/chat/ws carries the visitor's socket. Everything else (the conversation Durable Object, the
3
+ // model, the limits, the lead email and SMS) lives in pmeweb-chat, reached over the CHAT service
4
+ // binding to its ChatApi entrypoint. A socket can only travel over `fetch` (an RPC method cannot
5
+ // carry one, spike task 1), so both routes are `env.CHAT.fetch`, with the RPC version in a header.
6
+ //
7
+ // - PREVIEW OFF (manifest `preview: "off"`): off the site's real hosts every route answers 403
8
+ // `disabled` and nothing is called, so nobody spends the client's cap from « Contenu » or a
9
+ // workers.dev address. Without `hosts`, every request is off (fail closed).
10
+ // - The host sent to pmeweb-chat is the one this request ARRIVED on, never a header or a body
11
+ // field: it is how pmeweb-chat finds the client's settings.
12
+ // - start: Turnstile (@pmeweb/turnstile, action `chat`, solved on this host), then CHAT_START_LIMIT
13
+ // (3 a minute) keyed on the visitor's IP, then pmeweb-chat. The contact form's start
14
+ // (`fallbackLead`) is on the looser CHAT_LIMIT instead, so a visitor refused a conversation can
15
+ // still leave a lead; a site that has not bound CHAT_START_LIMIT yet falls back to CHAT_LIMIT.
16
+ // The IP goes to siteverify, the limiters and, in the `x-pmw-visitor-ip` header only, to
17
+ // pmeweb-chat, which counts conversations per visitor per day (10) and keeps only a daily hash
18
+ // of it. pmeweb-chat trusts that header because only service bindings reach it.
19
+ // Only named fields go on; `sms` is sent only as `voipms` (pmeweb-chat refuses any other value),
20
+ // `photos` only as true. pmeweb-chat's status and JSON pass through (426 rpc_version, 503
21
+ // not_configured, ...): the widget reads them.
22
+ // - ws: rate-limited, then forwarded with the conversation id and the visitor key only, the
23
+ // Upgrade and RPC headers, and none of the visitor's cookies. The socket is never read here.
24
+ // A refusal past this point is pmeweb-chat's: an accepted socket closed with a 44xx code.
25
+ // - photo (config `photos: true` only): rate-limited like a message, capped at 5 MB (the declared
26
+ // length first, then a counted read), forwarded as bytes with the conversation id, the visitor key
27
+ // and the message id only. pmeweb-chat checks the key, the signature and the count, re-encodes and
28
+ // stores; its status and JSON pass through. The bytes are never logged or looked at here.
29
+ // - Nothing throws: every failure is a JSON answer with a status.
30
+
31
+ import { isCanonicalHost } from '@pmeweb/plugin-kit'
32
+ import { verifyTurnstile } from '@pmeweb/turnstile/worker'
33
+ import { RPC_HEADER, RPC_VERSION } from './protocol.js'
34
+
35
+ /** This package's version, reported by the health route (a test holds it equal to package.json). */
36
+ export const VERSION = '1.1.0'
37
+ export { RPC_VERSION }
38
+
39
+ const SERVICE = 'https://pmeweb-chat.internal'
40
+ const PING_TIMEOUT_MS = 3000
41
+ const MAX_BODY = 8192
42
+ const LIMITS = { name: 100, contact: 200, need: 2000 }
43
+ /** pmeweb-chat's cap (src/photos.ts MAX_PHOTO_BYTES); the browser sends ~300 KB at 1600 px. */
44
+ export const MAX_PHOTO_BYTES = 5 * 1024 * 1024
45
+
46
+ function json(body, status = 200, headers = {}) {
47
+ return new Response(JSON.stringify(body), {
48
+ status,
49
+ headers: {
50
+ 'content-type': 'application/json; charset=utf-8',
51
+ 'x-content-type-options': 'nosniff',
52
+ 'cache-control': 'no-store',
53
+ ...headers,
54
+ },
55
+ })
56
+ }
57
+
58
+ const langOf = (v) => (v === 'en' || v === 'fr' ? v : null)
59
+
60
+ const VISITOR_IP_HEADER = 'x-pmw-visitor-ip'
61
+
62
+ /** CHAT_LIMIT by default (sockets, photos, the contact form); `start` uses CHAT_START_LIMIT when bound. */
63
+ async function allowed(request, env, { start = false } = {}) {
64
+ const [limiter, prefix] = start && env.CHAT_START_LIMIT ? [env.CHAT_START_LIMIT, 'chat-start'] : [env.CHAT_LIMIT, 'chat']
65
+ if (!limiter) return true
66
+ try {
67
+ return (await limiter.limit({ key: `${prefix}:${request.headers.get('cf-connecting-ip') ?? 'unknown'}` })).success
68
+ } catch (e) {
69
+ // The limiter is approximate by design; a failure of it lets a Turnstile-verified visitor
70
+ // through, and pmeweb-chat still caps turns and the day's spend.
71
+ console.error('[chat] rate limiter failed', e)
72
+ return true
73
+ }
74
+ }
75
+
76
+ /** The fallback form's three fields, trimmed; null when one is missing or too long. */
77
+ function fallbackLead(v) {
78
+ if (typeof v !== 'object' || v === null) return null
79
+ const out = {}
80
+ for (const [k, max] of Object.entries(LIMITS)) {
81
+ const s = typeof v[k] === 'string' ? v[k].trim() : ''
82
+ if (s === '' || s.length > max) return null
83
+ out[k] = s
84
+ }
85
+ return out
86
+ }
87
+
88
+ async function start(request, url, env, { config, lang }) {
89
+ if (!env.TURNSTILE_SECRET) {
90
+ console.error('[chat] TURNSTILE_SECRET not set, refusing the conversation')
91
+ return json({ ok: false, error: 'not_configured' }, 503)
92
+ }
93
+ if (Number(request.headers.get('content-length') ?? '0') > MAX_BODY) return json({ ok: false, error: 'too_large' }, 413)
94
+ let b
95
+ try {
96
+ const text = await request.text()
97
+ if (text.length > MAX_BODY) return json({ ok: false, error: 'too_large' }, 413)
98
+ b = JSON.parse(text)
99
+ } catch {
100
+ return json({ ok: false, error: 'invalid_json' }, 400)
101
+ }
102
+ if (!b || typeof b !== 'object' || typeof b.token !== 'string' || b.token === '') return json({ ok: false, error: 'bad_request' }, 400)
103
+ let lead
104
+ if (b.fallbackLead !== undefined) {
105
+ lead = fallbackLead(b.fallbackLead)
106
+ if (!lead) return json({ ok: false, error: 'bad_request' }, 400)
107
+ }
108
+
109
+ const verdict = await verifyTurnstile(request, { secret: env.TURNSTILE_SECRET, token: b.token, action: 'chat' })
110
+ if (!verdict.ok) {
111
+ console.warn(`[chat] turnstile refused: ${verdict.reason}`)
112
+ return json({ ok: false, error: 'turnstile' }, 403)
113
+ }
114
+ if (!(await allowed(request, env, { start: !lead }))) return json({ ok: false, error: 'too_many' }, 429)
115
+
116
+ const page = typeof b.page === 'string' && b.page.startsWith('/') && !b.page.startsWith('//') && b.page.length <= 512 ? b.page : null
117
+ const body = { host: url.hostname.toLowerCase(), lang: langOf(b.lang) ?? langOf(lang) ?? 'fr', page }
118
+ if (config?.sms === 'voipms') body.sms = 'voipms'
119
+ if (config?.photos === true) body.photos = true
120
+ if (lead) body.fallbackLead = lead
121
+
122
+ const headers = { 'content-type': 'application/json', [RPC_HEADER]: String(RPC_VERSION) }
123
+ const ip = request.headers.get('cf-connecting-ip')
124
+ if (ip) headers[VISITOR_IP_HEADER] = ip
125
+ let res
126
+ try {
127
+ res = await env.CHAT.fetch(`${SERVICE}/start`, {
128
+ method: 'POST',
129
+ headers,
130
+ body: JSON.stringify(body),
131
+ })
132
+ } catch {
133
+ return json({ ok: false, error: 'unavailable' }, 503)
134
+ }
135
+ let answer
136
+ try {
137
+ answer = await res.json()
138
+ } catch {
139
+ return json({ ok: false, error: 'unavailable' }, 503)
140
+ }
141
+ return json(answer, res.status)
142
+ }
143
+
144
+ async function socket(request, url, env) {
145
+ if (request.headers.get('upgrade')?.toLowerCase() !== 'websocket') return json({ ok: false, error: 'expected_websocket' }, 426)
146
+ if (!(await allowed(request, env))) return json({ ok: false, error: 'too_many' }, 429)
147
+ const q = new URLSearchParams()
148
+ for (const k of ['c', 'k']) q.set(k, url.searchParams.get(k) ?? '')
149
+ const headers = new Headers(request.headers)
150
+ for (const h of ['cookie', 'authorization', VISITOR_IP_HEADER]) headers.delete(h)
151
+ headers.set(RPC_HEADER, String(RPC_VERSION))
152
+ try {
153
+ return await env.CHAT.fetch(new Request(`${SERVICE}/ws?${q}`, { method: 'GET', headers }))
154
+ } catch {
155
+ return json({ ok: false, error: 'unavailable' }, 503)
156
+ }
157
+ }
158
+
159
+ /** The body, at most `max` bytes: a declared length over it is refused unread, the read is counted. */
160
+ async function readCapped(request, max) {
161
+ const declared = Number(request.headers.get('content-length') ?? '')
162
+ if (Number.isFinite(declared) && declared > max) return null
163
+ if (!request.body) return new Uint8Array()
164
+ const reader = request.body.getReader()
165
+ const chunks = []
166
+ let size = 0
167
+ for (;;) {
168
+ const { done, value } = await reader.read()
169
+ if (done) break
170
+ size += value.byteLength
171
+ if (size > max) {
172
+ await reader.cancel().catch(() => {})
173
+ return null
174
+ }
175
+ chunks.push(value)
176
+ }
177
+ const out = new Uint8Array(size)
178
+ let o = 0
179
+ for (const c of chunks) { out.set(c, o); o += c.byteLength }
180
+ return out
181
+ }
182
+
183
+ async function photo(request, url, env, { config }) {
184
+ if (config?.photos !== true) return json({ ok: false, error: 'photos_off' }, 403)
185
+ if (!(await allowed(request, env))) return json({ ok: false, error: 'too_many' }, 429)
186
+ let bytes
187
+ try {
188
+ bytes = await readCapped(request, MAX_PHOTO_BYTES)
189
+ } catch {
190
+ return json({ ok: false, error: 'bad_request' }, 400)
191
+ }
192
+ if (!bytes) return json({ ok: false, error: 'too_large' }, 413)
193
+ const q = new URLSearchParams()
194
+ for (const k of ['c', 'k', 'id']) q.set(k, url.searchParams.get(k) ?? '')
195
+ let res
196
+ try {
197
+ res = await env.CHAT.fetch(`${SERVICE}/photo?${q}`, {
198
+ method: 'POST',
199
+ headers: { 'content-type': 'application/octet-stream', [RPC_HEADER]: String(RPC_VERSION) },
200
+ body: bytes,
201
+ })
202
+ } catch {
203
+ return json({ ok: false, error: 'unavailable' }, 503)
204
+ }
205
+ let answer
206
+ try {
207
+ answer = await res.json()
208
+ } catch {
209
+ return json({ ok: false, error: 'unavailable' }, 503)
210
+ }
211
+ return json(answer, res.status)
212
+ }
213
+
214
+ /**
215
+ * Route every `/api/chat/*` request here from the site Worker, before the assets. `hosts` are the
216
+ * site's real hosts (src/data/hosts.ts); `config` is pmeweb.json `plugins.chat` (every route answers
217
+ * 403 `disabled` unless `config.enabled === true`); `lang` the site's default language.
218
+ */
219
+ export async function handleChat(request, env, ctx, { hosts, config = {}, lang = 'fr' } = {}) {
220
+ const url = new URL(request.url)
221
+ if (!isCanonicalHost(url.host, Array.isArray(hosts) ? hosts : [])) return json({ ok: false, error: 'disabled' }, 403)
222
+ // Switched off (pmeweb.json plugins.chat.enabled not true): paused, nothing reaches pmeweb-chat.
223
+ if (config?.enabled !== true) return json({ ok: false, error: 'disabled' }, 403)
224
+ if (url.pathname === '/api/chat/start') {
225
+ if (request.method !== 'POST') return json({ ok: false, error: 'method' }, 405, { allow: 'POST' })
226
+ return start(request, url, env, { config, lang })
227
+ }
228
+ if (url.pathname === '/api/chat/ws') {
229
+ if (request.method !== 'GET') return json({ ok: false, error: 'method' }, 405, { allow: 'GET' })
230
+ return socket(request, url, env)
231
+ }
232
+ if (url.pathname === '/api/chat/photo') {
233
+ if (request.method !== 'POST') return json({ ok: false, error: 'method' }, 405, { allow: 'POST' })
234
+ return photo(request, url, env, { config })
235
+ }
236
+ return json({ ok: false, error: 'not_found' }, 404)
237
+ }
238
+
239
+ /**
240
+ * For the site's /api/plugins/sante (pluginHealth): booleans only, the ping bounded by a timeout.
241
+ * `host` is the site's canonical host (CANONICAL_HOSTS[0]): pmeweb-chat reads the client's settings
242
+ * by host, so without it `settings` is false. `config` is pmeweb.json `plugins.chat`: given and not
243
+ * `enabled: true`, the answer is `{ checks: { disabled: true } }` (ok, « Désactivé » in the portal).
244
+ */
245
+ export async function health(env, { host, config } = {}) {
246
+ // Switched off: ok, and one check the portal reads as « Désactivé ». No ping: nothing to check.
247
+ if (config !== undefined && config?.enabled !== true) return { version: VERSION, rpc: RPC_VERSION, checks: { disabled: true } }
248
+ let answer = null
249
+ let timer
250
+ try {
251
+ if (env.CHAT?.ping) {
252
+ const ping = Promise.resolve(env.CHAT.ping(host ?? ''))
253
+ const timeout = new Promise((resolve) => { timer = setTimeout(() => resolve(null), PING_TIMEOUT_MS) })
254
+ answer = await Promise.race([ping, timeout])
255
+ }
256
+ } catch {
257
+ answer = null
258
+ }
259
+ clearTimeout(timer)
260
+ const rpc = answer?.ok === true && Array.isArray(answer.accepts) && answer.accepts.includes(RPC_VERSION)
261
+ return {
262
+ version: VERSION,
263
+ rpc: RPC_VERSION,
264
+ checks: {
265
+ secret: !!env.TURNSTILE_SECRET,
266
+ binding: !!env.CHAT,
267
+ limit: !!env.CHAT_LIMIT,
268
+ startLimit: !!env.CHAT_START_LIMIT,
269
+ rpc,
270
+ settings: rpc && !!host && answer.configured === true,
271
+ },
272
+ }
273
+ }