Merge pull request #24 from LordMathis/chore/apidocs

Create swagger api docs
This commit is contained in:
2024-12-04 21:44:35 +01:00
committed by GitHub
35 changed files with 6730 additions and 575 deletions

View File

@@ -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`,
{
await apiCall(`${API_BASE_URL}/workspaces/${workspaceName}/files/last`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ filePath }),
}
);
return response.json();
});
};
export const getLastOpenedFile = async (workspaceName) => {

View File

@@ -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}`);

View File

@@ -5,21 +5,28 @@ import (
"log"
"novamd/internal/app"
"novamd/internal/config"
)
// @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 := 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)

1829
server/docs/docs.go Normal file

File diff suppressed because it is too large Load Diff

1804
server/docs/swagger.json Normal file

File diff suppressed because it is too large Load Diff

1168
server/docs/swagger.yaml Normal file

File diff suppressed because it is too large Load Diff

832
server/documentation.md Normal file
View File

@@ -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.
```

View File

@@ -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
)

View File

@@ -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=

View File

@@ -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())
})
})
})
})
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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

111
server/internal/app/init.go Normal file
View File

@@ -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
}

View File

@@ -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
}

View File

@@ -0,0 +1,152 @@
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"
httpSwagger "github.com/swaggo/http-swagger"
_ "novamd/docs" // Swagger docs
)
// 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,
}
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
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
}

View File

@@ -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
}

View File

@@ -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

View File

@@ -31,12 +31,37 @@ 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 {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
}
@@ -44,38 +69,55 @@ 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 {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
}
@@ -89,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
}
@@ -103,18 +145,29 @@ 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 {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
}
@@ -122,25 +175,41 @@ 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 {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
}
@@ -157,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
}
@@ -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 {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) {
ctx, ok := context.GetRequestContext(w, r)
@@ -182,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
}
@@ -214,22 +296,23 @@ 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 {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
}
@@ -241,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
}
@@ -253,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
}
@@ -266,24 +349,28 @@ 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 {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
}

View File

@@ -34,39 +34,52 @@ 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 {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
}
@@ -82,43 +95,64 @@ 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 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)
}
}
// 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 {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
}
@@ -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 {object} ErrorResponse "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)
@@ -141,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
}

View File

@@ -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{

View File

@@ -5,6 +5,7 @@ import (
"io"
"net/http"
"os"
"time"
"novamd/internal/context"
"novamd/internal/storage"
@@ -12,7 +13,39 @@ import (
"github.com/go-chi/chi/v5"
)
// ListFiles returns a list of all files in the workspace
// 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
// @Tags files
// @ID listFiles
// @Security BearerAuth
// @Produce json
// @Param workspace_name path string true "Workspace name"
// @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) {
ctx, ok := context.GetRequestContext(w, r)
@@ -22,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
}
@@ -30,7 +63,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} 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) {
ctx, ok := context.GetRequestContext(w, r)
@@ -40,21 +85,35 @@ 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})
}
}
// 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} 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/{file_path} [get]
func (h *Handler) GetFileContent() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx, ok := context.GetRequestContext(w, r)
@@ -67,29 +126,43 @@ 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, "Failed to read file", 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
}
}
}
// 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 {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/{file_path} [post]
func (h *Handler) SaveFile() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx, ok := context.GetRequestContext(w, r)
@@ -100,26 +173,46 @@ 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)
}
}
// DeleteFile deletes a file
// DeleteFile godoc
// @Summary Delete file
// @Description Deletes a file in the user's workspace
// @Tags files
// @ID deleteFile
// @Security BearerAuth
// @Param workspace_name path string true "Workspace name"
// @Param file_path path string true "File path"
// @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/{file_path} [delete]
func (h *Handler) DeleteFile() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx, ok := context.GetRequestContext(w, r)
@@ -131,29 +224,35 @@ 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)
}
}
// 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} 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) {
ctx, ok := context.GetRequestContext(w, r)
@@ -163,20 +262,35 @@ 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})
}
}
// 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"
// @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) {
ctx, ok := context.GetRequestContext(w, r)
@@ -184,12 +298,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
}
@@ -198,25 +310,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 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)
}
}

View File

@@ -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)

View File

@@ -7,7 +7,35 @@ import (
"novamd/internal/context"
)
// StageCommitAndPush stages, commits, and pushes changes to the remote repository
// 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
// @Tags git
// @ID stageCommitAndPush
// @Security BearerAuth
// @Produce json
// @Param workspace_name path string true "Workspace name"
// @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) {
ctx, ok := context.GetRequestContext(w, r)
@@ -15,31 +43,39 @@ 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()})
}
}
// 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} 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) {
ctx, ok := context.GetRequestContext(w, r)
@@ -49,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"})
}
}

View File

@@ -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")
})

View File

@@ -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})
}

View File

@@ -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
}

View File

@@ -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

View File

@@ -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
}

View File

@@ -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 {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) {
ctx, ok := context.GetRequestContext(w, r)
@@ -32,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
}
@@ -47,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)
@@ -76,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
}
}
@@ -91,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
@@ -104,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
}
@@ -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 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) {
ctx, ok := context.GetRequestContext(w, r)
@@ -123,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
}
@@ -145,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
}
}
@@ -157,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)
}
}

View File

@@ -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)

View File

@@ -9,7 +9,26 @@ import (
"novamd/internal/models"
)
// ListWorkspaces returns a list of all workspaces for the current user
// DeleteWorkspaceResponse contains the name of the next workspace after deleting the current one
type DeleteWorkspaceResponse struct {
NextWorkspaceName string `json:"nextWorkspaceName"`
}
// LastWorkspaceNameResponse contains the name of the last opened workspace
type LastWorkspaceNameResponse struct {
LastWorkspaceName string `json:"lastWorkspaceName"`
}
// 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 {object} ErrorResponse "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)
@@ -19,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
}
@@ -27,7 +46,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 {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) {
ctx, ok := context.GetRequestContext(w, r)
@@ -37,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
}
@@ -67,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
}
}
@@ -76,7 +110,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 {object} ErrorResponse "Internal server error"
// @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 +150,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 {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) {
ctx, ok := context.GetRequestContext(w, r)
@@ -116,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
}
@@ -126,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
}
@@ -142,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
}
@@ -152,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
}
@@ -160,7 +218,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} 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) {
ctx, ok := context.GetRequestContext(w, r)
@@ -171,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
}
@@ -194,41 +268,50 @@ 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})
}
}
// 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} 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) {
ctx, ok := context.GetRequestContext(w, r)
@@ -238,15 +321,26 @@ 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, &LastWorkspaceNameResponse{LastWorkspaceName: workspaceName})
}
}
// 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 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) {
ctx, ok := context.GetRequestContext(w, r)
@@ -259,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)
}
}

View File

@@ -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)

View File

@@ -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.

View File

@@ -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")
}