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)
This commit is contained in:
Reese Norris
2025-05-16 22:27:26 -07:00
parent 5cde160fe7
commit 335409c4b4
38 changed files with 1632 additions and 98 deletions

1
.gitignore vendored
View File

@@ -2,3 +2,4 @@
.idea
.vscode
*.db
**tmp**

61
db/config_postgres.go Normal file
View File

@@ -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
}

48
db/config_repository.go Normal file
View File

@@ -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
}

57
db/config_sqlite.go Normal file
View File

@@ -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
}

176
db/config_sqlite_test.go Normal file
View File

@@ -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)
}
}

View File

@@ -0,0 +1 @@
drop table config;

View File

@@ -0,0 +1,8 @@
create table config
(
key varchar not null,
value varchar not null
);
create unique index config_key_uindex
on config (key);

View File

@@ -0,0 +1 @@
drop table config;

View File

@@ -0,0 +1,8 @@
create table config
(
key text not null,
value text not null
);
create unique index config_key_uindex
on config (key);

View File

@@ -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
}

View File

@@ -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:

View File

@@ -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
}

25
fsd/env.go Normal file
View File

@@ -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
}

View File

@@ -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)

View File

@@ -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:

View File

@@ -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
}

View File

@@ -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) {

View File

@@ -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
}

1
go.mod
View File

@@ -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

2
go.sum
View File

@@ -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=

43
main.go
View File

@@ -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")
}

66
web/api_tokens.go Normal file
View File

@@ -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)
}

View File

@@ -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
}

107
web/config.go Normal file
View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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

View File

@@ -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"
}

View File

@@ -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 + ' <div class="d-flex align-items-center"><code class="api-key me-2">' + token + '</code><button class="btn btn-sm btn-outline-secondary copy-btn">Copy</button></div>';
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 = `
<label for="${kv.key}" class="form-label">${label}</label>
<input type="text" class="form-control" id="${kv.key}" value="${kv.value}" data-key="${kv.key}">
`;
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 += `<option value="${key}">${keyLabels[key]}</option>`;
});
div.innerHTML = `
<label class="form-label">New Key</label>
<select class="form-control mb-2" data-type="new-key">
<option value="" disabled selected>Select a key</option>
${options}
</select>
<label class="form-label">Value</label>
<input type="text" class="form-control" placeholder="Value" data-type="new-value">
`;
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);

View File

@@ -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(`
<div class="mb-2"><a href="/usereditor" class="btn btn-primary">Edit Users</a></div>
<div class="mb-2"><a href="/configeditor" class="btn btn-primary">Configure Server</a></div>
`)
}
}
let dashboardMarkers = [];

View File

@@ -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);
});
});

View File

@@ -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)
}

View File

@@ -0,0 +1,71 @@
{{ define "title" }}Config Editor{{ end }}
{{ define "body" }}
<div class="container mt-4" style="max-width: 600px">
<div class="mb-3 card">
<div class="card-body">
<h5 class="card-title">Config Editor</h5>
<form id="config-form">
<!-- Config fields will be dynamically added here -->
</form>
<button type="button" class="btn btn-secondary mt-2" id="add-config">Add New Config</button>
<button type="button" class="btn btn-primary mt-2" id="save-config">Save</button>
</div>
</div>
<div class="card">
<div class="my-2 rounded text-center fs-5">
API Tokens
</div>
<button type="button" class="btn btn-outline-success mx-2 mb-2" id="create-new-api-key">Create</button>
<button type="button" class="btn btn-danger mx-2 mb-2" id="reset-jwt-secret">Reset All</button>
</div>
<!-- Create Token Modal -->
<div class="modal fade" id="createTokenModal" tabindex="-1" aria-labelledby="createTokenModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="createTokenModalLabel">Create New API Token</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<form id="createTokenForm">
<div class="mb-3">
<label for="expiryDate" class="form-label">Expiry Date (YYYY-MM-DD, leave blank for one year from now)</label>
<input type="date" class="form-control" id="expiryDate">
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="submitCreateToken">Create</button>
</div>
</div>
</div>
</div>
<!-- Message Modal -->
<div class="modal fade" id="messageModal" tabindex="-1" aria-labelledby="messageModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="messageModalLabel">Message</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p id="messageText"></p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary" data-bs-dismiss="modal">OK</button>
</div>
</div>
</div>
</div>
</div>
<style>
.api-key {
word-break: break-all;
}
</style>
<script src="/static/js/openfsd/configeditor.js"></script>
{{ end }}

View File

@@ -2,11 +2,18 @@
{{ define "body" }}
<div class="container-fluid d-flex flex-column justify-content-center align-items-center">
<div id="dashboard-real-name">Loading...</div>
<div>Network Rating: <span id="dashboard-network-rating">Loading...</span></div>
<div><span id="dashboard-connection-count"></span> users connected</div>
<div id="map" class="rounded" style="width: 600px; height: 400px;"></div>
<div id="map" class="mb-3 rounded" style="width: 600px; height: 400px;"></div>
<div class="d-flex justify-content-around w-100 mt-auto" style="max-width: 600px">
<div>
<div id="dashboard-real-name">Loading...</div>
<div id="dashboard-cid">Loading...</div>
<div>Network Rating: <span id="dashboard-network-rating">Loading...</span></div>
</div>
<div id="dashboard-user-editor">
</div>
</div>
</div>
<script src="/static/js/openfsd/dashboard.js" defer></script>
{{ end }}
{{ end }}

View File

@@ -17,9 +17,9 @@
<title>openfsd - {{ template "title" . }}</title>
</head>
<body>
<div class="container-fluid p-2 d-flex justify-content-start align-items-center border-bottom border-2">
<div class="container-fluid p-2 d-flex justify-content-start align-items-center border-bottom border-1">
<div>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" id="Layer_1" x="0px" y="0px" width="64px" height="64px" viewBox="0 0 120.135 120.148" style="enable-background:new 0 0 120.135 120.148;" xml:space="preserve">
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" id="Layer_1" x="0px" y="0px" width="32px" height="32px" viewBox="0 0 120.135 120.148" style="enable-background:new 0 0 120.135 120.148;" xml:space="preserve">
<g>
<path d="M105.771,120.147c7.933,0,14.363-6.438,14.363-14.359V14.352C120.134,6.422,113.702,0,105.771,0H14.337 C6.408,0,0,6.422,0,14.352v91.436c0,7.922,6.408,14.359,14.337,14.359H105.771z"/>
<path style="fill:#FFFFFF;" d="M14.337,2.435c-6.564,0-11.908,5.347-11.908,11.917v91.436c0,6.58,5.344,11.926,11.908,11.926 h91.434c6.584,0,11.932-5.346,11.932-11.926V14.352c0-6.57-5.348-11.917-11.932-11.917H14.337z"/>

View File

@@ -0,0 +1,284 @@
{{ define "title" }}User Editor{{ end }}
{{ define "body" }}
<div class="container mt-4">
<div class="row">
<div class="col-12 col-md-4 mb-4">
<div class="card">
<div class="card-body">
<h5 class="card-title">Create User</h5>
<form class="mb-3" id="create-form">
<div class="mb-3">
<label for="create-first-name" class="form-label">First Name</label>
<input type="text" class="form-control" id="create-first-name">
</div>
<div class="mb-3">
<label for="create-last-name" class="form-label">Last Name</label>
<input type="text" class="form-control" id="create-last-name">
</div>
<div class="mb-3">
<label for="create-password" class="form-label">Password</label>
<input type="password" class="form-control" id="create-password" minlength="8" required>
<div class="mt-2">
<label class="form-label">Password Strength</label>
<div class="progress" style="height: 10px;">
<div id="create-password-strength" class="progress-bar" role="progressbar" style="width: 0%;" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100"></div>
</div>
<small id="create-password-feedback" class="form-text text-muted"></small>
</div>
</div>
<div class="mb-3">
<label for="create-network-rating" class="form-label">Network Rating</label>
<select class="form-select" id="create-network-rating" required aria-label="Select network rating">
<option value="-1">Inactive</option>
<option value="0">Suspended</option>
<option value="1" selected>Observer</option>
<option value="2">Student 1</option>
<option value="3">Student 2</option>
<option value="4">Student 3</option>
<option value="5">Controller 1</option>
<option value="6">Controller 2</option>
<option value="7">Controller 3</option>
<option value="8">Instructor 1</option>
<option value="9">Instructor 2</option>
<option value="10">Instructor 3</option>
<option value="11">Supervisor</option>
<option value="12">Administrator</option>
</select>
</div>
<button type="submit" class="btn btn-primary">Create</button>
</form>
<div id="create-success-message" class="alert alert-success d-none" role="alert"></div>
</div>
</div>
</div>
<div class="col-12 col-md-4 mb-4">
<div class="card">
<div class="card-body">
<h5 class="card-title">Search for User by CID</h5>
<form id="search-form">
<div class="mb-3">
<label for="search-cid" class="form-label">CID</label>
<input type="number" class="form-control" id="search-cid" min="1" required>
</div>
<button type="submit" class="btn btn-primary">Search</button>
</form>
</div>
</div>
</div>
<div class="col-12 col-md-4 mb-4">
<div class="card">
<div class="card-body">
<h5 class="card-title">Edit User</h5>
<form class="mb-3" id="edit-form">
<div class="mb-3">
<label for="edit-cid" class="form-label">CID</label>
<input type="text" class="form-control" id="edit-cid" readonly>
</div>
<div class="mb-3">
<label for="edit-first-name" class="form-label">First Name</label>
<input type="text" class="form-control" id="edit-first-name">
</div>
<div class="mb-3">
<label for="edit-last-name" class="form-label">Last Name</label>
<input type="text" class="form-control" id="edit-last-name">
</div>
<div class="mb-3">
<label for="edit-network-rating" class="form-label">Network Rating</label>
<select class="form-select" id="edit-network-rating" aria-label="Select network rating">
<option value="-1">Inactive</option>
<option value="0">Suspended</option>
<option value="1" selected>Observer</option>
<option value="2">Student 1</option>
<option value="3">Student 2</option>
<option value="4">Student 3</option>
<option value="5">Controller 1</option>
<option value="6">Controller 2</option>
<option value="7">Controller 3</option>
<option value="8">Instructor 1</option>
<option value="9">Instructor 2</option>
<option value="10">Instructor 3</option>
<option value="11">Supervisor</option>
<option value="12">Administrator</option>
</select>
</div>
<div class="mb-3">
<label for="edit-password" class="form-label">New Password (leave blank to keep current)</label>
<input type="password" class="form-control" id="edit-password" minlength="8">
<div class="mt-2">
<label class="form-label">Password Strength</label>
<div class="progress" style="height: 10px;">
<div id="edit-password-strength" class="progress-bar" role="progressbar" style="width: 0%;" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100"></div>
</div>
<small id="edit-password-feedback" class="form-text text-muted"></small>
</div>
</div>
<button type="submit" class="btn btn-primary">Update</button>
</form>
<div id="edit-success-message" class="alert alert-success d-none" role="alert"></div>
</div>
</div>
</div>
</div>
</div>
<!-- Modal for error messages only -->
<div class="modal fade" id="messageModal" tabindex="-1" aria-labelledby="messageModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="messageModalLabel">Error</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body" id="messageModalBody"></div>
<div class="modal-footer">
<button type="button" class="btn btn-primary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
<script>
// Function to show modal for errors only
function showModal(message) {
const modal = new bootstrap.Modal(document.getElementById('messageModal'));
const modalBody = document.getElementById('messageModalBody');
modalBody.textContent = message;
modal.show();
}
// 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) {
showModal(res.err);
} else {
const user = res.data;
document.getElementById('edit-cid').value = user.cid;
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';
showModal(errMsg);
console.error('Request failed:', xhr);
}
});
document.getElementById('create-form').addEventListener('submit', async function(event) {
event.preventDefault();
document.getElementById('create-success-message').classList.add('d-none');
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) {
showModal(res.err);
} else {
const successMessage = document.getElementById('create-success-message');
successMessage.textContent = 'User created successfully. CID: ' + res.data.cid;
successMessage.classList.remove('d-none');
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';
showModal(errMsg);
console.error('Request failed:', xhr);
}
});
document.getElementById('edit-form').addEventListener('submit', async function(event) {
event.preventDefault();
document.getElementById('edit-success-message').classList.add('d-none');
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) {
showModal(res.err);
} else {
const successMessage = document.getElementById('edit-success-message');
successMessage.textContent = 'User updated successfully';
successMessage.classList.remove('d-none');
}
} catch (xhr) {
const errMsg = xhr.responseJSON && xhr.responseJSON.err ? xhr.responseJSON.err : 'An error occurred';
showModal(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);
});
});
</script>
{{ end }}

View File

@@ -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)
}