diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..631898b --- /dev/null +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/cmd/server/main.go b/cmd/server/main.go index 2bb08ae..b3a1688 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -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 { diff --git a/docker-compose.yml b/docker-compose.yml index fe5b5b1..1c2e87a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,6 +9,8 @@ services: - "8080:8080" volumes: - uportal_data:/data + env_file: + - .env environment: LOG_LEVEL: info # Optional configuration (uncomment as needed): diff --git a/go.mod b/go.mod index 4345dee..a99a101 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 5965d85..54a3457 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/auth/model.go b/internal/auth/model.go index fc094c6..3bfa250 100644 --- a/internal/auth/model.go +++ b/internal/auth/model.go @@ -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 } diff --git a/internal/auth/repository.go b/internal/auth/repository.go index da6e5b4..46a1a5e 100644 --- a/internal/auth/repository.go +++ b/internal/auth/repository.go @@ -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() } diff --git a/internal/jitsi/jitsi.go b/internal/jitsi/jitsi.go index 67bf2fb..4addc4c 100644 --- a/internal/jitsi/jitsi.go +++ b/internal/jitsi/jitsi.go @@ -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, }, }, } diff --git a/internal/log/log.go b/internal/log/log.go index 651b45a..4920911 100644 --- a/internal/log/log.go +++ b/internal/log/log.go @@ -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": diff --git a/internal/user/service.go b/internal/user/service.go index f479b67..3d44c71 100644 --- a/internal/user/service.go +++ b/internal/user/service.go @@ -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 { diff --git a/internal/web/auth.go b/internal/web/auth.go index 9f1f34e..1fbd2cb 100644 --- a/internal/web/auth.go +++ b/internal/web/auth.go @@ -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"} } diff --git a/internal/web/invite.go b/internal/web/invite.go index 3bd4e9f..da61f21 100644 --- a/internal/web/invite.go +++ b/internal/web/invite.go @@ -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) diff --git a/internal/web/jitsi.go b/internal/web/jitsi.go index 3fb0940..cf6b7d5 100644 --- a/internal/web/jitsi.go +++ b/internal/web/jitsi.go @@ -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 { diff --git a/internal/web/profile.go b/internal/web/profile.go index 0f2a6fa..d421efc 100644 --- a/internal/web/profile.go +++ b/internal/web/profile.go @@ -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 { diff --git a/internal/web/service.go b/internal/web/service.go index 6642e91..d73e2c0 100644 --- a/internal/web/service.go +++ b/internal/web/service.go @@ -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) diff --git a/internal/web/session.go b/internal/web/session.go index 3a36817..a62643a 100644 --- a/internal/web/session.go +++ b/internal/web/session.go @@ -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) diff --git a/internal/web/user.go b/internal/web/user.go index 1707675..0a78e45 100644 --- a/internal/web/user.go +++ b/internal/web/user.go @@ -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) diff --git a/templates/pages/auth/signin.html b/templates/pages/auth/signin.html index 25bd44f..ff0d1d5 100644 --- a/templates/pages/auth/signin.html +++ b/templates/pages/auth/signin.html @@ -13,7 +13,7 @@ {{template "redirect" .}}
-
+
{{if .Redirect}} diff --git a/templates/pages/invite/view.html b/templates/pages/invite/view.html index 89c48bd..ed9a06a 100644 --- a/templates/pages/invite/view.html +++ b/templates/pages/invite/view.html @@ -19,8 +19,8 @@ {{.Token}} {{.Uses}} -{{.ExpiresAt.Format "2006-01-02 15:04"}} -{{.CreatedAt.Format "2006-01-02 15:04"}} +{{.ExpiresAtLocal}} +{{.CreatedAtLocal}}
{{template "csrf" $}} diff --git a/templates/pages/session/view.html b/templates/pages/session/view.html index 35d76c4..cdfc674 100644 --- a/templates/pages/session/view.html +++ b/templates/pages/session/view.html @@ -12,12 +12,11 @@ - + {{range .Sessions}} -
TokenDeviceAddressCreated At
DeviceAddressCreated At
{{printf "%.8s" .Token}}... {{.Device}} {{.Address}} {{.CreatedAtLocal}}