utn-mcp 0.6.7 → 0.7.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 +180 -146
- package/build/aula-virtual/client/client.js +55 -0
- package/build/aula-virtual/client/link.js +27 -0
- package/build/aula-virtual/client/moodle-api.js +34 -0
- package/build/aula-virtual/client/session.js +0 -1
- package/build/aula-virtual/client/stored-session.js +65 -0
- package/build/aula-virtual/format/activities.js +1 -0
- package/build/aula-virtual/format/submission.js +214 -0
- package/build/aula-virtual/login.js +23 -4
- package/build/aula-virtual/tools.js +101 -1
- package/build/http.js +139 -8
- package/build/index.js +45 -9
- package/build/oauth/access.js +52 -0
- package/build/oauth/admin.js +48 -0
- package/build/oauth/expiring.js +57 -0
- package/build/oauth/server.js +272 -0
- package/build/server.js +7 -4
- package/build/shared/pages.js +61 -0
- package/build/shared/paths.js +4 -0
- package/build/shared/store/session-store.js +1 -0
- package/build/shared/store/sqlite-store.js +201 -0
- package/build/shared/uploads.js +80 -0
- package/package.json +1 -1
|
@@ -0,0 +1,201 @@
|
|
|
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
|
+
email TEXT,
|
|
10
|
+
created_at INTEGER NOT NULL
|
|
11
|
+
);
|
|
12
|
+
CREATE TABLE IF NOT EXISTS tokens (
|
|
13
|
+
hash TEXT PRIMARY KEY,
|
|
14
|
+
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
15
|
+
kind TEXT NOT NULL CHECK (kind IN ('access', 'refresh')),
|
|
16
|
+
client_id TEXT,
|
|
17
|
+
expires_at INTEGER,
|
|
18
|
+
created_at INTEGER NOT NULL
|
|
19
|
+
);
|
|
20
|
+
CREATE INDEX IF NOT EXISTS tokens_user ON tokens(user_id);
|
|
21
|
+
CREATE TABLE IF NOT EXISTS platform_sessions (
|
|
22
|
+
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
23
|
+
platform TEXT NOT NULL,
|
|
24
|
+
data BLOB NOT NULL,
|
|
25
|
+
updated_at INTEGER NOT NULL,
|
|
26
|
+
PRIMARY KEY (user_id, platform)
|
|
27
|
+
);
|
|
28
|
+
CREATE TABLE IF NOT EXISTS oauth_clients (
|
|
29
|
+
client_id TEXT PRIMARY KEY,
|
|
30
|
+
client_name TEXT,
|
|
31
|
+
redirect_uris TEXT NOT NULL,
|
|
32
|
+
created_at INTEGER NOT NULL
|
|
33
|
+
);
|
|
34
|
+
`;
|
|
35
|
+
/** Bytes de la clave AES-256. */
|
|
36
|
+
const KEY_BYTES = 32;
|
|
37
|
+
/** Bytes del IV de AES-GCM. */
|
|
38
|
+
const IV_BYTES = 12;
|
|
39
|
+
/** Bytes del tag de autenticación de AES-GCM. */
|
|
40
|
+
const TAG_BYTES = 16;
|
|
41
|
+
const now = () => Math.floor(Date.now() / 1000);
|
|
42
|
+
const hashToken = (token) => createHash("sha256").update(token).digest("hex");
|
|
43
|
+
/**
|
|
44
|
+
* El SessionStore sobre SQLite. Las sesiones van cifradas con AES-256-GCM: tienen el token de Moodle
|
|
45
|
+
* y las cookies del SSO, que alcanzan para entrar al aula como el usuario. Los tokens se guardan
|
|
46
|
+
* hasheados, así que una copia de la base no sirve para usar el servidor.
|
|
47
|
+
*/
|
|
48
|
+
export class SqliteStore {
|
|
49
|
+
db;
|
|
50
|
+
key;
|
|
51
|
+
constructor(db, key) {
|
|
52
|
+
this.db = db;
|
|
53
|
+
this.key = key;
|
|
54
|
+
}
|
|
55
|
+
/** `node:sqlite` llega con Node 22.13; se importa acá para que el modo STDIO siga andando en Node 20. */
|
|
56
|
+
static async open(path = storeFile()) {
|
|
57
|
+
let sqlite;
|
|
58
|
+
try {
|
|
59
|
+
sqlite = await import("node:sqlite");
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
throw new Error(`El modo HTTP necesita Node 22.13 o más nuevo; tenés ${process.version}.`);
|
|
63
|
+
}
|
|
64
|
+
await mkdir(dirname(path), { recursive: true });
|
|
65
|
+
const db = new sqlite.DatabaseSync(path);
|
|
66
|
+
// WAL: el login escribe mientras el servidor lee, desde otro proceso.
|
|
67
|
+
db.exec("PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON; PRAGMA busy_timeout = 5000;");
|
|
68
|
+
db.exec(SCHEMA);
|
|
69
|
+
migrate(db);
|
|
70
|
+
return new SqliteStore(db, await loadKey());
|
|
71
|
+
}
|
|
72
|
+
async findOrCreateUser(utnUsername, email) {
|
|
73
|
+
const username = utnUsername.trim().toLowerCase();
|
|
74
|
+
this.db
|
|
75
|
+
.prepare("INSERT INTO users (utn_username, created_at) VALUES (?, ?) ON CONFLICT DO NOTHING")
|
|
76
|
+
.run(username, now());
|
|
77
|
+
if (email) {
|
|
78
|
+
this.db.prepare("UPDATE users SET email = ? WHERE utn_username = ?").run(email.trim().toLowerCase(), username);
|
|
79
|
+
}
|
|
80
|
+
const row = this.db.prepare("SELECT id FROM users WHERE utn_username = ?").get(username);
|
|
81
|
+
return row.id;
|
|
82
|
+
}
|
|
83
|
+
async findUserByEmail(email) {
|
|
84
|
+
const row = this.db.prepare("SELECT id, utn_username, email FROM users WHERE email = ?").get(email.trim().toLowerCase());
|
|
85
|
+
return row ? { id: row.id, utnUsername: row.utn_username, email: row.email } : null;
|
|
86
|
+
}
|
|
87
|
+
async findUser(userId) {
|
|
88
|
+
const row = this.db.prepare("SELECT id, utn_username, email FROM users WHERE id = ?").get(userId);
|
|
89
|
+
return row ? { id: row.id, utnUsername: row.utn_username, email: row.email } : null;
|
|
90
|
+
}
|
|
91
|
+
async issueToken(userId, options) {
|
|
92
|
+
const token = randomBytes(32).toString("base64url");
|
|
93
|
+
const createdAt = now();
|
|
94
|
+
this.db
|
|
95
|
+
.prepare("INSERT INTO tokens (hash, user_id, kind, client_id, expires_at, created_at) VALUES (?, ?, ?, ?, ?, ?)")
|
|
96
|
+
.run(hashToken(token), userId, options.kind, options.clientId ?? null, options.ttlSeconds ? createdAt + options.ttlSeconds : null, createdAt);
|
|
97
|
+
return token;
|
|
98
|
+
}
|
|
99
|
+
async findToken(token, kind) {
|
|
100
|
+
const row = this.db
|
|
101
|
+
.prepare("SELECT user_id, client_id, expires_at FROM tokens " +
|
|
102
|
+
"WHERE hash = ? AND kind = ? AND (expires_at IS NULL OR expires_at > ?)")
|
|
103
|
+
.get(hashToken(token), kind, now());
|
|
104
|
+
return row ? { userId: row.user_id, clientId: row.client_id, expiresAt: row.expires_at } : null;
|
|
105
|
+
}
|
|
106
|
+
async revokeToken(token) {
|
|
107
|
+
this.db.prepare("DELETE FROM tokens WHERE hash = ?").run(hashToken(token));
|
|
108
|
+
}
|
|
109
|
+
async loadSession(userId, platform) {
|
|
110
|
+
const row = this.db
|
|
111
|
+
.prepare("SELECT data FROM platform_sessions WHERE user_id = ? AND platform = ?")
|
|
112
|
+
.get(userId, platform);
|
|
113
|
+
return row ? this.decrypt(row.data) : null;
|
|
114
|
+
}
|
|
115
|
+
async saveSession(userId, platform, data) {
|
|
116
|
+
this.db
|
|
117
|
+
.prepare("INSERT INTO platform_sessions (user_id, platform, data, updated_at) VALUES (?, ?, ?, ?) " +
|
|
118
|
+
"ON CONFLICT (user_id, platform) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at")
|
|
119
|
+
.run(userId, platform, this.encrypt(data), now());
|
|
120
|
+
}
|
|
121
|
+
async saveClient(client) {
|
|
122
|
+
this.db
|
|
123
|
+
.prepare("INSERT INTO oauth_clients (client_id, client_name, redirect_uris, created_at) VALUES (?, ?, ?, ?)")
|
|
124
|
+
.run(client.clientId, client.clientName, JSON.stringify(client.redirectUris), now());
|
|
125
|
+
}
|
|
126
|
+
async findClient(clientId) {
|
|
127
|
+
const row = this.db
|
|
128
|
+
.prepare("SELECT client_id, client_name, redirect_uris FROM oauth_clients WHERE client_id = ?")
|
|
129
|
+
.get(clientId);
|
|
130
|
+
if (!row)
|
|
131
|
+
return null;
|
|
132
|
+
return { clientId: row.client_id, clientName: row.client_name, redirectUris: JSON.parse(row.redirect_uris) };
|
|
133
|
+
}
|
|
134
|
+
async sessionUpdatedAt(userId, platform) {
|
|
135
|
+
const row = this.db
|
|
136
|
+
.prepare("SELECT updated_at FROM platform_sessions WHERE user_id = ? AND platform = ?")
|
|
137
|
+
.get(userId, platform);
|
|
138
|
+
return row?.updated_at ?? null;
|
|
139
|
+
}
|
|
140
|
+
async listConnectedClients(userId) {
|
|
141
|
+
const rows = this.db
|
|
142
|
+
.prepare("SELECT t.client_id, c.client_name, MAX(t.created_at) AS last_token_at FROM tokens t " +
|
|
143
|
+
"LEFT JOIN oauth_clients c ON c.client_id = t.client_id " +
|
|
144
|
+
"WHERE t.user_id = ? AND t.client_id IS NOT NULL AND (t.expires_at IS NULL OR t.expires_at > ?) " +
|
|
145
|
+
"GROUP BY t.client_id ORDER BY last_token_at DESC")
|
|
146
|
+
.all(userId, now());
|
|
147
|
+
return rows.map((r) => ({ clientId: r.client_id, clientName: r.client_name, lastTokenAt: r.last_token_at }));
|
|
148
|
+
}
|
|
149
|
+
async revokeClient(userId, clientId) {
|
|
150
|
+
this.db.prepare("DELETE FROM tokens WHERE user_id = ? AND client_id = ?").run(userId, clientId);
|
|
151
|
+
}
|
|
152
|
+
/** iv + tag + texto cifrado, en un solo blob. */
|
|
153
|
+
encrypt(plain) {
|
|
154
|
+
const iv = randomBytes(IV_BYTES);
|
|
155
|
+
const cipher = createCipheriv("aes-256-gcm", this.key, iv);
|
|
156
|
+
const body = Buffer.concat([cipher.update(plain, "utf8"), cipher.final()]);
|
|
157
|
+
return Buffer.concat([iv, cipher.getAuthTag(), body]);
|
|
158
|
+
}
|
|
159
|
+
decrypt(blob) {
|
|
160
|
+
const buf = Buffer.from(blob);
|
|
161
|
+
const decipher = createDecipheriv("aes-256-gcm", this.key, buf.subarray(0, IV_BYTES));
|
|
162
|
+
decipher.setAuthTag(buf.subarray(IV_BYTES, IV_BYTES + TAG_BYTES));
|
|
163
|
+
try {
|
|
164
|
+
return Buffer.concat([decipher.update(buf.subarray(IV_BYTES + TAG_BYTES)), decipher.final()]).toString("utf8");
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
throw new Error("No se pudo descifrar la sesión: la clave (UTN_MCP_KEY o store.key) no es la que la cifró.");
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
/** Lo que `CREATE TABLE IF NOT EXISTS` no agrega a una base que ya existía. */
|
|
172
|
+
function migrate(db) {
|
|
173
|
+
const columns = db.prepare("PRAGMA table_info(users)").all();
|
|
174
|
+
if (!columns.some((c) => c.name === "email"))
|
|
175
|
+
db.exec("ALTER TABLE users ADD COLUMN email TEXT");
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* UTN_MCP_KEY (32 bytes en base64) en el servidor hosteado, donde la clave no tiene que estar en el
|
|
179
|
+
* mismo disco que la base. En local, si no está, se genera una en la carpeta de configuración.
|
|
180
|
+
*/
|
|
181
|
+
async function loadKey() {
|
|
182
|
+
const fromEnv = process.env.UTN_MCP_KEY;
|
|
183
|
+
if (fromEnv) {
|
|
184
|
+
const key = Buffer.from(fromEnv, "base64");
|
|
185
|
+
if (key.length !== KEY_BYTES)
|
|
186
|
+
throw new Error(`UTN_MCP_KEY tiene que ser de ${KEY_BYTES} bytes en base64.`);
|
|
187
|
+
return key;
|
|
188
|
+
}
|
|
189
|
+
const file = storeKeyFile();
|
|
190
|
+
try {
|
|
191
|
+
return Buffer.from((await readFile(file, "utf8")).trim(), "base64");
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
const key = randomBytes(KEY_BYTES);
|
|
195
|
+
await mkdir(dirname(file), { recursive: true });
|
|
196
|
+
// `wx`: si otro proceso la creó recién, no la pisamos.
|
|
197
|
+
await writeFile(file, key.toString("base64"), { mode: 0o600, flag: "wx" }).catch(() => { });
|
|
198
|
+
await chmod(file, 0o600).catch(() => { }); // no-op en Windows
|
|
199
|
+
return Buffer.from((await readFile(file, "utf8")).trim(), "base64");
|
|
200
|
+
}
|
|
201
|
+
}
|
|
@@ -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