utn-mcp 0.7.0 → 0.8.0
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.
- package/README.md +199 -180
- package/build/aula-virtual/client/client.js +2 -2
- package/build/aula-virtual/client/link.js +17 -11
- package/build/aula-virtual/client/local-session.js +21 -13
- package/build/aula-virtual/client/moodle-api.js +6 -5
- package/build/aula-virtual/client/regionals.js +103 -0
- package/build/aula-virtual/client/sso-login.js +123 -24
- package/build/aula-virtual/client/stored-session.js +24 -20
- package/build/aula-virtual/format/actions.js +1 -2
- package/build/aula-virtual/format/files.js +30 -11
- package/build/aula-virtual/login.js +24 -10
- package/build/aula-virtual/tools.js +11 -5
- package/build/http.js +57 -9
- package/build/index.js +12 -13
- package/build/oauth/access.js +3 -3
- package/build/oauth/admin.js +75 -13
- package/build/oauth/redirects.js +60 -0
- package/build/oauth/server.js +106 -37
- package/build/server.js +1 -1
- package/build/shared/input.js +60 -0
- package/build/shared/load-env.js +5 -0
- package/build/shared/pages.js +42 -37
- package/build/shared/paths.js +1 -1
- package/build/shared/store/sqlite-store.js +111 -40
- package/build/shared/uploads.js +9 -1
- package/package.json +58 -58
- package/build/aula-virtual/client/constants.js +0 -2
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* El soporte de las que no son `verified` sale de `tool_mobile_get_public_config`, que Moodle contesta
|
|
3
|
+
* sin login (octubre de 2026). Falta Villa María (FRVM): no encontramos su aula.
|
|
4
|
+
*/
|
|
5
|
+
const REGIONALS = [
|
|
6
|
+
{ id: "fra", name: "Avellaneda (FRA)", baseUrl: "https://www.utnfravirtual.org.ar", support: "candidate" },
|
|
7
|
+
{ id: "frbb", name: "Bahía Blanca (FRBB)", baseUrl: "https://aulavirtual.frbb.utn.edu.ar", support: "candidate" },
|
|
8
|
+
{
|
|
9
|
+
id: "frba",
|
|
10
|
+
name: "Buenos Aires (FRBA)",
|
|
11
|
+
baseUrl: "https://aulasvirtuales.frba.utn.edu.ar",
|
|
12
|
+
ssoHost: "auth.frba.utn.edu.ar",
|
|
13
|
+
support: "verified",
|
|
14
|
+
},
|
|
15
|
+
{ id: "frch", name: "Chubut (FRCh)", baseUrl: "https://frch.cvg.utn.edu.ar", support: "candidate" },
|
|
16
|
+
{ id: "frcu", name: "Concepción del Uruguay (FRCU)", baseUrl: "https://virtual.frcu.utn.edu.ar", support: "candidate" },
|
|
17
|
+
{ id: "frcon", name: "Concordia (FRCon)", baseUrl: "https://frcon.cvg.utn.edu.ar", support: "candidate" },
|
|
18
|
+
{ id: "frc", name: "Córdoba (FRC)", baseUrl: "https://uv.frc.utn.edu.ar", support: "verified" },
|
|
19
|
+
{ id: "frd", name: "Delta (FRD)", baseUrl: "https://frd.cvg.utn.edu.ar", support: "candidate" },
|
|
20
|
+
{ id: "frgp", name: "General Pacheco (FRGP)", baseUrl: "https://frgp.cvg.utn.edu.ar", support: "candidate" },
|
|
21
|
+
// Office 365.
|
|
22
|
+
{ id: "frh", name: "Haedo (FRH)", baseUrl: "https://frh.cvg.utn.edu.ar", support: "unsupported" },
|
|
23
|
+
{ id: "frlp", name: "La Plata (FRLP)", baseUrl: "https://frlp.cvg.utn.edu.ar", support: "candidate" },
|
|
24
|
+
{ id: "frlr", name: "La Rioja (FRLR)", baseUrl: "https://frlr.cvg.utn.edu.ar", support: "candidate" },
|
|
25
|
+
// Tiene también frmdp.cvg.utn.edu.ar, pero ese no tiene el servicio habilitado y entra con Office 365.
|
|
26
|
+
{ id: "frmdp", name: "Mar del Plata (FRMDP)", baseUrl: "https://campus.mdp.utn.edu.ar", support: "candidate" },
|
|
27
|
+
// Sin el servicio habilitado.
|
|
28
|
+
{ id: "frm", name: "Mendoza (FRM)", baseUrl: "https://campusvirtual.frm.utn.edu.ar", support: "unsupported" },
|
|
29
|
+
{ id: "frn", name: "Neuquén (FRN)", baseUrl: "https://frn.cvg.utn.edu.ar", support: "candidate" },
|
|
30
|
+
{ id: "frp", name: "Paraná (FRP)", baseUrl: "https://frp.cvg.utn.edu.ar", support: "candidate" },
|
|
31
|
+
{ id: "frra", name: "Rafaela (FRRa)", baseUrl: "https://frra.cvg.utn.edu.ar", support: "candidate" },
|
|
32
|
+
{ id: "frrq", name: "Reconquista (FRRq)", baseUrl: "https://frrq.cvg.utn.edu.ar", support: "candidate" },
|
|
33
|
+
{ id: "frre", name: "Resistencia (FRRE)", baseUrl: "https://frre.cvg.utn.edu.ar", support: "verified" },
|
|
34
|
+
{ id: "frro", name: "Rosario (FRRo)", baseUrl: "https://frro.cvg.utn.edu.ar", support: "candidate" },
|
|
35
|
+
{ id: "frsfco", name: "San Francisco (FRSFco)", baseUrl: "https://frsfco.cvg.utn.edu.ar", support: "candidate" },
|
|
36
|
+
// Office 365.
|
|
37
|
+
{ id: "frsn", name: "San Nicolás (FRSN)", baseUrl: "https://frsn.cvg.utn.edu.ar", support: "unsupported" },
|
|
38
|
+
{ id: "frsr", name: "San Rafael (FRSR)", baseUrl: "https://campus.frsr.utn.edu.ar/moodle", support: "candidate" },
|
|
39
|
+
{ id: "frsc", name: "Santa Cruz (FRSC)", baseUrl: "https://frsc.cvg.utn.edu.ar", support: "candidate" },
|
|
40
|
+
{ id: "frsf", name: "Santa Fe (FRSF)", baseUrl: "https://campusvirtual.frsf.utn.edu.ar", support: "candidate" },
|
|
41
|
+
// Sin el servicio habilitado.
|
|
42
|
+
{ id: "frtdf", name: "Tierra del Fuego (FRTDF)", baseUrl: "https://cvg.frtdf.utn.edu.ar", support: "unsupported" },
|
|
43
|
+
{ id: "frtl", name: "Trenque Lauquen (FRTL)", baseUrl: "https://frtl.cvg.utn.edu.ar", support: "candidate" },
|
|
44
|
+
{ id: "frt", name: "Tucumán (FRT)", baseUrl: "https://frt.cvg.utn.edu.ar", support: "verified" },
|
|
45
|
+
// Sin el servicio habilitado.
|
|
46
|
+
{ id: "frvt", name: "Venado Tuerto (FRVT)", baseUrl: "https://frvt.cvg.utn.edu.ar", support: "unsupported" },
|
|
47
|
+
];
|
|
48
|
+
const DEFAULT_REGIONAL = "frba";
|
|
49
|
+
/** `UTN_REGIONAL` no está en el registro: sin cortar acá, las credenciales irían al SSO de FRBA. */
|
|
50
|
+
export class UnknownRegionalError extends Error {
|
|
51
|
+
name = "UnknownRegionalError";
|
|
52
|
+
constructor(regional) {
|
|
53
|
+
super(`"${regional}" no es una regional conocida. Las válidas son: ${availableRegionals().map((r) => r.id).join(", ")}. ` +
|
|
54
|
+
"Si tu campus no está en la lista, borrá UTN_REGIONAL y definí UTN_AULA_BASE_URL con la URL del aula " +
|
|
55
|
+
"(y UTN_SSO_HOST si usa Keycloak).");
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/** Su aula no tiene el servicio habilitado o entra por un SSO que no sabemos usar: ni se intenta entrar. */
|
|
59
|
+
export class UnsupportedRegionalError extends Error {
|
|
60
|
+
regional;
|
|
61
|
+
name = "UnsupportedRegionalError";
|
|
62
|
+
constructor(regional) {
|
|
63
|
+
super(`Todavía no hay soporte para ${regional.name}: su aula no tiene habilitado el servicio que usa ` +
|
|
64
|
+
"UTN-MCP o entra por un login que todavía no sabemos usar.");
|
|
65
|
+
this.regional = regional;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/** El aula de UTN_AULA_BASE_URL, para un campus que no está en el registro. */
|
|
69
|
+
function customRegional() {
|
|
70
|
+
const raw = process.env.UTN_AULA_BASE_URL?.trim();
|
|
71
|
+
if (!raw)
|
|
72
|
+
return undefined;
|
|
73
|
+
const baseUrl = raw.replace(/\/+$/, "");
|
|
74
|
+
const host = new URL(baseUrl).host;
|
|
75
|
+
return { id: host, name: host, baseUrl, ssoHost: process.env.UTN_SSO_HOST?.trim() || undefined, support: "verified" };
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Las que se pueden elegir al iniciar sesión: el catálogo y, si está definida, la de UTN_AULA_BASE_URL.
|
|
79
|
+
* `verifiedIds` son las `candidate` que ya se verificaron en el servidor (las guarda el store).
|
|
80
|
+
*/
|
|
81
|
+
export function availableRegionals(verifiedIds = []) {
|
|
82
|
+
const verified = new Set(verifiedIds);
|
|
83
|
+
const custom = customRegional();
|
|
84
|
+
return [...REGIONALS, ...(custom ? [custom] : [])].map((r) => r.support === "candidate" && verified.has(r.id) ? { ...r, support: "verified" } : r);
|
|
85
|
+
}
|
|
86
|
+
/** La regional con ese id; si no está entre las disponibles, `UnknownRegionalError`. */
|
|
87
|
+
export function findRegional(id) {
|
|
88
|
+
const wanted = id.trim().toLowerCase();
|
|
89
|
+
const found = availableRegionals().find((r) => r.id === wanted);
|
|
90
|
+
if (!found)
|
|
91
|
+
throw new UnknownRegionalError(id);
|
|
92
|
+
return found;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* La que viene elegida si nadie dice otra: la de UTN_REGIONAL; si no, la de UTN_AULA_BASE_URL; si no,
|
|
96
|
+
* FRBA. Es la de quien usa UTN_USER/UTN_PASS sin `utn-mcp login`, y la opción por defecto al elegir.
|
|
97
|
+
*/
|
|
98
|
+
export function defaultRegional() {
|
|
99
|
+
const fromEnv = process.env.UTN_REGIONAL?.trim();
|
|
100
|
+
if (fromEnv)
|
|
101
|
+
return findRegional(fromEnv);
|
|
102
|
+
return customRegional() ?? findRegional(DEFAULT_REGIONAL);
|
|
103
|
+
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { randomInt } from "node:crypto";
|
|
2
|
-
import {
|
|
2
|
+
import { CookieJar } from "tough-cookie";
|
|
3
|
+
import { UnsupportedRegionalError } from "./regionals.js";
|
|
4
|
+
import { MoodleError } from "./moodle-api.js";
|
|
3
5
|
export class LoginRequiredError extends Error {
|
|
4
6
|
constructor(message = "Hace falta iniciar sesión: corré `utn-mcp login`.") {
|
|
5
7
|
super(message);
|
|
@@ -7,56 +9,135 @@ export class LoginRequiredError extends Error {
|
|
|
7
9
|
}
|
|
8
10
|
}
|
|
9
11
|
export class InvalidCredentialsError extends Error {
|
|
10
|
-
constructor() {
|
|
11
|
-
|
|
12
|
+
constructor(reason) {
|
|
13
|
+
const base = "El aula rechazó usuario o contraseña. Corré `utn-mcp login` para actualizarlos.";
|
|
14
|
+
super(reason ? `${base} Motivo del aula: ${reason}` : base);
|
|
12
15
|
this.name = "InvalidCredentialsError";
|
|
13
16
|
}
|
|
14
17
|
}
|
|
15
18
|
const MAX_REDIRECTS = 20;
|
|
16
19
|
const TIMEOUT_MS = 30_000;
|
|
17
20
|
/**
|
|
18
|
-
* Obtiene un token del web service
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
+
* Obtiene un token del web service. Dos estrategias según la regional:
|
|
22
|
+
* - Regionales con SSO (Keycloak, ej. FRBA): flujo de launch.php, que usa la sesión del SSO. Si el
|
|
23
|
+
* SSO rechaza las credenciales, `login/token.php`: hay usuarios sin cuenta en el SSO.
|
|
24
|
+
* - Regionales con login directo de Moodle (ej. FRT): primero `login/token.php` (no depende del
|
|
25
|
+
* plugin de mobile launch, que puede estar deshabilitado); si falla, flujo de launch.php.
|
|
26
|
+
* Las cookies se usan solo durante el login y se descartan: las sesiones de Moodle y del SSO duran
|
|
27
|
+
* horas y el token, semanas, así que no sirven para renovarlo.
|
|
21
28
|
*/
|
|
22
|
-
export async function fetchToken(
|
|
23
|
-
|
|
29
|
+
export async function fetchToken(regional, credentials) {
|
|
30
|
+
if (regional.support === "unsupported")
|
|
31
|
+
throw new UnsupportedRegionalError(regional);
|
|
32
|
+
const { baseUrl, ssoHost } = regional;
|
|
33
|
+
const jar = new CookieJar();
|
|
34
|
+
// Regionales con login directo: intentamos token.php primero (no depende de launch.php).
|
|
35
|
+
if (!ssoHost) {
|
|
36
|
+
const viaTokenPhp = await tryTokenPhp(baseUrl, credentials);
|
|
37
|
+
if (viaTokenPhp)
|
|
38
|
+
return viaTokenPhp;
|
|
39
|
+
}
|
|
40
|
+
const launchUrl = `${baseUrl}/admin/tool/mobile/launch.php` +
|
|
24
41
|
`?service=moodle_mobile_app&passport=${randomInt(1e9)}&urlscheme=moodlemobile`;
|
|
25
|
-
// 1. Con sesión de Moodle viva, launch.php redirige directo al token.
|
|
26
42
|
let landing = await follow(jar, launchUrl);
|
|
27
43
|
if ("token" in landing)
|
|
28
44
|
return landing.token;
|
|
29
|
-
// 2. Login de Moodle: el botón "Usuarios Institucionales" lleva al SSO.
|
|
30
45
|
const oauthUrl = landing.html.match(/href="([^"]*\/auth\/oauth2\/login\.php[^"]*)"/)?.[1];
|
|
31
|
-
if (
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
46
|
+
if (oauthUrl) {
|
|
47
|
+
landing = await follow(jar, decodeHtml(oauthUrl));
|
|
48
|
+
if ("token" in landing)
|
|
49
|
+
return landing.token;
|
|
50
|
+
if (ssoHost && new URL(landing.url).host === ssoHost) {
|
|
51
|
+
landing = await submitSsoForm(jar, landing.html, credentials);
|
|
52
|
+
if ("token" in landing)
|
|
53
|
+
return landing.token;
|
|
54
|
+
if (ssoHost && new URL(landing.url).host === ssoHost) {
|
|
55
|
+
// Aspirantes del ingreso y algunas tecnicaturas no tienen cuenta en el SSO: entran con el login de Moodle.
|
|
56
|
+
const viaTokenPhp = await tryTokenPhp(baseUrl, credentials);
|
|
57
|
+
if (viaTokenPhp)
|
|
58
|
+
return viaTokenPhp;
|
|
59
|
+
throw new InvalidCredentialsError(extractLoginError(landing.html));
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
const loginPageUrl = `${baseUrl}/login/index.php`;
|
|
65
|
+
const loginPage = await follow(jar, loginPageUrl);
|
|
66
|
+
if ("token" in loginPage)
|
|
67
|
+
return loginPage.token;
|
|
68
|
+
landing = await submitMoodleLoginForm(jar, loginPage.url, loginPage.html, credentials);
|
|
41
69
|
if ("token" in landing)
|
|
42
70
|
return landing.token;
|
|
43
|
-
|
|
44
|
-
|
|
71
|
+
const stillOnLogin = landing.url.includes("/login") ||
|
|
72
|
+
/alert-danger/i.test(landing.html) ||
|
|
73
|
+
/loginerrors/i.test(landing.html);
|
|
74
|
+
if (stillOnLogin) {
|
|
75
|
+
const reason = extractLoginError(landing.html);
|
|
76
|
+
throw new InvalidCredentialsError(reason);
|
|
77
|
+
}
|
|
45
78
|
}
|
|
46
|
-
// 4. Ya con sesión de Moodle (el SSO nos deja en el dashboard), pedimos el token de nuevo.
|
|
47
79
|
landing = await follow(jar, launchUrl);
|
|
48
80
|
if ("token" in landing)
|
|
49
81
|
return landing.token;
|
|
82
|
+
if (!ssoHost) {
|
|
83
|
+
const viaTokenPhp = await tryTokenPhp(baseUrl, credentials);
|
|
84
|
+
if (viaTokenPhp)
|
|
85
|
+
return viaTokenPhp;
|
|
86
|
+
}
|
|
50
87
|
throw unexpected(landing.url);
|
|
51
88
|
}
|
|
89
|
+
/**
|
|
90
|
+
* Pide el token a /login/token.php (Moodle estándar). Devuelve null si el aula no lo expone
|
|
91
|
+
* (no contesta JSON), para seguir con launch.php; si contesta con un error, lo propaga.
|
|
92
|
+
*/
|
|
93
|
+
async function tryTokenPhp(baseUrl, credentials) {
|
|
94
|
+
const res = await fetch(`${baseUrl}/login/token.php`, {
|
|
95
|
+
method: "POST",
|
|
96
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
97
|
+
body: new URLSearchParams({
|
|
98
|
+
username: credentials.username,
|
|
99
|
+
password: credentials.password,
|
|
100
|
+
service: "moodle_mobile_app",
|
|
101
|
+
}),
|
|
102
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
103
|
+
});
|
|
104
|
+
let data;
|
|
105
|
+
try {
|
|
106
|
+
data = await res.json();
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
if (data.token)
|
|
112
|
+
return data.token;
|
|
113
|
+
if (data.errorcode === "invalidlogin" || /invalid login/i.test(data.error ?? "")) {
|
|
114
|
+
throw new InvalidCredentialsError(data.error);
|
|
115
|
+
}
|
|
116
|
+
// El mismo servicio lo usa launch.php: si token.php lo rechaza, seguir no sirve.
|
|
117
|
+
throw new MoodleError(`El aula no dio el token (${data.errorcode ?? res.status}): ${data.error ?? "respuesta sin token"}. ` +
|
|
118
|
+
"Si dice que el servicio web no está disponible, lo tiene que habilitar el admin del aula.", data.errorcode);
|
|
119
|
+
}
|
|
52
120
|
async function submitSsoForm(jar, html, { username, password }) {
|
|
53
|
-
// La página de Keycloak trae su config como JSON (kcContext); de ahí sale la URL del form.
|
|
54
121
|
const action = html.match(/"loginAction"\s*:\s*("(?:[^"\\]|\\.)*")/)?.[1];
|
|
55
122
|
if (!action)
|
|
56
123
|
throw new Error("No se encontró el formulario del SSO; puede que haya cambiado.");
|
|
57
124
|
const body = new URLSearchParams({ username, password, rememberMe: "on", credentialId: "" });
|
|
58
125
|
return follow(jar, JSON.parse(action), { method: "POST", body });
|
|
59
126
|
}
|
|
127
|
+
/** Login directo de Moodle (sin SSO): manda usuario y contraseña al formulario de /login/index.php. */
|
|
128
|
+
async function submitMoodleLoginForm(jar, pageUrl, html, { username, password }) {
|
|
129
|
+
const tokenMatch = html.match(/name="logintoken"\s+value="([^"]*)"/) ||
|
|
130
|
+
html.match(/value="([^"]*)"\s+name="logintoken"/);
|
|
131
|
+
const logintoken = tokenMatch?.[1] ?? "";
|
|
132
|
+
const actionMatch = html.match(/<form[^>]*action="([^"]*)"[^>]*>/i);
|
|
133
|
+
let action = actionMatch?.[1] ? decodeHtml(actionMatch[1]) : "";
|
|
134
|
+
if (!action)
|
|
135
|
+
action = pageUrl;
|
|
136
|
+
if (!action.startsWith("http"))
|
|
137
|
+
action = new URL(action, pageUrl).toString();
|
|
138
|
+
const body = new URLSearchParams({ username, password, logintoken, anchor: "" });
|
|
139
|
+
return follow(jar, action, { method: "POST", body });
|
|
140
|
+
}
|
|
60
141
|
/** Hace la request y sigue redirecciones a mano, guardando cookies en cada salto. */
|
|
61
142
|
async function follow(jar, url, init) {
|
|
62
143
|
for (let i = 0; i < MAX_REDIRECTS; i++) {
|
|
@@ -92,6 +173,24 @@ export function decodeLaunchToken(url) {
|
|
|
92
173
|
return parts[1];
|
|
93
174
|
}
|
|
94
175
|
const decodeHtml = (s) => s.replaceAll("&", "&");
|
|
176
|
+
/** Extrae el mensaje de error que muestra Moodle/Keycloak en la página de login. */
|
|
177
|
+
function extractLoginError(html) {
|
|
178
|
+
const patterns = [
|
|
179
|
+
/alert-danger[^>]*>([\s\S]*?)<\/div>/i,
|
|
180
|
+
/alert-error[^>]*>([\s\S]*?)<\/div>/i,
|
|
181
|
+
/class="[^"]*error[^"]*"[^>]*>([\s\S]*?)<\/div>/i,
|
|
182
|
+
/<div[^>]*id="loginerrormessage"[^>]*>([\s\S]*?)<\/div>/i,
|
|
183
|
+
];
|
|
184
|
+
for (const re of patterns) {
|
|
185
|
+
const m = html.match(re);
|
|
186
|
+
if (m?.[1]) {
|
|
187
|
+
const text = m[1].replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
|
|
188
|
+
if (text)
|
|
189
|
+
return text;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return undefined;
|
|
193
|
+
}
|
|
95
194
|
const unexpected = (url) => {
|
|
96
195
|
const { host, pathname } = new URL(url);
|
|
97
196
|
return new Error(`Flujo de login inesperado: terminó en ${host}${pathname}`);
|
|
@@ -1,24 +1,34 @@
|
|
|
1
|
-
import { CookieJar } from "tough-cookie";
|
|
2
1
|
import { loadCredentials } from "./credentials.js";
|
|
2
|
+
import { defaultRegional, findRegional } from "./regionals.js";
|
|
3
3
|
import { fetchToken, LoginRequiredError } from "./sso-login.js";
|
|
4
4
|
/** Con qué nombre guarda el aula su sesión en el store. */
|
|
5
5
|
export const AULA_PLATFORM = "aula";
|
|
6
6
|
/**
|
|
7
|
-
* Sesión del modo HTTP: la de un usuario del store
|
|
8
|
-
*
|
|
9
|
-
*
|
|
7
|
+
* Sesión del modo HTTP: la de un usuario del store, en el aula de su regional. Renueva el token con la
|
|
8
|
+
* contraseña del keychain cuando es de este mismo usuario: eso pasa solo en local, porque el servidor
|
|
9
|
+
* hosteado no guarda contraseñas. Si no, el usuario vuelve a iniciar sesión.
|
|
10
10
|
*/
|
|
11
11
|
export class StoredAulaSession {
|
|
12
12
|
store;
|
|
13
13
|
userId;
|
|
14
14
|
loginUrl;
|
|
15
15
|
refreshing = null;
|
|
16
|
+
regional = null;
|
|
16
17
|
/** `loginUrl` es la página donde el usuario vuelve a iniciar sesión, si el servidor tiene una. */
|
|
17
18
|
constructor(store, userId, loginUrl) {
|
|
18
19
|
this.store = store;
|
|
19
20
|
this.userId = userId;
|
|
20
21
|
this.loginUrl = loginUrl;
|
|
21
22
|
}
|
|
23
|
+
/** La del usuario en el store; no cambia mientras dura la sesión, así que se lee una sola vez. */
|
|
24
|
+
getRegional() {
|
|
25
|
+
this.regional ??= this.store.findUser(this.userId).then((user) => {
|
|
26
|
+
if (!user)
|
|
27
|
+
throw this.loginRequired("No hay usuario para esta sesión.");
|
|
28
|
+
return findRegional(user.regional);
|
|
29
|
+
});
|
|
30
|
+
return this.regional;
|
|
31
|
+
}
|
|
22
32
|
async getToken() {
|
|
23
33
|
return (await this.read()).token;
|
|
24
34
|
}
|
|
@@ -29,23 +39,17 @@ export class StoredAulaSession {
|
|
|
29
39
|
return this.refreshing;
|
|
30
40
|
}
|
|
31
41
|
async renew() {
|
|
32
|
-
const stored = await this.read();
|
|
33
|
-
|
|
34
|
-
//
|
|
35
|
-
// entraríamos al aula de ella y guardaríamos su token en la sesión de este.
|
|
42
|
+
const [stored, regional] = await Promise.all([this.read(), this.getRegional()]);
|
|
43
|
+
// Las del keychain (o UTN_USER/UTN_PASS) son de una sola persona: con otro usuario (o el mismo
|
|
44
|
+
// nombre en otra regional) entraríamos al aula de ella y guardaríamos su token en la sesión de este.
|
|
36
45
|
const credentials = await loadCredentials();
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
throw this.loginRequired("La sesión del aula venció.");
|
|
45
|
-
}
|
|
46
|
-
throw e;
|
|
47
|
-
}
|
|
48
|
-
const data = { ...stored, token, cookies: await jar.serialize() };
|
|
46
|
+
const sameUser = credentials?.username.trim().toLowerCase() === stored.username &&
|
|
47
|
+
(credentials.regional ?? defaultRegional().id) === regional.id;
|
|
48
|
+
if (!credentials || !sameUser)
|
|
49
|
+
throw this.loginRequired("La sesión del aula venció.");
|
|
50
|
+
const token = await fetchToken(regional, credentials);
|
|
51
|
+
// Sin `...stored`: las sesiones guardadas antes traen cookies, que ya no se usan.
|
|
52
|
+
const data = { username: stored.username, token };
|
|
49
53
|
await this.store.saveSession(this.userId, AULA_PLATFORM, JSON.stringify(data));
|
|
50
54
|
return token;
|
|
51
55
|
}
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { textToHtml } from "../../shared/html.js";
|
|
2
2
|
import { addDiscussion, addDiscussionPost, createUserEvent, deleteChoiceResponses, deleteChoicegroupResponses, deletePost, getChoiceOptions, getChoicegroupOptions, getDiscussionPosts, getMemberName, sendInstantMessage, submitChoiceResponse, submitChoicegroupResponse, updateDiscussionPost, } from "../client/client.js";
|
|
3
|
-
import { AULA_BASE_URL } from "../client/constants.js";
|
|
4
3
|
import { toOption } from "./activities.js";
|
|
5
4
|
import { discussionAddr, formatAddress, parseAddress } from "./address.js";
|
|
6
5
|
import { isoDate } from "./fields.js";
|
|
@@ -154,6 +153,6 @@ export async function addCalendarEvent(session, event) {
|
|
|
154
153
|
inicio: isoDate(timestart),
|
|
155
154
|
fin: timeduration ? isoDate(timestart + timeduration) : undefined,
|
|
156
155
|
// Para que el usuario lo vea en el calendario del aula.
|
|
157
|
-
url: `${
|
|
156
|
+
url: `${(await session.getRegional()).baseUrl}/calendar/view.php?view=day&time=${timestart}`,
|
|
158
157
|
};
|
|
159
158
|
}
|
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
import { openDocument, UnsupportedFormat } from "../../shared/documents/index.js";
|
|
2
|
-
import { AULA_BASE_URL } from "../client/constants.js";
|
|
3
2
|
import { fetchFile } from "../client/moodle-api.js";
|
|
4
|
-
const ORIGIN = new URL(AULA_BASE_URL).origin;
|
|
5
3
|
const WS_PATH = "/webservice/pluginfile.php/";
|
|
6
4
|
const WEB_PATH = "/pluginfile.php/";
|
|
7
5
|
/** Cuánto se devuelve por llamada, en caracteres de texto. */
|
|
@@ -13,16 +11,28 @@ const MAX_BYTES = 30 * 1024 * 1024;
|
|
|
13
11
|
/**
|
|
14
12
|
* Moodle devuelve las URLs del web service, que piden token. Al usuario le sirve la misma ruta sin
|
|
15
13
|
* `/webservice`, que se abre con la sesión de su navegador. Es la única que mostramos: el token no
|
|
16
|
-
* sale nunca del servidor.
|
|
14
|
+
* sale nunca del servidor. `fileurl` viene de Moodle: ya es del aula del usuario.
|
|
17
15
|
*/
|
|
18
16
|
export function userFacingUrl(fileurl) {
|
|
19
|
-
|
|
17
|
+
let u;
|
|
18
|
+
try {
|
|
19
|
+
u = new URL(fileurl);
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return fileurl;
|
|
23
|
+
}
|
|
24
|
+
// Hay aulas en una subruta (`/moodle`): el tramo a cambiar puede no estar al principio.
|
|
25
|
+
const at = u.pathname.indexOf(WS_PATH);
|
|
26
|
+
if (at < 0)
|
|
27
|
+
return fileurl;
|
|
28
|
+
u.pathname = u.pathname.slice(0, at) + WEB_PATH + u.pathname.slice(at + WS_PATH.length);
|
|
29
|
+
return u.href;
|
|
20
30
|
}
|
|
21
31
|
/**
|
|
22
|
-
* Acepta cualquiera de las dos formas y devuelve la del web service. Solo archivos del aula
|
|
23
|
-
* cualquier otra URL estaríamos mandando
|
|
32
|
+
* Acepta cualquiera de las dos formas y devuelve la del web service. Solo archivos del aula del
|
|
33
|
+
* usuario (`baseUrl`): con cualquier otra URL estaríamos mandando su token a donde diga el modelo.
|
|
24
34
|
*/
|
|
25
|
-
function downloadUrl(url) {
|
|
35
|
+
function downloadUrl(baseUrl, url) {
|
|
26
36
|
let u;
|
|
27
37
|
try {
|
|
28
38
|
u = new URL(url);
|
|
@@ -30,10 +40,18 @@ function downloadUrl(url) {
|
|
|
30
40
|
catch {
|
|
31
41
|
throw new Error(`URL inválida: "${url}". Usá la \`url\` de un archivo tal como la devuelve aula_open.`);
|
|
32
42
|
}
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
43
|
+
const root = new URL(baseUrl);
|
|
44
|
+
const prefix = root.pathname.replace(/\/+$/, "");
|
|
45
|
+
const rest = u.pathname.startsWith(prefix + WS_PATH)
|
|
46
|
+
? u.pathname.slice(prefix.length + WS_PATH.length)
|
|
47
|
+
: u.pathname.startsWith(prefix + WEB_PATH)
|
|
48
|
+
? u.pathname.slice(prefix.length + WEB_PATH.length)
|
|
49
|
+
: null;
|
|
50
|
+
// `origin` ignora `usuario@`: con eso `fetch` rechaza la URL y su error, con el token ya puesto, llega al modelo.
|
|
51
|
+
if (u.origin !== root.origin || u.username || u.password || rest === null) {
|
|
52
|
+
throw new Error(`Solo se pueden leer archivos del aula virtual (${root.origin}${prefix}${WEB_PATH}...).`);
|
|
36
53
|
}
|
|
54
|
+
const path = prefix + WS_PATH + rest;
|
|
37
55
|
u.pathname = path;
|
|
38
56
|
u.searchParams.delete("token");
|
|
39
57
|
u.searchParams.delete("wstoken");
|
|
@@ -41,7 +59,8 @@ function downloadUrl(url) {
|
|
|
41
59
|
}
|
|
42
60
|
/** Baja un archivo del aula y devuelve desde la parte `from` tantas como entren en el presupuesto. */
|
|
43
61
|
export async function readAulaFile(session, url, from = 1) {
|
|
44
|
-
const
|
|
62
|
+
const { baseUrl } = await session.getRegional();
|
|
63
|
+
const download = downloadUrl(baseUrl, url);
|
|
45
64
|
const fileName = decodeURIComponent(download.pathname.split("/").pop() ?? "");
|
|
46
65
|
const forUser = userFacingUrl(download.href);
|
|
47
66
|
const { bytes } = await fetchFile(session, download, MAX_BYTES);
|
|
@@ -1,31 +1,33 @@
|
|
|
1
|
-
import { ask, askHidden } from "../shared/input.js";
|
|
1
|
+
import { ask, askChoice, askHidden } from "../shared/input.js";
|
|
2
2
|
import { logger } from "../shared/logger.js";
|
|
3
3
|
import { SqliteStore } from "../shared/store/sqlite-store.js";
|
|
4
4
|
import { saveCredentials } from "./client/credentials.js";
|
|
5
5
|
import { linkAulaAccount } from "./client/link.js";
|
|
6
6
|
import { LocalSession } from "./client/local-session.js";
|
|
7
|
+
import { availableRegionals, defaultRegional } from "./client/regionals.js";
|
|
7
8
|
/**
|
|
8
|
-
* `utn-mcp login`: pide usuario y contraseña una vez, los guarda en el keychain
|
|
9
|
+
* `utn-mcp login`: pide la regional, usuario y contraseña una vez, los guarda en el keychain
|
|
9
10
|
* y obtiene el primer token. Después el servidor renueva solo.
|
|
10
11
|
* Con `--http` guarda la sesión en el store y emite el token para el cliente MCP.
|
|
11
12
|
*/
|
|
12
13
|
export async function runLogin(http) {
|
|
13
|
-
const
|
|
14
|
+
const regional = await askRegional();
|
|
15
|
+
const username = (await ask("Usuario de la UTN: ")).trim();
|
|
14
16
|
if (!username)
|
|
15
17
|
throw new Error("Falta el usuario.");
|
|
16
18
|
const password = await askHidden("Contraseña: ");
|
|
17
|
-
const credentials = { username, password };
|
|
19
|
+
const credentials = { username, password, regional: regional.id };
|
|
18
20
|
let accessToken = null;
|
|
19
21
|
if (http)
|
|
20
|
-
accessToken = await loginToStore(credentials);
|
|
22
|
+
accessToken = await loginToStore(regional, credentials);
|
|
21
23
|
else
|
|
22
|
-
await new LocalSession().login(credentials);
|
|
23
|
-
// Se guardan recién después de comprobar que el
|
|
24
|
+
await new LocalSession().login(regional, credentials);
|
|
25
|
+
// Se guardan recién después de comprobar que el aula las aceptó.
|
|
24
26
|
if (await saveCredentials(credentials)) {
|
|
25
27
|
logger.info("Credenciales guardadas en el keychain del sistema.");
|
|
26
28
|
}
|
|
27
29
|
else {
|
|
28
|
-
logger.warn("No hay keychain disponible: para renovar sin intervención definí UTN_USER y
|
|
30
|
+
logger.warn("No hay keychain disponible: para renovar sin intervención definí UTN_USER, UTN_PASS y UTN_REGIONAL.");
|
|
29
31
|
}
|
|
30
32
|
if (accessToken) {
|
|
31
33
|
logger.info("Listo. Configurá tu cliente MCP con este header; no se vuelve a mostrar:\n\n" +
|
|
@@ -35,9 +37,21 @@ export async function runLogin(http) {
|
|
|
35
37
|
logger.info("Listo: ya podés usar el servidor MCP.");
|
|
36
38
|
}
|
|
37
39
|
}
|
|
40
|
+
const SUPPORT_LABEL = {
|
|
41
|
+
verified: "",
|
|
42
|
+
candidate: " · sin probar todavía",
|
|
43
|
+
unsupported: " · todavía sin soporte",
|
|
44
|
+
};
|
|
45
|
+
/** Arranca marcada la de UTN_REGIONAL (o FRBA). */
|
|
46
|
+
async function askRegional() {
|
|
47
|
+
const regionals = availableRegionals();
|
|
48
|
+
const initial = regionals.findIndex((r) => r.id === defaultRegional().id);
|
|
49
|
+
const labels = regionals.map((r) => r.name + SUPPORT_LABEL[r.support]);
|
|
50
|
+
return regionals[await askChoice("Regional:", labels, Math.max(initial, 0))];
|
|
51
|
+
}
|
|
38
52
|
/** Crea el usuario si no existe, guarda su sesión del aula y le emite un token que no vence. */
|
|
39
|
-
async function loginToStore(credentials) {
|
|
53
|
+
async function loginToStore(regional, credentials) {
|
|
40
54
|
const store = await SqliteStore.open();
|
|
41
|
-
const { userId } = await linkAulaAccount(store, credentials);
|
|
55
|
+
const { userId } = await linkAulaAccount(store, regional, credentials);
|
|
42
56
|
return store.issueToken(userId, { kind: "access", clientId: "local" });
|
|
43
57
|
}
|
|
@@ -17,12 +17,16 @@ const failure = (e) => ({
|
|
|
17
17
|
const READ_ONLY = { readOnlyHint: true, openWorldHint: false };
|
|
18
18
|
export function registerAulaVirtualTools(server, session, files) {
|
|
19
19
|
server.registerTool("aula_get_site_info", {
|
|
20
|
-
description: "Datos del usuario logueado
|
|
20
|
+
description: "Datos del usuario logueado y del sitio Moodle de su aula virtual. `regional` es la regional de la UTN " +
|
|
21
|
+
"de esa aula (por ejemplo, \"Tucumán (FRT)\"): usala para hablarle de su facultad.",
|
|
21
22
|
annotations: READ_ONLY,
|
|
22
23
|
}, async () => {
|
|
23
24
|
try {
|
|
24
|
-
const { sitename, fullname, username, userid, release } = await
|
|
25
|
-
|
|
25
|
+
const [{ sitename, fullname, username, userid, release }, regional] = await Promise.all([
|
|
26
|
+
getSiteInfo(session),
|
|
27
|
+
session.getRegional(),
|
|
28
|
+
]);
|
|
29
|
+
return json({ regional: regional.name, sitename, fullname, username, userid, release });
|
|
26
30
|
}
|
|
27
31
|
catch (e) {
|
|
28
32
|
return failure(e);
|
|
@@ -271,7 +275,8 @@ export function registerAulaVirtualTools(server, session, files) {
|
|
|
271
275
|
'`-F "archivos=@..."` por archivo, todos en el mismo comando. Responde JSON con lo recibido.\n' +
|
|
272
276
|
"- Si no, pasale el `url` al usuario: se abre en el navegador, también en el celu, y elige " +
|
|
273
277
|
"los archivos ahí. Esperá a que te diga que los subió.\n\n" +
|
|
274
|
-
"Subir al link no entrega nada
|
|
278
|
+
"Subir al link no entrega nada. Cada link acepta una sola subida: si hay que cambiar los " +
|
|
279
|
+
"archivos, pedí otro link. " +
|
|
275
280
|
"`acepta` trae los límites de la tarea.",
|
|
276
281
|
inputSchema: z.object({
|
|
277
282
|
direccion: z
|
|
@@ -299,7 +304,8 @@ export function registerAulaVirtualTools(server, session, files) {
|
|
|
299
304
|
'Ej: ["C:\\Users\\ana\\Documents\\TP3.pdf"].'
|
|
300
305
|
: "Lista final de los archivos que ya están en la entrega, por su `url` de aula_open; los " +
|
|
301
306
|
"de `subida` se suman. Lista vacía: quita los que estaban. " +
|
|
302
|
-
'Ej: ["https
|
|
307
|
+
'Ej: ["https://<aula>/pluginfile.php/123/assignsubmission_file/submission_files/456/TP3.pdf"], ' +
|
|
308
|
+
"con el dominio del aula tal como viene en la `url`."),
|
|
303
309
|
texto: z
|
|
304
310
|
.string()
|
|
305
311
|
.optional()
|