appview: tangled pds signup flow #357

merged
opened by anirudh.fi targeting master from push-qlzpkvltqlzm
Changed files
+594 -14
appview
+12
appview/config/config.go
···
DB int `env:"DB, default=0"`
}
+
type PdsConfig struct {
+
Host string `env:"HOST, default=https://tngl.sh"`
+
AdminSecret string `env:"ADMIN_SECRET"`
+
}
+
+
type Cloudflare struct {
+
ApiToken string `env:"API_TOKEN"`
+
ZoneId string `env:"ZONE_ID"`
+
}
+
func (cfg RedisConfig) ToURL() string {
u := &url.URL{
Scheme: "redis",
···
Avatar AvatarConfig `env:",prefix=TANGLED_AVATAR_"`
OAuth OAuthConfig `env:",prefix=TANGLED_OAUTH_"`
Redis RedisConfig `env:",prefix=TANGLED_REDIS_"`
+
Pds PdsConfig `env:",prefix=TANGLED_PDS_"`
+
Cloudflare Cloudflare `env:",prefix=TANGLED_CLOUDFLARE_"`
}
func LoadConfig(ctx context.Context) (*Config, error) {
+7
appview/db/db.go
···
unique(repo_at, ref, language)
);
+
create table if not exists signups_inflight (
+
id integer primary key autoincrement,
+
email text not null unique,
+
invite_code text not null,
+
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
+
);
+
create table if not exists migrations (
id integer primary key autoincrement,
name text unique
+16 -2
appview/db/email.go
···
query := `
select email, did
from emails
-
where
-
verified = ?
+
where
+
verified = ?
and email in (` + strings.Join(placeholders, ",") + `)
`
···
return count > 0, nil
}
+
func CheckEmailExistsAtAll(e Execer, email string) (bool, error) {
+
query := `
+
select count(*)
+
from emails
+
where email = ?
+
`
+
var count int
+
err := e.QueryRow(query, email).Scan(&count)
+
if err != nil {
+
return false, err
+
}
+
return count > 0, nil
+
}
+
func CheckValidVerificationCode(e Execer, did string, email string, code string) (bool, error) {
query := `
select count(*)
+29
appview/db/signup.go
···
+
package db
+
+
import "time"
+
+
type InflightSignup struct {
+
Id int64
+
Email string
+
InviteCode string
+
Created time.Time
+
}
+
+
func AddInflightSignup(e Execer, signup InflightSignup) error {
+
query := `insert into signups_inflight (email, invite_code) values (?, ?)`
+
_, err := e.Exec(query, signup.Email, signup.InviteCode)
+
return err
+
}
+
+
func DeleteInflightSignup(e Execer, email string) error {
+
query := `delete from signups_inflight where email = ?`
+
_, err := e.Exec(query, email)
+
return err
+
}
+
+
func GetEmailForCode(e Execer, inviteCode string) (string, error) {
+
query := `select email from signups_inflight where invite_code = ?`
+
var email string
+
err := e.QueryRow(query, inviteCode).Scan(&email)
+
return email, err
+
}
+53
appview/dns/cloudflare.go
···
+
package dns
+
+
import (
+
"context"
+
"fmt"
+
+
"github.com/cloudflare/cloudflare-go"
+
"tangled.sh/tangled.sh/core/appview/config"
+
)
+
+
type Record struct {
+
Type string
+
Name string
+
Content string
+
TTL int
+
Proxied bool
+
}
+
+
type Cloudflare struct {
+
api *cloudflare.API
+
zone string
+
}
+
+
func NewCloudflare(c *config.Config) (*Cloudflare, error) {
+
apiToken := c.Cloudflare.ApiToken
+
api, err := cloudflare.NewWithAPIToken(apiToken)
+
if err != nil {
+
return nil, err
+
}
+
return &Cloudflare{api: api, zone: c.Cloudflare.ZoneId}, nil
+
}
+
+
func (cf *Cloudflare) CreateDNSRecord(ctx context.Context, record Record) error {
+
_, err := cf.api.CreateDNSRecord(ctx, cloudflare.ZoneIdentifier(cf.zone), cloudflare.CreateDNSRecordParams{
+
Type: record.Type,
+
Name: record.Name,
+
Content: record.Content,
+
TTL: record.TTL,
+
Proxied: &record.Proxied,
+
})
+
if err != nil {
+
return fmt.Errorf("failed to create DNS record: %w", err)
+
}
+
return nil
+
}
+
+
func (cf *Cloudflare) DeleteDNSRecord(ctx context.Context, recordID string) error {
+
err := cf.api.DeleteDNSRecord(ctx, cloudflare.ZoneIdentifier(cf.zone), recordID)
+
if err != nil {
+
return fmt.Errorf("failed to delete DNS record: %w", err)
+
}
+
return nil
+
}
+6
appview/pages/pages.go
···
return p.executePlain("user/login", w, params)
}
+
type SignupParams struct{}
+
+
func (p *Pages) CompleteSignup(w io.Writer, params SignupParams) error {
+
return p.executePlain("user/completeSignup", w, params)
+
}
+
type TimelineParams struct {
LoggedInUser *oauth.User
Timeline []db.TimelineEvent
+104
appview/pages/templates/user/completeSignup.html
···
+
{{ define "user/completeSignup" }}
+
<!doctype html>
+
<html lang="en" class="dark:bg-gray-900">
+
<head>
+
<meta charset="UTF-8" />
+
<meta
+
name="viewport"
+
content="width=device-width, initial-scale=1.0"
+
/>
+
<meta
+
property="og:title"
+
content="complete signup · tangled"
+
/>
+
<meta
+
property="og:url"
+
content="https://tangled.sh/complete-signup"
+
/>
+
<meta
+
property="og:description"
+
content="complete your signup for tangled"
+
/>
+
<script src="/static/htmx.min.js"></script>
+
<link
+
rel="stylesheet"
+
href="/static/tw.css?{{ cssContentHash }}"
+
type="text/css"
+
/>
+
<title>complete signup &middot; tangled</title>
+
</head>
+
<body class="flex items-center justify-center min-h-screen">
+
<main class="max-w-md px-6 -mt-4">
+
<h1
+
class="text-center text-2xl font-semibold italic dark:text-white"
+
>
+
tangled
+
</h1>
+
<h2 class="text-center text-xl italic dark:text-white">
+
tightly-knit social coding.
+
</h2>
+
<form
+
class="mt-4 max-w-sm mx-auto"
+
hx-post="/signup/complete"
+
hx-swap="none"
+
hx-disabled-elt="#complete-signup-button"
+
>
+
<div class="flex flex-col">
+
<label for="code">verification code</label>
+
<input
+
type="text"
+
id="code"
+
name="code"
+
tabindex="1"
+
required
+
placeholder="pds-tngl-sh-foo-bar"
+
/>
+
<span class="text-sm text-gray-500 mt-1">
+
Enter the code sent to your email.
+
</span>
+
</div>
+
+
<div class="flex flex-col mt-4">
+
<label for="username">desired username</label>
+
<input
+
type="text"
+
id="username"
+
name="username"
+
tabindex="2"
+
required
+
placeholder="jason"
+
/>
+
<span class="text-sm text-gray-500 mt-1">
+
Your complete handle will be of the form <code>user.tngl.sh</code>.
+
</span>
+
</div>
+
+
<div class="flex flex-col mt-4">
+
<label for="password">password</label>
+
<input
+
type="password"
+
id="password"
+
name="password"
+
tabindex="3"
+
required
+
/>
+
<span class="text-sm text-gray-500 mt-1">
+
Choose a strong password for your account.
+
</span>
+
</div>
+
+
<button
+
class="btn-create w-full my-2 mt-6"
+
type="submit"
+
id="complete-signup-button"
+
tabindex="4"
+
>
+
<span>complete signup</span>
+
</button>
+
</form>
+
<p id="signup-error" class="error w-full"></p>
+
<p id="signup-msg" class="dark:text-white w-full"></p>
+
</main>
+
</body>
+
</html>
+
{{ end }}
+54 -7
appview/pages/templates/user/login.html
···
/>
<meta
property="og:description"
-
content="login to tangled"
+
content="login to or sign up for tangled"
/>
<script src="/static/htmx.min.js"></script>
<link
···
href="/static/tw.css?{{ cssContentHash }}"
type="text/css"
/>
-
<title>login &middot; tangled</title>
+
<title>login or sign up &middot; tangled</title>
</head>
<body class="flex items-center justify-center min-h-screen">
<main class="max-w-md px-6 -mt-4">
···
name="handle"
tabindex="1"
required
+
placeholder="foo.tngl.sh"
/>
<span class="text-sm text-gray-500 mt-1">
-
Use your
-
<a href="https://bsky.app">Bluesky</a> handle to log
-
in. You will then be redirected to your PDS to
-
complete authentication.
+
Use your <a href="https://atproto.com">ATProto</a>
+
handle to log in. If you're unsure, this is likely
+
your Tangled (<code>.tngl.sh</code>) or <a href="https://bsky.app">Bluesky</a> (<code>.bsky.social</code>) account.
</span>
</div>
···
<span>login</span>
</button>
</form>
-
<p class="text-sm text-gray-500">
+
<hr class="my-4">
+
<p class="text-sm text-gray-500 mt-4">
+
Alternatively, you may create an account on Tangled below. You will
+
get a <code>user.tngl.sh</code> handle.
+
</p>
+
+
<details class="group">
+
+
<summary
+
class="btn cursor-pointer w-full mt-4 flex items-center justify-center gap-2"
+
>
+
create an account
+
+
<div class="group-open:hidden flex">{{ i "arrow-right" "w-4 h-4" }}</div>
+
<div class="hidden group-open:flex">{{ i "arrow-down" "w-4 h-4" }}</div>
+
</summary>
+
<form
+
class="mt-4 max-w-sm mx-auto"
+
hx-post="/signup"
+
hx-swap="none"
+
hx-disabled-elt="#signup-button"
+
>
+
<div class="flex flex-col mt-2">
+
<label for="email">email</label>
+
<input
+
type="email"
+
id="email"
+
name="email"
+
tabindex="4"
+
required
+
placeholder="jason@bourne.co"
+
/>
+
</div>
+
<span class="text-sm text-gray-500 mt-1">
+
You will receive an email with a code. Enter that, along with your
+
desired username and password in the next page to complete your registration.
+
</span>
+
<button
+
class="btn w-full my-2 mt-6"
+
type="submit"
+
id="signup-button"
+
tabindex="7"
+
>
+
<span>sign up</span>
+
</button>
+
</form>
+
</details>
+
<p class="text-sm text-gray-500 mt-6">
Join our <a href="https://chat.tangled.sh">Discord</a> or
IRC channel:
<a href="https://web.libera.chat/#tangled"
+104
appview/signup/requests.go
···
+
package signup
+
+
// We have this extra code here for now since the xrpcclient package
+
// only supports OAuth'd requests; these are unauthenticated or use PDS admin auth.
+
+
import (
+
"bytes"
+
"encoding/json"
+
"fmt"
+
"io"
+
"net/http"
+
"net/url"
+
)
+
+
// makePdsRequest is a helper method to make requests to the PDS service
+
func (s *Signup) makePdsRequest(method, endpoint string, body interface{}, useAuth bool) (*http.Response, error) {
+
jsonData, err := json.Marshal(body)
+
if err != nil {
+
return nil, err
+
}
+
+
url := fmt.Sprintf("%s/xrpc/%s", s.config.Pds.Host, endpoint)
+
req, err := http.NewRequest(method, url, bytes.NewBuffer(jsonData))
+
if err != nil {
+
return nil, err
+
}
+
+
req.Header.Set("Content-Type", "application/json")
+
+
if useAuth {
+
req.SetBasicAuth("admin", s.config.Pds.AdminSecret)
+
}
+
+
return http.DefaultClient.Do(req)
+
}
+
+
// handlePdsError processes error responses from the PDS service
+
func (s *Signup) handlePdsError(resp *http.Response, action string) error {
+
var errorResp struct {
+
Error string `json:"error"`
+
Message string `json:"message"`
+
}
+
+
respBody, _ := io.ReadAll(resp.Body)
+
if err := json.Unmarshal(respBody, &errorResp); err == nil && errorResp.Message != "" {
+
return fmt.Errorf("Failed to %s: %s - %s.", action, errorResp.Error, errorResp.Message)
+
}
+
+
// Fallback if we couldn't parse the error
+
return fmt.Errorf("failed to %s, status code: %d", action, resp.StatusCode)
+
}
+
+
func (s *Signup) inviteCodeRequest() (string, error) {
+
body := map[string]any{"useCount": 1}
+
+
resp, err := s.makePdsRequest("POST", "com.atproto.server.createInviteCode", body, true)
+
if err != nil {
+
return "", err
+
}
+
defer resp.Body.Close()
+
+
if resp.StatusCode != http.StatusOK {
+
return "", s.handlePdsError(resp, "create invite code")
+
}
+
+
var result map[string]string
+
json.NewDecoder(resp.Body).Decode(&result)
+
return result["code"], nil
+
}
+
+
func (s *Signup) createAccountRequest(username, password, email, code string) (string, error) {
+
parsedURL, err := url.Parse(s.config.Pds.Host)
+
if err != nil {
+
return "", fmt.Errorf("invalid PDS host URL: %w", err)
+
}
+
+
pdsDomain := parsedURL.Hostname()
+
+
body := map[string]string{
+
"email": email,
+
"handle": fmt.Sprintf("%s.%s", username, pdsDomain),
+
"password": password,
+
"inviteCode": code,
+
}
+
+
resp, err := s.makePdsRequest("POST", "com.atproto.server.createAccount", body, false)
+
if err != nil {
+
return "", err
+
}
+
defer resp.Body.Close()
+
+
if resp.StatusCode != http.StatusOK {
+
return "", s.handlePdsError(resp, "create account")
+
}
+
+
var result struct {
+
DID string `json:"did"`
+
}
+
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
+
return "", fmt.Errorf("failed to decode create account response: %w", err)
+
}
+
+
return result.DID, nil
+
}
+173
appview/signup/signup.go
···
+
package signup
+
+
import (
+
"fmt"
+
"log/slog"
+
"net/http"
+
+
"github.com/go-chi/chi/v5"
+
"github.com/posthog/posthog-go"
+
"tangled.sh/tangled.sh/core/appview/config"
+
"tangled.sh/tangled.sh/core/appview/db"
+
"tangled.sh/tangled.sh/core/appview/dns"
+
"tangled.sh/tangled.sh/core/appview/email"
+
"tangled.sh/tangled.sh/core/appview/idresolver"
+
"tangled.sh/tangled.sh/core/appview/pages"
+
"tangled.sh/tangled.sh/core/appview/state/userutil"
+
"tangled.sh/tangled.sh/core/appview/xrpcclient"
+
)
+
+
type Signup struct {
+
config *config.Config
+
db *db.DB
+
cf *dns.Cloudflare
+
posthog posthog.Client
+
xrpc *xrpcclient.Client
+
idResolver *idresolver.Resolver
+
pages *pages.Pages
+
l *slog.Logger
+
}
+
+
func New(cfg *config.Config, cf *dns.Cloudflare, database *db.DB, pc posthog.Client, idResolver *idresolver.Resolver, pages *pages.Pages, l *slog.Logger) *Signup {
+
return &Signup{
+
config: cfg,
+
db: database,
+
cf: cf,
+
posthog: pc,
+
idResolver: idResolver,
+
pages: pages,
+
l: l,
+
}
+
}
+
+
func (s *Signup) Router() http.Handler {
+
r := chi.NewRouter()
+
r.Post("/", s.signup)
+
r.Get("/complete", s.complete)
+
r.Post("/complete", s.complete)
+
+
return r
+
}
+
+
func (s *Signup) signup(w http.ResponseWriter, r *http.Request) {
+
emailId := r.FormValue("email")
+
+
if !email.IsValidEmail(emailId) {
+
s.pages.Notice(w, "login-msg", "Invalid email address.")
+
return
+
}
+
+
exists, err := db.CheckEmailExistsAtAll(s.db, emailId)
+
if err != nil {
+
s.l.Error("failed to check email existence", "error", err)
+
s.pages.Notice(w, "login-msg", "Failed to complete signup. Try again later.")
+
return
+
}
+
if exists {
+
s.pages.Notice(w, "login-msg", "Email already exists.")
+
return
+
}
+
+
code, err := s.inviteCodeRequest()
+
if err != nil {
+
s.l.Error("failed to create invite code", "error", err)
+
s.pages.Notice(w, "login-msg", "Failed to create invite code.")
+
return
+
}
+
+
em := email.Email{
+
APIKey: s.config.Resend.ApiKey,
+
From: s.config.Resend.SentFrom,
+
To: emailId,
+
Subject: "Verify your Tangled account",
+
Text: `Copy and paste this code below to verify your account on Tangled.
+
` + code,
+
Html: `<p>Copy and paste this code below to verify your account on Tangled.</p>
+
<p><code>` + code + `</code></p>`,
+
}
+
+
err = email.SendEmail(em)
+
if err != nil {
+
s.l.Error("failed to send email", "error", err)
+
s.pages.Notice(w, "login-msg", "Failed to send email.")
+
return
+
}
+
err = db.AddInflightSignup(s.db, db.InflightSignup{
+
Email: emailId,
+
InviteCode: code,
+
})
+
if err != nil {
+
s.l.Error("failed to add inflight signup", "error", err)
+
s.pages.Notice(w, "login-msg", "Failed to complete sign up. Try again later.")
+
return
+
}
+
+
s.pages.HxRedirect(w, "/signup/complete")
+
}
+
+
func (s *Signup) complete(w http.ResponseWriter, r *http.Request) {
+
switch r.Method {
+
case http.MethodGet:
+
s.pages.CompleteSignup(w, pages.SignupParams{})
+
case http.MethodPost:
+
username := r.FormValue("username")
+
password := r.FormValue("password")
+
code := r.FormValue("code")
+
+
if !userutil.IsValidSubdomain(username) {
+
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.")
+
return
+
}
+
+
email, err := db.GetEmailForCode(s.db, code)
+
if err != nil {
+
s.l.Error("failed to get email for code", "error", err)
+
s.pages.Notice(w, "signup-error", "Failed to complete sign up. Try again later.")
+
return
+
}
+
+
did, err := s.createAccountRequest(username, password, email, code)
+
if err != nil {
+
s.l.Error("failed to create account", "error", err)
+
s.pages.Notice(w, "signup-error", err.Error())
+
return
+
}
+
+
err = s.cf.CreateDNSRecord(r.Context(), dns.Record{
+
Type: "TXT",
+
Name: "_atproto." + username,
+
Content: "did=" + did,
+
TTL: 6400,
+
Proxied: false,
+
})
+
if err != nil {
+
s.l.Error("failed to create DNS record", "error", err)
+
s.pages.Notice(w, "signup-error", "Failed to complete sign up. Try again later.")
+
return
+
}
+
+
err = db.AddEmail(s.db, db.Email{
+
Did: did,
+
Address: email,
+
Verified: true,
+
Primary: true,
+
})
+
if err != nil {
+
s.l.Error("failed to add email", "error", err)
+
s.pages.Notice(w, "signup-error", "Failed to complete sign up. Try again later.")
+
return
+
}
+
+
s.pages.Notice(w, "signup-msg", fmt.Sprintf(`Account created successfully. You can now
+
<a class="underline text-black dark:text-white" href="/login">login</a>
+
with <code>%s.tngl.sh</code>.`, username))
+
+
go func() {
+
err := db.DeleteInflightSignup(s.db, email)
+
if err != nil {
+
s.l.Error("failed to delete inflight signup", "error", err)
+
}
+
}()
+
return
+
}
+
}
+9
appview/state/router.go
···
"tangled.sh/tangled.sh/core/appview/pulls"
"tangled.sh/tangled.sh/core/appview/repo"
"tangled.sh/tangled.sh/core/appview/settings"
+
"tangled.sh/tangled.sh/core/appview/signup"
"tangled.sh/tangled.sh/core/appview/spindles"
"tangled.sh/tangled.sh/core/appview/state/userutil"
"tangled.sh/tangled.sh/core/log"
···
r.Mount("/settings", s.SettingsRouter())
r.Mount("/knots", s.KnotsRouter(mw))
r.Mount("/spindles", s.SpindlesRouter())
+
r.Mount("/signup", s.SignupRouter())
r.Mount("/", s.OAuthRouter())
r.Get("/keys/{user}", s.Keys)
···
pipes := pipelines.New(s.oauth, s.repoResolver, s.pages, s.spindlestream, s.idResolver, s.db, s.config, s.enforcer)
return pipes.Router(mw)
}
+
+
func (s *State) SignupRouter() http.Handler {
+
logger := log.New("signup")
+
+
sig := signup.New(s.config, s.cf, s.db, s.posthog, s.idResolver, s.pages, logger)
+
return sig.Router()
+
}
+10 -2
appview/state/state.go
···
"tangled.sh/tangled.sh/core/appview/cache/session"
"tangled.sh/tangled.sh/core/appview/config"
"tangled.sh/tangled.sh/core/appview/db"
+
"tangled.sh/tangled.sh/core/appview/dns"
"tangled.sh/tangled.sh/core/appview/idresolver"
"tangled.sh/tangled.sh/core/appview/notify"
"tangled.sh/tangled.sh/core/appview/oauth"
"tangled.sh/tangled.sh/core/appview/pages"
-
posthog_service "tangled.sh/tangled.sh/core/appview/posthog"
+
posthogService "tangled.sh/tangled.sh/core/appview/posthog"
"tangled.sh/tangled.sh/core/appview/reporesolver"
"tangled.sh/tangled.sh/core/eventconsumer"
"tangled.sh/tangled.sh/core/jetstream"
···
jc *jetstream.JetstreamClient
config *config.Config
repoResolver *reporesolver.RepoResolver
+
cf *dns.Cloudflare
knotstream *eventconsumer.Consumer
spindlestream *eventconsumer.Consumer
}
···
var notifiers []notify.Notifier
if !config.Core.Dev {
-
notifiers = append(notifiers, posthog_service.NewPosthogNotifier(posthog))
+
notifiers = append(notifiers, posthogService.NewPosthogNotifier(posthog))
}
notifier := notify.NewMergedNotifier(notifiers...)
+
cf, err := dns.NewCloudflare(config)
+
if err != nil {
+
return nil, fmt.Errorf("failed to create Cloudflare client: %w", err)
+
}
+
state := &State{
d,
notifier,
···
jc,
config,
repoResolver,
+
cf,
knotstream,
spindlestream,
}
+6
appview/state/userutil/userutil.go
···
func IsDid(s string) bool {
return didRegex.MatchString(s)
}
+
+
var subdomainRegex = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{2,61}[a-z0-9])?$`)
+
+
func IsValidSubdomain(name string) bool {
+
return len(name) >= 4 && len(name) <= 63 && subdomainRegex.MatchString(name)
+
}
+4 -1
go.mod
···
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
+
github.com/cloudflare/cloudflare-go v0.115.0
github.com/cyphar/filepath-securejoin v0.4.1
github.com/dgraph-io/ristretto v0.2.0
github.com/docker/docker v28.2.2+incompatible
···
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang-jwt/jwt/v5 v5.2.2 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
+
github.com/google/go-querystring v1.1.0 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/gorilla/securecookie v1.1.2 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
···
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
golang.org/x/sync v0.14.0 // indirect
golang.org/x/sys v0.33.0 // indirect
-
golang.org/x/time v0.8.0 // indirect
+
golang.org/x/text v0.25.0 // indirect
+
golang.org/x/time v0.9.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20250519155744-55703ea1f237 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250519155744-55703ea1f237 // indirect
google.golang.org/grpc v1.72.1 // indirect
+7 -2
go.sum
···
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/cloudflare/circl v1.6.0 h1:cr5JKic4HI+LkINy2lg3W2jF8sHCVTBncJr5gIIq7qk=
github.com/cloudflare/circl v1.6.0/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
+
github.com/cloudflare/cloudflare-go v0.115.0 h1:84/dxeeXweCc0PN5Cto44iTA8AkG1fyT11yPO5ZB7sM=
+
github.com/cloudflare/cloudflare-go v0.115.0/go.mod h1:Ds6urDwn/TF2uIU24mu7H91xkKP8gSAHxQ44DSZgVmU=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
···
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
+
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
+
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
···
golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
-
golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg=
-
golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
+
golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
+
golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=