package main import ( "bytes" "crypto/hmac" "crypto/rand" "crypto/sha256" "database/sql" "encoding/hex" "encoding/json" "fmt" "io" "log" "net/http" "strconv" "strings" "time" _ "modernc.org/sqlite" ) // Discussion is a top-level discussion thread. type Discussion struct { ID int Repo string Title string Body string Author string Avatar string CreatedAt time.Time ReplyCount int LastActive time.Time } // Reply is a reply to a discussion. type Reply struct { ID int Body string Author string Avatar string CreatedAt time.Time } const sessionCookieName = "forge_session" const sessionMaxAge = 30 * 24 * time.Hour const maxTitleLen = 200 const maxBodyLen = 8000 // sessionSecret is generated once at startup and used to sign cookies. var sessionSecret []byte func init() { sessionSecret = make([]byte, 32) if _, err := rand.Read(sessionSecret); err != nil { panic("failed to generate session secret: " + err.Error()) } } // --- Database --- func openDB(path string) (*sql.DB, error) { db, err := sql.Open("sqlite", path+"?_journal_mode=WAL&_busy_timeout=5000") if err != nil { return nil, fmt.Errorf("open database: %w", err) } if err := migrateDB(db); err != nil { db.Close() return nil, fmt.Errorf("migrate database: %w", err) } return db, nil } func migrateDB(db *sql.DB) error { _, err := db.Exec(` CREATE TABLE IF NOT EXISTS discussions ( id INTEGER PRIMARY KEY AUTOINCREMENT, repo TEXT NOT NULL, title TEXT NOT NULL, body TEXT NOT NULL DEFAULT '', author TEXT NOT NULL, created_at DATETIME NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS replies ( id INTEGER PRIMARY KEY AUTOINCREMENT, discussion_id INTEGER NOT NULL REFERENCES discussions(id), body TEXT NOT NULL, author TEXT NOT NULL, created_at DATETIME NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS avatars ( handle TEXT PRIMARY KEY, url TEXT NOT NULL, updated_at DATETIME NOT NULL DEFAULT (datetime('now')) ); CREATE INDEX IF NOT EXISTS idx_discussions_repo ON discussions(repo); CREATE INDEX IF NOT EXISTS idx_discussions_author ON discussions(author); CREATE INDEX IF NOT EXISTS idx_replies_discussion ON replies(discussion_id); CREATE INDEX IF NOT EXISTS idx_replies_author ON replies(author); `) return err } func upsertAvatar(db *sql.DB, handle, url string) { db.Exec(` INSERT INTO avatars (handle, url, updated_at) VALUES (?, ?, datetime('now')) ON CONFLICT(handle) DO UPDATE SET url = excluded.url, updated_at = excluded.updated_at `, handle, url) } func getAvatar(db *sql.DB, handle string) string { var url string db.QueryRow(`SELECT url FROM avatars WHERE handle = ?`, handle).Scan(&url) return url } func listDiscussions(db *sql.DB, repo string) []Discussion { rows, err := db.Query(` SELECT d.id, d.repo, d.title, d.body, d.author, COALESCE(a.url, ''), d.created_at, COUNT(r.id), COALESCE(MAX(r.created_at), d.created_at) FROM discussions d LEFT JOIN replies r ON r.discussion_id = d.id LEFT JOIN avatars a ON a.handle = d.author WHERE d.repo = ? GROUP BY d.id ORDER BY 9 DESC `, repo) if err != nil { log.Printf("listDiscussions: %v", err) return nil } defer rows.Close() var out []Discussion for rows.Next() { var d Discussion var lastActive string if err := rows.Scan(&d.ID, &d.Repo, &d.Title, &d.Body, &d.Author, &d.Avatar, &d.CreatedAt, &d.ReplyCount, &lastActive); err != nil { continue } for _, layout := range []string{time.RFC3339, "2006-01-02 15:04:05", "2006-01-02T15:04:05Z"} { if t, err := time.Parse(layout, lastActive); err == nil { d.LastActive = t break } } out = append(out, d) } return out } func getDiscussion(db *sql.DB, id int) (*Discussion, error) { var d Discussion err := db.QueryRow(` SELECT d.id, d.repo, d.title, d.body, d.author, COALESCE(a.url, ''), d.created_at FROM discussions d LEFT JOIN avatars a ON a.handle = d.author WHERE d.id = ? `, id).Scan(&d.ID, &d.Repo, &d.Title, &d.Body, &d.Author, &d.Avatar, &d.CreatedAt) if err != nil { return nil, err } return &d, nil } func getReplies(db *sql.DB, discussionID int) []Reply { rows, err := db.Query(` SELECT r.id, r.body, r.author, COALESCE(a.url, ''), r.created_at FROM replies r LEFT JOIN avatars a ON a.handle = r.author WHERE r.discussion_id = ? ORDER BY r.created_at ASC `, discussionID) if err != nil { return nil } defer rows.Close() var out []Reply for rows.Next() { var r Reply if err := rows.Scan(&r.ID, &r.Body, &r.Author, &r.Avatar, &r.CreatedAt); err != nil { continue } out = append(out, r) } return out } func createDiscussion(db *sql.DB, repo, title, body, author string) (int64, error) { res, err := db.Exec(` INSERT INTO discussions (repo, title, body, author, created_at) VALUES (?, ?, ?, ?, ?) `, repo, title, body, author, time.Now()) if err != nil { return 0, err } return res.LastInsertId() } func createReply(db *sql.DB, discussionID int, body, author string) (int64, error) { res, err := db.Exec(` INSERT INTO replies (discussion_id, body, author, created_at) VALUES (?, ?, ?, ?) `, discussionID, body, author, time.Now()) if err != nil { return 0, err } return res.LastInsertId() } // --- Sessions --- func signSession(handle string) string { expiry := time.Now().Add(sessionMaxAge).Unix() payload := fmt.Sprintf("%s|%d", handle, expiry) mac := hmac.New(sha256.New, sessionSecret) mac.Write([]byte(payload)) sig := hex.EncodeToString(mac.Sum(nil)) return payload + "|" + sig } func verifySession(value string) (string, bool) { parts := strings.SplitN(value, "|", 3) if len(parts) != 3 { return "", false } expiry, err := strconv.ParseInt(parts[1], 10, 64) if err != nil || time.Now().Unix() > expiry { return "", false } payload := parts[0] + "|" + parts[1] mac := hmac.New(sha256.New, sessionSecret) mac.Write([]byte(payload)) expected := hex.EncodeToString(mac.Sum(nil)) if !hmac.Equal([]byte(parts[2]), []byte(expected)) { return "", false } return parts[0], true } func getSessionHandle(r *http.Request) string { c, err := r.Cookie(sessionCookieName) if err != nil { return "" } handle, ok := verifySession(c.Value) if !ok { return "" } return handle } func setSessionCookie(w http.ResponseWriter, handle string) { http.SetCookie(w, &http.Cookie{ Name: sessionCookieName, Value: signSession(handle), Path: "/", MaxAge: int(sessionMaxAge.Seconds()), HttpOnly: true, SameSite: http.SameSiteLaxMode, }) } func clearSessionCookie(w http.ResponseWriter) { http.SetCookie(w, &http.Cookie{ Name: sessionCookieName, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteLaxMode, }) } // --- Bluesky --- // fetchBlueskyAvatar fetches the avatar URL for a Bluesky handle via the public API. func fetchBlueskyAvatar(handle string) string { client := &http.Client{Timeout: 5 * time.Second} resp, err := client.Get("https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=" + handle) if err != nil || resp.StatusCode != http.StatusOK { return "" } defer resp.Body.Close() var profile struct { Avatar string `json:"avatar"` } if err := json.NewDecoder(io.LimitReader(resp.Body, 16384)).Decode(&profile); err != nil { return "" } return profile.Avatar } // verifyBlueskyCredentials verifies a handle + app password by calling // com.atproto.server.createSession. The password is not stored. func verifyBlueskyCredentials(handle, appPassword string) error { body, _ := json.Marshal(map[string]string{ "identifier": handle, "password": appPassword, }) client := &http.Client{Timeout: 10 * time.Second} resp, err := client.Post("https://bsky.social/xrpc/com.atproto.server.createSession", "application/json", bytes.NewReader(body)) if err != nil { return fmt.Errorf("could not reach Bluesky server") } defer resp.Body.Close() if resp.StatusCode == http.StatusOK { return nil } respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) var errResp struct { Message string `json:"message"` } if json.Unmarshal(respBody, &errResp) == nil && errResp.Message != "" { return fmt.Errorf("%s", errResp.Message) } return fmt.Errorf("authentication failed (status %d)", resp.StatusCode) } // --- Handlers --- func (s *server) handleDiscussions(w http.ResponseWriter, r *http.Request, repo *RepoInfo) { type discussionsData struct { Discussions []Discussion IsEmpty bool } discussions := listDiscussions(s.db, repo.Name) pd := s.newPageData(r, repo, "discussions", "") pd.Data = discussionsData{ Discussions: discussions, IsEmpty: len(discussions) == 0, } s.tmpl.render(w, "discussions", pd) } func (s *server) handleDiscussion(w http.ResponseWriter, r *http.Request, repo *RepoInfo, idStr string) { type discussionData struct { Discussion *Discussion Replies []Reply Error string } id, err := strconv.Atoi(idStr) if err != nil { s.renderError(w, r, http.StatusNotFound, "Discussion not found") return } d, err := getDiscussion(s.db, id) if err != nil || d.Repo != repo.Name { s.renderError(w, r, http.StatusNotFound, "Discussion not found") return } handle := getSessionHandle(r) // Handle reply submission. if r.Method == http.MethodPost { if handle == "" { s.renderError(w, r, http.StatusForbidden, "You must be signed in to reply") return } body := strings.TrimSpace(r.FormValue("body")) if body == "" { pd := s.newPageData(r, repo, "discussions", "") pd.Data = discussionData{Discussion: d, Replies: getReplies(s.db, id), Error: "Reply cannot be empty."} s.tmpl.render(w, "discussion", pd) return } if len(body) > maxBodyLen { body = body[:maxBodyLen] } replyID, err := createReply(s.db, id, body, handle) if err != nil { s.renderError(w, r, http.StatusInternalServerError, "Failed to save reply") return } http.Redirect(w, r, fmt.Sprintf("%s/%s/discussions/%d#reply-%d", s.baseURL, repo.Name, d.ID, replyID), http.StatusSeeOther) return } pd := s.newPageData(r, repo, "discussions", "") pd.Data = discussionData{ Discussion: d, Replies: getReplies(s.db, id), } s.tmpl.render(w, "discussion", pd) } func (s *server) handleNewDiscussion(w http.ResponseWriter, r *http.Request, repo *RepoInfo) { type newDiscussionData struct { Error string Title string Body string } handle := getSessionHandle(r) if handle == "" { http.Redirect(w, r, fmt.Sprintf("%s/login?return=%s/%s/discussions/new", s.baseURL, s.baseURL, repo.Name), http.StatusSeeOther) return } if r.Method == http.MethodPost { title := strings.TrimSpace(r.FormValue("title")) body := strings.TrimSpace(r.FormValue("body")) if title == "" { pd := s.newPageData(r, repo, "discussions", "") pd.Data = newDiscussionData{Error: "Title is required.", Title: title, Body: body} s.tmpl.render(w, "discussion_new", pd) return } if len(title) > maxTitleLen { title = title[:maxTitleLen] } if len(body) > maxBodyLen { body = body[:maxBodyLen] } id, err := createDiscussion(s.db, repo.Name, title, body, handle) if err != nil { s.renderError(w, r, http.StatusInternalServerError, "Failed to save discussion") return } http.Redirect(w, r, fmt.Sprintf("%s/%s/discussions/%d", s.baseURL, repo.Name, id), http.StatusSeeOther) return } pd := s.newPageData(r, repo, "discussions", "") pd.Data = newDiscussionData{} s.tmpl.render(w, "discussion_new", pd) } // siteDiscussionRepo is the sentinel repo name used in the database for // site-level forum discussions (not scoped to any repository). const siteDiscussionRepo = "_site" func (s *server) routeSiteDiscussions(w http.ResponseWriter, r *http.Request, rest string) { switch { case rest == "" || rest == "/": s.handleSiteDiscussions(w, r) case rest == "new": s.handleNewSiteDiscussion(w, r) default: s.handleSiteDiscussion(w, r, rest) } } func (s *server) handleSiteDiscussions(w http.ResponseWriter, r *http.Request) { type discussionsData struct { Discussions []Discussion IsEmpty bool } discussions := listDiscussions(s.db, siteDiscussionRepo) pd := s.newPageData(r, nil, "forum", "") pd.Data = discussionsData{ Discussions: discussions, IsEmpty: len(discussions) == 0, } s.tmpl.render(w, "discussions", pd) } func (s *server) handleSiteDiscussion(w http.ResponseWriter, r *http.Request, idStr string) { type discussionData struct { Discussion *Discussion Replies []Reply Error string } id, err := strconv.Atoi(idStr) if err != nil { s.renderError(w, r, http.StatusNotFound, "Discussion not found") return } d, err := getDiscussion(s.db, id) if err != nil || d.Repo != siteDiscussionRepo { s.renderError(w, r, http.StatusNotFound, "Discussion not found") return } handle := getSessionHandle(r) if r.Method == http.MethodPost { if handle == "" { s.renderError(w, r, http.StatusForbidden, "You must be signed in to reply") return } body := strings.TrimSpace(r.FormValue("body")) if body == "" { pd := s.newPageData(r, nil, "forum", "") pd.Data = discussionData{Discussion: d, Replies: getReplies(s.db, id), Error: "Reply cannot be empty."} s.tmpl.render(w, "discussion", pd) return } if len(body) > maxBodyLen { body = body[:maxBodyLen] } replyID, err := createReply(s.db, id, body, handle) if err != nil { s.renderError(w, r, http.StatusInternalServerError, "Failed to save reply") return } http.Redirect(w, r, fmt.Sprintf("%s/discussions/%d#reply-%d", s.baseURL, d.ID, replyID), http.StatusSeeOther) return } pd := s.newPageData(r, nil, "forum", "") pd.Data = discussionData{ Discussion: d, Replies: getReplies(s.db, id), } s.tmpl.render(w, "discussion", pd) } func (s *server) handleNewSiteDiscussion(w http.ResponseWriter, r *http.Request) { type newDiscussionData struct { Error string Title string Body string } handle := getSessionHandle(r) if handle == "" { http.Redirect(w, r, fmt.Sprintf("%s/login?return=%s/discussions/new", s.baseURL, s.baseURL), http.StatusSeeOther) return } if r.Method == http.MethodPost { title := strings.TrimSpace(r.FormValue("title")) body := strings.TrimSpace(r.FormValue("body")) if title == "" { pd := s.newPageData(r, nil, "forum", "") pd.Data = newDiscussionData{Error: "Title is required.", Title: title, Body: body} s.tmpl.render(w, "discussion_new", pd) return } if len(title) > maxTitleLen { title = title[:maxTitleLen] } if len(body) > maxBodyLen { body = body[:maxBodyLen] } id, err := createDiscussion(s.db, siteDiscussionRepo, title, body, handle) if err != nil { s.renderError(w, r, http.StatusInternalServerError, "Failed to save discussion") return } http.Redirect(w, r, fmt.Sprintf("%s/discussions/%d", s.baseURL, id), http.StatusSeeOther) return } pd := s.newPageData(r, nil, "forum", "") pd.Data = newDiscussionData{} s.tmpl.render(w, "discussion_new", pd) } const devHandle = "cloudhead.io" func (s *server) handleDevLogin(w http.ResponseWriter, r *http.Request) { avatar := getAvatar(s.db, devHandle) if avatar != "" { upsertAvatar(s.db, devHandle, avatar) } setSessionCookie(w, devHandle) returnTo := r.URL.Query().Get("return") if returnTo == "" { returnTo = s.baseURL + "/" } http.Redirect(w, r, returnTo, http.StatusSeeOther) } func (s *server) handleLogin(w http.ResponseWriter, r *http.Request) { type loginData struct { Error string Handle string ReturnTo string } returnTo := r.URL.Query().Get("return") if returnTo == "" { returnTo = s.baseURL + "/" } if r.Method == http.MethodPost { handle := strings.TrimPrefix(strings.TrimSpace(r.FormValue("handle")), "@") appPassword := strings.TrimSpace(r.FormValue("app_password")) returnTo = r.FormValue("return") if handle == "" || appPassword == "" { pd := s.newPageData(r, nil, "", "") pd.Data = loginData{Error: "Handle and app password are required.", Handle: handle, ReturnTo: returnTo} s.tmpl.render(w, "login", pd) return } if err := verifyBlueskyCredentials(handle, appPassword); err != nil { pd := s.newPageData(r, nil, "", "") pd.Data = loginData{Error: "Sign-in failed: " + err.Error(), Handle: handle, ReturnTo: returnTo} s.tmpl.render(w, "login", pd) return } avatar := fetchBlueskyAvatar(handle) if avatar != "" { upsertAvatar(s.db, handle, avatar) } setSessionCookie(w, handle) http.Redirect(w, r, returnTo, http.StatusSeeOther) return } pd := s.newPageData(r, nil, "", "") pd.Data = loginData{ReturnTo: returnTo} s.tmpl.render(w, "login", pd) } func (s *server) handleLogout(w http.ResponseWriter, r *http.Request) { clearSessionCookie(w) returnTo := r.URL.Query().Get("return") if returnTo == "" { returnTo = s.baseURL + "/" } http.Redirect(w, r, returnTo, http.StatusSeeOther) }