+3
appview/config/config.go
+3
appview/config/config.go
···
16
16
AppviewHost string `env:"APPVIEW_HOST, default=https://tangled.sh"`
17
17
Dev bool `env:"DEV, default=false"`
18
18
DisallowedNicknamesFile string `env:"DISALLOWED_NICKNAMES_FILE"`
19
+
20
+
// temporarily, to add users to default spindle
21
+
AppPassword string `env:"APP_PASSWORD"`
19
22
}
20
23
21
24
type OAuthConfig struct {
+2
-2
appview/db/db.go
+2
-2
appview/db/db.go
···
728
728
kind := rv.Kind()
729
729
730
730
// if we have `FilterIn(k, [1, 2, 3])`, compile it down to `k in (?, ?, ?)`
731
-
if kind == reflect.Slice || kind == reflect.Array {
731
+
if (kind == reflect.Slice && rv.Type().Elem().Kind() != reflect.Uint8) || kind == reflect.Array {
732
732
if rv.Len() == 0 {
733
733
// always false
734
734
return "1 = 0"
···
748
748
func (f filter) Arg() []any {
749
749
rv := reflect.ValueOf(f.arg)
750
750
kind := rv.Kind()
751
-
if kind == reflect.Slice || kind == reflect.Array {
751
+
if (kind == reflect.Slice && rv.Type().Elem().Kind() != reflect.Uint8) || kind == reflect.Array {
752
752
if rv.Len() == 0 {
753
753
return nil
754
754
}
+4
appview/ingester.go
+4
appview/ingester.go
···
387
387
if err != nil {
388
388
return fmt.Errorf("failed to update ACLs: %w", err)
389
389
}
390
+
391
+
l.Info("added spindle member")
390
392
case models.CommitOperationDelete:
391
393
rkey := e.Commit.RKey
392
394
···
433
435
if err = i.Enforcer.E.SavePolicy(); err != nil {
434
436
return fmt.Errorf("failed to save ACLs: %w", err)
435
437
}
438
+
439
+
l.Info("removed spindle member")
436
440
}
437
441
438
442
return nil
+141
appview/oauth/handler/handler.go
+141
appview/oauth/handler/handler.go
···
1
1
package oauth
2
2
3
3
import (
4
+
"bytes"
5
+
"context"
4
6
"encoding/json"
5
7
"fmt"
6
8
"log"
7
9
"net/http"
8
10
"net/url"
9
11
"strings"
12
+
"time"
10
13
11
14
"github.com/go-chi/chi/v5"
12
15
"github.com/gorilla/sessions"
13
16
"github.com/lestrrat-go/jwx/v2/jwk"
14
17
"github.com/posthog/posthog-go"
15
18
"tangled.sh/icyphox.sh/atproto-oauth/helpers"
19
+
tangled "tangled.sh/tangled.sh/core/api/tangled"
16
20
sessioncache "tangled.sh/tangled.sh/core/appview/cache/session"
17
21
"tangled.sh/tangled.sh/core/appview/config"
18
22
"tangled.sh/tangled.sh/core/appview/db"
···
23
27
"tangled.sh/tangled.sh/core/idresolver"
24
28
"tangled.sh/tangled.sh/core/knotclient"
25
29
"tangled.sh/tangled.sh/core/rbac"
30
+
"tangled.sh/tangled.sh/core/tid"
26
31
)
27
32
28
33
const (
···
294
299
295
300
log.Println("session saved successfully")
296
301
go o.addToDefaultKnot(oauthRequest.Did)
302
+
go o.addToDefaultSpindle(oauthRequest.Did)
297
303
298
304
if !o.config.Core.Dev {
299
305
err = o.posthog.Enqueue(posthog.Capture{
···
330
336
return nil, err
331
337
}
332
338
return pubKey, nil
339
+
}
340
+
341
+
func (o *OAuthHandler) addToDefaultSpindle(did string) {
342
+
// use the tangled.sh app password to get an accessJwt
343
+
// and create an sh.tangled.spindle.member record with that
344
+
345
+
defaultSpindle := "spindle.tangled.sh"
346
+
appPassword := o.config.Core.AppPassword
347
+
348
+
spindleMembers, err := db.GetSpindleMembers(
349
+
o.db,
350
+
db.FilterEq("instance", "spindle.tangled.sh"),
351
+
db.FilterEq("subject", did),
352
+
)
353
+
if err != nil {
354
+
log.Printf("failed to get spindle members for did %s: %v", did, err)
355
+
return
356
+
}
357
+
358
+
if len(spindleMembers) != 0 {
359
+
log.Printf("did %s is already a member of the default spindle", did)
360
+
return
361
+
}
362
+
363
+
// TODO: hardcoded tangled handle and did for now
364
+
tangledHandle := "tangled.sh"
365
+
tangledDid := "did:plc:wshs7t2adsemcrrd4snkeqli"
366
+
367
+
if appPassword == "" {
368
+
log.Println("no app password configured, skipping spindle member addition")
369
+
return
370
+
}
371
+
372
+
log.Printf("adding %s to default spindle", did)
373
+
374
+
resolved, err := o.idResolver.ResolveIdent(context.Background(), tangledDid)
375
+
if err != nil {
376
+
log.Printf("failed to resolve tangled.sh DID %s: %v", tangledDid, err)
377
+
return
378
+
}
379
+
380
+
pdsEndpoint := resolved.PDSEndpoint()
381
+
if pdsEndpoint == "" {
382
+
log.Printf("no PDS endpoint found for tangled.sh DID %s", tangledDid)
383
+
return
384
+
}
385
+
386
+
sessionPayload := map[string]string{
387
+
"identifier": tangledHandle,
388
+
"password": appPassword,
389
+
}
390
+
sessionBytes, err := json.Marshal(sessionPayload)
391
+
if err != nil {
392
+
log.Printf("failed to marshal session payload: %v", err)
393
+
return
394
+
}
395
+
396
+
sessionURL := pdsEndpoint + "/xrpc/com.atproto.server.createSession"
397
+
sessionReq, err := http.NewRequestWithContext(context.Background(), "POST", sessionURL, bytes.NewBuffer(sessionBytes))
398
+
if err != nil {
399
+
log.Printf("failed to create session request: %v", err)
400
+
return
401
+
}
402
+
sessionReq.Header.Set("Content-Type", "application/json")
403
+
404
+
client := &http.Client{Timeout: 30 * time.Second}
405
+
sessionResp, err := client.Do(sessionReq)
406
+
if err != nil {
407
+
log.Printf("failed to create session: %v", err)
408
+
return
409
+
}
410
+
defer sessionResp.Body.Close()
411
+
412
+
if sessionResp.StatusCode != http.StatusOK {
413
+
log.Printf("failed to create session: HTTP %d", sessionResp.StatusCode)
414
+
return
415
+
}
416
+
417
+
var session struct {
418
+
AccessJwt string `json:"accessJwt"`
419
+
}
420
+
if err := json.NewDecoder(sessionResp.Body).Decode(&session); err != nil {
421
+
log.Printf("failed to decode session response: %v", err)
422
+
return
423
+
}
424
+
425
+
record := tangled.SpindleMember{
426
+
LexiconTypeID: "sh.tangled.spindle.member",
427
+
Subject: did,
428
+
Instance: defaultSpindle,
429
+
CreatedAt: time.Now().Format(time.RFC3339),
430
+
}
431
+
432
+
recordBytes, err := json.Marshal(record)
433
+
if err != nil {
434
+
log.Printf("failed to marshal spindle member record: %v", err)
435
+
return
436
+
}
437
+
438
+
payload := map[string]interface{}{
439
+
"repo": tangledDid,
440
+
"collection": tangled.SpindleMemberNSID,
441
+
"rkey": tid.TID(),
442
+
"record": json.RawMessage(recordBytes),
443
+
}
444
+
445
+
payloadBytes, err := json.Marshal(payload)
446
+
if err != nil {
447
+
log.Printf("failed to marshal request payload: %v", err)
448
+
return
449
+
}
450
+
451
+
url := pdsEndpoint + "/xrpc/com.atproto.repo.putRecord"
452
+
req, err := http.NewRequestWithContext(context.Background(), "POST", url, bytes.NewBuffer(payloadBytes))
453
+
if err != nil {
454
+
log.Printf("failed to create HTTP request: %v", err)
455
+
return
456
+
}
457
+
458
+
req.Header.Set("Content-Type", "application/json")
459
+
req.Header.Set("Authorization", "Bearer "+session.AccessJwt)
460
+
461
+
resp, err := client.Do(req)
462
+
if err != nil {
463
+
log.Printf("failed to add user to default spindle: %v", err)
464
+
return
465
+
}
466
+
defer resp.Body.Close()
467
+
468
+
if resp.StatusCode != http.StatusOK {
469
+
log.Printf("failed to add user to default spindle: HTTP %d", resp.StatusCode)
470
+
return
471
+
}
472
+
473
+
log.Printf("successfully added %s to default spindle", did)
333
474
}
334
475
335
476
func (o *OAuthHandler) addToDefaultKnot(did string) {
-1
appview/pages/pages.go
-1
appview/pages/pages.go
+20
-3
appview/pages/templates/layouts/topbar.html
+20
-3
appview/pages/templates/layouts/topbar.html
···
9
9
10
10
<div id="right-items" class="flex items-center gap-2">
11
11
{{ with .LoggedInUser }}
12
-
<a href="/repo/new" hx-boost="true" class="btn-create hover:no-underline hover:text-white">
13
-
{{ i "plus" "w-4 h-4" }}
14
-
</a>
12
+
{{ block "newButton" . }} {{ end }}
15
13
{{ block "dropDown" . }} {{ end }}
16
14
{{ else }}
17
15
<a href="/login">login</a>
···
25
23
</nav>
26
24
{{ end }}
27
25
26
+
{{ define "newButton" }}
27
+
<details class="relative inline-block text-left">
28
+
<summary class="btn-create py-0 cursor-pointer list-none flex items-center gap-2">
29
+
{{ i "plus" "w-4 h-4" }} new
30
+
</summary>
31
+
<div class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700">
32
+
<a href="/repo/new" class="flex items-center gap-2">
33
+
{{ i "book-plus" "w-4 h-4" }}
34
+
new repository
35
+
</a>
36
+
<a href="/strings/new" class="flex items-center gap-2">
37
+
{{ i "line-squiggle" "w-4 h-4" }}
38
+
new string
39
+
</a>
40
+
</div>
41
+
</details>
42
+
{{ end }}
43
+
28
44
{{ define "dropDown" }}
29
45
<details class="relative inline-block text-left">
30
46
<summary
···
38
54
>
39
55
<a href="/{{ $user }}">profile</a>
40
56
<a href="/{{ $user }}?tab=repos">repositories</a>
57
+
<a href="/strings/{{ $user }}">strings</a>
41
58
<a href="/knots">knots</a>
42
59
<a href="/spindles">spindles</a>
43
60
<a href="/settings">settings</a>
+5
-1
appview/pages/templates/repo/pipelines/workflow.html
+5
-1
appview/pages/templates/repo/pipelines/workflow.html
···
19
19
20
20
{{ define "sidebar" }}
21
21
{{ $active := .Workflow }}
22
+
23
+
{{ $activeTab := "bg-white dark:bg-gray-700 drop-shadow-sm" }}
24
+
{{ $inactiveTab := "bg-gray-100 dark:bg-gray-800" }}
25
+
22
26
{{ with .Pipeline }}
23
27
{{ $id := .Id }}
24
28
<div class="sticky top-2 grid grid-cols-1 rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700">
25
29
{{ range $name, $all := .Statuses }}
26
30
<a href="/{{ $.RepoInfo.FullName }}/pipelines/{{ $id }}/workflow/{{ $name }}" class="no-underline hover:no-underline hover:bg-gray-100/25 hover:dark:bg-gray-700/25">
27
31
<div
28
-
class="flex gap-2 items-center justify-between p-2 {{ if eq $name $active }}bg-gray-100/50 dark:bg-gray-700/50{{ end }}">
32
+
class="flex gap-2 items-center justify-between p-2 {{ if eq $name $active }} {{ $activeTab }} {{ else }} {{ $inactiveTab }} {{ end }}">
29
33
{{ $lastStatus := $all.Latest }}
30
34
{{ $kind := $lastStatus.Status.String }}
31
35
-168
appview/pages/templates/repo/settings.html
-168
appview/pages/templates/repo/settings.html
···
1
-
{{ define "title" }}settings · {{ .RepoInfo.FullName }}{{ end }}
2
-
3
-
{{ define "repoContent" }}
4
-
{{ template "collaboratorSettings" . }}
5
-
{{ template "branchSettings" . }}
6
-
{{ template "dangerZone" . }}
7
-
{{ template "spindleSelector" . }}
8
-
{{ template "spindleSecrets" . }}
9
-
{{ end }}
10
-
11
-
{{ define "collaboratorSettings" }}
12
-
<header class="font-bold text-sm mb-4 uppercase dark:text-white">
13
-
Collaborators
14
-
</header>
15
-
16
-
<div id="collaborator-list" class="flex flex-col gap-2 mb-2">
17
-
{{ range .Collaborators }}
18
-
<div id="collaborator" class="mb-2">
19
-
<a
20
-
href="/{{ didOrHandle .Did .Handle }}"
21
-
class="no-underline hover:underline text-black dark:text-white"
22
-
>
23
-
{{ didOrHandle .Did .Handle }}
24
-
</a>
25
-
<div>
26
-
<span class="text-sm text-gray-500 dark:text-gray-400">
27
-
{{ .Role }}
28
-
</span>
29
-
</div>
30
-
</div>
31
-
{{ end }}
32
-
</div>
33
-
34
-
{{ if .RepoInfo.Roles.CollaboratorInviteAllowed }}
35
-
<form
36
-
hx-put="/{{ $.RepoInfo.FullName }}/settings/collaborator"
37
-
class="group"
38
-
>
39
-
<label for="collaborator" class="dark:text-white">
40
-
add collaborator
41
-
</label>
42
-
<input
43
-
type="text"
44
-
id="collaborator"
45
-
name="collaborator"
46
-
required
47
-
class="dark:bg-gray-700 dark:text-white"
48
-
placeholder="enter did or handle">
49
-
<button class="btn my-2 flex gap-2 items-center dark:text-white dark:hover:bg-gray-700" type="text">
50
-
<span>add</span>
51
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
52
-
</button>
53
-
</form>
54
-
{{ end }}
55
-
{{ end }}
56
-
57
-
{{ define "dangerZone" }}
58
-
{{ if .RepoInfo.Roles.RepoDeleteAllowed }}
59
-
<form
60
-
hx-confirm="Are you sure you want to delete this repository?"
61
-
hx-delete="/{{ $.RepoInfo.FullName }}/settings/delete"
62
-
class="mt-6"
63
-
hx-indicator="#delete-repo-spinner">
64
-
<label for="branch">delete repository</label>
65
-
<button class="btn my-2 flex items-center" type="text">
66
-
<span>delete</span>
67
-
<span id="delete-repo-spinner" class="group">
68
-
{{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
69
-
</span>
70
-
</button>
71
-
<span>
72
-
Deleting a repository is irreversible and permanent.
73
-
</span>
74
-
</form>
75
-
{{ end }}
76
-
{{ end }}
77
-
78
-
{{ define "branchSettings" }}
79
-
<form hx-put="/{{ $.RepoInfo.FullName }}/settings/branches/default" class="mt-6 group">
80
-
<label for="branch">default branch</label>
81
-
<div class="flex gap-2 items-center">
82
-
<select id="branch" name="branch" required class="p-1 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700">
83
-
<option value="" disabled selected >
84
-
Choose a default branch
85
-
</option>
86
-
{{ range .Branches }}
87
-
<option value="{{ .Name }}" class="py-1" {{ if .IsDefault }}selected{{ end }} >
88
-
{{ .Name }}
89
-
</option>
90
-
{{ end }}
91
-
</select>
92
-
<button class="btn my-2 flex gap-2 items-center" type="submit">
93
-
<span>save</span>
94
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
95
-
</button>
96
-
</div>
97
-
</form>
98
-
{{ end }}
99
-
100
-
{{ define "spindleSelector" }}
101
-
{{ if .RepoInfo.Roles.IsOwner }}
102
-
<form hx-post="/{{ $.RepoInfo.FullName }}/settings/spindle" class="mt-6 group" >
103
-
<label for="spindle">spindle</label>
104
-
<div class="flex gap-2 items-center">
105
-
<select id="spindle" name="spindle" required class="p-1 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700">
106
-
<option value="" selected >
107
-
None
108
-
</option>
109
-
{{ range .Spindles }}
110
-
<option value="{{ . }}" class="py-1" {{ if eq . $.CurrentSpindle }}selected{{ end }}>
111
-
{{ . }}
112
-
</option>
113
-
{{ end }}
114
-
</select>
115
-
<button class="btn my-2 flex gap-2 items-center" type="submit">
116
-
<span>save</span>
117
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
118
-
</button>
119
-
</div>
120
-
</form>
121
-
{{ end }}
122
-
{{ end }}
123
-
124
-
{{ define "spindleSecrets" }}
125
-
{{ if $.CurrentSpindle }}
126
-
<header class="font-bold text-sm mb-4 uppercase dark:text-white">
127
-
Secrets
128
-
</header>
129
-
130
-
<div id="secret-list" class="flex flex-col gap-2 mb-2">
131
-
{{ range $idx, $secret := .Secrets }}
132
-
{{ with $secret }}
133
-
<div id="secret-{{$idx}}" class="mb-2">
134
-
{{ .Key }} created on {{ .CreatedAt }} by {{ .CreatedBy }}
135
-
</div>
136
-
{{ end }}
137
-
{{ end }}
138
-
</div>
139
-
<form
140
-
hx-put="/{{ $.RepoInfo.FullName }}/settings/secrets"
141
-
class="mt-6"
142
-
hx-indicator="#add-secret-spinner">
143
-
<label for="key">secret key</label>
144
-
<input
145
-
type="text"
146
-
id="key"
147
-
name="key"
148
-
required
149
-
class="dark:bg-gray-700 dark:text-white"
150
-
placeholder="SECRET_KEY" />
151
-
<label for="value">secret value</label>
152
-
<input
153
-
type="text"
154
-
id="value"
155
-
name="value"
156
-
required
157
-
class="dark:bg-gray-700 dark:text-white"
158
-
placeholder="SECRET VALUE" />
159
-
160
-
<button class="btn my-2 flex items-center" type="text">
161
-
<span>add</span>
162
-
<span id="add-secret-spinner" class="group">
163
-
{{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
164
-
</span>
165
-
</button>
166
-
</form>
167
-
{{ end }}
168
-
{{ end }}
+3
-2
appview/pages/templates/repo/tree.html
+3
-2
appview/pages/templates/repo/tree.html
···
61
61
62
62
{{ if .IsFile }}
63
63
{{ $icon = "file" }}
64
-
{{ $iconStyle = "size-4" }}
64
+
{{ $iconStyle = "flex-shrink-0 size-4" }}
65
65
{{ end }}
66
66
<a href="{{ $link }}" class="{{ $linkstyle }}">
67
67
<div class="flex items-center gap-2">
68
-
{{ i $icon $iconStyle }}{{ .Name }}
68
+
{{ i $icon $iconStyle }}
69
+
<span class="truncate">{{ .Name }}</span>
69
70
</div>
70
71
</a>
71
72
</div>
+57
appview/pages/templates/strings/dashboard.html
+57
appview/pages/templates/strings/dashboard.html
···
1
+
{{ define "title" }}strings by {{ or .Card.UserHandle .Card.UserDid }}{{ end }}
2
+
3
+
{{ define "extrameta" }}
4
+
<meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}" />
5
+
<meta property="og:type" content="profile" />
6
+
<meta property="og:url" content="https://tangled.sh/{{ or .Card.UserHandle .Card.UserDid }}" />
7
+
<meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" />
8
+
{{ end }}
9
+
10
+
11
+
{{ define "content" }}
12
+
<div class="grid grid-cols-1 md:grid-cols-11 gap-4">
13
+
<div class="md:col-span-3 order-1 md:order-1">
14
+
{{ template "user/fragments/profileCard" .Card }}
15
+
</div>
16
+
<div id="all-strings" class="md:col-span-8 order-2 md:order-2">
17
+
{{ block "allStrings" . }}{{ end }}
18
+
</div>
19
+
</div>
20
+
{{ end }}
21
+
22
+
{{ define "allStrings" }}
23
+
<p class="text-sm font-bold p-2 dark:text-white">ALL STRINGS</p>
24
+
<div id="strings" class="grid grid-cols-1 gap-4 mb-6">
25
+
{{ range .Strings }}
26
+
{{ template "singleString" (list $ .) }}
27
+
{{ else }}
28
+
<p class="px-6 dark:text-white">This user does not have any strings yet.</p>
29
+
{{ end }}
30
+
</div>
31
+
{{ end }}
32
+
33
+
{{ define "singleString" }}
34
+
{{ $root := index . 0 }}
35
+
{{ $s := index . 1 }}
36
+
<div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800">
37
+
<div class="font-medium dark:text-white flex gap-2 items-center">
38
+
<a href="/strings/{{ or $root.Card.UserHandle $root.Card.UserDid }}/{{ $s.Rkey }}">{{ $s.Filename }}</a>
39
+
</div>
40
+
{{ with $s.Description }}
41
+
<div class="text-gray-600 dark:text-gray-300 text-sm">
42
+
{{ . }}
43
+
</div>
44
+
{{ end }}
45
+
46
+
{{ $stat := $s.Stats }}
47
+
<div class="text-gray-400 pt-4 text-sm font-mono inline-flex gap-2 mt-auto">
48
+
<span>{{ $stat.LineCount }} line{{if ne $stat.LineCount 1}}s{{end}}</span>
49
+
<span class="select-none [&:before]:content-['ยท']"></span>
50
+
{{ with $s.Edited }}
51
+
<span>edited {{ template "repo/fragments/shortTimeAgo" . }}</span>
52
+
{{ else }}
53
+
{{ template "repo/fragments/shortTimeAgo" $s.Created }}
54
+
{{ end }}
55
+
</div>
56
+
</div>
57
+
{{ end }}
+1
-3
appview/pages/templates/user/fragments/profileCard.html
+1
-3
appview/pages/templates/user/fragments/profileCard.html
···
2
2
<div class="bg-white dark:bg-gray-800 px-6 py-4 rounded drop-shadow-sm max-h-fit">
3
3
<div class="grid grid-cols-3 md:grid-cols-1 gap-1 items-center">
4
4
<div id="avatar" class="col-span-1 flex justify-center items-center">
5
-
{{ if .AvatarUri }}
6
5
<div class="w-3/4 aspect-square relative">
7
-
<img class="absolute inset-0 w-full h-full object-cover rounded-full p-2" src="{{ .AvatarUri }}" />
6
+
<img class="absolute inset-0 w-full h-full object-cover rounded-full p-2" src="{{ fullAvatar .UserDid }}" />
8
7
</div>
9
-
{{ end }}
10
8
</div>
11
9
<div class="col-span-2">
12
10
<p title="{{ didOrHandle .UserDid .UserHandle }}"
+3
appview/repo/repo.go
+3
appview/repo/repo.go
···
742
742
return
743
743
}
744
744
745
+
// remove a single leading `@`, to make @handle work with ResolveIdent
746
+
collaborator = strings.TrimPrefix(collaborator, "@")
747
+
745
748
collaboratorIdent, err := rp.idResolver.ResolveIdent(r.Context(), collaborator)
746
749
if err != nil {
747
750
fail(fmt.Sprintf("'%s' is not a valid DID/handle.", collaborator), err)
+1
-1
appview/signup/signup.go
+1
-1
appview/signup/signup.go
+4
-4
appview/spindles/spindles.go
+4
-4
appview/spindles/spindles.go
···
619
619
620
620
if string(spindles[0].Owner) != user.Did {
621
621
l.Error("unauthorized", "user", user.Did, "owner", spindles[0].Owner)
622
-
s.Pages.Notice(w, noticeId, "Failed to add member, unauthorized attempt.")
622
+
s.Pages.Notice(w, noticeId, "Failed to remove member, unauthorized attempt.")
623
623
return
624
624
}
625
625
626
626
member := r.FormValue("member")
627
627
if member == "" {
628
628
l.Error("empty member")
629
-
s.Pages.Notice(w, noticeId, "Failed to add member, empty form.")
629
+
s.Pages.Notice(w, noticeId, "Failed to remove member, empty form.")
630
630
return
631
631
}
632
632
l = l.With("member", member)
···
634
634
memberId, err := s.IdResolver.ResolveIdent(r.Context(), member)
635
635
if err != nil {
636
636
l.Error("failed to resolve member identity to handle", "err", err)
637
-
s.Pages.Notice(w, noticeId, "Failed to add member, identity resolution failed.")
637
+
s.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.")
638
638
return
639
639
}
640
640
if memberId.Handle.IsInvalidHandle() {
641
641
l.Error("failed to resolve member identity to handle")
642
-
s.Pages.Notice(w, noticeId, "Failed to add member, identity resolution failed.")
642
+
s.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.")
643
643
return
644
644
}
645
645
-16
appview/state/profile.go
-16
appview/state/profile.go
···
1
1
package state
2
2
3
3
import (
4
-
"crypto/hmac"
5
-
"crypto/sha256"
6
-
"encoding/hex"
7
4
"fmt"
8
5
"log"
9
6
"net/http"
···
142
139
log.Println("failed to get punchcard for did", "did", ident.DID.String(), "err", err)
143
140
}
144
141
145
-
profileAvatarUri := s.GetAvatarUri(ident.Handle.String())
146
142
s.pages.ProfilePage(w, pages.ProfilePageParams{
147
143
LoggedInUser: loggedInUser,
148
144
Repos: pinnedRepos,
···
151
147
Card: pages.ProfileCard{
152
148
UserDid: ident.DID.String(),
153
149
UserHandle: ident.Handle.String(),
154
-
AvatarUri: profileAvatarUri,
155
150
Profile: profile,
156
151
FollowStatus: followStatus,
157
152
Followers: followers,
···
194
189
log.Printf("getting follow stats repos for %s: %s", ident.DID.String(), err)
195
190
}
196
191
197
-
profileAvatarUri := s.GetAvatarUri(ident.Handle.String())
198
-
199
192
s.pages.ReposPage(w, pages.ReposPageParams{
200
193
LoggedInUser: loggedInUser,
201
194
Repos: repos,
···
203
196
Card: pages.ProfileCard{
204
197
UserDid: ident.DID.String(),
205
198
UserHandle: ident.Handle.String(),
206
-
AvatarUri: profileAvatarUri,
207
199
Profile: profile,
208
200
FollowStatus: followStatus,
209
201
Followers: followers,
210
202
Following: following,
211
203
},
212
204
})
213
-
}
214
-
215
-
func (s *State) GetAvatarUri(handle string) string {
216
-
secret := s.config.Avatar.SharedSecret
217
-
h := hmac.New(sha256.New, []byte(secret))
218
-
h.Write([]byte(handle))
219
-
signature := hex.EncodeToString(h.Sum(nil))
220
-
return fmt.Sprintf("%s/%s/%s", s.config.Avatar.Host, signature, handle)
221
205
}
222
206
223
207
func (s *State) UpdateProfileBio(w http.ResponseWriter, r *http.Request) {
+5
appview/strings/strings.go
+5
appview/strings/strings.go
···
99
99
w.WriteHeader(http.StatusInternalServerError)
100
100
return
101
101
}
102
+
if len(strings) < 1 {
103
+
l.Error("string not found")
104
+
s.Pages.Error404(w)
105
+
return
106
+
}
102
107
if len(strings) != 1 {
103
108
l.Error("incorrect number of records returned", "len(strings)", len(strings))
104
109
w.WriteHeader(http.StatusInternalServerError)
+9
-10
docs/hacking.md
+9
-10
docs/hacking.md
···
56
56
`nixosConfiguration` to do so.
57
57
58
58
To begin, head to `http://localhost:3000/knots` in the browser
59
-
and generate a knot secret. Replace the existing secret in
60
-
`nix/vm.nix` (`KNOT_SERVER_SECRET`) with the newly generated
61
-
secret.
59
+
and generate a knot secret. Set `$TANGLED_KNOT_SECRET` to it,
60
+
ideally in a `.envrc` with [direnv](https://direnv.net) so you
61
+
don't lose it.
62
62
63
63
You can now start a lightweight NixOS VM using
64
64
`nixos-shell` like so:
···
91
91
92
92
## running a spindle
93
93
94
-
Be sure to change the `owner` field for the spindle in
95
-
`nix/vm.nix` to your own DID. The above VM should already
96
-
be running a spindle on `localhost:6555`. You can head to
97
-
the spindle dashboard on `http://localhost:3000/spindles`,
98
-
and register a spindle with hostname `localhost:6555`. It
99
-
should instantly be verified. You can then configure each
100
-
repository to use this spindle and run CI jobs.
94
+
Be sure to set `$TANGLED_SPINDLE_OWNER` to your own DID.
95
+
The above VM should already be running a spindle on `localhost:6555`.
96
+
You can head to the spindle dashboard on `http://localhost:3000/spindles`,
97
+
and register a spindle with hostname `localhost:6555`. It should instantly
98
+
be verified. You can then configure each repository to use this spindle
99
+
and run CI jobs.
101
100
102
101
Of interest when debugging spindles:
103
102
+1
-1
docs/knot-hosting.md
+1
-1
docs/knot-hosting.md
+1
-1
docs/spindle/openbao.md
+1
-1
docs/spindle/openbao.md
···
114
114
ROLE_ID=$(bao read -field=role_id auth/approle/role/spindle/role-id)
115
115
116
116
# Generate secret ID
117
-
SECRET_ID=$(bao write -field=secret_id auth/approle/role/spindle/secret-id)
117
+
SECRET_ID=$(bao write -f -field=secret_id auth/approle/role/spindle/secret-id)
118
118
119
119
echo "Role ID: $ROLE_ID"
120
120
echo "Secret ID: $SECRET_ID"
+7
-28
flake.lock
+7
-28
flake.lock
···
1
1
{
2
2
"nodes": {
3
-
"gitignore": {
4
-
"inputs": {
5
-
"nixpkgs": [
6
-
"nixpkgs"
7
-
]
8
-
},
9
-
"locked": {
10
-
"lastModified": 1709087332,
11
-
"narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=",
12
-
"owner": "hercules-ci",
13
-
"repo": "gitignore.nix",
14
-
"rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
15
-
"type": "github"
16
-
},
17
-
"original": {
18
-
"owner": "hercules-ci",
19
-
"repo": "gitignore.nix",
20
-
"type": "github"
21
-
}
22
-
},
23
3
"flake-utils": {
24
4
"inputs": {
25
5
"systems": "systems"
···
99
79
"indigo": {
100
80
"flake": false,
101
81
"locked": {
102
-
"lastModified": 1745333930,
103
-
"narHash": "sha256-83fIHqDE+dfnZ88HaNuwfKFO+R0RKAM1WxMfNh/Matk=",
82
+
"lastModified": 1753693716,
83
+
"narHash": "sha256-DMIKnCJRODQXEHUxA+7mLzRALmnZhkkbHlFT2rCQYrE=",
104
84
"owner": "oppiliappan",
105
85
"repo": "indigo",
106
-
"rev": "e4e59280737b8676611fc077a228d47b3e8e9491",
86
+
"rev": "5f170569da9360f57add450a278d73538092d8ca",
107
87
"type": "github"
108
88
},
109
89
"original": {
···
128
108
"lucide-src": {
129
109
"flake": false,
130
110
"locked": {
131
-
"lastModified": 1742302029,
132
-
"narHash": "sha256-OyPVtpnC4/AAmPq84Wt1r1Gcs48d9KG+UBCtZK87e9k=",
111
+
"lastModified": 1754044466,
112
+
"narHash": "sha256-+exBR2OToB1iv7ZQI2S4B0lXA/QRvC9n6U99UxGpJGs=",
133
113
"type": "tarball",
134
-
"url": "https://github.com/lucide-icons/lucide/releases/download/0.483.0/lucide-icons-0.483.0.zip"
114
+
"url": "https://github.com/lucide-icons/lucide/releases/download/0.536.0/lucide-icons-0.536.0.zip"
135
115
},
136
116
"original": {
137
117
"type": "tarball",
138
-
"url": "https://github.com/lucide-icons/lucide/releases/download/0.483.0/lucide-icons-0.483.0.zip"
118
+
"url": "https://github.com/lucide-icons/lucide/releases/download/0.536.0/lucide-icons-0.536.0.zip"
139
119
}
140
120
},
141
121
"nixpkgs": {
···
156
136
},
157
137
"root": {
158
138
"inputs": {
159
-
"gitignore": "gitignore",
160
139
"gomod2nix": "gomod2nix",
161
140
"htmx-src": "htmx-src",
162
141
"htmx-ws-src": "htmx-ws-src",
+74
-26
flake.nix
+74
-26
flake.nix
···
22
22
flake = false;
23
23
};
24
24
lucide-src = {
25
-
url = "https://github.com/lucide-icons/lucide/releases/download/0.483.0/lucide-icons-0.483.0.zip";
25
+
url = "https://github.com/lucide-icons/lucide/releases/download/0.536.0/lucide-icons-0.536.0.zip";
26
26
flake = false;
27
27
};
28
28
inter-fonts-src = {
···
37
37
url = "https://sqlite.org/2024/sqlite-amalgamation-3450100.zip";
38
38
flake = false;
39
39
};
40
-
gitignore = {
41
-
url = "github:hercules-ci/gitignore.nix";
42
-
inputs.nixpkgs.follows = "nixpkgs";
43
-
};
44
40
};
45
41
46
42
outputs = {
···
51
47
htmx-src,
52
48
htmx-ws-src,
53
49
lucide-src,
54
-
gitignore,
55
50
inter-fonts-src,
56
51
sqlite-lib-src,
57
52
ibm-plex-mono-src,
···
62
57
63
58
mkPackageSet = pkgs:
64
59
pkgs.lib.makeScope pkgs.newScope (self: {
65
-
inherit (gitignore.lib) gitignoreSource;
60
+
src = let
61
+
fs = pkgs.lib.fileset;
62
+
in
63
+
fs.toSource {
64
+
root = ./.;
65
+
fileset = fs.difference (fs.intersection (fs.gitTracked ./.) (fs.fileFilter (file: !(file.hasExt "nix")) ./.)) (fs.maybeMissing ./.jj);
66
+
};
66
67
buildGoApplication =
67
68
(self.callPackage "${gomod2nix}/builder" {
68
69
gomod2nix = gomod2nix.legacyPackages.${pkgs.system}.gomod2nix;
···
74
75
};
75
76
genjwks = self.callPackage ./nix/pkgs/genjwks.nix {};
76
77
lexgen = self.callPackage ./nix/pkgs/lexgen.nix {inherit indigo;};
77
-
appview = self.callPackage ./nix/pkgs/appview.nix {
78
+
appview-static-files = self.callPackage ./nix/pkgs/appview-static-files.nix {
78
79
inherit htmx-src htmx-ws-src lucide-src inter-fonts-src ibm-plex-mono-src;
79
80
};
81
+
appview = self.callPackage ./nix/pkgs/appview.nix {};
80
82
spindle = self.callPackage ./nix/pkgs/spindle.nix {};
81
83
knot-unwrapped = self.callPackage ./nix/pkgs/knot-unwrapped.nix {};
82
84
knot = self.callPackage ./nix/pkgs/knot.nix {};
···
92
94
staticPackages = mkPackageSet pkgs.pkgsStatic;
93
95
crossPackages = mkPackageSet pkgs.pkgsCross.gnu64.pkgsStatic;
94
96
in {
95
-
appview = packages.appview;
96
-
lexgen = packages.lexgen;
97
-
knot = packages.knot;
98
-
knot-unwrapped = packages.knot-unwrapped;
99
-
spindle = packages.spindle;
100
-
genjwks = packages.genjwks;
101
-
sqlite-lib = packages.sqlite-lib;
97
+
inherit (packages) appview appview-static-files lexgen genjwks spindle knot knot-unwrapped sqlite-lib;
102
98
103
99
pkgsStatic-appview = staticPackages.appview;
104
100
pkgsStatic-knot = staticPackages.knot;
···
131
127
pkgs.tailwindcss
132
128
pkgs.nixos-shell
133
129
pkgs.redis
130
+
pkgs.coreutils # for those of us who are on systems that use busybox (alpine)
134
131
packages'.lexgen
135
132
];
136
133
shellHook = ''
137
-
mkdir -p appview/pages/static/{fonts,icons}
138
-
cp -f ${htmx-src} appview/pages/static/htmx.min.js
139
-
cp -f ${htmx-ws-src} appview/pages/static/htmx-ext-ws.min.js
140
-
cp -rf ${lucide-src}/*.svg appview/pages/static/icons/
141
-
cp -f ${inter-fonts-src}/web/InterVariable*.woff2 appview/pages/static/fonts/
142
-
cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 appview/pages/static/fonts/
143
-
cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono-Regular.woff2 appview/pages/static/fonts/
134
+
mkdir -p appview/pages/static
135
+
# no preserve is needed because watch-tailwind will want to be able to overwrite
136
+
cp -fr --no-preserve=ownership ${packages'.appview-static-files}/* appview/pages/static
144
137
export TANGLED_OAUTH_JWKS="$(${packages'.genjwks}/bin/genjwks)"
145
138
'';
146
139
env.CGO_ENABLED = 1;
···
148
141
});
149
142
apps = forAllSystems (system: let
150
143
pkgs = nixpkgsFor."${system}";
144
+
packages' = self.packages.${system};
151
145
air-watcher = name: arg:
152
146
pkgs.writeShellScriptBin "run"
153
147
''
···
166
160
in {
167
161
watch-appview = {
168
162
type = "app";
169
-
program = ''${air-watcher "appview" ""}/bin/run'';
163
+
program = toString (pkgs.writeShellScript "watch-appview" ''
164
+
echo "copying static files to appview/pages/static..."
165
+
${pkgs.coreutils}/bin/cp -fr --no-preserve=ownership ${packages'.appview-static-files}/* appview/pages/static
166
+
${air-watcher "appview" ""}/bin/run
167
+
'');
170
168
};
171
169
watch-knot = {
172
170
type = "app";
···
176
174
type = "app";
177
175
program = ''${tailwind-watcher}/bin/run'';
178
176
};
179
-
vm = {
177
+
vm = let
178
+
system =
179
+
if pkgs.stdenv.hostPlatform.isAarch64
180
+
then "aarch64"
181
+
else "x86_64";
182
+
183
+
nixos-shell = pkgs.nixos-shell.overrideAttrs (old: {
184
+
patches =
185
+
(old.patches or [])
186
+
++ [
187
+
# https://github.com/Mic92/nixos-shell/pull/94
188
+
(pkgs.fetchpatch {
189
+
name = "fix-foreign-vm.patch";
190
+
url = "https://github.com/Mic92/nixos-shell/commit/113e4cc55ae236b5b0b1fbd8b321e9b67c77580e.patch";
191
+
hash = "sha256-eauetBK0wXAOcd9PYbExokNCiwz2QyFnZ4FnwGi9VCo=";
192
+
})
193
+
];
194
+
});
195
+
in {
180
196
type = "app";
181
197
program = toString (pkgs.writeShellScript "vm" ''
182
-
${pkgs.nixos-shell}/bin/nixos-shell --flake .#vm
198
+
${nixos-shell}/bin/nixos-shell --flake .#vm-${system} --guest-system ${system}-linux
183
199
'');
184
200
};
185
201
gomod2nix = {
···
188
204
${gomod2nix.legacyPackages.${system}.gomod2nix}/bin/gomod2nix generate --outdir ./nix
189
205
'');
190
206
};
207
+
lexgen = {
208
+
type = "app";
209
+
program =
210
+
(pkgs.writeShellApplication {
211
+
name = "lexgen";
212
+
text = ''
213
+
if ! command -v lexgen > /dev/null; then
214
+
echo "error: must be executed from devshell"
215
+
exit 1
216
+
fi
217
+
218
+
rootDir=$(jj --ignore-working-copy root || git rev-parse --show-toplevel) || (echo "error: can't find repo root?"; exit 1)
219
+
cd "$rootDir"
220
+
221
+
rm api/tangled/*
222
+
lexgen --build-file lexicon-build-config.json lexicons
223
+
sed -i.bak 's/\tutil/\/\/\tutil/' api/tangled/*
224
+
${pkgs.gotools}/bin/goimports -w api/tangled/*
225
+
go run cmd/gen.go
226
+
lexgen --build-file lexicon-build-config.json lexicons
227
+
rm api/tangled/*.bak
228
+
'';
229
+
})
230
+
+ /bin/lexgen;
231
+
};
191
232
});
192
233
193
234
nixosModules.appview = {
···
217
258
218
259
services.tangled-spindle.package = lib.mkDefault self.packages.${pkgs.system}.spindle;
219
260
};
220
-
nixosConfigurations.vm = import ./nix/vm.nix {inherit self nixpkgs;};
261
+
nixosConfigurations.vm-x86_64 = import ./nix/vm.nix {
262
+
inherit self nixpkgs;
263
+
system = "x86_64-linux";
264
+
};
265
+
nixosConfigurations.vm-aarch64 = import ./nix/vm.nix {
266
+
inherit self nixpkgs;
267
+
system = "aarch64-linux";
268
+
};
221
269
};
222
270
}
+6
nix/gomod2nix.toml
+6
nix/gomod2nix.toml
···
66
66
[mod."github.com/cloudflare/circl"]
67
67
version = "v1.6.2-0.20250618153321-aa837fd1539d"
68
68
hash = "sha256-0s/i/XmMcuvPQ+qK9OIU5KxwYZyLVXRtdlYvIXRJT3Y="
69
+
[mod."github.com/cloudflare/cloudflare-go"]
70
+
version = "v0.115.0"
71
+
hash = "sha256-jezmDs6IsHA4rag7DzcHDfDgde0vU4iKgCN9+0XDViw="
69
72
[mod."github.com/containerd/errdefs"]
70
73
version = "v1.0.0"
71
74
hash = "sha256-wMZGoeqvRhuovYCJx0Js4P3qFCNTZ/6Atea/kNYoPMI="
···
169
172
[mod."github.com/golang/mock"]
170
173
version = "v1.6.0"
171
174
hash = "sha256-fWdnMQisRbiRzGT3ISrUHovquzLRHWvcv1JEsJFZRno="
175
+
[mod."github.com/google/go-querystring"]
176
+
version = "v1.1.0"
177
+
hash = "sha256-itsKgKghuX26czU79cK6C2n+lc27jm5Dw1XbIRgwZJY="
172
178
[mod."github.com/google/uuid"]
173
179
version = "v1.6.0"
174
180
hash = "sha256-VWl9sqUzdOuhW0KzQlv0gwwUQClYkmZwSydHG2sALYw="
+22
nix/modules/spindle.nix
+22
nix/modules/spindle.nix
···
54
54
example = "did:plc:qfpnj4og54vl56wngdriaxug";
55
55
description = "DID of owner (required)";
56
56
};
57
+
58
+
secrets = {
59
+
provider = mkOption {
60
+
type = types.str;
61
+
default = "sqlite";
62
+
description = "Backend to use for secret management, valid options are 'sqlite', and 'openbao'.";
63
+
};
64
+
65
+
openbao = {
66
+
proxyAddr = mkOption {
67
+
type = types.str;
68
+
default = "http://127.0.0.1:8200";
69
+
};
70
+
mount = mkOption {
71
+
type = types.str;
72
+
default = "spindle";
73
+
};
74
+
};
75
+
};
57
76
};
58
77
59
78
pipelines = {
···
89
108
"SPINDLE_SERVER_JETSTREAM=${cfg.server.jetstreamEndpoint}"
90
109
"SPINDLE_SERVER_DEV=${lib.boolToString cfg.server.dev}"
91
110
"SPINDLE_SERVER_OWNER=${cfg.server.owner}"
111
+
"SPINDLE_SERVER_SECRETS_PROVIDER=${cfg.server.secrets.provider}"
112
+
"SPINDLE_SERVER_SECRETS_OPENBAO_PROXY_ADDR=${cfg.server.secrets.openbao.proxyAddr}"
113
+
"SPINDLE_SERVER_SECRETS_OPENBAO_MOUNT=${cfg.server.secrets.openbao.mount}"
92
114
"SPINDLE_PIPELINES_NIXERY=${cfg.pipelines.nixery}"
93
115
"SPINDLE_PIPELINES_WORKFLOW_TIMEOUT=${cfg.pipelines.workflowTimeout}"
94
116
];
+23
nix/pkgs/appview-static-files.nix
+23
nix/pkgs/appview-static-files.nix
···
1
+
{
2
+
runCommandLocal,
3
+
htmx-src,
4
+
htmx-ws-src,
5
+
lucide-src,
6
+
inter-fonts-src,
7
+
ibm-plex-mono-src,
8
+
sqlite-lib,
9
+
tailwindcss,
10
+
src,
11
+
}:
12
+
runCommandLocal "appview-static-files" {} ''
13
+
mkdir -p $out/{fonts,icons} && cd $out
14
+
cp -f ${htmx-src} htmx.min.js
15
+
cp -f ${htmx-ws-src} htmx-ext-ws.min.js
16
+
cp -rf ${lucide-src}/*.svg icons/
17
+
cp -f ${inter-fonts-src}/web/InterVariable*.woff2 fonts/
18
+
cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 fonts/
19
+
cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono-Regular.woff2 fonts/
20
+
# tailwindcss -c $src/tailwind.config.js -i $src/input.css -o tw.css won't work
21
+
# for whatever reason (produces broken css), so we are doing this instead
22
+
cd ${src} && ${tailwindcss}/bin/tailwindcss -i input.css -o $out/tw.css
23
+
''
+5
-17
nix/pkgs/appview.nix
+5
-17
nix/pkgs/appview.nix
···
1
1
{
2
2
buildGoApplication,
3
3
modules,
4
-
htmx-src,
5
-
htmx-ws-src,
6
-
lucide-src,
7
-
inter-fonts-src,
8
-
ibm-plex-mono-src,
9
-
tailwindcss,
4
+
appview-static-files,
10
5
sqlite-lib,
11
-
gitignoreSource,
6
+
src,
12
7
}:
13
8
buildGoApplication {
14
9
pname = "appview";
15
10
version = "0.1.0";
16
-
src = gitignoreSource ../..;
17
-
inherit modules;
11
+
inherit src modules;
18
12
19
13
postUnpack = ''
20
14
pushd source
21
-
mkdir -p appview/pages/static/{fonts,icons}
22
-
cp -f ${htmx-src} appview/pages/static/htmx.min.js
23
-
cp -f ${htmx-ws-src} appview/pages/static/htmx-ext-ws.min.js
24
-
cp -rf ${lucide-src}/*.svg appview/pages/static/icons/
25
-
cp -f ${inter-fonts-src}/web/InterVariable*.woff2 appview/pages/static/fonts/
26
-
cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 appview/pages/static/fonts/
27
-
cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono-Regular.woff2 appview/pages/static/fonts/
28
-
${tailwindcss}/bin/tailwindcss -i input.css -o appview/pages/static/tw.css
15
+
mkdir -p appview/pages/static
16
+
cp -frv ${appview-static-files}/* appview/pages/static
29
17
popd
30
18
'';
31
19
+2
-3
nix/pkgs/genjwks.nix
+2
-3
nix/pkgs/genjwks.nix
···
1
1
{
2
-
gitignoreSource,
2
+
src,
3
3
buildGoApplication,
4
4
modules,
5
5
}:
6
6
buildGoApplication {
7
7
pname = "genjwks";
8
8
version = "0.1.0";
9
-
src = gitignoreSource ../..;
10
-
inherit modules;
9
+
inherit src modules;
11
10
subPackages = ["cmd/genjwks"];
12
11
doCheck = false;
13
12
CGO_ENABLED = 0;
+2
-3
nix/pkgs/knot-unwrapped.nix
+2
-3
nix/pkgs/knot-unwrapped.nix
+1
-1
nix/pkgs/lexgen.nix
+1
-1
nix/pkgs/lexgen.nix
+2
-3
nix/pkgs/spindle.nix
+2
-3
nix/pkgs/spindle.nix
+81
-63
nix/vm.nix
+81
-63
nix/vm.nix
···
1
1
{
2
2
nixpkgs,
3
+
system,
3
4
self,
4
-
}:
5
-
nixpkgs.lib.nixosSystem {
6
-
system = "x86_64-linux";
7
-
modules = [
8
-
self.nixosModules.knot
9
-
self.nixosModules.spindle
10
-
({
11
-
config,
12
-
pkgs,
13
-
...
14
-
}: {
15
-
virtualisation = {
16
-
memorySize = 2048;
17
-
diskSize = 10 * 1024;
18
-
cores = 2;
19
-
forwardPorts = [
20
-
# ssh
21
-
{
22
-
from = "host";
23
-
host.port = 2222;
24
-
guest.port = 22;
25
-
}
26
-
# knot
27
-
{
28
-
from = "host";
29
-
host.port = 6000;
30
-
guest.port = 6000;
31
-
}
32
-
# spindle
33
-
{
34
-
from = "host";
35
-
host.port = 6555;
36
-
guest.port = 6555;
37
-
}
5
+
}: let
6
+
envVar = name: let
7
+
var = builtins.getEnv name;
8
+
in
9
+
if var == ""
10
+
then throw "\$${name} must be defined, see docs/hacking.md for more details"
11
+
else var;
12
+
in
13
+
nixpkgs.lib.nixosSystem {
14
+
inherit system;
15
+
modules = [
16
+
self.nixosModules.knot
17
+
self.nixosModules.spindle
18
+
({
19
+
config,
20
+
pkgs,
21
+
...
22
+
}: {
23
+
nixos-shell = {
24
+
inheritPath = false;
25
+
mounts = {
26
+
mountHome = false;
27
+
mountNixProfile = false;
28
+
};
29
+
};
30
+
virtualisation = {
31
+
memorySize = 2048;
32
+
diskSize = 10 * 1024;
33
+
cores = 2;
34
+
forwardPorts = [
35
+
# ssh
36
+
{
37
+
from = "host";
38
+
host.port = 2222;
39
+
guest.port = 22;
40
+
}
41
+
# knot
42
+
{
43
+
from = "host";
44
+
host.port = 6000;
45
+
guest.port = 6000;
46
+
}
47
+
# spindle
48
+
{
49
+
from = "host";
50
+
host.port = 6555;
51
+
guest.port = 6555;
52
+
}
53
+
];
54
+
};
55
+
services.getty.autologinUser = "root";
56
+
environment.systemPackages = with pkgs; [curl vim git sqlite litecli];
57
+
systemd.tmpfiles.rules = let
58
+
u = config.services.tangled-knot.gitUser;
59
+
g = config.services.tangled-knot.gitUser;
60
+
in [
61
+
"d /var/lib/knot 0770 ${u} ${g} - -" # Create the directory first
62
+
"f+ /var/lib/knot/secret 0660 ${u} ${g} - KNOT_SERVER_SECRET=${envVar "TANGLED_VM_KNOT_SECRET"}"
38
63
];
39
-
};
40
-
services.getty.autologinUser = "root";
41
-
environment.systemPackages = with pkgs; [curl vim git];
42
-
systemd.tmpfiles.rules = let
43
-
u = config.services.tangled-knot.gitUser;
44
-
g = config.services.tangled-knot.gitUser;
45
-
in [
46
-
"d /var/lib/knot 0770 ${u} ${g} - -" # Create the directory first
47
-
"f+ /var/lib/knot/secret 0660 ${u} ${g} - KNOT_SERVER_SECRET=168c426fa6d9829fcbe85c96bdf144e800fb9737d6ca87f21acc543b1aa3e440"
48
-
];
49
-
services.tangled-knot = {
50
-
enable = true;
51
-
motd = "Welcome to the development knot!\n";
52
-
server = {
53
-
secretFile = "/var/lib/knot/secret";
54
-
hostname = "localhost:6000";
55
-
listenAddr = "0.0.0.0:6000";
64
+
services.tangled-knot = {
65
+
enable = true;
66
+
motd = "Welcome to the development knot!\n";
67
+
server = {
68
+
secretFile = "/var/lib/knot/secret";
69
+
hostname = "localhost:6000";
70
+
listenAddr = "0.0.0.0:6000";
71
+
};
56
72
};
57
-
};
58
-
services.tangled-spindle = {
59
-
enable = true;
60
-
server = {
61
-
owner = "did:plc:qfpnj4og54vl56wngdriaxug";
62
-
hostname = "localhost:6555";
63
-
listenAddr = "0.0.0.0:6555";
64
-
dev = true;
73
+
services.tangled-spindle = {
74
+
enable = true;
75
+
server = {
76
+
owner = envVar "TANGLED_VM_SPINDLE_OWNER";
77
+
hostname = "localhost:6555";
78
+
listenAddr = "0.0.0.0:6555";
79
+
dev = true;
80
+
secrets = {
81
+
provider = "sqlite";
82
+
};
83
+
};
65
84
};
66
-
};
67
-
})
68
-
];
69
-
}
85
+
})
86
+
];
87
+
}
+15
spindle/db/db.go
+15
spindle/db/db.go
···
45
45
unique(owner, name)
46
46
);
47
47
48
+
create table if not exists spindle_members (
49
+
-- identifiers for the record
50
+
id integer primary key autoincrement,
51
+
did text not null,
52
+
rkey text not null,
53
+
54
+
-- data
55
+
instance text not null,
56
+
subject text not null,
57
+
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
58
+
59
+
-- constraints
60
+
unique (did, instance, subject)
61
+
);
62
+
48
63
-- status event for a single workflow
49
64
create table if not exists events (
50
65
rkey text not null,
+59
spindle/db/member.go
+59
spindle/db/member.go
···
1
+
package db
2
+
3
+
import (
4
+
"time"
5
+
6
+
"github.com/bluesky-social/indigo/atproto/syntax"
7
+
)
8
+
9
+
type SpindleMember struct {
10
+
Id int
11
+
Did syntax.DID // owner of the record
12
+
Rkey string // rkey of the record
13
+
Instance string
14
+
Subject syntax.DID // the member being added
15
+
Created time.Time
16
+
}
17
+
18
+
func AddSpindleMember(db *DB, member SpindleMember) error {
19
+
_, err := db.Exec(
20
+
`insert or ignore into spindle_members (did, rkey, instance, subject) values (?, ?, ?, ?)`,
21
+
member.Did,
22
+
member.Rkey,
23
+
member.Instance,
24
+
member.Subject,
25
+
)
26
+
return err
27
+
}
28
+
29
+
func RemoveSpindleMember(db *DB, owner_did, rkey string) error {
30
+
_, err := db.Exec(
31
+
"delete from spindle_members where did = ? and rkey = ?",
32
+
owner_did,
33
+
rkey,
34
+
)
35
+
return err
36
+
}
37
+
38
+
func GetSpindleMember(db *DB, did, rkey string) (*SpindleMember, error) {
39
+
query :=
40
+
`select id, did, rkey, instance, subject, created
41
+
from spindle_members
42
+
where did = ? and rkey = ?`
43
+
44
+
var member SpindleMember
45
+
var createdAt string
46
+
err := db.QueryRow(query, did, rkey).Scan(
47
+
&member.Id,
48
+
&member.Did,
49
+
&member.Rkey,
50
+
&member.Instance,
51
+
&member.Subject,
52
+
&createdAt,
53
+
)
54
+
if err != nil {
55
+
return nil, err
56
+
}
57
+
58
+
return &member, nil
59
+
}
+39
-4
spindle/ingester.go
+39
-4
spindle/ingester.go
···
5
5
"encoding/json"
6
6
"errors"
7
7
"fmt"
8
+
"time"
8
9
9
10
"tangled.sh/tangled.sh/core/api/tangled"
10
11
"tangled.sh/tangled.sh/core/eventconsumer"
11
12
"tangled.sh/tangled.sh/core/idresolver"
12
13
"tangled.sh/tangled.sh/core/rbac"
14
+
"tangled.sh/tangled.sh/core/spindle/db"
13
15
14
16
comatproto "github.com/bluesky-social/indigo/api/atproto"
15
17
"github.com/bluesky-social/indigo/atproto/identity"
···
50
52
}
51
53
52
54
func (s *Spindle) ingestMember(_ context.Context, e *models.Event) error {
55
+
var err error
53
56
did := e.Did
54
-
var err error
57
+
rkey := e.Commit.RKey
55
58
56
59
l := s.l.With("component", "ingester", "record", tangled.SpindleMemberNSID)
57
60
···
66
69
}
67
70
68
71
domain := s.cfg.Server.Hostname
69
-
if s.cfg.Server.Dev {
70
-
domain = s.cfg.Server.ListenAddr
71
-
}
72
72
recordInstance := record.Instance
73
73
74
74
if recordInstance != domain {
···
82
82
return fmt.Errorf("failed to enforce permissions: %w", err)
83
83
}
84
84
85
+
if err := db.AddSpindleMember(s.db, db.SpindleMember{
86
+
Did: syntax.DID(did),
87
+
Rkey: rkey,
88
+
Instance: recordInstance,
89
+
Subject: syntax.DID(record.Subject),
90
+
Created: time.Now(),
91
+
}); err != nil {
92
+
l.Error("failed to add member", "error", err)
93
+
return fmt.Errorf("failed to add member: %w", err)
94
+
}
95
+
85
96
if err := s.e.AddSpindleMember(rbacDomain, record.Subject); err != nil {
86
97
l.Error("failed to add member", "error", err)
87
98
return fmt.Errorf("failed to add member: %w", err)
···
95
106
s.jc.AddDid(record.Subject)
96
107
97
108
return nil
109
+
110
+
case models.CommitOperationDelete:
111
+
record, err := db.GetSpindleMember(s.db, did, rkey)
112
+
if err != nil {
113
+
l.Error("failed to find member", "error", err)
114
+
return fmt.Errorf("failed to find member: %w", err)
115
+
}
116
+
117
+
if err := db.RemoveSpindleMember(s.db, did, rkey); err != nil {
118
+
l.Error("failed to remove member", "error", err)
119
+
return fmt.Errorf("failed to remove member: %w", err)
120
+
}
121
+
122
+
if err := s.e.RemoveSpindleMember(rbacDomain, record.Subject.String()); err != nil {
123
+
l.Error("failed to add member", "error", err)
124
+
return fmt.Errorf("failed to add member: %w", err)
125
+
}
126
+
l.Info("added member from firehose", "member", record.Subject)
127
+
128
+
if err := s.db.RemoveDid(record.Subject.String()); err != nil {
129
+
l.Error("failed to add did", "error", err)
130
+
return fmt.Errorf("failed to add did: %w", err)
131
+
}
132
+
s.jc.RemoveDid(record.Subject.String())
98
133
99
134
}
100
135
return nil
+1
-1
spindle/secrets/openbao.go
+1
-1
spindle/secrets/openbao.go
+14
-1
spindle/server.go
+14
-1
spindle/server.go
···
98
98
return err
99
99
}
100
100
101
-
jq := queue.NewQueue(100, 2)
101
+
jq := queue.NewQueue(100, 5)
102
102
103
103
collections := []string{
104
104
tangled.SpindleMemberNSID,
···
110
110
return fmt.Errorf("failed to setup jetstream client: %w", err)
111
111
}
112
112
jc.AddDid(cfg.Server.Owner)
113
+
114
+
// Check if the spindle knows about any Dids;
115
+
dids, err := d.GetAllDids()
116
+
if err != nil {
117
+
return fmt.Errorf("failed to get all dids: %w", err)
118
+
}
119
+
for _, d := range dids {
120
+
jc.AddDid(d)
121
+
}
113
122
114
123
resolver := idresolver.DefaultResolver()
115
124
···
231
240
232
241
if tpl.TriggerMetadata.Repo == nil {
233
242
return fmt.Errorf("no repo data found")
243
+
}
244
+
245
+
if src.Key() != tpl.TriggerMetadata.Repo.Knot {
246
+
return fmt.Errorf("repo knot does not match event source: %s != %s", src.Key(), tpl.TriggerMetadata.Repo.Knot)
234
247
}
235
248
236
249
// filter by repos