+1
-1
api/api.go
+1
-1
api/api.go
+56
internal/auth/auth.go
+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
+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
+16
internal/errs/http.go
+48
internal/lookup/identity.go
+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
+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
-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
-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
+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
+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
-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
-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
+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
+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
+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
}