forked from tangled.org/core
this repo has no description

Compare changes

Choose any two refs to compare.

+1
.gitignore
··· 14 14 .DS_Store 15 15 .env 16 16 *.rdb 17 + .envrc
+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
··· 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
··· 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
··· 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
··· 416 416 UserDid string 417 417 UserHandle string 418 418 FollowStatus db.FollowStatus 419 - AvatarUri string 420 419 Followers int 421 420 Following int 422 421
+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
··· 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
··· 1 - {{ define "title" }}settings &middot; {{ .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
··· 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
··· 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
··· 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
··· 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
··· 219 219 err = s.cf.CreateDNSRecord(r.Context(), dns.Record{ 220 220 Type: "TXT", 221 221 Name: "_atproto." + username, 222 - Content: "did=" + did, 222 + Content: fmt.Sprintf(`"did=%s"`, did), 223 223 TTL: 6400, 224 224 Proxied: false, 225 225 })
+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
··· 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
··· 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
··· 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
··· 89 89 systemctl start knotserver 90 90 ``` 91 91 92 - The last step is to configure a reverse proxy like Nginx or Caddy to front yourself 92 + The last step is to configure a reverse proxy like Nginx or Caddy to front your 93 93 knot. Here's an example configuration for Nginx: 94 94 95 95 ```
+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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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 2 buildGoApplication, 3 3 modules, 4 4 sqlite-lib, 5 - gitignoreSource, 5 + src, 6 6 }: 7 7 buildGoApplication { 8 8 pname = "knot"; 9 9 version = "0.1.0"; 10 - src = gitignoreSource ../..; 11 - inherit modules; 10 + inherit src modules; 12 11 13 12 doCheck = false; 14 13
+1 -1
nix/pkgs/lexgen.nix
··· 7 7 version = "0.1.0"; 8 8 src = indigo; 9 9 subPackages = ["cmd/lexgen"]; 10 - vendorHash = "sha256-pGc29fgJFq8LP7n/pY1cv6ExZl88PAeFqIbFEhB3xXs="; 10 + vendorHash = "sha256-VbDrcN4r5b7utRFQzVsKgDsVgdQLSXl7oZ5kdPA/huw="; 11 11 doCheck = false; 12 12 }
+2 -3
nix/pkgs/spindle.nix
··· 2 2 buildGoApplication, 3 3 modules, 4 4 sqlite-lib, 5 - gitignoreSource, 5 + src, 6 6 }: 7 7 buildGoApplication { 8 8 pname = "spindle"; 9 9 version = "0.1.0"; 10 - src = gitignoreSource ../..; 11 - inherit modules; 10 + inherit src modules; 12 11 13 12 doCheck = false; 14 13
+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
··· 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
··· 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
··· 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
··· 132 132 return ErrKeyNotFound 133 133 } 134 134 135 - err = v.client.KVv2(v.mountPath).Delete(ctx, secretPath) 135 + err = v.client.KVv2(v.mountPath).DeleteMetadata(ctx, secretPath) 136 136 if err != nil { 137 137 return fmt.Errorf("failed to delete secret from openbao: %w", err) 138 138 }
+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