A container registry that uses the AT Protocol for manifest storage and S3 for blob storage. atcr.io
docker container atproto go
72
fork

Configure Feed

Select the types of activity you want to include in your feed.

new signup flow

+817 -9
+1 -1
go.mod
··· 21 21 github.com/go-viper/mapstructure/v2 v2.5.0 22 22 github.com/goki/freetype v1.0.5 23 23 github.com/golang-jwt/jwt/v5 v5.3.1 24 + github.com/google/go-querystring v1.2.0 24 25 github.com/google/uuid v1.6.0 25 26 github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 26 27 github.com/ipfs/go-block-format v0.2.3 ··· 105 106 github.com/go-logr/stdr v1.2.2 // indirect 106 107 github.com/gogo/protobuf v1.3.2 // indirect 107 108 github.com/golang/snappy v1.0.0 // indirect 108 - github.com/google/go-querystring v1.2.0 // indirect 109 109 github.com/gorilla/css v1.0.1 // indirect 110 110 github.com/gorilla/handlers v1.5.2 // indirect 111 111 github.com/gorilla/mux v1.8.1 // indirect
+2
pkg/appview/handlers/base.go
··· 10 10 "atcr.io/pkg/appview/webhooks" 11 11 "atcr.io/pkg/auth/oauth" 12 12 "atcr.io/pkg/billing" 13 + indigooauth "github.com/bluesky-social/indigo/atproto/auth/oauth" 13 14 "github.com/bluesky-social/indigo/atproto/identity" 14 15 ) 15 16 ··· 33 34 Directory identity.Directory 34 35 BillingManager *billing.Manager 35 36 WebhookDispatcher *webhooks.Dispatcher 37 + OAuthClientApp *indigooauth.ClientApp 36 38 37 39 // Stores 38 40 SessionStore *db.SessionStore
+187
pkg/appview/handlers/signup.go
··· 1 + package handlers 2 + 3 + import ( 4 + "log/slog" 5 + "net/http" 6 + "strings" 7 + 8 + "atcr.io/pkg/auth/oauth" 9 + ) 10 + 11 + // SignupProvider is a curated Atmosphere PDS that accepts prompt=create. 12 + // 13 + // v1 keeps the list hardcoded. When the community has a curated directory of 14 + // providers we can link or syndicate, this moves to a config file. 15 + type SignupProvider struct { 16 + // Domain is the brand hostname shown to the user on the picker and 17 + // interstitial — typically the site they already know (tangled.org). 18 + Domain string 19 + 20 + // AuthServerDomain is the actual PDS origin the OAuth redirect points 21 + // at. When blank it defaults to Domain. Set this only when the brand 22 + // the user recognizes is not the same host as the PDS (e.g. the 23 + // tangled.org site runs against a tngl.sh PDS). 24 + AuthServerDomain string 25 + 26 + // AvatarPath is the provider's mark. For mono vector marks, AvatarMono 27 + // should be true and this should point to a monochrome SVG used as a 28 + // CSS mask (the fill value in the file is irrelevant — theme color is 29 + // applied via `background-color` on the element). For raster avatars 30 + // (photographs, brand-colored icons that should render unchanged), 31 + // AvatarMono is false and this is emitted as a plain `<img>`. 32 + AvatarPath string 33 + 34 + // AvatarMono toggles the mask-based theme-aware render pipeline. True 35 + // for single-color SVG marks that should invert with theme; false for 36 + // raster or multi-color art that should render as-is. 37 + AvatarMono bool 38 + 39 + // RegionFlag is the flag emoji shown before the region code. 40 + RegionFlag string 41 + 42 + // Region is a short code shown in the region chip ("US", "EU", ...). 43 + Region string 44 + 45 + // RegionFullName is the long form shown in the chip's title attribute 46 + // for hover/assistive tech ("United States", "European Union"). 47 + RegionFullName string 48 + 49 + // TermsURL and PrivacyURL link out to the provider's policies (new tab). 50 + // Blank links are omitted rather than rendered as dead links. 51 + TermsURL string 52 + PrivacyURL string 53 + } 54 + 55 + // AuthServerHost returns the origin string (https://domain) passed to the 56 + // OAuth resolver. Prefers AuthServerDomain when set (so the brand label on 57 + // the row can differ from the PDS — e.g. tangled.org displayed, tngl.sh 58 + // used for auth). Hardcoded scheme is intentional — PAR against http PDSes 59 + // violates the AT Protocol OAuth spec. 60 + func (p SignupProvider) AuthServerHost() string { 61 + host := p.AuthServerDomain 62 + if host == "" { 63 + host = p.Domain 64 + } 65 + return "https://" + host 66 + } 67 + 68 + // signupProviders is the v1 curated list. Order is the order users see. 69 + // Avatars live at pkg/appview/public/static/providers/*.svg. 70 + var signupProviders = []SignupProvider{ 71 + { 72 + Domain: "selfhosted.social", 73 + AvatarPath: "/static/providers/selfhosted-social.png", 74 + AvatarMono: false, // raster avatar, renders as-is 75 + RegionFlag: "\U0001F1FA\U0001F1F8", // 🇺🇸 76 + Region: "US", 77 + RegionFullName: "United States", 78 + TermsURL: "https://selfhosted.social/legal", 79 + PrivacyURL: "https://selfhosted.social/legal", 80 + }, 81 + { 82 + Domain: "eurosky.social", 83 + AvatarPath: "/static/providers/eurosky-social.svg", 84 + AvatarMono: true, 85 + RegionFlag: "\U0001F1EA\U0001F1FA", // 🇪🇺 86 + Region: "EU", 87 + RegionFullName: "European Union", 88 + TermsURL: "https://eurosky.tech/accounts/terms/", 89 + PrivacyURL: "https://eurosky.tech/accounts/privacy/", 90 + }, 91 + } 92 + 93 + // findProvider returns the entry matching the given domain, or nil. 94 + func findProvider(domain string) *SignupProvider { 95 + domain = strings.ToLower(strings.TrimSpace(domain)) 96 + for i := range signupProviders { 97 + if signupProviders[i].Domain == domain { 98 + return &signupProviders[i] 99 + } 100 + } 101 + return nil 102 + } 103 + 104 + // SignupHandler serves GET /signup — the provider picker. 105 + type SignupHandler struct { 106 + BaseUIHandler 107 + } 108 + 109 + func (h *SignupHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 110 + meta := NewPageMeta( 111 + "Create your account - "+h.ClientShortName, 112 + "Pick an Atmosphere provider to create an account that works on "+h.ClientShortName+" and every other app on the AT Protocol.", 113 + ).WithCanonical("https://" + h.SiteURL + "/signup"). 114 + WithSiteName(h.ClientShortName) 115 + 116 + data := struct { 117 + PageData 118 + Meta *PageMeta 119 + Providers []SignupProvider 120 + }{ 121 + PageData: NewPageData(r, &h.BaseUIHandler), 122 + Meta: meta, 123 + Providers: signupProviders, 124 + } 125 + 126 + if err := h.Templates.ExecuteTemplate(w, "signup", data); err != nil { 127 + http.Error(w, err.Error(), http.StatusInternalServerError) 128 + return 129 + } 130 + } 131 + 132 + // SignupContinueHandler serves /signup/continue — the branded handoff. 133 + // 134 + // GET renders the interstitial. POST kicks off OAuth with prompt=create and 135 + // redirects to the provider's authorize URL. 136 + type SignupContinueHandler struct { 137 + BaseUIHandler 138 + } 139 + 140 + func (h *SignupContinueHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 141 + to := r.URL.Query().Get("to") 142 + if r.Method == http.MethodPost { 143 + to = r.FormValue("to") 144 + } 145 + 146 + provider := findProvider(to) 147 + if provider == nil { 148 + http.Redirect(w, r, "/signup", http.StatusSeeOther) 149 + return 150 + } 151 + 152 + if r.Method == http.MethodPost { 153 + redirectURL, err := oauth.StartSignupFlow(r.Context(), h.OAuthClientApp, provider.AuthServerHost()) 154 + if err != nil { 155 + slog.Error("signup flow start failed", 156 + "component", "signup", 157 + "provider", provider.Domain, 158 + "error", err, 159 + ) 160 + http.Redirect(w, r, "/signup?error=provider_unreachable&domain="+provider.Domain, http.StatusSeeOther) 161 + return 162 + } 163 + http.Redirect(w, r, redirectURL, http.StatusFound) 164 + return 165 + } 166 + 167 + meta := NewPageMeta( 168 + "Create your account on "+provider.Domain+" - "+h.ClientShortName, 169 + "You're about to be sent to "+provider.Domain+" to create your account.", 170 + ).WithRobots("noindex"). 171 + WithSiteName(h.ClientShortName) 172 + 173 + data := struct { 174 + PageData 175 + Meta *PageMeta 176 + Provider SignupProvider 177 + }{ 178 + PageData: NewPageData(r, &h.BaseUIHandler), 179 + Meta: meta, 180 + Provider: *provider, 181 + } 182 + 183 + if err := h.Templates.ExecuteTemplate(w, "signup-continue", data); err != nil { 184 + http.Error(w, err.Error(), http.StatusInternalServerError) 185 + return 186 + } 187 + }
+123
pkg/appview/handlers/signup_test.go
··· 1 + package handlers_test 2 + 3 + import ( 4 + "net/http" 5 + "net/http/httptest" 6 + "strings" 7 + "testing" 8 + 9 + "atcr.io/pkg/appview" 10 + "atcr.io/pkg/appview/handlers" 11 + ) 12 + 13 + func TestSignupHandler_RendersProviderPicker(t *testing.T) { 14 + templates, err := appview.Templates(nil) 15 + if err != nil { 16 + t.Fatalf("load templates: %v", err) 17 + } 18 + 19 + h := &handlers.SignupHandler{ 20 + BaseUIHandler: handlers.BaseUIHandler{ 21 + Templates: templates, 22 + RegistryURL: "seamark.dev", 23 + SiteURL: "seamark.dev", 24 + ClientShortName: "Seamark", 25 + }, 26 + } 27 + 28 + req := httptest.NewRequest("GET", "/signup", nil) 29 + rr := httptest.NewRecorder() 30 + h.ServeHTTP(rr, req) 31 + 32 + if rr.Code != http.StatusOK { 33 + t.Fatalf("status = %d, want 200", rr.Code) 34 + } 35 + 36 + body := rr.Body.String() 37 + 38 + // Each provider's domain should appear as a row title. 39 + for _, domain := range []string{"selfhosted.social", "eurosky.social"} { 40 + if !strings.Contains(body, domain) { 41 + t.Errorf("missing provider domain %q in body", domain) 42 + } 43 + } 44 + 45 + // The {appviewName} substitution should carry through from ClientShortName. 46 + if !strings.Contains(body, "Seamark") { 47 + t.Error("expected rendered body to include ClientShortName 'Seamark'") 48 + } 49 + 50 + // Each row should link to the branded handoff, not directly to OAuth. 51 + if !strings.Contains(body, "/signup/continue?to=selfhosted.social") { 52 + t.Error("expected CTA link to /signup/continue?to={domain}") 53 + } 54 + 55 + // Fallback route to existing Bluesky users must remain intact. 56 + if !strings.Contains(body, `href="/auth/oauth/login"`) { 57 + t.Error("expected footer link to /auth/oauth/login") 58 + } 59 + } 60 + 61 + func TestSignupContinueHandler_GETRendersInterstitial(t *testing.T) { 62 + templates, err := appview.Templates(nil) 63 + if err != nil { 64 + t.Fatalf("load templates: %v", err) 65 + } 66 + 67 + h := &handlers.SignupContinueHandler{ 68 + BaseUIHandler: handlers.BaseUIHandler{ 69 + Templates: templates, 70 + RegistryURL: "seamark.dev", 71 + SiteURL: "seamark.dev", 72 + ClientShortName: "Seamark", 73 + }, 74 + } 75 + 76 + req := httptest.NewRequest("GET", "/signup/continue?to=eurosky.social", nil) 77 + rr := httptest.NewRecorder() 78 + h.ServeHTTP(rr, req) 79 + 80 + if rr.Code != http.StatusOK { 81 + t.Fatalf("status = %d, want 200", rr.Code) 82 + } 83 + 84 + body := rr.Body.String() 85 + 86 + if !strings.Contains(body, "eurosky.social") { 87 + t.Error("expected interstitial to show target domain") 88 + } 89 + if !strings.Contains(body, `action="/signup/continue"`) { 90 + t.Error("expected continue form to POST back to /signup/continue") 91 + } 92 + if !strings.Contains(body, `href="/signup"`) { 93 + t.Error("expected back-link to /signup") 94 + } 95 + // Atmosphere story anchored with concrete app names. 96 + if !strings.Contains(body, "Bluesky") || !strings.Contains(body, "Tangled") { 97 + t.Error("expected body copy to name Bluesky and Tangled") 98 + } 99 + } 100 + 101 + func TestSignupContinueHandler_UnknownProviderRedirectsToPicker(t *testing.T) { 102 + templates, err := appview.Templates(nil) 103 + if err != nil { 104 + t.Fatalf("load templates: %v", err) 105 + } 106 + 107 + h := &handlers.SignupContinueHandler{ 108 + BaseUIHandler: handlers.BaseUIHandler{ 109 + Templates: templates, 110 + }, 111 + } 112 + 113 + req := httptest.NewRequest("GET", "/signup/continue?to=not-a-real-provider.example", nil) 114 + rr := httptest.NewRecorder() 115 + h.ServeHTTP(rr, req) 116 + 117 + if rr.Code != http.StatusSeeOther { 118 + t.Fatalf("status = %d, want %d", rr.Code, http.StatusSeeOther) 119 + } 120 + if loc := rr.Header().Get("Location"); loc != "/signup" { 121 + t.Errorf("redirect Location = %q, want /signup", loc) 122 + } 123 + }
+4
pkg/appview/public/static/providers/eurosky-social.svg
··· 1 + <?xml version="1.0" encoding="UTF-8"?> 2 + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1022 1022" width="40" height="40" role="img" aria-label="eurosky.social"> 3 + <path fill="currentColor" d="M990.9671,522.1991c-3.0947,0-6.1689,0-9.2229.0001-456.4914.0202-459.525,3.0539-459.5452,459.5452-.0001,3.0539-.0001,6.1282-.0001,9.2228h-44.3979c0-3.0947,0-6.1689-.0001-9.2229-.0202-456.4913-3.0538-459.525-459.5452-459.5452-3.0539-.0001-6.1282-.0001-9.2229-.0001v-44.3981c3.0947,0,6.1689,0,9.2229-.0001,456.4914-.0202,459.525-3.0534,459.5452-459.5452.0001-3.0539.0001-6.1282.0001-9.2229h44.3979c0,3.0947,0,6.1689.0001,9.2228.0202,456.4917,3.0538,459.525,459.5452,459.5452,3.0539.0001,6.1282.0001,9.2229.0001v44.3981Z"/> 4 + </svg>
pkg/appview/public/static/providers/selfhosted-social.png

This is a binary file and will not be displayed.

+7
pkg/appview/routes/routes.go
··· 70 70 HealthChecker: deps.HealthChecker, 71 71 ReadmeFetcher: deps.ReadmeFetcher, 72 72 Directory: deps.OAuthClientApp.Dir, 73 + OAuthClientApp: deps.OAuthClientApp, 73 74 SessionStore: deps.SessionStore, 74 75 DeviceStore: deps.DeviceStore, 75 76 OAuthStore: deps.OAuthStore, ··· 87 88 // OAuth login routes (public) 88 89 router.Get("/auth/oauth/login", (&uihandlers.LoginHandler{BaseUIHandler: base}).ServeHTTP) 89 90 router.Post("/auth/oauth/login", (&uihandlers.LoginSubmitHandler{BaseUIHandler: base}).ServeHTTP) 91 + 92 + // Signup: provider picker + branded OAuth handoff (both public) 93 + router.Get("/signup", (&uihandlers.SignupHandler{BaseUIHandler: base}).ServeHTTP) 94 + signupContinue := &uihandlers.SignupContinueHandler{BaseUIHandler: base} 95 + router.Get("/signup/continue", signupContinue.ServeHTTP) 96 + router.Post("/signup/continue", signupContinue.ServeHTTP) 90 97 91 98 // Public routes (with optional auth for navbar) 92 99 router.Get("/", middleware.OptionalAuth(deps.SessionStore, deps.Database)(
+167 -2
pkg/appview/src/css/main.css
··· 730 730 SAILOR INFO DISCLOSURE 731 731 ---------------------------------------- */ 732 732 .sailor-info summary { 733 - @apply cursor-pointer text-sm text-base-content/70; 733 + @apply cursor-pointer text-base font-medium text-base-content/80; 734 734 @apply py-2 select-none; 735 735 list-style: none; 736 736 } ··· 749 749 } 750 750 751 751 .sailor-info-body { 752 - @apply text-sm text-base-content/70 space-y-2 pl-5 pt-1 pb-2; 752 + @apply text-base text-base-content/75 leading-relaxed space-y-3 pl-5 pt-1 pb-2; 753 753 } 754 754 755 755 /* ---------------------------------------- ··· 834 834 .vuln-box-high { background-color: var(--color-severity-high); color: var(--color-severity-high-content); } 835 835 .vuln-box-medium { background-color: var(--color-severity-medium); color: var(--color-severity-medium-content); } 836 836 .vuln-box-low { background-color: var(--color-severity-low); color: var(--color-severity-low-content); } 837 + 838 + /* ---------------------------------------- 839 + SIGNUP PROVIDER LIST 840 + A single bordered container where each row is a "mooring" the user 841 + can pick between. No per-row card, no nested borders, no shadows. 842 + 843 + The single maritime gesture on this surface is the row divider: 844 + a repeating short-dash pattern that reads as a depth-sounding plot 845 + mark. It replaces a plain 1px rule without becoming decoration 846 + (the dividers still do the work of separating rows). 847 + 848 + Typography intent: the domain is rendered in the mono face because 849 + the domain IS the identity — same thing users will see in the URL 850 + bar on the provider's signup page. Mono treats it as an instrument 851 + label, not prose. 852 + ---------------------------------------- */ 853 + .provider-row + .provider-row { 854 + /* Background-image is a row of 4px dashes with 4px gaps — 855 + the plotted-depth look without resorting to a border-image. */ 856 + background-image: linear-gradient( 857 + to right, 858 + var(--color-base-300) 0 4px, 859 + transparent 4px 8px 860 + ); 861 + background-repeat: repeat-x; 862 + background-size: 8px 1px; 863 + background-position: left top; 864 + } 865 + 866 + .provider-row-inner { 867 + @apply flex items-center gap-4 p-4 sm:px-5; 868 + @apply transition-colors duration-150; 869 + background: transparent; 870 + } 871 + 872 + .provider-row:hover .provider-row-inner { 873 + background: color-mix(in oklch, var(--color-base-200) 55%, transparent); 874 + } 875 + 876 + .provider-mark { 877 + @apply shrink-0 w-10 h-10 rounded-full overflow-hidden bg-base-200; 878 + @apply flex items-center justify-center; 879 + /* Subtle inner ring so the avatar has a defined edge even when the 880 + art fills corner-to-corner. */ 881 + box-shadow: inset 0 0 0 1px oklch(from var(--color-base-content) l c h / 0.08); 882 + } 883 + .provider-mark img { 884 + display: block; 885 + width: 100%; 886 + height: 100%; 887 + object-fit: cover; 888 + } 889 + 890 + /* Mono mark variant: the SVG is used as a CSS mask and the element's 891 + background-color carries the theme-aware fill. One file per provider, 892 + theme colors driven by CSS, no shipping of light/dark pairs. */ 893 + .provider-mark-mono { 894 + display: block; 895 + width: 60%; 896 + height: 60%; 897 + background-color: var(--color-base-content); 898 + mask-image: var(--mark-url); 899 + mask-size: contain; 900 + mask-repeat: no-repeat; 901 + mask-position: center; 902 + -webkit-mask-image: var(--mark-url); 903 + -webkit-mask-size: contain; 904 + -webkit-mask-repeat: no-repeat; 905 + -webkit-mask-position: center; 906 + } 907 + 908 + .provider-body { 909 + @apply flex-1 min-w-0 flex flex-col; 910 + gap: 0.125rem; 911 + } 912 + 913 + .provider-title-row { 914 + @apply flex items-baseline gap-2 flex-wrap; 915 + } 916 + 917 + .provider-domain { 918 + font-family: var(--font-mono); 919 + font-weight: 500; 920 + font-size: 0.975rem; 921 + letter-spacing: -0.01em; 922 + color: var(--color-base-content); 923 + } 924 + 925 + /* Region chip. Tight, instrumenty. Tabular-like letterforms via the 926 + mono face; flag emoji sits first and uses the system emoji font stack 927 + to keep rendering consistent across platforms (Windows, Linux, macOS). */ 928 + .provider-chip { 929 + @apply inline-flex items-center gap-1.5 px-2 py-[0.15rem] rounded-sm; 930 + font-family: var(--font-mono); 931 + font-size: 0.7rem; 932 + font-weight: 500; 933 + letter-spacing: 0.04em; 934 + color: color-mix(in oklch, var(--color-base-content) 70%, transparent); 935 + background: color-mix(in oklch, var(--color-base-300) 55%, transparent); 936 + } 937 + .provider-chip-flag { 938 + font-family: 939 + "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", 940 + "Twemoji Mozilla", "EmojiOne Color", sans-serif; 941 + font-size: 0.9em; 942 + line-height: 1; 943 + letter-spacing: 0; 944 + } 945 + 946 + .provider-meta { 947 + @apply flex items-center gap-2 text-xs; 948 + color: color-mix(in oklch, var(--color-base-content) 55%, transparent); 949 + } 950 + .provider-meta a { 951 + @apply underline-offset-2; 952 + } 953 + .provider-meta a:hover { 954 + @apply underline text-base-content; 955 + } 956 + .provider-meta span { 957 + color: color-mix(in oklch, var(--color-base-content) 30%, transparent); 958 + } 959 + 960 + .provider-cta { 961 + @apply shrink-0; 962 + } 963 + 964 + /* Mobile: avatar + domain on row 1; region/links + CTA on row 2 so the 965 + CTA gets full width and the row doesn't wrap into 3 lines. */ 966 + @media (max-width: 32rem) { 967 + .provider-row-inner { 968 + display: grid; 969 + grid-template-columns: 2.5rem 1fr; 970 + grid-template-areas: 971 + "mark body" 972 + "cta cta"; 973 + row-gap: 0.85rem; 974 + } 975 + .provider-mark { grid-area: mark; } 976 + .provider-body { grid-area: body; } 977 + .provider-cta { grid-area: cta; width: 100%; justify-content: center; } 978 + } 979 + 980 + /* ---------------------------------------- 981 + SIGNUP CONTINUE — handoff mark 982 + A slightly larger avatar ringed in primary, so the "you are leaving" 983 + page reads as explicitly about the destination provider. 984 + ---------------------------------------- */ 985 + .signup-handoff-mark { 986 + @apply w-16 h-16 rounded-full overflow-hidden bg-base-200; 987 + @apply flex items-center justify-center; 988 + box-shadow: 989 + 0 0 0 1px var(--color-base-300), 990 + 0 0 0 5px color-mix(in oklch, var(--color-primary) 12%, transparent); 991 + } 992 + .signup-handoff-mark img { 993 + display: block; 994 + width: 100%; 995 + height: 100%; 996 + object-fit: cover; 997 + } 998 + .signup-handoff-mark .provider-mark-mono { 999 + width: 60%; 1000 + height: 60%; 1001 + } 837 1002 } 838 1003 839 1004 /* ========================================
+7 -6
pkg/appview/templates/pages/login.html
··· 67 67 <summary>Not sure if you have an account?</summary> 68 68 <div class="sailor-info-body"> 69 69 <p> 70 - An <strong>Atmosphere Account</strong> is a portable identity on the AT Protocol — 71 - the same network that powers Bluesky, Tangled, and other apps. One account works 72 - across every application built on the protocol. 70 + An <strong>Atmosphere account</strong> is your personal identity across a whole network 71 + of apps. Think of it as a digital passport that you own, not locked to any single app 72 + or company. 73 73 </p> 74 74 <p> 75 - The easiest way to create one is at 76 - <a href="https://bsky.app" target="_blank" rel="noopener noreferrer" class="link link-primary">bsky.app</a>. 77 - Already have a Bluesky handle? You're all set — use it here. 75 + It works the way an email address does: the same account signs you into many different 76 + apps. If you already have an account from a site like Bluesky, Blacksky, or Tangled, 77 + you're already set. If you don't, you can 78 + <a href="/signup" class="link link-primary">create one here →</a> 78 79 </p> 79 80 </div> 80 81 </details>
+52
pkg/appview/templates/pages/signup-continue.html
··· 1 + {{ define "signup-continue" }} 2 + <!DOCTYPE html> 3 + <html lang="en"> 4 + <head> 5 + {{ template "head" . }} 6 + {{ template "meta" .Meta }} 7 + </head> 8 + <body> 9 + {{ template "nav-simple" . }} 10 + 11 + <main id="main-content" class="flex-1 flex items-start justify-center px-4 pt-16 sm:pt-24 pb-20"> 12 + <div class="w-full max-w-md"> 13 + <div class="signup-handoff-mark mb-8"> 14 + {{ if .Provider.AvatarMono }} 15 + <span class="provider-mark-mono" style="--mark-url: url('{{ .Provider.AvatarPath }}')" role="img" aria-label="{{ .Provider.Domain }}"></span> 16 + {{ else }} 17 + <img src="{{ .Provider.AvatarPath }}" alt="" width="64" height="64" loading="lazy" decoding="async"> 18 + {{ end }} 19 + </div> 20 + 21 + <h1 class="text-2xl sm:text-3xl font-display font-semibold tracking-tight leading-[1.15]"> 22 + Create your account on<br> 23 + <span class="font-mono font-semibold text-primary tracking-tight">{{ .Provider.Domain }}</span> 24 + </h1> 25 + 26 + <p class="mt-5 text-base-content/75 leading-relaxed"> 27 + You're being sent to 28 + <span class="font-mono">{{ .Provider.Domain }}</span> 29 + to create your account. 30 + The same account works on {{ .ClientShortName }} and every other app on the Atmosphere, 31 + including Bluesky, Tangled, and dozens more. 32 + </p> 33 + 34 + <form action="/signup/continue" method="POST" class="mt-8 flex flex-col gap-3" 35 + onsubmit="var b=this.querySelector('button[type=submit]');if(b){b.disabled=true;b.querySelector('.cta-label').textContent='Redirecting…';}"> 36 + <input type="hidden" name="to" value="{{ .Provider.Domain }}"> 37 + <button type="submit" autofocus class="btn btn-primary btn-lg"> 38 + <span class="cta-label">Continue to {{ .Provider.Domain }}</span> 39 + {{ icon "arrow-right" "size-4" }} 40 + </button> 41 + <a href="/signup" class="btn btn-ghost btn-sm self-start mt-1 -ml-3"> 42 + {{ icon "chevron-left" "size-4" }} 43 + Pick a different provider 44 + </a> 45 + </form> 46 + </div> 47 + </main> 48 + 49 + {{ template "footer" . }} 50 + </body> 51 + </html> 52 + {{ end }}
+69
pkg/appview/templates/pages/signup.html
··· 1 + {{ define "signup" }} 2 + <!DOCTYPE html> 3 + <html lang="en"> 4 + <head> 5 + {{ template "head" . }} 6 + {{ template "meta" .Meta }} 7 + </head> 8 + <body> 9 + {{ template "nav-simple" . }} 10 + 11 + <main id="main-content" class="flex-1 flex items-start justify-center px-4 pt-16 sm:pt-24 pb-20"> 12 + <div class="w-full max-w-2xl"> 13 + <header class="mb-10"> 14 + <h1 class="text-3xl sm:text-4xl font-display font-semibold tracking-tight leading-[1.1]"> 15 + Create your Atmosphere account 16 + </h1> 17 + <p class="mt-4 text-base-content/70 text-base leading-relaxed max-w-md"> 18 + Pick a provider. Your account works on {{ .ClientShortName }} and every other app on the Atmosphere. 19 + </p> 20 + </header> 21 + 22 + <ul class="provider-list border border-base-300 rounded-md bg-base-100 overflow-hidden" 23 + aria-label="Atmosphere providers"> 24 + {{ range $i, $p := .Providers }} 25 + <li class="provider-row group"> 26 + <div class="provider-row-inner"> 27 + <div class="provider-mark"> 28 + {{ if $p.AvatarMono }} 29 + <span class="provider-mark-mono" style="--mark-url: url('{{ $p.AvatarPath }}')" role="img" aria-label="{{ $p.Domain }}"></span> 30 + {{ else }} 31 + <img src="{{ $p.AvatarPath }}" alt="" width="40" height="40" loading="lazy" decoding="async"> 32 + {{ end }} 33 + </div> 34 + <div class="provider-body"> 35 + <div class="provider-title-row"> 36 + <span class="provider-domain">{{ $p.Domain }}</span> 37 + <span class="provider-chip" title="{{ $p.RegionFullName }}"> 38 + {{ if $p.RegionFlag }}<span class="provider-chip-flag" aria-hidden="true">{{ $p.RegionFlag }}</span>{{ end }} 39 + {{ $p.Region }} 40 + </span> 41 + </div> 42 + <div class="provider-meta"> 43 + {{ if $p.TermsURL }}<a href="{{ $p.TermsURL }}" target="_blank" rel="noopener noreferrer">Terms</a>{{ end }} 44 + {{ if and $p.TermsURL $p.PrivacyURL }}<span aria-hidden="true">·</span>{{ end }} 45 + {{ if $p.PrivacyURL }}<a href="{{ $p.PrivacyURL }}" target="_blank" rel="noopener noreferrer">Privacy</a>{{ end }} 46 + </div> 47 + </div> 48 + <a href="/signup/continue?to={{ $p.Domain }}" 49 + class="btn btn-primary btn-sm provider-cta" 50 + {{ if eq $i 0 }}autofocus{{ end }}> 51 + Create account 52 + {{ icon "arrow-right" "size-4" }} 53 + </a> 54 + </div> 55 + </li> 56 + {{ end }} 57 + </ul> 58 + 59 + <p class="mt-8 text-sm text-base-content/60"> 60 + Already have an Atmosphere account? 61 + <a href="/auth/oauth/login" class="link link-primary">Sign in →</a> 62 + </p> 63 + </div> 64 + </main> 65 + 66 + {{ template "footer" . }} 67 + </body> 68 + </html> 69 + {{ end }}
+198
pkg/auth/oauth/signup.go
··· 1 + package oauth 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "crypto/rand" 7 + "encoding/base64" 8 + "encoding/json" 9 + "fmt" 10 + "io" 11 + "log/slog" 12 + "net/http" 13 + "net/url" 14 + 15 + "github.com/bluesky-social/indigo/atproto/atcrypto" 16 + "github.com/bluesky-social/indigo/atproto/auth/oauth" 17 + "github.com/google/go-querystring/query" 18 + ) 19 + 20 + // StartSignupFlow starts an OAuth authorization flow pointed at a specific 21 + // authorization server with prompt=create, asking the server to show its 22 + // signup UI rather than its login UI. 23 + // 24 + // indigo's ClientApp.StartAuthFlow does not expose prompt, so this 25 + // duplicates the PAR + redirect build using only exported indigo helpers. 26 + // The resulting AuthRequestData is persisted via clientApp.Store so the 27 + // existing ServeCallback path handles the return leg unchanged. 28 + // 29 + // authServerHost is the PDS origin (e.g. "https://eurosky.social"). It gets 30 + // resolved to the actual OAuth auth server URL before the PAR call. 31 + func StartSignupFlow(ctx context.Context, clientApp *oauth.ClientApp, authServerHost string) (string, error) { 32 + authserverURL, err := clientApp.Resolver.ResolveAuthServerURL(ctx, authServerHost) 33 + if err != nil { 34 + return "", fmt.Errorf("resolving auth server for %s: %w", authServerHost, err) 35 + } 36 + 37 + authserverMeta, err := clientApp.Resolver.ResolveAuthServerMetadata(ctx, authserverURL) 38 + if err != nil { 39 + return "", fmt.Errorf("fetching auth server metadata: %w", err) 40 + } 41 + 42 + state, err := secureRandomBase64(16) 43 + if err != nil { 44 + return "", fmt.Errorf("generating state: %w", err) 45 + } 46 + pkceVerifier, err := secureRandomBase64(48) 47 + if err != nil { 48 + return "", fmt.Errorf("generating PKCE verifier: %w", err) 49 + } 50 + codeChallenge := oauth.S256CodeChallenge(pkceVerifier) 51 + prompt := "create" 52 + 53 + body := oauth.PushedAuthRequest{ 54 + ClientID: clientApp.Config.ClientID, 55 + State: state, 56 + RedirectURI: clientApp.Config.CallbackURL, 57 + Scope: scopeString(clientApp.Config.Scopes), 58 + ResponseType: "code", 59 + CodeChallenge: codeChallenge, 60 + CodeChallengeMethod: "S256", 61 + Prompt: &prompt, 62 + } 63 + 64 + if clientApp.Config.IsConfidential() { 65 + assertionJWT, err := clientApp.Config.NewClientAssertion(authserverMeta.Issuer) 66 + if err != nil { 67 + return "", fmt.Errorf("client assertion: %w", err) 68 + } 69 + body.ClientAssertionType = oauth.ClientAssertionJWTBearer 70 + body.ClientAssertion = assertionJWT 71 + } 72 + 73 + vals, err := query.Values(body) 74 + if err != nil { 75 + return "", fmt.Errorf("encoding PAR body: %w", err) 76 + } 77 + bodyBytes := []byte(vals.Encode()) 78 + 79 + dpopPrivKey, err := atcrypto.GeneratePrivateKeyP256() 80 + if err != nil { 81 + return "", fmt.Errorf("generating DPoP key: %w", err) 82 + } 83 + 84 + parURL := authserverMeta.PushedAuthorizationRequestEndpoint 85 + dpopServerNonce := "" 86 + var resp *http.Response 87 + for range 2 { 88 + dpopJWT, err := oauth.NewAuthDPoP("POST", parURL, dpopServerNonce, dpopPrivKey) 89 + if err != nil { 90 + return "", fmt.Errorf("DPoP JWT: %w", err) 91 + } 92 + 93 + req, err := http.NewRequestWithContext(ctx, "POST", parURL, bytes.NewBuffer(bodyBytes)) 94 + if err != nil { 95 + return "", fmt.Errorf("new PAR request: %w", err) 96 + } 97 + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 98 + req.Header.Set("DPoP", dpopJWT) 99 + 100 + resp, err = clientApp.Client.Do(req) 101 + if err != nil { 102 + return "", fmt.Errorf("PAR request: %w", err) 103 + } 104 + 105 + if n := resp.Header.Get("DPoP-Nonce"); n != "" { 106 + dpopServerNonce = n 107 + } 108 + 109 + // Retry once on DPoP nonce challenge 110 + if resp.StatusCode == http.StatusBadRequest && dpopServerNonce != "" { 111 + reason := readAuthError(resp) 112 + if reason == "use_dpop_nonce" { 113 + continue 114 + } 115 + return "", fmt.Errorf("PAR request failed (HTTP %d): %s", resp.StatusCode, reason) 116 + } 117 + break 118 + } 119 + 120 + defer resp.Body.Close() 121 + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { 122 + return "", fmt.Errorf("PAR request failed (HTTP %d): %s", resp.StatusCode, readAuthError(resp)) 123 + } 124 + 125 + var parResp oauth.PushedAuthResponse 126 + if err := json.NewDecoder(resp.Body).Decode(&parResp); err != nil { 127 + return "", fmt.Errorf("decoding PAR response: %w", err) 128 + } 129 + 130 + info := oauth.AuthRequestData{ 131 + State: state, 132 + AuthServerURL: authserverMeta.Issuer, 133 + Scopes: clientApp.Config.Scopes, 134 + PKCEVerifier: pkceVerifier, 135 + RequestURI: parResp.RequestURI, 136 + AuthServerTokenEndpoint: authserverMeta.TokenEndpoint, 137 + AuthServerRevocationEndpoint: authserverMeta.RevocationEndpoint, 138 + DPoPAuthServerNonce: dpopServerNonce, 139 + DPoPPrivateKeyMultibase: dpopPrivKey.Multibase(), 140 + } 141 + 142 + if err := clientApp.Store.SaveAuthRequestInfo(ctx, info); err != nil { 143 + return "", fmt.Errorf("saving auth request info: %w", err) 144 + } 145 + 146 + params := url.Values{} 147 + params.Set("client_id", clientApp.Config.ClientID) 148 + params.Set("request_uri", parResp.RequestURI) 149 + redirectURL := fmt.Sprintf("%s?%s", authserverMeta.AuthorizationEndpoint, params.Encode()) 150 + 151 + slog.Debug("started signup flow", 152 + "authserver", authserverMeta.Issuer, 153 + "state", state, 154 + "redirect", redirectURL, 155 + ) 156 + 157 + return redirectURL, nil 158 + } 159 + 160 + // secureRandomBase64 returns `sizeBytes` random bytes base64 (URL-safe, no padding) encoded. 161 + // Mirrors indigo's private helper of the same name. 162 + func secureRandomBase64(sizeBytes int) (string, error) { 163 + buf := make([]byte, sizeBytes) 164 + if _, err := rand.Read(buf); err != nil { 165 + return "", err 166 + } 167 + return base64.RawURLEncoding.EncodeToString(buf), nil 168 + } 169 + 170 + // scopeString joins OAuth scopes with spaces (OAuth 2.0 / RFC 6749 §3.3). 171 + func scopeString(scopes []string) string { 172 + out := "" 173 + for i, s := range scopes { 174 + if i > 0 { 175 + out += " " 176 + } 177 + out += s 178 + } 179 + return out 180 + } 181 + 182 + // readAuthError best-effort extracts the `error` code from an OAuth error 183 + // response body and always closes the body. Mirrors indigo's private 184 + // parseAuthErrorReason. 185 + func readAuthError(resp *http.Response) string { 186 + defer resp.Body.Close() 187 + b, err := io.ReadAll(resp.Body) 188 + if err != nil { 189 + return "" 190 + } 191 + var e struct { 192 + Error string `json:"error"` 193 + } 194 + if json.Unmarshal(b, &e) == nil && e.Error != "" { 195 + return e.Error 196 + } 197 + return string(b) 198 + }