feat: initial open-source release of Pipely

OTA update server designed for high-concurrency, low-bandwidth deployments.
GORM-backed PostgreSQL, JWT auth, device management, artefact versioning,
deployment rollout with gated rate-limited downloads, and a React admin panel.
This commit is contained in:
2026-07-07 10:29:28 +08:00
commit e070a3fb5f
59 changed files with 6515 additions and 0 deletions
+93
View File
@@ -0,0 +1,93 @@
package auth
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"onixbyte.com/message-converter-manifest/internal/model"
)
// -- Admin user management handlers --
type CreateUserRequest struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
Role string `json:"role"`
}
type ChangePasswordRequest struct {
NewPassword string `json:"newPassword" binding:"required"`
}
// ListUsers handles GET /admin/users.
func (h *Handler) ListUsers(c *gin.Context) {
users, err := h.svc.ListUsers()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if users == nil {
users = []model.AdminUser{}
}
c.JSON(http.StatusOK, gin.H{"data": users})
}
// CreateUser handles POST /admin/users.
func (h *Handler) CreateUser(c *gin.Context) {
var req CreateUserRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
user, err := h.svc.CreateUser(req.Username, req.Password, req.Role)
if err != nil {
if err == ErrUserExists {
c.JSON(http.StatusConflict, gin.H{"error": "Username already exists"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, user)
}
// DeleteUser handles DELETE /admin/users/:id.
func (h *Handler) DeleteUser(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
if err := h.svc.DeleteUser(id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "deleted"})
}
// ChangePassword handles PUT /admin/users/:id/password.
func (h *Handler) ChangePassword(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
var req ChangePasswordRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.svc.ChangePassword(id, req.NewPassword); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "password changed"})
}
+32
View File
@@ -0,0 +1,32 @@
package auth
import (
"net/http"
"github.com/gin-gonic/gin"
"onixbyte.com/message-converter-manifest/internal/model"
)
type Handler struct {
svc *Service
}
func NewHandler(svc *Service) *Handler {
return &Handler{svc: svc}
}
func (h *Handler) Login(c *gin.Context) {
var req model.LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()})
return
}
resp, err := h.svc.Login(req.Username, req.Password)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"})
return
}
c.JSON(http.StatusOK, resp)
}
+4
View File
@@ -0,0 +1,4 @@
package auth
// Repository functions are in internal/database/repository.go (AdminUser methods).
// This file exists to satisfy the package structure for future auth-specific queries.
+155
View File
@@ -0,0 +1,155 @@
package auth
import (
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"errors"
"time"
"github.com/golang-jwt/jwt/v5"
"gorm.io/gorm"
"onixbyte.com/message-converter-manifest/internal/database"
"onixbyte.com/message-converter-manifest/internal/model"
)
var (
ErrInvalidCredentials = errors.New("invalid username or password")
ErrUserExists = errors.New("username already exists")
)
type Service struct {
db *gorm.DB
secret []byte
expiry time.Duration
}
func NewService(db *gorm.DB, secret []byte, expiryHrs int) *Service {
return &Service{
db: db,
secret: secret,
expiry: time.Duration(expiryHrs) * time.Hour,
}
}
func (s *Service) Login(username, password string) (*model.LoginResponse, error) {
user, err := database.GetAdminUserByUsername(s.db, username)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrInvalidCredentials
}
return nil, err
}
hash := hashPassword(password)
if subtle.ConstantTimeCompare([]byte(hash), []byte(user.PasswordHash)) != 1 {
return nil, ErrInvalidCredentials
}
token, expiresAt, err := s.generateToken(user)
if err != nil {
return nil, err
}
return &model.LoginResponse{
Token: token,
ExpiresAt: expiresAt,
}, nil
}
func (s *Service) CreateInitialAdmin(username, password string) error {
_, err := database.GetAdminUserByUsername(s.db, username)
if err == nil {
return nil // admin already exists
}
user := &model.AdminUser{
Username: username,
PasswordHash: hashPassword(password),
Role: "admin",
}
return s.db.Transaction(func(tx *gorm.DB) error {
return database.CreateAdminUser(tx, user)
})
}
func (s *Service) ValidateToken(tokenString string) (*model.AdminUser, error) {
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, errors.New("unexpected signing method")
}
return s.secret, nil
})
if err != nil {
return nil, err
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok || !token.Valid {
return nil, errors.New("invalid token")
}
username, _ := claims["sub"].(string)
if username == "" {
return nil, errors.New("invalid token subject")
}
return database.GetAdminUserByUsername(s.db, username)
}
func (s *Service) generateToken(user *model.AdminUser) (string, int64, error) {
expiresAt := time.Now().Add(s.expiry)
claims := jwt.MapClaims{
"sub": user.Username,
"role": user.Role,
"iat": time.Now().Unix(),
"exp": expiresAt.Unix(),
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenStr, err := token.SignedString(s.secret)
if err != nil {
return "", 0, err
}
return tokenStr, expiresAt.Unix(), nil
}
func hashPassword(password string) string {
h := sha256.Sum256([]byte(password))
return hex.EncodeToString(h[:])
}
func (s *Service) ListUsers() ([]model.AdminUser, error) {
return database.ListAdminUsers(s.db)
}
func (s *Service) CreateUser(username, password, role string) (*model.AdminUser, error) {
_, err := database.GetAdminUserByUsername(s.db, username)
if err == nil {
return nil, ErrUserExists
}
if role == "" {
role = "admin"
}
user := &model.AdminUser{
Username: username,
PasswordHash: hashPassword(password),
Role: role,
}
if err := database.CreateAdminUser(s.db, user); err != nil {
return nil, err
}
return user, nil
}
func (s *Service) DeleteUser(id int64) error {
return database.DeleteAdminUser(s.db, id)
}
func (s *Service) ChangePassword(id int64, newPassword string) error {
return database.UpdateAdminPassword(s.db, id, hashPassword(newPassword))
}