feat: add Fiber HTTPS server with embedded static files

This commit is contained in:
2026-03-01 03:03:15 +08:00
parent 044a8aa166
commit 4ebc9226ed
4 changed files with 262 additions and 0 deletions

34
internal/server/net.go Normal file
View 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
View 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
View 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
View 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,
})
}