Initial commit

This commit is contained in:
blankhex committed 2026-10-15 23:25:46 +03:00
commit c578b91c2d
46 files changed
+3241

No files matched your search

+283
View File
@@ -0,0 +1,283 @@
package web
import (
"net/http"
"net/url"
"strings"
"time"
"uPortal/internal/auth"
)
// HandleSignin renders the sign-in page on GET and authenticates on POST.
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)
}
return
}
switch r.Method {
case http.MethodGet:
redirect := r.URL.Query().Get("redirect")
s.render(w, r, "auth/signin.html", &ViewData{Redirect: redirect})
case http.MethodPost:
if err := auth.ValidateCsrf(r, CsrfCookieName, CsrfFieldName); err != nil {
s.render(w, r, "auth/signin.html", &ViewData{Error: "Invalid form, please try again"})
return
}
username := r.FormValue("username")
password := r.FormValue("password")
remember := r.FormValue("remember")
user, err := s.users.GetUserByLogin(r.Context(), username)
if err != nil || !auth.CheckPasswordHash(password, user.Password) {
s.render(w, r, "auth/signin.html", &ViewData{Error: "Invalid username or password"})
return
}
token, device, address, err := auth.CreateSession(r, user.Id)
if err != nil {
s.render(w, r, "auth/signin.html", &ViewData{Error: "Could not create session"})
return
}
// Session expires 24h from now; "remember" extends it to 30 days.
expiresAt := time.Now().UTC().Add(24 * time.Hour)
maxAge := 0 // zero => session cookie (until browser closes)
if remember == "on" {
expiresAt = time.Now().UTC().Add(30 * 24 * time.Hour)
maxAge = 30 * 24 * 3600
}
if _, err := s.auths.CreateSession(r.Context(), token, user.Id, device, address, expiresAt); err != nil {
s.render(w, r, "auth/signin.html", &ViewData{Error: "Could not save session"})
return
}
cookie := &http.Cookie{
Name: SessionCookieName,
Value: token,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: CookieSecure,
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)
}
}
}
// 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)
}
return
}
switch r.Method {
case http.MethodGet:
redirect := r.URL.Query().Get("redirect")
s.render(w, r, "auth/signup.html", &ViewData{Redirect: redirect})
case http.MethodPost:
if err := auth.ValidateCsrf(r, CsrfCookieName, CsrfFieldName); err != nil {
s.render(w, r, "auth/signup.html", &ViewData{Error: "Invalid form, please try again"})
return
}
username := r.FormValue("username")
password := r.FormValue("password")
confirm := r.FormValue("confirm")
inviteToken := r.FormValue("invite")
if password != confirm {
s.render(w, r, "auth/signup.html", &ViewData{Error: "Passwords do not match"})
return
}
if len(password) < 6 {
s.render(w, r, "auth/signup.html", &ViewData{Error: "Password must be at least 6 characters"})
return
}
if inviteToken == "" {
s.render(w, r, "auth/signup.html", &ViewData{Error: "Invite code is required"})
return
}
invite, err := s.auths.GetInviteByToken(r.Context(), inviteToken)
if err != nil {
s.render(w, r, "auth/signup.html", &ViewData{Error: "Invalid invite code"})
return
}
if !invite.IsValid() {
s.render(w, r, "auth/signup.html", &ViewData{Error: "Invite code has expired or been used up"})
return
}
// Decrement uses; delete the invite once exhausted.
invite.Uses--
if invite.Uses <= 0 {
if err := s.auths.DeleteInvite(r.Context(), invite.Token); err != nil {
s.render(w, r, "auth/signup.html", &ViewData{Error: "Could not process invite"})
return
}
} else {
if err := s.auths.UpdateInvite(r.Context(), invite); err != nil {
s.render(w, r, "auth/signup.html", &ViewData{Error: "Could not process invite"})
return
}
}
// CreateUser stores the password as-is, so hash it first.
hashed, err := auth.HashPassword(password)
if err != nil {
s.render(w, r, "auth/signup.html", &ViewData{Error: "Could not create account"})
return
}
user, err := s.users.CreateUser(r.Context(), username, hashed)
if err != nil {
s.render(w, r, "auth/signup.html", &ViewData{Error: "Username already taken"})
return
}
// Auto-login.
token, device, address, err := auth.CreateSession(r, user.Id)
if err != nil {
s.render(w, r, "auth/signup.html", &ViewData{Error: "Could not create session"})
return
}
expiresAt := time.Now().UTC().Add(24 * time.Hour)
if _, err := s.auths.CreateSession(r.Context(), token, user.Id, device, address, expiresAt); err != nil {
s.render(w, r, "auth/signup.html", &ViewData{Error: "Could not save session"})
return
}
cookie := &http.Cookie{
Name: SessionCookieName,
Value: token,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
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)
}
}
}
// 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:
data, ok := s.basicViewData(w, r, "Sign Out")
if !ok {
http.Redirect(w, r, "/signin?redirect="+url.PathEscape(r.URL.RequestURI()), http.StatusSeeOther)
return
}
s.render(w, r, "auth/signout.html", data)
case http.MethodPost:
if err := auth.ValidateCsrf(r, CsrfCookieName, CsrfFieldName); err != nil {
s.render(w, r, "auth/signout.html", &ViewData{Error: "Invalid form, please try again"})
return
}
if cookie, err := r.Cookie(SessionCookieName); err == nil {
_ = s.auths.DeleteSession(r.Context(), cookie.Value)
}
http.SetCookie(w, &http.Cookie{
Name: SessionCookieName,
Value: "",
Path: "/",
HttpOnly: true,
MaxAge: -1,
})
http.Redirect(w, r, "/signin", http.StatusSeeOther)
}
}
// 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 == "" {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
// Trusted targets skip the warning and are redirected directly.
if s.isTrustedTarget(target) {
http.Redirect(w, r, target, http.StatusSeeOther)
return
}
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"}
token, _ := auth.SetCsrfCookie(w, r, CsrfCookieName)
data.FormToken = token
}
data.Redirect = target
s.render(w, r, "redirect/warning.html", data)
}
+84
View File
@@ -0,0 +1,84 @@
package web
import (
"net/http"
"strconv"
"time"
"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 {
http.Redirect(w, r, "/signin", http.StatusSeeOther)
return
}
invites, err := s.auths.GetInvites(r.Context(), 1000, 0)
if err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
data.Invites = invites
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:
data, ok := s.basicViewData(w, r, "Create Invite")
if !ok {
http.Redirect(w, r, "/signin", http.StatusSeeOther)
return
}
s.render(w, r, "invite/create.html", data)
case http.MethodPost:
if err := auth.ValidateCsrf(r, CsrfCookieName, CsrfFieldName); err != nil {
s.render(w, r, "invite/create.html", &ViewData{Error: "Invalid form, please try again"})
return
}
uses := int64(1)
hours := int64(24)
if v, err := strconv.ParseInt(r.FormValue("uses"), 10, 64); err == nil && v > 0 {
uses = v
}
if v, err := strconv.ParseInt(r.FormValue("expires_hours"), 10, 64); err == nil && v > 0 {
hours = v
}
expiresAt := time.Now().UTC().Add(time.Duration(hours) * time.Hour)
token, err := auth.GenerateToken(32)
if err != nil {
s.render(w, r, "invite/create.html", &ViewData{Error: "Could not generate invite token"})
return
}
if _, err := s.auths.CreateInvite(r.Context(), token, uses, expiresAt); err != nil {
s.render(w, r, "invite/create.html", &ViewData{Error: "Could not create invite"})
return
}
http.Redirect(w, r, "/invites", http.StatusSeeOther)
}
}
// 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)
return
}
token := r.FormValue("token")
if token != "" {
_ = s.auths.DeleteInvite(r.Context(), token)
}
http.Redirect(w, r, "/invites", http.StatusSeeOther)
}
+113
View File
@@ -0,0 +1,113 @@
package web
import (
"fmt"
"net/http"
"strconv"
"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 {
http.Redirect(w, r, "/signin", http.StatusSeeOther)
return
}
switch r.Method {
case http.MethodGet:
s.render(w, r, "jitsi/token.html", data)
case http.MethodPost:
if err := auth.ValidateCsrf(r, CsrfCookieName, CsrfFieldName); err != nil {
data.Error = "Invalid form, please try again"
s.render(w, r, "jitsi/token.html", data)
return
}
if s.jitsiCfg == nil || s.jitsiCfg.Secret == "" {
data.Error = "Jitsi is not configured"
s.render(w, r, "jitsi/token.html", data)
return
}
room := r.FormValue("room")
if room == "" {
data.Error = "Room name is required"
s.render(w, r, "jitsi/token.html", data)
return
}
avatar := avatarURL(r, s, data.User.Id)
token, err := s.jitsiCfg.GenerateToken(
strconv.FormatInt(data.User.Id, 10),
data.User.EffectiveDisplayName(),
room,
data.User.SuperUser,
avatar,
)
if err != nil {
data.Error = "Could not generate token"
s.render(w, r, "jitsi/token.html", data)
return
}
http.Redirect(w, r, s.jitsiCfg.MeetURL(room, token), http.StatusSeeOther)
}
}
// 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)
return
}
user, err := s.currentUser(r)
if err != nil {
http.Redirect(w, r, "/signin", http.StatusSeeOther)
return
}
room := r.URL.Query().Get("room")
if room == "" {
http.Redirect(w, r, "/jitsi", http.StatusSeeOther)
return
}
avatar := avatarURL(r, s, user.Id)
token, err := s.jitsiCfg.GenerateToken(
strconv.FormatInt(user.Id, 10),
user.EffectiveDisplayName(),
room,
user.SuperUser,
avatar,
)
if err != nil {
http.Error(w, "Could not generate token", http.StatusInternalServerError)
return
}
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 {
return ""
}
path := fmt.Sprintf("/avatar/%d", id)
if s.jitsiCfg != nil && s.jitsiCfg.SiteDomain != "" {
return fmt.Sprintf("https://%s%s", s.jitsiCfg.SiteDomain, path)
}
return path
}
+164
View File
@@ -0,0 +1,164 @@
package web
import (
"net/http"
"strconv"
"uPortal/internal/auth"
"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 {
http.Redirect(w, r, "/signin", http.StatusSeeOther)
return
}
full, err := s.users.GetUserById(r.Context(), data.User.Id)
if err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
data.User = full
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 {
http.Redirect(w, r, "/signin", http.StatusSeeOther)
return
}
switch r.Method {
case http.MethodGet:
s.render(w, r, "profile/edit.html", data)
case http.MethodPost:
if err := r.ParseMultipartForm(32 << 20); err != nil {
data.Error = "Could not parse form"
s.render(w, r, "profile/edit.html", data)
return
}
if err := auth.ValidateCsrf(r, CsrfCookieName, CsrfFieldName); err != nil {
data.Error = "Invalid form, please try again"
s.render(w, r, "profile/edit.html", data)
return
}
// Display name: UpdateUser writes the displayname column via the user row.
displayName := r.FormValue("display_name")
if displayName != "" {
data.User.DisplayName = &displayName
if err := s.users.UpdateUser(r.Context(), data.User); err != nil {
data.Error = "Could not save display name"
s.render(w, r, "profile/edit.html", data)
return
}
}
// Avatar: upload a JPG/PNG <= 256x256 (validated by the user package).
if _, _, err := r.FormFile("avatar"); err == nil {
bytes, _, err := user.ExtractUploadedImageBytes(r, "avatar")
if err != nil {
data.Error = "Invalid avatar image: " + err.Error()
s.render(w, r, "profile/edit.html", data)
return
}
if err := s.users.CreateAvatar(r.Context(), data.User.Id, bytes); err != nil {
data.Error = "Could not save avatar"
s.render(w, r, "profile/edit.html", data)
return
}
}
http.Redirect(w, r, "/profile", http.StatusSeeOther)
}
}
// 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 {
http.Redirect(w, r, "/signin", http.StatusSeeOther)
return
}
switch r.Method {
case http.MethodGet:
s.render(w, r, "profile/password.html", data)
case http.MethodPost:
if err := r.ParseForm(); err != nil {
data.Error = "Could not parse form"
s.render(w, r, "profile/password.html", data)
return
}
if err := auth.ValidateCsrf(r, CsrfCookieName, CsrfFieldName); err != nil {
data.Error = "Invalid form token"
s.render(w, r, "profile/password.html", data)
return
}
oldPassword := r.FormValue("old_password")
newPassword := r.FormValue("new_password")
confirmPassword := r.FormValue("confirm_password")
if !auth.CheckPasswordHash(oldPassword, data.User.Password) {
data.Error = "Current password is incorrect"
s.render(w, r, "profile/password.html", data)
return
}
if len(newPassword) < 6 {
data.Error = "New password must be at least 6 characters"
s.render(w, r, "profile/password.html", data)
return
}
if newPassword != confirmPassword {
data.Error = "New passwords do not match"
s.render(w, r, "profile/password.html", data)
return
}
hashedPassword, err := auth.HashPassword(newPassword)
if err != nil {
data.Error = "Could not hash password"
s.render(w, r, "profile/password.html", data)
return
}
data.User.Password = hashedPassword
if err := s.users.UpdateUser(r.Context(), data.User); err != nil {
data.Error = "Could not update password"
s.render(w, r, "profile/password.html", data)
return
}
http.Redirect(w, r, "/profile", http.StatusSeeOther)
}
}
// 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 {
http.NotFound(w, r)
return
}
data, err := s.users.GetAvatar(r.Context(), id)
if err != nil || len(data) == 0 {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", http.DetectContentType(data))
w.Write(data)
}
+270
View File
@@ -0,0 +1,270 @@
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)
})
}
+85
View File
@@ -0,0 +1,85 @@
package web
import (
"net/http"
"net/url"
"uPortal/internal/auth"
)
// 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 {
http.Redirect(w, r, "/signin", http.StatusSeeOther)
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
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)
return
}
currentUser, err := s.currentUser(r)
if err != nil {
http.Redirect(w, r, "/signin", http.StatusSeeOther)
return
}
token := r.FormValue("token")
if token == "" {
http.Redirect(w, r, "/sessions", http.StatusSeeOther)
return
}
session, err := s.auths.GetSessionByToken(r.Context(), token)
if err != nil {
http.Redirect(w, r, "/sessions", http.StatusSeeOther)
return
}
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)
return
}
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)
return
}
u, err := s.currentUser(r)
if err != nil {
http.Redirect(w, r, "/signin?redirect="+url.PathEscape(r.URL.RequestURI()), http.StatusSeeOther)
return
}
_ = s.auths.DeleteSessionsByUserId(r.Context(), u.Id)
http.Redirect(w, r, "/sessions", http.StatusSeeOther)
}
+93
View File
@@ -0,0 +1,93 @@
package web
import (
"net/http"
"strconv"
"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 {
http.Redirect(w, r, "/signin", http.StatusSeeOther)
return
}
users, err := s.users.GetUsers(r.Context(), 1000, 0)
if err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
data.Users = users
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)
return
}
if err := r.ParseForm(); err != nil {
http.Redirect(w, r, "/users", http.StatusSeeOther)
return
}
userIdStr := r.FormValue("user_id")
newPassword := r.FormValue("new_password")
userId, err := strconv.ParseInt(userIdStr, 10, 64)
if err != nil {
http.Redirect(w, r, "/users", http.StatusSeeOther)
return
}
if len(newPassword) < 6 {
http.Redirect(w, r, "/users", http.StatusSeeOther)
return
}
user, err := s.users.GetUserById(r.Context(), userId)
if err != nil {
http.Redirect(w, r, "/users", http.StatusSeeOther)
return
}
hashedPassword, err := auth.HashPassword(newPassword)
if err != nil {
http.Redirect(w, r, "/users", http.StatusSeeOther)
return
}
user.Password = hashedPassword
s.users.UpdateUser(r.Context(), user)
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)
return
}
if err := r.ParseForm(); err != nil {
http.Redirect(w, r, "/users", http.StatusSeeOther)
return
}
userIdStr := r.FormValue("user_id")
userId, err := strconv.ParseInt(userIdStr, 10, 64)
if err != nil {
http.Redirect(w, r, "/users", http.StatusSeeOther)
return
}
s.auths.DeleteSessionsByUserId(r.Context(), userId)
http.Redirect(w, r, "/users", http.StatusSeeOther)
}
+57
View File
@@ -0,0 +1,57 @@
package web
import "net/http"
// HandleAuthRequest is an nginx auth_request endpoint. It validates the
// session cookie and, on success, returns the authenticated user's identity
// as response headers that nginx forwards to the upstream application.
//
// Expected usage in nginx:
//
// location / {
// auth_request /auth;
// auth_request_set $webauth_user $upstream_http_x_webauth_user;
// auth_request_set $webauth_email $upstream_http_x_webauth_email;
// auth_request_set $webauth_display_name $upstream_http_x_webauth_display_name;
// proxy_set_header X-WEBAUTH-USER $webauth_user;
// proxy_set_header X-WEBAUTH-EMAIL $webauth_email;
// proxy_set_header X-WEBAUTH-DISPLAY-NAME $webauth_display_name;
// proxy_pass http://backend;
// }
//
// location = /auth {
// internal;
// proxy_pass http://backend/auth;
// proxy_pass_request_body off;
// proxy_set_header Content-Length "";
// proxy_set_header X-Original-URI $request_uri;
// }
func (s *WebService) HandleAuthRequest(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie(SessionCookieName)
if err != nil {
w.WriteHeader(http.StatusUnauthorized)
return
}
session, err := s.auths.GetSessionByToken(r.Context(), cookie.Value)
if err != nil {
w.WriteHeader(http.StatusUnauthorized)
return
}
if !session.IsValid() {
w.WriteHeader(http.StatusUnauthorized)
return
}
user, err := s.users.GetUserById(r.Context(), session.UserId)
if err != nil {
w.WriteHeader(http.StatusUnauthorized)
return
}
w.Header().Set("X-WEBAUTH-USER", user.Login)
w.Header().Set("X-WEBAUTH-EMAIL", user.Login)
w.Header().Set("X-WEBAUTH-DISPLAY-NAME", user.EffectiveDisplayName())
w.WriteHeader(http.StatusOK)
}