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