diff --git a/appview/config/config.go b/appview/config/config.go index ed98533e..b5e861cc 100644 --- a/appview/config/config.go +++ b/appview/config/config.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "net/url" + "strings" "time" "github.com/sethvargo/go-envconfig" @@ -88,6 +89,10 @@ type PdsConfig struct { AdminSecret string `env:"ADMIN_SECRET"` } +func (p *PdsConfig) IsTnglShUser(pdsHost string) bool { + return strings.TrimRight(pdsHost, "/") == strings.TrimRight(p.Host, "/") +} + type R2Config struct { AccessKeyID string `env:"ACCESS_KEY_ID"` SecretAccessKey string `env:"SECRET_ACCESS_KEY"` diff --git a/appview/oauth/handler.go b/appview/oauth/handler.go index 7be729a0..c4f5532e 100644 --- a/appview/oauth/handler.go +++ b/appview/oauth/handler.go @@ -15,6 +15,7 @@ import ( comatproto "github.com/bluesky-social/indigo/api/atproto" "github.com/bluesky-social/indigo/atproto/auth/oauth" lexutil "github.com/bluesky-social/indigo/lex/util" + xrpc "github.com/bluesky-social/indigo/xrpc" "github.com/go-chi/chi/v5" "github.com/posthog/posthog-go" "tangled.org/core/api/tangled" @@ -40,6 +41,7 @@ func (o *OAuth) clientMetadata(w http.ResponseWriter, r *http.Request) { doc.JWKSURI = &o.JwksUri doc.ClientName = &o.ClientName doc.ClientURI = &o.ClientUri + doc.Scope = doc.Scope + " identity:handle" w.Header().Set("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(doc); err != nil { @@ -109,9 +111,35 @@ func (o *OAuth) callback(w http.ResponseWriter, r *http.Request) { redirectURL = authReturn.ReturnURL } + if o.isAccountDeactivated(sessData) { + redirectURL = "/settings/profile" + } + http.Redirect(w, r, redirectURL, http.StatusFound) } +func (o *OAuth) isAccountDeactivated(sessData *oauth.ClientSessionData) bool { + pdsClient := &xrpc.Client{ + Host: sessData.HostURL, + Client: &http.Client{Timeout: 5 * time.Second}, + } + + _, err := comatproto.RepoDescribeRepo( + context.Background(), + pdsClient, + sessData.AccountDID.String(), + ) + if err == nil { + return false + } + + var xrpcErr *xrpc.Error + var xrpcBody *xrpc.XRPCError + return errors.As(err, &xrpcErr) && + errors.As(xrpcErr.Wrapped, &xrpcBody) && + xrpcBody.ErrStr == "RepoDeactivated" +} + func (o *OAuth) addToDefaultSpindle(did string) { l := o.Logger.With("subject", did) diff --git a/appview/oauth/oauth.go b/appview/oauth/oauth.go index 3e76fcfc..81b2cbb2 100644 --- a/appview/oauth/oauth.go +++ b/appview/oauth/oauth.go @@ -1,10 +1,12 @@ package oauth import ( + "context" "errors" "fmt" "log/slog" "net/http" + "net/url" "sync" "time" @@ -363,3 +365,59 @@ func (o *OAuth) ServiceClient(r *http.Request, os ...ServiceClientOpt) (*xrpc.Cl }, }, nil } + +func (o *OAuth) StartElevatedAuthFlow(ctx context.Context, w http.ResponseWriter, r *http.Request, did string, extraScopes []string, returnURL string) (string, error) { + parsedDid, err := syntax.ParseDID(did) + if err != nil { + return "", fmt.Errorf("invalid DID: %w", err) + } + + ident, err := o.ClientApp.Dir.Lookup(ctx, parsedDid.AtIdentifier()) + if err != nil { + return "", fmt.Errorf("failed to resolve DID (%s): %w", did, err) + } + + host := ident.PDSEndpoint() + if host == "" { + return "", fmt.Errorf("identity does not link to an atproto host (PDS)") + } + + authserverURL, err := o.ClientApp.Resolver.ResolveAuthServerURL(ctx, host) + if err != nil { + return "", fmt.Errorf("resolving auth server: %w", err) + } + + authserverMeta, err := o.ClientApp.Resolver.ResolveAuthServerMetadata(ctx, authserverURL) + if err != nil { + return "", fmt.Errorf("fetching auth server metadata: %w", err) + } + + scopes := make([]string, 0, len(TangledScopes)+len(extraScopes)) + scopes = append(scopes, TangledScopes...) + scopes = append(scopes, extraScopes...) + + loginHint := did + if ident.Handle != "" && !ident.Handle.IsInvalidHandle() { + loginHint = ident.Handle.String() + } + + info, err := o.ClientApp.SendAuthRequest(ctx, authserverMeta, scopes, loginHint) + if err != nil { + return "", fmt.Errorf("auth request failed: %w", err) + } + + info.AccountDID = &parsedDid + o.ClientApp.Store.SaveAuthRequestInfo(ctx, *info) + + if err := o.SetAuthReturn(w, r, returnURL, false); err != nil { + return "", fmt.Errorf("failed to set auth return: %w", err) + } + + redirectURL := fmt.Sprintf("%s?client_id=%s&request_uri=%s", + authserverMeta.AuthorizationEndpoint, + url.QueryEscape(o.ClientApp.Config.ClientID), + url.QueryEscape(info.RequestURI), + ) + + return redirectURL, nil +} diff --git a/appview/pages/htmx.go b/appview/pages/htmx.go index f507c355..059e28bf 100644 --- a/appview/pages/htmx.go +++ b/appview/pages/htmx.go @@ -2,17 +2,27 @@ package pages import ( "fmt" + "html" "net/http" ) // Notice performs a hx-oob-swap to replace the content of an element with a message. // Pass the id of the element and the message to display. func (s *Pages) Notice(w http.ResponseWriter, id, msg string) { - html := fmt.Sprintf(`%s`, id, msg) + escaped := html.EscapeString(msg) + markup := fmt.Sprintf(`%s`, id, escaped) w.Header().Set("Content-Type", "text/html") w.WriteHeader(http.StatusOK) - w.Write([]byte(html)) + w.Write([]byte(markup)) +} + +func (s *Pages) NoticeHTML(w http.ResponseWriter, id string, trustedHTML string) { + markup := fmt.Sprintf(`%s`, id, trustedHTML) + + w.Header().Set("Content-Type", "text/html") + w.WriteHeader(http.StatusOK) + w.Write([]byte(markup)) } // HxRefresh is a client-side full refresh of the page. diff --git a/appview/pages/pages.go b/appview/pages/pages.go index 1c28ebab..4d9ebde3 100644 --- a/appview/pages/pages.go +++ b/appview/pages/pages.go @@ -364,6 +364,10 @@ type UserProfileSettingsParams struct { LoggedInUser *oauth.MultiAccountUser Tab string PunchcardPreference models.PunchcardPreference + IsTnglSh bool + IsDeactivated bool + PdsDomain string + HandleOpen bool } func (p *Pages) UserProfileSettings(w io.Writer, params UserProfileSettingsParams) error { @@ -1573,6 +1577,18 @@ func (p *Pages) CssContentHash() string { return hex.EncodeToString(hasher.Sum(nil))[:8] // Use first 8 chars of hash } +func (p *Pages) DangerPasswordTokenStep(w io.Writer) error { + return p.executePlain("user/settings/fragments/dangerPasswordToken", w, nil) +} + +func (p *Pages) DangerPasswordSuccess(w io.Writer) error { + return p.executePlain("user/settings/fragments/dangerPasswordSuccess", w, nil) +} + +func (p *Pages) DangerDeleteTokenStep(w io.Writer) error { + return p.executePlain("user/settings/fragments/dangerDeleteToken", w, nil) +} + func (p *Pages) Error500(w io.Writer) error { return p.execute("errors/500", w, nil) } diff --git a/appview/pages/templates/user/settings/fragments/dangerDeleteToken.html b/appview/pages/templates/user/settings/fragments/dangerDeleteToken.html new file mode 100644 index 00000000..bf47c807 --- /dev/null +++ b/appview/pages/templates/user/settings/fragments/dangerDeleteToken.html @@ -0,0 +1,26 @@ +{{ define "user/settings/fragments/dangerDeleteToken" }} +
Check your email for an account deletion code.
+ +Password changed.
+Check your email for a password reset code.
+ +This will restore your profile and repositories, making them accessible again.
+ + +Your profile and repositories will become inaccessible. You can reactivate by logging in again.
+ + +This permanently deletes your account and all associated data. This cannot be undone.
+ + +Configure punchcard visibility and preferences.
-