diff --git a/app.js b/app.js index cab28fd..3c2f52d 100644 --- a/app.js +++ b/app.js @@ -33,7 +33,7 @@ chatForm.addEventListener('submit', async (e) => { }); const data = await res.json(); typing.remove(); - addMsg(data.reply || 'Sorry, something went wrong.', 'msg-ai'); + addMsg(res.ok && typeof data.reply === 'string' ? data.reply : (data.error || 'The AI demo is unavailable. Please retry later.'), 'msg-ai'); } catch { typing.remove(); addMsg('Connection error. Please try again.', 'msg-ai'); @@ -55,8 +55,9 @@ contactForm.addEventListener('submit', async (e) => { body: JSON.stringify(body), }); const data = await res.json(); - contactStatus.textContent = data.ok ? 'Thanks! We will be in touch shortly.' : (data.error || 'Failed to send.'); - if (data.ok) contactForm.reset(); + const stored = res.ok && data.ok === true && data.status === 'stored'; + contactStatus.textContent = stored ? 'Your message has been saved. Thank you.' : (data.error || 'Your message was not saved. Please retry.'); + if (stored) contactForm.reset(); } catch { contactStatus.textContent = 'Network error. Please retry.'; } diff --git a/functions/api/chat.js b/functions/api/chat.js index f3ac803..df6f389 100644 --- a/functions/api/chat.js +++ b/functions/api/chat.js @@ -1,39 +1,25 @@ +import { InputError, json, readObject, textField } from '../../lib/request.js'; + export async function onRequestPost({ request, env }) { try { - const { message } = await request.json(); - if (!message || message.length > 1000) { - return json({ error: 'Invalid message.' }, 400); - } - - // Workers AI binding (Settings > Functions > AI bindings > name: AI) - if (env.AI) { - const result = await env.AI.run('@cf/meta/llama-3.1-8b-instruct', { - messages: [ - { - role: 'system', - content: - 'You are NovaMind, a friendly AI business assistant. Answer briefly (max 3 sentences) about how AI automation can help businesses.', - }, - { role: 'user', content: message }, - ], - max_tokens: 256, - }); - return json({ reply: result.response }); + const body = await readObject(request, 8000); + const message = textField(body, 'message', 1000); + if (!env.AI || typeof env.AI.run !== 'function') { + return json({ error: 'The AI demo is temporarily unavailable.' }, 503); } - - // Fallback if no AI binding is configured - return json({ - reply: - 'NovaMind agents can automate support, scheduling, and reporting for your business. Configure the Workers AI binding to enable live answers!', + const result = await env.AI.run('@cf/meta/llama-3.1-8b-instruct', { + messages: [ + { role: 'system', content: 'You are NovaMind, a demonstration business assistant. Answer briefly, distinguish suggestions from completed actions, and never claim you have accessed or changed a user system.' }, + { role: 'user', content: message }, + ], + max_tokens: 256, }); - } catch { - return json({ error: 'Server error.' }, 500); + if (typeof result?.response !== 'string' || !result.response.trim()) { + return json({ error: 'The AI service returned no usable answer. Please retry.' }, 502); + } + return json({ reply: result.response.trim(), mode: 'model' }); + } catch (error) { + if (error instanceof InputError) return json({ error: error.message }, error.status); + return json({ error: 'The AI service is temporarily unavailable. Please retry later.' }, 503); } } - -function json(data, status = 200) { - return new Response(JSON.stringify(data), { - status, - headers: { 'Content-Type': 'application/json' }, - }); -} diff --git a/functions/api/contact.js b/functions/api/contact.js index 1b0564f..45129b7 100644 --- a/functions/api/contact.js +++ b/functions/api/contact.js @@ -1,26 +1,20 @@ +import { InputError, json, readObject, textField } from '../../lib/request.js'; + export async function onRequestPost({ request, env }) { try { - const { name, email, message } = await request.json(); - - if (!name || !email || !message) return json({ ok: false, error: 'All fields required.' }, 400); + const body = await readObject(request); + const name = textField(body, 'name', 120); + const email = textField(body, 'email', 254); + const message = textField(body, 'message', 5000); if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return json({ ok: false, error: 'Invalid email.' }, 400); - if (message.length > 5000) return json({ ok: false, error: 'Message too long.' }, 400); - - // Persist to KV if bound (Settings > Functions > KV bindings > name: CONTACTS) - if (env.CONTACTS) { - const id = `contact:${Date.now()}:${crypto.randomUUID()}`; - await env.CONTACTS.put(id, JSON.stringify({ name, email, message, at: new Date().toISOString() })); + if (!env.CONTACTS || typeof env.CONTACTS.put !== 'function') { + return json({ ok: false, error: 'Contact submission is temporarily unavailable.' }, 503); } - - return json({ ok: true }); - } catch { - return json({ ok: false, error: 'Server error.' }, 500); + const id = `contact:${Date.now()}:${crypto.randomUUID()}`; + await env.CONTACTS.put(id, JSON.stringify({ name, email, message, at: new Date().toISOString() })); + return json({ ok: true, status: 'stored' }); + } catch (error) { + if (error instanceof InputError) return json({ ok: false, error: error.message }, error.status); + return json({ ok: false, error: 'Your message could not be saved. Please retry later.' }, 503); } } - -function json(data, status = 200) { - return new Response(JSON.stringify(data), { - status, - headers: { 'Content-Type': 'application/json' }, - }); -} diff --git a/lib/request.js b/lib/request.js new file mode 100644 index 0000000..451e8de --- /dev/null +++ b/lib/request.js @@ -0,0 +1,48 @@ +export class InputError extends Error { + constructor(message, status = 400) { super(message); this.status = status; } +} + +export function json(data, status = 200) { + return new Response(JSON.stringify(data), { status, headers: { + 'Content-Type': 'application/json; charset=utf-8', + 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff', + } }); +} + +export async function readObject(request, limit = 24000) { + if (request.headers.get('content-type')?.split(';')[0].trim().toLowerCase() !== 'application/json') { + throw new InputError('Send an application/json request.', 415); + } + if (!request.body) throw new InputError('A JSON object is required.'); + const reader = request.body.getReader(); + const chunks = []; + let size = 0; + try { + while (true) { + const { value, done } = await reader.read(); + if (done) break; + size += value.byteLength; + if (size > limit) { + await reader.cancel().catch(() => {}); + throw new InputError('Request body is too large.', 413); + } + chunks.push(value); + } + } finally { reader.releaseLock(); } + const bytes = new Uint8Array(size); + let offset = 0; + for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.length; } + let value; + try { value = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)); } + catch { throw new InputError('Malformed JSON.'); } + if (!value || typeof value !== 'object' || Array.isArray(value)) throw new InputError('A JSON object is required.'); + return value; +} + +export function textField(data, field, limit) { + const value = data[field]; + if (typeof value !== 'string' || !value.trim() || value.length > limit) { + throw new InputError(`${field} must be text between 1 and ${limit} characters.`); + } + return value.trim(); +} diff --git a/tests/api.test.mjs b/tests/api.test.mjs new file mode 100644 index 0000000..f9a5e48 --- /dev/null +++ b/tests/api.test.mjs @@ -0,0 +1,65 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { onRequestPost as contact } from '../functions/api/contact.js'; +import { onRequestPost as chat } from '../functions/api/chat.js'; + +const valid = { name: ' Test User ', email: 'test@example.com', message: ' An example message. ' }; +const req = (body, type = 'application/json') => new Request('https://example.com/api/contact', { + method: 'POST', headers: { 'Content-Type': type }, body: JSON.stringify(body), +}); + +test('contact is accepted only after the KV write completes', async () => { + let complete; + let saved; + const pending = contact({ request: req(valid), env: { CONTACTS: { + put: async (id, data) => { saved = [id, JSON.parse(data)]; await new Promise(resolve => { complete = resolve; }); }, + } } }); + while (!complete) await new Promise(resolve => setTimeout(resolve, 0)); + let resolved = false; + void pending.then(() => { resolved = true; }); + await new Promise(resolve => setTimeout(resolve, 0)); + assert.equal(resolved, false); + complete(); + const result = await pending; + assert.equal(result.status, 200); + assert.deepEqual(await result.json(), { ok: true, status: 'stored' }); + assert.equal(saved[1].name, 'Test User'); + assert.equal(saved[1].message, 'An example message.'); + assert.equal(result.headers.get('cache-control'), 'no-store'); +}); + +test('missing storage and failed writes never report success', async () => { + for (const env of [{}, { CONTACTS: { put: async () => { throw Error('private internal detail'); } } }]) { + const result = await contact({ request: req(valid), env }); + assert.equal(result.status, 503); + const body = await result.json(); + assert.equal(body.ok, false); + assert.doesNotMatch(body.error, /private internal/); + } +}); + +for (const body of [null, [], true, 3, {}, { ...valid, name: [] }, { ...valid, message: {} }, { ...valid, email: 42 }, { ...valid, name: ' ' }, { ...valid, message: 'x'.repeat(5001) }]) { + test(`contact rejects invalid input ${JSON.stringify(body).slice(0, 70)}`, async () => { + assert.equal((await contact({ request: req(body), env: {} })).status, 400); + }); +} + +test('malformed and oversized request bodies get client errors', async () => { + const malformed = new Request('https://example.com', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{' }); + assert.equal((await contact({ request: malformed, env: {} })).status, 400); + assert.equal((await contact({ request: req({ message: 'x'.repeat(25000) }), env: {} })).status, 413); + assert.equal((await contact({ request: req(valid, 'text/plain'), env: {} })).status, 415); +}); + +test('chat distinguishes unavailable, invalid, failed, and working inference', async () => { + assert.equal((await chat({ request: req({ message: 'Hello' }), env: {} })).status, 503); + assert.equal((await chat({ request: req({ message: [] }), env: {} })).status, 400); + for (const result of [{}, { response: '' }, { response: 99 }]) { + assert.equal((await chat({ request: req({ message: 'Hello' }), env: { AI: { run: async () => result } } })).status, 502); + } + assert.equal((await chat({ request: req({ message: 'Hello' }), env: { AI: { run: async () => { throw Error('secret'); } } } })).status, 503); + const good = await chat({ request: req({ message: ' Hello ' }), env: { AI: { run: async (_, args) => { + assert.equal(args.messages[1].content, 'Hello'); return { response: 'Example answer.' }; + } } } }); + assert.deepEqual(await good.json(), { reply: 'Example answer.', mode: 'model' }); +});