Files
b0esche_cloud/go_cloud/internal/http/routes.go

2105 lines
71 KiB
Go

package http
import (
"archive/zip"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/url"
"path"
"path/filepath"
"strings"
"time"
"go.b0esche.cloud/backend/internal/audit"
"go.b0esche.cloud/backend/internal/auth"
"go.b0esche.cloud/backend/internal/config"
"go.b0esche.cloud/backend/internal/database"
"go.b0esche.cloud/backend/internal/errors"
"go.b0esche.cloud/backend/internal/middleware"
"go.b0esche.cloud/backend/internal/org"
"go.b0esche.cloud/backend/internal/permission"
"go.b0esche.cloud/backend/pkg/jwt"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"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 {
Username string
NextcloudUsername string
NextcloudPassword string
}
err := db.QueryRowContext(ctx,
"SELECT username, COALESCE(nextcloud_username, ''), COALESCE(nextcloud_password, '') FROM users WHERE id = $1",
userID).Scan(&user.Username, &user.NextcloudUsername, &user.NextcloudPassword)
if err != nil {
return nil, fmt.Errorf("failed to get user: %w", err)
}
// If user doesn't have Nextcloud credentials, create them
if user.NextcloudUsername == "" || user.NextcloudPassword == "" {
// Use the actual username from the users table
ncUsername := user.Username
ncPassword, err := storage.GenerateSecurePassword(32)
if err != nil {
return nil, fmt.Errorf("failed to generate password: %w", err)
}
// 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)
}
// Update database with Nextcloud credentials
_, err = db.ExecContext(ctx,
"UPDATE users SET nextcloud_username = $1, nextcloud_password = $2 WHERE id = $3",
ncUsername, ncPassword, userID)
if err != nil {
return nil, fmt.Errorf("failed to update user credentials: %w", err)
}
user.NextcloudUsername = ncUsername
user.NextcloudPassword = ncPassword
fmt.Printf("[AUTO-PROVISION] Created Nextcloud account for user %s: %s\n", userID, ncUsername)
}
// Create user-specific WebDAV client
return storage.NewUserWebDAVClient(nextcloudBaseURL, user.NextcloudUsername, user.NextcloudPassword), nil
}
func NewRouter(cfg *config.Config, db *database.DB, jwtManager *jwt.Manager, authService *auth.Service, auditLogger *audit.Logger) http.Handler {
r := chi.NewRouter()
// Global middleware
r.Use(middleware.RequestID)
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Use(middleware.CORS(cfg.AllowedOrigins))
r.Use(middleware.RateLimit)
// Health check
r.Get("/health", healthHandler)
// WOPI routes (public, token validation done per endpoint)
r.Route("/wopi", func(r chi.Router) {
r.Route("/files/{fileId}", func(r chi.Router) {
// CheckFileInfo: GET /wopi/files/{fileId}
r.Get("/", func(w http.ResponseWriter, req *http.Request) {
wopiCheckFileInfoHandler(w, req, db, jwtManager)
})
// GetFile: GET /wopi/files/{fileId}/contents
r.Get("/contents", func(w http.ResponseWriter, req *http.Request) {
wopiGetFileHandler(w, req, db, jwtManager, cfg)
})
// PutFile & Lock operations: POST /wopi/files/{fileId}/contents and POST /wopi/files/{fileId}
r.Post("/contents", func(w http.ResponseWriter, req *http.Request) {
wopiPutFileHandler(w, req, db, jwtManager, cfg)
})
// Lock operations: POST /wopi/files/{fileId}
r.Post("/", func(w http.ResponseWriter, req *http.Request) {
wopiLockHandler(w, req, db, jwtManager)
})
})
})
// Auth routes (no auth required)
r.Route("/auth", func(r chi.Router) {
r.Post("/refresh", func(w http.ResponseWriter, req *http.Request) {
refreshHandler(w, req, jwtManager, db)
})
r.Post("/logout", func(w http.ResponseWriter, req *http.Request) {
logoutHandler(w, req, jwtManager, db, auditLogger)
})
// Passkey routes
r.Post("/signup", func(w http.ResponseWriter, req *http.Request) {
signupHandler(w, req, db, auditLogger)
})
r.Post("/registration-challenge", func(w http.ResponseWriter, req *http.Request) {
registrationChallengeHandler(w, req, db)
})
r.Post("/registration-verify", func(w http.ResponseWriter, req *http.Request) {
registrationVerifyHandler(w, req, db, jwtManager, auditLogger)
})
r.Post("/authentication-challenge", func(w http.ResponseWriter, req *http.Request) {
authenticationChallengeHandler(w, req, db)
})
r.Post("/authentication-verify", func(w http.ResponseWriter, req *http.Request) {
authenticationVerifyHandler(w, req, db, jwtManager, auditLogger)
})
// Password login route
r.Post("/password-login", func(w http.ResponseWriter, req *http.Request) {
passwordLoginHandler(w, req, db, jwtManager, auditLogger)
})
})
// Protected routes (with auth middleware)
r.Route("/", func(r chi.Router) {
r.Use(middleware.Auth(jwtManager, db))
// User-scoped routes (personal workspace)
r.Get("/user/files", func(w http.ResponseWriter, req *http.Request) {
userFilesHandler(w, req, db)
})
// User file viewer
r.Get("/user/files/{fileId}/view", func(w http.ResponseWriter, req *http.Request) {
userViewerHandler(w, req, db, jwtManager, auditLogger)
})
// Download user file
r.Get("/user/files/download", func(w http.ResponseWriter, req *http.Request) {
downloadUserFileHandler(w, req, db, cfg)
})
// Create / delete in user workspace
r.Post("/user/files", func(w http.ResponseWriter, req *http.Request) {
createUserFileHandler(w, req, db, auditLogger, cfg)
})
r.Delete("/user/files", func(w http.ResponseWriter, req *http.Request) {
deleteUserFileHandler(w, req, db, auditLogger, cfg)
})
// POST wrapper for delete
r.Post("/user/files/delete", func(w http.ResponseWriter, req *http.Request) {
deleteUserFilePostHandler(w, req, db, auditLogger, cfg)
})
// Move file/folder in user workspace
r.Post("/user/files/move", func(w http.ResponseWriter, req *http.Request) {
moveUserFileHandler(w, req, db, auditLogger, cfg)
})
// WOPI session for user files
r.Post("/user/files/{fileId}/wopi-session", func(w http.ResponseWriter, req *http.Request) {
wopiSessionHandler(w, req, db, jwtManager, "https://of.b0esche.cloud")
})
// User file editor
r.Get("/user/files/{fileId}/edit", func(w http.ResponseWriter, req *http.Request) {
userEditorHandler(w, req, db, auditLogger)
})
// Collabora form proxy for user files
r.Get("/user/files/{fileId}/collabora-proxy", func(w http.ResponseWriter, req *http.Request) {
collaboraProxyHandler(w, req, db, jwtManager, "https://of.b0esche.cloud")
})
// Org routes
r.Get("/orgs", func(w http.ResponseWriter, req *http.Request) {
listOrgsHandler(w, req, db)
})
r.Post("/orgs", func(w http.ResponseWriter, req *http.Request) {
createOrgHandler(w, req, db, auditLogger)
})
// Org-scoped routes
r.Route("/orgs/{orgId}", func(r chi.Router) {
r.Use(middleware.Org(db, auditLogger))
// File routes
r.With(middleware.Permission(db, auditLogger, permission.FileRead)).Get("/files", func(w http.ResponseWriter, req *http.Request) {
listFilesHandler(w, req, db)
})
// Download org file
r.With(middleware.Permission(db, auditLogger, permission.FileRead)).Get("/files/download", func(w http.ResponseWriter, req *http.Request) {
downloadOrgFileHandler(w, req, db, cfg)
})
// 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, cfg)
})
// 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) {
deleteOrgFilePostHandler(w, req, db, auditLogger, cfg)
})
// Delete file/folder in org workspace (body: {"path":"/path"})
r.With(middleware.Permission(db, auditLogger, permission.FileWrite)).Delete("/files", func(w http.ResponseWriter, req *http.Request) {
deleteOrgFileHandler(w, req, db, auditLogger, cfg)
})
// Move file/folder in org workspace (body: {"sourcePath":"/old", "targetPath":"/new"})
r.With(middleware.Permission(db, auditLogger, permission.FileWrite)).Post("/files/move", func(w http.ResponseWriter, req *http.Request) {
moveOrgFileHandler(w, req, db, auditLogger, cfg)
})
r.Route("/files/{fileId}", func(r chi.Router) {
r.With(middleware.Permission(db, auditLogger, permission.DocumentView)).Get("/view", func(w http.ResponseWriter, req *http.Request) {
viewerHandler(w, req, db, jwtManager, auditLogger)
})
r.With(middleware.Permission(db, auditLogger, permission.DocumentEdit)).Get("/edit", func(w http.ResponseWriter, req *http.Request) {
editorHandler(w, req, db, auditLogger)
})
r.With(middleware.Permission(db, auditLogger, permission.DocumentEdit)).Post("/annotations", func(w http.ResponseWriter, req *http.Request) {
annotationsHandler(w, req, db, auditLogger)
})
r.Get("/meta", func(w http.ResponseWriter, req *http.Request) {
fileMetaHandler(w, req)
})
// WOPI session for org files
r.With(middleware.Permission(db, auditLogger, permission.DocumentView)).Post("/wopi-session", func(w http.ResponseWriter, req *http.Request) {
wopiSessionHandler(w, req, db, jwtManager, "https://of.b0esche.cloud")
})
// Collabora form proxy for org files
r.With(middleware.Permission(db, auditLogger, permission.DocumentView)).Get("/collabora-proxy", func(w http.ResponseWriter, req *http.Request) {
collaboraProxyHandler(w, req, db, jwtManager, "https://of.b0esche.cloud")
})
})
r.Get("/activity", func(w http.ResponseWriter, req *http.Request) {
activityHandler(w, req, db)
})
r.With(middleware.Permission(db, auditLogger, permission.OrgManage)).Get("/members", func(w http.ResponseWriter, req *http.Request) {
listMembersHandler(w, req, db)
})
r.With(middleware.Permission(db, auditLogger, permission.OrgManage)).Patch("/members/{userId}", func(w http.ResponseWriter, req *http.Request) {
updateMemberRoleHandler(w, req, db, auditLogger)
})
})
}) // Close protected routes
return r
}
func healthHandler(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("OK"))
}
func refreshHandler(w http.ResponseWriter, r *http.Request, jwtManager *jwt.Manager, db *database.DB) {
authHeader := r.Header.Get("Authorization")
if !strings.HasPrefix(authHeader, "Bearer ") {
errors.WriteError(w, errors.CodeUnauthenticated, "Unauthorized", http.StatusUnauthorized)
return
}
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
claims, session, err := jwtManager.ValidateWithSession(r.Context(), tokenString, db)
if err != nil {
errors.LogError(r, err, "Invalid token")
errors.WriteError(w, errors.CodeUnauthenticated, "Unauthorized", http.StatusUnauthorized)
return
}
userID, _ := uuid.Parse(claims.UserID)
orgs, err := db.GetUserOrganizations(r.Context(), userID)
if err != nil {
errors.LogError(r, err, "Failed to get user organizations")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
orgIDs := make([]string, len(orgs))
for i, o := range orgs {
orgIDs[i] = o.ID.String()
}
newToken, err := jwtManager.Generate(claims.UserID, orgIDs, session.ID.String())
if err != nil {
errors.LogError(r, err, "Token generation failed")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"token": "` + newToken + `"}`))
}
func logoutHandler(w http.ResponseWriter, r *http.Request, jwtManager *jwt.Manager, db *database.DB, auditLogger *audit.Logger) {
authHeader := r.Header.Get("Authorization")
if !strings.HasPrefix(authHeader, "Bearer ") {
errors.WriteError(w, errors.CodeUnauthenticated, "Unauthorized", http.StatusUnauthorized)
return
}
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
claims, session, err := jwtManager.ValidateWithSession(r.Context(), tokenString, db)
if err != nil {
// Token invalid or session already revoked/expired — still return success
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"status": "ok"}`))
return
}
userID, _ := uuid.Parse(claims.UserID)
// Revoke session
if err := db.RevokeSession(r.Context(), session.ID); err != nil {
errors.LogError(r, err, "Failed to revoke session")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
auditLogger.Log(r.Context(), audit.Entry{
UserID: &userID,
Action: "logout",
Success: true,
})
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"status": "ok"}`))
}
func listOrgsHandler(w http.ResponseWriter, r *http.Request, db *database.DB) {
// User ID is already set by Auth middleware
userIDStr, ok := middleware.GetUserID(r.Context())
if !ok {
errors.WriteError(w, errors.CodeUnauthenticated, "Unauthorized", http.StatusUnauthorized)
return
}
userID, _ := uuid.Parse(userIDStr)
orgs, err := org.ResolveUserOrgs(r.Context(), db, userID)
if err != nil {
errors.LogError(r, err, "Failed to resolve user orgs")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(orgs)
}
func createOrgHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger) {
// User ID is already set by Auth middleware
userIDStr, ok := middleware.GetUserID(r.Context())
if !ok {
errors.WriteError(w, errors.CodeUnauthenticated, "Unauthorized", http.StatusUnauthorized)
return
}
userID, _ := uuid.Parse(userIDStr)
var req struct {
Name string `json:"name"`
Slug string `json:"slug,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
errors.WriteError(w, errors.CodeInvalidArgument, "Bad request", http.StatusBadRequest)
return
}
org, err := org.CreateOrg(r.Context(), db, userID, req.Name, req.Slug)
if err != nil {
errors.LogError(r, err, "Failed to create org")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
auditLogger.Log(r.Context(), audit.Entry{
UserID: &userID,
OrgID: &org.ID,
Action: "create_org",
Success: true,
})
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(org)
}
func listFilesHandler(w http.ResponseWriter, r *http.Request, db *database.DB) {
// Org ID is provided by middleware.Org
orgID := r.Context().Value(middleware.OrgKey).(uuid.UUID)
userIDStr, ok := middleware.GetUserID(r.Context())
if !ok || userIDStr == "" {
errors.WriteError(w, errors.CodeUnauthenticated, "Unauthorized", http.StatusUnauthorized)
return
}
userID, err := uuid.Parse(userIDStr)
if err != nil {
errors.LogError(r, err, "Invalid user id in context")
errors.WriteError(w, errors.CodeUnauthenticated, "Unauthorized", http.StatusUnauthorized)
return
}
// Query params: path, q (search), page, pageSize
path := r.URL.Query().Get("path")
if path == "" {
path = "/"
}
q := r.URL.Query().Get("q")
page := 1
pageSize := 100
if p := r.URL.Query().Get("page"); p != "" {
fmt.Sscanf(p, "%d", &page)
}
if ps := r.URL.Query().Get("pageSize"); ps != "" {
fmt.Sscanf(ps, "%d", &pageSize)
}
files, err := db.GetOrgFiles(r.Context(), orgID, userID, path, q, page, pageSize)
if err != nil {
errors.LogError(r, err, "Failed to get org files")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
// Convert to a JSON-friendly shape expected by frontend
out := make([]map[string]interface{}, 0, len(files))
for _, f := range files {
out = append(out, map[string]interface{}{
"id": f.ID.String(),
"name": f.Name,
"path": f.Path,
"type": f.Type,
"size": f.Size,
"lastModified": f.LastModified.UTC().Format(time.RFC3339),
})
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(out)
}
func viewerHandler(w http.ResponseWriter, r *http.Request, db *database.DB, jwtManager *jwt.Manager, auditLogger *audit.Logger) {
userIDStr, _ := middleware.GetUserID(r.Context())
userID, _ := uuid.Parse(userIDStr)
orgID := r.Context().Value(middleware.OrgKey).(uuid.UUID)
sessionObj, _ := middleware.GetSession(r.Context())
fileId := chi.URLParam(r, "fileId")
// Get file metadata to determine path and type
fileUUID, err := uuid.Parse(fileId)
if err != nil {
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid file ID", http.StatusBadRequest)
return
}
file, err := db.GetFileByID(r.Context(), fileUUID)
if err != nil {
errors.LogError(r, err, "Failed to get file metadata")
errors.WriteError(w, errors.CodeNotFound, "File not found", http.StatusNotFound)
return
}
// Log activity
db.LogActivity(r.Context(), userID, orgID, &fileId, "view_file", map[string]interface{}{})
// Build download URL with proper URL encoding using the request's scheme and host
scheme := "https"
if proto := r.Header.Get("X-Forwarded-Proto"); proto != "" {
scheme = proto
} else if r.TLS == nil {
scheme = "http"
}
host := r.Host
if host == "" {
host = "go.b0esche.cloud"
}
// Generate a long-lived token specifically for this viewer session (24 hours)
orgs, err := db.GetUserOrganizations(r.Context(), userID)
if err != nil {
errors.LogError(r, err, "Failed to get user organizations")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
orgIDs := make([]string, len(orgs))
for i, o := range orgs {
orgIDs[i] = o.ID.String()
}
viewerToken, err := jwtManager.GenerateWithDuration(userID.String(), orgIDs, sessionObj.ID.String(), 24*time.Hour)
if err != nil {
errors.LogError(r, err, "Failed to generate viewer token")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
downloadPath := fmt.Sprintf("%s://%s/orgs/%s/files/download?path=%s&token=%s", scheme, host, orgID.String(), url.QueryEscape(file.Path), url.QueryEscape(viewerToken))
// Determine file type based on extension
isPdf := strings.HasSuffix(strings.ToLower(file.Name), ".pdf")
mimeType := getMimeType(file.Name)
viewerSession := struct {
ViewUrl string `json:"viewUrl"`
Token string `json:"token"`
Capabilities struct {
CanEdit bool `json:"canEdit"`
CanAnnotate bool `json:"canAnnotate"`
IsPdf bool `json:"isPdf"`
MimeType string `json:"mimeType"`
} `json:"capabilities"`
FileInfo struct {
Name string `json:"name"`
Size int64 `json:"size"`
LastModified string `json:"lastModified"`
ModifiedByName string `json:"modifiedByName"`
} `json:"fileInfo"`
ExpiresAt string `json:"expiresAt"`
}{
ViewUrl: downloadPath,
Token: viewerToken, // Long-lived JWT token for authenticating file download
Capabilities: struct {
CanEdit bool `json:"canEdit"`
CanAnnotate bool `json:"canAnnotate"`
IsPdf bool `json:"isPdf"`
MimeType string `json:"mimeType"`
}{CanEdit: false, CanAnnotate: isPdf, IsPdf: isPdf, MimeType: mimeType},
FileInfo: struct {
Name string `json:"name"`
Size int64 `json:"size"`
LastModified string `json:"lastModified"`
ModifiedByName string `json:"modifiedByName"`
}{
Name: file.Name,
Size: file.Size,
LastModified: file.LastModified.UTC().Format(time.RFC3339),
ModifiedByName: file.ModifiedByName,
},
ExpiresAt: time.Now().Add(24 * time.Hour).UTC().Format(time.RFC3339),
}
fmt.Printf("[VIEWER-SESSION] orgId=%s, fileId=%s, token_included=yes, isPdf=%v, mimeType=%s\n", orgID.String(), fileId, isPdf, mimeType)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(viewerSession)
}
// userViewerHandler serves a viewer session for personal workspace files
func userViewerHandler(w http.ResponseWriter, r *http.Request, db *database.DB, jwtManager *jwt.Manager, auditLogger *audit.Logger) {
userIDStr, _ := middleware.GetUserID(r.Context())
userID, _ := uuid.Parse(userIDStr)
sessionObj, _ := middleware.GetSession(r.Context())
fileId := chi.URLParam(r, "fileId")
// Get file metadata to determine path and type
fileUUID, err := uuid.Parse(fileId)
if err != nil {
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid file ID", http.StatusBadRequest)
return
}
file, err := db.GetFileByID(r.Context(), fileUUID)
if err != nil {
errors.LogError(r, err, "Failed to get file metadata")
errors.WriteError(w, errors.CodeNotFound, "File not found", http.StatusNotFound)
return
}
// Optionally log activity without org id
db.LogActivity(r.Context(), userID, uuid.Nil, &fileId, "view_user_file", map[string]interface{}{})
// Build download URL with proper URL encoding using the request's scheme and host
scheme := "https"
if proto := r.Header.Get("X-Forwarded-Proto"); proto != "" {
scheme = proto
} else if r.TLS == nil {
scheme = "http"
}
host := r.Host
if host == "" {
host = "go.b0esche.cloud"
}
// Generate a long-lived token specifically for this viewer session (24 hours)
orgs, err := db.GetUserOrganizations(r.Context(), userID)
if err != nil {
errors.LogError(r, err, "Failed to get user organizations")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
orgIDs := make([]string, len(orgs))
for i, o := range orgs {
orgIDs[i] = o.ID.String()
}
viewerToken, err := jwtManager.GenerateWithDuration(userID.String(), orgIDs, sessionObj.ID.String(), 24*time.Hour)
if err != nil {
errors.LogError(r, err, "Failed to generate viewer token")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
downloadPath := fmt.Sprintf("%s://%s/user/files/download?path=%s&token=%s", scheme, host, url.QueryEscape(file.Path), url.QueryEscape(viewerToken))
// Determine file type based on extension
isPdf := strings.HasSuffix(strings.ToLower(file.Name), ".pdf")
mimeType := getMimeType(file.Name)
viewerSession := struct {
ViewUrl string `json:"viewUrl"`
Token string `json:"token"`
Capabilities struct {
CanEdit bool `json:"canEdit"`
CanAnnotate bool `json:"canAnnotate"`
IsPdf bool `json:"isPdf"`
MimeType string `json:"mimeType"`
} `json:"capabilities"`
FileInfo struct {
Name string `json:"name"`
Size int64 `json:"size"`
LastModified string `json:"lastModified"`
ModifiedByName string `json:"modifiedByName"`
} `json:"fileInfo"`
ExpiresAt string `json:"expiresAt"`
}{
ViewUrl: downloadPath,
Token: viewerToken, // Long-lived JWT token for authenticating file download
Capabilities: struct {
CanEdit bool `json:"canEdit"`
CanAnnotate bool `json:"canAnnotate"`
IsPdf bool `json:"isPdf"`
MimeType string `json:"mimeType"`
}{
CanEdit: false,
CanAnnotate: isPdf,
IsPdf: isPdf,
MimeType: mimeType,
},
FileInfo: struct {
Name string `json:"name"`
Size int64 `json:"size"`
LastModified string `json:"lastModified"`
ModifiedByName string `json:"modifiedByName"`
}{
Name: file.Name,
Size: file.Size,
LastModified: file.LastModified.UTC().Format(time.RFC3339),
ModifiedByName: file.ModifiedByName,
},
ExpiresAt: time.Now().Add(24 * time.Hour).UTC().Format(time.RFC3339),
}
fmt.Printf("[VIEWER-SESSION] userId=%s, fileId=%s, token_included=yes, isPdf=%v, mimeType=%s\n", userID.String(), fileId, isPdf, mimeType)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(viewerSession)
}
func editorHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger) {
userIDStr, _ := middleware.GetUserID(r.Context())
userID, _ := uuid.Parse(userIDStr)
orgID := r.Context().Value(middleware.OrgKey).(uuid.UUID)
fileId := chi.URLParam(r, "fileId")
// Log activity
db.LogActivity(r.Context(), userID, orgID, &fileId, "edit_file", map[string]interface{}{})
// Generate WOPI access token (1 hour duration)
token, _ := middleware.GetToken(r.Context())
// Build WOPISrc URL
wopiSrc := fmt.Sprintf("https://go.b0esche.cloud/wopi/files/%s?access_token=%s", fileId, token)
// Build Collabora editor URL
collaboraUrl := fmt.Sprintf("https://of.b0esche.cloud/lool/dist/mobile/cool.html?WOPISrc=%s", url.QueryEscape(wopiSrc))
// Check if user can edit (for now, all org members can edit)
readOnly := false
session := struct {
EditUrl string `json:"editUrl"`
Token string `json:"token"`
ReadOnly bool `json:"readOnly"`
ExpiresAt string `json:"expiresAt"`
}{
EditUrl: collaboraUrl,
Token: token, // JWT token for authenticating file access
ReadOnly: readOnly,
ExpiresAt: time.Now().Add(15 * time.Minute).UTC().Format(time.RFC3339),
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(session)
}
// userEditorHandler handles GET /user/files/{fileId}/edit
func userEditorHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger) {
userIDStr, _ := middleware.GetUserID(r.Context())
userID, _ := uuid.Parse(userIDStr)
fileId := chi.URLParam(r, "fileId")
// Get file metadata to determine path and type
fileUUID, err := uuid.Parse(fileId)
if err != nil {
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid file ID", http.StatusBadRequest)
return
}
file, err := db.GetFileByID(r.Context(), fileUUID)
if err != nil {
errors.LogError(r, err, "Failed to get file metadata")
errors.WriteError(w, errors.CodeNotFound, "File not found", http.StatusNotFound)
return
}
// Verify user owns this file
if file.UserID == nil || *file.UserID != userID {
errors.WriteError(w, errors.CodePermissionDenied, "Access denied", http.StatusForbidden)
return
}
// Log activity
db.LogActivity(r.Context(), userID, uuid.Nil, &fileId, "edit_file", map[string]interface{}{})
// Generate WOPI access token (1 hour duration)
token, _ := middleware.GetToken(r.Context())
// Build WOPISrc URL
wopiSrc := fmt.Sprintf("https://go.b0esche.cloud/wopi/files/%s?access_token=%s", fileId, token)
// Build Collabora editor URL
collaboraUrl := fmt.Sprintf("https://of.b0esche.cloud/lool/dist/mobile/cool.html?WOPISrc=%s", url.QueryEscape(wopiSrc))
// Check if user can edit (for now, all users can edit their own files)
readOnly := false
session := struct {
EditUrl string `json:"editUrl"`
Token string `json:"token"`
ReadOnly bool `json:"readOnly"`
ExpiresAt string `json:"expiresAt"`
}{
EditUrl: collaboraUrl,
Token: token, // JWT token for authenticating file access
ReadOnly: readOnly,
ExpiresAt: time.Now().Add(15 * time.Minute).UTC().Format(time.RFC3339),
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(session)
}
func annotationsHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger) {
userIDStr, _ := middleware.GetUserID(r.Context())
userID, _ := uuid.Parse(userIDStr)
orgID := r.Context().Value(middleware.OrgKey).(uuid.UUID)
fileId := chi.URLParam(r, "fileId")
// Parse payload
var payload struct {
Annotations []interface{} `json:"annotations"`
BaseVersionId string `json:"baseVersionId"`
}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
errors.WriteError(w, errors.CodeInvalidArgument, "Bad request", http.StatusBadRequest)
return
}
// Log activity
auditLogger.Log(r.Context(), audit.Entry{
UserID: &userID,
OrgID: &orgID,
Resource: &fileId,
Action: "annotate_pdf",
Success: true,
Metadata: map[string]interface{}{"count": len(payload.Annotations)},
})
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"status": "ok"}`))
}
func activityHandler(w http.ResponseWriter, r *http.Request, db *database.DB) {
orgID := r.Context().Value(middleware.OrgKey).(uuid.UUID)
activities, err := db.GetOrgActivities(r.Context(), orgID, 50)
if err != nil {
errors.LogError(r, err, "Failed to get org activities")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(activities)
}
func listMembersHandler(w http.ResponseWriter, r *http.Request, db *database.DB) {
orgID := r.Context().Value(middleware.OrgKey).(uuid.UUID)
members, err := db.GetOrgMembers(r.Context(), orgID)
if err != nil {
errors.LogError(r, err, "Failed to get org members")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(members)
}
func updateMemberRoleHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger) {
orgID := r.Context().Value(middleware.OrgKey).(uuid.UUID)
userIDStr := chi.URLParam(r, "userId")
userID, err := uuid.Parse(userIDStr)
if err != nil {
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid user ID", http.StatusBadRequest)
return
}
var req struct {
Role string `json:"role"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
errors.WriteError(w, errors.CodeInvalidArgument, "Bad request", http.StatusBadRequest)
return
}
if err := db.UpdateMemberRole(r.Context(), orgID, userID, req.Role); err != nil {
errors.LogError(r, err, "Failed to update member role")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"status": "ok"}`))
}
func fileMetaHandler(w http.ResponseWriter, r *http.Request) {
meta := struct {
LastModified string `json:"lastModified"`
LastModifiedBy string `json:"lastModifiedBy"`
VersionCount int `json:"versionCount"`
}{
LastModified: "2023-01-01T00:00:00Z",
LastModifiedBy: "user@example.com",
VersionCount: 1,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(meta)
}
// Passkey handlers
func signupHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger) {
var req struct {
Username string `json:"username"`
Email string `json:"email"`
DisplayName string `json:"displayName"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
errors.WriteError(w, errors.CodeInvalidArgument, "Bad request", http.StatusBadRequest)
return
}
if req.Username == "" || req.Email == "" || req.Password == "" {
errors.WriteError(w, errors.CodeInvalidArgument, "Username, email, and password are required", http.StatusBadRequest)
return
}
// Hash password
passkeyService := auth.NewService(db)
passwordHash, err := passkeyService.HashPassword(req.Password)
if err != nil {
errors.LogError(r, err, "Failed to hash password")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
// Create user with hashed password
user, err := db.CreateUser(r.Context(), req.Username, req.Email, req.DisplayName, &passwordHash)
if err != nil {
errors.LogError(r, err, "Failed to create user")
if strings.Contains(err.Error(), "duplicate key") {
errors.WriteError(w, errors.CodeConflict, "Username or email already exists", http.StatusConflict)
} else {
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
}
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]interface{}{
"userId": user.ID,
"user": user,
})
}
func registrationChallengeHandler(w http.ResponseWriter, r *http.Request, db *database.DB) {
var req struct {
UserID string `json:"userId"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
errors.WriteError(w, errors.CodeInvalidArgument, "Bad request", http.StatusBadRequest)
return
}
userID, err := uuid.Parse(req.UserID)
if err != nil {
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid user ID", http.StatusBadRequest)
return
}
passkeyService := auth.NewService(db)
challenge, err := passkeyService.StartRegistrationChallenge(r.Context(), userID)
if err != nil {
errors.LogError(r, err, "Failed to generate challenge")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"challenge": challenge,
"rp": map[string]string{
"name": auth.RPName,
"id": auth.RPID,
},
"user": map[string]string{
"id": userID.String(),
"name": userID.String(),
},
"pubKeyCredParams": []map[string]interface{}{
{"alg": -7, "type": "public-key"},
{"alg": -257, "type": "public-key"},
},
"timeout": 60000,
"attestation": "direct",
"authenticatorSelection": map[string]interface{}{
"authenticatorAttachment": "platform",
"requireResidentKey": false,
"userVerification": "preferred",
},
})
}
func registrationVerifyHandler(w http.ResponseWriter, r *http.Request, db *database.DB, jwtManager *jwt.Manager, auditLogger *audit.Logger) {
var req struct {
UserID string `json:"userId"`
Challenge string `json:"challenge"`
CredentialID string `json:"credentialId"`
PublicKey string `json:"publicKey"`
ClientDataJSON string `json:"clientDataJSON"`
AttestationObject string `json:"attestationObject"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
errors.WriteError(w, errors.CodeInvalidArgument, "Bad request", http.StatusBadRequest)
return
}
userID, err := uuid.Parse(req.UserID)
if err != nil {
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid user ID", http.StatusBadRequest)
return
}
passkeyService := auth.NewService(db)
_, err = passkeyService.VerifyRegistrationResponse(
r.Context(),
userID,
req.Challenge,
req.CredentialID,
req.PublicKey,
req.ClientDataJSON,
req.AttestationObject,
)
if err != nil {
errors.LogError(r, err, "Failed to verify registration")
errors.WriteError(w, errors.CodeUnauthenticated, "Registration failed: "+err.Error(), http.StatusBadRequest)
return
}
// Create session
session, err := db.CreateSession(r.Context(), userID, time.Now().Add(15*time.Minute))
if err != nil {
errors.LogError(r, err, "Failed to create session")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
// Get user
user, err := db.GetUserByID(r.Context(), userID)
if err != nil {
errors.LogError(r, err, "Failed to get user")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
// Generate JWT
orgIDs := []string{}
token, err := jwtManager.Generate(user.ID.String(), orgIDs, session.ID.String())
if err != nil {
errors.LogError(r, err, "Token generation failed")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
auditLogger.Log(r.Context(), audit.Entry{
UserID: &userID,
Action: "registration",
Success: true,
})
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"token": token,
"user": user,
})
}
func authenticationChallengeHandler(w http.ResponseWriter, r *http.Request, db *database.DB) {
var req struct {
Username string `json:"username"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
errors.WriteError(w, errors.CodeInvalidArgument, "Bad request", http.StatusBadRequest)
return
}
if req.Username == "" {
errors.WriteError(w, errors.CodeInvalidArgument, "Username is required", http.StatusBadRequest)
return
}
passkeyService := auth.NewService(db)
challenge, credentialIDs, err := passkeyService.StartAuthenticationChallenge(r.Context(), req.Username)
if err != nil {
errors.LogError(r, err, "Failed to generate challenge")
errors.WriteError(w, errors.CodeNotFound, "User not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"challenge": challenge,
"timeout": 60000,
"userVerification": "preferred",
"allowCredentials": credentialIDs,
})
}
func authenticationVerifyHandler(w http.ResponseWriter, r *http.Request, db *database.DB, jwtManager *jwt.Manager, auditLogger *audit.Logger) {
var req struct {
Username string `json:"username"`
Challenge string `json:"challenge"`
CredentialID string `json:"credentialId"`
AuthenticatorData string `json:"authenticatorData"`
ClientDataJSON string `json:"clientDataJSON"`
Signature string `json:"signature"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
errors.WriteError(w, errors.CodeInvalidArgument, "Bad request", http.StatusBadRequest)
return
}
passkeyService := auth.NewService(db)
user, err := passkeyService.VerifyAuthenticationResponse(
r.Context(),
req.Username,
req.Challenge,
req.CredentialID,
req.AuthenticatorData,
req.ClientDataJSON,
req.Signature,
)
if err != nil {
errors.LogError(r, err, "Failed to verify authentication")
errors.WriteError(w, errors.CodeUnauthenticated, "Authentication failed: "+err.Error(), http.StatusBadRequest)
return
}
// Create session
session, err := db.CreateSession(r.Context(), user.ID, time.Now().Add(15*time.Minute))
if err != nil {
errors.LogError(r, err, "Failed to create session")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
// Get user orgs
orgs, err := db.GetUserOrganizations(r.Context(), user.ID)
if err != nil {
errors.LogError(r, err, "Failed to get user orgs")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
orgIDs := make([]string, len(orgs))
for i, o := range orgs {
orgIDs[i] = o.ID.String()
}
// Generate JWT
token, err := jwtManager.Generate(user.ID.String(), orgIDs, session.ID.String())
if err != nil {
errors.LogError(r, err, "Token generation failed")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
auditLogger.Log(r.Context(), audit.Entry{
UserID: &user.ID,
Action: "login",
Success: true,
})
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"token": token,
"user": user,
})
}
func passwordLoginHandler(w http.ResponseWriter, r *http.Request, db *database.DB, jwtManager *jwt.Manager, auditLogger *audit.Logger) {
var req struct {
Username string `json:"username"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
errors.WriteError(w, errors.CodeInvalidArgument, "Bad request", http.StatusBadRequest)
return
}
if req.Username == "" || req.Password == "" {
errors.WriteError(w, errors.CodeInvalidArgument, "Username and password are required", http.StatusBadRequest)
return
}
// Verify password
passkeyService := auth.NewService(db)
user, err := passkeyService.VerifyPasswordLogin(r.Context(), req.Username, req.Password)
if err != nil {
auditLogger.Log(r.Context(), audit.Entry{
Action: "login",
Success: false,
Metadata: map[string]interface{}{"error": err.Error()},
})
errors.LogError(r, err, "Password login failed")
errors.WriteError(w, errors.CodeUnauthenticated, "Invalid credentials", http.StatusUnauthorized)
return
}
// Create session
session, err := db.CreateSession(r.Context(), user.ID, time.Now().Add(15*time.Minute))
if err != nil {
errors.LogError(r, err, "Failed to create session")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
// Get user orgs
orgs, err := db.GetUserOrganizations(r.Context(), user.ID)
if err != nil {
errors.LogError(r, err, "Failed to get user orgs")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
orgIDs := make([]string, len(orgs))
for i, o := range orgs {
orgIDs[i] = o.ID.String()
}
// Generate JWT
token, err := jwtManager.Generate(user.ID.String(), orgIDs, session.ID.String())
if err != nil {
errors.LogError(r, err, "Token generation failed")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
auditLogger.Log(r.Context(), audit.Entry{
UserID: &user.ID,
Action: "login",
Success: true,
})
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"token": token,
"user": user,
})
}
// userFilesHandler returns files for the authenticated user's personal workspace.
func userFilesHandler(w http.ResponseWriter, r *http.Request, db *database.DB) {
userIDStr, ok := middleware.GetUserID(r.Context())
if !ok || userIDStr == "" {
errors.WriteError(w, errors.CodeUnauthenticated, "Unauthorized", http.StatusUnauthorized)
return
}
userID, err := uuid.Parse(userIDStr)
if err != nil {
errors.LogError(r, err, "Invalid user id in context")
errors.WriteError(w, errors.CodeUnauthenticated, "Unauthorized", http.StatusUnauthorized)
return
}
path := r.URL.Query().Get("path")
if path == "" {
path = "/"
}
q := r.URL.Query().Get("q")
page := 1
pageSize := 100
if p := r.URL.Query().Get("page"); p != "" {
fmt.Sscanf(p, "%d", &page)
}
if ps := r.URL.Query().Get("pageSize"); ps != "" {
fmt.Sscanf(ps, "%d", &pageSize)
}
files, err := db.GetUserFiles(r.Context(), userID, path, q, page, pageSize)
if err != nil {
errors.LogError(r, err, "Failed to get user files")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
out := make([]map[string]interface{}, 0, len(files))
for _, f := range files {
out = append(out, map[string]interface{}{
"id": f.ID.String(),
"name": f.Name,
"path": f.Path,
"type": f.Type,
"size": f.Size,
"lastModified": f.LastModified.UTC().Format(time.RFC3339),
})
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(out)
}
// 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, cfg *config.Config) {
orgID := r.Context().Value(middleware.OrgKey).(uuid.UUID)
userIDStr, _ := middleware.GetUserID(r.Context())
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 {
errors.WriteError(w, errors.CodeInvalidArgument, "Bad multipart request", http.StatusBadRequest)
return
}
parentPath := r.FormValue("path")
if parentPath == "" {
parentPath = "/"
}
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
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))
// Get or create user's WebDAV client
storageClient, err := getUserWebDAVClient(r.Context(), db, userID, cfg.NextcloudURL, cfg.NextcloudUser, cfg.NextcloudPass)
if err != nil {
errors.LogError(r, err, "Failed to get user WebDAV client")
errors.WriteError(w, errors.CodeInternal, "Storage not configured", http.StatusInternalServerError)
return
}
// Upload to user's Nextcloud space 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 {
errors.LogError(r, err, "WebDAV upload failed")
errors.WriteError(w, errors.CodeInternal, "Failed to upload file to storage", http.StatusInternalServerError)
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
}
var req struct {
Name string `json:"name"`
Path string `json:"path"`
Type string `json:"type"` // file|folder
Size int64 `json:"size"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
errors.WriteError(w, errors.CodeInvalidArgument, "Bad request", http.StatusBadRequest)
return
}
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)
return
}
auditLogger.Log(r.Context(), audit.Entry{
UserID: &userID,
OrgID: &orgID,
Action: "create_file",
Success: true,
})
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{"id": f.ID})
}
// deleteOrgFileHandler deletes a file/folder in org workspace by path
func deleteOrgFileHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger, cfg *config.Config) {
orgID := r.Context().Value(middleware.OrgKey).(uuid.UUID)
userIDStr, _ := middleware.GetUserID(r.Context())
userID, _ := uuid.Parse(userIDStr)
var req struct {
Path string `json:"path"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
errors.WriteError(w, errors.CodeInvalidArgument, "Bad request", http.StatusBadRequest)
return
}
// Get or create user's WebDAV client and delete from Nextcloud
storageClient, err := getUserWebDAVClient(r.Context(), db, userID, cfg.NextcloudURL, cfg.NextcloudUser, cfg.NextcloudPass)
if err != nil {
errors.LogError(r, err, "Failed to get user WebDAV client (continuing with database deletion)")
} else {
rel := strings.TrimPrefix(req.Path, "/")
remotePath := path.Join("/orgs", orgID.String(), rel)
if err := storageClient.Delete(r.Context(), remotePath); err != nil {
errors.LogError(r, err, "Failed to delete from Nextcloud (continuing anyway)")
}
}
// Delete from database
if err := db.DeleteFileByPath(r.Context(), &orgID, nil, req.Path); err != nil {
errors.LogError(r, err, "Failed to delete org file")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
auditLogger.Log(r.Context(), audit.Entry{
UserID: &userID,
OrgID: &orgID,
Action: "delete_file",
Success: true,
})
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"status":"ok"}`))
}
// Also accept POST /orgs/{orgId}/files/delete for clients that cannot send DELETE with body
func deleteOrgFilePostHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger, cfg *config.Config) {
deleteOrgFileHandler(w, r, db, auditLogger, cfg)
}
// moveOrgFileHandler moves/renames a file in org workspace
func moveOrgFileHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger, cfg *config.Config) {
orgID := r.Context().Value(middleware.OrgKey).(uuid.UUID)
userIDStr, _ := middleware.GetUserID(r.Context())
userID, _ := uuid.Parse(userIDStr)
var req struct {
SourcePath string `json:"sourcePath"`
TargetPath string `json:"targetPath"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
errors.WriteError(w, errors.CodeInvalidArgument, "Bad request", http.StatusBadRequest)
return
}
// Get source file details before moving
sourceFiles, err := db.GetOrgFiles(r.Context(), orgID, userID, "/", "", 0, 1000)
if err != nil {
errors.LogError(r, err, "Failed to get org files")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
var sourceFile *database.File
for i := range sourceFiles {
if sourceFiles[i].Path == req.SourcePath {
sourceFile = &sourceFiles[i]
break
}
}
if sourceFile == nil {
errors.WriteError(w, errors.CodeInvalidArgument, "Source file not found", http.StatusNotFound)
return
}
// Determine new file name - check if target is a folder
var newPath string
var targetFile *database.File
for i := range sourceFiles {
if sourceFiles[i].Path == req.TargetPath {
targetFile = &sourceFiles[i]
break
}
}
if targetFile != nil && targetFile.Type == "folder" {
// Target is a folder, move file into it
newPath = path.Join(req.TargetPath, sourceFile.Name)
} else if targetFile == nil && strings.HasSuffix(req.TargetPath, "/") {
// Target path doesn't exist but ends with /, treat as folder
newPath = path.Join(req.TargetPath, sourceFile.Name)
} else {
// Moving/renaming to a specific path
newPath = req.TargetPath
}
// Get or create user's WebDAV client and move in Nextcloud
storageClient, err := getUserWebDAVClient(r.Context(), db, userID, cfg.NextcloudURL, cfg.NextcloudUser, cfg.NextcloudPass)
if err != nil {
errors.LogError(r, err, "Failed to get user WebDAV client (continuing with database operation)")
} else {
sourceRel := strings.TrimPrefix(req.SourcePath, "/")
sourcePath := path.Join("/orgs", orgID.String(), sourceRel)
targetRel := strings.TrimPrefix(newPath, "/")
targetPath := path.Join("/orgs", orgID.String(), targetRel)
if err := storageClient.Move(r.Context(), sourcePath, targetPath); err != nil {
errors.LogError(r, err, "Failed to move in Nextcloud (continuing with database operation)")
}
}
// Delete old file record
if err := db.DeleteFileByPath(r.Context(), &orgID, nil, req.SourcePath); err != nil {
errors.LogError(r, err, "Failed to delete old file record")
}
// Create new file record at the new location
if _, err := db.CreateFile(r.Context(), &orgID, nil, sourceFile.Name, newPath, sourceFile.Type, sourceFile.Size); err != nil {
errors.LogError(r, err, "Failed to create new file record at destination")
}
auditLogger.Log(r.Context(), audit.Entry{
UserID: &userID,
OrgID: &orgID,
Action: "move_file",
Success: true,
})
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"status":"ok"}`))
}
// 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, cfg *config.Config) {
userIDStr, ok := middleware.GetUserID(r.Context())
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 {
errors.WriteError(w, errors.CodeInvalidArgument, "Bad multipart request", http.StatusBadRequest)
return
}
parentPath := r.FormValue("path")
if parentPath == "" {
parentPath = "/"
}
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
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))
fmt.Printf("[DEBUG] Upload: user=%s, file=%s, size=%d, path=%s\n", userID.String(), header.Filename, len(data), storedPath)
// Get or create user's WebDAV client
storageClient, err := getUserWebDAVClient(r.Context(), db, userID, cfg.NextcloudURL, cfg.NextcloudUser, cfg.NextcloudPass)
if err != nil {
errors.LogError(r, err, "Failed to get user WebDAV client")
errors.WriteError(w, errors.CodeInternal, "Storage not configured", http.StatusInternalServerError)
return
}
// Upload to user's personal Nextcloud space (just the path, no username prefix)
remotePath := strings.TrimPrefix(storedPath, "/")
fmt.Printf("[DEBUG] Uploading to user WebDAV: /%s\n", remotePath)
if err = storageClient.Upload(r.Context(), "/"+remotePath, bytes.NewReader(data), int64(len(data))); err != nil {
errors.LogError(r, err, "WebDAV upload failed")
errors.WriteError(w, errors.CodeInternal, "Failed to upload file to storage", http.StatusInternalServerError)
return
}
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
}
var req struct {
Name string `json:"name"`
Path string `json:"path"`
Type string `json:"type"` // file|folder
Size int64 `json:"size"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
errors.WriteError(w, errors.CodeInvalidArgument, "Bad request", http.StatusBadRequest)
return
}
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)
return
}
auditLogger.Log(r.Context(), audit.Entry{
UserID: &userID,
Action: "create_user_file",
Success: true,
})
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{"id": f.ID})
}
// Also accept POST /user/files/delete
func deleteUserFilePostHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger, cfg *config.Config) {
deleteUserFileHandler(w, r, db, auditLogger, cfg)
}
// moveUserFileHandler moves/renames a file in user's personal workspace
func moveUserFileHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger, cfg *config.Config) {
userIDStr, ok := middleware.GetUserID(r.Context())
if !ok || userIDStr == "" {
errors.WriteError(w, errors.CodeUnauthenticated, "Unauthorized", http.StatusUnauthorized)
return
}
userID, _ := uuid.Parse(userIDStr)
var req struct {
SourcePath string `json:"sourcePath"`
TargetPath string `json:"targetPath"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
errors.WriteError(w, errors.CodeInvalidArgument, "Bad request", http.StatusBadRequest)
return
}
// Get source file details before moving
sourceFiles, err := db.GetUserFiles(r.Context(), userID, "/", "", 0, 1000)
if err != nil {
errors.LogError(r, err, "Failed to get user files")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
var sourceFile *database.File
for i := range sourceFiles {
if sourceFiles[i].Path == req.SourcePath {
sourceFile = &sourceFiles[i]
break
}
}
if sourceFile == nil {
errors.WriteError(w, errors.CodeInvalidArgument, "Source file not found", http.StatusNotFound)
return
}
// Determine new file name - check if target is a folder
var newPath string
var targetFile *database.File
for i := range sourceFiles {
if sourceFiles[i].Path == req.TargetPath {
targetFile = &sourceFiles[i]
break
}
}
if targetFile != nil && targetFile.Type == "folder" {
// Target is a folder, move file into it
newPath = path.Join(req.TargetPath, sourceFile.Name)
} else if targetFile == nil && strings.HasSuffix(req.TargetPath, "/") {
// Target path doesn't exist but ends with /, treat as folder
newPath = path.Join(req.TargetPath, sourceFile.Name)
} else {
// Moving/renaming to a specific path
newPath = req.TargetPath
}
// Get or create user's WebDAV client and move in Nextcloud
storageClient, err := getUserWebDAVClient(r.Context(), db, userID, cfg.NextcloudURL, cfg.NextcloudUser, cfg.NextcloudPass)
if err != nil {
errors.LogError(r, err, "Failed to get user WebDAV client (continuing with database operation)")
} else {
// User files are stored directly in the user's WebDAV root (no /users/{id} prefix)
sourcePath := "/" + strings.TrimPrefix(req.SourcePath, "/")
targetPath := "/" + strings.TrimPrefix(newPath, "/")
if err := storageClient.Move(r.Context(), sourcePath, targetPath); err != nil {
errors.LogError(r, err, "Failed to move in Nextcloud (continuing with database operation)")
}
}
// Delete old file record
if err := db.DeleteFileByPath(r.Context(), nil, &userID, req.SourcePath); err != nil {
errors.LogError(r, err, "Failed to delete old file record")
}
// Create new file record at the new location
if _, err := db.CreateFile(r.Context(), nil, &userID, sourceFile.Name, newPath, sourceFile.Type, sourceFile.Size); err != nil {
errors.LogError(r, err, "Failed to create new file record at destination")
}
auditLogger.Log(r.Context(), audit.Entry{
UserID: &userID,
Action: "move_file",
Success: true,
})
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"status":"ok"}`))
}
// deleteUserFileHandler deletes a file/folder in user's personal workspace by path
func deleteUserFileHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger, cfg *config.Config) {
userIDStr, ok := middleware.GetUserID(r.Context())
if !ok || userIDStr == "" {
errors.WriteError(w, errors.CodeUnauthenticated, "Unauthorized", http.StatusUnauthorized)
return
}
userID, _ := uuid.Parse(userIDStr)
var req struct {
Path string `json:"path"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
errors.WriteError(w, errors.CodeInvalidArgument, "Bad request", http.StatusBadRequest)
return
}
// Get or create user's WebDAV client and delete from Nextcloud
storageClient, err := getUserWebDAVClient(r.Context(), db, userID, cfg.NextcloudURL, cfg.NextcloudUser, cfg.NextcloudPass)
if err != nil {
errors.LogError(r, err, "Failed to get user WebDAV client (continuing with database deletion)")
} else {
remotePath := strings.TrimPrefix(req.Path, "/")
if err := storageClient.Delete(r.Context(), "/"+remotePath); err != nil {
errors.LogError(r, err, "Failed to delete from Nextcloud (continuing anyway)")
}
}
// Delete from database
if err := db.DeleteFileByPath(r.Context(), nil, &userID, req.Path); err != nil {
errors.LogError(r, err, "Failed to delete user file")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
auditLogger.Log(r.Context(), audit.Entry{
UserID: &userID,
Action: "delete_user_file",
Success: true,
})
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"status":"ok"}`))
}
// downloadOrgFileHandler downloads a file from org workspace
func downloadOrgFileHandler(w http.ResponseWriter, r *http.Request, db *database.DB, cfg *config.Config) {
orgID := r.Context().Value(middleware.OrgKey).(uuid.UUID)
userIDStr, _ := middleware.GetUserID(r.Context())
userID, _ := uuid.Parse(userIDStr)
filePath := r.URL.Query().Get("path")
if filePath == "" {
errors.WriteError(w, errors.CodeInvalidArgument, "Missing path parameter", http.StatusBadRequest)
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 {
errors.LogError(r, err, "Failed to get user WebDAV client")
errors.WriteError(w, errors.CodeInternal, "Storage not configured", http.StatusInternalServerError)
return
}
// Check if it's a folder
file, err := db.GetOrgFileByPath(r.Context(), orgID, userID, filePath)
if err != nil && err.Error() != "sql: no rows in result set" {
errors.LogError(r, err, "Failed to get file info")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
if file != nil && file.Type == "folder" {
// Download folder as ZIP
err = downloadOrgFolderAsZip(w, r, db, cfg, orgID, userID, filePath, storageClient)
if err != nil {
errors.LogError(r, err, "Failed to download folder")
errors.WriteError(w, errors.CodeInternal, "Failed to download folder", http.StatusInternalServerError)
}
return
}
// Download from user's Nextcloud space under /orgs/<orgID>/
rel := strings.TrimPrefix(filePath, "/")
remotePath := path.Join("/orgs", orgID.String(), rel)
resp, err := storageClient.Download(r.Context(), remotePath, r.Header.Get("Range"))
if err != nil {
errors.LogError(r, err, "Failed to download from Nextcloud")
errors.WriteError(w, errors.CodeNotFound, "File not found", http.StatusNotFound)
return
}
defer resp.Body.Close()
// Set appropriate headers for inline viewing
fileName := path.Base(filePath)
// Determine content type based on file extension
contentType := "application/octet-stream"
if strings.HasSuffix(strings.ToLower(fileName), ".pdf") {
contentType = "application/pdf"
} else if strings.HasSuffix(strings.ToLower(fileName), ".png") {
contentType = "image/png"
} else if strings.HasSuffix(strings.ToLower(fileName), ".jpg") || strings.HasSuffix(strings.ToLower(fileName), ".jpeg") {
contentType = "image/jpeg"
}
w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=\"%s\"", fileName))
if ct := resp.Header.Get("Content-Type"); ct != "" {
w.Header().Set("Content-Type", ct)
} else {
w.Header().Set("Content-Type", contentType)
}
w.Header().Set("Accept-Ranges", "bytes")
if cr := resp.Header.Get("Content-Range"); cr != "" {
w.Header().Set("Content-Range", cr)
}
if cl := resp.Header.Get("Content-Length"); cl != "" {
w.Header().Set("Content-Length", cl)
}
if resp.StatusCode == http.StatusPartialContent {
w.WriteHeader(http.StatusPartialContent)
}
// Stream the file
io.Copy(w, resp.Body)
}
// downloadOrgFolderAsZip downloads a folder as ZIP archive
func downloadOrgFolderAsZip(w http.ResponseWriter, r *http.Request, db *database.DB, cfg *config.Config, orgID, userID uuid.UUID, folderPath string, storageClient *storage.WebDAVClient) error {
// Get all files under the folder
files, err := db.GetAllOrgFilesUnderPath(r.Context(), orgID, userID, folderPath)
if err != nil {
return err
}
// Filter only files, not folders
var fileList []database.File
for _, f := range files {
if f.Type == "file" {
fileList = append(fileList, f)
}
}
// Set headers for ZIP download
folderName := path.Base(folderPath)
if folderName == "" || folderName == "/" {
folderName = "org_files"
}
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s.zip\"", folderName))
// Create ZIP writer
zipWriter := zip.NewWriter(w)
defer zipWriter.Close()
// Ensure folderPath ends with / for proper relative path calculation
if !strings.HasSuffix(folderPath, "/") {
folderPath += "/"
}
// Add each file to ZIP
for _, file := range fileList {
// Calculate relative path in ZIP
relPath := strings.TrimPrefix(file.Path, folderPath)
// Download file from WebDAV
remoteRel := strings.TrimPrefix(file.Path, "/")
remotePath := path.Join("/orgs", orgID.String(), remoteRel)
resp, err := storageClient.Download(r.Context(), remotePath, "")
if err != nil {
continue // Skip files that can't be downloaded
}
defer resp.Body.Close()
// Create ZIP entry
zipFile, err := zipWriter.Create(relPath)
if err != nil {
continue
}
// Copy file content to ZIP
io.Copy(zipFile, resp.Body)
}
return nil
}
// downloadUserFileHandler downloads a file from user's personal workspace
func downloadUserFileHandler(w http.ResponseWriter, r *http.Request, db *database.DB, cfg *config.Config) {
// Try to get userID from context (Bearer token), fallback to query parameter
userIDStr, ok := middleware.GetUserID(r.Context())
if !ok || userIDStr == "" {
// Token might be in query parameter for PDF viewer compatibility
// This is acceptable since the token is still validated
errors.WriteError(w, errors.CodeUnauthenticated, "Unauthorized", http.StatusUnauthorized)
return
}
userID, _ := uuid.Parse(userIDStr)
filePath := r.URL.Query().Get("path")
if filePath == "" {
errors.WriteError(w, errors.CodeInvalidArgument, "Missing path parameter", http.StatusBadRequest)
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 {
errors.LogError(r, err, "Failed to get user WebDAV client")
errors.WriteError(w, errors.CodeInternal, "Storage not configured", http.StatusInternalServerError)
return
}
// Check if it's a folder
file, err := db.GetUserFileByPath(r.Context(), userID, filePath)
if err != nil && err.Error() != "sql: no rows in result set" {
errors.LogError(r, err, "Failed to get file info")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
if file != nil && file.Type == "folder" {
// Download folder as ZIP
err = downloadUserFolderAsZip(w, r, db, cfg, userID, filePath, storageClient)
if err != nil {
errors.LogError(r, err, "Failed to download folder")
errors.WriteError(w, errors.CodeInternal, "Failed to download folder", http.StatusInternalServerError)
}
return
}
// Download from user's personal Nextcloud space
remotePath := strings.TrimPrefix(filePath, "/")
resp, err := storageClient.Download(r.Context(), "/"+remotePath, r.Header.Get("Range"))
if err != nil {
errors.LogError(r, err, "Failed to download from Nextcloud")
errors.WriteError(w, errors.CodeNotFound, "File not found", http.StatusNotFound)
return
}
defer resp.Body.Close()
// Set appropriate headers for inline viewing
fileName := path.Base(filePath)
// Determine content type based on file extension
contentType := "application/octet-stream"
if strings.HasSuffix(strings.ToLower(fileName), ".pdf") {
contentType = "application/pdf"
} else if strings.HasSuffix(strings.ToLower(fileName), ".png") {
contentType = "image/png"
} else if strings.HasSuffix(strings.ToLower(fileName), ".jpg") || strings.HasSuffix(strings.ToLower(fileName), ".jpeg") {
contentType = "image/jpeg"
}
w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=\"%s\"", fileName))
if ct := resp.Header.Get("Content-Type"); ct != "" {
w.Header().Set("Content-Type", ct)
} else {
w.Header().Set("Content-Type", contentType)
}
w.Header().Set("Accept-Ranges", "bytes")
if cr := resp.Header.Get("Content-Range"); cr != "" {
w.Header().Set("Content-Range", cr)
}
if cl := resp.Header.Get("Content-Length"); cl != "" {
w.Header().Set("Content-Length", cl)
}
if resp.StatusCode == http.StatusPartialContent {
w.WriteHeader(http.StatusPartialContent)
}
// Stream the file
io.Copy(w, resp.Body)
}
// downloadUserFolderAsZip downloads a folder as ZIP archive
func downloadUserFolderAsZip(w http.ResponseWriter, r *http.Request, db *database.DB, cfg *config.Config, userID uuid.UUID, folderPath string, storageClient *storage.WebDAVClient) error {
// Get all files under the folder
files, err := db.GetAllUserFilesUnderPath(r.Context(), userID, folderPath)
if err != nil {
return err
}
// Filter only files, not folders
var fileList []database.File
for _, f := range files {
if f.Type == "file" {
fileList = append(fileList, f)
}
}
// Set headers for ZIP download
folderName := path.Base(folderPath)
if folderName == "" || folderName == "/" {
folderName = "user_files"
}
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s.zip\"", folderName))
// Create ZIP writer
zipWriter := zip.NewWriter(w)
defer zipWriter.Close()
// Ensure folderPath ends with / for proper relative path calculation
if !strings.HasSuffix(folderPath, "/") {
folderPath += "/"
}
// Add each file to ZIP
for _, file := range fileList {
// Calculate relative path in ZIP
relPath := strings.TrimPrefix(file.Path, folderPath)
// Download file from WebDAV
remotePath := strings.TrimPrefix(file.Path, "/")
resp, err := storageClient.Download(r.Context(), "/"+remotePath, "")
if err != nil {
continue // Skip files that can't be downloaded
}
defer resp.Body.Close()
// Create ZIP entry
zipFile, err := zipWriter.Create(relPath)
if err != nil {
continue
}
// Copy file content to ZIP
io.Copy(zipFile, resp.Body)
}
return nil
}
// getMimeType returns the MIME type based on file extension
func getMimeType(filename string) string {
lower := strings.ToLower(filename)
switch {
case strings.HasSuffix(lower, ".pdf"):
return "application/pdf"
case strings.HasSuffix(lower, ".png"):
return "image/png"
case strings.HasSuffix(lower, ".jpg"), strings.HasSuffix(lower, ".jpeg"):
return "image/jpeg"
case strings.HasSuffix(lower, ".gif"):
return "image/gif"
case strings.HasSuffix(lower, ".webp"):
return "image/webp"
case strings.HasSuffix(lower, ".svg"):
return "image/svg+xml"
case strings.HasSuffix(lower, ".txt"):
return "text/plain"
case strings.HasSuffix(lower, ".html"):
return "text/html"
case strings.HasSuffix(lower, ".json"):
return "application/json"
case strings.HasSuffix(lower, ".csv"):
return "text/csv"
case strings.HasSuffix(lower, ".doc"), strings.HasSuffix(lower, ".docx"):
return "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
case strings.HasSuffix(lower, ".xls"), strings.HasSuffix(lower, ".xlsx"):
return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
case strings.HasSuffix(lower, ".ppt"), strings.HasSuffix(lower, ".pptx"):
return "application/vnd.openxmlformats-officedocument.presentationml.presentation"
default:
return "application/octet-stream"
}
}