# Implementation Plan: Application URL Setting for User Invitations **Issue**: When inviting users, verification email links use the internal address (e.g., `localhost:8080`) instead of the public-facing URL. External users cannot access these links. **Target Files**: - Backend: [settings_handler.go](../../backend/internal/api/handlers/settings_handler.go), [user_handler.go](../../backend/internal/api/handlers/user_handler.go), [mail_service.go](../../backend/internal/services/mail_service.go) - Frontend: [SystemSettings.tsx](../../frontend/src/pages/SystemSettings.tsx), [UsersPage.tsx](../../frontend/src/pages/UsersPage.tsx) - Translations: [translation.json](../../frontend/src/locales/en/translation.json) --- ## 1. Recommended Setting Name: "Application URL" ### Research & Justification | Application | Setting Name | Notes | |-------------|--------------|-------| | Nginx Proxy Manager | N/A | No email system | | GitLab | `external_url` | Most common in self-hosted apps | | Grafana | `root_url` | Part of `[server]` config | | Nextcloud | `overwrite.cli.url` | Complex naming | | WordPress | `Site Address (URL)` | User-facing name | | Portainer | `Public URL` | Clean, simple | | Home Assistant | `external_url` | Paired with `internal_url` | | Authentik | `AUTHENTIK_HOST` | Environment variable | ### Recommendation: **"Application URL"** **Why this name:** 1. **User-Friendly**: "Application URL" is clearer than "Base URL" or "External URL" for non-technical users 2. **Self-Explanatory**: Immediately conveys "the URL used to access this application" 3. **Consistent with Charon's naming**: Follows the pattern of human-readable settings (e.g., "Caddy Admin API Endpoint", "SSL Provider") 4. **Internal Key**: `app.public_url` - clear, namespaced, and follows existing `caddy.admin_api` pattern **Alternative consideration**: "Public URL" is slightly more descriptive but may confuse users who don't understand internal vs external networking. --- ## 2. Database Schema Changes **No schema changes required.** The existing `settings` table structure supports this: ```go // backend/internal/models/setting.go (existing) type Setting struct { ID uint `json:"id" gorm:"primaryKey"` Key string `json:"key" gorm:"uniqueIndex"` // "app.public_url" Value string `json:"value" gorm:"type:text"` // "https://charon.example.com" Type string `json:"type" gorm:"index"` // "string" Category string `json:"category" gorm:"index"` // "general" UpdatedAt time.Time `json:"updated_at"` } ``` The new setting will be stored as: - **Key**: `app.public_url` - **Value**: User-provided URL (e.g., `https://charon.example.com`) - **Type**: `string` - **Category**: `general` --- ## 3. Backend Changes ### 3.1 Settings Handler Updates **File**: [backend/internal/api/handlers/settings_handler.go](../../backend/internal/api/handlers/settings_handler.go) Add a new helper function to retrieve the public URL: ```go // GetPublicURL retrieves the configured public URL or falls back to request host. // This should be used for all user-facing URLs (emails, invite links). func GetPublicURL(db *gorm.DB, c *gin.Context) string { var setting models.Setting if err := db.Where("key = ?", "app.public_url").First(&setting).Error; err == nil { if setting.Value != "" { return strings.TrimSuffix(setting.Value, "/") } } // Fallback to request-derived URL return getBaseURL(c) } ``` Add a new endpoint to validate URL format: ```go // ValidatePublicURL validates a URL is properly formatted and accessible. func (h *SettingsHandler) ValidatePublicURL(c *gin.Context) { var req struct { URL string `json:"url" binding:"required,url"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } // Validate URL structure parsed, err := url.Parse(req.URL) if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") { c.JSON(http.StatusBadRequest, gin.H{ "valid": false, "error": "URL must start with http:// or https://", }) return } c.JSON(http.StatusOK, gin.H{ "valid": true, "normalized": strings.TrimSuffix(req.URL, "/"), }) } ``` **Route Registration** in [routes.go](../../backend/internal/api/routes/routes.go): ```go settings.POST("/validate-url", settingsHandler.ValidatePublicURL) ``` ### 3.2 User Handler Updates **File**: [backend/internal/api/handlers/user_handler.go](../../backend/internal/api/handlers/user_handler.go) **Current Issue** (lines 392-400): ```go // getBaseURL extracts the base URL from the request. func getBaseURL(c *gin.Context) string { scheme := "https" if c.Request.TLS == nil { if proto := c.GetHeader("X-Forwarded-Proto"); proto != "" { scheme = proto } else { scheme = "http" } } return scheme + "://" + c.Request.Host } ``` **Solution**: Modify `InviteUser` to use the configured public URL: ```go // InviteUser creates a new user with an invite token and sends an email (admin only). func (h *UserHandler) InviteUser(c *gin.Context) { // ... existing validation code ... // Try to send invite email emailSent := false if h.MailService.IsConfigured() { baseURL := GetPublicURL(h.DB, c) // Changed from getBaseURL(c) appName := getAppName(h.DB) if err := h.MailService.SendInvite(user.Email, inviteToken, appName, baseURL); err == nil { emailSent = true } } // ... rest of handler ... } ``` Add a new endpoint for previewing the invite URL: ```go // PreviewInviteURLRequest represents the request for previewing an invite URL. type PreviewInviteURLRequest struct { Email string `json:"email" binding:"required,email"` } // PreviewInviteURL returns what the invite URL would look like with current settings. func (h *UserHandler) PreviewInviteURL(c *gin.Context) { role, _ := c.Get("role") if role != "admin" { c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"}) return } var req PreviewInviteURLRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } baseURL := GetPublicURL(h.DB, c) // Generate a sample token for preview (not stored) sampleToken := "SAMPLE_TOKEN_PREVIEW" inviteURL := fmt.Sprintf("%s/accept-invite?token=%s", strings.TrimSuffix(baseURL, "/"), sampleToken) // Check if public URL is configured var setting models.Setting isConfigured := h.DB.Where("key = ?", "app.public_url").First(&setting).Error == nil && setting.Value != "" c.JSON(http.StatusOK, gin.H{ "preview_url": inviteURL, "base_url": baseURL, "is_configured": isConfigured, "email": req.Email, "warning": !isConfigured, "warning_message": "Application URL not configured. The invite link may not be accessible from external networks.", }) } ``` **Route Registration**: ```go r.POST("/users/preview-invite-url", h.PreviewInviteURL) ``` ### 3.3 Mail Service Updates **File**: [backend/internal/services/mail_service.go](../../backend/internal/services/mail_service.go) No changes required - the `SendInvite` function already accepts `baseURL` as a parameter: ```go func (s *MailService) SendInvite(email, inviteToken, appName, baseURL string) error { inviteURL := fmt.Sprintf("%s/accept-invite?token=%s", strings.TrimSuffix(baseURL, "/"), inviteToken) // ... } ``` --- ## 4. Frontend Changes ### 4.1 System Settings UI **File**: [frontend/src/pages/SystemSettings.tsx](../../frontend/src/pages/SystemSettings.tsx) Add a new section for Application URL after the General Configuration card: ```tsx // Add state const [publicURL, setPublicURL] = useState('') const [publicURLValid, setPublicURLValid] = useState(null) // Update useEffect to load setting useEffect(() => { if (settings) { // ... existing settings ... if (settings['app.public_url']) setPublicURL(settings['app.public_url']) } }, [settings]) // Add validation function const validatePublicURL = async (url: string) => { if (!url) { setPublicURLValid(null) return } try { const response = await client.post('/settings/validate-url', { url }) setPublicURLValid(response.data.valid) } catch { setPublicURLValid(false) } } // Add to saveSettingsMutation const saveSettingsMutation = useMutation({ mutationFn: async () => { // ... existing saves ... await updateSetting('app.public_url', publicURL, 'general', 'string') }, // ... }) ``` **New UI Section** (add after General Configuration card): ```tsx {/* Application URL */} {t('systemSettings.applicationUrl.title')} {t('systemSettings.applicationUrl.description')} {t('systemSettings.applicationUrl.infoMessage')}
{ setPublicURL(e.target.value) validatePublicURL(e.target.value) }} placeholder="https://charon.example.com" className={cn( publicURLValid === false && 'border-red-500', publicURLValid === true && 'border-green-500' )} /> {publicURLValid !== null && ( publicURLValid ? ( ) : ( ) )}

{t('systemSettings.applicationUrl.helper')}

{publicURLValid === false && (

{t('systemSettings.applicationUrl.invalidUrl')}

)}
{!publicURL && ( {t('systemSettings.applicationUrl.notConfiguredWarning')} )}
``` ### 4.2 User Invitation Flow Updates **File**: [frontend/src/pages/UsersPage.tsx](../../frontend/src/pages/UsersPage.tsx) Add URL preview to the `InviteModal` component: ```tsx // Add to InviteModal component const [urlPreview, setUrlPreview] = useState<{ preview_url: string base_url: string is_configured: boolean warning: boolean warning_message: string } | null>(null) // Add preview fetch when email changes useEffect(() => { if (email && email.includes('@')) { const fetchPreview = async () => { try { const response = await client.post('/users/preview-invite-url', { email }) setUrlPreview(response.data) } catch { setUrlPreview(null) } } const debounce = setTimeout(fetchPreview, 500) return () => clearTimeout(debounce) } else { setUrlPreview(null) } }, [email]) ``` Add preview UI to modal (before the submit buttons): ```tsx {/* URL Preview */} {urlPreview && (
{urlPreview.preview_url.replace('SAMPLE_TOKEN_PREVIEW', '...')}
{urlPreview.warning && ( {t('users.inviteUrlWarning')} {t('users.configureApplicationUrl')} )}
)} ``` ### 4.3 API Client Updates **File**: [frontend/src/api/settings.ts](../../frontend/src/api/settings.ts) Add new function: ```typescript /** * Validates a URL for use as the application URL. * @param url - The URL to validate * @returns Promise resolving to validation result */ export const validatePublicURL = async (url: string): Promise<{ valid: boolean normalized?: string error?: string }> => { const response = await client.post('/settings/validate-url', { url }) return response.data } ``` **File**: [frontend/src/api/users.ts](../../frontend/src/api/users.ts) Add new function: ```typescript /** Response from invite URL preview. */ export interface PreviewInviteURLResponse { preview_url: string base_url: string is_configured: boolean email: string warning: boolean warning_message: string } /** * Previews what the invite URL will look like for a given email. * @param email - The email to preview * @returns Promise resolving to PreviewInviteURLResponse */ export const previewInviteURL = async (email: string): Promise => { const response = await client.post('/users/preview-invite-url', { email }) return response.data } ``` --- ## 5. Translation Updates **File**: [frontend/src/locales/en/translation.json](../../frontend/src/locales/en/translation.json) Add under `systemSettings`: ```json "applicationUrl": { "title": "Application URL", "description": "Configure the public URL used for user-facing links and emails.", "label": "Application URL", "helper": "The public URL where users access Charon (e.g., https://charon.example.com). Used in invitation emails and password reset links.", "infoMessage": "This URL is used when sending invitation emails. If not configured, Charon will use the URL from the current browser request, which may not work for external users.", "invalidUrl": "Please enter a valid URL starting with http:// or https://", "notConfiguredWarning": "Application URL is not configured. Invitation emails will use the current browser URL, which may not be accessible from external networks." } ``` Add under `users`: ```json "inviteUrlPreview": "Invite Link Preview", "inviteUrlWarning": "Application URL is not configured. This link may not work for external users.", "configureApplicationUrl": "Configure Application URL" ``` **Repeat for other locale files**: `de/translation.json`, `es/translation.json`, `fr/translation.json`, `zh/translation.json` --- ## 6. Implementation Phases ### Phase 1: Backend Foundation **Files to modify:** 1. [backend/internal/api/handlers/settings_handler.go](../../backend/internal/api/handlers/settings_handler.go) - Add `GetPublicURL()` helper function - Add `ValidatePublicURL()` endpoint 2. [backend/internal/api/handlers/user_handler.go](../../backend/internal/api/handlers/user_handler.go) - Modify `InviteUser()` to use `GetPublicURL()` - Add `PreviewInviteURL()` endpoint 3. [backend/internal/api/routes/routes.go](../../backend/internal/api/routes/routes.go) - Register new routes **Deliverable**: Backend can store, validate, and use the public URL setting. ### Phase 2: Frontend Settings UI **Files to modify:** 1. [frontend/src/pages/SystemSettings.tsx](../../frontend/src/pages/SystemSettings.tsx) - Add Application URL input field - Add validation feedback - Add warning when not configured 2. [frontend/src/api/settings.ts](../../frontend/src/api/settings.ts) - Add `validatePublicURL()` function 3. [frontend/src/locales/en/translation.json](../../frontend/src/locales/en/translation.json) - Add `systemSettings.applicationUrl.*` translations **Deliverable**: Admin can configure the Application URL in settings. ### Phase 3: User Invitation Preview **Files to modify:** 1. [frontend/src/pages/UsersPage.tsx](../../frontend/src/pages/UsersPage.tsx) - Add URL preview to `InviteModal` - Add warning banner if URL not configured 2. [frontend/src/api/users.ts](../../frontend/src/api/users.ts) - Add `previewInviteURL()` function 3. [frontend/src/locales/en/translation.json](../../frontend/src/locales/en/translation.json) - Add `users.inviteUrlPreview`, `users.inviteUrlWarning`, `users.configureApplicationUrl` **Deliverable**: Admin sees URL preview and warnings when inviting users. ### Phase 4: i18n & Polish **Files to modify:** 1. All locale files: - `frontend/src/locales/de/translation.json` - `frontend/src/locales/es/translation.json` - `frontend/src/locales/fr/translation.json` - `frontend/src/locales/zh/translation.json` 2. Add unit tests for new endpoints **Deliverable**: Complete feature with all translations. --- ## 7. Security Considerations ### 7.1 URL Validation - **SSRF Prevention**: The URL is only used for generating email links, not for server-side requests - **Input Validation**: Backend validates URL format (must start with `http://` or `https://`) - **XSS Prevention**: URL is HTML-escaped in email templates (already handled by Go's `html/template`) ### 7.2 Authorization - **Settings Access**: Only admins can modify `app.public_url` (existing middleware) - **Preview Access**: Only admins can preview invite URLs ### 7.3 Data Exposure - The preview endpoint only returns sanitized data, no sensitive tokens - Sample tokens in preview are clearly marked and not stored --- ## 8. Testing Checklist - [ ] Setting saves and persists across restarts - [ ] URL validation rejects invalid formats - [ ] Invite emails use the configured URL - [ ] Invite emails fall back to request URL if not configured - [ ] Preview shows warning when URL not configured - [ ] Preview updates when email changes - [ ] Accept invite page works with the configured URL - [ ] All translations display correctly - [ ] Setting is admin-only --- ## 9. Files Changed Summary | File | Changes | |------|---------| | `backend/internal/api/handlers/settings_handler.go` | Add `GetPublicURL()`, `ValidatePublicURL()` | | `backend/internal/api/handlers/user_handler.go` | Modify `InviteUser()`, add `PreviewInviteURL()` | | `backend/internal/api/routes/routes.go` | Register 2 new routes | | `frontend/src/pages/SystemSettings.tsx` | Add Application URL card | | `frontend/src/pages/UsersPage.tsx` | Add URL preview to InviteModal | | `frontend/src/api/settings.ts` | Add `validatePublicURL()` | | `frontend/src/api/users.ts` | Add `previewInviteURL()` | | `frontend/src/locales/*/translation.json` | Add new translation keys | **No changes required to:** - `backend/internal/models/setting.go` - Uses existing schema - `backend/internal/services/mail_service.go` - Already accepts URL parameter - `.gitignore`, `codecov.yml`, `.dockerignore`, `Dockerfile` - No changes needed