first commit

This commit is contained in:
2026-07-16 20:48:43 -03:00
commit 5696cee264
1111 changed files with 322270 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
PORT = 4000
MONGO_URI = mongodb://horacio:frx8us2w@localhost:20215/turnosxpress
TENANCY_OCID = ocid1.tenancy.oc1..aaaaaaaaauyqhe53wuopce3w4f4n6bbrwq4oagkdc5iz5fbgdk2yqqzrkhoa
USER_OCID = ocid1.user.oc1..aaaaaaaaogwd4dyl4kgxbqaoorck2yjq7dvb5nyd3bijffwmesq72hqy3qha
FINGERPRINT = 24:2e:45:e0:70:1a:7e:cf:c3:db:a2:db:eb:0a:21:ac
PRIVATE_KEY_PATH=./keys/oci_api_key_rsa.pem
NAMESPACE = grvzezwnyhx9
BUCKET_NAME = tx-files
RATE_LIMIT_WINDOW_MINUTES = 15
RATE_LIMIT_MAX_REQUESTS = 100
JWT_SECRET = k34lklJ4HKJ2j3kj@kl23
+11
View File
@@ -0,0 +1,11 @@
PORT = 4000
MONGO_URI = mongodb://txuser:hdR2102Frx8us2WkuN21quAn12@mongodb:27017/turnosxpress
TENANCY_OCID = ocid1.tenancy.oc1..aaaaaaaaauyqhe53wuopce3w4f4n6bbrwq4oagkdc5iz5fbgdk2yqqzrkhoa
USER_OCID = ocid1.user.oc1..aaaaaaaaogwd4dyl4kgxbqaoorck2yjq7dvb5nyd3bijffwmesq72hqy3qha
FINGERPRINT = 24:2e:45:e0:70:1a:7e:cf:c3:db:a2:db:eb:0a:21:ac
PRIVATE_KEY_PATH=./keys/oci_api_key_rsa.pem
NAMESPACE = grvzezwnyhx9
BUCKET_NAME = tx-files
RATE_LIMIT_WINDOW_MINUTES = 15
RATE_LIMIT_MAX_REQUESTS = 100
JWT_SECRET = k34lklJ4HKJ2j3kj@kl23
+11
View File
@@ -0,0 +1,11 @@
PORT = 4000
MONGO_URI = mongodb://horacio:frx8us2w@mongodb:27017/turnosxpress
TENANCY_OCID = ocid1.tenancy.oc1..aaaaaaaaauyqhe53wuopce3w4f4n6bbrwq4oagkdc5iz5fbgdk2yqqzrkhoa
USER_OCID = ocid1.user.oc1..aaaaaaaaogwd4dyl4kgxbqaoorck2yjq7dvb5nyd3bijffwmesq72hqy3qha
FINGERPRINT = 24:2e:45:e0:70:1a:7e:cf:c3:db:a2:db:eb:0a:21:ac
PRIVATE_KEY_PATH=./keys/oci_api_key_rsa.pem
NAMESPACE = grvzezwnyhx9
BUCKET_NAME = tx-files
RATE_LIMIT_WINDOW_MINUTES = 15
RATE_LIMIT_MAX_REQUESTS = 100
JWT_SECRET = k34lklJ4HKJ2j3kj@kl23
+38
View File
@@ -0,0 +1,38 @@
# --- Etapa 1: Build ---
FROM node:20-alpine AS builder
WORKDIR /app
# Instalar dependencias de desarrollo
COPY package*.json ./
RUN npm install
# Copiar el código fuente y archivos de entorno
COPY . .
COPY .env* .
# Compilar TypeScript
RUN npm run build
# --- Etapa 2: Runtime mínimo ---
FROM node:20-alpine
WORKDIR /app
# Instalar solo dependencias de producción
COPY package*.json ./
RUN npm install --omit=dev
# Copiar la carpeta compilada desde la etapa de build
COPY --from=builder /app/dist ./dist
# Copiar archivos de entorno a la raíz /app
COPY --from=builder /app/.env* ./
# Copiar la carpeta de claves
COPY --from=builder /app/keys ./keys
EXPOSE 4000
# Comando de inicio que lanza tanto el server como el client
CMD [ "sh", "-c", "npm --prefix /app start" ]
+29
View File
@@ -0,0 +1,29 @@
-----BEGIN PRIVATE KEY-----
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDHCcx/dF07YGeW
3UQpnEFtaJwxg5cU89nR7OZwssajp99l9oL75uw2tNGeEVFMADiW7K4ie36xsFqT
irt86arUNGX1qnZlfi8BtnXDmGgcZ8ZL7ClhXJvXAJ1Z8RGYuz8sXibhwL7RqBB/
y3W128CPe34U2Ohz3AfqflpKY3JYILSGRMIgPC3BjJL7O03IXxxTZ8BORyeJf2nP
7t4BqOqYveRiXMNQEhl553EncS6KZdj9iCwFCdGB69woOW2q/AJ4B4pWX7yZKgVB
yGfbTGIjSQ3lcBWG9Ntof8ypyd4Qi8sDLv2bnk6u9Ddh29Fh5MzCpGP/+1gwy/w+
EZm2Wx2/AgMBAAECggEACHnSLmKfWRjKYIIiAHRJ1FBo8lzXBrU5reGyOyi0d8yo
IUNFoazcRe9zoRETZAf7C5ue8L355QmMWeMbB9SQNJgvTVkJ0K72bn1oIhZakfcY
r9ap48kEo+ZqolAMk/NJWz4pLAzqs1U+WqAomOrM65nygmMYRwge2C1dnUmt2V3R
Kvi+sStI0iL8PUO9g4LofpzfIjiLSL9KrihqrgB6gZ3nUI87KNUsVDc5waJMF+kx
Wlo8ncsd+76w6exOO5nE+N6yUhM6HOZhOIYel0MgNAJE7899YNuQcqpoeEIxUZea
lO9ZI7fYgsE1jYZ978YzSCCxYB6dX3W5wZ2zsnU7YQKBgQD73ZQWFC3pcf1qgGzP
yo2s0GEw/zsv1CRh2V3CB3/8FY5/iPh0Ztsd3k+324pL2Pg35C9HhtL6v6ESm4EW
KAs1CsI4LVWYHcDwXbywOLuuIdY9yDxmvjy0ZXtkqmAP3T9Jl39STavUAJ66sbqw
YLqFWGpvZgmHnsLfSx6MStg6nwKBgQDKTjkO0P/xWWhC3AKw4L9Ju56MFvvuDNe6
VaZzfnbayDBVMpA57gfVrDyIV2ixDHVLhvCRsI1Mbd7K5l1smjlqGi31Xe6nB/xT
cYWJvE5PVg48uqFzGQxsVnU0ffA6H7Tghk8tyGBB99G0ouqVlTP3bYfsuth3uWS+
5UU2eCFo4QKBgBzoZ7DJDCP5I17v8o0+8FffauhWwxJQRQS+EBbPCY+DYeGddPbX
P1sEQx4VIqBl/Mddd1e8T8bb677Y/k3utqqV24jbGzHkaFquR5PqTxBDbfWVX+li
+FC0PR9mtrPbbwIhU0m9BHXGxHElKQpJb98SMNUsXvej7zX1x0HO7VZ9AoGAWSJL
225URjc5S7P574qmXUsv2NNLnp/D8NJJ6zr+DPkE6pTDnPJ2OzuO1BeHBfLSPzuu
BNK//rut3MHQycfNoiT0uNXxD2DiWLV8u6ne6u+kiDdGaRrRMW6KZ11RmZpcrEwg
2LAfURFqMOdS6iL56Xs11ySfuWhSrhUAALa/KaECgYA+i8TlQjxOyjYlyVjnvLN0
IE+UvwRk+Iz2FUIP9YRsscmpZT3UFI9EYt08Vj4YHjTGEbFNRGxaGXMKUmDBtfg7
HPeo6M9AhZfP++kc4oUFd2SCIQ68U5qz+qaNQz6ivnbQQsbGCKE2yeg9+U+m7YC3
HO52419TG1+8xf9hZODkSQ==
-----END PRIVATE KEY-----
OCI_API_KEY
+4572
View File
File diff suppressed because it is too large Load Diff
+35
View File
@@ -0,0 +1,35 @@
{
"name": "files",
"version": "1.0.0",
"main": "index.js",
"type": "commonjs",
"scripts": {
"dev": "ts-node src/index.ts",
"build": "tsc",
"start": "node dist/index.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"dependencies": {
"cors": "^2.8.5",
"dotenv": "^17.2.2",
"express": "^5.1.0",
"express-rate-limit": "^8.1.0",
"helmet": "^8.1.0",
"mongoose": "^8.18.2",
"multer": "^2.0.2",
"oci-sdk": "^2.117.1",
"sharp": "^0.34.4"
},
"devDependencies": {
"@types/cors": "^2.8.19",
"@types/express": "^5.0.3",
"@types/multer": "^2.0.0",
"@types/node": "^24.5.2",
"nodemon": "^3.1.10",
"ts-node": "^10.9.2",
"typescript": "^5.9.2"
}
}
+21
View File
@@ -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" });
}
};
+22
View File
@@ -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}`);
});
})();
+26
View File
@@ -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.",
});
+11
View File
@@ -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);
+29
View File
@@ -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);
+14
View File
@@ -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);
+4
View File
@@ -0,0 +1,4 @@
export type FileUploadParams = {
originalName: string;
objectName: string;
};
+13
View File
@@ -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);
+23
View File
@@ -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);
};
+40
View File
@@ -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,
};
};
+16
View File
@@ -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"
);
}
};
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "es2020",
"module": "CommonJS",
"moduleResolution": "node",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"outDir": "./dist",
"sourceMap": true,
"declaration": true,
"declarationMap": true,
// Stricter Typechecking
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true
},
"include": ["src"]
}