From df40cc57e1d6ae953b67ac3e6d8a192633db43e0 Mon Sep 17 00:00:00 2001 From: domrichardson <100129001+domrichardson@users.noreply.github.com> Date: Tue, 24 Mar 2026 16:03:04 +0000 Subject: [PATCH] first commit --- .env.example | 22 + .gitignore | 45 + ENV_SETUP.md | 98 + PERMISSIONS.md | 69 + QUICKSTART.md | 304 ++ README.md | 426 +++ SECURITY.md | 284 ++ backend/.env.example | 28 + backend/cmd/server/main.go | 433 +++ backend/go.mod | 23 + backend/go.sum | 56 + backend/internal/application/dto/dto.go | 440 +++ .../application/services/admin_service.go | 313 +++ .../application/services/auth_service.go | 592 ++++ .../application/services/category_service.go | 283 ++ .../application/services/note_service.go | 427 +++ .../services/permission_service.go | 174 ++ .../application/services/space_service.go | 319 +++ backend/internal/domain/entities/auth.go | 51 + backend/internal/domain/entities/note.go | 55 + .../domain/entities/permission_group.go | 83 + backend/internal/domain/entities/space.go | 41 + backend/internal/domain/entities/user.go | 51 + .../domain/repositories/additional.go | 40 + .../domain/repositories/interfaces.go | 215 ++ backend/internal/infrastructure/auth/jwt.go | 145 + .../database/additional_repositories.go | 322 +++ .../infrastructure/database/database.go | 92 + .../database/group_repository.go | 129 + .../database/note_repository.go | 338 +++ .../database/space_repository.go | 249 ++ .../database/user_repository.go | 120 + .../infrastructure/security/encryption.go | 79 + .../infrastructure/security/password.go | 121 + .../interfaces/handlers/admin_handler.go | 294 ++ .../interfaces/handlers/auth_handler.go | 299 ++ .../interfaces/handlers/category_handler.go | 212 ++ .../interfaces/handlers/note_handler.go | 266 ++ .../interfaces/handlers/public_handler.go | 156 + .../interfaces/handlers/settings_handler.go | 30 + .../interfaces/handlers/space_handler.go | 295 ++ .../internal/interfaces/middleware/auth.go | 91 + .../interfaces/middleware/security.go | 91 + backend/tests/integration/integration_test.go | 55 + backend/tests/unit/auth_service_test.go | 185 ++ devops/docker/Dockerfile | 42 + devops/docker/nginx.conf | 78 + devops/kubernetes/deployment.yaml | 240 ++ docker-compose.yml | 68 + frontend/.env.example | 10 + frontend/index.html | 12 + frontend/package-lock.json | 2502 +++++++++++++++++ frontend/package.json | 29 + frontend/src/App.vue | 1081 +++++++ frontend/src/assets/styles/main.css | 45 + frontend/src/components/AdminSpaceModal.vue | 254 ++ frontend/src/components/CategoryTree.vue | 278 ++ .../src/components/CreateCategoryModal.vue | 93 + frontend/src/components/CreateNoteModal.vue | 156 + frontend/src/components/CreateSpaceModal.vue | 52 + .../components/ManageAuthProvidersModal.vue | 265 ++ frontend/src/components/NoteEditor.vue | 287 ++ frontend/src/components/NoteList.vue | 221 ++ frontend/src/components/NoteViewer.vue | 175 ++ .../src/components/SpaceSettingsModal.vue | 269 ++ frontend/src/main.js | 13 + frontend/src/pages/Admin.vue | 631 +++++ frontend/src/pages/Home.vue | 7 + frontend/src/pages/Login.vue | 298 ++ frontend/src/pages/PublicSpace.vue | 395 +++ frontend/src/pages/Register.vue | 205 ++ frontend/src/router/index.js | 123 + frontend/src/services/apiClient.js | 27 + frontend/src/stores/authStore.js | 108 + frontend/src/stores/settingsStore.js | 47 + frontend/src/stores/spaceStore.js | 224 ++ frontend/src/utils/noteSort.js | 17 + frontend/tests/auth.spec.js | 39 + frontend/vite.config.js | 20 + frontend/vitest.config.js | 14 + 80 files changed, 16766 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 ENV_SETUP.md create mode 100644 PERMISSIONS.md create mode 100644 QUICKSTART.md create mode 100644 README.md create mode 100644 SECURITY.md create mode 100644 backend/.env.example create mode 100644 backend/cmd/server/main.go create mode 100644 backend/go.mod create mode 100644 backend/go.sum create mode 100644 backend/internal/application/dto/dto.go create mode 100644 backend/internal/application/services/admin_service.go create mode 100644 backend/internal/application/services/auth_service.go create mode 100644 backend/internal/application/services/category_service.go create mode 100644 backend/internal/application/services/note_service.go create mode 100644 backend/internal/application/services/permission_service.go create mode 100644 backend/internal/application/services/space_service.go create mode 100644 backend/internal/domain/entities/auth.go create mode 100644 backend/internal/domain/entities/note.go create mode 100644 backend/internal/domain/entities/permission_group.go create mode 100644 backend/internal/domain/entities/space.go create mode 100644 backend/internal/domain/entities/user.go create mode 100644 backend/internal/domain/repositories/additional.go create mode 100644 backend/internal/domain/repositories/interfaces.go create mode 100644 backend/internal/infrastructure/auth/jwt.go create mode 100644 backend/internal/infrastructure/database/additional_repositories.go create mode 100644 backend/internal/infrastructure/database/database.go create mode 100644 backend/internal/infrastructure/database/group_repository.go create mode 100644 backend/internal/infrastructure/database/note_repository.go create mode 100644 backend/internal/infrastructure/database/space_repository.go create mode 100644 backend/internal/infrastructure/database/user_repository.go create mode 100644 backend/internal/infrastructure/security/encryption.go create mode 100644 backend/internal/infrastructure/security/password.go create mode 100644 backend/internal/interfaces/handlers/admin_handler.go create mode 100644 backend/internal/interfaces/handlers/auth_handler.go create mode 100644 backend/internal/interfaces/handlers/category_handler.go create mode 100644 backend/internal/interfaces/handlers/note_handler.go create mode 100644 backend/internal/interfaces/handlers/public_handler.go create mode 100644 backend/internal/interfaces/handlers/settings_handler.go create mode 100644 backend/internal/interfaces/handlers/space_handler.go create mode 100644 backend/internal/interfaces/middleware/auth.go create mode 100644 backend/internal/interfaces/middleware/security.go create mode 100644 backend/tests/integration/integration_test.go create mode 100644 backend/tests/unit/auth_service_test.go create mode 100644 devops/docker/Dockerfile create mode 100644 devops/docker/nginx.conf create mode 100644 devops/kubernetes/deployment.yaml create mode 100644 docker-compose.yml create mode 100644 frontend/.env.example create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/src/App.vue create mode 100644 frontend/src/assets/styles/main.css create mode 100644 frontend/src/components/AdminSpaceModal.vue create mode 100644 frontend/src/components/CategoryTree.vue create mode 100644 frontend/src/components/CreateCategoryModal.vue create mode 100644 frontend/src/components/CreateNoteModal.vue create mode 100644 frontend/src/components/CreateSpaceModal.vue create mode 100644 frontend/src/components/ManageAuthProvidersModal.vue create mode 100644 frontend/src/components/NoteEditor.vue create mode 100644 frontend/src/components/NoteList.vue create mode 100644 frontend/src/components/NoteViewer.vue create mode 100644 frontend/src/components/SpaceSettingsModal.vue create mode 100644 frontend/src/main.js create mode 100644 frontend/src/pages/Admin.vue create mode 100644 frontend/src/pages/Home.vue create mode 100644 frontend/src/pages/Login.vue create mode 100644 frontend/src/pages/PublicSpace.vue create mode 100644 frontend/src/pages/Register.vue create mode 100644 frontend/src/router/index.js create mode 100644 frontend/src/services/apiClient.js create mode 100644 frontend/src/stores/authStore.js create mode 100644 frontend/src/stores/settingsStore.js create mode 100644 frontend/src/stores/spaceStore.js create mode 100644 frontend/src/utils/noteSort.js create mode 100644 frontend/tests/auth.spec.js create mode 100644 frontend/vite.config.js create mode 100644 frontend/vitest.config.js diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8f4df57 --- /dev/null +++ b/.env.example @@ -0,0 +1,22 @@ +# Docker Compose Environment Configuration (Example) +# Copy this file to .env and update values as needed + +# MongoDB Configuration +MONGODB_URI=mongodb://admin:password@mongodb:27017/noteapp?authSource=admin + +# Backend Configuration +BACKEND_PORT=8080 +JWT_SECRET=your-super-secret-jwt-key-minimum-32-characters-change-in-production +ENCRYPTION_KEY=A5CC60AB92FCA026F5477DC486555882 +FRONTEND_URL="http://localhost" + +VITE_API_BASE_URL="http://localhost" + +# Default Admin +DEFAULT_ADMIN_EMAIL=admin@notely.local +DEFAULT_ADMIN_USERNAME=admin +DEFAULT_ADMIN_PASSWORD=ChangeThisAdminPassword123! + +# Nginx Configuration +NGINX_HTTP_PORT=80 +NGINX_HTTPS_PORT=443 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..46aceb2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,45 @@ +.env +.env.local +.env.*.local + +# Dependencies +node_modules/ +vendor/ + +# Build outputs +dist/ +build/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Docker +.docker/ + +# Logs +*.log +logs/ + +# Database +*.db +*.sqlite +data/ + +# Secrets (never commit!) +*.key +*.pem +secret* + +# Go +*.o +*.a +*.so +.go/ diff --git a/ENV_SETUP.md b/ENV_SETUP.md new file mode 100644 index 0000000..d5a39aa --- /dev/null +++ b/ENV_SETUP.md @@ -0,0 +1,98 @@ +# Environment Configuration + +Copy `.env.example` files and configure for your environment: + +## Backend (.env) + +```env +# MongoDB +MONGODB_URI=mongodb://admin:password@localhost:27017/noteapp?authSource=admin + +# JWT Configuration +JWT_SECRET=your-super-secret-jwt-key-minimum-32-characters +JWT_ISSUER=noteapp + +# Encryption (32 bytes = 32 characters) +ENCRYPTION_KEY=00000000000000000000000000000000 + +# Server +PORT=8080 +ENV=development +LOG_LEVEL=info + +# CORS (comma-separated for multiple origins) +CORS_ALLOWED_ORIGINS=http://localhost:5173,http://localhost:3000 + +# Rate Limiting +RATE_LIMIT_REQUESTS=50 +RATE_LIMIT_WINDOW=1s +``` + +## Frontend (.env) + +```env +VITE_API_BASE_URL=http://localhost:8080 +VITE_ENV=development +``` + +## Development vs Production + +### Development (.env.development) + +- Less strict security (for easier testing) +- Localhost CORS allowed +- JWT secrets can be simple +- Logging more verbose + +### Production (.env.production) + +- Strict security requirements +- Specific CORS origins only +- Strong random JWT secrets +- Limited logging (performance) +- All environment variables must be set + +## Generating Secrets + +```bash +# JWT Secret (32+ characters) +openssl rand -base64 32 + +# Encryption Key (32 bytes) +openssl rand -hex 16 # outputs 32 characters + +# Random token +openssl rand -hex 32 +``` + +## Docker Compose + +Environment variables are defined in `docker-compose.yml` and will override `.env` files. Update the file for your deployment: + +```yaml +environment: + MONGODB_URI: mongodb://admin:password@mongodb:27017/noteapp?authSource=admin + JWT_SECRET: your-secret-key-change-in-production + # ... other vars +``` + +## Kubernetes + +Use `kubectl create secret` for sensitive data: + +```bash +# Create secret from literal values +kubectl create secret generic app-secrets \ + --from-literal=mongodb-uri="..." \ + --from-literal=jwt-secret="..." \ + -n noteapp + +# Or use ConfigMap for non-sensitive config +kubectl create configmap app-config \ + --from-file=config.yaml \ + -n noteapp +``` + +--- + +**IMPORTANT**: Never commit .env files or secrets to version control! diff --git a/PERMISSIONS.md b/PERMISSIONS.md new file mode 100644 index 0000000..50bb034 --- /dev/null +++ b/PERMISSIONS.md @@ -0,0 +1,69 @@ +# Permissions Reference + +This file lists the permissions currently checked by the application. + +## Global Permissions + +- `*` + - Full access wildcard + - Also used by the built-in Admin group +- admin.access + - Access to admin API and admin UI +- space.create + - Create a new space +- space.edit + - Global space edit capability (used as fallback alongside space-scoped settings edit) +- space.delete + - Global space delete capability (used as fallback alongside space-scoped delete) + +## Space-Scoped Permission Format + +space.. + +- space_permission_key is derived from the space name (normalized token) +- Example: + - space.product_docs.note.create + - space.product_docs.settings.member.manage + +## Space-Scoped Actions Currently Enforced + +### Space Management + +- settings.edit +- delete + +### Member Management + +- settings.member.manage +- settings.member.view + +### Category Management + +- category.create +- category.edit +- category.delete + +### Note Management + +- note.create +- note.edit +- note.delete + +## Wildcard Support + +Permissions support wildcard matching with \*. + +Examples: + +- space.product_docs.\* + - Grants all permissions for the product_docs space +- space.\*.note.create + - Grants note.create for all spaces +- `*` + - Grants all permissions globally + +## Built-in Group + +- Admin group is auto-created at startup if missing +- Admin group permissions: + - `*` diff --git a/QUICKSTART.md b/QUICKSTART.md new file mode 100644 index 0000000..cd00180 --- /dev/null +++ b/QUICKSTART.md @@ -0,0 +1,304 @@ +# ๐Ÿš€ Quick Start Guide + +## Prerequisites + +- Docker and Docker Compose (recommended for quickest setup) +- OR: Go 1.21+, Node.js 18+, MongoDB 7.0+ + +## Option 1: Docker Compose (Recommended - 1 Command) + +```bash +# Clone/navigate to project +cd noteapp + +# Start everything +docker-compose up + +# Wait for services to initialize (~30 seconds) +# Then open: http://localhost +``` + +**Services running**: + +- Notely: http://localhost:8080 +- MongoDB: localhost:27017 +- Nginx Reverse Proxy: http://localhost:80 + +**Test user (after startup)**: + +- Register a new account at http://localhost/register +- Login and create a Space +- Add Categories and Notes + +## Option 2: Local Development + +### Backend Setup + +```bash +cd backend + +# Copy environment file +cp .env.example .env + +# Install dependencies +go mod download + +# Ensure MongoDB is running +# Docker: docker run -d -p 27017:27017 -e MONGO_INITDB_ROOT_USERNAME=admin \ +# -e MONGO_INITDB_ROOT_PASSWORD=password mongo:7.0-alpine + +# Run backend +go run ./cmd/server/main.go + +# Logs should show: "Server starting on port 8080" +``` + +### Frontend Setup + +```bash +cd frontend + +# Copy environment file +cp .env.example .env + +# Install dependencies +npm install + +# Start dev server +npm run dev + +# Open: http://localhost:5173 in browser +``` + +## ๐Ÿงช Testing + +### Backend Tests + +```bash +cd backend + +# Run all tests +go test ./... + +# Run with verbose output +go test -v ./... + +# Run specific test +go test -v -run TestRegisterUser ./tests/unit/... + +# With coverage +go test -cover ./... +``` + +### Frontend Tests + +```bash +cd frontend + +# Run tests +npm run test + +# Watch mode +npm run test:watch + +# Coverage +npm run test:coverage +``` + +## ๐Ÿ“ Key API Endpoints + +### Authentication + +```bash +# Register +curl -X POST http://localhost:8080/api/v1/auth/register \ + -H "Content-Type: application/json" \ + -d '{ + "email": "user@example.com", + "username": "myuser", + "password": "SecurePassword123", + "password_confirm": "SecurePassword123", + "first_name": "John", + "last_name": "Doe" + }' + +# Login +curl -X POST http://localhost:8080/api/v1/auth/login \ + -H "Content-Type: application/json" \ + -d '{ + "email": "user@example.com", + "password": "SecurePassword123" + }' + +# Response contains: access_token, refresh_token, user data +``` + +### Create Space + +```bash +curl -X POST http://localhost:8080/api/v1/spaces \ + -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "name": "My First Space", + "description": "Notes for my project", + "icon": "๐Ÿ“š", + "is_public": false + }' +``` + +### Create Note + +```bash +curl -X POST http://localhost:8080/api/v1/spaces/{spaceId}/notes \ + -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "title": "My First Note", + "content": "# Markdown Heading\n\nThis is a note", + "tags": ["important", "work"], + "category_id": null, + "is_pinned": false, + "is_favorite": true + }' +``` + +### Search Notes + +```bash +curl "http://localhost:8080/api/v1/spaces/{spaceId}/notes/search?q=important" \ + -H "Authorization: Bearer YOUR_ACCESS_TOKEN" +``` + +## ๐Ÿ” Troubleshooting + +### MongoDB Connection Error + +``` +Error: Failed to connect to database + +Solution: +docker run -d -p 27017:27017 \ + -e MONGO_INITDB_ROOT_USERNAME=admin \ + -e MONGO_INITDB_ROOT_PASSWORD=password \ + mongo:7.0-alpine +``` + +### Port Already in Use + +```bash +# Find process on port 8080 +lsof -i :8080 + +# Kill it +kill -9 + +# Or use different port +PORT=8081 go run ./cmd/server/main.go +``` + +### CORS Errors + +Make sure frontend and backend URLs match in: + +- Frontend: `VITE_API_BASE_URL` in `.env` +- Backend: `CORS_ALLOWED_ORIGINS` in `.env` + +### MongoDB Auth Failed + +If using Docker Compose: + +- Username: `admin` +- Password: `password` +- Connection string includes `?authSource=admin` + +## ๐Ÿ“š Project Structure + +``` +noteapp/ +โ”œโ”€โ”€ backend/ # Go REST API +โ”‚ โ”œโ”€โ”€ cmd/server/ # Entry point +โ”‚ โ”œโ”€โ”€ internal/ +โ”‚ โ”‚ โ”œโ”€โ”€ domain/ # Business logic +โ”‚ โ”‚ โ”œโ”€โ”€ application/ # Services & DTOs +โ”‚ โ”‚ โ”œโ”€โ”€ infrastructure/ # DB, auth, security +โ”‚ โ”‚ โ””โ”€โ”€ interfaces/ # HTTP handlers +โ”‚ โ”œโ”€โ”€ tests/ # Test files +โ”‚ โ”œโ”€โ”€ go.mod & go.sum # Dependencies +โ”‚ โ””โ”€โ”€ README.md +โ”‚ +โ”œโ”€โ”€ frontend/ # Vue 3 SPA +โ”‚ โ”œโ”€โ”€ src/ +โ”‚ โ”‚ โ”œโ”€โ”€ components/ # UI components +โ”‚ โ”‚ โ”œโ”€โ”€ pages/ # Page components +โ”‚ โ”‚ โ”œโ”€โ”€ stores/ # Pinia state +โ”‚ โ”‚ โ”œโ”€โ”€ services/ # API client +โ”‚ โ”‚ โ”œโ”€โ”€ router/ # Vue Router +โ”‚ โ”‚ โ”œโ”€โ”€ assets/ # Styles & images +โ”‚ โ”‚ โ””โ”€โ”€ main.js # Entry point +โ”‚ โ”œโ”€โ”€ tests/ # Test files +โ”‚ โ”œโ”€โ”€ package.json # Dependencies +โ”‚ โ””โ”€โ”€ vite.config.js # Vite configuration +โ”‚ +โ”œโ”€โ”€ devops/ +โ”‚ โ”œโ”€โ”€ docker/ +โ”‚ โ”‚ โ”œโ”€โ”€ Dockerfile.backend +โ”‚ โ”‚ โ”œโ”€โ”€ Dockerfile.frontend +โ”‚ โ”‚ โ””โ”€โ”€ nginx.conf +โ”‚ โ””โ”€โ”€ kubernetes/ +โ”‚ โ””โ”€โ”€ deployment.yaml +โ”‚ +โ”œโ”€โ”€ docker-compose.yml # Local development setup +โ”œโ”€โ”€ README.md # Project docs +โ”œโ”€โ”€ ARCHITECTURE.md # Architecture overview +โ”œโ”€โ”€ SECURITY.md # Security implementation +โ””โ”€โ”€ ENV_SETUP.md # Environment configuration +``` + +## ๐ŸŽ“ Learning Resources + +### Understanding the Code + +1. **Start here**: `ARCHITECTURE.md` - Clean architecture pattern +2. **Then read**: Backend `domain/entities/*.go` - Core models +3. **Next**: Backend `application/services/*.go` - Business logic +4. **UI**: Frontend `src/stores/authStore.js` - State management +5. **API**: Backend `interfaces/handlers/*.go` - HTTP layer + +### Security Deep Dive + +See `SECURITY.md` for: + +- Password hashing (Argon2id) +- JWT authentication +- Authorization (RBAC) +- Input validation +- XSS prevention +- CSRF protection + +## ๐Ÿš€ Next Steps + +1. **Explore the UI**: Create spaces, notes, categories +2. **Read the code**: Start with `index ARCHITECTURE.md` +3. **Run tests**: `go test ./...` and `npm test` +4. **Deploy**: Use `docker-compose.yml` or Kubernetes +5. **Extend**: Add OAuth2, WebSockets, more features + +## ๐Ÿ’ก Quick Tips + +- **Hot reload**: Changes auto-reload in dev mode +- **Network tab**: Check API calls in browser DevTools +- **Logs**: Docker: `docker-compose logs -f service-name` +- **Database GUI**: MongoDB Compass (free tool to browse data) +- **API testing**: Postman or `curl` commands + +## ๐Ÿ“ž Support + +- Check logs: `docker-compose logs` +- Review `SECURITY.md` for auth issues +- Check `ENV_SETUP.md` for config problems +- See `ARCHITECTURE.md` for code structure + +--- + +**Now you're ready to explore and extend Notely! ๐ŸŽ‰** diff --git a/README.md b/README.md new file mode 100644 index 0000000..ffd9787 --- /dev/null +++ b/README.md @@ -0,0 +1,426 @@ +# Notely - Secure Multi-Space Note-Taking Application + +A production-ready, secure multi-tenant note-taking platform built with Go, Vue 3, and MongoDB. + +## ๐Ÿš€ Quick Start + +### Prerequisites + +- Docker & Docker Compose +- Go 1.21+ (for local development) +- Node.js 18+ (for frontend development) +- MongoDB 7.0+ (for local development) + +### Development with Docker Compose + +```bash +# Start all services +docker-compose up + +# Backend: http://localhost:8080 +# Frontend: http://localhost:5173 +# MongoDB: localhost:27017 +# Nginx: http://localhost:80 +``` + +### Local Development Setup + +#### Backend + +```bash +cd backend + +# Install dependencies +go mod download + +# Set environment variables +export MONGODB_URI=mongodb://admin:password@localhost:27017/noteapp?authSource=admin +export JWT_SECRET=your-secret-key +export ENCRYPTION_KEY=00000000000000000000000000000000 + +# Run migrations and server +go run ./cmd/server/main.go +``` + +#### Frontend + +```bash +cd frontend + +# Install dependencies +npm install + +# Start development server +npm run dev +``` + +## ๐Ÿ“š Architecture + +### Backend (GoClean Architecture) + +``` +backend/ +โ”œโ”€โ”€ cmd/server/ # Entry point +โ”œโ”€โ”€ internal/ +โ”‚ โ”œโ”€โ”€ domain/ # Business logic (entities, interfaces) +โ”‚ โ”œโ”€โ”€ application/ # Use cases (services, DTOs) +โ”‚ โ”œโ”€โ”€ infrastructure/ # External dependencies (DB, auth) +โ”‚ โ””โ”€โ”€ interfaces/ # API handlers & middleware +โ”œโ”€โ”€ pkg/ # Public packages +โ””โ”€โ”€ tests/ # Test suites +``` + +### Frontend (Vue 3 Composition API) + +``` +frontend/ +โ”œโ”€โ”€ src/ +โ”‚ โ”œโ”€โ”€ components/ # Reusable Vue components +โ”‚ โ”œโ”€โ”€ pages/ # Page components +โ”‚ โ”œโ”€โ”€ stores/ # Pinia state management +โ”‚ โ”œโ”€โ”€ services/ # API client +โ”‚ โ”œโ”€โ”€ router/ # Vue Router config +โ”‚ โ”œโ”€โ”€ assets/ # Styles and assets +โ”‚ โ””โ”€โ”€ main.js # Entry point +โ”œโ”€โ”€ index.html +โ””โ”€โ”€ vite.config.js +``` + +## ๐Ÿ” Security Features + +### Authentication + +- **Argon2id password hashing** - Industry-standard PBKDF2 +- **JWT tokens** with short expiration (1 hour) +- **HTTP-only secure cookies** for refresh tokens +- **CSRF protection** via SameSite cookies +- **Brute-force protection** via login attempt tracking + +### Authorization + +- **Role-based access control (RBAC)** per space: + - Owner: Full control + - Editor: Edit notes and categories + - Viewer: Read-only access +- **Space-level data isolation** - all queries include space_id +- **IDOR prevention** - middleware enforces ownership verification + +### Data Security + +- **Encryption at rest** for sensitive fields (OAuth secrets) +- **HTTPS/TLS** in production (Nginx reverse proxy) +- **Content Security Policy (CSP)** headers +- **XSS protection** - DOMPurify for markdown sanitization +- **SQL injection prevention** - parameterized queries (MongoDB) + +### API Security + +- **Rate limiting** - IP-based and user-based +- **Security headers** - HSTS, X-Frame-Options, X-Content-Type-Options +- **CORS properly configured** - whitelist origin domains +- **Input validation** on all endpoints + +## ๐Ÿ“ฆ API Endpoints + +### Authentication + +``` +POST /api/v1/auth/register - Register new user +POST /api/v1/auth/login - Login user +POST /api/v1/auth/refresh - Refresh access token +POST /api/v1/auth/logout - Logout user +GET /health - Health check +``` + +### Spaces + +``` +GET /api/v1/spaces - List user's spaces +POST /api/v1/spaces - Create space +GET /api/v1/spaces/{spaceId} - Get space details +PUT /api/v1/spaces/{spaceId} - Update space +DELETE /api/v1/spaces/{spaceId} - Delete space +``` + +### Notes + +``` +GET /api/v1/spaces/{spaceId}/notes - List notes +POST /api/v1/spaces/{spaceId}/notes - Create note +GET /api/v1/spaces/{spaceId}/notes/{noteId} - Get note +PUT /api/v1/spaces/{spaceId}/notes/{noteId} - Update note +DELETE /api/v1/spaces/{spaceId}/notes/{noteId} - Delete note +GET /api/v1/spaces/{spaceId}/notes/search?q= - Search notes +``` + +### Categories + +``` +GET /api/v1/spaces/{spaceId}/categories - List categories +POST /api/v1/spaces/{spaceId}/categories - Create category +PUT /api/v1/spaces/{spaceId}/categories/{id} - Update category +DELETE /api/v1/spaces/{spaceId}/categories/{id} - Delete category +``` + +## ๐Ÿ—„๏ธ Database Design + +### MongoDB Collections + +#### users + +```javascript +{ + _id: ObjectId, + email: String (unique), + username: String (unique), + password_hash: String, + first_name: String, + last_name: String, + avatar: String, + is_active: Boolean, + email_verified: Boolean, + created_at: Date, + updated_at: Date, + last_login_at: Date +} +``` + +#### spaces + +```javascript +{ + _id: ObjectId, + name: String, + description: String, + icon: String, + owner_id: ObjectId, + is_public: Boolean, + created_at: Date, + updated_at: Date +} +``` + +#### memberships + +```javascript +{ + _id: ObjectId, + user_id: ObjectId, + space_id: ObjectId, + role: String (owner|editor|viewer), + joined_at: Date, + invited_by: ObjectId, + invited_at: Date +} +``` + +#### notes + +```javascript +{ + _id: ObjectId, + space_id: ObjectId, + category_id: ObjectId, + title: String, + content: String (Markdown), + tags: [String], + is_pinned: Boolean, + is_favorite: Boolean, + created_by: ObjectId, + updated_by: ObjectId, + created_at: Date, + updated_at: Date, + viewed_at: Date +} +``` + +#### categories + +```javascript +{ + _id: ObjectId, + space_id: ObjectId, + name: String, + description: String, + parent_id: ObjectId (for hierarchical structure), + icon: String, + order: Number, + created_by: ObjectId, + updated_by: ObjectId, + created_at: Date, + updated_at: Date +} +``` + +#### Indexes + +``` +users: { email: 1 (unique), username: 1 (unique) } +spaces: { owner_id: 1, created_at: -1 } +memberships: { user_id: 1, space_id: 1 (unique), space_id: 1 } +notes: { space_id: 1, category_id: 1, updated_at: -1, text: "text" } +categories: { space_id: 1, parent_id: 1, order: 1 } +``` + +## ๐Ÿณ Deployment + +### Docker Compose (Development/Testing) + +```bash +docker-compose up -d +``` + +Services: + +- **MongoDB** (port 27017) +- **Backend API** (port 8080) +- **Frontend** (port 5173) +- **Nginx Reverse Proxy** (port 80) + +### Kubernetes (Production) + +```bash +# Create namespace and secrets +kubectl apply -f devops/kubernetes/deployment.yaml + +# Verify deployment +kubectl get pods -n noteapp +kubectl port-forward svc/frontend 5173:5173 -n noteapp +kubectl port-forward svc/backend 8080:8080 -n noteapp +``` + +Features: + +- **StatefulSet** for MongoDB with persistent storage +- **Deployments** for backend and frontend with horizontal scaling +- **Ingress** for routing (requires ingress controller) +- **HPA** (Horizontal Pod Autoscaler) for automatic scaling +- **Liveness & readiness probes** for health checks +- **Resource limits** for fair resource allocation + +## ๐Ÿงช Testing + +### Backend Tests + +```bash +cd backend +go test ./... +go test -v ./tests/unit/... +go test -v ./tests/integration/... +``` + +### Frontend Tests + +```bash +cd frontend +npm run test +npm run test:watch +``` + +## ๐Ÿ”ง Configuration + +### Environment Variables + +#### Backend (.env) + +``` +MONGODB_URI=mongodb://admin:password@localhost:27017/noteapp +JWT_SECRET=your-secret-key-min-32-chars +ENCRYPTION_KEY=32-char-encryption-key-for-secrets +PORT=8080 +LOG_LEVEL=info +ENV=development +``` + +#### Frontend (.env) + +``` +VITE_API_BASE_URL=http://localhost:8080 +``` + +## ๐Ÿ“ Development Guidelines + +### Code Structure + +- Follow clean architecture principles +- Separate concerns: domain, application, infrastructure +- Use interfaces for dependency injection +- Keep services testable and focused + +### Security Best Practices + +1. **Never store secrets in code** - use environment variables +2. **Validate all inputs** on backend +3. **Sanitize outputs** before rendering +4. **Use HTTPS in production** +5. **Implement rate limiting** on APIs +6. **Log security events** (login attempts, permission denied) +7. **Audit trail** for sensitive operations + +### Commit Message Format + +``` +[TYPE] Description + +types: feat, fix, docs, style, refactor, test, chore +``` + +## ๐Ÿ“– API Documentation + +### Request/Response Format + +All API requests and responses use JSON. + +```bash +# Example: Create Note +curl -X POST http://localhost:8080/api/v1/spaces/{spaceId}/notes \ + -H "Authorization: Bearer {accessToken}" \ + -H "Content-Type: application/json" \ + -d '{ + "title": "My Note", + "content": "# Markdown content", + "tags": ["tag1", "tag2"], + "category_id": null, + "is_pinned": false, + "is_favorite": false + }' +``` + +## ๐Ÿšจ Error Handling + +All errors return appropriate HTTP status codes: + +- `400` - Bad Request +- `401` - Unauthorized +- `403` - Forbidden (insufficient permissions) +- `404` - Not Found +- `409` - Conflict (e.g., duplicate email) +- `429` - Too Many Requests (rate limit exceeded) +- `500` - Internal Server Error + +## ๐ŸŽฏ Future Enhancements + +- [ ] OAuth2/OIDC integration +- [ ] Email notifications +- [ ] Real-time collaboration (WebSockets) +- [ ] Full-text search with Elasticsearch +- [ ] Export to PDF/Markdown +- [ ] Mobile applications +- [ ] Plugin system +- [ ] Advanced permissions management + +## ๐Ÿ“„ License + +MIT License - See LICENSE file + +## ๐Ÿ‘ฅ Contributing + +1. Fork the repository +2. Create a feature branch +3. Commit your changes +4. Push to the branch +5. Create a Pull Request + +--- + +**Built with โค๏ธ for secure, collaborative note-taking** diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..69e55b3 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,284 @@ +# Security Implementation Guide + +This document outlines the security measures implemented in Notely. + +## ๐Ÿ” Authentication Security + +### Password Hashing + +- **Algorithm**: Argon2id (memory-hard, resistant to GPU attacks) +- **Configuration**: + - Memory: 64 MB + - Time: 1 iteration + - Parallelism: 4 threads + - Salt: 16 random bytes (cryptographically secure) + +```go +// Generated hash format: +$argon2id$v=19$m=65536,t=1,p=4$salt_hex$hash_hex +``` + +### JWT Tokens + +- **Algorithm**: HS256 (HMAC-SHA256) +- **Access Token TTL**: 1 hour +- **Refresh Token TTL**: 7 days (HTTP-only secure cookie) +- **Claims**: + - `user_id`: User's MongoDB ObjectID + - `email`: User's email address + - `username`: User's username + - `iat`: Issued at timestamp + - `exp`: Expiration timestamp + - `iss`: Issuer (verified against hardcoded value) + +### Brute-Force Protection + +- Track failed login attempts in `login_attempts` collection +- Rate limit: Max 5 failed attempts per IP per 15 minutes +- Account lockout: 15 minutes after 5 consecutive failures +- Cleanup: Expired records auto-deleted via TTL index + +## ๐Ÿ›ก๏ธ Authorization Security + +### Role-Based Access Control (RBAC) + +``` +Space Roles: +โ”œโ”€โ”€ Owner (all permissions) +โ”œโ”€โ”€ Editor (create/edit/delete notes) +โ””โ”€โ”€ Viewer (read-only) +``` + +### Space-Level Data Isolation + +**ALL queries include mandatory `space_id` filter** + +```go +// Correct query pattern: +db.notes.find({ space_id: spaceID, ... }) + +// Never allow: +db.notes.find({ user_id: userID }) // โŒ Cross-space leak possible +``` + +### Middleware Authorization Flow + +``` +1. Extract JWT token โ†’ Verify signature & expiration +2. Load user credentials โ†’ Verify user is active +3. Check space membership โ†’ Verify user_id + space_id + role +4. Execute request โ†’ With space_id context +``` + +## ๐Ÿ”‘ Data Encryption + +### At Rest + +- OAuth client secrets encrypted with AES-256-GCM +- Stored in MongoDB with encryption key in environment variables +- Decryption happens only when reading from database + +```go +plaintext, err := encryptor.Encrypt(clientSecret) // Stores encrypted blob +recovered, err := encryptor.Decrypt(plaintext) // Decrypts on retrieval +``` + +### In Transit + +- HTTPS/TLS required in production (enforced via Nginx) +- Secure cookies: `Secure`, `HttpOnly`, `SameSite=Lax` flags +- All sensitive data transmitted over encrypted channels + +## ๐Ÿšจ Input Validation + +### Backend Validation (MANDATORY) + +Every endpoint validates: + +1. **Type validation** - JSON schema validation +2. **Length limits** - min/max string lengths +3. **Format validation** - email, ObjectID, URL formats +4. **Range validation** - pagination limits + +```go +type CreateNoteRequest struct { + Title string `validate:"required,min=1,max=255"` + Content string `validate:"max=50000"` + Tags []string `validate:"max=100,dive,max=50"` +} +``` + +### Frontend Validation + +- **Input sanitization** - trim whitespace +- **Format validation** - regex patterns +- **Debounced searches** - prevent query spam +- **Client-side feedback** - improve UX + +### Output Sanitization + +Markdown โ†’ HTML conversion sanitized with DOMPurify: + +```javascript +// XSS prevention +const dirty = marked.parse(userMarkdown); +const clean = DOMPurify.sanitize(dirty); + +// Blocks: scripts, event handlers, dangerous attributes +``` + +## ๐ŸŒ Web Security Headers + +Implemented via Nginx and Go middleware: + +| Header | Value | Purpose | +| --------------------------- | --------------------------------- | ------------------------------- | +| `Strict-Transport-Security` | `max-age=31536000` | Force HTTPS | +| `X-Content-Type-Options` | `nosniff` | Prevent MIME sniffing | +| `X-Frame-Options` | `DENY` | Prevent clickjacking | +| `X-XSS-Protection` | `1; mode=block` | XSS protection (older browsers) | +| `Content-Security-Policy` | Restrictive policy | Prevent XSS attacks | +| `Referrer-Policy` | `strict-origin-when-cross-origin` | Referrer control | + +**CSP Policy:** + +``` +default-src 'self' +script-src 'self' 'unsafe-inline' (for development only) +style-src 'self' 'unsafe-inline' +img-src 'self' data: https: +font-src 'self' +connect-src 'self' +frame-ancestors 'none' +``` + +## ๐Ÿช Cookie Security + +### Access Token (via Authorization header) + +- Stored in **memory** (not localStorage) +- Passed via `Authorization: Bearer {token}` + +### Refresh Token (HTTP-only cookie) + +```go +http.SetCookie(w, &http.Cookie{ + Name: "refresh_token", + Value: token, + Path: "/", + MaxAge: 7 * 24 * 60 * 60, // 7 days + HttpOnly: true, // โœ… Cannot access from JavaScript + Secure: true, // โœ… HTTPS only + SameSite: http.SameSiteLaxMode, // โœ… CSRF protection +}) +``` + +## ๐Ÿ”„ Rate Limiting + +### API Rate Limiting + +- **General**: 50 requests / second per IP +- **Login**: 10 requests / second per IP +- **Burst allowance**: 20 additional requests + +```nginx +limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s; +limit_req zone=api_limit burst=20 nodelay; +``` + +### Login Attempt Tracking + +- Track per email + IP combination +- Maximum 5 attempts per 15 minutes +- Exponential backoff on repeated failures + +## ๐Ÿ”’ Database Security + +### MongoDB + +- **Authentication**: Username/password with role-based access +- **Network**: Runs in secure Docker network (not exposed) +- **Admin credentials**: Stored in Kubernetes Secrets (not in code) +- **Backups**: TBD - use MongoDB Atlas or encrypted backups + +### Connection String + +``` +mongodb://admin:password@mongodb:27017/dbname?authSource=admin +``` + +## ๐Ÿšจ Logging & Monitoring + +### Security Events Logged + +- โœ… User registration attempts +- โœ… Login attempts (success/failure) +- โœ… Authorization failures +- โœ… Permission denied events +- โœ… Sensitive data access + +### Data NOT logged + +- โŒ Passwords/hashes +- โŒ JWT tokens +- โŒ Encryption keys +- โŒ OAuth secrets + +## ๐Ÿงช Security Testing + +### What to Test + +1. **Authentication**: Register, login, token refresh, logout +2. **Authorization**: RBAC enforcement, space isolation +3. **Input validation**: Invalid data rejection +4. **XSS prevention**: Markdown sanitization +5. **CSRF protection**: Token validation +6. **Rate limiting**: Too many requests blocked +7. **SQL Injection**: MongoDB-specific (parameterized queries safe) + +### Manual Testing Commands + +```bash +# Test invalid input +curl -X POST http://localhost:8080/api/v1/auth/login \ + -d '{"email":"not-an-email","password":""}' + +# Test expired token +curl -H "Authorization: Bearer expired.token.here" \ + http://localhost:8080/api/v1/spaces + +# Test rate limiting +for i in {1..100}; do + curl http://localhost:8080/api/v1/auth/login & +done +``` + +## ๐Ÿ› ๏ธ Production Checklist + +- [ ] Change default JWT_SECRET (min 32 characters) +- [ ] Change default ENCRYPTION_KEY (32 bytes) +- [ ] Generate TLS certificates (Let's Encrypt recommended) +- [ ] Configure Nginx SSL/TLS +- [ ] Enable HTTPS redirect +- [ ] Set up database backups +- [ ] Configure logging & monitoring +- [ ] Implement CORS whitelist (specific domains) +- [ ] Set up rate limiting (tuned to your traffic) +- [ ] Enable database authentication +- [ ] Use Kubernetes Network Policies +- [ ] Set up Pod Security Policies +- [ ] Enable audit logging +- [ ] Configure Secrets encryption at rest + +## ๐Ÿ“š References + +- [OWASP Top 10](https://owasp.org/www-project-top-ten/) +- [MongoDB Security](https://docs.mongodb.com/manual/security/) +- [JWT Best Practices](https://tools.ietf.org/html/rfc8949) +- [Argon2 Specification](https://github.com/P-H-C/phc-winner-argon2) +- [Content Security Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP) + +--- + +**Last Updated**: March 2026 +**Security Level**: Production-Grade diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..37726b3 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,28 @@ +# Backend Environment Example + +# MongoDB +MONGODB_URI=mongodb://admin:password@localhost:27017/noteapp?authSource=admin + +# JWT Configuration +JWT_SECRET=your-super-secret-jwt-key-minimum-32-characters-change-in-production +JWT_ISSUER=noteapp + +# Encryption Key (32 bytes/characters for AES-256) +ENCRYPTION_KEY=00000000000000000000000000000000 + +# Server +PORT=8080 +ENV=development +LOG_LEVEL=info + +# Default Admin +DEFAULT_ADMIN_EMAIL=admin@notely.local +DEFAULT_ADMIN_USERNAME=admin +DEFAULT_ADMIN_PASSWORD=ChangeThisAdminPassword123! + +# CORS (comma-separated origins) +CORS_ALLOWED_ORIGINS=http://localhost:5173,http://localhost:3000 + +# Rate Limiting +RATE_LIMIT_REQUESTS=50 +RATE_LIMIT_WINDOW=1s diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go new file mode 100644 index 0000000..08722d7 --- /dev/null +++ b/backend/cmd/server/main.go @@ -0,0 +1,433 @@ +package main + +import ( + "context" + "errors" + "log" + "net/http" + "os" + "strings" + "time" + + "github.com/gorilla/mux" + "github.com/joho/godotenv" + "github.com/noteapp/backend/internal/application/services" + "github.com/noteapp/backend/internal/domain/entities" + "github.com/noteapp/backend/internal/domain/repositories" + "github.com/noteapp/backend/internal/infrastructure/auth" + "github.com/noteapp/backend/internal/infrastructure/database" + "github.com/noteapp/backend/internal/infrastructure/security" + "github.com/noteapp/backend/internal/interfaces/handlers" + "github.com/noteapp/backend/internal/interfaces/middleware" + "go.mongodb.org/mongo-driver/v2/bson" +) + +func main() { + // Load environment variables + _ = godotenv.Load() + + // Configuration + mongoURL := os.Getenv("MONGODB_URI") + if mongoURL == "" { + mongoURL = "mongodb://localhost:27017" + } + + jwtSecret := os.Getenv("JWT_SECRET") + if jwtSecret == "" { + jwtSecret = "your-secret-key-change-in-production" + } + + encryptionKey := os.Getenv("ENCRYPTION_KEY") + if encryptionKey == "" { + encryptionKey = "00000000000000000000000000000000" // 32 bytes + } + + port := os.Getenv("PORT") + if port == "" { + port = "8080" + } + + // Connect to database + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + db, err := database.NewDatabase(ctx, mongoURL) + if err != nil { + log.Fatalf("Failed to connect to database: %v", err) + } + defer db.Close(context.Background()) + + // Initialize security components + passwordHasher := security.NewPasswordHasher() + encryptor, err := security.NewEncryptor(encryptionKey) + if err != nil { + log.Fatalf("Failed to initialize encryptor: %v", err) + } + + // Initialize JWT manager + jwtManager := auth.NewJWTManager(jwtSecret, "noteapp", 1*time.Hour) + + // Initialize services + permissionService := services.NewPermissionService( + db.UserRepo, + db.GroupRepo, + db.MembershipRepo, + db.SpaceRepo, + ) + + authService := services.NewAuthService( + db.UserRepo, + db.GroupRepo, + db.ProviderRepo, + db.LinkRepo, + db.RecoveryRepo, + db.FeatureFlagRepo, + permissionService, + jwtManager, + passwordHasher, + encryptor, + ) + + spaceService := services.NewSpaceService( + db.SpaceRepo, + db.MembershipRepo, + db.NoteRepo, + db.CategoryRepo, + db.UserRepo, + permissionService, + ) + + noteService := services.NewNoteService( + db.NoteRepo, + db.CategoryRepo, + db.MembershipRepo, + nil, // NoteRevisionRepository + db.SpaceRepo, + permissionService, + passwordHasher, + ) + + categoryService := services.NewCategoryService( + db.CategoryRepo, + db.MembershipRepo, + db.NoteRepo, + permissionService, + ) + + adminService := services.NewAdminService( + db.UserRepo, + db.GroupRepo, + db.SpaceRepo, + db.MembershipRepo, + db.NoteRepo, + db.CategoryRepo, + db.FeatureFlagRepo, + permissionService, + ) + + if err := permissionService.EnsureAdminGroup(context.Background()); err != nil { + log.Fatalf("failed to initialize admin group: %v", err) + } + + if err := ensureDefaultAdminUser( + context.Background(), + db.UserRepo, + db.GroupRepo, + permissionService, + passwordHasher, + ); err != nil { + log.Fatalf("failed to initialize default admin user: %v", err) + } + + // Initialize handlers + authHandler := handlers.NewAuthHandler(authService) + spaceHandler := handlers.NewSpaceHandler(spaceService) + noteHandler := handlers.NewNoteHandler(noteService) + categoryHandler := handlers.NewCategoryHandler(categoryService) + adminHandler := handlers.NewAdminHandler(adminService) + publicHandler := handlers.NewPublicHandler(spaceService, noteService) + settingsHandler := handlers.NewSettingsHandler(authService) + + // Create router + router := mux.NewRouter() + router.PathPrefix("/").Methods(http.MethodOptions).HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + }) + + // Middleware + authMiddleware := middleware.NewAuthMiddleware(jwtManager) + router.Use(middleware.LoggingMiddleware) + router.Use(middleware.CORSMiddleware) + router.Use(middleware.SecurityHeaders) + + // Public endpoints + router.HandleFunc("/health", authHandler.Health).Methods("GET") + router.HandleFunc("/api/v1/auth/register", authHandler.Register).Methods("POST") + router.HandleFunc("/api/v1/auth/login", authHandler.Login).Methods("POST") + router.HandleFunc("/api/v1/auth/refresh", authHandler.RefreshToken).Methods("POST") + router.HandleFunc("/api/v1/auth/logout", authHandler.Logout).Methods("POST") + router.HandleFunc("/api/v1/auth/providers", authHandler.ListProviders).Methods("GET") + router.HandleFunc("/api/v1/auth/providers/{providerId}/start", authHandler.StartProviderLogin).Methods("GET") + router.HandleFunc("/api/v1/auth/providers/{providerId}/callback", authHandler.CompleteProviderLogin).Methods("GET") + router.HandleFunc("/api/v1/settings/feature-flags", settingsHandler.GetFeatureFlags).Methods("GET") + + // Public read-only endpoints (no auth required) + public := router.PathPrefix("/api/v1/public").Subrouter() + public.HandleFunc("/spaces", publicHandler.ListPublicSpaces).Methods("GET") + public.HandleFunc("/spaces/{spaceId}", publicHandler.GetPublicSpace).Methods("GET") + public.HandleFunc("/spaces/{spaceId}/notes", publicHandler.GetPublicNotes).Methods("GET") + public.HandleFunc("/spaces/{spaceId}/notes/{noteId}", publicHandler.GetPublicNote).Methods("GET") + public.HandleFunc("/spaces/{spaceId}/notes/{noteId}/unlock", publicHandler.UnlockPublicNote).Methods("POST") + + // Protected endpoints + api := router.PathPrefix("/api/v1").Subrouter() + api.Use(authMiddleware.Middleware) + + // Space endpoints + api.HandleFunc("/spaces", spaceHandler.GetUserSpaces).Methods("GET") + api.HandleFunc("/spaces", spaceHandler.CreateSpace).Methods("POST") + api.HandleFunc("/spaces/{spaceId}", spaceHandler.GetSpace).Methods("GET") + api.HandleFunc("/spaces/{spaceId}", spaceHandler.UpdateSpace).Methods("PUT") + api.HandleFunc("/spaces/{spaceId}", spaceHandler.DeleteSpace).Methods("DELETE") + api.HandleFunc("/spaces/{spaceId}/members", spaceHandler.AddMember).Methods("POST") + api.HandleFunc("/spaces/{spaceId}/members", spaceHandler.GetSpaceMembers).Methods("GET") + api.HandleFunc("/spaces/{spaceId}/members/{userId}", spaceHandler.RemoveMember).Methods("DELETE") + api.HandleFunc("/spaces/{spaceId}/available-users", spaceHandler.GetAvailableUsers).Methods("GET") + + // Note endpoints + api.HandleFunc("/spaces/{spaceId}/notes", noteHandler.GetNotesBySpace).Methods("GET") + api.HandleFunc("/spaces/{spaceId}/notes", noteHandler.CreateNote).Methods("POST") + api.HandleFunc("/spaces/{spaceId}/notes/search", noteHandler.SearchNotes).Methods("GET") + api.HandleFunc("/spaces/{spaceId}/notes/{noteId}", noteHandler.GetNote).Methods("GET") + api.HandleFunc("/spaces/{spaceId}/notes/{noteId}/unlock", noteHandler.UnlockNote).Methods("POST") + api.HandleFunc("/spaces/{spaceId}/notes/{noteId}", noteHandler.UpdateNote).Methods("PUT") + api.HandleFunc("/spaces/{spaceId}/notes/{noteId}", noteHandler.DeleteNote).Methods("DELETE") + + // Category endpoints + api.HandleFunc("/spaces/{spaceId}/categories", categoryHandler.GetCategoryTree).Methods("GET") + api.HandleFunc("/spaces/{spaceId}/categories", categoryHandler.CreateCategory).Methods("POST") + api.HandleFunc("/spaces/{spaceId}/categories/{categoryId}", categoryHandler.UpdateCategory).Methods("PUT") + api.HandleFunc("/spaces/{spaceId}/categories/{categoryId}", categoryHandler.DeleteCategory).Methods("DELETE") + api.HandleFunc("/spaces/{spaceId}/categories/{categoryId}/move", categoryHandler.MoveCategory).Methods("PATCH") + + // Admin endpoints + admin := router.PathPrefix("/api/v1/admin").Subrouter() + admin.Use(authMiddleware.Middleware) + admin.Use(func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + userIDHex, err := middleware.GetUserIDFromContext(r.Context()) + if err != nil { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + userID, err := bson.ObjectIDFromHex(userIDHex) + if err != nil { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + allowed, err := permissionService.UserHasPermission(r.Context(), userID, "admin.access") + if err != nil { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + if !allowed { + allowed, err = permissionService.UserHasPermission(r.Context(), userID, "*") + if err != nil || !allowed { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + } + + next.ServeHTTP(w, r) + }) + }) + admin.HandleFunc("/users", adminHandler.ListUsers).Methods("GET") + admin.HandleFunc("/users/{userId}/groups", adminHandler.UpdateUserGroups).Methods("PUT") + admin.HandleFunc("/groups", adminHandler.ListGroups).Methods("GET") + admin.HandleFunc("/groups", adminHandler.CreateGroup).Methods("POST") + admin.HandleFunc("/groups/{groupId}", adminHandler.UpdateGroup).Methods("PUT") + admin.HandleFunc("/spaces", adminHandler.ListAllSpaces).Methods("GET") + admin.HandleFunc("/spaces/{spaceId}", adminHandler.UpdateSpace).Methods("PUT") + admin.HandleFunc("/spaces/{spaceId}", adminHandler.DeleteSpace).Methods("DELETE") + admin.HandleFunc("/spaces/{spaceId}/members", adminHandler.AddSpaceMember).Methods("POST") + admin.HandleFunc("/spaces/{spaceId}/members", adminHandler.ListSpaceMembers).Methods("GET") + admin.HandleFunc("/spaces/{spaceId}/members/{userId}", adminHandler.RemoveSpaceMember).Methods("DELETE") + admin.HandleFunc("/spaces/{spaceId}/visibility", adminHandler.SetSpaceVisibility).Methods("PUT") + admin.HandleFunc("/feature-flags", adminHandler.GetFeatureFlags).Methods("GET") + admin.HandleFunc("/feature-flags", adminHandler.UpdateFeatureFlags).Methods("PUT") + // manage identity providers โ€” admin-only + admin.HandleFunc("/auth/providers", authHandler.CreateProvider).Methods("POST") + + // Serve static files (frontend) for all other routes + // This must be after all API route handlers to allow API routes to take precedence + router.PathPrefix("/").HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // List of static file extensions to serve directly + staticExts := map[string]bool{ + ".js": true, ".css": true, ".svg": true, ".png": true, + ".jpg": true, ".jpeg": true, ".gif": true, ".ico": true, + ".woff": true, ".woff2": true, ".ttf": true, ".eot": true, + } + + filePath := "./public" + r.URL.Path + if r.URL.Path == "/" { + filePath = "./public/index.html" + } + + // Check if it's a static file (has an extension in staticExts) + isStatic := false + for ext := range staticExts { + if len(r.URL.Path) > len(ext) { + if r.URL.Path[len(r.URL.Path)-len(ext):] == ext { + isStatic = true + break + } + } + } + + // If it doesn't look like a static file, serve index.html (SPA routing) + if !isStatic { + filePath = "./public/index.html" + } + + http.ServeFile(w, r, filePath) + }) + + // Start server + server := &http.Server{ + Addr: ":" + port, + Handler: router, + ReadTimeout: 15 * time.Second, + WriteTimeout: 15 * time.Second, + IdleTimeout: 60 * time.Second, + } + + log.Printf("Server starting on port %s\n", port) + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("Server error: %v", err) + } +} + +func ensureDefaultAdminUser( + ctx context.Context, + userRepo repositories.UserRepository, + groupRepo repositories.GroupRepository, + permissionService *services.PermissionService, + passwordHasher *security.PasswordHasher, +) error { + adminEmail := strings.ToLower(strings.TrimSpace(os.Getenv("DEFAULT_ADMIN_EMAIL"))) + adminUsername := strings.TrimSpace(os.Getenv("DEFAULT_ADMIN_USERNAME")) + adminPassword := strings.TrimSpace(os.Getenv("DEFAULT_ADMIN_PASSWORD")) + adminFirstName := "System" + adminLastName := "Admin" + + if adminEmail == "" && adminUsername == "" && adminPassword == "" { + log.Println("default admin bootstrap skipped (DEFAULT_ADMIN_* env vars not set)") + return nil + } + + if adminEmail == "" || adminUsername == "" || adminPassword == "" { + return errors.New("DEFAULT_ADMIN_EMAIL, DEFAULT_ADMIN_USERNAME and DEFAULT_ADMIN_PASSWORD must all be set") + } + + if len(adminPassword) < 8 { + return errors.New("DEFAULT_ADMIN_PASSWORD must be at least 8 characters") + } + + adminGroup, err := groupRepo.GetGroupByName(ctx, "Admin") + if err != nil { + return err + } + + user, err := userRepo.GetUserByEmail(ctx, adminEmail) + if err != nil { + if err.Error() != "user not found" { + return err + } + + if existingUsernameUser, usernameErr := userRepo.GetUserByUsername(ctx, adminUsername); usernameErr == nil && existingUsernameUser != nil { + return errors.New("DEFAULT_ADMIN_USERNAME already belongs to another user") + } else if usernameErr != nil && usernameErr.Error() != "user not found" { + return usernameErr + } + + hashedPassword, hashErr := passwordHasher.HashPassword(adminPassword) + if hashErr != nil { + return hashErr + } + + user = &entities.User{ + Email: adminEmail, + Username: adminUsername, + PasswordHash: hashedPassword, + FirstName: adminFirstName, + LastName: adminLastName, + GroupIDs: []bson.ObjectID{adminGroup.ID}, + IsActive: true, + EmailVerified: true, + } + + if createErr := userRepo.CreateUser(ctx, user); createErr != nil { + return createErr + } + + if permissionService != nil { + if permErr := permissionService.UpdateUserEffectivePermissions(ctx, user); permErr != nil { + return permErr + } + } + + log.Printf("default admin user created: %s", adminEmail) + return nil + } + + modified := false + if !user.IsActive { + user.IsActive = true + modified = true + } + + hasAdminGroup := false + for _, groupID := range user.GroupIDs { + if groupID == adminGroup.ID { + hasAdminGroup = true + break + } + } + if !hasAdminGroup { + user.GroupIDs = append(user.GroupIDs, adminGroup.ID) + modified = true + } + + passwordMatches, verifyErr := passwordHasher.VerifyPassword(adminPassword, user.PasswordHash) + if verifyErr != nil { + return verifyErr + } + if !passwordMatches { + hashedPassword, hashErr := passwordHasher.HashPassword(adminPassword) + if hashErr != nil { + return hashErr + } + user.PasswordHash = hashedPassword + modified = true + } + + if !modified { + log.Printf("default admin user already initialized: %s", adminEmail) + return nil + } + + if permissionService != nil { + if err := permissionService.UpdateUserEffectivePermissions(ctx, user); err != nil { + return err + } + } else { + if err := userRepo.UpdateUser(ctx, user); err != nil { + return err + } + } + + log.Printf("default admin user synchronized from environment: %s", adminEmail) + return nil +} diff --git a/backend/go.mod b/backend/go.mod new file mode 100644 index 0000000..c1c3f5b --- /dev/null +++ b/backend/go.mod @@ -0,0 +1,23 @@ +module github.com/noteapp/backend + +go 1.25.0 + +require ( + github.com/golang-jwt/jwt/v5 v5.2.0 + github.com/gorilla/mux v1.8.1 + github.com/joho/godotenv v1.5.1 + go.mongodb.org/mongo-driver/v2 v2.5.0 + golang.org/x/crypto v0.49.0 + golang.org/x/oauth2 v0.30.0 +) + +require ( + github.com/klauspost/compress v1.17.6 // indirect + github.com/xdg-go/pbkdf2 v1.0.0 // indirect + github.com/xdg-go/scram v1.2.0 // indirect + github.com/xdg-go/stringprep v1.0.4 // indirect + github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/text v0.35.0 // indirect +) diff --git a/backend/go.sum b/backend/go.sum new file mode 100644 index 0000000..7d455d3 --- /dev/null +++ b/backend/go.sum @@ -0,0 +1,56 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw= +github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= +github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/klauspost/compress v1.17.6 h1:60eq2E/jlfwQXtvZEeBUYADs+BwKBWURIY+Gj2eRGjI= +github.com/klauspost/compress v1.17.6/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= +github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= +github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= +github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs= +github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8= +github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= +github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= +go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= +golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/backend/internal/application/dto/dto.go b/backend/internal/application/dto/dto.go new file mode 100644 index 0000000..b76b7ce --- /dev/null +++ b/backend/internal/application/dto/dto.go @@ -0,0 +1,440 @@ +package dto + +import ( + "github.com/noteapp/backend/internal/domain/entities" +) + +// ========== AUTH DTOs ========== + +// RegisterRequest represents a registration request +type RegisterRequest struct { + Email string `json:"email" validate:"required,email"` + Username string `json:"username" validate:"required,min=3,max=20"` + Password string `json:"password" validate:"required,min=8"` + PasswordConfirm string `json:"password_confirm" validate:"required,eqfield=Password"` + FirstName string `json:"first_name" validate:"max=50"` + LastName string `json:"last_name" validate:"max=50"` +} + +// LoginRequest represents a login request +type LoginRequest struct { + Email string `json:"email" validate:"required,email"` + Password string `json:"password" validate:"required"` +} + +// LoginResponse represents a login response +type LoginResponse struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token,omitempty"` + User *UserDTO `json:"user"` + ExpiresIn int `json:"expires_in"` +} + +// AuthProviderDTO represents an OAuth/OIDC provider in API responses. +type AuthProviderDTO struct { + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + AuthorizationURL string `json:"authorization_url,omitempty"` + TokenURL string `json:"token_url,omitempty"` + UserInfoURL string `json:"userinfo_url,omitempty"` + Scopes []string `json:"scopes"` + IDTokenClaim string `json:"id_token_claim,omitempty"` + IsActive bool `json:"is_active"` +} + +// CreateAuthProviderRequest represents an OAuth/OIDC provider creation request. +type CreateAuthProviderRequest struct { + Name string `json:"name"` + Type string `json:"type"` + ClientID string `json:"client_id"` + ClientSecret string `json:"client_secret"` + AuthorizationURL string `json:"authorization_url"` + TokenURL string `json:"token_url"` + UserInfoURL string `json:"userinfo_url"` + Scopes []string `json:"scopes"` + IDTokenClaim string `json:"id_token_claim,omitempty"` + IsActive bool `json:"is_active"` +} + +// FeatureFlagsDTO represents app-wide feature flags in API responses. +type FeatureFlagsDTO struct { + RegistrationEnabled bool `json:"registration_enabled"` + ProviderLoginEnabled bool `json:"provider_login_enabled"` + PublicSharingEnabled bool `json:"public_sharing_enabled"` +} + +// UpdateFeatureFlagsRequest represents admin payload for feature flag updates. +type UpdateFeatureFlagsRequest struct { + RegistrationEnabled bool `json:"registration_enabled"` + ProviderLoginEnabled bool `json:"provider_login_enabled"` + PublicSharingEnabled bool `json:"public_sharing_enabled"` +} + +// UserDTO represents a user in API responses +type UserDTO struct { + ID string `json:"id"` + Email string `json:"email"` + Username string `json:"username"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + Avatar string `json:"avatar,omitempty"` + GroupIDs []string `json:"group_ids,omitempty"` + Permissions []string `json:"permissions,omitempty"` + EmailVerified bool `json:"email_verified"` +} + +// NewUserDTO creates a DTO from a user entity +func NewUserDTO(user *entities.User) *UserDTO { + groupIDs := make([]string, 0, len(user.GroupIDs)) + for _, groupID := range user.GroupIDs { + groupIDs = append(groupIDs, groupID.Hex()) + } + + return &UserDTO{ + ID: user.ID.Hex(), + Email: user.Email, + Username: user.Username, + FirstName: user.FirstName, + LastName: user.LastName, + Avatar: user.Avatar, + GroupIDs: groupIDs, + Permissions: user.Permissions, + EmailVerified: user.EmailVerified, + } +} + +// AdminUserDTO extends UserDTO with admin-visible fields +type AdminUserDTO struct { + *UserDTO + IsActive bool `json:"is_active"` + CreatedAt string `json:"created_at"` +} + +// PermissionGroupDTO represents a permission group in API responses. +type PermissionGroupDTO struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Permissions []string `json:"permissions"` + IsSystem bool `json:"is_system"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// CreatePermissionGroupRequest represents group creation input. +type CreatePermissionGroupRequest struct { + Name string `json:"name"` + Description string `json:"description"` + Permissions []string `json:"permissions"` +} + +// UpdatePermissionGroupRequest represents group update input. +type UpdatePermissionGroupRequest struct { + Name string `json:"name"` + Description string `json:"description"` + Permissions []string `json:"permissions"` +} + +// UpdateUserGroupsRequest represents user group assignment input. +type UpdateUserGroupsRequest struct { + GroupIDs []string `json:"group_ids"` +} + +// NewAdminUserDTO creates an admin DTO from a user entity +func NewAdminUserDTO(user *entities.User) *AdminUserDTO { + return &AdminUserDTO{ + UserDTO: NewUserDTO(user), + IsActive: user.IsActive, + CreatedAt: user.CreatedAt.Format("2006-01-02T15:04:05Z"), + } +} + +// NewPermissionGroupDTO creates a DTO from a permission group entity. +func NewPermissionGroupDTO(group *entities.PermissionGroup) *PermissionGroupDTO { + return &PermissionGroupDTO{ + ID: group.ID.Hex(), + Name: group.Name, + Description: group.Description, + Permissions: group.Permissions, + IsSystem: group.IsSystem, + CreatedAt: group.CreatedAt.Format("2006-01-02T15:04:05Z"), + UpdatedAt: group.UpdatedAt.Format("2006-01-02T15:04:05Z"), + } +} + +// AddSpaceMemberRequest represents a request to add a member to a space +type AddSpaceMemberRequest struct { + UserID string `json:"user_id"` +} + +// SpaceMemberDTO represents a member in a space +type SpaceMemberDTO struct { + UserID string `json:"user_id"` + Username string `json:"username"` + JoinedAt string `json:"joined_at"` +} + +// UserOptionDTO is a lightweight user object for dropdowns +type UserOptionDTO struct { + ID string `json:"id"` + Username string `json:"username"` +} + +// NewAuthProviderDTO creates a DTO from an auth provider entity. +func NewAuthProviderDTO(provider *entities.AuthProvider) *AuthProviderDTO { + return &AuthProviderDTO{ + ID: provider.ID.Hex(), + Name: provider.Name, + Type: provider.Type, + AuthorizationURL: provider.AuthorizationURL, + TokenURL: provider.TokenURL, + UserInfoURL: provider.UserInfoURL, + Scopes: provider.Scopes, + IDTokenClaim: provider.IDTokenClaim, + IsActive: provider.IsActive, + } +} + +// NewFeatureFlagsDTO creates a DTO from feature flags entity. +func NewFeatureFlagsDTO(flags *entities.FeatureFlags) *FeatureFlagsDTO { + if flags == nil { + flags = entities.NewDefaultFeatureFlags() + } + + return &FeatureFlagsDTO{ + RegistrationEnabled: flags.RegistrationEnabled, + ProviderLoginEnabled: flags.ProviderLoginEnabled, + PublicSharingEnabled: flags.PublicSharingEnabled, + } +} + +// ========== SPACE DTOs ========== + +// CreateSpaceRequest represents a space creation request +type CreateSpaceRequest struct { + Name string `json:"name" validate:"required,min=1,max=100"` + Description string `json:"description" validate:"max=500"` + Icon string `json:"icon,omitempty" validate:"max=20"` + IsPublic bool `json:"is_public"` +} + +// SpaceDTO represents a space in API responses +type SpaceDTO struct { + ID string `json:"id"` + Name string `json:"name"` + PermissionKey string `json:"permission_key"` + Description string `json:"description"` + Icon string `json:"icon,omitempty"` + OwnerID string `json:"owner_id"` + IsPublic bool `json:"is_public"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// NewSpaceDTO creates a DTO from a space entity +func NewSpaceDTO(space *entities.Space) *SpaceDTO { + dto := &SpaceDTO{ + ID: space.ID.Hex(), + Name: space.Name, + PermissionKey: entities.SpacePermissionToken(space.Name), + Description: space.Description, + Icon: space.Icon, + OwnerID: space.OwnerID.Hex(), + IsPublic: space.IsPublic, + CreatedAt: space.CreatedAt.Format("2006-01-02T15:04:05Z"), + UpdatedAt: space.UpdatedAt.Format("2006-01-02T15:04:05Z"), + } + return dto +} + +// ========== NOTE DTOs ========== + +// CreateNoteRequest represents a note creation request +type CreateNoteRequest struct { + Title string `json:"title" validate:"required,min=1,max=255"` + Description string `json:"description" validate:"max=500"` + Content string `json:"content"` + NotePassword string `json:"note_password,omitempty" validate:"omitempty,min=4,max=128"` + Tags []string `json:"tags"` + CategoryID *string `json:"category_id,omitempty"` + IsPinned bool `json:"is_pinned"` + IsFavorite bool `json:"is_favorite"` + IsPublic bool `json:"is_public"` +} + +// UpdateNoteRequest represents a note update request +type UpdateNoteRequest struct { + Title string `json:"title" validate:"min=1,max=255"` + Description *string `json:"description,omitempty" validate:"omitempty,max=500"` + Content string `json:"content"` + NotePassword *string `json:"note_password,omitempty" validate:"omitempty,max=128"` + Tags []string `json:"tags"` + CategoryID *string `json:"category_id,omitempty"` + IsPinned *bool `json:"is_pinned"` + IsFavorite *bool `json:"is_favorite"` + IsPublic *bool `json:"is_public,omitempty"` +} + +// UnlockNoteRequest represents a password unlock request for protected notes +type UnlockNoteRequest struct { + Password string `json:"password" validate:"required,min=1,max=128"` +} + +// NoteDTO represents a note in API responses +type NoteDTO struct { + ID string `json:"id"` + SpaceID string `json:"space_id"` + CategoryID *string `json:"category_id,omitempty"` + Title string `json:"title"` + Description string `json:"description"` + Content string `json:"content"` + Tags []string `json:"tags"` + IsPinned bool `json:"is_pinned"` + IsFavorite bool `json:"is_favorite"` + IsPublic bool `json:"is_public"` + IsPasswordProtected bool `json:"is_password_protected"` + CreatedBy string `json:"created_by"` + UpdatedBy string `json:"updated_by"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// NoteListItemDTO represents a lightweight note payload for list/tree endpoints +type NoteListItemDTO struct { + ID string `json:"id"` + SpaceID string `json:"space_id"` + CategoryID *string `json:"category_id,omitempty"` + Title string `json:"title"` + Description string `json:"description"` + IsPinned bool `json:"is_pinned"` + IsFavorite bool `json:"is_favorite"` + IsPublic bool `json:"is_public"` + IsPasswordProtected bool `json:"is_password_protected"` + UpdatedAt string `json:"updated_at"` +} + +// NewNoteDTO creates a DTO from a note entity +func NewNoteDTO(note *entities.Note) *NoteDTO { + var categoryID *string + if note.CategoryID != nil { + id := note.CategoryID.Hex() + categoryID = &id + } + return &NoteDTO{ + ID: note.ID.Hex(), + SpaceID: note.SpaceID.Hex(), + CategoryID: categoryID, + Title: note.Title, + Description: note.Description, + Content: note.Content, + Tags: note.Tags, + IsPinned: note.IsPinned, + IsFavorite: note.IsFavorite, + IsPublic: note.IsPublic, + IsPasswordProtected: note.IsPasswordProtected, + CreatedBy: note.CreatedBy.Hex(), + UpdatedBy: note.UpdatedBy.Hex(), + CreatedAt: note.CreatedAt.Format("2006-01-02T15:04:05Z"), + UpdatedAt: note.UpdatedAt.Format("2006-01-02T15:04:05Z"), + } +} + +// NewNoteListItemDTO creates a lightweight DTO from a note entity +func NewNoteListItemDTO(note *entities.Note) *NoteListItemDTO { + var categoryID *string + if note.CategoryID != nil { + id := note.CategoryID.Hex() + categoryID = &id + } + + return &NoteListItemDTO{ + ID: note.ID.Hex(), + SpaceID: note.SpaceID.Hex(), + CategoryID: categoryID, + Title: note.Title, + Description: note.Description, + IsPinned: note.IsPinned, + IsFavorite: note.IsFavorite, + IsPublic: note.IsPublic, + IsPasswordProtected: note.IsPasswordProtected, + UpdatedAt: note.UpdatedAt.Format("2006-01-02T15:04:05Z"), + } +} + +// ========== CATEGORY DTOs ========== + +// CreateCategoryRequest represents a category creation request +type CreateCategoryRequest struct { + Name string `json:"name" validate:"required,min=1,max=100"` + Description string `json:"description" validate:"max=500"` + ParentID *string `json:"parent_id,omitempty"` + Icon string `json:"icon,omitempty" validate:"max=20"` +} + +// UpdateCategoryRequest represents a category update request +type UpdateCategoryRequest struct { + Name string `json:"name" validate:"min=1,max=100"` + Description string `json:"description" validate:"max=500"` + Icon string `json:"icon,omitempty" validate:"max=20"` +} + +// CategoryDTO represents a category in API responses +type CategoryDTO struct { + ID string `json:"id"` + SpaceID string `json:"space_id"` + Name string `json:"name"` + Description string `json:"description"` + ParentID *string `json:"parent_id,omitempty"` + Icon string `json:"icon,omitempty"` + Order int `json:"order"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// CategoryTreeDTO represents a category with its subcategories and notes +type CategoryTreeDTO struct { + *CategoryDTO + Subcategories []*CategoryTreeDTO `json:"subcategories"` + Notes []*NoteListItemDTO `json:"notes"` +} + +// NewCategoryDTO creates a DTO from a category entity +func NewCategoryDTO(category *entities.Category) *CategoryDTO { + var parentID *string + if category.ParentID != nil { + id := category.ParentID.Hex() + parentID = &id + } + return &CategoryDTO{ + ID: category.ID.Hex(), + SpaceID: category.SpaceID.Hex(), + Name: category.Name, + Description: category.Description, + ParentID: parentID, + Icon: category.Icon, + Order: category.Order, + CreatedAt: category.CreatedAt.Format("2006-01-02T15:04:05Z"), + UpdatedAt: category.UpdatedAt.Format("2006-01-02T15:04:05Z"), + } +} + +// ========== ERROR DTOs ========== + +// ErrorResponse represents an error response +type ErrorResponse struct { + Error string `json:"error"` + Message string `json:"message"` + Code int `json:"code"` +} + +// ValidationError represents a validation error +type ValidationError struct { + Field string `json:"field"` + Message string `json:"message"` +} + +// ValidationErrorResponse represents multiple validation errors +type ValidationErrorResponse struct { + Errors []ValidationError `json:"errors"` +} diff --git a/backend/internal/application/services/admin_service.go b/backend/internal/application/services/admin_service.go new file mode 100644 index 0000000..9fd073f --- /dev/null +++ b/backend/internal/application/services/admin_service.go @@ -0,0 +1,313 @@ +package services + +import ( + "context" + "errors" + "strings" + + "go.mongodb.org/mongo-driver/v2/bson" + + "github.com/noteapp/backend/internal/application/dto" + "github.com/noteapp/backend/internal/domain/entities" + "github.com/noteapp/backend/internal/domain/repositories" +) + +// AdminService handles admin-level operations +type AdminService struct { + userRepo repositories.UserRepository + groupRepo repositories.GroupRepository + spaceRepo repositories.SpaceRepository + membershipRepo repositories.MembershipRepository + noteRepo repositories.NoteRepository + categoryRepo repositories.CategoryRepository + featureFlagRepo repositories.FeatureFlagRepository + permissionService *PermissionService +} + +// NewAdminService creates a new AdminService +func NewAdminService( + userRepo repositories.UserRepository, + groupRepo repositories.GroupRepository, + spaceRepo repositories.SpaceRepository, + membershipRepo repositories.MembershipRepository, + noteRepo repositories.NoteRepository, + categoryRepo repositories.CategoryRepository, + featureFlagRepo repositories.FeatureFlagRepository, + permissionService *PermissionService, +) *AdminService { + return &AdminService{ + userRepo: userRepo, + groupRepo: groupRepo, + spaceRepo: spaceRepo, + membershipRepo: membershipRepo, + noteRepo: noteRepo, + categoryRepo: categoryRepo, + featureFlagRepo: featureFlagRepo, + permissionService: permissionService, + } +} + +// ListUsers returns all users as admin DTOs +func (s *AdminService) ListUsers(ctx context.Context) ([]*dto.AdminUserDTO, error) { + users, err := s.userRepo.ListAllUsers(ctx) + if err != nil { + return nil, err + } + result := make([]*dto.AdminUserDTO, len(users)) + for i, u := range users { + if s.permissionService != nil { + permissions, err := s.permissionService.GetUserEffectivePermissions(ctx, u) + if err == nil { + u.Permissions = permissions + } + } + result[i] = dto.NewAdminUserDTO(u) + } + return result, nil +} + +// ListGroups returns all permission groups. +func (s *AdminService) ListGroups(ctx context.Context) ([]*dto.PermissionGroupDTO, error) { + groups, err := s.groupRepo.ListGroups(ctx) + if err != nil { + return nil, err + } + + result := make([]*dto.PermissionGroupDTO, len(groups)) + for i, group := range groups { + result[i] = dto.NewPermissionGroupDTO(group) + } + return result, nil +} + +// CreateGroup creates a new permission group. +func (s *AdminService) CreateGroup(ctx context.Context, req *dto.CreatePermissionGroupRequest) (*dto.PermissionGroupDTO, error) { + name := strings.TrimSpace(req.Name) + if name == "" { + return nil, errors.New("group name is required") + } + + group := &entities.PermissionGroup{ + Name: name, + Description: strings.TrimSpace(req.Description), + Permissions: normalizePermissions(req.Permissions), + IsSystem: false, + } + + if err := s.groupRepo.CreateGroup(ctx, group); err != nil { + return nil, err + } + + return dto.NewPermissionGroupDTO(group), nil +} + +// UpdateGroup updates a permission group. +func (s *AdminService) UpdateGroup(ctx context.Context, groupID bson.ObjectID, req *dto.UpdatePermissionGroupRequest) (*dto.PermissionGroupDTO, error) { + group, err := s.groupRepo.GetGroupByID(ctx, groupID) + if err != nil { + return nil, err + } + if group.IsSystem { + return nil, errors.New("system groups cannot be modified") + } + + if name := strings.TrimSpace(req.Name); name != "" { + group.Name = name + } + group.Description = strings.TrimSpace(req.Description) + group.Permissions = normalizePermissions(req.Permissions) + + if err := s.groupRepo.UpdateGroup(ctx, group); err != nil { + return nil, err + } + + if err := s.refreshAllUserPermissions(ctx); err != nil { + return nil, err + } + + return dto.NewPermissionGroupDTO(group), nil +} + +// UpdateUserGroups assigns groups to a user. +func (s *AdminService) UpdateUserGroups(ctx context.Context, userID bson.ObjectID, groupIDs []bson.ObjectID) (*dto.AdminUserDTO, error) { + if s.permissionService == nil { + return nil, errors.New("permission service unavailable") + } + + user, err := s.permissionService.SetUserGroups(ctx, userID, groupIDs) + if err != nil { + return nil, err + } + + return dto.NewAdminUserDTO(user), nil +} + +func (s *AdminService) refreshAllUserPermissions(ctx context.Context) error { + if s.permissionService == nil { + return nil + } + + users, err := s.userRepo.ListAllUsers(ctx) + if err != nil { + return err + } + + for _, user := range users { + if err := s.permissionService.UpdateUserEffectivePermissions(ctx, user); err != nil { + return err + } + } + return nil +} + +func normalizePermissions(permissions []string) []string { + unique := map[string]struct{}{} + result := make([]string, 0, len(permissions)) + for _, permission := range permissions { + normalized := entities.NormalizePermission(permission) + if normalized == "" { + continue + } + if _, exists := unique[normalized]; exists { + continue + } + unique[normalized] = struct{}{} + result = append(result, normalized) + } + return result +} + +// ListAllSpaces returns all spaces +func (s *AdminService) ListAllSpaces(ctx context.Context) ([]*dto.SpaceDTO, error) { + spaces, err := s.spaceRepo.GetAllSpaces(ctx) + if err != nil { + return nil, err + } + result := make([]*dto.SpaceDTO, len(spaces)) + for i, space := range spaces { + result[i] = dto.NewSpaceDTO(space) + } + return result, nil +} + +// UpdateSpace updates all editable space fields +func (s *AdminService) UpdateSpace(ctx context.Context, spaceID bson.ObjectID, req *dto.CreateSpaceRequest) (*dto.SpaceDTO, error) { + space, err := s.spaceRepo.GetSpaceByID(ctx, spaceID) + if err != nil { + return nil, err + } + + space.Name = req.Name + space.Description = req.Description + space.Icon = req.Icon + space.IsPublic = req.IsPublic + + if err := s.spaceRepo.UpdateSpace(ctx, space); err != nil { + return nil, err + } + + return dto.NewSpaceDTO(space), nil +} + +// SetSpaceVisibility sets the is_public flag on a space +func (s *AdminService) SetSpaceVisibility(ctx context.Context, spaceID bson.ObjectID, isPublic bool) error { + space, err := s.spaceRepo.GetSpaceByID(ctx, spaceID) + if err != nil { + return err + } + space.IsPublic = isPublic + return s.spaceRepo.UpdateSpace(ctx, space) +} + +// AddSpaceMember adds a member in a space if not already present. +func (s *AdminService) AddSpaceMember(ctx context.Context, spaceID, userID bson.ObjectID) error { + existing, err := s.membershipRepo.GetUserMembership(ctx, userID, spaceID) + if err == nil && existing != nil { + return nil + } + return s.membershipRepo.CreateMembership(ctx, &entities.Membership{ + UserID: userID, + SpaceID: spaceID, + }) +} + +// ListSpaceMembers returns all members for a space +func (s *AdminService) ListSpaceMembers(ctx context.Context, spaceID bson.ObjectID) ([]*dto.SpaceMemberDTO, error) { + memberships, err := s.membershipRepo.GetSpaceMembers(ctx, spaceID) + if err != nil { + return nil, err + } + + result := make([]*dto.SpaceMemberDTO, 0, len(memberships)) + for _, member := range memberships { + username := member.UserID.Hex() + if user, err := s.userRepo.GetUserByID(ctx, member.UserID); err == nil { + username = user.Username + } + + result = append(result, &dto.SpaceMemberDTO{ + UserID: member.UserID.Hex(), + Username: username, + JoinedAt: member.JoinedAt.Format("2006-01-02T15:04:05Z"), + }) + } + + return result, nil +} + +// RemoveSpaceMember removes a member from a space. +func (s *AdminService) RemoveSpaceMember(ctx context.Context, spaceID, userID bson.ObjectID) error { + membership, err := s.membershipRepo.GetUserMembership(ctx, userID, spaceID) + if err != nil { + return err + } + + return s.membershipRepo.DeleteMembership(ctx, membership.ID) +} + +// DeleteSpace deletes a space and all associated data (admin, no permission check). +func (s *AdminService) DeleteSpace(ctx context.Context, spaceID bson.ObjectID) error { + if err := s.noteRepo.DeleteNotesBySpaceID(ctx, spaceID); err != nil { + return err + } + if err := s.categoryRepo.DeleteCategoriesBySpaceID(ctx, spaceID); err != nil { + return err + } + if err := s.membershipRepo.DeleteMembershipsBySpaceID(ctx, spaceID); err != nil { + return err + } + return s.spaceRepo.DeleteSpace(ctx, spaceID) +} + +// GetFeatureFlags returns current app-wide feature flags. +func (s *AdminService) GetFeatureFlags(ctx context.Context) (*dto.FeatureFlagsDTO, error) { + if s.featureFlagRepo == nil { + return dto.NewFeatureFlagsDTO(nil), nil + } + + flags, err := s.featureFlagRepo.GetFeatureFlags(ctx) + if err != nil { + return nil, err + } + + return dto.NewFeatureFlagsDTO(flags), nil +} + +// UpdateFeatureFlags updates app-wide feature flags. +func (s *AdminService) UpdateFeatureFlags(ctx context.Context, req *dto.UpdateFeatureFlagsRequest) (*dto.FeatureFlagsDTO, error) { + if s.featureFlagRepo == nil { + return nil, errors.New("feature flags are unavailable") + } + + flags := &entities.FeatureFlags{ + RegistrationEnabled: req.RegistrationEnabled, + ProviderLoginEnabled: req.ProviderLoginEnabled, + PublicSharingEnabled: req.PublicSharingEnabled, + } + + if err := s.featureFlagRepo.UpdateFeatureFlags(ctx, flags); err != nil { + return nil, err + } + + return dto.NewFeatureFlagsDTO(flags), nil +} diff --git a/backend/internal/application/services/auth_service.go b/backend/internal/application/services/auth_service.go new file mode 100644 index 0000000..3695837 --- /dev/null +++ b/backend/internal/application/services/auth_service.go @@ -0,0 +1,592 @@ +package services + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "regexp" + "strings" + "time" + + "github.com/noteapp/backend/internal/application/dto" + "github.com/noteapp/backend/internal/domain/entities" + "github.com/noteapp/backend/internal/domain/repositories" + "github.com/noteapp/backend/internal/infrastructure/auth" + "github.com/noteapp/backend/internal/infrastructure/security" + "go.mongodb.org/mongo-driver/v2/bson" + "golang.org/x/oauth2" +) + +// AuthService handles authentication operations +type AuthService struct { + userRepo repositories.UserRepository + groupRepo repositories.GroupRepository + providerRepo repositories.AuthProviderRepository + linkRepo repositories.UserProviderLinkRepository + recoveryRepo repositories.AccountRecoveryRepository + featureFlagRepo repositories.FeatureFlagRepository + permissionService *PermissionService + jwtManager *auth.JWTManager + passHasher *security.PasswordHasher + encryptor *security.Encryptor +} + +// NewAuthService creates a new auth service +func NewAuthService( + userRepo repositories.UserRepository, + groupRepo repositories.GroupRepository, + providerRepo repositories.AuthProviderRepository, + linkRepo repositories.UserProviderLinkRepository, + recoveryRepo repositories.AccountRecoveryRepository, + featureFlagRepo repositories.FeatureFlagRepository, + permissionService *PermissionService, + jwtManager *auth.JWTManager, + passHasher *security.PasswordHasher, + encryptor *security.Encryptor, +) *AuthService { + return &AuthService{ + userRepo: userRepo, + groupRepo: groupRepo, + providerRepo: providerRepo, + linkRepo: linkRepo, + recoveryRepo: recoveryRepo, + featureFlagRepo: featureFlagRepo, + permissionService: permissionService, + jwtManager: jwtManager, + passHasher: passHasher, + encryptor: encryptor, + } +} + +// Register registers a new user +func (s *AuthService) Register(ctx context.Context, req *dto.RegisterRequest) (*dto.LoginResponse, error) { + flags, err := s.GetFeatureFlags(ctx) + if err != nil { + return nil, err + } + if !flags.RegistrationEnabled { + return nil, errors.New("registration is currently disabled") + } + + req.Email = strings.ToLower(strings.TrimSpace(req.Email)) + req.Username = strings.TrimSpace(req.Username) + + // Check if email already exists + _, err = s.userRepo.GetUserByEmail(ctx, req.Email) + if err == nil { + return nil, errors.New("email already registered") + } + + // Check if username already exists + _, err = s.userRepo.GetUserByUsername(ctx, req.Username) + if err == nil { + return nil, errors.New("username already taken") + } + + // Hash password + hashedPassword, err := s.passHasher.HashPassword(req.Password) + if err != nil { + return nil, err + } + + // Create user + user := &entities.User{ + Email: req.Email, + Username: req.Username, + PasswordHash: hashedPassword, + FirstName: req.FirstName, + LastName: req.LastName, + IsActive: true, + EmailVerified: false, // Should verify email in production + } + + if err := s.userRepo.CreateUser(ctx, user); err != nil { + return nil, err + } + + if s.permissionService != nil { + if err := s.permissionService.UpdateUserEffectivePermissions(ctx, user); err != nil { + return nil, err + } + } + + // Generate tokens + accessToken, err := s.jwtManager.GenerateAccessToken(user.ID.Hex(), user.Email, user.Username) + if err != nil { + return nil, err + } + + refreshToken, err := s.jwtManager.GenerateRefreshToken(user.ID.Hex()) + if err != nil { + return nil, err + } + + return &dto.LoginResponse{ + AccessToken: accessToken, + RefreshToken: refreshToken, + User: dto.NewUserDTO(user), + ExpiresIn: 3600, // 1 hour + }, nil +} + +// Login authenticates a user +func (s *AuthService) Login(ctx context.Context, req *dto.LoginRequest) (*dto.LoginResponse, error) { + req.Email = strings.ToLower(strings.TrimSpace(req.Email)) + + // Get user by email + user, err := s.userRepo.GetUserByEmail(ctx, req.Email) + if err != nil { + return nil, errors.New("invalid credentials") + } + + if !user.IsActive { + return nil, errors.New("account is inactive") + } + + // Verify password + match, err := s.passHasher.VerifyPassword(req.Password, user.PasswordHash) + if err != nil || !match { + return nil, errors.New("invalid credentials") + } + + // Update last login + now := time.Now() + user.LastLoginAt = &now + if s.permissionService != nil { + if err := s.permissionService.UpdateUserEffectivePermissions(ctx, user); err != nil { + return nil, err + } + } + if err := s.userRepo.UpdateUser(ctx, user); err != nil { + // Log error but don't fail the login + } + + // Generate tokens + accessToken, err := s.jwtManager.GenerateAccessToken(user.ID.Hex(), user.Email, user.Username) + if err != nil { + return nil, err + } + + refreshToken, err := s.jwtManager.GenerateRefreshToken(user.ID.Hex()) + if err != nil { + return nil, err + } + + return &dto.LoginResponse{ + AccessToken: accessToken, + RefreshToken: refreshToken, + User: dto.NewUserDTO(user), + ExpiresIn: 3600, + }, nil +} + +// RefreshAccessToken refreshes an access token +func (s *AuthService) RefreshAccessToken(ctx context.Context, refreshToken string) (string, error) { + claims, err := s.jwtManager.VerifyRefreshToken(refreshToken) + if err != nil { + return "", err + } + + user, err := s.userRepo.GetUserByID(ctx, mustParseObjectID(claims.UserID)) + if err != nil { + return "", err + } + + return s.jwtManager.GenerateAccessToken(user.ID.Hex(), user.Email, user.Username) +} + +// RequestPasswordReset initiates password reset flow +func (s *AuthService) RequestPasswordReset(ctx context.Context, email string) error { + user, err := s.userRepo.GetUserByEmail(ctx, email) + if err != nil { + // Don't reveal if email exists (security best practice) + return nil + } + + token, err := auth.GenerateRandomToken(32) + if err != nil { + return err + } + + recovery := &entities.AccountRecovery{ + UserID: user.ID, + Token: token, + Type: "password_reset", + ExpiresAt: time.Now().Add(1 * time.Hour), + } + + // Save recovery token + // This would need AccountRecoveryRepository implementation + _ = recovery + + // In production: send email with reset link containing token + return nil +} + +// mustParseObjectID parses a string to ObjectID, panics on error +func mustParseObjectID(id string) bson.ObjectID { + objID, _ := bson.ObjectIDFromHex(id) + return objID +} + +// ListProviders returns all active OAuth/OIDC providers. +func (s *AuthService) ListProviders(ctx context.Context) ([]*dto.AuthProviderDTO, error) { + flags, err := s.GetFeatureFlags(ctx) + if err != nil { + return nil, err + } + if !flags.ProviderLoginEnabled { + return []*dto.AuthProviderDTO{}, nil + } + + if s.providerRepo == nil { + return []*dto.AuthProviderDTO{}, nil + } + + providers, err := s.providerRepo.GetAllProviders(ctx) + if err != nil { + return nil, err + } + + result := make([]*dto.AuthProviderDTO, 0, len(providers)) + for _, provider := range providers { + result = append(result, dto.NewAuthProviderDTO(provider)) + } + + return result, nil +} + +// GetFeatureFlags returns current app-wide feature flags. +func (s *AuthService) GetFeatureFlags(ctx context.Context) (*dto.FeatureFlagsDTO, error) { + if s.featureFlagRepo == nil { + return dto.NewFeatureFlagsDTO(nil), nil + } + + flags, err := s.featureFlagRepo.GetFeatureFlags(ctx) + if err != nil { + return nil, err + } + + return dto.NewFeatureFlagsDTO(flags), nil +} + +// CreateProvider stores a new OAuth/OIDC provider. +func (s *AuthService) CreateProvider(ctx context.Context, req *dto.CreateAuthProviderRequest) (*dto.AuthProviderDTO, error) { + if s.providerRepo == nil || s.encryptor == nil { + return nil, errors.New("provider configuration unavailable") + } + + providerType := strings.ToLower(strings.TrimSpace(req.Type)) + if providerType != "oidc" && providerType != "oauth2" { + return nil, errors.New("provider type must be oidc or oauth2") + } + + name := strings.TrimSpace(req.Name) + clientID := strings.TrimSpace(req.ClientID) + clientSecret := strings.TrimSpace(req.ClientSecret) + authorizationURL := strings.TrimSpace(req.AuthorizationURL) + tokenURL := strings.TrimSpace(req.TokenURL) + if name == "" || clientID == "" || clientSecret == "" || authorizationURL == "" || tokenURL == "" { + return nil, errors.New("missing required provider fields") + } + + encryptedSecret, err := s.encryptor.Encrypt(clientSecret) + if err != nil { + return nil, err + } + + provider := &entities.AuthProvider{ + Name: name, + Type: providerType, + ClientID: clientID, + ClientSecret: encryptedSecret, + AuthorizationURL: authorizationURL, + TokenURL: tokenURL, + UserInfoURL: strings.TrimSpace(req.UserInfoURL), + Scopes: normalizeScopes(req.Scopes, providerType), + IDTokenClaim: strings.TrimSpace(req.IDTokenClaim), + IsActive: req.IsActive, + } + + if err := s.providerRepo.CreateProvider(ctx, provider); err != nil { + return nil, err + } + + return dto.NewAuthProviderDTO(provider), nil +} + +// BuildProviderAuthorizationURL constructs a provider authorization URL. +func (s *AuthService) BuildProviderAuthorizationURL(ctx context.Context, providerID bson.ObjectID, redirectURI, state string) (string, error) { + flags, err := s.GetFeatureFlags(ctx) + if err != nil { + return "", err + } + if !flags.ProviderLoginEnabled { + return "", errors.New("provider login is currently disabled") + } + + provider, secret, err := s.getProviderConfig(ctx, providerID) + if err != nil { + return "", err + } + + config := oauth2.Config{ + ClientID: provider.ClientID, + ClientSecret: secret, + RedirectURL: redirectURI, + Scopes: normalizeScopes(provider.Scopes, provider.Type), + Endpoint: oauth2.Endpoint{ + AuthURL: provider.AuthorizationURL, + TokenURL: provider.TokenURL, + }, + } + + return config.AuthCodeURL(state, oauth2.AccessTypeOffline), nil +} + +// CompleteProviderLogin exchanges an auth code and creates a user session. +func (s *AuthService) CompleteProviderLogin(ctx context.Context, providerID bson.ObjectID, code, redirectURI string) (*dto.LoginResponse, error) { + if s.providerRepo == nil || s.linkRepo == nil { + return nil, errors.New("provider login unavailable") + } + + flags, err := s.GetFeatureFlags(ctx) + if err != nil { + return nil, err + } + if !flags.ProviderLoginEnabled { + return nil, errors.New("provider login is currently disabled") + } + + provider, secret, err := s.getProviderConfig(ctx, providerID) + if err != nil { + return nil, err + } + + config := oauth2.Config{ + ClientID: provider.ClientID, + ClientSecret: secret, + RedirectURL: redirectURI, + Scopes: normalizeScopes(provider.Scopes, provider.Type), + Endpoint: oauth2.Endpoint{ + AuthURL: provider.AuthorizationURL, + TokenURL: provider.TokenURL, + }, + } + + token, err := config.Exchange(ctx, code) + if err != nil { + return nil, err + } + + profile, err := s.fetchProviderProfile(ctx, provider, token.AccessToken, token.Extra(provider.IDTokenClaim)) + if err != nil { + return nil, err + } + + user, err := s.findOrCreateOAuthUser(ctx, provider, profile) + if err != nil { + return nil, err + } + + accessToken, err := s.jwtManager.GenerateAccessToken(user.ID.Hex(), user.Email, user.Username) + if err != nil { + return nil, err + } + + refreshToken, err := s.jwtManager.GenerateRefreshToken(user.ID.Hex()) + if err != nil { + return nil, err + } + + return &dto.LoginResponse{AccessToken: accessToken, RefreshToken: refreshToken, User: dto.NewUserDTO(user), ExpiresIn: 3600}, nil +} + +type providerProfile struct { + ProviderUserID string + Email string + Username string + FirstName string + LastName string +} + +func (s *AuthService) getProviderConfig(ctx context.Context, providerID bson.ObjectID) (*entities.AuthProvider, string, error) { + provider, err := s.providerRepo.GetProviderByID(ctx, providerID) + if err != nil { + return nil, "", err + } + if !provider.IsActive { + return nil, "", errors.New("provider is inactive") + } + + secret, err := s.encryptor.Decrypt(provider.ClientSecret) + if err != nil { + return nil, "", err + } + + return provider, secret, nil +} + +func (s *AuthService) fetchProviderProfile(ctx context.Context, provider *entities.AuthProvider, accessToken string, rawIDToken any) (*providerProfile, error) { + payload := map[string]any{} + + if provider.UserInfoURL != "" { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, provider.UserInfoURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+accessToken) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("provider userinfo request failed: %s", string(body)) + } + + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + return nil, err + } + } else if idToken, ok := rawIDToken.(string); ok && idToken != "" { + payload = decodeJWTWithoutVerify(idToken) + } else { + return nil, errors.New("provider must define userinfo_url or return id_token") + } + + profile := &providerProfile{ + ProviderUserID: firstNonEmpty(asString(payload["sub"]), asString(payload["id"]), asString(payload["user_id"])), + Email: strings.ToLower(strings.TrimSpace(firstNonEmpty(asString(payload["email"]), asString(payload["upn"])))), + Username: firstNonEmpty(asString(payload["preferred_username"]), asString(payload["login"]), asString(payload["name"])), + FirstName: firstNonEmpty(asString(payload["given_name"]), asString(payload["first_name"])), + LastName: firstNonEmpty(asString(payload["family_name"]), asString(payload["last_name"])), + } + + if profile.ProviderUserID == "" { + return nil, errors.New("provider user info missing subject identifier") + } + if profile.Email == "" { + profile.Email = fmt.Sprintf("%s@%s.oauth.local", sanitizeUsername(profile.ProviderUserID), sanitizeUsername(provider.Name)) + } + if profile.Username == "" { + profile.Username = strings.Split(profile.Email, "@")[0] + } + profile.Username = sanitizeUsername(profile.Username) + + return profile, nil +} + +func (s *AuthService) findOrCreateOAuthUser(ctx context.Context, provider *entities.AuthProvider, profile *providerProfile) (*entities.User, error) { + if link, err := s.linkRepo.GetLinkByProviderUserID(ctx, provider.ID, profile.ProviderUserID); err == nil { + return s.userRepo.GetUserByID(ctx, link.UserID) + } + + user, err := s.userRepo.GetUserByEmail(ctx, profile.Email) + if err != nil { + username, err := s.generateUniqueUsername(ctx, profile.Username) + if err != nil { + return nil, err + } + + user = &entities.User{Email: profile.Email, Username: username, PasswordHash: "", FirstName: profile.FirstName, LastName: profile.LastName, IsActive: true, EmailVerified: true} + if err := s.userRepo.CreateUser(ctx, user); err != nil { + return nil, err + } + } + + if _, err := s.linkRepo.GetLink(ctx, user.ID, provider.ID); err != nil { + if err := s.linkRepo.CreateLink(ctx, &entities.UserProviderLink{UserID: user.ID, ProviderID: provider.ID, ProviderUserID: profile.ProviderUserID, Email: profile.Email}); err != nil { + return nil, err + } + } + + return user, nil +} + +func (s *AuthService) generateUniqueUsername(ctx context.Context, base string) (string, error) { + base = sanitizeUsername(base) + candidates := []string{base} + for i := 0; i < 5; i++ { + token, err := auth.GenerateRandomToken(2) + if err != nil { + return "", err + } + candidates = append(candidates, fmt.Sprintf("%s-%s", base, token[:4])) + } + + for _, candidate := range candidates { + if _, err := s.userRepo.GetUserByUsername(ctx, candidate); err != nil { + return candidate, nil + } + } + + return fmt.Sprintf("%s-%d", base, time.Now().Unix()), nil +} + +func normalizeScopes(scopes []string, providerType string) []string { + if len(scopes) == 0 { + if providerType == "oidc" { + return []string{"openid", "profile", "email"} + } + return []string{"profile", "email"} + } + + result := make([]string, 0, len(scopes)) + for _, scope := range scopes { + scope = strings.TrimSpace(scope) + if scope != "" { + result = append(result, scope) + } + } + return result +} + +func decodeJWTWithoutVerify(token string) map[string]any { + parts := strings.Split(token, ".") + if len(parts) < 2 { + return map[string]any{} + } + + decoded, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return map[string]any{} + } + + claims := map[string]any{} + if err := json.Unmarshal(decoded, &claims); err != nil { + return map[string]any{} + } + return claims +} + +func asString(value any) string { + if str, ok := value.(string); ok { + return strings.TrimSpace(str) + } + return "" +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if value != "" { + return value + } + } + return "" +} + +func sanitizeUsername(value string) string { + cleaned := regexp.MustCompile(`[^a-zA-Z0-9_-]+`).ReplaceAllString(strings.ToLower(strings.TrimSpace(value)), "-") + cleaned = strings.Trim(cleaned, "-") + if cleaned == "" { + return "user" + } + return cleaned +} diff --git a/backend/internal/application/services/category_service.go b/backend/internal/application/services/category_service.go new file mode 100644 index 0000000..87c55fb --- /dev/null +++ b/backend/internal/application/services/category_service.go @@ -0,0 +1,283 @@ +package services + +import ( + "context" + "errors" + "time" + + "go.mongodb.org/mongo-driver/v2/bson" + + "github.com/noteapp/backend/internal/application/dto" + "github.com/noteapp/backend/internal/domain/entities" + "github.com/noteapp/backend/internal/domain/repositories" +) + +// CategoryService handles category operations +type CategoryService struct { + categoryRepo repositories.CategoryRepository + membershipRepo repositories.MembershipRepository + noteRepo repositories.NoteRepository + permissionService *PermissionService +} + +// NewCategoryService creates a new category service +func NewCategoryService( + categoryRepo repositories.CategoryRepository, + membershipRepo repositories.MembershipRepository, + noteRepo repositories.NoteRepository, + permissionService *PermissionService, +) *CategoryService { + return &CategoryService{ + categoryRepo: categoryRepo, + membershipRepo: membershipRepo, + noteRepo: noteRepo, + permissionService: permissionService, + } +} + +// CreateCategory creates a new category +func (s *CategoryService) CreateCategory(ctx context.Context, spaceID, userID bson.ObjectID, req *dto.CreateCategoryRequest) (*dto.CategoryDTO, error) { + // Verify user has access to space + membership, err := s.membershipRepo.GetUserMembership(ctx, userID, spaceID) + if err != nil { + return nil, errors.New("unauthorized") + } + _ = membership + hasPermission, permErr := s.hasSpacePermission(ctx, userID, spaceID, "category.create") + if permErr != nil { + return nil, permErr + } + if !hasPermission { + return nil, errors.New("insufficient permissions") + } + + var parentID *bson.ObjectID + if req.ParentID != nil { + id, _ := bson.ObjectIDFromHex(*req.ParentID) + parentID = &id + + // Verify parent category exists and belongs to same space + parent, err := s.categoryRepo.GetCategoryByID(ctx, id) + if err != nil || parent.SpaceID != spaceID { + return nil, errors.New("invalid parent category") + } + } + + // Get next order value + categories, err := s.categoryRepo.GetCategoriesBySpaceID(ctx, spaceID) + order := len(categories) + + category := &entities.Category{ + SpaceID: spaceID, + Name: req.Name, + Description: req.Description, + ParentID: parentID, + Icon: req.Icon, + Order: order, + CreatedBy: userID, + UpdatedBy: userID, + } + + if err := s.categoryRepo.CreateCategory(ctx, category); err != nil { + return nil, err + } + + return dto.NewCategoryDTO(category), nil +} + +// GetCategoryTree retrieves the full tree structure for a space +func (s *CategoryService) GetCategoryTree(ctx context.Context, spaceID, userID bson.ObjectID) ([]*dto.CategoryTreeDTO, error) { + // Verify user has access to space + if _, err := s.membershipRepo.GetUserMembership(ctx, userID, spaceID); err != nil { + return nil, errors.New("unauthorized") + } + + // Get root categories + categories, err := s.categoryRepo.GetRootCategories(ctx, spaceID) + if err != nil { + return nil, err + } + + var trees []*dto.CategoryTreeDTO + for _, category := range categories { + tree, err := s.buildCategoryTree(ctx, category, spaceID) + if err == nil { + trees = append(trees, tree) + } + } + + return trees, nil +} + +// buildCategoryTree recursively builds a category tree +func (s *CategoryService) buildCategoryTree(ctx context.Context, category *entities.Category, spaceID bson.ObjectID) (*dto.CategoryTreeDTO, error) { + tree := &dto.CategoryTreeDTO{ + CategoryDTO: dto.NewCategoryDTO(category), + } + + // Get subcategories + subcategories, err := s.categoryRepo.GetSubcategories(ctx, category.ID) + if err == nil { + for _, subcat := range subcategories { + subtree, err := s.buildCategoryTree(ctx, subcat, spaceID) + if err == nil { + tree.Subcategories = append(tree.Subcategories, subtree) + } + } + } + + // Get notes in this category + notes, err := s.noteRepo.GetNotesByCategory(ctx, spaceID, category.ID) + if err == nil { + for _, note := range notes { + tree.Notes = append(tree.Notes, dto.NewNoteListItemDTO(note)) + } + } + + return tree, nil +} + +// UpdateCategory updates a category +func (s *CategoryService) UpdateCategory(ctx context.Context, categoryID, spaceID, userID bson.ObjectID, req *dto.UpdateCategoryRequest) (*dto.CategoryDTO, error) { + // Verify user has access to space + membership, err := s.membershipRepo.GetUserMembership(ctx, userID, spaceID) + if err != nil { + return nil, errors.New("unauthorized") + } + _ = membership + hasPermission, permErr := s.hasSpacePermission(ctx, userID, spaceID, "category.edit") + if permErr != nil { + return nil, permErr + } + if !hasPermission { + return nil, errors.New("insufficient permissions") + } + + category, err := s.categoryRepo.GetCategoryByID(ctx, categoryID) + if err != nil { + return nil, err + } + + // Verify category belongs to this space + if category.SpaceID != spaceID { + return nil, errors.New("category not found in this space") + } + + if req.Name != "" { + category.Name = req.Name + } + if req.Description != "" { + category.Description = req.Description + } + if req.Icon != "" { + category.Icon = req.Icon + } + + category.UpdatedBy = userID + + if err := s.categoryRepo.UpdateCategory(ctx, category); err != nil { + return nil, err + } + + return dto.NewCategoryDTO(category), nil +} + +// DeleteCategory deletes a category (and optionally move notes) +func (s *CategoryService) DeleteCategory(ctx context.Context, categoryID, spaceID, userID bson.ObjectID, moveNotesTo *string) error { + // Verify user has access to space + membership, err := s.membershipRepo.GetUserMembership(ctx, userID, spaceID) + if err != nil { + return errors.New("unauthorized") + } + _ = membership + hasPermission, permErr := s.hasSpacePermission(ctx, userID, spaceID, "category.delete") + if permErr != nil { + return permErr + } + if !hasPermission { + return errors.New("insufficient permissions") + } + + category, err := s.categoryRepo.GetCategoryByID(ctx, categoryID) + if err != nil { + return err + } + + // Verify category belongs to this space + if category.SpaceID != spaceID { + return errors.New("category not found in this space") + } + + // Handle notes in this category + notes, err := s.noteRepo.GetNotesByCategory(ctx, spaceID, categoryID) + if err == nil { + for _, note := range notes { + if moveNotesTo != nil { + targetID, _ := bson.ObjectIDFromHex(*moveNotesTo) + note.CategoryID = &targetID + s.noteRepo.UpdateNote(ctx, note) + } else { + // Move to root (no category) + note.CategoryID = nil + s.noteRepo.UpdateNote(ctx, note) + } + } + } + + return s.categoryRepo.DeleteCategory(ctx, categoryID) +} + +// MoveCategory moves a category to a new parent +func (s *CategoryService) MoveCategory(ctx context.Context, categoryID, spaceID, userID bson.ObjectID, newParentID *string) (*dto.CategoryDTO, error) { + // Verify user has access to space + membership, err := s.membershipRepo.GetUserMembership(ctx, userID, spaceID) + if err != nil { + return nil, errors.New("unauthorized") + } + _ = membership + hasPermission, permErr := s.hasSpacePermission(ctx, userID, spaceID, "category.edit") + if permErr != nil { + return nil, permErr + } + if !hasPermission { + return nil, errors.New("insufficient permissions") + } + + category, err := s.categoryRepo.GetCategoryByID(ctx, categoryID) + if err != nil { + return nil, err + } + + // Verify category belongs to this space + if category.SpaceID != spaceID { + return nil, errors.New("category not found in this space") + } + + // Validate new parent + if newParentID != nil { + parentID, _ := bson.ObjectIDFromHex(*newParentID) + parent, err := s.categoryRepo.GetCategoryByID(ctx, parentID) + if err != nil || parent.SpaceID != spaceID { + return nil, errors.New("invalid parent category") + } + category.ParentID = &parentID + } else { + category.ParentID = nil + } + + category.UpdatedBy = userID + category.UpdatedAt = time.Now() + + if err := s.categoryRepo.UpdateCategory(ctx, category); err != nil { + return nil, err + } + + return dto.NewCategoryDTO(category), nil +} + +func (s *CategoryService) hasSpacePermission(ctx context.Context, userID, spaceID bson.ObjectID, action string) (bool, error) { + if s.permissionService == nil { + return false, errors.New("permission service unavailable") + } + return s.permissionService.HasSpacePermission(ctx, userID, spaceID, action) +} diff --git a/backend/internal/application/services/note_service.go b/backend/internal/application/services/note_service.go new file mode 100644 index 0000000..3514ccc --- /dev/null +++ b/backend/internal/application/services/note_service.go @@ -0,0 +1,427 @@ +package services + +import ( + "context" + "errors" + "strings" + "time" + + "github.com/noteapp/backend/internal/application/dto" + "github.com/noteapp/backend/internal/domain/entities" + "github.com/noteapp/backend/internal/domain/repositories" + "github.com/noteapp/backend/internal/infrastructure/security" + "go.mongodb.org/mongo-driver/v2/bson" +) + +// NoteService handles note operations +type NoteService struct { + noteRepo repositories.NoteRepository + categoryRepo repositories.CategoryRepository + membershipRepo repositories.MembershipRepository + revisionRepo repositories.NoteRevisionRepository + spaceRepo repositories.SpaceRepository + permissionService *PermissionService + passwordHasher *security.PasswordHasher +} + +// NewNoteService creates a new note service +func NewNoteService( + noteRepo repositories.NoteRepository, + categoryRepo repositories.CategoryRepository, + membershipRepo repositories.MembershipRepository, + revisionRepo repositories.NoteRevisionRepository, + spaceRepo repositories.SpaceRepository, + permissionService *PermissionService, + passwordHasher *security.PasswordHasher, +) *NoteService { + return &NoteService{ + noteRepo: noteRepo, + categoryRepo: categoryRepo, + membershipRepo: membershipRepo, + revisionRepo: revisionRepo, + spaceRepo: spaceRepo, + permissionService: permissionService, + passwordHasher: passwordHasher, + } +} + +func (s *NoteService) toDisplayNoteDTO(note *entities.Note) *dto.NoteDTO { + noteDTO := dto.NewNoteDTO(note) + if note.IsPasswordProtected { + noteDTO.Content = "" + } + return noteDTO +} + +// CreateNote creates a new note +func (s *NoteService) CreateNote(ctx context.Context, spaceID, userID bson.ObjectID, req *dto.CreateNoteRequest) (*dto.NoteDTO, error) { + // Verify user has access to space + membership, err := s.membershipRepo.GetUserMembership(ctx, userID, spaceID) + if err != nil { + return nil, errors.New("unauthorized") + } + _ = membership + hasPermission, permErr := s.hasSpacePermission(ctx, userID, spaceID, "note.create") + if permErr != nil { + return nil, permErr + } + if !hasPermission { + return nil, errors.New("insufficient permissions") + } + + var categoryID *bson.ObjectID + if req.CategoryID != nil { + id, _ := bson.ObjectIDFromHex(*req.CategoryID) + categoryID = &id + } + + note := &entities.Note{ + SpaceID: spaceID, + CategoryID: categoryID, + Title: req.Title, + Description: req.Description, + Content: req.Content, + Tags: req.Tags, + IsPinned: req.IsPinned, + IsFavorite: req.IsFavorite, + IsPublic: req.IsPublic, + CreatedBy: userID, + UpdatedBy: userID, + } + + notePassword := strings.TrimSpace(req.NotePassword) + if notePassword != "" { + if len(notePassword) < 4 { + return nil, errors.New("note password must be at least 4 characters") + } + if s.passwordHasher == nil { + return nil, errors.New("password hasher unavailable") + } + hash, err := s.passwordHasher.HashPassword(notePassword) + if err != nil { + return nil, err + } + note.PasswordHash = hash + note.IsPasswordProtected = true + } + + if err := s.noteRepo.CreateNote(ctx, note); err != nil { + return nil, err + } + + return dto.NewNoteDTO(note), nil +} + +// GetNote retrieves a note (with space authorization check) +func (s *NoteService) GetNote(ctx context.Context, noteID, spaceID, userID bson.ObjectID) (*dto.NoteDTO, error) { + // Verify user has access to space + if _, err := s.membershipRepo.GetUserMembership(ctx, userID, spaceID); err != nil { + return nil, errors.New("unauthorized") + } + + note, err := s.noteRepo.GetNoteByID(ctx, noteID) + if err != nil { + return nil, err + } + + // Verify note belongs to this space + if note.SpaceID != spaceID { + return nil, errors.New("note not found in this space") + } + + // Update viewed time + now := time.Now() + note.ViewedAt = &now + _ = s.noteRepo.UpdateNote(ctx, note) + + return s.toDisplayNoteDTO(note), nil +} + +// GetNotesBySpace retrieves notes in a space +func (s *NoteService) GetNotesBySpace(ctx context.Context, spaceID, userID bson.ObjectID, skip, limit int) ([]*dto.NoteDTO, error) { + // Verify user has access to space + if _, err := s.membershipRepo.GetUserMembership(ctx, userID, spaceID); err != nil { + return nil, errors.New("unauthorized") + } + + notes, err := s.noteRepo.GetNotesBySpaceID(ctx, spaceID, skip, limit) + if err != nil { + return nil, err + } + + var noteDTOs []*dto.NoteDTO + for _, note := range notes { + noteDTOs = append(noteDTOs, s.toDisplayNoteDTO(note)) + } + + return noteDTOs, nil +} + +// GetPublicNotesBySpace returns notes for a public space without requiring auth +func (s *NoteService) GetPublicNotesBySpace(ctx context.Context, spaceID bson.ObjectID, skip, limit int) ([]*dto.NoteDTO, error) { + space, err := s.spaceRepo.GetSpaceByID(ctx, spaceID) + if err != nil { + return nil, errors.New("space not found") + } + if !space.IsPublic { + return nil, errors.New("space is not public") + } + + notes, err := s.noteRepo.GetPublicNotesBySpaceID(ctx, spaceID, skip, limit) + if err != nil { + return nil, err + } + + var noteDTOs []*dto.NoteDTO + for _, note := range notes { + noteDTOs = append(noteDTOs, s.toDisplayNoteDTO(note)) + } + return noteDTOs, nil +} + +// GetPublicNoteBySpaceAndID returns a single public note in a public space without requiring auth +func (s *NoteService) GetPublicNoteBySpaceAndID(ctx context.Context, spaceID, noteID bson.ObjectID) (*dto.NoteDTO, error) { + space, err := s.spaceRepo.GetSpaceByID(ctx, spaceID) + if err != nil { + return nil, errors.New("space not found") + } + if !space.IsPublic { + return nil, errors.New("space is not public") + } + + note, err := s.noteRepo.GetNoteByID(ctx, noteID) + if err != nil { + return nil, errors.New("note not found") + } + if note.SpaceID != spaceID { + return nil, errors.New("note not found") + } + if !note.IsPublic { + return nil, errors.New("note is not public") + } + + return s.toDisplayNoteDTO(note), nil +} + +// SearchNotes performs full-text search on notes in a space +func (s *NoteService) SearchNotes(ctx context.Context, spaceID, userID bson.ObjectID, query string) ([]*dto.NoteDTO, error) { + // Verify user has access to space + if _, err := s.membershipRepo.GetUserMembership(ctx, userID, spaceID); err != nil { + return nil, errors.New("unauthorized") + } + + notes, err := s.noteRepo.SearchNotes(ctx, spaceID, query) + if err != nil { + return nil, err + } + + var noteDTOs []*dto.NoteDTO + for _, note := range notes { + noteDTOs = append(noteDTOs, s.toDisplayNoteDTO(note)) + } + + return noteDTOs, nil +} + +// UpdateNote updates a note +func (s *NoteService) UpdateNote(ctx context.Context, noteID, spaceID, userID bson.ObjectID, req *dto.UpdateNoteRequest) (*dto.NoteDTO, error) { + // Verify user has access to space + membership, err := s.membershipRepo.GetUserMembership(ctx, userID, spaceID) + if err != nil { + return nil, errors.New("unauthorized") + } + _ = membership + hasPermission, permErr := s.hasSpacePermission(ctx, userID, spaceID, "note.edit") + if permErr != nil { + return nil, permErr + } + if !hasPermission { + return nil, errors.New("insufficient permissions") + } + + note, err := s.noteRepo.GetNoteByID(ctx, noteID) + if err != nil { + return nil, err + } + + // Verify note belongs to this space + if note.SpaceID != spaceID { + return nil, errors.New("note not found in this space") + } + + // Create revision before updating + if s.revisionRepo != nil { + revision := &entities.NoteRevision{ + NoteID: note.ID, + SpaceID: spaceID, + Title: note.Title, + Content: note.Content, + ChangedBy: userID, + CreatedAt: time.Now(), + } + _ = s.revisionRepo.CreateRevision(ctx, revision) + } + + // Update fields + if req.Title != "" { + note.Title = req.Title + } + if req.Content != "" { + note.Content = req.Content + } + if req.Description != nil { + note.Description = *req.Description + } + if req.Tags != nil { + note.Tags = req.Tags + } + if req.CategoryID != nil { + id, _ := bson.ObjectIDFromHex(*req.CategoryID) + note.CategoryID = &id + } + if req.IsPinned != nil { + note.IsPinned = *req.IsPinned + } + if req.IsFavorite != nil { + note.IsFavorite = *req.IsFavorite + } + if req.IsPublic != nil { + note.IsPublic = *req.IsPublic + } + if req.NotePassword != nil { + if s.passwordHasher == nil { + return nil, errors.New("password hasher unavailable") + } + notePassword := strings.TrimSpace(*req.NotePassword) + if notePassword == "" { + note.PasswordHash = "" + note.IsPasswordProtected = false + } else { + if len(notePassword) < 4 { + return nil, errors.New("note password must be at least 4 characters") + } + hash, hashErr := s.passwordHasher.HashPassword(notePassword) + if hashErr != nil { + return nil, hashErr + } + note.PasswordHash = hash + note.IsPasswordProtected = true + } + } + + note.UpdatedBy = userID + + if err := s.noteRepo.UpdateNote(ctx, note); err != nil { + return nil, err + } + + return dto.NewNoteDTO(note), nil +} + +// UnlockNote verifies a protected note password and returns full note content for authenticated users. +func (s *NoteService) UnlockNote(ctx context.Context, noteID, spaceID, userID bson.ObjectID, password string) (*dto.NoteDTO, error) { + if _, err := s.membershipRepo.GetUserMembership(ctx, userID, spaceID); err != nil { + return nil, errors.New("unauthorized") + } + + note, err := s.noteRepo.GetNoteByID(ctx, noteID) + if err != nil { + return nil, errors.New("note not found") + } + if note.SpaceID != spaceID { + return nil, errors.New("note not found in this space") + } + if !note.IsPasswordProtected { + return dto.NewNoteDTO(note), nil + } + if strings.TrimSpace(password) == "" { + return nil, errors.New("password is required") + } + if s.passwordHasher == nil { + return nil, errors.New("password hasher unavailable") + } + matched, verifyErr := s.passwordHasher.VerifyPassword(password, note.PasswordHash) + if verifyErr != nil || !matched { + return nil, errors.New("invalid note password") + } + + now := time.Now() + note.ViewedAt = &now + _ = s.noteRepo.UpdateNote(ctx, note) + + return dto.NewNoteDTO(note), nil +} + +// UnlockPublicNote verifies a protected public note password and returns full note content. +func (s *NoteService) UnlockPublicNote(ctx context.Context, spaceID, noteID bson.ObjectID, password string) (*dto.NoteDTO, error) { + space, err := s.spaceRepo.GetSpaceByID(ctx, spaceID) + if err != nil { + return nil, errors.New("space not found") + } + if !space.IsPublic { + return nil, errors.New("space is not public") + } + + note, err := s.noteRepo.GetNoteByID(ctx, noteID) + if err != nil { + return nil, errors.New("note not found") + } + if note.SpaceID != spaceID || !note.IsPublic { + return nil, errors.New("note not found") + } + if !note.IsPasswordProtected { + return dto.NewNoteDTO(note), nil + } + if strings.TrimSpace(password) == "" { + return nil, errors.New("password is required") + } + if s.passwordHasher == nil { + return nil, errors.New("password hasher unavailable") + } + matched, verifyErr := s.passwordHasher.VerifyPassword(password, note.PasswordHash) + if verifyErr != nil || !matched { + return nil, errors.New("invalid note password") + } + + now := time.Now() + note.ViewedAt = &now + _ = s.noteRepo.UpdateNote(ctx, note) + + return dto.NewNoteDTO(note), nil +} + +// DeleteNote deletes a note +func (s *NoteService) DeleteNote(ctx context.Context, noteID, spaceID, userID bson.ObjectID) error { + // Verify user has access to space + membership, err := s.membershipRepo.GetUserMembership(ctx, userID, spaceID) + if err != nil { + return errors.New("unauthorized") + } + _ = membership + hasPermission, permErr := s.hasSpacePermission(ctx, userID, spaceID, "note.delete") + if permErr != nil { + return permErr + } + if !hasPermission { + return errors.New("insufficient permissions") + } + + note, err := s.noteRepo.GetNoteByID(ctx, noteID) + if err != nil { + return err + } + + // Verify note belongs to this space + if note.SpaceID != spaceID { + return errors.New("note not found in this space") + } + + return s.noteRepo.DeleteNote(ctx, noteID) +} + +func (s *NoteService) hasSpacePermission(ctx context.Context, userID, spaceID bson.ObjectID, action string) (bool, error) { + if s.permissionService == nil { + return false, errors.New("permission service unavailable") + } + return s.permissionService.HasSpacePermission(ctx, userID, spaceID, action) +} diff --git a/backend/internal/application/services/permission_service.go b/backend/internal/application/services/permission_service.go new file mode 100644 index 0000000..19720e7 --- /dev/null +++ b/backend/internal/application/services/permission_service.go @@ -0,0 +1,174 @@ +package services + +import ( + "context" + "errors" + "strings" + + "github.com/noteapp/backend/internal/domain/entities" + "github.com/noteapp/backend/internal/domain/repositories" + "go.mongodb.org/mongo-driver/v2/bson" +) + +const adminGroupName = "Admin" + +// PermissionService resolves and checks user permissions. +type PermissionService struct { + userRepo repositories.UserRepository + groupRepo repositories.GroupRepository + membershipRepo repositories.MembershipRepository + spaceRepo repositories.SpaceRepository +} + +// NewPermissionService creates a permission service. +func NewPermissionService( + userRepo repositories.UserRepository, + groupRepo repositories.GroupRepository, + membershipRepo repositories.MembershipRepository, + spaceRepo repositories.SpaceRepository, +) *PermissionService { + return &PermissionService{ + userRepo: userRepo, + groupRepo: groupRepo, + membershipRepo: membershipRepo, + spaceRepo: spaceRepo, + } +} + +// EnsureAdminGroup ensures the built-in Admin group exists with full wildcard access. +func (s *PermissionService) EnsureAdminGroup(ctx context.Context) error { + adminGroup, err := s.groupRepo.GetGroupByName(ctx, adminGroupName) + if err != nil { + adminGroup = &entities.PermissionGroup{ + Name: adminGroupName, + Description: "System group with full access", + Permissions: []string{"*"}, + IsSystem: true, + } + if createErr := s.groupRepo.CreateGroup(ctx, adminGroup); createErr != nil { + return createErr + } + } + + return nil +} + +// GetUserEffectivePermissions returns a deduplicated list of permissions for a user. +func (s *PermissionService) GetUserEffectivePermissions(ctx context.Context, user *entities.User) ([]string, error) { + granted := make(map[string]struct{}) + + groups, err := s.groupRepo.GetGroupsByIDs(ctx, user.GroupIDs) + if err != nil { + return nil, err + } + + for _, group := range groups { + for _, permission := range group.Permissions { + normalized := entities.NormalizePermission(permission) + if normalized != "" { + granted[normalized] = struct{}{} + } + } + } + + result := make([]string, 0, len(granted)) + for permission := range granted { + result = append(result, permission) + } + return result, nil +} + +// UpdateUserEffectivePermissions resolves and persists effective user permissions. +func (s *PermissionService) UpdateUserEffectivePermissions(ctx context.Context, user *entities.User) error { + permissions, err := s.GetUserEffectivePermissions(ctx, user) + if err != nil { + return err + } + user.Permissions = permissions + return s.userRepo.UpdateUser(ctx, user) +} + +// SetUserGroups assigns groups to a user and refreshes permissions. +func (s *PermissionService) SetUserGroups(ctx context.Context, userID bson.ObjectID, groupIDs []bson.ObjectID) (*entities.User, error) { + user, err := s.userRepo.GetUserByID(ctx, userID) + if err != nil { + return nil, err + } + + if len(groupIDs) > 0 { + groups, err := s.groupRepo.GetGroupsByIDs(ctx, groupIDs) + if err != nil { + return nil, err + } + if len(groups) != len(groupIDs) { + return nil, errors.New("one or more groups not found") + } + } + + user.GroupIDs = dedupeObjectIDs(groupIDs) + if err := s.UpdateUserEffectivePermissions(ctx, user); err != nil { + return nil, err + } + return user, nil +} + +// UserHasPermission checks if user has a concrete permission, supporting wildcards. +func (s *PermissionService) UserHasPermission(ctx context.Context, userID bson.ObjectID, permission string) (bool, error) { + user, err := s.userRepo.GetUserByID(ctx, userID) + if err != nil { + return false, err + } + return s.UserEntityHasPermission(ctx, user, permission) +} + +// UserEntityHasPermission checks permission from a loaded user entity. +func (s *PermissionService) UserEntityHasPermission(ctx context.Context, user *entities.User, permission string) (bool, error) { + permission = entities.NormalizePermission(permission) + if permission == "" { + return false, nil + } + + granted, err := s.GetUserEffectivePermissions(ctx, user) + if err != nil { + return false, err + } + + for _, pattern := range granted { + if entities.PermissionMatches(pattern, permission) { + return true, nil + } + } + return false, nil +} + +// HasSpacePermission checks a space-scoped permission action, like note.create. +func (s *PermissionService) HasSpacePermission(ctx context.Context, userID, spaceID bson.ObjectID, action string) (bool, error) { + space, err := s.spaceRepo.GetSpaceByID(ctx, spaceID) + if err != nil { + return false, err + } + + action = strings.Trim(strings.ToLower(action), ". ") + if action == "" { + return false, nil + } + + permission := "space." + entities.SpacePermissionToken(space.Name) + "." + action + return s.UserHasPermission(ctx, userID, permission) +} + +func dedupeObjectIDs(ids []bson.ObjectID) []bson.ObjectID { + seen := map[bson.ObjectID]struct{}{} + result := make([]bson.ObjectID, 0, len(ids)) + for _, id := range ids { + if id.IsZero() { + continue + } + if _, exists := seen[id]; exists { + continue + } + seen[id] = struct{}{} + result = append(result, id) + } + return result +} diff --git a/backend/internal/application/services/space_service.go b/backend/internal/application/services/space_service.go new file mode 100644 index 0000000..4f36291 --- /dev/null +++ b/backend/internal/application/services/space_service.go @@ -0,0 +1,319 @@ +package services + +import ( + "context" + "errors" + "time" + + "github.com/noteapp/backend/internal/application/dto" + "github.com/noteapp/backend/internal/domain/entities" + "github.com/noteapp/backend/internal/domain/repositories" + "go.mongodb.org/mongo-driver/v2/bson" +) + +// SpaceService handles space operations +type SpaceService struct { + spaceRepo repositories.SpaceRepository + membershipRepo repositories.MembershipRepository + noteRepo repositories.NoteRepository + categoryRepo repositories.CategoryRepository + userRepo repositories.UserRepository + permissionService *PermissionService +} + +// NewSpaceService creates a new space service +func NewSpaceService( + spaceRepo repositories.SpaceRepository, + membershipRepo repositories.MembershipRepository, + noteRepo repositories.NoteRepository, + categoryRepo repositories.CategoryRepository, + userRepo repositories.UserRepository, + permissionService *PermissionService, +) *SpaceService { + return &SpaceService{ + spaceRepo: spaceRepo, + membershipRepo: membershipRepo, + noteRepo: noteRepo, + categoryRepo: categoryRepo, + userRepo: userRepo, + permissionService: permissionService, + } +} + +// GetPublicSpace returns a single publicly accessible space +func (s *SpaceService) GetPublicSpace(ctx context.Context, spaceID bson.ObjectID) (*dto.SpaceDTO, error) { + space, err := s.spaceRepo.GetSpaceByID(ctx, spaceID) + if err != nil { + return nil, err + } + if !space.IsPublic { + return nil, errors.New("space is not public") + } + return dto.NewSpaceDTO(space), nil +} + +// GetPublicSpaces returns all publicly accessible spaces +func (s *SpaceService) GetPublicSpaces(ctx context.Context) ([]*dto.SpaceDTO, error) { + spaces, err := s.spaceRepo.GetPublicSpaces(ctx) + if err != nil { + return nil, err + } + result := make([]*dto.SpaceDTO, len(spaces)) + for i, space := range spaces { + result[i] = dto.NewSpaceDTO(space) + } + return result, nil +} + +// CreateSpace creates a new space owned by the user +func (s *SpaceService) CreateSpace(ctx context.Context, userID bson.ObjectID, req *dto.CreateSpaceRequest) (*dto.SpaceDTO, error) { + if allowed, err := s.canCreateSpace(ctx, userID); err != nil { + return nil, err + } else if !allowed { + return nil, errors.New("insufficient permissions") + } + + space := &entities.Space{ + Name: req.Name, + Description: req.Description, + Icon: req.Icon, + OwnerID: userID, + IsPublic: req.IsPublic, + } + + if err := s.spaceRepo.CreateSpace(ctx, space); err != nil { + return nil, err + } + + // Add user as initial member + membership := &entities.Membership{ + UserID: userID, + SpaceID: space.ID, + JoinedAt: time.Now(), + } + + if err := s.membershipRepo.CreateMembership(ctx, membership); err != nil { + // Delete space if membership creation fails + s.spaceRepo.DeleteSpace(ctx, space.ID) + return nil, err + } + + return dto.NewSpaceDTO(space), nil +} + +// GetUserSpaces retrieves all spaces for a user +func (s *SpaceService) GetUserSpaces(ctx context.Context, userID bson.ObjectID) ([]*dto.SpaceDTO, error) { + // Get all memberships for the user + memberships, err := s.membershipRepo.GetUserMemberships(ctx, userID) + if err != nil { + return nil, err + } + + var spaceDTOs []*dto.SpaceDTO + for _, membership := range memberships { + space, err := s.spaceRepo.GetSpaceByID(ctx, membership.SpaceID) + if err != nil { + continue // Skip spaces that can't be loaded + } + + spaceDTOs = append(spaceDTOs, dto.NewSpaceDTO(space)) + } + + return spaceDTOs, nil +} + +// GetSpaceByID gets a space by ID (with authorization check) +func (s *SpaceService) GetSpaceByID(ctx context.Context, spaceID, userID bson.ObjectID) (*dto.SpaceDTO, error) { + // Verify user has access to this space + if _, err := s.membershipRepo.GetUserMembership(ctx, userID, spaceID); err != nil { + return nil, errors.New("unauthorized") + } + + space, err := s.spaceRepo.GetSpaceByID(ctx, spaceID) + if err != nil { + return nil, err + } + + return dto.NewSpaceDTO(space), nil +} + +// UpdateSpace updates a space (owner only) +func (s *SpaceService) UpdateSpace(ctx context.Context, spaceID, userID bson.ObjectID, updates *dto.CreateSpaceRequest) (*dto.SpaceDTO, error) { + hasPermission, err := s.hasGlobalOrSpacePermission(ctx, userID, spaceID, "space.edit", "settings.edit") + if err != nil { + return nil, err + } + if !hasPermission { + return nil, errors.New("unauthorized") + } + + space, err := s.spaceRepo.GetSpaceByID(ctx, spaceID) + if err != nil { + return nil, err + } + + space.Name = updates.Name + space.Description = updates.Description + space.Icon = updates.Icon + space.IsPublic = updates.IsPublic + + if err := s.spaceRepo.UpdateSpace(ctx, space); err != nil { + return nil, err + } + + return dto.NewSpaceDTO(space), nil +} + +// DeleteSpace deletes a space (owner only) +func (s *SpaceService) DeleteSpace(ctx context.Context, spaceID, userID bson.ObjectID) error { + hasPermission, err := s.hasGlobalOrSpacePermission(ctx, userID, spaceID, "space.delete", "settings.delete") + if err != nil { + return err + } + if !hasPermission { + return errors.New("unauthorized") + } + + if err := s.noteRepo.DeleteNotesBySpaceID(ctx, spaceID); err != nil { + return err + } + if err := s.categoryRepo.DeleteCategoriesBySpaceID(ctx, spaceID); err != nil { + return err + } + if err := s.membershipRepo.DeleteMembershipsBySpaceID(ctx, spaceID); err != nil { + return err + } + return s.spaceRepo.DeleteSpace(ctx, spaceID) +} + +// AddMember adds a member to a space. +func (s *SpaceService) AddMember(ctx context.Context, spaceID, userID, targetUserID bson.ObjectID) error { + hasPermission, err := s.hasSpacePermission(ctx, userID, spaceID, "settings.member.manage") + if err != nil { + return err + } + if !hasPermission { + return errors.New("unauthorized") + } + + // Create membership for target user + newMembership := &entities.Membership{ + UserID: targetUserID, + SpaceID: spaceID, + JoinedAt: time.Now(), + InvitedBy: userID, + } + + return s.membershipRepo.CreateMembership(ctx, newMembership) +} + +// RemoveMember removes a member from a space (owner only) +func (s *SpaceService) RemoveMember(ctx context.Context, spaceID, userID, targetUserID bson.ObjectID) error { + hasPermission, err := s.hasSpacePermission(ctx, userID, spaceID, "settings.member.manage") + if err != nil { + return err + } + if !hasPermission { + return errors.New("unauthorized") + } + + // Get target membership + targetMembership, err := s.membershipRepo.GetUserMembership(ctx, targetUserID, spaceID) + if err != nil { + return err + } + + return s.membershipRepo.DeleteMembership(ctx, targetMembership.ID) +} + +// GetSpaceMembers returns all space members (owner only) +func (s *SpaceService) GetSpaceMembers(ctx context.Context, spaceID, userID bson.ObjectID) ([]*dto.SpaceMemberDTO, error) { + hasPermission, err := s.hasSpacePermission(ctx, userID, spaceID, "settings.member.view") + if err != nil { + return nil, err + } + if !hasPermission { + return nil, errors.New("unauthorized") + } + + memberships, err := s.membershipRepo.GetSpaceMembers(ctx, spaceID) + if err != nil { + return nil, err + } + + result := make([]*dto.SpaceMemberDTO, 0, len(memberships)) + for _, member := range memberships { + username := member.UserID.Hex() + if user, err := s.userRepo.GetUserByID(ctx, member.UserID); err == nil { + username = user.Username + } + result = append(result, &dto.SpaceMemberDTO{ + UserID: member.UserID.Hex(), + Username: username, + JoinedAt: member.JoinedAt.Format("2006-01-02T15:04:05Z"), + }) + } + + return result, nil +} + +// ListAvailableUsers returns all users for member selection (owner only) +func (s *SpaceService) ListAvailableUsers(ctx context.Context, spaceID, userID bson.ObjectID) ([]*dto.UserOptionDTO, error) { + hasPermission, err := s.hasSpacePermission(ctx, userID, spaceID, "settings.member.manage") + if err != nil { + return nil, err + } + if !hasPermission { + return nil, errors.New("unauthorized") + } + + users, err := s.userRepo.ListAllUsers(ctx) + if err != nil { + return nil, err + } + + result := make([]*dto.UserOptionDTO, 0, len(users)) + for _, user := range users { + result = append(result, &dto.UserOptionDTO{ + ID: user.ID.Hex(), + Username: user.Username, + }) + } + + return result, nil +} + +func (s *SpaceService) canCreateSpace(ctx context.Context, userID bson.ObjectID) (bool, error) { + if s.permissionService == nil { + return false, errors.New("permission service unavailable") + } + + hasPermission, err := s.permissionService.UserHasPermission(ctx, userID, "space.create") + if err != nil { + return false, err + } + return hasPermission, nil +} + +func (s *SpaceService) hasSpacePermission(ctx context.Context, userID, spaceID bson.ObjectID, action string) (bool, error) { + if s.permissionService == nil { + return false, nil + } + return s.permissionService.HasSpacePermission(ctx, userID, spaceID, action) +} + +func (s *SpaceService) hasGlobalOrSpacePermission(ctx context.Context, userID, spaceID bson.ObjectID, globalPermission, spaceAction string) (bool, error) { + if s.permissionService == nil { + return false, nil + } + + hasGlobalPermission, err := s.permissionService.UserHasPermission(ctx, userID, globalPermission) + if err != nil { + return false, err + } + if hasGlobalPermission { + return true, nil + } + + return s.permissionService.HasSpacePermission(ctx, userID, spaceID, spaceAction) +} diff --git a/backend/internal/domain/entities/auth.go b/backend/internal/domain/entities/auth.go new file mode 100644 index 0000000..9832e4e --- /dev/null +++ b/backend/internal/domain/entities/auth.go @@ -0,0 +1,51 @@ +package entities + +import ( + "time" + + "go.mongodb.org/mongo-driver/v2/bson" +) + +// AuthProvider represents a configured OAuth/OIDC provider +type AuthProvider struct { + ID bson.ObjectID `bson:"_id,omitempty"` + Name string `bson:"name"` + Type string `bson:"type"` // "oidc", "oauth2" + ClientID string `bson:"client_id"` + ClientSecret string `bson:"client_secret"` // Encrypted in DB + AuthorizationURL string `bson:"authorization_url"` + TokenURL string `bson:"token_url"` + UserInfoURL string `bson:"userinfo_url"` + Scopes []string `bson:"scopes"` + IDTokenClaim string `bson:"id_token_claim,omitempty"` + IsActive bool `bson:"is_active"` + CreatedAt time.Time `bson:"created_at"` + UpdatedAt time.Time `bson:"updated_at"` +} + +// LoginAttempt tracks login attempts for brute-force protection +type LoginAttempt struct { + ID bson.ObjectID `bson:"_id,omitempty"` + Email string `bson:"email"` + IPAddress string `bson:"ip_address"` + Success bool `bson:"success"` + Reason string `bson:"reason,omitempty"` + CreatedAt time.Time `bson:"created_at"` + ExpiresAt time.Time `bson:"expires_at"` +} + +// FeatureFlags controls app-wide behavior toggles. +type FeatureFlags struct { + RegistrationEnabled bool `bson:"registration_enabled"` + ProviderLoginEnabled bool `bson:"provider_login_enabled"` + PublicSharingEnabled bool `bson:"public_sharing_enabled"` +} + +// NewDefaultFeatureFlags returns safe defaults for a new deployment. +func NewDefaultFeatureFlags() *FeatureFlags { + return &FeatureFlags{ + RegistrationEnabled: true, + ProviderLoginEnabled: true, + PublicSharingEnabled: true, + } +} diff --git a/backend/internal/domain/entities/note.go b/backend/internal/domain/entities/note.go new file mode 100644 index 0000000..7574deb --- /dev/null +++ b/backend/internal/domain/entities/note.go @@ -0,0 +1,55 @@ +package entities + +import ( + "time" + + "go.mongodb.org/mongo-driver/v2/bson" +) + +// Note represents a note within a space +type Note struct { + ID bson.ObjectID `bson:"_id,omitempty"` + SpaceID bson.ObjectID `bson:"space_id"` + CategoryID *bson.ObjectID `bson:"category_id,omitempty"` + Title string `bson:"title"` + Description string `bson:"description"` + Content string `bson:"content"` + PasswordHash string `bson:"password_hash,omitempty"` + Tags []string `bson:"tags"` + IsPinned bool `bson:"is_pinned"` + IsFavorite bool `bson:"is_favorite"` + IsPublic bool `bson:"is_public"` + IsPasswordProtected bool `bson:"is_password_protected"` + CreatedBy bson.ObjectID `bson:"created_by"` + UpdatedBy bson.ObjectID `bson:"updated_by"` + CreatedAt time.Time `bson:"created_at"` + UpdatedAt time.Time `bson:"updated_at"` + ViewedAt *time.Time `bson:"viewed_at,omitempty"` +} + +// Category represents a folder/category within a space +type Category struct { + ID bson.ObjectID `bson:"_id,omitempty"` + SpaceID bson.ObjectID `bson:"space_id"` + Name string `bson:"name"` + Description string `bson:"description,omitempty"` + ParentID *bson.ObjectID `bson:"parent_id,omitempty"` + Icon string `bson:"icon,omitempty"` + Order int `bson:"order"` + CreatedBy bson.ObjectID `bson:"created_by"` + UpdatedBy bson.ObjectID `bson:"updated_by"` + CreatedAt time.Time `bson:"created_at"` + UpdatedAt time.Time `bson:"updated_at"` +} + +// NoteRevision represents a historical version of a note +type NoteRevision struct { + ID bson.ObjectID `bson:"_id,omitempty"` + NoteID bson.ObjectID `bson:"note_id"` + SpaceID bson.ObjectID `bson:"space_id"` + Title string `bson:"title"` + Content string `bson:"content"` + ChangedBy bson.ObjectID `bson:"changed_by"` + CreatedAt time.Time `bson:"created_at"` + ChangeRef string `bson:"change_ref,omitempty"` +} diff --git a/backend/internal/domain/entities/permission_group.go b/backend/internal/domain/entities/permission_group.go new file mode 100644 index 0000000..554b1aa --- /dev/null +++ b/backend/internal/domain/entities/permission_group.go @@ -0,0 +1,83 @@ +package entities + +import ( + "regexp" + "strings" + "time" + + "go.mongodb.org/mongo-driver/v2/bson" +) + +var permissionTokenSanitizer = regexp.MustCompile(`[^a-z0-9_-]+`) + +// PermissionGroup represents a named group of permissions. +type PermissionGroup struct { + ID bson.ObjectID `bson:"_id,omitempty"` + Name string `bson:"name"` + NameKey string `bson:"name_key"` + Description string `bson:"description,omitempty"` + Permissions []string `bson:"permissions"` + IsSystem bool `bson:"is_system"` + CreatedAt time.Time `bson:"created_at"` + UpdatedAt time.Time `bson:"updated_at"` +} + +// NormalizePermission lowercases and trims a permission string. +func NormalizePermission(permission string) string { + return strings.ToLower(strings.TrimSpace(permission)) +} + +// SpacePermissionToken converts a space name to a dot-safe permission token. +func SpacePermissionToken(spaceName string) string { + normalized := strings.ToLower(strings.TrimSpace(spaceName)) + normalized = strings.ReplaceAll(normalized, " ", "_") + normalized = permissionTokenSanitizer.ReplaceAllString(normalized, "_") + normalized = strings.Trim(normalized, "_") + if normalized == "" { + return "space" + } + return normalized +} + +// PermissionMatches reports whether a wildcard pattern matches a concrete permission. +func PermissionMatches(pattern, permission string) bool { + pattern = NormalizePermission(pattern) + permission = NormalizePermission(permission) + + if pattern == "" || permission == "" { + return false + } + if pattern == "*" || pattern == permission { + return true + } + if !strings.Contains(pattern, "*") { + return false + } + + parts := strings.Split(pattern, "*") + remaining := permission + + if parts[0] != "" { + if !strings.HasPrefix(remaining, parts[0]) { + return false + } + remaining = remaining[len(parts[0]):] + } + + for i := 1; i < len(parts); i++ { + part := parts[i] + if part == "" { + continue + } + idx := strings.Index(remaining, part) + if idx < 0 { + return false + } + remaining = remaining[idx+len(part):] + } + + if parts[len(parts)-1] != "" { + return strings.HasSuffix(permission, parts[len(parts)-1]) + } + return true +} diff --git a/backend/internal/domain/entities/space.go b/backend/internal/domain/entities/space.go new file mode 100644 index 0000000..78e6b77 --- /dev/null +++ b/backend/internal/domain/entities/space.go @@ -0,0 +1,41 @@ +package entities + +import ( + "time" + + "go.mongodb.org/mongo-driver/v2/bson" +) + +// Space represents a top-level container for notes and categories +type Space struct { + ID bson.ObjectID `bson:"_id,omitempty"` + Name string `bson:"name"` + Description string `bson:"description,omitempty"` + Icon string `bson:"icon,omitempty"` + OwnerID bson.ObjectID `bson:"owner_id"` + IsPublic bool `bson:"is_public"` + CreatedAt time.Time `bson:"created_at"` + UpdatedAt time.Time `bson:"updated_at"` +} + +// Membership represents a user's membership in a space +type Membership struct { + ID bson.ObjectID `bson:"_id,omitempty"` + UserID bson.ObjectID `bson:"user_id"` + SpaceID bson.ObjectID `bson:"space_id"` + JoinedAt time.Time `bson:"joined_at"` + InvitedBy bson.ObjectID `bson:"invited_by,omitempty"` + InvitedAt *time.Time `bson:"invited_at,omitempty"` +} + +// SpaceInvitation represents an invitation to join a space +type SpaceInvitation struct { + ID bson.ObjectID `bson:"_id,omitempty"` + SpaceID bson.ObjectID `bson:"space_id"` + Email string `bson:"email"` + Token string `bson:"token"` + CreatedBy bson.ObjectID `bson:"created_by"` + CreatedAt time.Time `bson:"created_at"` + ExpiresAt time.Time `bson:"expires_at"` + AcceptedAt *time.Time `bson:"accepted_at,omitempty"` +} diff --git a/backend/internal/domain/entities/user.go b/backend/internal/domain/entities/user.go new file mode 100644 index 0000000..16fdf8b --- /dev/null +++ b/backend/internal/domain/entities/user.go @@ -0,0 +1,51 @@ +package entities + +import ( + "time" + + "go.mongodb.org/mongo-driver/v2/bson" +) + +// User represents a system user +type User struct { + ID bson.ObjectID `bson:"_id,omitempty"` + Email string `bson:"email"` + Username string `bson:"username"` + PasswordHash string `bson:"password_hash"` + FirstName string `bson:"first_name"` + LastName string `bson:"last_name"` + Avatar string `bson:"avatar,omitempty"` + GroupIDs []bson.ObjectID `bson:"group_ids,omitempty"` + Permissions []string `bson:"permissions,omitempty"` + IsActive bool `bson:"is_active"` + EmailVerified bool `bson:"email_verified"` + CreatedAt time.Time `bson:"created_at"` + UpdatedAt time.Time `bson:"updated_at"` + LastLoginAt *time.Time `bson:"last_login_at,omitempty"` +} + +// UserProviderLink links external OAuth/OIDC providers to a user +type UserProviderLink struct { + ID bson.ObjectID `bson:"_id,omitempty"` + UserID bson.ObjectID `bson:"user_id"` + ProviderID bson.ObjectID `bson:"provider_id"` + ProviderUserID string `bson:"provider_user_id"` + Email string `bson:"email"` + ProfileData map[string]any `bson:"profile_data,omitempty"` + AccessToken string `bson:"access_token"` // Consider encrypting in production + RefreshToken string `bson:"refresh_token,omitempty"` + AccessTokenExp *time.Time `bson:"access_token_exp,omitempty"` + LinkedAt time.Time `bson:"linked_at"` + LastUsedAt *time.Time `bson:"last_used_at,omitempty"` +} + +// AccountRecovery represents account recovery tokens +type AccountRecovery struct { + ID bson.ObjectID `bson:"_id,omitempty"` + UserID bson.ObjectID `bson:"user_id"` + Token string `bson:"token"` + Type string `bson:"type"` // "password_reset", "email_verification" + ExpiresAt time.Time `bson:"expires_at"` + UsedAt *time.Time `bson:"used_at,omitempty"` + CreatedAt time.Time `bson:"created_at"` +} diff --git a/backend/internal/domain/repositories/additional.go b/backend/internal/domain/repositories/additional.go new file mode 100644 index 0000000..9346c2a --- /dev/null +++ b/backend/internal/domain/repositories/additional.go @@ -0,0 +1,40 @@ +package repositories + +import ( + "context" + + "github.com/noteapp/backend/internal/domain/entities" + "go.mongodb.org/mongo-driver/v2/bson" +) + +// AccountRecoveryRepository defines account recovery operations +type AccountRecoveryRepository interface { + CreateRecovery(ctx context.Context, recovery *entities.AccountRecovery) error + GetRecoveryByToken(ctx context.Context, token string) (*entities.AccountRecovery, error) + MarkRecoveryUsed(ctx context.Context, id bson.ObjectID) error +} + +// FeatureFlagRepository defines app feature-flag operations. +type FeatureFlagRepository interface { + GetFeatureFlags(ctx context.Context) (*entities.FeatureFlags, error) + UpdateFeatureFlags(ctx context.Context, flags *entities.FeatureFlags) error +} + +// Additional repository extensions +type ( + // SpaceRepository extensions + SpaceRepositoryExt interface { + SpaceRepository + } + + // MembershipRepository extensions + MembershipRepositoryExt interface { + MembershipRepository + GetUserMemberships(ctx context.Context, userID bson.ObjectID) ([]*entities.Membership, error) + } + + // NoteRepository extensions + NoteRepositoryExt interface { + NoteRepository + } +) diff --git a/backend/internal/domain/repositories/interfaces.go b/backend/internal/domain/repositories/interfaces.go new file mode 100644 index 0000000..eca8de0 --- /dev/null +++ b/backend/internal/domain/repositories/interfaces.go @@ -0,0 +1,215 @@ +package repositories + +import ( + "context" + + "github.com/noteapp/backend/internal/domain/entities" + "go.mongodb.org/mongo-driver/v2/bson" +) + +// UserRepository defines user operations +type UserRepository interface { + // CreateUser creates a new user + CreateUser(ctx context.Context, user *entities.User) error + + // GetUserByID retrieves a user by ID + GetUserByID(ctx context.Context, id bson.ObjectID) (*entities.User, error) + + // GetUserByEmail retrieves a user by email + GetUserByEmail(ctx context.Context, email string) (*entities.User, error) + + // GetUserByUsername retrieves a user by username + GetUserByUsername(ctx context.Context, username string) (*entities.User, error) + + // UpdateUser updates a user + UpdateUser(ctx context.Context, user *entities.User) error + + // DeleteUser deletes a user + DeleteUser(ctx context.Context, id bson.ObjectID) error + + // ListAllUsers retrieves all users (admin use) + ListAllUsers(ctx context.Context) ([]*entities.User, error) +} + +// GroupRepository defines permission group operations +type GroupRepository interface { + // CreateGroup creates a new permission group + CreateGroup(ctx context.Context, group *entities.PermissionGroup) error + + // GetGroupByID retrieves a group by ID + GetGroupByID(ctx context.Context, id bson.ObjectID) (*entities.PermissionGroup, error) + + // GetGroupByName retrieves a group by name + GetGroupByName(ctx context.Context, name string) (*entities.PermissionGroup, error) + + // GetGroupsByIDs retrieves groups by IDs + GetGroupsByIDs(ctx context.Context, ids []bson.ObjectID) ([]*entities.PermissionGroup, error) + + // ListGroups retrieves all groups + ListGroups(ctx context.Context) ([]*entities.PermissionGroup, error) + + // UpdateGroup updates an existing group + UpdateGroup(ctx context.Context, group *entities.PermissionGroup) error + + // DeleteGroup deletes a group + DeleteGroup(ctx context.Context, id bson.ObjectID) error +} + +// SpaceRepository defines space operations +type SpaceRepository interface { + // CreateSpace creates a new space + CreateSpace(ctx context.Context, space *entities.Space) error + + // GetSpaceByID retrieves a space by ID + GetSpaceByID(ctx context.Context, id bson.ObjectID) (*entities.Space, error) + + // GetSpacesByUserID retrieves all spaces for a user + GetSpacesByUserID(ctx context.Context, userID bson.ObjectID) ([]*entities.Space, error) + + // GetAllSpaces retrieves all spaces (admin use) + GetAllSpaces(ctx context.Context) ([]*entities.Space, error) + + // GetPublicSpaces retrieves all spaces marked as public + GetPublicSpaces(ctx context.Context) ([]*entities.Space, error) + + // UpdateSpace updates a space + UpdateSpace(ctx context.Context, space *entities.Space) error + + // DeleteSpace deletes a space + DeleteSpace(ctx context.Context, id bson.ObjectID) error +} + +// MembershipRepository defines membership operations +type MembershipRepository interface { + // CreateMembership creates a new membership + CreateMembership(ctx context.Context, membership *entities.Membership) error + + // GetMembershipByID retrieves a membership by ID + GetMembershipByID(ctx context.Context, id bson.ObjectID) (*entities.Membership, error) + + // GetUserMembership retrieves a membership for a user in a space + GetUserMembership(ctx context.Context, userID, spaceID bson.ObjectID) (*entities.Membership, error) + + // GetSpaceMembers retrieves all members in a space + GetSpaceMembers(ctx context.Context, spaceID bson.ObjectID) ([]*entities.Membership, error) + + // GetUserMemberships retrieves all memberships for a user + GetUserMemberships(ctx context.Context, userID bson.ObjectID) ([]*entities.Membership, error) + + // UpdateMembership updates a membership + UpdateMembership(ctx context.Context, membership *entities.Membership) error + + // DeleteMembership deletes a membership + DeleteMembership(ctx context.Context, id bson.ObjectID) error + + // DeleteMembershipsBySpaceID deletes all memberships for a space + DeleteMembershipsBySpaceID(ctx context.Context, spaceID bson.ObjectID) error +} + +// NoteRepository defines note operations +type NoteRepository interface { + // CreateNote creates a new note + CreateNote(ctx context.Context, note *entities.Note) error + + // GetNoteByID retrieves a note by ID + GetNoteByID(ctx context.Context, id bson.ObjectID) (*entities.Note, error) + + // GetNotesBySpaceID retrieves all notes in a space + GetNotesBySpaceID(ctx context.Context, spaceID bson.ObjectID, skip, limit int) ([]*entities.Note, error) + + // GetPublicNotesBySpaceID retrieves public notes in a space + GetPublicNotesBySpaceID(ctx context.Context, spaceID bson.ObjectID, skip, limit int) ([]*entities.Note, error) + + // GetNotesByCategory retrieves notes in a category + GetNotesByCategory(ctx context.Context, spaceID, categoryID bson.ObjectID) ([]*entities.Note, error) + + // SearchNotes performs full-text search on notes + SearchNotes(ctx context.Context, spaceID bson.ObjectID, query string) ([]*entities.Note, error) + + // UpdateNote updates a note + UpdateNote(ctx context.Context, note *entities.Note) error + + // DeleteNote deletes a note + DeleteNote(ctx context.Context, id bson.ObjectID) error + + // DeleteNotesBySpaceID deletes all notes in a space + DeleteNotesBySpaceID(ctx context.Context, spaceID bson.ObjectID) error +} + +// CategoryRepository defines category operations +type CategoryRepository interface { + // CreateCategory creates a new category + CreateCategory(ctx context.Context, category *entities.Category) error + + // GetCategoryByID retrieves a category by ID + GetCategoryByID(ctx context.Context, id bson.ObjectID) (*entities.Category, error) + + // GetCategoriesBySpaceID retrieves all categories in a space + GetCategoriesBySpaceID(ctx context.Context, spaceID bson.ObjectID) ([]*entities.Category, error) + + // GetRootCategories retrieves root level categories in a space + GetRootCategories(ctx context.Context, spaceID bson.ObjectID) ([]*entities.Category, error) + + // GetSubcategories retrieves subcategories of a category + GetSubcategories(ctx context.Context, parentID bson.ObjectID) ([]*entities.Category, error) + + // UpdateCategory updates a category + UpdateCategory(ctx context.Context, category *entities.Category) error + + // DeleteCategory deletes a category + DeleteCategory(ctx context.Context, id bson.ObjectID) error + + // DeleteCategoriesBySpaceID deletes all categories in a space + DeleteCategoriesBySpaceID(ctx context.Context, spaceID bson.ObjectID) error +} + +// AuthProviderRepository defines auth provider operations +type AuthProviderRepository interface { + // CreateProvider creates a new auth provider + CreateProvider(ctx context.Context, provider *entities.AuthProvider) error + + // GetProviderByID retrieves a provider by ID + GetProviderByID(ctx context.Context, id bson.ObjectID) (*entities.AuthProvider, error) + + // GetAllProviders retrieves all active providers + GetAllProviders(ctx context.Context) ([]*entities.AuthProvider, error) + + // UpdateProvider updates a provider + UpdateProvider(ctx context.Context, provider *entities.AuthProvider) error + + // DeleteProvider deletes a provider + DeleteProvider(ctx context.Context, id bson.ObjectID) error +} + +// UserProviderLinkRepository defines user provider link operations +type UserProviderLinkRepository interface { + // CreateLink creates a new user provider link + CreateLink(ctx context.Context, link *entities.UserProviderLink) error + + // GetLink retrieves a user provider link + GetLink(ctx context.Context, userID, providerID bson.ObjectID) (*entities.UserProviderLink, error) + + // GetLinkByProviderUserID retrieves a link by provider user ID + GetLinkByProviderUserID(ctx context.Context, providerID bson.ObjectID, providerUserID string) (*entities.UserProviderLink, error) + + // GetUserLinks retrieves all provider links for a user + GetUserLinks(ctx context.Context, userID bson.ObjectID) ([]*entities.UserProviderLink, error) + + // UpdateLink updates a provider link + UpdateLink(ctx context.Context, link *entities.UserProviderLink) error + + // DeleteLink deletes a provider link + DeleteLink(ctx context.Context, id bson.ObjectID) error +} + +// NoteRevisionRepository defines note revision operations +type NoteRevisionRepository interface { + // CreateRevision creates a new note revision + CreateRevision(ctx context.Context, revision *entities.NoteRevision) error + + // GetRevisionsByNoteID retrieves all revisions for a note + GetRevisionsByNoteID(ctx context.Context, noteID bson.ObjectID) ([]*entities.NoteRevision, error) + + // GetRevisionByID retrieves a specific revision + GetRevisionByID(ctx context.Context, id bson.ObjectID) (*entities.NoteRevision, error) +} diff --git a/backend/internal/infrastructure/auth/jwt.go b/backend/internal/infrastructure/auth/jwt.go new file mode 100644 index 0000000..975c4db --- /dev/null +++ b/backend/internal/infrastructure/auth/jwt.go @@ -0,0 +1,145 @@ +package auth + +import ( + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +// JWTManager handles JWT token creation and verification +type JWTManager struct { + secretKey string + issuer string + duration time.Duration +} + +// JWTClaims represents custom JWT claims +type JWTClaims struct { + UserID string `json:"user_id"` + Email string `json:"email"` + Username string `json:"username"` + jwt.RegisteredClaims +} + +// RefreshTokenClaims represents refresh token claims +type RefreshTokenClaims struct { + UserID string `json:"user_id"` + jwt.RegisteredClaims +} + +// NewJWTManager creates a new JWT manager +func NewJWTManager(secretKey, issuer string, duration time.Duration) *JWTManager { + return &JWTManager{ + secretKey: secretKey, + issuer: issuer, + duration: duration, + } +} + +// GenerateAccessToken generates a new access token +func (m *JWTManager) GenerateAccessToken(userID, email, username string) (string, error) { + claims := JWTClaims{ + UserID: userID, + Email: email, + Username: username, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(m.duration)), + IssuedAt: jwt.NewNumericDate(time.Now()), + Issuer: m.issuer, + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString([]byte(m.secretKey)) +} + +// GenerateRefreshToken generates a new refresh token +func (m *JWTManager) GenerateRefreshToken(userID string) (string, error) { + claims := RefreshTokenClaims{ + UserID: userID, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour * 7)), // 7 days + IssuedAt: jwt.NewNumericDate(time.Now()), + Issuer: m.issuer, + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString([]byte(m.secretKey)) +} + +// VerifyAccessToken verifies and parses an access token +func (m *JWTManager) VerifyAccessToken(tokenString string) (*JWTClaims, error) { + claims := &JWTClaims{} + token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + return []byte(m.secretKey), nil + }) + + if err != nil { + return nil, err + } + + if !token.Valid { + return nil, errors.New("invalid token") + } + + return claims, nil +} + +// VerifyRefreshToken verifies and parses a refresh token +func (m *JWTManager) VerifyRefreshToken(tokenString string) (*RefreshTokenClaims, error) { + claims := &RefreshTokenClaims{} + token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + return []byte(m.secretKey), nil + }) + + if err != nil { + return nil, err + } + + if !token.Valid { + return nil, errors.New("invalid token") + } + + return claims, nil +} + +// GenerateRandomToken generates a random token for password reset, etc. +func GenerateRandomToken(length int) (string, error) { + token := make([]byte, length) + if _, err := rand.Read(token); err != nil { + return "", err + } + return hex.EncodeToString(token), nil +} + +// GeneratePKCEChallenge generates a PKCE code challenge +func GeneratePKCEChallenge() (codeVerifier, codeChallenge string, err error) { + bytes := make([]byte, 32) + if _, err := rand.Read(bytes); err != nil { + return "", "", err + } + + codeVerifier = hex.EncodeToString(bytes) + + // For code_challenge, we'd need base64url encoding of SHA256(verifier) + // For simplicity in this example, using hex, but in production use base64url(sha256(verifier)) + codeChallenge = codeVerifier + + return +} + +// GenerateStateToken generates a state token for OAuth/OIDC flows +func GenerateStateToken() (string, error) { + return GenerateRandomToken(16) +} diff --git a/backend/internal/infrastructure/database/additional_repositories.go b/backend/internal/infrastructure/database/additional_repositories.go new file mode 100644 index 0000000..e1aebf3 --- /dev/null +++ b/backend/internal/infrastructure/database/additional_repositories.go @@ -0,0 +1,322 @@ +package database + +import ( + "context" + "errors" + "time" + + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" + + "github.com/noteapp/backend/internal/domain/entities" +) + +// AccountRecoveryRepository implements account recovery operations +type AccountRecoveryRepository struct { + collection *mongo.Collection +} + +type featureFlagSettings struct { + ID string `bson:"_id"` + Flags entities.FeatureFlags `bson:"flags"` + UpdatedAt time.Time `bson:"updated_at"` +} + +// FeatureFlagRepository implements app-wide feature flag operations. +type FeatureFlagRepository struct { + collection *mongo.Collection +} + +// NewFeatureFlagRepository creates a new feature flag repository. +func NewFeatureFlagRepository(db *mongo.Database) *FeatureFlagRepository { + return &FeatureFlagRepository{ + collection: db.Collection("app_settings"), + } +} + +// GetFeatureFlags returns persisted feature flags or defaults when not set. +func (r *FeatureFlagRepository) GetFeatureFlags(ctx context.Context) (*entities.FeatureFlags, error) { + var settings featureFlagSettings + err := r.collection.FindOne(ctx, bson.M{"_id": "feature_flags"}).Decode(&settings) + if err != nil { + if err == mongo.ErrNoDocuments { + return entities.NewDefaultFeatureFlags(), nil + } + return nil, err + } + + flags := settings.Flags + return &flags, nil +} + +// UpdateFeatureFlags persists feature flags. +func (r *FeatureFlagRepository) UpdateFeatureFlags(ctx context.Context, flags *entities.FeatureFlags) error { + if flags == nil { + flags = entities.NewDefaultFeatureFlags() + } + + now := time.Now() + _, err := r.collection.UpdateOne( + ctx, + bson.M{"_id": "feature_flags"}, + bson.M{ + "$set": bson.M{ + "flags": flags, + "updated_at": now, + }, + }, + options.UpdateOne().SetUpsert(true), + ) + return err +} + +// NewAccountRecoveryRepository creates a new recovery repository +func NewAccountRecoveryRepository(db *mongo.Database) *AccountRecoveryRepository { + return &AccountRecoveryRepository{ + collection: db.Collection("account_recovery"), + } +} + +// CreateRecovery creates a new recovery token +func (r *AccountRecoveryRepository) CreateRecovery(ctx context.Context, recovery *entities.AccountRecovery) error { + recovery.ID = bson.NewObjectID() + recovery.CreatedAt = time.Now() + + _, err := r.collection.InsertOne(ctx, recovery) + return err +} + +// GetRecoveryByToken retrieves a recovery record by token +func (r *AccountRecoveryRepository) GetRecoveryByToken(ctx context.Context, token string) (*entities.AccountRecovery, error) { + var recovery entities.AccountRecovery + err := r.collection.FindOne(ctx, bson.M{ + "token": token, + "expires_at": bson.M{"$gt": time.Now()}, + "used_at": bson.M{"$exists": false}, + }).Decode(&recovery) + + if err != nil { + if err == mongo.ErrNoDocuments { + return nil, errors.New("recovery token not found or expired") + } + return nil, err + } + + return &recovery, nil +} + +// MarkRecoveryUsed marks a recovery token as used +func (r *AccountRecoveryRepository) MarkRecoveryUsed(ctx context.Context, id bson.ObjectID) error { + now := time.Now() + _, err := r.collection.UpdateOne(ctx, bson.M{"_id": id}, bson.M{ + "$set": bson.M{"used_at": now}, + }) + return err +} + +// NoteRevisionRepository implements note revision operations +type NoteRevisionRepository struct { + collection *mongo.Collection +} + +// NewNoteRevisionRepository creates a new revision repository +func NewNoteRevisionRepository(db *mongo.Database) *NoteRevisionRepository { + return &NoteRevisionRepository{ + collection: db.Collection("note_revisions"), + } +} + +// CreateRevision creates a new note revision +func (r *NoteRevisionRepository) CreateRevision(ctx context.Context, revision *entities.NoteRevision) error { + revision.ID = bson.NewObjectID() + revision.CreatedAt = time.Now() + + _, err := r.collection.InsertOne(ctx, revision) + return err +} + +// GetRevisionsByNoteID retrieves all revisions for a note +func (r *NoteRevisionRepository) GetRevisionsByNoteID(ctx context.Context, noteID bson.ObjectID) ([]*entities.NoteRevision, error) { + var revisions []*entities.NoteRevision + + cursor, err := r.collection.Find(ctx, bson.M{"note_id": noteID}) + if err != nil { + return nil, err + } + defer cursor.Close(ctx) + + if err = cursor.All(ctx, &revisions); err != nil { + return nil, err + } + + return revisions, nil +} + +// GetRevisionByID retrieves a specific revision +func (r *NoteRevisionRepository) GetRevisionByID(ctx context.Context, id bson.ObjectID) (*entities.NoteRevision, error) { + var revision entities.NoteRevision + err := r.collection.FindOne(ctx, bson.M{"_id": id}).Decode(&revision) + + if err != nil { + if err == mongo.ErrNoDocuments { + return nil, errors.New("revision not found") + } + return nil, err + } + + return &revision, nil +} + +// AuthProviderRepository implements auth provider operations +type AuthProviderRepository struct { + collection *mongo.Collection +} + +// NewAuthProviderRepository creates a new provider repository +func NewAuthProviderRepository(db *mongo.Database) *AuthProviderRepository { + return &AuthProviderRepository{ + collection: db.Collection("auth_providers"), + } +} + +// CreateProvider creates a new provider +func (r *AuthProviderRepository) CreateProvider(ctx context.Context, provider *entities.AuthProvider) error { + provider.ID = bson.NewObjectID() + provider.CreatedAt = time.Now() + provider.UpdatedAt = time.Now() + + _, err := r.collection.InsertOne(ctx, provider) + return err +} + +// GetProviderByID retrieves a provider by ID +func (r *AuthProviderRepository) GetProviderByID(ctx context.Context, id bson.ObjectID) (*entities.AuthProvider, error) { + var provider entities.AuthProvider + err := r.collection.FindOne(ctx, bson.M{"_id": id}).Decode(&provider) + + if err != nil { + if err == mongo.ErrNoDocuments { + return nil, errors.New("provider not found") + } + return nil, err + } + + return &provider, nil +} + +// GetAllProviders retrieves all active providers +func (r *AuthProviderRepository) GetAllProviders(ctx context.Context) ([]*entities.AuthProvider, error) { + var providers []*entities.AuthProvider + + cursor, err := r.collection.Find(ctx, bson.M{"is_active": true}) + if err != nil { + return nil, err + } + defer cursor.Close(ctx) + + if err = cursor.All(ctx, &providers); err != nil { + return nil, err + } + + return providers, nil +} + +// UpdateProvider updates a provider +func (r *AuthProviderRepository) UpdateProvider(ctx context.Context, provider *entities.AuthProvider) error { + provider.UpdatedAt = time.Now() + _, err := r.collection.ReplaceOne(ctx, bson.M{"_id": provider.ID}, provider) + return err +} + +// DeleteProvider deletes a provider +func (r *AuthProviderRepository) DeleteProvider(ctx context.Context, id bson.ObjectID) error { + _, err := r.collection.DeleteOne(ctx, bson.M{"_id": id}) + return err +} + +// UserProviderLinkRepository implements user provider link operations +type UserProviderLinkRepository struct { + collection *mongo.Collection +} + +// NewUserProviderLinkRepository creates a new link repository +func NewUserProviderLinkRepository(db *mongo.Database) *UserProviderLinkRepository { + return &UserProviderLinkRepository{ + collection: db.Collection("user_provider_links"), + } +} + +// CreateLink creates a new user provider link +func (r *UserProviderLinkRepository) CreateLink(ctx context.Context, link *entities.UserProviderLink) error { + link.ID = bson.NewObjectID() + link.LinkedAt = time.Now() + + _, err := r.collection.InsertOne(ctx, link) + return err +} + +// GetLink retrieves a user provider link +func (r *UserProviderLinkRepository) GetLink(ctx context.Context, userID, providerID bson.ObjectID) (*entities.UserProviderLink, error) { + var link entities.UserProviderLink + err := r.collection.FindOne(ctx, bson.M{ + "user_id": userID, + "provider_id": providerID, + }).Decode(&link) + + if err != nil { + if err == mongo.ErrNoDocuments { + return nil, errors.New("link not found") + } + return nil, err + } + + return &link, nil +} + +// GetLinkByProviderUserID retrieves a link by provider user ID +func (r *UserProviderLinkRepository) GetLinkByProviderUserID(ctx context.Context, providerID bson.ObjectID, providerUserID string) (*entities.UserProviderLink, error) { + var link entities.UserProviderLink + err := r.collection.FindOne(ctx, bson.M{ + "provider_id": providerID, + "provider_user_id": providerUserID, + }).Decode(&link) + + if err != nil { + if err == mongo.ErrNoDocuments { + return nil, errors.New("link not found") + } + return nil, err + } + + return &link, nil +} + +// GetUserLinks retrieves all provider links for a user +func (r *UserProviderLinkRepository) GetUserLinks(ctx context.Context, userID bson.ObjectID) ([]*entities.UserProviderLink, error) { + var links []*entities.UserProviderLink + + cursor, err := r.collection.Find(ctx, bson.M{"user_id": userID}) + if err != nil { + return nil, err + } + defer cursor.Close(ctx) + + if err = cursor.All(ctx, &links); err != nil { + return nil, err + } + + return links, nil +} + +// UpdateLink updates a provider link +func (r *UserProviderLinkRepository) UpdateLink(ctx context.Context, link *entities.UserProviderLink) error { + _, err := r.collection.ReplaceOne(ctx, bson.M{"_id": link.ID}, link) + return err +} + +// DeleteLink deletes a provider link +func (r *UserProviderLinkRepository) DeleteLink(ctx context.Context, id bson.ObjectID) error { + _, err := r.collection.DeleteOne(ctx, bson.M{"_id": id}) + return err +} diff --git a/backend/internal/infrastructure/database/database.go b/backend/internal/infrastructure/database/database.go new file mode 100644 index 0000000..093397e --- /dev/null +++ b/backend/internal/infrastructure/database/database.go @@ -0,0 +1,92 @@ +package database + +import ( + "context" + + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" +) + +// Database holds all repository instances +type Database struct { + Client *mongo.Client + DB *mongo.Database + UserRepo *UserRepository + SpaceRepo *SpaceRepository + MembershipRepo *MembershipRepository + NoteRepo *NoteRepository + CategoryRepo *CategoryRepository + RevisionRepo *NoteRevisionRepository + GroupRepo *PermissionGroupRepository + ProviderRepo *AuthProviderRepository + LinkRepo *UserProviderLinkRepository + RecoveryRepo *AccountRecoveryRepository + FeatureFlagRepo *FeatureFlagRepository +} + +// NewDatabase initializes a new database connection and repositories +func NewDatabase(ctx context.Context, mongoURL string) (*Database, error) { + client, err := mongo.Connect(options.Client().ApplyURI(mongoURL)) + if err != nil { + return nil, err + } + + // Verify connection + if err = client.Ping(ctx, nil); err != nil { + return nil, err + } + + db := client.Database("noteapp") + + // Create repositories + database := &Database{ + Client: client, + DB: db, + UserRepo: NewUserRepository(db), + SpaceRepo: NewSpaceRepository(db), + MembershipRepo: NewMembershipRepository(db), + NoteRepo: NewNoteRepository(db), + CategoryRepo: NewCategoryRepository(db), + RevisionRepo: NewNoteRevisionRepository(db), + GroupRepo: NewPermissionGroupRepository(db), + ProviderRepo: NewAuthProviderRepository(db), + LinkRepo: NewUserProviderLinkRepository(db), + RecoveryRepo: NewAccountRecoveryRepository(db), + FeatureFlagRepo: NewFeatureFlagRepository(db), + } + + // Ensure all indexes are created + if err := database.EnsureIndexes(ctx); err != nil { + return nil, err + } + + return database, nil +} + +// EnsureIndexes ensures all necessary indexes are created +func (d *Database) EnsureIndexes(ctx context.Context) error { + if err := d.UserRepo.EnsureIndexes(ctx); err != nil { + return err + } + if err := d.SpaceRepo.EnsureIndexes(ctx); err != nil { + return err + } + if err := d.MembershipRepo.EnsureIndexes(ctx); err != nil { + return err + } + if err := d.NoteRepo.EnsureIndexes(ctx); err != nil { + return err + } + if err := d.CategoryRepo.EnsureIndexes(ctx); err != nil { + return err + } + if err := d.GroupRepo.EnsureIndexes(ctx); err != nil { + return err + } + return nil +} + +// Close closes the database connection +func (d *Database) Close(ctx context.Context) error { + return d.Client.Disconnect(ctx) +} diff --git a/backend/internal/infrastructure/database/group_repository.go b/backend/internal/infrastructure/database/group_repository.go new file mode 100644 index 0000000..26a1734 --- /dev/null +++ b/backend/internal/infrastructure/database/group_repository.go @@ -0,0 +1,129 @@ +package database + +import ( + "context" + "errors" + "strings" + "time" + + "github.com/noteapp/backend/internal/domain/entities" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" +) + +// PermissionGroupRepository implements permission group data access. +type PermissionGroupRepository struct { + collection *mongo.Collection +} + +// NewPermissionGroupRepository creates a new group repository. +func NewPermissionGroupRepository(db *mongo.Database) *PermissionGroupRepository { + return &PermissionGroupRepository{collection: db.Collection("permission_groups")} +} + +// CreateGroup creates a new permission group. +func (r *PermissionGroupRepository) CreateGroup(ctx context.Context, group *entities.PermissionGroup) error { + group.ID = bson.NewObjectID() + group.Name = strings.TrimSpace(group.Name) + group.NameKey = strings.ToLower(group.Name) + group.CreatedAt = time.Now() + group.UpdatedAt = time.Now() + _, err := r.collection.InsertOne(ctx, group) + return err +} + +// GetGroupByID retrieves a group by ID. +func (r *PermissionGroupRepository) GetGroupByID(ctx context.Context, id bson.ObjectID) (*entities.PermissionGroup, error) { + var group entities.PermissionGroup + err := r.collection.FindOne(ctx, bson.M{"_id": id}).Decode(&group) + if err != nil { + if err == mongo.ErrNoDocuments { + return nil, errors.New("group not found") + } + return nil, err + } + return &group, nil +} + +// GetGroupByName retrieves a group by case-insensitive name. +func (r *PermissionGroupRepository) GetGroupByName(ctx context.Context, name string) (*entities.PermissionGroup, error) { + normalized := strings.ToLower(strings.TrimSpace(name)) + var group entities.PermissionGroup + err := r.collection.FindOne(ctx, bson.M{"name_key": normalized}).Decode(&group) + if err != nil { + if err == mongo.ErrNoDocuments { + return nil, errors.New("group not found") + } + return nil, err + } + return &group, nil +} + +// GetGroupsByIDs retrieves groups by IDs. +func (r *PermissionGroupRepository) GetGroupsByIDs(ctx context.Context, ids []bson.ObjectID) ([]*entities.PermissionGroup, error) { + if len(ids) == 0 { + return []*entities.PermissionGroup{}, nil + } + + cursor, err := r.collection.Find(ctx, bson.M{"_id": bson.M{"$in": ids}}) + if err != nil { + return nil, err + } + defer cursor.Close(ctx) + + var groups []*entities.PermissionGroup + if err := cursor.All(ctx, &groups); err != nil { + return nil, err + } + return groups, nil +} + +// ListGroups retrieves all groups sorted by name. +func (r *PermissionGroupRepository) ListGroups(ctx context.Context) ([]*entities.PermissionGroup, error) { + opts := options.Find().SetSort(bson.D{{Key: "name", Value: 1}}) + cursor, err := r.collection.Find(ctx, bson.M{}, opts) + if err != nil { + return nil, err + } + defer cursor.Close(ctx) + + var groups []*entities.PermissionGroup + if err := cursor.All(ctx, &groups); err != nil { + return nil, err + } + return groups, nil +} + +// UpdateGroup updates an existing group. +func (r *PermissionGroupRepository) UpdateGroup(ctx context.Context, group *entities.PermissionGroup) error { + group.UpdatedAt = time.Now() + _, err := r.collection.UpdateOne(ctx, bson.M{"_id": group.ID}, bson.M{ + "$set": bson.M{ + "name": strings.TrimSpace(group.Name), + "name_key": strings.ToLower(strings.TrimSpace(group.Name)), + "description": group.Description, + "permissions": group.Permissions, + "is_system": group.IsSystem, + "updated_at": group.UpdatedAt, + }, + }) + return err +} + +// DeleteGroup deletes a group. +func (r *PermissionGroupRepository) DeleteGroup(ctx context.Context, id bson.ObjectID) error { + _, err := r.collection.DeleteOne(ctx, bson.M{"_id": id}) + return err +} + +// EnsureIndexes creates indexes for the permission groups collection. +func (r *PermissionGroupRepository) EnsureIndexes(ctx context.Context) error { + _, err := r.collection.Indexes().CreateMany(ctx, []mongo.IndexModel{ + { + Keys: bson.D{{Key: "name_key", Value: 1}}, + Options: options.Index().SetUnique(true), + }, + }) + return err +} diff --git a/backend/internal/infrastructure/database/note_repository.go b/backend/internal/infrastructure/database/note_repository.go new file mode 100644 index 0000000..6804aa0 --- /dev/null +++ b/backend/internal/infrastructure/database/note_repository.go @@ -0,0 +1,338 @@ +package database + +import ( + "context" + "errors" + "time" + + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" + + "github.com/noteapp/backend/internal/domain/entities" +) + +// NoteRepository implements the note repository interface +type NoteRepository struct { + collection *mongo.Collection +} + +func notePrioritySortOptions(skip, limit int) *options.FindOptionsBuilder { + return options.Find(). + SetSkip(int64(skip)). + SetLimit(int64(limit)). + SetSort(bson.D{ + {Key: "is_pinned", Value: -1}, + {Key: "is_favorite", Value: -1}, + {Key: "title", Value: 1}, + }). + SetCollation(&options.Collation{Locale: "en", Strength: 2}) +} + +// NewNoteRepository creates a new note repository +func NewNoteRepository(db *mongo.Database) *NoteRepository { + return &NoteRepository{ + collection: db.Collection("notes"), + } +} + +// CreateNote creates a new note +func (r *NoteRepository) CreateNote(ctx context.Context, note *entities.Note) error { + note.ID = bson.NewObjectID() + note.CreatedAt = time.Now() + note.UpdatedAt = time.Now() + + _, err := r.collection.InsertOne(ctx, note) + return err +} + +// GetNoteByID retrieves a note by ID +func (r *NoteRepository) GetNoteByID(ctx context.Context, id bson.ObjectID) (*entities.Note, error) { + var note entities.Note + err := r.collection.FindOne(ctx, bson.M{"_id": id}).Decode(¬e) + if err != nil { + if err == mongo.ErrNoDocuments { + return nil, errors.New("note not found") + } + return nil, err + } + return ¬e, nil +} + +// GetNotesBySpaceID retrieves all notes in a space with pagination +func (r *NoteRepository) GetNotesBySpaceID(ctx context.Context, spaceID bson.ObjectID, skip, limit int) ([]*entities.Note, error) { + var notes []*entities.Note + + opts := notePrioritySortOptions(skip, limit) + cursor, err := r.collection.Find(ctx, bson.M{"space_id": spaceID}, opts) + if err != nil { + return nil, err + } + defer cursor.Close(ctx) + + if err = cursor.All(ctx, ¬es); err != nil { + return nil, err + } + + return notes, nil +} + +// GetPublicNotesBySpaceID retrieves public notes in a space with pagination. +func (r *NoteRepository) GetPublicNotesBySpaceID(ctx context.Context, spaceID bson.ObjectID, skip, limit int) ([]*entities.Note, error) { + var notes []*entities.Note + + opts := notePrioritySortOptions(skip, limit) + cursor, err := r.collection.Find(ctx, bson.M{"space_id": spaceID, "is_public": true}, opts) + if err != nil { + return nil, err + } + defer cursor.Close(ctx) + + if err = cursor.All(ctx, ¬es); err != nil { + return nil, err + } + + return notes, nil +} + +// GetNotesByCategory retrieves notes in a category +func (r *NoteRepository) GetNotesByCategory(ctx context.Context, spaceID, categoryID bson.ObjectID) ([]*entities.Note, error) { + var notes []*entities.Note + + opts := options.Find(). + SetSort(bson.D{ + {Key: "is_pinned", Value: -1}, + {Key: "is_favorite", Value: -1}, + {Key: "title", Value: 1}, + }). + SetCollation(&options.Collation{Locale: "en", Strength: 2}) + + cursor, err := r.collection.Find(ctx, bson.M{ + "space_id": spaceID, + "category_id": categoryID, + }, opts) + if err != nil { + return nil, err + } + defer cursor.Close(ctx) + + if err = cursor.All(ctx, ¬es); err != nil { + return nil, err + } + + return notes, nil +} + +// SearchNotes performs full-text search on notes +func (r *NoteRepository) SearchNotes(ctx context.Context, spaceID bson.ObjectID, query string) ([]*entities.Note, error) { + var notes []*entities.Note + + cursor, err := r.collection.Find(ctx, bson.M{ + "space_id": spaceID, + "$text": bson.M{ + "$search": query, + }, + }) + if err != nil { + return nil, err + } + defer cursor.Close(ctx) + + if err = cursor.All(ctx, ¬es); err != nil { + return nil, err + } + + return notes, nil +} + +// UpdateNote updates a note +func (r *NoteRepository) UpdateNote(ctx context.Context, note *entities.Note) error { + note.UpdatedAt = time.Now() + _, err := r.collection.ReplaceOne(ctx, bson.M{"_id": note.ID}, note) + return err +} + +// DeleteNote deletes a note +func (r *NoteRepository) DeleteNote(ctx context.Context, id bson.ObjectID) error { + _, err := r.collection.DeleteOne(ctx, bson.M{"_id": id}) + return err +} + +// DeleteNotesBySpaceID deletes all notes in a space +func (r *NoteRepository) DeleteNotesBySpaceID(ctx context.Context, spaceID bson.ObjectID) error { + _, err := r.collection.DeleteMany(ctx, bson.M{"space_id": spaceID}) + return err +} + +// EnsureIndexes creates necessary indexes +func (r *NoteRepository) EnsureIndexes(ctx context.Context) error { + indexModel := []mongo.IndexModel{ + { + Keys: bson.D{bson.E{Key: "space_id", Value: 1}}, + }, + { + Keys: bson.D{bson.E{Key: "category_id", Value: 1}}, + }, + { + Keys: bson.D{ + bson.E{Key: "title", Value: "text"}, + bson.E{Key: "content", Value: "text"}, + bson.E{Key: "tags", Value: "text"}, + }, + }, + { + Keys: bson.D{bson.E{Key: "updated_at", Value: -1}}, + }, + { + Keys: bson.D{ + {Key: "space_id", Value: 1}, + {Key: "is_pinned", Value: -1}, + {Key: "is_favorite", Value: -1}, + {Key: "title", Value: 1}, + }, + }, + { + Keys: bson.D{ + {Key: "space_id", Value: 1}, + {Key: "is_public", Value: 1}, + {Key: "is_pinned", Value: -1}, + {Key: "is_favorite", Value: -1}, + {Key: "title", Value: 1}, + }, + }, + } + + _, err := r.collection.Indexes().CreateMany(ctx, indexModel) + return err +} + +// ========== CATEGORY REPOSITORY ========== + +// CategoryRepository implements the category repository interface +type CategoryRepository struct { + collection *mongo.Collection +} + +// NewCategoryRepository creates a new category repository +func NewCategoryRepository(db *mongo.Database) *CategoryRepository { + return &CategoryRepository{ + collection: db.Collection("categories"), + } +} + +// CreateCategory creates a new category +func (r *CategoryRepository) CreateCategory(ctx context.Context, category *entities.Category) error { + category.ID = bson.NewObjectID() + category.CreatedAt = time.Now() + category.UpdatedAt = time.Now() + + _, err := r.collection.InsertOne(ctx, category) + return err +} + +// GetCategoryByID retrieves a category by ID +func (r *CategoryRepository) GetCategoryByID(ctx context.Context, id bson.ObjectID) (*entities.Category, error) { + var category entities.Category + err := r.collection.FindOne(ctx, bson.M{"_id": id}).Decode(&category) + if err != nil { + if err == mongo.ErrNoDocuments { + return nil, errors.New("category not found") + } + return nil, err + } + return &category, nil +} + +// GetCategoriesBySpaceID retrieves all categories in a space +func (r *CategoryRepository) GetCategoriesBySpaceID(ctx context.Context, spaceID bson.ObjectID) ([]*entities.Category, error) { + var categories []*entities.Category + + opts := options.Find().SetSort(bson.M{"order": 1}) + cursor, err := r.collection.Find(ctx, bson.M{"space_id": spaceID}, opts) + if err != nil { + return nil, err + } + defer cursor.Close(ctx) + + if err = cursor.All(ctx, &categories); err != nil { + return nil, err + } + + return categories, nil +} + +// GetRootCategories retrieves root level categories in a space +func (r *CategoryRepository) GetRootCategories(ctx context.Context, spaceID bson.ObjectID) ([]*entities.Category, error) { + var categories []*entities.Category + + opts := options.Find().SetSort(bson.M{"order": 1}) + cursor, err := r.collection.Find(ctx, bson.M{ + "space_id": spaceID, + "parent_id": nil, + }, opts) + if err != nil { + return nil, err + } + defer cursor.Close(ctx) + + if err = cursor.All(ctx, &categories); err != nil { + return nil, err + } + + return categories, nil +} + +// GetSubcategories retrieves subcategories of a category +func (r *CategoryRepository) GetSubcategories(ctx context.Context, parentID bson.ObjectID) ([]*entities.Category, error) { + var categories []*entities.Category + + opts := options.Find().SetSort(bson.M{"order": 1}) + cursor, err := r.collection.Find(ctx, bson.M{"parent_id": parentID}, opts) + if err != nil { + return nil, err + } + defer cursor.Close(ctx) + + if err = cursor.All(ctx, &categories); err != nil { + return nil, err + } + + return categories, nil +} + +// UpdateCategory updates a category +func (r *CategoryRepository) UpdateCategory(ctx context.Context, category *entities.Category) error { + category.UpdatedAt = time.Now() + _, err := r.collection.ReplaceOne(ctx, bson.M{"_id": category.ID}, category) + return err +} + +// DeleteCategory deletes a category +func (r *CategoryRepository) DeleteCategory(ctx context.Context, id bson.ObjectID) error { + _, err := r.collection.DeleteOne(ctx, bson.M{"_id": id}) + return err +} + +// DeleteCategoriesBySpaceID deletes all categories in a space +func (r *CategoryRepository) DeleteCategoriesBySpaceID(ctx context.Context, spaceID bson.ObjectID) error { + _, err := r.collection.DeleteMany(ctx, bson.M{"space_id": spaceID}) + return err +} + +// EnsureIndexes creates necessary indexes +func (r *CategoryRepository) EnsureIndexes(ctx context.Context) error { + indexModel := []mongo.IndexModel{ + { + Keys: bson.D{bson.E{Key: "space_id", Value: 1}}, + }, + { + Keys: bson.D{bson.E{Key: "parent_id", Value: 1}}, + }, + { + Keys: bson.D{bson.E{Key: "order", Value: 1}}, + }, + } + + _, err := r.collection.Indexes().CreateMany(ctx, indexModel) + return err +} diff --git a/backend/internal/infrastructure/database/space_repository.go b/backend/internal/infrastructure/database/space_repository.go new file mode 100644 index 0000000..c7fdc4b --- /dev/null +++ b/backend/internal/infrastructure/database/space_repository.go @@ -0,0 +1,249 @@ +package database + +import ( + "context" + "errors" + "time" + + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" + + "github.com/noteapp/backend/internal/domain/entities" +) + +// SpaceRepository implements the space repository interface +type SpaceRepository struct { + collection *mongo.Collection +} + +// NewSpaceRepository creates a new space repository +func NewSpaceRepository(db *mongo.Database) *SpaceRepository { + return &SpaceRepository{ + collection: db.Collection("spaces"), + } +} + +// CreateSpace creates a new space +func (r *SpaceRepository) CreateSpace(ctx context.Context, space *entities.Space) error { + space.ID = bson.NewObjectID() + space.CreatedAt = time.Now() + space.UpdatedAt = time.Now() + + _, err := r.collection.InsertOne(ctx, space) + return err +} + +// GetSpaceByID retrieves a space by ID +func (r *SpaceRepository) GetSpaceByID(ctx context.Context, id bson.ObjectID) (*entities.Space, error) { + var space entities.Space + err := r.collection.FindOne(ctx, bson.M{"_id": id}).Decode(&space) + if err != nil { + if err == mongo.ErrNoDocuments { + return nil, errors.New("space not found") + } + return nil, err + } + return &space, nil +} + +// GetSpacesByUserID retrieves all spaces for a user (via memberships) +func (r *SpaceRepository) GetSpacesByUserID(ctx context.Context, userID bson.ObjectID) ([]*entities.Space, error) { + var spaces []*entities.Space + + // Query spaces where user is a member + opts := options.Find().SetSort(bson.M{"created_at": -1}) + cursor, err := r.collection.Find(ctx, bson.M{}, opts) + if err != nil { + return nil, err + } + defer cursor.Close(ctx) + + // This would typically be joined with membership collection + // For now, returning all spaces - in production, filter by membership + if err = cursor.All(ctx, &spaces); err != nil { + return nil, err + } + + return spaces, nil +} + +// UpdateSpace updates a space +func (r *SpaceRepository) UpdateSpace(ctx context.Context, space *entities.Space) error { + space.UpdatedAt = time.Now() + _, err := r.collection.ReplaceOne(ctx, bson.M{"_id": space.ID}, space) + return err +} + +// DeleteSpace deletes a space +func (r *SpaceRepository) DeleteSpace(ctx context.Context, id bson.ObjectID) error { + _, err := r.collection.DeleteOne(ctx, bson.M{"_id": id}) + return err +} + +// GetAllSpaces retrieves all spaces sorted by creation date descending +func (r *SpaceRepository) GetAllSpaces(ctx context.Context) ([]*entities.Space, error) { + opts := options.Find().SetSort(bson.D{bson.E{Key: "created_at", Value: -1}}) + cursor, err := r.collection.Find(ctx, bson.M{}, opts) + if err != nil { + return nil, err + } + defer cursor.Close(ctx) + + var spaces []*entities.Space + if err := cursor.All(ctx, &spaces); err != nil { + return nil, err + } + return spaces, nil +} + +// GetPublicSpaces retrieves all spaces marked as public +func (r *SpaceRepository) GetPublicSpaces(ctx context.Context) ([]*entities.Space, error) { + opts := options.Find().SetSort(bson.D{bson.E{Key: "created_at", Value: -1}}) + cursor, err := r.collection.Find(ctx, bson.M{"is_public": true}, opts) + if err != nil { + return nil, err + } + defer cursor.Close(ctx) + + var spaces []*entities.Space + if err := cursor.All(ctx, &spaces); err != nil { + return nil, err + } + return spaces, nil +} + +// EnsureIndexes creates necessary indexes +func (r *SpaceRepository) EnsureIndexes(ctx context.Context) error { + indexModel := []mongo.IndexModel{ + { + Keys: bson.D{bson.E{Key: "owner_id", Value: 1}}, + }, + { + Keys: bson.D{bson.E{Key: "created_at", Value: -1}}, + }, + } + + _, err := r.collection.Indexes().CreateMany(ctx, indexModel) + return err +} + +// ========== MEMBERSHIP REPOSITORY ========== + +// MembershipRepository implements the membership repository interface +type MembershipRepository struct { + collection *mongo.Collection +} + +// NewMembershipRepository creates a new membership repository +func NewMembershipRepository(db *mongo.Database) *MembershipRepository { + return &MembershipRepository{ + collection: db.Collection("memberships"), + } +} + +// CreateMembership creates a new membership +func (r *MembershipRepository) CreateMembership(ctx context.Context, membership *entities.Membership) error { + membership.ID = bson.NewObjectID() + membership.JoinedAt = time.Now() + + _, err := r.collection.InsertOne(ctx, membership) + return err +} + +// GetMembershipByID retrieves a membership by ID +func (r *MembershipRepository) GetMembershipByID(ctx context.Context, id bson.ObjectID) (*entities.Membership, error) { + var membership entities.Membership + err := r.collection.FindOne(ctx, bson.M{"_id": id}).Decode(&membership) + if err != nil { + if err == mongo.ErrNoDocuments { + return nil, errors.New("membership not found") + } + return nil, err + } + return &membership, nil +} + +// GetUserMembership retrieves a membership for a user in a space +func (r *MembershipRepository) GetUserMembership(ctx context.Context, userID, spaceID bson.ObjectID) (*entities.Membership, error) { + var membership entities.Membership + err := r.collection.FindOne(ctx, bson.M{ + "user_id": userID, + "space_id": spaceID, + }).Decode(&membership) + if err != nil { + if err == mongo.ErrNoDocuments { + return nil, errors.New("membership not found") + } + return nil, err + } + return &membership, nil +} + +// GetSpaceMembers retrieves all members in a space +func (r *MembershipRepository) GetSpaceMembers(ctx context.Context, spaceID bson.ObjectID) ([]*entities.Membership, error) { + var memberships []*entities.Membership + + cursor, err := r.collection.Find(ctx, bson.M{"space_id": spaceID}) + if err != nil { + return nil, err + } + defer cursor.Close(ctx) + + if err = cursor.All(ctx, &memberships); err != nil { + return nil, err + } + + return memberships, nil +} + +// GetUserMemberships retrieves all memberships for a user +func (r *MembershipRepository) GetUserMemberships(ctx context.Context, userID bson.ObjectID) ([]*entities.Membership, error) { + var memberships []*entities.Membership + + cursor, err := r.collection.Find(ctx, bson.M{"user_id": userID}) + if err != nil { + return nil, err + } + defer cursor.Close(ctx) + + if err = cursor.All(ctx, &memberships); err != nil { + return nil, err + } + + return memberships, nil +} + +// UpdateMembership updates a membership +func (r *MembershipRepository) UpdateMembership(ctx context.Context, membership *entities.Membership) error { + _, err := r.collection.ReplaceOne(ctx, bson.M{"_id": membership.ID}, membership) + return err +} + +// DeleteMembership deletes a membership +func (r *MembershipRepository) DeleteMembership(ctx context.Context, id bson.ObjectID) error { + _, err := r.collection.DeleteOne(ctx, bson.M{"_id": id}) + return err +} + +// DeleteMembershipsBySpaceID deletes all memberships for a space +func (r *MembershipRepository) DeleteMembershipsBySpaceID(ctx context.Context, spaceID bson.ObjectID) error { + _, err := r.collection.DeleteMany(ctx, bson.M{"space_id": spaceID}) + return err +} + +// EnsureIndexes creates necessary indexes +func (r *MembershipRepository) EnsureIndexes(ctx context.Context) error { + indexModel := []mongo.IndexModel{ + { + Keys: bson.D{bson.E{Key: "user_id", Value: 1}, bson.E{Key: "space_id", Value: 1}}, + Options: options.Index().SetUnique(true), + }, + { + Keys: bson.D{bson.E{Key: "space_id", Value: 1}}, + }, + } + + _, err := r.collection.Indexes().CreateMany(ctx, indexModel) + return err +} diff --git a/backend/internal/infrastructure/database/user_repository.go b/backend/internal/infrastructure/database/user_repository.go new file mode 100644 index 0000000..1ed368b --- /dev/null +++ b/backend/internal/infrastructure/database/user_repository.go @@ -0,0 +1,120 @@ +package database + +import ( + "context" + "errors" + "time" + + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" + + "github.com/noteapp/backend/internal/domain/entities" +) + +// UserRepository implements the user repository interface +type UserRepository struct { + collection *mongo.Collection +} + +// NewUserRepository creates a new user repository +func NewUserRepository(db *mongo.Database) *UserRepository { + return &UserRepository{ + collection: db.Collection("users"), + } +} + +// CreateUser creates a new user +func (r *UserRepository) CreateUser(ctx context.Context, user *entities.User) error { + user.ID = bson.NewObjectID() + user.CreatedAt = time.Now() + user.UpdatedAt = time.Now() + + _, err := r.collection.InsertOne(ctx, user) + return err +} + +// GetUserByID retrieves a user by ID +func (r *UserRepository) GetUserByID(ctx context.Context, id bson.ObjectID) (*entities.User, error) { + var user entities.User + err := r.collection.FindOne(ctx, bson.M{"_id": id}).Decode(&user) + if err != nil { + if err == mongo.ErrNoDocuments { + return nil, errors.New("user not found") + } + return nil, err + } + return &user, nil +} + +// GetUserByEmail retrieves a user by email +func (r *UserRepository) GetUserByEmail(ctx context.Context, email string) (*entities.User, error) { + var user entities.User + err := r.collection.FindOne(ctx, bson.M{"email": email}).Decode(&user) + if err != nil { + if err == mongo.ErrNoDocuments { + return nil, errors.New("user not found") + } + return nil, err + } + return &user, nil +} + +// GetUserByUsername retrieves a user by username +func (r *UserRepository) GetUserByUsername(ctx context.Context, username string) (*entities.User, error) { + var user entities.User + err := r.collection.FindOne(ctx, bson.M{"username": username}).Decode(&user) + if err != nil { + if err == mongo.ErrNoDocuments { + return nil, errors.New("user not found") + } + return nil, err + } + return &user, nil +} + +// UpdateUser updates a user +func (r *UserRepository) UpdateUser(ctx context.Context, user *entities.User) error { + user.UpdatedAt = time.Now() + _, err := r.collection.ReplaceOne(ctx, bson.M{"_id": user.ID}, user) + return err +} + +// DeleteUser deletes a user +func (r *UserRepository) DeleteUser(ctx context.Context, id bson.ObjectID) error { + _, err := r.collection.DeleteOne(ctx, bson.M{"_id": id}) + return err +} + +// ListAllUsers retrieves all users sorted by creation date descending +func (r *UserRepository) ListAllUsers(ctx context.Context) ([]*entities.User, error) { + opts := options.Find().SetSort(bson.D{bson.E{Key: "created_at", Value: -1}}) + cursor, err := r.collection.Find(ctx, bson.M{}, opts) + if err != nil { + return nil, err + } + defer cursor.Close(ctx) + + var users []*entities.User + if err := cursor.All(ctx, &users); err != nil { + return nil, err + } + return users, nil +} + +// EnsureIndexes creates necessary indexes for users collection +func (r *UserRepository) EnsureIndexes(ctx context.Context) error { + indexModel := []mongo.IndexModel{ + { + Keys: bson.D{bson.E{Key: "email", Value: 1}}, + Options: options.Index().SetUnique(true), + }, + { + Keys: bson.D{bson.E{Key: "username", Value: 1}}, + Options: options.Index().SetUnique(true), + }, + } + + _, err := r.collection.Indexes().CreateMany(ctx, indexModel) + return err +} diff --git a/backend/internal/infrastructure/security/encryption.go b/backend/internal/infrastructure/security/encryption.go new file mode 100644 index 0000000..f217957 --- /dev/null +++ b/backend/internal/infrastructure/security/encryption.go @@ -0,0 +1,79 @@ +package security + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/base64" + "errors" + "io" +) + +// Encryptor provides encryption/decryption for sensitive data +type Encryptor struct { + key []byte +} + +// NewEncryptor creates a new encryptor with the given key +// The key must be 32 bytes for AES-256 +func NewEncryptor(key string) (*Encryptor, error) { + if len(key) != 32 { + return nil, errors.New("encryption key must be 32 bytes (256 bits)") + } + return &Encryptor{ + key: []byte(key), + }, nil +} + +// Encrypt encrypts data using AES-256-GCM +func (e *Encryptor) Encrypt(plaintext string) (string, error) { + block, err := aes.NewCipher(e.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 data encrypted with Encrypt +func (e *Encryptor) Decrypt(ciphertext string) (string, error) { + data, err := base64.StdEncoding.DecodeString(ciphertext) + if err != nil { + return "", err + } + + block, err := aes.NewCipher(e.key) + if err != nil { + return "", err + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + + nonceSize := gcm.NonceSize() + if len(data) < nonceSize { + return "", errors.New("ciphertext too short") + } + + nonce := data[:nonceSize] + ciphertextBytes := data[nonceSize:] + plaintext, err := gcm.Open(nil, nonce, ciphertextBytes, nil) + if err != nil { + return "", err + } + + return string(plaintext), nil +} diff --git a/backend/internal/infrastructure/security/password.go b/backend/internal/infrastructure/security/password.go new file mode 100644 index 0000000..0e4e223 --- /dev/null +++ b/backend/internal/infrastructure/security/password.go @@ -0,0 +1,121 @@ +package security + +import ( + "crypto/rand" + "crypto/subtle" + "encoding/hex" + "errors" + "fmt" + "strconv" + "strings" + + "golang.org/x/crypto/argon2" + "golang.org/x/crypto/bcrypt" +) + +// PasswordHasher provides password hashing and verification +type PasswordHasher struct { + time uint32 + memory uint32 + threads uint8 + keyLen uint32 + saltLen int +} + +// NewPasswordHasher creates a new password hasher with sensible defaults +func NewPasswordHasher() *PasswordHasher { + return &PasswordHasher{ + time: 1, + memory: 64 * 1024, // 64 MB + threads: 4, + keyLen: 32, + saltLen: 16, + } +} + +// HashPassword hashes a password using Argon2id +// Returns hash in format "$argon2id$v=19$m=65536,t=1,p=4$salt$hash" +func (ph *PasswordHasher) HashPassword(password string) (string, error) { + salt := make([]byte, ph.saltLen) + if _, err := rand.Read(salt); err != nil { + return "", err + } + + hash := argon2.IDKey( + []byte(password), + salt, + ph.time, + ph.memory, + ph.threads, + ph.keyLen, + ) + + hashStr := fmt.Sprintf( + "$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s", + 19, + ph.memory, + ph.time, + ph.threads, + hex.EncodeToString(salt), + hex.EncodeToString(hash), + ) + + return hashStr, nil +} + +// VerifyPassword verifies a password against a hash +func (ph *PasswordHasher) VerifyPassword(password, hash string) (bool, error) { + // Backward compatibility: accept legacy bcrypt hashes. + if strings.HasPrefix(hash, "$2a$") || strings.HasPrefix(hash, "$2b$") || strings.HasPrefix(hash, "$2y$") { + if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)); err != nil { + return false, nil + } + return true, nil + } + + parts := strings.Split(hash, "$") + if len(parts) != 6 || parts[1] != "argon2id" { + return false, errors.New("invalid password hash format") + } + + versionPart := strings.TrimPrefix(parts[2], "v=") + version, err := strconv.Atoi(versionPart) + if err != nil || version != 19 { + return false, errors.New("invalid password hash version") + } + + var memory, timeCost uint32 + var threads uint8 + if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &timeCost, &threads); err != nil { + return false, errors.New("invalid password hash parameters") + } + + saltStr := parts[4] + hashStr := parts[5] + + salt, err := hex.DecodeString(saltStr) + if err != nil { + return false, errors.New("invalid salt in password hash") + } + + expectedHashBytes, err := hex.DecodeString(hashStr) + if err != nil { + return false, errors.New("invalid hash in password hash") + } + + // Hash the input password with the extracted parameters + computedHash := argon2.IDKey( + []byte(password), + salt, + timeCost, + memory, + threads, + uint32(len(expectedHashBytes)), + ) + + if subtle.ConstantTimeCompare(computedHash, expectedHashBytes) == 1 { + return true, nil + } + + return false, nil +} diff --git a/backend/internal/interfaces/handlers/admin_handler.go b/backend/internal/interfaces/handlers/admin_handler.go new file mode 100644 index 0000000..e0a990e --- /dev/null +++ b/backend/internal/interfaces/handlers/admin_handler.go @@ -0,0 +1,294 @@ +package handlers + +import ( + "encoding/json" + "net/http" + + "github.com/gorilla/mux" + "go.mongodb.org/mongo-driver/v2/bson" + + "github.com/noteapp/backend/internal/application/dto" + "github.com/noteapp/backend/internal/application/services" +) + +// AdminHandler handles admin-level HTTP requests +type AdminHandler struct { + adminService *services.AdminService +} + +// NewAdminHandler creates a new AdminHandler +func NewAdminHandler(adminService *services.AdminService) *AdminHandler { + return &AdminHandler{adminService: adminService} +} + +// ListUsers handles GET /admin/users +func (h *AdminHandler) ListUsers(w http.ResponseWriter, r *http.Request) { + users, err := h.adminService.ListUsers(r.Context()) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{"users": users}) +} + +// UpdateUserGroups handles PUT /admin/users/{userId}/groups +func (h *AdminHandler) UpdateUserGroups(w http.ResponseWriter, r *http.Request) { + userID, err := bson.ObjectIDFromHex(mux.Vars(r)["userId"]) + if err != nil { + http.Error(w, "invalid user id", http.StatusBadRequest) + return + } + + var req dto.UpdateUserGroupsRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } + + groupIDs := make([]bson.ObjectID, 0, len(req.GroupIDs)) + for _, groupID := range req.GroupIDs { + parsed, err := bson.ObjectIDFromHex(groupID) + if err != nil { + http.Error(w, "invalid group id", http.StatusBadRequest) + return + } + groupIDs = append(groupIDs, parsed) + } + + user, err := h.adminService.UpdateUserGroups(r.Context(), userID, groupIDs) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(user) +} + +// ListGroups handles GET /admin/groups +func (h *AdminHandler) ListGroups(w http.ResponseWriter, r *http.Request) { + groups, err := h.adminService.ListGroups(r.Context()) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{"groups": groups}) +} + +// CreateGroup handles POST /admin/groups +func (h *AdminHandler) CreateGroup(w http.ResponseWriter, r *http.Request) { + var req dto.CreatePermissionGroupRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } + + group, err := h.adminService.CreateGroup(r.Context(), &req) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(group) +} + +// UpdateGroup handles PUT /admin/groups/{groupId} +func (h *AdminHandler) UpdateGroup(w http.ResponseWriter, r *http.Request) { + groupID, err := bson.ObjectIDFromHex(mux.Vars(r)["groupId"]) + if err != nil { + http.Error(w, "invalid group id", http.StatusBadRequest) + return + } + + var req dto.UpdatePermissionGroupRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } + + group, err := h.adminService.UpdateGroup(r.Context(), groupID, &req) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(group) +} + +// ListAllSpaces handles GET /admin/spaces +func (h *AdminHandler) ListAllSpaces(w http.ResponseWriter, r *http.Request) { + spaces, err := h.adminService.ListAllSpaces(r.Context()) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{"spaces": spaces}) +} + +// UpdateSpace handles PUT /admin/spaces/{spaceId} +func (h *AdminHandler) UpdateSpace(w http.ResponseWriter, r *http.Request) { + spaceID, err := bson.ObjectIDFromHex(mux.Vars(r)["spaceId"]) + if err != nil { + http.Error(w, "invalid space id", http.StatusBadRequest) + return + } + + var req dto.CreateSpaceRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } + + space, err := h.adminService.UpdateSpace(r.Context(), spaceID, &req) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(space) +} + +// SetSpaceVisibility handles PUT /admin/spaces/{spaceId}/visibility +func (h *AdminHandler) SetSpaceVisibility(w http.ResponseWriter, r *http.Request) { + spaceID, err := bson.ObjectIDFromHex(mux.Vars(r)["spaceId"]) + if err != nil { + http.Error(w, "invalid space id", http.StatusBadRequest) + return + } + var req struct { + IsPublic bool `json:"is_public"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } + if err := h.adminService.SetSpaceVisibility(r.Context(), spaceID, req.IsPublic); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"message": "visibility updated"}) +} + +// AddSpaceMember handles POST /admin/spaces/{spaceId}/members +func (h *AdminHandler) AddSpaceMember(w http.ResponseWriter, r *http.Request) { + spaceID, err := bson.ObjectIDFromHex(mux.Vars(r)["spaceId"]) + if err != nil { + http.Error(w, "invalid space id", http.StatusBadRequest) + return + } + + var req dto.AddSpaceMemberRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } + + userID, err := bson.ObjectIDFromHex(req.UserID) + if err != nil { + http.Error(w, "invalid user id", http.StatusBadRequest) + return + } + + if err := h.adminService.AddSpaceMember(r.Context(), spaceID, userID); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]string{"message": "member added"}) +} + +// ListSpaceMembers handles GET /admin/spaces/{spaceId}/members +func (h *AdminHandler) ListSpaceMembers(w http.ResponseWriter, r *http.Request) { + spaceID, err := bson.ObjectIDFromHex(mux.Vars(r)["spaceId"]) + if err != nil { + http.Error(w, "invalid space id", http.StatusBadRequest) + return + } + + members, err := h.adminService.ListSpaceMembers(r.Context(), spaceID) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{"members": members}) +} + +// RemoveSpaceMember handles DELETE /admin/spaces/{spaceId}/members/{userId} +func (h *AdminHandler) RemoveSpaceMember(w http.ResponseWriter, r *http.Request) { + spaceID, err := bson.ObjectIDFromHex(mux.Vars(r)["spaceId"]) + if err != nil { + http.Error(w, "invalid space id", http.StatusBadRequest) + return + } + + userID, err := bson.ObjectIDFromHex(mux.Vars(r)["userId"]) + if err != nil { + http.Error(w, "invalid user id", http.StatusBadRequest) + return + } + + if err := h.adminService.RemoveSpaceMember(r.Context(), spaceID, userID); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +// DeleteSpace handles DELETE /admin/spaces/{spaceId} +func (h *AdminHandler) DeleteSpace(w http.ResponseWriter, r *http.Request) { + spaceID, err := bson.ObjectIDFromHex(mux.Vars(r)["spaceId"]) + if err != nil { + http.Error(w, "invalid space id", http.StatusBadRequest) + return + } + + if err := h.adminService.DeleteSpace(r.Context(), spaceID); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +// GetFeatureFlags handles GET /admin/feature-flags +func (h *AdminHandler) GetFeatureFlags(w http.ResponseWriter, r *http.Request) { + flags, err := h.adminService.GetFeatureFlags(r.Context()) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(flags) +} + +// UpdateFeatureFlags handles PUT /admin/feature-flags +func (h *AdminHandler) UpdateFeatureFlags(w http.ResponseWriter, r *http.Request) { + var req dto.UpdateFeatureFlagsRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } + + flags, err := h.adminService.UpdateFeatureFlags(r.Context(), &req) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(flags) +} diff --git a/backend/internal/interfaces/handlers/auth_handler.go b/backend/internal/interfaces/handlers/auth_handler.go new file mode 100644 index 0000000..2a20696 --- /dev/null +++ b/backend/internal/interfaces/handlers/auth_handler.go @@ -0,0 +1,299 @@ +package handlers + +import ( + "encoding/base64" + "encoding/json" + "net/http" + "net/url" + "os" + "strings" + + "github.com/gorilla/mux" + "github.com/noteapp/backend/internal/application/dto" + "github.com/noteapp/backend/internal/application/services" + "github.com/noteapp/backend/internal/infrastructure/auth" + "go.mongodb.org/mongo-driver/v2/bson" +) + +// AuthHandler handles authentication endpoints +type AuthHandler struct { + authService *services.AuthService +} + +// NewAuthHandler creates a new auth handler +func NewAuthHandler(authService *services.AuthService) *AuthHandler { + return &AuthHandler{ + authService: authService, + } +} + +// Register handles user registration +func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var req dto.RegisterRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + // Basic validation + if req.Email == "" || req.Password == "" || req.Username == "" { + http.Error(w, "Missing required fields", http.StatusBadRequest) + return + } + + response, err := h.authService.Register(r.Context(), &req) + if err != nil { + if strings.Contains(strings.ToLower(err.Error()), "registration is currently disabled") { + http.Error(w, err.Error(), http.StatusForbidden) + return + } + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} + +// Login handles user login +func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var req dto.LoginRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + response, err := h.authService.Login(r.Context(), &req) + if err != nil { + http.Error(w, err.Error(), http.StatusUnauthorized) + return + } + + // Set secure HTTP-only cookie for refresh token + http.SetCookie(w, &http.Cookie{ + Name: "refresh_token", + Value: response.RefreshToken, + Path: "/", + MaxAge: 7 * 24 * 60 * 60, // 7 days + HttpOnly: true, + Secure: isSecureRequest(r), + SameSite: http.SameSiteLaxMode, + }) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} + +// Logout handles user logout +func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) { + // Clear refresh token cookie + http.SetCookie(w, &http.Cookie{ + Name: "refresh_token", + Value: "", + Path: "/", + MaxAge: -1, + HttpOnly: true, + Secure: isSecureRequest(r), + }) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"message": "Logged out successfully"}) +} + +// ListProviders returns all active OAuth/OIDC providers. +func (h *AuthHandler) ListProviders(w http.ResponseWriter, r *http.Request) { + providers, err := h.authService.ListProviders(r.Context()) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{"providers": providers}) +} + +// CreateProvider stores a new OAuth/OIDC provider configuration. +func (h *AuthHandler) CreateProvider(w http.ResponseWriter, r *http.Request) { + var req dto.CreateAuthProviderRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + provider, err := h.authService.CreateProvider(r.Context(), &req) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(provider) +} + +// StartProviderLogin redirects the browser to the selected provider. +func (h *AuthHandler) StartProviderLogin(w http.ResponseWriter, r *http.Request) { + providerID, err := bson.ObjectIDFromHex(mux.Vars(r)["providerId"]) + if err != nil { + http.Error(w, "Invalid provider ID", http.StatusBadRequest) + return + } + + state, err := auth.GenerateStateToken() + if err != nil { + http.Error(w, "Failed to create OAuth state", http.StatusInternalServerError) + return + } + + http.SetCookie(w, &http.Cookie{ + Name: "oauth_state", + Value: state, + Path: "/", + MaxAge: 10 * 60, + HttpOnly: true, + Secure: isSecureRequest(r), + SameSite: http.SameSiteLaxMode, + }) + + redirectURI := buildBackendURL(r, "/api/v1/auth/providers/"+providerID.Hex()+"/callback") + authorizationURL, err := h.authService.BuildProviderAuthorizationURL(r.Context(), providerID, redirectURI, state) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + http.Redirect(w, r, authorizationURL, http.StatusFound) +} + +// CompleteProviderLogin exchanges the authorization code and redirects back to the frontend. +func (h *AuthHandler) CompleteProviderLogin(w http.ResponseWriter, r *http.Request) { + providerID, err := bson.ObjectIDFromHex(mux.Vars(r)["providerId"]) + if err != nil { + http.Error(w, "Invalid provider ID", http.StatusBadRequest) + return + } + + stateCookie, err := r.Cookie("oauth_state") + if err != nil || stateCookie.Value == "" || stateCookie.Value != r.URL.Query().Get("state") { + http.Error(w, "Invalid OAuth state", http.StatusBadRequest) + return + } + + response, err := h.authService.CompleteProviderLogin(r.Context(), providerID, r.URL.Query().Get("code"), buildBackendURL(r, "/api/v1/auth/providers/"+providerID.Hex()+"/callback")) + if err != nil { + http.Redirect(w, r, buildFrontendLoginURL("oauth_error", err.Error(), "", nil), http.StatusFound) + return + } + + http.SetCookie(w, &http.Cookie{ + Name: "oauth_state", + Value: "", + Path: "/", + MaxAge: -1, + HttpOnly: true, + Secure: isSecureRequest(r), + SameSite: http.SameSiteLaxMode, + }) + + http.SetCookie(w, &http.Cookie{ + Name: "refresh_token", + Value: response.RefreshToken, + Path: "/", + MaxAge: 7 * 24 * 60 * 60, + HttpOnly: true, + Secure: isSecureRequest(r), + SameSite: http.SameSiteLaxMode, + }) + + http.Redirect(w, r, buildFrontendLoginURL("oauth_success", "", response.AccessToken, response.User), http.StatusFound) +} + +// RefreshToken handles token refresh +func (h *AuthHandler) RefreshToken(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // Get refresh token from cookie + cookie, err := r.Cookie("refresh_token") + if err != nil { + http.Error(w, "Refresh token not found", http.StatusUnauthorized) + return + } + + accessToken, err := h.authService.RefreshAccessToken(r.Context(), cookie.Value) + if err != nil { + http.Error(w, "Invalid refresh token", http.StatusUnauthorized) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "access_token": accessToken, + "expires_in": 3600, + }) +} + +// Health check endpoint +func (h *AuthHandler) Health(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{ + "status": "healthy", + }) +} + +func isSecureRequest(r *http.Request) bool { + if r.TLS != nil { + return true + } + return strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") +} + +func buildBackendURL(r *http.Request, path string) string { + scheme := "http" + if isSecureRequest(r) { + scheme = "https" + } + return scheme + "://" + r.Host + path +} + +func buildFrontendLoginURL(status, message, accessToken string, user *dto.UserDTO) string { + frontendURL := os.Getenv("FRONTEND_URL") + if frontendURL == "" { + frontendURL = "http://localhost:5173" + } + + parsed, err := url.Parse(strings.TrimRight(frontendURL, "/") + "/login") + if err != nil { + return frontendURL + "/login" + } + + query := parsed.Query() + if status != "" { + query.Set("status", status) + } + if message != "" { + query.Set("message", message) + } + if accessToken != "" { + query.Set("access_token", accessToken) + } + if user != nil { + payload, _ := json.Marshal(user) + query.Set("user_json", string(payload)) + query.Set("user", base64.RawURLEncoding.EncodeToString(payload)) + } + parsed.RawQuery = query.Encode() + return parsed.String() +} diff --git a/backend/internal/interfaces/handlers/category_handler.go b/backend/internal/interfaces/handlers/category_handler.go new file mode 100644 index 0000000..56f2729 --- /dev/null +++ b/backend/internal/interfaces/handlers/category_handler.go @@ -0,0 +1,212 @@ +package handlers + +import ( + "encoding/json" + "net/http" + + "github.com/gorilla/mux" + "go.mongodb.org/mongo-driver/v2/bson" + + "github.com/noteapp/backend/internal/application/dto" + "github.com/noteapp/backend/internal/application/services" + "github.com/noteapp/backend/internal/interfaces/middleware" +) + +// CategoryHandler handles category endpoints +type CategoryHandler struct { + categoryService *services.CategoryService +} + +// NewCategoryHandler creates a new category handler +func NewCategoryHandler(categoryService *services.CategoryService) *CategoryHandler { + return &CategoryHandler{categoryService: categoryService} +} + +// GetCategoryTree returns the full category tree for a space +func (h *CategoryHandler) GetCategoryTree(w http.ResponseWriter, r *http.Request) { + userID, err := getUserObjectID(r) + if err != nil { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + vars := mux.Vars(r) + spaceID, err := bson.ObjectIDFromHex(vars["spaceId"]) + if err != nil { + http.Error(w, "invalid space id", http.StatusBadRequest) + return + } + + tree, err := h.categoryService.GetCategoryTree(r.Context(), spaceID, userID) + if err != nil { + http.Error(w, err.Error(), http.StatusForbidden) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(tree) +} + +// CreateCategory creates a new category in a space +func (h *CategoryHandler) CreateCategory(w http.ResponseWriter, r *http.Request) { + userID, err := getUserObjectID(r) + if err != nil { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + vars := mux.Vars(r) + spaceID, err := bson.ObjectIDFromHex(vars["spaceId"]) + if err != nil { + http.Error(w, "invalid space id", http.StatusBadRequest) + return + } + + var req dto.CreateCategoryRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } + + category, err := h.categoryService.CreateCategory(r.Context(), spaceID, userID, &req) + if err != nil { + if err.Error() == "unauthorized" { + http.Error(w, err.Error(), http.StatusForbidden) + } else { + http.Error(w, err.Error(), http.StatusBadRequest) + } + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(category) +} + +// UpdateCategory updates a category +func (h *CategoryHandler) UpdateCategory(w http.ResponseWriter, r *http.Request) { + userID, err := getUserObjectID(r) + if err != nil { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + vars := mux.Vars(r) + spaceID, err := bson.ObjectIDFromHex(vars["spaceId"]) + if err != nil { + http.Error(w, "invalid space id", http.StatusBadRequest) + return + } + categoryID, err := bson.ObjectIDFromHex(vars["categoryId"]) + if err != nil { + http.Error(w, "invalid category id", http.StatusBadRequest) + return + } + + var req dto.UpdateCategoryRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } + + category, err := h.categoryService.UpdateCategory(r.Context(), categoryID, spaceID, userID, &req) + if err != nil { + if err.Error() == "unauthorized" { + http.Error(w, err.Error(), http.StatusForbidden) + } else { + http.Error(w, err.Error(), http.StatusNotFound) + } + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(category) +} + +// DeleteCategory deletes a category +func (h *CategoryHandler) DeleteCategory(w http.ResponseWriter, r *http.Request) { + userID, err := getUserObjectID(r) + if err != nil { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + vars := mux.Vars(r) + spaceID, err := bson.ObjectIDFromHex(vars["spaceId"]) + if err != nil { + http.Error(w, "invalid space id", http.StatusBadRequest) + return + } + categoryID, err := bson.ObjectIDFromHex(vars["categoryId"]) + if err != nil { + http.Error(w, "invalid category id", http.StatusBadRequest) + return + } + + var moveNotesTo *string + if v := r.URL.Query().Get("moveNotesTo"); v != "" { + moveNotesTo = &v + } + + if err := h.categoryService.DeleteCategory(r.Context(), categoryID, spaceID, userID, moveNotesTo); err != nil { + if err.Error() == "unauthorized" { + http.Error(w, err.Error(), http.StatusForbidden) + } else { + http.Error(w, err.Error(), http.StatusNotFound) + } + return + } + + w.WriteHeader(http.StatusNoContent) +} + +// MoveCategory moves a category to a new parent +func (h *CategoryHandler) MoveCategory(w http.ResponseWriter, r *http.Request) { + userID, err := getUserObjectID(r) + if err != nil { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + vars := mux.Vars(r) + spaceID, err := bson.ObjectIDFromHex(vars["spaceId"]) + if err != nil { + http.Error(w, "invalid space id", http.StatusBadRequest) + return + } + categoryID, err := bson.ObjectIDFromHex(vars["categoryId"]) + if err != nil { + http.Error(w, "invalid category id", http.StatusBadRequest) + return + } + + var body struct { + ParentID *string `json:"parent_id"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } + + category, err := h.categoryService.MoveCategory(r.Context(), categoryID, spaceID, userID, body.ParentID) + if err != nil { + if err.Error() == "unauthorized" { + http.Error(w, err.Error(), http.StatusForbidden) + } else { + http.Error(w, err.Error(), http.StatusBadRequest) + } + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(category) +} + +// getUserObjectID extracts the user ObjectID from the request context +func getUserObjectID(r *http.Request) (bson.ObjectID, error) { + userIDStr, ok := r.Context().Value(middleware.UserIDKey).(string) + if !ok || userIDStr == "" { + return bson.NilObjectID, http.ErrNoCookie + } + return bson.ObjectIDFromHex(userIDStr) +} diff --git a/backend/internal/interfaces/handlers/note_handler.go b/backend/internal/interfaces/handlers/note_handler.go new file mode 100644 index 0000000..36f1981 --- /dev/null +++ b/backend/internal/interfaces/handlers/note_handler.go @@ -0,0 +1,266 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "strconv" + + "github.com/gorilla/mux" + "go.mongodb.org/mongo-driver/v2/bson" + + "github.com/noteapp/backend/internal/application/dto" + "github.com/noteapp/backend/internal/application/services" + "github.com/noteapp/backend/internal/interfaces/middleware" +) + +// NoteHandler handles note endpoints +type NoteHandler struct { + noteService *services.NoteService +} + +// NewNoteHandler creates a new note handler +func NewNoteHandler(noteService *services.NoteService) *NoteHandler { + return &NoteHandler{ + noteService: noteService, + } +} + +// CreateNote creates a new note +func (h *NoteHandler) CreateNote(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + spaceID := mux.Vars(r)["spaceId"] + userID, err := middleware.GetUserIDFromContext(r.Context()) + if err != nil { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + spaceObjID, _ := bson.ObjectIDFromHex(spaceID) + userObjID, _ := bson.ObjectIDFromHex(userID) + + var req dto.CreateNoteRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + note, err := h.noteService.CreateNote(r.Context(), spaceObjID, userObjID, &req) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(note) +} + +// GetNote retrieves a note +func (h *NoteHandler) GetNote(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + vars := mux.Vars(r) + spaceID := vars["spaceId"] + noteID := vars["noteId"] + + userID, err := middleware.GetUserIDFromContext(r.Context()) + if err != nil { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + spaceObjID, _ := bson.ObjectIDFromHex(spaceID) + noteObjID, _ := bson.ObjectIDFromHex(noteID) + userObjID, _ := bson.ObjectIDFromHex(userID) + + note, err := h.noteService.GetNote(r.Context(), noteObjID, spaceObjID, userObjID) + if err != nil { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(note) +} + +// GetNotesBySpace retrieves notes in a space +func (h *NoteHandler) GetNotesBySpace(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + spaceID := mux.Vars(r)["spaceId"] + userID, err := middleware.GetUserIDFromContext(r.Context()) + if err != nil { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + // Pagination + skip, _ := strconv.Atoi(r.URL.Query().Get("skip")) + limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) + if limit == 0 || limit > 100 { + limit = 20 + } + + spaceObjID, _ := bson.ObjectIDFromHex(spaceID) + userObjID, _ := bson.ObjectIDFromHex(userID) + + notes, err := h.noteService.GetNotesBySpace(r.Context(), spaceObjID, userObjID, skip, limit) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(notes) +} + +// SearchNotes performs full-text search +func (h *NoteHandler) SearchNotes(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + spaceID := mux.Vars(r)["spaceId"] + query := r.URL.Query().Get("q") + + if query == "" { + http.Error(w, "Missing search query", http.StatusBadRequest) + return + } + + userID, err := middleware.GetUserIDFromContext(r.Context()) + if err != nil { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + spaceObjID, _ := bson.ObjectIDFromHex(spaceID) + userObjID, _ := bson.ObjectIDFromHex(userID) + + notes, err := h.noteService.SearchNotes(r.Context(), spaceObjID, userObjID, query) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(notes) +} + +// UpdateNote updates a note +func (h *NoteHandler) UpdateNote(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + vars := mux.Vars(r) + spaceID := vars["spaceId"] + noteID := vars["noteId"] + + userID, err := middleware.GetUserIDFromContext(r.Context()) + if err != nil { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + var req dto.UpdateNoteRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + spaceObjID, _ := bson.ObjectIDFromHex(spaceID) + noteObjID, _ := bson.ObjectIDFromHex(noteID) + userObjID, _ := bson.ObjectIDFromHex(userID) + + note, err := h.noteService.UpdateNote(r.Context(), noteObjID, spaceObjID, userObjID, &req) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(note) +} + +// DeleteNote deletes a note +func (h *NoteHandler) DeleteNote(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + vars := mux.Vars(r) + spaceID := vars["spaceId"] + noteID := vars["noteId"] + + userID, err := middleware.GetUserIDFromContext(r.Context()) + if err != nil { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + spaceObjID, _ := bson.ObjectIDFromHex(spaceID) + noteObjID, _ := bson.ObjectIDFromHex(noteID) + userObjID, _ := bson.ObjectIDFromHex(userID) + + if err := h.noteService.DeleteNote(r.Context(), noteObjID, spaceObjID, userObjID); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +// UnlockNote verifies a note password and returns full note content +func (h *NoteHandler) UnlockNote(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + vars := mux.Vars(r) + spaceID := vars["spaceId"] + noteID := vars["noteId"] + + userID, err := middleware.GetUserIDFromContext(r.Context()) + if err != nil { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + var req dto.UnlockNoteRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + spaceObjID, _ := bson.ObjectIDFromHex(spaceID) + noteObjID, _ := bson.ObjectIDFromHex(noteID) + userObjID, _ := bson.ObjectIDFromHex(userID) + + note, err := h.noteService.UnlockNote(r.Context(), noteObjID, spaceObjID, userObjID, req.Password) + if err != nil { + if err.Error() == "invalid note password" { + http.Error(w, err.Error(), http.StatusUnauthorized) + return + } + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(note) +} diff --git a/backend/internal/interfaces/handlers/public_handler.go b/backend/internal/interfaces/handlers/public_handler.go new file mode 100644 index 0000000..c5e853e --- /dev/null +++ b/backend/internal/interfaces/handlers/public_handler.go @@ -0,0 +1,156 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "strconv" + + "github.com/gorilla/mux" + "go.mongodb.org/mongo-driver/v2/bson" + + "github.com/noteapp/backend/internal/application/dto" + "github.com/noteapp/backend/internal/application/services" +) + +// PublicHandler handles unauthenticated public read-only requests +type PublicHandler struct { + spaceService *services.SpaceService + noteService *services.NoteService +} + +// NewPublicHandler creates a new PublicHandler +func NewPublicHandler(spaceService *services.SpaceService, noteService *services.NoteService) *PublicHandler { + return &PublicHandler{spaceService: spaceService, noteService: noteService} +} + +// GetPublicSpace handles GET /public/spaces/{spaceId} +func (h *PublicHandler) GetPublicSpace(w http.ResponseWriter, r *http.Request) { + spaceID, err := bson.ObjectIDFromHex(mux.Vars(r)["spaceId"]) + if err != nil { + http.Error(w, "invalid space id", http.StatusBadRequest) + return + } + space, err := h.spaceService.GetPublicSpace(r.Context(), spaceID) + if err != nil { + if err.Error() == "space is not public" { + http.Error(w, "not found", http.StatusNotFound) + return + } + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(space) +} + +// ListPublicSpaces handles GET /public/spaces +func (h *PublicHandler) ListPublicSpaces(w http.ResponseWriter, r *http.Request) { + spaces, err := h.spaceService.GetPublicSpaces(r.Context()) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{"spaces": spaces}) +} + +// GetPublicNotes handles GET /public/spaces/{spaceId}/notes +func (h *PublicHandler) GetPublicNotes(w http.ResponseWriter, r *http.Request) { + spaceID, err := bson.ObjectIDFromHex(mux.Vars(r)["spaceId"]) + if err != nil { + http.Error(w, "invalid space id", http.StatusBadRequest) + return + } + + skip := 0 + limit := 50 + if v := r.URL.Query().Get("skip"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n >= 0 { + skip = n + } + } + if v := r.URL.Query().Get("limit"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 100 { + limit = n + } + } + + notes, err := h.noteService.GetPublicNotesBySpace(r.Context(), spaceID, skip, limit) + if err != nil { + if err.Error() == "space is not public" { + http.Error(w, "not found", http.StatusNotFound) + return + } + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{"notes": notes}) +} + +// GetPublicNote handles GET /public/spaces/{spaceId}/notes/{noteId} +func (h *PublicHandler) GetPublicNote(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + spaceID, err := bson.ObjectIDFromHex(vars["spaceId"]) + if err != nil { + http.Error(w, "invalid space id", http.StatusBadRequest) + return + } + noteID, err := bson.ObjectIDFromHex(vars["noteId"]) + if err != nil { + http.Error(w, "invalid note id", http.StatusBadRequest) + return + } + + note, err := h.noteService.GetPublicNoteBySpaceAndID(r.Context(), spaceID, noteID) + if err != nil { + if err.Error() == "space is not public" || err.Error() == "note is not public" || err.Error() == "space not found" || err.Error() == "note not found" { + http.Error(w, "not found", http.StatusNotFound) + return + } + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(note) +} + +// UnlockPublicNote verifies a public note password and returns full note content +func (h *PublicHandler) UnlockPublicNote(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + spaceID, err := bson.ObjectIDFromHex(vars["spaceId"]) + if err != nil { + http.Error(w, "invalid space id", http.StatusBadRequest) + return + } + noteID, err := bson.ObjectIDFromHex(vars["noteId"]) + if err != nil { + http.Error(w, "invalid note id", http.StatusBadRequest) + return + } + + var req dto.UnlockNoteRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } + + note, err := h.noteService.UnlockPublicNote(r.Context(), spaceID, noteID, req.Password) + if err != nil { + if err.Error() == "invalid note password" { + http.Error(w, err.Error(), http.StatusUnauthorized) + return + } + if err.Error() == "space is not public" || err.Error() == "space not found" || err.Error() == "note not found" { + http.Error(w, "not found", http.StatusNotFound) + return + } + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(note) +} diff --git a/backend/internal/interfaces/handlers/settings_handler.go b/backend/internal/interfaces/handlers/settings_handler.go new file mode 100644 index 0000000..bc3f0e8 --- /dev/null +++ b/backend/internal/interfaces/handlers/settings_handler.go @@ -0,0 +1,30 @@ +package handlers + +import ( + "encoding/json" + "net/http" + + "github.com/noteapp/backend/internal/application/services" +) + +// SettingsHandler handles public app settings endpoints. +type SettingsHandler struct { + authService *services.AuthService +} + +// NewSettingsHandler creates a new settings handler. +func NewSettingsHandler(authService *services.AuthService) *SettingsHandler { + return &SettingsHandler{authService: authService} +} + +// GetFeatureFlags handles GET /api/v1/settings/feature-flags. +func (h *SettingsHandler) GetFeatureFlags(w http.ResponseWriter, r *http.Request) { + flags, err := h.authService.GetFeatureFlags(r.Context()) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(flags) +} diff --git a/backend/internal/interfaces/handlers/space_handler.go b/backend/internal/interfaces/handlers/space_handler.go new file mode 100644 index 0000000..dff5489 --- /dev/null +++ b/backend/internal/interfaces/handlers/space_handler.go @@ -0,0 +1,295 @@ +package handlers + +import ( + "encoding/json" + "net/http" + + "github.com/gorilla/mux" + "github.com/noteapp/backend/internal/application/dto" + "github.com/noteapp/backend/internal/application/services" + "github.com/noteapp/backend/internal/interfaces/middleware" + "go.mongodb.org/mongo-driver/v2/bson" +) + +// SpaceHandler handles space endpoints +type SpaceHandler struct { + spaceService *services.SpaceService +} + +// NewSpaceHandler creates a new space handler +func NewSpaceHandler(spaceService *services.SpaceService) *SpaceHandler { + return &SpaceHandler{ + spaceService: spaceService, + } +} + +// CreateSpace creates a new space +func (h *SpaceHandler) CreateSpace(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + userID, err := middleware.GetUserIDFromContext(r.Context()) + if err != nil { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + var req dto.CreateSpaceRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + userObjID, _ := bson.ObjectIDFromHex(userID) + space, err := h.spaceService.CreateSpace(r.Context(), userObjID, &req) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(space) +} + +// GetUserSpaces retrieves all spaces for the user +func (h *SpaceHandler) GetUserSpaces(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + userID, err := middleware.GetUserIDFromContext(r.Context()) + if err != nil { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + userObjID, _ := bson.ObjectIDFromHex(userID) + spaces, err := h.spaceService.GetUserSpaces(r.Context(), userObjID) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(spaces) +} + +// GetSpace retrieves a space +func (h *SpaceHandler) GetSpace(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + spaceID := mux.Vars(r)["spaceId"] + userID, err := middleware.GetUserIDFromContext(r.Context()) + if err != nil { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + spaceObjID, _ := bson.ObjectIDFromHex(spaceID) + userObjID, _ := bson.ObjectIDFromHex(userID) + + space, err := h.spaceService.GetSpaceByID(r.Context(), spaceObjID, userObjID) + if err != nil { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(space) +} + +// UpdateSpace updates a space +func (h *SpaceHandler) UpdateSpace(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + spaceID := mux.Vars(r)["spaceId"] + userID, err := middleware.GetUserIDFromContext(r.Context()) + if err != nil { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + var req dto.CreateSpaceRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + spaceObjID, _ := bson.ObjectIDFromHex(spaceID) + userObjID, _ := bson.ObjectIDFromHex(userID) + + space, err := h.spaceService.UpdateSpace(r.Context(), spaceObjID, userObjID, &req) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(space) +} + +// DeleteSpace deletes a space +func (h *SpaceHandler) DeleteSpace(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + spaceID := mux.Vars(r)["spaceId"] + userID, err := middleware.GetUserIDFromContext(r.Context()) + if err != nil { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + spaceObjID, _ := bson.ObjectIDFromHex(spaceID) + userObjID, _ := bson.ObjectIDFromHex(userID) + + if err := h.spaceService.DeleteSpace(r.Context(), spaceObjID, userObjID); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +// GetSpaceMembers retrieves all members in a space (owner only) +func (h *SpaceHandler) GetSpaceMembers(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + spaceID := mux.Vars(r)["spaceId"] + userID, err := middleware.GetUserIDFromContext(r.Context()) + if err != nil { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + spaceObjID, _ := bson.ObjectIDFromHex(spaceID) + userObjID, _ := bson.ObjectIDFromHex(userID) + + members, err := h.spaceService.GetSpaceMembers(r.Context(), spaceObjID, userObjID) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{"members": members}) +} + +// AddMember adds a member to a space (owner/editor) +func (h *SpaceHandler) AddMember(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + spaceID := mux.Vars(r)["spaceId"] + userID, err := middleware.GetUserIDFromContext(r.Context()) + if err != nil { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + var req dto.AddSpaceMemberRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + spaceObjID, _ := bson.ObjectIDFromHex(spaceID) + userObjID, _ := bson.ObjectIDFromHex(userID) + targetUserObjID, err := bson.ObjectIDFromHex(req.UserID) + if err != nil { + http.Error(w, "Invalid user id", http.StatusBadRequest) + return + } + + if err := h.spaceService.AddMember(r.Context(), spaceObjID, userObjID, targetUserObjID); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]string{"message": "member added"}) +} + +// RemoveMember removes a member from a space (owner/editor) +func (h *SpaceHandler) RemoveMember(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + spaceID := mux.Vars(r)["spaceId"] + targetUserID := mux.Vars(r)["userId"] + userID, err := middleware.GetUserIDFromContext(r.Context()) + if err != nil { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + spaceObjID, err := bson.ObjectIDFromHex(spaceID) + if err != nil { + http.Error(w, "Invalid space id", http.StatusBadRequest) + return + } + userObjID, err := bson.ObjectIDFromHex(userID) + if err != nil { + http.Error(w, "Invalid user id", http.StatusBadRequest) + return + } + targetUserObjID, err := bson.ObjectIDFromHex(targetUserID) + if err != nil { + http.Error(w, "Invalid target user id", http.StatusBadRequest) + return + } + + if err := h.spaceService.RemoveMember(r.Context(), spaceObjID, userObjID, targetUserObjID); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +// GetAvailableUsers returns user options for member selection (owner only) +func (h *SpaceHandler) GetAvailableUsers(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + spaceID := mux.Vars(r)["spaceId"] + userID, err := middleware.GetUserIDFromContext(r.Context()) + if err != nil { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + spaceObjID, _ := bson.ObjectIDFromHex(spaceID) + userObjID, _ := bson.ObjectIDFromHex(userID) + + users, err := h.spaceService.ListAvailableUsers(r.Context(), spaceObjID, userObjID) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{"users": users}) +} diff --git a/backend/internal/interfaces/middleware/auth.go b/backend/internal/interfaces/middleware/auth.go new file mode 100644 index 0000000..93e22c0 --- /dev/null +++ b/backend/internal/interfaces/middleware/auth.go @@ -0,0 +1,91 @@ +package middleware + +import ( + "context" + "errors" + "net/http" + "strings" + + "github.com/noteapp/backend/internal/infrastructure/auth" +) + +// ContextKey is a custom type for context keys +type ContextKey string + +const ( + UserIDKey ContextKey = "user_id" + EmailKey ContextKey = "email" + UserKey ContextKey = "user" +) + +// AuthMiddleware verifies JWT tokens +type AuthMiddleware struct { + jwtManager *auth.JWTManager +} + +// NewAuthMiddleware creates a new auth middleware +func NewAuthMiddleware(jwtManager *auth.JWTManager) *AuthMiddleware { + return &AuthMiddleware{ + jwtManager: jwtManager, + } +} + +// Middleware wraps an HTTP handler with authentication +func (m *AuthMiddleware) Middleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Skip auth for login and register endpoints + if strings.HasSuffix(r.URL.Path, "/auth/login") || + strings.HasSuffix(r.URL.Path, "/auth/register") || + strings.HasSuffix(r.URL.Path, "/health") { + next.ServeHTTP(w, r) + return + } + + // Extract token from Authorization header + authHeader := r.Header.Get("Authorization") + if authHeader == "" { + http.Error(w, "Missing authorization header", http.StatusUnauthorized) + return + } + + parts := strings.SplitN(authHeader, " ", 2) + if len(parts) != 2 || parts[0] != "Bearer" { + http.Error(w, "Invalid authorization header format", http.StatusUnauthorized) + return + } + + token := parts[1] + + // Verify token + claims, err := m.jwtManager.VerifyAccessToken(token) + if err != nil { + http.Error(w, "Invalid token: "+err.Error(), http.StatusUnauthorized) + return + } + + // Add claims to context + ctx := context.WithValue(r.Context(), UserIDKey, claims.UserID) + ctx = context.WithValue(ctx, EmailKey, claims.Email) + + r = r.WithContext(ctx) + next.ServeHTTP(w, r) + }) +} + +// GetUserIDFromContext extracts user ID from context +func GetUserIDFromContext(ctx context.Context) (string, error) { + userID, ok := ctx.Value(UserIDKey).(string) + if !ok { + return "", errors.New("user ID not found in context") + } + return userID, nil +} + +// GetEmailFromContext extracts email from context +func GetEmailFromContext(ctx context.Context) (string, error) { + email, ok := ctx.Value(EmailKey).(string) + if !ok { + return "", errors.New("email not found in context") + } + return email, nil +} diff --git a/backend/internal/interfaces/middleware/security.go b/backend/internal/interfaces/middleware/security.go new file mode 100644 index 0000000..8ef9e66 --- /dev/null +++ b/backend/internal/interfaces/middleware/security.go @@ -0,0 +1,91 @@ +package middleware + +import ( + "fmt" + "net/http" + "strings" +) + +// SecurityHeaders adds security headers to responses +func SecurityHeaders(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // HSTS - HTTP Strict Transport Security + w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains") + + // CSRF Protection - same-site cookies + w.Header().Set("X-Content-Type-Options", "nosniff") + + // XSS Protection + w.Header().Set("X-XSS-Protection", "1; mode=block") + + // Clickjacking protection + w.Header().Set("X-Frame-Options", "DENY") + + // CSP - Content Security Policy + w.Header().Set("Content-Security-Policy", + "default-src 'self'; "+ + "script-src 'self' 'unsafe-inline'; "+ + "style-src 'self' 'unsafe-inline'; "+ + "img-src 'self' data: https:; "+ + "font-src 'self'; "+ + "connect-src 'self'; "+ + "frame-ancestors 'none'") + + // Referrer Policy + w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin") + + next.ServeHTTP(w, r) + }) +} + +// RateLimitMiddleware implements basic rate limiting +type RateLimitMiddleware struct { + // In production, use a proper rate limiter like github.com/go-chi/chi/middleware + // This is a placeholder for demonstration +} + +// NewRateLimitMiddleware creates a new rate limit middleware +func NewRateLimitMiddleware() *RateLimitMiddleware { + return &RateLimitMiddleware{} +} + +// Middleware returns the rate limit middleware handler +func (m *RateLimitMiddleware) Middleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // TODO: Implement proper rate limiting using distributed cache + // For now, this is a placeholder + next.ServeHTTP(w, r) + }) +} + +// LoggingMiddleware logs HTTP requests +func LoggingMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Printf("[%s] %s %s\n", r.Method, r.RequestURI, r.RemoteAddr) + next.ServeHTTP(w, r) + }) +} + +// CORSMiddleware enables CORS +func CORSMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + origin := r.Header.Get("Origin") + switch { + case origin == "http://localhost", origin == "http://localhost:5173", strings.HasPrefix(origin, "http://127.0.0.1:"): + w.Header().Set("Access-Control-Allow-Origin", origin) + w.Header().Set("Vary", "Origin") + default: + w.Header().Set("Access-Control-Allow-Origin", "http://localhost") + } + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS, PATCH") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With") + w.Header().Set("Access-Control-Max-Age", "600") + + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + + next.ServeHTTP(w, r) + }) +} diff --git a/backend/tests/integration/integration_test.go b/backend/tests/integration/integration_test.go new file mode 100644 index 0000000..c05cd30 --- /dev/null +++ b/backend/tests/integration/integration_test.go @@ -0,0 +1,55 @@ +package main + +import ( + "context" + "fmt" + "log" + "net/http" + "testing" + "time" + + "github.com/noteapp/backend/internal/infrastructure/database" +) + +// TestDatabaseConnection tests MongoDB connection +func TestDatabaseConnection(t *testing.T) { + mongoURL := "mongodb://admin:password@localhost:27017/noteapp?authSource=admin" + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + db, err := database.NewDatabase(ctx, mongoURL) + if err != nil { + t.Fatalf("Failed to connect to database: %v", err) + } + defer db.Close(ctx) + + t.Log("โœ“ Successfully connected to MongoDB") +} + +// TestAPIHealth tests the health check endpoint +func TestAPIHealth(t *testing.T) { + resp, err := http.Get("http://localhost:8080/health") + if err != nil { + t.Skipf("Skipping test - server not running: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("Expected status 200, got %d", resp.StatusCode) + } + + t.Log("โœ“ Health check passed") +} + +// TestAuthenticationFlow does an integration test of auth flow +func TestAuthenticationFlow(t *testing.T) { + log.Println("Integration test: Authentication flow") + log.Println("1. Register new user") + log.Println("2. Login with credentials") + log.Println("3. Use access token to access protected endpoint") + log.Println("4. Refresh access token") + log.Println("5. Logout") + + fmt.Println("\nTo run: cd backend && go test ./tests/integration/...") +} diff --git a/backend/tests/unit/auth_service_test.go b/backend/tests/unit/auth_service_test.go new file mode 100644 index 0000000..f810488 --- /dev/null +++ b/backend/tests/unit/auth_service_test.go @@ -0,0 +1,185 @@ +package unit + +import ( + "context" + "testing" + + "github.com/noteapp/backend/internal/application/dto" + "github.com/noteapp/backend/internal/application/services" + "github.com/noteapp/backend/internal/domain/entities" + "github.com/noteapp/backend/internal/infrastructure/auth" + "github.com/noteapp/backend/internal/infrastructure/security" + "go.mongodb.org/mongo-driver/v2/bson" +) + +// MockUserRepository is a mock for testing +type MockUserRepository struct { + users map[string]*entities.User +} + +func NewMockUserRepository() *MockUserRepository { + return &MockUserRepository{ + users: make(map[string]*entities.User), + } +} + +func (m *MockUserRepository) CreateUser(ctx context.Context, user *entities.User) error { + m.users[user.Email] = user + return nil +} + +func (m *MockUserRepository) GetUserByID(ctx context.Context, id bson.ObjectID) (*entities.User, error) { + for _, u := range m.users { + if u.Email != "" { + return u, nil + } + } + return nil, nil +} + +func (m *MockUserRepository) GetUserByEmail(ctx context.Context, email string) (*entities.User, error) { + if user, ok := m.users[email]; ok { + return user, nil + } + return nil, nil +} + +func (m *MockUserRepository) GetUserByUsername(ctx context.Context, username string) (*entities.User, error) { + // Simplified mock + return nil, nil +} + +func (m *MockUserRepository) UpdateUser(ctx context.Context, user *entities.User) error { + return nil +} + +func (m *MockUserRepository) DeleteUser(ctx context.Context, id bson.ObjectID) error { + return nil +} + +func (m *MockUserRepository) ListAllUsers(ctx context.Context) ([]*entities.User, error) { + users := make([]*entities.User, 0, len(m.users)) + for _, user := range m.users { + users = append(users, user) + } + return users, nil +} + +// TestRegisterUser tests user registration +func TestRegisterUser(t *testing.T) { + mockRepo := NewMockUserRepository() + jwtManager := auth.NewJWTManager("test-secret-key", "noteapp", 0) + passHasher := security.NewPasswordHasher() + encryptor, _ := security.NewEncryptor("00000000000000000000000000000000") + + authService := services.NewAuthService( + mockRepo, + nil, + nil, + nil, + nil, + nil, + nil, + jwtManager, + passHasher, + encryptor, + ) + + req := &dto.RegisterRequest{ + Email: "test@example.com", + Username: "testuser", + Password: "SecurePassword123", + PasswordConfirm: "SecurePassword123", + FirstName: "Test", + LastName: "User", + } + + response, err := authService.Register(context.Background(), req) + + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + if response == nil { + t.Fatal("Expected response, got nil") + } + + if response.AccessToken == "" { + t.Fatal("Expected access token") + } + + if response.User.Email != req.Email { + t.Fatalf("Expected email %s, got %s", req.Email, response.User.Email) + } +} + +// TestPasswordHashing tests password hashing and verification +func TestPasswordHashing(t *testing.T) { + hasher := security.NewPasswordHasher() + password := "MySecurePassword123" + + // Hash password + hash, err := hasher.HashPassword(password) + if err != nil { + t.Fatalf("Failed to hash password: %v", err) + } + + // Verify correct password + valid, err := hasher.VerifyPassword(password, hash) + if err != nil || !valid { + t.Fatal("Expected password verification to succeed") + } + + // Verify wrong password + valid, err = hasher.VerifyPassword("WrongPassword", hash) + if err == nil || valid { + t.Fatal("Expected password verification to fail") + } +} + +// TestJWTGeneration tests JWT token generation and verification +func TestJWTGeneration(t *testing.T) { + jwtManager := auth.NewJWTManager("test-secret-key", "noteapp", 0) + + token, err := jwtManager.GenerateAccessToken("user123", "user@example.com", "testuser") + if err != nil { + t.Fatalf("Failed to generate token: %v", err) + } + + claims, err := jwtManager.VerifyAccessToken(token) + if err != nil { + t.Fatalf("Failed to verify token: %v", err) + } + + if claims.UserID != "user123" { + t.Fatalf("Expected user_id user123, got %s", claims.UserID) + } + + if claims.Email != "user@example.com" { + t.Fatalf("Expected email user@example.com, got %s", claims.Email) + } +} + +// TestEncryption tests encryption and decryption +func TestEncryption(t *testing.T) { + encryptor, err := security.NewEncryptor("00000000000000000000000000000000") + if err != nil { + t.Fatalf("Failed to create encryptor: %v", err) + } + + plaintext := "sensitive-data-to-encrypt" + + encrypted, err := encryptor.Encrypt(plaintext) + if err != nil { + t.Fatalf("Failed to encrypt: %v", err) + } + + decrypted, err := encryptor.Decrypt(encrypted) + if err != nil { + t.Fatalf("Failed to decrypt: %v", err) + } + + if decrypted != plaintext { + t.Fatalf("Expected %s, got %s", plaintext, decrypted) + } +} diff --git a/devops/docker/Dockerfile b/devops/docker/Dockerfile new file mode 100644 index 0000000..5140236 --- /dev/null +++ b/devops/docker/Dockerfile @@ -0,0 +1,42 @@ +# Frontend build stage +FROM node:25-alpine AS frontend-builder + +WORKDIR /frontend + +ARG VITE_API_BASE_URL +ENV VITE_API_BASE_URL=${VITE_API_BASE_URL} + +COPY frontend/package*.json ./ +RUN npm install + +COPY frontend/ . +RUN npm run build + +# Backend build stage +FROM golang:1.26-alpine AS backend-builder + +WORKDIR /app + +RUN apk add --no-cache git + +COPY backend/go.mod backend/go.sum ./ +RUN go mod download + +COPY backend/ . + +# Build the binary +RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o server ./cmd/server/main.go + +# Final stage +FROM alpine:latest + +RUN apk --no-cache add ca-certificates + +WORKDIR /root/ + +COPY --from=backend-builder /app/server . +COPY --from=frontend-builder /frontend/dist ./public + +EXPOSE 8080 + +CMD ["./server"] diff --git a/devops/docker/nginx.conf b/devops/docker/nginx.conf new file mode 100644 index 0000000..78e2b85 --- /dev/null +++ b/devops/docker/nginx.conf @@ -0,0 +1,78 @@ +user nginx; +worker_processes auto; +error_log /var/log/nginx/error.log warn; +pid /var/run/nginx.pid; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /var/log/nginx/access.log main; + + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + types_hash_max_size 2048; + client_max_body_size 100M; + + # Security headers + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "DENY" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + + # Rate limiting + limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s; + limit_req_zone $binary_remote_addr zone=general_limit:10m rate=50r/s; + + # notely upstream + upstream notely { + server notely:8080; + } + + server { + listen 80; + server_name localhost; + + # API routes + location /api/ { + limit_req zone=api_limit burst=20 nodelay; + + proxy_pass http://notely; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_connect_timeout 60s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; + } + + # Health check + location /health { + proxy_pass http://notely; + access_log off; + } + + # All other routes (frontend) served by notely + location / { + limit_req zone=general_limit burst=50 nodelay; + + proxy_pass http://notely; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + } +} diff --git a/devops/kubernetes/deployment.yaml b/devops/kubernetes/deployment.yaml new file mode 100644 index 0000000..03b9712 --- /dev/null +++ b/devops/kubernetes/deployment.yaml @@ -0,0 +1,240 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: noteapp + +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: mongodb-pvc + namespace: noteapp +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 10Gi + +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: app-config + namespace: noteapp +data: + MONGODB_URI: "mongodb://admin:password@mongodb:27017/noteapp?authSource=admin" + JWT_SECRET: "your-super-secret-jwt-key-change-in-production" + ENCRYPTION_KEY: "00000000000000000000000000000000" + PORT: "8080" + +--- +apiVersion: v1 +kind: Secret +metadata: + name: mongodb-credentials + namespace: noteapp +type: Opaque +stringData: + username: admin + password: password + +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: mongodb + namespace: noteapp +spec: + serviceName: mongodb + replicas: 1 + selector: + matchLabels: + app: mongodb + template: + metadata: + labels: + app: mongodb + spec: + containers: + - name: mongodb + image: mongo:7.0-alpine + ports: + - containerPort: 27017 + name: mongodb + env: + - name: MONGO_INITDB_ROOT_USERNAME + valueFrom: + secretKeyRef: + name: mongodb-credentials + key: username + - name: MONGO_INITDB_ROOT_PASSWORD + valueFrom: + secretKeyRef: + name: mongodb-credentials + key: password + volumeMounts: + - name: mongodb-storage + mountPath: /data/db + livenessProbe: + exec: + command: + - mongosh + - mongodb://admin:password@localhost:27017/admin?authSource=admin + - --quiet + - --eval + - db.adminCommand('ping').ok + initialDelaySeconds: 30 + periodSeconds: 10 + readinessProbe: + exec: + command: + - mongosh + - mongodb://admin:password@localhost:27017/admin?authSource=admin + - --quiet + - --eval + - db.adminCommand('ping').ok + initialDelaySeconds: 5 + periodSeconds: 5 + resources: + requests: + memory: "256Mi" + cpu: "100m" + limits: + memory: "512Mi" + cpu: "500m" + volumes: + - name: mongodb-storage + persistentVolumeClaim: + claimName: mongodb-pvc + +--- +apiVersion: v1 +kind: Service +metadata: + name: mongodb + namespace: noteapp +spec: + clusterIP: None + ports: + - port: 27017 + targetPort: 27017 + selector: + app: mongodb + +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: noteapp + namespace: noteapp +spec: + replicas: 2 + selector: + matchLabels: + app: noteapp + template: + metadata: + labels: + app: noteapp + spec: + containers: + - name: noteapp + image: noteapp:latest + imagePullPolicy: IfNotPresent + ports: + - containerPort: 8080 + name: http + envFrom: + - configMapRef: + name: app-config + livenessProbe: + httpGet: + path: /health + port: 8080 + initialDelaySeconds: 30 + periodSeconds: 10 + readinessProbe: + httpGet: + path: /health + port: 8080 + initialDelaySeconds: 5 + periodSeconds: 5 + resources: + requests: + memory: "256Mi" + cpu: "100m" + limits: + memory: "512Mi" + cpu: "500m" + +--- +apiVersion: v1 +kind: Service +metadata: + name: noteapp + namespace: noteapp +spec: + selector: + app: noteapp + ports: + - port: 8080 + targetPort: 8080 + protocol: TCP + type: ClusterIP + +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: noteapp-ingress + namespace: noteapp + annotations: + nginx.ingress.kubernetes.io/ssl-redirect: "false" +spec: + ingressClassName: nginx + rules: + - host: noteapp.local + http: + paths: + - path: /api + pathType: Prefix + backend: + service: + name: noteapp + port: + number: 8080 + - path: / + pathType: Prefix + backend: + service: + name: noteapp + port: + number: 8080 + +--- +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: noteapp-hpa + namespace: noteapp +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: noteapp + minReplicas: 2 + maxReplicas: 10 + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: 70 + - type: Resource + resource: + name: memory + target: + type: Utilization + averageUtilization: 80 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..071ee09 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,68 @@ +version: "3.8" + +services: + mongodb: + image: mongo:8.0 + container_name: notely-mongodb + environment: + MONGO_INITDB_DATABASE: notely + MONGO_INITDB_ROOT_USERNAME: admin + MONGO_INITDB_ROOT_PASSWORD: password + ports: + - "27017:27017" + volumes: + - mongodb_data:/data/db + - mongodb_config:/data/configdb + networks: + - notely-network + healthcheck: + test: mongosh "mongodb://admin:password@localhost:27017/admin?authSource=admin" --quiet --eval "db.adminCommand('ping').ok" + interval: 10s + timeout: 5s + retries: 5 + + notely: + build: + context: . + dockerfile: ./devops/docker/Dockerfile + args: + VITE_API_BASE_URL: ${VITE_API_BASE_URL} + container_name: notely-app + ports: + - "${BACKEND_PORT}:${BACKEND_PORT}" + environment: + MONGODB_URI: ${MONGODB_URI} + JWT_SECRET: ${JWT_SECRET} + ENCRYPTION_KEY: ${ENCRYPTION_KEY} + PORT: ${BACKEND_PORT} + FRONTEND_URL: ${FRONTEND_URL} + DEFAULT_ADMIN_EMAIL: ${DEFAULT_ADMIN_EMAIL} + DEFAULT_ADMIN_USERNAME: ${DEFAULT_ADMIN_USERNAME} + DEFAULT_ADMIN_PASSWORD: ${DEFAULT_ADMIN_PASSWORD} + depends_on: + mongodb: + condition: service_healthy + networks: + - notely-network + + nginx: + image: nginx:1.25-alpine + container_name: notely-nginx + ports: + - "${NGINX_HTTP_PORT}:80" + - "${NGINX_HTTPS_PORT}:443" + volumes: + - ./devops/docker/nginx.conf:/etc/nginx/nginx.conf:ro + - ./devops/docker/ssl:/etc/nginx/ssl:ro + depends_on: + - notely + networks: + - notely-network + +volumes: + mongodb_data: + mongodb_config: + +networks: + notely-network: + driver: bridge diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..388b0c8 --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,10 @@ +# Frontend Environment Example + +# API Base URL (Backend server) +VITE_API_BASE_URL=http://localhost:8080 + +# Environment +VITE_ENV=development + +# Feature Flags +VITE_ENABLE_ANALYTICS=false diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..adec67e --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + Notely + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..f956cb8 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,2502 @@ +{ + "name": "noteapp-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "noteapp-frontend", + "version": "0.1.0", + "dependencies": { + "@mdi/font": "^7.4.47", + "@mdi/js": "^7.2.0", + "axios": "^1.4.0", + "bootstrap": "^5.3.0", + "dompurify": "^3.0.0", + "marked": "^9.0.0", + "pinia": "^2.1.0", + "vue": "^3.3.0", + "vue-router": "^4.2.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^4.2.0", + "@vue/test-utils": "^2.4.0", + "vite": "^4.3.0", + "vitest": "^0.34.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.20.tgz", + "integrity": "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.18.20.tgz", + "integrity": "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.18.20.tgz", + "integrity": "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.18.20.tgz", + "integrity": "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.18.20.tgz", + "integrity": "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.20.tgz", + "integrity": "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.18.20.tgz", + "integrity": "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.18.20.tgz", + "integrity": "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.18.20.tgz", + "integrity": "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.18.20.tgz", + "integrity": "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.18.20.tgz", + "integrity": "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.18.20.tgz", + "integrity": "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.18.20.tgz", + "integrity": "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.18.20.tgz", + "integrity": "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.18.20.tgz", + "integrity": "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.18.20.tgz", + "integrity": "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz", + "integrity": "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz", + "integrity": "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz", + "integrity": "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.18.20.tgz", + "integrity": "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.18.20.tgz", + "integrity": "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz", + "integrity": "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@mdi/font": { + "version": "7.4.47", + "resolved": "https://registry.npmjs.org/@mdi/font/-/font-7.4.47.tgz", + "integrity": "sha512-43MtGpd585SNzHZPcYowu/84Vz2a2g31TvPMTm9uTiCSWzaheQySUcSyUH/46fPnuPQWof2yd0pGBtzee/IQWw==", + "license": "Apache-2.0" + }, + "node_modules/@mdi/js": { + "version": "7.4.47", + "resolved": "https://registry.npmjs.org/@mdi/js/-/js-7.4.47.tgz", + "integrity": "sha512-KPnNOtm5i2pMabqZxpUz7iQf+mfrYZyKCZ8QNz85czgEt7cuHcGorWfdzUMWYA0SD+a6Hn4FmJ+YhzzzjkTZrQ==", + "license": "Apache-2.0" + }, + "node_modules/@one-ini/wasm": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz", + "integrity": "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "license": "MIT", + "peer": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@sinclair/typebox": { + "version": "0.27.10", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", + "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "4.3.20", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.20.tgz", + "integrity": "sha512-/pC9HAB5I/xMlc5FP77qjCnI16ChlJfW0tGa0IUcFn38VJrTV6DeZ60NU5KZBtaOZqjdpwTWohz5HU1RrhiYxQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/chai-subset": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/@types/chai-subset/-/chai-subset-1.3.6.tgz", + "integrity": "sha512-m8lERkkQj+uek18hXOZuec3W/fCRTrU4hrnXjH3qhHy96ytuPaPiWGgu7sJb7tZxZonO75vYAjCvpe/e4VUwRw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/chai": "<5.2.0" + } + }, + "node_modules/@types/node": { + "version": "25.5.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", + "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, + "node_modules/@vitejs/plugin-vue": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-4.6.2.tgz", + "integrity": "sha512-kqf7SGFoG+80aZG6Pf+gsZIVvGSCKE98JbiWqcCV9cThtg91Jav0yvYFC9Zb+jKetNGF6ZKeoaxgZfND21fWKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.0.0 || ^5.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vitest/expect": { + "version": "0.34.6", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-0.34.6.tgz", + "integrity": "sha512-QUzKpUQRc1qC7qdGo7rMK3AkETI7w18gTCUrsNnyjjJKYiuUB9+TQK3QnR1unhCnWRC0AbKv2omLGQDF/mIjOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "0.34.6", + "@vitest/utils": "0.34.6", + "chai": "^4.3.10" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "0.34.6", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-0.34.6.tgz", + "integrity": "sha512-1CUQgtJSLF47NnhN+F9X2ycxUP0kLHQ/JWvNHbeBfwW8CzEGgeskzNnHDyv1ieKTltuR6sdIHV+nmR6kPxQqzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "0.34.6", + "p-limit": "^4.0.0", + "pathe": "^1.1.1" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "0.34.6", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-0.34.6.tgz", + "integrity": "sha512-B3OZqYn6k4VaN011D+ve+AA4whM4QkcwcrwaKwAbyyvS/NB1hCWjFIBQxAQQSQir9/RtyAAGuq+4RJmbn2dH4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.1", + "pathe": "^1.1.1", + "pretty-format": "^29.5.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "0.34.6", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-0.34.6.tgz", + "integrity": "sha512-xaCvneSaeBw/cz8ySmF7ZwGvL0lBjfvqc1LpQ/vcdHEvpLn3Ff1vAvjw+CoGn0802l++5L/pxb7whwcWAw+DUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^2.1.1" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "0.34.6", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-0.34.6.tgz", + "integrity": "sha512-IG5aDD8S6zlvloDsnzHw0Ut5xczlF+kv2BOTo+iXfPr54Yhi5qbVOgGB1hZaVq4iJ4C/MZ2J0y15IlsV/ZcI0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "diff-sequences": "^29.4.3", + "loupe": "^2.3.6", + "pretty-format": "^29.5.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.30.tgz", + "integrity": "sha512-s3DfdZkcu/qExZ+td75015ljzHc6vE+30cFMGRPROYjqkroYI5NV2X1yAMX9UeyBNWB9MxCfPcsjpLS11nzkkw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@vue/shared": "3.5.30", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.30.tgz", + "integrity": "sha512-eCFYESUEVYHhiMuK4SQTldO3RYxyMR/UQL4KdGD1Yrkfdx4m/HYuZ9jSfPdA+nWJY34VWndiYdW/wZXyiPEB9g==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.30", + "@vue/shared": "3.5.30" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.30.tgz", + "integrity": "sha512-LqmFPDn89dtU9vI3wHJnwaV6GfTRD87AjWpTWpyrdVOObVtjIuSeZr181z5C4PmVx/V3j2p+0f7edFKGRMpQ5A==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@vue/compiler-core": "3.5.30", + "@vue/compiler-dom": "3.5.30", + "@vue/compiler-ssr": "3.5.30", + "@vue/shared": "3.5.30", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.8", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.30.tgz", + "integrity": "sha512-NsYK6OMTnx109PSL2IAyf62JP6EUdk4Dmj6AkWcJGBvN0dQoMYtVekAmdqgTtWQgEJo+Okstbf/1p7qZr5H+bA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.30", + "@vue/shared": "3.5.30" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/@vue/reactivity": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.30.tgz", + "integrity": "sha512-179YNgKATuwj9gB+66snskRDOitDiuOZqkYia7mHKJaidOMo/WJxHKF8DuGc4V4XbYTJANlfEKb0yxTQotnx4Q==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.30" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.30.tgz", + "integrity": "sha512-e0Z+8PQsUTdwV8TtEsLzUM7SzC7lQwYKePydb7K2ZnmS6jjND+WJXkmmfh/swYzRyfP1EY3fpdesyYoymCzYfg==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.30", + "@vue/shared": "3.5.30" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.30.tgz", + "integrity": "sha512-2UIGakjU4WSQ0T4iwDEW0W7vQj6n7AFn7taqZ9Cvm0Q/RA2FFOziLESrDL4GmtI1wV3jXg5nMoJSYO66egDUBw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.30", + "@vue/runtime-core": "3.5.30", + "@vue/shared": "3.5.30", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.30.tgz", + "integrity": "sha512-v+R34icapydRwbZRD0sXwtHqrQJv38JuMB4JxbOxd8NEpGLny7cncMp53W9UH/zo4j8eDHjQ1dEJXwzFQknjtQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.30", + "@vue/shared": "3.5.30" + }, + "peerDependencies": { + "vue": "3.5.30" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.30.tgz", + "integrity": "sha512-YXgQ7JjaO18NeK2K9VTbDHaFy62WrObMa6XERNfNOkAhD1F1oDSf3ZJ7K6GqabZ0BvSDHajp8qfS5Sa2I9n8uQ==", + "license": "MIT" + }, + "node_modules/@vue/test-utils": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@vue/test-utils/-/test-utils-2.4.6.tgz", + "integrity": "sha512-FMxEjOpYNYiFe0GkaHsnJPXFHxQ6m4t8vI/ElPGpMWxZKpmRvQ33OIrvRXemy6yha03RxhOlQuy+gZMC3CQSow==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-beautify": "^1.14.9", + "vue-component-type-helpers": "^2.0.0" + } + }, + "node_modules/abbrev": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz", + "integrity": "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/assertion-error": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", + "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.13.6", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", + "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/bootstrap": { + "version": "5.3.8", + "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.8.tgz", + "integrity": "sha512-HP1SZDqaLDPwsNiqRqi5NcP0SSXciX2s9E+RyqJIIqGo+vJeN5AJVM98CXmW/Wux0nQ5L7jeWUdplCEf0Ee+tg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/twbs" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/bootstrap" + } + ], + "license": "MIT", + "peerDependencies": { + "@popperjs/core": "^2.11.8" + } + }, + "node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chai": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz", + "integrity": "sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^1.1.0", + "check-error": "^1.0.3", + "deep-eql": "^4.1.3", + "get-func-name": "^2.0.2", + "loupe": "^2.3.6", + "pathval": "^1.1.1", + "type-detect": "^4.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/check-error": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", + "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-func-name": "^2.0.2" + }, + "engines": { + "node": "*" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", + "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/config-chain": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", + "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ini": "^1.3.4", + "proto-list": "~1.2.1" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz", + "integrity": "sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-detect": "^4.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/dompurify": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz", + "integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/editorconfig": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.7.tgz", + "integrity": "sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@one-ini/wasm": "0.1.1", + "commander": "^10.0.0", + "minimatch": "^9.0.1", + "semver": "^7.5.3" + }, + "bin": { + "editorconfig": "bin/editorconfig" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz", + "integrity": "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/android-arm": "0.18.20", + "@esbuild/android-arm64": "0.18.20", + "@esbuild/android-x64": "0.18.20", + "@esbuild/darwin-arm64": "0.18.20", + "@esbuild/darwin-x64": "0.18.20", + "@esbuild/freebsd-arm64": "0.18.20", + "@esbuild/freebsd-x64": "0.18.20", + "@esbuild/linux-arm": "0.18.20", + "@esbuild/linux-arm64": "0.18.20", + "@esbuild/linux-ia32": "0.18.20", + "@esbuild/linux-loong64": "0.18.20", + "@esbuild/linux-mips64el": "0.18.20", + "@esbuild/linux-ppc64": "0.18.20", + "@esbuild/linux-riscv64": "0.18.20", + "@esbuild/linux-s390x": "0.18.20", + "@esbuild/linux-x64": "0.18.20", + "@esbuild/netbsd-x64": "0.18.20", + "@esbuild/openbsd-x64": "0.18.20", + "@esbuild/sunos-x64": "0.18.20", + "@esbuild/win32-arm64": "0.18.20", + "@esbuild/win32-ia32": "0.18.20", + "@esbuild/win32-x64": "0.18.20" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-func-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", + "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/js-beautify": { + "version": "1.15.4", + "resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.15.4.tgz", + "integrity": "sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "config-chain": "^1.1.13", + "editorconfig": "^1.0.4", + "glob": "^10.4.2", + "js-cookie": "^3.0.5", + "nopt": "^7.2.1" + }, + "bin": { + "css-beautify": "js/bin/css-beautify.js", + "html-beautify": "js/bin/html-beautify.js", + "js-beautify": "js/bin/js-beautify.js" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/js-cookie": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", + "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/local-pkg": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.4.3.tgz", + "integrity": "sha512-SFppqq5p42fe2qcZQqqEOiVRXl+WCP1MdT6k7BDEW1j++sp5fIY+/fdRQitvKgB5BrBcmrs5m/L0v2FrU5MY1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/loupe": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz", + "integrity": "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-func-name": "^2.0.1" + } + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/marked": { + "version": "9.1.6", + "resolved": "https://registry.npmjs.org/marked/-/marked-9.1.6.tgz", + "integrity": "sha512-jcByLnIFkd5gSXZmjNvS1TlmRhCXZjIzHYlaGkPlLIekG55JDR2Z4va9tZwCiP+/RDERiNhMOFu01xd6O5ct1Q==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 16" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/mlly": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", + "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" + } + }, + "node_modules/mlly/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/nopt": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.1.tgz", + "integrity": "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^2.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/p-limit": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-4.0.0.tgz", + "integrity": "sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^1.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", + "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/pinia": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/pinia/-/pinia-2.3.1.tgz", + "integrity": "sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.3", + "vue-demi": "^0.14.10" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.4.4", + "vue": "^2.7.0 || ^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/pkg-types/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/proto-list": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", + "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==", + "dev": true, + "license": "ISC" + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/rollup": { + "version": "3.30.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.30.0.tgz", + "integrity": "sha512-kQvGasUgN+AlWGliFn2POSajRQEsULVYFGTvOZmK06d7vCD+YhZztt70kGk3qaeAXeWYL5eO7zx+rAubBc55eA==", + "dev": true, + "license": "MIT", + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=14.18.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-literal": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-1.3.0.tgz", + "integrity": "sha512-PugKzOsyXpArk0yWmUwqOZecSO0GH0bPoctLcqNDH9J04pVW3lflYE0ujElBGTloevcxF5MofAOZ7C5l2b+wLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.10.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinypool": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.7.0.tgz", + "integrity": "sha512-zSYNUlYSMhJ6Zdou4cJwo/p7w5nmAH17GRfU/ui3ctvjXFErXXkruT4MWW6poDeXgCaIBlGLrfU6TbTXxyGMww==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-2.2.1.tgz", + "integrity": "sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/type-detect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", + "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/ufo": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz", + "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "4.5.14", + "resolved": "https://registry.npmjs.org/vite/-/vite-4.5.14.tgz", + "integrity": "sha512-+v57oAaoYNnO3hIu5Z/tJRZjq5aHM2zDve9YZ8HngVHbhk66RStobhb1sqPMIPEleV6cNKYK4eGrAbE9Ulbl2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.18.10", + "postcss": "^8.4.27", + "rollup": "^3.27.1" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + }, + "peerDependencies": { + "@types/node": ">= 14", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "0.34.6", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-0.34.6.tgz", + "integrity": "sha512-nlBMJ9x6n7/Amaz6F3zJ97EBwR2FkzhBRxF5e+jE6LA3yi6Wtc2lyTij1OnDMIr34v5g/tVQtsVAzhT0jc5ygA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.4", + "mlly": "^1.4.0", + "pathe": "^1.1.1", + "picocolors": "^1.0.0", + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": ">=v14.18.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "0.34.6", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-0.34.6.tgz", + "integrity": "sha512-+5CALsOvbNKnS+ZHMXtuUC7nL8/7F1F2DnHGjSsszX8zCjWSSviphCb/NuS9Nzf4Q03KyyDRBAXhF/8lffME4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^4.3.5", + "@types/chai-subset": "^1.3.3", + "@types/node": "*", + "@vitest/expect": "0.34.6", + "@vitest/runner": "0.34.6", + "@vitest/snapshot": "0.34.6", + "@vitest/spy": "0.34.6", + "@vitest/utils": "0.34.6", + "acorn": "^8.9.0", + "acorn-walk": "^8.2.0", + "cac": "^6.7.14", + "chai": "^4.3.10", + "debug": "^4.3.4", + "local-pkg": "^0.4.3", + "magic-string": "^0.30.1", + "pathe": "^1.1.1", + "picocolors": "^1.0.0", + "std-env": "^3.3.3", + "strip-literal": "^1.0.1", + "tinybench": "^2.5.0", + "tinypool": "^0.7.0", + "vite": "^3.1.0 || ^4.0.0 || ^5.0.0-0", + "vite-node": "0.34.6", + "why-is-node-running": "^2.2.2" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": ">=v14.18.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@vitest/browser": "*", + "@vitest/ui": "*", + "happy-dom": "*", + "jsdom": "*", + "playwright": "*", + "safaridriver": "*", + "webdriverio": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "playwright": { + "optional": true + }, + "safaridriver": { + "optional": true + }, + "webdriverio": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.30.tgz", + "integrity": "sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.30", + "@vue/compiler-sfc": "3.5.30", + "@vue/runtime-dom": "3.5.30", + "@vue/server-renderer": "3.5.30", + "@vue/shared": "3.5.30" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-component-type-helpers": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-2.2.12.tgz", + "integrity": "sha512-YbGqHZ5/eW4SnkPNR44mKVc6ZKQoRs/Rux1sxC6rdwXb4qpbOSYfDr9DsTHolOTGmIKgM9j141mZbBeg05R1pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/yocto-queue": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", + "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..293b2fb --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,29 @@ +{ + "name": "noteapp-frontend", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs" + }, + "dependencies": { + "@mdi/font": "^7.4.47", + "@mdi/js": "^7.2.0", + "axios": "^1.4.0", + "bootstrap": "^5.3.0", + "dompurify": "^3.0.0", + "marked": "^9.0.0", + "pinia": "^2.1.0", + "vue": "^3.3.0", + "vue-router": "^4.2.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^4.2.0", + "@vue/test-utils": "^2.4.0", + "vite": "^4.3.0", + "vitest": "^0.34.0" + } +} diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..96060fe --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,1081 @@ +๏ปฟ + + + diff --git a/frontend/src/assets/styles/main.css b/frontend/src/assets/styles/main.css new file mode 100644 index 0000000..103efcd --- /dev/null +++ b/frontend/src/assets/styles/main.css @@ -0,0 +1,45 @@ +:root { + --primary-color: #667eea; + --secondary-color: #764ba2; + --text-color: #333; + --bg-color: #f8f9fa; + --border-color: #dee2e6; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif; + color: var(--text-color); + background-color: var(--bg-color); +} + +html, +body, +#app { + height: 100%; + width: 100%; +} + +/* Scrollbar styling */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: #f1f1f1; +} + +::-webkit-scrollbar-thumb { + background: #888; + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: #555; +} diff --git a/frontend/src/components/AdminSpaceModal.vue b/frontend/src/components/AdminSpaceModal.vue new file mode 100644 index 0000000..2ff9820 --- /dev/null +++ b/frontend/src/components/AdminSpaceModal.vue @@ -0,0 +1,254 @@ + + + diff --git a/frontend/src/components/CategoryTree.vue b/frontend/src/components/CategoryTree.vue new file mode 100644 index 0000000..bf320fb --- /dev/null +++ b/frontend/src/components/CategoryTree.vue @@ -0,0 +1,278 @@ + + + + + diff --git a/frontend/src/components/CreateCategoryModal.vue b/frontend/src/components/CreateCategoryModal.vue new file mode 100644 index 0000000..9bcd6cb --- /dev/null +++ b/frontend/src/components/CreateCategoryModal.vue @@ -0,0 +1,93 @@ + + + + + diff --git a/frontend/src/components/CreateNoteModal.vue b/frontend/src/components/CreateNoteModal.vue new file mode 100644 index 0000000..a2ca1b7 --- /dev/null +++ b/frontend/src/components/CreateNoteModal.vue @@ -0,0 +1,156 @@ + + + + + diff --git a/frontend/src/components/CreateSpaceModal.vue b/frontend/src/components/CreateSpaceModal.vue new file mode 100644 index 0000000..344b162 --- /dev/null +++ b/frontend/src/components/CreateSpaceModal.vue @@ -0,0 +1,52 @@ + + + + + diff --git a/frontend/src/components/ManageAuthProvidersModal.vue b/frontend/src/components/ManageAuthProvidersModal.vue new file mode 100644 index 0000000..f578f0d --- /dev/null +++ b/frontend/src/components/ManageAuthProvidersModal.vue @@ -0,0 +1,265 @@ + + + + + diff --git a/frontend/src/components/NoteEditor.vue b/frontend/src/components/NoteEditor.vue new file mode 100644 index 0000000..58d0a06 --- /dev/null +++ b/frontend/src/components/NoteEditor.vue @@ -0,0 +1,287 @@ + + + + + diff --git a/frontend/src/components/NoteList.vue b/frontend/src/components/NoteList.vue new file mode 100644 index 0000000..94ef780 --- /dev/null +++ b/frontend/src/components/NoteList.vue @@ -0,0 +1,221 @@ + + + + + diff --git a/frontend/src/components/NoteViewer.vue b/frontend/src/components/NoteViewer.vue new file mode 100644 index 0000000..664dbc3 --- /dev/null +++ b/frontend/src/components/NoteViewer.vue @@ -0,0 +1,175 @@ + + + + + diff --git a/frontend/src/components/SpaceSettingsModal.vue b/frontend/src/components/SpaceSettingsModal.vue new file mode 100644 index 0000000..def1030 --- /dev/null +++ b/frontend/src/components/SpaceSettingsModal.vue @@ -0,0 +1,269 @@ + + + diff --git a/frontend/src/main.js b/frontend/src/main.js new file mode 100644 index 0000000..88a20a7 --- /dev/null +++ b/frontend/src/main.js @@ -0,0 +1,13 @@ +import { createApp } from "vue"; +import { createPinia } from "pinia"; +import router from "./router"; +import App from "./App.vue"; +import "bootstrap/dist/css/bootstrap.min.css"; +import "@mdi/font/css/materialdesignicons.min.css"; +import "./assets/styles/main.css"; + +const app = createApp(App); + +app.use(createPinia()); +app.use(router); +app.mount("#app"); diff --git a/frontend/src/pages/Admin.vue b/frontend/src/pages/Admin.vue new file mode 100644 index 0000000..2a2f096 --- /dev/null +++ b/frontend/src/pages/Admin.vue @@ -0,0 +1,631 @@ + + + + + diff --git a/frontend/src/pages/Home.vue b/frontend/src/pages/Home.vue new file mode 100644 index 0000000..c891e86 --- /dev/null +++ b/frontend/src/pages/Home.vue @@ -0,0 +1,7 @@ + + + diff --git a/frontend/src/pages/Login.vue b/frontend/src/pages/Login.vue new file mode 100644 index 0000000..06a71e7 --- /dev/null +++ b/frontend/src/pages/Login.vue @@ -0,0 +1,298 @@ + + + + + diff --git a/frontend/src/pages/PublicSpace.vue b/frontend/src/pages/PublicSpace.vue new file mode 100644 index 0000000..1ea5541 --- /dev/null +++ b/frontend/src/pages/PublicSpace.vue @@ -0,0 +1,395 @@ + + + + + diff --git a/frontend/src/pages/Register.vue b/frontend/src/pages/Register.vue new file mode 100644 index 0000000..fb4b779 --- /dev/null +++ b/frontend/src/pages/Register.vue @@ -0,0 +1,205 @@ + + + + + diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js new file mode 100644 index 0000000..260fe4f --- /dev/null +++ b/frontend/src/router/index.js @@ -0,0 +1,123 @@ +import { createRouter, createWebHistory } from "vue-router"; +import { useAuthStore } from "../stores/authStore"; +import { useSettingsStore } from "../stores/settingsStore"; +import LoginPage from "../pages/Login.vue"; +import RegisterPage from "../pages/Register.vue"; + +const decodeBase64UrlUTF8 = (value) => { + const normalized = value.replace(/-/g, "+").replace(/_/g, "/"); + const padding = normalized.length % 4; + const padded = padding === 0 ? normalized : `${normalized}${"=".repeat(4 - padding)}`; + const binary = atob(padded); + const bytes = Uint8Array.from(binary, (ch) => ch.charCodeAt(0)); + return new TextDecoder().decode(bytes); +}; +const restoreOAuthSessionFromQuery = (query, authStore) => { + // Merge router query with URLSearchParams for full coverage + const params = new URLSearchParams(window.location.search); + const accessToken = query.access_token || query.accessToken || query.token || params.get("access_token") || params.get("accessToken") || params.get("token"); + + if (!accessToken) { + return false; + } + + try { + const plainUserJSON = query.user_json || params.get("user_json"); + const encodedUser = query.user || params.get("user"); + const user = plainUserJSON ? JSON.parse(plainUserJSON) : encodedUser ? JSON.parse(decodeBase64UrlUTF8(encodedUser)) : null; + + if (!user) { + return false; + } + + authStore.setSession({ access_token: accessToken, user }); + return true; + } catch { + return false; + } +}; + +const routes = [ + { + path: "/login", + name: "Login", + component: LoginPage, + }, + { + path: "/register", + name: "Register", + component: RegisterPage, + }, + { + path: "/", + name: "Home", + component: () => import("../pages/Home.vue"), + meta: { requiresAuth: true }, + }, + { + path: "/admin", + name: "Admin", + component: () => import("../pages/Admin.vue"), + meta: { requiresAuth: true, requiresAdminPermission: true }, + }, + { + path: "/s/:spaceId", + name: "PublicSpace", + component: () => import("../pages/PublicSpace.vue"), + }, + { + path: "/s/:spaceId/n/:noteId", + name: "PublicNote", + component: () => import("../pages/PublicSpace.vue"), + }, +]; + +const router = createRouter({ + history: createWebHistory(), + routes, +}); + +router.beforeEach(async (to, from, next) => { + const authStore = useAuthStore(); + const settingsStore = useSettingsStore(); + + // Only attempt OAuth callback restoration if actual OAuth query params are present + const params = new URLSearchParams(window.location.search); + const hasOAuthParams = to.query.access_token || to.query.accessToken || to.query.token || params.get("access_token") || params.get("accessToken") || params.get("token"); + + if (to.path === "/login") { + if (hasOAuthParams) { + const restored = restoreOAuthSessionFromQuery(to.query, authStore); + if (restored) { + next({ path: "/", replace: true }); + return; + } + } + + // Allow login page to be viewed regardless of auth state if no OAuth callback + if (!hasOAuthParams) { + next(); + return; + } + } + + if (to.path === "/register") { + await settingsStore.loadFeatureFlags(); + if (!settingsStore.registrationEnabled) { + next({ path: "/login", query: { message: "Registration is currently disabled." } }); + return; + } + } + + if (to.meta.requiresAuth && !authStore.isAuthenticated) { + next("/login"); + } else if (to.meta.requiresAdminPermission && !authStore.isAdmin) { + next("/"); + } else if ((to.path === "/login" || to.path === "/register") && authStore.isAuthenticated) { + next("/"); + } else { + next(); + } +}); + +export default router; diff --git a/frontend/src/services/apiClient.js b/frontend/src/services/apiClient.js new file mode 100644 index 0000000..d1f1bb6 --- /dev/null +++ b/frontend/src/services/apiClient.js @@ -0,0 +1,27 @@ +import axios from "axios"; +import { useAuthStore } from "../stores/authStore"; + +const apiClient = axios.create({ + baseURL: import.meta.env.VITE_API_BASE_URL || "http://localhost:8080", +}); + +apiClient.interceptors.request.use((config) => { + const authStore = useAuthStore(); + if (authStore.accessToken) { + config.headers.Authorization = `Bearer ${authStore.accessToken}`; + } + return config; +}); + +apiClient.interceptors.response.use( + (response) => response, + (error) => { + if (error.response?.status === 401) { + const authStore = useAuthStore(); + authStore.logout(); + } + return Promise.reject(error); + }, +); + +export default apiClient; diff --git a/frontend/src/stores/authStore.js b/frontend/src/stores/authStore.js new file mode 100644 index 0000000..94304de --- /dev/null +++ b/frontend/src/stores/authStore.js @@ -0,0 +1,108 @@ +import { defineStore } from "pinia"; +import { ref, computed } from "vue"; +import apiClient from "../services/apiClient"; + +export const useAuthStore = defineStore("auth", () => { + const storedUser = localStorage.getItem("user"); + const user = ref(storedUser ? JSON.parse(storedUser) : null); + const accessToken = ref(localStorage.getItem("accessToken")); + const isAuthenticated = computed(() => !!accessToken.value && !!user.value); + const isAdmin = computed(() => hasPermission("*") || hasPermission("admin.access")); + + const normalizePermission = (permission) => (permission || "").trim().toLowerCase(); + + const permissionMatches = (pattern, permission) => { + const normalizedPattern = normalizePermission(pattern); + const normalizedPermission = normalizePermission(permission); + + if (!normalizedPattern || !normalizedPermission) { + return false; + } + if (normalizedPattern === "*" || normalizedPattern === normalizedPermission) { + return true; + } + if (!normalizedPattern.includes("*")) { + return false; + } + + const escaped = normalizedPattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*"); + const regex = new RegExp(`^${escaped}$`); + return regex.test(normalizedPermission); + }; + + const hasPermission = (permission) => { + const userPermissions = user.value?.permissions || []; + return userPermissions.some((pattern) => permissionMatches(pattern, permission)); + }; + + const getSpacePermissionToken = (space) => space?.permission_key || ""; + + const hasSpacePermission = (space, action) => { + const token = getSpacePermissionToken(space); + if (!token) { + return false; + } + return hasPermission(`space.${token}.${action}`); + }; + + const setSession = (responseData) => { + accessToken.value = responseData.access_token; + user.value = responseData.user; + localStorage.setItem("accessToken", accessToken.value); + localStorage.setItem("user", JSON.stringify(user.value)); + }; + + const register = async (email, username, password, firstName = "", lastName = "") => { + try { + const response = await apiClient.post("/api/v1/auth/register", { + email, + username, + password, + password_confirm: password, + first_name: firstName, + last_name: lastName, + }); + + setSession(response.data); + + return response.data; + } catch (error) { + throw error.response?.data?.message || error.message; + } + }; + + const login = async (email, password) => { + try { + const response = await apiClient.post("/api/v1/auth/login", { + email: email?.trim(), + password, + }); + + setSession(response.data); + + return response.data; + } catch (error) { + throw error.response?.data?.message || error.message; + } + }; + + const logout = () => { + accessToken.value = null; + user.value = null; + localStorage.removeItem("accessToken"); + localStorage.removeItem("user"); + }; + + return { + user, + accessToken, + isAuthenticated, + isAdmin, + hasPermission, + hasSpacePermission, + setSession, + register, + login, + logout, + }; +}); diff --git a/frontend/src/stores/settingsStore.js b/frontend/src/stores/settingsStore.js new file mode 100644 index 0000000..b0e74eb --- /dev/null +++ b/frontend/src/stores/settingsStore.js @@ -0,0 +1,47 @@ +import { defineStore } from "pinia"; +import { computed, ref } from "vue"; +import apiClient from "../services/apiClient"; + +const DEFAULT_FLAGS = { + registration_enabled: true, + provider_login_enabled: true, + public_sharing_enabled: true, +}; + +export const useSettingsStore = defineStore("settings", () => { + const featureFlags = ref({ ...DEFAULT_FLAGS }); + const flagsLoaded = ref(false); + + const registrationEnabled = computed(() => !!featureFlags.value.registration_enabled); + const providerLoginEnabled = computed(() => !!featureFlags.value.provider_login_enabled); + const publicSharingEnabled = computed(() => !!featureFlags.value.public_sharing_enabled); + + const loadFeatureFlags = async (force = false) => { + if (flagsLoaded.value && !force) { + return featureFlags.value; + } + + try { + const response = await apiClient.get("/api/v1/settings/feature-flags"); + featureFlags.value = { + ...DEFAULT_FLAGS, + ...response.data, + }; + flagsLoaded.value = true; + } catch { + featureFlags.value = { ...DEFAULT_FLAGS }; + flagsLoaded.value = true; + } + + return featureFlags.value; + }; + + return { + featureFlags, + flagsLoaded, + registrationEnabled, + providerLoginEnabled, + publicSharingEnabled, + loadFeatureFlags, + }; +}); diff --git a/frontend/src/stores/spaceStore.js b/frontend/src/stores/spaceStore.js new file mode 100644 index 0000000..63d8a78 --- /dev/null +++ b/frontend/src/stores/spaceStore.js @@ -0,0 +1,224 @@ +import { defineStore } from "pinia"; +import { ref } from "vue"; +import apiClient from "../services/apiClient"; + +export const useSpaceStore = defineStore("space", () => { + const spaces = ref([]); + const currentSpace = ref(null); + const notes = ref([]); + const notesSkip = ref(0); + const notesLimit = ref(20); + const notesHasMore = ref(true); + const notesLoading = ref(false); + const categories = ref([]); + const categoryTree = ref([]); + + const refreshSpaceData = async (spaceId) => { + await Promise.all([fetchCategories(spaceId), fetchNotes(spaceId)]); + }; + + const fetchSpaces = async () => { + try { + const response = await apiClient.get("/api/v1/spaces"); + spaces.value = response.data || []; + } catch (error) { + console.error("Error fetching spaces:", error); + } + }; + + const selectSpace = async (spaceId) => { + try { + const response = await apiClient.get(`/api/v1/spaces/${spaceId}`); + currentSpace.value = response.data; + await refreshSpaceData(spaceId); + } catch (error) { + console.error("Error selecting space:", error); + } + }; + + const fetchCategories = async (spaceId) => { + try { + const response = await apiClient.get(`/api/v1/spaces/${spaceId}/categories`); + categoryTree.value = response.data || []; + categories.value = categoryTree.value; + } catch (error) { + console.error("Error fetching categories:", error); + categoryTree.value = []; + categories.value = []; + } + }; + + const fetchNotes = async (spaceId, options = {}) => { + const { reset = true, limit = notesLimit.value } = options; + if (!spaceId) { + return; + } + if (notesLoading.value) { + return; + } + if (!reset && !notesHasMore.value) { + return; + } + + if (reset) { + notesSkip.value = 0; + notesHasMore.value = true; + notesLimit.value = limit; + } + + try { + notesLoading.value = true; + const response = await apiClient.get(`/api/v1/spaces/${spaceId}/notes`, { + params: { + skip: notesSkip.value, + limit, + }, + }); + const fetchedNotes = response.data || []; + + if (reset) { + notes.value = fetchedNotes; + } else { + notes.value = [...notes.value, ...fetchedNotes]; + } + + notesSkip.value += fetchedNotes.length; + notesHasMore.value = fetchedNotes.length === limit; + } catch (error) { + console.error("Error fetching notes:", error); + } finally { + notesLoading.value = false; + } + }; + + const loadMoreNotes = async (spaceId) => { + await fetchNotes(spaceId, { reset: false, limit: notesLimit.value }); + }; + + const createSpace = async (spaceData) => { + try { + const response = await apiClient.post("/api/v1/spaces", spaceData); + spaces.value.push(response.data); + currentSpace.value = response.data; + localStorage.setItem("currentSpaceId", response.data.id); + await refreshSpaceData(response.data.id); + return response.data; + } catch (error) { + throw error.response?.data?.message || error.message; + } + }; + + const updateSpace = async (spaceId, spaceData) => { + try { + const response = await apiClient.put(`/api/v1/spaces/${spaceId}`, spaceData); + const index = spaces.value.findIndex((s) => s.id === spaceId); + if (index !== -1) { + spaces.value[index] = response.data; + } + if (currentSpace.value?.id === spaceId) { + currentSpace.value = response.data; + } + return response.data; + } catch (error) { + throw error.response?.data?.message || error.message; + } + }; + + const createCategory = async (spaceId, categoryData) => { + try { + const response = await apiClient.post(`/api/v1/spaces/${spaceId}/categories`, categoryData); + await fetchCategories(spaceId); + return response.data; + } catch (error) { + throw error.response?.data?.message || error.message; + } + }; + + const updateCategory = async (spaceId, categoryId, categoryData) => { + try { + const response = await apiClient.put(`/api/v1/spaces/${spaceId}/categories/${categoryId}`, categoryData); + await fetchCategories(spaceId); + return response.data; + } catch (error) { + throw error.response?.data?.message || error.message; + } + }; + + const deleteCategory = async (spaceId, categoryId) => { + try { + await apiClient.delete(`/api/v1/spaces/${spaceId}/categories/${categoryId}`); + await refreshSpaceData(spaceId); + } catch (error) { + throw error.response?.data?.message || error.message; + } + }; + + const createNote = async (spaceId, noteData) => { + try { + const response = await apiClient.post(`/api/v1/spaces/${spaceId}/notes`, noteData); + await refreshSpaceData(spaceId); + return response.data; + } catch (error) { + throw error.response?.data?.message || error.message; + } + }; + + const updateNote = async (spaceId, noteData) => { + try { + const response = await apiClient.put(`/api/v1/spaces/${spaceId}/notes/${noteData.id}`, noteData); + const index = notes.value.findIndex((n) => n.id === noteData.id); + if (index !== -1) { + notes.value[index] = response.data; + } + await fetchCategories(spaceId); + return response.data; + } catch (error) { + throw error.response?.data?.message || error.message; + } + }; + + const deleteNote = async (spaceId, noteId) => { + try { + await apiClient.delete(`/api/v1/spaces/${spaceId}/notes/${noteId}`); + notes.value = notes.value.filter((n) => n.id !== noteId); + await fetchCategories(spaceId); + } catch (error) { + throw error.response?.data?.message || error.message; + } + }; + + const searchNotes = async (query) => { + try { + const response = await apiClient.get(`/api/v1/spaces/${currentSpace.value.id}/notes/search`, { params: { q: query } }); + notes.value = response.data || []; + notesHasMore.value = false; + notesSkip.value = notes.value.length; + } catch (error) { + console.error("Error searching notes:", error); + } + }; + + return { + spaces, + currentSpace, + notes, + notesHasMore, + notesLoading, + categories, + categoryTree, + fetchSpaces, + selectSpace, + fetchNotes, + loadMoreNotes, + fetchCategories, + createSpace, + updateSpace, + createCategory, + updateCategory, + deleteCategory, + createNote, + updateNote, + deleteNote, + searchNotes, + }; +}); diff --git a/frontend/src/utils/noteSort.js b/frontend/src/utils/noteSort.js new file mode 100644 index 0000000..d99d8b2 --- /dev/null +++ b/frontend/src/utils/noteSort.js @@ -0,0 +1,17 @@ +export const compareNotesByPriority = (a, b) => { + const pinnedDelta = (b?.is_pinned ? 1 : 0) - (a?.is_pinned ? 1 : 0); + if (pinnedDelta !== 0) { + return pinnedDelta; + } + + const featuredDelta = (b?.is_favorite || b?.is_featured ? 1 : 0) - (a?.is_favorite || a?.is_featured ? 1 : 0); + if (featuredDelta !== 0) { + return featuredDelta; + } + + const leftTitle = (a?.title || "").trim(); + const rightTitle = (b?.title || "").trim(); + return leftTitle.localeCompare(rightTitle, undefined, { sensitivity: "base" }); +}; + +export const sortNotesByPriority = (notes = []) => [...notes].sort(compareNotesByPriority); diff --git a/frontend/tests/auth.spec.js b/frontend/tests/auth.spec.js new file mode 100644 index 0000000..2afac07 --- /dev/null +++ b/frontend/tests/auth.spec.js @@ -0,0 +1,39 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { useAuthStore } from "../../src/stores/authStore"; +import { createPinia, setActivePinia } from "pinia"; + +describe("Auth Store", () => { + beforeEach(() => { + setActivePinia(createPinia()); + }); + + it("should initialize with no user", () => { + const store = useAuthStore(); + expect(store.isAuthenticated).toBe(false); + expect(store.user).toBeNull(); + }); + + it("should store user data on login", () => { + const store = useAuthStore(); + + // Mock user data + const mockUser = { + id: "123", + email: "test@example.com", + username: "testuser", + }; + + // In a real test, you'd mock the API call + // For now, just test the store structure + expect(store.user).toBeNull(); + }); + + it("should clear user data on logout", () => { + const store = useAuthStore(); + store.logout(); + + expect(store.isAuthenticated).toBe(false); + expect(store.user).toBeNull(); + expect(store.accessToken).toBeNull(); + }); +}); diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..fc65663 --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,20 @@ +import { defineConfig } from "vite"; +import vue from "@vitejs/plugin-vue"; + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [vue()], + server: { + port: 5173, + proxy: { + "/api": { + target: "http://localhost:8080", + changeOrigin: true, + }, + }, + }, + build: { + outDir: "dist", + assetsDir: "assets", + }, +}); diff --git a/frontend/vitest.config.js b/frontend/vitest.config.js new file mode 100644 index 0000000..6b6ee42 --- /dev/null +++ b/frontend/vitest.config.js @@ -0,0 +1,14 @@ +// vitest.config.js +import { defineConfig } from "vitest/config"; +import vue from "@vitejs/plugin-vue"; + +export default defineConfig({ + plugins: [vue()], + test: { + globals: true, + environment: "jsdom", + coverage: { + provider: "v8", + }, + }, +});