Files
Strix/internal/camera/stream/builder.go
T
eduard256 88e76eadb5 Refactor authentication system: centralize credential handling in Builder
Major architectural improvement to eliminate duplicate credential embedding
and simplify authentication logic across the codebase.

Changes:
- Builder now generates all URL variants with proper authentication upfront
  * RTSP: generates URLs with embedded credentials and without (for open cameras)
  * HTTP/JPEG/MJPEG: generates variants with query params and for Basic Auth
- Tester simplified to only test ready-to-use URLs without modification
  * Removed auth chain logic and multiple authentication method attempts
  * Removed AuthMethod enum and related complexity
  * Credentials automatically extracted from URLs when needed
- Scanner cleaned up by removing embedCredentialsInURL function
  * All TestStream calls now use single URL parameter
  * Removed AuthMethod from DiscoveredStream model

Benefits:
- Eliminates bug where credentials were added up to 3 times
- Centralizes all URL generation logic in one place (Builder)
- Cleaner, more maintainable code with clear separation of concerns
- Reduces complexity by ~200 lines of code
- All authentication scenarios still fully supported
2025-11-07 19:08:05 +03:00

374 lines
10 KiB
Go

package stream
import (
"encoding/base64"
"fmt"
"net/url"
"regexp"
"strconv"
"strings"
"github.com/strix-project/strix/internal/models"
)
// Builder handles stream URL construction
type Builder struct {
queryParams []string
logger interface{ Debug(string, ...any) }
}
// NewBuilder creates a new stream URL builder
func NewBuilder(queryParams []string, logger interface{ Debug(string, ...any) }) *Builder {
return &Builder{
queryParams: queryParams,
logger: logger,
}
}
// BuildContext contains parameters for URL building
type BuildContext struct {
IP string
Port int
Username string
Password string
Channel int
Width int
Height int
Protocol string
Path string
}
// BuildURL builds a complete URL from an entry and context
func (b *Builder) BuildURL(entry models.CameraEntry, ctx BuildContext) string {
b.logger.Debug("BuildURL called",
"entry_type", entry.Type,
"entry_url", entry.URL,
"entry_port", entry.Port,
"entry_protocol", entry.Protocol,
"ctx_ip", ctx.IP,
"ctx_port", ctx.Port,
"ctx_username", ctx.Username,
"ctx_channel", ctx.Channel)
// Set defaults
if ctx.Width == 0 {
ctx.Width = 640
}
if ctx.Height == 0 {
ctx.Height = 480
}
// NOTE: Channel default is 0 - will only be used for [CHANNEL] placeholder replacement
// Literal channel values in URLs (like "channel=1") are preserved as-is
// Use entry's port if not specified
if ctx.Port == 0 {
ctx.Port = entry.Port
// If entry port is also 0, use default port for the protocol
if ctx.Port == 0 {
// Use entry's protocol if not specified for port determination
protocol := ctx.Protocol
if protocol == "" {
protocol = entry.Protocol
}
switch protocol {
case "http":
ctx.Port = 80
case "https":
ctx.Port = 443
case "rtsp", "rtsps":
ctx.Port = 554
default:
ctx.Port = 80 // Default to 80 if unknown
}
b.logger.Debug("using default port for protocol",
"protocol", protocol,
"default_port", ctx.Port)
}
}
// Use entry's protocol if not specified
if ctx.Protocol == "" {
ctx.Protocol = entry.Protocol
}
// Replace placeholders in URL path
path := b.replacePlaceholders(entry.URL, ctx)
b.logger.Debug("placeholders replaced", "original", entry.URL, "after_replacement", path)
// Build the complete URL
var fullURL string
// Check if the URL already contains authentication parameters
hasAuthInURL := b.hasAuthenticationParams(path)
b.logger.Debug("auth params detection", "has_auth_in_url", hasAuthInURL, "path", path)
switch ctx.Protocol {
case "rtsp":
if ctx.Username != "" && ctx.Password != "" && !hasAuthInURL {
// Standard ports can be omitted
if ctx.Port == 554 {
fullURL = fmt.Sprintf("rtsp://%s:%s@%s/%s",
ctx.Username, ctx.Password, ctx.IP, path)
} else {
fullURL = fmt.Sprintf("rtsp://%s:%s@%s:%d/%s",
ctx.Username, ctx.Password, ctx.IP, ctx.Port, path)
}
} else {
if ctx.Port == 554 {
fullURL = fmt.Sprintf("rtsp://%s/%s", ctx.IP, path)
} else {
fullURL = fmt.Sprintf("rtsp://%s:%d/%s", ctx.IP, ctx.Port, path)
}
}
case "http", "https":
// For HTTP, check if auth should be in URL or parameters
if ctx.Username != "" && ctx.Password != "" && !hasAuthInURL {
// Don't put auth in URL for HTTP, will use Basic Auth header
if (ctx.Protocol == "http" && ctx.Port == 80) ||
(ctx.Protocol == "https" && ctx.Port == 443) {
fullURL = fmt.Sprintf("%s://%s/%s", ctx.Protocol, ctx.IP, path)
} else {
fullURL = fmt.Sprintf("%s://%s:%d/%s", ctx.Protocol, ctx.IP, ctx.Port, path)
}
} else {
if (ctx.Protocol == "http" && ctx.Port == 80) ||
(ctx.Protocol == "https" && ctx.Port == 443) {
fullURL = fmt.Sprintf("%s://%s/%s", ctx.Protocol, ctx.IP, path)
} else {
fullURL = fmt.Sprintf("%s://%s:%d/%s", ctx.Protocol, ctx.IP, ctx.Port, path)
}
}
default:
// Generic URL construction
fullURL = fmt.Sprintf("%s://%s:%d/%s", ctx.Protocol, ctx.IP, ctx.Port, path)
}
// Clean up double slashes (except after protocol://)
fullURL = b.cleanURL(fullURL)
b.logger.Debug("BuildURL complete",
"final_url", fullURL,
"entry_type", entry.Type,
"entry_url_pattern", entry.URL,
"protocol", ctx.Protocol,
"port", ctx.Port,
"has_auth_in_url", hasAuthInURL)
return fullURL
}
// replacePlaceholders replaces all placeholders in the URL
func (b *Builder) replacePlaceholders(urlPath string, ctx BuildContext) string {
result := urlPath
// Generate base64 auth for [AUTH] placeholder
auth := ""
if ctx.Username != "" && ctx.Password != "" {
auth = base64.StdEncoding.EncodeToString([]byte(ctx.Username + ":" + ctx.Password))
}
// Common placeholders
replacements := map[string]string{
"[CHANNEL]": strconv.Itoa(ctx.Channel),
"[channel]": strconv.Itoa(ctx.Channel),
"[WIDTH]": strconv.Itoa(ctx.Width),
"[width]": strconv.Itoa(ctx.Width),
"[HEIGHT]": strconv.Itoa(ctx.Height),
"[height]": strconv.Itoa(ctx.Height),
"[USERNAME]": ctx.Username,
"[username]": ctx.Username,
"[PASSWORD]": ctx.Password,
"[password]": ctx.Password,
"[PASWORD]": ctx.Password, // Handle typo in database
"[pasword]": ctx.Password,
"[USER]": ctx.Username,
"[user]": ctx.Username,
"[PASS]": ctx.Password,
"[pass]": ctx.Password,
"[PWD]": ctx.Password,
"[pwd]": ctx.Password,
"[IP]": ctx.IP,
"[ip]": ctx.IP,
"[PORT]": strconv.Itoa(ctx.Port),
"[port]": strconv.Itoa(ctx.Port),
"[AUTH]": auth, // base64(username:password) for basic auth
"[auth]": auth,
"[TOKEN]": "", // Empty for now
"[token]": "",
}
// Replace all placeholders
for placeholder, value := range replacements {
result = strings.ReplaceAll(result, placeholder, value)
}
// Handle query parameter placeholders (only for auth params)
result = b.replaceQueryParams(result, ctx)
return result
}
// replaceQueryParams handles query parameter replacements
func (b *Builder) replaceQueryParams(urlPath string, ctx BuildContext) string {
// Parse URL to handle query params
parts := strings.SplitN(urlPath, "?", 2)
if len(parts) < 2 {
return urlPath
}
basePath := parts[0]
queryString := parts[1]
// Parse query parameters
params, err := url.ParseQuery(queryString)
if err != nil {
return urlPath
}
// ONLY replace authentication parameters
// DO NOT replace channel, width, height - they should stay as-is from URL patterns
for key := range params {
lowerKey := strings.ToLower(key)
switch lowerKey {
case "user", "username", "usr", "loginuse":
params.Set(key, ctx.Username)
case "password", "pass", "pwd", "loginpas", "passwd":
params.Set(key, ctx.Password)
// Removed: channel, width, height replacements - they were breaking working URLs
}
}
// Rebuild URL
return basePath + "?" + params.Encode()
}
// hasAuthenticationParams checks if URL contains auth parameters
func (b *Builder) hasAuthenticationParams(urlPath string) bool {
authParams := []string{
"user=", "username=", "usr=", "loginuse=",
"password=", "pass=", "pwd=", "loginpas=", "passwd=",
}
lowerPath := strings.ToLower(urlPath)
for _, param := range authParams {
if strings.Contains(lowerPath, param) {
return true
}
}
return false
}
// cleanURL cleans up the URL
func (b *Builder) cleanURL(fullURL string) string {
// Remove double slashes except after protocol://
protocolEnd := strings.Index(fullURL, "://")
if protocolEnd > 0 {
protocol := fullURL[:protocolEnd+3]
rest := fullURL[protocolEnd+3:]
// Replace multiple slashes with single slash
rest = regexp.MustCompile(`/{2,}`).ReplaceAllString(rest, "/")
return protocol + rest
}
return fullURL
}
// BuildURLsFromEntry generates all possible URLs from a camera entry
func (b *Builder) BuildURLsFromEntry(entry models.CameraEntry, ctx BuildContext) []string {
urlMap := make(map[string]bool)
var urls []string
// Helper to add unique URLs
addURL := func(url string) {
if !urlMap[url] {
urls = append(urls, url)
urlMap[url] = true
}
}
switch entry.Protocol {
case "rtsp", "rtsps":
// For RTSP: generate with and without credentials
// 1. With credentials (if provided)
if ctx.Username != "" && ctx.Password != "" {
addURL(b.BuildURL(entry, ctx))
}
// 2. Without credentials (for open cameras)
ctxNoAuth := ctx
ctxNoAuth.Username = ""
ctxNoAuth.Password = ""
addURL(b.BuildURL(entry, ctxNoAuth))
case "http", "https":
// For HTTP/JPEG/MJPEG: generate multiple auth variants
if entry.Type == "JPEG" || entry.Type == "MJPEG" {
// Check if URL has auth placeholders
hasAuthPlaceholders := strings.Contains(entry.URL, "[USERNAME]") ||
strings.Contains(entry.URL, "[PASSWORD]") ||
strings.Contains(entry.URL, "[AUTH]")
if hasAuthPlaceholders {
// 1. URL with credentials in parameters (replaced placeholders)
addURL(b.BuildURL(entry, ctx))
// 2. URL without credentials (for cameras that don't require auth)
ctxNoAuth := ctx
ctxNoAuth.Username = ""
ctxNoAuth.Password = ""
addURL(b.BuildURL(entry, ctxNoAuth))
} else {
// URL without placeholders - will use Basic Auth in headers
// Generate only one URL, auth will be in headers
addURL(b.BuildURL(entry, ctx))
}
} else {
// Other HTTP types - single URL
addURL(b.BuildURL(entry, ctx))
}
default:
// Other protocols - single URL
addURL(b.BuildURL(entry, ctx))
}
// For NVR systems, try multiple channels
if ctx.Channel == 0 && strings.Contains(strings.ToLower(entry.Notes), "channel") {
for ch := 1; ch <= 4; ch++ {
altCtx := ctx
altCtx.Channel = ch
// Regenerate with different channel
if entry.Protocol == "rtsp" || entry.Protocol == "rtsps" {
if ctx.Username != "" && ctx.Password != "" {
addURL(b.BuildURL(entry, altCtx))
}
altCtx.Username = ""
altCtx.Password = ""
addURL(b.BuildURL(entry, altCtx))
} else {
addURL(b.BuildURL(entry, altCtx))
}
}
}
b.logger.Debug("BuildURLsFromEntry complete",
"entry_url_pattern", entry.URL,
"entry_type", entry.Type,
"entry_protocol", entry.Protocol,
"total_urls_generated", len(urls),
"urls", urls)
return urls
}