A Tianji-style Websites section: a small tracking script, a public collection endpoint, and a page per website with visitors, page views, visits, bounce rate, a chart and top lists for pages, referrers, browsers, systems, devices, countries, languages and events. No cookies and no stored IP address or user agent. SANDBAR_TRUST_PROXY makes the client address come from the proxy. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
221 lines
6.4 KiB
Go
221 lines
6.4 KiB
Go
package sandbar
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"golang.org/x/crypto/bcrypt"
|
|
)
|
|
|
|
type User struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Email string `json:"email"`
|
|
Role string `json:"role"` // admin or member
|
|
Phone string `json:"phone"`
|
|
Created int64 `json:"created"`
|
|
Topic string `json:"topic"` // the personal topic their phone subscribes to
|
|
hash string
|
|
}
|
|
|
|
const (
|
|
sessionCookie = "sandbar_session"
|
|
sessionTTL = 14 * 24 * time.Hour
|
|
)
|
|
|
|
type ctxKey struct{}
|
|
|
|
func (s *Server) countUsers() int {
|
|
var n int
|
|
s.st.db.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&n)
|
|
return n
|
|
}
|
|
|
|
func scanUser(r scanner) (*User, error) {
|
|
var u User
|
|
err := r.Scan(&u.ID, &u.Name, &u.Email, &u.hash, &u.Role, &u.Phone, &u.Created)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, errNotFound
|
|
}
|
|
u.Topic = userTopic(u.ID)
|
|
return &u, err
|
|
}
|
|
|
|
const userCols = `id,name,email,hash,role,phone,created`
|
|
|
|
func (s *Server) userByID(id string) (*User, error) {
|
|
return scanUser(s.st.db.QueryRow(`SELECT `+userCols+` FROM users WHERE id=?`, id))
|
|
}
|
|
|
|
func (s *Server) userByEmail(email string) (*User, error) {
|
|
return scanUser(s.st.db.QueryRow(`SELECT `+userCols+` FROM users WHERE email=?`, strings.TrimSpace(email)))
|
|
}
|
|
|
|
func (s *Server) listUsers() []User {
|
|
out := []User{}
|
|
rows, err := s.st.db.Query(`SELECT ` + userCols + ` FROM users ORDER BY name`)
|
|
if err != nil {
|
|
return out
|
|
}
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
if u, err := scanUser(rows); err == nil {
|
|
out = append(out, *u)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func validPassword(p string) error {
|
|
if len(p) < 10 {
|
|
return errors.New("use a password of at least 10 characters")
|
|
}
|
|
if len(p) > 72 {
|
|
return errors.New("passwords are limited to 72 characters")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) createUser(name, email, password, role, phone string) (*User, error) {
|
|
name, email = strings.TrimSpace(name), strings.TrimSpace(email)
|
|
if name == "" || !strings.Contains(email, "@") || strings.ContainsAny(email, " \r\n<>") {
|
|
return nil, errors.New("a user needs a name and an email address")
|
|
}
|
|
if role != "admin" && role != "member" {
|
|
return nil, errors.New("role must be admin or member")
|
|
}
|
|
if err := validPassword(password); err != nil {
|
|
return nil, err
|
|
}
|
|
h, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
u := &User{ID: newID(), Name: name, Email: email, Role: role, Phone: strings.TrimSpace(phone), Created: s.now().Unix()}
|
|
u.Topic = userTopic(u.ID)
|
|
_, err = s.st.db.Exec(`INSERT INTO users(`+userCols+`) VALUES(?,?,?,?,?,?,?)`, u.ID, u.Name, u.Email, string(h), u.Role, u.Phone, u.Created)
|
|
if err != nil {
|
|
if strings.Contains(err.Error(), "UNIQUE") {
|
|
return nil, errors.New("that email address is already registered")
|
|
}
|
|
return nil, err
|
|
}
|
|
return u, nil
|
|
}
|
|
|
|
func (s *Server) setPassword(id, password string) error {
|
|
if err := validPassword(password); err != nil {
|
|
return err
|
|
}
|
|
h, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = s.st.db.Exec(`UPDATE users SET hash=? WHERE id=?`, string(h), id)
|
|
if err == nil {
|
|
s.st.db.Exec(`DELETE FROM sessions WHERE user_id=?`, id) // a password change signs everyone out
|
|
}
|
|
return err
|
|
}
|
|
|
|
// throttled limits password guessing per client address: 10 tries per 10 minutes.
|
|
func (s *Server) throttled(ip string) bool {
|
|
s.loginMu.Lock()
|
|
defer s.loginMu.Unlock()
|
|
cut := s.now().Add(-10 * time.Minute)
|
|
var keep []time.Time
|
|
for _, t := range s.loginTry[ip] {
|
|
if t.After(cut) {
|
|
keep = append(keep, t)
|
|
}
|
|
}
|
|
s.loginTry[ip] = keep
|
|
return len(keep) >= 10
|
|
}
|
|
|
|
func (s *Server) noteFailure(ip string) {
|
|
s.loginMu.Lock()
|
|
s.loginTry[ip] = append(s.loginTry[ip], s.now())
|
|
s.loginMu.Unlock()
|
|
}
|
|
|
|
var errThrottled = errors.New("too many attempts, try again in a few minutes")
|
|
|
|
// login checks credentials and starts a session.
|
|
func (s *Server) login(w http.ResponseWriter, r *http.Request, email, password string) (*User, error) {
|
|
ip := s.realIP(r)
|
|
if s.throttled(ip) {
|
|
return nil, errThrottled
|
|
}
|
|
u, err := s.userByEmail(email)
|
|
hash := "$2a$10$7EqJtq98hPqEX7fNZaFWoOhi5BWYKTaYBwxwEGLMzRVr4qhCXWM9S" // so a wrong email costs the same time as a wrong password
|
|
if err == nil {
|
|
hash = u.hash
|
|
}
|
|
if bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) != nil || err != nil {
|
|
s.noteFailure(ip)
|
|
return nil, errors.New("wrong email or password")
|
|
}
|
|
tok := newToken()
|
|
exp := s.now().Add(sessionTTL)
|
|
if _, err := s.st.db.Exec(`INSERT INTO sessions(hash,user_id,expires) VALUES(?,?,?)`, hashToken(tok), u.ID, exp.Unix()); err != nil {
|
|
return nil, err
|
|
}
|
|
http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: tok, Path: "/", Expires: exp, HttpOnly: true,
|
|
SameSite: http.SameSiteStrictMode, Secure: strings.HasPrefix(s.opts.URL, "https://")})
|
|
return u, nil
|
|
}
|
|
|
|
func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
|
|
if c, err := r.Cookie(sessionCookie); err == nil {
|
|
s.st.db.Exec(`DELETE FROM sessions WHERE hash=?`, hashToken(c.Value))
|
|
}
|
|
http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: "", Path: "/", MaxAge: -1, HttpOnly: true})
|
|
}
|
|
|
|
// authenticate finds who is calling: a session cookie, or an API key given as a
|
|
// bearer token or as the password of HTTP basic auth (ntfy clients do the latter).
|
|
func (s *Server) authenticate(r *http.Request) *User {
|
|
if c, err := r.Cookie(sessionCookie); err == nil {
|
|
var uid string
|
|
var exp int64
|
|
if s.st.db.QueryRow(`SELECT user_id,expires FROM sessions WHERE hash=?`, hashToken(c.Value)).Scan(&uid, &exp) == nil && exp > s.now().Unix() {
|
|
if u, err := s.userByID(uid); err == nil {
|
|
return u
|
|
}
|
|
}
|
|
}
|
|
key := ""
|
|
if h := r.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer ") {
|
|
key = strings.TrimSpace(h[7:])
|
|
} else if _, pw, ok := r.BasicAuth(); ok {
|
|
key = pw
|
|
}
|
|
if key == "" {
|
|
return nil
|
|
}
|
|
var uid string
|
|
if s.st.db.QueryRow(`SELECT user_id FROM apikeys WHERE hash=?`, hashToken(key)).Scan(&uid) == nil {
|
|
if u, err := s.userByID(uid); err == nil {
|
|
return u
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func userFrom(ctx context.Context) *User {
|
|
u, _ := ctx.Value(ctxKey{}).(*User)
|
|
return u
|
|
}
|
|
|
|
func (s *Server) createAPIKey(userID, name string) (id, token string, err error) {
|
|
token = "sb_" + newToken()
|
|
id = newID()
|
|
_, err = s.st.db.Exec(`INSERT INTO apikeys(id,name,hash,user_id,created) VALUES(?,?,?,?,?)`, id, strings.TrimSpace(name), hashToken(token), userID, s.now().Unix())
|
|
return
|
|
}
|