[very crude, wip] post to bsky without the distraction of feeds
at main 144 lines 3.6 kB view raw
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}