From 27d9efd60cb000da6d184b7d211c170c563d701b Mon Sep 17 00:00:00 2001 From: bryan newbold Date: Tue, 20 Aug 2024 23:30:25 -0700 Subject: [PATCH 1/6] initial version of astrolabe --- cmd/astrolabe/README.md | 39 +++ cmd/astrolabe/handlers.go | 243 +++++++++++++++++++ cmd/astrolabe/main.go | 66 +++++ cmd/astrolabe/renderer.go | 85 +++++++ cmd/astrolabe/service.go | 172 +++++++++++++ cmd/astrolabe/static/apple-touch-icon.png | Bin 0 -> 14742 bytes cmd/astrolabe/static/default-avatar.png | Bin 0 -> 1624 bytes cmd/astrolabe/static/favicon-16x16.png | Bin 0 -> 316 bytes cmd/astrolabe/static/favicon-32x32.png | Bin 0 -> 769 bytes cmd/astrolabe/static/favicon.ico | Bin 0 -> 15406 bytes cmd/astrolabe/static/favicon.png | Bin 0 -> 6405 bytes cmd/astrolabe/static/robots.txt | 9 + cmd/astrolabe/templates/account.html | 24 ++ cmd/astrolabe/templates/base.html | 46 ++++ cmd/astrolabe/templates/error.html | 11 + cmd/astrolabe/templates/home.html | 5 + cmd/astrolabe/templates/repo.html | 16 ++ cmd/astrolabe/templates/repo_collection.html | 19 ++ cmd/astrolabe/templates/repo_record.html | 12 + 19 files changed, 747 insertions(+) create mode 100644 cmd/astrolabe/README.md create mode 100644 cmd/astrolabe/handlers.go create mode 100644 cmd/astrolabe/main.go create mode 100644 cmd/astrolabe/renderer.go create mode 100644 cmd/astrolabe/service.go create mode 100644 cmd/astrolabe/static/apple-touch-icon.png create mode 100644 cmd/astrolabe/static/default-avatar.png create mode 100644 cmd/astrolabe/static/favicon-16x16.png create mode 100644 cmd/astrolabe/static/favicon-32x32.png create mode 100644 cmd/astrolabe/static/favicon.ico create mode 100644 cmd/astrolabe/static/favicon.png create mode 100644 cmd/astrolabe/static/robots.txt create mode 100644 cmd/astrolabe/templates/account.html create mode 100644 cmd/astrolabe/templates/base.html create mode 100644 cmd/astrolabe/templates/error.html create mode 100644 cmd/astrolabe/templates/home.html create mode 100644 cmd/astrolabe/templates/repo.html create mode 100644 cmd/astrolabe/templates/repo_collection.html create mode 100644 cmd/astrolabe/templates/repo_record.html diff --git a/cmd/astrolabe/README.md b/cmd/astrolabe/README.md new file mode 100644 index 000000000..9d04a0323 --- /dev/null +++ b/cmd/astrolabe/README.md @@ -0,0 +1,39 @@ + +astrolabe: basic atproto network data explorer +============================================== + +⚠️ This is a fun little proof-of-concept ⚠️ + + +## Run It + +The recommended way to run `astrolabe` is behind a `caddy` HTTPS server which does automatic on-demand SSL certificate registration (using Let's Encrypt). + +Build and run `astrolabe`: + + go build ./cmd/astrolabe + + # will listen on :8400 by default + ./astrolabe serve + +Create a `Caddyfile`: + +``` +{ + on_demand_tls { + interval 1h + burst 8 + } +} + +:443 { + reverse_proxy localhost:8400 + tls YOUREMAIL@example.com { + on_demand + } +} +``` + +Run `caddy`: + + caddy run diff --git a/cmd/astrolabe/handlers.go b/cmd/astrolabe/handlers.go new file mode 100644 index 000000000..091add3f2 --- /dev/null +++ b/cmd/astrolabe/handlers.go @@ -0,0 +1,243 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + comatproto "github.com/bluesky-social/indigo/api/atproto" + _ "github.com/bluesky-social/indigo/api/bsky" + "github.com/bluesky-social/indigo/atproto/data" + "github.com/bluesky-social/indigo/atproto/identity" + "github.com/bluesky-social/indigo/atproto/syntax" + "github.com/bluesky-social/indigo/xrpc" + + "github.com/flosch/pongo2/v6" + "github.com/labstack/echo/v4" +) + +func (srv *Server) WebHome(c echo.Context) error { + info := pongo2.Context{} + return c.Render(http.StatusOK, "home.html", info) +} + +func (srv *Server) WebQuery(c echo.Context) error { + + // parse the q query param, redirect based on that + q := c.QueryParam("q") + if q == "" { + return c.Redirect(http.StatusFound, "/") + } + if strings.HasPrefix(q, "at://") { + aturi, err := syntax.ParseATURI(q) + if err != nil { + return err + } + if aturi.RecordKey() != "" { + return c.Redirect(http.StatusFound, fmt.Sprintf("/at/%s/%s/%s", aturi.Authority(), aturi.Collection(), aturi.RecordKey())) + } + if aturi.Collection() != "" { + return c.Redirect(http.StatusFound, fmt.Sprintf("/at/%s/%s", aturi.Authority(), aturi.Collection())) + } + return c.Redirect(http.StatusFound, fmt.Sprintf("/at/%s", aturi.Authority())) + } + if strings.HasPrefix(q, "did:") { + return c.Redirect(http.StatusFound, fmt.Sprintf("/account/%s", q)) + } + _, err := syntax.ParseHandle(q) + if nil == err { + return c.Redirect(http.StatusFound, fmt.Sprintf("/account/%s", q)) + } + return echo.NewHTTPError(400, "failed to parse query") +} + +// e.GET("/account/:atid", srv.WebAccount) +func (srv *Server) WebAccount(c echo.Context) error { + ctx := c.Request().Context() + //req := c.Request() + info := pongo2.Context{} + + atid, err := syntax.ParseAtIdentifier(c.Param("atid")) + if err != nil { + return echo.NewHTTPError(404, fmt.Sprintf("failed to parse handle or DID")) + } + + ident, err := srv.dir.Lookup(ctx, *atid) + if err != nil { + // TODO: proper error page? + return err + } + + bdir := identity.BaseDirectory{} + doc, err := bdir.ResolveDID(ctx, ident.DID) + if nil == err { + b, err := json.MarshalIndent(doc, "", " ") + if err != nil { + return err + } + info["didDocJSON"] = string(b) + } + info["atid"] = atid + info["ident"] = ident + return c.Render(http.StatusOK, "account.html", info) +} + +// e.GET("/at/:atid", srv.WebRepo) +func (srv *Server) WebRepo(c echo.Context) error { + ctx := c.Request().Context() + //req := c.Request() + info := pongo2.Context{} + + atid, err := syntax.ParseAtIdentifier(c.Param("atid")) + if err != nil { + return echo.NewHTTPError(400, fmt.Sprintf("failed to parse handle or DID")) + } + + ident, err := srv.dir.Lookup(ctx, *atid) + if err != nil { + // TODO: proper error page? + return err + } + info["atid"] = atid + info["ident"] = ident + + // create a new API client to connect to the account's PDS + xrpcc := xrpc.Client{ + Host: ident.PDSEndpoint(), + } + if xrpcc.Host == "" { + return fmt.Errorf("no PDS endpoint for identity") + } + + desc, err := comatproto.RepoDescribeRepo(ctx, &xrpcc, ident.DID.String()) + if err != nil { + return err + } + info["collections"] = desc.Collections + + return c.Render(http.StatusOK, "repo.html", info) +} + +// e.GET("/at/:atid/:collection", srv.WebCollection) +func (srv *Server) WebRepoCollection(c echo.Context) error { + ctx := c.Request().Context() + //req := c.Request() + info := pongo2.Context{} + + atid, err := syntax.ParseAtIdentifier(c.Param("atid")) + if err != nil { + return echo.NewHTTPError(400, fmt.Sprintf("failed to parse handle or DID")) + } + + collection, err := syntax.ParseNSID(c.Param("collection")) + if err != nil { + return echo.NewHTTPError(400, fmt.Sprintf("failed to parse collection NSID")) + } + + ident, err := srv.dir.Lookup(ctx, *atid) + if err != nil { + // TODO: proper error page? + return err + } + info["atid"] = atid + info["ident"] = ident + info["collection"] = collection + + // create a new API client to connect to the account's PDS + xrpcc := xrpc.Client{ + Host: ident.PDSEndpoint(), + } + if xrpcc.Host == "" { + return fmt.Errorf("no PDS endpoint for identity") + } + + cursor := c.QueryParam("cursor") + // collection string, cursor string, limit int64, repo string, reverse bool, rkeyEnd string, rkeyStart string + resp, err := comatproto.RepoListRecords(ctx, &xrpcc, collection.String(), cursor, 100, ident.DID.String(), false, "", "") + if err != nil { + return err + } + recordURIs := make([]syntax.ATURI, len(resp.Records)) + for i, rec := range resp.Records { + aturi, err := syntax.ParseATURI(rec.Uri) + if err != nil { + return err + } + recordURIs[i] = aturi + } + if resp.Cursor != nil && *resp.Cursor != "" { + cursor = *resp.Cursor + } + + info["records"] = resp.Records + info["recordURIs"] = recordURIs + info["cursor"] = cursor + return c.Render(http.StatusOK, "repo_collection.html", info) +} + +// e.GET("/at/:atid/:collection/:rkey", srv.WebRecord) +func (srv *Server) WebRepoRecord(c echo.Context) error { + ctx := c.Request().Context() + //req := c.Request() + info := pongo2.Context{} + + atid, err := syntax.ParseAtIdentifier(c.Param("atid")) + if err != nil { + return echo.NewHTTPError(400, fmt.Sprintf("failed to parse handle or DID")) + } + + collection, err := syntax.ParseNSID(c.Param("collection")) + if err != nil { + return echo.NewHTTPError(400, fmt.Sprintf("failed to parse collection NSID")) + } + + rkey, err := syntax.ParseRecordKey(c.Param("rkey")) + if err != nil { + return echo.NewHTTPError(400, fmt.Sprintf("failed to parse record key")) + } + + ident, err := srv.dir.Lookup(ctx, *atid) + if err != nil { + // TODO: proper error page? + return err + } + info["atid"] = atid + info["ident"] = ident + info["collection"] = collection + info["rkey"] = rkey + + pdsURL := ident.PDSEndpoint() + + //slog.Debug("fetching record", "did", ident.DID.String(), "collection", aturi.Collection().String(), "rkey", aturi.RecordKey().String()) + url := fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=%s&rkey=%s", + pdsURL, ident.DID, collection, rkey) + resp, err := http.Get(url) + if err != nil { + return err + } + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("fetch failed") + } + respBytes, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + body, err := data.UnmarshalJSON(respBytes) + if err != nil { + return err + } + record, ok := body["value"].(map[string]any) + if !ok { + return fmt.Errorf("fetched record was not an object") + } + info["record"] = record + b, err := json.MarshalIndent(record, "", " ") + if err != nil { + return err + } + info["recordJSON"] = string(b) + + return c.Render(http.StatusOK, "repo_record.html", info) +} diff --git a/cmd/astrolabe/main.go b/cmd/astrolabe/main.go new file mode 100644 index 000000000..b9075b139 --- /dev/null +++ b/cmd/astrolabe/main.go @@ -0,0 +1,66 @@ +package main + +import ( + "fmt" + slogging "log/slog" + "os" + + "github.com/carlmjohnson/versioninfo" + "github.com/urfave/cli/v2" + + _ "github.com/joho/godotenv/autoload" +) + +var ( + slog = slogging.New(slogging.NewJSONHandler(os.Stdout, nil)) + version = versioninfo.Short() +) + +func main() { + if err := run(os.Args); err != nil { + slog.Error("fatal", "err", err) + os.Exit(-1) + } +} + +func run(args []string) error { + + app := cli.App{ + Name: "astrolabe", + Usage: "public web interface to explore atproto network content", + } + + app.Commands = []*cli.Command{ + &cli.Command{ + Name: "serve", + Usage: "run the server", + Action: serve, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "bind", + Usage: "Specify the local IP/port to bind to", + Required: false, + Value: ":8400", + EnvVars: []string{"ASTROLABE_BIND"}, + }, + &cli.BoolFlag{ + Name: "debug", + Usage: "Enable debug mode", + Value: false, + Required: false, + EnvVars: []string{"DEBUG"}, + }, + }, + }, + &cli.Command{ + Name: "version", + Usage: "print version", + Action: func(cctx *cli.Context) error { + fmt.Println(version) + return nil + }, + }, + } + + return app.Run(args) +} diff --git a/cmd/astrolabe/renderer.go b/cmd/astrolabe/renderer.go new file mode 100644 index 000000000..0fa3cb1ce --- /dev/null +++ b/cmd/astrolabe/renderer.go @@ -0,0 +1,85 @@ +package main + +import ( + "bytes" + "embed" + "errors" + "fmt" + "io" + "path/filepath" + + "github.com/flosch/pongo2/v6" + "github.com/labstack/echo/v4" +) + +//go:embed templates/* +var TemplateFS embed.FS + +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) +} diff --git a/cmd/astrolabe/service.go b/cmd/astrolabe/service.go new file mode 100644 index 000000000..af6394b74 --- /dev/null +++ b/cmd/astrolabe/service.go @@ -0,0 +1,172 @@ +package main + +import ( + "context" + "embed" + "errors" + "io/fs" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/bluesky-social/indigo/atproto/identity" + + "github.com/flosch/pongo2/v6" + "github.com/labstack/echo/v4" + "github.com/labstack/echo/v4/middleware" + slogecho "github.com/samber/slog-echo" + "github.com/urfave/cli/v2" +) + +//go:embed static/* +var StaticFS embed.FS + +type Server struct { + echo *echo.Echo + httpd *http.Server + dir identity.Directory +} + +func serve(cctx *cli.Context) error { + debug := cctx.Bool("debug") + httpAddress := cctx.String("bind") + + e := echo.New() + + // httpd + var ( + httpTimeout = 1 * time.Minute + httpMaxHeaderBytes = 1 * (1024 * 1024) + ) + + srv := &Server{ + echo: e, + dir: identity.DefaultDirectory(), + } + srv.httpd = &http.Server{ + Handler: srv, + Addr: httpAddress, + WriteTimeout: httpTimeout, + ReadTimeout: httpTimeout, + MaxHeaderBytes: httpMaxHeaderBytes, + } + + e.HideBanner = true + e.Use(slogecho.New(slog)) + e.Use(middleware.Recover()) + e.Use(middleware.BodyLimit("64M")) + e.HTTPErrorHandler = srv.errorHandler + e.Renderer = NewRenderer("templates/", &TemplateFS, debug) + e.Use(middleware.SecureWithConfig(middleware.SecureConfig{ + ContentTypeNosniff: "nosniff", + XFrameOptions: "SAMEORIGIN", + HSTSMaxAge: 31536000, // 365 days + // TODO: + // ContentSecurityPolicy + // XSSProtection + })) + + // redirect trailing slash to non-trailing slash. + // all of our current endpoints have no trailing slash. + e.Use(middleware.RemoveTrailingSlashWithConfig(middleware.TrailingSlashConfig{ + RedirectCode: http.StatusFound, + })) + + staticHandler := http.FileServer(func() http.FileSystem { + if debug { + return http.FS(os.DirFS("static")) + } + fsys, err := fs.Sub(StaticFS, "static") + if err != nil { + slog.Error("static template error", "err", err) + os.Exit(-1) + } + return http.FS(fsys) + }()) + + e.GET("/static/*", echo.WrapHandler(http.StripPrefix("/static/", staticHandler))) + e.GET("/_health", srv.HandleHealthCheck) + + // basic static routes + e.GET("/robots.txt", echo.WrapHandler(staticHandler)) + e.GET("/favicon.ico", echo.WrapHandler(staticHandler)) + + // actual content + e.GET("/", srv.WebHome) + e.GET("/query", srv.WebQuery) + //e.GET("/at://:rkey", srv.WebRedirect) + e.GET("/account/:atid", srv.WebAccount) + e.GET("/at/:atid", srv.WebRepo) + e.GET("/at/:atid/:collection", srv.WebRepoCollection) + e.GET("/at/:atid/:collection/:rkey", srv.WebRepoRecord) + + // Start the server + slog.Info("starting server", "bind", httpAddress) + go func() { + if err := srv.httpd.ListenAndServe(); err != nil { + if !errors.Is(err, http.ErrServerClosed) { + slog.Error("HTTP server shutting down unexpectedly", "err", err) + } + } + }() + + // Wait for a signal to exit. + slog.Info("registering OS exit signal handler") + quit := make(chan struct{}) + exitSignals := make(chan os.Signal, 1) + signal.Notify(exitSignals, syscall.SIGINT, syscall.SIGTERM) + go func() { + sig := <-exitSignals + slog.Info("received OS exit signal", "signal", sig) + + // Shut down the HTTP server + if err := srv.Shutdown(); err != nil { + slog.Error("HTTP server shutdown error", "err", err) + } + + // Trigger the return that causes an exit. + close(quit) + }() + <-quit + slog.Info("graceful shutdown complete") + return nil +} + +type GenericStatus struct { + Daemon string `json:"daemon"` + Status string `json:"status"` + Message string `json:"msg,omitempty"` +} + +func (srv *Server) errorHandler(err error, c echo.Context) { + code := http.StatusInternalServerError + if he, ok := err.(*echo.HTTPError); ok { + code = he.Code + } + if code >= 500 { + slog.Warn("astrolabe-http-internal-error", "err", err) + } + data := pongo2.Context{ + "statusCode": code, + } + c.Render(code, "error.html", data) +} + +func (srv *Server) ServeHTTP(rw http.ResponseWriter, req *http.Request) { + srv.echo.ServeHTTP(rw, req) +} + +func (srv *Server) Shutdown() error { + slog.Info("shutting down") + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + return srv.httpd.Shutdown(ctx) +} + +func (s *Server) HandleHealthCheck(c echo.Context) error { + return c.JSON(200, GenericStatus{Status: "ok", Daemon: "astrolabe"}) +} diff --git a/cmd/astrolabe/static/apple-touch-icon.png b/cmd/astrolabe/static/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..5ebb6787c999108d56d1b2f2aa4a5f2275ac5e37 GIT binary patch literal 14742 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 literal 0 HcmV?d00001 diff --git a/cmd/astrolabe/static/favicon-16x16.png b/cmd/astrolabe/static/favicon-16x16.png new file mode 100644 index 0000000000000000000000000000000000000000..4267562b808197d5b00b58f3ffdedde4b689b3c2 GIT binary patch literal 316 zcmV-C0mJ@@P)>%ZR9||%g>bzjFkk1#=``{^V(~A)@&|y((Gsg1;9dVQMFy=A8wp9GEY`kJq(Ars64F*uHc2@q z<4_^tej&C@%Ddc5VHICYUxWrnnQ1ROQ+9^z4B7b;P?iYU*}0QG0qGr%AY^3?_d!Si O0000 zk(N&3+#cbc|G}DEi?B-~EnqFk714wzyTjurVc}0$_=QPe<%C<%))Y&0S$g_QxbK2L z+U+etTT^EBo?(CBLS))$l?${PvSSYcvDx^xH+S%FQRHQYpaG$>FamMU7&Z#l6T z0|BGQtbm1q=3*%52~QebP=mL2Z@8JTBNWgUTXjLZq*(ogWX&&h2<$2ZWEHFh zrxd(SdWIyyvyuxG*CrwAQN=ZcJwt&=Sb)Ro3A(7HccIOt|AkV7ea~4CoiRP5fEgB9 z&8yJP3D1M{K>=x%R3JPV3R|{kNWnTKb!SyF2rnw7IN{Rb|CTq3AK_(V9jIl*?E79@ z!YhHmoOxj4??%&CVkC?puZ=>KK1Eu1j z@639jW(=JTL9v*8?~EX9L8R0>8lmEOm`AdHCbG|*PG>2ecue~Tk7Ng8DAg+DEF7RK zj&U{X$KVY^>3bf_iN|u!%H;ejvB_3S;p-BPE7=f-MIOsP42y{ui%HFbjokBM0`Ow; ziB~f>tY$D)vyBK8Oo;l`3}H13=OZttomftTJ6camwv6>ZZbN76@?$o7f!(m2rW25W z@B3LFRt(qtA8nv8l>)izaq?#^1L&bqAo)jsE*`>=Zu8$QATfd+Rj_)25>amO1VqOskxiTL2ka7wyRC#Z6a?5VGeWig>++d9=R6^Lchf0jgPt2 zS@b>&p5$HA<%>uTq|SXCI^|4*(RM%eC~v~oSN!+0<`!=X zGdOvlS%Lf%!u!*-f4FBnaCneErz^V7TiTfbun{sbdoV9VC>~WPnIRmd7`c+^xx`bMHK+wNJKlXe8_uA9wzdy@8 zdKp~w-LHFJz|Z?4KJ$yXcHYa0l9DZ-?)%DiHM+=d%{EOP0k^kEULJ9epW(ZZPOM`rB;!J?3@g z7g~?ZXT(23;*v!DKf>2DEc=T~pL9O2 zwn&bCJ?BQh*++7I+g`u!`y0ev$fvlkP3N<+VtsSOCo+f6;u6-Au4i=FdMU51D`t4)J#Pd3x`Z{m;xok9JDdF&Z9((6?JOdB$3_5Ij6QygOc~P~5RtJ4Yd4S$)c>h?#`^Nx%CF0YTE{vS$ z_qgS&pOvhutb|)fb>}>0WIY#=@{Nye~5LGr?IB-A@B>XnLw|Wv0gGu4fL(5%jqWY z-;hr*!|{AJt>iZnR-cdlga12NcX%9Y5FbOngzRD6<$!e{hig7t_-<_%gnmgWPM_jJ z=gDWtwIQrERS?dwhuN+JKgC>6-_+QjF2>CQ=W4Y%uS*iKLKyM(ox z2Qm8xxQ3g+_I(A-$(c2`>xvWQkJyLfIR!pv#9qcNT+0>wu0D*ppMrc@ajna9m!)&P zYgx6LI-C_V{}yu9AVe4Xy8Ip!S3bAp^V)5C%xmmsjqfJ#6Rfp; z0o<==6aRcOtL(1}`}844Vsl{T1IRV3)$QRLsxe305;8yft>!pZ;>Emq?WQ(o#`phb zgVWbK&tZ1SS?Cnm^C0J+mghY}Pp*;HpF3+PHVeMnlGo;swKaS6x7n;)sx5yPvSwGj z6zj_Aij``0`QE{7UGnynxN_Tg@(aZ;Avuy5FZtzL6wg@SMQX8*OP^Yi@Ag}$M{!@& zW4upJtTD}2k5X-|e+$KtO)U8oS4!$vnsz?qr^NS>K6$9^Z%2UQFV6tET%F z(OFM>ijJ)5w&sXF`Ad9pW2|dl=)9q2eV}j$&^6{^@I& z!R=}K*Uj(h@4czN3L&-$YqiDhwx6^25zaY{7dg@P{H~buq>~?WUV%^Yvgz`*A-PdX z+*xWe-}$Ovsw|C2GWYoP9S?4x*JPKhrcYfF!a7-z-I*Kr+B=jYRd zIEV28e7v5c|F0zG&uVwgqFVYaWE)~8p2-;+ zkmlp;rTOI-M!x(V*v!k`2bgIO_I2Q=5Pt3xv+FcU{ayNH`T;mk;yT|0{tJ0LpHwY0 lf5)5?b&ZGK6@+tN1HKP@DnFYZ(sxEPe}mtA0*ibC{{v@~$EN@Q literal 0 HcmV?d00001 diff --git a/cmd/astrolabe/static/favicon.png b/cmd/astrolabe/static/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..61cf7c943b6ac083ab0752f283391e37b39c1221 GIT binary patch literal 6405 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 LX2?n%hsgf}ycPxo literal 0 HcmV?d00001 diff --git a/cmd/astrolabe/static/robots.txt b/cmd/astrolabe/static/robots.txt new file mode 100644 index 000000000..4f8510d18 --- /dev/null +++ b/cmd/astrolabe/static/robots.txt @@ -0,0 +1,9 @@ +# Hello Friends! +# If you are considering bulk or automated crawling, you may want to look in +# to our protocol (API), including a firehose of updates. See: https://atproto.com/ + +# By default, may crawl anything on this domain. HTTP 429 ("backoff") status +# codes are used for rate-limiting. Up to a handful concurrent requests should +# be ok. +User-Agent: * +Allow: / diff --git a/cmd/astrolabe/templates/account.html b/cmd/astrolabe/templates/account.html new file mode 100644 index 000000000..b967c7ff6 --- /dev/null +++ b/cmd/astrolabe/templates/account.html @@ -0,0 +1,24 @@ +{% extends "base.html" %} + +{% block main_content %} +

{{ atid }}

+ + + + + + + + + + +
DID{{ ident.DID }}
Handle{{ ident.Handle }}
PDS{{ ident.PDSEndpoint() }}
+ +

Repo Index +

Repo CAR Export + +{% if didDocJSON %} +

DID Document

+
{{ didDocJSON }}
+{% endif %} +{% endblock %} diff --git a/cmd/astrolabe/templates/base.html b/cmd/astrolabe/templates/base.html new file mode 100644 index 000000000..ce8e2666a --- /dev/null +++ b/cmd/astrolabe/templates/base.html @@ -0,0 +1,46 @@ + + + + + + + + + + + {% block head_title %}astrolabe{% endblock %} + + + + +
+ {% block main_content %}Base Template{% endblock %} +
+
+
+
+ + + + diff --git a/cmd/astrolabe/templates/error.html b/cmd/astrolabe/templates/error.html new file mode 100644 index 000000000..d1ec9dc43 --- /dev/null +++ b/cmd/astrolabe/templates/error.html @@ -0,0 +1,11 @@ +{% extends "base.html" %} + +{% block head_title %}Error {{ statusCode }} - astrolabe{% endblock %} + +{% block main_content %} +
+
+

{{ statusCode }}

+

Error!

+
+{% endblock %} diff --git a/cmd/astrolabe/templates/home.html b/cmd/astrolabe/templates/home.html new file mode 100644 index 000000000..577c1832a --- /dev/null +++ b/cmd/astrolabe/templates/home.html @@ -0,0 +1,5 @@ +{% extends "base.html" %} + +{% block main_content %} +This is the homepage +{% endblock %} diff --git a/cmd/astrolabe/templates/repo.html b/cmd/astrolabe/templates/repo.html new file mode 100644 index 000000000..d139ec839 --- /dev/null +++ b/cmd/astrolabe/templates/repo.html @@ -0,0 +1,16 @@ +{% extends "base.html" %} + +{% block main_content %} +

at://{{ atid }}

+ +

Index

+ + + + {% for collection in collections %} + + {% endfor %} + +
..
{{ collection }}/
+ +{% endblock %} diff --git a/cmd/astrolabe/templates/repo_collection.html b/cmd/astrolabe/templates/repo_collection.html new file mode 100644 index 000000000..802b1358f --- /dev/null +++ b/cmd/astrolabe/templates/repo_collection.html @@ -0,0 +1,19 @@ +{% extends "base.html" %} + +{% block main_content %} +

at://{{ atid }}/{{ collection }}

+ +

Index

+ + + + {% for uri in recordURIs %} + + {% endfor %} + {% if cursor != "" %} + + {% endif %} + +
..
{{ collection }}/{{ uri.RecordKey() }}
[more]
+ +{% endblock %} diff --git a/cmd/astrolabe/templates/repo_record.html b/cmd/astrolabe/templates/repo_record.html new file mode 100644 index 000000000..3f3ef30a3 --- /dev/null +++ b/cmd/astrolabe/templates/repo_record.html @@ -0,0 +1,12 @@ +{% extends "base.html" %} + +{% block main_content %} +

at://{{ atid }}/{{ collection }}/{{ rkey }}

+

Back to Collection + +{% if recordJSON %} +

Record JSON

+
{{ recordJSON }}
+{% endif %} + +{% endblock %} From 8a4fd50dc6f8122374f8b6e900378b2765880e50 Mon Sep 17 00:00:00 2001 From: bryan newbold Date: Mon, 26 Aug 2024 23:33:44 -0700 Subject: [PATCH 2/6] fix generic record fetching/listing --- cmd/astrolabe/handlers.go | 32 ++++++++----------- cmd/astrolabe/repogetRecord.go | 42 +++++++++++++++++++++++++ cmd/astrolabe/repolistRecords.go | 53 ++++++++++++++++++++++++++++++++ 3 files changed, 107 insertions(+), 20 deletions(-) create mode 100644 cmd/astrolabe/repogetRecord.go create mode 100644 cmd/astrolabe/repolistRecords.go diff --git a/cmd/astrolabe/handlers.go b/cmd/astrolabe/handlers.go index 091add3f2..acb5cfead 100644 --- a/cmd/astrolabe/handlers.go +++ b/cmd/astrolabe/handlers.go @@ -3,7 +3,6 @@ package main import ( "encoding/json" "fmt" - "io" "net/http" "strings" @@ -155,7 +154,7 @@ func (srv *Server) WebRepoCollection(c echo.Context) error { cursor := c.QueryParam("cursor") // collection string, cursor string, limit int64, repo string, reverse bool, rkeyEnd string, rkeyStart string - resp, err := comatproto.RepoListRecords(ctx, &xrpcc, collection.String(), cursor, 100, ident.DID.String(), false, "", "") + resp, err := RepoListRecords(ctx, &xrpcc, collection.String(), cursor, 100, ident.DID.String(), false, "", "") if err != nil { return err } @@ -208,31 +207,24 @@ func (srv *Server) WebRepoRecord(c echo.Context) error { info["collection"] = collection info["rkey"] = rkey - pdsURL := ident.PDSEndpoint() - - //slog.Debug("fetching record", "did", ident.DID.String(), "collection", aturi.Collection().String(), "rkey", aturi.RecordKey().String()) - url := fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=%s&rkey=%s", - pdsURL, ident.DID, collection, rkey) - resp, err := http.Get(url) - if err != nil { - return err - } - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("fetch failed") + xrpcc := xrpc.Client{ + Host: ident.PDSEndpoint(), } - respBytes, err := io.ReadAll(resp.Body) + resp, err := RepoGetRecord(ctx, &xrpcc, "", collection.String(), ident.DID.String(), rkey.String()) if err != nil { return err } - body, err := data.UnmarshalJSON(respBytes) - if err != nil { - return err + + if nil == resp.Value { + return fmt.Errorf("empty record in response") } - record, ok := body["value"].(map[string]any) - if !ok { - return fmt.Errorf("fetched record was not an object") + + record, err := data.UnmarshalJSON(*resp.Value) + if err != nil { + return fmt.Errorf("fetched record was invalid data: %w", err) } info["record"] = record + b, err := json.MarshalIndent(record, "", " ") if err != nil { return err diff --git a/cmd/astrolabe/repogetRecord.go b/cmd/astrolabe/repogetRecord.go new file mode 100644 index 000000000..e3b857cd5 --- /dev/null +++ b/cmd/astrolabe/repogetRecord.go @@ -0,0 +1,42 @@ +// Copied from indigo:api/atproto/repolistRecords.go + +package main + +// schema: com.atproto.repo.getRecord + +import ( + "context" + "encoding/json" + + "github.com/bluesky-social/indigo/xrpc" +) + +// RepoGetRecord_Output is the output of a com.atproto.repo.getRecord call. +type RepoGetRecord_Output struct { + Cid *string `json:"cid,omitempty" cborgen:"cid,omitempty"` + Uri string `json:"uri" cborgen:"uri"` + // NOTE: changed from lex decoder to json.RawMessage + Value *json.RawMessage `json:"value" cborgen:"value"` +} + +// RepoGetRecord calls the XRPC method "com.atproto.repo.getRecord". +// +// cid: The CID of the version of the record. If not specified, then return the most recent version. +// collection: The NSID of the record collection. +// repo: The handle or DID of the repo. +// rkey: The Record Key. +func RepoGetRecord(ctx context.Context, c *xrpc.Client, cid string, collection string, repo string, rkey string) (*RepoGetRecord_Output, error) { + var out RepoGetRecord_Output + + params := map[string]interface{}{ + "cid": cid, + "collection": collection, + "repo": repo, + "rkey": rkey, + } + if err := c.Do(ctx, xrpc.Query, "", "com.atproto.repo.getRecord", params, nil, &out); err != nil { + return nil, err + } + + return &out, nil +} diff --git a/cmd/astrolabe/repolistRecords.go b/cmd/astrolabe/repolistRecords.go new file mode 100644 index 000000000..3cfdd9b24 --- /dev/null +++ b/cmd/astrolabe/repolistRecords.go @@ -0,0 +1,53 @@ +// Copied from indigo:api/atproto/repolistRecords.go + +package main + +// schema: com.atproto.repo.listRecords + +import ( + "context" + "encoding/json" + + "github.com/bluesky-social/indigo/xrpc" +) + +// RepoListRecords_Output is the output of a com.atproto.repo.listRecords call. +type RepoListRecords_Output struct { + Cursor *string `json:"cursor,omitempty" cborgen:"cursor,omitempty"` + Records []*RepoListRecords_Record `json:"records" cborgen:"records"` +} + +// RepoListRecords_Record is a "record" in the com.atproto.repo.listRecords schema. +type RepoListRecords_Record struct { + Cid string `json:"cid" cborgen:"cid"` + Uri string `json:"uri" cborgen:"uri"` + // NOTE: changed from lex decoder to json.RawMessage + Value *json.RawMessage `json:"value" cborgen:"value"` +} + +// RepoListRecords calls the XRPC method "com.atproto.repo.listRecords". +// +// collection: The NSID of the record type. +// limit: The number of records to return. +// repo: The handle or DID of the repo. +// reverse: Flag to reverse the order of the returned records. +// rkeyEnd: DEPRECATED: The highest sort-ordered rkey to stop at (exclusive) +// rkeyStart: DEPRECATED: The lowest sort-ordered rkey to start from (exclusive) +func RepoListRecords(ctx context.Context, c *xrpc.Client, collection string, cursor string, limit int64, repo string, reverse bool, rkeyEnd string, rkeyStart string) (*RepoListRecords_Output, error) { + var out RepoListRecords_Output + + params := map[string]interface{}{ + "collection": collection, + "cursor": cursor, + "limit": limit, + "repo": repo, + "reverse": reverse, + "rkeyEnd": rkeyEnd, + "rkeyStart": rkeyStart, + } + if err := c.Do(ctx, xrpc.Query, "", "com.atproto.repo.listRecords", params, nil, &out); err != nil { + return nil, err + } + + return &out, nil +} From 17a2fd6a0a7c390a48aabf33cf1febc50e540ac9 Mon Sep 17 00:00:00 2001 From: bryan newbold Date: Mon, 26 Aug 2024 23:49:27 -0700 Subject: [PATCH 3/6] fix double error rendering --- cmd/astrolabe/handlers.go | 2 +- cmd/astrolabe/service.go | 10 ++++++++-- cmd/astrolabe/templates/error.html | 3 +++ 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/cmd/astrolabe/handlers.go b/cmd/astrolabe/handlers.go index acb5cfead..5b28b693b 100644 --- a/cmd/astrolabe/handlers.go +++ b/cmd/astrolabe/handlers.go @@ -212,7 +212,7 @@ func (srv *Server) WebRepoRecord(c echo.Context) error { } resp, err := RepoGetRecord(ctx, &xrpcc, "", collection.String(), ident.DID.String(), rkey.String()) if err != nil { - return err + return echo.NewHTTPError(400, fmt.Sprintf("failed to load record: %s", err)) } if nil == resp.Value { diff --git a/cmd/astrolabe/service.go b/cmd/astrolabe/service.go index af6394b74..f7300c451 100644 --- a/cmd/astrolabe/service.go +++ b/cmd/astrolabe/service.go @@ -4,6 +4,7 @@ import ( "context" "embed" "errors" + "fmt" "io/fs" "net/http" "os" @@ -142,16 +143,21 @@ type GenericStatus struct { func (srv *Server) errorHandler(err error, c echo.Context) { code := http.StatusInternalServerError + var errorMessage string if he, ok := err.(*echo.HTTPError); ok { code = he.Code + errorMessage = fmt.Sprintf("%s", he.Message) } if code >= 500 { slog.Warn("astrolabe-http-internal-error", "err", err) } data := pongo2.Context{ - "statusCode": code, + "statusCode": code, + "errorMessage": errorMessage, + } + if !c.Response().Committed { + c.Render(code, "error.html", data) } - c.Render(code, "error.html", data) } func (srv *Server) ServeHTTP(rw http.ResponseWriter, req *http.Request) { diff --git a/cmd/astrolabe/templates/error.html b/cmd/astrolabe/templates/error.html index d1ec9dc43..08ea1c6b2 100644 --- a/cmd/astrolabe/templates/error.html +++ b/cmd/astrolabe/templates/error.html @@ -7,5 +7,8 @@

{{ statusCode }}

Error!

+ {% if errorMessage %} +

{{ errorMessage }}

+ {% endif %}
{% endblock %} From ed539991b1566fb4afca0b9fe910d83193b84192 Mon Sep 17 00:00:00 2001 From: bryan newbold Date: Mon, 26 Aug 2024 23:54:45 -0700 Subject: [PATCH 4/6] uri in location bar --- cmd/astrolabe/handlers.go | 4 ++++ cmd/astrolabe/templates/base.html | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/cmd/astrolabe/handlers.go b/cmd/astrolabe/handlers.go index 5b28b693b..5d400cd63 100644 --- a/cmd/astrolabe/handlers.go +++ b/cmd/astrolabe/handlers.go @@ -80,6 +80,7 @@ func (srv *Server) WebAccount(c echo.Context) error { } info["atid"] = atid info["ident"] = ident + info["uri"] = atid return c.Render(http.StatusOK, "account.html", info) } @@ -101,6 +102,7 @@ func (srv *Server) WebRepo(c echo.Context) error { } info["atid"] = atid info["ident"] = ident + info["uri"] = fmt.Sprintf("at://%s", atid) // create a new API client to connect to the account's PDS xrpcc := xrpc.Client{ @@ -143,6 +145,7 @@ func (srv *Server) WebRepoCollection(c echo.Context) error { info["atid"] = atid info["ident"] = ident info["collection"] = collection + info["uri"] = fmt.Sprintf("at://%s/%s", atid, collection) // create a new API client to connect to the account's PDS xrpcc := xrpc.Client{ @@ -206,6 +209,7 @@ func (srv *Server) WebRepoRecord(c echo.Context) error { info["ident"] = ident info["collection"] = collection info["rkey"] = rkey + info["uri"] = fmt.Sprintf("at://%s/%s/%s", atid, collection, rkey) xrpcc := xrpc.Client{ Host: ident.PDSEndpoint(), diff --git a/cmd/astrolabe/templates/base.html b/cmd/astrolabe/templates/base.html index ce8e2666a..fa2217d28 100644 --- a/cmd/astrolabe/templates/base.html +++ b/cmd/astrolabe/templates/base.html @@ -23,7 +23,7 @@
  • astrolabe
  • - +
    • Code
    • From 9da2fb5ca138349bfec8ab0b34bf7afdcbdc0f3f Mon Sep 17 00:00:00 2001 From: bryan newbold Date: Tue, 27 Aug 2024 00:42:22 -0700 Subject: [PATCH 5/6] little tweaks --- cmd/astrolabe/handlers.go | 4 ++++ cmd/astrolabe/templates/account.html | 2 +- cmd/astrolabe/templates/base.html | 10 ++-------- 3 files changed, 7 insertions(+), 9 deletions(-) diff --git a/cmd/astrolabe/handlers.go b/cmd/astrolabe/handlers.go index 5d400cd63..799536b73 100644 --- a/cmd/astrolabe/handlers.go +++ b/cmd/astrolabe/handlers.go @@ -30,6 +30,10 @@ func (srv *Server) WebQuery(c echo.Context) error { return c.Redirect(http.StatusFound, "/") } if strings.HasPrefix(q, "at://") { + if strings.HasSuffix(q, "/") { + q = q[0:len(q)-1] + } + aturi, err := syntax.ParseATURI(q) if err != nil { return err diff --git a/cmd/astrolabe/templates/account.html b/cmd/astrolabe/templates/account.html index b967c7ff6..dd27bc31a 100644 --- a/cmd/astrolabe/templates/account.html +++ b/cmd/astrolabe/templates/account.html @@ -19,6 +19,6 @@

      {{ atid }}

      {% if didDocJSON %}

      DID Document

      -
      {{ didDocJSON }}
      +
      {{ didDocJSON }}
      {% endif %} {% endblock %} diff --git a/cmd/astrolabe/templates/base.html b/cmd/astrolabe/templates/base.html index fa2217d28..ee4699433 100644 --- a/cmd/astrolabe/templates/base.html +++ b/cmd/astrolabe/templates/base.html @@ -13,6 +13,8 @@ body > footer { position: absolute; bottom: 0px; padding: 2em; background-color: var(--pico-muted-border-color); } thead th { font-weight: bold; } main article { margin: 2.5rem 0; padding: 2rem; } + code { background: none; } + td { padding: 0; } {% block head_title %}astrolabe{% endblock %} @@ -32,15 +34,7 @@
      {% block main_content %}Base Template{% endblock %} -
      -
      -
      - From f0a27c58d4113ce275c7e3e5998e2c1ec3c2635e Mon Sep 17 00:00:00 2001 From: bryan newbold Date: Tue, 27 Aug 2024 00:46:16 -0700 Subject: [PATCH 6/6] missing tweak --- cmd/astrolabe/templates/repo_record.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/astrolabe/templates/repo_record.html b/cmd/astrolabe/templates/repo_record.html index 3f3ef30a3..09da3c57a 100644 --- a/cmd/astrolabe/templates/repo_record.html +++ b/cmd/astrolabe/templates/repo_record.html @@ -6,7 +6,7 @@

      at://{{ atid }}/{{ collection }}/{{ rkey }}< {% if recordJSON %}

      Record JSON

      -
      {{ recordJSON }}
      +
      {{ recordJSON }}
      {% endif %} {% endblock %}