appview: allow timeline db queries to be filterable by users follows #626

closed
opened by willdot.net targeting master from willdot.net/tangled-core: feat/filter-user-timeline

Signed-off-by: Will Andrews did:plc:dadhhalkfcq3gucaq25hjqon

Changed files
+623 -1031
appview
+6 -6
appview/knots/knots.go
···
return
}
-
ex, _ := client.RepoGetRecord(r.Context(), "", tangled.KnotNSID, user.Did, domain)
+
ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.KnotNSID, user.Did, domain)
var exCid *string
if ex != nil {
exCid = ex.Cid
}
// re-announce by registering under same rkey
-
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
+
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
Collection: tangled.KnotNSID,
Repo: user.Did,
Rkey: domain,
···
return
}
-
_, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
+
_, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
Collection: tangled.KnotNSID,
Repo: user.Did,
Rkey: domain,
···
return
}
-
ex, _ := client.RepoGetRecord(r.Context(), "", tangled.KnotNSID, user.Did, domain)
+
ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.KnotNSID, user.Did, domain)
var exCid *string
if ex != nil {
exCid = ex.Cid
}
// ignore the error here
-
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
+
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
Collection: tangled.KnotNSID,
Repo: user.Did,
Rkey: domain,
···
rkey := tid.TID()
-
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
+
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
Collection: tangled.KnotMemberNSID,
Repo: user.Did,
Rkey: rkey,
+9 -9
appview/labels/labels.go
···
"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/go-chi/chi/v5"
-
"tangled.org/core/api/tangled"
"tangled.org/core/appview/db"
"tangled.org/core/appview/middleware"
···
"tangled.org/core/appview/oauth"
"tangled.org/core/appview/pages"
"tangled.org/core/appview/validator"
-
"tangled.org/core/appview/xrpcclient"
"tangled.org/core/log"
"tangled.org/core/rbac"
"tangled.org/core/tid"
+
+
comatproto "github.com/bluesky-social/indigo/api/atproto"
+
atpclient "github.com/bluesky-social/indigo/atproto/client"
+
"github.com/bluesky-social/indigo/atproto/syntax"
+
lexutil "github.com/bluesky-social/indigo/lex/util"
+
"github.com/go-chi/chi/v5"
)
type Labels struct {
···
return
}
-
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
+
resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
Collection: tangled.LabelOpNSID,
Repo: did,
Rkey: rkey,
···
// this is used to rollback changes made to the PDS
//
// it is a no-op if the provided ATURI is empty
-
func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error {
+
func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error {
if aturi == "" {
return nil
}
···
repo := parsed.Authority().String()
rkey := parsed.RecordKey().String()
-
_, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{
+
_, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{
Collection: collection,
Repo: repo,
Rkey: rkey,
+5 -14
appview/middleware/middleware.go
···
type middlewareFunc func(http.Handler) http.Handler
-
func (mw *Middleware) TryRefreshSession() middlewareFunc {
-
return func(next http.Handler) http.Handler {
-
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-
_, _, _ = mw.oauth.GetSession(r)
-
next.ServeHTTP(w, r)
-
})
-
}
-
}
-
-
func AuthMiddleware(a *oauth.OAuth) middlewareFunc {
+
func AuthMiddleware(o *oauth.OAuth) middlewareFunc {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
returnURL := "/"
···
}
}
-
_, auth, err := a.GetSession(r)
+
sess, err := o.ResumeSession(r)
if err != nil {
-
log.Println("not logged in, redirecting", "err", err)
+
log.Println("failed to resume session, redirecting...", "err", err, "url", r.URL.String())
redirectFunc(w, r)
return
}
-
if !auth {
-
log.Printf("not logged in, redirecting")
+
if sess == nil {
+
log.Printf("session is nil, redirecting...")
redirectFunc(w, r)
return
}
+18 -20
appview/notifications/notifications.go
···
package notifications
import (
-
"fmt"
"log"
"net/http"
"strconv"
···
func (n *Notifications) Router(mw *middleware.Middleware) http.Handler {
r := chi.NewRouter()
-
r.Use(middleware.AuthMiddleware(n.oauth))
-
-
r.With(middleware.Paginate).Get("/", n.notificationsPage)
-
r.Get("/count", n.getUnreadCount)
-
r.Post("/{id}/read", n.markRead)
-
r.Post("/read-all", n.markAllRead)
-
r.Delete("/{id}", n.deleteNotification)
+
+
r.Group(func(r chi.Router) {
+
r.Use(middleware.AuthMiddleware(n.oauth))
+
r.With(middleware.Paginate).Get("/", n.notificationsPage)
+
r.Post("/{id}/read", n.markRead)
+
r.Post("/read-all", n.markAllRead)
+
r.Delete("/{id}", n.deleteNotification)
+
})
return r
}
func (n *Notifications) notificationsPage(w http.ResponseWriter, r *http.Request) {
-
userDid := n.oauth.GetDid(r)
+
user := n.oauth.GetUser(r)
page, ok := r.Context().Value("page").(pagination.Page)
if !ok {
···
total, err := db.CountNotifications(
n.db,
-
db.FilterEq("recipient_did", userDid),
+
db.FilterEq("recipient_did", user.Did),
)
if err != nil {
log.Println("failed to get total notifications:", err)
···
notifications, err := db.GetNotificationsWithEntities(
n.db,
page,
-
db.FilterEq("recipient_did", userDid),
+
db.FilterEq("recipient_did", user.Did),
)
if err != nil {
log.Println("failed to get notifications:", err)
···
return
}
-
err = n.db.MarkAllNotificationsRead(r.Context(), userDid)
+
err = n.db.MarkAllNotificationsRead(r.Context(), user.Did)
if err != nil {
log.Println("failed to mark notifications as read:", err)
}
unreadCount := 0
-
user := n.oauth.GetUser(r)
-
if user == nil {
-
http.Error(w, "Failed to get user", http.StatusInternalServerError)
-
return
-
}
-
-
fmt.Println(n.pages.Notifications(w, pages.NotificationsParams{
+
n.pages.Notifications(w, pages.NotificationsParams{
LoggedInUser: user,
Notifications: notifications,
UnreadCount: unreadCount,
Page: page,
Total: total,
-
}))
+
})
}
func (n *Notifications) getUnreadCount(w http.ResponseWriter, r *http.Request) {
user := n.oauth.GetUser(r)
+
if user == nil {
+
return
+
}
+
count, err := db.CountNotifications(
n.db,
db.FilterEq("recipient_did", user.Did),
-24
appview/oauth/client/oauth_client.go
···
-
package client
-
-
import (
-
oauth "tangled.org/anirudh.fi/atproto-oauth"
-
"tangled.org/anirudh.fi/atproto-oauth/helpers"
-
)
-
-
type OAuthClient struct {
-
*oauth.Client
-
}
-
-
func NewClient(clientId, clientJwk, redirectUri string) (*OAuthClient, error) {
-
k, err := helpers.ParseJWKFromBytes([]byte(clientJwk))
-
if err != nil {
-
return nil, err
-
}
-
-
cli, err := oauth.NewClient(oauth.ClientArgs{
-
ClientId: clientId,
-
ClientJwk: k,
-
RedirectUri: redirectUri,
-
})
-
return &OAuthClient{cli}, err
-
}
+2 -1
appview/oauth/consts.go
···
package oauth
const (
-
SessionName = "appview-session"
+
SessionName = "appview-session-v2"
SessionHandle = "handle"
SessionDid = "did"
+
SessionId = "id"
SessionPds = "pds"
SessionAccessJwt = "accessJwt"
SessionRefreshJwt = "refreshJwt"
+65
appview/oauth/handler.go
···
+
package oauth
+
+
import (
+
"encoding/json"
+
"log"
+
"net/http"
+
+
"github.com/go-chi/chi/v5"
+
"github.com/lestrrat-go/jwx/v2/jwk"
+
)
+
+
func (o *OAuth) Router() http.Handler {
+
r := chi.NewRouter()
+
+
r.Get("/oauth/client-metadata.json", o.clientMetadata)
+
r.Get("/oauth/jwks.json", o.jwks)
+
r.Get("/oauth/callback", o.callback)
+
return r
+
}
+
+
func (o *OAuth) clientMetadata(w http.ResponseWriter, r *http.Request) {
+
doc := o.ClientApp.Config.ClientMetadata()
+
doc.JWKSURI = &o.JwksUri
+
+
w.Header().Set("Content-Type", "application/json")
+
if err := json.NewEncoder(w).Encode(doc); err != nil {
+
http.Error(w, err.Error(), http.StatusInternalServerError)
+
return
+
}
+
}
+
+
func (o *OAuth) jwks(w http.ResponseWriter, r *http.Request) {
+
jwks := o.Config.OAuth.Jwks
+
pubKey, err := pubKeyFromJwk(jwks)
+
if err != nil {
+
log.Printf("error parsing public key: %v", err)
+
http.Error(w, err.Error(), http.StatusInternalServerError)
+
return
+
}
+
+
response := map[string]any{
+
"keys": []jwk.Key{pubKey},
+
}
+
+
w.Header().Set("Content-Type", "application/json")
+
w.WriteHeader(http.StatusOK)
+
json.NewEncoder(w).Encode(response)
+
}
+
+
func (o *OAuth) callback(w http.ResponseWriter, r *http.Request) {
+
ctx := r.Context()
+
+
sessData, err := o.ClientApp.ProcessCallback(ctx, r.URL.Query())
+
if err != nil {
+
http.Error(w, err.Error(), http.StatusInternalServerError)
+
return
+
}
+
+
if err := o.SaveSession(w, r, sessData); err != nil {
+
http.Error(w, err.Error(), http.StatusInternalServerError)
+
return
+
}
+
+
http.Redirect(w, r, "/", http.StatusFound)
+
}
-538
appview/oauth/handler/handler.go
···
-
package oauth
-
-
import (
-
"bytes"
-
"context"
-
"encoding/json"
-
"fmt"
-
"log"
-
"net/http"
-
"net/url"
-
"slices"
-
"strings"
-
"time"
-
-
"github.com/go-chi/chi/v5"
-
"github.com/gorilla/sessions"
-
"github.com/lestrrat-go/jwx/v2/jwk"
-
"github.com/posthog/posthog-go"
-
"tangled.org/anirudh.fi/atproto-oauth/helpers"
-
tangled "tangled.org/core/api/tangled"
-
sessioncache "tangled.org/core/appview/cache/session"
-
"tangled.org/core/appview/config"
-
"tangled.org/core/appview/db"
-
"tangled.org/core/appview/middleware"
-
"tangled.org/core/appview/oauth"
-
"tangled.org/core/appview/oauth/client"
-
"tangled.org/core/appview/pages"
-
"tangled.org/core/consts"
-
"tangled.org/core/idresolver"
-
"tangled.org/core/rbac"
-
"tangled.org/core/tid"
-
)
-
-
const (
-
oauthScope = "atproto transition:generic"
-
)
-
-
type OAuthHandler struct {
-
config *config.Config
-
pages *pages.Pages
-
idResolver *idresolver.Resolver
-
sess *sessioncache.SessionStore
-
db *db.DB
-
store *sessions.CookieStore
-
oauth *oauth.OAuth
-
enforcer *rbac.Enforcer
-
posthog posthog.Client
-
}
-
-
func New(
-
config *config.Config,
-
pages *pages.Pages,
-
idResolver *idresolver.Resolver,
-
db *db.DB,
-
sess *sessioncache.SessionStore,
-
store *sessions.CookieStore,
-
oauth *oauth.OAuth,
-
enforcer *rbac.Enforcer,
-
posthog posthog.Client,
-
) *OAuthHandler {
-
return &OAuthHandler{
-
config: config,
-
pages: pages,
-
idResolver: idResolver,
-
db: db,
-
sess: sess,
-
store: store,
-
oauth: oauth,
-
enforcer: enforcer,
-
posthog: posthog,
-
}
-
}
-
-
func (o *OAuthHandler) Router() http.Handler {
-
r := chi.NewRouter()
-
-
r.Get("/login", o.login)
-
r.Post("/login", o.login)
-
-
r.With(middleware.AuthMiddleware(o.oauth)).Post("/logout", o.logout)
-
-
r.Get("/oauth/client-metadata.json", o.clientMetadata)
-
r.Get("/oauth/jwks.json", o.jwks)
-
r.Get("/oauth/callback", o.callback)
-
return r
-
}
-
-
func (o *OAuthHandler) clientMetadata(w http.ResponseWriter, r *http.Request) {
-
w.Header().Set("Content-Type", "application/json")
-
w.WriteHeader(http.StatusOK)
-
json.NewEncoder(w).Encode(o.oauth.ClientMetadata())
-
}
-
-
func (o *OAuthHandler) jwks(w http.ResponseWriter, r *http.Request) {
-
jwks := o.config.OAuth.Jwks
-
pubKey, err := pubKeyFromJwk(jwks)
-
if err != nil {
-
log.Printf("error parsing public key: %v", err)
-
http.Error(w, err.Error(), http.StatusInternalServerError)
-
return
-
}
-
-
response := helpers.CreateJwksResponseObject(pubKey)
-
-
w.Header().Set("Content-Type", "application/json")
-
w.WriteHeader(http.StatusOK)
-
json.NewEncoder(w).Encode(response)
-
}
-
-
func (o *OAuthHandler) login(w http.ResponseWriter, r *http.Request) {
-
switch r.Method {
-
case http.MethodGet:
-
returnURL := r.URL.Query().Get("return_url")
-
o.pages.Login(w, pages.LoginParams{
-
ReturnUrl: returnURL,
-
})
-
case http.MethodPost:
-
handle := r.FormValue("handle")
-
-
// when users copy their handle from bsky.app, it tends to have these characters around it:
-
//
-
// @nelind.dk:
-
// \u202a ensures that the handle is always rendered left to right and
-
// \u202c reverts that so the rest of the page renders however it should
-
handle = strings.TrimPrefix(handle, "\u202a")
-
handle = strings.TrimSuffix(handle, "\u202c")
-
-
// `@` is harmless
-
handle = strings.TrimPrefix(handle, "@")
-
-
// basic handle validation
-
if !strings.Contains(handle, ".") {
-
log.Println("invalid handle format", "raw", handle)
-
o.pages.Notice(w, "login-msg", fmt.Sprintf("\"%s\" is an invalid handle. Did you mean %s.bsky.social?", handle, handle))
-
return
-
}
-
-
resolved, err := o.idResolver.ResolveIdent(r.Context(), handle)
-
if err != nil {
-
log.Println("failed to resolve handle:", err)
-
o.pages.Notice(w, "login-msg", fmt.Sprintf("\"%s\" is an invalid handle.", handle))
-
return
-
}
-
self := o.oauth.ClientMetadata()
-
oauthClient, err := client.NewClient(
-
self.ClientID,
-
o.config.OAuth.Jwks,
-
self.RedirectURIs[0],
-
)
-
-
if err != nil {
-
log.Println("failed to create oauth client:", err)
-
o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
-
return
-
}
-
-
authServer, err := oauthClient.ResolvePdsAuthServer(r.Context(), resolved.PDSEndpoint())
-
if err != nil {
-
log.Println("failed to resolve auth server:", err)
-
o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
-
return
-
}
-
-
authMeta, err := oauthClient.FetchAuthServerMetadata(r.Context(), authServer)
-
if err != nil {
-
log.Println("failed to fetch auth server metadata:", err)
-
o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
-
return
-
}
-
-
dpopKey, err := helpers.GenerateKey(nil)
-
if err != nil {
-
log.Println("failed to generate dpop key:", err)
-
o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
-
return
-
}
-
-
dpopKeyJson, err := json.Marshal(dpopKey)
-
if err != nil {
-
log.Println("failed to marshal dpop key:", err)
-
o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
-
return
-
}
-
-
parResp, err := oauthClient.SendParAuthRequest(r.Context(), authServer, authMeta, handle, oauthScope, dpopKey)
-
if err != nil {
-
log.Println("failed to send par auth request:", err)
-
o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
-
return
-
}
-
-
err = o.sess.SaveRequest(r.Context(), sessioncache.OAuthRequest{
-
Did: resolved.DID.String(),
-
PdsUrl: resolved.PDSEndpoint(),
-
Handle: handle,
-
AuthserverIss: authMeta.Issuer,
-
PkceVerifier: parResp.PkceVerifier,
-
DpopAuthserverNonce: parResp.DpopAuthserverNonce,
-
DpopPrivateJwk: string(dpopKeyJson),
-
State: parResp.State,
-
ReturnUrl: r.FormValue("return_url"),
-
})
-
if err != nil {
-
log.Println("failed to save oauth request:", err)
-
o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
-
return
-
}
-
-
u, _ := url.Parse(authMeta.AuthorizationEndpoint)
-
query := url.Values{}
-
query.Add("client_id", self.ClientID)
-
query.Add("request_uri", parResp.RequestUri)
-
u.RawQuery = query.Encode()
-
o.pages.HxRedirect(w, u.String())
-
}
-
}
-
-
func (o *OAuthHandler) callback(w http.ResponseWriter, r *http.Request) {
-
state := r.FormValue("state")
-
-
oauthRequest, err := o.sess.GetRequestByState(r.Context(), state)
-
if err != nil {
-
log.Println("failed to get oauth request:", err)
-
o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
-
return
-
}
-
-
defer func() {
-
err := o.sess.DeleteRequestByState(r.Context(), state)
-
if err != nil {
-
log.Println("failed to delete oauth request for state:", state, err)
-
}
-
}()
-
-
error := r.FormValue("error")
-
errorDescription := r.FormValue("error_description")
-
if error != "" || errorDescription != "" {
-
log.Printf("error: %s, %s", error, errorDescription)
-
o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
-
return
-
}
-
-
code := r.FormValue("code")
-
if code == "" {
-
log.Println("missing code for state: ", state)
-
o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
-
return
-
}
-
-
iss := r.FormValue("iss")
-
if iss == "" {
-
log.Println("missing iss for state: ", state)
-
o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
-
return
-
}
-
-
if iss != oauthRequest.AuthserverIss {
-
log.Println("mismatched iss:", iss, "!=", oauthRequest.AuthserverIss, "for state:", state)
-
o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
-
return
-
}
-
-
self := o.oauth.ClientMetadata()
-
-
oauthClient, err := client.NewClient(
-
self.ClientID,
-
o.config.OAuth.Jwks,
-
self.RedirectURIs[0],
-
)
-
-
if err != nil {
-
log.Println("failed to create oauth client:", err)
-
o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
-
return
-
}
-
-
jwk, err := helpers.ParseJWKFromBytes([]byte(oauthRequest.DpopPrivateJwk))
-
if err != nil {
-
log.Println("failed to parse jwk:", err)
-
o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
-
return
-
}
-
-
tokenResp, err := oauthClient.InitialTokenRequest(
-
r.Context(),
-
code,
-
oauthRequest.AuthserverIss,
-
oauthRequest.PkceVerifier,
-
oauthRequest.DpopAuthserverNonce,
-
jwk,
-
)
-
if err != nil {
-
log.Println("failed to get token:", err)
-
o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
-
return
-
}
-
-
if tokenResp.Scope != oauthScope {
-
log.Println("scope doesn't match:", tokenResp.Scope)
-
o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
-
return
-
}
-
-
err = o.oauth.SaveSession(w, r, *oauthRequest, tokenResp)
-
if err != nil {
-
log.Println("failed to save session:", err)
-
o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
-
return
-
}
-
-
log.Println("session saved successfully")
-
go o.addToDefaultKnot(oauthRequest.Did)
-
go o.addToDefaultSpindle(oauthRequest.Did)
-
-
if !o.config.Core.Dev {
-
err = o.posthog.Enqueue(posthog.Capture{
-
DistinctId: oauthRequest.Did,
-
Event: "signin",
-
})
-
if err != nil {
-
log.Println("failed to enqueue posthog event:", err)
-
}
-
}
-
-
returnUrl := oauthRequest.ReturnUrl
-
if returnUrl == "" {
-
returnUrl = "/"
-
}
-
-
http.Redirect(w, r, returnUrl, http.StatusFound)
-
}
-
-
func (o *OAuthHandler) logout(w http.ResponseWriter, r *http.Request) {
-
err := o.oauth.ClearSession(r, w)
-
if err != nil {
-
log.Println("failed to clear session:", err)
-
http.Redirect(w, r, "/", http.StatusFound)
-
return
-
}
-
-
log.Println("session cleared successfully")
-
o.pages.HxRedirect(w, "/login")
-
}
-
-
func pubKeyFromJwk(jwks string) (jwk.Key, error) {
-
k, err := helpers.ParseJWKFromBytes([]byte(jwks))
-
if err != nil {
-
return nil, err
-
}
-
pubKey, err := k.PublicKey()
-
if err != nil {
-
return nil, err
-
}
-
return pubKey, nil
-
}
-
-
func (o *OAuthHandler) addToDefaultSpindle(did string) {
-
// use the tangled.sh app password to get an accessJwt
-
// and create an sh.tangled.spindle.member record with that
-
spindleMembers, err := db.GetSpindleMembers(
-
o.db,
-
db.FilterEq("instance", "spindle.tangled.sh"),
-
db.FilterEq("subject", did),
-
)
-
if err != nil {
-
log.Printf("failed to get spindle members for did %s: %v", did, err)
-
return
-
}
-
-
if len(spindleMembers) != 0 {
-
log.Printf("did %s is already a member of the default spindle", did)
-
return
-
}
-
-
log.Printf("adding %s to default spindle", did)
-
session, err := o.createAppPasswordSession(o.config.Core.AppPassword, consts.TangledDid)
-
if err != nil {
-
log.Printf("failed to create session: %s", err)
-
return
-
}
-
-
record := tangled.SpindleMember{
-
LexiconTypeID: "sh.tangled.spindle.member",
-
Subject: did,
-
Instance: consts.DefaultSpindle,
-
CreatedAt: time.Now().Format(time.RFC3339),
-
}
-
-
if err := session.putRecord(record, tangled.SpindleMemberNSID); err != nil {
-
log.Printf("failed to add member to default spindle: %s", err)
-
return
-
}
-
-
log.Printf("successfully added %s to default spindle", did)
-
}
-
-
func (o *OAuthHandler) addToDefaultKnot(did string) {
-
// use the tangled.sh app password to get an accessJwt
-
// and create an sh.tangled.spindle.member record with that
-
-
allKnots, err := o.enforcer.GetKnotsForUser(did)
-
if err != nil {
-
log.Printf("failed to get knot members for did %s: %v", did, err)
-
return
-
}
-
-
if slices.Contains(allKnots, consts.DefaultKnot) {
-
log.Printf("did %s is already a member of the default knot", did)
-
return
-
}
-
-
log.Printf("adding %s to default knot", did)
-
session, err := o.createAppPasswordSession(o.config.Core.TmpAltAppPassword, consts.IcyDid)
-
if err != nil {
-
log.Printf("failed to create session: %s", err)
-
return
-
}
-
-
record := tangled.KnotMember{
-
LexiconTypeID: "sh.tangled.knot.member",
-
Subject: did,
-
Domain: consts.DefaultKnot,
-
CreatedAt: time.Now().Format(time.RFC3339),
-
}
-
-
if err := session.putRecord(record, tangled.KnotMemberNSID); err != nil {
-
log.Printf("failed to add member to default knot: %s", err)
-
return
-
}
-
-
if err := o.enforcer.AddKnotMember(consts.DefaultKnot, did); err != nil {
-
log.Printf("failed to set up enforcer rules: %s", err)
-
return
-
}
-
-
log.Printf("successfully added %s to default Knot", did)
-
}
-
-
// create a session using apppasswords
-
type session struct {
-
AccessJwt string `json:"accessJwt"`
-
PdsEndpoint string
-
Did string
-
}
-
-
func (o *OAuthHandler) createAppPasswordSession(appPassword, did string) (*session, error) {
-
if appPassword == "" {
-
return nil, fmt.Errorf("no app password configured, skipping member addition")
-
}
-
-
resolved, err := o.idResolver.ResolveIdent(context.Background(), did)
-
if err != nil {
-
return nil, fmt.Errorf("failed to resolve tangled.sh DID %s: %v", did, err)
-
}
-
-
pdsEndpoint := resolved.PDSEndpoint()
-
if pdsEndpoint == "" {
-
return nil, fmt.Errorf("no PDS endpoint found for tangled.sh DID %s", did)
-
}
-
-
sessionPayload := map[string]string{
-
"identifier": did,
-
"password": appPassword,
-
}
-
sessionBytes, err := json.Marshal(sessionPayload)
-
if err != nil {
-
return nil, fmt.Errorf("failed to marshal session payload: %v", err)
-
}
-
-
sessionURL := pdsEndpoint + "/xrpc/com.atproto.server.createSession"
-
sessionReq, err := http.NewRequestWithContext(context.Background(), "POST", sessionURL, bytes.NewBuffer(sessionBytes))
-
if err != nil {
-
return nil, fmt.Errorf("failed to create session request: %v", err)
-
}
-
sessionReq.Header.Set("Content-Type", "application/json")
-
-
client := &http.Client{Timeout: 30 * time.Second}
-
sessionResp, err := client.Do(sessionReq)
-
if err != nil {
-
return nil, fmt.Errorf("failed to create session: %v", err)
-
}
-
defer sessionResp.Body.Close()
-
-
if sessionResp.StatusCode != http.StatusOK {
-
return nil, fmt.Errorf("failed to create session: HTTP %d", sessionResp.StatusCode)
-
}
-
-
var session session
-
if err := json.NewDecoder(sessionResp.Body).Decode(&session); err != nil {
-
return nil, fmt.Errorf("failed to decode session response: %v", err)
-
}
-
-
session.PdsEndpoint = pdsEndpoint
-
session.Did = did
-
-
return &session, nil
-
}
-
-
func (s *session) putRecord(record any, collection string) error {
-
recordBytes, err := json.Marshal(record)
-
if err != nil {
-
return fmt.Errorf("failed to marshal knot member record: %w", err)
-
}
-
-
payload := map[string]any{
-
"repo": s.Did,
-
"collection": collection,
-
"rkey": tid.TID(),
-
"record": json.RawMessage(recordBytes),
-
}
-
-
payloadBytes, err := json.Marshal(payload)
-
if err != nil {
-
return fmt.Errorf("failed to marshal request payload: %w", err)
-
}
-
-
url := s.PdsEndpoint + "/xrpc/com.atproto.repo.putRecord"
-
req, err := http.NewRequestWithContext(context.Background(), "POST", url, bytes.NewBuffer(payloadBytes))
-
if err != nil {
-
return fmt.Errorf("failed to create HTTP request: %w", err)
-
}
-
-
req.Header.Set("Content-Type", "application/json")
-
req.Header.Set("Authorization", "Bearer "+s.AccessJwt)
-
-
client := &http.Client{Timeout: 30 * time.Second}
-
resp, err := client.Do(req)
-
if err != nil {
-
return fmt.Errorf("failed to add user to default service: %w", err)
-
}
-
defer resp.Body.Close()
-
-
if resp.StatusCode != http.StatusOK {
-
return fmt.Errorf("failed to add user to default service: HTTP %d", resp.StatusCode)
-
}
-
-
return nil
-
}
+110 -205
appview/oauth/oauth.go
···
package oauth
import (
+
"errors"
"fmt"
-
"log"
"net/http"
-
"net/url"
"time"
-
indigo_xrpc "github.com/bluesky-social/indigo/xrpc"
+
comatproto "github.com/bluesky-social/indigo/api/atproto"
+
"github.com/bluesky-social/indigo/atproto/auth/oauth"
+
atpclient "github.com/bluesky-social/indigo/atproto/client"
+
"github.com/bluesky-social/indigo/atproto/syntax"
+
xrpc "github.com/bluesky-social/indigo/xrpc"
"github.com/gorilla/sessions"
-
oauth "tangled.org/anirudh.fi/atproto-oauth"
-
"tangled.org/anirudh.fi/atproto-oauth/helpers"
-
sessioncache "tangled.org/core/appview/cache/session"
+
"github.com/lestrrat-go/jwx/v2/jwk"
"tangled.org/core/appview/config"
-
"tangled.org/core/appview/oauth/client"
-
xrpc "tangled.org/core/appview/xrpcclient"
)
-
type OAuth struct {
-
store *sessions.CookieStore
-
config *config.Config
-
sess *sessioncache.SessionStore
-
}
+
func New(config *config.Config) (*OAuth, error) {
-
func NewOAuth(config *config.Config, sess *sessioncache.SessionStore) *OAuth {
-
return &OAuth{
-
store: sessions.NewCookieStore([]byte(config.Core.CookieSecret)),
-
config: config,
-
sess: sess,
+
var oauthConfig oauth.ClientConfig
+
var clientUri string
+
+
if config.Core.Dev {
+
clientUri = "http://127.0.0.1:3000"
+
callbackUri := clientUri + "/oauth/callback"
+
oauthConfig = oauth.NewLocalhostConfig(callbackUri, []string{"atproto", "transition:generic"})
+
} else {
+
clientUri = config.Core.AppviewHost
+
clientId := fmt.Sprintf("%s/oauth/client-metadata.json", clientUri)
+
callbackUri := clientUri + "/oauth/callback"
+
oauthConfig = oauth.NewPublicConfig(clientId, callbackUri, []string{"atproto", "transition:generic"})
}
+
+
jwksUri := clientUri + "/oauth/jwks.json"
+
+
authStore, err := NewRedisStore(config.Redis.ToURL())
+
if err != nil {
+
return nil, err
+
}
+
+
sessStore := sessions.NewCookieStore([]byte(config.Core.CookieSecret))
+
+
return &OAuth{
+
ClientApp: oauth.NewClientApp(&oauthConfig, authStore),
+
Config: config,
+
SessStore: sessStore,
+
JwksUri: jwksUri,
+
}, nil
}
-
func (o *OAuth) Stores() *sessions.CookieStore {
-
return o.store
+
type OAuth struct {
+
ClientApp *oauth.ClientApp
+
SessStore *sessions.CookieStore
+
Config *config.Config
+
JwksUri string
}
-
func (o *OAuth) SaveSession(w http.ResponseWriter, r *http.Request, oreq sessioncache.OAuthRequest, oresp *oauth.TokenResponse) error {
+
func (o *OAuth) SaveSession(w http.ResponseWriter, r *http.Request, sessData *oauth.ClientSessionData) error {
// first we save the did in the user session
-
userSession, err := o.store.Get(r, SessionName)
+
userSession, err := o.SessStore.Get(r, SessionName)
if err != nil {
return err
}
-
userSession.Values[SessionDid] = oreq.Did
-
userSession.Values[SessionHandle] = oreq.Handle
-
userSession.Values[SessionPds] = oreq.PdsUrl
+
userSession.Values[SessionDid] = sessData.AccountDID.String()
+
userSession.Values[SessionPds] = sessData.HostURL
+
userSession.Values[SessionId] = sessData.SessionID
userSession.Values[SessionAuthenticated] = true
-
err = userSession.Save(r, w)
+
return userSession.Save(r, w)
+
}
+
+
func (o *OAuth) ResumeSession(r *http.Request) (*oauth.ClientSession, error) {
+
userSession, err := o.SessStore.Get(r, SessionName)
if err != nil {
-
return fmt.Errorf("error saving user session: %w", err)
+
return nil, fmt.Errorf("error getting user session: %w", err)
}
-
-
// then save the whole thing in the db
-
session := sessioncache.OAuthSession{
-
Did: oreq.Did,
-
Handle: oreq.Handle,
-
PdsUrl: oreq.PdsUrl,
-
DpopAuthserverNonce: oreq.DpopAuthserverNonce,
-
AuthServerIss: oreq.AuthserverIss,
-
DpopPrivateJwk: oreq.DpopPrivateJwk,
-
AccessJwt: oresp.AccessToken,
-
RefreshJwt: oresp.RefreshToken,
-
Expiry: time.Now().Add(time.Duration(oresp.ExpiresIn) * time.Second).Format(time.RFC3339),
+
if userSession.IsNew {
+
return nil, fmt.Errorf("no session available for user")
}
-
return o.sess.SaveSession(r.Context(), session)
-
}
-
-
func (o *OAuth) ClearSession(r *http.Request, w http.ResponseWriter) error {
-
userSession, err := o.store.Get(r, SessionName)
-
if err != nil || userSession.IsNew {
-
return fmt.Errorf("error getting user session (or new session?): %w", err)
+
d := userSession.Values[SessionDid].(string)
+
sessDid, err := syntax.ParseDID(d)
+
if err != nil {
+
return nil, fmt.Errorf("malformed DID in session cookie '%s': %w", d, err)
}
-
did := userSession.Values[SessionDid].(string)
+
sessId := userSession.Values[SessionId].(string)
-
err = o.sess.DeleteSession(r.Context(), did)
+
clientSess, err := o.ClientApp.ResumeSession(r.Context(), sessDid, sessId)
if err != nil {
-
return fmt.Errorf("error deleting oauth session: %w", err)
+
return nil, fmt.Errorf("failed to resume session: %w", err)
}
-
userSession.Options.MaxAge = -1
-
-
return userSession.Save(r, w)
+
return clientSess, nil
}
-
func (o *OAuth) GetSession(r *http.Request) (*sessioncache.OAuthSession, bool, error) {
-
userSession, err := o.store.Get(r, SessionName)
-
if err != nil || userSession.IsNew {
-
return nil, false, fmt.Errorf("error getting user session (or new session?): %w", err)
+
func (o *OAuth) DeleteSession(w http.ResponseWriter, r *http.Request) error {
+
userSession, err := o.SessStore.Get(r, SessionName)
+
if err != nil {
+
return fmt.Errorf("error getting user session: %w", err)
+
}
+
if userSession.IsNew {
+
return fmt.Errorf("no session available for user")
}
-
did := userSession.Values[SessionDid].(string)
-
auth := userSession.Values[SessionAuthenticated].(bool)
-
-
session, err := o.sess.GetSession(r.Context(), did)
+
d := userSession.Values[SessionDid].(string)
+
sessDid, err := syntax.ParseDID(d)
if err != nil {
-
return nil, false, fmt.Errorf("error getting oauth session: %w", err)
+
return fmt.Errorf("malformed DID in session cookie '%s': %w", d, err)
}
-
expiry, err := time.Parse(time.RFC3339, session.Expiry)
+
sessId := userSession.Values[SessionId].(string)
+
+
// delete the session
+
err1 := o.ClientApp.Logout(r.Context(), sessDid, sessId)
+
+
// remove the cookie
+
userSession.Options.MaxAge = -1
+
err2 := o.SessStore.Save(r, w, userSession)
+
+
return errors.Join(err1, err2)
+
}
+
+
func pubKeyFromJwk(jwks string) (jwk.Key, error) {
+
k, err := jwk.ParseKey([]byte(jwks))
if err != nil {
-
return nil, false, fmt.Errorf("error parsing expiry time: %w", err)
+
return nil, err
}
-
if time.Until(expiry) <= 5*time.Minute {
-
privateJwk, err := helpers.ParseJWKFromBytes([]byte(session.DpopPrivateJwk))
-
if err != nil {
-
return nil, false, err
-
}
-
-
self := o.ClientMetadata()
-
-
oauthClient, err := client.NewClient(
-
self.ClientID,
-
o.config.OAuth.Jwks,
-
self.RedirectURIs[0],
-
)
-
-
if err != nil {
-
return nil, false, err
-
}
-
-
resp, err := oauthClient.RefreshTokenRequest(r.Context(), session.RefreshJwt, session.AuthServerIss, session.DpopAuthserverNonce, privateJwk)
-
if err != nil {
-
return nil, false, err
-
}
-
-
newExpiry := time.Now().Add(time.Duration(resp.ExpiresIn) * time.Second).Format(time.RFC3339)
-
err = o.sess.RefreshSession(r.Context(), did, resp.AccessToken, resp.RefreshToken, newExpiry)
-
if err != nil {
-
return nil, false, fmt.Errorf("error refreshing oauth session: %w", err)
-
}
-
-
// update the current session
-
session.AccessJwt = resp.AccessToken
-
session.RefreshJwt = resp.RefreshToken
-
session.DpopAuthserverNonce = resp.DpopAuthserverNonce
-
session.Expiry = newExpiry
+
pubKey, err := k.PublicKey()
+
if err != nil {
+
return nil, err
}
-
-
return session, auth, nil
+
return pubKey, nil
}
type User struct {
-
Handle string
-
Did string
-
Pds string
+
Did string
+
Pds string
}
-
func (a *OAuth) GetUser(r *http.Request) *User {
-
clientSession, err := a.store.Get(r, SessionName)
+
func (o *OAuth) GetUser(r *http.Request) *User {
+
sess, err := o.SessStore.Get(r, SessionName)
-
if err != nil || clientSession.IsNew {
+
if err != nil || sess.IsNew {
return nil
}
return &User{
-
Handle: clientSession.Values[SessionHandle].(string),
-
Did: clientSession.Values[SessionDid].(string),
-
Pds: clientSession.Values[SessionPds].(string),
+
Did: sess.Values[SessionDid].(string),
+
Pds: sess.Values[SessionPds].(string),
}
}
-
func (a *OAuth) GetDid(r *http.Request) string {
-
clientSession, err := a.store.Get(r, SessionName)
-
-
if err != nil || clientSession.IsNew {
-
return ""
+
func (o *OAuth) GetDid(r *http.Request) string {
+
if u := o.GetUser(r); u != nil {
+
return u.Did
}
-
return clientSession.Values[SessionDid].(string)
+
return ""
}
-
func (o *OAuth) AuthorizedClient(r *http.Request) (*xrpc.Client, error) {
-
session, auth, err := o.GetSession(r)
+
func (o *OAuth) AuthorizedClient(r *http.Request) (*atpclient.APIClient, error) {
+
session, err := o.ResumeSession(r)
if err != nil {
return nil, fmt.Errorf("error getting session: %w", err)
}
-
if !auth {
-
return nil, fmt.Errorf("not authorized")
-
}
-
-
client := &oauth.XrpcClient{
-
OnDpopPdsNonceChanged: func(did, newNonce string) {
-
err := o.sess.UpdateNonce(r.Context(), did, newNonce)
-
if err != nil {
-
log.Printf("error updating dpop pds nonce: %v", err)
-
}
-
},
-
}
-
-
privateJwk, err := helpers.ParseJWKFromBytes([]byte(session.DpopPrivateJwk))
-
if err != nil {
-
return nil, fmt.Errorf("error parsing private jwk: %w", err)
-
}
-
-
xrpcClient := xrpc.NewClient(client, &oauth.XrpcAuthedRequestArgs{
-
Did: session.Did,
-
PdsUrl: session.PdsUrl,
-
DpopPdsNonce: session.PdsUrl,
-
AccessToken: session.AccessJwt,
-
Issuer: session.AuthServerIss,
-
DpopPrivateJwk: privateJwk,
-
})
-
-
return xrpcClient, nil
+
return session.APIClient(), nil
}
-
// use this to create a client to communicate with knots or spindles
-
//
// this is a higher level abstraction on ServerGetServiceAuth
type ServiceClientOpts struct {
service string
···
return scheme + s.service
}
-
func (o *OAuth) ServiceClient(r *http.Request, os ...ServiceClientOpt) (*indigo_xrpc.Client, error) {
+
func (o *OAuth) ServiceClient(r *http.Request, os ...ServiceClientOpt) (*xrpc.Client, error) {
opts := ServiceClientOpts{}
for _, o := range os {
o(&opts)
}
-
authorizedClient, err := o.AuthorizedClient(r)
+
client, err := o.AuthorizedClient(r)
if err != nil {
return nil, err
}
···
opts.exp = sixty
}
-
resp, err := authorizedClient.ServerGetServiceAuth(r.Context(), opts.Audience(), opts.exp, opts.lxm)
+
resp, err := comatproto.ServerGetServiceAuth(r.Context(), client, opts.Audience(), opts.exp, opts.lxm)
if err != nil {
return nil, err
}
-
return &indigo_xrpc.Client{
-
Auth: &indigo_xrpc.AuthInfo{
+
return &xrpc.Client{
+
Auth: &xrpc.AuthInfo{
AccessJwt: resp.Token,
},
Host: opts.Host(),
···
},
}, nil
}
-
-
type ClientMetadata struct {
-
ClientID string `json:"client_id"`
-
ClientName string `json:"client_name"`
-
SubjectType string `json:"subject_type"`
-
ClientURI string `json:"client_uri"`
-
RedirectURIs []string `json:"redirect_uris"`
-
GrantTypes []string `json:"grant_types"`
-
ResponseTypes []string `json:"response_types"`
-
ApplicationType string `json:"application_type"`
-
DpopBoundAccessTokens bool `json:"dpop_bound_access_tokens"`
-
JwksURI string `json:"jwks_uri"`
-
Scope string `json:"scope"`
-
TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"`
-
TokenEndpointAuthSigningAlg string `json:"token_endpoint_auth_signing_alg"`
-
}
-
-
func (o *OAuth) ClientMetadata() ClientMetadata {
-
makeRedirectURIs := func(c string) []string {
-
return []string{fmt.Sprintf("%s/oauth/callback", c)}
-
}
-
-
clientURI := o.config.Core.AppviewHost
-
clientID := fmt.Sprintf("%s/oauth/client-metadata.json", clientURI)
-
redirectURIs := makeRedirectURIs(clientURI)
-
-
if o.config.Core.Dev {
-
clientURI = "http://127.0.0.1:3000"
-
redirectURIs = makeRedirectURIs(clientURI)
-
-
query := url.Values{}
-
query.Add("redirect_uri", redirectURIs[0])
-
query.Add("scope", "atproto transition:generic")
-
clientID = fmt.Sprintf("http://localhost?%s", query.Encode())
-
}
-
-
jwksURI := fmt.Sprintf("%s/oauth/jwks.json", clientURI)
-
-
return ClientMetadata{
-
ClientID: clientID,
-
ClientName: "Tangled",
-
SubjectType: "public",
-
ClientURI: clientURI,
-
RedirectURIs: redirectURIs,
-
GrantTypes: []string{"authorization_code", "refresh_token"},
-
ResponseTypes: []string{"code"},
-
ApplicationType: "web",
-
DpopBoundAccessTokens: true,
-
JwksURI: jwksURI,
-
Scope: "atproto transition:generic",
-
TokenEndpointAuthMethod: "private_key_jwt",
-
TokenEndpointAuthSigningAlg: "ES256",
-
}
-
}
+147
appview/oauth/store.go
···
+
package oauth
+
+
import (
+
"context"
+
"encoding/json"
+
"fmt"
+
"time"
+
+
"github.com/bluesky-social/indigo/atproto/auth/oauth"
+
"github.com/bluesky-social/indigo/atproto/syntax"
+
"github.com/redis/go-redis/v9"
+
)
+
+
// redis-backed implementation of ClientAuthStore.
+
type RedisStore struct {
+
client *redis.Client
+
SessionTTL time.Duration
+
AuthRequestTTL time.Duration
+
}
+
+
var _ oauth.ClientAuthStore = &RedisStore{}
+
+
func NewRedisStore(redisURL string) (*RedisStore, error) {
+
opts, err := redis.ParseURL(redisURL)
+
if err != nil {
+
return nil, fmt.Errorf("failed to parse redis URL: %w", err)
+
}
+
+
client := redis.NewClient(opts)
+
+
// test the connection
+
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+
defer cancel()
+
+
if err := client.Ping(ctx).Err(); err != nil {
+
return nil, fmt.Errorf("failed to connect to redis: %w", err)
+
}
+
+
return &RedisStore{
+
client: client,
+
SessionTTL: 30 * 24 * time.Hour, // 30 days
+
AuthRequestTTL: 10 * time.Minute, // 10 minutes
+
}, nil
+
}
+
+
func (r *RedisStore) Close() error {
+
return r.client.Close()
+
}
+
+
func sessionKey(did syntax.DID, sessionID string) string {
+
return fmt.Sprintf("oauth:session:%s:%s", did, sessionID)
+
}
+
+
func authRequestKey(state string) string {
+
return fmt.Sprintf("oauth:auth_request:%s", state)
+
}
+
+
func (r *RedisStore) GetSession(ctx context.Context, did syntax.DID, sessionID string) (*oauth.ClientSessionData, error) {
+
key := sessionKey(did, sessionID)
+
data, err := r.client.Get(ctx, key).Bytes()
+
if err == redis.Nil {
+
return nil, fmt.Errorf("session not found: %s", did)
+
}
+
if err != nil {
+
return nil, fmt.Errorf("failed to get session: %w", err)
+
}
+
+
var sess oauth.ClientSessionData
+
if err := json.Unmarshal(data, &sess); err != nil {
+
return nil, fmt.Errorf("failed to unmarshal session: %w", err)
+
}
+
+
return &sess, nil
+
}
+
+
func (r *RedisStore) SaveSession(ctx context.Context, sess oauth.ClientSessionData) error {
+
key := sessionKey(sess.AccountDID, sess.SessionID)
+
+
data, err := json.Marshal(sess)
+
if err != nil {
+
return fmt.Errorf("failed to marshal session: %w", err)
+
}
+
+
if err := r.client.Set(ctx, key, data, r.SessionTTL).Err(); err != nil {
+
return fmt.Errorf("failed to save session: %w", err)
+
}
+
+
return nil
+
}
+
+
func (r *RedisStore) DeleteSession(ctx context.Context, did syntax.DID, sessionID string) error {
+
key := sessionKey(did, sessionID)
+
if err := r.client.Del(ctx, key).Err(); err != nil {
+
return fmt.Errorf("failed to delete session: %w", err)
+
}
+
return nil
+
}
+
+
func (r *RedisStore) GetAuthRequestInfo(ctx context.Context, state string) (*oauth.AuthRequestData, error) {
+
key := authRequestKey(state)
+
data, err := r.client.Get(ctx, key).Bytes()
+
if err == redis.Nil {
+
return nil, fmt.Errorf("request info not found: %s", state)
+
}
+
if err != nil {
+
return nil, fmt.Errorf("failed to get auth request: %w", err)
+
}
+
+
var req oauth.AuthRequestData
+
if err := json.Unmarshal(data, &req); err != nil {
+
return nil, fmt.Errorf("failed to unmarshal auth request: %w", err)
+
}
+
+
return &req, nil
+
}
+
+
func (r *RedisStore) SaveAuthRequestInfo(ctx context.Context, info oauth.AuthRequestData) error {
+
key := authRequestKey(info.State)
+
+
// check if already exists (to match MemStore behavior)
+
exists, err := r.client.Exists(ctx, key).Result()
+
if err != nil {
+
return fmt.Errorf("failed to check auth request existence: %w", err)
+
}
+
if exists > 0 {
+
return fmt.Errorf("auth request already saved for state %s", info.State)
+
}
+
+
data, err := json.Marshal(info)
+
if err != nil {
+
return fmt.Errorf("failed to marshal auth request: %w", err)
+
}
+
+
if err := r.client.Set(ctx, key, data, r.AuthRequestTTL).Err(); err != nil {
+
return fmt.Errorf("failed to save auth request: %w", err)
+
}
+
+
return nil
+
}
+
+
func (r *RedisStore) DeleteAuthRequestInfo(ctx context.Context, state string) error {
+
key := authRequestKey(state)
+
if err := r.client.Del(ctx, key).Err(); err != nil {
+
return fmt.Errorf("failed to delete auth request: %w", err)
+
}
+
return nil
+
}
+1 -1
appview/pages/templates/layouts/fragments/topbar.html
···
<summary
class="cursor-pointer list-none flex items-center gap-1"
>
-
{{ $user := didOrHandle .Did .Handle }}
+
{{ $user := .Did }}
<img
src="{{ tinyAvatar $user }}"
alt=""
+1 -1
appview/pages/templates/repo/pulls/fragments/pullNewComment.html
···
id="pull-comment-card-{{ .RoundNumber }}"
class="bg-white dark:bg-gray-800 rounded drop-shadow-sm p-4 relative w-full flex flex-col gap-2">
<div class="text-sm text-gray-500 dark:text-gray-400">
-
{{ didOrHandle .LoggedInUser.Did .LoggedInUser.Handle }}
+
{{ resolve .LoggedInUser.Did }}
</div>
<form
hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ .RoundNumber }}/comment"
+1 -3
appview/pages/templates/user/settings/profile.html
···
<div class="flex flex-wrap text-sm items-center gap-1 text-gray-500 dark:text-gray-400">
<span>Handle</span>
</div>
-
{{ if .LoggedInUser.Handle }}
<span class="font-bold">
-
@{{ .LoggedInUser.Handle }}
+
{{ resolve .LoggedInUser.Did }}
</span>
-
{{ end }}
</div>
</div>
<div class="flex items-center justify-between p-4">
+2 -1
appview/pipelines/pipelines.go
···
) *Pipelines {
logger := log.New("pipelines")
-
return &Pipelines{oauth: oauth,
+
return &Pipelines{
+
oauth: oauth,
repoResolver: repoResolver,
pages: pages,
idResolver: idResolver,
+11 -10
appview/repo/artifact.go
···
"net/url"
"time"
-
comatproto "github.com/bluesky-social/indigo/api/atproto"
-
lexutil "github.com/bluesky-social/indigo/lex/util"
-
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
-
"github.com/dustin/go-humanize"
-
"github.com/go-chi/chi/v5"
-
"github.com/go-git/go-git/v5/plumbing"
-
"github.com/ipfs/go-cid"
"tangled.org/core/api/tangled"
"tangled.org/core/appview/db"
"tangled.org/core/appview/models"
···
"tangled.org/core/appview/xrpcclient"
"tangled.org/core/tid"
"tangled.org/core/types"
+
+
comatproto "github.com/bluesky-social/indigo/api/atproto"
+
lexutil "github.com/bluesky-social/indigo/lex/util"
+
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
+
"github.com/dustin/go-humanize"
+
"github.com/go-chi/chi/v5"
+
"github.com/go-git/go-git/v5/plumbing"
+
"github.com/ipfs/go-cid"
)
// TODO: proper statuses here on early exit
···
return
}
-
uploadBlobResp, err := client.RepoUploadBlob(r.Context(), file)
+
uploadBlobResp, err := comatproto.RepoUploadBlob(r.Context(), client, file)
if err != nil {
log.Println("failed to upload blob", err)
rp.pages.Notice(w, "upload", "Failed to upload blob to your PDS. Try again later.")
···
rkey := tid.TID()
createdAt := time.Now()
-
putRecordResp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
+
putRecordResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
Collection: tangled.RepoArtifactNSID,
Repo: user.Did,
Rkey: rkey,
···
return
}
-
_, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
+
_, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
Collection: tangled.RepoArtifactNSID,
Repo: user.Did,
Rkey: artifact.Rkey,
+28 -35
appview/repo/repo.go
···
"strings"
"time"
-
comatproto "github.com/bluesky-social/indigo/api/atproto"
-
lexutil "github.com/bluesky-social/indigo/lex/util"
-
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
"tangled.org/core/api/tangled"
"tangled.org/core/appview/commitverify"
"tangled.org/core/appview/config"
···
"tangled.org/core/types"
"tangled.org/core/xrpc/serviceauth"
+
comatproto "github.com/bluesky-social/indigo/api/atproto"
+
atpclient "github.com/bluesky-social/indigo/atproto/client"
+
"github.com/bluesky-social/indigo/atproto/syntax"
+
lexutil "github.com/bluesky-social/indigo/lex/util"
+
indigoxrpc "github.com/bluesky-social/indigo/xrpc"
securejoin "github.com/cyphar/filepath-securejoin"
"github.com/go-chi/chi/v5"
"github.com/go-git/go-git/v5/plumbing"
-
-
"github.com/bluesky-social/indigo/atproto/syntax"
)
type Repo struct {
···
// this is a bit of a pain because the golang atproto impl does not allow nil SwapRecord field
//
// SwapRecord is optional and should happen automagically, but given that it does not, we have to perform two requests
-
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey)
+
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey)
if err != nil {
// failed to get record
rp.pages.Notice(w, "repo-notice", "Failed to update description, no record found on PDS.")
return
}
-
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
+
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
Collection: tangled.RepoNSID,
Repo: newRepo.Did,
Rkey: newRepo.Rkey,
···
user := rp.oauth.GetUser(r)
l := rp.logger.With("handler", "EditSpindle")
l = l.With("did", user.Did)
-
l = l.With("handle", user.Handle)
errorId := "operation-error"
fail := func(msg string, err error) {
···
return
}
-
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey)
+
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey)
if err != nil {
fail("Failed to update spindle, no record found on PDS.", err)
return
}
-
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
+
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
Collection: tangled.RepoNSID,
Repo: newRepo.Did,
Rkey: newRepo.Rkey,
···
user := rp.oauth.GetUser(r)
l := rp.logger.With("handler", "AddLabel")
l = l.With("did", user.Did)
-
l = l.With("handle", user.Handle)
f, err := rp.repoResolver.Resolve(r)
if err != nil {
···
// emit a labelRecord
labelRecord := label.AsRecord()
-
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
+
resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
Collection: tangled.LabelDefinitionNSID,
Repo: label.Did,
Rkey: label.Rkey,
···
newRepo.Labels = append(newRepo.Labels, aturi)
repoRecord := newRepo.AsRecord()
-
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey)
+
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey)
if err != nil {
fail("Failed to update labels, no record found on PDS.", err)
return
-
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
+
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
Collection: tangled.RepoNSID,
Repo: newRepo.Did,
Rkey: newRepo.Rkey,
···
user := rp.oauth.GetUser(r)
l := rp.logger.With("handler", "DeleteLabel")
l = l.With("did", user.Did)
-
l = l.With("handle", user.Handle)
f, err := rp.repoResolver.Resolve(r)
if err != nil {
···
// delete label record from PDS
-
_, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
+
_, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
Collection: tangled.LabelDefinitionNSID,
Repo: label.Did,
Rkey: label.Rkey,
···
newRepo.Labels = updated
repoRecord := newRepo.AsRecord()
-
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey)
+
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey)
if err != nil {
fail("Failed to update labels, no record found on PDS.", err)
return
-
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
+
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
Collection: tangled.RepoNSID,
Repo: newRepo.Did,
Rkey: newRepo.Rkey,
···
user := rp.oauth.GetUser(r)
l := rp.logger.With("handler", "SubscribeLabel")
l = l.With("did", user.Did)
-
l = l.With("handle", user.Handle)
f, err := rp.repoResolver.Resolve(r)
if err != nil {
···
return
-
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, f.Repo.Did, f.Repo.Rkey)
+
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, f.Repo.Did, f.Repo.Rkey)
if err != nil {
fail("Failed to update labels, no record found on PDS.", err)
return
-
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
+
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
Collection: tangled.RepoNSID,
Repo: newRepo.Did,
Rkey: newRepo.Rkey,
···
user := rp.oauth.GetUser(r)
l := rp.logger.With("handler", "UnsubscribeLabel")
l = l.With("did", user.Did)
-
l = l.With("handle", user.Handle)
f, err := rp.repoResolver.Resolve(r)
if err != nil {
···
return
-
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, f.Repo.Did, f.Repo.Rkey)
+
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, f.Repo.Did, f.Repo.Rkey)
if err != nil {
fail("Failed to update labels, no record found on PDS.", err)
return
-
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
+
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
Collection: tangled.RepoNSID,
Repo: newRepo.Did,
Rkey: newRepo.Rkey,
···
user := rp.oauth.GetUser(r)
l := rp.logger.With("handler", "AddCollaborator")
l = l.With("did", user.Did)
-
l = l.With("handle", user.Handle)
f, err := rp.repoResolver.Resolve(r)
if err != nil {
···
currentUser := rp.oauth.GetUser(r)
rkey := tid.TID()
createdAt := time.Now()
-
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
+
resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
Collection: tangled.RepoCollaboratorNSID,
Repo: currentUser.Did,
Rkey: rkey,
···
// remove record from pds
-
xrpcClient, err := rp.oauth.AuthorizedClient(r)
+
atpClient, err := rp.oauth.AuthorizedClient(r)
if err != nil {
log.Println("failed to get authorized client", err)
return
-
_, err = xrpcClient.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
+
_, err = comatproto.RepoDeleteRecord(r.Context(), atpClient, &comatproto.RepoDeleteRecord_Input{
Collection: tangled.RepoNSID,
Repo: user.Did,
Rkey: f.Rkey,
···
func (rp *Repo) Secrets(w http.ResponseWriter, r *http.Request) {
user := rp.oauth.GetUser(r)
l := rp.logger.With("handler", "Secrets")
-
l = l.With("handle", user.Handle)
l = l.With("did", user.Did)
f, err := rp.repoResolver.Resolve(r)
···
record := repo.AsRecord()
-
xrpcClient, err := rp.oauth.AuthorizedClient(r)
+
atpClient, err := rp.oauth.AuthorizedClient(r)
if err != nil {
l.Error("failed to create xrpcclient", "err", err)
rp.pages.Notice(w, "repo", "Failed to fork repository.")
return
-
atresp, err := xrpcClient.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
+
atresp, err := comatproto.RepoPutRecord(r.Context(), atpClient, &comatproto.RepoPutRecord_Input{
Collection: tangled.RepoNSID,
Repo: user.Did,
Rkey: rkey,
···
rollback := func() {
err1 := tx.Rollback()
err2 := rp.enforcer.E.LoadPolicy()
-
err3 := rollbackRecord(context.Background(), aturi, xrpcClient)
+
err3 := rollbackRecord(context.Background(), aturi, atpClient)
// ignore txn complete errors, this is okay
if errors.Is(err1, sql.ErrTxDone) {
···
aturi = ""
rp.notifier.NewRepo(r.Context(), repo)
-
rp.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, forkName))
+
rp.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Did, forkName))
// this is used to rollback changes made to the PDS
//
// it is a no-op if the provided ATURI is empty
-
func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error {
+
func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error {
if aturi == "" {
return nil
···
repo := parsed.Authority().String()
rkey := parsed.RecordKey().String()
-
_, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{
+
_, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{
Collection: collection,
Repo: repo,
Rkey: rkey,
+2 -2
appview/settings/settings.go
···
}
// store in pds too
-
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
+
resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
Collection: tangled.PublicKeyNSID,
Repo: did,
Rkey: rkey,
···
if rkey != "" {
// remove from pds too
-
_, err := client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
+
_, err := comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
Collection: tangled.PublicKeyNSID,
Repo: did,
Rkey: rkey,
-2
appview/signup/signup.go
···
"tangled.org/core/appview/models"
"tangled.org/core/appview/pages"
"tangled.org/core/appview/state/userutil"
-
"tangled.org/core/appview/xrpcclient"
"tangled.org/core/idresolver"
)
···
db *db.DB
cf *dns.Cloudflare
posthog posthog.Client
-
xrpc *xrpcclient.Client
idResolver *idresolver.Resolver
pages *pages.Pages
l *slog.Logger
+5 -5
appview/spindles/spindles.go
···
return
}
-
ex, _ := client.RepoGetRecord(r.Context(), "", tangled.SpindleNSID, user.Did, instance)
+
ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.SpindleNSID, user.Did, instance)
var exCid *string
if ex != nil {
exCid = ex.Cid
}
// re-announce by registering under same rkey
-
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
+
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
Collection: tangled.SpindleNSID,
Repo: user.Did,
Rkey: instance,
···
return
}
-
_, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
+
_, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
Collection: tangled.SpindleNSID,
Repo: user.Did,
Rkey: instance,
···
return
}
-
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
+
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
Collection: tangled.SpindleMemberNSID,
Repo: user.Did,
Rkey: rkey,
···
}
// remove from pds
-
_, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
+
_, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
Collection: tangled.SpindleMemberNSID,
Repo: user.Did,
Rkey: members[0].Rkey,
+2 -2
appview/state/follow.go
···
case http.MethodPost:
createdAt := time.Now().Format(time.RFC3339)
rkey := tid.TID()
-
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
+
resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
Collection: tangled.GraphFollowNSID,
Repo: currentUser.Did,
Rkey: rkey,
···
return
}
-
_, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
+
_, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
Collection: tangled.GraphFollowNSID,
Repo: currentUser.Did,
Rkey: follow.Rkey,
+63
appview/state/login.go
···
+
package state
+
+
import (
+
"fmt"
+
"log"
+
"net/http"
+
"strings"
+
+
"tangled.org/core/appview/pages"
+
)
+
+
func (s *State) Login(w http.ResponseWriter, r *http.Request) {
+
switch r.Method {
+
case http.MethodGet:
+
returnURL := r.URL.Query().Get("return_url")
+
s.pages.Login(w, pages.LoginParams{
+
ReturnUrl: returnURL,
+
})
+
case http.MethodPost:
+
handle := r.FormValue("handle")
+
+
// when users copy their handle from bsky.app, it tends to have these characters around it:
+
//
+
// @nelind.dk:
+
// \u202a ensures that the handle is always rendered left to right and
+
// \u202c reverts that so the rest of the page renders however it should
+
handle = strings.TrimPrefix(handle, "\u202a")
+
handle = strings.TrimSuffix(handle, "\u202c")
+
+
// `@` is harmless
+
handle = strings.TrimPrefix(handle, "@")
+
+
// basic handle validation
+
if !strings.Contains(handle, ".") {
+
log.Println("invalid handle format", "raw", handle)
+
s.pages.Notice(
+
w,
+
"login-msg",
+
fmt.Sprintf("\"%s\" is an invalid handle. Did you mean %s.bsky.social or %s.tngl.sh?", handle, handle, handle),
+
)
+
return
+
}
+
+
redirectURL, err := s.oauth.ClientApp.StartAuthFlow(r.Context(), handle)
+
if err != nil {
+
http.Error(w, err.Error(), http.StatusInternalServerError)
+
return
+
}
+
+
s.pages.HxRedirect(w, redirectURL)
+
}
+
}
+
+
func (s *State) Logout(w http.ResponseWriter, r *http.Request) {
+
err := s.oauth.DeleteSession(w, r)
+
if err != nil {
+
log.Println("failed to logout", "err", err)
+
} else {
+
log.Println("logged out successfully")
+
}
+
+
s.pages.HxRedirect(w, "/login")
+
}
+2 -2
appview/state/profile.go
···
vanityStats = append(vanityStats, string(v.Kind))
}
-
ex, _ := client.RepoGetRecord(r.Context(), "", tangled.ActorProfileNSID, user.Did, "self")
+
ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.ActorProfileNSID, user.Did, "self")
var cid *string
if ex != nil {
cid = ex.Cid
}
-
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
+
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
Collection: tangled.ActorProfileNSID,
Repo: user.Did,
Rkey: "self",
+5 -10
appview/state/router.go
···
"strings"
"github.com/go-chi/chi/v5"
-
"github.com/gorilla/sessions"
"tangled.org/core/appview/issues"
"tangled.org/core/appview/knots"
"tangled.org/core/appview/labels"
"tangled.org/core/appview/middleware"
"tangled.org/core/appview/notifications"
-
oauthhandler "tangled.org/core/appview/oauth/handler"
"tangled.org/core/appview/pipelines"
"tangled.org/core/appview/pulls"
"tangled.org/core/appview/repo"
···
s.pages,
)
-
router.Use(middleware.TryRefreshSession())
router.Get("/favicon.svg", s.Favicon)
router.Get("/favicon.ico", s.Favicon)
router.Get("/pwa-manifest.json", s.PWAManifest)
···
// special-case handler for serving tangled.org/core
r.Get("/core", s.Core())
+
r.Get("/login", s.Login)
+
r.Post("/login", s.Login)
+
r.Post("/logout", s.Logout)
+
r.Route("/repo", func(r chi.Router) {
r.Route("/new", func(r chi.Router) {
r.Use(middleware.AuthMiddleware(s.oauth))
···
r.Mount("/notifications", s.NotificationsRouter(mw))
r.Mount("/signup", s.SignupRouter())
-
r.Mount("/", s.OAuthRouter())
+
r.Mount("/", s.oauth.Router())
r.Get("/keys/{user}", s.Keys)
r.Get("/terms", s.TermsOfService)
···
}
}
-
func (s *State) OAuthRouter() http.Handler {
-
store := sessions.NewCookieStore([]byte(s.config.Core.CookieSecret))
-
oauth := oauthhandler.New(s.config, s.pages, s.idResolver, s.db, s.sess, store, s.oauth, s.enforcer, s.posthog)
-
return oauth.Router()
-
}
-
func (s *State) SettingsRouter() http.Handler {
settings := &settings.Settings{
Db: s.db,
+2 -2
appview/state/star.go
···
case http.MethodPost:
createdAt := time.Now().Format(time.RFC3339)
rkey := tid.TID()
-
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
+
resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
Collection: tangled.FeedStarNSID,
Repo: currentUser.Did,
Rkey: rkey,
···
return
}
-
_, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
+
_, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
Collection: tangled.FeedStarNSID,
Repo: currentUser.Did,
Rkey: star.Rkey,
+9 -7
appview/strings/strings.go
···
"github.com/bluesky-social/indigo/api/atproto"
"github.com/bluesky-social/indigo/atproto/identity"
"github.com/bluesky-social/indigo/atproto/syntax"
-
lexutil "github.com/bluesky-social/indigo/lex/util"
"github.com/go-chi/chi/v5"
+
+
comatproto "github.com/bluesky-social/indigo/api/atproto"
+
lexutil "github.com/bluesky-social/indigo/lex/util"
)
type Strings struct {
···
}
// first replace the existing record in the PDS
-
ex, err := client.RepoGetRecord(r.Context(), "", tangled.StringNSID, entry.Did.String(), entry.Rkey)
+
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.StringNSID, entry.Did.String(), entry.Rkey)
if err != nil {
fail("Failed to updated existing record.", err)
return
}
-
resp, err := client.RepoPutRecord(r.Context(), &atproto.RepoPutRecord_Input{
+
resp, err := comatproto.RepoPutRecord(r.Context(), client, &atproto.RepoPutRecord_Input{
Collection: tangled.StringNSID,
Repo: entry.Did.String(),
Rkey: entry.Rkey,
···
s.Notifier.EditString(r.Context(), &entry)
// if that went okay, redir to the string
-
s.Pages.HxRedirect(w, "/strings/"+user.Handle+"/"+entry.Rkey)
+
s.Pages.HxRedirect(w, "/strings/"+user.Did+"/"+entry.Rkey)
}
}
···
return
}
-
resp, err := client.RepoPutRecord(r.Context(), &atproto.RepoPutRecord_Input{
+
resp, err := comatproto.RepoPutRecord(r.Context(), client, &atproto.RepoPutRecord_Input{
Collection: tangled.StringNSID,
Repo: user.Did,
Rkey: string.Rkey,
···
s.Notifier.NewString(r.Context(), &string)
// successful
-
s.Pages.HxRedirect(w, "/strings/"+user.Handle+"/"+string.Rkey)
+
s.Pages.HxRedirect(w, "/strings/"+user.Did+"/"+string.Rkey)
}
}
···
s.Notifier.DeleteString(r.Context(), user.Did, rkey)
-
s.Pages.HxRedirect(w, "/strings/"+user.Handle)
+
s.Pages.HxRedirect(w, "/strings/"+user.Did)
}
func (s *Strings) comment(w http.ResponseWriter, r *http.Request) {
-99
appview/xrpcclient/xrpc.go
···
package xrpcclient
import (
-
"bytes"
-
"context"
"errors"
-
"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.org/anirudh.fi/atproto-oauth"
)
var (
···
ErrXrpcInvalid = errors.New("invalid xrpc request")
)
-
type Client struct {
-
*oauth.XrpcClient
-
authArgs *oauth.XrpcAuthedRequestArgs
-
}
-
-
func NewClient(client *oauth.XrpcClient, authArgs *oauth.XrpcAuthedRequestArgs) *Client {
-
return &Client{
-
XrpcClient: client,
-
authArgs: authArgs,
-
}
-
}
-
-
func (c *Client) RepoPutRecord(ctx context.Context, input *atproto.RepoPutRecord_Input) (*atproto.RepoPutRecord_Output, error) {
-
var out atproto.RepoPutRecord_Output
-
if err := c.Do(ctx, c.authArgs, xrpc.Procedure, "application/json", "com.atproto.repo.putRecord", nil, input, &out); err != nil {
-
return nil, err
-
}
-
-
return &out, nil
-
}
-
-
func (c *Client) RepoApplyWrites(ctx context.Context, input *atproto.RepoApplyWrites_Input) (*atproto.RepoApplyWrites_Output, error) {
-
var out atproto.RepoApplyWrites_Output
-
if err := c.Do(ctx, c.authArgs, xrpc.Procedure, "application/json", "com.atproto.repo.applyWrites", nil, input, &out); err != nil {
-
return nil, err
-
}
-
-
return &out, nil
-
}
-
-
func (c *Client) RepoGetRecord(ctx context.Context, cid string, collection string, repo string, rkey string) (*atproto.RepoGetRecord_Output, error) {
-
var out atproto.RepoGetRecord_Output
-
-
params := map[string]interface{}{
-
"cid": cid,
-
"collection": collection,
-
"repo": repo,
-
"rkey": rkey,
-
}
-
if err := c.Do(ctx, c.authArgs, xrpc.Query, "", "com.atproto.repo.getRecord", params, nil, &out); err != nil {
-
return nil, err
-
}
-
-
return &out, nil
-
}
-
-
func (c *Client) RepoUploadBlob(ctx context.Context, input io.Reader) (*atproto.RepoUploadBlob_Output, error) {
-
var out atproto.RepoUploadBlob_Output
-
if err := c.Do(ctx, c.authArgs, xrpc.Procedure, "*/*", "com.atproto.repo.uploadBlob", nil, input, &out); err != nil {
-
return nil, err
-
}
-
-
return &out, nil
-
}
-
-
func (c *Client) SyncGetBlob(ctx context.Context, cid string, did string) ([]byte, error) {
-
buf := new(bytes.Buffer)
-
-
params := map[string]interface{}{
-
"cid": cid,
-
"did": did,
-
}
-
if err := c.Do(ctx, c.authArgs, xrpc.Query, "", "com.atproto.sync.getBlob", params, nil, buf); err != nil {
-
return nil, err
-
}
-
-
return buf.Bytes(), nil
-
}
-
-
func (c *Client) RepoDeleteRecord(ctx context.Context, input *atproto.RepoDeleteRecord_Input) (*atproto.RepoDeleteRecord_Output, error) {
-
var out atproto.RepoDeleteRecord_Output
-
if err := c.Do(ctx, c.authArgs, xrpc.Procedure, "application/json", "com.atproto.repo.deleteRecord", nil, input, &out); err != nil {
-
return nil, err
-
}
-
-
return &out, nil
-
}
-
-
func (c *Client) ServerGetServiceAuth(ctx context.Context, aud string, exp int64, lxm string) (*atproto.ServerGetServiceAuth_Output, error) {
-
var out atproto.ServerGetServiceAuth_Output
-
-
params := map[string]interface{}{
-
"aud": aud,
-
"exp": exp,
-
"lxm": lxm,
-
}
-
if err := c.Do(ctx, c.authArgs, xrpc.Query, "", "com.atproto.server.getServiceAuth", params, nil, &out); err != nil {
-
return nil, err
-
}
-
-
return &out, nil
-
}
-
// produces a more manageable error
func HandleXrpcErr(err error) error {
if err == nil {
+1 -1
go.mod
···
github.com/alecthomas/chroma/v2 v2.15.0
github.com/avast/retry-go/v4 v4.6.1
github.com/bluekeyes/go-gitdiff v0.8.1
-
github.com/bluesky-social/indigo v0.0.0-20250724221105-5827c8fb61bb
+
github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e
github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1
github.com/carlmjohnson/versioninfo v0.22.5
github.com/casbin/casbin/v2 v2.103.0
+2
go.sum
···
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bluesky-social/indigo v0.0.0-20250724221105-5827c8fb61bb h1:BqMNDZMfXwiRTJ6NvQotJ0qInn37JH5U8E+TF01CFHQ=
github.com/bluesky-social/indigo v0.0.0-20250724221105-5827c8fb61bb/go.mod h1:0XUyOCRtL4/OiyeqMTmr6RlVHQMDgw3LS7CfibuZR5Q=
+
github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e h1:IutKPwmbU0LrYqw03EuwJtMdAe67rDTrL1U8S8dicRU=
+
github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e/go.mod h1:n6QE1NDPFoi7PRbMUZmc2y7FibCqiVU4ePpsvhHUBR8=
github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 h1:CFvRtYNSnWRAi/98M3O466t9dYuwtesNbu6FVPymRrA=
github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1/go.mod h1:WiYEeyJSdUwqoaZ71KJSpTblemUCpwJfh5oVXplK6T4=
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
+1 -1
appview/pages/templates/repo/fragments/labelPanel.html
···
{{ define "repo/fragments/labelPanel" }}
-
<div id="label-panel" class="flex flex-col gap-6 px-6 md:px-0">
+
<div id="label-panel" class="flex flex-col gap-6 px-2 md:px-0">
{{ template "basicLabels" . }}
{{ template "kvLabels" . }}
</div>
+1 -1
appview/pages/templates/repo/fragments/participants.html
···
{{ define "repo/fragments/participants" }}
{{ $all := . }}
{{ $ps := take $all 5 }}
-
<div class="px-6 md:px-0">
+
<div class="px-2 md:px-0">
<div class="py-1 flex items-center text-sm">
<span class="font-bold text-gray-500 dark:text-gray-400 capitalize">Participants</span>
<span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 ml-1">{{ len $all }}</span>
+7 -2
appview/pages/templates/repo/issues/fragments/newComment.html
···
</div>
</form>
{{ else }}
-
<div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-4 px-4 relative w-fit">
-
<a href="/login" class="underline">login</a> to join the discussion
+
<div class="bg-amber-50 dark:bg-amber-900 border border-amber-500 rounded drop-shadow-sm p-6 relative flex gap-2 items-center">
+
<a href="/signup" class="btn-create py-0 hover:no-underline hover:text-white flex items-center gap-2">
+
sign up
+
</a>
+
<span class="text-gray-500 dark:text-gray-400">or</span>
+
<a href="/login" class="underline">login</a>
+
to add to the discussion
</div>
{{ end }}
{{ end }}
+7 -3
appview/pages/templates/repo/pulls/pull.html
···
{{ if $.LoggedInUser }}
{{ template "repo/pulls/fragments/pullActions" (dict "LoggedInUser" $.LoggedInUser "Pull" $.Pull "RepoInfo" $.RepoInfo "RoundNumber" .RoundNumber "MergeCheck" $.MergeCheck "ResubmitCheck" $.ResubmitCheck "Stack" $.Stack) }}
{{ else }}
-
<div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm px-6 py-4 w-fit dark:text-white">
-
<div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div>
-
<a href="/login" class="underline">login</a> to join the discussion
+
<div class="bg-amber-50 dark:bg-amber-900 border border-amber-500 rounded drop-shadow-sm p-2 relative flex gap-2 items-center w-fit">
+
<a href="/signup" class="btn-create py-0 hover:no-underline hover:text-white flex items-center gap-2">
+
sign up
+
</a>
+
<span class="text-gray-500 dark:text-gray-400">or</span>
+
<a href="/login" class="underline">login</a>
+
to add to the discussion
</div>
{{ end }}
</div>
+34 -7
appview/db/reaction.go
···
return count, nil
}
-
func GetReactionCountMap(e Execer, threadAt syntax.ATURI) (map[models.ReactionKind]int, error) {
-
countMap := map[models.ReactionKind]int{}
+
func GetReactionMap(e Execer, userLimit int, threadAt syntax.ATURI) (map[models.ReactionKind]models.ReactionDisplayData, error) {
+
query := `
+
select kind, reacted_by_did,
+
row_number() over (partition by kind order by created asc) as rn,
+
count(*) over (partition by kind) as total
+
from reactions
+
where thread_at = ?
+
order by kind, created asc`
+
+
rows, err := e.Query(query, threadAt)
+
if err != nil {
+
return nil, err
+
}
+
defer rows.Close()
+
+
reactionMap := map[models.ReactionKind]models.ReactionDisplayData{}
for _, kind := range models.OrderedReactionKinds {
-
count, err := GetReactionCount(e, threadAt, kind)
-
if err != nil {
-
return map[models.ReactionKind]int{}, nil
+
reactionMap[kind] = models.ReactionDisplayData{Count: 0, Users: []string{}}
+
}
+
+
for rows.Next() {
+
var kind models.ReactionKind
+
var did string
+
var rn, total int
+
if err := rows.Scan(&kind, &did, &rn, &total); err != nil {
+
return nil, err
}
-
countMap[kind] = count
+
+
data := reactionMap[kind]
+
data.Count = total
+
if userLimit > 0 && rn <= userLimit {
+
data.Users = append(data.Users, did)
+
}
+
reactionMap[kind] = data
}
-
return countMap, nil
+
+
return reactionMap, rows.Err()
}
func GetReactionStatus(e Execer, userDid string, threadAt syntax.ATURI, kind models.ReactionKind) bool {
+5
appview/models/reaction.go
···
Rkey string
Kind ReactionKind
}
+
+
type ReactionDisplayData struct {
+
Count int
+
Users []string
+
}
+6 -1
appview/pages/templates/repo/fragments/reaction.html
···
<button
id="reactIndi-{{ .Kind }}"
class="flex justify-center items-center min-w-8 min-h-8 rounded border
-
leading-4 px-3 gap-1
+
leading-4 px-3 gap-1 relative group
{{ if eq .Count 0 }}
hidden
{{ end }}
···
dark:hover:border-gray-600
{{ end }}
"
+
{{ if gt (length .Users) 0 }}
+
title="{{ range $i, $did := .Users }}{{ if ne $i 0 }}, {{ end }}{{ resolve $did }}{{ end }}{{ if gt .Count (length .Users) }}, and {{ sub .Count (length .Users) }} more{{ end }}"
+
{{ else }}
+
title="{{ .Kind }}"
+
{{ end }}
{{ if .IsReacted }}
hx-delete="/react?subject={{ .ThreadAt }}&kind={{ .Kind }}"
{{ else }}
+4 -2
appview/pages/templates/repo/issues/issue.html
···
<div class="flex items-center gap-2">
{{ template "repo/fragments/reactionsPopUp" .OrderedReactionKinds }}
{{ range $kind := .OrderedReactionKinds }}
+
{{ $reactionData := index $.Reactions $kind }}
{{
template "repo/fragments/reaction"
(dict
"Kind" $kind
-
"Count" (index $.Reactions $kind)
+
"Count" $reactionData.Count
"IsReacted" (index $.UserReacted $kind)
-
"ThreadAt" $.Issue.AtUri)
+
"ThreadAt" $.Issue.AtUri
+
"Users" $reactionData.Users)
}}
{{ end }}
</div>
+4 -2
appview/pages/templates/repo/pulls/fragments/pullHeader.html
···
<div class="flex items-center gap-2 mt-2">
{{ template "repo/fragments/reactionsPopUp" . }}
{{ range $kind := . }}
+
{{ $reactionData := index $.Reactions $kind }}
{{
template "repo/fragments/reaction"
(dict
"Kind" $kind
-
"Count" (index $.Reactions $kind)
+
"Count" $reactionData.Count
"IsReacted" (index $.UserReacted $kind)
-
"ThreadAt" $.Pull.PullAt)
+
"ThreadAt" $.Pull.PullAt
+
"Users" $reactionData.Users)
}}
{{ end }}
</div>
+38 -10
appview/db/timeline.go
···
// TODO: this gathers heterogenous events from different sources and aggregates
// them in code; if we did this entirely in sql, we could order and limit and paginate easily
-
func MakeTimeline(e Execer, limit int, loggedInUserDid string) ([]models.TimelineEvent, error) {
+
func MakeTimeline(e Execer, limit int, loggedInUserDid string, limitToUsersIsFollowing bool) ([]models.TimelineEvent, error) {
var events []models.TimelineEvent
-
repos, err := getTimelineRepos(e, limit, loggedInUserDid)
+
var userIsFollowing []string
+
if limitToUsersIsFollowing {
+
following, err := GetFollowing(e, loggedInUserDid)
+
if err != nil {
+
return nil, err
+
}
+
+
userIsFollowing = make([]string, 0, len(following))
+
for _, follow := range following {
+
userIsFollowing = append(userIsFollowing, follow.SubjectDid)
+
}
+
}
+
+
repos, err := getTimelineRepos(e, limit, loggedInUserDid, userIsFollowing)
if err != nil {
return nil, err
}
-
stars, err := getTimelineStars(e, limit, loggedInUserDid)
+
stars, err := getTimelineStars(e, limit, loggedInUserDid, userIsFollowing)
if err != nil {
return nil, err
}
-
follows, err := getTimelineFollows(e, limit, loggedInUserDid)
+
follows, err := getTimelineFollows(e, limit, loggedInUserDid, userIsFollowing)
if err != nil {
return nil, err
}
···
return isStarred, starCount
}
-
func getTimelineRepos(e Execer, limit int, loggedInUserDid string) ([]models.TimelineEvent, error) {
-
repos, err := GetRepos(e, limit)
+
func getTimelineRepos(e Execer, limit int, loggedInUserDid string, userIsFollowing []string) ([]models.TimelineEvent, error) {
+
filters := make([]filter, 0)
+
if userIsFollowing != nil {
+
filters = append(filters, FilterIn("did", userIsFollowing))
+
}
+
+
repos, err := GetRepos(e, limit, filters...)
if err != nil {
return nil, err
}
···
return events, nil
}
-
func getTimelineStars(e Execer, limit int, loggedInUserDid string) ([]models.TimelineEvent, error) {
-
stars, err := GetStars(e, limit)
+
func getTimelineStars(e Execer, limit int, loggedInUserDid string, userIsFollowing []string) ([]models.TimelineEvent, error) {
+
filters := make([]filter, 0)
+
if userIsFollowing != nil {
+
filters = append(filters, FilterIn("starred_by_did", userIsFollowing))
+
}
+
+
stars, err := GetStars(e, limit, filters...)
if err != nil {
return nil, err
}
···
return events, nil
}
-
func getTimelineFollows(e Execer, limit int, loggedInUserDid string) ([]models.TimelineEvent, error) {
-
follows, err := GetFollows(e, limit)
+
func getTimelineFollows(e Execer, limit int, loggedInUserDid string, userIsFollowing []string) ([]models.TimelineEvent, error) {
+
filters := make([]filter, 0)
+
if userIsFollowing != nil {
+
filters = append(filters, FilterIn("user_did", userIsFollowing))
+
}
+
+
follows, err := GetFollows(e, limit, filters...)
if err != nil {
return nil, err
}
+17 -2
appview/state/state.go
···
"log"
"log/slog"
"net/http"
+
"strconv"
"strings"
"time"
···
}
func (s *State) Timeline(w http.ResponseWriter, r *http.Request) {
+
filtered := getTimelineFilteredQuery(r)
user := s.oauth.GetUser(r)
var userDid string
if user != nil {
userDid = user.Did
}
-
timeline, err := db.MakeTimeline(s.db, 50, userDid, false)
+
timeline, err := db.MakeTimeline(s.db, 50, userDid, filtered)
if err != nil {
log.Println(err)
s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.")
···
Timeline: timeline,
Repos: repos,
GfiLabel: gfiLabel,
+
Filtered: filtered,
})
}
···
}
func (s *State) Home(w http.ResponseWriter, r *http.Request) {
-
timeline, err := db.MakeTimeline(s.db, 5, "")
+
filtered := getTimelineFilteredQuery(r)
+
timeline, err := db.MakeTimeline(s.db, 5, "", filtered)
if err != nil {
log.Println(err)
s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.")
···
LoggedInUser: nil,
Timeline: timeline,
Repos: repos,
+
Filtered: filtered,
})
}
···
return nil
}
+
+
func getTimelineFilteredQuery(r *http.Request) bool {
+
filteredStr := r.URL.Query().Get("filtered")
+
if filteredStr == "" {
+
return false
+
}
+
+
res, _ := strconv.ParseBool(filteredStr)
+
return res
+
}