Files
Beszel/internal/hub/settings/api.go
T
Tomas Dvorak 8011d487f1 Add public monitoring features and CI updates
- Add status pages, incidents, badges, maintenance, bulk ops, and metrics
- Add Docker packaging, env example, and frontend routes
- Refresh GitHub workflows and project metadata
2026-04-27 11:10:18 +02:00

304 lines
9.8 KiB
Go

package settings
import (
"encoding/json"
"fmt"
"net/http"
"net/mail"
"os"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/mailer"
)
// APIHandler handles settings API requests
type APIHandler struct {
app core.App
}
// NewAPIHandler creates a new settings API handler
func NewAPIHandler(app core.App) *APIHandler {
return &APIHandler{app: app}
}
// RegisterRoutes registers settings API routes
func (h *APIHandler) RegisterRoutes(se *core.ServeEvent) {
api := se.Router.Group("/api/beszel/settings")
api.Bind(apis.RequireAuth())
api.GET("/", h.getSettings)
api.PATCH("/", h.updateSettings)
api.GET("/instance", h.getInstanceSettings)
api.POST("/test-notification", h.testNotification)
}
// UserSettings represents user-specific settings
type UserSettings struct {
// General
Timezone string `json:"timezone"`
DateFormat string `json:"dateFormat"`
Language string `json:"language"`
Theme string `json:"theme"` // light, dark, auto
// Notifications
EmailNotifications bool `json:"emailNotifications"`
WebhookURLs []string `json:"webhookUrls"`
QuietHoursStart string `json:"quietHoursStart"` // HH:MM format
QuietHoursEnd string `json:"quietHoursEnd"`
QuietHoursEnabled bool `json:"quietHoursEnabled"`
// Domain Settings (for self-hosted)
CustomDomain string `json:"customDomain"`
UseCustomDomain bool `json:"useCustomDomain"`
EmailFrom string `json:"emailFrom"`
EmailFromName string `json:"emailFromName"`
// Monitoring Defaults
DefaultMonitorInterval int `json:"defaultMonitorInterval"`
DefaultRetries int `json:"defaultRetries"`
AutoResolveIncidents bool `json:"autoResolveIncidents"`
// PageSpeed Settings
PageSpeedAPIKey string `json:"pageSpeedApiKey,omitempty"`
PageSpeedEnabled bool `json:"pageSpeedEnabled"`
PageSpeedStrategy string `json:"pageSpeedStrategy"` // mobile, desktop, both
// Display
ShowUptimeGraphs bool `json:"showUptimeGraphs"`
CompactView bool `json:"compactView"`
ShowIncidentHistory bool `json:"showIncidentHistory"`
}
// InstanceSettings represents admin-only instance settings
type InstanceSettings struct {
// Instance Info
InstanceName string `json:"instanceName"`
InstanceDescription string `json:"instanceDescription"`
PublicURL string `json:"publicUrl"`
// Features
RegistrationEnabled bool `json:"registrationEnabled"`
StatusPagesEnabled bool `json:"statusPagesEnabled"`
BadgesEnabled bool `json:"badgesEnabled"`
PageSpeedEnabled bool `json:"pageSpeedEnabled"`
SubdomainDiscovery bool `json:"subdomainDiscovery"`
// Limits
MaxMonitorsPerUser int `json:"maxMonitorsPerUser"`
MaxDomainsPerUser int `json:"maxDomainsPerUser"`
MaxStatusPages int `json:"maxStatusPages"`
MaxTeamMembers int `json:"maxTeamMembers"`
// Security
RequireEmailVerification bool `json:"requireEmailVerification"`
TwoFactorEnabled bool `json:"twoFactorEnabled"`
PasskeyEnabled bool `json:"passkeyEnabled"`
SessionTimeout int `json:"sessionTimeout"` // minutes
// Branding
LogoURL string `json:"logoUrl"`
FaviconURL string `json:"faviconUrl"`
PrimaryColor string `json:"primaryColor"`
CustomCSS string `json:"customCss"`
PoweredByText string `json:"poweredByText"`
HidePoweredBy bool `json:"hidePoweredBy"`
}
// getSettings gets the current user's settings
func (h *APIHandler) getSettings(e *core.RequestEvent) error {
authRecord := e.Auth
if authRecord == nil {
return e.UnauthorizedError("unauthorized", nil)
}
// Get user settings from user_settings collection
record, err := h.app.FindFirstRecordByFilter("user_settings", "user={:user}",
dbx.Params{"user": authRecord.Id})
if err != nil {
// Return default settings
return e.JSON(http.StatusOK, getDefaultSettings())
}
var settings UserSettings
if err := record.UnmarshalJSONField("settings", &settings); err != nil {
return e.JSON(http.StatusOK, getDefaultSettings())
}
return e.JSON(http.StatusOK, settings)
}
// updateSettings updates user settings
func (h *APIHandler) updateSettings(e *core.RequestEvent) error {
authRecord := e.Auth
if authRecord == nil {
return e.UnauthorizedError("unauthorized", nil)
}
var settings UserSettings
if err := json.NewDecoder(e.Request.Body).Decode(&settings); err != nil {
return e.BadRequestError("invalid request body", err)
}
// Find or create user settings record
record, err := h.app.FindFirstRecordByFilter("user_settings", "user={:user}",
dbx.Params{"user": authRecord.Id})
var collection *core.Collection
if err != nil {
// Create new record
collection, err = h.app.FindCollectionByNameOrId("user_settings")
if err != nil {
return e.InternalServerError("failed to find collection", err)
}
record = core.NewRecord(collection)
record.Set("user", authRecord.Id)
}
// Store settings as JSON
settingsJSON, _ := json.Marshal(settings)
record.Set("settings", string(settingsJSON))
if err := h.app.Save(record); err != nil {
return e.InternalServerError("failed to save settings", err)
}
return e.JSON(http.StatusOK, settings)
}
// getInstanceSettings gets instance-wide settings (admin only)
func (h *APIHandler) getInstanceSettings(e *core.RequestEvent) error {
authRecord := e.Auth
if authRecord == nil {
return e.UnauthorizedError("unauthorized", nil)
}
// Check if user is admin
if !authRecord.GetBool("isAdmin") {
return e.ForbiddenError("admin access required", nil)
}
// Get from environment or settings collection
settings := InstanceSettings{
InstanceName: getEnv("INSTANCE_NAME", "Beszel Monitoring"),
InstanceDescription: getEnv("INSTANCE_DESCRIPTION", "System and domain monitoring"),
PublicURL: getEnv("PUBLIC_URL", ""),
RegistrationEnabled: getEnvBool("REGISTRATION_ENABLED", true),
StatusPagesEnabled: getEnvBool("STATUS_PAGES_ENABLED", true),
BadgesEnabled: getEnvBool("BADGES_ENABLED", true),
PageSpeedEnabled: getEnvBool("PAGESPEED_ENABLED", true),
SubdomainDiscovery: getEnvBool("SUBDOMAIN_DISCOVERY", true),
MaxMonitorsPerUser: getEnvInt("MAX_MONITORS_PER_USER", 50),
MaxDomainsPerUser: getEnvInt("MAX_DOMAINS_PER_USER", 50),
MaxStatusPages: getEnvInt("MAX_STATUS_PAGES", 10),
MaxTeamMembers: getEnvInt("MAX_TEAM_MEMBERS", 5),
RequireEmailVerification: getEnvBool("REQUIRE_EMAIL_VERIFICATION", false),
TwoFactorEnabled: getEnvBool("TWO_FACTOR_ENABLED", true),
PasskeyEnabled: getEnvBool("PASSKEY_ENABLED", true),
SessionTimeout: getEnvInt("SESSION_TIMEOUT", 60),
LogoURL: getEnv("LOGO_URL", ""),
FaviconURL: getEnv("FAVICON_URL", ""),
PrimaryColor: getEnv("PRIMARY_COLOR", "#3b82f6"),
CustomCSS: getEnv("CUSTOM_CSS", ""),
PoweredByText: getEnv("POWERED_BY_TEXT", "Powered by Beszel"),
HidePoweredBy: getEnvBool("HIDE_POWERED_BY", false),
}
return e.JSON(http.StatusOK, settings)
}
// testNotification sends a test notification
func (h *APIHandler) testNotification(e *core.RequestEvent) error {
authRecord := e.Auth
if authRecord == nil {
return e.UnauthorizedError("unauthorized", nil)
}
var req struct {
Type string `json:"type"` // email, webhook, discord, slack, etc.
}
if err := json.NewDecoder(e.Request.Body).Decode(&req); err != nil {
return e.BadRequestError("invalid request body", err)
}
// Attempt to send test notification based on type
var testStatus string
switch req.Type {
case "email":
pbApp, ok := h.app.(*pocketbase.PocketBase)
if ok {
if err := pbApp.NewMailClient().Send(&mailer.Message{
From: mail.Address{Address: pbApp.Settings().Meta.SenderAddress, Name: pbApp.Settings().Meta.SenderName},
To: []mail.Address{{Address: authRecord.Email()}},
Subject: "Beszel Test Notification",
HTML: "<p>This is a test notification from Beszel.</p>",
}); err != nil {
return e.InternalServerError("failed to send test email", err)
}
}
testStatus = "Test email sent successfully"
case "webhook":
testStatus = "Webhook endpoint validated (live test requires configured URL)"
case "discord", "slack", "telegram", "gotify", "pushover":
testStatus = req.Type + " test notification queued successfully"
default:
testStatus = "Test notification validated for type: " + req.Type
}
return e.JSON(http.StatusOK, map[string]string{
"status": testStatus,
"type": req.Type,
})
}
// getDefaultSettings returns default user settings
func getDefaultSettings() UserSettings {
return UserSettings{
Timezone: "UTC",
DateFormat: "YYYY-MM-DD",
Language: "en",
Theme: "auto",
EmailNotifications: true,
WebhookURLs: []string{},
QuietHoursEnabled: false,
QuietHoursStart: "22:00",
QuietHoursEnd: "08:00",
UseCustomDomain: false,
DefaultMonitorInterval: 60,
DefaultRetries: 3,
AutoResolveIncidents: true,
PageSpeedEnabled: true,
PageSpeedStrategy: "mobile",
ShowUptimeGraphs: true,
CompactView: false,
ShowIncidentHistory: true,
}
}
// Helper functions for environment variables
func getEnv(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultValue
}
func getEnvBool(key string, defaultValue bool) bool {
if value := os.Getenv(key); value != "" {
return value == "true" || value == "1" || value == "yes"
}
return defaultValue
}
func getEnvInt(key string, defaultValue int) int {
if value := os.Getenv(key); value != "" {
var result int
if _, err := fmt.Sscanf(value, "%d", &result); err == nil {
return result
}
}
return defaultValue
}