add loger, access log and bans/whitelist
This commit is contained in:
@@ -142,65 +142,122 @@ func Open(cfg *config.Config) (*Service, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) migrate() error {
|
func (s *Service) migrate() error {
|
||||||
stmts := []string{
|
stmts := []string{
|
||||||
`CREATE TABLE IF NOT EXISTS users (
|
`CREATE TABLE IF NOT EXISTS users (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
username TEXT UNIQUE NOT NULL,
|
username TEXT UNIQUE NOT NULL,
|
||||||
email TEXT UNIQUE NOT NULL,
|
email TEXT UNIQUE NOT NULL,
|
||||||
password_hash TEXT NOT NULL,
|
password_hash TEXT NOT NULL,
|
||||||
is_active INTEGER NOT NULL DEFAULT 1,
|
is_active INTEGER NOT NULL DEFAULT 1,
|
||||||
email_confirmed INTEGER NOT NULL DEFAULT 0,
|
email_confirmed INTEGER NOT NULL DEFAULT 0,
|
||||||
mfa_secret TEXT NULL,
|
mfa_secret TEXT NULL,
|
||||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
)` ,
|
)` ,
|
||||||
`CREATE TABLE IF NOT EXISTS groups (
|
`CREATE TABLE IF NOT EXISTS groups (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
name TEXT UNIQUE NOT NULL
|
name TEXT UNIQUE NOT NULL
|
||||||
)` ,
|
)` ,
|
||||||
`CREATE TABLE IF NOT EXISTS user_groups (
|
`CREATE TABLE IF NOT EXISTS user_groups (
|
||||||
user_id INTEGER NOT NULL,
|
user_id INTEGER NOT NULL,
|
||||||
group_id INTEGER NOT NULL,
|
group_id INTEGER NOT NULL,
|
||||||
PRIMARY KEY(user_id, group_id),
|
PRIMARY KEY(user_id, group_id),
|
||||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
|
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
FOREIGN KEY(group_id) REFERENCES groups(id) ON DELETE CASCADE
|
FOREIGN KEY(group_id) REFERENCES groups(id) ON DELETE CASCADE
|
||||||
)` ,
|
)` ,
|
||||||
`CREATE TABLE IF NOT EXISTS permissions (
|
`CREATE TABLE IF NOT EXISTS permissions (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
group_id INTEGER NOT NULL,
|
group_id INTEGER NOT NULL,
|
||||||
path_prefix TEXT NOT NULL,
|
path_prefix TEXT NOT NULL,
|
||||||
can_read INTEGER NOT NULL DEFAULT 1,
|
can_read INTEGER NOT NULL DEFAULT 1,
|
||||||
can_write INTEGER NOT NULL DEFAULT 0,
|
can_write INTEGER NOT NULL DEFAULT 0,
|
||||||
can_delete INTEGER NOT NULL DEFAULT 0,
|
can_delete INTEGER NOT NULL DEFAULT 0,
|
||||||
FOREIGN KEY(group_id) REFERENCES groups(id) ON DELETE CASCADE
|
FOREIGN KEY(group_id) REFERENCES groups(id) ON DELETE CASCADE
|
||||||
)` ,
|
)` ,
|
||||||
`CREATE TABLE IF NOT EXISTS email_verification_tokens (
|
`CREATE TABLE IF NOT EXISTS email_verification_tokens (
|
||||||
user_id INTEGER NOT NULL,
|
user_id INTEGER NOT NULL,
|
||||||
token TEXT NOT NULL UNIQUE,
|
token TEXT NOT NULL UNIQUE,
|
||||||
expires_at DATETIME NOT NULL,
|
expires_at DATETIME NOT NULL,
|
||||||
PRIMARY KEY(user_id),
|
PRIMARY KEY(user_id),
|
||||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
)` ,
|
)` ,
|
||||||
`CREATE TABLE IF NOT EXISTS password_reset_tokens (
|
`CREATE TABLE IF NOT EXISTS password_reset_tokens (
|
||||||
user_id INTEGER NOT NULL,
|
user_id INTEGER NOT NULL,
|
||||||
token TEXT NOT NULL UNIQUE,
|
token TEXT NOT NULL UNIQUE,
|
||||||
expires_at DATETIME NOT NULL,
|
expires_at DATETIME NOT NULL,
|
||||||
PRIMARY KEY(user_id),
|
PRIMARY KEY(user_id),
|
||||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
)` ,
|
)` ,
|
||||||
`CREATE TABLE IF NOT EXISTS mfa_enrollments (
|
`CREATE TABLE IF NOT EXISTS mfa_enrollments (
|
||||||
user_id INTEGER PRIMARY KEY,
|
user_id INTEGER PRIMARY KEY,
|
||||||
secret TEXT NOT NULL,
|
secret TEXT NOT NULL,
|
||||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
)` ,
|
)` ,
|
||||||
}
|
// Access logs for all requests
|
||||||
for _, stmt := range stmts {
|
`CREATE TABLE IF NOT EXISTS access_logs (
|
||||||
if _, err := s.DB.Exec(stmt); err != nil {
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
return err
|
user_id INTEGER NULL,
|
||||||
}
|
ip TEXT NOT NULL,
|
||||||
}
|
method TEXT NOT NULL,
|
||||||
return nil
|
path TEXT NOT NULL,
|
||||||
|
status INTEGER NOT NULL,
|
||||||
|
duration_ms INTEGER NOT NULL,
|
||||||
|
user_agent TEXT NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE SET NULL
|
||||||
|
)` ,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_access_logs_created_at ON access_logs(created_at)` ,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_access_logs_user_id ON access_logs(user_id)` ,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_access_logs_ip ON access_logs(ip)` ,
|
||||||
|
|
||||||
|
// Error logs for server-side errors
|
||||||
|
`CREATE TABLE IF NOT EXISTS error_logs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NULL,
|
||||||
|
ip TEXT NULL,
|
||||||
|
path TEXT NULL,
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
stack TEXT NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE SET NULL
|
||||||
|
)` ,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_error_logs_created_at ON error_logs(created_at)` ,
|
||||||
|
|
||||||
|
// Failed login attempts (both password and MFA tracked separately)
|
||||||
|
`CREATE TABLE IF NOT EXISTS failed_logins (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
ip TEXT NOT NULL,
|
||||||
|
user_id INTEGER NULL,
|
||||||
|
username TEXT NULL,
|
||||||
|
type TEXT NOT NULL CHECK(type IN ('password','mfa')),
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE SET NULL
|
||||||
|
)` ,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_failed_logins_ip_created ON failed_logins(ip, created_at)` ,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_failed_logins_type_created ON failed_logins(type, created_at)` ,
|
||||||
|
|
||||||
|
// IP bans and whitelist
|
||||||
|
`CREATE TABLE IF NOT EXISTS ip_bans (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
ip TEXT NOT NULL UNIQUE,
|
||||||
|
reason TEXT NULL,
|
||||||
|
until DATETIME NULL,
|
||||||
|
permanent INTEGER NOT NULL DEFAULT 0,
|
||||||
|
whitelisted INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)` ,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_ip_bans_ip ON ip_bans(ip)` ,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_ip_bans_whitelist ON ip_bans(whitelisted)` ,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_ip_bans_permanent ON ip_bans(permanent)` ,
|
||||||
|
}
|
||||||
|
for _, stmt := range stmts {
|
||||||
|
if _, err := s.DB.Exec(stmt); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) ensureDefaultAdmin() error {
|
func (s *Service) ensureDefaultAdmin() error {
|
||||||
|
|||||||
@@ -52,6 +52,13 @@ type Config struct {
|
|||||||
SMTPPassword string
|
SMTPPassword string
|
||||||
SMTPSender string
|
SMTPSender string
|
||||||
SMTPUseTLS bool
|
SMTPUseTLS bool
|
||||||
|
|
||||||
|
// Security settings (failed-login thresholds and auto-ban config)
|
||||||
|
PwdFailuresThreshold int
|
||||||
|
MFAFailuresThreshold int
|
||||||
|
FailuresWindowMinutes int
|
||||||
|
AutoBanDurationHours int
|
||||||
|
AutoBanPermanent bool
|
||||||
}
|
}
|
||||||
|
|
||||||
var defaultConfig = map[string]map[string]string{
|
var defaultConfig = map[string]map[string]string{
|
||||||
@@ -96,6 +103,13 @@ var defaultConfig = map[string]map[string]string{
|
|||||||
"SMTP_SENDER": "",
|
"SMTP_SENDER": "",
|
||||||
"SMTP_USE_TLS": "true",
|
"SMTP_USE_TLS": "true",
|
||||||
},
|
},
|
||||||
|
"SECURITY": {
|
||||||
|
"PWD_FAILURES_THRESHOLD": "5",
|
||||||
|
"MFA_FAILURES_THRESHOLD": "10",
|
||||||
|
"FAILURES_WINDOW_MINUTES": "30",
|
||||||
|
"AUTO_BAN_DURATION_HOURS": "12",
|
||||||
|
"AUTO_BAN_PERMANENT": "false",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load() (*Config, error) {
|
func Load() (*Config, error) {
|
||||||
@@ -194,6 +208,14 @@ func Load() (*Config, error) {
|
|||||||
config.SMTPSender = emailSection.Key("SMTP_SENDER").String()
|
config.SMTPSender = emailSection.Key("SMTP_SENDER").String()
|
||||||
config.SMTPUseTLS, _ = emailSection.Key("SMTP_USE_TLS").Bool()
|
config.SMTPUseTLS, _ = emailSection.Key("SMTP_USE_TLS").Bool()
|
||||||
|
|
||||||
|
// Load SECURITY section
|
||||||
|
secSection := cfg.Section("SECURITY")
|
||||||
|
config.PwdFailuresThreshold, _ = secSection.Key("PWD_FAILURES_THRESHOLD").Int()
|
||||||
|
config.MFAFailuresThreshold, _ = secSection.Key("MFA_FAILURES_THRESHOLD").Int()
|
||||||
|
config.FailuresWindowMinutes, _ = secSection.Key("FAILURES_WINDOW_MINUTES").Int()
|
||||||
|
config.AutoBanDurationHours, _ = secSection.Key("AUTO_BAN_DURATION_HOURS").Int()
|
||||||
|
config.AutoBanPermanent, _ = secSection.Key("AUTO_BAN_PERMANENT").Bool()
|
||||||
|
|
||||||
return config, nil
|
return config, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -362,6 +384,27 @@ func (c *Config) SaveSetting(section, key, value string) error {
|
|||||||
case "SMTP_USE_TLS":
|
case "SMTP_USE_TLS":
|
||||||
c.SMTPUseTLS = value == "true"
|
c.SMTPUseTLS = value == "true"
|
||||||
}
|
}
|
||||||
|
case "SECURITY":
|
||||||
|
switch key {
|
||||||
|
case "PWD_FAILURES_THRESHOLD":
|
||||||
|
if v, err := strconv.Atoi(value); err == nil {
|
||||||
|
c.PwdFailuresThreshold = v
|
||||||
|
}
|
||||||
|
case "MFA_FAILURES_THRESHOLD":
|
||||||
|
if v, err := strconv.Atoi(value); err == nil {
|
||||||
|
c.MFAFailuresThreshold = v
|
||||||
|
}
|
||||||
|
case "FAILURES_WINDOW_MINUTES":
|
||||||
|
if v, err := strconv.Atoi(value); err == nil {
|
||||||
|
c.FailuresWindowMinutes = v
|
||||||
|
}
|
||||||
|
case "AUTO_BAN_DURATION_HOURS":
|
||||||
|
if v, err := strconv.Atoi(value); err == nil {
|
||||||
|
c.AutoBanDurationHours = v
|
||||||
|
}
|
||||||
|
case "AUTO_BAN_PERMANENT":
|
||||||
|
c.AutoBanPermanent = value == "true"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return cfg.SaveTo(configPath)
|
return cfg.SaveTo(configPath)
|
||||||
|
|||||||
@@ -30,6 +30,87 @@ func (h *Handlers) LoginPage(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Failed login tracking and automatic IP bans ---
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultPwdFailuresThreshold = 5
|
||||||
|
defaultMFAFailuresThreshold = 10
|
||||||
|
defaultFailuresWindow = 30 * time.Minute
|
||||||
|
defaultBanDuration = 12 * time.Hour
|
||||||
|
)
|
||||||
|
|
||||||
|
// recordFailedAttempt logs a failed attempt and applies IP ban if thresholds exceeded.
|
||||||
|
func (h *Handlers) recordFailedAttempt(c *gin.Context, typ, username string, userID *int64) {
|
||||||
|
ip := c.ClientIP()
|
||||||
|
var uid interface{}
|
||||||
|
if userID != nil {
|
||||||
|
uid = *userID
|
||||||
|
}
|
||||||
|
// Insert failed attempt (best-effort)
|
||||||
|
_, _ = h.authSvc.DB.Exec(`INSERT INTO failed_logins (ip, user_id, username, type) VALUES (?,?,?,?)`, ip, uid, username, typ)
|
||||||
|
|
||||||
|
// Determine threshold using config overrides (fallback to defaults when zero)
|
||||||
|
threshold := h.config.PwdFailuresThreshold
|
||||||
|
if threshold <= 0 {
|
||||||
|
threshold = defaultPwdFailuresThreshold
|
||||||
|
}
|
||||||
|
if typ == "mfa" {
|
||||||
|
t2 := h.config.MFAFailuresThreshold
|
||||||
|
if t2 <= 0 {
|
||||||
|
t2 = defaultMFAFailuresThreshold
|
||||||
|
}
|
||||||
|
threshold = t2
|
||||||
|
}
|
||||||
|
// Count recent failures for this IP and type
|
||||||
|
var cnt int
|
||||||
|
// Window from config (minutes)
|
||||||
|
windowMinutes := h.config.FailuresWindowMinutes
|
||||||
|
if windowMinutes <= 0 {
|
||||||
|
windowMinutes = int(defaultFailuresWindow / time.Minute)
|
||||||
|
}
|
||||||
|
cutoff := time.Now().Add(-time.Duration(windowMinutes) * time.Minute)
|
||||||
|
_ = h.authSvc.DB.QueryRow(`SELECT COUNT(1) FROM failed_logins WHERE ip = ? AND type = ? AND created_at >= ?`, ip, typ, cutoff).Scan(&cnt)
|
||||||
|
if cnt >= threshold {
|
||||||
|
// Duration/permanence from config
|
||||||
|
banHours := h.config.AutoBanDurationHours
|
||||||
|
if banHours <= 0 {
|
||||||
|
banHours = int(defaultBanDuration / time.Hour)
|
||||||
|
}
|
||||||
|
makePermanent := h.config.AutoBanPermanent
|
||||||
|
var until interface{}
|
||||||
|
if makePermanent {
|
||||||
|
until = nil
|
||||||
|
} else {
|
||||||
|
until = time.Now().Add(time.Duration(banHours) * time.Hour)
|
||||||
|
}
|
||||||
|
reason := fmt.Sprintf("auto-ban: %s failures=%d within %d minutes", typ, cnt, windowMinutes)
|
||||||
|
// Upsert ban unless whitelisted or permanent existing
|
||||||
|
if makePermanent {
|
||||||
|
_, _ = h.authSvc.DB.Exec(`
|
||||||
|
INSERT INTO ip_bans (ip, reason, until, permanent, whitelisted, created_at, updated_at)
|
||||||
|
VALUES (?, ?, NULL, 1, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||||
|
ON CONFLICT(ip) DO UPDATE SET
|
||||||
|
reason=excluded.reason,
|
||||||
|
until=NULL,
|
||||||
|
permanent=1,
|
||||||
|
updated_at=CURRENT_TIMESTAMP
|
||||||
|
WHERE ip_bans.whitelisted = 0
|
||||||
|
`, ip, reason)
|
||||||
|
} else {
|
||||||
|
_, _ = h.authSvc.DB.Exec(`
|
||||||
|
INSERT INTO ip_bans (ip, reason, until, permanent, whitelisted, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, 0, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||||
|
ON CONFLICT(ip) DO UPDATE SET
|
||||||
|
reason=excluded.reason,
|
||||||
|
until=excluded.until,
|
||||||
|
permanent=0,
|
||||||
|
updated_at=CURRENT_TIMESTAMP
|
||||||
|
WHERE ip_bans.whitelisted = 0 AND ip_bans.permanent = 0
|
||||||
|
`, ip, reason, until)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// isAllDigits returns true if s consists only of ASCII digits 0-9
|
// isAllDigits returns true if s consists only of ASCII digits 0-9
|
||||||
func isAllDigits(s string) bool {
|
func isAllDigits(s string) bool {
|
||||||
if s == "" {
|
if s == "" {
|
||||||
@@ -65,6 +146,8 @@ func (h *Handlers) MFALoginVerify(c *gin.Context) {
|
|||||||
code := strings.TrimSpace(c.PostForm("code"))
|
code := strings.TrimSpace(c.PostForm("code"))
|
||||||
if len(code) != 6 || !isAllDigits(code) {
|
if len(code) != 6 || !isAllDigits(code) {
|
||||||
token, _ := c.Get("csrf_token")
|
token, _ := c.Get("csrf_token")
|
||||||
|
// record failed MFA attempt
|
||||||
|
h.recordFailedAttempt(c, "mfa", "", nil)
|
||||||
c.HTML(http.StatusUnauthorized, "mfa", gin.H{
|
c.HTML(http.StatusUnauthorized, "mfa", gin.H{
|
||||||
"app_name": h.config.AppName,
|
"app_name": h.config.AppName,
|
||||||
"csrf_token": token,
|
"csrf_token": token,
|
||||||
@@ -84,11 +167,13 @@ func (h *Handlers) MFALoginVerify(c *gin.Context) {
|
|||||||
uid, _ := uidAny.(int64)
|
uid, _ := uidAny.(int64)
|
||||||
var secret string
|
var secret string
|
||||||
if err := h.authSvc.DB.QueryRow(`SELECT mfa_secret FROM users WHERE id = ?`, uid).Scan(&secret); err != nil || secret == "" {
|
if err := h.authSvc.DB.QueryRow(`SELECT mfa_secret FROM users WHERE id = ?`, uid).Scan(&secret); err != nil || secret == "" {
|
||||||
|
h.recordFailedAttempt(c, "mfa", "", &uid)
|
||||||
c.HTML(http.StatusUnauthorized, "mfa", gin.H{"error": "MFA not enabled", "Page": "mfa", "ContentTemplate": "mfa_content", "ScriptsTemplate": "mfa_scripts", "app_name": h.config.AppName})
|
c.HTML(http.StatusUnauthorized, "mfa", gin.H{"error": "MFA not enabled", "Page": "mfa", "ContentTemplate": "mfa_content", "ScriptsTemplate": "mfa_scripts", "app_name": h.config.AppName})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !verifyTOTP(secret, code, time.Now()) {
|
if !verifyTOTP(secret, code, time.Now()) {
|
||||||
token, _ := c.Get("csrf_token")
|
token, _ := c.Get("csrf_token")
|
||||||
|
h.recordFailedAttempt(c, "mfa", "", &uid)
|
||||||
c.HTML(http.StatusUnauthorized, "mfa", gin.H{
|
c.HTML(http.StatusUnauthorized, "mfa", gin.H{
|
||||||
"app_name": h.config.AppName,
|
"app_name": h.config.AppName,
|
||||||
"csrf_token": token,
|
"csrf_token": token,
|
||||||
@@ -227,6 +312,8 @@ func (h *Handlers) LoginPost(c *gin.Context) {
|
|||||||
user, err := h.authSvc.Authenticate(username, password)
|
user, err := h.authSvc.Authenticate(username, password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
token, _ := c.Get("csrf_token")
|
token, _ := c.Get("csrf_token")
|
||||||
|
// record failed password attempt
|
||||||
|
h.recordFailedAttempt(c, "password", username, nil)
|
||||||
c.HTML(http.StatusUnauthorized, "login", gin.H{
|
c.HTML(http.StatusUnauthorized, "login", gin.H{
|
||||||
"app_name": h.config.AppName,
|
"app_name": h.config.AppName,
|
||||||
"csrf_token": token,
|
"csrf_token": token,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
|
|
||||||
@@ -32,6 +33,312 @@ type Handlers struct {
|
|||||||
authSvc *auth.Service
|
authSvc *auth.Service
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AdminBanIP bans an IP (temporary via hours or permanent)
|
||||||
|
func (h *Handlers) AdminBanIP(c *gin.Context) {
|
||||||
|
ip := strings.TrimSpace(c.PostForm("ip"))
|
||||||
|
if ip == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "ip is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reason := strings.TrimSpace(c.PostForm("reason"))
|
||||||
|
permStr := strings.TrimSpace(c.PostForm("permanent"))
|
||||||
|
hoursStr := strings.TrimSpace(c.PostForm("hours"))
|
||||||
|
permanent := 0
|
||||||
|
if permStr == "1" || strings.EqualFold(permStr, "true") {
|
||||||
|
permanent = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
var until sql.NullTime
|
||||||
|
if permanent == 0 && hoursStr != "" {
|
||||||
|
if hInt, err := strconv.Atoi(hoursStr); err == nil && hInt > 0 {
|
||||||
|
t := time.Now().Add(time.Duration(hInt) * time.Hour)
|
||||||
|
until = sql.NullTime{Time: t, Valid: true}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert ban
|
||||||
|
if permanent == 1 {
|
||||||
|
_, err := h.authSvc.DB.Exec(`INSERT INTO ip_bans (ip, reason, until, permanent, whitelisted, updated_at)
|
||||||
|
VALUES (?, ?, NULL, 1, 0, CURRENT_TIMESTAMP)
|
||||||
|
ON CONFLICT(ip) DO UPDATE SET reason=excluded.reason, until=NULL, permanent=1, whitelisted=0, updated_at=CURRENT_TIMESTAMP`, ip, nullIfEmpty(reason))
|
||||||
|
if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}); return }
|
||||||
|
} else {
|
||||||
|
_, err := h.authSvc.DB.Exec(`INSERT INTO ip_bans (ip, reason, until, permanent, whitelisted, updated_at)
|
||||||
|
VALUES (?, ?, ?, 0, 0, CURRENT_TIMESTAMP)
|
||||||
|
ON CONFLICT(ip) DO UPDATE SET reason=excluded.reason, until=excluded.until, permanent=0, whitelisted=0, updated_at=CURRENT_TIMESTAMP`, ip, nullIfEmpty(reason), until)
|
||||||
|
if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}); return }
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminUnbanIP removes a ban entry (and whitelist) for an IP
|
||||||
|
func (h *Handlers) AdminUnbanIP(c *gin.Context) {
|
||||||
|
ip := strings.TrimSpace(c.PostForm("ip"))
|
||||||
|
if ip == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "ip is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := h.authSvc.DB.Exec(`DELETE FROM ip_bans WHERE ip = ?`, ip); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminWhitelistIP sets or clears whitelist for an IP
|
||||||
|
func (h *Handlers) AdminWhitelistIP(c *gin.Context) {
|
||||||
|
ip := strings.TrimSpace(c.PostForm("ip"))
|
||||||
|
if ip == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "ip is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val := strings.TrimSpace(c.PostForm("value"))
|
||||||
|
w := 0
|
||||||
|
if val == "1" || strings.EqualFold(val, "true") { w = 1 }
|
||||||
|
// Ensure row exists and update flags
|
||||||
|
_, err := h.authSvc.DB.Exec(`INSERT INTO ip_bans (ip, reason, until, permanent, whitelisted, updated_at)
|
||||||
|
VALUES (?, NULL, NULL, 0, ?, CURRENT_TIMESTAMP)
|
||||||
|
ON CONFLICT(ip) DO UPDATE SET whitelisted=excluded.whitelisted, permanent=CASE WHEN excluded.whitelisted=1 THEN 0 ELSE permanent END, until=CASE WHEN excluded.whitelisted=1 THEN NULL ELSE until END, updated_at=CURRENT_TIMESTAMP`, ip, w)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// helper: turn empty string to sql.NullString
|
||||||
|
func nullIfEmpty(s string) sql.NullString {
|
||||||
|
if strings.TrimSpace(s) == "" { return sql.NullString{Valid: false} }
|
||||||
|
return sql.NullString{String: s, Valid: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminLogsPage shows recent access, error, failed login, and IP ban entries
|
||||||
|
func (h *Handlers) AdminLogsPage(c *gin.Context) {
|
||||||
|
|
||||||
|
// Parse filters
|
||||||
|
ipFilter := strings.TrimSpace(c.Query("ip"))
|
||||||
|
hoursStr := strings.TrimSpace(c.Query("hours"))
|
||||||
|
limitStr := strings.TrimSpace(c.Query("limit"))
|
||||||
|
bansFilter := strings.TrimSpace(c.Query("bans")) // all|active|perma|whitelist
|
||||||
|
|
||||||
|
// Defaults
|
||||||
|
var lastSince *time.Time
|
||||||
|
if hoursStr != "" {
|
||||||
|
if hInt, err := strconv.Atoi(hoursStr); err == nil && hInt > 0 {
|
||||||
|
t := time.Now().Add(-time.Duration(hInt) * time.Hour)
|
||||||
|
lastSince = &t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lim := 100
|
||||||
|
if limitStr != "" {
|
||||||
|
if v, err := strconv.Atoi(limitStr); err == nil {
|
||||||
|
if v <= 0 {
|
||||||
|
lim = 100
|
||||||
|
} else if v > 500 {
|
||||||
|
lim = 500
|
||||||
|
} else {
|
||||||
|
lim = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if bansFilter == "" {
|
||||||
|
bansFilter = "all"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query recent entries with filters
|
||||||
|
type accessRow struct {
|
||||||
|
CreatedAt time.Time
|
||||||
|
IP string
|
||||||
|
Method string
|
||||||
|
Path string
|
||||||
|
Status int
|
||||||
|
Duration int64
|
||||||
|
UserAgent string
|
||||||
|
UserID sql.NullInt64
|
||||||
|
Username sql.NullString
|
||||||
|
}
|
||||||
|
type errorRow struct {
|
||||||
|
CreatedAt time.Time
|
||||||
|
IP sql.NullString
|
||||||
|
Path sql.NullString
|
||||||
|
Message string
|
||||||
|
}
|
||||||
|
type failedRow struct {
|
||||||
|
CreatedAt time.Time
|
||||||
|
IP string
|
||||||
|
Username sql.NullString
|
||||||
|
Type string
|
||||||
|
UserID sql.NullInt64
|
||||||
|
}
|
||||||
|
type banRow struct {
|
||||||
|
IP string
|
||||||
|
Reason sql.NullString
|
||||||
|
Until sql.NullTime
|
||||||
|
Permanent int
|
||||||
|
Whitelisted int
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Access logs
|
||||||
|
access := []accessRow{}
|
||||||
|
{
|
||||||
|
sb := strings.Builder{}
|
||||||
|
sb.WriteString("SELECT al.created_at, al.ip, al.method, al.path, al.status, al.duration_ms, al.user_agent, al.user_id, u.username FROM access_logs al LEFT JOIN users u ON u.id = al.user_id WHERE 1=1")
|
||||||
|
args := []any{}
|
||||||
|
if lastSince != nil {
|
||||||
|
sb.WriteString(" AND al.created_at >= ?")
|
||||||
|
args = append(args, *lastSince)
|
||||||
|
}
|
||||||
|
if ipFilter != "" {
|
||||||
|
sb.WriteString(" AND al.ip LIKE ?")
|
||||||
|
args = append(args, "%"+ipFilter+"%")
|
||||||
|
}
|
||||||
|
sb.WriteString(" ORDER BY al.created_at DESC LIMIT ")
|
||||||
|
sb.WriteString(strconv.Itoa(lim))
|
||||||
|
if rows, err := h.authSvc.DB.Query(sb.String(), args...); err == nil {
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var r accessRow
|
||||||
|
if err := rows.Scan(&r.CreatedAt, &r.IP, &r.Method, &r.Path, &r.Status, &r.Duration, &r.UserAgent, &r.UserID, &r.Username); err == nil {
|
||||||
|
access = append(access, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error logs
|
||||||
|
errors := []errorRow{}
|
||||||
|
{
|
||||||
|
sb := strings.Builder{}
|
||||||
|
sb.WriteString("SELECT created_at, ip, path, message FROM error_logs WHERE 1=1")
|
||||||
|
args := []any{}
|
||||||
|
if lastSince != nil {
|
||||||
|
sb.WriteString(" AND created_at >= ?")
|
||||||
|
args = append(args, *lastSince)
|
||||||
|
}
|
||||||
|
if ipFilter != "" {
|
||||||
|
sb.WriteString(" AND ip IS NOT NULL AND ip LIKE ?")
|
||||||
|
args = append(args, "%"+ipFilter+"%")
|
||||||
|
}
|
||||||
|
sb.WriteString(" ORDER BY created_at DESC LIMIT ")
|
||||||
|
sb.WriteString(strconv.Itoa(lim))
|
||||||
|
if rows, err := h.authSvc.DB.Query(sb.String(), args...); err == nil {
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var r errorRow
|
||||||
|
if err := rows.Scan(&r.CreatedAt, &r.IP, &r.Path, &r.Message); err == nil {
|
||||||
|
errors = append(errors, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Failed logins
|
||||||
|
failed := []failedRow{}
|
||||||
|
{
|
||||||
|
sb := strings.Builder{}
|
||||||
|
sb.WriteString("SELECT created_at, ip, username, type, user_id FROM failed_logins WHERE 1=1")
|
||||||
|
args := []any{}
|
||||||
|
if lastSince != nil {
|
||||||
|
sb.WriteString(" AND created_at >= ?")
|
||||||
|
args = append(args, *lastSince)
|
||||||
|
}
|
||||||
|
if ipFilter != "" {
|
||||||
|
sb.WriteString(" AND ip LIKE ?")
|
||||||
|
args = append(args, "%"+ipFilter+"%")
|
||||||
|
}
|
||||||
|
sb.WriteString(" ORDER BY created_at DESC LIMIT ")
|
||||||
|
sb.WriteString(strconv.Itoa(lim))
|
||||||
|
if rows, err := h.authSvc.DB.Query(sb.String(), args...); err == nil {
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var r failedRow
|
||||||
|
if err := rows.Scan(&r.CreatedAt, &r.IP, &r.Username, &r.Type, &r.UserID); err == nil {
|
||||||
|
failed = append(failed, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IP bans
|
||||||
|
bans := []banRow{}
|
||||||
|
{
|
||||||
|
sb := strings.Builder{}
|
||||||
|
sb.WriteString("SELECT ip, reason, until, permanent, whitelisted, updated_at FROM ip_bans WHERE 1=1")
|
||||||
|
args := []any{}
|
||||||
|
switch strings.ToLower(bansFilter) {
|
||||||
|
case "perma", "perm", "permanent":
|
||||||
|
sb.WriteString(" AND permanent = 1")
|
||||||
|
case "whitelist", "whitelisted":
|
||||||
|
sb.WriteString(" AND whitelisted = 1")
|
||||||
|
case "active":
|
||||||
|
// Active means currently in effect and not whitelisted
|
||||||
|
sb.WriteString(" AND whitelisted = 0 AND (permanent = 1 OR (until IS NOT NULL AND until > CURRENT_TIMESTAMP))")
|
||||||
|
default:
|
||||||
|
// all
|
||||||
|
}
|
||||||
|
if ipFilter != "" {
|
||||||
|
sb.WriteString(" AND ip LIKE ?")
|
||||||
|
args = append(args, "%"+ipFilter+"%")
|
||||||
|
}
|
||||||
|
sb.WriteString(" ORDER BY updated_at DESC LIMIT ")
|
||||||
|
sb.WriteString(strconv.Itoa(lim))
|
||||||
|
if rows, err := h.authSvc.DB.Query(sb.String(), args...); err == nil {
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var r banRow
|
||||||
|
if err := rows.Scan(&r.IP, &r.Reason, &r.Until, &r.Permanent, &r.Whitelisted, &r.UpdatedAt); err == nil {
|
||||||
|
bans = append(bans, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.HTML(http.StatusOK, "admin_logs", gin.H{
|
||||||
|
"app_name": h.config.AppName,
|
||||||
|
"NoSidebar": true,
|
||||||
|
"breadcrumbs": []gin.H{{"Name": "/", "URL": "/"}, {"Name": "Admin", "URL": "/editor/admin"}, {"Name": "Logs", "URL": ""}},
|
||||||
|
"Page": "admin_logs",
|
||||||
|
"AccessLogs": access,
|
||||||
|
"ErrorLogs": errors,
|
||||||
|
"FailedLogins": failed,
|
||||||
|
"IPBans": bans,
|
||||||
|
// Echo filters back to template
|
||||||
|
"FilterIP": ipFilter,
|
||||||
|
"FilterHours": hoursStr,
|
||||||
|
"FilterLimit": lim,
|
||||||
|
"FilterBans": bansFilter,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminClearAccessLogs clears access logs based on days parameter: 0=all, default 7
|
||||||
|
func (h *Handlers) AdminClearAccessLogs(c *gin.Context) {
|
||||||
|
daysStr := strings.TrimSpace(c.PostForm("days"))
|
||||||
|
days := 7
|
||||||
|
if daysStr != "" {
|
||||||
|
if v, err := strconv.Atoi(daysStr); err == nil && v >= 0 {
|
||||||
|
days = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var (
|
||||||
|
query string
|
||||||
|
args []any
|
||||||
|
)
|
||||||
|
if days == 0 {
|
||||||
|
query = `DELETE FROM access_logs`
|
||||||
|
} else {
|
||||||
|
query = `DELETE FROM access_logs WHERE created_at < DATETIME('now', ? || ' days')`
|
||||||
|
// negative days for SQLite modifier, e.g., '-7 days'
|
||||||
|
args = append(args, fmt.Sprintf("-%d", days))
|
||||||
|
}
|
||||||
|
if _, err := h.authSvc.DB.Exec(query, args...); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||||
|
}
|
||||||
|
|
||||||
// ProfilePage renders the user profile page for the signed-in user
|
// ProfilePage renders the user profile page for the signed-in user
|
||||||
func (h *Handlers) ProfilePage(c *gin.Context) {
|
func (h *Handlers) ProfilePage(c *gin.Context) {
|
||||||
// Must be authenticated; middleware ensures user_id is set
|
// Must be authenticated; middleware ensures user_id is set
|
||||||
|
|||||||
@@ -207,3 +207,75 @@ func boolToStr(b bool) string {
|
|||||||
}
|
}
|
||||||
return "false"
|
return "false"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Security (IP Ban & Thresholds) Settings ---
|
||||||
|
|
||||||
|
// GetSecuritySettingsHandler returns current security-related config
|
||||||
|
func (h *Handlers) GetSecuritySettingsHandler(c *gin.Context) {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"pwd_failures_threshold": h.config.PwdFailuresThreshold,
|
||||||
|
"mfa_failures_threshold": h.config.MFAFailuresThreshold,
|
||||||
|
"failures_window_minutes": h.config.FailuresWindowMinutes,
|
||||||
|
"auto_ban_duration_hours": h.config.AutoBanDurationHours,
|
||||||
|
"auto_ban_permanent": h.config.AutoBanPermanent,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// PostSecuritySettingsHandler validates and saves security-related config
|
||||||
|
func (h *Handlers) PostSecuritySettingsHandler(c *gin.Context) {
|
||||||
|
pwdStr := strings.TrimSpace(c.PostForm("pwd_failures_threshold"))
|
||||||
|
mfaStr := strings.TrimSpace(c.PostForm("mfa_failures_threshold"))
|
||||||
|
winStr := strings.TrimSpace(c.PostForm("failures_window_minutes"))
|
||||||
|
durStr := strings.TrimSpace(c.PostForm("auto_ban_duration_hours"))
|
||||||
|
permStr := strings.TrimSpace(c.PostForm("auto_ban_permanent"))
|
||||||
|
|
||||||
|
// basic validation
|
||||||
|
if pwdStr == "" || mfaStr == "" || winStr == "" || durStr == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "All numeric fields are required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := strconv.Atoi(pwdStr); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid password failures threshold"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := strconv.Atoi(mfaStr); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid MFA failures threshold"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := strconv.Atoi(winStr); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid failures window (minutes)"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := strconv.Atoi(durStr); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid auto-ban duration (hours)"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalize perm
|
||||||
|
perm := strings.EqualFold(permStr, "true") || permStr == "1" || strings.EqualFold(permStr, "on")
|
||||||
|
permStr = boolToStr(perm)
|
||||||
|
|
||||||
|
// Save values
|
||||||
|
if err := h.config.SaveSetting("SECURITY", "PWD_FAILURES_THRESHOLD", pwdStr); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save PWD_FAILURES_THRESHOLD"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.config.SaveSetting("SECURITY", "MFA_FAILURES_THRESHOLD", mfaStr); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save MFA_FAILURES_THRESHOLD"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.config.SaveSetting("SECURITY", "FAILURES_WINDOW_MINUTES", winStr); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save FAILURES_WINDOW_MINUTES"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.config.SaveSetting("SECURITY", "AUTO_BAN_DURATION_HOURS", durStr); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save AUTO_BAN_DURATION_HOURS"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.config.SaveSetting("SECURITY", "AUTO_BAN_PERMANENT", permStr); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save AUTO_BAN_PERMANENT"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ package server
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
|
"database/sql"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@@ -105,3 +107,84 @@ func (s *Server) RequireAdmin() gin.HandlerFunc {
|
|||||||
c.Next()
|
c.Next()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AccessLogger logs all requests to the access_logs table after handling.
|
||||||
|
func (s *Server) AccessLogger() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
start := time.Now()
|
||||||
|
c.Next()
|
||||||
|
|
||||||
|
duration := time.Since(start)
|
||||||
|
status := c.Writer.Status()
|
||||||
|
ip := c.ClientIP()
|
||||||
|
method := c.Request.Method
|
||||||
|
path := c.FullPath()
|
||||||
|
if path == "" {
|
||||||
|
path = c.Request.URL.Path
|
||||||
|
}
|
||||||
|
ua := c.Request.UserAgent()
|
||||||
|
|
||||||
|
var userID interface{}
|
||||||
|
if uidAny, ok := c.Get("user_id"); ok {
|
||||||
|
if v, ok := uidAny.(int64); ok {
|
||||||
|
userID = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _ = s.auth.DB.Exec(
|
||||||
|
`INSERT INTO access_logs (user_id, ip, method, path, status, duration_ms, user_agent) VALUES (?,?,?,?,?,?,?)`,
|
||||||
|
userID, ip, method, path, status, duration.Milliseconds(), ua,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IPBanEnforce blocks banned IPs (unless whitelisted).
|
||||||
|
func (s *Server) IPBanEnforce() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
ip := c.ClientIP()
|
||||||
|
|
||||||
|
var whitelisted, permanent int
|
||||||
|
var until sql.NullTime
|
||||||
|
err := s.auth.DB.QueryRow(`SELECT whitelisted, permanent, until FROM ip_bans WHERE ip = ?`, ip).Scan(&whitelisted, &permanent, &until)
|
||||||
|
if err != nil {
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.logError(c, "ip_ban_lookup_failed: "+err.Error(), "")
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if whitelisted == 1 {
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
banned := false
|
||||||
|
if permanent == 1 {
|
||||||
|
banned = true
|
||||||
|
} else if until.Valid && until.Time.After(time.Now()) {
|
||||||
|
banned = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if banned {
|
||||||
|
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "access denied"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// logError stores error logs (best-effort).
|
||||||
|
func (s *Server) logError(c *gin.Context, message, stack string) {
|
||||||
|
var userID interface{}
|
||||||
|
if uidAny, ok := c.Get("user_id"); ok {
|
||||||
|
if v, ok := uidAny.(int64); ok {
|
||||||
|
userID = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ip := c.ClientIP()
|
||||||
|
path := c.Request.URL.Path
|
||||||
|
_, _ = s.auth.DB.Exec(`INSERT INTO error_logs (user_id, ip, path, message, stack) VALUES (?,?,?,?,?)`, userID, ip, path, message, stack)
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/gorilla/sessions"
|
"github.com/gorilla/sessions"
|
||||||
@@ -43,8 +44,11 @@ func New(cfg *config.Config) *Server {
|
|||||||
auth: authSvc,
|
auth: authSvc,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Global middlewares: session user + template setup
|
// Global middlewares
|
||||||
s.router.Use(s.SessionUser())
|
s.router.Use(s.SessionUser())
|
||||||
|
// Enforce IP bans/whitelists and log access for every request
|
||||||
|
s.router.Use(s.IPBanEnforce())
|
||||||
|
s.router.Use(s.AccessLogger())
|
||||||
|
|
||||||
s.setupRoutes()
|
s.setupRoutes()
|
||||||
s.setupStaticFiles()
|
s.setupStaticFiles()
|
||||||
@@ -66,6 +70,9 @@ func (s *Server) Start() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Start background cleanup for access logs older than 7 days (daily)
|
||||||
|
go s.startAccessLogCleanup()
|
||||||
|
|
||||||
addr := fmt.Sprintf("%s:%d", s.config.Host, s.config.Port)
|
addr := fmt.Sprintf("%s:%d", s.config.Host, s.config.Port)
|
||||||
fmt.Printf("Starting Gobsidian server on %s\n", addr)
|
fmt.Printf("Starting Gobsidian server on %s\n", addr)
|
||||||
fmt.Printf("Notes directory: %s\n", s.config.NotesDir)
|
fmt.Printf("Notes directory: %s\n", s.config.NotesDir)
|
||||||
@@ -119,6 +126,9 @@ func (s *Server) setupRoutes() {
|
|||||||
editor.POST("/settings/notes_dir", h.PostNotesDirSettingsHandler)
|
editor.POST("/settings/notes_dir", h.PostNotesDirSettingsHandler)
|
||||||
editor.GET("/settings/file_extensions", h.GetFileExtensionsSettingsHandler)
|
editor.GET("/settings/file_extensions", h.GetFileExtensionsSettingsHandler)
|
||||||
editor.POST("/settings/file_extensions", h.PostFileExtensionsSettingsHandler)
|
editor.POST("/settings/file_extensions", h.PostFileExtensionsSettingsHandler)
|
||||||
|
// Security settings (IP ban thresholds/duration/permanent)
|
||||||
|
editor.GET("/settings/security", h.GetSecuritySettingsHandler)
|
||||||
|
editor.POST("/settings/security", h.PostSecuritySettingsHandler)
|
||||||
|
|
||||||
// Profile
|
// Profile
|
||||||
editor.GET("/profile", h.ProfilePage)
|
editor.GET("/profile", h.ProfilePage)
|
||||||
@@ -136,6 +146,14 @@ func (s *Server) setupRoutes() {
|
|||||||
// Admin CRUD API under /editor/admin
|
// Admin CRUD API under /editor/admin
|
||||||
admin := editor.Group("/admin", s.RequireAdmin())
|
admin := editor.Group("/admin", s.RequireAdmin())
|
||||||
{
|
{
|
||||||
|
// Logs page
|
||||||
|
admin.GET("/logs", h.AdminLogsPage)
|
||||||
|
// Manual clear old access logs (older than 7 days)
|
||||||
|
admin.POST("/logs/clear_access", h.AdminClearAccessLogs)
|
||||||
|
// Security: IP ban/whitelist actions
|
||||||
|
admin.POST("/ip/ban", h.AdminBanIP)
|
||||||
|
admin.POST("/ip/unban", h.AdminUnbanIP)
|
||||||
|
admin.POST("/ip/whitelist", h.AdminWhitelistIP)
|
||||||
admin.POST("/users", h.AdminCreateUser)
|
admin.POST("/users", h.AdminCreateUser)
|
||||||
admin.DELETE("/users/:id", h.AdminDeleteUser)
|
admin.DELETE("/users/:id", h.AdminDeleteUser)
|
||||||
admin.POST("/users/:id/active", h.AdminSetUserActive)
|
admin.POST("/users/:id/active", h.AdminSetUserActive)
|
||||||
@@ -232,3 +250,13 @@ func (s *Server) setupTemplates() {
|
|||||||
|
|
||||||
fmt.Printf("DEBUG: Templates loaded successfully\n")
|
fmt.Printf("DEBUG: Templates loaded successfully\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// startAccessLogCleanup deletes access logs older than 7 days once at startup and then daily.
|
||||||
|
func (s *Server) startAccessLogCleanup() {
|
||||||
|
// initial cleanup
|
||||||
|
_, _ = s.auth.DB.Exec(`DELETE FROM access_logs WHERE created_at < DATETIME('now', '-7 days')`)
|
||||||
|
ticker := time.NewTicker(24 * time.Hour)
|
||||||
|
for range ticker.C {
|
||||||
|
_, _ = s.auth.DB.Exec(`DELETE FROM access_logs WHERE created_at < DATETIME('now', '-7 days')`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
312
web/templates/admin_logs.html
Normal file
312
web/templates/admin_logs.html
Normal file
@@ -0,0 +1,312 @@
|
|||||||
|
{{define "admin_logs"}}
|
||||||
|
{{template "base" .}}
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "admin_logs_content"}}
|
||||||
|
<div class="max-w-7xl mx-auto p-6">
|
||||||
|
<div class="mb-6 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-3xl font-bold text-white">Admin Logs</h1>
|
||||||
|
<p class="text-gray-400">Recent access, errors, failed logins, and IP bans</p>
|
||||||
|
</div>
|
||||||
|
<a href="/editor/admin" class="btn-secondary">Back to Admin</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filters -->
|
||||||
|
<form method="GET" action="/editor/admin/logs" class="mb-6 bg-slate-800 border border-slate-700 rounded-lg p-4">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-5 gap-3 items-end">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm text-gray-300 mb-1">IP contains</label>
|
||||||
|
<input type="text" name="ip" value="{{.FilterIP}}" class="form-input" placeholder="e.g. 192.168" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm text-gray-300 mb-1">Last hours</label>
|
||||||
|
<input type="number" min="0" step="1" name="hours" value="{{.FilterHours}}" class="form-input" placeholder="e.g. 24" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm text-gray-300 mb-1">Limit</label>
|
||||||
|
<input type="number" min="1" max="500" step="1" name="limit" value="{{.FilterLimit}}" class="form-input" placeholder="100" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm text-gray-300 mb-1">Bans filter</label>
|
||||||
|
<select name="bans" class="form-input">
|
||||||
|
<option value="all" {{if eq .FilterBans "all"}}selected{{end}}>All</option>
|
||||||
|
<option value="active" {{if eq .FilterBans "active"}}selected{{end}}>Active only</option>
|
||||||
|
<option value="perma" {{if or (eq .FilterBans "perma") (eq .FilterBans "permanent") (eq .FilterBans "perm")}}selected{{end}}>Permanent</option>
|
||||||
|
<option value="whitelist" {{if or (eq .FilterBans "whitelist") (eq .FilterBans "whitelisted")}}selected{{end}}>Whitelisted</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="flex space-x-2">
|
||||||
|
<button type="submit" class="btn-primary">Apply</button>
|
||||||
|
<a href="/editor/admin/logs" class="btn-secondary">Reset</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Admin Actions: Manual Ban + Clear Logs -->
|
||||||
|
<div class="mb-6 grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||||
|
<div class="bg-slate-800 border border-slate-700 rounded-lg p-4 lg:col-span-2">
|
||||||
|
<div class="text-white font-semibold mb-2">Manual Ban/Whitelist</div>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-6 gap-2">
|
||||||
|
<input id="ban-ip" type="text" class="form-input" placeholder="IP e.g. 203.0.113.5" />
|
||||||
|
<input id="ban-reason" type="text" class="form-input" placeholder="Reason (optional)" />
|
||||||
|
<input id="ban-hours" type="number" min="1" step="1" class="form-input" placeholder="Hours (temp)" />
|
||||||
|
<label class="flex items-center text-gray-300"><input id="ban-permanent" type="checkbox" class="mr-2">Permanent</label>
|
||||||
|
<button id="ban-submit" class="btn-primary">Ban</button>
|
||||||
|
<button id="whitelist-submit" class="btn-secondary">Whitelist</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-slate-800 border border-slate-700 rounded-lg p-4 flex items-center justify-between">
|
||||||
|
<div class="flex-1 mr-3">
|
||||||
|
<div class="text-white font-semibold">Clear Access Logs</div>
|
||||||
|
<div class="text-sm text-gray-400">Delete entries older than N days (0 = delete all)</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<input id="clear-days" type="number" min="0" step="1" value="7" class="form-input w-24" title="Days threshold (0 deletes all)" />
|
||||||
|
<button id="clear-access" class="btn-danger">Clear</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tabs -->
|
||||||
|
<div class="mb-4 border-b border-slate-700">
|
||||||
|
<nav class="flex flex-wrap gap-2" role="tablist">
|
||||||
|
<button class="tab-btn active" data-tab="access" aria-selected="true"><i class="fas fa-list mr-2"></i>Access</button>
|
||||||
|
<button class="tab-btn" data-tab="errors"><i class="fas fa-bug mr-2"></i>Errors</button>
|
||||||
|
<button class="tab-btn" data-tab="failed"><i class="fas fa-user-lock mr-2"></i>Failed Logins</button>
|
||||||
|
<button class="tab-btn" data-tab="bans"><i class="fas fa-shield-alt mr-2"></i>IP Bans</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tab-panels space-y-6">
|
||||||
|
<!-- Access Logs Panel -->
|
||||||
|
<div class="tab-panel" data-tab-panel="access">
|
||||||
|
<div class="bg-gray-800 rounded-lg p-4">
|
||||||
|
<h2 class="text-xl font-semibold text-white mb-3"><i class="fas fa-list mr-2"></i>Access Logs</h2>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="min-w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="text-gray-300 border-b border-gray-700">
|
||||||
|
<th class="text-left py-2 pr-4">Time</th>
|
||||||
|
<th class="text-left py-2 pr-4">IP</th>
|
||||||
|
<th class="text-left py-2 pr-4">Method</th>
|
||||||
|
<th class="text-left py-2 pr-4">Path</th>
|
||||||
|
<th class="text-left py-2 pr-4">Status</th>
|
||||||
|
<th class="text-left py-2 pr-4">Duration(ms)</th>
|
||||||
|
<th class="text-left py-2 pr-4">User</th>
|
||||||
|
<th class="text-left py-2 pr-4">UA</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .AccessLogs}}
|
||||||
|
<tr class="border-b border-gray-700 text-gray-200">
|
||||||
|
<td class="py-1 pr-4">{{formatTime .CreatedAt}}</td>
|
||||||
|
<td class="py-1 pr-4">{{.IP}}</td>
|
||||||
|
<td class="py-1 pr-4">{{.Method}}</td>
|
||||||
|
<td class="py-1 pr-4">{{.Path}}</td>
|
||||||
|
<td class="py-1 pr-4">{{.Status}}</td>
|
||||||
|
<td class="py-1 pr-4">{{.Duration}}</td>
|
||||||
|
<td class="py-1 pr-4">{{if .Username.Valid}}{{.Username.String}}{{else}}-{{end}}</td>
|
||||||
|
<td class="py-1 pr-4 truncate max-w-[16rem]" title="{{.UserAgent}}">{{.UserAgent}}</td>
|
||||||
|
</tr>
|
||||||
|
{{else}}
|
||||||
|
<tr><td class="py-2 text-gray-400" colspan="8">No access logs</td></tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error Logs Panel -->
|
||||||
|
<div class="tab-panel hidden" data-tab-panel="errors">
|
||||||
|
<div class="bg-gray-800 rounded-lg p-4">
|
||||||
|
<h2 class="text-xl font-semibold text-white mb-3"><i class="fas fa-bug mr-2"></i>Error Logs</h2>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="min-w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="text-gray-300 border-b border-gray-700">
|
||||||
|
<th class="text-left py-2 pr-4">Time</th>
|
||||||
|
<th class="text-left py-2 pr-4">IP</th>
|
||||||
|
<th class="text-left py-2 pr-4">Path</th>
|
||||||
|
<th class="text-left py-2 pr-4">Message</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .ErrorLogs}}
|
||||||
|
<tr class="border-b border-gray-700 text-gray-200">
|
||||||
|
<td class="py-1 pr-4">{{formatTime .CreatedAt}}</td>
|
||||||
|
<td class="py-1 pr-4">{{.IP.String}}</td>
|
||||||
|
<td class="py-1 pr-4">{{.Path.String}}</td>
|
||||||
|
<td class="py-1 pr-4 whitespace-pre-line">{{.Message}}</td>
|
||||||
|
</tr>
|
||||||
|
{{else}}
|
||||||
|
<tr><td class="py-2 text-gray-400" colspan="4">No error logs</td></tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Failed Logins Panel -->
|
||||||
|
<div class="tab-panel hidden" data-tab-panel="failed">
|
||||||
|
<div class="bg-gray-800 rounded-lg p-4">
|
||||||
|
<h2 class="text-xl font-semibold text-white mb-3"><i class="fas fa-user-lock mr-2"></i>Failed Logins</h2>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="min-w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="text-gray-300 border-b border-gray-700">
|
||||||
|
<th class="text-left py-2 pr-4">Time</th>
|
||||||
|
<th class="text-left py-2 pr-4">IP</th>
|
||||||
|
<th class="text-left py-2 pr-4">Username</th>
|
||||||
|
<th class="text-left py-2 pr-4">Type</th>
|
||||||
|
<th class="text-left py-2 pr-4">UserID</th>
|
||||||
|
<th class="text-left py-2 pr-4">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .FailedLogins}}
|
||||||
|
<tr class="border-b border-gray-700 text-gray-200">
|
||||||
|
<td class="py-1 pr-4">{{formatTime .CreatedAt}}</td>
|
||||||
|
<td class="py-1 pr-4">{{.IP}}</td>
|
||||||
|
<td class="py-1 pr-4">{{.Username.String}}</td>
|
||||||
|
<td class="py-1 pr-4">{{.Type}}</td>
|
||||||
|
<td class="py-1 pr-4">{{if .UserID.Valid}}{{.UserID.Int64}}{{else}}-{{end}}</td>
|
||||||
|
<td class="py-1 pr-4 space-x-2">
|
||||||
|
<button class="btn-secondary btn-ban-24" data-ip="{{.IP}}" title="Temp ban 24h">Ban 24h</button>
|
||||||
|
<button class="btn-danger btn-ban-perma" data-ip="{{.IP}}" title="Permanent ban">Perma</button>
|
||||||
|
<button class="btn-secondary btn-whitelist" data-ip="{{.IP}}" title="Whitelist">Whitelist</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{else}}
|
||||||
|
<tr><td class="py-2 text-gray-400" colspan="6">No failed logins</td></tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- IP Bans Panel -->
|
||||||
|
<div class="tab-panel hidden" data-tab-panel="bans">
|
||||||
|
<div class="bg-gray-800 rounded-lg p-4">
|
||||||
|
<h2 class="text-xl font-semibold text-white mb-3"><i class="fas fa-shield-alt mr-2"></i>IP Bans</h2>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="min-w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="text-gray-300 border-b border-gray-700">
|
||||||
|
<th class="text-left py-2 pr-4">IP</th>
|
||||||
|
<th class="text-left py-2 pr-4">Reason</th>
|
||||||
|
<th class="text-left py-2 pr-4">Until</th>
|
||||||
|
<th class="text-left py-2 pr-4">Permanent</th>
|
||||||
|
<th class="text-left py-2 pr-4">Whitelisted</th>
|
||||||
|
<th class="text-left py-2 pr-4">Updated</th>
|
||||||
|
<th class="text-left py-2 pr-4">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .IPBans}}
|
||||||
|
<tr class="border-b border-gray-700 text-gray-200">
|
||||||
|
<td class="py-1 pr-4">{{.IP}}</td>
|
||||||
|
<td class="py-1 pr-4">{{if .Reason.Valid}}{{.Reason.String}}{{else}}-{{end}}</td>
|
||||||
|
<td class="py-1 pr-4">{{if .Until.Valid}}{{formatTime .Until.Time}}{{else}}-{{end}}</td>
|
||||||
|
<td class="py-1 pr-4">{{if eq .Permanent 1}}Yes{{else}}No{{end}}</td>
|
||||||
|
<td class="py-1 pr-4">{{if eq .Whitelisted 1}}Yes{{else}}No{{end}}</td>
|
||||||
|
<td class="py-1 pr-4">{{formatTime .UpdatedAt}}</td>
|
||||||
|
<td class="py-1 pr-4 space-x-2">
|
||||||
|
<button class="btn-danger btn-unban" data-ip="{{.IP}}">Unban</button>
|
||||||
|
{{if eq .Whitelisted 1}}
|
||||||
|
<button class="btn-secondary btn-whitelist-off" data-ip="{{.IP}}">Unwhitelist</button>
|
||||||
|
{{else}}
|
||||||
|
<button class="btn-secondary btn-whitelist-on" data-ip="{{.IP}}">Whitelist</button>
|
||||||
|
{{end}}
|
||||||
|
{{if ne .Permanent 1}}
|
||||||
|
<button class="btn-secondary btn-permanent" data-ip="{{.IP}}">Make permanent</button>
|
||||||
|
{{end}}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{else}}
|
||||||
|
<tr><td class="py-2 text-gray-400" colspan="7">No bans</td></tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "admin_logs_scripts"}}
|
||||||
|
<script>
|
||||||
|
function getCSRF() {
|
||||||
|
const m = document.cookie.match(/(?:^|; )csrf_token=([^;]+)/);
|
||||||
|
return m && m[1] ? decodeURIComponent(m[1]) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postForm(url, data) {
|
||||||
|
const csrf = getCSRF();
|
||||||
|
const form = new URLSearchParams();
|
||||||
|
Object.entries(data || {}).forEach(([k, v]) => form.append(k, v));
|
||||||
|
const res = await fetch(url, { method: 'POST', headers: Object.assign({'Content-Type': 'application/x-www-form-urlencoded'}, csrf ? {'X-CSRF-Token': csrf} : {}), body: form.toString() });
|
||||||
|
if (!res.ok) {
|
||||||
|
const j = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(j.error || res.statusText);
|
||||||
|
}
|
||||||
|
return res.json().catch(() => ({}));
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('ban-submit')?.addEventListener('click', async () => {
|
||||||
|
const ip = document.getElementById('ban-ip').value.trim();
|
||||||
|
const reason = document.getElementById('ban-reason').value.trim();
|
||||||
|
const hours = document.getElementById('ban-hours').value.trim();
|
||||||
|
const permanent = document.getElementById('ban-permanent').checked ? '1' : '0';
|
||||||
|
if (!ip) { showNotification('IP is required', 'error'); return; }
|
||||||
|
try { await postForm('/editor/admin/ip/ban', { ip, reason, hours, permanent }); location.reload(); } catch (e) { showNotification('Ban failed: ' + e.message, 'error'); }
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('whitelist-submit')?.addEventListener('click', async () => {
|
||||||
|
const ip = document.getElementById('ban-ip').value.trim();
|
||||||
|
if (!ip) { showNotification('IP is required', 'error'); return; }
|
||||||
|
try { await postForm('/editor/admin/ip/whitelist', { ip, value: '1' }); location.reload(); } catch (e) { showNotification('Whitelist failed: ' + e.message, 'error'); }
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('clear-access')?.addEventListener('click', async () => {
|
||||||
|
const days = (document.getElementById('clear-days')?.value || '7').trim();
|
||||||
|
const d = parseInt(days, 10);
|
||||||
|
if (isNaN(d) || d < 0) { showNotification('Days must be a non-negative number', 'error'); return; }
|
||||||
|
const msg = d === 0 ? 'This will delete ALL access logs. Proceed?' : `Delete access logs older than ${d} days?`;
|
||||||
|
if (!confirm(msg)) return;
|
||||||
|
try { await postForm('/editor/admin/logs/clear_access', { days: String(d) }); location.reload(); } catch (e) { showNotification('Clear failed: ' + e.message, 'error'); }
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('click', async (e) => {
|
||||||
|
const t = e.target.closest('button'); if (!t) return;
|
||||||
|
const ip = t.getAttribute('data-ip');
|
||||||
|
try {
|
||||||
|
if (t.classList.contains('btn-ban-24')) { await postForm('/editor/admin/ip/ban', { ip, hours: '24', permanent: '0' }); location.reload(); }
|
||||||
|
else if (t.classList.contains('btn-ban-perma')) { await postForm('/editor/admin/ip/ban', { ip, permanent: '1' }); location.reload(); }
|
||||||
|
else if (t.classList.contains('btn-whitelist')) { await postForm('/editor/admin/ip/whitelist', { ip, value: '1' }); location.reload(); }
|
||||||
|
else if (t.classList.contains('btn-unban')) { await postForm('/editor/admin/ip/unban', { ip }); location.reload(); }
|
||||||
|
else if (t.classList.contains('btn-whitelist-on')) { await postForm('/editor/admin/ip/whitelist', { ip, value: '1' }); location.reload(); }
|
||||||
|
else if (t.classList.contains('btn-whitelist-off')) { await postForm('/editor/admin/ip/whitelist', { ip, value: '0' }); location.reload(); }
|
||||||
|
else if (t.classList.contains('btn-permanent')) { await postForm('/editor/admin/ip/ban', { ip, permanent: '1' }); location.reload(); }
|
||||||
|
} catch (err) {
|
||||||
|
showNotification('Action failed: ' + err.message, 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tabs behavior
|
||||||
|
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
|
||||||
|
btn.classList.add('active');
|
||||||
|
const key = btn.getAttribute('data-tab');
|
||||||
|
document.querySelectorAll('.tab-panel').forEach(p => {
|
||||||
|
if (p.getAttribute('data-tab-panel') === key) p.classList.remove('hidden'); else p.classList.add('hidden');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{{end}}
|
||||||
@@ -259,6 +259,7 @@
|
|||||||
<body class="bg-slate-900 text-gray-300 min-h-screen">
|
<body class="bg-slate-900 text-gray-300 min-h-screen">
|
||||||
<div class="flex h-screen">
|
<div class="flex h-screen">
|
||||||
<!-- Sidebar -->
|
<!-- Sidebar -->
|
||||||
|
{{if not .NoSidebar}}
|
||||||
<div id="sidebar" class="w-80 bg-slate-800 border-r border-gray-700 flex flex-col">
|
<div id="sidebar" class="w-80 bg-slate-800 border-r border-gray-700 flex flex-col">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="p-4 border-b border-gray-700">
|
<div class="p-4 border-b border-gray-700">
|
||||||
@@ -320,6 +321,7 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
<!-- Main Content -->
|
<!-- Main Content -->
|
||||||
<div class="flex-1 flex flex-col overflow-hidden">
|
<div class="flex-1 flex flex-col overflow-hidden">
|
||||||
@@ -357,6 +359,8 @@
|
|||||||
{{template "settings_content" .}}
|
{{template "settings_content" .}}
|
||||||
{{else if eq .Page "admin"}}
|
{{else if eq .Page "admin"}}
|
||||||
{{template "admin_content" .}}
|
{{template "admin_content" .}}
|
||||||
|
{{else if eq .Page "admin_logs"}}
|
||||||
|
{{template "admin_logs_content" .}}
|
||||||
{{else if eq .Page "profile"}}
|
{{else if eq .Page "profile"}}
|
||||||
{{template "profile_content" .}}
|
{{template "profile_content" .}}
|
||||||
{{else if eq .Page "error"}}
|
{{else if eq .Page "error"}}
|
||||||
@@ -726,6 +730,8 @@
|
|||||||
{{template "settings_scripts" .}}
|
{{template "settings_scripts" .}}
|
||||||
{{else if eq .Page "admin"}}
|
{{else if eq .Page "admin"}}
|
||||||
{{template "admin_scripts" .}}
|
{{template "admin_scripts" .}}
|
||||||
|
{{else if eq .Page "admin_logs"}}
|
||||||
|
{{template "admin_logs_scripts" .}}
|
||||||
{{else if eq .Page "profile"}}
|
{{else if eq .Page "profile"}}
|
||||||
{{template "profile_scripts" .}}
|
{{template "profile_scripts" .}}
|
||||||
{{else if eq .Page "error"}}
|
{{else if eq .Page "error"}}
|
||||||
|
|||||||
@@ -12,6 +12,22 @@
|
|||||||
|
|
||||||
<!-- Settings Sections -->
|
<!-- Settings Sections -->
|
||||||
<div class="space-y-8">
|
<div class="space-y-8">
|
||||||
|
<!-- Quick Actions -->
|
||||||
|
<div class="bg-gray-800 rounded-lg p-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-xl font-semibold text-white mb-1">
|
||||||
|
<i class="fas fa-tools mr-2"></i>Admin Tools
|
||||||
|
</h2>
|
||||||
|
<p class="text-gray-400">Access logs and security controls</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<a href="/editor/admin/logs" target="_blank" class="btn-secondary inline-flex items-center">
|
||||||
|
<i class="fas fa-list mr-2"></i>View Logs
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<!-- Image Storage Settings -->
|
<!-- Image Storage Settings -->
|
||||||
<div class="bg-gray-800 rounded-lg p-6">
|
<div class="bg-gray-800 rounded-lg p-6">
|
||||||
<h2 class="text-xl font-semibold text-white mb-4">
|
<h2 class="text-xl font-semibold text-white mb-4">
|
||||||
@@ -170,6 +186,58 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Security Settings -->
|
||||||
|
<div class="bg-gray-800 rounded-lg p-6">
|
||||||
|
<h2 class="text-xl font-semibold text-white mb-4">
|
||||||
|
<i class="fas fa-shield-alt mr-2"></i>Security (IP Ban & Thresholds)
|
||||||
|
</h2>
|
||||||
|
<p class="text-gray-400 mb-6">Configure failed login thresholds, window, and automatic ban behavior</p>
|
||||||
|
|
||||||
|
<form id="security-settings-form" class="space-y-6">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label for="pwd_failures_threshold" class="block text-sm font-medium text-gray-300 mb-2">Password Failures Threshold</label>
|
||||||
|
<input type="number" id="pwd_failures_threshold" name="pwd_failures_threshold" min="1"
|
||||||
|
class="w-full bg-gray-700 border border-gray-600 rounded-lg px-4 py-2 text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
placeholder="e.g., 5">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="mfa_failures_threshold" class="block text-sm font-medium text-gray-300 mb-2">MFA Failures Threshold</label>
|
||||||
|
<input type="number" id="mfa_failures_threshold" name="mfa_failures_threshold" min="1"
|
||||||
|
class="w-full bg-gray-700 border border-gray-600 rounded-lg px-4 py-2 text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
placeholder="e.g., 10">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label for="failures_window_minutes" class="block text-sm font-medium text-gray-300 mb-2">Failures Window (minutes)</label>
|
||||||
|
<input type="number" id="failures_window_minutes" name="failures_window_minutes" min="1"
|
||||||
|
class="w-full bg-gray-700 border border-gray-600 rounded-lg px-4 py-2 text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
placeholder="e.g., 30">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="auto_ban_duration_hours" class="block text-sm font-medium text-gray-300 mb-2">Auto-ban Duration (hours)</label>
|
||||||
|
<input type="number" id="auto_ban_duration_hours" name="auto_ban_duration_hours" min="1"
|
||||||
|
class="w-full bg-gray-700 border border-gray-600 rounded-lg px-4 py-2 text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
placeholder="e.g., 12">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="flex items-center space-x-2">
|
||||||
|
<input type="checkbox" id="auto_ban_permanent" name="auto_ban_permanent" class="h-4 w-4 text-blue-600 rounded border-gray-600 bg-gray-700">
|
||||||
|
<span class="text-sm text-gray-300">Make auto-bans permanent</span>
|
||||||
|
</label>
|
||||||
|
<p class="text-xs text-gray-500 mt-1">If enabled, IPs exceeding thresholds are permanently banned instead of temporary bans.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<button type="submit" class="btn-primary">
|
||||||
|
<i class="fas fa-save mr-2"></i>Save Security Settings
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
@@ -213,6 +281,18 @@
|
|||||||
document.getElementById('show_files_in_folder').checked = !!data.show_files_in_folder;
|
document.getElementById('show_files_in_folder').checked = !!data.show_files_in_folder;
|
||||||
})
|
})
|
||||||
.catch(error => console.error('Error loading file extensions settings:', error));
|
.catch(error => console.error('Error loading file extensions settings:', error));
|
||||||
|
|
||||||
|
// Load security settings
|
||||||
|
fetch('/editor/settings/security')
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
document.getElementById('pwd_failures_threshold').value = data.pwd_failures_threshold ?? '';
|
||||||
|
document.getElementById('mfa_failures_threshold').value = data.mfa_failures_threshold ?? '';
|
||||||
|
document.getElementById('failures_window_minutes').value = data.failures_window_minutes ?? '';
|
||||||
|
document.getElementById('auto_ban_duration_hours').value = data.auto_ban_duration_hours ?? '';
|
||||||
|
document.getElementById('auto_ban_permanent').checked = !!data.auto_ban_permanent;
|
||||||
|
})
|
||||||
|
.catch(error => console.error('Error loading security settings:', error));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Toggle storage mode options
|
// Toggle storage mode options
|
||||||
@@ -232,6 +312,34 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Security settings form
|
||||||
|
document.getElementById('security-settings-form').addEventListener('submit', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const formData = new FormData(this);
|
||||||
|
// Normalize checkbox to boolean string
|
||||||
|
formData.set('auto_ban_permanent', document.getElementById('auto_ban_permanent').checked ? 'true' : 'false');
|
||||||
|
|
||||||
|
const csrf = (document.cookie.match(/(?:^|; )csrf_token=([^;]+)/)||[])[1] ? decodeURIComponent((document.cookie.match(/(?:^|; )csrf_token=([^;]+)/)||[])[1]) : '';
|
||||||
|
|
||||||
|
fetch('/editor/settings/security', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: csrf ? { 'X-CSRF-Token': csrf } : {},
|
||||||
|
body: formData
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
showNotification('Security settings saved successfully', 'success');
|
||||||
|
} else {
|
||||||
|
throw new Error(data.error || 'Failed to save settings');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
showNotification('Error: ' + error.message, 'error');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Image storage form
|
// Image storage form
|
||||||
document.getElementById('image-storage-form').addEventListener('submit', function(e) {
|
document.getElementById('image-storage-form').addEventListener('submit', function(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|||||||
Reference in New Issue
Block a user