about summary refs log tree commit diff
diff options
context:
space:
mode:
authorbnewbold <bnewbold@robocracy.org>2024-04-13 12:20:06 -0700
committerGitHub <noreply@github.com>2024-04-13 12:20:06 -0700
commit58842d03a95af014cb44c3495d109e3bb6731fde (patch)
tree08c27ccc28e05235e02440d9584788c78d654bc7
parent196dd3a8abdc4ebdd0a73c5f6afe2acca38d8efc (diff)
downloadvoidsky-58842d03a95af014cb44c3495d109e3bb6731fde.tar.zst
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>
-rw-r--r--.github/workflows/build-and-push-embedr-aws.yaml57
-rw-r--r--Dockerfile.embedr78
-rw-r--r--Makefile6
-rw-r--r--bskyembed/src/screens/post.tsx3
-rw-r--r--bskyweb/.gitignore6
-rw-r--r--bskyweb/Makefile5
-rw-r--r--bskyweb/README.embed.md52
-rw-r--r--bskyweb/cmd/embedr/.gitignore1
-rw-r--r--bskyweb/cmd/embedr/handlers.go207
-rw-r--r--bskyweb/cmd/embedr/main.go60
-rw-r--r--bskyweb/cmd/embedr/render.go16
-rw-r--r--bskyweb/cmd/embedr/server.go236
-rw-r--r--bskyweb/cmd/embedr/snippet.go71
-rw-r--r--bskyweb/embedr-static/.well-known/security.txt4
-rw-r--r--bskyweb/embedr-static/embed.js1
-rw-r--r--bskyweb/embedr-static/favicon-16x16.pngbin0 -> 1731 bytes
-rw-r--r--bskyweb/embedr-static/favicon-32x32.pngbin0 -> 2240 bytes
-rw-r--r--bskyweb/embedr-static/favicon.pngbin0 -> 1412 bytes
-rw-r--r--bskyweb/embedr-static/iframe-resize.js1
-rw-r--r--bskyweb/embedr-static/ips-v430
-rw-r--r--bskyweb/embedr-static/ips-v60
-rw-r--r--bskyweb/embedr-static/robots.txt9
-rw-r--r--bskyweb/embedr-templates/error.html1
-rw-r--r--bskyweb/embedr-templates/home.html8
-rw-r--r--bskyweb/embedr-templates/oembed.html1
-rw-r--r--bskyweb/embedr-templates/postEmbed.html1
-rw-r--r--bskyweb/static.go3
-rw-r--r--bskyweb/templates.go3
-rw-r--r--package.json2
-rw-r--r--scripts/post-embed-build.js114
30 files changed, 923 insertions, 53 deletions
diff --git a/.github/workflows/build-and-push-embedr-aws.yaml b/.github/workflows/build-and-push-embedr-aws.yaml
new file mode 100644
index 000000000..f7f24af9f
--- /dev/null
+++ b/.github/workflows/build-and-push-embedr-aws.yaml
@@ -0,0 +1,57 @@
+name: build-and-push-embedr-aws
+on:
+  push:
+    branches:
+      - main
+      - bnewbold/embedr
+      - bnewbold/embedr-rebase
+
+env:
+  REGISTRY: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_REGISTRY }}
+  USERNAME: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_USERNAME }}
+  PASSWORD: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_PASSWORD }}
+  IMAGE_NAME: embed
+
+jobs:
+  embedr-container-aws:
+    if: github.repository == 'bluesky-social/social-app'
+    runs-on: ubuntu-latest
+    permissions:
+      contents: read
+      packages: write
+      id-token: write
+
+    steps:
+      - name: Checkout repository
+        uses: actions/checkout@v3
+
+      - name: Setup Docker buildx
+        uses: docker/setup-buildx-action@v1
+
+      - name: Log into registry ${{ env.REGISTRY }}
+        uses: docker/login-action@v2
+        with:
+          registry: ${{ env.REGISTRY }}
+          username: ${{ env.USERNAME}}
+          password: ${{ env.PASSWORD }}
+
+      - name: Extract Docker metadata
+        id: meta
+        uses: docker/metadata-action@v4
+        with:
+          images: |
+            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
+          tags: |
+            type=sha,enable=true,priority=100,prefix=,suffix=,format=long
+
+      - name: Build and push Docker image
+        id: build-and-push
+        uses: docker/build-push-action@v4
+        with:
+          context: .
+          push: ${{ github.event_name != 'pull_request' }}
+          file: ./Dockerfile.embedr
+          tags: ${{ steps.meta.outputs.tags }}
+          labels: ${{ steps.meta.outputs.labels }}
+          cache-from: type=gha
+          cache-to: type=gha,mode=max
diff --git a/Dockerfile.embedr b/Dockerfile.embedr
new file mode 100644
index 000000000..c70251658
--- /dev/null
+++ b/Dockerfile.embedr
@@ -0,0 +1,78 @@
+FROM golang:1.21-bullseye AS build-env
+
+WORKDIR /usr/src/social-app
+
+ENV DEBIAN_FRONTEND=noninteractive
+
+# Node
+ENV NODE_VERSION=18
+ENV NVM_DIR=/usr/share/nvm
+
+# Go
+ENV GODEBUG="netdns=go"
+ENV GOOS="linux"
+ENV GOARCH="amd64"
+ENV CGO_ENABLED=1
+ENV GOEXPERIMENT="loopvar"
+
+COPY . .
+
+#
+# Generate the JavaScript webpack. NOTE: this will change
+#
+RUN mkdir --parents $NVM_DIR && \
+  wget \
+    --output-document=/tmp/nvm-install.sh \
+    https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh && \
+  bash /tmp/nvm-install.sh
+
+RUN \. "$NVM_DIR/nvm.sh" && \
+  nvm install $NODE_VERSION && \
+  nvm use $NODE_VERSION && \
+  npm install --global yarn && \
+  yarn && \
+  cd bskyembed && yarn install --frozen-lockfile && cd .. && \
+  yarn intl:build && \
+  yarn build-embed
+
+# DEBUG
+RUN find ./bskyweb/embedr-static && find ./bskyweb/embedr-templates && find ./bskyembed/dist
+
+# hack around issue with empty directory and go:embed
+RUN touch bskyweb/static/js/empty.txt
+
+#
+# Generate the embedr Go binary.
+#
+RUN cd bskyweb/ && \
+  go mod download && \
+  go mod verify
+
+RUN cd bskyweb/ && \
+  go build \
+    -v  \
+    -trimpath \
+    -tags timetzdata \
+    -o /embedr \
+    ./cmd/embedr
+
+FROM debian:bullseye-slim
+
+ENV GODEBUG=netdns=go
+ENV TZ=Etc/UTC
+ENV DEBIAN_FRONTEND=noninteractive
+
+RUN apt-get update && apt-get install --yes \
+  dumb-init \
+  ca-certificates
+
+ENTRYPOINT ["dumb-init", "--"]
+
+WORKDIR /embedr
+COPY --from=build-env /embedr /usr/bin/embedr
+
+CMD ["/usr/bin/embedr"]
+
+LABEL org.opencontainers.image.source=https://github.com/bluesky-social/social-app
+LABEL org.opencontainers.image.description="embed.bsky.app Web App"
+LABEL org.opencontainers.image.licenses=MIT
diff --git a/Makefile b/Makefile
index c90abb783..a40d37610 100644
--- a/Makefile
+++ b/Makefile
@@ -13,6 +13,11 @@ build-web: ## Compile web bundle, copy to bskyweb directory
 	yarn intl:build
 	yarn build-web
 
+.PHONY: build-web-embed
+build-web-embed: ## Compile web embed bundle, copy to bskyweb/embedr* directories
+	yarn intl:build
+	yarn build-embed
+
 .PHONY: test
 test: ## Run all tests
 	NODE_ENV=test EXPO_PUBLIC_ENV=test yarn test
@@ -28,6 +33,7 @@ lint: ## Run style checks and verify syntax
 .PHONY: deps
 deps: ## Installs dependent libs using 'yarn install'
 	yarn install --frozen-lockfile
+	cd bskyembed && yarn install --frozen-lockfile
 
 .PHONY: nvm-setup
 nvm-setup: ## Use NVM to install and activate node+yarn
diff --git a/bskyembed/src/screens/post.tsx b/bskyembed/src/screens/post.tsx
index 76c921540..365227cd4 100644
--- a/bskyembed/src/screens/post.tsx
+++ b/bskyembed/src/screens/post.tsx
@@ -17,9 +17,6 @@ const agent = new BskyAgent({
 })
 
 const uri = `at://${window.location.pathname.slice('/embed/'.length)}`
-
-console.log(uri)
-
 if (!uri) {
   throw new Error('No uri in path')
 }
diff --git a/bskyweb/.gitignore b/bskyweb/.gitignore
index ace9fbf51..fad122a28 100644
--- a/bskyweb/.gitignore
+++ b/bskyweb/.gitignore
@@ -3,16 +3,22 @@ test-coverage.out
 
 # Don't check in the binary.
 /bskyweb
+/embedr
 
 # Don't accidentally commit JS-generated code
 static/js/*.js
 static/js/*.map
 static/js/*.js.LICENSE.txt
+static/js/empty.txt
 templates/scripts.html
 templates/*-embed.html
 static/embed/*.html
 static/embed/assets/*.js
 static/embed/assets/*.css
+embedr-static/post-*.js
+embedr-static/post-*.css
+embedr-static/index-*.js
+embedr-static/polyfills-*.js
 
 # Don't ignore this file
 !.gitignore
diff --git a/bskyweb/Makefile b/bskyweb/Makefile
index 6f979fa84..bb2da525f 100644
--- a/bskyweb/Makefile
+++ b/bskyweb/Makefile
@@ -14,6 +14,7 @@ help: ## Print info about all commands
 .PHONY: build
 build: ## Build all executables
 	go build ./cmd/bskyweb
+	go build ./cmd/embedr
 
 .PHONY: test
 test: ## Run all tests
@@ -43,3 +44,7 @@ check: ## Compile everything, checking syntax (does not output binaries)
 .PHONY: run-dev-bskyweb
 run-dev-bskyweb: .env ## Runs 'bskyweb' for local dev
 	GOLOG_LOG_LEVEL=info go run ./cmd/bskyweb serve
+
+.PHONY: run-dev-embedr
+run-dev-embedr: .env ## Runs 'embedr' for local dev
+	GOLOG_LOG_LEVEL=info go run ./cmd/embedr serve
diff --git a/bskyweb/README.embed.md b/bskyweb/README.embed.md
new file mode 100644
index 000000000..8f19ef022
--- /dev/null
+++ b/bskyweb/README.embed.md
@@ -0,0 +1,52 @@
+
+## oEmbed
+
+<https://oembed.com/>
+
+* URL scheme: `https://bsky.app/profile/*/post/*`
+* API endpoint: `https://embed.bsky.app/oembed`
+
+Request params:
+
+- `url` (required): support both AT-URI and bsky.app URL
+- `maxwidth` (optional): [220..550], 325 is default
+- `maxheight` (not supported!)
+- `format` (optional): only `json` supported
+
+Response format:
+
+- `type` (required): "rich"
+- `version` (required): "1.0"
+- `author_name` (optional): display name
+- `author_url` (optional): profile URL
+- `provider_name` (optional): "Bluesky Social"
+- `provider_url` (optional): "https://bsky.app"
+- `cache_age` (optional, integer seconds): 86400 (24 hours) (?)
+- `width` (required): ?
+- `height` (required): ?
+
+Not used:
+
+- title (optional): A text title, describing the resource.
+- 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.
+- thumbnail_width (optional): The width of the optional thumbnail. If this parameter is present, thumbnail_url and thumbnail_height must also be present.
+- thumbnail_height (optional): The height of the optional thumbnail. If this parameter is present, thumbnail_url and thumbnail_width must also be present.
+
+Only `json` is supported; `xml` is a 501.
+
+```
+<link rel="alternate" type="application/json+oembed" href="https://embed.bsky.app/oembed?format=json&url=https://bsky.app/profile/bnewbold.net/post/abc123" />
+```
+
+
+## iframe URL
+
+`https://embed.bsky.app/embed/<did>/app.bsky.feed.post/<rkey>`
+`https://embed.bsky.app/static/embed.js`
+
+```
+<blockquote class="bluesky-post" data-lang="en" data-align="center">
+  <p lang="en" dir="ltr">{{ post-text }}</p>
+  &mdash; US Department of the Interior (@Interior) <a href="https://twitter.com/Interior/status/463440424141459456?ref_src=twsrc%5Etfw">May 5, 2014</a>
+</blockquote>
+```
diff --git a/bskyweb/cmd/embedr/.gitignore b/bskyweb/cmd/embedr/.gitignore
new file mode 100644
index 000000000..c810652a1
--- /dev/null
+++ b/bskyweb/cmd/embedr/.gitignore
@@ -0,0 +1 @@
+/bskyweb
diff --git a/bskyweb/cmd/embedr/handlers.go b/bskyweb/cmd/embedr/handlers.go
new file mode 100644
index 000000000..2ab72be44
--- /dev/null
+++ b/bskyweb/cmd/embedr/handlers.go
@@ -0,0 +1,207 @@
+package main
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"net/http"
+	"net/url"
+	"strconv"
+	"strings"
+
+	appbsky "github.com/bluesky-social/indigo/api/bsky"
+	"github.com/bluesky-social/indigo/atproto/syntax"
+
+	"github.com/labstack/echo/v4"
+)
+
+var ErrPostNotFound = errors.New("post not found")
+var ErrPostNotPublic = errors.New("post is not publicly accessible")
+
+func (srv *Server) getBlueskyPost(ctx context.Context, did syntax.DID, rkey syntax.RecordKey) (*appbsky.FeedDefs_PostView, error) {
+
+	// fetch the post post (with extra context)
+	uri := fmt.Sprintf("at://%s/app.bsky.feed.post/%s", did, rkey)
+	tpv, err := appbsky.FeedGetPostThread(ctx, srv.xrpcc, 1, 0, uri)
+	if err != nil {
+		log.Warnf("failed to fetch post: %s\t%v", uri, err)
+		// TODO: detect 404, specifically?
+		return nil, ErrPostNotFound
+	}
+
+	if tpv.Thread.FeedDefs_BlockedPost != nil {
+		return nil, ErrPostNotPublic
+	} else if tpv.Thread.FeedDefs_ThreadViewPost.Post == nil {
+		return nil, ErrPostNotFound
+	}
+
+	postView := tpv.Thread.FeedDefs_ThreadViewPost.Post
+	for _, label := range postView.Author.Labels {
+		if label.Src == postView.Author.Did && label.Val == "!no-unauthenticated" {
+			return nil, ErrPostNotPublic
+		}
+	}
+	return postView, nil
+}
+
+func (srv *Server) WebHome(c echo.Context) error {
+	return c.Render(http.StatusOK, "home.html", nil)
+}
+
+type OEmbedResponse struct {
+	Type         string `json:"type"`
+	Version      string `json:"version"`
+	AuthorName   string `json:"author_name,omitempty"`
+	AuthorURL    string `json:"author_url,omitempty"`
+	ProviderName string `json:"provider_url,omitempty"`
+	CacheAge     int    `json:"cache_age,omitempty"`
+	Width        int    `json:"width,omitempty"`
+	Height       *int   `json:"height,omitempty"`
+	HTML         string `json:"html,omitempty"`
+}
+
+func (srv *Server) parseBlueskyURL(ctx context.Context, raw string) (*syntax.ATURI, error) {
+
+	if raw == "" {
+		return nil, fmt.Errorf("empty url")
+	}
+
+	// first try simple AT-URI
+	uri, err := syntax.ParseATURI(raw)
+	if nil == err {
+		return &uri, nil
+	}
+
+	// then try bsky.app post URL
+	u, err := url.Parse(raw)
+	if err != nil {
+		return nil, err
+	}
+	if u.Hostname() != "bsky.app" {
+		return nil, fmt.Errorf("only bsky.app URLs currently supported")
+	}
+	pathParts := strings.Split(u.Path, "/") // NOTE: pathParts[0] will be empty string
+	if len(pathParts) != 5 || pathParts[1] != "profile" || pathParts[3] != "post" {
+		return nil, fmt.Errorf("only bsky.app post URLs currently supported")
+	}
+	atid, err := syntax.ParseAtIdentifier(pathParts[2])
+	if err != nil {
+		return nil, err
+	}
+	rkey, err := syntax.ParseRecordKey(pathParts[4])
+	if err != nil {
+		return nil, err
+	}
+	var did syntax.DID
+	if atid.IsHandle() {
+		ident, err := srv.dir.Lookup(ctx, *atid)
+		if err != nil {
+			return nil, err
+		}
+		did = ident.DID
+	} else {
+		did, err = atid.AsDID()
+		if err != nil {
+			return nil, err
+		}
+	}
+
+	// TODO: don't really need to re-parse here, if we had test coverage
+	aturi, err := syntax.ParseATURI(fmt.Sprintf("at://%s/app.bsky.feed.post/%s", did, rkey))
+	if err != nil {
+		return nil, err
+	} else {
+		return &aturi, nil
+	}
+}
+
+func (srv *Server) WebOEmbed(c echo.Context) error {
+	formatParam := c.QueryParam("format")
+	if formatParam != "" && formatParam != "json" {
+		return c.String(http.StatusNotImplemented, "Unsupported oEmbed format: "+formatParam)
+	}
+
+	// TODO: do we actually do something with width?
+	width := 550
+	maxWidthParam := c.QueryParam("maxwidth")
+	if maxWidthParam != "" {
+		maxWidthInt, err := strconv.Atoi(maxWidthParam)
+		if err != nil || maxWidthInt < 220 || maxWidthInt > 550 {
+			return c.String(http.StatusBadRequest, "Invalid maxwidth (expected integer between 220 and 550)")
+		}
+		width = maxWidthInt
+	}
+	// NOTE: maxheight ignored
+
+	aturi, err := srv.parseBlueskyURL(c.Request().Context(), c.QueryParam("url"))
+	if err != nil {
+		return c.String(http.StatusBadRequest, fmt.Sprintf("Expected 'url' to be bsky.app URL or AT-URI: %v", err))
+	}
+	if aturi.Collection() != syntax.NSID("app.bsky.feed.post") {
+		return c.String(http.StatusNotImplemented, "Only posts (app.bsky.feed.post records) can be embedded currently")
+	}
+	did, err := aturi.Authority().AsDID()
+	if err != nil {
+		return err
+	}
+
+	post, err := srv.getBlueskyPost(c.Request().Context(), did, aturi.RecordKey())
+	if err == ErrPostNotFound {
+		return c.String(http.StatusNotFound, fmt.Sprintf("%v", err))
+	} else if err == ErrPostNotPublic {
+		return c.String(http.StatusForbidden, fmt.Sprintf("%v", err))
+	} else if err != nil {
+		return c.String(http.StatusInternalServerError, fmt.Sprintf("%v", err))
+	}
+
+	html, err := srv.postEmbedHTML(post)
+	if err != nil {
+		return c.String(http.StatusInternalServerError, fmt.Sprintf("%v", err))
+	}
+	data := OEmbedResponse{
+		Type:         "rich",
+		Version:      "1.0",
+		AuthorName:   "@" + post.Author.Handle,
+		AuthorURL:    fmt.Sprintf("https://bsky.app/profile/%s", post.Author.Handle),
+		ProviderName: "Bluesky Social",
+		CacheAge:     86400,
+		Width:        width,
+		Height:       nil,
+		HTML:         html,
+	}
+	if post.Author.DisplayName != nil {
+		data.AuthorName = fmt.Sprintf("%s (@%s)", *post.Author.DisplayName, post.Author.Handle)
+	}
+	return c.JSON(http.StatusOK, data)
+}
+
+func (srv *Server) WebPostEmbed(c echo.Context) error {
+
+	// sanity check arguments. don't 4xx, just let app handle if not expected format
+	rkeyParam := c.Param("rkey")
+	rkey, err := syntax.ParseRecordKey(rkeyParam)
+	if err != nil {
+		return c.String(http.StatusBadRequest, fmt.Sprintf("Invalid RecordKey: %v", err))
+	}
+	didParam := c.Param("did")
+	did, err := syntax.ParseDID(didParam)
+	if err != nil {
+		return c.String(http.StatusBadRequest, fmt.Sprintf("Invalid DID: %v", err))
+	}
+	_ = rkey
+	_ = did
+
+	// NOTE: this request was't really necessary; the JS will do the same fetch
+	/*
+		postView, err := srv.getBlueskyPost(ctx, did, rkey)
+		if err == ErrPostNotFound {
+			return c.String(http.StatusNotFound, fmt.Sprintf("%v", err))
+		} else if err == ErrPostNotPublic {
+			return c.String(http.StatusForbidden, fmt.Sprintf("%v", err))
+		} else if err != nil {
+			return c.String(http.StatusInternalServerError, fmt.Sprintf("%v", err))
+		}
+	*/
+
+	return c.Render(http.StatusOK, "postEmbed.html", nil)
+}
diff --git a/bskyweb/cmd/embedr/main.go b/bskyweb/cmd/embedr/main.go
new file mode 100644
index 000000000..9f75ed69a
--- /dev/null
+++ b/bskyweb/cmd/embedr/main.go
@@ -0,0 +1,60 @@
+package main
+
+import (
+	"os"
+
+	_ "github.com/joho/godotenv/autoload"
+
+	logging "github.com/ipfs/go-log"
+	"github.com/urfave/cli/v2"
+)
+
+var log = logging.Logger("embedr")
+
+func init() {
+	logging.SetAllLoggers(logging.LevelDebug)
+	//logging.SetAllLoggers(logging.LevelWarn)
+}
+
+func main() {
+	run(os.Args)
+}
+
+func run(args []string) {
+
+	app := cli.App{
+		Name:  "embedr",
+		Usage: "web server for embed.bsky.app post embeds",
+	}
+
+	app.Commands = []*cli.Command{
+		&cli.Command{
+			Name:   "serve",
+			Usage:  "run the server",
+			Action: serve,
+			Flags: []cli.Flag{
+				&cli.StringFlag{
+					Name:    "appview-host",
+					Usage:   "method, hostname, and port of PDS instance",
+					Value:   "https://public.api.bsky.app",
+					EnvVars: []string{"ATP_APPVIEW_HOST"},
+				},
+				&cli.StringFlag{
+					Name:     "http-address",
+					Usage:    "Specify the local IP/port to bind to",
+					Required: false,
+					Value:    ":8100",
+					EnvVars:  []string{"HTTP_ADDRESS"},
+				},
+				&cli.BoolFlag{
+					Name:     "debug",
+					Usage:    "Enable debug mode",
+					Value:    false,
+					Required: false,
+					EnvVars:  []string{"DEBUG"},
+				},
+			},
+		},
+	}
+	app.RunAndExitOnError()
+}
diff --git a/bskyweb/cmd/embedr/render.go b/bskyweb/cmd/embedr/render.go
new file mode 100644
index 000000000..cc8f0759a
--- /dev/null
+++ b/bskyweb/cmd/embedr/render.go
@@ -0,0 +1,16 @@
+package main
+
+import (
+	"html/template"
+	"io"
+
+	"github.com/labstack/echo/v4"
+)
+
+type Template struct {
+	templates *template.Template
+}
+
+func (t *Template) Render(w io.Writer, name string, data interface{}, c echo.Context) error {
+	return t.templates.ExecuteTemplate(w, name, data)
+}
diff --git a/bskyweb/cmd/embedr/server.go b/bskyweb/cmd/embedr/server.go
new file mode 100644
index 000000000..904b4df9a
--- /dev/null
+++ b/bskyweb/cmd/embedr/server.go
@@ -0,0 +1,236 @@
+package main
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"html/template"
+	"io/fs"
+	"net/http"
+	"os"
+	"os/signal"
+	"strings"
+	"syscall"
+	"time"
+
+	"github.com/bluesky-social/indigo/atproto/identity"
+	"github.com/bluesky-social/indigo/util/cliutil"
+	"github.com/bluesky-social/indigo/xrpc"
+	"github.com/bluesky-social/social-app/bskyweb"
+
+	"github.com/klauspost/compress/gzhttp"
+	"github.com/klauspost/compress/gzip"
+	"github.com/labstack/echo/v4"
+	"github.com/labstack/echo/v4/middleware"
+	"github.com/urfave/cli/v2"
+)
+
+type Server struct {
+	echo  *echo.Echo
+	httpd *http.Server
+	xrpcc *xrpc.Client
+	dir   identity.Directory
+}
+
+func serve(cctx *cli.Context) error {
+	debug := cctx.Bool("debug")
+	httpAddress := cctx.String("http-address")
+	appviewHost := cctx.String("appview-host")
+
+	// Echo
+	e := echo.New()
+
+	// create a new session (no auth)
+	xrpcc := &xrpc.Client{
+		Client: cliutil.NewHttpClient(),
+		Host:   appviewHost,
+	}
+
+	// httpd
+	var (
+		httpTimeout          = 2 * time.Minute
+		httpMaxHeaderBytes   = 2 * (1024 * 1024)
+		gzipMinSizeBytes     = 1024 * 2
+		gzipCompressionLevel = gzip.BestSpeed
+		gzipExceptMIMETypes  = []string{"image/png"}
+	)
+
+	// Wrap the server handler in a gzip handler to compress larger responses.
+	gzipHandler, err := gzhttp.NewWrapper(
+		gzhttp.MinSize(gzipMinSizeBytes),
+		gzhttp.CompressionLevel(gzipCompressionLevel),
+		gzhttp.ExceptContentTypes(gzipExceptMIMETypes),
+	)
+	if err != nil {
+		return err
+	}
+
+	//
+	// server
+	//
+	server := &Server{
+		echo:  e,
+		xrpcc: xrpcc,
+		dir:   identity.DefaultDirectory(),
+	}
+
+	// Create the HTTP server.
+	server.httpd = &http.Server{
+		Handler:        gzipHandler(server),
+		Addr:           httpAddress,
+		WriteTimeout:   httpTimeout,
+		ReadTimeout:    httpTimeout,
+		MaxHeaderBytes: httpMaxHeaderBytes,
+	}
+
+	e.HideBanner = true
+
+	tmpl := &Template{
+		templates: template.Must(template.ParseFS(bskyweb.EmbedrTemplateFS, "embedr-templates/*.html")),
+	}
+	e.Renderer = tmpl
+	e.HTTPErrorHandler = server.errorHandler
+
+	e.IPExtractor = echo.ExtractIPFromXFFHeader()
+
+	// SECURITY: Do not modify without due consideration.
+	e.Use(middleware.SecureWithConfig(middleware.SecureConfig{
+		ContentTypeNosniff: "nosniff",
+		// diable XFrameOptions; we're embedding here!
+		HSTSMaxAge: 31536000, // 365 days
+		// TODO:
+		// ContentSecurityPolicy
+		// XSSProtection
+	}))
+	e.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{
+		// Don't log requests for static content.
+		Skipper: func(c echo.Context) bool {
+			return strings.HasPrefix(c.Request().URL.Path, "/static")
+		},
+	}))
+	e.Use(middleware.RateLimiterWithConfig(middleware.RateLimiterConfig{
+		Skipper: middleware.DefaultSkipper,
+		Store: middleware.NewRateLimiterMemoryStoreWithConfig(
+			middleware.RateLimiterMemoryStoreConfig{
+				Rate:      10,              // requests per second
+				Burst:     30,              // allow bursts
+				ExpiresIn: 3 * time.Minute, // garbage collect entries older than 3 minutes
+			},
+		),
+		IdentifierExtractor: func(ctx echo.Context) (string, error) {
+			id := ctx.RealIP()
+			return id, nil
+		},
+		DenyHandler: func(c echo.Context, identifier string, err error) error {
+			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")
+		},
+	}))
+
+	// redirect trailing slash to non-trailing slash.
+	// all of our current endpoints have no trailing slash.
+	e.Use(middleware.RemoveTrailingSlashWithConfig(middleware.TrailingSlashConfig{
+		RedirectCode: http.StatusFound,
+	}))
+
+	//
+	// configure routes
+	//
+	// static files
+	staticHandler := http.FileServer(func() http.FileSystem {
+		if debug {
+			log.Debugf("serving static file from the local file system")
+			return http.FS(os.DirFS("embedr-static"))
+		}
+		fsys, err := fs.Sub(bskyweb.EmbedrStaticFS, "embedr-static")
+		if err != nil {
+			log.Fatal(err)
+		}
+		return http.FS(fsys)
+	}())
+
+	e.GET("/robots.txt", echo.WrapHandler(staticHandler))
+	e.GET("/ips-v4", echo.WrapHandler(staticHandler))
+	e.GET("/ips-v6", echo.WrapHandler(staticHandler))
+	e.GET("/.well-known/*", echo.WrapHandler(staticHandler))
+	e.GET("/security.txt", func(c echo.Context) error {
+		return c.Redirect(http.StatusMovedPermanently, "/.well-known/security.txt")
+	})
+	e.GET("/static/*", echo.WrapHandler(http.StripPrefix("/static/", staticHandler)), func(next echo.HandlerFunc) echo.HandlerFunc {
+		return func(c echo.Context) error {
+			path := c.Request().URL.Path
+			maxAge := 1 * (60 * 60) // default is 1 hour
+
+			// Cache javascript and images files for 1 week, which works because
+			// they're always versioned (e.g. /static/js/main.64c14927.js)
+			if strings.HasPrefix(path, "/static/js/") || strings.HasPrefix(path, "/static/images/") {
+				maxAge = 7 * (60 * 60 * 24) // 1 week
+			}
+
+			c.Response().Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%d", maxAge))
+			return next(c)
+		}
+	})
+
+	// actual routes
+	e.GET("/", server.WebHome)
+	e.GET("/iframe-resize.js", echo.WrapHandler(staticHandler))
+	e.GET("/embed.js", echo.WrapHandler(staticHandler))
+	e.GET("/oembed", server.WebOEmbed)
+	e.GET("/embed/:did/app.bsky.feed.post/:rkey", server.WebPostEmbed)
+
+	// Start the server.
+	log.Infof("starting server address=%s", httpAddress)
+	go func() {
+		if err := server.httpd.ListenAndServe(); err != nil {
+			if !errors.Is(err, http.ErrServerClosed) {
+				log.Errorf("HTTP server shutting down unexpectedly: %s", err)
+			}
+		}
+	}()
+
+	// Wait for a signal to exit.
+	log.Info("registering OS exit signal handler")
+	quit := make(chan struct{})
+	exitSignals := make(chan os.Signal, 1)
+	signal.Notify(exitSignals, syscall.SIGINT, syscall.SIGTERM)
+	go func() {
+		sig := <-exitSignals
+		log.Infof("received OS exit signal: %s", sig)
+
+		// Shut down the HTTP server.
+		if err := server.Shutdown(); err != nil {
+			log.Errorf("HTTP server shutdown error: %s", err)
+		}
+
+		// Trigger the return that causes an exit.
+		close(quit)
+	}()
+	<-quit
+	log.Infof("graceful shutdown complete")
+	return nil
+}
+
+func (srv *Server) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
+	srv.echo.ServeHTTP(rw, req)
+}
+
+func (srv *Server) Shutdown() error {
+	log.Info("shutting down")
+
+	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+	defer cancel()
+
+	return srv.httpd.Shutdown(ctx)
+}
+
+func (srv *Server) errorHandler(err error, c echo.Context) {
+	code := http.StatusInternalServerError
+	if he, ok := err.(*echo.HTTPError); ok {
+		code = he.Code
+	}
+	c.Logger().Error(err)
+	data := map[string]interface{}{
+		"statusCode": code,
+	}
+	c.Render(code, "error.html", data)
+}
diff --git a/bskyweb/cmd/embedr/snippet.go b/bskyweb/cmd/embedr/snippet.go
new file mode 100644
index 000000000..e65f38a62
--- /dev/null
+++ b/bskyweb/cmd/embedr/snippet.go
@@ -0,0 +1,71 @@
+package main
+
+import (
+	"bytes"
+	"fmt"
+	"html/template"
+
+	appbsky "github.com/bluesky-social/indigo/api/bsky"
+	"github.com/bluesky-social/indigo/atproto/syntax"
+)
+
+func (srv *Server) postEmbedHTML(postView *appbsky.FeedDefs_PostView) (string, error) {
+	// ensure that there isn't an injection from the URI
+	aturi, err := syntax.ParseATURI(postView.Uri)
+	if err != nil {
+		log.Error("bad AT-URI in reponse", "aturi", aturi, "err", err)
+		return "", err
+	}
+
+	post, ok := postView.Record.Val.(*appbsky.FeedPost)
+	if !ok {
+		log.Error("bad post record value", "err", err)
+		return "", err
+	}
+
+	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>`
+
+	t, err := template.New("snippet").Parse(tpl)
+	if err != nil {
+		log.Error("template parse error", "err", err)
+		return "", err
+	}
+
+	var lang string
+	if len(post.Langs) > 0 {
+		lang = post.Langs[0]
+	}
+	var authorName string
+	if postView.Author.DisplayName != nil {
+		authorName = fmt.Sprintf("%s (@%s)", *postView.Author.DisplayName, postView.Author.Handle)
+	} else {
+		authorName = fmt.Sprintf("@%s", postView.Author.Handle)
+	}
+	fmt.Println(postView.Uri)
+	fmt.Println(fmt.Sprintf("%s", postView.Uri))
+	data := struct {
+		PostURI       template.URL
+		PostCID       string
+		PostLang      string
+		PostText      string
+		PostAuthor    string
+		PostIndexedAt string
+		WidgetURL     template.URL
+	}{
+		PostURI:       template.URL(postView.Uri),
+		PostCID:       postView.Cid,
+		PostLang:      lang,
+		PostText:      post.Text,
+		PostAuthor:    authorName,
+		PostIndexedAt: postView.IndexedAt, // TODO: createdAt?
+		WidgetURL:     template.URL("https://embed.bsky.app/static/embed.js"),
+	}
+
+	var buf bytes.Buffer
+	err = t.Execute(&buf, data)
+	if err != nil {
+		log.Error("template parse error", "err", err)
+		return "", err
+	}
+	return buf.String(), nil
+}
diff --git a/bskyweb/embedr-static/.well-known/security.txt b/bskyweb/embedr-static/.well-known/security.txt
new file mode 100644
index 000000000..8173cb72d
--- /dev/null
+++ b/bskyweb/embedr-static/.well-known/security.txt
@@ -0,0 +1,4 @@
+Contact: mailto:security@bsky.app
+Preferred-Languages: en
+Canonical: https://bsky.app/.well-known/security.txt
+Acknowledgements: https://github.com/bluesky-social/atproto/blob/main/CONTRIBUTORS.md
diff --git a/bskyweb/embedr-static/embed.js b/bskyweb/embedr-static/embed.js
new file mode 100644
index 000000000..15964a76c
--- /dev/null
+++ b/bskyweb/embedr-static/embed.js
@@ -0,0 +1 @@
+/* embed javascript widget will go here */
diff --git a/bskyweb/embedr-static/favicon-16x16.png b/bskyweb/embedr-static/favicon-16x16.png
new file mode 100644
index 000000000..ea256e056
--- /dev/null
+++ b/bskyweb/embedr-static/favicon-16x16.png
Binary files differdiff --git a/bskyweb/embedr-static/favicon-32x32.png b/bskyweb/embedr-static/favicon-32x32.png
new file mode 100644
index 000000000..a5ca7eed1
--- /dev/null
+++ b/bskyweb/embedr-static/favicon-32x32.png
Binary files differdiff --git a/bskyweb/embedr-static/favicon.png b/bskyweb/embedr-static/favicon.png
new file mode 100644
index 000000000..ddf55f4c8
--- /dev/null
+++ b/bskyweb/embedr-static/favicon.png
Binary files differdiff --git a/bskyweb/embedr-static/iframe-resize.js b/bskyweb/embedr-static/iframe-resize.js
new file mode 100644
index 000000000..6bf2793df
--- /dev/null
+++ b/bskyweb/embedr-static/iframe-resize.js
@@ -0,0 +1 @@
+/* script to resize embed ifame would go here? */
diff --git a/bskyweb/embedr-static/ips-v4 b/bskyweb/embedr-static/ips-v4
new file mode 100644
index 000000000..087996ef9
--- /dev/null
+++ b/bskyweb/embedr-static/ips-v4
@@ -0,0 +1,30 @@
+13.59.225.103/32
+3.18.47.21/32
+18.191.104.94/32
+3.129.134.255/32
+3.129.237.113/32
+3.138.56.230/32
+44.218.10.163/32
+54.89.116.251/32
+44.217.166.202/32
+54.208.221.149/32
+54.166.110.54/32
+54.208.146.65/32
+3.129.234.15/32
+3.138.168.48/32
+3.23.53.192/32
+52.14.89.53/32
+3.18.126.246/32
+3.136.69.4/32
+3.22.137.152/32
+3.132.247.113/32
+3.141.186.104/32
+18.222.43.214/32
+3.14.35.197/32
+3.23.182.70/32
+18.224.144.69/32
+3.129.98.29/32
+3.130.134.20/32
+3.17.197.213/32
+18.223.234.21/32
+3.20.248.177/32
diff --git a/bskyweb/embedr-static/ips-v6 b/bskyweb/embedr-static/ips-v6
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/bskyweb/embedr-static/ips-v6
diff --git a/bskyweb/embedr-static/robots.txt b/bskyweb/embedr-static/robots.txt
new file mode 100644
index 000000000..4f8510d18
--- /dev/null
+++ b/bskyweb/embedr-static/robots.txt
@@ -0,0 +1,9 @@
+# Hello Friends!
+# If you are considering bulk or automated crawling, you may want to look in
+# to our protocol (API), including a firehose of updates. See: https://atproto.com/
+
+# By default, may crawl anything on this domain. HTTP 429 ("backoff") status
+# codes are used for rate-limiting. Up to a handful concurrent requests should
+# be ok.
+User-Agent: *
+Allow: /
diff --git a/bskyweb/embedr-templates/error.html b/bskyweb/embedr-templates/error.html
new file mode 100644
index 000000000..5aa04c83b
--- /dev/null
+++ b/bskyweb/embedr-templates/error.html
@@ -0,0 +1 @@
+placeholder!
diff --git a/bskyweb/embedr-templates/home.html b/bskyweb/embedr-templates/home.html
new file mode 100644
index 000000000..f938c32d6
--- /dev/null
+++ b/bskyweb/embedr-templates/home.html
@@ -0,0 +1,8 @@
+<html>
+<head>
+</head>
+<body>
+	<h1>embed.bsky.app homepage</h1>
+	<p>could redirect to bsky.app? or show a "create embed" widget?
+</body>
+</html>
diff --git a/bskyweb/embedr-templates/oembed.html b/bskyweb/embedr-templates/oembed.html
new file mode 100644
index 000000000..646f0a482
--- /dev/null
+++ b/bskyweb/embedr-templates/oembed.html
@@ -0,0 +1 @@
+oembed JSON response will go here
diff --git a/bskyweb/embedr-templates/postEmbed.html b/bskyweb/embedr-templates/postEmbed.html
new file mode 100644
index 000000000..6329b3a19
--- /dev/null
+++ b/bskyweb/embedr-templates/postEmbed.html
@@ -0,0 +1 @@
+embed post HTML will go here
diff --git a/bskyweb/static.go b/bskyweb/static.go
index a67d189f5..38adb8333 100644
--- a/bskyweb/static.go
+++ b/bskyweb/static.go
@@ -4,3 +4,6 @@ import "embed"
 
 //go:embed static/*
 var StaticFS embed.FS
+
+//go:embed embedr-static/*
+var EmbedrStaticFS embed.FS
diff --git a/bskyweb/templates.go b/bskyweb/templates.go
index ce3fa29af..a66965aba 100644
--- a/bskyweb/templates.go
+++ b/bskyweb/templates.go
@@ -4,3 +4,6 @@ import "embed"
 
 //go:embed templates/*
 var TemplateFS embed.FS
+
+//go:embed embedr-templates/*
+var EmbedrTemplateFS embed.FS
diff --git a/package.json b/package.json
index e4a8de744..21e632aa4 100644
--- a/package.json
+++ b/package.json
@@ -20,7 +20,7 @@
     "build-ios": "yarn use-build-number-with-bump eas build -p ios",
     "build-android": "yarn use-build-number-with-bump eas build -p android",
     "build": "yarn use-build-number-with-bump eas build",
-    "build-embed": "cd bskyembed && yarn build && cd .. && node ./scripts/post-embed-build.js",
+    "build-embed": "cd bskyembed && yarn build && yarn build-snippet && cd .. && node ./scripts/post-embed-build.js",
     "start": "expo start --dev-client",
     "start:prod": "expo start --dev-client --no-dev --minify",
     "clean-cache": "rm -rf node_modules/.cache/babel-loader/*",
diff --git a/scripts/post-embed-build.js b/scripts/post-embed-build.js
index 5bece544a..c0897e1b7 100644
--- a/scripts/post-embed-build.js
+++ b/scripts/post-embed-build.js
@@ -1,49 +1,65 @@
-// const path = require('node:path')
-// const fs = require('node:fs')
-
-// const projectRoot = path.join(__dirname, '..')
-
-// // copy embed assets to web-build
-
-// const embedAssetSource = path.join(
-//   projectRoot,
-//   'bskyembed',
-//   'dist',
-//   'static',
-//   'embed',
-//   'assets',
-// )
-
-// const embedAssetDest = path.join(
-//   projectRoot,
-//   'web-build',
-//   'static',
-//   'embed',
-//   'assets',
-// )
-
-// fs.cpSync(embedAssetSource, embedAssetDest, {recursive: true})
-
-// // copy entrypoint(s) to web-build
-
-// // additional entrypoints will need more work, but this'll do for now
-// const embedHtmlSource = path.join(
-//   projectRoot,
-//   'bskyembed',
-//   'dist',
-//   'index.html',
-// )
-
-// const embedHtmlDest = path.join(
-//   projectRoot,
-//   'web-build',
-//   'static',
-//   'embed',
-//   'post.html',
-// )
-
-// fs.copyFileSync(embedHtmlSource, embedHtmlDest)
-
-// console.log(`Copied embed assets to web-build`)
-
-console.log('post-embed-build.js - waiting for embedr!')
+const path = require('node:path')
+const fs = require('node:fs')
+
+const projectRoot = path.join(__dirname, '..')
+
+// copy embed assets to embedr
+
+const embedAssetSource = path.join(projectRoot, 'bskyembed', 'dist', 'static')
+
+const embedAssetDest = path.join(projectRoot, 'bskyweb', 'embedr-static')
+
+fs.cpSync(embedAssetSource, embedAssetDest, {recursive: true})
+
+const embedEmbedJSSource = path.join(
+  projectRoot,
+  'bskyembed',
+  'dist',
+  'embed.js',
+)
+
+const embedEmbedJSDest = path.join(
+  projectRoot,
+  'bskyweb',
+  'embedr-static',
+  'embed.js',
+)
+
+fs.cpSync(embedEmbedJSSource, embedEmbedJSDest)
+
+// copy entrypoint(s) to embedr
+
+// additional entrypoints will need more work, but this'll do for now
+const embedHomeHtmlSource = path.join(
+  projectRoot,
+  'bskyembed',
+  'dist',
+  'index.html',
+)
+
+const embedHomeHtmlDest = path.join(
+  projectRoot,
+  'bskyweb',
+  'embedr-templates',
+  'home.html',
+)
+
+fs.copyFileSync(embedHomeHtmlSource, embedHomeHtmlDest)
+
+const embedPostHtmlSource = path.join(
+  projectRoot,
+  'bskyembed',
+  'dist',
+  'post.html',
+)
+
+const embedPostHtmlDest = path.join(
+  projectRoot,
+  'bskyweb',
+  'embedr-templates',
+  'postEmbed.html',
+)
+
+fs.copyFileSync(embedPostHtmlSource, embedPostHtmlDest)
+
+console.log(`Copied embed assets to embedr`)