Implement initial API structure with Docker support, CI/CD workflows, and authentication features
Despliegue Automático / desplegar (push) Failing after 1m29s
Despliegue Automático / desplegar (push) Failing after 1m29s
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
node_modules
|
||||
.git
|
||||
.gitea
|
||||
.env
|
||||
.envGitea
|
||||
docs
|
||||
README.md
|
||||
pnpm-debug.log
|
||||
npm-debug.log
|
||||
@@ -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 .
|
||||
@@ -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
|
||||
+47
@@ -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
|
||||
+28
@@ -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"]
|
||||
@@ -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:
|
||||
+289
@@ -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 <accessToken>` (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.
|
||||
@@ -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://<IP_DEL_PORTATIL>:${puertoInterno}/api/status`);
|
||||
});
|
||||
}
|
||||
|
||||
arrancarServidor();
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
+19
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
Reference in New Issue
Block a user