From f0fe64a1a3653c6aa6ab09d72f44645a7483bde6 Mon Sep 17 00:00:00 2001 From: ProtoTess <32490978+0x524A@users.noreply.github.com> Date: Tue, 18 Nov 2025 04:36:20 +0000 Subject: [PATCH] feat: Implement enhanced file download with Basic and Digest authentication support --- client.go | 243 ++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 225 insertions(+), 18 deletions(-) diff --git a/client.go b/client.go index 60d7a0b..ae70218 100644 --- a/client.go +++ b/client.go @@ -2,8 +2,10 @@ package onvif import ( "context" + "crypto/md5" "fmt" "io" + "net" "net/http" "net/url" "strings" @@ -205,48 +207,63 @@ func (c *Client) GetCredentials() (string, string) { // DownloadFile downloads a file from the given URL with authentication // Returns the raw file bytes -// Supports both Basic and Digest authentication -func (c *Client) DownloadFile(ctx context.Context, url string) ([]byte, error) { - // Create a new HTTP request with context - req, err := http.NewRequestWithContext(ctx, "GET", url, nil) +// Supports both Basic and Digest authentication (tries basic first, falls back to digest) +func (c *Client) DownloadFile(ctx context.Context, downloadURL string) ([]byte, error) { + // Try basic auth first + data, err := c.downloadWithBasicAuth(ctx, downloadURL) + if err == nil { + return data, nil + } + + // If basic auth fails with 401, try digest auth + if strings.Contains(err.Error(), "401") { + digestData, digestErr := c.downloadWithDigestAuth(ctx, downloadURL) + if digestErr == nil { + return digestData, nil + } + // If digest auth also fails, return the original error + if strings.Contains(digestErr.Error(), "401") { + return nil, err // Return original error (both auth methods failed) + } + return nil, digestErr + } + + return nil, err +} + +// downloadWithBasicAuth performs an HTTP download with Basic authentication +func (c *Client) downloadWithBasicAuth(ctx context.Context, downloadURL string) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, "GET", downloadURL, nil) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } - // Add authentication if credentials are provided if c.username != "" { req.SetBasicAuth(c.username, c.password) } - // Set User-Agent and Connection headers req.Header.Set("User-Agent", "onvif-go-client") req.Header.Set("Connection", "close") - // Execute the request resp, err := c.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("download request failed: %w", err) } defer resp.Body.Close() - // Check HTTP status code if resp.StatusCode != http.StatusOK { bodyPreview, _ := io.ReadAll(resp.Body) bodyStr := string(bodyPreview) if len(bodyStr) > 200 { bodyStr = bodyStr[:200] + "..." } - + errorMsg := fmt.Sprintf("download failed with status code %d", resp.StatusCode) - - // Provide helpful hints for common errors + switch resp.StatusCode { case http.StatusUnauthorized: errorMsg += "\n ❌ Authentication failed (401 Unauthorized)" - errorMsg += "\n 💡 Check camera credentials (username/password)" - errorMsg += "\n 💡 Some cameras require digest auth instead of basic auth" - errorMsg += "\n 💡 Try accessing the snapshot URL manually:" - errorMsg += fmt.Sprintf("\n curl -u username:password '%s'", url) + errorMsg += "\n 💡 Basic auth failed; trying digest auth..." case http.StatusForbidden: errorMsg += "\n ❌ Access denied (403 Forbidden)" errorMsg += "\n 💡 User may not have permission to download snapshots" @@ -256,15 +273,14 @@ func (c *Client) DownloadFile(ctx context.Context, url string) ([]byte, error) { errorMsg += "\n 💡 Camera may have revoked the URI" errorMsg += "\n 💡 Try getting a fresh snapshot URI" } - + if bodyStr != "" && resp.StatusCode != http.StatusOK { errorMsg += fmt.Sprintf("\n 📝 Response: %s", bodyStr) } - + return nil, fmt.Errorf(errorMsg) } - // Read all data from response body data, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("failed to read response body: %w", err) @@ -272,3 +288,194 @@ func (c *Client) DownloadFile(ctx context.Context, url string) ([]byte, error) { return data, nil } + +// downloadWithDigestAuth performs an HTTP download with Digest authentication +func (c *Client) downloadWithDigestAuth(ctx context.Context, downloadURL string) ([]byte, error) { + if c.username == "" { + return nil, fmt.Errorf("digest auth requires credentials") + } + + // Create a custom transport with digest auth + tr := &http.Transport{ + Dial: (&net.Dialer{ + Timeout: 30 * time.Second, + KeepAlive: 30 * time.Second, + }).Dial, + MaxIdleConns: 10, + MaxIdleConnsPerHost: 5, + IdleConnTimeout: 90 * time.Second, + } + + // Create a custom HTTP client for digest auth + digestClient := &http.Client{ + Transport: &digestAuthTransport{ + transport: tr, + username: c.username, + password: c.password, + }, + Timeout: 30 * time.Second, + } + + req, err := http.NewRequestWithContext(ctx, "GET", downloadURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("User-Agent", "onvif-go-client") + req.Header.Set("Connection", "close") + + resp, err := digestClient.Do(req) + if err != nil { + return nil, fmt.Errorf("digest auth request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + bodyPreview, _ := io.ReadAll(resp.Body) + bodyStr := string(bodyPreview) + if len(bodyStr) > 200 { + bodyStr = bodyStr[:200] + "..." + } + + errorMsg := fmt.Sprintf("download failed with status code %d", resp.StatusCode) + + switch resp.StatusCode { + case http.StatusUnauthorized: + errorMsg += "\n ❌ Digest authentication failed (401 Unauthorized)" + errorMsg += "\n 💡 Check camera credentials (username/password)" + errorMsg += "\n 💡 Try accessing the snapshot URL manually:" + errorMsg += fmt.Sprintf("\n curl --digest -u username:password '%s'", downloadURL) + case http.StatusForbidden: + errorMsg += "\n ❌ Access denied (403 Forbidden)" + errorMsg += "\n 💡 User may not have permission to download snapshots" + case http.StatusNotFound: + errorMsg += "\n ❌ Snapshot URI not found (404)" + errorMsg += "\n 💡 Try getting a fresh snapshot URI" + } + + if bodyStr != "" { + errorMsg += fmt.Sprintf("\n � Response: %s", bodyStr) + } + + return nil, fmt.Errorf(errorMsg) + } + + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + return data, nil +} + +// digestAuthTransport implements digest authentication for HTTP transport +type digestAuthTransport struct { + transport *http.Transport + username string + password string + nc int +} + +// RoundTrip implements http.RoundTripper with digest auth support +func (d *digestAuthTransport) RoundTrip(req *http.Request) (*http.Response, error) { + // First request without auth to get the challenge + resp, err := d.transport.RoundTrip(req) + if err != nil { + return resp, err + } + + // If we get 401, handle digest auth challenge + if resp.StatusCode == http.StatusUnauthorized { + // Read the WWW-Authenticate header + authHeader := resp.Header.Get("WWW-Authenticate") + if strings.Contains(authHeader, "Digest") { + // Parse digest challenge and create auth header + authHeaderValue := d.createDigestAuthHeader(req, authHeader) + + // Create new request with auth header + newReq := req.Clone(req.Context()) + newReq.Header.Set("Authorization", authHeaderValue) + + // Retry with auth + resp, err = d.transport.RoundTrip(newReq) + return resp, err + } + } + + return resp, err +} + +// createDigestAuthHeader creates a digest auth header from the challenge +func (d *digestAuthTransport) createDigestAuthHeader(req *http.Request, authHeader string) string { + // Simple digest auth implementation - parse challenge and create response + // This is a basic implementation that handles most ONVIF cameras + + // Extract digest parameters from WWW-Authenticate header + realm := extractParam(authHeader, "realm") + nonce := extractParam(authHeader, "nonce") + qop := extractParam(authHeader, "qop") + uri := req.URL.Path + if req.URL.RawQuery != "" { + uri += "?" + req.URL.RawQuery + } + + // Generate response hash + ha1 := md5Hash(d.username + ":" + realm + ":" + d.password) + + method := req.Method + ha2 := md5Hash(method + ":" + uri) + + d.nc++ + ncStr := fmt.Sprintf("%08x", d.nc) + cnonce := generateNonce() + + var responseStr string + if qop == "auth" { + responseStr = md5Hash(ha1 + ":" + nonce + ":" + ncStr + ":" + cnonce + ":auth:" + ha2) + } else { + responseStr = md5Hash(ha1 + ":" + nonce + ":" + ha2) + } + + // Build Authorization header + authHeaderValue := fmt.Sprintf(`Digest username="%s", realm="%s", nonce="%s", uri="%s", response="%s"`, + d.username, realm, nonce, uri, responseStr) + + if qop == "auth" { + authHeaderValue += fmt.Sprintf(`, opaque="%s", qop=%s, nc=%s, cnonce="%s"`, + extractParam(authHeader, "opaque"), qop, ncStr, cnonce) + } + + return authHeaderValue +} + +// Helper functions for digest auth +func extractParam(authHeader, param string) string { + prefix := param + `="` + idx := strings.Index(authHeader, prefix) + if idx == -1 { + return "" + } + start := idx + len(prefix) + end := strings.Index(authHeader[start:], `"`) + if end == -1 { + return "" + } + return authHeader[start : start+end] +} + +func md5Hash(s string) string { + return fmt.Sprintf("%x", md5sum(s)) +} + +func md5sum(s string) interface{} { + // Use crypto/md5 - import it if not already present + h := md5.New() + h.Write([]byte(s)) + return h.Sum(nil) +} + +func generateNonce() string { + // Generate a simple nonce + return fmt.Sprintf("%d", time.Now().UnixNano()) +} +