+6
-5
base.html
+6
-5
base.html
···
6
6
<meta name="referrer" content="origin-when-cross-origin">
7
7
<meta name="viewport" content="width=device-width, initial-scale=1">
8
8
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.classless.purple.min.css">
9
-
<title>atproto OAuth demo (indigo)</title>
9
+
<title>ATProto User Intents Demo</title>
10
10
</head>
11
11
<body>
12
12
<header>
13
13
<hgroup>
14
-
<h1>atproto OAuth demo (indigo)</h1>
14
+
<h1>ATProto User Intents Demo</h1>
15
15
{{ if false }}
16
+
<!-- XXX -->
16
17
<p>Hello <span style="font-family: monospace;">@{{ "handle" }}</span>!</p>
17
18
{{ end }}
18
19
</hgroup>
19
20
<nav>
20
21
<ul>
21
22
{{ if false }}
22
-
<li><a href="/bsky/post">Create Post</a>
23
-
<li><a href="/oauth/refresh">Refresh Token</a>
23
+
<!-- XXX -->
24
+
<li><a href="/intents">Configure Intents</a>
24
25
<li><a href="/oauth/logout">Logout</a>
25
26
{{ else }}
26
27
<li><a href="/oauth/login">Login</a>
27
28
{{ end }}
28
-
<li><a href="https://github.com/bluesky-social/indigo/tree/main/atproto/auth/oauth">Code</a>
29
+
<li><a href="https://tangled.sh/@bnewbold.net/user-intents">Code</a>
29
30
</ul>
30
31
</nav>
31
32
</header>
+30
declaration.go
+30
declaration.go
···
1
+
package main
2
+
3
+
type GetDeclarationResp struct {
4
+
Cid *string `json:"cid,omitempty"`
5
+
Uri string `json:"uri"`
6
+
Value Declaration `json:"value"`
7
+
}
8
+
9
+
type PutDeclarationBody struct {
10
+
Repo string `json:"repo"`
11
+
Collection string `json:"collection"`
12
+
Rkey string `json:"rkey,omitempty"`
13
+
SwapCommit *string `json:"swapCommit,omitempty"`
14
+
Validate *bool `json:"validate,omitempty"`
15
+
Record Declaration `json:"record"`
16
+
}
17
+
18
+
type DeclarationIntent struct {
19
+
Value *bool `json:"value,omitempty"`
20
+
UpdatedAt string `json:"updatedAt"`
21
+
}
22
+
23
+
type Declaration struct {
24
+
Type string `json:"$type"`
25
+
UpdatedAt string `json:"updatedAt,omitempty"`
26
+
SyntheticContentGeneration *DeclarationIntent `json:"syntheticContentGeneration,omitempty"`
27
+
PublicAccessArchive *DeclarationIntent `json:"publicAccessArchive,omitempty"`
28
+
BulkDataset *DeclarationIntent `json:"bulkDataset,omitempty"`
29
+
ProtocolBridging *DeclarationIntent `json:"protocolBridging,omitempty"`
30
+
}
+34
intents.html
+34
intents.html
···
1
+
{{ define "content" }}
2
+
<form method="post">
3
+
4
+
<label for="syntheticContentGeneration-select">Synthetic Concent Generation:</label>
5
+
<select name="syntheticContentGeneration" id="syntheticContentGeneration-select">
6
+
<option value="undefined">Undefined</option>
7
+
<option value="allow">Allow</option>
8
+
<option value="disallow">Disallow</option>
9
+
</select>
10
+
11
+
<label for="publicAccessArchive-select">Public Access Archiving:</label>
12
+
<select name="publicAccessArchive" id="publicAccessArchive-select">
13
+
<option value="undefined">Undefined</option>
14
+
<option value="allow">Allow</option>
15
+
<option value="disallow">Disallow</option>
16
+
</select>
17
+
18
+
<label for="bulkDataset-select">Inclusion in Bulk Datasets:</label>
19
+
<select name="bulkDataset" id="bulkDataset-select">
20
+
<option value="undefined">Undefined</option>
21
+
<option value="allow">Allow</option>
22
+
<option value="disallow">Disallow</option>
23
+
</select>
24
+
25
+
<label for="protocolBridging-select">Protocol Bridging:</label>
26
+
<select name="protocolBridging" id="protocolBridging-select">
27
+
<option value="undefined">Undefined</option>
28
+
<option value="allow">Allow</option>
29
+
<option value="disallow">Disallow</option>
30
+
</select>
31
+
32
+
<input type="submit" value="Update Intents">
33
+
</form>
34
+
{{ end }}
+2
-2
lexicons/org.user-intents/demo/declaration.json
+2
-2
lexicons/org.user-intents/demo/declaration.json
···
25
25
"ref": "#intent",
26
26
"description": "Public access to or replay of account data as part of archiving and preservation efforts"
27
27
},
28
-
"syntheticContentGeneration": {
28
+
"bulkDataset": {
29
29
"type": "ref",
30
30
"ref": "#intent",
31
31
"description": "Inclusion of account data in bulk 'snapshot' datasets which are publicly redistributed, even if only for a fixed time period"
32
32
},
33
-
"syntheticContentGeneration": {
33
+
"protocolBridging": {
34
34
"type": "ref",
35
35
"ref": "#intent",
36
36
"description": "Bridging account data or interactions into distinct social web protocol ecosystems"
+88
-80
main.go
+88
-80
main.go
···
12
12
_ "github.com/joho/godotenv/autoload"
13
13
14
14
"github.com/bluesky-social/indigo/atproto/auth/oauth"
15
-
"github.com/bluesky-social/indigo/atproto/crypto"
16
15
"github.com/bluesky-social/indigo/atproto/identity"
17
16
"github.com/bluesky-social/indigo/atproto/syntax"
18
17
···
37
36
Usage: "public host name for this client (if not localhost dev mode)",
38
37
EnvVars: []string{"CLIENT_HOSTNAME"},
39
38
},
40
-
&cli.StringFlag{
41
-
Name: "client-secret-key",
42
-
Usage: "confidential client secret key. should be P-256 private key in multibase encoding",
43
-
EnvVars: []string{"CLIENT_SECRET_KEY"},
44
-
},
45
-
&cli.StringFlag{
46
-
Name: "client-secret-key-id",
47
-
Usage: "key id for client-secret-key",
48
-
Value: "primary",
49
-
EnvVars: []string{"CLIENT_SECRET_KEY_ID"},
50
-
},
51
39
},
52
40
}
53
41
h := slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug})
···
72
60
var tmplLoginText string
73
61
var tmplLogin = template.Must(template.Must(template.New("login.html").Parse(tmplBaseText)).Parse(tmplLoginText))
74
62
75
-
//go:embed "post.html"
76
-
var tmplPostText string
77
-
var tmplPost = template.Must(template.Must(template.New("post.html").Parse(tmplBaseText)).Parse(tmplPostText))
63
+
//go:embed "intents.html"
64
+
var tmplIntentsText string
65
+
var tmplIntents = template.Must(template.Must(template.New("intents.html").Parse(tmplBaseText)).Parse(tmplIntentsText))
78
66
79
-
func (s *Server) Homepage(w http.ResponseWriter, r *http.Request) {
80
-
tmplHome.Execute(w, nil)
67
+
type WebInfo struct {
68
+
DID string
69
+
//Handle string
70
+
Declaration *Declaration
81
71
}
82
72
83
73
func runServer(cctx *cli.Context) error {
···
101
91
)
102
92
}
103
93
104
-
// If a client secret key is provided (as a multibase string), turn this in to a confidential client
105
-
if cctx.String("client-secret-key") != "" && hostname != "" {
106
-
priv, err := crypto.ParsePrivateMultibase(cctx.String("client-secret-key"))
107
-
if err != nil {
108
-
return err
109
-
}
110
-
config.AddClientSecret(priv, cctx.String("client-secret-key-id"))
111
-
slog.Info("configuring confidential OAuth client")
112
-
}
113
-
114
94
oauthClient := oauth.NewClientApp(&config, oauth.NewMemStore())
115
95
116
96
srv := Server{
···
125
105
http.HandleFunc("GET /oauth/login", srv.OAuthLogin)
126
106
http.HandleFunc("POST /oauth/login", srv.OAuthLogin)
127
107
http.HandleFunc("GET /oauth/callback", srv.OAuthCallback)
128
-
http.HandleFunc("GET /oauth/refresh", srv.OAuthRefresh)
129
108
http.HandleFunc("GET /oauth/logout", srv.OAuthLogout)
130
-
http.HandleFunc("GET /bsky/post", srv.Post)
131
-
http.HandleFunc("POST /bsky/post", srv.Post)
109
+
http.HandleFunc("GET /intents", srv.UpdateIntents)
110
+
http.HandleFunc("POST /intents", srv.UpdateIntents)
132
111
133
112
slog.Info("starting http server", "bind", bind)
134
113
if err := http.ListenAndServe(bind, nil); err != nil {
···
160
139
161
140
scope := "atproto transition:generic"
162
141
meta := s.OAuth.Config.ClientMetadata(scope)
163
-
if s.OAuth.Config.IsConfidential() {
164
-
meta.JWKSUri = strPtr(fmt.Sprintf("https://%s/oauth/jwks.json", r.Host))
165
-
}
166
-
meta.ClientName = strPtr("indigo atp-oauth-demo")
142
+
meta.ClientName = strPtr("AI-PREF / Bluesky Demo App")
167
143
meta.ClientURI = strPtr(fmt.Sprintf("https://%s", r.Host))
168
144
169
145
// internal consistency check
···
187
163
http.Error(w, err.Error(), http.StatusInternalServerError)
188
164
return
189
165
}
166
+
}
167
+
168
+
func (s *Server) Homepage(w http.ResponseWriter, r *http.Request) {
169
+
did := s.currentSessionDID(r)
170
+
if did != nil {
171
+
tmplHome.Execute(w, WebInfo{DID: did.String()})
172
+
return
173
+
}
174
+
tmplHome.Execute(w, nil)
190
175
}
191
176
192
177
func (s *Server) OAuthLogin(w http.ResponseWriter, r *http.Request) {
···
237
222
}
238
223
239
224
slog.Info("login successful", "did", sessData.AccountDID.String())
240
-
http.Redirect(w, r, "/bsky/post", http.StatusFound)
241
-
}
242
-
243
-
func (s *Server) OAuthRefresh(w http.ResponseWriter, r *http.Request) {
244
-
ctx := r.Context()
245
-
246
-
did := s.currentSessionDID(r)
247
-
if did == nil {
248
-
// TODO: suppowed to set a WWW header; and could redirect?
249
-
http.Error(w, "not authenticated", http.StatusUnauthorized)
250
-
return
251
-
}
252
-
253
-
oauthSess, err := s.OAuth.ResumeSession(ctx, *did)
254
-
if err != nil {
255
-
http.Error(w, "not authenticated", http.StatusUnauthorized)
256
-
return
257
-
}
258
-
259
-
if err := oauthSess.RefreshTokens(ctx); err != nil {
260
-
http.Error(w, err.Error(), http.StatusBadRequest)
261
-
return
262
-
}
263
-
s.OAuth.Store.SaveSession(ctx, *oauthSess.Data)
264
-
slog.Info("refreshed tokens")
265
-
http.Redirect(w, r, "/", http.StatusFound)
225
+
http.Redirect(w, r, "/intents", http.StatusFound)
266
226
}
267
227
268
228
func (s *Server) OAuthLogout(w http.ResponseWriter, r *http.Request) {
···
280
240
http.Redirect(w, r, "/", http.StatusFound)
281
241
}
282
242
283
-
func (s *Server) Post(w http.ResponseWriter, r *http.Request) {
284
-
ctx := r.Context()
285
-
286
-
slog.Info("in post handler")
243
+
func parseTriState(raw string) *bool {
244
+
switch raw {
245
+
case "allow":
246
+
v := true
247
+
return &v
248
+
case "disallow":
249
+
v := false
250
+
return &v
251
+
default:
252
+
return nil
253
+
}
254
+
}
287
255
288
-
if r.Method != "POST" {
289
-
tmplPost.Execute(w, nil)
290
-
return
291
-
}
256
+
func (s *Server) UpdateIntents(w http.ResponseWriter, r *http.Request) {
257
+
ctx := r.Context()
292
258
293
259
did := s.currentSessionDID(r)
294
260
if did == nil {
···
303
269
return
304
270
}
305
271
c := oauthSess.APIClient()
272
+
info := WebInfo{
273
+
DID: did.String(),
274
+
}
275
+
276
+
p := map[string]any{
277
+
"repo": did.String(),
278
+
"collection": "org.user-intents.demo.declaration",
279
+
"rkey": "self",
280
+
}
281
+
resp := GetDeclarationResp{}
282
+
err = c.Get(ctx, "com.atproto.repo.getRecord", p, &resp)
283
+
if err != nil {
284
+
slog.Info("could not fetch existing declaration", "err", err)
285
+
}
286
+
287
+
if r.Method != "POST" {
288
+
info.Declaration = &resp.Value
289
+
tmplIntents.Execute(w, info)
290
+
return
291
+
}
306
292
307
293
if err := r.ParseForm(); err != nil {
308
294
http.Error(w, fmt.Errorf("parsing form data: %w", err).Error(), http.StatusBadRequest)
309
295
return
310
296
}
311
-
text := r.PostFormValue("post_text")
312
297
313
-
body := map[string]any{
314
-
"repo": c.AccountDID.String(),
315
-
"collection": "app.bsky.feed.post",
316
-
"record": map[string]any{
317
-
"$type": "app.bsky.feed.post",
318
-
"text": text,
319
-
"createdAt": syntax.DatetimeNow(),
298
+
// TODO: have this not clobber current timestamps
299
+
now := syntax.DatetimeNow().String()
300
+
decl := Declaration{
301
+
Type: "org.user-intents.demo.declaration",
302
+
UpdatedAt: now,
303
+
SyntheticContentGeneration: &DeclarationIntent{
304
+
Value: parseTriState(r.PostFormValue("syntheticContentGeneration")),
305
+
UpdatedAt: now,
306
+
},
307
+
PublicAccessArchive: &DeclarationIntent{
308
+
Value: parseTriState(r.PostFormValue("publicAccessArchive")),
309
+
UpdatedAt: now,
310
+
},
311
+
BulkDataset: &DeclarationIntent{
312
+
Value: parseTriState(r.PostFormValue("bulkDataset")),
313
+
UpdatedAt: now,
314
+
},
315
+
ProtocolBridging: &DeclarationIntent{
316
+
Value: parseTriState(r.PostFormValue("protocolBridging")),
317
+
UpdatedAt: now,
320
318
},
321
319
}
322
320
323
-
slog.Info("attempting post...", "text", text)
324
-
if err := c.Post(ctx, "com.atproto.repo.createRecord", body, nil); err != nil {
325
-
http.Error(w, fmt.Errorf("posting failed: %w", err).Error(), http.StatusBadRequest)
321
+
//nope := false
322
+
body := PutDeclarationBody{
323
+
Repo: did.String(),
324
+
Collection: "org.user-intents.demo.declaration",
325
+
Rkey: "self",
326
+
// XXX: Validate: &nope,
327
+
Record: decl,
328
+
}
329
+
330
+
slog.Info("updating intents", "did", did, "declaration", decl)
331
+
if err := c.Post(ctx, "com.atproto.repo.putRecord", body, nil); err != nil {
332
+
slog.Info("failed to update intents record", "err", err)
333
+
http.Error(w, fmt.Errorf("update failed: %w", err).Error(), http.StatusBadRequest)
326
334
return
327
335
}
328
336
329
-
http.Redirect(w, r, "/bsky/post", http.StatusFound)
337
+
http.Redirect(w, r, "/", http.StatusFound)
330
338
}