feat(bskyweb): add /invite/pass.* and /invite/wallet/* routes
This commit is contained in:
@@ -0,0 +1,265 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/png"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
appbsky "github.com/bluesky-social/indigo/api/bsky"
|
||||
comatproto "github.com/bluesky-social/indigo/api/atproto"
|
||||
"github.com/bluesky-social/indigo/xrpc"
|
||||
"github.com/labstack/echo/v4"
|
||||
"golang.org/x/image/font"
|
||||
)
|
||||
|
||||
const passTokenTTL = 60 * time.Second
|
||||
const inviteHeroPath = "/invite/wallet/hero"
|
||||
|
||||
var errAuthMissing = errors.New("auth missing")
|
||||
|
||||
// Authenticator is satisfied by the production session checker and by stub
|
||||
// auth in tests.
|
||||
type Authenticator interface {
|
||||
Authenticate(c echo.Context) (did, handle string, err error)
|
||||
}
|
||||
|
||||
// XrpcAuthenticator calls com.atproto.server.getSession with the incoming bearer
|
||||
// token, verifying the session and returning the (did, handle).
|
||||
type XrpcAuthenticator struct {
|
||||
BaseHost string // e.g. "https://public.api.bsky.app"
|
||||
}
|
||||
|
||||
func (a XrpcAuthenticator) Authenticate(c echo.Context) (string, string, error) {
|
||||
hdr := c.Request().Header.Get("Authorization")
|
||||
if hdr == "" || !strings.HasPrefix(hdr, "Bearer ") {
|
||||
return "", "", errAuthMissing
|
||||
}
|
||||
jwt := strings.TrimPrefix(hdr, "Bearer ")
|
||||
client := &xrpc.Client{
|
||||
Host: a.BaseHost,
|
||||
Auth: &xrpc.AuthInfo{AccessJwt: jwt},
|
||||
}
|
||||
resp, err := comatproto.ServerGetSession(c.Request().Context(), client)
|
||||
if err != nil {
|
||||
return "", "", errAuthMissing
|
||||
}
|
||||
return resp.Did, resp.Handle, nil
|
||||
}
|
||||
|
||||
type InvitePassConfig struct {
|
||||
TokenSecret []byte
|
||||
Signer *PassSigner
|
||||
Wallet *WalletConfig
|
||||
StripFS fs.FS
|
||||
FontFace font.Face
|
||||
TeamID string
|
||||
BaseURL string // e.g. https://bsky.app
|
||||
}
|
||||
|
||||
func (srv *Server) RegisterInvitePassRoutes() {
|
||||
srv.echo.POST("/invite/pass.url", srv.WebInvitePassURL)
|
||||
srv.echo.GET("/invite/pass.pkpass", srv.WebInvitePassPkpass)
|
||||
srv.echo.GET("/invite/wallet/jwt", srv.WebInviteWalletJWT)
|
||||
srv.echo.GET(inviteHeroPath, srv.WebInviteWalletHero)
|
||||
}
|
||||
|
||||
func (srv *Server) WebInvitePassURL(c echo.Context) error {
|
||||
did, _, err := srv.authenticator.Authenticate(c)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusUnauthorized, echo.Map{"error": "AuthMissing"})
|
||||
}
|
||||
var body struct {
|
||||
Theme string `json:"theme"`
|
||||
}
|
||||
_ = json.NewDecoder(c.Request().Body).Decode(&body)
|
||||
theme := CoerceTheme(body.Theme)
|
||||
tok, err := MintPassToken(srv.cfg.InvitePass.TokenSecret, did, theme, time.Now(), passTokenTTL)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, echo.Map{"error": "TokenMintFailed"})
|
||||
}
|
||||
rawURL := srv.cfg.InvitePass.BaseURL + "/invite/pass.pkpass?theme=" + theme + "&t=" + tok
|
||||
return c.JSON(http.StatusOK, echo.Map{"url": rawURL})
|
||||
}
|
||||
|
||||
func (srv *Server) WebInvitePassPkpass(c echo.Context) error {
|
||||
theme := CoerceTheme(c.QueryParam("theme"))
|
||||
tok := c.QueryParam("t")
|
||||
did, tokTheme, err := VerifyPassToken(srv.cfg.InvitePass.TokenSecret, tok, time.Now())
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusGone, echo.Map{"error": "TokenInvalid"})
|
||||
}
|
||||
if tokTheme != theme {
|
||||
return c.JSON(http.StatusGone, echo.Map{"error": "TokenInvalid"})
|
||||
}
|
||||
if srv.cfg.InvitePass.Signer == nil {
|
||||
return c.JSON(http.StatusServiceUnavailable, echo.Map{"error": "SignerDisabled"})
|
||||
}
|
||||
|
||||
handle, avatarBytes, err := srv.fetchProfile(c.Request().Context(), did)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadGateway, echo.Map{"error": "ProfileFetchFailed"})
|
||||
}
|
||||
|
||||
passJSON, err := BuildPassJSON(did, handle, theme, srv.cfg.InvitePass.TeamID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, echo.Map{"error": "PassBuildFailed"})
|
||||
}
|
||||
|
||||
assets, err := srv.buildPassAssets(theme, handle, avatarBytes)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, echo.Map{"error": "AssetBuildFailed"})
|
||||
}
|
||||
|
||||
pkpass, err := SignAndZipPass(passJSON, assets, srv.cfg.InvitePass.Signer)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, echo.Map{"error": "SignFailed"})
|
||||
}
|
||||
|
||||
c.Response().Header().Set("Content-Type", "application/vnd.apple.pkpass")
|
||||
c.Response().Header().Set("Content-Disposition", "attachment; filename=bsky-invite.pkpass")
|
||||
c.Response().Header().Set("Cache-Control", "no-store")
|
||||
_, err = c.Response().Write(pkpass)
|
||||
return err
|
||||
}
|
||||
|
||||
func (srv *Server) WebInviteWalletJWT(c echo.Context) error {
|
||||
did, handle, err := srv.authenticator.Authenticate(c)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusUnauthorized, echo.Map{"error": "AuthMissing"})
|
||||
}
|
||||
theme := CoerceTheme(c.QueryParam("theme"))
|
||||
if srv.cfg.InvitePass.Wallet == nil {
|
||||
return c.JSON(http.StatusServiceUnavailable, echo.Map{"error": "GoogleWalletDisabled"})
|
||||
}
|
||||
jwt, err := BuildSaveJWT(srv.cfg.InvitePass.Wallet, did, handle, theme)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, echo.Map{"error": "JWTBuildFailed"})
|
||||
}
|
||||
return c.JSON(http.StatusOK, echo.Map{"jwt": jwt})
|
||||
}
|
||||
|
||||
func (srv *Server) WebInviteWalletHero(c echo.Context) error {
|
||||
did := c.QueryParam("did")
|
||||
theme := CoerceTheme(c.QueryParam("theme"))
|
||||
if did == "" {
|
||||
return c.NoContent(http.StatusBadRequest)
|
||||
}
|
||||
handle, avatarBytes, err := srv.fetchProfile(c.Request().Context(), did)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadGateway, echo.Map{"error": "ProfileFetchFailed"})
|
||||
}
|
||||
base, err := LoadGradientBase(srv.cfg.InvitePass.StripFS, theme, 2)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, echo.Map{"error": "StripLoadFailed"})
|
||||
}
|
||||
avatarImg, _ := decodePNG(avatarBytes)
|
||||
out, err := CompositeStrip(base, avatarImg, handle, srv.cfg.InvitePass.FontFace)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, echo.Map{"error": "StripBuildFailed"})
|
||||
}
|
||||
c.Response().Header().Set("Content-Type", "image/png")
|
||||
c.Response().Header().Set("Cache-Control", "public, max-age=86400")
|
||||
_, err = c.Response().Write(out)
|
||||
return err
|
||||
}
|
||||
|
||||
func (srv *Server) buildPassAssets(theme, handle string, avatarBytes []byte) ([]PassAsset, error) {
|
||||
icon1, err := readFS(srv.cfg.InvitePass.StripFS, "passes/"+theme+"/icon.png")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("icon: %w", err)
|
||||
}
|
||||
icon2, _ := readFS(srv.cfg.InvitePass.StripFS, "passes/"+theme+"/icon@2x.png")
|
||||
icon3, _ := readFS(srv.cfg.InvitePass.StripFS, "passes/"+theme+"/icon@3x.png")
|
||||
logo1, err := readFS(srv.cfg.InvitePass.StripFS, "passes/logo.png")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("logo: %w", err)
|
||||
}
|
||||
logo2, _ := readFS(srv.cfg.InvitePass.StripFS, "passes/logo@2x.png")
|
||||
logo3, _ := readFS(srv.cfg.InvitePass.StripFS, "passes/logo@3x.png")
|
||||
|
||||
avatarImg, _ := decodePNG(avatarBytes)
|
||||
strip1Bytes, err := buildStripAtDensity(srv.cfg.InvitePass.StripFS, theme, 1, avatarImg, handle, srv.cfg.InvitePass.FontFace)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("strip@1x: %w", err)
|
||||
}
|
||||
strip2Bytes, _ := buildStripAtDensity(srv.cfg.InvitePass.StripFS, theme, 2, avatarImg, handle, srv.cfg.InvitePass.FontFace)
|
||||
strip3Bytes, _ := buildStripAtDensity(srv.cfg.InvitePass.StripFS, theme, 3, avatarImg, handle, srv.cfg.InvitePass.FontFace)
|
||||
|
||||
assets := []PassAsset{
|
||||
{Name: "icon.png", Data: icon1},
|
||||
{Name: "logo.png", Data: logo1},
|
||||
{Name: "strip.png", Data: strip1Bytes},
|
||||
}
|
||||
if len(icon2) > 0 {
|
||||
assets = append(assets, PassAsset{Name: "icon@2x.png", Data: icon2})
|
||||
}
|
||||
if len(icon3) > 0 {
|
||||
assets = append(assets, PassAsset{Name: "icon@3x.png", Data: icon3})
|
||||
}
|
||||
if len(logo2) > 0 {
|
||||
assets = append(assets, PassAsset{Name: "logo@2x.png", Data: logo2})
|
||||
}
|
||||
if len(logo3) > 0 {
|
||||
assets = append(assets, PassAsset{Name: "logo@3x.png", Data: logo3})
|
||||
}
|
||||
if len(strip2Bytes) > 0 {
|
||||
assets = append(assets, PassAsset{Name: "strip@2x.png", Data: strip2Bytes})
|
||||
}
|
||||
if len(strip3Bytes) > 0 {
|
||||
assets = append(assets, PassAsset{Name: "strip@3x.png", Data: strip3Bytes})
|
||||
}
|
||||
return assets, nil
|
||||
}
|
||||
|
||||
func buildStripAtDensity(staticFS fs.FS, theme string, scale int, avatar image.Image, handle string, fontFace font.Face) ([]byte, error) {
|
||||
base, err := LoadGradientBase(staticFS, theme, scale)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return CompositeStrip(base, avatar, handle, fontFace)
|
||||
}
|
||||
|
||||
func readFS(staticFS fs.FS, name string) ([]byte, error) {
|
||||
f, err := staticFS.Open(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
return io.ReadAll(f)
|
||||
}
|
||||
|
||||
func decodePNG(data []byte) (image.Image, error) {
|
||||
if len(data) == 0 {
|
||||
return nil, errors.New("empty")
|
||||
}
|
||||
img, _, err := image.Decode(bytes.NewReader(data))
|
||||
return img, err
|
||||
}
|
||||
|
||||
func (srv *Server) fetchProfile(ctx context.Context, did string) (handle string, avatarBytes []byte, err error) {
|
||||
pv, err := appbsky.ActorGetProfile(ctx, srv.xrpcc, did)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
handle = pv.Handle
|
||||
if pv.Avatar != nil {
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, *pv.Avatar, nil)
|
||||
resp, herr := http.DefaultClient.Do(req)
|
||||
if herr == nil {
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == 200 {
|
||||
avatarBytes, _ = io.ReadAll(io.LimitReader(resp.Body, 1024*1024))
|
||||
}
|
||||
}
|
||||
}
|
||||
return handle, avatarBytes, nil
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// stubAuth replaces the real ServerGetSession check; bound DID is returned to the handler.
|
||||
type stubAuth struct{ did, handle string }
|
||||
|
||||
func (s stubAuth) Authenticate(c echo.Context) (did, handle string, err error) {
|
||||
if c.Request().Header.Get("Authorization") == "" {
|
||||
return "", "", errAuthMissing
|
||||
}
|
||||
return s.did, s.handle, nil
|
||||
}
|
||||
|
||||
func newTestServer(t *testing.T) (*Server, *stubAuth) {
|
||||
t.Helper()
|
||||
cert, key, wwdr := genTestCertChain(t)
|
||||
signer := &PassSigner{Cert: cert, Key: key, WWDR: wwdr}
|
||||
authStub := &stubAuth{did: "did:plc:abc", handle: "alice.bsky.social"}
|
||||
srv := &Server{
|
||||
echo: echo.New(),
|
||||
cfg: &Config{
|
||||
InvitePass: InvitePassConfig{
|
||||
TokenSecret: []byte("test-secret-32-bytes-of-random!!"),
|
||||
Signer: signer,
|
||||
Wallet: nil, // skip google in this test
|
||||
},
|
||||
},
|
||||
authenticator: authStub,
|
||||
}
|
||||
srv.RegisterInvitePassRoutes()
|
||||
return srv, authStub
|
||||
}
|
||||
|
||||
func TestWebInvitePassURL_Unauthorized(t *testing.T) {
|
||||
srv, _ := newTestServer(t)
|
||||
req := httptest.NewRequest(http.MethodPost, "/invite/pass.url", bytes.NewReader([]byte(`{"theme":"dusk"}`)))
|
||||
rec := httptest.NewRecorder()
|
||||
srv.echo.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebInvitePassURL_OK(t *testing.T) {
|
||||
srv, _ := newTestServer(t)
|
||||
req := httptest.NewRequest(http.MethodPost, "/invite/pass.url", bytes.NewReader([]byte(`{"theme":"dusk"}`)))
|
||||
req.Header.Set("Authorization", "Bearer fake")
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
srv.echo.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var body struct{ URL string }
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &body)
|
||||
if !strings.Contains(body.URL, "/invite/pass.pkpass?theme=dusk&t=") {
|
||||
t.Fatalf("unexpected url: %s", body.URL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebInvitePassPkpass_TokenExpired(t *testing.T) {
|
||||
srv, _ := newTestServer(t)
|
||||
expiredTok, _ := MintPassToken(srv.cfg.InvitePass.TokenSecret, "did:plc:abc", "dusk",
|
||||
time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC), 60*time.Second)
|
||||
req := httptest.NewRequest(http.MethodGet, "/invite/pass.pkpass?theme=dusk&t="+expiredTok, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
srv.echo.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusGone {
|
||||
t.Fatalf("status = %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebInvitePassPkpass_ThemeMismatchInToken(t *testing.T) {
|
||||
srv, _ := newTestServer(t)
|
||||
tok, _ := MintPassToken(srv.cfg.InvitePass.TokenSecret, "did:plc:abc", "day", time.Now(), 60*time.Second)
|
||||
req := httptest.NewRequest(http.MethodGet, "/invite/pass.pkpass?theme=dusk&t="+tok, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
srv.echo.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusGone {
|
||||
t.Fatalf("status = %d", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import (
|
||||
"github.com/bluesky-social/indigo/util/cliutil"
|
||||
"github.com/bluesky-social/indigo/xrpc"
|
||||
"github.com/bluesky-social/social-app/bskyweb"
|
||||
"golang.org/x/image/font/basicfont"
|
||||
|
||||
"github.com/flosch/pongo2/v6"
|
||||
"github.com/klauspost/compress/gzhttp"
|
||||
@@ -46,7 +47,8 @@ type Server struct {
|
||||
metricsHttpd *http.Server
|
||||
xrpcc *xrpc.Client
|
||||
chatXrpcc *xrpc.Client
|
||||
cfg *Config
|
||||
cfg *Config
|
||||
authenticator Authenticator
|
||||
|
||||
ipccClient http.Client
|
||||
|
||||
@@ -65,6 +67,7 @@ type Config struct {
|
||||
linkHost string
|
||||
ipccHost string
|
||||
staticCDNHost string
|
||||
InvitePass InvitePassConfig
|
||||
}
|
||||
|
||||
func serve(cctx *cli.Context) error {
|
||||
@@ -120,6 +123,38 @@ func serve(cctx *cli.Context) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// invite pass config
|
||||
var invitePass InvitePassConfig
|
||||
if v := os.Getenv("INVITE_PASS_TOKEN_SECRET"); v != "" {
|
||||
invitePass.TokenSecret = []byte(v)
|
||||
}
|
||||
if cert, key, wwdr := os.Getenv("APPLE_PASS_CERT_PEM"), os.Getenv("APPLE_PASS_KEY_PEM"), os.Getenv("APPLE_PASS_WWDR_PEM"); cert != "" && key != "" && wwdr != "" {
|
||||
signer, serr := LoadPassSigner([]byte(cert), []byte(key), []byte(wwdr))
|
||||
if serr != nil {
|
||||
slog.Warn("invite pass signer disabled", "err", serr)
|
||||
} else {
|
||||
invitePass.Signer = signer
|
||||
invitePass.TeamID = os.Getenv("APPLE_PASS_TEAM_ID")
|
||||
}
|
||||
}
|
||||
if saJSON, issuer := os.Getenv("GOOGLE_WALLET_SA_JSON"), os.Getenv("GOOGLE_WALLET_ISSUER_ID"); saJSON != "" && issuer != "" {
|
||||
wcfg, werr := LoadWalletConfig([]byte(saJSON), issuer,
|
||||
os.Getenv("GOOGLE_WALLET_HERO_BASE_URL"),
|
||||
os.Getenv("GOOGLE_WALLET_LOGO_URL"))
|
||||
if werr != nil {
|
||||
slog.Warn("google wallet disabled", "err", werr)
|
||||
} else {
|
||||
invitePass.Wallet = wcfg
|
||||
}
|
||||
}
|
||||
stripFS, err := fs.Sub(bskyweb.StaticFS, "static")
|
||||
if err != nil {
|
||||
return fmt.Errorf("static fs sub: %w", err)
|
||||
}
|
||||
invitePass.StripFS = stripFS
|
||||
invitePass.FontFace = basicfont.Face7x13
|
||||
invitePass.BaseURL = "https://bsky.app"
|
||||
|
||||
//
|
||||
// server
|
||||
//
|
||||
@@ -136,6 +171,7 @@ func serve(cctx *cli.Context) error {
|
||||
linkHost: linkHost,
|
||||
ipccHost: ipccHost,
|
||||
staticCDNHost: staticCDNHost,
|
||||
InvitePass: invitePass,
|
||||
},
|
||||
ipccClient: http.Client{
|
||||
Transport: &http.Transport{
|
||||
@@ -155,6 +191,7 @@ func serve(cctx *cli.Context) error {
|
||||
},
|
||||
},
|
||||
}
|
||||
server.authenticator = XrpcAuthenticator{BaseHost: appviewHost}
|
||||
|
||||
// Create the HTTP server.
|
||||
server.httpd = &http.Server{
|
||||
@@ -379,6 +416,9 @@ func serve(cctx *cli.Context) error {
|
||||
// ipcc
|
||||
e.GET("/ipcc", server.WebIpCC)
|
||||
|
||||
// invite passes
|
||||
server.RegisterInvitePassRoutes()
|
||||
|
||||
// sitemap handlers
|
||||
e.GET("/sitemap/users.xml.gz", server.handleSitemapUsersIndex)
|
||||
e.GET("/sitemap/users/*", server.handleSitemapUsersSubpage)
|
||||
|
||||
@@ -1,2 +1,16 @@
|
||||
GOLOG_LOG_LEVEL=info
|
||||
ATP_APPVIEW_HOST=https://public.api.bsky.app
|
||||
|
||||
# Invite passes (Apple Wallet)
|
||||
APPLE_PASS_TYPE_ID=pass.app.bsky.invite
|
||||
APPLE_PASS_TEAM_ID=
|
||||
APPLE_PASS_CERT_PEM=
|
||||
APPLE_PASS_KEY_PEM=
|
||||
APPLE_PASS_WWDR_PEM=
|
||||
# Invite passes (Google Wallet)
|
||||
GOOGLE_WALLET_ISSUER_ID=
|
||||
GOOGLE_WALLET_SA_JSON=
|
||||
GOOGLE_WALLET_HERO_BASE_URL=https://bsky.app/invite/wallet/hero
|
||||
GOOGLE_WALLET_LOGO_URL=https://web-cdn.bsky.app/passes/logo.png
|
||||
# HMAC key for the iOS download token
|
||||
INVITE_PASS_TOKEN_SECRET=
|
||||
|
||||
Reference in New Issue
Block a user