openlearn-next 0.7.10 → 0.7.12
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 +9 -2
- package/dist/assets/{ActivityWorkspaceWidget-BU88GGUG.js → ActivityWorkspaceWidget-TtBuNORs.js} +1 -1
- package/dist/assets/AdminDirectoryView-D03WOMc-.js +5 -0
- package/dist/assets/{AssessmentCenter-BoSWbJMt.js → AssessmentCenter-MUvoJySb.js} +1 -1
- package/dist/assets/{AssignmentFilePreview-D_POvLmv.js → AssignmentFilePreview-dVhnTYmq.js} +1 -1
- package/dist/assets/{BatchPickerModal-DiHd_6Tg.js → BatchPickerModal-QALdhrX6.js} +1 -1
- package/dist/assets/{ClassesView-CWKqzWH0.js → ClassesView-CKwlRz-A.js} +5 -5
- package/dist/assets/{ClassroomCountdownWidget-B5-KUVU8.js → ClassroomCountdownWidget-DyWyForY.js} +1 -1
- package/dist/assets/{CloudDriveModal-CjcvdDiw.js → CloudDriveModal-C8UAn3MO.js} +1 -1
- package/dist/assets/{ComputerLabView-CNTcmWAh.js → ComputerLabView-E5b3XTkk.js} +1 -1
- package/dist/assets/{CourseManagement-ha_iSnLU.js → CourseManagement-3jSlq7lV.js} +1 -1
- package/dist/assets/{CourseWizardModal-oVT9mYSG.js → CourseWizardModal-BAkW5VJX.js} +1 -1
- package/dist/assets/{CoursewareHubPanel-BuKp0mmW.js → CoursewareHubPanel-BkjcusjZ.js} +1 -1
- package/dist/assets/{Dashboard-Vb9coh9_.js → Dashboard-Ha2fZAqD.js} +2 -2
- package/dist/assets/{ExportWeightModal-BEesz6rd.js → ExportWeightModal-D2Y13384.js} +1 -1
- package/dist/assets/{HelpTour-D3QcGDUz.js → HelpTour-DBq2otcV.js} +1 -1
- package/dist/assets/{HelpView--Um9DFYx.js → HelpView-BVLRnMxl.js} +4 -4
- package/dist/assets/{ImportLessonsModal-DXGopj4j.js → ImportLessonsModal-CQlq4b81.js} +1 -1
- package/dist/assets/ImportModal-Ct0I5cAL.js +3 -0
- package/dist/assets/{InteractiveCoursewareViewer-DzgF9_LU.js → InteractiveCoursewareViewer-DGKh1gO8.js} +1 -1
- package/dist/assets/InteractiveWhiteboard-BbZlYpRu.js +65 -0
- package/dist/assets/{LazyCourseware-6yb_QiYS.js → LazyCourseware-zONqinCM.js} +2 -2
- package/dist/assets/{LazyWhiteboard-CsF9GB6J.js → LazyWhiteboard-BV98LEZ8.js} +2 -2
- package/dist/assets/{LessonEditorView-CZTZ7Q0B.js → LessonEditorView-LXRkKxdY.js} +1 -1
- package/dist/assets/LiveClassroomView-TaHtHWkY.js +63 -0
- package/dist/assets/{NotificationDetailModal-mUeIQP4R.js → NotificationDetailModal-BF_-pn0c.js} +1 -1
- package/dist/assets/{PluginView-CT3-zS2n.js → PluginView-sVMPXae9.js} +1 -1
- package/dist/assets/{ProcessLogsModal-DmsOrZnV.js → ProcessLogsModal-D97xLgnL.js} +1 -1
- package/dist/assets/{QuizGeneratorModal-ViKxNX5Q.js → QuizGeneratorModal-CNY8ISjR.js} +1 -1
- package/dist/assets/{StudentAssignmentEvalPanel-BRxHMABY.js → StudentAssignmentEvalPanel-CUmM5DbT.js} +1 -1
- package/dist/assets/{StudentAssignmentView-g-ewJIS3.js → StudentAssignmentView-B-1MtwKj.js} +1 -1
- package/dist/assets/{StudentLessonView-Dbvr3Xlo.js → StudentLessonView-CqrcvVND.js} +1 -1
- package/dist/assets/{StudentView-20vfEk4v.js → StudentView-Bi9kgSrN.js} +2 -2
- package/dist/assets/{SystemResourceLibraryModal-CDKeaKf8.js → SystemResourceLibraryModal-DDALKrJC.js} +1 -1
- package/dist/assets/{TeacherView-KIKuhXGF.js → TeacherView-C5JmoKHk.js} +2 -2
- package/dist/assets/{TimetableView-7V2AwWml.js → TimetableView-BOLB-kZC.js} +4 -4
- package/dist/assets/index-Bc9003lB.js +131 -0
- package/dist/assets/index-D4aq9poS.css +1 -0
- package/dist/assets/pluginParsers-DD3QL1Xl.js +1 -0
- package/dist/assets/{student-workspace-DstiW0pM.js → student-workspace-C4UgD58C.js} +1 -1
- package/dist/assets/{vendor-icons-DLOPlM4i.js → vendor-icons-CaXLq01o.js} +1 -1
- package/dist/index.html +3 -3
- package/dist/server.cjs +630 -433
- package/migrations/045_ai_providers_owner_and_scope.sql +20 -0
- package/migrations/README.md +1 -0
- package/migrations/schema-golden.json +13 -3
- package/package.json +4 -2
- package/dist/assets/AdminDirectoryView-Cdsk9Mdr.js +0 -5
- package/dist/assets/ImportModal-h5ShIMzO.js +0 -2
- package/dist/assets/InteractiveWhiteboard-DJqgP_tU.js +0 -65
- package/dist/assets/LiveClassroomView-D2ugupT9.js +0 -61
- package/dist/assets/index-C4cHQZyx.css +0 -1
- package/dist/assets/index-XduPa2-x.js +0 -131
- package/dist/assets/pluginParsers-BO6U1E-h.js +0 -1
package/dist/server.cjs
CHANGED
|
@@ -3304,6 +3304,95 @@ var init_ai_event_bus = __esm({
|
|
|
3304
3304
|
}
|
|
3305
3305
|
});
|
|
3306
3306
|
|
|
3307
|
+
// packages/core/di/ai-provider-resolver.ts
|
|
3308
|
+
function hasOwnerColumns(db2) {
|
|
3309
|
+
try {
|
|
3310
|
+
const cols = db2.prepare("PRAGMA table_info(ai_providers)").all();
|
|
3311
|
+
return cols.some((c) => c.name === "owner_id");
|
|
3312
|
+
} catch {
|
|
3313
|
+
return false;
|
|
3314
|
+
}
|
|
3315
|
+
}
|
|
3316
|
+
function resolveAccessibleAiProvider(db2, options = {}) {
|
|
3317
|
+
const { providerId, userId, lang = "zh" } = options;
|
|
3318
|
+
const supportsOwner = hasOwnerColumns(db2);
|
|
3319
|
+
let provider;
|
|
3320
|
+
if (providerId) {
|
|
3321
|
+
const row = supportsOwner ? db2.prepare(
|
|
3322
|
+
"SELECT id, name, api_url, api_key, model_name, owner_id, is_shared, created_at, updated_at FROM ai_providers WHERE id = ?"
|
|
3323
|
+
).get(providerId) : db2.prepare("SELECT id, name, api_url, api_key, model_name FROM ai_providers WHERE id = ?").get(providerId);
|
|
3324
|
+
if (!row) {
|
|
3325
|
+
throw new AiProviderError(
|
|
3326
|
+
lang === "zh" ? "\u672A\u627E\u5230\u6307\u5B9A\u7684 AI \u63D0\u4F9B\u5546" : "Specified AI Provider not found",
|
|
3327
|
+
404,
|
|
3328
|
+
"PROVIDER_NOT_FOUND"
|
|
3329
|
+
);
|
|
3330
|
+
}
|
|
3331
|
+
if (supportsOwner) {
|
|
3332
|
+
const isShared = Number(row.is_shared) === 1 || row.owner_id === null;
|
|
3333
|
+
const isOwner = Boolean(userId && row.owner_id === userId);
|
|
3334
|
+
if (!isShared && !isOwner) {
|
|
3335
|
+
throw new AiProviderError(
|
|
3336
|
+
lang === "zh" ? "\u65E0\u6743\u4F7F\u7528\u8BE5 AI \u63D0\u4F9B\u5546\uFF08\u975E\u672C\u4EBA\u79C1\u6709\u4E14\u672A\u5171\u4EAB\uFF09" : "Forbidden: You do not have access to this private AI provider",
|
|
3337
|
+
403,
|
|
3338
|
+
"PROVIDER_ACCESS_DENIED"
|
|
3339
|
+
);
|
|
3340
|
+
}
|
|
3341
|
+
}
|
|
3342
|
+
provider = row;
|
|
3343
|
+
} else {
|
|
3344
|
+
if (!supportsOwner) {
|
|
3345
|
+
provider = db2.prepare(
|
|
3346
|
+
"SELECT id, name, api_url, api_key, model_name FROM ai_providers WHERE api_key IS NOT NULL AND api_key != '' ORDER BY created_at DESC LIMIT 1"
|
|
3347
|
+
).get();
|
|
3348
|
+
} else if (userId) {
|
|
3349
|
+
provider = db2.prepare(
|
|
3350
|
+
`SELECT id, name, api_url, api_key, model_name, owner_id, is_shared, created_at, updated_at
|
|
3351
|
+
FROM ai_providers
|
|
3352
|
+
WHERE (owner_id = ? OR is_shared = 1 OR owner_id IS NULL)
|
|
3353
|
+
AND api_key IS NOT NULL AND api_key != ''
|
|
3354
|
+
ORDER BY (CASE WHEN owner_id = ? THEN 0 WHEN is_shared = 1 THEN 1 ELSE 2 END), created_at DESC
|
|
3355
|
+
LIMIT 1`
|
|
3356
|
+
).get(userId, userId);
|
|
3357
|
+
} else {
|
|
3358
|
+
provider = db2.prepare(
|
|
3359
|
+
`SELECT id, name, api_url, api_key, model_name, owner_id, is_shared, created_at, updated_at
|
|
3360
|
+
FROM ai_providers
|
|
3361
|
+
WHERE (is_shared = 1 OR owner_id IS NULL)
|
|
3362
|
+
AND api_key IS NOT NULL AND api_key != ''
|
|
3363
|
+
ORDER BY (CASE WHEN is_shared = 1 THEN 0 ELSE 1 END), created_at DESC
|
|
3364
|
+
LIMIT 1`
|
|
3365
|
+
).get();
|
|
3366
|
+
}
|
|
3367
|
+
}
|
|
3368
|
+
if (provider?.api_key) {
|
|
3369
|
+
provider.api_key = decryptApiKey(provider.api_key);
|
|
3370
|
+
}
|
|
3371
|
+
if (!provider || !provider.api_key || !provider.api_key.trim()) {
|
|
3372
|
+
throw new AiProviderError(
|
|
3373
|
+
lang === "zh" ? "\u672A\u68C0\u6D4B\u5230\u53EF\u7528\u7684 AI \u63D0\u4F9B\u5546\u3002\u8BF7\u524D\u5F80\u914D\u7F6E\u5927\u6A21\u578B\u670D\u52A1\u3002" : "No active AI Provider configured. Please add and configure an AI Provider in settings.",
|
|
3374
|
+
400,
|
|
3375
|
+
"NO_AI_PROVIDER"
|
|
3376
|
+
);
|
|
3377
|
+
}
|
|
3378
|
+
return provider;
|
|
3379
|
+
}
|
|
3380
|
+
var AiProviderError;
|
|
3381
|
+
var init_ai_provider_resolver = __esm({
|
|
3382
|
+
"packages/core/di/ai-provider-resolver.ts"() {
|
|
3383
|
+
"use strict";
|
|
3384
|
+
init_api_key_crypto();
|
|
3385
|
+
AiProviderError = class extends Error {
|
|
3386
|
+
constructor(message, status = 400, code = "AI_PROVIDER_ERROR") {
|
|
3387
|
+
super(message);
|
|
3388
|
+
this.name = "AiProviderError";
|
|
3389
|
+
this.status = status;
|
|
3390
|
+
this.code = code;
|
|
3391
|
+
}
|
|
3392
|
+
};
|
|
3393
|
+
}
|
|
3394
|
+
});
|
|
3395
|
+
|
|
3307
3396
|
// packages/core/di/ai-service.ts
|
|
3308
3397
|
var AIService;
|
|
3309
3398
|
var init_ai_service = __esm({
|
|
@@ -3312,6 +3401,7 @@ var init_ai_service = __esm({
|
|
|
3312
3401
|
init_provider_gateway();
|
|
3313
3402
|
init_ai_event_bus();
|
|
3314
3403
|
init_api_key_crypto();
|
|
3404
|
+
init_ai_provider_resolver();
|
|
3315
3405
|
AIService = class {
|
|
3316
3406
|
constructor(db2) {
|
|
3317
3407
|
this.db = db2;
|
|
@@ -3359,16 +3449,11 @@ var init_ai_service = __esm({
|
|
|
3359
3449
|
this._contextDelegates.unregister?.(id);
|
|
3360
3450
|
}
|
|
3361
3451
|
async generateText(prompt, options) {
|
|
3362
|
-
const provider = this.db
|
|
3363
|
-
|
|
3364
|
-
|
|
3365
|
-
|
|
3366
|
-
|
|
3367
|
-
}
|
|
3368
|
-
const apiKey = decryptApiKey(provider.api_key);
|
|
3369
|
-
if (!apiKey || !apiKey.trim()) {
|
|
3370
|
-
throw new Error("AI Provider API Key \u89E3\u5BC6\u4E3A\u7A7A\uFF0C\u8BF7\u524D\u5F80\u300C\u7CFB\u7EDF\u7BA1\u7406 -> AI \u63D0\u4F9B\u5546\u7BA1\u7406\u300D\u91CD\u65B0\u4FDD\u5B58\u5BC6\u94A5\u3002");
|
|
3371
|
-
}
|
|
3452
|
+
const provider = resolveAccessibleAiProvider(this.db, {
|
|
3453
|
+
userId: options?.userId,
|
|
3454
|
+
providerId: options?.providerId
|
|
3455
|
+
});
|
|
3456
|
+
const apiKey = provider.api_key;
|
|
3372
3457
|
if (looksLikeCiphertext(apiKey)) {
|
|
3373
3458
|
throw new Error(
|
|
3374
3459
|
"AI Provider API Key \u89E3\u5BC6\u5931\u8D25\uFF1AENCRYPTION_KEY \u4E0E\u52A0\u5BC6\u65F6\u4E0D\u4E00\u81F4\uFF08\u5E38\u89C1\u4E8E PM2 \u5C06 ENCRYPTION_KEY \u7F6E\u7A7A\u6216\u5BC6\u94A5\u88AB\u8F6E\u6362\uFF09\u3002\u8BF7\u6062\u590D\u539F ENCRYPTION_KEY\uFF0C\u6216\u524D\u5F80\u300C\u7CFB\u7EDF\u7BA1\u7406 -> AI \u63D0\u4F9B\u5546\u7BA1\u7406\u300D\u91CD\u65B0\u4FDD\u5B58 API Key\u3002"
|
|
@@ -9211,6 +9296,7 @@ var init_di = __esm({
|
|
|
9211
9296
|
init_storage_service();
|
|
9212
9297
|
init_ai_service();
|
|
9213
9298
|
init_courseware_runtime_script_registry();
|
|
9299
|
+
init_ai_provider_resolver();
|
|
9214
9300
|
init_container();
|
|
9215
9301
|
}
|
|
9216
9302
|
});
|
|
@@ -13409,8 +13495,8 @@ var ManagementPlugin = {
|
|
|
13409
13495
|
});
|
|
13410
13496
|
await commandBus.registerHandler(classTemplateDownloadCmd, {
|
|
13411
13497
|
async execute() {
|
|
13412
|
-
const headers = "Class Name,Class Desc,Student Name,Student Email";
|
|
13413
|
-
const sampleRow = "Class 101,Introduction to English,John Doe,john@example.com\nClass 101,Introduction to English,Jane Smith,jane@example.com";
|
|
13498
|
+
const headers = "Class Name,Class Desc,Student Number,Student Name,Student Email,Initial Password,Student Notes,Gender";
|
|
13499
|
+
const sampleRow = "Class 101,Introduction to English,20260101,John Doe,john@example.com,,,Male\nClass 101,Introduction to English,20260102,Jane Smith,jane@example.com,,,Female";
|
|
13414
13500
|
const content = `${headers}
|
|
13415
13501
|
${sampleRow}`;
|
|
13416
13502
|
const fileId = (0, import_uuid4.v7)();
|
|
@@ -13442,8 +13528,8 @@ ${sampleRow}`;
|
|
|
13442
13528
|
});
|
|
13443
13529
|
await commandBus.registerHandler(studentTemplateDownloadCmd, {
|
|
13444
13530
|
async execute() {
|
|
13445
|
-
const headers = "Student Name,Student Email";
|
|
13446
|
-
const sampleRow = "Alice Cooper,alice@example.com\
|
|
13531
|
+
const headers = "Student Number,Student Name,Student Email,Initial Password,Student Notes,Gender";
|
|
13532
|
+
const sampleRow = "20260101,Alice Cooper,alice@example.com,,,Male\n20260102,Bob Dylan,bob@example.com,,,Female";
|
|
13447
13533
|
const content = `${headers}
|
|
13448
13534
|
${sampleRow}`;
|
|
13449
13535
|
const fileId = (0, import_uuid4.v7)();
|
|
@@ -21204,7 +21290,7 @@ var import_node_fs11 = __toESM(require("node:fs"), 1);
|
|
|
21204
21290
|
var import_node_path13 = __toESM(require("node:path"), 1);
|
|
21205
21291
|
var import_node_url5 = require("node:url");
|
|
21206
21292
|
var import_meta7 = {};
|
|
21207
|
-
var FALLBACK_VERSION = "0.7.
|
|
21293
|
+
var FALLBACK_VERSION = "0.7.12";
|
|
21208
21294
|
function readPlatformVersion() {
|
|
21209
21295
|
const bases = [];
|
|
21210
21296
|
if (typeof __dirname !== "undefined") bases.push(__dirname);
|
|
@@ -32769,6 +32855,299 @@ var import_helmet = __toESM(require("helmet"), 1);
|
|
|
32769
32855
|
|
|
32770
32856
|
// server/security/rate-limit.ts
|
|
32771
32857
|
var import_express_rate_limit = __toESM(require("express-rate-limit"), 1);
|
|
32858
|
+
|
|
32859
|
+
// server/security/route-scope.ts
|
|
32860
|
+
var AUTH_MIDDLEWARE_MARKER = Symbol.for("openlearn.routeScope.authMiddleware");
|
|
32861
|
+
var DATA_SCOPE_MARKER = Symbol.for("openlearn.routeScope.dataScope");
|
|
32862
|
+
function markAuthMiddleware(middleware, roles) {
|
|
32863
|
+
Object.defineProperty(middleware, AUTH_MIDDLEWARE_MARKER, {
|
|
32864
|
+
value: { roles: [...roles] },
|
|
32865
|
+
enumerable: false,
|
|
32866
|
+
configurable: true
|
|
32867
|
+
});
|
|
32868
|
+
return middleware;
|
|
32869
|
+
}
|
|
32870
|
+
function markDataScopeMiddleware(middleware, scope) {
|
|
32871
|
+
Object.defineProperty(middleware, DATA_SCOPE_MARKER, {
|
|
32872
|
+
value: scope,
|
|
32873
|
+
enumerable: false,
|
|
32874
|
+
configurable: true
|
|
32875
|
+
});
|
|
32876
|
+
return middleware;
|
|
32877
|
+
}
|
|
32878
|
+
function readMarker(handle, marker) {
|
|
32879
|
+
if (typeof handle !== "function") return void 0;
|
|
32880
|
+
return handle[marker];
|
|
32881
|
+
}
|
|
32882
|
+
function collectAuthRoutes(app) {
|
|
32883
|
+
const router = app._router ?? app.router;
|
|
32884
|
+
const stack = router?.stack ?? [];
|
|
32885
|
+
const result = [];
|
|
32886
|
+
for (const layer of stack) {
|
|
32887
|
+
const route = layer?.route;
|
|
32888
|
+
if (!route) continue;
|
|
32889
|
+
const handles = (route.stack ?? []).map((entry) => entry.handle);
|
|
32890
|
+
const authMeta = handles.map((handle) => readMarker(handle, AUTH_MIDDLEWARE_MARKER)).find(Boolean);
|
|
32891
|
+
if (!authMeta) continue;
|
|
32892
|
+
const scope = handles.map((handle) => readMarker(handle, DATA_SCOPE_MARKER)).find(Boolean);
|
|
32893
|
+
const methods = Object.keys(route.methods ?? {}).filter((method) => method !== "_all" && route.methods[method]).map((method) => method.toUpperCase());
|
|
32894
|
+
for (const method of methods) {
|
|
32895
|
+
result.push({
|
|
32896
|
+
method,
|
|
32897
|
+
path: String(route.path),
|
|
32898
|
+
scope,
|
|
32899
|
+
roles: [...authMeta.roles]
|
|
32900
|
+
});
|
|
32901
|
+
}
|
|
32902
|
+
}
|
|
32903
|
+
return result;
|
|
32904
|
+
}
|
|
32905
|
+
function isWhitelisted(route, whitelist) {
|
|
32906
|
+
return whitelist.some(
|
|
32907
|
+
(entry) => entry.method.toUpperCase() === route.method && normalizePath2(entry.path) === normalizePath2(route.path)
|
|
32908
|
+
);
|
|
32909
|
+
}
|
|
32910
|
+
function normalizePath2(path48) {
|
|
32911
|
+
if (!path48) return "/";
|
|
32912
|
+
return path48.endsWith("/") && path48.length > 1 ? path48.slice(0, -1) : path48;
|
|
32913
|
+
}
|
|
32914
|
+
function auditRouteScopes(app, whitelist = []) {
|
|
32915
|
+
const declared = [];
|
|
32916
|
+
const roleGated = [];
|
|
32917
|
+
const undeclared = [];
|
|
32918
|
+
for (const route of collectAuthRoutes(app)) {
|
|
32919
|
+
if (route.scope) {
|
|
32920
|
+
declared.push(route);
|
|
32921
|
+
continue;
|
|
32922
|
+
}
|
|
32923
|
+
if (route.roles.length > 0) {
|
|
32924
|
+
roleGated.push(route);
|
|
32925
|
+
continue;
|
|
32926
|
+
}
|
|
32927
|
+
if (isWhitelisted(route, whitelist)) continue;
|
|
32928
|
+
undeclared.push(route);
|
|
32929
|
+
}
|
|
32930
|
+
return { declared, roleGated, undeclared };
|
|
32931
|
+
}
|
|
32932
|
+
function formatRouteScopeViolation(route) {
|
|
32933
|
+
return `${route.method} ${route.path}`;
|
|
32934
|
+
}
|
|
32935
|
+
function assertRouteDataScopes(app, options = {}) {
|
|
32936
|
+
const logger2 = options.logger ?? console;
|
|
32937
|
+
const audit = auditRouteScopes(app, options.whitelist ?? []);
|
|
32938
|
+
if (audit.undeclared.length > 0) {
|
|
32939
|
+
const lines = audit.undeclared.map(formatRouteScopeViolation);
|
|
32940
|
+
const message = `[RouteScope] ${audit.undeclared.length} \u4E2A\u5DF2\u8BA4\u8BC1\u8DEF\u7531\u672A\u58F0\u660E\u6570\u636E\u8303\u56F4\uFF08requireScopedAuth\uFF09\uFF1A
|
|
32941
|
+
- ${lines.join("\n - ")}`;
|
|
32942
|
+
if (options.enforce) {
|
|
32943
|
+
logger2.error(message);
|
|
32944
|
+
throw new Error(message);
|
|
32945
|
+
}
|
|
32946
|
+
logger2.warn(message);
|
|
32947
|
+
}
|
|
32948
|
+
if (audit.declared.length > 0) {
|
|
32949
|
+
logger2.info?.(
|
|
32950
|
+
`[RouteScope] \u5DF2\u58F0\u660E\u6570\u636E\u8303\u56F4 ${audit.declared.length} \u6761\uFF0C\u89D2\u8272\u95E8\u7981\u8DEF\u7531 ${audit.roleGated.length} \u6761\uFF0C\u672A\u58F0\u660E ${audit.undeclared.length} \u6761\uFF08\u767D\u540D\u5355 ${options.whitelist?.length ?? 0} \u6761\uFF09\u3002`
|
|
32951
|
+
);
|
|
32952
|
+
}
|
|
32953
|
+
return audit;
|
|
32954
|
+
}
|
|
32955
|
+
|
|
32956
|
+
// server/utils/password-gate.ts
|
|
32957
|
+
var PASSWORD_GATE_SKIP_ENV = "OPENLEARN_SKIP_PASSWORD_CHANGE_GATE";
|
|
32958
|
+
var PASSWORD_GATE_SKIP_ENV_ALIAS = "DEV_BYPASS_PASSWORD_CHANGE_GATE";
|
|
32959
|
+
var productionWarningEmitted = false;
|
|
32960
|
+
function isTruthyFlag2(value) {
|
|
32961
|
+
if (typeof value !== "string") return false;
|
|
32962
|
+
const normalized = value.trim().toLowerCase();
|
|
32963
|
+
return normalized === "1" || normalized === "true";
|
|
32964
|
+
}
|
|
32965
|
+
function isPasswordChangeGateSkipped(env = process.env) {
|
|
32966
|
+
const requested = isTruthyFlag2(env[PASSWORD_GATE_SKIP_ENV]) || isTruthyFlag2(env[PASSWORD_GATE_SKIP_ENV_ALIAS]);
|
|
32967
|
+
if (!requested) return false;
|
|
32968
|
+
if ((env.NODE_ENV ?? "") === "production") {
|
|
32969
|
+
if (!productionWarningEmitted) {
|
|
32970
|
+
productionWarningEmitted = true;
|
|
32971
|
+
console.warn(
|
|
32972
|
+
`[SECURITY] ${PASSWORD_GATE_SKIP_ENV}/${PASSWORD_GATE_SKIP_ENV_ALIAS} \u5DF2\u914D\u7F6E\uFF0C\u4F46\u751F\u4EA7\u73AF\u5883\uFF08NODE_ENV=production\uFF09\u4E25\u683C\u5FFD\u7565\u8BE5\u5F00\u5173\uFF1A\u5F3A\u5236\u6539\u5BC6\u9632\u7EBF\u4E0D\u5931\u6548\u3002\u8BF7\u7ACB\u5373\u4ECE\u751F\u4EA7\u73AF\u5883\u79FB\u9664\u8BE5\u53D8\u91CF\u3002`
|
|
32973
|
+
);
|
|
32974
|
+
}
|
|
32975
|
+
return false;
|
|
32976
|
+
}
|
|
32977
|
+
return true;
|
|
32978
|
+
}
|
|
32979
|
+
|
|
32980
|
+
// server/services/session-role.ts
|
|
32981
|
+
var SESSION_ROLE_WHITELIST = /* @__PURE__ */ new Set(["administrator", "teacher", "student", "anonymous"]);
|
|
32982
|
+
function resolveSessionRole(session) {
|
|
32983
|
+
if (!session) return "anonymous";
|
|
32984
|
+
for (const candidate of [session.subRole, session.role]) {
|
|
32985
|
+
if (typeof candidate !== "string" || candidate.length === 0) continue;
|
|
32986
|
+
const normalized = candidate === "admin" ? "administrator" : candidate;
|
|
32987
|
+
if (SESSION_ROLE_WHITELIST.has(normalized)) return normalized;
|
|
32988
|
+
}
|
|
32989
|
+
return "anonymous";
|
|
32990
|
+
}
|
|
32991
|
+
function isAdministrator(session) {
|
|
32992
|
+
return resolveSessionRole(session) === "administrator";
|
|
32993
|
+
}
|
|
32994
|
+
|
|
32995
|
+
// server/middleware/auth.ts
|
|
32996
|
+
var SESSION_REFRESH_INTERVAL_MS = 5 * 60 * 1e3;
|
|
32997
|
+
function getValidSession(token) {
|
|
32998
|
+
let sessionRow;
|
|
32999
|
+
try {
|
|
33000
|
+
sessionRow = kernelContainer.db.prepare("SELECT * FROM client_sessions WHERE id = ?").get(token);
|
|
33001
|
+
} catch {
|
|
33002
|
+
return null;
|
|
33003
|
+
}
|
|
33004
|
+
if (!sessionRow) return null;
|
|
33005
|
+
const now = Date.now();
|
|
33006
|
+
if (sessionRow.expires_at && sessionRow.expires_at < now) {
|
|
33007
|
+
kernelContainer.db.prepare("DELETE FROM client_sessions WHERE id = ?").run(token);
|
|
33008
|
+
return null;
|
|
33009
|
+
}
|
|
33010
|
+
const idleTimeout = 24 * 60 * 60 * 1e3;
|
|
33011
|
+
if (sessionRow.updated_at && now - sessionRow.updated_at > idleTimeout) {
|
|
33012
|
+
kernelContainer.db.prepare("DELETE FROM client_sessions WHERE id = ?").run(token);
|
|
33013
|
+
return null;
|
|
33014
|
+
}
|
|
33015
|
+
if (!sessionRow.updated_at || now - sessionRow.updated_at > SESSION_REFRESH_INTERVAL_MS) {
|
|
33016
|
+
kernelContainer.db.prepare("UPDATE client_sessions SET updated_at = ? WHERE id = ?").run(now, token);
|
|
33017
|
+
}
|
|
33018
|
+
let session;
|
|
33019
|
+
try {
|
|
33020
|
+
session = JSON.parse(sessionRow.session_data);
|
|
33021
|
+
} catch {
|
|
33022
|
+
return null;
|
|
33023
|
+
}
|
|
33024
|
+
if (session && !session.userId && session.studentId) {
|
|
33025
|
+
session.userId = session.studentId;
|
|
33026
|
+
}
|
|
33027
|
+
if (session.role && !["teacher", "student", "administrator", "admin"].includes(session.role)) {
|
|
33028
|
+
session.role = "anonymous";
|
|
33029
|
+
}
|
|
33030
|
+
return session;
|
|
33031
|
+
}
|
|
33032
|
+
function getCookieToken(req) {
|
|
33033
|
+
const rc = req.headers.cookie;
|
|
33034
|
+
if (!rc) return null;
|
|
33035
|
+
const parts = rc.split(";");
|
|
33036
|
+
for (const part of parts) {
|
|
33037
|
+
const trimmed2 = part.trim();
|
|
33038
|
+
if (trimmed2.startsWith("edu_os_token=")) {
|
|
33039
|
+
return trimmed2.substring("edu_os_token=".length);
|
|
33040
|
+
}
|
|
33041
|
+
}
|
|
33042
|
+
return null;
|
|
33043
|
+
}
|
|
33044
|
+
function getActorId(req) {
|
|
33045
|
+
const token = getCookieToken(req);
|
|
33046
|
+
if (!token) return "anonymous";
|
|
33047
|
+
try {
|
|
33048
|
+
const session = getValidSession(token);
|
|
33049
|
+
if (!session) return "anonymous";
|
|
33050
|
+
const role = resolveSessionRole(session);
|
|
33051
|
+
const rawUserId = session.userId || session.studentId || "demo";
|
|
33052
|
+
const safeUserId = String(rawUserId).replace(/[:\s]/g, "_").slice(0, 64);
|
|
33053
|
+
return `user:${safeUserId}:${role}`;
|
|
33054
|
+
} catch {
|
|
33055
|
+
return "anonymous";
|
|
33056
|
+
}
|
|
33057
|
+
}
|
|
33058
|
+
function requireAuth(...roles) {
|
|
33059
|
+
const middleware = (req, res, next) => {
|
|
33060
|
+
const token = getCookieToken(req);
|
|
33061
|
+
if (!token) return res.status(401).json({ success: false, error: "Authentication required" });
|
|
33062
|
+
const session = getValidSession(token);
|
|
33063
|
+
if (!session) return res.status(401).json({ success: false, error: "Session expired or invalid" });
|
|
33064
|
+
if (roles.length > 0) {
|
|
33065
|
+
const userRole = resolveSessionRole(session);
|
|
33066
|
+
const effectiveRoles = roles.map((r) => r === "admin" ? "administrator" : r);
|
|
33067
|
+
if (!effectiveRoles.includes(userRole)) {
|
|
33068
|
+
return res.status(403).json({ success: false, error: `Role ${session.role} not allowed. Required: ${roles.join(", ")}` });
|
|
33069
|
+
}
|
|
33070
|
+
}
|
|
33071
|
+
req.session = session;
|
|
33072
|
+
next();
|
|
33073
|
+
};
|
|
33074
|
+
return markAuthMiddleware(middleware, roles);
|
|
33075
|
+
}
|
|
33076
|
+
var PASSWORD_CHANGE_EXEMPT_PATHS = /* @__PURE__ */ new Set([
|
|
33077
|
+
"/api/auth/change-password",
|
|
33078
|
+
"/api/auth/logout",
|
|
33079
|
+
"/api/auth/session",
|
|
33080
|
+
"/api/auth/me",
|
|
33081
|
+
"/api/client-errors"
|
|
33082
|
+
]);
|
|
33083
|
+
function enforcePasswordChanged(req, res, next) {
|
|
33084
|
+
if (process.env.PLAYWRIGHT_TEST) {
|
|
33085
|
+
return next();
|
|
33086
|
+
}
|
|
33087
|
+
if (isPasswordChangeGateSkipped()) {
|
|
33088
|
+
return next();
|
|
33089
|
+
}
|
|
33090
|
+
const path48 = req.path || "";
|
|
33091
|
+
if (!path48.startsWith("/api/")) {
|
|
33092
|
+
return next();
|
|
33093
|
+
}
|
|
33094
|
+
if (PASSWORD_CHANGE_EXEMPT_PATHS.has(path48)) {
|
|
33095
|
+
return next();
|
|
33096
|
+
}
|
|
33097
|
+
const token = getCookieToken(req);
|
|
33098
|
+
if (token) {
|
|
33099
|
+
const session = getValidSession(token);
|
|
33100
|
+
if (session && session.mustChangePassword) {
|
|
33101
|
+
res.status(403).json({
|
|
33102
|
+
success: false,
|
|
33103
|
+
code: "FORBIDDEN_DEFAULT_PASSWORD",
|
|
33104
|
+
error: "Default password must be changed first"
|
|
33105
|
+
});
|
|
33106
|
+
return;
|
|
33107
|
+
}
|
|
33108
|
+
}
|
|
33109
|
+
next();
|
|
33110
|
+
}
|
|
33111
|
+
function socketAuthMiddleware(socket, next) {
|
|
33112
|
+
try {
|
|
33113
|
+
const cookieHeader = socket.handshake.headers.cookie;
|
|
33114
|
+
let token = null;
|
|
33115
|
+
if (cookieHeader) {
|
|
33116
|
+
const parts = cookieHeader.split(";");
|
|
33117
|
+
for (const part of parts) {
|
|
33118
|
+
const trimmed2 = part.trim();
|
|
33119
|
+
if (trimmed2.startsWith("edu_os_token=")) {
|
|
33120
|
+
token = trimmed2.substring("edu_os_token=".length);
|
|
33121
|
+
break;
|
|
33122
|
+
}
|
|
33123
|
+
}
|
|
33124
|
+
}
|
|
33125
|
+
if (!token && socket.handshake.auth?.token) {
|
|
33126
|
+
token = socket.handshake.auth.token;
|
|
33127
|
+
}
|
|
33128
|
+
if (process.env.NODE_ENV === "test" && !token) {
|
|
33129
|
+
return next();
|
|
33130
|
+
}
|
|
33131
|
+
if (!token) {
|
|
33132
|
+
return next(new Error("Authentication required: missing edu_os_token"));
|
|
33133
|
+
}
|
|
33134
|
+
const session = getValidSession(token);
|
|
33135
|
+
if (!session) {
|
|
33136
|
+
return next(new Error("Authentication required: session expired or invalid"));
|
|
33137
|
+
}
|
|
33138
|
+
if (session.mustChangePassword && !isPasswordChangeGateSkipped()) {
|
|
33139
|
+
return next(new Error("FORBIDDEN_DEFAULT_PASSWORD"));
|
|
33140
|
+
}
|
|
33141
|
+
socket.data.session = session;
|
|
33142
|
+
socket.data.userId = session.userId;
|
|
33143
|
+
socket.data.role = session.role;
|
|
33144
|
+
next();
|
|
33145
|
+
} catch (err) {
|
|
33146
|
+
next(new Error(`Authentication error: ${err.message}`));
|
|
33147
|
+
}
|
|
33148
|
+
}
|
|
33149
|
+
|
|
33150
|
+
// server/security/rate-limit.ts
|
|
32772
33151
|
function loginRateLimitKey(req) {
|
|
32773
33152
|
const body = req.body ?? {};
|
|
32774
33153
|
const raw = body.username ?? body.studentId ?? body.studentName ?? "";
|
|
@@ -32785,6 +33164,18 @@ function writeRateLimitKey(req) {
|
|
|
32785
33164
|
return ip;
|
|
32786
33165
|
}
|
|
32787
33166
|
}
|
|
33167
|
+
function sessionRateLimitKey(req) {
|
|
33168
|
+
try {
|
|
33169
|
+
const token = getCookieToken(req);
|
|
33170
|
+
const session = token ? getValidSession(token) : null;
|
|
33171
|
+
if (session) {
|
|
33172
|
+
const identity = String(session.userId || session.studentId || session.username || "").trim();
|
|
33173
|
+
if (identity) return `session:${identity.slice(0, 64)}`;
|
|
33174
|
+
}
|
|
33175
|
+
} catch {
|
|
33176
|
+
}
|
|
33177
|
+
return `ip:${writeRateLimitKey(req)}`;
|
|
33178
|
+
}
|
|
32788
33179
|
var limiterValidation = {
|
|
32789
33180
|
xForwardedForHeader: false,
|
|
32790
33181
|
trustProxy: false,
|
|
@@ -32823,6 +33214,17 @@ function createAiLimiter(max) {
|
|
|
32823
33214
|
validate: limiterValidation
|
|
32824
33215
|
});
|
|
32825
33216
|
}
|
|
33217
|
+
function createSessionWriteLimiter(max) {
|
|
33218
|
+
return (0, import_express_rate_limit.default)({
|
|
33219
|
+
windowMs: 60 * 1e3,
|
|
33220
|
+
max,
|
|
33221
|
+
keyGenerator: sessionRateLimitKey,
|
|
33222
|
+
message: { error: "\u8BF7\u6C42\u8FC7\u4E8E\u9891\u7E41\uFF0C\u8BF7\u7A0D\u540E\u518D\u8BD5\u3002Too many requests, please try again later." },
|
|
33223
|
+
standardHeaders: true,
|
|
33224
|
+
legacyHeaders: false,
|
|
33225
|
+
validate: limiterValidation
|
|
33226
|
+
});
|
|
33227
|
+
}
|
|
32826
33228
|
|
|
32827
33229
|
// server/security/trust-proxy.ts
|
|
32828
33230
|
function resolveTrustProxy(raw) {
|
|
@@ -34199,297 +34601,6 @@ function startBackupScheduler(deps) {
|
|
|
34199
34601
|
};
|
|
34200
34602
|
}
|
|
34201
34603
|
|
|
34202
|
-
// server/security/route-scope.ts
|
|
34203
|
-
var AUTH_MIDDLEWARE_MARKER = Symbol.for("openlearn.routeScope.authMiddleware");
|
|
34204
|
-
var DATA_SCOPE_MARKER = Symbol.for("openlearn.routeScope.dataScope");
|
|
34205
|
-
function markAuthMiddleware(middleware, roles) {
|
|
34206
|
-
Object.defineProperty(middleware, AUTH_MIDDLEWARE_MARKER, {
|
|
34207
|
-
value: { roles: [...roles] },
|
|
34208
|
-
enumerable: false,
|
|
34209
|
-
configurable: true
|
|
34210
|
-
});
|
|
34211
|
-
return middleware;
|
|
34212
|
-
}
|
|
34213
|
-
function markDataScopeMiddleware(middleware, scope) {
|
|
34214
|
-
Object.defineProperty(middleware, DATA_SCOPE_MARKER, {
|
|
34215
|
-
value: scope,
|
|
34216
|
-
enumerable: false,
|
|
34217
|
-
configurable: true
|
|
34218
|
-
});
|
|
34219
|
-
return middleware;
|
|
34220
|
-
}
|
|
34221
|
-
function readMarker(handle, marker) {
|
|
34222
|
-
if (typeof handle !== "function") return void 0;
|
|
34223
|
-
return handle[marker];
|
|
34224
|
-
}
|
|
34225
|
-
function collectAuthRoutes(app) {
|
|
34226
|
-
const router = app._router ?? app.router;
|
|
34227
|
-
const stack = router?.stack ?? [];
|
|
34228
|
-
const result = [];
|
|
34229
|
-
for (const layer of stack) {
|
|
34230
|
-
const route = layer?.route;
|
|
34231
|
-
if (!route) continue;
|
|
34232
|
-
const handles = (route.stack ?? []).map((entry) => entry.handle);
|
|
34233
|
-
const authMeta = handles.map((handle) => readMarker(handle, AUTH_MIDDLEWARE_MARKER)).find(Boolean);
|
|
34234
|
-
if (!authMeta) continue;
|
|
34235
|
-
const scope = handles.map((handle) => readMarker(handle, DATA_SCOPE_MARKER)).find(Boolean);
|
|
34236
|
-
const methods = Object.keys(route.methods ?? {}).filter((method) => method !== "_all" && route.methods[method]).map((method) => method.toUpperCase());
|
|
34237
|
-
for (const method of methods) {
|
|
34238
|
-
result.push({
|
|
34239
|
-
method,
|
|
34240
|
-
path: String(route.path),
|
|
34241
|
-
scope,
|
|
34242
|
-
roles: [...authMeta.roles]
|
|
34243
|
-
});
|
|
34244
|
-
}
|
|
34245
|
-
}
|
|
34246
|
-
return result;
|
|
34247
|
-
}
|
|
34248
|
-
function isWhitelisted(route, whitelist) {
|
|
34249
|
-
return whitelist.some(
|
|
34250
|
-
(entry) => entry.method.toUpperCase() === route.method && normalizePath2(entry.path) === normalizePath2(route.path)
|
|
34251
|
-
);
|
|
34252
|
-
}
|
|
34253
|
-
function normalizePath2(path48) {
|
|
34254
|
-
if (!path48) return "/";
|
|
34255
|
-
return path48.endsWith("/") && path48.length > 1 ? path48.slice(0, -1) : path48;
|
|
34256
|
-
}
|
|
34257
|
-
function auditRouteScopes(app, whitelist = []) {
|
|
34258
|
-
const declared = [];
|
|
34259
|
-
const roleGated = [];
|
|
34260
|
-
const undeclared = [];
|
|
34261
|
-
for (const route of collectAuthRoutes(app)) {
|
|
34262
|
-
if (route.scope) {
|
|
34263
|
-
declared.push(route);
|
|
34264
|
-
continue;
|
|
34265
|
-
}
|
|
34266
|
-
if (route.roles.length > 0) {
|
|
34267
|
-
roleGated.push(route);
|
|
34268
|
-
continue;
|
|
34269
|
-
}
|
|
34270
|
-
if (isWhitelisted(route, whitelist)) continue;
|
|
34271
|
-
undeclared.push(route);
|
|
34272
|
-
}
|
|
34273
|
-
return { declared, roleGated, undeclared };
|
|
34274
|
-
}
|
|
34275
|
-
function formatRouteScopeViolation(route) {
|
|
34276
|
-
return `${route.method} ${route.path}`;
|
|
34277
|
-
}
|
|
34278
|
-
function assertRouteDataScopes(app, options = {}) {
|
|
34279
|
-
const logger2 = options.logger ?? console;
|
|
34280
|
-
const audit = auditRouteScopes(app, options.whitelist ?? []);
|
|
34281
|
-
if (audit.undeclared.length > 0) {
|
|
34282
|
-
const lines = audit.undeclared.map(formatRouteScopeViolation);
|
|
34283
|
-
const message = `[RouteScope] ${audit.undeclared.length} \u4E2A\u5DF2\u8BA4\u8BC1\u8DEF\u7531\u672A\u58F0\u660E\u6570\u636E\u8303\u56F4\uFF08requireScopedAuth\uFF09\uFF1A
|
|
34284
|
-
- ${lines.join("\n - ")}`;
|
|
34285
|
-
if (options.enforce) {
|
|
34286
|
-
logger2.error(message);
|
|
34287
|
-
throw new Error(message);
|
|
34288
|
-
}
|
|
34289
|
-
logger2.warn(message);
|
|
34290
|
-
}
|
|
34291
|
-
if (audit.declared.length > 0) {
|
|
34292
|
-
logger2.info?.(
|
|
34293
|
-
`[RouteScope] \u5DF2\u58F0\u660E\u6570\u636E\u8303\u56F4 ${audit.declared.length} \u6761\uFF0C\u89D2\u8272\u95E8\u7981\u8DEF\u7531 ${audit.roleGated.length} \u6761\uFF0C\u672A\u58F0\u660E ${audit.undeclared.length} \u6761\uFF08\u767D\u540D\u5355 ${options.whitelist?.length ?? 0} \u6761\uFF09\u3002`
|
|
34294
|
-
);
|
|
34295
|
-
}
|
|
34296
|
-
return audit;
|
|
34297
|
-
}
|
|
34298
|
-
|
|
34299
|
-
// server/utils/password-gate.ts
|
|
34300
|
-
var PASSWORD_GATE_SKIP_ENV = "OPENLEARN_SKIP_PASSWORD_CHANGE_GATE";
|
|
34301
|
-
var PASSWORD_GATE_SKIP_ENV_ALIAS = "DEV_BYPASS_PASSWORD_CHANGE_GATE";
|
|
34302
|
-
var productionWarningEmitted = false;
|
|
34303
|
-
function isTruthyFlag2(value) {
|
|
34304
|
-
if (typeof value !== "string") return false;
|
|
34305
|
-
const normalized = value.trim().toLowerCase();
|
|
34306
|
-
return normalized === "1" || normalized === "true";
|
|
34307
|
-
}
|
|
34308
|
-
function isPasswordChangeGateSkipped(env = process.env) {
|
|
34309
|
-
const requested = isTruthyFlag2(env[PASSWORD_GATE_SKIP_ENV]) || isTruthyFlag2(env[PASSWORD_GATE_SKIP_ENV_ALIAS]);
|
|
34310
|
-
if (!requested) return false;
|
|
34311
|
-
if ((env.NODE_ENV ?? "") === "production") {
|
|
34312
|
-
if (!productionWarningEmitted) {
|
|
34313
|
-
productionWarningEmitted = true;
|
|
34314
|
-
console.warn(
|
|
34315
|
-
`[SECURITY] ${PASSWORD_GATE_SKIP_ENV}/${PASSWORD_GATE_SKIP_ENV_ALIAS} \u5DF2\u914D\u7F6E\uFF0C\u4F46\u751F\u4EA7\u73AF\u5883\uFF08NODE_ENV=production\uFF09\u4E25\u683C\u5FFD\u7565\u8BE5\u5F00\u5173\uFF1A\u5F3A\u5236\u6539\u5BC6\u9632\u7EBF\u4E0D\u5931\u6548\u3002\u8BF7\u7ACB\u5373\u4ECE\u751F\u4EA7\u73AF\u5883\u79FB\u9664\u8BE5\u53D8\u91CF\u3002`
|
|
34316
|
-
);
|
|
34317
|
-
}
|
|
34318
|
-
return false;
|
|
34319
|
-
}
|
|
34320
|
-
return true;
|
|
34321
|
-
}
|
|
34322
|
-
|
|
34323
|
-
// server/services/session-role.ts
|
|
34324
|
-
var SESSION_ROLE_WHITELIST = /* @__PURE__ */ new Set(["administrator", "teacher", "student", "anonymous"]);
|
|
34325
|
-
function resolveSessionRole(session) {
|
|
34326
|
-
if (!session) return "anonymous";
|
|
34327
|
-
for (const candidate of [session.subRole, session.role]) {
|
|
34328
|
-
if (typeof candidate !== "string" || candidate.length === 0) continue;
|
|
34329
|
-
const normalized = candidate === "admin" ? "administrator" : candidate;
|
|
34330
|
-
if (SESSION_ROLE_WHITELIST.has(normalized)) return normalized;
|
|
34331
|
-
}
|
|
34332
|
-
return "anonymous";
|
|
34333
|
-
}
|
|
34334
|
-
function isAdministrator(session) {
|
|
34335
|
-
return resolveSessionRole(session) === "administrator";
|
|
34336
|
-
}
|
|
34337
|
-
|
|
34338
|
-
// server/middleware/auth.ts
|
|
34339
|
-
var SESSION_REFRESH_INTERVAL_MS = 5 * 60 * 1e3;
|
|
34340
|
-
function getValidSession(token) {
|
|
34341
|
-
let sessionRow;
|
|
34342
|
-
try {
|
|
34343
|
-
sessionRow = kernelContainer.db.prepare("SELECT * FROM client_sessions WHERE id = ?").get(token);
|
|
34344
|
-
} catch {
|
|
34345
|
-
return null;
|
|
34346
|
-
}
|
|
34347
|
-
if (!sessionRow) return null;
|
|
34348
|
-
const now = Date.now();
|
|
34349
|
-
if (sessionRow.expires_at && sessionRow.expires_at < now) {
|
|
34350
|
-
kernelContainer.db.prepare("DELETE FROM client_sessions WHERE id = ?").run(token);
|
|
34351
|
-
return null;
|
|
34352
|
-
}
|
|
34353
|
-
const idleTimeout = 24 * 60 * 60 * 1e3;
|
|
34354
|
-
if (sessionRow.updated_at && now - sessionRow.updated_at > idleTimeout) {
|
|
34355
|
-
kernelContainer.db.prepare("DELETE FROM client_sessions WHERE id = ?").run(token);
|
|
34356
|
-
return null;
|
|
34357
|
-
}
|
|
34358
|
-
if (!sessionRow.updated_at || now - sessionRow.updated_at > SESSION_REFRESH_INTERVAL_MS) {
|
|
34359
|
-
kernelContainer.db.prepare("UPDATE client_sessions SET updated_at = ? WHERE id = ?").run(now, token);
|
|
34360
|
-
}
|
|
34361
|
-
let session;
|
|
34362
|
-
try {
|
|
34363
|
-
session = JSON.parse(sessionRow.session_data);
|
|
34364
|
-
} catch {
|
|
34365
|
-
return null;
|
|
34366
|
-
}
|
|
34367
|
-
if (session && !session.userId && session.studentId) {
|
|
34368
|
-
session.userId = session.studentId;
|
|
34369
|
-
}
|
|
34370
|
-
if (session.role && !["teacher", "student", "administrator", "admin"].includes(session.role)) {
|
|
34371
|
-
session.role = "anonymous";
|
|
34372
|
-
}
|
|
34373
|
-
return session;
|
|
34374
|
-
}
|
|
34375
|
-
function getCookieToken(req) {
|
|
34376
|
-
const rc = req.headers.cookie;
|
|
34377
|
-
if (!rc) return null;
|
|
34378
|
-
const parts = rc.split(";");
|
|
34379
|
-
for (const part of parts) {
|
|
34380
|
-
const trimmed2 = part.trim();
|
|
34381
|
-
if (trimmed2.startsWith("edu_os_token=")) {
|
|
34382
|
-
return trimmed2.substring("edu_os_token=".length);
|
|
34383
|
-
}
|
|
34384
|
-
}
|
|
34385
|
-
return null;
|
|
34386
|
-
}
|
|
34387
|
-
function getActorId(req) {
|
|
34388
|
-
const token = getCookieToken(req);
|
|
34389
|
-
if (!token) return "anonymous";
|
|
34390
|
-
try {
|
|
34391
|
-
const session = getValidSession(token);
|
|
34392
|
-
if (!session) return "anonymous";
|
|
34393
|
-
const role = resolveSessionRole(session);
|
|
34394
|
-
const rawUserId = session.userId || session.studentId || "demo";
|
|
34395
|
-
const safeUserId = String(rawUserId).replace(/[:\s]/g, "_").slice(0, 64);
|
|
34396
|
-
return `user:${safeUserId}:${role}`;
|
|
34397
|
-
} catch {
|
|
34398
|
-
return "anonymous";
|
|
34399
|
-
}
|
|
34400
|
-
}
|
|
34401
|
-
function requireAuth(...roles) {
|
|
34402
|
-
const middleware = (req, res, next) => {
|
|
34403
|
-
const token = getCookieToken(req);
|
|
34404
|
-
if (!token) return res.status(401).json({ success: false, error: "Authentication required" });
|
|
34405
|
-
const session = getValidSession(token);
|
|
34406
|
-
if (!session) return res.status(401).json({ success: false, error: "Session expired or invalid" });
|
|
34407
|
-
if (roles.length > 0) {
|
|
34408
|
-
const userRole = resolveSessionRole(session);
|
|
34409
|
-
const effectiveRoles = roles.map((r) => r === "admin" ? "administrator" : r);
|
|
34410
|
-
if (!effectiveRoles.includes(userRole)) {
|
|
34411
|
-
return res.status(403).json({ success: false, error: `Role ${session.role} not allowed. Required: ${roles.join(", ")}` });
|
|
34412
|
-
}
|
|
34413
|
-
}
|
|
34414
|
-
req.session = session;
|
|
34415
|
-
next();
|
|
34416
|
-
};
|
|
34417
|
-
return markAuthMiddleware(middleware, roles);
|
|
34418
|
-
}
|
|
34419
|
-
var PASSWORD_CHANGE_EXEMPT_PATHS = /* @__PURE__ */ new Set([
|
|
34420
|
-
"/api/auth/change-password",
|
|
34421
|
-
"/api/auth/logout",
|
|
34422
|
-
"/api/auth/session",
|
|
34423
|
-
"/api/auth/me",
|
|
34424
|
-
"/api/client-errors"
|
|
34425
|
-
]);
|
|
34426
|
-
function enforcePasswordChanged(req, res, next) {
|
|
34427
|
-
if (process.env.PLAYWRIGHT_TEST) {
|
|
34428
|
-
return next();
|
|
34429
|
-
}
|
|
34430
|
-
if (isPasswordChangeGateSkipped()) {
|
|
34431
|
-
return next();
|
|
34432
|
-
}
|
|
34433
|
-
const path48 = req.path || "";
|
|
34434
|
-
if (!path48.startsWith("/api/")) {
|
|
34435
|
-
return next();
|
|
34436
|
-
}
|
|
34437
|
-
if (PASSWORD_CHANGE_EXEMPT_PATHS.has(path48)) {
|
|
34438
|
-
return next();
|
|
34439
|
-
}
|
|
34440
|
-
const token = getCookieToken(req);
|
|
34441
|
-
if (token) {
|
|
34442
|
-
const session = getValidSession(token);
|
|
34443
|
-
if (session && session.mustChangePassword) {
|
|
34444
|
-
res.status(403).json({
|
|
34445
|
-
success: false,
|
|
34446
|
-
code: "FORBIDDEN_DEFAULT_PASSWORD",
|
|
34447
|
-
error: "Default password must be changed first"
|
|
34448
|
-
});
|
|
34449
|
-
return;
|
|
34450
|
-
}
|
|
34451
|
-
}
|
|
34452
|
-
next();
|
|
34453
|
-
}
|
|
34454
|
-
function socketAuthMiddleware(socket, next) {
|
|
34455
|
-
try {
|
|
34456
|
-
const cookieHeader = socket.handshake.headers.cookie;
|
|
34457
|
-
let token = null;
|
|
34458
|
-
if (cookieHeader) {
|
|
34459
|
-
const parts = cookieHeader.split(";");
|
|
34460
|
-
for (const part of parts) {
|
|
34461
|
-
const trimmed2 = part.trim();
|
|
34462
|
-
if (trimmed2.startsWith("edu_os_token=")) {
|
|
34463
|
-
token = trimmed2.substring("edu_os_token=".length);
|
|
34464
|
-
break;
|
|
34465
|
-
}
|
|
34466
|
-
}
|
|
34467
|
-
}
|
|
34468
|
-
if (!token && socket.handshake.auth?.token) {
|
|
34469
|
-
token = socket.handshake.auth.token;
|
|
34470
|
-
}
|
|
34471
|
-
if (process.env.NODE_ENV === "test" && !token) {
|
|
34472
|
-
return next();
|
|
34473
|
-
}
|
|
34474
|
-
if (!token) {
|
|
34475
|
-
return next(new Error("Authentication required: missing edu_os_token"));
|
|
34476
|
-
}
|
|
34477
|
-
const session = getValidSession(token);
|
|
34478
|
-
if (!session) {
|
|
34479
|
-
return next(new Error("Authentication required: session expired or invalid"));
|
|
34480
|
-
}
|
|
34481
|
-
if (session.mustChangePassword && !isPasswordChangeGateSkipped()) {
|
|
34482
|
-
return next(new Error("FORBIDDEN_DEFAULT_PASSWORD"));
|
|
34483
|
-
}
|
|
34484
|
-
socket.data.session = session;
|
|
34485
|
-
socket.data.userId = session.userId;
|
|
34486
|
-
socket.data.role = session.role;
|
|
34487
|
-
next();
|
|
34488
|
-
} catch (err) {
|
|
34489
|
-
next(new Error(`Authentication error: ${err.message}`));
|
|
34490
|
-
}
|
|
34491
|
-
}
|
|
34492
|
-
|
|
34493
34604
|
// server/middleware/csrf.ts
|
|
34494
34605
|
var WRITE_METHODS = /* @__PURE__ */ new Set(["POST", "PUT", "PATCH", "DELETE"]);
|
|
34495
34606
|
var EXEMPT_PREFIXES = [
|
|
@@ -38406,9 +38517,12 @@ var generateStudentNumber = (db2) => {
|
|
|
38406
38517
|
return `S${nextSeq.toString().padStart(3, "0")}`;
|
|
38407
38518
|
};
|
|
38408
38519
|
|
|
38520
|
+
// server/services/ai-provider-resolver.ts
|
|
38521
|
+
init_ai_provider_resolver();
|
|
38522
|
+
|
|
38409
38523
|
// server/routes/os.ts
|
|
38410
38524
|
function registerOsRoutes(ctx) {
|
|
38411
|
-
const { app,
|
|
38525
|
+
const { app, runOpenAIAgentChat: runOpenAIAgentChat2, activityRegistry, aiLimiter } = ctx;
|
|
38412
38526
|
app.get("/api/ping", (_req, res) => {
|
|
38413
38527
|
res.json({ ok: true, ts: Date.now() });
|
|
38414
38528
|
});
|
|
@@ -38627,13 +38741,10 @@ function registerOsRoutes(ctx) {
|
|
|
38627
38741
|
}
|
|
38628
38742
|
let callerRole;
|
|
38629
38743
|
let userId;
|
|
38630
|
-
const
|
|
38631
|
-
if (
|
|
38632
|
-
|
|
38633
|
-
|
|
38634
|
-
callerRole = session.role;
|
|
38635
|
-
userId = session.userId;
|
|
38636
|
-
}
|
|
38744
|
+
const session = req.session || (getCookieToken(req) ? getValidSession(getCookieToken(req)) : null);
|
|
38745
|
+
if (session) {
|
|
38746
|
+
callerRole = session.role;
|
|
38747
|
+
userId = session.userId || session.username;
|
|
38637
38748
|
}
|
|
38638
38749
|
const convKey = `agent:${userId || "anonymous"}:${currentLessonId || "global"}`;
|
|
38639
38750
|
const MEMORY_TURNS = 20;
|
|
@@ -38643,19 +38754,17 @@ function registerOsRoutes(ctx) {
|
|
|
38643
38754
|
content: r.content
|
|
38644
38755
|
}));
|
|
38645
38756
|
let provider;
|
|
38646
|
-
|
|
38647
|
-
provider = kernelContainer.db
|
|
38648
|
-
|
|
38649
|
-
|
|
38650
|
-
|
|
38651
|
-
)
|
|
38652
|
-
}
|
|
38653
|
-
|
|
38654
|
-
if (!provider || !provider.api_key || !provider.api_key.trim()) {
|
|
38655
|
-
return res.status(400).json({
|
|
38757
|
+
try {
|
|
38758
|
+
provider = resolveAccessibleAiProvider(kernelContainer.db, {
|
|
38759
|
+
providerId,
|
|
38760
|
+
userId,
|
|
38761
|
+
lang
|
|
38762
|
+
});
|
|
38763
|
+
} catch (err) {
|
|
38764
|
+
return res.status(err.status || 400).json({
|
|
38656
38765
|
success: false,
|
|
38657
|
-
error: "NO_AI_PROVIDER",
|
|
38658
|
-
message:
|
|
38766
|
+
error: err.code || "NO_AI_PROVIDER",
|
|
38767
|
+
message: err.message
|
|
38659
38768
|
});
|
|
38660
38769
|
}
|
|
38661
38770
|
const actorId = getActorId(req);
|
|
@@ -42119,7 +42228,7 @@ Provide a short, friendly, and helpful hint (1-2 sentences) directly related to
|
|
|
42119
42228
|
* 的按学科硬编码字符串拼接,与课题内容无关。此方法用真实的 LLM 调用取代它。
|
|
42120
42229
|
*/
|
|
42121
42230
|
async generateOutlineWithAi(params) {
|
|
42122
|
-
const { title, category, description, timeline, lang = "zh" } = params;
|
|
42231
|
+
const { title, category, description, timeline, lang = "zh", providerId, userId } = params;
|
|
42123
42232
|
const trimmedTitle = (title || "").trim();
|
|
42124
42233
|
if (!trimmedTitle) {
|
|
42125
42234
|
const err = new Error(lang === "zh" ? "\u8BFE\u7A0B\u8BFE\u9898\u4E0D\u80FD\u4E3A\u7A7A" : "Course title is required");
|
|
@@ -42159,7 +42268,12 @@ ${timelineText}` : "- \u8BFE\u5802\u65F6\u95F4\u8F74\u73AF\u8282\uFF1A\uFF08\u65
|
|
|
42159
42268
|
- \`## \u{1F4E5} \u8BFE\u540E\u4F5C\u4E1A\`\uFF1A\u4E00\u4EFD\u660E\u786E\u7684\u8BFE\u540E\u4EFB\u52A1\u4E0E\u63D0\u4EA4\u8981\u6C42\u3002
|
|
42160
42269
|
4. \u5185\u5BB9\u5FC5\u987B\u7D27\u6263\u8BFE\u9898\u672C\u8EAB\uFF0C\u7981\u6B62\u4F7F\u7528"\u516C\u5F0F A""\u6A21\u5757 1"\u4E4B\u7C7B\u7684\u5360\u4F4D\u7B26\u6216\u4E0E\u8BFE\u9898\u65E0\u5173\u7684\u901A\u7528\u5957\u8BDD\u3002
|
|
42161
42270
|
5. \u7BC7\u5E45\u63A7\u5236\u5728 600-1000 \u5B57\uFF0C\u8BED\u8A00${lang === "zh" ? "\u4F7F\u7528\u7B80\u4F53\u4E2D\u6587" : "use English"}\u3002`;
|
|
42162
|
-
const raw = await this.aiService.generateText(prompt, {
|
|
42271
|
+
const raw = await this.aiService.generateText(prompt, {
|
|
42272
|
+
temperature: 0.6,
|
|
42273
|
+
maxTokens: 2048,
|
|
42274
|
+
userId,
|
|
42275
|
+
providerId
|
|
42276
|
+
});
|
|
42163
42277
|
const outline = stripReasoningArtifacts(stripOuterCodeFence(String(raw || "").trim()));
|
|
42164
42278
|
if (!outline) {
|
|
42165
42279
|
const err = new Error(
|
|
@@ -42423,13 +42537,17 @@ function registerLessonsRoutes(ctx) {
|
|
|
42423
42537
|
safeAiLimiter,
|
|
42424
42538
|
async (req, res) => {
|
|
42425
42539
|
try {
|
|
42426
|
-
const { title, category, description, timeline, lang } = req.body || {};
|
|
42540
|
+
const { title, category, description, timeline, lang, providerId } = req.body || {};
|
|
42541
|
+
const session = req.session;
|
|
42542
|
+
const userId = session?.userId || session?.username;
|
|
42427
42543
|
const content = await lessonService.generateOutlineWithAi({
|
|
42428
42544
|
title,
|
|
42429
42545
|
category,
|
|
42430
42546
|
description,
|
|
42431
42547
|
timeline,
|
|
42432
|
-
lang
|
|
42548
|
+
lang,
|
|
42549
|
+
providerId,
|
|
42550
|
+
userId
|
|
42433
42551
|
});
|
|
42434
42552
|
res.json({ success: true, content });
|
|
42435
42553
|
} catch (e) {
|
|
@@ -48515,7 +48633,7 @@ var ScheduleService = class {
|
|
|
48515
48633
|
* Timetable OCR 课表图像识别与数据清洗
|
|
48516
48634
|
*/
|
|
48517
48635
|
async processTimetableOcr(imageBase64, options = {}) {
|
|
48518
|
-
const { lang = "zh", providerId } = options;
|
|
48636
|
+
const { lang = "zh", providerId, userId } = options;
|
|
48519
48637
|
if (!imageBase64) {
|
|
48520
48638
|
throw new Error("imageBase64 is required");
|
|
48521
48639
|
}
|
|
@@ -48546,21 +48664,11 @@ var ScheduleService = class {
|
|
|
48546
48664
|
|
|
48547
48665
|
\u8FD4\u56DE\u683C\u5F0F\u793A\u4F8B\uFF1A
|
|
48548
48666
|
[{"dayOfWeek":1,"periodNumber":1,"className":"\u9AD8\u4E00(13)","subject":"\u4FE1\u606F","timeSlot":"08:00-08:40","location":"312","teacherName":""}]`;
|
|
48549
|
-
|
|
48550
|
-
|
|
48551
|
-
|
|
48552
|
-
|
|
48553
|
-
|
|
48554
|
-
provider = this.db.prepare(
|
|
48555
|
-
"SELECT id, name, api_url, api_key, model_name FROM ai_providers WHERE api_key IS NOT NULL AND api_key != '' ORDER BY created_at DESC LIMIT 1"
|
|
48556
|
-
).get();
|
|
48557
|
-
}
|
|
48558
|
-
if (provider?.api_key) provider.api_key = decryptApiKey(provider.api_key);
|
|
48559
|
-
if (!provider || !provider.api_key || !provider.api_key.trim()) {
|
|
48560
|
-
throw new Error(
|
|
48561
|
-
lang === "zh" ? "\u672A\u68C0\u6D4B\u5230\u53EF\u7528\u7684 AI \u63D0\u4F9B\u5546\u3002\u8BF7\u524D\u5F80\u300C\u7CFB\u7EDF\u7BA1\u7406 -> AI \u63D0\u4F9B\u5546\u7BA1\u7406\u300D\u6DFB\u52A0\u5E76\u914D\u7F6E\u5927\u6A21\u578B\u670D\u52A1\u3002" : 'No AI provider configured. Please add and configure an AI Provider in "System Management -> AI Provider Management".'
|
|
48562
|
-
);
|
|
48563
|
-
}
|
|
48667
|
+
const provider = resolveAccessibleAiProvider(this.db, {
|
|
48668
|
+
providerId,
|
|
48669
|
+
userId,
|
|
48670
|
+
lang
|
|
48671
|
+
});
|
|
48564
48672
|
let chatUrl = provider.api_url.trim();
|
|
48565
48673
|
if (!chatUrl.endsWith("/chat/completions")) {
|
|
48566
48674
|
chatUrl = chatUrl.endsWith("/") ? chatUrl + "chat/completions" : chatUrl + "/chat/completions";
|
|
@@ -48776,7 +48884,9 @@ function registerSchedulesRoutes(ctx) {
|
|
|
48776
48884
|
const startTime = Date.now();
|
|
48777
48885
|
try {
|
|
48778
48886
|
const { imageBase64, lang = "zh", providerId } = req.body;
|
|
48779
|
-
const
|
|
48887
|
+
const session = req.session;
|
|
48888
|
+
const userId = session?.userId || session?.username;
|
|
48889
|
+
const result = await scheduleService.processTimetableOcr(imageBase64, { lang, providerId, userId });
|
|
48780
48890
|
res.json({
|
|
48781
48891
|
success: true,
|
|
48782
48892
|
entries: result.entries,
|
|
@@ -49162,7 +49272,7 @@ var GradingService = class {
|
|
|
49162
49272
|
}
|
|
49163
49273
|
// ── 6. AI 学期综合总评评语生成 ────────────────────────────────────────────────
|
|
49164
49274
|
async generateSemesterAiEvaluation(params) {
|
|
49165
|
-
const { classId, studentId, semesterName = "2026\u5E74\u6625\u5B63\u5B66\u671F", providerId } = params;
|
|
49275
|
+
const { classId, studentId, semesterName = "2026\u5E74\u6625\u5B63\u5B66\u671F", providerId, userId } = params;
|
|
49166
49276
|
const student = this.db.prepare("SELECT name FROM students WHERE id = ?").get(studentId);
|
|
49167
49277
|
if (!student) {
|
|
49168
49278
|
throw new Error("Student not found");
|
|
@@ -49220,13 +49330,10 @@ ${examsText}
|
|
|
49220
49330
|
- \u6307\u51FA\u5176\u53EF\u4EE5\u6539\u8FDB\u7684\u5730\u65B9\uFF08\u5982\u8FDB\u5EA6\u843D\u540E\u3001\u8003\u8BD5\u53D1\u6325\u4E0D\u4F73\u7B49\uFF09\uFF0C\u8BED\u6C14\u8981\u975E\u5E38\u6E29\u67D4\u3001\u59D4\u5A49\uFF0C\u7ED9\u4E88\u5176\u4FE1\u5FC3\u3002
|
|
49221
49331
|
- \u5BF9\u672A\u6765\u7684\u671F\u8BB8\uFF0C\u6FC0\u52B1\u5B66\u751F\u5728\u4E0B\u5B66\u671F\u7EE7\u7EED\u52AA\u529B\u3002
|
|
49222
49332
|
3. \u957F\u5EA6\u63A7\u5236\u5728 150-250 \u5B57\u4E4B\u95F4\u3002\u4E0D\u8981\u5305\u542B\u4EFB\u4F55 Markdown \u683C\u5F0F\uFF0C\u53EA\u8FD4\u56DE\u7EAF\u6587\u672C\u8BC4\u8BED\u3002`;
|
|
49223
|
-
const provider =
|
|
49224
|
-
|
|
49225
|
-
|
|
49226
|
-
|
|
49227
|
-
if (!provider || !provider.api_key || !provider.api_key.trim()) {
|
|
49228
|
-
throw new Error("\u672A\u68C0\u6D4B\u5230\u53EF\u7528\u7684 AI \u63D0\u4F9B\u5546\u3002\u8BF7\u524D\u5F80\u300C\u7CFB\u7EDF\u7BA1\u7406 -> AI \u63D0\u4F9B\u5546\u7BA1\u7406\u300D\u6DFB\u52A0\u5E76\u914D\u7F6E\u5927\u6A21\u578B\u670D\u52A1\u3002");
|
|
49229
|
-
}
|
|
49333
|
+
const provider = resolveAccessibleAiProvider(this.db, {
|
|
49334
|
+
providerId,
|
|
49335
|
+
userId
|
|
49336
|
+
});
|
|
49230
49337
|
let chatUrl = provider.api_url.trim();
|
|
49231
49338
|
if (!chatUrl.endsWith("/chat/completions")) {
|
|
49232
49339
|
chatUrl = chatUrl.endsWith("/") ? chatUrl + "chat/completions" : chatUrl + "/chat/completions";
|
|
@@ -49499,11 +49606,14 @@ function registerGradingRoutes(ctx) {
|
|
|
49499
49606
|
const { classId, studentId } = req.params;
|
|
49500
49607
|
if (gate(req, res, classId, "staff")) return;
|
|
49501
49608
|
const { semesterName = "2026\u5E74\u6625\u5B63\u5B66\u671F", providerId } = req.body;
|
|
49609
|
+
const session = req.session;
|
|
49610
|
+
const userId = session?.userId || session?.username;
|
|
49502
49611
|
const aiEvaluation = await gradingService.generateSemesterAiEvaluation({
|
|
49503
49612
|
classId,
|
|
49504
49613
|
studentId,
|
|
49505
49614
|
semesterName,
|
|
49506
|
-
providerId
|
|
49615
|
+
providerId,
|
|
49616
|
+
userId
|
|
49507
49617
|
});
|
|
49508
49618
|
res.json({ success: true, aiEvaluation });
|
|
49509
49619
|
} catch (e) {
|
|
@@ -49931,7 +50041,8 @@ async function pluginApiGatewayMiddleware(req, res, next) {
|
|
|
49931
50041
|
}
|
|
49932
50042
|
}
|
|
49933
50043
|
const clientIp = req.ip || req.socket?.remoteAddress || "unknown-ip";
|
|
49934
|
-
const
|
|
50044
|
+
const identityKey = auth.isAuthenticated ? `u:${String(auth.userId || auth.username || auth.actorId).slice(0, 64)}` : `ip:${clientIp}`;
|
|
50045
|
+
const rateLimitKey = `${identityKey}:${resolvedUuid || pluginId}:${method}:${normalizedSubPath}`;
|
|
49935
50046
|
const windowMs = matchedRule?.rateLimit?.windowMs ?? 60 * 1e3;
|
|
49936
50047
|
const maxRequests = matchedRule?.rateLimit?.max ?? 120;
|
|
49937
50048
|
if (!rateLimiter.consume(rateLimitKey, maxRequests, windowMs)) {
|
|
@@ -50920,16 +51031,9 @@ function listInstalledManifestVersions() {
|
|
|
50920
51031
|
return installed;
|
|
50921
51032
|
}
|
|
50922
51033
|
function registerPluginsRoutes(ctx) {
|
|
50923
|
-
const {
|
|
50924
|
-
|
|
50925
|
-
|
|
50926
|
-
buildOpenAITools: buildOpenAITools2,
|
|
50927
|
-
executeAgentToolCall: executeAgentToolCall2,
|
|
50928
|
-
buildOpenAIChatUrl: buildOpenAIChatUrl2,
|
|
50929
|
-
runGeminiAgentChat: runGeminiAgentChat2,
|
|
50930
|
-
runOpenAIAgentChat: runOpenAIAgentChat2
|
|
50931
|
-
} = ctx;
|
|
50932
|
-
const safeAiLimiter = typeof aiLimiter === "function" ? aiLimiter : (_req, _res, next) => next();
|
|
51034
|
+
const { app, aiLimiter } = ctx;
|
|
51035
|
+
const noopMiddleware = (_req, _res, next) => next();
|
|
51036
|
+
const safeAiLimiter = typeof aiLimiter === "function" ? aiLimiter : noopMiddleware;
|
|
50933
51037
|
app.get("/api/docs/plugin-guide", (req, res) => {
|
|
50934
51038
|
try {
|
|
50935
51039
|
const docPath = import_path22.default.join(process.cwd(), "docs_plugin_guide.md");
|
|
@@ -51965,9 +52069,13 @@ function registerPluginsRoutes(ctx) {
|
|
|
51965
52069
|
}
|
|
51966
52070
|
});
|
|
51967
52071
|
app.all("/api/plugins/:pluginId/*", pluginApiGatewayMiddleware);
|
|
51968
|
-
app.get("/api/ai-providers",
|
|
52072
|
+
app.get("/api/ai-providers", requireScopedAuth("self", "teacher", "administrator"), (req, res) => {
|
|
51969
52073
|
try {
|
|
51970
|
-
const
|
|
52074
|
+
const session = req.session;
|
|
52075
|
+
const currentUserId = session?.userId || session?.username;
|
|
52076
|
+
const providers = kernelContainer.db.prepare(
|
|
52077
|
+
"SELECT id, name, api_url, api_key, model_name, owner_id, is_shared, created_at, updated_at FROM ai_providers WHERE (owner_id = ? OR is_shared = 1) ORDER BY created_at DESC"
|
|
52078
|
+
).all(currentUserId);
|
|
51971
52079
|
const masked = providers.map((p) => {
|
|
51972
52080
|
let apiKey;
|
|
51973
52081
|
try {
|
|
@@ -51975,54 +52083,122 @@ function registerPluginsRoutes(ctx) {
|
|
|
51975
52083
|
} catch {
|
|
51976
52084
|
apiKey = "[UNDECRYPTABLE]";
|
|
51977
52085
|
}
|
|
51978
|
-
return {
|
|
52086
|
+
return {
|
|
52087
|
+
...p,
|
|
52088
|
+
api_key: apiKey,
|
|
52089
|
+
is_shared: Number(p.is_shared) === 1
|
|
52090
|
+
};
|
|
51979
52091
|
});
|
|
51980
52092
|
res.json(masked);
|
|
51981
52093
|
} catch (e) {
|
|
51982
52094
|
sendSafeError(res, e);
|
|
51983
52095
|
}
|
|
51984
52096
|
});
|
|
51985
|
-
app.post("/api/ai-providers",
|
|
52097
|
+
app.post("/api/ai-providers", requireScopedAuth("self", "teacher", "administrator"), (req, res) => {
|
|
51986
52098
|
try {
|
|
51987
|
-
const
|
|
52099
|
+
const session = req.session;
|
|
52100
|
+
const currentUserId = session?.userId || session?.username;
|
|
52101
|
+
const isAdmin = isAdministrator2(session);
|
|
52102
|
+
const { name, api_url, api_key, model_name, is_shared } = req.body;
|
|
51988
52103
|
if (!name || !api_url || !model_name) {
|
|
51989
52104
|
return res.status(400).json({ error: "Missing name, api_url or model_name" });
|
|
51990
52105
|
}
|
|
52106
|
+
const wantsShared = is_shared === 1 || is_shared === true || is_shared === "1";
|
|
52107
|
+
if (wantsShared && !isAdmin) {
|
|
52108
|
+
return res.status(403).json({ error: "Only administrators can create platform shared providers" });
|
|
52109
|
+
}
|
|
51991
52110
|
const id = "prov_" + Date.now();
|
|
51992
52111
|
const now = Date.now();
|
|
51993
52112
|
const encryptedKey = api_key ? encryptApiKey(api_key) : "";
|
|
51994
52113
|
kernelContainer.db.prepare(
|
|
51995
|
-
"INSERT INTO ai_providers (id, name, api_url, api_key, model_name, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)"
|
|
51996
|
-
).run(
|
|
52114
|
+
"INSERT INTO ai_providers (id, name, api_url, api_key, model_name, owner_id, is_shared, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
|
52115
|
+
).run(
|
|
52116
|
+
id,
|
|
52117
|
+
name.trim(),
|
|
52118
|
+
api_url.trim(),
|
|
52119
|
+
encryptedKey,
|
|
52120
|
+
model_name.trim(),
|
|
52121
|
+
currentUserId,
|
|
52122
|
+
wantsShared && isAdmin ? 1 : 0,
|
|
52123
|
+
now,
|
|
52124
|
+
now
|
|
52125
|
+
);
|
|
51997
52126
|
res.json({ success: true, id });
|
|
51998
52127
|
} catch (e) {
|
|
51999
52128
|
sendSafeError(res, e);
|
|
52000
52129
|
}
|
|
52001
52130
|
});
|
|
52002
|
-
app.put("/api/ai-providers/:id",
|
|
52131
|
+
app.put("/api/ai-providers/:id", requireScopedAuth("self", "teacher", "administrator"), (req, res) => {
|
|
52003
52132
|
try {
|
|
52004
|
-
const
|
|
52133
|
+
const session = req.session;
|
|
52134
|
+
const currentUserId = session?.userId || session?.username;
|
|
52135
|
+
const isAdmin = isAdministrator2(session);
|
|
52136
|
+
const { name, api_url, api_key, model_name, is_shared } = req.body;
|
|
52005
52137
|
if (!name || !api_url || !model_name) {
|
|
52006
52138
|
return res.status(400).json({ error: "Missing name, api_url or model_name" });
|
|
52007
52139
|
}
|
|
52140
|
+
const existing = kernelContainer.db.prepare("SELECT id, api_key, owner_id, is_shared FROM ai_providers WHERE id = ?").get(req.params.id);
|
|
52141
|
+
if (!existing) {
|
|
52142
|
+
return res.status(404).json({ error: "AI provider not found" });
|
|
52143
|
+
}
|
|
52144
|
+
const isExistingShared = Number(existing.is_shared) === 1;
|
|
52145
|
+
if (isExistingShared) {
|
|
52146
|
+
if (!isAdmin) {
|
|
52147
|
+
return res.status(403).json({ error: "Forbidden: Only administrators can modify platform shared providers" });
|
|
52148
|
+
}
|
|
52149
|
+
} else {
|
|
52150
|
+
if (existing.owner_id !== currentUserId) {
|
|
52151
|
+
return res.status(403).json({
|
|
52152
|
+
error: "Forbidden: You do not have permission to modify another user's private AI provider"
|
|
52153
|
+
});
|
|
52154
|
+
}
|
|
52155
|
+
}
|
|
52156
|
+
let nextShared = existing.is_shared ?? 0;
|
|
52157
|
+
if (is_shared !== void 0) {
|
|
52158
|
+
const wantsShared = is_shared === 1 || is_shared === true || is_shared === "1";
|
|
52159
|
+
if (wantsShared !== (Number(existing.is_shared) === 1)) {
|
|
52160
|
+
if (!isAdmin) {
|
|
52161
|
+
return res.status(403).json({ error: "Forbidden: Only administrators can change platform shared status" });
|
|
52162
|
+
}
|
|
52163
|
+
nextShared = wantsShared ? 1 : 0;
|
|
52164
|
+
}
|
|
52165
|
+
}
|
|
52008
52166
|
const now = Date.now();
|
|
52009
52167
|
let finalKey;
|
|
52010
52168
|
if (api_key && api_key.trim() !== "" && !api_key.includes("****")) {
|
|
52011
52169
|
finalKey = encryptApiKey(api_key);
|
|
52012
52170
|
} else {
|
|
52013
|
-
const existing = kernelContainer.db.prepare("SELECT api_key FROM ai_providers WHERE id = ?").get(req.params.id);
|
|
52014
52171
|
finalKey = existing?.api_key || "";
|
|
52015
52172
|
}
|
|
52016
52173
|
kernelContainer.db.prepare(
|
|
52017
|
-
"UPDATE ai_providers SET name = ?, api_url = ?, api_key = ?, model_name = ?, updated_at = ? WHERE id = ?"
|
|
52018
|
-
).run(name, api_url, finalKey, model_name, now, req.params.id);
|
|
52174
|
+
"UPDATE ai_providers SET name = ?, api_url = ?, api_key = ?, model_name = ?, is_shared = ?, updated_at = ? WHERE id = ?"
|
|
52175
|
+
).run(name.trim(), api_url.trim(), finalKey, model_name.trim(), nextShared, now, req.params.id);
|
|
52019
52176
|
res.json({ success: true });
|
|
52020
52177
|
} catch (e) {
|
|
52021
52178
|
sendSafeError(res, e);
|
|
52022
52179
|
}
|
|
52023
52180
|
});
|
|
52024
|
-
app.delete("/api/ai-providers/:id",
|
|
52181
|
+
app.delete("/api/ai-providers/:id", requireScopedAuth("self", "teacher", "administrator"), (req, res) => {
|
|
52025
52182
|
try {
|
|
52183
|
+
const session = req.session;
|
|
52184
|
+
const currentUserId = session?.userId || session?.username;
|
|
52185
|
+
const isAdmin = isAdministrator2(session);
|
|
52186
|
+
const existing = kernelContainer.db.prepare("SELECT id, owner_id, is_shared FROM ai_providers WHERE id = ?").get(req.params.id);
|
|
52187
|
+
if (!existing) {
|
|
52188
|
+
return res.status(404).json({ error: "AI provider not found" });
|
|
52189
|
+
}
|
|
52190
|
+
const isExistingShared = Number(existing.is_shared) === 1;
|
|
52191
|
+
if (isExistingShared) {
|
|
52192
|
+
if (!isAdmin) {
|
|
52193
|
+
return res.status(403).json({ error: "Forbidden: Only administrators can delete platform shared providers" });
|
|
52194
|
+
}
|
|
52195
|
+
} else {
|
|
52196
|
+
if (existing.owner_id !== currentUserId) {
|
|
52197
|
+
return res.status(403).json({
|
|
52198
|
+
error: "Forbidden: You do not have permission to delete another user's private AI provider"
|
|
52199
|
+
});
|
|
52200
|
+
}
|
|
52201
|
+
}
|
|
52026
52202
|
kernelContainer.db.prepare("DELETE FROM ai_providers WHERE id = ?").run(req.params.id);
|
|
52027
52203
|
res.json({ success: true });
|
|
52028
52204
|
} catch (e) {
|
|
@@ -52105,65 +52281,82 @@ function registerPluginsRoutes(ctx) {
|
|
|
52105
52281
|
sendSafeError(res, e);
|
|
52106
52282
|
}
|
|
52107
52283
|
});
|
|
52108
|
-
app.post(
|
|
52109
|
-
|
|
52110
|
-
|
|
52111
|
-
|
|
52112
|
-
|
|
52113
|
-
}
|
|
52114
|
-
let api_key = "";
|
|
52115
|
-
if (providedKey && providedKey.includes("****")) {
|
|
52116
|
-
const existing = kernelContainer.db.prepare("SELECT api_key FROM ai_providers WHERE api_url = ? AND model_name = ? LIMIT 1").get(api_url, model_name);
|
|
52117
|
-
api_key = existing ? decryptApiKey(existing.api_key) : "";
|
|
52118
|
-
} else if (providedKey) {
|
|
52119
|
-
api_key = providedKey.includes(":") ? decryptApiKey(providedKey) : providedKey;
|
|
52120
|
-
}
|
|
52121
|
-
let cleanUrl = api_url.trim();
|
|
52122
|
-
if (!cleanUrl.endsWith("/chat/completions")) {
|
|
52123
|
-
cleanUrl = cleanUrl.endsWith("/") ? cleanUrl + "chat/completions" : cleanUrl + "/chat/completions";
|
|
52124
|
-
}
|
|
52125
|
-
const urlSafety = isSafeExternalUrl(cleanUrl);
|
|
52126
|
-
if (!urlSafety.safe) {
|
|
52127
|
-
return res.status(400).json({ error: `Blocked unsafe AI provider URL: ${urlSafety.reason}` });
|
|
52128
|
-
}
|
|
52129
|
-
let response;
|
|
52284
|
+
app.post(
|
|
52285
|
+
"/api/ai-providers/test",
|
|
52286
|
+
requireScopedAuth("self", "teacher", "administrator"),
|
|
52287
|
+
safeAiLimiter,
|
|
52288
|
+
async (req, res) => {
|
|
52130
52289
|
try {
|
|
52131
|
-
|
|
52132
|
-
|
|
52133
|
-
|
|
52134
|
-
|
|
52135
|
-
|
|
52136
|
-
|
|
52137
|
-
|
|
52138
|
-
|
|
52139
|
-
|
|
52140
|
-
|
|
52141
|
-
|
|
52142
|
-
|
|
52143
|
-
|
|
52144
|
-
|
|
52145
|
-
|
|
52146
|
-
|
|
52147
|
-
|
|
52290
|
+
const session = req.session;
|
|
52291
|
+
const currentUserId = session?.userId || session?.username;
|
|
52292
|
+
const { api_url, api_key: providedKey, model_name, id } = req.body;
|
|
52293
|
+
if (!api_url || !model_name) {
|
|
52294
|
+
return res.status(400).json({ error: "api_url and model_name are required" });
|
|
52295
|
+
}
|
|
52296
|
+
let api_key = "";
|
|
52297
|
+
if (providedKey && providedKey.includes("****")) {
|
|
52298
|
+
let existing;
|
|
52299
|
+
if (id) {
|
|
52300
|
+
existing = kernelContainer.db.prepare("SELECT api_key FROM ai_providers WHERE id = ? AND (owner_id = ? OR is_shared = 1)").get(id, currentUserId);
|
|
52301
|
+
} else {
|
|
52302
|
+
existing = kernelContainer.db.prepare(
|
|
52303
|
+
"SELECT api_key FROM ai_providers WHERE api_url = ? AND model_name = ? AND (owner_id = ? OR is_shared = 1) LIMIT 1"
|
|
52304
|
+
).get(api_url, model_name, currentUserId);
|
|
52305
|
+
}
|
|
52306
|
+
if (!existing) {
|
|
52307
|
+
return res.status(403).json({ error: "Forbidden: Cannot access key for this provider" });
|
|
52308
|
+
}
|
|
52309
|
+
api_key = decryptApiKey(existing.api_key);
|
|
52310
|
+
} else if (providedKey) {
|
|
52311
|
+
api_key = providedKey.includes(":") ? decryptApiKey(providedKey) : providedKey;
|
|
52312
|
+
}
|
|
52313
|
+
let cleanUrl = api_url.trim();
|
|
52314
|
+
if (!cleanUrl.endsWith("/chat/completions")) {
|
|
52315
|
+
cleanUrl = cleanUrl.endsWith("/") ? cleanUrl + "chat/completions" : cleanUrl + "/chat/completions";
|
|
52316
|
+
}
|
|
52317
|
+
const urlSafety = isSafeExternalUrl(cleanUrl);
|
|
52318
|
+
if (!urlSafety.safe) {
|
|
52319
|
+
return res.status(400).json({ error: `Blocked unsafe AI provider URL: ${urlSafety.reason}` });
|
|
52320
|
+
}
|
|
52321
|
+
let response;
|
|
52322
|
+
try {
|
|
52323
|
+
response = await fetchWithSafeRedirects(cleanUrl, {
|
|
52324
|
+
method: "POST",
|
|
52325
|
+
headers: {
|
|
52326
|
+
"Content-Type": "application/json",
|
|
52327
|
+
Authorization: `Bearer ${api_key || ""}`
|
|
52328
|
+
},
|
|
52329
|
+
body: JSON.stringify({
|
|
52330
|
+
model: model_name,
|
|
52331
|
+
messages: [{ role: "user", content: "Say connected" }],
|
|
52332
|
+
max_tokens: 5
|
|
52333
|
+
}),
|
|
52334
|
+
timeoutMs: 1e4
|
|
52335
|
+
});
|
|
52336
|
+
} catch (err) {
|
|
52337
|
+
const isTimeout = err instanceof AIFetchTimeoutError || err?.name === "TimeoutError" || err?.name === "AbortError" || /abort|timeout/i.test(err?.message ?? "");
|
|
52338
|
+
if (isTimeout) {
|
|
52339
|
+
return res.status(504).json({
|
|
52340
|
+
success: false,
|
|
52341
|
+
error: "AI Provider request timed out after 10000ms. Please verify the URL or provider availability."
|
|
52342
|
+
});
|
|
52343
|
+
}
|
|
52344
|
+
throw err;
|
|
52345
|
+
}
|
|
52346
|
+
const responseText = await response.text();
|
|
52347
|
+
if (response.ok) {
|
|
52348
|
+
res.json({ success: true, message: "Successfully connected and received response." });
|
|
52349
|
+
} else {
|
|
52350
|
+
res.status(response.status).json({
|
|
52148
52351
|
success: false,
|
|
52149
|
-
error:
|
|
52352
|
+
error: `API responded with status ${response.status}: ${responseText.slice(0, 200)}`
|
|
52150
52353
|
});
|
|
52151
52354
|
}
|
|
52152
|
-
|
|
52153
|
-
|
|
52154
|
-
const responseText = await response.text();
|
|
52155
|
-
if (response.ok) {
|
|
52156
|
-
res.json({ success: true, message: "Successfully connected and received response." });
|
|
52157
|
-
} else {
|
|
52158
|
-
res.status(response.status).json({
|
|
52159
|
-
success: false,
|
|
52160
|
-
error: `API responded with status ${response.status}: ${responseText.slice(0, 200)}`
|
|
52161
|
-
});
|
|
52355
|
+
} catch (e) {
|
|
52356
|
+
sendSafeError(res, e, 500, "Connection failed");
|
|
52162
52357
|
}
|
|
52163
|
-
} catch (e) {
|
|
52164
|
-
sendSafeError(res, e, 500, "Connection failed");
|
|
52165
52358
|
}
|
|
52166
|
-
|
|
52359
|
+
);
|
|
52167
52360
|
}
|
|
52168
52361
|
|
|
52169
52362
|
// server/services/assessment-service.ts
|
|
@@ -58608,12 +58801,16 @@ async function startServer() {
|
|
|
58608
58801
|
const defaultWriteLimit = process.env.PLAYWRIGHT_TEST ? 1e4 : isDev ? 600 : 180;
|
|
58609
58802
|
const writeLimiter = createWriteLimiter(defaultWriteLimit);
|
|
58610
58803
|
const viewStateLimiter = createWriteLimiter(process.env.PLAYWRIGHT_TEST ? 1e4 : isDev ? 600 : 120);
|
|
58804
|
+
const pluginApiLimiter = createSessionWriteLimiter(process.env.PLAYWRIGHT_TEST ? 1e4 : 600);
|
|
58611
58805
|
const aiLimiter = createAiLimiter(process.env.PLAYWRIGHT_TEST ? 1e3 : 10);
|
|
58612
58806
|
app.use((req, res, next) => {
|
|
58613
58807
|
if (req.method === "POST" || req.method === "PUT" || req.method === "DELETE") {
|
|
58614
58808
|
if (/^\/api\/classroom\/sessions\/[^/]+\/view-state\/?$/.test(req.path)) {
|
|
58615
58809
|
return viewStateLimiter(req, res, next);
|
|
58616
58810
|
}
|
|
58811
|
+
if (req.path.startsWith("/api/plugins/")) {
|
|
58812
|
+
return pluginApiLimiter(req, res, next);
|
|
58813
|
+
}
|
|
58617
58814
|
return writeLimiter(req, res, next);
|
|
58618
58815
|
}
|
|
58619
58816
|
next();
|