- Introduce S3-compatible storage backend support using minio-go. - Add configuration options for local storage limits, box limits, and rate limiting. - Implement storage backend selection (local vs S3) for anonymous and registered users. - Add an `/admin/storage` management interface. - Update documentation and environment examples with the new configuration variables.
223 lines
7.3 KiB
Go
223 lines
7.3 KiB
Go
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, r, http.StatusOK, authPageData{Mode: "register"})
|
|
}
|
|
|
|
func (a *App) RegisterPost(w http.ResponseWriter, r *http.Request) {
|
|
if !a.rateLimiter.Allow("register:"+uploadClientIP(r), 10, time.Minute, time.Now().UTC()) {
|
|
a.renderAuth(w, r, http.StatusTooManyRequests, authPageData{Mode: "register", Error: "Too many registration attempts."})
|
|
return
|
|
}
|
|
if err := r.ParseForm(); err != nil {
|
|
a.renderAuth(w, r, 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, r, 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, r, http.StatusOK, authPageData{Mode: "login", ReturnPath: r.URL.Query().Get("next")})
|
|
}
|
|
|
|
func (a *App) LoginPost(w http.ResponseWriter, r *http.Request) {
|
|
if !a.rateLimiter.Allow("login:"+uploadClientIP(r), 10, time.Minute, time.Now().UTC()) {
|
|
a.renderAuth(w, r, http.StatusTooManyRequests, authPageData{Mode: "login", Error: "Too many login attempts."})
|
|
return
|
|
}
|
|
if err := r.ParseForm(); err != nil {
|
|
a.renderAuth(w, r, 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, r, 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 !a.validateCSRF(w, r) {
|
|
return
|
|
}
|
|
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, r, http.StatusNotFound, authPageData{Mode: "invite", Error: "This invite is invalid or expired."})
|
|
return
|
|
}
|
|
a.renderAuth(w, r, 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, r, http.StatusNotFound, authPageData{Mode: "invite", Error: "This invite is invalid or expired."})
|
|
return
|
|
}
|
|
if err := r.ParseForm(); err != nil {
|
|
a.renderAuth(w, r, 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, r, 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.renderPage(w, r, 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 || !a.validateCSRF(w, r) {
|
|
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, r *http.Request, status int, data authPageData) {
|
|
a.renderPage(w, r, 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
|
|
}
|