From e16b1bb083a73182495b9703d78b8f6c8c8bccd9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leon=20B=C3=B6sche?= Date: Fri, 9 Jan 2026 17:32:16 +0100 Subject: [PATCH] personal workspace backend flush --- go_cloud/internal/config/config.go | 8 ++ go_cloud/internal/http/routes.go | 151 +++++++++++++++++++++------- go_cloud/internal/storage/webdav.go | 113 +++++++++++++++++++++ 3 files changed, 233 insertions(+), 39 deletions(-) create mode 100644 go_cloud/internal/storage/webdav.go diff --git a/go_cloud/internal/config/config.go b/go_cloud/internal/config/config.go index 75bd752..f9e464c 100644 --- a/go_cloud/internal/config/config.go +++ b/go_cloud/internal/config/config.go @@ -12,6 +12,10 @@ type Config struct { OIDCClientID string OIDCClientSecret string JWTSecret string + NextcloudURL string + NextcloudUser string + NextcloudPass string + NextcloudBase string } func Load() *Config { @@ -23,6 +27,10 @@ func Load() *Config { OIDCClientID: os.Getenv("OIDC_CLIENT_ID"), OIDCClientSecret: os.Getenv("OIDC_CLIENT_SECRET"), JWTSecret: os.Getenv("JWT_SECRET"), + NextcloudURL: os.Getenv("NEXTCLOUD_URL"), + NextcloudUser: os.Getenv("NEXTCLOUD_USER"), + NextcloudPass: os.Getenv("NEXTCLOUD_PASSWORD"), + NextcloudBase: getEnv("NEXTCLOUD_BASEPATH", "/"), } } diff --git a/go_cloud/internal/http/routes.go b/go_cloud/internal/http/routes.go index ce4e1de..a1313ae 100644 --- a/go_cloud/internal/http/routes.go +++ b/go_cloud/internal/http/routes.go @@ -1,11 +1,14 @@ package http import ( + "bytes" "encoding/json" "fmt" "io" + "mime/multipart" "net/http" "os" + "path" "path/filepath" "strings" "time" @@ -22,10 +25,13 @@ import ( "github.com/go-chi/chi/v5" "github.com/google/uuid" + "go.b0esche.cloud/backend/internal/storage" ) 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) @@ -74,7 +80,7 @@ func NewRouter(cfg *config.Config, db *database.DB, jwtManager *jwt.Manager, aut }) // Create / delete in user workspace r.Post("/user/files", func(w http.ResponseWriter, req *http.Request) { - createUserFileHandler(w, req, db, auditLogger) + createUserFileHandler(w, req, db, auditLogger, storageClient) }) r.Delete("/user/files", func(w http.ResponseWriter, req *http.Request) { deleteUserFileHandler(w, req, db, auditLogger) @@ -103,7 +109,7 @@ func NewRouter(cfg *config.Config, db *database.DB, jwtManager *jwt.Manager, aut // 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) + createOrgFileHandler(w, req, db, auditLogger, storageClient) }) // 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) { @@ -845,16 +851,18 @@ 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) { +func createOrgFileHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger, storageClient *storage.WebDAVClient) { orgID := r.Context().Value("org").(uuid.UUID) userIDStr, _ := r.Context().Value("user").(string) userID, _ := uuid.Parse(userIDStr) + var f *database.File + var err error // Support multipart uploads (field "file") or JSON metadata for folders contentType := r.Header.Get("Content-Type") if strings.HasPrefix(contentType, "multipart/form-data") { // Handle file upload - if err := r.ParseMultipartForm(32 << 20); err != nil { + if err = r.ParseMultipartForm(32 << 20); err != nil { errors.WriteError(w, errors.CodeInvalidArgument, "Bad multipart request", http.StatusBadRequest) return } @@ -862,43 +870,79 @@ func createOrgFileHandler(w http.ResponseWriter, r *http.Request, db *database.D if parentPath == "" { parentPath = "/" } - file, header, err := r.FormFile("file") + var file multipart.File + var header *multipart.FileHeader + file, header, err = r.FormFile("file") if err != nil { errors.WriteError(w, errors.CodeInvalidArgument, "Missing file", http.StatusBadRequest) return } defer file.Close() - // Save to disk under data/uploads/orgs// - baseDir := filepath.Join("data", "uploads", "orgs", orgID.String()) - targetDir := filepath.Join(baseDir, parentPath) - if err := os.MkdirAll(targetDir, 0o755); err != nil { - errors.LogError(r, err, "Failed to create target dir") + // Read file into memory (so we can attempt WebDAV upload and fallback to disk) + data, err := io.ReadAll(file) + if err != nil { + errors.LogError(r, err, "Failed to read uploaded file") errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError) return } - outPath := filepath.Join(targetDir, header.Filename) - outFile, err := os.Create(outPath) - if err != nil { - errors.LogError(r, err, "Failed to create file") + // Attempt WebDAV upload when configured + storedPath := filepath.ToSlash(filepath.Join(parentPath, header.Filename)) + if !strings.HasPrefix(storedPath, "/") { + storedPath = "/" + storedPath + } + written := int64(len(data)) + if storageClient != nil { + // Build remote path 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 { + // Log and fallback to local disk + errors.LogError(r, err, "WebDAV upload failed, falling back to local disk") + } else { + // success -> persist metadata and return + f, err = db.CreateFile(r.Context(), &orgID, &userID, header.Filename, storedPath, "file", written) + if err != nil { + errors.LogError(r, err, "Failed to create org file") + errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError) + return + } + + auditLogger.Log(r.Context(), audit.Entry{ + UserID: &userID, + OrgID: &orgID, + Action: "upload_file", + Success: true, + }) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{"id": f.ID}) + return + } + } + + // Fallback: Save to disk under data/uploads/orgs// + baseDir := filepath.Join("data", "uploads", "orgs", orgID.String()) + targetDir := filepath.Join(baseDir, parentPath) + if err = os.MkdirAll(targetDir, 0o755); err != nil { + errors.LogError(r, err, "Failed to create target dir") errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError) return } - defer outFile.Close() - written, err := io.Copy(outFile, file) - if err != nil { + outPath := filepath.Join(targetDir, header.Filename) + if err = os.WriteFile(outPath, data, 0o644); err != nil { errors.LogError(r, err, "Failed to write file") errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError) return } // Store metadata in DB; store path relative to workspace root - storedPath := filepath.ToSlash(filepath.Join(parentPath, header.Filename)) + storedPath = filepath.ToSlash(filepath.Join(parentPath, header.Filename)) if !strings.HasPrefix(storedPath, "/") { storedPath = "/" + storedPath } - f, err := db.CreateFile(r.Context(), &orgID, &userID, header.Filename, storedPath, "file", written) + f, err = db.CreateFile(r.Context(), &orgID, &userID, header.Filename, storedPath, "file", written) if err != nil { errors.LogError(r, err, "Failed to create org file") errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError) @@ -928,7 +972,7 @@ func createOrgFileHandler(w http.ResponseWriter, r *http.Request, db *database.D return } - f, err := db.CreateFile(r.Context(), &orgID, &userID, req.Name, req.Path, req.Type, req.Size) + f, err = db.CreateFile(r.Context(), &orgID, &userID, req.Name, req.Path, req.Type, req.Size) if err != nil { errors.LogError(r, err, "Failed to create org file") errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError) @@ -983,17 +1027,19 @@ func deleteOrgFilePostHandler(w http.ResponseWriter, r *http.Request, db *databa } // 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) { +func createUserFileHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger, storageClient *storage.WebDAVClient) { userIDStr, ok := r.Context().Value("user").(string) if !ok || userIDStr == "" { errors.WriteError(w, errors.CodeUnauthenticated, "Unauthorized", http.StatusUnauthorized) return } userID, _ := uuid.Parse(userIDStr) + var f *database.File + var err error // Support multipart uploads for file content or JSON for folders contentType := r.Header.Get("Content-Type") if strings.HasPrefix(contentType, "multipart/form-data") { - if err := r.ParseMultipartForm(32 << 20); err != nil { + if err = r.ParseMultipartForm(32 << 20); err != nil { errors.WriteError(w, errors.CodeInvalidArgument, "Bad multipart request", http.StatusBadRequest) return } @@ -1001,40 +1047,67 @@ func createUserFileHandler(w http.ResponseWriter, r *http.Request, db *database. if parentPath == "" { parentPath = "/" } - file, header, err := r.FormFile("file") + var file multipart.File + var header *multipart.FileHeader + file, header, err = r.FormFile("file") if err != nil { errors.WriteError(w, errors.CodeInvalidArgument, "Missing file", http.StatusBadRequest) return } defer file.Close() + // Read file into memory to allow WebDAV upload and disk fallback + data, err := io.ReadAll(file) + if err != nil { + errors.LogError(r, err, "Failed to read uploaded file") + errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError) + return + } + storedPath := filepath.ToSlash(filepath.Join(parentPath, header.Filename)) + if !strings.HasPrefix(storedPath, "/") { + storedPath = "/" + storedPath + } + written := int64(len(data)) + if storageClient != nil { + rel := strings.TrimPrefix(storedPath, "/") + remotePath := path.Join("/users", userID.String(), rel) + if err = storageClient.Upload(r.Context(), remotePath, bytes.NewReader(data), int64(len(data))); err != nil { + errors.LogError(r, err, "WebDAV upload failed, falling back to local disk") + } else { + f, err = db.CreateFile(r.Context(), nil, &userID, header.Filename, storedPath, "file", written) + if err != nil { + errors.LogError(r, err, "Failed to create user file") + errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError) + return + } + auditLogger.Log(r.Context(), audit.Entry{ + UserID: &userID, + Action: "upload_user_file", + Success: true, + }) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{"id": f.ID}) + return + } + } + + // Fallback: write to disk baseDir := filepath.Join("data", "uploads", "users", userID.String()) targetDir := filepath.Join(baseDir, parentPath) - if err := os.MkdirAll(targetDir, 0o755); err != nil { + if err = os.MkdirAll(targetDir, 0o755); err != nil { errors.LogError(r, err, "Failed to create target dir") errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError) return } outPath := filepath.Join(targetDir, header.Filename) - outFile, err := os.Create(outPath) - if err != nil { + if err = os.WriteFile(outPath, data, 0o644); err != nil { errors.LogError(r, err, "Failed to create file") errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError) return } - defer outFile.Close() - written, err := io.Copy(outFile, file) - if err != nil { - errors.LogError(r, err, "Failed to write file") - errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError) - return - } - storedPath := filepath.ToSlash(filepath.Join(parentPath, header.Filename)) - if !strings.HasPrefix(storedPath, "/") { - storedPath = "/" + storedPath - } - f, err := db.CreateFile(r.Context(), nil, &userID, header.Filename, storedPath, "file", written) + f, err = db.CreateFile(r.Context(), nil, &userID, header.Filename, storedPath, "file", written) if err != nil { errors.LogError(r, err, "Failed to create user file") errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError) @@ -1063,7 +1136,7 @@ func createUserFileHandler(w http.ResponseWriter, r *http.Request, db *database. return } - f, err := db.CreateFile(r.Context(), nil, &userID, req.Name, req.Path, req.Type, req.Size) + f, err = db.CreateFile(r.Context(), nil, &userID, req.Name, req.Path, req.Type, req.Size) if err != nil { errors.LogError(r, err, "Failed to create user file") errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError) diff --git a/go_cloud/internal/storage/webdav.go b/go_cloud/internal/storage/webdav.go new file mode 100644 index 0000000..6630817 --- /dev/null +++ b/go_cloud/internal/storage/webdav.go @@ -0,0 +1,113 @@ +package storage + +import ( + "context" + "fmt" + "io" + "net/http" + "net/url" + "path" + "strings" + + "go.b0esche.cloud/backend/internal/config" +) + +type WebDAVClient struct { + baseURL string + user string + pass string + basePrefix string + httpClient *http.Client +} + +// NewWebDAVClient returns nil if no Nextcloud URL configured +func NewWebDAVClient(cfg *config.Config) *WebDAVClient { + if cfg == nil || strings.TrimSpace(cfg.NextcloudURL) == "" { + return nil + } + u := strings.TrimRight(cfg.NextcloudURL, "/") + base := cfg.NextcloudBase + if base == "" { + base = "/" + } + return &WebDAVClient{ + baseURL: u, + user: cfg.NextcloudUser, + pass: cfg.NextcloudPass, + basePrefix: strings.TrimRight(base, "/"), + httpClient: &http.Client{}, + } +} + +// ensureParent creates intermediate collections using MKCOL. Ignoring errors when already exists. +func (c *WebDAVClient) ensureParent(ctx context.Context, remotePath string) error { + // build incremental paths + dir := path.Dir(remotePath) + if dir == "." || dir == "/" || dir == "" { + return nil + } + // split and build prefixes + parts := strings.Split(strings.Trim(dir, "/"), "/") + cur := c.basePrefix + for _, p := range parts { + cur = path.Join(cur, p) + mkurl := fmt.Sprintf("%s%s", c.baseURL, cur) + req, _ := http.NewRequestWithContext(ctx, "MKCOL", mkurl, nil) + if c.user != "" { + req.SetBasicAuth(c.user, c.pass) + } + resp, err := c.httpClient.Do(req) + if err != nil { + return err + } + resp.Body.Close() + // 201 created, 405 exists — ignore + if resp.StatusCode == 201 || resp.StatusCode == 405 { + continue + } + } + return nil +} + +// Upload streams the content to the remotePath using HTTP PUT (WebDAV). remotePath should be absolute under basePrefix. +func (c *WebDAVClient) Upload(ctx context.Context, remotePath string, r io.Reader, size int64) error { + if c == nil { + return fmt.Errorf("no webdav client configured") + } + // Ensure parent collections + if err := c.ensureParent(ctx, remotePath); err != nil { + return err + } + // Construct URL + // remotePath might be like /orgs//file.txt; ensure it joins to basePrefix + rel := strings.TrimLeft(remotePath, "/") + u := c.basePrefix + if u == "/" || u == "" { + u = "/" + } + full := fmt.Sprintf("%s%s/%s", c.baseURL, u, url.PathEscape(rel)) + full = strings.ReplaceAll(full, "%2F", "/") + + req, err := http.NewRequestWithContext(ctx, "PUT", full, r) + if err != nil { + return err + } + if size > 0 { + req.ContentLength = size + } + if c.user != "" { + req.SetBasicAuth(c.user, c.pass) + } + req.Header.Set("Content-Type", "application/octet-stream") + + resp, err := c.httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + return nil + } + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("webdav upload failed: %d %s", resp.StatusCode, string(body)) +}