diff --git a/cmd/tulip/conf.go b/cmd/tulip/conf.go index 9ab5243..89a8337 100644 --- a/cmd/tulip/conf.go +++ b/cmd/tulip/conf.go @@ -1,7 +1,8 @@ package main type startUpConfig struct { - Listen string `json:"listen"` - Domain string `json:"domain"` - OtpIssuer string `json:"otp_issuer"` + Listen string `json:"listen"` + Domain string `json:"domain"` + OtpIssuer string `json:"otp_issuer"` + ServiceName string `json:"service_name"` } diff --git a/cmd/tulip/serve.go b/cmd/tulip/serve.go index bebdf4f..874a1e2 100644 --- a/cmd/tulip/serve.go +++ b/cmd/tulip/serve.go @@ -82,7 +82,7 @@ func normalLoad(startUp startUpConfig, wd string) { log.Fatal("[Tulip] Failed check:", err) } - srv := server.NewHttpServer(startUp.Listen, startUp.Domain, startUp.OtpIssuer, db, key) + srv := server.NewHttpServer(startUp.Listen, startUp.Domain, startUp.OtpIssuer, startUp.ServiceName, db, key) log.Printf("[Tulip] Starting HTTP server on '%s'\n", srv.Addr) go utils.RunBackgroundHttp("HTTP", srv) @@ -113,7 +113,7 @@ func checkDbHasUser(db *database.DB) error { defer tx.Rollback() if err := tx.HasUser(); err != nil { if errors.Is(err, sql.ErrNoRows) { - err := tx.InsertUser("Admin", "admin", "admin", "admin@localhost") + err := tx.InsertUser("Admin", "admin", "admin", "admin@localhost", database.RoleAdmin, false) if err != nil { return fmt.Errorf("failed to add user: %w", err) } diff --git a/database/db-types.go b/database/db-types.go index e80e73a..a5e6f7c 100644 --- a/database/db-types.go +++ b/database/db-types.go @@ -23,10 +23,32 @@ type User struct { Birthdate NullDateScanner `json:"birthdate,omitempty"` ZoneInfo LocationScanner `json:"zoneinfo,omitempty"` Locale LocaleScanner `json:"locale,omitempty"` + Role UserRole `json:"role"` UpdatedAt time.Time `json:"updated_at"` Active bool `json:"active"` } +type UserRole int + +const ( + RoleMember UserRole = iota + RoleAdmin +) + +func (r UserRole) String() string { + switch r { + case RoleMember: + return "Member" + case RoleAdmin: + return "Admin" + } + return fmt.Sprintf("UserRole{ %d }", r) +} + +func (r UserRole) IsValid() bool { + return r == RoleMember || r == RoleAdmin +} + type UserPatch struct { Name string Picture string diff --git a/database/init.sql b/database/init.sql index 9d9cdf5..4fb328e 100644 --- a/database/init.sql +++ b/database/init.sql @@ -12,6 +12,7 @@ CREATE TABLE IF NOT EXISTS users birthdate DATE, zoneinfo TEXT DEFAULT "UTC" NOT NULL, locale TEXT DEFAULT "en-US" NOT NULL, + role INTEGER DEFAULT 0 NOT NULL, updated_at DATETIME, active INTEGER DEFAULT 1 ); @@ -21,11 +22,13 @@ CREATE UNIQUE INDEX IF NOT EXISTS username_index ON users (username); CREATE TABLE IF NOT EXISTS client_store ( subject TEXT PRIMARY KEY UNIQUE NOT NULL, - name TEXT UNIQUE NOT NULL, + name TEXT NOT NULL, secret TEXT UNIQUE NOT NULL, domain TEXT NOT NULL, + owner TEXT NOT NULL, sso INTEGER, - active INTEGER DEFAULT 1 + active INTEGER DEFAULT 1, + FOREIGN KEY (owner) REFERENCES users (subject) ); CREATE TABLE IF NOT EXISTS otp diff --git a/database/tx.go b/database/tx.go index d8082ec..872f481 100644 --- a/database/tx.go +++ b/database/tx.go @@ -37,12 +37,12 @@ func (t *Tx) HasUser() error { return nil } -func (t *Tx) InsertUser(name, un, pw, email string) error { +func (t *Tx) InsertUser(name, un, pw, email string, role UserRole, active bool) error { pwHash, err := password.HashPassword(pw) if err != nil { return err } - _, err = t.tx.Exec(`INSERT INTO users (subject, name, username, password, email, updated_at) VALUES (?, ?, ?, ?, ?, ?)`, uuid.NewString(), name, un, pwHash, email, updatedAt()) + _, err = t.tx.Exec(`INSERT INTO users (subject, name, username, password, email, role, updated_at, active) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, uuid.NewString(), name, un, pwHash, email, role, updatedAt(), active) return err } @@ -66,6 +66,13 @@ func (t *Tx) GetUserDisplayName(sub uuid.UUID) (*User, error) { return &u, err } +func (t *Tx) GetUserRole(sub uuid.UUID) (UserRole, error) { + var r UserRole + row := t.tx.QueryRow(`SELECT role FROM users WHERE subject = ? LIMIT 1`, sub.String()) + err := row.Scan(&r) + return r, err +} + func (t *Tx) GetUser(sub uuid.UUID) (*User, error) { var u User row := t.tx.QueryRow(`SELECT name, username, password, picture, website, email, email_verified, pronouns, birthdate, zoneinfo, locale, updated_at, active FROM users WHERE subject = ?`, sub.String()) @@ -177,33 +184,111 @@ func (t *Tx) GetTwoFactor(sub uuid.UUID, issuer string) (*twofactor.Totp, error) return twofactor.TOTPFromBytes(u, issuer) } +func (t *Tx) HasTwoFactor(sub uuid.UUID) (bool, error) { + var hasOtp bool + row := t.tx.QueryRow(`SELECT EXISTS(SELECT 1 FROM otp WHERE otp.subject = ?)`, sub) + err := row.Scan(&hasOtp) + if err != nil { + return false, err + } + return hasOtp, row.Err() +} + func (t *Tx) GetClientInfo(sub string) (oauth2.ClientInfo, error) { - var u clientInfoDbOutput - var active bool + var u ClientInfoDbOutput row := t.tx.QueryRow(`SELECT secret, name, domain, sso, active FROM client_store WHERE subject = ? LIMIT 1`, sub) - err := row.Scan(&u.secret, &u.name, &u.domain, &u.sso, &active) - u.sub = sub - if !active { + err := row.Scan(&u.Secret, &u.Name, &u.Domain, &u.SSO, &u.Active) + u.Owner = sub + if !u.Active { return nil, fmt.Errorf("client is not active") } return &u, err } -type clientInfoDbOutput struct { - sub, name, secret, domain string - sso bool +func (t *Tx) GetAppList(offset int) ([]ClientInfoDbOutput, error) { + var u []ClientInfoDbOutput + row, err := t.tx.Query(`SELECT subject, name, domain, owner, sso, active FROM client_store LIMIT 25 OFFSET ?`, offset) + if err != nil { + return nil, err + } + defer row.Close() + for row.Next() { + var a ClientInfoDbOutput + err := row.Scan(&a.Sub, &a.Name, &a.Domain, &a.Owner, &a.SSO, &a.Active) + if err != nil { + return nil, err + } + u = append(u, a) + } + return u, row.Err() } -func (c *clientInfoDbOutput) GetID() string { return c.sub } -func (c *clientInfoDbOutput) GetSecret() string { return c.secret } -func (c *clientInfoDbOutput) GetDomain() string { return c.domain } -func (c *clientInfoDbOutput) IsPublic() bool { return false } -func (c *clientInfoDbOutput) GetUserID() string { return "" } +func (t *Tx) InsertClientApp(name, domain string, sso, active bool, owner uuid.UUID) error { + u := uuid.New() + secret, err := password.GenerateApiSecret(70) + if err != nil { + return err + } + _, err = t.tx.Exec(`INSERT INTO client_store (subject, name, secret, domain, owner, sso, active) VALUES (?, ?, ?, ?, ?, ?, ?)`, u.String(), name, secret, domain, owner.String(), sso, active) + return err +} -// IsSSO is an extra field for the oauth handler to skip the user input stage -// this is for trusted applications to get permissions without asking the user -func (c *clientInfoDbOutput) IsSSO() bool { return c.sso } +func (t *Tx) UpdateClientApp(subject uuid.UUID, name, domain string, sso, active bool) error { + _, err := t.tx.Exec(`UPDATE client_store SET name = ?, domain = ?, sso = ?, active = ? WHERE subject = ?`, name, domain, sso, active, subject.String()) + return err +} + +func (t *Tx) ResetClientAppSecret(subject uuid.UUID, secret string) error { + secret, err := password.GenerateApiSecret(70) + if err != nil { + return err + } + _, err = t.tx.Exec(`UPDATE client_store SET secret = ? WHERE subject = ?`, secret, subject.String()) + return err +} + +func (t *Tx) GetUserList(offset int) ([]User, error) { + var u []User + row, err := t.tx.Query(`SELECT subject, name, username, picture, website, email, email_verified, pronouns, birthdate, zoneinfo, locale, role, updated_at, active FROM users LIMIT 25 OFFSET ?`, offset) + if err != nil { + return nil, err + } + for row.Next() { + var a User + err := row.Scan(&a.Sub, &a.Name, &a.Username, &a.Picture, &a.Website, &a.Email, &a.EmailVerified, &a.Pronouns, &a.Birthdate, &a.ZoneInfo, &a.Locale, &a.Role, &a.UpdatedAt, &a.Active) + if err != nil { + return nil, err + } + u = append(u, a) + } + return u, row.Err() +} + +func (t *Tx) UpdateUser(subject uuid.UUID, role UserRole, active bool) error { + _, err := t.tx.Exec(`UPDATE users SET active = ?, role = ? WHERE subject = ?`, active, role, subject) + return err +} + +type ClientInfoDbOutput struct { + Sub, Name, Secret, Domain, Owner string + SSO, Active bool +} + +var _ oauth2.ClientInfo = &ClientInfoDbOutput{} + +func (c *ClientInfoDbOutput) GetID() string { return c.Sub } +func (c *ClientInfoDbOutput) GetSecret() string { return c.Secret } +func (c *ClientInfoDbOutput) GetDomain() string { return c.Domain } +func (c *ClientInfoDbOutput) IsPublic() bool { return false } +func (c *ClientInfoDbOutput) GetUserID() string { return c.Owner } // GetName is an extra field for the oauth handler to display the application // name -func (c *clientInfoDbOutput) GetName() string { return c.name } +func (c *ClientInfoDbOutput) GetName() string { return c.Name } + +// IsSSO is an extra field for the oauth handler to skip the user input stage +// this is for trusted applications to get permissions without asking the user +func (c *ClientInfoDbOutput) IsSSO() bool { return c.SSO } + +// IsActive is an extra field for the app manager to get the active state +func (c *ClientInfoDbOutput) IsActive() bool { return c.Active } diff --git a/go.mod b/go.mod index f407054..d6aa5a8 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,9 @@ require ( github.com/1f349/violet v0.0.9 github.com/MrMelon54/exit-reload v0.0.1 github.com/MrMelon54/pronouns v1.0.1 + github.com/emersion/go-message v0.17.0 + github.com/emersion/go-sasl v0.0.0-20220912192320-0145f2c60ead + github.com/emersion/go-smtp v0.18.1 github.com/go-oauth2/oauth2/v4 v4.5.2 github.com/go-session/session v3.1.2+incompatible github.com/google/subcommands v1.2.0 @@ -20,6 +23,7 @@ require ( require ( github.com/davecgh/go-spew v1.1.1 // indirect + github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594 // indirect github.com/golang-jwt/jwt v3.2.2+incompatible // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/sec51/convert v1.0.2 // indirect diff --git a/go.sum b/go.sum index bd6c4a6..b182558 100644 --- a/go.sum +++ b/go.sum @@ -14,6 +14,15 @@ github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHG github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emersion/go-message v0.17.0 h1:NIdSKHiVUx4qKqdd0HyJFD41cW8iFguM2XJnRZWQH04= +github.com/emersion/go-message v0.17.0/go.mod h1:/9Bazlb1jwUNB0npYYBsdJ2EMOiiyN3m5UVHbY7GoNw= +github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= +github.com/emersion/go-sasl v0.0.0-20220912192320-0145f2c60ead h1:fI1Jck0vUrXT8bnphprS1EoVRe2Q5CKCX8iDlpqjQ/Y= +github.com/emersion/go-sasl v0.0.0-20220912192320-0145f2c60ead/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= +github.com/emersion/go-smtp v0.18.1 h1:4DFV0jxKhq0Gqt/Br3BRHyKZy5TStk6NIMHAx6GE/LA= +github.com/emersion/go-smtp v0.18.1/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ= +github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594 h1:IbFBtwoTQyw0fIM5xv1HF+Y+3ZijDR839WMulgxCcUY= +github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U= github.com/fasthttp-contrib/websocket v0.0.0-20160511215533-1f3b11f56072/go.mod h1:duJ4Jxv5lDcvg4QuQr0oowTf7dz4/CR8NtyCooz9HL8= github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= @@ -144,22 +153,33 @@ github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FB github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 h1:BHyfKlQyqbsFN5p3IfnEUduWvb9is428/nNb5L3U01M= github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM= github.com/yudai/pp v2.0.1+incompatible/go.mod h1:PuxR/8QJ7cyCkFp/aUDS+JY727OFEZkTdatxwunjIkc= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20220214200702-86341886e292/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.13.0 h1:mvySKfSWJ+UKUii46M40LOvyWfN0s2U+46/jDd0e6Ck= golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M= golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -174,16 +194,27 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220227234510-4e6760a101f9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= diff --git a/pages/edit-otp.go.html b/pages/edit-otp.go.html index c80719a..dc62d23 100644 --- a/pages/edit-otp.go.html +++ b/pages/edit-otp.go.html @@ -1,11 +1,11 @@
-