discuss.go 16.7 KiB raw
1
package main
2
3
import (
4
	"bytes"
5
	"crypto/hmac"
6
	"crypto/rand"
7
	"crypto/sha256"
8
	"database/sql"
9
	"encoding/hex"
10
	"encoding/json"
11
	"fmt"
12
	"io"
13
	"log"
14
	"net/http"
15
	"strconv"
16
	"strings"
17
	"time"
18
19
	_ "modernc.org/sqlite"
20
)
21
22
// Discussion is a top-level discussion thread.
23
type Discussion struct {
24
	ID         int
25
	Repo       string
26
	Title      string
27
	Body       string
28
	Author     string
29
	Avatar     string
30
	CreatedAt  time.Time
31
	ReplyCount int
32
	LastActive time.Time
33
}
34
35
// Reply is a reply to a discussion.
36
type Reply struct {
37
	ID        int
38
	Body      string
39
	Author    string
40
	Avatar    string
41
	CreatedAt time.Time
42
}
43
44
const sessionCookieName = "forge_session"
45
const sessionMaxAge = 30 * 24 * time.Hour
46
const maxTitleLen = 200
47
const maxBodyLen = 8000
48
49
// sessionSecret is generated once at startup and used to sign cookies.
50
var sessionSecret []byte
51
52
func init() {
53
	sessionSecret = make([]byte, 32)
54
	if _, err := rand.Read(sessionSecret); err != nil {
55
		panic("failed to generate session secret: " + err.Error())
56
	}
57
}
58
59
// --- Database ---
60
61
func openDB(path string) (*sql.DB, error) {
62
	db, err := sql.Open("sqlite", path+"?_journal_mode=WAL&_busy_timeout=5000")
63
	if err != nil {
64
		return nil, fmt.Errorf("open database: %w", err)
65
	}
66
	if err := migrateDB(db); err != nil {
67
		db.Close()
68
		return nil, fmt.Errorf("migrate database: %w", err)
69
	}
70
	return db, nil
71
}
72
73
func migrateDB(db *sql.DB) error {
74
	_, err := db.Exec(`
75
		CREATE TABLE IF NOT EXISTS discussions (
76
			id          INTEGER PRIMARY KEY AUTOINCREMENT,
77
			repo        TEXT NOT NULL,
78
			title       TEXT NOT NULL,
79
			body        TEXT NOT NULL DEFAULT '',
80
			author      TEXT NOT NULL,
81
			created_at  DATETIME NOT NULL DEFAULT (datetime('now'))
82
		);
83
		CREATE TABLE IF NOT EXISTS replies (
84
			id              INTEGER PRIMARY KEY AUTOINCREMENT,
85
			discussion_id   INTEGER NOT NULL REFERENCES discussions(id),
86
			body            TEXT NOT NULL,
87
			author          TEXT NOT NULL,
88
			created_at      DATETIME NOT NULL DEFAULT (datetime('now'))
89
		);
90
		CREATE TABLE IF NOT EXISTS avatars (
91
			handle      TEXT PRIMARY KEY,
92
			url         TEXT NOT NULL,
93
			updated_at  DATETIME NOT NULL DEFAULT (datetime('now'))
94
		);
95
		CREATE INDEX IF NOT EXISTS idx_discussions_repo ON discussions(repo);
96
		CREATE INDEX IF NOT EXISTS idx_discussions_author ON discussions(author);
97
		CREATE INDEX IF NOT EXISTS idx_replies_discussion ON replies(discussion_id);
98
		CREATE INDEX IF NOT EXISTS idx_replies_author ON replies(author);
99
	`)
100
	return err
101
}
102
103
func upsertAvatar(db *sql.DB, handle, url string) {
104
	db.Exec(`
105
		INSERT INTO avatars (handle, url, updated_at) VALUES (?, ?, datetime('now'))
106
		ON CONFLICT(handle) DO UPDATE SET url = excluded.url, updated_at = excluded.updated_at
107
	`, handle, url)
108
}
109
110
func getAvatar(db *sql.DB, handle string) string {
111
	var url string
112
	db.QueryRow(`SELECT url FROM avatars WHERE handle = ?`, handle).Scan(&url)
113
	return url
114
}
115
116
func listDiscussions(db *sql.DB, repo string) []Discussion {
117
	rows, err := db.Query(`
118
		SELECT d.id, d.repo, d.title, d.body, d.author, COALESCE(a.url, ''), d.created_at,
119
		       COUNT(r.id),
120
		       COALESCE(MAX(r.created_at), d.created_at)
121
		FROM discussions d
122
		LEFT JOIN replies r ON r.discussion_id = d.id
123
		LEFT JOIN avatars a ON a.handle = d.author
124
		WHERE d.repo = ?
125
		GROUP BY d.id
126
		ORDER BY 9 DESC
127
	`, repo)
128
	if err != nil {
129
		log.Printf("listDiscussions: %v", err)
130
		return nil
131
	}
132
	defer rows.Close()
133
134
	var out []Discussion
135
	for rows.Next() {
136
		var d Discussion
137
		var lastActive string
138
		if err := rows.Scan(&d.ID, &d.Repo, &d.Title, &d.Body, &d.Author, &d.Avatar,
139
			&d.CreatedAt, &d.ReplyCount, &lastActive); err != nil {
140
			continue
141
		}
142
		for _, layout := range []string{time.RFC3339, "2006-01-02 15:04:05", "2006-01-02T15:04:05Z"} {
143
			if t, err := time.Parse(layout, lastActive); err == nil {
144
				d.LastActive = t
145
				break
146
			}
147
		}
148
		out = append(out, d)
149
	}
150
	return out
151
}
152
153
func getDiscussion(db *sql.DB, id int) (*Discussion, error) {
154
	var d Discussion
155
	err := db.QueryRow(`
156
		SELECT d.id, d.repo, d.title, d.body, d.author, COALESCE(a.url, ''), d.created_at
157
		FROM discussions d
158
		LEFT JOIN avatars a ON a.handle = d.author
159
		WHERE d.id = ?
160
	`, id).Scan(&d.ID, &d.Repo, &d.Title, &d.Body, &d.Author, &d.Avatar, &d.CreatedAt)
161
	if err != nil {
162
		return nil, err
163
	}
164
	return &d, nil
165
}
166
167
func getReplies(db *sql.DB, discussionID int) []Reply {
168
	rows, err := db.Query(`
169
		SELECT r.id, r.body, r.author, COALESCE(a.url, ''), r.created_at
170
		FROM replies r
171
		LEFT JOIN avatars a ON a.handle = r.author
172
		WHERE r.discussion_id = ?
173
		ORDER BY r.created_at ASC
174
	`, discussionID)
175
	if err != nil {
176
		return nil
177
	}
178
	defer rows.Close()
179
180
	var out []Reply
181
	for rows.Next() {
182
		var r Reply
183
		if err := rows.Scan(&r.ID, &r.Body, &r.Author, &r.Avatar, &r.CreatedAt); err != nil {
184
			continue
185
		}
186
		out = append(out, r)
187
	}
188
	return out
189
}
190
191
func createDiscussion(db *sql.DB, repo, title, body, author string) (int64, error) {
192
	res, err := db.Exec(`
193
		INSERT INTO discussions (repo, title, body, author, created_at)
194
		VALUES (?, ?, ?, ?, ?)
195
	`, repo, title, body, author, time.Now())
196
	if err != nil {
197
		return 0, err
198
	}
199
	return res.LastInsertId()
200
}
201
202
func createReply(db *sql.DB, discussionID int, body, author string) (int64, error) {
203
	res, err := db.Exec(`
204
		INSERT INTO replies (discussion_id, body, author, created_at)
205
		VALUES (?, ?, ?, ?)
206
	`, discussionID, body, author, time.Now())
207
	if err != nil {
208
		return 0, err
209
	}
210
	return res.LastInsertId()
211
}
212
213
// --- Sessions ---
214
215
func signSession(handle string) string {
216
	expiry := time.Now().Add(sessionMaxAge).Unix()
217
	payload := fmt.Sprintf("%s|%d", handle, expiry)
218
	mac := hmac.New(sha256.New, sessionSecret)
219
	mac.Write([]byte(payload))
220
	sig := hex.EncodeToString(mac.Sum(nil))
221
	return payload + "|" + sig
222
}
223
224
func verifySession(value string) (string, bool) {
225
	parts := strings.SplitN(value, "|", 3)
226
	if len(parts) != 3 {
227
		return "", false
228
	}
229
	expiry, err := strconv.ParseInt(parts[1], 10, 64)
230
	if err != nil || time.Now().Unix() > expiry {
231
		return "", false
232
	}
233
	payload := parts[0] + "|" + parts[1]
234
	mac := hmac.New(sha256.New, sessionSecret)
235
	mac.Write([]byte(payload))
236
	expected := hex.EncodeToString(mac.Sum(nil))
237
	if !hmac.Equal([]byte(parts[2]), []byte(expected)) {
238
		return "", false
239
	}
240
	return parts[0], true
241
}
242
243
func getSessionHandle(r *http.Request) string {
244
	c, err := r.Cookie(sessionCookieName)
245
	if err != nil {
246
		return ""
247
	}
248
	handle, ok := verifySession(c.Value)
249
	if !ok {
250
		return ""
251
	}
252
	return handle
253
}
254
255
func setSessionCookie(w http.ResponseWriter, handle string) {
256
	http.SetCookie(w, &http.Cookie{
257
		Name:     sessionCookieName,
258
		Value:    signSession(handle),
259
		Path:     "/",
260
		MaxAge:   int(sessionMaxAge.Seconds()),
261
		HttpOnly: true,
262
		SameSite: http.SameSiteLaxMode,
263
	})
264
}
265
266
func clearSessionCookie(w http.ResponseWriter) {
267
	http.SetCookie(w, &http.Cookie{
268
		Name:     sessionCookieName,
269
		Value:    "",
270
		Path:     "/",
271
		MaxAge:   -1,
272
		HttpOnly: true,
273
		SameSite: http.SameSiteLaxMode,
274
	})
275
}
276
277
// --- Bluesky ---
278
279
// fetchBlueskyAvatar fetches the avatar URL for a Bluesky handle via the public API.
280
func fetchBlueskyAvatar(handle string) string {
281
	client := &http.Client{Timeout: 5 * time.Second}
282
	resp, err := client.Get("https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=" + handle)
283
	if err != nil || resp.StatusCode != http.StatusOK {
284
		return ""
285
	}
286
	defer resp.Body.Close()
287
288
	var profile struct {
289
		Avatar string `json:"avatar"`
290
	}
291
	if err := json.NewDecoder(io.LimitReader(resp.Body, 16384)).Decode(&profile); err != nil {
292
		return ""
293
	}
294
	return profile.Avatar
295
}
296
297
// verifyBlueskyCredentials verifies a handle + app password by calling
298
// com.atproto.server.createSession. The password is not stored.
299
func verifyBlueskyCredentials(handle, appPassword string) error {
300
	body, _ := json.Marshal(map[string]string{
301
		"identifier": handle,
302
		"password":   appPassword,
303
	})
304
305
	client := &http.Client{Timeout: 10 * time.Second}
306
	resp, err := client.Post("https://bsky.social/xrpc/com.atproto.server.createSession",
307
		"application/json", bytes.NewReader(body))
308
	if err != nil {
309
		return fmt.Errorf("could not reach Bluesky server")
310
	}
311
	defer resp.Body.Close()
312
313
	if resp.StatusCode == http.StatusOK {
314
		return nil
315
	}
316
317
	respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
318
	var errResp struct {
319
		Message string `json:"message"`
320
	}
321
	if json.Unmarshal(respBody, &errResp) == nil && errResp.Message != "" {
322
		return fmt.Errorf("%s", errResp.Message)
323
	}
324
	return fmt.Errorf("authentication failed (status %d)", resp.StatusCode)
325
}
326
327
// --- Handlers ---
328
329
func (s *server) handleDiscussions(w http.ResponseWriter, r *http.Request, repo *RepoInfo) {
330
	type discussionsData struct {
331
		Discussions []Discussion
332
		IsEmpty     bool
333
	}
334
335
	discussions := listDiscussions(s.db, repo.Name)
336
	pd := s.newPageData(r, repo, "discussions", "")
337
	pd.Data = discussionsData{
338
		Discussions: discussions,
339
		IsEmpty:     len(discussions) == 0,
340
	}
341
	s.tmpl.render(w, "discussions", pd)
342
}
343
344
func (s *server) handleDiscussion(w http.ResponseWriter, r *http.Request, repo *RepoInfo, idStr string) {
345
	type discussionData struct {
346
		Discussion *Discussion
347
		Replies    []Reply
348
		Error      string
349
	}
350
351
	id, err := strconv.Atoi(idStr)
352
	if err != nil {
353
		s.renderError(w, r, http.StatusNotFound, "Discussion not found")
354
		return
355
	}
356
357
	d, err := getDiscussion(s.db, id)
358
	if err != nil || d.Repo != repo.Name {
359
		s.renderError(w, r, http.StatusNotFound, "Discussion not found")
360
		return
361
	}
362
	handle := getSessionHandle(r)
363
364
	// Handle reply submission.
365
	if r.Method == http.MethodPost {
366
		if handle == "" {
367
			s.renderError(w, r, http.StatusForbidden, "You must be signed in to reply")
368
			return
369
		}
370
371
		body := strings.TrimSpace(r.FormValue("body"))
372
		if body == "" {
373
			pd := s.newPageData(r, repo, "discussions", "")
374
			pd.Data = discussionData{Discussion: d, Replies: getReplies(s.db, id), Error: "Reply cannot be empty."}
375
			s.tmpl.render(w, "discussion", pd)
376
			return
377
		}
378
		if len(body) > maxBodyLen {
379
			body = body[:maxBodyLen]
380
		}
381
382
		replyID, err := createReply(s.db, id, body, handle)
383
		if err != nil {
384
			s.renderError(w, r, http.StatusInternalServerError, "Failed to save reply")
385
			return
386
		}
387
388
		http.Redirect(w, r, fmt.Sprintf("%s/%s/discussions/%d#reply-%d", s.baseURL, repo.Name, d.ID, replyID), http.StatusSeeOther)
389
		return
390
	}
391
392
	pd := s.newPageData(r, repo, "discussions", "")
393
	pd.Data = discussionData{
394
		Discussion: d,
395
		Replies:    getReplies(s.db, id),
396
	}
397
	s.tmpl.render(w, "discussion", pd)
398
}
399
400
func (s *server) handleNewDiscussion(w http.ResponseWriter, r *http.Request, repo *RepoInfo) {
401
	type newDiscussionData struct {
402
		Error string
403
		Title string
404
		Body  string
405
	}
406
407
	handle := getSessionHandle(r)
408
	if handle == "" {
409
		http.Redirect(w, r, fmt.Sprintf("%s/login?return=%s/%s/discussions/new", s.baseURL, s.baseURL, repo.Name), http.StatusSeeOther)
410
		return
411
	}
412
413
	if r.Method == http.MethodPost {
414
		title := strings.TrimSpace(r.FormValue("title"))
415
		body := strings.TrimSpace(r.FormValue("body"))
416
417
		if title == "" {
418
			pd := s.newPageData(r, repo, "discussions", "")
419
			pd.Data = newDiscussionData{Error: "Title is required.", Title: title, Body: body}
420
			s.tmpl.render(w, "discussion_new", pd)
421
			return
422
		}
423
		if len(title) > maxTitleLen {
424
			title = title[:maxTitleLen]
425
		}
426
		if len(body) > maxBodyLen {
427
			body = body[:maxBodyLen]
428
		}
429
430
		id, err := createDiscussion(s.db, repo.Name, title, body, handle)
431
		if err != nil {
432
			s.renderError(w, r, http.StatusInternalServerError, "Failed to save discussion")
433
			return
434
		}
435
436
		http.Redirect(w, r, fmt.Sprintf("%s/%s/discussions/%d", s.baseURL, repo.Name, id), http.StatusSeeOther)
437
		return
438
	}
439
440
	pd := s.newPageData(r, repo, "discussions", "")
441
	pd.Data = newDiscussionData{}
442
	s.tmpl.render(w, "discussion_new", pd)
443
}
444
445
// siteDiscussionRepo is the sentinel repo name used in the database for
446
// site-level forum discussions (not scoped to any repository).
447
const siteDiscussionRepo = "_site"
448
449
func (s *server) routeSiteDiscussions(w http.ResponseWriter, r *http.Request, rest string) {
450
	switch {
451
	case rest == "" || rest == "/":
452
		s.handleSiteDiscussions(w, r)
453
	case rest == "new":
454
		s.handleNewSiteDiscussion(w, r)
455
	default:
456
		s.handleSiteDiscussion(w, r, rest)
457
	}
458
}
459
460
func (s *server) handleSiteDiscussions(w http.ResponseWriter, r *http.Request) {
461
	type discussionsData struct {
462
		Discussions []Discussion
463
		IsEmpty     bool
464
	}
465
466
	discussions := listDiscussions(s.db, siteDiscussionRepo)
467
	pd := s.newPageData(r, nil, "forum", "")
468
	pd.Data = discussionsData{
469
		Discussions: discussions,
470
		IsEmpty:     len(discussions) == 0,
471
	}
472
	s.tmpl.render(w, "discussions", pd)
473
}
474
475
func (s *server) handleSiteDiscussion(w http.ResponseWriter, r *http.Request, idStr string) {
476
	type discussionData struct {
477
		Discussion *Discussion
478
		Replies    []Reply
479
		Error      string
480
	}
481
482
	id, err := strconv.Atoi(idStr)
483
	if err != nil {
484
		s.renderError(w, r, http.StatusNotFound, "Discussion not found")
485
		return
486
	}
487
488
	d, err := getDiscussion(s.db, id)
489
	if err != nil || d.Repo != siteDiscussionRepo {
490
		s.renderError(w, r, http.StatusNotFound, "Discussion not found")
491
		return
492
	}
493
	handle := getSessionHandle(r)
494
495
	if r.Method == http.MethodPost {
496
		if handle == "" {
497
			s.renderError(w, r, http.StatusForbidden, "You must be signed in to reply")
498
			return
499
		}
500
501
		body := strings.TrimSpace(r.FormValue("body"))
502
		if body == "" {
503
			pd := s.newPageData(r, nil, "forum", "")
504
			pd.Data = discussionData{Discussion: d, Replies: getReplies(s.db, id), Error: "Reply cannot be empty."}
505
			s.tmpl.render(w, "discussion", pd)
506
			return
507
		}
508
		if len(body) > maxBodyLen {
509
			body = body[:maxBodyLen]
510
		}
511
512
		replyID, err := createReply(s.db, id, body, handle)
513
		if err != nil {
514
			s.renderError(w, r, http.StatusInternalServerError, "Failed to save reply")
515
			return
516
		}
517
518
		http.Redirect(w, r, fmt.Sprintf("%s/discussions/%d#reply-%d", s.baseURL, d.ID, replyID), http.StatusSeeOther)
519
		return
520
	}
521
522
	pd := s.newPageData(r, nil, "forum", "")
523
	pd.Data = discussionData{
524
		Discussion: d,
525
		Replies:    getReplies(s.db, id),
526
	}
527
	s.tmpl.render(w, "discussion", pd)
528
}
529
530
func (s *server) handleNewSiteDiscussion(w http.ResponseWriter, r *http.Request) {
531
	type newDiscussionData struct {
532
		Error string
533
		Title string
534
		Body  string
535
	}
536
537
	handle := getSessionHandle(r)
538
	if handle == "" {
539
		http.Redirect(w, r, fmt.Sprintf("%s/login?return=%s/discussions/new", s.baseURL, s.baseURL), http.StatusSeeOther)
540
		return
541
	}
542
543
	if r.Method == http.MethodPost {
544
		title := strings.TrimSpace(r.FormValue("title"))
545
		body := strings.TrimSpace(r.FormValue("body"))
546
547
		if title == "" {
548
			pd := s.newPageData(r, nil, "forum", "")
549
			pd.Data = newDiscussionData{Error: "Title is required.", Title: title, Body: body}
550
			s.tmpl.render(w, "discussion_new", pd)
551
			return
552
		}
553
		if len(title) > maxTitleLen {
554
			title = title[:maxTitleLen]
555
		}
556
		if len(body) > maxBodyLen {
557
			body = body[:maxBodyLen]
558
		}
559
560
		id, err := createDiscussion(s.db, siteDiscussionRepo, title, body, handle)
561
		if err != nil {
562
			s.renderError(w, r, http.StatusInternalServerError, "Failed to save discussion")
563
			return
564
		}
565
566
		http.Redirect(w, r, fmt.Sprintf("%s/discussions/%d", s.baseURL, id), http.StatusSeeOther)
567
		return
568
	}
569
570
	pd := s.newPageData(r, nil, "forum", "")
571
	pd.Data = newDiscussionData{}
572
	s.tmpl.render(w, "discussion_new", pd)
573
}
574
575
const devHandle = "cloudhead.io"
576
577
func (s *server) handleDevLogin(w http.ResponseWriter, r *http.Request) {
578
	avatar := getAvatar(s.db, devHandle)
579
	if avatar != "" {
580
		upsertAvatar(s.db, devHandle, avatar)
581
	}
582
	setSessionCookie(w, devHandle)
583
	returnTo := r.URL.Query().Get("return")
584
	if returnTo == "" {
585
		returnTo = s.baseURL + "/"
586
	}
587
	http.Redirect(w, r, returnTo, http.StatusSeeOther)
588
}
589
590
func (s *server) handleLogin(w http.ResponseWriter, r *http.Request) {
591
	type loginData struct {
592
		Error    string
593
		Handle   string
594
		ReturnTo string
595
	}
596
597
	returnTo := r.URL.Query().Get("return")
598
	if returnTo == "" {
599
		returnTo = s.baseURL + "/"
600
	}
601
602
	if r.Method == http.MethodPost {
603
		handle := strings.TrimPrefix(strings.TrimSpace(r.FormValue("handle")), "@")
604
		appPassword := strings.TrimSpace(r.FormValue("app_password"))
605
		returnTo = r.FormValue("return")
606
607
		if handle == "" || appPassword == "" {
608
			pd := s.newPageData(r, nil, "", "")
609
			pd.Data = loginData{Error: "Handle and app password are required.", Handle: handle, ReturnTo: returnTo}
610
			s.tmpl.render(w, "login", pd)
611
			return
612
		}
613
614
		if err := verifyBlueskyCredentials(handle, appPassword); err != nil {
615
			pd := s.newPageData(r, nil, "", "")
616
			pd.Data = loginData{Error: "Sign-in failed: " + err.Error(), Handle: handle, ReturnTo: returnTo}
617
			s.tmpl.render(w, "login", pd)
618
			return
619
		}
620
621
		avatar := fetchBlueskyAvatar(handle)
622
		if avatar != "" {
623
			upsertAvatar(s.db, handle, avatar)
624
		}
625
		setSessionCookie(w, handle)
626
		http.Redirect(w, r, returnTo, http.StatusSeeOther)
627
		return
628
	}
629
630
	pd := s.newPageData(r, nil, "", "")
631
	pd.Data = loginData{ReturnTo: returnTo}
632
	s.tmpl.render(w, "login", pd)
633
}
634
635
func (s *server) handleLogout(w http.ResponseWriter, r *http.Request) {
636
	clearSessionCookie(w)
637
	returnTo := r.URL.Query().Get("return")
638
	if returnTo == "" {
639
		returnTo = s.baseURL + "/"
640
	}
641
	http.Redirect(w, r, returnTo, http.StatusSeeOther)
642
}