hako/internal/archival/archiver/direct_download.go
2026-01-12 19:35:18 +01:00

203 lines
5 KiB
Go

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 ""
}