Files
warpbox-dev/backend/libs/handlers/admin.go
Daniel Legt 9a3cb90b17
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m8s
feat(accounts): implement user accounts, sessions, and dashboards
Introduce Stage 4 features to support multi-user accounts, cookie-based web sessions, and personal dashboards.

Changes include:
- Adding `/register` to bootstrap the first admin account and `/login`/`/logout` for session management.
- Creating a personal dashboard (`/app`) to display owned boxes, storage usage, and upload history.
- Implementing admin user management (`/admin/users`) for generating invite links and managing user states.
- Updating the bbolt database schema to store users, sessions, invites, and collections.
- Adding `golang.org/x/crypto` for password hashing and introducing unit tests for account handlers.
2026-05-30 15:42:35 +03:00

353 lines
9.3 KiB
Go

package handlers
import (
"crypto/sha256"
"encoding/hex"
"net/http"
"net/url"
"time"
"warpbox.dev/backend/libs/services"
"warpbox.dev/backend/libs/web"
)
const adminCookieName = "warpbox_admin"
type adminPageData struct {
Stats services.AdminStats
Boxes []adminBoxView
Users []adminUserView
LastInviteURL string
Error string
}
type adminBoxView struct {
ID string
Owner string
CreatedAt string
ExpiresAt string
FileCount int
TotalSizeLabel string
DownloadCount int
MaxDownloads int
Protected bool
Expired bool
}
type adminUserView struct {
ID string
Username string
Email string
Role string
Status string
CreatedAt string
}
func (a *App) AdminLogin(w http.ResponseWriter, r *http.Request) {
if a.isAdmin(r) {
http.Redirect(w, r, "/admin", http.StatusSeeOther)
return
}
a.renderAdminLogin(w, http.StatusOK, "")
}
func (a *App) AdminLoginPost(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
a.renderAdminLogin(w, http.StatusBadRequest, "Unable to read login form.")
return
}
if a.cfg.AdminToken == "" || r.FormValue("token") != a.cfg.AdminToken {
a.logger.Warn("admin login failed", "source", "admin", "severity", "warn", "code", 4301)
a.renderAdminLogin(w, http.StatusUnauthorized, "Invalid admin token.")
return
}
http.SetCookie(w, &http.Cookie{
Name: adminCookieName,
Value: adminCookieValue(a.cfg.AdminToken),
Path: "/admin",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: r.TLS != nil,
Expires: time.Now().Add(12 * time.Hour),
})
a.logger.Info("admin login", "source", "admin", "severity", "user_activity", "code", 2301)
http.Redirect(w, r, "/admin", http.StatusSeeOther)
}
func (a *App) AdminLogout(w http.ResponseWriter, r *http.Request) {
a.clearUserSessionCookie(w)
http.SetCookie(w, &http.Cookie{
Name: adminCookieName,
Value: "",
Path: "/admin",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
MaxAge: -1,
})
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
}
func (a *App) AdminDashboard(w http.ResponseWriter, r *http.Request) {
if !a.requireAdmin(w, r) {
return
}
stats, err := a.uploadService.AdminStats()
if err != nil {
http.Error(w, "unable to load admin stats", http.StatusInternalServerError)
return
}
boxes, err := a.adminBoxes(8)
if err != nil {
http.Error(w, "unable to load recent boxes", http.StatusInternalServerError)
return
}
a.renderer.Render(w, http.StatusOK, "admin.html", web.PageData{
Title: "Admin overview",
Description: "Warpbox admin overview.",
CurrentUser: a.currentPublicUser(r),
Data: adminPageData{
Stats: stats,
Boxes: boxes,
},
})
}
func (a *App) AdminFiles(w http.ResponseWriter, r *http.Request) {
if !a.requireAdmin(w, r) {
return
}
stats, err := a.uploadService.AdminStats()
if err != nil {
http.Error(w, "unable to load admin stats", http.StatusInternalServerError)
return
}
boxes, err := a.adminBoxes(100)
if err != nil {
http.Error(w, "unable to load boxes", http.StatusInternalServerError)
return
}
a.renderer.Render(w, http.StatusOK, "admin.html", web.PageData{
Title: "Admin files",
Description: "Manage Warpbox uploads.",
CurrentUser: a.currentPublicUser(r),
Data: adminPageData{
Stats: stats,
Boxes: boxes,
},
})
}
func (a *App) AdminUsers(w http.ResponseWriter, r *http.Request) {
if !a.requireAdmin(w, r) {
return
}
stats, err := a.uploadService.AdminStats()
if err != nil {
http.Error(w, "unable to load admin stats", http.StatusInternalServerError)
return
}
users, err := a.authService.ListUsers()
if err != nil {
http.Error(w, "unable to load users", http.StatusInternalServerError)
return
}
rows := make([]adminUserView, 0, len(users))
for _, user := range users {
rows = append(rows, adminUserView{
ID: user.ID,
Username: user.Username,
Email: user.Email,
Role: user.Role,
Status: user.Status,
CreatedAt: user.CreatedAt.Format("Jan 2 15:04"),
})
}
a.renderer.Render(w, http.StatusOK, "admin_users.html", web.PageData{
Title: "Admin users",
Description: "Manage Warpbox users and invites.",
CurrentUser: a.currentPublicUser(r),
Data: adminPageData{
Stats: stats,
Users: rows,
LastInviteURL: r.URL.Query().Get("invite"),
},
})
}
func (a *App) AdminCreateInvite(w http.ResponseWriter, r *http.Request) {
admin, ok := a.requireAdminUser(w, r)
if !ok {
return
}
if err := r.ParseForm(); err != nil {
http.Redirect(w, r, "/admin/users", http.StatusSeeOther)
return
}
result, err := a.authService.CreateInvite(r.FormValue("email"), r.FormValue("role"), admin.ID, 7*24*time.Hour)
if err != nil {
http.Redirect(w, r, "/admin/users", http.StatusSeeOther)
return
}
a.logger.Info("invite created", "source", "admin", "severity", "user_activity", "code", 2404, "admin_id", admin.ID)
http.Redirect(w, r, "/admin/users?invite="+url.QueryEscape(result.URL), http.StatusSeeOther)
}
func (a *App) AdminDisableUser(w http.ResponseWriter, r *http.Request) {
if !a.requireAdmin(w, r) {
return
}
disabled := r.URL.Query().Get("disabled") != "false"
if err := a.authService.DisableUser(r.PathValue("userID"), disabled); err != nil {
http.Error(w, "unable to update user", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/admin/users", http.StatusSeeOther)
}
func (a *App) AdminResetUser(w http.ResponseWriter, r *http.Request) {
admin, ok := a.requireAdminUser(w, r)
if !ok {
return
}
result, err := a.authService.CreatePasswordResetInvite(r.PathValue("userID"), admin.ID)
if err != nil {
http.Error(w, "unable to create reset link", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/admin/users?invite="+url.QueryEscape(result.URL), http.StatusSeeOther)
}
func (a *App) AdminDeleteBox(w http.ResponseWriter, r *http.Request) {
if !a.requireAdmin(w, r) {
return
}
boxID := r.PathValue("boxID")
if err := a.uploadService.DeleteBox(boxID); err != nil {
a.logger.Warn("admin delete failed", "source", "admin", "severity", "warn", "code", 4302, "box_id", boxID, "error", err.Error())
http.Error(w, "unable to delete box", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/admin/files", http.StatusSeeOther)
}
func (a *App) AdminViewBox(w http.ResponseWriter, r *http.Request) {
if !a.requireAdmin(w, r) {
return
}
box, err := a.uploadService.GetBox(r.PathValue("boxID"))
if err != nil {
http.NotFound(w, r)
return
}
if a.uploadService.IsProtected(box) {
http.SetCookie(w, &http.Cookie{
Name: unlockCookieName(box.ID),
Value: a.uploadService.UnlockToken(box),
Path: "/d/" + box.ID,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: r.TLS != nil,
Expires: box.ExpiresAt,
})
a.logger.Info("admin bypassed box password", "source", "admin", "severity", "user_activity", "code", 2302, "box_id", box.ID)
}
http.Redirect(w, r, "/d/"+box.ID, http.StatusSeeOther)
}
func (a *App) renderAdminLogin(w http.ResponseWriter, status int, message string) {
a.renderer.Render(w, status, "admin_login.html", web.PageData{
Title: "Admin login",
Description: "Sign in to the Warpbox admin console.",
Data: adminPageData{
Error: message,
},
})
}
func (a *App) adminBoxes(limit int) ([]adminBoxView, error) {
boxes, err := a.uploadService.AdminBoxes(limit)
if err != nil {
return nil, err
}
rows := make([]adminBoxView, 0, len(boxes))
for _, box := range boxes {
owner := "Anonymous"
if box.OwnerID != "" {
if user, err := a.authService.UserByID(box.OwnerID); err == nil {
owner = user.Email
} else {
owner = "User"
}
}
rows = append(rows, adminBoxView{
ID: box.ID,
Owner: owner,
CreatedAt: box.CreatedAt.Format("Jan 2 15:04"),
ExpiresAt: box.ExpiresAt.Format("Jan 2 15:04"),
FileCount: box.FileCount,
TotalSizeLabel: box.TotalSizeLabel,
DownloadCount: box.DownloadCount,
MaxDownloads: box.MaxDownloads,
Protected: box.Protected,
Expired: box.Expired,
})
}
return rows, nil
}
func (a *App) requireAdmin(w http.ResponseWriter, r *http.Request) bool {
if a.isAdmin(r) {
return true
}
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
return false
}
func (a *App) isAdmin(r *http.Request) bool {
if user, ok := a.currentUser(r); ok && user.Role == services.UserRoleAdmin {
return true
}
if a.cfg.AdminToken == "" {
return false
}
cookie, err := r.Cookie(adminCookieName)
if err != nil {
return false
}
return cookie.Value == adminCookieValue(a.cfg.AdminToken)
}
func (a *App) requireAdminUser(w http.ResponseWriter, r *http.Request) (services.User, bool) {
user, ok := a.currentUser(r)
if ok && user.Role == services.UserRoleAdmin {
return user, true
}
if a.cfg.AdminToken != "" && a.isAdmin(r) {
return services.User{ID: "env-admin", Role: services.UserRoleAdmin, Status: services.UserStatusActive}, true
}
http.Redirect(w, r, "/login?next="+r.URL.Path, http.StatusSeeOther)
return services.User{}, false
}
func (a *App) currentPublicUser(r *http.Request) any {
if user, ok := a.currentUser(r); ok {
return a.authService.PublicUser(user)
}
return nil
}
func adminCookieValue(token string) string {
sum := sha256.Sum256([]byte("warpbox-admin:" + token))
return hex.EncodeToString(sum[:])
}