Mirror of @tangled.org/core. Running on a Raspberry Pi Zero 2 (Please be gentle).
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

appview: introduce UI for spindle registration

Signed-off-by: oppiliappan <me@oppi.li>

+902 -247
api/tangled/cbor_gen.go

This is a binary file and will not be displayed.

api/tangled/tangledpipeline.go

This is a binary file and will not be displayed.

api/tangled/tangledspindle.go

This is a binary file and will not be displayed.

+18 -25
appview/db/db.go
··· 321 321 primary key (did, date) 322 322 ); 323 323 324 + create table if not exists spindles ( 325 + id integer primary key autoincrement, 326 + owner text not null, 327 + instance text not null, 328 + verified text, -- time of verification 329 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 330 + 331 + unique(instance) 332 + ); 333 + 324 334 create table if not exists migrations ( 325 335 id integer primary key autoincrement, 326 336 name text unique ··· 525 515 cmp string 526 516 } 527 517 528 - func FilterEq(key string, arg any) filter { 518 + func newFilter(key, cmp string, arg any) filter { 529 519 return filter{ 530 520 key: key, 531 521 arg: arg, 532 - cmp: "=", 522 + cmp: cmp, 533 523 } 534 524 } 535 525 536 - func FilterNotEq(key string, arg any) filter { 537 - return filter{ 538 - key: key, 539 - arg: arg, 540 - cmp: "<>", 541 - } 542 - } 543 - 544 - func FilterGte(key string, arg any) filter { 545 - return filter{ 546 - key: key, 547 - arg: arg, 548 - cmp: ">=", 549 - } 550 - } 551 - 552 - func FilterLte(key string, arg any) filter { 553 - return filter{ 554 - key: key, 555 - arg: arg, 556 - cmp: "<=", 557 - } 558 - } 526 + func FilterEq(key string, arg any) filter { return newFilter(key, "=", arg) } 527 + func FilterNotEq(key string, arg any) filter { return newFilter(key, "<>", arg) } 528 + func FilterGte(key string, arg any) filter { return newFilter(key, ">=", arg) } 529 + func FilterLte(key string, arg any) filter { return newFilter(key, "<=", arg) } 530 + func FilterIs(key string, arg any) filter { return newFilter(key, "is", arg) } 531 + func FilterIsNot(key string, arg any) filter { return newFilter(key, "is not", arg) } 559 532 560 533 func (f filter) Condition() string { 561 534 return fmt.Sprintf("%s %s ?", f.key, f.cmp)
+131
appview/db/spindle.go
··· 1 + package db 2 + 3 + import ( 4 + "database/sql" 5 + "fmt" 6 + "strings" 7 + "time" 8 + 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + ) 11 + 12 + type Spindle struct { 13 + Id int 14 + Owner syntax.DID 15 + Instance string 16 + Verified *time.Time 17 + Created time.Time 18 + } 19 + 20 + func GetSpindles(e Execer, filters ...filter) ([]Spindle, error) { 21 + var spindles []Spindle 22 + 23 + var conditions []string 24 + var args []any 25 + for _, filter := range filters { 26 + conditions = append(conditions, filter.Condition()) 27 + args = append(args, filter.arg) 28 + } 29 + 30 + whereClause := "" 31 + if conditions != nil { 32 + whereClause = " where " + strings.Join(conditions, " and ") 33 + } 34 + 35 + query := fmt.Sprintf( 36 + `select id, owner, instance, verified, created 37 + from spindles 38 + %s 39 + order by created 40 + `, 41 + whereClause, 42 + ) 43 + 44 + rows, err := e.Query(query, args...) 45 + 46 + if err != nil { 47 + return nil, err 48 + } 49 + defer rows.Close() 50 + 51 + for rows.Next() { 52 + var spindle Spindle 53 + var createdAt string 54 + var verified sql.NullTime 55 + 56 + if err := rows.Scan( 57 + &spindle.Id, 58 + &spindle.Owner, 59 + &spindle.Instance, 60 + &verified, 61 + &createdAt, 62 + ); err != nil { 63 + return nil, err 64 + } 65 + 66 + spindle.Created, err = time.Parse(time.RFC3339, createdAt) 67 + if err != nil { 68 + spindle.Created = time.Now() 69 + } 70 + 71 + if verified.Valid { 72 + spindle.Verified = &verified.Time 73 + } 74 + 75 + spindles = append(spindles, spindle) 76 + } 77 + 78 + return spindles, nil 79 + } 80 + 81 + // if there is an existing spindle with the same instance, this returns an error 82 + func AddSpindle(e Execer, spindle Spindle) error { 83 + _, err := e.Exec( 84 + `insert into spindles (owner, instance) values (?, ?)`, 85 + spindle.Owner, 86 + spindle.Instance, 87 + ) 88 + return err 89 + } 90 + 91 + func VerifySpindle(e Execer, filters ...filter) (int64, error) { 92 + var conditions []string 93 + var args []any 94 + for _, filter := range filters { 95 + conditions = append(conditions, filter.Condition()) 96 + args = append(args, filter.arg) 97 + } 98 + 99 + whereClause := "" 100 + if conditions != nil { 101 + whereClause = " where " + strings.Join(conditions, " and ") 102 + } 103 + 104 + query := fmt.Sprintf(`update spindles set verified = strftime('%%Y-%%m-%%dT%%H:%%M:%%SZ', 'now') %s`, whereClause) 105 + 106 + res, err := e.Exec(query, args...) 107 + if err != nil { 108 + return 0, err 109 + } 110 + 111 + return res.RowsAffected() 112 + } 113 + 114 + func DeleteSpindle(e Execer, filters ...filter) error { 115 + var conditions []string 116 + var args []any 117 + for _, filter := range filters { 118 + conditions = append(conditions, filter.Condition()) 119 + args = append(args, filter.arg) 120 + } 121 + 122 + whereClause := "" 123 + if conditions != nil { 124 + whereClause = " where " + strings.Join(conditions, " and ") 125 + } 126 + 127 + query := fmt.Sprintf(`delete from spindles %s`, whereClause) 128 + 129 + _, err := e.Exec(query, args...) 130 + return err 131 + }
+76 -1
appview/ingester.go
··· 3 3 import ( 4 4 "context" 5 5 "encoding/json" 6 + "errors" 6 7 "fmt" 8 + "io" 7 9 "log" 10 + "net/http" 11 + "strings" 8 12 "time" 9 13 10 14 "github.com/bluesky-social/indigo/atproto/syntax" ··· 50 46 ingestProfile(&d, e) 51 47 case tangled.SpindleMemberNSID: 52 48 ingestSpindleMember(&d, e, enforcer) 49 + case tangled.SpindleNSID: 50 + ingestSpindle(&d, e, true) // TODO: change this to dynamic 53 51 } 54 52 55 53 return err ··· 294 288 return nil 295 289 } 296 290 297 - func ingestSpindleMember(_ *db.DbWrapper, e *models.Event, enforcer *rbac.Enforcer) error { 291 + func ingestSpindleMember(d *db.DbWrapper, e *models.Event, enforcer *rbac.Enforcer) error { 298 292 did := e.Did 299 293 var err error 300 294 ··· 321 315 } 322 316 323 317 return nil 318 + } 319 + 320 + func ingestSpindle(d *db.DbWrapper, e *models.Event, dev bool) error { 321 + did := e.Did 322 + var err error 323 + 324 + switch e.Commit.Operation { 325 + case models.CommitOperationCreate: 326 + raw := json.RawMessage(e.Commit.Record) 327 + record := tangled.Spindle{} 328 + err = json.Unmarshal(raw, &record) 329 + if err != nil { 330 + log.Printf("invalid record: %s", err) 331 + return err 332 + } 333 + 334 + // this is a special record whose rkey is the instance of the spindle itself 335 + domain := e.Commit.RKey 336 + 337 + owner, err := fetchOwner(context.TODO(), domain, true) 338 + if err != nil { 339 + log.Printf("failed to verify owner of %s: %w", domain, err) 340 + return err 341 + } 342 + 343 + // verify that the spindle owner points back to this did 344 + if owner != did { 345 + log.Printf("incorrect owner for domain: %s, %s != %s", domain, owner, did) 346 + return err 347 + } 348 + 349 + // mark this spindle as registered 350 + } 351 + 352 + return nil 353 + } 354 + 355 + func fetchOwner(ctx context.Context, domain string, dev bool) (string, error) { 356 + scheme := "https" 357 + if dev { 358 + scheme = "http" 359 + } 360 + 361 + url := fmt.Sprintf("%s://%s/owner", scheme, domain) 362 + req, err := http.NewRequest("GET", url, nil) 363 + if err != nil { 364 + return "", err 365 + } 366 + 367 + client := &http.Client{ 368 + Timeout: 1 * time.Second, 369 + } 370 + 371 + resp, err := client.Do(req.WithContext(ctx)) 372 + if err != nil || resp.StatusCode != 200 { 373 + return "", errors.New("failed to fetch /owner") 374 + } 375 + 376 + body, err := io.ReadAll(io.LimitReader(resp.Body, 1024)) // read atmost 1kb of data 377 + if err != nil { 378 + return "", fmt.Errorf("failed to read /owner response: %w", err) 379 + } 380 + 381 + did := strings.TrimSpace(string(body)) 382 + if did == "" { 383 + return "", errors.New("empty DID in /owner response") 384 + } 385 + 386 + return did, nil 324 387 }
+18
appview/pages/pages.go
··· 291 291 return p.execute("knot", w, params) 292 292 } 293 293 294 + type SpindlesParams struct { 295 + LoggedInUser *oauth.User 296 + Spindles []db.Spindle 297 + } 298 + 299 + func (p *Pages) Spindles(w io.Writer, params SpindlesParams) error { 300 + return p.execute("spindles/index", w, params) 301 + } 302 + 303 + type SpindleListingParams struct { 304 + LoggedInUser *oauth.User 305 + Spindle db.Spindle 306 + } 307 + 308 + func (p *Pages) SpindleListing(w io.Writer, params SpindleListingParams) error { 309 + return p.execute("spindles/fragments/spindleListing", w, params) 310 + } 311 + 294 312 type NewRepoParams struct { 295 313 LoggedInUser *oauth.User 296 314 Knots []string
+2 -2
appview/pages/templates/knots.html
··· 20 20 required 21 21 class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400" 22 22 > 23 - <button class="btn dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 flex gap-2 items-center" type="submit"> 23 + <button class="btn dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 flex items-center" type="submit"> 24 24 <span>generate key</span> 25 25 <span id="generate-knot-key-spinner" class="group"> 26 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 26 + {{ i "loader-circle" "pl-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 27 27 </span> 28 28 </button> 29 29 <div id="settings-knots-error" class="error dark:text-red-400"></div>
+1
appview/pages/templates/layouts/topbar.html
··· 45 45 > 46 46 <a href="/{{ didOrHandle .Did .Handle }}">profile</a> 47 47 <a href="/knots">knots</a> 48 + <a href="/spindles">spindles</a> 48 49 <a href="/settings">settings</a> 49 50 <a href="#" 50 51 hx-post="/logout"
+51
appview/pages/templates/spindles/fragments/spindleListing.html
··· 1 + {{ define "spindles/fragments/spindleListing" }} 2 + <div id="spindle-{{.Id}}" class="flex items-center justify-between p-2 border-b border-gray-200 dark:border-gray-700"> 3 + <div id="left-side" class="flex items-center gap-2 min-w-0 max-w-[60%]"> 4 + {{ i "hard-drive" "w-4 h-4" }} 5 + {{ .Instance }} 6 + <span class="text-gray-500"> 7 + {{ .Created | shortTimeFmt }} ago 8 + </span> 9 + </div> 10 + <div id="right-side" class="flex gap-2"> 11 + {{ $style := "px-2 py-1 rounded flex items-center flex-shrink-0 gap-2 text-sm" }} 12 + {{ if .Verified }} 13 + <span class="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 {{$style}}">{{ i "shield-check" "w-4 h-4" }} verified</span> 14 + {{ else }} 15 + <span class="bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200 {{$style}}">{{ i "shield-off" "w-4 h-4" }} unverified</span> 16 + {{ block "retryButton" . }} {{ end }} 17 + {{ end }} 18 + {{ block "deleteButton" . }} {{ end }} 19 + </div> 20 + </div> 21 + {{ end }} 22 + 23 + {{ define "deleteButton" }} 24 + <button 25 + class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group" 26 + title="Delete spindle" 27 + hx-delete="/spindles/{{ urlquery .Instance }}" 28 + hx-swap="outerHTML" 29 + hx-target="#spindle-{{.Id}}" 30 + hx-confirm="Are you sure you want to delete the spindle '{{ .Instance }}'?" 31 + > 32 + {{ i "trash-2" "w-5 h-5" }} 33 + <span class="hidden md:inline">delete</span> 34 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 35 + </button> 36 + {{ end }} 37 + 38 + 39 + {{ define "retryButton" }} 40 + <button 41 + class="btn gap-2 group" 42 + title="Retry spindle verification" 43 + hx-post="/spindles/{{ urlquery .Instance }}/retry" 44 + hx-swap="none" 45 + hx-target="#spindle-{{.Id}}" 46 + > 47 + {{ i "rotate-ccw" "w-5 h-5" }} 48 + <span class="hidden md:inline">retry</span> 49 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 50 + </button> 51 + {{ end }}
+69
appview/pages/templates/spindles/index.html
··· 1 + {{ define "title" }}spindles{{ end }} 2 + 3 + {{ define "content" }} 4 + <div class="px-6 py-4"> 5 + <h1 class="text-xl font-bold dark:text-white">Spindles</h1> 6 + </div> 7 + 8 + <section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> 9 + <div class="flex flex-col gap-6"> 10 + {{ block "all" . }} {{ end }} 11 + {{ block "register" . }} {{ end }} 12 + </div> 13 + </section> 14 + {{ end }} 15 + 16 + {{ define "all" }} 17 + <section class="rounded bg-white dark:bg-gray-800 drop-shadow-sm w-full flex flex-col gap-2"> 18 + <h2 class="text-sm font-bold py-2 uppercase dark:text-gray-300">your spindles</h2> 19 + <div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 w-full"> 20 + {{ range $spindle := .Spindles }} 21 + {{ template "spindles/fragments/spindleListing" . }} 22 + {{ else }} 23 + <div class="flex items-center justify-center p-2 border-b border-gray-200 dark:border-gray-700 text-gray-500"> 24 + no spindles registered yet 25 + </div> 26 + {{ end }} 27 + </div> 28 + <div id="operation-error" class="dark:text-red-400"></div> 29 + </section> 30 + {{ end }} 31 + 32 + {{ define "register" }} 33 + <section class="rounded bg-white dark:bg-gray-800 drop-shadow-sm w-full lg:w-fit flex flex-col gap-2"> 34 + <h2 class="text-sm font-bold py-2 uppercase dark:text-gray-300">register a spindle</h2> 35 + <p class="mb-2 dark:text-gray-300">Enter the hostname of your spindle to get started</p> 36 + <form 37 + hx-post="/spindles/register" 38 + class="max-w-2xl mb-2 space-y-4" 39 + hx-indicator="#register-spinner" 40 + hx-swap="none" 41 + > 42 + <div class="flex gap-2"> 43 + <input 44 + type="text" 45 + id="instance" 46 + name="instance" 47 + placeholder="spindle.example.com" 48 + required 49 + class="flex-1 w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400 px-3 py-2 border rounded" 50 + > 51 + <button 52 + type="submit" 53 + class="btn rounded flex items-center py-2 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 group" 54 + > 55 + <span class="inline-flex items-center gap-2"> 56 + {{ i "plus" "w-4 h-4" }} 57 + register 58 + </span> 59 + <span id="register-spinner" class="pl-2 hidden group-[.htmx-request]:inline"> 60 + {{ i "loader-circle" "w-4 h-4 animate-spin" }} 61 + </span> 62 + </button> 63 + </div> 64 + 65 + <div id="register-error" class="dark:text-red-400"></div> 66 + </form> 67 + 68 + </section> 69 + {{ end }}
+176
appview/spindleresolver/resolver.go
··· 1 + package spindleresolver 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "errors" 7 + "fmt" 8 + "io" 9 + "net/http" 10 + "strings" 11 + "time" 12 + 13 + "tangled.sh/tangled.sh/core/api/tangled" 14 + "tangled.sh/tangled.sh/core/appview/cache" 15 + "tangled.sh/tangled.sh/core/appview/idresolver" 16 + 17 + "github.com/bluesky-social/indigo/api/atproto" 18 + "github.com/bluesky-social/indigo/xrpc" 19 + ) 20 + 21 + type ResolutionStatus string 22 + 23 + const ( 24 + StatusOK ResolutionStatus = "ok" 25 + StatusError ResolutionStatus = "error" 26 + StatusInvalid ResolutionStatus = "invalid" 27 + ) 28 + 29 + type Resolution struct { 30 + Status ResolutionStatus `json:"status"` 31 + OwnerDID string `json:"ownerDid,omitempty"` 32 + VerifiedAt time.Time `json:"verifiedAt"` 33 + } 34 + 35 + type Resolver struct { 36 + cache *cache.Cache 37 + http *http.Client 38 + config Config 39 + idResolver *idresolver.Resolver 40 + } 41 + 42 + type Config struct { 43 + HitTTL time.Duration 44 + ErrTTL time.Duration 45 + InvalidTTL time.Duration 46 + Dev bool 47 + } 48 + 49 + func NewResolver(cache *cache.Cache, client *http.Client, config Config) *Resolver { 50 + if client == nil { 51 + client = &http.Client{ 52 + Timeout: 2 * time.Second, 53 + } 54 + } 55 + return &Resolver{ 56 + cache: cache, 57 + http: client, 58 + config: config, 59 + } 60 + } 61 + 62 + func DefaultResolver(cache *cache.Cache) *Resolver { 63 + return NewResolver( 64 + cache, 65 + &http.Client{ 66 + Timeout: 2 * time.Second, 67 + }, 68 + Config{ 69 + HitTTL: 24 * time.Hour, 70 + ErrTTL: 30 * time.Second, 71 + InvalidTTL: 1 * time.Minute, 72 + }, 73 + ) 74 + } 75 + 76 + func (r *Resolver) ResolveInstance(ctx context.Context, domain string) (*Resolution, error) { 77 + key := "spindle:" + domain 78 + 79 + val, err := r.cache.Get(ctx, key).Result() 80 + if err == nil { 81 + var cached Resolution 82 + if err := json.Unmarshal([]byte(val), &cached); err == nil { 83 + return &cached, nil 84 + } 85 + } 86 + 87 + resolution, ttl := r.verify(ctx, domain) 88 + 89 + data, _ := json.Marshal(resolution) 90 + r.cache.Set(ctx, key, data, ttl) 91 + 92 + if resolution.Status == StatusOK { 93 + return resolution, nil 94 + } 95 + 96 + return resolution, fmt.Errorf("verification failed: %s", resolution.Status) 97 + } 98 + 99 + func (r *Resolver) verify(ctx context.Context, domain string) (*Resolution, time.Duration) { 100 + owner, err := r.fetchOwner(ctx, domain) 101 + if err != nil { 102 + return &Resolution{Status: StatusError, VerifiedAt: time.Now()}, r.config.ErrTTL 103 + } 104 + 105 + record, err := r.fetchRecord(ctx, owner, domain) 106 + if err != nil { 107 + return &Resolution{Status: StatusError, VerifiedAt: time.Now()}, r.config.ErrTTL 108 + } 109 + 110 + if record.Instance == domain { 111 + return &Resolution{ 112 + Status: StatusOK, 113 + OwnerDID: owner, 114 + VerifiedAt: time.Now(), 115 + }, r.config.HitTTL 116 + } 117 + 118 + return &Resolution{ 119 + Status: StatusInvalid, 120 + OwnerDID: owner, 121 + VerifiedAt: time.Now(), 122 + }, r.config.InvalidTTL 123 + } 124 + 125 + func (r *Resolver) fetchOwner(ctx context.Context, domain string) (string, error) { 126 + scheme := "https" 127 + if r.config.Dev { 128 + scheme = "http" 129 + } 130 + 131 + url := fmt.Sprintf("%s://%s/owner", scheme, domain) 132 + req, err := http.NewRequest("GET", url, nil) 133 + if err != nil { 134 + return "", err 135 + } 136 + 137 + resp, err := r.http.Do(req.WithContext(ctx)) 138 + if err != nil || resp.StatusCode != 200 { 139 + return "", errors.New("failed to fetch /owner") 140 + } 141 + 142 + body, err := io.ReadAll(io.LimitReader(resp.Body, 1024)) // read atmost 1kb of data 143 + if err != nil { 144 + return "", fmt.Errorf("failed to read /owner response: %w", err) 145 + } 146 + 147 + did := strings.TrimSpace(string(body)) 148 + if did == "" { 149 + return "", errors.New("empty DID in /owner response") 150 + } 151 + 152 + return did, nil 153 + } 154 + 155 + func (r *Resolver) fetchRecord(ctx context.Context, did, rkey string) (*tangled.Spindle, error) { 156 + ident, err := r.idResolver.ResolveIdent(ctx, did) 157 + if err != nil { 158 + return nil, err 159 + } 160 + 161 + xrpcc := xrpc.Client{ 162 + Host: ident.PDSEndpoint(), 163 + } 164 + 165 + rec, err := atproto.RepoGetRecord(ctx, &xrpcc, "", tangled.SpindleNSID, did, rkey) 166 + if err != nil { 167 + return nil, err 168 + } 169 + 170 + out, ok := rec.Value.Val.(*tangled.Spindle) 171 + if !ok { 172 + return nil, fmt.Errorf("invalid record returned") 173 + } 174 + 175 + return out, nil 176 + }
+304
appview/spindles/spindles.go
··· 1 + package spindles 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "fmt" 7 + "io" 8 + "log/slog" 9 + "net/http" 10 + "strings" 11 + "time" 12 + 13 + "github.com/go-chi/chi/v5" 14 + "tangled.sh/tangled.sh/core/api/tangled" 15 + "tangled.sh/tangled.sh/core/appview/config" 16 + "tangled.sh/tangled.sh/core/appview/db" 17 + "tangled.sh/tangled.sh/core/appview/middleware" 18 + "tangled.sh/tangled.sh/core/appview/oauth" 19 + "tangled.sh/tangled.sh/core/appview/pages" 20 + "tangled.sh/tangled.sh/core/rbac" 21 + 22 + comatproto "github.com/bluesky-social/indigo/api/atproto" 23 + "github.com/bluesky-social/indigo/atproto/syntax" 24 + lexutil "github.com/bluesky-social/indigo/lex/util" 25 + ) 26 + 27 + type Spindles struct { 28 + Db *db.DB 29 + OAuth *oauth.OAuth 30 + Pages *pages.Pages 31 + Config *config.Config 32 + Enforcer *rbac.Enforcer 33 + Logger *slog.Logger 34 + } 35 + 36 + func (s *Spindles) Router() http.Handler { 37 + r := chi.NewRouter() 38 + 39 + r.Use(middleware.AuthMiddleware(s.OAuth)) 40 + 41 + r.Get("/", s.spindles) 42 + r.Post("/register", s.register) 43 + r.Delete("/{instance}", s.delete) 44 + r.Post("/{instance}/retry", s.retry) 45 + 46 + return r 47 + } 48 + 49 + func (s *Spindles) spindles(w http.ResponseWriter, r *http.Request) { 50 + user := s.OAuth.GetUser(r) 51 + all, err := db.GetSpindles( 52 + s.Db, 53 + db.FilterEq("owner", user.Did), 54 + ) 55 + if err != nil { 56 + s.Logger.Error("failed to fetch spindles", "err", err) 57 + } 58 + 59 + s.Pages.Spindles(w, pages.SpindlesParams{ 60 + LoggedInUser: user, 61 + Spindles: all, 62 + }) 63 + } 64 + 65 + // this endpoint inserts a record on behalf of the user to register that domain 66 + // 67 + // when registered, it also makes a request to see if the spindle declares this users as its owner, 68 + // and if so, marks the spindle as verified. 69 + // 70 + // if the spindle is not up yet, the user is free to retry verification at a later point 71 + func (s *Spindles) register(w http.ResponseWriter, r *http.Request) { 72 + user := s.OAuth.GetUser(r) 73 + l := s.Logger.With("handler", "register") 74 + 75 + noticeId := "register-error" 76 + defaultErr := "Failed to register spindle. Try again later." 77 + fail := func() { 78 + s.Pages.Notice(w, noticeId, defaultErr) 79 + } 80 + 81 + instance := r.FormValue("instance") 82 + if instance == "" { 83 + s.Pages.Notice(w, noticeId, "Incomplete form.") 84 + return 85 + } 86 + 87 + tx, err := s.Db.Begin() 88 + if err != nil { 89 + l.Error("failed to start transaction", "err", err) 90 + fail() 91 + return 92 + } 93 + defer tx.Rollback() 94 + 95 + err = db.AddSpindle(tx, db.Spindle{ 96 + Owner: syntax.DID(user.Did), 97 + Instance: instance, 98 + }) 99 + if err != nil { 100 + l.Error("failed to insert", "err", err) 101 + fail() 102 + return 103 + } 104 + 105 + // create record on pds 106 + client, err := s.OAuth.AuthorizedClient(r) 107 + if err != nil { 108 + l.Error("failed to authorize client", "err", err) 109 + fail() 110 + return 111 + } 112 + 113 + _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 114 + Collection: tangled.SpindleNSID, 115 + Repo: user.Did, 116 + Rkey: instance, 117 + Record: &lexutil.LexiconTypeDecoder{ 118 + Val: &tangled.Spindle{ 119 + CreatedAt: time.Now().Format(time.RFC3339), 120 + }, 121 + }, 122 + }) 123 + if err != nil { 124 + l.Error("failed to put record", "err", err) 125 + fail() 126 + return 127 + } 128 + 129 + err = tx.Commit() 130 + if err != nil { 131 + l.Error("failed to commit transaction", "err", err) 132 + fail() 133 + return 134 + } 135 + 136 + // begin verification 137 + expectedOwner, err := fetchOwner(r.Context(), instance, s.Config.Core.Dev) 138 + if err != nil { 139 + l.Error("verification failed", "err", err) 140 + 141 + // just refresh the page 142 + s.Pages.HxRefresh(w) 143 + return 144 + } 145 + 146 + if expectedOwner != user.Did { 147 + // verification failed 148 + l.Error("verification failed", "expectedOwner", expectedOwner, "observedOwner", user.Did) 149 + s.Pages.HxRefresh(w) 150 + return 151 + } 152 + 153 + // ok 154 + s.Pages.HxRefresh(w) 155 + return 156 + } 157 + 158 + func (s *Spindles) delete(w http.ResponseWriter, r *http.Request) { 159 + user := s.OAuth.GetUser(r) 160 + l := s.Logger.With("handler", "register") 161 + 162 + noticeId := "operation-error" 163 + defaultErr := "Failed to delete spindle. Try again later." 164 + fail := func() { 165 + s.Pages.Notice(w, noticeId, defaultErr) 166 + } 167 + 168 + instance := chi.URLParam(r, "instance") 169 + if instance == "" { 170 + l.Error("empty instance") 171 + fail() 172 + return 173 + } 174 + 175 + tx, err := s.Db.Begin() 176 + if err != nil { 177 + l.Error("failed to start txn", "err", err) 178 + fail() 179 + return 180 + } 181 + defer tx.Rollback() 182 + 183 + err = db.DeleteSpindle( 184 + tx, 185 + db.FilterEq("owner", user.Did), 186 + db.FilterEq("instance", instance), 187 + ) 188 + if err != nil { 189 + l.Error("failed to delete spindle", "err", err) 190 + fail() 191 + return 192 + } 193 + 194 + client, err := s.OAuth.AuthorizedClient(r) 195 + if err != nil { 196 + l.Error("failed to authorize client", "err", err) 197 + fail() 198 + return 199 + } 200 + 201 + _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 202 + Collection: tangled.SpindleNSID, 203 + Repo: user.Did, 204 + Rkey: instance, 205 + }) 206 + if err != nil { 207 + // non-fatal 208 + l.Error("failed to delete record", "err", err) 209 + } 210 + 211 + err = tx.Commit() 212 + if err != nil { 213 + l.Error("failed to delete spindle", "err", err) 214 + fail() 215 + return 216 + } 217 + 218 + w.Write([]byte{}) 219 + } 220 + 221 + func (s *Spindles) retry(w http.ResponseWriter, r *http.Request) { 222 + user := s.OAuth.GetUser(r) 223 + l := s.Logger.With("handler", "register") 224 + 225 + noticeId := "operation-error" 226 + defaultErr := "Failed to verify spindle. Try again later." 227 + fail := func() { 228 + s.Pages.Notice(w, noticeId, defaultErr) 229 + } 230 + 231 + instance := chi.URLParam(r, "instance") 232 + if instance == "" { 233 + l.Error("empty instance") 234 + fail() 235 + return 236 + } 237 + 238 + // begin verification 239 + expectedOwner, err := fetchOwner(r.Context(), instance, s.Config.Core.Dev) 240 + if err != nil { 241 + l.Error("verification failed", "err", err) 242 + fail() 243 + return 244 + } 245 + 246 + if expectedOwner != user.Did { 247 + l.Error("verification failed", "expectedOwner", expectedOwner, "observedOwner", user.Did) 248 + s.Pages.Notice(w, noticeId, fmt.Sprintf("Owner did not match, expected %s, got %s", expectedOwner, user.Did)) 249 + return 250 + } 251 + 252 + // mark this spindle as verified in the db 253 + rowId, err := db.VerifySpindle( 254 + s.Db, 255 + db.FilterEq("owner", user.Did), 256 + db.FilterEq("instance", instance), 257 + ) 258 + 259 + verifiedSpindle := db.Spindle{ 260 + Id: int(rowId), 261 + Owner: syntax.DID(user.Did), 262 + Instance: instance, 263 + } 264 + 265 + w.Header().Set("HX-Reswap", "outerHTML") 266 + s.Pages.SpindleListing(w, pages.SpindleListingParams{ 267 + LoggedInUser: user, 268 + Spindle: verifiedSpindle, 269 + }) 270 + } 271 + 272 + func fetchOwner(ctx context.Context, domain string, dev bool) (string, error) { 273 + scheme := "https" 274 + if dev { 275 + scheme = "http" 276 + } 277 + 278 + url := fmt.Sprintf("%s://%s/owner", scheme, domain) 279 + req, err := http.NewRequest("GET", url, nil) 280 + if err != nil { 281 + return "", err 282 + } 283 + 284 + client := &http.Client{ 285 + Timeout: 1 * time.Second, 286 + } 287 + 288 + resp, err := client.Do(req.WithContext(ctx)) 289 + if err != nil || resp.StatusCode != 200 { 290 + return "", errors.New("failed to fetch /owner") 291 + } 292 + 293 + body, err := io.ReadAll(io.LimitReader(resp.Body, 1024)) // read atmost 1kb of data 294 + if err != nil { 295 + return "", fmt.Errorf("failed to read /owner response: %w", err) 296 + } 297 + 298 + did := strings.TrimSpace(string(body)) 299 + if did == "" { 300 + return "", errors.New("empty DID in /owner response") 301 + } 302 + 303 + return did, nil 304 + }
+18
appview/state/router.go
··· 12 12 "tangled.sh/tangled.sh/core/appview/pulls" 13 13 "tangled.sh/tangled.sh/core/appview/repo" 14 14 "tangled.sh/tangled.sh/core/appview/settings" 15 + "tangled.sh/tangled.sh/core/appview/spindles" 15 16 "tangled.sh/tangled.sh/core/appview/state/userutil" 17 + "tangled.sh/tangled.sh/core/log" 16 18 ) 17 19 18 20 func (s *State) Router() http.Handler { ··· 144 142 }) 145 143 146 144 r.Mount("/settings", s.SettingsRouter()) 145 + r.Mount("/spindles", s.SpindlesRouter()) 147 146 r.Mount("/", s.OAuthRouter()) 148 147 149 148 r.Get("/keys/{user}", s.Keys) ··· 170 167 } 171 168 172 169 return settings.Router() 170 + } 171 + 172 + func (s *State) SpindlesRouter() http.Handler { 173 + logger := log.New("spindles") 174 + 175 + spindles := &spindles.Spindles{ 176 + Db: s.db, 177 + OAuth: s.oauth, 178 + Pages: s.pages, 179 + Config: s.config, 180 + Enforcer: s.enforcer, 181 + Logger: logger, 182 + } 183 + 184 + return spindles.Router() 173 185 } 174 186 175 187 func (s *State) IssuesRouter(mw *middleware.Middleware) http.Handler {
+13 -12
cmd/gen.go
··· 16 16 "tangled", 17 17 tangled.ActorProfile{}, 18 18 tangled.FeedStar{}, 19 - tangled.GitRefUpdate_Meta_CommitCount_ByEmail_Elem{}, 20 - tangled.GitRefUpdate_Meta_CommitCount{}, 21 - tangled.GitRefUpdate_Meta{}, 22 19 tangled.GitRefUpdate{}, 20 + tangled.GitRefUpdate_Meta{}, 21 + tangled.GitRefUpdate_Meta_CommitCount{}, 22 + tangled.GitRefUpdate_Meta_CommitCount_ByEmail_Elem{}, 23 23 tangled.GraphFollow{}, 24 24 tangled.KnotMember{}, 25 - tangled.PipelineStatus{}, 25 + tangled.Pipeline{}, 26 26 tangled.Pipeline_CloneOpts{}, 27 27 tangled.Pipeline_Dependencies_Elem{}, 28 - tangled.Pipeline_ManualTriggerData_Inputs_Elem{}, 29 28 tangled.Pipeline_ManualTriggerData{}, 29 + tangled.Pipeline_ManualTriggerData_Inputs_Elem{}, 30 30 tangled.Pipeline_PullRequestTriggerData{}, 31 31 tangled.Pipeline_PushTriggerData{}, 32 32 tangled.Pipeline_Step_Environment_Elem{}, 33 + tangled.PipelineStatus{}, 33 34 tangled.Pipeline_Step{}, 34 35 tangled.Pipeline_TriggerMetadata{}, 35 36 tangled.Pipeline_TriggerRepo{}, 36 - tangled.Pipeline_Workflow_Environment_Elem{}, 37 37 tangled.Pipeline_Workflow{}, 38 - tangled.Pipeline{}, 38 + tangled.Pipeline_Workflow_Environment_Elem{}, 39 39 tangled.PublicKey{}, 40 + tangled.Repo{}, 40 41 tangled.RepoArtifact{}, 42 + tangled.RepoIssue{}, 41 43 tangled.RepoIssueComment{}, 42 44 tangled.RepoIssueState{}, 43 - tangled.RepoIssue{}, 44 - tangled.RepoPullComment{}, 45 - tangled.RepoPullStatus{}, 46 - tangled.RepoPull_Source{}, 47 45 tangled.RepoPull{}, 48 - tangled.Repo{}, 46 + tangled.RepoPullComment{}, 47 + tangled.RepoPull_Source{}, 48 + tangled.RepoPullStatus{}, 49 + tangled.Spindle{}, 49 50 tangled.SpindleMember{}, 50 51 ); err != nil { 51 52 panic(err)
+25
lexicons/spindle.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.spindle", 4 + "needsCbor": true, 5 + "needsType": true, 6 + "defs": { 7 + "main": { 8 + "type": "record", 9 + "key": "any", 10 + "record": { 11 + "type": "object", 12 + "required": [ 13 + "createdAt" 14 + ], 15 + "properties": { 16 + "createdAt": { 17 + "type": "string", 18 + "format": "datetime" 19 + } 20 + } 21 + } 22 + } 23 + } 24 + } 25 +
-207
spindle/db/pipelines.go
··· 1 - package db 2 - 3 - // 4 - // import ( 5 - // "database/sql" 6 - // "fmt" 7 - // "time" 8 - // 9 - // "tangled.sh/tangled.sh/core/api/tangled" 10 - // "tangled.sh/tangled.sh/core/notifier" 11 - // ) 12 - // 13 - // type PipelineRunStatus string 14 - // 15 - // var ( 16 - // PipelinePending PipelineRunStatus = "pending" 17 - // PipelineRunning PipelineRunStatus = "running" 18 - // PipelineFailed PipelineRunStatus = "failed" 19 - // PipelineTimeout PipelineRunStatus = "timeout" 20 - // PipelineCancelled PipelineRunStatus = "cancelled" 21 - // PipelineSuccess PipelineRunStatus = "success" 22 - // ) 23 - // 24 - // type PipelineStatus struct { 25 - // Rkey string `json:"rkey"` 26 - // Pipeline string `json:"pipeline"` 27 - // Status PipelineRunStatus `json:"status"` 28 - // 29 - // // only if Failed 30 - // Error string `json:"error"` 31 - // ExitCode int `json:"exit_code"` 32 - // 33 - // LastUpdate int64 `json:"last_update"` 34 - // StartedAt time.Time `json:"started_at"` 35 - // UpdatedAt time.Time `json:"updated_at"` 36 - // FinishedAt time.Time `json:"finished_at"` 37 - // } 38 - // 39 - // func (p PipelineStatus) AsRecord() *tangled.PipelineStatus { 40 - // exitCode64 := int64(p.ExitCode) 41 - // finishedAt := p.FinishedAt.String() 42 - // 43 - // return &tangled.PipelineStatus{ 44 - // LexiconTypeID: tangled.PipelineStatusNSID, 45 - // Pipeline: p.Pipeline, 46 - // Status: string(p.Status), 47 - // 48 - // ExitCode: &exitCode64, 49 - // Error: &p.Error, 50 - // 51 - // StartedAt: p.StartedAt.String(), 52 - // UpdatedAt: p.UpdatedAt.String(), 53 - // FinishedAt: &finishedAt, 54 - // } 55 - // } 56 - // 57 - // func pipelineAtUri(rkey, knot string) string { 58 - // return fmt.Sprintf("at://%s/did:web:%s/%s", tangled.PipelineStatusNSID, knot, rkey) 59 - // } 60 - // 61 - // func (db *DB) CreatePipeline(rkey, pipeline string, n *notifier.Notifier) error { 62 - // _, err := db.Exec(` 63 - // insert into pipeline_status (rkey, status, pipeline, last_update) 64 - // values (?, ?, ?, ?) 65 - // `, rkey, PipelinePending, pipeline, time.Now().UnixNano()) 66 - // 67 - // if err != nil { 68 - // return err 69 - // } 70 - // n.NotifyAll() 71 - // return nil 72 - // } 73 - // 74 - // func (db *DB) MarkPipelineRunning(rkey string, n *notifier.Notifier) error { 75 - // _, err := db.Exec(` 76 - // update pipeline_status 77 - // set status = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now'), last_update = ? 78 - // where rkey = ? 79 - // `, PipelineRunning, rkey, time.Now().UnixNano()) 80 - // 81 - // if err != nil { 82 - // return err 83 - // } 84 - // n.NotifyAll() 85 - // return nil 86 - // } 87 - // 88 - // func (db *DB) MarkPipelineFailed(rkey string, exitCode int, errorMsg string, n *notifier.Notifier) error { 89 - // _, err := db.Exec(` 90 - // update pipeline_status 91 - // set status = ?, 92 - // exit_code = ?, 93 - // error = ?, 94 - // updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now'), 95 - // finished_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now'), 96 - // last_update = ? 97 - // where rkey = ? 98 - // `, PipelineFailed, exitCode, errorMsg, rkey, time.Now().UnixNano()) 99 - // if err != nil { 100 - // return err 101 - // } 102 - // n.NotifyAll() 103 - // return nil 104 - // } 105 - // 106 - // func (db *DB) MarkPipelineTimeout(rkey string, n *notifier.Notifier) error { 107 - // _, err := db.Exec(` 108 - // update pipeline_status 109 - // set status = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') 110 - // where rkey = ? 111 - // `, PipelineTimeout, rkey) 112 - // if err != nil { 113 - // return err 114 - // } 115 - // n.NotifyAll() 116 - // return nil 117 - // } 118 - // 119 - // func (db *DB) MarkPipelineSuccess(rkey string, n *notifier.Notifier) error { 120 - // _, err := db.Exec(` 121 - // update pipeline_status 122 - // set status = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now'), 123 - // finished_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') 124 - // where rkey = ? 125 - // `, PipelineSuccess, rkey) 126 - // 127 - // if err != nil { 128 - // return err 129 - // } 130 - // n.NotifyAll() 131 - // return nil 132 - // } 133 - // 134 - // func (db *DB) GetPipelineStatus(rkey string) (PipelineStatus, error) { 135 - // var p PipelineStatus 136 - // err := db.QueryRow(` 137 - // select rkey, status, error, exit_code, started_at, updated_at, finished_at 138 - // from pipelines 139 - // where rkey = ? 140 - // `, rkey).Scan(&p.Rkey, &p.Status, &p.Error, &p.ExitCode, &p.StartedAt, &p.UpdatedAt, &p.FinishedAt) 141 - // return p, err 142 - // } 143 - // 144 - // func (db *DB) GetPipelineStatusAsRecords(cursor int64) ([]PipelineStatus, error) { 145 - // whereClause := "" 146 - // args := []any{} 147 - // if cursor != 0 { 148 - // whereClause = "where created_at > ?" 149 - // args = append(args, cursor) 150 - // } 151 - // 152 - // query := fmt.Sprintf(` 153 - // select rkey, status, error, exit_code, created_at, started_at, updated_at, finished_at 154 - // from pipeline_status 155 - // %s 156 - // order by created_at asc 157 - // limit 100 158 - // `, whereClause) 159 - // 160 - // rows, err := db.Query(query, args...) 161 - // if err != nil { 162 - // return nil, err 163 - // } 164 - // defer rows.Close() 165 - // 166 - // var pipelines []PipelineStatus 167 - // for rows.Next() { 168 - // var p PipelineStatus 169 - // var pipelineError sql.NullString 170 - // var exitCode sql.NullInt64 171 - // var startedAt, updatedAt string 172 - // var finishedAt sql.NullTime 173 - // 174 - // err := rows.Scan(&p.Rkey, &p.Status, &pipelineError, &exitCode, &p.LastUpdate, &startedAt, &updatedAt, &finishedAt) 175 - // if err != nil { 176 - // return nil, err 177 - // } 178 - // 179 - // if pipelineError.Valid { 180 - // p.Error = pipelineError.String 181 - // } 182 - // 183 - // if exitCode.Valid { 184 - // p.ExitCode = int(exitCode.Int64) 185 - // } 186 - // 187 - // if v, err := time.Parse(time.RFC3339, startedAt); err == nil { 188 - // p.StartedAt = v 189 - // } 190 - // 191 - // if v, err := time.Parse(time.RFC3339, updatedAt); err == nil { 192 - // p.UpdatedAt = v 193 - // } 194 - // 195 - // if finishedAt.Valid { 196 - // p.FinishedAt = finishedAt.Time 197 - // } 198 - // 199 - // pipelines = append(pipelines, p) 200 - // } 201 - // 202 - // if err := rows.Err(); err != nil { 203 - // return nil, err 204 - // } 205 - // 206 - // return pipelines, nil 207 - // }