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:
+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