feat: runtime config, Matomo analytics, chat accounts, DM history/search, and Prometheus retention
This commit is contained in:
1 parent
06ab5b4655
commit
121fdbacdc
27 files changed
+1841
-161
No files matched your search
@@ -13,6 +13,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"chat/src/auth"
|
||||
@@ -197,6 +198,7 @@ type Dependencies struct {
|
||||
Redis db.RedisClient
|
||||
Hub *ws.Hub
|
||||
SessionStore *auth.SessionStore
|
||||
UserSessions *auth.UserSessionStore
|
||||
DBDriver string
|
||||
}
|
||||
|
||||
@@ -333,6 +335,251 @@ func HandleAdminSession(deps *Dependencies) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// userSessionCookieName is the cookie for registered chat users.
|
||||
const userSessionCookieName = "linearvoid_user_session"
|
||||
|
||||
// UserCredentialsPayload is used by registration and login.
|
||||
type UserCredentialsPayload struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
// validateUsername checks the username length constraints.
|
||||
func validateUsername(username string) error {
|
||||
if len(username) < 3 || len(username) > 32 {
|
||||
return fmt.Errorf("username must be 3-32 characters")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validatePassword checks the password length constraints.
|
||||
func validatePassword(password string) error {
|
||||
if len(password) < 6 {
|
||||
return fmt.Errorf("password must be at least 6 characters")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// startUserSession issues a user session cookie for the given username.
|
||||
func startUserSession(deps *Dependencies, w http.ResponseWriter, r *http.Request, username string) {
|
||||
token, err := auth.GenerateToken()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
expiresAt := time.Now().Add(config.UserSessionTTL())
|
||||
deps.UserSessions.Add(token, username, expiresAt)
|
||||
auth.SetCookie(w, r, userSessionCookieName, token, expiresAt)
|
||||
}
|
||||
|
||||
// HandleUserRegister creates a new chat user and signs them in.
|
||||
func HandleUserRegister(deps *Dependencies) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var payload UserCredentialsPayload
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
http.Error(w, "invalid body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
username := strings.TrimSpace(payload.Username)
|
||||
if err := validateUsername(username); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := validatePassword(payload.Password); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var count int64
|
||||
deps.DB.Model(&db.User{}).Where("username = ?", username).Count(&count)
|
||||
if count > 0 {
|
||||
http.Error(w, "username already taken", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(payload.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
http.Error(w, "failed to hash password", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
user := db.User{Username: username, PasswordHash: string(hash)}
|
||||
if err := deps.DB.Create(&user).Error; err != nil {
|
||||
http.Error(w, "failed to create user", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
startUserSession(deps, w, r, user.Username)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{"username": user.Username})
|
||||
}
|
||||
}
|
||||
|
||||
// HandleUserLogin verifies credentials and signs the user in.
|
||||
func HandleUserLogin(deps *Dependencies) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var payload UserCredentialsPayload
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
http.Error(w, "invalid body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
username := strings.TrimSpace(payload.Username)
|
||||
|
||||
var user db.User
|
||||
if err := deps.DB.Where("username = ?", username).First(&user).Error; err != nil {
|
||||
http.Error(w, "invalid credentials", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(payload.Password)); err != nil {
|
||||
http.Error(w, "invalid credentials", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
startUserSession(deps, w, r, user.Username)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{"username": user.Username})
|
||||
}
|
||||
}
|
||||
|
||||
// HandleUserSession returns the current user session state.
|
||||
func HandleUserSession(deps *Dependencies) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
token, _ := auth.GetTokenFromCookie(r, userSessionCookieName)
|
||||
username, ok := deps.UserSessions.Username(token)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"authenticated": ok,
|
||||
"username": username,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// HandleUserLogout clears the user session.
|
||||
func HandleUserLogout(deps *Dependencies) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if token, ok := auth.GetTokenFromCookie(r, userSessionCookieName); ok {
|
||||
deps.UserSessions.Remove(token)
|
||||
}
|
||||
auth.ClearCookie(w, r, userSessionCookieName)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]bool{"ok": true})
|
||||
}
|
||||
}
|
||||
|
||||
// currentUsername returns the authenticated username from the user session cookie.
|
||||
func currentUsername(deps *Dependencies, r *http.Request) (string, bool) {
|
||||
token, ok := auth.GetTokenFromCookie(r, userSessionCookieName)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
return deps.UserSessions.Username(token)
|
||||
}
|
||||
|
||||
// dmRoomFor returns the canonical DM room for two users (sorted, prefixed).
|
||||
func dmRoomFor(a, b string) string {
|
||||
if a > b {
|
||||
a, b = b, a
|
||||
}
|
||||
return "dm:" + a + "-" + b
|
||||
}
|
||||
|
||||
// HandleUserDMHistory returns paginated private message history with a target user.
|
||||
func HandleUserDMHistory(deps *Dependencies) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
username, ok := currentUsername(deps, r)
|
||||
if !ok {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
target := strings.TrimSpace(r.URL.Query().Get("with"))
|
||||
if target == "" {
|
||||
http.Error(w, "missing target user", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
limit := 50
|
||||
if raw := r.URL.Query().Get("limit"); raw != "" {
|
||||
if n, err := strconv.Atoi(raw); err == nil && n > 0 && n <= 200 {
|
||||
limit = n
|
||||
}
|
||||
}
|
||||
room := dmRoomFor(username, target)
|
||||
|
||||
query := deps.DB.Where("room = ?", room)
|
||||
if beforeRaw := r.URL.Query().Get("before"); beforeRaw != "" {
|
||||
if t, err := time.Parse(time.RFC3339, beforeRaw); err == nil {
|
||||
query = query.Where("timestamp < ?", t)
|
||||
}
|
||||
}
|
||||
var messages []db.Message
|
||||
if err := query.Order("timestamp desc").Limit(limit).Find(&messages).Error; err != nil {
|
||||
http.Error(w, "failed to load history", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
for i, j := 0, len(messages)-1; i < j; i, j = i+1, j-1 {
|
||||
messages[i], messages[j] = messages[j], messages[i]
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(messages)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleUserDMSearch searches private messages with a target user.
|
||||
func HandleUserDMSearch(deps *Dependencies) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
username, ok := currentUsername(deps, r)
|
||||
if !ok {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
target := strings.TrimSpace(r.URL.Query().Get("with"))
|
||||
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||
if target == "" || q == "" {
|
||||
http.Error(w, "missing target or query", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
limit := 50
|
||||
if raw := r.URL.Query().Get("limit"); raw != "" {
|
||||
if n, err := strconv.Atoi(raw); err == nil && n > 0 && n <= 200 {
|
||||
limit = n
|
||||
}
|
||||
}
|
||||
room := dmRoomFor(username, target)
|
||||
var messages []db.Message
|
||||
if err := deps.DB.Where("room = ? AND LOWER(text) LIKE ?", room, "%"+strings.ToLower(q)+"%").
|
||||
Order("timestamp desc").Limit(limit).Find(&messages).Error; err != nil {
|
||||
http.Error(w, "failed to search", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
for i, j := 0, len(messages)-1; i < j; i, j = i+1, j-1 {
|
||||
messages[i], messages[j] = messages[j], messages[i]
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(messages)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleAdminOverview returns the admin dashboard overview.
|
||||
func HandleAdminOverview(deps *Dependencies) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -757,29 +1004,83 @@ func HandleAdminSystemSummary(deps *Dependencies) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// HandlePublicMatomo exposes the Matomo analytics settings for the viewer frontend.
|
||||
func HandlePublicMatomo() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"matomoUrl": config.MatomoURL(),
|
||||
"matomoSiteId": config.MatomoSiteID(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// adminConfigResponse builds the admin configuration payload from runtime config
|
||||
// and read-only runtime facts.
|
||||
func adminConfigResponse(deps *Dependencies) map[string]any {
|
||||
resp := config.RuntimeConfigSnapshot()
|
||||
resp["rateLimitMax"] = config.RateLimitMax()
|
||||
resp["rateLimitWindowSec"] = int(config.RateLimitWindow().Seconds())
|
||||
resp["databaseDriver"] = deps.DBDriver
|
||||
resp["redisEnabled"] = deps.Redis != nil
|
||||
resp["sessionTTLHours"] = int(config.AdminSessionTTL().Hours())
|
||||
return resp
|
||||
}
|
||||
|
||||
// HandleAdminConfig returns/updates runtime configuration.
|
||||
func HandleAdminConfig(deps *Dependencies) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"rateLimitMax": config.RateLimitMax(),
|
||||
"rateLimitWindowSec": int(config.RateLimitWindow().Seconds()),
|
||||
"databaseDriver": deps.DBDriver,
|
||||
"redisEnabled": deps.Redis != nil,
|
||||
"sessionTTLHours": int(config.AdminSessionTTL().Hours()),
|
||||
})
|
||||
json.NewEncoder(w).Encode(adminConfigResponse(deps))
|
||||
case http.MethodPut:
|
||||
var payload map[string]any
|
||||
// Editable runtime fields (Matomo + HLS). Rate limit fields remain
|
||||
// env-driven and are intentionally read-only here.
|
||||
var payload struct {
|
||||
MatomoURL *string `json:"matomoUrl"`
|
||||
MatomoSiteID *string `json:"matomoSiteId"`
|
||||
HLSSegmentDuration *int `json:"hlsSegmentDuration"`
|
||||
HLSWindowSize *int `json:"hlsWindowSize"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
http.Error(w, "invalid body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// Note: rate limit config is read-only at runtime in this refactored version.
|
||||
// To change, restart with env vars.
|
||||
if payload.MatomoURL != nil {
|
||||
if err := config.SetMatomoURL(*payload.MatomoURL); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
if payload.MatomoSiteID != nil {
|
||||
if err := config.SetMatomoSiteID(*payload.MatomoSiteID); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
if payload.HLSSegmentDuration != nil {
|
||||
if err := config.SetHLSSegmentDuration(*payload.HLSSegmentDuration); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
if payload.HLSWindowSize != nil {
|
||||
if err := config.SetHLSWindowSize(*payload.HLSWindowSize); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := config.SaveRuntimeConfig(); err != nil {
|
||||
http.Error(w, fmt.Sprintf("failed to save config: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||
json.NewEncoder(w).Encode(adminConfigResponse(deps))
|
||||
default:
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user