commit 9d117a5887d6bcce5ad2e1f6daeac3094b5f4330 Author: Marcos Date: Mon May 18 18:43:33 2026 +0200 Implement initial API structure with Docker support, CI/CD workflows, and authentication features diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..8c8a29a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +node_modules +.git +.gitea +.env +.envGitea +docs +README.md +pnpm-debug.log +npm-debug.log \ No newline at end of file diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..e9ce9a6 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,18 @@ +name: CI - Prueba de Compilación + +on: + pull_request: + branches: [main, master] + +jobs: + test-build: + runs-on: ubuntu-latest + steps: + - name: 📥 Descargar código + uses: actions/checkout@v6 + + - name: 🛠️ Probar Build de Docker (Sin desplegar) + run: | + # Esto solo construye la imagen para ver si hay errores + # pero no levanta el contenedor en tu servidor + docker build . diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..4e83acd --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,24 @@ +name: Despliegue Automático + +on: + push: + branches: + - main + - master + workflow_dispatch: + +jobs: + desplegar: + runs-on: ubuntu-latest + + steps: + - name: 📥 Descargar el código nuevo + uses: actions/checkout@v6 + + - name: 🔐 Generar archivo .env desde secrets + run: | + printf '%s\n' "${{ secrets.ENV_API_NOTAS }}" > .env + + - name: 🚀 Construir y levantar Docker + run: | + docker compose up -d --build diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..43acf8a --- /dev/null +++ b/.gitignore @@ -0,0 +1,47 @@ +# Dependencies +node_modules/ +pnpm-lock.yaml + +# Environment variables +.env +.env.local +.env.*.local +.envGitea + +# IDE / Editor +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store + +# Logs +logs/ +*.log +npm-debug.log* +pnpm-debug.log* + +# Runtime data +pids/ +*.pid +*.seed +*.pid.lock + +# Database +*.sqlite +*.db +*.sqlite3 + +# OS +.DS_Store +Thumbs.db + +# Build output +dist/ +build/ + +# Temporary files +tmp/ +temp/ +*.tmp diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2d9fd03 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,28 @@ +FROM node:22-bookworm-slim AS base + +ENV PNPM_HOME=/pnpm +ENV PATH=$PNPM_HOME:$PATH + +RUN corepack enable + +WORKDIR /app + +FROM base AS deps + +RUN apt-get update \ + && apt-get install -y --no-install-recommends python3 make g++ \ + && rm -rf /var/lib/apt/lists/* + +COPY package.json pnpm-lock.yaml ./ +RUN pnpm install --frozen-lockfile --prod + +FROM base AS production + +ENV NODE_ENV=production + +COPY --from=deps /app/node_modules ./node_modules +COPY . . + +EXPOSE 3000 + +CMD ["pnpm", "start"] \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..4b88aab --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,42 @@ +services: + db: + image: postgres:15-alpine + container_name: notas_postgres + restart: unless-stopped + env_file: + - .env + environment: + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: ${POSTGRES_DB} + volumes: + - db_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] + interval: 10s + timeout: 5s + retries: 5 + + app: + build: + context: . + dockerfile: Dockerfile + container_name: notas_api + restart: unless-stopped + depends_on: + db: + condition: service_healthy + env_file: + - .env + environment: + NODE_ENV: ${NODE_ENV:-production} + APP_PORT: ${APP_PORT:-3000} + DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB} + JWT_SECRET: ${JWT_SECRET} + DEFAULT_USER: ${DEFAULT_USER:-admin} + DEFAULT_PASS: ${DEFAULT_PASS:-supersecreta123} + ports: + - "${HOST_PORT:-3005}:3000" + +volumes: + db_data: \ No newline at end of file diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..b80d8a1 --- /dev/null +++ b/docs/API.md @@ -0,0 +1,289 @@ +# API de `api_notas` + +Esta guía resume las rutas disponibles, qué datos esperan y los tipos más importantes del proyecto. + +## Base URL + +Todas las rutas cuelgan de: + +```text +/api +``` + +Ejemplo local: + +```text +http://localhost:3000/api +``` + +## Tipos principales + +- `UUID`: identificador único en formato UUID. +- `string`: texto plano o cifrado. +- `boolean`: `true` o `false`. +- `number`: valor numérico. +- `ISO date string`: fecha en formato ISO 8601, por ejemplo `2026-05-18T10:00:00.000Z`. + +## Rutas disponibles + +### `GET /api/status` + +Comprueba que la API está viva. + +Respuesta ejemplo: + +```json +{ + "message": "¡API funcionando correctamente!" +} +``` + +### `POST /api/auth/register` + +Crea un usuario nuevo. + +Body: + +```json +{ + "username": "maria", + "password": "123456", + "encrypted_master_key": "texto_cifrado_opcional" +} +``` + +Campos: + +- `username`: `string`, obligatorio. +- `password`: `string`, obligatorio. +- `encrypted_master_key`: `string | null`, opcional. + +Respuesta ejemplo: + +```json +{ + "message": "Usuario registrado con éxito", + "accessToken": "jwt_access_token", + "refreshToken": "token_largo_aleatorio" +} +``` + +### `POST /api/auth/login` + +Inicia sesión y devuelve tokens. + +Body: + +```json +{ + "username": "maria", + "password": "123456", + "deviceName": "Flutter Pixel" +} +``` + +Campos: + +- `username`: `string`, obligatorio. +- `password`: `string`, obligatorio. +- `deviceName`: `string`, opcional. + +Respuesta ejemplo: + +```json +{ + "message": "Login exitoso", + "accessToken": "jwt_access_token", + "refreshToken": "token_largo_aleatorio", + "encrypted_master_key": "texto_cifrado" +} +``` + +### `POST /api/auth/refresh` + +Genera un nuevo `accessToken` usando un `refreshToken` válido. + +Body: + +```json +{ + "refreshToken": "token_largo_aleatorio" +} +``` + +Campos: + +- `refreshToken`: `string`, obligatorio. + +Respuesta ejemplo: + +```json +{ + "accessToken": "nuevo_jwt_access_token" +} +``` + +### `POST /api/auth/logout` + +Revoca la sesión del dispositivo actual. + +Body: + +```json +{ + "refreshToken": "token_largo_aleatorio" +} +``` + +Campos: + +- `refreshToken`: `string`, obligatorio. + +Respuesta ejemplo: + +```json +{ + "message": "Sesión cerrada en el dispositivo actual" +} +``` + +### `POST /api/sync` + +Endpoint único de sincronización offline-first. + +Autenticación requerida: + +- Enviar `Authorization: Bearer ` (obligatorio). El servidor ya no acepta `userId` en el body. + +#### Request + +Body ejemplo: + +```json +{ + "lastSyncAt": "2026-05-18T10:00:00.000Z", + "changes": { + "categories": [ + { + "id": "uuid-categoria-1", + "encrypted_name": "texto_cifrado...", + "serverVersion": 1, + "isDeleted": false, + "updatedAt": "2026-05-18T10:05:00.000Z" + } + ], + "notes": [ + { + "id": "uuid-nota-1", + "categoryId": "uuid-categoria-1", + "encrypted_title": "titulo_cifrado...", + "encrypted_body": "cuerpo_cifrado...", + "serverVersion": 1, + "position": 2000, + "isDeleted": true, + "updatedAt": "2026-05-18T10:10:00.000Z" + } + ] + } +} +``` + +Campos: + +- `lastSyncAt`: `string` ISO, opcional. Si no se envía, el servidor usa `1970-01-01T00:00:00.000Z`. +- `changes`: `object`, obligatorio. +- `changes.categories`: `array`, opcional. +- `changes.notes`: `array`, opcional. + +#### Estructura de categoría + +```json +{ + "id": "uuid", + "encrypted_name": "texto_cifrado", + "serverVersion": 1, + "isDeleted": false, + "updatedAt": "2026-05-18T10:05:00.000Z" +} +``` + +Campos: + +- `id`: `UUID`, obligatorio. +- `encrypted_name`: `string`, obligatorio. +- `serverVersion`: `number` entero >= 0, obligatorio. Es la versión base local con la que se hizo el cambio. +- `isDeleted`: `boolean`, opcional, por defecto `false`. +- `updatedAt`: `ISO date string`, opcional (solo informativo para UI). + +#### Estructura de nota + +```json +{ + "id": "uuid", + "categoryId": "uuid-categoria", + "encrypted_title": "titulo_cifrado", + "encrypted_body": "cuerpo_cifrado", + "serverVersion": 1, + "position": 2000, + "isDeleted": false, + "updatedAt": "2026-05-18T10:10:00.000Z" +} +``` + +Campos: + +- `id`: `UUID`, obligatorio. +- `categoryId`: `UUID | null`, opcional. +- `encrypted_title`: `string`, obligatorio. +- `encrypted_body`: `string`, obligatorio. +- `serverVersion`: `number` entero >= 0, obligatorio. Es la versión base local con la que se hizo el cambio. +- `position`: `number`, opcional. +- `isDeleted`: `boolean`, opcional, por defecto `false`. +- `updatedAt`: `ISO date string`, opcional (solo informativo para UI). + +#### Response + +Respuesta ejemplo (nuevo contrato): + +```json +{ + "serverTimestamp": "2026-05-18T10:20:00.000Z", + "synced": true, + "changes": { + "categories": [], + "notes": [ + { + "id": "uuid-nota-2", + "categoryId": null, + "encrypted_title": "titulo_cifrado...", + "encrypted_body": "cuerpo_cifrado...", + "serverVersion": 3, + "position": 0, + "isDeleted": false, + "updatedAt": "2026-05-18T10:15:00.000Z" + } + ] + } +} +``` + +Campos de salida: + +- `serverTimestamp`: `string` ISO con la hora del servidor. +- `synced`: `boolean`. +- `changes.categories`: array con todas las categorías cambiadas en el servidor desde `lastSyncAt`, incluidas las creadas por otros dispositivos. +- `changes.notes`: array con todas las notas cambiadas en el servidor desde `lastSyncAt`, incluidas las creadas por otros dispositivos y las copias creadas automáticamente en caso de conflicto. + +## Reglas de sincronización + +- El cliente debe guardar su último `lastSyncAt`. +- Un borrado no elimina el registro localmente: se marca con `isDeleted: true`. +- El servidor decide conflictos comparando `serverVersion` (no `updatedAt`). +- En categorías, solo se actualiza si `incoming.serverVersion === serverVersion`. Si no coincide, gana el servidor y no se crea duplicado. +- Si la versión entrante de una nota coincide con la versión actual del servidor, acepta el cambio y sube versión (`v -> v+1`). +- Si la versión del servidor es mayor que la entrante en una nota, el servidor crea una nueva nota duplicada con el contenido entrante para no perder datos. + +## Notas importantes + +- Las notas y categorías están pensadas para contenido cifrado del lado del cliente. +- El servidor guarda `encrypted_title`, `encrypted_body` y `encrypted_name`, pero no interpreta el contenido. diff --git a/index.js b/index.js new file mode 100644 index 0000000..b7a3226 --- /dev/null +++ b/index.js @@ -0,0 +1,18 @@ +require('dotenv').config(); +const app = require('./src/app'); +const initDB = require('./src/initDB'); + +const puertoInterno = process.env.APP_PORT || process.env.PORT || 3000; + +async function arrancarServidor() { + // 1. Preparamos la base de datos + await initDB(); + + // 2. Encendemos el servidor web + app.listen(puertoInterno, '0.0.0.0', () => { + console.log(`🚀 Servidor API escuchando en http://0.0.0.0:${puertoInterno}/api/status`); + console.log(`🌐 Desde el móvil usa la IP del portátil: http://:${puertoInterno}/api/status`); + }); +} + +arrancarServidor(); \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..9cf4f7b --- /dev/null +++ b/package.json @@ -0,0 +1,28 @@ +{ + "name": "api_notas", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "start": "node index.js", + "dev": "nodemon index.js" + }, + "keywords": [], + "author": "", + "license": "ISC", + "packageManager": "pnpm@10.33.0", + "dependencies": { + "bcryptjs": "^3.0.3", + "cors": "^2.8.6", + "dotenv": "^17.4.2", + "express": "^5.2.1", + "jsonwebtoken": "^9.0.3", + "pg": "^8.20.0", + "pg-hstore": "^2.3.4", + "sequelize": "^6.37.8", + "sqlite3": "^6.0.1" + }, + "devDependencies": { + "nodemon": "^3.1.14" + } +} diff --git a/src/app.js b/src/app.js new file mode 100644 index 0000000..31279bf --- /dev/null +++ b/src/app.js @@ -0,0 +1,19 @@ +const express = require('express'); +const cors = require('cors'); +const Routes = require('./routes'); + +const app = express(); + +// Middlewares +app.use(cors()); +app.use(express.json()); + +// Montamos las rutas con el prefijo de versión +app.use('/api', Routes); + +// Manejo de rutas no encontradas (404) +app.use((req, res) => { + res.status(404).json({ error: 'Ruta no encontrada' }); +}); + +module.exports = app; \ No newline at end of file diff --git a/src/config/database.js b/src/config/database.js new file mode 100644 index 0000000..dbfc77d --- /dev/null +++ b/src/config/database.js @@ -0,0 +1,9 @@ +const { Sequelize } = require('sequelize'); +require('dotenv').config(); + +const sequelize = new Sequelize(process.env.DATABASE_URL, { + dialect: 'postgres', + logging: false, // Cambia a console.log si quieres ver las consultas SQL +}); + +module.exports = sequelize; \ No newline at end of file diff --git a/src/controllers/authController.js b/src/controllers/authController.js new file mode 100644 index 0000000..7c99529 --- /dev/null +++ b/src/controllers/authController.js @@ -0,0 +1,77 @@ +const authService = require('../services/authService'); + +// 1. REGISTRO +const register = async (req, res) => { + try { + console.log(req.body); // Log completo del body para depuración + const { username, password, encrypted_master_key } = req.body; + + if (!username || !password) { + return res.status(400).json({ error: 'Usuario y contraseña son obligatorios' }); + } + console.log(`Intentando registrar usuario: ${username}`); + console.log(`Encrypted Master Key recibida: ${encrypted_master_key}`); + // Registramos el usuario y emitimos tokens para entrar directamente + await authService.register(username, password, encrypted_master_key); + + // Generamos tokens reutilizando el flujo de login (no hay deviceName en register) + const nombreDispositivo = req.headers['user-agent'] || 'Dispositivo Desconocido'; + const { accessToken, refreshToken } = await authService.login(username, password, nombreDispositivo); + + res.status(201).json({ + message: 'Usuario registrado con éxito', + accessToken, + refreshToken + }); + } catch (error) { + res.status(400).json({ error: error.message }); + } +}; + +// 2. LOGIN +const login = async (req, res) => { + try { + const { username, password, deviceName } = req.body; + + // Si Flutter no envía un nombre, intentamos leer el User-Agent + const nombreDispositivo = deviceName || req.headers['user-agent'] || 'Dispositivo Desconocido'; + + const { user, accessToken, refreshToken } = await authService.login(username, password, nombreDispositivo); + + res.json({ + message: 'Login exitoso', + accessToken, + refreshToken, + encrypted_master_key: user.encrypted_master_key + }); + } catch (error) { + res.status(401).json({ error: error.message }); + } +}; + +// 3. REFRESCAR TOKEN +const refresh = async (req, res) => { + try { + const { refreshToken } = req.body; + if (!refreshToken) return res.status(400).json({ error: 'Refresh token es obligatorio' }); + + const { accessToken } = await authService.refreshAccessToken(refreshToken); + res.json({ accessToken }); + } catch (error) { + res.status(401).json({ error: error.message }); + } +}; + +// 4. CERRAR SESIÓN +const logout = async (req, res) => { + try { + const { refreshToken } = req.body; + await authService.logoutDevice(refreshToken); + res.json({ message: 'Sesión cerrada en el dispositivo actual' }); + } catch (error) { + res.status(400).json({ error: error.message }); + } +}; + +// ¡EXPORTAMOS LAS 4 FUNCIONES! +module.exports = { register, login, refresh, logout }; \ No newline at end of file diff --git a/src/controllers/syncController.js b/src/controllers/syncController.js new file mode 100644 index 0000000..5ed8080 --- /dev/null +++ b/src/controllers/syncController.js @@ -0,0 +1,39 @@ +const jwt = require('jsonwebtoken'); +const syncService = require('../services/syncService'); + +const JWT_SECRET = process.env.JWT_SECRET; + +if (!JWT_SECRET) { + throw new Error('JWT_SECRET no está definido'); +} + +const sync = async (req, res) => { + try { + const authorizationHeader = req.headers.authorization || ''; + if (!authorizationHeader.startsWith('Bearer ')) { + return res.status(401).json({ error: 'Authorization header missing or invalid' }); + } + + const token = authorizationHeader.slice(7).trim(); + const payload = jwt.verify(token, JWT_SECRET); + const userId = payload && payload.id; + if (!userId) return res.status(401).json({ error: 'Usuario inválido en token' }); + + const result = await syncService.sync(userId, req.body); + + res.json(result); + } catch (error) { + if (error.name === 'JsonWebTokenError' || error.name === 'TokenExpiredError') { + return res.status(401).json({ error: 'Token inválido o expirado' }); + } + + const statusCode = ( + error.message.includes('obligatorio') || + error.message.includes('debe incluir') || + error.message.includes('inválida') + ) ? 400 : 500; + res.status(statusCode).json({ error: error.message }); + } +}; + +module.exports = { sync }; \ No newline at end of file diff --git a/src/initDB.js b/src/initDB.js new file mode 100644 index 0000000..cbaeec6 --- /dev/null +++ b/src/initDB.js @@ -0,0 +1,16 @@ +const sequelize = require('./config/database'); + +async function initDB() { + try { + await sequelize.sync({ alter: true }); + console.log('✅ Base de datos conectada y tablas sincronizadas.'); + + // console.log('ℹ️ Creación de usuario por defecto desactivada.'); + + } catch (error) { + console.error('❌ Error fatal al iniciar la base de datos:', error); + process.exit(1); + } +} + +module.exports = initDB; \ No newline at end of file diff --git a/src/models/Category.js b/src/models/Category.js new file mode 100644 index 0000000..eefb23b --- /dev/null +++ b/src/models/Category.js @@ -0,0 +1,29 @@ +const { DataTypes } = require('sequelize'); +const sequelize = require('../config/database'); +const User = require('./User'); + +const Category = sequelize.define('Category', { + id: { + type: DataTypes.UUID, + primaryKey: true, + }, + // Como somos E2EE, el nombre de la categoría también viaja encriptado + encrypted_name: { + type: DataTypes.TEXT, + allowNull: false + }, + serverVersion: { + type: DataTypes.INTEGER, + defaultValue: 1, + }, + isDeleted: { + type: DataTypes.BOOLEAN, + defaultValue: false + } +}); + +// Relación: Un Usuario tiene muchas Categorías +User.hasMany(Category, { foreignKey: 'userId' }); +Category.belongsTo(User, { foreignKey: 'userId' }); + +module.exports = Category; \ No newline at end of file diff --git a/src/models/Note.js b/src/models/Note.js new file mode 100644 index 0000000..d40201d --- /dev/null +++ b/src/models/Note.js @@ -0,0 +1,41 @@ +const { DataTypes } = require('sequelize'); +const sequelize = require('../config/database'); +const User = require('./User'); +const Category = require('./Category'); // Importamos la nueva tabla + +const Note = sequelize.define('Note', { + id: { + type: DataTypes.UUID, + primaryKey: true, + }, + encrypted_title: { + type: DataTypes.TEXT, + allowNull: false + }, + encrypted_body: { + type: DataTypes.TEXT, + allowNull: false + }, + position: { + type: DataTypes.DOUBLE, + defaultValue: 0, + }, + serverVersion: { + type: DataTypes.INTEGER, + defaultValue: 1, + }, + isDeleted: { + type: DataTypes.BOOLEAN, + defaultValue: false + } +}); + +// Relación: Un Usuario tiene muchas Notas +User.hasMany(Note, { foreignKey: 'userId' }); +Note.belongsTo(User, { foreignKey: 'userId' }); + +// Relación: Una Categoría tiene muchas Notas (y una nota puede no tener categoría) +Category.hasMany(Note, { foreignKey: 'categoryId', allowNull: true }); +Note.belongsTo(Category, { foreignKey: 'categoryId', allowNull: true }); + +module.exports = Note; \ No newline at end of file diff --git a/src/models/RefreshToken.js b/src/models/RefreshToken.js new file mode 100644 index 0000000..99ca882 --- /dev/null +++ b/src/models/RefreshToken.js @@ -0,0 +1,27 @@ +const { DataTypes } = require('sequelize'); +const sequelize = require('../config/database'); +const User = require('./User'); + +const RefreshToken = sequelize.define('RefreshToken', { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + token: { + type: DataTypes.STRING, + allowNull: false, + unique: true + }, + deviceName: { + type: DataTypes.STRING, + allowNull: false, + defaultValue: 'Dispositivo Desconocido' + } +}); + +// Relación: Un Usuario puede tener muchos Refresh Tokens (muchos dispositivos conectados) +User.hasMany(RefreshToken, { foreignKey: 'userId', onDelete: 'CASCADE' }); +RefreshToken.belongsTo(User, { foreignKey: 'userId' }); + +module.exports = RefreshToken; \ No newline at end of file diff --git a/src/models/User.js b/src/models/User.js new file mode 100644 index 0000000..390ca6e --- /dev/null +++ b/src/models/User.js @@ -0,0 +1,26 @@ +const { DataTypes } = require('sequelize'); +const sequelize = require('../config/database'); + +const User = sequelize.define('User', { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + username: { + type: DataTypes.STRING, + allowNull: false, + unique: true + }, + password: { + type: DataTypes.STRING, + allowNull: false + }, + // La llave de las notas encriptada con la contraseña del usuario + encrypted_master_key: { + type: DataTypes.TEXT, + allowNull: true + } +}); + +module.exports = User; \ No newline at end of file diff --git a/src/routes/authRoutes.js b/src/routes/authRoutes.js new file mode 100644 index 0000000..4544f7b --- /dev/null +++ b/src/routes/authRoutes.js @@ -0,0 +1,17 @@ +const express = require('express'); +const router = express.Router(); +const authController = require('../controllers/authController'); + +// POST /api/auth/register +router.post('/register', authController.register); + +// POST /api/auth/login +router.post('/login', authController.login); + +// POST /api/auth/refresh +router.post('/refresh', authController.refresh); + +// POST /api/auth/logout +router.post('/logout', authController.logout); + +module.exports = router; \ No newline at end of file diff --git a/src/routes/index.js b/src/routes/index.js new file mode 100644 index 0000000..2005a1a --- /dev/null +++ b/src/routes/index.js @@ -0,0 +1,16 @@ +const express = require('express'); +const router = express.Router(); +const authRoutes = require('./authRoutes'); // Añadimos esto +const syncRoutes = require('./syncRoutes'); + +router.get('/status', (req, res) => { + res.json({ message: '¡API funcionando correctamente!' }); +}); + +// Todas las rutas de auth estarán bajo /api/auth/... +router.use('/auth', authRoutes); + +// Endpoint único de sincronización offline-first +router.use('/', syncRoutes); + +module.exports = router; \ No newline at end of file diff --git a/src/routes/syncRoutes.js b/src/routes/syncRoutes.js new file mode 100644 index 0000000..2339226 --- /dev/null +++ b/src/routes/syncRoutes.js @@ -0,0 +1,7 @@ +const express = require('express'); +const router = express.Router(); +const syncController = require('../controllers/syncController'); + +router.post('/sync', syncController.sync); + +module.exports = router; \ No newline at end of file diff --git a/src/services/authService.js b/src/services/authService.js new file mode 100644 index 0000000..17b1297 --- /dev/null +++ b/src/services/authService.js @@ -0,0 +1,90 @@ +const bcrypt = require('bcryptjs'); +const jwt = require('jsonwebtoken'); +const crypto = require('crypto'); +const User = require('../models/User'); +const RefreshToken = require('../models/RefreshToken'); + +const JWT_SECRET = process.env.JWT_SECRET; + +if (!JWT_SECRET) { + throw new Error('JWT_SECRET no está definido'); +} + +class AuthService { + + async register(username, password, encrypted_master_key = null) { + const userExists = await User.findOne({ where: { username } }); + if (userExists) throw new Error('El usuario ya existe'); + + const salt = await bcrypt.genSalt(10); + const hashedPassword = await bcrypt.hash(password, salt); + + return await User.create({ username, password: hashedPassword, encrypted_master_key }); + } + + // LOGIN: Crea el Access Token corto y registra el dispositivo con un Refresh Token único + async login(username, password, deviceName) { + const user = await User.findOne({ where: { username } }); + if (!user) throw new Error('Credenciales inválidas'); + + const isValid = await bcrypt.compare(password, user.password); + if (!isValid) throw new Error('Credenciales inválidas'); + + // A. Generar Access Token (Vence en 15 minutos - Sin datos sensibles) + const accessToken = jwt.sign( + { id: user.id, username: user.username }, + JWT_SECRET, + { expiresIn: '15m' } + ); + + // B. Generar un Refresh Token único y aleatorio (String seguro de 64 caracteres) + const randomToken = crypto.randomBytes(32).toString('hex'); + + // C. Guardar el Refresh Token asociado al dispositivo en PostgreSQL + await RefreshToken.create({ + token: randomToken, + deviceName: deviceName || 'Dispositivo Móvil', + userId: user.id + }); + + return { user, accessToken, refreshToken: randomToken }; + } + + // REFRESH: Intercambia un Refresh Token válido por un nuevo Access Token limpio + async refreshAccessToken(tokenRecibido) { + // 1. Buscar el token en la base de datos de Postgres + const tokenGuardado = await RefreshToken.findOne({ + where: { token: tokenRecibido }, + include: User + }); + + if (!tokenGuardado) { + throw new Error('Refresh Token inválido o sesión revocada'); + } + + const user = tokenGuardado.User; + + // 2. Si existe, emitimos un nuevo Access Token de 15 minutos + const newAccessToken = jwt.sign( + { id: user.id, username: user.username }, + JWT_SECRET, + { expiresIn: '15m' } + ); + + return { accessToken: newAccessToken }; + } + + // CERRAR SESIÓN EN UN SOLO DISPOSITIVO (El bisturí) + async logoutDevice(tokenValue) { + await RefreshToken.destroy({ where: { token: tokenValue } }); + return { message: 'Sesión cerrada en este dispositivo correctamente.' }; + } + + // CERRAR SESIÓN EN TODOS LOS SITIOS (El botón nuclear) + async logoutAllDevices(userId) { + await RefreshToken.destroy({ where: { userId } }); + return { message: 'Se ha cerrado sesión en todos los dispositivos de forma global.' }; + } +} + +module.exports = new AuthService(); \ No newline at end of file diff --git a/src/services/syncService.js b/src/services/syncService.js new file mode 100644 index 0000000..368bef0 --- /dev/null +++ b/src/services/syncService.js @@ -0,0 +1,175 @@ +const { Op } = require('sequelize'); +const sequelize = require('../config/database'); +const Note = require('../models/Note'); +const Category = require('../models/Category'); +const { randomUUID } = require('crypto'); + +const toIso = (value) => (value ? new Date(value).toISOString() : null); + +const isValidDate = (value) => { + const parsed = new Date(value); + return !Number.isNaN(parsed.getTime()); +}; + +const normalizeCategory = (category) => ({ + id: category.id, + encrypted_name: category.encrypted_name, + isDeleted: Boolean(category.isDeleted), + serverVersion: category.serverVersion || 1, + updatedAt: category.updatedAt instanceof Date ? category.updatedAt.toISOString() : toIso(category.updatedAt) +}); + +const normalizeNote = (note) => ({ + id: note.id, + categoryId: note.categoryId || null, + encrypted_title: note.encrypted_title, + encrypted_body: note.encrypted_body, + position: note.position, + isDeleted: Boolean(note.isDeleted), + serverVersion: note.serverVersion || 1, + updatedAt: note.updatedAt instanceof Date ? note.updatedAt.toISOString() : toIso(note.updatedAt) +}); + +const getIncomingVersion = (entityName, incoming) => { + if (!Number.isInteger(incoming.serverVersion) || incoming.serverVersion < 0) { + throw new Error(`Cada ${entityName} debe incluir serverVersion como entero >= 0`); + } + return incoming.serverVersion; +}; + +class SyncService { + async sync(userId, payload = {}) { + if (!userId) { + throw new Error('userId es obligatorio para sincronizar'); + } + + const lastSyncAtValue = payload.lastSyncAt || '1970-01-01T00:00:00.000Z'; + if (!isValidDate(lastSyncAtValue)) { + throw new Error('lastSyncAt debe ser una fecha ISO válida'); + } + + const lastSyncAt = new Date(lastSyncAtValue); + const changes = payload.changes || {}; + const incomingCategories = Array.isArray(changes.categories) ? changes.categories : []; + const incomingNotes = Array.isArray(changes.notes) ? changes.notes : []; + + await sequelize.transaction(async (transaction) => { + for (const incomingCategory of incomingCategories) { + if (!incomingCategory.id || !incomingCategory.encrypted_name) { + throw new Error('Cada categoría debe incluir id y encrypted_name'); + } + + const incomingBaseVersion = getIncomingVersion('categoría', incomingCategory); + + const existingCategory = await Category.findOne({ + where: { id: incomingCategory.id, userId }, + transaction + }); + + if (!existingCategory) { + await Category.create({ + id: incomingCategory.id, + userId, + encrypted_name: incomingCategory.encrypted_name, + isDeleted: Boolean(incomingCategory.isDeleted), + serverVersion: 1 + }, { transaction }); + continue; + } + + const serverVersion = existingCategory.serverVersion || 1; + if (incomingBaseVersion === serverVersion) { + await existingCategory.update({ + encrypted_name: incomingCategory.encrypted_name, + isDeleted: Boolean(incomingCategory.isDeleted), + serverVersion: serverVersion + 1 + }, { transaction }); + } else { + // server wins for categories; do not create conflict copies for categories + } + } + + for (const incomingNote of incomingNotes) { + if (!incomingNote.id || !incomingNote.encrypted_title || !incomingNote.encrypted_body) { + throw new Error('Cada nota debe incluir id, encrypted_title y encrypted_body'); + } + + const incomingBaseVersion = getIncomingVersion('nota', incomingNote); + + const existingNote = await Note.findOne({ + where: { id: incomingNote.id, userId }, + transaction + }); + + if (!existingNote) { + await Note.create({ + id: incomingNote.id, + userId, + categoryId: incomingNote.categoryId || null, + encrypted_title: incomingNote.encrypted_title, + encrypted_body: incomingNote.encrypted_body, + position: incomingNote.position ?? 0, + isDeleted: Boolean(incomingNote.isDeleted), + serverVersion: 1 + }, { transaction }); + continue; + } + + const serverVersion = existingNote.serverVersion || 1; + + if (incomingBaseVersion === serverVersion) { + await existingNote.update({ + categoryId: incomingNote.categoryId || null, + encrypted_title: incomingNote.encrypted_title, + encrypted_body: incomingNote.encrypted_body, + position: incomingNote.position ?? existingNote.position, + isDeleted: Boolean(incomingNote.isDeleted), + serverVersion: serverVersion + 1 + }, { transaction }); + } else if (serverVersion > incomingBaseVersion) { + // Server version is newer -> create a new note with incoming content so user sees a duplicate + await Note.create({ + id: randomUUID(), + userId, + categoryId: incomingNote.categoryId || null, + encrypted_title: incomingNote.encrypted_title, + encrypted_body: incomingNote.encrypted_body, + position: incomingNote.position ?? 0, + isDeleted: Boolean(incomingNote.isDeleted), + serverVersion: 1 + }, { transaction }); + } else { + throw new Error(`serverVersion inválida en la nota ${incomingNote.id}`); + } + } + }); + + const [pulledCategories, pulledNotes] = await Promise.all([ + Category.findAll({ + where: { + userId, + updatedAt: { [Op.gt]: lastSyncAt } + }, + order: [['updatedAt', 'ASC']] + }), + Note.findAll({ + where: { + userId, + updatedAt: { [Op.gt]: lastSyncAt } + }, + order: [['updatedAt', 'ASC']] + }) + ]); + + return { + serverTimestamp: new Date().toISOString(), + synced: true, + changes: { + categories: pulledCategories.map(normalizeCategory), + notes: pulledNotes.map(normalizeNote) + } + }; + } +} + +module.exports = new SyncService(); \ No newline at end of file