diff --git a/appview/db/db.go b/appview/db/db.go index 58ba0428..c999b280 100644 --- a/appview/db/db.go +++ b/appview/db/db.go @@ -92,6 +92,17 @@ 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, + cid 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..349644fb --- /dev/null +++ b/appview/db/vouch.go @@ -0,0 +1,360 @@ +package db + +import ( + "database/sql" + "fmt" + "log" + "strings" + "time" + + "github.com/bluesky-social/indigo/atproto/syntax" + "github.com/ipfs/go-cid" + "tangled.org/core/appview/models" + "tangled.org/core/appview/pagination" + "tangled.org/core/orm" +) + +func AddVouch(e Execer, vouch *models.Vouch) error { + query := `insert or replace into vouches (did, subject_did, cid, kind, reason) values (?, ?, ?, ?, ?)` + _, err := e.Exec(query, vouch.Did, vouch.SubjectDid, vouch.Cid.String(), vouch.Kind, vouch.Reason) + return err +} + +func GetVouch(e Execer, did, subjectDid string) (*models.Vouch, error) { + vouches, err := GetVouches(e, pagination.Page{Limit: 1}, + 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, page pagination.Page, filters ...orm.Filter) ([]models.Vouch, error) { + var conditions []string + var args []any + for _, filter := range filters { + conditions = append(conditions, filter.Condition()) + args = append(args, filter.Arg()...) + } + + whereClause := "" + if len(conditions) > 0 { + whereClause = "where " + strings.Join(conditions, " and ") + } + + pageClause := "" + if page.Limit > 0 { + pageClause = fmt.Sprintf("limit %d offset %d", page.Limit, page.Offset) + } + + query := fmt.Sprintf( + `select did, subject_did, cid, kind, reason, created_at + from vouches + %s + order by created_at desc + %s`, + whereClause, pageClause) + + rows, err := e.Query(query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var vouches []models.Vouch + for rows.Next() { + var v models.Vouch + var cidStr string + var createdAt string + var reason sql.NullString + + if err := rows.Scan(&v.Did, &v.SubjectDid, &cidStr, &v.Kind, &reason, &createdAt); err != nil { + log.Println("error scanning vouch:", err) + continue + } + + v.Cid, err = cid.Parse(cidStr) + if err != nil { + log.Println("unable to parse CID:", err) + continue + } + + t, err := time.Parse(time.RFC3339, createdAt) + if err != nil { + log.Println("unable to determine created at time") + v.CreatedAt = time.Now() + } else { + v.CreatedAt = t + } + + if reason.Valid { + v.Reason = &reason.String + } + + vouches = append(vouches, v) + } + 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 GetNetworkVouchTimeline(e Execer, viewerDid, profileDid string, page pagination.Page) ([]models.Vouch, error) { + pageClause := "" + if page.Limit > 0 { + pageClause = fmt.Sprintf("limit %d offset %d", page.Limit, page.Offset) + } + + query := fmt.Sprintf( + `select did, subject_did, cid, kind, reason, created_at + from vouches + where ( + subject_did = ? and did in (select subject_did from vouches where did = ? and kind = 'vouch') + ) or ( + did = ? and subject_did in (select subject_did from vouches where did = ? and kind = 'vouch') + ) + order by created_at desc + %s`, + pageClause) + + rows, err := e.Query(query, profileDid, viewerDid, profileDid, viewerDid) + if err != nil { + return nil, err + } + defer rows.Close() + + var vouches []models.Vouch + for rows.Next() { + var v models.Vouch + var cidStr string + var createdAt string + var reason sql.NullString + + if err := rows.Scan(&v.Did, &v.SubjectDid, &cidStr, &v.Kind, &reason, &createdAt); err != nil { + log.Println("error scanning vouch:", err) + continue + } + + v.Cid, err = cid.Parse(cidStr) + if err != nil { + log.Println("unable to parse CID:", err) + continue + } + + t, err := time.Parse(time.RFC3339, createdAt) + if err != nil { + log.Println("unable to determine created at time") + v.CreatedAt = time.Now() + } else { + v.CreatedAt = t + } + + if reason.Valid { + v.Reason = &reason.String + } + + vouches = append(vouches, v) + } + return vouches, nil +} + +func GetVouchRelationshipsBatch(e Execer, viewerDid syntax.DID, subjectDids []syntax.DID) (map[syntax.DID]*models.VouchRelationship, error) { + if viewerDid == "" { + return nil, fmt.Errorf("viewerDid cannot be empty") + } + + result := make(map[syntax.DID]*models.VouchRelationship) + for _, subjectDid := range subjectDids { + result[subjectDid] = &models.VouchRelationship{ + ViewerDid: viewerDid, + SubjectDid: subjectDid, + NetworkVouches: []models.Vouch{}, + } + } + + if len(subjectDids) == 0 { + return result, nil + } + + directVouches, err := GetVouches(e, pagination.Page{}, + orm.FilterEq("did", viewerDid), + orm.FilterIn("subject_did", subjectDids), + ) + if err != nil { + return nil, err + } + for _, v := range directVouches { + if rel, ok := result[v.SubjectDid]; ok { + rel.NetworkVouches = append(rel.NetworkVouches, v) + } + } + + networkVouches, err := GetVouches(e, pagination.Page{}, + orm.FilterEq("did", viewerDid), + orm.FilterEq("kind", string(models.VouchKindVouch)), + ) + if err != nil { + return nil, err + } + + network := make([]syntax.DID, 0, len(networkVouches)) + for _, v := range networkVouches { + network = append(network, v.SubjectDid) + } + + if len(network) > 0 { + networkToSubject, err := GetVouches(e, pagination.Page{}, + orm.FilterIn("subject_did", subjectDids), + orm.FilterIn("did", network), + ) + if err != nil { + return nil, err + } + for _, v := range networkToSubject { + if rel, ok := result[v.SubjectDid]; ok { + rel.NetworkVouches = append(rel.NetworkVouches, v) + } + } + } + + return result, nil +} + +func GetVouchRelationship(e Execer, viewerDid, subjectDid syntax.DID) (*models.VouchRelationship, error) { + batch, err := GetVouchRelationshipsBatch(e, viewerDid, []syntax.DID{subjectDid}) + if err != nil { + return nil, err + } + return batch[subjectDid], nil +} + +// priority: +// 1. collaborator invites sent +// 2. knot member invites sent +// 3. PR authors on FOO's repositories +// 4. issue authors on FOO's repositories +// 5. PR comment authors on FOO's repositories +// 6. issue comment authors on FOO's repositories +// 7. users FOO recently followed +// 8. owners of repositories FOO recently starred +func GetVouchSuggestions(e Execer, did string, limit int) ([]models.VouchSuggestion, error) { + query := ` + select did, reason from ( + select subject_did as did, 1 as priority, created, + 'You invited this user to collaborate on a repository' as reason + from collaborators + where collaborators.did = ? + and subject_did != ? + + union all + + select subject as did, 2 as priority, created, + 'You invited this user to your knot' as reason + from spindle_members + where spindle_members.did = ? + and subject != ? + + union all + + select p.owner_did as did, 3 as priority, p.created, + 'This user opened a pull request on your repository' as reason + from pulls p + join repos r on r.at_uri = p.repo_at + where r.did = ? + and p.owner_did != ? + + union all + + select i.did as did, 4 as priority, i.created, + 'This user opened an issue on your repository' as reason + from issues i + join repos r on r.at_uri = i.repo_at + where r.did = ? + and i.did != ? + + union all + + select pc.owner_did as did, 5 as priority, pc.created, + 'This user commented on a pull request on your repository' as reason + from pull_comments pc + join repos r on r.at_uri = pc.repo_at + where r.did = ? + and pc.owner_did != ? + + union all + + select ic.did as did, 6 as priority, ic.created, + 'This user commented on an issue on your repository' as reason + from issue_comments ic + join issues i on i.at_uri = ic.issue_at + join repos r on r.at_uri = i.repo_at + where r.did = ? + and ic.did != ? + + union all + + select f.subject_did as did, 7 as priority, f.followed_at as created, + 'You recently followed this user' as reason + from follows f + where f.user_did = ? + and f.subject_did != ? + + union all + + select r.did as did, 8 as priority, s.created, + 'You recently starred a repository by this user' as reason + from stars s + join repos r on r.at_uri = s.subject_at + where s.did = ? + and r.did != ? + ) + where did not in ( + select subject_did from vouches where vouches.did = ? + ) + group by did + order by min(priority) asc, max(created) desc + limit ? + ` + + args := []any{ + did, did, // collaborators + did, did, // spindle_members + did, did, // pulls + did, did, // issues + did, did, // pull_comments + did, did, // issue_comments + did, did, // follows + did, did, // stars + did, // vouches exclusion + limit, + } + + rows, err := e.Query(query, args...) + if err != nil { + return nil, fmt.Errorf("GetVouchSuggestions: %w", err) + } + defer rows.Close() + + var suggestions []models.VouchSuggestion + for rows.Next() { + var s models.VouchSuggestion + if err := rows.Scan(&s.Did, &s.Reason); err != nil { + log.Println("error scanning vouch suggestion:", err) + continue + } + suggestions = append(suggestions, s) + } + return suggestions, nil +} diff --git a/appview/models/vouch.go b/appview/models/vouch.go new file mode 100644 index 00000000..1ce9d3a9 --- /dev/null +++ b/appview/models/vouch.go @@ -0,0 +1,123 @@ +package models + +import ( + "fmt" + "time" + + "github.com/bluesky-social/indigo/atproto/syntax" + "github.com/ipfs/go-cid" +) + +type VouchSuggestion struct { + Did syntax.DID + Reason string + VouchRelationship *VouchRelationship +} + +type VouchKind string + +const ( + VouchKindVouch VouchKind = "vouch" + VouchKindDenounce VouchKind = "denounce" +) + +func ParseVouchKind(v string) (VouchKind, error) { + switch v { + case "vouch": + return VouchKindVouch, nil + case "denounce": + return VouchKindDenounce, nil + default: + return VouchKindVouch, fmt.Errorf("invalid vouch kind: %s", v) + } +} + +type Vouch struct { + Did syntax.DID + SubjectDid syntax.DID + Cid cid.Cid + Kind VouchKind + Reason *string + CreatedAt time.Time +} + +func (v Vouch) IsVouch() bool { + return v.Kind == VouchKindVouch +} + +func (v Vouch) IsDenounce() bool { + return v.Kind == VouchKindDenounce +} + +type VouchStats struct { + Vouches int64 + Denounces int64 +} + +type VouchRelationship struct { + ViewerDid syntax.DID + SubjectDid syntax.DID + + NetworkVouches []Vouch +} + +func (vr *VouchRelationship) IsDirectVouch() bool { + for _, v := range vr.NetworkVouches { + if v.Did == vr.ViewerDid && v.SubjectDid == vr.SubjectDid && v.Kind == VouchKindVouch { + return true + } + } + return false +} + +func (vr *VouchRelationship) IsDirectDenounce() bool { + for _, v := range vr.NetworkVouches { + if v.Did == vr.ViewerDid && v.SubjectDid == vr.SubjectDid && v.Kind == VouchKindDenounce { + return true + } + } + return false +} + +func (vr *VouchRelationship) IndirectVouches() []Vouch { + var indirectVouches []Vouch + for _, v := range vr.NetworkVouches { + if v.Did != vr.ViewerDid { + indirectVouches = append(indirectVouches, v) + } + } + return indirectVouches +} + +func (vr *VouchRelationship) IsEmpty() bool { + return len(vr.NetworkVouches) == 0 +} + +func (vr *VouchRelationship) GetDirectVouch() *Vouch { + for _, v := range vr.NetworkVouches { + if v.Did == vr.ViewerDid && v.SubjectDid == vr.SubjectDid { + return &v + } + } + return nil +} + +func (vr *VouchRelationship) VouchStrength() int { + count := 0 + for _, v := range vr.NetworkVouches { + if v.Did != vr.ViewerDid && v.Kind == VouchKindVouch { + count++ + } + } + return count +} + +func (vr *VouchRelationship) DenounceStrength() int { + count := 0 + for _, v := range vr.NetworkVouches { + if v.Did != vr.ViewerDid && v.Kind == VouchKindDenounce { + count++ + } + } + return count +}