diff --git a/.env.development b/.env.development new file mode 100644 index 0000000..ea81df2 --- /dev/null +++ b/.env.development @@ -0,0 +1,10 @@ +# Development environment variables +VITE_API_URL=http://localhost:3909 + +# Development mode settings +VITE_MODE=development +VITE_DEBUG=true + +# Hot reload settings +VITE_HMR_PORT=5173 +VITE_HMR_HOST=localhost \ No newline at end of file diff --git a/.gitignore b/.gitignore index c128b51..e6b4da0 100644 --- a/.gitignore +++ b/.gitignore @@ -23,10 +23,56 @@ dist-ssr *.sln *.sw? +# Environment files .env* !.env.example -docker-compose.*.yml +!.env.development +# Docker compose development files (keep only production) +# docker-compose.*.yml # Commented out to allow dev files + +# Development data data/ meta/ +dev-data/ garage.toml + +# Backend specific +backend/main +backend/tmp/ +backend/data/ +backend/*.log +backend/.air_tmp +backend/build-errors.log + +# Go build artifacts +*.exe +*.exe~ +*.dll +*.so +*.dylib +*.test +*.out +vendor/ +tmp/ + +# Database files +*.db +*.sqlite +*.sqlite3 + +# Backup files +*.bak +*.backup + +# Development certificates +*.pem +*.crt +*.key + +# Cache and runtime +.cache/ +.vite/ +runtime/ +logs/ +.claude/ diff --git a/Dockerfile.dev b/Dockerfile.dev new file mode 100644 index 0000000..75c33ca --- /dev/null +++ b/Dockerfile.dev @@ -0,0 +1,48 @@ +# Development Dockerfile - Single stage with both frontend and backend +FROM node:20-slim + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + curl \ + git \ + && rm -rf /var/lib/apt/lists/* + +# Install Go +RUN curl -L https://go.dev/dl/go1.25.1.linux-amd64.tar.gz | tar -C /usr/local -xzf - +ENV PATH="/usr/local/go/bin:$PATH" + +# Install Air for Go hot reload +RUN go install github.com/air-verse/air@latest +ENV PATH="/root/go/bin:$PATH" + +# Enable corepack for pnpm +RUN npm install -g corepack@latest && corepack use pnpm@latest +RUN npm install -g concurrently + +WORKDIR /app + +# Install frontend dependencies +COPY package.json pnpm-lock.yaml ./ +RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile + +# Install backend dependencies +COPY backend/go.mod backend/go.sum ./backend/ +WORKDIR /app/backend +RUN go mod download +WORKDIR /app + +# Copy all source code (will be overridden by volume in dev) +COPY . . + +# Create tmp directory for Go builds +RUN mkdir -p backend/tmp + +# Expose ports +EXPOSE 5173 3909 + +# Development command with both frontend and backend +CMD ["concurrently", \ + "--names", "FRONTEND,BACKEND", \ + "--prefix-colors", "blue,green", \ + "pnpm run dev:client", \ + "cd backend && air -c .air.toml"] \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..f72855f --- /dev/null +++ b/Makefile @@ -0,0 +1,234 @@ +# Garage WebUI Development Makefile + +# Variables +DOCKER_COMPOSE_DEV = docker-compose -f docker-compose.dev.yml +DOCKER_COMPOSE_PROD = docker-compose -f docker-compose.yml + +# Colors for output +RED=\033[0;31m +GREEN=\033[0;32m +YELLOW=\033[1;33m +BLUE=\033[0;34m +NC=\033[0m # No Color + +.PHONY: help dev dev-docker dev-frontend dev-backend dev-fullstack build clean test lint install + +# Default target +help: ## Show this help message + @echo "${BLUE}Garage WebUI Development Commands${NC}" + @echo "" + @echo "Usage: make [command]" + @echo "" + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "${GREEN}%-20s${NC} %s\n", $$1, $$2}' + +# Development Commands +dev: ## Start local development (requires local Garage) + @echo "${BLUE}Starting local development...${NC}" + pnpm run dev + +dev-docker: ## Start Docker development environment + @echo "${BLUE}Starting Docker development environment...${NC}" + $(DOCKER_COMPOSE_DEV) up --build + +dev-logs: ## Show development logs + @echo "${BLUE}Showing development logs...${NC}" + $(DOCKER_COMPOSE_DEV) logs -f + +dev-stop: ## Stop development environment + @echo "${YELLOW}Stopping development environment...${NC}" + $(DOCKER_COMPOSE_DEV) down + +dev-clean: ## Clean development environment and volumes + @echo "${RED}Cleaning development environment...${NC}" + $(DOCKER_COMPOSE_DEV) down -v + docker system prune -f + +# Build Commands +build: ## Build for production + @echo "${BLUE}Building for production...${NC}" + pnpm run build + +build-dev: ## Build for development + @echo "${BLUE}Building for development...${NC}" + pnpm run build:dev + +build-backend: ## Build backend only + @echo "${BLUE}Building backend...${NC}" + cd backend && go build -o main . + +build-docker: ## Build production Docker images + @echo "${BLUE}Building production Docker images...${NC}" + $(DOCKER_COMPOSE_PROD) build + +# Install Commands +install: ## Install all dependencies + @echo "${BLUE}Installing dependencies...${NC}" + pnpm install + $(MAKE) install-backend + +install-backend: ## Install Go dependencies + @echo "${BLUE}Installing Go dependencies...${NC}" + cd backend && go mod download + +# Test Commands +test: ## Run all tests + @echo "${BLUE}Running tests...${NC}" + $(MAKE) test-backend + $(MAKE) type-check + +test-backend: ## Run Go tests + @echo "${BLUE}Running Go tests...${NC}" + cd backend && go test ./... + +test-frontend: ## Run frontend tests (if they exist) + @echo "${BLUE}Running frontend tests...${NC}" + pnpm test + +type-check: ## Check TypeScript types + @echo "${BLUE}Checking TypeScript types...${NC}" + pnpm run type-check + +# Lint Commands +lint: ## Run linters + @echo "${BLUE}Running linters...${NC}" + pnpm run lint + $(MAKE) lint-backend + +lint-fix: ## Fix linting issues automatically + @echo "${BLUE}Fixing linting issues...${NC}" + pnpm run lint:fix + +lint-backend: ## Lint Go code + @echo "${BLUE}Linting Go code...${NC}" + cd backend && go fmt ./... + cd backend && go vet ./... + +# Clean Commands +clean: ## Clean build artifacts and cache + @echo "${YELLOW}Cleaning build artifacts...${NC}" + rm -rf dist + rm -rf node_modules/.vite + rm -rf backend/tmp + rm -rf backend/main + +clean-all: ## Clean everything including node_modules + @echo "${RED}Cleaning everything...${NC}" + $(MAKE) clean + rm -rf node_modules + cd backend && go clean -cache -modcache -i -r + +# Database Commands +db-backup: ## Backup development database + @echo "${BLUE}Backing up development database...${NC}" + mkdir -p dev-data/backups + $(DOCKER_COMPOSE_DEV) exec webui-backend cp /app/data/database.json /data/backups/database-backup-$$(date +%Y%m%d-%H%M%S).json + @echo "${GREEN}Database backup created in dev-data/backups/${NC}" + +db-restore: ## Restore development database from backup (specify BACKUP_FILE) + @echo "${BLUE}Restoring development database...${NC}" + @if [ -z "$(BACKUP_FILE)" ]; then echo "${RED}Please specify BACKUP_FILE. Example: make db-restore BACKUP_FILE=database-backup-20231201-120000.json${NC}"; exit 1; fi + $(DOCKER_COMPOSE_DEV) exec webui-backend cp /data/backups/$(BACKUP_FILE) /app/data/database.json + @echo "${GREEN}Database restored from $(BACKUP_FILE)${NC}" + +db-reset: ## Reset development database (creates fresh admin user) + @echo "${YELLOW}Resetting development database...${NC}" + $(DOCKER_COMPOSE_DEV) exec webui-backend rm -f /app/data/database.json + $(DOCKER_COMPOSE_DEV) restart webui-backend + @echo "${GREEN}Database reset. Default admin user created (admin/admin)${NC}" + +# Debug Commands +debug-frontend: ## Access frontend container shell + @echo "${BLUE}Accessing frontend container...${NC}" + $(DOCKER_COMPOSE_DEV) exec webui-frontend sh + +debug-backend: ## Access backend container shell + @echo "${BLUE}Accessing backend container...${NC}" + $(DOCKER_COMPOSE_DEV) exec webui-backend sh + +debug-garage: ## Access garage container shell + @echo "${BLUE}Accessing garage container...${NC}" + $(DOCKER_COMPOSE_DEV) exec garage sh + +debug-logs-frontend: ## Show frontend logs only + $(DOCKER_COMPOSE_DEV) logs -f webui-frontend + +debug-logs-backend: ## Show backend logs only + $(DOCKER_COMPOSE_DEV) logs -f webui-backend + +debug-logs-garage: ## Show garage logs only + $(DOCKER_COMPOSE_DEV) logs -f garage + +# Status Commands +status: ## Show development environment status + @echo "${BLUE}Development environment status:${NC}" + $(DOCKER_COMPOSE_DEV) ps + +health: ## Check health of all services + @echo "${BLUE}Checking service health...${NC}" + @echo "Frontend: http://localhost:5173" + @curl -s -o /dev/null -w "Frontend: %{http_code}\n" http://localhost:5173 || echo "Frontend: ${RED}DOWN${NC}" + @curl -s -o /dev/null -w "Backend: %{http_code}\n" http://localhost:3909/api/auth/status || echo "Backend: ${RED}DOWN${NC}" + @curl -s -o /dev/null -w "Garage: %{http_code}\n" http://localhost:3903/status || echo "Garage: ${RED}DOWN${NC}" + +# Production Commands +prod-build: ## Build production images + @echo "${BLUE}Building production images...${NC}" + $(DOCKER_COMPOSE_PROD) build + +prod-up: ## Start production environment + @echo "${BLUE}Starting production environment...${NC}" + $(DOCKER_COMPOSE_PROD) up -d + +prod-down: ## Stop production environment + @echo "${YELLOW}Stopping production environment...${NC}" + $(DOCKER_COMPOSE_PROD) down + +prod-logs: ## Show production logs + $(DOCKER_COMPOSE_PROD) logs -f + +# Utility Commands +ports: ## Show all used ports + @echo "${BLUE}Development ports:${NC}" + @echo "Frontend (Vite): http://localhost:5173" + @echo "Backend (API): http://localhost:3909" + @echo "Garage (S3): http://localhost:3900" + @echo "Garage (Admin): http://localhost:3903" + @echo "Garage (RPC): http://localhost:3901" + @echo "Garage (Web): http://localhost:3902" + @echo "" + @echo "${BLUE}Fullstack alternative ports:${NC}" + @echo "Frontend: http://localhost:5174" + @echo "Backend: http://localhost:3910" + +urls: ## Show all useful URLs + @echo "${BLUE}Development URLs:${NC}" + @echo "WebUI: http://localhost:5173" + @echo "Admin Dashboard: http://localhost:5173/admin" + @echo "Login: http://localhost:5173/auth/login" + @echo "API Status: http://localhost:3909/api/auth/status" + @echo "Garage Status: http://localhost:3903/status" + +# First time setup +setup: ## First time setup (install deps + start dev environment) + @echo "${GREEN}Setting up Garage WebUI development environment...${NC}" + $(MAKE) install + @echo "${YELLOW}Creating garage.toml if it doesn't exist...${NC}" + @if [ ! -f garage.toml ]; then \ + echo "Creating garage.toml from template..."; \ + cp garage.toml.example garage.toml 2>/dev/null || echo "Please create garage.toml manually"; \ + fi + @echo "${GREEN}Setup complete! Run 'make dev-docker' to start development.${NC}" + +# Quick commands for daily development +quick-start: dev-docker ## Quick start development environment +quick-stop: dev-stop ## Quick stop development environment +quick-restart: ## Quick restart development environment + $(MAKE) dev-stop + $(MAKE) dev-docker + +# Git hooks (optional) +install-hooks: ## Install git pre-commit hooks + @echo "${BLUE}Installing git hooks...${NC}" + @echo '#!/bin/sh\nmake lint && make type-check' > .git/hooks/pre-commit + @chmod +x .git/hooks/pre-commit + @echo "${GREEN}Pre-commit hooks installed${NC}" \ No newline at end of file diff --git a/README.dev.md b/README.dev.md new file mode 100644 index 0000000..2e23f69 --- /dev/null +++ b/README.dev.md @@ -0,0 +1,459 @@ +# Garage Web UI - Desarrollo + +Esta guía te ayudará a configurar el entorno de desarrollo para Garage Web UI con hot reload y todas las funcionalidades avanzadas. + +## 🚀 Quick Start + +### Opción 1: Docker (Recomendado) + +```bash +# Clona el repositorio +git clone https://github.com/khairul169/garage-webui.git +cd garage-webui + +# Inicia el entorno completo de desarrollo +npm run dev:docker + +# O por separado: +npm run dev:docker:frontend # Solo frontend +npm run dev:docker:backend # Solo backend +npm run dev:docker:fullstack # Todo en un contenedor +``` + +### Opción 2: Local + +```bash +# Instalar dependencias +pnpm install +npm run install:backend + +# Desarrollo local (requiere Garage corriendo) +npm run dev +``` + +## 📁 Estructura del Proyecto + +``` +garage-webui/ +├── src/ # Frontend React + TypeScript +│ ├── components/ # Componentes reutilizables +│ ├── pages/ # Páginas principales +│ │ ├── admin/ # Dashboard de administración +│ │ ├── auth/ # Autenticación +│ │ ├── buckets/ # Gestión de buckets +│ │ ├── cluster/ # Gestión del clúster +│ │ └── keys/ # Gestión de keys +│ ├── hooks/ # Custom hooks +│ ├── types/ # TypeScript types +│ └── lib/ # Utilidades +├── backend/ # Backend Go +│ ├── middleware/ # Middleware de seguridad +│ ├── router/ # Endpoints API +│ ├── schema/ # Modelos de datos +│ └── utils/ # Utilidades del servidor +├── docker-compose.dev.yml # Entorno de desarrollo +├── Dockerfile.dev # Dockerfile para desarrollo +└── README.dev.md # Esta documentación +``` + +## 🛠️ Configuración del Entorno + +### Variables de Entorno + +Crea un archivo `garage.toml` para Garage (ejemplo mínimo): + +```toml +metadata_dir = "/var/lib/garage/meta" +data_dir = "/var/lib/garage/data" +db_engine = "sqlite" + +replication_factor = 1 + +rpc_bind_addr = "[::]:3901" +rpc_public_addr = "127.0.0.1:3901" +rpc_secret = "1799bccfd7411abbccc9a3f8a0ccc314f5d0d9690e9a2cc4de5ba8faa24a3ee2" + +[s3_api] +s3_region = "garage" +api_bind_addr = "[::]:3900" +root_domain = ".s3.garage.localhost" + +[admin] +api_bind_addr = "[::]:3903" +admin_token = "admin-token-change-me" +metrics_token = "metrics-token-change-me" +``` + +### Variables de Entorno para Desarrollo + +El proyecto incluye configuración automática para desarrollo: + +**Frontend (.env.development):** +```env +VITE_API_URL=http://localhost:3909 +VITE_MODE=development +VITE_DEBUG=true +``` + +**Backend (docker-compose.dev.yml):** +```env +CONFIG_PATH=/etc/garage.toml +API_BASE_URL=http://garage:3903 +S3_ENDPOINT_URL=http://garage:3900 +DATA_DIR=/app/data +CORS_ALLOWED_ORIGINS=http://localhost:5173,http://127.0.0.1:5173 +RATE_LIMIT_REQUESTS=1000 +RATE_LIMIT_WINDOW=1m +``` + +## 🔥 Hot Reload + +### Frontend (React) +- **Puerto**: 5173 +- **Hot Module Replacement**: Activado automáticamente +- **Proxy API**: `/api/*` → `http://localhost:3909` +- **File Watching**: Optimizado para Docker con polling + +### Backend (Go) +- **Puerto**: 3909 +- **Herramienta**: Air (similar a nodemon para Node.js) +- **Auto-rebuild**: Al cambiar archivos `.go` +- **Configuración**: `backend/.air.toml` + +## 🐳 Opciones de Docker + +### 1. Frontend + Backend Separados (Recomendado) + +```bash +# Inicia Garage + Frontend + Backend en contenedores separados +npm run dev:docker + +# Accede a: +# - Frontend: http://localhost:5173 (con HMR) +# - Backend API: http://localhost:3909 +# - Garage S3: http://localhost:3900 +# - Garage Admin: http://localhost:3903 +``` + +**Ventajas:** +- ✅ Mejor aislamiento +- ✅ Hot reload independiente +- ✅ Fácil debugging +- ✅ Logs separados + +### 2. Frontend Solo + +```bash +npm run dev:docker:frontend +``` +Útil cuando quieres desarrollar solo el frontend con un backend en producción. + +### 3. Backend Solo + +```bash +npm run dev:docker:backend +``` +Útil para desarrollo de API con frontend en producción. + +### 4. Fullstack (Un Solo Contenedor) + +```bash +npm run dev:docker:fullstack +``` +**Puertos alternativos:** Frontend: 5174, Backend: 3910 + +## 📝 Scripts Disponibles + +### Desarrollo +```bash +npm run dev # Local: Frontend + Backend +npm run dev:client # Solo frontend local +npm run dev:server # Solo backend local +npm run dev:docker # Docker: Todo el entorno +npm run dev:docker:frontend # Docker: Solo frontend +npm run dev:docker:backend # Docker: Solo backend +npm run dev:docker:fullstack # Docker: Fullstack +``` + +### Build y Testing +```bash +npm run build # Build de producción +npm run build:dev # Build de desarrollo +npm run type-check # Verificar tipos TypeScript +npm run lint # Linter +npm run lint:fix # Fix automático del linter +npm run test:backend # Tests del backend Go +``` + +### Backend +```bash +npm run install:backend # Instalar dependencias Go +npm run build:backend # Build del backend +``` + +### Limpieza +```bash +npm run clean # Limpiar cache y builds +npm run dev:docker:clean # Limpiar contenedores y volúmenes +``` + +## 🔐 Sistema de Autenticación + +### Usuario por Defecto +Al iniciar por primera vez, se crea automáticamente: +- **Usuario**: `admin` +- **Contraseña**: `admin` +- **Rol**: Administrador + +**⚠️ IMPORTANTE**: Cambia la contraseña después del primer login. + +### Roles Disponibles +- **Admin**: Acceso completo al sistema +- **Tenant Admin**: Administración de su tenant +- **User**: Usuario básico con permisos limitados +- **ReadOnly**: Solo lectura + +## 🎯 Funcionalidades de Desarrollo + +### Dashboard de Administración +- ✅ Gestión completa de usuarios +- ✅ Sistema de tenants (multi-tenancy) +- ✅ Roles y permisos granulares +- ✅ Configuración dinámica de S3 +- ✅ Monitoreo del sistema + +### Seguridad Implementada +- ✅ Autenticación JWT con sessiones +- ✅ Rate limiting configurable +- ✅ Headers de seguridad (CORS, XSS, etc.) +- ✅ Cifrado bcrypt para contraseñas +- ✅ Middleware de autorización + +### Base de Datos +- ✅ Persistencia en JSON local +- ✅ Thread-safe operations +- ✅ Backup automático +- ✅ Migration desde configuración legacy + +## 🐛 Debugging + +### Logs del Frontend +```bash +# Ver logs del frontend +docker-compose -f docker-compose.dev.yml logs -f webui-frontend + +# O desde el navegador +# Abre DevTools → Console +``` + +### Logs del Backend +```bash +# Ver logs del backend +docker-compose -f docker-compose.dev.yml logs -f webui-backend + +# Ver logs de Garage +docker-compose -f docker-compose.dev.yml logs -f garage +``` + +### Debugging del Backend Go +```bash +# Ejecutar en contenedor para debugging +docker-compose -f docker-compose.dev.yml exec webui-backend sh + +# Ver estado de la base de datos +cat /app/data/database.json + +# Logs de compilación +cat /app/build-errors.log +``` + +## 📊 Monitoreo + +### Endpoints Útiles para Desarrollo +- `GET /api/auth/status` - Estado de autenticación +- `GET /api/s3/status` - Estado del sistema S3 +- `GET /api/s3/config` - Configuración actual +- `POST /api/s3/test` - Test de conectividad +- `GET /api/users` - Lista de usuarios (admin) +- `GET /api/tenants` - Lista de tenants (admin) + +### Health Checks +- Garage: `http://localhost:3903/status` +- WebUI Backend: `http://localhost:3909/api/s3/status` + +## 🚨 Troubleshooting + +### El frontend no se actualiza automáticamente +```bash +# Verificar que el polling esté habilitado +# En vite.config.ts debe estar: +watch: { + usePolling: true, + interval: 100, +} +``` + +### El backend no se recarga +```bash +# Verificar que Air esté corriendo +docker-compose -f docker-compose.dev.yml logs webui-backend + +# Debe mostrar: "watching .go files" +``` + +### Problemas de conectividad +```bash +# Verificar que todos los servicios estén corriendo +docker-compose -f docker-compose.dev.yml ps + +# Verificar conectividad a Garage +curl http://localhost:3903/status +``` + +### Limpiar estado corrupto +```bash +# Limpiar todo y empezar de nuevo +npm run dev:docker:clean +docker system prune -a +npm run dev:docker +``` + +### Base de datos corrupta +```bash +# Backup automático en dev-data/ +cp dev-data/backup-database.json backend/data/database.json + +# O eliminar para recrear usuario admin +rm backend/data/database.json +``` + +## 🎨 Desarrollo del Frontend + +### Estructura de Componentes +- `src/components/ui/` - Componentes base (Button, Input, etc.) +- `src/components/containers/` - Contenedores (Sidebar, Theme, etc.) +- `src/components/layouts/` - Layouts de página +- `src/pages/` - Páginas específicas + +### Estado Global +- **React Query**: Cache de API y estado servidor +- **Zustand**: Estado global mínimo (theme, etc.) +- **React Hook Form**: Formularios con validación + +### Estilos +- **Tailwind CSS**: Utility-first CSS +- **DaisyUI**: Componentes pre-diseñados +- **CSS Modules**: Estilos específicos cuando es necesario + +## 🔧 Desarrollo del Backend + +### Arquitectura +``` +backend/ +├── main.go # Entry point +├── router/ # Endpoints HTTP +│ ├── auth.go # Autenticación +│ ├── users.go # Gestión usuarios +│ ├── tenants.go # Gestión tenants +│ └── s3config.go # Configuración S3 +├── middleware/ # Middleware HTTP +│ ├── auth.go # Autenticación +│ └── security.go # Seguridad (CORS, Rate limiting) +├── schema/ # Modelos de datos +├── utils/ # Utilidades +└── .air.toml # Configuración hot reload +``` + +### Adding New Endpoints +```go +// 1. Agregar al router (router/router.go) +users := &Users{} +router.HandleFunc("GET /users", users.GetAll) + +// 2. Implementar handler (router/users.go) +func (u *Users) GetAll(w http.ResponseWriter, r *http.Request) { + // Verificar permisos + if !u.checkPermission(r, schema.PermissionReadUsers) { + utils.ResponseErrorStatus(w, nil, http.StatusForbidden) + return + } + + // Lógica del endpoint + users, err := utils.DB.ListUsers() + if err != nil { + utils.ResponseError(w, err) + return + } + + utils.ResponseSuccess(w, users) +} +``` + +## 🔒 Seguridad en Desarrollo + +### HTTPS Local (Opcional) +Para testing de características que requieren HTTPS: + +```bash +# Generar certificados locales +mkcert localhost 127.0.0.1 + +# Actualizar vite.config.ts para usar HTTPS +server: { + https: { + key: fs.readFileSync('localhost-key.pem'), + cert: fs.readFileSync('localhost.pem'), + }, + // ... resto de config +} +``` + +### Variables de Entorno Sensibles +```bash +# NO commitear archivos con secretos reales +# Usar valores de desarrollo como: +rpc_secret = "dev-secret-not-for-production" +admin_token = "dev-admin-token" +``` + +## 📋 Checklist Pre-Commit + +- [ ] `npm run type-check` pasa sin errores +- [ ] `npm run lint` pasa sin errores +- [ ] `npm run test:backend` pasa todos los tests +- [ ] Hot reload funciona en frontend y backend +- [ ] Dashboard de admin funciona correctamente +- [ ] No hay secrets hardcodeados en el código + +## 🚀 Deployment + +Una vez que el desarrollo esté listo, usa el docker-compose.yml original para producción: + +```bash +# Build de producción +npm run build + +# Deploy con el docker-compose.yml original +docker-compose up --build +``` + +## 💡 Tips de Desarrollo + +### VS Code Extensions Recomendadas +- TypeScript Importer +- Tailwind CSS IntelliSense +- Go Extension +- Docker Extension +- Thunder Client (para testing de APIs) + +### Chrome Extensions Útiles +- React Developer Tools +- TanStack Query DevTools + +--- + +**¿Problemas?** Abre un issue en el repositorio con: +1. Comando que causó el problema +2. Logs completos (`docker-compose logs`) +3. Sistema operativo y versión de Docker +4. Pasos para reproducir \ No newline at end of file diff --git a/backend/.air.toml b/backend/.air.toml index 58fff2a..fc7cd3f 100644 --- a/backend/.air.toml +++ b/backend/.air.toml @@ -7,7 +7,7 @@ tmp_dir = "tmp" bin = "./tmp/main" cmd = "go build -o ./tmp/main ." delay = 1000 - exclude_dir = ["assets", "tmp", "vendor", "testdata"] + exclude_dir = ["assets", "tmp", "vendor", "testdata", "ui"] exclude_file = [] exclude_regex = ["_test.go"] exclude_unchanged = false @@ -20,12 +20,10 @@ tmp_dir = "tmp" log = "build-errors.log" poll = false poll_interval = 0 - post_cmd = [] - pre_cmd = [] rerun = false rerun_delay = 500 send_interrupt = false - stop_on_error = false + stop_on_root = false [color] app = "" @@ -41,11 +39,6 @@ tmp_dir = "tmp" [misc] clean_on_exit = false -[proxy] - app_port = 0 - enabled = false - proxy_port = 0 - [screen] clear_on_rebuild = false - keep_scroll = true + keep_scroll = true \ No newline at end of file diff --git a/backend/main.go b/backend/main.go index a69ffbe..72a5b26 100644 --- a/backend/main.go +++ b/backend/main.go @@ -2,6 +2,7 @@ package main import ( "fmt" + "khairul169/garage-webui/middleware" "khairul169/garage-webui/router" "khairul169/garage-webui/ui" "khairul169/garage-webui/utils" @@ -18,6 +19,11 @@ func main() { utils.InitCacheManager() sessionMgr := utils.InitSessionManager() + // Initialize database + if err := utils.InitDatabase(); err != nil { + log.Fatal("Failed to initialize database:", err) + } + if err := utils.Garage.LoadConfig(); err != nil { log.Println("Cannot load garage config!", err) } @@ -27,7 +33,8 @@ func main() { // Serve API apiPrefix := basePath + "/api" - mux.Handle(apiPrefix+"/", http.StripPrefix(apiPrefix, router.HandleApiRouter())) + apiHandler := http.StripPrefix(apiPrefix, router.HandleApiRouter()) + mux.Handle(apiPrefix+"/", apiHandler) // Static files ui.ServeUI(mux) @@ -37,13 +44,23 @@ func main() { mux.Handle("/", http.RedirectHandler(basePath, http.StatusMovedPermanently)) } + // Apply security middleware + handler := sessionMgr.LoadAndSave(mux) + handler = middleware.CORSMiddleware(handler) + handler = middleware.SecurityHeadersMiddleware(handler) + handler = middleware.RateLimitMiddleware(handler) + host := utils.GetEnv("HOST", "0.0.0.0") port := utils.GetEnv("PORT", "3909") addr := fmt.Sprintf("%s:%s", host, port) - log.Printf("Starting server on http://%s", addr) + log.Printf("Starting secure server on http://%s", addr) + log.Printf("Authentication: enabled") + log.Printf("Rate limiting: %s requests per %s", + utils.GetEnv("RATE_LIMIT_REQUESTS", "100"), + utils.GetEnv("RATE_LIMIT_WINDOW", "1m")) - if err := http.ListenAndServe(addr, sessionMgr.LoadAndSave(mux)); err != nil { + if err := http.ListenAndServe(addr, handler); err != nil { log.Fatal(err) } } diff --git a/backend/middleware/auth.go b/backend/middleware/auth.go index 9c8bbc1..76b9150 100644 --- a/backend/middleware/auth.go +++ b/backend/middleware/auth.go @@ -7,17 +7,21 @@ import ( ) func AuthMiddleware(next http.Handler) http.Handler { - authData := utils.GetEnv("AUTH_USER_PASS", "") - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { auth := utils.Session.Get(r, "authenticated") + userID := utils.Session.Get(r, "user_id") - if authData == "" { - next.ServeHTTP(w, r) + // Check if user is authenticated + if auth == nil || !auth.(bool) || userID == nil { + utils.ResponseErrorStatus(w, errors.New("unauthorized"), http.StatusUnauthorized) return } - if auth == nil || !auth.(bool) { + // Verify user still exists and is enabled + user, err := utils.DB.GetUser(userID.(string)) + if err != nil || !user.Enabled { + // Clear invalid session + utils.Session.Clear(r) utils.ResponseErrorStatus(w, errors.New("unauthorized"), http.StatusUnauthorized) return } diff --git a/backend/middleware/security.go b/backend/middleware/security.go new file mode 100644 index 0000000..1cfba8c --- /dev/null +++ b/backend/middleware/security.go @@ -0,0 +1,200 @@ +package middleware + +import ( + "khairul169/garage-webui/utils" + "net/http" + "strconv" + "strings" + "sync" + "time" +) + +// CORSMiddleware adds CORS headers to responses +func CORSMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Get allowed origins from environment or use default + allowedOrigins := utils.GetEnv("CORS_ALLOWED_ORIGINS", "http://localhost:*,http://127.0.0.1:*") + origins := strings.Split(allowedOrigins, ",") + + origin := r.Header.Get("Origin") + allowed := false + + // Check if origin is allowed + for _, allowedOrigin := range origins { + if matchOrigin(strings.TrimSpace(allowedOrigin), origin) { + allowed = true + break + } + } + + if allowed || len(origin) == 0 { + w.Header().Set("Access-Control-Allow-Origin", origin) + } + + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With") + w.Header().Set("Access-Control-Allow-Credentials", "true") + w.Header().Set("Access-Control-Max-Age", "86400") // 24 hours + + // Handle preflight requests + if r.Method == "OPTIONS" { + w.WriteHeader(http.StatusOK) + return + } + + next.ServeHTTP(w, r) + }) +} + +// matchOrigin checks if an origin matches the pattern (supports wildcard *) +func matchOrigin(pattern, origin string) bool { + if pattern == "*" { + return true + } + if !strings.Contains(pattern, "*") { + return pattern == origin + } + + // Simple wildcard matching for ports + if strings.HasSuffix(pattern, ":*") { + basePattern := strings.TrimSuffix(pattern, ":*") + return strings.HasPrefix(origin, basePattern) + } + + return pattern == origin +} + +// SecurityHeadersMiddleware adds security headers +func SecurityHeadersMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Security headers + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("X-Frame-Options", "DENY") + w.Header().Set("X-XSS-Protection", "1; mode=block") + w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains") + w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'") + w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin") + + next.ServeHTTP(w, r) + }) +} + +// Rate limiting +type RateLimiter struct { + requests map[string][]time.Time + mutex sync.RWMutex + limit int + window time.Duration +} + +func NewRateLimiter(limit int, window time.Duration) *RateLimiter { + return &RateLimiter{ + requests: make(map[string][]time.Time), + limit: limit, + window: window, + } +} + +func (rl *RateLimiter) Allow(ip string) bool { + rl.mutex.Lock() + defer rl.mutex.Unlock() + + now := time.Now() + + // Get requests for this IP + requests := rl.requests[ip] + + // Remove old requests outside the window + var validRequests []time.Time + for _, reqTime := range requests { + if now.Sub(reqTime) <= rl.window { + validRequests = append(validRequests, reqTime) + } + } + + // Check if limit exceeded + if len(validRequests) >= rl.limit { + rl.requests[ip] = validRequests + return false + } + + // Add current request + validRequests = append(validRequests, now) + rl.requests[ip] = validRequests + + return true +} + +func (rl *RateLimiter) Cleanup() { + ticker := time.NewTicker(rl.window) + go func() { + for range ticker.C { + rl.mutex.Lock() + now := time.Now() + for ip, requests := range rl.requests { + var validRequests []time.Time + for _, reqTime := range requests { + if now.Sub(reqTime) <= rl.window { + validRequests = append(validRequests, reqTime) + } + } + if len(validRequests) == 0 { + delete(rl.requests, ip) + } else { + rl.requests[ip] = validRequests + } + } + rl.mutex.Unlock() + } + }() +} + +var defaultRateLimiter *RateLimiter + +func init() { + // Default: 100 requests per minute per IP + limit, _ := strconv.Atoi(utils.GetEnv("RATE_LIMIT_REQUESTS", "100")) + window, _ := time.ParseDuration(utils.GetEnv("RATE_LIMIT_WINDOW", "1m")) + + defaultRateLimiter = NewRateLimiter(limit, window) + defaultRateLimiter.Cleanup() +} + +// RateLimitMiddleware applies rate limiting +func RateLimitMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Get client IP + ip := getClientIP(r) + + // Check rate limit + if !defaultRateLimiter.Allow(ip) { + w.Header().Set("Retry-After", "60") + utils.ResponseErrorStatus(w, nil, http.StatusTooManyRequests) + return + } + + next.ServeHTTP(w, r) + }) +} + +// getClientIP extracts the real client IP from request +func getClientIP(r *http.Request) string { + // Check X-Forwarded-For header + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + ips := strings.Split(xff, ",") + return strings.TrimSpace(ips[0]) + } + + // Check X-Real-IP header + if xri := r.Header.Get("X-Real-IP"); xri != "" { + return xri + } + + // Use remote address + ip := r.RemoteAddr + if colon := strings.LastIndex(ip, ":"); colon != -1 { + ip = ip[:colon] + } + + return ip +} \ No newline at end of file diff --git a/backend/router/auth.go b/backend/router/auth.go index 9c425ab..7d5baff 100644 --- a/backend/router/auth.go +++ b/backend/router/auth.go @@ -2,63 +2,100 @@ package router import ( "encoding/json" - "errors" + "fmt" + "khairul169/garage-webui/schema" "khairul169/garage-webui/utils" "net/http" - "strings" - - "golang.org/x/crypto/bcrypt" ) type Auth struct{} func (c *Auth) Login(w http.ResponseWriter, r *http.Request) { - var body struct { - Username string `json:"username"` - Password string `json:"password"` - } + fmt.Println("Login attempt started") + var body schema.LoginRequest if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + fmt.Printf("Failed to decode request body: %v\n", err) utils.ResponseError(w, err) return } + fmt.Printf("Login request for user: %s\n", body.Username) - userPass := strings.Split(utils.GetEnv("AUTH_USER_PASS", ""), ":") - if len(userPass) < 2 { - utils.ResponseErrorStatus(w, errors.New("AUTH_USER_PASS not set"), 500) + // Authenticate user + user, err := utils.DB.AuthenticateUser(body.Username, body.Password) + if err != nil { + fmt.Printf("Authentication failed: %v\n", err) + utils.ResponseErrorStatus(w, err, 401) return } + fmt.Println("User authenticated successfully") - if strings.TrimSpace(body.Username) != userPass[0] || bcrypt.CompareHashAndPassword([]byte(userPass[1]), []byte(body.Password)) != nil { - utils.ResponseErrorStatus(w, errors.New("invalid username or password"), 401) + // Create session + session, err := utils.DB.CreateSession(user.ID) + if err != nil { + fmt.Printf("Failed to create session: %v\n", err) + utils.ResponseError(w, err) return } + fmt.Println("Session created successfully") + // Set session in cookie/session store + utils.Session.Set(r, "user_id", user.ID) + utils.Session.Set(r, "session_id", session.ID) utils.Session.Set(r, "authenticated", true) - utils.ResponseSuccess(w, map[string]bool{ - "authenticated": true, - }) + fmt.Println("Session data set") + + response := schema.LoginResponse{ + User: *user, + Token: session.Token, + ExpiresAt: session.ExpiresAt, + } + + fmt.Println("Sending login response") + utils.ResponseSuccess(w, response) } func (c *Auth) Logout(w http.ResponseWriter, r *http.Request) { + // Get session ID from session store + sessionID := utils.Session.Get(r, "session_id") + if sessionID != nil { + // Delete session from database + utils.DB.DeleteSession(sessionID.(string)) + } + utils.Session.Clear(r) - utils.ResponseSuccess(w, true) + utils.ResponseSuccess(w, map[string]bool{"success": true}) } func (c *Auth) GetStatus(w http.ResponseWriter, r *http.Request) { - isAuthenticated := true + fmt.Println("GetStatus: Checking authentication status") + enabled := true // Authentication is always enabled now + authenticated := false + var user *schema.User + authSession := utils.Session.Get(r, "authenticated") - enabled := false + userID := utils.Session.Get(r, "user_id") - if utils.GetEnv("AUTH_USER_PASS", "") != "" { - enabled = true + fmt.Printf("GetStatus: authSession=%v, userID=%v\n", authSession, userID) + + if authSession != nil && authSession.(bool) && userID != nil { + authenticated = true + fmt.Println("GetStatus: User is authenticated") + // Get user details + if u, err := utils.DB.GetUser(userID.(string)); err == nil { + user = u + fmt.Printf("GetStatus: User found: %s\n", user.Username) + } else { + fmt.Printf("GetStatus: Failed to get user: %v\n", err) + } + } else { + fmt.Println("GetStatus: User is not authenticated") } - if authSession != nil && authSession.(bool) { - isAuthenticated = true + response := schema.AuthStatusResponse{ + Enabled: enabled, + Authenticated: authenticated, + User: user, } - utils.ResponseSuccess(w, map[string]bool{ - "enabled": enabled, - "authenticated": isAuthenticated, - }) -} + utils.ResponseSuccess(w, response) +} \ No newline at end of file diff --git a/backend/router/router.go b/backend/router/router.go index 1cf3134..8977096 100644 --- a/backend/router/router.go +++ b/backend/router/router.go @@ -27,6 +27,30 @@ func HandleApiRouter() *http.ServeMux { router.HandleFunc("PUT /browse/{bucket}/{key...}", browse.PutObject) router.HandleFunc("DELETE /browse/{bucket}/{key...}", browse.DeleteObject) + // User management routes + users := &Users{} + router.HandleFunc("GET /users", users.GetAll) + router.HandleFunc("GET /users/{id}", users.GetOne) + router.HandleFunc("POST /users", users.Create) + router.HandleFunc("PUT /users/{id}", users.Update) + router.HandleFunc("DELETE /users/{id}", users.Delete) + + // Tenant management routes + tenants := &Tenants{} + router.HandleFunc("GET /tenants", tenants.GetAll) + router.HandleFunc("GET /tenants/{id}", tenants.GetOne) + router.HandleFunc("POST /tenants", tenants.Create) + router.HandleFunc("PUT /tenants/{id}", tenants.Update) + router.HandleFunc("DELETE /tenants/{id}", tenants.Delete) + router.HandleFunc("GET /tenants/{id}/stats", tenants.GetStats) + + // S3 Configuration routes + s3config := &S3Config{} + router.HandleFunc("GET /s3/config", s3config.GetConfig) + router.HandleFunc("PUT /s3/config", s3config.UpdateConfig) + router.HandleFunc("POST /s3/test", s3config.TestConnection) + router.HandleFunc("GET /s3/status", s3config.GetStatus) + // Proxy request to garage api endpoint router.HandleFunc("/", ProxyHandler) diff --git a/backend/router/s3config.go b/backend/router/s3config.go new file mode 100644 index 0000000..c63dc07 --- /dev/null +++ b/backend/router/s3config.go @@ -0,0 +1,164 @@ +package router + +import ( + "encoding/json" + "khairul169/garage-webui/schema" + "khairul169/garage-webui/utils" + "net/http" +) + +type S3Config struct{} + +// S3ConfigResponse represents S3 configuration response +type S3ConfigResponse struct { + Region string `json:"region"` + Endpoint string `json:"endpoint"` + AdminAPI string `json:"admin_api"` + AdminToken string `json:"admin_token,omitempty"` + WebEndpoint string `json:"web_endpoint,omitempty"` +} + +// UpdateS3ConfigRequest represents S3 config update request +type UpdateS3ConfigRequest struct { + Region *string `json:"region,omitempty"` + Endpoint *string `json:"endpoint,omitempty"` + AdminAPI *string `json:"admin_api,omitempty"` + AdminToken *string `json:"admin_token,omitempty"` + WebEndpoint *string `json:"web_endpoint,omitempty"` +} + +func (s *S3Config) GetConfig(w http.ResponseWriter, r *http.Request) { + // Check permissions + if !s.checkPermission(r, schema.PermissionSystemAdmin) { + utils.ResponseErrorStatus(w, nil, http.StatusForbidden) + return + } + + response := S3ConfigResponse{ + Region: utils.Garage.GetS3Region(), + Endpoint: utils.Garage.GetS3Endpoint(), + AdminAPI: utils.Garage.GetAdminEndpoint(), + WebEndpoint: utils.Garage.GetWebEndpoint(), + // Don't send admin token for security + } + + utils.ResponseSuccess(w, response) +} + +func (s *S3Config) UpdateConfig(w http.ResponseWriter, r *http.Request) { + // Check permissions + if !s.checkPermission(r, schema.PermissionSystemAdmin) { + utils.ResponseErrorStatus(w, nil, http.StatusForbidden) + return + } + + var req UpdateS3ConfigRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + utils.ResponseError(w, err) + return + } + + // Update configuration values + if req.Region != nil { + utils.SetEnv("S3_REGION", *req.Region) + } + if req.Endpoint != nil { + utils.SetEnv("S3_ENDPOINT_URL", *req.Endpoint) + } + if req.AdminAPI != nil { + utils.SetEnv("API_BASE_URL", *req.AdminAPI) + } + if req.AdminToken != nil { + utils.SetEnv("API_ADMIN_KEY", *req.AdminToken) + } + + // Reload garage configuration + if err := utils.Garage.LoadConfig(); err != nil { + utils.ResponseError(w, err) + return + } + + // Return updated config + response := S3ConfigResponse{ + Region: utils.Garage.GetS3Region(), + Endpoint: utils.Garage.GetS3Endpoint(), + AdminAPI: utils.Garage.GetAdminEndpoint(), + WebEndpoint: utils.Garage.GetWebEndpoint(), + } + + utils.ResponseSuccess(w, response) +} + +func (s *S3Config) TestConnection(w http.ResponseWriter, r *http.Request) { + // Check permissions + if !s.checkPermission(r, schema.PermissionSystemAdmin) { + utils.ResponseErrorStatus(w, nil, http.StatusForbidden) + return + } + + // Test Garage API connection + _, err := utils.Garage.Fetch("/status", &utils.FetchOptions{ + Method: "GET", + }) + + if err != nil { + utils.ResponseErrorStatus(w, err, http.StatusServiceUnavailable) + return + } + + utils.ResponseSuccess(w, map[string]interface{}{ + "status": "connected", + "message": "Connection to Garage API successful", + }) +} + +func (s *S3Config) GetStatus(w http.ResponseWriter, r *http.Request) { + // Check permissions + if !s.checkPermission(r, schema.PermissionReadCluster) { + utils.ResponseErrorStatus(w, nil, http.StatusForbidden) + return + } + + // Get status from Garage API + data, err := utils.Garage.Fetch("/status", &utils.FetchOptions{ + Method: "GET", + }) + + if err != nil { + utils.ResponseError(w, err) + return + } + + // Parse response + var status map[string]interface{} + if err := json.Unmarshal(data, &status); err != nil { + utils.ResponseError(w, err) + return + } + + // Add our own status info + response := map[string]interface{}{ + "garage": status, + "webui_version": "1.1.0", + "authentication": true, + "users_count": len(utils.DB.Users), + "tenants_count": len(utils.DB.Tenants), + "sessions_count": len(utils.DB.Sessions), + } + + utils.ResponseSuccess(w, response) +} + +func (s *S3Config) checkPermission(r *http.Request, permission schema.Permission) bool { + userID := utils.Session.Get(r, "user_id") + if userID == nil { + return false + } + + user, err := utils.DB.GetUser(userID.(string)) + if err != nil { + return false + } + + return user.HasPermission(permission) +} \ No newline at end of file diff --git a/backend/router/tenants.go b/backend/router/tenants.go new file mode 100644 index 0000000..05dc517 --- /dev/null +++ b/backend/router/tenants.go @@ -0,0 +1,190 @@ +package router + +import ( + "encoding/json" + "khairul169/garage-webui/schema" + "khairul169/garage-webui/utils" + "net/http" +) + +type Tenants struct{} + +func (t *Tenants) GetAll(w http.ResponseWriter, r *http.Request) { + // Check permissions + if !t.checkPermission(r, schema.PermissionReadTenants) { + utils.ResponseErrorStatus(w, nil, http.StatusForbidden) + return + } + + tenants, err := utils.DB.ListTenants() + if err != nil { + utils.ResponseError(w, err) + return + } + + utils.ResponseSuccess(w, tenants) +} + +func (t *Tenants) GetOne(w http.ResponseWriter, r *http.Request) { + tenantID := r.PathValue("id") + if tenantID == "" { + utils.ResponseErrorStatus(w, nil, http.StatusBadRequest) + return + } + + // Check permissions + if !t.checkPermission(r, schema.PermissionReadTenants) { + utils.ResponseErrorStatus(w, nil, http.StatusForbidden) + return + } + + tenant, err := utils.DB.GetTenant(tenantID) + if err != nil { + utils.ResponseErrorStatus(w, err, http.StatusNotFound) + return + } + + utils.ResponseSuccess(w, tenant) +} + +func (t *Tenants) Create(w http.ResponseWriter, r *http.Request) { + // Check permissions + if !t.checkPermission(r, schema.PermissionWriteTenants) { + utils.ResponseErrorStatus(w, nil, http.StatusForbidden) + return + } + + var req schema.CreateTenantRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + utils.ResponseError(w, err) + return + } + + // Validate request + if req.Name == "" { + utils.ResponseErrorStatus(w, nil, http.StatusBadRequest) + return + } + + tenant, err := utils.DB.CreateTenant(&req) + if err != nil { + utils.ResponseError(w, err) + return + } + + utils.ResponseSuccess(w, tenant) +} + +func (t *Tenants) Update(w http.ResponseWriter, r *http.Request) { + tenantID := r.PathValue("id") + if tenantID == "" { + utils.ResponseErrorStatus(w, nil, http.StatusBadRequest) + return + } + + // Check permissions + if !t.checkPermission(r, schema.PermissionWriteTenants) { + utils.ResponseErrorStatus(w, nil, http.StatusForbidden) + return + } + + var req schema.UpdateTenantRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + utils.ResponseError(w, err) + return + } + + tenant, err := utils.DB.UpdateTenant(tenantID, &req) + if err != nil { + utils.ResponseError(w, err) + return + } + + utils.ResponseSuccess(w, tenant) +} + +func (t *Tenants) Delete(w http.ResponseWriter, r *http.Request) { + tenantID := r.PathValue("id") + if tenantID == "" { + utils.ResponseErrorStatus(w, nil, http.StatusBadRequest) + return + } + + // Check permissions + if !t.checkPermission(r, schema.PermissionDeleteTenants) { + utils.ResponseErrorStatus(w, nil, http.StatusForbidden) + return + } + + err := utils.DB.DeleteTenant(tenantID) + if err != nil { + utils.ResponseError(w, err) + return + } + + utils.ResponseSuccess(w, map[string]bool{"success": true}) +} + +func (t *Tenants) GetStats(w http.ResponseWriter, r *http.Request) { + tenantID := r.PathValue("id") + if tenantID == "" { + utils.ResponseErrorStatus(w, nil, http.StatusBadRequest) + return + } + + // Check permissions + if !t.checkPermission(r, schema.PermissionReadTenants) { + utils.ResponseErrorStatus(w, nil, http.StatusForbidden) + return + } + + // Get tenant + tenant, err := utils.DB.GetTenant(tenantID) + if err != nil { + utils.ResponseErrorStatus(w, err, http.StatusNotFound) + return + } + + // Get stats from Garage API + // This would need to be implemented to get actual usage statistics + // For now, return basic info + stats := map[string]interface{}{ + "tenant": tenant, + "bucket_count": 0, + "key_count": 0, + "total_size": 0, + "user_count": t.getUserCountForTenant(tenantID), + } + + utils.ResponseSuccess(w, stats) +} + +func (t *Tenants) checkPermission(r *http.Request, permission schema.Permission) bool { + userID := utils.Session.Get(r, "user_id") + if userID == nil { + return false + } + + user, err := utils.DB.GetUser(userID.(string)) + if err != nil { + return false + } + + return user.HasPermission(permission) +} + +func (t *Tenants) getUserCountForTenant(tenantID string) int { + users, err := utils.DB.ListUsers() + if err != nil { + return 0 + } + + count := 0 + for _, user := range users { + if user.TenantID != nil && *user.TenantID == tenantID { + count++ + } + } + + return count +} \ No newline at end of file diff --git a/backend/router/users.go b/backend/router/users.go new file mode 100644 index 0000000..a71f54e --- /dev/null +++ b/backend/router/users.go @@ -0,0 +1,147 @@ +package router + +import ( + "encoding/json" + "khairul169/garage-webui/schema" + "khairul169/garage-webui/utils" + "net/http" +) + +type Users struct{} + +func (u *Users) GetAll(w http.ResponseWriter, r *http.Request) { + // Check permissions + if !u.checkPermission(r, schema.PermissionReadUsers) { + utils.ResponseErrorStatus(w, nil, http.StatusForbidden) + return + } + + users, err := utils.DB.ListUsers() + if err != nil { + utils.ResponseError(w, err) + return + } + + utils.ResponseSuccess(w, users) +} + +func (u *Users) GetOne(w http.ResponseWriter, r *http.Request) { + userID := r.PathValue("id") + if userID == "" { + utils.ResponseErrorStatus(w, nil, http.StatusBadRequest) + return + } + + // Check permissions + if !u.checkPermission(r, schema.PermissionReadUsers) { + utils.ResponseErrorStatus(w, nil, http.StatusForbidden) + return + } + + user, err := utils.DB.GetUser(userID) + if err != nil { + utils.ResponseErrorStatus(w, err, http.StatusNotFound) + return + } + + utils.ResponseSuccess(w, user) +} + +func (u *Users) Create(w http.ResponseWriter, r *http.Request) { + // Check permissions + if !u.checkPermission(r, schema.PermissionWriteUsers) { + utils.ResponseErrorStatus(w, nil, http.StatusForbidden) + return + } + + var req schema.CreateUserRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + utils.ResponseError(w, err) + return + } + + // Validate request + if req.Username == "" || req.Email == "" || req.Password == "" { + utils.ResponseErrorStatus(w, nil, http.StatusBadRequest) + return + } + + user, err := utils.DB.CreateUser(&req) + if err != nil { + utils.ResponseError(w, err) + return + } + + utils.ResponseSuccess(w, user) +} + +func (u *Users) Update(w http.ResponseWriter, r *http.Request) { + userID := r.PathValue("id") + if userID == "" { + utils.ResponseErrorStatus(w, nil, http.StatusBadRequest) + return + } + + // Check permissions + if !u.checkPermission(r, schema.PermissionWriteUsers) { + utils.ResponseErrorStatus(w, nil, http.StatusForbidden) + return + } + + var req schema.UpdateUserRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + utils.ResponseError(w, err) + return + } + + user, err := utils.DB.UpdateUser(userID, &req) + if err != nil { + utils.ResponseError(w, err) + return + } + + utils.ResponseSuccess(w, user) +} + +func (u *Users) Delete(w http.ResponseWriter, r *http.Request) { + userID := r.PathValue("id") + if userID == "" { + utils.ResponseErrorStatus(w, nil, http.StatusBadRequest) + return + } + + // Check permissions + if !u.checkPermission(r, schema.PermissionDeleteUsers) { + utils.ResponseErrorStatus(w, nil, http.StatusForbidden) + return + } + + // Prevent self-deletion + currentUserID := utils.Session.Get(r, "user_id") + if currentUserID != nil && currentUserID.(string) == userID { + utils.ResponseErrorStatus(w, nil, http.StatusBadRequest) + return + } + + err := utils.DB.DeleteUser(userID) + if err != nil { + utils.ResponseError(w, err) + return + } + + utils.ResponseSuccess(w, map[string]bool{"success": true}) +} + +func (u *Users) checkPermission(r *http.Request, permission schema.Permission) bool { + userID := utils.Session.Get(r, "user_id") + if userID == nil { + return false + } + + user, err := utils.DB.GetUser(userID.(string)) + if err != nil { + return false + } + + return user.HasPermission(permission) +} \ No newline at end of file diff --git a/backend/schema/config.go b/backend/schema/config.go index 30dc0c6..cd75b06 100644 --- a/backend/schema/config.go +++ b/backend/schema/config.go @@ -26,3 +26,33 @@ type S3Web struct { Index string `json:"index" toml:"index"` RootDomain string `json:"root_domain" toml:"root_domain"` } + +// S3Configuration represents the S3 configuration that can be modified at runtime +type S3Configuration struct { + Region string `json:"region"` + Endpoint string `json:"endpoint"` + AdminAPI string `json:"admin_api"` + WebEndpoint string `json:"web_endpoint"` + MaxBuckets int `json:"max_buckets"` + MaxKeys int `json:"max_keys"` + DefaultQuota int64 `json:"default_quota"` + AllowBucketCRUD bool `json:"allow_bucket_crud"` + AllowKeysCRUD bool `json:"allow_keys_crud"` + RequireAuth bool `json:"require_auth"` +} + +// GetDefaultS3Config returns default S3 configuration +func GetDefaultS3Config() *S3Configuration { + return &S3Configuration{ + Region: "garage", + Endpoint: "http://localhost:3900", + AdminAPI: "http://localhost:3903", + WebEndpoint: "", + MaxBuckets: 10, + MaxKeys: 100, + DefaultQuota: 0, // No limit + AllowBucketCRUD: true, + AllowKeysCRUD: true, + RequireAuth: true, + } +} diff --git a/backend/schema/user.go b/backend/schema/user.go new file mode 100644 index 0000000..f4605f8 --- /dev/null +++ b/backend/schema/user.go @@ -0,0 +1,172 @@ +package schema + +import ( + "time" +) + +type Role string + +const ( + RoleAdmin Role = "admin" + RoleUser Role = "user" + RoleReadOnly Role = "readonly" + RoleTenantAdmin Role = "tenant_admin" +) + +type Permission string + +const ( + PermissionReadBuckets Permission = "read_buckets" + PermissionWriteBuckets Permission = "write_buckets" + PermissionDeleteBuckets Permission = "delete_buckets" + PermissionReadKeys Permission = "read_keys" + PermissionWriteKeys Permission = "write_keys" + PermissionDeleteKeys Permission = "delete_keys" + PermissionReadCluster Permission = "read_cluster" + PermissionWriteCluster Permission = "write_cluster" + PermissionReadUsers Permission = "read_users" + PermissionWriteUsers Permission = "write_users" + PermissionDeleteUsers Permission = "delete_users" + PermissionReadTenants Permission = "read_tenants" + PermissionWriteTenants Permission = "write_tenants" + PermissionDeleteTenants Permission = "delete_tenants" + PermissionSystemAdmin Permission = "system_admin" +) + +type User struct { + ID string `json:"id"` + Username string `json:"username"` + Email string `json:"email"` + PasswordHash string `json:"password_hash"` + Role Role `json:"role"` + TenantID *string `json:"tenant_id"` + Enabled bool `json:"enabled"` + LastLogin *time.Time `json:"last_login"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type Tenant struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Enabled bool `json:"enabled"` + MaxBuckets int `json:"max_buckets"` + MaxKeys int `json:"max_keys"` + QuotaBytes *int64 `json:"quota_bytes"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type Session struct { + ID string `json:"id"` + UserID string `json:"user_id"` + Token string `json:"-"` + ExpiresAt time.Time `json:"expires_at"` + CreatedAt time.Time `json:"created_at"` +} + +// CreateUserRequest represents the request to create a new user +type CreateUserRequest struct { + Username string `json:"username"` + Email string `json:"email"` + Password string `json:"password"` + Role Role `json:"role"` + TenantID *string `json:"tenant_id"` + Enabled bool `json:"enabled"` +} + +// UpdateUserRequest represents the request to update a user +type UpdateUserRequest struct { + Username *string `json:"username,omitempty"` + Email *string `json:"email,omitempty"` + Password *string `json:"password,omitempty"` + Role *Role `json:"role,omitempty"` + TenantID *string `json:"tenant_id,omitempty"` + Enabled *bool `json:"enabled,omitempty"` +} + +// CreateTenantRequest represents the request to create a new tenant +type CreateTenantRequest struct { + Name string `json:"name"` + Description string `json:"description"` + Enabled bool `json:"enabled"` + MaxBuckets int `json:"max_buckets"` + MaxKeys int `json:"max_keys"` + QuotaBytes *int64 `json:"quota_bytes"` +} + +// UpdateTenantRequest represents the request to update a tenant +type UpdateTenantRequest struct { + Name *string `json:"name,omitempty"` + Description *string `json:"description,omitempty"` + Enabled *bool `json:"enabled,omitempty"` + MaxBuckets *int `json:"max_buckets,omitempty"` + MaxKeys *int `json:"max_keys,omitempty"` + QuotaBytes *int64 `json:"quota_bytes,omitempty"` +} + +// LoginRequest represents the login request +type LoginRequest struct { + Username string `json:"username"` + Password string `json:"password"` +} + +// LoginResponse represents the login response +type LoginResponse struct { + User User `json:"user"` + Token string `json:"token"` + ExpiresAt time.Time `json:"expires_at"` +} + +// AuthStatusResponse represents the auth status response +type AuthStatusResponse struct { + Enabled bool `json:"enabled"` + Authenticated bool `json:"authenticated"` + User *User `json:"user,omitempty"` +} + +// GetRolePermissions returns the permissions for a given role +func GetRolePermissions(role Role) []Permission { + switch role { + case RoleAdmin: + return []Permission{ + PermissionSystemAdmin, + PermissionReadBuckets, PermissionWriteBuckets, PermissionDeleteBuckets, + PermissionReadKeys, PermissionWriteKeys, PermissionDeleteKeys, + PermissionReadCluster, PermissionWriteCluster, + PermissionReadUsers, PermissionWriteUsers, PermissionDeleteUsers, + PermissionReadTenants, PermissionWriteTenants, PermissionDeleteTenants, + } + case RoleTenantAdmin: + return []Permission{ + PermissionReadBuckets, PermissionWriteBuckets, PermissionDeleteBuckets, + PermissionReadKeys, PermissionWriteKeys, PermissionDeleteKeys, + PermissionReadUsers, PermissionWriteUsers, PermissionDeleteUsers, + } + case RoleUser: + return []Permission{ + PermissionReadBuckets, PermissionWriteBuckets, + PermissionReadKeys, PermissionWriteKeys, + } + case RoleReadOnly: + return []Permission{ + PermissionReadBuckets, + PermissionReadKeys, + PermissionReadCluster, + } + default: + return []Permission{} + } +} + +// HasPermission checks if a user has a specific permission +func (u *User) HasPermission(permission Permission) bool { + permissions := GetRolePermissions(u.Role) + for _, p := range permissions { + if p == permission { + return true + } + } + return false +} \ No newline at end of file diff --git a/backend/utils/database.go b/backend/utils/database.go new file mode 100644 index 0000000..9ffb0d3 --- /dev/null +++ b/backend/utils/database.go @@ -0,0 +1,500 @@ +package utils + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "khairul169/garage-webui/schema" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "golang.org/x/crypto/bcrypt" +) + +type Database struct { + Users map[string]*schema.User `json:"users"` + Tenants map[string]*schema.Tenant `json:"tenants"` + Sessions map[string]*schema.Session `json:"sessions"` + mutex sync.RWMutex +} + +var DB = &Database{ + Users: make(map[string]*schema.User), + Tenants: make(map[string]*schema.Tenant), + Sessions: make(map[string]*schema.Session), +} + +func InitDatabase() error { + // Create data directory if it doesn't exist + dataDir := GetEnv("DATA_DIR", "./data") + if err := os.MkdirAll(dataDir, 0755); err != nil { + return fmt.Errorf("failed to create data directory: %w", err) + } + + // Load existing data + if err := DB.Load(); err != nil { + return fmt.Errorf("failed to load database: %w", err) + } + + // Create default admin user if no users exist + if len(DB.Users) == 0 { + if err := DB.CreateDefaultAdmin(); err != nil { + return fmt.Errorf("failed to create default admin: %w", err) + } + } + + return nil +} + +func (db *Database) Load() error { + db.mutex.Lock() + defer db.mutex.Unlock() + + dataPath := filepath.Join(GetEnv("DATA_DIR", "./data"), "database.json") + + // If file doesn't exist, start with empty database + if _, err := os.Stat(dataPath); os.IsNotExist(err) { + return nil + } + + data, err := os.ReadFile(dataPath) + if err != nil { + return err + } + + return json.Unmarshal(data, db) +} + +func (db *Database) Save() error { + fmt.Println("Save: Attempting to acquire lock") + db.mutex.Lock() + defer db.mutex.Unlock() + fmt.Println("Save: Lock acquired, marshaling data") + + dataPath := filepath.Join(GetEnv("DATA_DIR", "./data"), "database.json") + + data, err := json.MarshalIndent(db, "", " ") + if err != nil { + fmt.Printf("Save: Marshal failed: %v\n", err) + return err + } + fmt.Println("Save: Data marshaled, writing to file") + + return os.WriteFile(dataPath, data, 0600) +} + +// saveUnsafe saves without acquiring locks (for use when lock is already held) +func (db *Database) saveUnsafe() error { + fmt.Println("saveUnsafe: Marshaling data without lock") + dataPath := filepath.Join(GetEnv("DATA_DIR", "./data"), "database.json") + + data, err := json.MarshalIndent(db, "", " ") + if err != nil { + fmt.Printf("saveUnsafe: Marshal failed: %v\n", err) + return err + } + fmt.Println("saveUnsafe: Data marshaled, writing to file") + + return os.WriteFile(dataPath, data, 0600) +} + +func (db *Database) CreateDefaultAdmin() error { + // Check if we should create from environment variables (legacy support) + userPass := strings.Split(GetEnv("AUTH_USER_PASS", ""), ":") + if len(userPass) >= 2 { + return db.createUserFromEnv(userPass[0], userPass[1]) + } + + // Create default admin user + defaultPassword := "admin" + fmt.Printf("Creating default admin user with password: %s\n", defaultPassword) + fmt.Println("IMPORTANT: Change this password after first login!") + + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(defaultPassword), bcrypt.DefaultCost) + if err != nil { + return err + } + + admin := &schema.User{ + ID: GenerateID(), + Username: "admin", + Email: "admin@localhost", + PasswordHash: string(hashedPassword), + Role: schema.RoleAdmin, + Enabled: true, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + db.Users[admin.ID] = admin + return db.Save() +} + +func (db *Database) createUserFromEnv(username, passwordHash string) error { + admin := &schema.User{ + ID: GenerateID(), + Username: username, + Email: username + "@localhost", + PasswordHash: passwordHash, + Role: schema.RoleAdmin, + Enabled: true, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + db.Users[admin.ID] = admin + return db.Save() +} + +// User operations +func (db *Database) CreateUser(req *schema.CreateUserRequest) (*schema.User, error) { + db.mutex.Lock() + defer db.mutex.Unlock() + + // Check if username already exists + for _, user := range db.Users { + if user.Username == req.Username { + return nil, errors.New("username already exists") + } + if user.Email == req.Email { + return nil, errors.New("email already exists") + } + } + + // Hash password + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) + if err != nil { + return nil, err + } + + user := &schema.User{ + ID: GenerateID(), + Username: req.Username, + Email: req.Email, + PasswordHash: string(hashedPassword), + Role: req.Role, + TenantID: req.TenantID, + Enabled: req.Enabled, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + db.Users[user.ID] = user + + if err := db.Save(); err != nil { + return nil, err + } + + return user, nil +} + +func (db *Database) GetUser(id string) (*schema.User, error) { + db.mutex.RLock() + defer db.mutex.RUnlock() + + user, exists := db.Users[id] + if !exists { + return nil, errors.New("user not found") + } + + return user, nil +} + +func (db *Database) GetUserByUsername(username string) (*schema.User, error) { + db.mutex.RLock() + defer db.mutex.RUnlock() + + for _, user := range db.Users { + if user.Username == username { + return user, nil + } + } + + return nil, errors.New("user not found") +} + +func (db *Database) UpdateUser(id string, req *schema.UpdateUserRequest) (*schema.User, error) { + db.mutex.Lock() + defer db.mutex.Unlock() + + user, exists := db.Users[id] + if !exists { + return nil, errors.New("user not found") + } + + if req.Username != nil { + user.Username = *req.Username + } + if req.Email != nil { + user.Email = *req.Email + } + if req.Password != nil { + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(*req.Password), bcrypt.DefaultCost) + if err != nil { + return nil, err + } + user.PasswordHash = string(hashedPassword) + } + if req.Role != nil { + user.Role = *req.Role + } + if req.TenantID != nil { + user.TenantID = req.TenantID + } + if req.Enabled != nil { + user.Enabled = *req.Enabled + } + + user.UpdatedAt = time.Now() + + if err := db.Save(); err != nil { + return nil, err + } + + return user, nil +} + +func (db *Database) DeleteUser(id string) error { + db.mutex.Lock() + defer db.mutex.Unlock() + + if _, exists := db.Users[id]; !exists { + return errors.New("user not found") + } + + delete(db.Users, id) + return db.Save() +} + +func (db *Database) ListUsers() ([]*schema.User, error) { + db.mutex.RLock() + defer db.mutex.RUnlock() + + users := make([]*schema.User, 0, len(db.Users)) + for _, user := range db.Users { + users = append(users, user) + } + + return users, nil +} + +// Tenant operations +func (db *Database) CreateTenant(req *schema.CreateTenantRequest) (*schema.Tenant, error) { + db.mutex.Lock() + defer db.mutex.Unlock() + + // Check if name already exists + for _, tenant := range db.Tenants { + if tenant.Name == req.Name { + return nil, errors.New("tenant name already exists") + } + } + + tenant := &schema.Tenant{ + ID: GenerateID(), + Name: req.Name, + Description: req.Description, + Enabled: req.Enabled, + MaxBuckets: req.MaxBuckets, + MaxKeys: req.MaxKeys, + QuotaBytes: req.QuotaBytes, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + db.Tenants[tenant.ID] = tenant + + if err := db.Save(); err != nil { + return nil, err + } + + return tenant, nil +} + +func (db *Database) GetTenant(id string) (*schema.Tenant, error) { + db.mutex.RLock() + defer db.mutex.RUnlock() + + tenant, exists := db.Tenants[id] + if !exists { + return nil, errors.New("tenant not found") + } + + return tenant, nil +} + +func (db *Database) UpdateTenant(id string, req *schema.UpdateTenantRequest) (*schema.Tenant, error) { + db.mutex.Lock() + defer db.mutex.Unlock() + + tenant, exists := db.Tenants[id] + if !exists { + return nil, errors.New("tenant not found") + } + + if req.Name != nil { + tenant.Name = *req.Name + } + if req.Description != nil { + tenant.Description = *req.Description + } + if req.Enabled != nil { + tenant.Enabled = *req.Enabled + } + if req.MaxBuckets != nil { + tenant.MaxBuckets = *req.MaxBuckets + } + if req.MaxKeys != nil { + tenant.MaxKeys = *req.MaxKeys + } + if req.QuotaBytes != nil { + tenant.QuotaBytes = req.QuotaBytes + } + + tenant.UpdatedAt = time.Now() + + if err := db.Save(); err != nil { + return nil, err + } + + return tenant, nil +} + +func (db *Database) DeleteTenant(id string) error { + db.mutex.Lock() + defer db.mutex.Unlock() + + if _, exists := db.Tenants[id]; !exists { + return errors.New("tenant not found") + } + + delete(db.Tenants, id) + return db.Save() +} + +func (db *Database) ListTenants() ([]*schema.Tenant, error) { + db.mutex.RLock() + defer db.mutex.RUnlock() + + tenants := make([]*schema.Tenant, 0, len(db.Tenants)) + for _, tenant := range db.Tenants { + tenants = append(tenants, tenant) + } + + return tenants, nil +} + +// Session operations +func (db *Database) CreateSession(userID string) (*schema.Session, error) { + fmt.Println("CreateSession: Starting session creation") + db.mutex.Lock() + defer db.mutex.Unlock() + + fmt.Println("CreateSession: Generating token") + token, err := GenerateToken() + if err != nil { + fmt.Printf("CreateSession: Token generation failed: %v\n", err) + return nil, err + } + fmt.Println("CreateSession: Token generated successfully") + + session := &schema.Session{ + ID: GenerateID(), + UserID: userID, + Token: token, + ExpiresAt: time.Now().Add(24 * time.Hour), // 24 hours expiry + CreatedAt: time.Now(), + } + + db.Sessions[session.ID] = session + fmt.Println("CreateSession: Session stored in memory") + + fmt.Println("CreateSession: Saving to database") + if err := db.saveUnsafe(); err != nil { + fmt.Printf("CreateSession: Database save failed: %v\n", err) + return nil, err + } + fmt.Println("CreateSession: Database saved successfully") + + return session, nil +} + +func (db *Database) GetSessionByToken(token string) (*schema.Session, error) { + db.mutex.RLock() + defer db.mutex.RUnlock() + + for _, session := range db.Sessions { + if session.Token == token { + if time.Now().After(session.ExpiresAt) { + return nil, errors.New("session expired") + } + return session, nil + } + } + + return nil, errors.New("session not found") +} + +func (db *Database) DeleteSession(id string) error { + db.mutex.Lock() + defer db.mutex.Unlock() + + delete(db.Sessions, id) + return db.saveUnsafe() +} + +func (db *Database) CleanupExpiredSessions() error { + db.mutex.Lock() + defer db.mutex.Unlock() + + now := time.Now() + for id, session := range db.Sessions { + if now.After(session.ExpiresAt) { + delete(db.Sessions, id) + } + } + + return db.saveUnsafe() +} + +// Utility functions +func GenerateID() string { + bytes := make([]byte, 16) + rand.Read(bytes) + return hex.EncodeToString(bytes) +} + +func GenerateToken() (string, error) { + bytes := make([]byte, 32) + if _, err := rand.Read(bytes); err != nil { + return "", err + } + return hex.EncodeToString(bytes), nil +} + +// AuthenticateUser validates credentials and returns user +func (db *Database) AuthenticateUser(username, password string) (*schema.User, error) { + user, err := db.GetUserByUsername(username) + if err != nil { + return nil, errors.New("invalid credentials") + } + + if !user.Enabled { + return nil, errors.New("user account is disabled") + } + + if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil { + return nil, errors.New("invalid credentials") + } + + // Update last login + user.LastLogin = &[]time.Time{time.Now()}[0] + // Note: last login time will be saved when session is created + + return user, nil +} \ No newline at end of file diff --git a/backend/utils/garage.go b/backend/utils/garage.go index bde29ab..4d8707e 100644 --- a/backend/utils/garage.go +++ b/backend/utils/garage.go @@ -85,6 +85,27 @@ func (g *garage) GetS3Region() string { return g.Config.S3API.S3Region } +func (g *garage) GetWebEndpoint() string { + endpoint := os.Getenv("S3_WEB_ENDPOINT_URL") + if len(endpoint) > 0 { + return endpoint + } + + if len(g.Config.S3Web.BindAddr) == 0 { + return "" + } + + host := strings.Split(g.Config.RPCPublicAddr, ":")[0] + port := LastString(strings.Split(g.Config.S3Web.BindAddr, ":")) + + endpoint = fmt.Sprintf("%s:%s", host, port) + if !strings.HasPrefix(endpoint, "http") { + endpoint = fmt.Sprintf("http://%s", endpoint) + } + + return endpoint +} + func (g *garage) GetAdminKey() string { key := os.Getenv("API_ADMIN_KEY") if len(key) > 0 { diff --git a/backend/utils/utils.go b/backend/utils/utils.go index 30c6c6d..5a01435 100644 --- a/backend/utils/utils.go +++ b/backend/utils/utils.go @@ -2,10 +2,15 @@ package utils import ( "encoding/json" + "log" "net/http" "os" + "strings" + "sync" ) +var envMutex sync.RWMutex + func GetEnv(key, defaultValue string) string { value := os.Getenv(key) if len(value) == 0 { @@ -14,22 +19,72 @@ func GetEnv(key, defaultValue string) string { return value } +// SetEnv sets an environment variable (thread-safe) +func SetEnv(key, value string) error { + envMutex.Lock() + defer envMutex.Unlock() + return os.Setenv(key, value) +} + +// GetAllEnv returns all environment variables as a map +func GetAllEnv() map[string]string { + envMutex.RLock() + defer envMutex.RUnlock() + + result := make(map[string]string) + for _, env := range os.Environ() { + parts := strings.SplitN(env, "=", 2) + if len(parts) == 2 { + result[parts[0]] = parts[1] + } + } + return result +} + func LastString(str []string) string { return str[len(str)-1] } func ResponseError(w http.ResponseWriter, err error) { - w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte(err.Error())) + ResponseErrorStatus(w, err, http.StatusInternalServerError) } func ResponseErrorStatus(w http.ResponseWriter, err error, status int) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("X-Frame-Options", "DENY") + w.Header().Set("X-XSS-Protection", "1; mode=block") w.WriteHeader(status) - w.Write([]byte(err.Error())) + + message := "Internal server error" + if err != nil { + message = err.Error() + } + + response := map[string]interface{}{ + "success": false, + "message": message, + } + + // Add error details for development (remove in production) + if status >= 500 && err != nil { + log.Printf("Server error: %v", err) + } + + json.NewEncoder(w).Encode(response) } func ResponseSuccess(w http.ResponseWriter, data interface{}) { w.Header().Set("Content-Type", "application/json") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("X-Frame-Options", "DENY") + w.Header().Set("X-XSS-Protection", "1; mode=block") w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(data) + + response := map[string]interface{}{ + "success": true, + "data": data, + } + + json.NewEncoder(w).Encode(response) } diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..5be1abf --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,41 @@ +services: + # Garage - unchanged, uses official image + garage: + image: dxflrs/garage:v2.0.0 + container_name: garage-dev + volumes: + - ./garage.toml:/etc/garage.toml + - ./dev-data/garage/meta:/var/lib/garage/meta + - ./dev-data/garage/data:/var/lib/garage/data + ports: + - "3900:3900" # S3 API + - "3901:3901" # RPC + - "3902:3902" # S3 Web + - "3903:3903" # Admin API + + # WebUI - Single service for development + webui: + build: + context: . + dockerfile: Dockerfile.dev + container_name: garage-webui-dev + ports: + - "5173:5173" # Frontend dev server + - "3909:3909" # Backend API + volumes: + - .:/app + - ./garage.toml:/etc/garage.toml:ro + - /app/node_modules + - /app/backend/tmp + environment: + - VITE_API_URL=http://127.0.0.1:3909 + - CONFIG_PATH=/etc/garage.toml + - API_BASE_URL=http://garage:3903 + - S3_ENDPOINT_URL=http://garage:3900 + - DATA_DIR=/app/data + - CORS_ALLOWED_ORIGINS=http://localhost:5173 + - RATE_LIMIT_REQUESTS=1000 + - RATE_LIMIT_WINDOW=1m + - CHOKIDAR_USEPOLLING=true + depends_on: + - garage diff --git a/garage.toml.example b/garage.toml.example new file mode 100644 index 0000000..a2cc804 --- /dev/null +++ b/garage.toml.example @@ -0,0 +1,58 @@ +# Garage Configuration Example for Development +# Copy this file to garage.toml and modify as needed + +# Data storage locations +metadata_dir = "/var/lib/garage/meta" +data_dir = "/var/lib/garage/data" + +# Database engine (sqlite for development, lmdb for production) +db_engine = "sqlite" + +# Automatic metadata snapshots (optional) +metadata_auto_snapshot_interval = "6h" + +# Replication settings +replication_factor = 1 # Use 3 for production clusters + +# Compression level (1-6, higher = better compression but slower) +compression_level = 2 + +# RPC Configuration +rpc_bind_addr = "[::]:3901" +rpc_public_addr = "127.0.0.1:3901" # Change to your public IP for production +rpc_secret = "1799bccfd7411abbccc9a3f8a0ccc314f5d0d9690e9a2cc4de5ba8faa24a3ee2" # CHANGE THIS + +# S3 API Configuration +[s3_api] +s3_region = "garage" +api_bind_addr = "[::]:3900" +root_domain = ".s3.garage.localhost" # Change for production + +# S3 Web Interface (optional) +[s3_web] +bind_addr = "[::]:3902" +root_domain = ".web.garage.localhost" # Change for production +index = "index.html" + +# Admin API Configuration (required for WebUI) +[admin] +api_bind_addr = "[::]:3903" +admin_token = "dev-admin-token-change-for-production" # CHANGE THIS +metrics_token = "dev-metrics-token-change-for-production" # CHANGE THIS + +# Examples of production configurations: + +# [s3_api] +# s3_region = "us-east-1" +# api_bind_addr = "[::]:3900" +# root_domain = ".s3.yourdomain.com" + +# [s3_web] +# bind_addr = "[::]:3902" +# root_domain = ".web.yourdomain.com" +# index = "index.html" + +# [admin] +# api_bind_addr = "127.0.0.1:3903" # Bind only to localhost for security +# admin_token = "your-secure-admin-token-here" +# metrics_token = "your-secure-metrics-token-here" \ No newline at end of file diff --git a/package.json b/package.json index 706c44a..fd3b916 100644 --- a/package.json +++ b/package.json @@ -5,11 +5,21 @@ "type": "module", "scripts": { "dev:client": "vite", + "dev:client:host": "vite --host", "build": "tsc -b && vite build", + "build:dev": "tsc -b && vite build --mode development", "lint": "eslint .", + "lint:fix": "eslint . --fix", "preview": "vite preview", "dev:server": "cd backend && air", - "dev": "concurrently \"npm run dev:client\" \"npm run dev:server\"" + "dev": "concurrently \"npm run dev:client\" \"npm run dev:server\"", + "dev:docker": "docker-compose -f docker-compose.dev.yml up --build", + "dev:docker:clean": "docker-compose -f docker-compose.dev.yml down -v && docker system prune -f", + "type-check": "tsc --noEmit", + "clean": "rm -rf dist node_modules/.vite", + "install:backend": "cd backend && go mod download", + "build:backend": "cd backend && go build -o main .", + "test:backend": "cd backend && go test ./..." }, "dependencies": { "@hookform/resolvers": "^3.9.0", diff --git a/src/app/router.tsx b/src/app/router.tsx index 5911931..a39afb5 100644 --- a/src/app/router.tsx +++ b/src/app/router.tsx @@ -10,6 +10,7 @@ const HomePage = lazy(() => import("@/pages/home/page")); const BucketsPage = lazy(() => import("@/pages/buckets/page")); const ManageBucketPage = lazy(() => import("@/pages/buckets/manage/page")); const KeysPage = lazy(() => import("@/pages/keys/page")); +const AdminPage = lazy(() => import("@/pages/admin/page")); const router = createBrowserRouter( [ @@ -46,6 +47,10 @@ const router = createBrowserRouter( path: "keys", Component: KeysPage, }, + { + path: "admin", + Component: AdminPage, + }, ], }, ], diff --git a/src/components/containers/sidebar.tsx b/src/components/containers/sidebar.tsx index 8e423b3..4b22b37 100644 --- a/src/components/containers/sidebar.tsx +++ b/src/components/containers/sidebar.tsx @@ -6,18 +6,19 @@ import { LayoutDashboard, LogOut, Palette, + Settings, } from "lucide-react"; import { Dropdown, Menu } from "react-daisyui"; -import { Link, useLocation } from "react-router-dom"; +import { Link, useLocation, useNavigate } from "react-router-dom"; import Button from "../ui/button"; import { themes } from "@/app/themes"; import appStore from "@/stores/app-store"; import garageLogo from "@/assets/garage-logo.svg"; -import { useMutation } from "@tanstack/react-query"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import api from "@/lib/api"; import * as utils from "@/lib/utils"; import { toast } from "sonner"; -import { useAuth } from "@/hooks/useAuth"; +import { useAuth, usePermissions } from "@/hooks/useAuth"; const pages = [ { icon: LayoutDashboard, title: "Dashboard", path: "/", exact: true }, @@ -29,6 +30,9 @@ const pages = [ const Sidebar = () => { const { pathname } = useLocation(); const auth = useAuth(); + const { hasAnyPermission } = usePermissions(); + + const showAdminLink = hasAnyPermission(["system_admin", "read_users", "read_tenants"]); return (