diff --git a/go_cloud/api b/go_cloud/api index e764123..8ade3c2 100755 Binary files a/go_cloud/api and b/go_cloud/api differ diff --git a/go_cloud/internal/http/routes.go b/go_cloud/internal/http/routes.go index c39b3a5..47f19a7 100644 --- a/go_cloud/internal/http/routes.go +++ b/go_cloud/internal/http/routes.go @@ -2,6 +2,7 @@ package http import ( "bytes" + "context" "encoding/json" "fmt" "io" @@ -28,10 +29,55 @@ import ( "go.b0esche.cloud/backend/internal/storage" ) +// getUserWebDAVClient gets or creates a user's Nextcloud account and returns a WebDAV client for them +func getUserWebDAVClient(ctx context.Context, db *database.DB, userID uuid.UUID, nextcloudBaseURL, adminUser, adminPass string) (*storage.WebDAVClient, error) { + var user struct { + NextcloudUsername string + NextcloudPassword string + Email string + } + + err := db.QueryRowContext(ctx, + "SELECT nextcloud_username, nextcloud_password, email FROM users WHERE id = $1", + userID).Scan(&user.NextcloudUsername, &user.NextcloudPassword, &user.Email) + if err != nil { + return nil, fmt.Errorf("failed to get user: %w", err) + } + + // If user doesn't have Nextcloud credentials, create them + if user.NextcloudUsername == "" || user.NextcloudPassword == "" { + // Use email prefix as username + ncUsername := strings.Split(user.Email, "@")[0] + ncPassword, err := storage.GenerateSecurePassword(32) + if err != nil { + return nil, fmt.Errorf("failed to generate password: %w", err) + } + + // Create Nextcloud user account + err = storage.CreateNextcloudUser(nextcloudBaseURL, adminUser, adminPass, ncUsername, ncPassword) + if err != nil { + return nil, fmt.Errorf("failed to create Nextcloud user: %w", err) + } + + // Update database with Nextcloud credentials + _, err = db.ExecContext(ctx, + "UPDATE users SET nextcloud_username = $1, nextcloud_password = $2 WHERE id = $3", + ncUsername, ncPassword, userID) + if err != nil { + return nil, fmt.Errorf("failed to update user credentials: %w", err) + } + + user.NextcloudUsername = ncUsername + user.NextcloudPassword = ncPassword + fmt.Printf("[AUTO-PROVISION] Created Nextcloud account for user %s: %s\n", userID, ncUsername) + } + + // Create user-specific WebDAV client + return storage.NewUserWebDAVClient(nextcloudBaseURL, user.NextcloudUsername, user.NextcloudPassword), nil +} + func NewRouter(cfg *config.Config, db *database.DB, jwtManager *jwt.Manager, authService *auth.Service, auditLogger *audit.Logger) http.Handler { r := chi.NewRouter() - // optional WebDAV/Nextcloud client - storageClient := storage.NewWebDAVClient(cfg) // Global middleware r.Use(middleware.RequestID) @@ -87,18 +133,18 @@ func NewRouter(cfg *config.Config, db *database.DB, jwtManager *jwt.Manager, aut }) // Download user file r.Get("/user/files/download", func(w http.ResponseWriter, req *http.Request) { - downloadUserFileHandler(w, req, db, storageClient) + downloadUserFileHandler(w, req, db, cfg) }) // Create / delete in user workspace r.Post("/user/files", func(w http.ResponseWriter, req *http.Request) { - createUserFileHandler(w, req, db, auditLogger, storageClient) + createUserFileHandler(w, req, db, auditLogger, cfg) }) r.Delete("/user/files", func(w http.ResponseWriter, req *http.Request) { - deleteUserFileHandler(w, req, db, auditLogger, storageClient) + deleteUserFileHandler(w, req, db, auditLogger, cfg) }) // POST wrapper for delete r.Post("/user/files/delete", func(w http.ResponseWriter, req *http.Request) { - deleteUserFilePostHandler(w, req, db, auditLogger, storageClient) + deleteUserFilePostHandler(w, req, db, auditLogger, cfg) }) // Org routes @@ -119,21 +165,21 @@ func NewRouter(cfg *config.Config, db *database.DB, jwtManager *jwt.Manager, aut }) // Download org file r.With(middleware.Permission(db, auditLogger, permission.FileRead)).Get("/files/download", func(w http.ResponseWriter, req *http.Request) { - downloadOrgFileHandler(w, req, db, storageClient) + downloadOrgFileHandler(w, req, db, cfg) }) // Create file/folder in org workspace r.With(middleware.Permission(db, auditLogger, permission.FileWrite)).Post("/files", func(w http.ResponseWriter, req *http.Request) { - createOrgFileHandler(w, req, db, auditLogger, storageClient) + createOrgFileHandler(w, req, db, auditLogger, cfg) }) // Also accept POST delete for clients that cannot send DELETE with body r.With(middleware.Permission(db, auditLogger, permission.FileWrite)).Post("/files/delete", func(w http.ResponseWriter, req *http.Request) { - deleteOrgFilePostHandler(w, req, db, auditLogger, storageClient) + deleteOrgFilePostHandler(w, req, db, auditLogger, cfg) }) // Delete file/folder in org workspace (body: {"path":"/path"}) r.With(middleware.Permission(db, auditLogger, permission.FileWrite)).Delete("/files", func(w http.ResponseWriter, req *http.Request) { - deleteOrgFileHandler(w, req, db, auditLogger, storageClient) + deleteOrgFileHandler(w, req, db, auditLogger, cfg) }) r.Route("/files/{fileId}", func(r chi.Router) { r.With(middleware.Permission(db, auditLogger, permission.DocumentView)).Get("/view", func(w http.ResponseWriter, req *http.Request) { @@ -1020,7 +1066,7 @@ func userFilesHandler(w http.ResponseWriter, r *http.Request, db *database.DB) { } // createOrgFileHandler creates a file or folder record for an org workspace. -func createOrgFileHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger, storageClient *storage.WebDAVClient) { +func createOrgFileHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger, cfg *config.Config) { orgID := r.Context().Value("org").(uuid.UUID) userIDStr, _ := middleware.GetUserID(r.Context()) userID, _ := uuid.Parse(userIDStr) @@ -1048,7 +1094,7 @@ func createOrgFileHandler(w http.ResponseWriter, r *http.Request, db *database.D } defer file.Close() - // Read file into memory (so we can attempt WebDAV upload and fallback to disk) + // Read file into memory data, err := io.ReadAll(file) if err != nil { errors.LogError(r, err, "Failed to read uploaded file") @@ -1056,19 +1102,21 @@ func createOrgFileHandler(w http.ResponseWriter, r *http.Request, db *database.D return } - // ONLY use Nextcloud WebDAV storage storedPath := filepath.ToSlash(filepath.Join(parentPath, header.Filename)) if !strings.HasPrefix(storedPath, "/") { storedPath = "/" + storedPath } written := int64(len(data)) - if storageClient == nil { + // Get or create user's WebDAV client + storageClient, err := getUserWebDAVClient(r.Context(), db, userID, cfg.NextcloudURL, cfg.NextcloudUser, cfg.NextcloudPass) + if err != nil { + errors.LogError(r, err, "Failed to get user WebDAV client") errors.WriteError(w, errors.CodeInternal, "Storage not configured", http.StatusInternalServerError) return } - // Build remote path under /orgs/ + // Upload to user's Nextcloud space under /orgs// rel := strings.TrimPrefix(storedPath, "/") remotePath := path.Join("/orgs", orgID.String(), rel) if err = storageClient.Upload(r.Context(), remotePath, bytes.NewReader(data), int64(len(data))); err != nil { @@ -1125,7 +1173,7 @@ func createOrgFileHandler(w http.ResponseWriter, r *http.Request, db *database.D } // deleteOrgFileHandler deletes a file/folder in org workspace by path -func deleteOrgFileHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger, storageClient *storage.WebDAVClient) { +func deleteOrgFileHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger, cfg *config.Config) { orgID := r.Context().Value("org").(uuid.UUID) userIDStr, _ := middleware.GetUserID(r.Context()) userID, _ := uuid.Parse(userIDStr) @@ -1138,8 +1186,11 @@ func deleteOrgFileHandler(w http.ResponseWriter, r *http.Request, db *database.D return } - // Delete from Nextcloud if configured - if storageClient != nil { + // Get or create user's WebDAV client and delete from Nextcloud + storageClient, err := getUserWebDAVClient(r.Context(), db, userID, cfg.NextcloudURL, cfg.NextcloudUser, cfg.NextcloudPass) + if err != nil { + errors.LogError(r, err, "Failed to get user WebDAV client (continuing with database deletion)") + } else { rel := strings.TrimPrefix(req.Path, "/") remotePath := path.Join("/orgs", orgID.String(), rel) if err := storageClient.Delete(r.Context(), remotePath); err != nil { @@ -1166,12 +1217,12 @@ func deleteOrgFileHandler(w http.ResponseWriter, r *http.Request, db *database.D } // Also accept POST /orgs/{orgId}/files/delete for clients that cannot send DELETE with body -func deleteOrgFilePostHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger, storageClient *storage.WebDAVClient) { - deleteOrgFileHandler(w, r, db, auditLogger, storageClient) +func deleteOrgFilePostHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger, cfg *config.Config) { + deleteOrgFileHandler(w, r, db, auditLogger, cfg) } // createUserFileHandler creates a file or folder record for the authenticated user's personal workspace. -func createUserFileHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger, storageClient *storage.WebDAVClient) { +func createUserFileHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger, cfg *config.Config) { userIDStr, ok := middleware.GetUserID(r.Context()) if !ok || userIDStr == "" { errors.WriteError(w, errors.CodeUnauthenticated, "Unauthorized", http.StatusUnauthorized) @@ -1199,7 +1250,7 @@ func createUserFileHandler(w http.ResponseWriter, r *http.Request, db *database. return } defer file.Close() - // Read file into memory to allow WebDAV upload and disk fallback + // Read file into memory to allow WebDAV upload data, err := io.ReadAll(file) if err != nil { errors.LogError(r, err, "Failed to read uploaded file") @@ -1213,16 +1264,18 @@ func createUserFileHandler(w http.ResponseWriter, r *http.Request, db *database. written := int64(len(data)) fmt.Printf("[DEBUG] Upload: user=%s, file=%s, size=%d, path=%s\n", userID.String(), header.Filename, len(data), storedPath) - // ONLY use Nextcloud WebDAV storage - if storageClient == nil { + // Get or create user's WebDAV client + storageClient, err := getUserWebDAVClient(r.Context(), db, userID, cfg.NextcloudURL, cfg.NextcloudUser, cfg.NextcloudPass) + if err != nil { + errors.LogError(r, err, "Failed to get user WebDAV client") errors.WriteError(w, errors.CodeInternal, "Storage not configured", http.StatusInternalServerError) return } - rel := strings.TrimPrefix(storedPath, "/") - remotePath := path.Join("/users", userID.String(), rel) - fmt.Printf("[DEBUG] Uploading to WebDAV: %s\n", remotePath) - if err = storageClient.Upload(r.Context(), remotePath, bytes.NewReader(data), int64(len(data))); err != nil { + // Upload to user's personal Nextcloud space (just the path, no username prefix) + remotePath := strings.TrimPrefix(storedPath, "/") + fmt.Printf("[DEBUG] Uploading to user WebDAV: /%s\n", remotePath) + if err = storageClient.Upload(r.Context(), "/"+remotePath, bytes.NewReader(data), int64(len(data))); err != nil { errors.LogError(r, err, "WebDAV upload failed") errors.WriteError(w, errors.CodeInternal, "Failed to upload file to storage", http.StatusInternalServerError) return @@ -1275,12 +1328,12 @@ func createUserFileHandler(w http.ResponseWriter, r *http.Request, db *database. } // Also accept POST /user/files/delete -func deleteUserFilePostHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger, storageClient *storage.WebDAVClient) { - deleteUserFileHandler(w, r, db, auditLogger, storageClient) +func deleteUserFilePostHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger, cfg *config.Config) { + deleteUserFileHandler(w, r, db, auditLogger, cfg) } // deleteUserFileHandler deletes a file/folder in user's personal workspace by path -func deleteUserFileHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger, storageClient *storage.WebDAVClient) { +func deleteUserFileHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger, cfg *config.Config) { userIDStr, ok := middleware.GetUserID(r.Context()) if !ok || userIDStr == "" { errors.WriteError(w, errors.CodeUnauthenticated, "Unauthorized", http.StatusUnauthorized) @@ -1296,12 +1349,13 @@ func deleteUserFileHandler(w http.ResponseWriter, r *http.Request, db *database. return } - // Delete from Nextcloud if configured - if storageClient != nil { - rel := strings.TrimPrefix(req.Path, "/") - // Keep remote user workspace path consistent with uploads: "/users//" - remotePath := path.Join("/users", userID.String(), rel) - if err := storageClient.Delete(r.Context(), remotePath); err != nil { + // Get or create user's WebDAV client and delete from Nextcloud + storageClient, err := getUserWebDAVClient(r.Context(), db, userID, cfg.NextcloudURL, cfg.NextcloudUser, cfg.NextcloudPass) + if err != nil { + errors.LogError(r, err, "Failed to get user WebDAV client (continuing with database deletion)") + } else { + remotePath := strings.TrimPrefix(req.Path, "/") + if err := storageClient.Delete(r.Context(), "/"+remotePath); err != nil { errors.LogError(r, err, "Failed to delete from Nextcloud (continuing anyway)") } } @@ -1324,8 +1378,10 @@ func deleteUserFileHandler(w http.ResponseWriter, r *http.Request, db *database. } // downloadOrgFileHandler downloads a file from org workspace -func downloadOrgFileHandler(w http.ResponseWriter, r *http.Request, db *database.DB, storageClient *storage.WebDAVClient) { +func downloadOrgFileHandler(w http.ResponseWriter, r *http.Request, db *database.DB, cfg *config.Config) { orgID := r.Context().Value("org").(uuid.UUID) + userIDStr, _ := middleware.GetUserID(r.Context()) + userID, _ := uuid.Parse(userIDStr) filePath := r.URL.Query().Get("path") if filePath == "" { @@ -1333,15 +1389,17 @@ func downloadOrgFileHandler(w http.ResponseWriter, r *http.Request, db *database return } - // ONLY use Nextcloud WebDAV storage - if storageClient == nil { + // Get or create user's WebDAV client + storageClient, err := getUserWebDAVClient(r.Context(), db, userID, cfg.NextcloudURL, cfg.NextcloudUser, cfg.NextcloudPass) + if err != nil { + errors.LogError(r, err, "Failed to get user WebDAV client") errors.WriteError(w, errors.CodeInternal, "Storage not configured", http.StatusInternalServerError) return } + // Download from user's Nextcloud space under /orgs// rel := strings.TrimPrefix(filePath, "/") remotePath := path.Join("/orgs", orgID.String(), rel) - reader, size, err := storageClient.Download(r.Context(), remotePath) if err != nil { errors.LogError(r, err, "Failed to download from Nextcloud") @@ -1373,7 +1431,7 @@ func downloadOrgFileHandler(w http.ResponseWriter, r *http.Request, db *database } // downloadUserFileHandler downloads a file from user's personal workspace -func downloadUserFileHandler(w http.ResponseWriter, r *http.Request, db *database.DB, storageClient *storage.WebDAVClient) { +func downloadUserFileHandler(w http.ResponseWriter, r *http.Request, db *database.DB, cfg *config.Config) { userIDStr, ok := middleware.GetUserID(r.Context()) if !ok || userIDStr == "" { errors.WriteError(w, errors.CodeUnauthenticated, "Unauthorized", http.StatusUnauthorized) @@ -1390,18 +1448,19 @@ func downloadUserFileHandler(w http.ResponseWriter, r *http.Request, db *databas // Log the requested file path for debugging fmt.Printf("[DEBUG] Download request - User: %s, Path: %s\n", userID.String(), filePath) - // ONLY use Nextcloud WebDAV storage - if storageClient == nil { + // Get or create user's WebDAV client + storageClient, err := getUserWebDAVClient(r.Context(), db, userID, cfg.NextcloudURL, cfg.NextcloudUser, cfg.NextcloudPass) + if err != nil { + errors.LogError(r, err, "Failed to get user WebDAV client") errors.WriteError(w, errors.CodeInternal, "Storage not configured", http.StatusInternalServerError) return } - rel := strings.TrimPrefix(filePath, "/") - // Keep remote user workspace path consistent with uploads: "/users//" - remotePath := path.Join("/users", userID.String(), rel) - fmt.Printf("[DEBUG] Trying WebDAV path: %s\n", remotePath) + // Download from user's personal Nextcloud space + remotePath := strings.TrimPrefix(filePath, "/") + fmt.Printf("[DEBUG] Downloading from user WebDAV: /%s\n", remotePath) - reader, size, err := storageClient.Download(r.Context(), remotePath) + reader, size, err := storageClient.Download(r.Context(), "/"+remotePath) if err != nil { errors.LogError(r, err, "Failed to download from Nextcloud") errors.WriteError(w, errors.CodeNotFound, "File not found", http.StatusNotFound) diff --git a/go_cloud/internal/storage/nextcloud.go b/go_cloud/internal/storage/nextcloud.go new file mode 100644 index 0000000..831d704 --- /dev/null +++ b/go_cloud/internal/storage/nextcloud.go @@ -0,0 +1,77 @@ +package storage + +import ( + "bytes" + "crypto/rand" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" +) + +// CreateNextcloudUser creates a new Nextcloud user account via OCS API +func CreateNextcloudUser(nextcloudBaseURL, adminUser, adminPass, username, password string) error { + // Remove any path from base URL, we need just the scheme://host:port + baseURL := strings.Split(nextcloudBaseURL, "/remote.php")[0] + url := fmt.Sprintf("%s/ocs/v1.php/cloud/users", baseURL) + + payload := map[string]string{ + "userid": username, + "password": password, + } + + jsonData, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("failed to marshal payload: %w", err) + } + + req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData)) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + req.SetBasicAuth(adminUser, adminPass) + req.Header.Set("OCS-APIRequest", "true") + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("failed to create user: %w", err) + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + + // 200 = success, 409 = user already exists (which is fine) + if resp.StatusCode != 200 && resp.StatusCode != 409 { + return fmt.Errorf("failed to create Nextcloud user (status %d): %s", resp.StatusCode, string(body)) + } + + fmt.Printf("[NEXTCLOUD] Created user account: %s\n", username) + return nil +} + +// GenerateSecurePassword generates a random secure password +func GenerateSecurePassword(length int) (string, error) { + bytes := make([]byte, length) + if _, err := rand.Read(bytes); err != nil { + return "", err + } + return base64.URLEncoding.EncodeToString(bytes)[:length], nil +} + +// NewUserWebDAVClient creates a WebDAV client for a specific user +func NewUserWebDAVClient(nextcloudBaseURL, username, password string) *WebDAVClient { + baseURL := fmt.Sprintf("%s/remote.php/dav/files/%s", nextcloudBaseURL, username) + + return &WebDAVClient{ + baseURL: baseURL, + user: username, + pass: password, + basePrefix: "/", + httpClient: &http.Client{}, + } +}