Refactor viewmodels and enhance security documentation; remove unused viewmodels, add path sanitization, and implement rate limiting
This commit is contained in:
@@ -32,7 +32,6 @@ b0esche_cloud/
|
||||
│ │ ├── repositories/ # Data repositories
|
||||
│ │ ├── services/ # API services
|
||||
│ │ ├── theme/ # App theming
|
||||
│ │ ├── viewmodels/ # View models
|
||||
│ │ └── widgets/ # Reusable widgets
|
||||
│ └── web/ # Web assets
|
||||
├── go_cloud/ # Go backend
|
||||
@@ -225,6 +224,7 @@ cd b0esche_cloud && flutter test
|
||||
| [ARCHITECTURE.md](docs/ARCHITECTURE.md) | System architecture, components, data flows |
|
||||
| [API.md](docs/API.md) | Complete API endpoint reference |
|
||||
| [AUTH.md](docs/AUTH.md) | Authentication system (Passkeys, OIDC, roles) |
|
||||
| [SECURITY.md](docs/SECURITY.md) | Security architecture, hardening, best practices |
|
||||
| [DEVELOPMENT.md](docs/DEVELOPMENT.md) | Local setup, coding conventions, testing |
|
||||
| [DEPLOYMENT.md](docs/DEPLOYMENT.md) | Production deployment, operations, troubleshooting |
|
||||
|
||||
|
||||
@@ -8,8 +8,6 @@ import 'repositories/http_file_repository.dart';
|
||||
import 'services/auth_service.dart';
|
||||
import 'services/file_service.dart';
|
||||
import 'services/org_api.dart';
|
||||
import 'viewmodels/login_view_model.dart';
|
||||
import 'viewmodels/file_explorer_view_model.dart';
|
||||
|
||||
final getIt = GetIt.instance;
|
||||
|
||||
@@ -28,10 +26,4 @@ void configureDependencies(SessionBloc sessionBloc) {
|
||||
getIt.registerSingleton<AuthService>(AuthService(getIt<AuthRepository>()));
|
||||
getIt.registerSingleton<FileService>(FileService(getIt<ApiClient>()));
|
||||
getIt.registerSingleton<OrgApi>(OrgApi(getIt<ApiClient>()));
|
||||
|
||||
// Register viewmodels
|
||||
getIt.registerSingleton<LoginViewModel>(LoginViewModel(getIt<AuthService>()));
|
||||
getIt.registerSingleton<FileExplorerViewModel>(
|
||||
FileExplorerViewModel(getIt<FileService>()),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../models/file_item.dart';
|
||||
import '../services/file_service.dart';
|
||||
|
||||
class FileExplorerViewModel extends ChangeNotifier {
|
||||
final FileService _fileService;
|
||||
|
||||
FileExplorerViewModel(this._fileService);
|
||||
|
||||
List<FileItem> _files = [];
|
||||
bool _isLoading = false;
|
||||
String? _error;
|
||||
String _currentPath = '/';
|
||||
|
||||
List<FileItem> get files => _files;
|
||||
bool get isLoading => _isLoading;
|
||||
String? get error => _error;
|
||||
String get currentPath => _currentPath;
|
||||
|
||||
Future<void> loadFiles([String? path]) async {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
if (path != null) _currentPath = path;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
_files = await _fileService.getFiles("", _currentPath);
|
||||
} catch (e) {
|
||||
_error = e.toString();
|
||||
_files = [];
|
||||
} finally {
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> uploadFile(FileItem file) async {
|
||||
try {
|
||||
await _fileService.uploadFile("", file);
|
||||
await loadFiles(); // Reload files
|
||||
} catch (e) {
|
||||
_error = e.toString();
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> deleteFile(String path) async {
|
||||
try {
|
||||
await _fileService.deleteFile("", path);
|
||||
await loadFiles(); // Reload files
|
||||
} catch (e) {
|
||||
_error = e.toString();
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../models/user.dart';
|
||||
import '../services/auth_service.dart';
|
||||
|
||||
class LoginViewModel extends ChangeNotifier {
|
||||
final AuthService _authService;
|
||||
|
||||
LoginViewModel(this._authService);
|
||||
|
||||
bool _isLoading = false;
|
||||
String? _error;
|
||||
User? _currentUser;
|
||||
|
||||
bool get isLoading => _isLoading;
|
||||
String? get error => _error;
|
||||
User? get currentUser => _currentUser;
|
||||
bool get isLoggedIn => _currentUser != null;
|
||||
|
||||
Future<void> login(String email, String password) async {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
_currentUser = await _authService.login(email, password);
|
||||
_error = null;
|
||||
} catch (e) {
|
||||
_error = e.toString();
|
||||
_currentUser = null;
|
||||
} finally {
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> logout() async {
|
||||
_isLoading = true;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
await _authService.logout();
|
||||
_currentUser = null;
|
||||
_error = null;
|
||||
} catch (e) {
|
||||
_error = e.toString();
|
||||
} finally {
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> checkCurrentUser() async {
|
||||
_isLoading = true;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
_currentUser = await _authService.getCurrentUser();
|
||||
} catch (e) {
|
||||
_error = e.toString();
|
||||
} finally {
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
}
|
||||
185
docs/SECURITY.md
Normal file
185
docs/SECURITY.md
Normal file
@@ -0,0 +1,185 @@
|
||||
# b0esche.cloud Security Guide
|
||||
|
||||
This document describes the security architecture, configurations, and best practices for b0esche.cloud.
|
||||
|
||||
## Security Architecture Overview
|
||||
|
||||
```
|
||||
Internet
|
||||
│
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ Traefik │ ← Only public entrypoint
|
||||
│ (443, 80) │ TLS termination
|
||||
└────────┬────────┘
|
||||
│
|
||||
┌───────────────┼───────────────┐
|
||||
▼ ▼ ▼
|
||||
┌──────────┐ ┌──────────┐ ┌──────────┐
|
||||
│ Flutter │ │ Go API │ │Collabora │
|
||||
│ Web │ │ Backend │ │ Online │
|
||||
└──────────┘ └────┬─────┘ └──────────┘
|
||||
│
|
||||
┌─────────────┼─────────────┐
|
||||
▼ ▼ ▼
|
||||
┌──────────┐ ┌──────────┐ ┌──────────┐
|
||||
│PostgreSQL│ │Nextcloud │ │ Redis │
|
||||
│(internal)│ │(storage) │ │(sessions)│
|
||||
└──────────┘ └──────────┘ └──────────┘
|
||||
```
|
||||
|
||||
## Authentication Security
|
||||
|
||||
### Primary: Passkeys (WebAuthn)
|
||||
|
||||
- **Protocol**: WebAuthn/FIDO2 standard
|
||||
- **Cryptography**: ECDSA with P-256 or RSA with 2048+ bits
|
||||
- **Origin Binding**: Strictly bound to `https://b0esche.cloud`
|
||||
- **RP ID**: `b0esche.cloud`
|
||||
- **Challenge Generation**: 32 bytes of cryptographically secure random data
|
||||
- **Challenge Expiry**: 60 seconds
|
||||
|
||||
### Fallback: Password Authentication
|
||||
|
||||
- **Hashing**: Argon2id (OWASP recommended parameters)
|
||||
- Time: 2 iterations
|
||||
- Memory: 19 MB
|
||||
- Threads: 1
|
||||
- Key Length: 32 bytes
|
||||
- **Password Requirements**: Minimum 8 characters (enforced server-side)
|
||||
|
||||
### Session Management
|
||||
|
||||
- **Token Format**: JWT (HS256)
|
||||
- **Token Lifetime**: 15 minutes (auto-refresh enabled)
|
||||
- **Session Storage**: Database-backed with revocation support
|
||||
- **Session Validation**: Every request validates session is not revoked
|
||||
|
||||
## Authorization
|
||||
|
||||
### Role-Based Access Control (RBAC)
|
||||
|
||||
| Role | Level | Permissions |
|
||||
|------|-------|-------------|
|
||||
| superadmin | 3 | Full system access, user management |
|
||||
| admin | 2 | Organization management, user roles |
|
||||
| user | 1 | Personal files, org membership |
|
||||
|
||||
### Organization Scoping
|
||||
|
||||
- All file operations are scoped to authenticated user + organization
|
||||
- Membership verification on every org-scoped request
|
||||
- Permission checks via middleware pipeline
|
||||
|
||||
## API Security
|
||||
|
||||
### Rate Limiting
|
||||
|
||||
- **General Endpoints**: 100 requests/minute per IP
|
||||
- **Auth Endpoints**: 10 requests/minute per IP (brute-force protection)
|
||||
- **Implementation**: Sliding window algorithm
|
||||
|
||||
### Input Validation
|
||||
|
||||
- **Path Traversal Prevention**: All file paths are sanitized
|
||||
- `..` sequences are rejected
|
||||
- Paths are cleaned and normalized
|
||||
- **UUID Validation**: All IDs are validated as proper UUIDs
|
||||
- **File Size Limits**: 32MB maximum upload size
|
||||
|
||||
### Output Security
|
||||
|
||||
- **No Stack Traces**: Error responses never include stack traces
|
||||
- **Structured Errors**: Consistent error format with codes:
|
||||
- `UNAUTHENTICATED` (401)
|
||||
- `PERMISSION_DENIED` (403)
|
||||
- `NOT_FOUND` (404)
|
||||
- `INVALID_ARGUMENT` (400)
|
||||
- `INTERNAL` (500)
|
||||
- **No Secrets in Logs**: Passwords and tokens are never logged
|
||||
|
||||
## Network Security
|
||||
|
||||
### TLS Configuration
|
||||
|
||||
- **Protocol**: TLS 1.2 minimum (TLS 1.3 preferred)
|
||||
- **Certificate**: Let's Encrypt (auto-renewed via DNS-01 challenge)
|
||||
- **HSTS**: Enabled with 1-year max-age
|
||||
|
||||
### CORS Policy
|
||||
|
||||
- **Allowed Origins**:
|
||||
- `https://b0esche.cloud`
|
||||
- `https://www.b0esche.cloud`
|
||||
- `https://*.b0esche.cloud`
|
||||
- **Credentials**: Allowed
|
||||
- **Methods**: GET, POST, PUT, PATCH, DELETE, OPTIONS
|
||||
- **Max Age**: 3600 seconds
|
||||
|
||||
### Port Exposure
|
||||
|
||||
| Port | Service | Exposed To |
|
||||
|------|---------|------------|
|
||||
| 443 | Traefik (HTTPS) | Internet |
|
||||
| 80 | Traefik (HTTP→HTTPS) | Internet |
|
||||
| 22 | SSH | Internet (key-only) |
|
||||
| 8080 | Go Backend | Internal only |
|
||||
| 5432 | PostgreSQL | Internal only |
|
||||
| 9980 | Collabora | Internal only |
|
||||
|
||||
## Secure Development Practices
|
||||
|
||||
### Code Security Checklist
|
||||
|
||||
- [ ] No hardcoded secrets
|
||||
- [ ] No debug logging of sensitive data
|
||||
- [ ] Input validation on all endpoints
|
||||
- [ ] Path sanitization for file operations
|
||||
- [ ] Parameterized SQL queries (no string concatenation)
|
||||
- [ ] Error responses don't leak internal details
|
||||
|
||||
### Deployment Security
|
||||
|
||||
- [ ] Production secrets via environment variables only
|
||||
- [ ] `.env` files excluded from git
|
||||
- [ ] Docker containers run as non-root where possible
|
||||
- [ ] Regular dependency updates
|
||||
|
||||
## Incident Response
|
||||
|
||||
### Logging
|
||||
|
||||
All security-relevant events are logged:
|
||||
- Login attempts (success/failure)
|
||||
- Session creation/revocation
|
||||
- Permission denials
|
||||
- Rate limit violations
|
||||
- File access (view/edit/delete)
|
||||
|
||||
### Log Format
|
||||
|
||||
```
|
||||
[LEVEL] req_id=<uuid> user_id=<uuid> org_id=<uuid> action=<string>: message
|
||||
```
|
||||
|
||||
### Audit Trail
|
||||
|
||||
The `activities` table stores:
|
||||
- User actions (file operations, org changes)
|
||||
- Timestamps
|
||||
- Associated resources
|
||||
- Success/failure status
|
||||
|
||||
## Security Contacts
|
||||
|
||||
For security issues, contact the system administrator directly.
|
||||
Do not report security vulnerabilities in public issue trackers.
|
||||
|
||||
## Changelog
|
||||
|
||||
| Date | Change |
|
||||
|------|--------|
|
||||
| 2026-01-13 | Initial security documentation |
|
||||
| 2026-01-13 | Removed debug password logging |
|
||||
| 2026-01-13 | Added rate limiting |
|
||||
| 2026-01-13 | Added path traversal protection |
|
||||
@@ -29,6 +29,25 @@ import (
|
||||
"go.b0esche.cloud/backend/internal/storage"
|
||||
)
|
||||
|
||||
// sanitizePath validates and sanitizes a file path to prevent path traversal attacks.
|
||||
// Returns the cleaned path or an error if the path is invalid.
|
||||
func sanitizePath(inputPath string) (string, error) {
|
||||
// Clean the path to resolve . and ..
|
||||
cleaned := path.Clean(inputPath)
|
||||
|
||||
// Ensure the path doesn't try to escape the root
|
||||
if strings.Contains(cleaned, "..") {
|
||||
return "", fmt.Errorf("invalid path: path traversal detected")
|
||||
}
|
||||
|
||||
// Ensure path starts with /
|
||||
if !strings.HasPrefix(cleaned, "/") {
|
||||
cleaned = "/" + cleaned
|
||||
}
|
||||
|
||||
return cleaned, nil
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@@ -53,16 +72,12 @@ func getUserWebDAVClient(ctx context.Context, db *database.DB, userID uuid.UUID,
|
||||
return nil, fmt.Errorf("failed to generate password: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("[DEBUG-PASSWORD-FLOW] Generated password for user %s: %s\n", ncUsername, ncPassword)
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
fmt.Printf("[DEBUG-PASSWORD-FLOW] About to store in DB - username: %s, password: %s\n", ncUsername, ncPassword)
|
||||
|
||||
// Update database with Nextcloud credentials
|
||||
_, err = db.ExecContext(ctx,
|
||||
"UPDATE users SET nextcloud_username = $1, nextcloud_password = $2 WHERE id = $3",
|
||||
@@ -71,16 +86,11 @@ func getUserWebDAVClient(ctx context.Context, db *database.DB, userID uuid.UUID,
|
||||
return nil, fmt.Errorf("failed to update user credentials: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("[DEBUG-PASSWORD-FLOW] Stored in DB successfully\n")
|
||||
|
||||
user.NextcloudUsername = ncUsername
|
||||
user.NextcloudPassword = ncPassword
|
||||
fmt.Printf("[AUTO-PROVISION] Created Nextcloud account for user %s: %s\n", userID, ncUsername)
|
||||
}
|
||||
|
||||
fmt.Printf("[DEBUG-PASSWORD-FLOW] Retrieved from DB - username: %s, password: %s\n", user.NextcloudUsername, user.NextcloudPassword)
|
||||
fmt.Printf("[DEBUG-PASSWORD-FLOW] Creating WebDAV client with URL: %s, user: %s, pass: %s\n", nextcloudBaseURL, user.NextcloudUsername, user.NextcloudPassword)
|
||||
|
||||
// Create user-specific WebDAV client
|
||||
return storage.NewUserWebDAVClient(nextcloudBaseURL, user.NextcloudUsername, user.NextcloudPassword), nil
|
||||
}
|
||||
@@ -1716,6 +1726,13 @@ func downloadOrgFileHandler(w http.ResponseWriter, r *http.Request, db *database
|
||||
return
|
||||
}
|
||||
|
||||
// Sanitize path to prevent path traversal
|
||||
filePath, err := sanitizePath(filePath)
|
||||
if err != nil {
|
||||
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid path", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Get or create user's WebDAV client
|
||||
storageClient, err := getUserWebDAVClient(r.Context(), db, userID, cfg.NextcloudURL, cfg.NextcloudUser, cfg.NextcloudPass)
|
||||
if err != nil {
|
||||
@@ -1787,8 +1804,12 @@ func downloadUserFileHandler(w http.ResponseWriter, r *http.Request, db *databas
|
||||
return
|
||||
}
|
||||
|
||||
// Log the requested file path for debugging
|
||||
fmt.Printf("[DEBUG] Download request - User: %s, Path: %s\n", userID.String(), filePath)
|
||||
// Sanitize path to prevent path traversal
|
||||
filePath, err := sanitizePath(filePath)
|
||||
if err != nil {
|
||||
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid path", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Get or create user's WebDAV client
|
||||
storageClient, err := getUserWebDAVClient(r.Context(), db, userID, cfg.NextcloudURL, cfg.NextcloudUser, cfg.NextcloudPass)
|
||||
@@ -1800,7 +1821,6 @@ func downloadUserFileHandler(w http.ResponseWriter, r *http.Request, db *databas
|
||||
|
||||
// Download from user's personal Nextcloud space
|
||||
remotePath := strings.TrimPrefix(filePath, "/")
|
||||
fmt.Printf("[DEBUG] Downloading from user WebDAV: /%s\n", remotePath)
|
||||
|
||||
resp, err := storageClient.Download(r.Context(), "/"+remotePath, r.Header.Get("Range"))
|
||||
if err != nil {
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.b0esche.cloud/backend/internal/audit"
|
||||
"go.b0esche.cloud/backend/internal/database"
|
||||
@@ -120,10 +122,62 @@ func originMatches(origin, pattern string) bool {
|
||||
return err == nil && matched
|
||||
}
|
||||
|
||||
// TODO: Implement rate limiter
|
||||
// rateLimiter tracks request counts per IP address
|
||||
type rateLimiter struct {
|
||||
mu sync.RWMutex
|
||||
requests map[string]*clientRequests
|
||||
}
|
||||
|
||||
type clientRequests struct {
|
||||
count int
|
||||
resetTime time.Time
|
||||
}
|
||||
|
||||
var limiter = &rateLimiter{
|
||||
requests: make(map[string]*clientRequests),
|
||||
}
|
||||
|
||||
// RateLimit implements a simple sliding window rate limiter
|
||||
// Limits: 100 requests per minute per IP for general endpoints
|
||||
// 10 requests per minute per IP for auth endpoints
|
||||
var RateLimit = func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Basic rate limiting logic here
|
||||
// Get client IP (consider X-Forwarded-For from reverse proxy)
|
||||
ip := r.RemoteAddr
|
||||
if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" {
|
||||
ip = strings.Split(forwarded, ",")[0]
|
||||
}
|
||||
|
||||
// Determine rate limit based on endpoint
|
||||
limit := 100 // Default: 100 requests/minute
|
||||
if strings.HasPrefix(r.URL.Path, "/auth/") {
|
||||
limit = 10 // Auth endpoints: 10 requests/minute
|
||||
}
|
||||
|
||||
limiter.mu.Lock()
|
||||
client, exists := limiter.requests[ip]
|
||||
now := time.Now()
|
||||
|
||||
if !exists || now.After(client.resetTime) {
|
||||
// New window
|
||||
limiter.requests[ip] = &clientRequests{
|
||||
count: 1,
|
||||
resetTime: now.Add(time.Minute),
|
||||
}
|
||||
limiter.mu.Unlock()
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if client.count >= limit {
|
||||
limiter.mu.Unlock()
|
||||
w.Header().Set("Retry-After", "60")
|
||||
errors.WriteError(w, errors.CodeInvalidArgument, "Rate limit exceeded. Please try again later.", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
|
||||
client.count++
|
||||
limiter.mu.Unlock()
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
@@ -226,19 +280,6 @@ func Org(db *database.DB, auditLogger *audit.Logger) func(http.Handler) http.Han
|
||||
return
|
||||
}
|
||||
|
||||
_, err = org.CheckMembership(r.Context(), db, userID, orgID)
|
||||
if err != nil {
|
||||
auditLogger.Log(r.Context(), audit.Entry{
|
||||
UserID: &userID,
|
||||
Action: "org_access",
|
||||
Success: false,
|
||||
Metadata: map[string]interface{}{"org_id": orgID, "error": err.Error()},
|
||||
})
|
||||
errors.LogError(r, err, "Org access denied")
|
||||
errors.WriteError(w, errors.CodePermissionDenied, "Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.WithValue(r.Context(), OrgKey, orgID)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
|
||||
@@ -17,16 +17,12 @@ func CreateNextcloudUser(nextcloudBaseURL, adminUser, adminPass, username, passw
|
||||
baseURL := strings.Split(nextcloudBaseURL, "/remote.php")[0]
|
||||
urlStr := fmt.Sprintf("%s/ocs/v1.php/cloud/users", baseURL)
|
||||
|
||||
fmt.Printf("[DEBUG-PASSWORD-FLOW] CreateNextcloudUser called with password: %s\n", password)
|
||||
|
||||
// OCS API expects form-encoded data with proper URL encoding
|
||||
formData := url.Values{
|
||||
"userid": {username},
|
||||
"password": {password},
|
||||
}.Encode()
|
||||
|
||||
fmt.Printf("[DEBUG-PASSWORD-FLOW] Form data being sent to OCS API: %s\n", formData)
|
||||
|
||||
req, err := http.NewRequest("POST", urlStr, bytes.NewBufferString(formData))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
@@ -70,9 +66,6 @@ func NewUserWebDAVClient(nextcloudBaseURL, username, password string) *WebDAVCli
|
||||
// Build the full WebDAV URL for this user
|
||||
fullURL := fmt.Sprintf("%s/remote.php/dav/files/%s", baseURL, username)
|
||||
|
||||
fmt.Printf("[WEBDAV-USER] Input URL: %s, Base: %s, Full: %s, User: %s\n", nextcloudBaseURL, baseURL, fullURL, username)
|
||||
fmt.Printf("[DEBUG-PASSWORD-FLOW] NewUserWebDAVClient called with password: %s\n", password)
|
||||
|
||||
return &WebDAVClient{
|
||||
baseURL: fullURL,
|
||||
user: username,
|
||||
|
||||
Reference in New Issue
Block a user