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