Coffee journaling on ATProto (alpha) alpha.arabica.social
coffee
17
fork

Configure Feed

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

feat: create account page

+288 -2
+17 -2
internal/email/email.go
··· 1 1 package email 2 2 3 3 import ( 4 + "crypto/rand" 4 5 "crypto/tls" 5 6 "fmt" 6 7 "net" 7 8 "net/smtp" 8 9 "strconv" 10 + "strings" 11 + "time" 9 12 ) 10 13 11 14 // Config holds SMTP configuration for sending email. ··· 45 48 } 46 49 47 50 addr := net.JoinHostPort(s.cfg.Host, strconv.Itoa(s.cfg.Port)) 48 - msg := fmt.Sprintf("From: %s\r\nTo: %s\r\nSubject: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\n%s", 49 - s.cfg.From, to, subject, body) 51 + 52 + // Extract domain from From address for Message-ID 53 + domain := s.cfg.Host 54 + if parts := strings.SplitN(s.cfg.From, "@", 2); len(parts) == 2 { 55 + domain = parts[1] 56 + } 57 + 58 + // Generate a random Message-ID 59 + randBytes := make([]byte, 16) 60 + rand.Read(randBytes) 61 + messageID := fmt.Sprintf("<%x.%d@%s>", randBytes, time.Now().UnixNano(), domain) 62 + 63 + msg := fmt.Sprintf("From: Arabica <%s>\r\nTo: %s\r\nSubject: %s\r\nDate: %s\r\nMessage-ID: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\n%s", 64 + s.cfg.From, to, subject, time.Now().UTC().Format(time.RFC1123Z), messageID, body) 50 65 51 66 var auth smtp.Auth 52 67 if s.cfg.User != "" {
+132
internal/handlers/handlers.go
··· 3 3 import ( 4 4 "context" 5 5 "encoding/json" 6 + "errors" 6 7 "fmt" 7 8 "net/http" 8 9 "sort" ··· 2080 2081 // Email the invite code to the requester 2081 2082 if h.emailSender != nil && h.emailSender.Enabled() { 2082 2083 subject := "Your Arabica Invite Code" 2084 + // TODO: this should probably use the env var rather than hard coded 2083 2085 body := fmt.Sprintf("Welcome to Arabica!\n\nHere is your invite code to create an account on arabica.systems:\n\n %s\n\nVisit https://arabica.systems to sign up with this code.\n\nHappy brewing!\n", out.Code) 2084 2086 if err := h.emailSender.Send(reqEmail, subject, body); err != nil { 2085 2087 log.Error().Err(err).Str("email", reqEmail).Msg("Failed to send invite email") ··· 2134 2136 2135 2137 w.Header().Set("HX-Trigger", "mod-action") 2136 2138 w.WriteHeader(http.StatusOK) 2139 + } 2140 + 2141 + // HandleCreateAccount renders the account creation form (GET /join/create). 2142 + func (h *Handler) HandleCreateAccount(w http.ResponseWriter, r *http.Request) { 2143 + didStr, err := atproto.GetAuthenticatedDID(r.Context()) 2144 + isAuthenticated := err == nil && didStr != "" 2145 + 2146 + var userProfile *bff.UserProfile 2147 + if isAuthenticated { 2148 + userProfile = h.getUserProfile(r.Context(), didStr) 2149 + } 2150 + 2151 + layoutData := h.buildLayoutData(r, "Create Account", isAuthenticated, didStr, userProfile) 2152 + 2153 + props := pages.CreateAccountProps{ 2154 + InviteCode: r.URL.Query().Get("code"), 2155 + HandleDomain: "arabica.systems", 2156 + } 2157 + 2158 + if err := pages.CreateAccount(layoutData, props).Render(r.Context(), w); err != nil { 2159 + http.Error(w, "Failed to render page", http.StatusInternalServerError) 2160 + log.Error().Err(err).Msg("Failed to render create account page") 2161 + } 2162 + } 2163 + 2164 + // HandleCreateAccountSubmit processes the account creation form (POST /join/create). 2165 + func (h *Handler) HandleCreateAccountSubmit(w http.ResponseWriter, r *http.Request) { 2166 + didStr, err := atproto.GetAuthenticatedDID(r.Context()) 2167 + isAuthenticated := err == nil && didStr != "" 2168 + 2169 + var userProfile *bff.UserProfile 2170 + if isAuthenticated { 2171 + userProfile = h.getUserProfile(r.Context(), didStr) 2172 + } 2173 + 2174 + if err := r.ParseForm(); err != nil { 2175 + http.Error(w, "Invalid request", http.StatusBadRequest) 2176 + return 2177 + } 2178 + 2179 + inviteCode := strings.TrimSpace(r.FormValue("invite_code")) 2180 + handle := strings.TrimSpace(r.FormValue("handle")) 2181 + emailAddr := strings.TrimSpace(r.FormValue("email")) 2182 + password := r.FormValue("password") 2183 + passwordConfirm := r.FormValue("password_confirm") 2184 + honeypot := r.FormValue("website") 2185 + 2186 + // Honeypot check — bots fill hidden fields; show fake success 2187 + if honeypot != "" { 2188 + layoutData := h.buildLayoutData(r, "Account Created", isAuthenticated, didStr, userProfile) 2189 + _ = pages.CreateAccountSuccess(layoutData, pages.CreateAccountSuccessProps{Handle: "user.arabica.systems"}).Render(r.Context(), w) 2190 + return 2191 + } 2192 + 2193 + handleDomain := "arabica.systems" 2194 + 2195 + // Render form with error helper 2196 + renderError := func(msg string) { 2197 + layoutData := h.buildLayoutData(r, "Create Account", isAuthenticated, didStr, userProfile) 2198 + props := pages.CreateAccountProps{ 2199 + Error: msg, 2200 + InviteCode: inviteCode, 2201 + Handle: handle, 2202 + Email: emailAddr, 2203 + HandleDomain: handleDomain, 2204 + } 2205 + if err := pages.CreateAccount(layoutData, props).Render(r.Context(), w); err != nil { 2206 + http.Error(w, "Failed to render page", http.StatusInternalServerError) 2207 + } 2208 + } 2209 + 2210 + // Validate required fields 2211 + if inviteCode == "" || handle == "" || emailAddr == "" || password == "" { 2212 + renderError("All fields are required.") 2213 + return 2214 + } 2215 + if password != passwordConfirm { 2216 + renderError("Passwords do not match.") 2217 + return 2218 + } 2219 + 2220 + // Build full handle 2221 + fullHandle := handle + "." + handleDomain 2222 + 2223 + if h.pdsAdminURL == "" { 2224 + renderError("Account creation is not available at this time.") 2225 + log.Error().Msg("PDS admin URL not configured for account creation") 2226 + return 2227 + } 2228 + 2229 + // Call PDS createAccount (public endpoint, no admin token needed) 2230 + client := &xrpc.Client{Host: h.pdsAdminURL} 2231 + out, err := comatproto.ServerCreateAccount(r.Context(), client, &comatproto.ServerCreateAccount_Input{ 2232 + Handle: fullHandle, 2233 + Email: &emailAddr, 2234 + Password: &password, 2235 + InviteCode: &inviteCode, 2236 + }) 2237 + if err != nil { 2238 + errMsg := "Account creation failed. Please try again." 2239 + var xrpcErr *xrpc.Error 2240 + if errors.As(err, &xrpcErr) { 2241 + var inner *xrpc.XRPCError 2242 + if errors.As(xrpcErr.Wrapped, &inner) { 2243 + switch inner.ErrStr { 2244 + case "InvalidInviteCode": 2245 + errMsg = "Invalid or expired invite code." 2246 + case "HandleNotAvailable": 2247 + errMsg = "This handle is already taken." 2248 + case "InvalidHandle": 2249 + errMsg = "Invalid handle format. Use only letters, numbers, and hyphens." 2250 + default: 2251 + if inner.Message != "" { 2252 + errMsg = inner.Message 2253 + } 2254 + } 2255 + } 2256 + } 2257 + log.Error().Err(err).Str("handle", fullHandle).Msg("Failed to create account") 2258 + renderError(errMsg) 2259 + return 2260 + } 2261 + 2262 + log.Info().Str("handle", out.Handle).Str("did", out.Did).Msg("Account created") 2263 + 2264 + layoutData := h.buildLayoutData(r, "Account Created", isAuthenticated, didStr, userProfile) 2265 + if err := pages.CreateAccountSuccess(layoutData, pages.CreateAccountSuccessProps{Handle: out.Handle}).Render(r.Context(), w); err != nil { 2266 + http.Error(w, "Failed to render page", http.StatusInternalServerError) 2267 + log.Error().Err(err).Msg("Failed to render create account success page") 2268 + } 2137 2269 } 2138 2270 2139 2271 func (h *Handler) HandleATProto(w http.ResponseWriter, r *http.Request) {
+2
internal/routing/routing.go
··· 55 55 mux.HandleFunc("GET /terms", h.HandleTerms) 56 56 mux.HandleFunc("GET /join", h.HandleJoin) 57 57 mux.Handle("POST /join", cop.Handler(http.HandlerFunc(h.HandleJoinSubmit))) 58 + mux.HandleFunc("GET /join/create", h.HandleCreateAccount) 59 + mux.Handle("POST /join/create", cop.Handler(http.HandlerFunc(h.HandleCreateAccountSubmit))) 58 60 mux.HandleFunc("GET /atproto", h.HandleATProto) 59 61 mux.HandleFunc("GET /manage", h.HandleManage) 60 62 mux.HandleFunc("GET /brews", h.HandleBrewList)
+1
internal/web/pages/admin.templ
··· 656 656 hx-post="/_mod/invite" 657 657 hx-vals={ fmt.Sprintf(`{"id": "%s", "email": "%s"}`, req.ID, req.Email) } 658 658 hx-swap="none" 659 + hx-disabled-elt="this" 659 660 hx-confirm={ fmt.Sprintf("Create invite code and send to %s?", req.Email) } 660 661 > 661 662 Send Invite
+136
internal/web/pages/create_account.templ
··· 1 + package pages 2 + 3 + import "arabica/internal/web/components" 4 + 5 + // CreateAccountProps holds form state for the account creation page. 6 + type CreateAccountProps struct { 7 + Error string // Validation or XRPC error message 8 + InviteCode string // Pre-filled or re-populated invite code 9 + Handle string // Re-populated handle on error 10 + Email string // Re-populated email on error 11 + HandleDomain string // e.g. "arabica.systems" 12 + } 13 + 14 + // CreateAccount renders the account creation page with layout. 15 + templ CreateAccount(layout *components.LayoutData, props CreateAccountProps) { 16 + @components.Layout(layout, CreateAccountContent(props)) 17 + } 18 + 19 + // CreateAccountContent renders the account creation form. 20 + templ CreateAccountContent(props CreateAccountProps) { 21 + <div class="page-container-md"> 22 + <div class="flex items-center gap-3 mb-8"> 23 + @components.BackButton() 24 + <h1 class="text-4xl font-bold text-brown-900">Create Account</h1> 25 + </div> 26 + <p class="text-brown-800 leading-relaxed mb-6"> 27 + Create your account on <strong>{ props.HandleDomain }</strong> using your invite code. 28 + </p> 29 + if props.Error != "" { 30 + <div class="mb-6 rounded-lg border border-red-300 bg-red-50 p-4 text-red-800 text-sm"> 31 + { props.Error } 32 + </div> 33 + } 34 + <div class="card card-inner"> 35 + <form method="POST" action="/join/create" class="space-y-5"> 36 + @components.FormField(components.FormFieldProps{ 37 + Label: "Invite Code", 38 + Required: true, 39 + }, components.TextInput(components.TextInputProps{ 40 + Name: "invite_code", 41 + Value: props.InviteCode, 42 + Placeholder: "arabica-systems-xxxxx-xxxxx", 43 + Required: true, 44 + })) 45 + @components.FormField(components.FormFieldProps{ 46 + Label: "Handle", 47 + Required: true, 48 + HelperText: "Your @handle." + props.HandleDomain + " username", 49 + }, components.TextInput(components.TextInputProps{ 50 + Name: "handle", 51 + Value: props.Handle, 52 + Placeholder: "yourname", 53 + Required: true, 54 + })) 55 + @components.FormField(components.FormFieldProps{ 56 + Label: "Email", 57 + Required: true, 58 + }, components.TextInput(components.TextInputProps{ 59 + Name: "email", 60 + Type: "email", 61 + Value: props.Email, 62 + Placeholder: "you@example.com", 63 + Required: true, 64 + })) 65 + @components.FormField(components.FormFieldProps{ 66 + Label: "Password", 67 + Required: true, 68 + }, components.TextInput(components.TextInputProps{ 69 + Name: "password", 70 + Type: "password", 71 + Placeholder: "Choose a strong password", 72 + Required: true, 73 + })) 74 + @components.FormField(components.FormFieldProps{ 75 + Label: "Confirm Password", 76 + Required: true, 77 + }, components.TextInput(components.TextInputProps{ 78 + Name: "password_confirm", 79 + Type: "password", 80 + Placeholder: "Confirm your password", 81 + Required: true, 82 + })) 83 + <!-- Honeypot field — hidden from real users --> 84 + <div style="display:none" aria-hidden="true"> 85 + <label for="website">Website</label> 86 + <input type="text" name="website" id="website" tabindex="-1" autocomplete="off"/> 87 + </div> 88 + <div class="pt-2"> 89 + @components.PrimaryButton(components.ButtonProps{ 90 + Text: "Create Account", 91 + Type: "submit", 92 + }) 93 + </div> 94 + </form> 95 + </div> 96 + <p class="text-sm text-brown-600 mt-6 text-center"> 97 + Already have an account? 98 + <a href="/login" class="link-bold">Log in here</a>. 99 + </p> 100 + </div> 101 + } 102 + 103 + // CreateAccountSuccessProps holds data for the success page. 104 + type CreateAccountSuccessProps struct { 105 + Handle string // The created handle (e.g. "yourname.arabica.systems") 106 + } 107 + 108 + // CreateAccountSuccess renders the success page after account creation. 109 + templ CreateAccountSuccess(layout *components.LayoutData, props CreateAccountSuccessProps) { 110 + @components.Layout(layout, CreateAccountSuccessContent(props)) 111 + } 112 + 113 + // CreateAccountSuccessContent renders the success message. 114 + templ CreateAccountSuccessContent(props CreateAccountSuccessProps) { 115 + <div class="page-container-md"> 116 + <div class="flex items-center gap-3 mb-8"> 117 + @components.BackButton() 118 + <h1 class="text-4xl font-bold text-brown-900">Account Created</h1> 119 + </div> 120 + <div class="card card-inner text-center py-12"> 121 + <svg class="w-16 h-16 mx-auto mb-6 text-green-600" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> 122 + <path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"></path> 123 + </svg> 124 + <h2 class="text-2xl font-semibold text-brown-900 mb-4">Welcome to Arabica!</h2> 125 + <p class="text-brown-800 leading-relaxed mb-2"> 126 + Your account <strong>{ props.Handle }</strong> has been created. 127 + </p> 128 + <p class="text-brown-700 text-sm mb-8"> 129 + You can now log in and start tracking your brews. 130 + </p> 131 + <a href="/login" class="btn-primary px-8 py-3 font-semibold shadow-lg hover:shadow-xl"> 132 + Log In 133 + </a> 134 + </div> 135 + </div> 136 + }