feat(accounts): implement user accounts, sessions, and dashboards
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:
2026-05-30 15:42:35 +03:00
parent 33d26804a0
commit 9a3cb90b17
24 changed files with 1956 additions and 21 deletions

View File

@@ -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_ENABLED` and `WARPBOX_THUMBNAIL_ENABLED`, and their schedules are configured with
`WARPBOX_CLEANUP_EVERY` and `WARPBOX_THUMBNAIL_EVERY`. `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: 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 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. 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 ## Runtime Data
Warpbox keeps local runtime data under the configured data directory: 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}/@each@{file_id}.ext` - uploaded file contents.
- `data/files/{box_id}/@thumb@{file_id}.jpg` - generated previews where available. - `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` - 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. - `data/logs/{YYYY-MM-DD}.log` - JSONL logs, one event per line.
## Static Asset Policy ## Static Asset Policy

View File

@@ -4,7 +4,8 @@ go 1.26
require ( require (
go.etcd.io/bbolt v1.4.3 go.etcd.io/bbolt v1.4.3
golang.org/x/crypto v0.33.0
golang.org/x/image v0.41.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

View File

@@ -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= 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 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E= 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 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo=
golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA= 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 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 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.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View 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
}

View File

@@ -4,6 +4,7 @@ import (
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
"net/http" "net/http"
"net/url"
"time" "time"
"warpbox.dev/backend/libs/services" "warpbox.dev/backend/libs/services"
@@ -15,11 +16,14 @@ const adminCookieName = "warpbox_admin"
type adminPageData struct { type adminPageData struct {
Stats services.AdminStats Stats services.AdminStats
Boxes []adminBoxView Boxes []adminBoxView
Users []adminUserView
LastInviteURL string
Error string Error string
} }
type adminBoxView struct { type adminBoxView struct {
ID string ID string
Owner string
CreatedAt string CreatedAt string
ExpiresAt string ExpiresAt string
FileCount int FileCount int
@@ -30,6 +34,15 @@ type adminBoxView struct {
Expired bool Expired bool
} }
type adminUserView struct {
ID string
Username string
Email string
Role string
Status string
CreatedAt string
}
func (a *App) AdminLogin(w http.ResponseWriter, r *http.Request) { func (a *App) AdminLogin(w http.ResponseWriter, r *http.Request) {
if a.isAdmin(r) { if a.isAdmin(r) {
http.Redirect(w, r, "/admin", http.StatusSeeOther) 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) { func (a *App) AdminLogout(w http.ResponseWriter, r *http.Request) {
a.clearUserSessionCookie(w)
http.SetCookie(w, &http.Cookie{ http.SetCookie(w, &http.Cookie{
Name: adminCookieName, Name: adminCookieName,
Value: "", 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{ a.renderer.Render(w, http.StatusOK, "admin.html", web.PageData{
Title: "Admin overview", Title: "Admin overview",
Description: "Warpbox admin overview.", Description: "Warpbox admin overview.",
CurrentUser: a.currentPublicUser(r),
Data: adminPageData{ Data: adminPageData{
Stats: stats, Stats: stats,
Boxes: boxes, 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{ a.renderer.Render(w, http.StatusOK, "admin.html", web.PageData{
Title: "Admin files", Title: "Admin files",
Description: "Manage Warpbox uploads.", Description: "Manage Warpbox uploads.",
CurrentUser: a.currentPublicUser(r),
Data: adminPageData{ Data: adminPageData{
Stats: stats, Stats: stats,
Boxes: boxes, 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) { func (a *App) AdminDeleteBox(w http.ResponseWriter, r *http.Request) {
if !a.requireAdmin(w, r) { if !a.requireAdmin(w, r) {
return return
@@ -185,8 +281,17 @@ func (a *App) adminBoxes(limit int) ([]adminBoxView, error) {
rows := make([]adminBoxView, 0, len(boxes)) rows := make([]adminBoxView, 0, len(boxes))
for _, box := range 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{ rows = append(rows, adminBoxView{
ID: box.ID, ID: box.ID,
Owner: owner,
CreatedAt: box.CreatedAt.Format("Jan 2 15:04"), CreatedAt: box.CreatedAt.Format("Jan 2 15:04"),
ExpiresAt: box.ExpiresAt.Format("Jan 2 15:04"), ExpiresAt: box.ExpiresAt.Format("Jan 2 15:04"),
FileCount: box.FileCount, 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 { 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 == "" { if a.cfg.AdminToken == "" {
return false return false
} }
@@ -219,6 +327,25 @@ func (a *App) isAdmin(r *http.Request) bool {
return cookie.Value == adminCookieValue(a.cfg.AdminToken) 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 { func adminCookieValue(token string) string {
sum := sha256.Sum256([]byte("warpbox-admin:" + token)) sum := sha256.Sum256([]byte("warpbox-admin:" + token))
return hex.EncodeToString(sum[:]) return hex.EncodeToString(sum[:])

View File

@@ -14,25 +14,45 @@ type App struct {
logger *slog.Logger logger *slog.Logger
renderer *web.Renderer renderer *web.Renderer
uploadService *services.UploadService 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{ return &App{
cfg: cfg, cfg: cfg,
logger: logger, logger: logger,
renderer: renderer, renderer: renderer,
uploadService: uploadService, uploadService: uploadService,
authService: authService,
} }
} }
func (a *App) RegisterRoutes(mux *http.ServeMux) { func (a *App) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /", a.Home) mux.HandleFunc("GET /", a.Home)
mux.HandleFunc("GET /api", a.APIDocs) 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("GET /admin/login", a.AdminLogin)
mux.HandleFunc("POST /admin/login", a.AdminLoginPost) mux.HandleFunc("POST /admin/login", a.AdminLoginPost)
mux.HandleFunc("POST /admin/logout", a.AdminLogout) mux.HandleFunc("POST /admin/logout", a.AdminLogout)
mux.HandleFunc("GET /admin", a.AdminDashboard) mux.HandleFunc("GET /admin", a.AdminDashboard)
mux.HandleFunc("GET /admin/files", a.AdminFiles) 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("GET /admin/boxes/{boxID}/view", a.AdminViewBox)
mux.HandleFunc("POST /admin/boxes/{boxID}/delete", a.AdminDeleteBox) mux.HandleFunc("POST /admin/boxes/{boxID}/delete", a.AdminDeleteBox)
mux.HandleFunc("GET /d/{boxID}", a.DownloadPage) mux.HandleFunc("GET /d/{boxID}", a.DownloadPage)

View 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
}

View 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)
}

View File

@@ -3,19 +3,38 @@ package handlers
import ( import (
"net/http" "net/http"
"warpbox.dev/backend/libs/services"
"warpbox.dev/backend/libs/web" "warpbox.dev/backend/libs/web"
) )
type homeData struct { type homeData struct {
MaxUploadSize string MaxUploadSize string
Collections []collectionView
IsAdmin bool
} }
func (a *App) Home(w http.ResponseWriter, r *http.Request) { 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{ a.renderer.Render(w, http.StatusOK, "home.html", web.PageData{
Title: "Upload your files", Title: "Upload your files",
Description: "Upload and share files through a self-hosted Warpbox instance.", Description: "Upload and share files through a self-hosted Warpbox instance.",
CurrentUser: currentUser,
Data: homeData{ Data: homeData{
MaxUploadSize: a.uploadService.MaxUploadSizeLabel(), MaxUploadSize: a.uploadService.MaxUploadSizeLabel(),
Collections: collections,
IsAdmin: isAdmin,
}, },
}) })
} }

View File

@@ -14,18 +14,39 @@ import (
) )
func (a *App) Upload(w http.ResponseWriter, r *http.Request) { func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
user, loggedIn := a.currentUser(r)
isAdminUpload := loggedIn && user.Role == services.UserRoleAdmin
if !isAdminUpload {
r.Body = http.MaxBytesReader(w, r.Body, a.uploadService.MaxUploadSize()*8) r.Body = http.MaxBytesReader(w, r.Body, a.uploadService.MaxUploadSize()*8)
if err := r.ParseMultipartForm(a.uploadService.MaxUploadSize() * 8); err != nil { }
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") helpers.WriteJSONError(w, http.StatusBadRequest, "upload form could not be read")
return return
} }
files := uploadFiles(r) 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{ result, err := a.uploadService.CreateBox(files, services.UploadOptions{
MaxDays: parseInt(r.FormValue("max_days")), MaxDays: parseInt(r.FormValue("max_days")),
MaxDownloads: parseInt(r.FormValue("max_downloads")), MaxDownloads: parseInt(r.FormValue("max_downloads")),
Password: r.FormValue("password"), Password: r.FormValue("password"),
ObfuscateMetadata: r.FormValue("obfuscate_metadata") == "on", ObfuscateMetadata: r.FormValue("obfuscate_metadata") == "on",
OwnerID: ownerID,
CollectionID: collectionID,
SkipSizeLimit: isAdminUpload,
}) })
if err != nil { if err != nil {
a.logger.Warn("upload failed", "source", "user-upload", "severity", "warn", "code", 4001, "error", err.Error()) a.logger.Warn("upload failed", "source", "user-upload", "severity", "warn", "code", 4001, "error", err.Error())

View File

@@ -194,7 +194,12 @@ func newTestApp(t *testing.T) (*App, func()) {
service.Close() service.Close()
t.Fatalf("NewRenderer returned error: %v", err) 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 { if err := service.Close(); err != nil {
t.Fatalf("Close returned error: %v", err) t.Fatalf("Close returned error: %v", err)
} }

View File

@@ -22,8 +22,13 @@ func New(cfg config.Config, logger *slog.Logger) (*http.Server, error) {
if err != nil { if err != nil {
return nil, err 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) stopJobs := jobs.StartAll(cfg, logger, uploadService)
app := handlers.NewApp(cfg, logger, renderer, uploadService) app := handlers.NewApp(cfg, logger, renderer, uploadService, authService)
router := http.NewServeMux() router := http.NewServeMux()
app.RegisterRoutes(router) app.RegisterRoutes(router)

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -14,6 +14,7 @@ import (
"mime/multipart" "mime/multipart"
"os" "os"
"path/filepath" "path/filepath"
"sort"
"strings" "strings"
"time" "time"
@@ -37,10 +38,16 @@ type UploadOptions struct {
MaxDownloads int MaxDownloads int
Password string Password string
ObfuscateMetadata bool ObfuscateMetadata bool
OwnerID string
CollectionID string
SkipSizeLimit bool
} }
type Box struct { type Box struct {
ID string `json:"id"` ID string `json:"id"`
OwnerID string `json:"ownerId,omitempty"`
CollectionID string `json:"collectionId,omitempty"`
Title string `json:"title,omitempty"`
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time `json:"createdAt"`
ExpiresAt time.Time `json:"expiresAt"` ExpiresAt time.Time `json:"expiresAt"`
MaxDownloads int `json:"maxDownloads"` MaxDownloads int `json:"maxDownloads"`
@@ -93,6 +100,7 @@ type AdminStats struct {
type AdminBox struct { type AdminBox struct {
ID string ID string
OwnerID string
CreatedAt time.Time CreatedAt time.Time
ExpiresAt time.Time ExpiresAt time.Time
FileCount int FileCount int
@@ -104,6 +112,12 @@ type AdminBox struct {
Expired bool Expired bool
} }
type UserBox struct {
Box Box
CollectionName string
TotalSizeLabel string
}
func NewUploadService(maxUploadSize int64, dataDir, baseURL string, logger *slog.Logger) (*UploadService, error) { func NewUploadService(maxUploadSize int64, dataDir, baseURL string, logger *slog.Logger) (*UploadService, error) {
filesDir := filepath.Join(dataDir, "files") filesDir := filepath.Join(dataDir, "files")
dbDir := filepath.Join(dataDir, "db") dbDir := filepath.Join(dataDir, "db")
@@ -141,6 +155,10 @@ func (s *UploadService) Close() error {
return s.db.Close() return s.db.Close()
} }
func (s *UploadService) DB() *bbolt.DB {
return s.db
}
func (s *UploadService) MaxUploadSize() int64 { func (s *UploadService) MaxUploadSize() int64 {
return s.maxUploadSize return s.maxUploadSize
} }
@@ -166,6 +184,8 @@ func (s *UploadService) CreateBox(files []*multipart.FileHeader, opts UploadOpti
box := Box{ box := Box{
ID: randomID(10), ID: randomID(10),
OwnerID: strings.TrimSpace(opts.OwnerID),
CollectionID: strings.TrimSpace(opts.CollectionID),
CreatedAt: time.Now().UTC(), CreatedAt: time.Now().UTC(),
ExpiresAt: time.Now().UTC().Add(time.Duration(opts.MaxDays) * 24 * time.Hour), ExpiresAt: time.Now().UTC().Add(time.Duration(opts.MaxDays) * 24 * time.Hour),
MaxDownloads: opts.MaxDownloads, MaxDownloads: opts.MaxDownloads,
@@ -186,9 +206,16 @@ func (s *UploadService) CreateBox(files []*multipart.FileHeader, opts UploadOpti
} }
for _, header := range files { for _, header := range files {
if !opts.SkipSizeLimit {
if err := s.ValidateSize(header.Size); err != nil { if err := s.ValidateSize(header.Size); err != nil {
return UploadResult{}, err return UploadResult{}, err
} }
}
maxSize := s.maxUploadSize
if opts.SkipSizeLimit {
maxSize = 0
}
file, err := header.Open() file, err := header.Open()
if err != nil { if err != nil {
@@ -203,7 +230,7 @@ func (s *UploadService) CreateBox(files []*multipart.FileHeader, opts UploadOpti
contentType = "application/octet-stream" contentType = "application/octet-stream"
} }
if err := writeUploadedFile(storedPath, file, s.maxUploadSize); err != nil { if err := writeUploadedFile(storedPath, file, maxSize); err != nil {
file.Close() file.Close()
return UploadResult{}, err return UploadResult{}, err
} }
@@ -314,6 +341,7 @@ func (s *UploadService) AdminBoxes(limit int) ([]AdminBox, error) {
} }
rows = append(rows, AdminBox{ rows = append(rows, AdminBox{
ID: box.ID, ID: box.ID,
OwnerID: box.OwnerID,
CreatedAt: box.CreatedAt, CreatedAt: box.CreatedAt,
ExpiresAt: box.ExpiresAt, ExpiresAt: box.ExpiresAt,
FileCount: len(box.Files), FileCount: len(box.Files),
@@ -328,6 +356,85 @@ func (s *UploadService) AdminBoxes(limit int) ([]AdminBox, error) {
return rows, nil 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 { func (s *UploadService) DeleteBox(boxID string) error {
return s.DeleteBoxWithSource(boxID, "admin") return s.DeleteBoxWithSource(boxID, "admin")
} }
@@ -518,12 +625,17 @@ func writeUploadedFile(path string, source multipart.File, maxSize int64) error
} }
defer target.Close() 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 { if err != nil {
os.Remove(path) os.Remove(path)
return err return err
} }
if written > maxSize { if maxSize > 0 && written > maxSize {
os.Remove(path) os.Remove(path)
return fmt.Errorf("file exceeds max upload size") return fmt.Errorf("file exceeds max upload size")
} }

View File

@@ -20,6 +20,7 @@ type PageData struct {
Description string Description string
ImageURL string ImageURL string
CurrentYear int CurrentYear int
CurrentUser any
Data any Data any
} }

View File

@@ -90,12 +90,17 @@ svg {
.brand, .brand,
.nav-links, .nav-links,
.footer-links { .footer-links,
.inline-form {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
} }
.inline-form {
margin: 0;
}
.brand { .brand {
font-weight: 650; font-weight: 650;
text-decoration: none; text-decoration: none;
@@ -171,6 +176,125 @@ h1 {
padding: 1.5rem; 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 { .drop-zone {
min-height: 19rem; min-height: 19rem;
display: grid; display: grid;

View File

@@ -27,6 +27,13 @@
<span>{{.AppName}}</span> <span>{{.AppName}}</span>
</a> </a>
<div class="nav-links"> <div class="nav-links">
{{if .CurrentUser}}
<a class="button button-ghost" href="/app">My files</a>
<a class="button button-ghost" href="/account/settings">Account</a>
<form action="/logout" method="post" class="inline-form"><button class="button button-outline" type="submit">Logout</button></form>
{{else}}
<a class="button button-ghost" href="/login">Login</a>
{{end}}
<a class="button button-ghost" href="/api">API</a> <a class="button button-ghost" href="/api">API</a>
<a class="button button-outline" href="/healthz">Health</a> <a class="button button-outline" href="/healthz">Health</a>
</div> </div>
@@ -39,7 +46,7 @@
<footer class="site-footer"> <footer class="site-footer">
<span>{{.AppName}} · {{.CurrentYear}} · self-hosted</span> <span>{{.AppName}} · {{.CurrentYear}} · self-hosted</span>
<span class="footer-links"><a href="/">Upload</a><a href="/healthz">Health</a></span> <span class="footer-links"><a href="/">Upload</a>{{if .CurrentUser}}<a href="/app">My files</a>{{end}}<a href="/healthz">Health</a></span>
</footer> </footer>
</body> </body>
</html> </html>

View File

@@ -0,0 +1,19 @@
{{define "account.html"}}{{template "base" .}}{{end}}
{{define "content"}}
<section class="auth-view" aria-labelledby="account-title">
<div class="card auth-card">
<div class="card-content">
<p class="kicker">Account</p>
<h1 id="account-title">Settings</h1>
<p class="muted-copy">{{.Data.Email}} · {{.Data.Role}}</p>
<form class="stack-form" action="/account/password" method="post">
<label><span>Current password</span><input type="password" name="current_password" autocomplete="current-password" required></label>
<label><span>New password</span><input type="password" name="new_password" autocomplete="new-password" minlength="8" required></label>
<button class="button button-primary" type="submit">Update password</button>
</form>
<p class="muted-copy">Public forgot-password is deferred until SMTP support is added. Admins can generate reset links.</p>
</div>
</div>
</section>
{{end}}

View File

@@ -8,6 +8,7 @@
<h1 id="admin-title">Admin overview</h1> <h1 id="admin-title">Admin overview</h1>
</div> </div>
<form action="/admin/logout" method="post"> <form action="/admin/logout" method="post">
<a class="button button-outline" href="/admin/users">Users</a>
<button class="button button-outline" type="submit">Logout</button> <button class="button button-outline" type="submit">Logout</button>
</form> </form>
</div> </div>
@@ -54,6 +55,7 @@
<thead> <thead>
<tr> <tr>
<th>Box</th> <th>Box</th>
<th>Owner</th>
<th>Files</th> <th>Files</th>
<th>Size</th> <th>Size</th>
<th>Downloads</th> <th>Downloads</th>
@@ -67,6 +69,7 @@
{{range .Data.Boxes}} {{range .Data.Boxes}}
<tr> <tr>
<td><code>{{.ID}}</code></td> <td><code>{{.ID}}</code></td>
<td>{{.Owner}}</td>
<td>{{.FileCount}}</td> <td>{{.FileCount}}</td>
<td>{{.TotalSizeLabel}}</td> <td>{{.TotalSizeLabel}}</td>
<td>{{.DownloadCount}}{{if .MaxDownloads}} / {{.MaxDownloads}}{{end}}</td> <td>{{.DownloadCount}}{{if .MaxDownloads}} / {{.MaxDownloads}}{{end}}</td>
@@ -84,7 +87,7 @@
</td> </td>
</tr> </tr>
{{else}} {{else}}
<tr><td colspan="8">No uploads yet.</td></tr> <tr><td colspan="9">No uploads yet.</td></tr>
{{end}} {{end}}
</tbody> </tbody>
</table> </table>

View File

@@ -0,0 +1,67 @@
{{define "admin_users.html"}}{{template "base" .}}{{end}}
{{define "content"}}
<section class="admin-view" aria-labelledby="admin-users-title">
<div class="admin-header">
<div>
<p class="kicker">Operator console</p>
<h1 id="admin-users-title">Users</h1>
</div>
<div class="result-actions">
<a class="button button-outline" href="/admin">Overview</a>
<a class="button button-outline" href="/admin/files">Files</a>
</div>
</div>
<div class="card admin-table-card">
<div class="card-content">
<div class="table-header">
<div>
<h2>Create invite</h2>
<p>Copy the generated link and send it manually. SMTP delivery comes later.</p>
</div>
</div>
{{if .Data.LastInviteURL}}
<p class="manage-link"><span>Invite link:</span> <a href="{{.Data.LastInviteURL}}">{{.Data.LastInviteURL}}</a></p>
{{end}}
<form class="inline-controls" action="/admin/invites" method="post">
<label><span>Email</span><input type="email" name="email" required></label>
<label><span>Role</span><select name="role"><option value="user">User</option><option value="admin">Admin</option></select></label>
<button class="button button-primary" type="submit">Create invite</button>
</form>
</div>
</div>
<div class="card admin-table-card">
<div class="card-content">
<div class="table-header"><h2>Users</h2><p>Disable accounts or create reset links.</p></div>
<div class="admin-table-wrap">
<table class="admin-table">
<thead><tr><th>User</th><th>Email</th><th>Role</th><th>Status</th><th>Joined</th><th>Actions</th></tr></thead>
<tbody>
{{range .Data.Users}}
<tr>
<td>{{.Username}}</td>
<td>{{.Email}}</td>
<td>{{.Role}}</td>
<td><span class="badge">{{.Status}}</span></td>
<td>{{.CreatedAt}}</td>
<td class="table-actions">
{{if eq .Status "disabled"}}
<form action="/admin/users/{{.ID}}/disable?disabled=false" method="post"><button class="button button-outline" type="submit">Reactivate</button></form>
{{else}}
<form action="/admin/users/{{.ID}}/disable" method="post"><button class="button button-danger" type="submit">Disable</button></form>
{{end}}
<form action="/admin/users/{{.ID}}/reset" method="post"><button class="button button-outline" type="submit">Reset link</button></form>
</td>
</tr>
{{else}}
<tr><td colspan="6">No users yet.</td></tr>
{{end}}
</tbody>
</table>
</div>
</div>
</div>
</section>
{{end}}

View File

@@ -0,0 +1,43 @@
{{define "auth.html"}}{{template "base" .}}{{end}}
{{define "content"}}
<section class="auth-view" aria-labelledby="auth-title">
<div class="card auth-card">
<div class="card-content">
{{if eq .Data.Mode "register"}}
<p class="kicker">Instance bootstrap</p>
<h1 id="auth-title">Create the admin account</h1>
<p class="muted-copy">The first user becomes the instance admin. Registration closes after this account is created.</p>
<form class="stack-form" action="/register" method="post">
{{if .Data.Error}}<p class="form-error">{{.Data.Error}}</p>{{end}}
<label><span>Username</span><input name="username" autocomplete="username" required></label>
<label><span>Email</span><input type="email" name="email" autocomplete="email" required></label>
<label><span>Password</span><input type="password" name="password" autocomplete="new-password" minlength="8" required></label>
<button class="button button-primary" type="submit">Create admin</button>
</form>
{{else if eq .Data.Mode "invite"}}
<p class="kicker">{{if .Data.IsReset}}Password reset{{else}}Invite{{end}}</p>
<h1 id="auth-title">{{if .Data.IsReset}}Choose a new password{{else}}Create your account{{end}}</h1>
{{if .Data.Email}}<p class="muted-copy">{{.Data.Email}}</p>{{end}}
<form class="stack-form" action="/invite/{{.Data.Token}}" method="post">
{{if .Data.Error}}<p class="form-error">{{.Data.Error}}</p>{{end}}
{{if not .Data.IsReset}}<label><span>Username</span><input name="username" autocomplete="username" required></label>{{end}}
<label><span>Password</span><input type="password" name="password" autocomplete="new-password" minlength="8" required></label>
<button class="button button-primary" type="submit">Accept invite</button>
</form>
{{else}}
<p class="kicker">Account</p>
<h1 id="auth-title">Sign in</h1>
<form class="stack-form" action="/login" method="post">
{{if .Data.Error}}<p class="form-error">{{.Data.Error}}</p>{{end}}
<input type="hidden" name="next" value="{{.Data.ReturnPath}}">
<label><span>Email</span><input type="email" name="email" autocomplete="email" required></label>
<label><span>Password</span><input type="password" name="password" autocomplete="current-password" required></label>
<button class="button button-primary" type="submit">Sign in</button>
</form>
{{end}}
<p class="auth-alt"><a href="/">Back to upload</a></p>
</div>
</div>
</section>
{{end}}

View File

@@ -0,0 +1,70 @@
{{define "dashboard.html"}}{{template "base" .}}{{end}}
{{define "content"}}
<section class="app-shell" aria-labelledby="dashboard-title">
<aside class="app-sidebar">
<a class="sidebar-link is-active" href="/app">Dashboard</a>
<a class="sidebar-link" href="/account/settings">Settings</a>
{{if eq .Data.User.Role "admin"}}<a class="sidebar-link" href="/admin">Admin</a>{{end}}
<form class="collection-create" action="/app/collections" method="post">
<label>
<span>New collection</span>
<input name="name" placeholder="Projects">
</label>
<button class="button button-outline" type="submit">Create</button>
</form>
</aside>
<div class="app-main">
<div class="admin-header">
<div>
<p class="kicker">Personal space</p>
<h1 id="dashboard-title">My files</h1>
<p class="muted-copy">{{.Data.StorageUsed}} used · max file size {{.Data.MaxUploadSize}}</p>
</div>
<a class="button button-primary" href="/">Upload files</a>
</div>
<div class="collection-tabs">
<a class="button {{if not .Data.Selected}}button-primary{{else}}button-outline{{end}}" href="/app">All</a>
{{range .Data.Collections}}
<a class="button {{if eq $.Data.Selected .ID}}button-primary{{else}}button-outline{{end}}" href="/app?collection={{.ID}}">{{.Name}}</a>
{{end}}
</div>
<div class="card admin-table-card">
<div class="card-content">
<div class="table-header"><h2>Owned boxes</h2><p>Collections organize boxes. Shared links remain unlisted.</p></div>
<div class="admin-table-wrap">
<table class="admin-table">
<thead><tr><th>Title</th><th>Collection</th><th>Files</th><th>Size</th><th>Created</th><th>Expires</th><th>Actions</th></tr></thead>
<tbody>
{{range .Data.Boxes}}
<tr>
<td class="file-name">{{.Title}}</td>
<td>{{if .CollectionName}}{{.CollectionName}}{{else}}Unsorted{{end}}</td>
<td>{{.FileCount}}</td>
<td>{{.Size}}</td>
<td>{{.CreatedAt}}</td>
<td>{{.ExpiresAt}}</td>
<td class="table-actions">
<a class="button button-outline" href="{{.URL}}" target="_blank" rel="noopener noreferrer">Open</a>
<form action="/app/boxes/{{.ID}}/rename" method="post"><input class="compact-input" name="title" placeholder="Rename"><button class="button button-outline" type="submit">Save</button></form>
<form action="/app/boxes/{{.ID}}/move" method="post">
<select name="collection_id"><option value="">Unsorted</option>{{range $.Data.Collections}}<option value="{{.ID}}">{{.Name}}</option>{{end}}</select>
<button class="button button-outline" type="submit">Move</button>
</form>
<form action="/app/boxes/{{.ID}}/delete" method="post"><button class="button button-danger" type="submit">Delete</button></form>
</td>
</tr>
{{else}}
<tr><td colspan="7">You have no boxes yet.</td></tr>
{{end}}
</tbody>
</table>
</div>
</div>
</div>
</div>
</section>
{{end}}

View File

@@ -15,7 +15,7 @@
</span> </span>
<span class="drop-title">Drop files to upload</span> <span class="drop-title">Drop files to upload</span>
<span class="drop-copy">or click to browse</span> <span class="drop-copy">or click to browse</span>
<span class="drop-meta">Max file size: {{.Data.MaxUploadSize}} · Links expire in 7 days</span> <span class="drop-meta">{{if .Data.IsAdmin}}Admin upload: no file size limit{{else}}Max file size: {{.Data.MaxUploadSize}}{{end}} · Links expire in 7 days</span>
<input id="file-input" name="file" type="file" multiple> <input id="file-input" name="file" type="file" multiple>
</label> </label>
@@ -25,6 +25,15 @@
Advanced options Advanced options
</summary> </summary>
<div class="option-grid"> <div class="option-grid">
{{if .CurrentUser}}
<label>
<span>Collection</span>
<select name="collection_id">
<option value="">Unsorted</option>
{{range .Data.Collections}}<option value="{{.ID}}">{{.Name}}</option>{{end}}
</select>
</label>
{{end}}
<label> <label>
<span>Expires in</span> <span>Expires in</span>
<select name="max_days"> <select name="max_days">