Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d793b5ccf2 | ||
|
|
005a8f4cf0 | ||
|
|
9cf71ab4a0 | ||
|
|
cf94697d07 | ||
|
|
94f11be77c | ||
|
|
6e642da57a |
215
ENV_SETUP.md
215
ENV_SETUP.md
@@ -1,98 +1,151 @@
|
|||||||
# Environment Configuration
|
# Environment Setup
|
||||||
|
|
||||||
Copy `.env.example` files and configure for your environment:
|
Notely uses three different environment-file locations depending on how you run the app.
|
||||||
|
|
||||||
## Backend (.env)
|
## 1. Root `.env`
|
||||||
|
|
||||||
```env
|
Use the root `.env` file when running `docker compose` from the repository root.
|
||||||
# MongoDB
|
|
||||||
MONGODB_URI=mongodb://admin:password@localhost:27017/noteapp?authSource=admin
|
|
||||||
|
|
||||||
# JWT Configuration
|
Start from:
|
||||||
JWT_SECRET=your-super-secret-jwt-key-minimum-32-characters
|
|
||||||
JWT_ISSUER=noteapp
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
# Encryption (32 bytes = 32 characters)
|
```
|
||||||
ENCRYPTION_KEY=00000000000000000000000000000000
|
|
||||||
|
### Variables Used By Docker Compose
|
||||||
# Server
|
|
||||||
PORT=8080
|
Required or commonly used:
|
||||||
ENV=development
|
|
||||||
LOG_LEVEL=info
|
- `MONGODB_URI`
|
||||||
|
- `BACKEND_PORT`
|
||||||
# CORS (comma-separated for multiple origins)
|
- `JWT_SECRET`
|
||||||
CORS_ALLOWED_ORIGINS=http://localhost:5173,http://localhost:3000
|
- `ENCRYPTION_KEY`
|
||||||
|
- `FRONTEND_URL`
|
||||||
# Rate Limiting
|
- `VITE_API_BASE_URL`
|
||||||
RATE_LIMIT_REQUESTS=50
|
- `DEFAULT_ADMIN_EMAIL`
|
||||||
RATE_LIMIT_WINDOW=1s
|
- `DEFAULT_ADMIN_USERNAME`
|
||||||
```
|
- `DEFAULT_ADMIN_PASSWORD`
|
||||||
|
- `NGINX_HTTP_PORT`
|
||||||
## Frontend (.env)
|
- `NGINX_HTTPS_PORT`
|
||||||
|
|
||||||
```env
|
Optional backend runtime values that Docker Compose will also pass through if present:
|
||||||
VITE_API_BASE_URL=http://localhost:8080
|
|
||||||
VITE_ENV=development
|
- `REDIS_ADDR`
|
||||||
```
|
- `REDIS_USER`
|
||||||
|
- `REDIS_PASSWORD`
|
||||||
## Development vs Production
|
- `REDIS_DB`
|
||||||
|
- `SESSION_TTL_HOURS`
|
||||||
### Development (.env.development)
|
|
||||||
|
### Current Defaults In The Checked-In Example
|
||||||
- Less strict security (for easier testing)
|
|
||||||
- Localhost CORS allowed
|
- MongoDB container: `mongodb://admin:password@mongodb:27017/noteapp?authSource=admin`
|
||||||
- JWT secrets can be simple
|
- Backend port: `8080`
|
||||||
- Logging more verbose
|
- Public frontend URL: `http://localhost`
|
||||||
|
- Browser API base URL for container builds: `http://localhost`
|
||||||
### Production (.env.production)
|
|
||||||
|
## 2. `backend/.env`
|
||||||
- Strict security requirements
|
|
||||||
- Specific CORS origins only
|
Use `backend/.env` for local backend development.
|
||||||
- Strong random JWT secrets
|
|
||||||
- Limited logging (performance)
|
Start from:
|
||||||
- All environment variables must be set
|
|
||||||
|
```bash
|
||||||
## Generating Secrets
|
cd backend
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
### Variables Currently Read By The Backend Runtime
|
||||||
|
|
||||||
|
Read in `backend/cmd/server/main.go` or other active handlers:
|
||||||
|
|
||||||
|
- `MONGODB_URI`
|
||||||
|
- `JWT_SECRET`
|
||||||
|
- `ENCRYPTION_KEY`
|
||||||
|
- `PORT`
|
||||||
|
- `REDIS_ADDR`
|
||||||
|
- `REDIS_USER`
|
||||||
|
- `REDIS_PASSWORD`
|
||||||
|
- `REDIS_DB`
|
||||||
|
- `SESSION_TTL_HOURS`
|
||||||
|
- `DEFAULT_ADMIN_EMAIL`
|
||||||
|
- `DEFAULT_ADMIN_USERNAME`
|
||||||
|
- `DEFAULT_ADMIN_PASSWORD`
|
||||||
|
- `FRONTEND_URL`
|
||||||
|
|
||||||
|
### Variables Present In `backend/.env.example` But Not Currently Consumed By Runtime Code
|
||||||
|
|
||||||
|
These values exist in the example file, but the current code path does not read them yet:
|
||||||
|
|
||||||
|
- `JWT_ISSUER`
|
||||||
|
- `ENV`
|
||||||
|
- `LOG_LEVEL`
|
||||||
|
- `CORS_ALLOWED_ORIGINS`
|
||||||
|
- `RATE_LIMIT_REQUESTS`
|
||||||
|
- `RATE_LIMIT_WINDOW`
|
||||||
|
|
||||||
|
### Backend Defaults If A Variable Is Missing
|
||||||
|
|
||||||
|
- `MONGODB_URI`: `mongodb://localhost:27017`
|
||||||
|
- `JWT_SECRET`: `your-secret-key-change-in-production`
|
||||||
|
- `ENCRYPTION_KEY`: `00000000000000000000000000000000`
|
||||||
|
- `PORT`: `8080`
|
||||||
|
- `REDIS_ADDR`: `localhost:6379`
|
||||||
|
- `REDIS_DB`: `0`
|
||||||
|
- `SESSION_TTL_HOURS`: `168`
|
||||||
|
- `FRONTEND_URL`: falls back to `http://localhost:5173` for login redirects
|
||||||
|
|
||||||
|
## 3. `frontend/.env`
|
||||||
|
|
||||||
|
Use `frontend/.env` for local frontend development.
|
||||||
|
|
||||||
|
Start from:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend Variables In `frontend/.env.example`
|
||||||
|
|
||||||
|
- `VITE_API_BASE_URL`
|
||||||
|
- `VITE_ENV`
|
||||||
|
- `VITE_ENABLE_ANALYTICS`
|
||||||
|
|
||||||
|
### Variables Currently Relevant To The Frontend App
|
||||||
|
|
||||||
|
- `VITE_API_BASE_URL`: used by the API client
|
||||||
|
|
||||||
|
The other example values are safe to keep, but the current checked-in frontend code does not actively consume them.
|
||||||
|
|
||||||
|
## Secret Generation
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# JWT Secret (32+ characters)
|
|
||||||
openssl rand -base64 32
|
openssl rand -base64 32
|
||||||
|
openssl rand -hex 16
|
||||||
# Encryption Key (32 bytes)
|
|
||||||
openssl rand -hex 16 # outputs 32 characters
|
|
||||||
|
|
||||||
# Random token
|
|
||||||
openssl rand -hex 32
|
openssl rand -hex 32
|
||||||
```
|
```
|
||||||
|
|
||||||
## Docker Compose
|
Use generated values for:
|
||||||
|
|
||||||
Environment variables are defined in `docker-compose.yml` and will override `.env` files. Update the file for your deployment:
|
- `JWT_SECRET`
|
||||||
|
- `ENCRYPTION_KEY`
|
||||||
|
- provider secrets or other sensitive credentials stored through admin settings
|
||||||
|
|
||||||
```yaml
|
## Compose Vs Local Development
|
||||||
environment:
|
|
||||||
MONGODB_URI: mongodb://admin:password@mongodb:27017/noteapp?authSource=admin
|
|
||||||
JWT_SECRET: your-secret-key-change-in-production
|
|
||||||
# ... other vars
|
|
||||||
```
|
|
||||||
|
|
||||||
## Kubernetes
|
Use the right env file for the right mode:
|
||||||
|
|
||||||
Use `kubectl create secret` for sensitive data:
|
- root `.env`: Docker Compose
|
||||||
|
- `backend/.env`: local backend
|
||||||
|
- `frontend/.env`: local frontend
|
||||||
|
|
||||||
```bash
|
Do not assume values from one location are automatically shared with the others.
|
||||||
# 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
|
## Important Notes
|
||||||
kubectl create configmap app-config \
|
|
||||||
--from-file=config.yaml \
|
|
||||||
-n noteapp
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
- Do not commit real secrets
|
||||||
|
- Keep `ENCRYPTION_KEY` at 32 characters for the current AES-256 usage
|
||||||
**IMPORTANT**: Never commit .env files or secrets to version control!
|
- If OAuth login is enabled, set `FRONTEND_URL` correctly so callback redirects go to the intended UI
|
||||||
|
- If Redis settings are omitted, the backend assumes a local Redis instance at `localhost:6379`
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ This file lists the permissions currently checked by the application.
|
|||||||
- space.edit
|
- space.edit
|
||||||
- Global space edit capability (used as fallback alongside space-scoped settings edit)
|
- Global space edit capability (used as fallback alongside space-scoped settings edit)
|
||||||
- space.delete
|
- space.delete
|
||||||
- Global space delete capability (used as fallback alongside space-scoped delete)
|
- Global space delete capability (used as fallback alongside space-scoped settings.delete)
|
||||||
|
|
||||||
## Space-Scoped Permission Format
|
## Space-Scoped Permission Format
|
||||||
|
|
||||||
@@ -30,7 +30,7 @@ space.<space_permission_key>.<action>
|
|||||||
### Space Management
|
### Space Management
|
||||||
|
|
||||||
- settings.edit
|
- settings.edit
|
||||||
- delete
|
- settings.delete
|
||||||
|
|
||||||
### Member Management
|
### Member Management
|
||||||
|
|
||||||
|
|||||||
323
QUICKSTART.md
323
QUICKSTART.md
@@ -1,304 +1,151 @@
|
|||||||
# 🚀 Quick Start Guide
|
# Quick Start
|
||||||
|
|
||||||
## Prerequisites
|
This guide covers the fastest way to run Notely and the current local-development workflow.
|
||||||
|
|
||||||
- Docker and Docker Compose (recommended for quickest setup)
|
## Option 1: Docker Compose
|
||||||
- OR: Go 1.21+, Node.js 18+, MongoDB 7.0+
|
|
||||||
|
|
||||||
## Option 1: Docker Compose (Recommended - 1 Command)
|
From the repository root:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Clone/navigate to project
|
cp .env.example .env
|
||||||
cd noteapp
|
docker compose up -d --build
|
||||||
|
|
||||||
# Start everything
|
|
||||||
docker-compose up
|
|
||||||
|
|
||||||
# Wait for services to initialize (~30 seconds)
|
|
||||||
# Then open: http://localhost
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Services running**:
|
Open:
|
||||||
|
|
||||||
- Notely: http://localhost:8080
|
- App UI: `http://localhost`
|
||||||
- MongoDB: localhost:27017
|
- Backend health endpoint: `http://localhost:8080/health`
|
||||||
- Nginx Reverse Proxy: http://localhost:80
|
- MongoDB: `localhost:27017`
|
||||||
|
- Redis: `localhost:6379`
|
||||||
|
|
||||||
**Test user (after startup)**:
|
Compose starts four services:
|
||||||
|
|
||||||
- Register a new account at http://localhost/register
|
- `mongodb`
|
||||||
- Login and create a Space
|
- `redis`
|
||||||
- Add Categories and Notes
|
- `notely`
|
||||||
|
- `nginx`
|
||||||
|
|
||||||
## Option 2: Local Development
|
## Option 2: Local Development
|
||||||
|
|
||||||
### Backend Setup
|
### Prerequisites
|
||||||
|
|
||||||
|
- Go 1.25+
|
||||||
|
- Node.js 18+
|
||||||
|
- MongoDB
|
||||||
|
- Redis
|
||||||
|
|
||||||
|
If you do not already have MongoDB and Redis running locally, you can start just those services with Docker Compose:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d mongodb redis
|
||||||
|
```
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd backend
|
cd backend
|
||||||
|
|
||||||
# Copy environment file
|
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
|
|
||||||
# Install dependencies
|
|
||||||
go mod download
|
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
|
go run ./cmd/server/main.go
|
||||||
|
|
||||||
# Logs should show: "Server starting on port 8080"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Frontend Setup
|
The backend listens on `http://localhost:8080` by default.
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd frontend
|
cd frontend
|
||||||
|
|
||||||
# Copy environment file
|
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
|
|
||||||
# Install dependencies
|
|
||||||
npm install
|
npm install
|
||||||
|
|
||||||
# Start dev server
|
|
||||||
npm run dev
|
npm run dev
|
||||||
|
|
||||||
# Open: http://localhost:5173 in browser
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🧪 Testing
|
The Vite dev server listens on `http://localhost:5173` and proxies `/api` to `http://localhost:8080`.
|
||||||
|
|
||||||
### Backend Tests
|
## Day-To-Day Commands
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd backend
|
cd backend
|
||||||
|
|
||||||
# Run all tests
|
|
||||||
go test ./...
|
go test ./...
|
||||||
|
go test -v ./tests/unit/...
|
||||||
# Run with verbose output
|
go test -v ./tests/integration/...
|
||||||
go test -v ./...
|
|
||||||
|
|
||||||
# Run specific test
|
|
||||||
go test -v -run TestRegisterUser ./tests/unit/...
|
|
||||||
|
|
||||||
# With coverage
|
|
||||||
go test -cover ./...
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Frontend Tests
|
### Frontend
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd frontend
|
cd frontend
|
||||||
|
npm run build
|
||||||
# Run tests
|
npm run lint
|
||||||
npm run test
|
npm run test
|
||||||
|
|
||||||
# Watch mode
|
|
||||||
npm run test:watch
|
|
||||||
|
|
||||||
# Coverage
|
|
||||||
npm run test:coverage
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 📝 Key API Endpoints
|
## First Run Checklist
|
||||||
|
|
||||||
### Authentication
|
1. Register a user or set `DEFAULT_ADMIN_*` values in your env file before startup.
|
||||||
|
2. Sign in.
|
||||||
|
3. Create a space.
|
||||||
|
4. Create categories and notes.
|
||||||
|
5. Use the top search bar to verify note search.
|
||||||
|
|
||||||
```bash
|
## Useful Endpoints
|
||||||
# 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
|
Authentication:
|
||||||
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
|
- `POST /api/v1/auth/register`
|
||||||
```
|
- `POST /api/v1/auth/login`
|
||||||
|
- `POST /api/v1/auth/refresh`
|
||||||
|
- `GET /api/v1/auth/me`
|
||||||
|
|
||||||
### Create Space
|
Spaces:
|
||||||
|
|
||||||
```bash
|
- `GET /api/v1/spaces`
|
||||||
curl -X POST http://localhost:8080/api/v1/spaces \
|
- `POST /api/v1/spaces`
|
||||||
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
|
- `PUT /api/v1/spaces/{spaceId}`
|
||||||
-H "Content-Type: application/json" \
|
- `DELETE /api/v1/spaces/{spaceId}`
|
||||||
-d '{
|
|
||||||
"name": "My First Space",
|
|
||||||
"description": "Notes for my project",
|
|
||||||
"icon": "📚",
|
|
||||||
"is_public": false
|
|
||||||
}'
|
|
||||||
```
|
|
||||||
|
|
||||||
### Create Note
|
Notes:
|
||||||
|
|
||||||
```bash
|
- `GET /api/v1/spaces/{spaceId}/notes`
|
||||||
curl -X POST http://localhost:8080/api/v1/spaces/{spaceId}/notes \
|
- `POST /api/v1/spaces/{spaceId}/notes`
|
||||||
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
|
- `GET /api/v1/spaces/{spaceId}/notes/search?q=<query>`
|
||||||
-H "Content-Type: application/json" \
|
- `POST /api/v1/spaces/{spaceId}/notes/{noteId}/unlock`
|
||||||
-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
|
Public access:
|
||||||
|
|
||||||
```bash
|
- `GET /api/v1/public/spaces`
|
||||||
curl "http://localhost:8080/api/v1/spaces/{spaceId}/notes/search?q=important" \
|
- `GET /api/v1/public/spaces/{spaceId}/notes`
|
||||||
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🔍 Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
### MongoDB Connection Error
|
### Backend cannot connect to MongoDB
|
||||||
|
|
||||||
```
|
Check `MONGODB_URI` in your selected env file and make sure MongoDB is reachable.
|
||||||
Error: Failed to connect to database
|
|
||||||
|
|
||||||
Solution:
|
### Backend cannot connect to Redis
|
||||||
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
|
Check `REDIS_ADDR`, `REDIS_PASSWORD`, and `REDIS_DB`. For local defaults, Redis should usually be reachable at `localhost:6379`.
|
||||||
|
|
||||||
```bash
|
### The browser cannot reach the API in local dev
|
||||||
# Find process on port 8080
|
|
||||||
lsof -i :8080
|
|
||||||
|
|
||||||
# Kill it
|
Check:
|
||||||
kill -9 <PID>
|
|
||||||
|
|
||||||
# Or use different port
|
- backend is running on port `8080`
|
||||||
PORT=8081 go run ./cmd/server/main.go
|
- frontend `VITE_API_BASE_URL`
|
||||||
```
|
- Vite proxy settings in `frontend/vite.config.js`
|
||||||
|
|
||||||
### CORS Errors
|
### OAuth callback redirects to the wrong URL
|
||||||
|
|
||||||
Make sure frontend and backend URLs match in:
|
Check `FRONTEND_URL` in your selected env file.
|
||||||
|
|
||||||
- Frontend: `VITE_API_BASE_URL` in `.env`
|
### Permission-denied behavior is unclear
|
||||||
- Backend: `CORS_ALLOWED_ORIGINS` in `.env`
|
|
||||||
|
|
||||||
### MongoDB Auth Failed
|
Read `PERMISSIONS.md` and then inspect the relevant backend service in `backend/internal/application/services/`.
|
||||||
|
|
||||||
If using Docker Compose:
|
## Related Docs
|
||||||
|
|
||||||
- Username: `admin`
|
- `README.md`
|
||||||
- Password: `password`
|
- `ENV_SETUP.md`
|
||||||
- Connection string includes `?authSource=admin`
|
- `PERMISSIONS.md`
|
||||||
|
|
||||||
## 📚 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! 🎉**
|
|
||||||
|
|||||||
543
README.md
543
README.md
@@ -1,306 +1,174 @@
|
|||||||
# Notely - Secure Multi-Space Note-Taking Application
|
# Notely
|
||||||
|
|
||||||
A production-ready, secure multi-tenant note-taking platform built with Go, Vue 3, and MongoDB.
|
Notely is a multi-space note application built with Go, Vue 3, MongoDB, and Redis.
|
||||||
|
|
||||||
## 🚀 Quick Start
|
The repository contains a Go backend, a Vue frontend, Docker Compose assets for local deployment, and Kubernetes manifests for cluster deployment. In containerized environments, the frontend is built into the backend image and served by the Go server. Docker Compose also places Nginx in front of the app for HTTP and HTTPS entry points.
|
||||||
|
|
||||||
### Prerequisites
|
## What Is In This Repo
|
||||||
|
|
||||||
- Docker & Docker Compose
|
- Backend API in `backend/`
|
||||||
- Go 1.21+ (for local development)
|
- Frontend SPA in `frontend/`
|
||||||
- Node.js 18+ (for frontend development)
|
- Docker and Nginx assets in `devops/docker/`
|
||||||
- MongoDB 7.0+ (for local development)
|
- Kubernetes manifests in `devops/kubernetes/`
|
||||||
|
- Root documentation in `README.md`, `QUICKSTART.md`, `ENV_SETUP.md`, and `PERMISSIONS.md`
|
||||||
|
|
||||||
### Development with Docker Compose
|
## Core Features
|
||||||
|
|
||||||
|
- Email/password authentication
|
||||||
|
- Session cookies backed by Redis, with bearer-token fallback for API clients
|
||||||
|
- Admin bootstrap from environment variables
|
||||||
|
- Permission-based authorization with wildcard support
|
||||||
|
- Spaces, categories, and notes
|
||||||
|
- Full-text note search
|
||||||
|
- Public spaces and public notes
|
||||||
|
- Password-protected notes
|
||||||
|
- OAuth/OIDC provider support
|
||||||
|
- Feature flags for registration, provider login, public sharing, and file explorer support
|
||||||
|
- Optional S3-compatible file explorer when enabled through feature flags
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
- Language: Go
|
||||||
|
- Module: `gitea.hostxtra.co.uk/mrhid6/notely/backend`
|
||||||
|
- Entry point: `backend/cmd/server/main.go`
|
||||||
|
- Architecture style: domain/application/infrastructure/interfaces split
|
||||||
|
- Storage: MongoDB
|
||||||
|
- Session store: Redis
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
- Framework: Vue 3
|
||||||
|
- Router: Vue Router
|
||||||
|
- State: Pinia
|
||||||
|
- Build tool: Vite
|
||||||
|
|
||||||
|
### Container Layout
|
||||||
|
|
||||||
|
- `devops/docker/Dockerfile` builds the frontend and backend into a single app image
|
||||||
|
- `docker-compose.yml` starts:
|
||||||
|
- `mongodb`
|
||||||
|
- `redis`
|
||||||
|
- `notely` (combined app image)
|
||||||
|
- `nginx`
|
||||||
|
|
||||||
|
## Documentation Map
|
||||||
|
|
||||||
|
- `README.md`: project overview and current architecture
|
||||||
|
- `QUICKSTART.md`: fast setup and day-to-day development commands
|
||||||
|
- `ENV_SETUP.md`: environment-variable reference and configuration layout
|
||||||
|
- `PERMISSIONS.md`: enforced permission model and naming
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
### Docker Compose
|
||||||
|
|
||||||
|
1. Copy the root environment file:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Start all services
|
cp .env.example .env
|
||||||
docker-compose up
|
|
||||||
|
|
||||||
# Backend: http://localhost:8080
|
|
||||||
# Frontend: http://localhost:5173
|
|
||||||
# MongoDB: localhost:27017
|
|
||||||
# Nginx: http://localhost:80
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Local Development Setup
|
2. Start the stack:
|
||||||
|
|
||||||
#### Backend
|
```bash
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Open the app:
|
||||||
|
|
||||||
|
- UI through Nginx: `http://localhost`
|
||||||
|
- Backend health check: `http://localhost:8080/health`
|
||||||
|
- MongoDB: `localhost:27017`
|
||||||
|
- Redis: `localhost:6379`
|
||||||
|
|
||||||
|
### Local Development
|
||||||
|
|
||||||
|
Prerequisites:
|
||||||
|
|
||||||
|
- Go 1.25+
|
||||||
|
- Node.js 18+
|
||||||
|
- MongoDB
|
||||||
|
- Redis
|
||||||
|
|
||||||
|
Backend:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd backend
|
cd backend
|
||||||
|
cp .env.example .env
|
||||||
# Install dependencies
|
|
||||||
go mod download
|
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
|
go run ./cmd/server/main.go
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Frontend
|
Frontend:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd frontend
|
cd frontend
|
||||||
|
cp .env.example .env
|
||||||
# Install dependencies
|
|
||||||
npm install
|
npm install
|
||||||
|
|
||||||
# Start development server
|
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
## 📚 Architecture
|
Local frontend development runs at `http://localhost:5173` and proxies `/api` requests to `http://localhost:8080`.
|
||||||
|
|
||||||
### Backend (GoClean Architecture)
|
## API Surface
|
||||||
|
|
||||||
```
|
The router in `backend/cmd/server/main.go` currently exposes these endpoint groups.
|
||||||
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)
|
### Public Endpoints
|
||||||
|
|
||||||
```
|
- `GET /health`
|
||||||
frontend/
|
- `POST /api/v1/auth/register`
|
||||||
├── src/
|
- `POST /api/v1/auth/login`
|
||||||
│ ├── components/ # Reusable Vue components
|
- `POST /api/v1/auth/refresh`
|
||||||
│ ├── pages/ # Page components
|
- `POST /api/v1/auth/logout`
|
||||||
│ ├── stores/ # Pinia state management
|
- `GET /api/v1/auth/providers`
|
||||||
│ ├── services/ # API client
|
- `GET /api/v1/auth/providers/{providerId}/start`
|
||||||
│ ├── router/ # Vue Router config
|
- `GET /api/v1/auth/providers/{providerId}/callback`
|
||||||
│ ├── assets/ # Styles and assets
|
- `GET /api/v1/settings/feature-flags`
|
||||||
│ └── main.js # Entry point
|
- `GET /api/v1/public/spaces`
|
||||||
├── index.html
|
- `GET /api/v1/public/spaces/{spaceId}`
|
||||||
└── vite.config.js
|
- `GET /api/v1/public/spaces/{spaceId}/notes`
|
||||||
```
|
- `GET /api/v1/public/spaces/{spaceId}/notes/{noteId}`
|
||||||
|
- `POST /api/v1/public/spaces/{spaceId}/notes/{noteId}/unlock`
|
||||||
|
|
||||||
## 🔐 Security Features
|
### Authenticated User Endpoints
|
||||||
|
|
||||||
### Authentication
|
- `GET /api/v1/auth/me`
|
||||||
|
- Space CRUD under `/api/v1/spaces`
|
||||||
|
- Space member management under `/api/v1/spaces/{spaceId}/members`
|
||||||
|
- Note CRUD, search, and unlock under `/api/v1/spaces/{spaceId}/notes`
|
||||||
|
- Category CRUD and move under `/api/v1/spaces/{spaceId}/categories`
|
||||||
|
- File explorer operations under `/api/v1/spaces/{spaceId}/files`
|
||||||
|
|
||||||
- **Argon2id password hashing** - Industry-standard PBKDF2
|
### Admin Endpoints
|
||||||
- **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
|
Admin routes live under `/api/v1/admin` and cover:
|
||||||
|
|
||||||
- **Role-based access control (RBAC)** per space:
|
- users
|
||||||
- Owner: Full control
|
- groups
|
||||||
- Editor: Edit notes and categories
|
- spaces
|
||||||
- Viewer: Read-only access
|
- feature flags
|
||||||
- **Space-level data isolation** - all queries include space_id
|
- auth providers
|
||||||
- **IDOR prevention** - middleware enforces ownership verification
|
|
||||||
|
|
||||||
### Data Security
|
## Permissions
|
||||||
|
|
||||||
- **Encryption at rest** for sensitive fields (OAuth secrets)
|
Notely uses permission-based authorization, not fixed owner/editor/viewer roles.
|
||||||
- **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
|
- Global permissions include `space.create`, `space.edit`, and `space.delete`
|
||||||
|
- Space-scoped permissions follow `space.<space_key>.<action>`
|
||||||
|
- Example: `space.product_docs.note.create`
|
||||||
|
- Example: `space.product_docs.settings.delete`
|
||||||
|
- Space deletion requires either:
|
||||||
|
- global `space.delete`, or
|
||||||
|
- space-scoped `space.<space_key>.settings.delete`
|
||||||
|
|
||||||
- **Rate limiting** - IP-based and user-based
|
See `PERMISSIONS.md` for the current enforced permission set.
|
||||||
- **Security headers** - HSTS, X-Frame-Options, X-Content-Type-Options
|
|
||||||
- **CORS properly configured** - whitelist origin domains
|
|
||||||
- **Input validation** on all endpoints
|
|
||||||
|
|
||||||
## 📦 API Endpoints
|
## Testing And Quality Checks
|
||||||
|
|
||||||
### Authentication
|
Backend:
|
||||||
|
|
||||||
```
|
|
||||||
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
|
```bash
|
||||||
cd backend
|
cd backend
|
||||||
@@ -309,118 +177,73 @@ go test -v ./tests/unit/...
|
|||||||
go test -v ./tests/integration/...
|
go test -v ./tests/integration/...
|
||||||
```
|
```
|
||||||
|
|
||||||
### Frontend Tests
|
Frontend:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd frontend
|
cd frontend
|
||||||
|
npm run build
|
||||||
|
npm run lint
|
||||||
npm run test
|
npm run test
|
||||||
npm run test:watch
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🔧 Configuration
|
## Deployment Notes
|
||||||
|
|
||||||
### Environment Variables
|
### Docker Compose
|
||||||
|
|
||||||
#### Backend (.env)
|
Docker Compose uses the combined application image plus Nginx, MongoDB, and Redis. Configuration is driven by the root `.env` file.
|
||||||
|
|
||||||
```
|
### Kubernetes
|
||||||
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)
|
The manifest at `devops/kubernetes/deployment.yaml` currently provisions:
|
||||||
|
|
||||||
```
|
- `noteapp` namespace
|
||||||
VITE_API_BASE_URL=http://localhost:8080
|
- MongoDB StatefulSet and PVC
|
||||||
```
|
- single `noteapp` Deployment for the combined app image
|
||||||
|
- ClusterIP services
|
||||||
|
- Ingress
|
||||||
|
- HorizontalPodAutoscaler
|
||||||
|
|
||||||
## 📝 Development Guidelines
|
Apply it with:
|
||||||
|
|
||||||
### 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
|
```bash
|
||||||
# Example: Create Note
|
kubectl apply -f devops/kubernetes/deployment.yaml
|
||||||
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
|
## Current Repo Layout
|
||||||
|
|
||||||
All errors return appropriate HTTP status codes:
|
```text
|
||||||
|
noteapp/
|
||||||
|
├── backend/
|
||||||
|
│ ├── cmd/server/
|
||||||
|
│ ├── internal/
|
||||||
|
│ ├── pkg/
|
||||||
|
│ ├── tests/
|
||||||
|
│ └── .env.example
|
||||||
|
├── frontend/
|
||||||
|
│ ├── src/
|
||||||
|
│ ├── tests/
|
||||||
|
│ ├── package.json
|
||||||
|
│ ├── vite.config.js
|
||||||
|
│ ├── vitest.config.js
|
||||||
|
│ └── .env.example
|
||||||
|
├── devops/
|
||||||
|
│ ├── docker/
|
||||||
|
│ │ ├── Dockerfile
|
||||||
|
│ │ ├── nginx.conf
|
||||||
|
│ │ └── ssl/
|
||||||
|
│ └── kubernetes/
|
||||||
|
│ └── deployment.yaml
|
||||||
|
├── docker-compose.yml
|
||||||
|
├── .env.example
|
||||||
|
├── ENV_SETUP.md
|
||||||
|
├── PERMISSIONS.md
|
||||||
|
├── QUICKSTART.md
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
- `400` - Bad Request
|
## Notes For Contributors
|
||||||
- `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
|
- Check `PERMISSIONS.md` when changing authorization behavior
|
||||||
|
- Check `ENV_SETUP.md` when adding or changing configuration
|
||||||
- [ ] OAuth2/OIDC integration
|
- Check `backend/cmd/server/main.go` before documenting routes
|
||||||
- [ ] Email notifications
|
- Keep docs aligned with actual package scripts and checked-in files
|
||||||
- [ ] 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**
|
|
||||||
|
|||||||
284
SECURITY.md
284
SECURITY.md
@@ -1,284 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -26,3 +26,9 @@ CORS_ALLOWED_ORIGINS=http://localhost:5173,http://localhost:3000
|
|||||||
# Rate Limiting
|
# Rate Limiting
|
||||||
RATE_LIMIT_REQUESTS=50
|
RATE_LIMIT_REQUESTS=50
|
||||||
RATE_LIMIT_WINDOW=1s
|
RATE_LIMIT_WINDOW=1s
|
||||||
|
|
||||||
|
# Redis Sessions
|
||||||
|
REDIS_ADDR=localhost:6379
|
||||||
|
REDIS_PASSWORD=
|
||||||
|
REDIS_DB=0
|
||||||
|
SESSION_TTL_HOURS=168
|
||||||
|
|||||||
@@ -6,19 +6,21 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/application/services"
|
||||||
|
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/domain/entities"
|
||||||
|
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/domain/repositories"
|
||||||
|
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/infrastructure/auth"
|
||||||
|
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/infrastructure/database"
|
||||||
|
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/infrastructure/security"
|
||||||
|
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/interfaces/handlers"
|
||||||
|
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/interfaces/middleware"
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
"github.com/joho/godotenv"
|
"github.com/joho/godotenv"
|
||||||
"github.com/noteapp/backend/internal/application/services"
|
"github.com/redis/go-redis/v9"
|
||||||
"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"
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -47,6 +49,31 @@ func main() {
|
|||||||
port = "8080"
|
port = "8080"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
redisAddr := os.Getenv("REDIS_ADDR")
|
||||||
|
if redisAddr == "" {
|
||||||
|
redisAddr = "localhost:6379"
|
||||||
|
}
|
||||||
|
|
||||||
|
redisUser := os.Getenv("REDIS_USER")
|
||||||
|
redisPassword := os.Getenv("REDIS_PASSWORD")
|
||||||
|
redisDB := 0
|
||||||
|
if redisDBText := os.Getenv("REDIS_DB"); redisDBText != "" {
|
||||||
|
parsedDB, err := strconv.Atoi(redisDBText)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("invalid REDIS_DB value: %v", err)
|
||||||
|
}
|
||||||
|
redisDB = parsedDB
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionTTL := 7 * 24 * time.Hour
|
||||||
|
if sessionTTLText := os.Getenv("SESSION_TTL_HOURS"); sessionTTLText != "" {
|
||||||
|
hours, err := strconv.Atoi(sessionTTLText)
|
||||||
|
if err != nil || hours <= 0 {
|
||||||
|
log.Fatalf("invalid SESSION_TTL_HOURS value: %q", sessionTTLText)
|
||||||
|
}
|
||||||
|
sessionTTL = time.Duration(hours) * time.Hour
|
||||||
|
}
|
||||||
|
|
||||||
// Connect to database
|
// Connect to database
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
@@ -57,6 +84,20 @@ func main() {
|
|||||||
}
|
}
|
||||||
defer db.Close(context.Background())
|
defer db.Close(context.Background())
|
||||||
|
|
||||||
|
redisClient := redis.NewClient(&redis.Options{
|
||||||
|
Addr: redisAddr,
|
||||||
|
Username: redisUser,
|
||||||
|
Password: redisPassword,
|
||||||
|
DB: redisDB,
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := redisClient.Ping(context.Background()).Err(); err != nil {
|
||||||
|
log.Fatalf("failed to connect to redis: %v", err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_ = redisClient.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
// Initialize security components
|
// Initialize security components
|
||||||
passwordHasher := security.NewPasswordHasher()
|
passwordHasher := security.NewPasswordHasher()
|
||||||
encryptor, err := security.NewEncryptor(encryptionKey)
|
encryptor, err := security.NewEncryptor(encryptionKey)
|
||||||
@@ -66,6 +107,7 @@ func main() {
|
|||||||
|
|
||||||
// Initialize JWT manager
|
// Initialize JWT manager
|
||||||
jwtManager := auth.NewJWTManager(jwtSecret, "noteapp", 1*time.Hour)
|
jwtManager := auth.NewJWTManager(jwtSecret, "noteapp", 1*time.Hour)
|
||||||
|
sessionManager := auth.NewSessionManager(redisClient, sessionTTL)
|
||||||
|
|
||||||
// Initialize services
|
// Initialize services
|
||||||
permissionService := services.NewPermissionService(
|
permissionService := services.NewPermissionService(
|
||||||
@@ -143,7 +185,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Initialize handlers
|
// Initialize handlers
|
||||||
authHandler := handlers.NewAuthHandler(authService)
|
authHandler := handlers.NewAuthHandler(authService, sessionManager)
|
||||||
spaceHandler := handlers.NewSpaceHandler(spaceService)
|
spaceHandler := handlers.NewSpaceHandler(spaceService)
|
||||||
noteHandler := handlers.NewNoteHandler(noteService)
|
noteHandler := handlers.NewNoteHandler(noteService)
|
||||||
categoryHandler := handlers.NewCategoryHandler(categoryService)
|
categoryHandler := handlers.NewCategoryHandler(categoryService)
|
||||||
@@ -160,7 +202,7 @@ func main() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Middleware
|
// Middleware
|
||||||
authMiddleware := middleware.NewAuthMiddleware(jwtManager)
|
authMiddleware := middleware.NewAuthMiddleware(jwtManager, sessionManager)
|
||||||
router.Use(middleware.LoggingMiddleware)
|
router.Use(middleware.LoggingMiddleware)
|
||||||
router.Use(middleware.CORSMiddleware)
|
router.Use(middleware.CORSMiddleware)
|
||||||
router.Use(middleware.SecurityHeaders)
|
router.Use(middleware.SecurityHeaders)
|
||||||
@@ -187,6 +229,7 @@ func main() {
|
|||||||
// Protected endpoints
|
// Protected endpoints
|
||||||
api := router.PathPrefix("/api/v1").Subrouter()
|
api := router.PathPrefix("/api/v1").Subrouter()
|
||||||
api.Use(authMiddleware.Middleware)
|
api.Use(authMiddleware.Middleware)
|
||||||
|
api.HandleFunc("/auth/me", authHandler.Me).Methods("GET")
|
||||||
|
|
||||||
// Space endpoints
|
// Space endpoints
|
||||||
api.HandleFunc("/spaces", spaceHandler.GetUserSpaces).Methods("GET")
|
api.HandleFunc("/spaces", spaceHandler.GetUserSpaces).Methods("GET")
|
||||||
@@ -273,6 +316,7 @@ func main() {
|
|||||||
admin.HandleFunc("/feature-flags", adminHandler.GetFeatureFlags).Methods("GET")
|
admin.HandleFunc("/feature-flags", adminHandler.GetFeatureFlags).Methods("GET")
|
||||||
admin.HandleFunc("/feature-flags", adminHandler.UpdateFeatureFlags).Methods("PUT")
|
admin.HandleFunc("/feature-flags", adminHandler.UpdateFeatureFlags).Methods("PUT")
|
||||||
// manage identity providers — admin-only
|
// manage identity providers — admin-only
|
||||||
|
admin.HandleFunc("/auth/providers", authHandler.ListProvidersForAdmin).Methods("GET")
|
||||||
admin.HandleFunc("/auth/providers", authHandler.CreateProvider).Methods("POST")
|
admin.HandleFunc("/auth/providers", authHandler.CreateProvider).Methods("POST")
|
||||||
admin.HandleFunc("/auth/providers/{providerId}", authHandler.UpdateProvider).Methods("PUT")
|
admin.HandleFunc("/auth/providers/{providerId}", authHandler.UpdateProvider).Methods("PUT")
|
||||||
admin.HandleFunc("/auth/providers/{providerId}", adminHandler.DeleteProvider).Methods("DELETE")
|
admin.HandleFunc("/auth/providers/{providerId}", adminHandler.DeleteProvider).Methods("DELETE")
|
||||||
|
|||||||
@@ -1,20 +1,22 @@
|
|||||||
module github.com/noteapp/backend
|
module gitea.hostxtra.co.uk/mrhid6/notely/backend
|
||||||
|
|
||||||
go 1.25.0
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/aws/aws-sdk-go-v2 v1.41.4
|
||||||
|
github.com/aws/aws-sdk-go-v2/credentials v1.19.12
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.97.2
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.0
|
github.com/golang-jwt/jwt/v5 v5.2.0
|
||||||
github.com/gorilla/mux v1.8.1
|
github.com/gorilla/mux v1.8.1
|
||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
|
github.com/redis/go-redis/v9 v9.18.0
|
||||||
go.mongodb.org/mongo-driver/v2 v2.5.0
|
go.mongodb.org/mongo-driver/v2 v2.5.0
|
||||||
golang.org/x/crypto v0.49.0
|
golang.org/x/crypto v0.49.0
|
||||||
golang.org/x/oauth2 v0.30.0
|
golang.org/x/oauth2 v0.30.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/aws/aws-sdk-go-v2 v1.41.4 // indirect
|
|
||||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.12 // indirect
|
|
||||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20 // indirect
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20 // indirect
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.21 // indirect
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.21 // indirect
|
||||||
@@ -22,13 +24,15 @@ require (
|
|||||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.12 // indirect
|
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.12 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20 // indirect
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.20 // indirect
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.20 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.97.2 // indirect
|
|
||||||
github.com/aws/smithy-go v1.24.2 // indirect
|
github.com/aws/smithy-go v1.24.2 // indirect
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||||
github.com/klauspost/compress v1.17.6 // indirect
|
github.com/klauspost/compress v1.17.6 // indirect
|
||||||
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
||||||
github.com/xdg-go/scram v1.2.0 // indirect
|
github.com/xdg-go/scram v1.2.0 // indirect
|
||||||
github.com/xdg-go/stringprep v1.0.4 // indirect
|
github.com/xdg-go/stringprep v1.0.4 // indirect
|
||||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
||||||
|
go.uber.org/atomic v1.11.0 // indirect
|
||||||
golang.org/x/sync v0.20.0 // indirect
|
golang.org/x/sync v0.20.0 // indirect
|
||||||
golang.org/x/sys v0.42.0 // indirect
|
golang.org/x/sys v0.42.0 // indirect
|
||||||
golang.org/x/text v0.35.0 // indirect
|
golang.org/x/text v0.35.0 // indirect
|
||||||
|
|||||||
@@ -22,8 +22,16 @@ github.com/aws/aws-sdk-go-v2/service/s3 v1.97.2 h1:MRNiP6nqa20aEl8fQ6PJpEq11b2d4
|
|||||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.97.2/go.mod h1:FrNA56srbsr3WShiaelyWYEo70x80mXnVZ17ZZfbeqg=
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.97.2/go.mod h1:FrNA56srbsr3WShiaelyWYEo70x80mXnVZ17ZZfbeqg=
|
||||||
github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
|
github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
|
||||||
github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||||
|
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||||
|
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||||
|
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||||
|
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
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/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||||
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw=
|
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/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 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
@@ -34,6 +42,14 @@ github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
|||||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
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 h1:60eq2E/jlfwQXtvZEeBUYADs+BwKBWURIY+Gj2eRGjI=
|
||||||
github.com/klauspost/compress v1.17.6/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
|
github.com/klauspost/compress v1.17.6/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
|
||||||
|
github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=
|
||||||
|
github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
|
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/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 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs=
|
||||||
@@ -43,8 +59,12 @@ github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gi
|
|||||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
|
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/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
|
||||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
|
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
|
||||||
|
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
|
||||||
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
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=
|
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||||
|
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||||
|
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
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.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 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package dto
|
package dto
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/noteapp/backend/internal/domain/entities"
|
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/domain/entities"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ========== AUTH DTOs ==========
|
// ========== AUTH DTOs ==========
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ import (
|
|||||||
|
|
||||||
"go.mongodb.org/mongo-driver/v2/bson"
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
|
|
||||||
"github.com/noteapp/backend/internal/application/dto"
|
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/application/dto"
|
||||||
"github.com/noteapp/backend/internal/domain/entities"
|
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/domain/entities"
|
||||||
"github.com/noteapp/backend/internal/domain/repositories"
|
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/domain/repositories"
|
||||||
"github.com/noteapp/backend/internal/infrastructure/security"
|
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/infrastructure/security"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AdminService handles admin-level operations
|
// AdminService handles admin-level operations
|
||||||
|
|||||||
@@ -12,11 +12,11 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/noteapp/backend/internal/application/dto"
|
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/application/dto"
|
||||||
"github.com/noteapp/backend/internal/domain/entities"
|
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/domain/entities"
|
||||||
"github.com/noteapp/backend/internal/domain/repositories"
|
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/domain/repositories"
|
||||||
"github.com/noteapp/backend/internal/infrastructure/auth"
|
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/infrastructure/auth"
|
||||||
"github.com/noteapp/backend/internal/infrastructure/security"
|
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/infrastructure/security"
|
||||||
"go.mongodb.org/mongo-driver/v2/bson"
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
"golang.org/x/oauth2"
|
"golang.org/x/oauth2"
|
||||||
)
|
)
|
||||||
@@ -114,22 +114,9 @@ func (s *AuthService) Register(ctx context.Context, req *dto.RegisterRequest) (*
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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{
|
return &dto.LoginResponse{
|
||||||
AccessToken: accessToken,
|
User: dto.NewUserDTO(user),
|
||||||
RefreshToken: refreshToken,
|
ExpiresIn: 3600, // 1 hour
|
||||||
User: dto.NewUserDTO(user),
|
|
||||||
ExpiresIn: 3600, // 1 hour
|
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,27 +152,18 @@ func (s *AuthService) Login(ctx context.Context, req *dto.LoginRequest) (*dto.Lo
|
|||||||
// Log error but don't fail the login
|
// 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{
|
return &dto.LoginResponse{
|
||||||
AccessToken: accessToken,
|
User: dto.NewUserDTO(user),
|
||||||
RefreshToken: refreshToken,
|
ExpiresIn: 3600,
|
||||||
User: dto.NewUserDTO(user),
|
|
||||||
ExpiresIn: 3600,
|
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RefreshAccessToken refreshes an access token
|
// RefreshAccessToken refreshes an access token
|
||||||
func (s *AuthService) RefreshAccessToken(ctx context.Context, refreshToken string) (string, error) {
|
func (s *AuthService) RefreshAccessToken(ctx context.Context, refreshToken string) (string, error) {
|
||||||
|
if s.jwtManager == nil {
|
||||||
|
return "", errors.New("jwt refresh is unavailable")
|
||||||
|
}
|
||||||
|
|
||||||
claims, err := s.jwtManager.VerifyRefreshToken(refreshToken)
|
claims, err := s.jwtManager.VerifyRefreshToken(refreshToken)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -199,6 +177,27 @@ func (s *AuthService) RefreshAccessToken(ctx context.Context, refreshToken strin
|
|||||||
return s.jwtManager.GenerateAccessToken(user.ID.Hex(), user.Email, user.Username)
|
return s.jwtManager.GenerateAccessToken(user.ID.Hex(), user.Email, user.Username)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetUserProfile returns profile DTO for the provided user ID.
|
||||||
|
func (s *AuthService) GetUserProfile(ctx context.Context, userID string) (*dto.UserDTO, error) {
|
||||||
|
objID, err := bson.ObjectIDFromHex(strings.TrimSpace(userID))
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("invalid user id")
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := s.userRepo.GetUserByID(ctx, objID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.permissionService != nil {
|
||||||
|
if err := s.permissionService.UpdateUserEffectivePermissions(ctx, user); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return dto.NewUserDTO(user), nil
|
||||||
|
}
|
||||||
|
|
||||||
// RequestPasswordReset initiates password reset flow
|
// RequestPasswordReset initiates password reset flow
|
||||||
func (s *AuthService) RequestPasswordReset(ctx context.Context, email string) error {
|
func (s *AuthService) RequestPasswordReset(ctx context.Context, email string) error {
|
||||||
user, err := s.userRepo.GetUserByEmail(ctx, email)
|
user, err := s.userRepo.GetUserByEmail(ctx, email)
|
||||||
@@ -260,6 +259,25 @@ func (s *AuthService) ListProviders(ctx context.Context) ([]*dto.AuthProviderDTO
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListProvidersForAdmin returns all OAuth/OIDC providers, including inactive ones.
|
||||||
|
func (s *AuthService) ListProvidersForAdmin(ctx context.Context) ([]*dto.AuthProviderDTO, error) {
|
||||||
|
if s.providerRepo == nil {
|
||||||
|
return []*dto.AuthProviderDTO{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
providers, err := s.providerRepo.GetAllProvidersForAdmin(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.
|
// GetFeatureFlags returns current app-wide feature flags.
|
||||||
func (s *AuthService) GetFeatureFlags(ctx context.Context) (*dto.FeatureFlagsDTO, error) {
|
func (s *AuthService) GetFeatureFlags(ctx context.Context) (*dto.FeatureFlagsDTO, error) {
|
||||||
if s.featureFlagRepo == nil {
|
if s.featureFlagRepo == nil {
|
||||||
@@ -444,17 +462,7 @@ func (s *AuthService) CompleteProviderLogin(ctx context.Context, providerID bson
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
accessToken, err := s.jwtManager.GenerateAccessToken(user.ID.Hex(), user.Email, user.Username)
|
return &dto.LoginResponse{User: dto.NewUserDTO(user), ExpiresIn: 3600}, nil
|
||||||
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 {
|
type providerProfile struct {
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ import (
|
|||||||
|
|
||||||
"go.mongodb.org/mongo-driver/v2/bson"
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
|
|
||||||
"github.com/noteapp/backend/internal/application/dto"
|
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/application/dto"
|
||||||
"github.com/noteapp/backend/internal/domain/entities"
|
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/domain/entities"
|
||||||
"github.com/noteapp/backend/internal/domain/repositories"
|
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/domain/repositories"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CategoryService handles category operations
|
// CategoryService handles category operations
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ import (
|
|||||||
"github.com/aws/aws-sdk-go-v2/service/s3/types"
|
"github.com/aws/aws-sdk-go-v2/service/s3/types"
|
||||||
"go.mongodb.org/mongo-driver/v2/bson"
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
|
|
||||||
"github.com/noteapp/backend/internal/domain/repositories"
|
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/domain/repositories"
|
||||||
"github.com/noteapp/backend/internal/infrastructure/security"
|
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/infrastructure/security"
|
||||||
)
|
)
|
||||||
|
|
||||||
// S3Object represents a file or folder entry with key relative to the space root.
|
// S3Object represents a file or folder entry with key relative to the space root.
|
||||||
|
|||||||
@@ -6,10 +6,10 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/noteapp/backend/internal/application/dto"
|
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/application/dto"
|
||||||
"github.com/noteapp/backend/internal/domain/entities"
|
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/domain/entities"
|
||||||
"github.com/noteapp/backend/internal/domain/repositories"
|
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/domain/repositories"
|
||||||
"github.com/noteapp/backend/internal/infrastructure/security"
|
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/infrastructure/security"
|
||||||
"go.mongodb.org/mongo-driver/v2/bson"
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/noteapp/backend/internal/domain/entities"
|
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/domain/entities"
|
||||||
"github.com/noteapp/backend/internal/domain/repositories"
|
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/domain/repositories"
|
||||||
"go.mongodb.org/mongo-driver/v2/bson"
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/noteapp/backend/internal/application/dto"
|
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/application/dto"
|
||||||
"github.com/noteapp/backend/internal/domain/entities"
|
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/domain/entities"
|
||||||
"github.com/noteapp/backend/internal/domain/repositories"
|
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/domain/repositories"
|
||||||
"go.mongodb.org/mongo-driver/v2/bson"
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package repositories
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
"github.com/noteapp/backend/internal/domain/entities"
|
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/domain/entities"
|
||||||
"go.mongodb.org/mongo-driver/v2/bson"
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package repositories
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
"github.com/noteapp/backend/internal/domain/entities"
|
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/domain/entities"
|
||||||
"go.mongodb.org/mongo-driver/v2/bson"
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -174,6 +174,9 @@ type AuthProviderRepository interface {
|
|||||||
// GetAllProviders retrieves all active providers
|
// GetAllProviders retrieves all active providers
|
||||||
GetAllProviders(ctx context.Context) ([]*entities.AuthProvider, error)
|
GetAllProviders(ctx context.Context) ([]*entities.AuthProvider, error)
|
||||||
|
|
||||||
|
// GetAllProvidersForAdmin retrieves all providers, including inactive ones
|
||||||
|
GetAllProvidersForAdmin(ctx context.Context) ([]*entities.AuthProvider, error)
|
||||||
|
|
||||||
// UpdateProvider updates a provider
|
// UpdateProvider updates a provider
|
||||||
UpdateProvider(ctx context.Context, provider *entities.AuthProvider) error
|
UpdateProvider(ctx context.Context, provider *entities.AuthProvider) error
|
||||||
|
|
||||||
|
|||||||
114
backend/internal/infrastructure/auth/session.go
Normal file
114
backend/internal/infrastructure/auth/session.go
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SessionData stores authenticated identity data in Redis.
|
||||||
|
type SessionData struct {
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SessionManager handles Redis-backed session lifecycle operations.
|
||||||
|
type SessionManager struct {
|
||||||
|
redis *redis.Client
|
||||||
|
ttl time.Duration
|
||||||
|
prefix string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSessionManager(redisClient *redis.Client, ttl time.Duration) *SessionManager {
|
||||||
|
if ttl <= 0 {
|
||||||
|
ttl = 7 * 24 * time.Hour
|
||||||
|
}
|
||||||
|
|
||||||
|
return &SessionManager{
|
||||||
|
redis: redisClient,
|
||||||
|
ttl: ttl,
|
||||||
|
prefix: "session:",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *SessionManager) TTL() time.Duration {
|
||||||
|
return m.ttl
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *SessionManager) CreateSession(ctx context.Context, data *SessionData) (string, error) {
|
||||||
|
if data == nil {
|
||||||
|
return "", errors.New("session data is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionID, err := GenerateRandomToken(32)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
payload, err := json.Marshal(data)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := m.redis.Set(ctx, m.key(sessionID), payload, m.ttl).Err(); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return sessionID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *SessionManager) GetSession(ctx context.Context, sessionID string) (*SessionData, error) {
|
||||||
|
if sessionID == "" {
|
||||||
|
return nil, errors.New("session id is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
payload, err := m.redis.Get(ctx, m.key(sessionID)).Result()
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, redis.Nil) {
|
||||||
|
return nil, errors.New("session not found")
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var data SessionData
|
||||||
|
if err := json.Unmarshal([]byte(payload), &data); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *SessionManager) RefreshSession(ctx context.Context, sessionID string) error {
|
||||||
|
if sessionID == "" {
|
||||||
|
return errors.New("session id is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := m.redis.Expire(ctx, m.key(sessionID), m.ttl).Err(); err != nil {
|
||||||
|
if errors.Is(err, redis.Nil) {
|
||||||
|
return errors.New("session not found")
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *SessionManager) DeleteSession(ctx context.Context, sessionID string) error {
|
||||||
|
if sessionID == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := m.redis.Del(ctx, m.key(sessionID)).Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *SessionManager) key(sessionID string) string {
|
||||||
|
return m.prefix + sessionID
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||||
|
|
||||||
"github.com/noteapp/backend/internal/domain/entities"
|
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/domain/entities"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AccountRecoveryRepository implements account recovery operations
|
// AccountRecoveryRepository implements account recovery operations
|
||||||
@@ -222,6 +222,23 @@ func (r *AuthProviderRepository) GetAllProviders(ctx context.Context) ([]*entiti
|
|||||||
return providers, nil
|
return providers, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetAllProvidersForAdmin retrieves all providers, including inactive ones
|
||||||
|
func (r *AuthProviderRepository) GetAllProvidersForAdmin(ctx context.Context) ([]*entities.AuthProvider, error) {
|
||||||
|
var providers []*entities.AuthProvider
|
||||||
|
|
||||||
|
cursor, err := r.collection.Find(ctx, bson.M{})
|
||||||
|
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
|
// UpdateProvider updates a provider
|
||||||
func (r *AuthProviderRepository) UpdateProvider(ctx context.Context, provider *entities.AuthProvider) error {
|
func (r *AuthProviderRepository) UpdateProvider(ctx context.Context, provider *entities.AuthProvider) error {
|
||||||
provider.UpdatedAt = time.Now()
|
provider.UpdatedAt = time.Now()
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/noteapp/backend/internal/domain/entities"
|
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/domain/entities"
|
||||||
"go.mongodb.org/mongo-driver/v2/bson"
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||||
|
|
||||||
"github.com/noteapp/backend/internal/domain/entities"
|
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/domain/entities"
|
||||||
)
|
)
|
||||||
|
|
||||||
// NoteRepository implements the note repository interface
|
// NoteRepository implements the note repository interface
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||||
|
|
||||||
"github.com/noteapp/backend/internal/domain/entities"
|
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/domain/entities"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SpaceRepository implements the space repository interface
|
// SpaceRepository implements the space repository interface
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||||
|
|
||||||
"github.com/noteapp/backend/internal/domain/entities"
|
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/domain/entities"
|
||||||
)
|
)
|
||||||
|
|
||||||
// UserRepository implements the user repository interface
|
// UserRepository implements the user repository interface
|
||||||
|
|||||||
@@ -4,12 +4,12 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/interfaces/middleware"
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
"github.com/noteapp/backend/internal/interfaces/middleware"
|
|
||||||
"go.mongodb.org/mongo-driver/v2/bson"
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
|
|
||||||
"github.com/noteapp/backend/internal/application/dto"
|
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/application/dto"
|
||||||
"github.com/noteapp/backend/internal/application/services"
|
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/application/services"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AdminHandler handles admin-level HTTP requests
|
// AdminHandler handles admin-level HTTP requests
|
||||||
|
|||||||
@@ -1,32 +1,35 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/application/dto"
|
||||||
|
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/application/services"
|
||||||
|
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/infrastructure/auth"
|
||||||
"github.com/gorilla/mux"
|
"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"
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AuthHandler handles authentication endpoints
|
// AuthHandler handles authentication endpoints
|
||||||
type AuthHandler struct {
|
type AuthHandler struct {
|
||||||
authService *services.AuthService
|
authService *services.AuthService
|
||||||
|
sessionManager *auth.SessionManager
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAuthHandler creates a new auth handler
|
// NewAuthHandler creates a new auth handler
|
||||||
func NewAuthHandler(authService *services.AuthService) *AuthHandler {
|
func NewAuthHandler(authService *services.AuthService, sessionManager *auth.SessionManager) *AuthHandler {
|
||||||
return &AuthHandler{
|
return &AuthHandler{
|
||||||
authService: authService,
|
authService: authService,
|
||||||
|
sessionManager: sessionManager,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sessionCookieName = "session_id"
|
||||||
|
|
||||||
// Register handles user registration
|
// Register handles user registration
|
||||||
func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) {
|
func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
@@ -56,6 +59,11 @@ func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := h.setSessionCookie(w, r, response.User); err != nil {
|
||||||
|
http.Error(w, "Failed to create session", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(response)
|
json.NewEncoder(w).Encode(response)
|
||||||
}
|
}
|
||||||
@@ -79,16 +87,10 @@ func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set secure HTTP-only cookie for refresh token
|
if err := h.setSessionCookie(w, r, response.User); err != nil {
|
||||||
http.SetCookie(w, &http.Cookie{
|
http.Error(w, "Failed to create session", http.StatusInternalServerError)
|
||||||
Name: "refresh_token",
|
return
|
||||||
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")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(response)
|
json.NewEncoder(w).Encode(response)
|
||||||
@@ -96,15 +98,12 @@ func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// Logout handles user logout
|
// Logout handles user logout
|
||||||
func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
|
func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
|
||||||
// Clear refresh token cookie
|
sessionCookie, err := r.Cookie(sessionCookieName)
|
||||||
http.SetCookie(w, &http.Cookie{
|
if err == nil {
|
||||||
Name: "refresh_token",
|
_ = h.sessionManager.DeleteSession(r.Context(), sessionCookie.Value)
|
||||||
Value: "",
|
}
|
||||||
Path: "/",
|
|
||||||
MaxAge: -1,
|
h.clearSessionCookie(w, r)
|
||||||
HttpOnly: true,
|
|
||||||
Secure: isSecureRequest(r),
|
|
||||||
})
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(map[string]string{"message": "Logged out successfully"})
|
json.NewEncoder(w).Encode(map[string]string{"message": "Logged out successfully"})
|
||||||
@@ -122,6 +121,18 @@ func (h *AuthHandler) ListProviders(w http.ResponseWriter, r *http.Request) {
|
|||||||
json.NewEncoder(w).Encode(map[string]interface{}{"providers": providers})
|
json.NewEncoder(w).Encode(map[string]interface{}{"providers": providers})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListProvidersForAdmin returns all OAuth/OIDC providers, including inactive ones.
|
||||||
|
func (h *AuthHandler) ListProvidersForAdmin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
providers, err := h.authService.ListProvidersForAdmin(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.
|
// CreateProvider stores a new OAuth/OIDC provider configuration.
|
||||||
func (h *AuthHandler) CreateProvider(w http.ResponseWriter, r *http.Request) {
|
func (h *AuthHandler) CreateProvider(w http.ResponseWriter, r *http.Request) {
|
||||||
var req dto.CreateAuthProviderRequest
|
var req dto.CreateAuthProviderRequest
|
||||||
@@ -215,7 +226,7 @@ func (h *AuthHandler) CompleteProviderLogin(w http.ResponseWriter, r *http.Reque
|
|||||||
|
|
||||||
response, err := h.authService.CompleteProviderLogin(r.Context(), providerID, r.URL.Query().Get("code"), buildBackendURL(r, "/api/v1/auth/providers/"+providerID.Hex()+"/callback"))
|
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 {
|
if err != nil {
|
||||||
http.Redirect(w, r, buildFrontendLoginURL("oauth_error", err.Error(), "", nil), http.StatusFound)
|
http.Redirect(w, r, buildFrontendLoginURL("oauth_error", err.Error()), http.StatusFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,17 +240,12 @@ func (h *AuthHandler) CompleteProviderLogin(w http.ResponseWriter, r *http.Reque
|
|||||||
SameSite: http.SameSiteLaxMode,
|
SameSite: http.SameSiteLaxMode,
|
||||||
})
|
})
|
||||||
|
|
||||||
http.SetCookie(w, &http.Cookie{
|
if err := h.setSessionCookie(w, r, response.User); err != nil {
|
||||||
Name: "refresh_token",
|
http.Redirect(w, r, buildFrontendLoginURL("oauth_error", "Failed to create session"), http.StatusFound)
|
||||||
Value: response.RefreshToken,
|
return
|
||||||
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)
|
http.Redirect(w, r, buildFrontendLoginURL("oauth_success", ""), http.StatusFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
// RefreshToken handles token refresh
|
// RefreshToken handles token refresh
|
||||||
@@ -249,23 +255,57 @@ func (h *AuthHandler) RefreshToken(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get refresh token from cookie
|
cookie, err := r.Cookie(sessionCookieName)
|
||||||
cookie, err := r.Cookie("refresh_token")
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Refresh token not found", http.StatusUnauthorized)
|
http.Error(w, "Session not found", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
accessToken, err := h.authService.RefreshAccessToken(r.Context(), cookie.Value)
|
sessionData, err := h.sessionManager.GetSession(r.Context(), cookie.Value)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Invalid refresh token", http.StatusUnauthorized)
|
http.Error(w, "Invalid session", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if err := h.sessionManager.RefreshSession(r.Context(), cookie.Value); err == nil {
|
||||||
|
http.SetCookie(w, h.newSessionCookie(r, cookie.Value))
|
||||||
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
"access_token": accessToken,
|
"user": sessionData,
|
||||||
"expires_in": 3600,
|
"expires_in": int(h.sessionManager.TTL().Seconds()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Me returns the currently authenticated user profile.
|
||||||
|
func (h *AuthHandler) Me(w http.ResponseWriter, r *http.Request) {
|
||||||
|
sessionCookie, err := r.Cookie(sessionCookieName)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionData, err := h.sessionManager.GetSession(r.Context(), sessionCookie.Value)
|
||||||
|
if err != nil {
|
||||||
|
h.clearSessionCookie(w, r)
|
||||||
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := h.authService.GetUserProfile(r.Context(), sessionData.UserID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.sessionManager.RefreshSession(r.Context(), sessionCookie.Value); err == nil {
|
||||||
|
http.SetCookie(w, h.newSessionCookie(r, sessionCookie.Value))
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"user": user,
|
||||||
|
"expires_in": int(h.sessionManager.TTL().Seconds()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -292,7 +332,7 @@ func buildBackendURL(r *http.Request, path string) string {
|
|||||||
return scheme + "://" + r.Host + path
|
return scheme + "://" + r.Host + path
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildFrontendLoginURL(status, message, accessToken string, user *dto.UserDTO) string {
|
func buildFrontendLoginURL(status, message string) string {
|
||||||
frontendURL := os.Getenv("FRONTEND_URL")
|
frontendURL := os.Getenv("FRONTEND_URL")
|
||||||
if frontendURL == "" {
|
if frontendURL == "" {
|
||||||
frontendURL = "http://localhost:5173"
|
frontendURL = "http://localhost:5173"
|
||||||
@@ -310,14 +350,48 @@ func buildFrontendLoginURL(status, message, accessToken string, user *dto.UserDT
|
|||||||
if message != "" {
|
if message != "" {
|
||||||
query.Set("message", 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()
|
parsed.RawQuery = query.Encode()
|
||||||
return parsed.String()
|
return parsed.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *AuthHandler) setSessionCookie(w http.ResponseWriter, r *http.Request, user *dto.UserDTO) error {
|
||||||
|
if user == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionID, err := h.sessionManager.CreateSession(r.Context(), &auth.SessionData{
|
||||||
|
UserID: user.ID,
|
||||||
|
Email: user.Email,
|
||||||
|
Username: user.Username,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
http.SetCookie(w, h.newSessionCookie(r, sessionID))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AuthHandler) newSessionCookie(r *http.Request, sessionID string) *http.Cookie {
|
||||||
|
return &http.Cookie{
|
||||||
|
Name: sessionCookieName,
|
||||||
|
Value: sessionID,
|
||||||
|
Path: "/",
|
||||||
|
MaxAge: int(h.sessionManager.TTL().Seconds()),
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: isSecureRequest(r),
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AuthHandler) clearSessionCookie(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: sessionCookieName,
|
||||||
|
Value: "",
|
||||||
|
Path: "/",
|
||||||
|
MaxAge: -1,
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: isSecureRequest(r),
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ import (
|
|||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
"go.mongodb.org/mongo-driver/v2/bson"
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
|
|
||||||
"github.com/noteapp/backend/internal/application/dto"
|
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/application/dto"
|
||||||
"github.com/noteapp/backend/internal/application/services"
|
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/application/services"
|
||||||
"github.com/noteapp/backend/internal/interfaces/middleware"
|
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/interfaces/middleware"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CategoryHandler handles category endpoints
|
// CategoryHandler handles category endpoints
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ import (
|
|||||||
"path"
|
"path"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/application/services"
|
||||||
|
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/interfaces/middleware"
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
"github.com/noteapp/backend/internal/application/services"
|
|
||||||
"github.com/noteapp/backend/internal/interfaces/middleware"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const maxUploadSize = 100 << 20 // 100 MB
|
const maxUploadSize = 100 << 20 // 100 MB
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ import (
|
|||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
"go.mongodb.org/mongo-driver/v2/bson"
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
|
|
||||||
"github.com/noteapp/backend/internal/application/dto"
|
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/application/dto"
|
||||||
"github.com/noteapp/backend/internal/application/services"
|
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/application/services"
|
||||||
"github.com/noteapp/backend/internal/interfaces/middleware"
|
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/interfaces/middleware"
|
||||||
)
|
)
|
||||||
|
|
||||||
// NoteHandler handles note endpoints
|
// NoteHandler handles note endpoints
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import (
|
|||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
"go.mongodb.org/mongo-driver/v2/bson"
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
|
|
||||||
"github.com/noteapp/backend/internal/application/dto"
|
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/application/dto"
|
||||||
"github.com/noteapp/backend/internal/application/services"
|
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/application/services"
|
||||||
)
|
)
|
||||||
|
|
||||||
// PublicHandler handles unauthenticated public read-only requests
|
// PublicHandler handles unauthenticated public read-only requests
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/noteapp/backend/internal/application/services"
|
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/application/services"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SettingsHandler handles public app settings endpoints.
|
// SettingsHandler handles public app settings endpoints.
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/application/dto"
|
||||||
|
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/application/services"
|
||||||
|
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/interfaces/middleware"
|
||||||
"github.com/gorilla/mux"
|
"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"
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/noteapp/backend/internal/infrastructure/auth"
|
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/infrastructure/auth"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ContextKey is a custom type for context keys
|
// ContextKey is a custom type for context keys
|
||||||
@@ -20,13 +20,15 @@ const (
|
|||||||
|
|
||||||
// AuthMiddleware verifies JWT tokens
|
// AuthMiddleware verifies JWT tokens
|
||||||
type AuthMiddleware struct {
|
type AuthMiddleware struct {
|
||||||
jwtManager *auth.JWTManager
|
jwtManager *auth.JWTManager
|
||||||
|
sessionManager *auth.SessionManager
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAuthMiddleware creates a new auth middleware
|
// NewAuthMiddleware creates a new auth middleware
|
||||||
func NewAuthMiddleware(jwtManager *auth.JWTManager) *AuthMiddleware {
|
func NewAuthMiddleware(jwtManager *auth.JWTManager, sessionManager *auth.SessionManager) *AuthMiddleware {
|
||||||
return &AuthMiddleware{
|
return &AuthMiddleware{
|
||||||
jwtManager: jwtManager,
|
jwtManager: jwtManager,
|
||||||
|
sessionManager: sessionManager,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,16 +43,23 @@ func (m *AuthMiddleware) Middleware(next http.Handler) http.Handler {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract token from Authorization header.
|
if sessionCookie, err := r.Cookie("session_id"); err == nil && sessionCookie.Value != "" {
|
||||||
// For GET /files/object, also accept ?token= so markdown images render in-browser.
|
sessionData, sessionErr := m.sessionManager.GetSession(r.Context(), sessionCookie.Value)
|
||||||
authHeader := r.Header.Get("Authorization")
|
if sessionErr == nil {
|
||||||
if authHeader == "" && r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/files/object") {
|
_ = m.sessionManager.RefreshSession(r.Context(), sessionCookie.Value)
|
||||||
if tok := r.URL.Query().Get("token"); tok != "" {
|
|
||||||
authHeader = "Bearer " + tok
|
ctx := context.WithValue(r.Context(), UserIDKey, sessionData.UserID)
|
||||||
|
ctx = context.WithValue(ctx, EmailKey, sessionData.Email)
|
||||||
|
r = r.WithContext(ctx)
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fall back to Authorization header for backwards compatibility.
|
||||||
|
authHeader := r.Header.Get("Authorization")
|
||||||
if authHeader == "" {
|
if authHeader == "" {
|
||||||
http.Error(w, "Missing authorization header", http.StatusUnauthorized)
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ func CORSMiddleware(next http.Handler) http.Handler {
|
|||||||
}
|
}
|
||||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS, PATCH")
|
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-Allow-Headers", "Content-Type, Authorization, X-Requested-With")
|
||||||
|
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||||
w.Header().Set("Access-Control-Max-Age", "600")
|
w.Header().Set("Access-Control-Max-Age", "600")
|
||||||
|
|
||||||
if r.Method == http.MethodOptions {
|
if r.Method == http.MethodOptions {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/noteapp/backend/internal/infrastructure/database"
|
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/infrastructure/database"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestDatabaseConnection tests MongoDB connection
|
// TestDatabaseConnection tests MongoDB connection
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/noteapp/backend/internal/application/dto"
|
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/application/dto"
|
||||||
"github.com/noteapp/backend/internal/application/services"
|
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/application/services"
|
||||||
"github.com/noteapp/backend/internal/domain/entities"
|
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/domain/entities"
|
||||||
"github.com/noteapp/backend/internal/infrastructure/auth"
|
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/infrastructure/auth"
|
||||||
"github.com/noteapp/backend/internal/infrastructure/security"
|
"gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/infrastructure/security"
|
||||||
"go.mongodb.org/mongo-driver/v2/bson"
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -44,20 +44,6 @@ http {
|
|||||||
listen 80;
|
listen 80;
|
||||||
server_name localhost;
|
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
|
# Health check
|
||||||
location /health {
|
location /health {
|
||||||
proxy_pass http://notely;
|
proxy_pass http://notely;
|
||||||
|
|||||||
@@ -1,6 +1,17 @@
|
|||||||
version: "3.8"
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
|
redis:
|
||||||
|
image: redis:8-alpine
|
||||||
|
container_name: notely-redis
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
networks:
|
||||||
|
- notely-network
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
mongodb:
|
mongodb:
|
||||||
image: mongo:8.0
|
image: mongo:8.0
|
||||||
container_name: notely-mongodb
|
container_name: notely-mongodb
|
||||||
@@ -39,9 +50,15 @@ services:
|
|||||||
DEFAULT_ADMIN_EMAIL: ${DEFAULT_ADMIN_EMAIL}
|
DEFAULT_ADMIN_EMAIL: ${DEFAULT_ADMIN_EMAIL}
|
||||||
DEFAULT_ADMIN_USERNAME: ${DEFAULT_ADMIN_USERNAME}
|
DEFAULT_ADMIN_USERNAME: ${DEFAULT_ADMIN_USERNAME}
|
||||||
DEFAULT_ADMIN_PASSWORD: ${DEFAULT_ADMIN_PASSWORD}
|
DEFAULT_ADMIN_PASSWORD: ${DEFAULT_ADMIN_PASSWORD}
|
||||||
|
REDIS_ADDR: ${REDIS_ADDR}
|
||||||
|
REDIS_PASSWORD: ${REDIS_PASSWORD}
|
||||||
|
REDIS_DB: ${REDIS_DB}
|
||||||
|
SESSION_TTL_HOURS: ${SESSION_TTL_HOURS}
|
||||||
depends_on:
|
depends_on:
|
||||||
mongodb:
|
mongodb:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
networks:
|
networks:
|
||||||
- notely-network
|
- notely-network
|
||||||
|
|
||||||
|
|||||||
33
frontend/eslint.config.js
Normal file
33
frontend/eslint.config.js
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import js from "@eslint/js";
|
||||||
|
import globals from "globals";
|
||||||
|
import pluginVue from "eslint-plugin-vue";
|
||||||
|
|
||||||
|
export default [
|
||||||
|
{
|
||||||
|
ignores: ["dist/**", "node_modules/**"],
|
||||||
|
},
|
||||||
|
js.configs.recommended,
|
||||||
|
...pluginVue.configs["flat/essential"],
|
||||||
|
{
|
||||||
|
files: ["**/*.{js,mjs,cjs,vue}"],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: "latest",
|
||||||
|
sourceType: "module",
|
||||||
|
globals: {
|
||||||
|
...globals.browser,
|
||||||
|
...globals.node,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
"no-unused-vars": [
|
||||||
|
"warn",
|
||||||
|
{
|
||||||
|
argsIgnorePattern: "^_",
|
||||||
|
varsIgnorePattern: "^_",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"no-console": "off",
|
||||||
|
"vue/multi-word-component-names": "off",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
1737
frontend/package-lock.json
generated
1737
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,9 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs"
|
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mdi/font": "^7.4.47",
|
"@mdi/font": "^7.4.47",
|
||||||
@@ -15,14 +17,21 @@
|
|||||||
"axios": "^1.4.0",
|
"axios": "^1.4.0",
|
||||||
"bootstrap": "^5.3.0",
|
"bootstrap": "^5.3.0",
|
||||||
"dompurify": "^3.0.0",
|
"dompurify": "^3.0.0",
|
||||||
|
"highlight.js": "^11.11.1",
|
||||||
"marked": "^9.0.0",
|
"marked": "^9.0.0",
|
||||||
|
"marked-highlight": "^2.2.3",
|
||||||
"pinia": "^2.1.0",
|
"pinia": "^2.1.0",
|
||||||
"vue": "^3.3.0",
|
"vue": "^3.3.0",
|
||||||
"vue-router": "^4.2.0"
|
"vue-router": "^4.2.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.22.0",
|
||||||
"@vitejs/plugin-vue": "^4.2.0",
|
"@vitejs/plugin-vue": "^4.2.0",
|
||||||
"@vue/test-utils": "^2.4.0",
|
"@vue/test-utils": "^2.4.0",
|
||||||
|
"eslint": "^9.22.0",
|
||||||
|
"eslint-plugin-vue": "^9.32.0",
|
||||||
|
"globals": "^16.0.0",
|
||||||
|
"jsdom": "^29.0.1",
|
||||||
"vite": "^4.3.0",
|
"vite": "^4.3.0",
|
||||||
"vitest": "^0.34.0"
|
"vitest": "^0.34.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,9 +44,20 @@
|
|||||||
|
|
||||||
<!-- Search -->
|
<!-- Search -->
|
||||||
<div class="search-box nav-search" v-if="!isAdminRoute">
|
<div class="search-box nav-search" v-if="!isAdminRoute">
|
||||||
<input type="text" class="form-control form-control-sm" placeholder="Search notes..." v-model="searchQuery" @keyup.enter="performSearch" />
|
<input type="text" class="form-control" placeholder="Search notes..." v-model="searchQuery" @keyup.enter="performSearch" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Theme Toggle -->
|
||||||
|
<button
|
||||||
|
class="btn btn-outline-light theme-toggle"
|
||||||
|
type="button"
|
||||||
|
:aria-label="isDarkMode ? 'Switch to light mode' : 'Switch to dark mode'"
|
||||||
|
:title="isDarkMode ? 'Switch to light mode' : 'Switch to dark mode'"
|
||||||
|
@click="isDarkMode = !isDarkMode"
|
||||||
|
>
|
||||||
|
<i :class="isDarkMode ? 'mdi mdi-weather-sunny' : 'mdi mdi-weather-night'" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
<!-- User Menu -->
|
<!-- User Menu -->
|
||||||
<div ref="userDropdownRef" class="dropdown nav-user-menu" v-if="currentUser" @mouseleave="showUserMenu = false">
|
<div ref="userDropdownRef" class="dropdown nav-user-menu" v-if="currentUser" @mouseleave="showUserMenu = false">
|
||||||
<button class="btn btn-outline-light dropdown-toggle" type="button" @click="toggleUserMenu">
|
<button class="btn btn-outline-light dropdown-toggle" type="button" @click="toggleUserMenu">
|
||||||
@@ -117,7 +128,7 @@
|
|||||||
</h5>
|
</h5>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-auto d-flex align-items-center">
|
<div class="col-auto d-flex align-items-center">
|
||||||
<div v-if="!selectedNote" class="btn-group me-2 d-none d-md-flex" role="group" aria-label="View mode">
|
<div v-if="!selectedNote || isSearchRoute" class="btn-group me-2 d-none d-md-flex" role="group" aria-label="View mode">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn action-button"
|
class="btn action-button"
|
||||||
@@ -140,7 +151,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
v-if="canEditNotes && selectedNote && !isEditingNote"
|
v-if="canEditNotes && selectedNote && !isEditingNote && !isSearchRoute"
|
||||||
class="btn btn-outline-secondary me-2 action-button"
|
class="btn btn-outline-secondary me-2 action-button"
|
||||||
aria-label="Edit note"
|
aria-label="Edit note"
|
||||||
title="Edit note"
|
title="Edit note"
|
||||||
@@ -150,7 +161,7 @@
|
|||||||
<span class="action-label">Edit Note</span>
|
<span class="action-label">Edit Note</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="canShareSelectedNote && !isEditingNote"
|
v-if="canShareSelectedNote && !isEditingNote && !isSearchRoute"
|
||||||
class="btn btn-outline-primary me-2 action-button"
|
class="btn btn-outline-primary me-2 action-button"
|
||||||
:aria-label="shareCopied ? 'Link copied' : 'Share note'"
|
:aria-label="shareCopied ? 'Link copied' : 'Share note'"
|
||||||
:title="shareCopied ? 'Link copied' : 'Share note'"
|
:title="shareCopied ? 'Link copied' : 'Share note'"
|
||||||
@@ -169,8 +180,18 @@
|
|||||||
|
|
||||||
<!-- Note Editor or Note List -->
|
<!-- Note Editor or Note List -->
|
||||||
<div class="content p-4">
|
<div class="content p-4">
|
||||||
|
<SearchResultsPage
|
||||||
|
v-if="isSearchRoute"
|
||||||
|
:notes="searchResults"
|
||||||
|
:query="searchQuery"
|
||||||
|
:current-page="searchPage"
|
||||||
|
:page-size="searchPageSize"
|
||||||
|
:view-mode="noteViewMode"
|
||||||
|
@select-note="selectSearchResultNote"
|
||||||
|
@page-change="setSearchPage"
|
||||||
|
/>
|
||||||
<NoteEditor
|
<NoteEditor
|
||||||
v-if="selectedNote && isEditingNote"
|
v-else-if="selectedNote && isEditingNote"
|
||||||
:note="selectedNote"
|
:note="selectedNote"
|
||||||
:category-options="categoryOptions"
|
:category-options="categoryOptions"
|
||||||
:can-delete="canDeleteNotes"
|
:can-delete="canDeleteNotes"
|
||||||
@@ -278,6 +299,7 @@ import CategoryTree from "./components/CategoryTree.vue";
|
|||||||
import NoteEditor from "./components/NoteEditor.vue";
|
import NoteEditor from "./components/NoteEditor.vue";
|
||||||
import NoteViewer from "./components/NoteViewer.vue";
|
import NoteViewer from "./components/NoteViewer.vue";
|
||||||
import NoteList from "./components/NoteList.vue";
|
import NoteList from "./components/NoteList.vue";
|
||||||
|
import SearchResultsPage from "./components/SearchResultsPage.vue";
|
||||||
import CreateSpaceModal from "./components/CreateSpaceModal.vue";
|
import CreateSpaceModal from "./components/CreateSpaceModal.vue";
|
||||||
import CreateCategoryModal from "./components/CreateCategoryModal.vue";
|
import CreateCategoryModal from "./components/CreateCategoryModal.vue";
|
||||||
import CreateNoteModal from "./components/CreateNoteModal.vue";
|
import CreateNoteModal from "./components/CreateNoteModal.vue";
|
||||||
@@ -311,6 +333,13 @@ const shareCopied = ref(false);
|
|||||||
const shareCopyTimeout = ref(null);
|
const shareCopyTimeout = ref(null);
|
||||||
const noteViewMode = ref(localStorage.getItem("noteViewMode") || "grid");
|
const noteViewMode = ref(localStorage.getItem("noteViewMode") || "grid");
|
||||||
watch(noteViewMode, (val) => localStorage.setItem("noteViewMode", val));
|
watch(noteViewMode, (val) => localStorage.setItem("noteViewMode", val));
|
||||||
|
const isDarkMode = ref(localStorage.getItem("theme") === "dark");
|
||||||
|
const applyTheme = (dark) => {
|
||||||
|
document.documentElement.setAttribute("data-bs-theme", dark ? "dark" : "light");
|
||||||
|
localStorage.setItem("theme", dark ? "dark" : "light");
|
||||||
|
};
|
||||||
|
watch(isDarkMode, applyTheme);
|
||||||
|
applyTheme(isDarkMode.value);
|
||||||
const showUnlockModal = ref(false);
|
const showUnlockModal = ref(false);
|
||||||
const unlockTargetNote = ref(null);
|
const unlockTargetNote = ref(null);
|
||||||
const unlockPassword = ref("");
|
const unlockPassword = ref("");
|
||||||
@@ -319,10 +348,20 @@ const unlockingNote = ref(false);
|
|||||||
|
|
||||||
const currentUser = computed(() => authStore.user);
|
const currentUser = computed(() => authStore.user);
|
||||||
const isAdminRoute = computed(() => route.path === "/admin");
|
const isAdminRoute = computed(() => route.path === "/admin");
|
||||||
|
const isSearchRoute = computed(() => route.path === "/search");
|
||||||
const isPublicRoute = computed(() => route.path.startsWith("/s/"));
|
const isPublicRoute = computed(() => route.path.startsWith("/s/"));
|
||||||
const isAuthRoute = computed(() => route.path === "/login" || route.path === "/register");
|
const isAuthRoute = computed(() => route.path === "/login" || route.path === "/register");
|
||||||
const spaces = computed(() => spaceStore.spaces);
|
const spaces = computed(() => spaceStore.spaces);
|
||||||
const currentSpace = computed(() => spaceStore.currentSpace);
|
const currentSpace = computed(() => spaceStore.currentSpace);
|
||||||
|
const searchResults = computed(() => sortNotesByPriority(spaceStore.searchResults));
|
||||||
|
const searchPageSize = 12;
|
||||||
|
const searchPage = computed(() => {
|
||||||
|
const pageValue = Number.parseInt(route.query.page || "1", 10);
|
||||||
|
if (Number.isNaN(pageValue) || pageValue < 1) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
return pageValue;
|
||||||
|
});
|
||||||
const categoryTree = computed(() => spaceStore.categoryTree);
|
const categoryTree = computed(() => spaceStore.categoryTree);
|
||||||
const canCreateSpaces = computed(() => authStore.hasPermission("space.create"));
|
const canCreateSpaces = computed(() => authStore.hasPermission("space.create"));
|
||||||
const canCreateCategories = computed(() => authStore.hasSpacePermission(currentSpace.value, "category.create"));
|
const canCreateCategories = computed(() => authStore.hasSpacePermission(currentSpace.value, "category.create"));
|
||||||
@@ -382,7 +421,7 @@ const canLoadMoreMainNotes = computed(() => {
|
|||||||
if (selectedCategory.value || selectedNote.value) {
|
if (selectedCategory.value || selectedNote.value) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (searchQuery.value.trim()) {
|
if (isSearchRoute.value) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return spaceStore.notesHasMore;
|
return spaceStore.notesHasMore;
|
||||||
@@ -411,6 +450,10 @@ const openSpaceHome = () => {
|
|||||||
unlockPassword.value = "";
|
unlockPassword.value = "";
|
||||||
unlockError.value = "";
|
unlockError.value = "";
|
||||||
searchQuery.value = "";
|
searchQuery.value = "";
|
||||||
|
spaceStore.clearSearchResults();
|
||||||
|
if (route.path !== "/") {
|
||||||
|
router.push("/");
|
||||||
|
}
|
||||||
if (currentSpace.value?.id) {
|
if (currentSpace.value?.id) {
|
||||||
spaceStore.fetchNotes(currentSpace.value.id, { reset: true });
|
spaceStore.fetchNotes(currentSpace.value.id, { reset: true });
|
||||||
}
|
}
|
||||||
@@ -425,6 +468,21 @@ const breadcrumbItems = computed(() => {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isSearchRoute.value) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: currentSpace.value.name,
|
||||||
|
clickable: true,
|
||||||
|
onClick: openSpaceHome,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: searchQuery.value.trim() ? `Search: ${searchQuery.value.trim()}` : "Search",
|
||||||
|
clickable: false,
|
||||||
|
onClick: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
const items = [
|
const items = [
|
||||||
{
|
{
|
||||||
label: currentSpace.value.name,
|
label: currentSpace.value.name,
|
||||||
@@ -527,6 +585,30 @@ watch(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
[() => route.path, () => route.query.q, () => currentSpace.value?.id],
|
||||||
|
async ([path, routeQuery, spaceId]) => {
|
||||||
|
if (path !== "/search") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedNote.value = null;
|
||||||
|
selectedCategory.value = null;
|
||||||
|
isEditingNote.value = false;
|
||||||
|
|
||||||
|
const q = typeof routeQuery === "string" ? routeQuery.trim() : "";
|
||||||
|
searchQuery.value = q;
|
||||||
|
|
||||||
|
if (!spaceId || !q) {
|
||||||
|
spaceStore.clearSearchResults();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await spaceStore.searchNotes(q);
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => selectedNote.value?.id,
|
() => selectedNote.value?.id,
|
||||||
() => {
|
() => {
|
||||||
@@ -683,10 +765,52 @@ const selectCategory = (category) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const performSearch = async () => {
|
const performSearch = async () => {
|
||||||
if (searchQuery.value.trim()) {
|
const q = searchQuery.value.trim();
|
||||||
await spaceStore.searchNotes(searchQuery.value);
|
if (!q) {
|
||||||
} else if (currentSpace.value?.id) {
|
spaceStore.clearSearchResults();
|
||||||
await spaceStore.fetchNotes(currentSpace.value.id, { reset: true });
|
if (route.path !== "/") {
|
||||||
|
await router.push("/");
|
||||||
|
}
|
||||||
|
if (currentSpace.value?.id) {
|
||||||
|
await spaceStore.fetchNotes(currentSpace.value.id, { reset: true });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (route.path !== "/search" || route.query.q !== q || route.query.page !== "1") {
|
||||||
|
await router.push({
|
||||||
|
path: "/search",
|
||||||
|
query: {
|
||||||
|
q,
|
||||||
|
page: "1",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await spaceStore.searchNotes(q);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setSearchPage = async (page) => {
|
||||||
|
const q = typeof route.query.q === "string" ? route.query.q : "";
|
||||||
|
if (!q) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await router.push({
|
||||||
|
path: "/search",
|
||||||
|
query: {
|
||||||
|
q,
|
||||||
|
page: String(page),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectSearchResultNote = async (note) => {
|
||||||
|
if (!note) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await selectNote(note);
|
||||||
|
if (route.path === "/search") {
|
||||||
|
router.push("/");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -702,11 +826,6 @@ const createSpace = async (spaceData) => {
|
|||||||
await spaceStore.createSpace(spaceData);
|
await spaceStore.createSpace(spaceData);
|
||||||
};
|
};
|
||||||
|
|
||||||
const createCategory = async (categoryData) => {
|
|
||||||
showCreateCategoryModal.value = false;
|
|
||||||
await spaceStore.createCategory(currentSpace.value.id, categoryData);
|
|
||||||
};
|
|
||||||
|
|
||||||
const openCreateCategoryModal = () => {
|
const openCreateCategoryModal = () => {
|
||||||
if (!canCreateCategories.value) {
|
if (!canCreateCategories.value) {
|
||||||
return;
|
return;
|
||||||
@@ -1090,4 +1209,21 @@ const logout = () => {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Dark mode overrides */
|
||||||
|
:root[data-bs-theme="dark"] .sidebar-header {
|
||||||
|
border-bottom-color: #3a3f4b;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-bs-theme="dark"] .breadcrumb-title {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-bs-theme="dark"] .breadcrumb-link {
|
||||||
|
color: #7aa2f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-bs-theme="dark"] .breadcrumb-separator {
|
||||||
|
color: #4a5568;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -6,6 +6,75 @@
|
|||||||
--border-color: #dee2e6;
|
--border-color: #dee2e6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[data-bs-theme="dark"] {
|
||||||
|
--text-color: #e2e8f0;
|
||||||
|
--bg-color: #1a1d23;
|
||||||
|
--border-color: #3a3f4b;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-bs-theme="dark"] body {
|
||||||
|
background-color: #1a1d23;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-bs-theme="dark"] .sidebar {
|
||||||
|
background-color: #21252e !important;
|
||||||
|
border-color: #3a3f4b !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-bs-theme="dark"] .toolbar {
|
||||||
|
background-color: #21252e;
|
||||||
|
border-color: #3a3f4b !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-bs-theme="dark"] .main-content {
|
||||||
|
background-color: #1a1d23;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-bs-theme="dark"] .markdown-body table {
|
||||||
|
background: #21252e;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-bs-theme="dark"] .markdown-body th {
|
||||||
|
background: #2a2f3a;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-bs-theme="dark"] .markdown-body tr:nth-child(even) td {
|
||||||
|
background: #232830;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-bs-theme="dark"] .markdown-body blockquote {
|
||||||
|
background: #1e2430;
|
||||||
|
color: #a0aec0;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-bs-theme="dark"] .markdown-body :not(pre) > code {
|
||||||
|
background: #2d3748;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-bs-theme="dark"] .markdown-body pre code {
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-bs-theme="dark"] .markdown-body pre {
|
||||||
|
background: #2d3748;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-bs-theme="dark"] ::-webkit-scrollbar-track {
|
||||||
|
background: #2d3748;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-bs-theme="dark"] ::-webkit-scrollbar-thumb {
|
||||||
|
background: #4a5568;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-bs-theme="dark"] ::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #718096;
|
||||||
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -25,6 +94,70 @@ body,
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.markdown-body table {
|
||||||
|
width: 100%;
|
||||||
|
margin: 1rem 0;
|
||||||
|
border-collapse: collapse;
|
||||||
|
border-spacing: 0;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body th,
|
||||||
|
.markdown-body td {
|
||||||
|
padding: 0.7rem 0.9rem;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body th {
|
||||||
|
font-weight: 600;
|
||||||
|
background: #f3f6fb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body tr:nth-child(even) td {
|
||||||
|
background: #fbfcfe;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body table code {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body blockquote {
|
||||||
|
margin: 1rem 0;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-left: 4px solid #748ffc;
|
||||||
|
background: #f8f9ff;
|
||||||
|
color: #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body blockquote > :last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body pre {
|
||||||
|
margin: 1rem 0;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
background: #353943;
|
||||||
|
color: #f9fafb;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body pre code {
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-body code {
|
||||||
|
font-family: "Courier New", monospace;
|
||||||
|
font-size: 0.95em;
|
||||||
|
padding: 0.1rem 0.3rem;
|
||||||
|
border-radius: 0.35rem;
|
||||||
|
background: #f1f3f5;
|
||||||
|
}
|
||||||
|
|
||||||
/* Scrollbar styling */
|
/* Scrollbar styling */
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
width: 8px;
|
width: 8px;
|
||||||
|
|||||||
@@ -60,6 +60,20 @@
|
|||||||
<label for="provider-active" class="form-check-label">Provider is active</label>
|
<label for="provider-active" class="form-check-label">Provider is active</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="mode === 'edit'" class="col-12">
|
||||||
|
<div class="danger-zone border border-danger-subtle rounded p-3 mt-2">
|
||||||
|
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-2">
|
||||||
|
<div>
|
||||||
|
<div class="fw-semibold text-danger">Danger Zone</div>
|
||||||
|
<div class="small text-muted">Permanently delete this provider configuration.</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-danger" :disabled="submitting || deleting" @click="emit('delete', props.provider)">
|
||||||
|
<i class="mdi mdi-trash-can-outline me-1" aria-hidden="true"></i>Delete Provider
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
@@ -92,9 +106,13 @@ const props = defineProps({
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
|
deleting: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(["close", "submit"]);
|
const emit = defineEmits(["close", "submit", "delete"]);
|
||||||
|
|
||||||
const form = ref({
|
const form = ref({
|
||||||
name: "",
|
name: "",
|
||||||
|
|||||||
@@ -275,4 +275,53 @@ const handleDeleteCategory = (category) => {
|
|||||||
.subcategories {
|
.subcategories {
|
||||||
margin-top: 0.25rem;
|
margin-top: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Dark mode overrides */
|
||||||
|
:root[data-bs-theme="dark"] .category-header:hover {
|
||||||
|
background-color: #2d3748;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-bs-theme="dark"] .menu-button {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-bs-theme="dark"] .menu-button:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-bs-theme="dark"] .menu-dropdown {
|
||||||
|
background: #2d3748;
|
||||||
|
border-color: #4a5568;
|
||||||
|
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-bs-theme="dark"] .menu-item {
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-bs-theme="dark"] .menu-item:hover {
|
||||||
|
background-color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-bs-theme="dark"] .note-item:hover {
|
||||||
|
background-color: #2d3748;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-bs-theme="dark"] .note-item.is-pinned {
|
||||||
|
background: #1a3a5c;
|
||||||
|
border-color: #2d6a9f;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-bs-theme="dark"] .note-item.is-pinned:hover {
|
||||||
|
background: #1e4470;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-bs-theme="dark"] .note-item.is-featured {
|
||||||
|
background: #3a2e0a;
|
||||||
|
border-color: #7a5a0a;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-bs-theme="dark"] .note-item.is-featured:hover {
|
||||||
|
background: #453710;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -328,4 +328,23 @@ watch(showNewFolderInput, async (v) => {
|
|||||||
.file-item:hover .btn-delete {
|
.file-item:hover .btn-delete {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Dark mode overrides */
|
||||||
|
:root[data-bs-theme="dark"] .file-explorer {
|
||||||
|
background: #21252e;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-bs-theme="dark"] .file-explorer-header {
|
||||||
|
background: #21252e;
|
||||||
|
border-color: #3a3f4b;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-bs-theme="dark"] .file-item {
|
||||||
|
border-bottom-color: #3a3f4b;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-bs-theme="dark"] .file-item:hover {
|
||||||
|
background-color: #2d3748;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -262,4 +262,20 @@ onMounted(loadProviders);
|
|||||||
padding-right: 0.85rem;
|
padding-right: 0.85rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Dark mode overrides */
|
||||||
|
:root[data-bs-theme="dark"] .modal-panel {
|
||||||
|
background: #21252e;
|
||||||
|
border-color: #3a3f4b;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-bs-theme="dark"] .provider-modal-header {
|
||||||
|
background: linear-gradient(180deg, #2a2f3a 0%, #21252e 100%);
|
||||||
|
border-bottom-color: #3a3f4b;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-bs-theme="dark"] .provider-section {
|
||||||
|
background: #2a2f3a;
|
||||||
|
border-color: #3a3f4b;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
<div class="note-editor">
|
<div class="note-editor">
|
||||||
<div class="editor-toolbar mb-3">
|
<div class="editor-toolbar mb-3">
|
||||||
<button class="btn btn-sm btn-primary" @click="saveNote">Save</button>
|
<button class="btn btn-sm btn-primary" @click="saveNote">Save</button>
|
||||||
<button v-if="canDelete" class="btn btn-sm btn-danger ms-2" @click="confirmDelete">Delete</button>
|
|
||||||
<button class="btn btn-sm btn-outline-secondary ms-2" @click="emit('cancel')">Cancel</button>
|
<button class="btn btn-sm btn-outline-secondary ms-2" @click="emit('cancel')">Cancel</button>
|
||||||
<button
|
<button
|
||||||
v-if="fileExplorerEnabled"
|
v-if="fileExplorerEnabled"
|
||||||
@@ -32,7 +31,7 @@
|
|||||||
|
|
||||||
<div :class="showFileExplorer ? 'col-12 col-md-4 mt-3 mt-md-0' : 'col-12 col-md-6 mt-3 mt-md-0'">
|
<div :class="showFileExplorer ? 'col-12 col-md-4 mt-3 mt-md-0' : 'col-12 col-md-6 mt-3 mt-md-0'">
|
||||||
<div class="preview-pane border rounded p-3">
|
<div class="preview-pane border rounded p-3">
|
||||||
<div v-html="renderedMarkdown"></div>
|
<div class="markdown-body" v-html="renderedMarkdown"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -82,17 +81,24 @@
|
|||||||
</select>
|
</select>
|
||||||
<input v-if="passwordAction === 'set'" v-model="notePassword" type="password" class="form-control mt-2" minlength="4" maxlength="128" placeholder="Enter a note password" />
|
<input v-if="passwordAction === 'set'" v-model="notePassword" type="password" class="form-control mt-2" minlength="4" maxlength="128" placeholder="Enter a note password" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<section v-if="canDelete && editingNote.id" class="danger-zone mt-4" aria-labelledby="danger-zone-title">
|
||||||
|
<h3 id="danger-zone-title" class="danger-zone-title mb-2">Danger Zone</h3>
|
||||||
|
<p class="danger-zone-copy mb-3">Deleting this note is permanent and cannot be undone.</p>
|
||||||
|
<button class="btn btn-danger" type="button" @click="confirmDelete">
|
||||||
|
<i class="mdi mdi-delete-outline me-1" aria-hidden="true"></i>
|
||||||
|
Delete Note
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, watch, onBeforeUnmount, onMounted, nextTick } from "vue";
|
import { ref, computed, watch, onBeforeUnmount, onMounted, nextTick } from "vue";
|
||||||
import { marked } from "marked";
|
|
||||||
import DOMPurify from "dompurify";
|
import DOMPurify from "dompurify";
|
||||||
import { useSettingsStore } from "../stores/settingsStore";
|
import { useSettingsStore } from "../stores/settingsStore";
|
||||||
import { useAuthStore } from "../stores/authStore";
|
import { renderMarkdown } from "../utils/markdown.js";
|
||||||
import { preprocessMarkdown } from "../utils/markdown.js";
|
|
||||||
import FileExplorer from "./FileExplorer.vue";
|
import FileExplorer from "./FileExplorer.vue";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -116,7 +122,6 @@ const props = defineProps({
|
|||||||
|
|
||||||
const emit = defineEmits(["save", "delete", "cancel"]);
|
const emit = defineEmits(["save", "delete", "cancel"]);
|
||||||
const settingsStore = useSettingsStore();
|
const settingsStore = useSettingsStore();
|
||||||
const authStore = useAuthStore();
|
|
||||||
const publicSharingEnabled = ref(true);
|
const publicSharingEnabled = ref(true);
|
||||||
const fileExplorerEnabled = computed(() => settingsStore.fileExplorerEnabled);
|
const fileExplorerEnabled = computed(() => settingsStore.fileExplorerEnabled);
|
||||||
|
|
||||||
@@ -132,18 +137,8 @@ const saveState = ref("saved");
|
|||||||
const saveStateTimeout = ref(null);
|
const saveStateTimeout = ref(null);
|
||||||
|
|
||||||
const renderedMarkdown = computed(() => {
|
const renderedMarkdown = computed(() => {
|
||||||
const html = marked.parse(preprocessMarkdown(editingNote.value.content || ""));
|
const html = renderMarkdown(editingNote.value.content || "");
|
||||||
let clean = DOMPurify.sanitize(html);
|
return DOMPurify.sanitize(html);
|
||||||
// Inject access token into space file API URLs so images render without a separate JS fetch
|
|
||||||
const token = authStore.accessToken;
|
|
||||||
if (token && props.spaceId) {
|
|
||||||
clean = clean.replace(/((?:src|href)=["'])([^"']*\/api\/v1\/spaces\/[^"']*\/files\/object[^"']*)(["'])/g, (_, attr, url, quote) => {
|
|
||||||
if (url.includes("token=")) return attr + url + quote;
|
|
||||||
const sep = url.includes("?") ? "&" : "?";
|
|
||||||
return `${attr}${url}${sep}token=${encodeURIComponent(token)}${quote}`;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return clean;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const saveStatusLabel = computed(() => {
|
const saveStatusLabel = computed(() => {
|
||||||
@@ -294,7 +289,7 @@ onMounted(async () => {
|
|||||||
|
|
||||||
.editor-textarea {
|
.editor-textarea {
|
||||||
font-family: "Courier New", monospace;
|
font-family: "Courier New", monospace;
|
||||||
min-height: 400px;
|
min-height: 600px;
|
||||||
resize: vertical;
|
resize: vertical;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -333,4 +328,49 @@ onMounted(async () => {
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
max-height: 600px;
|
max-height: 600px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.danger-zone {
|
||||||
|
padding: 1rem;
|
||||||
|
border: 1px solid #f3b5b5;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
background: #fff5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger-zone-title {
|
||||||
|
color: #9f1c1c;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger-zone-copy {
|
||||||
|
color: #7a2727;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode overrides */
|
||||||
|
:root[data-bs-theme="dark"] .editor-toolbar {
|
||||||
|
border-bottom-color: #3a3f4b;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-bs-theme="dark"] .flag-check {
|
||||||
|
background: #2d3748;
|
||||||
|
border-color: #4a5568;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-bs-theme="dark"] .preview-pane {
|
||||||
|
background-color: #21252e;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-bs-theme="dark"] .danger-zone {
|
||||||
|
background: #2d1a1a;
|
||||||
|
border-color: #7a3030;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-bs-theme="dark"] .danger-zone-title {
|
||||||
|
color: #fc8181;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-bs-theme="dark"] .danger-zone-copy {
|
||||||
|
color: #fca5a5;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -180,6 +180,10 @@ const getDescription = (note) => {
|
|||||||
|
|
||||||
.note-list--list .note-card:hover {
|
.note-list--list .note-card:hover {
|
||||||
transform: none;
|
transform: none;
|
||||||
|
box-shadow: none;
|
||||||
|
background-color: #eef2ff;
|
||||||
|
border-color: #667eea;
|
||||||
|
border-left: 3px solid #667eea;
|
||||||
}
|
}
|
||||||
|
|
||||||
.note-list--list .note-title {
|
.note-list--list .note-title {
|
||||||
@@ -218,4 +222,51 @@ const getDescription = (note) => {
|
|||||||
font-size: 1.45rem;
|
font-size: 1.45rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Dark mode overrides */
|
||||||
|
:root[data-bs-theme="dark"] .empty-notes-state {
|
||||||
|
border-color: #3a3f4b;
|
||||||
|
background: linear-gradient(180deg, #1e2430 0%, #21252e 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-bs-theme="dark"] .empty-notes-title {
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-bs-theme="dark"] .empty-notes-message {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-bs-theme="dark"] .note-card {
|
||||||
|
border-color: #3a3f4b;
|
||||||
|
background-color: #21252e;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-bs-theme="dark"] .note-card:hover {
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-bs-theme="dark"] .note-list--list .note-card:hover {
|
||||||
|
background-color: #2a2f3a;
|
||||||
|
border-color: #7aa2f7;
|
||||||
|
border-left-color: #7aa2f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-bs-theme="dark"] .note-title {
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-bs-theme="dark"] .note-preview {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-bs-theme="dark"] .note-card.is-pinned {
|
||||||
|
background: #1a3a5c;
|
||||||
|
border-color: #2d6a9f;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-bs-theme="dark"] .note-card.is-featured {
|
||||||
|
background: #3a2e0a;
|
||||||
|
border-color: #7a5a0a;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -30,10 +30,8 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from "vue";
|
import { computed } from "vue";
|
||||||
import { marked } from "marked";
|
|
||||||
import DOMPurify from "dompurify";
|
import DOMPurify from "dompurify";
|
||||||
import { useAuthStore } from "../stores/authStore";
|
import { renderMarkdown } from "../utils/markdown.js";
|
||||||
import { preprocessMarkdown } from "../utils/markdown.js";
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
note: {
|
note: {
|
||||||
@@ -50,20 +48,9 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const authStore = useAuthStore();
|
|
||||||
|
|
||||||
const renderedMarkdown = computed(() => {
|
const renderedMarkdown = computed(() => {
|
||||||
const html = marked.parse(preprocessMarkdown(props.note.content || ""));
|
const html = renderMarkdown(props.note.content || "");
|
||||||
let clean = DOMPurify.sanitize(html);
|
return DOMPurify.sanitize(html);
|
||||||
const token = authStore.accessToken;
|
|
||||||
if (token && props.spaceId) {
|
|
||||||
clean = clean.replace(/((?:src|href)=["'])([^"']*\/api\/v1\/spaces\/[^"']*\/files\/object[^"']*)(["'])/g, (_, attr, url, quote) => {
|
|
||||||
if (url.includes("token=")) return attr + url + quote;
|
|
||||||
const sep = url.includes("?") ? "&" : "?";
|
|
||||||
return `${attr}${url}${sep}token=${encodeURIComponent(token)}${quote}`;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return clean;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const categoryLabel = computed(() => {
|
const categoryLabel = computed(() => {
|
||||||
@@ -174,8 +161,6 @@ const formatDateTime = (dateString) => new Date(dateString).toLocaleString();
|
|||||||
.markdown-body :deep(pre) {
|
.markdown-body :deep(pre) {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
border-radius: 0.75rem;
|
border-radius: 0.75rem;
|
||||||
background: #111827;
|
|
||||||
color: #f9fafb;
|
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,4 +174,49 @@ const formatDateTime = (dateString) => new Date(dateString).toLocaleString();
|
|||||||
border-left: 4px solid #748ffc;
|
border-left: 4px solid #748ffc;
|
||||||
background: #f8f9ff;
|
background: #f8f9ff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Dark mode overrides */
|
||||||
|
:root[data-bs-theme="dark"] .note-meta {
|
||||||
|
border-bottom-color: #3a3f4b;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-bs-theme="dark"] .tag-chip {
|
||||||
|
background: #1e2d5f;
|
||||||
|
color: #93b4ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-bs-theme="dark"] .pinned-chip {
|
||||||
|
color: #7dd3fc;
|
||||||
|
background: #1a3a5c;
|
||||||
|
border-color: #2d6a9f;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-bs-theme="dark"] .featured-chip {
|
||||||
|
color: #fbbf24;
|
||||||
|
background: #3a2e0a;
|
||||||
|
border-color: #7a5a0a;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-bs-theme="dark"] .public-chip {
|
||||||
|
color: #67e8f9;
|
||||||
|
background: #0c2a3a;
|
||||||
|
border-color: #1d6a7a;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-bs-theme="dark"] .private-chip {
|
||||||
|
color: #c4b5fd;
|
||||||
|
background: #2d1f5e;
|
||||||
|
border-color: #5b3f9a;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-bs-theme="dark"] .protected-chip {
|
||||||
|
color: #fdba74;
|
||||||
|
background: #3a1f0a;
|
||||||
|
border-color: #7a4f1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-bs-theme="dark"] .markdown-body :deep(blockquote) {
|
||||||
|
background: #1e2430;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
184
frontend/src/components/SearchResultsPage.vue
Normal file
184
frontend/src/components/SearchResultsPage.vue
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
<template>
|
||||||
|
<section class="search-results-page">
|
||||||
|
<header class="search-results-header">
|
||||||
|
<h2>Search Results</h2>
|
||||||
|
<p v-if="query" class="search-meta">{{ totalResults }} matches for "{{ query }}"</p>
|
||||||
|
<p v-else class="search-meta">Type in the top bar and press Enter to search notes.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div v-if="!query" class="empty-state">
|
||||||
|
<i class="mdi mdi-magnify empty-state-icon" aria-hidden="true"></i>
|
||||||
|
<h3>Start your search</h3>
|
||||||
|
<p>Use a title, content keyword, or tag to find matching notes in the selected space.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="totalResults === 0" class="empty-state">
|
||||||
|
<i class="mdi mdi-file-search-outline empty-state-icon" aria-hidden="true"></i>
|
||||||
|
<h3>No matching notes</h3>
|
||||||
|
<p>Try different keywords or a shorter phrase.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else>
|
||||||
|
<NoteList :notes="paginatedNotes" :view-mode="viewMode" @select-note="emit('select-note', $event)" />
|
||||||
|
|
||||||
|
<nav v-if="totalPages > 1" class="pagination-bar" aria-label="Search result pages">
|
||||||
|
<button class="btn btn-outline-secondary" :disabled="currentPage <= 1" @click="goToPage(currentPage - 1)">Previous</button>
|
||||||
|
<span class="page-indicator">Page {{ currentPage }} of {{ totalPages }}</span>
|
||||||
|
<button class="btn btn-outline-secondary" :disabled="currentPage >= totalPages" @click="goToPage(currentPage + 1)">Next</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from "vue";
|
||||||
|
import NoteList from "./NoteList.vue";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
query: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
notes: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
currentPage: {
|
||||||
|
type: Number,
|
||||||
|
default: 1,
|
||||||
|
},
|
||||||
|
pageSize: {
|
||||||
|
type: Number,
|
||||||
|
default: 12,
|
||||||
|
},
|
||||||
|
viewMode: {
|
||||||
|
type: String,
|
||||||
|
default: "grid",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(["select-note", "page-change"]);
|
||||||
|
|
||||||
|
const totalResults = computed(() => props.notes.length);
|
||||||
|
const totalPages = computed(() => Math.max(1, Math.ceil(totalResults.value / props.pageSize)));
|
||||||
|
|
||||||
|
const normalizedPage = computed(() => {
|
||||||
|
if (!Number.isFinite(props.currentPage) || props.currentPage < 1) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
return Math.min(props.currentPage, totalPages.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
const paginatedNotes = computed(() => {
|
||||||
|
const start = (normalizedPage.value - 1) * props.pageSize;
|
||||||
|
return props.notes.slice(start, start + props.pageSize);
|
||||||
|
});
|
||||||
|
|
||||||
|
const goToPage = (page) => {
|
||||||
|
if (page < 1 || page > totalPages.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emit("page-change", page);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.search-results-page {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-results-header {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-results-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: #223149;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-meta {
|
||||||
|
margin: 0.35rem 0 0;
|
||||||
|
color: #5b6f8b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-bar {
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-indicator {
|
||||||
|
color: #4f637d;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
min-height: 48vh;
|
||||||
|
border: 1px dashed #cfdae9;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: radial-gradient(circle at 20% 20%, #f2f9ff 0%, #edf2ff 70%);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state-icon {
|
||||||
|
font-size: 4.2rem;
|
||||||
|
color: #60789a;
|
||||||
|
margin-bottom: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state h3 {
|
||||||
|
margin: 0;
|
||||||
|
color: #223149;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state p {
|
||||||
|
margin: 0.6rem 0 0;
|
||||||
|
color: #5b6f8b;
|
||||||
|
max-width: 500px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.pagination-bar {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode overrides */
|
||||||
|
:root[data-bs-theme="dark"] .search-results-header h2 {
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-bs-theme="dark"] .search-meta {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-bs-theme="dark"] .page-indicator {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-bs-theme="dark"] .empty-state {
|
||||||
|
border-color: #3a3f4b;
|
||||||
|
background: radial-gradient(circle at 20% 20%, #1a2035 0%, #1e2430 70%);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-bs-theme="dark"] .empty-state h3 {
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-bs-theme="dark"] .empty-state p {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-bs-theme="dark"] .empty-state-icon {
|
||||||
|
color: #4a6fa5;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -4,6 +4,7 @@ import router from "./router";
|
|||||||
import App from "./App.vue";
|
import App from "./App.vue";
|
||||||
import "bootstrap/dist/css/bootstrap.min.css";
|
import "bootstrap/dist/css/bootstrap.min.css";
|
||||||
import "@mdi/font/css/materialdesignicons.min.css";
|
import "@mdi/font/css/materialdesignicons.min.css";
|
||||||
|
import "highlight.js/styles/github-dark.min.css";
|
||||||
import "./assets/styles/main.css";
|
import "./assets/styles/main.css";
|
||||||
|
|
||||||
const app = createApp(App);
|
const app = createApp(App);
|
||||||
|
|||||||
@@ -153,6 +153,12 @@
|
|||||||
<div v-else class="list-group">
|
<div v-else class="list-group">
|
||||||
<div v-for="provider in providers" :key="provider.id" class="list-group-item d-flex justify-content-between align-items-center">
|
<div v-for="provider in providers" :key="provider.id" class="list-group-item d-flex justify-content-between align-items-center">
|
||||||
<div class="d-flex align-items-center gap-2">
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<i
|
||||||
|
class="mdi"
|
||||||
|
:class="provider.is_active ? 'mdi-check-circle text-success' : 'mdi-close-circle text-secondary'"
|
||||||
|
:title="provider.is_active ? 'Provider enabled' : 'Provider disabled'"
|
||||||
|
aria-hidden="true"
|
||||||
|
></i>
|
||||||
<span class="fw-semibold">{{ provider.name }}</span>
|
<span class="fw-semibold">{{ provider.name }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex gap-2">
|
<div class="d-flex gap-2">
|
||||||
@@ -277,8 +283,10 @@
|
|||||||
:mode="providerModalMode"
|
:mode="providerModalMode"
|
||||||
:provider="selectedProvider"
|
:provider="selectedProvider"
|
||||||
:submitting="submittingProviderModal"
|
:submitting="submittingProviderModal"
|
||||||
|
:deleting="deletingProviderModal"
|
||||||
@close="closeProviderModal"
|
@close="closeProviderModal"
|
||||||
@submit="submitProviderModal"
|
@submit="submitProviderModal"
|
||||||
|
@delete="deleteProviderFromModal"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -335,6 +343,7 @@ const showProviderModal = ref(false);
|
|||||||
const providerModalMode = ref("create");
|
const providerModalMode = ref("create");
|
||||||
const selectedProvider = ref(null);
|
const selectedProvider = ref(null);
|
||||||
const submittingProviderModal = ref(false);
|
const submittingProviderModal = ref(false);
|
||||||
|
const deletingProviderModal = ref(false);
|
||||||
|
|
||||||
const loadingFeatureFlags = ref(false);
|
const loadingFeatureFlags = ref(false);
|
||||||
const savingFeatureFlags = ref(false);
|
const savingFeatureFlags = ref(false);
|
||||||
@@ -584,6 +593,7 @@ const openEditProviderModal = (provider) => {
|
|||||||
const closeProviderModal = () => {
|
const closeProviderModal = () => {
|
||||||
showProviderModal.value = false;
|
showProviderModal.value = false;
|
||||||
submittingProviderModal.value = false;
|
submittingProviderModal.value = false;
|
||||||
|
deletingProviderModal.value = false;
|
||||||
selectedProvider.value = null;
|
selectedProvider.value = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -612,7 +622,7 @@ const loadProviders = async () => {
|
|||||||
loadingProviders.value = true;
|
loadingProviders.value = true;
|
||||||
clearMessages();
|
clearMessages();
|
||||||
try {
|
try {
|
||||||
const res = await apiClient.get("/api/v1/auth/providers");
|
const res = await apiClient.get("/api/v1/admin/auth/providers");
|
||||||
providers.value = res.data.providers || [];
|
providers.value = res.data.providers || [];
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = e.response?.data || "Failed to load providers.";
|
error.value = e.response?.data || "Failed to load providers.";
|
||||||
@@ -621,18 +631,26 @@ const loadProviders = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteProvider = async (provider) => {
|
const deleteProviderFromModal = async (provider) => {
|
||||||
|
if (!provider?.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!confirm(`Delete identity provider "${provider.name}"? This action cannot be undone.`)) {
|
if (!confirm(`Delete identity provider "${provider.name}"? This action cannot be undone.`)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
deletingProviderModal.value = true;
|
||||||
clearMessages();
|
clearMessages();
|
||||||
try {
|
try {
|
||||||
await apiClient.delete(`/api/v1/admin/auth/providers/${provider.id}`);
|
await apiClient.delete(`/api/v1/admin/auth/providers/${provider.id}`);
|
||||||
providers.value = providers.value.filter((item) => item.id !== provider.id);
|
providers.value = providers.value.filter((item) => item.id !== provider.id);
|
||||||
successMessage.value = `Provider "${provider.name}" deleted.`;
|
successMessage.value = `Provider "${provider.name}" deleted.`;
|
||||||
|
closeProviderModal();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = e.response?.data || "Failed to delete provider.";
|
error.value = e.response?.data || "Failed to delete provider.";
|
||||||
|
} finally {
|
||||||
|
deletingProviderModal.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -901,4 +919,36 @@ onMounted(async () => {
|
|||||||
gap: 0.65rem;
|
gap: 0.65rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Dark mode overrides */
|
||||||
|
:root[data-bs-theme="dark"] .admin-topbar {
|
||||||
|
border-bottom-color: #3a3f4b;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-bs-theme="dark"] .admin-sidebar {
|
||||||
|
background: #21252e;
|
||||||
|
border-right-color: #3a3f4b;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-bs-theme="dark"] .admin-nav .nav-link {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-bs-theme="dark"] .admin-nav .nav-link:hover {
|
||||||
|
background: #2d3748;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-bs-theme="dark"] .admin-nav .nav-link.active {
|
||||||
|
background: #e2e8f0;
|
||||||
|
color: #1a1d23;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-bs-theme="dark"] .user-meta-value {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-bs-theme="dark"] .admin-section {
|
||||||
|
background-color: #21252e;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -88,73 +88,33 @@ const startProviderLogin = (providerId) => {
|
|||||||
window.location.href = `${apiClient.defaults.baseURL}/api/v1/auth/providers/${providerId}/start`;
|
window.location.href = `${apiClient.defaults.baseURL}/api/v1/auth/providers/${providerId}/start`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const decodeBase64Url = (value) => {
|
|
||||||
const normalized = value.replace(/-/g, "+").replace(/_/g, "/");
|
|
||||||
const padding = normalized.length % 4;
|
|
||||||
const padded = padding === 0 ? normalized : `${normalized}${"=".repeat(4 - padding)}`;
|
|
||||||
return atob(padded);
|
|
||||||
};
|
|
||||||
|
|
||||||
const decodeBase64UrlUTF8 = (value) => {
|
|
||||||
const binary = decodeBase64Url(value);
|
|
||||||
const bytes = Uint8Array.from(binary, (ch) => ch.charCodeAt(0));
|
|
||||||
return new TextDecoder().decode(bytes);
|
|
||||||
};
|
|
||||||
|
|
||||||
const readUserFromQuery = (params) => {
|
|
||||||
const plainUserJSON = params.get("user_json");
|
|
||||||
if (plainUserJSON) {
|
|
||||||
return JSON.parse(plainUserJSON);
|
|
||||||
}
|
|
||||||
|
|
||||||
const encodedUser = params.get("user");
|
|
||||||
if (encodedUser) {
|
|
||||||
return JSON.parse(decodeBase64UrlUTF8(encodedUser));
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const completeOAuthRedirect = async () => {
|
const completeOAuthRedirect = async () => {
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
const status = params.get("status");
|
const status = params.get("status");
|
||||||
const accessToken = params.get("access_token") || params.get("accessToken") || params.get("token");
|
|
||||||
|
|
||||||
if (status === "oauth_error") {
|
if (status === "oauth_error") {
|
||||||
error.value = params.get("message") || "Provider sign-in failed.";
|
error.value = params.get("message") || "Provider sign-in failed.";
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Accept callback payloads even when `status` is missing.
|
if (status !== "oauth_success") {
|
||||||
if (status !== "oauth_success" && !accessToken) {
|
|
||||||
if (status === "oauth_error") {
|
|
||||||
error.value = params.get("message") || "Provider sign-in failed.";
|
|
||||||
}
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!accessToken) {
|
try {
|
||||||
error.value = "Provider sign-in returned an incomplete session.";
|
await authStore.ensureInitialized();
|
||||||
|
} catch {
|
||||||
|
error.value = "Unable to restore provider session.";
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
|
||||||
const user = readUserFromQuery(params);
|
|
||||||
if (!user) {
|
|
||||||
error.value = "Provider sign-in returned an incomplete session.";
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
authStore.setSession({ access_token: accessToken, user });
|
|
||||||
await router.replace("/");
|
|
||||||
} catch {
|
|
||||||
error.value = "Unable to restore the provider session.";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (authStore.isAuthenticated) {
|
if (authStore.isAuthenticated) {
|
||||||
window.location.replace("/");
|
await router.replace("/");
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
error.value = "Provider sign-in returned an incomplete session.";
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -163,6 +123,8 @@ onMounted(async () => {
|
|||||||
registrationEnabled.value = !!flags.registration_enabled;
|
registrationEnabled.value = !!flags.registration_enabled;
|
||||||
providerLoginEnabled.value = !!flags.provider_login_enabled;
|
providerLoginEnabled.value = !!flags.provider_login_enabled;
|
||||||
|
|
||||||
|
await authStore.ensureInitialized();
|
||||||
|
|
||||||
if (authStore.isAuthenticated) {
|
if (authStore.isAuthenticated) {
|
||||||
await router.replace("/");
|
await router.replace("/");
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -4,39 +4,6 @@ import { useSettingsStore } from "../stores/settingsStore";
|
|||||||
import LoginPage from "../pages/Login.vue";
|
import LoginPage from "../pages/Login.vue";
|
||||||
import RegisterPage from "../pages/Register.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 = [
|
const routes = [
|
||||||
{
|
{
|
||||||
path: "/login",
|
path: "/login",
|
||||||
@@ -54,6 +21,12 @@ const routes = [
|
|||||||
component: () => import("../pages/Home.vue"),
|
component: () => import("../pages/Home.vue"),
|
||||||
meta: { requiresAuth: true },
|
meta: { requiresAuth: true },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/search",
|
||||||
|
name: "Search",
|
||||||
|
component: () => import("../pages/Home.vue"),
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "/admin",
|
path: "/admin",
|
||||||
name: "Admin",
|
name: "Admin",
|
||||||
@@ -81,25 +54,7 @@ router.beforeEach(async (to, from, next) => {
|
|||||||
const authStore = useAuthStore();
|
const authStore = useAuthStore();
|
||||||
const settingsStore = useSettingsStore();
|
const settingsStore = useSettingsStore();
|
||||||
|
|
||||||
// Only attempt OAuth callback restoration if actual OAuth query params are present
|
await authStore.ensureInitialized();
|
||||||
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") {
|
if (to.path === "/register") {
|
||||||
await settingsStore.loadFeatureFlags();
|
await settingsStore.loadFeatureFlags();
|
||||||
|
|||||||
@@ -3,23 +3,57 @@ import { useAuthStore } from "../stores/authStore";
|
|||||||
|
|
||||||
const apiClient = axios.create({
|
const apiClient = axios.create({
|
||||||
baseURL: import.meta.env.VITE_API_BASE_URL || "http://localhost:8080",
|
baseURL: import.meta.env.VITE_API_BASE_URL || "http://localhost:8080",
|
||||||
|
withCredentials: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
apiClient.interceptors.request.use((config) => {
|
let isRefreshing = false;
|
||||||
const authStore = useAuthStore();
|
let refreshSubscribers = [];
|
||||||
if (authStore.accessToken) {
|
|
||||||
config.headers.Authorization = `Bearer ${authStore.accessToken}`;
|
function onRefreshed() {
|
||||||
}
|
refreshSubscribers.forEach((cb) => cb());
|
||||||
return config;
|
refreshSubscribers = [];
|
||||||
});
|
}
|
||||||
|
|
||||||
apiClient.interceptors.response.use(
|
apiClient.interceptors.response.use(
|
||||||
(response) => response,
|
(response) => response,
|
||||||
(error) => {
|
async (error) => {
|
||||||
if (error.response?.status === 401) {
|
const originalRequest = error.config;
|
||||||
const authStore = useAuthStore();
|
|
||||||
authStore.logout();
|
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||||
|
// Avoid retrying the refresh request itself
|
||||||
|
if (originalRequest.url?.includes("/auth/refresh") || originalRequest.url?.includes("/auth/login")) {
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
authStore.clearSession();
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRefreshing) {
|
||||||
|
// Queue the request until the ongoing refresh completes
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
refreshSubscribers.push(() => {
|
||||||
|
originalRequest._retry = true;
|
||||||
|
apiClient(originalRequest).then(resolve).catch(reject);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
originalRequest._retry = true;
|
||||||
|
isRefreshing = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiClient.post("/api/v1/auth/refresh");
|
||||||
|
onRefreshed();
|
||||||
|
return apiClient(originalRequest);
|
||||||
|
} catch {
|
||||||
|
refreshSubscribers = [];
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
authStore.clearSession();
|
||||||
|
return Promise.reject(error);
|
||||||
|
} finally {
|
||||||
|
isRefreshing = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,10 +3,11 @@ import { ref, computed } from "vue";
|
|||||||
import apiClient from "../services/apiClient";
|
import apiClient from "../services/apiClient";
|
||||||
|
|
||||||
export const useAuthStore = defineStore("auth", () => {
|
export const useAuthStore = defineStore("auth", () => {
|
||||||
const storedUser = localStorage.getItem("user");
|
const user = ref(null);
|
||||||
const user = ref(storedUser ? JSON.parse(storedUser) : null);
|
const initialized = ref(false);
|
||||||
const accessToken = ref(localStorage.getItem("accessToken"));
|
let initPromise = null;
|
||||||
const isAuthenticated = computed(() => !!accessToken.value && !!user.value);
|
|
||||||
|
const isAuthenticated = computed(() => !!user.value);
|
||||||
const isAdmin = computed(() => hasPermission("*") || hasPermission("admin.access"));
|
const isAdmin = computed(() => hasPermission("*") || hasPermission("admin.access"));
|
||||||
|
|
||||||
const normalizePermission = (permission) => (permission || "").trim().toLowerCase();
|
const normalizePermission = (permission) => (permission || "").trim().toLowerCase();
|
||||||
@@ -46,10 +47,36 @@ export const useAuthStore = defineStore("auth", () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const setSession = (responseData) => {
|
const setSession = (responseData) => {
|
||||||
accessToken.value = responseData.access_token;
|
user.value = responseData?.user || null;
|
||||||
user.value = responseData.user;
|
initialized.value = true;
|
||||||
localStorage.setItem("accessToken", accessToken.value);
|
};
|
||||||
localStorage.setItem("user", JSON.stringify(user.value));
|
|
||||||
|
const clearSession = () => {
|
||||||
|
user.value = null;
|
||||||
|
initialized.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadSession = async () => {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get("/api/v1/auth/me");
|
||||||
|
user.value = response.data?.user || null;
|
||||||
|
} catch {
|
||||||
|
user.value = null;
|
||||||
|
} finally {
|
||||||
|
initialized.value = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const ensureInitialized = async () => {
|
||||||
|
if (initialized.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!initPromise) {
|
||||||
|
initPromise = loadSession().finally(() => {
|
||||||
|
initPromise = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await initPromise;
|
||||||
};
|
};
|
||||||
|
|
||||||
const register = async (email, username, password, firstName = "", lastName = "") => {
|
const register = async (email, username, password, firstName = "", lastName = "") => {
|
||||||
@@ -87,20 +114,20 @@ export const useAuthStore = defineStore("auth", () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const logout = () => {
|
const logout = () => {
|
||||||
accessToken.value = null;
|
apiClient.post("/api/v1/auth/logout").catch(() => {});
|
||||||
user.value = null;
|
clearSession();
|
||||||
localStorage.removeItem("accessToken");
|
|
||||||
localStorage.removeItem("user");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
user,
|
user,
|
||||||
accessToken,
|
initialized,
|
||||||
isAuthenticated,
|
isAuthenticated,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
hasPermission,
|
hasPermission,
|
||||||
hasSpacePermission,
|
hasSpacePermission,
|
||||||
setSession,
|
setSession,
|
||||||
|
clearSession,
|
||||||
|
ensureInitialized,
|
||||||
register,
|
register,
|
||||||
login,
|
login,
|
||||||
logout,
|
logout,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export const useSpaceStore = defineStore("space", () => {
|
|||||||
const spaces = ref([]);
|
const spaces = ref([]);
|
||||||
const currentSpace = ref(null);
|
const currentSpace = ref(null);
|
||||||
const notes = ref([]);
|
const notes = ref([]);
|
||||||
|
const searchResults = ref([]);
|
||||||
const notesSkip = ref(0);
|
const notesSkip = ref(0);
|
||||||
const notesLimit = ref(20);
|
const notesLimit = ref(20);
|
||||||
const notesHasMore = ref(true);
|
const notesHasMore = ref(true);
|
||||||
@@ -188,20 +189,30 @@ export const useSpaceStore = defineStore("space", () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const searchNotes = async (query) => {
|
const searchNotes = async (query) => {
|
||||||
|
if (!currentSpace.value?.id) {
|
||||||
|
searchResults.value = [];
|
||||||
|
return [];
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get(`/api/v1/spaces/${currentSpace.value.id}/notes/search`, { params: { q: query } });
|
const response = await apiClient.get(`/api/v1/spaces/${currentSpace.value.id}/notes/search`, { params: { q: query } });
|
||||||
notes.value = response.data || [];
|
searchResults.value = response.data || [];
|
||||||
notesHasMore.value = false;
|
return searchResults.value;
|
||||||
notesSkip.value = notes.value.length;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error searching notes:", error);
|
console.error("Error searching notes:", error);
|
||||||
|
searchResults.value = [];
|
||||||
|
return [];
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const clearSearchResults = () => {
|
||||||
|
searchResults.value = [];
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
spaces,
|
spaces,
|
||||||
currentSpace,
|
currentSpace,
|
||||||
notes,
|
notes,
|
||||||
|
searchResults,
|
||||||
notesHasMore,
|
notesHasMore,
|
||||||
notesLoading,
|
notesLoading,
|
||||||
categories,
|
categories,
|
||||||
@@ -220,5 +231,6 @@ export const useSpaceStore = defineStore("space", () => {
|
|||||||
updateNote,
|
updateNote,
|
||||||
deleteNote,
|
deleteNote,
|
||||||
searchNotes,
|
searchNotes,
|
||||||
|
clearSearchResults,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,3 +1,19 @@
|
|||||||
|
import { marked } from "marked";
|
||||||
|
import { markedHighlight } from "marked-highlight";
|
||||||
|
import hljs from "highlight.js/lib/common";
|
||||||
|
|
||||||
|
marked.use(
|
||||||
|
markedHighlight({
|
||||||
|
langPrefix: "hljs language-",
|
||||||
|
highlight(code, lang) {
|
||||||
|
if (lang && hljs.getLanguage(lang)) {
|
||||||
|
return hljs.highlight(code, { language: lang }).value;
|
||||||
|
}
|
||||||
|
return hljs.highlightAuto(code).value;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Preprocesses markdown content to support extended image size syntax:
|
* Preprocesses markdown content to support extended image size syntax:
|
||||||
*
|
*
|
||||||
@@ -24,3 +40,7 @@ export function preprocessMarkdown(content) {
|
|||||||
return `<img ${attrs}>`;
|
return `<img ${attrs}>`;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function renderMarkdown(content) {
|
||||||
|
return marked.parse(preprocessMarkdown(content || ""), { gfm: true });
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,22 @@
|
|||||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
// @vitest-environment node
|
||||||
import { useAuthStore } from "../../src/stores/authStore";
|
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { createPinia, setActivePinia } from "pinia";
|
import { createPinia, setActivePinia } from "pinia";
|
||||||
|
|
||||||
|
vi.mock("../src/services/apiClient.js", () => ({
|
||||||
|
default: {
|
||||||
|
get: vi.fn(),
|
||||||
|
post: vi.fn(() => Promise.resolve({})),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import apiClient from "../src/services/apiClient.js";
|
||||||
|
import { useAuthStore } from "../src/stores/authStore.js";
|
||||||
|
|
||||||
describe("Auth Store", () => {
|
describe("Auth Store", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
setActivePinia(createPinia());
|
setActivePinia(createPinia());
|
||||||
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should initialize with no user", () => {
|
it("should initialize with no user", () => {
|
||||||
@@ -13,27 +25,76 @@ describe("Auth Store", () => {
|
|||||||
expect(store.user).toBeNull();
|
expect(store.user).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should store user data on login", () => {
|
it("should store user data with setSession", () => {
|
||||||
const store = useAuthStore();
|
const store = useAuthStore();
|
||||||
|
|
||||||
// Mock user data
|
|
||||||
const mockUser = {
|
const mockUser = {
|
||||||
id: "123",
|
id: "123",
|
||||||
email: "test@example.com",
|
email: "test@example.com",
|
||||||
username: "testuser",
|
username: "testuser",
|
||||||
|
permissions: ["space.demo.note.create"],
|
||||||
};
|
};
|
||||||
|
|
||||||
// In a real test, you'd mock the API call
|
store.setSession({ user: mockUser });
|
||||||
// For now, just test the store structure
|
|
||||||
expect(store.user).toBeNull();
|
expect(store.isAuthenticated).toBe(true);
|
||||||
|
expect(store.user).toEqual(mockUser);
|
||||||
|
expect(store.hasPermission("space.demo.note.create")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should clear user data on logout", () => {
|
it("should login and persist returned user", async () => {
|
||||||
const store = useAuthStore();
|
const store = useAuthStore();
|
||||||
|
apiClient.post.mockResolvedValueOnce({
|
||||||
|
data: {
|
||||||
|
user: {
|
||||||
|
id: "123",
|
||||||
|
email: "test@example.com",
|
||||||
|
username: "testuser",
|
||||||
|
permissions: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await store.login(" test@example.com ", "password123");
|
||||||
|
|
||||||
|
expect(apiClient.post).toHaveBeenCalledWith("/api/v1/auth/login", {
|
||||||
|
email: "test@example.com",
|
||||||
|
password: "password123",
|
||||||
|
});
|
||||||
|
expect(result.user.username).toBe("testuser");
|
||||||
|
expect(store.user?.username).toBe("testuser");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should clear user data on logout", async () => {
|
||||||
|
const store = useAuthStore();
|
||||||
|
store.setSession({
|
||||||
|
user: {
|
||||||
|
id: "123",
|
||||||
|
email: "test@example.com",
|
||||||
|
username: "testuser",
|
||||||
|
permissions: ["space.demo.settings.delete"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
store.logout();
|
store.logout();
|
||||||
|
|
||||||
expect(store.isAuthenticated).toBe(false);
|
expect(store.isAuthenticated).toBe(false);
|
||||||
expect(store.user).toBeNull();
|
expect(store.user).toBeNull();
|
||||||
expect(store.accessToken).toBeNull();
|
expect(apiClient.post).toHaveBeenCalledWith("/api/v1/auth/logout");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should evaluate space permissions using the space permission key", () => {
|
||||||
|
const store = useAuthStore();
|
||||||
|
store.setSession({
|
||||||
|
user: {
|
||||||
|
id: "123",
|
||||||
|
email: "test@example.com",
|
||||||
|
username: "testuser",
|
||||||
|
permissions: ["space.docs.settings.delete", "space.*.note.create"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(store.hasSpacePermission({ permission_key: "docs" }, "settings.delete")).toBe(true);
|
||||||
|
expect(store.hasSpacePermission({ permission_key: "docs" }, "note.create")).toBe(true);
|
||||||
|
expect(store.hasSpacePermission({ permission_key: "docs" }, "note.delete")).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user