personal workspace backend flush
This commit is contained in:
@@ -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", "/"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
113
go_cloud/internal/storage/webdav.go
Normal file
113
go_cloud/internal/storage/webdav.go
Normal 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))
|
||||
}
|
||||
Reference in New Issue
Block a user