diff --git a/appview/oauth/scopes.go b/appview/oauth/scopes.go index 351e05d9..83397f86 100644 --- a/appview/oauth/scopes.go +++ b/appview/oauth/scopes.go @@ -3,39 +3,40 @@ package oauth var TangledScopes = []string{ "atproto", + "repo:sh.tangled.actor.profile", + "repo:sh.tangled.feed.reaction", + "repo:sh.tangled.feed.star", + "repo:sh.tangled.graph.follow", + "repo:sh.tangled.graph.vouch", + "repo:sh.tangled.knot", + "repo:sh.tangled.knot.member", + "repo:sh.tangled.label.definition", + "repo:sh.tangled.label.op", "repo:sh.tangled.publicKey", "repo:sh.tangled.repo", - "repo:sh.tangled.repo.pull", - "repo:sh.tangled.repo.pull.comment", "repo:sh.tangled.repo.artifact", + "repo:sh.tangled.repo.collaborator", "repo:sh.tangled.repo.issue", "repo:sh.tangled.repo.issue.comment", - "repo:sh.tangled.repo.collaborator", - "repo:sh.tangled.knot", - "repo:sh.tangled.knot.member", + "repo:sh.tangled.repo.pull", + "repo:sh.tangled.repo.pull.comment", "repo:sh.tangled.spindle", "repo:sh.tangled.spindle.member", - "repo:sh.tangled.graph.follow", - "repo:sh.tangled.feed.star", - "repo:sh.tangled.feed.reaction", - "repo:sh.tangled.label.definition", - "repo:sh.tangled.label.op", "repo:sh.tangled.string", - "repo:sh.tangled.actor.profile", "blob:*/*", + "rpc:sh.tangled.pipeline.cancelPipeline?aud=*", + "rpc:sh.tangled.repo.addSecret?aud=*", "rpc:sh.tangled.repo.create?aud=*", "rpc:sh.tangled.repo.delete?aud=*", - "rpc:sh.tangled.repo.merge?aud=*", - "rpc:sh.tangled.repo.hiddenRef?aud=*", "rpc:sh.tangled.repo.deleteBranch?aud=*", - "rpc:sh.tangled.repo.setDefaultBranch?aud=*", - "rpc:sh.tangled.repo.forkSync?aud=*", "rpc:sh.tangled.repo.forkStatus?aud=*", + "rpc:sh.tangled.repo.forkSync?aud=*", + "rpc:sh.tangled.repo.hiddenRef?aud=*", + "rpc:sh.tangled.repo.listSecrets?aud=*", + "rpc:sh.tangled.repo.merge?aud=*", "rpc:sh.tangled.repo.mergeCheck?aud=*", - "rpc:sh.tangled.pipeline.cancelPipeline?aud=*", - "rpc:sh.tangled.repo.addSecret?aud=*", "rpc:sh.tangled.repo.removeSecret?aud=*", - "rpc:sh.tangled.repo.listSecrets?aud=*", + "rpc:sh.tangled.repo.setDefaultBranch?aud=*", } diff --git a/appview/pages/templates/user/fragments/profileCard.html b/appview/pages/templates/user/fragments/profileCard.html index 95ed743a..135301b6 100644 --- a/appview/pages/templates/user/fragments/profileCard.html +++ b/appview/pages/templates/user/fragments/profileCard.html @@ -39,7 +39,7 @@
-
+
{{ $profile := .Profile }} {{ with .Profile }} @@ -87,7 +87,7 @@
{{ end }} -
+
{{ if ne .FollowStatus.String "IsSelf" }} {{ template "user/fragments/follow" . }} {{ else }} @@ -108,6 +108,18 @@
+ {{ if ne .FollowStatus.String "IsSelf" }} + {{ template "user/fragments/vouch" . }} + + {{ if .VouchRelationship }} + {{ if .VouchRelationship.IndirectVouches }} +
+ {{ template "user/fragments/networkVouches" .VouchRelationship }} +
+ {{ end }} + {{ end }} + {{ end }} +
@@ -115,15 +127,40 @@ {{ end }} {{ define "followerFollowing" }} -{{ $root := index . 0 }} -{{ $userIdent := index . 1 }} -{{ with $root }} -
- {{ i "users" "size-4" }} - {{ - .Stats.FollowersCount }} followers - - {{ .Stats.FollowingCount }} following -
+ {{ $root := index . 0 }} + {{ $userIdent := index . 1 }} + {{ with $root }} +
+ {{ i "users" "size-4" }} + {{ + .Stats.FollowersCount }} followers + + {{ .Stats.FollowingCount }} following +
+ {{ end }} {{ end }} + + +{{ define "networkVouchItem" }} +
+
+ {{ template "user/fragments/picLink" (list .Did "size-8") }} +
+
+
+ {{ resolve .Did }} +
+
+ {{ .Reason | description }} +
+
+
+
+ {{ template "repo/fragments/shortTime" .CreatedAt }} +
+
+ view {{ i "arrow-right" "size-3" }} +
+
+
{{ end }} diff --git a/appview/pages/templates/user/fragments/vouch.html b/appview/pages/templates/user/fragments/vouch.html new file mode 100644 index 00000000..375e502c --- /dev/null +++ b/appview/pages/templates/user/fragments/vouch.html @@ -0,0 +1,6 @@ +{{ define "user/fragments/vouch" }} +
+ {{ template "user/fragments/vouchButton" . }} + {{ template "user/fragments/vouchPopover" . }} +
+{{ end }} diff --git a/appview/pages/templates/user/fragments/vouchTooltip.html b/appview/pages/templates/user/fragments/vouchTooltip.html new file mode 100644 index 00000000..914da99b --- /dev/null +++ b/appview/pages/templates/user/fragments/vouchTooltip.html @@ -0,0 +1,25 @@ +{{ define "user/fragments/vouchTooltip" }} + {{- $vr := . -}} + {{- if not $vr.IsEmpty -}} + {{- $networkPart := "" -}} + {{- if and (gt $vr.VouchStrength 0) (eq $vr.DenounceStrength 0) -}} + {{- $networkPart = printf "%s from your network vouched for this user." (plural $vr.VouchStrength "person" "people") -}} + {{- else if and (gt $vr.DenounceStrength 0) (eq $vr.VouchStrength 0) -}} + {{- $networkPart = printf "%s from your network denounced this user." (plural $vr.DenounceStrength "person" "people") -}} + {{- else if and (eq $vr.DenounceStrength 0) (eq $vr.VouchStrength 0) -}} + {{- $networkPart = "" -}} + {{- else -}} + {{- $networkPart = printf "%s and %s from your network." (plural $vr.VouchStrength "vouch" "vouches") (plural $vr.DenounceStrength "denounce" "") -}} + {{- end -}} + {{- $direct := $vr.GetDirectVouch -}} + {{- if $direct -}} + {{- if $direct.IsVouch -}} + You vouched for {{ resolve $vr.SubjectDid }} {{ relTimeFmt $direct.CreatedAt }}. {{ $networkPart }} + {{- else -}} + You denounced {{ resolve $vr.SubjectDid }} {{ relTimeFmt $direct.CreatedAt }}. {{ $networkPart }} + {{- end -}} + {{- else -}} + {{- $networkPart -}}. + {{- end -}} + {{- end -}} +{{ end }} diff --git a/appview/pages/templates/user/vouches.html b/appview/pages/templates/user/vouches.html new file mode 100644 index 00000000..6b9f675f --- /dev/null +++ b/appview/pages/templates/user/vouches.html @@ -0,0 +1,109 @@ +{{ define "title" }}{{ resolve .Card.UserDid }} · vouches {{ end }} + +{{ define "profileContent" }} + {{ $isSelf := and .LoggedInUser (eq .LoggedInUser.Did .Card.UserDid) }} +
+ {{ if $isSelf }}{{ template "vouchSuggestions" . }}{{ end }} + {{ block "vouchTimeline" . }}{{ end }} + {{ if ge (len .Vouches) .Page.Limit }} + {{ $handle := resolve .Card.UserDid }} + {{ template "fragments/pagination" (dict + "Page" .Page + "TotalCount" (add (len .Vouches) .Page.Offset) + "BasePath" (printf "/%s" $handle) + "QueryParams" (queryParams "tab" "vouches") + ) }} + {{ end }} +
+{{ end }} + +{{ define "vouchTimeline" }} + {{ $isSelf := and .LoggedInUser (eq .LoggedInUser.Did .Card.UserDid) }} + {{ if not .LoggedInUser }} +
+ You need to log in to view vouches. +
+ {{ else if not .Vouches }} + {{ if not (and $isSelf .Suggestions) }} +
+ No vouches in your network yet. +
+ {{ end }} + {{ else }} +
+
+ {{ range .Vouches }} + {{ template "vouchTimelineItem" (list $.Card.UserDid $.LoggedInUser.Did .) }} + {{ end }} +
+ {{ end }} +{{ end }} + +{{ define "vouchSuggestions" }} + {{ if .Suggestions }} +
+ {{ range .Suggestions }} +
+ +
+ {{ resolve .Did.String }} + {{ .Reason }} +
+
+ {{ template "user/fragments/vouch" . }} +
+
+ {{ end }} +
+ {{ end }} +{{ end }} + +{{ define "vouchTimelineItem" }} + {{ $profileDid := index . 0 }} + {{ $viewerDid := index . 1 }} + {{ $v := index . 2 }} + {{ $isOutgoing := eq $v.Did $profileDid }} + {{ $isVouch := $v.IsVouch }} + {{ $isSelf := eq $profileDid $viewerDid }} + +
+
+ {{ if $isVouch }} + {{ if $isOutgoing }} + {{ i "arrow-up-right" "size-5 text-green-500 dark:text-green-400" }} + {{ else }} + {{ i "arrow-down-left" "size-5 text-green-500 dark:text-green-400" }} + {{ end }} + {{ else }} + {{ if $isOutgoing }} + {{ i "arrow-up-right" "size-5 text-red-500 dark:text-red-400" }} + {{ else }} + {{ i "arrow-down-left" "size-5 text-red-500 dark:text-red-400" }} + {{ end }} + {{ end }} +
+ +
+
+ {{ if and $isSelf $isOutgoing }} + you + {{ else }} + {{ resolve $v.Did.String }} + {{ end }} + + {{ if $isVouch }}vouched for{{ else }}denounced{{ end }} + + {{ if and $isSelf (not $isOutgoing) }} + you + {{ else }} + {{ resolve $v.SubjectDid.String }} + {{ end }} + {{ relTimeFmt $v.CreatedAt }} +
+ {{ with $v.Reason }} +

{{ . }}

+ {{ end }} +
+
+{{ end }} diff --git a/appview/state/router.go b/appview/state/router.go index df931238..9a5f11d8 100644 --- a/appview/state/router.go +++ b/appview/state/router.go @@ -181,6 +181,10 @@ func (s *State) StandardRouter(mw *middleware.Middleware) http.Handler { r.Delete("/", s.Follow) }) + r.With(middleware.AuthMiddleware(s.oauth)).Route("/vouch", func(r chi.Router) { + r.Post("/", s.Vouch) + }) + r.With(middleware.AuthMiddleware(s.oauth)).Route("/star", func(r chi.Router) { r.Post("/", s.Star) r.Delete("/", s.Star) diff --git a/appview/state/state.go b/appview/state/state.go index b6d60199..fc18104f 100644 --- a/appview/state/state.go +++ b/appview/state/state.go @@ -123,21 +123,22 @@ func Make(ctx context.Context, config *config.Config) (*State, error) { config.Jetstream.Endpoint, "appview", []string{ - tangled.GraphFollowNSID, + tangled.ActorProfileNSID, tangled.FeedStarNSID, + tangled.GraphFollowNSID, + tangled.GraphVouchNSID, + tangled.KnotMemberNSID, + tangled.KnotNSID, + tangled.LabelDefinitionNSID, + tangled.LabelOpNSID, tangled.PublicKeyNSID, tangled.RepoArtifactNSID, - tangled.ActorProfileNSID, - tangled.KnotMemberNSID, + tangled.RepoIssueCommentNSID, + tangled.RepoIssueNSID, + tangled.RepoPullNSID, tangled.SpindleMemberNSID, tangled.SpindleNSID, - tangled.KnotNSID, tangled.StringNSID, - tangled.RepoPullNSID, - tangled.RepoIssueNSID, - tangled.RepoIssueCommentNSID, - tangled.LabelDefinitionNSID, - tangled.LabelOpNSID, }, nil, tlog.SubLogger(logger, "jetstream"), diff --git a/appview/state/vouch.go b/appview/state/vouch.go new file mode 100644 index 00000000..bcf2f22d --- /dev/null +++ b/appview/state/vouch.go @@ -0,0 +1,156 @@ +package state + +import ( + "net/http" + "time" + + comatproto "github.com/bluesky-social/indigo/api/atproto" + "github.com/bluesky-social/indigo/atproto/syntax" + lexutil "github.com/bluesky-social/indigo/lex/util" + "github.com/ipfs/go-cid" + "tangled.org/core/api/tangled" + "tangled.org/core/appview/db" + "tangled.org/core/appview/models" + "tangled.org/core/log" +) + +func (s *State) Vouch(w http.ResponseWriter, r *http.Request) { + l := log.SubLogger(s.logger, "vouch") + l = s.logger.With("handler", "Vouch") + currentUser := s.oauth.GetMultiAccountUser(r) + + var subject string + + subject = r.FormValue("subject") + + if subject == "" { + l.Warn("invalid form: missing subject") + s.pages.Notice(w, "error", "Missing subject user.") + return + } + + subjectIdent, err := s.idResolver.ResolveIdent(r.Context(), subject) + if err != nil { + l.Error("failed to vouch, invalid did", "subject", subject, "err", err) + s.pages.Notice(w, "error", "Could not find that user.") + return + } + + if currentUser.Did == subjectIdent.DID.String() { + l.Warn("cant vouch or denounce yourself") + s.pages.Notice(w, "error", "You cannot vouch for yourself.") + return + } + + client, err := s.oauth.AuthorizedClient(r) + if err != nil { + l.Error("failed to authorize client", "err", err) + s.pages.Notice(w, "error", "Authentication required.") + return + } + + if err := r.ParseForm(); err != nil { + l.Warn("failed to parse form", "err", err) + s.pages.Notice(w, "error", "Invalid form data.") + return + } + + subjectDid := subjectIdent.DID.String() + + // handle "none" by deleting any existing vouch + if r.FormValue("kind") == "none" { + _, err := db.GetVouch(s.db, currentUser.Did, subjectDid) + if err != nil { + l.Info("no existing vouch to delete") + s.pages.HxRefresh(w) + return + } + + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ + Collection: tangled.GraphVouchNSID, + Repo: currentUser.Did, + Rkey: subjectDid, + }) + + if err != nil { + l.Error("failed to delete vouch record", "err", err) + s.pages.Notice(w, "error", "Failed to delete vouch record.") + return + } + + err = db.DeleteVouch(s.db, currentUser.Did, subjectDid) + if err != nil { + l.Warn("failed to delete vouch from DB", "err", err) + } + + l.Info("deleted vouch record") + s.pages.HxRefresh(w) + return + } + + kind, err := models.ParseVouchKind(r.FormValue("kind")) + if err != nil { + l.Warn("failed to parse vouch kind", "err", err) + s.pages.Notice(w, "error", "Invalid action type.") + return + } + + reason := r.FormValue("reason") + createdAt := time.Now().Format(time.RFC3339) + + var reasonPtr *string + if reason != "" { + reasonPtr = &reason + } + + var swapCid *string + existingVouch, err := db.GetVouch(s.db, currentUser.Did, subjectDid) + if err == nil { + cidStr := existingVouch.Cid.String() + swapCid = &cidStr + } + + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ + Collection: tangled.GraphVouchNSID, + Repo: currentUser.Did, + Rkey: subjectDid, + SwapRecord: swapCid, + Record: &lexutil.LexiconTypeDecoder{ + Val: &tangled.GraphVouch{ + Kind: string(kind), + Reason: reasonPtr, + CreatedAt: createdAt, + }}, + }) + if err != nil { + l.Error("failed to create atproto record", "err", err) + s.pages.Notice(w, "error", "Failed to create vouch record.") + return + } + + l.Info("created atproto record", "uri", resp.Uri, "kind", kind) + + newCid, err := cid.Parse(resp.Cid) + if err != nil { + l.Error("failed to parse returned cid", "err", err) + s.pages.Notice(w, "error", "Failed to save vouch.") + return + } + + vouch := &models.Vouch{ + Did: syntax.DID(currentUser.Did), + SubjectDid: subjectIdent.DID, + Cid: newCid, + Kind: kind, + Reason: reasonPtr, + } + + err = db.AddVouch(s.db, vouch) + if err != nil { + l.Error("failed to add vouch to db", "err", err) + s.pages.Notice(w, "error", "Failed to save vouch.") + return + } + + s.pages.HxRefresh(w) +}