diff --git a/db/config_postgres.go b/db/config_postgres.go index 2b2f229..69b0fb3 100644 --- a/db/config_postgres.go +++ b/db/config_postgres.go @@ -44,6 +44,15 @@ func (p *PostgresConfigRepository) Set(key string, value string) (err error) { return } +func (p *PostgresConfigRepository) SetIfNotExists(key string, value string) (err error) { + querystr := ` + INSERT INTO config (key, value) VALUES ($1, $2) + ON CONFLICT (key) DO NOTHING; + ` + _, 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) { diff --git a/db/config_repository.go b/db/config_repository.go index 0c99537..18f07d2 100644 --- a/db/config_repository.go +++ b/db/config_repository.go @@ -8,12 +8,12 @@ import ( ) 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) + // SetIfNotExists sets a value for a given key if it does not already exist + SetIfNotExists(key string, value string) (err error) + // Get gets a value for a given key. // // Returns ErrConfigKeyNotFound if no key/value pair is found. @@ -21,7 +21,14 @@ type ConfigRepository interface { } const ( - ConfigJwtSecretKey = "JWT_SECRET_KEY" + ConfigJwtSecretKey = "JWT_SECRET_KEY" + + ConfigFsdServerHostname = "FSD_SERVER_HOSTNAME" + ConfigFsdServerIdent = "FSD_SERVER_IDENT" + ConfigFsdServerLocation = "FSD_SERVER_LOCATION" + + ConfigApiServerBaseURL = "API_SERVER_BASE_URL" + ConfigWelcomeMessage = "WELCOME_MESSAGE" ) @@ -46,3 +53,27 @@ func GetWelcomeMessage(r *ConfigRepository) (msg string) { msg, _ = (*r).Get(ConfigWelcomeMessage) return } + +func InitDefaultConfig(r *ConfigRepository) (err error) { + secretKey, err := GenerateJwtSecretKey() + if err != nil { + return + } + + defaultConfig := map[string]string{ + ConfigJwtSecretKey: string(secretKey[:]), + ConfigWelcomeMessage: "Connected to openfsd", + ConfigFsdServerHostname: "localhost", + ConfigFsdServerIdent: "OPENFSD", + ConfigFsdServerLocation: "Earth", + ConfigApiServerBaseURL: "http://localhost", + } + + for k, v := range defaultConfig { + if err = (*r).SetIfNotExists(k, v); err != nil { + return + } + } + + return +} diff --git a/db/config_sqlite.go b/db/config_sqlite.go index 91c4165..b0a199f 100644 --- a/db/config_sqlite.go +++ b/db/config_sqlite.go @@ -9,24 +9,12 @@ 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 - } - +func (s *SQLiteConfigRepository) SetIfNotExists(key string, value string) (err error) { querystr := ` INSERT INTO config (key, value) VALUES (?, ?) ON CONFLICT(key) DO NOTHING; ` - if _, err = s.db.Exec(querystr, ConfigJwtSecretKey, secretKey[:]); err != nil { + if _, err = s.db.Exec(querystr, key, value); err != nil { return } return diff --git a/fsd/client.go b/fsd/client.go index d6a3e3b..1cbce7f 100644 --- a/fsd/client.go +++ b/fsd/client.go @@ -16,13 +16,19 @@ type Client struct { cancelCtx func() sendChan chan string - latLon [2]float64 - visRange float64 + lat, lon, visRange atomic.Float64 - flightPlan *atomic.String - beaconCode *atomic.String + flightPlan atomic.String + assignedBeaconCode atomic.String - facilityType int // ATC facility type. This value is only relevant for ATC + frequency atomic.String // OnlineUserATC frequency + altitude atomic.Int32 // OnlineUserPilot altitude + groundspeed atomic.Int32 // OnlineUserPilot ground speed + transponder atomic.String // Active pilot transponder + heading atomic.Int32 // OnlineUserPilot heading + lastUpdated atomic.Time // Last updated time + + facilityType int // OnlineUserATC facility type. This value is only relevant for OnlineUserATC loginData authState vatsimAuthState @@ -31,14 +37,12 @@ type Client struct { func newClient(ctx context.Context, conn net.Conn, scanner *bufio.Scanner, loginData loginData) (client *Client) { clientCtx, cancel := context.WithCancel(ctx) return &Client{ - conn: conn, - scanner: scanner, - ctx: clientCtx, - cancelCtx: cancel, - sendChan: make(chan string, 32), - flightPlan: &atomic.String{}, - beaconCode: &atomic.String{}, - loginData: loginData, + conn: conn, + scanner: scanner, + ctx: clientCtx, + cancelCtx: cancel, + sendChan: make(chan string, 32), + loginData: loginData, } } @@ -114,3 +118,7 @@ func (s *Server) eventLoop(client *Client) { handler(client, packet) } } + +func (c *Client) latLon() [2]float64 { + return [2]float64{c.lat.Load(), c.lon.Load()} +} diff --git a/fsd/conn.go b/fsd/conn.go index 7bc8833..7718061 100644 --- a/fsd/conn.go +++ b/fsd/conn.go @@ -11,6 +11,7 @@ import ( "net" "strconv" "strings" + "time" ) // sendError sends an FSD error packet to an io.Writer with the specified code and message. @@ -96,13 +97,14 @@ func sendServerIdent(conn io.Writer) (err error) { // loginData holds the data extracted from the Client's login packets. type loginData struct { clientChallenge string // Optional Client challenge for authentication - callsign string // Callsign of the Client (ATC or pilot) + callsign string // Callsign of the Client (OnlineUserATC or pilot) cid int // Cert ID realName string // Real name networkRating NetworkRating // Network rating of the Client protoRevision int // Protocol revision + loginTime time.Time // Time of login clientId uint16 // Client ID - isAtc bool // True if the Client is an ATC, false if a pilot + isAtc bool // True if the Client is an OnlineUserATC, false if a pilot } // ErrInvalidAddPacket is returned when the add packet from the Client is invalid. @@ -171,7 +173,7 @@ func readLoginPackets(conn net.Conn, scanner *bufio.Scanner) (data loginData, to if data.isAtc { if countFields(addPacket) != 7 { err = ErrInvalidAddPacket - sendError(conn, SyntaxError, "Invalid number of fields in ATC add packet") + sendError(conn, SyntaxError, "Invalid number of fields in OnlineUserATC add packet") return } } else { @@ -194,7 +196,7 @@ func readLoginPackets(conn net.Conn, scanner *bufio.Scanner) (data loginData, to data.realName = string(getField(addPacket, 2)) if data.cid, err = strconv.Atoi(string(getField(addPacket, 3))); err != nil { err = ErrInvalidAddPacket - sendError(conn, SyntaxError, "Invalid CID in ATC add packet") + sendError(conn, SyntaxError, "Invalid CID in OnlineUserATC add packet") return } token = string(getField(addPacket, 4)) @@ -207,7 +209,7 @@ func readLoginPackets(conn net.Conn, scanner *bufio.Scanner) (data loginData, to data.networkRating = NetworkRating(networkRating) if data.protoRevision, err = strconv.Atoi(string(getField(addPacket, 6))); err != nil { err = ErrInvalidAddPacket - sendError(conn, SyntaxError, "Invalid protocol revision in ATC add packet") + sendError(conn, SyntaxError, "Invalid protocol revision in OnlineUserATC add packet") return } } else { @@ -238,6 +240,8 @@ func readLoginPackets(conn net.Conn, scanner *bufio.Scanner) (data loginData, to return } + data.loginTime = time.Now() + return } diff --git a/fsd/env.go b/fsd/env.go index 7fd19e1..124cded 100644 --- a/fsd/env.go +++ b/fsd/env.go @@ -14,6 +14,8 @@ type ServerConfig struct { 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 + + ServiceHTTPListenAddr string `env:"SERVICE_HTTP_LISTEN_ADDR, default=:13618"` } func loadServerConfig(ctx context.Context) (config *ServerConfig, err error) { diff --git a/fsd/handler.go b/fsd/handler.go index 870b29f..195a2fb 100644 --- a/fsd/handler.go +++ b/fsd/handler.go @@ -3,8 +3,10 @@ package fsd import ( "bytes" "fmt" + "log/slog" "strconv" "strings" + "time" ) func (s *Server) getHandler(packetType PacketType) handlerFunc { @@ -38,14 +40,19 @@ func (s *Server) getHandler(packetType PacketType) handlerFunc { case PacketTypeFlightPlanAmendment: return s.handleAmendFlightplan default: - return nil + return s.emptyHandler } } +func (s *Server) emptyHandler(client *Client, packet []byte) { + slog.Error("empty handler called") + return +} + func (s *Server) handleTextMessage(client *Client, packet []byte) { recipient := getField(packet, 1) - // ATC chat + // OnlineUserATC chat if string(recipient) == "@49999" { if !client.isAtc { return @@ -122,6 +129,8 @@ func (s *Server) handleATCPosition(client *Client, packet []byte) { // Broadcast position update broadcastRanged(s.postOffice, client, packet) + + client.lastUpdated.Store(time.Now()) } // handlePilotPosition handles logic for 0.2hz `@` pilot position updates @@ -139,6 +148,17 @@ func (s *Server) handlePilotPosition(client *Client, packet []byte) { // Broadcast position update broadcastRanged(s.postOffice, client, packet) + + // Update state + client.transponder.Store(string(getField(packet, 2))) + groundspeed, _ := strconv.Atoi(string(getField(packet, 7))) + client.groundspeed.Store(int32(groundspeed)) + altitude, _ := strconv.Atoi(string(getField(packet, 6))) + client.altitude.Store(int32(altitude)) + pbhUint, _ := strconv.ParseUint(string(getField(packet, 8)), 10, 32) + _, _, heading := pitchBankHeading(pbhUint).vals() + client.heading.Store(int32(heading)) + client.lastUpdated.Store(time.Now()) } // handleFastPilotPosition handles logic for fast `^`, stopped `#ST`, and slow `#SL` pilot position updates @@ -147,7 +167,7 @@ func (s *Server) handleFastPilotPosition(client *Client, packet []byte) { broadcastRangedVelocity(s.postOffice, client, packet) } -// handleDelete handles logic for Delete ATC `#DA` and Delete Pilot `#DP` packets +// handleDelete handles logic for Delete OnlineUserATC `#DA` and Delete OnlineUserPilot `#DP` packets func (s *Server) handleDelete(client *Client, packet []byte) { // Broadcast delete packet broadcastAll(s.postOffice, client, packet) @@ -165,7 +185,7 @@ func (s *Server) handleSquawkbox(client *Client, packet []byte) { // handleProcontroller handles logic for Pro Controller `#PC` packets func (s *Server) handleProcontroller(client *Client, packet []byte) { - // ATC-only packet + // OnlineUserATC-only packet if !client.isAtc { return } @@ -204,7 +224,7 @@ func (s *Server) handleProcontroller(client *Client, packet []byte) { "DP", // Push to departure list "ST": // Set flight strip - // Only active ATC above OBS + // Only active OnlineUserATC above OBS if client.facilityType <= 0 { client.sendError(InvalidControlError, "Invalid control") return @@ -224,7 +244,7 @@ func (s *Server) handleClientQuery(client *Client, packet []byte) { // Handle queries sent to SERVER if string(recipient) == "SERVER" { switch string(queryType) { - case "ATC": + case "OnlineUserATC": s.handleClientQueryATCRequest(client, packet) case "IP": s.handleClientQueryIPRequest(client, packet) @@ -238,7 +258,7 @@ func (s *Server) handleClientQuery(client *Client, packet []byte) { if bytes.HasPrefix(recipient, []byte("@")) { switch string(queryType) { - // Unprivileged ATC queries + // Unprivileged OnlineUserATC queries case "BY", // Request relief "HI", // Cancel request relief @@ -248,14 +268,14 @@ func (s *Server) handleClientQuery(client *Client, packet []byte) { "NEWATIS", // Broadcast new ATIS letter "NEWINFO": // Broadcast new ATIS info - // ATC only + // OnlineUserATC only if !client.isAtc { client.sendError(InvalidControlError, "Invalid control") return } broadcastRangedAtcOnly(s.postOffice, client, packet) - // Privileged ATC queries + // Privileged OnlineUserATC queries case "IT", // Initiate track "DR", // Drop track @@ -268,7 +288,7 @@ func (s *Server) handleClientQuery(client *Client, packet []byte) { "EST", // Set estimate time "GD": // Set global data - // ATC above OBS facility only + // OnlineUserATC above OBS facility only if !client.isAtc || client.facilityType <= 0 { client.sendError(InvalidControlError, "Invalid control") return @@ -316,7 +336,7 @@ func (s *Server) handleClientQuery(client *Client, packet []byte) { func (s *Server) handleClientQueryATCRequest(client *Client, packet []byte) { if countFields(packet) != 4 { - client.sendError(SyntaxError, "Invalid ATC request") + client.sendError(SyntaxError, "Invalid OnlineUserATC request") return } @@ -329,9 +349,9 @@ func (s *Server) handleClientQueryATCRequest(client *Client, packet []byte) { var p string if targetClient.facilityType > 0 { - p = fmt.Sprintf("$CRSERVER:%s:ATC:Y:%s\r\n", client.callsign, targetCallsign) + p = fmt.Sprintf("$CRSERVER:%s:OnlineUserATC:Y:%s\r\n", client.callsign, targetCallsign) } else { - p = fmt.Sprintf("$CRSERVER:%s:ATC:N:%s\r\n", client.callsign, targetCallsign) + p = fmt.Sprintf("$CRSERVER:%s:OnlineUserATC:N:%s\r\n", client.callsign, targetCallsign) } client.send(p) } @@ -365,7 +385,7 @@ func (s *Server) handleClientQueryFlightplanRequest(client *Client, packet []byt return } - beaconCode := targetClient.beaconCode.Load() + beaconCode := targetClient.assignedBeaconCode.Load() if beaconCode == "" { beaconCode = "0" } @@ -431,7 +451,7 @@ func (s *Server) handleAuthChallenge(client *Client, packet []byte) { } func (s *Server) handleHandoff(client *Client, packet []byte) { - // Active >OBS ATC only + // Active >OBS OnlineUserATC only if !client.isAtc || client.facilityType <= 1 { return } diff --git a/fsd/http_service.go b/fsd/http_service.go new file mode 100644 index 0000000..f54dbac --- /dev/null +++ b/fsd/http_service.go @@ -0,0 +1,145 @@ +package fsd + +import ( + "context" + "encoding/json" + "github.com/gin-gonic/gin" + "github.com/renorris/openfsd/db" + "log/slog" + "maps" + "net/http" + "strings" + "time" +) + +// runServiceHTTP starts the admin service HTTP server used for +// internal communication between the API HTTP server and this FSD server. +func (s *Server) runServiceHTTP(ctx context.Context) { + e := s.setupRoutes() + if err := e.Run(s.cfg.ServiceHTTPListenAddr); err != nil { + slog.Error(err.Error()) + } +} + +func (s *Server) setupRoutes() (e *gin.Engine) { + e = gin.New() + + // Verify administrator service JWT + e.Use(s.authMiddleware) + e.GET("/online_users", s.handleGetOnlineUsers) + + return +} + +func (s *Server) authMiddleware(c *gin.Context) { + authHeader, found := strings.CutPrefix(c.GetHeader("Authorization"), "Bearer ") + if !found { + c.AbortWithStatus(http.StatusBadRequest) + return + } + + jwtSecret, err := s.dbRepo.ConfigRepo.Get(db.ConfigJwtSecretKey) + if err != nil { + slog.Error(err.Error()) + c.AbortWithStatus(http.StatusInternalServerError) + return + } + + accessToken, err := ParseJwtToken(authHeader, []byte(jwtSecret)) + if err != nil { + c.AbortWithStatus(http.StatusBadRequest) + return + } + + claims := accessToken.CustomClaims() + if claims.TokenType != "fsd_service" || claims.NetworkRating < NetworkRatingAdministator { + c.AbortWithStatus(http.StatusForbidden) + return + } + + c.Next() +} + +type OnlineUserGeneralData struct { + Callsign string `json:"callsign"` + CID int `json:"cid"` + Name string `json:"name"` + NetworkRating int `json:"network_rating"` + Latitude float64 `json:"latitude"` + Longitude float64 `json:"longitude"` + LogonTime time.Time `json:"logon_time"` + LastUpdated time.Time `json:"last_updated"` +} + +type OnlineUserPilot struct { + OnlineUserGeneralData + Altitude int `json:"altitude"` + Groundspeed int `json:"groundspeed"` + Heading int `json:"heading"` + Transponder string `json:"transponder"` +} + +type OnlineUserATC struct { + OnlineUserGeneralData + Frequency string `json:"frequency"` + Facility int `json:"facility"` + VisRange int `json:"visual_range"` +} + +type OnlineUsersResponseData struct { + Pilots []OnlineUserPilot `json:"pilots"` + ATC []OnlineUserATC `json:"atc"` +} + +func (s *Server) handleGetOnlineUsers(c *gin.Context) { + s.postOffice.clientMapLock.RLock() + mapLen := len(s.postOffice.clientMap) + s.postOffice.clientMapLock.RUnlock() + + clientMap := make(map[string]*Client, mapLen+16) + + s.postOffice.clientMapLock.RLock() + maps.Copy(clientMap, s.postOffice.clientMap) + s.postOffice.clientMapLock.RUnlock() + + resData := OnlineUsersResponseData{ + Pilots: make([]OnlineUserPilot, 0, 512), + ATC: make([]OnlineUserATC, 0, 128), + } + + for _, client := range clientMap { + genData := OnlineUserGeneralData{ + Callsign: client.callsign, + CID: client.cid, + Name: client.realName, + NetworkRating: int(client.networkRating), + Latitude: client.lat.Load(), + Longitude: client.lon.Load(), + LogonTime: client.loginTime, + LastUpdated: client.lastUpdated.Load(), + } + + if client.isAtc { + atc := OnlineUserATC{ + OnlineUserGeneralData: genData, + Frequency: client.frequency.Load(), + Facility: client.facilityType, + VisRange: int(client.visRange.Load()), + } + resData.ATC = append(resData.ATC, atc) + } else { + pilot := OnlineUserPilot{ + OnlineUserGeneralData: genData, + Altitude: int(client.altitude.Load()), + Groundspeed: int(client.groundspeed.Load()), + Heading: int(client.heading.Load()), + Transponder: client.transponder.Load(), + } + resData.Pilots = append(resData.Pilots, pilot) + } + } + + c.Writer.Header().Set("Content-Type", "application/json") + c.Writer.WriteHeader(http.StatusOK) + json.NewEncoder(c.Writer).Encode(&resData) +} diff --git a/fsd/postoffice.go b/fsd/postoffice.go index 8887355..3e0e77b 100644 --- a/fsd/postoffice.go +++ b/fsd/postoffice.go @@ -39,7 +39,7 @@ func (p *postOffice) register(client *Client) (err error) { p.clientMapLock.Unlock() // Insert into R-tree - clientMin, clientMax := calculateBoundingBox(client.latLon, client.visRange) + clientMin, clientMax := calculateBoundingBox(client.latLon(), client.visRange.Load()) p.treeLock.Lock() p.tree.Insert(clientMin, clientMax, client) p.treeLock.Unlock() @@ -49,7 +49,7 @@ func (p *postOffice) register(client *Client) (err error) { // release removes a Client from the post office. func (p *postOffice) release(client *Client) { - clientMin, clientMax := calculateBoundingBox(client.latLon, client.visRange) + clientMin, clientMax := calculateBoundingBox(client.latLon(), client.visRange.Load()) p.treeLock.Lock() p.tree.Delete(clientMin, clientMax, client) @@ -65,11 +65,12 @@ func (p *postOffice) release(client *Client) { // updatePosition updates the geospatial position of a Client. // The referenced client's latLon and visRange are rewritten. func (p *postOffice) updatePosition(client *Client, newCenter [2]float64, newVisRange float64) { - oldMin, oldMax := calculateBoundingBox(client.latLon, client.visRange) + oldMin, oldMax := calculateBoundingBox(client.latLon(), client.visRange.Load()) newMin, newMax := calculateBoundingBox(newCenter, newVisRange) - client.latLon = newCenter - client.visRange = newVisRange + client.lat.Store(newCenter[0]) + client.lon.Store(newCenter[1]) + client.visRange.Store(newVisRange) // Avoid redundant updates if oldMin == newMin && oldMax == newMax { @@ -86,7 +87,7 @@ func (p *postOffice) updatePosition(client *Client, newCenter [2]float64, newVis // search calls `callback` for every other Client within geographical range of the provided Client func (p *postOffice) search(client *Client, callback func(recipient *Client) bool) { - clientMin, clientMax := calculateBoundingBox(client.latLon, client.visRange) + clientMin, clientMax := calculateBoundingBox(client.latLon(), client.visRange.Load()) p.treeLock.RLock() p.tree.Search(clientMin, clientMax, func(foundMin [2]float64, foundMax [2]float64, foundClient *Client) bool { diff --git a/fsd/postoffice_test.go b/fsd/postoffice_test.go index 67b0e5b..cce916a 100644 --- a/fsd/postoffice_test.go +++ b/fsd/postoffice_test.go @@ -254,7 +254,7 @@ func approxEqual(a, b float64) bool { // BenchmarkDistance measures the performance of the distance function using pre-generated pseudo-random coordinates. func BenchmarkDistance(b *testing.B) { - const numPairs = 1000 + const numPairs = 1024 * 64 lats1 := make([]float64, numPairs) lons1 := make([]float64, numPairs) lats2 := make([]float64, numPairs) diff --git a/fsd/server.go b/fsd/server.go index 89939de..281641a 100644 --- a/fsd/server.go +++ b/fsd/server.go @@ -15,7 +15,7 @@ import ( ) type Server struct { - listenAddrs []string + cfg *ServerConfig postOffice *postOffice metarService *metarService dbRepo *db.Repositories @@ -24,9 +24,9 @@ type Server struct { // 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) { +func NewServer(cfg *ServerConfig, dbRepo *db.Repositories, numMetarWorkers int) (server *Server, err error) { server = &Server{ - listenAddrs: listenAddrs, + cfg: cfg, postOffice: newPostOffice(), metarService: newMetarService(numMetarWorkers), dbRepo: dbRepo, @@ -92,12 +92,12 @@ func NewDefaultServer(ctx context.Context) (server *Server, err error) { // Ensure default configuration is written to persistent storage slog.Debug("initializing default config") - if err = dbRepo.ConfigRepo.InitDefault(); err != nil { + if err = db.InitDefaultConfig(&dbRepo.ConfigRepo); err != nil { return } slog.Debug("config OK") - if server, err = NewServer(config.FsdListenAddrs, dbRepo, config.NumMetarWorkers); err != nil { + if server, err = NewServer(config, dbRepo, config.NumMetarWorkers); err != nil { return } @@ -128,10 +128,13 @@ func (s *Server) Run(ctx context.Context) (err error) { // Start metar service go s.metarService.run(ctx) - errCh := make(chan error, len(s.listenAddrs)) + // Start HTTP service + go s.runServiceHTTP(ctx) + + errCh := make(chan error, len(s.cfg.FsdListenAddrs)) var listenerWg sync.WaitGroup - for _, addr := range s.listenAddrs { + for _, addr := range s.cfg.FsdListenAddrs { slog.Info(fmt.Sprintf("Listening on %s\n", addr)) listenerWg.Add(1) go func(ctx context.Context, addr string) { diff --git a/fsd/util.go b/fsd/util.go index 884d625..76ca9a3 100644 --- a/fsd/util.go +++ b/fsd/util.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/base64" "encoding/json" + "errors" "slices" "strconv" "strings" @@ -228,7 +229,7 @@ func broadcastRangedVelocity(po *postOffice, client *Client, packet []byte) { }) } -// broadcastRangedAtcOnly broadcasts a packet to all ATC clients in range +// broadcastRangedAtcOnly broadcasts a packet to all OnlineUserATC clients in range func broadcastRangedAtcOnly(po *postOffice, client *Client, packet []byte) { packetStr := string(packet) po.search(client, func(recipient *Client) bool { @@ -249,7 +250,7 @@ func broadcastAll(po *postOffice, client *Client, packet []byte) { }) } -// broadcastAllATC broadcasts a packet to all ATC on entire server +// broadcastAllATC broadcasts a packet to all OnlineUserATC on entire server func broadcastAllATC(po *postOffice, client *Client, packet []byte) { packetStr := string(packet) po.all(client, func(recipient *Client) bool { @@ -353,6 +354,31 @@ func buildBeaconCodePacket(source, recipient, targetCallsign, beaconCode string) return builder.String() } +type pitchBankHeading uint32 + +const maxPbhValue = 0b1111111111 + +func newPitchBankHeading(pitch uint32, bank uint32, heading uint32) (pbh pitchBankHeading, err error) { + if pitch > maxPbhValue || bank > maxPbhValue || heading > maxPbhValue { + err = errors.New("out of range") + return + } + + pbh = (pbh | pitchBankHeading(pitch)) << 10 + pbh = (pbh | pitchBankHeading(bank)) << 10 + pbh = (pbh | pitchBankHeading(heading)) << 2 + + return +} + +func (pbh pitchBankHeading) vals() (pitch uint32, bank uint32, heading uint32) { + pitch = uint32(pbh>>22) & maxPbhValue + bank = uint32(pbh>>12) & maxPbhValue + heading = uint32(pbh>>2) & maxPbhValue + + return +} + func strPtr(str string) *string { return &str } diff --git a/main.go b/main.go index 70f7498..06c2902 100644 --- a/main.go +++ b/main.go @@ -2,7 +2,6 @@ package main import ( "context" - "fmt" "github.com/renorris/openfsd/fsd" "log/slog" _ "modernc.org/sqlite" @@ -11,17 +10,19 @@ import ( ) func main() { - fmt.Println("hello world") - setSlogLevel() + ctx, _ := signal.NotifyContext(context.Background(), os.Interrupt) + os.Setenv("DATABASE_AUTO_MIGRATE", "true") - server, err := fsd.NewDefaultServer(context.Background()) + os.Setenv("DATABASE_DRIVER", "sqlite") + os.Setenv("DATABASE_SOURCE_NAME", "test.db") + + server, err := fsd.NewDefaultServer(ctx) if err != nil { panic(err) } - ctx, _ := signal.NotifyContext(context.Background(), os.Interrupt) if err = server.Run(ctx); err != nil { slog.Error(err.Error()) } diff --git a/web/config.go b/web/config.go index 4c8e962..db7750d 100644 --- a/web/config.go +++ b/web/config.go @@ -22,6 +22,10 @@ func (s *Server) handleGetConfig(c *gin.Context) { var configKeys = []string{ db.ConfigWelcomeMessage, + db.ConfigFsdServerHostname, + db.ConfigFsdServerIdent, + db.ConfigFsdServerLocation, + db.ConfigApiServerBaseURL, } type ResponseBody struct { diff --git a/web/data.go b/web/data.go new file mode 100644 index 0000000..a08076c --- /dev/null +++ b/web/data.go @@ -0,0 +1,355 @@ +package main + +import ( + "bytes" + "context" + _ "embed" + "encoding/json" + "errors" + "github.com/gin-gonic/gin" + "github.com/renorris/openfsd/db" + "github.com/renorris/openfsd/fsd" + "go.uber.org/atomic" + "log/slog" + "net/http" + "strings" + "text/template" + "time" +) + +//go:embed data_templates/status.txt +var statusTxtRawTemplate string +var statusTxtTemplate *template.Template + +//go:embed data_templates/servers.txt +var serversTxtRawTemplate string +var serversTxtTemplate *template.Template + +func init() { + var err error + statusTxtTemplate = template.New("statustxt") + if statusTxtTemplate, err = statusTxtTemplate.Parse(statusTxtRawTemplate); err != nil { + panic("Unable to parse status.txt template: " + err.Error()) + } + + serversTxtTemplate = template.New("serverstxt") + if serversTxtTemplate, err = serversTxtTemplate.Parse(serversTxtRawTemplate); err != nil { + panic("Unable to parse servers.txt template: " + err.Error()) + } +} + +func (s *Server) handleGetStatusTxt(c *gin.Context) { + baseURL, ok := s.getBaseURLOrErr(c) + if !ok { + return + } + + // Generate a new status.txt + statusTxt, err := generateStatusTxt(baseURL) + if err != nil { + c.Writer.WriteHeader(http.StatusInternalServerError) + c.Writer.WriteString("Error generating status.txt") + slog.Error(err.Error()) + return + } + + c.Writer.WriteHeader(http.StatusOK) + c.Writer.WriteString(statusTxt) +} + +func generateStatusTxt(baseURL string) (txt string, err error) { + type TemplateData struct { + ApiServerBaseURL string + } + + tmplData := TemplateData{ApiServerBaseURL: baseURL} + + buf := bytes.Buffer{} + buf.Grow(1024) + if err = statusTxtTemplate.Execute(&buf, &tmplData); err != nil { + return + } + + // Ensure all newlines have a carriage return + txt = strings.ReplaceAll(buf.String(), "\n", "\r\n") + return +} + +type DataJsonStatus struct { + Data map[string][]string `json:"data"` +} + +func (s *Server) handleGetStatusJSON(c *gin.Context) { + baseURL, ok := s.getBaseURLOrErr(c) + if !ok { + return + } + + statusJson := DataJsonStatus{ + Data: map[string][]string{ + "v3": { + baseURL + "/api/v1/data/openfsd-data.json", + }, + "servers": { + baseURL + "/api/v1/data/openfsd-servers.json", + }, + "servers_sweatbox": { + baseURL + "/api/v1/data/sweatbox-servers.json", + }, + "servers_all": { + baseURL + "/api/v1/data/all-servers.json", + }, + }, + } + + res, err := json.Marshal(&statusJson) + if err != nil { + slog.Error(err.Error()) + writePlaintext500Error(c, "Unable to marshal JSON") + return + } + + c.Writer.Header().Set("Content-Type", "application/json") + c.Writer.WriteHeader(http.StatusOK) + c.Writer.Write(res) +} + +type DataJsonServer struct { + Ident string `json:"ident"` + HostnameOrIp string `json:"hostname_or_ip"` + Location string `json:"location"` + Name string `json:"name"` + ClientsConnectionAllowed int `json:"clients_connection_allowed"` + ClientConnectionsAllowed bool `json:"client_connections_allowed"` + IsSweatbox bool `json:"is_sweatbox"` +} + +func (s *Server) handleGetServersJSON(c *gin.Context) { + serverIdent, serverHostname, serverLocation, err := s.getFsdServerInfo() + if err != nil { + writePlaintext500Error(c, "Unable to load FSD server info from configuration") + return + } + + _, isSweatbox := c.Get("is_sweatbox") + + type ServersJson []DataJsonServer + dataJson := ServersJson{ + { + Ident: serverIdent, + HostnameOrIp: serverHostname, + Location: serverLocation, + Name: serverIdent, + ClientConnectionsAllowed: true, + ClientsConnectionAllowed: 99, + IsSweatbox: isSweatbox, + }, + } + + res, err := json.Marshal(&dataJson) + if err != nil { + slog.Error(err.Error()) + writePlaintext500Error(c, "Unable to marshal JSON") + return + } + + c.Writer.Header().Set("Content-Type", "application/json") + c.Writer.WriteHeader(http.StatusOK) + c.Writer.Write(res) +} + +func (s *Server) handleGetServersTxt(c *gin.Context) { + serversTxt, err := s.generateServersTxt() + if err != nil { + c.Writer.WriteHeader(http.StatusInternalServerError) + c.Writer.WriteString("Error generating status.txt") + slog.Error(err.Error()) + return + } + + c.Writer.WriteHeader(http.StatusOK) + c.Writer.WriteString(serversTxt) +} + +func (s *Server) generateServersTxt() (txt string, err error) { + serverIdent, serverHostname, serverLocation, err := s.getFsdServerInfo() + if err != nil { + slog.Error(err.Error()) + return + } + + type TemplateData []DataJsonServer + tmplData := TemplateData{ + { + Ident: serverIdent, + HostnameOrIp: serverHostname, + Location: serverLocation, + Name: serverIdent, + ClientConnectionsAllowed: true, + ClientsConnectionAllowed: 99, + IsSweatbox: false, + }, + } + + buf := bytes.Buffer{} + buf.Grow(1024) + if err = serversTxtTemplate.Execute(&buf, &tmplData); err != nil { + return + } + + // Ensure all newlines have a carriage return + txt = strings.ReplaceAll(buf.String(), "\n", "\r\n") + return +} + +func (s *Server) getFsdServerInfo() (serverIdent string, serverHostname string, serverLocation string, err error) { + serverIdent, err = s.dbRepo.ConfigRepo.Get(db.ConfigFsdServerIdent) + if err != nil { + slog.Error(err.Error()) + return + } + + serverHostname, err = s.dbRepo.ConfigRepo.Get(db.ConfigFsdServerHostname) + if err != nil { + slog.Error(err.Error()) + return + } + + serverLocation, err = s.dbRepo.ConfigRepo.Get(db.ConfigFsdServerLocation) + if err != nil { + slog.Error(err.Error()) + return + } + + return +} + +func writePlaintext500Error(c *gin.Context, msg string) { + c.Writer.Header().Set("Content-Type", "text/plain") + c.Writer.WriteHeader(http.StatusInternalServerError) + c.Writer.WriteString(msg) +} + +func (s *Server) getBaseURLOrErr(c *gin.Context) (baseURL string, ok bool) { + baseURL, err := s.dbRepo.ConfigRepo.Get(db.ConfigApiServerBaseURL) + if err != nil { + c.Writer.WriteHeader(http.StatusInternalServerError) + if !errors.Is(err, db.ErrConfigKeyNotFound) { + slog.Error(err.Error()) + return + } + errMsg := "API server base URL is not set in the config" + slog.Error(errMsg) + c.Writer.WriteString(errMsg) + return + } + + ok = true + return +} + +type Datafeed struct { + Pilots []fsd.OnlineUserPilot `json:"pilots"` + ATC []fsd.OnlineUserATC `json:"atc"` +} + +type DatafeedCache struct { + jsonStr string + lastUpdated time.Time +} + +var datafeedCache atomic.Pointer[DatafeedCache] + +func (s *Server) getDatafeed(c *gin.Context) { + feed := datafeedCache.Load() + if feed == nil { + c.AbortWithStatus(http.StatusInternalServerError) + return + } + c.Writer.Header().Set("Content-Type", "application/json") + c.Writer.WriteHeader(http.StatusOK) + c.Writer.WriteString(feed.jsonStr) +} + +func (s *Server) generateDatafeed() (feed *DatafeedCache, err error) { + // Generate JWT bearer token + customFields := fsd.CustomFields{ + TokenType: "fsd_service", + CID: -1, + NetworkRating: fsd.NetworkRatingAdministator, + } + token, err := fsd.MakeJwtToken(&customFields, 15*time.Minute) + if err != nil { + return + } + secretKey, err := s.dbRepo.ConfigRepo.Get(db.ConfigJwtSecretKey) + if err != nil { + return + } + tokenStr, err := token.SignedString([]byte(secretKey)) + if err != nil { + return + } + + client := http.Client{} + req, err := http.NewRequest("GET", s.cfg.FsdHttpServiceAddress+"/online_users", nil) + if err != nil { + return + } + + req.Header.Set("Authorization", "Bearer "+tokenStr) + res, err := client.Do(req) + if err != nil { + return + } + + if res.StatusCode != http.StatusOK { + err = errors.New("FSD HTTP service returned a non-200 status code") + return + } + + decoder := json.NewDecoder(res.Body) + onlineUsers := fsd.OnlineUsersResponseData{} + if err = decoder.Decode(&onlineUsers); err != nil { + return + } + + dataFeed := Datafeed{ + Pilots: onlineUsers.Pilots, + ATC: onlineUsers.ATC, + } + buf := bytes.Buffer{} + encoder := json.NewEncoder(&buf) + if err = encoder.Encode(&dataFeed); err != nil { + return + } + + feed = &DatafeedCache{ + jsonStr: buf.String(), + lastUpdated: time.Now(), + } + return +} + +func (s *Server) runDatafeedWorker(ctx context.Context) { + s.updateDataFeedCache() + ticker := time.NewTicker(15 * time.Second) + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + s.updateDataFeedCache() + } + } +} + +func (s *Server) updateDataFeedCache() { + feed, err := s.generateDatafeed() + if err != nil { + slog.Error(err.Error()) + return + } + datafeedCache.Store(feed) +} diff --git a/web/data_templates/servers.txt b/web/data_templates/servers.txt new file mode 100644 index 0000000..e4e7661 --- /dev/null +++ b/web/data_templates/servers.txt @@ -0,0 +1,12 @@ +!GENERAL: +VERSION = 8 +RELOAD = 2 +UPDATE = 20220401021210 +ATIS ALLOW MIN = 5 +CONNECTED CLIENTS = 1 +; +; +!SERVERS: +{{ range . }}{{ .Ident }}:{{ .HostnameOrIp }}:{{ .Location }}:{{ .Ident }}:{{ .ClientsConnectionAllowed }}:{{ end }} +; +; END diff --git a/web/data_templates/status.txt b/web/data_templates/status.txt new file mode 100644 index 0000000..6bc37ce --- /dev/null +++ b/web/data_templates/status.txt @@ -0,0 +1,23 @@ +; IMPORTANT NOTE: This file can change as data sources change. Please check at regular intervals. +; +; PEOPLE UTILISING THIS FEED ARE STRONGLY ENCOURAGED TO MIGRATE TO {{ .ApiServerBaseURL }}/api/v1/data/status.json +; +; Data formats are: +; +; 120128:NOTCP - used by WhazzUp only +; json3 - JSON Data Version 3 +; url1 - URLs where servers list data files are available. Please choose one randomly every time +; +; +120218:NOTCP +; +json3={{ .ApiServerBaseURL }}/api/v1/data/openfsd-data.json +; +url1={{ .ApiServerBaseURL }}/api/v1/data/openfsd-servers.txt +; +servers.live={{ .ApiServerBaseURL }}/api/v1/data/openfsd-servers.txt +; +voice0=afv +; +; END + \ No newline at end of file diff --git a/web/env.go b/web/env.go new file mode 100644 index 0000000..86dab0a --- /dev/null +++ b/web/env.go @@ -0,0 +1,25 @@ +package main + +import ( + "context" + "github.com/sethvargo/go-envconfig" +) + +type ServerConfig struct { + ListenAddr string `env:"LISTEN_ADDR, default=:8000"` // HTTP listen address + + 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=1"` // Max number of database connections + + FsdHttpServiceAddress string `env:"FSD_HTTP_SERVICE_ADDRESS, required"` // HTTP address to talk to the FSD http service +} + +func loadServerConfig(ctx context.Context) (config *ServerConfig, err error) { + config = &ServerConfig{} + if err = envconfig.Process(ctx, config); err != nil { + return + } + return +} diff --git a/web/main.go b/web/main.go index 3d5cd43..63fa28f 100644 --- a/web/main.go +++ b/web/main.go @@ -2,51 +2,21 @@ package main import ( "context" - "database/sql" - "github.com/renorris/openfsd/db" + "os" + "os/signal" ) func main() { - sqlDb, err := sql.Open("sqlite", ":memory:") + ctx, _ := signal.NotifyContext(context.Background(), os.Interrupt) + + os.Setenv("DATABASE_DRIVER", "sqlite") + os.Setenv("DATABASE_SOURCE_NAME", "../test.db") + os.Setenv("FSD_HTTP_SERVICE_ADDRESS", "http://localhost:13618") + + server, err := NewDefaultServer(ctx) if err != nil { panic(err) } - if err = db.Migrate(sqlDb); err != nil { - panic(err) - } - - dbRepo, err := db.NewRepositories(sqlDb) - if err != nil { - 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) - } - - 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) - } - - server.Run(context.Background(), "0.0.0.0:8080") + server.Run(ctx) } diff --git a/web/routes.go b/web/routes.go index 7bbb7cd..1d4e7bf 100644 --- a/web/routes.go +++ b/web/routes.go @@ -23,6 +23,7 @@ func (s *Server) setupRoutes() (e *gin.Engine) { s.setupAuthRoutes(apiV1Group) s.setupUserRoutes(apiV1Group) s.setupConfigRoutes(apiV1Group) + s.setupDataRoutes(apiV1Group) // Frontend groups s.setupFrontendRoutes(e.Group("")) @@ -56,6 +57,20 @@ func (s *Server) setupConfigRoutes(parent *gin.RouterGroup) { configGroup.POST("/createtoken", s.handleCreateNewAPIToken) } +func (s *Server) setupDataRoutes(parent *gin.RouterGroup) { + dataGroup := parent.Group("/data") + dataGroup.GET("/status.txt", s.handleGetStatusTxt) + dataGroup.GET("/status.json", s.handleGetStatusJSON) + dataGroup.GET("/openfsd-servers.txt", s.handleGetServersTxt) + dataGroup.GET("/openfsd-servers.json", s.handleGetServersJSON) + dataGroup.GET("/sweatbox-servers.json", func(c *gin.Context) { + c.Set("is_sweatbox", "true") + s.handleGetServersJSON(c) + }) + dataGroup.GET("/all-servers.json", s.handleGetServersJSON) + dataGroup.GET("/openfsd-data.json", s.getDatafeed) +} + func (s *Server) setupFrontendRoutes(parent *gin.RouterGroup) { frontendGroup := parent.Group("") frontendGroup.GET("", s.handleFrontendLanding) diff --git a/web/server.go b/web/server.go index 423a26f..ce88c76 100644 --- a/web/server.go +++ b/web/server.go @@ -2,24 +2,73 @@ package main import ( "context" + "database/sql" + "fmt" "github.com/renorris/openfsd/db" + "log/slog" + "net" ) type Server struct { + cfg *ServerConfig dbRepo *db.Repositories } -func NewServer(dbRepo *db.Repositories) (server *Server, err error) { +func NewDefaultServer(ctx context.Context) (server *Server, err error) { + cfg, err := loadServerConfig(ctx) + if err != nil { + return + } + + slog.Info(fmt.Sprintf("using %s", cfg.DatabaseDriver)) + + slog.Debug("connecting to SQL") + sqlDb, err := sql.Open(cfg.DatabaseDriver, cfg.DatabaseSourceName) + if err != nil { + return + } + slog.Debug("SQL OK") + + sqlDb.SetMaxOpenConns(cfg.DatabaseMaxConns) + + dbRepo, err := db.NewRepositories(sqlDb) + if err != nil { + return + } + + if server, err = NewServer(cfg, dbRepo); err != nil { + return + } + + return +} + +func NewServer(cfg *ServerConfig, dbRepo *db.Repositories) (server *Server, err error) { server = &Server{ + cfg: cfg, dbRepo: dbRepo, } return } -func (s *Server) Run(ctx context.Context, addr string) (err error) { +func (s *Server) Run(ctx context.Context) (err error) { e := s.setupRoutes() - e.Run(addr) + go s.runDatafeedWorker(ctx) + listener, err := net.Listen("tcp", s.cfg.ListenAddr) + if err != nil { + return + } + defer listener.Close() + + go func() { + if err := e.RunListener(listener); err != nil { + slog.Error(err.Error()) + } + }() + + <-ctx.Done() + return } diff --git a/web/static/js/openfsd/api.js b/web/static/js/openfsd/api.js index a1b5df0..6113c91 100644 --- a/web/static/js/openfsd/api.js +++ b/web/static/js/openfsd/api.js @@ -23,7 +23,6 @@ async function doAPIRequest(method, url, withAuth, data) { }).done((res) => { resolve(res) }).fail((xhr) => { - logout() reject(xhr) }); }); diff --git a/web/static/js/openfsd/configeditor.js b/web/static/js/openfsd/configeditor.js index c6a0997..9d37f57 100644 --- a/web/static/js/openfsd/configeditor.js +++ b/web/static/js/openfsd/configeditor.js @@ -1,5 +1,34 @@ const keyLabels = { - "WELCOME_MESSAGE": "Welcome Message", + "WELCOME_MESSAGE": { + "name": "Welcome Message", + "description": "Welcome message sent to FSD clients after they connect", + "type": "text", + "placeholder": "Welcome to my FSD server!" + }, + "FSD_SERVER_HOSTNAME": { + "name": "FSD Server Hostname", + "description": "Server hostname advertised to clients", + "type": "text", + "placeholder": "myfsdserver.com" + }, + "FSD_SERVER_IDENT": { + "name": "FSD Server Ident", + "description": "Server ident advertised to clients", + "type": "text", + "placeholder": "MY-FSD-SERVER" + }, + "FSD_SERVER_LOCATION": { + "name": "FSD Server Location", + "description": "Geographical server location advertised to clients", + "type": "text", + "placeholder": "East US", + }, + "API_SERVER_BASE_URL": { + "name": "API Server Base URL", + "description": "API server base URL advertised to clients", + "type": "text", + "placeholder": "https://example.com" + }, }; // Function to show message modal @@ -35,12 +64,14 @@ async function loadConfig() { 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 label = keyLabels[kv.key].name || kv.key; + const desc = keyLabels[kv.key].description || kv.key const div = document.createElement('div'); div.className = 'mb-3'; div.innerHTML = ` - + +
${desc}
`; configForm.appendChild(div); }); @@ -58,7 +89,7 @@ document.getElementById('add-config').addEventListener('click', function() { // Create dropdown options from keyLabels let options = ''; Object.keys(keyLabels).forEach(key => { - options += ``; + options += ``; }); div.innerHTML = ` @@ -68,8 +99,31 @@ document.getElementById('add-config').addEventListener('click', function() { +
`; configForm.appendChild(div); + + // Add event listener to update input type based on selected key + const select = div.querySelector('select[data-type="new-key"]'); + const valueInput = div.querySelector('input[data-type="new-value"]'); + select.addEventListener('change', function() { + const selectedKey = select.value; + if (selectedKey && keyLabels[selectedKey]) { + const inputType = keyLabels[selectedKey].type; + if (inputType === 'checkbox') { + valueInput.type = 'checkbox'; + valueInput.removeAttribute('placeholder'); + valueInput.classList.add('form-check-input'); + valueInput.value = 'true'; // Default for checkbox + } else { + valueInput.type = inputType; + valueInput.setAttribute('placeholder', keyLabels[selectedKey].placeholder); + valueInput.classList.remove('form-check-input'); + valueInput.value = ''; // Clear value for text input + } + } + document.getElementById("new-value-description").innerText = keyLabels[selectedKey].description + }); }); document.getElementById('save-config').addEventListener('click', async function() { @@ -78,9 +132,11 @@ document.getElementById('save-config').addEventListener('click', async function( // Existing configs const existingInputs = document.querySelectorAll('#config-form input[data-key]'); existingInputs.forEach(input => { + const key = input.getAttribute('data-key'); + const value = keyLabels[key].type === 'checkbox' ? input.checked.toString() : input.value; keyValuePairs.push({ - key: input.getAttribute('data-key'), - value: input.value + key: key, + value: value }); }); @@ -90,9 +146,11 @@ document.getElementById('save-config').addEventListener('click', async function( const keySelect = div.querySelector('select[data-type="new-key"]'); const valueInput = div.querySelector('input[data-type="new-value"]'); if (keySelect && valueInput && keySelect.value.trim() !== '') { + const key = keySelect.value; + const value = keyLabels[key].type === 'checkbox' ? valueInput.checked.toString() : valueInput.value; keyValuePairs.push({ - key: keySelect.value, - value: valueInput.value + key: key, + value: value }); } }); @@ -186,4 +244,4 @@ document.addEventListener('DOMContentLoaded', function() { }); }); -document.addEventListener('DOMContentLoaded', loadConfig); \ No newline at end of file +document.addEventListener('DOMContentLoaded', loadConfig);