Add backend/internal/api/api.go

This commit is contained in:
Zac Gaetano 2026-03-31 15:29:57 -04:00
parent 59488d3c51
commit c5aa40f5a0

159
backend/internal/api/api.go Normal file
View 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})
}