Compare commits
1 Commits
163f5641e5
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
442a1e2f9f
|
@@ -63,3 +63,12 @@ BANDWIDTH_BPS=209715
|
|||||||
# Default: 100
|
# Default: 100
|
||||||
# Example: RATE_LIMIT_RPS=50 (stricter, for constrained environments)
|
# Example: RATE_LIMIT_RPS=50 (stricter, for constrained environments)
|
||||||
RATE_LIMIT_RPS=100
|
RATE_LIMIT_RPS=100
|
||||||
|
|
||||||
|
# ── Web Admin Panel ───────────────────────────────────────
|
||||||
|
|
||||||
|
# Whether the Go server should serve the built SPA frontend from ./web/dist.
|
||||||
|
# Set to false when using Caddy, Nginx, or another reverse proxy to serve
|
||||||
|
# the frontend and proxy API requests to this server.
|
||||||
|
# Default: true
|
||||||
|
# Example: SERVE_SPA=false (frontend served by external web server)
|
||||||
|
SERVE_SPA=true
|
||||||
|
|||||||
+172
-154
@@ -1,187 +1,205 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"log"
|
"errors"
|
||||||
"net/http"
|
"log"
|
||||||
"os"
|
"net/http"
|
||||||
"os/signal"
|
"os"
|
||||||
"syscall"
|
"os/signal"
|
||||||
"time"
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
"onixbyte.com/pipely/internal/artefact"
|
"onixbyte.com/pipely/internal/artefact"
|
||||||
"onixbyte.com/pipely/internal/auth"
|
"onixbyte.com/pipely/internal/auth"
|
||||||
"onixbyte.com/pipely/internal/config"
|
"onixbyte.com/pipely/internal/config"
|
||||||
"onixbyte.com/pipely/internal/database"
|
"onixbyte.com/pipely/internal/database"
|
||||||
"onixbyte.com/pipely/internal/delta"
|
"onixbyte.com/pipely/internal/delta"
|
||||||
"onixbyte.com/pipely/internal/deployment"
|
"onixbyte.com/pipely/internal/deployment"
|
||||||
"onixbyte.com/pipely/internal/device"
|
"onixbyte.com/pipely/internal/device"
|
||||||
"onixbyte.com/pipely/internal/download"
|
"onixbyte.com/pipely/internal/download"
|
||||||
"onixbyte.com/pipely/internal/middleware"
|
"onixbyte.com/pipely/internal/middleware"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
cfg := config.Load()
|
cfg := config.Load()
|
||||||
|
|
||||||
// Connect to PostgreSQL (auto-creates database if needed)
|
// Connect to PostgreSQL (auto-creates database if needed)
|
||||||
db, err := database.Connect(cfg.DatabaseURL)
|
db, err := database.Connect(cfg.DatabaseURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Database connection failed: %v", err)
|
log.Fatalf("Database connection failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-migrate tables
|
// Auto-migrate tables
|
||||||
if err := database.AutoMigrate(db); err != nil {
|
if err := database.AutoMigrate(db); err != nil {
|
||||||
log.Fatalf("Auto-migrate failed: %v", err)
|
log.Fatalf("Auto-migrate failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialise auth service and create default admin
|
// Initialise auth service and create default admin
|
||||||
authSvc := auth.NewService(db, []byte(cfg.JWTSecret), cfg.JWTExpiryHrs)
|
authSvc := auth.NewService(db, []byte(cfg.JWTSecret), cfg.JWTExpiryHrs)
|
||||||
if err := authSvc.CreateInitialAdmin("admin", "admin"); err != nil {
|
if err := authSvc.CreateInitialAdmin("admin", "admin"); err != nil {
|
||||||
log.Printf("Warning: failed to create initial admin: %v", err)
|
log.Printf("Warning: failed to create initial admin: %v", err)
|
||||||
}
|
}
|
||||||
log.Println("[main] Default admin user ready (admin / admin)")
|
log.Println("[main] Default admin user ready (admin / admin)")
|
||||||
|
|
||||||
// Ensure artefact storage directory exists
|
// Ensure artefact storage directory exists
|
||||||
if err := os.MkdirAll(cfg.ArtefactDir, 0755); err != nil {
|
if err := os.MkdirAll(cfg.ArtefactDir, 0755); err != nil {
|
||||||
log.Fatalf("Failed to create artefact directory: %v", err)
|
log.Fatalf("Failed to create artefact directory: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Global rate limiter
|
// Global rate limiter
|
||||||
tokenBucket := middleware.NewTokenBucket(cfg.RateLimitRPS, cfg.RateLimitRPS*2)
|
tokenBucket := middleware.NewTokenBucket(cfg.RateLimitRPS, cfg.RateLimitRPS*2)
|
||||||
go func() {
|
go func() {
|
||||||
ticker := time.NewTicker(10 * time.Minute)
|
ticker := time.NewTicker(10 * time.Minute)
|
||||||
for range ticker.C {
|
for range ticker.C {
|
||||||
tokenBucket.Cleanup(30 * time.Minute)
|
tokenBucket.Cleanup(30 * time.Minute)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Gated rollout semaphore
|
// Gated rollout semaphore
|
||||||
downloadGate := middleware.NewGatedSemaphore(cfg.MaxConcurrent)
|
downloadGate := middleware.NewGatedSemaphore(cfg.MaxConcurrent)
|
||||||
|
|
||||||
// Offline device detection
|
// Offline device detection
|
||||||
go func() {
|
go func() {
|
||||||
ticker := time.NewTicker(5 * time.Minute)
|
ticker := time.NewTicker(5 * time.Minute)
|
||||||
for range ticker.C {
|
for range ticker.C {
|
||||||
offline, err := database.GetOfflineDevices(db, 10*time.Minute)
|
offline, err := database.GetOfflineDevices(db, 10*time.Minute)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[health] Offline check failed: %v", err)
|
log.Printf("[health] Offline check failed: %v", err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
ids := make([]string, len(offline))
|
ids := make([]string, len(offline))
|
||||||
for i, d := range offline {
|
for i, d := range offline {
|
||||||
ids[i] = d.DeviceID
|
ids[i] = d.DeviceID
|
||||||
}
|
}
|
||||||
if len(ids) > 0 {
|
if len(ids) > 0 {
|
||||||
if err := database.MarkDevicesOffline(db, ids); err != nil {
|
if err := database.MarkDevicesOffline(db, ids); err != nil {
|
||||||
log.Printf("[health] Mark offline failed: %v", err)
|
log.Printf("[health] Mark offline failed: %v", err)
|
||||||
} else {
|
} else {
|
||||||
log.Printf("[health] Marked %d devices as offline", len(ids))
|
log.Printf("[health] Marked %d devices as offline", len(ids))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Initialise handlers
|
// Initialise handlers
|
||||||
authHandler := auth.NewHandler(authSvc)
|
authHandler := auth.NewHandler(authSvc)
|
||||||
deviceHandler := device.NewHandler(db)
|
deviceHandler := device.NewHandler(db)
|
||||||
artefactHandler := artefact.NewHandler(db, cfg.ArtefactDir)
|
artefactHandler := artefact.NewHandler(db, cfg.ArtefactDir)
|
||||||
deploymentHandler := deployment.NewHandler(db)
|
deploymentHandler := deployment.NewHandler(db)
|
||||||
deltaHandler := delta.NewHandler(cfg.ArtefactDir)
|
deltaHandler := delta.NewHandler(cfg.ArtefactDir)
|
||||||
downloadHandler := download.NewHandler(db, cfg.ArtefactDir, downloadGate, cfg.BandwidthBPS)
|
downloadHandler := download.NewHandler(db, cfg.ArtefactDir, downloadGate, cfg.BandwidthBPS)
|
||||||
|
|
||||||
// Setup Gin
|
// Setup Gin
|
||||||
gin.SetMode(gin.ReleaseMode)
|
gin.SetMode(gin.ReleaseMode)
|
||||||
r := gin.New()
|
r := gin.New()
|
||||||
r.Use(gin.Recovery())
|
r.Use(gin.Recovery())
|
||||||
|
|
||||||
// CORS
|
// CORS — withCredentials requires explicit origin, not wildcard
|
||||||
r.Use(func(c *gin.Context) {
|
r.Use(func(c *gin.Context) {
|
||||||
c.Header("Access-Control-Allow-Origin", "*")
|
origin := c.GetHeader("Origin")
|
||||||
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
|
if origin != "" {
|
||||||
c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Accept, Authorization, Range")
|
c.Header("Access-Control-Allow-Origin", origin)
|
||||||
if c.Request.Method == "OPTIONS" {
|
c.Header("Access-Control-Allow-Credentials", "true")
|
||||||
c.AbortWithStatus(http.StatusNoContent)
|
} else {
|
||||||
return
|
c.Header("Access-Control-Allow-Origin", "*")
|
||||||
}
|
}
|
||||||
c.Next()
|
c.Header("Vary", "Origin")
|
||||||
})
|
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
|
||||||
|
c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Accept, Authorization, Range")
|
||||||
|
if c.Request.Method == "OPTIONS" {
|
||||||
|
c.AbortWithStatus(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
})
|
||||||
|
|
||||||
// -- Public routes (daemon-facing) --
|
// -- Public routes (daemon-facing) --
|
||||||
api := r.Group("/api/v1")
|
api := r.Group("/api")
|
||||||
{
|
{
|
||||||
api.POST("/devices/register", deviceHandler.Register)
|
api.POST("/devices/register", deviceHandler.Register)
|
||||||
api.POST("/devices/heartbeat", deviceHandler.Heartbeat)
|
api.POST("/devices/heartbeat", deviceHandler.Heartbeat)
|
||||||
api.GET("/devices/check-update", deviceHandler.CheckUpdate)
|
api.GET("/devices/check-update", deviceHandler.CheckUpdate)
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- Authenticated admin routes --
|
// -- Public auth routes --
|
||||||
admin := api.Group("")
|
api.POST("/auth/login", authHandler.Login)
|
||||||
admin.Use(middleware.AuthMiddleware(authSvc))
|
|
||||||
{
|
|
||||||
api.POST("/auth/login", authHandler.Login)
|
|
||||||
|
|
||||||
admin.GET("/devices", deviceHandler.List)
|
// -- Authenticated admin routes --
|
||||||
admin.GET("/devices/:deviceId", deviceHandler.Get)
|
admin := api.Group("")
|
||||||
|
admin.Use(middleware.AuthMiddleware(authSvc))
|
||||||
|
{
|
||||||
|
api.POST("/auth/logout", authHandler.Logout)
|
||||||
|
|
||||||
admin.POST("/artefacts", artefactHandler.Upload)
|
admin.GET("/devices", deviceHandler.List)
|
||||||
admin.GET("/artefacts", artefactHandler.List)
|
admin.GET("/devices/:deviceId", deviceHandler.Get)
|
||||||
admin.GET("/artefacts/:id", artefactHandler.Get)
|
|
||||||
|
|
||||||
admin.POST("/delta/generate", deltaHandler.GenerateDelta)
|
admin.POST("/artefacts", artefactHandler.Upload)
|
||||||
|
admin.GET("/artefacts", artefactHandler.List)
|
||||||
|
admin.GET("/artefacts/:id", artefactHandler.Get)
|
||||||
|
|
||||||
admin.POST("/deployments", deploymentHandler.Create)
|
admin.POST("/delta/generate", deltaHandler.GenerateDelta)
|
||||||
admin.GET("/deployments", deploymentHandler.List)
|
|
||||||
admin.GET("/deployments/:id", deploymentHandler.Get)
|
|
||||||
admin.POST("/deployments/:id/activate", deploymentHandler.Activate)
|
|
||||||
admin.POST("/deployments/:id/pause", deploymentHandler.Pause)
|
|
||||||
admin.GET("/deployments/:id/devices", deploymentHandler.ListDevices)
|
|
||||||
admin.POST("/deployments/:id/rollback", deploymentHandler.Rollback)
|
|
||||||
|
|
||||||
admin.GET("/admin/users", authHandler.ListUsers)
|
admin.POST("/deployments", deploymentHandler.Create)
|
||||||
admin.POST("/admin/users", authHandler.CreateUser)
|
admin.GET("/deployments", deploymentHandler.List)
|
||||||
admin.DELETE("/admin/users/:id", authHandler.DeleteUser)
|
admin.GET("/deployments/:id", deploymentHandler.Get)
|
||||||
admin.PUT("/admin/users/:id/password", authHandler.ChangePassword)
|
admin.POST("/deployments/:id/activate", deploymentHandler.Activate)
|
||||||
}
|
admin.POST("/deployments/:id/pause", deploymentHandler.Pause)
|
||||||
|
admin.GET("/deployments/:id/devices", deploymentHandler.ListDevices)
|
||||||
|
admin.POST("/deployments/:id/rollback", deploymentHandler.Rollback)
|
||||||
|
|
||||||
// -- Download endpoint (rate-limited + gated) --
|
admin.GET("/admin/users", authHandler.ListUsers)
|
||||||
packages := api.Group("/packages")
|
admin.POST("/admin/users", authHandler.CreateUser)
|
||||||
packages.Use(middleware.RateLimitMiddleware(tokenBucket))
|
admin.DELETE("/admin/users/:id", authHandler.DeleteUser)
|
||||||
packages.Use(middleware.GatedMiddleware(downloadGate))
|
admin.PUT("/admin/users/:id/password", authHandler.ChangePassword)
|
||||||
{
|
}
|
||||||
packages.GET("/download/:id", downloadHandler.HandleDownload)
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Device status update --
|
// -- Download endpoint (rate-limited + gated) --
|
||||||
api.POST("/deployments/:id/status", deploymentHandler.UpdateStatus)
|
packages := api.Group("/packages")
|
||||||
|
packages.Use(middleware.RateLimitMiddleware(tokenBucket))
|
||||||
|
packages.Use(middleware.GatedMiddleware(downloadGate))
|
||||||
|
{
|
||||||
|
packages.GET("/download/:id", downloadHandler.HandleDownload)
|
||||||
|
}
|
||||||
|
|
||||||
// -- Static admin panel (served at root; API routes take precedence) --
|
// -- Device status update --
|
||||||
r.StaticFS("/", http.Dir("./web/dist"))
|
api.POST("/deployments/:id/status", deploymentHandler.UpdateStatus)
|
||||||
|
|
||||||
// Graceful shutdown
|
// -- Static admin panel (SPA fallback) --
|
||||||
srv := &http.Server{
|
// Enable with SERVE_SPA=true (default). Set to false when using Caddy/Nginx as reverse proxy.
|
||||||
Addr: ":" + cfg.ServerPort,
|
if cfg.ServeSPA {
|
||||||
Handler: r,
|
r.Static("/assets", "./web/dist/assets")
|
||||||
}
|
r.GET("/favicon.ico", func(c *gin.Context) { c.File("./web/dist/favicon.ico") })
|
||||||
|
r.NoRoute(func(c *gin.Context) {
|
||||||
|
c.File("./web/dist/index.html")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
go func() {
|
// Graceful shutdown
|
||||||
log.Printf("[main] OTA Manifest Server listening on :%s", cfg.ServerPort)
|
srv := &http.Server{
|
||||||
log.Printf("[main] Max concurrent downloads: %d, bandwidth per stream: %d B/s", cfg.MaxConcurrent, cfg.BandwidthBPS)
|
Addr: ":" + cfg.ServerPort,
|
||||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
Handler: r,
|
||||||
log.Fatalf("Server error: %v", err)
|
}
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
quit := make(chan os.Signal, 1)
|
go func() {
|
||||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
log.Printf("[main] Pipely listening on port :%s", cfg.ServerPort)
|
||||||
<-quit
|
log.Printf("[main] Max concurrent downloads: %d, bandwidth per stream: %d B/s", cfg.MaxConcurrent, cfg.BandwidthBPS)
|
||||||
log.Println("[main] Shutting down...")
|
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
|
log.Fatalf("Server error: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
quit := make(chan os.Signal, 1)
|
||||||
defer cancel()
|
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||||
if err := srv.Shutdown(ctx); err != nil {
|
<-quit
|
||||||
log.Fatalf("Forced shutdown: %v", err)
|
log.Println("[main] Shutting down...")
|
||||||
}
|
|
||||||
log.Println("[main] Server stopped")
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := srv.Shutdown(ctx); err != nil {
|
||||||
|
log.Fatalf("Forced shutdown: %v", err)
|
||||||
|
}
|
||||||
|
log.Println("[main] Server stopped")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,5 +28,16 @@ func (h *Handler) Login(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, resp)
|
// Set JWT as HttpOnly cookie so the frontend doesn't need to manage it.
|
||||||
|
// The browser sends it automatically on every request.
|
||||||
|
maxAge := h.svc.ExpiryHrs() * 3600
|
||||||
|
c.SetCookie("pipely_token", resp.Token, maxAge, "/", "", false, true)
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Login successful"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) Logout(c *gin.Context) {
|
||||||
|
// Clear the auth cookie by setting MaxAge=-1
|
||||||
|
c.SetCookie("pipely_token", "", -1, "/", "", false, true)
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Logged out"})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ func NewService(db *gorm.DB, secret []byte, expiryHrs int) *Service {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) ExpiryHrs() int {
|
||||||
|
return int(s.expiry.Hours())
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) Login(username, password string) (*model.LoginResponse, error) {
|
func (s *Service) Login(username, password string) (*model.LoginResponse, error) {
|
||||||
user, err := database.GetAdminUserByUsername(s.db, username)
|
user, err := database.GetAdminUserByUsername(s.db, username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ type Config struct {
|
|||||||
MaxConcurrent int
|
MaxConcurrent int
|
||||||
BandwidthBPS int
|
BandwidthBPS int
|
||||||
RateLimitRPS int
|
RateLimitRPS int
|
||||||
|
ServeSPA bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load() *Config {
|
func Load() *Config {
|
||||||
@@ -34,6 +35,7 @@ func Load() *Config {
|
|||||||
MaxConcurrent: envOrDefaultInt("MAX_CONCURRENT_DOWNLOADS", 50),
|
MaxConcurrent: envOrDefaultInt("MAX_CONCURRENT_DOWNLOADS", 50),
|
||||||
BandwidthBPS: envOrDefaultInt("BANDWIDTH_BPS", 10*1024*1024/50),
|
BandwidthBPS: envOrDefaultInt("BANDWIDTH_BPS", 10*1024*1024/50),
|
||||||
RateLimitRPS: envOrDefaultInt("RATE_LIMIT_RPS", 100),
|
RateLimitRPS: envOrDefaultInt("RATE_LIMIT_RPS", 100),
|
||||||
|
ServeSPA: envOrDefaultBool("SERVE_SPA", true),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,3 +54,12 @@ func envOrDefaultInt(key string, def int) int {
|
|||||||
}
|
}
|
||||||
return def
|
return def
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func envOrDefaultBool(key string, def bool) bool {
|
||||||
|
if v := os.Getenv(key); v != "" {
|
||||||
|
if b, err := strconv.ParseBool(v); err == nil {
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|||||||
+22
-10
@@ -10,19 +10,13 @@ import (
|
|||||||
|
|
||||||
func AuthMiddleware(authSvc *auth.Service) gin.HandlerFunc {
|
func AuthMiddleware(authSvc *auth.Service) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
authHeader := c.GetHeader("Authorization")
|
token := extractToken(c)
|
||||||
if authHeader == "" {
|
if token == "" {
|
||||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Authorisation header is missing"})
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Authorisation token is missing"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
parts := strings.SplitN(authHeader, " ", 2)
|
user, err := authSvc.ValidateToken(token)
|
||||||
if !(len(parts) == 2 && parts[0] == "Bearer") {
|
|
||||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid authorisation format"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
user, err := authSvc.ValidateToken(parts[1])
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unauthorised: " + err.Error()})
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unauthorised: " + err.Error()})
|
||||||
return
|
return
|
||||||
@@ -32,3 +26,21 @@ func AuthMiddleware(authSvc *auth.Service) gin.HandlerFunc {
|
|||||||
c.Next()
|
c.Next()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func extractToken(c *gin.Context) string {
|
||||||
|
// First try the Authorization header
|
||||||
|
authHeader := c.GetHeader("Authorization")
|
||||||
|
if authHeader != "" {
|
||||||
|
parts := strings.SplitN(authHeader, " ", 2)
|
||||||
|
if len(parts) == 2 && parts[0] == "Bearer" {
|
||||||
|
return parts[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to the cookie set at login
|
||||||
|
if cookie, err := c.Cookie("pipely_token"); err == nil {
|
||||||
|
return cookie
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# ─────────────────────────────────────────────────────────
|
||||||
|
# Pipely Web — Environment Variables
|
||||||
|
# ─────────────────────────────────────────────────────────
|
||||||
|
# Copy this file to .env and adjust values for your environment:
|
||||||
|
# cp .env.example .env
|
||||||
|
# ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# ── API Server ───────────────────────────────────────────
|
||||||
|
|
||||||
|
# Base URL for the OTA Manifest Server API.
|
||||||
|
# Default: https://pipely.onixbyte.com/
|
||||||
|
# Development: http://localhost:8080
|
||||||
|
VITE_API_BASE_URL=http://localhost:5001/api
|
||||||
|
|
||||||
|
# Redux Persistent storage location
|
||||||
|
# Options: `local` (default), `session`
|
||||||
|
VITE_REDUX_STORAGE=local
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
import WebClient from "@/shared/web-client"
|
import WebClient from "@/shared/web-client"
|
||||||
|
|
||||||
export interface Artefact {
|
export interface Artefact {
|
||||||
id: string
|
id: number
|
||||||
name: string
|
|
||||||
version: string
|
version: string
|
||||||
checksum: string
|
fileName: string
|
||||||
size: number
|
fileSize: number
|
||||||
|
sha256Hash: string
|
||||||
|
isDelta: boolean
|
||||||
|
deltaRef: string
|
||||||
createdAt: string
|
createdAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,11 +35,17 @@ export async function getArtefacts(query?: ArtefactsQuery): Promise<ArtefactsRes
|
|||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function uploadArtefact(file: File): Promise<Artefact> {
|
export async function uploadArtefact(version: string, file: File): Promise<Artefact> {
|
||||||
const { data } = await WebClient.post<Artefact>("/artefacts/upload", file)
|
const form = new FormData()
|
||||||
|
form.append("version", version)
|
||||||
|
form.append("file", file)
|
||||||
|
|
||||||
|
const { data } = await WebClient.post<Artefact>("/artefacts", form, {
|
||||||
|
headers: { "Content-Type": "multipart/form-data" },
|
||||||
|
})
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteArtefact(id: string): Promise<void> {
|
export async function deleteArtefact(id: number): Promise<void> {
|
||||||
await WebClient.delete<void>(`/artefacts/${id}`)
|
await WebClient.delete(`/artefacts/${id}`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,11 +5,10 @@ export interface LoginRequest {
|
|||||||
password: string
|
password: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LoginResponse {
|
export async function login(credentials: LoginRequest): Promise<void> {
|
||||||
token: string
|
await WebClient.post("/auth/login", credentials)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function login(credentials: LoginRequest): Promise<LoginResponse> {
|
export async function logout(): Promise<void> {
|
||||||
const { data } = await WebClient.post<LoginResponse>("/auth/login", credentials)
|
await WebClient.post("/auth/logout")
|
||||||
return data
|
}
|
||||||
}
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import { NavLink, useNavigate, Outlet } from "react-router-dom"
|
import { NavLink, useNavigate, Outlet } from "react-router-dom"
|
||||||
import { useDispatch } from "react-redux"
|
import { useDispatch } from "react-redux"
|
||||||
import { setToken } from "@/store/auth-slice"
|
|
||||||
import {
|
import {
|
||||||
Drawer,
|
Drawer,
|
||||||
List,
|
List,
|
||||||
@@ -21,6 +20,8 @@ import {
|
|||||||
People,
|
People,
|
||||||
Logout,
|
Logout,
|
||||||
} from "@mui/icons-material"
|
} from "@mui/icons-material"
|
||||||
|
import { logoutAction } from "@/store/auth-slice"
|
||||||
|
import { logout } from "@/api/auth"
|
||||||
|
|
||||||
const drawerWidth = 240
|
const drawerWidth = 240
|
||||||
|
|
||||||
@@ -28,8 +29,9 @@ export default function Layout() {
|
|||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const dispatch = useDispatch()
|
const dispatch = useDispatch()
|
||||||
|
|
||||||
function handleLogout() {
|
async function handleLogout() {
|
||||||
dispatch(setToken(""))
|
dispatch(logoutAction())
|
||||||
|
await logout().catch(() => {})
|
||||||
navigate("/")
|
navigate("/")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,7 +52,7 @@ export default function Layout() {
|
|||||||
}}>
|
}}>
|
||||||
<Toolbar>
|
<Toolbar>
|
||||||
<Typography variant="h6" noWrap component="div">
|
<Typography variant="h6" noWrap component="div">
|
||||||
OTA Manager
|
Pipely
|
||||||
</Typography>
|
</Typography>
|
||||||
</Toolbar>
|
</Toolbar>
|
||||||
</AppBar>
|
</AppBar>
|
||||||
|
|||||||
+10
-10
@@ -1,5 +1,4 @@
|
|||||||
import { useEffect, useState, type MouseEvent } from "react"
|
import { useEffect, useState, type MouseEvent } from "react"
|
||||||
import { getArtefacts, uploadArtefact, deleteArtefact, type Artefact } from "@/api/artefacts"
|
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
@@ -24,6 +23,7 @@ import {
|
|||||||
Pagination,
|
Pagination,
|
||||||
} from "@mui/material"
|
} from "@mui/material"
|
||||||
import { Upload, Delete, Archive } from "@mui/icons-material"
|
import { Upload, Delete, Archive } from "@mui/icons-material"
|
||||||
|
import { getArtefacts, uploadArtefact, deleteArtefact, type Artefact } from "@/api/artefacts"
|
||||||
|
|
||||||
const PAGE_SIZE = 20
|
const PAGE_SIZE = 20
|
||||||
|
|
||||||
@@ -34,7 +34,7 @@ export default function Artefacts() {
|
|||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [dialogOpen, setDialogOpen] = useState(false)
|
const [dialogOpen, setDialogOpen] = useState(false)
|
||||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
||||||
const [deleteTarget, setDeleteTarget] = useState<{ id: string; version: string } | null>(null)
|
const [deleteTarget, setDeleteTarget] = useState<{ id: number; version: string } | null>(null)
|
||||||
const [uploading, setUploading] = useState(false)
|
const [uploading, setUploading] = useState(false)
|
||||||
const [version, setVersion] = useState("")
|
const [version, setVersion] = useState("")
|
||||||
const [file, setFile] = useState<File | null>(null)
|
const [file, setFile] = useState<File | null>(null)
|
||||||
@@ -73,7 +73,7 @@ export default function Artefacts() {
|
|||||||
|
|
||||||
setUploading(true)
|
setUploading(true)
|
||||||
try {
|
try {
|
||||||
await uploadArtefact(file)
|
await uploadArtefact(version, file)
|
||||||
setVersion("")
|
setVersion("")
|
||||||
setFile(null)
|
setFile(null)
|
||||||
setDialogOpen(false)
|
setDialogOpen(false)
|
||||||
@@ -90,7 +90,7 @@ export default function Artefacts() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDeleteClick(id: string, version: string) {
|
function handleDeleteClick(id: number, version: string) {
|
||||||
setDeleteTarget({ id, version })
|
setDeleteTarget({ id, version })
|
||||||
setDeleteDialogOpen(true)
|
setDeleteDialogOpen(true)
|
||||||
}
|
}
|
||||||
@@ -203,10 +203,10 @@ export default function Artefacts() {
|
|||||||
<TableCell>
|
<TableCell>
|
||||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||||
<Archive fontSize="small" color="action" />
|
<Archive fontSize="small" color="action" />
|
||||||
{a.name}
|
{a.fileName}
|
||||||
</Box>
|
</Box>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{formatSize(a.size)}</TableCell>
|
<TableCell>{formatSize(a.fileSize)}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Box
|
<Box
|
||||||
component="span"
|
component="span"
|
||||||
@@ -215,8 +215,8 @@ export default function Artefacts() {
|
|||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
cursor: "help",
|
cursor: "help",
|
||||||
}}
|
}}
|
||||||
title={a.checksum}>
|
title={a.sha256Hash}>
|
||||||
{a.checksum.slice(0, 12)}...
|
{a.sha256Hash.slice(0, 12)}...
|
||||||
</Box>
|
</Box>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{new Date(a.createdAt).toLocaleDateString()}</TableCell>
|
<TableCell>{new Date(a.createdAt).toLocaleDateString()}</TableCell>
|
||||||
@@ -280,6 +280,6 @@ export default function Artefacts() {
|
|||||||
|
|
||||||
function formatSize(bytes: number): string {
|
function formatSize(bytes: number): string {
|
||||||
if (bytes < 1024) return bytes + " B"
|
if (bytes < 1024) return bytes + " B"
|
||||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB"
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KiB"
|
||||||
return (bytes / (1024 * 1024)).toFixed(1) + " MB"
|
return (bytes / (1024 * 1024)).toFixed(1) + " MiB"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export default function Deployments() {
|
|||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
const [dialogOpen, setDialogOpen] = useState(false)
|
const [dialogOpen, setDialogOpen] = useState(false)
|
||||||
const [artefacts, setArtefacts] = useState<{ id: string; version: string }[]>([])
|
const [artefacts, setArtefacts] = useState<{ id: number; version: string }[]>([])
|
||||||
const [name, setName] = useState("")
|
const [name, setName] = useState("")
|
||||||
const [artefactId, setArtefactId] = useState("")
|
const [artefactId, setArtefactId] = useState("")
|
||||||
const [targetGroups, setTargetGroups] = useState("")
|
const [targetGroups, setTargetGroups] = useState("")
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useState, type SubmitEvent } from "react"
|
import { useState } from "react"
|
||||||
import { useNavigate } from "react-router-dom"
|
import { useNavigate } from "react-router-dom"
|
||||||
|
import { useDispatch } from "react-redux"
|
||||||
import { login } from "@/api/auth"
|
import { login } from "@/api/auth"
|
||||||
|
import { loginAction } from "@/store/auth-slice"
|
||||||
import { Container, Paper, Box, TextField, Button, Typography, Alert } from "@mui/material"
|
import { Container, Paper, Box, TextField, Button, Typography, Alert } from "@mui/material"
|
||||||
|
|
||||||
export default function Login() {
|
export default function Login() {
|
||||||
@@ -9,15 +11,16 @@ export default function Login() {
|
|||||||
const [error, setError] = useState("")
|
const [error, setError] = useState("")
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const dispatch = useDispatch()
|
||||||
|
|
||||||
async function handleSubmit(e: SubmitEvent) {
|
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setError("")
|
setError("")
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await login({ username, password })
|
await login({ username, password })
|
||||||
console.log(res.token)
|
dispatch(loginAction())
|
||||||
navigate("/dashboard")
|
navigate("/dashboard")
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Login failed")
|
setError(err instanceof Error ? err.message : "Login failed")
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import ErrorPage from "@/components/ErrorPage"
|
|||||||
import Layout from "@/components/Layout"
|
import Layout from "@/components/Layout"
|
||||||
import EmptyLayout from "@/components/EmptyLayout"
|
import EmptyLayout from "@/components/EmptyLayout"
|
||||||
import { isAuthenticated } from "@/store/auth-slice"
|
import { isAuthenticated } from "@/store/auth-slice"
|
||||||
import { useAppSelector } from "@/store"
|
import { useAppSelector } from "@/hooks/store"
|
||||||
|
|
||||||
function lazy<T extends { default: ComponentType<unknown> }>(importer: () => Promise<T>) {
|
function lazy<T extends { default: ComponentType<unknown> }>(importer: () => Promise<T>) {
|
||||||
return async () => {
|
return async () => {
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import axios from "axios"
|
import axios from "axios"
|
||||||
import dayjs from "@/lib/dayjs"
|
import dayjs from "@/lib/dayjs"
|
||||||
|
|
||||||
export default axios.create({
|
const WebClient = axios.create({
|
||||||
baseURL: import.meta.env.VITE_API_BASE_URL,
|
baseURL: import.meta.env.VITE_API_BASE_URL,
|
||||||
timeout: dayjs.duration({ seconds: 10 }).asMilliseconds(),
|
timeout: dayjs.duration({ seconds: 10 }).asMilliseconds(),
|
||||||
|
withCredentials: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export default WebClient
|
||||||
@@ -1,24 +1,27 @@
|
|||||||
import { createSlice, type PayloadAction } from "@reduxjs/toolkit"
|
import { createSlice } from "@reduxjs/toolkit"
|
||||||
|
|
||||||
export interface AuthState {
|
export interface AuthState {
|
||||||
token: string
|
authenticated: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialState: AuthState = {
|
const initialState: AuthState = {
|
||||||
token: "",
|
authenticated: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
const authSlice = createSlice({
|
const authSlice = createSlice({
|
||||||
name: "auth",
|
name: "auth",
|
||||||
initialState,
|
initialState,
|
||||||
reducers: {
|
reducers: {
|
||||||
setToken(state, action: PayloadAction<string>) {
|
login(state) {
|
||||||
state.token = action.payload
|
state.authenticated = true
|
||||||
|
},
|
||||||
|
logout(state) {
|
||||||
|
state.authenticated = false
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
export const { setToken } = authSlice.actions
|
export const { login: loginAction, logout: logoutAction } = authSlice.actions
|
||||||
export const authReducer = authSlice.reducer
|
export const authReducer = authSlice.reducer
|
||||||
|
|
||||||
export const isAuthenticated = (state: { auth: AuthState }) => state.auth.token !== ""
|
export const isAuthenticated = (state: { auth: AuthState }) => state.auth.authenticated
|
||||||
@@ -42,5 +42,3 @@ export default store
|
|||||||
export type RootState = ReturnType<typeof rootReducer>
|
export type RootState = ReturnType<typeof rootReducer>
|
||||||
export type AppDispatch = typeof store.dispatch
|
export type AppDispatch = typeof store.dispatch
|
||||||
export type AppStore = typeof store
|
export type AppStore = typeof store
|
||||||
|
|
||||||
export { useAppSelector, useAppDispatch } from "./hooks"
|
|
||||||
|
|||||||
Reference in New Issue
Block a user