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