From 335409c4b4d0e85bff761dfcab492af02fca0c46 Mon Sep 17 00:00:00 2001 From: Reese Norris Date: Fri, 16 May 2025 22:27:26 -0700 Subject: [PATCH] Add ConfigRepository and enhance server configuration management 1. Database Enhancements (db/repositories.go): - Added ConfigRepository interface and implementations for PostgreSQL and SQLite - Updated Repositories struct to include ConfigRepository - Modified NewRepositories to initialize both UserRepo and ConfigRepo 2. FSD Server Improvements: - Removed hardcoded jwtSecret, now retrieved from ConfigRepository (fsd/conn.go, web/auth.go) - Added dynamic welcome message retrieval from ConfigRepository (fsd/conn.go) - Optimized METAR buffer size from 4096 to 512 bytes (fsd/metar.go) - Reduced minimum fields for DeleteATC and DeletePilot packets (fsd/packet.go) - Improved Haversine distance calculation with constants (fsd/postoffice.go) - Added thread-safety documentation for sendError (fsd/client.go) 3. Server Configuration (fsd/server.go): - Added NewDefaultServer to initialize server with environment-based config - Implemented automatic database migration and default admin user creation - Added configurable METAR worker count - Improved logging with slog and environment-based debug level 4. Web Interface Enhancements: - Added user and config editor frontend routes (web/frontend.go, web/routes.go) - Improved JWT handling by retrieving secret from ConfigRepository (web/auth.go) - Enhanced user management API endpoints (web/user.go) - Updated dashboard to display CID and conditional admin links (web/templates/dashboard.html) - Embedded templates using go:embed (web/templates.go) 5. Frontend JavaScript Improvements: - Added networkRatingFromInt helper for readable ratings (web/static/js/openfsd/dashboard.js) - Improved API request handling with auth/no-auth variants (web/static/js/openfsd/api.js) 6. Miscellaneous: - Added sethvargo/go-envconfig dependency for environment variable parsing - Fixed parseVisRange to use 64-bit float parsing (fsd/util.go) - Added strPtr utility function (fsd/util.go, web/main.go) - Improved SVG logo rendering in layout (web/templates/layout.html) --- .gitignore | 1 + db/config_postgres.go | 61 ++++ db/config_repository.go | 48 +++ db/config_sqlite.go | 57 ++++ db/config_sqlite_test.go | 176 +++++++++++ ...0250515185717_create_config_table.down.sql | 1 + .../20250515185717_create_config_table.up.sql | 8 + ...0250515185720_create_config_table.down.sql | 1 + .../20250515185720_create_config_table.up.sql | 8 + db/repositories.go | 33 +- fsd/client.go | 6 + fsd/conn.go | 47 ++- fsd/env.go | 25 ++ fsd/metar.go | 2 +- fsd/packet.go | 2 +- fsd/postoffice.go | 30 +- fsd/server.go | 107 ++++++- fsd/util.go | 12 +- go.mod | 1 + go.sum | 2 + main.go | 43 +-- web/api_tokens.go | 66 ++++ web/auth.go | 46 ++- web/config.go | 107 +++++++ web/frontend.go | 8 + web/main.go | 17 +- web/routes.go | 20 +- web/server.go | 8 +- web/static/js/openfsd/api.js | 15 + web/static/js/openfsd/configeditor.js | 189 ++++++++++++ web/static/js/openfsd/dashboard.js | 30 +- web/static/js/openfsd/usereditor.js | 128 ++++++++ web/templates.go | 6 +- web/templates/configeditor.html | 71 +++++ web/templates/dashboard.html | 15 +- web/templates/layout.html | 4 +- web/templates/usereditor.html | 284 ++++++++++++++++++ web/user.go | 45 ++- 38 files changed, 1632 insertions(+), 98 deletions(-) create mode 100644 db/config_postgres.go create mode 100644 db/config_repository.go create mode 100644 db/config_sqlite.go create mode 100644 db/config_sqlite_test.go create mode 100644 db/migrations/postgres/20250515185717_create_config_table.down.sql create mode 100644 db/migrations/postgres/20250515185717_create_config_table.up.sql create mode 100644 db/migrations/sqlite/20250515185720_create_config_table.down.sql create mode 100644 db/migrations/sqlite/20250515185720_create_config_table.up.sql create mode 100644 fsd/env.go create mode 100644 web/api_tokens.go create mode 100644 web/config.go create mode 100644 web/static/js/openfsd/configeditor.js create mode 100644 web/static/js/openfsd/usereditor.js create mode 100644 web/templates/configeditor.html create mode 100644 web/templates/usereditor.html diff --git a/.gitignore b/.gitignore index b81bdd6..bc446b9 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ .idea .vscode *.db +**tmp** diff --git a/db/config_postgres.go b/db/config_postgres.go new file mode 100644 index 0000000..2b2f229 --- /dev/null +++ b/db/config_postgres.go @@ -0,0 +1,61 @@ +package db + +import ( + "database/sql" + "errors" +) + +type PostgresConfigRepository struct { + db *sql.DB +} + +// InitDefault initializes the default configuration values. +func (p *PostgresConfigRepository) InitDefault() (err error) { + if err = p.ensureSecretKeyExists(); err != nil { + return + } + return +} + +func (p *PostgresConfigRepository) ensureSecretKeyExists() (err error) { + secretKey, err := GenerateJwtSecretKey() + if err != nil { + return + } + + querystr := ` + INSERT INTO config (key, value) + SELECT $1, $2 + WHERE NOT EXISTS ( + SELECT 1 FROM config WHERE key = $1 + );` + _, err = p.db.Exec(querystr, ConfigJwtSecretKey, secretKey) + return +} + +// Set sets the value for the given key in the configuration. +// If the key already exists, it updates the value. +func (p *PostgresConfigRepository) Set(key string, value string) (err error) { + querystr := ` + INSERT INTO config (key, value) VALUES ($1, $2) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value; + ` + _, err = p.db.Exec(querystr, key, value) + return +} + +// Get retrieves the value for the given key from the configuration. +// If the key does not exist, it returns ErrConfigKeyNotFound. +func (p *PostgresConfigRepository) Get(key string) (value string, err error) { + querystr := ` + SELECT value FROM config WHERE key = $1; + ` + err = p.db.QueryRow(querystr, key).Scan(&value) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + err = ErrConfigKeyNotFound + } + return + } + return +} diff --git a/db/config_repository.go b/db/config_repository.go new file mode 100644 index 0000000..0c99537 --- /dev/null +++ b/db/config_repository.go @@ -0,0 +1,48 @@ +package db + +import ( + "crypto/rand" + "encoding/hex" + "errors" + "io" +) + +type ConfigRepository interface { + // InitDefault initializes the default state of the Config if one does not already exist. + InitDefault() (err error) + + // Set sets a value for a given key + Set(key string, value string) (err error) + + // Get gets a value for a given key. + // + // Returns ErrConfigKeyNotFound if no key/value pair is found. + Get(key string) (value string, err error) +} + +const ( + ConfigJwtSecretKey = "JWT_SECRET_KEY" + ConfigWelcomeMessage = "WELCOME_MESSAGE" +) + +var ErrConfigKeyNotFound = errors.New("config: key not found") + +const secretKeyBits = 256 + +func GenerateJwtSecretKey() (key [secretKeyBits / 8]byte, err error) { + secretKey := make([]byte, (secretKeyBits/8)/2) + if _, err = io.ReadFull(rand.Reader, secretKey); err != nil { + return + } + + hex.Encode(key[:], secretKey) + + return +} + +// GetWelcomeMessage returns any configured welcome message. +// Returns an empty string if no message is found. +func GetWelcomeMessage(r *ConfigRepository) (msg string) { + msg, _ = (*r).Get(ConfigWelcomeMessage) + return +} diff --git a/db/config_sqlite.go b/db/config_sqlite.go new file mode 100644 index 0000000..91c4165 --- /dev/null +++ b/db/config_sqlite.go @@ -0,0 +1,57 @@ +package db + +import ( + "database/sql" + "errors" +) + +type SQLiteConfigRepository struct { + db *sql.DB +} + +func (s *SQLiteConfigRepository) InitDefault() (err error) { + if err = s.ensureSecretKeyExists(); err != nil { + return + } + return +} + +func (s *SQLiteConfigRepository) ensureSecretKeyExists() (err error) { + secretKey, err := GenerateJwtSecretKey() + if err != nil { + return + } + + querystr := ` + INSERT INTO config (key, value) VALUES (?, ?) + ON CONFLICT(key) DO NOTHING; + ` + if _, err = s.db.Exec(querystr, ConfigJwtSecretKey, secretKey[:]); err != nil { + return + } + return +} + +func (s *SQLiteConfigRepository) Set(key string, value string) (err error) { + querystr := ` + INSERT INTO config (key, value) VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value; + ` + if _, err = s.db.Exec(querystr, key, value); err != nil { + return + } + return +} + +func (s *SQLiteConfigRepository) Get(key string) (value string, err error) { + querystr := ` + SELECT value FROM config WHERE key = ?; + ` + if err = s.db.QueryRow(querystr, key).Scan(&value); err != nil { + if errors.Is(err, sql.ErrNoRows) { + err = ErrConfigKeyNotFound + } + return + } + return +} diff --git a/db/config_sqlite_test.go b/db/config_sqlite_test.go new file mode 100644 index 0000000..074bbeb --- /dev/null +++ b/db/config_sqlite_test.go @@ -0,0 +1,176 @@ +package db + +import ( + "database/sql" + "errors" + _ "modernc.org/sqlite" + "testing" +) + +// setupConfigTestDB initializes an in-memory SQLite database, applies migrations, and returns the database connection and repository. +func setupConfigTestDB(t *testing.T) (*sql.DB, *SQLiteConfigRepository) { + db, err := sql.Open("sqlite", ":memory:") + if err != nil { + t.Fatalf("failed to open database: %v", err) + } + + err = Migrate(db) + if err != nil { + t.Fatalf("failed to migrate database: %v", err) + } + + repo := &SQLiteConfigRepository{db: db} + return db, repo +} + +// TestInitDefault verifies that InitDefault correctly inserts the JWT secret key and does not overwrite it on subsequent calls. +func TestInitDefault(t *testing.T) { + db, repo := setupConfigTestDB(t) + defer db.Close() + + // Initially, the key should not exist + _, err := repo.Get(ConfigJwtSecretKey) + if !errors.Is(err, ErrConfigKeyNotFound) { + t.Errorf("expected ErrConfigKeyNotFound, got %v", err) + } + + // Call InitDefault + err = repo.InitDefault() + if err != nil { + t.Errorf("expected no error, got %v", err) + } + + // Verify the key exists and is a 32-character hex string + value, err := repo.Get(ConfigJwtSecretKey) + if err != nil { + t.Errorf("expected no error, got %v", err) + } + if len(value) != 32 { + t.Errorf("expected 32-character hex string, got %s", value) + } + + // Call InitDefault again + err = repo.InitDefault() + if err != nil { + t.Errorf("expected no error, got %v", err) + } + + // Verify the key has not changed + newValue, err := repo.Get(ConfigJwtSecretKey) + if err != nil { + t.Errorf("expected no error, got %v", err) + } + if newValue != value { + t.Errorf("expected the same value, but it changed from %s to %s", value, newValue) + } +} + +// TestSet verifies that Set correctly inserts new key-value pairs and updates existing ones. +func TestSet(t *testing.T) { + db, repo := setupConfigTestDB(t) + defer db.Close() + + // Set a new key-value pair + key := "test_key" + value := "test_value" + err := repo.Set(key, value) + if err != nil { + t.Errorf("expected no error, got %v", err) + } + + // Retrieve and verify + retrievedValue, err := repo.Get(key) + if err != nil { + t.Errorf("expected no error, got %v", err) + } + if retrievedValue != value { + t.Errorf("expected %s, got %s", value, retrievedValue) + } + + // Update the key with a new value + newValue := "new_test_value" + err = repo.Set(key, newValue) + if err != nil { + t.Errorf("expected no error, got %v", err) + } + + // Retrieve and verify again + retrievedValue, err = repo.Get(key) + if err != nil { + t.Errorf("expected no error, got %v", err) + } + if retrievedValue != newValue { + t.Errorf("expected %s, got %s", newValue, retrievedValue) + } +} + +// TestGet verifies that Get retrieves values for existing keys and returns an error for non-existing keys. +func TestGet(t *testing.T) { + db, repo := setupConfigTestDB(t) + defer db.Close() + + // Try to get a non-existing key + _, err := repo.Get("non_existing_key") + if !errors.Is(err, ErrConfigKeyNotFound) { + t.Errorf("expected ErrConfigKeyNotFound, got %v", err) + } + + // Set a key-value pair + key := "another_key" + value := "another_value" + err = repo.Set(key, value) + if err != nil { + t.Errorf("expected no error, got %v", err) + } + + // Retrieve and verify + retrievedValue, err := repo.Get(key) + if err != nil { + t.Errorf("expected no error, got %v", err) + } + if retrievedValue != value { + t.Errorf("expected %s, got %s", value, retrievedValue) + } +} + +// TestMultipleSets verifies that setting multiple keys works correctly and updating one does not affect others. +func TestMultipleSets(t *testing.T) { + db, repo := setupConfigTestDB(t) + defer db.Close() + + // Set multiple keys + err := repo.Set("key1", "value1") + if err != nil { + t.Errorf("expected no error, got %v", err) + } + err = repo.Set("key2", "value2") + if err != nil { + t.Errorf("expected no{kcal error, got %v", err) + } + + // Retrieve and verify + val1, err := repo.Get("key1") + if err != nil || val1 != "value1" { + t.Errorf("expected value1, got %s, err %v", val1, err) + } + val2, err := repo.Get("key2") + if err != nil || val2 != "value2" { + t.Errorf("expected value2, got %s, err %v", val2, err) + } + + // Update one key + err = repo.Set("key1", "new_value1") + if err != nil { + t.Errorf("expected no error, got %v", err) + } + + // Check both keys + val1, err = repo.Get("key1") + if err != nil || val1 != "new_value1" { + t.Errorf("expected new_value1, got %s, err %v", val1, err) + } + val2, err = repo.Get("key2") + if err != nil || val2 != "value2" { + t.Errorf("expected value2, got %s, err %v", val2, err) + } +} diff --git a/db/migrations/postgres/20250515185717_create_config_table.down.sql b/db/migrations/postgres/20250515185717_create_config_table.down.sql new file mode 100644 index 0000000..3492d18 --- /dev/null +++ b/db/migrations/postgres/20250515185717_create_config_table.down.sql @@ -0,0 +1 @@ +drop table config; diff --git a/db/migrations/postgres/20250515185717_create_config_table.up.sql b/db/migrations/postgres/20250515185717_create_config_table.up.sql new file mode 100644 index 0000000..193315d --- /dev/null +++ b/db/migrations/postgres/20250515185717_create_config_table.up.sql @@ -0,0 +1,8 @@ +create table config +( + key varchar not null, + value varchar not null +); + +create unique index config_key_uindex + on config (key); diff --git a/db/migrations/sqlite/20250515185720_create_config_table.down.sql b/db/migrations/sqlite/20250515185720_create_config_table.down.sql new file mode 100644 index 0000000..c20b90c --- /dev/null +++ b/db/migrations/sqlite/20250515185720_create_config_table.down.sql @@ -0,0 +1 @@ +drop table config; \ No newline at end of file diff --git a/db/migrations/sqlite/20250515185720_create_config_table.up.sql b/db/migrations/sqlite/20250515185720_create_config_table.up.sql new file mode 100644 index 0000000..0bbf527 --- /dev/null +++ b/db/migrations/sqlite/20250515185720_create_config_table.up.sql @@ -0,0 +1,8 @@ +create table config +( + key text not null, + value text not null +); + +create unique index config_key_uindex + on config (key); diff --git a/db/repositories.go b/db/repositories.go index bb2345b..b5c911e 100644 --- a/db/repositories.go +++ b/db/repositories.go @@ -9,7 +9,8 @@ import ( // Repositories bundles all repository interfaces type Repositories struct { - UserRepo UserRepository + UserRepo UserRepository + ConfigRepo ConfigRepository } // NewUserRepository creates a UserRepository based on the database driver @@ -24,14 +25,26 @@ func NewUserRepository(db *sql.DB) (UserRepository, error) { } } -// NewRepositories creates a Repositories bundle with implementations for the given database -func NewRepositories(db *sql.DB) (*Repositories, error) { - userRepo, err := NewUserRepository(db) - if err != nil { - return nil, err +// NewConfigRepository creates a ConfigRepository based on the database driver +func NewConfigRepository(db *sql.DB) (ConfigRepository, error) { + switch db.Driver().(type) { + case *pq.Driver: + return &PostgresConfigRepository{db: db}, nil + case *sqlite.Driver: + return &SQLiteConfigRepository{db: db}, nil + default: + return nil, fmt.Errorf("unsupported database") } - - return &Repositories{ - UserRepo: userRepo, - }, nil +} + +// NewRepositories creates a Repositories bundle with implementations for the given database +func NewRepositories(db *sql.DB) (repositories *Repositories, err error) { + repositories = &Repositories{} + if repositories.UserRepo, err = NewUserRepository(db); err != nil { + return + } + if repositories.ConfigRepo, err = NewConfigRepository(db); err != nil { + return + } + return } diff --git a/fsd/client.go b/fsd/client.go index abd0ed1..d6a3e3b 100644 --- a/fsd/client.go +++ b/fsd/client.go @@ -60,6 +60,8 @@ func (c *Client) senderWorker() { // sendError sends an FSD error packet to a Client with the specified code and message. // It returns an error if writing to the connection fails. +// +// This call is thread-safe func (c *Client) sendError(code int, message string) (err error) { packet := strings.Builder{} packet.Grow(128) @@ -74,6 +76,10 @@ func (c *Client) sendError(code int, message string) (err error) { return c.send(packet.String()) } +// send sends a packet string to a Client. +// This call queues the packet in the Client's outbound send channel. +// This call will block until the packet can be queued in the send channel. +// Returns a context error if the Client's context has elapsed. func (c *Client) send(packet string) (err error) { select { case c.sendChan <- packet: diff --git a/fsd/conn.go b/fsd/conn.go index 30cfc17..7bc8833 100644 --- a/fsd/conn.go +++ b/fsd/conn.go @@ -6,6 +6,7 @@ import ( "context" "errors" "fmt" + "github.com/renorris/openfsd/db" "io" "net" "strconv" @@ -257,8 +258,13 @@ func (s *Server) attemptAuthentication(client *Client, token string) (err error) // Check if the provided token is actually a JWT if mostLikelyJwt([]byte(token)) { + var jwtSecret string + if jwtSecret, err = s.dbRepo.ConfigRepo.Get(db.ConfigJwtSecretKey); err != nil { + return + } + var jwtToken *JwtToken - if jwtToken, err = ParseJwtToken(token, s.jwtSecret); err != nil { + if jwtToken, err = ParseJwtToken(token, []byte(jwtSecret)); err != nil { err = ErrInvalidAddPacket sendError(client.conn, InvalidLogonError, invalidLogonMsg) return @@ -282,6 +288,11 @@ func (s *Server) attemptAuthentication(client *Client, token string) (err error) sendError(client.conn, RequestedLevelTooHighError, "Requested level too high") return } + if client.networkRating < NetworkRatingObserver { + err = ErrInvalidAddPacket + sendError(client.conn, CertificateSuspendedError, "Certificate inactive or suspended") + return + } return } @@ -309,6 +320,11 @@ func (s *Server) attemptAuthentication(client *Client, token string) (err error) sendError(client.conn, RequestedLevelTooHighError, "Requested level too high") return } + if client.networkRating < NetworkRatingObserver { + err = ErrInvalidAddPacket + sendError(client.conn, CertificateSuspendedError, "Certificate inactive or suspended") + return + } return } @@ -353,7 +369,32 @@ func (s *Server) broadcastDisconnectPacket(client *Client) { } func (s *Server) sendMotd(client *Client) (err error) { - packet := fmt.Sprintf("#TMserver:%s:Connected to openfsd\r\n", client.callsign) - _, err = client.conn.Write([]byte(packet)) + welcomeMsg := db.GetWelcomeMessage(&s.dbRepo.ConfigRepo) + if welcomeMsg != "" { + lines := strings.Split(welcomeMsg, "\n") + for i := range lines { + if err = s.sendServerTextMessage(client, lines[i]); err != nil { + return + } + } + } else { + if err = s.sendServerTextMessage(client, "Connected to openfsd"); err != nil { + return + } + } + return +} + +// sendServerTextMessage synchronously sends a server #TM to the client's socket +func (s *Server) sendServerTextMessage(client *Client, msg string) (err error) { + packet := strings.Builder{} + packet.Grow(32 + len(msg)) + packet.WriteString("#TMserver:") + packet.WriteString(client.callsign) + packet.WriteByte(':') + packet.WriteString(msg) + packet.WriteString("\r\n") + + _, err = client.conn.Write([]byte(packet.String())) return } diff --git a/fsd/env.go b/fsd/env.go new file mode 100644 index 0000000..7fd19e1 --- /dev/null +++ b/fsd/env.go @@ -0,0 +1,25 @@ +package fsd + +import ( + "context" + "github.com/sethvargo/go-envconfig" +) + +type ServerConfig struct { + FsdListenAddrs []string `env:"FSD_LISTEN_ADDRS, default=:6809"` // FSD listen addresses + + DatabaseDriver string `env:"DATABASE_DRIVER, default=sqlite"` // Golang sql database driver name + DatabaseSourceName string `env:"DATABASE_SOURCE_NAME, default=:memory:"` // Golang sql database source name + DatabaseAutoMigrate bool `env:"DATABASE_AUTO_MIGRATE, default=false"` // Whether to automatically run database migrations on startup + DatabaseMaxConns int `env:"DATABASE_MAX_CONNS, default=4"` // Max number of database connections + + NumMetarWorkers int `env:"NUM_METAR_WORKERS, default=4"` // Number of METAR fetch workers to run +} + +func loadServerConfig(ctx context.Context) (config *ServerConfig, err error) { + config = &ServerConfig{} + if err = envconfig.Process(ctx, config); err != nil { + return + } + return +} diff --git a/fsd/metar.go b/fsd/metar.go index 7160a93..24930ae 100644 --- a/fsd/metar.go +++ b/fsd/metar.go @@ -57,7 +57,7 @@ func (s *metarService) handleMetarRequest(req *metarRequest) { return } - bufBytes := make([]byte, 4096) + bufBytes := make([]byte, 512) buf := bytes.NewBuffer(bufBytes) if _, err = io.Copy(buf, res.Body); err != nil { sendMetarServiceError(req) diff --git a/fsd/packet.go b/fsd/packet.go index 70b01a7..05b5b11 100644 --- a/fsd/packet.go +++ b/fsd/packet.go @@ -147,7 +147,7 @@ func minFields(packetType PacketType) int { case PacketTypeATCPosition: return 7 case PacketTypeDeleteATC, PacketTypeDeletePilot: - return 2 + return 1 case PacketTypeTextMessage: return 3 case PacketTypeProController: diff --git a/fsd/postoffice.go b/fsd/postoffice.go index d41b92d..8887355 100644 --- a/fsd/postoffice.go +++ b/fsd/postoffice.go @@ -143,11 +143,14 @@ func (p *postOffice) all(client *Client, callback func(recipient *Client) bool) p.clientMapLock.RUnlock() } -const earthRadius = 6371000.0 // meters, approximate mean radius of Earth +const ( + earthRadius = 6371000.0 // meters, approximate mean radius of Earth + degToRad = math.Pi / 180 +) func calculateBoundingBox(center [2]float64, radius float64) (min [2]float64, max [2]float64) { - latRad := center[0] * math.Pi / 180 - metersPerDegreeLat := (math.Pi * earthRadius) / 180 + latRad := center[0] * degToRad + const metersPerDegreeLat = (math.Pi * earthRadius) / 180 deltaLat := radius / metersPerDegreeLat metersPerDegreeLon := metersPerDegreeLat * math.Cos(latRad) deltaLon := radius / metersPerDegreeLon @@ -165,16 +168,21 @@ func calculateBoundingBox(center [2]float64, radius float64) (min [2]float64, ma // distance calculates the great-circle distance between two points using the Haversine formula. func distance(lat1, lon1, lat2, lon2 float64) float64 { - lat1Rad := lat1 * (math.Pi / 180) - lon1Rad := lon1 * (math.Pi / 180) - lat2Rad := lat2 * (math.Pi / 180) - lon2Rad := lon2 * (math.Pi / 180) + dLat := (lat2 - lat1) * degToRad + dLon := (lon2 - lon1) * degToRad - dLat := lat2Rad - lat1Rad - dLon := lon2Rad - lon1Rad + sinDLat2 := math.Sin(dLat * 0.5) + sinDLon2 := math.Sin(dLon * 0.5) - a := math.Sin(dLat/2)*math.Sin(dLat/2) + math.Cos(lat1Rad)*math.Cos(lat2Rad)*math.Sin(dLon/2)*math.Sin(dLon/2) - c := 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a)) + cosLat1 := math.Cos(lat1 * degToRad) + cosLat2 := math.Cos(lat2 * degToRad) + + a := sinDLat2*sinDLat2 + cosLat1*cosLat2*sinDLon2*sinDLon2 + + sqrtA := math.Sqrt(a) + sqrt1MinusA := math.Sqrt(1 - a) + + c := 2 * math.Atan2(sqrtA, sqrt1MinusA) return earthRadius * c } diff --git a/fsd/server.go b/fsd/server.go index d10bd04..89939de 100644 --- a/fsd/server.go +++ b/fsd/server.go @@ -2,32 +2,128 @@ package fsd import ( "context" + "crypto/rand" + "database/sql" + "encoding/hex" "errors" "fmt" "github.com/renorris/openfsd/db" + "io" + "log/slog" "net" "sync" ) type Server struct { listenAddrs []string - jwtSecret []byte postOffice *postOffice metarService *metarService dbRepo *db.Repositories } -func NewServer(listenAddrs []string, jwtSecret []byte, dbRepo *db.Repositories) (server *Server, err error) { +// NewServer creates a new Server instance. +// +// See NewDefaultServer to create a server using default settings obtained via environment variables. +func NewServer(listenAddrs []string, dbRepo *db.Repositories, numMetarWorkers int) (server *Server, err error) { server = &Server{ listenAddrs: listenAddrs, - jwtSecret: jwtSecret, postOffice: newPostOffice(), - metarService: newMetarService(4), + metarService: newMetarService(numMetarWorkers), dbRepo: dbRepo, } return } +// NewDefaultServer creates a new Server instance using the default configuration obtained via environment variables +func NewDefaultServer(ctx context.Context) (server *Server, err error) { + config, err := loadServerConfig(ctx) + if err != nil { + return + } + + slog.Info(fmt.Sprintf("using %s", config.DatabaseDriver)) + + slog.Debug("connecting to SQL") + sqlDb, err := sql.Open(config.DatabaseDriver, config.DatabaseSourceName) + if err != nil { + return + } + slog.Debug("SQL OK") + + sqlDb.SetMaxOpenConns(config.DatabaseMaxConns) + + if config.DatabaseAutoMigrate { + slog.Debug("automatically migrating database") + if err = db.Migrate(sqlDb); err != nil { + return + } + slog.Debug("migrate OK") + } + + dbRepo, err := db.NewRepositories(sqlDb) + if err != nil { + return + } + + // Generate a default admin user if CID 1 isn't taken + if _, err = dbRepo.UserRepo.GetUserByCID(1); err != nil { + if !errors.Is(err, sql.ErrNoRows) { + return + } + err = nil + + slog.Debug("no user with CID = 1 found, creating default admin user") + var user *db.User + if user, err = generateDefaultAdminUser(dbRepo); err != nil { + return + } + slog.Info(fmt.Sprintf( + ` + + DEFAULT ADMINISTRATOR CREDENTIALS: + CID: %d + Password: %s + +`, + user.CID, + user.Password, + )) + } + + // Ensure default configuration is written to persistent storage + slog.Debug("initializing default config") + if err = dbRepo.ConfigRepo.InitDefault(); err != nil { + return + } + slog.Debug("config OK") + + if server, err = NewServer(config.FsdListenAddrs, dbRepo, config.NumMetarWorkers); err != nil { + return + } + + return +} + +func generateDefaultAdminUser(dbRepo *db.Repositories) (user *db.User, err error) { + passwordBuf := make([]byte, 8) + if _, err = io.ReadFull(rand.Reader, passwordBuf); err != nil { + return + } + password := hex.EncodeToString(passwordBuf) + + user = &db.User{ + Password: password, + FirstName: strPtr("Default Administrator"), + NetworkRating: int(NetworkRatingAdministator), + } + + if err = dbRepo.UserRepo.CreateUser(user); err != nil { + return + } + + return +} + func (s *Server) Run(ctx context.Context) (err error) { // Start metar service go s.metarService.run(ctx) @@ -36,6 +132,7 @@ func (s *Server) Run(ctx context.Context) (err error) { var listenerWg sync.WaitGroup for _, addr := range s.listenAddrs { + slog.Info(fmt.Sprintf("Listening on %s\n", addr)) listenerWg.Add(1) go func(ctx context.Context, addr string) { defer listenerWg.Done() @@ -61,7 +158,7 @@ func (s *Server) Run(ctx context.Context) (err error) { // All listeners started successfully; wait for context to be cancelled <-ctx.Done() - return ctx.Err() + return } func (s *Server) listen(ctx context.Context, addr string, errCh chan<- error) { diff --git a/fsd/util.go b/fsd/util.go index 23e850b..884d625 100644 --- a/fsd/util.go +++ b/fsd/util.go @@ -194,7 +194,7 @@ func parseLatLon(packet []byte, latIndex, lonIndex int) (lat float64, lon float6 // parseVisRange parses an FSD-encoded visibility range and returns the distance in meters func parseVisRange(packet []byte, index int) (visRange float64, ok bool) { - visRangeNauticalMiles, err := strconv.ParseFloat(string(getField(packet, index)), 10) + visRangeNauticalMiles, err := strconv.ParseFloat(string(getField(packet, index)), 64) if err != nil { return } @@ -220,7 +220,7 @@ func broadcastRanged(po *postOffice, client *Client, packet []byte) { func broadcastRangedVelocity(po *postOffice, client *Client, packet []byte) { packetStr := string(packet) po.search(client, func(recipient *Client) bool { - if client.protoRevision != 101 { + if recipient.protoRevision != 101 { return true } recipient.send(packetStr) @@ -232,7 +232,7 @@ func broadcastRangedVelocity(po *postOffice, client *Client, packet []byte) { func broadcastRangedAtcOnly(po *postOffice, client *Client, packet []byte) { packetStr := string(packet) po.search(client, func(recipient *Client) bool { - if !client.isAtc { + if !recipient.isAtc { return true } recipient.send(packetStr) @@ -265,7 +265,7 @@ func broadcastAllATC(po *postOffice, client *Client, packet []byte) { func broadcastAllSupervisors(po *postOffice, client *Client, packet []byte) { packetStr := string(packet) po.all(client, func(recipient *Client) bool { - if client.networkRating < NetworkRatingSupervisor { + if recipient.networkRating < NetworkRatingSupervisor { return true } recipient.send(packetStr) @@ -352,3 +352,7 @@ func buildBeaconCodePacket(source, recipient, targetCallsign, beaconCode string) return builder.String() } + +func strPtr(str string) *string { + return &str +} diff --git a/go.mod b/go.mod index b9e5aa5..9707f59 100644 --- a/go.mod +++ b/go.mod @@ -43,6 +43,7 @@ require ( github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/sethvargo/go-envconfig v1.3.0 // indirect github.com/tidwall/geoindex v1.7.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.12 // indirect diff --git a/go.sum b/go.sum index 74015d3..8411183 100644 --- a/go.sum +++ b/go.sum @@ -69,6 +69,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/sethvargo/go-envconfig v1.3.0 h1:gJs+Fuv8+f05omTpwWIu6KmuseFAXKrIaOZSh8RMt0U= +github.com/sethvargo/go-envconfig v1.3.0/go.mod h1:JLd0KFWQYzyENqnEPWWZ49i4vzZo/6nRidxI8YvGiHw= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= diff --git a/main.go b/main.go index dbf7a55..70f7498 100644 --- a/main.go +++ b/main.go @@ -2,10 +2,9 @@ package main import ( "context" - "database/sql" "fmt" - "github.com/renorris/openfsd/db" "github.com/renorris/openfsd/fsd" + "log/slog" _ "modernc.org/sqlite" "os" "os/signal" @@ -14,39 +13,23 @@ import ( func main() { fmt.Println("hello world") - sqlDb, err := sql.Open("sqlite", ":memory:") - if err != nil { - panic(err) - } + setSlogLevel() - if err = db.Migrate(sqlDb); err != nil { - panic(err) - } - - dbRepo, err := db.NewRepositories(sqlDb) - if err != nil { - panic(err) - } - - user := &db.User{ - Password: "12345", - NetworkRating: 1, - } - err = dbRepo.UserRepo.CreateUser(user) - if err != nil { - panic(err) - } - fmt.Println(user) - - s, err := fsd.NewServer([]string{":6809"}, []byte("abcdef"), dbRepo) + os.Setenv("DATABASE_AUTO_MIGRATE", "true") + server, err := fsd.NewDefaultServer(context.Background()) if err != nil { panic(err) } ctx, _ := signal.NotifyContext(context.Background(), os.Interrupt) - - if err = s.Run(ctx); err != nil { - fmt.Println(err) + if err = server.Run(ctx); err != nil { + slog.Error(err.Error()) + } + slog.Info("server closed") +} + +func setSlogLevel() { + if os.Getenv("LOG_DEBUG") == "true" { + slog.SetLogLoggerLevel(slog.LevelDebug) } - fmt.Println("server closed") } diff --git a/web/api_tokens.go b/web/api_tokens.go new file mode 100644 index 0000000..3cf9979 --- /dev/null +++ b/web/api_tokens.go @@ -0,0 +1,66 @@ +package main + +import ( + "github.com/gin-gonic/gin" + "github.com/renorris/openfsd/db" + "github.com/renorris/openfsd/fsd" + "net/http" + "time" +) + +func (s *Server) handleCreateNewAPIToken(c *gin.Context) { + claims := getJwtContext(c) + if claims.NetworkRating < fsd.NetworkRatingAdministator { + writeAPIV1Response(c, http.StatusForbidden, &genericAPIV1Forbidden) + return + } + + type RequestBody struct { + ExpiryDateTime time.Time `json:"expiry_date_time" time_format:"2006-01-02T15:04:05.000Z" binding:"required"` + } + + var reqBody RequestBody + if ok := bindJSONOrAbort(c, &reqBody); !ok { + return + } + + now := time.Now() + + if reqBody.ExpiryDateTime.Before(now) { + res := newAPIV1Failure("expiry_date_time cannot be in the past") + writeAPIV1Response(c, http.StatusBadRequest, &res) + return + } + + validityDuration := reqBody.ExpiryDateTime.Sub(now) + + accessToken, err := fsd.MakeJwtToken(&fsd.CustomFields{ + TokenType: "access", + CID: claims.CID, + NetworkRating: fsd.NetworkRatingAdministator, + }, validityDuration) + if err != nil { + writeAPIV1Response(c, http.StatusInternalServerError, &genericAPIV1InternalServerError) + return + } + + secretKey, err := s.dbRepo.ConfigRepo.Get(db.ConfigJwtSecretKey) + if err != nil { + writeAPIV1Response(c, http.StatusInternalServerError, &genericAPIV1InternalServerError) + return + } + + accessTokenStr, err := accessToken.SignedString([]byte(secretKey)) + if err != nil { + writeAPIV1Response(c, http.StatusInternalServerError, &genericAPIV1InternalServerError) + return + } + + type ResponseBody struct { + Token string `json:"token"` + } + + resBody := ResponseBody{Token: accessTokenStr} + res := newAPIV1Success(&resBody) + writeAPIV1Response(c, http.StatusCreated, &res) +} diff --git a/web/auth.go b/web/auth.go index 8b9d7cc..6ad4ab5 100644 --- a/web/auth.go +++ b/web/auth.go @@ -71,7 +71,13 @@ func (s *Server) refreshAccessToken(c *gin.Context) { badTokenRes := newAPIV1Failure("bad token") - refreshToken, err := fsd.ParseJwtToken(reqBody.RefreshToken, s.jwtSecret) + jwtSecret, err := s.dbRepo.ConfigRepo.Get(db.ConfigJwtSecretKey) + if err != nil { + writeAPIV1Response(c, http.StatusInternalServerError, &genericAPIV1InternalServerError) + return + } + + refreshToken, err := fsd.ParseJwtToken(reqBody.RefreshToken, []byte(jwtSecret)) if err != nil { writeAPIV1Response(c, http.StatusUnauthorized, &badTokenRes) return @@ -90,7 +96,7 @@ func (s *Server) refreshAccessToken(c *gin.Context) { return } - access, err := s.makeAccessToken(user) + access, err := s.makeAccessToken(user, []byte(jwtSecret)) if err != nil { writeAPIV1Response(c, http.StatusInternalServerError, &genericAPIV1InternalServerError) return @@ -174,7 +180,13 @@ func (s *Server) getFsdJwt(c *gin.Context) { return } - fsdJwtTokenStr, err := fsdJwtToken.SignedString(s.jwtSecret) + jwtSecret, err := s.dbRepo.ConfigRepo.Get(db.ConfigJwtSecretKey) + if err != nil { + writeAPIV1Response(c, http.StatusInternalServerError, &genericAPIV1InternalServerError) + return + } + + fsdJwtTokenStr, err := fsdJwtToken.SignedString([]byte(jwtSecret)) if err != nil { resBody := ResponseBody{ ErrorMsg: "Internal server error", @@ -200,13 +212,22 @@ func (s *Server) jwtBearerMiddleware(c *gin.Context) { if !found { res := newAPIV1Failure("bad bearer token") writeAPIV1Response(c, http.StatusBadRequest, &res) + c.Abort() return } - accessToken, err := fsd.ParseJwtToken(authHeader, s.jwtSecret) + jwtSecret, err := s.dbRepo.ConfigRepo.Get(db.ConfigJwtSecretKey) + if err != nil { + writeAPIV1Response(c, http.StatusInternalServerError, &genericAPIV1InternalServerError) + c.Abort() + return + } + + accessToken, err := fsd.ParseJwtToken(authHeader, []byte(jwtSecret)) if err != nil { res := newAPIV1Failure("invalid bearer token") writeAPIV1Response(c, http.StatusUnauthorized, &res) + c.Abort() return } @@ -233,12 +254,17 @@ func getJwtContext(c *gin.Context) (claims *fsd.CustomClaims) { } func (s *Server) makeAccessRefreshTokens(user *db.User, rememberMe bool) (access string, refresh string, err error) { - access, err = s.makeAccessToken(user) + jwtSecret, err := s.dbRepo.ConfigRepo.Get(db.ConfigJwtSecretKey) if err != nil { return } - refresh, err = s.makeRefreshToken(user, rememberMe) + access, err = s.makeAccessToken(user, []byte(jwtSecret)) + if err != nil { + return + } + + refresh, err = s.makeRefreshToken(user, rememberMe, []byte(jwtSecret)) if err != nil { return } @@ -246,7 +272,7 @@ func (s *Server) makeAccessRefreshTokens(user *db.User, rememberMe bool) (access return } -func (s *Server) makeAccessToken(user *db.User) (access string, err error) { +func (s *Server) makeAccessToken(user *db.User, jwtSecret []byte) (access string, err error) { // Make access token accessToken, err := fsd.MakeJwtToken(&fsd.CustomFields{ TokenType: "access", @@ -259,7 +285,7 @@ func (s *Server) makeAccessToken(user *db.User) (access string, err error) { return } - access, err = accessToken.SignedString(s.jwtSecret) + access, err = accessToken.SignedString(jwtSecret) if err != nil { return } @@ -267,7 +293,7 @@ func (s *Server) makeAccessToken(user *db.User) (access string, err error) { return } -func (s *Server) makeRefreshToken(user *db.User, rememberMe bool) (refresh string, err error) { +func (s *Server) makeRefreshToken(user *db.User, rememberMe bool, jwtSecret []byte) (refresh string, err error) { refreshTokenDuration := time.Hour * 24 if rememberMe { refreshTokenDuration = time.Hour * 24 * 30 @@ -285,7 +311,7 @@ func (s *Server) makeRefreshToken(user *db.User, rememberMe bool) (refresh strin return } - refresh, err = refreshToken.SignedString(s.jwtSecret) + refresh, err = refreshToken.SignedString(jwtSecret) if err != nil { return } diff --git a/web/config.go b/web/config.go new file mode 100644 index 0000000..4c8e962 --- /dev/null +++ b/web/config.go @@ -0,0 +1,107 @@ +package main + +import ( + "errors" + "github.com/gin-gonic/gin" + "github.com/renorris/openfsd/db" + "github.com/renorris/openfsd/fsd" + "net/http" +) + +type KeyValuePair struct { + Key string `json:"key" binding:"required"` + Value string `json:"value" binding:"required"` +} + +func (s *Server) handleGetConfig(c *gin.Context) { + claims := getJwtContext(c) + if claims.NetworkRating < fsd.NetworkRatingAdministator { + writeAPIV1Response(c, http.StatusForbidden, &genericAPIV1Forbidden) + return + } + + var configKeys = []string{ + db.ConfigWelcomeMessage, + } + + type ResponseBody struct { + KeyValuePairs []KeyValuePair `json:"key_value_pairs" binding:"required"` + } + + resBody := ResponseBody{ + KeyValuePairs: make([]KeyValuePair, 0, len(configKeys)), + } + + for i := range configKeys { + key := configKeys[i] + val, err := s.dbRepo.ConfigRepo.Get(key) + if err != nil { + if !errors.Is(err, db.ErrConfigKeyNotFound) { + res := newAPIV1Failure("Error reading key/value from persistent storage") + writeAPIV1Response(c, http.StatusInternalServerError, &res) + return + } + continue + } + resBody.KeyValuePairs = append(resBody.KeyValuePairs, + KeyValuePair{ + Key: key, + Value: val, + }, + ) + } + + res := newAPIV1Success(&resBody) + writeAPIV1Response(c, http.StatusOK, &res) +} + +func (s *Server) handleUpdateConfig(c *gin.Context) { + claims := getJwtContext(c) + if claims.NetworkRating < fsd.NetworkRatingAdministator { + writeAPIV1Response(c, http.StatusForbidden, &genericAPIV1Forbidden) + return + } + + type RequestBody struct { + KeyValuePairs []KeyValuePair `json:"key_value_pairs" binding:"required"` + } + + var reqBody RequestBody + if ok := bindJSONOrAbort(c, &reqBody); !ok { + return + } + + for i := range reqBody.KeyValuePairs { + kv := reqBody.KeyValuePairs[i] + if err := s.dbRepo.ConfigRepo.Set(kv.Key, kv.Value); err != nil { + res := newAPIV1Failure("Error writing key/value into persistent storage") + writeAPIV1Response(c, http.StatusInternalServerError, &res) + return + } + } + + res := newAPIV1Success(nil) + writeAPIV1Response(c, http.StatusOK, &res) +} + +func (s *Server) handleResetSecretKey(c *gin.Context) { + claims := getJwtContext(c) + if claims.NetworkRating < fsd.NetworkRatingAdministator { + writeAPIV1Response(c, http.StatusForbidden, &genericAPIV1Forbidden) + return + } + + secretKey, err := db.GenerateJwtSecretKey() + if err != nil { + writeAPIV1Response(c, http.StatusInternalServerError, &genericAPIV1InternalServerError) + return + } + + if err = s.dbRepo.ConfigRepo.Set(db.ConfigJwtSecretKey, string(secretKey[:])); err != nil { + writeAPIV1Response(c, http.StatusInternalServerError, &genericAPIV1InternalServerError) + return + } + + res := newAPIV1Success(nil) + writeAPIV1Response(c, http.StatusOK, &res) +} diff --git a/web/frontend.go b/web/frontend.go index 6664ed4..cdf95c9 100644 --- a/web/frontend.go +++ b/web/frontend.go @@ -15,3 +15,11 @@ func (s *Server) handleFrontendLogin(c *gin.Context) { func (s *Server) handleFrontendDashboard(c *gin.Context) { writeTemplate(c, "dashboard", nil) } + +func (s *Server) handleFrontendUserEditor(c *gin.Context) { + writeTemplate(c, "usereditor", nil) +} + +func (s *Server) handleFrontendConfigEditor(c *gin.Context) { + writeTemplate(c, "configeditor", nil) +} diff --git a/web/main.go b/web/main.go index ebb3a98..3d5cd43 100644 --- a/web/main.go +++ b/web/main.go @@ -21,14 +21,29 @@ func main() { panic(err) } + strPtr := func(str string) *string { + return &str + } + if err = dbRepo.UserRepo.CreateUser(&db.User{ + FirstName: strPtr("Default Administrator"), Password: "12345", NetworkRating: 12, }); err != nil { panic(err) } - server, err := NewServer(dbRepo, []byte("abcdef")) + err = dbRepo.ConfigRepo.Set(db.ConfigJwtSecretKey, "abcdef") + if err != nil { + panic(err) + } + + err = dbRepo.ConfigRepo.InitDefault() + if err != nil { + panic(err) + } + + server, err := NewServer(dbRepo) if err != nil { panic(err) } diff --git a/web/routes.go b/web/routes.go index 00fcc23..7bbb7cd 100644 --- a/web/routes.go +++ b/web/routes.go @@ -22,6 +22,7 @@ func (s *Server) setupRoutes() (e *gin.Engine) { apiV1Group.POST("/fsd-jwt", s.getFsdJwt) s.setupAuthRoutes(apiV1Group) s.setupUserRoutes(apiV1Group) + s.setupConfigRoutes(apiV1Group) // Frontend groups s.setupFrontendRoutes(e.Group("")) @@ -39,9 +40,20 @@ func (s *Server) setupAuthRoutes(parent *gin.RouterGroup) { } func (s *Server) setupUserRoutes(parent *gin.RouterGroup) { - usersGroup := parent.Group("/user").Use(s.jwtBearerMiddleware) - usersGroup.POST("/load", s.getUserInfo) - usersGroup.POST("/update", s.updateUser) + usersGroup := parent.Group("/user") + usersGroup.Use(s.jwtBearerMiddleware) + usersGroup.POST("/load", s.getUserByCID) + usersGroup.PATCH("/update", s.updateUser) + usersGroup.POST("/create", s.createUser) +} + +func (s *Server) setupConfigRoutes(parent *gin.RouterGroup) { + configGroup := parent.Group("/config") + configGroup.Use(s.jwtBearerMiddleware) + configGroup.GET("/load", s.handleGetConfig) + configGroup.POST("/update", s.handleUpdateConfig) + configGroup.POST("/resetsecretkey", s.handleResetSecretKey) + configGroup.POST("/createtoken", s.handleCreateNewAPIToken) } func (s *Server) setupFrontendRoutes(parent *gin.RouterGroup) { @@ -49,4 +61,6 @@ func (s *Server) setupFrontendRoutes(parent *gin.RouterGroup) { frontendGroup.GET("", s.handleFrontendLanding) frontendGroup.GET("/login", s.handleFrontendLogin) frontendGroup.GET("/dashboard", s.handleFrontendDashboard) + frontendGroup.GET("/usereditor", s.handleFrontendUserEditor) + frontendGroup.GET("/configeditor", s.handleFrontendConfigEditor) } diff --git a/web/server.go b/web/server.go index 030bba8..423a26f 100644 --- a/web/server.go +++ b/web/server.go @@ -6,14 +6,12 @@ import ( ) type Server struct { - dbRepo *db.Repositories - jwtSecret []byte + dbRepo *db.Repositories } -func NewServer(dbRepo *db.Repositories, jwtSecret []byte) (server *Server, err error) { +func NewServer(dbRepo *db.Repositories) (server *Server, err error) { server = &Server{ - dbRepo: dbRepo, - jwtSecret: jwtSecret, + dbRepo: dbRepo, } return diff --git a/web/static/js/openfsd/api.js b/web/static/js/openfsd/api.js index f3cf7d3..a1b5df0 100644 --- a/web/static/js/openfsd/api.js +++ b/web/static/js/openfsd/api.js @@ -1,3 +1,11 @@ +async function doAPIRequestWithAuth(method, url, data) { + return doAPIRequest(method, url, true, data) +} + +async function doAPIRequestNoAuth(method, url, data) { + return doAPIRequest(method, url, false, data) +} + async function doAPIRequest(method, url, withAuth, data) { return new Promise(async (resolve, reject) => { let accessToken = ""; @@ -15,6 +23,7 @@ async function doAPIRequest(method, url, withAuth, data) { }).done((res) => { resolve(res) }).fail((xhr) => { + logout() reject(xhr) }); }); @@ -86,3 +95,9 @@ function decodeJwt(token) { return JSON.parse(jsonPayload); } + +function logout() { + localStorage.removeItem("access_token") + localStorage.removeItem("refresh_token") + window.location.href = "/login" +} diff --git a/web/static/js/openfsd/configeditor.js b/web/static/js/openfsd/configeditor.js new file mode 100644 index 0000000..c6a0997 --- /dev/null +++ b/web/static/js/openfsd/configeditor.js @@ -0,0 +1,189 @@ +const keyLabels = { + "WELCOME_MESSAGE": "Welcome Message", +}; + +// Function to show message modal +function showMessageModal(message, token) { + const messageText = document.getElementById('messageText'); + if (token) { + messageText.innerHTML = message + '
' + token + '
'; + const copyBtn = messageText.querySelector('.copy-btn'); + copyBtn.addEventListener('click', function() { + navigator.clipboard.writeText(token).then(() => { + copyBtn.textContent = 'Copied!'; + setTimeout(() => { + copyBtn.textContent = 'Copy'; + }, 2000); + }).catch(err => { + console.error('Failed to copy: ', err); + }); + }); + } else { + messageText.textContent = message; + } + const messageModal = new bootstrap.Modal(document.getElementById('messageModal')); + messageModal.show(); +} + +async function loadConfig() { + try { + const res = await doAPIRequestWithAuth('GET', '/api/v1/config/load'); + if (res.err) { + alert('Error: ' + res.err); + return; + } + const configForm = document.getElementById('config-form'); + configForm.innerHTML = ''; // Clear existing fields + res.data.key_value_pairs.forEach(kv => { + const label = keyLabels[kv.key] || kv.key; + const div = document.createElement('div'); + div.className = 'mb-3'; + div.innerHTML = ` + + + `; + configForm.appendChild(div); + }); + } catch (xhr) { + const errMsg = xhr.responseJSON && xhr.responseJSON.err ? xhr.responseJSON.err : 'An error occurred'; + alert('Error: ' + errMsg); + console.error('Request failed:', xhr); + } +} + +document.getElementById('add-config').addEventListener('click', function() { + const configForm = document.getElementById('config-form'); + const div = document.createElement('div'); + div.className = 'mb-3 new-config'; + // Create dropdown options from keyLabels + let options = ''; + Object.keys(keyLabels).forEach(key => { + options += ``; + }); + div.innerHTML = ` + + + + + `; + configForm.appendChild(div); +}); + +document.getElementById('save-config').addEventListener('click', async function() { + const keyValuePairs = []; + + // Existing configs + const existingInputs = document.querySelectorAll('#config-form input[data-key]'); + existingInputs.forEach(input => { + keyValuePairs.push({ + key: input.getAttribute('data-key'), + value: input.value + }); + }); + + // New configs + const newConfigDivs = document.querySelectorAll('#config-form .new-config'); + newConfigDivs.forEach(div => { + const keySelect = div.querySelector('select[data-type="new-key"]'); + const valueInput = div.querySelector('input[data-type="new-value"]'); + if (keySelect && valueInput && keySelect.value.trim() !== '') { + keyValuePairs.push({ + key: keySelect.value, + value: valueInput.value + }); + } + }); + + try { + const res = await doAPIRequestWithAuth('POST', '/api/v1/config/update', { key_value_pairs: keyValuePairs }); + if (res.err) { + alert('Error: ' + res.err); + } else { + alert('Config updated successfully'); + loadConfig(); // Reload to show new configs if added + } + } catch (xhr) { + const errMsg = xhr.responseJSON && xhr.responseJSON.err ? xhr.responseJSON.err : 'An error occurred'; + alert('Error: ' + errMsg); + console.error('Request failed:', xhr); + } +}); + +// Added function to reset the JWT Secret Key +async function resetJwtSecretKey() { + try { + const res = await doAPIRequestWithAuth('POST', '/api/v1/config/resetsecretkey'); + if (res.err) { + alert('Error: ' + res.err); + } else { + alert('JWT Secret Key reset successfully'); + } + } catch (xhr) { + const errMsg = xhr.responseJSON && xhr.responseJSON.err ? xhr.responseJSON.err : 'An error occurred'; + alert('Error: ' + errMsg); + console.error('Request failed:', xhr); + } +} + +// Added event listener for the reset button +document.addEventListener('DOMContentLoaded', function() { + document.getElementById('reset-jwt-secret').addEventListener('click', function() { + if (confirm('Are you sure you want to reset the JWT Secret Key? All previously generated API tokens and all active sessions will be invalidated.')) { + resetJwtSecretKey(); + } + }); +}); + +// Modified event listener for create new API token button +document.addEventListener('DOMContentLoaded', function() { + document.getElementById('create-new-api-key').addEventListener('click', function() { + const createTokenModal = new bootstrap.Modal(document.getElementById('createTokenModal')); + createTokenModal.show(); + }); +}); + +// Added event listener for submit create token +document.addEventListener('DOMContentLoaded', function() { + document.getElementById('submitCreateToken').addEventListener('click', async function() { + const expiryDateStr = document.getElementById('expiryDate').value; + let expiryDate; + if (expiryDateStr) { + const [year, month, day] = expiryDateStr.split('-').map(Number); + if (isNaN(year) || isNaN(month) || isNaN(day)) { + showMessageModal('Invalid date format.'); + return; + } + expiryDate = new Date(Date.UTC(year, month - 1, day)); + if (isNaN(expiryDate.getTime())) { + showMessageModal('Invalid date.'); + return; + } + } else { + expiryDate = new Date(); + expiryDate.setFullYear(expiryDate.getFullYear() + 1); + } + const expiryDateTime = expiryDate.toJSON(); + const data = { + "expiry_date_time": expiryDateTime + }; + try { + const res = await doAPIRequestWithAuth('POST', '/api/v1/config/createtoken', data); + if (res.err) { + showMessageModal('Error: ' + res.err); + } else { + showMessageModal('API Token created:', res.data.token); + } + } catch (xhr) { + const errMsg = xhr.responseJSON && xhr.responseJSON.err ? xhr.responseJSON.err : 'An error occurred'; + showMessageModal('Error: ' + errMsg); + } + // Hide the createTokenModal + const createTokenModal = bootstrap.Modal.getInstance(document.getElementById('createTokenModal')); + createTokenModal.hide(); + }); +}); + +document.addEventListener('DOMContentLoaded', loadConfig); \ No newline at end of file diff --git a/web/static/js/openfsd/dashboard.js b/web/static/js/openfsd/dashboard.js index bc25ebc..d1030a8 100644 --- a/web/static/js/openfsd/dashboard.js +++ b/web/static/js/openfsd/dashboard.js @@ -1,3 +1,23 @@ +function networkRatingFromInt(val) { + switch (val) { + case -1: return "Inactive" + case 0: return "Suspended" + case 1: return "Observer" + case 2: return "Student 1" + case 3: return "Student 2" + case 4: return "Student 3" + case 5: return "Controller 1" + case 6: return "Controller 2" + case 7: return "Controller 3" + case 8: return "Instructor 1" + case 9: return "Instructor 2" + case 10: return "Instructor 3" + case 11: return "Supervisor" + case 12: return "Administrator" + default: return "Unknown" + } +} + $(document).ready(async () => { const claims = getAccessTokenClaims() loadUserInfo(claims.cid) @@ -27,7 +47,15 @@ async function loadUserInfo(cid) { $("#dashboard-real-name").text("Welcome!") } - $("#dashboard-network-rating").text(res.data.network_rating) + $("#dashboard-network-rating").text(networkRatingFromInt(res.data.network_rating)) + $("#dashboard-cid").text(`CID: ${res.data.cid}`) + + if (res.data.network_rating >= 11) { + $("#dashboard-user-editor").html(` +
Edit Users
+
Configure Server
+ `) + } } let dashboardMarkers = []; diff --git a/web/static/js/openfsd/usereditor.js b/web/static/js/openfsd/usereditor.js new file mode 100644 index 0000000..48f1dd8 --- /dev/null +++ b/web/static/js/openfsd/usereditor.js @@ -0,0 +1,128 @@ +// Form Handlers +document.getElementById('search-form').addEventListener('submit', async function(event) { + event.preventDefault(); + const cid = document.getElementById('search-cid').value; + try { + const res = await doAPIRequestWithAuth('POST', '/api/v1/user/load', {cid: parseInt(cid)}); + if (res.err) { + alert('Error: ' + res.err); + } else { + const user = res.data; + document.getElementById('edit-cid').value = user.cid; + document.getElementById('edit-cid').hidden = false; + document.getElementById('edit-first-name').value = user.first_name || ''; + document.getElementById('edit-last-name').value = user.last_name || ''; + document.getElementById('edit-network-rating').value = user.network_rating; + document.getElementById('edit-password').value = ''; + } + } catch (xhr) { + const errMsg = xhr.responseJSON && xhr.responseJSON.err ? xhr.responseJSON.err : 'An error occurred'; + alert('Error: ' + errMsg); + console.error('Request failed:', xhr); + } +}); + +document.getElementById('create-form').addEventListener('submit', async function(event) { + event.preventDefault(); + const firstName = document.getElementById('create-first-name').value; + const lastName = document.getElementById('create-last-name').value; + const password = document.getElementById('create-password').value; + const networkRating = document.getElementById('create-network-rating').value; + const data = { + password: password, + network_rating: parseInt(networkRating) + }; + if (firstName) data.first_name = firstName; + if (lastName) data.last_name = lastName; + try { + const res = await doAPIRequestWithAuth('POST', '/api/v1/user/create', data); + if (res.err) { + alert('Error: ' + res.err); + } else { + alert('User created successfully. CID: ' + res.data.cid); + document.getElementById('create-first-name').value = ''; + document.getElementById('create-last-name').value = ''; + document.getElementById('create-password').value = ''; + document.getElementById('create-network-rating').value = '-1'; + } + } catch (xhr) { + const errMsg = xhr.responseJSON && xhr.responseJSON.err ? xhr.responseJSON.err : 'An error occurred'; + alert('Error: ' + errMsg); + console.error('Request failed:', xhr); + } +}); + +document.getElementById('edit-form').addEventListener('submit', async function(event) { + event.preventDefault(); + const cid = document.getElementById('edit-cid').value; + const firstName = document.getElementById('edit-first-name').value; + const lastName = document.getElementById('edit-last-name').value; + const networkRating = document.getElementById('edit-network-rating').value; + const password = document.getElementById('edit-password').value; + const data = { + cid: parseInt(cid), + first_name: firstName, + last_name: lastName, + network_rating: parseInt(networkRating) + }; + if (password) data.password = password; + try { + const res = await doAPIRequestWithAuth('PATCH', '/api/v1/user/update', data); + if (res.err) { + alert('Error: ' + res.err); + } else { + alert('User updated successfully'); + } + } catch (xhr) { + const errMsg = xhr.responseJSON && xhr.responseJSON.err ? xhr.responseJSON.err : 'An error occurred'; + alert('Error: ' + errMsg); + console.error('Request failed:', xhr); + } +}); + +document.addEventListener('DOMContentLoaded', () => { + const createPasswordInput = document.getElementById('create-password'); + const createStrengthBar = document.getElementById('create-password-strength'); + const createFeedback = document.getElementById('create-password-feedback'); + const editPasswordInput = document.getElementById('edit-password'); + const editStrengthBar = document.getElementById('edit-password-strength'); + const editFeedback = document.getElementById('edit-password-feedback'); + + const evaluatePassword = (password, strengthBar, feedback) => { + if (!password) { + strengthBar.style.width = '0%'; + strengthBar.className = 'progress-bar'; + feedback.textContent = ''; + return; + } + + let strength = 0; + if (password.length === 8) strength += 50; + if (/[A-Z]/.test(password)) strength += 15; + if (/[a-z]/.test(password)) strength += 15; + if (/[0-9]/.test(password)) strength += 10; + if (/[^A-Za-z0-9]/.test(password)) strength += 10; + + strength = Math.min(strength, 100); + strengthBar.style.width = `${strength}%`; + + if (strength < 60) { + strengthBar.className = 'progress-bar bg-danger'; + feedback.textContent = 'Weak: Include uppercase, lowercase, numbers, or symbols.'; + } else if (strength < 80) { + strengthBar.className = 'progress-bar bg-warning'; + feedback.textContent = 'Moderate: Add more character types for strength.'; + } else { + strengthBar.className = 'progress-bar bg-success'; + feedback.textContent = 'Strong: Good password!'; + } + }; + + createPasswordInput.addEventListener('input', () => { + evaluatePassword(createPasswordInput.value, createStrengthBar, createFeedback); + }); + + editPasswordInput.addEventListener('input', () => { + evaluatePassword(editPasswordInput.value, editStrengthBar, editFeedback); + }); +}); \ No newline at end of file diff --git a/web/templates.go b/web/templates.go index 4abcbf5..8ec5afa 100644 --- a/web/templates.go +++ b/web/templates.go @@ -2,6 +2,7 @@ package main import ( "bytes" + "embed" "github.com/gin-gonic/gin" "html/template" "io" @@ -9,10 +10,13 @@ import ( "path" ) +//go:embed templates +var templatesFS embed.FS + var basePath = path.Join(".", "templates") func loadTemplate(key string) (t *template.Template) { - t, err := template.ParseFiles(path.Join(basePath, "layout.html"), path.Join(basePath, key+".html")) + t, err := template.ParseFS(templatesFS, path.Join(basePath, "layout.html"), path.Join(basePath, key+".html")) if err != nil { panic(err) } diff --git a/web/templates/configeditor.html b/web/templates/configeditor.html new file mode 100644 index 0000000..4c6175c --- /dev/null +++ b/web/templates/configeditor.html @@ -0,0 +1,71 @@ +{{ define "title" }}Config Editor{{ end }} + +{{ define "body" }} +
+
+
+
Config Editor
+
+ +
+ + +
+
+
+
+ API Tokens +
+ + +
+ + + + +
+ + + + +{{ end }} \ No newline at end of file diff --git a/web/templates/dashboard.html b/web/templates/dashboard.html index 140b045..e9b1e55 100644 --- a/web/templates/dashboard.html +++ b/web/templates/dashboard.html @@ -2,11 +2,18 @@ {{ define "body" }}
-
Loading...
-
Network Rating: Loading...
users connected
-
+
+
+
+
Loading...
+
Loading...
+
Network Rating: Loading...
+
+
+
+
-{{ end }} +{{ end }} \ No newline at end of file diff --git a/web/templates/layout.html b/web/templates/layout.html index d847c4b..aa22455 100644 --- a/web/templates/layout.html +++ b/web/templates/layout.html @@ -17,9 +17,9 @@ openfsd - {{ template "title" . }} -
+
- + diff --git a/web/templates/usereditor.html b/web/templates/usereditor.html new file mode 100644 index 0000000..88d3941 --- /dev/null +++ b/web/templates/usereditor.html @@ -0,0 +1,284 @@ +{{ define "title" }}User Editor{{ end }} + +{{ define "body" }} +
+
+
+
+
+
Create User
+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+ +
+
+
+ + +
+ +
+ +
+
+
+
+
+
+
Search for User by CID
+
+
+ + +
+ +
+
+
+
+
+
+
+
Edit User
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+ +
+
+ +
+ +
+
+
+
+
+ + + + + +{{ end }} \ No newline at end of file diff --git a/web/user.go b/web/user.go index 2ff5306..38e8260 100644 --- a/web/user.go +++ b/web/user.go @@ -4,14 +4,15 @@ import ( "database/sql" "errors" "github.com/gin-gonic/gin" + "github.com/renorris/openfsd/db" "github.com/renorris/openfsd/fsd" "net/http" ) -// getUserInfo returns the user info of the specified CID. +// getUserByCID returns the user info of the specified CID. // // Only >= SUP can request CIDs other than what is indicated in their bearer token. -func (s *Server) getUserInfo(c *gin.Context) { +func (s *Server) getUserByCID(c *gin.Context) { type RequestBody struct { CID int `json:"cid" binding:"min=1,required"` } @@ -61,6 +62,12 @@ func (s *Server) getUserInfo(c *gin.Context) { // The CID itself is immutable and cannot be changed. // Only >= SUP can update CIDs other than what is indicated in their bearer token. func (s *Server) updateUser(c *gin.Context) { + claims := getJwtContext(c) + if claims.NetworkRating < fsd.NetworkRatingSupervisor { + writeAPIV1Response(c, http.StatusForbidden, &genericAPIV1Forbidden) + return + } + type RequestBody struct { CID int `json:"cid" binding:"min=1,required"` Password *string `json:"password"` @@ -80,8 +87,6 @@ func (s *Server) updateUser(c *gin.Context) { return } - claims := getJwtContext(c) - if targetUser.NetworkRating > int(claims.NetworkRating) { res := newAPIV1Failure("cannot update user with higher network rating") writeAPIV1Response(c, http.StatusForbidden, &res) @@ -144,10 +149,38 @@ func (s *Server) createUser(c *gin.Context) { } claims := getJwtContext(c) - - if claims.NetworkRating < fsd.NetworkRatingSupervisor || reqBody.NetworkRating > int(claims.NetworkRating) { + if claims.NetworkRating < fsd.NetworkRatingSupervisor || + reqBody.NetworkRating > int(claims.NetworkRating) { writeAPIV1Response(c, http.StatusForbidden, &genericAPIV1Forbidden) return } + user := &db.User{ + Password: reqBody.Password, + FirstName: reqBody.FirstName, + LastName: reqBody.LastName, + NetworkRating: reqBody.NetworkRating, + } + + if err := s.dbRepo.UserRepo.CreateUser(user); err != nil { + writeAPIV1Response(c, http.StatusInternalServerError, &genericAPIV1InternalServerError) + return + } + + type ResponseBody struct { + CID int `json:"cid"` + FirstName *string `json:"first_name"` + LastName *string `json:"last_name"` + NetworkRating int `json:"network_rating" binding:"min=-1,max=12,required"` + } + + resBody := ResponseBody{ + CID: user.CID, + FirstName: user.FirstName, + LastName: user.LastName, + NetworkRating: user.NetworkRating, + } + + res := newAPIV1Success(&resBody) + writeAPIV1Response(c, http.StatusCreated, &res) }