diff --git a/bskyweb/cmd/bskyweb/invitepass.go b/bskyweb/cmd/bskyweb/invitepass.go new file mode 100644 index 0000000000..74c7f603f4 --- /dev/null +++ b/bskyweb/cmd/bskyweb/invitepass.go @@ -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 +} diff --git a/bskyweb/cmd/bskyweb/invitepass_test.go b/bskyweb/cmd/bskyweb/invitepass_test.go new file mode 100644 index 0000000000..60f7b61d22 --- /dev/null +++ b/bskyweb/cmd/bskyweb/invitepass_test.go @@ -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) + } +} diff --git a/bskyweb/cmd/bskyweb/server.go b/bskyweb/cmd/bskyweb/server.go index 7373dc5cdc..afd5cd8d86 100644 --- a/bskyweb/cmd/bskyweb/server.go +++ b/bskyweb/cmd/bskyweb/server.go @@ -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) diff --git a/bskyweb/example.env b/bskyweb/example.env index 80adc15550..d0360455d5 100644 --- a/bskyweb/example.env +++ b/bskyweb/example.env @@ -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=