package users import ( "crypto/rand" "database/sql" "encoding/hex" "errors" "fmt" "time" _ "modernc.org/sqlite" "golang.org/x/crypto/bcrypt" ) var ErrNotFound = errors.New("user not found") var ErrUserExists = errors.New("user already exists") type User struct { ID int64 Username string CanUpload bool } type Session struct { Token string UserID int64 Username string CanUpload bool ExpiresAt time.Time } type Store struct { db *sql.DB } func Open(path string) (*Store, error) { db, err := sql.Open("sqlite", path) if err != nil { return nil, err } db.SetMaxOpenConns(1) // SQLite doesn't support concurrent writers s := &Store{db: db} if err := s.migrate(); err != nil { db.Close() return nil, err } return s, nil } func (s *Store) Close() error { return s.db.Close() } func (s *Store) migrate() error { _, err := s.db.Exec(` CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, can_upload INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS sessions ( token TEXT PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, expires_at INTEGER NOT NULL ); `) return err } // CreateUser adds a new user. Returns ErrUserExists if the username is taken. func (s *Store) CreateUser(username, password string, canUpload bool) (*User, error) { hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { return nil, err } res, err := s.db.Exec( `INSERT INTO users (username, password_hash, can_upload) VALUES (?, ?, ?)`, username, string(hash), boolToInt(canUpload), ) if err != nil { if isUnique(err) { return nil, ErrUserExists } return nil, err } id, _ := res.LastInsertId() return &User{ID: id, Username: username, CanUpload: canUpload}, nil } // SetUpload changes the upload permission of an existing user. func (s *Store) SetUpload(username string, canUpload bool) error { res, err := s.db.Exec( `UPDATE users SET can_upload = ? WHERE username = ?`, boolToInt(canUpload), username, ) if err != nil { return err } if n, _ := res.RowsAffected(); n == 0 { return ErrNotFound } return nil } // DeleteUser removes a user by username. func (s *Store) DeleteUser(username string) error { res, err := s.db.Exec(`DELETE FROM users WHERE username = ?`, username) if err != nil { return err } if n, _ := res.RowsAffected(); n == 0 { return ErrNotFound } return nil } // ListUsers returns all users ordered by username. func (s *Store) ListUsers() ([]User, error) { rows, err := s.db.Query(`SELECT id, username, can_upload FROM users ORDER BY username`) if err != nil { return nil, err } defer rows.Close() var users []User for rows.Next() { var u User var up int if err := rows.Scan(&u.ID, &u.Username, &up); err != nil { return nil, err } u.CanUpload = up != 0 users = append(users, u) } return users, rows.Err() } // Authenticate verifies credentials and returns the matching user. func (s *Store) Authenticate(username, password string) (*User, error) { var u User var hash string var up int err := s.db.QueryRow( `SELECT id, username, password_hash, can_upload FROM users WHERE username = ?`, username, ).Scan(&u.ID, &u.Username, &hash, &up) if errors.Is(err, sql.ErrNoRows) { return nil, nil } if err != nil { return nil, err } if bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) != nil { return nil, nil } u.CanUpload = up != 0 return &u, nil } // CreateSession issues a new session token for a user (TTL: 30 days). func (s *Store) CreateSession(u *User) (*Session, error) { token, err := randomToken() if err != nil { return nil, err } exp := time.Now().Add(30 * 24 * time.Hour) _, err = s.db.Exec( `INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)`, token, u.ID, exp.Unix(), ) if err != nil { return nil, err } return &Session{ Token: token, UserID: u.ID, Username: u.Username, CanUpload: u.CanUpload, ExpiresAt: exp, }, nil } // LookupSession returns the session if valid, nil if not found/expired. func (s *Store) LookupSession(token string) (*Session, error) { var sess Session var up int var expUnix int64 err := s.db.QueryRow(` SELECT s.token, s.user_id, u.username, u.can_upload, s.expires_at FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.token = ? `, token).Scan(&sess.Token, &sess.UserID, &sess.Username, &up, &expUnix) if errors.Is(err, sql.ErrNoRows) { return nil, nil } if err != nil { return nil, err } sess.ExpiresAt = time.Unix(expUnix, 0) if time.Now().After(sess.ExpiresAt) { _, _ = s.db.Exec(`DELETE FROM sessions WHERE token = ?`, token) return nil, nil } sess.CanUpload = up != 0 return &sess, nil } // DeleteSession invalidates a session token (logout). func (s *Store) DeleteSession(token string) error { _, err := s.db.Exec(`DELETE FROM sessions WHERE token = ?`, token) return err } func randomToken() (string, error) { b := make([]byte, 32) if _, err := rand.Read(b); err != nil { return "", fmt.Errorf("random token: %w", err) } return hex.EncodeToString(b), nil } func boolToInt(b bool) int { if b { return 1 } return 0 } func isUnique(err error) bool { return err != nil && (contains(err.Error(), "UNIQUE") || contains(err.Error(), "unique")) } func contains(s, sub string) bool { return len(s) >= len(sub) && (s == sub || len(s) > 0 && containsStr(s, sub)) } func containsStr(s, sub string) bool { for i := 0; i+len(sub) <= len(s); i++ { if s[i:i+len(sub)] == sub { return true } } return false }