bskyweb: proof-of-concept golang daemon to serve SPA (#275)
* gitignore: /dist/ * bskyweb: initial work-in-progress * bskyweb: import icons from bluesky-website * bskyweb: switch to pongo2 templates; iterate on views * bskyweb: example.env (and docs) * bskyweb: go fmt * bskyweb: remove plan file * bskyweb: README: tweak formatting * prettier: ignore /dist/, bskyweb templates --------- Co-authored-by: Paul Frazee <pfrazee@gmail.com>
This commit is contained in:
parent
528e14fe90
commit
8629e167cd
21 changed files with 796 additions and 2 deletions
68
bskyweb/cmd/bskyweb/main.go
Normal file
68
bskyweb/cmd/bskyweb/main.go
Normal file
|
@ -0,0 +1,68 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
logging "github.com/ipfs/go-log"
|
||||
"github.com/joho/godotenv"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
var log = logging.Logger("bskyweb")
|
||||
|
||||
func init() {
|
||||
logging.SetAllLoggers(logging.LevelDebug)
|
||||
//logging.SetAllLoggers(logging.LevelWarn)
|
||||
}
|
||||
|
||||
func main() {
|
||||
|
||||
// only try dotenv if it exists
|
||||
if _, err := os.Stat(".env"); err == nil {
|
||||
err := godotenv.Load()
|
||||
if err != nil {
|
||||
log.Fatal("Error loading .env file")
|
||||
}
|
||||
}
|
||||
|
||||
run(os.Args)
|
||||
}
|
||||
|
||||
func run(args []string) {
|
||||
|
||||
app := cli.App{
|
||||
Name: "bskyweb",
|
||||
Usage: "web server for bsky.app web app (SPA)",
|
||||
}
|
||||
|
||||
app.Flags = []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "pds-host",
|
||||
Usage: "method, hostname, and port of PDS instance",
|
||||
Value: "http://localhost:4849",
|
||||
EnvVars: []string{"ATP_PDS_HOST"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "handle",
|
||||
Usage: "for PDS login",
|
||||
Required: true,
|
||||
EnvVars: []string{"ATP_AUTH_HANDLE"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "password",
|
||||
Usage: "for PDS login",
|
||||
Required: true,
|
||||
EnvVars: []string{"ATP_AUTH_PASSWORD"},
|
||||
},
|
||||
// TODO: local IP/port to bind on
|
||||
}
|
||||
|
||||
app.Commands = []*cli.Command{
|
||||
&cli.Command{
|
||||
Name: "serve",
|
||||
Usage: "run the server",
|
||||
Action: serve,
|
||||
},
|
||||
}
|
||||
app.RunAndExitOnError()
|
||||
}
|
190
bskyweb/cmd/bskyweb/server.go
Normal file
190
bskyweb/cmd/bskyweb/server.go
Normal file
|
@ -0,0 +1,190 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
comatproto "github.com/bluesky-social/indigo/api/atproto"
|
||||
appbsky "github.com/bluesky-social/indigo/api/bsky"
|
||||
cliutil "github.com/bluesky-social/indigo/cmd/gosky/util"
|
||||
"github.com/bluesky-social/indigo/xrpc"
|
||||
|
||||
"github.com/flosch/pongo2/v6"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/labstack/echo/v4/middleware"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
// TODO: embed templates in executable
|
||||
|
||||
type Renderer struct {
|
||||
Debug bool
|
||||
}
|
||||
|
||||
func (r Renderer) Render(w io.Writer, name string, data interface{}, c echo.Context) error {
|
||||
|
||||
var ctx pongo2.Context
|
||||
|
||||
if data != nil {
|
||||
var ok bool
|
||||
ctx, ok = data.(pongo2.Context)
|
||||
|
||||
if !ok {
|
||||
return errors.New("no pongo2.Context data was passed...")
|
||||
}
|
||||
}
|
||||
|
||||
var t *pongo2.Template
|
||||
var err error
|
||||
|
||||
if r.Debug {
|
||||
t, err = pongo2.FromFile(name)
|
||||
} else {
|
||||
t, err = pongo2.FromCache(name)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return t.ExecuteWriter(ctx, w)
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
xrpcc *xrpc.Client
|
||||
}
|
||||
|
||||
func serve(cctx *cli.Context) error {
|
||||
|
||||
// create a new session
|
||||
// TODO: does this work with no auth at all?
|
||||
xrpcc := &xrpc.Client{
|
||||
Client: cliutil.NewHttpClient(),
|
||||
Host: cctx.String("pds-host"),
|
||||
Auth: &xrpc.AuthInfo{
|
||||
Handle: cctx.String("handle"),
|
||||
},
|
||||
}
|
||||
|
||||
auth, err := comatproto.SessionCreate(context.TODO(), xrpcc, &comatproto.SessionCreate_Input{
|
||||
Identifier: &xrpcc.Auth.Handle,
|
||||
Password: cctx.String("password"),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
xrpcc.Auth.AccessJwt = auth.AccessJwt
|
||||
xrpcc.Auth.RefreshJwt = auth.RefreshJwt
|
||||
xrpcc.Auth.Did = auth.Did
|
||||
xrpcc.Auth.Handle = auth.Handle
|
||||
|
||||
server := Server{xrpcc}
|
||||
|
||||
e := echo.New()
|
||||
e.HideBanner = true
|
||||
e.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{
|
||||
Format: "method=${method} path=${uri} status=${status} latency=${latency_human}\n",
|
||||
}))
|
||||
e.Renderer = Renderer{Debug: true}
|
||||
e.HTTPErrorHandler = customHTTPErrorHandler
|
||||
|
||||
// configure routes
|
||||
e.File("/robots.txt", "static/robots.txt")
|
||||
e.Static("/static", "static")
|
||||
|
||||
e.GET("/", server.WebHome)
|
||||
|
||||
// generic routes
|
||||
e.GET("/contacts", server.WebGeneric)
|
||||
e.GET("/search", server.WebGeneric)
|
||||
e.GET("/notifications", server.WebGeneric)
|
||||
e.GET("/settings", server.WebGeneric)
|
||||
e.GET("/settings", server.WebGeneric)
|
||||
|
||||
// profile endpoints; only first populates info
|
||||
e.GET("/profile/:handle", server.WebProfile)
|
||||
e.GET("/profile/:handle/follows", server.WebGeneric)
|
||||
e.GET("/profile/:handle/following", server.WebGeneric)
|
||||
|
||||
// post endpoints; only first populates info
|
||||
e.GET("/profile/:handle/post/:rkey", server.WebPost)
|
||||
e.GET("/profile/:handle/post/:rkey/upvoted-by", server.WebGeneric)
|
||||
e.GET("/profile/:handle/post/:rkey/downvoted-by", server.WebGeneric)
|
||||
e.GET("/profile/:handle/post/:rkey/reposted-by", server.WebGeneric)
|
||||
|
||||
bind := "localhost:8100"
|
||||
log.Infof("starting server bind=%s", bind)
|
||||
return e.Start(bind)
|
||||
}
|
||||
|
||||
func customHTTPErrorHandler(err error, c echo.Context) {
|
||||
code := http.StatusInternalServerError
|
||||
if he, ok := err.(*echo.HTTPError); ok {
|
||||
code = he.Code
|
||||
}
|
||||
c.Logger().Error(err)
|
||||
data := pongo2.Context{
|
||||
"statusCode": code,
|
||||
}
|
||||
c.Render(code, "templates/error.html", data)
|
||||
}
|
||||
|
||||
// handler for endpoint that have no specific server-side handling
|
||||
func (srv *Server) WebGeneric(c echo.Context) error {
|
||||
data := pongo2.Context{}
|
||||
return c.Render(http.StatusOK, "templates/base.html", data)
|
||||
}
|
||||
|
||||
func (srv *Server) WebHome(c echo.Context) error {
|
||||
data := pongo2.Context{}
|
||||
return c.Render(http.StatusOK, "templates/home.html", data)
|
||||
}
|
||||
|
||||
func (srv *Server) WebPost(c echo.Context) error {
|
||||
data := pongo2.Context{}
|
||||
handle := c.Param("handle")
|
||||
rkey := c.Param("rkey")
|
||||
// sanity check argument
|
||||
if len(handle) > 4 && len(handle) < 128 && len(rkey) > 0 {
|
||||
ctx := context.TODO()
|
||||
// requires two fetches: first fetch profile (!)
|
||||
pv, err := appbsky.ActorGetProfile(ctx, srv.xrpcc, handle)
|
||||
if err != nil {
|
||||
log.Warnf("failed to fetch handle: %s\t%v", handle, err)
|
||||
} else {
|
||||
did := pv.Did
|
||||
data["did"] = did
|
||||
|
||||
// then fetch the post thread (with extra context)
|
||||
uri := fmt.Sprintf("at://%s/app.bsky.feed.post/%s", did, rkey)
|
||||
tpv, err := appbsky.FeedGetPostThread(ctx, srv.xrpcc, 1, uri)
|
||||
if err != nil {
|
||||
log.Warnf("failed to fetch post: %s\t%v", uri, err)
|
||||
} else {
|
||||
data["postView"] = tpv.Thread.FeedGetPostThread_ThreadViewPost.Post
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
return c.Render(http.StatusOK, "templates/post.html", data)
|
||||
}
|
||||
|
||||
func (srv *Server) WebProfile(c echo.Context) error {
|
||||
data := pongo2.Context{}
|
||||
handle := c.Param("handle")
|
||||
// sanity check argument
|
||||
if len(handle) > 4 && len(handle) < 128 {
|
||||
ctx := context.TODO()
|
||||
pv, err := appbsky.ActorGetProfile(ctx, srv.xrpcc, handle)
|
||||
if err != nil {
|
||||
log.Warnf("failed to fetch handle: %s\t%v", handle, err)
|
||||
} else {
|
||||
data["profileView"] = pv
|
||||
}
|
||||
}
|
||||
|
||||
return c.Render(http.StatusOK, "templates/profile.html", data)
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue