picoclaw/pkg/utils/media.go
KoheiYamashita c267db36a6 style: fix goimports formatting across codebase
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 19:04:43 +09:00

178 lines
4.5 KiB
Go

package utils
import (
"encoding/base64"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/KarakuriAgent/clawdroid/pkg/logger"
"github.com/google/uuid"
)
const maxImageFileSize = 50 * 1024 * 1024 // 50MB
// EncodeFileToDataURL reads a local file and returns a base64 data URL string.
// Supported: JPEG, PNG, WEBP, GIF. Max 50MB.
// Returns empty string on error.
func EncodeFileToDataURL(path string) string {
ext := strings.ToLower(filepath.Ext(path))
var mime string
switch ext {
case ".jpg", ".jpeg":
mime = "image/jpeg"
case ".png":
mime = "image/png"
case ".webp":
mime = "image/webp"
case ".gif":
mime = "image/gif"
default:
logger.WarnCF("media", "Unsupported image extension", map[string]interface{}{
"path": path,
"ext": ext,
})
return ""
}
info, err := os.Stat(path)
if err != nil {
logger.ErrorCF("media", "Failed to stat image file", map[string]interface{}{
"path": path,
"error": err.Error(),
})
return ""
}
if info.Size() > maxImageFileSize {
logger.WarnCF("media", "Image file too large, skipping", map[string]interface{}{
"path": path,
"size_mb": info.Size() / (1024 * 1024),
"max_mb": maxImageFileSize / (1024 * 1024),
})
return ""
}
data, err := os.ReadFile(path)
if err != nil {
logger.ErrorCF("media", "Failed to read image file", map[string]interface{}{
"path": path,
"error": err.Error(),
})
return ""
}
encoded := base64.StdEncoding.EncodeToString(data)
return "data:" + mime + ";base64," + encoded
}
// SanitizeFilename removes potentially dangerous characters from a filename
// and returns a safe version for local filesystem storage.
func SanitizeFilename(filename string) string {
// Get the base filename without path
base := filepath.Base(filename)
// Remove any directory traversal attempts
base = strings.ReplaceAll(base, "..", "")
base = strings.ReplaceAll(base, "/", "_")
base = strings.ReplaceAll(base, "\\", "_")
return base
}
// DownloadOptions holds optional parameters for downloading files
type DownloadOptions struct {
Timeout time.Duration
ExtraHeaders map[string]string
LoggerPrefix string
}
// DownloadFile downloads a file from URL to a local temp directory.
// Returns the local file path or empty string on error.
func DownloadFile(url, filename string, opts DownloadOptions) string {
// Set defaults
if opts.Timeout == 0 {
opts.Timeout = 60 * time.Second
}
if opts.LoggerPrefix == "" {
opts.LoggerPrefix = "utils"
}
mediaDir := filepath.Join(os.TempDir(), "clawdroid_media")
if err := os.MkdirAll(mediaDir, 0700); err != nil {
logger.ErrorCF(opts.LoggerPrefix, "Failed to create media directory", map[string]interface{}{
"error": err.Error(),
})
return ""
}
// Generate unique filename with UUID prefix to prevent conflicts
safeName := SanitizeFilename(filename)
localPath := filepath.Join(mediaDir, uuid.New().String()[:8]+"_"+safeName)
// Create HTTP request
req, err := http.NewRequest("GET", url, nil)
if err != nil {
logger.ErrorCF(opts.LoggerPrefix, "Failed to create download request", map[string]interface{}{
"error": err.Error(),
})
return ""
}
// Add extra headers (e.g., Authorization for Slack)
for key, value := range opts.ExtraHeaders {
req.Header.Set(key, value)
}
client := &http.Client{Timeout: opts.Timeout}
resp, err := client.Do(req)
if err != nil {
logger.ErrorCF(opts.LoggerPrefix, "Failed to download file", map[string]interface{}{
"error": err.Error(),
"url": url,
})
return ""
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
logger.ErrorCF(opts.LoggerPrefix, "File download returned non-200 status", map[string]interface{}{
"status": resp.StatusCode,
"url": url,
})
return ""
}
out, err := os.Create(localPath)
if err != nil {
logger.ErrorCF(opts.LoggerPrefix, "Failed to create local file", map[string]interface{}{
"error": err.Error(),
})
return ""
}
defer out.Close()
if _, err := io.Copy(out, resp.Body); err != nil {
out.Close()
os.Remove(localPath)
logger.ErrorCF(opts.LoggerPrefix, "Failed to write file", map[string]interface{}{
"error": err.Error(),
})
return ""
}
logger.DebugCF(opts.LoggerPrefix, "File downloaded successfully", map[string]interface{}{
"path": localPath,
})
return localPath
}
// DownloadFileSimple is a simplified version of DownloadFile without options
func DownloadFileSimple(url, filename string) string {
return DownloadFile(url, filename, DownloadOptions{
LoggerPrefix: "media",
})
}