about summary refs log tree commit diff
path: root/bskyweb/cmd
diff options
context:
space:
mode:
authorbnewbold <bnewbold@robocracy.org>2023-03-14 13:00:44 -0700
committerGitHub <noreply@github.com>2023-03-14 15:00:44 -0500
commit8629e167cd668cd1d41bf6a37acf9d94502e5c2b (patch)
treed42bb4210cc779fc69b9127fd70edcdc5c88c09a /bskyweb/cmd
parent528e14fe90af1614af025cb101acfbaa0ddb5a15 (diff)
downloadvoidsky-8629e167cd668cd1d41bf6a37acf9d94502e5c2b.tar.zst
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>
Diffstat (limited to 'bskyweb/cmd')
-rw-r--r--bskyweb/cmd/bskyweb/main.go68
-rw-r--r--bskyweb/cmd/bskyweb/server.go190
2 files changed, 258 insertions, 0 deletions
diff --git a/bskyweb/cmd/bskyweb/main.go b/bskyweb/cmd/bskyweb/main.go
new file mode 100644
index 000000000..4b691b686
--- /dev/null
+++ b/bskyweb/cmd/bskyweb/main.go
@@ -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()
+}
diff --git a/bskyweb/cmd/bskyweb/server.go b/bskyweb/cmd/bskyweb/server.go
new file mode 100644
index 000000000..6a8a88884
--- /dev/null
+++ b/bskyweb/cmd/bskyweb/server.go
@@ -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)
+}