2025-12-17 22:57:57 +01:00
package http
import (
2026-01-14 12:02:20 +01:00
"archive/zip"
2026-01-09 17:32:16 +01:00
"bytes"
2026-01-10 22:58:35 +01:00
"context"
2026-01-15 13:39:47 +01:00
"database/sql"
2026-01-31 18:09:07 +01:00
"encoding/base64"
2025-12-18 00:02:50 +01:00
"encoding/json"
2026-01-09 17:01:41 +01:00
"fmt"
2026-01-09 17:01:41 +01:00
"io"
2026-01-24 23:45:08 +01:00
"log"
2026-01-29 23:00:59 +01:00
"mime"
2026-01-09 17:32:16 +01:00
"mime/multipart"
2025-12-17 22:57:57 +01:00
"net/http"
2026-01-10 04:48:28 +01:00
"net/url"
2026-01-29 23:00:59 +01:00
"os"
2026-01-09 17:32:16 +01:00
"path"
2026-01-09 17:01:41 +01:00
"path/filepath"
2025-12-18 00:02:50 +01:00
"strings"
2026-01-08 13:07:07 +01:00
"time"
2025-12-17 22:57:57 +01:00
"go.b0esche.cloud/backend/internal/audit"
"go.b0esche.cloud/backend/internal/auth"
"go.b0esche.cloud/backend/internal/config"
"go.b0esche.cloud/backend/internal/database"
2025-12-18 00:11:30 +01:00
"go.b0esche.cloud/backend/internal/errors"
2025-12-17 22:57:57 +01:00
"go.b0esche.cloud/backend/internal/middleware"
2025-12-18 00:02:50 +01:00
"go.b0esche.cloud/backend/internal/org"
"go.b0esche.cloud/backend/internal/permission"
2025-12-17 22:57:57 +01:00
"go.b0esche.cloud/backend/pkg/jwt"
"github.com/go-chi/chi/v5"
2025-12-18 00:02:50 +01:00
"github.com/google/uuid"
2026-01-09 17:32:16 +01:00
"go.b0esche.cloud/backend/internal/storage"
2025-12-17 22:57:57 +01:00
)
2026-01-13 22:11:02 +01:00
// 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
}
2026-01-29 23:00:59 +01:00
// Avatar cache helpers
2026-01-30 13:41:17 +01:00
// 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 {
2026-01-29 23:00:59 +01:00
dir := cfg . AvatarCacheDir
if dir == "" {
dir = "/var/cache/b0esche/avatars"
}
os . MkdirAll ( dir , 0755 )
2026-01-30 13:41:17 +01:00
// Filename: <userID>.<version><ext> (if version empty, use <userID><ext>)
if version != "" {
return filepath . Join ( dir , fmt . Sprintf ( "%s.%s%s" , userID , version , ext ) )
}
2026-01-29 23:00:59 +01:00
return filepath . Join ( dir , fmt . Sprintf ( "%s%s" , userID , ext ) )
}
2026-01-30 13:41:17 +01:00
// 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 {
2026-01-29 23:00:59 +01:00
dir := cfg . AvatarCacheDir
if dir == "" {
dir = "/var/cache/b0esche/avatars"
}
2026-01-29 23:12:12 +01:00
// 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
2026-01-29 23:00:59 +01:00
}
2026-01-30 13:41:17 +01:00
p := avatarCachePath ( & config . Config { AvatarCacheDir : dir } , userID , version , ext )
2026-01-31 18:09:07 +01:00
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
2026-01-29 23:12:12 +01:00
}
2026-01-30 13:41:17 +01:00
// 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 ) {
2026-01-29 23:12:12 +01:00
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" ) )
2026-01-31 18:09:07 +01:00
fmt . Printf ( "[DEBUG] readAvatarCache checking dirs=%v for user=%s version=%s\n" , checkDirs , userID , version )
2026-01-29 23:12:12 +01:00
for _ , dir := range checkDirs {
entries , err := os . ReadDir ( dir )
if err != nil {
2026-01-31 18:09:07 +01:00
fmt . Printf ( "[DEBUG] readAvatarCache cannot read dir %s: %v\n" , dir , err )
2026-01-29 23:12:12 +01:00
continue
}
2026-01-30 13:41:17 +01:00
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 ( ) )
2026-01-31 18:09:07 +01:00
fmt . Printf ( "[INFO] Found cached avatar for user=%s v=%s path=%s\n" , userID , version , p )
2026-01-30 13:41:17 +01:00
b , err := os . ReadFile ( p )
if err != nil {
2026-01-31 18:09:07 +01:00
fmt . Printf ( "[WARN] failed to read cached avatar %s: %v\n" , p , err )
2026-01-30 13:41:17 +01:00
return nil , "" , err
}
ext := filepath . Ext ( e . Name ( ) )
ct := mime . TypeByExtension ( ext )
if ct == "" {
ct = "application/octet-stream"
}
return b , ct , nil
}
}
// not found
2026-01-31 18:09:07 +01:00
fmt . Printf ( "[DEBUG] no exact cached avatar match in %s for prefix=%s\n" , dir , prefix )
2026-01-30 13:41:17 +01:00
continue
}
// no version specified: return latest by modtime of files starting with userID.
var latest os . FileInfo
var latestName string
2026-01-29 23:12:12 +01:00
for _ , e := range entries {
if strings . HasPrefix ( e . Name ( ) , userID + "." ) {
2026-01-30 13:41:17 +01:00
fi , err := e . Info ( )
2026-01-29 23:12:12 +01:00
if err != nil {
2026-01-30 13:41:17 +01:00
continue
2026-01-29 23:12:12 +01:00
}
2026-01-30 13:41:17 +01:00
if latest == nil || fi . ModTime ( ) . After ( latest . ModTime ( ) ) {
latest = fi
latestName = e . Name ( )
2026-01-29 23:12:12 +01:00
}
2026-01-29 23:00:59 +01:00
}
}
2026-01-30 13:41:17 +01:00
if latest != nil {
p := filepath . Join ( dir , latestName )
2026-01-31 18:09:07 +01:00
fmt . Printf ( "[INFO] Found latest cached avatar for user=%s path=%s\n" , userID , p )
2026-01-30 13:41:17 +01:00
b , err := os . ReadFile ( p )
if err != nil {
2026-01-31 18:09:07 +01:00
fmt . Printf ( "[WARN] failed to read cached avatar %s: %v\n" , p , err )
2026-01-30 13:41:17 +01:00
return nil , "" , err
}
ext := filepath . Ext ( latestName )
ct := mime . TypeByExtension ( ext )
if ct == "" {
ct = "application/octet-stream"
}
return b , ct , nil
}
2026-01-29 23:00:59 +01:00
}
return nil , "" , fmt . Errorf ( "cache miss" )
}
2026-01-10 22:58:35 +01:00
// 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 {
2026-01-10 23:16:02 +01:00
Username string
2026-01-10 22:58:35 +01:00
NextcloudUsername string
NextcloudPassword string
}
err := db . QueryRowContext ( ctx ,
2026-01-10 23:16:02 +01:00
"SELECT username, COALESCE(nextcloud_username, ''), COALESCE(nextcloud_password, '') FROM users WHERE id = $1" ,
userID ) . Scan ( & user . Username , & user . NextcloudUsername , & user . NextcloudPassword )
2026-01-10 22:58:35 +01:00
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 == "" {
2026-01-10 23:16:02 +01:00
// Use the actual username from the users table
ncUsername := user . Username
2026-01-10 22:58:35 +01:00
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
2026-01-27 01:40:36 +01:00
log . Printf ( "[AUTO-PROVISION] Created Nextcloud account for user %s: %s\n" , userID , ncUsername )
2026-01-10 22:58:35 +01:00
}
// Create user-specific WebDAV client
return storage . NewUserWebDAVClient ( nextcloudBaseURL , user . NextcloudUsername , user . NextcloudPassword ) , nil
}
2025-12-17 22:57:57 +01:00
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 )
2026-01-10 01:06:37 +01:00
r . Use ( middleware . CORS ( cfg . AllowedOrigins ) )
2026-01-27 01:40:36 +01:00
r . Use ( middleware . SecurityHeaders ( ) )
2025-12-17 22:57:57 +01:00
r . Use ( middleware . RateLimit )
// Health check
r . Get ( "/health" , healthHandler )
2026-01-23 23:39:59 +01:00
// Join org by invite token (public)
r . Get ( "/join" , func ( w http . ResponseWriter , req * http . Request ) {
getOrgByInviteTokenHandler ( w , req , db )
} )
2026-01-12 01:08:22 +01:00
// 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 ) {
2026-01-13 15:25:04 +01:00
wopiPutFileHandler ( w , req , db , jwtManager , cfg )
2026-01-12 01:08:22 +01:00
} )
// Lock operations: POST /wopi/files/{fileId}
r . Post ( "/" , func ( w http . ResponseWriter , req * http . Request ) {
wopiLockHandler ( w , req , db , jwtManager )
} )
} )
} )
2025-12-17 22:57:57 +01:00
// Auth routes (no auth required)
r . Route ( "/auth" , func ( r chi . Router ) {
2025-12-18 00:02:50 +01:00
r . Post ( "/refresh" , func ( w http . ResponseWriter , req * http . Request ) {
refreshHandler ( w , req , jwtManager , db )
2025-12-17 22:57:57 +01:00
} )
2026-01-09 19:53:09 +01:00
r . Post ( "/logout" , func ( w http . ResponseWriter , req * http . Request ) {
logoutHandler ( w , req , jwtManager , db , auditLogger )
} )
2026-01-08 13:07:07 +01:00
// 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 )
} )
2025-12-17 22:57:57 +01:00
} )
2026-01-08 20:40:07 +01:00
// Protected routes (with auth middleware)
r . Route ( "/" , func ( r chi . Router ) {
r . Use ( middleware . Auth ( jwtManager , db ) )
2025-12-17 22:57:57 +01:00
2026-01-09 17:01:41 +01:00
// User-scoped routes (personal workspace)
r . Get ( "/user/files" , func ( w http . ResponseWriter , req * http . Request ) {
userFilesHandler ( w , req , db )
} )
2026-01-10 01:39:15 +01:00
// User file viewer
r . Get ( "/user/files/{fileId}/view" , func ( w http . ResponseWriter , req * http . Request ) {
2026-01-11 17:39:12 +01:00
userViewerHandler ( w , req , db , jwtManager , auditLogger )
2026-01-10 01:39:15 +01:00
} )
2026-01-09 18:58:09 +01:00
// Download user file
r . Get ( "/user/files/download" , func ( w http . ResponseWriter , req * http . Request ) {
2026-01-10 22:58:35 +01:00
downloadUserFileHandler ( w , req , db , cfg )
2026-01-09 18:58:09 +01:00
} )
2026-01-09 17:01:41 +01:00
// Create / delete in user workspace
r . Post ( "/user/files" , func ( w http . ResponseWriter , req * http . Request ) {
2026-01-10 22:58:35 +01:00
createUserFileHandler ( w , req , db , auditLogger , cfg )
2026-01-09 17:01:41 +01:00
} )
r . Delete ( "/user/files" , func ( w http . ResponseWriter , req * http . Request ) {
2026-01-10 22:58:35 +01:00
deleteUserFileHandler ( w , req , db , auditLogger , cfg )
2026-01-09 17:01:41 +01:00
} )
// POST wrapper for delete
r . Post ( "/user/files/delete" , func ( w http . ResponseWriter , req * http . Request ) {
2026-01-10 22:58:35 +01:00
deleteUserFilePostHandler ( w , req , db , auditLogger , cfg )
2026-01-09 17:01:41 +01:00
} )
2026-01-12 00:01:47 +01:00
// Move file/folder in user workspace
r . Post ( "/user/files/move" , func ( w http . ResponseWriter , req * http . Request ) {
moveUserFileHandler ( w , req , db , auditLogger , cfg )
} )
2026-01-12 01:08:22 +01:00
// 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" )
} )
2026-01-14 12:09:25 +01:00
// User file editor
r . Get ( "/user/files/{fileId}/edit" , func ( w http . ResponseWriter , req * http . Request ) {
userEditorHandler ( w , req , db , auditLogger )
} )
2026-01-12 16:08:35 +01:00
// 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" )
} )
2026-01-24 22:32:58 +01:00
// 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 )
} )
2026-01-09 17:01:41 +01:00
2026-01-24 22:47:27 +01:00
// 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 )
} )
2026-01-27 03:28:27 +01:00
// 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 )
} )
2026-01-29 00:59:22 +01:00
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 )
} )
2026-01-27 03:28:27 +01:00
r . Post ( "/user/change-password" , func ( w http . ResponseWriter , req * http . Request ) {
changePasswordHandler ( w , req , db , auditLogger )
} )
2026-01-29 00:59:22 +01:00
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 )
} )
2026-01-27 03:28:27 +01:00
r . Post ( "/user/avatar" , func ( w http . ResponseWriter , req * http . Request ) {
uploadUserAvatarHandler ( w , req , db , auditLogger , cfg )
} )
2026-01-29 10:12:20 +01:00
r . Get ( "/user/avatar" , func ( w http . ResponseWriter , req * http . Request ) {
2026-01-29 21:19:15 +01:00
getUserAvatarHandler ( w , req , db , jwtManager , cfg )
2026-01-29 10:12:20 +01:00
} )
2026-01-29 00:59:22 +01:00
r . Options ( "/user/avatar" , func ( w http . ResponseWriter , req * http . Request ) {
w . Header ( ) . Set ( "Access-Control-Allow-Origin" , "*" )
2026-01-29 10:12:20 +01:00
w . Header ( ) . Set ( "Access-Control-Allow-Methods" , "GET, POST, OPTIONS" )
2026-01-29 00:59:22 +01:00
w . Header ( ) . Set ( "Access-Control-Allow-Headers" , "Content-Type, Authorization" )
w . WriteHeader ( http . StatusOK )
} )
2026-01-28 23:52:18 +01:00
r . Delete ( "/user/account" , func ( w http . ResponseWriter , req * http . Request ) {
deleteUserAccountHandler ( w , req , db , auditLogger , cfg )
} )
2026-01-29 00:59:22 +01:00
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 )
} )
2026-01-27 03:28:27 +01:00
2026-01-08 20:40:07 +01:00
// Org routes
r . Get ( "/orgs" , func ( w http . ResponseWriter , req * http . Request ) {
2026-01-10 05:02:07 +01:00
listOrgsHandler ( w , req , db )
2026-01-08 20:40:07 +01:00
} )
r . Post ( "/orgs" , func ( w http . ResponseWriter , req * http . Request ) {
2026-01-10 05:02:07 +01:00
createOrgHandler ( w , req , db , auditLogger )
2026-01-08 20:40:07 +01:00
} )
2025-12-18 00:02:50 +01:00
2026-01-08 20:40:07 +01:00
// Org-scoped routes
r . Route ( "/orgs/{orgId}" , func ( r chi . Router ) {
r . Use ( middleware . Org ( db , auditLogger ) )
2025-12-18 00:02:50 +01:00
2026-01-08 20:40:07 +01:00
// File routes
r . With ( middleware . Permission ( db , auditLogger , permission . FileRead ) ) . Get ( "/files" , func ( w http . ResponseWriter , req * http . Request ) {
2026-01-09 17:01:41 +01:00
listFilesHandler ( w , req , db )
} )
2026-01-09 18:58:09 +01:00
// Download org file
r . With ( middleware . Permission ( db , auditLogger , permission . FileRead ) ) . Get ( "/files/download" , func ( w http . ResponseWriter , req * http . Request ) {
2026-01-10 22:58:35 +01:00
downloadOrgFileHandler ( w , req , db , cfg )
2026-01-09 18:58:09 +01:00
} )
2026-01-09 17:01:41 +01:00
// Create file/folder in org workspace
r . With ( middleware . Permission ( db , auditLogger , permission . FileWrite ) ) . Post ( "/files" , func ( w http . ResponseWriter , req * http . Request ) {
2026-01-10 22:58:35 +01:00
createOrgFileHandler ( w , req , db , auditLogger , cfg )
2026-01-09 17:01:41 +01:00
} )
// 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 ) {
2026-01-10 22:58:35 +01:00
deleteOrgFilePostHandler ( w , req , db , auditLogger , cfg )
2026-01-09 17:01:41 +01:00
} )
// 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 ) {
2026-01-10 22:58:35 +01:00
deleteOrgFileHandler ( w , req , db , auditLogger , cfg )
2026-01-08 20:40:07 +01:00
} )
2026-01-11 23:10:14 +01:00
// 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 )
} )
2026-01-08 20:45:23 +01:00
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 ) {
2026-01-11 17:39:12 +01:00
viewerHandler ( w , req , db , jwtManager , auditLogger )
2026-01-08 20:45:23 +01:00
} )
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 )
} )
2026-01-24 21:06:18 +01:00
// Share link management
r . With ( middleware . Permission ( db , auditLogger , permission . FileRead ) ) . Get ( "/share" , func ( w http . ResponseWriter , req * http . Request ) {
getFileShareLinkHandler ( w , req , db )
} )
2026-01-24 22:21:50 +01:00
r . With ( middleware . Permission ( db , auditLogger , permission . FileRead ) ) . Post ( "/share" , func ( w http . ResponseWriter , req * http . Request ) {
2026-01-24 21:06:18 +01:00
createFileShareLinkHandler ( w , req , db )
} )
2026-01-24 22:21:50 +01:00
r . With ( middleware . Permission ( db , auditLogger , permission . FileRead ) ) . Delete ( "/share" , func ( w http . ResponseWriter , req * http . Request ) {
2026-01-24 21:06:18 +01:00
revokeFileShareLinkHandler ( w , req , db )
} )
2026-01-12 01:08:22 +01:00
// 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" )
} )
2026-01-12 16:08:35 +01:00
// 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" )
} )
2025-12-18 00:02:50 +01:00
} )
2026-01-08 20:45:23 +01:00
r . Get ( "/activity" , func ( w http . ResponseWriter , req * http . Request ) {
activityHandler ( w , req , db )
2025-12-18 00:02:50 +01:00
} )
2026-01-08 20:45:23 +01:00
r . With ( middleware . Permission ( db , auditLogger , permission . OrgManage ) ) . Get ( "/members" , func ( w http . ResponseWriter , req * http . Request ) {
listMembersHandler ( w , req , db )
2025-12-18 00:02:50 +01:00
} )
2026-01-08 20:45:23 +01:00
r . With ( middleware . Permission ( db , auditLogger , permission . OrgManage ) ) . Patch ( "/members/{userId}" , func ( w http . ResponseWriter , req * http . Request ) {
updateMemberRoleHandler ( w , req , db , auditLogger )
2025-12-18 00:02:50 +01:00
} )
2026-01-23 23:21:23 +01:00
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 )
} )
2025-12-18 00:02:50 +01:00
} )
2026-01-08 20:40:07 +01:00
} ) // Close protected routes
2025-12-18 00:02:50 +01:00
2026-01-24 21:06:18 +01:00
// 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 )
} )
2026-01-25 03:22:39 +01:00
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 )
} )
2026-01-24 21:06:18 +01:00
r . Get ( "/share/{token}/download" , func ( w http . ResponseWriter , req * http . Request ) {
publicFileDownloadHandler ( w , req , db , cfg , jwtManager )
} )
2026-01-25 03:22:39 +01:00
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 )
} )
2026-01-25 00:57:55 +01:00
r . Get ( "/share/{token}/view" , func ( w http . ResponseWriter , req * http . Request ) {
publicFileViewHandler ( w , req , db , cfg , jwtManager )
} )
2026-01-25 03:22:39 +01:00
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 )
} )
2026-01-25 15:47:59 +01:00
// 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 )
} )
} )
2026-01-24 21:06:18 +01:00
} )
2025-12-17 22:57:57 +01:00
return r
}
2026-01-23 23:39:59 +01:00
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 )
}
2025-12-17 22:57:57 +01:00
func healthHandler ( w http . ResponseWriter , r * http . Request ) {
w . WriteHeader ( http . StatusOK )
w . Write ( [ ] byte ( "OK" ) )
}
2025-12-18 00:02:50 +01:00
func refreshHandler ( w http . ResponseWriter , r * http . Request , jwtManager * jwt . Manager , db * database . DB ) {
authHeader := r . Header . Get ( "Authorization" )
if ! strings . HasPrefix ( authHeader , "Bearer " ) {
2025-12-18 00:11:30 +01:00
errors . WriteError ( w , errors . CodeUnauthenticated , "Unauthorized" , http . StatusUnauthorized )
2025-12-18 00:02:50 +01:00
return
}
tokenString := strings . TrimPrefix ( authHeader , "Bearer " )
claims , session , err := jwtManager . ValidateWithSession ( r . Context ( ) , tokenString , db )
if err != nil {
2025-12-18 00:11:30 +01:00
errors . LogError ( r , err , "Invalid token" )
errors . WriteError ( w , errors . CodeUnauthenticated , "Unauthorized" , http . StatusUnauthorized )
2025-12-18 00:02:50 +01:00
return
}
userID , _ := uuid . Parse ( claims . UserID )
orgs , err := db . GetUserOrganizations ( r . Context ( ) , userID )
if err != nil {
2025-12-18 00:11:30 +01:00
errors . LogError ( r , err , "Failed to get user organizations" )
errors . WriteError ( w , errors . CodeInternal , "Server error" , http . StatusInternalServerError )
2025-12-18 00:02:50 +01:00
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 {
2025-12-18 00:11:30 +01:00
errors . LogError ( r , err , "Token generation failed" )
errors . WriteError ( w , errors . CodeInternal , "Server error" , http . StatusInternalServerError )
2025-12-18 00:02:50 +01:00
return
}
w . Header ( ) . Set ( "Content-Type" , "application/json" )
w . Write ( [ ] byte ( ` { "token": " ` + newToken + ` "} ` ) )
}
2026-01-09 19:53:09 +01:00
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"} ` ) )
}
2026-01-10 05:02:07 +01:00
func listOrgsHandler ( w http . ResponseWriter , r * http . Request , db * database . DB ) {
2026-01-10 04:48:28 +01:00
// User ID is already set by Auth middleware
userIDStr , ok := middleware . GetUserID ( r . Context ( ) )
if ! ok {
2025-12-18 00:11:30 +01:00
errors . WriteError ( w , errors . CodeUnauthenticated , "Unauthorized" , http . StatusUnauthorized )
2025-12-18 00:02:50 +01:00
return
}
2026-01-10 04:48:28 +01:00
userID , _ := uuid . Parse ( userIDStr )
2025-12-18 00:02:50 +01:00
orgs , err := org . ResolveUserOrgs ( r . Context ( ) , db , userID )
if err != nil {
2025-12-18 00:11:30 +01:00
errors . LogError ( r , err , "Failed to resolve user orgs" )
errors . WriteError ( w , errors . CodeInternal , "Server error" , http . StatusInternalServerError )
2025-12-18 00:02:50 +01:00
return
}
w . Header ( ) . Set ( "Content-Type" , "application/json" )
json . NewEncoder ( w ) . Encode ( orgs )
}
2026-01-10 05:02:07 +01:00
func createOrgHandler ( w http . ResponseWriter , r * http . Request , db * database . DB , auditLogger * audit . Logger ) {
2026-01-10 04:48:28 +01:00
// User ID is already set by Auth middleware
userIDStr , ok := middleware . GetUserID ( r . Context ( ) )
if ! ok {
2025-12-18 00:11:30 +01:00
errors . WriteError ( w , errors . CodeUnauthenticated , "Unauthorized" , http . StatusUnauthorized )
2025-12-18 00:02:50 +01:00
return
}
2026-01-10 04:48:28 +01:00
userID , _ := uuid . Parse ( userIDStr )
2025-12-18 00:02:50 +01:00
var req struct {
Name string ` json:"name" `
Slug string ` json:"slug,omitempty" `
}
if err := json . NewDecoder ( r . Body ) . Decode ( & req ) ; err != nil {
2025-12-18 00:11:30 +01:00
errors . WriteError ( w , errors . CodeInvalidArgument , "Bad request" , http . StatusBadRequest )
2025-12-18 00:02:50 +01:00
return
}
org , err := org . CreateOrg ( r . Context ( ) , db , userID , req . Name , req . Slug )
if err != nil {
2025-12-18 00:11:30 +01:00
errors . LogError ( r , err , "Failed to create org" )
errors . WriteError ( w , errors . CodeInternal , "Server error" , http . StatusInternalServerError )
2025-12-18 00:02:50 +01:00
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 )
}
2026-01-09 17:01:41 +01:00
func listFilesHandler ( w http . ResponseWriter , r * http . Request , db * database . DB ) {
// Org ID is provided by middleware.Org
2026-01-11 03:30:31 +01:00
orgID := r . Context ( ) . Value ( middleware . OrgKey ) . ( uuid . UUID )
2026-01-11 05:01:52 +01:00
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
}
2026-01-09 17:01:41 +01:00
// Query params: path, q (search), page, pageSize
path := r . URL . Query ( ) . Get ( "path" )
if path == "" {
path = "/"
}
2026-01-27 01:40:36 +01:00
path , err = sanitizePath ( path )
if err != nil {
errors . WriteError ( w , errors . CodeInvalidArgument , "Invalid path" , http . StatusBadRequest )
return
}
2026-01-09 17:01:41 +01:00
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 )
}
2026-01-11 05:01:52 +01:00
files , err := db . GetOrgFiles ( r . Context ( ) , orgID , userID , path , q , page , pageSize )
2026-01-09 17:01:41 +01:00
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 { } {
2026-01-10 00:26:34 +01:00
"id" : f . ID . String ( ) ,
2026-01-09 17:01:41 +01:00
"name" : f . Name ,
"path" : f . Path ,
"type" : f . Type ,
"size" : f . Size ,
"lastModified" : f . LastModified . UTC ( ) . Format ( time . RFC3339 ) ,
} )
2025-12-18 00:02:50 +01:00
}
w . Header ( ) . Set ( "Content-Type" , "application/json" )
2026-01-09 17:01:41 +01:00
json . NewEncoder ( w ) . Encode ( out )
2025-12-18 00:02:50 +01:00
}
2026-01-11 17:39:12 +01:00
func viewerHandler ( w http . ResponseWriter , r * http . Request , db * database . DB , jwtManager * jwt . Manager , auditLogger * audit . Logger ) {
2026-01-09 20:26:55 +01:00
userIDStr , _ := middleware . GetUserID ( r . Context ( ) )
2025-12-18 00:02:50 +01:00
userID , _ := uuid . Parse ( userIDStr )
2026-01-11 03:30:31 +01:00
orgID := r . Context ( ) . Value ( middleware . OrgKey ) . ( uuid . UUID )
2026-01-11 17:39:12 +01:00
sessionObj , _ := middleware . GetSession ( r . Context ( ) )
2025-12-18 00:02:50 +01:00
fileId := chi . URLParam ( r , "fileId" )
2026-01-10 02:06:03 +01:00
// 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
}
2026-01-26 04:17:38 +01:00
// Check if it's a folder - cannot view folders
if file . Type == "folder" {
errors . WriteError ( w , errors . CodeInvalidArgument , "Cannot view folders" , http . StatusBadRequest )
return
}
2025-12-18 00:02:50 +01:00
// Log activity
db . LogActivity ( r . Context ( ) , userID , orgID , & fileId , "view_file" , map [ string ] interface { } { } )
2026-01-10 05:21:43 +01:00
// Build download URL with proper URL encoding using the request's scheme and host
scheme := "https"
2026-01-10 15:58:14 +01:00
if proto := r . Header . Get ( "X-Forwarded-Proto" ) ; proto != "" {
scheme = proto
} else if r . TLS == nil {
2026-01-10 05:21:43 +01:00
scheme = "http"
}
host := r . Host
if host == "" {
host = "go.b0esche.cloud"
}
2026-01-11 17:39:12 +01:00
// 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
}
2026-01-11 17:54:01 +01:00
downloadPath := fmt . Sprintf ( "%s://%s/orgs/%s/files/download?path=%s&token=%s" , scheme , host , orgID . String ( ) , url . QueryEscape ( file . Path ) , url . QueryEscape ( viewerToken ) )
2026-01-10 02:06:03 +01:00
2026-01-12 00:22:12 +01:00
// Determine file type based on extension
2026-01-10 02:06:03 +01:00
isPdf := strings . HasSuffix ( strings . ToLower ( file . Name ) , ".pdf" )
2026-01-12 00:22:12 +01:00
mimeType := getMimeType ( file . Name )
2026-01-10 02:06:03 +01:00
2026-01-11 17:39:12 +01:00
viewerSession := struct {
2025-12-18 00:02:50 +01:00
ViewUrl string ` json:"viewUrl" `
2026-01-10 04:48:28 +01:00
Token string ` json:"token" `
2025-12-18 00:02:50 +01:00
Capabilities struct {
2026-01-12 00:22:12 +01:00
CanEdit bool ` json:"canEdit" `
CanAnnotate bool ` json:"canAnnotate" `
IsPdf bool ` json:"isPdf" `
MimeType string ` json:"mimeType" `
2025-12-18 00:02:50 +01:00
} ` json:"capabilities" `
2026-01-13 16:45:57 +01:00
FileInfo struct {
Name string ` json:"name" `
Size int64 ` json:"size" `
LastModified string ` json:"lastModified" `
ModifiedByName string ` json:"modifiedByName" `
} ` json:"fileInfo" `
2025-12-18 00:02:50 +01:00
ExpiresAt string ` json:"expiresAt" `
} {
2026-01-10 04:48:28 +01:00
ViewUrl : downloadPath ,
2026-01-11 17:39:12 +01:00
Token : viewerToken , // Long-lived JWT token for authenticating file download
2025-12-18 00:02:50 +01:00
Capabilities : struct {
2026-01-12 00:22:12 +01:00
CanEdit bool ` json:"canEdit" `
CanAnnotate bool ` json:"canAnnotate" `
IsPdf bool ` json:"isPdf" `
MimeType string ` json:"mimeType" `
} { CanEdit : false , CanAnnotate : isPdf , IsPdf : isPdf , MimeType : mimeType } ,
2026-01-13 16:45:57 +01:00
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 ,
} ,
2026-01-11 17:39:12 +01:00
ExpiresAt : time . Now ( ) . Add ( 24 * time . Hour ) . UTC ( ) . Format ( time . RFC3339 ) ,
2025-12-18 00:02:50 +01:00
}
2026-01-12 00:22:12 +01:00
fmt . Printf ( "[VIEWER-SESSION] orgId=%s, fileId=%s, token_included=yes, isPdf=%v, mimeType=%s\n" , orgID . String ( ) , fileId , isPdf , mimeType )
2026-01-11 17:39:12 +01:00
2025-12-18 00:02:50 +01:00
w . Header ( ) . Set ( "Content-Type" , "application/json" )
2026-01-11 17:39:12 +01:00
json . NewEncoder ( w ) . Encode ( viewerSession )
2025-12-18 00:02:50 +01:00
}
2026-01-10 01:39:15 +01:00
// userViewerHandler serves a viewer session for personal workspace files
2026-01-11 17:39:12 +01:00
func userViewerHandler ( w http . ResponseWriter , r * http . Request , db * database . DB , jwtManager * jwt . Manager , auditLogger * audit . Logger ) {
2026-01-10 01:39:15 +01:00
userIDStr , _ := middleware . GetUserID ( r . Context ( ) )
userID , _ := uuid . Parse ( userIDStr )
2026-01-11 17:39:12 +01:00
sessionObj , _ := middleware . GetSession ( r . Context ( ) )
2026-01-10 01:39:15 +01:00
fileId := chi . URLParam ( r , "fileId" )
2026-01-10 02:06:03 +01:00
// 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
}
2026-01-26 04:17:38 +01:00
// Check if it's a folder - cannot view folders
if file . Type == "folder" {
errors . WriteError ( w , errors . CodeInvalidArgument , "Cannot view folders" , http . StatusBadRequest )
return
}
2026-01-10 02:06:03 +01:00
// Optionally log activity without org id
db . LogActivity ( r . Context ( ) , userID , uuid . Nil , & fileId , "view_user_file" , map [ string ] interface { } { } )
2026-01-10 05:21:43 +01:00
// Build download URL with proper URL encoding using the request's scheme and host
scheme := "https"
2026-01-10 15:58:14 +01:00
if proto := r . Header . Get ( "X-Forwarded-Proto" ) ; proto != "" {
scheme = proto
} else if r . TLS == nil {
2026-01-10 05:21:43 +01:00
scheme = "http"
}
host := r . Host
if host == "" {
host = "go.b0esche.cloud"
}
2026-01-11 17:39:12 +01:00
// 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
}
2026-01-11 17:54:01 +01:00
downloadPath := fmt . Sprintf ( "%s://%s/user/files/download?path=%s&token=%s" , scheme , host , url . QueryEscape ( file . Path ) , url . QueryEscape ( viewerToken ) )
2026-01-10 02:06:03 +01:00
2026-01-12 00:22:12 +01:00
// Determine file type based on extension
2026-01-10 02:06:03 +01:00
isPdf := strings . HasSuffix ( strings . ToLower ( file . Name ) , ".pdf" )
2026-01-12 00:22:12 +01:00
mimeType := getMimeType ( file . Name )
2026-01-10 02:06:03 +01:00
2026-01-11 17:39:12 +01:00
viewerSession := struct {
2026-01-10 01:39:15 +01:00
ViewUrl string ` json:"viewUrl" `
2026-01-10 04:48:28 +01:00
Token string ` json:"token" `
2026-01-10 01:39:15 +01:00
Capabilities struct {
2026-01-12 00:22:12 +01:00
CanEdit bool ` json:"canEdit" `
CanAnnotate bool ` json:"canAnnotate" `
IsPdf bool ` json:"isPdf" `
MimeType string ` json:"mimeType" `
2026-01-10 01:39:15 +01:00
} ` json:"capabilities" `
2026-01-13 16:45:57 +01:00
FileInfo struct {
Name string ` json:"name" `
Size int64 ` json:"size" `
LastModified string ` json:"lastModified" `
ModifiedByName string ` json:"modifiedByName" `
} ` json:"fileInfo" `
2026-01-10 01:39:15 +01:00
ExpiresAt string ` json:"expiresAt" `
} {
2026-01-10 04:48:28 +01:00
ViewUrl : downloadPath ,
2026-01-11 17:39:12 +01:00
Token : viewerToken , // Long-lived JWT token for authenticating file download
2026-01-10 01:39:15 +01:00
Capabilities : struct {
2026-01-12 00:22:12 +01:00
CanEdit bool ` json:"canEdit" `
CanAnnotate bool ` json:"canAnnotate" `
IsPdf bool ` json:"isPdf" `
MimeType string ` json:"mimeType" `
2026-01-10 01:39:15 +01:00
} {
CanEdit : false ,
2026-01-10 02:06:03 +01:00
CanAnnotate : isPdf ,
IsPdf : isPdf ,
2026-01-12 00:22:12 +01:00
MimeType : mimeType ,
2026-01-10 01:39:15 +01:00
} ,
2026-01-13 16:45:57 +01:00
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 ,
} ,
2026-01-11 17:39:12 +01:00
ExpiresAt : time . Now ( ) . Add ( 24 * time . Hour ) . UTC ( ) . Format ( time . RFC3339 ) ,
2026-01-10 01:39:15 +01:00
}
2026-01-12 00:22:12 +01:00
fmt . Printf ( "[VIEWER-SESSION] userId=%s, fileId=%s, token_included=yes, isPdf=%v, mimeType=%s\n" , userID . String ( ) , fileId , isPdf , mimeType )
2026-01-11 17:39:12 +01:00
2026-01-10 01:39:15 +01:00
w . Header ( ) . Set ( "Content-Type" , "application/json" )
2026-01-11 17:39:12 +01:00
json . NewEncoder ( w ) . Encode ( viewerSession )
2026-01-10 01:39:15 +01:00
}
2025-12-18 00:02:50 +01:00
func editorHandler ( w http . ResponseWriter , r * http . Request , db * database . DB , auditLogger * audit . Logger ) {
2026-01-09 20:26:55 +01:00
userIDStr , _ := middleware . GetUserID ( r . Context ( ) )
2025-12-18 00:02:50 +01:00
userID , _ := uuid . Parse ( userIDStr )
2026-01-11 03:30:31 +01:00
orgID := r . Context ( ) . Value ( middleware . OrgKey ) . ( uuid . UUID )
2025-12-18 00:02:50 +01:00
fileId := chi . URLParam ( r , "fileId" )
2026-01-14 12:23:31 +01:00
fmt . Printf ( "[EDITOR] Starting editor session for file=%s user=%s org=%s\n" , fileId , userIDStr , orgID . String ( ) )
2026-01-14 12:09:25 +01:00
// 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" )
2026-01-14 12:23:31 +01:00
fmt . Printf ( "[EDITOR] Starting user editor session for file=%s user=%s\n" , fileId , userIDStr )
2026-01-10 04:48:28 +01:00
// 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
}
2026-01-14 12:09:25 +01:00
// Verify user owns this file
if file . UserID == nil || * file . UserID != userID {
errors . WriteError ( w , errors . CodePermissionDenied , "Access denied" , http . StatusForbidden )
return
}
2025-12-18 00:02:50 +01:00
// Log activity
2026-01-14 12:09:25 +01:00
db . LogActivity ( r . Context ( ) , userID , uuid . Nil , & fileId , "edit_file" , map [ string ] interface { } { } )
2025-12-18 00:02:50 +01:00
2026-01-14 12:09:25 +01:00
// Generate WOPI access token (1 hour duration)
token , _ := middleware . GetToken ( r . Context ( ) )
2026-01-10 04:48:28 +01:00
2026-01-14 12:09:25 +01:00
// Build WOPISrc URL
wopiSrc := fmt . Sprintf ( "https://go.b0esche.cloud/wopi/files/%s?access_token=%s" , fileId , token )
2026-01-10 04:48:28 +01:00
2026-01-14 12:09:25 +01:00
// Build Collabora editor URL
collaboraUrl := fmt . Sprintf ( "https://of.b0esche.cloud/lool/dist/mobile/cool.html?WOPISrc=%s" , url . QueryEscape ( wopiSrc ) )
2026-01-14 12:23:31 +01:00
fmt . Printf ( "[EDITOR] Built user URLs: wopiSrc=%s collaboraUrl=%s\n" , wopiSrc , collaboraUrl )
2026-01-14 12:09:25 +01:00
// Check if user can edit (for now, all users can edit their own files)
readOnly := false
2026-01-10 05:00:18 +01:00
2025-12-18 00:02:50 +01:00
session := struct {
EditUrl string ` json:"editUrl" `
2026-01-10 05:00:18 +01:00
Token string ` json:"token" `
2025-12-18 00:02:50 +01:00
ReadOnly bool ` json:"readOnly" `
ExpiresAt string ` json:"expiresAt" `
} {
2026-01-10 04:48:28 +01:00
EditUrl : collaboraUrl ,
2026-01-10 05:00:18 +01:00
Token : token , // JWT token for authenticating file access
2026-01-10 04:48:28 +01:00
ReadOnly : readOnly ,
ExpiresAt : time . Now ( ) . Add ( 15 * time . Minute ) . UTC ( ) . Format ( time . RFC3339 ) ,
2025-12-18 00:02:50 +01:00
}
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 ) {
2026-01-09 20:26:55 +01:00
userIDStr , _ := middleware . GetUserID ( r . Context ( ) )
2025-12-18 00:02:50 +01:00
userID , _ := uuid . Parse ( userIDStr )
2026-01-11 03:30:31 +01:00
orgID := r . Context ( ) . Value ( middleware . OrgKey ) . ( uuid . UUID )
2025-12-18 00:02:50 +01:00
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 {
2025-12-18 00:11:30 +01:00
errors . WriteError ( w , errors . CodeInvalidArgument , "Bad request" , http . StatusBadRequest )
2025-12-18 00:02:50 +01:00
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 ) {
2026-01-11 03:30:31 +01:00
orgID := r . Context ( ) . Value ( middleware . OrgKey ) . ( uuid . UUID )
2025-12-18 00:02:50 +01:00
activities , err := db . GetOrgActivities ( r . Context ( ) , orgID , 50 )
if err != nil {
2025-12-18 00:11:30 +01:00
errors . LogError ( r , err , "Failed to get org activities" )
errors . WriteError ( w , errors . CodeInternal , "Server error" , http . StatusInternalServerError )
2025-12-18 00:02:50 +01:00
return
}
w . Header ( ) . Set ( "Content-Type" , "application/json" )
json . NewEncoder ( w ) . Encode ( activities )
}
2026-01-23 23:48:10 +01:00
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 {
2026-01-24 02:30:15 +01:00
ID string ` json:"id" `
Username string ` json:"username" `
DisplayName * string ` json:"displayName" `
Email string ` json:"email" `
2026-01-23 23:48:10 +01:00
}
2025-12-18 00:02:50 +01:00
func listMembersHandler ( w http . ResponseWriter , r * http . Request , db * database . DB ) {
2026-01-11 03:30:31 +01:00
orgID := r . Context ( ) . Value ( middleware . OrgKey ) . ( uuid . UUID )
2025-12-18 00:02:50 +01:00
2026-01-23 23:21:23 +01:00
members , err := db . GetOrgMembersWithUsers ( r . Context ( ) , orgID )
2025-12-18 00:02:50 +01:00
if err != nil {
2025-12-18 00:11:30 +01:00
errors . LogError ( r , err , "Failed to get org members" )
errors . WriteError ( w , errors . CodeInternal , "Server error" , http . StatusInternalServerError )
2025-12-18 00:02:50 +01:00
return
}
2026-01-23 23:48:10 +01:00
// 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 {
2026-01-24 02:30:34 +01:00
ID : m . User . ID . String ( ) ,
Username : m . User . Username ,
2026-01-24 02:30:15 +01:00
DisplayName : func ( ) * string {
if m . User . DisplayName == "" {
return nil
}
return & m . User . DisplayName
} ( ) ,
2026-01-24 02:30:34 +01:00
Email : m . User . Email ,
2026-01-23 23:48:10 +01:00
} ,
} )
}
2025-12-18 00:02:50 +01:00
w . Header ( ) . Set ( "Content-Type" , "application/json" )
2026-01-23 23:48:10 +01:00
json . NewEncoder ( w ) . Encode ( response )
2025-12-18 00:02:50 +01:00
}
func updateMemberRoleHandler ( w http . ResponseWriter , r * http . Request , db * database . DB , auditLogger * audit . Logger ) {
2026-01-11 03:30:31 +01:00
orgID := r . Context ( ) . Value ( middleware . OrgKey ) . ( uuid . UUID )
2025-12-18 00:02:50 +01:00
userIDStr := chi . URLParam ( r , "userId" )
userID , err := uuid . Parse ( userIDStr )
if err != nil {
2025-12-18 00:11:30 +01:00
errors . WriteError ( w , errors . CodeInvalidArgument , "Invalid user ID" , http . StatusBadRequest )
2025-12-18 00:02:50 +01:00
return
}
var req struct {
Role string ` json:"role" `
}
if err := json . NewDecoder ( r . Body ) . Decode ( & req ) ; err != nil {
2025-12-18 00:11:30 +01:00
errors . WriteError ( w , errors . CodeInvalidArgument , "Bad request" , http . StatusBadRequest )
2025-12-18 00:02:50 +01:00
return
}
if err := db . UpdateMemberRole ( r . Context ( ) , orgID , userID , req . Role ) ; err != nil {
2025-12-18 00:11:30 +01:00
errors . LogError ( r , err , "Failed to update member role" )
errors . WriteError ( w , errors . CodeInternal , "Server error" , http . StatusInternalServerError )
2025-12-18 00:02:50 +01:00
return
}
w . WriteHeader ( http . StatusOK )
w . Write ( [ ] byte ( ` { "status": "ok"} ` ) )
}
2026-01-23 23:21:23 +01:00
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 {
2026-01-23 23:21:46 +01:00
OrgID : & orgID ,
Action : "remove_member" ,
2026-01-23 23:21:23 +01:00
Resource : & resource ,
2026-01-23 23:21:46 +01:00
Success : true ,
2026-01-23 23:21:23 +01:00
} )
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 {
2026-01-23 23:21:46 +01:00
UserID : & invitedBy ,
OrgID : & orgID ,
Action : "create_invitation" ,
2026-01-23 23:21:23 +01:00
Resource : & req . Username ,
2026-01-23 23:21:46 +01:00
Success : true ,
2026-01-23 23:21:23 +01:00
} )
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 {
2026-01-23 23:21:46 +01:00
OrgID : & orgID ,
Action : "cancel_invitation" ,
2026-01-23 23:21:23 +01:00
Resource : & resource ,
2026-01-23 23:21:46 +01:00
Success : true ,
2026-01-23 23:21:23 +01:00
} )
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 )
}
2026-01-23 23:48:10 +01:00
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" `
}
2026-01-23 23:21:23 +01:00
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
}
2026-01-23 23:48:10 +01:00
// 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 {
2026-01-24 02:30:34 +01:00
ID : req . User . ID . String ( ) ,
Username : req . User . Username ,
2026-01-24 02:30:15 +01:00
DisplayName : func ( ) * string {
if req . User . DisplayName == "" {
return nil
}
return & req . User . DisplayName
} ( ) ,
2026-01-24 02:30:34 +01:00
Email : req . User . Email ,
2026-01-23 23:48:10 +01:00
} ,
} )
}
2026-01-23 23:21:23 +01:00
w . Header ( ) . Set ( "Content-Type" , "application/json" )
2026-01-23 23:48:10 +01:00
json . NewEncoder ( w ) . Encode ( response )
2026-01-23 23:21:23 +01:00
}
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 {
2026-01-23 23:21:46 +01:00
OrgID : & orgID ,
Action : "accept_join_request" ,
2026-01-23 23:21:23 +01:00
Resource : & resource ,
2026-01-23 23:21:46 +01:00
Success : true ,
2026-01-23 23:21:23 +01:00
} )
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 {
2026-01-23 23:21:46 +01:00
OrgID : & orgID ,
Action : "reject_join_request" ,
2026-01-23 23:21:23 +01:00
Resource : & resource ,
2026-01-23 23:21:46 +01:00
Success : true ,
2026-01-23 23:21:23 +01:00
} )
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 )
}
2025-12-18 00:02:50 +01:00
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 )
}
2026-01-08 13:07:07 +01:00
// 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 { }
2026-01-09 19:53:09 +01:00
token , err := jwtManager . Generate ( user . ID . String ( ) , orgIDs , session . ID . String ( ) )
2026-01-08 13:07:07 +01:00
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
2026-01-09 19:53:09 +01:00
token , err := jwtManager . Generate ( user . ID . String ( ) , orgIDs , session . ID . String ( ) )
2026-01-08 13:07:07 +01:00
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" )
2026-02-01 00:24:19 +01:00
// 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
}
2026-01-08 13:07:07 +01:00
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
2026-01-09 19:53:09 +01:00
token , err := jwtManager . Generate ( user . ID . String ( ) , orgIDs , session . ID . String ( ) )
2026-01-08 13:07:07 +01:00
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 ,
} )
}
2026-01-09 17:01:41 +01:00
// userFilesHandler returns files for the authenticated user's personal workspace.
func userFilesHandler ( w http . ResponseWriter , r * http . Request , db * database . DB ) {
2026-01-09 20:26:55 +01:00
userIDStr , ok := middleware . GetUserID ( r . Context ( ) )
2026-01-09 17:01:41 +01:00
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 { } {
2026-01-10 00:26:34 +01:00
"id" : f . ID . String ( ) ,
2026-01-09 17:01:41 +01:00
"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.
2026-01-10 22:58:35 +01:00
func createOrgFileHandler ( w http . ResponseWriter , r * http . Request , db * database . DB , auditLogger * audit . Logger , cfg * config . Config ) {
2026-01-11 03:30:31 +01:00
orgID := r . Context ( ) . Value ( middleware . OrgKey ) . ( uuid . UUID )
2026-01-09 20:26:55 +01:00
userIDStr , _ := middleware . GetUserID ( r . Context ( ) )
2026-01-09 17:01:41 +01:00
userID , _ := uuid . Parse ( userIDStr )
2026-01-09 17:32:16 +01:00
var f * database . File
var err error
2026-01-09 17:01:41 +01:00
// 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
2026-01-09 17:32:16 +01:00
if err = r . ParseMultipartForm ( 32 << 20 ) ; err != nil {
2026-01-09 17:01:41 +01:00
errors . WriteError ( w , errors . CodeInvalidArgument , "Bad multipart request" , http . StatusBadRequest )
return
}
parentPath := r . FormValue ( "path" )
if parentPath == "" {
parentPath = "/"
}
2026-01-27 01:40:36 +01:00
parentPath , err = sanitizePath ( parentPath )
if err != nil {
errors . WriteError ( w , errors . CodeInvalidArgument , "Invalid path" , http . StatusBadRequest )
return
}
2026-01-09 17:32:16 +01:00
var file multipart . File
var header * multipart . FileHeader
file , header , err = r . FormFile ( "file" )
2026-01-09 17:01:41 +01:00
if err != nil {
errors . WriteError ( w , errors . CodeInvalidArgument , "Missing file" , http . StatusBadRequest )
return
}
defer file . Close ( )
2026-01-10 22:58:35 +01:00
// Read file into memory
2026-01-09 17:32:16 +01:00
data , err := io . ReadAll ( file )
if err != nil {
errors . LogError ( r , err , "Failed to read uploaded file" )
2026-01-09 17:01:41 +01:00
errors . WriteError ( w , errors . CodeInternal , "Server error" , http . StatusInternalServerError )
return
}
2026-01-09 17:32:16 +01:00
storedPath := filepath . ToSlash ( filepath . Join ( parentPath , header . Filename ) )
if ! strings . HasPrefix ( storedPath , "/" ) {
storedPath = "/" + storedPath
}
written := int64 ( len ( data ) )
2026-01-10 21:46:16 +01:00
2026-01-10 22:58:35 +01:00
// 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" )
2026-01-10 21:46:12 +01:00
errors . WriteError ( w , errors . CodeInternal , "Storage not configured" , http . StatusInternalServerError )
2026-01-09 17:01:41 +01:00
return
}
2026-01-10 21:46:16 +01:00
2026-01-10 22:58:35 +01:00
// Upload to user's Nextcloud space under /orgs/<orgID>/
2026-01-10 21:46:12 +01:00
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 )
2026-01-09 17:01:41 +01:00
return
}
2026-01-09 17:32:16 +01:00
f , err = db . CreateFile ( r . Context ( ) , & orgID , & userID , header . Filename , storedPath , "file" , written )
2026-01-09 17:01:41 +01:00
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
}
2026-01-09 17:32:16 +01:00
f , err = db . CreateFile ( r . Context ( ) , & orgID , & userID , req . Name , req . Path , req . Type , req . Size )
2026-01-09 17:01:41 +01:00
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
2026-01-10 22:58:35 +01:00
func deleteOrgFileHandler ( w http . ResponseWriter , r * http . Request , db * database . DB , auditLogger * audit . Logger , cfg * config . Config ) {
2026-01-11 03:30:31 +01:00
orgID := r . Context ( ) . Value ( middleware . OrgKey ) . ( uuid . UUID )
2026-01-09 20:26:55 +01:00
userIDStr , _ := middleware . GetUserID ( r . Context ( ) )
2026-01-09 17:01:41 +01:00
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
}
2026-01-27 01:40:36 +01:00
var err error
req . Path , err = sanitizePath ( req . Path )
if err != nil {
errors . WriteError ( w , errors . CodeInvalidArgument , "Invalid path" , http . StatusBadRequest )
return
}
2026-01-10 22:58:35 +01:00
// 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 {
2026-01-09 18:58:09 +01:00
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
2026-01-09 17:01:41 +01:00
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
2026-01-10 22:58:35 +01:00
func deleteOrgFilePostHandler ( w http . ResponseWriter , r * http . Request , db * database . DB , auditLogger * audit . Logger , cfg * config . Config ) {
deleteOrgFileHandler ( w , r , db , auditLogger , cfg )
2026-01-09 17:01:41 +01:00
}
2026-01-11 23:10:14 +01:00
// 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
}
2026-01-27 01:40:36 +01:00
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
}
2026-01-11 23:47:14 +01:00
// Get source file details before moving
sourceFiles , err := db . GetOrgFiles ( r . Context ( ) , orgID , userID , "/" , "" , 0 , 1000 )
if err != nil {
errors . LogError ( r , err , "Failed to get org files" )
errors . WriteError ( w , errors . CodeInternal , "Server error" , http . StatusInternalServerError )
return
}
var sourceFile * database . File
for i := range sourceFiles {
if sourceFiles [ i ] . Path == req . SourcePath {
sourceFile = & sourceFiles [ i ]
break
}
}
if sourceFile == nil {
errors . WriteError ( w , errors . CodeInvalidArgument , "Source file not found" , http . StatusNotFound )
return
}
2026-01-14 18:26:02 +01:00
// 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
}
}
2026-01-12 00:09:39 +01:00
// Determine new file name - check if target is a folder
2026-01-11 23:47:14 +01:00
var newPath string
2026-01-12 00:09:39 +01:00
var targetFile * database . File
for i := range sourceFiles {
if sourceFiles [ i ] . Path == req . TargetPath {
targetFile = & sourceFiles [ i ]
break
}
}
if targetFile != nil && targetFile . Type == "folder" {
// Target is a folder, move file into it
newPath = path . Join ( req . TargetPath , sourceFile . Name )
} else if targetFile == nil && strings . HasSuffix ( req . TargetPath , "/" ) {
// Target path doesn't exist but ends with /, treat as folder
2026-01-11 23:47:14 +01:00
newPath = path . Join ( req . TargetPath , sourceFile . Name )
} else {
// Moving/renaming to a specific path
newPath = req . TargetPath
}
2026-02-07 23:14:38 +01:00
// Determine new filename from the path
newName := path . Base ( newPath )
2026-01-11 23:10:14 +01:00
// 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 {
2026-02-07 23:35:48 +01:00
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
2026-01-11 23:10:14 +01:00
}
2026-02-07 23:14:38 +01:00
// 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
2026-01-11 23:47:14 +01:00
}
2026-01-11 23:10:14 +01:00
auditLogger . Log ( r . Context ( ) , audit . Entry {
UserID : & userID ,
OrgID : & orgID ,
Action : "move_file" ,
Success : true ,
} )
w . WriteHeader ( http . StatusOK )
w . Write ( [ ] byte ( ` { "status":"ok"} ` ) )
}
2026-01-09 17:01:41 +01:00
// createUserFileHandler creates a file or folder record for the authenticated user's personal workspace.
2026-01-10 22:58:35 +01:00
func createUserFileHandler ( w http . ResponseWriter , r * http . Request , db * database . DB , auditLogger * audit . Logger , cfg * config . Config ) {
2026-01-09 20:26:55 +01:00
userIDStr , ok := middleware . GetUserID ( r . Context ( ) )
2026-01-09 17:01:41 +01:00
if ! ok || userIDStr == "" {
errors . WriteError ( w , errors . CodeUnauthenticated , "Unauthorized" , http . StatusUnauthorized )
return
}
userID , _ := uuid . Parse ( userIDStr )
2026-01-09 17:32:16 +01:00
var f * database . File
var err error
2026-01-09 17:01:41 +01:00
// Support multipart uploads for file content or JSON for folders
contentType := r . Header . Get ( "Content-Type" )
if strings . HasPrefix ( contentType , "multipart/form-data" ) {
2026-01-09 17:32:16 +01:00
if err = r . ParseMultipartForm ( 32 << 20 ) ; err != nil {
2026-01-09 17:01:41 +01:00
errors . WriteError ( w , errors . CodeInvalidArgument , "Bad multipart request" , http . StatusBadRequest )
return
}
parentPath := r . FormValue ( "path" )
if parentPath == "" {
parentPath = "/"
}
2026-01-27 01:40:36 +01:00
parentPath , err = sanitizePath ( parentPath )
if err != nil {
errors . WriteError ( w , errors . CodeInvalidArgument , "Invalid path" , http . StatusBadRequest )
return
}
2026-01-09 17:32:16 +01:00
var file multipart . File
var header * multipart . FileHeader
file , header , err = r . FormFile ( "file" )
2026-01-09 17:01:41 +01:00
if err != nil {
errors . WriteError ( w , errors . CodeInvalidArgument , "Missing file" , http . StatusBadRequest )
return
}
defer file . Close ( )
2026-01-10 22:58:35 +01:00
// Read file into memory to allow WebDAV upload
2026-01-09 17:32:16 +01:00
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 ) )
2026-01-10 21:11:53 +01:00
fmt . Printf ( "[DEBUG] Upload: user=%s, file=%s, size=%d, path=%s\n" , userID . String ( ) , header . Filename , len ( data ) , storedPath )
2026-01-10 21:46:16 +01:00
2026-01-10 22:58:35 +01:00
// 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" )
2026-01-10 21:46:12 +01:00
errors . WriteError ( w , errors . CodeInternal , "Storage not configured" , http . StatusInternalServerError )
2026-01-09 17:01:41 +01:00
return
}
2026-01-10 21:46:16 +01:00
2026-01-10 22:58:35 +01:00
// 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 {
2026-01-10 21:46:12 +01:00
errors . LogError ( r , err , "WebDAV upload failed" )
errors . WriteError ( w , errors . CodeInternal , "Failed to upload file to storage" , http . StatusInternalServerError )
2026-01-09 17:01:41 +01:00
return
}
2026-01-09 17:32:16 +01:00
f , err = db . CreateFile ( r . Context ( ) , nil , & userID , header . Filename , storedPath , "file" , written )
2026-01-09 17:01:41 +01:00
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
}
2026-01-09 17:32:16 +01:00
f , err = db . CreateFile ( r . Context ( ) , nil , & userID , req . Name , req . Path , req . Type , req . Size )
2026-01-09 17:01:41 +01:00
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
2026-01-10 22:58:35 +01:00
func deleteUserFilePostHandler ( w http . ResponseWriter , r * http . Request , db * database . DB , auditLogger * audit . Logger , cfg * config . Config ) {
deleteUserFileHandler ( w , r , db , auditLogger , cfg )
2026-01-09 17:01:41 +01:00
}
2026-01-12 00:01:47 +01:00
// 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
}
2026-01-27 01:40:36 +01:00
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
}
2026-01-12 00:01:47 +01:00
// Get source file details before moving
2026-01-15 13:39:47 +01:00
sourceFile , err := db . GetUserFileByPath ( r . Context ( ) , userID , req . SourcePath )
2026-01-12 00:01:47 +01:00
if err != nil {
2026-01-15 13:39:47 +01:00
if err == sql . ErrNoRows {
errors . WriteError ( w , errors . CodeInvalidArgument , "Source file not found" , http . StatusNotFound )
return
2026-01-12 00:01:47 +01:00
}
2026-01-15 13:39:47 +01:00
errors . LogError ( r , err , "Failed to get source file" )
errors . WriteError ( w , errors . CodeInternal , "Server error" , http . StatusInternalServerError )
2026-01-12 00:01:47 +01:00
return
}
2026-01-14 18:26:02 +01:00
// 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
}
}
2026-01-12 00:09:39 +01:00
// Determine new file name - check if target is a folder
2026-01-12 00:01:47 +01:00
var newPath string
2026-01-15 13:39:47 +01:00
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
2026-01-12 00:09:39 +01:00
}
2026-01-15 13:39:47 +01:00
// If err == sql.ErrNoRows, targetFile is nil
2026-01-12 00:09:39 +01:00
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
2026-01-12 00:01:47 +01:00
newPath = path . Join ( req . TargetPath , sourceFile . Name )
} else {
// Moving/renaming to a specific path
newPath = req . TargetPath
}
2026-02-07 23:14:38 +01:00
// Determine new filename from the path
newName := path . Base ( newPath )
2026-01-12 00:01:47 +01:00
// 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 {
2026-02-07 23:35:48 +01:00
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
2026-01-12 00:01:47 +01:00
}
2026-02-07 23:14:38 +01:00
// 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
2026-01-12 00:01:47 +01:00
}
auditLogger . Log ( r . Context ( ) , audit . Entry {
UserID : & userID ,
Action : "move_file" ,
Success : true ,
} )
w . WriteHeader ( http . StatusOK )
w . Write ( [ ] byte ( ` { "status":"ok"} ` ) )
}
2026-01-09 17:01:41 +01:00
// deleteUserFileHandler deletes a file/folder in user's personal workspace by path
2026-01-10 22:58:35 +01:00
func deleteUserFileHandler ( w http . ResponseWriter , r * http . Request , db * database . DB , auditLogger * audit . Logger , cfg * config . Config ) {
2026-01-09 20:26:55 +01:00
userIDStr , ok := middleware . GetUserID ( r . Context ( ) )
2026-01-09 17:01:41 +01:00
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
}
2026-01-27 01:40:36 +01:00
var err error
req . Path , err = sanitizePath ( req . Path )
if err != nil {
errors . WriteError ( w , errors . CodeInvalidArgument , "Invalid path" , http . StatusBadRequest )
return
}
2026-01-10 22:58:35 +01:00
// 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 {
2026-01-09 18:58:09 +01:00
errors . LogError ( r , err , "Failed to delete from Nextcloud (continuing anyway)" )
}
}
// Delete from database
2026-01-09 17:01:41 +01:00
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"} ` ) )
}
2026-01-09 18:58:09 +01:00
// downloadOrgFileHandler downloads a file from org workspace
2026-01-10 22:58:35 +01:00
func downloadOrgFileHandler ( w http . ResponseWriter , r * http . Request , db * database . DB , cfg * config . Config ) {
2026-01-11 03:30:31 +01:00
orgID := r . Context ( ) . Value ( middleware . OrgKey ) . ( uuid . UUID )
2026-01-10 22:58:35 +01:00
userIDStr , _ := middleware . GetUserID ( r . Context ( ) )
userID , _ := uuid . Parse ( userIDStr )
2026-01-09 18:58:09 +01:00
filePath := r . URL . Query ( ) . Get ( "path" )
if filePath == "" {
errors . WriteError ( w , errors . CodeInvalidArgument , "Missing path parameter" , http . StatusBadRequest )
return
}
2026-01-13 22:11:02 +01:00
// Sanitize path to prevent path traversal
filePath , err := sanitizePath ( filePath )
if err != nil {
errors . WriteError ( w , errors . CodeInvalidArgument , "Invalid path" , http . StatusBadRequest )
return
}
2026-01-10 22:58:35 +01:00
// 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" )
2026-01-10 21:46:12 +01:00
errors . WriteError ( w , errors . CodeInternal , "Storage not configured" , http . StatusInternalServerError )
2026-01-10 03:47:35 +01:00
return
}
2026-01-10 21:46:16 +01:00
2026-01-14 12:02:20 +01:00
// 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
}
2026-01-10 22:58:35 +01:00
// Download from user's Nextcloud space under /orgs/<orgID>/
2026-01-10 21:46:12 +01:00
rel := strings . TrimPrefix ( filePath , "/" )
remotePath := path . Join ( "/orgs" , orgID . String ( ) , rel )
2026-01-11 14:38:03 +01:00
resp , err := storageClient . Download ( r . Context ( ) , remotePath , r . Header . Get ( "Range" ) )
2026-01-10 03:47:35 +01:00
if err != nil {
2026-01-10 21:46:12 +01:00
errors . LogError ( r , err , "Failed to download from Nextcloud" )
2026-01-10 03:47:35 +01:00
errors . WriteError ( w , errors . CodeNotFound , "File not found" , http . StatusNotFound )
return
}
2026-01-11 14:38:03 +01:00
defer resp . Body . Close ( )
2026-01-10 21:46:12 +01:00
// Set appropriate headers for inline viewing
2026-01-10 03:47:35 +01:00
fileName := path . Base ( filePath )
2026-01-14 17:47:28 +01:00
// Determine content type based on file extension - use our getMimeType function
// which supports all common video/audio/image formats
contentType := getMimeType ( fileName )
2026-01-25 20:24:07 +01:00
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" )
2026-01-10 03:47:35 +01:00
w . Header ( ) . Set ( "Content-Disposition" , fmt . Sprintf ( "inline; filename=\"%s\"" , fileName ) )
2026-01-14 17:47:28 +01:00
w . Header ( ) . Set ( "Content-Type" , contentType )
2026-01-11 14:38:03 +01:00
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 )
2026-01-10 03:47:35 +01:00
}
2026-01-10 21:46:12 +01:00
// Stream the file
2026-01-11 14:38:03 +01:00
io . Copy ( w , resp . Body )
2026-01-10 21:46:12 +01:00
2026-01-09 18:58:09 +01:00
}
2026-01-14 12:02:20 +01:00
// 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 ( )
2026-01-14 12:11:25 +01:00
// Ensure folderPath ends with / for proper relative path calculation
if ! strings . HasSuffix ( folderPath , "/" ) {
folderPath += "/"
}
2026-01-14 12:02:20 +01:00
// 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
}
2026-01-09 18:58:09 +01:00
// downloadUserFileHandler downloads a file from user's personal workspace
2026-01-10 22:58:35 +01:00
func downloadUserFileHandler ( w http . ResponseWriter , r * http . Request , db * database . DB , cfg * config . Config ) {
2026-01-11 03:40:44 +01:00
// Try to get userID from context (Bearer token), fallback to query parameter
2026-01-09 20:26:55 +01:00
userIDStr , ok := middleware . GetUserID ( r . Context ( ) )
2026-01-09 18:58:09 +01:00
if ! ok || userIDStr == "" {
2026-01-11 03:40:44 +01:00
// Token might be in query parameter for PDF viewer compatibility
// This is acceptable since the token is still validated
2026-01-09 18:58:09 +01:00
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
}
2026-01-13 22:11:02 +01:00
// Sanitize path to prevent path traversal
filePath , err := sanitizePath ( filePath )
if err != nil {
errors . WriteError ( w , errors . CodeInvalidArgument , "Invalid path" , http . StatusBadRequest )
return
}
2026-01-10 05:21:43 +01:00
2026-01-10 22:58:35 +01:00
// 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" )
2026-01-10 21:46:12 +01:00
errors . WriteError ( w , errors . CodeInternal , "Storage not configured" , http . StatusInternalServerError )
2026-01-10 03:47:35 +01:00
return
}
2026-01-10 21:46:16 +01:00
2026-01-14 12:02:20 +01:00
// 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
}
2026-01-10 22:58:35 +01:00
// Download from user's personal Nextcloud space
remotePath := strings . TrimPrefix ( filePath , "/" )
2026-01-10 03:47:35 +01:00
2026-01-11 14:38:03 +01:00
resp , err := storageClient . Download ( r . Context ( ) , "/" + remotePath , r . Header . Get ( "Range" ) )
2026-01-10 03:47:35 +01:00
if err != nil {
2026-01-10 21:46:12 +01:00
errors . LogError ( r , err , "Failed to download from Nextcloud" )
2026-01-10 03:47:35 +01:00
errors . WriteError ( w , errors . CodeNotFound , "File not found" , http . StatusNotFound )
return
}
2026-01-11 14:38:03 +01:00
defer resp . Body . Close ( )
2026-01-10 21:46:12 +01:00
// Set appropriate headers for inline viewing
2026-01-10 03:47:35 +01:00
fileName := path . Base ( filePath )
2026-01-14 17:47:28 +01:00
// Determine content type based on file extension - use our getMimeType function
// which supports all common video/audio/image formats
contentType := getMimeType ( fileName )
2026-01-25 20:24:07 +01:00
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" )
2026-01-10 03:47:35 +01:00
w . Header ( ) . Set ( "Content-Disposition" , fmt . Sprintf ( "inline; filename=\"%s\"" , fileName ) )
2026-01-14 17:47:28 +01:00
w . Header ( ) . Set ( "Content-Type" , contentType )
2026-01-11 14:38:03 +01:00
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 )
2026-01-10 03:47:35 +01:00
}
2026-01-10 21:46:12 +01:00
// Stream the file
2026-01-11 14:38:03 +01:00
io . Copy ( w , resp . Body )
2026-01-10 21:46:12 +01:00
2026-01-09 18:58:09 +01:00
}
2026-01-12 00:22:12 +01:00
2026-01-14 12:02:20 +01:00
// 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 ( )
2026-01-14 12:11:25 +01:00
// Ensure folderPath ends with / for proper relative path calculation
if ! strings . HasSuffix ( folderPath , "/" ) {
folderPath += "/"
}
2026-01-14 12:02:20 +01:00
// 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
}
2026-01-12 00:22:12 +01:00
// getMimeType returns the MIME type based on file extension
func getMimeType ( filename string ) string {
lower := strings . ToLower ( filename )
switch {
2026-01-14 17:47:28 +01:00
// Documents
2026-01-12 00:22:12 +01:00
case strings . HasSuffix ( lower , ".pdf" ) :
return "application/pdf"
2026-01-14 17:47:28 +01:00
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
2026-01-12 00:22:12 +01:00
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"
2026-01-14 17:47:28 +01:00
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
2026-01-12 00:22:12 +01:00
case strings . HasSuffix ( lower , ".txt" ) :
return "text/plain"
2026-01-14 17:47:28 +01:00
case strings . HasSuffix ( lower , ".html" ) , strings . HasSuffix ( lower , ".htm" ) :
2026-01-12 00:22:12 +01:00
return "text/html"
2026-01-14 17:47:28 +01:00
case strings . HasSuffix ( lower , ".css" ) :
return "text/css"
case strings . HasSuffix ( lower , ".js" ) :
return "application/javascript"
2026-01-12 00:22:12 +01:00
case strings . HasSuffix ( lower , ".json" ) :
return "application/json"
2026-01-14 17:47:28 +01:00
case strings . HasSuffix ( lower , ".xml" ) :
return "application/xml"
2026-01-12 00:22:12 +01:00
case strings . HasSuffix ( lower , ".csv" ) :
return "text/csv"
2026-01-14 17:47:28 +01:00
// 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"
2026-01-12 00:22:12 +01:00
default :
return "application/octet-stream"
}
}
2026-01-24 21:06:18 +01:00
// File share handlers
func getFileShareLinkHandler ( w http . ResponseWriter , r * http . Request , db * database . DB ) {
2026-01-24 22:13:23 +01:00
userIDStr , _ := middleware . GetUserID ( r . Context ( ) )
userID , _ := uuid . Parse ( userIDStr )
2026-01-24 21:06:18 +01:00
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
}
2026-01-24 22:13:23 +01:00
// Check if file exists and belongs to org or is owned by user (for personal files)
2026-01-24 21:06:18 +01:00
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
}
2026-01-24 22:13:23 +01:00
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 {
2026-01-24 21:06:18 +01:00
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
2026-01-24 23:16:51 +01:00
errors . WriteError ( w , errors . CodeNotFound , "Share link not found" , http . StatusNotFound )
2026-01-24 21:06:18 +01:00
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"
}
2026-01-25 00:27:58 +01:00
host := "www.b0esche.cloud"
2026-01-25 18:50:02 +01:00
fullURL := fmt . Sprintf ( "%s://%s/share/%s" , scheme , host , link . Token )
2026-01-24 21:06:18 +01:00
w . Header ( ) . Set ( "Content-Type" , "application/json" )
json . NewEncoder ( w ) . Encode ( map [ string ] interface { } {
2026-01-24 23:16:51 +01:00
"shareUrl" : fullURL ,
"token" : link . Token ,
2026-01-24 21:06:18 +01:00
} )
}
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
}
2026-01-24 22:13:23 +01:00
// Check if file exists and belongs to org or is owned by user (for personal files)
2026-01-24 21:06:18 +01:00
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
}
2026-01-24 22:13:23 +01:00
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 {
2026-01-24 21:06:18 +01:00
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
2026-01-24 23:16:51 +01:00
token , err := storage . GenerateSecurePassword ( 48 )
2026-01-24 21:06:18 +01:00
if err != nil {
errors . LogError ( r , err , "Failed to generate token" )
errors . WriteError ( w , errors . CodeInternal , "Server error" , http . StatusInternalServerError )
return
}
2026-01-24 22:56:10 +01:00
link , err := db . CreateFileShareLink ( r . Context ( ) , token , fileUUID , & orgID , userID )
2026-01-24 21:06:18 +01:00
if err != nil {
errors . LogError ( r , err , "Failed to create share link" )
errors . WriteError ( w , errors . CodeInternal , "Server error" , http . StatusInternalServerError )
return
}
2026-01-24 23:45:08 +01:00
log . Printf ( "Share link created: user_id=%s, file_id=%s, org_id=%v" , userID , fileUUID , link . OrgID )
2026-01-24 21:06:18 +01:00
// Build full URL
scheme := "https"
if proto := r . Header . Get ( "X-Forwarded-Proto" ) ; proto != "" {
scheme = proto
} else if r . TLS == nil {
scheme = "http"
}
2026-01-25 00:27:58 +01:00
host := "www.b0esche.cloud"
2026-01-25 18:50:02 +01:00
fullURL := fmt . Sprintf ( "%s://%s/share/%s" , scheme , host , link . Token )
2026-01-24 21:06:18 +01:00
w . Header ( ) . Set ( "Content-Type" , "application/json" )
json . NewEncoder ( w ) . Encode ( map [ string ] interface { } {
2026-01-24 23:16:51 +01:00
"shareUrl" : fullURL ,
"token" : link . Token ,
2026-01-24 21:06:18 +01:00
} )
}
func revokeFileShareLinkHandler ( w http . ResponseWriter , r * http . Request , db * database . DB ) {
2026-01-24 22:13:23 +01:00
userIDStr , _ := middleware . GetUserID ( r . Context ( ) )
userID , _ := uuid . Parse ( userIDStr )
2026-01-24 21:06:18 +01:00
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
}
2026-01-24 22:13:23 +01:00
// Check if file exists and belongs to org or is owned by user (for personal files)
2026-01-24 21:06:18 +01:00
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
}
2026-01-24 22:13:23 +01:00
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 {
2026-01-24 21:06:18 +01:00
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)
2026-01-24 22:56:10 +01:00
var orgIDs [ ] string
if link . OrgID != nil {
orgIDs = [ ] string { link . OrgID . String ( ) }
}
viewerToken , err := jwtManager . GenerateWithDuration ( "" , orgIDs , "" , time . Hour )
2026-01-24 21:06:18 +01:00
if err != nil {
errors . LogError ( r , err , "Failed to generate viewer token" )
errors . WriteError ( w , errors . CodeInternal , "Server error" , http . StatusInternalServerError )
return
}
2026-01-25 00:57:55 +01:00
// Build URLs
2026-01-24 21:06:18 +01:00
scheme := "https"
if proto := r . Header . Get ( "X-Forwarded-Proto" ) ; proto != "" {
scheme = proto
} else if r . TLS == nil {
scheme = "http"
}
2026-01-25 20:06:38 +01:00
host := r . Host
if host == "" {
host = "go.b0esche.cloud"
}
2026-01-24 21:06:18 +01:00
downloadPath := fmt . Sprintf ( "%s://%s/public/share/%s/download?token=%s" , scheme , host , token , url . QueryEscape ( viewerToken ) )
2026-01-25 00:57:55 +01:00
viewPath := fmt . Sprintf ( "%s://%s/public/share/%s/view?token=%s" , scheme , host , token , url . QueryEscape ( viewerToken ) )
2026-01-24 21:06:18 +01:00
2026-01-25 17:11:58 +01:00
// 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
}
}
}
}
}
}
2026-01-24 21:06:18 +01:00
// Determine file type
isPdf := strings . HasSuffix ( strings . ToLower ( file . Name ) , ".pdf" )
mimeType := getMimeType ( file . Name )
2026-01-25 17:11:58 +01:00
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 ,
} ,
2026-01-24 21:06:18 +01:00
}
2026-01-25 00:57:55 +01:00
2026-01-25 15:47:59 +01:00
// Set view URL for PDFs, videos, audio, and documents (for inline viewing)
2026-01-25 19:39:33 +01:00
if isPdf || strings . HasPrefix ( mimeType , "video/" ) || strings . HasPrefix ( mimeType , "audio/" ) || strings . HasPrefix ( mimeType , "image/" ) {
2026-01-25 17:11:58 +01:00
viewerSession [ "viewUrl" ] = viewPath
2026-01-25 15:47:59 +01:00
} else if strings . Contains ( mimeType , "document" ) || strings . Contains ( mimeType , "word" ) || strings . Contains ( mimeType , "spreadsheet" ) || strings . Contains ( mimeType , "presentation" ) {
// Use Collabora for document viewing
2026-01-25 19:22:26 +01:00
wopiSrc := fmt . Sprintf ( "%s://go.b0esche.cloud/public/wopi/share/%s" , scheme , token )
2026-01-25 19:30:53 +01:00
editorUrl := getCollaboraEditorURL ( "https://of.b0esche.cloud" )
collaboraUrl := fmt . Sprintf ( "%s?WOPISrc=%s" , editorUrl , url . QueryEscape ( wopiSrc ) )
2026-01-25 17:11:58 +01:00
viewerSession [ "viewUrl" ] = collaboraUrl
2026-01-25 00:57:55 +01:00
}
2026-01-25 17:11:58 +01:00
// Add internal access info if user has access
if internalFileId != nil {
viewerSession [ "fileId" ] = * internalFileId
if internalOrgId != nil {
viewerSession [ "orgId" ] = * internalOrgId
}
}
2026-01-24 21:06:18 +01:00
2026-01-25 02:47:39 +01:00
// 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" )
2026-01-24 21:06:18 +01:00
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
}
2026-01-24 22:56:10 +01:00
// 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" )
2026-01-24 21:06:18 +01:00
errors . WriteError ( w , errors . CodeUnauthenticated , "Invalid token" , http . StatusUnauthorized )
return
}
2026-01-24 22:56:10 +01:00
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
}
}
2026-01-24 21:06:18 +01:00
// 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
}
2026-01-24 21:11:25 +01:00
if file . UserID == nil {
errors . WriteError ( w , errors . CodeNotFound , "File not accessible" , http . StatusNotFound )
return
}
2026-01-28 23:43:02 +01:00
// Check if it's a folder - if so, create a zip download
2026-01-25 20:00:31 +01:00
if file . Type == "folder" {
2026-01-28 23:43:02 +01:00
// 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 )
2026-01-29 00:00:14 +01:00
if after , ok := strings . CutPrefix ( relativePath , "/" ) ; ok {
relativePath = after
2026-01-28 23:43:02 +01:00
}
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 ( ) )
2026-01-25 20:00:31 +01:00
return
}
2026-01-25 02:47:39 +01:00
// Determine MIME type
mimeType := getMimeType ( file . Name )
2026-01-24 21:11:25 +01:00
// Get WebDAV client for the file's owner
client , err := getUserWebDAVClient ( r . Context ( ) , db , * file . UserID , cfg . NextcloudURL , cfg . NextcloudUser , cfg . NextcloudPass )
2026-01-24 21:06:18 +01:00
if err != nil {
errors . LogError ( r , err , "Failed to get WebDAV client" )
errors . WriteError ( w , errors . CodeInternal , "Server error" , http . StatusInternalServerError )
return
}
2026-01-25 16:14:03 +01:00
// Create context with longer timeout for file downloads
downloadCtx , cancel := context . WithTimeout ( context . Background ( ) , 10 * time . Minute )
defer cancel ( )
2026-01-24 21:06:18 +01:00
// Stream file
2026-01-25 16:14:03 +01:00
resp , err := client . Download ( downloadCtx , file . Path , "" )
2026-01-24 21:06:18 +01:00
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 ( )
2026-01-25 02:47:39 +01:00
// 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" )
2026-01-25 03:22:39 +01:00
// Copy headers from Nextcloud response, but skip Content-Type to ensure correct MIME type
2026-01-24 21:06:18 +01:00
for k , v := range resp . Header {
2026-01-25 03:22:39 +01:00
if k != "Content-Type" {
w . Header ( ) [ k ] = v
}
2026-01-24 21:06:18 +01:00
}
2026-01-25 03:22:39 +01:00
// Set correct Content-Type based on file extension
w . Header ( ) . Set ( "Content-Type" , mimeType )
2026-01-25 02:47:39 +01:00
2026-01-24 21:11:25 +01:00
// Ensure download behavior
w . Header ( ) . Set ( "Content-Disposition" , fmt . Sprintf ( "attachment; filename=\"%s\"" , file . Name ) )
2026-01-24 21:06:18 +01:00
// Copy body
io . Copy ( w , resp . Body )
}
2026-01-24 22:32:58 +01:00
2026-01-25 00:57:55 +01:00
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
}
2026-01-25 20:00:31 +01:00
// 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
}
2026-01-25 02:47:39 +01:00
// Determine MIME type
mimeType := getMimeType ( file . Name )
2026-01-25 00:57:55 +01:00
// 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
}
2026-01-25 15:26:09 +01:00
// Create context with longer timeout for file downloads
2026-01-25 16:14:03 +01:00
downloadCtx , cancel := context . WithTimeout ( context . Background ( ) , 10 * time . Minute )
2026-01-25 15:26:09 +01:00
defer cancel ( )
2026-01-25 15:42:10 +01:00
// Stream file
resp , err := client . Download ( downloadCtx , file . Path , r . Header . Get ( "Range" ) )
if err != nil {
errors . LogError ( r , err , "Failed to download file" )
2026-01-25 19:47:59 +01:00
errors . WriteError ( w , errors . CodeInternal , "File temporarily unavailable. Please try again later." , http . StatusInternalServerError )
2026-01-25 15:42:10 +01:00
return
}
defer resp . Body . Close ( )
2026-01-25 00:57:55 +01:00
2026-01-25 02:47:39 +01:00
// 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" )
2026-01-25 15:42:10 +01:00
// 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
}
}
2026-01-25 00:57:55 +01:00
2026-01-25 03:22:39 +01:00
// Set correct Content-Type based on file extension
w . Header ( ) . Set ( "Content-Type" , mimeType )
2026-01-25 02:47:39 +01:00
2026-01-25 00:57:55 +01:00
// Ensure inline viewing behavior (no Content-Disposition attachment)
2026-01-25 15:42:10 +01:00
w . Header ( ) . Del ( "Content-Disposition" )
2026-01-25 00:57:55 +01:00
w . Header ( ) . Set ( "Content-Disposition" , fmt . Sprintf ( "inline; filename=\"%s\"" , file . Name ) )
2026-01-25 15:42:10 +01:00
// Set status code (200 or 206 for partial)
w . WriteHeader ( resp . StatusCode )
// Copy body
io . Copy ( w , resp . Body )
2026-01-25 00:57:55 +01:00
}
2026-01-24 22:32:58 +01:00
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
2026-01-24 23:16:51 +01:00
errors . WriteError ( w , errors . CodeNotFound , "Share link not found" , http . StatusNotFound )
2026-01-24 22:32:58 +01:00
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"
}
2026-01-25 00:27:58 +01:00
host := "www.b0esche.cloud"
2026-01-25 18:50:02 +01:00
fullURL := fmt . Sprintf ( "%s://%s/share/%s" , scheme , host , link . Token )
2026-01-24 22:32:58 +01:00
w . Header ( ) . Set ( "Content-Type" , "application/json" )
json . NewEncoder ( w ) . Encode ( map [ string ] interface { } {
2026-01-24 23:16:51 +01:00
"shareUrl" : fullURL ,
"token" : link . Token ,
2026-01-24 22:32:58 +01:00
} )
}
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
2026-01-24 23:16:51 +01:00
token , err := storage . GenerateSecurePassword ( 48 )
2026-01-24 22:32:58 +01:00
if err != nil {
errors . LogError ( r , err , "Failed to generate token" )
errors . WriteError ( w , errors . CodeInternal , "Server error" , http . StatusInternalServerError )
return
}
2026-01-24 23:16:51 +01:00
// 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.
2026-01-24 22:54:50 +01:00
link , err := db . CreateFileShareLink ( r . Context ( ) , token , fileUUID , nil , userID )
2026-01-24 22:32:58 +01:00
if err != nil {
errors . LogError ( r , err , "Failed to create share link" )
errors . WriteError ( w , errors . CodeInternal , "Server error" , http . StatusInternalServerError )
return
}
2026-01-24 23:45:08 +01:00
log . Printf ( "Share link created: user_id=%s, file_id=%s, org_id=%v" , userID , fileUUID , link . OrgID )
2026-01-24 22:32:58 +01:00
// Build full URL
scheme := "https"
if proto := r . Header . Get ( "X-Forwarded-Proto" ) ; proto != "" {
scheme = proto
} else if r . TLS == nil {
scheme = "http"
}
2026-01-25 00:27:58 +01:00
host := "www.b0esche.cloud"
2026-01-25 18:50:02 +01:00
fullURL := fmt . Sprintf ( "%s://%s/share/%s" , scheme , host , link . Token )
2026-01-24 22:32:58 +01:00
w . Header ( ) . Set ( "Content-Type" , "application/json" )
json . NewEncoder ( w ) . Encode ( map [ string ] interface { } {
2026-01-24 23:16:51 +01:00
"shareUrl" : fullURL ,
"token" : link . Token ,
2026-01-24 22:32:58 +01:00
} )
}
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 )
}
2026-01-25 15:47:59 +01:00
// 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 )
}
2026-01-27 03:28:27 +01:00
// 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" `
2026-01-29 22:42:36 +01:00
UpdatedAt * time . Time ` json:"-" `
2026-01-27 03:28:27 +01:00
}
err = db . QueryRowContext ( r . Context ( ) ,
2026-01-29 22:42:36 +01:00
` SELECT id , username , email , display_name , avatar_url , created_at , last_login_at , updated_at
2026-01-27 03:28:27 +01:00
FROM users WHERE id = $ 1 ` , userID ) .
2026-01-29 22:42:36 +01:00
Scan ( & user . ID , & user . Username , & user . Email , & user . DisplayName , & user . AvatarURL , & user . CreatedAt , & user . LastLoginAt , & user . UpdatedAt )
2026-01-27 03:28:27 +01:00
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
}
2026-01-29 10:12:20 +01:00
// If avatar exists, return the backend URL instead of the internal WebDAV URL
if user . AvatarURL != nil && * user . AvatarURL != "" {
2026-01-29 22:42:36 +01:00
// 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 ]
}
2026-01-29 10:12:20 +01:00
}
2026-01-27 03:28:27 +01:00
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" `
2026-01-27 08:47:21 +01:00
Email * string ` json:"email" `
2026-01-27 03:28:27 +01:00
}
if err = json . NewDecoder ( r . Body ) . Decode ( & req ) ; err != nil {
errors . WriteError ( w , errors . CodeInvalidArgument , "Invalid JSON" , http . StatusBadRequest )
return
}
2026-01-27 08:47:21 +01:00
// 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 ... )
2026-01-27 03:28:27 +01:00
if err != nil {
errors . LogError ( r , err , "Failed to update user profile" )
errors . WriteError ( w , errors . CodeInternal , "Server error" , http . StatusInternalServerError )
return
}
// Audit log
2026-01-27 08:47:21 +01:00
metadata := make ( map [ string ] interface { } )
if req . DisplayName != nil {
metadata [ "displayName" ] = * req . DisplayName
}
if req . Email != nil {
metadata [ "email" ] = * req . Email
}
2026-01-27 03:28:27 +01:00
auditLogger . Log ( r . Context ( ) , audit . Entry {
2026-01-27 08:47:21 +01:00
UserID : & userID ,
Action : "profile_update" ,
Success : true ,
Metadata : metadata ,
2026-01-27 03:28:27 +01:00
} )
2026-01-29 22:42:36 +01:00
// 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 ]
}
}
2026-01-30 13:24:43 +01:00
w . Header ( ) . Set ( "Content-Type" , "application/json" )
2026-01-27 03:28:27 +01:00
w . WriteHeader ( http . StatusOK )
2026-01-29 22:42:36 +01:00
json . NewEncoder ( w ) . Encode ( updatedUser )
2026-01-27 03:28:27 +01:00
}
// 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
}
2026-01-29 22:42:36 +01:00
// Generate deterministic filename based on user ID
2026-01-27 03:28:27 +01:00
ext := filepath . Ext ( header . Filename )
if ext == "" {
2026-01-29 22:42:36 +01:00
ext = ".png" // default extension
2026-01-27 03:28:27 +01:00
}
2026-01-29 22:42:36 +01:00
filename := fmt . Sprintf ( "%s%s" , userID . String ( ) , ext )
2026-01-27 03:28:27 +01:00
2026-01-29 22:42:36 +01:00
// Upload to Nextcloud at .avatars/<user-id>.<ext>
2026-01-31 23:48:08 +01:00
// Use internal Nextcloud WebDAV endpoint for server-to-server operations to avoid external TLS/timeouts
internalClient := storage . NewUserWebDAVClient ( cfg . NextcloudURL , cfg . NextcloudUser , cfg . NextcloudPass )
2026-01-29 12:08:32 +01:00
avatarPath := fmt . Sprintf ( ".avatars/%s" , filename )
2026-01-31 23:48:08 +01:00
err = internalClient . Upload ( r . Context ( ) , avatarPath , bytes . NewReader ( fileBytes ) , header . Size )
2026-01-27 03:28:27 +01:00
if err != nil {
2026-01-31 23:48:08 +01:00
errors . LogError ( r , err , "Failed to upload avatar (internal)" )
2026-01-27 03:28:27 +01:00
errors . WriteError ( w , errors . CodeInternal , "Server error" , http . StatusInternalServerError )
return
}
2026-01-31 23:48:08 +01:00
// 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 )
2026-01-27 03:28:27 +01:00
2026-01-29 22:42:36 +01:00
// Update user profile with avatar URL and updated_at
2026-01-27 03:28:27 +01:00
_ , err = db . ExecContext ( r . Context ( ) ,
` UPDATE users SET avatar_url = $1, updated_at = NOW() WHERE id = $2 ` ,
2026-01-29 10:30:24 +01:00
webdavURL , userID )
2026-01-27 03:28:27 +01:00
if err != nil {
errors . LogError ( r , err , "Failed to update user avatar" )
errors . WriteError ( w , errors . CodeInternal , "Server error" , http . StatusInternalServerError )
return
}
2026-01-31 18:44:52 +01:00
// 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 )
2026-01-31 23:48:08 +01:00
resp , derr := internalClient . Download ( vctx , avatarPath , "" )
2026-01-31 18:44:52 +01:00
if derr == nil && resp != nil {
2026-02-01 00:50:50 +01:00
// Close body while context is still valid
2026-01-31 18:44:52 +01:00
resp . Body . Close ( )
2026-02-01 00:50:50 +01:00
vcancel ( )
2026-01-31 18:44:52 +01:00
verified = true
break
}
2026-02-01 00:50:50 +01:00
// Cancel context for failed attempt
vcancel ( )
2026-01-31 18:44:52 +01:00
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 )
}
2026-01-30 13:41:17 +01:00
// 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 )
2026-01-31 18:09:07 +01:00
cached := false
2026-01-30 13:41:17 +01:00
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 )
2026-01-31 18:44:52 +01:00
// 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 )
}
2026-01-31 18:09:07 +01:00
} else {
cached = true
2026-01-29 23:00:59 +01:00
}
2026-01-31 18:44:52 +01:00
// 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 ) )
}
}
2026-01-29 23:00:59 +01:00
2026-01-27 03:28:27 +01:00
// Audit log
auditLogger . Log ( r . Context ( ) , audit . Entry {
UserID : & userID ,
Action : "avatar_upload" ,
Success : true ,
Metadata : map [ string ] interface { } {
"filename" : filename ,
"size" : header . Size ,
} ,
} )
2026-01-31 18:09:07 +01:00
// Provide avatarData for immediate preview (small images only)
avatarData := base64 . StdEncoding . EncodeToString ( fileBytes )
2026-01-29 22:42:36 +01:00
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 )
}
2026-01-27 03:28:27 +01:00
w . Header ( ) . Set ( "Content-Type" , "application/json" )
json . NewEncoder ( w ) . Encode ( map [ string ] interface { } {
2026-01-31 18:09:07 +01:00
"message" : "Avatar uploaded successfully" ,
"avatarUrl" : publicURL ,
"cached" : cached ,
"avatarData" : avatarData ,
"contentType" : contentType ,
2026-01-27 03:28:27 +01:00
} )
}
2026-01-28 23:52:18 +01:00
2026-01-29 10:12:20 +01:00
// getUserAvatarHandler serves the user's avatar image
2026-01-29 21:19:15 +01:00
func getUserAvatarHandler ( w http . ResponseWriter , r * http . Request , db * database . DB , jwtManager * jwt . Manager , cfg * config . Config ) {
2026-01-29 22:42:36 +01:00
// Accept token via query param or Authorization header (Bearer)
2026-01-29 21:14:50 +01:00
tokenString := r . URL . Query ( ) . Get ( "token" )
2026-01-29 22:42:36 +01:00
if tokenString == "" {
authHeader := r . Header . Get ( "Authorization" )
if strings . HasPrefix ( authHeader , "Bearer " ) {
tokenString = strings . TrimPrefix ( authHeader , "Bearer " )
}
}
2026-01-29 21:14:50 +01:00
if tokenString == "" {
errors . WriteError ( w , errors . CodeUnauthenticated , "Unauthorized" , http . StatusUnauthorized )
return
}
2026-01-29 21:20:01 +01:00
claims , err := jwtManager . Validate ( tokenString )
2026-01-29 21:14:50 +01:00
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
}
2026-01-29 10:12:20 +01:00
var avatarURL * string
2026-01-29 21:14:50 +01:00
err = db . QueryRowContext ( r . Context ( ) ,
2026-01-29 10:12:20 +01:00
` 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
}
2026-01-30 13:41:17 +01:00
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
}
}
2026-01-31 17:48:30 +01:00
// Download from WebDAV with retries and backoff
2026-01-31 23:48:08 +01:00
// Use external client instance only to compute remotePath from stored avatar URL
externalClient := storage . NewWebDAVClient ( cfg )
if externalClient == nil {
2026-01-29 10:30:24 +01:00
errors . WriteError ( w , errors . CodeInternal , "WebDAV client not configured" , http . StatusInternalServerError )
return
}
2026-01-31 23:48:08 +01:00
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 )
2026-01-29 22:55:46 +01:00
2026-01-31 17:48:30 +01:00
// 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
2026-01-29 22:55:46 +01:00
2026-01-31 17:48:30 +01:00
var resp * http . Response
var dlErr error
2026-02-01 00:50:50 +01:00
var cancel context . CancelFunc
2026-01-31 17:48:30 +01:00
for attempt := 0 ; attempt < attempts ; attempt ++ {
2026-02-01 00:50:50 +01:00
ctx , c := context . WithTimeout ( r . Context ( ) , time . Duration ( timeoutSeconds ) * time . Second )
cancel = c
2026-01-31 23:48:08 +01:00
// Use internal client to avoid external network/TLS overhead
resp , dlErr = internalClient . Download ( ctx , remotePath , "" )
2026-02-01 00:50:50 +01:00
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
}
2026-01-29 23:00:59 +01:00
2026-02-01 00:50:50 +01:00
// 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
2026-01-29 22:55:46 +01:00
}
2026-02-01 00:50:50 +01:00
// Success: keep the cancel func so we can call it after reading the body
break
}
if cancel != nil {
defer cancel ( )
2026-01-31 17:48:30 +01:00
}
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 )
2026-01-29 10:12:20 +01:00
return
}
defer resp . Body . Close ( )
2026-01-29 23:00:59 +01:00
// 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
2026-01-29 10:12:20 +01:00
}
2026-01-29 22:55:46 +01:00
2026-01-29 23:00:59 +01:00
contentType := resp . Header . Get ( "Content-Type" )
if contentType == "" {
contentType = mime . TypeByExtension ( filepath . Ext ( remotePath ) )
if contentType == "" {
contentType = "application/octet-stream"
}
}
2026-01-30 13:41:17 +01:00
// 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 )
2026-01-29 23:00:59 +01:00
}
2026-01-30 13:41:17 +01:00
} ( v )
2026-01-29 23:00:59 +01:00
// Copy headers but ensure sensible caching
w . Header ( ) . Set ( "Content-Type" , contentType )
w . Header ( ) . Set ( "Cache-Control" , "public, max-age=300" )
2026-01-29 10:12:20 +01:00
w . WriteHeader ( resp . StatusCode )
2026-01-29 23:00:59 +01:00
w . Write ( bodyBytes )
2026-01-29 10:12:20 +01:00
}
2026-01-28 23:52:18 +01:00
// 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" ,
} )
}