All checks were successful
Automated Container Build / build-and-push (push) Successful in 46s
291 lines
9.5 KiB
Go
291 lines
9.5 KiB
Go
package main
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"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 == "" {
|
|
cfg.JWTSecret = getOrCreateJWTSecret(cfg.DataDir)
|
|
}
|
|
|
|
// 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.NewAuthHandler(db, cfg, 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)
|
|
// WebDAV Handler
|
|
webdavHandler := handlers.NewWebDAVHandler(db, cfg, guard)
|
|
archiveHandler := &handlers.ArchiveHandler{DB: db, Config: cfg}
|
|
shareGuard := middleware.NewBruteForceGuard(3, 60)
|
|
shareHandler := &handlers.ShareHandler{DB: db, Config: cfg, Guard: shareGuard}
|
|
onlyOfficeHandler := &handlers.OnlyOfficeHandler{Config: cfg, DB: db}
|
|
|
|
// Initialize background workers
|
|
workers.InitTaskManager(cfg)
|
|
workers.InitThumbnailManager(cfg, db)
|
|
workers.StartBackupWorker(cfg, db)
|
|
workers.StartTrashWorker(cfg, db)
|
|
|
|
// Start prune worker
|
|
go func() {
|
|
ticker := time.NewTicker(12 * time.Hour)
|
|
for range ticker.C {
|
|
workers.Tasks.CleanOldTasks()
|
|
db.CleanOldAuditLogs()
|
|
}
|
|
}()
|
|
|
|
// 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(func(c *fiber.Ctx) error {
|
|
c.Set("Cache-Control", "no-store, no-cache, must-revalidate")
|
|
return c.Next()
|
|
})
|
|
app.Use(helmet.New())
|
|
app.Use(func(c *fiber.Ctx) error {
|
|
c.Set("Content-Security-Policy", "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self' wss: https: http:;")
|
|
return c.Next()
|
|
})
|
|
allowedOrigins := os.Getenv("ALLOWED_ORIGINS")
|
|
if allowedOrigins == "" {
|
|
log.Println("WARNING: ALLOWED_ORIGINS not set \u2014 CORS will reject all cross-origin requests")
|
|
allowedOrigins = "https://no-origin-configured.invalid"
|
|
}
|
|
|
|
app.Use(cors.New(cors.Config{
|
|
AllowOrigins: allowedOrigins,
|
|
AllowHeaders: "Origin, Content-Type, Accept, Authorization, Upload-Length, Upload-Offset, Upload-Metadata, Tus-Resumable",
|
|
AllowMethods: "GET, POST, PUT, DELETE, PATCH, OPTIONS, HEAD",
|
|
ExposeHeaders: "Location, Upload-Offset, Upload-Length, Tus-Resumable, Tus-Version, Tus-Extension, Tus-Max-Size",
|
|
}))
|
|
|
|
// === 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: 500,
|
|
Expiration: 1 * time.Minute,
|
|
}))
|
|
public.Post("/share/:id/token", shareGuard.Check(), shareHandler.CreateShareToken)
|
|
public.Post("/share/:id", shareGuard.Check(), shareHandler.GetPublicShare)
|
|
public.Get("/download/:id", shareHandler.DownloadPublicShare)
|
|
|
|
// OnlyOffice callback (public but validates query token)
|
|
public.Post("/onlyoffice/callback", onlyOfficeHandler.Callback)
|
|
// OnlyOffice file download (public but validates query token — bypasses auth middleware
|
|
// since requests come from the Document Server via Next.js proxy which can drop query params)
|
|
public.Get("/onlyoffice/download", onlyOfficeHandler.DownloadForEditor)
|
|
|
|
// 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)
|
|
|
|
// Password management
|
|
protected.Put("/auth/password", authHandler.ChangePassword)
|
|
|
|
// 2FA management
|
|
protected.Get("/auth/download-token", authHandler.GetDownloadToken)
|
|
protected.Get("/auth/edit-token", authHandler.GetEditToken)
|
|
protected.Post("/auth/2fa/enable", authHandler.Enable2FA)
|
|
protected.Post("/auth/2fa/confirm", authHandler.Confirm2FA)
|
|
protected.Post("/auth/2fa/disable", authHandler.Disable2FA)
|
|
|
|
// Download tokens
|
|
protected.Get("/auth/download-token", authHandler.GetDownloadToken)
|
|
|
|
// 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)
|
|
protected.Post("/files/check-conflicts", fsHandler.CheckConflicts)
|
|
|
|
// OnlyOffice
|
|
protected.Post("/onlyoffice/create", onlyOfficeHandler.CreateBlank)
|
|
protected.Post("/onlyoffice/template", onlyOfficeHandler.CreateFromTemplate)
|
|
protected.Post("/onlyoffice/sign", onlyOfficeHandler.SignConfig)
|
|
protected.Get("/onlyoffice/files", onlyOfficeHandler.ListOfficeFiles)
|
|
|
|
|
|
|
|
protected.Get("/files/folders-tree", fsHandler.GetFolderTree)
|
|
|
|
// File listing and download (with path jail)
|
|
files := protected.Group("/files", middleware.PathJail(cfg.StorageDir))
|
|
files.Get("/info/*", fsHandler.GetExtendedFileInfo)
|
|
files.Get("/thumbnail/:checksum", fsHandler.ServeThumbnail)
|
|
files.Get("/download-folder", fsHandler.DownloadFolder)
|
|
files.Get("/archive-contents", archiveHandler.GetArchiveContents)
|
|
files.Post("/download-bulk", fsHandler.DownloadBulk)
|
|
files.Get("/download", fsHandler.Download)
|
|
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/*", guard.Check(), 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
|
|
}
|
|
|
|
func getOrCreateJWTSecret(dataDir string) string {
|
|
if secret := os.Getenv("JWT_SECRET"); secret != "" {
|
|
return secret
|
|
}
|
|
|
|
secretPath := filepath.Join(dataDir, ".jwt_secret")
|
|
|
|
if data, err := os.ReadFile(secretPath); err == nil {
|
|
secret := strings.TrimSpace(string(data))
|
|
if len(secret) >= 32 {
|
|
return secret
|
|
}
|
|
}
|
|
|
|
buf := make([]byte, 32)
|
|
if _, err := rand.Read(buf); err != nil {
|
|
log.Fatal("failed to generate JWT secret: ", err)
|
|
}
|
|
secret := base64.URLEncoding.EncodeToString(buf)
|
|
|
|
if err := os.WriteFile(secretPath, []byte(secret), 0600); err != nil {
|
|
log.Printf("WARNING: could not persist JWT secret to %s: %v", secretPath, err)
|
|
log.Printf("Sessions will be invalidated on next restart.")
|
|
} else {
|
|
log.Printf("Generated and persisted new JWT secret to %s", secretPath)
|
|
}
|
|
|
|
return secret
|
|
}
|