Add backend/internal/api/api.go
This commit is contained in:
parent
59488d3c51
commit
c5aa40f5a0
1 changed files with 159 additions and 0 deletions
159
backend/internal/api/api.go
Normal file
159
backend/internal/api/api.go
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
// Package api exposes a REST API and serves the embedded React SPA.
|
||||
package api
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"moonrelay/internal/discovery"
|
||||
"moonrelay/internal/moonlight"
|
||||
tsnode "moonrelay/internal/tailscale"
|
||||
)
|
||||
|
||||
// Server is the HTTP API + SPA server.
|
||||
type Server struct {
|
||||
disc *discovery.Discoverer
|
||||
launcher *moonlight.Launcher
|
||||
tsNode *tsnode.Node
|
||||
router *gin.Engine
|
||||
}
|
||||
|
||||
// New creates and configures the server.
|
||||
// staticFS should be the embedded React build (nil = API-only mode for dev).
|
||||
func New(disc *discovery.Discoverer, launcher *moonlight.Launcher, ts *tsnode.Node, staticFS fs.FS) *Server {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
r := gin.Default()
|
||||
|
||||
s := &Server{
|
||||
disc: disc,
|
||||
launcher: launcher,
|
||||
tsNode: ts,
|
||||
router: r,
|
||||
}
|
||||
|
||||
// CORS for local dev (browser hitting :8080 from :1420 vite dev server)
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.Header("Access-Control-Allow-Origin", "*")
|
||||
c.Header("Access-Control-Allow-Methods", "GET,POST,OPTIONS")
|
||||
c.Header("Access-Control-Allow-Headers", "Content-Type")
|
||||
if c.Request.Method == "OPTIONS" {
|
||||
c.AbortWithStatus(204)
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
})
|
||||
|
||||
// REST API routes
|
||||
api := r.Group("/api")
|
||||
{
|
||||
api.GET("/status", s.getStatus)
|
||||
api.GET("/hosts", s.getHosts)
|
||||
api.POST("/hosts/manual", s.addManualHost)
|
||||
api.POST("/connect", s.connect)
|
||||
api.POST("/pair", s.pair)
|
||||
}
|
||||
|
||||
// Serve the embedded React SPA for all non-API routes
|
||||
if staticFS != nil {
|
||||
r.NoRoute(func(c *gin.Context) {
|
||||
// Try to serve the file from the embedded FS
|
||||
path := c.Request.URL.Path
|
||||
if path == "/" || path == "" {
|
||||
path = "/index.html"
|
||||
}
|
||||
// Strip leading slash for fs.FS
|
||||
fsPath := path[1:]
|
||||
f, err := staticFS.Open(fsPath)
|
||||
if err != nil {
|
||||
// Fall back to index.html for SPA client-side routing
|
||||
c.FileFromFS("index.html", http.FS(staticFS))
|
||||
return
|
||||
}
|
||||
f.Close()
|
||||
c.FileFromFS(fsPath, http.FS(staticFS))
|
||||
})
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
// Handler returns the underlying http.Handler.
|
||||
func (s *Server) Handler() http.Handler {
|
||||
return s.router
|
||||
}
|
||||
|
||||
// getStatus returns VPN and launcher status.
|
||||
func (s *Server) getStatus(c *gin.Context) {
|
||||
vpnAddr := ""
|
||||
vpnConnected := false
|
||||
if s.tsNode != nil {
|
||||
vpnAddr = s.tsNode.LocalAddr()
|
||||
vpnConnected = vpnAddr != ""
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"vpn_connected": vpnConnected,
|
||||
"vpn_address": vpnAddr,
|
||||
"moonlight_path": s.launcher.ExecPath(),
|
||||
})
|
||||
}
|
||||
|
||||
// getHosts returns all discovered streaming hosts.
|
||||
func (s *Server) getHosts(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, s.disc.Hosts())
|
||||
}
|
||||
|
||||
// addManualHost adds a host by IP for tailnet peers that don't multicast mDNS.
|
||||
func (s *Server) addManualHost(c *gin.Context) {
|
||||
var body struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
IP string `json:"ip" binding:"required"`
|
||||
Port int `json:"port"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if body.Port == 0 {
|
||||
body.Port = 47984 // Apollo default
|
||||
}
|
||||
s.disc.AddManual(body.Name, body.IP, body.Port)
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// connect launches a Moonlight stream to a host.
|
||||
// In Docker mode this sends back an rtsp:// URI the browser can hand off.
|
||||
func (s *Server) connect(c *gin.Context) {
|
||||
var body struct {
|
||||
IP string `json:"ip" binding:"required"`
|
||||
App string `json:"app"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if body.App == "" {
|
||||
body.App = "Desktop"
|
||||
}
|
||||
if err := s.launcher.Stream(body.IP, body.App); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// pair runs the Moonlight pair flow for a host.
|
||||
func (s *Server) pair(c *gin.Context) {
|
||||
var body struct {
|
||||
IP string `json:"ip" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := s.launcher.Pair(body.IP); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
Loading…
Reference in a new issue