Add the Hammerhead control plane
Dashboard and API, accounts (organizations, roles, invitations, 2FA, OIDC single sign-on, API keys, revocable sessions), an encrypted SQLite database, resource logins, alerts and /metrics, mail egress management with DKIM keys, and the .env loader. Split out of the original single repository; depends on Reef for the shared types. The end-to-end tests that run Hammerhead, Blacktip and Bamboo together live in e2e/. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3daa368233
commit
f521fa8668
35 files changed
+10392
-1
No files matched your search
+565
@@ -0,0 +1,565 @@
|
||||
package hammerhead
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha1"
|
||||
"encoding/base32"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
const sessionTTL = 14 * 24 * time.Hour
|
||||
|
||||
type principal struct {
|
||||
User User // copy; zero ID when authenticated by API key
|
||||
Key *APIKey // set for API-key callers
|
||||
}
|
||||
|
||||
type ticket struct {
|
||||
userID string
|
||||
expires time.Time
|
||||
}
|
||||
|
||||
func clientIP(r *http.Request) string {
|
||||
h, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
return r.RemoteAddr
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func (s *Server) principalFrom(r *http.Request) *principal {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if c, err := r.Cookie("hh_session"); err == nil {
|
||||
if sess := s.st.Sessions[sha(c.Value)]; sess != nil && time.Now().Before(sess.Expires) {
|
||||
if u := s.findUser(sess.UserID); u != nil {
|
||||
return &principal{User: *u}
|
||||
}
|
||||
}
|
||||
}
|
||||
if tok := bearer(r); strings.HasPrefix(tok, "hhk_") {
|
||||
parts := strings.SplitN(tok, "_", 3)
|
||||
if len(parts) == 3 {
|
||||
for _, k := range s.st.APIKeys {
|
||||
if k.ID == parts[1] && hmac.Equal([]byte(k.Hash), []byte(sha(parts[2]))) {
|
||||
k.LastUsed = time.Now()
|
||||
kc := *k
|
||||
return &principal{Key: &kc}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) withUser(fn func(http.ResponseWriter, *http.Request, *principal)) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
p := s.principalFrom(r)
|
||||
if p == nil || p.User.ID == "" {
|
||||
fail(w, 401, errors.New("not logged in"))
|
||||
return
|
||||
}
|
||||
fn(w, r, p)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) withServerAdmin(fn func(http.ResponseWriter, *http.Request, *principal)) http.HandlerFunc {
|
||||
return s.withUser(func(w http.ResponseWriter, r *http.Request, p *principal) {
|
||||
if !p.User.ServerAdmin {
|
||||
fail(w, 403, errors.New("server administrators only"))
|
||||
return
|
||||
}
|
||||
fn(w, r, p)
|
||||
})
|
||||
}
|
||||
|
||||
// orgCtx is the caller's standing inside one org.
|
||||
type orgCtx struct {
|
||||
Org Org
|
||||
Admin bool
|
||||
User User
|
||||
Key *APIKey
|
||||
Actor string
|
||||
}
|
||||
|
||||
func (s *Server) withOrg(needAdmin bool, fn func(http.ResponseWriter, *http.Request, *orgCtx)) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
p := s.principalFrom(r)
|
||||
if p == nil {
|
||||
fail(w, 401, errors.New("not logged in"))
|
||||
return
|
||||
}
|
||||
id := r.PathValue("org")
|
||||
s.mu.Lock()
|
||||
org := s.findOrg(id)
|
||||
c := &orgCtx{User: p.User, Key: p.Key}
|
||||
allowed := false
|
||||
if org != nil {
|
||||
c.Org = *org
|
||||
switch {
|
||||
case p.Key != nil:
|
||||
allowed = p.Key.OrgID == org.ID
|
||||
c.Admin = allowed
|
||||
c.Actor = "apikey:" + p.Key.Name
|
||||
case p.User.ServerAdmin:
|
||||
allowed, c.Admin = true, true
|
||||
c.Actor = p.User.Email
|
||||
default:
|
||||
if m := s.member(p.User.ID, org.ID); m != nil {
|
||||
allowed = true
|
||||
if role := s.findRole(m.RoleID); role != nil {
|
||||
c.Admin = role.Admin
|
||||
}
|
||||
}
|
||||
c.Actor = p.User.Email
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
if !allowed {
|
||||
fail(w, 404, errors.New("no such organization"))
|
||||
return
|
||||
}
|
||||
if needAdmin && !c.Admin {
|
||||
fail(w, 403, errors.New("organization administrators only"))
|
||||
return
|
||||
}
|
||||
fn(w, r, c)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- first run ----------
|
||||
|
||||
func hashPassword(pw string) (string, error) {
|
||||
if len(pw) < 8 {
|
||||
return "", errors.New("password must be at least 8 characters")
|
||||
}
|
||||
b, err := bcrypt.GenerateFromPassword([]byte(pw), 10)
|
||||
return string(b), err
|
||||
}
|
||||
|
||||
// newOrgLocked creates an org with its two built-in roles. Caller holds s.mu.
|
||||
func (s *Server) newOrgLocked(name string, allowLocal bool) (*Org, *Role) {
|
||||
o := &Org{ID: newID(), Name: name, AllowLocal: allowLocal}
|
||||
admin := &Role{ID: newID(), OrgID: o.ID, Name: "Admin", Admin: true, Builtin: true}
|
||||
member := &Role{ID: newID(), OrgID: o.ID, Name: "Member", Builtin: true}
|
||||
s.st.Orgs = append(s.st.Orgs, o)
|
||||
s.st.Roles = append(s.st.Roles, admin, member)
|
||||
return o, admin
|
||||
}
|
||||
|
||||
func (s *Server) createFirstAdmin(email, name, password, orgName string) error {
|
||||
hash, err := hashPassword(password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
email = strings.ToLower(strings.TrimSpace(email))
|
||||
if !strings.Contains(email, "@") {
|
||||
return errors.New("enter a valid email address")
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if len(s.st.Users) != 0 {
|
||||
return errors.New("setup already done")
|
||||
}
|
||||
u := &User{ID: newID(), Email: email, Name: name, PassHash: hash, ServerAdmin: true, Created: time.Now()}
|
||||
s.st.Users = append(s.st.Users, u)
|
||||
org, adminRole := s.newOrgLocked(orgName, true)
|
||||
s.st.Members = append(s.st.Members, &Member{UserID: u.ID, OrgID: org.ID, RoleID: adminRole.ID})
|
||||
return s.save()
|
||||
}
|
||||
|
||||
func (s *Server) setup(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct{ Email, Name, Password, OrgName string }
|
||||
if !decode(w, r, &in) {
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(in.OrgName) == "" {
|
||||
in.OrgName = "Default"
|
||||
}
|
||||
if strings.TrimSpace(in.Name) == "" {
|
||||
in.Name = "Admin"
|
||||
}
|
||||
if err := s.createFirstAdmin(in.Email, in.Name, in.Password, in.OrgName); err != nil {
|
||||
fail(w, 400, err)
|
||||
return
|
||||
}
|
||||
ok(w)
|
||||
}
|
||||
|
||||
// ---------- sessions ----------
|
||||
|
||||
func (s *Server) secureCookies(r *http.Request) bool {
|
||||
return r.TLS != nil || strings.HasPrefix(s.st.Settings.DashboardURL, "https://")
|
||||
}
|
||||
|
||||
func (s *Server) startSession(w http.ResponseWriter, r *http.Request, userID string) {
|
||||
tok := randHex(24)
|
||||
now := time.Now()
|
||||
s.mu.Lock()
|
||||
for k, v := range s.st.Sessions {
|
||||
if now.After(v.Expires) {
|
||||
delete(s.st.Sessions, k)
|
||||
}
|
||||
}
|
||||
s.st.Sessions[sha(tok)] = &Session{UserID: userID, Expires: now.Add(sessionTTL), Created: now, IP: clientIP(r), UA: trim(r.UserAgent(), 200)}
|
||||
secure := s.secureCookies(r)
|
||||
s.save()
|
||||
s.mu.Unlock()
|
||||
http.SetCookie(w, &http.Cookie{Name: "hh_session", Value: tok, Path: "/", HttpOnly: true,
|
||||
Secure: secure, SameSite: http.SameSiteLaxMode, MaxAge: int(sessionTTL.Seconds())})
|
||||
}
|
||||
|
||||
func (s *Server) logout(w http.ResponseWriter, r *http.Request, _ *principal) {
|
||||
if c, err := r.Cookie("hh_session"); err == nil {
|
||||
s.mu.Lock()
|
||||
delete(s.st.Sessions, sha(c.Value))
|
||||
s.save()
|
||||
s.mu.Unlock()
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{Name: "hh_session", Value: "", Path: "/", MaxAge: -1})
|
||||
ok(w)
|
||||
}
|
||||
|
||||
// dummyHash lets a missing user cost the same as a wrong password.
|
||||
var dummyHash, _ = bcrypt.GenerateFromPassword([]byte("not-a-real-password"), 10)
|
||||
|
||||
func (s *Server) login(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct{ Email, Password string }
|
||||
if !decode(w, r, &in) {
|
||||
return
|
||||
}
|
||||
email := strings.ToLower(strings.TrimSpace(in.Email))
|
||||
if !s.allow("login|"+clientIP(r)+"|"+email, 8, 5*time.Minute) {
|
||||
fail(w, 429, errors.New("too many attempts, wait a few minutes"))
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
u := s.userByEmail(email)
|
||||
hash := string(dummyHash)
|
||||
if u != nil && u.PassHash != "" {
|
||||
hash = u.PassHash
|
||||
}
|
||||
s.mu.Unlock()
|
||||
good := bcrypt.CompareHashAndPassword([]byte(hash), []byte(in.Password)) == nil
|
||||
if u == nil || !good || u.PassHash == "" {
|
||||
s.audit("", email, "login.failed", clientIP(r))
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
fail(w, 401, errors.New("wrong email or password"))
|
||||
return
|
||||
}
|
||||
if u.TOTPEnabled {
|
||||
t := randHex(16)
|
||||
s.mu.Lock()
|
||||
s.tickets[t] = ticket{userID: u.ID, expires: time.Now().Add(5 * time.Minute)}
|
||||
s.mu.Unlock()
|
||||
writeJSON(w, 200, map[string]any{"needTotp": true, "ticket": t})
|
||||
return
|
||||
}
|
||||
s.startSession(w, r, u.ID)
|
||||
s.audit("", u.Email, "login", clientIP(r))
|
||||
ok(w)
|
||||
}
|
||||
|
||||
func (s *Server) loginTOTP(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct{ Ticket, Code string }
|
||||
if !decode(w, r, &in) {
|
||||
return
|
||||
}
|
||||
if !s.allow("totp|"+clientIP(r), 10, 5*time.Minute) {
|
||||
fail(w, 429, errors.New("too many attempts, wait a few minutes"))
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
t, found := s.tickets[in.Ticket]
|
||||
var u *User
|
||||
if found && time.Now().Before(t.expires) {
|
||||
u = s.findUser(t.userID)
|
||||
}
|
||||
good := false
|
||||
if u != nil {
|
||||
code := strings.ReplaceAll(strings.TrimSpace(in.Code), " ", "")
|
||||
if totpOK(u.TOTPSecret, code) {
|
||||
good = true
|
||||
} else {
|
||||
h := sha(strings.ToLower(code))
|
||||
for i, b := range u.BackupCodes {
|
||||
if b == h {
|
||||
u.BackupCodes = append(append([]string{}, u.BackupCodes[:i]...), u.BackupCodes[i+1:]...)
|
||||
good = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if good {
|
||||
delete(s.tickets, in.Ticket)
|
||||
s.save()
|
||||
}
|
||||
s.mu.Unlock()
|
||||
if !good {
|
||||
fail(w, 401, errors.New("wrong code"))
|
||||
return
|
||||
}
|
||||
s.startSession(w, r, u.ID)
|
||||
s.audit("", u.Email, "login", clientIP(r)+" (2fa)")
|
||||
ok(w)
|
||||
}
|
||||
|
||||
// ---------- TOTP ----------
|
||||
|
||||
var b32 = base32.StdEncoding.WithPadding(base32.NoPadding)
|
||||
|
||||
func hotp(key []byte, counter uint64) string {
|
||||
var b [8]byte
|
||||
binary.BigEndian.PutUint64(b[:], counter)
|
||||
m := hmac.New(sha1.New, key)
|
||||
m.Write(b[:])
|
||||
sum := m.Sum(nil)
|
||||
off := sum[len(sum)-1] & 0xf
|
||||
v := (binary.BigEndian.Uint32(sum[off:off+4]) & 0x7fffffff) % 1000000
|
||||
return fmt.Sprintf("%06d", v)
|
||||
}
|
||||
|
||||
func totpOK(secretB32, code string) bool {
|
||||
key, err := b32.DecodeString(secretB32)
|
||||
if err != nil || len(code) != 6 {
|
||||
return false
|
||||
}
|
||||
now := uint64(time.Now().Unix() / 30)
|
||||
for _, d := range []int{-1, 0, 1} {
|
||||
if hmac.Equal([]byte(hotp(key, now+uint64(d))), []byte(code)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ---------- /api/me ----------
|
||||
|
||||
type orgBrief struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Role string `json:"role"`
|
||||
Admin bool `json:"admin"`
|
||||
}
|
||||
|
||||
func (s *Server) publicInfo(w http.ResponseWriter, r *http.Request) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
writeJSON(w, 200, map[string]any{"needsSetup": len(s.st.Users) == 0, "providers": s.providerList()})
|
||||
}
|
||||
|
||||
func (s *Server) providerList() []map[string]string {
|
||||
out := []map[string]string{}
|
||||
for _, p := range s.st.IdPs {
|
||||
out = append(out, map[string]string{"id": p.ID, "name": p.Name})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Server) me(w http.ResponseWriter, r *http.Request, p *principal) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
u := s.findUser(p.User.ID)
|
||||
orgs := []orgBrief{}
|
||||
for _, o := range s.st.Orgs {
|
||||
if u.ServerAdmin {
|
||||
orgs = append(orgs, orgBrief{ID: o.ID, Name: o.Name, Role: "Server admin", Admin: true})
|
||||
continue
|
||||
}
|
||||
if m := s.member(u.ID, o.ID); m != nil {
|
||||
role := s.findRole(m.RoleID)
|
||||
b := orgBrief{ID: o.ID, Name: o.Name}
|
||||
if role != nil {
|
||||
b.Role, b.Admin = role.Name, role.Admin
|
||||
}
|
||||
orgs = append(orgs, b)
|
||||
}
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{
|
||||
"id": u.ID, "email": u.Email, "name": u.Name, "serverAdmin": u.ServerAdmin,
|
||||
"totpEnabled": u.TOTPEnabled, "hasPassword": u.PassHash != "", "orgs": orgs,
|
||||
"backupCodes": len(u.BackupCodes), "dashboardUrl": s.st.Settings.DashboardURL,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) updateMe(w http.ResponseWriter, r *http.Request, p *principal) {
|
||||
var in struct{ Name string }
|
||||
if !decode(w, r, &in) {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if n := strings.TrimSpace(in.Name); n != "" {
|
||||
s.findUser(p.User.ID).Name = n
|
||||
}
|
||||
s.save()
|
||||
ok(w)
|
||||
}
|
||||
|
||||
func (s *Server) changePassword(w http.ResponseWriter, r *http.Request, p *principal) {
|
||||
var in struct{ Current, New string }
|
||||
if !decode(w, r, &in) {
|
||||
return
|
||||
}
|
||||
if p.User.PassHash != "" && bcrypt.CompareHashAndPassword([]byte(p.User.PassHash), []byte(in.Current)) != nil {
|
||||
fail(w, 400, errors.New("current password is wrong"))
|
||||
return
|
||||
}
|
||||
hash, err := hashPassword(in.New)
|
||||
if err != nil {
|
||||
fail(w, 400, err)
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.findUser(p.User.ID).PassHash = hash
|
||||
// sign out every other session
|
||||
cur := ""
|
||||
if c, err := r.Cookie("hh_session"); err == nil {
|
||||
cur = sha(c.Value)
|
||||
}
|
||||
for k, sess := range s.st.Sessions {
|
||||
if sess.UserID == p.User.ID && k != cur {
|
||||
delete(s.st.Sessions, k)
|
||||
}
|
||||
}
|
||||
s.save()
|
||||
s.mu.Unlock()
|
||||
s.audit("", p.User.Email, "password.change", "")
|
||||
ok(w)
|
||||
}
|
||||
|
||||
func (s *Server) totpSetup(w http.ResponseWriter, r *http.Request, p *principal) {
|
||||
if p.User.TOTPEnabled {
|
||||
fail(w, 400, errors.New("two-factor is already on"))
|
||||
return
|
||||
}
|
||||
raw := make([]byte, 20)
|
||||
rand.Read(raw)
|
||||
secret := b32.EncodeToString(raw)
|
||||
s.mu.Lock()
|
||||
s.findUser(p.User.ID).TOTPSecret = secret
|
||||
s.save()
|
||||
s.mu.Unlock()
|
||||
writeJSON(w, 200, map[string]string{"secret": secret,
|
||||
"uri": fmt.Sprintf("otpauth://totp/Hammerhead:%s?secret=%s&issuer=Hammerhead", p.User.Email, secret)})
|
||||
}
|
||||
|
||||
func (s *Server) totpEnable(w http.ResponseWriter, r *http.Request, p *principal) {
|
||||
var in struct{ Code string }
|
||||
if !decode(w, r, &in) {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
u := s.findUser(p.User.ID)
|
||||
if u.TOTPSecret == "" || !totpOK(u.TOTPSecret, strings.TrimSpace(in.Code)) {
|
||||
fail(w, 400, errors.New("that code doesn't match; check your authenticator's clock"))
|
||||
return
|
||||
}
|
||||
codes, hashes := make([]string, 8), make([]string, 8)
|
||||
for i := range codes {
|
||||
codes[i] = randHex(5)
|
||||
hashes[i] = sha(codes[i])
|
||||
}
|
||||
u.TOTPEnabled, u.BackupCodes = true, hashes
|
||||
s.save()
|
||||
writeJSON(w, 200, map[string]any{"backupCodes": codes})
|
||||
}
|
||||
|
||||
func (s *Server) totpDisable(w http.ResponseWriter, r *http.Request, p *principal) {
|
||||
var in struct{ Password, Code string }
|
||||
if !decode(w, r, &in) {
|
||||
return
|
||||
}
|
||||
if p.User.PassHash != "" && bcrypt.CompareHashAndPassword([]byte(p.User.PassHash), []byte(in.Password)) != nil {
|
||||
fail(w, 400, errors.New("password is wrong"))
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
u := s.findUser(p.User.ID)
|
||||
if !totpOK(u.TOTPSecret, strings.TrimSpace(in.Code)) {
|
||||
fail(w, 400, errors.New("code is wrong"))
|
||||
return
|
||||
}
|
||||
u.TOTPEnabled, u.TOTPSecret, u.BackupCodes = false, "", nil
|
||||
s.save()
|
||||
ok(w)
|
||||
}
|
||||
|
||||
func trim(s string, n int) string {
|
||||
if len(s) > n {
|
||||
return s[:n]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// ---------- your signed-in devices ----------
|
||||
|
||||
func (s *Server) mySessions(w http.ResponseWriter, r *http.Request, p *principal) {
|
||||
cur := ""
|
||||
if c, err := r.Cookie("hh_session"); err == nil {
|
||||
cur = sha(c.Value)
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
type row struct {
|
||||
ID string `json:"id"`
|
||||
Created time.Time `json:"created"`
|
||||
Expires time.Time `json:"expires"`
|
||||
IP string `json:"ip"`
|
||||
UA string `json:"ua"`
|
||||
Current bool `json:"current"`
|
||||
}
|
||||
out := []row{}
|
||||
for k, sess := range s.st.Sessions {
|
||||
if sess.UserID == p.User.ID && time.Now().Before(sess.Expires) {
|
||||
out = append(out, row{k[:16], sess.Created, sess.Expires, sess.IP, sess.UA, k == cur})
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Created.After(out[j].Created) })
|
||||
writeJSON(w, 200, out)
|
||||
}
|
||||
|
||||
func (s *Server) endSession(w http.ResponseWriter, r *http.Request, p *principal) {
|
||||
id := r.PathValue("id")
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if len(id) >= 16 {
|
||||
for k, sess := range s.st.Sessions {
|
||||
if sess.UserID == p.User.ID && strings.HasPrefix(k, id) {
|
||||
delete(s.st.Sessions, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
s.save()
|
||||
ok(w)
|
||||
}
|
||||
|
||||
// endOtherSessions signs the user out everywhere except the browser they are using.
|
||||
func (s *Server) endOtherSessions(w http.ResponseWriter, r *http.Request, p *principal) {
|
||||
cur := ""
|
||||
if c, err := r.Cookie("hh_session"); err == nil {
|
||||
cur = sha(c.Value)
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for k, sess := range s.st.Sessions {
|
||||
if sess.UserID == p.User.ID && k != cur {
|
||||
delete(s.st.Sessions, k)
|
||||
}
|
||||
}
|
||||
s.save()
|
||||
ok(w)
|
||||
}
|
||||
Reference in new issue
Block a user