Add Bluesky login

afe0ef998afa8448ca67e212304db6c6be2f7c71
Alexis Sellier committed ago 1 parent 45240762
discuss.go added +490 -0
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 +
		if err := rows.Scan(&d.ID, &d.Repo, &d.Title, &d.Body, &d.Author, &d.Avatar,
138 +
			&d.CreatedAt, &d.ReplyCount, &d.LastActive); err != nil {
139 +
			continue
140 +
		}
141 +
		out = append(out, d)
142 +
	}
143 +
	return out
144 +
}
145 +
146 +
func getDiscussion(db *sql.DB, id int) (*Discussion, error) {
147 +
	var d Discussion
148 +
	err := db.QueryRow(`
149 +
		SELECT d.id, d.repo, d.title, d.body, d.author, COALESCE(a.url, ''), d.created_at
150 +
		FROM discussions d
151 +
		LEFT JOIN avatars a ON a.handle = d.author
152 +
		WHERE d.id = ?
153 +
	`, id).Scan(&d.ID, &d.Repo, &d.Title, &d.Body, &d.Author, &d.Avatar, &d.CreatedAt)
154 +
	if err != nil {
155 +
		return nil, err
156 +
	}
157 +
	return &d, nil
158 +
}
159 +
160 +
func getReplies(db *sql.DB, discussionID int) []Reply {
161 +
	rows, err := db.Query(`
162 +
		SELECT r.id, r.body, r.author, COALESCE(a.url, ''), r.created_at
163 +
		FROM replies r
164 +
		LEFT JOIN avatars a ON a.handle = r.author
165 +
		WHERE r.discussion_id = ?
166 +
		ORDER BY r.created_at ASC
167 +
	`, discussionID)
168 +
	if err != nil {
169 +
		return nil
170 +
	}
171 +
	defer rows.Close()
172 +
173 +
	var out []Reply
174 +
	for rows.Next() {
175 +
		var r Reply
176 +
		if err := rows.Scan(&r.ID, &r.Body, &r.Author, &r.Avatar, &r.CreatedAt); err != nil {
177 +
			continue
178 +
		}
179 +
		out = append(out, r)
180 +
	}
181 +
	return out
182 +
}
183 +
184 +
func createDiscussion(db *sql.DB, repo, title, body, author string) (int64, error) {
185 +
	res, err := db.Exec(`
186 +
		INSERT INTO discussions (repo, title, body, author, created_at)
187 +
		VALUES (?, ?, ?, ?, ?)
188 +
	`, repo, title, body, author, time.Now())
189 +
	if err != nil {
190 +
		return 0, err
191 +
	}
192 +
	return res.LastInsertId()
193 +
}
194 +
195 +
func createReply(db *sql.DB, discussionID int, body, author string) (int64, error) {
196 +
	res, err := db.Exec(`
197 +
		INSERT INTO replies (discussion_id, body, author, created_at)
198 +
		VALUES (?, ?, ?, ?)
199 +
	`, discussionID, body, author, time.Now())
200 +
	if err != nil {
201 +
		return 0, err
202 +
	}
203 +
	return res.LastInsertId()
204 +
}
205 +
206 +
// --- Sessions ---
207 +
208 +
func signSession(handle string) string {
209 +
	expiry := time.Now().Add(sessionMaxAge).Unix()
210 +
	payload := fmt.Sprintf("%s|%d", handle, expiry)
211 +
	mac := hmac.New(sha256.New, sessionSecret)
212 +
	mac.Write([]byte(payload))
213 +
	sig := hex.EncodeToString(mac.Sum(nil))
214 +
	return payload + "|" + sig
215 +
}
216 +
217 +
func verifySession(value string) (string, bool) {
218 +
	parts := strings.SplitN(value, "|", 3)
219 +
	if len(parts) != 3 {
220 +
		return "", false
221 +
	}
222 +
	expiry, err := strconv.ParseInt(parts[1], 10, 64)
223 +
	if err != nil || time.Now().Unix() > expiry {
224 +
		return "", false
225 +
	}
226 +
	payload := parts[0] + "|" + parts[1]
227 +
	mac := hmac.New(sha256.New, sessionSecret)
228 +
	mac.Write([]byte(payload))
229 +
	expected := hex.EncodeToString(mac.Sum(nil))
230 +
	if !hmac.Equal([]byte(parts[2]), []byte(expected)) {
231 +
		return "", false
232 +
	}
233 +
	return parts[0], true
234 +
}
235 +
236 +
func getSessionHandle(r *http.Request) string {
237 +
	c, err := r.Cookie(sessionCookieName)
238 +
	if err != nil {
239 +
		return ""
240 +
	}
241 +
	handle, ok := verifySession(c.Value)
242 +
	if !ok {
243 +
		return ""
244 +
	}
245 +
	return handle
246 +
}
247 +
248 +
func setSessionCookie(w http.ResponseWriter, handle string) {
249 +
	http.SetCookie(w, &http.Cookie{
250 +
		Name:     sessionCookieName,
251 +
		Value:    signSession(handle),
252 +
		Path:     "/",
253 +
		MaxAge:   int(sessionMaxAge.Seconds()),
254 +
		HttpOnly: true,
255 +
		SameSite: http.SameSiteLaxMode,
256 +
	})
257 +
}
258 +
259 +
func clearSessionCookie(w http.ResponseWriter) {
260 +
	http.SetCookie(w, &http.Cookie{
261 +
		Name:     sessionCookieName,
262 +
		Value:    "",
263 +
		Path:     "/",
264 +
		MaxAge:   -1,
265 +
		HttpOnly: true,
266 +
		SameSite: http.SameSiteLaxMode,
267 +
	})
268 +
}
269 +
270 +
// --- Bluesky ---
271 +
272 +
// fetchBlueskyAvatar fetches the avatar URL for a Bluesky handle via the public API.
273 +
func fetchBlueskyAvatar(handle string) string {
274 +
	client := &http.Client{Timeout: 5 * time.Second}
275 +
	resp, err := client.Get("https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=" + handle)
276 +
	if err != nil || resp.StatusCode != http.StatusOK {
277 +
		return ""
278 +
	}
279 +
	defer resp.Body.Close()
280 +
281 +
	var profile struct {
282 +
		Avatar string `json:"avatar"`
283 +
	}
284 +
	if err := json.NewDecoder(io.LimitReader(resp.Body, 16384)).Decode(&profile); err != nil {
285 +
		return ""
286 +
	}
287 +
	return profile.Avatar
288 +
}
289 +
290 +
// verifyBlueskyCredentials verifies a handle + app password by calling
291 +
// com.atproto.server.createSession. The password is not stored.
292 +
func verifyBlueskyCredentials(handle, appPassword string) error {
293 +
	body, _ := json.Marshal(map[string]string{
294 +
		"identifier": handle,
295 +
		"password":   appPassword,
296 +
	})
297 +
298 +
	client := &http.Client{Timeout: 10 * time.Second}
299 +
	resp, err := client.Post("https://bsky.social/xrpc/com.atproto.server.createSession",
300 +
		"application/json", bytes.NewReader(body))
301 +
	if err != nil {
302 +
		return fmt.Errorf("could not reach Bluesky server")
303 +
	}
304 +
	defer resp.Body.Close()
305 +
306 +
	if resp.StatusCode == http.StatusOK {
307 +
		return nil
308 +
	}
309 +
310 +
	respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
311 +
	var errResp struct {
312 +
		Message string `json:"message"`
313 +
	}
314 +
	if json.Unmarshal(respBody, &errResp) == nil && errResp.Message != "" {
315 +
		return fmt.Errorf("%s", errResp.Message)
316 +
	}
317 +
	return fmt.Errorf("authentication failed (status %d)", resp.StatusCode)
318 +
}
319 +
320 +
// --- Handlers ---
321 +
322 +
func (s *server) handleDiscussions(w http.ResponseWriter, r *http.Request, repo *RepoInfo) {
323 +
	type discussionsData struct {
324 +
		Discussions []Discussion
325 +
		IsEmpty     bool
326 +
	}
327 +
328 +
	discussions := listDiscussions(s.db, repo.Name)
329 +
	pd := s.newPageData(r, repo, "discussions", "")
330 +
	pd.Data = discussionsData{
331 +
		Discussions: discussions,
332 +
		IsEmpty:     len(discussions) == 0,
333 +
	}
334 +
	s.tmpl.render(w, "discussions", pd)
335 +
}
336 +
337 +
func (s *server) handleDiscussion(w http.ResponseWriter, r *http.Request, repo *RepoInfo, idStr string) {
338 +
	type discussionData struct {
339 +
		Discussion *Discussion
340 +
		Replies    []Reply
341 +
		Error      string
342 +
	}
343 +
344 +
	id, err := strconv.Atoi(idStr)
345 +
	if err != nil {
346 +
		s.renderError(w, r, http.StatusNotFound, "Discussion not found")
347 +
		return
348 +
	}
349 +
350 +
	d, err := getDiscussion(s.db, id)
351 +
	if err != nil || d.Repo != repo.Name {
352 +
		s.renderError(w, r, http.StatusNotFound, "Discussion not found")
353 +
		return
354 +
	}
355 +
	handle := getSessionHandle(r)
356 +
357 +
	// Handle reply submission.
358 +
	if r.Method == http.MethodPost {
359 +
		if handle == "" {
360 +
			s.renderError(w, r, http.StatusForbidden, "You must be signed in to reply")
361 +
			return
362 +
		}
363 +
364 +
		body := strings.TrimSpace(r.FormValue("body"))
365 +
		if body == "" {
366 +
			pd := s.newPageData(r, repo, "discussions", "")
367 +
			pd.Data = discussionData{Discussion: d, Replies: getReplies(s.db, id), Error: "Reply cannot be empty."}
368 +
			s.tmpl.render(w, "discussion", pd)
369 +
			return
370 +
		}
371 +
		if len(body) > maxBodyLen {
372 +
			body = body[:maxBodyLen]
373 +
		}
374 +
375 +
		replyID, err := createReply(s.db, id, body, handle)
376 +
		if err != nil {
377 +
			s.renderError(w, r, http.StatusInternalServerError, "Failed to save reply")
378 +
			return
379 +
		}
380 +
381 +
		http.Redirect(w, r, fmt.Sprintf("%s/%s/discussions/%d#reply-%d", s.baseURL, repo.Name, d.ID, replyID), http.StatusSeeOther)
382 +
		return
383 +
	}
384 +
385 +
	pd := s.newPageData(r, repo, "discussions", "")
386 +
	pd.Data = discussionData{
387 +
		Discussion: d,
388 +
		Replies:    getReplies(s.db, id),
389 +
	}
390 +
	s.tmpl.render(w, "discussion", pd)
391 +
}
392 +
393 +
func (s *server) handleNewDiscussion(w http.ResponseWriter, r *http.Request, repo *RepoInfo) {
394 +
	type newDiscussionData struct {
395 +
		Error string
396 +
		Title string
397 +
		Body  string
398 +
	}
399 +
400 +
	handle := getSessionHandle(r)
401 +
	if handle == "" {
402 +
		http.Redirect(w, r, fmt.Sprintf("%s/login?return=%s/%s/discussions/new", s.baseURL, s.baseURL, repo.Name), http.StatusSeeOther)
403 +
		return
404 +
	}
405 +
406 +
	if r.Method == http.MethodPost {
407 +
		title := strings.TrimSpace(r.FormValue("title"))
408 +
		body := strings.TrimSpace(r.FormValue("body"))
409 +
410 +
		if title == "" {
411 +
			pd := s.newPageData(r, repo, "discussions", "")
412 +
			pd.Data = newDiscussionData{Error: "Title is required.", Title: title, Body: body}
413 +
			s.tmpl.render(w, "discussion_new", pd)
414 +
			return
415 +
		}
416 +
		if len(title) > maxTitleLen {
417 +
			title = title[:maxTitleLen]
418 +
		}
419 +
		if len(body) > maxBodyLen {
420 +
			body = body[:maxBodyLen]
421 +
		}
422 +
423 +
		id, err := createDiscussion(s.db, repo.Name, title, body, handle)
424 +
		if err != nil {
425 +
			s.renderError(w, r, http.StatusInternalServerError, "Failed to save discussion")
426 +
			return
427 +
		}
428 +
429 +
		http.Redirect(w, r, fmt.Sprintf("%s/%s/discussions/%d", s.baseURL, repo.Name, id), http.StatusSeeOther)
430 +
		return
431 +
	}
432 +
433 +
	pd := s.newPageData(r, repo, "discussions", "")
434 +
	pd.Data = newDiscussionData{}
435 +
	s.tmpl.render(w, "discussion_new", pd)
436 +
}
437 +
438 +
func (s *server) handleLogin(w http.ResponseWriter, r *http.Request) {
439 +
	type loginData struct {
440 +
		Error    string
441 +
		Handle   string
442 +
		ReturnTo string
443 +
	}
444 +
445 +
	returnTo := r.URL.Query().Get("return")
446 +
	if returnTo == "" {
447 +
		returnTo = s.baseURL + "/"
448 +
	}
449 +
450 +
	if r.Method == http.MethodPost {
451 +
		handle := strings.TrimPrefix(strings.TrimSpace(r.FormValue("handle")), "@")
452 +
		appPassword := strings.TrimSpace(r.FormValue("app_password"))
453 +
		returnTo = r.FormValue("return")
454 +
455 +
		if handle == "" || appPassword == "" {
456 +
			pd := s.newPageData(r, nil, "", "")
457 +
			pd.Data = loginData{Error: "Handle and app password are required.", Handle: handle, ReturnTo: returnTo}
458 +
			s.tmpl.render(w, "login", pd)
459 +
			return
460 +
		}
461 +
462 +
		if err := verifyBlueskyCredentials(handle, appPassword); err != nil {
463 +
			pd := s.newPageData(r, nil, "", "")
464 +
			pd.Data = loginData{Error: "Sign-in failed: " + err.Error(), Handle: handle, ReturnTo: returnTo}
465 +
			s.tmpl.render(w, "login", pd)
466 +
			return
467 +
		}
468 +
469 +
		avatar := fetchBlueskyAvatar(handle)
470 +
		if avatar != "" {
471 +
			upsertAvatar(s.db, handle, avatar)
472 +
		}
473 +
		setSessionCookie(w, handle)
474 +
		http.Redirect(w, r, returnTo, http.StatusSeeOther)
475 +
		return
476 +
	}
477 +
478 +
	pd := s.newPageData(r, nil, "", "")
479 +
	pd.Data = loginData{ReturnTo: returnTo}
480 +
	s.tmpl.render(w, "login", pd)
481 +
}
482 +
483 +
func (s *server) handleLogout(w http.ResponseWriter, r *http.Request) {
484 +
	clearSessionCookie(w)
485 +
	returnTo := r.URL.Query().Get("return")
486 +
	if returnTo == "" {
487 +
		returnTo = s.baseURL + "/"
488 +
	}
489 +
	http.Redirect(w, r, returnTo, http.StatusSeeOther)
490 +
}
go.mod +14 -0
1 1
module forge
2 2
3 3
go 1.25.7
4 +
5 +
require (
6 +
	github.com/dustin/go-humanize v1.0.1 // indirect
7 +
	github.com/google/uuid v1.6.0 // indirect
8 +
	github.com/mattn/go-isatty v0.0.20 // indirect
9 +
	github.com/ncruces/go-strftime v1.0.0 // indirect
10 +
	github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
11 +
	golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
12 +
	golang.org/x/sys v0.37.0 // indirect
13 +
	modernc.org/libc v1.67.6 // indirect
14 +
	modernc.org/mathutil v1.7.1 // indirect
15 +
	modernc.org/memory v1.11.0 // indirect
16 +
	modernc.org/sqlite v1.46.1 // indirect
17 +
)
go.sum +23 -0
1 +
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
2 +
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
3 +
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
4 +
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
5 +
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
6 +
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
7 +
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
8 +
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
9 +
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
10 +
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
11 +
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
12 +
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
13 +
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
14 +
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
15 +
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
16 +
modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI=
17 +
modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE=
18 +
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
19 +
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
20 +
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
21 +
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
22 +
modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=
23 +
modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
handler.go +40 -9
15 15
	Repo            string
16 16
	Description     string
17 17
	Section         string
18 18
	Ref             string
19 19
	CommitHash      string
20 +
	Handle          string
21 +
	Avatar          string
20 22
	Data            any
21 23
}
22 24
23 -
func (s *server) newPageData(repo *RepoInfo, section, ref string) pageData {
25 +
func (s *server) newPageData(r *http.Request, repo *RepoInfo, section, ref string) pageData {
26 +
	handle := getSessionHandle(r)
27 +
	var avatar string
28 +
	if handle != "" {
29 +
		avatar = getAvatar(s.db, handle)
30 +
	}
24 31
	pd := pageData{
25 32
		SiteTitle:       s.title,
26 33
		SiteDescription: s.description,
27 34
		BaseURL:         s.baseURL,
28 35
		Section:         section,
29 36
		Ref:             ref,
37 +
		Handle:          handle,
38 +
		Avatar:          avatar,
30 39
	}
31 40
	if repo != nil {
32 41
		pd.Repo = repo.Name
33 42
		pd.Description = repo.Description
34 43
	}
83 92
	if path == "" {
84 93
		s.handleIndex(w, r)
85 94
		return
86 95
	}
87 96
97 +
	if path == "login" {
98 +
		s.handleLogin(w, r)
99 +
		return
100 +
	}
101 +
	if path == "logout" {
102 +
		s.handleLogout(w, r)
103 +
		return
104 +
	}
105 +
88 106
	segments := strings.SplitN(path, "/", 3)
89 107
	repoName := strings.TrimSuffix(segments[0], ".git")
90 108
91 109
	repo, ok := s.repos[repoName]
92 110
	if !ok {
120 138
		s.handleTree(w, r, repo, rest)
121 139
	case "commit":
122 140
		s.handleCommit(w, r, repo, rest)
123 141
	case "raw":
124 142
		s.handleRaw(w, r, repo, rest)
143 +
	case "discussions":
144 +
		s.routeDiscussions(w, r, repo, rest)
125 145
	default:
126 146
		s.renderError(w, r, http.StatusNotFound, "Page not found")
127 147
	}
128 148
}
129 149
134 154
	}
135 155
	repos := make([]*RepoInfo, 0, len(s.sorted))
136 156
	for _, name := range s.sorted {
137 157
		repos = append(repos, s.repos[name])
138 158
	}
139 -
	pd := s.newPageData(nil, "", "")
159 +
	pd := s.newPageData(r, nil, "", "")
140 160
	pd.Data = indexData{Repos: repos, IsEmpty: len(repos) == 0}
141 161
	s.tmpl.render(w, "index", pd)
142 162
}
143 163
144 164
type homeData struct {
165 185
		ref = git.getDefaultBranch()
166 186
	}
167 187
168 188
	hash, err := git.resolveRef(ref)
169 189
	if err != nil {
170 -
		pd := s.newPageData(repo, "home", ref)
190 +
		pd := s.newPageData(r, repo, "home", ref)
171 191
		pd.Data = homeData{
172 192
			DefaultRef: ref,
173 193
			IsEmpty:    true,
174 194
		}
175 195
		s.tmpl.render(w, "home", pd)
185 205
	if activePath != "" && blob == nil {
186 206
		readmeDir = activePath
187 207
	}
188 208
	readme := git.getReadme(hash, readmeDir)
189 209
190 -
	pd := s.newPageData(repo, "home", ref)
210 +
	pd := s.newPageData(r, repo, "home", ref)
191 211
	scheme := "https"
192 212
	if r.TLS == nil && r.Header.Get("X-Forwarded-Proto") == "" {
193 213
		scheme = "http"
194 214
	}
195 215
	if proto := r.Header.Get("X-Forwarded-Proto"); proto != "" {
238 258
		refStr = git.getDefaultBranch()
239 259
	}
240 260
241 261
	hash, err := git.resolveRef(refStr)
242 262
	if err != nil {
243 -
		pd := s.newPageData(repo, "log", refStr)
263 +
		pd := s.newPageData(r, repo, "log", refStr)
244 264
		pd.Data = logData{IsEmpty: true, Ref: refStr}
245 265
		s.tmpl.render(w, "log", pd)
246 266
		return
247 267
	}
248 268
253 273
	}
254 274
255 275
	branches, _ := git.getBranches()
256 276
	lastCommit, _ := git.getCommit(hash)
257 277
258 -
	pd := s.newPageData(repo, "log", refStr)
278 +
	pd := s.newPageData(r, repo, "log", refStr)
259 279
	pd.Data = logData{
260 280
		Branches:   branches,
261 281
		LastCommit: lastCommit,
262 282
		Commits:    commits,
263 283
		Page:       page,
324 344
	if len(files) > maxDiffFiles {
325 345
		truncatedFiles = len(files) - maxDiffFiles
326 346
		files = files[:maxDiffFiles]
327 347
	}
328 348
329 -
	pd := s.newPageData(repo, "commit", "")
349 +
	pd := s.newPageData(r, repo, "commit", "")
330 350
	pd.CommitHash = commit.Hash
331 351
	pd.Data = commitData{
332 352
		Commit:         commit,
333 353
		Files:          files,
334 354
		TruncatedFiles: truncatedFiles,
344 364
345 365
	git := repo.Git
346 366
	branches, _ := git.getBranches()
347 367
	tags, _ := git.getTags()
348 368
349 -
	pd := s.newPageData(repo, "refs", "")
369 +
	pd := s.newPageData(r, repo, "refs", "")
350 370
	pd.Data = refsData{Branches: branches, Tags: tags}
351 371
	s.tmpl.render(w, "refs", pd)
352 372
}
353 373
354 374
func (s *server) handleRaw(w http.ResponseWriter, r *http.Request, repo *RepoInfo, rest string) {
379 399
	w.Header().Set("Content-Type", contentType)
380 400
	w.Header().Set("Content-Length", fmt.Sprintf("%d", size))
381 401
	io.Copy(w, reader)
382 402
}
383 403
404 +
func (s *server) routeDiscussions(w http.ResponseWriter, r *http.Request, repo *RepoInfo, rest string) {
405 +
	switch {
406 +
	case rest == "" || rest == "/":
407 +
		s.handleDiscussions(w, r, repo)
408 +
	case rest == "new":
409 +
		s.handleNewDiscussion(w, r, repo)
410 +
	default:
411 +
		s.handleDiscussion(w, r, repo, rest)
412 +
	}
413 +
}
414 +
384 415
func (s *server) renderError(w http.ResponseWriter, r *http.Request, code int, message string) {
385 416
	type errorData struct {
386 417
		Code    int
387 418
		Message string
388 419
		Path    string
389 420
	}
390 421
	w.WriteHeader(code)
391 -
	pd := s.newPageData(nil, "", "")
422 +
	pd := s.newPageData(r, nil, "", "")
392 423
	pd.Data = errorData{Code: code, Message: message, Path: r.URL.Path}
393 424
	s.tmpl.render(w, "error", pd)
394 425
}
main.go +13 -0
1 1
package main
2 2
3 3
import (
4 +
	"database/sql"
4 5
	"flag"
5 6
	"fmt"
6 7
	"log"
7 8
	"net/http"
8 9
	"os"
13 14
14 15
type server struct {
15 16
	repos       map[string]*RepoInfo
16 17
	sorted      []string
17 18
	tmpl        *templateSet
19 +
	db          *sql.DB
18 20
	title       string
19 21
	description string
20 22
	baseURL     string
21 23
	scanPath    string
22 24
	username    string
30 32
	description := flag.String("description", "", "site description shown on the index page")
31 33
	baseURL := flag.String("base-url", "", "base URL prefix (e.g. /git)")
32 34
	nonBare := flag.Bool("non-bare", false, "also scan for non-bare repos (dirs containing .git)")
33 35
	username := flag.String("username", "", "HTTP basic auth username (requires -password)")
34 36
	password := flag.String("password", "", "HTTP basic auth password (requires -username)")
37 +
	dbPath := flag.String("db", "", "path to SQLite database for discussions (default: <scan-path>/forge.db)")
35 38
	flag.Parse()
36 39
37 40
	if (*username == "") != (*password == "") {
38 41
		log.Fatal("-username and -password must both be set, or both be omitted")
39 42
	}
59 62
	tmpl, err := loadTemplates()
60 63
	if err != nil {
61 64
		log.Fatalf("load templates: %v", err)
62 65
	}
63 66
67 +
	if *dbPath == "" {
68 +
		*dbPath = filepath.Join(abs, "forge.db")
69 +
	}
70 +
	db, err := openDB(*dbPath)
71 +
	if err != nil {
72 +
		log.Fatalf("open database: %v", err)
73 +
	}
74 +
	defer db.Close()
75 +
64 76
	srv := &server{
65 77
		repos:       repos,
66 78
		sorted:      sorted,
67 79
		tmpl:        tmpl,
80 +
		db:          db,
68 81
		title:       *title,
69 82
		description: *description,
70 83
		baseURL:     strings.TrimRight(*baseURL, "/"),
71 84
		scanPath:    abs,
72 85
		username:    *username,
static/style.css +237 -0
141 141
.tab.tab-mono {
142 142
  font-family: var(--mono);
143 143
  font-size: 1rem;
144 144
}
145 145
.repo-nav .tab:first-of-type { margin-left: auto; }
146 +
.nav-auth {
147 +
  display: flex;
148 +
  align-items: center;
149 +
  gap: 0.75rem;
150 +
  margin-left: 1.5rem;
151 +
  align-self: flex-end;
152 +
  margin-bottom: 0.5rem;
153 +
  white-space: nowrap;
154 +
}
155 +
.repo-nav .repo-name + .nav-auth { margin-left: auto; }
156 +
.nav-btn { color: var(--fg); }
157 +
.nav-btn:hover { color: var(--fg); }
158 +
159 +
.avatar {
160 +
  width: 24px;
161 +
  height: 24px;
162 +
  border-radius: 4px;
163 +
  border: 1px solid var(--border);
164 +
  object-fit: cover;
165 +
}
166 +
167 +
.avatar-menu {
168 +
  position: relative;
169 +
}
170 +
.avatar-menu summary {
171 +
  list-style: none;
172 +
  cursor: pointer;
173 +
  line-height: 0;
174 +
}
175 +
.avatar-menu summary::-webkit-details-marker { display: none; }
176 +
.avatar-dropdown {
177 +
  position: absolute;
178 +
  right: 0;
179 +
  z-index: 10;
180 +
  background: var(--bg);
181 +
  border: 1px solid var(--border);
182 +
  border-radius: 0 0 4px 4px;
183 +
  margin-top: 0.5rem;
184 +
  padding: 0.75rem 1rem 1rem;
185 +
  display: flex;
186 +
  flex-direction: column;
187 +
  gap: 0.5rem;
188 +
}
189 +
.avatar-dropdown .signed-in {
190 +
  font-family: var(--mono);
191 +
  font-size: 0.875rem;
192 +
  color: var(--fg-dim);
193 +
}
194 +
.avatar-dropdown .btn {
195 +
  text-align: center;
196 +
}
146 197
147 198
/* --- Headings --- */
148 199
149 200
h2 { font-family: var(--sans); font-weight: 500; font-size: 1rem; margin-bottom: 0.75rem; color: var(--fg-headers); }
150 201
h3 {
751 802
.language-ril .string { color: var(--color-secondary); }
752 803
.language-ril .reg { color: var(--color-secondary); }
753 804
.language-ril .symbol { color: var(--color-highlight-dim); }
754 805
.language-ril .label { color: var(--color-highlight-dim); font-style: italic; }
755 806
807 +
/* --- Discussions --- */
808 +
809 +
.discussions-header {
810 +
  display: flex;
811 +
  align-items: baseline;
812 +
  justify-content: space-between;
813 +
  margin-bottom: 1rem;
814 +
}
815 +
.discussions-header h2 { margin-bottom: 0; }
816 +
.discussions-actions {
817 +
  display: flex;
818 +
  align-items: center;
819 +
  gap: 0.75rem;
820 +
}
821 +
.signed-in {
822 +
  font-family: var(--mono);
823 +
  font-size: 0.875rem;
824 +
  color: var(--fg-dim);
825 +
}
826 +
827 +
.btn {
828 +
  display: inline-block;
829 +
  padding: 0.25rem 0.75rem;
830 +
  border: 1px solid var(--border);
831 +
  border-radius: 4px;
832 +
  font-size: 0.875rem;
833 +
  font-family: var(--sans);
834 +
  color: var(--fg);
835 +
  background: var(--bg);
836 +
  cursor: pointer;
837 +
  text-decoration: none;
838 +
}
839 +
.btn:hover { background: var(--bg-alt); text-decoration: none; }
840 +
.btn:disabled { opacity: 0.5; cursor: default; }
841 +
main .btn { font-size: 1rem; }
842 +
843 +
.discussion-list {
844 +
  width: 100%;
845 +
  border: 1px solid var(--border);
846 +
  border-radius: 4px;
847 +
}
848 +
.discussion-list td { border: none; padding: 0.5rem 0.75rem; }
849 +
.discussion-list tr:hover { background: var(--bg-alt); }
850 +
.discussion-list .discussion-title { font-weight: 600; }
851 +
.discussion-list .discussion-title a { color: var(--fg); }
852 +
.discussion-list .discussion-title a:hover { color: var(--link); }
853 +
.discussion-list .discussion-meta {
854 +
  white-space: nowrap;
855 +
  color: var(--fg-dim);
856 +
  font-size: 0.875rem;
857 +
  text-align: right;
858 +
}
859 +
.discussion-list .discussion-replies {
860 +
  white-space: nowrap;
861 +
  color: var(--fg-dim);
862 +
  font-size: 0.875rem;
863 +
  text-align: right;
864 +
  width: 1%;
865 +
}
866 +
867 +
.discussion-thread { max-width: 48rem; }
868 +
869 +
.post {
870 +
  border: 1px solid var(--border);
871 +
  border-radius: 4px;
872 +
  margin-bottom: 1rem;
873 +
}
874 +
.post-header {
875 +
  padding: 0.75rem;
876 +
  border-bottom: 1px solid var(--border);
877 +
}
878 +
.post-header h2 {
879 +
  margin: 0;
880 +
  font-size: 1.125rem;
881 +
  font-weight: 600;
882 +
}
883 +
.post .post-meta {
884 +
  padding: 0.5rem 0.75rem;
885 +
  border-bottom: 1px solid var(--border);
886 +
}
887 +
.post .post-body {
888 +
  padding: 0.75rem;
889 +
}
890 +
891 +
.post-meta {
892 +
  display: flex;
893 +
  align-items: baseline;
894 +
  gap: 0.75rem;
895 +
  font-size: 0.875rem;
896 +
}
897 +
.post-meta .author {
898 +
  font-family: var(--mono);
899 +
  color: var(--fg);
900 +
  font-weight: 600;
901 +
}
902 +
.post-meta .date { color: var(--fg-dim); }
903 +
904 +
.post-body pre {
905 +
  margin: 0;
906 +
  font-family: var(--sans);
907 +
  font-size: 1rem;
908 +
  line-height: 1.5;
909 +
  white-space: pre-wrap;
910 +
  word-wrap: break-word;
911 +
}
912 +
913 +
.discussion-form {
914 +
  margin-top: 1rem;
915 +
}
916 +
.discussion-form label {
917 +
  display: block;
918 +
  font-size: 0.875rem;
919 +
  font-weight: 600;
920 +
  margin-bottom: 0.25rem;
921 +
  color: var(--fg);
922 +
}
923 +
.discussion-form input[type="text"],
924 +
.discussion-form input[type="password"],
925 +
.discussion-form textarea {
926 +
  display: block;
927 +
  width: 100%;
928 +
  padding: 0.5rem 0.75rem;
929 +
  font-family: var(--sans);
930 +
  font-size: 1rem;
931 +
  border: 1px solid var(--border);
932 +
  border-radius: 4px;
933 +
  background: var(--bg-alt);
934 +
  color: var(--fg);
935 +
  margin-bottom: 1rem;
936 +
  outline: none;
937 +
}
938 +
.discussion-form input:focus,
939 +
.discussion-form textarea:focus {
940 +
  border-color: var(--fg);
941 +
}
942 +
.discussion-form textarea {
943 +
  resize: vertical;
944 +
  min-height: 4rem;
945 +
}
946 +
947 +
.form-actions {
948 +
  display: flex;
949 +
  align-items: center;
950 +
  gap: 1rem;
951 +
}
952 +
953 +
.form-error {
954 +
  color: var(--del-fg);
955 +
  font-weight: 600;
956 +
  margin-bottom: 1rem;
957 +
}
958 +
959 +
.sign-in-prompt {
960 +
  color: var(--fg-dim);
961 +
  font-size: 0.875rem;
962 +
  padding: 0.75rem 0;
963 +
}
964 +
965 +
.login-page {
966 +
  max-width: 24rem;
967 +
  margin: 1rem 0 1rem 2rem;
968 +
}
969 +
970 +
.bsky-logo {
971 +
  width: 1rem;
972 +
  height: 1rem;
973 +
  vertical-align: -0.125rem;
974 +
}
975 +
976 +
.login-hint {
977 +
  color: var(--fg-dim);
978 +
  font-size: 0.875rem;
979 +
  margin-bottom: 1rem;
980 +
  line-height: 1.5;
981 +
}
982 +
756 983
/* --- Mobile --- */
757 984
758 985
@media (max-width: 720px) {
759 986
  .container {
760 987
    padding: 0 0.5rem 2rem;
761 988
  }
762 989
990 +
763 991
  .file-view pre,
764 992
  .blob-code pre,
765 993
  .blob-code .line-num a,
766 994
  .diff-hunk pre,
767 995
  td.diff-num,
796 1024
  }
797 1025
798 1026
  .diff-header {
799 1027
    flex-wrap: wrap;
800 1028
  }
1029 +
1030 +
  .discussions-header {
1031 +
    flex-wrap: wrap;
1032 +
    gap: 0.5rem;
1033 +
  }
1034 +
1035 +
  .discussion-list .discussion-meta {
1036 +
    display: none;
1037 +
  }
801 1038
}
template.go +1 -1
203 203
	layoutContent, err := templateFS.ReadFile("templates/layout.html")
204 204
	if err != nil {
205 205
		return nil, fmt.Errorf("read layout: %w", err)
206 206
	}
207 207
208 -
	pages := []string{"index", "home", "log", "commit", "refs", "error"}
208 +
	pages := []string{"index", "home", "log", "commit", "refs", "error", "discussions", "discussion", "discussion_new", "login"}
209 209
	ts := &templateSet{
210 210
		templates: make(map[string]*template.Template, len(pages)),
211 211
	}
212 212
213 213
	for _, page := range pages {
templates/discussion.html added +42 -0
1 +
{{define "content"}}
2 +
<div class="discussion-thread">
3 +
  <div class="post">
4 +
    <div class="post-header">
5 +
      <h2>{{.Data.Discussion.Title}}</h2>
6 +
    </div>
7 +
    <div class="post-meta">
8 +
      {{if .Data.Discussion.Avatar}}<img class="avatar" src="{{.Data.Discussion.Avatar}}" alt="" width="20" height="20">{{end}}<span class="author">{{.Data.Discussion.Author}}</span>
9 +
      <span class="date">{{timeAgo .Data.Discussion.CreatedAt}} ago</span>
10 +
    </div>
11 +
    {{if .Data.Discussion.Body}}
12 +
    <div class="post-body"><pre>{{autolink .Data.Discussion.Body}}</pre></div>
13 +
    {{end}}
14 +
  </div>
15 +
16 +
  {{range .Data.Replies}}
17 +
  <div class="post" id="reply-{{.ID}}">
18 +
    <div class="post-meta">
19 +
      {{if .Avatar}}<img class="avatar" src="{{.Avatar}}" alt="" width="20" height="20">{{end}}<span class="author">{{.Author}}</span>
20 +
      <span class="date">{{timeAgo .CreatedAt}} ago</span>
21 +
    </div>
22 +
    <div class="post-body"><pre>{{autolink .Body}}</pre></div>
23 +
  </div>
24 +
  {{end}}
25 +
26 +
  {{if .Data.Error}}
27 +
  <p class="form-error">{{.Data.Error}}</p>
28 +
  {{end}}
29 +
30 +
  {{if .Handle}}
31 +
  <div class="discussion-form">
32 +
    <form method="POST" action="{{.BaseURL}}/{{.Repo}}/discussions/{{.Data.Discussion.ID}}">
33 +
      <label for="body">Reply as <strong>{{.Handle}}</strong></label>
34 +
      <textarea id="body" name="body" rows="4" placeholder="Write a reply…" required></textarea>
35 +
      <button type="submit" class="btn">Post reply</button>
36 +
    </form>
37 +
  </div>
38 +
  {{else}}
39 +
  <p class="sign-in-prompt"><a href="{{.BaseURL}}/login?return={{.BaseURL}}/{{.Repo}}/discussions/{{.Data.Discussion.ID}}">Sign in with Bluesky</a> to reply.</p>
40 +
  {{end}}
41 +
</div>
42 +
{{end}}
templates/discussion_new.html added +22 -0
1 +
{{define "content"}}
2 +
<div class="discussion-thread">
3 +
  <h2>New discussion</h2>
4 +
5 +
  {{if .Data.Error}}
6 +
  <p class="form-error">{{.Data.Error}}</p>
7 +
  {{end}}
8 +
9 +
  <div class="discussion-form">
10 +
    <form method="POST" action="{{.BaseURL}}/{{.Repo}}/discussions/new">
11 +
      <label for="title">Title</label>
12 +
      <input type="text" id="title" name="title" value="{{.Data.Title}}" required maxlength="200" placeholder="Discussion title">
13 +
      <label for="body">Body</label>
14 +
      <textarea id="body" name="body" rows="8" placeholder="Write your discussion…">{{.Data.Body}}</textarea>
15 +
      <div class="form-actions">
16 +
        <button type="submit" class="btn">Create discussion</button>
17 +
        <a href="{{.BaseURL}}/{{.Repo}}/discussions">Cancel</a>
18 +
      </div>
19 +
    </form>
20 +
  </div>
21 +
</div>
22 +
{{end}}
templates/discussions.html added +29 -0
1 +
{{define "content"}}
2 +
<div class="discussions-header">
3 +
  <h2>Discussions</h2>
4 +
  {{if .Handle}}
5 +
  <div class="discussions-actions">
6 +
    <a href="{{.BaseURL}}/{{.Repo}}/discussions/new" class="btn">New discussion</a>
7 +
  </div>
8 +
  {{end}}
9 +
</div>
10 +
11 +
{{if .Data.IsEmpty}}
12 +
<p class="empty">No discussions yet.</p>
13 +
{{else}}
14 +
<table class="discussion-list">
15 +
<tbody>
16 +
{{range .Data.Discussions}}
17 +
<tr>
18 +
  <td class="discussion-title"><a href="{{$.BaseURL}}/{{$.Repo}}/discussions/{{.ID}}">{{.Title}}</a></td>
19 +
  <td class="discussion-meta">
20 +
    <span class="author">{{.Author}}</span>
21 +
    <span class="date">{{timeAgo .CreatedAt}} ago</span>
22 +
  </td>
23 +
  <td class="discussion-replies">{{.ReplyCount}} {{if eq .ReplyCount 1}}reply{{else}}replies{{end}}</td>
24 +
</tr>
25 +
{{end}}
26 +
</tbody>
27 +
</table>
28 +
{{end}}
29 +
{{end}}
templates/layout.html +3 -0
9 9
<body>
10 10
<div class="container">
11 11
{{if not .Repo}}
12 12
<nav class="repo-nav">
13 13
  <span class="repo-name"><a href="{{.BaseURL}}/" class="logo-link"><img class="logo" src="{{.BaseURL}}/radiant.svg" alt="" width="16" height="16"></a><a href="{{.BaseURL}}/">{{.SiteTitle}}</a>{{if .SiteDescription}}<span class="repo-desc desktop">{{.SiteDescription}}</span>{{end}}</span>
14 +
  <span class="nav-auth">{{if .Handle}}<details class="avatar-menu"><summary>{{if .Avatar}}<img class="avatar" src="{{.Avatar}}" alt="" width="24" height="24">{{else}}<span class="avatar-placeholder">{{.Handle}}</span>{{end}}</summary><div class="avatar-dropdown"><span class="signed-in">{{.Handle}}</span><a href="{{.BaseURL}}/logout?return={{.BaseURL}}/" class="btn nav-btn">Sign out</a></div></details>{{else}}<a href="{{.BaseURL}}/login?return={{.BaseURL}}/" class="btn nav-btn">Sign in</a>{{end}}</span>
14 15
</nav>
15 16
{{end}}
16 17
{{if .Repo}}
17 18
<nav class="repo-nav">
18 19
  <span class="repo-name"><a href="{{.BaseURL}}/" class="logo-link"><img class="logo" src="{{.BaseURL}}/radiant.svg" alt="" width="16" height="16"></a><a href="{{.BaseURL}}/{{.Repo}}/">{{.Repo}}</a>{{if .Description}}<span class="repo-desc desktop">{{.Description}}</span>{{end}}</span>
19 20
  <a href="{{.BaseURL}}/{{.Repo}}/" class="tab{{if eq .Section "home"}} active{{end}}">home</a>
20 21
  <a href="{{.BaseURL}}/{{.Repo}}/log/{{.Ref}}" class="tab{{if eq .Section "log"}} active{{end}}">log</a>
21 22
  <a href="{{.BaseURL}}/{{.Repo}}/refs" class="tab{{if eq .Section "refs"}} active{{end}}">refs</a>
23 +
  <a href="{{.BaseURL}}/{{.Repo}}/discussions" class="tab{{if eq .Section "discussions"}} active{{end}}">discussions</a>
22 24
  {{if .CommitHash}}<a href="{{.BaseURL}}/{{.Repo}}/commit/{{.CommitHash}}" class="tab tab-mono active">{{shortHash .CommitHash}}</a>{{end}}
25 +
  <span class="nav-auth">{{if .Handle}}<details class="avatar-menu"><summary>{{if .Avatar}}<img class="avatar" src="{{.Avatar}}" alt="" width="24" height="24">{{else}}<span class="avatar-placeholder">{{.Handle}}</span>{{end}}</summary><div class="avatar-dropdown"><span class="signed-in">{{.Handle}}</span><a href="{{.BaseURL}}/logout?return={{.BaseURL}}/{{.Repo}}/" class="btn nav-btn">Sign out</a></div></details>{{else}}<a href="{{.BaseURL}}/login?return={{.BaseURL}}/{{.Repo}}/" class="btn nav-btn">Sign in</a>{{end}}</span>
23 26
</nav>
24 27
{{end}}
25 28
<main>
26 29
{{template "content" .}}
27 30
</main>
templates/login.html added +24 -0
1 +
{{define "content"}}
2 +
<div class="login-page">
3 +
  <h2>Sign in with <svg class="bsky-logo" viewBox="0 0 320 286" xmlns="http://www.w3.org/2000/svg"><path fill="#0a7aff" d="M69.364 19.146c36.687 27.806 76.147 84.186 90.636 114.439 14.489-30.253 53.948-86.633 90.636-114.439C277.107-.917 320-16.44 320 32.957c0 9.865-5.603 82.875-8.889 94.729-11.423 41.208-53.045 51.719-90.071 45.357 64.719 11.12 81.182 47.953 45.627 84.785-80 82.874-106.667-44.333-106.667-44.333s-26.667 127.207-106.667 44.333c-35.555-36.832-19.092-73.665 45.627-84.785-37.026 6.362-78.648-4.149-90.071-45.357C5.603 115.832 0 42.822 0 32.957 0-16.44 42.893-.917 69.364 19.147Z"/></svg> Bluesky</h2>
4 +
  <p class="login-hint">Sign in with your <a href="https://bsky.app">Bluesky</a> account using an <a href="https://bsky.app/settings/app-passwords">app password</a>. Your app password is only sent to Bluesky to verify your identity and is not stored.</p>
5 +
6 +
  {{if .Data.Error}}
7 +
  <p class="form-error">{{.Data.Error}}</p>
8 +
  {{end}}
9 +
10 +
  <div class="discussion-form">
11 +
    <form method="POST" action="{{.BaseURL}}/login" onsubmit="this.querySelector('button[type=submit]').disabled=true">
12 +
      <input type="hidden" name="return" value="{{.Data.ReturnTo}}">
13 +
      <label for="handle">Bluesky handle</label>
14 +
      <input type="text" id="handle" name="handle" value="{{.Data.Handle}}" required placeholder="alice.bsky.social">
15 +
      <label for="app_password">App password</label>
16 +
      <input type="password" id="app_password" name="app_password" required placeholder="xxxx-xxxx-xxxx-xxxx">
17 +
      <div class="form-actions">
18 +
        <button type="submit" class="btn">Sign in</button>
19 +
        <a href="{{.Data.ReturnTo}}">Cancel</a>
20 +
      </div>
21 +
    </form>
22 +
  </div>
23 +
</div>
24 +
{{end}}