diff --git a/.vscode/settings.json b/.vscode/settings.json index e0a5ace..4d7334d 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -18,7 +18,8 @@ "editor.formatOnSave": true, "editor.codeActionsOnSave": { "source.organizeImports": "explicit" - } + }, + "editor.defaultFormatter": "golang.go" }, "gopls": { "usePlaceholders": true, diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index fbdfb43..82595b5 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -5,6 +5,7 @@ import ( "net/http" "os" "path/filepath" + "strings" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" @@ -12,6 +13,7 @@ import ( "novamd/internal/api" "novamd/internal/db" "novamd/internal/filesystem" + "novamd/internal/user" ) func main() { @@ -36,16 +38,15 @@ func main() { workdir = "./data" } - settings, err := database.GetSettings(1) // Assuming user ID 1 for now - if err != nil { - log.Print("Settings not found, using default settings") - } - fs := filesystem.New(workdir, &settings) + fs := filesystem.New(workdir) - if settings.Settings.GitEnabled { - if err := fs.InitializeGitRepo(); err != nil { - log.Fatal(err) - } + // User service + userService := user.NewUserService(database, fs) + + // Admin user + _, err = userService.SetupAdminUser() + if err != nil { + log.Fatal(err) } // Set up router @@ -64,21 +65,27 @@ func main() { staticPath = "../frontend/dist" } fileServer := http.FileServer(http.Dir(staticPath)) - r.Get("/*", func(w http.ResponseWriter, r *http.Request) { - requestedPath := r.URL.Path - validatedPath, err := filesystem.ValidatePath(staticPath, requestedPath) - if err != nil { - http.Error(w, "Invalid path", http.StatusBadRequest) - return - } + r.Get( + "/*", + func(w http.ResponseWriter, r *http.Request) { + requestedPath := r.URL.Path - _, err = os.Stat(validatedPath) - if os.IsNotExist(err) { - http.ServeFile(w, r, filepath.Join(staticPath, "index.html")) - return - } - http.StripPrefix("/", fileServer).ServeHTTP(w, r) - }) + fullPath := filepath.Join(staticPath, requestedPath) + cleanPath := filepath.Clean(fullPath) + + if !strings.HasPrefix(cleanPath, staticPath) { + http.Error(w, "Invalid path", http.StatusBadRequest) + return + } + + _, err = os.Stat(cleanPath) + if os.IsNotExist(err) { + http.ServeFile(w, r, filepath.Join(staticPath, "index.html")) + return + } + http.StripPrefix("/", fileServer).ServeHTTP(w, r) + }, + ) // Start server port := os.Getenv("NOVAMD_PORT") @@ -87,4 +94,4 @@ func main() { } log.Printf("Server starting on port %s", port) log.Fatal(http.ListenAndServe(":"+port, r)) -} \ No newline at end of file +} diff --git a/backend/go.mod b/backend/go.mod index 9fb7aeb..3cea35d 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -7,6 +7,7 @@ require ( github.com/go-git/go-git/v5 v5.12.0 github.com/go-playground/validator/v10 v10.22.1 github.com/mattn/go-sqlite3 v1.14.23 + golang.org/x/crypto v0.21.0 ) require ( @@ -29,7 +30,6 @@ require ( github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect github.com/skeema/knownhosts v1.2.2 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect - golang.org/x/crypto v0.21.0 // indirect golang.org/x/mod v0.12.0 // indirect golang.org/x/net v0.23.0 // indirect golang.org/x/sys v0.18.0 // indirect diff --git a/backend/internal/api/file_handlers.go b/backend/internal/api/file_handlers.go new file mode 100644 index 0000000..1a6e943 --- /dev/null +++ b/backend/internal/api/file_handlers.go @@ -0,0 +1,170 @@ +package api + +import ( + "encoding/json" + "io" + "net/http" + + "novamd/internal/db" + "novamd/internal/filesystem" + + "github.com/go-chi/chi/v5" +) + +func ListFiles(fs *filesystem.FileSystem) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + userID, workspaceID, err := getUserAndWorkspaceIDs(r) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + files, err := fs.ListFilesRecursively(userID, workspaceID) + if err != nil { + http.Error(w, "Failed to list files", http.StatusInternalServerError) + return + } + + respondJSON(w, files) + } +} + +func LookupFileByName(fs *filesystem.FileSystem) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + userID, workspaceID, err := getUserAndWorkspaceIDs(r) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + filename := r.URL.Query().Get("filename") + if filename == "" { + http.Error(w, "Filename is required", http.StatusBadRequest) + return + } + + filePaths, err := fs.FindFileByName(userID, workspaceID, filename) + if err != nil { + http.Error(w, "File not found", http.StatusNotFound) + return + } + + respondJSON(w, map[string][]string{"paths": filePaths}) + } +} + +func GetFileContent(fs *filesystem.FileSystem) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + userID, workspaceID, err := getUserAndWorkspaceIDs(r) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + filePath := chi.URLParam(r, "*") + content, err := fs.GetFileContent(userID, workspaceID, filePath) + if err != nil { + http.Error(w, "Failed to read file", http.StatusNotFound) + return + } + + w.Header().Set("Content-Type", "text/plain") + w.Write(content) + } +} + +func SaveFile(fs *filesystem.FileSystem) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + userID, workspaceID, err := getUserAndWorkspaceIDs(r) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + filePath := chi.URLParam(r, "*") + content, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "Failed to read request body", http.StatusBadRequest) + return + } + + err = fs.SaveFile(userID, workspaceID, filePath, content) + if err != nil { + http.Error(w, "Failed to save file", http.StatusInternalServerError) + return + } + + respondJSON(w, map[string]string{"message": "File saved successfully"}) + } +} + +func DeleteFile(fs *filesystem.FileSystem) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + userID, workspaceID, err := getUserAndWorkspaceIDs(r) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + filePath := chi.URLParam(r, "*") + err = fs.DeleteFile(userID, workspaceID, filePath) + if err != nil { + http.Error(w, "Failed to delete file", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) + w.Write([]byte("File deleted successfully")) + } +} + +func GetLastOpenedFile(db *db.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + userID, _, err := getUserAndWorkspaceIDs(r) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + user, err := db.GetUserByID(userID) + if err != nil { + http.Error(w, "Failed to get user", http.StatusInternalServerError) + return + } + + respondJSON(w, map[string]string{"lastOpenedFile": user.LastOpenedFilePath}) + } +} + +func UpdateLastOpenedFile(db *db.DB, fs *filesystem.FileSystem) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + userID, workspaceID, err := getUserAndWorkspaceIDs(r) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + var requestBody struct { + FilePath string `json:"filePath"` + } + + if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + // Validate that the file path is valid within the workspace + _, err = fs.ValidatePath(userID, workspaceID, requestBody.FilePath) + if err != nil { + http.Error(w, "Invalid file path", http.StatusBadRequest) + return + } + + if err := db.UpdateLastOpenedFile(userID, requestBody.FilePath); err != nil { + http.Error(w, "Failed to update last opened file", http.StatusInternalServerError) + return + } + + respondJSON(w, map[string]string{"message": "Last opened file updated successfully"}) + } +} diff --git a/backend/internal/api/git_handlers.go b/backend/internal/api/git_handlers.go new file mode 100644 index 0000000..7eaa146 --- /dev/null +++ b/backend/internal/api/git_handlers.go @@ -0,0 +1,58 @@ +package api + +import ( + "encoding/json" + "net/http" + + "novamd/internal/filesystem" +) + +func StageCommitAndPush(fs *filesystem.FileSystem) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + userID, workspaceID, err := getUserAndWorkspaceIDs(r) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + var requestBody struct { + Message string `json:"message"` + } + + if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + if requestBody.Message == "" { + http.Error(w, "Commit message is required", http.StatusBadRequest) + return + } + + err = fs.StageCommitAndPush(userID, workspaceID, requestBody.Message) + if err != nil { + http.Error(w, "Failed to stage, commit, and push changes: "+err.Error(), http.StatusInternalServerError) + return + } + + respondJSON(w, map[string]string{"message": "Changes staged, committed, and pushed successfully"}) + } +} + +func PullChanges(fs *filesystem.FileSystem) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + userID, workspaceID, err := getUserAndWorkspaceIDs(r) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + err = fs.Pull(userID, workspaceID) + if err != nil { + http.Error(w, "Failed to pull changes: "+err.Error(), http.StatusInternalServerError) + return + } + + respondJSON(w, map[string]string{"message": "Pulled changes from remote"}) + } +} diff --git a/backend/internal/api/handler_utils.go b/backend/internal/api/handler_utils.go new file mode 100644 index 0000000..75c50cd --- /dev/null +++ b/backend/internal/api/handler_utils.go @@ -0,0 +1,37 @@ +package api + +import ( + "encoding/json" + "errors" + "net/http" + "strconv" + + "github.com/go-chi/chi/v5" +) + +func getUserID(r *http.Request) (int, error) { + userIDStr := chi.URLParam(r, "userId") + return strconv.Atoi(userIDStr) +} + +func getUserAndWorkspaceIDs(r *http.Request) (int, int, error) { + userID, err := getUserID(r) + if err != nil { + return 0, 0, errors.New("invalid userId") + } + + workspaceIDStr := chi.URLParam(r, "workspaceId") + workspaceID, err := strconv.Atoi(workspaceIDStr) + if err != nil { + return userID, 0, errors.New("invalid workspaceId") + } + + return userID, workspaceID, nil +} + +func respondJSON(w http.ResponseWriter, data interface{}) { + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(data); err != nil { + http.Error(w, "Failed to encode response", http.StatusInternalServerError) + } +} diff --git a/backend/internal/api/handlers.go b/backend/internal/api/handlers.go deleted file mode 100644 index 717094e..0000000 --- a/backend/internal/api/handlers.go +++ /dev/null @@ -1,235 +0,0 @@ -package api - -import ( - "encoding/json" - "io" - "net/http" - "path/filepath" - "strconv" - "strings" - - "novamd/internal/db" - "novamd/internal/filesystem" - "novamd/internal/models" -) - -func ListFiles(fs *filesystem.FileSystem) http.HandlerFunc { - return func(w http.ResponseWriter, _ *http.Request) { - files, err := fs.ListFilesRecursively() - if err != nil { - http.Error(w, "Failed to list files", http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(files); err != nil { - http.Error(w, "Failed to encode response", http.StatusInternalServerError) - } - } -} - -func LookupFileByName(fs *filesystem.FileSystem) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - filenameOrPath := r.URL.Query().Get("filename") - if filenameOrPath == "" { - http.Error(w, "Filename or path is required", http.StatusBadRequest) - return - } - - filePaths, err := fs.FindFileByName(filenameOrPath) - if err != nil { - http.Error(w, "File not found", http.StatusNotFound) - return - } - - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(map[string][]string{"paths": filePaths}); err != nil { - http.Error(w, "Failed to encode response", http.StatusInternalServerError) - } - } -} - -func GetFileContent(fs *filesystem.FileSystem) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - filePath := strings.TrimPrefix(r.URL.Path, "/api/v1/files/") - content, err := fs.GetFileContent(filePath) - if err != nil { - http.Error(w, "Failed to read file", http.StatusNotFound) - return - } - - // Determine content type based on file extension - contentType := "text/plain" - switch filepath.Ext(filePath) { - case ".png": - contentType = "image/png" - case ".jpg", ".jpeg": - contentType = "image/jpeg" - case ".webp": - contentType = "image/webp" - case ".gif": - contentType = "image/gif" - case ".svg": - contentType = "image/svg+xml" - case ".md": - contentType = "text/markdown" - } - - w.Header().Set("Content-Type", contentType) - if _, err := w.Write(content); err != nil { - http.Error(w, "Failed to write response", http.StatusInternalServerError) - } - } -} - -func SaveFile(fs *filesystem.FileSystem) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - filePath := strings.TrimPrefix(r.URL.Path, "/api/v1/files/") - content, err := io.ReadAll(r.Body) - if err != nil { - http.Error(w, "Failed to read request body", http.StatusBadRequest) - return - } - - err = fs.SaveFile(filePath, content) - if err != nil { - http.Error(w, "Failed to save file", http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(map[string]string{"message": "File saved successfully"}); err != nil { - http.Error(w, "Failed to encode response", http.StatusInternalServerError) - } - } -} - -func DeleteFile(fs *filesystem.FileSystem) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - filePath := strings.TrimPrefix(r.URL.Path, "/api/v1/files/") - err := fs.DeleteFile(filePath) - if err != nil { - http.Error(w, "Failed to delete file", http.StatusInternalServerError) - return - } - - w.WriteHeader(http.StatusOK) - if _, err := w.Write([]byte("File deleted successfully")); err != nil { - http.Error(w, "Failed to write response", http.StatusInternalServerError) - } - } -} - -func GetSettings(db *db.DB) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - userIDStr := r.URL.Query().Get("userId") - userID, err := strconv.Atoi(userIDStr) - if err != nil { - http.Error(w, "Invalid userId", http.StatusBadRequest) - return - } - - settings, err := db.GetSettings(userID) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - settings.SetDefaults() - - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(settings); err != nil { - http.Error(w, "Failed to encode response", http.StatusInternalServerError) - } - } -} - -func UpdateSettings(db *db.DB, fs *filesystem.FileSystem) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - var settings models.Settings - if err := json.NewDecoder(r.Body).Decode(&settings); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - - settings.SetDefaults() - - if err := settings.Validate(); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - - err := db.SaveSettings(settings) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - if settings.Settings.GitEnabled { - err := fs.SetupGitRepo(settings.Settings.GitURL, settings.Settings.GitUser, settings.Settings.GitToken) - if err != nil { - http.Error(w, "Failed to setup git repo", http.StatusInternalServerError) - } - } else { - fs.DisableGitRepo() - } - - // Fetch the saved settings to return - savedSettings, err := db.GetSettings(settings.UserID) - if err != nil { - http.Error(w, "Settings saved but could not be retrieved", http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - if err := json.NewEncoder(w).Encode(savedSettings); err != nil { - http.Error(w, "Failed to encode response", http.StatusInternalServerError) - } - } -} - -func StageCommitAndPush(fs *filesystem.FileSystem) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - var requestBody struct { - Message string `json:"message"` - } - - err := json.NewDecoder(r.Body).Decode(&requestBody) - if err != nil { - http.Error(w, "Invalid request body", http.StatusBadRequest) - return - } - - if requestBody.Message == "" { - http.Error(w, "Commit message is required", http.StatusBadRequest) - return - } - - err = fs.StageCommitAndPush(requestBody.Message) - if err != nil { - http.Error(w, "Failed to stage, commit, and push changes: "+err.Error(), http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(map[string]string{"message": "Changes staged, committed, and pushed successfully"}); err != nil { - http.Error(w, "Failed to encode response", http.StatusInternalServerError) - } - } -} - -func PullChanges(fs *filesystem.FileSystem) http.HandlerFunc { - return func(w http.ResponseWriter, _ *http.Request) { - err := fs.Pull() - if err != nil { - http.Error(w, "Failed to pull changes: "+err.Error(), http.StatusInternalServerError) - return - } - - w.WriteHeader(http.StatusOK) - if err := json.NewEncoder(w).Encode(map[string]string{"message": "Pulled changes from remote"}); err != nil { - http.Error(w, "Failed to encode response", http.StatusInternalServerError) - } - } -} diff --git a/backend/internal/api/routes.go b/backend/internal/api/routes.go index 761bab7..af0b1f0 100644 --- a/backend/internal/api/routes.go +++ b/backend/internal/api/routes.go @@ -9,20 +9,42 @@ import ( func SetupRoutes(r chi.Router, db *db.DB, fs *filesystem.FileSystem) { r.Route("/", func(r chi.Router) { - r.Route("/settings", func(r chi.Router) { - r.Get("/", GetSettings(db)) - r.Post("/", UpdateSettings(db, fs)) - }) - r.Route("/files", func(r chi.Router) { - r.Get("/", ListFiles(fs)) - r.Get("/*", GetFileContent(fs)) - r.Post("/*", SaveFile(fs)) - r.Delete("/*", DeleteFile(fs)) - r.Get("/lookup", LookupFileByName(fs)) - }) - r.Route("/git", func(r chi.Router) { - r.Post("/commit", StageCommitAndPush(fs)) - r.Post("/pull", PullChanges(fs)) + // User routes + r.Route("/users/{userId}", func(r chi.Router) { + r.Get("/", GetUser(db)) + + // Workspace routes + r.Route("/workspaces", func(r chi.Router) { + r.Get("/", ListWorkspaces(db)) + r.Post("/", CreateWorkspace(db)) + r.Get("/last", GetLastWorkspace(db)) + r.Put("/last", UpdateLastWorkspace(db)) + + r.Route("/{workspaceId}", func(r chi.Router) { + r.Get("/", GetWorkspace(db)) + r.Put("/", UpdateWorkspace(db, fs)) + r.Delete("/", DeleteWorkspace(db)) + + // File routes + r.Route("/files", func(r chi.Router) { + r.Get("/", ListFiles(fs)) + r.Get("/last", GetLastOpenedFile(db)) + r.Put("/last", UpdateLastOpenedFile(db, fs)) + r.Get("/lookup", LookupFileByName(fs)) // Moved here + + r.Post("/*", SaveFile(fs)) + r.Get("/*", GetFileContent(fs)) + r.Delete("/*", DeleteFile(fs)) + + }) + + // Git routes + r.Route("/git", func(r chi.Router) { + r.Post("/commit", StageCommitAndPush(fs)) + r.Post("/pull", PullChanges(fs)) + }) + }) + }) }) }) } diff --git a/backend/internal/api/user_handlers.go b/backend/internal/api/user_handlers.go new file mode 100644 index 0000000..28cd9fe --- /dev/null +++ b/backend/internal/api/user_handlers.go @@ -0,0 +1,25 @@ +package api + +import ( + "net/http" + + "novamd/internal/db" +) + +func GetUser(db *db.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + userID, err := getUserID(r) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + user, err := db.GetUserByID(userID) + if err != nil { + http.Error(w, "Failed to get user", http.StatusInternalServerError) + return + } + + respondJSON(w, user) + } +} diff --git a/backend/internal/api/workspace_handlers.go b/backend/internal/api/workspace_handlers.go new file mode 100644 index 0000000..c1c23c6 --- /dev/null +++ b/backend/internal/api/workspace_handlers.go @@ -0,0 +1,242 @@ +package api + +import ( + "encoding/json" + "net/http" + + "novamd/internal/db" + "novamd/internal/filesystem" + "novamd/internal/models" +) + +func ListWorkspaces(db *db.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + userID, err := getUserID(r) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + workspaces, err := db.GetWorkspacesByUserID(userID) + if err != nil { + http.Error(w, "Failed to list workspaces", http.StatusInternalServerError) + return + } + + respondJSON(w, workspaces) + } +} + +func CreateWorkspace(db *db.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + userID, err := getUserID(r) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + var workspace models.Workspace + if err := json.NewDecoder(r.Body).Decode(&workspace); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + workspace.UserID = userID + if err := db.CreateWorkspace(&workspace); err != nil { + http.Error(w, "Failed to create workspace", http.StatusInternalServerError) + return + } + + respondJSON(w, workspace) + } +} + +func GetWorkspace(db *db.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + userID, workspaceID, err := getUserAndWorkspaceIDs(r) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + workspace, err := db.GetWorkspaceByID(workspaceID) + if err != nil { + http.Error(w, "Workspace not found", http.StatusNotFound) + return + } + + if workspace.UserID != userID { + http.Error(w, "Unauthorized access to workspace", http.StatusForbidden) + return + } + + respondJSON(w, workspace) + } +} + +func UpdateWorkspace(db *db.DB, fs *filesystem.FileSystem) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + userID, workspaceID, err := getUserAndWorkspaceIDs(r) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + var workspace models.Workspace + if err := json.NewDecoder(r.Body).Decode(&workspace); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + // Set IDs from the request + workspace.ID = workspaceID + workspace.UserID = userID + + // Validate the workspace + if err := workspace.Validate(); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + // Get current workspace for comparison + currentWorkspace, err := db.GetWorkspaceByID(workspaceID) + if err != nil { + http.Error(w, "Workspace not found", http.StatusNotFound) + return + } + + if currentWorkspace.UserID != userID { + http.Error(w, "Unauthorized access to workspace", http.StatusForbidden) + return + } + + // Handle Git repository setup/teardown if Git settings changed + if workspace.GitEnabled != currentWorkspace.GitEnabled || + (workspace.GitEnabled && (workspace.GitURL != currentWorkspace.GitURL || + workspace.GitUser != currentWorkspace.GitUser || + workspace.GitToken != currentWorkspace.GitToken)) { + if workspace.GitEnabled { + err = fs.SetupGitRepo(userID, workspaceID, workspace.GitURL, workspace.GitUser, workspace.GitToken) + if err != nil { + http.Error(w, "Failed to setup git repo: "+err.Error(), http.StatusInternalServerError) + return + } + } else { + fs.DisableGitRepo(userID, workspaceID) + } + } + + if err := db.UpdateWorkspace(&workspace); err != nil { + http.Error(w, "Failed to update workspace", http.StatusInternalServerError) + return + } + + respondJSON(w, workspace) + } +} + +func DeleteWorkspace(db *db.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + userID, workspaceID, err := getUserAndWorkspaceIDs(r) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + // Check if this is the user's last workspace + workspaces, err := db.GetWorkspacesByUserID(userID) + if err != nil { + http.Error(w, "Failed to get workspaces", http.StatusInternalServerError) + return + } + + if len(workspaces) <= 1 { + http.Error(w, "Cannot delete the last workspace", http.StatusBadRequest) + return + } + + // Find another workspace to set as last + var nextWorkspaceID int + for _, ws := range workspaces { + if ws.ID != workspaceID { + nextWorkspaceID = ws.ID + break + } + } + + // Start transaction + tx, err := db.Begin() + if err != nil { + http.Error(w, "Failed to start transaction", http.StatusInternalServerError) + return + } + defer tx.Rollback() + + // Update last workspace ID first + err = db.UpdateLastWorkspaceTx(tx, userID, nextWorkspaceID) + if err != nil { + http.Error(w, "Failed to update last workspace", http.StatusInternalServerError) + return + } + + // Delete the workspace + err = db.DeleteWorkspaceTx(tx, workspaceID) + if err != nil { + http.Error(w, "Failed to delete workspace", http.StatusInternalServerError) + return + } + + // Commit transaction + if err = tx.Commit(); err != nil { + http.Error(w, "Failed to commit transaction", http.StatusInternalServerError) + return + } + + // Return the next workspace ID in the response so frontend knows where to redirect + respondJSON(w, map[string]int{"nextWorkspaceId": nextWorkspaceID}) + } +} + +func GetLastWorkspace(db *db.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + userID, err := getUserID(r) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + workspaceID, err := db.GetLastWorkspaceID(userID) + if err != nil { + http.Error(w, "Failed to get last workspace", http.StatusInternalServerError) + return + } + + respondJSON(w, map[string]int{"lastWorkspaceId": workspaceID}) + } +} + +func UpdateLastWorkspace(db *db.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + userID, err := getUserID(r) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + var requestBody struct { + WorkspaceID int `json:"workspaceId"` + } + + if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + if err := db.UpdateLastWorkspace(userID, requestBody.WorkspaceID); err != nil { + http.Error(w, "Failed to update last workspace", http.StatusInternalServerError) + return + } + + respondJSON(w, map[string]string{"message": "Last workspace updated successfully"}) + } +} diff --git a/backend/internal/db/migrations.go b/backend/internal/db/migrations.go index 1973da6..4eb9402 100644 --- a/backend/internal/db/migrations.go +++ b/backend/internal/db/migrations.go @@ -13,10 +13,37 @@ type Migration struct { var migrations = []Migration{ { Version: 1, - SQL: `CREATE TABLE IF NOT EXISTS settings ( - user_id INTEGER PRIMARY KEY, - settings JSON NOT NULL - )`, + SQL: ` + -- Create users table + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + email TEXT NOT NULL UNIQUE, + display_name TEXT, + password_hash TEXT NOT NULL, + role TEXT NOT NULL CHECK(role IN ('admin', 'editor', 'viewer')), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + last_workspace_id INTEGER, + last_opened_file_path TEXT + ); + + -- Create workspaces table with integrated settings + CREATE TABLE IF NOT EXISTS workspaces ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + name TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + -- Settings fields + theme TEXT NOT NULL DEFAULT 'light' CHECK(theme IN ('light', 'dark')), + auto_save BOOLEAN NOT NULL DEFAULT 0, + git_enabled BOOLEAN NOT NULL DEFAULT 0, + git_url TEXT, + git_user TEXT, + git_token TEXT, + git_auto_commit BOOLEAN NOT NULL DEFAULT 0, + git_commit_msg_template TEXT DEFAULT '${action} ${filename}', + FOREIGN KEY (user_id) REFERENCES users (id) + ); + `, }, } diff --git a/backend/internal/db/settings.go b/backend/internal/db/settings.go deleted file mode 100644 index f0da94a..0000000 --- a/backend/internal/db/settings.go +++ /dev/null @@ -1,45 +0,0 @@ -package db - -import ( - "database/sql" - "encoding/json" - - "novamd/internal/models" -) - -func (db *DB) GetSettings(userID int) (models.Settings, error) { - var settings models.Settings - var settingsJSON []byte - - err := db.QueryRow("SELECT user_id, settings FROM settings WHERE user_id = ?", userID).Scan(&settings.UserID, &settingsJSON) - if err != nil { - if err == sql.ErrNoRows { - // If no settings found, return default settings - settings.UserID = userID - settings.Settings = models.UserSettings{} // This will be filled with defaults later - return settings, nil - } - return settings, err - } - - err = json.Unmarshal(settingsJSON, &settings.Settings) - if err != nil { - return settings, err - } - - return settings, nil -} - -func (db *DB) SaveSettings(settings models.Settings) error { - if err := settings.Validate(); err != nil { - return err - } - - settingsJSON, err := json.Marshal(settings.Settings) - if err != nil { - return err - } - - _, err = db.Exec("INSERT OR REPLACE INTO settings (user_id, settings) VALUES (?, json(?))", settings.UserID, string(settingsJSON)) - return err -} diff --git a/backend/internal/db/users.go b/backend/internal/db/users.go new file mode 100644 index 0000000..4a0251d --- /dev/null +++ b/backend/internal/db/users.go @@ -0,0 +1,168 @@ +package db + +import ( + "database/sql" + "novamd/internal/models" +) + +func (db *DB) CreateUser(user *models.User) error { + tx, err := db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + + result, err := tx.Exec(` + INSERT INTO users (email, display_name, password_hash, role) + VALUES (?, ?, ?, ?)`, + user.Email, user.DisplayName, user.PasswordHash, user.Role) + if err != nil { + return err + } + + userID, err := result.LastInsertId() + if err != nil { + return err + } + user.ID = int(userID) + + // Create default workspace with default settings + defaultWorkspace := &models.Workspace{ + UserID: user.ID, + Name: "Main", + } + defaultWorkspace.GetDefaultSettings() // Initialize default settings + + // Create workspace with settings + err = db.createWorkspaceTx(tx, defaultWorkspace) + if err != nil { + return err + } + + // Update user's last workspace ID + _, err = tx.Exec("UPDATE users SET last_workspace_id = ? WHERE id = ?", defaultWorkspace.ID, user.ID) + if err != nil { + return err + } + + err = tx.Commit() + if err != nil { + return err + } + + user.LastWorkspaceID = defaultWorkspace.ID + return nil +} + +func (db *DB) createWorkspaceTx(tx *sql.Tx, workspace *models.Workspace) error { + result, err := tx.Exec(` + INSERT INTO workspaces ( + user_id, name, + theme, auto_save, + git_enabled, git_url, git_user, git_token, + git_auto_commit, git_commit_msg_template + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + workspace.UserID, workspace.Name, + workspace.Theme, workspace.AutoSave, + workspace.GitEnabled, workspace.GitURL, workspace.GitUser, workspace.GitToken, + workspace.GitAutoCommit, workspace.GitCommitMsgTemplate, + ) + if err != nil { + return err + } + id, err := result.LastInsertId() + if err != nil { + return err + } + workspace.ID = int(id) + return nil +} + +func (db *DB) GetUserByID(id int) (*models.User, error) { + user := &models.User{} + err := db.QueryRow(` + SELECT + u.id, u.email, u.display_name, u.role, u.created_at, + u.last_workspace_id, u.last_opened_file_path, + COALESCE(w.id, 0) as workspace_id + FROM users u + LEFT JOIN workspaces w ON w.id = u.last_workspace_id + WHERE u.id = ?`, id). + Scan(&user.ID, &user.Email, &user.DisplayName, &user.Role, &user.CreatedAt, + &user.LastWorkspaceID, &user.LastOpenedFilePath, &user.LastWorkspaceID) + if err != nil { + return nil, err + } + return user, nil +} + +func (db *DB) GetUserByEmail(email string) (*models.User, error) { + user := &models.User{} + var lastOpenedFilePath sql.NullString + err := db.QueryRow(` + SELECT + u.id, u.email, u.display_name, u.password_hash, u.role, u.created_at, + u.last_workspace_id, u.last_opened_file_path, + COALESCE(w.id, 0) as workspace_id + FROM users u + LEFT JOIN workspaces w ON w.id = u.last_workspace_id + WHERE u.email = ?`, email). + Scan(&user.ID, &user.Email, &user.DisplayName, &user.PasswordHash, &user.Role, &user.CreatedAt, + &user.LastWorkspaceID, &lastOpenedFilePath, &user.LastWorkspaceID) + if err != nil { + return nil, err + } + if lastOpenedFilePath.Valid { + user.LastOpenedFilePath = lastOpenedFilePath.String + } else { + user.LastOpenedFilePath = "" + } + return user, nil +} + +func (db *DB) UpdateUser(user *models.User) error { + _, err := db.Exec(` + UPDATE users + SET email = ?, display_name = ?, role = ?, last_workspace_id = ?, last_opened_file_path = ? + WHERE id = ?`, + user.Email, user.DisplayName, user.Role, user.LastWorkspaceID, user.LastOpenedFilePath, user.ID) + return err +} + +func (db *DB) UpdateLastWorkspace(userID, workspaceID int) error { + _, err := db.Exec("UPDATE users SET last_workspace_id = ? WHERE id = ?", workspaceID, userID) + return err +} + +func (db *DB) UpdateLastOpenedFile(userID int, filePath string) error { + _, err := db.Exec("UPDATE users SET last_opened_file_path = ? WHERE id = ?", filePath, userID) + return err +} + +func (db *DB) DeleteUser(id int) error { + tx, err := db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + + // Delete all user's workspaces first + _, err = tx.Exec("DELETE FROM workspaces WHERE user_id = ?", id) + if err != nil { + return err + } + + // Delete the user + _, err = tx.Exec("DELETE FROM users WHERE id = ?", id) + if err != nil { + return err + } + + return tx.Commit() +} + +func (db *DB) GetLastWorkspaceID(userID int) (int, error) { + var workspaceID int + err := db.QueryRow("SELECT last_workspace_id FROM users WHERE id = ?", userID).Scan(&workspaceID) + return workspaceID, err +} diff --git a/backend/internal/db/workspaces.go b/backend/internal/db/workspaces.go new file mode 100644 index 0000000..344e4e2 --- /dev/null +++ b/backend/internal/db/workspaces.go @@ -0,0 +1,162 @@ +package db + +import ( + "database/sql" + "novamd/internal/models" +) + +func (db *DB) CreateWorkspace(workspace *models.Workspace) error { + // Set default settings if not provided + if workspace.Theme == "" { + workspace.GetDefaultSettings() + } + + result, err := db.Exec(` + INSERT INTO workspaces ( + user_id, name, theme, auto_save, + git_enabled, git_url, git_user, git_token, + git_auto_commit, git_commit_msg_template + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + workspace.UserID, workspace.Name, workspace.Theme, workspace.AutoSave, + workspace.GitEnabled, workspace.GitURL, workspace.GitUser, workspace.GitToken, + workspace.GitAutoCommit, workspace.GitCommitMsgTemplate, + ) + if err != nil { + return err + } + + id, err := result.LastInsertId() + if err != nil { + return err + } + workspace.ID = int(id) + return nil +} + +func (db *DB) GetWorkspaceByID(id int) (*models.Workspace, error) { + workspace := &models.Workspace{} + err := db.QueryRow(` + SELECT + id, user_id, name, created_at, + theme, auto_save, + git_enabled, git_url, git_user, git_token, + git_auto_commit, git_commit_msg_template + FROM workspaces + WHERE id = ?`, + id, + ).Scan( + &workspace.ID, &workspace.UserID, &workspace.Name, &workspace.CreatedAt, + &workspace.Theme, &workspace.AutoSave, + &workspace.GitEnabled, &workspace.GitURL, &workspace.GitUser, &workspace.GitToken, + &workspace.GitAutoCommit, &workspace.GitCommitMsgTemplate, + ) + if err != nil { + return nil, err + } + return workspace, nil +} + +func (db *DB) GetWorkspacesByUserID(userID int) ([]*models.Workspace, error) { + rows, err := db.Query(` + SELECT + id, user_id, name, created_at, + theme, auto_save, + git_enabled, git_url, git_user, git_token, + git_auto_commit, git_commit_msg_template + FROM workspaces + WHERE user_id = ?`, + userID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var workspaces []*models.Workspace + for rows.Next() { + workspace := &models.Workspace{} + err := rows.Scan( + &workspace.ID, &workspace.UserID, &workspace.Name, &workspace.CreatedAt, + &workspace.Theme, &workspace.AutoSave, + &workspace.GitEnabled, &workspace.GitURL, &workspace.GitUser, &workspace.GitToken, + &workspace.GitAutoCommit, &workspace.GitCommitMsgTemplate, + ) + if err != nil { + return nil, err + } + workspaces = append(workspaces, workspace) + } + return workspaces, nil +} + +func (db *DB) UpdateWorkspace(workspace *models.Workspace) error { + _, err := db.Exec(` + UPDATE workspaces + SET + name = ?, + theme = ?, + auto_save = ?, + git_enabled = ?, + git_url = ?, + git_user = ?, + git_token = ?, + git_auto_commit = ?, + git_commit_msg_template = ? + WHERE id = ? AND user_id = ?`, + workspace.Name, + workspace.Theme, + workspace.AutoSave, + workspace.GitEnabled, + workspace.GitURL, + workspace.GitUser, + workspace.GitToken, + workspace.GitAutoCommit, + workspace.GitCommitMsgTemplate, + workspace.ID, + workspace.UserID, + ) + return err +} + +// UpdateWorkspaceSettings updates only the settings portion of a workspace +// This is useful when you don't want to modify the name or other core workspace properties +func (db *DB) UpdateWorkspaceSettings(workspace *models.Workspace) error { + _, err := db.Exec(` + UPDATE workspaces + SET + theme = ?, + auto_save = ?, + git_enabled = ?, + git_url = ?, + git_user = ?, + git_token = ?, + git_auto_commit = ?, + git_commit_msg_template = ? + WHERE id = ?`, + workspace.Theme, + workspace.AutoSave, + workspace.GitEnabled, + workspace.GitURL, + workspace.GitUser, + workspace.GitToken, + workspace.GitAutoCommit, + workspace.GitCommitMsgTemplate, + workspace.ID, + ) + return err +} + +func (db *DB) DeleteWorkspace(id int) error { + _, err := db.Exec("DELETE FROM workspaces WHERE id = ?", id) + return err +} + +func (db *DB) DeleteWorkspaceTx(tx *sql.Tx, id int) error { + _, err := tx.Exec("DELETE FROM workspaces WHERE id = ?", id) + return err +} + +func (db *DB) UpdateLastWorkspaceTx(tx *sql.Tx, userID, workspaceID int) error { + _, err := tx.Exec("UPDATE users SET last_workspace_id = ? WHERE id = ?", workspaceID, userID) + return err +} diff --git a/backend/internal/filesystem/filesystem.go b/backend/internal/filesystem/filesystem.go index b589ea8..6e01536 100644 --- a/backend/internal/filesystem/filesystem.go +++ b/backend/internal/filesystem/filesystem.go @@ -4,17 +4,14 @@ import ( "errors" "fmt" "novamd/internal/gitutils" - "novamd/internal/models" "os" "path/filepath" - "sort" "strings" ) type FileSystem struct { RootDir string - GitRepo *gitutils.GitRepo - Settings *models.Settings + GitRepos map[int]map[int]*gitutils.GitRepo // map[userID]map[workspaceID]*gitutils.GitRepo } type FileNode struct { @@ -24,67 +21,59 @@ type FileNode struct { Children []FileNode `json:"children,omitempty"` } -func New(rootDir string, settings *models.Settings) *FileSystem { - fs := &FileSystem{ +func New(rootDir string) *FileSystem { + return &FileSystem{ RootDir: rootDir, - Settings: settings, + GitRepos: make(map[int]map[int]*gitutils.GitRepo), + } +} + +func (fs *FileSystem) GetWorkspacePath(userID, workspaceID int) string { + return filepath.Join(fs.RootDir, fmt.Sprintf("%d", userID), fmt.Sprintf("%d", workspaceID)) +} + +func (fs *FileSystem) InitializeUserWorkspace(userID, workspaceID int) error { + workspacePath := fs.GetWorkspacePath(userID, workspaceID) + err := os.MkdirAll(workspacePath, 0755) + if err != nil { + return fmt.Errorf("failed to create workspace directory: %w", err) + } + // Optionally, create a welcome file in the new workspace + // welcomeFilePath := filepath.Join(workspacePath, "Welcome.md") + // welcomeContent := []byte("# Welcome to Your Main Workspace\n\nThis is your default workspace in NovaMD. You can start creating and editing files right away!") + // err = os.WriteFile(welcomeFilePath, welcomeContent, 0644) + // if err != nil { + // return fmt.Errorf("failed to create welcome file: %w", err) + // } + + return nil +} + +func (fs *FileSystem) DeleteUserWorkspace(userID, workspaceID int) error { + workspacePath := fs.GetWorkspacePath(userID, workspaceID) + err := os.RemoveAll(workspacePath) + if err != nil { + return fmt.Errorf("failed to delete workspace directory: %w", err) } - if settings.Settings.GitEnabled { - fs.GitRepo = gitutils.New( - settings.Settings.GitURL, - settings.Settings.GitUser, - settings.Settings.GitToken, - rootDir, - ) - } - - return fs + return nil } -func (fs *FileSystem) SetupGitRepo(gitURL string, gitUser string, gitToken string) error { - fs.GitRepo = gitutils.New(gitURL, gitUser, gitToken, fs.RootDir) - return fs.InitializeGitRepo() -} - -func (fs *FileSystem) DisableGitRepo() { - fs.GitRepo = nil; -} - -func (fs *FileSystem) InitializeGitRepo() error { - if fs.GitRepo == nil { - return errors.New("git settings not configured") - } - - return fs.GitRepo.EnsureRepo() -} - -func ValidatePath(rootDir, path string) (string, error) { - fullPath := filepath.Join(rootDir, path) +func (fs *FileSystem) ValidatePath(userID, workspaceID int, path string) (string, error) { + workspacePath := fs.GetWorkspacePath(userID, workspaceID) + fullPath := filepath.Join(workspacePath, path) cleanPath := filepath.Clean(fullPath) - if !strings.HasPrefix(cleanPath, filepath.Clean(rootDir)) { - return "", fmt.Errorf("invalid path: outside of root directory") - } - - relPath, err := filepath.Rel(rootDir, cleanPath) - if err != nil { - return "", err - } - - if strings.HasPrefix(relPath, "..") { - return "", fmt.Errorf("invalid path: outside of root directory") + if !strings.HasPrefix(cleanPath, workspacePath) { + return "", fmt.Errorf("invalid path: outside of workspace") } return cleanPath, nil } -func (fs *FileSystem) validatePath(path string) (string, error) { - return ValidatePath(fs.RootDir, path) -} - -func (fs *FileSystem) ListFilesRecursively() ([]FileNode, error) { - return fs.walkDirectory(fs.RootDir, "") +func (fs *FileSystem) ListFilesRecursively(userID, workspaceID int) ([]FileNode, error) { + workspacePath := fs.GetWorkspacePath(userID, workspaceID) + return fs.walkDirectory(workspacePath, "") } func (fs *FileSystem) walkDirectory(dir, prefix string) ([]FileNode, error) { @@ -93,67 +82,46 @@ func (fs *FileSystem) walkDirectory(dir, prefix string) ([]FileNode, error) { return nil, err } - var folders []FileNode - var files []FileNode - + nodes := make([]FileNode, 0) for _, entry := range entries { name := entry.Name() path := filepath.Join(prefix, name) fullPath := filepath.Join(dir, name) + node := FileNode{ + ID: path, + Name: name, + Path: path, + } + if entry.IsDir() { children, err := fs.walkDirectory(fullPath, path) if err != nil { return nil, err } - folders = append(folders, FileNode{ - ID: path, // Using path as ID ensures uniqueness - Name: name, - Path: path, - Children: children, - }) - } else { - files = append(files, FileNode{ - ID: path, // Using path as ID ensures uniqueness - Name: name, - Path: path, - }) + node.Children = children } + + nodes = append(nodes, node) } - // Sort folders and files alphabetically - sort.Slice(folders, func(i, j int) bool { return folders[i].Name < folders[j].Name }) - sort.Slice(files, func(i, j int) bool { return files[i].Name < files[i].Name }) - - // Combine folders and files, with folders first - return append(folders, files...), nil + return nodes, nil } - -func (fs *FileSystem) FindFileByName(filenameOrPath string) ([]string, error) { +func (fs *FileSystem) FindFileByName(userID, workspaceID int, filename string) ([]string, error) { var foundPaths []string - var searchPattern string + workspacePath := fs.GetWorkspacePath(userID, workspaceID) - // If no extension is provided, assume .md - if !strings.Contains(filenameOrPath, ".") { - searchPattern = filenameOrPath + ".md" - } else { - searchPattern = filenameOrPath - } - - err := filepath.Walk(fs.RootDir, func(path string, info os.FileInfo, err error) error { + err := filepath.Walk(workspacePath, func(path string, info os.FileInfo, err error) error { if err != nil { return err } if !info.IsDir() { - relPath, err := filepath.Rel(fs.RootDir, path) + relPath, err := filepath.Rel(workspacePath, path) if err != nil { return err } - - // Check if the file matches the search pattern - if strings.HasSuffix(relPath, searchPattern) || - strings.EqualFold(info.Name(), searchPattern) { + if strings.EqualFold(info.Name(), filename) { foundPaths = append(foundPaths, relPath) } } @@ -171,16 +139,16 @@ func (fs *FileSystem) FindFileByName(filenameOrPath string) ([]string, error) { return foundPaths, nil } -func (fs *FileSystem) GetFileContent(filePath string) ([]byte, error) { - fullPath, err := fs.validatePath(filePath) +func (fs *FileSystem) GetFileContent(userID, workspaceID int, filePath string) ([]byte, error) { + fullPath, err := fs.ValidatePath(userID, workspaceID, filePath) if err != nil { return nil, err } return os.ReadFile(fullPath) } -func (fs *FileSystem) SaveFile(filePath string, content []byte) error { - fullPath, err := fs.validatePath(filePath) +func (fs *FileSystem) SaveFile(userID, workspaceID int, filePath string, content []byte) error { + fullPath, err := fs.ValidatePath(userID, workspaceID, filePath) if err != nil { return err } @@ -193,30 +161,64 @@ func (fs *FileSystem) SaveFile(filePath string, content []byte) error { return os.WriteFile(fullPath, content, 0644) } -func (fs *FileSystem) DeleteFile(filePath string) error { - fullPath, err := fs.validatePath(filePath) +func (fs *FileSystem) DeleteFile(userID, workspaceID int, filePath string) error { + fullPath, err := fs.ValidatePath(userID, workspaceID, filePath) if err != nil { return err } return os.Remove(fullPath) } -func (fs *FileSystem) StageCommitAndPush(message string) error { - if fs.GitRepo == nil { - return errors.New("git settings not configured") +func (fs *FileSystem) CreateWorkspaceDirectory(userID, workspaceID int) error { + dir := fs.GetWorkspacePath(userID, workspaceID) + return os.MkdirAll(dir, 0755) +} + +func (fs *FileSystem) SetupGitRepo(userID, workspaceID int, gitURL, gitUser, gitToken string) error { + workspacePath := fs.GetWorkspacePath(userID, workspaceID) + if _, ok := fs.GitRepos[userID]; !ok { + fs.GitRepos[userID] = make(map[int]*gitutils.GitRepo) + } + fs.GitRepos[userID][workspaceID] = gitutils.New(gitURL, gitUser, gitToken, workspacePath) + return fs.GitRepos[userID][workspaceID].EnsureRepo() +} + +func (fs *FileSystem) DisableGitRepo(userID, workspaceID int) { + if userRepos, ok := fs.GitRepos[userID]; ok { + delete(userRepos, workspaceID) + if len(userRepos) == 0 { + delete(fs.GitRepos, userID) + } + } +} + +func (fs *FileSystem) StageCommitAndPush(userID, workspaceID int, message string) error { + repo, ok := fs.getGitRepo(userID, workspaceID) + if !ok { + return errors.New("git settings not configured for this workspace") } - if err := fs.GitRepo.Commit(message); err != nil { + if err := repo.Commit(message); err != nil { return err } - return fs.GitRepo.Push() + return repo.Push() } -func (fs *FileSystem) Pull() error { - if fs.GitRepo == nil { - return errors.New("git settings not configured") +func (fs *FileSystem) Pull(userID, workspaceID int) error { + repo, ok := fs.getGitRepo(userID, workspaceID) + if !ok { + return errors.New("git settings not configured for this workspace") } - return fs.GitRepo.Pull() + return repo.Pull() +} + +func (fs *FileSystem) getGitRepo(userID, workspaceID int) (*gitutils.GitRepo, bool) { + userRepos, ok := fs.GitRepos[userID] + if !ok { + return nil, false + } + repo, ok := userRepos[workspaceID] + return repo, ok } diff --git a/backend/internal/models/settings.go b/backend/internal/models/settings.go deleted file mode 100644 index 602bd45..0000000 --- a/backend/internal/models/settings.go +++ /dev/null @@ -1,58 +0,0 @@ -package models - -import ( - "encoding/json" - - "github.com/go-playground/validator/v10" -) - -type UserSettings struct { - Theme string `json:"theme" validate:"oneof=light dark"` - AutoSave bool `json:"autoSave"` - GitEnabled bool `json:"gitEnabled"` - GitURL string `json:"gitUrl" validate:"required_with=GitEnabled"` - GitUser string `json:"gitUser" validate:"required_with=GitEnabled"` - GitToken string `json:"gitToken" validate:"required_with=GitEnabled"` - GitAutoCommit bool `json:"gitAutoCommit"` - GitCommitMsgTemplate string `json:"gitCommitMsgTemplate"` -} - -type Settings struct { - UserID int `json:"userId" validate:"required,min=1"` - Settings UserSettings `json:"settings" validate:"required"` -} - -var defaultUserSettings = UserSettings{ - Theme: "light", - AutoSave: false, - GitEnabled: false, - GitCommitMsgTemplate: "Update ${filename}", -} - -var validate = validator.New() - -func (s *Settings) Validate() error { - return validate.Struct(s) -} - -func (s *Settings) SetDefaults() { - if s.Settings.Theme == "" { - s.Settings.Theme = defaultUserSettings.Theme - } - if s.Settings.GitCommitMsgTemplate == "" { - s.Settings.GitCommitMsgTemplate = defaultUserSettings.GitCommitMsgTemplate - } -} - -func (s *Settings) UnmarshalJSON(data []byte) error { - type Alias Settings - aux := &struct { - *Alias - }{ - Alias: (*Alias)(s), - } - if err := json.Unmarshal(data, &aux); err != nil { - return err - } - return s.Validate() -} diff --git a/backend/internal/models/user.go b/backend/internal/models/user.go new file mode 100644 index 0000000..8881601 --- /dev/null +++ b/backend/internal/models/user.go @@ -0,0 +1,32 @@ +package models + +import ( + "time" + + "github.com/go-playground/validator/v10" +) + +var validate = validator.New() + +type UserRole string + +const ( + RoleAdmin UserRole = "admin" + RoleEditor UserRole = "editor" + RoleViewer UserRole = "viewer" +) + +type User struct { + ID int `json:"id" validate:"required,min=1"` + Email string `json:"email" validate:"required,email"` + DisplayName string `json:"displayName"` + PasswordHash string `json:"-"` + Role UserRole `json:"role" validate:"required,oneof=admin editor viewer"` + CreatedAt time.Time `json:"createdAt"` + LastWorkspaceID int `json:"lastWorkspaceId"` + LastOpenedFilePath string `json:"lastOpenedFilePath"` +} + +func (u *User) Validate() error { + return validate.Struct(u) +} diff --git a/backend/internal/models/workspace.go b/backend/internal/models/workspace.go new file mode 100644 index 0000000..28f1bc5 --- /dev/null +++ b/backend/internal/models/workspace.go @@ -0,0 +1,37 @@ +package models + +import ( + "time" +) + +type Workspace struct { + ID int `json:"id" validate:"required,min=1"` + UserID int `json:"userId" validate:"required,min=1"` + Name string `json:"name" validate:"required"` + CreatedAt time.Time `json:"createdAt"` + + // Integrated settings + Theme string `json:"theme" validate:"oneof=light dark"` + AutoSave bool `json:"autoSave"` + GitEnabled bool `json:"gitEnabled"` + GitURL string `json:"gitUrl" validate:"required_if=GitEnabled true"` + GitUser string `json:"gitUser" validate:"required_if=GitEnabled true"` + GitToken string `json:"gitToken" validate:"required_if=GitEnabled true"` + GitAutoCommit bool `json:"gitAutoCommit"` + GitCommitMsgTemplate string `json:"gitCommitMsgTemplate"` +} + +func (w *Workspace) Validate() error { + return validate.Struct(w) +} + +func (w *Workspace) GetDefaultSettings() { + w.Theme = "light" + w.AutoSave = false + w.GitEnabled = false + w.GitURL = "" + w.GitUser = "" + w.GitToken = "" + w.GitAutoCommit = false + w.GitCommitMsgTemplate = "${action} ${filename}" +} diff --git a/backend/internal/user/user.go b/backend/internal/user/user.go new file mode 100644 index 0000000..b725256 --- /dev/null +++ b/backend/internal/user/user.go @@ -0,0 +1,120 @@ +package user + +import ( + "fmt" + "log" + "os" + + "golang.org/x/crypto/bcrypt" + + "novamd/internal/db" + "novamd/internal/filesystem" + "novamd/internal/models" +) + +type UserService struct { + DB *db.DB + FS *filesystem.FileSystem +} + +func NewUserService(database *db.DB, fs *filesystem.FileSystem) *UserService { + return &UserService{ + DB: database, + FS: fs, + } +} + +func (s *UserService) SetupAdminUser() (*models.User, error) { + // Get admin email and password from environment variables + adminEmail := os.Getenv("NOVAMD_ADMIN_EMAIL") + adminPassword := os.Getenv("NOVAMD_ADMIN_PASSWORD") + if adminEmail == "" || adminPassword == "" { + return nil, fmt.Errorf("NOVAMD_ADMIN_EMAIL and NOVAMD_ADMIN_PASSWORD environment variables must be set") + } + + // Check if admin user exists + adminUser, err := s.DB.GetUserByEmail(adminEmail) + if adminUser != nil { + return adminUser, nil // Admin user already exists + } + + // Hash the password + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(adminPassword), bcrypt.DefaultCost) + if err != nil { + return nil, fmt.Errorf("failed to hash password: %w", err) + } + + // Create admin user + adminUser = &models.User{ + Email: adminEmail, + DisplayName: "Admin", + PasswordHash: string(hashedPassword), + Role: models.RoleAdmin, + } + + err = s.DB.CreateUser(adminUser) + if err != nil { + return nil, fmt.Errorf("failed to create admin user: %w", err) + } + + // Initialize workspace directory + err = s.FS.InitializeUserWorkspace(adminUser.ID, adminUser.LastWorkspaceID) + if err != nil { + return nil, fmt.Errorf("failed to initialize admin workspace: %w", err) + } + + log.Printf("Created admin user with ID: %d and default workspace with ID: %d", adminUser.ID, adminUser.LastWorkspaceID) + + return adminUser, nil +} + +func (s *UserService) CreateUser(user *models.User) error { + err := s.DB.CreateUser(user) + if err != nil { + return fmt.Errorf("failed to create user: %w", err) + } + + err = s.FS.InitializeUserWorkspace(user.ID, user.LastWorkspaceID) + if err != nil { + return fmt.Errorf("failed to initialize user workspace: %w", err) + } + + return nil +} + +func (s *UserService) GetUserByID(id int) (*models.User, error) { + return s.DB.GetUserByID(id) +} + +func (s *UserService) GetUserByEmail(email string) (*models.User, error) { + return s.DB.GetUserByEmail(email) +} + +func (s *UserService) UpdateUser(user *models.User) error { + return s.DB.UpdateUser(user) +} + +func (s *UserService) DeleteUser(id int) error { + // First, get the user to check if they exist + user, err := s.DB.GetUserByID(id) + if err != nil { + return fmt.Errorf("failed to get user: %w", err) + } + + // Get user's workspaces + workspaces, err := s.DB.GetWorkspacesByUserID(id) + if err != nil { + return fmt.Errorf("failed to get user's workspaces: %w", err) + } + + // Delete workspace directories + for _, workspace := range workspaces { + err = s.FS.DeleteUserWorkspace(user.ID, workspace.ID) + if err != nil { + return fmt.Errorf("failed to delete workspace files: %w", err) + } + } + + // Delete user from database (this will cascade delete workspaces) + return s.DB.DeleteUser(id) +} diff --git a/frontend/src/App.js b/frontend/src/App.js index 5b3a52b..2ba651a 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -3,19 +3,13 @@ import { MantineProvider, ColorSchemeScript } from '@mantine/core'; import { Notifications } from '@mantine/notifications'; import { ModalsProvider } from '@mantine/modals'; import Layout from './components/Layout'; -import { SettingsProvider, useSettings } from './contexts/SettingsContext'; +import { WorkspaceProvider } from './contexts/WorkspaceContext'; import { ModalProvider } from './contexts/ModalContext'; import '@mantine/core/styles.css'; import '@mantine/notifications/styles.css'; import './App.scss'; function AppContent() { - const { loading } = useSettings(); - - if (loading) { - return