personal workspace backend flush

This commit is contained in:
Leon Bösche
2026-01-09 17:32:16 +01:00
parent ebb97f4f39
commit e16b1bb083
3 changed files with 233 additions and 39 deletions

View File

@@ -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", "/"),
}
}

View File

@@ -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/<orgId>/<parentPath>
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/<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 {
// 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/<orgId>/<parentPath>
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)

View File

@@ -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/<id>/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))
}