Skip to content

Commit

Permalink
astrolabe: crude web UI for exploring atmosphere (#725)
Browse files Browse the repository at this point in the history
- [x] remove footer
- [x] fix double-rendered error page
- [x] fix unknown lexicons not listing
- [ ] link-ify AT-URIs in JSON
- [ ] better table aesthetics
- [ ] better aesthetics generally
  • Loading branch information
bnewbold committed Sep 6, 2024
2 parents 502cf77 + f0a27c5 commit 4942010
Show file tree
Hide file tree
Showing 21 changed files with 845 additions and 0 deletions.
39 changes: 39 additions & 0 deletions cmd/astrolabe/README.md
Original file line number Diff line number Diff line change
@@ -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
243 changes: 243 additions & 0 deletions cmd/astrolabe/handlers.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
package main

import (
"encoding/json"
"fmt"
"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://") {
if strings.HasSuffix(q, "/") {
q = q[0:len(q)-1]
}

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
info["uri"] = atid
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
info["uri"] = fmt.Sprintf("at://%s", atid)

// 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
info["uri"] = fmt.Sprintf("at://%s/%s", atid, 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 := 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
info["uri"] = fmt.Sprintf("at://%s/%s/%s", atid, collection, rkey)

xrpcc := xrpc.Client{
Host: ident.PDSEndpoint(),
}
resp, err := RepoGetRecord(ctx, &xrpcc, "", collection.String(), ident.DID.String(), rkey.String())
if err != nil {
return echo.NewHTTPError(400, fmt.Sprintf("failed to load record: %s", err))
}

if nil == resp.Value {
return fmt.Errorf("empty record in response")
}

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
}
info["recordJSON"] = string(b)

return c.Render(http.StatusOK, "repo_record.html", info)
}
66 changes: 66 additions & 0 deletions cmd/astrolabe/main.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading

0 comments on commit 4942010

Please sign in to comment.