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:
@@ -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"})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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))
|
||||
}
|
||||
Reference in New Issue
Block a user