diff --git a/Dockerfile b/Dockerfile index 292b709c..a7f506ae 100644 --- a/Dockerfile +++ b/Dockerfile @@ -68,13 +68,11 @@ ARG VCS_REF=unknown ARG BUILD_DATE=unknown # Build the Go binary with version information injected via ldflags -# -gcflags "all=-N -l" disables optimizations and inlining for better debugging # xx-go handles CGO and cross-compilation flags automatically RUN --mount=type=cache,target=/root/.cache/go-build \ --mount=type=cache,target=/go/pkg/mod \ CGO_ENABLED=1 xx-go build \ - -gcflags "all=-N -l" \ - -ldflags "-X github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/version.Version=${VERSION} \ + -ldflags "-s -w -X github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/version.Version=${VERSION} \ -X github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/version.GitCommit=${VCS_REF} \ -X github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/version.BuildTime=${BUILD_DATE}" \ -o cpmp ./cmd/api diff --git a/backend/internal/api/handlers/auth_handlers.go b/backend/internal/api/handlers/auth_handlers.go index 126547aa..3983f2b3 100644 --- a/backend/internal/api/handlers/auth_handlers.go +++ b/backend/internal/api/handlers/auth_handlers.go @@ -45,12 +45,13 @@ func (h *AuthUserHandler) Get(c *gin.Context) { // CreateRequest represents the request body for creating an auth user type CreateAuthUserRequest struct { - Username string `json:"username" binding:"required"` - Email string `json:"email" binding:"required,email"` - Name string `json:"name"` - Password string `json:"password" binding:"required,min=8"` - Roles string `json:"roles"` - MFAEnabled bool `json:"mfa_enabled"` + Username string `json:"username" binding:"required"` + Email string `json:"email" binding:"required,email"` + Name string `json:"name"` + Password string `json:"password" binding:"required,min=8"` + Roles string `json:"roles"` + MFAEnabled bool `json:"mfa_enabled"` + AdditionalEmails string `json:"additional_emails"` } // Create creates a new auth user @@ -62,12 +63,13 @@ func (h *AuthUserHandler) Create(c *gin.Context) { } user := models.AuthUser{ - Username: req.Username, - Email: req.Email, - Name: req.Name, - Roles: req.Roles, - MFAEnabled: req.MFAEnabled, - Enabled: true, + Username: req.Username, + Email: req.Email, + Name: req.Name, + Roles: req.Roles, + MFAEnabled: req.MFAEnabled, + AdditionalEmails: req.AdditionalEmails, + Enabled: true, } if err := user.SetPassword(req.Password); err != nil { @@ -85,12 +87,13 @@ func (h *AuthUserHandler) Create(c *gin.Context) { // UpdateRequest represents the request body for updating an auth user type UpdateAuthUserRequest struct { - Email *string `json:"email,omitempty"` - Name *string `json:"name,omitempty"` - Password *string `json:"password,omitempty"` - Roles *string `json:"roles,omitempty"` - Enabled *bool `json:"enabled,omitempty"` - MFAEnabled *bool `json:"mfa_enabled,omitempty"` + Email *string `json:"email,omitempty"` + Name *string `json:"name,omitempty"` + Password *string `json:"password,omitempty"` + Roles *string `json:"roles,omitempty"` + Enabled *bool `json:"enabled,omitempty"` + MFAEnabled *bool `json:"mfa_enabled,omitempty"` + AdditionalEmails *string `json:"additional_emails,omitempty"` } // Update updates an existing auth user @@ -133,6 +136,9 @@ func (h *AuthUserHandler) Update(c *gin.Context) { if req.MFAEnabled != nil { user.MFAEnabled = *req.MFAEnabled } + if req.AdditionalEmails != nil { + user.AdditionalEmails = *req.AdditionalEmails + } if err := h.db.Save(&user).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) diff --git a/backend/internal/api/handlers/auth_handlers_test.go b/backend/internal/api/handlers/auth_handlers_test.go index 97eea904..10578cb3 100644 --- a/backend/internal/api/handlers/auth_handlers_test.go +++ b/backend/internal/api/handlers/auth_handlers_test.go @@ -147,6 +147,28 @@ func TestAuthUserHandler_Create(t *testing.T) { assert.True(t, result.Enabled) }) + t.Run("with additional emails", func(t *testing.T) { + body := map[string]interface{}{ + "username": "multiemail", + "email": "primary@example.com", + "password": "password123", + "additional_emails": "alt1@example.com,alt2@example.com", + } + jsonBody, _ := json.Marshal(body) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", "/api/v1/security/users", bytes.NewBuffer(jsonBody)) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusCreated, w.Code) + + var result models.AuthUser + json.Unmarshal(w.Body.Bytes(), &result) + assert.Equal(t, "multiemail", result.Username) + assert.Equal(t, "alt1@example.com,alt2@example.com", result.AdditionalEmails) + }) + t.Run("invalid email", func(t *testing.T) { body := map[string]interface{}{ "username": "baduser", @@ -192,6 +214,24 @@ func TestAuthUserHandler_Update(t *testing.T) { assert.False(t, result.Enabled) }) + t.Run("update additional emails", func(t *testing.T) { + body := map[string]interface{}{ + "additional_emails": "newalt@example.com", + } + jsonBody, _ := json.Marshal(body) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("PUT", "/api/v1/security/users/"+user.UUID, bytes.NewBuffer(jsonBody)) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var result models.AuthUser + json.Unmarshal(w.Body.Bytes(), &result) + assert.Equal(t, "newalt@example.com", result.AdditionalEmails) + }) + t.Run("not found", func(t *testing.T) { body := map[string]interface{}{"name": "Test"} jsonBody, _ := json.Marshal(body) diff --git a/backend/internal/caddy/config.go b/backend/internal/caddy/config.go index 09630f5f..7f4844dc 100644 --- a/backend/internal/caddy/config.go +++ b/backend/internal/caddy/config.go @@ -23,7 +23,7 @@ func GenerateConfig(hosts []models.ProxyHost, storageDir string, acmeEmail strin Logging: &LoggingConfig{ Logs: map[string]*LogConfig{ "access": { - Level: "DEBUG", + Level: "INFO", Writer: &WriterConfig{ Output: "file", Filename: logFile, @@ -440,21 +440,41 @@ func convertAuthUsersToConfig(users []models.AuthUser) []map[string]interface{} continue } - userConfig := map[string]interface{}{ - "username": user.Username, - "email": user.Email, - "password": user.PasswordHash, // Already bcrypt hashed + // Helper to create user config + createUserConfig := func(username, email string) map[string]interface{} { + cfg := map[string]interface{}{ + "username": username, + "email": email, + "password": user.PasswordHash, // Already bcrypt hashed + } + + if user.Name != "" { + cfg["name"] = user.Name + } + + if user.Roles != "" { + cfg["roles"] = strings.Split(user.Roles, ",") + } + return cfg } - if user.Name != "" { - userConfig["name"] = user.Name - } + // Add primary user + result = append(result, createUserConfig(user.Username, user.Email)) - if user.Roles != "" { - userConfig["roles"] = strings.Split(user.Roles, ",") + // Add additional emails as alias users + if user.AdditionalEmails != "" { + emails := strings.Split(user.AdditionalEmails, ",") + for i, email := range emails { + email = strings.TrimSpace(email) + if email == "" { + continue + } + // Create a derived username for the alias + // We use a predictable suffix so it doesn't change + aliasUsername := fmt.Sprintf("%s_alt%d", user.Username, i+1) + result = append(result, createUserConfig(aliasUsername, email)) + } } - - result = append(result, userConfig) } return result } diff --git a/backend/internal/caddy/config_test.go b/backend/internal/caddy/config_test.go index 03af57cc..c5f8b4cb 100644 --- a/backend/internal/caddy/config_test.go +++ b/backend/internal/caddy/config_test.go @@ -124,7 +124,7 @@ func TestGenerateConfig_Logging(t *testing.T) { require.NotNil(t, config.Logging) require.NotNil(t, config.Logging.Logs) require.NotNil(t, config.Logging.Logs["access"]) - require.Equal(t, "DEBUG", config.Logging.Logs["access"].Level) + require.Equal(t, "INFO", config.Logging.Logs["access"].Level) require.Contains(t, config.Logging.Logs["access"].Writer.Filename, "access.log") require.Equal(t, 10, config.Logging.Logs["access"].Writer.RollSize) require.Equal(t, 5, config.Logging.Logs["access"].Writer.RollKeep) diff --git a/backend/internal/caddy/types.go b/backend/internal/caddy/types.go index 95c28627..66d5868e 100644 --- a/backend/internal/caddy/types.go +++ b/backend/internal/caddy/types.go @@ -101,7 +101,8 @@ type Handler map[string]interface{} // ReverseProxyHandler creates a reverse_proxy handler. func ReverseProxyHandler(dial string, enableWS bool) Handler { h := Handler{ - "handler": "reverse_proxy", + "handler": "reverse_proxy", + "flush_interval": -1, // Disable buffering for better streaming performance (Plex, etc.) "upstreams": []map[string]interface{}{ {"dial": dial}, }, diff --git a/backend/internal/models/auth_user.go b/backend/internal/models/auth_user.go index 78dda319..df7559c9 100644 --- a/backend/internal/models/auth_user.go +++ b/backend/internal/models/auth_user.go @@ -24,6 +24,9 @@ type AuthUser struct { PasswordHash string `gorm:"not null" json:"-"` // Never expose in JSON Enabled bool `gorm:"default:true" json:"enabled"` + // Additional emails for linking identities (comma-separated) + AdditionalEmails string `json:"additional_emails"` + // Authorization Roles string `json:"roles"` // Comma-separated roles (e.g., "admin,user") diff --git a/frontend/src/api/security.ts b/frontend/src/api/security.ts index 5466448f..66215943 100644 --- a/frontend/src/api/security.ts +++ b/frontend/src/api/security.ts @@ -48,6 +48,7 @@ export interface AuthUser { enabled: boolean; created_at: string; updated_at: string; + additional_emails?: string; } export interface AuthUserStats { @@ -62,6 +63,7 @@ export interface CreateAuthUserRequest { password?: string; roles: string; mfa_enabled: boolean; + additional_emails?: string; } export interface UpdateAuthUserRequest { @@ -71,6 +73,7 @@ export interface UpdateAuthUserRequest { roles?: string; mfa_enabled?: boolean; enabled?: boolean; + additional_emails?: string; } export const getAuthUsers = async (): Promise => { diff --git a/frontend/src/pages/Security/Providers.tsx b/frontend/src/pages/Security/Providers.tsx index 5da37350..1ce91585 100644 --- a/frontend/src/pages/Security/Providers.tsx +++ b/frontend/src/pages/Security/Providers.tsx @@ -226,24 +226,58 @@ export default function Providers() {
- +
+ +
+ +
+ The public identifier for your OAuth application.
+ Found in your provider's developer console (e.g., Google Cloud Console, GitHub Developer Settings). +
+
+
+
setFormData({ ...formData, client_id: e.target.value })} + placeholder="e.g., 123456789.apps.googleusercontent.com" className="w-full bg-gray-900 border border-gray-700 rounded-lg px-3 py-2 text-white focus:ring-2 focus:ring-blue-500" />
- +
+ +
+ +
+ The private key for your OAuth application.
+ Keep this secret and secure! Generate/regenerate in your provider's developer console. +
+
+
+
setFormData({ ...formData, client_secret: e.target.value })} + placeholder="Enter your client secret" className="w-full bg-gray-900 border border-gray-700 rounded-lg px-3 py-2 text-white focus:ring-2 focus:ring-blue-500" />
diff --git a/frontend/src/pages/Security/Users.tsx b/frontend/src/pages/Security/Users.tsx index 9140d5c5..810b4237 100644 --- a/frontend/src/pages/Security/Users.tsx +++ b/frontend/src/pages/Security/Users.tsx @@ -16,6 +16,7 @@ export default function Users() { password: '', roles: '', mfa_enabled: false, + additional_emails: '', }); const handleSubmit = async (e: React.FormEvent) => { @@ -27,6 +28,7 @@ export default function Users() { name: formData.name, roles: formData.roles, mfa_enabled: formData.mfa_enabled, + additional_emails: formData.additional_emails, }; if (formData.password) { updateData.password = formData.password; @@ -63,6 +65,7 @@ export default function Users() { password: '', roles: '', mfa_enabled: false, + additional_emails: '', }); setEditingUser(null); }; @@ -76,6 +79,7 @@ export default function Users() { password: '', // Don't populate password roles: user.roles, mfa_enabled: user.mfa_enabled, + additional_emails: user.additional_emails || '', }); setIsModalOpen(true); }; @@ -222,6 +226,17 @@ export default function Users() { className="w-full bg-gray-900 border border-gray-700 rounded-lg px-3 py-2 text-white focus:ring-2 focus:ring-blue-500" />
+
+ + setFormData({ ...formData, additional_emails: e.target.value })} + placeholder="email2@example.com, email3@example.com" + className="w-full bg-gray-900 border border-gray-700 rounded-lg px-3 py-2 text-white focus:ring-2 focus:ring-blue-500" + /> +

Used for linking multiple OAuth identities to this user.

+