feat(bskyweb): iOS 27 posterGeneric layout with DID, PDS, MEMBER SINCE
This commit is contained in:
@@ -13,6 +13,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -29,21 +30,22 @@ const inviteHeroPath = "/invite/wallet/hero"
|
|||||||
var errAuthMissing = errors.New("auth missing")
|
var errAuthMissing = errors.New("auth missing")
|
||||||
|
|
||||||
// Authenticator is satisfied by the production session checker and by stub
|
// Authenticator is satisfied by the production session checker and by stub
|
||||||
// auth in tests.
|
// auth in tests. pdsHost is the user's home PDS hostname extracted from the
|
||||||
|
// DID document service endpoint.
|
||||||
type Authenticator interface {
|
type Authenticator interface {
|
||||||
Authenticate(c echo.Context) (did, handle string, err error)
|
Authenticate(c echo.Context) (did, handle, pdsHost string, err error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// XrpcAuthenticator calls com.atproto.server.getSession with the incoming bearer
|
// XrpcAuthenticator calls com.atproto.server.getSession with the incoming bearer
|
||||||
// token, verifying the session and returning the (did, handle).
|
// token, verifying the session and returning (did, handle, pdsHost).
|
||||||
type XrpcAuthenticator struct {
|
type XrpcAuthenticator struct {
|
||||||
BaseHost string // e.g. "https://public.api.bsky.app"
|
BaseHost string // e.g. "https://bsky.social"
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a XrpcAuthenticator) Authenticate(c echo.Context) (string, string, error) {
|
func (a XrpcAuthenticator) Authenticate(c echo.Context) (string, string, string, error) {
|
||||||
hdr := c.Request().Header.Get("Authorization")
|
hdr := c.Request().Header.Get("Authorization")
|
||||||
if hdr == "" || !strings.HasPrefix(hdr, "Bearer ") {
|
if hdr == "" || !strings.HasPrefix(hdr, "Bearer ") {
|
||||||
return "", "", errAuthMissing
|
return "", "", "", errAuthMissing
|
||||||
}
|
}
|
||||||
jwt := strings.TrimPrefix(hdr, "Bearer ")
|
jwt := strings.TrimPrefix(hdr, "Bearer ")
|
||||||
client := &xrpc.Client{
|
client := &xrpc.Client{
|
||||||
@@ -52,9 +54,42 @@ func (a XrpcAuthenticator) Authenticate(c echo.Context) (string, string, error)
|
|||||||
}
|
}
|
||||||
resp, err := comatproto.ServerGetSession(c.Request().Context(), client)
|
resp, err := comatproto.ServerGetSession(c.Request().Context(), client)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", errAuthMissing
|
return "", "", "", errAuthMissing
|
||||||
}
|
}
|
||||||
return resp.Did, resp.Handle, nil
|
return resp.Did, resp.Handle, extractPDSHost(resp.DidDoc), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractPDSHost parses the AtprotoPersonalDataServer service entry from the
|
||||||
|
// DID document returned by getSession and returns just the hostname (no scheme
|
||||||
|
// or path). Returns "" if absent or unparseable - the caller can fall back to
|
||||||
|
// a default.
|
||||||
|
func extractPDSHost(didDoc any) string {
|
||||||
|
if didDoc == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
raw, err := json.Marshal(didDoc)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var doc struct {
|
||||||
|
Service []struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
ServiceEndpoint string `json:"serviceEndpoint"`
|
||||||
|
} `json:"service"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &doc); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for _, s := range doc.Service {
|
||||||
|
if s.Type != "AtprotoPersonalDataServer" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
u, err := url.Parse(s.ServiceEndpoint)
|
||||||
|
if err == nil && u.Hostname() != "" {
|
||||||
|
return u.Hostname()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
type InvitePassConfig struct {
|
type InvitePassConfig struct {
|
||||||
@@ -78,7 +113,7 @@ func (srv *Server) WebInvitePassURL(c echo.Context) error {
|
|||||||
if len(srv.cfg.InvitePass.TokenSecret) == 0 {
|
if len(srv.cfg.InvitePass.TokenSecret) == 0 {
|
||||||
return c.JSON(http.StatusServiceUnavailable, echo.Map{"error": "InvitePassDisabled"})
|
return c.JSON(http.StatusServiceUnavailable, echo.Map{"error": "InvitePassDisabled"})
|
||||||
}
|
}
|
||||||
did, _, err := srv.authenticator.Authenticate(c)
|
did, _, pdsHost, err := srv.authenticator.Authenticate(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.JSON(http.StatusUnauthorized, echo.Map{"error": "AuthMissing"})
|
return c.JSON(http.StatusUnauthorized, echo.Map{"error": "AuthMissing"})
|
||||||
}
|
}
|
||||||
@@ -87,7 +122,7 @@ func (srv *Server) WebInvitePassURL(c echo.Context) error {
|
|||||||
}
|
}
|
||||||
_ = json.NewDecoder(c.Request().Body).Decode(&body)
|
_ = json.NewDecoder(c.Request().Body).Decode(&body)
|
||||||
theme := CoerceTheme(body.Theme)
|
theme := CoerceTheme(body.Theme)
|
||||||
tok, err := MintPassToken(srv.cfg.InvitePass.TokenSecret, did, theme, time.Now(), passTokenTTL)
|
tok, err := MintPassToken(srv.cfg.InvitePass.TokenSecret, did, theme, pdsHost, time.Now(), passTokenTTL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.JSON(http.StatusInternalServerError, echo.Map{"error": "TokenMintFailed"})
|
return c.JSON(http.StatusInternalServerError, echo.Map{"error": "TokenMintFailed"})
|
||||||
}
|
}
|
||||||
@@ -101,7 +136,7 @@ func (srv *Server) WebInvitePassPkpass(c echo.Context) error {
|
|||||||
}
|
}
|
||||||
theme := CoerceTheme(c.QueryParam("theme"))
|
theme := CoerceTheme(c.QueryParam("theme"))
|
||||||
tok := c.QueryParam("t")
|
tok := c.QueryParam("t")
|
||||||
did, tokTheme, err := VerifyPassToken(srv.cfg.InvitePass.TokenSecret, tok, time.Now())
|
did, tokTheme, pdsHost, err := VerifyPassToken(srv.cfg.InvitePass.TokenSecret, tok, time.Now())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.JSON(http.StatusGone, echo.Map{"error": "TokenInvalid"})
|
return c.JSON(http.StatusGone, echo.Map{"error": "TokenInvalid"})
|
||||||
}
|
}
|
||||||
@@ -112,12 +147,12 @@ func (srv *Server) WebInvitePassPkpass(c echo.Context) error {
|
|||||||
return c.JSON(http.StatusServiceUnavailable, echo.Map{"error": "SignerDisabled"})
|
return c.JSON(http.StatusServiceUnavailable, echo.Map{"error": "SignerDisabled"})
|
||||||
}
|
}
|
||||||
|
|
||||||
handle, displayName, avatarBytes, err := srv.fetchProfile(c.Request().Context(), did)
|
handle, displayName, createdAt, avatarBytes, err := srv.fetchProfile(c.Request().Context(), did)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.JSON(http.StatusBadGateway, echo.Map{"error": "ProfileFetchFailed"})
|
return c.JSON(http.StatusBadGateway, echo.Map{"error": "ProfileFetchFailed"})
|
||||||
}
|
}
|
||||||
|
|
||||||
passJSON, err := BuildPassJSON(did, handle, displayName, theme, srv.cfg.InvitePass.TeamID)
|
passJSON, err := BuildPassJSON(did, handle, displayName, pdsHost, theme, srv.cfg.InvitePass.TeamID, createdAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.JSON(http.StatusInternalServerError, echo.Map{"error": "PassBuildFailed"})
|
return c.JSON(http.StatusInternalServerError, echo.Map{"error": "PassBuildFailed"})
|
||||||
}
|
}
|
||||||
@@ -140,7 +175,7 @@ func (srv *Server) WebInvitePassPkpass(c echo.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (srv *Server) WebInviteWalletJWT(c echo.Context) error {
|
func (srv *Server) WebInviteWalletJWT(c echo.Context) error {
|
||||||
did, handle, err := srv.authenticator.Authenticate(c)
|
did, handle, _, err := srv.authenticator.Authenticate(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.JSON(http.StatusUnauthorized, echo.Map{"error": "AuthMissing"})
|
return c.JSON(http.StatusUnauthorized, echo.Map{"error": "AuthMissing"})
|
||||||
}
|
}
|
||||||
@@ -165,7 +200,7 @@ func (srv *Server) WebInviteWalletHero(c echo.Context) error {
|
|||||||
if did == "" {
|
if did == "" {
|
||||||
return c.NoContent(http.StatusBadRequest)
|
return c.NoContent(http.StatusBadRequest)
|
||||||
}
|
}
|
||||||
handle, _, avatarBytes, err := srv.fetchProfile(c.Request().Context(), did)
|
handle, _, _, avatarBytes, err := srv.fetchProfile(c.Request().Context(), did)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.JSON(http.StatusBadGateway, echo.Map{"error": "ProfileFetchFailed"})
|
return c.JSON(http.StatusBadGateway, echo.Map{"error": "ProfileFetchFailed"})
|
||||||
}
|
}
|
||||||
@@ -198,9 +233,17 @@ func (srv *Server) buildPassAssets(theme, handle string, avatarBytes []byte) ([]
|
|||||||
logo2, _ := readFS(srv.cfg.InvitePass.StripFS, "passes/logo@2x.png")
|
logo2, _ := readFS(srv.cfg.InvitePass.StripFS, "passes/logo@2x.png")
|
||||||
logo3, _ := readFS(srv.cfg.InvitePass.StripFS, "passes/logo@3x.png")
|
logo3, _ := readFS(srv.cfg.InvitePass.StripFS, "passes/logo@3x.png")
|
||||||
|
|
||||||
// Generic passes do not use a strip image. The whole pass renders as
|
// iOS 27 posterGeneric assets - shared across themes (no theme-specific
|
||||||
// backgroundColor. avatarBytes is currently unused; reserved for a future
|
// variants yet). primaryLogo is the big butterfly to the right; background
|
||||||
// design that adds a thumbnail.
|
// is the full-bleed dark navy. Optional - if missing, Wallet falls back to
|
||||||
|
// the iOS <= 26 generic layout that doesn't reference them.
|
||||||
|
primaryLogo2, _ := readFS(srv.cfg.InvitePass.StripFS, "passes/primaryLogo@2x.png")
|
||||||
|
primaryLogo3, _ := readFS(srv.cfg.InvitePass.StripFS, "passes/primaryLogo@3x.png")
|
||||||
|
background2, _ := readFS(srv.cfg.InvitePass.StripFS, "passes/background@2x.png")
|
||||||
|
background3, _ := readFS(srv.cfg.InvitePass.StripFS, "passes/background@3x.png")
|
||||||
|
|
||||||
|
// avatarBytes is currently unused on this pass design; reserved for a
|
||||||
|
// future thumbnail composite.
|
||||||
_ = avatarBytes
|
_ = avatarBytes
|
||||||
|
|
||||||
assets := []PassAsset{
|
assets := []PassAsset{
|
||||||
@@ -219,6 +262,18 @@ func (srv *Server) buildPassAssets(theme, handle string, avatarBytes []byte) ([]
|
|||||||
if len(logo3) > 0 {
|
if len(logo3) > 0 {
|
||||||
assets = append(assets, PassAsset{Name: "logo@3x.png", Data: logo3})
|
assets = append(assets, PassAsset{Name: "logo@3x.png", Data: logo3})
|
||||||
}
|
}
|
||||||
|
if len(primaryLogo2) > 0 {
|
||||||
|
assets = append(assets, PassAsset{Name: "primaryLogo@2x.png", Data: primaryLogo2})
|
||||||
|
}
|
||||||
|
if len(primaryLogo3) > 0 {
|
||||||
|
assets = append(assets, PassAsset{Name: "primaryLogo@3x.png", Data: primaryLogo3})
|
||||||
|
}
|
||||||
|
if len(background2) > 0 {
|
||||||
|
assets = append(assets, PassAsset{Name: "background@2x.png", Data: background2})
|
||||||
|
}
|
||||||
|
if len(background3) > 0 {
|
||||||
|
assets = append(assets, PassAsset{Name: "background@3x.png", Data: background3})
|
||||||
|
}
|
||||||
return assets, nil
|
return assets, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,19 +302,32 @@ func decodeImage(data []byte) (image.Image, error) {
|
|||||||
return img, err
|
return img, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (srv *Server) fetchProfile(ctx context.Context, did string) (handle, displayName string, avatarBytes []byte, err error) {
|
func (srv *Server) fetchProfile(ctx context.Context, did string) (handle, displayName string, createdAt time.Time, avatarBytes []byte, err error) {
|
||||||
pv, err := appbsky.ActorGetProfile(ctx, srv.xrpcc, did)
|
pv, err := appbsky.ActorGetProfile(ctx, srv.xrpcc, did)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", nil, err
|
return "", "", time.Time{}, nil, err
|
||||||
}
|
}
|
||||||
handle = pv.Handle
|
handle = pv.Handle
|
||||||
if pv.DisplayName != nil {
|
if pv.DisplayName != nil {
|
||||||
displayName = *pv.DisplayName
|
displayName = *pv.DisplayName
|
||||||
}
|
}
|
||||||
|
// CreatedAt is when the profile record was written, which is usually within
|
||||||
|
// seconds of account creation - close enough for a "member since" display.
|
||||||
|
// Fall back to IndexedAt if CreatedAt is missing (older profiles).
|
||||||
|
if pv.CreatedAt != nil {
|
||||||
|
if t, terr := time.Parse(time.RFC3339, *pv.CreatedAt); terr == nil {
|
||||||
|
createdAt = t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if createdAt.IsZero() && pv.IndexedAt != nil {
|
||||||
|
if t, terr := time.Parse(time.RFC3339, *pv.IndexedAt); terr == nil {
|
||||||
|
createdAt = t
|
||||||
|
}
|
||||||
|
}
|
||||||
if pv.Avatar != nil {
|
if pv.Avatar != nil {
|
||||||
// SSRF defense: only fetch https:// URLs
|
// SSRF defense: only fetch https:// URLs
|
||||||
if !strings.HasPrefix(*pv.Avatar, "https://") {
|
if !strings.HasPrefix(*pv.Avatar, "https://") {
|
||||||
return handle, displayName, nil, nil
|
return handle, displayName, createdAt, nil, nil
|
||||||
}
|
}
|
||||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, *pv.Avatar, nil)
|
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, *pv.Avatar, nil)
|
||||||
client := &http.Client{Timeout: 5 * time.Second}
|
client := &http.Client{Timeout: 5 * time.Second}
|
||||||
@@ -271,5 +339,5 @@ func (srv *Server) fetchProfile(ctx context.Context, did string) (handle, displa
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return handle, displayName, avatarBytes, nil
|
return handle, displayName, createdAt, avatarBytes, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,14 +12,15 @@ import (
|
|||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
// stubAuth replaces the real ServerGetSession check; bound DID is returned to the handler.
|
// stubAuth replaces the real ServerGetSession check; bound DID/handle/PDS are
|
||||||
type stubAuth struct{ did, handle string }
|
// returned to the handler.
|
||||||
|
type stubAuth struct{ did, handle, pds string }
|
||||||
|
|
||||||
func (s stubAuth) Authenticate(c echo.Context) (did, handle string, err error) {
|
func (s stubAuth) Authenticate(c echo.Context) (did, handle, pdsHost string, err error) {
|
||||||
if c.Request().Header.Get("Authorization") == "" {
|
if c.Request().Header.Get("Authorization") == "" {
|
||||||
return "", "", errAuthMissing
|
return "", "", "", errAuthMissing
|
||||||
}
|
}
|
||||||
return s.did, s.handle, nil
|
return s.did, s.handle, s.pds, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func newTestServer(t *testing.T) (*Server, *stubAuth) {
|
func newTestServer(t *testing.T) (*Server, *stubAuth) {
|
||||||
@@ -71,7 +72,7 @@ func TestWebInvitePassURL_OK(t *testing.T) {
|
|||||||
|
|
||||||
func TestWebInvitePassPkpass_TokenExpired(t *testing.T) {
|
func TestWebInvitePassPkpass_TokenExpired(t *testing.T) {
|
||||||
srv, _ := newTestServer(t)
|
srv, _ := newTestServer(t)
|
||||||
expiredTok, _ := MintPassToken(srv.cfg.InvitePass.TokenSecret, "did:plc:abc", "dusk",
|
expiredTok, _ := MintPassToken(srv.cfg.InvitePass.TokenSecret, "did:plc:abc", "dusk", "",
|
||||||
time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC), 60*time.Second)
|
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)
|
req := httptest.NewRequest(http.MethodGet, "/invite/pass.pkpass?theme=dusk&t="+expiredTok, nil)
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
@@ -83,7 +84,7 @@ func TestWebInvitePassPkpass_TokenExpired(t *testing.T) {
|
|||||||
|
|
||||||
func TestWebInvitePassPkpass_ThemeMismatchInToken(t *testing.T) {
|
func TestWebInvitePassPkpass_ThemeMismatchInToken(t *testing.T) {
|
||||||
srv, _ := newTestServer(t)
|
srv, _ := newTestServer(t)
|
||||||
tok, _ := MintPassToken(srv.cfg.InvitePass.TokenSecret, "did:plc:abc", "day", time.Now(), 60*time.Second)
|
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)
|
req := httptest.NewRequest(http.MethodGet, "/invite/pass.pkpass?theme=dusk&t="+tok, nil)
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
srv.echo.ServeHTTP(rec, req)
|
srv.echo.ServeHTTP(rec, req)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
var themeBgRGB = map[string]string{
|
var themeBgRGB = map[string]string{
|
||||||
@@ -29,11 +30,14 @@ func ThemeBackgroundRGB(theme string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type passField struct {
|
type passField struct {
|
||||||
Key string `json:"key"`
|
Key string `json:"key"`
|
||||||
Label string `json:"label"`
|
Label string `json:"label"`
|
||||||
Value string `json:"value"`
|
Value string `json:"value"`
|
||||||
|
DateStyle string `json:"dateStyle,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// passFields is the iOS <= 26 storeCard/generic layout: header, primary,
|
||||||
|
// secondary, back.
|
||||||
type passFields struct {
|
type passFields struct {
|
||||||
HeaderFields []passField `json:"headerFields"`
|
HeaderFields []passField `json:"headerFields"`
|
||||||
PrimaryFields []passField `json:"primaryFields"`
|
PrimaryFields []passField `json:"primaryFields"`
|
||||||
@@ -41,6 +45,17 @@ type passFields struct {
|
|||||||
BackFields []passField `json:"backFields"`
|
BackFields []passField `json:"backFields"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// posterFields is the iOS 27 layout used by Pass Designer. Adds auxiliary
|
||||||
|
// and footer field slots that the classic layout does not have.
|
||||||
|
type posterFields struct {
|
||||||
|
HeaderFields []passField `json:"headerFields"`
|
||||||
|
PrimaryFields []passField `json:"primaryFields"`
|
||||||
|
SecondaryFields []passField `json:"secondaryFields"`
|
||||||
|
AuxiliaryFields []passField `json:"auxiliaryFields"`
|
||||||
|
FooterFields []passField `json:"footerFields"`
|
||||||
|
BackFields []passField `json:"backFields"`
|
||||||
|
}
|
||||||
|
|
||||||
type barcode struct {
|
type barcode struct {
|
||||||
Format string `json:"format"`
|
Format string `json:"format"`
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
@@ -49,23 +64,35 @@ type barcode struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type pkPass struct {
|
type pkPass struct {
|
||||||
FormatVersion int `json:"formatVersion"`
|
FormatVersion int `json:"formatVersion"`
|
||||||
PassTypeIdentifier string `json:"passTypeIdentifier"`
|
PassTypeIdentifier string `json:"passTypeIdentifier"`
|
||||||
SerialNumber string `json:"serialNumber"`
|
SerialNumber string `json:"serialNumber"`
|
||||||
TeamIdentifier string `json:"teamIdentifier"`
|
TeamIdentifier string `json:"teamIdentifier"`
|
||||||
OrganizationName string `json:"organizationName"`
|
OrganizationName string `json:"organizationName"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
LogoText string `json:"logoText"`
|
LogoText string `json:"logoText"`
|
||||||
ForegroundColor string `json:"foregroundColor"`
|
ForegroundColor string `json:"foregroundColor"`
|
||||||
LabelColor string `json:"labelColor"`
|
LabelColor string `json:"labelColor"`
|
||||||
BackgroundColor string `json:"backgroundColor"`
|
BackgroundColor string `json:"backgroundColor"`
|
||||||
Generic passFields `json:"generic"`
|
Generic passFields `json:"generic"`
|
||||||
Barcodes []barcode `json:"barcodes"`
|
PosterGeneric posterFields `json:"posterGeneric"`
|
||||||
|
SuppressHeaderDarkening bool `json:"suppressHeaderDarkening"`
|
||||||
|
UseAutomaticColors bool `json:"useAutomaticColors"`
|
||||||
|
Barcodes []barcode `json:"barcodes"`
|
||||||
}
|
}
|
||||||
|
|
||||||
const passTypeIdentifier = "pass.xyz.blueskyweb.app"
|
const passTypeIdentifier = "pass.xyz.blueskyweb.app"
|
||||||
|
|
||||||
func BuildPassJSON(did, handle, displayName, theme, teamID string) ([]byte, error) {
|
// BuildPassJSON builds the pkpass JSON for both the iOS <= 26 fallback layout
|
||||||
|
// (`generic` block) and the iOS 27 layout (`posterGeneric` block). The two
|
||||||
|
// blocks describe the same fields in the layouts each iOS version expects:
|
||||||
|
// the user sees the poster layout on iOS 27+ and the legacy layout below that.
|
||||||
|
//
|
||||||
|
// pdsHost is the hostname extracted from the user's DID document
|
||||||
|
// serviceEndpoint (e.g. "suillus.us-west.host.bsky.network").
|
||||||
|
// createdAt is the user's profile record creation time, used as a rough
|
||||||
|
// stand-in for account creation.
|
||||||
|
func BuildPassJSON(did, handle, displayName, pdsHost, theme, teamID string, createdAt time.Time) ([]byte, error) {
|
||||||
theme = CoerceTheme(theme)
|
theme = CoerceTheme(theme)
|
||||||
profileURL := "https://bsky.app/profile/" + handle
|
profileURL := "https://bsky.app/profile/" + handle
|
||||||
atHandle := "@" + handle
|
atHandle := "@" + handle
|
||||||
@@ -73,35 +100,55 @@ func BuildPassJSON(did, handle, displayName, theme, teamID string) ([]byte, erro
|
|||||||
if memberName == "" {
|
if memberName == "" {
|
||||||
memberName = atHandle
|
memberName = atHandle
|
||||||
}
|
}
|
||||||
|
if pdsHost == "" {
|
||||||
|
pdsHost = "bsky.social"
|
||||||
|
}
|
||||||
|
createdAtISO := ""
|
||||||
|
if !createdAt.IsZero() {
|
||||||
|
createdAtISO = createdAt.UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
|
||||||
|
pdsField := passField{Key: "pds", Label: "PDS", Value: pdsHost}
|
||||||
|
nameField := passField{Key: "name", Label: "NAME", Value: memberName}
|
||||||
|
didField := passField{Key: "did", Label: "DID", Value: did}
|
||||||
|
sinceField := passField{Key: "since", Label: "MEMBER SINCE", Value: createdAtISO, DateStyle: "PKDateStyleShort"}
|
||||||
|
backFields := []passField{
|
||||||
|
{Key: "about", Label: "About", Value: "Scan the QR code to view this Bluesky profile."},
|
||||||
|
{Key: "url", Label: "Profile URL", Value: profileURL},
|
||||||
|
}
|
||||||
|
|
||||||
p := pkPass{
|
p := pkPass{
|
||||||
FormatVersion: 1,
|
FormatVersion: 1,
|
||||||
PassTypeIdentifier: passTypeIdentifier,
|
PassTypeIdentifier: passTypeIdentifier,
|
||||||
SerialNumber: did + "-" + theme + "-v1",
|
SerialNumber: did + "-" + theme + "-v1",
|
||||||
TeamIdentifier: teamID,
|
TeamIdentifier: teamID,
|
||||||
OrganizationName: "Bluesky",
|
OrganizationName: "Bluesky",
|
||||||
Description: "Bluesky invite - " + atHandle,
|
Description: "Bluesky profile - " + atHandle,
|
||||||
LogoText: "",
|
LogoText: "Bluesky",
|
||||||
ForegroundColor: "rgb(255, 255, 255)",
|
ForegroundColor: "rgb(255, 255, 255)",
|
||||||
LabelColor: "rgb(255, 255, 255)",
|
LabelColor: "rgb(255, 255, 255)",
|
||||||
BackgroundColor: ThemeBackgroundRGB(theme),
|
BackgroundColor: ThemeBackgroundRGB(theme),
|
||||||
Generic: passFields{
|
Generic: passFields{
|
||||||
HeaderFields: []passField{
|
HeaderFields: []passField{pdsField},
|
||||||
{Key: "username", Label: "USERNAME", Value: atHandle},
|
PrimaryFields: []passField{nameField},
|
||||||
},
|
SecondaryFields: []passField{didField, sinceField},
|
||||||
PrimaryFields: []passField{
|
BackFields: backFields,
|
||||||
{Key: "name", Label: "MEMBER NAME", Value: memberName},
|
|
||||||
},
|
|
||||||
SecondaryFields: []passField{},
|
|
||||||
BackFields: []passField{
|
|
||||||
{Key: "about", Label: "About", Value: "Scan the QR code to view this Bluesky profile."},
|
|
||||||
{Key: "url", Label: "Profile URL", Value: profileURL},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
PosterGeneric: posterFields{
|
||||||
|
HeaderFields: []passField{pdsField},
|
||||||
|
PrimaryFields: []passField{nameField},
|
||||||
|
SecondaryFields: []passField{didField, sinceField},
|
||||||
|
AuxiliaryFields: []passField{},
|
||||||
|
FooterFields: []passField{},
|
||||||
|
BackFields: backFields,
|
||||||
|
},
|
||||||
|
SuppressHeaderDarkening: false,
|
||||||
|
UseAutomaticColors: false,
|
||||||
Barcodes: []barcode{{
|
Barcodes: []barcode{{
|
||||||
Format: "PKBarcodeFormatQR",
|
Format: "PKBarcodeFormatQR",
|
||||||
Message: profileURL,
|
Message: profileURL,
|
||||||
MessageEncoding: "iso-8859-1",
|
MessageEncoding: "iso-8859-1",
|
||||||
AltText: atHandle,
|
AltText: handle,
|
||||||
}},
|
}},
|
||||||
}
|
}
|
||||||
return json.MarshalIndent(p, "", " ")
|
return json.MarshalIndent(p, "", " ")
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestCoerceTheme(t *testing.T) {
|
func TestCoerceTheme(t *testing.T) {
|
||||||
@@ -38,7 +39,8 @@ func TestThemeBackgroundRGB(t *testing.T) {
|
|||||||
func TestBuildPassJSON_Golden(t *testing.T) {
|
func TestBuildPassJSON_Golden(t *testing.T) {
|
||||||
for _, theme := range []string{"dawn", "day", "dusk", "night"} {
|
for _, theme := range []string{"dawn", "day", "dusk", "night"} {
|
||||||
t.Run(theme, func(t *testing.T) {
|
t.Run(theme, func(t *testing.T) {
|
||||||
got, err := BuildPassJSON("did:plc:abc123", "alice.bsky.social", "Alice", theme, "TEAMID00")
|
createdAt := time.Date(2026, 1, 25, 23, 59, 5, 0, time.UTC)
|
||||||
|
got, err := BuildPassJSON("did:plc:abc123", "alice.bsky.social", "Alice", "alice.host.bsky.network", theme, "TEAMID00", createdAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("build: %v", err)
|
t.Fatalf("build: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,11 +19,12 @@ var (
|
|||||||
type passTokenPayload struct {
|
type passTokenPayload struct {
|
||||||
Did string `json:"d"`
|
Did string `json:"d"`
|
||||||
Theme string `json:"t"`
|
Theme string `json:"t"`
|
||||||
|
PDS string `json:"p,omitempty"`
|
||||||
Exp int64 `json:"e"`
|
Exp int64 `json:"e"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func MintPassToken(secret []byte, did, theme string, now time.Time, ttl time.Duration) (string, error) {
|
func MintPassToken(secret []byte, did, theme, pdsHost string, now time.Time, ttl time.Duration) (string, error) {
|
||||||
p := passTokenPayload{Did: did, Theme: theme, Exp: now.Add(ttl).Unix()}
|
p := passTokenPayload{Did: did, Theme: theme, PDS: pdsHost, Exp: now.Add(ttl).Unix()}
|
||||||
body, err := json.Marshal(p)
|
body, err := json.Marshal(p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -35,27 +36,27 @@ func MintPassToken(secret []byte, did, theme string, now time.Time, ttl time.Dur
|
|||||||
return bodyB64 + "." + sig, nil
|
return bodyB64 + "." + sig, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func VerifyPassToken(secret []byte, token string, now time.Time) (string, string, error) {
|
func VerifyPassToken(secret []byte, token string, now time.Time) (did, theme, pdsHost string, err error) {
|
||||||
parts := strings.SplitN(token, ".", 2)
|
parts := strings.SplitN(token, ".", 2)
|
||||||
if len(parts) != 2 {
|
if len(parts) != 2 {
|
||||||
return "", "", ErrTokenInvalid
|
return "", "", "", ErrTokenInvalid
|
||||||
}
|
}
|
||||||
mac := hmac.New(sha256.New, secret)
|
mac := hmac.New(sha256.New, secret)
|
||||||
mac.Write([]byte(parts[0]))
|
mac.Write([]byte(parts[0]))
|
||||||
want := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
want := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||||
if !hmac.Equal([]byte(want), []byte(parts[1])) {
|
if !hmac.Equal([]byte(want), []byte(parts[1])) {
|
||||||
return "", "", ErrTokenInvalid
|
return "", "", "", ErrTokenInvalid
|
||||||
}
|
}
|
||||||
body, err := base64.RawURLEncoding.DecodeString(parts[0])
|
body, perr := base64.RawURLEncoding.DecodeString(parts[0])
|
||||||
if err != nil {
|
if perr != nil {
|
||||||
return "", "", ErrTokenInvalid
|
return "", "", "", ErrTokenInvalid
|
||||||
}
|
}
|
||||||
var p passTokenPayload
|
var p passTokenPayload
|
||||||
if err := json.Unmarshal(body, &p); err != nil {
|
if uerr := json.Unmarshal(body, &p); uerr != nil {
|
||||||
return "", "", ErrTokenInvalid
|
return "", "", "", ErrTokenInvalid
|
||||||
}
|
}
|
||||||
if now.Unix() > p.Exp {
|
if now.Unix() > p.Exp {
|
||||||
return "", "", ErrTokenExpired
|
return "", "", "", ErrTokenExpired
|
||||||
}
|
}
|
||||||
return p.Did, p.Theme, nil
|
return p.Did, p.Theme, p.PDS, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,24 +10,40 @@ import (
|
|||||||
func TestPassToken_RoundTrip(t *testing.T) {
|
func TestPassToken_RoundTrip(t *testing.T) {
|
||||||
secret := []byte("test-secret-32-bytes-of-random!!")
|
secret := []byte("test-secret-32-bytes-of-random!!")
|
||||||
now := time.Date(2026, 6, 25, 12, 0, 0, 0, time.UTC)
|
now := time.Date(2026, 6, 25, 12, 0, 0, 0, time.UTC)
|
||||||
tok, err := MintPassToken(secret, "did:plc:abc", "dusk", now, 60*time.Second)
|
tok, err := MintPassToken(secret, "did:plc:abc", "dusk", "suillus.us-west.host.bsky.network", now, 60*time.Second)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("mint: %v", err)
|
t.Fatalf("mint: %v", err)
|
||||||
}
|
}
|
||||||
gotDid, gotTheme, err := VerifyPassToken(secret, tok, now.Add(10*time.Second))
|
gotDid, gotTheme, gotPDS, err := VerifyPassToken(secret, tok, now.Add(10*time.Second))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("verify: %v", err)
|
t.Fatalf("verify: %v", err)
|
||||||
}
|
}
|
||||||
if gotDid != "did:plc:abc" || gotTheme != "dusk" {
|
if gotDid != "did:plc:abc" || gotTheme != "dusk" || gotPDS != "suillus.us-west.host.bsky.network" {
|
||||||
t.Fatalf("mismatch: %q %q", gotDid, gotTheme)
|
t.Fatalf("mismatch: %q %q %q", gotDid, gotTheme, gotPDS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPassToken_RoundTripEmptyPDS(t *testing.T) {
|
||||||
|
secret := []byte("test-secret-32-bytes-of-random!!")
|
||||||
|
now := time.Date(2026, 6, 25, 12, 0, 0, 0, time.UTC)
|
||||||
|
tok, err := MintPassToken(secret, "did:plc:abc", "day", "", now, 60*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("mint: %v", err)
|
||||||
|
}
|
||||||
|
_, _, gotPDS, err := VerifyPassToken(secret, tok, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("verify: %v", err)
|
||||||
|
}
|
||||||
|
if gotPDS != "" {
|
||||||
|
t.Fatalf("expected empty PDS, got %q", gotPDS)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPassToken_Expired(t *testing.T) {
|
func TestPassToken_Expired(t *testing.T) {
|
||||||
secret := []byte("test-secret-32-bytes-of-random!!")
|
secret := []byte("test-secret-32-bytes-of-random!!")
|
||||||
now := time.Date(2026, 6, 25, 12, 0, 0, 0, time.UTC)
|
now := time.Date(2026, 6, 25, 12, 0, 0, 0, time.UTC)
|
||||||
tok, _ := MintPassToken(secret, "did:plc:abc", "day", now, 60*time.Second)
|
tok, _ := MintPassToken(secret, "did:plc:abc", "day", "", now, 60*time.Second)
|
||||||
_, _, err := VerifyPassToken(secret, tok, now.Add(61*time.Second))
|
_, _, _, err := VerifyPassToken(secret, tok, now.Add(61*time.Second))
|
||||||
if !errors.Is(err, ErrTokenExpired) {
|
if !errors.Is(err, ErrTokenExpired) {
|
||||||
t.Fatalf("want ErrTokenExpired, got %v", err)
|
t.Fatalf("want ErrTokenExpired, got %v", err)
|
||||||
}
|
}
|
||||||
@@ -37,8 +53,8 @@ func TestPassToken_BadSignature(t *testing.T) {
|
|||||||
secretA := []byte("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
|
secretA := []byte("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
|
||||||
secretB := []byte("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb")
|
secretB := []byte("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb")
|
||||||
now := time.Date(2026, 6, 25, 12, 0, 0, 0, time.UTC)
|
now := time.Date(2026, 6, 25, 12, 0, 0, 0, time.UTC)
|
||||||
tok, _ := MintPassToken(secretA, "did:plc:abc", "day", now, 60*time.Second)
|
tok, _ := MintPassToken(secretA, "did:plc:abc", "day", "", now, 60*time.Second)
|
||||||
_, _, err := VerifyPassToken(secretB, tok, now)
|
_, _, _, err := VerifyPassToken(secretB, tok, now)
|
||||||
if !errors.Is(err, ErrTokenInvalid) {
|
if !errors.Is(err, ErrTokenInvalid) {
|
||||||
t.Fatalf("want ErrTokenInvalid, got %v", err)
|
t.Fatalf("want ErrTokenInvalid, got %v", err)
|
||||||
}
|
}
|
||||||
@@ -47,11 +63,11 @@ func TestPassToken_BadSignature(t *testing.T) {
|
|||||||
func TestPassToken_Mutated(t *testing.T) {
|
func TestPassToken_Mutated(t *testing.T) {
|
||||||
secret := []byte("test-secret-32-bytes-of-random!!")
|
secret := []byte("test-secret-32-bytes-of-random!!")
|
||||||
now := time.Date(2026, 6, 25, 12, 0, 0, 0, time.UTC)
|
now := time.Date(2026, 6, 25, 12, 0, 0, 0, time.UTC)
|
||||||
tok, _ := MintPassToken(secret, "did:plc:abc", "day", now, 60*time.Second)
|
tok, _ := MintPassToken(secret, "did:plc:abc", "day", "", now, 60*time.Second)
|
||||||
// flip a byte in the middle of the payload portion
|
// flip a byte in the middle of the payload portion
|
||||||
mutated := []byte(tok)
|
mutated := []byte(tok)
|
||||||
mutated[10] ^= 0x01
|
mutated[10] ^= 0x01
|
||||||
_, _, err := VerifyPassToken(secret, string(mutated), now)
|
_, _, _, err := VerifyPassToken(secret, string(mutated), now)
|
||||||
if !errors.Is(err, ErrTokenInvalid) {
|
if !errors.Is(err, ErrTokenInvalid) {
|
||||||
t.Fatalf("want ErrTokenInvalid, got %v", err)
|
t.Fatalf("want ErrTokenInvalid, got %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -198,7 +198,10 @@ func serve(cctx *cli.Context) error {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
server.authenticator = XrpcAuthenticator{BaseHost: appviewHost}
|
// Auth check (`com.atproto.server.getSession`) must go to a PDS, not the
|
||||||
|
// appview - the appview doesn't validate user accessJwts. bsky.social
|
||||||
|
// proxies getSession to each user's actual home PDS.
|
||||||
|
server.authenticator = XrpcAuthenticator{BaseHost: "https://bsky.social"}
|
||||||
|
|
||||||
// Create the HTTP server.
|
// Create the HTTP server.
|
||||||
server.httpd = &http.Server{
|
server.httpd = &http.Server{
|
||||||
|
|||||||
+65
-8
@@ -4,27 +4,39 @@
|
|||||||
"serialNumber": "did:plc:abc123-dawn-v1",
|
"serialNumber": "did:plc:abc123-dawn-v1",
|
||||||
"teamIdentifier": "TEAMID00",
|
"teamIdentifier": "TEAMID00",
|
||||||
"organizationName": "Bluesky",
|
"organizationName": "Bluesky",
|
||||||
"description": "Bluesky invite - @alice.bsky.social",
|
"description": "Bluesky profile - @alice.bsky.social",
|
||||||
"logoText": "",
|
"logoText": "Bluesky",
|
||||||
"foregroundColor": "rgb(255, 255, 255)",
|
"foregroundColor": "rgb(255, 255, 255)",
|
||||||
"labelColor": "rgb(255, 255, 255)",
|
"labelColor": "rgb(255, 255, 255)",
|
||||||
"backgroundColor": "rgb(255, 109, 190)",
|
"backgroundColor": "rgb(255, 109, 190)",
|
||||||
"generic": {
|
"generic": {
|
||||||
"headerFields": [
|
"headerFields": [
|
||||||
{
|
{
|
||||||
"key": "username",
|
"key": "pds",
|
||||||
"label": "USERNAME",
|
"label": "PDS",
|
||||||
"value": "@alice.bsky.social"
|
"value": "alice.host.bsky.network"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"primaryFields": [
|
"primaryFields": [
|
||||||
{
|
{
|
||||||
"key": "name",
|
"key": "name",
|
||||||
"label": "MEMBER NAME",
|
"label": "NAME",
|
||||||
"value": "Alice"
|
"value": "Alice"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"secondaryFields": [],
|
"secondaryFields": [
|
||||||
|
{
|
||||||
|
"key": "did",
|
||||||
|
"label": "DID",
|
||||||
|
"value": "did:plc:abc123"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "since",
|
||||||
|
"label": "MEMBER SINCE",
|
||||||
|
"value": "2026-01-25T23:59:05Z",
|
||||||
|
"dateStyle": "PKDateStyleShort"
|
||||||
|
}
|
||||||
|
],
|
||||||
"backFields": [
|
"backFields": [
|
||||||
{
|
{
|
||||||
"key": "about",
|
"key": "about",
|
||||||
@@ -38,12 +50,57 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"posterGeneric": {
|
||||||
|
"headerFields": [
|
||||||
|
{
|
||||||
|
"key": "pds",
|
||||||
|
"label": "PDS",
|
||||||
|
"value": "alice.host.bsky.network"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryFields": [
|
||||||
|
{
|
||||||
|
"key": "name",
|
||||||
|
"label": "NAME",
|
||||||
|
"value": "Alice"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"secondaryFields": [
|
||||||
|
{
|
||||||
|
"key": "did",
|
||||||
|
"label": "DID",
|
||||||
|
"value": "did:plc:abc123"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "since",
|
||||||
|
"label": "MEMBER SINCE",
|
||||||
|
"value": "2026-01-25T23:59:05Z",
|
||||||
|
"dateStyle": "PKDateStyleShort"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"auxiliaryFields": [],
|
||||||
|
"footerFields": [],
|
||||||
|
"backFields": [
|
||||||
|
{
|
||||||
|
"key": "about",
|
||||||
|
"label": "About",
|
||||||
|
"value": "Scan the QR code to view this Bluesky profile."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "url",
|
||||||
|
"label": "Profile URL",
|
||||||
|
"value": "https://bsky.app/profile/alice.bsky.social"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"suppressHeaderDarkening": false,
|
||||||
|
"useAutomaticColors": false,
|
||||||
"barcodes": [
|
"barcodes": [
|
||||||
{
|
{
|
||||||
"format": "PKBarcodeFormatQR",
|
"format": "PKBarcodeFormatQR",
|
||||||
"message": "https://bsky.app/profile/alice.bsky.social",
|
"message": "https://bsky.app/profile/alice.bsky.social",
|
||||||
"messageEncoding": "iso-8859-1",
|
"messageEncoding": "iso-8859-1",
|
||||||
"altText": "@alice.bsky.social"
|
"altText": "alice.bsky.social"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
+65
-8
@@ -4,27 +4,39 @@
|
|||||||
"serialNumber": "did:plc:abc123-day-v1",
|
"serialNumber": "did:plc:abc123-day-v1",
|
||||||
"teamIdentifier": "TEAMID00",
|
"teamIdentifier": "TEAMID00",
|
||||||
"organizationName": "Bluesky",
|
"organizationName": "Bluesky",
|
||||||
"description": "Bluesky invite - @alice.bsky.social",
|
"description": "Bluesky profile - @alice.bsky.social",
|
||||||
"logoText": "",
|
"logoText": "Bluesky",
|
||||||
"foregroundColor": "rgb(255, 255, 255)",
|
"foregroundColor": "rgb(255, 255, 255)",
|
||||||
"labelColor": "rgb(255, 255, 255)",
|
"labelColor": "rgb(255, 255, 255)",
|
||||||
"backgroundColor": "rgb(117, 175, 255)",
|
"backgroundColor": "rgb(117, 175, 255)",
|
||||||
"generic": {
|
"generic": {
|
||||||
"headerFields": [
|
"headerFields": [
|
||||||
{
|
{
|
||||||
"key": "username",
|
"key": "pds",
|
||||||
"label": "USERNAME",
|
"label": "PDS",
|
||||||
"value": "@alice.bsky.social"
|
"value": "alice.host.bsky.network"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"primaryFields": [
|
"primaryFields": [
|
||||||
{
|
{
|
||||||
"key": "name",
|
"key": "name",
|
||||||
"label": "MEMBER NAME",
|
"label": "NAME",
|
||||||
"value": "Alice"
|
"value": "Alice"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"secondaryFields": [],
|
"secondaryFields": [
|
||||||
|
{
|
||||||
|
"key": "did",
|
||||||
|
"label": "DID",
|
||||||
|
"value": "did:plc:abc123"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "since",
|
||||||
|
"label": "MEMBER SINCE",
|
||||||
|
"value": "2026-01-25T23:59:05Z",
|
||||||
|
"dateStyle": "PKDateStyleShort"
|
||||||
|
}
|
||||||
|
],
|
||||||
"backFields": [
|
"backFields": [
|
||||||
{
|
{
|
||||||
"key": "about",
|
"key": "about",
|
||||||
@@ -38,12 +50,57 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"posterGeneric": {
|
||||||
|
"headerFields": [
|
||||||
|
{
|
||||||
|
"key": "pds",
|
||||||
|
"label": "PDS",
|
||||||
|
"value": "alice.host.bsky.network"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryFields": [
|
||||||
|
{
|
||||||
|
"key": "name",
|
||||||
|
"label": "NAME",
|
||||||
|
"value": "Alice"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"secondaryFields": [
|
||||||
|
{
|
||||||
|
"key": "did",
|
||||||
|
"label": "DID",
|
||||||
|
"value": "did:plc:abc123"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "since",
|
||||||
|
"label": "MEMBER SINCE",
|
||||||
|
"value": "2026-01-25T23:59:05Z",
|
||||||
|
"dateStyle": "PKDateStyleShort"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"auxiliaryFields": [],
|
||||||
|
"footerFields": [],
|
||||||
|
"backFields": [
|
||||||
|
{
|
||||||
|
"key": "about",
|
||||||
|
"label": "About",
|
||||||
|
"value": "Scan the QR code to view this Bluesky profile."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "url",
|
||||||
|
"label": "Profile URL",
|
||||||
|
"value": "https://bsky.app/profile/alice.bsky.social"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"suppressHeaderDarkening": false,
|
||||||
|
"useAutomaticColors": false,
|
||||||
"barcodes": [
|
"barcodes": [
|
||||||
{
|
{
|
||||||
"format": "PKBarcodeFormatQR",
|
"format": "PKBarcodeFormatQR",
|
||||||
"message": "https://bsky.app/profile/alice.bsky.social",
|
"message": "https://bsky.app/profile/alice.bsky.social",
|
||||||
"messageEncoding": "iso-8859-1",
|
"messageEncoding": "iso-8859-1",
|
||||||
"altText": "@alice.bsky.social"
|
"altText": "alice.bsky.social"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
+65
-8
@@ -4,27 +4,39 @@
|
|||||||
"serialNumber": "did:plc:abc123-dusk-v1",
|
"serialNumber": "did:plc:abc123-dusk-v1",
|
||||||
"teamIdentifier": "TEAMID00",
|
"teamIdentifier": "TEAMID00",
|
||||||
"organizationName": "Bluesky",
|
"organizationName": "Bluesky",
|
||||||
"description": "Bluesky invite - @alice.bsky.social",
|
"description": "Bluesky profile - @alice.bsky.social",
|
||||||
"logoText": "",
|
"logoText": "Bluesky",
|
||||||
"foregroundColor": "rgb(255, 255, 255)",
|
"foregroundColor": "rgb(255, 255, 255)",
|
||||||
"labelColor": "rgb(255, 255, 255)",
|
"labelColor": "rgb(255, 255, 255)",
|
||||||
"backgroundColor": "rgb(177, 90, 162)",
|
"backgroundColor": "rgb(177, 90, 162)",
|
||||||
"generic": {
|
"generic": {
|
||||||
"headerFields": [
|
"headerFields": [
|
||||||
{
|
{
|
||||||
"key": "username",
|
"key": "pds",
|
||||||
"label": "USERNAME",
|
"label": "PDS",
|
||||||
"value": "@alice.bsky.social"
|
"value": "alice.host.bsky.network"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"primaryFields": [
|
"primaryFields": [
|
||||||
{
|
{
|
||||||
"key": "name",
|
"key": "name",
|
||||||
"label": "MEMBER NAME",
|
"label": "NAME",
|
||||||
"value": "Alice"
|
"value": "Alice"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"secondaryFields": [],
|
"secondaryFields": [
|
||||||
|
{
|
||||||
|
"key": "did",
|
||||||
|
"label": "DID",
|
||||||
|
"value": "did:plc:abc123"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "since",
|
||||||
|
"label": "MEMBER SINCE",
|
||||||
|
"value": "2026-01-25T23:59:05Z",
|
||||||
|
"dateStyle": "PKDateStyleShort"
|
||||||
|
}
|
||||||
|
],
|
||||||
"backFields": [
|
"backFields": [
|
||||||
{
|
{
|
||||||
"key": "about",
|
"key": "about",
|
||||||
@@ -38,12 +50,57 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"posterGeneric": {
|
||||||
|
"headerFields": [
|
||||||
|
{
|
||||||
|
"key": "pds",
|
||||||
|
"label": "PDS",
|
||||||
|
"value": "alice.host.bsky.network"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryFields": [
|
||||||
|
{
|
||||||
|
"key": "name",
|
||||||
|
"label": "NAME",
|
||||||
|
"value": "Alice"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"secondaryFields": [
|
||||||
|
{
|
||||||
|
"key": "did",
|
||||||
|
"label": "DID",
|
||||||
|
"value": "did:plc:abc123"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "since",
|
||||||
|
"label": "MEMBER SINCE",
|
||||||
|
"value": "2026-01-25T23:59:05Z",
|
||||||
|
"dateStyle": "PKDateStyleShort"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"auxiliaryFields": [],
|
||||||
|
"footerFields": [],
|
||||||
|
"backFields": [
|
||||||
|
{
|
||||||
|
"key": "about",
|
||||||
|
"label": "About",
|
||||||
|
"value": "Scan the QR code to view this Bluesky profile."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "url",
|
||||||
|
"label": "Profile URL",
|
||||||
|
"value": "https://bsky.app/profile/alice.bsky.social"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"suppressHeaderDarkening": false,
|
||||||
|
"useAutomaticColors": false,
|
||||||
"barcodes": [
|
"barcodes": [
|
||||||
{
|
{
|
||||||
"format": "PKBarcodeFormatQR",
|
"format": "PKBarcodeFormatQR",
|
||||||
"message": "https://bsky.app/profile/alice.bsky.social",
|
"message": "https://bsky.app/profile/alice.bsky.social",
|
||||||
"messageEncoding": "iso-8859-1",
|
"messageEncoding": "iso-8859-1",
|
||||||
"altText": "@alice.bsky.social"
|
"altText": "alice.bsky.social"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
+65
-8
@@ -4,27 +4,39 @@
|
|||||||
"serialNumber": "did:plc:abc123-night-v1",
|
"serialNumber": "did:plc:abc123-night-v1",
|
||||||
"teamIdentifier": "TEAMID00",
|
"teamIdentifier": "TEAMID00",
|
||||||
"organizationName": "Bluesky",
|
"organizationName": "Bluesky",
|
||||||
"description": "Bluesky invite - @alice.bsky.social",
|
"description": "Bluesky profile - @alice.bsky.social",
|
||||||
"logoText": "",
|
"logoText": "Bluesky",
|
||||||
"foregroundColor": "rgb(255, 255, 255)",
|
"foregroundColor": "rgb(255, 255, 255)",
|
||||||
"labelColor": "rgb(255, 255, 255)",
|
"labelColor": "rgb(255, 255, 255)",
|
||||||
"backgroundColor": "rgb(0, 21, 51)",
|
"backgroundColor": "rgb(0, 21, 51)",
|
||||||
"generic": {
|
"generic": {
|
||||||
"headerFields": [
|
"headerFields": [
|
||||||
{
|
{
|
||||||
"key": "username",
|
"key": "pds",
|
||||||
"label": "USERNAME",
|
"label": "PDS",
|
||||||
"value": "@alice.bsky.social"
|
"value": "alice.host.bsky.network"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"primaryFields": [
|
"primaryFields": [
|
||||||
{
|
{
|
||||||
"key": "name",
|
"key": "name",
|
||||||
"label": "MEMBER NAME",
|
"label": "NAME",
|
||||||
"value": "Alice"
|
"value": "Alice"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"secondaryFields": [],
|
"secondaryFields": [
|
||||||
|
{
|
||||||
|
"key": "did",
|
||||||
|
"label": "DID",
|
||||||
|
"value": "did:plc:abc123"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "since",
|
||||||
|
"label": "MEMBER SINCE",
|
||||||
|
"value": "2026-01-25T23:59:05Z",
|
||||||
|
"dateStyle": "PKDateStyleShort"
|
||||||
|
}
|
||||||
|
],
|
||||||
"backFields": [
|
"backFields": [
|
||||||
{
|
{
|
||||||
"key": "about",
|
"key": "about",
|
||||||
@@ -38,12 +50,57 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"posterGeneric": {
|
||||||
|
"headerFields": [
|
||||||
|
{
|
||||||
|
"key": "pds",
|
||||||
|
"label": "PDS",
|
||||||
|
"value": "alice.host.bsky.network"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryFields": [
|
||||||
|
{
|
||||||
|
"key": "name",
|
||||||
|
"label": "NAME",
|
||||||
|
"value": "Alice"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"secondaryFields": [
|
||||||
|
{
|
||||||
|
"key": "did",
|
||||||
|
"label": "DID",
|
||||||
|
"value": "did:plc:abc123"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "since",
|
||||||
|
"label": "MEMBER SINCE",
|
||||||
|
"value": "2026-01-25T23:59:05Z",
|
||||||
|
"dateStyle": "PKDateStyleShort"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"auxiliaryFields": [],
|
||||||
|
"footerFields": [],
|
||||||
|
"backFields": [
|
||||||
|
{
|
||||||
|
"key": "about",
|
||||||
|
"label": "About",
|
||||||
|
"value": "Scan the QR code to view this Bluesky profile."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "url",
|
||||||
|
"label": "Profile URL",
|
||||||
|
"value": "https://bsky.app/profile/alice.bsky.social"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"suppressHeaderDarkening": false,
|
||||||
|
"useAutomaticColors": false,
|
||||||
"barcodes": [
|
"barcodes": [
|
||||||
{
|
{
|
||||||
"format": "PKBarcodeFormatQR",
|
"format": "PKBarcodeFormatQR",
|
||||||
"message": "https://bsky.app/profile/alice.bsky.social",
|
"message": "https://bsky.app/profile/alice.bsky.social",
|
||||||
"messageEncoding": "iso-8859-1",
|
"messageEncoding": "iso-8859-1",
|
||||||
"altText": "@alice.bsky.social"
|
"altText": "alice.bsky.social"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Executable
+9
@@ -0,0 +1,9 @@
|
|||||||
|
HANDLE="thepope.dev"
|
||||||
|
read -s -p "App password: " PW; echo
|
||||||
|
|
||||||
|
JWT=$(curl -s -X POST https://bsky.social/xrpc/com.atproto.server.createSession \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"identifier\":\"$HANDLE\",\"password\":\"$PW\"}" \
|
||||||
|
| jq -r .accessJwt)
|
||||||
|
|
||||||
|
echo "Got JWT (length: ${#JWT})"
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 467 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 778 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.4 KiB |
Reference in New Issue
Block a user