Implement initial API structure with Docker support, CI/CD workflows, and authentication features
Despliegue Automático / desplegar (push) Failing after 1m29s

This commit is contained in:
2026-05-18 18:43:33 +02:00
commit 9d117a5887
23 changed files with 1091 additions and 0 deletions
+19
View File
@@ -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;
+9
View File
@@ -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;
+77
View File
@@ -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 };
+39
View File
@@ -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 };
+16
View File
@@ -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;
+29
View File
@@ -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;
+41
View File
@@ -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;
+27
View File
@@ -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;
+26
View File
@@ -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;
+17
View File
@@ -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;
+16
View File
@@ -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;
+7
View File
@@ -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;
+90
View File
@@ -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();
+175
View File
@@ -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();