feat(accounts): implement user accounts, sessions, and dashboards
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m8s
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m8s
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.
This commit is contained in:
165
backend/libs/handlers/accounts_test.go
Normal file
165
backend/libs/handlers/accounts_test.go
Normal file
@@ -0,0 +1,165 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"warpbox.dev/backend/libs/services"
|
||||
)
|
||||
|
||||
func TestLoggedInUploadStoresOwnerAndAnonymousUploadDoesNot(t *testing.T) {
|
||||
app, cleanup := newTestApp(t)
|
||||
defer cleanup()
|
||||
|
||||
user, err := app.authService.CreateBootstrapUser("daniel", "daniel@example.test", "password123")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateBootstrapUser returned error: %v", err)
|
||||
}
|
||||
_, token, err := app.authService.Login("daniel@example.test", "password123")
|
||||
if err != nil {
|
||||
t.Fatalf("Login returned error: %v", err)
|
||||
}
|
||||
|
||||
request := multipartUploadRequest(t, "/api/v1/upload", "file", "owned.txt", "owned")
|
||||
request.Header.Set("Accept", "application/json")
|
||||
request.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: token})
|
||||
response := httptest.NewRecorder()
|
||||
app.Upload(response, request)
|
||||
if response.Code != http.StatusCreated {
|
||||
t.Fatalf("owned upload status = %d, body = %s", response.Code, response.Body.String())
|
||||
}
|
||||
var ownedPayload services.UploadResult
|
||||
if err := json.Unmarshal(response.Body.Bytes(), &ownedPayload); err != nil {
|
||||
t.Fatalf("json.Unmarshal owned returned error: %v", err)
|
||||
}
|
||||
ownedBox, err := app.uploadService.GetBox(ownedPayload.BoxID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetBox owned returned error: %v", err)
|
||||
}
|
||||
if ownedBox.OwnerID != user.ID {
|
||||
t.Fatalf("owned OwnerID = %q, want %q", ownedBox.OwnerID, user.ID)
|
||||
}
|
||||
|
||||
owned := uploadThroughApp(t, app)
|
||||
anonymous, err := app.uploadService.GetBox(owned.BoxID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetBox anonymous returned error: %v", err)
|
||||
}
|
||||
if anonymous.OwnerID != "" {
|
||||
t.Fatalf("anonymous OwnerID = %q, want empty", anonymous.OwnerID)
|
||||
}
|
||||
|
||||
boxes, err := app.uploadService.ListBoxes(0)
|
||||
if err != nil {
|
||||
t.Fatalf("ListBoxes returned error: %v", err)
|
||||
}
|
||||
foundOwned := false
|
||||
for _, box := range boxes {
|
||||
if box.OwnerID == user.ID {
|
||||
foundOwned = true
|
||||
}
|
||||
}
|
||||
if !foundOwned {
|
||||
t.Fatalf("logged-in upload did not store owner id %q", user.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInviteHandlerCreatesUserAndMarksInviteUsed(t *testing.T) {
|
||||
app, cleanup := newTestApp(t)
|
||||
defer cleanup()
|
||||
|
||||
admin, err := app.authService.CreateBootstrapUser("admin", "admin@example.test", "password123")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateBootstrapUser returned error: %v", err)
|
||||
}
|
||||
invite, err := app.authService.CreateInvite("friend@example.test", services.UserRoleUser, admin.ID, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateInvite returned error: %v", err)
|
||||
}
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/invite/"+invite.Token, strings.NewReader("username=friend&password=password123"))
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.SetPathValue("token", invite.Token)
|
||||
response := httptest.NewRecorder()
|
||||
app.InvitePost(response, request)
|
||||
if response.Code != http.StatusSeeOther {
|
||||
t.Fatalf("InvitePost status = %d, body = %s", response.Code, response.Body.String())
|
||||
}
|
||||
if _, err := app.authService.AcceptInvite(invite.Token, "friend", "password123"); err == nil {
|
||||
t.Fatalf("invite token remained reusable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNonOwnerCannotManageOwnedBox(t *testing.T) {
|
||||
app, cleanup := newTestApp(t)
|
||||
defer cleanup()
|
||||
|
||||
owner, err := app.authService.CreateBootstrapUser("owner", "owner@example.test", "password123")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateBootstrapUser returned error: %v", err)
|
||||
}
|
||||
invite, err := app.authService.CreateInvite("other@example.test", services.UserRoleUser, owner.ID, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateInvite returned error: %v", err)
|
||||
}
|
||||
other, err := app.authService.AcceptInvite(invite.Token, "other", "password123")
|
||||
if err != nil {
|
||||
t.Fatalf("AcceptInvite returned error: %v", err)
|
||||
}
|
||||
|
||||
result := createOwnedBoxThroughApp(t, app, owner.ID)
|
||||
if err := app.uploadService.RenameOwnedBox(result.BoxID, other.ID, "stolen"); err == nil {
|
||||
t.Fatalf("RenameOwnedBox allowed non-owner")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminUploadBypassesMaxUploadSize(t *testing.T) {
|
||||
app, cleanup := newTestApp(t)
|
||||
defer cleanup()
|
||||
|
||||
_, err := app.authService.CreateBootstrapUser("admin", "admin@example.test", "password123")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateBootstrapUser returned error: %v", err)
|
||||
}
|
||||
_, token, err := app.authService.Login("admin@example.test", "password123")
|
||||
if err != nil {
|
||||
t.Fatalf("Login returned error: %v", err)
|
||||
}
|
||||
|
||||
request := multipartUploadRequest(t, "/api/v1/upload", "file", "large.txt", strings.Repeat("x", int(app.uploadService.MaxUploadSize())+1))
|
||||
request.Header.Set("Accept", "application/json")
|
||||
request.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: token})
|
||||
response := httptest.NewRecorder()
|
||||
app.Upload(response, request)
|
||||
if response.Code != http.StatusCreated {
|
||||
t.Fatalf("admin upload status = %d, body = %s", response.Code, response.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func createOwnedBoxThroughApp(t *testing.T, app *App, userID string) services.UploadResult {
|
||||
t.Helper()
|
||||
user, err := app.authService.UserByID(userID)
|
||||
if err != nil {
|
||||
t.Fatalf("UserByID returned error: %v", err)
|
||||
}
|
||||
_, token, err := app.authService.Login(user.Email, "password123")
|
||||
if err != nil {
|
||||
t.Fatalf("Login returned error: %v", err)
|
||||
}
|
||||
request := multipartUploadRequest(t, "/api/v1/upload", "file", "owned.txt", "owned")
|
||||
request.Header.Set("Accept", "application/json")
|
||||
request.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: token})
|
||||
response := httptest.NewRecorder()
|
||||
app.Upload(response, request)
|
||||
if response.Code != http.StatusCreated {
|
||||
t.Fatalf("upload status = %d, body = %s", response.Code, response.Body.String())
|
||||
}
|
||||
var payload services.UploadResult
|
||||
if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("json.Unmarshal returned error: %v", err)
|
||||
}
|
||||
return payload
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"warpbox.dev/backend/libs/services"
|
||||
@@ -13,13 +14,16 @@ import (
|
||||
const adminCookieName = "warpbox_admin"
|
||||
|
||||
type adminPageData struct {
|
||||
Stats services.AdminStats
|
||||
Boxes []adminBoxView
|
||||
Error string
|
||||
Stats services.AdminStats
|
||||
Boxes []adminBoxView
|
||||
Users []adminUserView
|
||||
LastInviteURL string
|
||||
Error string
|
||||
}
|
||||
|
||||
type adminBoxView struct {
|
||||
ID string
|
||||
Owner string
|
||||
CreatedAt string
|
||||
ExpiresAt string
|
||||
FileCount int
|
||||
@@ -30,6 +34,15 @@ type adminBoxView struct {
|
||||
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)
|
||||
@@ -63,6 +76,7 @@ func (a *App) AdminLoginPost(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (a *App) AdminLogout(w http.ResponseWriter, r *http.Request) {
|
||||
a.clearUserSessionCookie(w)
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: adminCookieName,
|
||||
Value: "",
|
||||
@@ -93,6 +107,7 @@ func (a *App) AdminDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
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,
|
||||
@@ -119,6 +134,7 @@ func (a *App) AdminFiles(w http.ResponseWriter, r *http.Request) {
|
||||
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,
|
||||
@@ -126,6 +142,86 @@ func (a *App) AdminFiles(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
@@ -185,8 +281,17 @@ func (a *App) adminBoxes(limit int) ([]adminBoxView, error) {
|
||||
|
||||
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,
|
||||
@@ -209,6 +314,9 @@ func (a *App) requireAdmin(w http.ResponseWriter, r *http.Request) bool {
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -219,6 +327,25 @@ func (a *App) isAdmin(r *http.Request) bool {
|
||||
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[:])
|
||||
|
||||
@@ -14,25 +14,45 @@ type App struct {
|
||||
logger *slog.Logger
|
||||
renderer *web.Renderer
|
||||
uploadService *services.UploadService
|
||||
authService *services.AuthService
|
||||
}
|
||||
|
||||
func NewApp(cfg config.Config, logger *slog.Logger, renderer *web.Renderer, uploadService *services.UploadService) *App {
|
||||
func NewApp(cfg config.Config, logger *slog.Logger, renderer *web.Renderer, uploadService *services.UploadService, authService *services.AuthService) *App {
|
||||
return &App{
|
||||
cfg: cfg,
|
||||
logger: logger,
|
||||
renderer: renderer,
|
||||
uploadService: uploadService,
|
||||
authService: authService,
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) RegisterRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /", a.Home)
|
||||
mux.HandleFunc("GET /api", a.APIDocs)
|
||||
mux.HandleFunc("GET /register", a.Register)
|
||||
mux.HandleFunc("POST /register", a.RegisterPost)
|
||||
mux.HandleFunc("GET /login", a.Login)
|
||||
mux.HandleFunc("POST /login", a.LoginPost)
|
||||
mux.HandleFunc("POST /logout", a.Logout)
|
||||
mux.HandleFunc("GET /invite/{token}", a.Invite)
|
||||
mux.HandleFunc("POST /invite/{token}", a.InvitePost)
|
||||
mux.HandleFunc("GET /app", a.Dashboard)
|
||||
mux.HandleFunc("POST /app/collections", a.CreateCollection)
|
||||
mux.HandleFunc("POST /app/boxes/{boxID}/rename", a.RenameUserBox)
|
||||
mux.HandleFunc("POST /app/boxes/{boxID}/move", a.MoveUserBox)
|
||||
mux.HandleFunc("POST /app/boxes/{boxID}/delete", a.DeleteUserBox)
|
||||
mux.HandleFunc("GET /account/settings", a.AccountSettings)
|
||||
mux.HandleFunc("POST /account/password", a.ChangePassword)
|
||||
mux.HandleFunc("GET /admin/login", a.AdminLogin)
|
||||
mux.HandleFunc("POST /admin/login", a.AdminLoginPost)
|
||||
mux.HandleFunc("POST /admin/logout", a.AdminLogout)
|
||||
mux.HandleFunc("GET /admin", a.AdminDashboard)
|
||||
mux.HandleFunc("GET /admin/files", a.AdminFiles)
|
||||
mux.HandleFunc("GET /admin/users", a.AdminUsers)
|
||||
mux.HandleFunc("POST /admin/invites", a.AdminCreateInvite)
|
||||
mux.HandleFunc("POST /admin/users/{userID}/disable", a.AdminDisableUser)
|
||||
mux.HandleFunc("POST /admin/users/{userID}/reset", a.AdminResetUser)
|
||||
mux.HandleFunc("GET /admin/boxes/{boxID}/view", a.AdminViewBox)
|
||||
mux.HandleFunc("POST /admin/boxes/{boxID}/delete", a.AdminDeleteBox)
|
||||
mux.HandleFunc("GET /d/{boxID}", a.DownloadPage)
|
||||
|
||||
211
backend/libs/handlers/auth.go
Normal file
211
backend/libs/handlers/auth.go
Normal file
@@ -0,0 +1,211 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"warpbox.dev/backend/libs/services"
|
||||
"warpbox.dev/backend/libs/web"
|
||||
)
|
||||
|
||||
const userSessionCookieName = "warpbox_session"
|
||||
|
||||
type authPageData struct {
|
||||
Mode string
|
||||
Token string
|
||||
Email string
|
||||
IsReset bool
|
||||
Error string
|
||||
ReturnPath string
|
||||
}
|
||||
|
||||
func (a *App) Register(w http.ResponseWriter, r *http.Request) {
|
||||
available, err := a.authService.BootstrapAvailable()
|
||||
if err != nil {
|
||||
http.Error(w, "unable to check registration", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if !available {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
a.renderAuth(w, http.StatusOK, authPageData{Mode: "register"})
|
||||
}
|
||||
|
||||
func (a *App) RegisterPost(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
a.renderAuth(w, http.StatusBadRequest, authPageData{Mode: "register", Error: "Unable to read form."})
|
||||
return
|
||||
}
|
||||
user, err := a.authService.CreateBootstrapUser(r.FormValue("username"), r.FormValue("email"), r.FormValue("password"))
|
||||
if err != nil {
|
||||
a.renderAuth(w, http.StatusBadRequest, authPageData{Mode: "register", Error: err.Error()})
|
||||
return
|
||||
}
|
||||
a.logger.Info("first admin created", "source", "auth", "severity", "user_activity", "code", 2401, "user_id", user.ID)
|
||||
a.loginAndRedirect(w, r, user.Email, r.FormValue("password"), "/app")
|
||||
}
|
||||
|
||||
func (a *App) Login(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := a.currentUser(r); ok {
|
||||
http.Redirect(w, r, "/app", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
a.renderAuth(w, http.StatusOK, authPageData{Mode: "login", ReturnPath: r.URL.Query().Get("next")})
|
||||
}
|
||||
|
||||
func (a *App) LoginPost(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
a.renderAuth(w, http.StatusBadRequest, authPageData{Mode: "login", Error: "Unable to read form."})
|
||||
return
|
||||
}
|
||||
next := r.FormValue("next")
|
||||
if next == "" {
|
||||
next = "/app"
|
||||
}
|
||||
user, token, err := a.authService.Login(r.FormValue("email"), r.FormValue("password"))
|
||||
if err != nil {
|
||||
a.logger.Warn("login failed", "source", "auth", "severity", "warn", "code", 4401, "email", r.FormValue("email"))
|
||||
a.renderAuth(w, http.StatusUnauthorized, authPageData{Mode: "login", Error: "Invalid email or password.", ReturnPath: next})
|
||||
return
|
||||
}
|
||||
a.setUserSessionCookie(w, r, token)
|
||||
a.logger.Info("user login", "source", "auth", "severity", "user_activity", "code", 2402, "user_id", user.ID)
|
||||
http.Redirect(w, r, safeReturnPath(next), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (a *App) Logout(w http.ResponseWriter, r *http.Request) {
|
||||
if cookie, err := r.Cookie(userSessionCookieName); err == nil {
|
||||
_ = a.authService.Logout(cookie.Value)
|
||||
}
|
||||
a.clearUserSessionCookie(w)
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (a *App) Invite(w http.ResponseWriter, r *http.Request) {
|
||||
invite, err := a.authService.InviteByToken(r.PathValue("token"))
|
||||
if err != nil || invite.UsedAt != nil || time.Now().UTC().After(invite.ExpiresAt) {
|
||||
a.renderAuth(w, http.StatusNotFound, authPageData{Mode: "invite", Error: "This invite is invalid or expired."})
|
||||
return
|
||||
}
|
||||
a.renderAuth(w, http.StatusOK, authPageData{Mode: "invite", Token: r.PathValue("token"), Email: invite.Email, IsReset: invite.UserID != ""})
|
||||
}
|
||||
|
||||
func (a *App) InvitePost(w http.ResponseWriter, r *http.Request) {
|
||||
token := r.PathValue("token")
|
||||
invite, err := a.authService.InviteByToken(token)
|
||||
if err != nil {
|
||||
a.renderAuth(w, http.StatusNotFound, authPageData{Mode: "invite", Error: "This invite is invalid or expired."})
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
a.renderAuth(w, http.StatusBadRequest, authPageData{Mode: "invite", Token: token, Email: invite.Email, IsReset: invite.UserID != "", Error: "Unable to read form."})
|
||||
return
|
||||
}
|
||||
user, err := a.authService.AcceptInvite(token, r.FormValue("username"), r.FormValue("password"))
|
||||
if err != nil {
|
||||
a.renderAuth(w, http.StatusBadRequest, authPageData{Mode: "invite", Token: token, Email: invite.Email, IsReset: invite.UserID != "", Error: err.Error()})
|
||||
return
|
||||
}
|
||||
a.logger.Info("invite accepted", "source", "auth", "severity", "user_activity", "code", 2403, "user_id", user.ID)
|
||||
a.loginAndRedirect(w, r, user.Email, r.FormValue("password"), "/app")
|
||||
}
|
||||
|
||||
func (a *App) AccountSettings(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := a.requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
a.renderer.Render(w, http.StatusOK, "account.html", web.PageData{
|
||||
Title: "Account settings",
|
||||
Description: "Manage your Warpbox account.",
|
||||
CurrentUser: a.authService.PublicUser(user),
|
||||
Data: user,
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) ChangePassword(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := a.requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Redirect(w, r, "/account/settings", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if !services.VerifyPasswordHash(user.PasswordHash, r.FormValue("current_password")) {
|
||||
http.Redirect(w, r, "/account/settings", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if err := a.authService.SetPassword(user.ID, r.FormValue("new_password")); err != nil {
|
||||
http.Redirect(w, r, "/account/settings", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/account/settings", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (a *App) renderAuth(w http.ResponseWriter, status int, data authPageData) {
|
||||
a.renderer.Render(w, status, "auth.html", web.PageData{
|
||||
Title: "Account",
|
||||
Description: "Sign in to Warpbox.",
|
||||
Data: data,
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) loginAndRedirect(w http.ResponseWriter, r *http.Request, email, password, path string) {
|
||||
_, token, err := a.authService.Login(email, password)
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
a.setUserSessionCookie(w, r, token)
|
||||
http.Redirect(w, r, path, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (a *App) currentUser(r *http.Request) (services.User, bool) {
|
||||
cookie, err := r.Cookie(userSessionCookieName)
|
||||
if err != nil {
|
||||
return services.User{}, false
|
||||
}
|
||||
user, _, err := a.authService.UserForSession(cookie.Value)
|
||||
return user, err == nil
|
||||
}
|
||||
|
||||
func (a *App) requireUser(w http.ResponseWriter, r *http.Request) (services.User, bool) {
|
||||
user, ok := a.currentUser(r)
|
||||
if ok {
|
||||
return user, true
|
||||
}
|
||||
http.Redirect(w, r, "/login?next="+r.URL.Path, http.StatusSeeOther)
|
||||
return services.User{}, false
|
||||
}
|
||||
|
||||
func (a *App) setUserSessionCookie(w http.ResponseWriter, r *http.Request, token string) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: userSessionCookieName,
|
||||
Value: token,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Secure: r.TLS != nil,
|
||||
Expires: time.Now().Add(30 * 24 * time.Hour),
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) clearUserSessionCookie(w http.ResponseWriter) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: userSessionCookieName,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: -1,
|
||||
})
|
||||
}
|
||||
|
||||
func safeReturnPath(path string) string {
|
||||
if path == "" || path[0] != '/' || len(path) > 1 && path[1] == '/' {
|
||||
return "/app"
|
||||
}
|
||||
return path
|
||||
}
|
||||
179
backend/libs/handlers/dashboard.go
Normal file
179
backend/libs/handlers/dashboard.go
Normal file
@@ -0,0 +1,179 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"warpbox.dev/backend/libs/helpers"
|
||||
"warpbox.dev/backend/libs/services"
|
||||
"warpbox.dev/backend/libs/web"
|
||||
)
|
||||
|
||||
type dashboardData struct {
|
||||
User services.PublicUser
|
||||
Collections []collectionView
|
||||
Boxes []userBoxView
|
||||
StorageUsed string
|
||||
MaxUploadSize string
|
||||
Selected string
|
||||
LastInviteURL string
|
||||
}
|
||||
|
||||
type collectionView struct {
|
||||
ID string
|
||||
Name string
|
||||
}
|
||||
|
||||
type userBoxView struct {
|
||||
ID string
|
||||
Title string
|
||||
CollectionID string
|
||||
CollectionName string
|
||||
FileCount int
|
||||
Size string
|
||||
CreatedAt string
|
||||
ExpiresAt string
|
||||
URL string
|
||||
}
|
||||
|
||||
func (a *App) Dashboard(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := a.requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
collections, err := a.authService.ListCollections(user.ID)
|
||||
if err != nil {
|
||||
http.Error(w, "unable to load collections", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
collectionNames := map[string]string{}
|
||||
collectionViews := make([]collectionView, 0, len(collections))
|
||||
for _, collection := range collections {
|
||||
collectionNames[collection.ID] = collection.Name
|
||||
collectionViews = append(collectionViews, collectionView{ID: collection.ID, Name: collection.Name})
|
||||
}
|
||||
boxes, err := a.uploadService.UserBoxes(user.ID, collectionNames)
|
||||
if err != nil {
|
||||
http.Error(w, "unable to load boxes", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
storageUsed, err := a.uploadService.UserStorageUsed(user.ID)
|
||||
if err != nil {
|
||||
http.Error(w, "unable to load storage usage", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
selected := r.URL.Query().Get("collection")
|
||||
boxViews := make([]userBoxView, 0, len(boxes))
|
||||
for _, row := range boxes {
|
||||
if selected != "" && row.Box.CollectionID != selected {
|
||||
continue
|
||||
}
|
||||
title := row.Box.Title
|
||||
if title == "" {
|
||||
title = fmt.Sprintf("%d file upload", len(row.Box.Files))
|
||||
}
|
||||
boxViews = append(boxViews, userBoxView{
|
||||
ID: row.Box.ID,
|
||||
Title: title,
|
||||
CollectionID: row.Box.CollectionID,
|
||||
CollectionName: row.CollectionName,
|
||||
FileCount: len(row.Box.Files),
|
||||
Size: row.TotalSizeLabel,
|
||||
CreatedAt: row.Box.CreatedAt.Format("Jan 2 15:04"),
|
||||
ExpiresAt: row.Box.ExpiresAt.Format("Jan 2 15:04"),
|
||||
URL: "/d/" + row.Box.ID,
|
||||
})
|
||||
}
|
||||
|
||||
a.renderer.Render(w, http.StatusOK, "dashboard.html", web.PageData{
|
||||
Title: "My files",
|
||||
Description: "Your Warpbox personal file space.",
|
||||
CurrentUser: a.authService.PublicUser(user),
|
||||
Data: dashboardData{
|
||||
User: a.authService.PublicUser(user),
|
||||
Collections: collectionViews,
|
||||
Boxes: boxViews,
|
||||
StorageUsed: helpers.FormatBytes(storageUsed),
|
||||
MaxUploadSize: a.uploadService.MaxUploadSizeLabel(),
|
||||
Selected: selected,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) CreateCollection(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := a.requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Redirect(w, r, "/app", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if _, err := a.authService.CreateCollection(user.ID, r.FormValue("name")); err != nil {
|
||||
a.logger.Warn("collection create failed", "source", "user_activity", "severity", "warn", "code", 4410, "user_id", user.ID, "error", err.Error())
|
||||
}
|
||||
http.Redirect(w, r, "/app", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (a *App) RenameUserBox(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := a.requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Redirect(w, r, "/app", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if err := a.uploadService.RenameOwnedBox(r.PathValue("boxID"), user.ID, r.FormValue("title")); err != nil {
|
||||
a.handleUserBoxError(w, r, err)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/app", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (a *App) MoveUserBox(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := a.requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Redirect(w, r, "/app", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
collectionID := r.FormValue("collection_id")
|
||||
if !a.authService.CollectionOwnedBy(collectionID, user.ID) {
|
||||
http.Error(w, "collection not found", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if err := a.uploadService.MoveOwnedBox(r.PathValue("boxID"), user.ID, collectionID); err != nil {
|
||||
a.handleUserBoxError(w, r, err)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/app", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (a *App) DeleteUserBox(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := a.requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := a.uploadService.DeleteOwnedBox(r.PathValue("boxID"), user.ID); err != nil {
|
||||
a.handleUserBoxError(w, r, err)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/app", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (a *App) handleUserBoxError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
if os.IsPermission(err) {
|
||||
http.Error(w, "not allowed", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if os.IsNotExist(err) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
http.Error(w, "unable to update box", http.StatusInternalServerError)
|
||||
}
|
||||
@@ -3,19 +3,38 @@ package handlers
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"warpbox.dev/backend/libs/services"
|
||||
"warpbox.dev/backend/libs/web"
|
||||
)
|
||||
|
||||
type homeData struct {
|
||||
MaxUploadSize string
|
||||
Collections []collectionView
|
||||
IsAdmin bool
|
||||
}
|
||||
|
||||
func (a *App) Home(w http.ResponseWriter, r *http.Request) {
|
||||
currentUser := a.currentPublicUser(r)
|
||||
var collections []collectionView
|
||||
var isAdmin bool
|
||||
if user, ok := a.currentUser(r); ok {
|
||||
isAdmin = user.Role == services.UserRoleAdmin
|
||||
userCollections, err := a.authService.ListCollections(user.ID)
|
||||
if err == nil {
|
||||
collections = make([]collectionView, 0, len(userCollections))
|
||||
for _, collection := range userCollections {
|
||||
collections = append(collections, collectionView{ID: collection.ID, Name: collection.Name})
|
||||
}
|
||||
}
|
||||
}
|
||||
a.renderer.Render(w, http.StatusOK, "home.html", web.PageData{
|
||||
Title: "Upload your files",
|
||||
Description: "Upload and share files through a self-hosted Warpbox instance.",
|
||||
CurrentUser: currentUser,
|
||||
Data: homeData{
|
||||
MaxUploadSize: a.uploadService.MaxUploadSizeLabel(),
|
||||
Collections: collections,
|
||||
IsAdmin: isAdmin,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -14,18 +14,39 @@ import (
|
||||
)
|
||||
|
||||
func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, a.uploadService.MaxUploadSize()*8)
|
||||
if err := r.ParseMultipartForm(a.uploadService.MaxUploadSize() * 8); err != nil {
|
||||
user, loggedIn := a.currentUser(r)
|
||||
isAdminUpload := loggedIn && user.Role == services.UserRoleAdmin
|
||||
if !isAdminUpload {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, a.uploadService.MaxUploadSize()*8)
|
||||
}
|
||||
parseLimit := a.uploadService.MaxUploadSize() * 8
|
||||
if isAdminUpload {
|
||||
parseLimit = 32 << 20
|
||||
}
|
||||
if err := r.ParseMultipartForm(parseLimit); err != nil {
|
||||
helpers.WriteJSONError(w, http.StatusBadRequest, "upload form could not be read")
|
||||
return
|
||||
}
|
||||
|
||||
files := uploadFiles(r)
|
||||
var ownerID string
|
||||
var collectionID string
|
||||
if loggedIn {
|
||||
ownerID = user.ID
|
||||
collectionID = r.FormValue("collection_id")
|
||||
if !a.authService.CollectionOwnedBy(collectionID, user.ID) {
|
||||
helpers.WriteJSONError(w, http.StatusForbidden, "collection not found")
|
||||
return
|
||||
}
|
||||
}
|
||||
result, err := a.uploadService.CreateBox(files, services.UploadOptions{
|
||||
MaxDays: parseInt(r.FormValue("max_days")),
|
||||
MaxDownloads: parseInt(r.FormValue("max_downloads")),
|
||||
Password: r.FormValue("password"),
|
||||
ObfuscateMetadata: r.FormValue("obfuscate_metadata") == "on",
|
||||
OwnerID: ownerID,
|
||||
CollectionID: collectionID,
|
||||
SkipSizeLimit: isAdminUpload,
|
||||
})
|
||||
if err != nil {
|
||||
a.logger.Warn("upload failed", "source", "user-upload", "severity", "warn", "code", 4001, "error", err.Error())
|
||||
|
||||
@@ -194,7 +194,12 @@ func newTestApp(t *testing.T) (*App, func()) {
|
||||
service.Close()
|
||||
t.Fatalf("NewRenderer returned error: %v", err)
|
||||
}
|
||||
return NewApp(cfg, logger, renderer, service), func() {
|
||||
authService, err := services.NewAuthService(service.DB(), cfg.BaseURL)
|
||||
if err != nil {
|
||||
service.Close()
|
||||
t.Fatalf("NewAuthService returned error: %v", err)
|
||||
}
|
||||
return NewApp(cfg, logger, renderer, service, authService), func() {
|
||||
if err := service.Close(); err != nil {
|
||||
t.Fatalf("Close returned error: %v", err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user