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

+36
View File
@@ -0,0 +1,36 @@
# If you prefer the allow list template instead of the deny list, see community template:
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
#
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binary, built with `go test -c`
*.test
# Code coverage profiles and other test artifacts
*.out
coverage.*
!coverage.go
*.coverprofile
profile.cov
# Dependency directories (remove the comment below to include it)
# vendor/
# Go workspace file
go.work
go.work.sum
# env file
.env
# Editor/IDE
# .idea/
# .vscode/
# Databases
*.db
+17 -6
View File
@@ -27,7 +27,6 @@ var (
)
func main() {
// Load .env first (lowest priority; system env wins).
if err := env.LoadEnvDefault(); err != nil {
log.Printf("Warning: no .env file found: %v", err)
}
@@ -53,7 +52,7 @@ func main() {
}
defer db.Close()
db.SetMaxOpenConns(1) // SQLite does not support concurrent writes
db.SetMaxOpenConns(1)
if _, err := db.Exec("PRAGMA foreign_keys = ON"); err != nil {
appLog.Error("Failed to set PRAGMA foreign_keys: %v", err)
@@ -154,7 +153,7 @@ func main() {
webSvc := web.New(userRepo, authRepo, views, db, jitsiCfg, trustedDomains, appLog)
// ── Start background session cleanup ──
// -- Start background session cleanup --
const defaultCleanupInterval = 5 * time.Minute
go func() {
cleanupInterval := defaultCleanupInterval
@@ -186,6 +185,13 @@ func main() {
} else if deleted > 0 {
appLog.Info("cleaned up %d expired sessions", deleted)
}
deleted, err = authRepo.DeleteExpiredInvites(ctx)
if err != nil {
appLog.Warn("invites cleanup failed: %v", err)
} else if deleted > 0 {
appLog.Info("cleaned up %d expired invites", deleted)
}
}
}
}()
@@ -199,7 +205,6 @@ func main() {
os.Exit(1)
}
// mustExec runs a DDL/DML statement and fatally logs on error.
func mustExec(db *sql.DB, ddl string) {
if _, err := db.Exec(ddl); err != nil {
log.Fatalf("Migration failed: %v\nSQL: %s", err, ddl)
@@ -218,9 +223,15 @@ func seed(ctx context.Context, userRepo *user.Repository, authRepo *auth.Reposit
return
}
password, err := auth.GenerateToken(16)
if err != nil {
appLog.Error("Failed to generate admin password: %v", err)
os.Exit(1)
}
appLog.Info("Seeding: no users found, creating admin account and first invite")
hashed, err := auth.HashPassword("admin")
hashed, err := auth.HashPassword(password)
if err != nil {
appLog.Error("Failed to hash admin password: %v", err)
os.Exit(1)
@@ -232,7 +243,7 @@ func seed(ctx context.Context, userRepo *user.Repository, authRepo *auth.Reposit
os.Exit(1)
}
appLog.Info("Seeded admin user — id=%d login=admin password=admin", user.Id)
appLog.Info("Seeded admin user — id=%d login=admin password=%s", user.Id, password)
// userRepo.СreateUser does not set superuser, so update it directly.
if _, err := db.Exec("UPDATE users SET superuser = 1 WHERE id = ?", user.Id); err != nil {
+2
View File
@@ -9,6 +9,8 @@ services:
- "8080:8080"
volumes:
- uportal_data:/data
env_file:
- .env
environment:
LOG_LEVEL: info
# Optional configuration (uncomment as needed):
+2
View File
@@ -7,3 +7,5 @@ require golang.org/x/crypto v0.58.0
require github.com/mattn/go-sqlite3 v1.14.52
require github.com/golang-jwt/jwt/v5 v5.3.1
require github.com/mileusna/useragent v1.3.5
+2
View File
@@ -2,5 +2,7 @@ github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63Y
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/mattn/go-sqlite3 v1.14.52 h1:wVbm2Qnf4OXkqhBTSPuCRZDRnxfbVrrmiCEroVdog8U=
github.com/mattn/go-sqlite3 v1.14.52/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
github.com/mileusna/useragent v1.3.5 h1:SJM5NzBmh/hO+4LGeATKpaEX9+b4vcGg2qXGLiNGDws=
github.com/mileusna/useragent v1.3.5/go.mod h1:3d8TOmwL/5I8pJjyVDteHtgDGcefrFUX4ccGOMKNYYc=
golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54=
golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w=
+14 -2
View File
@@ -22,10 +22,22 @@ func (s *Session) CreatedAtLocal() string {
return s.CreatedAt.Local().Format("2006-01-02 15:04")
}
func (s *Session) ExpiresAtLocal() string {
return s.ExpiresAt.Local().Format("2006-01-02 15:04")
}
func (i *Invite) CreatedAtLocal() string {
return i.CreatedAt.Local().Format("2006-01-02 15:04")
}
func (i *Invite) ExpiresAtLocal() string {
return i.ExpiresAt.Local().Format("2006-01-02 15:04")
}
func (s *Session) IsValid() bool {
return time.Now().Before(s.ExpiresAt)
return time.Now().UTC().Before(s.ExpiresAt)
}
func (i *Invite) IsValid() bool {
return time.Now().Before(i.ExpiresAt) && i.Uses > 0
return time.Now().UTC().Before(i.ExpiresAt) && i.Uses > 0
}
+12 -4
View File
@@ -92,6 +92,16 @@ func (r *Repository) DeleteInvite(ctx context.Context, token string) error {
return nil
}
func (r *Repository) DeleteExpiredInvites(ctx context.Context) (int64, error) {
result, err := r.Db.ExecContext(ctx,
"DELETE FROM invites WHERE expiresat < CURRENT_TIMESTAMP",
)
if err != nil {
return 0, fmt.Errorf("db delete failed: %w", err)
}
return result.RowsAffected()
}
func (r *Repository) CreateSession(ctx context.Context, token string, userId int64, device, address string, expiresAt time.Time) (*Session, error) {
query := "INSERT INTO sessions(token, userid, device, address, expiresat) VALUES(?, ?, ?, ?, ?) RETURNING createdat"
@@ -178,14 +188,12 @@ func (r *Repository) DeleteSessionsByUserId(ctx context.Context, userId int64) e
return nil
}
// DeleteExpiredSessions deletes all sessions whose expiresat < now.
// Returns the number of rows deleted.
func (r *Repository) DeleteExpiredSessions(ctx context.Context) (int64, error) {
result, err := r.Db.ExecContext(ctx,
"DELETE FROM sessions WHERE expiresat < datetime('now')",
"DELETE FROM sessions WHERE expiresat < CURRENT_TIMESTAMP",
)
if err != nil {
return 0, fmt.Errorf("delete expired sessions: %w", err)
return 0, fmt.Errorf("db delete failed: %w", err)
}
return result.RowsAffected()
}
+6 -20
View File
@@ -10,25 +10,16 @@ import (
)
type Config struct {
// Secret is the HMAC-SHA256 shared secret (at least 32 bytes hex-encoded).
Secret string
// AppID is the Jitsi App ID (the "iss" and "aud" claim value).
AppID string
// AppName is the friendly name shown in the Jitsi client (optional).
AppName string
// Domain is the Jitsi Meet domain (e.g. "meet.jit.si").
Domain string
// SiteDomain is the public domain of this portal (e.g. "portal.example.com").
// When set, avatar URLs embedded in the JWT are absolute (https://...) so
// Jitsi, running on another domain, can load them. When empty, relative
// URLs are used.
SiteDomain string
}
type JitsiClaims struct {
jwt.RegisteredClaims
Room string `json:"room,omitempty"`
Moderator bool `json:"moderator"`
Context *JitsiContext `json:"context,omitempty"`
}
@@ -37,21 +28,19 @@ type JitsiContext struct {
}
type JitsiUser struct {
ID string `json:"id"`
Name string `json:"name"`
Email string `json:"email,omitempty"`
Avatar string `json:"avatar,omitempty"`
ID string `json:"id"`
Name string `json:"name"`
Email string `json:"email,omitempty"`
Avatar string `json:"avatar,omitempty"`
Moderator bool `json:"moderator"`
}
// GenerateSecret creates a new random hex-encoded 32-byte secret.
func GenerateSecret() string {
b := make([]byte, 32)
rand.Read(b)
return hex.EncodeToString(b)
}
// MeetURL returns the full Jitsi Meet URL for a room with a JWT token.
// If Domain is empty it defaults to "meet.jit.si".
func (c *Config) MeetURL(room, jwtToken string) string {
domain := c.Domain
if domain == "" {
@@ -60,9 +49,6 @@ func (c *Config) MeetURL(room, jwtToken string) string {
return fmt.Sprintf("https://%s/%s?jwt=%s", domain, room, jwtToken)
}
// GenerateToken creates a JWT for the given room and user.
// moderator grants Jitsi moderator privileges (mute, kick, etc.) and avatar
// is the user's avatar URL (may be empty).
func (c *Config) GenerateToken(userID, userName, room string, moderator bool, avatar string) (string, error) {
if c.Secret == "" {
return "", fmt.Errorf("jitsi secret is not configured")
@@ -83,12 +69,12 @@ func (c *Config) GenerateToken(userID, userName, room string, moderator bool, av
ID: fmt.Sprintf("%s-%d", userID, now.Unix()),
},
Room: room,
Moderator: moderator,
Context: &JitsiContext{
User: &JitsiUser{
ID: userID,
Name: userName,
Avatar: avatar,
Moderator: moderator,
},
},
}
-4
View File
@@ -28,8 +28,6 @@ type Logger struct {
logger *stdlog.Logger
}
// New returns a Logger that writes to the given output. Messages below the
// configured level are silently dropped.
func New(level Level, output io.Writer) *Logger {
return &Logger{
level: level,
@@ -62,8 +60,6 @@ func (l *Logger) log(level Level, format string, args ...any) {
l.logger.Printf("%s %s", label, msg)
}
// ParseLevel converts a string to a Level. Input is case-insensitive.
// Valid values: "debug", "info", "warn", "error".
func ParseLevel(s string) (Level, error) {
switch strings.ToLower(strings.TrimSpace(s)) {
case "debug":
-3
View File
@@ -39,8 +39,6 @@ func VerifyImageLimit(r io.Reader) error {
}
func ExtractUploadedImageBytes(r *http.Request, formKey string) ([]byte, string, error) {
// Read file with a size limit to prevent abuse. We read the raw multipart
// bytes, verify size and image format, then return them.
const maxRead = MaxUploadSize + 1024 // small extra for multipart framing
file, header, err := r.FormFile(formKey)
@@ -54,7 +52,6 @@ func ExtractUploadedImageBytes(r *http.Request, formKey string) ([]byte, string,
return nil, "", fmt.Errorf("invalid file type: only JPG or PNG are allowed")
}
// Read all bytes so we can verify image dimensions from a reusable reader.
lr := io.LimitReader(file, maxRead)
fileBytes, err := io.ReadAll(lr)
if err != nil {
+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)
+1 -1
View File
@@ -13,7 +13,7 @@
{{template "redirect" .}}
<div class="form-group"><label for="login_username">Username</label><input type="text" class="w-100" id="login_username" name="username"></div>
<div class="form-group"><label for="login_password">Password</label><input type="password" class="w-100" id="login_password" name="password"></div>
<div class="form-group"><label><input type="checkbox" id="remember" name="remember" checked> Remember me</label></div>
<div class="form-group"><label><input type="checkbox" id="remember" name="remember"> Remember me</label></div>
<div class="text-center"><button type="submit">Sign In</button></div>
</form>
{{if .Redirect}}
+2 -2
View File
@@ -19,8 +19,8 @@
<tr>
<td>{{.Token}}</td>
<td>{{.Uses}}</td>
<td>{{.ExpiresAt.Format "2006-01-02 15:04"}}</td>
<td>{{.CreatedAt.Format "2006-01-02 15:04"}}</td>
<td>{{.ExpiresAtLocal}}</td>
<td>{{.CreatedAtLocal}}</td>
<td>
<form method="post" action="/invites/delete">
{{template "csrf" $}}
+1 -2
View File
@@ -12,12 +12,11 @@
<table class="table">
<thead>
<tr><th>Token</th><th>Device</th><th>Address</th><th>Created At</th><th></th></tr>
<tr><th>Device</th><th>Address</th><th>Created At</th><th></th></tr>
</thead>
<tbody>
{{range .Sessions}}
<tr>
<td>{{printf "%.8s" .Token}}...</td>
<td>{{.Device}}</td>
<td>{{.Address}}</td>
<td>{{.CreatedAtLocal}}</td>