package srv import ( "database/sql" "fmt" "html/template" "log/slog" "net/http" "path/filepath" "runtime" "time" "srv.exe.dev/db" "srv.exe.dev/db/dbgen" ) const ( BskyCharLimit = 300 // Bluesky post character limit (graphemes) SessionCookie = "bsky_session" ) type Server struct { DB *sql.DB Hostname string BaseURL string TemplatesDir string StaticDir string oauth *OAuthClient } func New(dbPath, hostname, baseURL string) (*Server, error) { _, thisFile, _, _ := runtime.Caller(0) baseDir := filepath.Dir(thisFile) srv := &Server{ Hostname: hostname, BaseURL: baseURL, TemplatesDir: filepath.Join(baseDir, "templates"), StaticDir: filepath.Join(baseDir, "static"), } if err := srv.setUpDatabase(dbPath); err != nil { return nil, err } srv.oauth = NewOAuthClient(baseURL, srv.DB) return srv, nil } func (s *Server) setUpDatabase(dbPath string) error { wdb, err := db.Open(dbPath) if err != nil { return fmt.Errorf("failed to open db: %w", err) } s.DB = wdb if err := db.RunMigrations(wdb); err != nil { return fmt.Errorf("failed to run migrations: %w", err) } return nil } func (s *Server) Serve(addr string) error { mux := http.NewServeMux() // Main pages mux.HandleFunc("GET /{$}", s.HandleHome) // Auth endpoints mux.HandleFunc("POST /auth/login", s.HandleLogin) mux.HandleFunc("POST /auth/apppassword", s.HandleAppPasswordLogin) mux.HandleFunc("GET /auth/callback", s.HandleCallback) mux.HandleFunc("POST /auth/logout", s.HandleLogout) // Client metadata for OAuth mux.HandleFunc("GET /oauth-client-metadata.json", s.HandleClientMetadata) // Posting mux.HandleFunc("POST /post", s.HandlePost) // Static files mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir(s.StaticDir)))) slog.Info("starting server", "addr", addr, "baseURL", s.BaseURL) return http.ListenAndServe(addr, mux) } type homePageData struct { LoggedIn bool Handle string DID string Posts []dbgen.Post CharLimit int Error string Success string } func (s *Server) HandleHome(w http.ResponseWriter, r *http.Request) { data := homePageData{ CharLimit: BskyCharLimit, } // Check for session cookie cookie, err := r.Cookie(SessionCookie) if err == nil && cookie.Value != "" { q := dbgen.New(s.DB) session, err := q.GetOAuthSession(r.Context(), cookie.Value) if err == nil { // Check if session is still valid (token not expired) if time.Now().Before(session.ExpiresAt) { data.LoggedIn = true data.Handle = session.Handle data.DID = session.Did // Get recent posts from this session posts, err := q.GetPostsBySession(r.Context(), dbgen.GetPostsBySessionParams{ SessionID: session.ID, Limit: 50, }) if err == nil { data.Posts = posts } } } } // Check for flash messages in query params data.Error = r.URL.Query().Get("error") data.Success = r.URL.Query().Get("success") w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := s.renderTemplate(w, "home.html", data); err != nil { slog.Error("render template", "error", err) http.Error(w, "Internal server error", http.StatusInternalServerError) } } func (s *Server) renderTemplate(w http.ResponseWriter, name string, data any) error { path := filepath.Join(s.TemplatesDir, name) tmpl, err := template.ParseFiles(path) if err != nil { return fmt.Errorf("parse template %q: %w", name, err) } if err := tmpl.Execute(w, data); err != nil { return fmt.Errorf("execute template %q: %w", name, err) } return nil }