feat: add Fiber HTTPS server with embedded static files
This commit is contained in:
34
internal/server/net.go
Normal file
34
internal/server/net.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
)
|
||||
|
||||
// GetLANIP returns the first non-loopback IPv4 address.
|
||||
func GetLANIP() (string, error) {
|
||||
addrs, err := net.InterfaceAddrs()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, addr := range addrs {
|
||||
if ipNet, ok := addr.(*net.IPNet); ok && !ipNet.IP.IsLoopback() && ipNet.IP.To4() != nil {
|
||||
return ipNet.IP.String(), nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("no LAN IP found")
|
||||
}
|
||||
|
||||
// GenerateToken creates a cryptographically random token for WS auth.
|
||||
func GenerateToken() string {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
slog.Error("failed to generate token", "error", err)
|
||||
// Fallback to a less secure but functional token
|
||||
return "voicepaste-fallback-token"
|
||||
}
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
91
internal/server/server.go
Normal file
91
internal/server/server.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
"github.com/gofiber/fiber/v3/middleware/static"
|
||||
|
||||
"github.com/imbytecat/voicepaste/internal/config"
|
||||
vpTLS "github.com/imbytecat/voicepaste/internal/tls"
|
||||
)
|
||||
|
||||
// Server holds the Fiber app and related state.
|
||||
type Server struct {
|
||||
app *fiber.App
|
||||
token string
|
||||
lanIP string
|
||||
webFS fs.FS
|
||||
}
|
||||
|
||||
// New creates a new Server instance.
|
||||
func New(token, lanIP string, webFS fs.FS) *Server {
|
||||
app := fiber.New(fiber.Config{
|
||||
AppName: "VoicePaste",
|
||||
})
|
||||
|
||||
s := &Server{
|
||||
app: app,
|
||||
token: token,
|
||||
lanIP: lanIP,
|
||||
webFS: webFS,
|
||||
}
|
||||
|
||||
s.setupRoutes()
|
||||
return s
|
||||
}
|
||||
|
||||
// setupRoutes configures HTTP routes.
|
||||
func (s *Server) setupRoutes() {
|
||||
// Health check
|
||||
s.app.Get("/health", func(c fiber.Ctx) error {
|
||||
return c.SendString("ok")
|
||||
})
|
||||
|
||||
// Static files from embedded FS
|
||||
s.app.Use("/", static.New("", static.Config{
|
||||
FS: s.webFS,
|
||||
Browse: false,
|
||||
}))
|
||||
}
|
||||
|
||||
// App returns the underlying Fiber app for WebSocket route registration.
|
||||
func (s *Server) App() *fiber.App {
|
||||
return s.app
|
||||
}
|
||||
|
||||
// Token returns the auth token.
|
||||
func (s *Server) Token() string {
|
||||
return s.token
|
||||
}
|
||||
|
||||
// Start starts the HTTPS server.
|
||||
func (s *Server) Start() error {
|
||||
cfg := config.Get()
|
||||
addr := fmt.Sprintf(":%d", cfg.Server.Port)
|
||||
|
||||
if cfg.Server.TLSAuto {
|
||||
tlsCfg, err := vpTLS.GetTLSConfig(s.lanIP)
|
||||
if err != nil {
|
||||
return fmt.Errorf("TLS setup failed: %w", err)
|
||||
}
|
||||
slog.Info("starting HTTPS server", "addr", addr)
|
||||
return s.app.Listen(addr, fiber.ListenConfig{
|
||||
TLSConfig: &tls.Config{
|
||||
Certificates: tlsCfg.Certificates,
|
||||
MinVersion: tls.VersionTLS12,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
slog.Info("starting HTTP server (no TLS)", "addr", addr)
|
||||
return s.app.Listen(addr)
|
||||
}
|
||||
|
||||
// Shutdown gracefully shuts down the server.
|
||||
func (s *Server) Shutdown() error {
|
||||
return s.app.Shutdown()
|
||||
}
|
||||
119
main.go
Normal file
119
main.go
Normal file
@@ -0,0 +1,119 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"github.com/imbytecat/voicepaste/internal/asr"
|
||||
"github.com/imbytecat/voicepaste/internal/config"
|
||||
"github.com/imbytecat/voicepaste/internal/paste"
|
||||
"github.com/imbytecat/voicepaste/internal/server"
|
||||
"github.com/imbytecat/voicepaste/internal/ws"
|
||||
)
|
||||
|
||||
//go:embed all:web
|
||||
var webFS embed.FS
|
||||
var version = "dev"
|
||||
|
||||
func main() {
|
||||
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
|
||||
Level: slog.LevelInfo,
|
||||
})))
|
||||
|
||||
slog.Info("VoicePaste", "version", version)
|
||||
|
||||
// Load config
|
||||
cfg, err := config.Load("")
|
||||
if err != nil {
|
||||
slog.Error("failed to load config", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Start config hot-reload watcher
|
||||
config.WatchAndReload("")
|
||||
|
||||
// Initialize clipboard
|
||||
if err := paste.Init(); err != nil {
|
||||
slog.Warn("clipboard init failed, paste will be unavailable", "err", err)
|
||||
}
|
||||
// Detect LAN IP
|
||||
lanIP, err := server.GetLANIP()
|
||||
if err != nil {
|
||||
slog.Error("failed to detect LAN IP", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Generate auth token
|
||||
token := server.GenerateToken()
|
||||
|
||||
// Build URL
|
||||
scheme := "https"
|
||||
if !cfg.Server.TLSAuto {
|
||||
scheme = "http"
|
||||
}
|
||||
url := fmt.Sprintf("%s://%s:%d/?token=%s", scheme, lanIP, cfg.Server.Port, token)
|
||||
|
||||
// Print connection info
|
||||
fmt.Println()
|
||||
fmt.Println("╔══════════════════════════════════════╗")
|
||||
fmt.Println("║ VoicePaste Ready ║")
|
||||
fmt.Println("╚══════════════════════════════════════╝")
|
||||
fmt.Println()
|
||||
fmt.Printf(" URL: %s\n", url)
|
||||
fmt.Println()
|
||||
printQRCode(url)
|
||||
fmt.Println()
|
||||
fmt.Println(" Scan QR code with your phone to connect.")
|
||||
fmt.Println(" Press Ctrl+C to stop.")
|
||||
fmt.Println()
|
||||
|
||||
// Create and start server
|
||||
webContent, _ := fs.Sub(webFS, "web")
|
||||
srv := server.New(token, lanIP, webContent)
|
||||
|
||||
// Build ASR factory from config
|
||||
asrCfg := asr.Config{
|
||||
AppKey: cfg.Doubao.AppKey,
|
||||
AccessKey: cfg.Doubao.AccessKey,
|
||||
ResourceID: cfg.Doubao.ResourceID,
|
||||
}
|
||||
asrFactory := func(resultCh chan<- ws.ServerMsg) (func([]byte), func(), error) {
|
||||
client, err := asr.Dial(asrCfg, resultCh)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
sendAudio := func(pcm []byte) {
|
||||
if err := client.SendAudio(pcm, false); err != nil {
|
||||
slog.Warn("send audio to asr", "err", err)
|
||||
}
|
||||
}
|
||||
cleanup := func() {
|
||||
// Send last empty frame to signal end
|
||||
_ = client.SendAudio(nil, true)
|
||||
client.Close()
|
||||
}
|
||||
return sendAudio, cleanup, nil
|
||||
}
|
||||
|
||||
// Register WebSocket handler
|
||||
wsHandler := ws.NewHandler(token, paste.Paste, asrFactory)
|
||||
wsHandler.Register(srv.App())
|
||||
|
||||
// Graceful shutdown
|
||||
go func() {
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-sigCh
|
||||
slog.Info("shutting down...")
|
||||
srv.Shutdown()
|
||||
}()
|
||||
|
||||
if err := srv.Start(); err != nil {
|
||||
slog.Error("server error", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
18
qrcode.go
Normal file
18
qrcode.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/mdp/qrterminal/v3"
|
||||
)
|
||||
|
||||
// printQRCode prints a QR code to the terminal.
|
||||
func printQRCode(url string) {
|
||||
qrterminal.GenerateWithConfig(url, qrterminal.Config{
|
||||
Level: qrterminal.L,
|
||||
Writer: os.Stdout,
|
||||
BlackChar: qrterminal.BLACK,
|
||||
WhiteChar: qrterminal.WHITE,
|
||||
QuietZone: 2,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user