Add Bluesky login
afe0ef998afa8448ca67e212304db6c6be2f7c71
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}} |