Experimenting with AT Protocol to hit up your friends

Much bigger refactor; break out separate packages

+1 -1
api/api.go
··· 11 11 ActorProfileNSID = "app.atyo.actor.profile" 12 12 PingNSID = "app.atyo.ping" 13 13 14 - SelfActorProfile = "self" 14 + ActorProfileRKeySelf = "self" 15 15 )
+56
internal/auth/auth.go
··· 1 + package auth 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "os" 7 + 8 + "atyo.app/internal/lookup" 9 + "github.com/bluesky-social/indigo/api/atproto" 10 + "github.com/bluesky-social/indigo/lex/util" 11 + "github.com/bluesky-social/indigo/xrpc" 12 + ) 13 + 14 + type Client struct { 15 + directory *lookup.Directory 16 + xrpc *xrpc.Client 17 + } 18 + 19 + func NewClient(directory *lookup.Directory) *Client { 20 + return &Client{ 21 + directory: directory, 22 + xrpc: &xrpc.Client{}, 23 + } 24 + } 25 + 26 + func (c *Client) IsLoggedIn() bool { 27 + return c.xrpc.Auth != nil 28 + } 29 + 30 + // LexDo implements [util.LexClient]. 31 + func (c *Client) LexDo( 32 + ctx context.Context, 33 + method, inputEncoding, endpoint string, 34 + params map[string]any, 35 + bodyData, out any, 36 + ) error { 37 + return c.xrpc.LexDo(ctx, method, inputEncoding, endpoint, params, bodyData, out) 38 + } 39 + 40 + func (c *Client) PutRecord( 41 + ctx context.Context, 42 + collection string, 43 + record *util.LexiconTypeDecoder, 44 + rkey string, 45 + ) error { 46 + output, err := atproto.RepoPutRecord(ctx, c, &atproto.RepoPutRecord_Input{ 47 + Collection: collection, 48 + Record: record, 49 + Repo: c.xrpc.Auth.Did, 50 + Rkey: rkey, 51 + }) 52 + 53 + fmt.Fprintln(os.Stderr, "Created record: "+output.Uri) 54 + 55 + return err 56 + }
+57
internal/auth/basic.go
··· 1 + package auth 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "os" 7 + 8 + "github.com/bluesky-social/indigo/api/atproto" 9 + "github.com/bluesky-social/indigo/atproto/identity" 10 + "github.com/bluesky-social/indigo/xrpc" 11 + ) 12 + 13 + func (c *Client) Login( 14 + ctx context.Context, 15 + user, password string, 16 + authFactorToken *string, 17 + ) (did *identity.Identity, err error) { 18 + // Gotta look up the PDS for the given handle/did 19 + did, err = c.directory.LookupID(ctx, user) 20 + if err != nil { 21 + return nil, err 22 + } 23 + 24 + pds := did.PDSEndpoint() 25 + if pds == "" { 26 + return nil, fmt.Errorf("failed to retrieve account PDS") 27 + } 28 + 29 + fmt.Fprintln(os.Stderr, "sending login request to "+pds) 30 + 31 + // clear out existing auth first 32 + // TODO: use refresh token etc instead of this 33 + c.xrpc = &xrpc.Client{Host: pds} 34 + 35 + loginResult, err := atproto.ServerCreateSession( 36 + ctx, 37 + c.xrpc, 38 + &atproto.ServerCreateSession_Input{ 39 + Identifier: user, 40 + Password: password, 41 + AuthFactorToken: authFactorToken, 42 + }, 43 + ) 44 + if err != nil { 45 + return nil, err 46 + } 47 + 48 + // TODO(#3) persist 49 + c.xrpc.Auth = &xrpc.AuthInfo{ 50 + AccessJwt: loginResult.AccessJwt, 51 + RefreshJwt: loginResult.RefreshJwt, // TODO persist in db or something? 52 + Handle: loginResult.Handle, 53 + Did: loginResult.Did, 54 + } 55 + 56 + return did, nil 57 + }
+16
internal/errs/http.go
··· 1 + package errs 2 + 3 + import "net/http" 4 + 5 + type Http struct { 6 + Code int 7 + Message string 8 + } 9 + 10 + func (e *Http) Write(w http.ResponseWriter) { 11 + http.Error(w, e.Error(), e.Code) 12 + } 13 + 14 + func (e *Http) Error() string { 15 + return e.Message 16 + }
+48
internal/lookup/identity.go
··· 1 + package lookup 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "net/http" 7 + "os" 8 + "strings" 9 + 10 + "atyo.app/internal/errs" 11 + id "github.com/bluesky-social/indigo/atproto/identity" 12 + "github.com/bluesky-social/indigo/atproto/syntax" 13 + ) 14 + 15 + type Directory struct { 16 + dir id.Directory 17 + } 18 + 19 + func NewDirectory() *Directory { 20 + return &Directory{dir: id.DefaultDirectory()} 21 + } 22 + 23 + func (d *Directory) LookupID(ctx context.Context, targetStr string) (*id.Identity, error) { 24 + targetStr = strings.TrimPrefix(targetStr, "@") 25 + 26 + target, err := syntax.ParseAtIdentifier(targetStr) 27 + if err != nil { 28 + return nil, &errs.Http{ 29 + http.StatusBadRequest, 30 + "Invalid target identity `" + targetStr + "`", 31 + } 32 + } 33 + 34 + targetId, err := d.dir.Lookup(ctx, *target) 35 + if err != nil { 36 + return nil, &errs.Http{ 37 + http.StatusNotFound, 38 + "Target `" + targetStr + "` not found", 39 + } 40 + } 41 + 42 + fmt.Fprintln( 43 + os.Stderr, 44 + "resolved to `"+targetId.Handle.String()+"` ("+targetId.DID.String()+")", 45 + ) 46 + 47 + return targetId, nil 48 + }
+29
internal/ping/client.go
··· 1 + package ping 2 + 3 + import ( 4 + "atyo.app/internal/auth" 5 + "atyo.app/internal/lookup" 6 + "github.com/bluesky-social/indigo/atproto/syntax" 7 + "github.com/bluesky-social/indigo/xrpc" 8 + ) 9 + 10 + type Client struct { 11 + auth *auth.Client 12 + relay *xrpc.Client 13 + keys *KeyManager 14 + directory *lookup.Directory 15 + tids syntax.TIDClock 16 + } 17 + 18 + func NewClient(auth *auth.Client, keys *KeyManager, dir *lookup.Directory) *Client { 19 + // https://docs.bsky.app/blog/relay-sync-updates 20 + relay := &xrpc.Client{Host: "https://relay1.us-west.bsky.network"} 21 + 22 + return &Client{ 23 + auth: auth, 24 + relay: relay, 25 + keys: keys, 26 + directory: dir, 27 + tids: syntax.NewTIDClock(0), // TODO random number (store in DB) 28 + } 29 + }
-16
internal/server/error.go
··· 1 - package appview 2 - 3 - import "net/http" 4 - 5 - type httpError struct { 6 - Code int 7 - Message string 8 - } 9 - 10 - func (e *httpError) Write(w http.ResponseWriter) { 11 - http.Error(w, e.Error(), e.Code) 12 - } 13 - 14 - func (e *httpError) Error() string { 15 - return e.Message 16 - }
-42
internal/server/identity.go
··· 1 - package appview 2 - 3 - import ( 4 - "context" 5 - "fmt" 6 - "net/http" 7 - "os" 8 - "strings" 9 - 10 - "github.com/bluesky-social/indigo/atproto/identity" 11 - "github.com/bluesky-social/indigo/atproto/syntax" 12 - ) 13 - 14 - func (s *Server) lookupId( 15 - ctx context.Context, 16 - targetStr string, 17 - ) (*identity.Identity, *httpError) { 18 - targetStr = strings.TrimPrefix(targetStr, "@") 19 - 20 - target, err := syntax.ParseAtIdentifier(targetStr) 21 - if err != nil { 22 - return nil, &httpError{ 23 - http.StatusBadRequest, 24 - "Invalid target identity `" + targetStr + "`", 25 - } 26 - } 27 - 28 - targetId, err := s.directory.Lookup(ctx, *target) 29 - if err != nil { 30 - return nil, &httpError{ 31 - http.StatusNotFound, 32 - "Target `" + targetStr + "` not found", 33 - } 34 - } 35 - 36 - fmt.Fprintln( 37 - os.Stderr, 38 - "resolved to `"+targetId.Handle.String()+"` ("+targetId.DID.String()+")", 39 - ) 40 - 41 - return targetId, nil 42 - }
+43 -64
internal/server/ingest_pings.go internal/ping/ingest_pings.go
··· 1 - package appview 1 + package ping 2 2 3 3 import ( 4 + "context" 4 5 "encoding/hex" 5 - "encoding/json" 6 + "errors" 6 7 "fmt" 7 8 "net/http" 8 9 "os" ··· 10 11 11 12 "atyo.app/api" 12 13 "atyo.app/api/atyo" 14 + "atyo.app/internal/errs" 13 15 "github.com/bluesky-social/indigo/api/atproto" 14 16 "github.com/bluesky-social/indigo/lex/util" 15 17 "github.com/bluesky-social/indigo/xrpc" ··· 18 20 ) 19 21 20 22 type jsonPings struct { 21 - Pings []jsonPing `json:"pings"` 23 + Pings []Ping `json:"pings"` 22 24 } 23 25 24 - type jsonPing struct { 26 + type Ping struct { 25 27 FromDID string `json:"from_did"` 26 - FromHandle *string `json:"from_handle",omitempty` 28 + FromHandle *string `json:"from_handle,omitempty"` 27 29 Contents *atyo.Ping_Contents `json:"contents"` 28 30 } 29 31 30 - func (s *Server) fetchPings(w http.ResponseWriter, req *http.Request) { 31 - key, err := s.keys.fetchPrivKey() 32 + const ( 33 + // TODO: maybe there's an API to filter by DIDs, follow list, or we could even just 34 + // lookup each did in our follow list and query the PDS directly, not sure. 35 + maxRepos = 20 36 + maxRecordsPerRepo = 10 37 + ) 38 + 39 + func (c *Client) FetchPings(ctx context.Context) ([]Ping, error) { 40 + key, err := c.keys.fetchPrivKey() 32 41 if err != nil { 33 - http.Error(w, err.Error(), http.StatusUnauthorized) 42 + return nil, errors.Join( 43 + fmt.Errorf( 44 + "%w: %w", 45 + &errs.Http{http.StatusUnauthorized, "no key found to decode pings"}, 46 + err, 47 + ), 48 + ) 34 49 } 35 50 36 51 // TODO for now let's be dumb and fetch the whole world; later 37 52 // we can store a tid or something in the sqlite db and fetch from that point 38 - 39 - // TODO: maybe there's an API to filter by DIDs, follow list, or we could even just 40 - // lookup each did in our follow list and query the PDS directly, not sure. 41 - response, err := atproto.SyncListReposByCollection( 42 - req.Context(), 43 - s.relayClient, 44 - api.PingNSID, 45 - "", 46 - 20, 47 - ) 53 + // 54 + response, err := atproto.SyncListReposByCollection(ctx, c.relay, api.PingNSID, "", maxRepos) 48 55 if err != nil { 49 - http.Error( 50 - w, 51 - "failed to fetch upstream repos: "+err.Error(), 52 - http.StatusInternalServerError, 53 - ) 54 - return 56 + return nil, errors.New("failed to list repos to crawl") 55 57 } 56 58 57 - var results jsonPings 59 + result := make([]Ping, 0) 58 60 59 61 // TODO pagination, store cursors if necessary. Unlikely to matter for a while lol 60 62 // Maybe also asyncify? This kind of thing probably works well with a 61 63 // worker pool or something when the record count gets bigger 62 64 for _, repo := range response.Repos { 63 - id, lookupErr := s.lookupId(req.Context(), repo.Did) 64 - if err != nil { 65 - fmt.Fprintf( 66 - os.Stderr, 67 - "failed to resolve id %s: %s\n", 68 - repo.Did, lookupErr, 69 - ) 65 + id, lookupErr := c.directory.LookupID(ctx, repo.Did) 66 + if lookupErr != nil { 67 + fmt.Fprintf(os.Stderr, "failed to resolve id %s: %s\n", repo.Did, lookupErr) 70 68 continue 71 69 } 72 70 73 71 // TODO: seems like there should be a "sync" way to do this, but I guess it won't 74 72 // matter once I start handling things "live" off the firehose instead of this 75 - repoClient := &xrpc.Client{Host: id.PDSEndpoint()} 73 + client := &xrpc.Client{Host: id.PDSEndpoint()} 76 74 77 75 // TODO need to hit the corresponding PDS, or can we rely on relay proxy here 78 76 records, err := atproto.RepoListRecords( 79 - req.Context(), 80 - repoClient, 77 + ctx, 78 + client, 81 79 api.PingNSID, 82 - "", 83 - 3, 80 + "", // latest CID 81 + maxRecordsPerRepo, 84 82 repo.Did, 85 83 false, 86 84 ) 87 85 88 86 if err != nil { 89 - fmt.Fprintf( 90 - os.Stderr, 91 - "failed to fetch from %s: %s\n", 92 - repo.Did, err, 93 - ) 87 + fmt.Fprintf(os.Stderr, "failed to fetch from %s: %s\n", repo.Did, err) 94 88 continue 95 89 } 96 - 97 - fmt.Fprintf( 98 - os.Stderr, 99 - "got %d records for %s\n", 100 - len(records.Records), repo.Did, 101 - ) 90 + fmt.Fprintf(os.Stderr, "got %d records for %s\n", len(records.Records), repo.Did) 102 91 103 92 for _, record := range records.Records { 104 93 ping, ok := record.Value.Val.(*atyo.Ping) 105 94 if !ok { 106 - fmt.Fprintf( 107 - os.Stderr, 108 - "failed to convert %s to ping", 109 - record.Uri, 110 - ) 95 + fmt.Fprintf(os.Stderr, "failed to convert %s to ping", record.Uri) 111 96 continue 112 97 } 113 98 114 - contents, err := s.decodePingContents(ping, key) 99 + contents, err := decodePingContents(ping, key) 115 100 if err != nil { 116 101 fmt.Fprintln(os.Stderr, "failed to decode ping ", record.Uri, " - ", err) 117 102 continue 118 103 } 119 104 if contents != nil { 120 - from, _ := s.lookupId(req.Context(), repo.Did) 105 + from, _ := c.directory.LookupID(ctx, repo.Did) 121 106 var fromHandle *string 122 107 if from != nil { 123 108 handle := from.Handle.String() 124 109 fromHandle = &handle 125 110 } 126 111 127 - results.Pings = append(results.Pings, jsonPing{ 112 + result = append(result, Ping{ 128 113 FromDID: repo.Did, 129 114 FromHandle: fromHandle, 130 115 Contents: contents, ··· 135 120 } 136 121 } 137 122 138 - resultBytes, err := json.Marshal(&results) 139 - if err != nil { 140 - http.Error(w, "Failed to encode JSON result: "+err.Error(), http.StatusInternalServerError) 141 - return 142 - } 143 - 144 - w.Write(resultBytes) 123 + return result, nil 145 124 } 146 125 147 - func (s *Server) decodePingContents(ping *atyo.Ping, privateKey *[sharedKeyLen]byte) (*atyo.Ping_Contents, error) { 126 + func decodePingContents(ping *atyo.Ping, privateKey *[sharedKeyLen]byte) (*atyo.Ping_Contents, error) { 148 127 contents := ping.Contents 149 128 150 129 if len(contents) < sharedKeyLen {
+24 -15
internal/server/keys.go internal/ping/keys.go
··· 1 - package appview 1 + package ping 2 2 3 3 import ( 4 4 "context" ··· 8 8 9 9 "atyo.app/api" 10 10 "atyo.app/api/atyo" 11 + "atyo.app/internal/auth" 11 12 "github.com/bluesky-social/indigo/api/atproto" 12 13 "github.com/bluesky-social/indigo/atproto/identity" 13 14 "github.com/bluesky-social/indigo/atproto/syntax" ··· 17 18 ) 18 19 19 20 type KeyManager struct { 20 - client *xrpc.Client 21 - authenticatedClient *xrpc.Client 22 - selfPrivateKey *[32]byte 23 - cache map[string]*[32]byte 21 + client *auth.Client 22 + selfPrivateKey *[32]byte 23 + cache map[string]*[32]byte 24 + } 25 + 26 + func NewKeyManager(client *auth.Client) *KeyManager { 27 + return &KeyManager{client: client} 24 28 } 25 29 26 30 // TODO: get pubkey from atproto profile; probably needs a new lexicon ··· 50 54 } 51 55 52 56 func (k *KeyManager) fetchProfile(ctx context.Context, id *identity.Identity) (*atproto.RepoGetRecord_Output, *atyo.ActorProfile, error) { 57 + pds := id.PDSEndpoint() 58 + if pds == "" { 59 + return nil, nil, fmt.Errorf("failed to fetch PDS endpoing for %s", id.DID) 60 + } 61 + 62 + // use an ephemeral client, since we're fetching directly from the PDS 63 + client := &xrpc.Client{Host: id.PDSEndpoint()} 64 + 53 65 record, err := atproto.RepoGetRecord( 54 66 ctx, 55 - // hmm should this be a relay instance or something? Need to check this 56 - // when fetching a record for someone with a different PDS (ugh which means 57 - // I need an account that uses a different PDS which maybe means selfhosting) 58 - k.client, 59 - "", 67 + client, 68 + "", // empty cid = most recent version 60 69 api.ActorProfileNSID, 61 70 id.DID.String(), 62 - api.SelfActorProfile, 71 + api.ActorProfileRKeySelf, 63 72 ) 64 73 if err != nil { 65 74 return nil, nil, err ··· 86 95 return nil, fmt.Errorf("no private key set, maybe login required?") 87 96 } 88 97 89 - func (k *KeyManager) genAndPublishKeyPair(ctx context.Context, did string) error { 98 + func (k *KeyManager) GenAndPublishKeyPair(ctx context.Context, did string) error { 90 99 selfPubKey, selfPrivKey, err := box.GenerateKey(rand.Reader) 91 100 if err != nil { 92 101 panic(err) ··· 98 107 // for now (and is also what tangled.sh appears to do). 99 108 100 109 var swapRecord *string 101 - existing, _ := atproto.RepoGetRecord(ctx, k.authenticatedClient, "", api.ActorProfileNSID, did, api.SelfActorProfile) 110 + existing, _ := atproto.RepoGetRecord(ctx, k.client, "", api.ActorProfileNSID, did, api.ActorProfileRKeySelf) 102 111 if existing != nil { 103 112 swapRecord = existing.Cid 104 113 } 105 114 106 115 _, err = atproto.RepoPutRecord( 107 116 ctx, 108 - k.authenticatedClient, 117 + k.client, 109 118 &atproto.RepoPutRecord_Input{ 110 119 Collection: api.ActorProfileNSID, 111 120 Repo: did, 112 - Rkey: api.SelfActorProfile, 121 + Rkey: api.ActorProfileRKeySelf, 113 122 SwapRecord: swapRecord, 114 123 Record: &util.LexiconTypeDecoder{ 115 124 Val: &atyo.ActorProfile{
-95
internal/server/login.go
··· 1 - package appview 2 - 3 - import ( 4 - "encoding/json" 5 - "errors" 6 - "fmt" 7 - "net/http" 8 - "os" 9 - 10 - "github.com/bluesky-social/indigo/api/atproto" 11 - "github.com/bluesky-social/indigo/xrpc" 12 - ) 13 - 14 - func (s *Server) login(w http.ResponseWriter, req *http.Request) { 15 - user := req.FormValue("username") 16 - pass := req.FormValue("password") 17 - 18 - fmt.Fprintln(os.Stderr, "Attempting login as `"+user+"`") 19 - 20 - var authFactorToken *string 21 - if req.Form.Has("authFactorToken") { 22 - tok := req.FormValue("authFactorToken") 23 - authFactorToken = &tok 24 - } 25 - 26 - // Gotta look up the PDS for the given handle/did 27 - did, lookupErr := s.lookupId(req.Context(), user) 28 - if lookupErr != nil { 29 - http.Error(w, lookupErr.Message, lookupErr.Code) 30 - return 31 - } 32 - 33 - // clear out existing auth first 34 - // TODO: use refresh token etc instead of this 35 - s.authenticatedClient.Host = did.PDSEndpoint() 36 - s.authenticatedClient.Auth = nil 37 - 38 - fmt.Fprintln(os.Stderr, "sending login request to "+s.authenticatedClient.Host) 39 - 40 - loginResult, err := atproto.ServerCreateSession( 41 - req.Context(), 42 - s.authenticatedClient, 43 - &atproto.ServerCreateSession_Input{ 44 - Identifier: user, 45 - Password: pass, 46 - AuthFactorToken: authFactorToken, 47 - }, 48 - ) 49 - if err != nil { 50 - handleXrpcError(w, err) 51 - s.authenticatedClient.Host = s.client.Host 52 - return 53 - } 54 - 55 - s.authenticatedClient.Auth = &xrpc.AuthInfo{ 56 - AccessJwt: loginResult.AccessJwt, 57 - RefreshJwt: loginResult.RefreshJwt, // TODO persist in db or something? 58 - Handle: loginResult.Handle, 59 - Did: loginResult.Did, 60 - } 61 - 62 - // for now, just create a key and publish it on every login. In the future 63 - // this private key should live in sqlite db instead or something, 64 - // so we don't need to republish it every time lmao. Also we should in theory 65 - // make this more of an update than a replace but it's fine for now 66 - 67 - if err = s.keys.genAndPublishKeyPair(req.Context(), loginResult.Did); err != nil { 68 - http.Error( 69 - w, 70 - "Failed to create profile record: "+err.Error(), 71 - http.StatusInternalServerError, 72 - ) 73 - 74 - } 75 - 76 - resp, err := json.Marshal(loginResult) 77 - if err != nil { 78 - http.Error(w, "failed to re-serialize login response", http.StatusInternalServerError) 79 - } 80 - _, err = w.Write(resp) 81 - if err != nil { 82 - fmt.Fprintln(os.Stderr, err) 83 - } 84 - } 85 - 86 - func handleXrpcError(w http.ResponseWriter, err error) { 87 - var xErr *xrpc.Error 88 - if errors.As(err, &xErr) { 89 - // Hmm maybe this should be a 50x or certain codes should translate... 90 - http.Error(w, xErr.Error(), xErr.StatusCode) 91 - return 92 - } 93 - 94 - http.Error(w, err.Error(), http.StatusInternalServerError) 95 - }
-74
internal/server/oauth.go
··· 1 - package appview 2 - 3 - import ( 4 - "encoding/json" 5 - "fmt" 6 - "net/http" 7 - "net/url" 8 - ) 9 - 10 - // https://atproto.com/specs/oauth#client-id-metadata-document 11 - type clientMetadata struct { 12 - ClientID string `json:"client_id"` 13 - ClientName string `json:"client_name"` 14 - SubjectType string `json:"subject_type"` 15 - ClientURI string `json:"client_uri"` 16 - RedirectURIs []string `json:"redirect_uris"` 17 - GrantTypes []string `json:"grant_types"` 18 - ResponseTypes []string `json:"response_types"` 19 - ApplicationType string `json:"application_type"` 20 - DpopBoundAccessTokens bool `json:"dpop_bound_access_tokens"` 21 - JwksURI string `json:"jwks_uri"` 22 - Scope string `json:"scope"` 23 - TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"` 24 - TokenEndpointAuthSigningAlg string `json:"token_endpoint_auth_signing_alg"` 25 - } 26 - 27 - func newClientMetadata() clientMetadata { 28 - makeRedirectURIs := func(c string) []string { 29 - return []string{fmt.Sprintf("%s/oauth/callback", c)} 30 - } 31 - 32 - // TODO: configurable host 33 - clientURI := "http://127.0.0.1:3000" 34 - redirectURIs := makeRedirectURIs(clientURI) 35 - 36 - query := url.Values{} 37 - query.Add("redirect_uri", redirectURIs[0]) 38 - query.Add("scope", "atproto transition:generic") 39 - clientID := fmt.Sprintf("http://localhost?%s", query.Encode()) 40 - 41 - jwksURI := fmt.Sprintf("%s/oauth/jwks.json", clientURI) 42 - 43 - return clientMetadata{ 44 - ClientID: clientID, 45 - ClientName: "ATyo", 46 - SubjectType: "public", 47 - ClientURI: clientURI, 48 - RedirectURIs: redirectURIs, 49 - GrantTypes: []string{"authorization_code", "refresh_token"}, 50 - ResponseTypes: []string{"code"}, 51 - ApplicationType: "web", 52 - DpopBoundAccessTokens: true, 53 - JwksURI: jwksURI, 54 - Scope: "atproto transition:generic", 55 - TokenEndpointAuthMethod: "private_key_jwt", 56 - TokenEndpointAuthSigningAlg: "ES256", 57 - } 58 - } 59 - 60 - // Serve client metadata for OAuth. 61 - func (*Server) handleOAuthMetadata(w http.ResponseWriter, req *http.Request) { 62 - w.Header().Set("Content-Type", "application/json") 63 - w.WriteHeader(http.StatusOK) 64 - meta := newClientMetadata() 65 - json.NewEncoder(w).Encode(meta) 66 - } 67 - 68 - func (*Server) handleJwks(w http.ResponseWriter, req *http.Request) { 69 - // TODO 70 - } 71 - 72 - func (*Server) handleOAuthCallback(w http.ResponseWriter, req *http.Request) { 73 - // TODO 74 - }
+120 -33
internal/server/router.go
··· 1 1 package appview 2 2 3 3 import ( 4 + "encoding/json" 5 + "errors" 6 + "fmt" 4 7 "net/http" 8 + "os" 5 9 6 - "github.com/bluesky-social/indigo/atproto/identity" 7 - "github.com/bluesky-social/indigo/atproto/syntax" 10 + "atyo.app/internal/auth" 11 + "atyo.app/internal/errs" 12 + "atyo.app/internal/lookup" 13 + "atyo.app/internal/ping" 8 14 "github.com/bluesky-social/indigo/xrpc" 9 15 ) 10 16 11 17 type Server struct { 12 - directory *identity.BaseDirectory 13 - keys *KeyManager 14 - client *xrpc.Client 15 - relayClient *xrpc.Client 16 - authenticatedClient *xrpc.Client 17 - tidClock syntax.TIDClock 18 + directory *lookup.Directory 19 + client *auth.Client 20 + keys *ping.KeyManager 21 + pings *ping.Client 18 22 } 19 23 20 24 func NewServer() *Server { 21 - // https://docs.bsky.app/docs/advanced-guides/api-directory#bluesky-services 22 - client := &xrpc.Client{Host: "https://bsky.social"} 23 - // For new Sync 1.1 APIs: https://docs.bsky.app/blog/relay-sync-updates 24 - relayClient := &xrpc.Client{Host: "https://relay1.us-west.bsky.network"} 25 - authClient := &xrpc.Client{} 25 + dir := lookup.NewDirectory() 26 + client := auth.NewClient(dir) 27 + keys := ping.NewKeyManager(client) 26 28 27 29 return &Server{ 28 - directory: &identity.BaseDirectory{}, 29 - client: client, 30 - relayClient: relayClient, 31 - authenticatedClient: authClient, 32 - keys: &KeyManager{ 33 - client: client, 34 - authenticatedClient: authClient, 35 - }, 36 - tidClock: syntax.NewTIDClock(0), // this int needs to be unique per instance-ish 30 + directory: dir, 31 + client: client, 32 + keys: keys, 33 + pings: ping.NewClient(client, keys, dir), 37 34 } 38 35 } 39 36 40 37 func (s *Server) InitializeRoutes() *http.ServeMux { 41 38 mux := http.NewServeMux() 42 39 43 - // static web app / client 40 + // Static web app/client 44 41 mux.HandleFunc("GET /", s.handleStatic) 45 42 46 - // app functionality 43 + // App functionality 47 44 mux.HandleFunc("POST /ping/{target}", s.sendPing) 48 45 mux.HandleFunc("GET /ping", s.fetchPings) 49 46 50 - // auth 47 + // Auth 51 48 mux.HandleFunc("POST /login", s.login) 52 49 53 - // TODO: oauth handlers 54 - // mux.HandleFunc("GET /oauth/client-metadata.json", r.handleOAuthMetadata) 55 - // mux.HandleFunc("GET /oauth/jwks.json", r.handleJwks) 56 - // mux.HandleFunc("GET /oauth/callback", r.handleOAuthCallback) 50 + return mux 51 + } 52 + 53 + func (s *Server) handleStatic(w http.ResponseWriter, req *http.Request) { 54 + http.ServeFile(w, req, "client") 55 + } 56 + 57 + func (s *Server) sendPing(w http.ResponseWriter, req *http.Request) { 58 + target := req.PathValue("target") 59 + 60 + err := s.pings.SendPing(req.Context(), target) 61 + if err != nil { 62 + handleErr(w, err) 63 + return 64 + } 65 + 66 + w.WriteHeader(http.StatusNoContent) 67 + } 68 + 69 + func (s *Server) fetchPings(w http.ResponseWriter, req *http.Request) { 70 + pings, err := s.pings.FetchPings(req.Context()) 71 + if err != nil { 72 + handleErr(w, err) 73 + return 74 + } 75 + 76 + output := struct { 77 + Pings []ping.Ping `json:"pings"` 78 + }{Pings: pings} 79 + 80 + bytes, err := json.Marshal(output) 81 + if err != nil { 82 + handleErr(w, err) 83 + return 84 + } 57 85 58 - return mux 86 + _, err = w.Write(bytes) 87 + if err != nil { 88 + fmt.Fprintln(os.Stderr, "failed to write response: "+err.Error()) 89 + } 59 90 } 60 91 61 - // Static assets (i.e. the client application). 62 - func (*Server) handleStatic(w http.ResponseWriter, req *http.Request) { 63 - http.ServeFile(w, req, "public") 92 + func (s *Server) login(w http.ResponseWriter, req *http.Request) { 93 + user := req.FormValue("username") 94 + pass := req.FormValue("password") 95 + 96 + fmt.Fprintln(os.Stderr, "Attempting login as `"+user+"`") 97 + 98 + var authFactorToken *string 99 + if req.Form.Has("authFactorToken") { 100 + tok := req.FormValue("authFactorToken") 101 + authFactorToken = &tok 102 + } 103 + 104 + did, err := s.client.Login(req.Context(), user, pass, authFactorToken) 105 + if err != nil { 106 + handleErr(w, err) 107 + return 108 + } 109 + 110 + // for now, just create a key and publish it on every login. In the future 111 + // this private key should live in sqlite db instead or something, 112 + // so we don't need to republish it every time lmao. Also we should in theory 113 + // make this more of an update than a replace but it's fine for now 114 + if err = s.keys.GenAndPublishKeyPair(req.Context(), did.DID.String()); err != nil { 115 + http.Error( 116 + w, 117 + "Failed to create profile record: "+err.Error(), 118 + http.StatusInternalServerError, 119 + ) 120 + return 121 + } 122 + 123 + // TODO set some auth cookies or something, SPA style or redirect? 124 + bytes, err := json.Marshal(did.DIDDocument()) 125 + if err != nil { 126 + http.Error(w, "failed to encode DID document as JSON: "+err.Error(), http.StatusInternalServerError) 127 + return 128 + } 129 + 130 + _, err = w.Write(bytes) 131 + if err != nil { 132 + fmt.Fprintln(os.Stderr, "failed to write JSON response: "+err.Error()) 133 + } 134 + } 135 + 136 + func handleErr(w http.ResponseWriter, err error) { 137 + var xErr *xrpc.Error 138 + if errors.As(err, &xErr) { 139 + // Hmm maybe this should be a 50x or certain codes should translate... 140 + http.Error(w, xErr.Error(), xErr.StatusCode) 141 + return 142 + } 143 + 144 + var hErr *errs.Http 145 + if errors.As(err, &hErr) { 146 + http.Error(w, hErr.Message, hErr.Code) 147 + return 148 + } 149 + 150 + http.Error(w, err.Error(), http.StatusInternalServerError) 64 151 }
+21 -51
internal/server/send_ping.go internal/ping/send_ping.go
··· 1 - package appview 1 + package ping 2 2 3 3 import ( 4 4 "bytes" 5 5 "context" 6 6 "crypto/rand" 7 7 "encoding/hex" 8 - "encoding/json" 8 + "errors" 9 9 "fmt" 10 - "net/http" 11 10 "os" 12 11 13 12 "atyo.app/api" 14 13 "atyo.app/api/atyo" 15 - "github.com/bluesky-social/indigo/api/atproto" 16 14 "github.com/bluesky-social/indigo/atproto/identity" 17 15 "github.com/bluesky-social/indigo/atproto/syntax" 18 16 "github.com/bluesky-social/indigo/lex/util" ··· 28 26 29 27 // Basic ping functionality. 30 28 // - `target` should be a DID or handle 31 - func (s *Server) sendPing(w http.ResponseWriter, req *http.Request) { 32 - fmt.Fprintln(os.Stderr, "got request for ping to `"+req.PathValue("target")+"`") 33 - 34 - targetId, hErr := s.lookupId(req.Context(), req.PathValue("target")) 35 - if hErr != nil { 36 - http.Error(w, hErr.Message, hErr.Code) 37 - } 29 + func (c *Client) SendPing(ctx context.Context, target string) error { 30 + fmt.Fprintln(os.Stderr, "got request for ping to `"+target+"`") 38 31 39 - ping, hErr := s.buildPing(req.Context(), targetId) 40 - if hErr != nil { 41 - http.Error(w, hErr.Error(), hErr.Code) 42 - return 32 + targetId, err := c.directory.LookupID(ctx, target) 33 + if err != nil { 34 + return fmt.Errorf("failed to lookup %q: %w", target, err) 43 35 } 44 36 45 - jsonPing, err := json.Marshal(ping) 37 + ping, err := c.buildPing(ctx, targetId) 46 38 if err != nil { 47 - http.Error( 48 - w, 49 - "Failed to serialize ping: "+err.Error(), 50 - http.StatusInternalServerError, 51 - ) 52 - return 39 + return fmt.Errorf("failed to create ping: %w", err) 53 40 } 54 41 55 - _, err = atproto.RepoPutRecord( 56 - req.Context(), 57 - s.authenticatedClient, 58 - &atproto.RepoPutRecord_Input{ 59 - Collection: api.PingNSID, 60 - Record: &util.LexiconTypeDecoder{Val: ping}, 61 - Repo: s.authenticatedClient.Auth.Did, 62 - Rkey: s.tidClock.Next().String(), 63 - }, 42 + err = c.auth.PutRecord( 43 + ctx, 44 + api.PingNSID, 45 + &util.LexiconTypeDecoder{Val: ping}, 46 + c.tids.Next().String(), 64 47 ) 65 48 if err != nil { 66 - http.Error(w, "failed to put record: "+err.Error(), http.StatusInternalServerError) 67 - return 49 + return fmt.Errorf("failed to write ping record: %w", err) 68 50 } 69 51 70 - _, err = w.Write(jsonPing) 71 - if err != nil { 72 - fmt.Fprintln(os.Stderr, err) 73 - } 52 + return nil 74 53 } 75 54 76 55 const ( ··· 87 66 // 88 67 // Roughly based on the Scuttlebutt protocol for pivate messages: 89 68 // https://ssbc.github.io/scuttlebutt-protocol-guide/#private-messages 90 - func (s *Server) buildPing(ctx context.Context, target *identity.Identity) (*atyo.Ping, *httpError) { 69 + func (c *Client) buildPing(ctx context.Context, target *identity.Identity) (*atyo.Ping, error) { 91 70 // NOTE: CBOR marshaller needs to serialize Ping_Empty{} directly, 92 71 // while the JSON serializer needs Ping_Contents{}. Just an odd quirk 93 72 emptyPing := &atyo.Ping_Empty{} ··· 95 74 var secretMessage bytes.Buffer 96 75 err := emptyPing.MarshalCBOR(&secretMessage) 97 76 if err != nil { 98 - return nil, &httpError{ 99 - http.StatusInternalServerError, 100 - "failed to marshal ping contents as CBOR", 101 - } 77 + return nil, errors.New("failed to marshal ping contents as CBOR") 102 78 } 103 79 104 80 // This nonce is actually used once with the shared secret and once ··· 109 85 110 86 ephemeralPubKey, ephemeralPrivKey, err := box.GenerateKey(rand.Reader) 111 87 if err != nil { 112 - return nil, &httpError{ 113 - http.StatusInternalServerError, 114 - "failed to generate shared keypair", 115 - } 88 + return nil, errors.New("failed to generate shared keypair") 116 89 } 117 90 118 - targetPubkey, err := s.keys.fetchUserPubKey(ctx, target) 91 + targetPubkey, err := c.keys.fetchUserPubKey(ctx, target) 119 92 if err != nil { 120 - return nil, &httpError{ 121 - http.StatusNotFound, 122 - "Could not find atyo.app id for `" + target.Handle.String() + "`: " + err.Error(), 123 - } 93 + return nil, fmt.Errorf("Could not find atyo.app ID for %q: %w", target, err) 124 94 } 125 95 126 96 const numRecipients = 1
+5 -3
public/index.html client/index.html
··· 18 18 throw new Error(`response status: ${response.status}: ${body}`); 19 19 } 20 20 21 - const result = await response.json(); 22 - status.textContent += "Success! "; 23 - status.textContent += JSON.stringify(result, null, " "); 21 + status.textContent += "Success!"; 22 + if (response.status != 204) { 23 + const result = await response.json(); 24 + status.textContent += "\n" + JSON.stringify(result, null, " "); 25 + } 24 26 } catch (err) { 25 27 status.textContent += `${err}`; 26 28 }