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 @@ - - - openfsd - {{ template "title" . }}