package archiver import ( "context" "crypto/sha256" "encoding/hex" "fmt" "io" "net/http" "path/filepath" "time" "git.nakama.town/fmartingr/hako/internal/model" "git.nakama.town/fmartingr/hako/internal/storage" ) // DirectDownloadArchiver implements direct HTTP download archival type DirectDownloadArchiver struct { httpClient *http.Client } // NewDirectDownloadExtractor creates a new DirectDownloadArchiver func NewDirectDownloadExtractor() *DirectDownloadArchiver { return &DirectDownloadArchiver{ httpClient: &http.Client{ Timeout: 5 * time.Minute, }, } } // Key returns the unique identifier for this archiver func (a *DirectDownloadArchiver) Key() string { return "direct_download" } // Name returns the human-readable name for this archiver func (a *DirectDownloadArchiver) Name() string { return "Direct Download" } // IsEnabled checks if this archiver is available func (a *DirectDownloadArchiver) IsEnabled(ctx context.Context) bool { // Direct download is always enabled return true } // Init initializes the archiver func (a *DirectDownloadArchiver) Init() error { // No initialization needed return nil } // GetDefaultConfig returns the default configuration func (a *DirectDownloadArchiver) GetDefaultConfig() any { return map[string]any{ "timeout": "5m", } } // ApplyConfig applies the provided configuration to the archiver func (a *DirectDownloadArchiver) ApplyConfig(config map[string]any) error { // Parse timeout duration if timeoutStr, ok := config["timeout"].(string); ok { timeout, err := time.ParseDuration(timeoutStr) if err != nil { return fmt.Errorf("invalid timeout format: %w", err) } a.httpClient.Timeout = timeout } return nil } // Archive performs the direct download archival func (a *DirectDownloadArchiver) Archive(ctx context.Context, link *model.Link, stor storage.Storage) (*ArchiveResult, error) { // Create HTTP request req, err := http.NewRequestWithContext(ctx, http.MethodGet, link.URL, nil) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } // Set a user agent req.Header.Set("User-Agent", "Hako/1.0") // Perform the download resp, err := a.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to download: %w", err) } defer func() { _ = resp.Body.Close() }() // Check status code if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) } // Detect content type contentType := resp.Header.Get("Content-Type") if contentType == "" { contentType = "application/octet-stream" } // Generate filename based on URL filename := filepath.Base(link.URL) if filename == "." || filename == "/" || filename == "" { filename = "download" } // Add extension based on content type if not present if filepath.Ext(filename) == "" { ext := getExtensionFromContentType(contentType) if ext != "" { filename += ext } } // Create a pipe to calculate hash while saving pipeReader, pipeWriter := io.Pipe() hash := sha256.New() multiWriter := io.MultiWriter(pipeWriter, hash) // Channel for errors from the goroutine errChan := make(chan error, 1) var path string var fileSize int64 // Copy to storage in goroutine go func() { defer func() { _ = pipeWriter.Close() }() if _, err := io.Copy(multiWriter, resp.Body); err != nil { errChan <- fmt.Errorf("failed to copy data: %w", err) return } errChan <- nil }() // Save to storage path, err = stor.Save(ctx, link.ID, filename, pipeReader) if err != nil { return nil, fmt.Errorf("failed to save file: %w", err) } // Check for copy errors if copyErr := <-errChan; copyErr != nil { return nil, copyErr } // Get file size if resp.ContentLength > 0 { fileSize = resp.ContentLength } // Calculate hash hashStr := hex.EncodeToString(hash.Sum(nil)) // Use filename as title (without extension for cleaner display) title := filename if ext := filepath.Ext(filename); ext != "" { title = filename[:len(filename)-len(ext)] } // Fallback to URL if filename is not meaningful if title == "" || title == "." || title == "/" || title == "download" { title = link.URL } result := &ArchiveResult{ Title: title, Files: []FileInfo{ { Filename: filename, MimeType: contentType, FileSize: fileSize, HashSha256: hashStr, Path: path, }, }, } return result, nil } // getExtensionFromContentType returns a file extension based on content type func getExtensionFromContentType(contentType string) string { // Simple mapping of common content types to extensions types := map[string]string{ "text/html": ".html", "text/plain": ".txt", "application/pdf": ".pdf", "image/jpeg": ".jpg", "image/png": ".png", "image/gif": ".gif", "image/webp": ".webp", "application/json": ".json", "application/xml": ".xml", "application/zip": ".zip", "application/octet-stream": ".bin", } if ext, ok := types[contentType]; ok { return ext } return "" }