first commit
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
import express from "express";
|
||||
import helmet from "helmet";
|
||||
import cors from "cors";
|
||||
import archivosRoutes from "./routes/archivos";
|
||||
|
||||
const app = express();
|
||||
|
||||
app.use(helmet());
|
||||
app.use(cors());
|
||||
app.use(express.json()); // por si mandás JSON en otras rutas
|
||||
|
||||
app.use("/", archivosRoutes);
|
||||
|
||||
// manejo básico de errores
|
||||
app.use((err: any, req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||
console.error(err);
|
||||
if (res.headersSent) return next(err);
|
||||
res.status(err.status ?? 500).json({ error: err.message ?? "Error interno" });
|
||||
});
|
||||
|
||||
export default app;
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { Request, Response } from "express";
|
||||
import * as oracleService from "../services/oracleStorageService";
|
||||
|
||||
const BUCKET = process.env.BUCKET_NAME!;
|
||||
if (!BUCKET) throw new Error("Faltan variables de entorno: BUCKET_NAME");
|
||||
|
||||
const streamToBuffer = async (
|
||||
stream: ReadableStream<any> | NodeJS.ReadableStream
|
||||
): Promise<Buffer> => {
|
||||
if ("getReader" in stream) {
|
||||
// Es un ReadableStream web
|
||||
const reader = stream.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
chunks.push(value);
|
||||
}
|
||||
return Buffer.concat(chunks.map((chunk) => Buffer.from(chunk)));
|
||||
} else {
|
||||
// Es un NodeJS ReadableStream
|
||||
const chunks: Buffer[] = [];
|
||||
return new Promise((resolve, reject) => {
|
||||
stream.on("data", (chunk) =>
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
|
||||
);
|
||||
stream.on("end", () => resolve(Buffer.concat(chunks)));
|
||||
stream.on("error", reject);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const downloadFile = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { nombre } = req.params;
|
||||
if (!nombre) return res.status(400).json({ error: "Falta parámetro nombre" });
|
||||
|
||||
const obj = await oracleService.descargarArchivo(BUCKET, nombre);
|
||||
// obj.value es un ReadableStream web estándar
|
||||
const body: Buffer = await streamToBuffer(obj.value);
|
||||
|
||||
res.setHeader("Content-Disposition", `attachment; filename="${nombre}"`);
|
||||
res.send(body);
|
||||
} catch (err) {
|
||||
console.error("Error en downloadArchivo:", err);
|
||||
res.status(500).json({ error: "Error al descargar archivo" });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { Request, Response } from "express";
|
||||
import DataProfile from "../models/dataprofile";
|
||||
import { processFile } from "../services/processFile";
|
||||
import { validateOrganization } from "../services/validateOrganization";
|
||||
import type { FileUploadParams } from "../models/types";
|
||||
|
||||
export const uploadOrganizationFile = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const data = await validateOrganization(req, res);
|
||||
const procesados: FileUploadParams[] = [];
|
||||
|
||||
for (const file of data.files) {
|
||||
const result = await processFile(file);
|
||||
const { objectName, originalName, bufferToUpload, mimeType } = result;
|
||||
|
||||
await DataProfile.create({
|
||||
objectName,
|
||||
originalName,
|
||||
size: bufferToUpload.length,
|
||||
mimeType,
|
||||
userId: data.sessionUser,
|
||||
organizationId: data.organizationId,
|
||||
});
|
||||
|
||||
procesados.push({ originalName, objectName });
|
||||
}
|
||||
|
||||
return res.status(200).json({
|
||||
message: `${procesados.length} archivos subidos y registrados`,
|
||||
files: procesados,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Error en uploadArchivos:", err);
|
||||
return res.status(500).json({ error: "Error al subir archivos" });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
import dotenv from "dotenv";
|
||||
dotenv.config();
|
||||
|
||||
import mongoose from "mongoose";
|
||||
import app from "./app";
|
||||
|
||||
const PORT = process.env.PORT ?? 4000;
|
||||
const MONGO_URI = `${process.env.MONGO_URI}`;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
await mongoose.connect(MONGO_URI);
|
||||
console.log("MongoDB conectada");
|
||||
} catch (err) {
|
||||
console.error("Error conectando MongoDB:", err);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Servidor corriendo en puerto ${PORT}`);
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { Request, Response, NextFunction } from "express";
|
||||
import jwt from "jsonwebtoken";
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET;
|
||||
if (!JWT_SECRET) throw new Error("Falta JWT_SECRET en .env");
|
||||
|
||||
export interface AuthRequest extends Request {
|
||||
user?: any; // Puedes tiparlo mejor según tu payload
|
||||
}
|
||||
|
||||
export const verifyToken = (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
||||
return res.status(401).json({ error: "Token no proporcionado" });
|
||||
}
|
||||
|
||||
const token = authHeader.split(" ")[1];
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(`${token}`, JWT_SECRET);
|
||||
req.user = decoded; // Puedes guardar el payload en req.user
|
||||
next();
|
||||
} catch (err) {
|
||||
return res.status(401).json({ error: "Token inválido" });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
import rateLimit from "express-rate-limit";
|
||||
|
||||
const windowMs = (parseInt(process.env.RATE_LIMIT_WINDOW_MINUTES ?? "15", 10) || 15) * 60 * 1000;
|
||||
const max = parseInt(process.env.RATE_LIMIT_MAX_REQUESTS ?? "100", 10) || 100;
|
||||
|
||||
export const archivosRateLimiter = rateLimit({
|
||||
windowMs,
|
||||
max,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: "Demasiadas solicitudes, por favor intente más tarde.",
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import mongoose, { Schema, Document } from "mongoose";
|
||||
|
||||
export interface ICompany extends Document {
|
||||
name: string;
|
||||
}
|
||||
|
||||
const CompanySchema = new Schema<ICompany>({
|
||||
name: { type: String, required: true },
|
||||
});
|
||||
|
||||
export default mongoose.model<ICompany>("Companie", CompanySchema);
|
||||
@@ -0,0 +1,29 @@
|
||||
import mongoose, { Schema, Document } from "mongoose";
|
||||
|
||||
export interface IFile extends Document {
|
||||
objectName: string;
|
||||
originalName: string;
|
||||
size: number;
|
||||
mimeType: string;
|
||||
userId: string;
|
||||
organizationId: string;
|
||||
employeeId: string;
|
||||
serviceId: string;
|
||||
appointmentId: string;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
const DataFileSchema = new Schema<IFile>({
|
||||
objectName: { type: String, required: true },
|
||||
originalName: { type: String, required: true },
|
||||
size: { type: Number, required: true },
|
||||
mimeType: { type: String, required: true },
|
||||
userId: { type: String, required: false },
|
||||
organizationId: { type: String, required: false },
|
||||
employeeId: { type: String, required: false },
|
||||
serviceId: { type: String, required: false },
|
||||
appointmentId: { type: String, required: false },
|
||||
createdAt: { type: Date, default: Date.now },
|
||||
});
|
||||
|
||||
export default mongoose.model<IFile>("File", DataFileSchema);
|
||||
@@ -0,0 +1,14 @@
|
||||
import mongoose, { Schema, Document } from "mongoose";
|
||||
|
||||
export interface IEmployee extends Document {
|
||||
name: string;
|
||||
userId: Schema.Types.ObjectId;
|
||||
companyId: Schema.Types.ObjectId;
|
||||
}
|
||||
|
||||
const EmployeeSchema = new Schema<IEmployee>({
|
||||
userId: { type: Schema.Types.ObjectId, required: true, ref: "User" },
|
||||
companyId: { type: Schema.Types.ObjectId, required: true, ref: "Companie" },
|
||||
});
|
||||
|
||||
export default mongoose.model<IEmployee>("Employee", EmployeeSchema);
|
||||
@@ -0,0 +1,4 @@
|
||||
export type FileUploadParams = {
|
||||
originalName: string;
|
||||
objectName: string;
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
import mongoose, { Schema, Document } from "mongoose";
|
||||
|
||||
export interface IUser extends Document {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
}
|
||||
|
||||
const UserSchema = new Schema<IUser>({
|
||||
firstName: { type: String, required: true },
|
||||
lastName: { type: String, required: true },
|
||||
});
|
||||
|
||||
export default mongoose.model<IUser>("User", UserSchema);
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Router } from "express";
|
||||
import multer from "multer";
|
||||
import { archivosRateLimiter } from "../middlewares/rateLimiter";
|
||||
import { downloadFile } from "../controllers/downloadController";
|
||||
import { uploadOrganizationFile } from "../controllers/uploadOrganizationController";
|
||||
import { verifyToken } from "../middlewares/auth";
|
||||
|
||||
const upload = multer(); // memoria
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.post(
|
||||
"/upload-by-organization",
|
||||
verifyToken,
|
||||
archivosRateLimiter,
|
||||
upload.array("files"),
|
||||
uploadOrganizationFile
|
||||
);
|
||||
|
||||
// GET /archivos/download/:nombre (nombre = objectName guardado en DB)
|
||||
router.get("/download/:nombre", archivosRateLimiter, downloadFile);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,51 @@
|
||||
import sharp from "sharp";
|
||||
|
||||
const MAX_SIZE_BYTES = 512 * 1024; // 512 KB
|
||||
|
||||
/**
|
||||
* Convierte una imagen a WebP y asegura que no supere MAX_SIZE_BYTES.
|
||||
* Mantiene el aspect ratio y ajusta calidad si la reducción supera el 50%.
|
||||
* @param inputBuffer Buffer con la imagen original
|
||||
* @returns Buffer de la imagen en WebP
|
||||
*/
|
||||
export const processImageToWebP = async (inputBuffer: Buffer): Promise<Buffer> => {
|
||||
let quality = 80;
|
||||
let width: number | null = null;
|
||||
const metadata = await sharp(inputBuffer).metadata();
|
||||
const originalWidth = metadata.width || 0;
|
||||
if (!originalWidth) throw new Error("No se pudo obtener el ancho de la imagen original");
|
||||
|
||||
let outputBuffer: Buffer;
|
||||
|
||||
while (true) {
|
||||
const transformer = sharp(inputBuffer).webp({ quality });
|
||||
if (width) {
|
||||
transformer.resize({ width, withoutEnlargement: true });
|
||||
}
|
||||
|
||||
outputBuffer = await transformer.toBuffer();
|
||||
|
||||
if (outputBuffer.length <= MAX_SIZE_BYTES) break;
|
||||
|
||||
// Reducimos width progresivamente
|
||||
if (!width) {
|
||||
width = Math.floor(originalWidth * 0.9); // reducimos 10% la primera vez
|
||||
} else {
|
||||
width = Math.floor(width * 0.9); // reducciones sucesivas
|
||||
}
|
||||
|
||||
// Calculamos porcentaje de reducción respecto al tamaño original
|
||||
const reductionPercent = (originalWidth - width) / originalWidth;
|
||||
|
||||
// Si se redujo más del 50%, empezamos a bajar la calidad
|
||||
if (reductionPercent >= 0.5 && quality > 40) {
|
||||
quality -= 5;
|
||||
width = null; // volvemos a tamaño original pero con menor calidad
|
||||
}
|
||||
|
||||
// Evitamos bucle infinito
|
||||
if (width && width < 10) break;
|
||||
}
|
||||
|
||||
return outputBuffer;
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
import { readFileSync } from "fs";
|
||||
import { SimpleAuthenticationDetailsProvider, Region } from "oci-common";
|
||||
import { ObjectStorageClient } from "oci-objectstorage";
|
||||
|
||||
const userOcId = process.env.USER_OCID!;
|
||||
const tenancyOcId = process.env.TENANCY_OCID!;
|
||||
const privateKeyPath = process.env.PRIVATE_KEY_PATH!;
|
||||
const fingerprint = process.env.FINGERPRINT!;
|
||||
const namespace = process.env.NAMESPACE!;
|
||||
|
||||
if (!userOcId || !tenancyOcId || !privateKeyPath || !fingerprint || !namespace) {
|
||||
throw new Error(
|
||||
"Faltan variables de entorno de Oracle (USER_OCID, TENANCY_OCID, PRIVATE_KEY_PATH, FINGERPRINT, NAMESPACE)"
|
||||
);
|
||||
}
|
||||
|
||||
const privateKey = readFileSync(privateKeyPath, "utf-8");
|
||||
|
||||
const region = Region.fromRegionId("sa-saopaulo-1");
|
||||
|
||||
const provider = new SimpleAuthenticationDetailsProvider(
|
||||
tenancyOcId,
|
||||
userOcId,
|
||||
fingerprint,
|
||||
privateKey,
|
||||
null,
|
||||
region
|
||||
);
|
||||
|
||||
const client = new ObjectStorageClient({ authenticationDetailsProvider: provider });
|
||||
|
||||
export const subirArchivo = async (bucketName: string, objectName: string, data: Buffer) => {
|
||||
const request = {
|
||||
namespaceName: namespace,
|
||||
bucketName,
|
||||
objectName,
|
||||
putObjectBody: data,
|
||||
};
|
||||
return client.putObject(request);
|
||||
};
|
||||
|
||||
export const descargarArchivo = async (bucketName: string, objectName: string) => {
|
||||
const request = {
|
||||
namespaceName: namespace,
|
||||
bucketName,
|
||||
objectName,
|
||||
};
|
||||
return client.getObject(request);
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
import { processImageToWebP } from "./imageCompresionService";
|
||||
import * as oracleService from "../services/oracleStorageService";
|
||||
|
||||
const BUCKET = process.env.BUCKET_NAME!;
|
||||
if (!BUCKET) throw new Error("Faltan variables de entorno: BUCKET_NAME");
|
||||
|
||||
export type ProcessFileResult = {
|
||||
objectName: string;
|
||||
originalName: string;
|
||||
bufferToUpload: Buffer<ArrayBufferLike>;
|
||||
mimeType: string;
|
||||
};
|
||||
|
||||
export const processFile = async (file: Express.Multer.File) => {
|
||||
let bufferToUpload = file.buffer;
|
||||
let mimeType = file.mimetype;
|
||||
const originalName = file.originalname;
|
||||
|
||||
// Si es imagen, procesar con sharp (resize + convertir a jpeg)
|
||||
if (mimeType.startsWith("image/")) {
|
||||
bufferToUpload = await processImageToWebP(bufferToUpload);
|
||||
mimeType = "image/webp";
|
||||
}
|
||||
|
||||
// Generar objectName único para evitar colisiones
|
||||
const timestamp = Date.now();
|
||||
// escapamos/normalizamos el originalName ligeramente
|
||||
const safeOriginal = originalName.replace(/\s+/g, "_");
|
||||
const objectName = `${timestamp}-${safeOriginal}`;
|
||||
|
||||
// Subir a Oracle
|
||||
await oracleService.subirArchivo(BUCKET, objectName, bufferToUpload);
|
||||
|
||||
return {
|
||||
objectName,
|
||||
originalName,
|
||||
bufferToUpload,
|
||||
mimeType,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { Request, Response } from "express";
|
||||
|
||||
export type ValidateFilesResult = {
|
||||
files: Express.Multer.File[];
|
||||
};
|
||||
|
||||
export const validateFiles = async (req: Request, res: Response): Promise<ValidateFilesResult> => {
|
||||
const files = req.files as Express.Multer.File[] | undefined;
|
||||
if (!files || files.length === 0) throw new Error("No se encontraron archivos");
|
||||
|
||||
return {
|
||||
files,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { Request, Response } from "express";
|
||||
import { validateFiles } from "./validateFiles";
|
||||
import { validateSessionUser } from "./validateUser";
|
||||
import CompanySchema from "../models/companies";
|
||||
import EmployeeSchema from "../models/employees";
|
||||
|
||||
export type ValidateOrganizationResult = {
|
||||
files: Express.Multer.File[];
|
||||
organizationId: string;
|
||||
sessionUser: string;
|
||||
};
|
||||
|
||||
export const validateOrganization = async (
|
||||
req: Request,
|
||||
res: Response
|
||||
): Promise<ValidateOrganizationResult> => {
|
||||
const filesResul = await validateFiles(req, res);
|
||||
const { sessionUser, organizationId } = req.body;
|
||||
if (!organizationId || !sessionUser) {
|
||||
throw new Error("Faltan campos obligatorios: userId o profileId");
|
||||
}
|
||||
|
||||
await validateSessionUser(sessionUser);
|
||||
|
||||
const company = await CompanySchema.findOne({
|
||||
_id: organizationId,
|
||||
});
|
||||
|
||||
if (!company) {
|
||||
throw new Error("No se ha encontrado la organización");
|
||||
}
|
||||
|
||||
const employee = await EmployeeSchema.findOne({
|
||||
userId: sessionUser,
|
||||
companyId: organizationId,
|
||||
});
|
||||
|
||||
if (!employee) {
|
||||
throw new Error("El usuario no pertenece a la organización");
|
||||
}
|
||||
|
||||
return {
|
||||
files: filesResul.files,
|
||||
organizationId,
|
||||
sessionUser,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import mongoose from "mongoose";
|
||||
import UserSchema from "../models/users";
|
||||
|
||||
export const validateSessionUser = async (sessionUser: string): Promise<void> => {
|
||||
const user = await UserSchema.findOne({
|
||||
_id: new mongoose.Types.ObjectId(sessionUser),
|
||||
});
|
||||
|
||||
console.log(new mongoose.Types.ObjectId(sessionUser));
|
||||
|
||||
if (!user) {
|
||||
throw new Error(
|
||||
"No se ha encontrado el usuario o no tiene permisos para realizar esta acción"
|
||||
);
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user