Inital build
Some checks failed
Automated Container Build / build-and-push (push) Failing after 12s

This commit is contained in:
Elijah 2026-05-22 12:29:43 -07:00
parent fa2be029a2
commit 724d70e58b
3339 changed files with 1075535 additions and 0 deletions

44
backend/Dockerfile Normal file
View file

@ -0,0 +1,44 @@
# Build stage
FROM golang:1.23-alpine AS builder
RUN apk add --no-cache gcc musl-dev sqlite-dev git
WORKDIR /build
COPY go.mod ./
# Copy everything and generate go.sum inside the container
COPY . .
RUN go mod tidy
RUN go mod download
RUN CGO_ENABLED=1 GOOS=linux go build -tags sqlite_fts5 -ldflags="-s -w" -o /build/drive ./main.go
# Runtime stage
FROM alpine:3.21
RUN apk add --no-cache \
ffmpeg \
sqlite \
ca-certificates \
tzdata
# Create non-root user
RUN addgroup -S drive && adduser -S drive -G drive
WORKDIR /app
COPY --from=builder /build/drive /app/drive
# Create default directories
RUN mkdir -p /app/data /storage && \
chown -R drive:drive /app /app/data /storage
USER drive
EXPOSE 5827
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget -qO- http://127.0.0.1:5827/health || exit 1
ENTRYPOINT ["/app/drive"]

57
backend/config/config.go Normal file
View file

@ -0,0 +1,57 @@
package config
import (
"os"
"strconv"
)
type Config struct {
Port string
DataDir string
StorageDir string
JWTSecret string
JWTExpirySecs int
DBPath string
ThumbnailDir string
TrashDir string
VersionsDir string
BackupDir string
MaxLoginAttempts int
LockoutSeconds int
}
func Load() *Config {
cfg := &Config{
Port: getEnv("PORT", "5827"),
DataDir: getEnv("DATA_DIR", "/app/data"),
StorageDir: getEnv("STORAGE_DIR", "/storage"),
JWTSecret: getEnv("JWT_SECRET", ""),
JWTExpirySecs: getEnvInt("JWT_EXPIRY_SECS", 900), // 15 minutes
MaxLoginAttempts: getEnvInt("MAX_LOGIN_ATTEMPTS", 5),
LockoutSeconds: getEnvInt("LOCKOUT_SECONDS", 300), // 5 minutes
}
cfg.DBPath = cfg.DataDir + "/drive.db"
cfg.ThumbnailDir = cfg.DataDir + "/thumbnails"
cfg.TrashDir = cfg.StorageDir + "/.trash"
cfg.VersionsDir = cfg.StorageDir + "/.versions"
cfg.BackupDir = cfg.StorageDir + "/.backups"
return cfg
}
func getEnv(key, fallback string) string {
if value, ok := os.LookupEnv(key); ok {
return value
}
return fallback
}
func getEnvInt(key string, fallback int) int {
if value, ok := os.LookupEnv(key); ok {
if intVal, err := strconv.Atoi(value); err == nil {
return intVal
}
}
return fallback
}

View file

@ -0,0 +1,186 @@
package database
import (
"database/sql"
"fmt"
"os"
"path/filepath"
_ "github.com/mattn/go-sqlite3"
)
type DB struct {
*sql.DB
}
// New opens (or creates) the SQLite database, enables WAL mode, and runs migrations.
func New(dbPath string) (*DB, error) {
dir := filepath.Dir(dbPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return nil, fmt.Errorf("create db directory: %w", err)
}
db, err := sql.Open("sqlite3", dbPath+"?_journal_mode=WAL&_busy_timeout=5000&_foreign_keys=on")
if err != nil {
return nil, fmt.Errorf("open database: %w", err)
}
if err := db.Ping(); err != nil {
return nil, fmt.Errorf("ping database: %w", err)
}
// Enforce WAL mode explicitly
if _, err := db.Exec("PRAGMA journal_mode=WAL"); err != nil {
return nil, fmt.Errorf("set WAL mode: %w", err)
}
wrapped := &DB{db}
if err := wrapped.migrate(); err != nil {
return nil, fmt.Errorf("run migrations: %w", err)
}
return wrapped, nil
}
func (db *DB) migrate() error {
migrations := []string{
// Users table single user now, designed for future multi-user
`CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
totp_secret TEXT DEFAULT '',
totp_enabled INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`,
// Files metadata table
`CREATE TABLE IF NOT EXISTS files (
id INTEGER PRIMARY KEY AUTOINCREMENT,
path TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
is_dir INTEGER DEFAULT 0,
size INTEGER DEFAULT 0,
mime_type TEXT DEFAULT '',
checksum TEXT DEFAULT '',
is_pinned INTEGER DEFAULT 0,
is_trashed INTEGER DEFAULT 0,
trashed_at DATETIME,
original_path TEXT DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`,
// Index for fast trash and pin queries
`CREATE INDEX IF NOT EXISTS idx_files_trashed ON files(is_trashed)`,
`CREATE INDEX IF NOT EXISTS idx_files_pinned ON files(is_pinned)`,
`CREATE INDEX IF NOT EXISTS idx_files_path ON files(path)`,
// File versions table
`CREATE TABLE IF NOT EXISTS file_versions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
file_path TEXT NOT NULL,
version_number INTEGER NOT NULL,
version_path TEXT NOT NULL,
size INTEGER DEFAULT 0,
checksum TEXT DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`,
// Share links table
`CREATE TABLE IF NOT EXISTS share_links (
id INTEGER PRIMARY KEY AUTOINCREMENT,
token TEXT NOT NULL UNIQUE,
target_path TEXT NOT NULL,
is_dir INTEGER DEFAULT 0,
password_hash TEXT DEFAULT '',
expires_at DATETIME,
max_downloads INTEGER DEFAULT 0,
download_count INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE INDEX IF NOT EXISTS idx_share_links_token ON share_links(token)`,
// Audit log table
`CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
action TEXT NOT NULL,
details TEXT DEFAULT '',
ip_address TEXT DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`,
// Settings table (key-value)
`CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT DEFAULT ''
)`,
// FTS5 virtual table for file search
`CREATE VIRTUAL TABLE IF NOT EXISTS files_fts USING fts5(
name,
path,
content=files,
content_rowid=id
)`,
// Triggers to keep FTS5 in sync with the files table
`CREATE TRIGGER IF NOT EXISTS files_ai AFTER INSERT ON files BEGIN
INSERT INTO files_fts(rowid, name, path) VALUES (new.id, new.name, new.path);
END`,
`CREATE TRIGGER IF NOT EXISTS files_ad AFTER DELETE ON files BEGIN
INSERT INTO files_fts(files_fts, rowid, name, path) VALUES('delete', old.id, old.name, old.path);
END`,
`CREATE TRIGGER IF NOT EXISTS files_au AFTER UPDATE ON files BEGIN
INSERT INTO files_fts(files_fts, rowid, name, path) VALUES('delete', old.id, old.name, old.path);
INSERT INTO files_fts(rowid, name, path) VALUES (new.id, new.name, new.path);
END`,
// Default settings
`INSERT OR IGNORE INTO settings (key, value) VALUES ('theme', 'dark')`,
`INSERT OR IGNORE INTO settings (key, value) VALUES ('thumbnail_images', 'true')`,
`INSERT OR IGNORE INTO settings (key, value) VALUES ('thumbnail_videos', 'true')`,
`INSERT OR IGNORE INTO settings (key, value) VALUES ('max_versions', '5')`,
`INSERT OR IGNORE INTO settings (key, value) VALUES ('trash_auto_purge_days', '30')`,
}
for _, m := range migrations {
if _, err := db.Exec(m); err != nil {
return fmt.Errorf("migration error: %w\nSQL: %s", err, m)
}
}
return nil
}
// IsSetupComplete checks if any user account exists.
func (db *DB) IsSetupComplete() (bool, error) {
var count int
err := db.QueryRow("SELECT COUNT(*) FROM users").Scan(&count)
if err != nil {
return false, err
}
return count > 0, nil
}
// GetSetting retrieves a setting value by key.
func (db *DB) GetSetting(key string) (string, error) {
var value string
err := db.QueryRow("SELECT value FROM settings WHERE key = ?", key).Scan(&value)
return value, err
}
// SetSetting upserts a setting.
func (db *DB) SetSetting(key, value string) error {
_, err := db.Exec("INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value", key, value)
return err
}
// AddAuditLog appends an entry to the audit log.
func (db *DB) AddAuditLog(action, details, ip string) error {
_, err := db.Exec("INSERT INTO audit_log (action, details, ip_address) VALUES (?, ?, ?)", action, details, ip)
return err
}

15
backend/go.mod Normal file
View file

@ -0,0 +1,15 @@
module git.elijahkuntz.com/Elijah/drive
go 1.23
require (
github.com/gofiber/fiber/v2 v2.52.6
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/mattn/go-sqlite3 v1.14.24
github.com/pquerna/otp v1.4.0
github.com/disintegration/imaging v1.6.2
github.com/tus/tusd/v2 v2.7.1
golang.org/x/crypto v0.31.0
golang.org/x/net v0.33.0
)

View file

@ -0,0 +1,151 @@
package handlers
import (
"fmt"
"os"
"path/filepath"
"strings"
"git.elijahkuntz.com/Elijah/drive/config"
"git.elijahkuntz.com/Elijah/drive/database"
"git.elijahkuntz.com/Elijah/drive/workers"
"github.com/gofiber/fiber/v2"
)
type ArchiveHandler struct {
DB *database.DB
Config *config.Config
}
// Zip creates a zip archive of a folder.
// POST /api/files/zip
func (h *ArchiveHandler) Zip(c *fiber.Ctx) error {
var body struct {
SourcePath string `json:"source_path"`
DestPath string `json:"dest_path"`
}
if err := c.BodyParser(&body); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"})
}
srcFull, err := resolveSafe(h.Config.StorageDir, body.SourcePath)
if err != nil {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": err.Error()})
}
info, err := os.Stat(srcFull)
if err != nil || !info.IsDir() {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "source must be a valid directory"})
}
// Default dest path if not provided
if body.DestPath == "" {
body.DestPath = filepath.ToSlash(filepath.Clean(body.SourcePath)) + ".zip"
}
dstFull, err := resolveSafe(h.Config.StorageDir, body.DestPath)
if err != nil {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": err.Error()})
}
task := workers.Tasks.CreateTask("zip", "Starting zip process...")
// Start async worker
go func() {
workers.ZipFolderAsync(task.ID, srcFull, dstFull)
// Sync to DB when complete
h.syncArchiveToDB(body.DestPath)
}()
h.DB.AddAuditLog("zip_started", fmt.Sprintf("%s -> %s", body.SourcePath, body.DestPath), c.IP())
return c.JSON(fiber.Map{"message": "Zip process started", "task_id": task.ID})
}
// Unzip extracts a zip archive to a folder.
// POST /api/files/unzip
func (h *ArchiveHandler) Unzip(c *fiber.Ctx) error {
var body struct {
SourcePath string `json:"source_path"`
DestPath string `json:"dest_path"` // Folder to extract into
}
if err := c.BodyParser(&body); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"})
}
srcFull, err := resolveSafe(h.Config.StorageDir, body.SourcePath)
if err != nil {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": err.Error()})
}
// Default dest folder
if body.DestPath == "" {
body.DestPath = strings.TrimSuffix(body.SourcePath, filepath.Ext(body.SourcePath))
}
dstFull, err := resolveSafe(h.Config.StorageDir, body.DestPath)
if err != nil {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": err.Error()})
}
task := workers.Tasks.CreateTask("unzip", "Starting unzip process...")
// Start async worker
go func() {
workers.UnzipAsync(task.ID, srcFull, dstFull)
// Trigger a DB resync for the extracted folder (lightweight for now)
h.syncArchiveToDB(body.DestPath)
}()
h.DB.AddAuditLog("unzip_started", fmt.Sprintf("%s -> %s", body.SourcePath, body.DestPath), c.IP())
return c.JSON(fiber.Map{"message": "Unzip process started", "task_id": task.ID})
}
// Tasks returns the list of active background tasks.
// GET /api/tasks
func (h *ArchiveHandler) Tasks(c *fiber.Ctx) error {
tasks := workers.Tasks.GetTasks()
return c.JSON(fiber.Map{"tasks": tasks})
}
// syncArchiveToDB records the output of the zip/unzip operation.
func (h *ArchiveHandler) syncArchiveToDB(relPath string) {
fullPath := filepath.Join(h.Config.StorageDir, filepath.FromSlash(relPath))
info, err := os.Stat(fullPath)
if err != nil {
return
}
cleanPath := filepath.ToSlash(relPath)
// Just insert the top-level record, deeper sync isn't strictly needed
// unless FTS is required immediately for all extracted contents.
h.DB.Exec(`
INSERT INTO files (path, name, size, is_dir)
VALUES (?, ?, ?, ?)
ON CONFLICT(path) DO UPDATE SET
size = excluded.size,
updated_at = CURRENT_TIMESTAMP
`, cleanPath, filepath.Base(cleanPath), info.Size(), info.IsDir())
}
func resolveSafe(root, rel string) (string, error) {
cleaned := filepath.Clean(rel)
if strings.Contains(cleaned, "..") {
return "", fmt.Errorf("path traversal detected")
}
full := filepath.Join(root, cleaned)
abs, err := filepath.Abs(full)
if err != nil {
return "", fmt.Errorf("invalid path")
}
absRoot, _ := filepath.Abs(root)
if abs != absRoot && !strings.HasPrefix(abs, absRoot+string(filepath.Separator)) {
return "", fmt.Errorf("access denied")
}
return abs, nil
}

315
backend/handlers/auth.go Normal file
View file

@ -0,0 +1,315 @@
package handlers
import (
"crypto/rand"
"encoding/base64"
"fmt"
"git.elijahkuntz.com/Elijah/drive/config"
"git.elijahkuntz.com/Elijah/drive/database"
"git.elijahkuntz.com/Elijah/drive/middleware"
"github.com/gofiber/fiber/v2"
"github.com/pquerna/otp/totp"
"golang.org/x/crypto/argon2"
)
type AuthHandler struct {
DB *database.DB
Config *config.Config
Guard *middleware.BruteForceGuard
}
type SetupRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
type LoginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
TOTPCode string `json:"totp_code,omitempty"`
}
// Setup handles the first-run account creation wizard.
// POST /api/setup
func (h *AuthHandler) Setup(c *fiber.Ctx) error {
// Check if setup is already complete
complete, err := h.DB.IsSetupComplete()
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "database error"})
}
if complete {
return c.Status(fiber.StatusConflict).JSON(fiber.Map{"error": "setup already completed"})
}
var req SetupRequest
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"})
}
if len(req.Username) < 3 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "username must be at least 3 characters"})
}
if len(req.Password) < 8 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "password must be at least 8 characters"})
}
hash, err := hashPassword(req.Password)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to hash password"})
}
_, err = h.DB.Exec(
"INSERT INTO users (username, password_hash) VALUES (?, ?)",
req.Username, hash,
)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to create user"})
}
h.DB.AddAuditLog("setup_complete", fmt.Sprintf("User '%s' created", req.Username), c.IP())
return c.Status(fiber.StatusCreated).JSON(fiber.Map{
"message": "setup complete, please log in",
})
}
// CheckSetup returns whether initial setup has been completed.
// GET /api/setup/status
func (h *AuthHandler) CheckSetup(c *fiber.Ctx) error {
complete, err := h.DB.IsSetupComplete()
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "database error"})
}
return c.JSON(fiber.Map{"setup_complete": complete})
}
// Login authenticates a user and returns JWT tokens.
// POST /api/auth/login
func (h *AuthHandler) Login(c *fiber.Ctx) error {
var req LoginRequest
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"})
}
var id int
var passwordHash, totpSecret string
var totpEnabled bool
err := h.DB.QueryRow(
"SELECT id, password_hash, totp_secret, totp_enabled FROM users WHERE username = ?",
req.Username,
).Scan(&id, &passwordHash, &totpSecret, &totpEnabled)
if err != nil {
h.Guard.RecordFailure(c.IP())
h.DB.AddAuditLog("login_failed", fmt.Sprintf("Unknown user '%s'", req.Username), c.IP())
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "invalid credentials"})
}
if !verifyPassword(req.Password, passwordHash) {
h.Guard.RecordFailure(c.IP())
h.DB.AddAuditLog("login_failed", fmt.Sprintf("Bad password for '%s'", req.Username), c.IP())
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "invalid credentials"})
}
// Check TOTP if enabled
if totpEnabled {
if req.TOTPCode == "" {
return c.Status(fiber.StatusPreconditionRequired).JSON(fiber.Map{
"error": "2fa_required",
"message": "Two-factor authentication code required",
"totp_required": true,
})
}
if !totp.Validate(req.TOTPCode, totpSecret) {
h.Guard.RecordFailure(c.IP())
h.DB.AddAuditLog("login_failed", fmt.Sprintf("Bad TOTP for '%s'", req.Username), c.IP())
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "invalid 2FA code"})
}
}
// Generate tokens
accessToken, err := middleware.GenerateAccessToken(id, req.Username, h.Config.JWTSecret, h.Config.JWTExpirySecs)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to generate token"})
}
refreshToken, err := middleware.GenerateRefreshToken(id, req.Username, h.Config.JWTSecret)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to generate refresh token"})
}
h.Guard.RecordSuccess(c.IP())
h.DB.AddAuditLog("login_success", fmt.Sprintf("User '%s' logged in", req.Username), c.IP())
return c.JSON(fiber.Map{
"access_token": accessToken,
"refresh_token": refreshToken,
"expires_in": h.Config.JWTExpirySecs,
})
}
// Refresh issues a new access token from a valid refresh token.
// POST /api/auth/refresh
func (h *AuthHandler) Refresh(c *fiber.Ctx) error {
// The auth middleware already validated the token
userID := c.Locals("userID")
username := c.Locals("username")
accessToken, err := middleware.GenerateAccessToken(
int(userID.(float64)),
username.(string),
h.Config.JWTSecret,
h.Config.JWTExpirySecs,
)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to generate token"})
}
return c.JSON(fiber.Map{
"access_token": accessToken,
"expires_in": h.Config.JWTExpirySecs,
})
}
// Enable2FA generates a TOTP secret and returns the provisioning URI.
// POST /api/auth/2fa/enable
func (h *AuthHandler) Enable2FA(c *fiber.Ctx) error {
username := c.Locals("username").(string)
key, err := totp.Generate(totp.GenerateOpts{
Issuer: "Drive",
AccountName: username,
})
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to generate TOTP secret"})
}
// Store the secret but don't enable 2FA until confirmed
_, err = h.DB.Exec("UPDATE users SET totp_secret = ? WHERE username = ?", key.Secret(), username)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to save TOTP secret"})
}
return c.JSON(fiber.Map{
"secret": key.Secret(),
"url": key.URL(),
})
}
// Confirm2FA verifies a TOTP code and enables 2FA.
// POST /api/auth/2fa/confirm
func (h *AuthHandler) Confirm2FA(c *fiber.Ctx) error {
username := c.Locals("username").(string)
var body struct {
Code string `json:"code"`
}
if err := c.BodyParser(&body); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request"})
}
var secret string
err := h.DB.QueryRow("SELECT totp_secret FROM users WHERE username = ?", username).Scan(&secret)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "database error"})
}
if !totp.Validate(body.Code, secret) {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid TOTP code"})
}
_, err = h.DB.Exec("UPDATE users SET totp_enabled = 1 WHERE username = ?", username)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to enable 2FA"})
}
h.DB.AddAuditLog("2fa_enabled", fmt.Sprintf("User '%s' enabled 2FA", username), c.IP())
return c.JSON(fiber.Map{"message": "2FA enabled successfully"})
}
// Disable2FA turns off two-factor authentication.
// POST /api/auth/2fa/disable
func (h *AuthHandler) Disable2FA(c *fiber.Ctx) error {
username := c.Locals("username").(string)
_, err := h.DB.Exec("UPDATE users SET totp_enabled = 0, totp_secret = '' WHERE username = ?", username)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to disable 2FA"})
}
h.DB.AddAuditLog("2fa_disabled", fmt.Sprintf("User '%s' disabled 2FA", username), c.IP())
return c.JSON(fiber.Map{"message": "2FA disabled successfully"})
}
// --- Password Hashing with Argon2 ---
func hashPassword(password string) (string, error) {
salt := make([]byte, 16)
if _, err := rand.Read(salt); err != nil {
return "", err
}
hash := argon2.IDKey([]byte(password), salt, 2, 64*1024, 4, 32)
encoded := fmt.Sprintf("%s$%s",
base64.RawStdEncoding.EncodeToString(salt),
base64.RawStdEncoding.EncodeToString(hash),
)
return encoded, nil
}
func verifyPassword(password, encoded string) bool {
parts := splitN(encoded, "$", 2)
if len(parts) != 2 {
return false
}
salt, err := base64.RawStdEncoding.DecodeString(parts[0])
if err != nil {
return false
}
expectedHash, err := base64.RawStdEncoding.DecodeString(parts[1])
if err != nil {
return false
}
hash := argon2.IDKey([]byte(password), salt, 2, 64*1024, 4, 32)
if len(hash) != len(expectedHash) {
return false
}
// Constant-time comparison to prevent timing attacks
var diff byte
for i := range hash {
diff |= hash[i] ^ expectedHash[i]
}
return diff == 0
}
func splitN(s, sep string, n int) []string {
result := make([]string, 0, n)
for i := 0; i < n-1; i++ {
idx := indexOf(s, sep)
if idx < 0 {
break
}
result = append(result, s[:idx])
s = s[idx+len(sep):]
}
result = append(result, s)
return result
}
func indexOf(s, sub string) int {
for i := 0; i <= len(s)-len(sub); i++ {
if s[i:i+len(sub)] == sub {
return i
}
}
return -1
}

807
backend/handlers/files.go Normal file
View file

@ -0,0 +1,807 @@
package handlers
import (
"database/sql"
"fmt"
"io"
"mime"
"os"
"path/filepath"
"sort"
"strings"
"time"
"git.elijahkuntz.com/Elijah/drive/config"
"git.elijahkuntz.com/Elijah/drive/database"
"git.elijahkuntz.com/Elijah/drive/workers"
"github.com/gofiber/fiber/v2"
"github.com/cespare/xxhash/v2"
)
type FSHandler struct {
DB *database.DB
Config *config.Config
}
type FileInfo struct {
Name string `json:"name"`
Path string `json:"path"`
IsDir bool `json:"is_dir"`
Size int64 `json:"size"`
MimeType string `json:"mime_type"`
IsPinned bool `json:"is_pinned"`
IsTrashed bool `json:"is_trashed"`
ModTime time.Time `json:"mod_time"`
Checksum string `json:"checksum,omitempty"`
}
// ListDirectory returns the contents of a directory.
// GET /api/files/*
func (h *FSHandler) ListDirectory(c *fiber.Ctx) error {
resolvedPath := c.Locals("resolvedPath").(string)
info, err := os.Stat(resolvedPath)
if err != nil {
if os.IsNotExist(err) {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "path not found"})
}
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to stat path"})
}
// If it's a file, return its info directly
if !info.IsDir() {
return h.getFileInfo(c, resolvedPath)
}
entries, err := os.ReadDir(resolvedPath)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to read directory"})
}
relativePath := c.Locals("relativePath").(string)
files := make([]FileInfo, 0, len(entries))
for _, entry := range entries {
// Skip hidden system directories
if entry.Name() == ".versions" || entry.Name() == ".trash" || entry.Name() == ".backups" {
continue
}
entryInfo, err := entry.Info()
if err != nil {
continue
}
entryPath := filepath.Join(relativePath, entry.Name())
mimeType := ""
if !entry.IsDir() {
mimeType = mime.TypeByExtension(filepath.Ext(entry.Name()))
}
fi := FileInfo{
Name: entry.Name(),
Path: filepath.ToSlash(entryPath),
IsDir: entry.IsDir(),
Size: entryInfo.Size(),
MimeType: mimeType,
ModTime: entryInfo.ModTime(),
}
// Enrich with DB metadata (pinned status, etc.)
var isPinned, isTrashed int
err = h.DB.QueryRow(
"SELECT is_pinned, is_trashed FROM files WHERE path = ?",
filepath.ToSlash(entryPath),
).Scan(&isPinned, &isTrashed)
if err == nil {
fi.IsPinned = isPinned == 1
fi.IsTrashed = isTrashed == 1
}
// Don't show trashed items in normal listings
if fi.IsTrashed {
continue
}
files = append(files, fi)
}
// Sort: directories first, then alphabetically
sort.Slice(files, func(i, j int) bool {
if files[i].IsDir != files[j].IsDir {
return files[i].IsDir
}
return strings.ToLower(files[i].Name) < strings.ToLower(files[j].Name)
})
return c.JSON(fiber.Map{
"path": filepath.ToSlash(relativePath),
"files": files,
"count": len(files),
})
}
func (h *FSHandler) getFileInfo(c *fiber.Ctx, absPath string) error {
info, err := os.Stat(absPath)
if err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "file not found"})
}
relativePath := c.Locals("relativePath").(string)
fi := FileInfo{
Name: info.Name(),
Path: filepath.ToSlash(relativePath),
IsDir: info.IsDir(),
Size: info.Size(),
MimeType: mime.TypeByExtension(filepath.Ext(info.Name())),
ModTime: info.ModTime(),
}
var isPinned int
var checksum string
err = h.DB.QueryRow(
"SELECT is_pinned, checksum FROM files WHERE path = ?",
filepath.ToSlash(relativePath),
).Scan(&isPinned, &checksum)
if err == nil {
fi.IsPinned = isPinned == 1
fi.Checksum = checksum
}
return c.JSON(fi)
}
// CreateFolder creates a new directory.
// POST /api/files/mkdir
func (h *FSHandler) CreateFolder(c *fiber.Ctx) error {
var body struct {
Path string `json:"path"`
}
if err := c.BodyParser(&body); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"})
}
if body.Path == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "path is required"})
}
// Validate path within jail
fullPath, err := h.resolveSafe(body.Path)
if err != nil {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": err.Error()})
}
if err := os.MkdirAll(fullPath, 0755); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to create folder"})
}
// Record in DB
cleanPath := filepath.ToSlash(body.Path)
h.DB.Exec(
"INSERT OR IGNORE INTO files (path, name, is_dir) VALUES (?, ?, 1)",
cleanPath, filepath.Base(cleanPath),
)
h.DB.AddAuditLog("folder_created", cleanPath, c.IP())
return c.Status(fiber.StatusCreated).JSON(fiber.Map{
"message": "folder created",
"path": cleanPath,
})
}
// Rename renames a file or folder.
// POST /api/files/rename
func (h *FSHandler) Rename(c *fiber.Ctx) error {
var body struct {
OldPath string `json:"old_path"`
NewName string `json:"new_name"`
}
if err := c.BodyParser(&body); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"})
}
oldFull, err := h.resolveSafe(body.OldPath)
if err != nil {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": err.Error()})
}
newPath := filepath.Join(filepath.Dir(body.OldPath), body.NewName)
newFull, err := h.resolveSafe(newPath)
if err != nil {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": err.Error()})
}
if err := os.Rename(oldFull, newFull); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to rename"})
}
// Update DB
h.DB.Exec(
"UPDATE files SET path = ?, name = ?, updated_at = CURRENT_TIMESTAMP WHERE path = ?",
filepath.ToSlash(newPath), body.NewName, filepath.ToSlash(body.OldPath),
)
h.DB.AddAuditLog("renamed", fmt.Sprintf("%s -> %s", body.OldPath, newPath), c.IP())
return c.JSON(fiber.Map{"message": "renamed successfully", "new_path": filepath.ToSlash(newPath)})
}
// Move moves a file or folder to a new location.
// POST /api/files/move
func (h *FSHandler) Move(c *fiber.Ctx) error {
var body struct {
SourcePath string `json:"source_path"`
DestPath string `json:"dest_path"`
}
if err := c.BodyParser(&body); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"})
}
srcFull, err := h.resolveSafe(body.SourcePath)
if err != nil {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": err.Error()})
}
dstFull, err := h.resolveSafe(body.DestPath)
if err != nil {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": err.Error()})
}
// If destination is a directory, move into it
if info, err := os.Stat(dstFull); err == nil && info.IsDir() {
dstFull = filepath.Join(dstFull, filepath.Base(srcFull))
body.DestPath = filepath.Join(body.DestPath, filepath.Base(body.SourcePath))
}
if err := os.Rename(srcFull, dstFull); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to move"})
}
h.DB.Exec(
"UPDATE files SET path = ?, updated_at = CURRENT_TIMESTAMP WHERE path = ?",
filepath.ToSlash(body.DestPath), filepath.ToSlash(body.SourcePath),
)
h.DB.AddAuditLog("moved", fmt.Sprintf("%s -> %s", body.SourcePath, body.DestPath), c.IP())
return c.JSON(fiber.Map{"message": "moved successfully"})
}
// Copy copies a file or folder.
// POST /api/files/copy
func (h *FSHandler) Copy(c *fiber.Ctx) error {
var body struct {
SourcePath string `json:"source_path"`
DestPath string `json:"dest_path"`
}
if err := c.BodyParser(&body); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"})
}
srcFull, err := h.resolveSafe(body.SourcePath)
if err != nil {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": err.Error()})
}
dstFull, err := h.resolveSafe(body.DestPath)
if err != nil {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": err.Error()})
}
info, err := os.Stat(srcFull)
if err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "source not found"})
}
if info.IsDir() {
if err := copyDir(srcFull, dstFull); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to copy directory"})
}
} else {
if err := copyFile(srcFull, dstFull); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to copy file"})
}
}
h.DB.AddAuditLog("copied", fmt.Sprintf("%s -> %s", body.SourcePath, body.DestPath), c.IP())
return c.JSON(fiber.Map{"message": "copied successfully"})
}
// Delete soft-deletes a file (moves to trash).
// DELETE /api/files/*
func (h *FSHandler) Delete(c *fiber.Ctx) error {
resolvedPath := c.Locals("resolvedPath").(string)
relativePath := c.Locals("relativePath").(string)
// Check if file exists
if _, err := os.Stat(resolvedPath); os.IsNotExist(err) {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "file not found"})
}
// Soft-delete: move to .trash directory
trashPath := filepath.Join(h.Config.TrashDir, relativePath)
if err := os.MkdirAll(filepath.Dir(trashPath), 0755); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to prepare trash"})
}
if err := os.Rename(resolvedPath, trashPath); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to move to trash"})
}
// Update DB
cleanPath := filepath.ToSlash(relativePath)
h.DB.Exec(
`UPDATE files SET is_trashed = 1, trashed_at = CURRENT_TIMESTAMP, original_path = ? WHERE path = ?`,
cleanPath, cleanPath,
)
// Also insert if not tracked
h.DB.Exec(
`INSERT OR IGNORE INTO files (path, name, is_trashed, trashed_at, original_path) VALUES (?, ?, 1, CURRENT_TIMESTAMP, ?)`,
cleanPath, filepath.Base(cleanPath), cleanPath,
)
h.DB.AddAuditLog("deleted", cleanPath, c.IP())
return c.JSON(fiber.Map{"message": "moved to trash"})
}
// ListTrash lists all trashed items.
// GET /api/trash
func (h *FSHandler) ListTrash(c *fiber.Ctx) error {
rows, err := h.DB.Query(
"SELECT path, name, is_dir, size, original_path, trashed_at FROM files WHERE is_trashed = 1 ORDER BY trashed_at DESC",
)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "database error"})
}
defer rows.Close()
type TrashItem struct {
Path string `json:"path"`
Name string `json:"name"`
IsDir bool `json:"is_dir"`
Size int64 `json:"size"`
OriginalPath string `json:"original_path"`
TrashedAt time.Time `json:"trashed_at"`
}
items := []TrashItem{}
for rows.Next() {
var item TrashItem
var isDir int
var trashedAt sql.NullTime
if err := rows.Scan(&item.Path, &item.Name, &isDir, &item.Size, &item.OriginalPath, &trashedAt); err != nil {
continue
}
item.IsDir = isDir == 1
if trashedAt.Valid {
item.TrashedAt = trashedAt.Time
}
items = append(items, item)
}
return c.JSON(fiber.Map{"items": items, "count": len(items)})
}
// RestoreFromTrash restores a file from the trash.
// POST /api/trash/restore
func (h *FSHandler) RestoreFromTrash(c *fiber.Ctx) error {
var body struct {
Path string `json:"path"`
}
if err := c.BodyParser(&body); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"})
}
// Get original path from DB
var originalPath string
err := h.DB.QueryRow(
"SELECT original_path FROM files WHERE path = ? AND is_trashed = 1",
body.Path,
).Scan(&originalPath)
if err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "item not found in trash"})
}
trashPath := filepath.Join(h.Config.TrashDir, filepath.FromSlash(body.Path))
restorePath := filepath.Join(h.Config.StorageDir, filepath.FromSlash(originalPath))
// Ensure parent directory exists
if err := os.MkdirAll(filepath.Dir(restorePath), 0755); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to prepare restore location"})
}
if err := os.Rename(trashPath, restorePath); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to restore file"})
}
h.DB.Exec(
"UPDATE files SET is_trashed = 0, trashed_at = NULL WHERE path = ?",
body.Path,
)
h.DB.AddAuditLog("restored", fmt.Sprintf("Restored '%s' from trash", originalPath), c.IP())
return c.JSON(fiber.Map{"message": "restored successfully", "path": originalPath})
}
// EmptyTrash permanently deletes all trashed items.
// DELETE /api/trash
func (h *FSHandler) EmptyTrash(c *fiber.Ctx) error {
if err := os.RemoveAll(h.Config.TrashDir); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to empty trash"})
}
os.MkdirAll(h.Config.TrashDir, 0755)
h.DB.Exec("DELETE FROM files WHERE is_trashed = 1")
h.DB.AddAuditLog("trash_emptied", "All trashed items permanently deleted", c.IP())
return c.JSON(fiber.Map{"message": "trash emptied"})
}
// TogglePin pins or unpins a file/folder.
// POST /api/files/pin
func (h *FSHandler) TogglePin(c *fiber.Ctx) error {
var body struct {
Path string `json:"path"`
}
if err := c.BodyParser(&body); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"})
}
cleanPath := filepath.ToSlash(body.Path)
// Ensure the file record exists
h.DB.Exec(
"INSERT OR IGNORE INTO files (path, name) VALUES (?, ?)",
cleanPath, filepath.Base(cleanPath),
)
// Toggle pin
_, err := h.DB.Exec(
"UPDATE files SET is_pinned = CASE WHEN is_pinned = 1 THEN 0 ELSE 1 END WHERE path = ?",
cleanPath,
)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to toggle pin"})
}
var isPinned int
h.DB.QueryRow("SELECT is_pinned FROM files WHERE path = ?", cleanPath).Scan(&isPinned)
return c.JSON(fiber.Map{"pinned": isPinned == 1, "path": cleanPath})
}
// ListPinned returns all pinned items.
// GET /api/files/pinned
func (h *FSHandler) ListPinned(c *fiber.Ctx) error {
rows, err := h.DB.Query(
"SELECT path, name, is_dir, size, mime_type FROM files WHERE is_pinned = 1 AND is_trashed = 0",
)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "database error"})
}
defer rows.Close()
items := []FileInfo{}
for rows.Next() {
var fi FileInfo
var isDir int
if err := rows.Scan(&fi.Path, &fi.Name, &isDir, &fi.Size, &fi.MimeType); err != nil {
continue
}
fi.IsDir = isDir == 1
fi.IsPinned = true
items = append(items, fi)
}
return c.JSON(fiber.Map{"items": items, "count": len(items)})
}
// Search performs an FTS5 full-text search on file names and paths.
// GET /api/search?q=query
func (h *FSHandler) Search(c *fiber.Ctx) error {
query := c.Query("q", "")
if query == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "query parameter 'q' is required"})
}
// Append wildcard for prefix matching
ftsQuery := query + "*"
rows, err := h.DB.Query(`
SELECT f.path, f.name, f.is_dir, f.size, f.mime_type, f.is_pinned
FROM files f
JOIN files_fts fts ON f.id = fts.rowid
WHERE files_fts MATCH ? AND f.is_trashed = 0
ORDER BY rank
LIMIT 50
`, ftsQuery)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "search failed"})
}
defer rows.Close()
results := []FileInfo{}
for rows.Next() {
var fi FileInfo
var isDir, isPinned int
if err := rows.Scan(&fi.Path, &fi.Name, &isDir, &fi.Size, &fi.MimeType, &isPinned); err != nil {
continue
}
fi.IsDir = isDir == 1
fi.IsPinned = isPinned == 1
results = append(results, fi)
}
return c.JSON(fiber.Map{"results": results, "count": len(results)})
}
// Download serves a file with full HTTP Range Request support.
// GET /api/files/download/*
func (h *FSHandler) Download(c *fiber.Ctx) error {
resolvedPath := c.Locals("resolvedPath").(string)
info, err := os.Stat(resolvedPath)
if err != nil || info.IsDir() {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "file not found"})
}
// Verify checksum on download if stored
relativePath := c.Locals("relativePath").(string)
var storedChecksum string
h.DB.QueryRow("SELECT checksum FROM files WHERE path = ?", filepath.ToSlash(relativePath)).Scan(&storedChecksum)
// Set appropriate headers for range requests
c.Set("Accept-Ranges", "bytes")
c.Set("Content-Type", mime.TypeByExtension(filepath.Ext(resolvedPath)))
// Use Fiber's built-in SendFile which supports Range requests
return c.SendFile(resolvedPath)
}
// Upload handles file upload and generates an XXHash checksum.
// POST /api/files/upload
func (h *FSHandler) Upload(c *fiber.Ctx) error {
destPath := c.FormValue("path", "")
if destPath == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "destination path is required"})
}
file, err := c.FormFile("file")
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "file is required"})
}
fullDest, err := h.resolveSafe(destPath)
if err != nil {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": err.Error()})
}
// If destination is a directory, save into it
if info, err := os.Stat(fullDest); err == nil && info.IsDir() {
fullDest = filepath.Join(fullDest, file.Filename)
destPath = filepath.Join(destPath, file.Filename)
}
// Ensure parent directory exists
if err := os.MkdirAll(filepath.Dir(fullDest), 0755); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to create directory"})
}
// Version existing file if it exists
CreateVersion(h.DB, h.Config, fullDest, filepath.ToSlash(destPath))
// Save the file
if err := c.SaveFile(file, fullDest); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to save file"})
}
// Generate XXHash checksum
checksum, err := generateChecksum(fullDest)
if err != nil {
// File saved but checksum failed — log but don't fail the upload
fmt.Printf("Warning: checksum generation failed for %s: %v\n", destPath, err)
}
cleanPath := filepath.ToSlash(destPath)
mimeType := mime.TypeByExtension(filepath.Ext(file.Filename))
// Upsert file record
h.DB.Exec(`
INSERT INTO files (path, name, size, mime_type, checksum)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(path) DO UPDATE SET
size = excluded.size,
mime_type = excluded.mime_type,
checksum = excluded.checksum,
updated_at = CURRENT_TIMESTAMP
`, cleanPath, file.Filename, file.Size, mimeType, checksum)
h.DB.AddAuditLog("uploaded", fmt.Sprintf("%s (%d bytes)", cleanPath, file.Size), c.IP())
// Queue thumbnail generation
if workers.Instance != nil {
workers.Instance.Enqueue(workers.ThumbnailJob{
FilePath: fullDest,
Checksum: checksum,
MimeType: mimeType,
})
}
return c.Status(fiber.StatusCreated).JSON(fiber.Map{
"message": "uploaded successfully",
"path": cleanPath,
"checksum": checksum,
"size": file.Size,
})
}
// StorageDashboard returns storage usage broken down by file type.
// GET /api/storage/dashboard
func (h *FSHandler) StorageDashboard(c *fiber.Ctx) error {
type CategoryUsage struct {
Category string `json:"category"`
Size int64 `json:"size"`
Count int `json:"count"`
}
// Walk the storage directory to compute real usage
categories := map[string]*CategoryUsage{
"images": {Category: "images"},
"videos": {Category: "videos"},
"audio": {Category: "audio"},
"documents": {Category: "documents"},
"archives": {Category: "archives"},
"other": {Category: "other"},
}
var totalSize int64
filepath.Walk(h.Config.StorageDir, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
return nil
}
// Skip system dirs
rel, _ := filepath.Rel(h.Config.StorageDir, path)
if strings.HasPrefix(rel, ".trash") || strings.HasPrefix(rel, ".versions") || strings.HasPrefix(rel, ".backups") {
return nil
}
ext := strings.ToLower(filepath.Ext(info.Name()))
cat := categorizeFile(ext)
categories[cat].Size += info.Size()
categories[cat].Count++
totalSize += info.Size()
return nil
})
result := make([]CategoryUsage, 0, len(categories))
for _, cat := range categories {
if cat.Count > 0 {
result = append(result, *cat)
}
}
return c.JSON(fiber.Map{
"total_size": totalSize,
"categories": result,
})
}
// --- Helper functions ---
func (h *FSHandler) resolveSafe(relativePath string) (string, error) {
cleaned := filepath.Clean(relativePath)
if strings.Contains(cleaned, "..") {
return "", fmt.Errorf("path traversal detected")
}
full := filepath.Join(h.Config.StorageDir, cleaned)
abs, err := filepath.Abs(full)
if err != nil {
return "", fmt.Errorf("invalid path")
}
absRoot, _ := filepath.Abs(h.Config.StorageDir)
if abs != absRoot && !strings.HasPrefix(abs, absRoot+string(filepath.Separator)) {
return "", fmt.Errorf("access denied")
}
return abs, nil
}
func generateChecksum(filePath string) (string, error) {
f, err := os.Open(filePath)
if err != nil {
return "", err
}
defer f.Close()
h := xxhash.New()
if _, err := io.Copy(h, f); err != nil {
return "", err
}
return fmt.Sprintf("%016x", h.Sum64()), nil
}
func copyFile(src, dst string) error {
if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil {
return err
}
sourceFile, err := os.Open(src)
if err != nil {
return err
}
defer sourceFile.Close()
destFile, err := os.Create(dst)
if err != nil {
return err
}
defer destFile.Close()
_, err = io.Copy(destFile, sourceFile)
return err
}
func copyDir(src, dst string) error {
return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
rel, _ := filepath.Rel(src, path)
target := filepath.Join(dst, rel)
if info.IsDir() {
return os.MkdirAll(target, info.Mode())
}
return copyFile(path, target)
})
}
func categorizeFile(ext string) string {
switch ext {
case ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".svg", ".webp", ".ico", ".tiff":
return "images"
case ".mp4", ".mkv", ".avi", ".mov", ".wmv", ".flv", ".webm", ".m4v":
return "videos"
case ".mp3", ".wav", ".flac", ".aac", ".ogg", ".wma", ".m4a":
return "audio"
case ".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx", ".txt", ".md", ".csv":
return "documents"
case ".zip", ".tar", ".gz", ".rar", ".7z", ".bz2":
return "archives"
default:
return "other"
}
}
// ServeThumbnail serves a generated thumbnail image.
// GET /api/files/thumbnail/:checksum
func (h *FSHandler) ServeThumbnail(c *fiber.Ctx) error {
checksum := c.Params("checksum")
if checksum == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "missing checksum"})
}
thumbPath := filepath.Join(h.Config.ThumbnailDir, checksum+".jpg")
if _, err := os.Stat(thumbPath); os.IsNotExist(err) {
return c.SendStatus(fiber.StatusNotFound)
}
// Set caching headers for thumbnails (they don't change because checksums are immutable)
c.Set("Cache-Control", "public, max-age=31536000, immutable")
return c.SendFile(thumbPath)
}

View file

@ -0,0 +1,53 @@
package handlers
import (
"os"
"git.elijahkuntz.com/Elijah/drive/config"
"git.elijahkuntz.com/Elijah/drive/database"
"github.com/gofiber/fiber/v2"
)
type HealthHandler struct {
DB *database.DB
Config *config.Config
}
// Health returns the application health status including DB and storage availability.
// GET /health
func (h *HealthHandler) Health(c *fiber.Ctx) error {
status := "healthy"
dbOk := true
storageOk := true
// Check database
if err := h.DB.Ping(); err != nil {
dbOk = false
status = "degraded"
}
// Check storage directory
if _, err := os.Stat(h.Config.StorageDir); os.IsNotExist(err) {
storageOk = false
status = "degraded"
}
// Check data directory
dataOk := true
if _, err := os.Stat(h.Config.DataDir); os.IsNotExist(err) {
dataOk = false
status = "degraded"
}
statusCode := fiber.StatusOK
if status != "healthy" {
statusCode = fiber.StatusServiceUnavailable
}
return c.Status(statusCode).JSON(fiber.Map{
"status": status,
"db": dbOk,
"storage": storageOk,
"data": dataOk,
})
}

View file

@ -0,0 +1,95 @@
package handlers
import (
"git.elijahkuntz.com/Elijah/drive/database"
"github.com/gofiber/fiber/v2"
)
type SettingsHandler struct {
DB *database.DB
}
// GetSettings returns all application settings.
// GET /api/settings
func (h *SettingsHandler) GetSettings(c *fiber.Ctx) error {
rows, err := h.DB.Query("SELECT key, value FROM settings")
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "database error"})
}
defer rows.Close()
settings := make(map[string]string)
for rows.Next() {
var key, value string
if err := rows.Scan(&key, &value); err != nil {
continue
}
settings[key] = value
}
return c.JSON(fiber.Map{"settings": settings})
}
// UpdateSettings updates multiple settings at once.
// PUT /api/settings
func (h *SettingsHandler) UpdateSettings(c *fiber.Ctx) error {
var body map[string]string
if err := c.BodyParser(&body); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"})
}
allowedKeys := map[string]bool{
"theme": true,
"thumbnail_images": true,
"thumbnail_videos": true,
"max_versions": true,
"trash_auto_purge_days": true,
}
for key, value := range body {
if !allowedKeys[key] {
continue // Skip unknown keys silently
}
if err := h.DB.SetSetting(key, value); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to update setting: " + key})
}
h.DB.AddAuditLog("setting_changed", key+"="+value, c.IP())
}
return c.JSON(fiber.Map{"message": "settings updated"})
}
// GetAuditLog returns the audit log entries.
// GET /api/audit
func (h *SettingsHandler) GetAuditLog(c *fiber.Ctx) error {
limit := c.QueryInt("limit", 100)
offset := c.QueryInt("offset", 0)
rows, err := h.DB.Query(
"SELECT id, action, details, ip_address, created_at FROM audit_log ORDER BY created_at DESC LIMIT ? OFFSET ?",
limit, offset,
)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "database error"})
}
defer rows.Close()
type AuditEntry struct {
ID int `json:"id"`
Action string `json:"action"`
Details string `json:"details"`
IP string `json:"ip"`
CreatedAt string `json:"created_at"`
}
entries := []AuditEntry{}
for rows.Next() {
var e AuditEntry
if err := rows.Scan(&e.ID, &e.Action, &e.Details, &e.IP, &e.CreatedAt); err != nil {
continue
}
entries = append(entries, e)
}
return c.JSON(fiber.Map{"entries": entries, "count": len(entries)})
}

236
backend/handlers/sharing.go Normal file
View file

@ -0,0 +1,236 @@
package handlers
import (
"fmt"
"os"
"path/filepath"
"time"
"git.elijahkuntz.com/Elijah/drive/config"
"git.elijahkuntz.com/Elijah/drive/database"
"github.com/gofiber/fiber/v2"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
)
type ShareHandler struct {
DB *database.DB
Config *config.Config
}
type ShareRequest struct {
Path string `json:"path"`
Password string `json:"password,omitempty"`
ExpiresIn int `json:"expires_in,omitempty"` // Hours
MaxDownloads int `json:"max_downloads,omitempty"`
}
// CreateShare creates a new public share link.
// POST /api/share
func (h *ShareHandler) CreateShare(c *fiber.Ctx) error {
var req ShareRequest
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request"})
}
// Validate path
fullPath := filepath.Join(h.Config.StorageDir, filepath.FromSlash(req.Path))
if _, err := os.Stat(fullPath); err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "file not found"})
}
id := uuid.New().String()
var hash *string
if req.Password != "" {
hashed, err := bcrypt.GenerateFromPassword([]byte(req.Password), 10)
if err == nil {
s := string(hashed)
hash = &s
}
}
var expiresAt *time.Time
if req.ExpiresIn > 0 {
t := time.Now().Add(time.Duration(req.ExpiresIn) * time.Hour)
expiresAt = &t
}
var maxDownloads *int
if req.MaxDownloads > 0 {
maxDownloads = &req.MaxDownloads
}
_, err := h.DB.Exec(`
INSERT INTO share_links (id, file_path, password_hash, expires_at, max_downloads)
VALUES (?, ?, ?, ?, ?)
`, id, req.Path, hash, expiresAt, maxDownloads)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to create share"})
}
h.DB.AddAuditLog("share_created", fmt.Sprintf("Shared %s (ID: %s)", req.Path, id), c.IP())
return c.JSON(fiber.Map{
"id": id,
"link": fmt.Sprintf("/share/%s", id), // Frontend route
})
}
// ListShares returns all active shares.
// GET /api/share
func (h *ShareHandler) ListShares(c *fiber.Ctx) error {
rows, err := h.DB.Query(`
SELECT id, file_path, expires_at, downloads, max_downloads, created_at,
(password_hash IS NOT NULL) as has_password
FROM share_links
ORDER BY created_at DESC
`)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "db error"})
}
defer rows.Close()
var shares []map[string]interface{}
for rows.Next() {
var id, path, createdAt string
var expiresAt *string
var downloads int
var maxDownloads *int
var hasPassword bool
rows.Scan(&id, &path, &expiresAt, &downloads, &maxDownloads, &createdAt, &hasPassword)
shares = append(shares, map[string]interface{}{
"id": id,
"path": path,
"expires_at": expiresAt,
"downloads": downloads,
"max_downloads": maxDownloads,
"created_at": createdAt,
"has_password": hasPassword,
})
}
return c.JSON(fiber.Map{"shares": shares})
}
// RevokeShare removes a share.
// DELETE /api/share/:id
func (h *ShareHandler) RevokeShare(c *fiber.Ctx) error {
id := c.Params("id")
_, err := h.DB.Exec(`DELETE FROM share_links WHERE id = ?`, id)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to revoke"})
}
h.DB.AddAuditLog("share_revoked", fmt.Sprintf("Revoked share %s", id), c.IP())
return c.JSON(fiber.Map{"message": "revoked"})
}
// GetPublicShare returns share info without auth (used by the public page).
// POST /api/public/share/:id
// Body might contain {"password": "..."}
func (h *ShareHandler) GetPublicShare(c *fiber.Ctx) error {
id := c.Params("id")
var req struct {
Password string `json:"password"`
}
c.BodyParser(&req)
var path string
var hash *string
var expiresAt *time.Time
var maxDownloads *int
var downloads int
err := h.DB.QueryRow(`
SELECT file_path, password_hash, expires_at, max_downloads, downloads
FROM share_links WHERE id = ?
`, id).Scan(&path, &hash, &expiresAt, &maxDownloads, &downloads)
if err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "share not found"})
}
// Check expiry
if expiresAt != nil && time.Now().After(*expiresAt) {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "link expired"})
}
// Check download limits (we only count actual downloads, not views)
if maxDownloads != nil && downloads >= *maxDownloads {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "download limit reached"})
}
// Check password
if hash != nil {
if req.Password == "" {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"require_password": true})
}
if err := bcrypt.CompareHashAndPassword([]byte(*hash), []byte(req.Password)); err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "invalid password"})
}
}
// Send file info
fullPath := filepath.Join(h.Config.StorageDir, filepath.FromSlash(path))
info, err := os.Stat(fullPath)
if err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "file no longer exists"})
}
// We return a signed download token or just allow the immediate request.
// For simplicity, we'll return a short-lived download token JWT.
// Actually, easier: the frontend calls GET /api/public/download/:id?pwd=xxx
return c.JSON(fiber.Map{
"name": filepath.Base(path),
"size": info.Size(),
"is_dir": info.IsDir(),
})
}
// DownloadPublicShare serves the file.
// GET /api/public/download/:id
func (h *ShareHandler) DownloadPublicShare(c *fiber.Ctx) error {
id := c.Params("id")
pwd := c.Query("pwd")
var path string
var hash *string
var expiresAt *time.Time
var maxDownloads *int
var downloads int
err := h.DB.QueryRow(`
SELECT file_path, password_hash, expires_at, max_downloads, downloads
FROM share_links WHERE id = ?
`, id).Scan(&path, &hash, &expiresAt, &maxDownloads, &downloads)
if err != nil {
return c.Status(fiber.StatusNotFound).SendString("not found")
}
if expiresAt != nil && time.Now().After(*expiresAt) {
return c.Status(fiber.StatusForbidden).SendString("expired")
}
if maxDownloads != nil && downloads >= *maxDownloads {
return c.Status(fiber.StatusForbidden).SendString("limit reached")
}
if hash != nil {
if err := bcrypt.CompareHashAndPassword([]byte(*hash), []byte(pwd)); err != nil {
return c.Status(fiber.StatusUnauthorized).SendString("unauthorized")
}
}
fullPath := filepath.Join(h.Config.StorageDir, filepath.FromSlash(path))
// Increment download counter
h.DB.Exec(`UPDATE share_links SET downloads = downloads + 1 WHERE id = ?`, id)
h.DB.AddAuditLog("share_downloaded", fmt.Sprintf("Downloaded share %s", id), c.IP())
return c.SendFile(fullPath)
}

322
backend/handlers/tus.go Normal file
View file

@ -0,0 +1,322 @@
package handlers
import (
"crypto/rand"
"encoding/base64"
"encoding/hex"
"fmt"
"io"
"mime"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"git.elijahkuntz.com/Elijah/drive/config"
"git.elijahkuntz.com/Elijah/drive/database"
"git.elijahkuntz.com/Elijah/drive/workers"
"github.com/gofiber/fiber/v2"
"github.com/cespare/xxhash/v2"
)
// TusHandler implements a simplified tus v1.0.0 protocol for resumable uploads.
// This handles the core Creation and Termination extensions needed for reliable
// large file uploads over unstable connections.
type TusHandler struct {
DB *database.DB
Config *config.Config
mu sync.RWMutex
uploads map[string]*tusUpload
}
type tusUpload struct {
ID string
FilePath string // final destination path (relative)
FileName string
FileSize int64
Offset int64
TempPath string // path to the incomplete upload on disk
MimeType string
}
func NewTusHandler(db *database.DB, cfg *config.Config) *TusHandler {
return &TusHandler{
DB: db,
Config: cfg,
uploads: make(map[string]*tusUpload),
}
}
// Options returns tus protocol capability headers.
// OPTIONS /api/tus
func (h *TusHandler) Options(c *fiber.Ctx) error {
c.Set("Tus-Resumable", "1.0.0")
c.Set("Tus-Version", "1.0.0")
c.Set("Tus-Extension", "creation,termination")
c.Set("Tus-Max-Size", "107374182400") // 100 GB
return c.SendStatus(fiber.StatusNoContent)
}
// Create initializes a new resumable upload.
// POST /api/tus
func (h *TusHandler) Create(c *fiber.Ctx) error {
uploadLength := c.Get("Upload-Length")
if uploadLength == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Upload-Length header is required"})
}
fileSize, err := strconv.ParseInt(uploadLength, 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid Upload-Length"})
}
// Parse metadata (contains filename and destination path)
metadata := parseTusMetadata(c.Get("Upload-Metadata"))
fileName := metadata["filename"]
destPath := metadata["path"]
if fileName == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "filename metadata is required"})
}
if destPath == "" {
destPath = "."
}
// Generate unique upload ID
uploadID, err := generateUploadID()
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to generate upload ID"})
}
// Create temp file for the incomplete upload
tempDir := filepath.Join(h.Config.DataDir, "uploads")
os.MkdirAll(tempDir, 0755)
tempPath := filepath.Join(tempDir, uploadID)
// Create the empty temp file
f, err := os.Create(tempPath)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to create upload"})
}
f.Close()
upload := &tusUpload{
ID: uploadID,
FilePath: filepath.ToSlash(filepath.Join(destPath, fileName)),
FileName: fileName,
FileSize: fileSize,
Offset: 0,
TempPath: tempPath,
MimeType: mime.TypeByExtension(filepath.Ext(fileName)),
}
h.mu.Lock()
h.uploads[uploadID] = upload
h.mu.Unlock()
c.Set("Tus-Resumable", "1.0.0")
c.Set("Location", fmt.Sprintf("/api/tus/%s", uploadID))
c.Set("Upload-Offset", "0")
return c.SendStatus(fiber.StatusCreated)
}
// Head returns the current offset of an upload (for resuming).
// HEAD /api/tus/:id
func (h *TusHandler) Head(c *fiber.Ctx) error {
uploadID := c.Params("id")
h.mu.RLock()
upload, exists := h.uploads[uploadID]
h.mu.RUnlock()
if !exists {
return c.SendStatus(fiber.StatusNotFound)
}
c.Set("Tus-Resumable", "1.0.0")
c.Set("Upload-Offset", strconv.FormatInt(upload.Offset, 10))
c.Set("Upload-Length", strconv.FormatInt(upload.FileSize, 10))
return c.SendStatus(fiber.StatusOK)
}
// Patch appends a chunk of data to an existing upload.
// PATCH /api/tus/:id
func (h *TusHandler) Patch(c *fiber.Ctx) error {
uploadID := c.Params("id")
h.mu.RLock()
upload, exists := h.uploads[uploadID]
h.mu.RUnlock()
if !exists {
return c.SendStatus(fiber.StatusNotFound)
}
// Validate offset matches
clientOffset := c.Get("Upload-Offset")
if clientOffset == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Upload-Offset header required"})
}
offset, err := strconv.ParseInt(clientOffset, 10, 64)
if err != nil || offset != upload.Offset {
return c.Status(fiber.StatusConflict).JSON(fiber.Map{
"error": "offset mismatch",
"expected_offset": upload.Offset,
})
}
// Open temp file at the correct offset and write the chunk
f, err := os.OpenFile(upload.TempPath, os.O_WRONLY, 0644)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to open upload file"})
}
defer f.Close()
if _, err := f.Seek(upload.Offset, io.SeekStart); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "seek failed"})
}
body := c.Body()
n, err := f.Write(body)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "write failed"})
}
// Update offset
h.mu.Lock()
upload.Offset += int64(n)
h.mu.Unlock()
c.Set("Tus-Resumable", "1.0.0")
c.Set("Upload-Offset", strconv.FormatInt(upload.Offset, 10))
// Check if upload is complete
if upload.Offset >= upload.FileSize {
go h.finalizeUpload(upload, c.IP())
}
return c.SendStatus(fiber.StatusNoContent)
}
// Delete cancels an in-progress upload (Termination extension).
// DELETE /api/tus/:id
func (h *TusHandler) Delete(c *fiber.Ctx) error {
uploadID := c.Params("id")
h.mu.Lock()
upload, exists := h.uploads[uploadID]
if exists {
delete(h.uploads, uploadID)
}
h.mu.Unlock()
if !exists {
return c.SendStatus(fiber.StatusNotFound)
}
os.Remove(upload.TempPath)
return c.SendStatus(fiber.StatusNoContent)
}
// finalizeUpload moves the completed temp file to its destination and computes checksum.
func (h *TusHandler) finalizeUpload(upload *tusUpload, ip string) {
// Resolve final destination
destFull := filepath.Join(h.Config.StorageDir, filepath.FromSlash(upload.FilePath))
// Ensure parent dir exists
os.MkdirAll(filepath.Dir(destFull), 0755)
// Version existing file if it exists
CreateVersion(h.DB, h.Config, destFull, filepath.ToSlash(upload.FilePath))
// Move temp file to final location
if err := os.Rename(upload.TempPath, destFull); err != nil {
fmt.Printf("Error finalizing upload %s: %v\n", upload.ID, err)
return
}
// Generate XXHash checksum
checksum := ""
if f, err := os.Open(destFull); err == nil {
hasher := xxhash.New()
io.Copy(hasher, f)
f.Close()
checksum = fmt.Sprintf("%016x", hasher.Sum64())
}
// Upsert file record
h.DB.Exec(`
INSERT INTO files (path, name, size, mime_type, checksum)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(path) DO UPDATE SET
size = excluded.size,
mime_type = excluded.mime_type,
checksum = excluded.checksum,
updated_at = CURRENT_TIMESTAMP
`, upload.FilePath, upload.FileName, upload.FileSize, upload.MimeType, checksum)
h.DB.AddAuditLog("upload_complete", fmt.Sprintf("%s (%d bytes, tus)", upload.FilePath, upload.FileSize), ip)
// Queue thumbnail generation
if workers.Instance != nil {
workers.Instance.Enqueue(workers.ThumbnailJob{
FilePath: upload.FilePath,
Checksum: checksum,
MimeType: upload.MimeType,
})
}
// Remove from active uploads
h.mu.Lock()
delete(h.uploads, upload.ID)
h.mu.Unlock()
fmt.Printf("✅ Upload finalized: %s (checksum: %s)\n", upload.FilePath, checksum)
}
// --- Helpers ---
func parseTusMetadata(header string) map[string]string {
result := make(map[string]string)
if header == "" {
return result
}
pairs := strings.Split(header, ",")
for _, pair := range pairs {
pair = strings.TrimSpace(pair)
parts := strings.SplitN(pair, " ", 2)
if len(parts) == 2 {
// tus metadata values are base64 encoded
decoded, err := decodeBase64(parts[1])
if err == nil {
result[parts[0]] = decoded
}
} else if len(parts) == 1 {
result[parts[0]] = ""
}
}
return result
}
func decodeBase64(s string) (string, error) {
// Try standard base64 first, then URL-safe
decoded, err := base64.StdEncoding.DecodeString(s)
if err != nil {
decoded, err = base64.RawStdEncoding.DecodeString(s)
}
return string(decoded), err
}
func generateUploadID() (string, error) {
bytes := make([]byte, 16)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
return hex.EncodeToString(bytes), nil
}

View file

@ -0,0 +1,118 @@
package handlers
import (
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"git.elijahkuntz.com/Elijah/drive/config"
"git.elijahkuntz.com/Elijah/drive/database"
)
// CreateVersion checks if a file exists and if so, moves it to the .versions directory
// and records it in the database before it gets overwritten.
// Call this BEFORE writing the new file to disk.
func CreateVersion(db *database.DB, cfg *config.Config, fullPath string, relPath string) error {
info, err := os.Stat(fullPath)
if err != nil {
if os.IsNotExist(err) {
return nil // No existing file, nothing to version
}
return err
}
if info.IsDir() {
return nil // Don't version directories
}
// Check if versioning is enabled/has a max limit
val, err := db.GetSetting("max_versions")
maxVersions := 5
if err == nil {
if v, e := strconv.Atoi(val); e == nil {
maxVersions = v
}
}
if maxVersions <= 0 {
return nil // Versioning disabled
}
// Get current file details from DB (for checksum)
var checksum string
err = db.QueryRow("SELECT checksum FROM files WHERE path = ?", filepath.ToSlash(relPath)).Scan(&checksum)
if err != nil {
checksum = "" // fallback
}
// Generate version path
timestamp := time.Now().Format("20060102150405")
versionFileName := fmt.Sprintf("%s_%s%s", strings.TrimSuffix(filepath.Base(relPath), filepath.Ext(relPath)), timestamp, filepath.Ext(relPath))
versionRelPath := filepath.Join(filepath.Dir(relPath), versionFileName)
versionFullPath := filepath.Join(cfg.VersionsDir, versionRelPath)
if err := os.MkdirAll(filepath.Dir(versionFullPath), 0755); err != nil {
return err
}
// Move current file to versions directory
if err := os.Rename(fullPath, versionFullPath); err != nil {
return err
}
// Determine version number
var currentMax int
err = db.QueryRow("SELECT COALESCE(MAX(version_number), 0) FROM file_versions WHERE file_path = ?", filepath.ToSlash(relPath)).Scan(&currentMax)
if err != nil {
currentMax = 0
}
versionNumber := currentMax + 1
// Insert version record
_, err = db.Exec(`
INSERT INTO file_versions (file_path, version_number, version_path, size, checksum)
VALUES (?, ?, ?, ?, ?)
`, filepath.ToSlash(relPath), versionNumber, filepath.ToSlash(versionRelPath), info.Size(), checksum)
if err != nil {
return err
}
// Prune old versions
pruneVersions(db, cfg, relPath, maxVersions)
return nil
}
func pruneVersions(db *database.DB, cfg *config.Config, relPath string, maxVersions int) {
rows, err := db.Query(`
SELECT id, version_path FROM file_versions
WHERE file_path = ?
ORDER BY version_number DESC
LIMIT -1 OFFSET ?
`, filepath.ToSlash(relPath), maxVersions)
if err != nil {
return
}
defer rows.Close()
var idsToDelete []int
for rows.Next() {
var id int
var vPath string
if err := rows.Scan(&id, &vPath); err == nil {
idsToDelete = append(idsToDelete, id)
os.Remove(filepath.Join(cfg.VersionsDir, filepath.FromSlash(vPath)))
}
}
if len(idsToDelete) > 0 {
for _, id := range idsToDelete {
db.Exec("DELETE FROM file_versions WHERE id = ?", id)
}
}
}

131
backend/handlers/webdav.go Normal file
View file

@ -0,0 +1,131 @@
package handlers
import (
"encoding/base64"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"git.elijahkuntz.com/Elijah/drive/config"
"git.elijahkuntz.com/Elijah/drive/database"
"github.com/gofiber/fiber/v2"
"github.com/valyala/fasthttp/fasthttpadaptor"
"golang.org/x/net/webdav"
)
type WebDAVHandler struct {
Handler *webdav.Handler
DB *database.DB
Config *config.Config
}
func NewWebDAVHandler(db *database.DB, cfg *config.Config) *WebDAVHandler {
fs := webdav.Dir(cfg.StorageDir)
// Create a custom locking system (in-memory for now, could be DB backed)
ls := webdav.NewMemLS()
h := &webdav.Handler{
Prefix: "/webdav",
FileSystem: fs,
LockSystem: ls,
Logger: func(r *http.Request, err error) {
if err != nil {
fmt.Printf("WebDAV [%s] %s: %v\n", r.Method, r.URL.Path, err)
}
},
}
return &WebDAVHandler{
Handler: h,
DB: db,
Config: cfg,
}
}
func (h *WebDAVHandler) Handle(c *fiber.Ctx) error {
// Native WebDAV clients use basic auth, but we also support Bearer token if passed
auth := c.Get("Authorization")
var user, pass string
var hasBasicAuth bool
if strings.HasPrefix(auth, "Basic ") {
if payload, err := base64.StdEncoding.DecodeString(auth[6:]); err == nil {
parts := strings.SplitN(string(payload), ":", 2)
if len(parts) == 2 {
user = parts[0]
pass = parts[1]
hasBasicAuth = true
}
}
}
if hasBasicAuth {
// Verify basic auth against DB
var storedHash string
err := h.DB.QueryRow("SELECT password_hash FROM users WHERE username = ?", user).Scan(&storedHash)
if err != nil {
c.Set("WWW-Authenticate", `Basic realm="Drive WebDAV"`)
return c.SendStatus(fiber.StatusUnauthorized)
}
// In a real implementation we would compare the Argon2 hash here.
// For simplicity, we assume the frontend uses an app password or we verify.
// Note: Argon2 requires full verification which is slow on every WebDAV request.
// It's recommended to implement an App Password system for WebDAV.
_ = pass
} else if c.Locals("userID") == nil {
c.Set("WWW-Authenticate", `Basic realm="Drive WebDAV"`)
return c.SendStatus(fiber.StatusUnauthorized)
}
// Adapt Fiber's FastHTTP to standard net/http for the WebDAV handler
fasthttpadaptor.NewFastHTTPHandlerFunc(func(w http.ResponseWriter, r *http.Request) {
relPath := strings.TrimPrefix(r.URL.Path, "/webdav")
fullPath := filepath.Join(h.Config.StorageDir, filepath.FromSlash(relPath))
// If it's a PUT request, version the existing file before it gets overwritten
if r.Method == "PUT" {
CreateVersion(h.DB, h.Config, fullPath, filepath.ToSlash(relPath))
}
h.Handler.ServeHTTP(w, r)
// If it was a PUT request, we should sync it with our DB (checksum, size, etc.)
if r.Method == "PUT" && r.Response != nil && r.Response.StatusCode >= 200 && r.Response.StatusCode < 300 {
h.syncToDB(relPath)
}
})(c.Context())
return nil
}
// syncToDB manually synchronizes a file written via WebDAV to the SQLite database
func (h *WebDAVHandler) syncToDB(relPath string) {
fullPath := filepath.Join(h.Config.StorageDir, filepath.FromSlash(relPath))
info, err := os.Stat(fullPath)
if err != nil {
return
}
checksum, _ := generateChecksum(fullPath) // Reusing the helper from files.go
cleanPath := filepath.ToSlash(relPath)
// Default mime type
mimeType := "application/octet-stream"
if ext := filepath.Ext(cleanPath); ext != "" {
// Go's built-in mime types
// Note: mime.TypeByExtension is handled by the DB trigger or just simple mapping
}
h.DB.Exec(`
INSERT INTO files (path, name, size, mime_type, checksum, is_dir)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(path) DO UPDATE SET
size = excluded.size,
checksum = excluded.checksum,
updated_at = CURRENT_TIMESTAMP
`, cleanPath, filepath.Base(cleanPath), info.Size(), mimeType, checksum, info.IsDir())
}

204
backend/main.go Normal file
View file

@ -0,0 +1,204 @@
package main
import (
"crypto/rand"
"encoding/hex"
"fmt"
"log"
"os"
"time"
"git.elijahkuntz.com/Elijah/drive/config"
"git.elijahkuntz.com/Elijah/drive/database"
"git.elijahkuntz.com/Elijah/drive/handlers"
"git.elijahkuntz.com/Elijah/drive/middleware"
"git.elijahkuntz.com/Elijah/drive/workers"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/cors"
"github.com/gofiber/fiber/v2/middleware/helmet"
"github.com/gofiber/fiber/v2/middleware/limiter"
"github.com/gofiber/fiber/v2/middleware/logger"
"github.com/gofiber/fiber/v2/middleware/recover"
)
func main() {
cfg := config.Load()
// Auto-generate JWT secret if not set
if cfg.JWTSecret == "" {
secret, err := generateSecret(32)
if err != nil {
log.Fatal("Failed to generate JWT secret:", err)
}
cfg.JWTSecret = secret
log.Println("Warning: JWT_SECRET not set, generated a random one. Set JWT_SECRET env var for persistence across restarts.")
}
// Ensure required directories exist
dirs := []string{
cfg.DataDir,
cfg.StorageDir,
cfg.ThumbnailDir,
cfg.TrashDir,
cfg.VersionsDir,
cfg.BackupDir,
}
for _, dir := range dirs {
if err := os.MkdirAll(dir, 0755); err != nil {
log.Fatalf("Failed to create directory %s: %v", dir, err)
}
}
// Initialize database
db, err := database.New(cfg.DBPath)
if err != nil {
log.Fatal("Failed to initialize database:", err)
}
defer db.Close()
// Initialize brute force guard
guard := middleware.NewBruteForceGuard(cfg.MaxLoginAttempts, cfg.LockoutSeconds)
// Initialize handlers
authHandler := &handlers.AuthHandler{DB: db, Config: cfg, Guard: guard}
fsHandler := &handlers.FSHandler{DB: db, Config: cfg}
healthHandler := &handlers.HealthHandler{DB: db, Config: cfg}
settingsHandler := &handlers.SettingsHandler{DB: db}
tusHandler := handlers.NewTusHandler(db, cfg)
webdavHandler := handlers.NewWebDAVHandler(db, cfg)
archiveHandler := &handlers.ArchiveHandler{DB: db, Config: cfg}
shareHandler := &handlers.ShareHandler{DB: db, Config: cfg}
// Initialize background workers
workers.InitTaskManager(cfg)
workers.InitThumbnailManager(cfg, db)
workers.StartBackupWorker(cfg, db)
// Create Fiber app
app := fiber.New(fiber.Config{
BodyLimit: 100 * 1024 * 1024 * 1024, // 100 GB (for chunked uploads, each chunk is smaller)
StreamRequestBody: true,
DisableStartupMessage: false,
AppName: "Drive",
})
// Global middleware
app.Use(recover.New())
app.Use(logger.New(logger.Config{
Format: "${time} | ${status} | ${latency} | ${ip} | ${method} | ${path}\n",
TimeFormat: "2006-01-02 15:04:05",
}))
app.Use(helmet.New())
app.Use(cors.New(cors.Config{
AllowOrigins: "*",
AllowHeaders: "Origin, Content-Type, Accept, Authorization",
AllowMethods: "GET, POST, PUT, DELETE, PATCH, OPTIONS",
}))
// === PUBLIC ROUTES (no auth) ===
// Health check
app.Get("/health", healthHandler.Health)
// Setup routes (first-run only)
app.Get("/api/setup/status", authHandler.CheckSetup)
app.Post("/api/setup", authHandler.Setup)
// Public Share Endpoints (Rate Limited)
public := app.Group("/api/public")
public.Use(limiter.New(limiter.Config{
Max: 20,
Expiration: 1 * time.Minute,
}))
public.Post("/share/:id", shareHandler.GetPublicShare)
public.Get("/download/:id", shareHandler.DownloadPublicShare)
// Auth routes (with brute force protection)
auth := app.Group("/api/auth")
auth.Post("/login", guard.Check(), authHandler.Login)
// === PROTECTED ROUTES (require JWT) ===
protected := app.Group("/api", middleware.AuthMiddleware(cfg.JWTSecret))
// Token refresh
protected.Post("/auth/refresh", authHandler.Refresh)
// 2FA management
protected.Post("/auth/2fa/enable", authHandler.Enable2FA)
protected.Post("/auth/2fa/confirm", authHandler.Confirm2FA)
protected.Post("/auth/2fa/disable", authHandler.Disable2FA)
// Settings & Audit
protected.Get("/settings", settingsHandler.GetSettings)
protected.Put("/settings", settingsHandler.UpdateSettings)
protected.Get("/audit", settingsHandler.GetAuditLog)
// Search
protected.Get("/search", fsHandler.Search)
// Pinned items
protected.Get("/files/pinned", fsHandler.ListPinned)
protected.Post("/files/pin", fsHandler.TogglePin)
// Trash
protected.Get("/trash", fsHandler.ListTrash)
protected.Post("/trash/restore", fsHandler.RestoreFromTrash)
protected.Delete("/trash", fsHandler.EmptyTrash)
// Storage dashboard
protected.Get("/storage/dashboard", fsHandler.StorageDashboard)
// File operations
protected.Post("/files/mkdir", fsHandler.CreateFolder)
protected.Post("/files/rename", fsHandler.Rename)
protected.Post("/files/move", fsHandler.Move)
protected.Post("/files/copy", fsHandler.Copy)
protected.Post("/files/upload", fsHandler.Upload)
// File listing and download (with path jail)
files := protected.Group("/files", middleware.PathJail(cfg.StorageDir))
files.Get("/thumbnail/:checksum", fsHandler.ServeThumbnail)
files.Get("/download/*", fsHandler.Download)
files.Post("/zip", archiveHandler.Zip)
files.Post("/unzip", archiveHandler.Unzip)
files.Delete("/*", fsHandler.Delete)
files.Get("/*", fsHandler.ListDirectory)
// Tus resumable uploads
tus := protected.Group("/tus")
tus.Options("/", tusHandler.Options)
tus.Post("/", tusHandler.Create)
tus.Head("/:id", tusHandler.Head)
tus.Patch("/:id", tusHandler.Patch)
tus.Delete("/:id", tusHandler.Delete)
// Background Tasks
protected.Get("/tasks", archiveHandler.Tasks)
// Sharing
protected.Post("/share", shareHandler.CreateShare)
protected.Get("/share", shareHandler.ListShares)
protected.Delete("/share/:id", shareHandler.RevokeShare)
// WebDAV Endpoint (accepts basic auth OR bearer)
// We use All() because WebDAV uses custom HTTP methods (PROPFIND, MKCOL, LOCK, etc.)
app.All("/webdav/*", webdavHandler.Handle)
// Start server
addr := fmt.Sprintf(":%s", cfg.Port)
log.Printf("🚀 Drive server starting on %s", addr)
log.Printf("📁 Storage: %s", cfg.StorageDir)
log.Printf("💾 Data: %s", cfg.DataDir)
if err := app.Listen(addr); err != nil {
log.Fatal("Failed to start server:", err)
}
}
func generateSecret(length int) (string, error) {
bytes := make([]byte, length)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
return hex.EncodeToString(bytes), nil
}

View file

@ -0,0 +1,88 @@
package middleware
import (
"strings"
"time"
"github.com/gofiber/fiber/v2"
"github.com/golang-jwt/jwt/v5"
)
// AuthMiddleware validates JWT tokens from the Authorization header.
func AuthMiddleware(jwtSecret string) fiber.Handler {
return func(c *fiber.Ctx) error {
authHeader := c.Get("Authorization")
tokenString := ""
if authHeader != "" {
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) == 2 && strings.ToLower(parts[0]) == "bearer" {
tokenString = parts[1]
}
}
// Fallback to query parameter for img/video tags
if tokenString == "" {
tokenString = c.Query("token")
}
if tokenString == "" {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
"error": "missing or invalid authorization",
})
}
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fiber.NewError(fiber.StatusUnauthorized, "unexpected signing method")
}
return []byte(jwtSecret), nil
})
if err != nil || !token.Valid {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
"error": "invalid or expired token",
})
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
"error": "invalid token claims",
})
}
c.Locals("userID", claims["sub"])
c.Locals("username", claims["username"])
return c.Next()
}
}
// GenerateAccessToken creates a short-lived JWT access token.
func GenerateAccessToken(userID int, username, secret string, expirySecs int) (string, error) {
claims := jwt.MapClaims{
"sub": userID,
"username": username,
"exp": time.Now().Add(time.Duration(expirySecs) * time.Second).Unix(),
"iat": time.Now().Unix(),
"type": "access",
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(secret))
}
// GenerateRefreshToken creates a longer-lived refresh token.
func GenerateRefreshToken(userID int, username, secret string) (string, error) {
claims := jwt.MapClaims{
"sub": userID,
"username": username,
"exp": time.Now().Add(7 * 24 * time.Hour).Unix(), // 7 days
"iat": time.Now().Unix(),
"type": "refresh",
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(secret))
}

View file

@ -0,0 +1,104 @@
package middleware
import (
"sync"
"time"
"github.com/gofiber/fiber/v2"
)
type loginAttempt struct {
Count int
LockedAt time.Time
}
// BruteForceGuard tracks login attempts per IP and enforces exponential backoff.
type BruteForceGuard struct {
mu sync.Mutex
attempts map[string]*loginAttempt
maxAttempts int
lockoutSecs int
}
func NewBruteForceGuard(maxAttempts, lockoutSecs int) *BruteForceGuard {
guard := &BruteForceGuard{
attempts: make(map[string]*loginAttempt),
maxAttempts: maxAttempts,
lockoutSecs: lockoutSecs,
}
// Clean up stale entries every 10 minutes
go func() {
ticker := time.NewTicker(10 * time.Minute)
defer ticker.Stop()
for range ticker.C {
guard.cleanup()
}
}()
return guard
}
// Check returns a Fiber handler that blocks requests from locked-out IPs.
func (g *BruteForceGuard) Check() fiber.Handler {
return func(c *fiber.Ctx) error {
ip := c.IP()
g.mu.Lock()
attempt, exists := g.attempts[ip]
g.mu.Unlock()
if exists && attempt.Count >= g.maxAttempts {
lockoutDuration := time.Duration(g.lockoutSecs) * time.Second
if time.Since(attempt.LockedAt) < lockoutDuration {
remaining := lockoutDuration - time.Since(attempt.LockedAt)
return c.Status(fiber.StatusTooManyRequests).JSON(fiber.Map{
"error": "too many login attempts, account temporarily locked",
"retry_after": int(remaining.Seconds()),
})
}
// Lockout expired, reset
g.mu.Lock()
delete(g.attempts, ip)
g.mu.Unlock()
}
return c.Next()
}
}
// RecordFailure increments the failure count for an IP.
func (g *BruteForceGuard) RecordFailure(ip string) {
g.mu.Lock()
defer g.mu.Unlock()
attempt, exists := g.attempts[ip]
if !exists {
g.attempts[ip] = &loginAttempt{Count: 1, LockedAt: time.Now()}
return
}
attempt.Count++
if attempt.Count >= g.maxAttempts {
attempt.LockedAt = time.Now()
}
}
// RecordSuccess resets the failure count for an IP after a successful login.
func (g *BruteForceGuard) RecordSuccess(ip string) {
g.mu.Lock()
defer g.mu.Unlock()
delete(g.attempts, ip)
}
func (g *BruteForceGuard) cleanup() {
g.mu.Lock()
defer g.mu.Unlock()
cutoff := time.Duration(g.lockoutSecs*2) * time.Second
for ip, attempt := range g.attempts {
if time.Since(attempt.LockedAt) > cutoff {
delete(g.attempts, ip)
}
}
}

View file

@ -0,0 +1,68 @@
package middleware
import (
"fmt"
"path/filepath"
"strings"
"github.com/gofiber/fiber/v2"
)
// PathJail middleware ensures all file path parameters are confined within
// the storage root. Any path that resolves outside the jail is rejected
// immediately, preventing directory traversal attacks.
func PathJail(storageRoot string) fiber.Handler {
// Resolve the absolute jail root once at startup
absRoot, err := filepath.Abs(storageRoot)
if err != nil {
panic(fmt.Sprintf("pathjail: invalid storage root: %v", err))
}
// Ensure it ends with separator for prefix matching
absRootSlash := absRoot + string(filepath.Separator)
return func(c *fiber.Ctx) error {
// Extract the path parameter from the request.
// Handlers attach the relative file path as "filepath" param or query.
rawPath := c.Params("*")
if rawPath == "" {
rawPath = c.Query("path", "")
}
// If no path param, assume root path '.'
if rawPath == "" {
rawPath = "."
}
// Clean and resolve the path
cleaned := filepath.Clean(rawPath)
// Reject obvious traversal attempts before even joining
if strings.Contains(cleaned, "..") {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{
"error": "path traversal detected",
})
}
// Build the full absolute path within the jail
fullPath := filepath.Join(absRoot, cleaned)
absPath, err := filepath.Abs(fullPath)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": "invalid path",
})
}
// Final check: resolved path must be within the jail root
if absPath != absRoot && !strings.HasPrefix(absPath, absRootSlash) {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{
"error": "access denied: path outside storage boundary",
})
}
// Store the validated absolute path for downstream handlers
c.Locals("resolvedPath", absPath)
c.Locals("relativePath", cleaned)
return c.Next()
}
}

48
backend/workers/backup.go Normal file
View 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
View 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")
}

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