package archiver import ( "bytes" "context" "crypto/sha256" "encoding/hex" "fmt" "io" "net/http" "strings" "time" "git.nakama.town/fmartingr/hako/internal/model" "git.nakama.town/fmartingr/hako/internal/storage" ) // ThumbnailArchiver implements thumbnail extraction from URLs type ThumbnailArchiver struct { httpClient *http.Client resize bool } // NewThumbnailExtractor creates a new ThumbnailArchiver func NewThumbnailExtractor() *ThumbnailArchiver { return &ThumbnailArchiver{ httpClient: &http.Client{ Timeout: 5 * time.Minute, }, } } // Key returns the unique identifier for this archiver func (e *ThumbnailArchiver) Key() string { return "thumbnail" } // Name returns the human-readable name for this archiver func (e *ThumbnailArchiver) Name() string { return "Thumbnail" } // IsEnabled checks if this archiver is available func (e *ThumbnailArchiver) IsEnabled(ctx context.Context) bool { // Thumbnail archiver is always enabled return true } // Init initializes the archiver func (e *ThumbnailArchiver) Init() error { // No initialization needed return nil } // GetDefaultConfig returns the default configuration func (e *ThumbnailArchiver) GetDefaultConfig() any { return map[string]any{ "timeout": "5m", "resize": false, } } // ApplyConfig applies the provided configuration to the archiver func (e *ThumbnailArchiver) 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) } e.httpClient.Timeout = timeout } // Parse resize setting if resizeVal, ok := config["resize"]; ok { if resize, ok := resizeVal.(bool); ok { e.resize = resize } else { return fmt.Errorf("invalid resize format: expected boolean") } } return nil } // Archive performs the thumbnail extraction func (e *ThumbnailArchiver) Archive(ctx context.Context, link *model.Link, stor storage.Storage) (*ArchiveResult, error) { // Fetch the URL to get HTML req, err := http.NewRequestWithContext(ctx, http.MethodGet, link.URL, nil) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } req.Header.Set("User-Agent", "Hako/1.0") resp, err := e.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to fetch URL: %w", err) } defer func() { _ = resp.Body.Close() }() // Check if it's HTML content contentType := resp.Header.Get("Content-Type") if !strings.HasPrefix(contentType, "text/html") { // Not HTML, skip thumbnail extraction return &ArchiveResult{ Title: "", Files: []FileInfo{}, }, nil } // Read HTML content htmlBytes, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("failed to read HTML: %w", err) } // Extract thumbnail from HTML thumbnailResult, err := extractThumbnailFromHTML(ctx, htmlBytes, link.URL, e.resize) if err != nil || thumbnailResult == nil { // No thumbnail found or error - return empty result (not an error) return &ArchiveResult{ Title: "", Files: []FileInfo{}, }, nil } // 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() }() reader := bytes.NewReader(thumbnailResult.ImageBytes) if _, err := io.Copy(multiWriter, reader); err != nil { errChan <- fmt.Errorf("failed to copy data: %w", err) return } errChan <- nil }() // Save to storage path, err = stor.Save(ctx, link.ID, thumbnailResult.Filename, pipeReader) if err != nil { return nil, fmt.Errorf("failed to save thumbnail: %w", err) } // Check for copy errors if copyErr := <-errChan; copyErr != nil { return nil, copyErr } // Get file size fileSize = int64(len(thumbnailResult.ImageBytes)) // Calculate hash hashStr := hex.EncodeToString(hash.Sum(nil)) // Create MIME type with +thumbnail suffix mimeType := thumbnailResult.MimeType + "+thumbnail" result := &ArchiveResult{ Title: "", Files: []FileInfo{ { Filename: thumbnailResult.Filename, MimeType: mimeType, FileSize: fileSize, HashSha256: hashStr, Path: path, }, }, } return result, nil }