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