Signed-off-by: oppiliappan me@oppi.li
+19
appview/state/router.go
+19
appview/state/router.go
···
17
17
"tangled.sh/tangled.sh/core/appview/signup"
18
18
"tangled.sh/tangled.sh/core/appview/spindles"
19
19
"tangled.sh/tangled.sh/core/appview/state/userutil"
20
+
avstrings "tangled.sh/tangled.sh/core/appview/strings"
20
21
"tangled.sh/tangled.sh/core/log"
21
22
)
22
23
···
136
137
})
137
138
138
139
r.Mount("/settings", s.SettingsRouter())
140
+
r.Mount("/strings", s.StringsRouter(mw))
139
141
r.Mount("/knots", s.KnotsRouter(mw))
140
142
r.Mount("/spindles", s.SpindlesRouter())
141
143
r.Mount("/signup", s.SignupRouter())
···
201
203
return knots.Router(mw)
202
204
}
203
205
206
+
func (s *State) StringsRouter(mw *middleware.Middleware) http.Handler {
207
+
logger := log.New("strings")
208
+
209
+
strs := &avstrings.Strings{
210
+
Db: s.db,
211
+
OAuth: s.oauth,
212
+
Pages: s.pages,
213
+
Config: s.config,
214
+
Enforcer: s.enforcer,
215
+
IdResolver: s.idResolver,
216
+
Knotstream: s.knotstream,
217
+
Logger: logger,
218
+
}
219
+
220
+
return strs.Router(mw)
221
+
}
222
+
204
223
func (s *State) IssuesRouter(mw *middleware.Middleware) http.Handler {
205
224
issues := issues.New(s.oauth, s.repoResolver, s.pages, s.idResolver, s.db, s.config, s.notifier)
206
225
return issues.Router(mw)
+449
appview/strings/strings.go
+449
appview/strings/strings.go
···
1
+
package strings
2
+
3
+
import (
4
+
"fmt"
5
+
"log/slog"
6
+
"net/http"
7
+
"path"
8
+
"slices"
9
+
"strconv"
10
+
"strings"
11
+
"time"
12
+
13
+
"tangled.sh/tangled.sh/core/api/tangled"
14
+
"tangled.sh/tangled.sh/core/appview/config"
15
+
"tangled.sh/tangled.sh/core/appview/db"
16
+
"tangled.sh/tangled.sh/core/appview/middleware"
17
+
"tangled.sh/tangled.sh/core/appview/oauth"
18
+
"tangled.sh/tangled.sh/core/appview/pages"
19
+
"tangled.sh/tangled.sh/core/appview/pages/markup"
20
+
"tangled.sh/tangled.sh/core/eventconsumer"
21
+
"tangled.sh/tangled.sh/core/idresolver"
22
+
"tangled.sh/tangled.sh/core/rbac"
23
+
"tangled.sh/tangled.sh/core/tid"
24
+
25
+
"github.com/bluesky-social/indigo/api/atproto"
26
+
"github.com/bluesky-social/indigo/atproto/identity"
27
+
"github.com/bluesky-social/indigo/atproto/syntax"
28
+
lexutil "github.com/bluesky-social/indigo/lex/util"
29
+
"github.com/go-chi/chi/v5"
30
+
)
31
+
32
+
type Strings struct {
33
+
Db *db.DB
34
+
OAuth *oauth.OAuth
35
+
Pages *pages.Pages
36
+
Config *config.Config
37
+
Enforcer *rbac.Enforcer
38
+
IdResolver *idresolver.Resolver
39
+
Logger *slog.Logger
40
+
Knotstream *eventconsumer.Consumer
41
+
}
42
+
43
+
func (s *Strings) Router(mw *middleware.Middleware) http.Handler {
44
+
r := chi.NewRouter()
45
+
46
+
r.
47
+
With(mw.ResolveIdent()).
48
+
Route("/{user}", func(r chi.Router) {
49
+
r.Get("/", s.dashboard)
50
+
51
+
r.Route("/{rkey}", func(r chi.Router) {
52
+
r.Get("/", s.contents)
53
+
r.Delete("/", s.delete)
54
+
r.Get("/raw", s.contents)
55
+
r.Get("/edit", s.edit)
56
+
r.Post("/edit", s.edit)
57
+
r.
58
+
With(middleware.AuthMiddleware(s.OAuth)).
59
+
Post("/comment", s.comment)
60
+
})
61
+
})
62
+
63
+
r.
64
+
With(middleware.AuthMiddleware(s.OAuth)).
65
+
Route("/new", func(r chi.Router) {
66
+
r.Get("/", s.create)
67
+
r.Post("/", s.create)
68
+
})
69
+
70
+
return r
71
+
}
72
+
73
+
func (s *Strings) contents(w http.ResponseWriter, r *http.Request) {
74
+
l := s.Logger.With("handler", "contents")
75
+
76
+
id, ok := r.Context().Value("resolvedId").(identity.Identity)
77
+
if !ok {
78
+
l.Error("malformed middleware")
79
+
w.WriteHeader(http.StatusInternalServerError)
80
+
return
81
+
}
82
+
l = l.With("did", id.DID, "handle", id.Handle)
83
+
84
+
rkey := chi.URLParam(r, "rkey")
85
+
if rkey == "" {
86
+
l.Error("malformed url, empty rkey")
87
+
w.WriteHeader(http.StatusBadRequest)
88
+
return
89
+
}
90
+
l = l.With("rkey", rkey)
91
+
92
+
strings, err := db.GetStrings(
93
+
s.Db,
94
+
db.FilterEq("did", id.DID),
95
+
db.FilterEq("rkey", rkey),
96
+
)
97
+
if err != nil {
98
+
l.Error("failed to fetch string", "err", err)
99
+
w.WriteHeader(http.StatusInternalServerError)
100
+
return
101
+
}
102
+
if len(strings) != 1 {
103
+
l.Error("incorrect number of records returned", "len(strings)", len(strings))
104
+
w.WriteHeader(http.StatusInternalServerError)
105
+
return
106
+
}
107
+
string := strings[0]
108
+
109
+
if path.Base(r.URL.Path) == "raw" {
110
+
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
111
+
if string.Filename != "" {
112
+
w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", string.Filename))
113
+
}
114
+
w.Header().Set("Content-Length", strconv.Itoa(len(string.Contents)))
115
+
116
+
_, err = w.Write([]byte(string.Contents))
117
+
if err != nil {
118
+
l.Error("failed to write raw response", "err", err)
119
+
}
120
+
return
121
+
}
122
+
123
+
var showRendered, renderToggle bool
124
+
if markup.GetFormat(string.Filename) == markup.FormatMarkdown {
125
+
renderToggle = true
126
+
showRendered = r.URL.Query().Get("code") != "true"
127
+
}
128
+
129
+
s.Pages.SingleString(w, pages.SingleStringParams{
130
+
LoggedInUser: s.OAuth.GetUser(r),
131
+
RenderToggle: renderToggle,
132
+
ShowRendered: showRendered,
133
+
String: string,
134
+
Stats: string.Stats(),
135
+
Owner: id,
136
+
})
137
+
}
138
+
139
+
func (s *Strings) dashboard(w http.ResponseWriter, r *http.Request) {
140
+
l := s.Logger.With("handler", "dashboard")
141
+
142
+
id, ok := r.Context().Value("resolvedId").(identity.Identity)
143
+
if !ok {
144
+
l.Error("malformed middleware")
145
+
w.WriteHeader(http.StatusInternalServerError)
146
+
return
147
+
}
148
+
l = l.With("did", id.DID, "handle", id.Handle)
149
+
150
+
all, err := db.GetStrings(
151
+
s.Db,
152
+
db.FilterEq("did", id.DID),
153
+
)
154
+
if err != nil {
155
+
l.Error("failed to fetch strings", "err", err)
156
+
w.WriteHeader(http.StatusInternalServerError)
157
+
return
158
+
}
159
+
160
+
slices.SortFunc(all, func(a, b db.String) int {
161
+
if a.Created.After(b.Created) {
162
+
return -1
163
+
} else {
164
+
return 1
165
+
}
166
+
})
167
+
168
+
profile, err := db.GetProfile(s.Db, id.DID.String())
169
+
if err != nil {
170
+
l.Error("failed to fetch user profile", "err", err)
171
+
w.WriteHeader(http.StatusInternalServerError)
172
+
return
173
+
}
174
+
loggedInUser := s.OAuth.GetUser(r)
175
+
followStatus := db.IsNotFollowing
176
+
if loggedInUser != nil {
177
+
followStatus = db.GetFollowStatus(s.Db, loggedInUser.Did, id.DID.String())
178
+
}
179
+
180
+
followers, following, err := db.GetFollowerFollowing(s.Db, id.DID.String())
181
+
if err != nil {
182
+
l.Error("failed to get follow stats", "err", err)
183
+
}
184
+
185
+
s.Pages.StringsDashboard(w, pages.StringsDashboardParams{
186
+
LoggedInUser: s.OAuth.GetUser(r),
187
+
Card: pages.ProfileCard{
188
+
UserDid: id.DID.String(),
189
+
UserHandle: id.Handle.String(),
190
+
Profile: profile,
191
+
FollowStatus: followStatus,
192
+
Followers: followers,
193
+
Following: following,
194
+
},
195
+
Strings: all,
196
+
})
197
+
}
198
+
199
+
func (s *Strings) edit(w http.ResponseWriter, r *http.Request) {
200
+
l := s.Logger.With("handler", "edit")
201
+
202
+
user := s.OAuth.GetUser(r)
203
+
204
+
id, ok := r.Context().Value("resolvedId").(identity.Identity)
205
+
if !ok {
206
+
l.Error("malformed middleware")
207
+
w.WriteHeader(http.StatusInternalServerError)
208
+
return
209
+
}
210
+
l = l.With("did", id.DID, "handle", id.Handle)
211
+
212
+
rkey := chi.URLParam(r, "rkey")
213
+
if rkey == "" {
214
+
l.Error("malformed url, empty rkey")
215
+
w.WriteHeader(http.StatusBadRequest)
216
+
return
217
+
}
218
+
l = l.With("rkey", rkey)
219
+
220
+
// get the string currently being edited
221
+
all, err := db.GetStrings(
222
+
s.Db,
223
+
db.FilterEq("did", id.DID),
224
+
db.FilterEq("rkey", rkey),
225
+
)
226
+
if err != nil {
227
+
l.Error("failed to fetch string", "err", err)
228
+
w.WriteHeader(http.StatusInternalServerError)
229
+
return
230
+
}
231
+
if len(all) != 1 {
232
+
l.Error("incorrect number of records returned", "len(strings)", len(all))
233
+
w.WriteHeader(http.StatusInternalServerError)
234
+
return
235
+
}
236
+
first := all[0]
237
+
238
+
// verify that the logged in user owns this string
239
+
if user.Did != id.DID.String() {
240
+
l.Error("unauthorized request", "expected", id.DID, "got", user.Did)
241
+
w.WriteHeader(http.StatusUnauthorized)
242
+
return
243
+
}
244
+
245
+
switch r.Method {
246
+
case http.MethodGet:
247
+
// return the form with prefilled fields
248
+
s.Pages.PutString(w, pages.PutStringParams{
249
+
LoggedInUser: s.OAuth.GetUser(r),
250
+
Action: "edit",
251
+
String: first,
252
+
})
253
+
case http.MethodPost:
254
+
fail := func(msg string, err error) {
255
+
l.Error(msg, "err", err)
256
+
s.Pages.Notice(w, "error", msg)
257
+
}
258
+
259
+
filename := r.FormValue("filename")
260
+
if filename == "" {
261
+
fail("Empty filename.", nil)
262
+
return
263
+
}
264
+
if !strings.Contains(filename, ".") {
265
+
// TODO: make this a htmx form validation
266
+
fail("No extension provided for filename.", nil)
267
+
return
268
+
}
269
+
270
+
content := r.FormValue("content")
271
+
if content == "" {
272
+
fail("Empty contents.", nil)
273
+
return
274
+
}
275
+
276
+
description := r.FormValue("description")
277
+
278
+
// construct new string from form values
279
+
entry := db.String{
280
+
Did: first.Did,
281
+
Rkey: first.Rkey,
282
+
Filename: filename,
283
+
Description: description,
284
+
Contents: content,
285
+
Created: first.Created,
286
+
}
287
+
288
+
record := entry.AsRecord()
289
+
290
+
client, err := s.OAuth.AuthorizedClient(r)
291
+
if err != nil {
292
+
fail("Failed to create record.", err)
293
+
return
294
+
}
295
+
296
+
// first replace the existing record in the PDS
297
+
ex, err := client.RepoGetRecord(r.Context(), "", tangled.StringNSID, entry.Did.String(), entry.Rkey)
298
+
if err != nil {
299
+
fail("Failed to updated existing record.", err)
300
+
return
301
+
}
302
+
resp, err := client.RepoPutRecord(r.Context(), &atproto.RepoPutRecord_Input{
303
+
Collection: tangled.StringNSID,
304
+
Repo: entry.Did.String(),
305
+
Rkey: entry.Rkey,
306
+
SwapRecord: ex.Cid,
307
+
Record: &lexutil.LexiconTypeDecoder{
308
+
Val: &record,
309
+
},
310
+
})
311
+
if err != nil {
312
+
fail("Failed to updated existing record.", err)
313
+
return
314
+
}
315
+
l := l.With("aturi", resp.Uri)
316
+
l.Info("edited string")
317
+
318
+
// if that went okay, updated the db
319
+
if err = db.AddString(s.Db, entry); err != nil {
320
+
fail("Failed to update string.", err)
321
+
return
322
+
}
323
+
324
+
// if that went okay, redir to the string
325
+
s.Pages.HxRedirect(w, "/strings/"+user.Handle+"/"+entry.Rkey)
326
+
}
327
+
328
+
}
329
+
330
+
func (s *Strings) create(w http.ResponseWriter, r *http.Request) {
331
+
l := s.Logger.With("handler", "create")
332
+
user := s.OAuth.GetUser(r)
333
+
334
+
switch r.Method {
335
+
case http.MethodGet:
336
+
s.Pages.PutString(w, pages.PutStringParams{
337
+
LoggedInUser: s.OAuth.GetUser(r),
338
+
Action: "new",
339
+
})
340
+
case http.MethodPost:
341
+
fail := func(msg string, err error) {
342
+
l.Error(msg, "err", err)
343
+
s.Pages.Notice(w, "error", msg)
344
+
}
345
+
346
+
filename := r.FormValue("filename")
347
+
if filename == "" {
348
+
fail("Empty filename.", nil)
349
+
return
350
+
}
351
+
if !strings.Contains(filename, ".") {
352
+
// TODO: make this a htmx form validation
353
+
fail("No extension provided for filename.", nil)
354
+
return
355
+
}
356
+
357
+
content := r.FormValue("content")
358
+
if content == "" {
359
+
fail("Empty contents.", nil)
360
+
return
361
+
}
362
+
363
+
description := r.FormValue("description")
364
+
365
+
string := db.String{
366
+
Did: syntax.DID(user.Did),
367
+
Rkey: tid.TID(),
368
+
Filename: filename,
369
+
Description: description,
370
+
Contents: content,
371
+
Created: time.Now(),
372
+
}
373
+
374
+
record := string.AsRecord()
375
+
376
+
client, err := s.OAuth.AuthorizedClient(r)
377
+
if err != nil {
378
+
fail("Failed to create record.", err)
379
+
return
380
+
}
381
+
382
+
resp, err := client.RepoPutRecord(r.Context(), &atproto.RepoPutRecord_Input{
383
+
Collection: tangled.StringNSID,
384
+
Repo: user.Did,
385
+
Rkey: string.Rkey,
386
+
Record: &lexutil.LexiconTypeDecoder{
387
+
Val: &record,
388
+
},
389
+
})
390
+
if err != nil {
391
+
fail("Failed to create record.", err)
392
+
return
393
+
}
394
+
l := l.With("aturi", resp.Uri)
395
+
l.Info("created record")
396
+
397
+
// insert into DB
398
+
if err = db.AddString(s.Db, string); err != nil {
399
+
fail("Failed to create string.", err)
400
+
return
401
+
}
402
+
403
+
// successful
404
+
s.Pages.HxRedirect(w, "/strings/"+user.Handle+"/"+string.Rkey)
405
+
}
406
+
}
407
+
408
+
func (s *Strings) delete(w http.ResponseWriter, r *http.Request) {
409
+
l := s.Logger.With("handler", "create")
410
+
user := s.OAuth.GetUser(r)
411
+
fail := func(msg string, err error) {
412
+
l.Error(msg, "err", err)
413
+
s.Pages.Notice(w, "error", msg)
414
+
}
415
+
416
+
id, ok := r.Context().Value("resolvedId").(identity.Identity)
417
+
if !ok {
418
+
l.Error("malformed middleware")
419
+
w.WriteHeader(http.StatusInternalServerError)
420
+
return
421
+
}
422
+
l = l.With("did", id.DID, "handle", id.Handle)
423
+
424
+
rkey := chi.URLParam(r, "rkey")
425
+
if rkey == "" {
426
+
l.Error("malformed url, empty rkey")
427
+
w.WriteHeader(http.StatusBadRequest)
428
+
return
429
+
}
430
+
431
+
if user.Did != id.DID.String() {
432
+
fail("You cannot delete this gist", fmt.Errorf("unauthorized deletion, %s != %s", user.Did, id.DID.String()))
433
+
return
434
+
}
435
+
436
+
if err := db.DeleteString(
437
+
s.Db,
438
+
db.FilterEq("did", user.Did),
439
+
db.FilterEq("rkey", rkey),
440
+
); err != nil {
441
+
fail("Failed to delete string.", err)
442
+
return
443
+
}
444
+
445
+
s.Pages.HxRedirect(w, "/strings/"+user.Handle)
446
+
}
447
+
448
+
func (s *Strings) comment(w http.ResponseWriter, r *http.Request) {
449
+
}