A bunch of fixes
This commit is contained in:
1 parent
65534ecfd5
commit
2a4e03e611
20 files changed
+137
-151
No files matched your search
+36
@@ -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
@@ -27,7 +27,6 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
// Load .env first (lowest priority; system env wins).
|
|
||||||
if err := env.LoadEnvDefault(); err != nil {
|
if err := env.LoadEnvDefault(); err != nil {
|
||||||
log.Printf("Warning: no .env file found: %v", err)
|
log.Printf("Warning: no .env file found: %v", err)
|
||||||
}
|
}
|
||||||
@@ -53,7 +52,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
defer db.Close()
|
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 {
|
if _, err := db.Exec("PRAGMA foreign_keys = ON"); err != nil {
|
||||||
appLog.Error("Failed to set PRAGMA foreign_keys: %v", err)
|
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)
|
webSvc := web.New(userRepo, authRepo, views, db, jitsiCfg, trustedDomains, appLog)
|
||||||
|
|
||||||
// ── Start background session cleanup ──
|
// -- Start background session cleanup --
|
||||||
const defaultCleanupInterval = 5 * time.Minute
|
const defaultCleanupInterval = 5 * time.Minute
|
||||||
go func() {
|
go func() {
|
||||||
cleanupInterval := defaultCleanupInterval
|
cleanupInterval := defaultCleanupInterval
|
||||||
@@ -186,6 +185,13 @@ func main() {
|
|||||||
} else if deleted > 0 {
|
} else if deleted > 0 {
|
||||||
appLog.Info("cleaned up %d expired sessions", deleted)
|
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)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// mustExec runs a DDL/DML statement and fatally logs on error.
|
|
||||||
func mustExec(db *sql.DB, ddl string) {
|
func mustExec(db *sql.DB, ddl string) {
|
||||||
if _, err := db.Exec(ddl); err != nil {
|
if _, err := db.Exec(ddl); err != nil {
|
||||||
log.Fatalf("Migration failed: %v\nSQL: %s", err, ddl)
|
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
|
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")
|
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 {
|
if err != nil {
|
||||||
appLog.Error("Failed to hash admin password: %v", err)
|
appLog.Error("Failed to hash admin password: %v", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
@@ -232,7 +243,7 @@ func seed(ctx context.Context, userRepo *user.Repository, authRepo *auth.Reposit
|
|||||||
os.Exit(1)
|
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.
|
// 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 {
|
if _, err := db.Exec("UPDATE users SET superuser = 1 WHERE id = ?", user.Id); err != nil {
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ services:
|
|||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
volumes:
|
volumes:
|
||||||
- uportal_data:/data
|
- uportal_data:/data
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
environment:
|
environment:
|
||||||
LOG_LEVEL: info
|
LOG_LEVEL: info
|
||||||
# Optional configuration (uncomment as needed):
|
# Optional configuration (uncomment as needed):
|
||||||
|
|||||||
@@ -7,3 +7,5 @@ require golang.org/x/crypto v0.58.0
|
|||||||
require github.com/mattn/go-sqlite3 v1.14.52
|
require github.com/mattn/go-sqlite3 v1.14.52
|
||||||
|
|
||||||
require github.com/golang-jwt/jwt/v5 v5.3.1
|
require github.com/golang-jwt/jwt/v5 v5.3.1
|
||||||
|
|
||||||
|
require github.com/mileusna/useragent v1.3.5
|
||||||
@@ -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/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 h1:wVbm2Qnf4OXkqhBTSPuCRZDRnxfbVrrmiCEroVdog8U=
|
||||||
github.com/mattn/go-sqlite3 v1.14.52/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
|
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 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54=
|
||||||
golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w=
|
golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w=
|
||||||
+14
-2
@@ -22,10 +22,22 @@ func (s *Session) CreatedAtLocal() string {
|
|||||||
return s.CreatedAt.Local().Format("2006-01-02 15:04")
|
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 {
|
func (s *Session) IsValid() bool {
|
||||||
return time.Now().Before(s.ExpiresAt)
|
return time.Now().UTC().Before(s.ExpiresAt)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (i *Invite) IsValid() bool {
|
func (i *Invite) IsValid() bool {
|
||||||
return time.Now().Before(i.ExpiresAt) && i.Uses > 0
|
return time.Now().UTC().Before(i.ExpiresAt) && i.Uses > 0
|
||||||
}
|
}
|
||||||
@@ -92,6 +92,16 @@ func (r *Repository) DeleteInvite(ctx context.Context, token string) error {
|
|||||||
return nil
|
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) {
|
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"
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteExpiredSessions deletes all sessions whose expiresat < now.
|
|
||||||
// Returns the number of rows deleted.
|
|
||||||
func (r *Repository) DeleteExpiredSessions(ctx context.Context) (int64, error) {
|
func (r *Repository) DeleteExpiredSessions(ctx context.Context) (int64, error) {
|
||||||
result, err := r.Db.ExecContext(ctx,
|
result, err := r.Db.ExecContext(ctx,
|
||||||
"DELETE FROM sessions WHERE expiresat < datetime('now')",
|
"DELETE FROM sessions WHERE expiresat < CURRENT_TIMESTAMP",
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("delete expired sessions: %w", err)
|
return 0, fmt.Errorf("db delete failed: %w", err)
|
||||||
}
|
}
|
||||||
return result.RowsAffected()
|
return result.RowsAffected()
|
||||||
}
|
}
|
||||||
+2
-16
@@ -10,25 +10,16 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
// Secret is the HMAC-SHA256 shared secret (at least 32 bytes hex-encoded).
|
|
||||||
Secret string
|
Secret string
|
||||||
// AppID is the Jitsi App ID (the "iss" and "aud" claim value).
|
|
||||||
AppID string
|
AppID string
|
||||||
// AppName is the friendly name shown in the Jitsi client (optional).
|
|
||||||
AppName string
|
AppName string
|
||||||
// Domain is the Jitsi Meet domain (e.g. "meet.jit.si").
|
|
||||||
Domain string
|
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
|
SiteDomain string
|
||||||
}
|
}
|
||||||
|
|
||||||
type JitsiClaims struct {
|
type JitsiClaims struct {
|
||||||
jwt.RegisteredClaims
|
jwt.RegisteredClaims
|
||||||
Room string `json:"room,omitempty"`
|
Room string `json:"room,omitempty"`
|
||||||
Moderator bool `json:"moderator"`
|
|
||||||
Context *JitsiContext `json:"context,omitempty"`
|
Context *JitsiContext `json:"context,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,17 +32,15 @@ type JitsiUser struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Email string `json:"email,omitempty"`
|
Email string `json:"email,omitempty"`
|
||||||
Avatar string `json:"avatar,omitempty"`
|
Avatar string `json:"avatar,omitempty"`
|
||||||
|
Moderator bool `json:"moderator"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GenerateSecret creates a new random hex-encoded 32-byte secret.
|
|
||||||
func GenerateSecret() string {
|
func GenerateSecret() string {
|
||||||
b := make([]byte, 32)
|
b := make([]byte, 32)
|
||||||
rand.Read(b)
|
rand.Read(b)
|
||||||
return hex.EncodeToString(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 {
|
func (c *Config) MeetURL(room, jwtToken string) string {
|
||||||
domain := c.Domain
|
domain := c.Domain
|
||||||
if 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)
|
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) {
|
func (c *Config) GenerateToken(userID, userName, room string, moderator bool, avatar string) (string, error) {
|
||||||
if c.Secret == "" {
|
if c.Secret == "" {
|
||||||
return "", fmt.Errorf("jitsi secret is not configured")
|
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()),
|
ID: fmt.Sprintf("%s-%d", userID, now.Unix()),
|
||||||
},
|
},
|
||||||
Room: room,
|
Room: room,
|
||||||
Moderator: moderator,
|
|
||||||
Context: &JitsiContext{
|
Context: &JitsiContext{
|
||||||
User: &JitsiUser{
|
User: &JitsiUser{
|
||||||
ID: userID,
|
ID: userID,
|
||||||
Name: userName,
|
Name: userName,
|
||||||
Avatar: avatar,
|
Avatar: avatar,
|
||||||
|
Moderator: moderator,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,8 +28,6 @@ type Logger struct {
|
|||||||
logger *stdlog.Logger
|
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 {
|
func New(level Level, output io.Writer) *Logger {
|
||||||
return &Logger{
|
return &Logger{
|
||||||
level: level,
|
level: level,
|
||||||
@@ -62,8 +60,6 @@ func (l *Logger) log(level Level, format string, args ...any) {
|
|||||||
l.logger.Printf("%s %s", label, msg)
|
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) {
|
func ParseLevel(s string) (Level, error) {
|
||||||
switch strings.ToLower(strings.TrimSpace(s)) {
|
switch strings.ToLower(strings.TrimSpace(s)) {
|
||||||
case "debug":
|
case "debug":
|
||||||
|
|||||||
@@ -39,8 +39,6 @@ func VerifyImageLimit(r io.Reader) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func ExtractUploadedImageBytes(r *http.Request, formKey string) ([]byte, string, 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
|
const maxRead = MaxUploadSize + 1024 // small extra for multipart framing
|
||||||
|
|
||||||
file, header, err := r.FormFile(formKey)
|
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")
|
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)
|
lr := io.LimitReader(file, maxRead)
|
||||||
fileBytes, err := io.ReadAll(lr)
|
fileBytes, err := io.ReadAll(lr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
+18
-58
@@ -9,21 +9,29 @@ import (
|
|||||||
"uPortal/internal/auth"
|
"uPortal/internal/auth"
|
||||||
)
|
)
|
||||||
|
|
||||||
// HandleSignin renders the sign-in page on GET and authenticates on POST.
|
// For relative paths, redirect directly; for external URLs, route
|
||||||
func (s *WebService) HandleSignin(w http.ResponseWriter, r *http.Request) {
|
// through the warning page unless the domain is trusted.
|
||||||
// If already authenticated, redirect
|
func (s *WebService) handleRedirect(w http.ResponseWriter, r *http.Request) {
|
||||||
if _, err := s.currentUser(r); err == nil {
|
|
||||||
redirectTo := "/"
|
redirectTo := "/"
|
||||||
if r.URL.Query().Get("redirect") != "" {
|
if r.FormValue("redirect") != "" {
|
||||||
|
redirectTo = r.FormValue("redirect")
|
||||||
|
} else if r.URL.Query().Get("redirect") != "" {
|
||||||
redirectTo = 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, "://") {
|
if strings.HasPrefix(redirectTo, "/") && !strings.Contains(redirectTo, "://") {
|
||||||
http.Redirect(w, r, redirectTo, http.StatusSeeOther)
|
http.Redirect(w, r, redirectTo, http.StatusSeeOther)
|
||||||
|
} else if s.isTrustedTarget(redirectTo) {
|
||||||
|
http.Redirect(w, r, redirectTo, http.StatusSeeOther)
|
||||||
} else {
|
} else {
|
||||||
http.Redirect(w, r, "/redirect?to="+url.QueryEscape(redirectTo), http.StatusSeeOther)
|
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 {
|
||||||
|
s.handleRedirect(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,41 +85,14 @@ func (s *WebService) HandleSignin(w http.ResponseWriter, r *http.Request) {
|
|||||||
MaxAge: maxAge,
|
MaxAge: maxAge,
|
||||||
}
|
}
|
||||||
http.SetCookie(w, cookie)
|
http.SetCookie(w, cookie)
|
||||||
|
s.handleRedirect(w, r)
|
||||||
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) {
|
func (s *WebService) HandleSignup(w http.ResponseWriter, r *http.Request) {
|
||||||
// If already authenticated, redirect
|
// If already authenticated, redirect
|
||||||
if _, err := s.currentUser(r); err == nil {
|
if _, err := s.currentUser(r); err == nil {
|
||||||
redirectTo := "/"
|
s.handleRedirect(w, r)
|
||||||
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,27 +184,10 @@ func (s *WebService) HandleSignup(w http.ResponseWriter, r *http.Request) {
|
|||||||
Secure: CookieSecure,
|
Secure: CookieSecure,
|
||||||
}
|
}
|
||||||
http.SetCookie(w, cookie)
|
http.SetCookie(w, cookie)
|
||||||
redirectTo := "/"
|
s.handleRedirect(w, r)
|
||||||
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) {
|
func (s *WebService) HandleSignout(w http.ResponseWriter, r *http.Request) {
|
||||||
switch r.Method {
|
switch r.Method {
|
||||||
case http.MethodGet:
|
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) {
|
func (s *WebService) HandleExternalRedirect(w http.ResponseWriter, r *http.Request) {
|
||||||
target := r.URL.Query().Get("to")
|
target := r.URL.Query().Get("to")
|
||||||
if target == "" {
|
if target == "" {
|
||||||
@@ -264,7 +226,6 @@ func (s *WebService) HandleExternalRedirect(w http.ResponseWriter, r *http.Reque
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Trusted targets skip the warning and are redirected directly.
|
|
||||||
if s.isTrustedTarget(target) {
|
if s.isTrustedTarget(target) {
|
||||||
http.Redirect(w, r, target, http.StatusSeeOther)
|
http.Redirect(w, r, target, http.StatusSeeOther)
|
||||||
return
|
return
|
||||||
@@ -272,7 +233,6 @@ func (s *WebService) HandleExternalRedirect(w http.ResponseWriter, r *http.Reque
|
|||||||
|
|
||||||
data, ok := s.basicViewData(w, r, "External Redirect")
|
data, ok := s.basicViewData(w, r, "External Redirect")
|
||||||
if !ok {
|
if !ok {
|
||||||
// Not authenticated is fine — just show the warning without nav
|
|
||||||
data = &ViewData{Title: "External Redirect"}
|
data = &ViewData{Title: "External Redirect"}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import (
|
|||||||
"uPortal/internal/auth"
|
"uPortal/internal/auth"
|
||||||
)
|
)
|
||||||
|
|
||||||
// HandleInvites lists all invite codes. Requires a super user.
|
|
||||||
func (s *WebService) HandleInvites(w http.ResponseWriter, r *http.Request) {
|
func (s *WebService) HandleInvites(w http.ResponseWriter, r *http.Request) {
|
||||||
data, ok := s.basicViewData(w, r, "Invites")
|
data, ok := s.basicViewData(w, r, "Invites")
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -26,7 +25,6 @@ func (s *WebService) HandleInvites(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.render(w, r, "invite/view.html", data)
|
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) {
|
func (s *WebService) HandleInviteCreate(w http.ResponseWriter, r *http.Request) {
|
||||||
switch r.Method {
|
switch r.Method {
|
||||||
case http.MethodGet:
|
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) {
|
func (s *WebService) HandleInviteDelete(w http.ResponseWriter, r *http.Request) {
|
||||||
if err := auth.ValidateCsrf(r, CsrfCookieName, CsrfFieldName); err != nil {
|
if err := auth.ValidateCsrf(r, CsrfCookieName, CsrfFieldName); err != nil {
|
||||||
http.Redirect(w, r, "/invites", http.StatusSeeOther)
|
http.Redirect(w, r, "/invites", http.StatusSeeOther)
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import (
|
|||||||
"uPortal/internal/auth"
|
"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) {
|
func (s *WebService) HandleJitsi(w http.ResponseWriter, r *http.Request) {
|
||||||
data, ok := s.basicViewData(w, r, "Jitsi")
|
data, ok := s.basicViewData(w, r, "Jitsi")
|
||||||
if !ok {
|
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) {
|
func (s *WebService) HandleJitsiGo(w http.ResponseWriter, r *http.Request) {
|
||||||
if s.jitsiCfg == nil || s.jitsiCfg.Secret == "" {
|
if s.jitsiCfg == nil || s.jitsiCfg.Secret == "" {
|
||||||
http.Error(w, "Jitsi is not configured", http.StatusInternalServerError)
|
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)
|
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 {
|
func avatarURL(r *http.Request, s *WebService, id int64) string {
|
||||||
data, err := s.users.GetAvatar(r.Context(), id)
|
data, err := s.users.GetAvatar(r.Context(), id)
|
||||||
if err != nil || len(data) == 0 {
|
if err != nil || len(data) == 0 {
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import (
|
|||||||
"uPortal/internal/user"
|
"uPortal/internal/user"
|
||||||
)
|
)
|
||||||
|
|
||||||
// HandleProfile shows the current user's own profile.
|
|
||||||
func (s *WebService) HandleProfile(w http.ResponseWriter, r *http.Request) {
|
func (s *WebService) HandleProfile(w http.ResponseWriter, r *http.Request) {
|
||||||
data, ok := s.basicViewData(w, r, "Profile")
|
data, ok := s.basicViewData(w, r, "Profile")
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -26,7 +25,6 @@ func (s *WebService) HandleProfile(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.render(w, r, "profile/view.html", data)
|
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) {
|
func (s *WebService) HandleProfileEdit(w http.ResponseWriter, r *http.Request) {
|
||||||
data, ok := s.basicViewData(w, r, "Edit Profile")
|
data, ok := s.basicViewData(w, r, "Edit Profile")
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -50,7 +48,6 @@ func (s *WebService) HandleProfileEdit(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Display name: UpdateUser writes the displayname column via the user row.
|
|
||||||
displayName := r.FormValue("display_name")
|
displayName := r.FormValue("display_name")
|
||||||
if displayName != "" {
|
if displayName != "" {
|
||||||
data.User.DisplayName = &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) {
|
func (s *WebService) HandleProfilePassword(w http.ResponseWriter, r *http.Request) {
|
||||||
data, ok := s.basicViewData(w, r, "Change Password")
|
data, ok := s.basicViewData(w, r, "Change Password")
|
||||||
if !ok {
|
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) {
|
func (s *WebService) HandleAvatar(w http.ResponseWriter, r *http.Request) {
|
||||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -15,22 +15,14 @@ import (
|
|||||||
"uPortal/internal/user"
|
"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 (
|
const (
|
||||||
SessionCookieName = "sessiontoken"
|
SessionCookieName = "sessiontoken"
|
||||||
CsrfCookieName = "csrftoken"
|
CsrfCookieName = "csrftoken"
|
||||||
CsrfFieldName = "formtoken"
|
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
|
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 {
|
type ViewData struct {
|
||||||
Title string
|
Title string
|
||||||
IsSuperUser bool
|
IsSuperUser bool
|
||||||
@@ -53,7 +45,6 @@ type WebService struct {
|
|||||||
log *log.Logger
|
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 {
|
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{
|
return &WebService{
|
||||||
users: users,
|
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 {
|
func (s *WebService) isTrustedTarget(target string) bool {
|
||||||
// Handle URLs without a scheme (e.g. "meet.jit.si/room").
|
|
||||||
if !strings.Contains(target, "://") {
|
if !strings.Contains(target, "://") {
|
||||||
target = "https://" + target
|
target = "https://" + target
|
||||||
}
|
}
|
||||||
@@ -80,7 +67,6 @@ func (s *WebService) isTrustedTarget(target string) bool {
|
|||||||
}
|
}
|
||||||
host := u.Hostname()
|
host := u.Hostname()
|
||||||
|
|
||||||
// Loopback addresses are always trusted for full URL redirects.
|
|
||||||
if host == "localhost" || host == "127.0.0.1" {
|
if host == "localhost" || host == "127.0.0.1" {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -95,7 +81,6 @@ func (s *WebService) isTrustedTarget(target string) bool {
|
|||||||
|
|
||||||
// Register wires every route (method + path) onto the mux.
|
// Register wires every route (method + path) onto the mux.
|
||||||
func (s *WebService) Register(mux *http.ServeMux) {
|
func (s *WebService) Register(mux *http.ServeMux) {
|
||||||
// Helper to wrap every handler with logging middleware.
|
|
||||||
handle := func(pattern string, handler http.HandlerFunc) {
|
handle := func(pattern string, handler http.HandlerFunc) {
|
||||||
mux.HandleFunc(pattern, s.loggingMiddleware(handler))
|
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.
|
// loggingMiddleware logs every HTTP request. Static file endpoints are skipped.
|
||||||
func (s *WebService) loggingMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
func (s *WebService) loggingMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
// Skip logging for static files and health checks.
|
|
||||||
if strings.HasPrefix(r.URL.Path, "/static/") {
|
if strings.HasPrefix(r.URL.Path, "/static/") {
|
||||||
next(w, r)
|
next(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
|
|
||||||
// Wrap the ResponseWriter to capture the status code.
|
|
||||||
lrw := &loggingResponseWriter{ResponseWriter: w, statusCode: http.StatusOK}
|
lrw := &loggingResponseWriter{ResponseWriter: w, statusCode: http.StatusOK}
|
||||||
next(lrw, r)
|
next(lrw, r)
|
||||||
|
|
||||||
@@ -185,9 +167,6 @@ func (lrw *loggingResponseWriter) WriteHeader(code int) {
|
|||||||
lrw.ResponseWriter.WriteHeader(code)
|
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) {
|
func (s *WebService) render(w http.ResponseWriter, r *http.Request, page string, data *ViewData) {
|
||||||
if data == nil {
|
if data == nil {
|
||||||
data = &ViewData{}
|
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) {
|
func (s *WebService) getUserInfo(r *http.Request) (*user.User, error) {
|
||||||
cookie, err := r.Cookie(SessionCookieName)
|
cookie, err := r.Cookie(SessionCookieName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -232,7 +210,6 @@ func (s *WebService) currentUser(r *http.Request) (*user.User, error) {
|
|||||||
return s.getUserInfo(r)
|
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) {
|
func (s *WebService) basicViewData(w http.ResponseWriter, r *http.Request, title string) (*ViewData, bool) {
|
||||||
user, err := s.getUserInfo(r)
|
user, err := s.getUserInfo(r)
|
||||||
if err != nil {
|
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
|
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 {
|
func (s *WebService) requireAuth(next http.HandlerFunc) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
if _, err := s.currentUser(r); err != nil {
|
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 {
|
func (s *WebService) requireSuperUser(next http.HandlerFunc) http.HandlerFunc {
|
||||||
return s.requireAuth(func(w http.ResponseWriter, r *http.Request) {
|
return s.requireAuth(func(w http.ResponseWriter, r *http.Request) {
|
||||||
u, err := s.currentUser(r)
|
u, err := s.currentUser(r)
|
||||||
|
|||||||
+28
-8
@@ -1,13 +1,15 @@
|
|||||||
package web
|
package web
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
|
||||||
"uPortal/internal/auth"
|
"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) {
|
func (s *WebService) HandleSessions(w http.ResponseWriter, r *http.Request) {
|
||||||
data, ok := s.basicViewData(w, r, "Sessions")
|
data, ok := s.basicViewData(w, r, "Sessions")
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -15,20 +17,41 @@ func (s *WebService) HandleSessions(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// A generous limit; the page renders whatever comes back.
|
|
||||||
sessions, err := s.auths.GetSessionByUserId(r.Context(), data.User.Id, 100, 0)
|
sessions, err := s.auths.GetSessionByUserId(r.Context(), data.User.Id, 100, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.log.Error("failed to get sessions: %v", err)
|
s.log.Error("failed to get sessions: %v", err)
|
||||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
return
|
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)
|
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) {
|
func (s *WebService) HandleSessionEnd(w http.ResponseWriter, r *http.Request) {
|
||||||
if err := auth.ValidateCsrf(r, CsrfCookieName, CsrfFieldName); err != nil {
|
if err := auth.ValidateCsrf(r, CsrfCookieName, CsrfFieldName); err != nil {
|
||||||
http.Redirect(w, r, "/sessions", http.StatusSeeOther)
|
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 {
|
if session.UserId == currentUser.Id {
|
||||||
// User deleting their own session — use defense-in-depth method
|
|
||||||
_ = s.auths.DeleteSessionByTokenAndUserId(r.Context(), token, currentUser.Id)
|
_ = s.auths.DeleteSessionByTokenAndUserId(r.Context(), token, currentUser.Id)
|
||||||
} else if currentUser.SuperUser {
|
} else if currentUser.SuperUser {
|
||||||
// Superuser overriding — can delete any session
|
|
||||||
_ = s.auths.DeleteSession(r.Context(), token)
|
_ = s.auths.DeleteSession(r.Context(), token)
|
||||||
} else {
|
} else {
|
||||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
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)
|
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) {
|
func (s *WebService) HandleSessionEndAll(w http.ResponseWriter, r *http.Request) {
|
||||||
if err := auth.ValidateCsrf(r, CsrfCookieName, CsrfFieldName); err != nil {
|
if err := auth.ValidateCsrf(r, CsrfCookieName, CsrfFieldName); err != nil {
|
||||||
http.Redirect(w, r, "/sessions", http.StatusSeeOther)
|
http.Redirect(w, r, "/sessions", http.StatusSeeOther)
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import (
|
|||||||
"uPortal/internal/auth"
|
"uPortal/internal/auth"
|
||||||
)
|
)
|
||||||
|
|
||||||
// HandleUsers lists every user in the system. Requires a super user.
|
|
||||||
func (s *WebService) HandleUsers(w http.ResponseWriter, r *http.Request) {
|
func (s *WebService) HandleUsers(w http.ResponseWriter, r *http.Request) {
|
||||||
data, ok := s.basicViewData(w, r, "Users")
|
data, ok := s.basicViewData(w, r, "Users")
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -25,7 +24,6 @@ func (s *WebService) HandleUsers(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.render(w, r, "user/view.html", data)
|
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) {
|
func (s *WebService) HandleUserResetPassword(w http.ResponseWriter, r *http.Request) {
|
||||||
if err := auth.ValidateCsrf(r, CsrfCookieName, CsrfFieldName); err != nil {
|
if err := auth.ValidateCsrf(r, CsrfCookieName, CsrfFieldName); err != nil {
|
||||||
http.Redirect(w, r, "/users", http.StatusSeeOther)
|
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)
|
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) {
|
func (s *WebService) HandleUserEndSessions(w http.ResponseWriter, r *http.Request) {
|
||||||
if err := auth.ValidateCsrf(r, CsrfCookieName, CsrfFieldName); err != nil {
|
if err := auth.ValidateCsrf(r, CsrfCookieName, CsrfFieldName); err != nil {
|
||||||
http.Redirect(w, r, "/users", http.StatusSeeOther)
|
http.Redirect(w, r, "/users", http.StatusSeeOther)
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
{{template "redirect" .}}
|
{{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_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 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>
|
<div class="text-center"><button type="submit">Sign In</button></div>
|
||||||
</form>
|
</form>
|
||||||
{{if .Redirect}}
|
{{if .Redirect}}
|
||||||
|
|||||||
@@ -19,8 +19,8 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td>{{.Token}}</td>
|
<td>{{.Token}}</td>
|
||||||
<td>{{.Uses}}</td>
|
<td>{{.Uses}}</td>
|
||||||
<td>{{.ExpiresAt.Format "2006-01-02 15:04"}}</td>
|
<td>{{.ExpiresAtLocal}}</td>
|
||||||
<td>{{.CreatedAt.Format "2006-01-02 15:04"}}</td>
|
<td>{{.CreatedAtLocal}}</td>
|
||||||
<td>
|
<td>
|
||||||
<form method="post" action="/invites/delete">
|
<form method="post" action="/invites/delete">
|
||||||
{{template "csrf" $}}
|
{{template "csrf" $}}
|
||||||
|
|||||||
@@ -12,12 +12,11 @@
|
|||||||
|
|
||||||
<table class="table">
|
<table class="table">
|
||||||
<thead>
|
<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>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{{range .Sessions}}
|
{{range .Sessions}}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{printf "%.8s" .Token}}...</td>
|
|
||||||
<td>{{.Device}}</td>
|
<td>{{.Device}}</td>
|
||||||
<td>{{.Address}}</td>
|
<td>{{.Address}}</td>
|
||||||
<td>{{.CreatedAtLocal}}</td>
|
<td>{{.CreatedAtLocal}}</td>
|
||||||
|
|||||||
Reference in new issue
Block a user