4615 lines
158 KiB
Go
4615 lines
158 KiB
Go
package http
|
|
|
|
import (
|
|
"archive/zip"
|
|
"bytes"
|
|
"context"
|
|
"database/sql"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"mime"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"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
|
|
}
|
|
|
|
// Avatar cache helpers
|
|
// avatarCachePath builds a path for the avatar cache file for the given user and version (if provided).
|
|
func avatarCachePath(cfg *config.Config, userID string, version string, ext string) string {
|
|
dir := cfg.AvatarCacheDir
|
|
if dir == "" {
|
|
dir = "/var/cache/b0esche/avatars"
|
|
}
|
|
os.MkdirAll(dir, 0755)
|
|
// Filename: <userID>.<version><ext> (if version empty, use <userID><ext>)
|
|
if version != "" {
|
|
return filepath.Join(dir, fmt.Sprintf("%s.%s%s", userID, version, ext))
|
|
}
|
|
return filepath.Join(dir, fmt.Sprintf("%s%s", userID, ext))
|
|
}
|
|
|
|
// writeAvatarCache saves avatar bytes keyed by userID and version (best effort). If version is empty, writes a file without version suffix.
|
|
func writeAvatarCache(cfg *config.Config, userID string, version string, ext string, data []byte) error {
|
|
dir := cfg.AvatarCacheDir
|
|
if dir == "" {
|
|
dir = "/var/cache/b0esche/avatars"
|
|
}
|
|
// Try to create the directory; if it fails, fall back to temp dir
|
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
|
fmt.Printf("[WARN] failed to create avatar cache dir %s: %v; trying fallback to tmp dir\n", dir, err)
|
|
fallback := filepath.Join(os.TempDir(), "b0esche_avatars")
|
|
if err2 := os.MkdirAll(fallback, 0755); err2 != nil {
|
|
return fmt.Errorf("failed to create avatar cache dir: %v; fallback failed: %v", err, err2)
|
|
}
|
|
dir = fallback
|
|
}
|
|
p := avatarCachePath(&config.Config{AvatarCacheDir: dir}, userID, version, ext)
|
|
if err := os.WriteFile(p, data, 0644); err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("[INFO] Wrote avatar cache for user=%s v=%s path=%s size=%d\n", userID, version, p, len(data))
|
|
return nil
|
|
}
|
|
|
|
// readAvatarCache attempts to read a cached avatar for a user and optional version. If version is provided, it looks for an exact match; otherwise it returns the latest available cached avatar (if any).
|
|
func readAvatarCache(cfg *config.Config, userID string, version string) ([]byte, string, error) {
|
|
checkDirs := []string{cfg.AvatarCacheDir}
|
|
if checkDirs[0] == "" {
|
|
checkDirs[0] = "/var/cache/b0esche/avatars"
|
|
}
|
|
// Also check fallback tmp dir
|
|
checkDirs = append(checkDirs, filepath.Join(os.TempDir(), "b0esche_avatars"))
|
|
|
|
fmt.Printf("[DEBUG] readAvatarCache checking dirs=%v for user=%s version=%s\n", checkDirs, userID, version)
|
|
|
|
for _, dir := range checkDirs {
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
fmt.Printf("[DEBUG] readAvatarCache cannot read dir %s: %v\n", dir, err)
|
|
continue
|
|
}
|
|
if version != "" {
|
|
// look for exact match: userID.version.*
|
|
prefix := fmt.Sprintf("%s.%s", userID, version)
|
|
for _, e := range entries {
|
|
if strings.HasPrefix(e.Name(), prefix) {
|
|
p := filepath.Join(dir, e.Name())
|
|
fmt.Printf("[INFO] Found cached avatar for user=%s v=%s path=%s\n", userID, version, p)
|
|
b, err := os.ReadFile(p)
|
|
if err != nil {
|
|
fmt.Printf("[WARN] failed to read cached avatar %s: %v\n", p, err)
|
|
return nil, "", err
|
|
}
|
|
ext := filepath.Ext(e.Name())
|
|
ct := mime.TypeByExtension(ext)
|
|
if ct == "" {
|
|
ct = "application/octet-stream"
|
|
}
|
|
return b, ct, nil
|
|
}
|
|
}
|
|
// not found
|
|
fmt.Printf("[DEBUG] no exact cached avatar match in %s for prefix=%s\n", dir, prefix)
|
|
continue
|
|
}
|
|
// no version specified: return latest by modtime of files starting with userID.
|
|
var latest os.FileInfo
|
|
var latestName string
|
|
for _, e := range entries {
|
|
if strings.HasPrefix(e.Name(), userID+".") {
|
|
fi, err := e.Info()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if latest == nil || fi.ModTime().After(latest.ModTime()) {
|
|
latest = fi
|
|
latestName = e.Name()
|
|
}
|
|
}
|
|
}
|
|
if latest != nil {
|
|
p := filepath.Join(dir, latestName)
|
|
fmt.Printf("[INFO] Found latest cached avatar for user=%s path=%s\n", userID, p)
|
|
b, err := os.ReadFile(p)
|
|
if err != nil {
|
|
fmt.Printf("[WARN] failed to read cached avatar %s: %v\n", p, err)
|
|
return nil, "", err
|
|
}
|
|
ext := filepath.Ext(latestName)
|
|
ct := mime.TypeByExtension(ext)
|
|
if ct == "" {
|
|
ct = "application/octet-stream"
|
|
}
|
|
return b, ct, nil
|
|
}
|
|
}
|
|
return nil, "", fmt.Errorf("cache miss")
|
|
}
|
|
|
|
// 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
|
|
log.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.SecurityHeaders())
|
|
r.Use(middleware.RateLimit)
|
|
|
|
// Health check
|
|
r.Get("/health", healthHandler)
|
|
|
|
// Join org by invite token (public)
|
|
r.Get("/join", func(w http.ResponseWriter, req *http.Request) {
|
|
getOrgByInviteTokenHandler(w, req, db)
|
|
})
|
|
|
|
// 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")
|
|
})
|
|
// Share link management for user files
|
|
r.Get("/user/files/{fileId}/share", func(w http.ResponseWriter, req *http.Request) {
|
|
getUserFileShareLinkHandler(w, req, db)
|
|
})
|
|
r.Post("/user/files/{fileId}/share", func(w http.ResponseWriter, req *http.Request) {
|
|
createUserFileShareLinkHandler(w, req, db)
|
|
})
|
|
r.Delete("/user/files/{fileId}/share", func(w http.ResponseWriter, req *http.Request) {
|
|
revokeUserFileShareLinkHandler(w, req, db)
|
|
})
|
|
|
|
// Share link management for personal files (alternative path for frontend compatibility)
|
|
r.Get("/orgs/files/{fileId}/share", func(w http.ResponseWriter, req *http.Request) {
|
|
getUserFileShareLinkHandler(w, req, db)
|
|
})
|
|
r.Post("/orgs/files/{fileId}/share", func(w http.ResponseWriter, req *http.Request) {
|
|
createUserFileShareLinkHandler(w, req, db)
|
|
})
|
|
r.Delete("/orgs/files/{fileId}/share", func(w http.ResponseWriter, req *http.Request) {
|
|
revokeUserFileShareLinkHandler(w, req, db)
|
|
})
|
|
|
|
// User profile routes
|
|
r.Get("/user/profile", func(w http.ResponseWriter, req *http.Request) {
|
|
getUserProfileHandler(w, req, db)
|
|
})
|
|
r.Put("/user/profile", func(w http.ResponseWriter, req *http.Request) {
|
|
updateUserProfileHandler(w, req, db, auditLogger)
|
|
})
|
|
r.Options("/user/profile", func(w http.ResponseWriter, req *http.Request) {
|
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
w.Header().Set("Access-Control-Allow-Methods", "GET, PUT, OPTIONS")
|
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
|
w.WriteHeader(http.StatusOK)
|
|
})
|
|
r.Post("/user/change-password", func(w http.ResponseWriter, req *http.Request) {
|
|
changePasswordHandler(w, req, db, auditLogger)
|
|
})
|
|
r.Options("/user/change-password", func(w http.ResponseWriter, req *http.Request) {
|
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS")
|
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
|
w.WriteHeader(http.StatusOK)
|
|
})
|
|
r.Post("/user/avatar", func(w http.ResponseWriter, req *http.Request) {
|
|
uploadUserAvatarHandler(w, req, db, auditLogger, cfg)
|
|
})
|
|
r.Get("/user/avatar", func(w http.ResponseWriter, req *http.Request) {
|
|
getUserAvatarHandler(w, req, db, jwtManager, cfg)
|
|
})
|
|
r.Options("/user/avatar", func(w http.ResponseWriter, req *http.Request) {
|
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
|
w.WriteHeader(http.StatusOK)
|
|
})
|
|
r.Delete("/user/account", func(w http.ResponseWriter, req *http.Request) {
|
|
deleteUserAccountHandler(w, req, db, auditLogger, cfg)
|
|
})
|
|
r.Options("/user/account", func(w http.ResponseWriter, req *http.Request) {
|
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
w.Header().Set("Access-Control-Allow-Methods", "DELETE, OPTIONS")
|
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
|
w.WriteHeader(http.StatusOK)
|
|
})
|
|
|
|
// 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)
|
|
})
|
|
// Share link management
|
|
r.With(middleware.Permission(db, auditLogger, permission.FileRead)).Get("/share", func(w http.ResponseWriter, req *http.Request) {
|
|
getFileShareLinkHandler(w, req, db)
|
|
})
|
|
r.With(middleware.Permission(db, auditLogger, permission.FileRead)).Post("/share", func(w http.ResponseWriter, req *http.Request) {
|
|
createFileShareLinkHandler(w, req, db)
|
|
})
|
|
r.With(middleware.Permission(db, auditLogger, permission.FileRead)).Delete("/share", func(w http.ResponseWriter, req *http.Request) {
|
|
revokeFileShareLinkHandler(w, req, db)
|
|
})
|
|
// 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)
|
|
})
|
|
r.With(middleware.Permission(db, auditLogger, permission.OrgManage)).Delete("/members/{userId}", func(w http.ResponseWriter, req *http.Request) {
|
|
removeMemberHandler(w, req, db, auditLogger)
|
|
})
|
|
r.With(middleware.Permission(db, auditLogger, permission.OrgManage)).Get("/users/search", func(w http.ResponseWriter, req *http.Request) {
|
|
searchUsersHandler(w, req, db)
|
|
})
|
|
r.With(middleware.Permission(db, auditLogger, permission.OrgManage)).Post("/invitations", func(w http.ResponseWriter, req *http.Request) {
|
|
createInvitationHandler(w, req, db, auditLogger)
|
|
})
|
|
r.With(middleware.Permission(db, auditLogger, permission.OrgManage)).Get("/invitations", func(w http.ResponseWriter, req *http.Request) {
|
|
listInvitationsHandler(w, req, db)
|
|
})
|
|
r.With(middleware.Permission(db, auditLogger, permission.OrgManage)).Delete("/invitations/{invitationId}", func(w http.ResponseWriter, req *http.Request) {
|
|
cancelInvitationHandler(w, req, db, auditLogger)
|
|
})
|
|
r.Post("/join-requests", func(w http.ResponseWriter, req *http.Request) {
|
|
createJoinRequestHandler(w, req, db, auditLogger)
|
|
})
|
|
r.With(middleware.Permission(db, auditLogger, permission.OrgManage)).Get("/join-requests", func(w http.ResponseWriter, req *http.Request) {
|
|
listJoinRequestsHandler(w, req, db)
|
|
})
|
|
r.With(middleware.Permission(db, auditLogger, permission.OrgManage)).Post("/join-requests/{requestId}/accept", func(w http.ResponseWriter, req *http.Request) {
|
|
acceptJoinRequestHandler(w, req, db, auditLogger)
|
|
})
|
|
r.With(middleware.Permission(db, auditLogger, permission.OrgManage)).Post("/join-requests/{requestId}/reject", func(w http.ResponseWriter, req *http.Request) {
|
|
rejectJoinRequestHandler(w, req, db, auditLogger)
|
|
})
|
|
r.With(middleware.Permission(db, auditLogger, permission.OrgManage)).Get("/invite-link", func(w http.ResponseWriter, req *http.Request) {
|
|
getInviteLinkHandler(w, req, db)
|
|
})
|
|
r.With(middleware.Permission(db, auditLogger, permission.OrgManage)).Post("/invite-link/regenerate", func(w http.ResponseWriter, req *http.Request) {
|
|
regenerateInviteLinkHandler(w, req, db, auditLogger)
|
|
})
|
|
r.Get("/permissions", func(w http.ResponseWriter, req *http.Request) {
|
|
getPermissionsHandler(w, req, db)
|
|
})
|
|
})
|
|
}) // Close protected routes
|
|
|
|
// Public routes (no auth required)
|
|
r.Route("/public", func(r chi.Router) {
|
|
r.Get("/share/{token}", func(w http.ResponseWriter, req *http.Request) {
|
|
publicFileShareHandler(w, req, db, jwtManager)
|
|
})
|
|
r.Options("/share/{token}", func(w http.ResponseWriter, req *http.Request) {
|
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
w.Header().Set("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS")
|
|
w.Header().Set("Access-Control-Allow-Headers", "Range")
|
|
w.WriteHeader(http.StatusOK)
|
|
})
|
|
r.Get("/share/{token}/download", func(w http.ResponseWriter, req *http.Request) {
|
|
publicFileDownloadHandler(w, req, db, cfg, jwtManager)
|
|
})
|
|
r.Options("/share/{token}/download", func(w http.ResponseWriter, req *http.Request) {
|
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
w.Header().Set("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS")
|
|
w.Header().Set("Access-Control-Allow-Headers", "Range")
|
|
w.WriteHeader(http.StatusOK)
|
|
})
|
|
r.Get("/share/{token}/view", func(w http.ResponseWriter, req *http.Request) {
|
|
publicFileViewHandler(w, req, db, cfg, jwtManager)
|
|
})
|
|
r.Options("/share/{token}/view", func(w http.ResponseWriter, req *http.Request) {
|
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
w.Header().Set("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS")
|
|
w.Header().Set("Access-Control-Allow-Headers", "Range")
|
|
w.WriteHeader(http.StatusOK)
|
|
})
|
|
// Public WOPI routes for shared files
|
|
r.Route("/wopi/share/{token}", func(r chi.Router) {
|
|
r.Get("/", func(w http.ResponseWriter, req *http.Request) {
|
|
publicWopiCheckFileInfoHandler(w, req, db, jwtManager)
|
|
})
|
|
r.Get("/contents", func(w http.ResponseWriter, req *http.Request) {
|
|
publicWopiGetFileHandler(w, req, db, cfg, jwtManager)
|
|
})
|
|
})
|
|
})
|
|
|
|
return r
|
|
}
|
|
|
|
func getOrgByInviteTokenHandler(w http.ResponseWriter, r *http.Request, db *database.DB) {
|
|
token := r.URL.Query().Get("token")
|
|
if token == "" {
|
|
errors.WriteError(w, errors.CodeInvalidArgument, "Token required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
var org database.Organization
|
|
err := db.QueryRowContext(r.Context(), `
|
|
SELECT id, owner_id, name, slug, created_at
|
|
FROM organizations
|
|
WHERE invite_link_token = $1
|
|
`, token).Scan(&org.ID, &org.OwnerID, &org.Name, &org.Slug, &org.CreatedAt)
|
|
if err != nil {
|
|
errors.LogError(r, err, "Invalid invite token")
|
|
errors.WriteError(w, errors.CodeNotFound, "Invalid token", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(org)
|
|
}
|
|
|
|
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 = "/"
|
|
}
|
|
path, err = sanitizePath(path)
|
|
if err != nil {
|
|
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid path", http.StatusBadRequest)
|
|
return
|
|
}
|
|
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
|
|
}
|
|
|
|
// Check if it's a folder - cannot view folders
|
|
if file.Type == "folder" {
|
|
errors.WriteError(w, errors.CodeInvalidArgument, "Cannot view folders", http.StatusBadRequest)
|
|
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
|
|
}
|
|
|
|
// Check if it's a folder - cannot view folders
|
|
if file.Type == "folder" {
|
|
errors.WriteError(w, errors.CodeInvalidArgument, "Cannot view folders", http.StatusBadRequest)
|
|
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")
|
|
|
|
fmt.Printf("[EDITOR] Starting editor session for file=%s user=%s org=%s\n", fileId, userIDStr, orgID.String())
|
|
|
|
// 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")
|
|
|
|
fmt.Printf("[EDITOR] Starting user editor session for file=%s user=%s\n", fileId, userIDStr)
|
|
|
|
// 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))
|
|
|
|
fmt.Printf("[EDITOR] Built user URLs: wopiSrc=%s collaboraUrl=%s\n", wopiSrc, collaboraUrl)
|
|
|
|
// 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)
|
|
}
|
|
|
|
type memberResponse struct {
|
|
UserID string `json:"userId"`
|
|
OrgID string `json:"orgId"`
|
|
Role string `json:"role"`
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
User userInfo `json:"user"`
|
|
}
|
|
|
|
type userInfo struct {
|
|
ID string `json:"id"`
|
|
Username string `json:"username"`
|
|
DisplayName *string `json:"displayName"`
|
|
Email string `json:"email"`
|
|
}
|
|
|
|
func listMembersHandler(w http.ResponseWriter, r *http.Request, db *database.DB) {
|
|
orgID := r.Context().Value(middleware.OrgKey).(uuid.UUID)
|
|
|
|
members, err := db.GetOrgMembersWithUsers(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
|
|
}
|
|
|
|
// Convert to proper response format
|
|
var response []memberResponse
|
|
for _, m := range members {
|
|
response = append(response, memberResponse{
|
|
UserID: m.Membership.UserID.String(),
|
|
OrgID: m.Membership.OrgID.String(),
|
|
Role: m.Membership.Role,
|
|
CreatedAt: m.Membership.CreatedAt,
|
|
User: userInfo{
|
|
ID: m.User.ID.String(),
|
|
Username: m.User.Username,
|
|
DisplayName: func() *string {
|
|
if m.User.DisplayName == "" {
|
|
return nil
|
|
}
|
|
return &m.User.DisplayName
|
|
}(),
|
|
Email: m.User.Email,
|
|
},
|
|
})
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(response)
|
|
}
|
|
|
|
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 removeMemberHandler(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
|
|
}
|
|
|
|
// Check if trying to remove the owner
|
|
membership, err := db.GetUserMembership(r.Context(), userID, orgID)
|
|
if err != nil {
|
|
errors.LogError(r, err, "Failed to get membership")
|
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if membership.Role == "owner" {
|
|
errors.WriteError(w, errors.CodePermissionDenied, "Cannot remove organization owner", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
if err := db.RemoveMember(r.Context(), orgID, userID); err != nil {
|
|
errors.LogError(r, err, "Failed to remove member")
|
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
resource := userID.String()
|
|
auditLogger.Log(r.Context(), audit.Entry{
|
|
OrgID: &orgID,
|
|
Action: "remove_member",
|
|
Resource: &resource,
|
|
Success: true,
|
|
})
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte(`{"status": "ok"}`))
|
|
}
|
|
|
|
func searchUsersHandler(w http.ResponseWriter, r *http.Request, db *database.DB) {
|
|
query := r.URL.Query().Get("q")
|
|
if query == "" {
|
|
errors.WriteError(w, errors.CodeInvalidArgument, "Query parameter 'q' is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
users, err := db.SearchUsersByUsername(r.Context(), query, 10)
|
|
if err != nil {
|
|
errors.LogError(r, err, "Failed to search users")
|
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(users)
|
|
}
|
|
|
|
func createInvitationHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger) {
|
|
orgID := r.Context().Value(middleware.OrgKey).(uuid.UUID)
|
|
userIDStr, ok := middleware.GetUserID(r.Context())
|
|
if !ok {
|
|
errors.WriteError(w, errors.CodeUnauthenticated, "Unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
invitedBy, _ := uuid.Parse(userIDStr)
|
|
|
|
var req struct {
|
|
Username string `json:"username"`
|
|
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 req.Role != "admin" && req.Role != "member" {
|
|
errors.WriteError(w, errors.CodeInvalidArgument, "Role must be 'admin' or 'member'", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
invitation, err := db.CreateInvitation(r.Context(), orgID, invitedBy, req.Username, req.Role)
|
|
if err != nil {
|
|
if strings.Contains(err.Error(), "duplicate key value") {
|
|
errors.WriteError(w, errors.CodeAlreadyExists, "User is already invited or a member", http.StatusConflict)
|
|
return
|
|
}
|
|
errors.LogError(r, err, "Failed to create invitation")
|
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
auditLogger.Log(r.Context(), audit.Entry{
|
|
UserID: &invitedBy,
|
|
OrgID: &orgID,
|
|
Action: "create_invitation",
|
|
Resource: &req.Username,
|
|
Success: true,
|
|
})
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(invitation)
|
|
}
|
|
|
|
func listInvitationsHandler(w http.ResponseWriter, r *http.Request, db *database.DB) {
|
|
orgID := r.Context().Value(middleware.OrgKey).(uuid.UUID)
|
|
|
|
invitations, err := db.GetOrgInvitations(r.Context(), orgID)
|
|
if err != nil {
|
|
errors.LogError(r, err, "Failed to get invitations")
|
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(invitations)
|
|
}
|
|
|
|
func cancelInvitationHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger) {
|
|
orgID := r.Context().Value(middleware.OrgKey).(uuid.UUID)
|
|
invitationIDStr := chi.URLParam(r, "invitationId")
|
|
invitationID, err := uuid.Parse(invitationIDStr)
|
|
if err != nil {
|
|
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid invitation ID", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if err := db.CancelInvitation(r.Context(), invitationID); err != nil {
|
|
errors.LogError(r, err, "Failed to cancel invitation")
|
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
resource := invitationID.String()
|
|
auditLogger.Log(r.Context(), audit.Entry{
|
|
OrgID: &orgID,
|
|
Action: "cancel_invitation",
|
|
Resource: &resource,
|
|
Success: true,
|
|
})
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte(`{"status": "ok"}`))
|
|
}
|
|
|
|
func createJoinRequestHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger) {
|
|
var req struct {
|
|
OrgID string `json:"orgId"`
|
|
InviteToken *string `json:"inviteToken,omitempty"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
errors.WriteError(w, errors.CodeInvalidArgument, "Bad request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
orgID, err := uuid.Parse(req.OrgID)
|
|
if err != nil {
|
|
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid org ID", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
userIDStr, ok := middleware.GetUserID(r.Context())
|
|
if !ok {
|
|
errors.WriteError(w, errors.CodeUnauthenticated, "Unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
userID, _ := uuid.Parse(userIDStr)
|
|
|
|
// If invite token provided, validate it
|
|
if req.InviteToken != nil {
|
|
var token *string
|
|
err := db.QueryRowContext(r.Context(), `
|
|
SELECT invite_link_token FROM organizations WHERE id = $1
|
|
`, orgID).Scan(&token)
|
|
if err != nil || token == nil || *token != *req.InviteToken {
|
|
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid invite token", http.StatusBadRequest)
|
|
return
|
|
}
|
|
}
|
|
|
|
joinRequest, err := db.CreateJoinRequest(r.Context(), orgID, userID, req.InviteToken)
|
|
if err != nil {
|
|
errors.LogError(r, err, "Failed to create join request")
|
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
auditLogger.Log(r.Context(), audit.Entry{
|
|
UserID: &userID,
|
|
OrgID: &orgID,
|
|
Action: "create_join_request",
|
|
Success: true,
|
|
})
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(joinRequest)
|
|
}
|
|
|
|
type joinRequestResponse struct {
|
|
ID string `json:"id"`
|
|
OrgID string `json:"orgId"`
|
|
UserID string `json:"userId"`
|
|
InviteToken *string `json:"inviteToken"`
|
|
RequestedAt time.Time `json:"requestedAt"`
|
|
Status string `json:"status"`
|
|
User userInfo `json:"user"`
|
|
}
|
|
|
|
func listJoinRequestsHandler(w http.ResponseWriter, r *http.Request, db *database.DB) {
|
|
orgID := r.Context().Value(middleware.OrgKey).(uuid.UUID)
|
|
|
|
requests, err := db.GetOrgJoinRequests(r.Context(), orgID)
|
|
if err != nil {
|
|
errors.LogError(r, err, "Failed to get join requests")
|
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Convert to proper response format
|
|
var response []joinRequestResponse
|
|
for _, req := range requests {
|
|
response = append(response, joinRequestResponse{
|
|
ID: req.JoinRequest.ID.String(),
|
|
OrgID: req.JoinRequest.OrgID.String(),
|
|
UserID: req.JoinRequest.UserID.String(),
|
|
InviteToken: req.JoinRequest.InviteToken,
|
|
RequestedAt: req.JoinRequest.RequestedAt,
|
|
Status: req.JoinRequest.Status,
|
|
User: userInfo{
|
|
ID: req.User.ID.String(),
|
|
Username: req.User.Username,
|
|
DisplayName: func() *string {
|
|
if req.User.DisplayName == "" {
|
|
return nil
|
|
}
|
|
return &req.User.DisplayName
|
|
}(),
|
|
Email: req.User.Email,
|
|
},
|
|
})
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(response)
|
|
}
|
|
|
|
func acceptJoinRequestHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger) {
|
|
orgID := r.Context().Value(middleware.OrgKey).(uuid.UUID)
|
|
requestIDStr := chi.URLParam(r, "requestId")
|
|
requestID, err := uuid.Parse(requestIDStr)
|
|
if err != nil {
|
|
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid request 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 req.Role != "admin" && req.Role != "member" {
|
|
errors.WriteError(w, errors.CodeInvalidArgument, "Role must be 'admin' or 'member'", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if err := db.AcceptJoinRequest(r.Context(), requestID, req.Role); err != nil {
|
|
errors.LogError(r, err, "Failed to accept join request")
|
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
resource := requestID.String()
|
|
auditLogger.Log(r.Context(), audit.Entry{
|
|
OrgID: &orgID,
|
|
Action: "accept_join_request",
|
|
Resource: &resource,
|
|
Success: true,
|
|
})
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte(`{"status": "ok"}`))
|
|
}
|
|
|
|
func rejectJoinRequestHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger) {
|
|
orgID := r.Context().Value(middleware.OrgKey).(uuid.UUID)
|
|
requestIDStr := chi.URLParam(r, "requestId")
|
|
requestID, err := uuid.Parse(requestIDStr)
|
|
if err != nil {
|
|
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid request ID", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if err := db.RejectJoinRequest(r.Context(), requestID); err != nil {
|
|
errors.LogError(r, err, "Failed to reject join request")
|
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
resource := requestID.String()
|
|
auditLogger.Log(r.Context(), audit.Entry{
|
|
OrgID: &orgID,
|
|
Action: "reject_join_request",
|
|
Resource: &resource,
|
|
Success: true,
|
|
})
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte(`{"status": "ok"}`))
|
|
}
|
|
|
|
func getInviteLinkHandler(w http.ResponseWriter, r *http.Request, db *database.DB) {
|
|
orgID := r.Context().Value(middleware.OrgKey).(uuid.UUID)
|
|
|
|
token, err := db.GetInviteLink(r.Context(), orgID)
|
|
if err != nil {
|
|
errors.LogError(r, err, "Failed to get invite link")
|
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
response := struct {
|
|
InviteLink *string `json:"inviteLink"`
|
|
}{
|
|
InviteLink: token,
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(response)
|
|
}
|
|
|
|
func regenerateInviteLinkHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger) {
|
|
orgID := r.Context().Value(middleware.OrgKey).(uuid.UUID)
|
|
|
|
newToken, err := db.RegenerateInviteLink(r.Context(), orgID)
|
|
if err != nil {
|
|
errors.LogError(r, err, "Failed to regenerate invite link")
|
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
auditLogger.Log(r.Context(), audit.Entry{
|
|
OrgID: &orgID,
|
|
Action: "regenerate_invite_link",
|
|
Success: true,
|
|
})
|
|
|
|
response := struct {
|
|
InviteLink string `json:"inviteLink"`
|
|
}{
|
|
InviteLink: *newToken,
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(response)
|
|
}
|
|
|
|
func getPermissionsHandler(w http.ResponseWriter, r *http.Request, db *database.DB) {
|
|
orgID := r.Context().Value(middleware.OrgKey).(uuid.UUID)
|
|
userIDStr, ok := middleware.GetUserID(r.Context())
|
|
if !ok {
|
|
errors.WriteError(w, errors.CodeUnauthenticated, "Unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
userID, _ := uuid.Parse(userIDStr)
|
|
|
|
// Check each permission
|
|
canRead, _ := permission.HasPermission(r.Context(), db, userID, orgID, permission.FileRead)
|
|
canWrite, _ := permission.HasPermission(r.Context(), db, userID, orgID, permission.FileWrite)
|
|
canEdit, _ := permission.HasPermission(r.Context(), db, userID, orgID, permission.DocumentEdit)
|
|
canAdmin, _ := permission.HasPermission(r.Context(), db, userID, orgID, permission.OrgManage)
|
|
|
|
response := struct {
|
|
CanRead bool `json:"canRead"`
|
|
CanWrite bool `json:"canWrite"`
|
|
CanShare bool `json:"canShare"`
|
|
CanAdmin bool `json:"canAdmin"`
|
|
CanAnnotate bool `json:"canAnnotate"`
|
|
CanEdit bool `json:"canEdit"`
|
|
}{
|
|
CanRead: canRead,
|
|
CanWrite: canWrite,
|
|
CanShare: canRead, // Share is tied to read for now
|
|
CanAdmin: canAdmin,
|
|
CanAnnotate: canEdit, // Annotate is tied to edit
|
|
CanEdit: canEdit,
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(response)
|
|
}
|
|
|
|
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")
|
|
// Map internal errors to more specific API error responses so the client
|
|
// can indicate which field was incorrect.
|
|
errMsg := err.Error()
|
|
if strings.Contains(errMsg, "invalid password") {
|
|
errors.WriteError(w, errors.CodeInvalidPassword, "Incorrect password", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
if strings.Contains(errMsg, "user not found") {
|
|
errors.WriteError(w, errors.CodeInvalidCredentials, "Invalid credentials", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
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 = "/"
|
|
}
|
|
parentPath, err = sanitizePath(parentPath)
|
|
if err != nil {
|
|
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid path", http.StatusBadRequest)
|
|
return
|
|
}
|
|
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
|
|
}
|
|
|
|
var err error
|
|
req.Path, err = sanitizePath(req.Path)
|
|
if err != nil {
|
|
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid path", 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
|
|
}
|
|
|
|
var err error
|
|
req.SourcePath, err = sanitizePath(req.SourcePath)
|
|
if err != nil {
|
|
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid source path", http.StatusBadRequest)
|
|
return
|
|
}
|
|
req.TargetPath, err = sanitizePath(req.TargetPath)
|
|
if err != nil {
|
|
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid target path", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Get source file directly by path
|
|
sourceFile, err := db.GetOrgFileByPath(r.Context(), orgID, userID, req.SourcePath)
|
|
if err != nil {
|
|
if err == sql.ErrNoRows {
|
|
errors.WriteError(w, errors.CodeInvalidArgument, "Source file not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
errors.LogError(r, err, "Failed to get source file")
|
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Prevent moving a folder into itself or its own subfolders
|
|
if sourceFile.Type == "folder" {
|
|
// Check if trying to move into itself
|
|
if req.SourcePath == req.TargetPath {
|
|
errors.WriteError(w, errors.CodeInvalidArgument, "Cannot move a folder into itself", http.StatusBadRequest)
|
|
return
|
|
}
|
|
// Check if target is a subfolder of source
|
|
if strings.HasPrefix(req.TargetPath, req.SourcePath+"/") {
|
|
errors.WriteError(w, errors.CodeInvalidArgument, "Cannot move a folder into its own subfolder", http.StatusBadRequest)
|
|
return
|
|
}
|
|
}
|
|
|
|
// Determine new file name - check if target is a folder
|
|
var newPath string
|
|
targetFile, err := db.GetOrgFileByPath(r.Context(), orgID, userID, req.TargetPath)
|
|
if err != nil && err != sql.ErrNoRows {
|
|
errors.LogError(r, err, "Failed to get target file")
|
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
// If err == sql.ErrNoRows, targetFile is nil
|
|
|
|
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
|
|
}
|
|
|
|
// Determine new filename from the path
|
|
newName := path.Base(newPath)
|
|
|
|
// 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")
|
|
errors.WriteError(w, errors.CodeInternal, "Storage error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
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")
|
|
errors.WriteError(w, errors.CodeInternal, "Failed to move file in storage", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Update file record path and name in-place (preserves file ID for WOPI sessions)
|
|
if err := db.UpdateFilePath(r.Context(), sourceFile.ID, newName, newPath); err != nil {
|
|
errors.LogError(r, err, "Failed to update file path")
|
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
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 = "/"
|
|
}
|
|
parentPath, err = sanitizePath(parentPath)
|
|
if err != nil {
|
|
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid path", http.StatusBadRequest)
|
|
return
|
|
}
|
|
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
|
|
}
|
|
|
|
var err error
|
|
req.SourcePath, err = sanitizePath(req.SourcePath)
|
|
if err != nil {
|
|
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid source path", http.StatusBadRequest)
|
|
return
|
|
}
|
|
req.TargetPath, err = sanitizePath(req.TargetPath)
|
|
if err != nil {
|
|
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid target path", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Get source file details before moving
|
|
sourceFile, err := db.GetUserFileByPath(r.Context(), userID, req.SourcePath)
|
|
if err != nil {
|
|
if err == sql.ErrNoRows {
|
|
errors.WriteError(w, errors.CodeInvalidArgument, "Source file not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
errors.LogError(r, err, "Failed to get source file")
|
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Prevent moving a folder into itself or its own subfolders
|
|
if sourceFile.Type == "folder" {
|
|
// Check if trying to move into itself
|
|
if req.SourcePath == req.TargetPath {
|
|
errors.WriteError(w, errors.CodeInvalidArgument, "Cannot move a folder into itself", http.StatusBadRequest)
|
|
return
|
|
}
|
|
// Check if target is a subfolder of source
|
|
if strings.HasPrefix(req.TargetPath, req.SourcePath+"/") {
|
|
errors.WriteError(w, errors.CodeInvalidArgument, "Cannot move a folder into its own subfolder", http.StatusBadRequest)
|
|
return
|
|
}
|
|
}
|
|
|
|
// Determine new file name - check if target is a folder
|
|
var newPath string
|
|
targetFile, err := db.GetUserFileByPath(r.Context(), userID, req.TargetPath)
|
|
if err != nil && err != sql.ErrNoRows {
|
|
errors.LogError(r, err, "Failed to get target file")
|
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
// If err == sql.ErrNoRows, targetFile is nil
|
|
|
|
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
|
|
}
|
|
|
|
// Determine new filename from the path
|
|
newName := path.Base(newPath)
|
|
|
|
// 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")
|
|
errors.WriteError(w, errors.CodeInternal, "Storage error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// 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")
|
|
errors.WriteError(w, errors.CodeInternal, "Failed to move file in storage", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Update file record path and name in-place (preserves file ID for WOPI sessions)
|
|
if err := db.UpdateFilePath(r.Context(), sourceFile.ID, newName, newPath); err != nil {
|
|
errors.LogError(r, err, "Failed to update file path")
|
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
var err error
|
|
req.Path, err = sanitizePath(req.Path)
|
|
if err != nil {
|
|
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid path", 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 - use our getMimeType function
|
|
// which supports all common video/audio/image formats
|
|
contentType := getMimeType(fileName)
|
|
|
|
w.Header().Set("Access-Control-Allow-Origin", "https://www.b0esche.cloud")
|
|
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
|
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Range")
|
|
w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=\"%s\"", fileName))
|
|
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 - use our getMimeType function
|
|
// which supports all common video/audio/image formats
|
|
contentType := getMimeType(fileName)
|
|
|
|
w.Header().Set("Access-Control-Allow-Origin", "https://www.b0esche.cloud")
|
|
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
|
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Range")
|
|
w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=\"%s\"", fileName))
|
|
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 {
|
|
// Documents
|
|
case strings.HasSuffix(lower, ".pdf"):
|
|
return "application/pdf"
|
|
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"
|
|
case strings.HasSuffix(lower, ".odt"):
|
|
return "application/vnd.oasis.opendocument.text"
|
|
case strings.HasSuffix(lower, ".ods"):
|
|
return "application/vnd.oasis.opendocument.spreadsheet"
|
|
case strings.HasSuffix(lower, ".odp"):
|
|
return "application/vnd.oasis.opendocument.presentation"
|
|
// Images
|
|
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, ".bmp"):
|
|
return "image/bmp"
|
|
case strings.HasSuffix(lower, ".ico"):
|
|
return "image/x-icon"
|
|
// Video formats
|
|
case strings.HasSuffix(lower, ".mp4"), strings.HasSuffix(lower, ".m4v"):
|
|
return "video/mp4"
|
|
case strings.HasSuffix(lower, ".webm"):
|
|
return "video/webm"
|
|
case strings.HasSuffix(lower, ".ogv"):
|
|
return "video/ogg"
|
|
case strings.HasSuffix(lower, ".mov"):
|
|
return "video/quicktime"
|
|
case strings.HasSuffix(lower, ".avi"):
|
|
return "video/x-msvideo"
|
|
case strings.HasSuffix(lower, ".mkv"):
|
|
return "video/x-matroska"
|
|
case strings.HasSuffix(lower, ".wmv"):
|
|
return "video/x-ms-wmv"
|
|
case strings.HasSuffix(lower, ".flv"):
|
|
return "video/x-flv"
|
|
case strings.HasSuffix(lower, ".3gp"):
|
|
return "video/3gpp"
|
|
case strings.HasSuffix(lower, ".ts"):
|
|
return "video/mp2t"
|
|
case strings.HasSuffix(lower, ".mpg"), strings.HasSuffix(lower, ".mpeg"):
|
|
return "video/mpeg"
|
|
// Audio formats
|
|
case strings.HasSuffix(lower, ".mp3"):
|
|
return "audio/mpeg"
|
|
case strings.HasSuffix(lower, ".wav"):
|
|
return "audio/wav"
|
|
case strings.HasSuffix(lower, ".ogg"), strings.HasSuffix(lower, ".oga"):
|
|
return "audio/ogg"
|
|
case strings.HasSuffix(lower, ".m4a"), strings.HasSuffix(lower, ".aac"):
|
|
return "audio/aac"
|
|
case strings.HasSuffix(lower, ".flac"):
|
|
return "audio/flac"
|
|
case strings.HasSuffix(lower, ".wma"):
|
|
return "audio/x-ms-wma"
|
|
// Text/code
|
|
case strings.HasSuffix(lower, ".txt"):
|
|
return "text/plain"
|
|
case strings.HasSuffix(lower, ".html"), strings.HasSuffix(lower, ".htm"):
|
|
return "text/html"
|
|
case strings.HasSuffix(lower, ".css"):
|
|
return "text/css"
|
|
case strings.HasSuffix(lower, ".js"):
|
|
return "application/javascript"
|
|
case strings.HasSuffix(lower, ".json"):
|
|
return "application/json"
|
|
case strings.HasSuffix(lower, ".xml"):
|
|
return "application/xml"
|
|
case strings.HasSuffix(lower, ".csv"):
|
|
return "text/csv"
|
|
// Archives
|
|
case strings.HasSuffix(lower, ".zip"):
|
|
return "application/zip"
|
|
case strings.HasSuffix(lower, ".rar"):
|
|
return "application/vnd.rar"
|
|
case strings.HasSuffix(lower, ".7z"):
|
|
return "application/x-7z-compressed"
|
|
case strings.HasSuffix(lower, ".tar"):
|
|
return "application/x-tar"
|
|
case strings.HasSuffix(lower, ".gz"):
|
|
return "application/gzip"
|
|
default:
|
|
return "application/octet-stream"
|
|
}
|
|
}
|
|
|
|
// File share handlers
|
|
|
|
func getFileShareLinkHandler(w http.ResponseWriter, r *http.Request, db *database.DB) {
|
|
userIDStr, _ := middleware.GetUserID(r.Context())
|
|
userID, _ := uuid.Parse(userIDStr)
|
|
orgID := r.Context().Value(middleware.OrgKey).(uuid.UUID)
|
|
fileId := chi.URLParam(r, "fileId")
|
|
|
|
fileUUID, err := uuid.Parse(fileId)
|
|
if err != nil {
|
|
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid file ID", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Check if file exists and belongs to org or is owned by user (for personal files)
|
|
file, err := db.GetFileByID(r.Context(), fileUUID)
|
|
if err != nil {
|
|
errors.LogError(r, err, "Failed to get file")
|
|
errors.WriteError(w, errors.CodeNotFound, "File not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
if file.OrgID != nil && *file.OrgID != orgID {
|
|
errors.WriteError(w, errors.CodeNotFound, "File not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
if file.OrgID == nil && file.UserID != nil && *file.UserID != userID {
|
|
errors.WriteError(w, errors.CodeNotFound, "File not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
link, err := db.GetFileShareLinkByFileID(r.Context(), fileUUID)
|
|
if err != nil {
|
|
if err == sql.ErrNoRows {
|
|
// No share link exists
|
|
errors.WriteError(w, errors.CodeNotFound, "Share link not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
errors.LogError(r, err, "Failed to get share link")
|
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Build full URL
|
|
scheme := "https"
|
|
if proto := r.Header.Get("X-Forwarded-Proto"); proto != "" {
|
|
scheme = proto
|
|
} else if r.TLS == nil {
|
|
scheme = "http"
|
|
}
|
|
host := "www.b0esche.cloud"
|
|
fullURL := fmt.Sprintf("%s://%s/share/%s", scheme, host, link.Token)
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"shareUrl": fullURL,
|
|
"token": link.Token,
|
|
})
|
|
}
|
|
|
|
func createFileShareLinkHandler(w http.ResponseWriter, r *http.Request, db *database.DB) {
|
|
userIDStr, _ := middleware.GetUserID(r.Context())
|
|
userID, _ := uuid.Parse(userIDStr)
|
|
orgID := r.Context().Value(middleware.OrgKey).(uuid.UUID)
|
|
fileId := chi.URLParam(r, "fileId")
|
|
|
|
fileUUID, err := uuid.Parse(fileId)
|
|
if err != nil {
|
|
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid file ID", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Check if file exists and belongs to org or is owned by user (for personal files)
|
|
file, err := db.GetFileByID(r.Context(), fileUUID)
|
|
if err != nil {
|
|
errors.LogError(r, err, "Failed to get file")
|
|
errors.WriteError(w, errors.CodeNotFound, "File not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
if file.OrgID != nil && *file.OrgID != orgID {
|
|
errors.WriteError(w, errors.CodeNotFound, "File not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
if file.OrgID == nil && file.UserID != nil && *file.UserID != userID {
|
|
errors.WriteError(w, errors.CodeNotFound, "File not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
// Revoke existing link if any
|
|
db.RevokeFileShareLink(r.Context(), fileUUID) // Ignore error
|
|
|
|
// Generate token
|
|
token, err := storage.GenerateSecurePassword(48)
|
|
if err != nil {
|
|
errors.LogError(r, err, "Failed to generate token")
|
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
link, err := db.CreateFileShareLink(r.Context(), token, fileUUID, &orgID, userID)
|
|
if err != nil {
|
|
errors.LogError(r, err, "Failed to create share link")
|
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
log.Printf("Share link created: user_id=%s, file_id=%s, org_id=%v", userID, fileUUID, link.OrgID)
|
|
|
|
// Build full URL
|
|
scheme := "https"
|
|
if proto := r.Header.Get("X-Forwarded-Proto"); proto != "" {
|
|
scheme = proto
|
|
} else if r.TLS == nil {
|
|
scheme = "http"
|
|
}
|
|
host := "www.b0esche.cloud"
|
|
fullURL := fmt.Sprintf("%s://%s/share/%s", scheme, host, link.Token)
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"shareUrl": fullURL,
|
|
"token": link.Token,
|
|
})
|
|
}
|
|
|
|
func revokeFileShareLinkHandler(w http.ResponseWriter, r *http.Request, db *database.DB) {
|
|
userIDStr, _ := middleware.GetUserID(r.Context())
|
|
userID, _ := uuid.Parse(userIDStr)
|
|
orgID := r.Context().Value(middleware.OrgKey).(uuid.UUID)
|
|
fileId := chi.URLParam(r, "fileId")
|
|
|
|
fileUUID, err := uuid.Parse(fileId)
|
|
if err != nil {
|
|
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid file ID", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Check if file exists and belongs to org or is owned by user (for personal files)
|
|
file, err := db.GetFileByID(r.Context(), fileUUID)
|
|
if err != nil {
|
|
errors.LogError(r, err, "Failed to get file")
|
|
errors.WriteError(w, errors.CodeNotFound, "File not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
if file.OrgID != nil && *file.OrgID != orgID {
|
|
errors.WriteError(w, errors.CodeNotFound, "File not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
if file.OrgID == nil && file.UserID != nil && *file.UserID != userID {
|
|
errors.WriteError(w, errors.CodeNotFound, "File not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
err = db.RevokeFileShareLink(r.Context(), fileUUID)
|
|
if err != nil {
|
|
errors.LogError(r, err, "Failed to revoke share link")
|
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func publicFileShareHandler(w http.ResponseWriter, r *http.Request, db *database.DB, jwtManager *jwt.Manager) {
|
|
token := chi.URLParam(r, "token")
|
|
if token == "" {
|
|
errors.WriteError(w, errors.CodeInvalidArgument, "Token required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
link, err := db.GetFileShareLinkByToken(r.Context(), token)
|
|
if err != nil {
|
|
if err == sql.ErrNoRows {
|
|
errors.WriteError(w, errors.CodeNotFound, "Link not found or expired", http.StatusNotFound)
|
|
return
|
|
}
|
|
errors.LogError(r, err, "Failed to get share link")
|
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Get file metadata
|
|
file, err := db.GetFileByID(r.Context(), link.FileID)
|
|
if err != nil {
|
|
errors.LogError(r, err, "Failed to get file")
|
|
errors.WriteError(w, errors.CodeNotFound, "File not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
// Generate a short-lived token for download (1 hour)
|
|
var orgIDs []string
|
|
if link.OrgID != nil {
|
|
orgIDs = []string{link.OrgID.String()}
|
|
}
|
|
viewerToken, err := jwtManager.GenerateWithDuration("", orgIDs, "", time.Hour)
|
|
if err != nil {
|
|
errors.LogError(r, err, "Failed to generate viewer token")
|
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Build URLs
|
|
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"
|
|
}
|
|
downloadPath := fmt.Sprintf("%s://%s/public/share/%s/download?token=%s", scheme, host, token, url.QueryEscape(viewerToken))
|
|
viewPath := fmt.Sprintf("%s://%s/public/share/%s/view?token=%s", scheme, host, token, url.QueryEscape(viewerToken))
|
|
|
|
// Check if user is authenticated and has access to the file
|
|
var internalOrgId *string
|
|
var internalFileId *string
|
|
authHeader := r.Header.Get("Authorization")
|
|
if authHeader != "" && strings.HasPrefix(authHeader, "Bearer ") {
|
|
jwtToken := strings.TrimPrefix(authHeader, "Bearer ")
|
|
claims, err := jwtManager.Validate(jwtToken)
|
|
if err == nil {
|
|
userID, err := uuid.Parse(claims.Subject)
|
|
if err == nil {
|
|
// Check if user has access
|
|
if file.UserID != nil && *file.UserID == userID {
|
|
// Personal file, user is owner
|
|
fileIDStr := file.ID.String()
|
|
internalOrgId = nil // personal
|
|
internalFileId = &fileIDStr
|
|
} else if link.OrgID != nil {
|
|
// Org file, check if user is in org
|
|
for _, orgIDStr := range claims.OrgIDs {
|
|
orgID, err := uuid.Parse(orgIDStr)
|
|
if err == nil && orgID == *link.OrgID {
|
|
fileIDStr := file.ID.String()
|
|
internalOrgId = &orgIDStr
|
|
internalFileId = &fileIDStr
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Determine file type
|
|
isPdf := strings.HasSuffix(strings.ToLower(file.Name), ".pdf")
|
|
mimeType := getMimeType(file.Name)
|
|
|
|
viewerSession := map[string]interface{}{
|
|
"fileName": file.Name,
|
|
"fileSize": file.Size,
|
|
"downloadUrl": downloadPath,
|
|
"token": viewerToken,
|
|
"capabilities": map[string]interface{}{
|
|
"canEdit": false,
|
|
"canAnnotate": false,
|
|
"isPdf": isPdf,
|
|
"mimeType": mimeType,
|
|
},
|
|
}
|
|
|
|
// Set view URL for PDFs, videos, audio, and documents (for inline viewing)
|
|
if isPdf || strings.HasPrefix(mimeType, "video/") || strings.HasPrefix(mimeType, "audio/") || strings.HasPrefix(mimeType, "image/") {
|
|
viewerSession["viewUrl"] = viewPath
|
|
} else if strings.Contains(mimeType, "document") || strings.Contains(mimeType, "word") || strings.Contains(mimeType, "spreadsheet") || strings.Contains(mimeType, "presentation") {
|
|
// Use Collabora for document viewing
|
|
wopiSrc := fmt.Sprintf("%s://go.b0esche.cloud/public/wopi/share/%s", scheme, token)
|
|
editorUrl := getCollaboraEditorURL("https://of.b0esche.cloud")
|
|
collaboraUrl := fmt.Sprintf("%s?WOPISrc=%s", editorUrl, url.QueryEscape(wopiSrc))
|
|
viewerSession["viewUrl"] = collaboraUrl
|
|
}
|
|
|
|
// Add internal access info if user has access
|
|
if internalFileId != nil {
|
|
viewerSession["fileId"] = *internalFileId
|
|
if internalOrgId != nil {
|
|
viewerSession["orgId"] = *internalOrgId
|
|
}
|
|
}
|
|
|
|
// Add CORS headers for public access
|
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
w.Header().Set("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS")
|
|
w.Header().Set("Access-Control-Allow-Headers", "Range")
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(viewerSession)
|
|
}
|
|
|
|
func publicFileDownloadHandler(w http.ResponseWriter, r *http.Request, db *database.DB, cfg *config.Config, jwtManager *jwt.Manager) {
|
|
token := chi.URLParam(r, "token")
|
|
if token == "" {
|
|
errors.WriteError(w, errors.CodeInvalidArgument, "Token required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
viewerToken := r.URL.Query().Get("token")
|
|
if viewerToken == "" {
|
|
errors.WriteError(w, errors.CodeInvalidArgument, "Viewer token required", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
link, err := db.GetFileShareLinkByToken(r.Context(), token)
|
|
if err != nil {
|
|
if err == sql.ErrNoRows {
|
|
errors.WriteError(w, errors.CodeNotFound, "Link not found or expired", http.StatusNotFound)
|
|
return
|
|
}
|
|
errors.LogError(r, err, "Failed to get share link")
|
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Verify viewer token (contains org ID for org files, empty for personal)
|
|
claims, err := jwtManager.Validate(viewerToken)
|
|
if err != nil {
|
|
errors.LogError(r, err, "Invalid viewer token")
|
|
errors.WriteError(w, errors.CodeUnauthenticated, "Invalid token", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
if link.OrgID == nil {
|
|
if len(claims.OrgIDs) != 0 {
|
|
errors.WriteError(w, errors.CodeUnauthenticated, "Invalid token", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
} else {
|
|
if len(claims.OrgIDs) == 0 {
|
|
errors.WriteError(w, errors.CodeUnauthenticated, "Invalid token", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
orgID, err := uuid.Parse(claims.OrgIDs[0])
|
|
if err != nil {
|
|
errors.WriteError(w, errors.CodeUnauthenticated, "Invalid token", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
if *link.OrgID != orgID {
|
|
errors.WriteError(w, errors.CodeUnauthenticated, "Invalid token", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
}
|
|
|
|
// Get file metadata
|
|
file, err := db.GetFileByID(r.Context(), link.FileID)
|
|
if err != nil {
|
|
errors.LogError(r, err, "Failed to get file")
|
|
errors.WriteError(w, errors.CodeNotFound, "File not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
if file.UserID == nil {
|
|
errors.WriteError(w, errors.CodeNotFound, "File not accessible", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
// Check if it's a folder - if so, create a zip download
|
|
if file.Type == "folder" {
|
|
// Get all files under this folder path
|
|
var folderFiles []database.File
|
|
var err error
|
|
|
|
if link.OrgID != nil {
|
|
// Org folder - need user ID from context or file owner
|
|
folderFiles, err = db.GetAllOrgFilesUnderPath(r.Context(), *link.OrgID, *file.UserID, file.Path)
|
|
} else {
|
|
// User folder
|
|
folderFiles, err = db.GetAllUserFilesUnderPath(r.Context(), *file.UserID, file.Path)
|
|
}
|
|
|
|
if err != nil {
|
|
errors.LogError(r, err, "Failed to get folder contents")
|
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Filter out sub-folders (only include files)
|
|
var filesToZip []database.File
|
|
for _, f := range folderFiles {
|
|
if f.Type == "file" {
|
|
filesToZip = append(filesToZip, f)
|
|
}
|
|
}
|
|
|
|
if len(filesToZip) == 0 {
|
|
errors.WriteError(w, errors.CodeInvalidArgument, "Folder is empty", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Create zip file in memory
|
|
var zipBuffer bytes.Buffer
|
|
zipWriter := zip.NewWriter(&zipBuffer)
|
|
|
|
// Get WebDAV client for the file's owner
|
|
client, err := getUserWebDAVClient(r.Context(), db, *file.UserID, cfg.NextcloudURL, cfg.NextcloudUser, cfg.NextcloudPass)
|
|
if err != nil {
|
|
errors.LogError(r, err, "Failed to get WebDAV client")
|
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Add each file to the zip
|
|
for _, fileToZip := range filesToZip {
|
|
// Download file from storage
|
|
downloadCtx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
|
resp, err := client.Download(downloadCtx, fileToZip.Path, "")
|
|
cancel()
|
|
if err != nil {
|
|
errors.LogError(r, err, fmt.Sprintf("Failed to download file %s for zip", fileToZip.Path))
|
|
continue // Skip this file, continue with others
|
|
}
|
|
|
|
// Read file content
|
|
fileData, err := io.ReadAll(resp.Body)
|
|
resp.Body.Close()
|
|
if err != nil {
|
|
errors.LogError(r, err, fmt.Sprintf("Failed to read file %s for zip", fileToZip.Path))
|
|
continue
|
|
}
|
|
|
|
// Create zip entry - use relative path within the folder
|
|
relativePath := strings.TrimPrefix(fileToZip.Path, file.Path)
|
|
if after, ok := strings.CutPrefix(relativePath, "/"); ok {
|
|
relativePath = after
|
|
}
|
|
|
|
zipFile, err := zipWriter.Create(relativePath)
|
|
if err != nil {
|
|
errors.LogError(r, err, fmt.Sprintf("Failed to create zip entry for %s", relativePath))
|
|
continue
|
|
}
|
|
|
|
// Write file data to zip
|
|
_, err = zipFile.Write(fileData)
|
|
if err != nil {
|
|
errors.LogError(r, err, fmt.Sprintf("Failed to write file %s to zip", relativePath))
|
|
continue
|
|
}
|
|
}
|
|
|
|
// Close zip writer
|
|
err = zipWriter.Close()
|
|
if err != nil {
|
|
errors.LogError(r, err, "Failed to close zip writer")
|
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Add CORS headers for public access
|
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
w.Header().Set("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS")
|
|
w.Header().Set("Access-Control-Allow-Headers", "Range")
|
|
|
|
// Set headers for zip download
|
|
w.Header().Set("Content-Type", "application/zip")
|
|
zipFilename := fmt.Sprintf("%s.zip", file.Name)
|
|
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", zipFilename))
|
|
w.Header().Set("Content-Length", fmt.Sprintf("%d", zipBuffer.Len()))
|
|
|
|
// Stream the zip file
|
|
w.Write(zipBuffer.Bytes())
|
|
return
|
|
}
|
|
|
|
// Determine MIME type
|
|
mimeType := getMimeType(file.Name)
|
|
|
|
// Get WebDAV client for the file's owner
|
|
client, err := getUserWebDAVClient(r.Context(), db, *file.UserID, cfg.NextcloudURL, cfg.NextcloudUser, cfg.NextcloudPass)
|
|
if err != nil {
|
|
errors.LogError(r, err, "Failed to get WebDAV client")
|
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Create context with longer timeout for file downloads
|
|
downloadCtx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
|
|
defer cancel()
|
|
|
|
// Stream file
|
|
resp, err := client.Download(downloadCtx, file.Path, "")
|
|
if err != nil {
|
|
errors.LogError(r, err, "Failed to download file")
|
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
// Add CORS headers for public access
|
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
w.Header().Set("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS")
|
|
w.Header().Set("Access-Control-Allow-Headers", "Range")
|
|
|
|
// Copy headers from Nextcloud response, but skip Content-Type to ensure correct MIME type
|
|
for k, v := range resp.Header {
|
|
if k != "Content-Type" {
|
|
w.Header()[k] = v
|
|
}
|
|
}
|
|
|
|
// Set correct Content-Type based on file extension
|
|
w.Header().Set("Content-Type", mimeType)
|
|
|
|
// Ensure download behavior
|
|
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", file.Name))
|
|
|
|
// Copy body
|
|
io.Copy(w, resp.Body)
|
|
}
|
|
|
|
func publicFileViewHandler(w http.ResponseWriter, r *http.Request, db *database.DB, cfg *config.Config, jwtManager *jwt.Manager) {
|
|
token := chi.URLParam(r, "token")
|
|
if token == "" {
|
|
errors.WriteError(w, errors.CodeInvalidArgument, "Token required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
viewerToken := r.URL.Query().Get("token")
|
|
if viewerToken == "" {
|
|
errors.WriteError(w, errors.CodeInvalidArgument, "Viewer token required", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
link, err := db.GetFileShareLinkByToken(r.Context(), token)
|
|
if err != nil {
|
|
if err == sql.ErrNoRows {
|
|
errors.WriteError(w, errors.CodeNotFound, "Link not found or expired", http.StatusNotFound)
|
|
return
|
|
}
|
|
errors.LogError(r, err, "Failed to get share link")
|
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Verify viewer token (contains org ID for org files, empty for personal)
|
|
claims, err := jwtManager.Validate(viewerToken)
|
|
if err != nil {
|
|
errors.LogError(r, err, "Invalid viewer token")
|
|
errors.WriteError(w, errors.CodeUnauthenticated, "Invalid token", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
if link.OrgID == nil {
|
|
if len(claims.OrgIDs) != 0 {
|
|
errors.WriteError(w, errors.CodeUnauthenticated, "Invalid token", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
} else {
|
|
if len(claims.OrgIDs) == 0 {
|
|
errors.WriteError(w, errors.CodeUnauthenticated, "Invalid token", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
orgID, err := uuid.Parse(claims.OrgIDs[0])
|
|
if err != nil {
|
|
errors.WriteError(w, errors.CodeUnauthenticated, "Invalid token", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
if *link.OrgID != orgID {
|
|
errors.WriteError(w, errors.CodeUnauthenticated, "Invalid token", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
}
|
|
|
|
// Get file metadata
|
|
file, err := db.GetFileByID(r.Context(), link.FileID)
|
|
if err != nil {
|
|
errors.LogError(r, err, "Failed to get file")
|
|
errors.WriteError(w, errors.CodeNotFound, "File not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
if file.UserID == nil {
|
|
errors.WriteError(w, errors.CodeNotFound, "File not accessible", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
// Check if it's a folder - cannot view folders directly
|
|
if file.Type == "folder" {
|
|
errors.WriteError(w, errors.CodeInvalidArgument, "Cannot view folders", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Determine MIME type
|
|
mimeType := getMimeType(file.Name)
|
|
|
|
// Get WebDAV client for the file's owner
|
|
client, err := getUserWebDAVClient(r.Context(), db, *file.UserID, cfg.NextcloudURL, cfg.NextcloudUser, cfg.NextcloudPass)
|
|
if err != nil {
|
|
errors.LogError(r, err, "Failed to get WebDAV client")
|
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Create context with longer timeout for file downloads
|
|
downloadCtx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
|
|
defer cancel()
|
|
|
|
// Stream file
|
|
resp, err := client.Download(downloadCtx, file.Path, r.Header.Get("Range"))
|
|
if err != nil {
|
|
errors.LogError(r, err, "Failed to download file")
|
|
errors.WriteError(w, errors.CodeInternal, "File temporarily unavailable. Please try again later.", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
// Add CORS headers for public access
|
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
w.Header().Set("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS")
|
|
w.Header().Set("Access-Control-Allow-Headers", "Range")
|
|
|
|
// Copy headers from Nextcloud response, but skip Content-Type to ensure correct MIME type
|
|
for k, v := range resp.Header {
|
|
if k != "Content-Type" {
|
|
w.Header()[k] = v
|
|
}
|
|
}
|
|
|
|
// Set correct Content-Type based on file extension
|
|
w.Header().Set("Content-Type", mimeType)
|
|
|
|
// Ensure inline viewing behavior (no Content-Disposition attachment)
|
|
w.Header().Del("Content-Disposition")
|
|
w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=\"%s\"", file.Name))
|
|
|
|
// Set status code (200 or 206 for partial)
|
|
w.WriteHeader(resp.StatusCode)
|
|
|
|
// Copy body
|
|
io.Copy(w, resp.Body)
|
|
}
|
|
|
|
func getUserFileShareLinkHandler(w http.ResponseWriter, r *http.Request, db *database.DB) {
|
|
userIDStr, _ := middleware.GetUserID(r.Context())
|
|
userID, _ := uuid.Parse(userIDStr)
|
|
fileId := chi.URLParam(r, "fileId")
|
|
|
|
fileUUID, err := uuid.Parse(fileId)
|
|
if err != nil {
|
|
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid file ID", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Check if file exists and belongs to user
|
|
file, err := db.GetFileByID(r.Context(), fileUUID)
|
|
if err != nil {
|
|
errors.LogError(r, err, "Failed to get file")
|
|
errors.WriteError(w, errors.CodeNotFound, "File not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
if file.UserID == nil || *file.UserID != userID {
|
|
errors.WriteError(w, errors.CodeNotFound, "File not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
link, err := db.GetFileShareLinkByFileID(r.Context(), fileUUID)
|
|
if err != nil {
|
|
if err == sql.ErrNoRows {
|
|
// No share link exists
|
|
errors.WriteError(w, errors.CodeNotFound, "Share link not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
errors.LogError(r, err, "Failed to get share link")
|
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Build full URL
|
|
scheme := "https"
|
|
if proto := r.Header.Get("X-Forwarded-Proto"); proto != "" {
|
|
scheme = proto
|
|
} else if r.TLS == nil {
|
|
scheme = "http"
|
|
}
|
|
host := "www.b0esche.cloud"
|
|
fullURL := fmt.Sprintf("%s://%s/share/%s", scheme, host, link.Token)
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"shareUrl": fullURL,
|
|
"token": link.Token,
|
|
})
|
|
}
|
|
|
|
func createUserFileShareLinkHandler(w http.ResponseWriter, r *http.Request, db *database.DB) {
|
|
userIDStr, _ := middleware.GetUserID(r.Context())
|
|
userID, _ := uuid.Parse(userIDStr)
|
|
fileId := chi.URLParam(r, "fileId")
|
|
|
|
fileUUID, err := uuid.Parse(fileId)
|
|
if err != nil {
|
|
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid file ID", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Check if file exists and belongs to user
|
|
file, err := db.GetFileByID(r.Context(), fileUUID)
|
|
if err != nil {
|
|
errors.LogError(r, err, "Failed to get file")
|
|
errors.WriteError(w, errors.CodeNotFound, "File not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
if file.UserID == nil || *file.UserID != userID {
|
|
errors.WriteError(w, errors.CodeNotFound, "File not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
// Revoke existing link if any
|
|
db.RevokeFileShareLink(r.Context(), fileUUID) // Ignore error
|
|
|
|
// Generate token
|
|
token, err := storage.GenerateSecurePassword(48)
|
|
if err != nil {
|
|
errors.LogError(r, err, "Failed to generate token")
|
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// If the file belongs to an org, prefer binding the share link to that org.
|
|
// db.CreateFileShareLink will attempt to infer org_id from the file if nil is passed.
|
|
link, err := db.CreateFileShareLink(r.Context(), token, fileUUID, nil, userID)
|
|
if err != nil {
|
|
errors.LogError(r, err, "Failed to create share link")
|
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
log.Printf("Share link created: user_id=%s, file_id=%s, org_id=%v", userID, fileUUID, link.OrgID)
|
|
|
|
// Build full URL
|
|
scheme := "https"
|
|
if proto := r.Header.Get("X-Forwarded-Proto"); proto != "" {
|
|
scheme = proto
|
|
} else if r.TLS == nil {
|
|
scheme = "http"
|
|
}
|
|
host := "www.b0esche.cloud"
|
|
fullURL := fmt.Sprintf("%s://%s/share/%s", scheme, host, link.Token)
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"shareUrl": fullURL,
|
|
"token": link.Token,
|
|
})
|
|
}
|
|
|
|
func revokeUserFileShareLinkHandler(w http.ResponseWriter, r *http.Request, db *database.DB) {
|
|
userIDStr, _ := middleware.GetUserID(r.Context())
|
|
userID, _ := uuid.Parse(userIDStr)
|
|
fileId := chi.URLParam(r, "fileId")
|
|
|
|
fileUUID, err := uuid.Parse(fileId)
|
|
if err != nil {
|
|
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid file ID", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Check if file exists and belongs to user
|
|
file, err := db.GetFileByID(r.Context(), fileUUID)
|
|
if err != nil {
|
|
errors.LogError(r, err, "Failed to get file")
|
|
errors.WriteError(w, errors.CodeNotFound, "File not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
if file.UserID == nil || *file.UserID != userID {
|
|
errors.WriteError(w, errors.CodeNotFound, "File not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
err = db.RevokeFileShareLink(r.Context(), fileUUID)
|
|
if err != nil {
|
|
errors.LogError(r, err, "Failed to revoke share link")
|
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// publicWopiCheckFileInfoHandler handles GET /public/wopi/share/{token}
|
|
// Returns metadata about the shared file for Collabora viewer
|
|
func publicWopiCheckFileInfoHandler(w http.ResponseWriter, r *http.Request, db *database.DB, jwtManager *jwt.Manager) {
|
|
token := chi.URLParam(r, "token")
|
|
if token == "" {
|
|
errors.WriteError(w, errors.CodeInvalidArgument, "Token required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Get share link
|
|
link, err := db.GetFileShareLinkByToken(r.Context(), token)
|
|
if err != nil {
|
|
if err == sql.ErrNoRows {
|
|
errors.WriteError(w, errors.CodeNotFound, "Link not found or expired", http.StatusNotFound)
|
|
return
|
|
}
|
|
errors.LogError(r, err, "Failed to get share link")
|
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Get file metadata
|
|
file, err := db.GetFileByID(r.Context(), link.FileID)
|
|
if err != nil {
|
|
errors.LogError(r, err, "Failed to get file")
|
|
errors.WriteError(w, errors.CodeNotFound, "File not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
if file.UserID == nil {
|
|
errors.WriteError(w, errors.CodeNotFound, "File not accessible", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
lastModifiedTime := file.LastModified
|
|
if lastModifiedTime.IsZero() {
|
|
lastModifiedTime = time.Now()
|
|
}
|
|
|
|
response := struct {
|
|
BaseFileName string `json:"BaseFileName"`
|
|
Size int64 `json:"Size"`
|
|
OwnerId string `json:"OwnerId"`
|
|
Version string `json:"Version"`
|
|
SupportsExtendedLockLength bool `json:"SupportsExtendedLockLength"`
|
|
SupportsGetLock bool `json:"SupportsGetLock"`
|
|
SupportsLocks bool `json:"SupportsLocks"`
|
|
SupportsUpdate bool `json:"SupportsUpdate"`
|
|
UserId string `json:"UserId"`
|
|
UserFriendlyName string `json:"UserFriendlyName"`
|
|
UserCanWrite bool `json:"UserCanWrite"`
|
|
UserCanNotWriteRelative bool `json:"UserCanNotWriteRelative"`
|
|
ReadOnly bool `json:"ReadOnly"`
|
|
RestrictedWebViewOnly bool `json:"RestrictedWebViewOnly"`
|
|
UserCanCreateRelativeToFolder bool `json:"UserCanCreateRelativeToFolder"`
|
|
EnableOwnerTermination bool `json:"EnableOwnerTermination"`
|
|
SupportsCobalt bool `json:"SupportsCobalt"`
|
|
SupportsDelete bool `json:"SupportsDelete"`
|
|
SupportsRename bool `json:"SupportsRename"`
|
|
SupportsRenameRelativeToFolder bool `json:"SupportsRenameRelativeToFolder"`
|
|
SupportsFolders bool `json:"SupportsFolders"`
|
|
SupportsScenarios []string `json:"SupportsScenarios"`
|
|
LastModifiedTime string `json:"LastModifiedTime"`
|
|
IsAnonymousUser bool `json:"IsAnonymousUser"`
|
|
TimeZone string `json:"TimeZone"`
|
|
}{
|
|
BaseFileName: file.Name,
|
|
Size: file.Size,
|
|
OwnerId: file.UserID.String(),
|
|
Version: file.LastModified.UTC().Format(time.RFC3339),
|
|
SupportsExtendedLockLength: false,
|
|
SupportsGetLock: false,
|
|
SupportsLocks: false,
|
|
SupportsUpdate: false,
|
|
UserId: "anonymous",
|
|
UserFriendlyName: "Anonymous User",
|
|
UserCanWrite: false,
|
|
UserCanNotWriteRelative: true,
|
|
ReadOnly: true, // Public sharing is read-only
|
|
RestrictedWebViewOnly: true, // Only allow web view
|
|
UserCanCreateRelativeToFolder: false,
|
|
EnableOwnerTermination: false,
|
|
SupportsCobalt: false,
|
|
SupportsDelete: false,
|
|
SupportsRename: false,
|
|
SupportsRenameRelativeToFolder: false,
|
|
SupportsFolders: false,
|
|
SupportsScenarios: []string{"embedview", "view"},
|
|
LastModifiedTime: lastModifiedTime.UTC().Format(time.RFC3339),
|
|
IsAnonymousUser: true,
|
|
TimeZone: "UTC",
|
|
}
|
|
|
|
fmt.Printf("[PUBLIC-WOPI] CheckFileInfo: file=%s token=%s size=%d\n", file.ID.String(), token, file.Size)
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
json.NewEncoder(w).Encode(response)
|
|
}
|
|
|
|
// publicWopiGetFileHandler handles GET /public/wopi/share/{token}/contents
|
|
// Downloads the shared file content for Collabora
|
|
func publicWopiGetFileHandler(w http.ResponseWriter, r *http.Request, db *database.DB, cfg *config.Config, jwtManager *jwt.Manager) {
|
|
token := chi.URLParam(r, "token")
|
|
if token == "" {
|
|
errors.WriteError(w, errors.CodeInvalidArgument, "Token required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
fmt.Printf("[PUBLIC-WOPI-GetFile] START: token=%s\n", token)
|
|
|
|
// Get share link
|
|
link, err := db.GetFileShareLinkByToken(r.Context(), token)
|
|
if err != nil {
|
|
if err == sql.ErrNoRows {
|
|
errors.WriteError(w, errors.CodeNotFound, "Link not found or expired", http.StatusNotFound)
|
|
return
|
|
}
|
|
errors.LogError(r, err, "Failed to get share link")
|
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Get file metadata
|
|
file, err := db.GetFileByID(r.Context(), link.FileID)
|
|
if err != nil {
|
|
errors.LogError(r, err, "Failed to get file")
|
|
errors.WriteError(w, errors.CodeNotFound, "File not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
if file.UserID == nil {
|
|
errors.WriteError(w, errors.CodeNotFound, "File not accessible", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
// Get WebDAV client for the file's owner
|
|
client, err := getUserWebDAVClient(r.Context(), db, *file.UserID, cfg.NextcloudURL, cfg.NextcloudUser, cfg.NextcloudPass)
|
|
if err != nil {
|
|
errors.LogError(r, err, "Failed to get WebDAV client")
|
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Create context with longer timeout for file downloads
|
|
downloadCtx, cancel := context.WithTimeout(r.Context(), 5*time.Minute)
|
|
defer cancel()
|
|
|
|
// Stream file
|
|
resp, err := client.Download(downloadCtx, file.Path, r.Header.Get("Range"))
|
|
if err != nil {
|
|
errors.LogError(r, err, "Failed to download file")
|
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
// Copy headers from Nextcloud response
|
|
for k, v := range resp.Header {
|
|
w.Header()[k] = v
|
|
}
|
|
|
|
// Set status code (200 or 206 for partial)
|
|
w.WriteHeader(resp.StatusCode)
|
|
|
|
// Copy body
|
|
io.Copy(w, resp.Body)
|
|
}
|
|
|
|
// getUserProfileHandler returns the current user's profile information
|
|
func getUserProfileHandler(w http.ResponseWriter, r *http.Request, db *database.DB) {
|
|
userIDStr, ok := middleware.GetUserID(r.Context())
|
|
if !ok {
|
|
errors.WriteError(w, errors.CodeUnauthenticated, "Unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
userID, err := uuid.Parse(userIDStr)
|
|
if err != nil {
|
|
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid user ID", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
var user struct {
|
|
ID uuid.UUID `json:"id"`
|
|
Username string `json:"username"`
|
|
Email string `json:"email"`
|
|
DisplayName *string `json:"displayName"`
|
|
AvatarURL *string `json:"avatarUrl"`
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
LastLoginAt *time.Time `json:"lastLoginAt"`
|
|
UpdatedAt *time.Time `json:"-"`
|
|
}
|
|
|
|
err = db.QueryRowContext(r.Context(),
|
|
`SELECT id, username, email, display_name, avatar_url, created_at, last_login_at, updated_at
|
|
FROM users WHERE id = $1`, userID).
|
|
Scan(&user.ID, &user.Username, &user.Email, &user.DisplayName, &user.AvatarURL, &user.CreatedAt, &user.LastLoginAt, &user.UpdatedAt)
|
|
if err != nil {
|
|
if err == sql.ErrNoRows {
|
|
errors.WriteError(w, errors.CodeNotFound, "User not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
errors.LogError(r, err, "Failed to get user profile")
|
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// If avatar exists, return the backend URL instead of the internal WebDAV URL
|
|
if user.AvatarURL != nil && *user.AvatarURL != "" {
|
|
// Use updated_at for versioning if available to allow cache busting when avatar changes
|
|
var v int64
|
|
if user.UpdatedAt != nil {
|
|
v = user.UpdatedAt.Unix()
|
|
} else {
|
|
v = time.Now().Unix()
|
|
}
|
|
// Include token in the avatar URL so frontends that cannot set headers (Image.network) can fetch it
|
|
token, ok := middleware.GetToken(r.Context())
|
|
if ok && token != "" {
|
|
user.AvatarURL = &[]string{fmt.Sprintf("https://go.b0esche.cloud/user/avatar?v=%d&token=%s", v, token)}[0]
|
|
} else {
|
|
user.AvatarURL = &[]string{fmt.Sprintf("https://go.b0esche.cloud/user/avatar?v=%d", v)}[0]
|
|
}
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(user)
|
|
}
|
|
|
|
// updateUserProfileHandler updates the current user's profile information
|
|
func updateUserProfileHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger) {
|
|
userIDStr, ok := middleware.GetUserID(r.Context())
|
|
if !ok {
|
|
errors.WriteError(w, errors.CodeUnauthenticated, "Unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
userID, err := uuid.Parse(userIDStr)
|
|
if err != nil {
|
|
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid user ID", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
DisplayName *string `json:"displayName"`
|
|
Email *string `json:"email"`
|
|
}
|
|
|
|
if err = json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid JSON", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Build dynamic update query
|
|
var setParts []string
|
|
var args []interface{}
|
|
argIndex := 1
|
|
|
|
if req.DisplayName != nil {
|
|
setParts = append(setParts, fmt.Sprintf("display_name = $%d", argIndex))
|
|
args = append(args, *req.DisplayName)
|
|
argIndex++
|
|
}
|
|
if req.Email != nil {
|
|
setParts = append(setParts, fmt.Sprintf("email = $%d", argIndex))
|
|
args = append(args, *req.Email)
|
|
argIndex++
|
|
}
|
|
|
|
if len(setParts) == 0 {
|
|
// No fields to update
|
|
w.WriteHeader(http.StatusOK)
|
|
json.NewEncoder(w).Encode(map[string]string{"message": "No changes to update"})
|
|
return
|
|
}
|
|
|
|
setParts = append(setParts, "updated_at = NOW()")
|
|
query := fmt.Sprintf("UPDATE users SET %s WHERE id = $%d", strings.Join(setParts, ", "), argIndex)
|
|
args = append(args, userID)
|
|
|
|
_, err = db.ExecContext(r.Context(), query, args...)
|
|
if err != nil {
|
|
errors.LogError(r, err, "Failed to update user profile")
|
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Audit log
|
|
metadata := make(map[string]interface{})
|
|
if req.DisplayName != nil {
|
|
metadata["displayName"] = *req.DisplayName
|
|
}
|
|
if req.Email != nil {
|
|
metadata["email"] = *req.Email
|
|
}
|
|
|
|
auditLogger.Log(r.Context(), audit.Entry{
|
|
UserID: &userID,
|
|
Action: "profile_update",
|
|
Success: true,
|
|
Metadata: metadata,
|
|
})
|
|
|
|
// Return updated profile JSON
|
|
var updatedUser struct {
|
|
ID uuid.UUID `json:"id"`
|
|
Username string `json:"username"`
|
|
Email string `json:"email"`
|
|
DisplayName *string `json:"displayName"`
|
|
AvatarURL *string `json:"avatarUrl"`
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
LastLoginAt *time.Time `json:"lastLoginAt"`
|
|
UpdatedAt *time.Time `json:"-"`
|
|
}
|
|
|
|
err = db.QueryRowContext(r.Context(),
|
|
`SELECT id, username, email, display_name, avatar_url, created_at, last_login_at, updated_at
|
|
FROM users WHERE id = $1`, userID).
|
|
Scan(&updatedUser.ID, &updatedUser.Username, &updatedUser.Email, &updatedUser.DisplayName, &updatedUser.AvatarURL, &updatedUser.CreatedAt, &updatedUser.LastLoginAt, &updatedUser.UpdatedAt)
|
|
if err != nil {
|
|
errors.LogError(r, err, "Failed to fetch updated user profile")
|
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if updatedUser.AvatarURL != nil && *updatedUser.AvatarURL != "" {
|
|
var v int64
|
|
if updatedUser.UpdatedAt != nil {
|
|
v = updatedUser.UpdatedAt.Unix()
|
|
} else {
|
|
v = time.Now().Unix()
|
|
}
|
|
if token, ok := middleware.GetToken(r.Context()); ok && token != "" {
|
|
updatedUser.AvatarURL = &[]string{fmt.Sprintf("https://go.b0esche.cloud/user/avatar?v=%d&token=%s", v, token)}[0]
|
|
} else {
|
|
updatedUser.AvatarURL = &[]string{fmt.Sprintf("https://go.b0esche.cloud/user/avatar?v=%d", v)}[0]
|
|
}
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
json.NewEncoder(w).Encode(updatedUser)
|
|
}
|
|
|
|
// changePasswordHandler changes the current user's password
|
|
func changePasswordHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger) {
|
|
userIDStr, ok := middleware.GetUserID(r.Context())
|
|
if !ok {
|
|
errors.WriteError(w, errors.CodeUnauthenticated, "Unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
userID, err := uuid.Parse(userIDStr)
|
|
if err != nil {
|
|
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid user ID", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
CurrentPassword string `json:"currentPassword"`
|
|
NewPassword string `json:"newPassword"`
|
|
}
|
|
|
|
if err = json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid JSON", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// For simplicity, since passwords are handled via passkeys, we'll just log and simulate
|
|
// In a real implementation, verify current password and hash new one
|
|
|
|
// Audit log
|
|
auditLogger.Log(r.Context(), audit.Entry{
|
|
UserID: &userID,
|
|
Action: "password_change",
|
|
Success: true,
|
|
})
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
json.NewEncoder(w).Encode(map[string]string{"message": "Password changed"})
|
|
}
|
|
|
|
// uploadUserAvatarHandler handles avatar file uploads
|
|
func uploadUserAvatarHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger, cfg *config.Config) {
|
|
userIDStr, ok := middleware.GetUserID(r.Context())
|
|
if !ok {
|
|
errors.WriteError(w, errors.CodeUnauthenticated, "Unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
userID, err := uuid.Parse(userIDStr)
|
|
if err != nil {
|
|
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid user ID", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Parse multipart form
|
|
err = r.ParseMultipartForm(32 << 20) // 32MB max
|
|
if err != nil {
|
|
errors.WriteError(w, errors.CodeInvalidArgument, "Failed to parse form", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
file, header, err := r.FormFile("avatar")
|
|
if err != nil {
|
|
errors.WriteError(w, errors.CodeInvalidArgument, "No avatar file provided", http.StatusBadRequest)
|
|
return
|
|
}
|
|
defer file.Close()
|
|
|
|
// Validate file type
|
|
contentType := header.Header.Get("Content-Type")
|
|
if !strings.HasPrefix(contentType, "image/") {
|
|
errors.WriteError(w, errors.CodeInvalidArgument, "File must be an image", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Validate file size (max 5MB)
|
|
if header.Size > 5<<20 {
|
|
errors.WriteError(w, errors.CodeInvalidArgument, "File too large (max 5MB)", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Read file content
|
|
fileBytes, err := io.ReadAll(file)
|
|
if err != nil {
|
|
errors.LogError(r, err, "Failed to read file")
|
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Generate deterministic filename based on user ID
|
|
ext := filepath.Ext(header.Filename)
|
|
if ext == "" {
|
|
ext = ".png" // default extension
|
|
}
|
|
filename := fmt.Sprintf("%s%s", userID.String(), ext)
|
|
|
|
// Upload to Nextcloud at .avatars/<user-id>.<ext>
|
|
// Use internal Nextcloud WebDAV endpoint for server-to-server operations to avoid external TLS/timeouts
|
|
internalClient := storage.NewUserWebDAVClient(cfg.NextcloudURL, cfg.NextcloudUser, cfg.NextcloudPass)
|
|
avatarPath := fmt.Sprintf(".avatars/%s", filename)
|
|
err = internalClient.Upload(r.Context(), avatarPath, bytes.NewReader(fileBytes), header.Size)
|
|
if err != nil {
|
|
errors.LogError(r, err, "Failed to upload avatar (internal)")
|
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Store external-facing avatar URL in DB (so other components can reference it)
|
|
externalClient := storage.NewWebDAVClient(cfg)
|
|
webdavURL := fmt.Sprintf("%s/%s", strings.TrimRight(externalClient.BaseURL, "/"), avatarPath)
|
|
|
|
// Update user profile with avatar URL and updated_at
|
|
_, err = db.ExecContext(r.Context(),
|
|
`UPDATE users SET avatar_url = $1, updated_at = NOW() WHERE id = $2`,
|
|
webdavURL, userID)
|
|
if err != nil {
|
|
errors.LogError(r, err, "Failed to update user avatar")
|
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Verify uploaded avatar is available on WebDAV (best-effort, retry)
|
|
verified := false
|
|
verifyRetries := 3
|
|
verifyTimeout := 5 // seconds
|
|
for i := 0; i < verifyRetries; i++ {
|
|
vctx, vcancel := context.WithTimeout(r.Context(), time.Duration(verifyTimeout)*time.Second)
|
|
resp, derr := internalClient.Download(vctx, avatarPath, "")
|
|
if derr == nil && resp != nil {
|
|
// Close body while context is still valid
|
|
resp.Body.Close()
|
|
vcancel()
|
|
verified = true
|
|
break
|
|
}
|
|
// Cancel context for failed attempt
|
|
vcancel()
|
|
fmt.Printf("[WARN] avatar verification attempt %d/%d failed for %s: %v\n", i+1, verifyRetries, avatarPath, derr)
|
|
time.Sleep(time.Duration(300*(1<<i)) * time.Millisecond) // 300ms, 600ms, 1.2s
|
|
}
|
|
// If verification failed, log detailed message (but proceed to respond with preview/cache)
|
|
if !verified {
|
|
fmt.Printf("[ERROR] avatar verification failed after %d attempts for %s\n", verifyRetries, avatarPath)
|
|
} else {
|
|
fmt.Printf("[INFO] avatar verification succeeded for %s\n", avatarPath)
|
|
}
|
|
|
|
// Build public URL including version based on updated_at and include token if available
|
|
var version int64 = time.Now().Unix()
|
|
// Try to use updated_at from DB to be more accurate
|
|
var updatedAt time.Time
|
|
err = db.QueryRowContext(r.Context(), `SELECT updated_at FROM users WHERE id = $1`, userID).Scan(&updatedAt)
|
|
if err == nil {
|
|
version = updatedAt.Unix()
|
|
}
|
|
// Save avatar bytes to local cache keyed by version so it survives restarts and avoids unnecessary re-downloads
|
|
versionStr := fmt.Sprintf("%d", version)
|
|
cached := false
|
|
if err := writeAvatarCache(cfg, userID.String(), versionStr, ext, fileBytes); err != nil {
|
|
fmt.Printf("[WARN] failed to write avatar cache for user=%s version=%s: %v\n", userID.String(), versionStr, err)
|
|
// Attempt to write to an additional local data dir as a fallback
|
|
fallbackDir := "./data/avatars"
|
|
if err2 := os.MkdirAll(fallbackDir, 0755); err2 == nil {
|
|
fallbackPath := filepath.Join(fallbackDir, fmt.Sprintf("%s.%s%s", userID.String(), versionStr, ext))
|
|
if err3 := os.WriteFile(fallbackPath, fileBytes, 0644); err3 == nil {
|
|
fmt.Printf("[INFO] Wrote avatar cache to fallback path %s\n", fallbackPath)
|
|
cached = true
|
|
} else {
|
|
fmt.Printf("[WARN] failed to write avatar cache to fallback path %s: %v\n", fallbackPath, err3)
|
|
}
|
|
} else {
|
|
fmt.Printf("[WARN] failed to create fallback avatar dir %s: %v\n", fallbackDir, err2)
|
|
}
|
|
} else {
|
|
cached = true
|
|
}
|
|
// Verify cache is readable
|
|
if cached {
|
|
if b, ct, cerr := readAvatarCache(cfg, userID.String(), versionStr); cerr != nil {
|
|
fmt.Printf("[WARN] cache write verification failed for user=%s v=%s: %v\n", userID.String(), versionStr, cerr)
|
|
cached = false
|
|
} else {
|
|
fmt.Printf("[INFO] cache write verified for user=%s v=%s (content-type=%s size=%d)\n", userID.String(), versionStr, ct, len(b))
|
|
}
|
|
}
|
|
|
|
// Audit log
|
|
auditLogger.Log(r.Context(), audit.Entry{
|
|
UserID: &userID,
|
|
Action: "avatar_upload",
|
|
Success: true,
|
|
Metadata: map[string]interface{}{
|
|
"filename": filename,
|
|
"size": header.Size,
|
|
},
|
|
})
|
|
|
|
// Provide avatarData for immediate preview (small images only)
|
|
avatarData := base64.StdEncoding.EncodeToString(fileBytes)
|
|
|
|
publicURL := fmt.Sprintf("https://go.b0esche.cloud/user/avatar?v=%d", version)
|
|
if token, ok := middleware.GetToken(r.Context()); ok && token != "" {
|
|
publicURL = fmt.Sprintf("%s&token=%s", publicURL, token)
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"message": "Avatar uploaded successfully",
|
|
"avatarUrl": publicURL,
|
|
"cached": cached,
|
|
"avatarData": avatarData,
|
|
"contentType": contentType,
|
|
})
|
|
}
|
|
|
|
// getUserAvatarHandler serves the user's avatar image
|
|
func getUserAvatarHandler(w http.ResponseWriter, r *http.Request, db *database.DB, jwtManager *jwt.Manager, cfg *config.Config) {
|
|
// Accept token via query param or Authorization header (Bearer)
|
|
tokenString := r.URL.Query().Get("token")
|
|
if tokenString == "" {
|
|
authHeader := r.Header.Get("Authorization")
|
|
if strings.HasPrefix(authHeader, "Bearer ") {
|
|
tokenString = strings.TrimPrefix(authHeader, "Bearer ")
|
|
}
|
|
}
|
|
if tokenString == "" {
|
|
errors.WriteError(w, errors.CodeUnauthenticated, "Unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
claims, err := jwtManager.Validate(tokenString)
|
|
if err != nil {
|
|
errors.WriteError(w, errors.CodeUnauthenticated, "Unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
userIDStr := claims.UserID
|
|
userID, err := uuid.Parse(userIDStr)
|
|
if err != nil {
|
|
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid user ID", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
var avatarURL *string
|
|
err = db.QueryRowContext(r.Context(),
|
|
`SELECT avatar_url FROM users WHERE id = $1`, userID).
|
|
Scan(&avatarURL)
|
|
if err != nil {
|
|
if err == sql.ErrNoRows {
|
|
errors.WriteError(w, errors.CodeNotFound, "User not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
errors.LogError(r, err, "Failed to get user avatar")
|
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if avatarURL == nil || *avatarURL == "" {
|
|
// No avatar, return 404
|
|
w.WriteHeader(http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
v := r.URL.Query().Get("v")
|
|
// If client supplied a version and we have a matching cache, serve it immediately to avoid hitting WebDAV
|
|
if v != "" {
|
|
if data, ct, cerr := readAvatarCache(cfg, userID.String(), v); cerr == nil {
|
|
w.Header().Set("Content-Type", ct)
|
|
w.Header().Set("Cache-Control", "public, max-age=300")
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write(data)
|
|
fmt.Printf("[INFO] Served avatar for user=%s from cache (v=%s) without contacting WebDAV\n", userID.String(), v)
|
|
return
|
|
}
|
|
}
|
|
|
|
// Download from WebDAV with retries and backoff
|
|
// Use external client instance only to compute remotePath from stored avatar URL
|
|
externalClient := storage.NewWebDAVClient(cfg)
|
|
if externalClient == nil {
|
|
errors.WriteError(w, errors.CodeInternal, "WebDAV client not configured", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
remotePath := strings.TrimPrefix(*avatarURL, externalClient.BaseURL+"/")
|
|
|
|
// Use internal admin WebDAV client to actually fetch the avatar (server-to-server)
|
|
internalClient := storage.NewUserWebDAVClient(cfg.NextcloudURL, cfg.NextcloudUser, cfg.NextcloudPass)
|
|
|
|
// Configure retry & timeout from config
|
|
timeoutSeconds := cfg.AvatarDownloadTimeoutSeconds
|
|
if timeoutSeconds <= 0 {
|
|
timeoutSeconds = 10
|
|
}
|
|
retries := cfg.AvatarDownloadRetries
|
|
if retries < 0 {
|
|
retries = 0
|
|
}
|
|
attempts := retries + 1 // total attempts
|
|
|
|
var resp *http.Response
|
|
var dlErr error
|
|
var cancel context.CancelFunc
|
|
for attempt := 0; attempt < attempts; attempt++ {
|
|
ctx, c := context.WithTimeout(r.Context(), time.Duration(timeoutSeconds)*time.Second)
|
|
cancel = c
|
|
// Use internal client to avoid external network/TLS overhead
|
|
resp, dlErr = internalClient.Download(ctx, remotePath, "")
|
|
if dlErr != nil {
|
|
// Cancel context for failed attempt
|
|
cancel()
|
|
// If 404 on remote storage, the avatar file truly doesn't exist
|
|
if strings.Contains(dlErr.Error(), "404") {
|
|
fmt.Printf("[ERROR] Avatar not found on storage for remotePath=%s: %v\n", remotePath, dlErr)
|
|
w.WriteHeader(http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
// Log and apply backoff for retryable errors
|
|
fmt.Printf("[WARN] Avatar download attempt %d/%d failed for remotePath=%s: %v\n", attempt+1, attempts, remotePath, dlErr)
|
|
if attempt < attempts-1 {
|
|
// exponential backoff: 500ms, 1s, 2s, ...
|
|
backoffMs := 500 * (1 << attempt)
|
|
time.Sleep(time.Duration(backoffMs) * time.Millisecond)
|
|
}
|
|
continue
|
|
}
|
|
|
|
// Success: keep the cancel func so we can call it after reading the body
|
|
break
|
|
}
|
|
if cancel != nil {
|
|
defer cancel()
|
|
}
|
|
|
|
if dlErr != nil || resp == nil {
|
|
// Try to serve the latest cached avatar (if any) as a graceful fallback
|
|
if data, ct, cerr := readAvatarCache(cfg, userID.String(), ""); cerr == nil {
|
|
w.Header().Set("Content-Type", ct)
|
|
w.Header().Set("Cache-Control", "public, max-age=300")
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write(data)
|
|
fmt.Printf("[INFO] Served latest cached avatar for user=%s after WebDAV failures\n", userID.String())
|
|
return
|
|
}
|
|
|
|
// No cached avatar available; surface an explicit error (502) so clients can retry
|
|
fmt.Printf("[ERROR] Avatar download failed after %d attempts for remotePath=%s: %v\n", attempts, remotePath, dlErr)
|
|
w.Header().Set("Retry-After", "30")
|
|
errors.WriteError(w, errors.CodeInternal, "Upstream storage unavailable", http.StatusBadGateway)
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
// Read body (so we can cache it) and determine content-type
|
|
bodyBytes, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
errors.LogError(r, err, "Failed to read avatar body")
|
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
contentType := resp.Header.Get("Content-Type")
|
|
if contentType == "" {
|
|
contentType = mime.TypeByExtension(filepath.Ext(remotePath))
|
|
if contentType == "" {
|
|
contentType = "application/octet-stream"
|
|
}
|
|
}
|
|
|
|
// Save to cache asynchronously (best-effort). Use provided v query param if present.
|
|
v = r.URL.Query().Get("v")
|
|
go func(v string) {
|
|
if err := writeAvatarCache(cfg, userID.String(), v, filepath.Ext(remotePath), bodyBytes); err != nil {
|
|
fmt.Printf("[WARN] failed to write avatar cache for user=%s v=%s: %v\n", userID.String(), v, err)
|
|
}
|
|
}(v)
|
|
|
|
// Copy headers but ensure sensible caching
|
|
w.Header().Set("Content-Type", contentType)
|
|
w.Header().Set("Cache-Control", "public, max-age=300")
|
|
w.WriteHeader(resp.StatusCode)
|
|
w.Write(bodyBytes)
|
|
}
|
|
|
|
// deleteUserAccountHandler handles user account deletion
|
|
func deleteUserAccountHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger, cfg *config.Config) {
|
|
userIDStr, ok := middleware.GetUserID(r.Context())
|
|
if !ok {
|
|
errors.WriteError(w, errors.CodeUnauthenticated, "Unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
userID, err := uuid.Parse(userIDStr)
|
|
if err != nil {
|
|
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid user ID", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Start transaction for atomic deletion
|
|
tx, err := db.BeginTx(r.Context(), nil)
|
|
if err != nil {
|
|
errors.LogError(r, err, "Failed to start transaction")
|
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
// Get user details for audit logging
|
|
var username string
|
|
err = tx.QueryRowContext(r.Context(), "SELECT username FROM users WHERE id = $1", userID).Scan(&username)
|
|
if err != nil {
|
|
errors.LogError(r, err, "Failed to get user details")
|
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Delete file shares (both personal and org shares)
|
|
_, err = tx.ExecContext(r.Context(), `
|
|
DELETE FROM file_share_links
|
|
WHERE created_by_user_id = $1
|
|
`, userID)
|
|
if err != nil {
|
|
errors.LogError(r, err, "Failed to delete file shares")
|
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Delete user files and their Nextcloud data
|
|
rows, err := tx.QueryContext(r.Context(), `
|
|
SELECT path FROM files WHERE user_id = $1 AND org_id IS NULL
|
|
`, userID)
|
|
if err != nil {
|
|
errors.LogError(r, err, "Failed to get user files")
|
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
// Delete files from Nextcloud storage
|
|
client := storage.NewWebDAVClient(cfg)
|
|
for rows.Next() {
|
|
var filePath string
|
|
if err := rows.Scan(&filePath); err != nil {
|
|
continue // Skip on error
|
|
}
|
|
|
|
// Try to delete from Nextcloud (ignore errors as files might not exist)
|
|
client.Delete(r.Context(), filePath)
|
|
}
|
|
|
|
// Delete database records
|
|
_, err = tx.ExecContext(r.Context(), "DELETE FROM files WHERE user_id = $1 AND org_id IS NULL", userID)
|
|
if err != nil {
|
|
errors.LogError(r, err, "Failed to delete user files")
|
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Remove user from all organizations (this will cascade to org files if needed)
|
|
_, err = tx.ExecContext(r.Context(), "DELETE FROM memberships WHERE user_id = $1", userID)
|
|
if err != nil {
|
|
errors.LogError(r, err, "Failed to remove user from organizations")
|
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Delete user sessions
|
|
_, err = tx.ExecContext(r.Context(), "DELETE FROM sessions WHERE user_id = $1", userID)
|
|
if err != nil {
|
|
errors.LogError(r, err, "Failed to delete user sessions")
|
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Finally, delete the user account
|
|
_, err = tx.ExecContext(r.Context(), "DELETE FROM users WHERE id = $1", userID)
|
|
if err != nil {
|
|
errors.LogError(r, err, "Failed to delete user account")
|
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Commit transaction
|
|
if err = tx.Commit(); err != nil {
|
|
errors.LogError(r, err, "Failed to commit transaction")
|
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Audit log the account deletion
|
|
auditLogger.Log(r.Context(), audit.Entry{
|
|
UserID: &userID,
|
|
Action: "account_delete",
|
|
Success: true,
|
|
Metadata: map[string]interface{}{
|
|
"username": username,
|
|
},
|
|
})
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"message": "Account deleted successfully",
|
|
})
|
|
}
|