mirror of
https://github.com/renorris/openfsd
synced 2026-03-22 06:25:35 +08:00
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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -2,3 +2,4 @@
|
||||
.idea
|
||||
.vscode
|
||||
*.db
|
||||
**tmp**
|
||||
|
||||
61
db/config_postgres.go
Normal file
61
db/config_postgres.go
Normal 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
48
db/config_repository.go
Normal 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
57
db/config_sqlite.go
Normal 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
176
db/config_sqlite_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
drop table config;
|
||||
@@ -0,0 +1,8 @@
|
||||
create table config
|
||||
(
|
||||
key varchar not null,
|
||||
value varchar not null
|
||||
);
|
||||
|
||||
create unique index config_key_uindex
|
||||
on config (key);
|
||||
@@ -0,0 +1 @@
|
||||
drop table config;
|
||||
@@ -0,0 +1,8 @@
|
||||
create table config
|
||||
(
|
||||
key text not null,
|
||||
value text not null
|
||||
);
|
||||
|
||||
create unique index config_key_uindex
|
||||
on config (key);
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
47
fsd/conn.go
47
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
|
||||
}
|
||||
|
||||
25
fsd/env.go
Normal file
25
fsd/env.go
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
107
fsd/server.go
107
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) {
|
||||
|
||||
12
fsd/util.go
12
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
|
||||
}
|
||||
|
||||
1
go.mod
1
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
|
||||
|
||||
2
go.sum
2
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=
|
||||
|
||||
43
main.go
43
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")
|
||||
}
|
||||
|
||||
66
web/api_tokens.go
Normal file
66
web/api_tokens.go
Normal 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)
|
||||
}
|
||||
46
web/auth.go
46
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
|
||||
}
|
||||
|
||||
107
web/config.go
Normal file
107
web/config.go
Normal 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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
17
web/main.go
17
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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
189
web/static/js/openfsd/configeditor.js
Normal file
189
web/static/js/openfsd/configeditor.js
Normal 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);
|
||||
@@ -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 = [];
|
||||
|
||||
128
web/static/js/openfsd/usereditor.js
Normal file
128
web/static/js/openfsd/usereditor.js
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
71
web/templates/configeditor.html
Normal file
71
web/templates/configeditor.html
Normal 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 }}
|
||||
@@ -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 }}
|
||||
@@ -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"/>
|
||||
|
||||
284
web/templates/usereditor.html
Normal file
284
web/templates/usereditor.html
Normal 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 }}
|
||||
45
web/user.go
45
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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user