personal workspace backend flush
This commit is contained in:
@@ -12,6 +12,10 @@ type Config struct {
|
|||||||
OIDCClientID string
|
OIDCClientID string
|
||||||
OIDCClientSecret string
|
OIDCClientSecret string
|
||||||
JWTSecret string
|
JWTSecret string
|
||||||
|
NextcloudURL string
|
||||||
|
NextcloudUser string
|
||||||
|
NextcloudPass string
|
||||||
|
NextcloudBase string
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load() *Config {
|
func Load() *Config {
|
||||||
@@ -23,6 +27,10 @@ func Load() *Config {
|
|||||||
OIDCClientID: os.Getenv("OIDC_CLIENT_ID"),
|
OIDCClientID: os.Getenv("OIDC_CLIENT_ID"),
|
||||||
OIDCClientSecret: os.Getenv("OIDC_CLIENT_SECRET"),
|
OIDCClientSecret: os.Getenv("OIDC_CLIENT_SECRET"),
|
||||||
JWTSecret: os.Getenv("JWT_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
|
package http
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -22,10 +25,13 @@ import (
|
|||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/google/uuid"
|
"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 {
|
func NewRouter(cfg *config.Config, db *database.DB, jwtManager *jwt.Manager, authService *auth.Service, auditLogger *audit.Logger) http.Handler {
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
|
// optional WebDAV/Nextcloud client
|
||||||
|
storageClient := storage.NewWebDAVClient(cfg)
|
||||||
|
|
||||||
// Global middleware
|
// Global middleware
|
||||||
r.Use(middleware.RequestID)
|
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
|
// Create / delete in user workspace
|
||||||
r.Post("/user/files", func(w http.ResponseWriter, req *http.Request) {
|
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) {
|
r.Delete("/user/files", func(w http.ResponseWriter, req *http.Request) {
|
||||||
deleteUserFileHandler(w, req, db, auditLogger)
|
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
|
// Create file/folder in org workspace
|
||||||
r.With(middleware.Permission(db, auditLogger, permission.FileWrite)).Post("/files", func(w http.ResponseWriter, req *http.Request) {
|
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
|
// 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) {
|
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.
|
// 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)
|
orgID := r.Context().Value("org").(uuid.UUID)
|
||||||
userIDStr, _ := r.Context().Value("user").(string)
|
userIDStr, _ := r.Context().Value("user").(string)
|
||||||
userID, _ := uuid.Parse(userIDStr)
|
userID, _ := uuid.Parse(userIDStr)
|
||||||
|
var f *database.File
|
||||||
|
var err error
|
||||||
|
|
||||||
// Support multipart uploads (field "file") or JSON metadata for folders
|
// Support multipart uploads (field "file") or JSON metadata for folders
|
||||||
contentType := r.Header.Get("Content-Type")
|
contentType := r.Header.Get("Content-Type")
|
||||||
if strings.HasPrefix(contentType, "multipart/form-data") {
|
if strings.HasPrefix(contentType, "multipart/form-data") {
|
||||||
// Handle file upload
|
// 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)
|
errors.WriteError(w, errors.CodeInvalidArgument, "Bad multipart request", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -862,43 +870,79 @@ func createOrgFileHandler(w http.ResponseWriter, r *http.Request, db *database.D
|
|||||||
if parentPath == "" {
|
if parentPath == "" {
|
||||||
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 {
|
if err != nil {
|
||||||
errors.WriteError(w, errors.CodeInvalidArgument, "Missing file", http.StatusBadRequest)
|
errors.WriteError(w, errors.CodeInvalidArgument, "Missing file", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
|
|
||||||
// Save to disk under data/uploads/orgs/<orgId>/<parentPath>
|
// Read file into memory (so we can attempt WebDAV upload and fallback to disk)
|
||||||
baseDir := filepath.Join("data", "uploads", "orgs", orgID.String())
|
data, err := io.ReadAll(file)
|
||||||
targetDir := filepath.Join(baseDir, parentPath)
|
if err != nil {
|
||||||
if err := os.MkdirAll(targetDir, 0o755); err != nil {
|
errors.LogError(r, err, "Failed to read uploaded file")
|
||||||
errors.LogError(r, err, "Failed to create target dir")
|
|
||||||
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
outPath := filepath.Join(targetDir, header.Filename)
|
// Attempt WebDAV upload when configured
|
||||||
outFile, err := os.Create(outPath)
|
storedPath := filepath.ToSlash(filepath.Join(parentPath, header.Filename))
|
||||||
if err != nil {
|
if !strings.HasPrefix(storedPath, "/") {
|
||||||
errors.LogError(r, err, "Failed to create file")
|
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)
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer outFile.Close()
|
outPath := filepath.Join(targetDir, header.Filename)
|
||||||
written, err := io.Copy(outFile, file)
|
if err = os.WriteFile(outPath, data, 0o644); err != nil {
|
||||||
if err != nil {
|
|
||||||
errors.LogError(r, err, "Failed to write file")
|
errors.LogError(r, err, "Failed to write file")
|
||||||
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store metadata in DB; store path relative to workspace root
|
// 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, "/") {
|
if !strings.HasPrefix(storedPath, "/") {
|
||||||
storedPath = "/" + 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 {
|
if err != nil {
|
||||||
errors.LogError(r, err, "Failed to create org file")
|
errors.LogError(r, err, "Failed to create org file")
|
||||||
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
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
|
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 {
|
if err != nil {
|
||||||
errors.LogError(r, err, "Failed to create org file")
|
errors.LogError(r, err, "Failed to create org file")
|
||||||
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
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.
|
// 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)
|
userIDStr, ok := r.Context().Value("user").(string)
|
||||||
if !ok || userIDStr == "" {
|
if !ok || userIDStr == "" {
|
||||||
errors.WriteError(w, errors.CodeUnauthenticated, "Unauthorized", http.StatusUnauthorized)
|
errors.WriteError(w, errors.CodeUnauthenticated, "Unauthorized", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
userID, _ := uuid.Parse(userIDStr)
|
userID, _ := uuid.Parse(userIDStr)
|
||||||
|
var f *database.File
|
||||||
|
var err error
|
||||||
// Support multipart uploads for file content or JSON for folders
|
// Support multipart uploads for file content or JSON for folders
|
||||||
contentType := r.Header.Get("Content-Type")
|
contentType := r.Header.Get("Content-Type")
|
||||||
if strings.HasPrefix(contentType, "multipart/form-data") {
|
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)
|
errors.WriteError(w, errors.CodeInvalidArgument, "Bad multipart request", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1001,40 +1047,67 @@ func createUserFileHandler(w http.ResponseWriter, r *http.Request, db *database.
|
|||||||
if parentPath == "" {
|
if parentPath == "" {
|
||||||
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 {
|
if err != nil {
|
||||||
errors.WriteError(w, errors.CodeInvalidArgument, "Missing file", http.StatusBadRequest)
|
errors.WriteError(w, errors.CodeInvalidArgument, "Missing file", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer file.Close()
|
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())
|
baseDir := filepath.Join("data", "uploads", "users", userID.String())
|
||||||
targetDir := filepath.Join(baseDir, parentPath)
|
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.LogError(r, err, "Failed to create target dir")
|
||||||
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
outPath := filepath.Join(targetDir, header.Filename)
|
outPath := filepath.Join(targetDir, header.Filename)
|
||||||
outFile, err := os.Create(outPath)
|
if err = os.WriteFile(outPath, data, 0o644); err != nil {
|
||||||
if err != nil {
|
|
||||||
errors.LogError(r, err, "Failed to create file")
|
errors.LogError(r, err, "Failed to create file")
|
||||||
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
||||||
return
|
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))
|
f, err = db.CreateFile(r.Context(), nil, &userID, header.Filename, storedPath, "file", written)
|
||||||
if !strings.HasPrefix(storedPath, "/") {
|
|
||||||
storedPath = "/" + storedPath
|
|
||||||
}
|
|
||||||
f, err := db.CreateFile(r.Context(), nil, &userID, header.Filename, storedPath, "file", written)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errors.LogError(r, err, "Failed to create user file")
|
errors.LogError(r, err, "Failed to create user file")
|
||||||
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
||||||
@@ -1063,7 +1136,7 @@ func createUserFileHandler(w http.ResponseWriter, r *http.Request, db *database.
|
|||||||
return
|
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 {
|
if err != nil {
|
||||||
errors.LogError(r, err, "Failed to create user file")
|
errors.LogError(r, err, "Failed to create user file")
|
||||||
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
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