This commit is contained in:
parent
fa2be029a2
commit
724d70e58b
3339 changed files with 1075535 additions and 0 deletions
44
backend/Dockerfile
Normal file
44
backend/Dockerfile
Normal 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
57
backend/config/config.go
Normal 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
|
||||
}
|
||||
186
backend/database/database.go
Normal file
186
backend/database/database.go
Normal 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
15
backend/go.mod
Normal 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
|
||||
)
|
||||
151
backend/handlers/archives.go
Normal file
151
backend/handlers/archives.go
Normal 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
315
backend/handlers/auth.go
Normal 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
807
backend/handlers/files.go
Normal 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)
|
||||
}
|
||||
53
backend/handlers/health.go
Normal file
53
backend/handlers/health.go
Normal 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,
|
||||
})
|
||||
}
|
||||
95
backend/handlers/settings.go
Normal file
95
backend/handlers/settings.go
Normal 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
236
backend/handlers/sharing.go
Normal 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
322
backend/handlers/tus.go
Normal 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
|
||||
}
|
||||
118
backend/handlers/versioning.go
Normal file
118
backend/handlers/versioning.go
Normal 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(¤tMax)
|
||||
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
131
backend/handlers/webdav.go
Normal 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
204
backend/main.go
Normal 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
|
||||
}
|
||||
88
backend/middleware/auth.go
Normal file
88
backend/middleware/auth.go
Normal 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))
|
||||
}
|
||||
104
backend/middleware/bruteforce.go
Normal file
104
backend/middleware/bruteforce.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
68
backend/middleware/pathjail.go
Normal file
68
backend/middleware/pathjail.go
Normal 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
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