A bunch of fixes

This commit is contained in:
blankhex committed 2026-10-16 11:34:34 +03:00
1 parent 65534ecfd5
commit 2a4e03e611
20 files changed
+146 -160

No files matched your search

+23 -63
View File
@@ -9,21 +9,29 @@ import (
"uPortal/internal/auth"
)
// HandleSignin renders the sign-in page on GET and authenticates on POST.
// For relative paths, redirect directly; for external URLs, route
// through the warning page unless the domain is trusted.
func (s *WebService) handleRedirect(w http.ResponseWriter, r *http.Request) {
redirectTo := "/"
if r.FormValue("redirect") != "" {
redirectTo = r.FormValue("redirect")
} else if r.URL.Query().Get("redirect") != "" {
redirectTo = r.URL.Query().Get("redirect")
}
if strings.HasPrefix(redirectTo, "/") && !strings.Contains(redirectTo, "://") {
http.Redirect(w, r, redirectTo, http.StatusSeeOther)
} else if s.isTrustedTarget(redirectTo) {
http.Redirect(w, r, redirectTo, http.StatusSeeOther)
} else {
http.Redirect(w, r, "/redirect?to="+url.QueryEscape(redirectTo), http.StatusSeeOther)
}
}
func (s *WebService) HandleSignin(w http.ResponseWriter, r *http.Request) {
// If already authenticated, redirect
if _, err := s.currentUser(r); err == nil {
redirectTo := "/"
if r.URL.Query().Get("redirect") != "" {
redirectTo = r.URL.Query().Get("redirect")
}
// For relative paths, redirect directly; for external URLs, route
// through the warning page.
if strings.HasPrefix(redirectTo, "/") && !strings.Contains(redirectTo, "://") {
http.Redirect(w, r, redirectTo, http.StatusSeeOther)
} else {
http.Redirect(w, r, "/redirect?to="+url.QueryEscape(redirectTo), http.StatusSeeOther)
}
s.handleRedirect(w, r)
return
}
@@ -77,41 +85,14 @@ func (s *WebService) HandleSignin(w http.ResponseWriter, r *http.Request) {
MaxAge: maxAge,
}
http.SetCookie(w, cookie)
redirectTo := "/"
if r.FormValue("redirect") != "" {
redirectTo = r.FormValue("redirect")
} else if r.URL.Query().Get("redirect") != "" {
redirectTo = r.URL.Query().Get("redirect")
}
// For relative paths, redirect directly; for external URLs, route
// through the warning page unless the domain is trusted.
if strings.HasPrefix(redirectTo, "/") && !strings.Contains(redirectTo, "://") {
http.Redirect(w, r, redirectTo, http.StatusSeeOther)
} else if s.isTrustedTarget(redirectTo) {
http.Redirect(w, r, redirectTo, http.StatusSeeOther)
} else {
http.Redirect(w, r, "/redirect?to="+url.QueryEscape(redirectTo), http.StatusSeeOther)
}
s.handleRedirect(w, r)
}
}
// HandleSignup renders the sign-up page on GET and creates an account on POST.
// Invite codes are one-time-use unless they carry multiple uses.
func (s *WebService) HandleSignup(w http.ResponseWriter, r *http.Request) {
// If already authenticated, redirect
if _, err := s.currentUser(r); err == nil {
redirectTo := "/"
if r.URL.Query().Get("redirect") != "" {
redirectTo = r.URL.Query().Get("redirect")
}
// For relative paths, redirect directly; for external URLs, route
// through the warning page.
if strings.HasPrefix(redirectTo, "/") && !strings.Contains(redirectTo, "://") {
http.Redirect(w, r, redirectTo, http.StatusSeeOther)
} else {
http.Redirect(w, r, "/redirect?to="+url.QueryEscape(redirectTo), http.StatusSeeOther)
}
s.handleRedirect(w, r)
return
}
@@ -203,27 +184,10 @@ func (s *WebService) HandleSignup(w http.ResponseWriter, r *http.Request) {
Secure: CookieSecure,
}
http.SetCookie(w, cookie)
redirectTo := "/"
if r.FormValue("redirect") != "" {
redirectTo = r.FormValue("redirect")
} else if r.URL.Query().Get("redirect") != "" {
redirectTo = r.URL.Query().Get("redirect")
}
// For relative paths, redirect directly; for external URLs, route
// through the warning page unless the domain is trusted.
if strings.HasPrefix(redirectTo, "/") && !strings.Contains(redirectTo, "://") {
http.Redirect(w, r, redirectTo, http.StatusSeeOther)
} else if s.isTrustedTarget(redirectTo) {
http.Redirect(w, r, redirectTo, http.StatusSeeOther)
} else {
http.Redirect(w, r, "/redirect?to="+url.QueryEscape(redirectTo), http.StatusSeeOther)
}
s.handleRedirect(w, r)
}
}
// HandleSignout renders a confirmation page on GET and deletes the session on POST.
// It is wrapped in requireAuth for the POST route; the GET route may show the page
// even for anonymous visitors (the confirm form just redirects to /signin).
func (s *WebService) HandleSignout(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
@@ -255,8 +219,6 @@ func (s *WebService) HandleSignout(w http.ResponseWriter, r *http.Request) {
}
}
// HandleExternalRedirect shows a warning page when the user is about to
// leave the site via an external redirect.
func (s *WebService) HandleExternalRedirect(w http.ResponseWriter, r *http.Request) {
target := r.URL.Query().Get("to")
if target == "" {
@@ -264,7 +226,6 @@ func (s *WebService) HandleExternalRedirect(w http.ResponseWriter, r *http.Reque
return
}
// Trusted targets skip the warning and are redirected directly.
if s.isTrustedTarget(target) {
http.Redirect(w, r, target, http.StatusSeeOther)
return
@@ -272,7 +233,6 @@ func (s *WebService) HandleExternalRedirect(w http.ResponseWriter, r *http.Reque
data, ok := s.basicViewData(w, r, "External Redirect")
if !ok {
// Not authenticated is fine — just show the warning without nav
data = &ViewData{Title: "External Redirect"}
}
-3
View File
@@ -8,7 +8,6 @@ import (
"uPortal/internal/auth"
)
// HandleInvites lists all invite codes. Requires a super user.
func (s *WebService) HandleInvites(w http.ResponseWriter, r *http.Request) {
data, ok := s.basicViewData(w, r, "Invites")
if !ok {
@@ -26,7 +25,6 @@ func (s *WebService) HandleInvites(w http.ResponseWriter, r *http.Request) {
s.render(w, r, "invite/view.html", data)
}
// HandleInviteCreate renders the creation form on GET and mints an invite on POST.
func (s *WebService) HandleInviteCreate(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
@@ -69,7 +67,6 @@ func (s *WebService) HandleInviteCreate(w http.ResponseWriter, r *http.Request)
}
}
// HandleInviteDelete removes the invite identified by the form field "token".
func (s *WebService) HandleInviteDelete(w http.ResponseWriter, r *http.Request) {
if err := auth.ValidateCsrf(r, CsrfCookieName, CsrfFieldName); err != nil {
http.Redirect(w, r, "/invites", http.StatusSeeOther)
-7
View File
@@ -8,7 +8,6 @@ import (
"uPortal/internal/auth"
)
// HandleJitsi renders the Jitsi room form on GET and redirects to Jitsi Meet on POST.
func (s *WebService) HandleJitsi(w http.ResponseWriter, r *http.Request) {
data, ok := s.basicViewData(w, r, "Jitsi")
if !ok {
@@ -59,8 +58,6 @@ func (s *WebService) HandleJitsi(w http.ResponseWriter, r *http.Request) {
}
}
// HandleJitsiGo is a GET endpoint that reads a "room" query parameter,
// generates a JWT token, and redirects to the Jitsi Meet URL.
func (s *WebService) HandleJitsiGo(w http.ResponseWriter, r *http.Request) {
if s.jitsiCfg == nil || s.jitsiCfg.Secret == "" {
http.Error(w, "Jitsi is not configured", http.StatusInternalServerError)
@@ -96,10 +93,6 @@ func (s *WebService) HandleJitsiGo(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, s.jitsiCfg.MeetURL(room, token), http.StatusSeeOther)
}
// avatarURL returns the app's avatar URL for the given user id, or an empty
// string if the user has no stored avatar. If the Jitsi site domain is
// configured, an absolute https:// URL is returned so Jitsi (running on a
// different domain) can load the avatar; otherwise a relative URL is used.
func avatarURL(r *http.Request, s *WebService, id int64) string {
data, err := s.users.GetAvatar(r.Context(), id)
if err != nil || len(data) == 0 {
-6
View File
@@ -8,7 +8,6 @@ import (
"uPortal/internal/user"
)
// HandleProfile shows the current user's own profile.
func (s *WebService) HandleProfile(w http.ResponseWriter, r *http.Request) {
data, ok := s.basicViewData(w, r, "Profile")
if !ok {
@@ -26,7 +25,6 @@ func (s *WebService) HandleProfile(w http.ResponseWriter, r *http.Request) {
s.render(w, r, "profile/view.html", data)
}
// HandleProfileEdit renders the edit form on GET and applies changes on POST.
func (s *WebService) HandleProfileEdit(w http.ResponseWriter, r *http.Request) {
data, ok := s.basicViewData(w, r, "Edit Profile")
if !ok {
@@ -50,7 +48,6 @@ func (s *WebService) HandleProfileEdit(w http.ResponseWriter, r *http.Request) {
return
}
// Display name: UpdateUser writes the displayname column via the user row.
displayName := r.FormValue("display_name")
if displayName != "" {
data.User.DisplayName = &displayName
@@ -80,8 +77,6 @@ func (s *WebService) HandleProfileEdit(w http.ResponseWriter, r *http.Request) {
}
}
// HandleProfilePassword renders the password change form on GET and
// applies the new password on POST.
func (s *WebService) HandleProfilePassword(w http.ResponseWriter, r *http.Request) {
data, ok := s.basicViewData(w, r, "Change Password")
if !ok {
@@ -145,7 +140,6 @@ func (s *WebService) HandleProfilePassword(w http.ResponseWriter, r *http.Reques
}
}
// HandleAvatar serves a stored avatar image by user id.
func (s *WebService) HandleAvatar(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
-26
View File
@@ -15,22 +15,14 @@ import (
"uPortal/internal/user"
)
// Cookie names and the CSRF form field name. These match the layout
// template (templates/layouts/csrf.html) and the auth package helpers.
const (
SessionCookieName = "sessiontoken"
CsrfCookieName = "csrftoken"
CsrfFieldName = "formtoken"
)
// CookieSecure controls the Secure attribute of the session cookie.
// It is disabled by default so the app works over plain http://127.0.0.1
// during development (browsers drop Secure cookies over plain http).
// Production deployments behind HTTPS must flip this to true.
var CookieSecure = false
// ViewData is the data bag passed to every page template. It carries the
// CSRF token, the current user, and any collections the pages display.
type ViewData struct {
Title string
IsSuperUser bool
@@ -53,7 +45,6 @@ type WebService struct {
log *log.Logger
}
// New returns a WebService ready to be registered onto an http.ServeMux.
func New(users *user.Repository, auths *auth.Repository, views *template.Service, db *sql.DB, jitsiCfg *jitsi.Config, trustedDomains []string, logger *log.Logger) *WebService {
return &WebService{
users: users,
@@ -66,11 +57,7 @@ func New(users *user.Repository, auths *auth.Repository, views *template.Service
}
}
// isTrustedTarget reports whether target may skip the /redirect warning page.
// Relative paths, empty hostnames and unknown hosts are never trusted;
// localhost and 127.0.0.1 are always trusted.
func (s *WebService) isTrustedTarget(target string) bool {
// Handle URLs without a scheme (e.g. "meet.jit.si/room").
if !strings.Contains(target, "://") {
target = "https://" + target
}
@@ -80,7 +67,6 @@ func (s *WebService) isTrustedTarget(target string) bool {
}
host := u.Hostname()
// Loopback addresses are always trusted for full URL redirects.
if host == "localhost" || host == "127.0.0.1" {
return true
}
@@ -95,7 +81,6 @@ func (s *WebService) isTrustedTarget(target string) bool {
// Register wires every route (method + path) onto the mux.
func (s *WebService) Register(mux *http.ServeMux) {
// Helper to wrap every handler with logging middleware.
handle := func(pattern string, handler http.HandlerFunc) {
mux.HandleFunc(pattern, s.loggingMiddleware(handler))
}
@@ -152,15 +137,12 @@ func (s *WebService) Register(mux *http.ServeMux) {
// loggingMiddleware logs every HTTP request. Static file endpoints are skipped.
func (s *WebService) loggingMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Skip logging for static files and health checks.
if strings.HasPrefix(r.URL.Path, "/static/") {
next(w, r)
return
}
start := time.Now()
// Wrap the ResponseWriter to capture the status code.
lrw := &loggingResponseWriter{ResponseWriter: w, statusCode: http.StatusOK}
next(lrw, r)
@@ -185,9 +167,6 @@ func (lrw *loggingResponseWriter) WriteHeader(code int) {
lrw.ResponseWriter.WriteHeader(code)
}
// render fills ViewData defaults and writes the response.
// If data.FormToken is empty (e.g. for call sites that don't use
// basicViewData), a CSRF token is minted as a fallback.
func (s *WebService) render(w http.ResponseWriter, r *http.Request, page string, data *ViewData) {
if data == nil {
data = &ViewData{}
@@ -212,7 +191,6 @@ func (s *WebService) render(w http.ResponseWriter, r *http.Request, page string,
}
}
// getUserInfo reads the session cookie and looks up the associated user.
func (s *WebService) getUserInfo(r *http.Request) (*user.User, error) {
cookie, err := r.Cookie(SessionCookieName)
if err != nil {
@@ -232,7 +210,6 @@ func (s *WebService) currentUser(r *http.Request) (*user.User, error) {
return s.getUserInfo(r)
}
// basicViewData builds a ViewData with the current user and CSRF token.
func (s *WebService) basicViewData(w http.ResponseWriter, r *http.Request, title string) (*ViewData, bool) {
user, err := s.getUserInfo(r)
if err != nil {
@@ -245,8 +222,6 @@ func (s *WebService) basicViewData(w http.ResponseWriter, r *http.Request, title
return &ViewData{Title: title, User: user, IsSuperUser: user.SuperUser, FormToken: token}, true
}
// requireAuth redirects unauthenticated visitors to /signin with a ?redirect=
// parameter so they return to their intended page after login.
func (s *WebService) requireAuth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if _, err := s.currentUser(r); err != nil {
@@ -261,7 +236,6 @@ func (s *WebService) requireAuth(next http.HandlerFunc) http.HandlerFunc {
}
}
// requireSuperUser wraps a handler, requiring an authenticated super user.
func (s *WebService) requireSuperUser(next http.HandlerFunc) http.HandlerFunc {
return s.requireAuth(func(w http.ResponseWriter, r *http.Request) {
u, err := s.currentUser(r)
+28 -8
View File
@@ -1,13 +1,15 @@
package web
import (
"fmt"
"net/http"
"net/url"
"uPortal/internal/auth"
"github.com/mileusna/useragent"
)
// HandleSessions lists the current user's active sessions.
func (s *WebService) HandleSessions(w http.ResponseWriter, r *http.Request) {
data, ok := s.basicViewData(w, r, "Sessions")
if !ok {
@@ -15,20 +17,41 @@ func (s *WebService) HandleSessions(w http.ResponseWriter, r *http.Request) {
return
}
// A generous limit; the page renders whatever comes back.
sessions, err := s.auths.GetSessionByUserId(r.Context(), data.User.Id, 100, 0)
if err != nil {
s.log.Error("failed to get sessions: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
data.Sessions = sessions
for _, session := range sessions {
ua := useragent.Parse(session.Device)
var class string
if ua.Mobile {
class = "Mobile"
}
if ua.Tablet {
class = "Tablet"
}
if ua.Desktop {
class = "Desktop"
}
if ua.Bot {
class = "Bot"
}
newSession := auth.Session{
Device: fmt.Sprintf("%s, %s %s, %s %s %s", class, ua.Name, ua.Version, ua.OS, ua.OSVersion, ua.Device),
Address: session.Address,
Token: session.Token,
CreatedAt: session.CreatedAt,
ExpiresAt: session.ExpiresAt,
UserId: session.UserId,
}
data.Sessions = append(data.Sessions, newSession)
}
s.render(w, r, "session/view.html", data)
}
// HandleSessionEnd deletes a single session identified by the form field "token".
// It enforces ownership: a user can only delete their own session unless they are a superuser.
func (s *WebService) HandleSessionEnd(w http.ResponseWriter, r *http.Request) {
if err := auth.ValidateCsrf(r, CsrfCookieName, CsrfFieldName); err != nil {
http.Redirect(w, r, "/sessions", http.StatusSeeOther)
@@ -54,10 +77,8 @@ func (s *WebService) HandleSessionEnd(w http.ResponseWriter, r *http.Request) {
}
if session.UserId == currentUser.Id {
// User deleting their own session — use defense-in-depth method
_ = s.auths.DeleteSessionByTokenAndUserId(r.Context(), token, currentUser.Id)
} else if currentUser.SuperUser {
// Superuser overriding — can delete any session
_ = s.auths.DeleteSession(r.Context(), token)
} else {
http.Error(w, "Forbidden", http.StatusForbidden)
@@ -67,7 +88,6 @@ func (s *WebService) HandleSessionEnd(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/sessions", http.StatusSeeOther)
}
// HandleSessionEndAll ends every session belonging to the current user.
func (s *WebService) HandleSessionEndAll(w http.ResponseWriter, r *http.Request) {
if err := auth.ValidateCsrf(r, CsrfCookieName, CsrfFieldName); err != nil {
http.Redirect(w, r, "/sessions", http.StatusSeeOther)
-3
View File
@@ -7,7 +7,6 @@ import (
"uPortal/internal/auth"
)
// HandleUsers lists every user in the system. Requires a super user.
func (s *WebService) HandleUsers(w http.ResponseWriter, r *http.Request) {
data, ok := s.basicViewData(w, r, "Users")
if !ok {
@@ -25,7 +24,6 @@ func (s *WebService) HandleUsers(w http.ResponseWriter, r *http.Request) {
s.render(w, r, "user/view.html", data)
}
// HandleUserResetPassword lets a super admin reset another user's password.
func (s *WebService) HandleUserResetPassword(w http.ResponseWriter, r *http.Request) {
if err := auth.ValidateCsrf(r, CsrfCookieName, CsrfFieldName); err != nil {
http.Redirect(w, r, "/users", http.StatusSeeOther)
@@ -69,7 +67,6 @@ func (s *WebService) HandleUserResetPassword(w http.ResponseWriter, r *http.Requ
http.Redirect(w, r, "/users", http.StatusSeeOther)
}
// HandleUserEndSessions ends all sessions for a given user. Requires a super user.
func (s *WebService) HandleUserEndSessions(w http.ResponseWriter, r *http.Request) {
if err := auth.ValidateCsrf(r, CsrfCookieName, CsrfFieldName); err != nil {
http.Redirect(w, r, "/users", http.StatusSeeOther)