go scratch code for atproto

import athome from indigo

+891
+72
cmd/athome/README.md
··· 1 + 2 + athome: Public Bluesky Web Home 3 + =============================== 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 + ```
+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 %}