@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/INSTALL.md +130 -0
- package/LICENSE-LUCIDE +19 -0
- package/README.md +131 -2
- package/avatars/illustration-1.webp +0 -0
- package/avatars/illustration-2.webp +0 -0
- package/avatars/illustration-3.webp +0 -0
- package/avatars/illustration-4.webp +0 -0
- package/avatars/illustration-5.webp +0 -0
- package/avatars/photos-1.webp +0 -0
- package/avatars/photos-2.webp +0 -0
- package/avatars/photos-3.webp +0 -0
- package/avatars/photos-4.webp +0 -0
- package/avatars/photos-5.webp +0 -0
- package/avatars/sans-visage-1.svg +4 -0
- package/avatars/sans-visage-2.svg +3 -0
- package/avatars/sans-visage-3.svg +4 -0
- package/avatars/sans-visage-4.svg +4 -0
- package/avatars/sans-visage-5.svg +4 -0
- package/dist/trigger.iife.js +1 -0
- package/dist/widget.js +2 -0
- package/manifest.json +82 -0
- package/package.json +48 -4
- package/privacy/en.json +20 -0
- package/privacy/fr.json +20 -0
- package/src/Chat.astro +110 -0
- package/src/build.d.ts +30 -0
- package/src/build.js +87 -0
- package/src/chat.css +65 -0
- package/src/colors.js +104 -0
- package/src/csp.d.ts +2 -0
- package/src/csp.js +5 -0
- package/src/faces.js +24 -0
- package/src/protocol.js +53 -0
- package/src/protocol.ts +113 -0
- package/src/strings.js +114 -0
- package/src/trigger-entry.js +3 -0
- package/src/trigger.js +78 -0
- package/src/widget.css +108 -0
- package/src/widget.js +616 -0
- package/src/worker.d.ts +62 -0
- package/src/worker.js +273 -0
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
|
+
}
|