add kick user web interface

This commit is contained in:
Reese Norris
2025-05-18 15:37:36 -07:00
parent f03855b7db
commit 75178b7557
9 changed files with 270 additions and 43 deletions

View File

@@ -3,6 +3,7 @@ package fsd
import (
"context"
"encoding/json"
"errors"
"github.com/gin-gonic/gin"
"github.com/renorris/openfsd/db"
"log/slog"
@@ -27,6 +28,7 @@ func (s *Server) setupRoutes() (e *gin.Engine) {
// Verify administrator service JWT
e.Use(s.authMiddleware)
e.GET("/online_users", s.handleGetOnlineUsers)
e.POST("/kick_user", s.handleKickUser)
return
}
@@ -143,3 +145,29 @@ func (s *Server) handleGetOnlineUsers(c *gin.Context) {
c.Writer.WriteHeader(http.StatusOK)
json.NewEncoder(c.Writer).Encode(&resData)
}
func (s *Server) handleKickUser(c *gin.Context) {
type RequestBody struct {
Callsign string `json:"callsign" binding:"required"`
}
var reqBody RequestBody
if err := c.ShouldBindJSON(&reqBody); err != nil {
c.AbortWithStatus(http.StatusBadRequest)
}
client, err := s.postOffice.find(reqBody.Callsign)
if err != nil {
if !errors.Is(err, ErrCallsignDoesNotExist) {
c.AbortWithStatus(http.StatusInternalServerError)
return
}
c.AbortWithStatus(http.StatusNotFound)
return
}
// Cancelling the context will cause the client's event loop to close
client.cancelCtx()
c.AbortWithStatus(http.StatusNoContent)
}

View File

@@ -231,7 +231,16 @@ func (s *Server) jwtBearerMiddleware(c *gin.Context) {
return
}
setJwtContext(c, accessToken.CustomClaims())
claims := accessToken.CustomClaims()
if claims.TokenType != "access" {
res := newAPIV1Failure("invalid token type")
writeAPIV1Response(c, http.StatusUnauthorized, &res)
c.Abort()
return
}
setJwtContext(c, claims)
c.Next()
}

View File

@@ -10,6 +10,7 @@ import (
"github.com/renorris/openfsd/db"
"github.com/renorris/openfsd/fsd"
"go.uber.org/atomic"
"io"
"log/slog"
"net/http"
"strings"
@@ -272,32 +273,11 @@ func (s *Server) getDatafeed(c *gin.Context) {
}
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)
req, err := s.makeFsdHttpServiceHttpRequest("GET", "/online_users", nil)
if err != nil {
return
}
req.Header.Set("Authorization", "Bearer "+tokenStr)
res, err := client.Do(req)
if err != nil {
return
@@ -331,6 +311,40 @@ func (s *Server) generateDatafeed() (feed *DatafeedCache, err error) {
return
}
// makeFsdHttpServiceHttpRequest prepares an HTTP request destined for the internal FSD HTTP API.
//
// method sets the HTTP method, path is the relative HTTP path (e.g. /online_users), and body is an optional request body.
func (s *Server) makeFsdHttpServiceHttpRequest(method string, path string, body io.Reader) (req *http.Request, 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
}
url := s.cfg.FsdHttpServiceAddress + path
req, err = http.NewRequest(method, url, body)
if err != nil {
return
}
req.Header.Set("Authorization", "Bearer "+tokenStr)
return
}
func (s *Server) runDatafeedWorker(ctx context.Context) {
s.updateDataFeedCache()
ticker := time.NewTicker(15 * time.Second)

57
web/fsdconn.go Normal file
View File

@@ -0,0 +1,57 @@
package main
import (
"bytes"
"encoding/json"
"github.com/gin-gonic/gin"
"github.com/renorris/openfsd/fsd"
"net/http"
)
func (s *Server) handleKickActiveConnection(c *gin.Context) {
claims := getJwtContext(c)
if claims.NetworkRating < fsd.NetworkRatingSupervisor {
writeAPIV1Response(c, http.StatusForbidden, &genericAPIV1Forbidden)
return
}
type RequestBody struct {
Callsign string `json:"callsign" binding:"required"`
}
var reqBody RequestBody
if !bindJSONOrAbort(c, &reqBody) {
return
}
buf := bytes.Buffer{}
if err := json.NewEncoder(&buf).Encode(reqBody); err != nil {
writeAPIV1Response(c, http.StatusInternalServerError, &genericAPIV1InternalServerError)
return
}
client := http.Client{}
defer client.CloseIdleConnections()
req, err := s.makeFsdHttpServiceHttpRequest("POST", "/kick_user", &buf)
if err != nil {
return
}
res, err := client.Do(req)
if err != nil {
return
}
switch res.StatusCode {
case http.StatusNoContent:
apiV1Res := newAPIV1Success(nil)
writeAPIV1Response(c, http.StatusOK, &apiV1Res)
return
case http.StatusNotFound:
apiV1Res := newAPIV1Failure("Callsign not found")
writeAPIV1Response(c, http.StatusNotFound, &apiV1Res)
return
default:
writeAPIV1Response(c, http.StatusInternalServerError, &genericAPIV1InternalServerError)
return
}
}

View File

@@ -30,6 +30,7 @@ func (s *Server) setupRoutes() (e *gin.Engine) {
s.setupUserRoutes(apiV1Group)
s.setupConfigRoutes(apiV1Group)
s.setupDataRoutes(apiV1Group)
s.setupFsdConnRoutes(apiV1Group)
// Frontend groups
s.setupFrontendRoutes(e.Group(""))
@@ -67,6 +68,12 @@ func (s *Server) setupConfigRoutes(parent *gin.RouterGroup) {
configGroup.POST("/createtoken", s.handleCreateNewAPIToken)
}
func (s *Server) setupFsdConnRoutes(parent *gin.RouterGroup) {
fsdConnGroup := parent.Group("/fsdconn")
fsdConnGroup.Use(s.jwtBearerMiddleware)
fsdConnGroup.POST("/kickuser", s.handleKickActiveConnection)
}
func (s *Server) setupDataRoutes(parent *gin.RouterGroup) {
dataGroup := parent.Group("/data")
dataGroup.GET("/status.txt", s.handleGetStatusTxt)

View File

@@ -1,3 +1,14 @@
let userNetworkRating;
async function kickUser(callsign) {
try {
await doAPIRequestWithAuth("POST", "/api/v1/fsdconn/kickuser", { callsign: callsign });
alert("User kicked successfully");
} catch (error) {
alert("Failed to kick user");
}
}
function networkRatingFromInt(val) {
switch (val) {
case -1: return "Inactive"
@@ -18,9 +29,9 @@ function networkRatingFromInt(val) {
}
}
$(document).ready(async () => {
$(async () => {
const claims = getAccessTokenClaims()
loadUserInfo(claims.cid)
await loadUserInfo(claims.cid)
const map = L.map('map').setView([30, 0], 1);
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
@@ -35,11 +46,12 @@ $(document).ready(async () => {
});
await populateMap(map, planeIcon)
setInterval(() => { populateMap(map, planeIcon) }, 5000)
setInterval(() => { populateMap(map, planeIcon) }, 15000)
})
async function loadUserInfo(cid) {
const res = await doAPIRequest("POST", "/api/v1/user/load", true, { cid: cid })
userNetworkRating = res.data.network_rating;
if (res.data.first_name !== "") {
$("#dashboard-real-name").text(`Welcome, ${res.data.first_name}!`);
@@ -62,17 +74,26 @@ let dashboardMarkers = [];
async function populateMap(map, planeIcon) {
try {
const res = await $.ajax("https://data.vatsim.net/v3/vatsim-data.json", {
const res = await $.ajax("/api/v1/data/openfsd-data.json", {
method: "GET",
dataType: "json"
});
// Collect callsigns of markers with open popups
const openCallsigns = new Set();
dashboardMarkers.forEach((marker) => {
if (marker.getPopup() && marker.getPopup().isOpen()) {
openCallsigns.add(marker.options.title);
}
});
// Remove existing markers
dashboardMarkers.forEach((marker) => {
map.removeLayer(marker);
});
dashboardMarkers = [];
// Add new markers
res.pilots.forEach((pilot) => {
const callsign = pilot.callsign;
const lat = pilot.latitude;
@@ -80,23 +101,28 @@ async function populateMap(map, planeIcon) {
const heading = pilot.heading;
const name = pilot.name;
const marker = L.marker([lat, lon], { icon: planeIcon, title: callsign });
// Bind popup with callsign
marker.bindPopup(`<b>Callsign:</b> ${callsign}<br>${name}<br>${lat} ${lon}`).openPopup();
marker.on('click', function() {
this.openPopup();
const marker = L.marker([lat, lon], {
icon: planeIcon,
rotationAngle: heading,
rotationOrigin: 'center center',
title: callsign
});
marker.addTo(map);
// Set rotation
if (marker._icon) {
const currentTransform = marker._icon.style.transform || '';
marker._icon.style.transform = currentTransform + ` rotate(${heading}deg)`;
marker._icon.style.transformOrigin = "center"
let popupContent = `<b>Callsign:</b> ${callsign}<br>${name}<br>${lat} ${lon}`;
if (userNetworkRating >= 11) {
popupContent += `<br><button onclick="kickUser('${callsign}')">Kick</button>`;
}
marker.bindPopup(popupContent);
marker.addTo(map);
dashboardMarkers.push(marker);
// If this callsign was open before, open its popup
if (openCallsigns.has(callsign)) {
marker.openPopup();
}
});
$("#dashboard-connection-count").text(dashboardMarkers.length);
} catch (error) {
console.error("Failed to fetch VATSIM data:", error);
}
}
}

View File

@@ -0,0 +1,85 @@
/*
The MIT License (MIT)
Copyright (c) 2015 Benjamin Becquet
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
/* https://github.com/bbecquet/Leaflet.RotatedMarker */
(function() {
// save these original methods before they are overwritten
var proto_initIcon = L.Marker.prototype._initIcon;
var proto_setPos = L.Marker.prototype._setPos;
var oldIE = (L.DomUtil.TRANSFORM === 'msTransform');
L.Marker.addInitHook(function () {
var iconOptions = this.options.icon && this.options.icon.options;
var iconAnchor = iconOptions && this.options.icon.options.iconAnchor;
if (iconAnchor) {
iconAnchor = (iconAnchor[0] + 'px ' + iconAnchor[1] + 'px');
}
this.options.rotationOrigin = this.options.rotationOrigin || iconAnchor || 'center bottom' ;
this.options.rotationAngle = this.options.rotationAngle || 0;
// Ensure marker keeps rotated during dragging
this.on('drag', function(e) { e.target._applyRotation(); });
});
L.Marker.include({
_initIcon: function() {
proto_initIcon.call(this);
},
_setPos: function (pos) {
proto_setPos.call(this, pos);
this._applyRotation();
},
_applyRotation: function () {
if(this.options.rotationAngle) {
this._icon.style[L.DomUtil.TRANSFORM+'Origin'] = this.options.rotationOrigin;
if(oldIE) {
// for IE 9, use the 2D rotation
this._icon.style[L.DomUtil.TRANSFORM] = 'rotate(' + this.options.rotationAngle + 'deg)';
} else {
// for modern browsers, prefer the 3D accelerated version
this._icon.style[L.DomUtil.TRANSFORM] += ' rotateZ(' + this.options.rotationAngle + 'deg)';
}
}
},
setRotationAngle: function(angle) {
this.options.rotationAngle = angle;
this.update();
return this;
},
setRotationOrigin: function(origin) {
this.options.rotationOrigin = origin;
this.update();
return this;
}
});
})();

View File

@@ -15,5 +15,9 @@
</div>
</div>
<script src="/static/js/leaflet.js"></script>
<script src="/static/js/openfsd/leaflet.rotatedmarker.js"></script>
<link href="/static/css/leaflet.css" rel="stylesheet">
<script src="/static/js/openfsd/dashboard.js" defer></script>
{{ end }}
{{ end }}

View File

@@ -9,9 +9,6 @@
<link href="/static/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-SgOJa3DmI69IUzQ2PVdRZhwQ+dy64/BUtbMJw1MZ8t5HZApcHrRKUc4W0kG879m7">
<script src="/static/js/bootstrap.bundle.min.js" integrity="sha384-k6d4wzSIapyDyv1kpU366/PK5hCdSbCRGRCMv+eplOQJWyd1fbcAu9OCUj5zNLiq"></script>
<script src="/static/js/leaflet.js"></script>
<link href="/static/css/leaflet.css" rel="stylesheet">
<script src="/static/js/openfsd/api.js"></script>
<title>openfsd - {{ template "title" . }}</title>