Files
Charon/backend/internal/utils/url.go
GitHub Actions 9392d9454c feat: add Application URL setting for user invitations
Add configurable public-facing URL setting to fix issue where invite emails
contained internal localhost addresses inaccessible to external users.

Features:
- New "Application URL" setting in System Settings (key: app.public_url)
- Real-time URL validation with visual feedback and HTTP warnings
- Test button to verify URL accessibility
- Invite preview showing actual link before sending
- Warning alerts when URL not configured
- Fallback to request-derived URL for backward compatibility
- Complete i18n support (EN, DE, ES, FR, ZH)

Backend:
- Created utils.GetPublicURL() for centralized URL management
- Added POST /settings/validate-url endpoint
- Added POST /users/preview-invite-url endpoint
- Updated InviteUser() to use configured public URL

Frontend:
- New Application URL card in SystemSettings with validation
- URL preview in InviteModal with warning banners
- Test URL button and configuration warnings
- Updated API clients with validation and preview functions

Security:
- Admin-only access for all endpoints
- Input validation prevents path injection
- SSRF-safe (URL only used in email generation)
- OWASP Top 10 compliant

Coverage: Backend 87.6%, Frontend 86.5% (both exceed 85% threshold)

Refs: #application-url-feature
2025-12-21 22:32:41 +00:00

77 lines
1.9 KiB
Go

package utils
import (
"net/url"
"strings"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"github.com/Wikid82/charon/backend/internal/models"
)
// 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)
}
// getBaseURL extracts the base URL from the request.
func getBaseURL(c *gin.Context) string {
scheme := "https"
if c.Request.TLS == nil {
// Check for X-Forwarded-Proto header
if proto := c.GetHeader("X-Forwarded-Proto"); proto != "" {
scheme = proto
} else {
scheme = "http"
}
}
return scheme + "://" + c.Request.Host
}
// ValidateURL validates that a URL is properly formatted for use as an application URL.
// Returns error message if invalid, empty string if valid.
func ValidateURL(rawURL string) (normalized string, warning string, err error) {
// Parse URL
parsed, parseErr := url.Parse(rawURL)
if parseErr != nil {
return "", "", parseErr
}
// Validate scheme
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return "", "", &url.Error{
Op: "parse",
URL: rawURL,
Err: nil,
}
}
// Warn if HTTP
if parsed.Scheme == "http" {
warning = "Using HTTP is not recommended. Consider using HTTPS for security."
}
// Reject URLs with path components beyond "/"
if parsed.Path != "" && parsed.Path != "/" {
return "", "", &url.Error{
Op: "validate",
URL: rawURL,
Err: nil,
}
}
// Normalize URL (remove trailing slash, keep scheme and host)
normalized = strings.TrimSuffix(rawURL, "/")
return normalized, warning, nil
}