mirror of
https://github.com/renorris/openfsd
synced 2026-03-22 06:25:35 +08:00
add kick user web interface
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
11
web/auth.go
11
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()
|
||||
}
|
||||
|
||||
58
web/data.go
58
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)
|
||||
|
||||
57
web/fsdconn.go
Normal file
57
web/fsdconn.go
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
85
web/static/js/openfsd/leaflet.rotatedmarker.js
Normal file
85
web/static/js/openfsd/leaflet.rotatedmarker.js
Normal 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;
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user