···436436 unique(repo_at, ref, language)437437 );438438439439+ create table if not exists signups_inflight (440440+ id integer primary key autoincrement,441441+ email text not null unique,442442+ invite_code text not null,443443+ created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))444444+ );445445+439446 create table if not exists migrations (440447 id integer primary key autoincrement,441448 name text unique
+16-2
appview/db/email.go
···103103 query := `104104 select email, did105105 from emails106106- where 107107- verified = ? 106106+ where107107+ verified = ?108108 and email in (` + strings.Join(placeholders, ",") + `)109109 `110110···153153 `154154 var count int155155 err := e.QueryRow(query, did, email).Scan(&count)156156+ if err != nil {157157+ return false, err158158+ }159159+ return count > 0, nil160160+}161161+162162+func CheckEmailExistsAtAll(e Execer, email string) (bool, error) {163163+ query := `164164+ select count(*)165165+ from emails166166+ where email = ?167167+ `168168+ var count int169169+ err := e.QueryRow(query, email).Scan(&count)156170 if err != nil {157171 return false, err158172 }
···1717 />1818 <meta1919 property="og:description"2020- content="login to tangled"2020+ content="login to or sign up for tangled"2121 />2222 <script src="/static/htmx.min.js"></script>2323 <link···2525 href="/static/tw.css?{{ cssContentHash }}"2626 type="text/css"2727 />2828- <title>login · tangled</title>2828+ <title>login or sign up · tangled</title>2929 </head>3030 <body class="flex items-center justify-center min-h-screen">3131 <main class="max-w-md px-6 -mt-4">···5151 name="handle"5252 tabindex="1"5353 required5454+ placeholder="foo.tngl.sh"5455 />5556 <span class="text-sm text-gray-500 mt-1">5656- Use your5757- <a href="https://bsky.app">Bluesky</a> handle to log5858- in. You will then be redirected to your PDS to5959- complete authentication.5757+ Use your <a href="https://atproto.com">ATProto</a>5858+ handle to log in. If you're unsure, this is likely5959+ your Tangled (<code>.tngl.sh</code>) or <a href="https://bsky.app">Bluesky</a> (<code>.bsky.social</code>) account.6060 </span>6161 </div>6262···6969 <span>login</span>7070 </button>7171 </form>7272- <p class="text-sm text-gray-500">7272+ <hr class="my-4">7373+ <p class="text-sm text-gray-500 mt-4">7474+ Alternatively, you may create an account on Tangled below. You will7575+ get a <code>user.tngl.sh</code> handle.7676+ </p>7777+7878+ <details class="group">7979+8080+ <summary8181+ class="btn cursor-pointer w-full mt-4 flex items-center justify-center gap-2"8282+ >8383+ create an account8484+8585+ <div class="group-open:hidden flex">{{ i "arrow-right" "w-4 h-4" }}</div>8686+ <div class="hidden group-open:flex">{{ i "arrow-down" "w-4 h-4" }}</div>8787+ </summary>8888+ <form8989+ class="mt-4 max-w-sm mx-auto"9090+ hx-post="/signup"9191+ hx-swap="none"9292+ hx-disabled-elt="#signup-button"9393+ >9494+ <div class="flex flex-col mt-2">9595+ <label for="email">email</label>9696+ <input9797+ type="email"9898+ id="email"9999+ name="email"100100+ tabindex="4"101101+ required102102+ placeholder="jason@bourne.co"103103+ />104104+ </div>105105+ <span class="text-sm text-gray-500 mt-1">106106+ You will receive an email with a code. Enter that, along with your107107+ desired username and password in the next page to complete your registration.108108+ </span>109109+ <button110110+ class="btn w-full my-2 mt-6"111111+ type="submit"112112+ id="signup-button"113113+ tabindex="7"114114+ >115115+ <span>sign up</span>116116+ </button>117117+ </form>118118+ </details>119119+ <p class="text-sm text-gray-500 mt-6">73120 Join our <a href="https://chat.tangled.sh">Discord</a> or74121 IRC channel:75122 <a href="https://web.libera.chat/#tangled"
+104
appview/signup/requests.go
···11+package signup22+33+// We have this extra code here for now since the xrpcclient package44+// only supports OAuth'd requests; these are unauthenticated or use PDS admin auth.55+66+import (77+ "bytes"88+ "encoding/json"99+ "fmt"1010+ "io"1111+ "net/http"1212+ "net/url"1313+)1414+1515+// makePdsRequest is a helper method to make requests to the PDS service1616+func (s *Signup) makePdsRequest(method, endpoint string, body interface{}, useAuth bool) (*http.Response, error) {1717+ jsonData, err := json.Marshal(body)1818+ if err != nil {1919+ return nil, err2020+ }2121+2222+ url := fmt.Sprintf("%s/xrpc/%s", s.config.Pds.Host, endpoint)2323+ req, err := http.NewRequest(method, url, bytes.NewBuffer(jsonData))2424+ if err != nil {2525+ return nil, err2626+ }2727+2828+ req.Header.Set("Content-Type", "application/json")2929+3030+ if useAuth {3131+ req.SetBasicAuth("admin", s.config.Pds.AdminSecret)3232+ }3333+3434+ return http.DefaultClient.Do(req)3535+}3636+3737+// handlePdsError processes error responses from the PDS service3838+func (s *Signup) handlePdsError(resp *http.Response, action string) error {3939+ var errorResp struct {4040+ Error string `json:"error"`4141+ Message string `json:"message"`4242+ }4343+4444+ respBody, _ := io.ReadAll(resp.Body)4545+ if err := json.Unmarshal(respBody, &errorResp); err == nil && errorResp.Message != "" {4646+ return fmt.Errorf("Failed to %s: %s - %s.", action, errorResp.Error, errorResp.Message)4747+ }4848+4949+ // Fallback if we couldn't parse the error5050+ return fmt.Errorf("failed to %s, status code: %d", action, resp.StatusCode)5151+}5252+5353+func (s *Signup) inviteCodeRequest() (string, error) {5454+ body := map[string]any{"useCount": 1}5555+5656+ resp, err := s.makePdsRequest("POST", "com.atproto.server.createInviteCode", body, true)5757+ if err != nil {5858+ return "", err5959+ }6060+ defer resp.Body.Close()6161+6262+ if resp.StatusCode != http.StatusOK {6363+ return "", s.handlePdsError(resp, "create invite code")6464+ }6565+6666+ var result map[string]string6767+ json.NewDecoder(resp.Body).Decode(&result)6868+ return result["code"], nil6969+}7070+7171+func (s *Signup) createAccountRequest(username, password, email, code string) (string, error) {7272+ parsedURL, err := url.Parse(s.config.Pds.Host)7373+ if err != nil {7474+ return "", fmt.Errorf("invalid PDS host URL: %w", err)7575+ }7676+7777+ pdsDomain := parsedURL.Hostname()7878+7979+ body := map[string]string{8080+ "email": email,8181+ "handle": fmt.Sprintf("%s.%s", username, pdsDomain),8282+ "password": password,8383+ "inviteCode": code,8484+ }8585+8686+ resp, err := s.makePdsRequest("POST", "com.atproto.server.createAccount", body, false)8787+ if err != nil {8888+ return "", err8989+ }9090+ defer resp.Body.Close()9191+9292+ if resp.StatusCode != http.StatusOK {9393+ return "", s.handlePdsError(resp, "create account")9494+ }9595+9696+ var result struct {9797+ DID string `json:"did"`9898+ }9999+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {100100+ return "", fmt.Errorf("failed to decode create account response: %w", err)101101+ }102102+103103+ return result.DID, nil104104+}
+172
appview/signup/signup.go
···11+package signup22+33+import (44+ "fmt"55+ "log/slog"66+ "net/http"77+88+ "github.com/go-chi/chi/v5"99+ "github.com/posthog/posthog-go"1010+ "tangled.sh/tangled.sh/core/appview/config"1111+ "tangled.sh/tangled.sh/core/appview/db"1212+ "tangled.sh/tangled.sh/core/appview/dns"1313+ "tangled.sh/tangled.sh/core/appview/email"1414+ "tangled.sh/tangled.sh/core/appview/pages"1515+ "tangled.sh/tangled.sh/core/appview/state/userutil"1616+ "tangled.sh/tangled.sh/core/appview/xrpcclient"1717+)1818+1919+type Signup struct {2020+ config *config.Config2121+ db *db.DB2222+ cf *dns.Cloudflare2323+ posthog posthog.Client2424+ xrpc *xrpcclient.Client2525+ idResolver *idresolver.Resolver2626+ pages *pages.Pages2727+ l *slog.Logger2828+}2929+3030+func New(cfg *config.Config, cf *dns.Cloudflare, database *db.DB, pc posthog.Client, idResolver *idresolver.Resolver, pages *pages.Pages, l *slog.Logger) *Signup {3131+ return &Signup{3232+ config: cfg,3333+ db: database,3434+ cf: cf,3535+ posthog: pc,3636+ idResolver: idResolver,3737+ pages: pages,3838+ l: l,3939+ }4040+}4141+4242+func (s *Signup) Router() http.Handler {4343+ r := chi.NewRouter()4444+ r.Post("/", s.signup)4545+ r.Get("/complete", s.complete)4646+ r.Post("/complete", s.complete)4747+4848+ return r4949+}5050+5151+func (s *Signup) signup(w http.ResponseWriter, r *http.Request) {5252+ emailId := r.FormValue("email")5353+5454+ if !email.IsValidEmail(emailId) {5555+ s.pages.Notice(w, "login-msg", "Invalid email address.")5656+ return5757+ }5858+5959+ exists, err := db.CheckEmailExistsAtAll(s.db, emailId)6060+ if err != nil {6161+ s.l.Error("failed to check email existence", "error", err)6262+ s.pages.Notice(w, "login-msg", "Failed to complete signup. Try again later.")6363+ return6464+ }6565+ if exists {6666+ s.pages.Notice(w, "login-msg", "Email already exists.")6767+ return6868+ }6969+7070+ code, err := s.inviteCodeRequest()7171+ if err != nil {7272+ s.l.Error("failed to create invite code", "error", err)7373+ s.pages.Notice(w, "login-msg", "Failed to create invite code.")7474+ return7575+ }7676+7777+ em := email.Email{7878+ APIKey: s.config.Resend.ApiKey,7979+ From: s.config.Resend.SentFrom,8080+ To: emailId,8181+ Subject: "Verify your Tangled account",8282+ Text: `Copy and paste this code below to verify your account on Tangled.8383+ ` + code,8484+ Html: `<p>Copy and paste this code below to verify your account on Tangled.</p>8585+<p><code>` + code + `</code></p>`,8686+ }8787+8888+ err = email.SendEmail(em)8989+ if err != nil {9090+ s.l.Error("failed to send email", "error", err)9191+ s.pages.Notice(w, "login-msg", "Failed to send email.")9292+ return9393+ }9494+ err = db.AddInflightSignup(s.db, db.InflightSignup{9595+ Email: emailId,9696+ InviteCode: code,9797+ })9898+ if err != nil {9999+ s.l.Error("failed to add inflight signup", "error", err)100100+ s.pages.Notice(w, "login-msg", "Failed to complete sign up. Try again later.")101101+ return102102+ }103103+104104+ s.pages.HxRedirect(w, "/signup/complete")105105+}106106+107107+func (s *Signup) complete(w http.ResponseWriter, r *http.Request) {108108+ switch r.Method {109109+ case http.MethodGet:110110+ s.pages.CompleteSignup(w, pages.SignupParams{})111111+ case http.MethodPost:112112+ username := r.FormValue("username")113113+ password := r.FormValue("password")114114+ code := r.FormValue("code")115115+116116+ if !userutil.IsValidSubdomain(username) {117117+ s.pages.Notice(w, "signup-error", "Invalid username. Username must be 4–63 characters, lowercase letters, digits, or hyphens, and can't start or end with a hyphen.")118118+ return119119+ }120120+121121+ email, err := db.GetEmailForCode(s.db, code)122122+ if err != nil {123123+ s.l.Error("failed to get email for code", "error", err)124124+ s.pages.Notice(w, "signup-error", "Failed to complete sign up. Try again later.")125125+ return126126+ }127127+128128+ did, err := s.createAccountRequest(username, password, email, code)129129+ if err != nil {130130+ s.l.Error("failed to create account", "error", err)131131+ s.pages.Notice(w, "signup-error", err.Error())132132+ return133133+ }134134+135135+ err = s.cf.CreateDNSRecord(r.Context(), dns.Record{136136+ Type: "TXT",137137+ Name: "_atproto." + username,138138+ Content: "did=" + did,139139+ TTL: 6400,140140+ Proxied: false,141141+ })142142+ if err != nil {143143+ s.l.Error("failed to create DNS record", "error", err)144144+ s.pages.Notice(w, "signup-error", "Failed to complete sign up. Try again later.")145145+ return146146+ }147147+148148+ err = db.AddEmail(s.db, db.Email{149149+ Did: did,150150+ Address: email,151151+ Verified: true,152152+ Primary: true,153153+ })154154+ if err != nil {155155+ s.l.Error("failed to add email", "error", err)156156+ s.pages.Notice(w, "signup-error", "Failed to complete sign up. Try again later.")157157+ return158158+ }159159+160160+ s.pages.Notice(w, "signup-msg", fmt.Sprintf(`Account created successfully. You can now161161+ <a class="underline text-black dark:text-white" href="/login">login</a>162162+ with <code>%s.tngl.sh</code>.`, username))163163+164164+ go func() {165165+ err := db.DeleteInflightSignup(s.db, email)166166+ if err != nil {167167+ s.l.Error("failed to delete inflight signup", "error", err)168168+ }169169+ }()170170+ return171171+ }172172+}