Add ConfigRepository and enhance server configuration management

1. Database Enhancements (db/repositories.go):
   - Added ConfigRepository interface and implementations for PostgreSQL and SQLite
   - Updated Repositories struct to include ConfigRepository
   - Modified NewRepositories to initialize both UserRepo and ConfigRepo

2. FSD Server Improvements:
   - Removed hardcoded jwtSecret, now retrieved from ConfigRepository (fsd/conn.go, web/auth.go)
   - Added dynamic welcome message retrieval from ConfigRepository (fsd/conn.go)
   - Optimized METAR buffer size from 4096 to 512 bytes (fsd/metar.go)
   - Reduced minimum fields for DeleteATC and DeletePilot packets (fsd/packet.go)
   - Improved Haversine distance calculation with constants (fsd/postoffice.go)
   - Added thread-safety documentation for sendError (fsd/client.go)

3. Server Configuration (fsd/server.go):
   - Added NewDefaultServer to initialize server with environment-based config
   - Implemented automatic database migration and default admin user creation
   - Added configurable METAR worker count
   - Improved logging with slog and environment-based debug level

4. Web Interface Enhancements:
   - Added user and config editor frontend routes (web/frontend.go, web/routes.go)
   - Improved JWT handling by retrieving secret from ConfigRepository (web/auth.go)
   - Enhanced user management API endpoints (web/user.go)
   - Updated dashboard to display CID and conditional admin links (web/templates/dashboard.html)
   - Embedded templates using go:embed (web/templates.go)

5. Frontend JavaScript Improvements:
   - Added networkRatingFromInt helper for readable ratings (web/static/js/openfsd/dashboard.js)
   - Improved API request handling with auth/no-auth variants (web/static/js/openfsd/api.js)

6. Miscellaneous:
   - Added sethvargo/go-envconfig dependency for environment variable parsing
   - Fixed parseVisRange to use 64-bit float parsing (fsd/util.go)
   - Added strPtr utility function (fsd/util.go, web/main.go)
   - Improved SVG logo rendering in layout (web/templates/layout.html)
This commit is contained in:
Reese Norris
2025-05-16 22:27:26 -07:00
parent 5cde160fe7
commit 335409c4b4
38 changed files with 1632 additions and 98 deletions

66
web/api_tokens.go Normal file
View File

@@ -0,0 +1,66 @@
package main
import (
"github.com/gin-gonic/gin"
"github.com/renorris/openfsd/db"
"github.com/renorris/openfsd/fsd"
"net/http"
"time"
)
func (s *Server) handleCreateNewAPIToken(c *gin.Context) {
claims := getJwtContext(c)
if claims.NetworkRating < fsd.NetworkRatingAdministator {
writeAPIV1Response(c, http.StatusForbidden, &genericAPIV1Forbidden)
return
}
type RequestBody struct {
ExpiryDateTime time.Time `json:"expiry_date_time" time_format:"2006-01-02T15:04:05.000Z" binding:"required"`
}
var reqBody RequestBody
if ok := bindJSONOrAbort(c, &reqBody); !ok {
return
}
now := time.Now()
if reqBody.ExpiryDateTime.Before(now) {
res := newAPIV1Failure("expiry_date_time cannot be in the past")
writeAPIV1Response(c, http.StatusBadRequest, &res)
return
}
validityDuration := reqBody.ExpiryDateTime.Sub(now)
accessToken, err := fsd.MakeJwtToken(&fsd.CustomFields{
TokenType: "access",
CID: claims.CID,
NetworkRating: fsd.NetworkRatingAdministator,
}, validityDuration)
if err != nil {
writeAPIV1Response(c, http.StatusInternalServerError, &genericAPIV1InternalServerError)
return
}
secretKey, err := s.dbRepo.ConfigRepo.Get(db.ConfigJwtSecretKey)
if err != nil {
writeAPIV1Response(c, http.StatusInternalServerError, &genericAPIV1InternalServerError)
return
}
accessTokenStr, err := accessToken.SignedString([]byte(secretKey))
if err != nil {
writeAPIV1Response(c, http.StatusInternalServerError, &genericAPIV1InternalServerError)
return
}
type ResponseBody struct {
Token string `json:"token"`
}
resBody := ResponseBody{Token: accessTokenStr}
res := newAPIV1Success(&resBody)
writeAPIV1Response(c, http.StatusCreated, &res)
}

View File

@@ -71,7 +71,13 @@ func (s *Server) refreshAccessToken(c *gin.Context) {
badTokenRes := newAPIV1Failure("bad token")
refreshToken, err := fsd.ParseJwtToken(reqBody.RefreshToken, s.jwtSecret)
jwtSecret, err := s.dbRepo.ConfigRepo.Get(db.ConfigJwtSecretKey)
if err != nil {
writeAPIV1Response(c, http.StatusInternalServerError, &genericAPIV1InternalServerError)
return
}
refreshToken, err := fsd.ParseJwtToken(reqBody.RefreshToken, []byte(jwtSecret))
if err != nil {
writeAPIV1Response(c, http.StatusUnauthorized, &badTokenRes)
return
@@ -90,7 +96,7 @@ func (s *Server) refreshAccessToken(c *gin.Context) {
return
}
access, err := s.makeAccessToken(user)
access, err := s.makeAccessToken(user, []byte(jwtSecret))
if err != nil {
writeAPIV1Response(c, http.StatusInternalServerError, &genericAPIV1InternalServerError)
return
@@ -174,7 +180,13 @@ func (s *Server) getFsdJwt(c *gin.Context) {
return
}
fsdJwtTokenStr, err := fsdJwtToken.SignedString(s.jwtSecret)
jwtSecret, err := s.dbRepo.ConfigRepo.Get(db.ConfigJwtSecretKey)
if err != nil {
writeAPIV1Response(c, http.StatusInternalServerError, &genericAPIV1InternalServerError)
return
}
fsdJwtTokenStr, err := fsdJwtToken.SignedString([]byte(jwtSecret))
if err != nil {
resBody := ResponseBody{
ErrorMsg: "Internal server error",
@@ -200,13 +212,22 @@ func (s *Server) jwtBearerMiddleware(c *gin.Context) {
if !found {
res := newAPIV1Failure("bad bearer token")
writeAPIV1Response(c, http.StatusBadRequest, &res)
c.Abort()
return
}
accessToken, err := fsd.ParseJwtToken(authHeader, s.jwtSecret)
jwtSecret, err := s.dbRepo.ConfigRepo.Get(db.ConfigJwtSecretKey)
if err != nil {
writeAPIV1Response(c, http.StatusInternalServerError, &genericAPIV1InternalServerError)
c.Abort()
return
}
accessToken, err := fsd.ParseJwtToken(authHeader, []byte(jwtSecret))
if err != nil {
res := newAPIV1Failure("invalid bearer token")
writeAPIV1Response(c, http.StatusUnauthorized, &res)
c.Abort()
return
}
@@ -233,12 +254,17 @@ func getJwtContext(c *gin.Context) (claims *fsd.CustomClaims) {
}
func (s *Server) makeAccessRefreshTokens(user *db.User, rememberMe bool) (access string, refresh string, err error) {
access, err = s.makeAccessToken(user)
jwtSecret, err := s.dbRepo.ConfigRepo.Get(db.ConfigJwtSecretKey)
if err != nil {
return
}
refresh, err = s.makeRefreshToken(user, rememberMe)
access, err = s.makeAccessToken(user, []byte(jwtSecret))
if err != nil {
return
}
refresh, err = s.makeRefreshToken(user, rememberMe, []byte(jwtSecret))
if err != nil {
return
}
@@ -246,7 +272,7 @@ func (s *Server) makeAccessRefreshTokens(user *db.User, rememberMe bool) (access
return
}
func (s *Server) makeAccessToken(user *db.User) (access string, err error) {
func (s *Server) makeAccessToken(user *db.User, jwtSecret []byte) (access string, err error) {
// Make access token
accessToken, err := fsd.MakeJwtToken(&fsd.CustomFields{
TokenType: "access",
@@ -259,7 +285,7 @@ func (s *Server) makeAccessToken(user *db.User) (access string, err error) {
return
}
access, err = accessToken.SignedString(s.jwtSecret)
access, err = accessToken.SignedString(jwtSecret)
if err != nil {
return
}
@@ -267,7 +293,7 @@ func (s *Server) makeAccessToken(user *db.User) (access string, err error) {
return
}
func (s *Server) makeRefreshToken(user *db.User, rememberMe bool) (refresh string, err error) {
func (s *Server) makeRefreshToken(user *db.User, rememberMe bool, jwtSecret []byte) (refresh string, err error) {
refreshTokenDuration := time.Hour * 24
if rememberMe {
refreshTokenDuration = time.Hour * 24 * 30
@@ -285,7 +311,7 @@ func (s *Server) makeRefreshToken(user *db.User, rememberMe bool) (refresh strin
return
}
refresh, err = refreshToken.SignedString(s.jwtSecret)
refresh, err = refreshToken.SignedString(jwtSecret)
if err != nil {
return
}

107
web/config.go Normal file
View File

@@ -0,0 +1,107 @@
package main
import (
"errors"
"github.com/gin-gonic/gin"
"github.com/renorris/openfsd/db"
"github.com/renorris/openfsd/fsd"
"net/http"
)
type KeyValuePair struct {
Key string `json:"key" binding:"required"`
Value string `json:"value" binding:"required"`
}
func (s *Server) handleGetConfig(c *gin.Context) {
claims := getJwtContext(c)
if claims.NetworkRating < fsd.NetworkRatingAdministator {
writeAPIV1Response(c, http.StatusForbidden, &genericAPIV1Forbidden)
return
}
var configKeys = []string{
db.ConfigWelcomeMessage,
}
type ResponseBody struct {
KeyValuePairs []KeyValuePair `json:"key_value_pairs" binding:"required"`
}
resBody := ResponseBody{
KeyValuePairs: make([]KeyValuePair, 0, len(configKeys)),
}
for i := range configKeys {
key := configKeys[i]
val, err := s.dbRepo.ConfigRepo.Get(key)
if err != nil {
if !errors.Is(err, db.ErrConfigKeyNotFound) {
res := newAPIV1Failure("Error reading key/value from persistent storage")
writeAPIV1Response(c, http.StatusInternalServerError, &res)
return
}
continue
}
resBody.KeyValuePairs = append(resBody.KeyValuePairs,
KeyValuePair{
Key: key,
Value: val,
},
)
}
res := newAPIV1Success(&resBody)
writeAPIV1Response(c, http.StatusOK, &res)
}
func (s *Server) handleUpdateConfig(c *gin.Context) {
claims := getJwtContext(c)
if claims.NetworkRating < fsd.NetworkRatingAdministator {
writeAPIV1Response(c, http.StatusForbidden, &genericAPIV1Forbidden)
return
}
type RequestBody struct {
KeyValuePairs []KeyValuePair `json:"key_value_pairs" binding:"required"`
}
var reqBody RequestBody
if ok := bindJSONOrAbort(c, &reqBody); !ok {
return
}
for i := range reqBody.KeyValuePairs {
kv := reqBody.KeyValuePairs[i]
if err := s.dbRepo.ConfigRepo.Set(kv.Key, kv.Value); err != nil {
res := newAPIV1Failure("Error writing key/value into persistent storage")
writeAPIV1Response(c, http.StatusInternalServerError, &res)
return
}
}
res := newAPIV1Success(nil)
writeAPIV1Response(c, http.StatusOK, &res)
}
func (s *Server) handleResetSecretKey(c *gin.Context) {
claims := getJwtContext(c)
if claims.NetworkRating < fsd.NetworkRatingAdministator {
writeAPIV1Response(c, http.StatusForbidden, &genericAPIV1Forbidden)
return
}
secretKey, err := db.GenerateJwtSecretKey()
if err != nil {
writeAPIV1Response(c, http.StatusInternalServerError, &genericAPIV1InternalServerError)
return
}
if err = s.dbRepo.ConfigRepo.Set(db.ConfigJwtSecretKey, string(secretKey[:])); err != nil {
writeAPIV1Response(c, http.StatusInternalServerError, &genericAPIV1InternalServerError)
return
}
res := newAPIV1Success(nil)
writeAPIV1Response(c, http.StatusOK, &res)
}

View File

@@ -15,3 +15,11 @@ func (s *Server) handleFrontendLogin(c *gin.Context) {
func (s *Server) handleFrontendDashboard(c *gin.Context) {
writeTemplate(c, "dashboard", nil)
}
func (s *Server) handleFrontendUserEditor(c *gin.Context) {
writeTemplate(c, "usereditor", nil)
}
func (s *Server) handleFrontendConfigEditor(c *gin.Context) {
writeTemplate(c, "configeditor", nil)
}

View File

@@ -21,14 +21,29 @@ func main() {
panic(err)
}
strPtr := func(str string) *string {
return &str
}
if err = dbRepo.UserRepo.CreateUser(&db.User{
FirstName: strPtr("Default Administrator"),
Password: "12345",
NetworkRating: 12,
}); err != nil {
panic(err)
}
server, err := NewServer(dbRepo, []byte("abcdef"))
err = dbRepo.ConfigRepo.Set(db.ConfigJwtSecretKey, "abcdef")
if err != nil {
panic(err)
}
err = dbRepo.ConfigRepo.InitDefault()
if err != nil {
panic(err)
}
server, err := NewServer(dbRepo)
if err != nil {
panic(err)
}

View File

@@ -22,6 +22,7 @@ func (s *Server) setupRoutes() (e *gin.Engine) {
apiV1Group.POST("/fsd-jwt", s.getFsdJwt)
s.setupAuthRoutes(apiV1Group)
s.setupUserRoutes(apiV1Group)
s.setupConfigRoutes(apiV1Group)
// Frontend groups
s.setupFrontendRoutes(e.Group(""))
@@ -39,9 +40,20 @@ func (s *Server) setupAuthRoutes(parent *gin.RouterGroup) {
}
func (s *Server) setupUserRoutes(parent *gin.RouterGroup) {
usersGroup := parent.Group("/user").Use(s.jwtBearerMiddleware)
usersGroup.POST("/load", s.getUserInfo)
usersGroup.POST("/update", s.updateUser)
usersGroup := parent.Group("/user")
usersGroup.Use(s.jwtBearerMiddleware)
usersGroup.POST("/load", s.getUserByCID)
usersGroup.PATCH("/update", s.updateUser)
usersGroup.POST("/create", s.createUser)
}
func (s *Server) setupConfigRoutes(parent *gin.RouterGroup) {
configGroup := parent.Group("/config")
configGroup.Use(s.jwtBearerMiddleware)
configGroup.GET("/load", s.handleGetConfig)
configGroup.POST("/update", s.handleUpdateConfig)
configGroup.POST("/resetsecretkey", s.handleResetSecretKey)
configGroup.POST("/createtoken", s.handleCreateNewAPIToken)
}
func (s *Server) setupFrontendRoutes(parent *gin.RouterGroup) {
@@ -49,4 +61,6 @@ func (s *Server) setupFrontendRoutes(parent *gin.RouterGroup) {
frontendGroup.GET("", s.handleFrontendLanding)
frontendGroup.GET("/login", s.handleFrontendLogin)
frontendGroup.GET("/dashboard", s.handleFrontendDashboard)
frontendGroup.GET("/usereditor", s.handleFrontendUserEditor)
frontendGroup.GET("/configeditor", s.handleFrontendConfigEditor)
}

View File

@@ -6,14 +6,12 @@ import (
)
type Server struct {
dbRepo *db.Repositories
jwtSecret []byte
dbRepo *db.Repositories
}
func NewServer(dbRepo *db.Repositories, jwtSecret []byte) (server *Server, err error) {
func NewServer(dbRepo *db.Repositories) (server *Server, err error) {
server = &Server{
dbRepo: dbRepo,
jwtSecret: jwtSecret,
dbRepo: dbRepo,
}
return

View File

@@ -1,3 +1,11 @@
async function doAPIRequestWithAuth(method, url, data) {
return doAPIRequest(method, url, true, data)
}
async function doAPIRequestNoAuth(method, url, data) {
return doAPIRequest(method, url, false, data)
}
async function doAPIRequest(method, url, withAuth, data) {
return new Promise(async (resolve, reject) => {
let accessToken = "";
@@ -15,6 +23,7 @@ async function doAPIRequest(method, url, withAuth, data) {
}).done((res) => {
resolve(res)
}).fail((xhr) => {
logout()
reject(xhr)
});
});
@@ -86,3 +95,9 @@ function decodeJwt(token) {
return JSON.parse(jsonPayload);
}
function logout() {
localStorage.removeItem("access_token")
localStorage.removeItem("refresh_token")
window.location.href = "/login"
}

View File

@@ -0,0 +1,189 @@
const keyLabels = {
"WELCOME_MESSAGE": "Welcome Message",
};
// Function to show message modal
function showMessageModal(message, token) {
const messageText = document.getElementById('messageText');
if (token) {
messageText.innerHTML = message + ' <div class="d-flex align-items-center"><code class="api-key me-2">' + token + '</code><button class="btn btn-sm btn-outline-secondary copy-btn">Copy</button></div>';
const copyBtn = messageText.querySelector('.copy-btn');
copyBtn.addEventListener('click', function() {
navigator.clipboard.writeText(token).then(() => {
copyBtn.textContent = 'Copied!';
setTimeout(() => {
copyBtn.textContent = 'Copy';
}, 2000);
}).catch(err => {
console.error('Failed to copy: ', err);
});
});
} else {
messageText.textContent = message;
}
const messageModal = new bootstrap.Modal(document.getElementById('messageModal'));
messageModal.show();
}
async function loadConfig() {
try {
const res = await doAPIRequestWithAuth('GET', '/api/v1/config/load');
if (res.err) {
alert('Error: ' + res.err);
return;
}
const configForm = document.getElementById('config-form');
configForm.innerHTML = ''; // Clear existing fields
res.data.key_value_pairs.forEach(kv => {
const label = keyLabels[kv.key] || kv.key;
const div = document.createElement('div');
div.className = 'mb-3';
div.innerHTML = `
<label for="${kv.key}" class="form-label">${label}</label>
<input type="text" class="form-control" id="${kv.key}" value="${kv.value}" data-key="${kv.key}">
`;
configForm.appendChild(div);
});
} catch (xhr) {
const errMsg = xhr.responseJSON && xhr.responseJSON.err ? xhr.responseJSON.err : 'An error occurred';
alert('Error: ' + errMsg);
console.error('Request failed:', xhr);
}
}
document.getElementById('add-config').addEventListener('click', function() {
const configForm = document.getElementById('config-form');
const div = document.createElement('div');
div.className = 'mb-3 new-config';
// Create dropdown options from keyLabels
let options = '';
Object.keys(keyLabels).forEach(key => {
options += `<option value="${key}">${keyLabels[key]}</option>`;
});
div.innerHTML = `
<label class="form-label">New Key</label>
<select class="form-control mb-2" data-type="new-key">
<option value="" disabled selected>Select a key</option>
${options}
</select>
<label class="form-label">Value</label>
<input type="text" class="form-control" placeholder="Value" data-type="new-value">
`;
configForm.appendChild(div);
});
document.getElementById('save-config').addEventListener('click', async function() {
const keyValuePairs = [];
// Existing configs
const existingInputs = document.querySelectorAll('#config-form input[data-key]');
existingInputs.forEach(input => {
keyValuePairs.push({
key: input.getAttribute('data-key'),
value: input.value
});
});
// New configs
const newConfigDivs = document.querySelectorAll('#config-form .new-config');
newConfigDivs.forEach(div => {
const keySelect = div.querySelector('select[data-type="new-key"]');
const valueInput = div.querySelector('input[data-type="new-value"]');
if (keySelect && valueInput && keySelect.value.trim() !== '') {
keyValuePairs.push({
key: keySelect.value,
value: valueInput.value
});
}
});
try {
const res = await doAPIRequestWithAuth('POST', '/api/v1/config/update', { key_value_pairs: keyValuePairs });
if (res.err) {
alert('Error: ' + res.err);
} else {
alert('Config updated successfully');
loadConfig(); // Reload to show new configs if added
}
} catch (xhr) {
const errMsg = xhr.responseJSON && xhr.responseJSON.err ? xhr.responseJSON.err : 'An error occurred';
alert('Error: ' + errMsg);
console.error('Request failed:', xhr);
}
});
// Added function to reset the JWT Secret Key
async function resetJwtSecretKey() {
try {
const res = await doAPIRequestWithAuth('POST', '/api/v1/config/resetsecretkey');
if (res.err) {
alert('Error: ' + res.err);
} else {
alert('JWT Secret Key reset successfully');
}
} catch (xhr) {
const errMsg = xhr.responseJSON && xhr.responseJSON.err ? xhr.responseJSON.err : 'An error occurred';
alert('Error: ' + errMsg);
console.error('Request failed:', xhr);
}
}
// Added event listener for the reset button
document.addEventListener('DOMContentLoaded', function() {
document.getElementById('reset-jwt-secret').addEventListener('click', function() {
if (confirm('Are you sure you want to reset the JWT Secret Key? All previously generated API tokens and all active sessions will be invalidated.')) {
resetJwtSecretKey();
}
});
});
// Modified event listener for create new API token button
document.addEventListener('DOMContentLoaded', function() {
document.getElementById('create-new-api-key').addEventListener('click', function() {
const createTokenModal = new bootstrap.Modal(document.getElementById('createTokenModal'));
createTokenModal.show();
});
});
// Added event listener for submit create token
document.addEventListener('DOMContentLoaded', function() {
document.getElementById('submitCreateToken').addEventListener('click', async function() {
const expiryDateStr = document.getElementById('expiryDate').value;
let expiryDate;
if (expiryDateStr) {
const [year, month, day] = expiryDateStr.split('-').map(Number);
if (isNaN(year) || isNaN(month) || isNaN(day)) {
showMessageModal('Invalid date format.');
return;
}
expiryDate = new Date(Date.UTC(year, month - 1, day));
if (isNaN(expiryDate.getTime())) {
showMessageModal('Invalid date.');
return;
}
} else {
expiryDate = new Date();
expiryDate.setFullYear(expiryDate.getFullYear() + 1);
}
const expiryDateTime = expiryDate.toJSON();
const data = {
"expiry_date_time": expiryDateTime
};
try {
const res = await doAPIRequestWithAuth('POST', '/api/v1/config/createtoken', data);
if (res.err) {
showMessageModal('Error: ' + res.err);
} else {
showMessageModal('API Token created:', res.data.token);
}
} catch (xhr) {
const errMsg = xhr.responseJSON && xhr.responseJSON.err ? xhr.responseJSON.err : 'An error occurred';
showMessageModal('Error: ' + errMsg);
}
// Hide the createTokenModal
const createTokenModal = bootstrap.Modal.getInstance(document.getElementById('createTokenModal'));
createTokenModal.hide();
});
});
document.addEventListener('DOMContentLoaded', loadConfig);

View File

@@ -1,3 +1,23 @@
function networkRatingFromInt(val) {
switch (val) {
case -1: return "Inactive"
case 0: return "Suspended"
case 1: return "Observer"
case 2: return "Student 1"
case 3: return "Student 2"
case 4: return "Student 3"
case 5: return "Controller 1"
case 6: return "Controller 2"
case 7: return "Controller 3"
case 8: return "Instructor 1"
case 9: return "Instructor 2"
case 10: return "Instructor 3"
case 11: return "Supervisor"
case 12: return "Administrator"
default: return "Unknown"
}
}
$(document).ready(async () => {
const claims = getAccessTokenClaims()
loadUserInfo(claims.cid)
@@ -27,7 +47,15 @@ async function loadUserInfo(cid) {
$("#dashboard-real-name").text("Welcome!")
}
$("#dashboard-network-rating").text(res.data.network_rating)
$("#dashboard-network-rating").text(networkRatingFromInt(res.data.network_rating))
$("#dashboard-cid").text(`CID: ${res.data.cid}`)
if (res.data.network_rating >= 11) {
$("#dashboard-user-editor").html(`
<div class="mb-2"><a href="/usereditor" class="btn btn-primary">Edit Users</a></div>
<div class="mb-2"><a href="/configeditor" class="btn btn-primary">Configure Server</a></div>
`)
}
}
let dashboardMarkers = [];

View File

@@ -0,0 +1,128 @@
// Form Handlers
document.getElementById('search-form').addEventListener('submit', async function(event) {
event.preventDefault();
const cid = document.getElementById('search-cid').value;
try {
const res = await doAPIRequestWithAuth('POST', '/api/v1/user/load', {cid: parseInt(cid)});
if (res.err) {
alert('Error: ' + res.err);
} else {
const user = res.data;
document.getElementById('edit-cid').value = user.cid;
document.getElementById('edit-cid').hidden = false;
document.getElementById('edit-first-name').value = user.first_name || '';
document.getElementById('edit-last-name').value = user.last_name || '';
document.getElementById('edit-network-rating').value = user.network_rating;
document.getElementById('edit-password').value = '';
}
} catch (xhr) {
const errMsg = xhr.responseJSON && xhr.responseJSON.err ? xhr.responseJSON.err : 'An error occurred';
alert('Error: ' + errMsg);
console.error('Request failed:', xhr);
}
});
document.getElementById('create-form').addEventListener('submit', async function(event) {
event.preventDefault();
const firstName = document.getElementById('create-first-name').value;
const lastName = document.getElementById('create-last-name').value;
const password = document.getElementById('create-password').value;
const networkRating = document.getElementById('create-network-rating').value;
const data = {
password: password,
network_rating: parseInt(networkRating)
};
if (firstName) data.first_name = firstName;
if (lastName) data.last_name = lastName;
try {
const res = await doAPIRequestWithAuth('POST', '/api/v1/user/create', data);
if (res.err) {
alert('Error: ' + res.err);
} else {
alert('User created successfully. CID: ' + res.data.cid);
document.getElementById('create-first-name').value = '';
document.getElementById('create-last-name').value = '';
document.getElementById('create-password').value = '';
document.getElementById('create-network-rating').value = '-1';
}
} catch (xhr) {
const errMsg = xhr.responseJSON && xhr.responseJSON.err ? xhr.responseJSON.err : 'An error occurred';
alert('Error: ' + errMsg);
console.error('Request failed:', xhr);
}
});
document.getElementById('edit-form').addEventListener('submit', async function(event) {
event.preventDefault();
const cid = document.getElementById('edit-cid').value;
const firstName = document.getElementById('edit-first-name').value;
const lastName = document.getElementById('edit-last-name').value;
const networkRating = document.getElementById('edit-network-rating').value;
const password = document.getElementById('edit-password').value;
const data = {
cid: parseInt(cid),
first_name: firstName,
last_name: lastName,
network_rating: parseInt(networkRating)
};
if (password) data.password = password;
try {
const res = await doAPIRequestWithAuth('PATCH', '/api/v1/user/update', data);
if (res.err) {
alert('Error: ' + res.err);
} else {
alert('User updated successfully');
}
} catch (xhr) {
const errMsg = xhr.responseJSON && xhr.responseJSON.err ? xhr.responseJSON.err : 'An error occurred';
alert('Error: ' + errMsg);
console.error('Request failed:', xhr);
}
});
document.addEventListener('DOMContentLoaded', () => {
const createPasswordInput = document.getElementById('create-password');
const createStrengthBar = document.getElementById('create-password-strength');
const createFeedback = document.getElementById('create-password-feedback');
const editPasswordInput = document.getElementById('edit-password');
const editStrengthBar = document.getElementById('edit-password-strength');
const editFeedback = document.getElementById('edit-password-feedback');
const evaluatePassword = (password, strengthBar, feedback) => {
if (!password) {
strengthBar.style.width = '0%';
strengthBar.className = 'progress-bar';
feedback.textContent = '';
return;
}
let strength = 0;
if (password.length === 8) strength += 50;
if (/[A-Z]/.test(password)) strength += 15;
if (/[a-z]/.test(password)) strength += 15;
if (/[0-9]/.test(password)) strength += 10;
if (/[^A-Za-z0-9]/.test(password)) strength += 10;
strength = Math.min(strength, 100);
strengthBar.style.width = `${strength}%`;
if (strength < 60) {
strengthBar.className = 'progress-bar bg-danger';
feedback.textContent = 'Weak: Include uppercase, lowercase, numbers, or symbols.';
} else if (strength < 80) {
strengthBar.className = 'progress-bar bg-warning';
feedback.textContent = 'Moderate: Add more character types for strength.';
} else {
strengthBar.className = 'progress-bar bg-success';
feedback.textContent = 'Strong: Good password!';
}
};
createPasswordInput.addEventListener('input', () => {
evaluatePassword(createPasswordInput.value, createStrengthBar, createFeedback);
});
editPasswordInput.addEventListener('input', () => {
evaluatePassword(editPasswordInput.value, editStrengthBar, editFeedback);
});
});

View File

@@ -2,6 +2,7 @@ package main
import (
"bytes"
"embed"
"github.com/gin-gonic/gin"
"html/template"
"io"
@@ -9,10 +10,13 @@ import (
"path"
)
//go:embed templates
var templatesFS embed.FS
var basePath = path.Join(".", "templates")
func loadTemplate(key string) (t *template.Template) {
t, err := template.ParseFiles(path.Join(basePath, "layout.html"), path.Join(basePath, key+".html"))
t, err := template.ParseFS(templatesFS, path.Join(basePath, "layout.html"), path.Join(basePath, key+".html"))
if err != nil {
panic(err)
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,284 @@
{{ define "title" }}User Editor{{ end }}
{{ define "body" }}
<div class="container mt-4">
<div class="row">
<div class="col-12 col-md-4 mb-4">
<div class="card">
<div class="card-body">
<h5 class="card-title">Create User</h5>
<form class="mb-3" id="create-form">
<div class="mb-3">
<label for="create-first-name" class="form-label">First Name</label>
<input type="text" class="form-control" id="create-first-name">
</div>
<div class="mb-3">
<label for="create-last-name" class="form-label">Last Name</label>
<input type="text" class="form-control" id="create-last-name">
</div>
<div class="mb-3">
<label for="create-password" class="form-label">Password</label>
<input type="password" class="form-control" id="create-password" minlength="8" required>
<div class="mt-2">
<label class="form-label">Password Strength</label>
<div class="progress" style="height: 10px;">
<div id="create-password-strength" class="progress-bar" role="progressbar" style="width: 0%;" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100"></div>
</div>
<small id="create-password-feedback" class="form-text text-muted"></small>
</div>
</div>
<div class="mb-3">
<label for="create-network-rating" class="form-label">Network Rating</label>
<select class="form-select" id="create-network-rating" required aria-label="Select network rating">
<option value="-1">Inactive</option>
<option value="0">Suspended</option>
<option value="1" selected>Observer</option>
<option value="2">Student 1</option>
<option value="3">Student 2</option>
<option value="4">Student 3</option>
<option value="5">Controller 1</option>
<option value="6">Controller 2</option>
<option value="7">Controller 3</option>
<option value="8">Instructor 1</option>
<option value="9">Instructor 2</option>
<option value="10">Instructor 3</option>
<option value="11">Supervisor</option>
<option value="12">Administrator</option>
</select>
</div>
<button type="submit" class="btn btn-primary">Create</button>
</form>
<div id="create-success-message" class="alert alert-success d-none" role="alert"></div>
</div>
</div>
</div>
<div class="col-12 col-md-4 mb-4">
<div class="card">
<div class="card-body">
<h5 class="card-title">Search for User by CID</h5>
<form id="search-form">
<div class="mb-3">
<label for="search-cid" class="form-label">CID</label>
<input type="number" class="form-control" id="search-cid" min="1" required>
</div>
<button type="submit" class="btn btn-primary">Search</button>
</form>
</div>
</div>
</div>
<div class="col-12 col-md-4 mb-4">
<div class="card">
<div class="card-body">
<h5 class="card-title">Edit User</h5>
<form class="mb-3" id="edit-form">
<div class="mb-3">
<label for="edit-cid" class="form-label">CID</label>
<input type="text" class="form-control" id="edit-cid" readonly>
</div>
<div class="mb-3">
<label for="edit-first-name" class="form-label">First Name</label>
<input type="text" class="form-control" id="edit-first-name">
</div>
<div class="mb-3">
<label for="edit-last-name" class="form-label">Last Name</label>
<input type="text" class="form-control" id="edit-last-name">
</div>
<div class="mb-3">
<label for="edit-network-rating" class="form-label">Network Rating</label>
<select class="form-select" id="edit-network-rating" aria-label="Select network rating">
<option value="-1">Inactive</option>
<option value="0">Suspended</option>
<option value="1" selected>Observer</option>
<option value="2">Student 1</option>
<option value="3">Student 2</option>
<option value="4">Student 3</option>
<option value="5">Controller 1</option>
<option value="6">Controller 2</option>
<option value="7">Controller 3</option>
<option value="8">Instructor 1</option>
<option value="9">Instructor 2</option>
<option value="10">Instructor 3</option>
<option value="11">Supervisor</option>
<option value="12">Administrator</option>
</select>
</div>
<div class="mb-3">
<label for="edit-password" class="form-label">New Password (leave blank to keep current)</label>
<input type="password" class="form-control" id="edit-password" minlength="8">
<div class="mt-2">
<label class="form-label">Password Strength</label>
<div class="progress" style="height: 10px;">
<div id="edit-password-strength" class="progress-bar" role="progressbar" style="width: 0%;" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100"></div>
</div>
<small id="edit-password-feedback" class="form-text text-muted"></small>
</div>
</div>
<button type="submit" class="btn btn-primary">Update</button>
</form>
<div id="edit-success-message" class="alert alert-success d-none" role="alert"></div>
</div>
</div>
</div>
</div>
</div>
<!-- Modal for error messages only -->
<div class="modal fade" id="messageModal" tabindex="-1" aria-labelledby="messageModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="messageModalLabel">Error</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body" id="messageModalBody"></div>
<div class="modal-footer">
<button type="button" class="btn btn-primary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
<script>
// Function to show modal for errors only
function showModal(message) {
const modal = new bootstrap.Modal(document.getElementById('messageModal'));
const modalBody = document.getElementById('messageModalBody');
modalBody.textContent = message;
modal.show();
}
// Form Handlers
document.getElementById('search-form').addEventListener('submit', async function(event) {
event.preventDefault();
const cid = document.getElementById('search-cid').value;
try {
const res = await doAPIRequestWithAuth('POST', '/api/v1/user/load', {cid: parseInt(cid)});
if (res.err) {
showModal(res.err);
} else {
const user = res.data;
document.getElementById('edit-cid').value = user.cid;
document.getElementById('edit-first-name').value = user.first_name || '';
document.getElementById('edit-last-name').value = user.last_name || '';
document.getElementById('edit-network-rating').value = user.network_rating;
document.getElementById('edit-password').value = '';
}
} catch (xhr) {
const errMsg = xhr.responseJSON && xhr.responseJSON.err ? xhr.responseJSON.err : 'An error occurred';
showModal(errMsg);
console.error('Request failed:', xhr);
}
});
document.getElementById('create-form').addEventListener('submit', async function(event) {
event.preventDefault();
document.getElementById('create-success-message').classList.add('d-none');
const firstName = document.getElementById('create-first-name').value;
const lastName = document.getElementById('create-last-name').value;
const password = document.getElementById('create-password').value;
const networkRating = document.getElementById('create-network-rating').value;
const data = {
password: password,
network_rating: parseInt(networkRating)
};
if (firstName) data.first_name = firstName;
if (lastName) data.last_name = lastName;
try {
const res = await doAPIRequestWithAuth('POST', '/api/v1/user/create', data);
if (res.err) {
showModal(res.err);
} else {
const successMessage = document.getElementById('create-success-message');
successMessage.textContent = 'User created successfully. CID: ' + res.data.cid;
successMessage.classList.remove('d-none');
document.getElementById('create-first-name').value = '';
document.getElementById('create-last-name').value = '';
document.getElementById('create-password').value = '';
document.getElementById('create-network-rating').value = '-1';
}
} catch (xhr) {
const errMsg = xhr.responseJSON && xhr.responseJSON.err ? xhr.responseJSON.err : 'An error occurred';
showModal(errMsg);
console.error('Request failed:', xhr);
}
});
document.getElementById('edit-form').addEventListener('submit', async function(event) {
event.preventDefault();
document.getElementById('edit-success-message').classList.add('d-none');
const cid = document.getElementById('edit-cid').value;
const firstName = document.getElementById('edit-first-name').value;
const lastName = document.getElementById('edit-last-name').value;
const networkRating = document.getElementById('edit-network-rating').value;
const password = document.getElementById('edit-password').value;
const data = {
cid: parseInt(cid),
first_name: firstName,
last_name: lastName,
network_rating: parseInt(networkRating)
};
if (password) data.password = password;
try {
const res = await doAPIRequestWithAuth('PATCH', '/api/v1/user/update', data);
if (res.err) {
showModal(res.err);
} else {
const successMessage = document.getElementById('edit-success-message');
successMessage.textContent = 'User updated successfully';
successMessage.classList.remove('d-none');
}
} catch (xhr) {
const errMsg = xhr.responseJSON && xhr.responseJSON.err ? xhr.responseJSON.err : 'An error occurred';
showModal(errMsg);
console.error('Request failed:', xhr);
}
});
document.addEventListener('DOMContentLoaded', () => {
const createPasswordInput = document.getElementById('create-password');
const createStrengthBar = document.getElementById('create-password-strength');
const createFeedback = document.getElementById('create-password-feedback');
const editPasswordInput = document.getElementById('edit-password');
const editStrengthBar = document.getElementById('edit-password-strength');
const editFeedback = document.getElementById('edit-password-feedback');
const evaluatePassword = (password, strengthBar, feedback) => {
if (!password) {
strengthBar.style.width = '0%';
strengthBar.className = 'progress-bar';
feedback.textContent = '';
return;
}
let strength = 0;
if (password.length >= 8) strength += 50;
if (/[A-Z]/.test(password)) strength += 15;
if (/[a-z]/.test(password)) strength += 15;
if (/[0-9]/.test(password)) strength += 10;
if (/[^A-Za-z0-9]/.test(password)) strength += 10;
strength = Math.min(strength, 100);
strengthBar.style.width = `${strength}%`;
if (strength < 60) {
strengthBar.className = 'progress-bar bg-danger';
feedback.textContent = 'Weak: Include uppercase, lowercase, numbers, or symbols.';
} else if (strength < 80) {
strengthBar.className = 'progress-bar bg-warning';
feedback.textContent = 'Moderate: Add more character types for strength.';
} else {
strengthBar.className = 'progress-bar bg-success';
feedback.textContent = 'Strong: Good password!';
}
};
createPasswordInput.addEventListener('input', () => {
evaluatePassword(createPasswordInput.value, createStrengthBar, createFeedback);
});
editPasswordInput.addEventListener('input', () => {
evaluatePassword(editPasswordInput.value, editStrengthBar, editFeedback);
});
});
</script>
{{ end }}

View File

@@ -4,14 +4,15 @@ import (
"database/sql"
"errors"
"github.com/gin-gonic/gin"
"github.com/renorris/openfsd/db"
"github.com/renorris/openfsd/fsd"
"net/http"
)
// getUserInfo returns the user info of the specified CID.
// getUserByCID returns the user info of the specified CID.
//
// Only >= SUP can request CIDs other than what is indicated in their bearer token.
func (s *Server) getUserInfo(c *gin.Context) {
func (s *Server) getUserByCID(c *gin.Context) {
type RequestBody struct {
CID int `json:"cid" binding:"min=1,required"`
}
@@ -61,6 +62,12 @@ func (s *Server) getUserInfo(c *gin.Context) {
// The CID itself is immutable and cannot be changed.
// Only >= SUP can update CIDs other than what is indicated in their bearer token.
func (s *Server) updateUser(c *gin.Context) {
claims := getJwtContext(c)
if claims.NetworkRating < fsd.NetworkRatingSupervisor {
writeAPIV1Response(c, http.StatusForbidden, &genericAPIV1Forbidden)
return
}
type RequestBody struct {
CID int `json:"cid" binding:"min=1,required"`
Password *string `json:"password"`
@@ -80,8 +87,6 @@ func (s *Server) updateUser(c *gin.Context) {
return
}
claims := getJwtContext(c)
if targetUser.NetworkRating > int(claims.NetworkRating) {
res := newAPIV1Failure("cannot update user with higher network rating")
writeAPIV1Response(c, http.StatusForbidden, &res)
@@ -144,10 +149,38 @@ func (s *Server) createUser(c *gin.Context) {
}
claims := getJwtContext(c)
if claims.NetworkRating < fsd.NetworkRatingSupervisor || reqBody.NetworkRating > int(claims.NetworkRating) {
if claims.NetworkRating < fsd.NetworkRatingSupervisor ||
reqBody.NetworkRating > int(claims.NetworkRating) {
writeAPIV1Response(c, http.StatusForbidden, &genericAPIV1Forbidden)
return
}
user := &db.User{
Password: reqBody.Password,
FirstName: reqBody.FirstName,
LastName: reqBody.LastName,
NetworkRating: reqBody.NetworkRating,
}
if err := s.dbRepo.UserRepo.CreateUser(user); err != nil {
writeAPIV1Response(c, http.StatusInternalServerError, &genericAPIV1InternalServerError)
return
}
type ResponseBody struct {
CID int `json:"cid"`
FirstName *string `json:"first_name"`
LastName *string `json:"last_name"`
NetworkRating int `json:"network_rating" binding:"min=-1,max=12,required"`
}
resBody := ResponseBody{
CID: user.CID,
FirstName: user.FirstName,
LastName: user.LastName,
NetworkRating: user.NetworkRating,
}
res := newAPIV1Success(&resBody)
writeAPIV1Response(c, http.StatusCreated, &res)
}