mirror of
https://github.com/lordmathis/lemma.git
synced 2025-11-06 07:54:22 +00:00
Rename root folders
This commit is contained in:
97
server/internal/api/routes.go
Normal file
97
server/internal/api/routes.go
Normal file
@@ -0,0 +1,97 @@
|
||||
// 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/db"
|
||||
"novamd/internal/filesystem"
|
||||
"novamd/internal/handlers"
|
||||
"novamd/internal/middleware"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// SetupRoutes configures the API routes
|
||||
func SetupRoutes(r chi.Router, db *db.DB, fs *filesystem.FileSystem, authMiddleware *auth.Middleware, sessionService *auth.SessionService) {
|
||||
|
||||
handler := &handlers.Handler{
|
||||
DB: db,
|
||||
FS: fs,
|
||||
}
|
||||
|
||||
// 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(middleware.WithUserContext)
|
||||
|
||||
// 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(middleware.WithWorkspaceContext(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())
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
135
server/internal/auth/jwt.go
Normal file
135
server/internal/auth/jwt.go
Normal file
@@ -0,0 +1,135 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
// TokenType represents the type of JWT token (access or refresh)
|
||||
type TokenType string
|
||||
|
||||
const (
|
||||
AccessToken TokenType = "access" // AccessToken - Short-lived token for API access
|
||||
RefreshToken TokenType = "refresh" // RefreshToken - Long-lived token for obtaining new access tokens
|
||||
)
|
||||
|
||||
// Claims represents the custom claims we store in JWT tokens
|
||||
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)
|
||||
}
|
||||
|
||||
// JWTConfig holds the configuration for the JWT service
|
||||
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
|
||||
}
|
||||
|
||||
// JWTService handles JWT token generation and validation
|
||||
type JWTService struct {
|
||||
config JWTConfig
|
||||
}
|
||||
|
||||
// NewJWTService creates a new JWT service with the provided configuration
|
||||
// Returns an error if the signing key is missing
|
||||
func NewJWTService(config JWTConfig) (*JWTService, error) {
|
||||
if config.SigningKey == "" {
|
||||
return nil, fmt.Errorf("signing key is required")
|
||||
}
|
||||
// Set default expiry times if not provided
|
||||
if config.AccessTokenExpiry == 0 {
|
||||
config.AccessTokenExpiry = 15 * time.Minute // Default to 15 minutes
|
||||
}
|
||||
if config.RefreshTokenExpiry == 0 {
|
||||
config.RefreshTokenExpiry = 7 * 24 * time.Hour // Default to 7 days
|
||||
}
|
||||
return &JWTService{config: config}, nil
|
||||
}
|
||||
|
||||
// GenerateAccessToken creates a new access token for a user
|
||||
// Parameters:
|
||||
// - userID: the ID of the user
|
||||
// - role: the role of the user
|
||||
// Returns the signed token string or an error
|
||||
func (s *JWTService) GenerateAccessToken(userID int, role string) (string, error) {
|
||||
return s.generateToken(userID, role, AccessToken, s.config.AccessTokenExpiry)
|
||||
}
|
||||
|
||||
// GenerateRefreshToken creates a new refresh token for a user
|
||||
// Parameters:
|
||||
// - userID: the ID of the user
|
||||
// - role: the role of the user
|
||||
// Returns the signed token string or an error
|
||||
func (s *JWTService) GenerateRefreshToken(userID int, role string) (string, error) {
|
||||
return s.generateToken(userID, role, RefreshToken, s.config.RefreshTokenExpiry)
|
||||
}
|
||||
|
||||
// generateToken is an internal helper function that creates a new JWT token
|
||||
// Parameters:
|
||||
// - userID: the ID of the user
|
||||
// - role: the role of the user
|
||||
// - tokenType: the type of token (access or refresh)
|
||||
// - expiry: how long the token should be valid
|
||||
// Returns the signed token string or an error
|
||||
func (s *JWTService) generateToken(userID int, role string, tokenType TokenType, expiry time.Duration) (string, error) {
|
||||
now := time.Now()
|
||||
claims := Claims{
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(expiry)),
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
NotBefore: jwt.NewNumericDate(now),
|
||||
},
|
||||
UserID: userID,
|
||||
Role: role,
|
||||
Type: tokenType,
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString([]byte(s.config.SigningKey))
|
||||
}
|
||||
|
||||
// ValidateToken validates and parses a JWT token
|
||||
// Parameters:
|
||||
// - tokenString: the token to validate
|
||||
// Returns the token claims if valid, or an error if invalid
|
||||
func (s *JWTService) ValidateToken(tokenString string) (*Claims, error) {
|
||||
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
|
||||
// Validate the signing method
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||
}
|
||||
return []byte(s.config.SigningKey), nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid token: %w", err)
|
||||
}
|
||||
|
||||
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("invalid token claims")
|
||||
}
|
||||
|
||||
// RefreshAccessToken creates a new access token using a refresh token
|
||||
// Parameters:
|
||||
// - refreshToken: the refresh token to use
|
||||
// Returns a new access token if the refresh token is valid, or an error
|
||||
func (s *JWTService) RefreshAccessToken(refreshToken string) (string, error) {
|
||||
claims, err := s.ValidateToken(refreshToken)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid refresh token: %w", err)
|
||||
}
|
||||
|
||||
if claims.Type != RefreshToken {
|
||||
return "", fmt.Errorf("invalid token type: expected refresh token")
|
||||
}
|
||||
|
||||
return s.GenerateAccessToken(claims.UserID, claims.Role)
|
||||
}
|
||||
128
server/internal/auth/middleware.go
Normal file
128
server/internal/auth/middleware.go
Normal file
@@ -0,0 +1,128 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"novamd/internal/httpcontext"
|
||||
)
|
||||
|
||||
type contextKey string
|
||||
|
||||
const (
|
||||
UserContextKey contextKey = "user"
|
||||
)
|
||||
|
||||
// UserClaims represents the user information stored in the request context
|
||||
type UserClaims struct {
|
||||
UserID int
|
||||
Role string
|
||||
}
|
||||
|
||||
// Middleware handles JWT authentication for protected routes
|
||||
type Middleware struct {
|
||||
jwtService *JWTService
|
||||
}
|
||||
|
||||
// NewMiddleware creates a new authentication middleware
|
||||
func NewMiddleware(jwtService *JWTService) *Middleware {
|
||||
return &Middleware{
|
||||
jwtService: jwtService,
|
||||
}
|
||||
}
|
||||
|
||||
// Authenticate middleware validates JWT tokens and sets user information in context
|
||||
func (m *Middleware) Authenticate(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Extract token from Authorization header
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
http.Error(w, "Authorization header required", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Check Bearer token format
|
||||
parts := strings.Split(authHeader, " ")
|
||||
if len(parts) != 2 || parts[0] != "Bearer" {
|
||||
http.Error(w, "Invalid authorization format", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate token
|
||||
claims, err := m.jwtService.ValidateToken(parts[1])
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid token", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Check token type
|
||||
if claims.Type != AccessToken {
|
||||
http.Error(w, "Invalid token type", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Add user claims to request context
|
||||
ctx := context.WithValue(r.Context(), UserContextKey, UserClaims{
|
||||
UserID: claims.UserID,
|
||||
Role: claims.Role,
|
||||
})
|
||||
|
||||
// Call the next handler with the updated context
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
// RequireRole returns a middleware that ensures the user has the required role
|
||||
func (m *Middleware) RequireRole(role string) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
claims, ok := r.Context().Value(UserContextKey).(UserClaims)
|
||||
if !ok {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if claims.Role != role && claims.Role != "admin" {
|
||||
http.Error(w, "Insufficient permissions", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Middleware) RequireWorkspaceAccess(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Get our handler context
|
||||
ctx, ok := httpcontext.GetRequestContext(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// If no workspace in context, allow the request (might be a non-workspace endpoint)
|
||||
if ctx.Workspace == nil {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if user has access (either owner or admin)
|
||||
if ctx.Workspace.UserID != ctx.UserID && ctx.UserRole != "admin" {
|
||||
http.Error(w, "Not Found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// GetUserFromContext retrieves user claims from the request context
|
||||
func GetUserFromContext(ctx context.Context) (*UserClaims, error) {
|
||||
claims, ok := ctx.Value(UserContextKey).(UserClaims)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("no user found in context")
|
||||
}
|
||||
return &claims, nil
|
||||
}
|
||||
140
server/internal/auth/session.go
Normal file
140
server/internal/auth/session.go
Normal file
@@ -0,0 +1,140 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Session represents a user session in the database
|
||||
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
|
||||
}
|
||||
|
||||
// SessionService manages user sessions in the database
|
||||
type SessionService struct {
|
||||
db *sql.DB // Database connection
|
||||
jwtService *JWTService // JWT service for token operations
|
||||
}
|
||||
|
||||
// NewSessionService creates a new session service
|
||||
// Parameters:
|
||||
// - db: database connection
|
||||
// - jwtService: JWT service for token operations
|
||||
func NewSessionService(db *sql.DB, jwtService *JWTService) *SessionService {
|
||||
return &SessionService{
|
||||
db: db,
|
||||
jwtService: jwtService,
|
||||
}
|
||||
}
|
||||
|
||||
// CreateSession creates a new user session
|
||||
// Parameters:
|
||||
// - userID: the ID of the user
|
||||
// - role: the role of the user
|
||||
// Returns:
|
||||
// - session: the created session
|
||||
// - accessToken: a new access token
|
||||
// - error: any error that occurred
|
||||
func (s *SessionService) CreateSession(userID int, role string) (*Session, string, error) {
|
||||
// Generate both access and refresh tokens
|
||||
accessToken, err := s.jwtService.GenerateAccessToken(userID, role)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to generate access token: %w", err)
|
||||
}
|
||||
|
||||
refreshToken, err := s.jwtService.GenerateRefreshToken(userID, role)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to generate refresh token: %w", err)
|
||||
}
|
||||
|
||||
// Validate the refresh token to get its expiry time
|
||||
claims, err := s.jwtService.ValidateToken(refreshToken)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to validate refresh token: %w", err)
|
||||
}
|
||||
|
||||
// Create a new session record
|
||||
session := &Session{
|
||||
ID: uuid.New().String(),
|
||||
UserID: userID,
|
||||
RefreshToken: refreshToken,
|
||||
ExpiresAt: claims.ExpiresAt.Time,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
// Store the session in the database
|
||||
_, err = s.db.Exec(`
|
||||
INSERT INTO sessions (id, user_id, refresh_token, expires_at, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
session.ID, session.UserID, session.RefreshToken, session.ExpiresAt, session.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to store session: %w", err)
|
||||
}
|
||||
|
||||
return session, accessToken, nil
|
||||
}
|
||||
|
||||
// RefreshSession creates a new access token using a refresh token
|
||||
// Parameters:
|
||||
// - refreshToken: the refresh token to use
|
||||
// Returns:
|
||||
// - string: a new access token
|
||||
// - error: any error that occurred
|
||||
func (s *SessionService) RefreshSession(refreshToken string) (string, error) {
|
||||
// Validate the refresh token
|
||||
claims, err := s.jwtService.ValidateToken(refreshToken)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid refresh token: %w", err)
|
||||
}
|
||||
|
||||
// Check if the session exists and is not expired
|
||||
var session Session
|
||||
err = s.db.QueryRow(`
|
||||
SELECT id, user_id, refresh_token, expires_at, created_at
|
||||
FROM sessions
|
||||
WHERE refresh_token = ? AND expires_at > ?`,
|
||||
refreshToken, time.Now(),
|
||||
).Scan(&session.ID, &session.UserID, &session.RefreshToken, &session.ExpiresAt, &session.CreatedAt)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return "", fmt.Errorf("session not found or expired")
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to fetch session: %w", err)
|
||||
}
|
||||
|
||||
// Generate a new access token
|
||||
return s.jwtService.GenerateAccessToken(claims.UserID, claims.Role)
|
||||
}
|
||||
|
||||
// InvalidateSession removes a session from the database
|
||||
// Parameters:
|
||||
// - sessionID: the ID of the session to invalidate
|
||||
// Returns:
|
||||
// - error: any error that occurred
|
||||
func (s *SessionService) InvalidateSession(sessionID string) error {
|
||||
_, err := s.db.Exec("DELETE FROM sessions WHERE id = ?", sessionID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to invalidate session: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CleanExpiredSessions removes all expired sessions from the database
|
||||
// Returns:
|
||||
// - error: any error that occurred
|
||||
func (s *SessionService) CleanExpiredSessions() error {
|
||||
_, err := s.db.Exec("DELETE FROM sessions WHERE expires_at <= ?", time.Now())
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to clean expired sessions: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
124
server/internal/config/config.go
Normal file
124
server/internal/config/config.go
Normal file
@@ -0,0 +1,124 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"novamd/internal/crypto"
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func DefaultConfig() *Config {
|
||||
return &Config{
|
||||
DBPath: "./novamd.db",
|
||||
WorkDir: "./data",
|
||||
StaticPath: "../app/dist",
|
||||
Port: "8080",
|
||||
RateLimitRequests: 100,
|
||||
RateLimitWindow: time.Minute * 15,
|
||||
IsDevelopment: false,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Config) Validate() error {
|
||||
if c.AdminEmail == "" || c.AdminPassword == "" {
|
||||
return fmt.Errorf("NOVAMD_ADMIN_EMAIL and NOVAMD_ADMIN_PASSWORD must be set")
|
||||
}
|
||||
|
||||
// Validate encryption key
|
||||
if err := crypto.ValidateKey(c.EncryptionKey); err != nil {
|
||||
return fmt.Errorf("invalid NOVAMD_ENCRYPTION_KEY: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Load creates a new Config instance with values from environment variables
|
||||
func Load() (*Config, error) {
|
||||
config := DefaultConfig()
|
||||
|
||||
if env := os.Getenv("NOVAMD_ENV"); env != "" {
|
||||
config.IsDevelopment = env == "development"
|
||||
}
|
||||
|
||||
if dbPath := os.Getenv("NOVAMD_DB_PATH"); dbPath != "" {
|
||||
config.DBPath = dbPath
|
||||
}
|
||||
if err := ensureDir(filepath.Dir(config.DBPath)); err != nil {
|
||||
return nil, fmt.Errorf("failed to create database directory: %w", err)
|
||||
}
|
||||
|
||||
if workDir := os.Getenv("NOVAMD_WORKDIR"); workDir != "" {
|
||||
config.WorkDir = workDir
|
||||
}
|
||||
if err := ensureDir(config.WorkDir); err != nil {
|
||||
return nil, fmt.Errorf("failed to create work directory: %w", err)
|
||||
}
|
||||
|
||||
if staticPath := os.Getenv("NOVAMD_STATIC_PATH"); staticPath != "" {
|
||||
config.StaticPath = staticPath
|
||||
}
|
||||
|
||||
if port := os.Getenv("NOVAMD_PORT"); port != "" {
|
||||
config.Port = port
|
||||
}
|
||||
|
||||
if appURL := os.Getenv("NOVAMD_APP_URL"); appURL != "" {
|
||||
config.AppURL = appURL
|
||||
}
|
||||
|
||||
if corsOrigins := os.Getenv("NOVAMD_CORS_ORIGINS"); corsOrigins != "" {
|
||||
config.CORSOrigins = strings.Split(corsOrigins, ",")
|
||||
}
|
||||
|
||||
config.AdminEmail = os.Getenv("NOVAMD_ADMIN_EMAIL")
|
||||
config.AdminPassword = os.Getenv("NOVAMD_ADMIN_PASSWORD")
|
||||
config.EncryptionKey = os.Getenv("NOVAMD_ENCRYPTION_KEY")
|
||||
config.JWTSigningKey = os.Getenv("NOVAMD_JWT_SIGNING_KEY")
|
||||
|
||||
// Configure rate limiting
|
||||
if reqStr := os.Getenv("NOVAMD_RATE_LIMIT_REQUESTS"); reqStr != "" {
|
||||
if parsed, err := strconv.Atoi(reqStr); err == nil {
|
||||
config.RateLimitRequests = parsed
|
||||
}
|
||||
}
|
||||
|
||||
if windowStr := os.Getenv("NOVAMD_RATE_LIMIT_WINDOW"); windowStr != "" {
|
||||
if parsed, err := time.ParseDuration(windowStr); err == nil {
|
||||
config.RateLimitWindow = parsed
|
||||
}
|
||||
}
|
||||
|
||||
// Validate all settings
|
||||
if err := config.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func ensureDir(dir string) error {
|
||||
if dir == "" {
|
||||
return nil
|
||||
}
|
||||
return os.MkdirAll(dir, 0755)
|
||||
}
|
||||
114
server/internal/crypto/crypto.go
Normal file
114
server/internal/crypto/crypto.go
Normal file
@@ -0,0 +1,114 @@
|
||||
package crypto
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrKeyRequired = fmt.Errorf("encryption key is required")
|
||||
ErrInvalidKeySize = fmt.Errorf("encryption key must be 32 bytes (256 bits) when decoded")
|
||||
)
|
||||
|
||||
type Crypto struct {
|
||||
key []byte
|
||||
}
|
||||
|
||||
// ValidateKey checks if the provided key is suitable for AES-256
|
||||
func ValidateKey(key string) error {
|
||||
if key == "" {
|
||||
return ErrKeyRequired
|
||||
}
|
||||
|
||||
// Attempt to decode base64
|
||||
keyBytes, err := base64.StdEncoding.DecodeString(key)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid base64 encoding: %w", err)
|
||||
}
|
||||
|
||||
if len(keyBytes) != 32 {
|
||||
return fmt.Errorf("%w: got %d bytes", ErrInvalidKeySize, len(keyBytes))
|
||||
}
|
||||
|
||||
// Verify the key can be used for AES
|
||||
_, err = aes.NewCipher(keyBytes)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid encryption key: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// New creates a new Crypto instance with the provided base64-encoded key
|
||||
func New(key string) (*Crypto, error) {
|
||||
if err := ValidateKey(key); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
keyBytes, _ := base64.StdEncoding.DecodeString(key)
|
||||
return &Crypto{key: keyBytes}, nil
|
||||
}
|
||||
|
||||
// Encrypt encrypts the plaintext using AES-256-GCM
|
||||
func (c *Crypto) Encrypt(plaintext string) (string, error) {
|
||||
if plaintext == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
block, err := aes.NewCipher(c.key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
|
||||
return base64.StdEncoding.EncodeToString(ciphertext), nil
|
||||
}
|
||||
|
||||
// Decrypt decrypts the ciphertext using AES-256-GCM
|
||||
func (c *Crypto) Decrypt(ciphertext string) (string, error) {
|
||||
if ciphertext == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
data, err := base64.StdEncoding.DecodeString(ciphertext)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
block, err := aes.NewCipher(c.key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
nonceSize := gcm.NonceSize()
|
||||
if len(data) < nonceSize {
|
||||
return "", fmt.Errorf("ciphertext too short")
|
||||
}
|
||||
|
||||
nonce, ciphertextBytes := data[:nonceSize], data[nonceSize:]
|
||||
plaintext, err := gcm.Open(nil, nonce, ciphertextBytes, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(plaintext), nil
|
||||
}
|
||||
68
server/internal/db/admin.go
Normal file
68
server/internal/db/admin.go
Normal file
@@ -0,0 +1,68 @@
|
||||
// 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.
|
||||
package db
|
||||
|
||||
import "novamd/internal/models"
|
||||
|
||||
// UserStats represents system-wide statistics
|
||||
type UserStats struct {
|
||||
TotalUsers int `json:"totalUsers"`
|
||||
TotalWorkspaces int `json:"totalWorkspaces"`
|
||||
ActiveUsers int `json:"activeUsers"` // Users with activity in last 30 days
|
||||
}
|
||||
|
||||
// GetAllUsers returns a list of all users in the system
|
||||
func (db *DB) GetAllUsers() ([]*models.User, error) {
|
||||
rows, err := db.Query(`
|
||||
SELECT
|
||||
id, email, display_name, role, created_at,
|
||||
last_workspace_id
|
||||
FROM users
|
||||
ORDER BY id ASC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var users []*models.User
|
||||
for rows.Next() {
|
||||
user := &models.User{}
|
||||
err := rows.Scan(
|
||||
&user.ID, &user.Email, &user.DisplayName, &user.Role,
|
||||
&user.CreatedAt, &user.LastWorkspaceID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
users = append(users, user)
|
||||
}
|
||||
return users, nil
|
||||
}
|
||||
|
||||
// GetSystemStats returns system-wide statistics
|
||||
func (db *DB) GetSystemStats() (*UserStats, error) {
|
||||
stats := &UserStats{}
|
||||
|
||||
// Get total users
|
||||
err := db.QueryRow("SELECT COUNT(*) FROM users").Scan(&stats.TotalUsers)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Get total workspaces
|
||||
err = db.QueryRow("SELECT COUNT(*) FROM workspaces").Scan(&stats.TotalWorkspaces)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Get active users (users with activity in last 30 days)
|
||||
err = db.QueryRow(`
|
||||
SELECT COUNT(DISTINCT user_id)
|
||||
FROM sessions
|
||||
WHERE created_at > datetime('now', '-30 days')`).
|
||||
Scan(&stats.ActiveUsers)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
65
server/internal/db/db.go
Normal file
65
server/internal/db/db.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
"novamd/internal/crypto"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3" // SQLite driver
|
||||
)
|
||||
|
||||
// DB represents the database connection
|
||||
type DB struct {
|
||||
*sql.DB
|
||||
crypto *crypto.Crypto
|
||||
}
|
||||
|
||||
// Init initializes the database connection
|
||||
func Init(dbPath string, encryptionKey string) (*DB, error) {
|
||||
db, err := sql.Open("sqlite3", dbPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := db.Ping(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Initialize crypto service
|
||||
cryptoService, err := crypto.New(encryptionKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize encryption: %w", err)
|
||||
}
|
||||
|
||||
database := &DB{
|
||||
DB: db,
|
||||
crypto: cryptoService,
|
||||
}
|
||||
|
||||
if err := database.Migrate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return database, nil
|
||||
}
|
||||
|
||||
// Close closes the database connection
|
||||
func (db *DB) Close() error {
|
||||
return db.DB.Close()
|
||||
}
|
||||
|
||||
// Helper methods for token encryption/decryption
|
||||
func (db *DB) encryptToken(token string) (string, error) {
|
||||
if token == "" {
|
||||
return "", nil
|
||||
}
|
||||
return db.crypto.Encrypt(token)
|
||||
}
|
||||
|
||||
func (db *DB) decryptToken(token string) (string, error) {
|
||||
if token == "" {
|
||||
return "", nil
|
||||
}
|
||||
return db.crypto.Decrypt(token)
|
||||
}
|
||||
139
server/internal/db/migrations.go
Normal file
139
server/internal/db/migrations.go
Normal file
@@ -0,0 +1,139 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
)
|
||||
|
||||
// Migration represents a database migration
|
||||
type Migration struct {
|
||||
Version int
|
||||
SQL string
|
||||
}
|
||||
|
||||
var migrations = []Migration{
|
||||
{
|
||||
Version: 1,
|
||||
SQL: `
|
||||
-- Create users table
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
display_name TEXT,
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL CHECK(role IN ('admin', 'editor', 'viewer')),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
last_workspace_id INTEGER
|
||||
);
|
||||
|
||||
-- Create workspaces table with integrated settings
|
||||
CREATE TABLE IF NOT EXISTS workspaces (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
last_opened_file_path TEXT,
|
||||
-- Settings fields
|
||||
theme TEXT NOT NULL DEFAULT 'light' CHECK(theme IN ('light', 'dark')),
|
||||
auto_save BOOLEAN NOT NULL DEFAULT 0,
|
||||
git_enabled BOOLEAN NOT NULL DEFAULT 0,
|
||||
git_url TEXT,
|
||||
git_user TEXT,
|
||||
git_token TEXT,
|
||||
git_auto_commit BOOLEAN NOT NULL DEFAULT 0,
|
||||
git_commit_msg_template TEXT DEFAULT '${action} ${filename}',
|
||||
FOREIGN KEY (user_id) REFERENCES users (id)
|
||||
);
|
||||
`,
|
||||
},
|
||||
{
|
||||
Version: 2,
|
||||
SQL: `
|
||||
-- Create sessions table for authentication
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL,
|
||||
refresh_token TEXT NOT NULL,
|
||||
expires_at TIMESTAMP NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Add show_hidden_files field to workspaces
|
||||
ALTER TABLE workspaces ADD COLUMN show_hidden_files BOOLEAN NOT NULL DEFAULT 0;
|
||||
|
||||
-- Add indexes for performance
|
||||
CREATE INDEX idx_sessions_user_id ON sessions(user_id);
|
||||
CREATE INDEX idx_sessions_expires_at ON sessions(expires_at);
|
||||
CREATE INDEX idx_sessions_refresh_token ON sessions(refresh_token);
|
||||
|
||||
-- Add audit fields to workspaces
|
||||
ALTER TABLE workspaces ADD COLUMN created_by INTEGER REFERENCES users(id);
|
||||
ALTER TABLE workspaces ADD COLUMN updated_by INTEGER REFERENCES users(id);
|
||||
ALTER TABLE workspaces ADD COLUMN updated_at TIMESTAMP;
|
||||
|
||||
-- Create system_settings table for application settings
|
||||
CREATE TABLE IF NOT EXISTS system_settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);`,
|
||||
},
|
||||
}
|
||||
|
||||
// Migrate applies all database migrations
|
||||
func (db *DB) Migrate() error {
|
||||
// Create migrations table if it doesn't exist
|
||||
_, err := db.Exec(`CREATE TABLE IF NOT EXISTS migrations (
|
||||
version INTEGER PRIMARY KEY
|
||||
)`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get current version
|
||||
var currentVersion int
|
||||
err = db.QueryRow("SELECT COALESCE(MAX(version), 0) FROM migrations").Scan(¤tVersion)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Apply new migrations
|
||||
for _, migration := range migrations {
|
||||
if migration.Version > currentVersion {
|
||||
log.Printf("Applying migration %d", migration.Version)
|
||||
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = tx.Exec(migration.SQL)
|
||||
if err != nil {
|
||||
if rbErr := tx.Rollback(); rbErr != nil {
|
||||
return fmt.Errorf("migration %d failed: %v, rollback failed: %v", migration.Version, err, rbErr)
|
||||
}
|
||||
return fmt.Errorf("migration %d failed: %v", migration.Version, err)
|
||||
}
|
||||
|
||||
_, err = tx.Exec("INSERT INTO migrations (version) VALUES (?)", migration.Version)
|
||||
if err != nil {
|
||||
if rbErr := tx.Rollback(); rbErr != nil {
|
||||
return fmt.Errorf("failed to update migration version: %v, rollback failed: %v", err, rbErr)
|
||||
}
|
||||
return fmt.Errorf("failed to update migration version: %v", err)
|
||||
}
|
||||
|
||||
err = tx.Commit()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to commit migration %d: %v", migration.Version, err)
|
||||
}
|
||||
|
||||
currentVersion = migration.Version
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("Database is at version %d", currentVersion)
|
||||
return nil
|
||||
}
|
||||
66
server/internal/db/system_settings.go
Normal file
66
server/internal/db/system_settings.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
const (
|
||||
// JWTSecretKey is the key for the JWT secret in the system settings
|
||||
JWTSecretKey = "jwt_secret"
|
||||
)
|
||||
|
||||
// EnsureJWTSecret makes sure a JWT signing secret exists in the database
|
||||
// If no secret exists, it generates and stores a new one
|
||||
func (db *DB) EnsureJWTSecret() (string, error) {
|
||||
// First, try to get existing secret
|
||||
secret, err := db.GetSystemSetting(JWTSecretKey)
|
||||
if err == nil {
|
||||
return secret, nil
|
||||
}
|
||||
|
||||
// Generate new secret if none exists
|
||||
newSecret, err := generateRandomSecret(32) // 256 bits
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to generate JWT secret: %w", err)
|
||||
}
|
||||
|
||||
// Store the new secret
|
||||
err = db.SetSystemSetting(JWTSecretKey, newSecret)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to store JWT secret: %w", err)
|
||||
}
|
||||
|
||||
return newSecret, nil
|
||||
}
|
||||
|
||||
// GetSystemSetting retrieves a system setting by key
|
||||
func (db *DB) GetSystemSetting(key string) (string, error) {
|
||||
var value string
|
||||
err := db.QueryRow("SELECT value FROM system_settings WHERE key = ?", key).Scan(&value)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
// SetSystemSetting stores or updates a system setting
|
||||
func (db *DB) SetSystemSetting(key, value string) error {
|
||||
_, err := db.Exec(`
|
||||
INSERT INTO system_settings (key, value)
|
||||
VALUES (?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = ?`,
|
||||
key, value, value)
|
||||
return err
|
||||
}
|
||||
|
||||
// generateRandomSecret generates a cryptographically secure random string
|
||||
func generateRandomSecret(bytes int) (string, error) {
|
||||
b := make([]byte, bytes)
|
||||
_, err := rand.Read(b)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(b), nil
|
||||
}
|
||||
191
server/internal/db/users.go
Normal file
191
server/internal/db/users.go
Normal file
@@ -0,0 +1,191 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"novamd/internal/models"
|
||||
)
|
||||
|
||||
// CreateUser inserts a new user record into the database
|
||||
func (db *DB) CreateUser(user *models.User) (*models.User, error) {
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
result, err := tx.Exec(`
|
||||
INSERT INTO users (email, display_name, password_hash, role)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
user.Email, user.DisplayName, user.PasswordHash, user.Role)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
userID, err := result.LastInsertId()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
user.ID = int(userID)
|
||||
|
||||
// Retrieve the created_at timestamp
|
||||
err = tx.QueryRow("SELECT created_at FROM users WHERE id = ?", user.ID).Scan(&user.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Create default workspace with default settings
|
||||
defaultWorkspace := &models.Workspace{
|
||||
UserID: user.ID,
|
||||
Name: "Main",
|
||||
}
|
||||
defaultWorkspace.GetDefaultSettings() // Initialize default settings
|
||||
|
||||
// Create workspace with settings
|
||||
err = db.createWorkspaceTx(tx, defaultWorkspace)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Update user's last workspace ID
|
||||
_, err = tx.Exec("UPDATE users SET last_workspace_id = ? WHERE id = ?", defaultWorkspace.ID, user.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = tx.Commit()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
user.LastWorkspaceID = defaultWorkspace.ID
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// Helper function to create a workspace in a transaction
|
||||
func (db *DB) createWorkspaceTx(tx *sql.Tx, workspace *models.Workspace) error {
|
||||
result, err := tx.Exec(`
|
||||
INSERT INTO workspaces (
|
||||
user_id, name,
|
||||
theme, auto_save, show_hidden_files,
|
||||
git_enabled, git_url, git_user, git_token,
|
||||
git_auto_commit, git_commit_msg_template
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
workspace.UserID, workspace.Name,
|
||||
workspace.Theme, workspace.AutoSave, workspace.ShowHiddenFiles,
|
||||
workspace.GitEnabled, workspace.GitURL, workspace.GitUser, workspace.GitToken,
|
||||
workspace.GitAutoCommit, workspace.GitCommitMsgTemplate,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := result.LastInsertId()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
workspace.ID = int(id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetUserByID retrieves a user by ID
|
||||
func (db *DB) GetUserByID(id int) (*models.User, error) {
|
||||
user := &models.User{}
|
||||
err := db.QueryRow(`
|
||||
SELECT
|
||||
id, email, display_name, password_hash, role, created_at,
|
||||
last_workspace_id
|
||||
FROM users
|
||||
WHERE id = ?`, id).
|
||||
Scan(&user.ID, &user.Email, &user.DisplayName, &user.PasswordHash, &user.Role, &user.CreatedAt,
|
||||
&user.LastWorkspaceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// GetUserByEmail retrieves a user by email
|
||||
func (db *DB) GetUserByEmail(email string) (*models.User, error) {
|
||||
user := &models.User{}
|
||||
err := db.QueryRow(`
|
||||
SELECT
|
||||
id, email, display_name, password_hash, role, created_at,
|
||||
last_workspace_id
|
||||
FROM users
|
||||
WHERE email = ?`, email).
|
||||
Scan(&user.ID, &user.Email, &user.DisplayName, &user.PasswordHash, &user.Role, &user.CreatedAt,
|
||||
&user.LastWorkspaceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// UpdateUser updates a user's information
|
||||
func (db *DB) UpdateUser(user *models.User) error {
|
||||
_, err := db.Exec(`
|
||||
UPDATE users
|
||||
SET email = ?, display_name = ?, password_hash = ?, role = ?, last_workspace_id = ?
|
||||
WHERE id = ?`,
|
||||
user.Email, user.DisplayName, user.PasswordHash, user.Role, user.LastWorkspaceID, user.ID)
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateLastWorkspace updates the last workspace the user accessed
|
||||
func (db *DB) UpdateLastWorkspace(userID int, workspaceName string) error {
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var workspaceID int
|
||||
|
||||
err = tx.QueryRow("SELECT id FROM workspaces WHERE user_id = ? AND name = ?", userID, workspaceName).Scan(&workspaceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = tx.Exec("UPDATE users SET last_workspace_id = ? WHERE id = ?", workspaceID, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// DeleteUser deletes a user and all their workspaces
|
||||
func (db *DB) DeleteUser(id int) error {
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// Delete all user's workspaces first
|
||||
_, err = tx.Exec("DELETE FROM workspaces WHERE user_id = ?", id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Delete the user
|
||||
_, err = tx.Exec("DELETE FROM users WHERE id = ?", id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// GetLastWorkspaceName returns the name of the last workspace the user accessed
|
||||
func (db *DB) GetLastWorkspaceName(userID int) (string, error) {
|
||||
var workspaceName string
|
||||
err := db.QueryRow(`
|
||||
SELECT
|
||||
w.name
|
||||
FROM workspaces w
|
||||
JOIN users u ON u.last_workspace_id = w.id
|
||||
WHERE u.id = ?`, userID).
|
||||
Scan(&workspaceName)
|
||||
return workspaceName, err
|
||||
}
|
||||
295
server/internal/db/workspaces.go
Normal file
295
server/internal/db/workspaces.go
Normal file
@@ -0,0 +1,295 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"novamd/internal/models"
|
||||
)
|
||||
|
||||
// CreateWorkspace inserts a new workspace record into the database
|
||||
func (db *DB) CreateWorkspace(workspace *models.Workspace) error {
|
||||
// Set default settings if not provided
|
||||
if workspace.Theme == "" {
|
||||
workspace.GetDefaultSettings()
|
||||
}
|
||||
|
||||
// Encrypt token if present
|
||||
encryptedToken, err := db.encryptToken(workspace.GitToken)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encrypt token: %w", err)
|
||||
}
|
||||
|
||||
result, err := db.Exec(`
|
||||
INSERT INTO workspaces (
|
||||
user_id, name, theme, auto_save, show_hidden_files,
|
||||
git_enabled, git_url, git_user, git_token,
|
||||
git_auto_commit, git_commit_msg_template
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
workspace.UserID, workspace.Name, workspace.Theme, workspace.AutoSave, workspace.ShowHiddenFiles,
|
||||
workspace.GitEnabled, workspace.GitURL, workspace.GitUser, encryptedToken,
|
||||
workspace.GitAutoCommit, workspace.GitCommitMsgTemplate,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
id, err := result.LastInsertId()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
workspace.ID = int(id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetWorkspaceByID retrieves a workspace by its ID
|
||||
func (db *DB) GetWorkspaceByID(id int) (*models.Workspace, error) {
|
||||
workspace := &models.Workspace{}
|
||||
var encryptedToken string
|
||||
|
||||
err := db.QueryRow(`
|
||||
SELECT
|
||||
id, user_id, name, created_at,
|
||||
theme, auto_save, show_hidden_files,
|
||||
git_enabled, git_url, git_user, git_token,
|
||||
git_auto_commit, git_commit_msg_template
|
||||
FROM workspaces
|
||||
WHERE id = ?`,
|
||||
id,
|
||||
).Scan(
|
||||
&workspace.ID, &workspace.UserID, &workspace.Name, &workspace.CreatedAt,
|
||||
&workspace.Theme, &workspace.AutoSave, &workspace.ShowHiddenFiles,
|
||||
&workspace.GitEnabled, &workspace.GitURL, &workspace.GitUser, &encryptedToken,
|
||||
&workspace.GitAutoCommit, &workspace.GitCommitMsgTemplate,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Decrypt token
|
||||
workspace.GitToken, err = db.decryptToken(encryptedToken)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decrypt token: %w", err)
|
||||
}
|
||||
|
||||
return workspace, nil
|
||||
}
|
||||
|
||||
// GetWorkspaceByName retrieves a workspace by its name and user ID
|
||||
func (db *DB) GetWorkspaceByName(userID int, workspaceName string) (*models.Workspace, error) {
|
||||
workspace := &models.Workspace{}
|
||||
var encryptedToken string
|
||||
|
||||
err := db.QueryRow(`
|
||||
SELECT
|
||||
id, user_id, name, created_at,
|
||||
theme, auto_save, show_hidden_files,
|
||||
git_enabled, git_url, git_user, git_token,
|
||||
git_auto_commit, git_commit_msg_template
|
||||
FROM workspaces
|
||||
WHERE user_id = ? AND name = ?`,
|
||||
userID, workspaceName,
|
||||
).Scan(
|
||||
&workspace.ID, &workspace.UserID, &workspace.Name, &workspace.CreatedAt,
|
||||
&workspace.Theme, &workspace.AutoSave, &workspace.ShowHiddenFiles,
|
||||
&workspace.GitEnabled, &workspace.GitURL, &workspace.GitUser, &encryptedToken,
|
||||
&workspace.GitAutoCommit, &workspace.GitCommitMsgTemplate,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Decrypt token
|
||||
workspace.GitToken, err = db.decryptToken(encryptedToken)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decrypt token: %w", err)
|
||||
}
|
||||
|
||||
return workspace, nil
|
||||
}
|
||||
|
||||
// UpdateWorkspace updates a workspace record in the database
|
||||
func (db *DB) UpdateWorkspace(workspace *models.Workspace) error {
|
||||
// Encrypt token before storing
|
||||
encryptedToken, err := db.encryptToken(workspace.GitToken)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encrypt token: %w", err)
|
||||
}
|
||||
|
||||
_, err = db.Exec(`
|
||||
UPDATE workspaces
|
||||
SET
|
||||
name = ?,
|
||||
theme = ?,
|
||||
auto_save = ?,
|
||||
show_hidden_files = ?,
|
||||
git_enabled = ?,
|
||||
git_url = ?,
|
||||
git_user = ?,
|
||||
git_token = ?,
|
||||
git_auto_commit = ?,
|
||||
git_commit_msg_template = ?
|
||||
WHERE id = ? AND user_id = ?`,
|
||||
workspace.Name,
|
||||
workspace.Theme,
|
||||
workspace.AutoSave,
|
||||
workspace.ShowHiddenFiles,
|
||||
workspace.GitEnabled,
|
||||
workspace.GitURL,
|
||||
workspace.GitUser,
|
||||
encryptedToken,
|
||||
workspace.GitAutoCommit,
|
||||
workspace.GitCommitMsgTemplate,
|
||||
workspace.ID,
|
||||
workspace.UserID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetWorkspacesByUserID retrieves all workspaces for a user
|
||||
func (db *DB) GetWorkspacesByUserID(userID int) ([]*models.Workspace, error) {
|
||||
rows, err := db.Query(`
|
||||
SELECT
|
||||
id, user_id, name, created_at,
|
||||
theme, auto_save, show_hidden_files,
|
||||
git_enabled, git_url, git_user, git_token,
|
||||
git_auto_commit, git_commit_msg_template
|
||||
FROM workspaces
|
||||
WHERE user_id = ?`,
|
||||
userID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var workspaces []*models.Workspace
|
||||
for rows.Next() {
|
||||
workspace := &models.Workspace{}
|
||||
var encryptedToken string
|
||||
err := rows.Scan(
|
||||
&workspace.ID, &workspace.UserID, &workspace.Name, &workspace.CreatedAt,
|
||||
&workspace.Theme, &workspace.AutoSave, &workspace.ShowHiddenFiles,
|
||||
&workspace.GitEnabled, &workspace.GitURL, &workspace.GitUser, &encryptedToken,
|
||||
&workspace.GitAutoCommit, &workspace.GitCommitMsgTemplate,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Decrypt token
|
||||
workspace.GitToken, err = db.decryptToken(encryptedToken)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decrypt token: %w", err)
|
||||
}
|
||||
|
||||
workspaces = append(workspaces, workspace)
|
||||
}
|
||||
return workspaces, nil
|
||||
}
|
||||
|
||||
// UpdateWorkspaceSettings updates only the settings portion of a workspace
|
||||
// This is useful when you don't want to modify the name or other core workspace properties
|
||||
func (db *DB) UpdateWorkspaceSettings(workspace *models.Workspace) error {
|
||||
_, err := db.Exec(`
|
||||
UPDATE workspaces
|
||||
SET
|
||||
theme = ?,
|
||||
auto_save = ?,
|
||||
show_hidden_files = ?,
|
||||
git_enabled = ?,
|
||||
git_url = ?,
|
||||
git_user = ?,
|
||||
git_token = ?,
|
||||
git_auto_commit = ?,
|
||||
git_commit_msg_template = ?
|
||||
WHERE id = ?`,
|
||||
workspace.Theme,
|
||||
workspace.AutoSave,
|
||||
workspace.ShowHiddenFiles,
|
||||
workspace.GitEnabled,
|
||||
workspace.GitURL,
|
||||
workspace.GitUser,
|
||||
workspace.GitToken,
|
||||
workspace.GitAutoCommit,
|
||||
workspace.GitCommitMsgTemplate,
|
||||
workspace.ID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteWorkspace removes a workspace record from the database
|
||||
func (db *DB) DeleteWorkspace(id int) error {
|
||||
_, err := db.Exec("DELETE FROM workspaces WHERE id = ?", id)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteWorkspaceTx removes a workspace record from the database within a transaction
|
||||
func (db *DB) DeleteWorkspaceTx(tx *sql.Tx, id int) error {
|
||||
_, err := tx.Exec("DELETE FROM workspaces WHERE id = ?", id)
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateLastWorkspaceTx sets the last workspace for a user in with a transaction
|
||||
func (db *DB) UpdateLastWorkspaceTx(tx *sql.Tx, userID, workspaceID int) error {
|
||||
_, err := tx.Exec("UPDATE users SET last_workspace_id = ? WHERE id = ?", workspaceID, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateLastOpenedFile updates the last opened file path for a workspace
|
||||
func (db *DB) UpdateLastOpenedFile(workspaceID int, filePath string) error {
|
||||
_, err := db.Exec("UPDATE workspaces SET last_opened_file_path = ? WHERE id = ?", filePath, workspaceID)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetLastOpenedFile retrieves the last opened file path for a workspace
|
||||
func (db *DB) GetLastOpenedFile(workspaceID int) (string, error) {
|
||||
var filePath sql.NullString
|
||||
err := db.QueryRow("SELECT last_opened_file_path FROM workspaces WHERE id = ?", workspaceID).Scan(&filePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !filePath.Valid {
|
||||
return "", nil
|
||||
}
|
||||
return filePath.String, nil
|
||||
}
|
||||
|
||||
// GetAllWorkspaces retrieves all workspaces in the database
|
||||
func (db *DB) GetAllWorkspaces() ([]*models.Workspace, error) {
|
||||
rows, err := db.Query(`
|
||||
SELECT
|
||||
id, user_id, name, created_at,
|
||||
theme, auto_save, show_hidden_files,
|
||||
git_enabled, git_url, git_user, git_token,
|
||||
git_auto_commit, git_commit_msg_template
|
||||
FROM workspaces`,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var workspaces []*models.Workspace
|
||||
for rows.Next() {
|
||||
workspace := &models.Workspace{}
|
||||
var encryptedToken string
|
||||
err := rows.Scan(
|
||||
&workspace.ID, &workspace.UserID, &workspace.Name, &workspace.CreatedAt,
|
||||
&workspace.Theme, &workspace.AutoSave, &workspace.ShowHiddenFiles,
|
||||
&workspace.GitEnabled, &workspace.GitURL, &workspace.GitUser, &encryptedToken,
|
||||
&workspace.GitAutoCommit, &workspace.GitCommitMsgTemplate,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Decrypt token
|
||||
workspace.GitToken, err = db.decryptToken(encryptedToken)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decrypt token: %w", err)
|
||||
}
|
||||
|
||||
workspaces = append(workspaces, workspace)
|
||||
}
|
||||
return workspaces, nil
|
||||
}
|
||||
218
server/internal/filesystem/files.go
Normal file
218
server/internal/filesystem/files.go
Normal file
@@ -0,0 +1,218 @@
|
||||
// Package filesystem provides functionalities to interact with the file system,
|
||||
// including listing files, finding files by name, getting file content, saving files, and deleting files.
|
||||
package filesystem
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// FileNode represents a file or directory in the file system.
|
||||
type FileNode struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
Children []FileNode `json:"children,omitempty"`
|
||||
}
|
||||
|
||||
// ListFilesRecursively returns a list of all files in the workspace directory and its subdirectories.
|
||||
func (fs *FileSystem) ListFilesRecursively(userID, workspaceID int) ([]FileNode, error) {
|
||||
workspacePath := fs.GetWorkspacePath(userID, workspaceID)
|
||||
return fs.walkDirectory(workspacePath, "")
|
||||
}
|
||||
|
||||
func (fs *FileSystem) walkDirectory(dir, prefix string) ([]FileNode, error) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Split entries into directories and files
|
||||
var dirs, files []os.DirEntry
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
dirs = append(dirs, entry)
|
||||
} else {
|
||||
files = append(files, entry)
|
||||
}
|
||||
}
|
||||
|
||||
// Sort directories and files separately
|
||||
sort.Slice(dirs, func(i, j int) bool {
|
||||
return strings.ToLower(dirs[i].Name()) < strings.ToLower(dirs[j].Name())
|
||||
})
|
||||
sort.Slice(files, func(i, j int) bool {
|
||||
return strings.ToLower(files[i].Name()) < strings.ToLower(files[j].Name())
|
||||
})
|
||||
|
||||
// Create combined slice with directories first, then files
|
||||
nodes := make([]FileNode, 0, len(entries))
|
||||
|
||||
// Add directories first
|
||||
for _, entry := range dirs {
|
||||
name := entry.Name()
|
||||
path := filepath.Join(prefix, name)
|
||||
fullPath := filepath.Join(dir, name)
|
||||
|
||||
children, err := fs.walkDirectory(fullPath, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
node := FileNode{
|
||||
ID: path,
|
||||
Name: name,
|
||||
Path: path,
|
||||
Children: children,
|
||||
}
|
||||
nodes = append(nodes, node)
|
||||
}
|
||||
|
||||
// Then add files
|
||||
for _, entry := range files {
|
||||
name := entry.Name()
|
||||
path := filepath.Join(prefix, name)
|
||||
|
||||
node := FileNode{
|
||||
ID: path,
|
||||
Name: name,
|
||||
Path: path,
|
||||
}
|
||||
nodes = append(nodes, node)
|
||||
}
|
||||
|
||||
return nodes, nil
|
||||
}
|
||||
|
||||
// FindFileByName returns a list of file paths that match the given filename.
|
||||
func (fs *FileSystem) FindFileByName(userID, workspaceID int, filename string) ([]string, error) {
|
||||
var foundPaths []string
|
||||
workspacePath := fs.GetWorkspacePath(userID, workspaceID)
|
||||
|
||||
err := filepath.Walk(workspacePath, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !info.IsDir() {
|
||||
relPath, err := filepath.Rel(workspacePath, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.EqualFold(info.Name(), filename) {
|
||||
foundPaths = append(foundPaths, relPath)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(foundPaths) == 0 {
|
||||
return nil, fmt.Errorf("file not found")
|
||||
}
|
||||
|
||||
return foundPaths, nil
|
||||
}
|
||||
|
||||
// GetFileContent returns the content of the file at the given path.
|
||||
func (fs *FileSystem) GetFileContent(userID, workspaceID int, filePath string) ([]byte, error) {
|
||||
fullPath, err := fs.ValidatePath(userID, workspaceID, filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return os.ReadFile(fullPath)
|
||||
}
|
||||
|
||||
// SaveFile writes the content to the file at the given path.
|
||||
func (fs *FileSystem) SaveFile(userID, workspaceID int, filePath string, content []byte) error {
|
||||
fullPath, err := fs.ValidatePath(userID, workspaceID, filePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dir := filepath.Dir(fullPath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(fullPath, content, 0644)
|
||||
}
|
||||
|
||||
// DeleteFile deletes the file at the given path.
|
||||
func (fs *FileSystem) DeleteFile(userID, workspaceID int, filePath string) error {
|
||||
fullPath, err := fs.ValidatePath(userID, workspaceID, filePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Remove(fullPath)
|
||||
}
|
||||
|
||||
// FileCountStats holds statistics about files in a workspace
|
||||
type FileCountStats struct {
|
||||
TotalFiles int `json:"totalFiles"`
|
||||
TotalSize int64 `json:"totalSize"`
|
||||
}
|
||||
|
||||
// GetFileStats returns the total number of files and related statistics in a workspace
|
||||
// Parameters:
|
||||
// - userID: the ID of the user who owns the workspace
|
||||
// - workspaceID: the ID of the workspace to count files in
|
||||
// Returns:
|
||||
// - result: statistics about the files in the workspace
|
||||
// - error: any error that occurred during counting
|
||||
func (fs *FileSystem) GetFileStats(userID, workspaceID int) (*FileCountStats, error) {
|
||||
workspacePath := fs.GetWorkspacePath(userID, workspaceID)
|
||||
|
||||
// Check if workspace exists
|
||||
if _, err := os.Stat(workspacePath); os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("workspace directory does not exist")
|
||||
}
|
||||
|
||||
return fs.countFilesInPath(workspacePath)
|
||||
|
||||
}
|
||||
|
||||
func (fs *FileSystem) countFilesInPath(directoryPath string) (*FileCountStats, error) {
|
||||
result := &FileCountStats{}
|
||||
|
||||
err := filepath.WalkDir(directoryPath, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Skip the .git directory
|
||||
if d.IsDir() && d.Name() == ".git" {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
|
||||
// Only count regular files
|
||||
if !d.IsDir() {
|
||||
// Get relative path from workspace root
|
||||
relPath, err := filepath.Rel(directoryPath, path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get relative path: %w", err)
|
||||
}
|
||||
|
||||
// Get file info for size
|
||||
info, err := d.Info()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get file info for %s: %w", relPath, err)
|
||||
}
|
||||
|
||||
result.TotalFiles++
|
||||
result.TotalSize += info.Size()
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error counting files: %w", err)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
40
server/internal/filesystem/filesystem.go
Normal file
40
server/internal/filesystem/filesystem.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package filesystem
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"novamd/internal/gitutils"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// FileSystem represents the file system structure.
|
||||
type FileSystem struct {
|
||||
RootDir string
|
||||
GitRepos map[int]map[int]*gitutils.GitRepo // map[userID]map[workspaceID]*gitutils.GitRepo
|
||||
}
|
||||
|
||||
// New creates a new FileSystem instance.
|
||||
func New(rootDir string) *FileSystem {
|
||||
return &FileSystem{
|
||||
RootDir: rootDir,
|
||||
GitRepos: make(map[int]map[int]*gitutils.GitRepo),
|
||||
}
|
||||
}
|
||||
|
||||
// ValidatePath validates the given path and returns the cleaned path if it is valid.
|
||||
func (fs *FileSystem) ValidatePath(userID, workspaceID int, path string) (string, error) {
|
||||
workspacePath := fs.GetWorkspacePath(userID, workspaceID)
|
||||
fullPath := filepath.Join(workspacePath, path)
|
||||
cleanPath := filepath.Clean(fullPath)
|
||||
|
||||
if !strings.HasPrefix(cleanPath, workspacePath) {
|
||||
return "", fmt.Errorf("invalid path: outside of workspace")
|
||||
}
|
||||
|
||||
return cleanPath, nil
|
||||
}
|
||||
|
||||
// GetTotalFileStats returns the total file statistics for the file system.
|
||||
func (fs *FileSystem) GetTotalFileStats() (*FileCountStats, error) {
|
||||
return fs.countFilesInPath(fs.RootDir)
|
||||
}
|
||||
60
server/internal/filesystem/git.go
Normal file
60
server/internal/filesystem/git.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package filesystem
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"novamd/internal/gitutils"
|
||||
)
|
||||
|
||||
// SetupGitRepo sets up a Git repository for the given user and workspace IDs.
|
||||
func (fs *FileSystem) SetupGitRepo(userID, workspaceID int, gitURL, gitUser, gitToken string) error {
|
||||
workspacePath := fs.GetWorkspacePath(userID, workspaceID)
|
||||
if _, ok := fs.GitRepos[userID]; !ok {
|
||||
fs.GitRepos[userID] = make(map[int]*gitutils.GitRepo)
|
||||
}
|
||||
fs.GitRepos[userID][workspaceID] = gitutils.New(gitURL, gitUser, gitToken, workspacePath)
|
||||
return fs.GitRepos[userID][workspaceID].EnsureRepo()
|
||||
}
|
||||
|
||||
// DisableGitRepo disables the Git repository for the given user and workspace IDs.
|
||||
func (fs *FileSystem) DisableGitRepo(userID, workspaceID int) {
|
||||
if userRepos, ok := fs.GitRepos[userID]; ok {
|
||||
delete(userRepos, workspaceID)
|
||||
if len(userRepos) == 0 {
|
||||
delete(fs.GitRepos, userID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// StageCommitAndPush stages, commits, and pushes the changes to the Git repository.
|
||||
func (fs *FileSystem) StageCommitAndPush(userID, workspaceID int, message string) error {
|
||||
repo, ok := fs.getGitRepo(userID, workspaceID)
|
||||
if !ok {
|
||||
return fmt.Errorf("git settings not configured for this workspace")
|
||||
}
|
||||
|
||||
if err := repo.Commit(message); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return repo.Push()
|
||||
}
|
||||
|
||||
// Pull pulls the changes from the remote Git repository.
|
||||
func (fs *FileSystem) Pull(userID, workspaceID int) error {
|
||||
repo, ok := fs.getGitRepo(userID, workspaceID)
|
||||
if !ok {
|
||||
return fmt.Errorf("git settings not configured for this workspace")
|
||||
}
|
||||
|
||||
return repo.Pull()
|
||||
}
|
||||
|
||||
// getGitRepo returns the Git repository for the given user and workspace IDs.
|
||||
func (fs *FileSystem) getGitRepo(userID, workspaceID int) (*gitutils.GitRepo, bool) {
|
||||
userRepos, ok := fs.GitRepos[userID]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
repo, ok := userRepos[workspaceID]
|
||||
return repo, ok
|
||||
}
|
||||
40
server/internal/filesystem/workspace.go
Normal file
40
server/internal/filesystem/workspace.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package filesystem
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// GetWorkspacePath returns the path to the workspace directory for the given user and workspace IDs.
|
||||
func (fs *FileSystem) GetWorkspacePath(userID, workspaceID int) string {
|
||||
return filepath.Join(fs.RootDir, fmt.Sprintf("%d", userID), fmt.Sprintf("%d", workspaceID))
|
||||
}
|
||||
|
||||
// InitializeUserWorkspace creates the workspace directory for the given user and workspace IDs.
|
||||
func (fs *FileSystem) InitializeUserWorkspace(userID, workspaceID int) error {
|
||||
workspacePath := fs.GetWorkspacePath(userID, workspaceID)
|
||||
err := os.MkdirAll(workspacePath, 0755)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create workspace directory: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteUserWorkspace deletes the workspace directory for the given user and workspace IDs.
|
||||
func (fs *FileSystem) DeleteUserWorkspace(userID, workspaceID int) error {
|
||||
workspacePath := fs.GetWorkspacePath(userID, workspaceID)
|
||||
err := os.RemoveAll(workspacePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete workspace directory: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateWorkspaceDirectory creates the workspace directory for the given user and workspace IDs.
|
||||
func (fs *FileSystem) CreateWorkspaceDirectory(userID, workspaceID int) error {
|
||||
dir := fs.GetWorkspacePath(userID, workspaceID)
|
||||
return os.MkdirAll(dir, 0755)
|
||||
}
|
||||
133
server/internal/gitutils/git.go
Normal file
133
server/internal/gitutils/git.go
Normal file
@@ -0,0 +1,133 @@
|
||||
package gitutils
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/go-git/go-git/v5"
|
||||
"github.com/go-git/go-git/v5/plumbing/transport/http"
|
||||
)
|
||||
|
||||
type GitRepo struct {
|
||||
URL string
|
||||
Username string
|
||||
Token string
|
||||
WorkDir string
|
||||
repo *git.Repository
|
||||
}
|
||||
|
||||
func New(url, username, token, workDir string) *GitRepo {
|
||||
return &GitRepo{
|
||||
URL: url,
|
||||
Username: username,
|
||||
Token: token,
|
||||
WorkDir: workDir,
|
||||
}
|
||||
}
|
||||
|
||||
func (g *GitRepo) Clone() error {
|
||||
auth := &http.BasicAuth{
|
||||
Username: g.Username,
|
||||
Password: g.Token,
|
||||
}
|
||||
|
||||
var err error
|
||||
g.repo, err = git.PlainClone(g.WorkDir, false, &git.CloneOptions{
|
||||
URL: g.URL,
|
||||
Auth: auth,
|
||||
Progress: os.Stdout,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to clone repository: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *GitRepo) Pull() error {
|
||||
if g.repo == nil {
|
||||
return fmt.Errorf("repository not initialized")
|
||||
}
|
||||
|
||||
w, err := g.repo.Worktree()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get worktree: %w", err)
|
||||
}
|
||||
|
||||
auth := &http.BasicAuth{
|
||||
Username: g.Username,
|
||||
Password: g.Token,
|
||||
}
|
||||
|
||||
err = w.Pull(&git.PullOptions{
|
||||
Auth: auth,
|
||||
Progress: os.Stdout,
|
||||
})
|
||||
|
||||
if err != nil && err != git.NoErrAlreadyUpToDate {
|
||||
return fmt.Errorf("failed to pull changes: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *GitRepo) Commit(message string) error {
|
||||
if g.repo == nil {
|
||||
return fmt.Errorf("repository not initialized")
|
||||
}
|
||||
|
||||
w, err := g.repo.Worktree()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get worktree: %w", err)
|
||||
}
|
||||
|
||||
_, err = w.Add(".")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to add changes: %w", err)
|
||||
}
|
||||
|
||||
_, err = w.Commit(message, &git.CommitOptions{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to commit changes: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *GitRepo) Push() error {
|
||||
if g.repo == nil {
|
||||
return fmt.Errorf("repository not initialized")
|
||||
}
|
||||
|
||||
auth := &http.BasicAuth{
|
||||
Username: g.Username,
|
||||
Password: g.Token,
|
||||
}
|
||||
|
||||
err := g.repo.Push(&git.PushOptions{
|
||||
Auth: auth,
|
||||
Progress: os.Stdout,
|
||||
})
|
||||
|
||||
if err != nil && err != git.NoErrAlreadyUpToDate {
|
||||
return fmt.Errorf("failed to push changes: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *GitRepo) EnsureRepo() error {
|
||||
if _, err := os.Stat(filepath.Join(g.WorkDir, ".git")); os.IsNotExist(err) {
|
||||
return g.Clone()
|
||||
}
|
||||
|
||||
var err error
|
||||
g.repo, err = git.PlainOpen(g.WorkDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open existing repository: %w", err)
|
||||
}
|
||||
|
||||
return g.Pull()
|
||||
}
|
||||
294
server/internal/handlers/admin_handlers.go
Normal file
294
server/internal/handlers/admin_handlers.go
Normal file
@@ -0,0 +1,294 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"novamd/internal/db"
|
||||
"novamd/internal/filesystem"
|
||||
"novamd/internal/httpcontext"
|
||||
"novamd/internal/models"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type createUserRequest struct {
|
||||
Email string `json:"email"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Password string `json:"password"`
|
||||
Role models.UserRole `json:"role"`
|
||||
}
|
||||
|
||||
type updateUserRequest struct {
|
||||
Email string `json:"email,omitempty"`
|
||||
DisplayName string `json:"displayName,omitempty"`
|
||||
Password string `json:"password,omitempty"`
|
||||
Role models.UserRole `json:"role,omitempty"`
|
||||
}
|
||||
|
||||
// AdminListUsers returns a list of all users
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, users)
|
||||
}
|
||||
}
|
||||
|
||||
// AdminCreateUser creates a new user
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate request
|
||||
if req.Email == "" || req.Password == "" || req.Role == "" {
|
||||
http.Error(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)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if password is long enough
|
||||
if len(req.Password) < 8 {
|
||||
http.Error(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)
|
||||
return
|
||||
}
|
||||
|
||||
// Create user
|
||||
user := &models.User{
|
||||
Email: req.Email,
|
||||
DisplayName: req.DisplayName,
|
||||
PasswordHash: string(hashedPassword),
|
||||
Role: req.Role,
|
||||
}
|
||||
|
||||
insertedUser, err := h.DB.CreateUser(user)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to create user", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Initialize user workspace
|
||||
if err := h.FS.InitializeUserWorkspace(insertedUser.ID, insertedUser.LastWorkspaceID); err != nil {
|
||||
http.Error(w, "Failed to initialize user workspace", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, insertedUser)
|
||||
}
|
||||
}
|
||||
|
||||
// AdminGetUser gets a specific user by ID
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.DB.GetUserByID(userID)
|
||||
if err != nil {
|
||||
http.Error(w, "User not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, user)
|
||||
}
|
||||
}
|
||||
|
||||
// AdminUpdateUser updates a specific user
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
||||
// Get existing user
|
||||
user, err := h.DB.GetUserByID(userID)
|
||||
if err != nil {
|
||||
http.Error(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)
|
||||
return
|
||||
}
|
||||
|
||||
// Update fields if provided
|
||||
if req.Email != "" {
|
||||
user.Email = req.Email
|
||||
}
|
||||
if req.DisplayName != "" {
|
||||
user.DisplayName = req.DisplayName
|
||||
}
|
||||
if req.Role != "" {
|
||||
user.Role = req.Role
|
||||
}
|
||||
if req.Password != "" {
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
http.Error(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)
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, user)
|
||||
}
|
||||
}
|
||||
|
||||
// AdminDeleteUser deletes a specific user
|
||||
func (h *Handler) AdminDeleteUser() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, ok := httpcontext.GetRequestContext(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
userID, err := strconv.Atoi(chi.URLParam(r, "userId"))
|
||||
if err != nil {
|
||||
http.Error(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)
|
||||
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)
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.DB.DeleteUser(userID); err != nil {
|
||||
http.Error(w, "Failed to delete user", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
// 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"`
|
||||
*filesystem.FileCountStats
|
||||
}
|
||||
|
||||
// AdminListWorkspaces returns a list of all workspaces and their stats
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
||||
workspacesStats := make([]*WorkspaceStats, 0, len(workspaces))
|
||||
|
||||
for _, ws := range workspaces {
|
||||
|
||||
workspaceData := &WorkspaceStats{}
|
||||
|
||||
user, err := h.DB.GetUserByID(ws.UserID)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to get user", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
workspaceData.UserID = ws.UserID
|
||||
workspaceData.UserEmail = user.Email
|
||||
workspaceData.WorkspaceID = ws.ID
|
||||
workspaceData.WorkspaceName = ws.Name
|
||||
workspaceData.WorkspaceCreatedAt = ws.CreatedAt
|
||||
|
||||
fileStats, err := h.FS.GetFileStats(ws.UserID, ws.ID)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to get file stats", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
workspaceData.FileCountStats = fileStats
|
||||
|
||||
workspacesStats = append(workspacesStats, workspaceData)
|
||||
}
|
||||
|
||||
respondJSON(w, workspacesStats)
|
||||
}
|
||||
}
|
||||
|
||||
// SystemStats holds system-wide statistics
|
||||
type SystemStats struct {
|
||||
*db.UserStats
|
||||
*filesystem.FileCountStats
|
||||
}
|
||||
|
||||
// AdminGetSystemStats returns system-wide statistics for admins
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
||||
fileStats, err := h.FS.GetTotalFileStats()
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to get file stats", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
stats := &SystemStats{
|
||||
UserStats: userStats,
|
||||
FileCountStats: fileStats,
|
||||
}
|
||||
|
||||
respondJSON(w, stats)
|
||||
}
|
||||
}
|
||||
146
server/internal/handlers/auth_handlers.go
Normal file
146
server/internal/handlers/auth_handlers.go
Normal file
@@ -0,0 +1,146 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"novamd/internal/auth"
|
||||
"novamd/internal/httpcontext"
|
||||
"novamd/internal/models"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type LoginRequest struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type LoginResponse struct {
|
||||
AccessToken string `json:"accessToken"`
|
||||
RefreshToken string `json:"refreshToken"`
|
||||
User *models.User `json:"user"`
|
||||
Session *auth.Session `json:"session"`
|
||||
}
|
||||
|
||||
type RefreshRequest struct {
|
||||
RefreshToken string `json:"refreshToken"`
|
||||
}
|
||||
|
||||
type RefreshResponse struct {
|
||||
AccessToken string `json:"accessToken"`
|
||||
}
|
||||
|
||||
// Login handles user authentication and returns JWT tokens
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate request
|
||||
if req.Email == "" || req.Password == "" {
|
||||
http.Error(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)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify password
|
||||
err = bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password))
|
||||
if err != nil {
|
||||
http.Error(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)
|
||||
return
|
||||
}
|
||||
|
||||
// Prepare response
|
||||
response := LoginResponse{
|
||||
AccessToken: accessToken,
|
||||
RefreshToken: session.RefreshToken,
|
||||
User: user,
|
||||
Session: session,
|
||||
}
|
||||
|
||||
respondJSON(w, response)
|
||||
}
|
||||
}
|
||||
|
||||
// Logout invalidates the user's session
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
||||
err := authService.InvalidateSession(sessionID)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to logout", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
// RefreshToken generates a new access token using a refresh token
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
||||
if req.RefreshToken == "" {
|
||||
http.Error(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)
|
||||
return
|
||||
}
|
||||
|
||||
response := RefreshResponse{
|
||||
AccessToken: accessToken,
|
||||
}
|
||||
|
||||
respondJSON(w, response)
|
||||
}
|
||||
}
|
||||
|
||||
// GetCurrentUser returns the currently authenticated user
|
||||
func (h *Handler) GetCurrentUser() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, ok := httpcontext.GetRequestContext(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// Get user from database
|
||||
user, err := h.DB.GetUserByID(ctx.UserID)
|
||||
if err != nil {
|
||||
http.Error(w, "User not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, user)
|
||||
}
|
||||
}
|
||||
168
server/internal/handlers/file_handlers.go
Normal file
168
server/internal/handlers/file_handlers.go
Normal file
@@ -0,0 +1,168 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"novamd/internal/httpcontext"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
func (h *Handler) ListFiles() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, ok := httpcontext.GetRequestContext(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
files, err := h.FS.ListFilesRecursively(ctx.UserID, ctx.Workspace.ID)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to list files", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, files)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) LookupFileByName() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, ok := httpcontext.GetRequestContext(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
filename := r.URL.Query().Get("filename")
|
||||
if filename == "" {
|
||||
http.Error(w, "Filename is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
filePaths, err := h.FS.FindFileByName(ctx.UserID, ctx.Workspace.ID, filename)
|
||||
if err != nil {
|
||||
http.Error(w, "File not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, map[string][]string{"paths": filePaths})
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) GetFileContent() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, ok := httpcontext.GetRequestContext(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
filePath := chi.URLParam(r, "*")
|
||||
content, err := h.FS.GetFileContent(ctx.UserID, ctx.Workspace.ID, filePath)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to read file", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
w.Write(content)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) SaveFile() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, ok := httpcontext.GetRequestContext(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
filePath := chi.URLParam(r, "*")
|
||||
content, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to read request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
err = h.FS.SaveFile(ctx.UserID, ctx.Workspace.ID, filePath, content)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to save file", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, map[string]string{"message": "File saved successfully"})
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) DeleteFile() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, ok := httpcontext.GetRequestContext(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
filePath := chi.URLParam(r, "*")
|
||||
err := h.FS.DeleteFile(ctx.UserID, ctx.Workspace.ID, filePath)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to delete file", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("File deleted successfully"))
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) GetLastOpenedFile() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, ok := httpcontext.GetRequestContext(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
filePath, err := h.DB.GetLastOpenedFile(ctx.Workspace.ID)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to get last opened file", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := h.FS.ValidatePath(ctx.UserID, ctx.Workspace.ID, filePath); err != nil {
|
||||
http.Error(w, "Invalid file path", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, map[string]string{"lastOpenedFilePath": filePath})
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateLastOpenedFile() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, ok := httpcontext.GetRequestContext(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var requestBody struct {
|
||||
FilePath string `json:"filePath"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
|
||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate the file path exists in the workspace
|
||||
if requestBody.FilePath != "" {
|
||||
if _, err := h.FS.ValidatePath(ctx.UserID, ctx.Workspace.ID, requestBody.FilePath); err != nil {
|
||||
http.Error(w, "Invalid file path", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := h.DB.UpdateLastOpenedFile(ctx.Workspace.ID, requestBody.FilePath); err != nil {
|
||||
http.Error(w, "Failed to update last opened file", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, map[string]string{"message": "Last opened file updated successfully"})
|
||||
}
|
||||
}
|
||||
56
server/internal/handlers/git_handlers.go
Normal file
56
server/internal/handlers/git_handlers.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"novamd/internal/httpcontext"
|
||||
)
|
||||
|
||||
func (h *Handler) StageCommitAndPush() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, ok := httpcontext.GetRequestContext(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var requestBody struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
|
||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if requestBody.Message == "" {
|
||||
http.Error(w, "Commit message is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
err := h.FS.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)
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, map[string]string{"message": "Changes staged, committed, and pushed successfully"})
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) PullChanges() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, ok := httpcontext.GetRequestContext(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
err := h.FS.Pull(ctx.UserID, ctx.Workspace.ID)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to pull changes: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, map[string]string{"message": "Pulled changes from remote"})
|
||||
}
|
||||
}
|
||||
30
server/internal/handlers/handlers.go
Normal file
30
server/internal/handlers/handlers.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"novamd/internal/db"
|
||||
"novamd/internal/filesystem"
|
||||
)
|
||||
|
||||
// Handler provides common functionality for all handlers
|
||||
type Handler struct {
|
||||
DB *db.DB
|
||||
FS *filesystem.FileSystem
|
||||
}
|
||||
|
||||
// NewHandler creates a new handler with the given dependencies
|
||||
func NewHandler(db *db.DB, fs *filesystem.FileSystem) *Handler {
|
||||
return &Handler{
|
||||
DB: db,
|
||||
FS: fs,
|
||||
}
|
||||
}
|
||||
|
||||
// respondJSON is a helper to send JSON responses
|
||||
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)
|
||||
}
|
||||
}
|
||||
70
server/internal/handlers/static_handler.go
Normal file
70
server/internal/handlers/static_handler.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// StaticHandler serves static files with support for SPA routing and pre-compressed files
|
||||
type StaticHandler struct {
|
||||
staticPath string
|
||||
}
|
||||
|
||||
func NewStaticHandler(staticPath string) *StaticHandler {
|
||||
return &StaticHandler{
|
||||
staticPath: staticPath,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *StaticHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
// Get the requested path
|
||||
requestedPath := r.URL.Path
|
||||
fullPath := filepath.Join(h.staticPath, requestedPath)
|
||||
cleanPath := filepath.Clean(fullPath)
|
||||
|
||||
// Security check to prevent directory traversal
|
||||
if !strings.HasPrefix(cleanPath, h.staticPath) {
|
||||
http.Error(w, "Invalid path", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Set cache headers for assets
|
||||
if strings.HasPrefix(requestedPath, "/assets/") {
|
||||
w.Header().Set("Cache-Control", "public, max-age=31536000") // 1 year
|
||||
}
|
||||
|
||||
// Check if file exists (not counting .gz files)
|
||||
stat, err := os.Stat(cleanPath)
|
||||
if err != nil || stat.IsDir() {
|
||||
// Serve index.html for SPA routing
|
||||
indexPath := filepath.Join(h.staticPath, "index.html")
|
||||
http.ServeFile(w, r, indexPath)
|
||||
return
|
||||
}
|
||||
|
||||
// Check for pre-compressed version
|
||||
if strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
|
||||
gzPath := cleanPath + ".gz"
|
||||
if _, err := os.Stat(gzPath); err == nil {
|
||||
w.Header().Set("Content-Encoding", "gzip")
|
||||
|
||||
// Set proper content type based on original file
|
||||
switch filepath.Ext(cleanPath) {
|
||||
case ".js":
|
||||
w.Header().Set("Content-Type", "application/javascript")
|
||||
case ".css":
|
||||
w.Header().Set("Content-Type", "text/css")
|
||||
case ".html":
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
}
|
||||
|
||||
http.ServeFile(w, r, gzPath)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Serve original file
|
||||
http.ServeFile(w, r, cleanPath)
|
||||
}
|
||||
222
server/internal/handlers/user_handlers.go
Normal file
222
server/internal/handlers/user_handlers.go
Normal file
@@ -0,0 +1,222 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"novamd/internal/httpcontext"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type UpdateProfileRequest struct {
|
||||
DisplayName string `json:"displayName"`
|
||||
Email string `json:"email"`
|
||||
CurrentPassword string `json:"currentPassword"`
|
||||
NewPassword string `json:"newPassword"`
|
||||
}
|
||||
|
||||
type DeleteAccountRequest struct {
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
func (h *Handler) GetUser() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, ok := httpcontext.GetRequestContext(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.DB.GetUserByID(ctx.UserID)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to get user", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, user)
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateProfile updates the current user's profile
|
||||
func (h *Handler) UpdateProfile() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, ok := httpcontext.GetRequestContext(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var req UpdateProfileRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(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)
|
||||
return
|
||||
}
|
||||
|
||||
// Start transaction for atomic updates
|
||||
tx, err := h.DB.Begin()
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to start transaction", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// Handle password update if requested
|
||||
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)
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate new password
|
||||
if len(req.NewPassword) < 8 {
|
||||
http.Error(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)
|
||||
return
|
||||
}
|
||||
user.PasswordHash = string(hashedPassword)
|
||||
}
|
||||
|
||||
// Handle email update if requested
|
||||
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)
|
||||
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)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
return
|
||||
}
|
||||
user.Email = req.Email
|
||||
}
|
||||
|
||||
// Update display name if provided (no password required)
|
||||
if req.DisplayName != "" {
|
||||
user.DisplayName = req.DisplayName
|
||||
}
|
||||
|
||||
// Update user in database
|
||||
if err := h.DB.UpdateUser(user); err != nil {
|
||||
http.Error(w, "Failed to update profile", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
http.Error(w, "Failed to commit changes", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Return updated user data
|
||||
respondJSON(w, user)
|
||||
}
|
||||
}
|
||||
|
||||
// DeleteAccount handles user account deletion
|
||||
func (h *Handler) DeleteAccount() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, ok := httpcontext.GetRequestContext(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var req DeleteAccountRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(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)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify password
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil {
|
||||
http.Error(w, "Password is incorrect", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Prevent admin from deleting their own account if they're the last admin
|
||||
if user.Role == "admin" {
|
||||
// Count number of admin users
|
||||
adminCount := 0
|
||||
err := h.DB.QueryRow("SELECT COUNT(*) FROM users WHERE role = 'admin'").Scan(&adminCount)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to verify admin status", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if adminCount <= 1 {
|
||||
http.Error(w, "Cannot delete the last admin account", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Start transaction for consistent deletion
|
||||
tx, err := h.DB.Begin()
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to start transaction", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// 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)
|
||||
return
|
||||
}
|
||||
|
||||
// Delete workspace directories
|
||||
for _, workspace := range workspaces {
|
||||
if err := h.FS.DeleteUserWorkspace(ctx.UserID, workspace.ID); err != nil {
|
||||
http.Error(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)
|
||||
return
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
http.Error(w, "Failed to commit transaction", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, map[string]string{"message": "Account deleted successfully"})
|
||||
}
|
||||
}
|
||||
240
server/internal/handlers/workspace_handlers.go
Normal file
240
server/internal/handlers/workspace_handlers.go
Normal file
@@ -0,0 +1,240 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"novamd/internal/httpcontext"
|
||||
"novamd/internal/models"
|
||||
)
|
||||
|
||||
func (h *Handler) ListWorkspaces() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, ok := httpcontext.GetRequestContext(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
workspaces, err := h.DB.GetWorkspacesByUserID(ctx.UserID)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to list workspaces", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, workspaces)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) CreateWorkspace() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, ok := httpcontext.GetRequestContext(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var workspace models.Workspace
|
||||
if err := json.NewDecoder(r.Body).Decode(&workspace); err != nil {
|
||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
workspace.UserID = ctx.UserID
|
||||
if err := h.DB.CreateWorkspace(&workspace); err != nil {
|
||||
http.Error(w, "Failed to create workspace", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.FS.InitializeUserWorkspace(workspace.UserID, workspace.ID); err != nil {
|
||||
http.Error(w, "Failed to initialize workspace directory", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, workspace)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) GetWorkspace() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, ok := httpcontext.GetRequestContext(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, ctx.Workspace)
|
||||
}
|
||||
}
|
||||
|
||||
func gitSettingsChanged(new, old *models.Workspace) bool {
|
||||
// Check if Git was enabled/disabled
|
||||
if new.GitEnabled != old.GitEnabled {
|
||||
return true
|
||||
}
|
||||
|
||||
// If Git is enabled, check if any settings changed
|
||||
if new.GitEnabled {
|
||||
return new.GitURL != old.GitURL ||
|
||||
new.GitUser != old.GitUser ||
|
||||
new.GitToken != old.GitToken
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateWorkspace() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, ok := httpcontext.GetRequestContext(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var workspace models.Workspace
|
||||
if err := json.NewDecoder(r.Body).Decode(&workspace); err != nil {
|
||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Set IDs from the request
|
||||
workspace.ID = ctx.Workspace.ID
|
||||
workspace.UserID = ctx.UserID
|
||||
|
||||
// Validate the workspace
|
||||
if err := workspace.Validate(); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Handle Git repository setup/teardown if Git settings changed
|
||||
if gitSettingsChanged(&workspace, ctx.Workspace) {
|
||||
if workspace.GitEnabled {
|
||||
if err := h.FS.SetupGitRepo(
|
||||
ctx.UserID,
|
||||
ctx.Workspace.ID,
|
||||
workspace.GitURL,
|
||||
workspace.GitUser,
|
||||
workspace.GitToken,
|
||||
); err != nil {
|
||||
http.Error(w, "Failed to setup git repo: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
} else {
|
||||
h.FS.DisableGitRepo(ctx.UserID, ctx.Workspace.ID)
|
||||
}
|
||||
}
|
||||
|
||||
if err := h.DB.UpdateWorkspace(&workspace); err != nil {
|
||||
http.Error(w, "Failed to update workspace", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, workspace)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) DeleteWorkspace() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, ok := httpcontext.GetRequestContext(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// 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)
|
||||
return
|
||||
}
|
||||
|
||||
if len(workspaces) <= 1 {
|
||||
http.Error(w, "Cannot delete the last workspace", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Find another workspace to set as last
|
||||
var nextWorkspaceName string
|
||||
var nextWorkspaceID int
|
||||
for _, ws := range workspaces {
|
||||
if ws.ID != ctx.Workspace.ID {
|
||||
nextWorkspaceID = ws.ID
|
||||
nextWorkspaceName = ws.Name
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Start transaction
|
||||
tx, err := h.DB.Begin()
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to start transaction", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// 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)
|
||||
return
|
||||
}
|
||||
|
||||
// Delete the workspace
|
||||
err = h.DB.DeleteWorkspaceTx(tx, ctx.Workspace.ID)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to delete workspace", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Commit transaction
|
||||
if err = tx.Commit(); err != nil {
|
||||
http.Error(w, "Failed to commit transaction", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Return the next workspace ID in the response so frontend knows where to redirect
|
||||
respondJSON(w, map[string]string{"nextWorkspaceName": nextWorkspaceName})
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) GetLastWorkspaceName() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, ok := httpcontext.GetRequestContext(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
workspaceName, err := h.DB.GetLastWorkspaceName(ctx.UserID)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to get last workspace", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, map[string]string{"lastWorkspaceName": workspaceName})
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateLastWorkspaceName() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, ok := httpcontext.GetRequestContext(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var requestBody struct {
|
||||
WorkspaceName string `json:"workspaceName"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
|
||||
fmt.Println(err)
|
||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.DB.UpdateLastWorkspace(ctx.UserID, requestBody.WorkspaceName); err != nil {
|
||||
fmt.Println(err)
|
||||
http.Error(w, "Failed to update last workspace", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, map[string]string{"message": "Last workspace updated successfully"})
|
||||
}
|
||||
}
|
||||
31
server/internal/httpcontext/context.go
Normal file
31
server/internal/httpcontext/context.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package httpcontext
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"novamd/internal/models"
|
||||
)
|
||||
|
||||
// HandlerContext holds the request-specific data available to all handlers
|
||||
type HandlerContext struct {
|
||||
UserID int
|
||||
UserRole string
|
||||
Workspace *models.Workspace
|
||||
}
|
||||
|
||||
type contextKey string
|
||||
|
||||
const HandlerContextKey contextKey = "handlerContext"
|
||||
|
||||
func GetRequestContext(w http.ResponseWriter, r *http.Request) (*HandlerContext, bool) {
|
||||
ctx := r.Context().Value(HandlerContextKey)
|
||||
if ctx == nil {
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return nil, false
|
||||
}
|
||||
return ctx.(*HandlerContext), true
|
||||
}
|
||||
|
||||
func WithHandlerContext(r *http.Request, hctx *HandlerContext) *http.Request {
|
||||
return r.WithContext(context.WithValue(r.Context(), HandlerContextKey, hctx))
|
||||
}
|
||||
53
server/internal/middleware/context.go
Normal file
53
server/internal/middleware/context.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"novamd/internal/auth"
|
||||
"novamd/internal/db"
|
||||
"novamd/internal/httpcontext"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// User ID and User Role context
|
||||
func WithUserContext(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
claims, err := auth.GetUserFromContext(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
hctx := &httpcontext.HandlerContext{
|
||||
UserID: claims.UserID,
|
||||
UserRole: claims.Role,
|
||||
}
|
||||
|
||||
r = httpcontext.WithHandlerContext(r, hctx)
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// Workspace context
|
||||
func WithWorkspaceContext(db *db.DB) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, ok := httpcontext.GetRequestContext(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
workspaceName := chi.URLParam(r, "workspaceName")
|
||||
workspace, err := db.GetWorkspaceByName(ctx.UserID, workspaceName)
|
||||
if err != nil {
|
||||
http.Error(w, "Workspace not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Update existing context with workspace
|
||||
ctx.Workspace = workspace
|
||||
r = httpcontext.WithHandlerContext(r, ctx)
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
31
server/internal/models/user.go
Normal file
31
server/internal/models/user.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/go-playground/validator/v10"
|
||||
)
|
||||
|
||||
var validate = validator.New()
|
||||
|
||||
type UserRole string
|
||||
|
||||
const (
|
||||
RoleAdmin UserRole = "admin"
|
||||
RoleEditor UserRole = "editor"
|
||||
RoleViewer UserRole = "viewer"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID int `json:"id" validate:"required,min=1"`
|
||||
Email string `json:"email" validate:"required,email"`
|
||||
DisplayName string `json:"displayName"`
|
||||
PasswordHash string `json:"-"`
|
||||
Role UserRole `json:"role" validate:"required,oneof=admin editor viewer"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
LastWorkspaceID int `json:"lastWorkspaceId"`
|
||||
}
|
||||
|
||||
func (u *User) Validate() error {
|
||||
return validate.Struct(u)
|
||||
}
|
||||
40
server/internal/models/workspace.go
Normal file
40
server/internal/models/workspace.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type Workspace struct {
|
||||
ID int `json:"id" validate:"required,min=1"`
|
||||
UserID int `json:"userId" validate:"required,min=1"`
|
||||
Name string `json:"name" validate:"required"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
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"`
|
||||
}
|
||||
|
||||
func (w *Workspace) Validate() error {
|
||||
return validate.Struct(w)
|
||||
}
|
||||
|
||||
func (w *Workspace) GetDefaultSettings() {
|
||||
w.Theme = "light"
|
||||
w.AutoSave = false
|
||||
w.ShowHiddenFiles = false
|
||||
w.GitEnabled = false
|
||||
w.GitURL = ""
|
||||
w.GitUser = ""
|
||||
w.GitToken = ""
|
||||
w.GitAutoCommit = false
|
||||
w.GitCommitMsgTemplate = "${action} ${filename}"
|
||||
}
|
||||
64
server/internal/user/user.go
Normal file
64
server/internal/user/user.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"novamd/internal/db"
|
||||
"novamd/internal/filesystem"
|
||||
"novamd/internal/models"
|
||||
)
|
||||
|
||||
type UserService struct {
|
||||
DB *db.DB
|
||||
FS *filesystem.FileSystem
|
||||
}
|
||||
|
||||
func NewUserService(database *db.DB, fs *filesystem.FileSystem) *UserService {
|
||||
return &UserService{
|
||||
DB: database,
|
||||
FS: fs,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *UserService) SetupAdminUser(adminEmail, adminPassword string) (*models.User, error) {
|
||||
// Check if admin user exists
|
||||
adminUser, err := s.DB.GetUserByEmail(adminEmail)
|
||||
if adminUser != nil {
|
||||
return adminUser, nil // Admin user already exists
|
||||
} else if err != sql.ErrNoRows {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Hash the password
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(adminPassword), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to hash password: %w", err)
|
||||
}
|
||||
|
||||
// Create admin user
|
||||
adminUser = &models.User{
|
||||
Email: adminEmail,
|
||||
DisplayName: "Admin",
|
||||
PasswordHash: string(hashedPassword),
|
||||
Role: models.RoleAdmin,
|
||||
}
|
||||
|
||||
createdUser, err := s.DB.CreateUser(adminUser)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create admin user: %w", err)
|
||||
}
|
||||
|
||||
// Initialize workspace directory
|
||||
err = s.FS.InitializeUserWorkspace(createdUser.ID, createdUser.LastWorkspaceID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize admin workspace: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("Created admin user with ID: %d and default workspace with ID: %d", createdUser.ID, createdUser.LastWorkspaceID)
|
||||
|
||||
return adminUser, nil
|
||||
}
|
||||
Reference in New Issue
Block a user