From 08e76671d068b86890a2efb3873b27ede0193ef4 Mon Sep 17 00:00:00 2001 From: LordMathis Date: Mon, 2 Dec 2024 21:23:47 +0100 Subject: [PATCH 1/5] Rework app setup --- server/cmd/server/main.go | 9 +- server/documentation.md | 832 ++++++++++++++++++ server/internal/api/routes.go | 97 -- server/internal/app/app.go | 223 ----- server/internal/{config => app}/config.go | 13 +- .../internal/{config => app}/config_test.go | 13 +- server/internal/app/init.go | 111 +++ server/internal/app/options.go | 53 ++ server/internal/app/routes.go | 142 +++ server/internal/app/server.go | 40 + server/internal/handlers/integration_test.go | 55 +- 11 files changed, 1226 insertions(+), 362 deletions(-) create mode 100644 server/documentation.md delete mode 100644 server/internal/api/routes.go delete mode 100644 server/internal/app/app.go rename server/internal/{config => app}/config.go (88%) rename server/internal/{config => app}/config_test.go (97%) create mode 100644 server/internal/app/init.go create mode 100644 server/internal/app/options.go create mode 100644 server/internal/app/routes.go create mode 100644 server/internal/app/server.go diff --git a/server/cmd/server/main.go b/server/cmd/server/main.go index 47fcdc8..929a769 100644 --- a/server/cmd/server/main.go +++ b/server/cmd/server/main.go @@ -5,21 +5,22 @@ import ( "log" "novamd/internal/app" - "novamd/internal/config" ) func main() { // Load configuration - cfg, err := config.Load() + cfg, err := app.LoadConfig() if err != nil { log.Fatal("Failed to load configuration:", err) } // Initialize and start server - server, err := app.NewServer(cfg) + options, err := app.DefaultOptions(cfg) if err != nil { - log.Fatal("Failed to initialize server:", err) + log.Fatal("Failed to initialize server options:", err) } + + server := app.NewServer(options) defer func() { if err := server.Close(); err != nil { log.Println("Error closing server:", err) diff --git a/server/documentation.md b/server/documentation.md new file mode 100644 index 0000000..76e9edb --- /dev/null +++ b/server/documentation.md @@ -0,0 +1,832 @@ +# NovaMD Package Documentation + +Generated documentation for all packages in the NovaMD project. + +## Table of Contents + +- [cmd/server](#cmd-server) +- [internal/app](#internal-app) +- [internal/auth](#internal-auth) +- [internal/config](#internal-config) +- [internal/context](#internal-context) +- [internal/db](#internal-db) +- [internal/git](#internal-git) +- [internal/handlers](#internal-handlers) +- [internal/models](#internal-models) +- [internal/secrets](#internal-secrets) +- [internal/storage](#internal-storage) + +## cmd/server + +```go +Package main provides the entry point for the application. It loads the +configuration, initializes the server, and starts the server. +``` + +## internal/app + +```go +package app // import "novamd/internal/app" + +Package app provides application-level functionality for initializing and +running the server + +FUNCTIONS + +func SetupRoutes(r chi.Router, db db.Database, s storage.Manager, authMiddleware *auth.Middleware, sessionService *auth.SessionService) + SetupRoutes configures the API routes + + +TYPES + +type Server struct { + // Has unexported fields. +} + Server represents the HTTP server and its dependencies + +func NewServer(cfg *config.Config) (*Server, error) + NewServer initializes a new server instance with all dependencies + +func (s *Server) Close() error + Close handles graceful shutdown of server dependencies + +func (s *Server) Start() error + Start configures and starts the HTTP server + +``` + +## internal/auth + +```go +package auth // import "novamd/internal/auth" + +Package auth provides JWT token generation and validation + +TYPES + +type Claims struct { + jwt.RegisteredClaims // Embedded standard JWT claims + UserID int `json:"uid"` // User identifier + Role string `json:"role"` // User role (admin, editor, viewer) + Type TokenType `json:"type"` // Token type (access or refresh) +} + Claims represents the custom claims we store in JWT tokens + +type JWTConfig struct { + SigningKey string // Secret key used to sign tokens + AccessTokenExpiry time.Duration // How long access tokens are valid + RefreshTokenExpiry time.Duration // How long refresh tokens are valid +} + JWTConfig holds the configuration for the JWT service + +type JWTManager interface { + GenerateAccessToken(userID int, role string) (string, error) + GenerateRefreshToken(userID int, role string) (string, error) + ValidateToken(tokenString string) (*Claims, error) + RefreshAccessToken(refreshToken string) (string, error) +} + JWTManager defines the interface for managing JWT tokens + +func NewJWTService(config JWTConfig) (JWTManager, error) + NewJWTService creates a new JWT service with the provided configuration + Returns an error if the signing key is missing + +type Middleware struct { + // Has unexported fields. +} + Middleware handles JWT authentication for protected routes + +func NewMiddleware(jwtManager JWTManager) *Middleware + NewMiddleware creates a new authentication middleware + +func (m *Middleware) Authenticate(next http.Handler) http.Handler + Authenticate middleware validates JWT tokens and sets user information in + context + +func (m *Middleware) RequireRole(role string) func(http.Handler) http.Handler + RequireRole returns a middleware that ensures the user has the required role + +func (m *Middleware) RequireWorkspaceAccess(next http.Handler) http.Handler + RequireWorkspaceAccess returns a middleware that ensures the user has access + to the workspace + +type SessionService struct { + // Has unexported fields. +} + SessionService manages user sessions in the database + +func NewSessionService(db db.SessionStore, jwtManager JWTManager) *SessionService + NewSessionService creates a new session service with the given database and + JWT manager + +func (s *SessionService) CleanExpiredSessions() error + CleanExpiredSessions removes all expired sessions from the database + +func (s *SessionService) CreateSession(userID int, role string) (*models.Session, string, error) + CreateSession creates a new user session for a user with the given userID + and role + +func (s *SessionService) InvalidateSession(sessionID string) error + InvalidateSession removes a session with the given sessionID from the + database + +func (s *SessionService) RefreshSession(refreshToken string) (string, error) + RefreshSession creates a new access token using a refreshToken + +type TokenType string + TokenType represents the type of JWT token (access or refresh) + +const ( + AccessToken TokenType = "access" // AccessToken - Short-lived token for API access + RefreshToken TokenType = "refresh" // RefreshToken - Long-lived token for obtaining new access tokens +) +``` + +## internal/config + +```go +package config // import "novamd/internal/config" + +Package config provides the configuration for the application + +TYPES + +type Config struct { + DBPath string + WorkDir string + StaticPath string + Port string + AppURL string + CORSOrigins []string + AdminEmail string + AdminPassword string + EncryptionKey string + JWTSigningKey string + RateLimitRequests int + RateLimitWindow time.Duration + IsDevelopment bool +} + Config holds the configuration for the application + +func DefaultConfig() *Config + DefaultConfig returns a new Config instance with default values + +func Load() (*Config, error) + Load creates a new Config instance with values from environment variables + +func (c *Config) Validate() error + Validate checks if the configuration is valid + +``` + +## internal/context + +```go +package context // import "novamd/internal/context" + +Package context provides functions for managing request context + +CONSTANTS + +const ( + // HandlerContextKey is the key used to store handler context in the request context + HandlerContextKey contextKey = "handlerContext" +) + +FUNCTIONS + +func WithHandlerContext(r *http.Request, hctx *HandlerContext) *http.Request + WithHandlerContext adds handler context to the request + +func WithUserContextMiddleware(next http.Handler) http.Handler + WithUserContextMiddleware extracts user information from JWT claims and adds + it to the request context + +func WithWorkspaceContextMiddleware(db db.WorkspaceReader) func(http.Handler) http.Handler + WithWorkspaceContextMiddleware adds workspace information to the request + context + + +TYPES + +type HandlerContext struct { + UserID int + UserRole string + Workspace *models.Workspace // Optional, only set for workspace routes +} + HandlerContext holds the request-specific data available to all handlers + +func GetRequestContext(w http.ResponseWriter, r *http.Request) (*HandlerContext, bool) + GetRequestContext retrieves the handler context from the request + +type UserClaims struct { + UserID int + Role string +} + UserClaims represents user information from authentication + +func GetUserFromContext(ctx context.Context) (*UserClaims, error) + GetUserFromContext retrieves user claims from the context + +``` + +## internal/db + +```go +package db // import "novamd/internal/db" + +Package db provides the database access layer for the application. It contains +methods for interacting with the database, such as creating, updating, and +deleting records. + +CONSTANTS + +const ( + // JWTSecretKey is the key for the JWT secret in the system settings + JWTSecretKey = "jwt_secret" +) + +TYPES + +type Database interface { + UserStore + WorkspaceStore + SessionStore + SystemStore + Begin() (*sql.Tx, error) + Close() error + Migrate() error +} + Database defines the methods for interacting with the database + +func Init(dbPath string, secretsService secrets.Service) (Database, error) + Init initializes the database connection + +type Migration struct { + Version int + SQL string +} + Migration represents a database migration + +type SessionStore interface { + CreateSession(session *models.Session) error + GetSessionByRefreshToken(refreshToken string) (*models.Session, error) + DeleteSession(sessionID string) error + CleanExpiredSessions() error +} + SessionStore defines the methods for interacting with jwt sessions in the + database + +type SystemStore interface { + GetSystemStats() (*UserStats, error) + EnsureJWTSecret() (string, error) + GetSystemSetting(key string) (string, error) + SetSystemSetting(key, value string) error +} + SystemStore defines the methods for interacting with system settings and + stats in the database + +type UserStats struct { + TotalUsers int `json:"totalUsers"` + TotalWorkspaces int `json:"totalWorkspaces"` + ActiveUsers int `json:"activeUsers"` // Users with activity in last 30 days +} + UserStats represents system-wide statistics + +type UserStore interface { + CreateUser(user *models.User) (*models.User, error) + GetUserByEmail(email string) (*models.User, error) + GetUserByID(userID int) (*models.User, error) + GetAllUsers() ([]*models.User, error) + UpdateUser(user *models.User) error + DeleteUser(userID int) error + UpdateLastWorkspace(userID int, workspaceName string) error + GetLastWorkspaceName(userID int) (string, error) + CountAdminUsers() (int, error) +} + UserStore defines the methods for interacting with user data in the database + +type WorkspaceReader interface { + GetWorkspaceByID(workspaceID int) (*models.Workspace, error) + GetWorkspaceByName(userID int, workspaceName string) (*models.Workspace, error) + GetWorkspacesByUserID(userID int) ([]*models.Workspace, error) + GetAllWorkspaces() ([]*models.Workspace, error) +} + WorkspaceReader defines the methods for reading workspace data from the + database + +type WorkspaceStore interface { + WorkspaceReader + WorkspaceWriter +} + WorkspaceStore defines the methods for interacting with workspace data in + the database + +type WorkspaceWriter interface { + CreateWorkspace(workspace *models.Workspace) error + UpdateWorkspace(workspace *models.Workspace) error + DeleteWorkspace(workspaceID int) error + UpdateWorkspaceSettings(workspace *models.Workspace) error + DeleteWorkspaceTx(tx *sql.Tx, workspaceID int) error + UpdateLastWorkspaceTx(tx *sql.Tx, userID, workspaceID int) error + UpdateLastOpenedFile(workspaceID int, filePath string) error + GetLastOpenedFile(workspaceID int) (string, error) +} + WorkspaceWriter defines the methods for writing workspace data to the + database + +``` + +## internal/git + +```go +package git // import "novamd/internal/git" + +Package git provides functionalities to interact with Git repositories, +including cloning, pulling, committing, and pushing changes. + +TYPES + +type Client interface { + Clone() error + Pull() error + Commit(message string) error + Push() error + EnsureRepo() error +} + Client defines the interface for Git operations + +func New(url, username, token, workDir, commitName, commitEmail string) Client + New creates a new git Client instance + +type Config struct { + URL string + Username string + Token string + WorkDir string + CommitName string + CommitEmail string +} + Config holds the configuration for a Git client + +``` + +## internal/handlers + +```go +package handlers // import "novamd/internal/handlers" + +Package handlers contains the request handlers for the api routes. + +TYPES + +type CreateUserRequest struct { + Email string `json:"email"` + DisplayName string `json:"displayName"` + Password string `json:"password"` + Role models.UserRole `json:"role"` +} + CreateUserRequest holds the request fields for creating a new user + +type DeleteAccountRequest struct { + Password string `json:"password"` +} + DeleteAccountRequest represents a user account deletion request + +type Handler struct { + DB db.Database + Storage storage.Manager +} + Handler provides common functionality for all handlers + +func NewHandler(db db.Database, s storage.Manager) *Handler + NewHandler creates a new handler with the given dependencies + +func (h *Handler) AdminCreateUser() http.HandlerFunc + AdminCreateUser creates a new user + +func (h *Handler) AdminDeleteUser() http.HandlerFunc + AdminDeleteUser deletes a specific user + +func (h *Handler) AdminGetSystemStats() http.HandlerFunc + AdminGetSystemStats returns system-wide statistics for admins + +func (h *Handler) AdminGetUser() http.HandlerFunc + AdminGetUser gets a specific user by ID + +func (h *Handler) AdminListUsers() http.HandlerFunc + AdminListUsers returns a list of all users + +func (h *Handler) AdminListWorkspaces() http.HandlerFunc + AdminListWorkspaces returns a list of all workspaces and their stats + +func (h *Handler) AdminUpdateUser() http.HandlerFunc + AdminUpdateUser updates a specific user + +func (h *Handler) CreateWorkspace() http.HandlerFunc + CreateWorkspace creates a new workspace + +func (h *Handler) DeleteAccount() http.HandlerFunc + DeleteAccount handles user account deletion + +func (h *Handler) DeleteFile() http.HandlerFunc + DeleteFile deletes a file + +func (h *Handler) DeleteWorkspace() http.HandlerFunc + DeleteWorkspace deletes the current workspace + +func (h *Handler) GetCurrentUser() http.HandlerFunc + GetCurrentUser returns the currently authenticated user + +func (h *Handler) GetFileContent() http.HandlerFunc + GetFileContent returns the content of a file + +func (h *Handler) GetLastOpenedFile() http.HandlerFunc + GetLastOpenedFile returns the last opened file in the workspace + +func (h *Handler) GetLastWorkspaceName() http.HandlerFunc + GetLastWorkspaceName returns the name of the last opened workspace + +func (h *Handler) GetWorkspace() http.HandlerFunc + GetWorkspace returns the current workspace + +func (h *Handler) ListFiles() http.HandlerFunc + ListFiles returns a list of all files in the workspace + +func (h *Handler) ListWorkspaces() http.HandlerFunc + ListWorkspaces returns a list of all workspaces for the current user + +func (h *Handler) Login(authService *auth.SessionService) http.HandlerFunc + Login handles user authentication and returns JWT tokens + +func (h *Handler) Logout(authService *auth.SessionService) http.HandlerFunc + Logout invalidates the user's session + +func (h *Handler) LookupFileByName() http.HandlerFunc + LookupFileByName returns the paths of files with the given name + +func (h *Handler) PullChanges() http.HandlerFunc + PullChanges pulls changes from the remote repository + +func (h *Handler) RefreshToken(authService *auth.SessionService) http.HandlerFunc + RefreshToken generates a new access token using a refresh token + +func (h *Handler) SaveFile() http.HandlerFunc + SaveFile saves the content of a file + +func (h *Handler) StageCommitAndPush() http.HandlerFunc + StageCommitAndPush stages, commits, and pushes changes to the remote + repository + +func (h *Handler) UpdateLastOpenedFile() http.HandlerFunc + UpdateLastOpenedFile updates the last opened file in the workspace + +func (h *Handler) UpdateLastWorkspaceName() http.HandlerFunc + UpdateLastWorkspaceName updates the name of the last opened workspace + +func (h *Handler) UpdateProfile() http.HandlerFunc + UpdateProfile updates the current user's profile + +func (h *Handler) UpdateWorkspace() http.HandlerFunc + UpdateWorkspace updates the current workspace + +type LoginRequest struct { + Email string `json:"email"` + Password string `json:"password"` +} + LoginRequest represents a user login request + +type LoginResponse struct { + AccessToken string `json:"accessToken"` + RefreshToken string `json:"refreshToken"` + User *models.User `json:"user"` + Session *models.Session `json:"session"` +} + LoginResponse represents a user login response + +type RefreshRequest struct { + RefreshToken string `json:"refreshToken"` +} + RefreshRequest represents a refresh token request + +type RefreshResponse struct { + AccessToken string `json:"accessToken"` +} + RefreshResponse represents a refresh token response + +type StaticHandler struct { + // Has unexported fields. +} + StaticHandler serves static files with support for SPA routing and + pre-compressed files + +func NewStaticHandler(staticPath string) *StaticHandler + NewStaticHandler creates a new StaticHandler with the given static path + +func (h *StaticHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) + ServeHTTP serves the static files + +type SystemStats struct { + *db.UserStats + *storage.FileCountStats +} + SystemStats holds system-wide statistics + +type UpdateProfileRequest struct { + DisplayName string `json:"displayName"` + Email string `json:"email"` + CurrentPassword string `json:"currentPassword"` + NewPassword string `json:"newPassword"` +} + UpdateProfileRequest represents a user profile update request + +type UpdateUserRequest struct { + Email string `json:"email,omitempty"` + DisplayName string `json:"displayName,omitempty"` + Password string `json:"password,omitempty"` + Role models.UserRole `json:"role,omitempty"` +} + UpdateUserRequest holds the request fields for updating a user + +type WorkspaceStats struct { + UserID int `json:"userID"` + UserEmail string `json:"userEmail"` + WorkspaceID int `json:"workspaceID"` + WorkspaceName string `json:"workspaceName"` + WorkspaceCreatedAt time.Time `json:"workspaceCreatedAt"` + *storage.FileCountStats +} + WorkspaceStats holds workspace statistics + +``` + +## internal/models + +```go +package models // import "novamd/internal/models" + +Package models contains the data models used throughout the application. +These models are used to represent data in the database, as well as to validate +and serialize data in the application. + +TYPES + +type Session struct { + ID string // Unique session identifier + UserID int // ID of the user this session belongs to + RefreshToken string // The refresh token associated with this session + ExpiresAt time.Time // When this session expires + CreatedAt time.Time // When this session was created +} + Session represents a user session in the database + +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"` +} + User represents a user in the system + +func (u *User) Validate() error + Validate validates the user struct + +type UserRole string + UserRole represents the role of a user in the system + +const ( + RoleAdmin UserRole = "admin" + RoleEditor UserRole = "editor" + RoleViewer UserRole = "viewer" +) + User roles + +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"` + LastOpenedFilePath string `json:"lastOpenedFilePath"` + + // Integrated settings + Theme string `json:"theme" validate:"oneof=light dark"` + AutoSave bool `json:"autoSave"` + ShowHiddenFiles bool `json:"showHiddenFiles"` + 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"` + GitCommitName string `json:"gitCommitName"` + GitCommitEmail string `json:"gitCommitEmail" validate:"omitempty,required_if=GitEnabled true,email"` +} + Workspace represents a user's workspace in the system + +func (w *Workspace) SetDefaultSettings() + SetDefaultSettings sets the default settings for the workspace + +func (w *Workspace) Validate() error + Validate validates the workspace struct + +func (w *Workspace) ValidateGitSettings() error + ValidateGitSettings validates the git settings if git is enabled + +``` + +## internal/secrets + +```go +package secrets // import "novamd/internal/secrets" + +Package secrets provides an Encryptor interface for encrypting and decrypting +strings using AES-256-GCM. + +FUNCTIONS + +func ValidateKey(key string) error + ValidateKey checks if the provided base64-encoded key is suitable for + AES-256 + + +TYPES + +type Service interface { + Encrypt(plaintext string) (string, error) + Decrypt(ciphertext string) (string, error) +} + Service is an interface for encrypting and decrypting strings + +func NewService(key string) (Service, error) + NewService creates a new Encryptor instance with the provided base64-encoded + key + +``` + +## internal/storage + +```go +package storage // import "novamd/internal/storage" + +Package storage provides functionalities to interact with the file system, +including listing files, finding files by name, getting file content, saving +files, and deleting files. + +FUNCTIONS + +func IsPathValidationError(err error) bool + IsPathValidationError checks if the error is a PathValidationError + + +TYPES + +type FileCountStats struct { + TotalFiles int `json:"totalFiles"` + TotalSize int64 `json:"totalSize"` +} + FileCountStats holds statistics about files in a workspace + +type FileManager interface { + ListFilesRecursively(userID, workspaceID int) ([]FileNode, error) + FindFileByName(userID, workspaceID int, filename string) ([]string, error) + GetFileContent(userID, workspaceID int, filePath string) ([]byte, error) + SaveFile(userID, workspaceID int, filePath string, content []byte) error + DeleteFile(userID, workspaceID int, filePath string) error + GetFileStats(userID, workspaceID int) (*FileCountStats, error) + GetTotalFileStats() (*FileCountStats, error) +} + FileManager provides functionalities to interact with files in the storage. + +type FileNode struct { + ID string `json:"id"` + Name string `json:"name"` + Path string `json:"path"` + Children []FileNode `json:"children,omitempty"` +} + FileNode represents a file or directory in the storage. + +type Manager interface { + FileManager + WorkspaceManager + RepositoryManager +} + Manager interface combines all storage interfaces. + +type Options struct { + Fs fileSystem + NewGitClient func(url, user, token, path, commitName, commitEmail string) git.Client +} + Options represents the options for the storage service. + +type PathValidationError struct { + Path string + Message string +} + PathValidationError represents a path validation error (e.g., path traversal + attempt) + +func (e *PathValidationError) Error() string + +type RepositoryManager interface { + SetupGitRepo(userID, workspaceID int, gitURL, gitUser, gitToken, commitName, commitEmail string) error + DisableGitRepo(userID, workspaceID int) + StageCommitAndPush(userID, workspaceID int, message string) error + Pull(userID, workspaceID int) error +} + RepositoryManager defines the interface for managing Git repositories. + +type Service struct { + RootDir string + GitRepos map[int]map[int]git.Client // map[userID]map[workspaceID]*git.Client + // Has unexported fields. +} + Service represents the file system structure. + +func NewService(rootDir string) *Service + NewService creates a new Storage instance with the default options and the + given rootDir root directory. + +func NewServiceWithOptions(rootDir string, options Options) *Service + NewServiceWithOptions creates a new Storage instance with the given options + and the given rootDir root directory. + +func (s *Service) DeleteFile(userID, workspaceID int, filePath string) error + DeleteFile deletes the file at the given filePath. Path must be a relative + path within the workspace directory given by userID and workspaceID. + +func (s *Service) DeleteUserWorkspace(userID, workspaceID int) error + DeleteUserWorkspace deletes the workspace directory for the given userID and + workspaceID. + +func (s *Service) DisableGitRepo(userID, workspaceID int) + DisableGitRepo disables the Git repository for the given userID and + workspaceID. + +func (s *Service) FindFileByName(userID, workspaceID int, filename string) ([]string, error) + FindFileByName returns a list of file paths that match the given filename. + Files are searched recursively in the workspace directory and its + subdirectories. Workspace is identified by the given userID and workspaceID. + +func (s *Service) GetFileContent(userID, workspaceID int, filePath string) ([]byte, error) + GetFileContent returns the content of the file at the given filePath. + Path must be a relative path within the workspace directory given by userID + and workspaceID. + +func (s *Service) GetFileStats(userID, workspaceID int) (*FileCountStats, error) + GetFileStats returns the total number of files and related statistics in a + workspace Workspace is identified by the given userID and workspaceID + +func (s *Service) GetTotalFileStats() (*FileCountStats, error) + GetTotalFileStats returns the total file statistics for the storage. + +func (s *Service) GetWorkspacePath(userID, workspaceID int) string + GetWorkspacePath returns the path to the workspace directory for the given + userID and workspaceID. + +func (s *Service) InitializeUserWorkspace(userID, workspaceID int) error + InitializeUserWorkspace creates the workspace directory for the given userID + and workspaceID. + +func (s *Service) ListFilesRecursively(userID, workspaceID int) ([]FileNode, error) + ListFilesRecursively returns a list of all files in the workspace directory + and its subdirectories. Workspace is identified by the given userID and + workspaceID. + +func (s *Service) Pull(userID, workspaceID int) error + Pull pulls the changes from the remote Git repository. The git repository + belongs to the given userID and is associated with the given workspaceID. + +func (s *Service) SaveFile(userID, workspaceID int, filePath string, content []byte) error + SaveFile writes the content to the file at the given filePath. Path must + be a relative path within the workspace directory given by userID and + workspaceID. + +func (s *Service) SetupGitRepo(userID, workspaceID int, gitURL, gitUser, gitToken, commitName, commitEmail string) error + SetupGitRepo sets up a Git repository for the given userID and workspaceID. + The repository is cloned from the given gitURL using the given gitUser and + gitToken. + +func (s *Service) StageCommitAndPush(userID, workspaceID int, message string) error + StageCommitAndPush stages, commit with the message, and pushes the changes + to the Git repository. The git repository belongs to the given userID and is + associated with the given workspaceID. + +func (s *Service) ValidatePath(userID, workspaceID int, path string) (string, error) + ValidatePath validates the if the given path is valid within the workspace + directory. Workspace directory is defined as the directory for the given + userID and workspaceID. + +type WorkspaceManager interface { + ValidatePath(userID, workspaceID int, path string) (string, error) + GetWorkspacePath(userID, workspaceID int) string + InitializeUserWorkspace(userID, workspaceID int) error + DeleteUserWorkspace(userID, workspaceID int) error +} + WorkspaceManager provides functionalities to interact with workspaces in the + storage. + +``` + diff --git a/server/internal/api/routes.go b/server/internal/api/routes.go deleted file mode 100644 index 1409ddc..0000000 --- a/server/internal/api/routes.go +++ /dev/null @@ -1,97 +0,0 @@ -// Package api contains the API routes for the application. It sets up the routes for the public and protected endpoints, as well as the admin-only routes. -package api - -import ( - "novamd/internal/auth" - "novamd/internal/context" - "novamd/internal/db" - "novamd/internal/handlers" - "novamd/internal/storage" - - "github.com/go-chi/chi/v5" -) - -// SetupRoutes configures the API routes -func SetupRoutes(r chi.Router, db db.Database, s storage.Manager, authMiddleware *auth.Middleware, sessionService *auth.SessionService) { - - handler := &handlers.Handler{ - DB: db, - Storage: s, - } - - // Public routes (no authentication required) - r.Group(func(r chi.Router) { - r.Post("/auth/login", handler.Login(sessionService)) - r.Post("/auth/refresh", handler.RefreshToken(sessionService)) - }) - - // Protected routes (authentication required) - r.Group(func(r chi.Router) { - // Apply authentication middleware to all routes in this group - r.Use(authMiddleware.Authenticate) - r.Use(context.WithUserContextMiddleware) - - // Auth routes - r.Post("/auth/logout", handler.Logout(sessionService)) - r.Get("/auth/me", handler.GetCurrentUser()) - - // User profile routes - r.Put("/profile", handler.UpdateProfile()) - r.Delete("/profile", handler.DeleteAccount()) - - // Admin-only routes - r.Route("/admin", func(r chi.Router) { - r.Use(authMiddleware.RequireRole("admin")) - // User management - r.Route("/users", func(r chi.Router) { - r.Get("/", handler.AdminListUsers()) - r.Post("/", handler.AdminCreateUser()) - r.Get("/{userId}", handler.AdminGetUser()) - r.Put("/{userId}", handler.AdminUpdateUser()) - r.Delete("/{userId}", handler.AdminDeleteUser()) - }) - // Workspace management - r.Route("/workspaces", func(r chi.Router) { - r.Get("/", handler.AdminListWorkspaces()) - }) - // System stats - r.Get("/stats", handler.AdminGetSystemStats()) - }) - - // Workspace routes - r.Route("/workspaces", func(r chi.Router) { - r.Get("/", handler.ListWorkspaces()) - r.Post("/", handler.CreateWorkspace()) - r.Get("/last", handler.GetLastWorkspaceName()) - r.Put("/last", handler.UpdateLastWorkspaceName()) - - // Single workspace routes - r.Route("/{workspaceName}", func(r chi.Router) { - r.Use(context.WithWorkspaceContextMiddleware(db)) - r.Use(authMiddleware.RequireWorkspaceAccess) - - r.Get("/", handler.GetWorkspace()) - r.Put("/", handler.UpdateWorkspace()) - r.Delete("/", handler.DeleteWorkspace()) - - // File routes - r.Route("/files", func(r chi.Router) { - r.Get("/", handler.ListFiles()) - r.Get("/last", handler.GetLastOpenedFile()) - r.Put("/last", handler.UpdateLastOpenedFile()) - r.Get("/lookup", handler.LookupFileByName()) - - r.Post("/*", handler.SaveFile()) - r.Get("/*", handler.GetFileContent()) - r.Delete("/*", handler.DeleteFile()) - }) - - // Git routes - r.Route("/git", func(r chi.Router) { - r.Post("/commit", handler.StageCommitAndPush()) - r.Post("/pull", handler.PullChanges()) - }) - }) - }) - }) -} diff --git a/server/internal/app/app.go b/server/internal/app/app.go deleted file mode 100644 index 75f1c04..0000000 --- a/server/internal/app/app.go +++ /dev/null @@ -1,223 +0,0 @@ -// Package app provides application-level functionality for initializing and running the server -package app - -import ( - "database/sql" - "fmt" - "log" - "net/http" - "time" - - "github.com/go-chi/chi/v5" - "github.com/go-chi/chi/v5/middleware" - "github.com/go-chi/cors" - "github.com/go-chi/httprate" - "github.com/unrolled/secure" - "golang.org/x/crypto/bcrypt" - - "novamd/internal/api" - "novamd/internal/auth" - "novamd/internal/config" - "novamd/internal/db" - "novamd/internal/handlers" - "novamd/internal/models" - "novamd/internal/secrets" - "novamd/internal/storage" -) - -// Server represents the HTTP server and its dependencies -type Server struct { - router *chi.Mux - config *config.Config - db db.Database - storage storage.Manager -} - -// NewServer initializes a new server instance with all dependencies -func NewServer(cfg *config.Config) (*Server, error) { - // Initialize secrets service - secretsService, err := secrets.NewService(cfg.EncryptionKey) - if err != nil { - return nil, fmt.Errorf("failed to initialize secrets service: %w", err) - } - - // Initialize database - database, err := initDatabase(cfg, secretsService) - if err != nil { - return nil, fmt.Errorf("failed to initialize database: %w", err) - } - - // Initialize filesystem - storageManager := storage.NewService(cfg.WorkDir) - - // Setup admin user - err = setupAdminUser(database, storageManager, cfg) - if err != nil { - return nil, fmt.Errorf("failed to setup admin user: %w", err) - } - - // Initialize router - router := initRouter(cfg) - - return &Server{ - router: router, - config: cfg, - db: database, - storage: storageManager, - }, nil -} - -// Start configures and starts the HTTP server -func (s *Server) Start() error { - // Set up authentication - jwtManager, sessionService, err := s.setupAuth() - if err != nil { - return fmt.Errorf("failed to setup authentication: %w", err) - } - - // Set up routes - s.setupRoutes(jwtManager, sessionService) - - // Start server - addr := ":" + s.config.Port - log.Printf("Server starting on port %s", s.config.Port) - return http.ListenAndServe(addr, s.router) -} - -// Close handles graceful shutdown of server dependencies -func (s *Server) Close() error { - return s.db.Close() -} - -// initDatabase initializes and migrates the database -func initDatabase(cfg *config.Config, secretsService secrets.Service) (db.Database, error) { - database, err := db.Init(cfg.DBPath, secretsService) - if err != nil { - return nil, fmt.Errorf("failed to initialize database: %w", err) - } - - if err := database.Migrate(); err != nil { - return nil, fmt.Errorf("failed to apply database migrations: %w", err) - } - - return database, nil -} - -// initRouter creates and configures the chi router with middleware -func initRouter(cfg *config.Config) *chi.Mux { - r := chi.NewRouter() - - // Basic middleware - r.Use(middleware.Logger) - r.Use(middleware.Recoverer) - r.Use(middleware.RequestID) - r.Use(middleware.RealIP) - r.Use(middleware.Timeout(30 * time.Second)) - - // Security headers - r.Use(secure.New(secure.Options{ - SSLRedirect: false, // Let proxy handle HTTPS - SSLProxyHeaders: map[string]string{"X-Forwarded-Proto": "https"}, - IsDevelopment: cfg.IsDevelopment, - }).Handler) - - // CORS if origins are configured - if len(cfg.CORSOrigins) > 0 { - r.Use(cors.Handler(cors.Options{ - AllowedOrigins: cfg.CORSOrigins, - AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, - AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-Requested-With"}, - AllowCredentials: true, - MaxAge: 300, - })) - } - - return r -} - -// setupAuth initializes JWT and session services -func (s *Server) setupAuth() (auth.JWTManager, *auth.SessionService, error) { - // Get or generate JWT signing key - signingKey := s.config.JWTSigningKey - if signingKey == "" { - var err error - signingKey, err = s.db.EnsureJWTSecret() - if err != nil { - return nil, nil, fmt.Errorf("failed to ensure JWT secret: %w", err) - } - } - - // Initialize JWT service - jwtManager, err := auth.NewJWTService(auth.JWTConfig{ - SigningKey: signingKey, - AccessTokenExpiry: 15 * time.Minute, - RefreshTokenExpiry: 7 * 24 * time.Hour, - }) - if err != nil { - return nil, nil, fmt.Errorf("failed to initialize JWT service: %w", err) - } - - // Initialize session service - sessionService := auth.NewSessionService(s.db, jwtManager) - - return jwtManager, sessionService, nil -} - -// setupRoutes configures all application routes -func (s *Server) setupRoutes(jwtManager auth.JWTManager, sessionService *auth.SessionService) { - // Initialize auth middleware - authMiddleware := auth.NewMiddleware(jwtManager) - - // Set up API routes - s.router.Route("/api/v1", func(r chi.Router) { - r.Use(httprate.LimitByIP(s.config.RateLimitRequests, s.config.RateLimitWindow)) - api.SetupRoutes(r, s.db, s.storage, authMiddleware, sessionService) - }) - - // Handle all other routes with static file server - s.router.Get("/*", handlers.NewStaticHandler(s.config.StaticPath).ServeHTTP) -} - -func setupAdminUser(db db.Database, w storage.WorkspaceManager, cfg *config.Config) error { - - adminEmail := cfg.AdminEmail - adminPassword := cfg.AdminPassword - - // Check if admin user exists - adminUser, err := db.GetUserByEmail(adminEmail) - if adminUser != nil { - return nil // Admin user already exists - } else if err != sql.ErrNoRows { - return err - } - - // Hash the password - hashedPassword, err := bcrypt.GenerateFromPassword([]byte(adminPassword), bcrypt.DefaultCost) - if err != nil { - return fmt.Errorf("failed to hash password: %w", err) - } - - // Create admin user - adminUser = &models.User{ - Email: adminEmail, - DisplayName: "Admin", - PasswordHash: string(hashedPassword), - Role: models.RoleAdmin, - } - - createdUser, err := db.CreateUser(adminUser) - if err != nil { - return fmt.Errorf("failed to create admin user: %w", err) - } - - // Initialize workspace directory - err = w.InitializeUserWorkspace(createdUser.ID, createdUser.LastWorkspaceID) - if err != nil { - return fmt.Errorf("failed to initialize admin workspace: %w", err) - } - - log.Printf("Created admin user with ID: %d and default workspace with ID: %d", createdUser.ID, createdUser.LastWorkspaceID) - - return nil - -} diff --git a/server/internal/config/config.go b/server/internal/app/config.go similarity index 88% rename from server/internal/config/config.go rename to server/internal/app/config.go index 41c21a6..fc34487 100644 --- a/server/internal/config/config.go +++ b/server/internal/app/config.go @@ -1,5 +1,4 @@ -// Package config provides the configuration for the application -package config +package app import ( "fmt" @@ -40,8 +39,8 @@ func DefaultConfig() *Config { } } -// Validate checks if the configuration is valid -func (c *Config) Validate() error { +// validate checks if the configuration is valid +func (c *Config) validate() error { if c.AdminEmail == "" || c.AdminPassword == "" { return fmt.Errorf("NOVAMD_ADMIN_EMAIL and NOVAMD_ADMIN_PASSWORD must be set") } @@ -54,8 +53,8 @@ func (c *Config) Validate() error { return nil } -// Load creates a new Config instance with values from environment variables -func Load() (*Config, error) { +// LoadConfig creates a new Config instance with values from environment variables +func LoadConfig() (*Config, error) { config := DefaultConfig() if env := os.Getenv("NOVAMD_ENV"); env != "" { @@ -105,7 +104,7 @@ func Load() (*Config, error) { } // Validate all settings - if err := config.Validate(); err != nil { + if err := config.validate(); err != nil { return nil, err } diff --git a/server/internal/config/config_test.go b/server/internal/app/config_test.go similarity index 97% rename from server/internal/config/config_test.go rename to server/internal/app/config_test.go index 51aef69..383f73f 100644 --- a/server/internal/config/config_test.go +++ b/server/internal/app/config_test.go @@ -1,15 +1,14 @@ -package config_test +package app_test import ( + "novamd/internal/app" "os" "testing" "time" - - "novamd/internal/config" ) func TestDefaultConfig(t *testing.T) { - cfg := config.DefaultConfig() + cfg := app.DefaultConfig() tests := []struct { name string @@ -75,7 +74,7 @@ func TestLoad(t *testing.T) { setEnv(t, "NOVAMD_ADMIN_PASSWORD", "password123") setEnv(t, "NOVAMD_ENCRYPTION_KEY", "YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXoxMjM0NTY=") // 32 bytes base64 encoded - cfg, err := config.Load() + cfg, err := app.LoadConfig() if err != nil { t.Fatalf("Load() error = %v", err) } @@ -110,7 +109,7 @@ func TestLoad(t *testing.T) { setEnv(t, k, v) } - cfg, err := config.Load() + cfg, err := app.LoadConfig() if err != nil { t.Fatalf("Load() error = %v", err) } @@ -201,7 +200,7 @@ func TestLoad(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { tc.setupEnv(t) - _, err := config.Load() + _, err := app.LoadConfig() if err == nil { t.Error("expected error, got nil") return diff --git a/server/internal/app/init.go b/server/internal/app/init.go new file mode 100644 index 0000000..eec83b4 --- /dev/null +++ b/server/internal/app/init.go @@ -0,0 +1,111 @@ +// Package app provides application-level functionality for initializing and running the server +package app + +import ( + "database/sql" + "fmt" + "log" + "time" + + "golang.org/x/crypto/bcrypt" + + "novamd/internal/auth" + "novamd/internal/db" + "novamd/internal/models" + "novamd/internal/secrets" + "novamd/internal/storage" +) + +// initSecretsService initializes the secrets service +func initSecretsService(cfg *Config) (secrets.Service, error) { + secretsService, err := secrets.NewService(cfg.EncryptionKey) + if err != nil { + return nil, fmt.Errorf("failed to initialize secrets service: %w", err) + } + return secretsService, nil +} + +// initDatabase initializes and migrates the database +func initDatabase(cfg *Config, secretsService secrets.Service) (db.Database, error) { + database, err := db.Init(cfg.DBPath, secretsService) + if err != nil { + return nil, fmt.Errorf("failed to initialize database: %w", err) + } + + if err := database.Migrate(); err != nil { + return nil, fmt.Errorf("failed to apply database migrations: %w", err) + } + + return database, nil +} + +// initAuth initializes JWT and session services +func initAuth(cfg *Config, database db.Database) (auth.JWTManager, *auth.SessionService, error) { + // Get or generate JWT signing key + signingKey := cfg.JWTSigningKey + if signingKey == "" { + var err error + signingKey, err = database.EnsureJWTSecret() + if err != nil { + return nil, nil, fmt.Errorf("failed to ensure JWT secret: %w", err) + } + } + + // Initialize JWT service + jwtManager, err := auth.NewJWTService(auth.JWTConfig{ + SigningKey: signingKey, + AccessTokenExpiry: 15 * time.Minute, + RefreshTokenExpiry: 7 * 24 * time.Hour, + }) + if err != nil { + return nil, nil, fmt.Errorf("failed to initialize JWT service: %w", err) + } + + // Initialize session service + sessionService := auth.NewSessionService(database, jwtManager) + + return jwtManager, sessionService, nil +} + +// setupAdminUser creates the admin user if it doesn't exist +func setupAdminUser(database db.Database, storageManager storage.Manager, cfg *Config) error { + adminEmail := cfg.AdminEmail + adminPassword := cfg.AdminPassword + + // Check if admin user exists + adminUser, err := database.GetUserByEmail(adminEmail) + if adminUser != nil { + return nil // Admin user already exists + } else if err != sql.ErrNoRows { + return err + } + + // Hash the password + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(adminPassword), bcrypt.DefaultCost) + if err != nil { + return fmt.Errorf("failed to hash password: %w", err) + } + + // Create admin user + adminUser = &models.User{ + Email: adminEmail, + DisplayName: "Admin", + PasswordHash: string(hashedPassword), + Role: models.RoleAdmin, + } + + createdUser, err := database.CreateUser(adminUser) + if err != nil { + return fmt.Errorf("failed to create admin user: %w", err) + } + + // Initialize workspace directory + err = storageManager.InitializeUserWorkspace(createdUser.ID, createdUser.LastWorkspaceID) + if err != nil { + return fmt.Errorf("failed to initialize admin workspace: %w", err) + } + + log.Printf("Created admin user with ID: %d and default workspace with ID: %d", createdUser.ID, createdUser.LastWorkspaceID) + + return nil +} diff --git a/server/internal/app/options.go b/server/internal/app/options.go new file mode 100644 index 0000000..9e8b2fb --- /dev/null +++ b/server/internal/app/options.go @@ -0,0 +1,53 @@ +package app + +import ( + "novamd/internal/auth" + "novamd/internal/db" + "novamd/internal/storage" +) + +// Options holds all dependencies and configuration for the server +type Options struct { + Config *Config + Database db.Database + Storage storage.Manager + JWTManager auth.JWTManager + SessionService *auth.SessionService +} + +// DefaultOptions creates server options with default configuration +func DefaultOptions(cfg *Config) (*Options, error) { + // Initialize secrets service + secretsService, err := initSecretsService(cfg) + if err != nil { + return nil, err + } + + // Initialize database + database, err := initDatabase(cfg, secretsService) + if err != nil { + return nil, err + } + + // Initialize storage + storageManager := storage.NewService(cfg.WorkDir) + + // Initialize auth services + jwtManager, sessionService, err := initAuth(cfg, database) + if err != nil { + return nil, err + } + + // Setup admin user + if err := setupAdminUser(database, storageManager, cfg); err != nil { + return nil, err + } + + return &Options{ + Config: cfg, + Database: database, + Storage: storageManager, + JWTManager: jwtManager, + SessionService: sessionService, + }, nil +} diff --git a/server/internal/app/routes.go b/server/internal/app/routes.go new file mode 100644 index 0000000..3b02d02 --- /dev/null +++ b/server/internal/app/routes.go @@ -0,0 +1,142 @@ +package app + +import ( + "novamd/internal/auth" + "novamd/internal/context" + "novamd/internal/handlers" + "time" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + "github.com/go-chi/cors" + "github.com/go-chi/httprate" + "github.com/unrolled/secure" +) + +// setupRouter creates and configures the chi router with middleware and routes +func setupRouter(o Options) *chi.Mux { + r := chi.NewRouter() + + // Basic middleware + r.Use(middleware.Logger) + r.Use(middleware.Recoverer) + r.Use(middleware.RequestID) + r.Use(middleware.RealIP) + r.Use(middleware.Timeout(30 * time.Second)) + + // Security headers + r.Use(secure.New(secure.Options{ + SSLRedirect: false, + SSLProxyHeaders: map[string]string{"X-Forwarded-Proto": "https"}, + IsDevelopment: o.Config.IsDevelopment, + }).Handler) + + // CORS if origins are configured + if len(o.Config.CORSOrigins) > 0 { + r.Use(cors.Handler(cors.Options{ + AllowedOrigins: o.Config.CORSOrigins, + AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, + AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-Requested-With"}, + AllowCredentials: true, + MaxAge: 300, + })) + } + + // Initialize auth middleware and handler + authMiddleware := auth.NewMiddleware(o.JWTManager) + handler := &handlers.Handler{ + DB: o.Database, + Storage: o.Storage, + } + + // API routes + r.Route("/api/v1", func(r chi.Router) { + // Rate limiting for API routes + if o.Config.RateLimitRequests > 0 { + r.Use(httprate.LimitByIP( + o.Config.RateLimitRequests, + o.Config.RateLimitWindow, + )) + } + + // Public routes (no authentication required) + r.Group(func(r chi.Router) { + r.Post("/auth/login", handler.Login(o.SessionService)) + r.Post("/auth/refresh", handler.RefreshToken(o.SessionService)) + }) + + // Protected routes (authentication required) + r.Group(func(r chi.Router) { + r.Use(authMiddleware.Authenticate) + r.Use(context.WithUserContextMiddleware) + + // Auth routes + r.Post("/auth/logout", handler.Logout(o.SessionService)) + r.Get("/auth/me", handler.GetCurrentUser()) + + // User profile routes + r.Put("/profile", handler.UpdateProfile()) + r.Delete("/profile", handler.DeleteAccount()) + + // Admin-only routes + r.Route("/admin", func(r chi.Router) { + r.Use(authMiddleware.RequireRole("admin")) + // User management + r.Route("/users", func(r chi.Router) { + r.Get("/", handler.AdminListUsers()) + r.Post("/", handler.AdminCreateUser()) + r.Get("/{userId}", handler.AdminGetUser()) + r.Put("/{userId}", handler.AdminUpdateUser()) + r.Delete("/{userId}", handler.AdminDeleteUser()) + }) + // Workspace management + r.Route("/workspaces", func(r chi.Router) { + r.Get("/", handler.AdminListWorkspaces()) + }) + // System stats + r.Get("/stats", handler.AdminGetSystemStats()) + }) + + // Workspace routes + r.Route("/workspaces", func(r chi.Router) { + r.Get("/", handler.ListWorkspaces()) + r.Post("/", handler.CreateWorkspace()) + r.Get("/last", handler.GetLastWorkspaceName()) + r.Put("/last", handler.UpdateLastWorkspaceName()) + + // Single workspace routes + r.Route("/{workspaceName}", func(r chi.Router) { + r.Use(context.WithWorkspaceContextMiddleware(o.Database)) + r.Use(authMiddleware.RequireWorkspaceAccess) + + r.Get("/", handler.GetWorkspace()) + r.Put("/", handler.UpdateWorkspace()) + r.Delete("/", handler.DeleteWorkspace()) + + // File routes + r.Route("/files", func(r chi.Router) { + r.Get("/", handler.ListFiles()) + r.Get("/last", handler.GetLastOpenedFile()) + r.Put("/last", handler.UpdateLastOpenedFile()) + r.Get("/lookup", handler.LookupFileByName()) + + r.Post("/*", handler.SaveFile()) + r.Get("/*", handler.GetFileContent()) + r.Delete("/*", handler.DeleteFile()) + }) + + // Git routes + r.Route("/git", func(r chi.Router) { + r.Post("/commit", handler.StageCommitAndPush()) + r.Post("/pull", handler.PullChanges()) + }) + }) + }) + }) + }) + + // Handle all other routes with static file server + r.Get("/*", handlers.NewStaticHandler(o.Config.StaticPath).ServeHTTP) + + return r +} diff --git a/server/internal/app/server.go b/server/internal/app/server.go new file mode 100644 index 0000000..adb381e --- /dev/null +++ b/server/internal/app/server.go @@ -0,0 +1,40 @@ +package app + +import ( + "log" + "net/http" + + "github.com/go-chi/chi/v5" +) + +// Server represents the HTTP server and its dependencies +type Server struct { + router *chi.Mux + options *Options +} + +// NewServer creates a new server instance with the given options +func NewServer(options *Options) *Server { + return &Server{ + router: setupRouter(*options), + options: options, + } +} + +// Start configures and starts the HTTP server +func (s *Server) Start() error { + // Start server + addr := ":" + s.options.Config.Port + log.Printf("Server starting on port %s", s.options.Config.Port) + return http.ListenAndServe(addr, s.router) +} + +// Close handles graceful shutdown of server dependencies +func (s *Server) Close() error { + return s.options.Database.Close() +} + +// Router returns the chi router for testing +func (s *Server) Router() chi.Router { + return s.router +} diff --git a/server/internal/handlers/integration_test.go b/server/internal/handlers/integration_test.go index 9b380e9..047966f 100644 --- a/server/internal/handlers/integration_test.go +++ b/server/internal/handlers/integration_test.go @@ -11,14 +11,12 @@ import ( "testing" "time" - "github.com/go-chi/chi/v5" "golang.org/x/crypto/bcrypt" - "novamd/internal/api" + "novamd/internal/app" "novamd/internal/auth" "novamd/internal/db" "novamd/internal/git" - "novamd/internal/handlers" "novamd/internal/models" "novamd/internal/secrets" "novamd/internal/storage" @@ -26,10 +24,9 @@ import ( // testHarness encapsulates all the dependencies needed for testing type testHarness struct { + Server *app.Server DB db.TestDatabase Storage storage.Manager - Router *chi.Mux - Handler *handlers.Handler JWTManager auth.JWTManager SessionSvc *auth.SessionService AdminUser *models.User @@ -89,24 +86,34 @@ func setupTestHarness(t *testing.T) *testHarness { // Initialize session service sessionSvc := auth.NewSessionService(database, jwtSvc) - // Create handler - handler := &handlers.Handler{ - DB: database, - Storage: storageSvc, + // Create test config + testConfig := &app.Config{ + DBPath: ":memory:", + WorkDir: tempDir, + StaticPath: "../testdata", + Port: "8081", + AdminEmail: "admin@test.com", + AdminPassword: "admin123", + EncryptionKey: "YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXoxMjM0NTY=", + IsDevelopment: true, } - // Set up router with middlewares - router := chi.NewRouter() - authMiddleware := auth.NewMiddleware(jwtSvc) - router.Route("/api/v1", func(r chi.Router) { - api.SetupRoutes(r, database, storageSvc, authMiddleware, sessionSvc) - }) + // Create server options + serverOpts := &app.Options{ + Config: testConfig, + Database: database, + Storage: storageSvc, + JWTManager: jwtSvc, + SessionService: sessionSvc, + } + + // Create server + srv := app.NewServer(serverOpts) h := &testHarness{ + Server: srv, DB: database, Storage: storageSvc, - Router: router, - Handler: handler, JWTManager: jwtSvc, SessionSvc: sessionSvc, TempDirectory: tempDir, @@ -114,8 +121,8 @@ func setupTestHarness(t *testing.T) *testHarness { } // Create test users - adminUser, adminToken := h.createTestUser(t, database, sessionSvc, "admin@test.com", "admin123", models.RoleAdmin) - regularUser, regularToken := h.createTestUser(t, database, sessionSvc, "user@test.com", "user123", models.RoleEditor) + adminUser, adminToken := h.createTestUser(t, "admin@test.com", "admin123", models.RoleAdmin) + regularUser, regularToken := h.createTestUser(t, "user@test.com", "user123", models.RoleEditor) h.AdminUser = adminUser h.AdminToken = adminToken @@ -139,7 +146,7 @@ func (h *testHarness) teardown(t *testing.T) { } // createTestUser creates a test user and returns the user and access token -func (h *testHarness) createTestUser(t *testing.T, db db.Database, sessionSvc *auth.SessionService, email, password string, role models.UserRole) (*models.User, string) { +func (h *testHarness) createTestUser(t *testing.T, email, password string, role models.UserRole) (*models.User, string) { t.Helper() hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) @@ -154,7 +161,7 @@ func (h *testHarness) createTestUser(t *testing.T, db db.Database, sessionSvc *a Role: role, } - user, err = db.CreateUser(user) + user, err = h.DB.CreateUser(user) if err != nil { t.Fatalf("Failed to create user: %v", err) } @@ -165,7 +172,7 @@ func (h *testHarness) createTestUser(t *testing.T, db db.Database, sessionSvc *a t.Fatalf("Failed to initialize user workspace: %v", err) } - session, accessToken, err := sessionSvc.CreateSession(user.ID, string(user.Role)) + session, accessToken, err := h.SessionSvc.CreateSession(user.ID, string(user.Role)) if err != nil { t.Fatalf("Failed to create session: %v", err) } @@ -203,7 +210,7 @@ func (h *testHarness) makeRequest(t *testing.T, method, path string, body interf } rr := httptest.NewRecorder() - h.Router.ServeHTTP(rr, req) + h.Server.Router().ServeHTTP(rr, req) return rr } @@ -223,7 +230,7 @@ func (h *testHarness) makeRequestRaw(t *testing.T, method, path string, body io. } rr := httptest.NewRecorder() - h.Router.ServeHTTP(rr, req) + h.Server.Router().ServeHTTP(rr, req) return rr } From c400d81c87143dce4b2abd698e450ae95cc618b8 Mon Sep 17 00:00:00 2001 From: LordMathis Date: Mon, 2 Dec 2024 22:28:12 +0100 Subject: [PATCH 2/5] Add initial api doc comments --- server/cmd/server/main.go | 6 + server/internal/handlers/admin_handlers.go | 133 +++++++++++++++--- server/internal/handlers/auth_handlers.go | 51 ++++++- server/internal/handlers/file_handlers.go | 107 ++++++++++++-- server/internal/handlers/git_handlers.go | 27 +++- server/internal/handlers/user_handlers.go | 39 ++++- .../internal/handlers/workspace_handlers.go | 98 ++++++++++++- 7 files changed, 414 insertions(+), 47 deletions(-) diff --git a/server/cmd/server/main.go b/server/cmd/server/main.go index 929a769..ae26731 100644 --- a/server/cmd/server/main.go +++ b/server/cmd/server/main.go @@ -7,6 +7,12 @@ import ( "novamd/internal/app" ) +// @title NovaMD API +// @version 1.0 +// @description This is the API for NovaMD markdown note taking app. +// @license.name Apache 2.0 +// @license.url http://www.apache.org/licenses/LICENSE-2.0.html +// @BasePath /api/v1 func main() { // Load configuration cfg, err := app.LoadConfig() diff --git a/server/internal/handlers/admin_handlers.go b/server/internal/handlers/admin_handlers.go index 02b436c..3d1a2db 100644 --- a/server/internal/handlers/admin_handlers.go +++ b/server/internal/handlers/admin_handlers.go @@ -31,7 +31,32 @@ type UpdateUserRequest struct { Role models.UserRole `json:"role,omitempty"` } -// AdminListUsers returns a list of all users +// WorkspaceStats holds workspace statistics +type WorkspaceStats struct { + UserID int `json:"userID"` + UserEmail string `json:"userEmail"` + WorkspaceID int `json:"workspaceID"` + WorkspaceName string `json:"workspaceName"` + WorkspaceCreatedAt time.Time `json:"workspaceCreatedAt"` + *storage.FileCountStats +} + +// SystemStats holds system-wide statistics +type SystemStats struct { + *db.UserStats + *storage.FileCountStats +} + +// AdminListUsers godoc +// @Summary List all users +// @Description Returns the list of all users +// @Tags Admin +// @Security BearerAuth +// @ID adminListUsers +// @Produce json +// @Success 200 {array} models.User +// @Failure 500 {string} "Failed to list users" +// @Router /admin/users [get] func (h *Handler) AdminListUsers() http.HandlerFunc { return func(w http.ResponseWriter, _ *http.Request) { users, err := h.DB.GetAllUsers() @@ -44,7 +69,24 @@ func (h *Handler) AdminListUsers() http.HandlerFunc { } } -// AdminCreateUser creates a new user +// AdminCreateUser godoc +// @Summary Create a new user +// @Description Create a new user as an admin +// @Tags Admin +// @Security BearerAuth +// @ID adminCreateUser +// @Accept json +// @Produce json +// @Param user body CreateUserRequest true "User details" +// @Success 200 {object} models.User +// @Failure 400 {string} "Invalid request body" +// @Failure 400 {string} "Email, password, and role are required" +// @Failure 400 {string} "Password must be at least 8 characters" +// @Failure 409 {string} "Email already exists" +// @Failure 500 {string} "Failed to hash password" +// @Failure 500 {string} "Failed to create user" +// @Failure 500 {string} "Failed to initialize user workspace" +// @Router /admin/users [post] func (h *Handler) AdminCreateUser() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { var req CreateUserRequest @@ -103,7 +145,18 @@ func (h *Handler) AdminCreateUser() http.HandlerFunc { } } -// AdminGetUser gets a specific user by ID +// AdminGetUser godoc +// @Summary Get a specific user +// @Description Get a specific user as an admin +// @Tags Admin +// @Security BearerAuth +// @ID adminGetUser +// @Produce json +// @Param userId path int true "User ID" +// @Success 200 {object} models.User +// @Failure 400 {string} "Invalid user ID" +// @Failure 404 {string} "User not found" +// @Router /admin/users/{userId} [get] func (h *Handler) AdminGetUser() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { userID, err := strconv.Atoi(chi.URLParam(r, "userId")) @@ -122,7 +175,23 @@ func (h *Handler) AdminGetUser() http.HandlerFunc { } } -// AdminUpdateUser updates a specific user +// AdminUpdateUser godoc +// @Summary Update a specific user +// @Description Update a specific user as an admin +// @Tags Admin +// @Security BearerAuth +// @ID adminUpdateUser +// @Accept json +// @Produce json +// @Param userId path int true "User ID" +// @Param user body UpdateUserRequest true "User details" +// @Success 200 {object} models.User +// @Failure 400 {string} "Invalid user ID" +// @Failure 400 {string} "Invalid request body" +// @Failure 404 {string} "User not found" +// @Failure 500 {string} "Failed to hash password" +// @Failure 500 {string} "Failed to update user" +// @Router /admin/users/{userId} [put] func (h *Handler) AdminUpdateUser() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { userID, err := strconv.Atoi(chi.URLParam(r, "userId")) @@ -172,7 +241,20 @@ func (h *Handler) AdminUpdateUser() http.HandlerFunc { } } -// AdminDeleteUser deletes a specific user +// AdminDeleteUser godoc +// @Summary Delete a specific user +// @Description Delete a specific user as an admin +// @Tags Admin +// @Security BearerAuth +// @ID adminDeleteUser +// @Param userId path int true "User ID" +// @Success 204 "No Content" +// @Failure 400 {string} "Invalid user ID" +// @Failure 400 {string} "Cannot delete your own account" +// @Failure 403 {string} "Cannot delete other admin users" +// @Failure 404 {string} "User not found" +// @Failure 500 {string} "Failed to delete user" +// @Router /admin/users/{userId} [delete] func (h *Handler) AdminDeleteUser() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { ctx, ok := context.GetRequestContext(w, r) @@ -214,17 +296,18 @@ func (h *Handler) AdminDeleteUser() http.HandlerFunc { } } -// WorkspaceStats holds workspace statistics -type WorkspaceStats struct { - UserID int `json:"userID"` - UserEmail string `json:"userEmail"` - WorkspaceID int `json:"workspaceID"` - WorkspaceName string `json:"workspaceName"` - WorkspaceCreatedAt time.Time `json:"workspaceCreatedAt"` - *storage.FileCountStats -} - -// AdminListWorkspaces returns a list of all workspaces and their stats +// AdminListWorkspaces godoc +// @Summary List all workspaces +// @Description List all workspaces and their stats as an admin +// @Tags Admin +// @Security BearerAuth +// @ID adminListWorkspaces +// @Produce json +// @Success 200 {array} WorkspaceStats +// @Failure 500 {string} "Failed to list workspaces" +// @Failure 500 {string} "Failed to get user" +// @Failure 500 {string} "Failed to get file stats" +// @Router /admin/workspaces [get] func (h *Handler) AdminListWorkspaces() http.HandlerFunc { return func(w http.ResponseWriter, _ *http.Request) { workspaces, err := h.DB.GetAllWorkspaces() @@ -266,13 +349,17 @@ func (h *Handler) AdminListWorkspaces() http.HandlerFunc { } } -// SystemStats holds system-wide statistics -type SystemStats struct { - *db.UserStats - *storage.FileCountStats -} - -// AdminGetSystemStats returns system-wide statistics for admins +// AdminGetSystemStats godoc +// @Summary Get system statistics +// @Description Get system-wide statistics as an admin +// @Tags Admin +// @Security BearerAuth +// @ID adminGetSystemStats +// @Produce json +// @Success 200 {object} SystemStats +// @Failure 500 {string} "Failed to get user stats" +// @Failure 500 {string} "Failed to get file stats" +// @Router /admin/stats [get] func (h *Handler) AdminGetSystemStats() http.HandlerFunc { return func(w http.ResponseWriter, _ *http.Request) { userStats, err := h.DB.GetSystemStats() diff --git a/server/internal/handlers/auth_handlers.go b/server/internal/handlers/auth_handlers.go index a1bc4b7..d039623 100644 --- a/server/internal/handlers/auth_handlers.go +++ b/server/internal/handlers/auth_handlers.go @@ -34,7 +34,20 @@ type RefreshResponse struct { AccessToken string `json:"accessToken"` } -// Login handles user authentication and returns JWT tokens +// Login godoc +// @Summary Login +// @Description Logs in a user +// @Tags auth +// @ID login +// @Accept json +// @Produce json +// @Param body body LoginRequest true "Login request" +// @Success 200 {object} LoginResponse +// @Failure 400 {string} string "Invalid request body" +// @Failure 400 {string} string "Email and password are required" +// @Failure 401 {string} string "Invalid credentials" +// @Failure 500 {string} string "Failed to create session" +// @Router /auth/login [post] func (h *Handler) Login(authService *auth.SessionService) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { var req LoginRequest @@ -82,7 +95,16 @@ func (h *Handler) Login(authService *auth.SessionService) http.HandlerFunc { } } -// Logout invalidates the user's session +// Logout godoc +// @Summary Logout +// @Description Log out invalidates the user's session +// @Tags auth +// @ID logout +// @Security BearerAuth +// @Success 200 {string} string "OK" +// @Failure 400 {string} string "Session ID required" +// @Failure 500 {string} string "Failed to logout" +// @Router /auth/logout [post] func (h *Handler) Logout(authService *auth.SessionService) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { sessionID := r.Header.Get("X-Session-ID") @@ -101,7 +123,19 @@ func (h *Handler) Logout(authService *auth.SessionService) http.HandlerFunc { } } -// RefreshToken generates a new access token using a refresh token +// RefreshToken godoc +// @Summary Refresh token +// @Description Refreshes the access token using the refresh token +// @Tags auth +// @ID refreshToken +// @Accept json +// @Produce json +// @Param body body RefreshRequest true "Refresh request" +// @Success 200 {object} RefreshResponse +// @Failure 400 {string} string "Invalid request body" +// @Failure 400 {string} string "Refresh token required" +// @Failure 401 {string} string "Invalid refresh token" +// @Router /auth/refresh [post] func (h *Handler) RefreshToken(authService *auth.SessionService) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { var req RefreshRequest @@ -130,7 +164,16 @@ func (h *Handler) RefreshToken(authService *auth.SessionService) http.HandlerFun } } -// GetCurrentUser returns the currently authenticated user +// GetCurrentUser godoc +// @Summary Get current user +// @Description Returns the current authenticated user +// @Tags auth +// @ID getCurrentUser +// @Security BearerAuth +// @Produce json +// @Success 200 {object} models.User +// @Failure 404 {string} string "User not found" +// @Router /auth/me [get] func (h *Handler) GetCurrentUser() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { ctx, ok := context.GetRequestContext(w, r) diff --git a/server/internal/handlers/file_handlers.go b/server/internal/handlers/file_handlers.go index 93ddd99..b0b52f1 100644 --- a/server/internal/handlers/file_handlers.go +++ b/server/internal/handlers/file_handlers.go @@ -12,7 +12,17 @@ import ( "github.com/go-chi/chi/v5" ) -// ListFiles returns a list of all files in the workspace +// ListFiles godoc +// @Summary List files +// @Description Lists all files in the user's workspace +// @Tags files +// @ID listFiles +// @Security BearerAuth +// @Produce json +// @Param workspace_name path string true "Workspace name" +// @Success 200 {array} string +// @Failure 500 {string} string "Failed to list files" +// @Router /workspaces/{workspace_name}/files [get] func (h *Handler) ListFiles() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { ctx, ok := context.GetRequestContext(w, r) @@ -30,7 +40,19 @@ func (h *Handler) ListFiles() http.HandlerFunc { } } -// LookupFileByName returns the paths of files with the given name +// LookupFileByName godoc +// @Summary Lookup file by name +// @Description Returns the paths of files with the given name in the user's workspace +// @Tags files +// @ID lookupFileByName +// @Security BearerAuth +// @Produce json +// @Param workspace_name path string true "Workspace name" +// @Param filename query string true "File name" +// @Success 200 {object} map[string][]string +// @Failure 400 {string} string "Filename is required" +// @Failure 404 {string} string "File not found" +// @Router /workspaces/{workspace_name}/files/lookup [get] func (h *Handler) LookupFileByName() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { ctx, ok := context.GetRequestContext(w, r) @@ -54,7 +76,21 @@ func (h *Handler) LookupFileByName() http.HandlerFunc { } } -// GetFileContent returns the content of a file +// GetFileContent godoc +// @Summary Get file content +// @Description Returns the content of a file in the user's workspace +// @Tags files +// @ID getFileContent +// @Security BearerAuth +// @Produce plain +// @Param workspace_name path string true "Workspace name" +// @Param file_path path string true "File path" +// @Success 200 {string} "File content" +// @Failure 400 {string} string "Invalid file path" +// @Failure 404 {string} string "File not found" +// @Failure 500 {string} string "Failed to read file" +// @Failure 500 {string} string "Failed to write response" +// @Router /workspaces/{workspace_name}/files/* [get] func (h *Handler) GetFileContent() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { ctx, ok := context.GetRequestContext(w, r) @@ -72,7 +108,7 @@ func (h *Handler) GetFileContent() http.HandlerFunc { } if os.IsNotExist(err) { - http.Error(w, "Failed to read file", http.StatusNotFound) + http.Error(w, "File not found", http.StatusNotFound) return } @@ -89,7 +125,21 @@ func (h *Handler) GetFileContent() http.HandlerFunc { } } -// SaveFile saves the content of a file +// SaveFile godoc +// @Summary Save file +// @Description Saves the content of a file in the user's workspace +// @Tags files +// @ID saveFile +// @Security BearerAuth +// @Accept plain +// @Produce json +// @Param workspace_name path string true "Workspace name" +// @Param file_path path string true "File path" +// @Success 200 {string} "File saved successfully" +// @Failure 400 {string} string "Failed to read request body" +// @Failure 400 {string} string "Invalid file path" +// @Failure 500 {string} string "Failed to save file" +// @Router /workspaces/{workspace_name}/files/* [post] func (h *Handler) SaveFile() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { ctx, ok := context.GetRequestContext(w, r) @@ -119,7 +169,21 @@ func (h *Handler) SaveFile() http.HandlerFunc { } } -// DeleteFile deletes a file +// DeleteFile godoc +// @Summary Delete file +// @Description Deletes a file in the user's workspace +// @Tags files +// @ID deleteFile +// @Security BearerAuth +// @Produce string +// @Param workspace_name path string true "Workspace name" +// @Param file_path path string true "File path" +// @Success 200 {string} "File deleted successfully" +// @Failure 400 {string} string "Invalid file path" +// @Failure 404 {string} string "File not found" +// @Failure 500 {string} string "Failed to delete file" +// @Failure 500 {string} string "Failed to write response" +// @Router /workspaces/{workspace_name}/files/* [delete] func (h *Handler) DeleteFile() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { ctx, ok := context.GetRequestContext(w, r) @@ -153,7 +217,18 @@ func (h *Handler) DeleteFile() http.HandlerFunc { } } -// GetLastOpenedFile returns the last opened file in the workspace +// GetLastOpenedFile godoc +// @Summary Get last opened file +// @Description Returns the path of the last opened file in the user's workspace +// @Tags files +// @ID getLastOpenedFile +// @Security BearerAuth +// @Produce json +// @Param workspace_name path string true "Workspace name" +// @Success 200 {object} map[string]string +// @Failure 400 {string} string "Invalid file path" +// @Failure 500 {string} string "Failed to get last opened file" +// @Router /workspaces/{workspace_name}/files/last [get] func (h *Handler) GetLastOpenedFile() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { ctx, ok := context.GetRequestContext(w, r) @@ -176,7 +251,21 @@ func (h *Handler) GetLastOpenedFile() http.HandlerFunc { } } -// UpdateLastOpenedFile updates the last opened file in the workspace +// UpdateLastOpenedFile godoc +// @Summary Update last opened file +// @Description Updates the last opened file in the user's workspace +// @Tags files +// @ID updateLastOpenedFile +// @Security BearerAuth +// @Accept json +// @Produce json +// @Param workspace_name path string true "Workspace name" +// @Success 200 {object} map[string]string +// @Failure 400 {string} string "Invalid request body" +// @Failure 400 {string} string "Invalid file path" +// @Failure 404 {string} string "File not found" +// @Failure 500 {string} string "Failed to update file" +// @Router /workspaces/{workspace_name}/files/last [put] func (h *Handler) UpdateLastOpenedFile() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { ctx, ok := context.GetRequestContext(w, r) @@ -207,7 +296,7 @@ func (h *Handler) UpdateLastOpenedFile() http.HandlerFunc { return } - http.Error(w, "Failed to update file", http.StatusInternalServerError) + http.Error(w, "Failed to update last opened file", http.StatusInternalServerError) return } } diff --git a/server/internal/handlers/git_handlers.go b/server/internal/handlers/git_handlers.go index f34b12a..433a242 100644 --- a/server/internal/handlers/git_handlers.go +++ b/server/internal/handlers/git_handlers.go @@ -7,7 +7,20 @@ import ( "novamd/internal/context" ) -// StageCommitAndPush stages, commits, and pushes changes to the remote repository +// StageCommitAndPush godoc +// @Summary Stage, commit, and push changes +// @Description Stages, commits, and pushes changes to the remote repository +// @Tags git +// @ID stageCommitAndPush +// @Security BearerAuth +// @Produce json +// @Param workspace_name path string true "Workspace name" +// @Param body body string true "Commit message" +// @Success 200 {object} map[string]string +// @Failure 400 {string} string "Invalid request body" +// @Failure 400 {string} string "Commit message is required" +// @Failure 500 {string} string "Failed to stage, commit, and push changes" +// @Router /workspaces/{workspace_name}/git/commit [post] func (h *Handler) StageCommitAndPush() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { ctx, ok := context.GetRequestContext(w, r) @@ -39,7 +52,17 @@ func (h *Handler) StageCommitAndPush() http.HandlerFunc { } } -// PullChanges pulls changes from the remote repository +// PullChanges godoc +// @Summary Pull changes from remote +// @Description Pulls changes from the remote repository +// @Tags git +// @ID pullChanges +// @Security BearerAuth +// @Produce json +// @Param workspace_name path string true "Workspace name" +// @Success 200 {object} map[string]string +// @Failure 500 {string} string "Failed to pull changes" +// @Router /workspaces/{workspace_name}/git/pull [post] func (h *Handler) PullChanges() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { ctx, ok := context.GetRequestContext(w, r) diff --git a/server/internal/handlers/user_handlers.go b/server/internal/handlers/user_handlers.go index 210dd64..3e042df 100644 --- a/server/internal/handlers/user_handlers.go +++ b/server/internal/handlers/user_handlers.go @@ -22,7 +22,26 @@ type DeleteAccountRequest struct { Password string `json:"password"` } -// UpdateProfile updates the current user's profile +// UpdateProfile godoc +// @Summary Update profile +// @Description Updates the user's profile +// @Tags users +// @ID updateProfile +// @Security BearerAuth +// @Accept json +// @Produce json +// @Param body body UpdateProfileRequest true "Profile update request" +// @Success 200 {object} models.User +// @Failure 400 {string} string "Invalid request body" +// @Failure 400 {string} string "Current password is required to change password" +// @Failure 400 {string} string "New password must be at least 8 characters long" +// @Failure 400 {string} string "Current password is required to change email" +// @Failure 401 {string} string "Current password is incorrect" +// @Failure 404 {string} string "User not found" +// @Failure 409 {string} string "Email already in use" +// @Failure 500 {string} string "Failed to process new password" +// @Failure 500 {string} string "Failed to update profile" +// @Router /profile [put] func (h *Handler) UpdateProfile() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { ctx, ok := context.GetRequestContext(w, r) @@ -113,7 +132,23 @@ func (h *Handler) UpdateProfile() http.HandlerFunc { } } -// DeleteAccount handles user account deletion +// DeleteAccount godoc +// @Summary Delete account +// @Description Deletes the user's account and all associated data +// @Tags users +// @ID deleteAccount +// @Security BearerAuth +// @Accept json +// @Produce json +// @Param body body DeleteAccountRequest true "Account deletion request" +// @Success 200 {object} map[string]string +// @Failure 400 {string} string "Invalid request body" +// @Failure 401 {string} string "Password is incorrect" +// @Failure 403 {string} string "Cannot delete the last admin account" +// @Failure 404 {string} string "User not found" +// @Failure 500 {string} string "Failed to verify admin status" +// @Failure 500 {string} string "Failed to delete account" +// @Router /profile [delete] func (h *Handler) DeleteAccount() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { ctx, ok := context.GetRequestContext(w, r) diff --git a/server/internal/handlers/workspace_handlers.go b/server/internal/handlers/workspace_handlers.go index 514c9ff..4e65bdc 100644 --- a/server/internal/handlers/workspace_handlers.go +++ b/server/internal/handlers/workspace_handlers.go @@ -9,7 +9,16 @@ import ( "novamd/internal/models" ) -// ListWorkspaces returns a list of all workspaces for the current user +// ListWorkspaces godoc +// @Summary List workspaces +// @Description Lists all workspaces for the current user +// @Tags workspaces +// @ID listWorkspaces +// @Security BearerAuth +// @Produce json +// @Success 200 {array} models.Workspace +// @Failure 500 {string} string "Failed to list workspaces" +// @Router /workspaces [get] func (h *Handler) ListWorkspaces() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { ctx, ok := context.GetRequestContext(w, r) @@ -27,7 +36,22 @@ func (h *Handler) ListWorkspaces() http.HandlerFunc { } } -// CreateWorkspace creates a new workspace +// CreateWorkspace godoc +// @Summary Create workspace +// @Description Creates a new workspace +// @Tags workspaces +// @ID createWorkspace +// @Security BearerAuth +// @Accept json +// @Produce json +// @Param body body models.Workspace true "Workspace" +// @Success 200 {object} models.Workspace +// @Failure 400 {string} string "Invalid request body" +// @Failure 400 {string} string "Invalid workspace" +// @Failure 500 {string} string "Failed to create workspace" +// @Failure 500 {string} string "Failed to initialize workspace directory" +// @Failure 500 {string} string "Failed to setup git repo" +// @Router /workspaces [post] func (h *Handler) CreateWorkspace() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { ctx, ok := context.GetRequestContext(w, r) @@ -76,7 +100,17 @@ func (h *Handler) CreateWorkspace() http.HandlerFunc { } } -// GetWorkspace returns the current workspace +// GetWorkspace godoc +// @Summary Get workspace +// @Description Returns the current workspace +// @Tags workspaces +// @ID getWorkspace +// @Security BearerAuth +// @Produce json +// @Param workspace_name path string true "Workspace name" +// @Success 200 {object} models.Workspace +// @Failure 500 {string} string "Failed to get workspace" +// @Router /workspaces/{workspace_name} [get] func (h *Handler) GetWorkspace() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { ctx, ok := context.GetRequestContext(w, r) @@ -106,7 +140,21 @@ func gitSettingsChanged(new, old *models.Workspace) bool { return false } -// UpdateWorkspace updates the current workspace +// UpdateWorkspace godoc +// @Summary Update workspace +// @Description Updates the current workspace +// @Tags workspaces +// @ID updateWorkspace +// @Security BearerAuth +// @Accept json +// @Produce json +// @Param workspace_name path string true "Workspace name" +// @Param body body models.Workspace true "Workspace" +// @Success 200 {object} models.Workspace +// @Failure 400 {string} string "Invalid request body" +// @Failure 500 {string} string "Failed to update workspace" +// @Failure 500 {string} string "Failed to setup git repo" +// @Router /workspaces/{workspace_name} [put] func (h *Handler) UpdateWorkspace() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { ctx, ok := context.GetRequestContext(w, r) @@ -160,7 +208,23 @@ func (h *Handler) UpdateWorkspace() http.HandlerFunc { } } -// DeleteWorkspace deletes the current workspace +// DeleteWorkspace godoc +// @Summary Delete workspace +// @Description Deletes the current workspace +// @Tags workspaces +// @ID deleteWorkspace +// @Security BearerAuth +// @Produce json +// @Param workspace_name path string true "Workspace name" +// @Success 200 {object} map[string]string +// @Failure 400 {string} string "Cannot delete the last workspace" +// @Failure 500 {string} string "Failed to get workspaces" +// @Failure 500 {string} string "Failed to start transaction" +// @Failure 500 {string} string "Failed to update last workspace" +// @Failure 500 {string} string "Failed to delete workspace" +// @Failure 500 {string} string "Failed to rollback transaction" +// @Failure 500 {string} string "Failed to commit transaction" +// @Router /workspaces/{workspace_name} [delete] func (h *Handler) DeleteWorkspace() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { ctx, ok := context.GetRequestContext(w, r) @@ -228,7 +292,16 @@ func (h *Handler) DeleteWorkspace() http.HandlerFunc { } } -// GetLastWorkspaceName returns the name of the last opened workspace +// GetLastWorkspaceName godoc +// @Summary Get last workspace name +// @Description Returns the name of the last opened workspace +// @Tags workspaces +// @ID getLastWorkspaceName +// @Security BearerAuth +// @Produce json +// @Success 200 {object} map[string]string +// @Failure 500 {string} string "Failed to get last workspace" +// @Router /workspaces/last [get] func (h *Handler) GetLastWorkspaceName() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { ctx, ok := context.GetRequestContext(w, r) @@ -246,7 +319,18 @@ func (h *Handler) GetLastWorkspaceName() http.HandlerFunc { } } -// UpdateLastWorkspaceName updates the name of the last opened workspace +// UpdateLastWorkspaceName godoc +// @Summary Update last workspace name +// @Description Updates the name of the last opened workspace +// @Tags workspaces +// @ID updateLastWorkspaceName +// @Security BearerAuth +// @Accept json +// @Produce json +// @Success 200 {object} map[string]string +// @Failure 400 {string} string "Invalid request body" +// @Failure 500 {string} string "Failed to update last workspace" +// @Router /workspaces/last [put] func (h *Handler) UpdateLastWorkspaceName() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { ctx, ok := context.GetRequestContext(w, r) From e413e955c5ead71c6dcde494d68bf9ffd2b8c636 Mon Sep 17 00:00:00 2001 From: LordMathis Date: Tue, 3 Dec 2024 21:50:16 +0100 Subject: [PATCH 3/5] Update api docs --- server/internal/git/client.go | 25 ++- server/internal/handlers/admin_handlers.go | 100 ++++++------ server/internal/handlers/auth_handlers.go | 46 +++--- .../auth_handlers_integration_test.go | 2 +- server/internal/handlers/file_handlers.go | 142 ++++++++++-------- .../file_handlers_integration_test.go | 4 +- server/internal/handlers/git_handlers.go | 47 +++--- .../handlers/git_handlers_integration_test.go | 4 +- server/internal/handlers/handlers.go | 13 +- server/internal/handlers/mock_git_test.go | 7 +- server/internal/handlers/static_handler.go | 2 +- server/internal/handlers/user_handlers.go | 70 ++++----- .../user_handlers_integration_test.go | 2 +- .../internal/handlers/workspace_handlers.go | 102 +++++++------ .../workspace_handlers_integration_test.go | 2 +- server/internal/storage/git.go | 14 +- server/internal/storage/git_test.go | 10 +- 17 files changed, 331 insertions(+), 261 deletions(-) diff --git a/server/internal/git/client.go b/server/internal/git/client.go index 0b9cc9b..24e09c1 100644 --- a/server/internal/git/client.go +++ b/server/internal/git/client.go @@ -8,6 +8,7 @@ import ( "time" "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing" "github.com/go-git/go-git/v5/plumbing/object" "github.com/go-git/go-git/v5/plumbing/transport/http" ) @@ -26,11 +27,19 @@ type Config struct { type Client interface { Clone() error Pull() error - Commit(message string) error + Commit(message string) (CommitHash, error) Push() error EnsureRepo() error } +// CommitHash represents a Git commit hash +type CommitHash plumbing.Hash + +// String returns the string representation of the CommitHash +func (h CommitHash) String() string { + return plumbing.Hash(h).String() +} + // client implements the Client interface type client struct { Config @@ -101,22 +110,22 @@ func (c *client) Pull() error { } // Commit commits the changes in the repository with the given message -func (c *client) Commit(message string) error { +func (c *client) Commit(message string) (CommitHash, error) { if c.repo == nil { - return fmt.Errorf("repository not initialized") + return CommitHash(plumbing.ZeroHash), fmt.Errorf("repository not initialized") } w, err := c.repo.Worktree() if err != nil { - return fmt.Errorf("failed to get worktree: %w", err) + return CommitHash(plumbing.ZeroHash), fmt.Errorf("failed to get worktree: %w", err) } _, err = w.Add(".") if err != nil { - return fmt.Errorf("failed to add changes: %w", err) + return CommitHash(plumbing.ZeroHash), fmt.Errorf("failed to add changes: %w", err) } - _, err = w.Commit(message, &git.CommitOptions{ + hash, err := w.Commit(message, &git.CommitOptions{ Author: &object.Signature{ Name: c.CommitName, Email: c.CommitEmail, @@ -124,10 +133,10 @@ func (c *client) Commit(message string) error { }, }) if err != nil { - return fmt.Errorf("failed to commit changes: %w", err) + return CommitHash(plumbing.ZeroHash), fmt.Errorf("failed to commit changes: %w", err) } - return nil + return CommitHash(hash), nil } // Push pushes the changes to the remote repository diff --git a/server/internal/handlers/admin_handlers.go b/server/internal/handlers/admin_handlers.go index 3d1a2db..a776204 100644 --- a/server/internal/handlers/admin_handlers.go +++ b/server/internal/handlers/admin_handlers.go @@ -55,13 +55,13 @@ type SystemStats struct { // @ID adminListUsers // @Produce json // @Success 200 {array} models.User -// @Failure 500 {string} "Failed to list users" +// @Failure 500 {object} ErrorResponse "Failed to list users" // @Router /admin/users [get] func (h *Handler) AdminListUsers() http.HandlerFunc { return func(w http.ResponseWriter, _ *http.Request) { users, err := h.DB.GetAllUsers() if err != nil { - http.Error(w, "Failed to list users", http.StatusInternalServerError) + respondError(w, "Failed to list users", http.StatusInternalServerError) return } @@ -79,45 +79,45 @@ func (h *Handler) AdminListUsers() http.HandlerFunc { // @Produce json // @Param user body CreateUserRequest true "User details" // @Success 200 {object} models.User -// @Failure 400 {string} "Invalid request body" -// @Failure 400 {string} "Email, password, and role are required" -// @Failure 400 {string} "Password must be at least 8 characters" -// @Failure 409 {string} "Email already exists" -// @Failure 500 {string} "Failed to hash password" -// @Failure 500 {string} "Failed to create user" -// @Failure 500 {string} "Failed to initialize user workspace" +// @Failure 400 {object} ErrorResponse "Invalid request body" +// @Failure 400 {object} ErrorResponse "Email, password, and role are required" +// @Failure 400 {object} ErrorResponse "Password must be at least 8 characters" +// @Failure 409 {object} ErrorResponse "Email already exists" +// @Failure 500 {object} ErrorResponse "Failed to hash password" +// @Failure 500 {object} ErrorResponse "Failed to create user" +// @Failure 500 {object} ErrorResponse "Failed to initialize user workspace" // @Router /admin/users [post] func (h *Handler) AdminCreateUser() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { var req CreateUserRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "Invalid request body", http.StatusBadRequest) + respondError(w, "Invalid request body", http.StatusBadRequest) return } // Validate request if req.Email == "" || req.Password == "" || req.Role == "" { - http.Error(w, "Email, password, and role are required", http.StatusBadRequest) + respondError(w, "Email, password, and role are required", http.StatusBadRequest) return } // Check if email already exists existingUser, err := h.DB.GetUserByEmail(req.Email) if err == nil && existingUser != nil { - http.Error(w, "Email already exists", http.StatusConflict) + respondError(w, "Email already exists", http.StatusConflict) return } // Check if password is long enough if len(req.Password) < 8 { - http.Error(w, "Password must be at least 8 characters", http.StatusBadRequest) + respondError(w, "Password must be at least 8 characters", http.StatusBadRequest) return } // Hash password hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) if err != nil { - http.Error(w, "Failed to hash password", http.StatusInternalServerError) + respondError(w, "Failed to hash password", http.StatusInternalServerError) return } @@ -131,13 +131,13 @@ func (h *Handler) AdminCreateUser() http.HandlerFunc { insertedUser, err := h.DB.CreateUser(user) if err != nil { - http.Error(w, "Failed to create user", http.StatusInternalServerError) + respondError(w, "Failed to create user", http.StatusInternalServerError) return } // Initialize user workspace if err := h.Storage.InitializeUserWorkspace(insertedUser.ID, insertedUser.LastWorkspaceID); err != nil { - http.Error(w, "Failed to initialize user workspace", http.StatusInternalServerError) + respondError(w, "Failed to initialize user workspace", http.StatusInternalServerError) return } @@ -154,20 +154,20 @@ func (h *Handler) AdminCreateUser() http.HandlerFunc { // @Produce json // @Param userId path int true "User ID" // @Success 200 {object} models.User -// @Failure 400 {string} "Invalid user ID" -// @Failure 404 {string} "User not found" +// @Failure 400 {object} ErrorResponse "Invalid user ID" +// @Failure 404 {object} ErrorResponse "User not found" // @Router /admin/users/{userId} [get] func (h *Handler) AdminGetUser() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { userID, err := strconv.Atoi(chi.URLParam(r, "userId")) if err != nil { - http.Error(w, "Invalid user ID", http.StatusBadRequest) + respondError(w, "Invalid user ID", http.StatusBadRequest) return } user, err := h.DB.GetUserByID(userID) if err != nil { - http.Error(w, "User not found", http.StatusNotFound) + respondError(w, "User not found", http.StatusNotFound) return } @@ -186,30 +186,30 @@ func (h *Handler) AdminGetUser() http.HandlerFunc { // @Param userId path int true "User ID" // @Param user body UpdateUserRequest true "User details" // @Success 200 {object} models.User -// @Failure 400 {string} "Invalid user ID" -// @Failure 400 {string} "Invalid request body" -// @Failure 404 {string} "User not found" -// @Failure 500 {string} "Failed to hash password" -// @Failure 500 {string} "Failed to update user" +// @Failure 400 {object} ErrorResponse "Invalid user ID" +// @Failure 400 {object} ErrorResponse "Invalid request body" +// @Failure 404 {object} ErrorResponse "User not found" +// @Failure 500 {object} ErrorResponse "Failed to hash password" +// @Failure 500 {object} ErrorResponse "Failed to update user" // @Router /admin/users/{userId} [put] func (h *Handler) AdminUpdateUser() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { userID, err := strconv.Atoi(chi.URLParam(r, "userId")) if err != nil { - http.Error(w, "Invalid user ID", http.StatusBadRequest) + respondError(w, "Invalid user ID", http.StatusBadRequest) return } // Get existing user user, err := h.DB.GetUserByID(userID) if err != nil { - http.Error(w, "User not found", http.StatusNotFound) + respondError(w, "User not found", http.StatusNotFound) return } var req UpdateUserRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "Invalid request body", http.StatusBadRequest) + respondError(w, "Invalid request body", http.StatusBadRequest) return } @@ -226,14 +226,14 @@ func (h *Handler) AdminUpdateUser() http.HandlerFunc { if req.Password != "" { hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) if err != nil { - http.Error(w, "Failed to hash password", http.StatusInternalServerError) + respondError(w, "Failed to hash password", http.StatusInternalServerError) return } user.PasswordHash = string(hashedPassword) } if err := h.DB.UpdateUser(user); err != nil { - http.Error(w, "Failed to update user", http.StatusInternalServerError) + respondError(w, "Failed to update user", http.StatusInternalServerError) return } @@ -249,11 +249,11 @@ func (h *Handler) AdminUpdateUser() http.HandlerFunc { // @ID adminDeleteUser // @Param userId path int true "User ID" // @Success 204 "No Content" -// @Failure 400 {string} "Invalid user ID" -// @Failure 400 {string} "Cannot delete your own account" -// @Failure 403 {string} "Cannot delete other admin users" -// @Failure 404 {string} "User not found" -// @Failure 500 {string} "Failed to delete user" +// @Failure 400 {object} ErrorResponse "Invalid user ID" +// @Failure 400 {object} ErrorResponse "Cannot delete your own account" +// @Failure 403 {object} ErrorResponse "Cannot delete other admin users" +// @Failure 404 {object} ErrorResponse "User not found" +// @Failure 500 {object} ErrorResponse "Failed to delete user" // @Router /admin/users/{userId} [delete] func (h *Handler) AdminDeleteUser() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { @@ -264,31 +264,31 @@ func (h *Handler) AdminDeleteUser() http.HandlerFunc { userID, err := strconv.Atoi(chi.URLParam(r, "userId")) if err != nil { - http.Error(w, "Invalid user ID", http.StatusBadRequest) + respondError(w, "Invalid user ID", http.StatusBadRequest) return } // Prevent admin from deleting themselves if userID == ctx.UserID { - http.Error(w, "Cannot delete your own account", http.StatusBadRequest) + respondError(w, "Cannot delete your own account", http.StatusBadRequest) return } // Get user before deletion to check role user, err := h.DB.GetUserByID(userID) if err != nil { - http.Error(w, "User not found", http.StatusNotFound) + respondError(w, "User not found", http.StatusNotFound) return } // Prevent deletion of other admin users if user.Role == models.RoleAdmin && ctx.UserID != userID { - http.Error(w, "Cannot delete other admin users", http.StatusForbidden) + respondError(w, "Cannot delete other admin users", http.StatusForbidden) return } if err := h.DB.DeleteUser(userID); err != nil { - http.Error(w, "Failed to delete user", http.StatusInternalServerError) + respondError(w, "Failed to delete user", http.StatusInternalServerError) return } @@ -304,15 +304,15 @@ func (h *Handler) AdminDeleteUser() http.HandlerFunc { // @ID adminListWorkspaces // @Produce json // @Success 200 {array} WorkspaceStats -// @Failure 500 {string} "Failed to list workspaces" -// @Failure 500 {string} "Failed to get user" -// @Failure 500 {string} "Failed to get file stats" +// @Failure 500 {object} ErrorResponse "Failed to list workspaces" +// @Failure 500 {object} ErrorResponse "Failed to get user" +// @Failure 500 {object} ErrorResponse "Failed to get file stats" // @Router /admin/workspaces [get] func (h *Handler) AdminListWorkspaces() http.HandlerFunc { return func(w http.ResponseWriter, _ *http.Request) { workspaces, err := h.DB.GetAllWorkspaces() if err != nil { - http.Error(w, "Failed to list workspaces", http.StatusInternalServerError) + respondError(w, "Failed to list workspaces", http.StatusInternalServerError) return } @@ -324,7 +324,7 @@ func (h *Handler) AdminListWorkspaces() http.HandlerFunc { user, err := h.DB.GetUserByID(ws.UserID) if err != nil { - http.Error(w, "Failed to get user", http.StatusInternalServerError) + respondError(w, "Failed to get user", http.StatusInternalServerError) return } @@ -336,7 +336,7 @@ func (h *Handler) AdminListWorkspaces() http.HandlerFunc { fileStats, err := h.Storage.GetFileStats(ws.UserID, ws.ID) if err != nil { - http.Error(w, "Failed to get file stats", http.StatusInternalServerError) + respondError(w, "Failed to get file stats", http.StatusInternalServerError) return } @@ -357,20 +357,20 @@ func (h *Handler) AdminListWorkspaces() http.HandlerFunc { // @ID adminGetSystemStats // @Produce json // @Success 200 {object} SystemStats -// @Failure 500 {string} "Failed to get user stats" -// @Failure 500 {string} "Failed to get file stats" +// @Failure 500 {object} ErrorResponse "Failed to get user stats" +// @Failure 500 {object} ErrorResponse "Failed to get file stats" // @Router /admin/stats [get] func (h *Handler) AdminGetSystemStats() http.HandlerFunc { return func(w http.ResponseWriter, _ *http.Request) { userStats, err := h.DB.GetSystemStats() if err != nil { - http.Error(w, "Failed to get user stats", http.StatusInternalServerError) + respondError(w, "Failed to get user stats", http.StatusInternalServerError) return } fileStats, err := h.Storage.GetTotalFileStats() if err != nil { - http.Error(w, "Failed to get file stats", http.StatusInternalServerError) + respondError(w, "Failed to get file stats", http.StatusInternalServerError) return } diff --git a/server/internal/handlers/auth_handlers.go b/server/internal/handlers/auth_handlers.go index d039623..68b637f 100644 --- a/server/internal/handlers/auth_handlers.go +++ b/server/internal/handlers/auth_handlers.go @@ -43,43 +43,43 @@ type RefreshResponse struct { // @Produce json // @Param body body LoginRequest true "Login request" // @Success 200 {object} LoginResponse -// @Failure 400 {string} string "Invalid request body" -// @Failure 400 {string} string "Email and password are required" -// @Failure 401 {string} string "Invalid credentials" -// @Failure 500 {string} string "Failed to create session" +// @Failure 400 {object} ErrorResponse "Invalid request body" +// @Failure 400 {object} ErrorResponse "Email and password are required" +// @Failure 401 {object} ErrorResponse "Invalid credentials" +// @Failure 500 {object} ErrorResponse "Failed to create session" // @Router /auth/login [post] func (h *Handler) Login(authService *auth.SessionService) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { var req LoginRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "Invalid request body", http.StatusBadRequest) + respondError(w, "Invalid request body", http.StatusBadRequest) return } // Validate request if req.Email == "" || req.Password == "" { - http.Error(w, "Email and password are required", http.StatusBadRequest) + respondError(w, "Email and password are required", http.StatusBadRequest) return } // Get user from database user, err := h.DB.GetUserByEmail(req.Email) if err != nil { - http.Error(w, "Invalid credentials", http.StatusUnauthorized) + respondError(w, "Invalid credentials", http.StatusUnauthorized) return } // Verify password err = bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)) if err != nil { - http.Error(w, "Invalid credentials", http.StatusUnauthorized) + respondError(w, "Invalid credentials", http.StatusUnauthorized) return } // Create session and generate tokens session, accessToken, err := authService.CreateSession(user.ID, string(user.Role)) if err != nil { - http.Error(w, "Failed to create session", http.StatusInternalServerError) + respondError(w, "Failed to create session", http.StatusInternalServerError) return } @@ -101,25 +101,25 @@ func (h *Handler) Login(authService *auth.SessionService) http.HandlerFunc { // @Tags auth // @ID logout // @Security BearerAuth -// @Success 200 {string} string "OK" -// @Failure 400 {string} string "Session ID required" -// @Failure 500 {string} string "Failed to logout" +// @Success 204 "No Content" +// @Failure 400 {object} ErrorResponse "Session ID required" +// @Failure 500 {object} ErrorResponse "Failed to logout" // @Router /auth/logout [post] func (h *Handler) Logout(authService *auth.SessionService) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { sessionID := r.Header.Get("X-Session-ID") if sessionID == "" { - http.Error(w, "Session ID required", http.StatusBadRequest) + respondError(w, "Session ID required", http.StatusBadRequest) return } err := authService.InvalidateSession(sessionID) if err != nil { - http.Error(w, "Failed to logout", http.StatusInternalServerError) + respondError(w, "Failed to logout", http.StatusInternalServerError) return } - w.WriteHeader(http.StatusOK) + w.WriteHeader(http.StatusNoContent) } } @@ -132,27 +132,27 @@ func (h *Handler) Logout(authService *auth.SessionService) http.HandlerFunc { // @Produce json // @Param body body RefreshRequest true "Refresh request" // @Success 200 {object} RefreshResponse -// @Failure 400 {string} string "Invalid request body" -// @Failure 400 {string} string "Refresh token required" -// @Failure 401 {string} string "Invalid refresh token" +// @Failure 400 {object} ErrorResponse "Invalid request body" +// @Failure 400 {object} ErrorResponse "Refresh token required" +// @Failure 401 {object} ErrorResponse "Invalid refresh token" // @Router /auth/refresh [post] func (h *Handler) RefreshToken(authService *auth.SessionService) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { var req RefreshRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "Invalid request body", http.StatusBadRequest) + respondError(w, "Invalid request body", http.StatusBadRequest) return } if req.RefreshToken == "" { - http.Error(w, "Refresh token required", http.StatusBadRequest) + respondError(w, "Refresh token required", http.StatusBadRequest) return } // Generate new access token accessToken, err := authService.RefreshSession(req.RefreshToken) if err != nil { - http.Error(w, "Invalid refresh token", http.StatusUnauthorized) + respondError(w, "Invalid refresh token", http.StatusUnauthorized) return } @@ -172,7 +172,7 @@ func (h *Handler) RefreshToken(authService *auth.SessionService) http.HandlerFun // @Security BearerAuth // @Produce json // @Success 200 {object} models.User -// @Failure 404 {string} string "User not found" +// @Failure 404 {object} ErrorResponse "User not found" // @Router /auth/me [get] func (h *Handler) GetCurrentUser() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { @@ -184,7 +184,7 @@ func (h *Handler) GetCurrentUser() http.HandlerFunc { // Get user from database user, err := h.DB.GetUserByID(ctx.UserID) if err != nil { - http.Error(w, "User not found", http.StatusNotFound) + respondError(w, "User not found", http.StatusNotFound) return } diff --git a/server/internal/handlers/auth_handlers_integration_test.go b/server/internal/handlers/auth_handlers_integration_test.go index d917e32..6db4153 100644 --- a/server/internal/handlers/auth_handlers_integration_test.go +++ b/server/internal/handlers/auth_handlers_integration_test.go @@ -188,7 +188,7 @@ func TestAuthHandlers_Integration(t *testing.T) { "X-Session-ID": loginResp.Session.ID, } rr = h.makeRequest(t, http.MethodPost, "/api/v1/auth/logout", nil, loginResp.AccessToken, headers) - require.Equal(t, http.StatusOK, rr.Code) + require.Equal(t, http.StatusNoContent, rr.Code) // Try to use the refresh token - should fail refreshReq := handlers.RefreshRequest{ diff --git a/server/internal/handlers/file_handlers.go b/server/internal/handlers/file_handlers.go index b0b52f1..5e6ad27 100644 --- a/server/internal/handlers/file_handlers.go +++ b/server/internal/handlers/file_handlers.go @@ -5,6 +5,7 @@ import ( "io" "net/http" "os" + "time" "novamd/internal/context" "novamd/internal/storage" @@ -12,6 +13,28 @@ import ( "github.com/go-chi/chi/v5" ) +// LookupResponse represents a response to a file lookup request +type LookupResponse struct { + Paths []string `json:"paths"` +} + +// SaveFileResponse represents a response to a save file request +type SaveFileResponse struct { + FilePath string `json:"filePath"` + Size int64 `json:"size"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// LastOpenedFileResponse represents a response to a last opened file request +type LastOpenedFileResponse struct { + LastOpenedFilePath string `json:"lastOpenedFilePath"` +} + +// UpdateLastOpenedFileRequest represents a request to update the last opened file +type UpdateLastOpenedFileRequest struct { + FilePath string `json:"filePath"` +} + // ListFiles godoc // @Summary List files // @Description Lists all files in the user's workspace @@ -20,8 +43,8 @@ import ( // @Security BearerAuth // @Produce json // @Param workspace_name path string true "Workspace name" -// @Success 200 {array} string -// @Failure 500 {string} string "Failed to list files" +// @Success 200 {array} storage.FileNode +// @Failure 500 {object} ErrorResponse "Failed to list files" // @Router /workspaces/{workspace_name}/files [get] func (h *Handler) ListFiles() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { @@ -32,7 +55,7 @@ func (h *Handler) ListFiles() http.HandlerFunc { files, err := h.Storage.ListFilesRecursively(ctx.UserID, ctx.Workspace.ID) if err != nil { - http.Error(w, "Failed to list files", http.StatusInternalServerError) + respondError(w, "Failed to list files", http.StatusInternalServerError) return } @@ -49,9 +72,9 @@ func (h *Handler) ListFiles() http.HandlerFunc { // @Produce json // @Param workspace_name path string true "Workspace name" // @Param filename query string true "File name" -// @Success 200 {object} map[string][]string -// @Failure 400 {string} string "Filename is required" -// @Failure 404 {string} string "File not found" +// @Success 200 {object} LookupResponse +// @Failure 400 {object} ErrorResponse "Filename is required" +// @Failure 404 {object} ErrorResponse "File not found" // @Router /workspaces/{workspace_name}/files/lookup [get] func (h *Handler) LookupFileByName() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { @@ -62,17 +85,17 @@ func (h *Handler) LookupFileByName() http.HandlerFunc { filename := r.URL.Query().Get("filename") if filename == "" { - http.Error(w, "Filename is required", http.StatusBadRequest) + respondError(w, "Filename is required", http.StatusBadRequest) return } filePaths, err := h.Storage.FindFileByName(ctx.UserID, ctx.Workspace.ID, filename) if err != nil { - http.Error(w, "File not found", http.StatusNotFound) + respondError(w, "File not found", http.StatusNotFound) return } - respondJSON(w, map[string][]string{"paths": filePaths}) + respondJSON(w, &LookupResponse{Paths: filePaths}) } } @@ -86,10 +109,10 @@ func (h *Handler) LookupFileByName() http.HandlerFunc { // @Param workspace_name path string true "Workspace name" // @Param file_path path string true "File path" // @Success 200 {string} "File content" -// @Failure 400 {string} string "Invalid file path" -// @Failure 404 {string} string "File not found" -// @Failure 500 {string} string "Failed to read file" -// @Failure 500 {string} string "Failed to write response" +// @Failure 400 {object} ErrorResponse "Invalid file path" +// @Failure 404 {object} ErrorResponse "File not found" +// @Failure 500 {object} ErrorResponse "Failed to read file" +// @Failure 500 {object} ErrorResponse "Failed to write response" // @Router /workspaces/{workspace_name}/files/* [get] func (h *Handler) GetFileContent() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { @@ -103,23 +126,23 @@ func (h *Handler) GetFileContent() http.HandlerFunc { if err != nil { if storage.IsPathValidationError(err) { - http.Error(w, "Invalid file path", http.StatusBadRequest) + respondError(w, "Invalid file path", http.StatusBadRequest) return } if os.IsNotExist(err) { - http.Error(w, "File not found", http.StatusNotFound) + respondError(w, "File not found", http.StatusNotFound) return } - http.Error(w, "Failed to read file", http.StatusInternalServerError) + respondError(w, "Failed to read file", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/plain") _, err = w.Write(content) if err != nil { - http.Error(w, "Failed to write response", http.StatusInternalServerError) + respondError(w, "Failed to write response", http.StatusInternalServerError) return } } @@ -135,10 +158,10 @@ func (h *Handler) GetFileContent() http.HandlerFunc { // @Produce json // @Param workspace_name path string true "Workspace name" // @Param file_path path string true "File path" -// @Success 200 {string} "File saved successfully" -// @Failure 400 {string} string "Failed to read request body" -// @Failure 400 {string} string "Invalid file path" -// @Failure 500 {string} string "Failed to save file" +// @Success 200 {object} SaveFileResponse +// @Failure 400 {object} ErrorResponse "Failed to read request body" +// @Failure 400 {object} ErrorResponse "Invalid file path" +// @Failure 500 {object} ErrorResponse "Failed to save file" // @Router /workspaces/{workspace_name}/files/* [post] func (h *Handler) SaveFile() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { @@ -150,22 +173,29 @@ func (h *Handler) SaveFile() http.HandlerFunc { filePath := chi.URLParam(r, "*") content, err := io.ReadAll(r.Body) if err != nil { - http.Error(w, "Failed to read request body", http.StatusBadRequest) + respondError(w, "Failed to read request body", http.StatusBadRequest) return } err = h.Storage.SaveFile(ctx.UserID, ctx.Workspace.ID, filePath, content) if err != nil { if storage.IsPathValidationError(err) { - http.Error(w, "Invalid file path", http.StatusBadRequest) + respondError(w, "Invalid file path", http.StatusBadRequest) return } - http.Error(w, "Failed to save file", http.StatusInternalServerError) + respondError(w, "Failed to save file", http.StatusInternalServerError) return } - respondJSON(w, map[string]string{"message": "File saved successfully"}) + response := SaveFileResponse{ + FilePath: filePath, + Size: int64(len(content)), + UpdatedAt: time.Now().UTC(), + } + + w.WriteHeader(http.StatusOK) + respondJSON(w, response) } } @@ -178,11 +208,11 @@ func (h *Handler) SaveFile() http.HandlerFunc { // @Produce string // @Param workspace_name path string true "Workspace name" // @Param file_path path string true "File path" -// @Success 200 {string} "File deleted successfully" -// @Failure 400 {string} string "Invalid file path" -// @Failure 404 {string} string "File not found" -// @Failure 500 {string} string "Failed to delete file" -// @Failure 500 {string} string "Failed to write response" +// @Success 204 "No Content - File deleted successfully" +// @Failure 400 {object} ErrorResponse "Invalid file path" +// @Failure 404 {object} ErrorResponse "File not found" +// @Failure 500 {object} ErrorResponse "Failed to delete file" +// @Failure 500 {object} ErrorResponse "Failed to write response" // @Router /workspaces/{workspace_name}/files/* [delete] func (h *Handler) DeleteFile() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { @@ -195,25 +225,20 @@ func (h *Handler) DeleteFile() http.HandlerFunc { err := h.Storage.DeleteFile(ctx.UserID, ctx.Workspace.ID, filePath) if err != nil { if storage.IsPathValidationError(err) { - http.Error(w, "Invalid file path", http.StatusBadRequest) + respondError(w, "Invalid file path", http.StatusBadRequest) return } if os.IsNotExist(err) { - http.Error(w, "File not found", http.StatusNotFound) + respondError(w, "File not found", http.StatusNotFound) return } - http.Error(w, "Failed to delete file", http.StatusInternalServerError) + respondError(w, "Failed to delete file", http.StatusInternalServerError) return } - w.WriteHeader(http.StatusOK) - _, err = w.Write([]byte("File deleted successfully")) - if err != nil { - http.Error(w, "Failed to write response", http.StatusInternalServerError) - return - } + w.WriteHeader(http.StatusNoContent) } } @@ -225,9 +250,9 @@ func (h *Handler) DeleteFile() http.HandlerFunc { // @Security BearerAuth // @Produce json // @Param workspace_name path string true "Workspace name" -// @Success 200 {object} map[string]string -// @Failure 400 {string} string "Invalid file path" -// @Failure 500 {string} string "Failed to get last opened file" +// @Success 200 {object} LastOpenedFileResponse +// @Failure 400 {object} ErrorResponse "Invalid file path" +// @Failure 500 {object} ErrorResponse "Failed to get last opened file" // @Router /workspaces/{workspace_name}/files/last [get] func (h *Handler) GetLastOpenedFile() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { @@ -238,16 +263,16 @@ func (h *Handler) GetLastOpenedFile() http.HandlerFunc { filePath, err := h.DB.GetLastOpenedFile(ctx.Workspace.ID) if err != nil { - http.Error(w, "Failed to get last opened file", http.StatusInternalServerError) + respondError(w, "Failed to get last opened file", http.StatusInternalServerError) return } if _, err := h.Storage.ValidatePath(ctx.UserID, ctx.Workspace.ID, filePath); err != nil { - http.Error(w, "Invalid file path", http.StatusBadRequest) + respondError(w, "Invalid file path", http.StatusBadRequest) return } - respondJSON(w, map[string]string{"lastOpenedFilePath": filePath}) + respondJSON(w, &LastOpenedFileResponse{LastOpenedFilePath: filePath}) } } @@ -260,11 +285,12 @@ func (h *Handler) GetLastOpenedFile() http.HandlerFunc { // @Accept json // @Produce json // @Param workspace_name path string true "Workspace name" -// @Success 200 {object} map[string]string -// @Failure 400 {string} string "Invalid request body" -// @Failure 400 {string} string "Invalid file path" -// @Failure 404 {string} string "File not found" -// @Failure 500 {string} string "Failed to update file" +// @Param body body UpdateLastOpenedFileRequest true "Update last opened file request" +// @Success 204 "No Content - Last opened file updated successfully" +// @Failure 400 {object} ErrorResponse "Invalid request body" +// @Failure 400 {object} ErrorResponse "Invalid file path" +// @Failure 404 {object} ErrorResponse "File not found" +// @Failure 500 {object} ErrorResponse "Failed to update file" // @Router /workspaces/{workspace_name}/files/last [put] func (h *Handler) UpdateLastOpenedFile() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { @@ -273,12 +299,10 @@ func (h *Handler) UpdateLastOpenedFile() http.HandlerFunc { return } - var requestBody struct { - FilePath string `json:"filePath"` - } + var requestBody UpdateLastOpenedFileRequest if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil { - http.Error(w, "Invalid request body", http.StatusBadRequest) + respondError(w, "Invalid request body", http.StatusBadRequest) return } @@ -287,25 +311,25 @@ func (h *Handler) UpdateLastOpenedFile() http.HandlerFunc { _, err := h.Storage.GetFileContent(ctx.UserID, ctx.Workspace.ID, requestBody.FilePath) if err != nil { if storage.IsPathValidationError(err) { - http.Error(w, "Invalid file path", http.StatusBadRequest) + respondError(w, "Invalid file path", http.StatusBadRequest) return } if os.IsNotExist(err) { - http.Error(w, "File not found", http.StatusNotFound) + respondError(w, "File not found", http.StatusNotFound) return } - http.Error(w, "Failed to update last opened file", http.StatusInternalServerError) + respondError(w, "Failed to update last opened file", http.StatusInternalServerError) return } } if err := h.DB.UpdateLastOpenedFile(ctx.Workspace.ID, requestBody.FilePath); err != nil { - http.Error(w, "Failed to update last opened file", http.StatusInternalServerError) + respondError(w, "Failed to update last opened file", http.StatusInternalServerError) return } - respondJSON(w, map[string]string{"message": "Last opened file updated successfully"}) + w.WriteHeader(http.StatusNoContent) } } diff --git a/server/internal/handlers/file_handlers_integration_test.go b/server/internal/handlers/file_handlers_integration_test.go index 70327d6..516c508 100644 --- a/server/internal/handlers/file_handlers_integration_test.go +++ b/server/internal/handlers/file_handlers_integration_test.go @@ -145,7 +145,7 @@ func TestFileHandlers_Integration(t *testing.T) { // Delete file rr = h.makeRequest(t, http.MethodDelete, baseURL+"/"+filePath, nil, h.RegularToken, nil) - require.Equal(t, http.StatusOK, rr.Code) + require.Equal(t, http.StatusNoContent, rr.Code) // Verify file is gone rr = h.makeRequest(t, http.MethodGet, baseURL+"/"+filePath, nil, h.RegularToken, nil) @@ -171,7 +171,7 @@ func TestFileHandlers_Integration(t *testing.T) { FilePath: "docs/readme.md", } rr = h.makeRequest(t, http.MethodPut, baseURL+"/last", updateReq, h.RegularToken, nil) - require.Equal(t, http.StatusOK, rr.Code) + require.Equal(t, http.StatusNoContent, rr.Code) // Verify update rr = h.makeRequest(t, http.MethodGet, baseURL+"/last", nil, h.RegularToken, nil) diff --git a/server/internal/handlers/git_handlers.go b/server/internal/handlers/git_handlers.go index 433a242..1ab45e0 100644 --- a/server/internal/handlers/git_handlers.go +++ b/server/internal/handlers/git_handlers.go @@ -7,6 +7,21 @@ import ( "novamd/internal/context" ) +// CommitRequest represents a request to commit changes +type CommitRequest struct { + Message string `json:"message" example:"Initial commit"` +} + +// CommitResponse represents a response to a commit request +type CommitResponse struct { + CommitHash string `json:"commitHash" example:"a1b2c3d4"` +} + +// PullResponse represents a response to a pull http request +type PullResponse struct { + Message string `json:"message" example:"Pulled changes from remote"` +} + // StageCommitAndPush godoc // @Summary Stage, commit, and push changes // @Description Stages, commits, and pushes changes to the remote repository @@ -15,11 +30,11 @@ import ( // @Security BearerAuth // @Produce json // @Param workspace_name path string true "Workspace name" -// @Param body body string true "Commit message" -// @Success 200 {object} map[string]string -// @Failure 400 {string} string "Invalid request body" -// @Failure 400 {string} string "Commit message is required" -// @Failure 500 {string} string "Failed to stage, commit, and push changes" +// @Param body body CommitRequest true "Commit request" +// @Success 200 {object} CommitResponse +// @Failure 400 {object} ErrorResponse "Invalid request body" +// @Failure 400 {object} ErrorResponse "Commit message is required" +// @Failure 500 {object} ErrorResponse "Failed to stage, commit, and push changes" // @Router /workspaces/{workspace_name}/git/commit [post] func (h *Handler) StageCommitAndPush() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { @@ -28,27 +43,25 @@ func (h *Handler) StageCommitAndPush() http.HandlerFunc { return } - var requestBody struct { - Message string `json:"message"` - } + var requestBody CommitRequest if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil { - http.Error(w, "Invalid request body", http.StatusBadRequest) + respondError(w, "Invalid request body", http.StatusBadRequest) return } if requestBody.Message == "" { - http.Error(w, "Commit message is required", http.StatusBadRequest) + respondError(w, "Commit message is required", http.StatusBadRequest) return } - err := h.Storage.StageCommitAndPush(ctx.UserID, ctx.Workspace.ID, requestBody.Message) + hash, err := h.Storage.StageCommitAndPush(ctx.UserID, ctx.Workspace.ID, requestBody.Message) if err != nil { - http.Error(w, "Failed to stage, commit, and push changes: "+err.Error(), http.StatusInternalServerError) + respondError(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"}) + respondJSON(w, CommitResponse{CommitHash: hash.String()}) } } @@ -60,8 +73,8 @@ func (h *Handler) StageCommitAndPush() http.HandlerFunc { // @Security BearerAuth // @Produce json // @Param workspace_name path string true "Workspace name" -// @Success 200 {object} map[string]string -// @Failure 500 {string} string "Failed to pull changes" +// @Success 200 {object} PullResponse +// @Failure 500 {object} ErrorResponse "Failed to pull changes" // @Router /workspaces/{workspace_name}/git/pull [post] func (h *Handler) PullChanges() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { @@ -72,10 +85,10 @@ func (h *Handler) PullChanges() http.HandlerFunc { err := h.Storage.Pull(ctx.UserID, ctx.Workspace.ID) if err != nil { - http.Error(w, "Failed to pull changes: "+err.Error(), http.StatusInternalServerError) + respondError(w, "Failed to pull changes: "+err.Error(), http.StatusInternalServerError) return } - respondJSON(w, map[string]string{"message": "Pulled changes from remote"}) + respondJSON(w, PullResponse{Message: "Successfully pulled changes from remote"}) } } diff --git a/server/internal/handlers/git_handlers_integration_test.go b/server/internal/handlers/git_handlers_integration_test.go index 6d26039..c7706c8 100644 --- a/server/internal/handlers/git_handlers_integration_test.go +++ b/server/internal/handlers/git_handlers_integration_test.go @@ -56,7 +56,7 @@ func TestGitHandlers_Integration(t *testing.T) { var response map[string]string err := json.NewDecoder(rr.Body).Decode(&response) require.NoError(t, err) - assert.Contains(t, response["message"], "successfully") + require.Contains(t, response, "commitHash") // Verify mock was called correctly assert.Equal(t, 1, h.MockGit.GetCommitCount(), "Commit should be called once") @@ -100,7 +100,7 @@ func TestGitHandlers_Integration(t *testing.T) { var response map[string]string err := json.NewDecoder(rr.Body).Decode(&response) require.NoError(t, err) - assert.Contains(t, response["message"], "Pulled changes") + assert.Contains(t, response["message"], "Successfully pulled changes") assert.Equal(t, 1, h.MockGit.GetPullCount(), "Pull should be called once") }) diff --git a/server/internal/handlers/handlers.go b/server/internal/handlers/handlers.go index 7af3611..da20999 100644 --- a/server/internal/handlers/handlers.go +++ b/server/internal/handlers/handlers.go @@ -7,6 +7,11 @@ import ( "novamd/internal/storage" ) +// ErrorResponse is a generic error response +type ErrorResponse struct { + Message string `json:"message"` +} + // Handler provides common functionality for all handlers type Handler struct { DB db.Database @@ -25,6 +30,12 @@ func NewHandler(db db.Database, s storage.Manager) *Handler { 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) + respondError(w, "Failed to encode response", http.StatusInternalServerError) } } + +// respondError is a helper to send error responses +func respondError(w http.ResponseWriter, message string, code int) { + w.WriteHeader(code) + respondJSON(w, ErrorResponse{Message: message}) +} diff --git a/server/internal/handlers/mock_git_test.go b/server/internal/handlers/mock_git_test.go index 23259f5..88a710b 100644 --- a/server/internal/handlers/mock_git_test.go +++ b/server/internal/handlers/mock_git_test.go @@ -4,6 +4,7 @@ package handlers_test import ( "fmt" + "novamd/internal/git" ) // MockGitClient implements the git.Client interface for testing @@ -51,13 +52,13 @@ func (m *MockGitClient) Pull() error { } // Commit implements git.Client -func (m *MockGitClient) Commit(message string) error { +func (m *MockGitClient) Commit(message string) (git.CommitHash, error) { if m.error != nil { - return m.error + return git.CommitHash{}, m.error } m.commitCount++ m.lastCommitMsg = message - return nil + return git.CommitHash{}, nil } // Push implements git.Client diff --git a/server/internal/handlers/static_handler.go b/server/internal/handlers/static_handler.go index 8360b3c..752e7b9 100644 --- a/server/internal/handlers/static_handler.go +++ b/server/internal/handlers/static_handler.go @@ -28,7 +28,7 @@ func (h *StaticHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { // Security check to prevent directory traversal if !strings.HasPrefix(cleanPath, h.staticPath) { - http.Error(w, "Invalid path", http.StatusBadRequest) + respondError(w, "Invalid path", http.StatusBadRequest) return } diff --git a/server/internal/handlers/user_handlers.go b/server/internal/handlers/user_handlers.go index 3e042df..58d4403 100644 --- a/server/internal/handlers/user_handlers.go +++ b/server/internal/handlers/user_handlers.go @@ -32,15 +32,15 @@ type DeleteAccountRequest struct { // @Produce json // @Param body body UpdateProfileRequest true "Profile update request" // @Success 200 {object} models.User -// @Failure 400 {string} string "Invalid request body" -// @Failure 400 {string} string "Current password is required to change password" -// @Failure 400 {string} string "New password must be at least 8 characters long" -// @Failure 400 {string} string "Current password is required to change email" -// @Failure 401 {string} string "Current password is incorrect" -// @Failure 404 {string} string "User not found" -// @Failure 409 {string} string "Email already in use" -// @Failure 500 {string} string "Failed to process new password" -// @Failure 500 {string} string "Failed to update profile" +// @Failure 400 {object} ErrorResponse "Invalid request body" +// @Failure 400 {object} ErrorResponse "Current password is required to change password" +// @Failure 400 {object} ErrorResponse "New password must be at least 8 characters long" +// @Failure 400 {object} ErrorResponse "Current password is required to change email" +// @Failure 401 {object} ErrorResponse "Current password is incorrect" +// @Failure 404 {object} ErrorResponse "User not found" +// @Failure 409 {object} ErrorResponse "Email already in use" +// @Failure 500 {object} ErrorResponse "Failed to process new password" +// @Failure 500 {object} ErrorResponse "Failed to update profile" // @Router /profile [put] func (h *Handler) UpdateProfile() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { @@ -51,14 +51,14 @@ func (h *Handler) UpdateProfile() http.HandlerFunc { var req UpdateProfileRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "Invalid request body", http.StatusBadRequest) + respondError(w, "Invalid request body", http.StatusBadRequest) return } // Get current user user, err := h.DB.GetUserByID(ctx.UserID) if err != nil { - http.Error(w, "User not found", http.StatusNotFound) + respondError(w, "User not found", http.StatusNotFound) return } @@ -66,26 +66,26 @@ func (h *Handler) UpdateProfile() http.HandlerFunc { if req.NewPassword != "" { // Current password must be provided to change password if req.CurrentPassword == "" { - http.Error(w, "Current password is required to change password", http.StatusBadRequest) + respondError(w, "Current password is required to change password", http.StatusBadRequest) return } // Verify current password if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.CurrentPassword)); err != nil { - http.Error(w, "Current password is incorrect", http.StatusUnauthorized) + respondError(w, "Current password is incorrect", http.StatusUnauthorized) return } // Validate new password if len(req.NewPassword) < 8 { - http.Error(w, "New password must be at least 8 characters long", http.StatusBadRequest) + respondError(w, "New password must be at least 8 characters long", http.StatusBadRequest) return } // Hash new password hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost) if err != nil { - http.Error(w, "Failed to process new password", http.StatusInternalServerError) + respondError(w, "Failed to process new password", http.StatusInternalServerError) return } user.PasswordHash = string(hashedPassword) @@ -95,14 +95,14 @@ func (h *Handler) UpdateProfile() http.HandlerFunc { if req.Email != "" && req.Email != user.Email { // Check if email change requires password verification if req.CurrentPassword == "" { - http.Error(w, "Current password is required to change email", http.StatusBadRequest) + respondError(w, "Current password is required to change email", http.StatusBadRequest) return } // Verify current password if not already verified for password change if req.NewPassword == "" { if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.CurrentPassword)); err != nil { - http.Error(w, "Current password is incorrect", http.StatusUnauthorized) + respondError(w, "Current password is incorrect", http.StatusUnauthorized) return } } @@ -110,7 +110,7 @@ func (h *Handler) UpdateProfile() http.HandlerFunc { // Check if new email is already in use existingUser, err := h.DB.GetUserByEmail(req.Email) if err == nil && existingUser.ID != user.ID { - http.Error(w, "Email already in use", http.StatusConflict) + respondError(w, "Email already in use", http.StatusConflict) return } user.Email = req.Email @@ -123,7 +123,7 @@ func (h *Handler) UpdateProfile() http.HandlerFunc { // Update user in database if err := h.DB.UpdateUser(user); err != nil { - http.Error(w, "Failed to update profile", http.StatusInternalServerError) + respondError(w, "Failed to update profile", http.StatusInternalServerError) return } @@ -141,13 +141,13 @@ func (h *Handler) UpdateProfile() http.HandlerFunc { // @Accept json // @Produce json // @Param body body DeleteAccountRequest true "Account deletion request" -// @Success 200 {object} map[string]string -// @Failure 400 {string} string "Invalid request body" -// @Failure 401 {string} string "Password is incorrect" -// @Failure 403 {string} string "Cannot delete the last admin account" -// @Failure 404 {string} string "User not found" -// @Failure 500 {string} string "Failed to verify admin status" -// @Failure 500 {string} string "Failed to delete account" +// @Success 204 "No Content - Account deleted successfully" +// @Failure 400 {object} ErrorResponse "Invalid request body" +// @Failure 401 {object} ErrorResponse "Password is incorrect" +// @Failure 403 {object} ErrorResponse "Cannot delete the last admin account" +// @Failure 404 {object} ErrorResponse "User not found" +// @Failure 500 {object} ErrorResponse "Failed to verify admin status" +// @Failure 500 {object} ErrorResponse "Failed to delete account" // @Router /profile [delete] func (h *Handler) DeleteAccount() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { @@ -158,20 +158,20 @@ func (h *Handler) DeleteAccount() http.HandlerFunc { var req DeleteAccountRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "Invalid request body", http.StatusBadRequest) + respondError(w, "Invalid request body", http.StatusBadRequest) return } // Get current user user, err := h.DB.GetUserByID(ctx.UserID) if err != nil { - http.Error(w, "User not found", http.StatusNotFound) + respondError(w, "User not found", http.StatusNotFound) return } // Verify password if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil { - http.Error(w, "Password is incorrect", http.StatusUnauthorized) + respondError(w, "Password is incorrect", http.StatusUnauthorized) return } @@ -180,11 +180,11 @@ func (h *Handler) DeleteAccount() http.HandlerFunc { // Count number of admin users adminCount, err := h.DB.CountAdminUsers() if err != nil { - http.Error(w, "Failed to verify admin status", http.StatusInternalServerError) + respondError(w, "Failed to verify admin status", http.StatusInternalServerError) return } if adminCount <= 1 { - http.Error(w, "Cannot delete the last admin account", http.StatusForbidden) + respondError(w, "Cannot delete the last admin account", http.StatusForbidden) return } } @@ -192,24 +192,24 @@ func (h *Handler) DeleteAccount() http.HandlerFunc { // Get user's workspaces for cleanup workspaces, err := h.DB.GetWorkspacesByUserID(ctx.UserID) if err != nil { - http.Error(w, "Failed to get user workspaces", http.StatusInternalServerError) + respondError(w, "Failed to get user workspaces", http.StatusInternalServerError) return } // Delete workspace directories for _, workspace := range workspaces { if err := h.Storage.DeleteUserWorkspace(ctx.UserID, workspace.ID); err != nil { - http.Error(w, "Failed to delete workspace files", http.StatusInternalServerError) + respondError(w, "Failed to delete workspace files", http.StatusInternalServerError) return } } // Delete user from database (this will cascade delete workspaces and sessions) if err := h.DB.DeleteUser(ctx.UserID); err != nil { - http.Error(w, "Failed to delete account", http.StatusInternalServerError) + respondError(w, "Failed to delete account", http.StatusInternalServerError) return } - respondJSON(w, map[string]string{"message": "Account deleted successfully"}) + w.WriteHeader(http.StatusNoContent) } } diff --git a/server/internal/handlers/user_handlers_integration_test.go b/server/internal/handlers/user_handlers_integration_test.go index 1722273..9c41772 100644 --- a/server/internal/handlers/user_handlers_integration_test.go +++ b/server/internal/handlers/user_handlers_integration_test.go @@ -191,7 +191,7 @@ func TestUserHandlers_Integration(t *testing.T) { } rr := h.makeRequest(t, http.MethodDelete, "/api/v1/profile", deleteReq, userToken, nil) - require.Equal(t, http.StatusOK, rr.Code) + require.Equal(t, http.StatusNoContent, rr.Code) // Verify user is deleted rr = h.makeRequest(t, http.MethodPost, "/api/v1/auth/login", loginReq, "", nil) diff --git a/server/internal/handlers/workspace_handlers.go b/server/internal/handlers/workspace_handlers.go index 4e65bdc..9cfb61f 100644 --- a/server/internal/handlers/workspace_handlers.go +++ b/server/internal/handlers/workspace_handlers.go @@ -9,6 +9,16 @@ import ( "novamd/internal/models" ) +// DeleteWorkspaceResponse contains the name of the next workspace after deleting the current one +type DeleteWorkspaceResponse struct { + NextWorkspaceName string `json:"nextWorkspaceName"` +} + +// GetLastWorkspaceNameResponse contains the name of the last opened workspace +type GetLastWorkspaceNameResponse struct { + LastWorkspaceName string `json:"lastWorkspaceName"` +} + // ListWorkspaces godoc // @Summary List workspaces // @Description Lists all workspaces for the current user @@ -17,7 +27,7 @@ import ( // @Security BearerAuth // @Produce json // @Success 200 {array} models.Workspace -// @Failure 500 {string} string "Failed to list workspaces" +// @Failure 500 {object} ErrorResponse "Failed to list workspaces" // @Router /workspaces [get] func (h *Handler) ListWorkspaces() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { @@ -28,7 +38,7 @@ func (h *Handler) ListWorkspaces() http.HandlerFunc { workspaces, err := h.DB.GetWorkspacesByUserID(ctx.UserID) if err != nil { - http.Error(w, "Failed to list workspaces", http.StatusInternalServerError) + respondError(w, "Failed to list workspaces", http.StatusInternalServerError) return } @@ -46,11 +56,11 @@ func (h *Handler) ListWorkspaces() http.HandlerFunc { // @Produce json // @Param body body models.Workspace true "Workspace" // @Success 200 {object} models.Workspace -// @Failure 400 {string} string "Invalid request body" -// @Failure 400 {string} string "Invalid workspace" -// @Failure 500 {string} string "Failed to create workspace" -// @Failure 500 {string} string "Failed to initialize workspace directory" -// @Failure 500 {string} string "Failed to setup git repo" +// @Failure 400 {object} ErrorResponse "Invalid request body" +// @Failure 400 {object} ErrorResponse "Invalid workspace" +// @Failure 500 {object} ErrorResponse "Failed to create workspace" +// @Failure 500 {object} ErrorResponse "Failed to initialize workspace directory" +// @Failure 500 {object} ErrorResponse "Failed to setup git repo" // @Router /workspaces [post] func (h *Handler) CreateWorkspace() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { @@ -61,23 +71,23 @@ func (h *Handler) CreateWorkspace() http.HandlerFunc { var workspace models.Workspace if err := json.NewDecoder(r.Body).Decode(&workspace); err != nil { - http.Error(w, "Invalid request body", http.StatusBadRequest) + respondError(w, "Invalid request body", http.StatusBadRequest) return } if err := workspace.ValidateGitSettings(); err != nil { - http.Error(w, "Invalid workspace", http.StatusBadRequest) + respondError(w, "Invalid workspace", http.StatusBadRequest) return } workspace.UserID = ctx.UserID if err := h.DB.CreateWorkspace(&workspace); err != nil { - http.Error(w, "Failed to create workspace", http.StatusInternalServerError) + respondError(w, "Failed to create workspace", http.StatusInternalServerError) return } if err := h.Storage.InitializeUserWorkspace(workspace.UserID, workspace.ID); err != nil { - http.Error(w, "Failed to initialize workspace directory", http.StatusInternalServerError) + respondError(w, "Failed to initialize workspace directory", http.StatusInternalServerError) return } @@ -91,7 +101,7 @@ func (h *Handler) CreateWorkspace() http.HandlerFunc { workspace.GitCommitName, workspace.GitCommitEmail, ); err != nil { - http.Error(w, "Failed to setup git repo: "+err.Error(), http.StatusInternalServerError) + respondError(w, "Failed to setup git repo: "+err.Error(), http.StatusInternalServerError) return } } @@ -109,7 +119,7 @@ func (h *Handler) CreateWorkspace() http.HandlerFunc { // @Produce json // @Param workspace_name path string true "Workspace name" // @Success 200 {object} models.Workspace -// @Failure 500 {string} string "Failed to get workspace" +// @Failure 500 {object} ErrorResponse "Internal server error" // @Router /workspaces/{workspace_name} [get] func (h *Handler) GetWorkspace() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { @@ -151,9 +161,9 @@ func gitSettingsChanged(new, old *models.Workspace) bool { // @Param workspace_name path string true "Workspace name" // @Param body body models.Workspace true "Workspace" // @Success 200 {object} models.Workspace -// @Failure 400 {string} string "Invalid request body" -// @Failure 500 {string} string "Failed to update workspace" -// @Failure 500 {string} string "Failed to setup git repo" +// @Failure 400 {object} ErrorResponse "Invalid request body" +// @Failure 500 {object} ErrorResponse "Failed to update workspace" +// @Failure 500 {object} ErrorResponse "Failed to setup git repo" // @Router /workspaces/{workspace_name} [put] func (h *Handler) UpdateWorkspace() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { @@ -164,7 +174,7 @@ func (h *Handler) UpdateWorkspace() http.HandlerFunc { var workspace models.Workspace if err := json.NewDecoder(r.Body).Decode(&workspace); err != nil { - http.Error(w, "Invalid request body", http.StatusBadRequest) + respondError(w, "Invalid request body", http.StatusBadRequest) return } @@ -174,7 +184,7 @@ func (h *Handler) UpdateWorkspace() http.HandlerFunc { // Validate the workspace if err := workspace.Validate(); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) + respondError(w, err.Error(), http.StatusBadRequest) return } @@ -190,7 +200,7 @@ func (h *Handler) UpdateWorkspace() http.HandlerFunc { workspace.GitCommitName, workspace.GitCommitEmail, ); err != nil { - http.Error(w, "Failed to setup git repo: "+err.Error(), http.StatusInternalServerError) + respondError(w, "Failed to setup git repo: "+err.Error(), http.StatusInternalServerError) return } @@ -200,7 +210,7 @@ func (h *Handler) UpdateWorkspace() http.HandlerFunc { } if err := h.DB.UpdateWorkspace(&workspace); err != nil { - http.Error(w, "Failed to update workspace", http.StatusInternalServerError) + respondError(w, "Failed to update workspace", http.StatusInternalServerError) return } @@ -216,14 +226,14 @@ func (h *Handler) UpdateWorkspace() http.HandlerFunc { // @Security BearerAuth // @Produce json // @Param workspace_name path string true "Workspace name" -// @Success 200 {object} map[string]string -// @Failure 400 {string} string "Cannot delete the last workspace" -// @Failure 500 {string} string "Failed to get workspaces" -// @Failure 500 {string} string "Failed to start transaction" -// @Failure 500 {string} string "Failed to update last workspace" -// @Failure 500 {string} string "Failed to delete workspace" -// @Failure 500 {string} string "Failed to rollback transaction" -// @Failure 500 {string} string "Failed to commit transaction" +// @Success 200 {object} DeleteWorkspaceResponse +// @Failure 400 {object} ErrorResponse "Cannot delete the last workspace" +// @Failure 500 {object} ErrorResponse "Failed to get workspaces" +// @Failure 500 {object} ErrorResponse "Failed to start transaction" +// @Failure 500 {object} ErrorResponse "Failed to update last workspace" +// @Failure 500 {object} ErrorResponse "Failed to delete workspace" +// @Failure 500 {object} ErrorResponse "Failed to rollback transaction" +// @Failure 500 {object} ErrorResponse "Failed to commit transaction" // @Router /workspaces/{workspace_name} [delete] func (h *Handler) DeleteWorkspace() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { @@ -235,12 +245,12 @@ func (h *Handler) DeleteWorkspace() http.HandlerFunc { // Check if this is the user's last workspace workspaces, err := h.DB.GetWorkspacesByUserID(ctx.UserID) if err != nil { - http.Error(w, "Failed to get workspaces", http.StatusInternalServerError) + respondError(w, "Failed to get workspaces", http.StatusInternalServerError) return } if len(workspaces) <= 1 { - http.Error(w, "Cannot delete the last workspace", http.StatusBadRequest) + respondError(w, "Cannot delete the last workspace", http.StatusBadRequest) return } @@ -258,37 +268,37 @@ func (h *Handler) DeleteWorkspace() http.HandlerFunc { // Start transaction tx, err := h.DB.Begin() if err != nil { - http.Error(w, "Failed to start transaction", http.StatusInternalServerError) + respondError(w, "Failed to start transaction", http.StatusInternalServerError) return } defer func() { if err := tx.Rollback(); err != nil && err != sql.ErrTxDone { - http.Error(w, "Failed to rollback transaction", http.StatusInternalServerError) + respondError(w, "Failed to rollback transaction", http.StatusInternalServerError) } }() // Update last workspace ID first err = h.DB.UpdateLastWorkspaceTx(tx, ctx.UserID, nextWorkspaceID) if err != nil { - http.Error(w, "Failed to update last workspace", http.StatusInternalServerError) + respondError(w, "Failed to update last workspace", http.StatusInternalServerError) return } // Delete the workspace err = h.DB.DeleteWorkspaceTx(tx, ctx.Workspace.ID) if err != nil { - http.Error(w, "Failed to delete workspace", http.StatusInternalServerError) + respondError(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) + respondError(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]string{"nextWorkspaceName": nextWorkspaceName}) + respondJSON(w, &DeleteWorkspaceResponse{NextWorkspaceName: nextWorkspaceName}) } } @@ -299,8 +309,8 @@ func (h *Handler) DeleteWorkspace() http.HandlerFunc { // @ID getLastWorkspaceName // @Security BearerAuth // @Produce json -// @Success 200 {object} map[string]string -// @Failure 500 {string} string "Failed to get last workspace" +// @Success 200 {object} LastWorkspaceNameResponse +// @Failure 500 {object} ErrorResponse "Failed to get last workspace" // @Router /workspaces/last [get] func (h *Handler) GetLastWorkspaceName() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { @@ -311,11 +321,11 @@ func (h *Handler) GetLastWorkspaceName() http.HandlerFunc { workspaceName, err := h.DB.GetLastWorkspaceName(ctx.UserID) if err != nil { - http.Error(w, "Failed to get last workspace", http.StatusInternalServerError) + respondError(w, "Failed to get last workspace", http.StatusInternalServerError) return } - respondJSON(w, map[string]string{"lastWorkspaceName": workspaceName}) + respondJSON(w, &GetLastWorkspaceNameResponse{LastWorkspaceName: workspaceName}) } } @@ -327,9 +337,9 @@ func (h *Handler) GetLastWorkspaceName() http.HandlerFunc { // @Security BearerAuth // @Accept json // @Produce json -// @Success 200 {object} map[string]string -// @Failure 400 {string} string "Invalid request body" -// @Failure 500 {string} string "Failed to update last workspace" +// @Success 204 "No Content - Last workspace updated successfully" +// @Failure 400 {object} ErrorResponse "Invalid request body" +// @Failure 500 {object} ErrorResponse "Failed to update last workspace" // @Router /workspaces/last [put] func (h *Handler) UpdateLastWorkspaceName() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { @@ -343,15 +353,15 @@ func (h *Handler) UpdateLastWorkspaceName() http.HandlerFunc { } if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil { - http.Error(w, "Invalid request body", http.StatusBadRequest) + respondError(w, "Invalid request body", http.StatusBadRequest) return } if err := h.DB.UpdateLastWorkspace(ctx.UserID, requestBody.WorkspaceName); err != nil { - http.Error(w, "Failed to update last workspace", http.StatusInternalServerError) + respondError(w, "Failed to update last workspace", http.StatusInternalServerError) return } - respondJSON(w, map[string]string{"message": "Last workspace updated successfully"}) + w.WriteHeader(http.StatusNoContent) } } diff --git a/server/internal/handlers/workspace_handlers_integration_test.go b/server/internal/handlers/workspace_handlers_integration_test.go index 0d44b73..e07efc0 100644 --- a/server/internal/handlers/workspace_handlers_integration_test.go +++ b/server/internal/handlers/workspace_handlers_integration_test.go @@ -226,7 +226,7 @@ func TestWorkspaceHandlers_Integration(t *testing.T) { } rr := h.makeRequest(t, http.MethodPut, "/api/v1/workspaces/last", req, h.RegularToken, nil) - require.Equal(t, http.StatusOK, rr.Code) + require.Equal(t, http.StatusNoContent, rr.Code) // Verify the update rr = h.makeRequest(t, http.MethodGet, "/api/v1/workspaces/last", nil, h.RegularToken, nil) diff --git a/server/internal/storage/git.go b/server/internal/storage/git.go index 13a32d0..a4d5d78 100644 --- a/server/internal/storage/git.go +++ b/server/internal/storage/git.go @@ -9,7 +9,7 @@ import ( type RepositoryManager interface { SetupGitRepo(userID, workspaceID int, gitURL, gitUser, gitToken, commitName, commitEmail string) error DisableGitRepo(userID, workspaceID int) - StageCommitAndPush(userID, workspaceID int, message string) error + StageCommitAndPush(userID, workspaceID int, message string) (git.CommitHash, error) Pull(userID, workspaceID int) error } @@ -36,17 +36,19 @@ func (s *Service) DisableGitRepo(userID, workspaceID int) { // StageCommitAndPush stages, commit with the message, and pushes the changes to the Git repository. // The git repository belongs to the given userID and is associated with the given workspaceID. -func (s *Service) StageCommitAndPush(userID, workspaceID int, message string) error { +func (s *Service) StageCommitAndPush(userID, workspaceID int, message string) (git.CommitHash, error) { repo, ok := s.getGitRepo(userID, workspaceID) if !ok { - return fmt.Errorf("git settings not configured for this workspace") + return git.CommitHash{}, fmt.Errorf("git settings not configured for this workspace") } - if err := repo.Commit(message); err != nil { - return err + hash, err := repo.Commit(message) + if err != nil { + return git.CommitHash{}, err } - return repo.Push() + err = repo.Push() + return hash, err } // Pull pulls the changes from the remote Git repository. diff --git a/server/internal/storage/git_test.go b/server/internal/storage/git_test.go index a849ff5..f852616 100644 --- a/server/internal/storage/git_test.go +++ b/server/internal/storage/git_test.go @@ -29,10 +29,10 @@ func (m *MockGitClient) Pull() error { return m.ReturnError } -func (m *MockGitClient) Commit(message string) error { +func (m *MockGitClient) Commit(message string) (git.CommitHash, error) { m.CommitCalled = true m.CommitMessage = message - return m.ReturnError + return git.CommitHash{}, m.ReturnError } func (m *MockGitClient) Push() error { @@ -138,7 +138,7 @@ func TestGitOperations(t *testing.T) { }) t.Run("operations on non-configured workspace", func(t *testing.T) { - err := s.StageCommitAndPush(1, 1, "test commit") + _, err := s.StageCommitAndPush(1, 1, "test commit") if err == nil { t.Error("expected error for non-configured workspace, got nil") } @@ -157,7 +157,7 @@ func TestGitOperations(t *testing.T) { s.GitRepos[1][1] = mockClient // Test commit and push - err := s.StageCommitAndPush(1, 1, "test commit") + _, err := s.StageCommitAndPush(1, 1, "test commit") if err != nil { t.Errorf("unexpected error: %v", err) } @@ -189,7 +189,7 @@ func TestGitOperations(t *testing.T) { s.GitRepos[1][1] = mockClient // Test commit error - err := s.StageCommitAndPush(1, 1, "test commit") + _, err := s.StageCommitAndPush(1, 1, "test commit") if err == nil { t.Error("expected error for commit, got nil") } From dc8fc6c22554e598217729db32babdb8616a882c Mon Sep 17 00:00:00 2001 From: LordMathis Date: Tue, 3 Dec 2024 22:06:57 +0100 Subject: [PATCH 4/5] Working swagger api docs --- server/docs/docs.go | 1829 +++++++++++++++++ server/docs/swagger.json | 1804 ++++++++++++++++ server/docs/swagger.yaml | 1168 +++++++++++ server/go.mod | 11 + server/go.sum | 34 + server/internal/app/routes.go | 10 + server/internal/handlers/file_handlers.go | 9 +- .../internal/handlers/workspace_handlers.go | 6 +- 8 files changed, 4863 insertions(+), 8 deletions(-) create mode 100644 server/docs/docs.go create mode 100644 server/docs/swagger.json create mode 100644 server/docs/swagger.yaml diff --git a/server/docs/docs.go b/server/docs/docs.go new file mode 100644 index 0000000..47dc923 --- /dev/null +++ b/server/docs/docs.go @@ -0,0 +1,1829 @@ +// Package docs Code generated by swaggo/swag. DO NOT EDIT +package docs + +import "github.com/swaggo/swag" + +const docTemplate = `{ + "schemes": {{ marshal .Schemes }}, + "swagger": "2.0", + "info": { + "description": "{{escape .Description}}", + "title": "{{.Title}}", + "contact": {}, + "license": { + "name": "Apache 2.0", + "url": "http://www.apache.org/licenses/LICENSE-2.0.html" + }, + "version": "{{.Version}}" + }, + "host": "{{.Host}}", + "basePath": "{{.BasePath}}", + "paths": { + "/admin/stats": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Get system-wide statistics as an admin", + "produces": [ + "application/json" + ], + "tags": [ + "Admin" + ], + "summary": "Get system statistics", + "operationId": "adminGetSystemStats", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.SystemStats" + } + }, + "500": { + "description": "Failed to get file stats", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/admin/users": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Returns the list of all users", + "produces": [ + "application/json" + ], + "tags": [ + "Admin" + ], + "summary": "List all users", + "operationId": "adminListUsers", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/models.User" + } + } + }, + "500": { + "description": "Failed to list users", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Create a new user as an admin", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Admin" + ], + "summary": "Create a new user", + "operationId": "adminCreateUser", + "parameters": [ + { + "description": "User details", + "name": "user", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.CreateUserRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.User" + } + }, + "400": { + "description": "Password must be at least 8 characters", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "409": { + "description": "Email already exists", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Failed to initialize user workspace", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/admin/users/{userId}": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Get a specific user as an admin", + "produces": [ + "application/json" + ], + "tags": [ + "Admin" + ], + "summary": "Get a specific user", + "operationId": "adminGetUser", + "parameters": [ + { + "type": "integer", + "description": "User ID", + "name": "userId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.User" + } + }, + "400": { + "description": "Invalid user ID", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "User not found", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + }, + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Update a specific user as an admin", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Admin" + ], + "summary": "Update a specific user", + "operationId": "adminUpdateUser", + "parameters": [ + { + "type": "integer", + "description": "User ID", + "name": "userId", + "in": "path", + "required": true + }, + { + "description": "User details", + "name": "user", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.UpdateUserRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.User" + } + }, + "400": { + "description": "Invalid request body", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "User not found", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Failed to update user", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Delete a specific user as an admin", + "tags": [ + "Admin" + ], + "summary": "Delete a specific user", + "operationId": "adminDeleteUser", + "parameters": [ + { + "type": "integer", + "description": "User ID", + "name": "userId", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Cannot delete your own account", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "Cannot delete other admin users", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "User not found", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Failed to delete user", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/admin/workspaces": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "List all workspaces and their stats as an admin", + "produces": [ + "application/json" + ], + "tags": [ + "Admin" + ], + "summary": "List all workspaces", + "operationId": "adminListWorkspaces", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/handlers.WorkspaceStats" + } + } + }, + "500": { + "description": "Failed to get file stats", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/auth/login": { + "post": { + "description": "Logs in a user", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Login", + "operationId": "login", + "parameters": [ + { + "description": "Login request", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.LoginRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.LoginResponse" + } + }, + "400": { + "description": "Email and password are required", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "Invalid credentials", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Failed to create session", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/auth/logout": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Log out invalidates the user's session", + "tags": [ + "auth" + ], + "summary": "Logout", + "operationId": "logout", + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Session ID required", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Failed to logout", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/auth/me": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Returns the current authenticated user", + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Get current user", + "operationId": "getCurrentUser", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.User" + } + }, + "404": { + "description": "User not found", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/auth/refresh": { + "post": { + "description": "Refreshes the access token using the refresh token", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Refresh token", + "operationId": "refreshToken", + "parameters": [ + { + "description": "Refresh request", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.RefreshRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.RefreshResponse" + } + }, + "400": { + "description": "Refresh token required", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "Invalid refresh token", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/profile": { + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Updates the user's profile", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "users" + ], + "summary": "Update profile", + "operationId": "updateProfile", + "parameters": [ + { + "description": "Profile update request", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.UpdateProfileRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.User" + } + }, + "400": { + "description": "Current password is required to change email", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "Current password is incorrect", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "User not found", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "409": { + "description": "Email already in use", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Failed to update profile", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Deletes the user's account and all associated data", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "users" + ], + "summary": "Delete account", + "operationId": "deleteAccount", + "parameters": [ + { + "description": "Account deletion request", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.DeleteAccountRequest" + } + } + ], + "responses": { + "204": { + "description": "No Content - Account deleted successfully" + }, + "400": { + "description": "Invalid request body", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "Password is incorrect", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "Cannot delete the last admin account", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "User not found", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Failed to delete account", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/workspaces": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Lists all workspaces for the current user", + "produces": [ + "application/json" + ], + "tags": [ + "workspaces" + ], + "summary": "List workspaces", + "operationId": "listWorkspaces", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/models.Workspace" + } + } + }, + "500": { + "description": "Failed to list workspaces", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Creates a new workspace", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "workspaces" + ], + "summary": "Create workspace", + "operationId": "createWorkspace", + "parameters": [ + { + "description": "Workspace", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.Workspace" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.Workspace" + } + }, + "400": { + "description": "Invalid workspace", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Failed to setup git repo", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/workspaces/last": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Returns the name of the last opened workspace", + "produces": [ + "application/json" + ], + "tags": [ + "workspaces" + ], + "summary": "Get last workspace name", + "operationId": "getLastWorkspaceName", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.LastWorkspaceNameResponse" + } + }, + "500": { + "description": "Failed to get last workspace", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + }, + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Updates the name of the last opened workspace", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "workspaces" + ], + "summary": "Update last workspace name", + "operationId": "updateLastWorkspaceName", + "responses": { + "204": { + "description": "No Content - Last workspace updated successfully" + }, + "400": { + "description": "Invalid request body", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Failed to update last workspace", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/workspaces/{workspace_name}": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Returns the current workspace", + "produces": [ + "application/json" + ], + "tags": [ + "workspaces" + ], + "summary": "Get workspace", + "operationId": "getWorkspace", + "parameters": [ + { + "type": "string", + "description": "Workspace name", + "name": "workspace_name", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.Workspace" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + }, + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Updates the current workspace", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "workspaces" + ], + "summary": "Update workspace", + "operationId": "updateWorkspace", + "parameters": [ + { + "type": "string", + "description": "Workspace name", + "name": "workspace_name", + "in": "path", + "required": true + }, + { + "description": "Workspace", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.Workspace" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.Workspace" + } + }, + "400": { + "description": "Invalid request body", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Failed to setup git repo", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Deletes the current workspace", + "produces": [ + "application/json" + ], + "tags": [ + "workspaces" + ], + "summary": "Delete workspace", + "operationId": "deleteWorkspace", + "parameters": [ + { + "type": "string", + "description": "Workspace name", + "name": "workspace_name", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.DeleteWorkspaceResponse" + } + }, + "400": { + "description": "Cannot delete the last workspace", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Failed to commit transaction", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/workspaces/{workspace_name}/files": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Lists all files in the user's workspace", + "produces": [ + "application/json" + ], + "tags": [ + "files" + ], + "summary": "List files", + "operationId": "listFiles", + "parameters": [ + { + "type": "string", + "description": "Workspace name", + "name": "workspace_name", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/storage.FileNode" + } + } + }, + "500": { + "description": "Failed to list files", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/workspaces/{workspace_name}/files/last": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Returns the path of the last opened file in the user's workspace", + "produces": [ + "application/json" + ], + "tags": [ + "files" + ], + "summary": "Get last opened file", + "operationId": "getLastOpenedFile", + "parameters": [ + { + "type": "string", + "description": "Workspace name", + "name": "workspace_name", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.LastOpenedFileResponse" + } + }, + "400": { + "description": "Invalid file path", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Failed to get last opened file", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + }, + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Updates the last opened file in the user's workspace", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "files" + ], + "summary": "Update last opened file", + "operationId": "updateLastOpenedFile", + "parameters": [ + { + "type": "string", + "description": "Workspace name", + "name": "workspace_name", + "in": "path", + "required": true + }, + { + "description": "Update last opened file request", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.UpdateLastOpenedFileRequest" + } + } + ], + "responses": { + "204": { + "description": "No Content - Last opened file updated successfully" + }, + "400": { + "description": "Invalid file path", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "File not found", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Failed to update file", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/workspaces/{workspace_name}/files/lookup": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Returns the paths of files with the given name in the user's workspace", + "produces": [ + "application/json" + ], + "tags": [ + "files" + ], + "summary": "Lookup file by name", + "operationId": "lookupFileByName", + "parameters": [ + { + "type": "string", + "description": "Workspace name", + "name": "workspace_name", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "File name", + "name": "filename", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.LookupResponse" + } + }, + "400": { + "description": "Filename is required", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "File not found", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/workspaces/{workspace_name}/files/{file_path}": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Returns the content of a file in the user's workspace", + "produces": [ + "text/plain" + ], + "tags": [ + "files" + ], + "summary": "Get file content", + "operationId": "getFileContent", + "parameters": [ + { + "type": "string", + "description": "Workspace name", + "name": "workspace_name", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "File path", + "name": "file_path", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "Raw file content", + "schema": { + "type": "string" + } + }, + "400": { + "description": "Invalid file path", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "File not found", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Failed to write response", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Saves the content of a file in the user's workspace", + "consumes": [ + "text/plain" + ], + "produces": [ + "application/json" + ], + "tags": [ + "files" + ], + "summary": "Save file", + "operationId": "saveFile", + "parameters": [ + { + "type": "string", + "description": "Workspace name", + "name": "workspace_name", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "File path", + "name": "file_path", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.SaveFileResponse" + } + }, + "400": { + "description": "Invalid file path", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Failed to save file", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Deletes a file in the user's workspace", + "tags": [ + "files" + ], + "summary": "Delete file", + "operationId": "deleteFile", + "parameters": [ + { + "type": "string", + "description": "Workspace name", + "name": "workspace_name", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "File path", + "name": "file_path", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content - File deleted successfully" + }, + "400": { + "description": "Invalid file path", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "File not found", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Failed to write response", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/workspaces/{workspace_name}/git/commit": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Stages, commits, and pushes changes to the remote repository", + "produces": [ + "application/json" + ], + "tags": [ + "git" + ], + "summary": "Stage, commit, and push changes", + "operationId": "stageCommitAndPush", + "parameters": [ + { + "type": "string", + "description": "Workspace name", + "name": "workspace_name", + "in": "path", + "required": true + }, + { + "description": "Commit request", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.CommitRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.CommitResponse" + } + }, + "400": { + "description": "Commit message is required", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Failed to stage, commit, and push changes", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/workspaces/{workspace_name}/git/pull": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Pulls changes from the remote repository", + "produces": [ + "application/json" + ], + "tags": [ + "git" + ], + "summary": "Pull changes from remote", + "operationId": "pullChanges", + "parameters": [ + { + "type": "string", + "description": "Workspace name", + "name": "workspace_name", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.PullResponse" + } + }, + "500": { + "description": "Failed to pull changes", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + } + }, + "definitions": { + "handlers.CommitRequest": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "Initial commit" + } + } + }, + "handlers.CommitResponse": { + "type": "object", + "properties": { + "commitHash": { + "type": "string", + "example": "a1b2c3d4" + } + } + }, + "handlers.CreateUserRequest": { + "type": "object", + "properties": { + "displayName": { + "type": "string" + }, + "email": { + "type": "string" + }, + "password": { + "type": "string" + }, + "role": { + "$ref": "#/definitions/models.UserRole" + } + } + }, + "handlers.DeleteAccountRequest": { + "type": "object", + "properties": { + "password": { + "type": "string" + } + } + }, + "handlers.DeleteWorkspaceResponse": { + "type": "object", + "properties": { + "nextWorkspaceName": { + "type": "string" + } + } + }, + "handlers.ErrorResponse": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + } + }, + "handlers.LastOpenedFileResponse": { + "type": "object", + "properties": { + "lastOpenedFilePath": { + "type": "string" + } + } + }, + "handlers.LastWorkspaceNameResponse": { + "type": "object", + "properties": { + "lastWorkspaceName": { + "type": "string" + } + } + }, + "handlers.LoginRequest": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "password": { + "type": "string" + } + } + }, + "handlers.LoginResponse": { + "type": "object", + "properties": { + "accessToken": { + "type": "string" + }, + "refreshToken": { + "type": "string" + }, + "session": { + "$ref": "#/definitions/models.Session" + }, + "user": { + "$ref": "#/definitions/models.User" + } + } + }, + "handlers.LookupResponse": { + "type": "object", + "properties": { + "paths": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "handlers.PullResponse": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "Pulled changes from remote" + } + } + }, + "handlers.RefreshRequest": { + "type": "object", + "properties": { + "refreshToken": { + "type": "string" + } + } + }, + "handlers.RefreshResponse": { + "type": "object", + "properties": { + "accessToken": { + "type": "string" + } + } + }, + "handlers.SaveFileResponse": { + "type": "object", + "properties": { + "filePath": { + "type": "string" + }, + "size": { + "type": "integer" + }, + "updatedAt": { + "type": "string" + } + } + }, + "handlers.SystemStats": { + "type": "object", + "properties": { + "activeUsers": { + "description": "Users with activity in last 30 days", + "type": "integer" + }, + "totalFiles": { + "type": "integer" + }, + "totalSize": { + "type": "integer" + }, + "totalUsers": { + "type": "integer" + }, + "totalWorkspaces": { + "type": "integer" + } + } + }, + "handlers.UpdateLastOpenedFileRequest": { + "type": "object", + "properties": { + "filePath": { + "type": "string" + } + } + }, + "handlers.UpdateProfileRequest": { + "type": "object", + "properties": { + "currentPassword": { + "type": "string" + }, + "displayName": { + "type": "string" + }, + "email": { + "type": "string" + }, + "newPassword": { + "type": "string" + } + } + }, + "handlers.UpdateUserRequest": { + "type": "object", + "properties": { + "displayName": { + "type": "string" + }, + "email": { + "type": "string" + }, + "password": { + "type": "string" + }, + "role": { + "$ref": "#/definitions/models.UserRole" + } + } + }, + "handlers.WorkspaceStats": { + "type": "object", + "properties": { + "totalFiles": { + "type": "integer" + }, + "totalSize": { + "type": "integer" + }, + "userEmail": { + "type": "string" + }, + "userID": { + "type": "integer" + }, + "workspaceCreatedAt": { + "type": "string" + }, + "workspaceID": { + "type": "integer" + }, + "workspaceName": { + "type": "string" + } + } + }, + "models.Session": { + "type": "object", + "properties": { + "createdAt": { + "description": "When this session was created", + "type": "string" + }, + "expiresAt": { + "description": "When this session expires", + "type": "string" + }, + "id": { + "description": "Unique session identifier", + "type": "string" + }, + "refreshToken": { + "description": "The refresh token associated with this session", + "type": "string" + }, + "userID": { + "description": "ID of the user this session belongs to", + "type": "integer" + } + } + }, + "models.User": { + "type": "object", + "required": [ + "email", + "id", + "role" + ], + "properties": { + "createdAt": { + "type": "string" + }, + "displayName": { + "type": "string" + }, + "email": { + "type": "string" + }, + "id": { + "type": "integer", + "minimum": 1 + }, + "lastWorkspaceId": { + "type": "integer" + }, + "role": { + "enum": [ + "admin", + "editor", + "viewer" + ], + "allOf": [ + { + "$ref": "#/definitions/models.UserRole" + } + ] + } + } + }, + "models.UserRole": { + "type": "string", + "enum": [ + "admin", + "editor", + "viewer" + ], + "x-enum-varnames": [ + "RoleAdmin", + "RoleEditor", + "RoleViewer" + ] + }, + "models.Workspace": { + "type": "object", + "required": [ + "id", + "name", + "userId" + ], + "properties": { + "autoSave": { + "type": "boolean" + }, + "createdAt": { + "type": "string" + }, + "gitAutoCommit": { + "type": "boolean" + }, + "gitCommitEmail": { + "type": "string" + }, + "gitCommitMsgTemplate": { + "type": "string" + }, + "gitCommitName": { + "type": "string" + }, + "gitEnabled": { + "type": "boolean" + }, + "gitToken": { + "type": "string" + }, + "gitUrl": { + "type": "string" + }, + "gitUser": { + "type": "string" + }, + "id": { + "type": "integer", + "minimum": 1 + }, + "lastOpenedFilePath": { + "type": "string" + }, + "name": { + "type": "string" + }, + "showHiddenFiles": { + "type": "boolean" + }, + "theme": { + "description": "Integrated settings", + "type": "string", + "enum": [ + "light", + "dark" + ] + }, + "userId": { + "type": "integer", + "minimum": 1 + } + } + }, + "storage.FileNode": { + "type": "object", + "properties": { + "children": { + "type": "array", + "items": { + "$ref": "#/definitions/storage.FileNode" + } + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "path": { + "type": "string" + } + } + } + } +}` + +// SwaggerInfo holds exported Swagger Info so clients can modify it +var SwaggerInfo = &swag.Spec{ + Version: "1.0", + Host: "", + BasePath: "/api/v1", + Schemes: []string{}, + Title: "NovaMD API", + Description: "This is the API for NovaMD markdown note taking app.", + InfoInstanceName: "swagger", + SwaggerTemplate: docTemplate, + LeftDelim: "{{", + RightDelim: "}}", +} + +func init() { + swag.Register(SwaggerInfo.InstanceName(), SwaggerInfo) +} diff --git a/server/docs/swagger.json b/server/docs/swagger.json new file mode 100644 index 0000000..52ca878 --- /dev/null +++ b/server/docs/swagger.json @@ -0,0 +1,1804 @@ +{ + "swagger": "2.0", + "info": { + "description": "This is the API for NovaMD markdown note taking app.", + "title": "NovaMD API", + "contact": {}, + "license": { + "name": "Apache 2.0", + "url": "http://www.apache.org/licenses/LICENSE-2.0.html" + }, + "version": "1.0" + }, + "basePath": "/api/v1", + "paths": { + "/admin/stats": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Get system-wide statistics as an admin", + "produces": [ + "application/json" + ], + "tags": [ + "Admin" + ], + "summary": "Get system statistics", + "operationId": "adminGetSystemStats", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.SystemStats" + } + }, + "500": { + "description": "Failed to get file stats", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/admin/users": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Returns the list of all users", + "produces": [ + "application/json" + ], + "tags": [ + "Admin" + ], + "summary": "List all users", + "operationId": "adminListUsers", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/models.User" + } + } + }, + "500": { + "description": "Failed to list users", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Create a new user as an admin", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Admin" + ], + "summary": "Create a new user", + "operationId": "adminCreateUser", + "parameters": [ + { + "description": "User details", + "name": "user", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.CreateUserRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.User" + } + }, + "400": { + "description": "Password must be at least 8 characters", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "409": { + "description": "Email already exists", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Failed to initialize user workspace", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/admin/users/{userId}": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Get a specific user as an admin", + "produces": [ + "application/json" + ], + "tags": [ + "Admin" + ], + "summary": "Get a specific user", + "operationId": "adminGetUser", + "parameters": [ + { + "type": "integer", + "description": "User ID", + "name": "userId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.User" + } + }, + "400": { + "description": "Invalid user ID", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "User not found", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + }, + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Update a specific user as an admin", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Admin" + ], + "summary": "Update a specific user", + "operationId": "adminUpdateUser", + "parameters": [ + { + "type": "integer", + "description": "User ID", + "name": "userId", + "in": "path", + "required": true + }, + { + "description": "User details", + "name": "user", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.UpdateUserRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.User" + } + }, + "400": { + "description": "Invalid request body", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "User not found", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Failed to update user", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Delete a specific user as an admin", + "tags": [ + "Admin" + ], + "summary": "Delete a specific user", + "operationId": "adminDeleteUser", + "parameters": [ + { + "type": "integer", + "description": "User ID", + "name": "userId", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Cannot delete your own account", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "Cannot delete other admin users", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "User not found", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Failed to delete user", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/admin/workspaces": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "List all workspaces and their stats as an admin", + "produces": [ + "application/json" + ], + "tags": [ + "Admin" + ], + "summary": "List all workspaces", + "operationId": "adminListWorkspaces", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/handlers.WorkspaceStats" + } + } + }, + "500": { + "description": "Failed to get file stats", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/auth/login": { + "post": { + "description": "Logs in a user", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Login", + "operationId": "login", + "parameters": [ + { + "description": "Login request", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.LoginRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.LoginResponse" + } + }, + "400": { + "description": "Email and password are required", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "Invalid credentials", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Failed to create session", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/auth/logout": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Log out invalidates the user's session", + "tags": [ + "auth" + ], + "summary": "Logout", + "operationId": "logout", + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Session ID required", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Failed to logout", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/auth/me": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Returns the current authenticated user", + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Get current user", + "operationId": "getCurrentUser", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.User" + } + }, + "404": { + "description": "User not found", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/auth/refresh": { + "post": { + "description": "Refreshes the access token using the refresh token", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Refresh token", + "operationId": "refreshToken", + "parameters": [ + { + "description": "Refresh request", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.RefreshRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.RefreshResponse" + } + }, + "400": { + "description": "Refresh token required", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "Invalid refresh token", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/profile": { + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Updates the user's profile", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "users" + ], + "summary": "Update profile", + "operationId": "updateProfile", + "parameters": [ + { + "description": "Profile update request", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.UpdateProfileRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.User" + } + }, + "400": { + "description": "Current password is required to change email", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "Current password is incorrect", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "User not found", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "409": { + "description": "Email already in use", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Failed to update profile", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Deletes the user's account and all associated data", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "users" + ], + "summary": "Delete account", + "operationId": "deleteAccount", + "parameters": [ + { + "description": "Account deletion request", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.DeleteAccountRequest" + } + } + ], + "responses": { + "204": { + "description": "No Content - Account deleted successfully" + }, + "400": { + "description": "Invalid request body", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "Password is incorrect", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "403": { + "description": "Cannot delete the last admin account", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "User not found", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Failed to delete account", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/workspaces": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Lists all workspaces for the current user", + "produces": [ + "application/json" + ], + "tags": [ + "workspaces" + ], + "summary": "List workspaces", + "operationId": "listWorkspaces", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/models.Workspace" + } + } + }, + "500": { + "description": "Failed to list workspaces", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Creates a new workspace", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "workspaces" + ], + "summary": "Create workspace", + "operationId": "createWorkspace", + "parameters": [ + { + "description": "Workspace", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.Workspace" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.Workspace" + } + }, + "400": { + "description": "Invalid workspace", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Failed to setup git repo", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/workspaces/last": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Returns the name of the last opened workspace", + "produces": [ + "application/json" + ], + "tags": [ + "workspaces" + ], + "summary": "Get last workspace name", + "operationId": "getLastWorkspaceName", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.LastWorkspaceNameResponse" + } + }, + "500": { + "description": "Failed to get last workspace", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + }, + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Updates the name of the last opened workspace", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "workspaces" + ], + "summary": "Update last workspace name", + "operationId": "updateLastWorkspaceName", + "responses": { + "204": { + "description": "No Content - Last workspace updated successfully" + }, + "400": { + "description": "Invalid request body", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Failed to update last workspace", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/workspaces/{workspace_name}": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Returns the current workspace", + "produces": [ + "application/json" + ], + "tags": [ + "workspaces" + ], + "summary": "Get workspace", + "operationId": "getWorkspace", + "parameters": [ + { + "type": "string", + "description": "Workspace name", + "name": "workspace_name", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.Workspace" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + }, + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Updates the current workspace", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "workspaces" + ], + "summary": "Update workspace", + "operationId": "updateWorkspace", + "parameters": [ + { + "type": "string", + "description": "Workspace name", + "name": "workspace_name", + "in": "path", + "required": true + }, + { + "description": "Workspace", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.Workspace" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.Workspace" + } + }, + "400": { + "description": "Invalid request body", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Failed to setup git repo", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Deletes the current workspace", + "produces": [ + "application/json" + ], + "tags": [ + "workspaces" + ], + "summary": "Delete workspace", + "operationId": "deleteWorkspace", + "parameters": [ + { + "type": "string", + "description": "Workspace name", + "name": "workspace_name", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.DeleteWorkspaceResponse" + } + }, + "400": { + "description": "Cannot delete the last workspace", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Failed to commit transaction", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/workspaces/{workspace_name}/files": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Lists all files in the user's workspace", + "produces": [ + "application/json" + ], + "tags": [ + "files" + ], + "summary": "List files", + "operationId": "listFiles", + "parameters": [ + { + "type": "string", + "description": "Workspace name", + "name": "workspace_name", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/storage.FileNode" + } + } + }, + "500": { + "description": "Failed to list files", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/workspaces/{workspace_name}/files/last": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Returns the path of the last opened file in the user's workspace", + "produces": [ + "application/json" + ], + "tags": [ + "files" + ], + "summary": "Get last opened file", + "operationId": "getLastOpenedFile", + "parameters": [ + { + "type": "string", + "description": "Workspace name", + "name": "workspace_name", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.LastOpenedFileResponse" + } + }, + "400": { + "description": "Invalid file path", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Failed to get last opened file", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + }, + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Updates the last opened file in the user's workspace", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "files" + ], + "summary": "Update last opened file", + "operationId": "updateLastOpenedFile", + "parameters": [ + { + "type": "string", + "description": "Workspace name", + "name": "workspace_name", + "in": "path", + "required": true + }, + { + "description": "Update last opened file request", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.UpdateLastOpenedFileRequest" + } + } + ], + "responses": { + "204": { + "description": "No Content - Last opened file updated successfully" + }, + "400": { + "description": "Invalid file path", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "File not found", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Failed to update file", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/workspaces/{workspace_name}/files/lookup": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Returns the paths of files with the given name in the user's workspace", + "produces": [ + "application/json" + ], + "tags": [ + "files" + ], + "summary": "Lookup file by name", + "operationId": "lookupFileByName", + "parameters": [ + { + "type": "string", + "description": "Workspace name", + "name": "workspace_name", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "File name", + "name": "filename", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.LookupResponse" + } + }, + "400": { + "description": "Filename is required", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "File not found", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/workspaces/{workspace_name}/files/{file_path}": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Returns the content of a file in the user's workspace", + "produces": [ + "text/plain" + ], + "tags": [ + "files" + ], + "summary": "Get file content", + "operationId": "getFileContent", + "parameters": [ + { + "type": "string", + "description": "Workspace name", + "name": "workspace_name", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "File path", + "name": "file_path", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "Raw file content", + "schema": { + "type": "string" + } + }, + "400": { + "description": "Invalid file path", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "File not found", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Failed to write response", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Saves the content of a file in the user's workspace", + "consumes": [ + "text/plain" + ], + "produces": [ + "application/json" + ], + "tags": [ + "files" + ], + "summary": "Save file", + "operationId": "saveFile", + "parameters": [ + { + "type": "string", + "description": "Workspace name", + "name": "workspace_name", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "File path", + "name": "file_path", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.SaveFileResponse" + } + }, + "400": { + "description": "Invalid file path", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Failed to save file", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Deletes a file in the user's workspace", + "tags": [ + "files" + ], + "summary": "Delete file", + "operationId": "deleteFile", + "parameters": [ + { + "type": "string", + "description": "Workspace name", + "name": "workspace_name", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "File path", + "name": "file_path", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content - File deleted successfully" + }, + "400": { + "description": "Invalid file path", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "File not found", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Failed to write response", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/workspaces/{workspace_name}/git/commit": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Stages, commits, and pushes changes to the remote repository", + "produces": [ + "application/json" + ], + "tags": [ + "git" + ], + "summary": "Stage, commit, and push changes", + "operationId": "stageCommitAndPush", + "parameters": [ + { + "type": "string", + "description": "Workspace name", + "name": "workspace_name", + "in": "path", + "required": true + }, + { + "description": "Commit request", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.CommitRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.CommitResponse" + } + }, + "400": { + "description": "Commit message is required", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Failed to stage, commit, and push changes", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/workspaces/{workspace_name}/git/pull": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Pulls changes from the remote repository", + "produces": [ + "application/json" + ], + "tags": [ + "git" + ], + "summary": "Pull changes from remote", + "operationId": "pullChanges", + "parameters": [ + { + "type": "string", + "description": "Workspace name", + "name": "workspace_name", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.PullResponse" + } + }, + "500": { + "description": "Failed to pull changes", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + } + }, + "definitions": { + "handlers.CommitRequest": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "Initial commit" + } + } + }, + "handlers.CommitResponse": { + "type": "object", + "properties": { + "commitHash": { + "type": "string", + "example": "a1b2c3d4" + } + } + }, + "handlers.CreateUserRequest": { + "type": "object", + "properties": { + "displayName": { + "type": "string" + }, + "email": { + "type": "string" + }, + "password": { + "type": "string" + }, + "role": { + "$ref": "#/definitions/models.UserRole" + } + } + }, + "handlers.DeleteAccountRequest": { + "type": "object", + "properties": { + "password": { + "type": "string" + } + } + }, + "handlers.DeleteWorkspaceResponse": { + "type": "object", + "properties": { + "nextWorkspaceName": { + "type": "string" + } + } + }, + "handlers.ErrorResponse": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + } + }, + "handlers.LastOpenedFileResponse": { + "type": "object", + "properties": { + "lastOpenedFilePath": { + "type": "string" + } + } + }, + "handlers.LastWorkspaceNameResponse": { + "type": "object", + "properties": { + "lastWorkspaceName": { + "type": "string" + } + } + }, + "handlers.LoginRequest": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "password": { + "type": "string" + } + } + }, + "handlers.LoginResponse": { + "type": "object", + "properties": { + "accessToken": { + "type": "string" + }, + "refreshToken": { + "type": "string" + }, + "session": { + "$ref": "#/definitions/models.Session" + }, + "user": { + "$ref": "#/definitions/models.User" + } + } + }, + "handlers.LookupResponse": { + "type": "object", + "properties": { + "paths": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "handlers.PullResponse": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "Pulled changes from remote" + } + } + }, + "handlers.RefreshRequest": { + "type": "object", + "properties": { + "refreshToken": { + "type": "string" + } + } + }, + "handlers.RefreshResponse": { + "type": "object", + "properties": { + "accessToken": { + "type": "string" + } + } + }, + "handlers.SaveFileResponse": { + "type": "object", + "properties": { + "filePath": { + "type": "string" + }, + "size": { + "type": "integer" + }, + "updatedAt": { + "type": "string" + } + } + }, + "handlers.SystemStats": { + "type": "object", + "properties": { + "activeUsers": { + "description": "Users with activity in last 30 days", + "type": "integer" + }, + "totalFiles": { + "type": "integer" + }, + "totalSize": { + "type": "integer" + }, + "totalUsers": { + "type": "integer" + }, + "totalWorkspaces": { + "type": "integer" + } + } + }, + "handlers.UpdateLastOpenedFileRequest": { + "type": "object", + "properties": { + "filePath": { + "type": "string" + } + } + }, + "handlers.UpdateProfileRequest": { + "type": "object", + "properties": { + "currentPassword": { + "type": "string" + }, + "displayName": { + "type": "string" + }, + "email": { + "type": "string" + }, + "newPassword": { + "type": "string" + } + } + }, + "handlers.UpdateUserRequest": { + "type": "object", + "properties": { + "displayName": { + "type": "string" + }, + "email": { + "type": "string" + }, + "password": { + "type": "string" + }, + "role": { + "$ref": "#/definitions/models.UserRole" + } + } + }, + "handlers.WorkspaceStats": { + "type": "object", + "properties": { + "totalFiles": { + "type": "integer" + }, + "totalSize": { + "type": "integer" + }, + "userEmail": { + "type": "string" + }, + "userID": { + "type": "integer" + }, + "workspaceCreatedAt": { + "type": "string" + }, + "workspaceID": { + "type": "integer" + }, + "workspaceName": { + "type": "string" + } + } + }, + "models.Session": { + "type": "object", + "properties": { + "createdAt": { + "description": "When this session was created", + "type": "string" + }, + "expiresAt": { + "description": "When this session expires", + "type": "string" + }, + "id": { + "description": "Unique session identifier", + "type": "string" + }, + "refreshToken": { + "description": "The refresh token associated with this session", + "type": "string" + }, + "userID": { + "description": "ID of the user this session belongs to", + "type": "integer" + } + } + }, + "models.User": { + "type": "object", + "required": [ + "email", + "id", + "role" + ], + "properties": { + "createdAt": { + "type": "string" + }, + "displayName": { + "type": "string" + }, + "email": { + "type": "string" + }, + "id": { + "type": "integer", + "minimum": 1 + }, + "lastWorkspaceId": { + "type": "integer" + }, + "role": { + "enum": [ + "admin", + "editor", + "viewer" + ], + "allOf": [ + { + "$ref": "#/definitions/models.UserRole" + } + ] + } + } + }, + "models.UserRole": { + "type": "string", + "enum": [ + "admin", + "editor", + "viewer" + ], + "x-enum-varnames": [ + "RoleAdmin", + "RoleEditor", + "RoleViewer" + ] + }, + "models.Workspace": { + "type": "object", + "required": [ + "id", + "name", + "userId" + ], + "properties": { + "autoSave": { + "type": "boolean" + }, + "createdAt": { + "type": "string" + }, + "gitAutoCommit": { + "type": "boolean" + }, + "gitCommitEmail": { + "type": "string" + }, + "gitCommitMsgTemplate": { + "type": "string" + }, + "gitCommitName": { + "type": "string" + }, + "gitEnabled": { + "type": "boolean" + }, + "gitToken": { + "type": "string" + }, + "gitUrl": { + "type": "string" + }, + "gitUser": { + "type": "string" + }, + "id": { + "type": "integer", + "minimum": 1 + }, + "lastOpenedFilePath": { + "type": "string" + }, + "name": { + "type": "string" + }, + "showHiddenFiles": { + "type": "boolean" + }, + "theme": { + "description": "Integrated settings", + "type": "string", + "enum": [ + "light", + "dark" + ] + }, + "userId": { + "type": "integer", + "minimum": 1 + } + } + }, + "storage.FileNode": { + "type": "object", + "properties": { + "children": { + "type": "array", + "items": { + "$ref": "#/definitions/storage.FileNode" + } + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "path": { + "type": "string" + } + } + } + } +} \ No newline at end of file diff --git a/server/docs/swagger.yaml b/server/docs/swagger.yaml new file mode 100644 index 0000000..505cd38 --- /dev/null +++ b/server/docs/swagger.yaml @@ -0,0 +1,1168 @@ +basePath: /api/v1 +definitions: + handlers.CommitRequest: + properties: + message: + example: Initial commit + type: string + type: object + handlers.CommitResponse: + properties: + commitHash: + example: a1b2c3d4 + type: string + type: object + handlers.CreateUserRequest: + properties: + displayName: + type: string + email: + type: string + password: + type: string + role: + $ref: '#/definitions/models.UserRole' + type: object + handlers.DeleteAccountRequest: + properties: + password: + type: string + type: object + handlers.DeleteWorkspaceResponse: + properties: + nextWorkspaceName: + type: string + type: object + handlers.ErrorResponse: + properties: + message: + type: string + type: object + handlers.LastOpenedFileResponse: + properties: + lastOpenedFilePath: + type: string + type: object + handlers.LastWorkspaceNameResponse: + properties: + lastWorkspaceName: + type: string + type: object + handlers.LoginRequest: + properties: + email: + type: string + password: + type: string + type: object + handlers.LoginResponse: + properties: + accessToken: + type: string + refreshToken: + type: string + session: + $ref: '#/definitions/models.Session' + user: + $ref: '#/definitions/models.User' + type: object + handlers.LookupResponse: + properties: + paths: + items: + type: string + type: array + type: object + handlers.PullResponse: + properties: + message: + example: Pulled changes from remote + type: string + type: object + handlers.RefreshRequest: + properties: + refreshToken: + type: string + type: object + handlers.RefreshResponse: + properties: + accessToken: + type: string + type: object + handlers.SaveFileResponse: + properties: + filePath: + type: string + size: + type: integer + updatedAt: + type: string + type: object + handlers.SystemStats: + properties: + activeUsers: + description: Users with activity in last 30 days + type: integer + totalFiles: + type: integer + totalSize: + type: integer + totalUsers: + type: integer + totalWorkspaces: + type: integer + type: object + handlers.UpdateLastOpenedFileRequest: + properties: + filePath: + type: string + type: object + handlers.UpdateProfileRequest: + properties: + currentPassword: + type: string + displayName: + type: string + email: + type: string + newPassword: + type: string + type: object + handlers.UpdateUserRequest: + properties: + displayName: + type: string + email: + type: string + password: + type: string + role: + $ref: '#/definitions/models.UserRole' + type: object + handlers.WorkspaceStats: + properties: + totalFiles: + type: integer + totalSize: + type: integer + userEmail: + type: string + userID: + type: integer + workspaceCreatedAt: + type: string + workspaceID: + type: integer + workspaceName: + type: string + type: object + models.Session: + properties: + createdAt: + description: When this session was created + type: string + expiresAt: + description: When this session expires + type: string + id: + description: Unique session identifier + type: string + refreshToken: + description: The refresh token associated with this session + type: string + userID: + description: ID of the user this session belongs to + type: integer + type: object + models.User: + properties: + createdAt: + type: string + displayName: + type: string + email: + type: string + id: + minimum: 1 + type: integer + lastWorkspaceId: + type: integer + role: + allOf: + - $ref: '#/definitions/models.UserRole' + enum: + - admin + - editor + - viewer + required: + - email + - id + - role + type: object + models.UserRole: + enum: + - admin + - editor + - viewer + type: string + x-enum-varnames: + - RoleAdmin + - RoleEditor + - RoleViewer + models.Workspace: + properties: + autoSave: + type: boolean + createdAt: + type: string + gitAutoCommit: + type: boolean + gitCommitEmail: + type: string + gitCommitMsgTemplate: + type: string + gitCommitName: + type: string + gitEnabled: + type: boolean + gitToken: + type: string + gitUrl: + type: string + gitUser: + type: string + id: + minimum: 1 + type: integer + lastOpenedFilePath: + type: string + name: + type: string + showHiddenFiles: + type: boolean + theme: + description: Integrated settings + enum: + - light + - dark + type: string + userId: + minimum: 1 + type: integer + required: + - id + - name + - userId + type: object + storage.FileNode: + properties: + children: + items: + $ref: '#/definitions/storage.FileNode' + type: array + id: + type: string + name: + type: string + path: + type: string + type: object +info: + contact: {} + description: This is the API for NovaMD markdown note taking app. + license: + name: Apache 2.0 + url: http://www.apache.org/licenses/LICENSE-2.0.html + title: NovaMD API + version: "1.0" +paths: + /admin/stats: + get: + description: Get system-wide statistics as an admin + operationId: adminGetSystemStats + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.SystemStats' + "500": + description: Failed to get file stats + schema: + $ref: '#/definitions/handlers.ErrorResponse' + security: + - BearerAuth: [] + summary: Get system statistics + tags: + - Admin + /admin/users: + get: + description: Returns the list of all users + operationId: adminListUsers + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/models.User' + type: array + "500": + description: Failed to list users + schema: + $ref: '#/definitions/handlers.ErrorResponse' + security: + - BearerAuth: [] + summary: List all users + tags: + - Admin + post: + consumes: + - application/json + description: Create a new user as an admin + operationId: adminCreateUser + parameters: + - description: User details + in: body + name: user + required: true + schema: + $ref: '#/definitions/handlers.CreateUserRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/models.User' + "400": + description: Password must be at least 8 characters + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "409": + description: Email already exists + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "500": + description: Failed to initialize user workspace + schema: + $ref: '#/definitions/handlers.ErrorResponse' + security: + - BearerAuth: [] + summary: Create a new user + tags: + - Admin + /admin/users/{userId}: + delete: + description: Delete a specific user as an admin + operationId: adminDeleteUser + parameters: + - description: User ID + in: path + name: userId + required: true + type: integer + responses: + "204": + description: No Content + "400": + description: Cannot delete your own account + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "403": + description: Cannot delete other admin users + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "404": + description: User not found + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "500": + description: Failed to delete user + schema: + $ref: '#/definitions/handlers.ErrorResponse' + security: + - BearerAuth: [] + summary: Delete a specific user + tags: + - Admin + get: + description: Get a specific user as an admin + operationId: adminGetUser + parameters: + - description: User ID + in: path + name: userId + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/models.User' + "400": + description: Invalid user ID + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "404": + description: User not found + schema: + $ref: '#/definitions/handlers.ErrorResponse' + security: + - BearerAuth: [] + summary: Get a specific user + tags: + - Admin + put: + consumes: + - application/json + description: Update a specific user as an admin + operationId: adminUpdateUser + parameters: + - description: User ID + in: path + name: userId + required: true + type: integer + - description: User details + in: body + name: user + required: true + schema: + $ref: '#/definitions/handlers.UpdateUserRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/models.User' + "400": + description: Invalid request body + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "404": + description: User not found + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "500": + description: Failed to update user + schema: + $ref: '#/definitions/handlers.ErrorResponse' + security: + - BearerAuth: [] + summary: Update a specific user + tags: + - Admin + /admin/workspaces: + get: + description: List all workspaces and their stats as an admin + operationId: adminListWorkspaces + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/handlers.WorkspaceStats' + type: array + "500": + description: Failed to get file stats + schema: + $ref: '#/definitions/handlers.ErrorResponse' + security: + - BearerAuth: [] + summary: List all workspaces + tags: + - Admin + /auth/login: + post: + consumes: + - application/json + description: Logs in a user + operationId: login + parameters: + - description: Login request + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.LoginRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.LoginResponse' + "400": + description: Email and password are required + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "401": + description: Invalid credentials + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "500": + description: Failed to create session + schema: + $ref: '#/definitions/handlers.ErrorResponse' + summary: Login + tags: + - auth + /auth/logout: + post: + description: Log out invalidates the user's session + operationId: logout + responses: + "204": + description: No Content + "400": + description: Session ID required + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "500": + description: Failed to logout + schema: + $ref: '#/definitions/handlers.ErrorResponse' + security: + - BearerAuth: [] + summary: Logout + tags: + - auth + /auth/me: + get: + description: Returns the current authenticated user + operationId: getCurrentUser + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/models.User' + "404": + description: User not found + schema: + $ref: '#/definitions/handlers.ErrorResponse' + security: + - BearerAuth: [] + summary: Get current user + tags: + - auth + /auth/refresh: + post: + consumes: + - application/json + description: Refreshes the access token using the refresh token + operationId: refreshToken + parameters: + - description: Refresh request + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.RefreshRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.RefreshResponse' + "400": + description: Refresh token required + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "401": + description: Invalid refresh token + schema: + $ref: '#/definitions/handlers.ErrorResponse' + summary: Refresh token + tags: + - auth + /profile: + delete: + consumes: + - application/json + description: Deletes the user's account and all associated data + operationId: deleteAccount + parameters: + - description: Account deletion request + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.DeleteAccountRequest' + produces: + - application/json + responses: + "204": + description: No Content - Account deleted successfully + "400": + description: Invalid request body + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "401": + description: Password is incorrect + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "403": + description: Cannot delete the last admin account + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "404": + description: User not found + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "500": + description: Failed to delete account + schema: + $ref: '#/definitions/handlers.ErrorResponse' + security: + - BearerAuth: [] + summary: Delete account + tags: + - users + put: + consumes: + - application/json + description: Updates the user's profile + operationId: updateProfile + parameters: + - description: Profile update request + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.UpdateProfileRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/models.User' + "400": + description: Current password is required to change email + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "401": + description: Current password is incorrect + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "404": + description: User not found + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "409": + description: Email already in use + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "500": + description: Failed to update profile + schema: + $ref: '#/definitions/handlers.ErrorResponse' + security: + - BearerAuth: [] + summary: Update profile + tags: + - users + /workspaces: + get: + description: Lists all workspaces for the current user + operationId: listWorkspaces + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/models.Workspace' + type: array + "500": + description: Failed to list workspaces + schema: + $ref: '#/definitions/handlers.ErrorResponse' + security: + - BearerAuth: [] + summary: List workspaces + tags: + - workspaces + post: + consumes: + - application/json + description: Creates a new workspace + operationId: createWorkspace + parameters: + - description: Workspace + in: body + name: body + required: true + schema: + $ref: '#/definitions/models.Workspace' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/models.Workspace' + "400": + description: Invalid workspace + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "500": + description: Failed to setup git repo + schema: + $ref: '#/definitions/handlers.ErrorResponse' + security: + - BearerAuth: [] + summary: Create workspace + tags: + - workspaces + /workspaces/{workspace_name}: + delete: + description: Deletes the current workspace + operationId: deleteWorkspace + parameters: + - description: Workspace name + in: path + name: workspace_name + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.DeleteWorkspaceResponse' + "400": + description: Cannot delete the last workspace + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "500": + description: Failed to commit transaction + schema: + $ref: '#/definitions/handlers.ErrorResponse' + security: + - BearerAuth: [] + summary: Delete workspace + tags: + - workspaces + get: + description: Returns the current workspace + operationId: getWorkspace + parameters: + - description: Workspace name + in: path + name: workspace_name + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/models.Workspace' + "500": + description: Internal server error + schema: + $ref: '#/definitions/handlers.ErrorResponse' + security: + - BearerAuth: [] + summary: Get workspace + tags: + - workspaces + put: + consumes: + - application/json + description: Updates the current workspace + operationId: updateWorkspace + parameters: + - description: Workspace name + in: path + name: workspace_name + required: true + type: string + - description: Workspace + in: body + name: body + required: true + schema: + $ref: '#/definitions/models.Workspace' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/models.Workspace' + "400": + description: Invalid request body + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "500": + description: Failed to setup git repo + schema: + $ref: '#/definitions/handlers.ErrorResponse' + security: + - BearerAuth: [] + summary: Update workspace + tags: + - workspaces + /workspaces/{workspace_name}/files: + get: + description: Lists all files in the user's workspace + operationId: listFiles + parameters: + - description: Workspace name + in: path + name: workspace_name + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/storage.FileNode' + type: array + "500": + description: Failed to list files + schema: + $ref: '#/definitions/handlers.ErrorResponse' + security: + - BearerAuth: [] + summary: List files + tags: + - files + /workspaces/{workspace_name}/files/{file_path}: + delete: + description: Deletes a file in the user's workspace + operationId: deleteFile + parameters: + - description: Workspace name + in: path + name: workspace_name + required: true + type: string + - description: File path + in: path + name: file_path + required: true + type: string + responses: + "204": + description: No Content - File deleted successfully + "400": + description: Invalid file path + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "404": + description: File not found + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "500": + description: Failed to write response + schema: + $ref: '#/definitions/handlers.ErrorResponse' + security: + - BearerAuth: [] + summary: Delete file + tags: + - files + get: + description: Returns the content of a file in the user's workspace + operationId: getFileContent + parameters: + - description: Workspace name + in: path + name: workspace_name + required: true + type: string + - description: File path + in: path + name: file_path + required: true + type: string + produces: + - text/plain + responses: + "200": + description: Raw file content + schema: + type: string + "400": + description: Invalid file path + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "404": + description: File not found + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "500": + description: Failed to write response + schema: + $ref: '#/definitions/handlers.ErrorResponse' + security: + - BearerAuth: [] + summary: Get file content + tags: + - files + post: + consumes: + - text/plain + description: Saves the content of a file in the user's workspace + operationId: saveFile + parameters: + - description: Workspace name + in: path + name: workspace_name + required: true + type: string + - description: File path + in: path + name: file_path + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.SaveFileResponse' + "400": + description: Invalid file path + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "500": + description: Failed to save file + schema: + $ref: '#/definitions/handlers.ErrorResponse' + security: + - BearerAuth: [] + summary: Save file + tags: + - files + /workspaces/{workspace_name}/files/last: + get: + description: Returns the path of the last opened file in the user's workspace + operationId: getLastOpenedFile + parameters: + - description: Workspace name + in: path + name: workspace_name + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.LastOpenedFileResponse' + "400": + description: Invalid file path + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "500": + description: Failed to get last opened file + schema: + $ref: '#/definitions/handlers.ErrorResponse' + security: + - BearerAuth: [] + summary: Get last opened file + tags: + - files + put: + consumes: + - application/json + description: Updates the last opened file in the user's workspace + operationId: updateLastOpenedFile + parameters: + - description: Workspace name + in: path + name: workspace_name + required: true + type: string + - description: Update last opened file request + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.UpdateLastOpenedFileRequest' + produces: + - application/json + responses: + "204": + description: No Content - Last opened file updated successfully + "400": + description: Invalid file path + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "404": + description: File not found + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "500": + description: Failed to update file + schema: + $ref: '#/definitions/handlers.ErrorResponse' + security: + - BearerAuth: [] + summary: Update last opened file + tags: + - files + /workspaces/{workspace_name}/files/lookup: + get: + description: Returns the paths of files with the given name in the user's workspace + operationId: lookupFileByName + parameters: + - description: Workspace name + in: path + name: workspace_name + required: true + type: string + - description: File name + in: query + name: filename + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.LookupResponse' + "400": + description: Filename is required + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "404": + description: File not found + schema: + $ref: '#/definitions/handlers.ErrorResponse' + security: + - BearerAuth: [] + summary: Lookup file by name + tags: + - files + /workspaces/{workspace_name}/git/commit: + post: + description: Stages, commits, and pushes changes to the remote repository + operationId: stageCommitAndPush + parameters: + - description: Workspace name + in: path + name: workspace_name + required: true + type: string + - description: Commit request + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.CommitRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.CommitResponse' + "400": + description: Commit message is required + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "500": + description: Failed to stage, commit, and push changes + schema: + $ref: '#/definitions/handlers.ErrorResponse' + security: + - BearerAuth: [] + summary: Stage, commit, and push changes + tags: + - git + /workspaces/{workspace_name}/git/pull: + post: + description: Pulls changes from the remote repository + operationId: pullChanges + parameters: + - description: Workspace name + in: path + name: workspace_name + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.PullResponse' + "500": + description: Failed to pull changes + schema: + $ref: '#/definitions/handlers.ErrorResponse' + security: + - BearerAuth: [] + summary: Pull changes from remote + tags: + - git + /workspaces/last: + get: + description: Returns the name of the last opened workspace + operationId: getLastWorkspaceName + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.LastWorkspaceNameResponse' + "500": + description: Failed to get last workspace + schema: + $ref: '#/definitions/handlers.ErrorResponse' + security: + - BearerAuth: [] + summary: Get last workspace name + tags: + - workspaces + put: + consumes: + - application/json + description: Updates the name of the last opened workspace + operationId: updateLastWorkspaceName + produces: + - application/json + responses: + "204": + description: No Content - Last workspace updated successfully + "400": + description: Invalid request body + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "500": + description: Failed to update last workspace + schema: + $ref: '#/definitions/handlers.ErrorResponse' + security: + - BearerAuth: [] + summary: Update last workspace name + tags: + - workspaces +swagger: "2.0" diff --git a/server/go.mod b/server/go.mod index 90ae840..a2d3dbd 100644 --- a/server/go.mod +++ b/server/go.mod @@ -12,12 +12,15 @@ require ( github.com/google/uuid v1.6.0 github.com/mattn/go-sqlite3 v1.14.23 github.com/stretchr/testify v1.9.0 + github.com/swaggo/http-swagger v1.3.4 + github.com/swaggo/swag v1.16.4 github.com/unrolled/secure v1.17.0 golang.org/x/crypto v0.21.0 ) require ( dario.cat/mergo v1.0.0 // indirect + github.com/KyleBanks/depth v1.2.1 // indirect github.com/Microsoft/go-winio v0.6.1 // indirect github.com/ProtonMail/go-crypto v1.0.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect @@ -28,16 +31,23 @@ require ( github.com/gabriel-vasile/mimetype v1.4.3 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-git/go-billy/v5 v5.5.0 // indirect + github.com/go-openapi/jsonpointer v0.19.5 // indirect + github.com/go-openapi/jsonreference v0.20.0 // indirect + github.com/go-openapi/spec v0.20.6 // indirect + github.com/go-openapi/swag v0.19.15 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect + github.com/josharian/intern v1.0.0 // indirect github.com/kevinburke/ssh_config v1.2.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect + github.com/mailru/easyjson v0.7.6 // indirect github.com/pjbgf/sha1cd v0.3.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect github.com/skeema/knownhosts v1.2.2 // indirect + github.com/swaggo/files v0.0.0-20220610200504-28940afbdbfe // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect golang.org/x/mod v0.12.0 // indirect golang.org/x/net v0.23.0 // indirect @@ -45,5 +55,6 @@ require ( golang.org/x/text v0.14.0 // indirect golang.org/x/tools v0.13.0 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/server/go.sum b/server/go.sum index 426c423..ea26479 100644 --- a/server/go.sum +++ b/server/go.sum @@ -1,5 +1,7 @@ dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= +github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow= github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM= @@ -15,6 +17,7 @@ github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA= github.com/cloudflare/circl v1.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vcU= github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/cyphar/filepath-securejoin v0.2.4 h1:Ugdm7cg7i6ZK6x3xDF1oEu1nfkyfH53EtKeQYTC3kyg= github.com/cyphar/filepath-securejoin v0.2.4/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -42,6 +45,16 @@ github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMj github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= github.com/go-git/go-git/v5 v5.12.0 h1:7Md+ndsjrzZxbddRDZjF14qK+NN56sy6wkqaVrjZtys= github.com/go-git/go-git/v5 v5.12.0/go.mod h1:FTM9VKtnI2m65hNI/TenDDDnUf2Q9FHnXYjuz9i5OEY= +github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY= +github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/jsonreference v0.20.0 h1:MYlu0sBgChmCfJxxUKZ8g1cPWFOB37YSZqewK7OKeyA= +github.com/go-openapi/jsonreference v0.20.0/go.mod h1:Ag74Ico3lPc+zR+qjn4XBUmXymS4zJbYVCZmcgkasdo= +github.com/go-openapi/spec v0.20.6 h1:ich1RQ3WDbfoeTqTAb+5EIxNmpKVJZWBNah9RAT0jIQ= +github.com/go-openapi/spec v0.20.6/go.mod h1:2OpW+JddWPrpXSCIX8eOx7lZ5iyuWj3RYR6VaaBKcWA= +github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= +github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM= +github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= @@ -60,6 +73,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= @@ -71,8 +86,13 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA= +github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mattn/go-sqlite3 v1.14.23 h1:gbShiuAP1W5j9UOksQ06aiiqPMxYecovVGwmTxWtuw0= github.com/mattn/go-sqlite3 v1.14.23/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/onsi/gomega v1.27.10 h1:naR28SdDFlqrG6kScpT8VWpu1xWY5nJRCF3XaYyBjhI= github.com/onsi/gomega v1.27.10/go.mod h1:RsS8tutOdbdgzbPtzzATp12yT7kM5I5aElG3evPbQ0M= github.com/pjbgf/sha1cd v0.3.0 h1:4D5XXmUUBUl/xQ6IjCkEAbqXskkq/4O7LmGn0AqMDs4= @@ -90,9 +110,17 @@ github.com/skeema/knownhosts v1.2.2 h1:Iug2P4fLmDw9f41PB6thxUkNUkJzB5i+1/exaj40L github.com/skeema/knownhosts v1.2.2/go.mod h1:xYbVRSPxqBZFrdmDyMmsOs+uX1UZC3nTN3ThzgDxUwo= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/swaggo/files v0.0.0-20220610200504-28940afbdbfe h1:K8pHPVoTgxFJt1lXuIzzOX7zZhZFldJQK/CgKx9BFIc= +github.com/swaggo/files v0.0.0-20220610200504-28940afbdbfe/go.mod h1:lKJPbtWzJ9JhsTN1k1gZgleJWY/cqq0psdoMmaThG3w= +github.com/swaggo/http-swagger v1.3.4 h1:q7t/XLx0n15H1Q9/tk3Y9L4n210XzJF5WtnDX64a5ww= +github.com/swaggo/http-swagger v1.3.4/go.mod h1:9dAh0unqMBAlbp1uE2Uc2mQTxNMU/ha4UbucIg1MFkQ= +github.com/swaggo/swag v1.16.4 h1:clWJtd9LStiG3VeijiCfOVODP6VpHtKdQy9ELFG3s1A= +github.com/swaggo/swag v1.16.4/go.mod h1:VBsHJRsDvfYvqoiMKnsdwhNV9LEMHgEDZcyVYX0sxPg= github.com/unrolled/secure v1.17.0 h1:Io7ifFgo99Bnh0J7+Q+qcMzWM6kaDPCA5FroFZEdbWU= github.com/unrolled/secure v1.17.0/go.mod h1:BmF5hyM6tXczk3MpQkFf1hpKSRqCyhqcbiQtiAF7+40= github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= @@ -111,6 +139,7 @@ golang.org/x/mod v0.12.0 h1:rmsUpXtvNzj340zd98LZ4KntptpfRHwpFOHG188oHXc= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= @@ -162,12 +191,17 @@ golang.org/x/tools v0.13.0 h1:Iey4qkscZuv0VvIt8E0neZjtPVQFSc870HQ448QgEmQ= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/server/internal/app/routes.go b/server/internal/app/routes.go index 3b02d02..f3e9df7 100644 --- a/server/internal/app/routes.go +++ b/server/internal/app/routes.go @@ -11,6 +11,10 @@ import ( "github.com/go-chi/cors" "github.com/go-chi/httprate" "github.com/unrolled/secure" + + httpSwagger "github.com/swaggo/http-swagger" + + _ "novamd/docs" // Swagger docs ) // setupRouter creates and configures the chi router with middleware and routes @@ -49,6 +53,12 @@ func setupRouter(o Options) *chi.Mux { Storage: o.Storage, } + if o.Config.IsDevelopment { + r.Get("/swagger/*", httpSwagger.Handler( + httpSwagger.URL("/swagger/doc.json"), // The URL pointing to API definition + )) + } + // API routes r.Route("/api/v1", func(r chi.Router) { // Rate limiting for API routes diff --git a/server/internal/handlers/file_handlers.go b/server/internal/handlers/file_handlers.go index 5e6ad27..6104d98 100644 --- a/server/internal/handlers/file_handlers.go +++ b/server/internal/handlers/file_handlers.go @@ -108,12 +108,12 @@ func (h *Handler) LookupFileByName() http.HandlerFunc { // @Produce plain // @Param workspace_name path string true "Workspace name" // @Param file_path path string true "File path" -// @Success 200 {string} "File content" +// @Success 200 {string} string "Raw file content" // @Failure 400 {object} ErrorResponse "Invalid file path" // @Failure 404 {object} ErrorResponse "File not found" // @Failure 500 {object} ErrorResponse "Failed to read file" // @Failure 500 {object} ErrorResponse "Failed to write response" -// @Router /workspaces/{workspace_name}/files/* [get] +// @Router /workspaces/{workspace_name}/files/{file_path} [get] func (h *Handler) GetFileContent() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { ctx, ok := context.GetRequestContext(w, r) @@ -162,7 +162,7 @@ func (h *Handler) GetFileContent() http.HandlerFunc { // @Failure 400 {object} ErrorResponse "Failed to read request body" // @Failure 400 {object} ErrorResponse "Invalid file path" // @Failure 500 {object} ErrorResponse "Failed to save file" -// @Router /workspaces/{workspace_name}/files/* [post] +// @Router /workspaces/{workspace_name}/files/{file_path} [post] func (h *Handler) SaveFile() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { ctx, ok := context.GetRequestContext(w, r) @@ -205,7 +205,6 @@ func (h *Handler) SaveFile() http.HandlerFunc { // @Tags files // @ID deleteFile // @Security BearerAuth -// @Produce string // @Param workspace_name path string true "Workspace name" // @Param file_path path string true "File path" // @Success 204 "No Content - File deleted successfully" @@ -213,7 +212,7 @@ func (h *Handler) SaveFile() http.HandlerFunc { // @Failure 404 {object} ErrorResponse "File not found" // @Failure 500 {object} ErrorResponse "Failed to delete file" // @Failure 500 {object} ErrorResponse "Failed to write response" -// @Router /workspaces/{workspace_name}/files/* [delete] +// @Router /workspaces/{workspace_name}/files/{file_path} [delete] func (h *Handler) DeleteFile() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { ctx, ok := context.GetRequestContext(w, r) diff --git a/server/internal/handlers/workspace_handlers.go b/server/internal/handlers/workspace_handlers.go index 9cfb61f..2b26d0d 100644 --- a/server/internal/handlers/workspace_handlers.go +++ b/server/internal/handlers/workspace_handlers.go @@ -14,8 +14,8 @@ type DeleteWorkspaceResponse struct { NextWorkspaceName string `json:"nextWorkspaceName"` } -// GetLastWorkspaceNameResponse contains the name of the last opened workspace -type GetLastWorkspaceNameResponse struct { +// LastWorkspaceNameResponse contains the name of the last opened workspace +type LastWorkspaceNameResponse struct { LastWorkspaceName string `json:"lastWorkspaceName"` } @@ -325,7 +325,7 @@ func (h *Handler) GetLastWorkspaceName() http.HandlerFunc { return } - respondJSON(w, &GetLastWorkspaceNameResponse{LastWorkspaceName: workspaceName}) + respondJSON(w, &LastWorkspaceNameResponse{LastWorkspaceName: workspaceName}) } } From 3d36c67c902e30c96aa2df39f3313daba44c0de0 Mon Sep 17 00:00:00 2001 From: LordMathis Date: Wed, 4 Dec 2024 21:33:34 +0100 Subject: [PATCH 5/5] Update frontend requests --- app/src/services/api.js | 23 +++++++++-------------- app/src/services/authApi.js | 6 +++++- 2 files changed, 14 insertions(+), 15 deletions(-) diff --git a/app/src/services/api.js b/app/src/services/api.js index dc8c1df..626f684 100644 --- a/app/src/services/api.js +++ b/app/src/services/api.js @@ -47,17 +47,16 @@ export const saveFileContent = async (workspaceName, filePath, content) => { body: content, } ); - return response.text(); + return response.json(); }; export const deleteFile = async (workspaceName, filePath) => { - const response = await apiCall( + await apiCall( `${API_BASE_URL}/workspaces/${workspaceName}/files/${filePath}`, { method: 'DELETE', } ); - return response.text(); }; export const getWorkspace = async (workspaceName) => { @@ -119,17 +118,13 @@ export const lookupFileByName = async (workspaceName, filename) => { }; export const updateLastOpenedFile = async (workspaceName, filePath) => { - const response = await apiCall( - `${API_BASE_URL}/workspaces/${workspaceName}/files/last`, - { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ filePath }), - } - ); - return response.json(); + await apiCall(`${API_BASE_URL}/workspaces/${workspaceName}/files/last`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ filePath }), + }); }; export const getLastOpenedFile = async (workspaceName) => { diff --git a/app/src/services/authApi.js b/app/src/services/authApi.js index 9b1ae31..1144f0c 100644 --- a/app/src/services/authApi.js +++ b/app/src/services/authApi.js @@ -49,13 +49,17 @@ export const apiCall = async (url, options = {}) => { throw new Error('Authentication failed'); } - if (!response.ok) { + if (!response.ok && response.status !== 204) { const errorData = await response.json().catch(() => null); throw new Error( errorData?.message || `HTTP error! status: ${response.status}` ); } + if (response.status === 204) { + return null; + } + return response; } catch (error) { console.error(`API call failed: ${error.message}`);