diff --git a/fsd/http_service.go b/fsd/http_service.go
index f54dbac..ff94976 100644
--- a/fsd/http_service.go
+++ b/fsd/http_service.go
@@ -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)
+}
diff --git a/web/auth.go b/web/auth.go
index 6ad4ab5..c892f48 100644
--- a/web/auth.go
+++ b/web/auth.go
@@ -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()
}
diff --git a/web/data.go b/web/data.go
index a08076c..d977d38 100644
--- a/web/data.go
+++ b/web/data.go
@@ -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)
diff --git a/web/fsdconn.go b/web/fsdconn.go
new file mode 100644
index 0000000..2c8c7ab
--- /dev/null
+++ b/web/fsdconn.go
@@ -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
+ }
+}
diff --git a/web/routes.go b/web/routes.go
index a71bac4..9aac656 100644
--- a/web/routes.go
+++ b/web/routes.go
@@ -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)
diff --git a/web/static/js/openfsd/dashboard.js b/web/static/js/openfsd/dashboard.js
index d1030a8..32c2ced 100644
--- a/web/static/js/openfsd/dashboard.js
+++ b/web/static/js/openfsd/dashboard.js
@@ -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(`Callsign: ${callsign}
${name}
${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 = `Callsign: ${callsign}
${name}
${lat} ${lon}`;
+ if (userNetworkRating >= 11) {
+ popupContent += `
`;
}
+ 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);
}
-}
+}
\ No newline at end of file
diff --git a/web/static/js/openfsd/leaflet.rotatedmarker.js b/web/static/js/openfsd/leaflet.rotatedmarker.js
new file mode 100644
index 0000000..b08b416
--- /dev/null
+++ b/web/static/js/openfsd/leaflet.rotatedmarker.js
@@ -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;
+ }
+ });
+})();
\ No newline at end of file
diff --git a/web/templates/dashboard.html b/web/templates/dashboard.html
index e9b1e55..7378a97 100644
--- a/web/templates/dashboard.html
+++ b/web/templates/dashboard.html
@@ -15,5 +15,9 @@
+
+
+
+
-{{ end }}
\ No newline at end of file
+{{ end }}
diff --git a/web/templates/layout.html b/web/templates/layout.html
index aa22455..0ed442e 100644
--- a/web/templates/layout.html
+++ b/web/templates/layout.html
@@ -9,9 +9,6 @@
-
-
-