+82
-1
appview/db/profile.go
+82
-1
appview/db/profile.go
···
4
4
"database/sql"
5
5
"fmt"
6
6
"log"
7
+
"net/url"
8
+
"slices"
9
+
"strings"
7
10
"time"
8
11
9
12
"github.com/bluesky-social/indigo/atproto/syntax"
···
337
340
)
338
341
339
342
if err != nil {
340
-
log.Println("profile_pinned_repositories")
343
+
log.Println("profile_pinned_repositories", "err", err)
341
344
return err
342
345
}
343
346
}
···
447
450
448
451
return result, nil
449
452
}
453
+
454
+
func ValidateProfile(e Execer, profile *Profile) error {
455
+
// ensure description is not too long
456
+
if len(profile.Description) > 256 {
457
+
return fmt.Errorf("Entered bio is too long.")
458
+
}
459
+
460
+
// ensure description is not too long
461
+
if len(profile.Location) > 40 {
462
+
return fmt.Errorf("Entered location is too long.")
463
+
}
464
+
465
+
// ensure links are in order
466
+
err := validateLinks(profile)
467
+
if err != nil {
468
+
return err
469
+
}
470
+
471
+
// ensure all pinned repos are either own repos or collaborating repos
472
+
repos, err := GetAllReposByDid(e, profile.Did)
473
+
if err != nil {
474
+
log.Printf("getting repos for %s: %s", profile.Did, err)
475
+
}
476
+
477
+
collaboratingRepos, err := CollaboratingIn(e, profile.Did)
478
+
if err != nil {
479
+
log.Printf("getting collaborating repos for %s: %s", profile.Did, err)
480
+
}
481
+
482
+
var validRepos []syntax.ATURI
483
+
for _, r := range repos {
484
+
validRepos = append(validRepos, r.RepoAt())
485
+
}
486
+
for _, r := range collaboratingRepos {
487
+
validRepos = append(validRepos, r.RepoAt())
488
+
}
489
+
490
+
for _, pinned := range profile.PinnedRepos {
491
+
if pinned == "" {
492
+
continue
493
+
}
494
+
if !slices.Contains(validRepos, pinned) {
495
+
return fmt.Errorf("Invalid pinned repo: `%s, does not belong to own or collaborating repos", pinned)
496
+
}
497
+
}
498
+
499
+
return nil
500
+
}
501
+
502
+
func validateLinks(profile *Profile) error {
503
+
for i, link := range profile.Links {
504
+
if link == "" {
505
+
continue
506
+
}
507
+
508
+
parsedURL, err := url.Parse(link)
509
+
if err != nil {
510
+
return fmt.Errorf("Invalid URL '%s': %v\n", link, err)
511
+
}
512
+
513
+
if parsedURL.Scheme == "" {
514
+
if strings.HasPrefix(link, "//") {
515
+
profile.Links[i] = "https:" + link
516
+
} else {
517
+
profile.Links[i] = "https://" + link
518
+
}
519
+
continue
520
+
} else if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
521
+
return fmt.Errorf("Warning: URL '%s' has unusual scheme: %s\n", link, parsedURL.Scheme)
522
+
}
523
+
524
+
// catch relative paths
525
+
if parsedURL.Host == "" {
526
+
return fmt.Errorf("Warning: URL '%s' appears to be a relative path\n", link)
527
+
}
528
+
}
529
+
return nil
530
+
}
+93
-2
appview/ingester.go
+93
-2
appview/ingester.go
···
41
41
ingestPublicKey(&d, e)
42
42
case tangled.RepoArtifactNSID:
43
43
ingestArtifact(&d, e)
44
+
case tangled.ActorProfileNSID:
45
+
ingestProfile(&d, e)
44
46
}
45
47
46
48
return err
···
143
145
144
146
switch e.Commit.Operation {
145
147
case models.CommitOperationCreate, models.CommitOperationUpdate:
146
-
log.Println("processing add of artifact")
147
148
raw := json.RawMessage(e.Commit.Record)
148
149
record := tangled.RepoArtifact{}
149
150
err = json.Unmarshal(raw, &record)
···
176
177
177
178
err = db.AddArtifact(d, artifact)
178
179
case models.CommitOperationDelete:
179
-
log.Println("processing delete of artifact")
180
180
err = db.DeleteArtifact(d, db.Filter("did", did), db.Filter("rkey", e.Commit.RKey))
181
181
}
182
182
···
186
186
187
187
return nil
188
188
}
189
+
190
+
func ingestProfile(d *db.DbWrapper, e *models.Event) error {
191
+
did := e.Did
192
+
var err error
193
+
194
+
if e.Commit.RKey != "self" {
195
+
return fmt.Errorf("ingestProfile only ingests `self` record")
196
+
}
197
+
198
+
switch e.Commit.Operation {
199
+
case models.CommitOperationCreate, models.CommitOperationUpdate:
200
+
raw := json.RawMessage(e.Commit.Record)
201
+
record := tangled.ActorProfile{}
202
+
err = json.Unmarshal(raw, &record)
203
+
if err != nil {
204
+
log.Printf("invalid record: %s", err)
205
+
return err
206
+
}
207
+
208
+
description := ""
209
+
if record.Description != nil {
210
+
description = *record.Description
211
+
}
212
+
213
+
includeBluesky := false
214
+
if record.Bluesky != nil {
215
+
includeBluesky = *record.Bluesky
216
+
}
217
+
218
+
location := ""
219
+
if record.Location != nil {
220
+
location = *record.Location
221
+
}
222
+
223
+
var links [5]string
224
+
for i, l := range record.Links {
225
+
if i < 5 {
226
+
links[i] = l
227
+
}
228
+
}
229
+
230
+
var stats [2]db.VanityStat
231
+
for i, s := range record.Stats {
232
+
if i < 2 {
233
+
stats[i].Kind = db.VanityStatKind(s)
234
+
}
235
+
}
236
+
237
+
var pinned [6]syntax.ATURI
238
+
for i, r := range record.PinnedRepositories {
239
+
if i < 6 {
240
+
pinned[i] = syntax.ATURI(r)
241
+
}
242
+
}
243
+
244
+
profile := db.Profile{
245
+
Did: did,
246
+
Description: description,
247
+
IncludeBluesky: includeBluesky,
248
+
Location: location,
249
+
Links: links,
250
+
Stats: stats,
251
+
PinnedRepos: pinned,
252
+
}
253
+
254
+
ddb, ok := d.Execer.(*db.DB)
255
+
if !ok {
256
+
return fmt.Errorf("failed to index profile record, invalid db cast")
257
+
}
258
+
259
+
tx, err := ddb.Begin()
260
+
if err != nil {
261
+
return fmt.Errorf("failed to start transaction")
262
+
}
263
+
264
+
err = db.ValidateProfile(tx, &profile)
265
+
if err != nil {
266
+
return fmt.Errorf("invalid profile record")
267
+
}
268
+
269
+
err = db.UpsertProfile(tx, &profile)
270
+
case models.CommitOperationDelete:
271
+
err = db.DeleteArtifact(d, db.Filter("did", did), db.Filter("rkey", e.Commit.RKey))
272
+
}
273
+
274
+
if err != nil {
275
+
return fmt.Errorf("failed to %s profile record: %w", e.Commit.Operation, err)
276
+
}
277
+
278
+
return nil
279
+
}
+26
-18
appview/pages/templates/user/profile.html
+26
-18
appview/pages/templates/user/profile.html
···
227
227
228
228
{{ define "profileCard" }}
229
229
<div class="bg-white dark:bg-gray-800 px-6 py-4 rounded drop-shadow-sm max-h-fit">
230
-
<div class="grid grid-cols-3 md:grid-cols-1 gap-3 items-center">
231
-
<div id="avatar" class="col-span-1 md-col-span-full flex justify-center items-center">
230
+
<div class="grid grid-cols-3 md:grid-cols-1 gap-1 items-center">
231
+
<div id="avatar" class="col-span-1 flex justify-center items-center">
232
232
{{ if .AvatarUri }}
233
233
<img class="w-3/4 rounded-full p-2" src="{{ .AvatarUri }}" />
234
234
{{ end }}
235
235
</div>
236
-
<div class="col-span-2 md:col-span-full">
236
+
<div class="col-span-2">
237
237
<p title="{{ didOrHandle .UserDid .UserHandle }}"
238
238
class="text-lg font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">
239
239
{{ didOrHandle .UserDid .UserHandle }}
240
240
</p>
241
+
242
+
<div class="md:hidden">
243
+
{{ block "followerFollowing" .ProfileStats }} {{ end }}
244
+
</div>
245
+
</div>
246
+
<div class="col-span-3 md:col-span-full">
241
247
<div id="profile-bio" class="text-sm">
242
-
{{ if .Profile }}
243
-
<p>{{ .Profile.Description }}</p>
248
+
{{ $profile := .Profile }}
249
+
{{ with .Profile }}
250
+
251
+
{{ if .Description }}
252
+
<p class="text-base pb-4 md:pb-2">{{ .Description }}</p>
244
253
{{ end }}
245
254
246
-
<div class="flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full">
247
-
<span class="flex-shrink-0">{{ i "users" "size-4" }}</span>
248
-
<span id="followers">{{ .ProfileStats.Followers }} followers</span>
249
-
<span class="select-none after:content-['·']"></span>
250
-
<span id="following">{{ .ProfileStats.Following }} following</span>
255
+
<div class="hidden md:block">
256
+
{{ block "followerFollowing" $.ProfileStats }} {{ end }}
251
257
</div>
252
258
253
-
{{ $profile := .Profile }}
254
-
{{ with .Profile }}
255
259
<div class="flex flex-col gap-2 mb-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full">
256
260
{{ if .Location }}
257
261
<div class="flex items-center gap-2">
···
259
263
<span>{{ .Location }}</span>
260
264
</div>
261
265
{{ end }}
262
-
263
266
{{ if .IncludeBluesky }}
264
267
<div class="flex items-center gap-2">
265
268
<span class="flex-shrink-0">{{ i "link" "size-4" }}</span>
···
268
271
</a>
269
272
</div>
270
273
{{ end }}
271
-
272
274
{{ range $link := .Links }}
273
275
{{ if $link }}
274
276
<div class="flex items-center gap-2">
···
277
279
</div>
278
280
{{ end }}
279
281
{{ end }}
280
-
281
282
{{ if not $profile.IsStatsEmpty }}
282
283
<div class="flex items-center justify-evenly gap-2 py-2">
283
284
{{ range $stat := .Stats }}
···
290
291
{{ end }}
291
292
</div>
292
293
{{ end }}
293
-
294
294
</div>
295
295
{{ end }}
296
-
297
296
{{ if ne .FollowStatus.String "IsSelf" }}
298
297
{{ template "user/fragments/follow" . }}
299
298
{{ else }}
···
303
302
hx-get="/{{ $.UserDid }}/profile/edit-bio"
304
303
hx-swap="innerHTML">
305
304
{{ i "pencil" "w-4 h-4" }}
306
-
edit profile
305
+
edit
307
306
</button>
308
307
{{ end }}
309
308
</div>
310
309
<div id="update-profile" class="text-red-400 dark:text-red-500"></div>
311
310
</div>
312
311
</div>
312
+
</div>
313
+
{{ end }}
314
+
315
+
{{ define "followerFollowing" }}
316
+
<div class="flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full text-sm">
317
+
<span class="flex-shrink-0">{{ i "users" "size-4" }}</span>
318
+
<span id="followers">{{ .Followers }} followers</span>
319
+
<span class="select-none after:content-['·']"></span>
320
+
<span id="following">{{ .Following }} following</span>
313
321
</div>
314
322
{{ end }}
315
323
+1
-80
appview/state/profile.go
+1
-80
appview/state/profile.go
···
7
7
"fmt"
8
8
"log"
9
9
"net/http"
10
-
"net/url"
11
10
"slices"
12
11
"strings"
13
12
···
181
180
profile.Stats[1].Kind = db.VanityStatKind(stat1)
182
181
}
183
182
184
-
if err := s.validateProfile(profile); err != nil {
183
+
if err := db.ValidateProfile(s.db, profile); err != nil {
185
184
log.Println("invalid profile", err)
186
185
s.pages.Notice(w, "update-profile", err.Error())
187
186
return
···
290
289
291
290
s.pages.HxRedirect(w, "/"+user.Did)
292
291
return
293
-
}
294
-
295
-
func (s *State) validateProfile(profile *db.Profile) error {
296
-
// ensure description is not too long
297
-
if len(profile.Description) > 256 {
298
-
return fmt.Errorf("Entered bio is too long.")
299
-
}
300
-
301
-
// ensure description is not too long
302
-
if len(profile.Location) > 40 {
303
-
return fmt.Errorf("Entered location is too long.")
304
-
}
305
-
306
-
// ensure links are in order
307
-
err := validateLinks(profile)
308
-
if err != nil {
309
-
return err
310
-
}
311
-
312
-
// ensure all pinned repos are either own repos or collaborating repos
313
-
repos, err := db.GetAllReposByDid(s.db, profile.Did)
314
-
if err != nil {
315
-
log.Printf("getting repos for %s: %s", profile.Did, err)
316
-
}
317
-
318
-
collaboratingRepos, err := db.CollaboratingIn(s.db, profile.Did)
319
-
if err != nil {
320
-
log.Printf("getting collaborating repos for %s: %s", profile.Did, err)
321
-
}
322
-
323
-
var validRepos []syntax.ATURI
324
-
for _, r := range repos {
325
-
validRepos = append(validRepos, r.RepoAt())
326
-
}
327
-
for _, r := range collaboratingRepos {
328
-
validRepos = append(validRepos, r.RepoAt())
329
-
}
330
-
331
-
for _, pinned := range profile.PinnedRepos {
332
-
if pinned == "" {
333
-
continue
334
-
}
335
-
if !slices.Contains(validRepos, pinned) {
336
-
return fmt.Errorf("Invalid pinned repo: `%s, does not belong to own or collaborating repos", pinned)
337
-
}
338
-
}
339
-
340
-
return nil
341
-
}
342
-
343
-
func validateLinks(profile *db.Profile) error {
344
-
for i, link := range profile.Links {
345
-
if link == "" {
346
-
continue
347
-
}
348
-
349
-
parsedURL, err := url.Parse(link)
350
-
if err != nil {
351
-
return fmt.Errorf("Invalid URL '%s': %v\n", link, err)
352
-
}
353
-
354
-
if parsedURL.Scheme == "" {
355
-
if strings.HasPrefix(link, "//") {
356
-
profile.Links[i] = "https:" + link
357
-
} else {
358
-
profile.Links[i] = "https://" + link
359
-
}
360
-
continue
361
-
} else if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
362
-
return fmt.Errorf("Warning: URL '%s' has unusual scheme: %s\n", link, parsedURL.Scheme)
363
-
}
364
-
365
-
// catch relative paths
366
-
if parsedURL.Host == "" {
367
-
return fmt.Errorf("Warning: URL '%s' appears to be a relative path\n", link)
368
-
}
369
-
}
370
-
return nil
371
292
}
372
293
373
294
func (s *State) EditBioFragment(w http.ResponseWriter, r *http.Request) {
+7
-1
appview/state/state.go
+7
-1
appview/state/state.go
···
63
63
jc, err := jetstream.NewJetstreamClient(
64
64
config.JetstreamEndpoint,
65
65
"appview",
66
-
[]string{tangled.GraphFollowNSID, tangled.FeedStarNSID, tangled.PublicKeyNSID, tangled.RepoArtifactNSID},
66
+
[]string{
67
+
tangled.GraphFollowNSID,
68
+
tangled.FeedStarNSID,
69
+
tangled.PublicKeyNSID,
70
+
tangled.RepoArtifactNSID,
71
+
tangled.ActorProfileNSID,
72
+
},
67
73
nil,
68
74
slog.Default(),
69
75
wrapper,