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

36
server/Dockerfile Normal file
View File

@@ -0,0 +1,36 @@
########################
# 1) Build stage
########################
FROM --platform=$BUILDPLATFORM golang:1.23-alpine AS build
WORKDIR /src
ENV CGO_ENABLED=0
# CA certs for TLS to Vault/MinIO
RUN apk add --no-cache ca-certificates && update-ca-certificates
# Cache deps
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
# Copy source
COPY . .
# Pick your entrypoint package; default assumes ./cmd/api/main.go
# You can override APP_DIR build-arg from compose if needed.
ARG APP_DIR=./cmd/api
ARG TARGETOS TARGETARCH
RUN --mount=type=cache,target=/root/.cache/go-build \
GOOS=$TARGETOS GOARCH=$TARGETARCH \
go build -trimpath -ldflags="-s -w -buildid=" -o /out/snoop-api $APP_DIR
########################
# 2) Minimal runtime
########################
FROM gcr.io/distroless/static:nonroot
# Copy CA bundle for HTTPS calls
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=build /out/snoop-api /snoop-api
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/snoop-api"]

96
server/cmd/api/main.go Normal file
View File

@@ -0,0 +1,96 @@
package main
import (
"log"
"net/http"
"os"
"time"
"smoop-api/internal/config"
"smoop-api/internal/crypto"
"smoop-api/internal/db"
"smoop-api/internal/models"
"smoop-api/internal/router"
"smoop-api/internal/storage"
"gorm.io/gorm"
)
func main() {
// 1) Load config from Vault
// useDev := os.Getenv("CONFIG_MODE") == "dev"
var err error
var cfg *config.Config
// if useDev {
// cfg, err = config.LoadDev()
// } else {
// cfg, err = config.Load()
// }
// if err != nil {
// log.Printf("config: %v", err)
// }
cfg, err = config.LoadDev()
if err != nil {
log.Fatalf("config: %v", err)
}
// 2) DB
gormDB, err := db.Open(cfg.DB.DSN)
if err != nil {
log.Fatalf("db: %v", err)
}
if err := db.AutoMigrate(gormDB); err != nil {
log.Fatalf("migrate: %v", err)
}
if err := seedAdmin(gormDB); err != nil {
log.Fatalf("seed admin: %v", err)
}
// 3) MinIO
minioClient, err := storage.NewMinio(cfg.MinIO)
if err != nil {
log.Fatalf("minio: %v", err)
}
if err := storage.EnsureBuckets(minioClient, cfg.MinIO); err != nil {
log.Fatalf("ensure buckets: %v", err)
}
// 4) Router
engine := router.Build(gormDB, minioClient, cfg)
srv := &http.Server{
Addr: ":8080",
Handler: engine,
ReadHeaderTimeout: 10 * time.Second,
}
log.Println("listening on :8080")
log.Fatal(srv.ListenAndServe())
}
func seedAdmin(g *gorm.DB) error {
password := os.Getenv("ADMIN_PASSWORD")
if password == "" {
return nil // nothing to do
}
username := os.Getenv("ADMIN_USERNAME")
if username == "" {
username = "admin"
}
var count int64
if err := g.Model(&models.User{}).Where("username = ?", username).Count(&count).Error; err != nil {
return err
}
if count > 0 {
return nil
}
hash, err := crypto.Hash(password, crypto.DefaultArgon2)
if err != nil {
return err
}
u := models.User{Username: username, Password: hash, Role: models.RoleAdmin}
if err := g.Create(&u).Error; err != nil {
return err
}
log.Printf("created admin user %s", username)
return nil
}

67
server/go.mod Normal file
View File

@@ -0,0 +1,67 @@
module smoop-api
go 1.23.0
require github.com/gin-gonic/gin v1.10.1
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/go-ini/ini v1.67.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-retryablehttp v0.7.1 // indirect
github.com/hashicorp/go-rootcerts v1.0.2 // indirect
github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.6.0 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/minio/crc64nvme v1.0.2 // indirect
github.com/minio/md5-simd v1.1.2 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/rs/xid v1.6.0 // indirect
github.com/ryanuber/go-glob v1.0.0 // indirect
github.com/tinylib/msgp v1.3.0 // indirect
golang.org/x/sync v0.15.0 // indirect
golang.org/x/time v0.0.0-20220922220347-f3bd1da661af // indirect
)
require (
github.com/bytedance/sonic v1.11.6 // indirect
github.com/bytedance/sonic/loader v0.1.1 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.20.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/golang-jwt/jwt v3.2.2+incompatible
github.com/golang-jwt/jwt/v5 v5.3.0
github.com/gorilla/websocket v1.5.3
github.com/hashicorp/vault-client-go v0.4.3
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.11 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/minio/minio-go/v7 v7.0.95
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
golang.org/x/arch v0.8.0 // indirect
golang.org/x/crypto v0.39.0 // indirect
golang.org/x/net v0.41.0 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/text v0.26.0 // indirect
google.golang.org/protobuf v1.34.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
gorm.io/driver/postgres v1.6.0
gorm.io/gorm v1.30.1
)

157
server/go.sum Normal file
View File

@@ -0,0 +1,157 @@
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
github.com/hashicorp/go-hclog v0.9.2/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ=
github.com/hashicorp/go-retryablehttp v0.7.1 h1:sUiuQAnLlbvmExtFQs72iFW/HXeUn8Z1aJLQ4LJJbTQ=
github.com/hashicorp/go-retryablehttp v0.7.1/go.mod h1:vAew36LZh98gCBJNLH42IQ1ER/9wtLZZ8meHqQvEYWY=
github.com/hashicorp/go-rootcerts v1.0.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5Oi2viEzc=
github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8=
github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts=
github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4=
github.com/hashicorp/vault-client-go v0.4.3 h1:zG7STGVgn/VK6rnZc0k8PGbfv2x/sJExRKHSUg3ljWc=
github.com/hashicorp/vault-client-go v0.4.3/go.mod h1:4tDw7Uhq5XOxS1fO+oMtotHL7j4sB9cp0T7U6m4FzDY=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU=
github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/minio/crc64nvme v1.0.2 h1:6uO1UxGAD+kwqWWp7mBFsi5gAse66C4NXO8cmcVculg=
github.com/minio/crc64nvme v1.0.2/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
github.com/minio/minio-go/v7 v7.0.95 h1:ywOUPg+PebTMTzn9VDsoFJy32ZuARN9zhB+K3IYEvYU=
github.com/minio/minio-go/v7 v7.0.95/go.mod h1:wOOX3uxS334vImCNRVyIDdXX9OsXDm89ToynKgqUKlo=
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk=
github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tinylib/msgp v1.3.0 h1:ULuf7GPooDaIlbyvgAxBV/FI7ynli6LZ1/nVUNu+0ww=
github.com/tinylib/msgp v1.3.0/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
golang.org/x/time v0.0.0-20220922220347-f3bd1da661af h1:Yx9k8YCG3dvF87UAn2tu2HQLf2dt/eR1bXxpLMWeH+Y=
golang.org/x/time v0.0.0-20220922220347-f3bd1da661af/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
gorm.io/gorm v1.30.1 h1:lSHg33jJTBxs2mgJRfRZeLDG+WZaHYCk3Wtfl6Ngzo4=
gorm.io/gorm v1.30.1/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=

View 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
}

View 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
}

View 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
View 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{},
)
}

View 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"`
}

View 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
}

View 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"`
}

View 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,
}
}

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))
}

View 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
}

View 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
}

View 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
}

View 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) ---

View 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
}

View 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)
}