A bunch of fixes
This commit is contained in:
1 parent
65534ecfd5
commit
2a4e03e611
20 files changed
+146
-160
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() {
|
||||
// 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 {
|
||||
|
||||
@@ -9,6 +9,8 @@ services:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- uportal_data:/data
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
LOG_LEVEL: info
|
||||
# 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/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/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
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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
@@ -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"}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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" $}}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user