diff --git a/README.md b/README.md index 15e9a7c..dcb8627 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,12 @@ Background jobs are enabled with `WARPBOX_JOBS_ENABLED=true`. Individual jobs ca `WARPBOX_CLEANUP_ENABLED` and `WARPBOX_THUMBNAIL_ENABLED`, and their schedules are configured with `WARPBOX_CLEANUP_EVERY` and `WARPBOX_THUMBNAIL_EVERY`. -The basic admin console is available at `/admin`. Set `WARPBOX_ADMIN_TOKEN` and use that value to sign in. +On a fresh data directory, visit `/register` to create the first account. That first user becomes +the instance admin and normal registration closes after bootstrap. Admins can create copyable invite +links from `/admin/users`. + +The env admin token still exists as emergency fallback access. Set `WARPBOX_ADMIN_TOKEN` and use it +at `/admin/login` if you need to recover access without a user session. For one-off Go commands, run them from the backend module: @@ -97,6 +102,23 @@ curl -F sharex=@./screenshot.png \ The upload endpoint accepts multipart fields named `file` and `sharex`. ShareX users can start from `examples/sharex/warpbox-anonymous.sxcu`; update `RequestURL` to match your instance URL. +## Stage 4 Accounts + Personal Boxes + +- `/register` bootstraps the first admin account only when no users exist. +- `/login` and `/logout` provide cookie-based web sessions. +- `/app` is the personal dashboard for logged-in users, showing owned boxes, storage usage, upload + history, and flat collections. Uploading still happens from the homepage. +- `/admin/users` lets admins create invite links, disable/reactivate users, and generate reset links. +- Logged-in browser uploads from `/` still use `POST /api/v1/upload`, but the resulting box is + stored with owner and optional collection metadata. +- Admin users are exempt from the global max upload size on the homepage upload flow. Future + per-user quotas should apply to this same upload path rather than creating a second uploader. +- Anonymous uploads, ShareX uploads, unlisted public box links, password protection, expiry, delete + tokens, thumbnails, and cleanup continue to work as before. + +Email delivery is intentionally deferred. Invite and reset links are copyable today; future SMTP +support will power public forgot-password and optional email delivery. + ## Runtime Data Warpbox keeps local runtime data under the configured data directory: @@ -104,6 +126,7 @@ Warpbox keeps local runtime data under the configured data directory: - `data/files/{box_id}/@each@{file_id}.ext` - uploaded file contents. - `data/files/{box_id}/@thumb@{file_id}.jpg` - generated previews where available. - `data/db/warpbox.bbolt` - bbolt metadata database for boxes and file records. +- `data/db/warpbox.bbolt` also stores users, sessions, invites, and collections. - `data/logs/{YYYY-MM-DD}.log` - JSONL logs, one event per line. ## Static Asset Policy diff --git a/backend/go.mod b/backend/go.mod index f8722a6..52b04b0 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -4,7 +4,8 @@ go 1.26 require ( go.etcd.io/bbolt v1.4.3 + golang.org/x/crypto v0.33.0 golang.org/x/image v0.41.0 ) -require golang.org/x/sys v0.29.0 // indirect +require golang.org/x/sys v0.30.0 // indirect diff --git a/backend/go.sum b/backend/go.sum index 524af7d..a6acba3 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -6,11 +6,13 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo= go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E= +golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= +golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo= golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA= golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= -golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/backend/libs/handlers/accounts_test.go b/backend/libs/handlers/accounts_test.go new file mode 100644 index 0000000..b1cea16 --- /dev/null +++ b/backend/libs/handlers/accounts_test.go @@ -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 +} diff --git a/backend/libs/handlers/admin.go b/backend/libs/handlers/admin.go index 6d9a133..f2dbb6f 100644 --- a/backend/libs/handlers/admin.go +++ b/backend/libs/handlers/admin.go @@ -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[:]) diff --git a/backend/libs/handlers/app.go b/backend/libs/handlers/app.go index 40c9d22..beb778a 100644 --- a/backend/libs/handlers/app.go +++ b/backend/libs/handlers/app.go @@ -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) diff --git a/backend/libs/handlers/auth.go b/backend/libs/handlers/auth.go new file mode 100644 index 0000000..8746fa2 --- /dev/null +++ b/backend/libs/handlers/auth.go @@ -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 +} diff --git a/backend/libs/handlers/dashboard.go b/backend/libs/handlers/dashboard.go new file mode 100644 index 0000000..a4d8d34 --- /dev/null +++ b/backend/libs/handlers/dashboard.go @@ -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) +} diff --git a/backend/libs/handlers/pages.go b/backend/libs/handlers/pages.go index e9bd3c7..e3dbd03 100644 --- a/backend/libs/handlers/pages.go +++ b/backend/libs/handlers/pages.go @@ -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, }, }) } diff --git a/backend/libs/handlers/upload.go b/backend/libs/handlers/upload.go index 3445f90..f59f209 100644 --- a/backend/libs/handlers/upload.go +++ b/backend/libs/handlers/upload.go @@ -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()) diff --git a/backend/libs/handlers/upload_stage3_test.go b/backend/libs/handlers/upload_stage3_test.go index 471e353..44b2a73 100644 --- a/backend/libs/handlers/upload_stage3_test.go +++ b/backend/libs/handlers/upload_stage3_test.go @@ -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) } diff --git a/backend/libs/httpserver/server.go b/backend/libs/httpserver/server.go index 3cab2d8..3489122 100644 --- a/backend/libs/httpserver/server.go +++ b/backend/libs/httpserver/server.go @@ -22,8 +22,13 @@ func New(cfg config.Config, logger *slog.Logger) (*http.Server, error) { if err != nil { return nil, err } + authService, err := services.NewAuthService(uploadService.DB(), cfg.BaseURL) + if err != nil { + uploadService.Close() + return nil, err + } stopJobs := jobs.StartAll(cfg, logger, uploadService) - app := handlers.NewApp(cfg, logger, renderer, uploadService) + app := handlers.NewApp(cfg, logger, renderer, uploadService, authService) router := http.NewServeMux() app.RegisterRoutes(router) diff --git a/backend/libs/services/auth.go b/backend/libs/services/auth.go new file mode 100644 index 0000000..ed62cd8 --- /dev/null +++ b/backend/libs/services/auth.go @@ -0,0 +1,579 @@ +package services + +import ( + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "net/mail" + "os" + "sort" + "strings" + "time" + + "go.etcd.io/bbolt" + "golang.org/x/crypto/argon2" +) + +var ( + usersBucket = []byte("users") + userEmailsBucket = []byte("user_emails") + sessionsBucket = []byte("sessions") + invitesBucket = []byte("invites") + collectionsBucket = []byte("collections") +) + +const ( + UserRoleAdmin = "admin" + UserRoleUser = "user" + + UserStatusActive = "active" + UserStatusDisabled = "disabled" +) + +var ( + ErrInvalidCredentials = errors.New("invalid credentials") + ErrRegistrationClosed = errors.New("registration is closed") + ErrInviteInvalid = errors.New("invite is invalid") + ErrUserDisabled = errors.New("user is disabled") +) + +type AuthService struct { + db *bbolt.DB + baseURL string +} + +type User struct { + ID string `json:"id"` + Username string `json:"username"` + Email string `json:"email"` + PasswordHash string `json:"passwordHash"` + Role string `json:"role"` + Status string `json:"status"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type PublicUser struct { + ID string + Username string + Email string + Role string + Status string + CreatedAt time.Time +} + +type Session struct { + ID string `json:"id"` + UserID string `json:"userId"` + TokenHash string `json:"tokenHash"` + ExpiresAt time.Time `json:"expiresAt"` + CreatedAt time.Time `json:"createdAt"` +} + +type Invite struct { + ID string `json:"id"` + UserID string `json:"userId,omitempty"` + Email string `json:"email"` + Role string `json:"role"` + TokenHash string `json:"tokenHash"` + CreatedBy string `json:"createdBy"` + CreatedAt time.Time `json:"createdAt"` + ExpiresAt time.Time `json:"expiresAt"` + UsedAt *time.Time `json:"usedAt,omitempty"` + UsedByUserID string `json:"usedByUserId,omitempty"` +} + +type Collection struct { + ID string `json:"id"` + UserID string `json:"userId"` + Name string `json:"name"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type InviteResult struct { + Invite Invite + URL string + Token string +} + +func NewAuthService(db *bbolt.DB, baseURL string) (*AuthService, error) { + service := &AuthService{db: db, baseURL: strings.TrimRight(baseURL, "/")} + err := db.Update(func(tx *bbolt.Tx) error { + for _, bucket := range [][]byte{usersBucket, userEmailsBucket, sessionsBucket, invitesBucket, collectionsBucket} { + if _, err := tx.CreateBucketIfNotExists(bucket); err != nil { + return err + } + } + return nil + }) + if err != nil { + return nil, err + } + return service, nil +} + +func (s *AuthService) BootstrapAvailable() (bool, error) { + count := 0 + err := s.db.View(func(tx *bbolt.Tx) error { + return tx.Bucket(usersBucket).ForEach(func(_, _ []byte) error { + count++ + return nil + }) + }) + return count == 0, err +} + +func (s *AuthService) CreateBootstrapUser(username, email, password string) (User, error) { + available, err := s.BootstrapAvailable() + if err != nil { + return User{}, err + } + if !available { + return User{}, ErrRegistrationClosed + } + return s.createUser(username, email, password, UserRoleAdmin) +} + +func (s *AuthService) Login(email, password string) (User, string, error) { + user, err := s.UserByEmail(email) + if err != nil { + return User{}, "", ErrInvalidCredentials + } + if user.Status != UserStatusActive { + return User{}, "", ErrUserDisabled + } + if !VerifyPasswordHash(user.PasswordHash, password) { + return User{}, "", ErrInvalidCredentials + } + + token := randomID(32) + session := Session{ + ID: randomID(12), + UserID: user.ID, + TokenHash: tokenHash(token), + CreatedAt: time.Now().UTC(), + ExpiresAt: time.Now().UTC().Add(30 * 24 * time.Hour), + } + err = s.db.Update(func(tx *bbolt.Tx) error { + data, err := json.Marshal(session) + if err != nil { + return err + } + return tx.Bucket(sessionsBucket).Put([]byte(session.ID), data) + }) + return user, session.ID + "." + token, err +} + +func (s *AuthService) UserForSession(raw string) (User, Session, error) { + sessionID, token, ok := strings.Cut(raw, ".") + if !ok || sessionID == "" || token == "" { + return User{}, Session{}, os.ErrNotExist + } + + var session Session + err := s.db.View(func(tx *bbolt.Tx) error { + data := tx.Bucket(sessionsBucket).Get([]byte(sessionID)) + if data == nil { + return os.ErrNotExist + } + return json.Unmarshal(data, &session) + }) + if err != nil { + return User{}, Session{}, err + } + if time.Now().UTC().After(session.ExpiresAt) || subtle.ConstantTimeCompare([]byte(tokenHash(token)), []byte(session.TokenHash)) != 1 { + return User{}, Session{}, os.ErrPermission + } + user, err := s.UserByID(session.UserID) + if err != nil { + return User{}, Session{}, err + } + if user.Status != UserStatusActive { + return User{}, Session{}, ErrUserDisabled + } + return user, session, nil +} + +func (s *AuthService) Logout(raw string) error { + sessionID, _, ok := strings.Cut(raw, ".") + if !ok || sessionID == "" { + return nil + } + return s.db.Update(func(tx *bbolt.Tx) error { + return tx.Bucket(sessionsBucket).Delete([]byte(sessionID)) + }) +} + +func (s *AuthService) CreateInvite(email, role, createdBy string, expiresIn time.Duration) (InviteResult, error) { + email, err := normalizeEmail(email) + if err != nil { + return InviteResult{}, err + } + if role == "" { + role = UserRoleUser + } + if role != UserRoleAdmin && role != UserRoleUser { + role = UserRoleUser + } + if expiresIn <= 0 { + expiresIn = 7 * 24 * time.Hour + } + + token := randomID(32) + invite := Invite{ + ID: randomID(12), + Email: email, + Role: role, + TokenHash: tokenHash(token), + CreatedBy: createdBy, + CreatedAt: time.Now().UTC(), + ExpiresAt: time.Now().UTC().Add(expiresIn), + } + err = s.saveInvite(invite) + if err != nil { + return InviteResult{}, err + } + return InviteResult{ + Invite: invite, + Token: token, + URL: fmt.Sprintf("%s/invite/%s", s.baseURL, token), + }, nil +} + +func (s *AuthService) AcceptInvite(token, username, password string) (User, error) { + invite, err := s.InviteByToken(token) + if err != nil { + return User{}, err + } + if invite.UsedAt != nil || time.Now().UTC().After(invite.ExpiresAt) { + return User{}, ErrInviteInvalid + } + + var user User + if invite.UserID != "" { + user, err = s.UserByID(invite.UserID) + if err != nil { + return User{}, err + } + if err := s.SetPassword(user.ID, password); err != nil { + return User{}, err + } + user, _ = s.UserByID(user.ID) + } else { + user, err = s.createUser(username, invite.Email, password, invite.Role) + if err != nil { + return User{}, err + } + } + + now := time.Now().UTC() + invite.UsedAt = &now + invite.UsedByUserID = user.ID + if err := s.saveInvite(invite); err != nil { + return User{}, err + } + return user, nil +} + +func (s *AuthService) InviteByToken(token string) (Invite, error) { + hash := tokenHash(token) + var match Invite + err := s.db.View(func(tx *bbolt.Tx) error { + return tx.Bucket(invitesBucket).ForEach(func(_, value []byte) error { + var invite Invite + if err := json.Unmarshal(value, &invite); err != nil { + return err + } + if subtle.ConstantTimeCompare([]byte(hash), []byte(invite.TokenHash)) == 1 { + match = invite + } + return nil + }) + }) + if err != nil { + return Invite{}, err + } + if match.ID == "" { + return Invite{}, ErrInviteInvalid + } + return match, nil +} + +func (s *AuthService) CreatePasswordResetInvite(userID, createdBy string) (InviteResult, error) { + user, err := s.UserByID(userID) + if err != nil { + return InviteResult{}, err + } + result, err := s.CreateInvite(user.Email, user.Role, createdBy, 24*time.Hour) + if err != nil { + return InviteResult{}, err + } + result.Invite.UserID = user.ID + if err := s.saveInvite(result.Invite); err != nil { + return InviteResult{}, err + } + return result, nil +} + +func (s *AuthService) ListUsers() ([]User, error) { + users := make([]User, 0) + err := s.db.View(func(tx *bbolt.Tx) error { + return tx.Bucket(usersBucket).ForEach(func(_, value []byte) error { + var user User + if err := json.Unmarshal(value, &user); err != nil { + return err + } + users = append(users, user) + return nil + }) + }) + sort.Slice(users, func(i, j int) bool { + return users[i].CreatedAt.After(users[j].CreatedAt) + }) + return users, err +} + +func (s *AuthService) DisableUser(userID string, disabled bool) error { + user, err := s.UserByID(userID) + if err != nil { + return err + } + if disabled { + user.Status = UserStatusDisabled + } else { + user.Status = UserStatusActive + } + user.UpdatedAt = time.Now().UTC() + return s.saveUser(user) +} + +func (s *AuthService) SetPassword(userID, password string) error { + if len(password) < 8 { + return fmt.Errorf("password must be at least 8 characters") + } + user, err := s.UserByID(userID) + if err != nil { + return err + } + user.PasswordHash = HashPassword(password) + user.UpdatedAt = time.Now().UTC() + return s.saveUser(user) +} + +func (s *AuthService) UserByID(id string) (User, error) { + var user User + err := s.db.View(func(tx *bbolt.Tx) error { + data := tx.Bucket(usersBucket).Get([]byte(id)) + if data == nil { + return os.ErrNotExist + } + return json.Unmarshal(data, &user) + }) + return user, err +} + +func (s *AuthService) UserByEmail(email string) (User, error) { + email, err := normalizeEmail(email) + if err != nil { + return User{}, err + } + var userID string + err = s.db.View(func(tx *bbolt.Tx) error { + data := tx.Bucket(userEmailsBucket).Get([]byte(email)) + if data == nil { + return os.ErrNotExist + } + userID = string(data) + return nil + }) + if err != nil { + return User{}, err + } + return s.UserByID(userID) +} + +func (s *AuthService) CreateCollection(userID, name string) (Collection, error) { + name = strings.TrimSpace(name) + if name == "" { + return Collection{}, fmt.Errorf("collection name is required") + } + collection := Collection{ + ID: randomID(10), + UserID: userID, + Name: name, + CreatedAt: time.Now().UTC(), + UpdatedAt: time.Now().UTC(), + } + return collection, s.saveCollection(collection) +} + +func (s *AuthService) ListCollections(userID string) ([]Collection, error) { + collections := make([]Collection, 0) + err := s.db.View(func(tx *bbolt.Tx) error { + return tx.Bucket(collectionsBucket).ForEach(func(_, value []byte) error { + var collection Collection + if err := json.Unmarshal(value, &collection); err != nil { + return err + } + if collection.UserID == userID { + collections = append(collections, collection) + } + return nil + }) + }) + sort.Slice(collections, func(i, j int) bool { + return strings.ToLower(collections[i].Name) < strings.ToLower(collections[j].Name) + }) + return collections, err +} + +func (s *AuthService) CollectionOwnedBy(collectionID, userID string) bool { + if collectionID == "" { + return true + } + collection, err := s.CollectionByID(collectionID) + return err == nil && collection.UserID == userID +} + +func (s *AuthService) CollectionByID(id string) (Collection, error) { + var collection Collection + err := s.db.View(func(tx *bbolt.Tx) error { + data := tx.Bucket(collectionsBucket).Get([]byte(id)) + if data == nil { + return os.ErrNotExist + } + return json.Unmarshal(data, &collection) + }) + return collection, err +} + +func (s *AuthService) PublicUser(user User) PublicUser { + return PublicUser{ + ID: user.ID, + Username: user.Username, + Email: user.Email, + Role: user.Role, + Status: user.Status, + CreatedAt: user.CreatedAt, + } +} + +func (s *AuthService) createUser(username, email, password, role string) (User, error) { + username = strings.TrimSpace(username) + if username == "" { + return User{}, fmt.Errorf("username is required") + } + email, err := normalizeEmail(email) + if err != nil { + return User{}, err + } + if len(password) < 8 { + return User{}, fmt.Errorf("password must be at least 8 characters") + } + if role != UserRoleAdmin && role != UserRoleUser { + role = UserRoleUser + } + + now := time.Now().UTC() + user := User{ + ID: randomID(12), + Username: username, + Email: email, + PasswordHash: HashPassword(password), + Role: role, + Status: UserStatusActive, + CreatedAt: now, + UpdatedAt: now, + } + return user, s.db.Update(func(tx *bbolt.Tx) error { + if existing := tx.Bucket(userEmailsBucket).Get([]byte(email)); existing != nil { + return fmt.Errorf("email is already registered") + } + data, err := json.Marshal(user) + if err != nil { + return err + } + if err := tx.Bucket(usersBucket).Put([]byte(user.ID), data); err != nil { + return err + } + return tx.Bucket(userEmailsBucket).Put([]byte(email), []byte(user.ID)) + }) +} + +func (s *AuthService) saveUser(user User) error { + data, err := json.Marshal(user) + if err != nil { + return err + } + return s.db.Update(func(tx *bbolt.Tx) error { + return tx.Bucket(usersBucket).Put([]byte(user.ID), data) + }) +} + +func (s *AuthService) saveInvite(invite Invite) error { + data, err := json.Marshal(invite) + if err != nil { + return err + } + return s.db.Update(func(tx *bbolt.Tx) error { + return tx.Bucket(invitesBucket).Put([]byte(invite.ID), data) + }) +} + +func (s *AuthService) saveCollection(collection Collection) error { + data, err := json.Marshal(collection) + if err != nil { + return err + } + return s.db.Update(func(tx *bbolt.Tx) error { + return tx.Bucket(collectionsBucket).Put([]byte(collection.ID), data) + }) +} + +func normalizeEmail(email string) (string, error) { + email = strings.ToLower(strings.TrimSpace(email)) + if email == "" { + return "", fmt.Errorf("email is required") + } + if _, err := mail.ParseAddress(email); err != nil { + return "", fmt.Errorf("email is invalid") + } + return email, nil +} + +func tokenHash(token string) string { + sum := sha256.Sum256([]byte("warpbox-session:" + token)) + return hex.EncodeToString(sum[:]) +} + +func HashPassword(password string) string { + salt := make([]byte, 16) + if _, err := rand.Read(salt); err != nil { + salt = []byte(randomID(16))[:16] + } + hash := argon2.IDKey([]byte(password), salt, 1, 64*1024, 4, 32) + return "argon2id$v=19$m=65536,t=1,p=4$" + base64.RawStdEncoding.EncodeToString(salt) + "$" + base64.RawStdEncoding.EncodeToString(hash) +} + +func VerifyPasswordHash(encoded, password string) bool { + parts := strings.Split(encoded, "$") + if len(parts) != 5 || parts[0] != "argon2id" { + return false + } + salt, err := base64.RawStdEncoding.DecodeString(parts[3]) + if err != nil { + return false + } + expected, err := base64.RawStdEncoding.DecodeString(parts[4]) + if err != nil { + return false + } + actual := argon2.IDKey([]byte(password), salt, 1, 64*1024, 4, uint32(len(expected))) + return subtle.ConstantTimeCompare(actual, expected) == 1 +} diff --git a/backend/libs/services/auth_test.go b/backend/libs/services/auth_test.go new file mode 100644 index 0000000..d992bfd --- /dev/null +++ b/backend/libs/services/auth_test.go @@ -0,0 +1,123 @@ +package services + +import ( + "log/slog" + "path/filepath" + "testing" + "time" +) + +func TestPasswordHashVerification(t *testing.T) { + hash := HashPassword("correct-horse") + if !VerifyPasswordHash(hash, "correct-horse") { + t.Fatalf("VerifyPasswordHash rejected the correct password") + } + if VerifyPasswordHash(hash, "wrong-password") { + t.Fatalf("VerifyPasswordHash accepted the wrong password") + } +} + +func TestBootstrapCreatesAdminAndClosesRegistration(t *testing.T) { + auth := newTestAuthService(t) + available, err := auth.BootstrapAvailable() + if err != nil { + t.Fatalf("BootstrapAvailable returned error: %v", err) + } + if !available { + t.Fatalf("BootstrapAvailable = false, want true") + } + + user, err := auth.CreateBootstrapUser("daniel", "daniel@example.test", "password123") + if err != nil { + t.Fatalf("CreateBootstrapUser returned error: %v", err) + } + if user.Role != UserRoleAdmin { + t.Fatalf("role = %q, want admin", user.Role) + } + + if _, err := auth.CreateBootstrapUser("other", "other@example.test", "password123"); err == nil { + t.Fatalf("second bootstrap unexpectedly succeeded") + } +} + +func TestLoginSessionAndDisabledUser(t *testing.T) { + auth := newTestAuthService(t) + user, err := auth.CreateBootstrapUser("daniel", "daniel@example.test", "password123") + if err != nil { + t.Fatalf("CreateBootstrapUser returned error: %v", err) + } + if _, _, err := auth.Login("daniel@example.test", "wrong"); err == nil { + t.Fatalf("Login accepted wrong password") + } + + _, token, err := auth.Login("daniel@example.test", "password123") + if err != nil { + t.Fatalf("Login returned error: %v", err) + } + sessionUser, _, err := auth.UserForSession(token) + if err != nil { + t.Fatalf("UserForSession returned error: %v", err) + } + if sessionUser.ID != user.ID { + t.Fatalf("session user = %q, want %q", sessionUser.ID, user.ID) + } + + if err := auth.DisableUser(user.ID, true); err != nil { + t.Fatalf("DisableUser returned error: %v", err) + } + if _, _, err := auth.UserForSession(token); err == nil { + t.Fatalf("disabled user session still resolved") + } +} + +func TestInviteAcceptsOnceAndResetChangesPassword(t *testing.T) { + auth := newTestAuthService(t) + admin, err := auth.CreateBootstrapUser("admin", "admin@example.test", "password123") + if err != nil { + t.Fatalf("CreateBootstrapUser returned error: %v", err) + } + invite, err := auth.CreateInvite("friend@example.test", UserRoleUser, admin.ID, time.Hour) + if err != nil { + t.Fatalf("CreateInvite returned error: %v", err) + } + user, err := auth.AcceptInvite(invite.Token, "friend", "password123") + if err != nil { + t.Fatalf("AcceptInvite returned error: %v", err) + } + if user.Email != "friend@example.test" { + t.Fatalf("email = %q, want friend@example.test", user.Email) + } + if _, err := auth.AcceptInvite(invite.Token, "friend", "password123"); err == nil { + t.Fatalf("AcceptInvite allowed token reuse") + } + + reset, err := auth.CreatePasswordResetInvite(user.ID, admin.ID) + if err != nil { + t.Fatalf("CreatePasswordResetInvite returned error: %v", err) + } + if _, err := auth.AcceptInvite(reset.Token, "", "newpassword123"); err != nil { + t.Fatalf("AcceptInvite reset returned error: %v", err) + } + if _, _, err := auth.Login("friend@example.test", "newpassword123"); err != nil { + t.Fatalf("Login with reset password returned error: %v", err) + } +} + +func newTestAuthService(t *testing.T) *AuthService { + t.Helper() + root := t.TempDir() + upload, err := NewUploadService(1024*1024, filepath.Join(root, "data"), "http://example.test", slog.Default()) + if err != nil { + t.Fatalf("NewUploadService returned error: %v", err) + } + t.Cleanup(func() { + if err := upload.Close(); err != nil { + t.Fatalf("Close returned error: %v", err) + } + }) + auth, err := NewAuthService(upload.DB(), "http://example.test") + if err != nil { + t.Fatalf("NewAuthService returned error: %v", err) + } + return auth +} diff --git a/backend/libs/services/upload.go b/backend/libs/services/upload.go index 48bf38a..43f23f9 100644 --- a/backend/libs/services/upload.go +++ b/backend/libs/services/upload.go @@ -14,6 +14,7 @@ import ( "mime/multipart" "os" "path/filepath" + "sort" "strings" "time" @@ -37,10 +38,16 @@ type UploadOptions struct { MaxDownloads int Password string ObfuscateMetadata bool + OwnerID string + CollectionID string + SkipSizeLimit bool } type Box struct { ID string `json:"id"` + OwnerID string `json:"ownerId,omitempty"` + CollectionID string `json:"collectionId,omitempty"` + Title string `json:"title,omitempty"` CreatedAt time.Time `json:"createdAt"` ExpiresAt time.Time `json:"expiresAt"` MaxDownloads int `json:"maxDownloads"` @@ -93,6 +100,7 @@ type AdminStats struct { type AdminBox struct { ID string + OwnerID string CreatedAt time.Time ExpiresAt time.Time FileCount int @@ -104,6 +112,12 @@ type AdminBox struct { Expired bool } +type UserBox struct { + Box Box + CollectionName string + TotalSizeLabel string +} + func NewUploadService(maxUploadSize int64, dataDir, baseURL string, logger *slog.Logger) (*UploadService, error) { filesDir := filepath.Join(dataDir, "files") dbDir := filepath.Join(dataDir, "db") @@ -141,6 +155,10 @@ func (s *UploadService) Close() error { return s.db.Close() } +func (s *UploadService) DB() *bbolt.DB { + return s.db +} + func (s *UploadService) MaxUploadSize() int64 { return s.maxUploadSize } @@ -166,6 +184,8 @@ func (s *UploadService) CreateBox(files []*multipart.FileHeader, opts UploadOpti box := Box{ ID: randomID(10), + OwnerID: strings.TrimSpace(opts.OwnerID), + CollectionID: strings.TrimSpace(opts.CollectionID), CreatedAt: time.Now().UTC(), ExpiresAt: time.Now().UTC().Add(time.Duration(opts.MaxDays) * 24 * time.Hour), MaxDownloads: opts.MaxDownloads, @@ -186,8 +206,15 @@ func (s *UploadService) CreateBox(files []*multipart.FileHeader, opts UploadOpti } for _, header := range files { - if err := s.ValidateSize(header.Size); err != nil { - return UploadResult{}, err + if !opts.SkipSizeLimit { + if err := s.ValidateSize(header.Size); err != nil { + return UploadResult{}, err + } + } + + maxSize := s.maxUploadSize + if opts.SkipSizeLimit { + maxSize = 0 } file, err := header.Open() @@ -203,7 +230,7 @@ func (s *UploadService) CreateBox(files []*multipart.FileHeader, opts UploadOpti contentType = "application/octet-stream" } - if err := writeUploadedFile(storedPath, file, s.maxUploadSize); err != nil { + if err := writeUploadedFile(storedPath, file, maxSize); err != nil { file.Close() return UploadResult{}, err } @@ -314,6 +341,7 @@ func (s *UploadService) AdminBoxes(limit int) ([]AdminBox, error) { } rows = append(rows, AdminBox{ ID: box.ID, + OwnerID: box.OwnerID, CreatedAt: box.CreatedAt, ExpiresAt: box.ExpiresAt, FileCount: len(box.Files), @@ -328,6 +356,85 @@ func (s *UploadService) AdminBoxes(limit int) ([]AdminBox, error) { return rows, nil } +func (s *UploadService) UserBoxes(userID string, collectionNames map[string]string) ([]UserBox, error) { + boxes, err := s.ListBoxes(0) + if err != nil { + return nil, err + } + + rows := make([]UserBox, 0) + for _, box := range boxes { + if box.OwnerID != userID { + continue + } + var size int64 + for _, file := range box.Files { + size += file.Size + } + rows = append(rows, UserBox{ + Box: box, + CollectionName: collectionNames[box.CollectionID], + TotalSizeLabel: helpers.FormatBytes(size), + }) + } + sort.Slice(rows, func(i, j int) bool { + return rows[i].Box.CreatedAt.After(rows[j].Box.CreatedAt) + }) + return rows, nil +} + +func (s *UploadService) UserStorageUsed(userID string) (int64, error) { + boxes, err := s.ListBoxes(0) + if err != nil { + return 0, err + } + var total int64 + for _, box := range boxes { + if box.OwnerID != userID { + continue + } + for _, file := range box.Files { + total += file.Size + } + } + return total, nil +} + +func (s *UploadService) RenameOwnedBox(boxID, userID, title string) error { + box, err := s.GetBox(boxID) + if err != nil { + return err + } + if box.OwnerID != userID { + return os.ErrPermission + } + box.Title = strings.TrimSpace(title) + return s.SaveBox(box) +} + +func (s *UploadService) MoveOwnedBox(boxID, userID, collectionID string) error { + box, err := s.GetBox(boxID) + if err != nil { + return err + } + if box.OwnerID != userID { + return os.ErrPermission + } + box.CollectionID = strings.TrimSpace(collectionID) + return s.SaveBox(box) +} + +func (s *UploadService) DeleteOwnedBox(boxID, userID string) error { + box, err := s.GetBox(boxID) + if err != nil { + return err + } + if box.OwnerID != userID { + return os.ErrPermission + } + return s.DeleteBoxWithSource(boxID, "user-delete") +} + func (s *UploadService) DeleteBox(boxID string) error { return s.DeleteBoxWithSource(boxID, "admin") } @@ -518,12 +625,17 @@ func writeUploadedFile(path string, source multipart.File, maxSize int64) error } defer target.Close() - written, err := io.Copy(target, io.LimitReader(source, maxSize+1)) + var written int64 + if maxSize <= 0 { + written, err = io.Copy(target, source) + } else { + written, err = io.Copy(target, io.LimitReader(source, maxSize+1)) + } if err != nil { os.Remove(path) return err } - if written > maxSize { + if maxSize > 0 && written > maxSize { os.Remove(path) return fmt.Errorf("file exceeds max upload size") } diff --git a/backend/libs/web/renderer.go b/backend/libs/web/renderer.go index 8e42baa..114ab0a 100644 --- a/backend/libs/web/renderer.go +++ b/backend/libs/web/renderer.go @@ -20,6 +20,7 @@ type PageData struct { Description string ImageURL string CurrentYear int + CurrentUser any Data any } diff --git a/backend/static/css/app.css b/backend/static/css/app.css index eccc411..1842d91 100644 --- a/backend/static/css/app.css +++ b/backend/static/css/app.css @@ -90,12 +90,17 @@ svg { .brand, .nav-links, -.footer-links { +.footer-links, +.inline-form { display: inline-flex; align-items: center; gap: 0.5rem; } +.inline-form { + margin: 0; +} + .brand { font-weight: 650; text-decoration: none; @@ -171,6 +176,125 @@ h1 { padding: 1.5rem; } +.auth-view { + width: min(28rem, calc(100% - 2rem)); + min-height: calc(100vh - 7.25rem); + margin: 0 auto; + padding: 3rem 0; + display: grid; + place-items: center; +} + +.auth-card { + box-shadow: var(--shadow); +} + +.kicker { + margin: 0 0 0.5rem; + color: var(--muted-foreground); + font-size: 0.76rem; + font-weight: 700; + letter-spacing: 0; + text-transform: uppercase; +} + +.muted-copy, +.auth-alt { + color: var(--muted-foreground); + font-size: 0.9rem; + line-height: 1.5; +} + +.stack-form { + display: grid; + gap: 0.9rem; + margin-top: 1rem; +} + +.stack-form label, +.inline-controls label, +.collection-create label { + display: grid; + gap: 0.35rem; + color: var(--muted-foreground); + font-size: 0.82rem; +} + +.form-error { + margin: 0; + color: #fca5a5; + font-size: 0.86rem; +} + +.app-shell { + width: min(86rem, calc(100% - 2rem)); + margin: 0 auto; + padding: 2rem 0; + display: grid; + grid-template-columns: 14rem minmax(0, 1fr); + gap: 1.5rem; +} + +.app-sidebar { + position: sticky; + top: 5rem; + align-self: start; + display: grid; + gap: 0.5rem; +} + +.sidebar-link { + padding: 0.62rem 0.75rem; + border: 1px solid transparent; + border-radius: var(--radius); + color: var(--muted-foreground); + text-decoration: none; +} + +.sidebar-link:hover, +.sidebar-link.is-active { + border-color: var(--border); + background: var(--muted); + color: var(--foreground); +} + +.collection-create { + display: grid; + gap: 0.6rem; + margin-top: 1rem; +} + +.app-main { + min-width: 0; + display: grid; + gap: 1rem; +} + +.compact-upload .drop-zone { + min-height: 11rem; +} + +.dashboard-options { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.collection-tabs, +.inline-controls { + display: flex; + align-items: end; + flex-wrap: wrap; + gap: 0.65rem; +} + +.inline-controls input, +.inline-controls select { + min-width: 15rem; +} + +.compact-input { + width: 10rem; +} + .drop-zone { min-height: 19rem; display: grid; diff --git a/backend/templates/layouts/base.html b/backend/templates/layouts/base.html index fcc05a6..c6edacb 100644 --- a/backend/templates/layouts/base.html +++ b/backend/templates/layouts/base.html @@ -27,6 +27,13 @@ {{.AppName}}
@@ -39,7 +46,7 @@