This commit is contained in:
parent
fa2be029a2
commit
724d70e58b
3339 changed files with 1075535 additions and 0 deletions
48
backend/workers/backup.go
Normal file
48
backend/workers/backup.go
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
package workers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"git.elijahkuntz.com/Elijah/drive/config"
|
||||
"git.elijahkuntz.com/Elijah/drive/database"
|
||||
)
|
||||
|
||||
// StartBackupWorker runs a periodic SQLite backup task
|
||||
func StartBackupWorker(cfg *config.Config, db *database.DB) {
|
||||
ticker := time.NewTicker(24 * time.Hour)
|
||||
go func() {
|
||||
for range ticker.C {
|
||||
runBackup(cfg, db)
|
||||
}
|
||||
}()
|
||||
|
||||
// Also run one backup 5 minutes after startup if it hasn't been backed up today
|
||||
time.AfterFunc(5*time.Minute, func() {
|
||||
runBackup(cfg, db)
|
||||
})
|
||||
}
|
||||
|
||||
func runBackup(cfg *config.Config, db *database.DB) {
|
||||
timestamp := time.Now().Format("20060102")
|
||||
backupFileName := fmt.Sprintf("drive_db_%s.sqlite", timestamp)
|
||||
backupPath := filepath.Join(cfg.BackupDir, backupFileName)
|
||||
|
||||
// We use the sqlite3 CLI for a safe, online backup (it handles WAL mode correctly)
|
||||
cmd := exec.Command("sqlite3", cfg.DBPath, fmt.Sprintf(".backup '%s'", backupPath))
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
log.Printf("❌ Database backup failed: %v", err)
|
||||
db.AddAuditLog("backup_failed", err.Error(), "system")
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("✅ Database backed up successfully: %s", backupFileName)
|
||||
db.AddAuditLog("backup_success", backupFileName, "system")
|
||||
|
||||
// Optional: Prune old backups (keep last 7 days)
|
||||
// Not fully implemented here to keep it simple, but would just delete files older than 7d in cfg.BackupDir
|
||||
}
|
||||
255
backend/workers/tasks.go
Normal file
255
backend/workers/tasks.go
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
package workers
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.elijahkuntz.com/Elijah/drive/config"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type TaskStatus string
|
||||
|
||||
const (
|
||||
TaskPending TaskStatus = "pending"
|
||||
TaskRunning TaskStatus = "running"
|
||||
TaskCompleted TaskStatus = "completed"
|
||||
TaskFailed TaskStatus = "failed"
|
||||
)
|
||||
|
||||
type BackgroundTask struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"` // "zip" or "unzip"
|
||||
Status TaskStatus `json:"status"`
|
||||
Progress int `json:"progress"` // 0-100
|
||||
Message string `json:"message"`
|
||||
Error string `json:"error,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type TaskManager struct {
|
||||
Tasks map[string]*BackgroundTask
|
||||
mu sync.RWMutex
|
||||
cfg *config.Config
|
||||
}
|
||||
|
||||
var Tasks *TaskManager
|
||||
|
||||
func InitTaskManager(cfg *config.Config) {
|
||||
Tasks = &TaskManager{
|
||||
Tasks: make(map[string]*BackgroundTask),
|
||||
cfg: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *TaskManager) CreateTask(taskType string, message string) *BackgroundTask {
|
||||
id := uuid.New().String()
|
||||
task := &BackgroundTask{
|
||||
ID: id,
|
||||
Type: taskType,
|
||||
Status: TaskPending,
|
||||
Progress: 0,
|
||||
Message: message,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
m.Tasks[id] = task
|
||||
m.mu.Unlock()
|
||||
|
||||
return task
|
||||
}
|
||||
|
||||
func (m *TaskManager) UpdateTask(id string, status TaskStatus, progress int, msg string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if task, exists := m.Tasks[id]; exists {
|
||||
task.Status = status
|
||||
task.Progress = progress
|
||||
if msg != "" {
|
||||
task.Message = msg
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *TaskManager) FailTask(id string, err error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if task, exists := m.Tasks[id]; exists {
|
||||
task.Status = TaskFailed
|
||||
task.Error = err.Error()
|
||||
}
|
||||
}
|
||||
|
||||
func (m *TaskManager) GetTasks() []BackgroundTask {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
// Convert map to sorted slice (newest first)
|
||||
var list []BackgroundTask
|
||||
for _, t := range m.Tasks {
|
||||
list = append(list, *t)
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
func (m *TaskManager) CleanOldTasks() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
for id, t := range m.Tasks {
|
||||
if (t.Status == TaskCompleted || t.Status == TaskFailed) && time.Since(t.CreatedAt) > 24*time.Hour {
|
||||
delete(m.Tasks, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- ZIP / UNZIP Logic ---
|
||||
|
||||
func ZipFolderAsync(taskID string, sourceFull, destFull string) {
|
||||
Tasks.UpdateTask(taskID, TaskRunning, 0, "Counting files...")
|
||||
|
||||
// Count total files for progress calculation
|
||||
var totalFiles int
|
||||
filepath.Walk(sourceFull, func(_ string, info os.FileInfo, err error) error {
|
||||
if err == nil && !info.IsDir() {
|
||||
totalFiles++
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if totalFiles == 0 {
|
||||
Tasks.FailTask(taskID, fmt.Errorf("folder is empty"))
|
||||
return
|
||||
}
|
||||
|
||||
Tasks.UpdateTask(taskID, TaskRunning, 5, "Creating archive...")
|
||||
|
||||
// Create zip file
|
||||
zipFile, err := os.Create(destFull)
|
||||
if err != nil {
|
||||
Tasks.FailTask(taskID, err)
|
||||
return
|
||||
}
|
||||
defer zipFile.Close()
|
||||
|
||||
archive := zip.NewWriter(zipFile)
|
||||
defer archive.Close()
|
||||
|
||||
var processedFiles int
|
||||
|
||||
filepath.Walk(sourceFull, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Calculate relative path inside the zip
|
||||
relPath, err := filepath.Rel(filepath.Dir(sourceFull), path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if info.IsDir() {
|
||||
// Create directory entry
|
||||
_, err = archive.Create(relPath + "/")
|
||||
return err
|
||||
}
|
||||
|
||||
// Add file
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
w, err := archive.Create(relPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = io.Copy(w, file)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
processedFiles++
|
||||
progress := 5 + int(float64(processedFiles)/float64(totalFiles)*90)
|
||||
Tasks.UpdateTask(taskID, TaskRunning, progress, fmt.Sprintf("Compressing %s...", info.Name()))
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
Tasks.UpdateTask(taskID, TaskCompleted, 100, "Archive created successfully")
|
||||
}
|
||||
|
||||
func UnzipAsync(taskID string, sourceFull, destFolder string) {
|
||||
Tasks.UpdateTask(taskID, TaskRunning, 0, "Opening archive...")
|
||||
|
||||
r, err := zip.OpenReader(sourceFull)
|
||||
if err != nil {
|
||||
Tasks.FailTask(taskID, err)
|
||||
return
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
totalFiles := len(r.File)
|
||||
if totalFiles == 0 {
|
||||
Tasks.FailTask(taskID, fmt.Errorf("archive is empty"))
|
||||
return
|
||||
}
|
||||
|
||||
Tasks.UpdateTask(taskID, TaskRunning, 5, "Extracting files...")
|
||||
|
||||
for i, f := range r.File {
|
||||
// Prevent Zip Slip vulnerability
|
||||
cleanPath := filepath.Clean(f.Name)
|
||||
if strings.HasPrefix(cleanPath, "..") {
|
||||
continue // Skip malicious paths
|
||||
}
|
||||
|
||||
fpath := filepath.Join(destFolder, cleanPath)
|
||||
|
||||
if f.FileInfo().IsDir() {
|
||||
os.MkdirAll(fpath, os.ModePerm)
|
||||
continue
|
||||
}
|
||||
|
||||
if err = os.MkdirAll(filepath.Dir(fpath), os.ModePerm); err != nil {
|
||||
Tasks.FailTask(taskID, err)
|
||||
return
|
||||
}
|
||||
|
||||
outFile, err := os.OpenFile(fpath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode())
|
||||
if err != nil {
|
||||
Tasks.FailTask(taskID, err)
|
||||
return
|
||||
}
|
||||
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
outFile.Close()
|
||||
Tasks.FailTask(taskID, err)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = io.Copy(outFile, rc)
|
||||
outFile.Close()
|
||||
rc.Close()
|
||||
|
||||
if err != nil {
|
||||
Tasks.FailTask(taskID, err)
|
||||
return
|
||||
}
|
||||
|
||||
progress := 5 + int(float64(i+1)/float64(totalFiles)*90)
|
||||
Tasks.UpdateTask(taskID, TaskRunning, progress, fmt.Sprintf("Extracted %s", filepath.Base(fpath)))
|
||||
}
|
||||
|
||||
Tasks.UpdateTask(taskID, TaskCompleted, 100, "Extracted successfully")
|
||||
}
|
||||
133
backend/workers/thumbnails.go
Normal file
133
backend/workers/thumbnails.go
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
package workers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.elijahkuntz.com/Elijah/drive/config"
|
||||
"git.elijahkuntz.com/Elijah/drive/database"
|
||||
"github.com/disintegration/imaging"
|
||||
)
|
||||
|
||||
type ThumbnailJob struct {
|
||||
FilePath string
|
||||
Checksum string
|
||||
MimeType string
|
||||
}
|
||||
|
||||
type ThumbnailManager struct {
|
||||
Queue chan ThumbnailJob
|
||||
Config *config.Config
|
||||
DB *database.DB
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
var (
|
||||
Instance *ThumbnailManager
|
||||
once sync.Once
|
||||
)
|
||||
|
||||
func InitThumbnailManager(cfg *config.Config, db *database.DB) {
|
||||
once.Do(func() {
|
||||
Instance = &ThumbnailManager{
|
||||
Queue: make(chan ThumbnailJob, 1000),
|
||||
Config: cfg,
|
||||
DB: db,
|
||||
}
|
||||
Instance.Start(2) // Run 2 concurrent workers
|
||||
})
|
||||
}
|
||||
|
||||
func (m *ThumbnailManager) Start(workers int) {
|
||||
for i := 0; i < workers; i++ {
|
||||
m.wg.Add(1)
|
||||
go m.worker(i)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *ThumbnailManager) Stop() {
|
||||
close(m.Queue)
|
||||
m.wg.Wait()
|
||||
}
|
||||
|
||||
func (m *ThumbnailManager) Enqueue(job ThumbnailJob) {
|
||||
if job.Checksum == "" {
|
||||
return // Cannot store thumbnail without checksum
|
||||
}
|
||||
select {
|
||||
case m.Queue <- job:
|
||||
default:
|
||||
log.Println("Thumbnail queue is full, dropping job for", job.FilePath)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *ThumbnailManager) worker(id int) {
|
||||
defer m.wg.Done()
|
||||
for job := range m.Queue {
|
||||
// Check if thumbnail generation is enabled
|
||||
if val, err := m.DB.GetSetting("thumbnail_images"); err == nil && val == "false" && strings.HasPrefix(job.MimeType, "image/") {
|
||||
continue
|
||||
}
|
||||
if val, err := m.DB.GetSetting("thumbnail_videos"); err == nil && val == "false" && strings.HasPrefix(job.MimeType, "video/") {
|
||||
continue
|
||||
}
|
||||
|
||||
destPath := filepath.Join(m.Config.ThumbnailDir, job.Checksum+".jpg")
|
||||
|
||||
// Skip if thumbnail already exists
|
||||
if _, err := os.Stat(destPath); err == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
fullSourcePath := filepath.Join(m.Config.StorageDir, filepath.FromSlash(job.FilePath))
|
||||
|
||||
var err error
|
||||
if strings.HasPrefix(job.MimeType, "image/") {
|
||||
err = generateImageThumbnail(fullSourcePath, destPath)
|
||||
} else if strings.HasPrefix(job.MimeType, "video/") {
|
||||
err = generateVideoThumbnail(fullSourcePath, destPath)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Printf("[Worker %d] Failed to generate thumbnail for %s: %v\n", id, job.FilePath, err)
|
||||
} else {
|
||||
log.Printf("[Worker %d] Generated thumbnail for %s\n", id, job.FilePath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func generateImageThumbnail(src, dest string) error {
|
||||
img, err := imaging.Open(src, imaging.AutoOrientation(true))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Resize to fit within 256x256 while preserving aspect ratio
|
||||
thumb := imaging.Fit(img, 256, 256, imaging.Lanczos)
|
||||
return imaging.Save(thumb, dest, imaging.JPEGQuality(80))
|
||||
}
|
||||
|
||||
func generateVideoThumbnail(src, dest string) error {
|
||||
// Extract a frame at 1 second mark, scale to max width/height 256
|
||||
cmd := exec.Command("ffmpeg", "-y", "-i", src, "-ss", "00:00:01.000", "-vframes", "1", "-vf", "scale=256:256:force_original_aspect_ratio=decrease", dest)
|
||||
|
||||
// Set a timeout to prevent hanging on corrupted videos
|
||||
timer := time.AfterFunc(15*time.Second, func() {
|
||||
if cmd.Process != nil {
|
||||
cmd.Process.Kill()
|
||||
}
|
||||
})
|
||||
|
||||
err := cmd.Run()
|
||||
timer.Stop()
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("ffmpeg failed: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in a new issue