Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 52 additions & 2 deletions server/src/demos/cuentas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@ import {
urlDeAutorizacion,
} from "../cuentas.js";
import { addMember, colorDe, type Room } from "../rooms.js";
import {
guardarAvatar,
rutaAvatar,
mediaTypeDeAvatar,
borrarAvatar,
esAvatarPropio,
} from "../engine/avatares.js";

/**
* Demo: las cuentas, que son OPCIONALES.
Expand Down Expand Up @@ -46,7 +53,11 @@ function salaVacia(): Room {
return { members: new Map() } as unknown as Room;
}

function main(): void {
/** Un PNG de un pixel, para no traer un archivo de prueba al repo. */
const pngDeUnPixel =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==";

async function main(): Promise<void> {
console.log("\n=== cuentas (opcionales) ===\n");

console.log("1. Sin credenciales configuradas no se ofrece entrar");
Expand Down Expand Up @@ -195,8 +206,47 @@ function main(): void {
check("con su foto", conCuenta.foto === "https://x/f.jpg");
}

console.log("\n11. Las fotos que sube la gente");
{
// El id cambia en cada subida, incluso de la misma persona. Eso es lo que
// hace honesto el `immutable` con el que se sirven: si se reusara el id, la
// foto vieja se quedaría pegada un año en el navegador de todos.
const uno = await guardarAvatar({ mediaType: "image/png", data: pngDeUnPixel });
const dos = await guardarAvatar({ mediaType: "image/png", data: pngDeUnPixel });
check("dos subidas, dos ids", uno !== dos, `${uno} vs ${dos}`);
check("y el tipo sale del id", mediaTypeDeAvatar(uno) === "image/png");

await borrarAvatar(uno);
await borrarAvatar(dos);
check("borrar deja de encontrarla", (await rutaAvatar(uno)) === null);
// Borrar algo que ya no está no puede reventar: pasa cada vez que alguien
// cambia dos veces de foto seguidas.
await borrarAvatar(uno);
check("y borrar dos veces no truena", true);

const grande = Buffer.alloc(600 * 1024).toString("base64");
await guardarAvatar({ mediaType: "image/png", data: grande }).then(
() => check("una foto enorme se rechaza", false, "la aceptó"),
(err: Error) => check("una foto enorme se rechaza", err.message.includes("512KB"), err.message),
);

await guardarAvatar({ mediaType: "application/pdf", data: pngDeUnPixel }).then(
() => check("un PDF no es una foto", false, "lo aceptó"),
(err: Error) => check("un PDF no es una foto", err.message.includes("no soportado")),
);
}

console.log("\n12. De quién es cada foto");
{
// Al cambiar de foto se borra la anterior, pero solo si era nuestra: las de
// Google son una URL suya y no hay nada que borrar en nuestro disco.
check("la de Google no se toca", !esAvatarPropio("https://lh3.googleusercontent.com/x"));
check("la subida sí es nuestra", esAvatarPropio("/auth/foto/abc.png"));
check("y sin foto no hay nada que borrar", !esAvatarPropio(null));
}

console.log(`\n${pass} pasaron, ${fail} fallaron\n`);
process.exit(fail > 0 ? 1 : 0);
}

main();
void main();
2 changes: 1 addition & 1 deletion server/src/engine/adjuntos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export interface Adjunto {
* Los formatos que los modelos con visión aceptan. Las dos documentaciones
* oficiales coinciden en la lista (agosto de 2026).
*/
const TIPOS: Record<string, string> = {
export const TIPOS: Record<string, string> = {
"image/png": ".png",
"image/jpeg": ".jpg",
"image/webp": ".webp",
Expand Down
110 changes: 110 additions & 0 deletions server/src/engine/avatares.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { mkdir, writeFile, readdir, rm } from "node:fs/promises";
import { join } from "node:path";
import { randomUUID } from "node:crypto";
import { TIPOS } from "./adjuntos.js";
import { WORKSPACES_ROOT } from "./workspace.js";

/**
* Las fotos de perfil de quien tiene cuenta.
*
* Van aparte de los adjuntos del chat y no colgando de una sala, porque una foto
* de perfil es de la persona y se ve en TODAS las salas donde entra. El camino de
* los adjuntos valida que el archivo pertenezca a la sala que lo pide, que es
* justo la garantía que aquí estorba.
*
* Lo que sí se reusa es la tabla de formatos: si algún día se acepta uno nuevo,
* se acepta en los dos sitios sin que uno se quede atrás.
*/

/** Tope por foto. Más bajo que el de los adjuntos a propósito. */
const MAX_BYTES = 512 * 1024;

export class AvatarInvalido extends Error {}

/**
* Dónde viven. Junto a los workspaces y con punto delante, que es lo que ya
* está gitignorado, para que no acaben en ningún commit ni en un .zip.
*/
export function avataresDir(): string {
return join(WORKSPACES_ROOT, ".avatares");
}

/**
* Guarda la foto y devuelve su id.
*
* El id lleva un uuid nuevo CADA VEZ, incluso si es la misma persona cambiando
* su foto. Eso es lo que hace honesto el `immutable` con el que se sirve: el
* contenido de un id nunca cambia, y cambiar de foto es cambiar de id. Si se
* reusara el id, la foto vieja se quedaría pegada un año en el navegador de
* todos los que la hubieran visto.
*/
export async function guardarAvatar(entrada: {
mediaType?: unknown;
data?: unknown;
}): Promise<string> {
const mediaType = typeof entrada.mediaType === "string" ? entrada.mediaType : "";
const ext = TIPOS[mediaType];
if (!ext) {
throw new AvatarInvalido(
`formato no soportado: ${mediaType || "(ninguno)"}. Solo PNG, JPEG, WebP y GIF.`,
);
}
if (typeof entrada.data !== "string" || !entrada.data) {
throw new AvatarInvalido("la imagen llegó vacía");
}

const buf = Buffer.from(entrada.data, "base64");
if (buf.length === 0) throw new AvatarInvalido("la imagen no es base64 válido");
if (buf.length > MAX_BYTES) {
const kb = Math.round(buf.length / 1024);
throw new AvatarInvalido(`la imagen pesa ${kb}KB y el tope son 512KB`);
}

const id = `${randomUUID()}${ext}`;
const dir = avataresDir();
await mkdir(dir, { recursive: true });
await writeFile(join(dir, id), buf);
return id;
}

/**
* La ruta en disco de una foto, o null si no está.
*
* Se comprueba listando el directorio y no armando la ruta con lo que pidieron:
* así un id con `..` o con barras no puede sacar a nadie de aquí. Mismo criterio
* que los adjuntos.
*/
export async function rutaAvatar(id: string): Promise<string | null> {
try {
const dir = avataresDir();
const hay = await readdir(dir);
return hay.includes(id) ? join(dir, id) : null;
} catch {
return null;
}
}

/** El tipo de una foto, deducido de su extensión. */
export function mediaTypeDeAvatar(id: string): string | null {
for (const [tipo, ext] of Object.entries(TIPOS)) {
if (id.endsWith(ext)) return tipo;
}
return null;
}

/**
* Borra una foto que ya no usa nadie.
*
* Se llama al cambiar de foto, con la anterior. Un fallo aquí no se propaga: que
* quede un archivo suelto de 200KB es mucho menos grave que impedirle a alguien
* cambiar su foto.
*/
export async function borrarAvatar(id: string): Promise<void> {
const ruta = await rutaAvatar(id);
if (ruta) await rm(ruta, { force: true }).catch(() => {});
}

/** Si esta foto la guardamos aquí (y no es una URL de Google). */
export function esAvatarPropio(foto: string | null | undefined): boolean {
return !!foto && !foto.startsWith("http");
}
109 changes: 109 additions & 0 deletions server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { existsSync } from "node:fs";
import {
createRoom,
getRoom,
allRooms,
addMember,
removeMember,
membersList,
Expand All @@ -24,6 +25,7 @@ import {
type SelectedElement,
} from "./rooms.js";
import { getStorage } from "./storage/index.js";
import { KeyedMutex } from "./engine/keyed-mutex.js";
import { setCredential, getCredential, clearCredential } from "./keys.js";
import {
aUsuarioPublico,
Expand All @@ -41,6 +43,14 @@ import {
urlDeAutorizacion,
} from "./cuentas.js";
import type { StoredUsuario } from "./storage/types.js";
import {
guardarAvatar,
rutaAvatar,
mediaTypeDeAvatar,
borrarAvatar,
esAvatarPropio,
AvatarInvalido,
} from "./engine/avatares.js";
import { makeProvider, esProviderId, PERFILES, proveedorVe } from "./agent/providers/profiles.js";
import {
guardarAdjunto,
Expand Down Expand Up @@ -324,6 +334,105 @@ fastify.delete<{ Params: { roomId: string } }>("/auth/salas/:roomId", async (req
return { ok: true };
});

/**
* Cambiar la foto es un recurso compartido más: se escribe la nueva, se apunta
* en la base y se borra la vieja. Dos peticiones a la vez de la misma persona
* pueden dejar el archivo nuevo borrado y la columna apuntando a nada.
*/
const cambiosDePerfil = new KeyedMutex();

/** Cambiar tu nombre. El que pongas aquí pisa al que dijo Google. */
fastify.patch<{ Body: { nombre?: unknown } }>("/auth/perfil", async (req, reply) => {
const usuario = await usuarioDe(req);
if (!usuario) return reply.code(401).send({ error: "sin sesión" });

const crudo = typeof req.body?.nombre === "string" ? req.body.nombre : "";
const nombre = crudo.replace(/\s+/g, " ").trim().slice(0, 40);
if (!nombre) return reply.code(400).send({ error: "el nombre no puede quedar vacío" });

await (await getStorage()).actualizarUsuario(usuario.id, {
nombre,
correo: usuario.correo,
foto: usuario.foto ?? null,
});
refrescarEnSalas(usuario.id, { nombre });
return { usuario: aUsuarioPublico({ ...usuario, nombre }) };
});

/** Subir tu propia foto, en lugar de la que trajo Google. */
fastify.post<{ Body: { mediaType?: unknown; data?: unknown } }>(
"/auth/perfil/foto",
async (req, reply) => {
const usuario = await usuarioDe(req);
if (!usuario) return reply.code(401).send({ error: "sin sesión" });

return cambiosDePerfil.run(usuario.id, async () => {
let id: string;
try {
id = await guardarAvatar(req.body ?? {});
} catch (err) {
return reply
.code(400)
.send({ error: err instanceof AvatarInvalido ? err.message : "no se pudo guardar" });
}

const anterior = usuario.foto;
const foto = `/auth/foto/${id}`;
await (await getStorage()).actualizarUsuario(usuario.id, {
nombre: usuario.nombre,
correo: usuario.correo,
foto,
});
// La anterior solo si era nuestra: las de Google son una URL suya.
if (esAvatarPropio(anterior)) {
void borrarAvatar(anterior!.replace("/auth/foto/", ""));
}
refrescarEnSalas(usuario.id, { foto });
return { usuario: aUsuarioPublico({ ...usuario, foto }) };
});
},
);

/**
* Las fotos de perfil, sin pedir sesión.
*
* Públicas a propósito: se ven en el chat de una sala donde puede haber gente
* sin cuenta, y esconderlas de ellos no protegería nada.
*
* `immutable` se puede porque el id cambia cada vez que alguien cambia su foto.
*/
fastify.get<{ Params: { avatarId: string } }>("/auth/foto/:avatarId", async (req, reply) => {
const ruta = await rutaAvatar(req.params.avatarId);
if (!ruta) return reply.code(404).send({ error: "no está" });
const tipo = mediaTypeDeAvatar(req.params.avatarId);
if (!tipo) return reply.code(404).send({ error: "no está" });
return reply
.header("content-type", tipo)
.header("cache-control", "public, max-age=31536000, immutable")
.send(await readFile(ruta));
});

/**
* Refresca lo que las salas saben de alguien que acaba de cambiar su perfil.
*
* Sin esto, el `Member` en memoria se queda con el nombre y la foto viejos hasta
* que la persona recargue, y los demás de la sala la seguirían viendo como antes.
* Se toca el miembro y se rebroadcastea la presencia, que es el mismo camino que
* usa `auth:key` cuando alguien trae su credencial.
*/
function refrescarEnSalas(usuarioId: string, cambios: { nombre?: string; foto?: string }): void {
for (const room of allRooms()) {
let tocada = false;
for (const member of room.members.values()) {
if (member.usuarioId !== usuarioId) continue;
if (cambios.nombre) member.name = cambios.nombre;
if (cambios.foto) member.foto = cambios.foto;
tocada = true;
}
if (tocada) io.to(room.id).emit("presence", { members: membersList(room) });
}
}

// Crear sala → devuelve su id. El cliente navega a /sala/:id.
fastify.post("/rooms", async () => {
const room = await createRoom();
Expand Down
Loading
Loading