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

appview: show banner for users with read-only knots

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

oppi.li f76a9d68 9b88f828

verified
Changed files
+183 -162
appview
db
knots
pages
templates
knots
layouts
spindles
state
xrpcclient
knotserver
+67 -133
appview/db/registration.go
··· 1 1 package db 2 2 3 3 import ( 4 - "crypto/rand" 5 4 "database/sql" 6 - "encoding/hex" 7 5 "fmt" 8 - "log" 9 6 "strings" 10 7 "time" 11 8 ) ··· 18 15 ByDid string 19 16 Created *time.Time 20 17 Registered *time.Time 18 + ReadOnly bool 21 19 } 22 20 23 21 func (r *Registration) Status() Status { 24 - if r.Registered != nil { 22 + if r.ReadOnly { 23 + return ReadOnly 24 + } else if r.Registered != nil { 25 25 return Registered 26 26 } else { 27 27 return Pending 28 28 } 29 29 } 30 30 31 + func (r *Registration) IsRegistered() bool { 32 + return r.Status() == Registered 33 + } 34 + 35 + func (r *Registration) IsReadOnly() bool { 36 + return r.Status() == ReadOnly 37 + } 38 + 39 + func (r *Registration) IsPending() bool { 40 + return r.Status() == Pending 41 + } 42 + 31 43 type Status uint32 32 44 33 45 const ( 34 46 Registered Status = iota 35 47 Pending 48 + ReadOnly 36 49 ) 37 50 38 - // returns registered status, did of owner, error 39 - func RegistrationsByDid(e Execer, did string) ([]Registration, error) { 51 + func GetRegistrations(e Execer, filters ...filter) ([]Registration, error) { 40 52 var registrations []Registration 41 53 42 - rows, err := e.Query(` 43 - select id, domain, did, created, registered from registrations 44 - where did = ? 45 - `, did) 46 - if err != nil { 47 - return nil, err 54 + var conditions []string 55 + var args []any 56 + for _, filter := range filters { 57 + conditions = append(conditions, filter.Condition()) 58 + args = append(args, filter.Arg()...) 48 59 } 49 60 50 - for rows.Next() { 51 - var createdAt *string 52 - var registeredAt *string 53 - var registration Registration 54 - err = rows.Scan(&registration.Id, &registration.Domain, &registration.ByDid, &createdAt, &registeredAt) 55 - 56 - if err != nil { 57 - log.Println(err) 58 - } else { 59 - createdAtTime, _ := time.Parse(time.RFC3339, *createdAt) 60 - var registeredAtTime *time.Time 61 - if registeredAt != nil { 62 - x, _ := time.Parse(time.RFC3339, *registeredAt) 63 - registeredAtTime = &x 64 - } 65 - 66 - registration.Created = &createdAtTime 67 - registration.Registered = registeredAtTime 68 - registrations = append(registrations, registration) 69 - } 61 + whereClause := "" 62 + if conditions != nil { 63 + whereClause = " where " + strings.Join(conditions, " and ") 70 64 } 71 65 72 - return registrations, nil 73 - } 74 - 75 - // returns registered status, did of owner, error 76 - func RegistrationByDomain(e Execer, domain string) (*Registration, error) { 77 - var createdAt *string 78 - var registeredAt *string 79 - var registration Registration 80 - 81 - err := e.QueryRow(` 82 - select id, domain, did, created, registered from registrations 83 - where domain = ? 84 - `, domain).Scan(&registration.Id, &registration.Domain, &registration.ByDid, &createdAt, &registeredAt) 66 + query := fmt.Sprintf(` 67 + select id, domain, did, created, registered, read_only 68 + from registrations 69 + %s 70 + order by created 71 + `, 72 + whereClause, 73 + ) 85 74 75 + rows, err := e.Query(query, args...) 86 76 if err != nil { 87 - if err == sql.ErrNoRows { 88 - return nil, nil 89 - } else { 90 - return nil, err 91 - } 77 + return nil, err 92 78 } 93 79 94 - createdAtTime, _ := time.Parse(time.RFC3339, *createdAt) 95 - var registeredAtTime *time.Time 96 - if registeredAt != nil { 97 - x, _ := time.Parse(time.RFC3339, *registeredAt) 98 - registeredAtTime = &x 99 - } 100 - 101 - registration.Created = &createdAtTime 102 - registration.Registered = registeredAtTime 103 - 104 - return &registration, nil 105 - } 106 - 107 - func genSecret() string { 108 - key := make([]byte, 32) 109 - rand.Read(key) 110 - return hex.EncodeToString(key) 111 - } 80 + for rows.Next() { 81 + var createdAt string 82 + var registeredAt sql.Null[string] 83 + var readOnly int 84 + var reg Registration 112 85 113 - func GenerateRegistrationKey(e Execer, domain, did string) (string, error) { 114 - // sanity check: does this domain already have a registration? 115 - reg, err := RegistrationByDomain(e, domain) 116 - if err != nil { 117 - return "", err 118 - } 119 - 120 - // registration is open 121 - if reg != nil { 122 - switch reg.Status() { 123 - case Registered: 124 - // already registered by `owner` 125 - return "", fmt.Errorf("%s already registered by %s", domain, reg.ByDid) 126 - case Pending: 127 - // TODO: be loud about this 128 - log.Printf("%s registered by %s, status pending", domain, reg.ByDid) 86 + err = rows.Scan(&reg.Id, &reg.Domain, &reg.ByDid, &createdAt, &registeredAt, &readOnly) 87 + if err != nil { 88 + return nil, err 129 89 } 130 - } 131 90 132 - secret := genSecret() 91 + if t, err := time.Parse(time.RFC3339, createdAt); err == nil { 92 + reg.Created = &t 93 + } 133 94 134 - _, err = e.Exec(` 135 - insert into registrations (domain, did, secret) 136 - values (?, ?, ?) 137 - on conflict(domain) do update set did = excluded.did, secret = excluded.secret, created = excluded.created 138 - `, domain, did, secret) 139 - 140 - if err != nil { 141 - return "", err 142 - } 143 - 144 - return secret, nil 145 - } 95 + if registeredAt.Valid { 96 + if t, err := time.Parse(time.RFC3339, registeredAt.V); err == nil { 97 + reg.Registered = &t 98 + } 99 + } 146 100 147 - func GetRegistrationKey(e Execer, domain string) (string, error) { 148 - res := e.QueryRow(`select secret from registrations where domain = ?`, domain) 101 + if readOnly != 0 { 102 + reg.ReadOnly = true 103 + } 149 104 150 - var secret string 151 - err := res.Scan(&secret) 152 - if err != nil || secret == "" { 153 - return "", err 105 + registrations = append(registrations, reg) 154 106 } 155 107 156 - return secret, nil 108 + return registrations, nil 157 109 } 158 110 159 - func GetCompletedRegistrations(e Execer) ([]string, error) { 160 - rows, err := e.Query(`select domain from registrations where registered not null`) 161 - if err != nil { 162 - return nil, err 163 - } 164 - 165 - var domains []string 166 - for rows.Next() { 167 - var domain string 168 - err = rows.Scan(&domain) 169 - 170 - if err != nil { 171 - log.Println(err) 172 - } else { 173 - domains = append(domains, domain) 174 - } 111 + func MarkRegistered(e Execer, filters ...filter) error { 112 + var conditions []string 113 + var args []any 114 + for _, filter := range filters { 115 + conditions = append(conditions, filter.Condition()) 116 + args = append(args, filter.Arg()...) 175 117 } 176 118 177 - if err = rows.Err(); err != nil { 178 - return nil, err 119 + query := "update registrations set registered = strftime('%Y-%m-%dT%H:%M:%SZ', 'now'), read_only = 0" 120 + if len(conditions) > 0 { 121 + query += " where " + strings.Join(conditions, " and ") 179 122 } 180 123 181 - return domains, nil 182 - } 183 - 184 - func Register(e Execer, domain string) error { 185 - _, err := e.Exec(` 186 - update registrations 187 - set registered = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') 188 - where domain = ?; 189 - `, domain) 190 - 124 + _, err := e.Exec(query, args...) 191 125 return err 192 126 } 193 127
+40 -16
appview/knots/knots.go
··· 3 3 import ( 4 4 "errors" 5 5 "fmt" 6 + "log" 6 7 "log/slog" 7 8 "net/http" 8 9 "slices" ··· 49 50 r.With(middleware.AuthMiddleware(k.OAuth)).Post("/{domain}/add", k.addMember) 50 51 r.With(middleware.AuthMiddleware(k.OAuth)).Post("/{domain}/remove", k.removeMember) 51 52 53 + r.With(middleware.AuthMiddleware(k.OAuth)).Get("/upgradeBanner", k.banner) 54 + 52 55 return r 53 56 } 54 57 55 58 func (k *Knots) knots(w http.ResponseWriter, r *http.Request) { 56 59 user := k.OAuth.GetUser(r) 57 - registrations, err := db.RegistrationsByDid(k.Db, user.Did) 60 + registrations, err := db.GetRegistrations( 61 + k.Db, 62 + db.FilterEq("did", user.Did), 63 + ) 58 64 if err != nil { 59 65 k.Logger.Error("failed to fetch knot registrations", "err", err) 60 66 w.WriteHeader(http.StatusInternalServerError) ··· 89 95 http.Error(w, "Not found", http.StatusNotFound) 90 96 return 91 97 } 92 - 93 - // Find the specific registration for this domain 94 - var registration *db.Registration 95 - for _, reg := range registrations { 96 - if reg.Domain == domain && reg.ByDid == user.Did && reg.Registered != nil { 97 - registration = &reg 98 - break 99 - } 100 - } 101 - 102 - if registration == nil { 103 - l.Error("registration not found or not verified") 104 - http.Error(w, "Not found", http.StatusNotFound) 98 + if len(registrations) != 1 { 99 + l.Error("got incorret number of registrations", "got", len(registrations), "expected", 1) 105 100 return 106 101 } 107 102 registration := registrations[0] ··· 518 513 db.FilterIsNot("registered", "null"), 519 514 ) 520 515 if err != nil { 521 - l.Error("failed to retrieve domain registration", "err", err) 522 - http.Error(w, "Not found", http.StatusNotFound) 516 + l.Error("failed to get registration", "err", err) 523 517 return 524 518 } 519 + if len(registrations) != 1 { 520 + l.Error("got incorret number of registrations", "got", len(registrations), "expected", 1) 521 + return 522 + } 523 + registration := registrations[0] 525 524 526 525 noticeId := fmt.Sprintf("add-member-error-%d", registration.Id) 527 526 defaultErr := "Failed to add member. Try again later." ··· 679 678 // ok 680 679 k.Pages.HxRefresh(w) 681 680 } 681 + 682 + func (k *Knots) banner(w http.ResponseWriter, r *http.Request) { 683 + user := k.OAuth.GetUser(r) 684 + l := k.Logger.With("handler", "removeMember") 685 + l = l.With("did", user.Did) 686 + l = l.With("handle", user.Handle) 687 + 688 + registrations, err := db.GetRegistrations( 689 + k.Db, 690 + db.FilterEq("did", user.Did), 691 + db.FilterEq("read_only", 1), 692 + ) 693 + if err != nil { 694 + l.Error("non-fatal: failed to get registrations") 695 + return 696 + } 697 + 698 + if registrations == nil { 699 + return 700 + } 701 + 702 + k.Pages.KnotBanner(w, pages.KnotBannerParams{ 703 + Registrations: registrations, 704 + }) 705 + }
+9 -1
appview/pages/pages.go
··· 338 338 return p.execute("user/settings/emails", w, params) 339 339 } 340 340 341 + type KnotBannerParams struct { 342 + Registrations []db.Registration 343 + } 344 + 345 + func (p *Pages) KnotBanner(w io.Writer, params KnotBannerParams) error { 346 + return p.executePlain("knots/fragments/banner", w, params) 347 + } 348 + 341 349 type KnotsParams struct { 342 350 LoggedInUser *oauth.User 343 351 Registrations []db.Registration ··· 360 368 } 361 369 362 370 type KnotListingParams struct { 363 - db.Registration 371 + *db.Registration 364 372 } 365 373 366 374 func (p *Pages) KnotListing(w io.Writer, params KnotListingParams) error {
+9
appview/pages/templates/knots/fragments/banner.html
··· 1 + {{ define "knots/fragments/banner" }} 2 + <div class="w-full px-6 py-2 -z-15 bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200 border border-yellow-200 dark:border-yellow-800 rounded-b drop-shadow-sm"> 3 + A knot ({{range $i, $r := .Registrations}}{{if ne $i 0}}, {{end}}{{ $r.Domain }}{{ end }}) 4 + that you administer is presently read-only. Consider upgrading this knot to 5 + continue creating repositories on it. 6 + <a href="https://tangled.sh/@tangled.sh/core/tree/master/docs/migrations">Click to read the upgrade guide</a>. 7 + </div> 8 + {{ end }} 9 +
+11 -2
appview/pages/templates/knots/fragments/knotListing.html
··· 30 30 {{ define "knotRightSide" }} 31 31 <div id="right-side" class="flex gap-2"> 32 32 {{ $style := "px-2 py-1 rounded flex items-center flex-shrink-0 gap-2 text-sm" }} 33 - {{ if .Registered }} 34 - <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> 33 + {{ if .IsRegistered }} 34 + <span class="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 {{$style}}"> 35 + {{ i "shield-check" "w-4 h-4" }} verified 36 + </span> 35 37 {{ template "knots/fragments/addMemberModal" . }} 38 + {{ block "knotDeleteButton" . }} {{ end }} 39 + {{ else if .IsReadOnly }} 40 + <span class="bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200 {{$style}}"> 41 + {{ i "shield-alert" "w-4 h-4" }} read-only 42 + </span> 43 + {{ block "knotRetryButton" . }} {{ end }} 44 + {{ block "knotDeleteButton" . }} {{ end }} 36 45 {{ else }} 37 46 <span class="bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200 {{$style}}"> 38 47 {{ i "shield-off" "w-4 h-4" }} unverified
+1 -1
appview/pages/templates/knots/index.html
··· 77 77 </button> 78 78 </div> 79 79 80 - <div id="registration-error" class="error dark:text-red-400"></div> 80 + <div id="register-error" class="error dark:text-red-400"></div> 81 81 </form> 82 82 83 83 </section>
+7
appview/pages/templates/layouts/topbar.html
··· 21 21 </div> 22 22 </div> 23 23 </nav> 24 + {{ if .LoggedInUser }} 25 + <div id="upgrade-banner" 26 + hx-get="/knots/upgradeBanner" 27 + hx-trigger="load" 28 + hx-swap="innerHTML"> 29 + </div> 30 + {{ end }} 24 31 {{ end }} 25 32 26 33 {{ define "newButton" }}
+3 -1
appview/pages/templates/spindles/fragments/spindleListing.html
··· 9 9 {{ if .Verified }} 10 10 <a href="/spindles/{{ .Instance }}" class="hover:no-underline flex items-center gap-2 min-w-0 max-w-[60%]"> 11 11 {{ i "hard-drive" "w-4 h-4" }} 12 - {{ .Instance }} 12 + <span class="hover:underline"> 13 + {{ .Instance }} 14 + </span> 13 15 <span class="text-gray-500"> 14 16 {{ template "repo/fragments/shortTimeAgo" .Created }} 15 17 </span>
+5 -2
appview/state/knotstream.go
··· 24 24 ) 25 25 26 26 func Knotstream(ctx context.Context, c *config.Config, d *db.DB, enforcer *rbac.Enforcer, posthog posthog.Client) (*ec.Consumer, error) { 27 - knots, err := db.GetCompletedRegistrations(d) 27 + knots, err := db.GetRegistrations( 28 + d, 29 + db.FilterIsNot("registered", "null"), 30 + ) 28 31 if err != nil { 29 32 return nil, err 30 33 } 31 34 32 35 srcs := make(map[ec.Source]struct{}) 33 36 for _, k := range knots { 34 - s := ec.NewKnotSource(k) 37 + s := ec.NewKnotSource(k.Domain) 35 38 srcs[s] = struct{}{} 36 39 } 37 40
+3 -3
appview/state/state.go
··· 435 435 Rkey: rkey, 436 436 }, 437 437 ) 438 - if err != nil { 439 - l.Error("xrpc request failed", "err", err) 440 - s.pages.Notice(w, "repo", fmt.Sprintf("Failed to create repository on knot server: %s.", err.Error())) 438 + if err := xrpcclient.HandleXrpcErr(xe); err != nil { 439 + l.Error("xrpc error", "xe", xe) 440 + s.pages.Notice(w, "repo", err.Error()) 441 441 return 442 442 } 443 443
+25
appview/xrpcclient/xrpc.go
··· 3 3 import ( 4 4 "bytes" 5 5 "context" 6 + "errors" 7 + "fmt" 6 8 "io" 9 + "net/http" 7 10 8 11 "github.com/bluesky-social/indigo/api/atproto" 9 12 "github.com/bluesky-social/indigo/xrpc" 13 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 10 14 oauth "tangled.sh/icyphox.sh/atproto-oauth" 11 15 ) 12 16 ··· 102 106 103 107 return &out, nil 104 108 } 109 + 110 + // produces a more manageable error 111 + func HandleXrpcErr(err error) error { 112 + if err == nil { 113 + return nil 114 + } 115 + 116 + var xrpcerr *indigoxrpc.Error 117 + if ok := errors.As(err, &xrpcerr); !ok { 118 + return fmt.Errorf("Recieved invalid XRPC error response.") 119 + } 120 + 121 + switch xrpcerr.StatusCode { 122 + case http.StatusNotFound: 123 + return fmt.Errorf("XRPC is unsupported on this knot, consider upgrading your knot.") 124 + case http.StatusUnauthorized: 125 + return fmt.Errorf("Unauthorized XRPC request.") 126 + default: 127 + return fmt.Errorf("Failed to perform operation. Try again later.") 128 + } 129 + }
+3 -3
knotserver/ingester.go
··· 73 73 } 74 74 l.Info("added member from firehose", "member", record.Subject) 75 75 76 - if err := h.db.AddDid(did); err != nil { 76 + if err := h.db.AddDid(record.Subject); err != nil { 77 77 l.Error("failed to add did", "error", err) 78 78 return fmt.Errorf("failed to add did: %w", err) 79 79 } 80 - h.jc.AddDid(did) 80 + h.jc.AddDid(record.Subject) 81 81 82 - if err := h.fetchAndAddKeys(ctx, did); err != nil { 82 + if err := h.fetchAndAddKeys(ctx, record.Subject); err != nil { 83 83 return fmt.Errorf("failed to fetch and add keys: %w", err) 84 84 } 85 85