package web import ( "database/sql" "fmt" "net/http" "net/url" "strings" "time" "uPortal/internal/auth" "uPortal/internal/jitsi" "uPortal/internal/log" "uPortal/internal/template" "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 FormToken string Error string User *user.User Users []user.User Sessions []auth.Session Invites []auth.Invite Redirect string } type WebService struct { users *user.Repository auths *auth.Repository views *template.Service db *sql.DB jitsiCfg *jitsi.Config trustedDomains []string 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, auths: auths, views: views, db: db, jitsiCfg: jitsiCfg, trustedDomains: trustedDomains, log: logger, } } // 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 } u, err := url.Parse(target) if err != nil || u.Hostname() == "" { return false } host := u.Hostname() // Loopback addresses are always trusted for full URL redirects. if host == "localhost" || host == "127.0.0.1" { return true } for _, domain := range s.trustedDomains { if strings.EqualFold(host, strings.TrimSpace(domain)) { return true } } return false } // 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)) } // Password handle("GET /profile/password", s.requireAuth(s.HandleProfilePassword)) handle("POST /profile/password", s.requireAuth(s.HandleProfilePassword)) // Profile // "/{$}" matches only the root path exactly; any other unmatched path // falls through to the mux default 404 instead of being served here. handle("GET /{$}", s.requireAuth(s.HandleProfile)) handle("GET /profile", s.requireAuth(s.HandleProfile)) handle("GET /profile/edit", s.requireAuth(s.HandleProfileEdit)) handle("POST /profile/edit", s.requireAuth(s.HandleProfileEdit)) handle("GET /avatar/{id}", s.HandleAvatar) // Auth handle("GET /signin", s.HandleSignin) handle("POST /signin", s.HandleSignin) handle("GET /signup", s.HandleSignup) handle("POST /signup", s.HandleSignup) handle("GET /signout", s.requireAuth(s.HandleSignout)) handle("POST /signout", s.requireAuth(s.HandleSignout)) // External redirect warning handle("GET /redirect", s.HandleExternalRedirect) // Sessions handle("GET /sessions", s.requireAuth(s.HandleSessions)) handle("POST /sessions/end", s.requireAuth(s.HandleSessionEnd)) handle("POST /sessions/end-all", s.requireAuth(s.HandleSessionEndAll)) // Users (super user only) handle("GET /users", s.requireSuperUser(s.HandleUsers)) handle("POST /users/reset-password", s.requireSuperUser(s.HandleUserResetPassword)) handle("POST /users/end-sessions", s.requireSuperUser(s.HandleUserEndSessions)) // Invites (super user only) handle("GET /invites", s.requireSuperUser(s.HandleInvites)) handle("GET /invites/create", s.requireSuperUser(s.HandleInviteCreate)) handle("POST /invites/create", s.requireSuperUser(s.HandleInviteCreate)) handle("POST /invites/delete", s.requireSuperUser(s.HandleInviteDelete)) // Jitsi handle("GET /jitsi", s.requireAuth(s.HandleJitsi)) handle("POST /jitsi", s.requireAuth(s.HandleJitsi)) handle("GET /jitsi/go", s.requireAuth(s.HandleJitsiGo)) // nginx auth_request endpoint — validates session, returns identity headers. handle("GET /auth", s.HandleAuthRequest) } // 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) query := r.URL.RawQuery path := r.URL.Path if query != "" { path = path + "?" + query } s.log.Info("%s %s -> %d in %s", r.Method, path, lrw.statusCode, time.Since(start).Round(time.Millisecond)) } } type loggingResponseWriter struct { http.ResponseWriter statusCode int } func (lrw *loggingResponseWriter) WriteHeader(code int) { lrw.statusCode = code lrw.ResponseWriter.WriteHeader(code) } // render mints a CSRF token, fills ViewData defaults, and writes the response. func (s *WebService) render(w http.ResponseWriter, r *http.Request, page string, data *ViewData) { token, err := auth.SetCsrfCookie(w, r, CsrfCookieName) if err != nil { http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } if data == nil { data = &ViewData{} } data.FormToken = token if data.Title == "" { data.Title = page } if data.User != nil && data.User.SuperUser { data.IsSuperUser = true } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := s.views.Render(w, page, data); err != nil { http.Error(w, "Internal Server Error", http.StatusInternalServerError) } } // 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 { return nil, err } session, err := s.auths.GetSessionByToken(r.Context(), cookie.Value) if err != nil { return nil, err } if !session.IsValid() { return nil, fmt.Errorf("session expired") } return s.users.GetUserById(r.Context(), session.UserId) } func (s *WebService) currentUser(r *http.Request) (*user.User, error) { return s.getUserInfo(r) } // basicViewData builds a ViewData with the current user and a CSRF token. func (s *WebService) basicViewData(w http.ResponseWriter, r *http.Request, title string) (*ViewData, bool) { user, err := s.getUserInfo(r) if err != nil { return nil, false } token, err := auth.SetCsrfCookie(w, r, CsrfCookieName) if err != nil { return nil, false } 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 { redirectTo := "/signin" if r.URL.Path != "/" && r.URL.Path != "" { redirectTo = "/signin?redirect=" + url.PathEscape(r.URL.RequestURI()) } http.Redirect(w, r, redirectTo, http.StatusSeeOther) return } next(w, r) } } // 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) if err != nil || u == nil || !u.SuperUser { http.Error(w, "Forbidden", http.StatusForbidden) return } next(w, r) }) }