[very crude, wip] post to bsky without the distraction of feeds
1package srv
2
3import (
4 "database/sql"
5 "fmt"
6 "html/template"
7 "log/slog"
8 "net/http"
9 "path/filepath"
10 "runtime"
11 "time"
12
13 "srv.exe.dev/db"
14 "srv.exe.dev/db/dbgen"
15)
16
17const (
18 BskyCharLimit = 300 // Bluesky post character limit (graphemes)
19 SessionCookie = "bsky_session"
20)
21
22type Server struct {
23 DB *sql.DB
24 Hostname string
25 BaseURL string
26 TemplatesDir string
27 StaticDir string
28 oauth *OAuthClient
29}
30
31func New(dbPath, hostname, baseURL string) (*Server, error) {
32 _, thisFile, _, _ := runtime.Caller(0)
33 baseDir := filepath.Dir(thisFile)
34 srv := &Server{
35 Hostname: hostname,
36 BaseURL: baseURL,
37 TemplatesDir: filepath.Join(baseDir, "templates"),
38 StaticDir: filepath.Join(baseDir, "static"),
39 }
40 if err := srv.setUpDatabase(dbPath); err != nil {
41 return nil, err
42 }
43 srv.oauth = NewOAuthClient(baseURL, srv.DB)
44 return srv, nil
45}
46
47func (s *Server) setUpDatabase(dbPath string) error {
48 wdb, err := db.Open(dbPath)
49 if err != nil {
50 return fmt.Errorf("failed to open db: %w", err)
51 }
52 s.DB = wdb
53 if err := db.RunMigrations(wdb); err != nil {
54 return fmt.Errorf("failed to run migrations: %w", err)
55 }
56 return nil
57}
58
59func (s *Server) Serve(addr string) error {
60 mux := http.NewServeMux()
61
62 // Main pages
63 mux.HandleFunc("GET /{$}", s.HandleHome)
64
65 // Auth endpoints
66 mux.HandleFunc("POST /auth/login", s.HandleLogin)
67 mux.HandleFunc("POST /auth/apppassword", s.HandleAppPasswordLogin)
68 mux.HandleFunc("GET /auth/callback", s.HandleCallback)
69 mux.HandleFunc("POST /auth/logout", s.HandleLogout)
70
71 // Client metadata for OAuth
72 mux.HandleFunc("GET /oauth-client-metadata.json", s.HandleClientMetadata)
73
74 // Posting
75 mux.HandleFunc("POST /post", s.HandlePost)
76
77 // Static files
78 mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir(s.StaticDir))))
79
80 slog.Info("starting server", "addr", addr, "baseURL", s.BaseURL)
81 return http.ListenAndServe(addr, mux)
82}
83
84type homePageData struct {
85 LoggedIn bool
86 Handle string
87 DID string
88 Posts []dbgen.Post
89 CharLimit int
90 Error string
91 Success string
92}
93
94func (s *Server) HandleHome(w http.ResponseWriter, r *http.Request) {
95 data := homePageData{
96 CharLimit: BskyCharLimit,
97 }
98
99 // Check for session cookie
100 cookie, err := r.Cookie(SessionCookie)
101 if err == nil && cookie.Value != "" {
102 q := dbgen.New(s.DB)
103 session, err := q.GetOAuthSession(r.Context(), cookie.Value)
104 if err == nil {
105 // Check if session is still valid (token not expired)
106 if time.Now().Before(session.ExpiresAt) {
107 data.LoggedIn = true
108 data.Handle = session.Handle
109 data.DID = session.Did
110
111 // Get recent posts from this session
112 posts, err := q.GetPostsBySession(r.Context(), dbgen.GetPostsBySessionParams{
113 SessionID: session.ID,
114 Limit: 50,
115 })
116 if err == nil {
117 data.Posts = posts
118 }
119 }
120 }
121 }
122
123 // Check for flash messages in query params
124 data.Error = r.URL.Query().Get("error")
125 data.Success = r.URL.Query().Get("success")
126
127 w.Header().Set("Content-Type", "text/html; charset=utf-8")
128 if err := s.renderTemplate(w, "home.html", data); err != nil {
129 slog.Error("render template", "error", err)
130 http.Error(w, "Internal server error", http.StatusInternalServerError)
131 }
132}
133
134func (s *Server) renderTemplate(w http.ResponseWriter, name string, data any) error {
135 path := filepath.Join(s.TemplatesDir, name)
136 tmpl, err := template.ParseFiles(path)
137 if err != nil {
138 return fmt.Errorf("parse template %q: %w", name, err)
139 }
140 if err := tmpl.Execute(w, data); err != nil {
141 return fmt.Errorf("execute template %q: %w", name, err)
142 }
143 return nil
144}