first commit, i i have no idea what i have done
This commit is contained in:
205
server/internal/config/config.go
Normal file
205
server/internal/config/config.go
Normal file
@@ -0,0 +1,205 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"smoop-api/internal/vault"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
DB struct {
|
||||
DSN string
|
||||
}
|
||||
MinIO struct {
|
||||
Endpoint string
|
||||
AccessKey string
|
||||
SecretKey string
|
||||
UseSSL bool
|
||||
RecordsBucket string
|
||||
LivestreamBucket string
|
||||
PresignTTL time.Duration
|
||||
}
|
||||
JWTSecret []byte
|
||||
}
|
||||
|
||||
func Load() (*Config, error) {
|
||||
addr := os.Getenv("VAULT_ADDR")
|
||||
token := os.Getenv("VAULT_TOKEN")
|
||||
|
||||
// New style: explicit KV v2 mount + key
|
||||
mount := os.Getenv("VAULT_KV_MOUNT") // e.g. "kv" or "secret"
|
||||
key := os.Getenv("VAULT_KV_KEY") // e.g. "snoop"
|
||||
|
||||
// Back-compat: allow legacy VAULT_KV_PATH like "kv/data/snoop" and derive mount+key
|
||||
if (mount == "" || key == "") && os.Getenv("VAULT_KV_PATH") != "" {
|
||||
legacy := strings.Trim(os.Getenv("VAULT_KV_PATH"), "/")
|
||||
parts := strings.Split(legacy, "/")
|
||||
if len(parts) >= 2 {
|
||||
mount = parts[0]
|
||||
key = parts[len(parts)-1]
|
||||
}
|
||||
}
|
||||
|
||||
if addr == "" || token == "" || mount == "" || key == "" {
|
||||
return nil, fmt.Errorf("VAULT_ADDR, VAULT_TOKEN, VAULT_KV_MOUNT and VAULT_KV_KEY must be set (or provide legacy VAULT_KV_PATH)")
|
||||
}
|
||||
|
||||
raw, err := vault.ReadKVv2(addr, token, mount, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
getStr := func(k string) (string, error) {
|
||||
v, ok := raw[k].(string)
|
||||
if !ok || v == "" {
|
||||
return "", fmt.Errorf("missing secret key: %s", k)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
getBool := func(k string) (bool, error) {
|
||||
v, ok := raw[k]
|
||||
if !ok {
|
||||
return false, fmt.Errorf("missing secret key: %s", k)
|
||||
}
|
||||
switch t := v.(type) {
|
||||
case bool:
|
||||
return t, nil
|
||||
case string:
|
||||
if t == "true" || t == "1" {
|
||||
return true, nil
|
||||
}
|
||||
return false, nil
|
||||
default:
|
||||
return false, fmt.Errorf("invalid bool for key %s", k)
|
||||
}
|
||||
}
|
||||
|
||||
dbDSN, err := getStr("db_dsn")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
endpoint, err := getStr("minio_endpoint")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ak, err := getStr("minio_access_key")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sk, err := getStr("minio_secret_key")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
useSSL, err := getBool("minio_use_ssl")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
jwt, err := getStr("jwt_secret")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
recordsBucket := "records"
|
||||
if v, ok := raw["minio_records_bucket"].(string); ok && v != "" {
|
||||
recordsBucket = v
|
||||
}
|
||||
liveBucket := "livestream"
|
||||
if v, ok := raw["minio_livestream_bucket"].(string); ok && v != "" {
|
||||
liveBucket = v
|
||||
}
|
||||
presignTTL := 15 * time.Minute
|
||||
if v, ok := raw["minio_presign_ttl_seconds"].(string); ok && v != "" {
|
||||
var sec int
|
||||
fmt.Sscanf(v, "%d", &sec)
|
||||
if sec > 0 {
|
||||
presignTTL = time.Duration(sec) * time.Second
|
||||
}
|
||||
}
|
||||
|
||||
cfg := &Config{}
|
||||
cfg.DB.DSN = dbDSN
|
||||
cfg.MinIO.Endpoint = endpoint
|
||||
cfg.MinIO.AccessKey = ak
|
||||
cfg.MinIO.SecretKey = sk
|
||||
cfg.MinIO.UseSSL = useSSL
|
||||
cfg.MinIO.RecordsBucket = recordsBucket
|
||||
cfg.MinIO.LivestreamBucket = liveBucket
|
||||
cfg.MinIO.PresignTTL = presignTTL
|
||||
cfg.JWTSecret = []byte(jwt)
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func LoadDev() (*Config, error) {
|
||||
getRequired := func(k string) (string, error) {
|
||||
v := os.Getenv(k)
|
||||
if v == "" {
|
||||
return "", fmt.Errorf("missing required env %s", k)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
getBoolEnv := func(k string, def bool) bool {
|
||||
v := strings.ToLower(strings.TrimSpace(os.Getenv(k)))
|
||||
if v == "true" || v == "1" || v == "yes" {
|
||||
return true
|
||||
}
|
||||
if v == "false" || v == "0" || v == "no" {
|
||||
return false
|
||||
}
|
||||
return def
|
||||
}
|
||||
getIntEnv := func(k string, def int) int {
|
||||
if v := strings.TrimSpace(os.Getenv(k)); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return def
|
||||
}
|
||||
dbDSN, err := getRequired("DB_DSN")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
endpoint, err := getRequired("MINIO_ENDPOINT")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ak, err := getRequired("MINIO_ACCESS_KEY")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sk, err := getRequired("MINIO_SECRET_KEY")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
jwt, err := getRequired("JWT_SECRET")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
useSSL := getBoolEnv("MINIO_USE_SSL", false)
|
||||
recordsBucket := os.Getenv("MINIO_RECORDS_BUCKET")
|
||||
if recordsBucket == "" {
|
||||
recordsBucket = "records"
|
||||
}
|
||||
liveBucket := os.Getenv("MINIO_LIVESTREAM_BUCKET")
|
||||
if liveBucket == "" {
|
||||
liveBucket = "livestream"
|
||||
}
|
||||
presignTTL := time.Duration(getIntEnv("MINIO_PRESIGN_TTL_SECONDS", 900)) * time.Second
|
||||
|
||||
cfg := &Config{}
|
||||
cfg.DB.DSN = dbDSN
|
||||
cfg.MinIO.Endpoint = endpoint
|
||||
cfg.MinIO.AccessKey = ak
|
||||
cfg.MinIO.SecretKey = sk
|
||||
cfg.MinIO.UseSSL = useSSL
|
||||
cfg.MinIO.RecordsBucket = recordsBucket
|
||||
cfg.MinIO.LivestreamBucket = liveBucket
|
||||
cfg.MinIO.PresignTTL = presignTTL
|
||||
cfg.JWTSecret = []byte(jwt)
|
||||
return cfg, nil
|
||||
}
|
||||
102
server/internal/crypto/argon2id.go
Normal file
102
server/internal/crypto/argon2id.go
Normal file
@@ -0,0 +1,102 @@
|
||||
package crypto
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/argon2"
|
||||
)
|
||||
|
||||
type Argon2Params struct {
|
||||
Memory uint32 // KiB
|
||||
Time uint32
|
||||
Threads uint8
|
||||
SaltLen uint32
|
||||
KeyLen uint32
|
||||
}
|
||||
|
||||
var DefaultArgon2 = Argon2Params{
|
||||
Memory: 7168, // 7 MiB
|
||||
Time: 5,
|
||||
Threads: 1,
|
||||
SaltLen: 16,
|
||||
KeyLen: 32,
|
||||
}
|
||||
|
||||
// Hash returns PHC string: $argon2id$v=19$m=...,t=...,p=...$<salt>$<hash>
|
||||
func Hash(password string, p Argon2Params) (string, error) {
|
||||
salt := make([]byte, p.SaltLen)
|
||||
if _, err := rand.Read(salt); err != nil {
|
||||
return "", err
|
||||
}
|
||||
sum := argon2.IDKey([]byte(password), salt, p.Time, p.Memory, p.Threads, p.KeyLen)
|
||||
return fmt.Sprintf("$argon2id$v=19$m=%d,t=%d,p=%d$%s$%s",
|
||||
p.Memory, p.Time, p.Threads,
|
||||
base64.RawStdEncoding.EncodeToString(salt),
|
||||
base64.RawStdEncoding.EncodeToString(sum),
|
||||
), nil
|
||||
}
|
||||
|
||||
func Verify(password, phc string) (bool, error) {
|
||||
parts := strings.Split(phc, "$")
|
||||
// Expect: ["", "argon2id", "v=19", "m=...,t=...,p=...", "<saltB64>", "<hashB64>"]
|
||||
if len(parts) != 6 || parts[1] != "argon2id" || parts[2] != "v=19" {
|
||||
return false, errors.New("invalid PHC header")
|
||||
}
|
||||
|
||||
// parse params
|
||||
var mem, iters uint64
|
||||
var threads uint64
|
||||
for _, kv := range strings.Split(parts[3], ",") {
|
||||
if strings.HasPrefix(kv, "m=") {
|
||||
v := strings.TrimPrefix(kv, "m=")
|
||||
x, err := strconv.ParseUint(v, 10, 32)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("mem: %w", err)
|
||||
}
|
||||
mem = x
|
||||
} else if strings.HasPrefix(kv, "t=") {
|
||||
v := strings.TrimPrefix(kv, "t=")
|
||||
x, err := strconv.ParseUint(v, 10, 32)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("time: %w", err)
|
||||
}
|
||||
iters = x
|
||||
} else if strings.HasPrefix(kv, "p=") {
|
||||
v := strings.TrimPrefix(kv, "p=")
|
||||
x, err := strconv.ParseUint(v, 10, 8)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("threads: %w", err)
|
||||
}
|
||||
threads = x
|
||||
}
|
||||
}
|
||||
if mem == 0 || iters == 0 || threads == 0 {
|
||||
return false, errors.New("invalid PHC params")
|
||||
}
|
||||
|
||||
salt, err := b64DecodeFlex(parts[4])
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("salt decode: %w", err)
|
||||
}
|
||||
|
||||
want, err := b64DecodeFlex(parts[5])
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("hash decode: %w", err)
|
||||
}
|
||||
|
||||
got := argon2.IDKey([]byte(password), salt, uint32(iters), uint32(mem), uint8(threads), uint32(len(want)))
|
||||
return subtle.ConstantTimeCompare(got, want) == 1, nil
|
||||
}
|
||||
|
||||
func b64DecodeFlex(s string) ([]byte, error) {
|
||||
if b, err := base64.RawStdEncoding.DecodeString(s); err == nil {
|
||||
return b, nil
|
||||
}
|
||||
return base64.StdEncoding.DecodeString(s) // padded fallback
|
||||
}
|
||||
30
server/internal/crypto/jwt.go
Normal file
30
server/internal/crypto/jwt.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package crypto
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
type JWTManager struct {
|
||||
secret []byte
|
||||
}
|
||||
|
||||
func NewJWT(secret []byte) *JWTManager {
|
||||
return &JWTManager{secret: secret}
|
||||
}
|
||||
|
||||
func (j *JWTManager) Generate(userID uint, username, role string) (string, error) {
|
||||
t := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
||||
"sub": userID,
|
||||
"name": username,
|
||||
"role": role,
|
||||
"iat": time.Now().Unix(),
|
||||
"exp": time.Now().Add(24 * time.Hour).Unix(),
|
||||
})
|
||||
return t.SignedString(j.secret)
|
||||
}
|
||||
|
||||
func (j *JWTManager) Parse(tok string) (*jwt.Token, error) {
|
||||
return jwt.Parse(tok, func(t *jwt.Token) (interface{}, error) { return j.secret, nil })
|
||||
}
|
||||
20
server/internal/db/db.go
Normal file
20
server/internal/db/db.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"smoop-api/internal/models"
|
||||
)
|
||||
|
||||
func Open(dsn string) (*gorm.DB, error) {
|
||||
return gorm.Open(postgres.Open(dsn), &gorm.Config{})
|
||||
}
|
||||
|
||||
func AutoMigrate(db *gorm.DB) error {
|
||||
return db.AutoMigrate(
|
||||
&models.User{},
|
||||
&models.Device{},
|
||||
&models.Record{},
|
||||
)
|
||||
}
|
||||
20
server/internal/dto/auth.go
Normal file
20
server/internal/dto/auth.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package dto
|
||||
|
||||
type AuthDto struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
|
||||
type AccessTokenDto struct {
|
||||
AccessToken string `json:"accessToken"`
|
||||
}
|
||||
|
||||
type ChangePasswordDto struct {
|
||||
UserID uint `json:"userId,omitempty"`
|
||||
OldPassword string `json:"oldPassword"`
|
||||
NewPassword string `json:"newPassword" binding:"required"`
|
||||
}
|
||||
|
||||
type CheckTokenResultDto struct {
|
||||
IsValid bool `json:"isValid"`
|
||||
}
|
||||
56
server/internal/dto/device.go
Normal file
56
server/internal/dto/device.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package dto
|
||||
|
||||
import "smoop-api/internal/models"
|
||||
|
||||
type DeviceDto struct {
|
||||
GUID string `json:"guid"`
|
||||
Name string `json:"name"`
|
||||
Users []UserDto `json:"users,omitempty"`
|
||||
}
|
||||
|
||||
type DeviceListDto struct {
|
||||
Devices []DeviceDto `json:"devices"`
|
||||
Offset int `json:"offset"`
|
||||
Limit int `json:"limit"`
|
||||
Total int64 `json:"total"`
|
||||
}
|
||||
|
||||
type CreateDeviceDto struct {
|
||||
GUID string `json:"guid" binding:"required"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
UserIDs []uint `json:"userIds"`
|
||||
}
|
||||
|
||||
type RenameDeviceDto struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
}
|
||||
|
||||
type EditDeviceToUserRelationDto struct {
|
||||
UserID uint `json:"userId"`
|
||||
UserIDs []uint `json:"userIds"`
|
||||
}
|
||||
|
||||
// Remove users: accept one or many (kept separate for clarity of intent)
|
||||
type RemoveDeviceUsersDto struct {
|
||||
UserID uint `json:"userId"`
|
||||
UserIDs []uint `json:"userIds"`
|
||||
}
|
||||
|
||||
// Replace users: set entire list (may be empty to clear all)
|
||||
type SetDeviceUsersDto struct {
|
||||
UserIDs []uint `json:"userIds"`
|
||||
}
|
||||
|
||||
func MapDevice(d models.Device) DeviceDto {
|
||||
out := DeviceDto{
|
||||
GUID: d.GUID,
|
||||
Name: d.Name,
|
||||
}
|
||||
if len(d.Users) > 0 {
|
||||
out.Users = make([]UserDto, 0, len(d.Users))
|
||||
for _, u := range d.Users {
|
||||
out.Users = append(out.Users, MapUser(u))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
14
server/internal/dto/record.go
Normal file
14
server/internal/dto/record.go
Normal file
@@ -0,0 +1,14 @@
|
||||
package dto
|
||||
|
||||
type RecordDto struct {
|
||||
ID uint `json:"id"`
|
||||
StartedAt int64 `json:"startedAt"`
|
||||
StoppedAt int64 `json:"stoppedAt"`
|
||||
}
|
||||
|
||||
type RecordListDto struct {
|
||||
Records []RecordDto `json:"records"`
|
||||
Offset int `json:"offset"`
|
||||
Limit int `json:"limit"`
|
||||
Total int64 `json:"total"`
|
||||
}
|
||||
29
server/internal/dto/user.go
Normal file
29
server/internal/dto/user.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package dto
|
||||
|
||||
import "smoop-api/internal/models"
|
||||
|
||||
type UserDto struct {
|
||||
ID uint `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Role models.Role `json:"role"`
|
||||
}
|
||||
|
||||
type UserRoleDto struct {
|
||||
Role models.Role `json:"role" binding:"required,oneof=admin user"`
|
||||
}
|
||||
|
||||
// CreateUserDto is used by POST /users/create to create a new user with a role.
|
||||
// Role must be one of: "admin" | "user".
|
||||
type CreateUserDto struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
Role string `json:"role" binding:"required,oneof=admin user"`
|
||||
}
|
||||
|
||||
func MapUser(u models.User) UserDto {
|
||||
return UserDto{
|
||||
ID: u.ID,
|
||||
Username: u.Username,
|
||||
Role: u.Role,
|
||||
}
|
||||
}
|
||||
117
server/internal/handlers/auth.go
Normal file
117
server/internal/handlers/auth.go
Normal 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})
|
||||
}
|
||||
250
server/internal/handlers/devices.go
Normal file
250
server/internal/handlers/devices.go
Normal 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
|
||||
}
|
||||
78
server/internal/handlers/helpers.go
Normal file
78
server/internal/handlers/helpers.go
Normal 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 ""
|
||||
}
|
||||
211
server/internal/handlers/livestream.go
Normal file
211
server/internal/handlers/livestream.go
Normal 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"})
|
||||
}
|
||||
}
|
||||
}
|
||||
123
server/internal/handlers/records.go
Normal file
123
server/internal/handlers/records.go
Normal 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())
|
||||
}
|
||||
98
server/internal/handlers/users.go
Normal file
98
server/internal/handlers/users.go
Normal 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))
|
||||
}
|
||||
12
server/internal/models/device.go
Normal file
12
server/internal/models/device.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type Device struct {
|
||||
GUID string `gorm:"primaryKey"`
|
||||
Name string `gorm:"size:255;not null"`
|
||||
Users []User `gorm:"many2many:user_devices;joinForeignKey:ID;joinReferences:GUID"`
|
||||
Records []Record `gorm:"foreignKey:DeviceGUID;references:GUID;constraint:OnDelete:CASCADE"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
13
server/internal/models/record.go
Normal file
13
server/internal/models/record.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type Record struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
DeviceGUID string `gorm:"index;size:64;not null"`
|
||||
StartedAt int64 `gorm:"not null"`
|
||||
StoppedAt int64 `gorm:"not null"`
|
||||
ObjectKey string `gorm:"size:512;not null"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
20
server/internal/models/user.go
Normal file
20
server/internal/models/user.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type Role string
|
||||
|
||||
const (
|
||||
RoleAdmin Role = "admin"
|
||||
RoleUser Role = "user"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
Username string `gorm:"uniqueIndex;size:255;not null"`
|
||||
Password string `gorm:"not null"`
|
||||
Role Role `gorm:"type:varchar(16);not null;default:'user'"`
|
||||
Devices []Device `gorm:"many2many:user_devices;joinForeignKey:ID;joinReferences:GUID"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
67
server/internal/router/router.go
Normal file
67
server/internal/router/router.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/minio/minio-go/v7"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"smoop-api/internal/config"
|
||||
"smoop-api/internal/crypto"
|
||||
"smoop-api/internal/handlers"
|
||||
)
|
||||
|
||||
func Build(db *gorm.DB, minio *minio.Client, cfg *config.Config) *gin.Engine {
|
||||
r := gin.Default()
|
||||
|
||||
jwtMgr := crypto.NewJWT(cfg.JWTSecret)
|
||||
|
||||
// --- Handlers
|
||||
authH := handlers.NewAuthHandler(db, jwtMgr)
|
||||
usersH := handlers.NewUsersHandler(db)
|
||||
devH := handlers.NewDevicesHandler(db)
|
||||
recH := handlers.NewRecordsHandler(db, minio, cfg.MinIO.RecordsBucket, cfg.MinIO.PresignTTL)
|
||||
liveH := handlers.NewLivestreamHandler(minio, cfg.MinIO.LivestreamBucket)
|
||||
|
||||
// --- Public auth
|
||||
r.POST("/auth/signup", authH.SignUp)
|
||||
r.POST("/auth/signin", authH.SignIn)
|
||||
r.POST("/auth/check_token", authH.CheckToken)
|
||||
|
||||
// Protected
|
||||
authMW := handlers.Auth(jwtMgr)
|
||||
adminOnly := handlers.RequireRole("admin")
|
||||
|
||||
r.POST("/auth/change_password", authMW, authH.ChangePassword)
|
||||
|
||||
r.GET("/users/profile", authMW, usersH.Profile)
|
||||
r.POST("/users/:id/set_role", authMW, adminOnly, usersH.SetRole)
|
||||
r.GET("/users", authMW, adminOnly, usersH.List)
|
||||
r.POST("/users/create", authMW, adminOnly, usersH.Create)
|
||||
|
||||
r.GET("/devices", authMW, devH.List)
|
||||
r.POST("/devices/create", authMW, devH.Create)
|
||||
r.POST("/devices/:guid/rename", authMW, devH.Rename)
|
||||
r.POST("/devices/:guid/add_to_user", authMW, devH.AddToUser)
|
||||
r.POST("/devices/:guid/set_users", authMW, adminOnly, devH.SetUsers)
|
||||
r.POST("/devices/:guid/remove_from_user", authMW, devH.RemoveFromUser)
|
||||
|
||||
r.POST("/records/upload", authMW, recH.Upload)
|
||||
r.GET("/records", authMW, recH.List)
|
||||
r.GET("/records/:id/file", authMW, recH.File)
|
||||
|
||||
// WebSocket livestream
|
||||
r.GET("/livestream", authMW, liveH.Upgrade)
|
||||
|
||||
// health
|
||||
r.GET("/healthz", func(c *gin.Context) { c.String(http.StatusOK, "ok") })
|
||||
|
||||
// sensible defaults
|
||||
r.MaxMultipartMemory = 64 << 20 // 64 MiB
|
||||
_ = time.Now() // appease linters
|
||||
return r
|
||||
}
|
||||
|
||||
// --- JWT middleware & helpers (kept here to avoid new dirs) ---
|
||||
60
server/internal/storage/minio.go
Normal file
60
server/internal/storage/minio.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/minio/minio-go/v7"
|
||||
"github.com/minio/minio-go/v7/pkg/credentials"
|
||||
)
|
||||
|
||||
type MinIOConfig struct {
|
||||
Endpoint string
|
||||
AccessKey string
|
||||
SecretKey string
|
||||
UseSSL bool
|
||||
RecordsBucket string
|
||||
LivestreamBucket string
|
||||
PresignTTL time.Duration
|
||||
}
|
||||
|
||||
func cleanEndpoint(ep string) string {
|
||||
ep = strings.TrimSpace(ep)
|
||||
if ep == "" {
|
||||
return ep
|
||||
}
|
||||
// If a scheme is present, strip it.
|
||||
if u, err := url.Parse(ep); err == nil && u.Host != "" {
|
||||
ep = u.Host // drops scheme and any path/query
|
||||
}
|
||||
// Remove any trailing slash that might remain.
|
||||
ep = strings.TrimSuffix(ep, "/")
|
||||
return ep
|
||||
}
|
||||
|
||||
func NewMinio(c MinIOConfig) (*minio.Client, error) {
|
||||
endpoint := cleanEndpoint(c.Endpoint)
|
||||
return minio.New(endpoint, &minio.Options{
|
||||
Creds: credentials.NewStaticV4(c.AccessKey, c.SecretKey, ""),
|
||||
Secure: c.UseSSL,
|
||||
})
|
||||
}
|
||||
|
||||
func EnsureBuckets(mc *minio.Client, c MinIOConfig) error {
|
||||
ctx := context.Background()
|
||||
for _, b := range []string{c.RecordsBucket, c.LivestreamBucket} {
|
||||
exists, err := mc.BucketExists(ctx, b)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !exists {
|
||||
if err := mc.MakeBucket(ctx, b, minio.MakeBucketOptions{}); err != nil {
|
||||
return fmt.Errorf("make bucket %s: %w", b, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
41
server/internal/vault/vault.go
Normal file
41
server/internal/vault/vault.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package vault
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
vault "github.com/hashicorp/vault-client-go"
|
||||
)
|
||||
|
||||
func ReadKVv2(addr, token, mountPath, key string) (map[string]any, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
client, err := vault.New(
|
||||
vault.WithAddress(addr),
|
||||
vault.WithRequestTimeout(30*time.Second),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("vault new: %w", err)
|
||||
}
|
||||
if err := client.SetToken(token); err != nil {
|
||||
return nil, fmt.Errorf("set token: %w", err)
|
||||
}
|
||||
|
||||
resp, err := client.Secrets.KvV2Read(ctx, key, vault.WithMountPath(mountPath))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp == nil || resp.Data.Data == nil {
|
||||
return nil, fmt.Errorf("vault: empty response for %s/%s", mountPath, key)
|
||||
}
|
||||
return resp.Data.Data, nil
|
||||
}
|
||||
|
||||
// tiny typed error
|
||||
type ErrNotFound string
|
||||
|
||||
func (e ErrNotFound) Error() string {
|
||||
return "vault: secret not found at " + string(e)
|
||||
}
|
||||
Reference in New Issue
Block a user