first commit, i i have no idea what i have done

This commit is contained in:
tdv
2025-08-31 22:42:08 +03:00
commit c5632f6a37
177 changed files with 9173 additions and 0 deletions

View File

@@ -0,0 +1,117 @@
package handlers
import (
"log"
"net/http"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"smoop-api/internal/crypto"
"smoop-api/internal/dto"
"smoop-api/internal/models"
)
type AuthHandler struct {
db *gorm.DB
jwtMgr *crypto.JWTManager
}
func NewAuthHandler(db *gorm.DB, jwt *crypto.JWTManager) *AuthHandler {
return &AuthHandler{db: db, jwtMgr: jwt}
}
func (h *AuthHandler) SignUp(c *gin.Context) {
var req dto.AuthDto
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
hash, err := crypto.Hash(req.Password, crypto.DefaultArgon2)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "hash failed"})
return
}
u := models.User{Username: req.Username, Password: hash, Role: models.RoleUser}
if err := h.db.Create(&u).Error; err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "username exists"})
return
}
tok, _ := h.jwtMgr.Generate(u.ID, u.Username, string(u.Role))
c.JSON(http.StatusCreated, dto.AccessTokenDto{AccessToken: tok})
}
func (h *AuthHandler) SignIn(c *gin.Context) {
var req dto.AuthDto
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var u models.User
if err := h.db.Where("username = ?", req.Username).First(&u).Error; err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials - login"})
return
}
ok, verr := crypto.Verify(req.Password, u.Password)
if verr != nil {
log.Printf("verify error: %v", verr) // keep log-only in prod
}
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials - password"})
return
}
tok, _ := h.jwtMgr.Generate(u.ID, u.Username, string(u.Role))
c.JSON(http.StatusCreated, dto.AccessTokenDto{AccessToken: tok})
}
func (h *AuthHandler) ChangePassword(c *gin.Context) {
var req dto.ChangePasswordDto
if err := c.ShouldBindJSON(&req); err != nil || req.NewPassword == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"})
return
}
claims := MustClaims(c)
currentUID := ClaimUserID(claims)
isAdmin := ClaimRole(claims) == "admin"
targetID := currentUID
if req.UserID != 0 {
if !isAdmin && req.UserID != currentUID {
c.JSON(http.StatusForbidden, gin.H{"error": "forbidden"})
return
}
targetID = req.UserID
}
var u models.User
if err := h.db.First(&u, targetID).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
return
}
if !isAdmin {
ok, _ := crypto.Verify(req.OldPassword, u.Password)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid old password"})
return
}
}
hash, _ := crypto.Hash(req.NewPassword, crypto.DefaultArgon2)
u.Password = hash
if err := h.db.Save(&u).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "update failed"})
return
}
c.Status(http.StatusCreated)
}
func (h *AuthHandler) CheckToken(c *gin.Context) {
var req dto.AccessTokenDto
if err := c.ShouldBindJSON(&req); err != nil || req.AccessToken == "" {
c.JSON(http.StatusBadRequest, gin.H{"isValid": false})
return
}
_, err := h.jwtMgr.Parse(req.AccessToken)
c.JSON(http.StatusCreated, gin.H{"isValid": err == nil})
}

View File

@@ -0,0 +1,250 @@
package handlers
import (
"fmt"
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"smoop-api/internal/dto"
"smoop-api/internal/models"
)
type DevicesHandler struct {
db *gorm.DB
}
func NewDevicesHandler(db *gorm.DB) *DevicesHandler { return &DevicesHandler{db: db} }
func (h *DevicesHandler) List(c *gin.Context) {
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
if limit <= 0 || limit > 200 {
limit = 50
}
var total int64
h.db.Model(&models.Device{}).Count(&total)
var devs []models.Device
if err := h.db.Preload("Users").Offset(offset).Limit(limit).Find(&devs).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
return
}
out := make([]dto.DeviceDto, 0, len(devs))
for _, d := range devs {
out = append(out, dto.MapDevice(d))
}
c.JSON(http.StatusOK, dto.DeviceListDto{Devices: out, Offset: offset, Limit: limit, Total: total})
}
func (h *DevicesHandler) Create(c *gin.Context) {
var req dto.CreateDeviceDto
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
d := models.Device{GUID: req.GUID, Name: req.Name}
if err := h.db.Create(&d).Error; err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "device exists?"})
return
}
// Optional initial user assignments
if len(req.UserIDs) > 0 {
users, err := h.fetchUsers(req.UserIDs)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.db.Model(&d).Association("Users").Append(&users); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "link failed"})
return
}
}
// Return with users
var withUsers models.Device
if err := h.db.Preload("Users").Where("guid = ?", d.GUID).First(&withUsers).Error; err != nil {
c.JSON(http.StatusCreated, dto.DeviceDto{GUID: d.GUID, Name: d.Name})
return
}
c.JSON(http.StatusCreated, dto.MapDevice(withUsers))
}
func (h *DevicesHandler) Rename(c *gin.Context) {
guid := c.Param("guid")
var d models.Device
if err := h.db.Where("guid = ?", guid).First(&d).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "device not found"})
return
}
var req dto.RenameDeviceDto
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
d.Name = req.Name
if err := h.db.Save(&d).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "save failed"})
return
}
c.JSON(http.StatusCreated, dto.DeviceDto{GUID: d.GUID, Name: d.Name})
}
func (h *DevicesHandler) AddToUser(c *gin.Context) {
guid := c.Param("guid")
var d models.Device
if err := h.db.Where("guid = ?", guid).First(&d).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "device not found"})
return
}
var req dto.EditDeviceToUserRelationDto
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ids := req.UserIDs
if req.UserID != 0 {
ids = append(ids, req.UserID)
}
if len(ids) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "userIds or userId required"})
return
}
users, err := h.fetchUsers(ids)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.db.Model(&d).Association("Users").Append(&users); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "link failed"})
return
}
var withUsers models.Device
_ = h.db.Preload("Users").Where("guid = ?", d.GUID).First(&withUsers).Error
c.JSON(http.StatusCreated, dto.MapDevice(withUsers))
}
// SetUsers replaces the users of a device with the provided list.
// Passing an empty list clears all assignments (covers the "no user assigned" case).
func (h *DevicesHandler) SetUsers(c *gin.Context) {
guid := c.Param("guid")
var d models.Device
if err := h.db.Where("guid = ?", guid).First(&d).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "device not found"})
return
}
var req dto.SetDeviceUsersDto
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Load users (if any)
users := []models.User{}
if len(req.UserIDs) > 0 {
found, err := h.fetchUsers(req.UserIDs)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
users = found
}
// Replace association: Clear() then Append(new)
if err := h.db.Model(&d).Association("Users").Clear(); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "clear failed"})
return
}
if len(users) > 0 {
if err := h.db.Model(&d).Association("Users").Append(&users); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "link failed"})
return
}
}
var withUsers models.Device
_ = h.db.Preload("Users").Where("guid = ?", d.GUID).First(&withUsers).Error
c.JSON(http.StatusCreated, dto.MapDevice(withUsers))
}
func (h *DevicesHandler) RemoveFromUser(c *gin.Context) {
guid := c.Param("guid")
var d models.Device
if err := h.db.Where("guid = ?", guid).First(&d).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "device not found"})
return
}
var req dto.RemoveDeviceUsersDto
_ = c.ShouldBindJSON(&req) // ignore error; we support query fallback
ids := make([]uint, 0, len(req.UserIDs)+1)
if req.UserID != 0 {
ids = append(ids, req.UserID)
}
if len(req.UserIDs) > 0 {
ids = append(ids, req.UserIDs...)
}
// query fallback
if len(ids) == 0 {
if q := strings.TrimSpace(c.Query("userId")); q != "" {
if n, err := strconv.Atoi(q); err == nil && n > 0 {
ids = append(ids, uint(n))
}
}
if q := strings.TrimSpace(c.Query("userIds")); q != "" {
for _, p := range strings.Split(q, ",") {
p = strings.TrimSpace(p)
if n, err := strconv.Atoi(p); err == nil && n > 0 {
ids = append(ids, uint(n))
}
}
}
}
if len(ids) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "userIds or userId required"})
return
}
users, err := h.fetchUsers(ids)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if len(users) == 0 {
c.JSON(http.StatusOK, dto.DeviceDto{GUID: d.GUID, Name: d.Name})
return
}
if err := h.db.Model(&d).Association("Users").Delete(&users); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "unlink failed"})
return
}
var withUsers models.Device
_ = h.db.Preload("Users").Where("guid = ?", d.GUID).First(&withUsers).Error
c.JSON(http.StatusOK, dto.MapDevice(withUsers))
}
func (h *DevicesHandler) fetchUsers(ids []uint) ([]models.User, error) {
unique := make(map[uint]struct{}, len(ids))
clean := make([]uint, 0, len(ids))
for _, id := range ids {
if id != 0 {
if _, ok := unique[id]; !ok {
unique[id] = struct{}{}
clean = append(clean, id)
}
}
}
if len(clean) == 0 {
return nil, nil
}
var users []models.User
if err := h.db.Find(&users, clean).Error; err != nil {
return nil, err
}
if len(users) != len(clean) {
return nil, fmt.Errorf("some users not found")
}
return users, nil
}

View File

@@ -0,0 +1,78 @@
package handlers
import (
"net/http"
"smoop-api/internal/crypto"
"strings"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
)
func Auth(jwtMgr *crypto.JWTManager) gin.HandlerFunc {
return func(c *gin.Context) {
h := c.GetHeader("Authorization")
if !strings.HasPrefix(h, "Bearer ") {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing bearer token"})
return
}
tok := strings.TrimPrefix(h, "Bearer ")
token, err := jwtMgr.Parse(tok)
if err != nil || !token.Valid {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
return
}
claims, _ := token.Claims.(jwt.MapClaims)
c.Set("claims", claims)
c.Next()
}
}
func RequireRole(role string) gin.HandlerFunc {
return func(c *gin.Context) {
claims := MustClaims(c)
if ClaimRole(claims) != role {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "forbidden"})
return
}
c.Next()
}
}
// helpers used by handlers
func MustClaims(c *gin.Context) map[string]interface{} {
val, ok := c.Get("claims")
if !ok {
return jwt.MapClaims{}
}
switch t := val.(type) {
case jwt.MapClaims:
return t
case map[string]interface{}:
return jwt.MapClaims(t)
default:
return jwt.MapClaims{}
}
}
func ClaimUserID(claims map[string]interface{}) uint {
if claims == nil {
return 0
}
if v, ok := claims["sub"]; ok {
switch n := v.(type) {
case float64:
return uint(n)
case int:
return uint(n)
case int64:
return uint(n)
}
}
return 0
}
func ClaimRole(claims map[string]interface{}) string {
if r, ok := claims["role"].(string); ok {
return r
}
return ""
}

View File

@@ -0,0 +1,211 @@
package handlers
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/minio/minio-go/v7"
)
// --- Minimal in-file hub (per-GUID), fanout to viewers and upload to MinIO ---
type stream struct {
GUID string
writer io.WriteCloser
active bool
object string
mu sync.RWMutex
view map[*viewer]struct{}
}
// Close implements io.WriteCloser.
func (s *stream) Close() error {
panic("unimplemented")
}
func (s *stream) Write(p []byte) (int, error) {
s.mu.RLock()
defer s.mu.RUnlock()
if !s.active {
return 0, fmt.Errorf("not active")
}
for v := range s.view {
select {
case v.out <- p:
default:
}
}
return s.writer.Write(p)
}
type viewer struct{ out chan []byte }
type liveHub struct {
mu sync.RWMutex
streams map[string]*stream
minio *minio.Client
bucket string
}
func newLiveHub(mc *minio.Client, bucket string) *liveHub {
return &liveHub{streams: map[string]*stream{}, minio: mc, bucket: bucket}
}
func (h *liveHub) start(guid string) (io.WriteCloser, string, error) {
h.mu.Lock()
defer h.mu.Unlock()
s, ok := h.streams[guid]
if !ok {
s = &stream{GUID: guid, view: map[*viewer]struct{}{}}
h.streams[guid] = s
}
if s.active {
return nil, "", fmt.Errorf("already active")
}
key := fmt.Sprintf("%s/live_%d.raw", guid, time.Now().Unix())
pr, pw := io.Pipe()
s.writer = pw
s.object = key
s.active = true
go func(reader io.Reader, bucket, object string) {
// naive buffering for simplicity
buf := new(bytes.Buffer)
_, _ = io.Copy(buf, reader)
_, _ = h.minio.PutObject(context.Background(), bucket, object, bytes.NewReader(buf.Bytes()), int64(buf.Len()), minio.PutObjectOptions{
ContentType: "application/octet-stream",
})
}(pr, h.bucket, key)
return s, key, nil
}
func (h *liveHub) stop(guid string) (string, error) {
h.mu.Lock()
defer h.mu.Unlock()
s, ok := h.streams[guid]
if !ok || !s.active {
return "", fmt.Errorf("not active")
}
_ = s.writer.Close()
s.active = false
return s.object, nil
}
func (h *liveHub) join(guid string) *viewer {
h.mu.Lock()
defer h.mu.Unlock()
s, ok := h.streams[guid]
if !ok {
s = &stream{GUID: guid, view: map[*viewer]struct{}{}}
h.streams[guid] = s
}
v := &viewer{out: make(chan []byte, 64)}
s.view[v] = struct{}{}
return v
}
func (h *liveHub) leave(guid string, v *viewer) {
h.mu.Lock()
defer h.mu.Unlock()
if s, ok := h.streams[guid]; ok {
delete(s.view, v)
close(v.out)
}
}
// --- Gin handler ---
type LivestreamHandler struct {
hub *liveHub
}
func NewLivestreamHandler(minio *minio.Client, bucket string) *LivestreamHandler {
return &LivestreamHandler{hub: newLiveHub(minio, bucket)}
}
var upgrader = websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }}
type wsMsg struct {
Type string `json:"type"` // "start", "stop"
Role string `json:"role"` // "device" or "viewer"
GUID string `json:"guid"` // required
Format string `json:"format"` // optional
}
func (h *LivestreamHandler) Upgrade(c *gin.Context) {
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
return
}
defer conn.Close()
for {
var m wsMsg
if err := conn.ReadJSON(&m); err != nil {
return
}
if m.GUID == "" {
_ = conn.WriteJSON(gin.H{"error": "guid required"})
continue
}
switch m.Role {
case "device":
switch m.Type {
case "start":
w, object, err := h.hub.start(m.GUID)
if err != nil {
_ = conn.WriteJSON(gin.H{"error": err.Error()})
continue
}
_ = conn.WriteJSON(gin.H{"ok": true, "object": object})
// read binary frames until stop/close
for {
mt, data, err := conn.ReadMessage()
if err != nil {
h.hub.stop(m.GUID)
return
}
if mt == websocket.BinaryMessage {
_, _ = w.Write(data)
} else if mt == websocket.TextMessage {
// best-effort: if a text control announcing stop arrives
var ctrl wsMsg
if err := conn.ReadJSON(&ctrl); err == nil && ctrl.Type == "stop" {
h.hub.stop(m.GUID)
return
}
}
}
case "stop":
_, _ = h.hub.stop(m.GUID)
_ = conn.WriteJSON(gin.H{"ok": true})
default:
_ = conn.WriteJSON(gin.H{"error": "unknown type"})
}
case "viewer":
v := h.hub.join(m.GUID)
defer h.hub.leave(m.GUID, v)
_ = conn.WriteJSON(gin.H{"ok": true})
for frame := range v.out {
if err := conn.WriteMessage(websocket.BinaryMessage, frame); err != nil {
return
}
}
default:
_ = conn.WriteJSON(gin.H{"error": "unknown role"})
}
}
}

View File

@@ -0,0 +1,123 @@
package handlers
import (
"context"
"fmt"
"mime/multipart"
"net/http"
"path"
"strconv"
"time"
"github.com/gin-gonic/gin"
"github.com/minio/minio-go/v7"
"gorm.io/gorm"
"smoop-api/internal/dto"
"smoop-api/internal/models"
)
type RecordsHandler struct {
db *gorm.DB
minio *minio.Client
recordsBucket string
presignTTL time.Duration
}
func NewRecordsHandler(db *gorm.DB, mc *minio.Client, bucket string, ttl time.Duration) *RecordsHandler {
return &RecordsHandler{db: db, minio: mc, recordsBucket: bucket, presignTTL: ttl}
}
func (h *RecordsHandler) Upload(c *gin.Context) {
file, err := c.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "file required"})
return
}
guid := c.PostForm("guid")
if guid == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "guid required"})
return
}
startedAt, _ := strconv.ParseInt(c.PostForm("startedAt"), 10, 64)
stoppedAt, _ := strconv.ParseInt(c.PostForm("stoppedAt"), 10, 64)
var dev models.Device
if err := h.db.Where("guid = ?", guid).First(&dev).Error; err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "device not found"})
return
}
objKey := fmt.Sprintf("%s/%d_%s", guid, time.Now().UnixNano(), path.Base(file.Filename))
if err := h.putFile(c, file, h.recordsBucket, objKey); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "upload failed"})
return
}
rec := models.Record{
DeviceGUID: dev.GUID,
StartedAt: startedAt,
StoppedAt: stoppedAt,
ObjectKey: objKey,
}
if err := h.db.Create(&rec).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "db save failed"})
return
}
c.JSON(http.StatusCreated, dto.RecordDto{ID: rec.ID, StartedAt: rec.StartedAt, StoppedAt: rec.StoppedAt})
}
func (h *RecordsHandler) putFile(c *gin.Context, fh *multipart.FileHeader, bucket, object string) error {
src, err := fh.Open()
if err != nil {
return err
}
defer src.Close()
_, err = h.minio.PutObject(c, bucket, object, src, fh.Size, minio.PutObjectOptions{
ContentType: fh.Header.Get("Content-Type"),
ContentDisposition: "attachment; filename=" + fh.Filename,
})
return err
}
func (h *RecordsHandler) List(c *gin.Context) {
guid := c.Query("guid")
if guid == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "guid is required"})
return
}
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
if limit <= 0 || limit > 200 {
limit = 50
}
var total int64
h.db.Model(&models.Record{}).Where("device_guid = ?", guid).Count(&total)
var recs []models.Record
if err := h.db.Where("device_guid = ?", guid).Order("id desc").Offset(offset).Limit(limit).Find(&recs).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
return
}
out := make([]dto.RecordDto, 0, len(recs))
for _, r := range recs {
out = append(out, dto.RecordDto{ID: r.ID, StartedAt: r.StartedAt, StoppedAt: r.StoppedAt})
}
c.JSON(http.StatusOK, dto.RecordListDto{Records: out, Offset: offset, Limit: limit, Total: total})
}
func (h *RecordsHandler) File(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
var rec models.Record
if err := h.db.First(&rec, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
u, err := h.minio.PresignedGetObject(context.Background(), h.recordsBucket, rec.ObjectKey, h.presignTTL, nil)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "presign failed"})
return
}
c.Redirect(http.StatusFound, u.String())
}

View File

@@ -0,0 +1,98 @@
package handlers
import (
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"smoop-api/internal/crypto"
"smoop-api/internal/dto"
"smoop-api/internal/models"
)
type UsersHandler struct {
db *gorm.DB
}
func NewUsersHandler(db *gorm.DB) *UsersHandler { return &UsersHandler{db: db} }
func (h *UsersHandler) Profile(c *gin.Context) {
claims := MustClaims(c)
uid := ClaimUserID(claims)
var u models.User
if err := h.db.First(&u, uid).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
return
}
c.JSON(http.StatusOK, dto.MapUser(u))
}
func (h *UsersHandler) SetRole(c *gin.Context) {
idStr := c.Param("id")
uid, _ := strconv.Atoi(idStr)
var u models.User
if err := h.db.First(&u, uid).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
return
}
var req dto.UserRoleDto
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
u.Role = req.Role
if err := h.db.Save(&u).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "save failed"})
return
}
c.JSON(http.StatusCreated, dto.MapUser(u))
}
func (h *UsersHandler) List(c *gin.Context) {
var users []models.User
if err := h.db.Order("id asc").Find(&users).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
return
}
out := make([]dto.UserDto, 0, len(users))
for _, u := range users {
out = append(out, dto.MapUser(u))
}
c.JSON(http.StatusOK, out)
}
// POST /users/create (admin) — create user with given role
func (h *UsersHandler) Create(c *gin.Context) {
var req dto.CreateUserDto
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
role := models.Role(strings.ToLower(req.Role))
if role != models.RoleAdmin && role != models.RoleUser {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid role"})
return
}
hash, err := crypto.Hash(req.Password, crypto.DefaultArgon2)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "hash error"})
return
}
u := models.User{Username: req.Username, Password: hash, Role: role}
if err := h.db.Create(&u).Error; err != nil {
// hint duplicate username
e := strings.ToLower(err.Error())
if strings.Contains(e, "duplicate") || strings.Contains(e, "unique") || strings.Contains(e, "exists") {
c.JSON(http.StatusBadRequest, gin.H{"error": "username already exists"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "create failed"})
return
}
c.JSON(http.StatusCreated, dto.MapUser(u))
}