From 4ebc9226ed5c569f999ff5a287a4d22a7a7c44e6 Mon Sep 17 00:00:00 2001 From: imbytecat Date: Sun, 1 Mar 2026 03:03:15 +0800 Subject: [PATCH] feat: add Fiber HTTPS server with embedded static files --- internal/server/net.go | 34 +++++++++++ internal/server/server.go | 91 +++++++++++++++++++++++++++++ main.go | 119 ++++++++++++++++++++++++++++++++++++++ qrcode.go | 18 ++++++ 4 files changed, 262 insertions(+) create mode 100644 internal/server/net.go create mode 100644 internal/server/server.go create mode 100644 main.go create mode 100644 qrcode.go diff --git a/internal/server/net.go b/internal/server/net.go new file mode 100644 index 0000000..21bd66f --- /dev/null +++ b/internal/server/net.go @@ -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) +} diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..90d5a18 --- /dev/null +++ b/internal/server/server.go @@ -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() +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..e179e17 --- /dev/null +++ b/main.go @@ -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) + } +} \ No newline at end of file diff --git a/qrcode.go b/qrcode.go new file mode 100644 index 0000000..c4ed0b6 --- /dev/null +++ b/qrcode.go @@ -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, + }) +}