Repository navigation
Expand file tree
/
Copy pathcomments.js
More file actions
122 lines (102 loc) · 6.19 KB
/
Copy pathcomments.js
File metadata and controls
122 lines (102 loc) · 6.19 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
// Comments for the blog (blog.izie.top). Stored in D1 (binding COMMENTS); see blog/schema.sql.
// Public API only: read approved comments, add one. Moderation happens from the local editor
// through `wrangler d1`, so there are no admin endpoints here to protect.
const MODES = ['review', 'open', 'off']
const MAX_DEPTH = 4 // replies deeper than this attach to their depth-4 ancestor
const LIMITS = { name: 40, body: 2000, perMinute: 3, perDay: 20 }
const json = (data, status = 200) =>
Response.json(data, { status, headers: { 'cache-control': 'no-store' } })
// each built post page says what its author wants in <meta name="comments">: default | on | locked | off
async function postSetting(env, url, post) {
const page = await env.ASSETS.fetch(new URL(`/__subdomains/blog/post/${post}/`, url))
if (!page.ok) return null
return (await page.text()).match(/<meta name="comments" content="(\w+)">/)?.[1] ?? 'default'
}
// the blog-wide mode, adjusted by the post: off and locked win; "on" opens a post even when the blog is off
// (its comments are held for review then)
const effective = (blog, setting) =>
setting === 'off' || setting === 'locked' ? setting : setting === 'on' && blog === 'off' ? 'review' : blog
const mode = async (db) =>
(await db.prepare("SELECT value FROM settings WHERE key = 'mode'").first('value')) ?? 'review'
// "https://www.Example.com/about" -> "example.com"; null when it isn't a domain
function cleanDomain(raw) {
const host = raw.trim().toLowerCase().replace(/^https?:\/\//, '').replace(/^www\./, '').split(/[/?#]/)[0]
return /^(?=.{3,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/.test(host) ? host : null
}
const cleanEmail = (raw) => {
const e = raw.trim().toLowerCase()
return e.length <= 254 && /^[^\s@<>"]+@[^\s@<>"]+\.[^\s@<>"]{2,}$/.test(e) ? e : null
}
async function hashIp(request, env) {
const ip = request.headers.get('cf-connecting-ip') || 'unknown'
const data = new TextEncoder().encode(`${env.COMMENT_SALT || 'blog'}:${ip}`)
return [...new Uint8Array(await crypto.subtle.digest('SHA-256', data))].map((b) => b.toString(16).padStart(2, '0')).join('')
}
export async function handleComments(request, env, url) {
const db = env.COMMENTS
if (!db) return json({ error: 'comments are not set up yet' }, 503)
if (request.method === 'GET') {
const post = url.searchParams.get('post') || ''
if (!/^[a-z0-9-]{1,120}$/.test(post)) return json({ error: 'bad post' }, 400)
const setting = await postSetting(env, url, post)
if (setting === null) return json({ error: 'no such post' }, 404)
const shown = effective(await mode(db), setting)
if (shown === 'off') return json({ mode: 'off', comments: [] })
const { results } = await db
.prepare("SELECT id, parent, name, site, body, created FROM comments WHERE post = ? AND status = 'approved' ORDER BY created, id")
.bind(post).all()
return json({ mode: shown, comments: results })
}
if (request.method !== 'POST') return json({ error: 'method not allowed' }, 405)
// only our own pages may post
const origin = request.headers.get('origin')
if (origin && origin !== url.origin) return json({ error: 'bad origin' }, 403)
let input
try { input = await request.json() } catch { return json({ error: 'bad json' }, 400) }
if (!input || typeof input !== 'object') return json({ error: 'bad json' }, 400)
// a field real people never see; bots fill everything in. pretend it worked.
if (input.website) return json({ ok: true, status: 'pending' })
const post = String(input.post ?? '')
const name = String(input.name ?? '').trim().replace(/\s+/g, ' ')
const contact = String(input.contact ?? '')
const body = String(input.body ?? '').replace(/\r\n/g, '\n').trim()
let parent = input.parent == null || input.parent === '' ? null : Number(input.parent)
if (!/^[a-z0-9-]{1,120}$/.test(post)) return json({ error: 'bad post' }, 400)
if (!name || name.length > LIMITS.name) return json({ error: `name needs 1-${LIMITS.name} characters` }, 400)
if (!body || body.length > LIMITS.body) return json({ error: `comment needs 1-${LIMITS.body} characters` }, 400)
const email = contact.includes('@') ? cleanEmail(contact) : null
const site = contact.includes('@') ? null : cleanDomain(contact)
if (!email && !site) return json({ error: 'add a website domain (like example.com) or an email address' }, 400)
// the post has to exist, so nobody can park comments on made-up pages
const setting = await postSetting(env, url, post)
if (setting === null) return json({ error: 'no such post' }, 404)
const current = effective(await mode(db), setting)
if (current === 'locked') return json({ error: 'this post is closed to new comments' }, 403)
if (current === 'off') return json({ error: 'comments are turned off right now' }, 403)
if (parent !== null) {
if (!Number.isInteger(parent)) return json({ error: 'bad parent' }, 400)
const row = await db.prepare("SELECT id, parent, post FROM comments WHERE id = ? AND status = 'approved'").bind(parent).first()
if (!row || row.post !== post) return json({ error: 'that comment is gone' }, 404)
let depth = 1
for (let up = row.parent; up !== null; depth++) {
up = (await db.prepare('SELECT parent FROM comments WHERE id = ?').bind(up).first('parent'))
}
// already at the deepest level: reply next to it instead of under it
if (depth >= MAX_DEPTH) parent = row.parent
}
const ip = await hashIp(request, env)
const now = new Date()
const minuteAgo = new Date(now - 60_000).toISOString()
const dayAgo = new Date(now - 86_400_000).toISOString()
const recent = await db
.prepare('SELECT SUM(created > ?) AS minute, COUNT(*) AS day FROM comments WHERE ip_hash = ? AND created > ?')
.bind(minuteAgo, ip, dayAgo).first()
if ((recent?.minute ?? 0) >= LIMITS.perMinute || (recent?.day ?? 0) >= LIMITS.perDay) {
return json({ error: 'slow down a little, then try again' }, 429)
}
const status = current === 'open' ? 'approved' : 'pending'
await db
.prepare('INSERT INTO comments (post, parent, name, site, email, body, status, created, ip_hash) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)')
.bind(post, parent, name, site, email, body, status, now.toISOString(), ip).run()
return json({ ok: true, status })
}