bskyweb additions (#296)
Add some minor bskyweb improvements, Mailmodo endpoint, Dockerfile for bskyweb, container image push
This commit is contained in:
parent
d8f4475696
commit
67e4882bb3
15 changed files with 458 additions and 84 deletions
68
bskyweb/cmd/bskyweb/mailmodo.go
Normal file
68
bskyweb/cmd/bskyweb/mailmodo.go
Normal file
|
@ -0,0 +1,68 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Mailmodo struct {
|
||||
httpClient *http.Client
|
||||
APIKey string
|
||||
BaseURL string
|
||||
}
|
||||
|
||||
func NewMailmodo(apiKey string) *Mailmodo {
|
||||
return &Mailmodo{
|
||||
APIKey: apiKey,
|
||||
BaseURL: "https://api.mailmodo.com/api/v1",
|
||||
httpClient: &http.Client{},
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Mailmodo) request(ctx context.Context, httpMethod string, apiMethod string, data any) error {
|
||||
endpoint := fmt.Sprintf("%s/%s", m.BaseURL, apiMethod)
|
||||
js, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Mailmodo JSON encoding failed: %w", err)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, httpMethod, endpoint, bytes.NewBuffer(js))
|
||||
if err != nil {
|
||||
return fmt.Errorf("Mailmodo HTTP creating request %s %s failed: %w", httpMethod, apiMethod, err)
|
||||
}
|
||||
req.Header.Set("mmApiKey", m.APIKey)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
res, err := m.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Mailmodo HTTP making request %s %s failed: %w", httpMethod, apiMethod, err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
status := struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
}{}
|
||||
if err := json.NewDecoder(res.Body).Decode(&status); err != nil {
|
||||
return fmt.Errorf("Mailmodo HTTP parsing response %s %s failed: %w", httpMethod, apiMethod, err)
|
||||
}
|
||||
if !status.Success {
|
||||
return fmt.Errorf("Mailmodo API response %s %s failed: %s", httpMethod, apiMethod, status.Message)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Mailmodo) AddToList(ctx context.Context, listName, email string) error {
|
||||
return m.request(ctx, "POST", "addToList", map[string]any{
|
||||
"listName": listName,
|
||||
"email": email,
|
||||
"data": map[string]any{
|
||||
"email_hashed": fmt.Sprintf("%x", sha256.Sum256([]byte(email))),
|
||||
},
|
||||
"created_at": time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
}
|
|
@ -35,33 +35,57 @@ func run(args []string) {
|
|||
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,
|
||||
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"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "mailmodo-api-key",
|
||||
Usage: "Mailmodo API key",
|
||||
Required: false,
|
||||
EnvVars: []string{"MAILMODO_API_KEY"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "mailmodo-list-name",
|
||||
Usage: "Mailmodo contact list to add email addresses to",
|
||||
Required: false,
|
||||
EnvVars: []string{"MAILMODO_LIST_NAME"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "http-address",
|
||||
Usage: "Specify the local IP/port to bind to",
|
||||
Required: false,
|
||||
Value: ":8100",
|
||||
EnvVars: []string{"HTTP_ADDRESS"},
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "debug",
|
||||
Usage: "Enable debug mode",
|
||||
Value: false,
|
||||
Required: false,
|
||||
EnvVars: []string{"DEBUG"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
app.RunAndExitOnError()
|
||||
|
|
82
bskyweb/cmd/bskyweb/renderer.go
Normal file
82
bskyweb/cmd/bskyweb/renderer.go
Normal file
|
@ -0,0 +1,82 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/flosch/pongo2/v6"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
type RendererLoader struct {
|
||||
prefix string
|
||||
fs *embed.FS
|
||||
}
|
||||
|
||||
func NewRendererLoader(prefix string, fs *embed.FS) pongo2.TemplateLoader {
|
||||
return &RendererLoader{
|
||||
prefix: prefix,
|
||||
fs: fs,
|
||||
}
|
||||
}
|
||||
func (l *RendererLoader) Abs(_, name string) string {
|
||||
// TODO: remove this workaround
|
||||
// Figure out why this method is being called
|
||||
// twice on template names resulting in a failure to resolve
|
||||
// the template name.
|
||||
if filepath.HasPrefix(name, l.prefix) {
|
||||
return name
|
||||
}
|
||||
return filepath.Join(l.prefix, name)
|
||||
}
|
||||
|
||||
func (l *RendererLoader) Get(path string) (io.Reader, error) {
|
||||
b, err := l.fs.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading template %q failed: %w", path, err)
|
||||
}
|
||||
return bytes.NewReader(b), nil
|
||||
}
|
||||
|
||||
type Renderer struct {
|
||||
TemplateSet *pongo2.TemplateSet
|
||||
Debug bool
|
||||
}
|
||||
|
||||
func NewRenderer(prefix string, fs *embed.FS, debug bool) *Renderer {
|
||||
return &Renderer{
|
||||
TemplateSet: pongo2.NewSet(prefix, NewRendererLoader(prefix, fs)),
|
||||
Debug: debug,
|
||||
}
|
||||
}
|
||||
|
||||
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 = r.TemplateSet.FromFile(name)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return t.ExecuteWriter(ctx, w)
|
||||
}
|
|
@ -2,15 +2,17 @@ package main
|
|||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
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/bluesky-social/social-app/bskyweb"
|
||||
|
||||
"github.com/flosch/pongo2/v6"
|
||||
"github.com/labstack/echo/v4"
|
||||
|
@ -18,60 +20,35 @@ import (
|
|||
"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 {
|
||||
debug := cctx.Bool("debug")
|
||||
httpAddress := cctx.String("http-address")
|
||||
pdsHost := cctx.String("pds-host")
|
||||
atpHandle := cctx.String("handle")
|
||||
atpPassword := cctx.String("password")
|
||||
mailmodoAPIKey := cctx.String("mailmodo-api-key")
|
||||
mailmodoListName := cctx.String("mailmodo-list-name")
|
||||
|
||||
// Mailmodo client.
|
||||
mailmodo := NewMailmodo(mailmodoAPIKey)
|
||||
|
||||
// create a new session
|
||||
// TODO: does this work with no auth at all?
|
||||
xrpcc := &xrpc.Client{
|
||||
Client: cliutil.NewHttpClient(),
|
||||
Host: cctx.String("pds-host"),
|
||||
Host: pdsHost,
|
||||
Auth: &xrpc.AuthInfo{
|
||||
Handle: cctx.String("handle"),
|
||||
Handle: atpHandle,
|
||||
},
|
||||
}
|
||||
|
||||
auth, err := comatproto.SessionCreate(context.TODO(), xrpcc, &comatproto.SessionCreate_Input{
|
||||
Identifier: &xrpcc.Auth.Handle,
|
||||
Password: cctx.String("password"),
|
||||
Password: atpPassword,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
|
@ -83,19 +60,32 @@ func serve(cctx *cli.Context) error {
|
|||
|
||||
server := Server{xrpcc}
|
||||
|
||||
staticHandler := http.FileServer(func() http.FileSystem {
|
||||
if debug {
|
||||
return http.FS(os.DirFS("static"))
|
||||
}
|
||||
fsys, err := fs.Sub(bskyweb.StaticFS, "static")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
return http.FS(fsys)
|
||||
}())
|
||||
|
||||
e := echo.New()
|
||||
e.HideBanner = true
|
||||
e.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{
|
||||
// Don't log requests for static content.
|
||||
Skipper: func(c echo.Context) bool {
|
||||
return strings.HasPrefix(c.Request().URL.Path, "/static")
|
||||
},
|
||||
Format: "method=${method} path=${uri} status=${status} latency=${latency_human}\n",
|
||||
}))
|
||||
e.Renderer = Renderer{Debug: true}
|
||||
e.Renderer = NewRenderer("templates/", &bskyweb.TemplateFS, debug)
|
||||
e.HTTPErrorHandler = customHTTPErrorHandler
|
||||
|
||||
// configure routes
|
||||
e.File("/robots.txt", "static/robots.txt")
|
||||
e.Static("/static", "static")
|
||||
e.Static("/static/js", "../web-build/static/js")
|
||||
|
||||
e.GET("/robots.txt", echo.WrapHandler(staticHandler))
|
||||
e.GET("/static/*", echo.WrapHandler(http.StripPrefix("/static/", staticHandler)))
|
||||
e.GET("/", server.WebHome)
|
||||
|
||||
// generic routes
|
||||
|
@ -118,9 +108,17 @@ func serve(cctx *cli.Context) error {
|
|||
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)
|
||||
// Mailmodo
|
||||
e.POST("/waitlist", func(c echo.Context) error {
|
||||
email := strings.TrimSpace(c.FormValue("email"))
|
||||
if err := mailmodo.AddToList(c.Request().Context(), mailmodoListName, email); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.JSON(http.StatusOK, map[string]bool{"success": true})
|
||||
})
|
||||
|
||||
log.Infof("starting server address=%s", httpAddress)
|
||||
return e.Start(httpAddress)
|
||||
}
|
||||
|
||||
func customHTTPErrorHandler(err error, c echo.Context) {
|
||||
|
@ -132,18 +130,18 @@ func customHTTPErrorHandler(err error, c echo.Context) {
|
|||
data := pongo2.Context{
|
||||
"statusCode": code,
|
||||
}
|
||||
c.Render(code, "templates/error.html", data)
|
||||
c.Render(code, "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)
|
||||
return c.Render(http.StatusOK, "base.html", data)
|
||||
}
|
||||
|
||||
func (srv *Server) WebHome(c echo.Context) error {
|
||||
data := pongo2.Context{}
|
||||
return c.Render(http.StatusOK, "templates/home.html", data)
|
||||
return c.Render(http.StatusOK, "home.html", data)
|
||||
}
|
||||
|
||||
func (srv *Server) WebPost(c echo.Context) error {
|
||||
|
@ -152,7 +150,7 @@ func (srv *Server) WebPost(c echo.Context) error {
|
|||
rkey := c.Param("rkey")
|
||||
// sanity check argument
|
||||
if len(handle) > 4 && len(handle) < 128 && len(rkey) > 0 {
|
||||
ctx := context.TODO()
|
||||
ctx := c.Request().Context()
|
||||
// requires two fetches: first fetch profile (!)
|
||||
pv, err := appbsky.ActorGetProfile(ctx, srv.xrpcc, handle)
|
||||
if err != nil {
|
||||
|
@ -172,7 +170,7 @@ func (srv *Server) WebPost(c echo.Context) error {
|
|||
}
|
||||
|
||||
}
|
||||
return c.Render(http.StatusOK, "templates/post.html", data)
|
||||
return c.Render(http.StatusOK, "post.html", data)
|
||||
}
|
||||
|
||||
func (srv *Server) WebProfile(c echo.Context) error {
|
||||
|
@ -180,7 +178,7 @@ func (srv *Server) WebProfile(c echo.Context) error {
|
|||
handle := c.Param("handle")
|
||||
// sanity check argument
|
||||
if len(handle) > 4 && len(handle) < 128 {
|
||||
ctx := context.TODO()
|
||||
ctx := c.Request().Context()
|
||||
pv, err := appbsky.ActorGetProfile(ctx, srv.xrpcc, handle)
|
||||
if err != nil {
|
||||
log.Warnf("failed to fetch handle: %s\t%v", handle, err)
|
||||
|
@ -189,5 +187,5 @@ func (srv *Server) WebProfile(c echo.Context) error {
|
|||
}
|
||||
}
|
||||
|
||||
return c.Render(http.StatusOK, "templates/profile.html", data)
|
||||
return c.Render(http.StatusOK, "profile.html", data)
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue