2026-05-01 02:34:47 +03:00
|
|
|
package server
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"net/http"
|
2026-05-04 02:27:36 +03:00
|
|
|
"strconv"
|
|
|
|
|
"strings"
|
|
|
|
|
"time"
|
2026-05-01 02:34:47 +03:00
|
|
|
|
|
|
|
|
"github.com/gin-gonic/gin"
|
2026-05-04 02:27:36 +03:00
|
|
|
|
|
|
|
|
"warpbox/lib/userstore"
|
2026-05-01 02:34:47 +03:00
|
|
|
)
|
|
|
|
|
|
2026-05-04 02:27:36 +03:00
|
|
|
const bytesPerMegabyte = 1024 * 1024
|
|
|
|
|
|
|
|
|
|
type adminUserView struct {
|
|
|
|
|
ID string `json:"id"`
|
|
|
|
|
Username string `json:"username"`
|
|
|
|
|
Email string `json:"email"`
|
|
|
|
|
Status string `json:"status"`
|
|
|
|
|
Permissions userstore.Permissions `json:"permissions"`
|
|
|
|
|
Limits userstore.Limits `json:"limits"`
|
|
|
|
|
APIKeys []adminAPIKeyView `json:"api_keys"`
|
|
|
|
|
APIKeyCount int `json:"api_key_count"`
|
|
|
|
|
CreatedAt string `json:"created_at"`
|
|
|
|
|
UpdatedAt string `json:"updated_at"`
|
|
|
|
|
LastSeenAt string `json:"last_seen_at"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type adminAPIKeyView struct {
|
|
|
|
|
ID string `json:"id"`
|
|
|
|
|
Name string `json:"name"`
|
|
|
|
|
Prefix string `json:"prefix"`
|
|
|
|
|
CreatedAt string `json:"created_at"`
|
|
|
|
|
LastUsedAt string `json:"last_used_at"`
|
|
|
|
|
RevokedAt string `json:"revoked_at"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func formatMaybeTime(value *time.Time) string {
|
|
|
|
|
if value == nil || value.IsZero() {
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
return value.UTC().Format(time.RFC3339)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func toAdminAPIKeyView(key userstore.APIKey) adminAPIKeyView {
|
|
|
|
|
return adminAPIKeyView{
|
|
|
|
|
ID: key.ID,
|
|
|
|
|
Name: key.Name,
|
|
|
|
|
Prefix: key.Prefix,
|
|
|
|
|
CreatedAt: key.CreatedAt.UTC().Format(time.RFC3339),
|
|
|
|
|
LastUsedAt: formatMaybeTime(key.LastUsedAt),
|
|
|
|
|
RevokedAt: formatMaybeTime(key.RevokedAt),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func toAdminAPIKeyViews(keys []userstore.APIKey) []adminAPIKeyView {
|
|
|
|
|
views := make([]adminAPIKeyView, 0, len(keys))
|
|
|
|
|
for _, key := range keys {
|
|
|
|
|
views = append(views, toAdminAPIKeyView(key))
|
|
|
|
|
}
|
|
|
|
|
return views
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func toAdminUserView(user userstore.User) adminUserView {
|
|
|
|
|
return adminUserView{
|
|
|
|
|
ID: user.ID,
|
|
|
|
|
Username: user.Username,
|
|
|
|
|
Email: user.Email,
|
|
|
|
|
Status: user.Status,
|
|
|
|
|
Permissions: user.Permissions,
|
|
|
|
|
Limits: user.Limits,
|
|
|
|
|
APIKeys: toAdminAPIKeyViews(user.APIKeys),
|
|
|
|
|
APIKeyCount: len(user.APIKeys),
|
|
|
|
|
CreatedAt: user.CreatedAt.UTC().Format(time.RFC3339),
|
|
|
|
|
UpdatedAt: user.UpdatedAt.UTC().Format(time.RFC3339),
|
|
|
|
|
LastSeenAt: formatMaybeTime(user.LastSeenAt),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-01 02:34:47 +03:00
|
|
|
func (app *App) handleAdminUsers(ctx *gin.Context) {
|
|
|
|
|
if !app.adminLoginEnabled() {
|
|
|
|
|
ctx.Redirect(http.StatusSeeOther, "/")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
ctx.HTML(http.StatusOK, "admin/users.html", gin.H{
|
|
|
|
|
"AdminUsername": app.config.AdminUsername,
|
|
|
|
|
"AdminEmail": app.config.AdminEmail,
|
|
|
|
|
"ActivePage": "users",
|
|
|
|
|
})
|
|
|
|
|
}
|
2026-05-04 02:27:36 +03:00
|
|
|
|
|
|
|
|
func (app *App) handleAdminUsersList(ctx *gin.Context) {
|
|
|
|
|
if app.userStore == nil {
|
|
|
|
|
ctx.JSON(http.StatusServiceUnavailable, gin.H{"error": "User store unavailable"})
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
users := app.userStore.List()
|
|
|
|
|
items := make([]adminUserView, 0, len(users))
|
|
|
|
|
for _, user := range users {
|
|
|
|
|
items = append(items, toAdminUserView(user))
|
|
|
|
|
}
|
|
|
|
|
ctx.JSON(http.StatusOK, gin.H{"users": items})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func parseInt64OrZero(value string) int64 {
|
|
|
|
|
value = strings.TrimSpace(value)
|
|
|
|
|
if value == "" {
|
|
|
|
|
return 0
|
|
|
|
|
}
|
|
|
|
|
parsed, err := strconv.ParseInt(value, 10, 64)
|
|
|
|
|
if err != nil || parsed < 0 {
|
|
|
|
|
return 0
|
|
|
|
|
}
|
|
|
|
|
return parsed
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func parseMegabytesToBytesOrZero(value string) int64 {
|
|
|
|
|
megabytes := parseInt64OrZero(value)
|
|
|
|
|
if megabytes <= 0 {
|
|
|
|
|
return 0
|
|
|
|
|
}
|
|
|
|
|
return megabytes * bytesPerMegabyte
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (app *App) handleAdminUsersSave(ctx *gin.Context) {
|
|
|
|
|
if app.userStore == nil {
|
|
|
|
|
ctx.JSON(http.StatusServiceUnavailable, gin.H{"error": "User store unavailable"})
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
var payload struct {
|
|
|
|
|
ID string `json:"id"`
|
|
|
|
|
Username string `json:"username"`
|
|
|
|
|
Email string `json:"email"`
|
|
|
|
|
Status string `json:"status"`
|
|
|
|
|
MaxFileMB string `json:"max_file_size_mb"`
|
|
|
|
|
MaxBoxMB string `json:"max_box_size_mb"`
|
|
|
|
|
MaxFileSize string `json:"max_file_size_bytes"`
|
|
|
|
|
MaxBoxSize string `json:"max_box_size_bytes"`
|
|
|
|
|
Permissions struct {
|
|
|
|
|
CanUseWeb bool `json:"can_use_web"`
|
|
|
|
|
CanUseAPI bool `json:"can_use_api"`
|
|
|
|
|
CanCreateBox bool `json:"can_create_box"`
|
|
|
|
|
CanUploadFile bool `json:"can_upload_file"`
|
|
|
|
|
} `json:"permissions"`
|
|
|
|
|
}
|
|
|
|
|
if err := ctx.ShouldBindJSON(&payload); err != nil {
|
|
|
|
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user payload"})
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
permissions := userstore.Permissions{
|
|
|
|
|
CanUseWeb: payload.Permissions.CanUseWeb,
|
|
|
|
|
CanUseAPI: payload.Permissions.CanUseAPI,
|
|
|
|
|
CanCreateBox: payload.Permissions.CanCreateBox,
|
|
|
|
|
CanUploadFile: payload.Permissions.CanUploadFile,
|
|
|
|
|
}
|
|
|
|
|
limits := userstore.Limits{
|
|
|
|
|
MaxFileSizeBytes: parseMegabytesToBytesOrZero(payload.MaxFileMB),
|
|
|
|
|
MaxBoxSizeBytes: parseMegabytesToBytesOrZero(payload.MaxBoxMB),
|
|
|
|
|
}
|
|
|
|
|
if limits.MaxFileSizeBytes == 0 && strings.TrimSpace(payload.MaxFileSize) != "" {
|
|
|
|
|
limits.MaxFileSizeBytes = parseInt64OrZero(payload.MaxFileSize)
|
|
|
|
|
}
|
|
|
|
|
if limits.MaxBoxSizeBytes == 0 && strings.TrimSpace(payload.MaxBoxSize) != "" {
|
|
|
|
|
limits.MaxBoxSizeBytes = parseInt64OrZero(payload.MaxBoxSize)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var (
|
|
|
|
|
user userstore.User
|
|
|
|
|
err error
|
|
|
|
|
)
|
|
|
|
|
if strings.TrimSpace(payload.ID) == "" {
|
|
|
|
|
user, err = app.userStore.Create(payload.Username, payload.Email, permissions, limits, payload.Status)
|
|
|
|
|
} else {
|
|
|
|
|
user, err = app.userStore.Update(payload.ID, payload.Username, payload.Email, permissions, limits, payload.Status)
|
|
|
|
|
}
|
|
|
|
|
if err != nil {
|
|
|
|
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
ctx.JSON(http.StatusOK, gin.H{"ok": true, "user": toAdminUserView(user)})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (app *App) handleAdminUsersDelete(ctx *gin.Context) {
|
|
|
|
|
if app.userStore == nil {
|
|
|
|
|
ctx.JSON(http.StatusServiceUnavailable, gin.H{"error": "User store unavailable"})
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
var payload struct {
|
|
|
|
|
ID string `json:"id"`
|
|
|
|
|
}
|
|
|
|
|
if err := ctx.ShouldBindJSON(&payload); err != nil || strings.TrimSpace(payload.ID) == "" {
|
|
|
|
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "User id is required"})
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if err := app.userStore.Delete(payload.ID); err != nil {
|
|
|
|
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
ctx.JSON(http.StatusOK, gin.H{"ok": true})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (app *App) handleAdminUserAPIKeyCreate(ctx *gin.Context) {
|
|
|
|
|
if app.userStore == nil {
|
|
|
|
|
ctx.JSON(http.StatusServiceUnavailable, gin.H{"error": "User store unavailable"})
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
var payload struct {
|
|
|
|
|
UserID string `json:"user_id"`
|
|
|
|
|
Name string `json:"name"`
|
|
|
|
|
}
|
|
|
|
|
if err := ctx.ShouldBindJSON(&payload); err != nil || strings.TrimSpace(payload.UserID) == "" {
|
|
|
|
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "User id is required"})
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
key, raw, err := app.userStore.CreateAPIKey(payload.UserID, payload.Name)
|
|
|
|
|
if err != nil {
|
|
|
|
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
ctx.JSON(http.StatusOK, gin.H{"ok": true, "api_key": raw, "key": toAdminAPIKeyView(key)})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (app *App) handleAdminUserAPIKeyRevoke(ctx *gin.Context) {
|
|
|
|
|
if app.userStore == nil {
|
|
|
|
|
ctx.JSON(http.StatusServiceUnavailable, gin.H{"error": "User store unavailable"})
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
var payload struct {
|
|
|
|
|
UserID string `json:"user_id"`
|
|
|
|
|
KeyID string `json:"key_id"`
|
|
|
|
|
}
|
|
|
|
|
if err := ctx.ShouldBindJSON(&payload); err != nil || strings.TrimSpace(payload.UserID) == "" || strings.TrimSpace(payload.KeyID) == "" {
|
|
|
|
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "User id and key id are required"})
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if err := app.userStore.RevokeAPIKey(payload.UserID, payload.KeyID); err != nil {
|
|
|
|
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
ctx.JSON(http.StatusOK, gin.H{"ok": true})
|
|
|
|
|
}
|