this repo has no description
0
fork

Configure Feed

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

remove side projects (#1172)

moved a couple personal side-projects to a separate git repo.

leaving READMEs with links for now.

authored by

bnewbold and committed by
GitHub
4286d9cc 09f107c1

+2 -1699
-1
.gitignore
··· 21 21 test-coverage.out 22 22 23 23 # executables 24 - /athome 25 24 /beemo 26 25 /bigsky 27 26 /bluepages
+1 -35
cmd/astrolabe/README.md
··· 2 2 astrolabe: basic atproto network data explorer 3 3 ============================================== 4 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 5 + **NOTE: this proof-of-concept has moved to [cobalt](https://tangled.org/@bnewbold.net/cobalt/tree/main/cmd/astrolabe)**
-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 %}
+1 -68
cmd/athome/README.md
··· 2 2 athome: Public Bluesky Web Home 3 3 =============================== 4 4 5 - ```text 6 - me: can we have public web interface? 7 - mom: we have public web interface at home 8 - public web interface at home: 9 - ``` 10 - 11 - 1. run this web service somewhere 12 - 2. point one or more handle domains to it (CNAME or reverse proxy) 13 - 3. serves up profile and feed for that account only 14 - 4. fetches data from public bsky app view API 15 - 16 - ⚠️ This is a fun little proof-of-concept ⚠️ 17 - 18 - Not all post features are rendered, has not been hardened against clever Unicode tricks, etc. 19 - 20 - 21 - ## Running athome 22 - 23 - The recommended way to run `athome` is behind a `caddy` HTTPS server which does automatic on-demand SSL certificate registration (using Let's Encrypt). 24 - 25 - Build and run `athome`: 26 - 27 - go build ./cmd/athome 28 - 29 - # will listen on :8200 by default 30 - ./athome serve 31 - 32 - Create a `Caddyfile`: 33 - 34 - ``` 35 - { 36 - on_demand_tls { 37 - interval 1h 38 - burst 8 39 - } 40 - } 41 - 42 - :443 { 43 - reverse_proxy localhost:8200 44 - tls YOUREMAIL@example.com { 45 - on_demand 46 - } 47 - } 48 - ``` 49 - 50 - Run `caddy`: 51 - 52 - caddy run 53 - 54 - 55 - ## Configuring a Handle 56 - 57 - The easiest way, if there is no existing web service on the handle domain, is to get the handle resolution working with the DNS TXT record option, then point the domain itself to a `athome` service using an A/AAAA or CNAME record. 58 - 59 - If there is an existing web service (eg, a blog), then handle resolution can be set up using either the DNS TXT mechanism or HTTP `/.well-known/` mechanism. Then HTTP proxy paths starting `/bsky` to an `athome` service. 60 - 61 - Here is an nginx config snippet demonstrating HTTP proxying: 62 - 63 - ``` 64 - location /bsky { 65 - // in theory https:// should work, on default port? 66 - proxy_pass http://athome.example.com:8200; 67 - proxy_set_header X-Real-IP $remote_addr; 68 - proxy_set_header Host $http_host; 69 - proxy_set_header X-Forwarded-Proto https; 70 - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 71 - } 72 - ``` 5 + **NOTE: this proof-of-concept has moved to [cobalt](https://tangled.org/@bnewbold.net/cobalt/tree/main/cmd/athome)**
-174
cmd/athome/handlers.go
··· 1 - package main 2 - 3 - import ( 4 - "fmt" 5 - "net/http" 6 - "strings" 7 - 8 - appbsky "github.com/bluesky-social/indigo/api/bsky" 9 - "github.com/bluesky-social/indigo/atproto/syntax" 10 - 11 - "github.com/flosch/pongo2/v6" 12 - "github.com/labstack/echo/v4" 13 - ) 14 - 15 - func (srv *Server) reqHandle(c echo.Context) syntax.Handle { 16 - host := c.Request().Host 17 - host = strings.SplitN(host, ":", 2)[0] 18 - handle, err := syntax.ParseHandle(host) 19 - if err != nil { 20 - slog.Warn("host is not a valid handle, fallback to default", "hostname", host) 21 - handle = srv.defaultHandle 22 - } 23 - return handle 24 - } 25 - 26 - func (srv *Server) WebHome(c echo.Context) error { 27 - return c.Redirect(http.StatusFound, "/bsky") 28 - } 29 - 30 - func (srv *Server) WebRepoCar(c echo.Context) error { 31 - handle := srv.reqHandle(c) 32 - ident, err := srv.dir.LookupHandle(c.Request().Context(), handle) 33 - if err != nil { 34 - return err 35 - } 36 - return c.Redirect(http.StatusFound, ident.PDSEndpoint()+"/xrpc/com.atproto.sync.getRepo?did="+ident.DID.String()) 37 - } 38 - 39 - func (srv *Server) WebPost(c echo.Context) error { 40 - ctx := c.Request().Context() 41 - req := c.Request() 42 - data := pongo2.Context{} 43 - handle := srv.reqHandle(c) 44 - // TODO: parse rkey 45 - rkey := c.Param("rkey") 46 - 47 - // requires two fetches: first fetch profile (!) 48 - pv, err := appbsky.ActorGetProfile(ctx, srv.xrpcc, handle.String()) 49 - if err != nil { 50 - slog.Warn("failed to fetch handle", "handle", handle, "err", err) 51 - // TODO: only if "not found" 52 - return echo.NewHTTPError(404, fmt.Sprintf("handle not found: %s", handle)) 53 - } 54 - did := pv.Did 55 - data["did"] = did 56 - 57 - // then fetch the post thread (with extra context) 58 - aturi := fmt.Sprintf("at://%s/app.bsky.feed.post/%s", did, rkey) 59 - tpv, err := appbsky.FeedGetPostThread(ctx, srv.xrpcc, 8, 8, aturi) 60 - if err != nil { 61 - slog.Warn("failed to fetch post", "aturi", aturi, "err", err) 62 - // TODO: only if "not found" 63 - return echo.NewHTTPError(404, "post not found: %s", handle) 64 - } 65 - data["postView"] = tpv.Thread.FeedDefs_ThreadViewPost 66 - data["requestURI"] = fmt.Sprintf("https://%s%s", req.Host, req.URL.Path) 67 - return c.Render(http.StatusOK, "post.html", data) 68 - } 69 - 70 - func (srv *Server) WebProfile(c echo.Context) error { 71 - ctx := c.Request().Context() 72 - data := pongo2.Context{} 73 - handle := srv.reqHandle(c) 74 - 75 - pv, err := appbsky.ActorGetProfile(ctx, srv.xrpcc, handle.String()) 76 - if err != nil { 77 - slog.Warn("failed to fetch handle", "handle", handle, "err", err) 78 - // TODO: only if "not found" 79 - return echo.NewHTTPError(404, fmt.Sprintf("handle not found: %s", handle)) 80 - } else { 81 - req := c.Request() 82 - data["profileView"] = pv 83 - data["requestURI"] = fmt.Sprintf("https://%s%s", req.Host, req.URL.Path) 84 - } 85 - did := pv.Did 86 - data["did"] = did 87 - 88 - af, err := appbsky.FeedGetAuthorFeed(ctx, srv.xrpcc, handle.String(), "", "posts_no_replies", false, 100) 89 - if err != nil { 90 - slog.Warn("failed to fetch author feed", "handle", handle, "err", err) 91 - // TODO: show some error? 92 - } else { 93 - data["authorFeed"] = af.Feed 94 - //slog.Warn("author feed", "feed", af.Feed) 95 - } 96 - 97 - return c.Render(http.StatusOK, "profile.html", data) 98 - } 99 - 100 - // https://medium.com/@etiennerouzeaud/a-rss-feed-valid-in-go-edfc22e410c7 101 - type Item struct { 102 - Title string `xml:"title"` 103 - Link string `xml:"link"` 104 - Description string `xml:"description"` 105 - PubDate string `xml:"pubDate"` 106 - } 107 - 108 - type rss struct { 109 - Version string `xml:"version,attr"` 110 - Description string `xml:"channel>description"` 111 - Link string `xml:"channel>link"` 112 - Title string `xml:"channel>title"` 113 - 114 - Item []Item `xml:"channel>item"` 115 - } 116 - 117 - func (srv *Server) WebRepoRSS(c echo.Context) error { 118 - ctx := c.Request().Context() 119 - handle := srv.reqHandle(c) 120 - 121 - pv, err := appbsky.ActorGetProfile(ctx, srv.xrpcc, handle.String()) 122 - if err != nil { 123 - slog.Warn("failed to fetch handle", "handle", handle, "err", err) 124 - // TODO: only if "not found" 125 - return echo.NewHTTPError(404, fmt.Sprintf("handle not found: %s", handle)) 126 - //return err 127 - } 128 - 129 - af, err := appbsky.FeedGetAuthorFeed(ctx, srv.xrpcc, handle.String(), "", "posts_no_replies", false, 30) 130 - if err != nil { 131 - slog.Warn("failed to fetch author feed", "handle", handle, "err", err) 132 - return err 133 - } 134 - 135 - posts := []Item{} 136 - for _, p := range af.Feed { 137 - // only include own posts in RSS 138 - if p.Post.Author.Did != pv.Did { 139 - continue 140 - } 141 - aturi, err := syntax.ParseATURI(p.Post.Uri) 142 - if err != nil { 143 - return err 144 - } 145 - rec := p.Post.Record.Val.(*appbsky.FeedPost) 146 - // only top-level posts in RSS 147 - if rec.Reply != nil { 148 - continue 149 - } 150 - posts = append(posts, Item{ 151 - Title: "@" + handle.String() + " post", 152 - Link: fmt.Sprintf("https://%s/bsky/post/%s", handle, aturi.RecordKey().String()), 153 - Description: rec.Text, 154 - PubDate: rec.CreatedAt, 155 - }) 156 - } 157 - 158 - title := "@" + handle.String() 159 - if pv.DisplayName != nil { 160 - title = title + " - " + *pv.DisplayName 161 - } 162 - desc := "" 163 - if pv.Description != nil { 164 - desc = *pv.Description 165 - } 166 - feed := &rss{ 167 - Version: "2.0", 168 - Description: desc, 169 - Link: fmt.Sprintf("https://%s/bsky", handle.String()), 170 - Title: title, 171 - Item: posts, 172 - } 173 - return c.XML(http.StatusOK, feed) 174 - }
-72
cmd/athome/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: "athome", 30 - Usage: "public web interface to bluesky account 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: "appview-host", 41 - Usage: "method, hostname, and port of AppView instance", 42 - Value: "https://api.bsky.app", 43 - EnvVars: []string{"ATP_APPVIEW_HOST"}, 44 - }, 45 - &cli.StringFlag{ 46 - Name: "bind", 47 - Usage: "Specify the local IP/port to bind to", 48 - Required: false, 49 - Value: ":8200", 50 - EnvVars: []string{"ATHOME_BIND"}, 51 - }, 52 - &cli.BoolFlag{ 53 - Name: "debug", 54 - Usage: "Enable debug mode", 55 - Value: false, 56 - Required: false, 57 - EnvVars: []string{"DEBUG"}, 58 - }, 59 - }, 60 - }, 61 - &cli.Command{ 62 - Name: "version", 63 - Usage: "print version", 64 - Action: func(cctx *cli.Context) error { 65 - fmt.Println(version) 66 - return nil 67 - }, 68 - }, 69 - } 70 - 71 - return app.Run(args) 72 - }
-85
cmd/athome/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 - }
-191
cmd/athome/service.go
··· 1 - package main 2 - 3 - import ( 4 - "context" 5 - "embed" 6 - "errors" 7 - "io/fs" 8 - "net/http" 9 - "os" 10 - "os/signal" 11 - "syscall" 12 - "time" 13 - 14 - "github.com/bluesky-social/indigo/atproto/identity" 15 - "github.com/bluesky-social/indigo/atproto/syntax" 16 - "github.com/bluesky-social/indigo/util" 17 - "github.com/bluesky-social/indigo/xrpc" 18 - 19 - "github.com/flosch/pongo2/v6" 20 - "github.com/labstack/echo-contrib/echoprometheus" 21 - "github.com/labstack/echo/v4" 22 - "github.com/labstack/echo/v4/middleware" 23 - slogecho "github.com/samber/slog-echo" 24 - "github.com/urfave/cli/v2" 25 - ) 26 - 27 - //go:embed static/* 28 - var StaticFS embed.FS 29 - 30 - type Server struct { 31 - echo *echo.Echo 32 - httpd *http.Server 33 - dir identity.Directory // TODO: unused? 34 - xrpcc *xrpc.Client 35 - defaultHandle syntax.Handle 36 - } 37 - 38 - func serve(cctx *cli.Context) error { 39 - debug := cctx.Bool("debug") 40 - httpAddress := cctx.String("bind") 41 - appviewHost := cctx.String("appview-host") 42 - 43 - dh, err := syntax.ParseHandle("atproto.com") 44 - if err != nil { 45 - return err 46 - } 47 - 48 - xrpcc := &xrpc.Client{ 49 - Client: util.RobustHTTPClient(), 50 - Host: appviewHost, 51 - // Headers: version 52 - } 53 - e := echo.New() 54 - 55 - // httpd 56 - var ( 57 - httpTimeout = 1 * time.Minute 58 - httpMaxHeaderBytes = 1 * (1024 * 1024) 59 - ) 60 - 61 - srv := &Server{ 62 - echo: e, 63 - xrpcc: xrpcc, 64 - dir: identity.DefaultDirectory(), 65 - defaultHandle: dh, 66 - } 67 - srv.httpd = &http.Server{ 68 - Handler: srv, 69 - Addr: httpAddress, 70 - WriteTimeout: httpTimeout, 71 - ReadTimeout: httpTimeout, 72 - MaxHeaderBytes: httpMaxHeaderBytes, 73 - } 74 - 75 - e.HideBanner = true 76 - e.Use(slogecho.New(slog)) 77 - e.Use(middleware.Recover()) 78 - e.Use(echoprometheus.NewMiddleware("athome")) 79 - e.Use(middleware.BodyLimit("64M")) 80 - e.HTTPErrorHandler = srv.errorHandler 81 - e.Renderer = NewRenderer("templates/", &TemplateFS, debug) 82 - e.Use(middleware.SecureWithConfig(middleware.SecureConfig{ 83 - ContentTypeNosniff: "nosniff", 84 - XFrameOptions: "SAMEORIGIN", 85 - HSTSMaxAge: 31536000, // 365 days 86 - // TODO: 87 - // ContentSecurityPolicy 88 - // XSSProtection 89 - })) 90 - 91 - // redirect trailing slash to non-trailing slash. 92 - // all of our current endpoints have no trailing slash. 93 - e.Use(middleware.RemoveTrailingSlashWithConfig(middleware.TrailingSlashConfig{ 94 - RedirectCode: http.StatusFound, 95 - })) 96 - 97 - staticHandler := http.FileServer(func() http.FileSystem { 98 - if debug { 99 - return http.FS(os.DirFS("static")) 100 - } 101 - fsys, err := fs.Sub(StaticFS, "static") 102 - if err != nil { 103 - slog.Error("static template error", "err", err) 104 - os.Exit(-1) 105 - } 106 - return http.FS(fsys) 107 - }()) 108 - 109 - e.GET("/static/*", echo.WrapHandler(http.StripPrefix("/static/", staticHandler))) 110 - e.GET("/_health", srv.HandleHealthCheck) 111 - e.GET("/metrics", echoprometheus.NewHandler()) 112 - 113 - // basic static routes 114 - e.GET("/robots.txt", echo.WrapHandler(staticHandler)) 115 - e.GET("/favicon.ico", echo.WrapHandler(staticHandler)) 116 - 117 - // actual content 118 - e.GET("/", srv.WebHome) 119 - e.GET("/bsky", srv.WebProfile) 120 - e.GET("/bsky/post/:rkey", srv.WebPost) 121 - e.GET("/bsky/repo.car", srv.WebRepoCar) 122 - e.GET("/bsky/rss.xml", srv.WebRepoRSS) 123 - 124 - // Start the server 125 - slog.Info("starting server", "bind", httpAddress) 126 - go func() { 127 - if err := srv.httpd.ListenAndServe(); err != nil { 128 - if !errors.Is(err, http.ErrServerClosed) { 129 - slog.Error("HTTP server shutting down unexpectedly", "err", err) 130 - } 131 - } 132 - }() 133 - 134 - // Wait for a signal to exit. 135 - slog.Info("registering OS exit signal handler") 136 - quit := make(chan struct{}) 137 - exitSignals := make(chan os.Signal, 1) 138 - signal.Notify(exitSignals, syscall.SIGINT, syscall.SIGTERM) 139 - go func() { 140 - sig := <-exitSignals 141 - slog.Info("received OS exit signal", "signal", sig) 142 - 143 - // Shut down the HTTP server 144 - if err := srv.Shutdown(); err != nil { 145 - slog.Error("HTTP server shutdown error", "err", err) 146 - } 147 - 148 - // Trigger the return that causes an exit. 149 - close(quit) 150 - }() 151 - <-quit 152 - slog.Info("graceful shutdown complete") 153 - return nil 154 - } 155 - 156 - type GenericStatus struct { 157 - Daemon string `json:"daemon"` 158 - Status string `json:"status"` 159 - Message string `json:"msg,omitempty"` 160 - } 161 - 162 - func (srv *Server) errorHandler(err error, c echo.Context) { 163 - code := http.StatusInternalServerError 164 - if he, ok := err.(*echo.HTTPError); ok { 165 - code = he.Code 166 - } 167 - if code >= 500 { 168 - slog.Warn("athome-http-internal-error", "err", err) 169 - } 170 - data := pongo2.Context{ 171 - "statusCode": code, 172 - } 173 - c.Render(code, "error.html", data) 174 - } 175 - 176 - func (srv *Server) ServeHTTP(rw http.ResponseWriter, req *http.Request) { 177 - srv.echo.ServeHTTP(rw, req) 178 - } 179 - 180 - func (srv *Server) Shutdown() error { 181 - slog.Info("shutting down") 182 - 183 - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 184 - defer cancel() 185 - 186 - return srv.httpd.Shutdown(ctx) 187 - } 188 - 189 - func (s *Server) HandleHealthCheck(c echo.Context) error { 190 - return c.JSON(200, GenericStatus{Status: "ok", Daemon: "athome"}) 191 - }
cmd/athome/static/apple-touch-icon.png

This is a binary file and will not be displayed.

cmd/athome/static/default-avatar.png

This is a binary file and will not be displayed.

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

This is a binary file and will not be displayed.

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

This is a binary file and will not be displayed.

cmd/athome/static/favicon.ico

This is a binary file and will not be displayed.

cmd/athome/static/favicon.png

This is a binary file and will not be displayed.

-9
cmd/athome/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: /
-53
cmd/athome/templates/base.html
··· 1 - <!DOCTYPE html> 2 - <html lang="en"> 3 - <head> 4 - <meta charset="UTF-8" /> 5 - <meta httpEquiv="X-UA-Compatible" content="IE=edge" /> 6 - <meta name="viewport" content="width=device-width, initial-scale=1.0"> 7 - <meta name="referrer" content="strict-origin-when-cross-origin"> 8 - <title>{%- block head_title -%}Bluesky{%- endblock -%}</title> 9 - 10 - <!-- Hello Humans! API docs at https://atproto.com --> 11 - 12 - <link rel="stylesheet" 13 - type="text/css" 14 - href="https://cdn.jsdelivr.net/npm/semantic-ui@2.5.0/dist/semantic.min.css" 15 - type="text/css" 16 - crossorigin="anonymous"> 17 - <!-- 18 - <link rel="preload" 19 - href="https://fonts.googleapis.com/css?family=Lato:400,700,400italic,700italic&subset=latin&display=swap" 20 - as="style"> 21 - <link rel="preload" 22 - href="https://cdn.jsdelivr.net/npm/fomantic-ui@2.8.6/dist/themes/default/assets/fonts/icons.woff2" 23 - as="font" 24 - type="font/woff2" 25 - crossorigin="anonymous"> 26 - --> 27 - <link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png"/> 28 - <link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png"/> 29 - <link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png"/> 30 - {% block html_head_extra -%}{%- endblock %} 31 - <meta name="application-name" name="Bluesky"> 32 - <meta name="generator" name="athome"> 33 - </head> 34 - <body> 35 - {%- block body_all %} 36 - <main class="ui main container" style="min-height: calc(100vh);"> 37 - <div class="ui grid"> 38 - <div class="fixed four wide column"> 39 - <div class="ui vertical text menu" style="padding-top: 2em; font-size: 1.3rem;"> 40 - <h2 style="color: blue;">{%- block sidebar_title -%}Bluesky{%- endblock -%}</h2> 41 - <a href="/bsky" class="item">Profile</a> 42 - <a href="/bsky/repo.car" class="item">repo.car</a> 43 - <a href="/bsky/rss.xml" class="item">RSS</a> 44 - </div> 45 - </div> 46 - <div class="ten wide column"> 47 - {% block main_content %}blank page{% endblock %} 48 - </div> 49 - </div> 50 - </main> 51 - {% endblock -%} 52 - </body> 53 - </html>
-12
cmd/athome/templates/error.html
··· 1 - {% extends "base.html" %} 2 - 3 - {% block head_title %}Error {{ statusCode }} - Bluesky{% 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 - <p>Sorry about that! The <a href="https://bluesky.statuspage.io/">Bluesky Status Page</a> might have more context. 11 - </center> 12 - {% endblock %}
-93
cmd/athome/templates/feed_macros.html
··· 1 - 2 - {% macro feed_post(feedItem, selfDID, primary) export %} 3 - {% if primary %} 4 - <div class="event" id="primary_post" style="background-color: lightyellow;"> 5 - {% else %} 6 - <div class="event"> 7 - {% endif %} 8 - <div class="label"> 9 - {% if feedItem.Post.Author.Avatar %} 10 - <img src="{{ feedItem.Post.Author.Avatar }}"> 11 - {% else %} 12 - <img src="/static/default-avatar.png"> 13 - {% endif %} 14 - </div> 15 - <div class="content" style="margin-top: 0px;"> 16 - {% if feedItem.Reason %} 17 - {{ feedItem.Reason.FeedDefs_ReasonRepost }} 18 - {% endif %} 19 - <div class="summary"> 20 - {% if feedItem.Post.Author.Did == selfDID %} 21 - <a href="/bsky" class="user"> 22 - {% else %} 23 - <a href="https://bsky.app/profile/{{ feedItem.Post.Author.Handle }}" class="user"> 24 - {% endif %} 25 - {% if feedItem.Post.Author.DisplayName %} 26 - <b>{{ feedItem.Post.Author.DisplayName }}</b> 27 - <span style="font-weight: normal;"> 28 - {% else %} 29 - <span> 30 - {% endif %} 31 - @{{ feedItem.Post.Author.Handle }}</span> 32 - </a> 33 - 34 - <div class="date"> 35 - {# TODO: relative time#} 36 - {# TODO: parse and fix link (custom filter?) #} 37 - {% if feedItem.Post.Author.Did == selfDID %} 38 - <a href="/bsky/post/{{ feedItem.Post.Uri|split:"/"|last }}">{{ feedItem.Post.IndexedAt }}</a> 39 - {% else %} 40 - <a href="https://bsky.app/profile/{{ feedItem.Post.Author.Handle }}/post/{{ feedItem.Post.Uri|split:"/"|last }}">{{ feedItem.Post.IndexedAt }}</a> 41 - {% endif %} 42 - </div> 43 - </div> 44 - <div class="extra text"> 45 - {{ feedItem.Post.Record.Val.Text }} 46 - {% if feedItem.Post.Embed and feedItem.Post.Embed.EmbedImages_View %} 47 - <div class="ui four cards"> 48 - {% for image in feedItem.Post.Embed.EmbedImages_View.Images %} 49 - <div class="card"> 50 - <div class="image"> 51 - <a href="{{ image.Fullsize }}"> 52 - <img alt="{{ image.Alt }}" src="{{ image.Thumb }}" style="width: 100%;"> 53 - </a> 54 - </div> 55 - </div> 56 - {% endfor %} 57 - </div> 58 - {% endif %} 59 - </div> 60 - <div class="meta"> 61 - <a class="like"><i class="reply icon"></i> {{ feedItem.Post.ReplyCount }}</a> 62 - <a class="like"><i class="comment outline icon"></i> {{ feedItem.Post.RepostCount }}</a> 63 - <a class="like"><i class="like outline icon"></i> {{ feedItem.Post.LikeCount }}</a> 64 - </div> 65 - </div> 66 - </div> 67 - 68 - {% if primary %} 69 - <script> 70 - window.onload = (event) => { 71 - setTimeout(function(){ 72 - document.getElementById("primary_post").scrollIntoView(true); 73 - }, 250); 74 - }; 75 - </script> 76 - {% endif %} 77 - {% endmacro %} 78 - 79 - {% macro thread_parents(post, selfDID, primary) export %} 80 - {% if post.Parent %} 81 - {{ thread_parents(post.Parent.FeedDefs_ThreadViewPost, selfDID, false) }} 82 - <div class="ui divider"></div> 83 - {% endif %} 84 - {{ feed_post(post, selfDID, primary) }} 85 - {% endmacro %} 86 - 87 - {% macro thread_children(post, selfDID) export %} 88 - {% for child in post.Replies %} 89 - <div class="ui divider"></div> 90 - {{ feed_post(child.FeedDefs_ThreadViewPost, selfDID) }} 91 - {{ thread_children(child.FeedDefs_ThreadViewPost, selfDID) }} 92 - {% endfor %} 93 - {% endmacro %}
-55
cmd/athome/templates/post.html
··· 1 - {% extends "base.html" %} 2 - 3 - {% block head_title %} 4 - {%- if postView.Post -%} 5 - @{{ postView.Post.Author.Handle }} on Bluesky 6 - {%- else -%} 7 - Bluesky 8 - {%- endif -%} 9 - {% endblock %} 10 - 11 - {% block sidebar_title %} 12 - {%- if postView.Post -%} 13 - {{ postView.Post.Author.Handle }} 14 - {%- else -%} 15 - Bluesky 16 - {%- endif -%} 17 - {% endblock %} 18 - 19 - {% block html_head_extra -%} 20 - {%- if postView.Post -%} 21 - <meta property="og:type" content="website"> 22 - <meta property="og:site_name" content="Bluesky Social"> 23 - {%- if requestURI %} 24 - <meta property="og:url" content="{{ requestURI }}"> 25 - {% endif -%} 26 - {%- if postView.Post.Author.DisplayName %} 27 - <meta property="og:title" content="{{ postView.Post.Author.DisplayName }} (@{{ postView.Post.Author.Handle }})"> 28 - {% else %} 29 - <meta property="og:title" content="@{{ postView.Post.Author.Handle }}"> 30 - {% endif -%} 31 - {%- if postView.Post.Record.Val.Text %} 32 - <meta name="description" content="{{ postView.Post.Record.Val.Text }}"> 33 - <meta property="og:description" content="{{ postView.Post.Record.Val.Text }}"> 34 - {% endif -%} 35 - {%- if imgThumbUrl %} 36 - <meta property="og:image" content="{{ imgThumbUrl }}"> 37 - <meta name="twitter:card" content="summary_large_image"> 38 - {%- elif postView.Post.Author.Avatar %} 39 - {# Don't use avatar image in cards; usually looks bad #} 40 - <meta name="twitter:card" content="summary"> 41 - {% endif %} 42 - <meta name="twitter:label1" content="Posted At"> 43 - <meta name="twitter:value1" content="{{ postView.Post.CreatedAt }}"> 44 - <meta name="twitter:site" content="@bluesky"> 45 - {% endif -%} 46 - {%- endblock %} 47 - 48 - {% block main_content %} 49 - {% import "feed_macros.html" feed_post, thread_parents, thread_children %} 50 - <div class="ui divider"></div> 51 - <div class="ui large feed"> 52 - {{ thread_parents(postView, did, true) }} 53 - {{ thread_children(postView) }} 54 - </div> 55 - {%- endblock %}
-75
cmd/athome/templates/profile.html
··· 1 - {% extends "base.html" %} 2 - 3 - {% block head_title %} 4 - {%- if profileView -%} 5 - @{{ profileView.Handle }} on Bluesky 6 - {%- else -%} 7 - Bluesky 8 - {%- endif -%} 9 - {% endblock %} 10 - 11 - {% block sidebar_title %} 12 - {%- if profileView -%} 13 - {{ profileView.Handle }} 14 - {%- else -%} 15 - Bluesky 16 - {%- endif -%} 17 - {% endblock %} 18 - 19 - {% block html_head_extra -%} 20 - {%- if profileView -%} 21 - <meta property="og:type" content="website"> 22 - <meta property="og:site_name" content="Bluesky Social"> 23 - {%- if requestURI %} 24 - <meta property="og:url" content="{{ requestURI }}"> 25 - {% endif -%} 26 - {%- if profileView.DisplayName %} 27 - <meta property="og:title" content="{{ profileView.DisplayName }} (@{{ profileView.Handle }})"> 28 - {% else %} 29 - <meta property="og:title" content="{{ profileView.Handle }}"> 30 - {% endif -%} 31 - {%- if profileView.Description %} 32 - <meta name="description" content="{{ profileView.Description }}"> 33 - <meta property="og:description" content="{{ profileView.Description }}"> 34 - {% endif -%} 35 - {%- if profileView.Banner %} 36 - <meta property="og:image" content="{{ profileView.Banner }}"> 37 - <meta name="twitter:card" content="summary_large_image"> 38 - {%- elif profileView.Avatar -%} 39 - {# Don't use avatar image in cards; usually looks bad #} 40 - <meta name="twitter:card" content="summary"> 41 - {% endif %} 42 - <meta name="twitter:label1" content="Account DID"> 43 - <meta name="twitter:value1" content="{{ profileView.Did }}"> 44 - <meta name="twitter:site" content="@bluesky"> 45 - {% endif -%} 46 - {%- endblock %} 47 - 48 - {% block main_content %} 49 - {% import "feed_macros.html" feed_post %} 50 - {% if profileView.Banner %} 51 - <img src="{{ profileView.Banner }}" style="width: 100%;"> 52 - <br> 53 - {% endif %} 54 - {% if profileView.DisplayName %} 55 - <h2>{{ profileView.DisplayName }}</h2> 56 - {% else %} 57 - <h2>{{ profileView.Handle}}</h2> 58 - {% endif %} 59 - <h3>@{{ profileView.Handle }}</h3> 60 - <p><code>{{ profileView.Did }}</code></p> 61 - <p> 62 - {{ profileView.FollowersCount }} followers | 63 - {{ profileView.FollowsCount }} following | 64 - {{ profileView.PostsCount }} posts 65 - </p> 66 - <p>{{ profileView.Description }}</p> 67 - 68 - <div class="ui divider"></div> 69 - <div class="ui large feed"> 70 - {% for feedItem in authorFeed %} 71 - {{ feed_post(feedItem, did) }} 72 - <div class="ui divider"></div> 73 - {% endfor %} 74 - </div> 75 - {%- endblock %}
-1
go.mod
··· 13 13 github.com/carlmjohnson/versioninfo v0.22.5 14 14 github.com/cockroachdb/pebble v1.1.2 15 15 github.com/dustinkirkland/golang-petname v0.0.0-20231002161417-6a283f1aaaf2 16 - github.com/flosch/pongo2/v6 v6.0.0 17 16 github.com/go-redis/cache/v9 v9.0.0 18 17 github.com/gocql/gocql v1.7.0 19 18 github.com/golang-jwt/jwt v3.2.2+incompatible
-2
go.sum
··· 83 83 github.com/dustinkirkland/golang-petname v0.0.0-20231002161417-6a283f1aaaf2/go.mod h1:8AuBTZBRSFqEYBPYULd+NN474/zZBLP+6WeT5S9xlAc= 84 84 github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= 85 85 github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= 86 - github.com/flosch/pongo2/v6 v6.0.0 h1:lsGru8IAzHgIAw6H2m4PCyleO58I40ow6apih0WprMU= 87 - github.com/flosch/pongo2/v6 v6.0.0/go.mod h1:CuDpFm47R0uGGE7z13/tTlt1Y6zdxvr2RLT5LJhsHEU= 88 86 github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= 89 87 github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= 90 88 github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=