this repo has no description
13
fork

Configure Feed

Select the types of activity you want to include in your feed.

import astrolabe from indigo

+812
+39
cmd/astrolabe/README.md
··· 1 + 2 + astrolabe: basic atproto network data explorer 3 + ============================================== 4 + 5 + ⚠️ This is a fun little proof-of-concept ⚠️ 6 + 7 + 8 + ## Run It 9 + 10 + The recommended way to run `astrolabe` is behind a `caddy` HTTPS server which does automatic on-demand SSL certificate registration (using Let's Encrypt). 11 + 12 + Build and run `astrolabe`: 13 + 14 + go build ./cmd/astrolabe 15 + 16 + # will listen on :8400 by default 17 + ./astrolabe serve 18 + 19 + Create a `Caddyfile`: 20 + 21 + ``` 22 + { 23 + on_demand_tls { 24 + interval 1h 25 + burst 8 26 + } 27 + } 28 + 29 + :443 { 30 + reverse_proxy localhost:8400 31 + tls YOUREMAIL@example.com { 32 + on_demand 33 + } 34 + } 35 + ``` 36 + 37 + Run `caddy`: 38 + 39 + caddy run
+247
cmd/astrolabe/handlers.go
··· 1 + package main 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "net/http" 7 + "strings" 8 + 9 + "github.com/bluesky-social/indigo/api/agnostic" 10 + comatproto "github.com/bluesky-social/indigo/api/atproto" 11 + _ "github.com/bluesky-social/indigo/api/bsky" 12 + "github.com/bluesky-social/indigo/atproto/atdata" 13 + "github.com/bluesky-social/indigo/atproto/identity" 14 + "github.com/bluesky-social/indigo/atproto/syntax" 15 + "github.com/bluesky-social/indigo/xrpc" 16 + 17 + "github.com/flosch/pongo2/v6" 18 + "github.com/labstack/echo/v4" 19 + ) 20 + 21 + func (srv *Server) WebHome(c echo.Context) error { 22 + info := pongo2.Context{} 23 + return c.Render(http.StatusOK, "home.html", info) 24 + } 25 + 26 + func (srv *Server) WebQuery(c echo.Context) error { 27 + 28 + // parse the q query param, redirect based on that 29 + q := c.QueryParam("q") 30 + if q == "" { 31 + return c.Redirect(http.StatusFound, "/") 32 + } 33 + if strings.HasPrefix(q, "https://") { 34 + q = ParseServiceURL(q) 35 + } 36 + if strings.HasPrefix(q, "at://") { 37 + if strings.HasSuffix(q, "/") { 38 + q = q[0 : len(q)-1] 39 + } 40 + 41 + aturi, err := syntax.ParseATURI(q) 42 + if err != nil { 43 + return err 44 + } 45 + if aturi.RecordKey() != "" { 46 + return c.Redirect(http.StatusFound, fmt.Sprintf("/at/%s/%s/%s", aturi.Authority(), aturi.Collection(), aturi.RecordKey())) 47 + } 48 + if aturi.Collection() != "" { 49 + return c.Redirect(http.StatusFound, fmt.Sprintf("/at/%s/%s", aturi.Authority(), aturi.Collection())) 50 + } 51 + return c.Redirect(http.StatusFound, fmt.Sprintf("/at/%s", aturi.Authority())) 52 + } 53 + if strings.HasPrefix(q, "did:") { 54 + return c.Redirect(http.StatusFound, fmt.Sprintf("/account/%s", q)) 55 + } 56 + _, err := syntax.ParseHandle(q) 57 + if nil == err { 58 + return c.Redirect(http.StatusFound, fmt.Sprintf("/account/%s", q)) 59 + } 60 + return echo.NewHTTPError(400, "failed to parse query") 61 + } 62 + 63 + // e.GET("/account/:atid", srv.WebAccount) 64 + func (srv *Server) WebAccount(c echo.Context) error { 65 + ctx := c.Request().Context() 66 + //req := c.Request() 67 + info := pongo2.Context{} 68 + 69 + atid, err := syntax.ParseAtIdentifier(c.Param("atid")) 70 + if err != nil { 71 + return echo.NewHTTPError(404, "failed to parse handle or DID") 72 + } 73 + 74 + ident, err := srv.dir.Lookup(ctx, *atid) 75 + if err != nil { 76 + // TODO: proper error page? 77 + return err 78 + } 79 + 80 + bdir := identity.BaseDirectory{} 81 + doc, err := bdir.ResolveDID(ctx, ident.DID) 82 + if nil == err { 83 + b, err := json.MarshalIndent(doc, "", " ") 84 + if err != nil { 85 + return err 86 + } 87 + info["didDocJSON"] = string(b) 88 + } 89 + info["atid"] = atid 90 + info["ident"] = ident 91 + info["uri"] = atid 92 + return c.Render(http.StatusOK, "account.html", info) 93 + } 94 + 95 + // e.GET("/at/:atid", srv.WebRepo) 96 + func (srv *Server) WebRepo(c echo.Context) error { 97 + ctx := c.Request().Context() 98 + //req := c.Request() 99 + info := pongo2.Context{} 100 + 101 + atid, err := syntax.ParseAtIdentifier(c.Param("atid")) 102 + if err != nil { 103 + return echo.NewHTTPError(400, "failed to parse handle or DID") 104 + } 105 + 106 + ident, err := srv.dir.Lookup(ctx, *atid) 107 + if err != nil { 108 + // TODO: proper error page? 109 + return err 110 + } 111 + info["atid"] = atid 112 + info["ident"] = ident 113 + info["uri"] = fmt.Sprintf("at://%s", atid) 114 + 115 + // create a new API client to connect to the account's PDS 116 + xrpcc := xrpc.Client{ 117 + Host: ident.PDSEndpoint(), 118 + } 119 + if xrpcc.Host == "" { 120 + return fmt.Errorf("no PDS endpoint for identity") 121 + } 122 + 123 + desc, err := comatproto.RepoDescribeRepo(ctx, &xrpcc, ident.DID.String()) 124 + if err != nil { 125 + return err 126 + } 127 + info["collections"] = desc.Collections 128 + 129 + return c.Render(http.StatusOK, "repo.html", info) 130 + } 131 + 132 + // e.GET("/at/:atid/:collection", srv.WebCollection) 133 + func (srv *Server) WebRepoCollection(c echo.Context) error { 134 + ctx := c.Request().Context() 135 + //req := c.Request() 136 + info := pongo2.Context{} 137 + 138 + atid, err := syntax.ParseAtIdentifier(c.Param("atid")) 139 + if err != nil { 140 + return echo.NewHTTPError(400, "failed to parse handle or DID") 141 + } 142 + 143 + collection, err := syntax.ParseNSID(c.Param("collection")) 144 + if err != nil { 145 + return echo.NewHTTPError(400, "failed to parse collection NSID") 146 + } 147 + 148 + ident, err := srv.dir.Lookup(ctx, *atid) 149 + if err != nil { 150 + // TODO: proper error page? 151 + return err 152 + } 153 + info["atid"] = atid 154 + info["ident"] = ident 155 + info["collection"] = collection 156 + info["uri"] = fmt.Sprintf("at://%s/%s", atid, collection) 157 + 158 + // create a new API client to connect to the account's PDS 159 + xrpcc := xrpc.Client{ 160 + Host: ident.PDSEndpoint(), 161 + } 162 + if xrpcc.Host == "" { 163 + return fmt.Errorf("no PDS endpoint for identity") 164 + } 165 + 166 + cursor := c.QueryParam("cursor") 167 + // collection string, cursor string, limit int64, repo string, reverse bool 168 + resp, err := agnostic.RepoListRecords(ctx, &xrpcc, collection.String(), cursor, 100, ident.DID.String(), false) 169 + if err != nil { 170 + return err 171 + } 172 + recordURIs := make([]syntax.ATURI, len(resp.Records)) 173 + for i, rec := range resp.Records { 174 + aturi, err := syntax.ParseATURI(rec.Uri) 175 + if err != nil { 176 + return err 177 + } 178 + recordURIs[i] = aturi 179 + } 180 + if resp.Cursor != nil && *resp.Cursor != "" { 181 + cursor = *resp.Cursor 182 + } 183 + 184 + info["records"] = resp.Records 185 + info["recordURIs"] = recordURIs 186 + info["cursor"] = cursor 187 + return c.Render(http.StatusOK, "repo_collection.html", info) 188 + } 189 + 190 + // e.GET("/at/:atid/:collection/:rkey", srv.WebRecord) 191 + func (srv *Server) WebRepoRecord(c echo.Context) error { 192 + ctx := c.Request().Context() 193 + //req := c.Request() 194 + info := pongo2.Context{} 195 + 196 + atid, err := syntax.ParseAtIdentifier(c.Param("atid")) 197 + if err != nil { 198 + return echo.NewHTTPError(400, "failed to parse handle or DID") 199 + } 200 + 201 + collection, err := syntax.ParseNSID(c.Param("collection")) 202 + if err != nil { 203 + return echo.NewHTTPError(400, "failed to parse collection NSID") 204 + } 205 + 206 + rkey, err := syntax.ParseRecordKey(c.Param("rkey")) 207 + if err != nil { 208 + return echo.NewHTTPError(400, "failed to parse record key") 209 + } 210 + 211 + ident, err := srv.dir.Lookup(ctx, *atid) 212 + if err != nil { 213 + // TODO: proper error page? 214 + return err 215 + } 216 + info["atid"] = atid 217 + info["ident"] = ident 218 + info["collection"] = collection 219 + info["rkey"] = rkey 220 + info["uri"] = fmt.Sprintf("at://%s/%s/%s", atid, collection, rkey) 221 + 222 + xrpcc := xrpc.Client{ 223 + Host: ident.PDSEndpoint(), 224 + } 225 + resp, err := agnostic.RepoGetRecord(ctx, &xrpcc, "", collection.String(), ident.DID.String(), rkey.String()) 226 + if err != nil { 227 + return echo.NewHTTPError(400, fmt.Sprintf("failed to load record: %s", err)) 228 + } 229 + 230 + if nil == resp.Value { 231 + return fmt.Errorf("empty record in response") 232 + } 233 + 234 + record, err := atdata.UnmarshalJSON(*resp.Value) 235 + if err != nil { 236 + return fmt.Errorf("fetched record was invalid data: %w", err) 237 + } 238 + info["record"] = record 239 + 240 + b, err := json.MarshalIndent(record, "", " ") 241 + if err != nil { 242 + return err 243 + } 244 + info["recordJSON"] = string(b) 245 + 246 + return c.Render(http.StatusOK, "repo_record.html", info) 247 + }
+66
cmd/astrolabe/main.go
··· 1 + package main 2 + 3 + import ( 4 + "fmt" 5 + slogging "log/slog" 6 + "os" 7 + 8 + "github.com/carlmjohnson/versioninfo" 9 + "github.com/urfave/cli/v2" 10 + 11 + _ "github.com/joho/godotenv/autoload" 12 + ) 13 + 14 + var ( 15 + slog = slogging.New(slogging.NewJSONHandler(os.Stdout, nil)) 16 + version = versioninfo.Short() 17 + ) 18 + 19 + func main() { 20 + if err := run(os.Args); err != nil { 21 + slog.Error("fatal", "err", err) 22 + os.Exit(-1) 23 + } 24 + } 25 + 26 + func run(args []string) error { 27 + 28 + app := cli.App{ 29 + Name: "astrolabe", 30 + Usage: "public web interface to explore atproto network content", 31 + } 32 + 33 + app.Commands = []*cli.Command{ 34 + &cli.Command{ 35 + Name: "serve", 36 + Usage: "run the server", 37 + Action: serve, 38 + Flags: []cli.Flag{ 39 + &cli.StringFlag{ 40 + Name: "bind", 41 + Usage: "Specify the local IP/port to bind to", 42 + Required: false, 43 + Value: ":8400", 44 + EnvVars: []string{"ASTROLABE_BIND"}, 45 + }, 46 + &cli.BoolFlag{ 47 + Name: "debug", 48 + Usage: "Enable debug mode", 49 + Value: false, 50 + Required: false, 51 + EnvVars: []string{"DEBUG"}, 52 + }, 53 + }, 54 + }, 55 + &cli.Command{ 56 + Name: "version", 57 + Usage: "print version", 58 + Action: func(cctx *cli.Context) error { 59 + fmt.Println(version) 60 + return nil 61 + }, 62 + }, 63 + } 64 + 65 + return app.Run(args) 66 + }
+23
cmd/astrolabe/parse.go
··· 1 + package main 2 + 3 + import ( 4 + "fmt" 5 + "strings" 6 + ) 7 + 8 + // attempts to parse a service URL to an AT-URI, handle, or DID. if it can't, passes string through as-is 9 + func ParseServiceURL(raw string) string { 10 + parts := strings.Split(raw, "/") 11 + if len(parts) < 3 || parts[0] != "https:" { 12 + return raw 13 + } 14 + if parts[2] == "bsky.app" && len(parts) >= 5 && parts[3] == "profile" { 15 + if len(parts) == 5 { 16 + return parts[4] 17 + } 18 + if len(parts) == 7 && parts[5] == "post" { 19 + return fmt.Sprintf("at://%s/app.bsky.feed.post/%s", parts[4], parts[6]) 20 + } 21 + } 22 + return raw 23 + }
+23
cmd/astrolabe/parse_test.go
··· 1 + package main 2 + 3 + import ( 4 + "testing" 5 + 6 + "github.com/stretchr/testify/assert" 7 + ) 8 + 9 + func TestParseServiceURL(t *testing.T) { 10 + assert := assert.New(t) 11 + 12 + testVec := [][]string{ 13 + {"", ""}, 14 + {"atproto.com", "atproto.com"}, 15 + {"https://bsky.app/profile/atproto.com", "atproto.com"}, 16 + {"https://bsky.app/profile/did:plc:ewvi7nxzyoun6zhxrhs64oiz", "did:plc:ewvi7nxzyoun6zhxrhs64oiz"}, 17 + {"https://bsky.app/profile/atproto.com/post/3lffzv6f4o22r", "at://atproto.com/app.bsky.feed.post/3lffzv6f4o22r"}, 18 + } 19 + 20 + for _, pair := range testVec { 21 + assert.Equal(pair[1], ParseServiceURL(pair[0])) 22 + } 23 + }
+85
cmd/astrolabe/renderer.go
··· 1 + package main 2 + 3 + import ( 4 + "bytes" 5 + "embed" 6 + "errors" 7 + "fmt" 8 + "io" 9 + "path/filepath" 10 + 11 + "github.com/flosch/pongo2/v6" 12 + "github.com/labstack/echo/v4" 13 + ) 14 + 15 + //go:embed templates/* 16 + var TemplateFS embed.FS 17 + 18 + type RendererLoader struct { 19 + prefix string 20 + fs *embed.FS 21 + } 22 + 23 + func NewRendererLoader(prefix string, fs *embed.FS) pongo2.TemplateLoader { 24 + return &RendererLoader{ 25 + prefix: prefix, 26 + fs: fs, 27 + } 28 + } 29 + func (l *RendererLoader) Abs(_, name string) string { 30 + // TODO: remove this workaround 31 + // Figure out why this method is being called 32 + // twice on template names resulting in a failure to resolve 33 + // the template name. 34 + if filepath.HasPrefix(name, l.prefix) { 35 + return name 36 + } 37 + return filepath.Join(l.prefix, name) 38 + } 39 + 40 + func (l *RendererLoader) Get(path string) (io.Reader, error) { 41 + b, err := l.fs.ReadFile(path) 42 + if err != nil { 43 + return nil, fmt.Errorf("reading template %q failed: %w", path, err) 44 + } 45 + return bytes.NewReader(b), nil 46 + } 47 + 48 + type Renderer struct { 49 + TemplateSet *pongo2.TemplateSet 50 + Debug bool 51 + } 52 + 53 + func NewRenderer(prefix string, fs *embed.FS, debug bool) *Renderer { 54 + return &Renderer{ 55 + TemplateSet: pongo2.NewSet(prefix, NewRendererLoader(prefix, fs)), 56 + Debug: debug, 57 + } 58 + } 59 + 60 + func (r Renderer) Render(w io.Writer, name string, data interface{}, c echo.Context) error { 61 + var ctx pongo2.Context 62 + 63 + if data != nil { 64 + var ok bool 65 + ctx, ok = data.(pongo2.Context) 66 + if !ok { 67 + return errors.New("no pongo2.Context data was passed") 68 + } 69 + } 70 + 71 + var t *pongo2.Template 72 + var err error 73 + 74 + if r.Debug { 75 + t, err = pongo2.FromFile(name) 76 + } else { 77 + t, err = r.TemplateSet.FromFile(name) 78 + } 79 + 80 + if err != nil { 81 + return err 82 + } 83 + 84 + return t.ExecuteWriter(ctx, w) 85 + }
+178
cmd/astrolabe/service.go
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "embed" 6 + "errors" 7 + "fmt" 8 + "io/fs" 9 + "net/http" 10 + "os" 11 + "os/signal" 12 + "syscall" 13 + "time" 14 + 15 + "github.com/bluesky-social/indigo/atproto/identity" 16 + 17 + "github.com/flosch/pongo2/v6" 18 + "github.com/labstack/echo/v4" 19 + "github.com/labstack/echo/v4/middleware" 20 + slogecho "github.com/samber/slog-echo" 21 + "github.com/urfave/cli/v2" 22 + ) 23 + 24 + //go:embed static/* 25 + var StaticFS embed.FS 26 + 27 + type Server struct { 28 + echo *echo.Echo 29 + httpd *http.Server 30 + dir identity.Directory 31 + } 32 + 33 + func serve(cctx *cli.Context) error { 34 + debug := cctx.Bool("debug") 35 + httpAddress := cctx.String("bind") 36 + 37 + e := echo.New() 38 + 39 + // httpd 40 + var ( 41 + httpTimeout = 1 * time.Minute 42 + httpMaxHeaderBytes = 1 * (1024 * 1024) 43 + ) 44 + 45 + srv := &Server{ 46 + echo: e, 47 + dir: identity.DefaultDirectory(), 48 + } 49 + srv.httpd = &http.Server{ 50 + Handler: srv, 51 + Addr: httpAddress, 52 + WriteTimeout: httpTimeout, 53 + ReadTimeout: httpTimeout, 54 + MaxHeaderBytes: httpMaxHeaderBytes, 55 + } 56 + 57 + e.HideBanner = true 58 + e.Use(slogecho.New(slog)) 59 + e.Use(middleware.Recover()) 60 + e.Use(middleware.BodyLimit("64M")) 61 + e.HTTPErrorHandler = srv.errorHandler 62 + e.Renderer = NewRenderer("templates/", &TemplateFS, debug) 63 + e.Use(middleware.SecureWithConfig(middleware.SecureConfig{ 64 + ContentTypeNosniff: "nosniff", 65 + XFrameOptions: "SAMEORIGIN", 66 + HSTSMaxAge: 31536000, // 365 days 67 + // TODO: 68 + // ContentSecurityPolicy 69 + // XSSProtection 70 + })) 71 + 72 + // redirect trailing slash to non-trailing slash. 73 + // all of our current endpoints have no trailing slash. 74 + e.Use(middleware.RemoveTrailingSlashWithConfig(middleware.TrailingSlashConfig{ 75 + RedirectCode: http.StatusFound, 76 + })) 77 + 78 + staticHandler := http.FileServer(func() http.FileSystem { 79 + if debug { 80 + return http.FS(os.DirFS("static")) 81 + } 82 + fsys, err := fs.Sub(StaticFS, "static") 83 + if err != nil { 84 + slog.Error("static template error", "err", err) 85 + os.Exit(-1) 86 + } 87 + return http.FS(fsys) 88 + }()) 89 + 90 + e.GET("/static/*", echo.WrapHandler(http.StripPrefix("/static/", staticHandler))) 91 + e.GET("/_health", srv.HandleHealthCheck) 92 + 93 + // basic static routes 94 + e.GET("/robots.txt", echo.WrapHandler(staticHandler)) 95 + e.GET("/favicon.ico", echo.WrapHandler(staticHandler)) 96 + 97 + // actual content 98 + e.GET("/", srv.WebHome) 99 + e.GET("/query", srv.WebQuery) 100 + //e.GET("/at://:rkey", srv.WebRedirect) 101 + e.GET("/account/:atid", srv.WebAccount) 102 + e.GET("/at/:atid", srv.WebRepo) 103 + e.GET("/at/:atid/:collection", srv.WebRepoCollection) 104 + e.GET("/at/:atid/:collection/:rkey", srv.WebRepoRecord) 105 + 106 + // Start the server 107 + slog.Info("starting server", "bind", httpAddress) 108 + go func() { 109 + if err := srv.httpd.ListenAndServe(); err != nil { 110 + if !errors.Is(err, http.ErrServerClosed) { 111 + slog.Error("HTTP server shutting down unexpectedly", "err", err) 112 + } 113 + } 114 + }() 115 + 116 + // Wait for a signal to exit. 117 + slog.Info("registering OS exit signal handler") 118 + quit := make(chan struct{}) 119 + exitSignals := make(chan os.Signal, 1) 120 + signal.Notify(exitSignals, syscall.SIGINT, syscall.SIGTERM) 121 + go func() { 122 + sig := <-exitSignals 123 + slog.Info("received OS exit signal", "signal", sig) 124 + 125 + // Shut down the HTTP server 126 + if err := srv.Shutdown(); err != nil { 127 + slog.Error("HTTP server shutdown error", "err", err) 128 + } 129 + 130 + // Trigger the return that causes an exit. 131 + close(quit) 132 + }() 133 + <-quit 134 + slog.Info("graceful shutdown complete") 135 + return nil 136 + } 137 + 138 + type GenericStatus struct { 139 + Daemon string `json:"daemon"` 140 + Status string `json:"status"` 141 + Message string `json:"msg,omitempty"` 142 + } 143 + 144 + func (srv *Server) errorHandler(err error, c echo.Context) { 145 + code := http.StatusInternalServerError 146 + var errorMessage string 147 + if he, ok := err.(*echo.HTTPError); ok { 148 + code = he.Code 149 + errorMessage = fmt.Sprintf("%s", he.Message) 150 + } 151 + if code >= 500 { 152 + slog.Warn("astrolabe-http-internal-error", "err", err) 153 + } 154 + data := pongo2.Context{ 155 + "statusCode": code, 156 + "errorMessage": errorMessage, 157 + } 158 + if !c.Response().Committed { 159 + c.Render(code, "error.html", data) 160 + } 161 + } 162 + 163 + func (srv *Server) ServeHTTP(rw http.ResponseWriter, req *http.Request) { 164 + srv.echo.ServeHTTP(rw, req) 165 + } 166 + 167 + func (srv *Server) Shutdown() error { 168 + slog.Info("shutting down") 169 + 170 + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 171 + defer cancel() 172 + 173 + return srv.httpd.Shutdown(ctx) 174 + } 175 + 176 + func (s *Server) HandleHealthCheck(c echo.Context) error { 177 + return c.JSON(200, GenericStatus{Status: "ok", Daemon: "astrolabe"}) 178 + }
cmd/astrolabe/static/apple-touch-icon.png

This is a binary file and will not be displayed.

cmd/astrolabe/static/default-avatar.png

This is a binary file and will not be displayed.

cmd/astrolabe/static/favicon-16x16.png

This is a binary file and will not be displayed.

cmd/astrolabe/static/favicon-32x32.png

This is a binary file and will not be displayed.

cmd/astrolabe/static/favicon.ico

This is a binary file and will not be displayed.

cmd/astrolabe/static/favicon.png

This is a binary file and will not be displayed.

+9
cmd/astrolabe/static/robots.txt
··· 1 + # Hello Friends! 2 + # If you are considering bulk or automated crawling, you may want to look in 3 + # to our protocol (API), including a firehose of updates. See: https://atproto.com/ 4 + 5 + # By default, may crawl anything on this domain. HTTP 429 ("backoff") status 6 + # codes are used for rate-limiting. Up to a handful concurrent requests should 7 + # be ok. 8 + User-Agent: * 9 + Allow: /
+24
cmd/astrolabe/templates/account.html
··· 1 + {% extends "base.html" %} 2 + 3 + {% block main_content %} 4 + <h2 style="font-family: monospace;">{{ atid }}</h2> 5 + 6 + <table> 7 + <tbody> 8 + <tr><td>DID</td> 9 + <td><code>{{ ident.DID }}</code></td> 10 + <tr><td>Handle</td> 11 + <td><code>{{ ident.Handle }}</code></td> 12 + <tr><td>PDS</td> 13 + <td><code>{{ ident.PDSEndpoint() }}</code></td> 14 + </tbody> 15 + </table> 16 + 17 + <p><a href="/at/{{ atid }}">Repo Index</a> 18 + <p><a href="{{ ident.PDSEndpoint() }}/xrpc/com.atproto.sync.getRepo?did={{ ident.DID }}">Repo CAR Export</a> 19 + 20 + {% if didDocJSON %} 21 + <h4>DID Document</h4> 22 + <pre style="padding: 1em;">{{ didDocJSON }}</pre> 23 + {% endif %} 24 + {% endblock %}
+40
cmd/astrolabe/templates/base.html
··· 1 + <!doctype html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="utf-8"> 5 + <meta name="referrer" content="origin-when-cross-origin"> 6 + <meta name="viewport" content="width=device-width, initial-scale=1"> 7 + <meta name="color-scheme" content="light dark" /> 8 + <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.zinc.min.css" /> 9 + <style> 10 + html { position: relative; min-height: 100%; height: auto; } 11 + body { margin-bottom: 3em; } 12 + body > nav { background-color: var(--pico-muted-border-color); } 13 + body > footer { position: absolute; bottom: 0px; padding: 2em; background-color: var(--pico-muted-border-color); } 14 + thead th { font-weight: bold; } 15 + main article { margin: 2.5rem 0; padding: 2rem; } 16 + code { background: none; } 17 + td { padding: 0; } 18 + </style> 19 + <meta name="generator" name="astrolabe"> 20 + <title>{% block head_title %}astrolabe{% endblock %}</title> 21 + </head> 22 + <body> 23 + <nav class="container-fluid"> 24 + <ul> 25 + <li><a href="/"><strong>astrolabe</strong></a></li> 26 + </ul> 27 + <form action="/query" method="get" style="width: 80%;"> 28 + <input type="text" name="q" placeholder="at://..." {% if uri %}value="{{ uri }}"{% endif %} style="margin: 0.5em;"> 29 + </form> 30 + <ul> 31 + <li><a href="https://github.com/bluesky-social/indigo/tree/main/cmd/astrolabe">Code</a></li> 32 + </ul> 33 + </nav> 34 + 35 + <main class="container"> 36 + {% block main_content %}Base Template{% endblock %} 37 + </main> 38 + 39 + </body> 40 + </html>
+14
cmd/astrolabe/templates/error.html
··· 1 + {% extends "base.html" %} 2 + 3 + {% block head_title %}Error {{ statusCode }} - astrolabe{% endblock %} 4 + 5 + {% block main_content %} 6 + <br> 7 + <center> 8 + <h1 style="font-size: 8em;">{{ statusCode }}</h1> 9 + <h2 style="font-size: 3em;">Error!</h2> 10 + {% if errorMessage %} 11 + <p><code>{{ errorMessage }}</code></p> 12 + {% endif %} 13 + </center> 14 + {% endblock %}
+17
cmd/astrolabe/templates/home.html
··· 1 + {% extends "base.html" %} 2 + 3 + {% block main_content %} 4 + <h2>astrolabe: AT Protocol Repository Browser</h2> 5 + 6 + <p>This is a tool for browsing <a href="https://atproto.com">AT Protocol</a> ("atproto") repositories and records. You can enter an account identifier or full URI to view content as JSON. It works by fetching data directly from account PDS instances: the data itself is not hosted by this service. 7 + 8 + <p>Examples: 9 + <ul> 10 + <li>Account Handle: <code><a href="/account/bnewbold.net">bnewbold.net</a></code></li> 11 + <li>Account DID: <code><a href="/account/did:plc:44ybard66vv44zksje25o7dz">did:plc:44ybard66vv44zksje25o7dz</a></code></li> 12 + <li>Collection: <code><a href="/at/bnewbold.net/app.bsky.feed.post">at://bnewbold.net/app.bsky.feed.post</a></code></li> 13 + <li>Record: <code><a href="/at/did:plc:44ybard66vv44zksje25o7dz/app.bsky.actor.profile/self">at://bnewbold.net/app.bsky.actor.profile/self</a></code></li> 14 + </ul> 15 + 16 + <p>Other similar services include <a href="https://atproto-browser.vercel.app/">atproto-browser.vercel.app</a> and <a href="https://pdsls.dev/">pdsls.dev</a>. 17 + {% endblock %}
+16
cmd/astrolabe/templates/repo.html
··· 1 + {% extends "base.html" %} 2 + 3 + {% block main_content %} 4 + <h2 style="font-family: monospace;">at://{{ atid }}</h2> 5 + 6 + <h4>Index</h4> 7 + <table> 8 + <tbody> 9 + <tr><td><code><a href="/account/{{ atid }}">..</a></code></td> 10 + {% for collection in collections %} 11 + <tr><td><code><a href="/at/{{ atid }}/{{ collection }}">{{ collection }}/</a></code></td> 12 + {% endfor %} 13 + </tbody> 14 + </table> 15 + 16 + {% endblock %}
+19
cmd/astrolabe/templates/repo_collection.html
··· 1 + {% extends "base.html" %} 2 + 3 + {% block main_content %} 4 + <h2 style="font-family: monospace;">at://{{ atid }}/{{ collection }}</h2> 5 + 6 + <h4>Index</h4> 7 + <table> 8 + <tbody> 9 + <tr><td><code><a href="/at/{{ atid }}">..</a></code></td> 10 + {% for uri in recordURIs %} 11 + <tr><td><code><a href="/at/{{ atid }}/{{ collection }}/{{ uri.RecordKey() }}">{{ collection }}/{{ uri.RecordKey() }}</a></code></td> 12 + {% endfor %} 13 + {% if cursor != "" %} 14 + <tr><td><code><a href="/at/{{ atid }}/{{ collection }}?cursor={{ cursor }}">[more]</a></code></td> 15 + {% endif %} 16 + </tbody> 17 + </table> 18 + 19 + {% endblock %}
+12
cmd/astrolabe/templates/repo_record.html
··· 1 + {% extends "base.html" %} 2 + 3 + {% block main_content %} 4 + <h2 style="font-family: monospace;">at://{{ atid }}/{{ collection }}/{{ rkey }}</h2> 5 + <p><a href="/at/{{ atid }}/{{ collection }}">Back to Collection</a> 6 + 7 + {% if recordJSON %} 8 + <h4>Record JSON</h4> 9 + <pre style="padding: 1em;">{{ recordJSON }}</pre> 10 + {% endif %} 11 + 12 + {% endblock %}