diff --git a/appview/db/db.go b/appview/db/db.go index 91421c62..92fbfb69 100644 --- a/appview/db/db.go +++ b/appview/db/db.go @@ -92,6 +92,16 @@ func Make(ctx context.Context, dbPath string) (*DB, error) { primary key (user_did, subject_did), check (user_did <> subject_did) ); + create table if not exists vouches ( + did text not null, + subject_did text not null, + kind text not null default 'vouch', + reason text, + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + primary key (did, subject_did), + check (did <> subject_did), + check (kind in ('vouch', 'denounce')) + ); create table if not exists issues ( id integer primary key autoincrement, owner_did text not null, diff --git a/appview/db/vouch.go b/appview/db/vouch.go new file mode 100644 index 00000000..25d22af1 --- /dev/null +++ b/appview/db/vouch.go @@ -0,0 +1,330 @@ +package db + +import ( + "database/sql" + "fmt" + "log" + "strings" + "time" + + "tangled.org/core/appview/models" + "tangled.org/core/orm" +) + +func AddVouch(e Execer, vouch *models.Vouch) error { + query := `insert or replace into vouches (did, subject_did, kind, reason) values (?, ?, ?, ?)` + _, err := e.Exec(query, vouch.Did, vouch.SubjectDid, vouch.Kind, vouch.Reason) + return err +} + +func GetVouch(e Execer, did, subjectDid string) (*models.Vouch, error) { + vouches, err := GetVouches(e, 0, orm.FilterEq("did", did), orm.FilterEq("subject_did", subjectDid)) + if err != nil { + return nil, err + } + if len(vouches) == 0 { + return nil, sql.ErrNoRows + } + return &vouches[0], nil +} + +func GetVouches(e Execer, limit int, filters ...orm.Filter) ([]models.Vouch, error) { + var vouches []models.Vouch + + var conditions []string + var args []any + for _, filter := range filters { + conditions = append(conditions, filter.Condition()) + args = append(args, filter.Arg()...) + } + + whereClause := "" + if conditions != nil { + whereClause = " where " + strings.Join(conditions, " and ") + } + limitClause := "" + if limit > 0 { + limitClause = " limit ?" + args = append(args, limit) + } + + query := fmt.Sprintf( + `select did, subject_did, kind, reason, created_at + from vouches + %s + order by created_at desc + %s + `, whereClause, limitClause) + + rows, err := e.Query(query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + for rows.Next() { + var vouch models.Vouch + var createdAt string + var reason sql.NullString + err := rows.Scan( + &vouch.Did, + &vouch.SubjectDid, + &vouch.Kind, + &reason, + &createdAt, + ) + if err != nil { + return nil, err + } + createdAtTime, err := time.Parse(time.RFC3339, createdAt) + if err != nil { + log.Println("unable to determine created at time") + vouch.CreatedAt = time.Now() + } else { + vouch.CreatedAt = createdAtTime + } + if reason.Valid { + vouch.Reason = &reason.String + } + vouches = append(vouches, vouch) + } + return vouches, nil +} + +func DeleteVouch(e Execer, did, subjectDid string) error { + _, err := e.Exec(`delete from vouches where did = ? and subject_did = ?`, did, subjectDid) + return err +} + +func DeleteVouchByRkey(e Execer, did, rkey string) error { + _, err := e.Exec(`delete from vouches where did = ? and subject_did = ?`, did, rkey) + return err +} + +func GetVouchStats(e Execer, did string) (models.VouchStats, error) { + var vouches, denounces int64 + err := e.QueryRow( + `SELECT + COUNT(CASE WHEN kind = 'vouch' THEN 1 END) AS vouches, + COUNT(CASE WHEN kind = 'denounce' THEN 1 END) AS denounces + FROM vouches + WHERE subject_did = ?`, did).Scan(&vouches, &denounces) + if err != nil { + return models.VouchStats{}, err + } + return models.VouchStats{ + Vouches: vouches, + Denounces: denounces, + }, nil +} + +func GetVouchStatsBatch(e Execer, dids []string) (map[string]models.VouchStats, error) { + if len(dids) == 0 { + return nil, nil + } + + placeholders := make([]string, len(dids)) + args := make([]any, len(dids)) + for i, did := range dids { + placeholders[i] = "?" + args[i] = did + } + placeholderStr := strings.Join(placeholders, ",") + + query := fmt.Sprintf(` + select + subject_did, + count(case when kind = 'vouch' then 1 end) as vouches, + count(case when kind = 'denounce' then 1 end) as denounces + from vouches + where subject_did in (%s) + group by subject_did + `, placeholderStr) + + result := make(map[string]models.VouchStats) + + rows, err := e.Query(query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + for rows.Next() { + var did string + var vouches, denounces int64 + if err := rows.Scan(&did, &vouches, &denounces); err != nil { + return nil, err + } + result[did] = models.VouchStats{ + Vouches: vouches, + Denounces: denounces, + } + } + + for _, did := range dids { + if _, exists := result[did]; !exists { + result[did] = models.VouchStats{ + Vouches: 0, + Denounces: 0, + } + } + } + + return result, nil +} + +func GetVouchesGiven(e Execer, did string) ([]models.Vouch, error) { + return GetVouches(e, 0, orm.FilterEq("did", did)) +} + +func GetVouchesReceived(e Execer, did string) ([]models.Vouch, error) { + return GetVouches(e, 0, orm.FilterEq("subject_did", did)) +} + +// GetNetworkVouchesForSubject returns vouches for subjectDid from people that viewerDid follows or vouches for +func GetNetworkVouchesForSubject(e Execer, viewerDid, subjectDid string, limit int) ([]models.Vouch, error) { + query := ` + select distinct v.did, v.subject_did, v.kind, v.reason, v.created_at + from vouches v + where v.subject_did = ? + and v.kind = 'vouch' + and v.did in ( + select subject_did from follows where user_did = ? + union + select subject_did from vouches where did = ? and kind = 'vouch' + ) + order by v.created_at desc + ` + args := []any{subjectDid, viewerDid, viewerDid} + + if limit > 0 { + query += " limit ?" + args = append(args, limit) + } + + rows, err := e.Query(query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var vouches []models.Vouch + for rows.Next() { + var vouch models.Vouch + var createdAt string + var reason sql.NullString + err := rows.Scan( + &vouch.Did, + &vouch.SubjectDid, + &vouch.Kind, + &reason, + &createdAt, + ) + if err != nil { + return nil, err + } + createdAtTime, err := time.Parse(time.RFC3339, createdAt) + if err != nil { + log.Println("unable to determine created at time") + vouch.CreatedAt = time.Now() + } else { + vouch.CreatedAt = createdAtTime + } + if reason.Valid { + vouch.Reason = &reason.String + } + vouches = append(vouches, vouch) + } + return vouches, nil +} + +// GetNetworkDenouncesForSubject returns denounces for subjectDid from people that viewerDid follows or vouches for +func GetNetworkDenouncesForSubject(e Execer, viewerDid, subjectDid string, limit int) ([]models.Vouch, error) { + query := ` + select distinct v.did, v.subject_did, v.kind, v.reason, v.created_at + from vouches v + where v.subject_did = ? + and v.kind = 'denounce' + and v.did in ( + select subject_did from follows where user_did = ? + union + select subject_did from vouches where did = ? and kind = 'vouch' + ) + order by v.created_at desc + ` + args := []any{subjectDid, viewerDid, viewerDid} + + if limit > 0 { + query += " limit ?" + args = append(args, limit) + } + + rows, err := e.Query(query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var vouches []models.Vouch + for rows.Next() { + var vouch models.Vouch + var createdAt string + var reason sql.NullString + err := rows.Scan( + &vouch.Did, + &vouch.SubjectDid, + &vouch.Kind, + &reason, + &createdAt, + ) + if err != nil { + return nil, err + } + createdAtTime, err := time.Parse(time.RFC3339, createdAt) + if err != nil { + log.Println("unable to determine created at time") + vouch.CreatedAt = time.Now() + } else { + vouch.CreatedAt = createdAtTime + } + if reason.Valid { + vouch.Reason = &reason.String + } + vouches = append(vouches, vouch) + } + return vouches, nil +} + +// CountNetworkVouchesForSubject returns count of vouches for subjectDid from viewerDid's network +func CountNetworkVouchesForSubject(e Execer, viewerDid, subjectDid string) (int64, error) { + var count int64 + err := e.QueryRow(` + select count(distinct v.did) + from vouches v + where v.subject_did = ? + and v.kind = 'vouch' + and v.did in ( + select subject_did from follows where user_did = ? + union + select subject_did from vouches where did = ? and kind = 'vouch' + ) + `, subjectDid, viewerDid, viewerDid).Scan(&count) + return count, err +} + +// CountNetworkDenouncesForSubject returns count of denounces for subjectDid from viewerDid's network +func CountNetworkDenouncesForSubject(e Execer, viewerDid, subjectDid string) (int64, error) { + var count int64 + err := e.QueryRow(` + select count(distinct v.did) + from vouches v + where v.subject_did = ? + and v.kind = 'denounce' + and v.did in ( + select subject_did from follows where user_did = ? + union + select subject_did from vouches where did = ? and kind = 'vouch' + ) + `, subjectDid, viewerDid, viewerDid).Scan(&count) + return count, err +} diff --git a/appview/ingester.go b/appview/ingester.go index d77e0564..8a2c96e9 100644 --- a/appview/ingester.go +++ b/appview/ingester.go @@ -65,6 +65,8 @@ func (i *Ingester) Ingest() processFunc { switch e.Commit.Collection { case tangled.GraphFollowNSID: err = i.ingestFollow(e) + case tangled.GraphVouchNSID: + err = i.ingestVouch(ctx, e) case tangled.FeedStarNSID: err = i.ingestStar(e) case tangled.PublicKeyNSID: @@ -203,6 +205,74 @@ func (i *Ingester) ingestFollow(e *jmodels.Event) error { return nil } +func (i *Ingester) ingestVouch(ctx context.Context, e *jmodels.Event) error { + var err error + did := e.Did + + l := i.Logger.With("handler", "ingestVouch") + l = l.With("nsid", e.Commit.Collection) + + switch e.Commit.Operation { + case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate: + raw := json.RawMessage(e.Commit.Record) + record := tangled.GraphVouch{} + err = json.Unmarshal(raw, &record) + if err != nil { + l.Error("invalid record", "err", err) + return err + } + + // rkey is the subject_did being vouched for/denounced + subjectDID := e.Commit.RKey + + _, err = syntax.ParseDID(subjectDID) + if err != nil { + l.Error("invalid subject_did in rkey", "err", err, "rkey", subjectDID) + return fmt.Errorf("invalid subject_did: %w", err) + } + + if did == subjectDID { + l.Warn("attempted self-vouch", "did", did) + return fmt.Errorf("cannot vouch for self") + } + + subjectId, err := i.IdResolver.ResolveIdent(ctx, subjectDID) + if err != nil { + return err + } + + if subjectId.Handle.IsInvalidHandle() { + return err + } + + kind := "vouch" + if record.Kind != nil { + kind = *record.Kind + } + + if kind != "vouch" && kind != "denounce" { + l.Error("invalid kind", "kind", kind) + return fmt.Errorf("invalid kind: %s", kind) + } + + err = db.AddVouch(i.Db, &models.Vouch{ + Did: did, + SubjectDid: subjectDID, + Kind: kind, + Reason: record.Reason, + }) + + case jmodels.CommitOperationDelete: + err = db.DeleteVouchByRkey(i.Db, did, e.Commit.RKey) + } + + if err != nil { + return fmt.Errorf("failed to %s vouch record: %w", e.Commit.Operation, err) + } + + return nil +} + func (i *Ingester) ingestPublicKey(e *jmodels.Event) error { did := e.Did var err error diff --git a/appview/models/vouch.go b/appview/models/vouch.go new file mode 100644 index 00000000..1549217a --- /dev/null +++ b/appview/models/vouch.go @@ -0,0 +1,16 @@ +package models + +import "time" + +type Vouch struct { + Did string + SubjectDid string + Kind string + Reason *string + CreatedAt time.Time +} + +type VouchStats struct { + Vouches int64 + Denounces int64 +}