my fork of the bluesky client

rebased embedr (#3511)

* skeleton of embedr service, based on bskyweb

* embedr container setup

* builds on this branch

* actual routes

* fix embedr go:embed

* tweak embedr dockerfile

* progress on embedr

* fix path params

* tweaks to build process

* try to get embedr dockerfile to install embed deps

* build this branch

* updates to match sam's output HTML

* try to unbreak embedr dockerfile

* small embedr tweak

* docker hack

* get embed.js copied over to embedr

* don't x-frame-options for embed.bsky.app

* bskyembed: remove a console.log

* use html/template for golang snippet generation

* simplify embedr API fetches

* missing file

* Rm console.log fully

---------

Co-authored-by: Dan Abramov <dan.abramov@gmail.com>

authored by bnewbold.net Dan Abramov and committed by GitHub 58842d03 196dd3a8

+57
.github/workflows/build-and-push-embedr-aws.yaml
··· 1 + name: build-and-push-embedr-aws 2 + on: 3 + push: 4 + branches: 5 + - main 6 + - bnewbold/embedr 7 + - bnewbold/embedr-rebase 8 + 9 + env: 10 + REGISTRY: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_REGISTRY }} 11 + USERNAME: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_USERNAME }} 12 + PASSWORD: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_PASSWORD }} 13 + IMAGE_NAME: embed 14 + 15 + jobs: 16 + embedr-container-aws: 17 + if: github.repository == 'bluesky-social/social-app' 18 + runs-on: ubuntu-latest 19 + permissions: 20 + contents: read 21 + packages: write 22 + id-token: write 23 + 24 + steps: 25 + - name: Checkout repository 26 + uses: actions/checkout@v3 27 + 28 + - name: Setup Docker buildx 29 + uses: docker/setup-buildx-action@v1 30 + 31 + - name: Log into registry ${{ env.REGISTRY }} 32 + uses: docker/login-action@v2 33 + with: 34 + registry: ${{ env.REGISTRY }} 35 + username: ${{ env.USERNAME}} 36 + password: ${{ env.PASSWORD }} 37 + 38 + - name: Extract Docker metadata 39 + id: meta 40 + uses: docker/metadata-action@v4 41 + with: 42 + images: | 43 + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 44 + tags: | 45 + type=sha,enable=true,priority=100,prefix=,suffix=,format=long 46 + 47 + - name: Build and push Docker image 48 + id: build-and-push 49 + uses: docker/build-push-action@v4 50 + with: 51 + context: . 52 + push: ${{ github.event_name != 'pull_request' }} 53 + file: ./Dockerfile.embedr 54 + tags: ${{ steps.meta.outputs.tags }} 55 + labels: ${{ steps.meta.outputs.labels }} 56 + cache-from: type=gha 57 + cache-to: type=gha,mode=max
+78
Dockerfile.embedr
··· 1 + FROM golang:1.21-bullseye AS build-env 2 + 3 + WORKDIR /usr/src/social-app 4 + 5 + ENV DEBIAN_FRONTEND=noninteractive 6 + 7 + # Node 8 + ENV NODE_VERSION=18 9 + ENV NVM_DIR=/usr/share/nvm 10 + 11 + # Go 12 + ENV GODEBUG="netdns=go" 13 + ENV GOOS="linux" 14 + ENV GOARCH="amd64" 15 + ENV CGO_ENABLED=1 16 + ENV GOEXPERIMENT="loopvar" 17 + 18 + COPY . . 19 + 20 + # 21 + # Generate the JavaScript webpack. NOTE: this will change 22 + # 23 + RUN mkdir --parents $NVM_DIR && \ 24 + wget \ 25 + --output-document=/tmp/nvm-install.sh \ 26 + https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh && \ 27 + bash /tmp/nvm-install.sh 28 + 29 + RUN \. "$NVM_DIR/nvm.sh" && \ 30 + nvm install $NODE_VERSION && \ 31 + nvm use $NODE_VERSION && \ 32 + npm install --global yarn && \ 33 + yarn && \ 34 + cd bskyembed && yarn install --frozen-lockfile && cd .. && \ 35 + yarn intl:build && \ 36 + yarn build-embed 37 + 38 + # DEBUG 39 + RUN find ./bskyweb/embedr-static && find ./bskyweb/embedr-templates && find ./bskyembed/dist 40 + 41 + # hack around issue with empty directory and go:embed 42 + RUN touch bskyweb/static/js/empty.txt 43 + 44 + # 45 + # Generate the embedr Go binary. 46 + # 47 + RUN cd bskyweb/ && \ 48 + go mod download && \ 49 + go mod verify 50 + 51 + RUN cd bskyweb/ && \ 52 + go build \ 53 + -v \ 54 + -trimpath \ 55 + -tags timetzdata \ 56 + -o /embedr \ 57 + ./cmd/embedr 58 + 59 + FROM debian:bullseye-slim 60 + 61 + ENV GODEBUG=netdns=go 62 + ENV TZ=Etc/UTC 63 + ENV DEBIAN_FRONTEND=noninteractive 64 + 65 + RUN apt-get update && apt-get install --yes \ 66 + dumb-init \ 67 + ca-certificates 68 + 69 + ENTRYPOINT ["dumb-init", "--"] 70 + 71 + WORKDIR /embedr 72 + COPY --from=build-env /embedr /usr/bin/embedr 73 + 74 + CMD ["/usr/bin/embedr"] 75 + 76 + LABEL org.opencontainers.image.source=https://github.com/bluesky-social/social-app 77 + LABEL org.opencontainers.image.description="embed.bsky.app Web App" 78 + LABEL org.opencontainers.image.licenses=MIT
+6
Makefile
··· 13 13 yarn intl:build 14 14 yarn build-web 15 15 16 + .PHONY: build-web-embed 17 + build-web-embed: ## Compile web embed bundle, copy to bskyweb/embedr* directories 18 + yarn intl:build 19 + yarn build-embed 20 + 16 21 .PHONY: test 17 22 test: ## Run all tests 18 23 NODE_ENV=test EXPO_PUBLIC_ENV=test yarn test ··· 28 33 .PHONY: deps 29 34 deps: ## Installs dependent libs using 'yarn install' 30 35 yarn install --frozen-lockfile 36 + cd bskyembed && yarn install --frozen-lockfile 31 37 32 38 .PHONY: nvm-setup 33 39 nvm-setup: ## Use NVM to install and activate node+yarn
-3
bskyembed/src/screens/post.tsx
··· 17 17 }) 18 18 19 19 const uri = `at://${window.location.pathname.slice('/embed/'.length)}` 20 - 21 - console.log(uri) 22 - 23 20 if (!uri) { 24 21 throw new Error('No uri in path') 25 22 }
+6
bskyweb/.gitignore
··· 3 3 4 4 # Don't check in the binary. 5 5 /bskyweb 6 + /embedr 6 7 7 8 # Don't accidentally commit JS-generated code 8 9 static/js/*.js 9 10 static/js/*.map 10 11 static/js/*.js.LICENSE.txt 12 + static/js/empty.txt 11 13 templates/scripts.html 12 14 templates/*-embed.html 13 15 static/embed/*.html 14 16 static/embed/assets/*.js 15 17 static/embed/assets/*.css 18 + embedr-static/post-*.js 19 + embedr-static/post-*.css 20 + embedr-static/index-*.js 21 + embedr-static/polyfills-*.js 16 22 17 23 # Don't ignore this file 18 24 !.gitignore
+5
bskyweb/Makefile
··· 14 14 .PHONY: build 15 15 build: ## Build all executables 16 16 go build ./cmd/bskyweb 17 + go build ./cmd/embedr 17 18 18 19 .PHONY: test 19 20 test: ## Run all tests ··· 43 44 .PHONY: run-dev-bskyweb 44 45 run-dev-bskyweb: .env ## Runs 'bskyweb' for local dev 45 46 GOLOG_LOG_LEVEL=info go run ./cmd/bskyweb serve 47 + 48 + .PHONY: run-dev-embedr 49 + run-dev-embedr: .env ## Runs 'embedr' for local dev 50 + GOLOG_LOG_LEVEL=info go run ./cmd/embedr serve
+52
bskyweb/README.embed.md
··· 1 + 2 + ## oEmbed 3 + 4 + <https://oembed.com/> 5 + 6 + * URL scheme: `https://bsky.app/profile/*/post/*` 7 + * API endpoint: `https://embed.bsky.app/oembed` 8 + 9 + Request params: 10 + 11 + - `url` (required): support both AT-URI and bsky.app URL 12 + - `maxwidth` (optional): [220..550], 325 is default 13 + - `maxheight` (not supported!) 14 + - `format` (optional): only `json` supported 15 + 16 + Response format: 17 + 18 + - `type` (required): "rich" 19 + - `version` (required): "1.0" 20 + - `author_name` (optional): display name 21 + - `author_url` (optional): profile URL 22 + - `provider_name` (optional): "Bluesky Social" 23 + - `provider_url` (optional): "https://bsky.app" 24 + - `cache_age` (optional, integer seconds): 86400 (24 hours) (?) 25 + - `width` (required): ? 26 + - `height` (required): ? 27 + 28 + Not used: 29 + 30 + - title (optional): A text title, describing the resource. 31 + - thumbnail_url (optional): A URL to a thumbnail image representing the resource. The thumbnail must respect any maxwidth and maxheight parameters. If this parameter is present, thumbnail_width and thumbnail_height must also be present. 32 + - thumbnail_width (optional): The width of the optional thumbnail. If this parameter is present, thumbnail_url and thumbnail_height must also be present. 33 + - thumbnail_height (optional): The height of the optional thumbnail. If this parameter is present, thumbnail_url and thumbnail_width must also be present. 34 + 35 + Only `json` is supported; `xml` is a 501. 36 + 37 + ``` 38 + <link rel="alternate" type="application/json+oembed" href="https://embed.bsky.app/oembed?format=json&url=https://bsky.app/profile/bnewbold.net/post/abc123" /> 39 + ``` 40 + 41 + 42 + ## iframe URL 43 + 44 + `https://embed.bsky.app/embed/<did>/app.bsky.feed.post/<rkey>` 45 + `https://embed.bsky.app/static/embed.js` 46 + 47 + ``` 48 + <blockquote class="bluesky-post" data-lang="en" data-align="center"> 49 + <p lang="en" dir="ltr">{{ post-text }}</p> 50 + &mdash; US Department of the Interior (@Interior) <a href="https://twitter.com/Interior/status/463440424141459456?ref_src=twsrc%5Etfw">May 5, 2014</a> 51 + </blockquote> 52 + ```
+1
bskyweb/cmd/embedr/.gitignore
··· 1 + /bskyweb
+207
bskyweb/cmd/embedr/handlers.go
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "fmt" 7 + "net/http" 8 + "net/url" 9 + "strconv" 10 + "strings" 11 + 12 + appbsky "github.com/bluesky-social/indigo/api/bsky" 13 + "github.com/bluesky-social/indigo/atproto/syntax" 14 + 15 + "github.com/labstack/echo/v4" 16 + ) 17 + 18 + var ErrPostNotFound = errors.New("post not found") 19 + var ErrPostNotPublic = errors.New("post is not publicly accessible") 20 + 21 + func (srv *Server) getBlueskyPost(ctx context.Context, did syntax.DID, rkey syntax.RecordKey) (*appbsky.FeedDefs_PostView, error) { 22 + 23 + // fetch the post post (with extra context) 24 + uri := fmt.Sprintf("at://%s/app.bsky.feed.post/%s", did, rkey) 25 + tpv, err := appbsky.FeedGetPostThread(ctx, srv.xrpcc, 1, 0, uri) 26 + if err != nil { 27 + log.Warnf("failed to fetch post: %s\t%v", uri, err) 28 + // TODO: detect 404, specifically? 29 + return nil, ErrPostNotFound 30 + } 31 + 32 + if tpv.Thread.FeedDefs_BlockedPost != nil { 33 + return nil, ErrPostNotPublic 34 + } else if tpv.Thread.FeedDefs_ThreadViewPost.Post == nil { 35 + return nil, ErrPostNotFound 36 + } 37 + 38 + postView := tpv.Thread.FeedDefs_ThreadViewPost.Post 39 + for _, label := range postView.Author.Labels { 40 + if label.Src == postView.Author.Did && label.Val == "!no-unauthenticated" { 41 + return nil, ErrPostNotPublic 42 + } 43 + } 44 + return postView, nil 45 + } 46 + 47 + func (srv *Server) WebHome(c echo.Context) error { 48 + return c.Render(http.StatusOK, "home.html", nil) 49 + } 50 + 51 + type OEmbedResponse struct { 52 + Type string `json:"type"` 53 + Version string `json:"version"` 54 + AuthorName string `json:"author_name,omitempty"` 55 + AuthorURL string `json:"author_url,omitempty"` 56 + ProviderName string `json:"provider_url,omitempty"` 57 + CacheAge int `json:"cache_age,omitempty"` 58 + Width int `json:"width,omitempty"` 59 + Height *int `json:"height,omitempty"` 60 + HTML string `json:"html,omitempty"` 61 + } 62 + 63 + func (srv *Server) parseBlueskyURL(ctx context.Context, raw string) (*syntax.ATURI, error) { 64 + 65 + if raw == "" { 66 + return nil, fmt.Errorf("empty url") 67 + } 68 + 69 + // first try simple AT-URI 70 + uri, err := syntax.ParseATURI(raw) 71 + if nil == err { 72 + return &uri, nil 73 + } 74 + 75 + // then try bsky.app post URL 76 + u, err := url.Parse(raw) 77 + if err != nil { 78 + return nil, err 79 + } 80 + if u.Hostname() != "bsky.app" { 81 + return nil, fmt.Errorf("only bsky.app URLs currently supported") 82 + } 83 + pathParts := strings.Split(u.Path, "/") // NOTE: pathParts[0] will be empty string 84 + if len(pathParts) != 5 || pathParts[1] != "profile" || pathParts[3] != "post" { 85 + return nil, fmt.Errorf("only bsky.app post URLs currently supported") 86 + } 87 + atid, err := syntax.ParseAtIdentifier(pathParts[2]) 88 + if err != nil { 89 + return nil, err 90 + } 91 + rkey, err := syntax.ParseRecordKey(pathParts[4]) 92 + if err != nil { 93 + return nil, err 94 + } 95 + var did syntax.DID 96 + if atid.IsHandle() { 97 + ident, err := srv.dir.Lookup(ctx, *atid) 98 + if err != nil { 99 + return nil, err 100 + } 101 + did = ident.DID 102 + } else { 103 + did, err = atid.AsDID() 104 + if err != nil { 105 + return nil, err 106 + } 107 + } 108 + 109 + // TODO: don't really need to re-parse here, if we had test coverage 110 + aturi, err := syntax.ParseATURI(fmt.Sprintf("at://%s/app.bsky.feed.post/%s", did, rkey)) 111 + if err != nil { 112 + return nil, err 113 + } else { 114 + return &aturi, nil 115 + } 116 + } 117 + 118 + func (srv *Server) WebOEmbed(c echo.Context) error { 119 + formatParam := c.QueryParam("format") 120 + if formatParam != "" && formatParam != "json" { 121 + return c.String(http.StatusNotImplemented, "Unsupported oEmbed format: "+formatParam) 122 + } 123 + 124 + // TODO: do we actually do something with width? 125 + width := 550 126 + maxWidthParam := c.QueryParam("maxwidth") 127 + if maxWidthParam != "" { 128 + maxWidthInt, err := strconv.Atoi(maxWidthParam) 129 + if err != nil || maxWidthInt < 220 || maxWidthInt > 550 { 130 + return c.String(http.StatusBadRequest, "Invalid maxwidth (expected integer between 220 and 550)") 131 + } 132 + width = maxWidthInt 133 + } 134 + // NOTE: maxheight ignored 135 + 136 + aturi, err := srv.parseBlueskyURL(c.Request().Context(), c.QueryParam("url")) 137 + if err != nil { 138 + return c.String(http.StatusBadRequest, fmt.Sprintf("Expected 'url' to be bsky.app URL or AT-URI: %v", err)) 139 + } 140 + if aturi.Collection() != syntax.NSID("app.bsky.feed.post") { 141 + return c.String(http.StatusNotImplemented, "Only posts (app.bsky.feed.post records) can be embedded currently") 142 + } 143 + did, err := aturi.Authority().AsDID() 144 + if err != nil { 145 + return err 146 + } 147 + 148 + post, err := srv.getBlueskyPost(c.Request().Context(), did, aturi.RecordKey()) 149 + if err == ErrPostNotFound { 150 + return c.String(http.StatusNotFound, fmt.Sprintf("%v", err)) 151 + } else if err == ErrPostNotPublic { 152 + return c.String(http.StatusForbidden, fmt.Sprintf("%v", err)) 153 + } else if err != nil { 154 + return c.String(http.StatusInternalServerError, fmt.Sprintf("%v", err)) 155 + } 156 + 157 + html, err := srv.postEmbedHTML(post) 158 + if err != nil { 159 + return c.String(http.StatusInternalServerError, fmt.Sprintf("%v", err)) 160 + } 161 + data := OEmbedResponse{ 162 + Type: "rich", 163 + Version: "1.0", 164 + AuthorName: "@" + post.Author.Handle, 165 + AuthorURL: fmt.Sprintf("https://bsky.app/profile/%s", post.Author.Handle), 166 + ProviderName: "Bluesky Social", 167 + CacheAge: 86400, 168 + Width: width, 169 + Height: nil, 170 + HTML: html, 171 + } 172 + if post.Author.DisplayName != nil { 173 + data.AuthorName = fmt.Sprintf("%s (@%s)", *post.Author.DisplayName, post.Author.Handle) 174 + } 175 + return c.JSON(http.StatusOK, data) 176 + } 177 + 178 + func (srv *Server) WebPostEmbed(c echo.Context) error { 179 + 180 + // sanity check arguments. don't 4xx, just let app handle if not expected format 181 + rkeyParam := c.Param("rkey") 182 + rkey, err := syntax.ParseRecordKey(rkeyParam) 183 + if err != nil { 184 + return c.String(http.StatusBadRequest, fmt.Sprintf("Invalid RecordKey: %v", err)) 185 + } 186 + didParam := c.Param("did") 187 + did, err := syntax.ParseDID(didParam) 188 + if err != nil { 189 + return c.String(http.StatusBadRequest, fmt.Sprintf("Invalid DID: %v", err)) 190 + } 191 + _ = rkey 192 + _ = did 193 + 194 + // NOTE: this request was't really necessary; the JS will do the same fetch 195 + /* 196 + postView, err := srv.getBlueskyPost(ctx, did, rkey) 197 + if err == ErrPostNotFound { 198 + return c.String(http.StatusNotFound, fmt.Sprintf("%v", err)) 199 + } else if err == ErrPostNotPublic { 200 + return c.String(http.StatusForbidden, fmt.Sprintf("%v", err)) 201 + } else if err != nil { 202 + return c.String(http.StatusInternalServerError, fmt.Sprintf("%v", err)) 203 + } 204 + */ 205 + 206 + return c.Render(http.StatusOK, "postEmbed.html", nil) 207 + }
+60
bskyweb/cmd/embedr/main.go
··· 1 + package main 2 + 3 + import ( 4 + "os" 5 + 6 + _ "github.com/joho/godotenv/autoload" 7 + 8 + logging "github.com/ipfs/go-log" 9 + "github.com/urfave/cli/v2" 10 + ) 11 + 12 + var log = logging.Logger("embedr") 13 + 14 + func init() { 15 + logging.SetAllLoggers(logging.LevelDebug) 16 + //logging.SetAllLoggers(logging.LevelWarn) 17 + } 18 + 19 + func main() { 20 + run(os.Args) 21 + } 22 + 23 + func run(args []string) { 24 + 25 + app := cli.App{ 26 + Name: "embedr", 27 + Usage: "web server for embed.bsky.app post embeds", 28 + } 29 + 30 + app.Commands = []*cli.Command{ 31 + &cli.Command{ 32 + Name: "serve", 33 + Usage: "run the server", 34 + Action: serve, 35 + Flags: []cli.Flag{ 36 + &cli.StringFlag{ 37 + Name: "appview-host", 38 + Usage: "method, hostname, and port of PDS instance", 39 + Value: "https://public.api.bsky.app", 40 + EnvVars: []string{"ATP_APPVIEW_HOST"}, 41 + }, 42 + &cli.StringFlag{ 43 + Name: "http-address", 44 + Usage: "Specify the local IP/port to bind to", 45 + Required: false, 46 + Value: ":8100", 47 + EnvVars: []string{"HTTP_ADDRESS"}, 48 + }, 49 + &cli.BoolFlag{ 50 + Name: "debug", 51 + Usage: "Enable debug mode", 52 + Value: false, 53 + Required: false, 54 + EnvVars: []string{"DEBUG"}, 55 + }, 56 + }, 57 + }, 58 + } 59 + app.RunAndExitOnError() 60 + }
+16
bskyweb/cmd/embedr/render.go
··· 1 + package main 2 + 3 + import ( 4 + "html/template" 5 + "io" 6 + 7 + "github.com/labstack/echo/v4" 8 + ) 9 + 10 + type Template struct { 11 + templates *template.Template 12 + } 13 + 14 + func (t *Template) Render(w io.Writer, name string, data interface{}, c echo.Context) error { 15 + return t.templates.ExecuteTemplate(w, name, data) 16 + }
+236
bskyweb/cmd/embedr/server.go
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "fmt" 7 + "html/template" 8 + "io/fs" 9 + "net/http" 10 + "os" 11 + "os/signal" 12 + "strings" 13 + "syscall" 14 + "time" 15 + 16 + "github.com/bluesky-social/indigo/atproto/identity" 17 + "github.com/bluesky-social/indigo/util/cliutil" 18 + "github.com/bluesky-social/indigo/xrpc" 19 + "github.com/bluesky-social/social-app/bskyweb" 20 + 21 + "github.com/klauspost/compress/gzhttp" 22 + "github.com/klauspost/compress/gzip" 23 + "github.com/labstack/echo/v4" 24 + "github.com/labstack/echo/v4/middleware" 25 + "github.com/urfave/cli/v2" 26 + ) 27 + 28 + type Server struct { 29 + echo *echo.Echo 30 + httpd *http.Server 31 + xrpcc *xrpc.Client 32 + dir identity.Directory 33 + } 34 + 35 + func serve(cctx *cli.Context) error { 36 + debug := cctx.Bool("debug") 37 + httpAddress := cctx.String("http-address") 38 + appviewHost := cctx.String("appview-host") 39 + 40 + // Echo 41 + e := echo.New() 42 + 43 + // create a new session (no auth) 44 + xrpcc := &xrpc.Client{ 45 + Client: cliutil.NewHttpClient(), 46 + Host: appviewHost, 47 + } 48 + 49 + // httpd 50 + var ( 51 + httpTimeout = 2 * time.Minute 52 + httpMaxHeaderBytes = 2 * (1024 * 1024) 53 + gzipMinSizeBytes = 1024 * 2 54 + gzipCompressionLevel = gzip.BestSpeed 55 + gzipExceptMIMETypes = []string{"image/png"} 56 + ) 57 + 58 + // Wrap the server handler in a gzip handler to compress larger responses. 59 + gzipHandler, err := gzhttp.NewWrapper( 60 + gzhttp.MinSize(gzipMinSizeBytes), 61 + gzhttp.CompressionLevel(gzipCompressionLevel), 62 + gzhttp.ExceptContentTypes(gzipExceptMIMETypes), 63 + ) 64 + if err != nil { 65 + return err 66 + } 67 + 68 + // 69 + // server 70 + // 71 + server := &Server{ 72 + echo: e, 73 + xrpcc: xrpcc, 74 + dir: identity.DefaultDirectory(), 75 + } 76 + 77 + // Create the HTTP server. 78 + server.httpd = &http.Server{ 79 + Handler: gzipHandler(server), 80 + Addr: httpAddress, 81 + WriteTimeout: httpTimeout, 82 + ReadTimeout: httpTimeout, 83 + MaxHeaderBytes: httpMaxHeaderBytes, 84 + } 85 + 86 + e.HideBanner = true 87 + 88 + tmpl := &Template{ 89 + templates: template.Must(template.ParseFS(bskyweb.EmbedrTemplateFS, "embedr-templates/*.html")), 90 + } 91 + e.Renderer = tmpl 92 + e.HTTPErrorHandler = server.errorHandler 93 + 94 + e.IPExtractor = echo.ExtractIPFromXFFHeader() 95 + 96 + // SECURITY: Do not modify without due consideration. 97 + e.Use(middleware.SecureWithConfig(middleware.SecureConfig{ 98 + ContentTypeNosniff: "nosniff", 99 + // diable XFrameOptions; we're embedding here! 100 + HSTSMaxAge: 31536000, // 365 days 101 + // TODO: 102 + // ContentSecurityPolicy 103 + // XSSProtection 104 + })) 105 + e.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{ 106 + // Don't log requests for static content. 107 + Skipper: func(c echo.Context) bool { 108 + return strings.HasPrefix(c.Request().URL.Path, "/static") 109 + }, 110 + })) 111 + e.Use(middleware.RateLimiterWithConfig(middleware.RateLimiterConfig{ 112 + Skipper: middleware.DefaultSkipper, 113 + Store: middleware.NewRateLimiterMemoryStoreWithConfig( 114 + middleware.RateLimiterMemoryStoreConfig{ 115 + Rate: 10, // requests per second 116 + Burst: 30, // allow bursts 117 + ExpiresIn: 3 * time.Minute, // garbage collect entries older than 3 minutes 118 + }, 119 + ), 120 + IdentifierExtractor: func(ctx echo.Context) (string, error) { 121 + id := ctx.RealIP() 122 + return id, nil 123 + }, 124 + DenyHandler: func(c echo.Context, identifier string, err error) error { 125 + return c.String(http.StatusTooManyRequests, "Your request has been rate limited. Please try again later. Contact support@bsky.app if you believe this was a mistake.\n") 126 + }, 127 + })) 128 + 129 + // redirect trailing slash to non-trailing slash. 130 + // all of our current endpoints have no trailing slash. 131 + e.Use(middleware.RemoveTrailingSlashWithConfig(middleware.TrailingSlashConfig{ 132 + RedirectCode: http.StatusFound, 133 + })) 134 + 135 + // 136 + // configure routes 137 + // 138 + // static files 139 + staticHandler := http.FileServer(func() http.FileSystem { 140 + if debug { 141 + log.Debugf("serving static file from the local file system") 142 + return http.FS(os.DirFS("embedr-static")) 143 + } 144 + fsys, err := fs.Sub(bskyweb.EmbedrStaticFS, "embedr-static") 145 + if err != nil { 146 + log.Fatal(err) 147 + } 148 + return http.FS(fsys) 149 + }()) 150 + 151 + e.GET("/robots.txt", echo.WrapHandler(staticHandler)) 152 + e.GET("/ips-v4", echo.WrapHandler(staticHandler)) 153 + e.GET("/ips-v6", echo.WrapHandler(staticHandler)) 154 + e.GET("/.well-known/*", echo.WrapHandler(staticHandler)) 155 + e.GET("/security.txt", func(c echo.Context) error { 156 + return c.Redirect(http.StatusMovedPermanently, "/.well-known/security.txt") 157 + }) 158 + e.GET("/static/*", echo.WrapHandler(http.StripPrefix("/static/", staticHandler)), func(next echo.HandlerFunc) echo.HandlerFunc { 159 + return func(c echo.Context) error { 160 + path := c.Request().URL.Path 161 + maxAge := 1 * (60 * 60) // default is 1 hour 162 + 163 + // Cache javascript and images files for 1 week, which works because 164 + // they're always versioned (e.g. /static/js/main.64c14927.js) 165 + if strings.HasPrefix(path, "/static/js/") || strings.HasPrefix(path, "/static/images/") { 166 + maxAge = 7 * (60 * 60 * 24) // 1 week 167 + } 168 + 169 + c.Response().Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%d", maxAge)) 170 + return next(c) 171 + } 172 + }) 173 + 174 + // actual routes 175 + e.GET("/", server.WebHome) 176 + e.GET("/iframe-resize.js", echo.WrapHandler(staticHandler)) 177 + e.GET("/embed.js", echo.WrapHandler(staticHandler)) 178 + e.GET("/oembed", server.WebOEmbed) 179 + e.GET("/embed/:did/app.bsky.feed.post/:rkey", server.WebPostEmbed) 180 + 181 + // Start the server. 182 + log.Infof("starting server address=%s", httpAddress) 183 + go func() { 184 + if err := server.httpd.ListenAndServe(); err != nil { 185 + if !errors.Is(err, http.ErrServerClosed) { 186 + log.Errorf("HTTP server shutting down unexpectedly: %s", err) 187 + } 188 + } 189 + }() 190 + 191 + // Wait for a signal to exit. 192 + log.Info("registering OS exit signal handler") 193 + quit := make(chan struct{}) 194 + exitSignals := make(chan os.Signal, 1) 195 + signal.Notify(exitSignals, syscall.SIGINT, syscall.SIGTERM) 196 + go func() { 197 + sig := <-exitSignals 198 + log.Infof("received OS exit signal: %s", sig) 199 + 200 + // Shut down the HTTP server. 201 + if err := server.Shutdown(); err != nil { 202 + log.Errorf("HTTP server shutdown error: %s", err) 203 + } 204 + 205 + // Trigger the return that causes an exit. 206 + close(quit) 207 + }() 208 + <-quit 209 + log.Infof("graceful shutdown complete") 210 + return nil 211 + } 212 + 213 + func (srv *Server) ServeHTTP(rw http.ResponseWriter, req *http.Request) { 214 + srv.echo.ServeHTTP(rw, req) 215 + } 216 + 217 + func (srv *Server) Shutdown() error { 218 + log.Info("shutting down") 219 + 220 + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 221 + defer cancel() 222 + 223 + return srv.httpd.Shutdown(ctx) 224 + } 225 + 226 + func (srv *Server) errorHandler(err error, c echo.Context) { 227 + code := http.StatusInternalServerError 228 + if he, ok := err.(*echo.HTTPError); ok { 229 + code = he.Code 230 + } 231 + c.Logger().Error(err) 232 + data := map[string]interface{}{ 233 + "statusCode": code, 234 + } 235 + c.Render(code, "error.html", data) 236 + }
+71
bskyweb/cmd/embedr/snippet.go
··· 1 + package main 2 + 3 + import ( 4 + "bytes" 5 + "fmt" 6 + "html/template" 7 + 8 + appbsky "github.com/bluesky-social/indigo/api/bsky" 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + ) 11 + 12 + func (srv *Server) postEmbedHTML(postView *appbsky.FeedDefs_PostView) (string, error) { 13 + // ensure that there isn't an injection from the URI 14 + aturi, err := syntax.ParseATURI(postView.Uri) 15 + if err != nil { 16 + log.Error("bad AT-URI in reponse", "aturi", aturi, "err", err) 17 + return "", err 18 + } 19 + 20 + post, ok := postView.Record.Val.(*appbsky.FeedPost) 21 + if !ok { 22 + log.Error("bad post record value", "err", err) 23 + return "", err 24 + } 25 + 26 + const tpl = `<blockquote class="bluesky-embed" data-bluesky-uri="{{ .PostURI }}" data-bluesky-cid="{{ .PostCID }}"><p{{ if .PostLang }} lang="{{ .PostLang }}"{{ end }}>{{ .PostText }}</p>&mdash; {{ .PostAuthor }} {{ .PostIndexedAt }}</blockquote><script async src="{{ .WidgetURL }}" charset="utf-8"></script>` 27 + 28 + t, err := template.New("snippet").Parse(tpl) 29 + if err != nil { 30 + log.Error("template parse error", "err", err) 31 + return "", err 32 + } 33 + 34 + var lang string 35 + if len(post.Langs) > 0 { 36 + lang = post.Langs[0] 37 + } 38 + var authorName string 39 + if postView.Author.DisplayName != nil { 40 + authorName = fmt.Sprintf("%s (@%s)", *postView.Author.DisplayName, postView.Author.Handle) 41 + } else { 42 + authorName = fmt.Sprintf("@%s", postView.Author.Handle) 43 + } 44 + fmt.Println(postView.Uri) 45 + fmt.Println(fmt.Sprintf("%s", postView.Uri)) 46 + data := struct { 47 + PostURI template.URL 48 + PostCID string 49 + PostLang string 50 + PostText string 51 + PostAuthor string 52 + PostIndexedAt string 53 + WidgetURL template.URL 54 + }{ 55 + PostURI: template.URL(postView.Uri), 56 + PostCID: postView.Cid, 57 + PostLang: lang, 58 + PostText: post.Text, 59 + PostAuthor: authorName, 60 + PostIndexedAt: postView.IndexedAt, // TODO: createdAt? 61 + WidgetURL: template.URL("https://embed.bsky.app/static/embed.js"), 62 + } 63 + 64 + var buf bytes.Buffer 65 + err = t.Execute(&buf, data) 66 + if err != nil { 67 + log.Error("template parse error", "err", err) 68 + return "", err 69 + } 70 + return buf.String(), nil 71 + }
+4
bskyweb/embedr-static/.well-known/security.txt
··· 1 + Contact: mailto:security@bsky.app 2 + Preferred-Languages: en 3 + Canonical: https://bsky.app/.well-known/security.txt 4 + Acknowledgements: https://github.com/bluesky-social/atproto/blob/main/CONTRIBUTORS.md
+1
bskyweb/embedr-static/embed.js
··· 1 + /* embed javascript widget will go here */
bskyweb/embedr-static/favicon-16x16.png

This is a binary file and will not be displayed.

bskyweb/embedr-static/favicon-32x32.png

This is a binary file and will not be displayed.

bskyweb/embedr-static/favicon.png

This is a binary file and will not be displayed.

+1
bskyweb/embedr-static/iframe-resize.js
··· 1 + /* script to resize embed ifame would go here? */
+30
bskyweb/embedr-static/ips-v4
··· 1 + 13.59.225.103/32 2 + 3.18.47.21/32 3 + 18.191.104.94/32 4 + 3.129.134.255/32 5 + 3.129.237.113/32 6 + 3.138.56.230/32 7 + 44.218.10.163/32 8 + 54.89.116.251/32 9 + 44.217.166.202/32 10 + 54.208.221.149/32 11 + 54.166.110.54/32 12 + 54.208.146.65/32 13 + 3.129.234.15/32 14 + 3.138.168.48/32 15 + 3.23.53.192/32 16 + 52.14.89.53/32 17 + 3.18.126.246/32 18 + 3.136.69.4/32 19 + 3.22.137.152/32 20 + 3.132.247.113/32 21 + 3.141.186.104/32 22 + 18.222.43.214/32 23 + 3.14.35.197/32 24 + 3.23.182.70/32 25 + 18.224.144.69/32 26 + 3.129.98.29/32 27 + 3.130.134.20/32 28 + 3.17.197.213/32 29 + 18.223.234.21/32 30 + 3.20.248.177/32
bskyweb/embedr-static/ips-v6

This is a binary file and will not be displayed.

+9
bskyweb/embedr-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: /
+1
bskyweb/embedr-templates/error.html
··· 1 + placeholder!
+8
bskyweb/embedr-templates/home.html
··· 1 + <html> 2 + <head> 3 + </head> 4 + <body> 5 + <h1>embed.bsky.app homepage</h1> 6 + <p>could redirect to bsky.app? or show a "create embed" widget? 7 + </body> 8 + </html>
+1
bskyweb/embedr-templates/oembed.html
··· 1 + oembed JSON response will go here
+1
bskyweb/embedr-templates/postEmbed.html
··· 1 + embed post HTML will go here
+3
bskyweb/static.go
··· 4 4 5 5 //go:embed static/* 6 6 var StaticFS embed.FS 7 + 8 + //go:embed embedr-static/* 9 + var EmbedrStaticFS embed.FS
+3
bskyweb/templates.go
··· 4 4 5 5 //go:embed templates/* 6 6 var TemplateFS embed.FS 7 + 8 + //go:embed embedr-templates/* 9 + var EmbedrTemplateFS embed.FS
+1 -1
package.json
··· 20 20 "build-ios": "yarn use-build-number-with-bump eas build -p ios", 21 21 "build-android": "yarn use-build-number-with-bump eas build -p android", 22 22 "build": "yarn use-build-number-with-bump eas build", 23 - "build-embed": "cd bskyembed && yarn build && cd .. && node ./scripts/post-embed-build.js", 23 + "build-embed": "cd bskyembed && yarn build && yarn build-snippet && cd .. && node ./scripts/post-embed-build.js", 24 24 "start": "expo start --dev-client", 25 25 "start:prod": "expo start --dev-client --no-dev --minify", 26 26 "clean-cache": "rm -rf node_modules/.cache/babel-loader/*",
+54 -38
scripts/post-embed-build.js
··· 1 - // const path = require('node:path') 2 - // const fs = require('node:fs') 1 + const path = require('node:path') 2 + const fs = require('node:fs') 3 3 4 - // const projectRoot = path.join(__dirname, '..') 4 + const projectRoot = path.join(__dirname, '..') 5 5 6 - // // copy embed assets to web-build 6 + // copy embed assets to embedr 7 7 8 - // const embedAssetSource = path.join( 9 - // projectRoot, 10 - // 'bskyembed', 11 - // 'dist', 12 - // 'static', 13 - // 'embed', 14 - // 'assets', 15 - // ) 8 + const embedAssetSource = path.join(projectRoot, 'bskyembed', 'dist', 'static') 16 9 17 - // const embedAssetDest = path.join( 18 - // projectRoot, 19 - // 'web-build', 20 - // 'static', 21 - // 'embed', 22 - // 'assets', 23 - // ) 10 + const embedAssetDest = path.join(projectRoot, 'bskyweb', 'embedr-static') 24 11 25 - // fs.cpSync(embedAssetSource, embedAssetDest, {recursive: true}) 12 + fs.cpSync(embedAssetSource, embedAssetDest, {recursive: true}) 26 13 27 - // // copy entrypoint(s) to web-build 14 + const embedEmbedJSSource = path.join( 15 + projectRoot, 16 + 'bskyembed', 17 + 'dist', 18 + 'embed.js', 19 + ) 28 20 29 - // // additional entrypoints will need more work, but this'll do for now 30 - // const embedHtmlSource = path.join( 31 - // projectRoot, 32 - // 'bskyembed', 33 - // 'dist', 34 - // 'index.html', 35 - // ) 21 + const embedEmbedJSDest = path.join( 22 + projectRoot, 23 + 'bskyweb', 24 + 'embedr-static', 25 + 'embed.js', 26 + ) 36 27 37 - // const embedHtmlDest = path.join( 38 - // projectRoot, 39 - // 'web-build', 40 - // 'static', 41 - // 'embed', 42 - // 'post.html', 43 - // ) 28 + fs.cpSync(embedEmbedJSSource, embedEmbedJSDest) 44 29 45 - // fs.copyFileSync(embedHtmlSource, embedHtmlDest) 30 + // copy entrypoint(s) to embedr 46 31 47 - // console.log(`Copied embed assets to web-build`) 32 + // additional entrypoints will need more work, but this'll do for now 33 + const embedHomeHtmlSource = path.join( 34 + projectRoot, 35 + 'bskyembed', 36 + 'dist', 37 + 'index.html', 38 + ) 48 39 49 - console.log('post-embed-build.js - waiting for embedr!') 40 + const embedHomeHtmlDest = path.join( 41 + projectRoot, 42 + 'bskyweb', 43 + 'embedr-templates', 44 + 'home.html', 45 + ) 46 + 47 + fs.copyFileSync(embedHomeHtmlSource, embedHomeHtmlDest) 48 + 49 + const embedPostHtmlSource = path.join( 50 + projectRoot, 51 + 'bskyembed', 52 + 'dist', 53 + 'post.html', 54 + ) 55 + 56 + const embedPostHtmlDest = path.join( 57 + projectRoot, 58 + 'bskyweb', 59 + 'embedr-templates', 60 + 'postEmbed.html', 61 + ) 62 + 63 + fs.copyFileSync(embedPostHtmlSource, embedPostHtmlDest) 64 + 65 + console.log(`Copied embed assets to embedr`)