utn-mcp 0.2.1 → 0.6.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,72 +1,73 @@
1
1
  import { inflateRawSync } from "node:zlib";
2
- const FIN_DIRECTORIO = 0x06054b50;
3
- const DIRECTORIO = 0x02014b50;
4
- const ENCABEZADO_LOCAL = 0x04034b50;
5
- const SIN_COMPRESION = 0;
2
+ const END_OF_CENTRAL_DIRECTORY = 0x06054b50;
3
+ const CENTRAL_DIRECTORY_ENTRY = 0x02014b50;
4
+ const LOCAL_HEADER = 0x04034b50;
5
+ const STORED = 0;
6
6
  const DEFLATE = 8;
7
- class ZipInvalido extends Error {
8
- constructor(detalle) {
9
- super(`El archivo no es un zip válido: ${detalle}.`);
10
- this.name = "ZipInvalido";
7
+ class InvalidZip extends Error {
8
+ constructor(detail) {
9
+ super(`El archivo no es un zip válido: ${detail}.`);
10
+ this.name = "InvalidZip";
11
11
  }
12
12
  }
13
- export function listarZip(bytes) {
14
- const vista = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
13
+ export function listZip(bytes) {
14
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
15
15
  // El fin del directorio está al final, seguido de un comentario opcional de hasta 64 KB.
16
- let fin = -1;
16
+ let end = -1;
17
17
  for (let i = bytes.length - 22; i >= Math.max(0, bytes.length - 22 - 0xffff); i--) {
18
- if (vista.getUint32(i, true) === FIN_DIRECTORIO) {
19
- fin = i;
18
+ if (view.getUint32(i, true) === END_OF_CENTRAL_DIRECTORY) {
19
+ end = i;
20
20
  break;
21
21
  }
22
22
  }
23
- if (fin < 0)
24
- throw new ZipInvalido("no tiene índice");
25
- const cantidad = vista.getUint16(fin + 10, true);
26
- let p = vista.getUint32(fin + 16, true);
27
- if (cantidad === 0xffff || p === 0xffffffff)
28
- throw new ZipInvalido("usa zip64");
29
- const entradas = [];
30
- const nombres = new TextDecoder();
31
- for (let i = 0; i < cantidad; i++) {
32
- if (p + 46 > bytes.length || vista.getUint32(p, true) !== DIRECTORIO)
33
- throw new ZipInvalido("índice corrupto");
34
- const largoNombre = vista.getUint16(p + 28, true);
35
- entradas.push({
36
- nombre: nombres.decode(bytes.subarray(p + 46, p + 46 + largoNombre)),
37
- metodo: vista.getUint16(p + 10, true),
38
- comprimido: vista.getUint32(p + 20, true),
39
- tamanio: vista.getUint32(p + 24, true),
40
- offset: vista.getUint32(p + 42, true),
23
+ if (end < 0)
24
+ throw new InvalidZip("no tiene índice");
25
+ const count = view.getUint16(end + 10, true);
26
+ let p = view.getUint32(end + 16, true);
27
+ if (count === 0xffff || p === 0xffffffff)
28
+ throw new InvalidZip("usa zip64");
29
+ const entries = [];
30
+ const decoder = new TextDecoder();
31
+ for (let i = 0; i < count; i++) {
32
+ if (p + 46 > bytes.length || view.getUint32(p, true) !== CENTRAL_DIRECTORY_ENTRY) {
33
+ throw new InvalidZip("índice corrupto");
34
+ }
35
+ const nameLength = view.getUint16(p + 28, true);
36
+ entries.push({
37
+ name: decoder.decode(bytes.subarray(p + 46, p + 46 + nameLength)),
38
+ method: view.getUint16(p + 10, true),
39
+ compressedSize: view.getUint32(p + 20, true),
40
+ size: view.getUint32(p + 24, true),
41
+ offset: view.getUint32(p + 42, true),
41
42
  });
42
- p += 46 + largoNombre + vista.getUint16(p + 30, true) + vista.getUint16(p + 32, true);
43
+ p += 46 + nameLength + view.getUint16(p + 30, true) + view.getUint16(p + 32, true);
43
44
  }
44
- return entradas;
45
+ return entries;
45
46
  }
46
47
  /**
47
48
  * Descomprime una entrada. `max` corta aunque el índice mienta sobre el tamaño: un zip chico que se
48
49
  * infla a gigas es un ataque conocido.
49
50
  */
50
- export function leerEntrada(bytes, entrada, max) {
51
- if (entrada.tamanio > max)
52
- throw new ZipInvalido(`"${entrada.nombre}" descomprimido pesa demasiado`);
53
- const vista = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
54
- const p = entrada.offset;
55
- if (vista.getUint32(p, true) !== ENCABEZADO_LOCAL)
56
- throw new ZipInvalido(`falta el encabezado de "${entrada.nombre}"`);
57
- const inicio = p + 30 + vista.getUint16(p + 26, true) + vista.getUint16(p + 28, true);
58
- const datos = bytes.subarray(inicio, inicio + entrada.comprimido);
59
- switch (entrada.metodo) {
60
- case SIN_COMPRESION:
61
- return datos;
51
+ export function readEntry(bytes, entry, max) {
52
+ if (entry.size > max)
53
+ throw new InvalidZip(`"${entry.name}" descomprimido pesa demasiado`);
54
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
55
+ const p = entry.offset;
56
+ if (view.getUint32(p, true) !== LOCAL_HEADER)
57
+ throw new InvalidZip(`falta el encabezado de "${entry.name}"`);
58
+ const start = p + 30 + view.getUint16(p + 26, true) + view.getUint16(p + 28, true);
59
+ const data = bytes.subarray(start, start + entry.compressedSize);
60
+ switch (entry.method) {
61
+ case STORED:
62
+ return data;
62
63
  case DEFLATE:
63
64
  try {
64
- return inflateRawSync(datos, { maxOutputLength: max });
65
+ return inflateRawSync(data, { maxOutputLength: max });
65
66
  }
66
67
  catch (e) {
67
- throw new ZipInvalido(`no se pudo descomprimir "${entrada.nombre}" (${e.message})`);
68
+ throw new InvalidZip(`no se pudo descomprimir "${entry.name}" (${e.message})`);
68
69
  }
69
70
  default:
70
- throw new ZipInvalido(`"${entrada.nombre}" usa una compresión no soportada (${entrada.metodo})`);
71
+ throw new InvalidZip(`"${entry.name}" usa una compresión no soportada (${entry.method})`);
71
72
  }
72
73
  }
@@ -14,6 +14,19 @@ export function htmlToText(html) {
14
14
  }
15
15
  /** Como htmlToText pero recortado: para índices, donde solo hace falta decidir si entrar. */
16
16
  export function htmlToSnippet(html, max = 200) {
17
- const texto = htmlToText(html);
18
- return texto.length > max ? `${texto.slice(0, max).trimEnd()}…` : texto;
17
+ const text = htmlToText(html);
18
+ return text.length > max ? `${text.slice(0, max).trimEnd()}…` : text;
19
+ }
20
+ /** Texto plano del modelo a HTML para Moodle: escapa, y cada línea en blanco separa un párrafo. */
21
+ export function textToHtml(text) {
22
+ const escaped = text
23
+ .trim()
24
+ .replace(/&/g, "&amp;")
25
+ .replace(/</g, "&lt;")
26
+ .replace(/>/g, "&gt;")
27
+ .replace(/"/g, "&quot;");
28
+ return escaped
29
+ .split(/\n\s*\n/)
30
+ .map((p) => `<p>${p.replace(/\n/g, "<br>")}</p>`)
31
+ .join("");
19
32
  }
@@ -11,3 +11,7 @@ export function configDir() {
11
11
  }
12
12
  /** Token de Moodle + cookies del SSO del modo local. */
13
13
  export const sessionFile = () => join(configDir(), "session.json");
14
+ /** Base de usuarios, tokens y sesiones del modo HTTP: UTN_MCP_DB o un SQLite en la carpeta de configuración. */
15
+ export const storeFile = () => process.env.UTN_MCP_DB ?? join(configDir(), "utn-mcp.db");
16
+ /** Clave con la que se cifran las sesiones en la base, si no viene en UTN_MCP_KEY. */
17
+ export const storeKeyFile = () => join(configDir(), "store.key");
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,145 @@
1
+ import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
2
+ import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import { dirname } from "node:path";
4
+ import { storeFile, storeKeyFile } from "../paths.js";
5
+ const SCHEMA = `
6
+ CREATE TABLE IF NOT EXISTS users (
7
+ id INTEGER PRIMARY KEY,
8
+ utn_username TEXT NOT NULL UNIQUE,
9
+ created_at INTEGER NOT NULL
10
+ );
11
+ CREATE TABLE IF NOT EXISTS tokens (
12
+ hash TEXT PRIMARY KEY,
13
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
14
+ kind TEXT NOT NULL CHECK (kind IN ('access', 'refresh')),
15
+ client_id TEXT,
16
+ expires_at INTEGER,
17
+ created_at INTEGER NOT NULL
18
+ );
19
+ CREATE INDEX IF NOT EXISTS tokens_user ON tokens(user_id);
20
+ CREATE TABLE IF NOT EXISTS platform_sessions (
21
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
22
+ platform TEXT NOT NULL,
23
+ data BLOB NOT NULL,
24
+ updated_at INTEGER NOT NULL,
25
+ PRIMARY KEY (user_id, platform)
26
+ );
27
+ `;
28
+ /** Bytes de la clave AES-256. */
29
+ const KEY_BYTES = 32;
30
+ /** Bytes del IV de AES-GCM. */
31
+ const IV_BYTES = 12;
32
+ /** Bytes del tag de autenticación de AES-GCM. */
33
+ const TAG_BYTES = 16;
34
+ const now = () => Math.floor(Date.now() / 1000);
35
+ const hashToken = (token) => createHash("sha256").update(token).digest("hex");
36
+ /**
37
+ * El SessionStore sobre SQLite. Las sesiones van cifradas con AES-256-GCM: tienen el token de Moodle
38
+ * y las cookies del SSO, que alcanzan para entrar al aula como el usuario. Los tokens se guardan
39
+ * hasheados, así que una copia de la base no sirve para usar el servidor.
40
+ */
41
+ export class SqliteStore {
42
+ db;
43
+ key;
44
+ constructor(db, key) {
45
+ this.db = db;
46
+ this.key = key;
47
+ }
48
+ /** `node:sqlite` llega con Node 22.13; se importa acá para que el modo STDIO siga andando en Node 20. */
49
+ static async open(path = storeFile()) {
50
+ let sqlite;
51
+ try {
52
+ sqlite = await import("node:sqlite");
53
+ }
54
+ catch {
55
+ throw new Error(`El modo HTTP necesita Node 22.13 o más nuevo; tenés ${process.version}.`);
56
+ }
57
+ await mkdir(dirname(path), { recursive: true });
58
+ const db = new sqlite.DatabaseSync(path);
59
+ // WAL: el login escribe mientras el servidor lee, desde otro proceso.
60
+ db.exec("PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON; PRAGMA busy_timeout = 5000;");
61
+ db.exec(SCHEMA);
62
+ return new SqliteStore(db, await loadKey());
63
+ }
64
+ async findOrCreateUser(utnUsername) {
65
+ const username = utnUsername.trim().toLowerCase();
66
+ this.db
67
+ .prepare("INSERT INTO users (utn_username, created_at) VALUES (?, ?) ON CONFLICT DO NOTHING")
68
+ .run(username, now());
69
+ const row = this.db.prepare("SELECT id FROM users WHERE utn_username = ?").get(username);
70
+ return row.id;
71
+ }
72
+ async issueToken(userId, options) {
73
+ const token = randomBytes(32).toString("base64url");
74
+ const createdAt = now();
75
+ this.db
76
+ .prepare("INSERT INTO tokens (hash, user_id, kind, client_id, expires_at, created_at) VALUES (?, ?, ?, ?, ?, ?)")
77
+ .run(hashToken(token), userId, options.kind, options.clientId ?? null, options.ttlSeconds ? createdAt + options.ttlSeconds : null, createdAt);
78
+ return token;
79
+ }
80
+ async findToken(token, kind) {
81
+ const row = this.db
82
+ .prepare("SELECT user_id, client_id, expires_at FROM tokens " +
83
+ "WHERE hash = ? AND kind = ? AND (expires_at IS NULL OR expires_at > ?)")
84
+ .get(hashToken(token), kind, now());
85
+ return row ? { userId: row.user_id, clientId: row.client_id, expiresAt: row.expires_at } : null;
86
+ }
87
+ async revokeToken(token) {
88
+ this.db.prepare("DELETE FROM tokens WHERE hash = ?").run(hashToken(token));
89
+ }
90
+ async loadSession(userId, platform) {
91
+ const row = this.db
92
+ .prepare("SELECT data FROM platform_sessions WHERE user_id = ? AND platform = ?")
93
+ .get(userId, platform);
94
+ return row ? this.decrypt(row.data) : null;
95
+ }
96
+ async saveSession(userId, platform, data) {
97
+ this.db
98
+ .prepare("INSERT INTO platform_sessions (user_id, platform, data, updated_at) VALUES (?, ?, ?, ?) " +
99
+ "ON CONFLICT (user_id, platform) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at")
100
+ .run(userId, platform, this.encrypt(data), now());
101
+ }
102
+ /** iv + tag + texto cifrado, en un solo blob. */
103
+ encrypt(plain) {
104
+ const iv = randomBytes(IV_BYTES);
105
+ const cipher = createCipheriv("aes-256-gcm", this.key, iv);
106
+ const body = Buffer.concat([cipher.update(plain, "utf8"), cipher.final()]);
107
+ return Buffer.concat([iv, cipher.getAuthTag(), body]);
108
+ }
109
+ decrypt(blob) {
110
+ const buf = Buffer.from(blob);
111
+ const decipher = createDecipheriv("aes-256-gcm", this.key, buf.subarray(0, IV_BYTES));
112
+ decipher.setAuthTag(buf.subarray(IV_BYTES, IV_BYTES + TAG_BYTES));
113
+ try {
114
+ return Buffer.concat([decipher.update(buf.subarray(IV_BYTES + TAG_BYTES)), decipher.final()]).toString("utf8");
115
+ }
116
+ catch {
117
+ throw new Error("No se pudo descifrar la sesión: la clave (UTN_MCP_KEY o store.key) no es la que la cifró.");
118
+ }
119
+ }
120
+ }
121
+ /**
122
+ * UTN_MCP_KEY (32 bytes en base64) en el servidor hosteado, donde la clave no tiene que estar en el
123
+ * mismo disco que la base. En local, si no está, se genera una en la carpeta de configuración.
124
+ */
125
+ async function loadKey() {
126
+ const fromEnv = process.env.UTN_MCP_KEY;
127
+ if (fromEnv) {
128
+ const key = Buffer.from(fromEnv, "base64");
129
+ if (key.length !== KEY_BYTES)
130
+ throw new Error(`UTN_MCP_KEY tiene que ser de ${KEY_BYTES} bytes en base64.`);
131
+ return key;
132
+ }
133
+ const file = storeKeyFile();
134
+ try {
135
+ return Buffer.from((await readFile(file, "utf8")).trim(), "base64");
136
+ }
137
+ catch {
138
+ const key = randomBytes(KEY_BYTES);
139
+ await mkdir(dirname(file), { recursive: true });
140
+ // `wx`: si otro proceso la creó recién, no la pisamos.
141
+ await writeFile(file, key.toString("base64"), { mode: 0o600, flag: "wx" }).catch(() => { });
142
+ await chmod(file, 0o600).catch(() => { }); // no-op en Windows
143
+ return Buffer.from((await readFile(file, "utf8")).trim(), "base64");
144
+ }
145
+ }
@@ -0,0 +1,80 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { extname } from "node:path";
3
+ /** Cuánto dura un link de subida, en milisegundos. */
4
+ const UPLOAD_TTL_MS = 30 * 60 * 1000;
5
+ /** Tope de una subida entera, en bytes: los archivos quedan en memoria hasta que se entregan. */
6
+ export const MAX_UPLOAD_BYTES = 100 * 1024 * 1024;
7
+ /**
8
+ * Links de un solo uso para recibir archivos por HTTP: los sube el agente con `curl` si tiene
9
+ * terminal, o el usuario desde el navegador. Los archivos quedan en memoria hasta que una tool los
10
+ * toma o el link vence; si el servidor se reinicia, se pierden y hay que pedir otro link.
11
+ */
12
+ export class UploadInbox {
13
+ baseUrl;
14
+ uploads = new Map();
15
+ constructor(baseUrl) {
16
+ this.baseUrl = baseUrl;
17
+ }
18
+ open(owner, title, limits) {
19
+ this.sweep();
20
+ const id = randomBytes(24).toString("base64url");
21
+ const expiresAt = Date.now() + UPLOAD_TTL_MS;
22
+ this.uploads.set(id, { owner, title, limits, files: null, expiresAt });
23
+ return { id, url: `${this.baseUrl}/subir/${id}`, expiresAt: new Date(expiresAt).toISOString() };
24
+ }
25
+ /** La subida para mostrar su formulario, o undefined si no existe o venció. */
26
+ get(id) {
27
+ this.sweep();
28
+ return this.uploads.get(id);
29
+ }
30
+ /** Guarda los archivos de una subida; una subida nueva al mismo link reemplaza la anterior. */
31
+ receive(id, files) {
32
+ const upload = this.uploads.get(id);
33
+ if (!upload || upload.expiresAt < Date.now())
34
+ throw new Error("El link de subida venció o no existe.");
35
+ if (!files.length)
36
+ throw new Error('No llegó ningún archivo: van en el campo "archivos".');
37
+ checkFiles(files, upload.limits);
38
+ upload.files = files;
39
+ }
40
+ /** Los archivos que se subieron, para entregarlos. No los borra: si la entrega falla, se reintenta. */
41
+ take(id, owner) {
42
+ const upload = this.uploads.get(id);
43
+ if (!upload || upload.owner !== owner || upload.expiresAt < Date.now()) {
44
+ throw new Error("La subida venció o no existe: pedí otro link.");
45
+ }
46
+ if (!upload.files) {
47
+ throw new Error("Todavía no se subió nada a ese link: esperá a que se use y volvé a intentar.");
48
+ }
49
+ return upload.files;
50
+ }
51
+ close(id) {
52
+ this.uploads.delete(id);
53
+ }
54
+ sweep() {
55
+ const now = Date.now();
56
+ for (const [id, upload] of this.uploads)
57
+ if (upload.expiresAt < now)
58
+ this.uploads.delete(id);
59
+ }
60
+ }
61
+ /** Cantidad, tamaño, tipo y nombres repetidos, que Moodle no acepta. */
62
+ export function checkFiles(files, limits) {
63
+ if (limits.maxFiles && files.length > limits.maxFiles) {
64
+ throw new Error(`Se aceptan hasta ${limits.maxFiles} archivo(s) y llegaron ${files.length}.`);
65
+ }
66
+ const seen = new Set();
67
+ for (const f of files) {
68
+ if (f.bytes.length > limits.maxBytes) {
69
+ throw new Error(`"${f.name}" pesa ${formatSize(f.bytes.length)}; el máximo es ${formatSize(limits.maxBytes)} por archivo.`);
70
+ }
71
+ if (limits.extensions?.length && !limits.extensions.includes(extname(f.name).toLowerCase())) {
72
+ throw new Error(`"${f.name}" no es de un tipo aceptado (${limits.extensions.join(", ")}).`);
73
+ }
74
+ if (seen.has(f.name))
75
+ throw new Error(`Hay dos archivos que se llaman "${f.name}"; renombrá uno.`);
76
+ seen.add(f.name);
77
+ }
78
+ }
79
+ /** Tamaño para personas: en KB por debajo del mega, para que un archivo chico no diga "0.0 MB". */
80
+ export const formatSize = (bytes) => bytes < 1024 * 1024 ? `${Math.ceil(bytes / 1024)} KB` : `${(bytes / 1024 / 1024).toFixed(1)} MB`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "utn-mcp",
3
- "version": "0.2.1",
3
+ "version": "0.6.9",
4
4
  "description": "Este es el Model Context Protocol server NO OFICIAL de la UTN para buscar informacion o archivos en el aula virtual.",
5
5
  "main": "build/index.js",
6
6
  "bin": {
@@ -1,39 +0,0 @@
1
- export const RAIZ = "/";
2
- export const cursoAddr = (courseId) => `/curso/${courseId}`;
3
- export const moduloAddr = (courseId, cmid) => `/curso/${courseId}/mod/${cmid}`;
4
- export const discusionAddr = (courseId, cmid, discussionId) => `/curso/${courseId}/mod/${cmid}/disc/${discussionId}`;
5
- const FORMATOS = [
6
- "/",
7
- "/curso/{courseId}",
8
- "/curso/{courseId}/mod/{cmid}",
9
- "/curso/{courseId}/mod/{cmid}/disc/{discussionId}",
10
- ].join(", ");
11
- const PATRON = /^\/curso\/(\d+)(?:\/mod\/(\d+)(?:\/disc\/(\d+))?)?$/;
12
- export function parseAddress(raw) {
13
- const path = raw.trim().replace(/\/+$/, "") || "/";
14
- if (path === "/")
15
- return { nivel: "raiz" };
16
- const m = PATRON.exec(path);
17
- if (!m)
18
- throw new Error(`Dirección inválida: "${raw}". Formatos válidos: ${FORMATOS}`);
19
- const [, curso, cmid, disc] = m;
20
- const courseId = Number(curso);
21
- if (!cmid)
22
- return { nivel: "curso", courseId };
23
- if (!disc)
24
- return { nivel: "modulo", courseId, cmid: Number(cmid) };
25
- return { nivel: "discusion", courseId, cmid: Number(cmid), discussionId: Number(disc) };
26
- }
27
- /** La dirección de un nivel más arriba, o null si ya estamos en la raíz. */
28
- export function padre(address) {
29
- switch (address.nivel) {
30
- case "raiz":
31
- return null;
32
- case "curso":
33
- return RAIZ;
34
- case "modulo":
35
- return cursoAddr(address.courseId);
36
- case "discusion":
37
- return moduloAddr(address.courseId, address.cmid);
38
- }
39
- }
@@ -1,89 +0,0 @@
1
- import { abrirDocumento, FormatoNoSoportado } from "../shared/documents/index.js";
2
- import { AULA_BASE_URL } from "./client/constants.js";
3
- import { fetchFile } from "./client/moodle-api.js";
4
- const ORIGEN = new URL(AULA_BASE_URL).origin;
5
- const RUTA_WS = "/webservice/pluginfile.php/";
6
- const RUTA_WEB = "/pluginfile.php/";
7
- /** Cuánto se devuelve por llamada, en caracteres de texto. */
8
- const PRESUPUESTO = 30_000;
9
- /** Lo que cuesta una página como imagen (~1.600 tokens), medido en caracteres de texto. */
10
- const COSTO_IMAGEN = 6_000;
11
- /** El más grande que hay hoy en los cursos pesa 12 MB. */
12
- const MAX_BYTES = 30 * 1024 * 1024;
13
- /**
14
- * Moodle devuelve las URLs del web service, que piden token. Al usuario le sirve la misma ruta sin
15
- * `/webservice`, que se abre con la sesión de su navegador. Es la única que mostramos: el token no
16
- * sale nunca del servidor.
17
- */
18
- export function urlParaUsuario(fileurl) {
19
- return fileurl.replace(`${ORIGEN}${RUTA_WS}`, `${ORIGEN}${RUTA_WEB}`);
20
- }
21
- /**
22
- * Acepta cualquiera de las dos formas y devuelve la del web service. Solo archivos del aula: con
23
- * cualquier otra URL estaríamos mandando el token del usuario a donde diga el modelo.
24
- */
25
- function urlDeDescarga(url) {
26
- let u;
27
- try {
28
- u = new URL(url);
29
- }
30
- catch {
31
- throw new Error(`URL inválida: "${url}". Usá la \`url\` de un archivo tal como la devuelve aula_open.`);
32
- }
33
- const ruta = u.pathname.startsWith(RUTA_WS) ? u.pathname : u.pathname.startsWith(RUTA_WEB) ? RUTA_WS + u.pathname.slice(RUTA_WEB.length) : null;
34
- if (u.origin !== ORIGEN || !ruta) {
35
- throw new Error(`Solo se pueden leer archivos del aula virtual (${ORIGEN}${RUTA_WEB}...).`);
36
- }
37
- u.pathname = ruta;
38
- u.searchParams.delete("token");
39
- u.searchParams.delete("wstoken");
40
- return u;
41
- }
42
- /** Baja un archivo del aula y devuelve desde la parte `desde` tantas como entren en el presupuesto. */
43
- export async function leerArchivo(session, url, desde = 1) {
44
- const descarga = urlDeDescarga(url);
45
- const archivo = decodeURIComponent(descarga.pathname.split("/").pop() ?? "");
46
- const paraUsuario = urlParaUsuario(descarga.href);
47
- const { bytes } = await fetchFile(session, descarga, MAX_BYTES);
48
- let documento;
49
- try {
50
- documento = await abrirDocumento(bytes, archivo);
51
- }
52
- catch (e) {
53
- if (e instanceof FormatoNoSoportado) {
54
- throw new Error(`${e.message} El usuario lo puede bajar desde ${paraUsuario}`);
55
- }
56
- throw e;
57
- }
58
- try {
59
- if (desde > documento.total) {
60
- throw new Error(`"${archivo}" termina en ${documento.unidad} ${documento.total}; se pidió desde ${desde}.`);
61
- }
62
- const partes = [];
63
- let gastado = 0;
64
- let n = desde;
65
- for (; n <= documento.total; n++) {
66
- const parte = await documento.parte(n);
67
- const costo = "texto" in parte ? parte.texto.length : COSTO_IMAGEN;
68
- // Siempre al menos una parte, aunque sola se pase del presupuesto.
69
- if (partes.length > 0 && gastado + costo > PRESUPUESTO)
70
- break;
71
- partes.push({ ...parte, numero: n });
72
- gastado += costo;
73
- }
74
- return {
75
- archivo,
76
- formato: documento.formato,
77
- unidad: documento.unidad,
78
- total: documento.total,
79
- desde,
80
- hasta: n - 1,
81
- siguiente: n <= documento.total ? n : undefined,
82
- urlParaUsuario: paraUsuario,
83
- partes,
84
- };
85
- }
86
- finally {
87
- await documento.cerrar();
88
- }
89
- }
@@ -1,144 +0,0 @@
1
- import { htmlToSnippet, htmlToText } from "../shared/html.js";
2
- import { cursoAddr, discusionAddr, moduloAddr, padre, parseAddress, RAIZ } from "./address.js";
3
- import { getCourseSections, getDiscussionPosts, getForumDiscussions, getUserCourses, POR_PAGINA, } from "./client/client.js";
4
- import { urlParaUsuario } from "./files.js";
5
- const isoDate = (seconds) => (seconds ? new Date(seconds * 1000).toISOString() : null);
6
- /**
7
- * Entra a una dirección del aula y devuelve su contenido, el padre y las direcciones de lo que hay
8
- * adentro. Es el único lugar que decide qué ve el modelo y cuánto texto le cuesta.
9
- */
10
- export async function open(session, direccion, pagina = 0) {
11
- const address = parseAddress(direccion);
12
- const base = { direccion: formatear(address), padre: padre(address) };
13
- switch (address.nivel) {
14
- case "raiz":
15
- return { ...base, cursos: await verCursos(session) };
16
- case "curso":
17
- return { ...base, secciones: await verCurso(session, address.courseId) };
18
- case "modulo":
19
- return { ...base, ...(await verModulo(session, address, pagina)) };
20
- case "discusion":
21
- return { ...base, ...(await verDiscusion(session, address.discussionId)) };
22
- }
23
- }
24
- function formatear(address) {
25
- switch (address.nivel) {
26
- case "raiz":
27
- return RAIZ;
28
- case "curso":
29
- return cursoAddr(address.courseId);
30
- case "modulo":
31
- return moduloAddr(address.courseId, address.cmid);
32
- case "discusion":
33
- return discusionAddr(address.courseId, address.cmid, address.discussionId);
34
- }
35
- }
36
- async function verCursos(session) {
37
- const courses = await getUserCourses(session);
38
- return courses.map((c) => ({
39
- direccion: cursoAddr(c.id),
40
- id: c.id,
41
- nombre: c.fullname,
42
- nombreCorto: c.shortname,
43
- visible: c.visible === 1,
44
- progreso: c.progress ?? null,
45
- ultimoAcceso: isoDate(c.lastaccess),
46
- }));
47
- }
48
- /** El mapa del curso: secciones y módulos, con las descripciones recortadas. */
49
- async function verCurso(session, courseId) {
50
- const sections = await getCourseSections(session, courseId);
51
- return sections
52
- .filter((s) => s.uservisible !== false)
53
- .map((s) => ({
54
- nombre: s.name,
55
- numero: s.section,
56
- resumen: htmlToText(s.summary) || undefined,
57
- modulos: s.modules.filter((m) => m.uservisible).map((m) => toModule(m, courseId, true)),
58
- }))
59
- .filter((s) => s.resumen || s.modulos.length > 0);
60
- }
61
- async function verModulo(session, address, pagina) {
62
- const { seccion, module } = await buscarModulo(session, address.courseId, address.cmid);
63
- const modulo = toModule(module, address.courseId);
64
- if (module.modname !== "forum")
65
- return { seccion, modulo };
66
- // Un foro es un índice: asuntos y un fragmento de cada uno. El mensaje completo sale al abrir la discusión.
67
- const { discussions } = await getForumDiscussions(session, module.instance, pagina);
68
- return {
69
- seccion,
70
- modulo,
71
- discusiones: discussions.map((d) => ({
72
- direccion: discusionAddr(address.courseId, address.cmid, d.discussion),
73
- asunto: d.subject,
74
- autor: d.userfullname,
75
- fecha: isoDate(d.created),
76
- ultimaActividad: isoDate(d.timemodified),
77
- respuestas: d.numreplies,
78
- fijada: d.pinned || undefined,
79
- adjuntos: d.attachment || undefined,
80
- resumen: htmlToSnippet(d.message),
81
- })),
82
- pagina,
83
- hayMasDiscusiones: discussions.length === POR_PAGINA || undefined,
84
- };
85
- }
86
- async function verDiscusion(session, discussionId) {
87
- const { posts } = await getDiscussionPosts(session, discussionId);
88
- const visibles = posts.filter((p) => !p.isdeleted);
89
- return {
90
- asunto: visibles[0]?.subject,
91
- publicaciones: visibles.map((p) => ({
92
- autor: p.author.fullname,
93
- fecha: isoDate(p.timecreated),
94
- respuestaA: p.parentid ?? undefined,
95
- id: p.id,
96
- mensaje: htmlToText(p.message),
97
- adjuntos: p.attachments.length ? p.attachments.map(toFile) : undefined,
98
- })),
99
- };
100
- }
101
- /** Ubica un módulo por su cmid y se queda con la sección que lo contiene. */
102
- async function buscarModulo(session, courseId, cmid) {
103
- const sections = await getCourseSections(session, courseId, cmid);
104
- for (const section of sections) {
105
- const module = section.modules.find((m) => m.id === cmid);
106
- if (!module)
107
- continue;
108
- if (!module.uservisible)
109
- throw new Error(`No tenés acceso al módulo ${cmid}.`);
110
- return { seccion: section.name, module };
111
- }
112
- throw new Error(`No existe el módulo ${cmid} en el curso ${courseId}.`);
113
- }
114
- /** `resumido` recorta la descripción: en el mapa del curso alcanza para decidir si entrar. */
115
- function toModule(m, courseId, resumido = false) {
116
- const contents = m.contents ?? [];
117
- const archivos = contents.filter((c) => c.type === "file");
118
- const enlaces = contents.filter((c) => c.type === "url");
119
- return {
120
- direccion: moduloAddr(courseId, m.id),
121
- id: m.id,
122
- nombre: m.name,
123
- tipo: m.modname,
124
- url: m.url,
125
- descripcion: (resumido ? htmlToSnippet(m.description) : htmlToText(m.description)) || undefined,
126
- restriccion: htmlToText(m.availabilityinfo) || undefined,
127
- fechas: m.dates?.length
128
- ? m.dates.map((d) => ({ etiqueta: d.label, fecha: isoDate(d.timestamp) }))
129
- : undefined,
130
- archivos: archivos.length ? archivos.map(toFile) : undefined,
131
- enlaces: enlaces.length ? enlaces.map((l) => l.fileurl) : undefined,
132
- };
133
- }
134
- function toFile(f) {
135
- return {
136
- nombre: f.filename,
137
- ruta: f.filepath || undefined,
138
- tamanio: f.filesize,
139
- mimetype: f.mimetype,
140
- modificado: isoDate(f.timemodified),
141
- // Sirve para pasársela al usuario y también para leerlo con aula_read_file.
142
- url: urlParaUsuario(f.fileurl),
143
- };
144
- }