1346 lines
44 KiB
Go
1346 lines
44 KiB
Go
package http
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"go.b0esche.cloud/backend/internal/audit"
|
|
"go.b0esche.cloud/backend/internal/auth"
|
|
"go.b0esche.cloud/backend/internal/config"
|
|
"go.b0esche.cloud/backend/internal/database"
|
|
"go.b0esche.cloud/backend/internal/errors"
|
|
"go.b0esche.cloud/backend/internal/middleware"
|
|
"go.b0esche.cloud/backend/internal/org"
|
|
"go.b0esche.cloud/backend/internal/permission"
|
|
"go.b0esche.cloud/backend/pkg/jwt"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/google/uuid"
|
|
"go.b0esche.cloud/backend/internal/storage"
|
|
)
|
|
|
|
func NewRouter(cfg *config.Config, db *database.DB, jwtManager *jwt.Manager, authService *auth.Service, auditLogger *audit.Logger) http.Handler {
|
|
r := chi.NewRouter()
|
|
// optional WebDAV/Nextcloud client
|
|
storageClient := storage.NewWebDAVClient(cfg)
|
|
|
|
// Global middleware
|
|
r.Use(middleware.RequestID)
|
|
r.Use(middleware.Logger)
|
|
r.Use(middleware.Recoverer)
|
|
r.Use(middleware.CORS(cfg.AllowedOrigins))
|
|
r.Use(middleware.RateLimit)
|
|
|
|
// Health check
|
|
r.Get("/health", healthHandler)
|
|
|
|
// Auth routes (no auth required)
|
|
r.Route("/auth", func(r chi.Router) {
|
|
r.Post("/refresh", func(w http.ResponseWriter, req *http.Request) {
|
|
refreshHandler(w, req, jwtManager, db)
|
|
})
|
|
r.Post("/logout", func(w http.ResponseWriter, req *http.Request) {
|
|
logoutHandler(w, req, jwtManager, db, auditLogger)
|
|
})
|
|
// Passkey routes
|
|
r.Post("/signup", func(w http.ResponseWriter, req *http.Request) {
|
|
signupHandler(w, req, db, auditLogger)
|
|
})
|
|
r.Post("/registration-challenge", func(w http.ResponseWriter, req *http.Request) {
|
|
registrationChallengeHandler(w, req, db)
|
|
})
|
|
r.Post("/registration-verify", func(w http.ResponseWriter, req *http.Request) {
|
|
registrationVerifyHandler(w, req, db, jwtManager, auditLogger)
|
|
})
|
|
r.Post("/authentication-challenge", func(w http.ResponseWriter, req *http.Request) {
|
|
authenticationChallengeHandler(w, req, db)
|
|
})
|
|
r.Post("/authentication-verify", func(w http.ResponseWriter, req *http.Request) {
|
|
authenticationVerifyHandler(w, req, db, jwtManager, auditLogger)
|
|
})
|
|
// Password login route
|
|
r.Post("/password-login", func(w http.ResponseWriter, req *http.Request) {
|
|
passwordLoginHandler(w, req, db, jwtManager, auditLogger)
|
|
})
|
|
})
|
|
|
|
// Protected routes (with auth middleware)
|
|
r.Route("/", func(r chi.Router) {
|
|
r.Use(middleware.Auth(jwtManager, db))
|
|
|
|
// User-scoped routes (personal workspace)
|
|
r.Get("/user/files", func(w http.ResponseWriter, req *http.Request) {
|
|
userFilesHandler(w, req, db)
|
|
})
|
|
// Download user file
|
|
r.Get("/user/files/download", func(w http.ResponseWriter, req *http.Request) {
|
|
downloadUserFileHandler(w, req, db, storageClient)
|
|
})
|
|
// Create / delete in user workspace
|
|
r.Post("/user/files", func(w http.ResponseWriter, req *http.Request) {
|
|
createUserFileHandler(w, req, db, auditLogger, storageClient)
|
|
})
|
|
r.Delete("/user/files", func(w http.ResponseWriter, req *http.Request) {
|
|
deleteUserFileHandler(w, req, db, auditLogger, storageClient)
|
|
})
|
|
// POST wrapper for delete
|
|
r.Post("/user/files/delete", func(w http.ResponseWriter, req *http.Request) {
|
|
deleteUserFilePostHandler(w, req, db, auditLogger, storageClient)
|
|
})
|
|
|
|
// Org routes
|
|
r.Get("/orgs", func(w http.ResponseWriter, req *http.Request) {
|
|
listOrgsHandler(w, req, db, jwtManager)
|
|
})
|
|
r.Post("/orgs", func(w http.ResponseWriter, req *http.Request) {
|
|
createOrgHandler(w, req, db, auditLogger, jwtManager)
|
|
})
|
|
|
|
// Org-scoped routes
|
|
r.Route("/orgs/{orgId}", func(r chi.Router) {
|
|
r.Use(middleware.Org(db, auditLogger))
|
|
|
|
// File routes
|
|
r.With(middleware.Permission(db, auditLogger, permission.FileRead)).Get("/files", func(w http.ResponseWriter, req *http.Request) {
|
|
listFilesHandler(w, req, db)
|
|
})
|
|
// Download org file
|
|
r.With(middleware.Permission(db, auditLogger, permission.FileRead)).Get("/files/download", func(w http.ResponseWriter, req *http.Request) {
|
|
downloadOrgFileHandler(w, req, db, storageClient)
|
|
})
|
|
|
|
// Create file/folder in org workspace
|
|
r.With(middleware.Permission(db, auditLogger, permission.FileWrite)).Post("/files", func(w http.ResponseWriter, req *http.Request) {
|
|
createOrgFileHandler(w, req, db, auditLogger, storageClient)
|
|
})
|
|
// Also accept POST delete for clients that cannot send DELETE with body
|
|
r.With(middleware.Permission(db, auditLogger, permission.FileWrite)).Post("/files/delete", func(w http.ResponseWriter, req *http.Request) {
|
|
deleteOrgFilePostHandler(w, req, db, auditLogger, storageClient)
|
|
})
|
|
|
|
// Delete file/folder in org workspace (body: {"path":"/path"})
|
|
r.With(middleware.Permission(db, auditLogger, permission.FileWrite)).Delete("/files", func(w http.ResponseWriter, req *http.Request) {
|
|
deleteOrgFileHandler(w, req, db, auditLogger, storageClient)
|
|
})
|
|
r.Route("/files/{fileId}", func(r chi.Router) {
|
|
r.With(middleware.Permission(db, auditLogger, permission.DocumentView)).Get("/view", func(w http.ResponseWriter, req *http.Request) {
|
|
viewerHandler(w, req, db, auditLogger)
|
|
})
|
|
r.With(middleware.Permission(db, auditLogger, permission.DocumentEdit)).Get("/edit", func(w http.ResponseWriter, req *http.Request) {
|
|
editorHandler(w, req, db, auditLogger)
|
|
})
|
|
r.With(middleware.Permission(db, auditLogger, permission.DocumentEdit)).Post("/annotations", func(w http.ResponseWriter, req *http.Request) {
|
|
annotationsHandler(w, req, db, auditLogger)
|
|
})
|
|
r.Get("/meta", func(w http.ResponseWriter, req *http.Request) {
|
|
fileMetaHandler(w, req)
|
|
})
|
|
})
|
|
r.Get("/activity", func(w http.ResponseWriter, req *http.Request) {
|
|
activityHandler(w, req, db)
|
|
})
|
|
r.With(middleware.Permission(db, auditLogger, permission.OrgManage)).Get("/members", func(w http.ResponseWriter, req *http.Request) {
|
|
listMembersHandler(w, req, db)
|
|
})
|
|
r.With(middleware.Permission(db, auditLogger, permission.OrgManage)).Patch("/members/{userId}", func(w http.ResponseWriter, req *http.Request) {
|
|
updateMemberRoleHandler(w, req, db, auditLogger)
|
|
})
|
|
})
|
|
}) // Close protected routes
|
|
|
|
return r
|
|
}
|
|
|
|
func healthHandler(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte("OK"))
|
|
}
|
|
|
|
func refreshHandler(w http.ResponseWriter, r *http.Request, jwtManager *jwt.Manager, db *database.DB) {
|
|
authHeader := r.Header.Get("Authorization")
|
|
if !strings.HasPrefix(authHeader, "Bearer ") {
|
|
errors.WriteError(w, errors.CodeUnauthenticated, "Unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
|
|
|
|
claims, session, err := jwtManager.ValidateWithSession(r.Context(), tokenString, db)
|
|
if err != nil {
|
|
errors.LogError(r, err, "Invalid token")
|
|
errors.WriteError(w, errors.CodeUnauthenticated, "Unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
userID, _ := uuid.Parse(claims.UserID)
|
|
orgs, err := db.GetUserOrganizations(r.Context(), userID)
|
|
if err != nil {
|
|
errors.LogError(r, err, "Failed to get user organizations")
|
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
orgIDs := make([]string, len(orgs))
|
|
for i, o := range orgs {
|
|
orgIDs[i] = o.ID.String()
|
|
}
|
|
|
|
newToken, err := jwtManager.Generate(claims.UserID, orgIDs, session.ID.String())
|
|
if err != nil {
|
|
errors.LogError(r, err, "Token generation failed")
|
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Write([]byte(`{"token": "` + newToken + `"}`))
|
|
}
|
|
|
|
func logoutHandler(w http.ResponseWriter, r *http.Request, jwtManager *jwt.Manager, db *database.DB, auditLogger *audit.Logger) {
|
|
authHeader := r.Header.Get("Authorization")
|
|
if !strings.HasPrefix(authHeader, "Bearer ") {
|
|
errors.WriteError(w, errors.CodeUnauthenticated, "Unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
|
|
|
|
claims, session, err := jwtManager.ValidateWithSession(r.Context(), tokenString, db)
|
|
if err != nil {
|
|
// Token invalid or session already revoked/expired — still return success
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte(`{"status": "ok"}`))
|
|
return
|
|
}
|
|
|
|
userID, _ := uuid.Parse(claims.UserID)
|
|
|
|
// Revoke session
|
|
if err := db.RevokeSession(r.Context(), session.ID); err != nil {
|
|
errors.LogError(r, err, "Failed to revoke session")
|
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
auditLogger.Log(r.Context(), audit.Entry{
|
|
UserID: &userID,
|
|
Action: "logout",
|
|
Success: true,
|
|
})
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte(`{"status": "ok"}`))
|
|
}
|
|
|
|
func listOrgsHandler(w http.ResponseWriter, r *http.Request, db *database.DB, jwtManager *jwt.Manager) {
|
|
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, _, err := jwtManager.ValidateWithSession(r.Context(), tokenString, db)
|
|
if err != nil {
|
|
errors.LogError(r, err, "Invalid token")
|
|
errors.WriteError(w, errors.CodeUnauthenticated, "Unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
userID, _ := uuid.Parse(claims.UserID)
|
|
orgs, err := org.ResolveUserOrgs(r.Context(), db, userID)
|
|
if err != nil {
|
|
errors.LogError(r, err, "Failed to resolve user orgs")
|
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(orgs)
|
|
}
|
|
|
|
func createOrgHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger, jwtManager *jwt.Manager) {
|
|
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, _, err := jwtManager.ValidateWithSession(r.Context(), tokenString, db)
|
|
if err != nil {
|
|
errors.LogError(r, err, "Invalid token")
|
|
errors.WriteError(w, errors.CodeUnauthenticated, "Unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
userID, _ := uuid.Parse(claims.UserID)
|
|
|
|
var req struct {
|
|
Name string `json:"name"`
|
|
Slug string `json:"slug,omitempty"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
errors.WriteError(w, errors.CodeInvalidArgument, "Bad request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
org, err := org.CreateOrg(r.Context(), db, userID, req.Name, req.Slug)
|
|
if err != nil {
|
|
errors.LogError(r, err, "Failed to create org")
|
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
auditLogger.Log(r.Context(), audit.Entry{
|
|
UserID: &userID,
|
|
OrgID: &org.ID,
|
|
Action: "create_org",
|
|
Success: true,
|
|
})
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(org)
|
|
}
|
|
|
|
func listFilesHandler(w http.ResponseWriter, r *http.Request, db *database.DB) {
|
|
// Org ID is provided by middleware.Org
|
|
orgID := r.Context().Value("org").(uuid.UUID)
|
|
// Query params: path, q (search), page, pageSize
|
|
path := r.URL.Query().Get("path")
|
|
if path == "" {
|
|
path = "/"
|
|
}
|
|
q := r.URL.Query().Get("q")
|
|
page := 1
|
|
pageSize := 100
|
|
if p := r.URL.Query().Get("page"); p != "" {
|
|
fmt.Sscanf(p, "%d", &page)
|
|
}
|
|
if ps := r.URL.Query().Get("pageSize"); ps != "" {
|
|
fmt.Sscanf(ps, "%d", &pageSize)
|
|
}
|
|
|
|
files, err := db.GetOrgFiles(r.Context(), orgID, path, q, page, pageSize)
|
|
if err != nil {
|
|
errors.LogError(r, err, "Failed to get org files")
|
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Convert to a JSON-friendly shape expected by frontend
|
|
out := make([]map[string]interface{}, 0, len(files))
|
|
for _, f := range files {
|
|
out = append(out, map[string]interface{}{
|
|
"id": f.ID.String(),
|
|
"name": f.Name,
|
|
"path": f.Path,
|
|
"type": f.Type,
|
|
"size": f.Size,
|
|
"lastModified": f.LastModified.UTC().Format(time.RFC3339),
|
|
})
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(out)
|
|
}
|
|
|
|
func viewerHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger) {
|
|
userIDStr, _ := middleware.GetUserID(r.Context())
|
|
userID, _ := uuid.Parse(userIDStr)
|
|
orgID := r.Context().Value("org").(uuid.UUID)
|
|
fileId := chi.URLParam(r, "fileId")
|
|
|
|
// Log activity
|
|
db.LogActivity(r.Context(), userID, orgID, &fileId, "view_file", map[string]interface{}{})
|
|
|
|
session := struct {
|
|
ViewUrl string `json:"viewUrl"`
|
|
Capabilities struct {
|
|
CanEdit bool `json:"canEdit"`
|
|
CanAnnotate bool `json:"canAnnotate"`
|
|
IsPdf bool `json:"isPdf"`
|
|
} `json:"capabilities"`
|
|
ExpiresAt string `json:"expiresAt"`
|
|
}{
|
|
ViewUrl: "https://view.example.com/" + fileId,
|
|
Capabilities: struct {
|
|
CanEdit bool `json:"canEdit"`
|
|
CanAnnotate bool `json:"canAnnotate"`
|
|
IsPdf bool `json:"isPdf"`
|
|
}{CanEdit: true, CanAnnotate: true, IsPdf: true},
|
|
ExpiresAt: "2023-01-01T01:00:00Z",
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(session)
|
|
}
|
|
|
|
func editorHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger) {
|
|
userIDStr, _ := middleware.GetUserID(r.Context())
|
|
userID, _ := uuid.Parse(userIDStr)
|
|
orgID := r.Context().Value("org").(uuid.UUID)
|
|
fileId := chi.URLParam(r, "fileId")
|
|
|
|
// Log activity
|
|
db.LogActivity(r.Context(), userID, orgID, &fileId, "edit_file", map[string]interface{}{})
|
|
|
|
session := struct {
|
|
EditUrl string `json:"editUrl"`
|
|
ReadOnly bool `json:"readOnly"`
|
|
ExpiresAt string `json:"expiresAt"`
|
|
}{
|
|
EditUrl: "https://edit.example.com/" + fileId,
|
|
ReadOnly: false,
|
|
ExpiresAt: "2023-01-01T01:00:00Z",
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(session)
|
|
}
|
|
|
|
func annotationsHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger) {
|
|
userIDStr, _ := middleware.GetUserID(r.Context())
|
|
userID, _ := uuid.Parse(userIDStr)
|
|
orgID := r.Context().Value("org").(uuid.UUID)
|
|
fileId := chi.URLParam(r, "fileId")
|
|
|
|
// Parse payload
|
|
var payload struct {
|
|
Annotations []interface{} `json:"annotations"`
|
|
BaseVersionId string `json:"baseVersionId"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
|
errors.WriteError(w, errors.CodeInvalidArgument, "Bad request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Log activity
|
|
auditLogger.Log(r.Context(), audit.Entry{
|
|
UserID: &userID,
|
|
OrgID: &orgID,
|
|
Resource: &fileId,
|
|
Action: "annotate_pdf",
|
|
Success: true,
|
|
Metadata: map[string]interface{}{"count": len(payload.Annotations)},
|
|
})
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte(`{"status": "ok"}`))
|
|
}
|
|
|
|
func activityHandler(w http.ResponseWriter, r *http.Request, db *database.DB) {
|
|
orgID := r.Context().Value("org").(uuid.UUID)
|
|
|
|
activities, err := db.GetOrgActivities(r.Context(), orgID, 50)
|
|
if err != nil {
|
|
errors.LogError(r, err, "Failed to get org activities")
|
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(activities)
|
|
}
|
|
|
|
func listMembersHandler(w http.ResponseWriter, r *http.Request, db *database.DB) {
|
|
orgID := r.Context().Value("org").(uuid.UUID)
|
|
|
|
members, err := db.GetOrgMembers(r.Context(), orgID)
|
|
if err != nil {
|
|
errors.LogError(r, err, "Failed to get org members")
|
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(members)
|
|
}
|
|
|
|
func updateMemberRoleHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger) {
|
|
orgID := r.Context().Value("org").(uuid.UUID)
|
|
userIDStr := chi.URLParam(r, "userId")
|
|
userID, err := uuid.Parse(userIDStr)
|
|
if err != nil {
|
|
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid user ID", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
Role string `json:"role"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
errors.WriteError(w, errors.CodeInvalidArgument, "Bad request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if err := db.UpdateMemberRole(r.Context(), orgID, userID, req.Role); err != nil {
|
|
errors.LogError(r, err, "Failed to update member role")
|
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte(`{"status": "ok"}`))
|
|
}
|
|
|
|
func fileMetaHandler(w http.ResponseWriter, r *http.Request) {
|
|
meta := struct {
|
|
LastModified string `json:"lastModified"`
|
|
LastModifiedBy string `json:"lastModifiedBy"`
|
|
VersionCount int `json:"versionCount"`
|
|
}{
|
|
LastModified: "2023-01-01T00:00:00Z",
|
|
LastModifiedBy: "user@example.com",
|
|
VersionCount: 1,
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(meta)
|
|
}
|
|
|
|
// Passkey handlers
|
|
|
|
func signupHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger) {
|
|
var req struct {
|
|
Username string `json:"username"`
|
|
Email string `json:"email"`
|
|
DisplayName string `json:"displayName"`
|
|
Password string `json:"password"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
errors.WriteError(w, errors.CodeInvalidArgument, "Bad request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if req.Username == "" || req.Email == "" || req.Password == "" {
|
|
errors.WriteError(w, errors.CodeInvalidArgument, "Username, email, and password are required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Hash password
|
|
passkeyService := auth.NewService(db)
|
|
passwordHash, err := passkeyService.HashPassword(req.Password)
|
|
if err != nil {
|
|
errors.LogError(r, err, "Failed to hash password")
|
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Create user with hashed password
|
|
user, err := db.CreateUser(r.Context(), req.Username, req.Email, req.DisplayName, &passwordHash)
|
|
if err != nil {
|
|
errors.LogError(r, err, "Failed to create user")
|
|
if strings.Contains(err.Error(), "duplicate key") {
|
|
errors.WriteError(w, errors.CodeConflict, "Username or email already exists", http.StatusConflict)
|
|
} else {
|
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
|
}
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusCreated)
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"userId": user.ID,
|
|
"user": user,
|
|
})
|
|
}
|
|
|
|
func registrationChallengeHandler(w http.ResponseWriter, r *http.Request, db *database.DB) {
|
|
var req struct {
|
|
UserID string `json:"userId"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
errors.WriteError(w, errors.CodeInvalidArgument, "Bad request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
userID, err := uuid.Parse(req.UserID)
|
|
if err != nil {
|
|
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid user ID", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
passkeyService := auth.NewService(db)
|
|
challenge, err := passkeyService.StartRegistrationChallenge(r.Context(), userID)
|
|
if err != nil {
|
|
errors.LogError(r, err, "Failed to generate challenge")
|
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"challenge": challenge,
|
|
"rp": map[string]string{
|
|
"name": auth.RPName,
|
|
"id": auth.RPID,
|
|
},
|
|
"user": map[string]string{
|
|
"id": userID.String(),
|
|
"name": userID.String(),
|
|
},
|
|
"pubKeyCredParams": []map[string]interface{}{
|
|
{"alg": -7, "type": "public-key"},
|
|
{"alg": -257, "type": "public-key"},
|
|
},
|
|
"timeout": 60000,
|
|
"attestation": "direct",
|
|
"authenticatorSelection": map[string]interface{}{
|
|
"authenticatorAttachment": "platform",
|
|
"requireResidentKey": false,
|
|
"userVerification": "preferred",
|
|
},
|
|
})
|
|
}
|
|
|
|
func registrationVerifyHandler(w http.ResponseWriter, r *http.Request, db *database.DB, jwtManager *jwt.Manager, auditLogger *audit.Logger) {
|
|
var req struct {
|
|
UserID string `json:"userId"`
|
|
Challenge string `json:"challenge"`
|
|
CredentialID string `json:"credentialId"`
|
|
PublicKey string `json:"publicKey"`
|
|
ClientDataJSON string `json:"clientDataJSON"`
|
|
AttestationObject string `json:"attestationObject"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
errors.WriteError(w, errors.CodeInvalidArgument, "Bad request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
userID, err := uuid.Parse(req.UserID)
|
|
if err != nil {
|
|
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid user ID", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
passkeyService := auth.NewService(db)
|
|
_, err = passkeyService.VerifyRegistrationResponse(
|
|
r.Context(),
|
|
userID,
|
|
req.Challenge,
|
|
req.CredentialID,
|
|
req.PublicKey,
|
|
req.ClientDataJSON,
|
|
req.AttestationObject,
|
|
)
|
|
if err != nil {
|
|
errors.LogError(r, err, "Failed to verify registration")
|
|
errors.WriteError(w, errors.CodeUnauthenticated, "Registration failed: "+err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Create session
|
|
session, err := db.CreateSession(r.Context(), userID, time.Now().Add(15*time.Minute))
|
|
if err != nil {
|
|
errors.LogError(r, err, "Failed to create session")
|
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Get user
|
|
user, err := db.GetUserByID(r.Context(), userID)
|
|
if err != nil {
|
|
errors.LogError(r, err, "Failed to get user")
|
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Generate JWT
|
|
orgIDs := []string{}
|
|
token, err := jwtManager.Generate(user.ID.String(), orgIDs, session.ID.String())
|
|
if err != nil {
|
|
errors.LogError(r, err, "Token generation failed")
|
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
auditLogger.Log(r.Context(), audit.Entry{
|
|
UserID: &userID,
|
|
Action: "registration",
|
|
Success: true,
|
|
})
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"token": token,
|
|
"user": user,
|
|
})
|
|
}
|
|
|
|
func authenticationChallengeHandler(w http.ResponseWriter, r *http.Request, db *database.DB) {
|
|
var req struct {
|
|
Username string `json:"username"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
errors.WriteError(w, errors.CodeInvalidArgument, "Bad request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if req.Username == "" {
|
|
errors.WriteError(w, errors.CodeInvalidArgument, "Username is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
passkeyService := auth.NewService(db)
|
|
challenge, credentialIDs, err := passkeyService.StartAuthenticationChallenge(r.Context(), req.Username)
|
|
if err != nil {
|
|
errors.LogError(r, err, "Failed to generate challenge")
|
|
errors.WriteError(w, errors.CodeNotFound, "User not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"challenge": challenge,
|
|
"timeout": 60000,
|
|
"userVerification": "preferred",
|
|
"allowCredentials": credentialIDs,
|
|
})
|
|
}
|
|
|
|
func authenticationVerifyHandler(w http.ResponseWriter, r *http.Request, db *database.DB, jwtManager *jwt.Manager, auditLogger *audit.Logger) {
|
|
var req struct {
|
|
Username string `json:"username"`
|
|
Challenge string `json:"challenge"`
|
|
CredentialID string `json:"credentialId"`
|
|
AuthenticatorData string `json:"authenticatorData"`
|
|
ClientDataJSON string `json:"clientDataJSON"`
|
|
Signature string `json:"signature"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
errors.WriteError(w, errors.CodeInvalidArgument, "Bad request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
passkeyService := auth.NewService(db)
|
|
user, err := passkeyService.VerifyAuthenticationResponse(
|
|
r.Context(),
|
|
req.Username,
|
|
req.Challenge,
|
|
req.CredentialID,
|
|
req.AuthenticatorData,
|
|
req.ClientDataJSON,
|
|
req.Signature,
|
|
)
|
|
if err != nil {
|
|
errors.LogError(r, err, "Failed to verify authentication")
|
|
errors.WriteError(w, errors.CodeUnauthenticated, "Authentication failed: "+err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Create session
|
|
session, err := db.CreateSession(r.Context(), user.ID, time.Now().Add(15*time.Minute))
|
|
if err != nil {
|
|
errors.LogError(r, err, "Failed to create session")
|
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Get user orgs
|
|
orgs, err := db.GetUserOrganizations(r.Context(), user.ID)
|
|
if err != nil {
|
|
errors.LogError(r, err, "Failed to get user orgs")
|
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
orgIDs := make([]string, len(orgs))
|
|
for i, o := range orgs {
|
|
orgIDs[i] = o.ID.String()
|
|
}
|
|
|
|
// Generate JWT
|
|
token, err := jwtManager.Generate(user.ID.String(), orgIDs, session.ID.String())
|
|
if err != nil {
|
|
errors.LogError(r, err, "Token generation failed")
|
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
auditLogger.Log(r.Context(), audit.Entry{
|
|
UserID: &user.ID,
|
|
Action: "login",
|
|
Success: true,
|
|
})
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"token": token,
|
|
"user": user,
|
|
})
|
|
}
|
|
|
|
func passwordLoginHandler(w http.ResponseWriter, r *http.Request, db *database.DB, jwtManager *jwt.Manager, auditLogger *audit.Logger) {
|
|
var req struct {
|
|
Username string `json:"username"`
|
|
Password string `json:"password"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
errors.WriteError(w, errors.CodeInvalidArgument, "Bad request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if req.Username == "" || req.Password == "" {
|
|
errors.WriteError(w, errors.CodeInvalidArgument, "Username and password are required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Verify password
|
|
passkeyService := auth.NewService(db)
|
|
user, err := passkeyService.VerifyPasswordLogin(r.Context(), req.Username, req.Password)
|
|
if err != nil {
|
|
auditLogger.Log(r.Context(), audit.Entry{
|
|
Action: "login",
|
|
Success: false,
|
|
Metadata: map[string]interface{}{"error": err.Error()},
|
|
})
|
|
errors.LogError(r, err, "Password login failed")
|
|
errors.WriteError(w, errors.CodeUnauthenticated, "Invalid credentials", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
// Create session
|
|
session, err := db.CreateSession(r.Context(), user.ID, time.Now().Add(15*time.Minute))
|
|
if err != nil {
|
|
errors.LogError(r, err, "Failed to create session")
|
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Get user orgs
|
|
orgs, err := db.GetUserOrganizations(r.Context(), user.ID)
|
|
if err != nil {
|
|
errors.LogError(r, err, "Failed to get user orgs")
|
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
orgIDs := make([]string, len(orgs))
|
|
for i, o := range orgs {
|
|
orgIDs[i] = o.ID.String()
|
|
}
|
|
|
|
// Generate JWT
|
|
token, err := jwtManager.Generate(user.ID.String(), orgIDs, session.ID.String())
|
|
if err != nil {
|
|
errors.LogError(r, err, "Token generation failed")
|
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
auditLogger.Log(r.Context(), audit.Entry{
|
|
UserID: &user.ID,
|
|
Action: "login",
|
|
Success: true,
|
|
})
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"token": token,
|
|
"user": user,
|
|
})
|
|
}
|
|
|
|
// userFilesHandler returns files for the authenticated user's personal workspace.
|
|
func userFilesHandler(w http.ResponseWriter, r *http.Request, db *database.DB) {
|
|
userIDStr, ok := middleware.GetUserID(r.Context())
|
|
if !ok || userIDStr == "" {
|
|
errors.WriteError(w, errors.CodeUnauthenticated, "Unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
userID, err := uuid.Parse(userIDStr)
|
|
if err != nil {
|
|
errors.LogError(r, err, "Invalid user id in context")
|
|
errors.WriteError(w, errors.CodeUnauthenticated, "Unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
path := r.URL.Query().Get("path")
|
|
if path == "" {
|
|
path = "/"
|
|
}
|
|
q := r.URL.Query().Get("q")
|
|
page := 1
|
|
pageSize := 100
|
|
if p := r.URL.Query().Get("page"); p != "" {
|
|
fmt.Sscanf(p, "%d", &page)
|
|
}
|
|
if ps := r.URL.Query().Get("pageSize"); ps != "" {
|
|
fmt.Sscanf(ps, "%d", &pageSize)
|
|
}
|
|
|
|
files, err := db.GetUserFiles(r.Context(), userID, path, q, page, pageSize)
|
|
if err != nil {
|
|
errors.LogError(r, err, "Failed to get user files")
|
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
out := make([]map[string]interface{}, 0, len(files))
|
|
for _, f := range files {
|
|
out = append(out, map[string]interface{}{
|
|
"id": f.ID.String(),
|
|
"name": f.Name,
|
|
"path": f.Path,
|
|
"type": f.Type,
|
|
"size": f.Size,
|
|
"lastModified": f.LastModified.UTC().Format(time.RFC3339),
|
|
})
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(out)
|
|
}
|
|
|
|
// createOrgFileHandler creates a file or folder record for an org workspace.
|
|
func createOrgFileHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger, storageClient *storage.WebDAVClient) {
|
|
orgID := r.Context().Value("org").(uuid.UUID)
|
|
userIDStr, _ := middleware.GetUserID(r.Context())
|
|
userID, _ := uuid.Parse(userIDStr)
|
|
var f *database.File
|
|
var err error
|
|
|
|
// Support multipart uploads (field "file") or JSON metadata for folders
|
|
contentType := r.Header.Get("Content-Type")
|
|
if strings.HasPrefix(contentType, "multipart/form-data") {
|
|
// Handle file upload
|
|
if err = r.ParseMultipartForm(32 << 20); err != nil {
|
|
errors.WriteError(w, errors.CodeInvalidArgument, "Bad multipart request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
parentPath := r.FormValue("path")
|
|
if parentPath == "" {
|
|
parentPath = "/"
|
|
}
|
|
var file multipart.File
|
|
var header *multipart.FileHeader
|
|
file, header, err = r.FormFile("file")
|
|
if err != nil {
|
|
errors.WriteError(w, errors.CodeInvalidArgument, "Missing file", http.StatusBadRequest)
|
|
return
|
|
}
|
|
defer file.Close()
|
|
|
|
// Read file into memory (so we can attempt WebDAV upload and fallback to disk)
|
|
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
|
|
}
|
|
|
|
// Attempt WebDAV upload when configured
|
|
storedPath := filepath.ToSlash(filepath.Join(parentPath, header.Filename))
|
|
if !strings.HasPrefix(storedPath, "/") {
|
|
storedPath = "/" + storedPath
|
|
}
|
|
written := int64(len(data))
|
|
if storageClient != nil {
|
|
// Build remote path under /orgs/<orgId>
|
|
rel := strings.TrimPrefix(storedPath, "/")
|
|
remotePath := path.Join("/orgs", orgID.String(), rel)
|
|
if err = storageClient.Upload(r.Context(), remotePath, bytes.NewReader(data), int64(len(data))); err != nil {
|
|
// Log and fallback to local disk
|
|
errors.LogError(r, err, "WebDAV upload failed, falling back to local disk")
|
|
} else {
|
|
// success -> persist metadata and return
|
|
f, err = db.CreateFile(r.Context(), &orgID, &userID, header.Filename, storedPath, "file", written)
|
|
if err != nil {
|
|
errors.LogError(r, err, "Failed to create org file")
|
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
auditLogger.Log(r.Context(), audit.Entry{
|
|
UserID: &userID,
|
|
OrgID: &orgID,
|
|
Action: "upload_file",
|
|
Success: true,
|
|
})
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]interface{}{"id": f.ID})
|
|
return
|
|
}
|
|
}
|
|
|
|
// Fallback: Save to temp directory (WebDAV should be the primary storage)
|
|
baseDir := filepath.Join("/tmp", "uploads", "orgs", orgID.String())
|
|
targetDir := filepath.Join(baseDir, parentPath)
|
|
if err = os.MkdirAll(targetDir, 0o755); err != nil {
|
|
errors.LogError(r, err, "Failed to create target dir in /tmp")
|
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
outPath := filepath.Join(targetDir, header.Filename)
|
|
if err = os.WriteFile(outPath, data, 0o644); err != nil {
|
|
errors.LogError(r, err, "Failed to write file to /tmp")
|
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Store metadata in DB; store path relative to workspace root
|
|
storedPath = filepath.ToSlash(filepath.Join(parentPath, header.Filename))
|
|
if !strings.HasPrefix(storedPath, "/") {
|
|
storedPath = "/" + storedPath
|
|
}
|
|
f, err = db.CreateFile(r.Context(), &orgID, &userID, header.Filename, storedPath, "file", written)
|
|
if err != nil {
|
|
errors.LogError(r, err, "Failed to create org file")
|
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
auditLogger.Log(r.Context(), audit.Entry{
|
|
UserID: &userID,
|
|
OrgID: &orgID,
|
|
Action: "upload_file",
|
|
Success: true,
|
|
})
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]interface{}{"id": f.ID})
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
Name string `json:"name"`
|
|
Path string `json:"path"`
|
|
Type string `json:"type"` // file|folder
|
|
Size int64 `json:"size"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
errors.WriteError(w, errors.CodeInvalidArgument, "Bad request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
f, err = db.CreateFile(r.Context(), &orgID, &userID, req.Name, req.Path, req.Type, req.Size)
|
|
if err != nil {
|
|
errors.LogError(r, err, "Failed to create org file")
|
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
auditLogger.Log(r.Context(), audit.Entry{
|
|
UserID: &userID,
|
|
OrgID: &orgID,
|
|
Action: "create_file",
|
|
Success: true,
|
|
})
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]interface{}{"id": f.ID})
|
|
}
|
|
|
|
// deleteOrgFileHandler deletes a file/folder in org workspace by path
|
|
func deleteOrgFileHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger, storageClient *storage.WebDAVClient) {
|
|
orgID := r.Context().Value("org").(uuid.UUID)
|
|
userIDStr, _ := middleware.GetUserID(r.Context())
|
|
userID, _ := uuid.Parse(userIDStr)
|
|
|
|
var req struct {
|
|
Path string `json:"path"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
errors.WriteError(w, errors.CodeInvalidArgument, "Bad request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Delete from Nextcloud if configured
|
|
if storageClient != nil {
|
|
rel := strings.TrimPrefix(req.Path, "/")
|
|
remotePath := path.Join("/orgs", orgID.String(), rel)
|
|
if err := storageClient.Delete(r.Context(), remotePath); err != nil {
|
|
errors.LogError(r, err, "Failed to delete from Nextcloud (continuing anyway)")
|
|
}
|
|
}
|
|
|
|
// Delete from database
|
|
if err := db.DeleteFileByPath(r.Context(), &orgID, nil, req.Path); err != nil {
|
|
errors.LogError(r, err, "Failed to delete org file")
|
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
auditLogger.Log(r.Context(), audit.Entry{
|
|
UserID: &userID,
|
|
OrgID: &orgID,
|
|
Action: "delete_file",
|
|
Success: true,
|
|
})
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte(`{"status":"ok"}`))
|
|
}
|
|
|
|
// Also accept POST /orgs/{orgId}/files/delete for clients that cannot send DELETE with body
|
|
func deleteOrgFilePostHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger, storageClient *storage.WebDAVClient) {
|
|
deleteOrgFileHandler(w, r, db, auditLogger, storageClient)
|
|
}
|
|
|
|
// createUserFileHandler creates a file or folder record for the authenticated user's personal workspace.
|
|
func createUserFileHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger, storageClient *storage.WebDAVClient) {
|
|
userIDStr, ok := middleware.GetUserID(r.Context())
|
|
if !ok || userIDStr == "" {
|
|
errors.WriteError(w, errors.CodeUnauthenticated, "Unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
userID, _ := uuid.Parse(userIDStr)
|
|
var f *database.File
|
|
var err error
|
|
// Support multipart uploads for file content or JSON for folders
|
|
contentType := r.Header.Get("Content-Type")
|
|
if strings.HasPrefix(contentType, "multipart/form-data") {
|
|
if err = r.ParseMultipartForm(32 << 20); err != nil {
|
|
errors.WriteError(w, errors.CodeInvalidArgument, "Bad multipart request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
parentPath := r.FormValue("path")
|
|
if parentPath == "" {
|
|
parentPath = "/"
|
|
}
|
|
var file multipart.File
|
|
var header *multipart.FileHeader
|
|
file, header, err = r.FormFile("file")
|
|
if err != nil {
|
|
errors.WriteError(w, errors.CodeInvalidArgument, "Missing file", http.StatusBadRequest)
|
|
return
|
|
}
|
|
defer file.Close()
|
|
// Read file into memory to allow WebDAV upload and disk fallback
|
|
data, err := io.ReadAll(file)
|
|
if err != nil {
|
|
errors.LogError(r, err, "Failed to read uploaded file")
|
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
storedPath := filepath.ToSlash(filepath.Join(parentPath, header.Filename))
|
|
if !strings.HasPrefix(storedPath, "/") {
|
|
storedPath = "/" + storedPath
|
|
}
|
|
written := int64(len(data))
|
|
if storageClient != nil {
|
|
rel := strings.TrimPrefix(storedPath, "/")
|
|
remotePath := path.Join("/users", userID.String(), rel)
|
|
if err = storageClient.Upload(r.Context(), remotePath, bytes.NewReader(data), int64(len(data))); err != nil {
|
|
errors.LogError(r, err, "WebDAV upload failed, falling back to local disk")
|
|
} else {
|
|
f, err = db.CreateFile(r.Context(), nil, &userID, header.Filename, storedPath, "file", written)
|
|
if err != nil {
|
|
errors.LogError(r, err, "Failed to create user file")
|
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
auditLogger.Log(r.Context(), audit.Entry{
|
|
UserID: &userID,
|
|
Action: "upload_user_file",
|
|
Success: true,
|
|
})
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]interface{}{"id": f.ID})
|
|
return
|
|
}
|
|
}
|
|
|
|
// Fallback: write to temp directory (WebDAV should be the primary storage)
|
|
baseDir := filepath.Join("/tmp", "uploads", "users", userID.String())
|
|
targetDir := filepath.Join(baseDir, parentPath)
|
|
if err = os.MkdirAll(targetDir, 0o755); err != nil {
|
|
errors.LogError(r, err, "Failed to create target dir in /tmp")
|
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
outPath := filepath.Join(targetDir, header.Filename)
|
|
if err = os.WriteFile(outPath, data, 0o644); err != nil {
|
|
errors.LogError(r, err, "Failed to write file to /tmp")
|
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
f, err = db.CreateFile(r.Context(), nil, &userID, header.Filename, storedPath, "file", written)
|
|
if err != nil {
|
|
errors.LogError(r, err, "Failed to create user file")
|
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
auditLogger.Log(r.Context(), audit.Entry{
|
|
UserID: &userID,
|
|
Action: "upload_user_file",
|
|
Success: true,
|
|
})
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]interface{}{"id": f.ID})
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
Name string `json:"name"`
|
|
Path string `json:"path"`
|
|
Type string `json:"type"` // file|folder
|
|
Size int64 `json:"size"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
errors.WriteError(w, errors.CodeInvalidArgument, "Bad request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
f, err = db.CreateFile(r.Context(), nil, &userID, req.Name, req.Path, req.Type, req.Size)
|
|
if err != nil {
|
|
errors.LogError(r, err, "Failed to create user file")
|
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
auditLogger.Log(r.Context(), audit.Entry{
|
|
UserID: &userID,
|
|
Action: "create_user_file",
|
|
Success: true,
|
|
})
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]interface{}{"id": f.ID})
|
|
}
|
|
|
|
// Also accept POST /user/files/delete
|
|
func deleteUserFilePostHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger, storageClient *storage.WebDAVClient) {
|
|
deleteUserFileHandler(w, r, db, auditLogger, storageClient)
|
|
}
|
|
|
|
// deleteUserFileHandler deletes a file/folder in user's personal workspace by path
|
|
func deleteUserFileHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger, storageClient *storage.WebDAVClient) {
|
|
userIDStr, ok := middleware.GetUserID(r.Context())
|
|
if !ok || userIDStr == "" {
|
|
errors.WriteError(w, errors.CodeUnauthenticated, "Unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
userID, _ := uuid.Parse(userIDStr)
|
|
|
|
var req struct {
|
|
Path string `json:"path"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
errors.WriteError(w, errors.CodeInvalidArgument, "Bad request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Delete from Nextcloud if configured
|
|
if storageClient != nil {
|
|
rel := strings.TrimPrefix(req.Path, "/")
|
|
// Keep remote user workspace path consistent with uploads: "/users/<userID>/<rel>"
|
|
remotePath := path.Join("/users", userID.String(), rel)
|
|
if err := storageClient.Delete(r.Context(), remotePath); err != nil {
|
|
errors.LogError(r, err, "Failed to delete from Nextcloud (continuing anyway)")
|
|
}
|
|
}
|
|
|
|
// Delete from database
|
|
if err := db.DeleteFileByPath(r.Context(), nil, &userID, req.Path); err != nil {
|
|
errors.LogError(r, err, "Failed to delete user file")
|
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
auditLogger.Log(r.Context(), audit.Entry{
|
|
UserID: &userID,
|
|
Action: "delete_user_file",
|
|
Success: true,
|
|
})
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte(`{"status":"ok"}`))
|
|
}
|
|
|
|
// downloadOrgFileHandler downloads a file from org workspace
|
|
func downloadOrgFileHandler(w http.ResponseWriter, r *http.Request, db *database.DB, storageClient *storage.WebDAVClient) {
|
|
orgID := r.Context().Value("org").(uuid.UUID)
|
|
|
|
filePath := r.URL.Query().Get("path")
|
|
if filePath == "" {
|
|
errors.WriteError(w, errors.CodeInvalidArgument, "Missing path parameter", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Try to download from Nextcloud first
|
|
if storageClient != nil {
|
|
rel := strings.TrimPrefix(filePath, "/")
|
|
remotePath := path.Join("/orgs", orgID.String(), rel)
|
|
|
|
reader, size, err := storageClient.Download(r.Context(), remotePath)
|
|
if err == nil {
|
|
defer reader.Close()
|
|
|
|
// Set appropriate headers
|
|
fileName := path.Base(filePath)
|
|
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", fileName))
|
|
w.Header().Set("Content-Type", "application/octet-stream")
|
|
if size > 0 {
|
|
w.Header().Set("Content-Length", fmt.Sprintf("%d", size))
|
|
}
|
|
|
|
// Stream the file
|
|
io.Copy(w, reader)
|
|
return
|
|
}
|
|
|
|
errors.LogError(r, err, "Failed to download from Nextcloud, trying local storage")
|
|
}
|
|
|
|
// Fallback to local storage (if implemented)
|
|
errors.WriteError(w, errors.CodeNotFound, "File not found", http.StatusNotFound)
|
|
}
|
|
|
|
// downloadUserFileHandler downloads a file from user's personal workspace
|
|
func downloadUserFileHandler(w http.ResponseWriter, r *http.Request, db *database.DB, storageClient *storage.WebDAVClient) {
|
|
userIDStr, ok := middleware.GetUserID(r.Context())
|
|
if !ok || userIDStr == "" {
|
|
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
|
|
}
|
|
|
|
// Try to download from Nextcloud first
|
|
if storageClient != nil {
|
|
rel := strings.TrimPrefix(filePath, "/")
|
|
// Keep remote user workspace path consistent with uploads: "/users/<userID>/<rel>"
|
|
remotePath := path.Join("/users", userID.String(), rel)
|
|
|
|
reader, size, err := storageClient.Download(r.Context(), remotePath)
|
|
if err == nil {
|
|
defer reader.Close()
|
|
|
|
// Set appropriate headers
|
|
fileName := path.Base(filePath)
|
|
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", fileName))
|
|
w.Header().Set("Content-Type", "application/octet-stream")
|
|
if size > 0 {
|
|
w.Header().Set("Content-Length", fmt.Sprintf("%d", size))
|
|
}
|
|
|
|
// Stream the file
|
|
io.Copy(w, reader)
|
|
return
|
|
}
|
|
|
|
errors.LogError(r, err, "Failed to download from Nextcloud, trying local storage")
|
|
}
|
|
|
|
// Fallback to local storage (if implemented)
|
|
errors.WriteError(w, errors.CodeNotFound, "File not found", http.StatusNotFound)
|
|
}
|