forked from tangled.org/core
Monorepo for Tangled — https://tangled.org

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
···
package db
import (
-
"crypto/rand"
"database/sql"
-
"encoding/hex"
"fmt"
-
"log"
"strings"
"time"
)
···
ByDid string
Created *time.Time
Registered *time.Time
+
ReadOnly bool
}
func (r *Registration) Status() Status {
-
if r.Registered != nil {
+
if r.ReadOnly {
+
return ReadOnly
+
} else if r.Registered != nil {
return Registered
} else {
return Pending
}
}
+
func (r *Registration) IsRegistered() bool {
+
return r.Status() == Registered
+
}
+
+
func (r *Registration) IsReadOnly() bool {
+
return r.Status() == ReadOnly
+
}
+
+
func (r *Registration) IsPending() bool {
+
return r.Status() == Pending
+
}
+
type Status uint32
const (
Registered Status = iota
Pending
+
ReadOnly
)
-
// returns registered status, did of owner, error
-
func RegistrationsByDid(e Execer, did string) ([]Registration, error) {
+
func GetRegistrations(e Execer, filters ...filter) ([]Registration, error) {
var registrations []Registration
-
rows, err := e.Query(`
-
select id, domain, did, created, registered from registrations
-
where did = ?
-
`, did)
-
if err != nil {
-
return nil, err
+
var conditions []string
+
var args []any
+
for _, filter := range filters {
+
conditions = append(conditions, filter.Condition())
+
args = append(args, filter.Arg()...)
}
-
for rows.Next() {
-
var createdAt *string
-
var registeredAt *string
-
var registration Registration
-
err = rows.Scan(&registration.Id, &registration.Domain, &registration.ByDid, &createdAt, &registeredAt)
-
-
if err != nil {
-
log.Println(err)
-
} else {
-
createdAtTime, _ := time.Parse(time.RFC3339, *createdAt)
-
var registeredAtTime *time.Time
-
if registeredAt != nil {
-
x, _ := time.Parse(time.RFC3339, *registeredAt)
-
registeredAtTime = &x
-
}
-
-
registration.Created = &createdAtTime
-
registration.Registered = registeredAtTime
-
registrations = append(registrations, registration)
-
}
+
whereClause := ""
+
if conditions != nil {
+
whereClause = " where " + strings.Join(conditions, " and ")
}
-
return registrations, nil
-
}
-
-
// returns registered status, did of owner, error
-
func RegistrationByDomain(e Execer, domain string) (*Registration, error) {
-
var createdAt *string
-
var registeredAt *string
-
var registration Registration
-
-
err := e.QueryRow(`
-
select id, domain, did, created, registered from registrations
-
where domain = ?
-
`, domain).Scan(&registration.Id, &registration.Domain, &registration.ByDid, &createdAt, &registeredAt)
+
query := fmt.Sprintf(`
+
select id, domain, did, created, registered, read_only
+
from registrations
+
%s
+
order by created
+
`,
+
whereClause,
+
)
+
rows, err := e.Query(query, args...)
if err != nil {
-
if err == sql.ErrNoRows {
-
return nil, nil
-
} else {
-
return nil, err
-
}
+
return nil, err
}
-
createdAtTime, _ := time.Parse(time.RFC3339, *createdAt)
-
var registeredAtTime *time.Time
-
if registeredAt != nil {
-
x, _ := time.Parse(time.RFC3339, *registeredAt)
-
registeredAtTime = &x
-
}
-
-
registration.Created = &createdAtTime
-
registration.Registered = registeredAtTime
-
-
return &registration, nil
-
}
-
-
func genSecret() string {
-
key := make([]byte, 32)
-
rand.Read(key)
-
return hex.EncodeToString(key)
-
}
+
for rows.Next() {
+
var createdAt string
+
var registeredAt sql.Null[string]
+
var readOnly int
+
var reg Registration
-
func GenerateRegistrationKey(e Execer, domain, did string) (string, error) {
-
// sanity check: does this domain already have a registration?
-
reg, err := RegistrationByDomain(e, domain)
-
if err != nil {
-
return "", err
-
}
-
-
// registration is open
-
if reg != nil {
-
switch reg.Status() {
-
case Registered:
-
// already registered by `owner`
-
return "", fmt.Errorf("%s already registered by %s", domain, reg.ByDid)
-
case Pending:
-
// TODO: be loud about this
-
log.Printf("%s registered by %s, status pending", domain, reg.ByDid)
+
err = rows.Scan(&reg.Id, &reg.Domain, &reg.ByDid, &createdAt, &registeredAt, &readOnly)
+
if err != nil {
+
return nil, err
}
-
}
-
secret := genSecret()
+
if t, err := time.Parse(time.RFC3339, createdAt); err == nil {
+
reg.Created = &t
+
}
-
_, err = e.Exec(`
-
insert into registrations (domain, did, secret)
-
values (?, ?, ?)
-
on conflict(domain) do update set did = excluded.did, secret = excluded.secret, created = excluded.created
-
`, domain, did, secret)
-
-
if err != nil {
-
return "", err
-
}
-
-
return secret, nil
-
}
+
if registeredAt.Valid {
+
if t, err := time.Parse(time.RFC3339, registeredAt.V); err == nil {
+
reg.Registered = &t
+
}
+
}
-
func GetRegistrationKey(e Execer, domain string) (string, error) {
-
res := e.QueryRow(`select secret from registrations where domain = ?`, domain)
+
if readOnly != 0 {
+
reg.ReadOnly = true
+
}
-
var secret string
-
err := res.Scan(&secret)
-
if err != nil || secret == "" {
-
return "", err
+
registrations = append(registrations, reg)
}
-
return secret, nil
+
return registrations, nil
}
-
func GetCompletedRegistrations(e Execer) ([]string, error) {
-
rows, err := e.Query(`select domain from registrations where registered not null`)
-
if err != nil {
-
return nil, err
-
}
-
-
var domains []string
-
for rows.Next() {
-
var domain string
-
err = rows.Scan(&domain)
-
-
if err != nil {
-
log.Println(err)
-
} else {
-
domains = append(domains, domain)
-
}
+
func MarkRegistered(e Execer, filters ...filter) error {
+
var conditions []string
+
var args []any
+
for _, filter := range filters {
+
conditions = append(conditions, filter.Condition())
+
args = append(args, filter.Arg()...)
}
-
if err = rows.Err(); err != nil {
-
return nil, err
+
query := "update registrations set registered = strftime('%Y-%m-%dT%H:%M:%SZ', 'now'), read_only = 0"
+
if len(conditions) > 0 {
+
query += " where " + strings.Join(conditions, " and ")
}
-
return domains, nil
-
}
-
-
func Register(e Execer, domain string) error {
-
_, err := e.Exec(`
-
update registrations
-
set registered = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
-
where domain = ?;
-
`, domain)
-
+
_, err := e.Exec(query, args...)
return err
}
+40 -16
appview/knots/knots.go
···
import (
"errors"
"fmt"
+
"log"
"log/slog"
"net/http"
"slices"
···
r.With(middleware.AuthMiddleware(k.OAuth)).Post("/{domain}/add", k.addMember)
r.With(middleware.AuthMiddleware(k.OAuth)).Post("/{domain}/remove", k.removeMember)
+
r.With(middleware.AuthMiddleware(k.OAuth)).Get("/upgradeBanner", k.banner)
+
return r
}
func (k *Knots) knots(w http.ResponseWriter, r *http.Request) {
user := k.OAuth.GetUser(r)
-
registrations, err := db.RegistrationsByDid(k.Db, user.Did)
+
registrations, err := db.GetRegistrations(
+
k.Db,
+
db.FilterEq("did", user.Did),
+
)
if err != nil {
k.Logger.Error("failed to fetch knot registrations", "err", err)
w.WriteHeader(http.StatusInternalServerError)
···
http.Error(w, "Not found", http.StatusNotFound)
return
}
-
-
// Find the specific registration for this domain
-
var registration *db.Registration
-
for _, reg := range registrations {
-
if reg.Domain == domain && reg.ByDid == user.Did && reg.Registered != nil {
-
registration = &reg
-
break
-
}
-
}
-
-
if registration == nil {
-
l.Error("registration not found or not verified")
-
http.Error(w, "Not found", http.StatusNotFound)
+
if len(registrations) != 1 {
+
l.Error("got incorret number of registrations", "got", len(registrations), "expected", 1)
return
}
registration := registrations[0]
···
db.FilterIsNot("registered", "null"),
)
if err != nil {
-
l.Error("failed to retrieve domain registration", "err", err)
-
http.Error(w, "Not found", http.StatusNotFound)
+
l.Error("failed to get registration", "err", err)
return
}
+
if len(registrations) != 1 {
+
l.Error("got incorret number of registrations", "got", len(registrations), "expected", 1)
+
return
+
}
+
registration := registrations[0]
noticeId := fmt.Sprintf("add-member-error-%d", registration.Id)
defaultErr := "Failed to add member. Try again later."
···
// ok
k.Pages.HxRefresh(w)
}
+
+
func (k *Knots) banner(w http.ResponseWriter, r *http.Request) {
+
user := k.OAuth.GetUser(r)
+
l := k.Logger.With("handler", "removeMember")
+
l = l.With("did", user.Did)
+
l = l.With("handle", user.Handle)
+
+
registrations, err := db.GetRegistrations(
+
k.Db,
+
db.FilterEq("did", user.Did),
+
db.FilterEq("read_only", 1),
+
)
+
if err != nil {
+
l.Error("non-fatal: failed to get registrations")
+
return
+
}
+
+
if registrations == nil {
+
return
+
}
+
+
k.Pages.KnotBanner(w, pages.KnotBannerParams{
+
Registrations: registrations,
+
})
+
}
+9 -1
appview/pages/pages.go
···
return p.execute("user/settings/emails", w, params)
}
+
type KnotBannerParams struct {
+
Registrations []db.Registration
+
}
+
+
func (p *Pages) KnotBanner(w io.Writer, params KnotBannerParams) error {
+
return p.executePlain("knots/fragments/banner", w, params)
+
}
+
type KnotsParams struct {
LoggedInUser *oauth.User
Registrations []db.Registration
···
}
type KnotListingParams struct {
-
db.Registration
+
*db.Registration
}
func (p *Pages) KnotListing(w io.Writer, params KnotListingParams) error {
+9
appview/pages/templates/knots/fragments/banner.html
···
+
{{ define "knots/fragments/banner" }}
+
<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">
+
A knot ({{range $i, $r := .Registrations}}{{if ne $i 0}}, {{end}}{{ $r.Domain }}{{ end }})
+
that you administer is presently read-only. Consider upgrading this knot to
+
continue creating repositories on it.
+
<a href="https://tangled.sh/@tangled.sh/core/tree/master/docs/migrations">Click to read the upgrade guide</a>.
+
</div>
+
{{ end }}
+
+11 -2
appview/pages/templates/knots/fragments/knotListing.html
···
{{ define "knotRightSide" }}
<div id="right-side" class="flex gap-2">
{{ $style := "px-2 py-1 rounded flex items-center flex-shrink-0 gap-2 text-sm" }}
-
{{ if .Registered }}
-
<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>
+
{{ if .IsRegistered }}
+
<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>
{{ template "knots/fragments/addMemberModal" . }}
+
{{ block "knotDeleteButton" . }} {{ end }}
+
{{ else if .IsReadOnly }}
+
<span class="bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200 {{$style}}">
+
{{ i "shield-alert" "w-4 h-4" }} read-only
+
</span>
+
{{ block "knotRetryButton" . }} {{ end }}
+
{{ block "knotDeleteButton" . }} {{ end }}
{{ else }}
<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
+1 -1
appview/pages/templates/knots/index.html
···
</button>
</div>
-
<div id="registration-error" class="error dark:text-red-400"></div>
+
<div id="register-error" class="error dark:text-red-400"></div>
</form>
</section>
+7
appview/pages/templates/layouts/topbar.html
···
</div>
</div>
</nav>
+
{{ if .LoggedInUser }}
+
<div id="upgrade-banner"
+
hx-get="/knots/upgradeBanner"
+
hx-trigger="load"
+
hx-swap="innerHTML">
+
</div>
+
{{ end }}
{{ end }}
{{ define "newButton" }}
+3 -1
appview/pages/templates/spindles/fragments/spindleListing.html
···
{{ if .Verified }}
<a href="/spindles/{{ .Instance }}" class="hover:no-underline flex items-center gap-2 min-w-0 max-w-[60%]">
{{ i "hard-drive" "w-4 h-4" }}
-
{{ .Instance }}
+
<span class="hover:underline">
+
{{ .Instance }}
+
</span>
<span class="text-gray-500">
{{ template "repo/fragments/shortTimeAgo" .Created }}
</span>
+5 -2
appview/state/knotstream.go
···
)
func Knotstream(ctx context.Context, c *config.Config, d *db.DB, enforcer *rbac.Enforcer, posthog posthog.Client) (*ec.Consumer, error) {
-
knots, err := db.GetCompletedRegistrations(d)
+
knots, err := db.GetRegistrations(
+
d,
+
db.FilterIsNot("registered", "null"),
+
)
if err != nil {
return nil, err
}
srcs := make(map[ec.Source]struct{})
for _, k := range knots {
-
s := ec.NewKnotSource(k)
+
s := ec.NewKnotSource(k.Domain)
srcs[s] = struct{}{}
}
+3 -3
appview/state/state.go
···
Rkey: rkey,
},
)
-
if err != nil {
-
l.Error("xrpc request failed", "err", err)
-
s.pages.Notice(w, "repo", fmt.Sprintf("Failed to create repository on knot server: %s.", err.Error()))
+
if err := xrpcclient.HandleXrpcErr(xe); err != nil {
+
l.Error("xrpc error", "xe", xe)
+
s.pages.Notice(w, "repo", err.Error())
return
}
+25
appview/xrpcclient/xrpc.go
···
import (
"bytes"
"context"
+
"errors"
+
"fmt"
"io"
+
"net/http"
"github.com/bluesky-social/indigo/api/atproto"
"github.com/bluesky-social/indigo/xrpc"
+
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
oauth "tangled.sh/icyphox.sh/atproto-oauth"
)
···
return &out, nil
}
+
+
// produces a more manageable error
+
func HandleXrpcErr(err error) error {
+
if err == nil {
+
return nil
+
}
+
+
var xrpcerr *indigoxrpc.Error
+
if ok := errors.As(err, &xrpcerr); !ok {
+
return fmt.Errorf("Recieved invalid XRPC error response.")
+
}
+
+
switch xrpcerr.StatusCode {
+
case http.StatusNotFound:
+
return fmt.Errorf("XRPC is unsupported on this knot, consider upgrading your knot.")
+
case http.StatusUnauthorized:
+
return fmt.Errorf("Unauthorized XRPC request.")
+
default:
+
return fmt.Errorf("Failed to perform operation. Try again later.")
+
}
+
}
+3 -3
knotserver/ingester.go
···
}
l.Info("added member from firehose", "member", record.Subject)
-
if err := h.db.AddDid(did); err != nil {
+
if err := h.db.AddDid(record.Subject); err != nil {
l.Error("failed to add did", "error", err)
return fmt.Errorf("failed to add did: %w", err)
}
-
h.jc.AddDid(did)
+
h.jc.AddDid(record.Subject)
-
if err := h.fetchAndAddKeys(ctx, did); err != nil {
+
if err := h.fetchAndAddKeys(ctx, record.Subject); err != nil {
return fmt.Errorf("failed to fetch and add keys: %w", err)
}