Files
openfsd/database/users.go
Reese Norris 57d54d6705 v0.1.0-alpha
Changes:
- Implement bootstrapping library for managing several concurrent internal services
- Refactor concurrency model for connections/logical clients and their associated I/O
- Refactor server context singleton
- Refactor error handling
    - Most errors are now gracefully sent to the FSD client directly encoded as an $ER packet,
      enhancing visibility and debugging
    - Most errors are now rightfully treated as non-fatal
- Refactor package/dependency graph
- Refactor calling conventions/interfaces for many packages
- Refactor database package
- Refactor post office

Features:
- Add VATSIM-esque HTTP/JSON "data feed"
- Add ephemeral in-memory database option
- Add user management REST API
- Add improved web interface
- Add MySQL support (drop SQLite support)
2024-10-07 12:50:39 -07:00

194 lines
5.5 KiB
Go

package database
import (
"context"
"database/sql"
"errors"
"github.com/renorris/openfsd/protocol"
"golang.org/x/crypto/bcrypt"
"time"
)
type FSDUserRecord struct {
CID int `json:"cid"`
Email string `json:"email"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Password string `json:"password,omitempty"`
FSDPassword string `json:"fsd_password,omitempty"`
NetworkRating protocol.NetworkRating `json:"network_rating"`
PilotRating protocol.PilotRating `json:"pilot_rating"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func noRowsChangedError() error { return errors.New("no rows changed") }
var NoRowsChangedError = noRowsChangedError()
// Update updates this record in the database `db`.
// CID is immutable: it must reference the account to update.
// All values must be set except for password and fsd_password, which are optional.
// Automatically hashes provided passwords, which must be in plaintext.
func (r *FSDUserRecord) Update(db *sql.DB) (err error) {
ctx, cancelCtx := context.WithTimeout(context.Background(), 5*time.Second)
defer cancelCtx()
var stmt *sql.Stmt
// Prepare the initial statement
if stmt, err = db.PrepareContext(ctx, "UPDATE users SET email=?, first_name=?, last_name=?, network_rating=?, pilot_rating=? WHERE cid=?"); err != nil {
return err
}
if _, err = stmt.ExecContext(ctx, r.Email, r.FirstName, r.LastName, int(r.NetworkRating), r.PilotRating, r.CID); err != nil {
return err
}
if err = stmt.Close(); err != nil {
return err
}
// Update passwords if necessary
if r.Password != "" {
var passwordHash []byte
if passwordHash, err = bcrypt.GenerateFromPassword([]byte(r.Password), bcrypt.MinCost); err != nil {
return err
}
if stmt, err = db.PrepareContext(ctx, "UPDATE users SET password=? WHERE cid=?"); err != nil {
return err
}
if _, err = stmt.ExecContext(ctx, string(passwordHash), r.CID); err != nil {
return err
}
if err = stmt.Close(); err != nil {
return err
}
}
if r.FSDPassword != "" {
var fsdPasswordHash []byte
if fsdPasswordHash, err = bcrypt.GenerateFromPassword([]byte(r.FSDPassword), bcrypt.MinCost); err != nil {
return err
}
if stmt, err = db.PrepareContext(ctx, "UPDATE users SET fsd_password=? WHERE cid=?"); err != nil {
return err
}
if _, err = stmt.ExecContext(ctx, string(fsdPasswordHash), r.CID); err != nil {
return err
}
if err = stmt.Close(); err != nil {
return err
}
}
return nil
}
// LoadByCID loads a user with the primary key `cid` from the database `db`
// Returns sql.ErrNoRows when no record matches the provided `cid`
func (r *FSDUserRecord) LoadByCID(db *sql.DB, cid int) error {
ctx, cancelCtx := context.WithTimeout(context.Background(), 5*time.Second)
defer cancelCtx()
var stmt *sql.Stmt
var err error
// Prepare the statement
if stmt, err = db.PrepareContext(ctx, "SELECT * FROM users WHERE cid=? LIMIT 1"); err != nil {
return err
}
var record FSDUserRecord
if err = stmt.QueryRowContext(ctx, cid).Scan(&record.CID, &record.Email, &record.FirstName,
&record.LastName, &record.Password, &record.FSDPassword, &record.NetworkRating,
&record.PilotRating, &record.CreatedAt, &record.UpdatedAt); err != nil {
return err
}
if err = stmt.Close(); err != nil {
return err
}
// Copy record into receiver
*r = record
return nil
}
// Insert inserts this user record into the database `db`
// Automatically hashes provided passwords.
// Received FSDUserRecord should contain the plaintext passwords to hash.
// Returns the automatically assigned CID
func (r *FSDUserRecord) Insert(db *sql.DB) (cid int, err error) {
ctx, cancelCtx := context.WithTimeout(context.Background(), 5*time.Second)
defer cancelCtx()
// hash the passwords
var passwordHash []byte
if passwordHash, err = bcrypt.GenerateFromPassword([]byte(r.Password), bcrypt.MinCost); err != nil {
return -1, err
}
var fsdPasswordHash []byte
if fsdPasswordHash, err = bcrypt.GenerateFromPassword([]byte(r.FSDPassword), bcrypt.MinCost); err != nil {
return -1, err
}
var stmt *sql.Stmt
// Prepare the statement
if stmt, err = db.PrepareContext(ctx, "INSERT INTO users (email, first_name, last_name, password, fsd_password, network_rating, pilot_rating) VALUES (?, ?, ?, ?, ?, ?, ?)"); err != nil {
return -1, err
}
var res sql.Result
if res, err = stmt.ExecContext(ctx, r.Email, r.FirstName, r.LastName, string(passwordHash), string(fsdPasswordHash), int(r.NetworkRating), int(r.PilotRating)); err != nil {
return -1, err
}
if err = stmt.Close(); err != nil {
return -1, err
}
var cid64 int64
if cid64, err = res.LastInsertId(); err != nil {
return -1, err
}
return int(cid64), nil
}
// Delete deletes this user record from the database `db`
// Returns NoRowsChangedError when nothing is deleted
func (r *FSDUserRecord) Delete(db *sql.DB, cid int) (err error) {
ctx, cancelCtx := context.WithTimeout(context.Background(), 5*time.Second)
defer cancelCtx()
var stmt *sql.Stmt
// Prepare the statement
if stmt, err = db.PrepareContext(ctx, "DELETE FROM users WHERE cid=?"); err != nil {
return err
}
var res sql.Result
if res, err = stmt.ExecContext(ctx, cid); err != nil {
return err
}
if err = stmt.Close(); err != nil {
return err
}
if rows, err := res.RowsAffected(); err != nil {
return err
} else if rows != 1 {
return NoRowsChangedError
}
return nil
}