feat: API key management improvements
- Rename routes: /dashboard/tokens → /dashboard/apikeys, /dashboard/manager/keys → /dashboard/manager/channels - Add KeyPlain field to store plaintext API keys for re-viewing - API key list shows masked key (sk-ot-123456****abcd) with eye toggle to reveal - Copy button with 2s feedback on key list - TokenNew shows full key + copy after creation - Increase key prefix display to 12 characters - Fix SQLite driver: replace gorm.io/driver/sqlite with ncruces/go-sqlite3/gormlite - Fix user.status === 'active' checks across frontend views - Add channel store for new channels API - Update Makefile frontend build to work reliably BREAKING CHANGE: Existing API keys created before this change will not show their plaintext value (only prefix visible).
This commit is contained in:
+11
-9
@@ -1,6 +1,6 @@
|
||||
module opencatd-open
|
||||
|
||||
go 1.23.2
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/gin-contrib/cors v1.7.2
|
||||
@@ -12,15 +12,15 @@ require (
|
||||
github.com/google/wire v0.6.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/lib/pq v1.10.9
|
||||
github.com/ncruces/go-sqlite3/gormlite v0.34.0
|
||||
github.com/pkoukk/tiktoken-go v0.1.7
|
||||
github.com/sashabaranov/go-openai v1.42.0
|
||||
github.com/spf13/cobra v1.9.1
|
||||
golang.org/x/crypto v0.37.0
|
||||
golang.org/x/crypto v0.50.0
|
||||
golang.org/x/time v0.10.0
|
||||
gorm.io/driver/mysql v1.5.7
|
||||
gorm.io/driver/postgres v1.5.11
|
||||
gorm.io/driver/sqlite v1.6.0
|
||||
gorm.io/gorm v1.30.0
|
||||
gorm.io/gorm v1.31.1
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -52,20 +52,22 @@ require (
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.22 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/ncruces/go-sqlite3 v0.34.0 // indirect
|
||||
github.com/ncruces/go-sqlite3-wasm/v2 v2.1.35300 // indirect
|
||||
github.com/ncruces/julianday v1.0.0 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
|
||||
github.com/spf13/pflag v1.0.6 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
golang.org/x/arch v0.16.0 // indirect
|
||||
golang.org/x/net v0.39.0 // indirect
|
||||
golang.org/x/sync v0.13.0 // indirect
|
||||
golang.org/x/sys v0.32.0 // indirect
|
||||
golang.org/x/text v0.24.0 // indirect
|
||||
golang.org/x/net v0.52.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
golang.org/x/text v0.36.0 // indirect
|
||||
google.golang.org/protobuf v1.36.6 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
+20
-16
@@ -91,8 +91,6 @@ github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
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/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
@@ -100,6 +98,14 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
|
||||
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/ncruces/go-sqlite3 v0.34.0 h1:q2I6wHTLWIoz6ehYkKdG5dGQc66eJv7ZGnekhvuMfK8=
|
||||
github.com/ncruces/go-sqlite3 v0.34.0/go.mod h1:qpBxsSdGPnO9K5OExuv5GEsrGQ7Rk6JsJFH6wn2DwwU=
|
||||
github.com/ncruces/go-sqlite3-wasm/v2 v2.1.35300 h1:cRdxCt3BDfMu0vfSdoqaAPD+dzIXPkGREjqyZMLN2Ak=
|
||||
github.com/ncruces/go-sqlite3-wasm/v2 v2.1.35300/go.mod h1:R2kJLPoSA/GBX/b8x7zwOq/KLAw6rLMY1l3Hi76SQIo=
|
||||
github.com/ncruces/go-sqlite3/gormlite v0.34.0 h1:QLlOy/i7OabsFUQ+d5KyXmq2hw9sMh/CRW435+eQMRY=
|
||||
github.com/ncruces/go-sqlite3/gormlite v0.34.0/go.mod h1:CMv+6YhqLmPBXYACiQtrWA0q/JLIMTKB4E65SUfLgF0=
|
||||
github.com/ncruces/julianday v1.0.0 h1:fH0OKwa7NWvniGQtxdJRxAgkBMolni2BjDHaWTxqt7M=
|
||||
github.com/ncruces/julianday v1.0.0/go.mod h1:Dusn2KvZrrovOMJuOt0TNXL6tB7U2E8kvza5fFc9G7g=
|
||||
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
|
||||
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
|
||||
github.com/pkoukk/tiktoken-go v0.1.7 h1:qOBHXX4PHtvIvmOtyg1EeKlwFRiMKAcoMp4Q+bLQDmw=
|
||||
@@ -141,8 +147,8 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
|
||||
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
|
||||
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
|
||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
@@ -154,15 +160,15 @@ golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
|
||||
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
|
||||
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
|
||||
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
|
||||
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
@@ -173,8 +179,8 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
|
||||
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
@@ -188,8 +194,8 @@ golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
|
||||
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
golang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4=
|
||||
golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
@@ -212,9 +218,7 @@ gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo=
|
||||
gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM=
|
||||
gorm.io/driver/postgres v1.5.11 h1:ubBVAfbKEUld/twyKZ0IYn9rSQh448EdelLYk9Mv314=
|
||||
gorm.io/driver/postgres v1.5.11/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI=
|
||||
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
|
||||
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
|
||||
gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
||||
gorm.io/gorm v1.30.0 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs=
|
||||
gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
|
||||
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
||||
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||
|
||||
+336
-18
@@ -37,13 +37,33 @@ func NewHandler(db *gorm.DB) *Handler {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
func userToResponse(user *store.User) gin.H {
|
||||
roleNum := 1
|
||||
if user.Role == store.RoleAdmin {
|
||||
roleNum = 10
|
||||
}
|
||||
return gin.H{
|
||||
"id": user.ID,
|
||||
"username": user.Username,
|
||||
"email": user.Email,
|
||||
"role": roleNum,
|
||||
"status": user.Status,
|
||||
"balance": user.Balance,
|
||||
"created_at": user.CreatedAt,
|
||||
"updated_at": user.UpdatedAt,
|
||||
"last_login_at": user.LastLoginAt,
|
||||
}
|
||||
}
|
||||
|
||||
// --- Auth ---
|
||||
|
||||
func (h *Handler) Register(c *gin.Context) {
|
||||
var req struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Email string `json:"email" binding:"omitempty,email"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
@@ -60,9 +80,13 @@ func (h *Handler) Register(c *gin.Context) {
|
||||
}
|
||||
|
||||
hash := crypto.Sha256Hex(req.Password)
|
||||
email := req.Email
|
||||
if email == "" {
|
||||
email = req.Username + "@placeholder.local"
|
||||
}
|
||||
user := &store.User{
|
||||
Username: req.Username,
|
||||
Email: req.Email,
|
||||
Email: email,
|
||||
PasswordHash: hash,
|
||||
Role: role,
|
||||
Status: store.UserStatusActive,
|
||||
@@ -127,34 +151,121 @@ func (h *Handler) Me(c *gin.Context) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
|
||||
return
|
||||
}
|
||||
// Map role string to number for frontend compatibility
|
||||
roleNum := 1 // default user
|
||||
if user.Role == store.RoleAdmin {
|
||||
roleNum = 10
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 200,
|
||||
"data": gin.H{
|
||||
"id": user.ID,
|
||||
"username": user.Username,
|
||||
"email": user.Email,
|
||||
"role": roleNum,
|
||||
"status": user.Status,
|
||||
},
|
||||
"data": userToResponse(user),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateProfile(c *gin.Context) {
|
||||
userID, _ := c.Get("user_id")
|
||||
user, err := h.userDAO.GetByID(userID.(uint64))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if req.Email != "" {
|
||||
user.Email = req.Email
|
||||
}
|
||||
|
||||
if err := h.userDAO.Update(user); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "profile updated"})
|
||||
}
|
||||
|
||||
func (h *Handler) UpdatePassword(c *gin.Context) {
|
||||
userID, _ := c.Get("user_id")
|
||||
user, err := h.userDAO.GetByID(userID.(uint64))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Password string `json:"password" binding:"required"`
|
||||
NewPassword string `json:"newpassword" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Verify old password
|
||||
oldHash := crypto.Sha256Hex(req.Password)
|
||||
if user.PasswordHash != oldHash {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "incorrect password"})
|
||||
return
|
||||
}
|
||||
|
||||
// Update to new password
|
||||
user.PasswordHash = crypto.Sha256Hex(req.NewPassword)
|
||||
if err := h.userDAO.Update(user); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "password updated"})
|
||||
}
|
||||
|
||||
// --- Users ---
|
||||
|
||||
func (h *Handler) ListUsers(c *gin.Context) {
|
||||
// Support both limit/offset and pageSize/page parameters
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
|
||||
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
|
||||
|
||||
// If pageSize/page are provided, use them instead
|
||||
if pageSize := c.Query("pageSize"); pageSize != "" {
|
||||
if size, err := strconv.Atoi(pageSize); err == nil && size > 0 {
|
||||
limit = size
|
||||
}
|
||||
}
|
||||
if page := c.Query("page"); page != "" {
|
||||
if p, err := strconv.Atoi(page); err == nil && p > 0 {
|
||||
offset = (p - 1) * limit
|
||||
}
|
||||
}
|
||||
|
||||
users, total, err := h.userDAO.List(limit, offset)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": users, "total": total})
|
||||
|
||||
data := make([]gin.H, len(users))
|
||||
for i, u := range users {
|
||||
data[i] = userToResponse(u)
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": data, "total": total})
|
||||
}
|
||||
|
||||
func (h *Handler) GetUser(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
user, err := h.userDAO.GetByID(id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": userToResponse(user),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) CreateUser(c *gin.Context) {
|
||||
@@ -188,7 +299,87 @@ func (h *Handler) CreateUser(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, user)
|
||||
c.JSON(http.StatusOK, userToResponse(user))
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateUser(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.userDAO.GetByID(id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
Role string `json:"role"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if req.Username != "" {
|
||||
user.Username = req.Username
|
||||
}
|
||||
if req.Email != "" {
|
||||
user.Email = req.Email
|
||||
}
|
||||
if req.Password != "" {
|
||||
user.PasswordHash = crypto.Sha256Hex(req.Password)
|
||||
}
|
||||
if req.Role != "" {
|
||||
user.Role = req.Role
|
||||
}
|
||||
if req.Status != "" {
|
||||
user.Status = req.Status
|
||||
}
|
||||
|
||||
if err := h.userDAO.Update(user); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, userToResponse(user))
|
||||
}
|
||||
|
||||
func (h *Handler) BatchUsers(c *gin.Context) {
|
||||
option := c.Param("option")
|
||||
var req struct {
|
||||
IDs []uint64 `json:"ids"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
for _, id := range req.IDs {
|
||||
user, err := h.userDAO.GetByID(id)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
switch option {
|
||||
case "enable":
|
||||
user.Status = store.UserStatusActive
|
||||
case "disable":
|
||||
user.Status = store.UserStatusDisabled
|
||||
case "delete":
|
||||
h.userDAO.Delete(id)
|
||||
continue
|
||||
}
|
||||
h.userDAO.Update(user)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "batch operation completed"})
|
||||
}
|
||||
|
||||
func (h *Handler) DeleteUser(c *gin.Context) {
|
||||
@@ -208,8 +399,22 @@ func (h *Handler) DeleteUser(c *gin.Context) {
|
||||
|
||||
func (h *Handler) ListApiKeys(c *gin.Context) {
|
||||
userID, _ := c.Get("user_id")
|
||||
// Support both limit/offset and pageSize/page parameters
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
|
||||
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
|
||||
|
||||
// If pageSize/page are provided, use them instead
|
||||
if pageSize := c.Query("pageSize"); pageSize != "" {
|
||||
if size, err := strconv.Atoi(pageSize); err == nil && size > 0 {
|
||||
limit = size
|
||||
}
|
||||
}
|
||||
if page := c.Query("page"); page != "" {
|
||||
if p, err := strconv.Atoi(page); err == nil && p > 0 {
|
||||
offset = (p - 1) * limit
|
||||
}
|
||||
}
|
||||
|
||||
keys, total, err := h.apiKeyDAO.ListByUserID(userID.(uint64), limit, offset)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
@@ -218,6 +423,20 @@ func (h *Handler) ListApiKeys(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"data": keys, "total": total})
|
||||
}
|
||||
|
||||
func (h *Handler) GetApiKey(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
key, err := h.apiKeyDAO.GetByID(id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "key not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": key})
|
||||
}
|
||||
|
||||
func (h *Handler) CreateApiKey(c *gin.Context) {
|
||||
var req struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
@@ -236,7 +455,8 @@ func (h *Handler) CreateApiKey(c *gin.Context) {
|
||||
UserID: userID.(uint64),
|
||||
Name: req.Name,
|
||||
KeyHash: apikey.Hash(keyValue),
|
||||
KeyPrefix: keyValue[:8],
|
||||
KeyPlain: keyValue,
|
||||
KeyPrefix: keyValue[:12],
|
||||
QuotaTokensPerDay: req.QuotaTokensPerDay,
|
||||
QuotaRequestsPerDay: req.QuotaRequestsPerDay,
|
||||
Status: store.KeyStatusActive,
|
||||
@@ -249,10 +469,80 @@ func (h *Handler) CreateApiKey(c *gin.Context) {
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"key": keyValue,
|
||||
"id": key.ID,
|
||||
"data": key,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateApiKey(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
|
||||
key, err := h.apiKeyDAO.GetByID(id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "key not found"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
QuotaTokensPerDay *int64 `json:"quota_tokens_per_day"`
|
||||
QuotaRequestsPerDay *int `json:"quota_requests_per_day"`
|
||||
AllowedModels []string `json:"allowed_models"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if req.Name != "" {
|
||||
key.Name = req.Name
|
||||
}
|
||||
if req.QuotaTokensPerDay != nil {
|
||||
key.QuotaTokensPerDay = req.QuotaTokensPerDay
|
||||
}
|
||||
if req.QuotaRequestsPerDay != nil {
|
||||
key.QuotaRequestsPerDay = req.QuotaRequestsPerDay
|
||||
}
|
||||
if req.AllowedModels != nil {
|
||||
key.AllowedModels = req.AllowedModels
|
||||
}
|
||||
if req.Status != "" {
|
||||
key.Status = req.Status
|
||||
}
|
||||
|
||||
if err := h.apiKeyDAO.Update(key); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"data": key})
|
||||
}
|
||||
|
||||
func (h *Handler) BatchApiKeys(c *gin.Context) {
|
||||
option := c.Param("option")
|
||||
var req struct {
|
||||
IDs []uint64 `json:"ids"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
switch option {
|
||||
case "delete":
|
||||
if err := h.apiKeyDAO.BatchDelete(req.IDs); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "batch operation completed"})
|
||||
}
|
||||
|
||||
func (h *Handler) DeleteApiKey(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
@@ -269,8 +559,22 @@ func (h *Handler) DeleteApiKey(c *gin.Context) {
|
||||
// --- Channels ---
|
||||
|
||||
func (h *Handler) ListChannels(c *gin.Context) {
|
||||
// Support both limit/offset and pageSize/page parameters
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
|
||||
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
|
||||
|
||||
// If pageSize/page are provided, use them instead
|
||||
if pageSize := c.Query("pageSize"); pageSize != "" {
|
||||
if size, err := strconv.Atoi(pageSize); err == nil && size > 0 {
|
||||
limit = size
|
||||
}
|
||||
}
|
||||
if page := c.Query("page"); page != "" {
|
||||
if p, err := strconv.Atoi(page); err == nil && p > 0 {
|
||||
offset = (p - 1) * limit
|
||||
}
|
||||
}
|
||||
|
||||
channels, total, err := h.channelDAO.List(limit, offset)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
@@ -401,8 +705,22 @@ func (h *Handler) DeleteChannel(c *gin.Context) {
|
||||
// --- Models ---
|
||||
|
||||
func (h *Handler) ListModels(c *gin.Context) {
|
||||
// Support both limit/offset and pageSize/page parameters
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
|
||||
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
|
||||
|
||||
// If pageSize/page are provided, use them instead
|
||||
if pageSize := c.Query("pageSize"); pageSize != "" {
|
||||
if size, err := strconv.Atoi(pageSize); err == nil && size > 0 {
|
||||
limit = size
|
||||
}
|
||||
}
|
||||
if page := c.Query("page"); page != "" {
|
||||
if p, err := strconv.Atoi(page); err == nil && p > 0 {
|
||||
offset = (p - 1) * limit
|
||||
}
|
||||
}
|
||||
|
||||
models, total, err := h.modelDAO.List(limit, offset)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
|
||||
@@ -6,9 +6,9 @@ import (
|
||||
"opencatd-open/pkg/config"
|
||||
|
||||
_ "github.com/lib/pq"
|
||||
"github.com/ncruces/go-sqlite3/gormlite"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -52,7 +52,7 @@ func sqliteDialector(dsn string) gorm.Dialector {
|
||||
if dsn == "" {
|
||||
dsn = "opencatd.db"
|
||||
}
|
||||
return sqlite.Open(dsn)
|
||||
return gormlite.Open(dsn)
|
||||
}
|
||||
|
||||
func postgresDialector(dsn string) gorm.Dialector {
|
||||
|
||||
@@ -58,6 +58,7 @@ type APIKey struct {
|
||||
UserID uint64 `gorm:"index;not null" json:"user_id"`
|
||||
Name string `gorm:"size:64;not null" json:"name"`
|
||||
KeyHash string `gorm:"uniqueIndex;size:64;not null" json:"-"`
|
||||
KeyPlain string `gorm:"size:255;not null" json:"key_plain"`
|
||||
KeyPrefix string `gorm:"size:32;not null" json:"key_prefix"`
|
||||
QuotaTokensPerDay *int64 `json:"quota_tokens_per_day,omitempty"`
|
||||
QuotaRequestsPerDay *int `json:"quota_requests_per_day,omitempty"`
|
||||
|
||||
@@ -80,16 +80,24 @@ func SetRouter(cfg *config.Config, db *gorm.DB, web *embed.FS) {
|
||||
// User profile
|
||||
apiGroup.GET("/me", apiHandler.Me)
|
||||
apiGroup.GET("/profile", apiHandler.Me)
|
||||
apiGroup.POST("/profile/update", apiHandler.UpdateProfile)
|
||||
apiGroup.POST("/profile/update/password", apiHandler.UpdatePassword)
|
||||
|
||||
// User management (admin)
|
||||
apiGroup.GET("/users", apiHandler.ListUsers)
|
||||
apiGroup.GET("/users/:id", apiHandler.GetUser)
|
||||
apiGroup.POST("/users", apiHandler.CreateUser)
|
||||
apiGroup.PUT("/users/:id", apiHandler.UpdateUser)
|
||||
apiGroup.DELETE("/users/:id", apiHandler.DeleteUser)
|
||||
apiGroup.POST("/users/batch/:option", apiHandler.BatchUsers)
|
||||
|
||||
// API Key management
|
||||
apiGroup.GET("/keys", apiHandler.ListApiKeys)
|
||||
apiGroup.GET("/keys/:id", apiHandler.GetApiKey)
|
||||
apiGroup.POST("/keys", apiHandler.CreateApiKey)
|
||||
apiGroup.PUT("/keys/:id", apiHandler.UpdateApiKey)
|
||||
apiGroup.DELETE("/keys/:id", apiHandler.DeleteApiKey)
|
||||
apiGroup.POST("/keys/batch/:option", apiHandler.BatchApiKeys)
|
||||
|
||||
// Channel management
|
||||
apiGroup.GET("/channels", apiHandler.ListChannels)
|
||||
|
||||
Reference in New Issue
Block a user