Implement user provisioning for Nextcloud accounts and enhance WebDAV client handling

This commit is contained in:
Leon Bösche
2026-01-10 22:58:35 +01:00
parent 2f20241ba6
commit 18600a6bc1
3 changed files with 185 additions and 49 deletions

Binary file not shown.

View File

@@ -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/<orgId>
// Upload to user's Nextcloud space under /orgs/<orgID>/
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/<userID>/<rel>"
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/<orgID>/
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/<userID>/<rel>"
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)

View File

@@ -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{},
}
}