Introduce SetIfNotExists, atomic Client, dynamic web config, ServerConfig, data APIs, and fixes.

This commit is contained in:
Reese Norris
2025-05-17 20:39:58 -07:00
parent 335409c4b4
commit 7e29193c80
23 changed files with 873 additions and 125 deletions

View File

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

355
web/data.go Normal file
View File

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

View File

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

View File

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

25
web/env.go Normal file
View File

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

View File

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

View File

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

View File

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

View File

@@ -23,7 +23,6 @@ async function doAPIRequest(method, url, withAuth, data) {
}).done((res) => {
resolve(res)
}).fail((xhr) => {
logout()
reject(xhr)
});
});

View File

@@ -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 = `
<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}">
<input type="${keyLabels[kv.key].type}" class="form-control" id="${kv.key}" value="${kv.value}" data-key="${kv.key}" placeholder="${keyLabels[kv.key].placeholder}">
<div class="form-text">${desc}</div>
`;
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 += `<option value="${key}">${keyLabels[key]}</option>`;
options += `<option value="${key}">${keyLabels[key].name}</option>`;
});
div.innerHTML = `
<label class="form-label">New Key</label>
@@ -68,8 +99,31 @@ document.getElementById('add-config').addEventListener('click', function() {
</select>
<label class="form-label">Value</label>
<input type="text" class="form-control" placeholder="Value" data-type="new-value">
<div class="form-text" id="new-value-description"></div>
`;
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);
document.addEventListener('DOMContentLoaded', loadConfig);