openlearn-next 0.7.9 → 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 +13 -4
- package/cli-data.mjs +1 -1
- package/dist/assets/{ActivityWorkspaceWidget-DbspLRxB.js → ActivityWorkspaceWidget-TtBuNORs.js} +1 -1
- package/dist/assets/AdminDirectoryView-D03WOMc-.js +5 -0
- package/dist/assets/{AssessmentCenter-DbJAcTvr.js → AssessmentCenter-MUvoJySb.js} +1 -1
- package/dist/assets/{AssignmentFilePreview-DEVTb4YJ.js → AssignmentFilePreview-dVhnTYmq.js} +1 -1
- package/dist/assets/{BatchPickerModal-czw6eA6e.js → BatchPickerModal-QALdhrX6.js} +1 -1
- package/dist/assets/{ClassesView-B1F0tTtB.js → ClassesView-CKwlRz-A.js} +5 -5
- package/dist/assets/{ClassroomCountdownWidget-C9pv3GLy.js → ClassroomCountdownWidget-DyWyForY.js} +1 -1
- package/dist/assets/{CloudDriveModal-goDB6dlC.js → CloudDriveModal-C8UAn3MO.js} +1 -1
- package/dist/assets/{ComputerLabView-Csqiqvw2.js → ComputerLabView-E5b3XTkk.js} +1 -1
- package/dist/assets/{CourseManagement-BD-4z2OZ.js → CourseManagement-3jSlq7lV.js} +1 -1
- package/dist/assets/{CourseWizardModal-NXwhuph_.js → CourseWizardModal-BAkW5VJX.js} +1 -1
- package/dist/assets/{CoursewareHubPanel-BuKp0mmW.js → CoursewareHubPanel-BkjcusjZ.js} +1 -1
- package/dist/assets/{Dashboard-CFDWNYSw.js → Dashboard-Ha2fZAqD.js} +2 -2
- package/dist/assets/{ExportWeightModal-dBxYVUP4.js → ExportWeightModal-D2Y13384.js} +1 -1
- package/dist/assets/{HelpTour-D3QcGDUz.js → HelpTour-DBq2otcV.js} +1 -1
- package/dist/assets/{HelpView-DSZC-1F0.js → HelpView-BVLRnMxl.js} +4 -4
- package/dist/assets/{ImportLessonsModal-m_s44eus.js → ImportLessonsModal-CQlq4b81.js} +1 -1
- package/dist/assets/ImportModal-Ct0I5cAL.js +3 -0
- package/dist/assets/{InteractiveCoursewareViewer-C7faFOFD.js → InteractiveCoursewareViewer-DGKh1gO8.js} +1 -1
- package/dist/assets/InteractiveWhiteboard-BbZlYpRu.js +65 -0
- package/dist/assets/{LazyCourseware-DNn0O8lA.js → LazyCourseware-zONqinCM.js} +2 -2
- package/dist/assets/{LazyWhiteboard-BxpydfnE.js → LazyWhiteboard-BV98LEZ8.js} +2 -2
- package/dist/assets/{LessonEditorView-CMCgnrJD.js → LessonEditorView-LXRkKxdY.js} +1 -1
- package/dist/assets/LiveClassroomView-TaHtHWkY.js +63 -0
- package/dist/assets/{NotificationDetailModal-BMrt09PY.js → NotificationDetailModal-BF_-pn0c.js} +1 -1
- package/dist/assets/{PluginView-B6gCF1ZB.js → PluginView-sVMPXae9.js} +1 -1
- package/dist/assets/{ProcessLogsModal-DmsOrZnV.js → ProcessLogsModal-D97xLgnL.js} +1 -1
- package/dist/assets/{QuizGeneratorModal-BNW6tZ78.js → QuizGeneratorModal-CNY8ISjR.js} +1 -1
- package/dist/assets/{StudentAssignmentEvalPanel-DMwBSOXf.js → StudentAssignmentEvalPanel-CUmM5DbT.js} +1 -1
- package/dist/assets/{StudentAssignmentView-cQpPme4_.js → StudentAssignmentView-B-1MtwKj.js} +1 -1
- package/dist/assets/{StudentLessonView-CpGgYV-b.js → StudentLessonView-CqrcvVND.js} +1 -1
- package/dist/assets/{StudentView-BfXi6-NQ.js → StudentView-Bi9kgSrN.js} +2 -2
- package/dist/assets/{SystemResourceLibraryModal-d4vfqM_9.js → SystemResourceLibraryModal-DDALKrJC.js} +1 -1
- package/dist/assets/{TeacherView-DSJCvFE-.js → TeacherView-C5JmoKHk.js} +2 -2
- package/dist/assets/{TimetableView-D9Rhnvfj.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-DO1mSl_I.js → student-workspace-C4UgD58C.js} +1 -1
- package/dist/assets/{vendor-icons-DLOPlM4i.js → vendor-icons-CaXLq01o.js} +1 -1
- package/dist/env-validation.cjs +14 -6
- package/dist/index.html +3 -3
- package/dist/server.cjs +1310 -778
- 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 +5 -3
- package/dist/assets/AdminDirectoryView-C2ZFIwHx.js +0 -5
- package/dist/assets/ImportModal-h5ShIMzO.js +0 -2
- package/dist/assets/InteractiveWhiteboard-10fno-bJ.js +0 -65
- package/dist/assets/LiveClassroomView-BA0us3bY.js +0 -61
- package/dist/assets/index-DCnGJbif.css +0 -1
- package/dist/assets/index-DuxQtTr9.js +0 -131
- package/dist/assets/pluginParsers-HRw6CVog.js +0 -1
package/dist/server.cjs
CHANGED
|
@@ -507,6 +507,74 @@ var init_plugin_metrics = __esm({
|
|
|
507
507
|
}
|
|
508
508
|
});
|
|
509
509
|
|
|
510
|
+
// packages/core/capability/catalog.ts
|
|
511
|
+
function validateManifestCapabilities(capabilities) {
|
|
512
|
+
const unknown = [];
|
|
513
|
+
for (const cap of capabilities) {
|
|
514
|
+
if (typeof cap !== "string" || !KNOWN_CAPABILITIES.has(cap)) {
|
|
515
|
+
unknown.push(typeof cap === "string" ? cap : String(cap));
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
return { valid: unknown.length === 0, unknown };
|
|
519
|
+
}
|
|
520
|
+
var CAP_API_WRITE, CAP_API_ADMIN, CAP_GRADES_WRITE, CAP_AGENT_TOOL, CAP_AI_INVOKE, CAP_TASK_REGISTER, CAP_STUDENT_DATA_READ, CAP_STUDENT_DATA_WRITE, CAP, CAPABILITIES, KNOWN_CAPABILITIES;
|
|
521
|
+
var init_catalog = __esm({
|
|
522
|
+
"packages/core/capability/catalog.ts"() {
|
|
523
|
+
"use strict";
|
|
524
|
+
CAP_API_WRITE = "api:write";
|
|
525
|
+
CAP_API_ADMIN = "api:admin";
|
|
526
|
+
CAP_GRADES_WRITE = "grades:write";
|
|
527
|
+
CAP_AGENT_TOOL = "agent:tool";
|
|
528
|
+
CAP_AI_INVOKE = "ai:invoke";
|
|
529
|
+
CAP_TASK_REGISTER = "task:register";
|
|
530
|
+
CAP_STUDENT_DATA_READ = "student-data:read";
|
|
531
|
+
CAP_STUDENT_DATA_WRITE = "student-data:write";
|
|
532
|
+
CAP = {
|
|
533
|
+
// ── 资源型(ActionDescriptor.capabilityRequired)──────────────────────
|
|
534
|
+
ASSIGNMENT_MANAGE: "assignment:manage",
|
|
535
|
+
ASSIGNMENT_READ: "assignment:read",
|
|
536
|
+
ASSIGNMENT_REVIEW: "assignment:review",
|
|
537
|
+
ASSIGNMENT_SUBMIT: "assignment:submit",
|
|
538
|
+
ASSIGNMENT_WRITE: "assignment:write",
|
|
539
|
+
LESSON_DELETE: "lesson:delete",
|
|
540
|
+
LESSON_READ: "lesson:read",
|
|
541
|
+
LESSON_WRITE: "lesson:write",
|
|
542
|
+
MANAGEMENT_READ: "management:read",
|
|
543
|
+
MANAGEMENT_WRITE: "management:write",
|
|
544
|
+
PLUGIN_READ: "plugin:read",
|
|
545
|
+
PLUGIN_WRITE: "plugin:write",
|
|
546
|
+
PROCESS_READ: "process:read",
|
|
547
|
+
PROCESS_WRITE: "process:write",
|
|
548
|
+
STUDENT_WRITE: "student:write",
|
|
549
|
+
VFS_READ: "vfs:read",
|
|
550
|
+
VFS_WRITE: "vfs:write",
|
|
551
|
+
WHITEBOARD_READ: "whiteboard:read",
|
|
552
|
+
WHITEBOARD_WRITE: "whiteboard:write",
|
|
553
|
+
// ── 历史上只出现在 capabilitiesProposed、从未作为 capabilityRequired ──
|
|
554
|
+
// 保留是为了不让内置 management 插件在升级后突然被标成「未知串」。
|
|
555
|
+
CLASS_READ: "class:read",
|
|
556
|
+
CLASS_WRITE: "class:write",
|
|
557
|
+
STUDENT_READ: "student:read",
|
|
558
|
+
// ── 方法级(method-policy / 服务代理的 Security Barrier 3)───────────
|
|
559
|
+
API_WRITE: CAP_API_WRITE,
|
|
560
|
+
API_ADMIN: CAP_API_ADMIN,
|
|
561
|
+
GRADES_WRITE: CAP_GRADES_WRITE,
|
|
562
|
+
AGENT_TOOL: CAP_AGENT_TOOL,
|
|
563
|
+
AI_INVOKE: CAP_AI_INVOKE,
|
|
564
|
+
TASK_REGISTER: CAP_TASK_REGISTER,
|
|
565
|
+
STUDENT_DATA_READ: CAP_STUDENT_DATA_READ,
|
|
566
|
+
STUDENT_DATA_WRITE: CAP_STUDENT_DATA_WRITE,
|
|
567
|
+
// ── 平台内部 ─────────────────────────────────────────────────────────
|
|
568
|
+
// 片段命令(segment gate)用,插件片段可声明;不在 AI 工具箱里。
|
|
569
|
+
SEGMENT_GATE_EVAL: "segment:gate_eval"
|
|
570
|
+
};
|
|
571
|
+
CAPABILITIES = Object.freeze(
|
|
572
|
+
Array.from(new Set(Object.values(CAP)))
|
|
573
|
+
);
|
|
574
|
+
KNOWN_CAPABILITIES = new Set(CAPABILITIES);
|
|
575
|
+
}
|
|
576
|
+
});
|
|
577
|
+
|
|
510
578
|
// packages/core/esm-loader/manifest-schema.ts
|
|
511
579
|
var import_zod, classroomToolSchema, helpDocSchema, quickActivitySchema, timelineSegmentSchema, paletteItemSchema, cockpitWidgetSchema, stageCardSchema, classroomTopbarActionSchema, classroomAttributionAwardSchema, studentCompetencyDimensionSchema, studentProfileWidgetSchema, canvasWidgetSchema, barometerMetricSchema, peerReviewRubricSchema, peerReviewBadgeSchema, preclassPasscodeActionSchema, preclassPasscodeAddonSchema, commandPaletteItemSchema, quickInsertItemSchema, aiPromptTemplateSchema, aiSkillProviderSchema, classTabSchema, classBatchActionSchema, studentRowPanelSchema, editorHeaderActionSchema, postClassWidgetSchema, coursewareViewerToolbarSchema, coursewareViewerOverlaySchema, portalTelemetrySchema, portalCourseBadgeSchema, portalTeachingModeSchema, portalInsightSchema, portalPreflightSchema, portalLaunchActionSchema, countdownWidgetSchema, countdownActionSchema, studentCountdownSchema, studentQuickActionsItemSchema, studentQuickActionsActionSchema, studentQuickActionsFabSchema, notificationTabsSchema, pacingDashboardSchema, diagnosticFeedSchema, collabCanvasSchema, topbarPillSchema, attributionActionSchema, leaderboardActionSchema, studentProfileActionSchema, studentProfileTimelineItemSchema, classroomHeaderActionSchema, classroomAgendaActionSchema, whiteboardDockPluginSchema, classroomAuditEventSchema, peerReviewActionSchema, peerReviewShowcaseWidgetSchema, seatingToolbarSchema, seatingLegendSchema, seatingSeatBadgeSchema, seatingSeatActionsSchema, seatingSummarySchema, whiteboardAutosaveStatusSchema, whiteboardAutosaveActionSchema, teacherPanelSchema, studentFullscreenSchema, globalSettingSchema, navUserMenuSchema, frontendSlotEntrySchema, frontendSlotsShape, pluginLocalesSchema, contributesSchema, deploySchema, apiRouteSchema, apiSchema, requiresItemSchema, manifestSchema, requiresItemV3Schema, manifestSchemaV3;
|
|
512
580
|
var init_manifest_schema = __esm({
|
|
@@ -1800,7 +1868,7 @@ var init_token = __esm({
|
|
|
1800
1868
|
});
|
|
1801
1869
|
|
|
1802
1870
|
// packages/core/di/interfaces.ts
|
|
1803
|
-
var ICommandBusServiceToken4, IEventBusServiceToken3, IActionRegistryServiceToken4, ICapabilityServiceToken, IProcessServiceToken2, IStorageServiceToken, IAIServiceToken, IDatabaseToken4, IPluginHostToken, IPluginLifecycleManagerToken, IPluginDistributionManagerToken, IPluginRuntimeCompositionToken, IUnifiedExtensionRegistryToken, IPluginCapabilityGatewayToken, ICapabilityRegistryToken, IAuthSessionBridgeToken, ISemesterGradeServiceToken, IPointsDimensionRegistryToken, IPointsLedgerServiceToken, ILessonEngineServiceToken, IClassroomRuntimeServiceToken, IPresenceEngineServiceToken, ITeachingCollaborationServiceToken, ILearningAnalyticsServiceToken, IAICapabilityServiceToken, ICapabilityRuntimeServiceToken, ICapabilityGovernanceServiceToken, IPlatformServiceRegistryToken, ICoursewareRuntimeScriptRegistryToken, IClassroomLifecycleServiceToken, IInteractionRuntimeServiceToken, IClassroomCountdownServiceToken, IAssessmentGraderRegistryToken, IStageGuardServiceToken, PLUGIN_RESOLVABLE_TOKENS, PLUGIN_RESOLVABLE_TOKEN_NAMES, INetworkProxyServiceToken;
|
|
1871
|
+
var ICommandBusServiceToken4, IEventBusServiceToken3, IActionRegistryServiceToken4, ICapabilityServiceToken, IProcessServiceToken2, IStorageServiceToken, IAIServiceToken, IDatabaseToken4, IPluginHostToken, IPluginLifecycleManagerToken, IPluginDistributionManagerToken, IPluginRuntimeCompositionToken, IUnifiedExtensionRegistryToken, IPluginCapabilityGatewayToken, ICapabilityRegistryToken, IAuthSessionBridgeToken, ISemesterGradeServiceToken, IPointsDimensionRegistryToken, IPointsLedgerServiceToken, IClassRosterServiceToken, ILessonEngineServiceToken, IClassroomRuntimeServiceToken, IPresenceEngineServiceToken, ITeachingCollaborationServiceToken, ILearningAnalyticsServiceToken, IAICapabilityServiceToken, ICapabilityRuntimeServiceToken, ICapabilityGovernanceServiceToken, IPlatformServiceRegistryToken, ICoursewareRuntimeScriptRegistryToken, IClassroomLifecycleServiceToken, IInteractionRuntimeServiceToken, IClassroomCountdownServiceToken, IAssessmentGraderRegistryToken, IStageGuardServiceToken, PLUGIN_RESOLVABLE_TOKENS, PLUGIN_RESOLVABLE_TOKEN_NAMES, INetworkProxyServiceToken;
|
|
1804
1872
|
var init_interfaces = __esm({
|
|
1805
1873
|
"packages/core/di/interfaces.ts"() {
|
|
1806
1874
|
"use strict";
|
|
@@ -1839,6 +1907,7 @@ var init_interfaces = __esm({
|
|
|
1839
1907
|
"@openlearn/core:IPointsDimensionRegistry"
|
|
1840
1908
|
);
|
|
1841
1909
|
IPointsLedgerServiceToken = new Token("@openlearn/core:IPointsLedgerService");
|
|
1910
|
+
IClassRosterServiceToken = new Token("@openlearn/core:IClassRosterService");
|
|
1842
1911
|
ILessonEngineServiceToken = new Token("@openlearn/core:ILessonEngineService");
|
|
1843
1912
|
IClassroomRuntimeServiceToken = new Token(
|
|
1844
1913
|
"@openlearn/core:IClassroomRuntimeService"
|
|
@@ -1889,6 +1958,7 @@ var init_interfaces = __esm({
|
|
|
1889
1958
|
ISemesterGradeServiceToken,
|
|
1890
1959
|
IPointsDimensionRegistryToken,
|
|
1891
1960
|
IPointsLedgerServiceToken,
|
|
1961
|
+
IClassRosterServiceToken,
|
|
1892
1962
|
ILessonEngineServiceToken,
|
|
1893
1963
|
IClassroomRuntimeServiceToken,
|
|
1894
1964
|
IPresenceEngineServiceToken,
|
|
@@ -3234,6 +3304,95 @@ var init_ai_event_bus = __esm({
|
|
|
3234
3304
|
}
|
|
3235
3305
|
});
|
|
3236
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
|
+
|
|
3237
3396
|
// packages/core/di/ai-service.ts
|
|
3238
3397
|
var AIService;
|
|
3239
3398
|
var init_ai_service = __esm({
|
|
@@ -3242,6 +3401,7 @@ var init_ai_service = __esm({
|
|
|
3242
3401
|
init_provider_gateway();
|
|
3243
3402
|
init_ai_event_bus();
|
|
3244
3403
|
init_api_key_crypto();
|
|
3404
|
+
init_ai_provider_resolver();
|
|
3245
3405
|
AIService = class {
|
|
3246
3406
|
constructor(db2) {
|
|
3247
3407
|
this.db = db2;
|
|
@@ -3289,16 +3449,11 @@ var init_ai_service = __esm({
|
|
|
3289
3449
|
this._contextDelegates.unregister?.(id);
|
|
3290
3450
|
}
|
|
3291
3451
|
async generateText(prompt, options) {
|
|
3292
|
-
const provider = this.db
|
|
3293
|
-
|
|
3294
|
-
|
|
3295
|
-
|
|
3296
|
-
|
|
3297
|
-
}
|
|
3298
|
-
const apiKey = decryptApiKey(provider.api_key);
|
|
3299
|
-
if (!apiKey || !apiKey.trim()) {
|
|
3300
|
-
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");
|
|
3301
|
-
}
|
|
3452
|
+
const provider = resolveAccessibleAiProvider(this.db, {
|
|
3453
|
+
userId: options?.userId,
|
|
3454
|
+
providerId: options?.providerId
|
|
3455
|
+
});
|
|
3456
|
+
const apiKey = provider.api_key;
|
|
3302
3457
|
if (looksLikeCiphertext(apiKey)) {
|
|
3303
3458
|
throw new Error(
|
|
3304
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"
|
|
@@ -4209,18 +4364,11 @@ function checkMethodPolicy(input) {
|
|
|
4209
4364
|
if (tokens.uiService && token === tokens.uiService) return null;
|
|
4210
4365
|
return null;
|
|
4211
4366
|
}
|
|
4212
|
-
var
|
|
4367
|
+
var HIGH_RISK_PATH_PREFIXES, IRREVERSIBLE_PATH_SUFFIXES, ENTITY_DELETE_RE;
|
|
4213
4368
|
var init_method_policy = __esm({
|
|
4214
4369
|
"packages/core/plugin-host/method-policy.ts"() {
|
|
4215
4370
|
"use strict";
|
|
4216
|
-
|
|
4217
|
-
CAP_API_ADMIN = "api:admin";
|
|
4218
|
-
CAP_GRADES_WRITE = "grades:write";
|
|
4219
|
-
CAP_AGENT_TOOL = "agent:tool";
|
|
4220
|
-
CAP_AI_INVOKE = "ai:invoke";
|
|
4221
|
-
CAP_TASK_REGISTER = "task:register";
|
|
4222
|
-
CAP_STUDENT_DATA_READ = "student-data:read";
|
|
4223
|
-
CAP_STUDENT_DATA_WRITE = "student-data:write";
|
|
4371
|
+
init_catalog();
|
|
4224
4372
|
HIGH_RISK_PATH_PREFIXES = Object.freeze([
|
|
4225
4373
|
"/api/plugins",
|
|
4226
4374
|
// 插件增删改、一键更新(可执行任意第三方代码)
|
|
@@ -4244,51 +4392,238 @@ var init_method_policy = __esm({
|
|
|
4244
4392
|
}
|
|
4245
4393
|
});
|
|
4246
4394
|
|
|
4247
|
-
// packages/core/
|
|
4248
|
-
function
|
|
4249
|
-
|
|
4250
|
-
|
|
4251
|
-
|
|
4395
|
+
// packages/core/plugin-host/sql-guard.ts
|
|
4396
|
+
function sanitizeSql(sql) {
|
|
4397
|
+
let inSingleQuote = false;
|
|
4398
|
+
let inDoubleQuote = false;
|
|
4399
|
+
let inBacktick = false;
|
|
4400
|
+
let inBracket = false;
|
|
4401
|
+
let result = "";
|
|
4402
|
+
const len = sql.length;
|
|
4403
|
+
for (let i = 0; i < len; i++) {
|
|
4404
|
+
const ch = sql[i];
|
|
4405
|
+
const next = i + 1 < len ? sql[i + 1] : "";
|
|
4406
|
+
if (!inSingleQuote && !inDoubleQuote && !inBacktick && !inBracket && ch === "-" && next === "-") {
|
|
4407
|
+
while (i < len && sql[i] !== "\n") i++;
|
|
4408
|
+
result += " ";
|
|
4409
|
+
continue;
|
|
4410
|
+
}
|
|
4411
|
+
if (!inSingleQuote && !inDoubleQuote && !inBacktick && !inBracket && ch === "/" && next === "*") {
|
|
4412
|
+
i += 2;
|
|
4413
|
+
while (i < len && !(sql[i] === "*" && i + 1 < len && sql[i + 1] === "/")) {
|
|
4414
|
+
i++;
|
|
4415
|
+
}
|
|
4416
|
+
i++;
|
|
4417
|
+
result += " ";
|
|
4418
|
+
continue;
|
|
4419
|
+
}
|
|
4420
|
+
if (ch === "'" && !inDoubleQuote && !inBacktick && !inBracket) {
|
|
4421
|
+
if (inSingleQuote) {
|
|
4422
|
+
if (next === "'") {
|
|
4423
|
+
i++;
|
|
4424
|
+
} else {
|
|
4425
|
+
inSingleQuote = false;
|
|
4426
|
+
}
|
|
4427
|
+
} else {
|
|
4428
|
+
inSingleQuote = true;
|
|
4429
|
+
}
|
|
4430
|
+
result += " ";
|
|
4431
|
+
continue;
|
|
4432
|
+
}
|
|
4433
|
+
if (inSingleQuote) {
|
|
4434
|
+
result += " ";
|
|
4435
|
+
continue;
|
|
4436
|
+
}
|
|
4437
|
+
if (ch === "[" && !inDoubleQuote && !inBacktick) {
|
|
4438
|
+
inBracket = true;
|
|
4439
|
+
result += " ";
|
|
4440
|
+
continue;
|
|
4441
|
+
}
|
|
4442
|
+
if (ch === "]" && inBracket) {
|
|
4443
|
+
inBracket = false;
|
|
4444
|
+
result += " ";
|
|
4445
|
+
continue;
|
|
4446
|
+
}
|
|
4447
|
+
if (ch === "`" && !inDoubleQuote) {
|
|
4448
|
+
inBacktick = !inBacktick;
|
|
4449
|
+
result += " ";
|
|
4450
|
+
continue;
|
|
4451
|
+
}
|
|
4452
|
+
if (ch === '"' && !inBacktick) {
|
|
4453
|
+
inDoubleQuote = !inDoubleQuote;
|
|
4454
|
+
result += " ";
|
|
4455
|
+
continue;
|
|
4456
|
+
}
|
|
4457
|
+
result += ch;
|
|
4252
4458
|
}
|
|
4253
|
-
|
|
4254
|
-
const metadata = raw.metadata && typeof raw.metadata === "object" ? { ...raw.metadata } : {};
|
|
4255
|
-
delete metadata.approved;
|
|
4256
|
-
return { ...raw, actorId: actor, metadata };
|
|
4459
|
+
return result;
|
|
4257
4460
|
}
|
|
4258
|
-
function
|
|
4259
|
-
const
|
|
4260
|
-
|
|
4261
|
-
|
|
4461
|
+
function extractFlatTables(flatSql, tables) {
|
|
4462
|
+
const ddlRegex = /\b(?:CREATE|DROP|ALTER)\s+(?:TEMP(?:ORARY)?\s+)?TABLE\s+(?:IF\s+(?:NOT\s+)?EXISTS\s+)?([a-zA-Z_][\w]*)/gi;
|
|
4463
|
+
for (const m of flatSql.matchAll(ddlRegex)) {
|
|
4464
|
+
if (m[1] && m[1].toLowerCase() !== SUBQUERY_PLACEHOLDER) {
|
|
4465
|
+
tables.add(m[1].toLowerCase());
|
|
4466
|
+
}
|
|
4467
|
+
}
|
|
4468
|
+
const renameToRegex = /\bRENAME\s+TO\s+([a-zA-Z_][\w]*)/gi;
|
|
4469
|
+
for (const m of flatSql.matchAll(renameToRegex)) {
|
|
4470
|
+
if (m[1] && m[1].toLowerCase() !== SUBQUERY_PLACEHOLDER) {
|
|
4471
|
+
tables.add(m[1].toLowerCase());
|
|
4472
|
+
}
|
|
4473
|
+
}
|
|
4474
|
+
const simpleClauseRegex = /\b(?:INTO|UPDATE(?:\s+OR\s+(?:ROLLBACK|ABORT|REPLACE|FAIL|IGNORE))?|JOIN)\s+([a-zA-Z_][\w]*)/gi;
|
|
4475
|
+
for (const m of flatSql.matchAll(simpleClauseRegex)) {
|
|
4476
|
+
if (m[1] && m[1].toLowerCase() !== SUBQUERY_PLACEHOLDER) {
|
|
4477
|
+
tables.add(m[1].toLowerCase());
|
|
4478
|
+
}
|
|
4479
|
+
}
|
|
4480
|
+
const fromRegex = /\bFROM\s+([^;]+?)(?=\bWHERE\b|\bGROUP\b|\bHAVING\b|\bORDER\b|\bLIMIT\b|\bUNION\b|\bEXCEPT\b|\bINTERSECT\b|\bWINDOW\b|\bVALUES\b|\bSET\b|\bINTO\b|\bJOIN\b|\bLEFT\b|\bRIGHT\b|\bINNER\b|\bOUTER\b|\bCROSS\b|\bNATURAL\b|\bON\b|\bUSING\b|\bSELECT\b|;|$)/gi;
|
|
4481
|
+
for (const match of flatSql.matchAll(fromRegex)) {
|
|
4482
|
+
const clause = match[1];
|
|
4483
|
+
if (!clause) continue;
|
|
4484
|
+
const parts = clause.split(",");
|
|
4485
|
+
for (const part of parts) {
|
|
4486
|
+
const trimmed2 = part.trim();
|
|
4487
|
+
if (!trimmed2) continue;
|
|
4488
|
+
const tokenMatch = /^[a-zA-Z_][\w]*/.exec(trimmed2);
|
|
4489
|
+
if (tokenMatch) {
|
|
4490
|
+
const tableName = tokenMatch[0].toLowerCase();
|
|
4491
|
+
if (tableName !== SUBQUERY_PLACEHOLDER) {
|
|
4492
|
+
tables.add(tableName);
|
|
4493
|
+
}
|
|
4494
|
+
}
|
|
4495
|
+
}
|
|
4262
4496
|
}
|
|
4263
|
-
return id.startsWith("plugin:") ? id : `plugin:${id}`;
|
|
4264
4497
|
}
|
|
4265
|
-
|
|
4266
|
-
|
|
4267
|
-
|
|
4498
|
+
function extractReferencedTables(cleanSql) {
|
|
4499
|
+
const tables = /* @__PURE__ */ new Set();
|
|
4500
|
+
let working = cleanSql;
|
|
4501
|
+
const parenRegex = /\(([^()]*)\)/g;
|
|
4502
|
+
let depth = 0;
|
|
4503
|
+
while (parenRegex.test(working) && depth++ < 100) {
|
|
4504
|
+
working = working.replace(parenRegex, (_full, inner) => {
|
|
4505
|
+
extractFlatTables(inner, tables);
|
|
4506
|
+
const trimmedInner = inner.trim();
|
|
4507
|
+
if (/^(?:SELECT|VALUES|WITH)\b/i.test(trimmedInner)) {
|
|
4508
|
+
return ` ${SUBQUERY_PLACEHOLDER} `;
|
|
4509
|
+
}
|
|
4510
|
+
return ` ${inner} `;
|
|
4511
|
+
});
|
|
4268
4512
|
}
|
|
4269
|
-
|
|
4270
|
-
|
|
4271
|
-
|
|
4272
|
-
function resolvePluginCommandType(type, pluginId) {
|
|
4273
|
-
const prefix = pluginId + ".";
|
|
4274
|
-
if (type.startsWith(prefix)) return type;
|
|
4275
|
-
if (UUID_V7_PREFIX.test(type)) return type;
|
|
4276
|
-
return prefix + type;
|
|
4513
|
+
working = working.replace(/[()]/g, " ");
|
|
4514
|
+
extractFlatTables(working, tables);
|
|
4515
|
+
return tables;
|
|
4277
4516
|
}
|
|
4278
|
-
function
|
|
4279
|
-
const
|
|
4280
|
-
if (
|
|
4281
|
-
|
|
4282
|
-
|
|
4283
|
-
|
|
4517
|
+
function assertPluginSqlAllowed(options) {
|
|
4518
|
+
const { sql, ownPrefix, allowedPrefixes = [], researchAllowed = false } = options;
|
|
4519
|
+
if (typeof sql !== "string" || sql.trim() === "") return;
|
|
4520
|
+
for (const opRegex of FORBIDDEN_OPERATIONS) {
|
|
4521
|
+
if (opRegex.test(sql)) {
|
|
4522
|
+
throw new Error(`Dangerous statement is forbidden: "${sql.trim()}"`);
|
|
4523
|
+
}
|
|
4524
|
+
}
|
|
4525
|
+
const clean = sanitizeSql(sql);
|
|
4526
|
+
const tables = extractReferencedTables(clean);
|
|
4527
|
+
if (/\b(?:FROM|JOIN|INTO|UPDATE|TABLE)\b/i.test(clean) && tables.size === 0) {
|
|
4528
|
+
throw new Error(`Worker plugin SQL rejected: unable to resolve target table in "${sql.trim()}"`);
|
|
4529
|
+
}
|
|
4530
|
+
const isDdl = /^\s*(DROP|CREATE|ALTER|TRUNCATE)\b/i.test(clean);
|
|
4531
|
+
const words = clean.toLowerCase().match(/\b[a-zA-Z_][\w]*\b/g) || [];
|
|
4532
|
+
for (const word of words) {
|
|
4533
|
+
if (FORBIDDEN_CORE_TABLES_SET.has(word)) {
|
|
4534
|
+
if (tables.has(word)) {
|
|
4535
|
+
if (isDdl) {
|
|
4536
|
+
throw new Error(
|
|
4537
|
+
`Worker plugin is not permitted to perform DDL on table "${word}" (core security table is strictly forbidden)`
|
|
4538
|
+
);
|
|
4539
|
+
}
|
|
4540
|
+
throw new Error(
|
|
4541
|
+
`Worker plugin is not permitted to access table "${word}" (core security table is strictly forbidden)`
|
|
4542
|
+
);
|
|
4543
|
+
}
|
|
4544
|
+
}
|
|
4545
|
+
}
|
|
4546
|
+
const ownPrefixLower = ownPrefix ? ownPrefix.toLowerCase() : "";
|
|
4547
|
+
const validPrefixes = [ownPrefixLower, ...allowedPrefixes.map((p) => p.toLowerCase())].filter(Boolean);
|
|
4548
|
+
for (const table of tables) {
|
|
4549
|
+
if (table === "plugin_migrations") continue;
|
|
4550
|
+
if (researchAllowed && table.startsWith("plugin_research_")) continue;
|
|
4551
|
+
const isOwn = validPrefixes.some((p) => table.startsWith(p));
|
|
4552
|
+
if (isOwn) continue;
|
|
4553
|
+
if (table === "sqlite_master" || table === "sqlite_schema" || table === "sqlite_temp_master") {
|
|
4554
|
+
const strippedComments = sql.replace(/--.*$/gm, " ").replace(/\/\*[\s\S]*?\*\//g, " ").trim();
|
|
4555
|
+
const isAllowedMetaQuery = /^\s*SELECT\s+name\s+FROM\s+(?:sqlite_master|sqlite_schema)\s+WHERE\s+type\s*=\s*['"]table['"]\s+AND\s+name\s+LIKE\s+['"][A-Za-z0-9_%]+['"]\s*$/i.test(
|
|
4556
|
+
strippedComments
|
|
4557
|
+
) && ownPrefixLower && strippedComments.toLowerCase().includes(ownPrefixLower);
|
|
4558
|
+
if (isAllowedMetaQuery) continue;
|
|
4559
|
+
throw new Error(`Direct access to sqlite metadata table "${table}" is forbidden`);
|
|
4560
|
+
}
|
|
4561
|
+
if (isDdl) {
|
|
4562
|
+
throw new Error(
|
|
4563
|
+
`Worker plugin is not permitted to perform DDL on table "${table}" (only ${ownPrefix || "<none>"} namespace is allowed)`
|
|
4564
|
+
);
|
|
4565
|
+
}
|
|
4566
|
+
throw new Error(
|
|
4567
|
+
`Worker plugin is not permitted to access table "${table}" (only ${ownPrefix || "<none>"} namespace is allowed)`
|
|
4568
|
+
);
|
|
4284
4569
|
}
|
|
4285
|
-
return type;
|
|
4286
4570
|
}
|
|
4287
|
-
var
|
|
4288
|
-
var
|
|
4289
|
-
"packages/core/plugin-host/
|
|
4571
|
+
var FORBIDDEN_CORE_TABLES, FORBIDDEN_CORE_TABLES_SET, FORBIDDEN_OPERATIONS, SUBQUERY_PLACEHOLDER;
|
|
4572
|
+
var init_sql_guard = __esm({
|
|
4573
|
+
"packages/core/plugin-host/sql-guard.ts"() {
|
|
4290
4574
|
"use strict";
|
|
4291
|
-
|
|
4575
|
+
FORBIDDEN_CORE_TABLES = Object.freeze([
|
|
4576
|
+
"users",
|
|
4577
|
+
"client_sessions",
|
|
4578
|
+
"plugins",
|
|
4579
|
+
"plugin_storage",
|
|
4580
|
+
"ai_providers",
|
|
4581
|
+
"processes",
|
|
4582
|
+
"pending_commands",
|
|
4583
|
+
"classes",
|
|
4584
|
+
"students",
|
|
4585
|
+
"class_students",
|
|
4586
|
+
"lessons",
|
|
4587
|
+
"assignments",
|
|
4588
|
+
"assignment_submissions",
|
|
4589
|
+
"attendance",
|
|
4590
|
+
"schedules",
|
|
4591
|
+
"class_grade_weights",
|
|
4592
|
+
"exams",
|
|
4593
|
+
"exam_scores",
|
|
4594
|
+
"student_semester_reports",
|
|
4595
|
+
"student_rollcalls",
|
|
4596
|
+
"student_seats",
|
|
4597
|
+
"student_read_notifications",
|
|
4598
|
+
"student_lesson_progress",
|
|
4599
|
+
"courseware",
|
|
4600
|
+
"courseware_attempt",
|
|
4601
|
+
"system_resources",
|
|
4602
|
+
"events",
|
|
4603
|
+
"_migrations",
|
|
4604
|
+
"site_settings",
|
|
4605
|
+
"agent_conversations",
|
|
4606
|
+
"classroom_sessions",
|
|
4607
|
+
"lesson_quiz_submissions",
|
|
4608
|
+
"courseware_score_config",
|
|
4609
|
+
"demo_data_registry",
|
|
4610
|
+
"whiteboard_elements",
|
|
4611
|
+
"student_point_logs",
|
|
4612
|
+
"lianyun_research_activities",
|
|
4613
|
+
"lianyun_research_groups",
|
|
4614
|
+
"lianyun_research_submissions",
|
|
4615
|
+
"lianyun_research_reviews"
|
|
4616
|
+
]);
|
|
4617
|
+
FORBIDDEN_CORE_TABLES_SET = new Set(FORBIDDEN_CORE_TABLES.map((t) => t.toLowerCase()));
|
|
4618
|
+
FORBIDDEN_OPERATIONS = [
|
|
4619
|
+
/\bATTACH(?:\s+DATABASE)?\b/i,
|
|
4620
|
+
/\bDETACH(?:\s+DATABASE)?\b/i,
|
|
4621
|
+
/\bPRAGMA\b/i,
|
|
4622
|
+
/\bVACUUM\b/i,
|
|
4623
|
+
/\bCREATE\s+(?:TEMP(?:ORARY)?\s+)?(?:TRIGGER|VIEW)\b/i,
|
|
4624
|
+
/\bDROP\s+(?:TEMP(?:ORARY)?\s+)?(?:TRIGGER|VIEW)\b/i
|
|
4625
|
+
];
|
|
4626
|
+
SUBQUERY_PLACEHOLDER = "__ol_subquery__";
|
|
4292
4627
|
}
|
|
4293
4628
|
});
|
|
4294
4629
|
|
|
@@ -4318,6 +4653,108 @@ var init_plugin_tables = __esm({
|
|
|
4318
4653
|
}
|
|
4319
4654
|
});
|
|
4320
4655
|
|
|
4656
|
+
// packages/core/plugin-host/inline-db-guard.ts
|
|
4657
|
+
function isSqlGuardAllModesEnabled(env = process.env) {
|
|
4658
|
+
const raw = env.OPENLEARN_SQL_GUARD_ALL_MODES?.trim().toLowerCase();
|
|
4659
|
+
return raw === "1" || raw === "true" || raw === "on" || raw === "yes";
|
|
4660
|
+
}
|
|
4661
|
+
function createGuardedPluginDatabase(db2, pluginId, manifestId) {
|
|
4662
|
+
const ownPrefix = pluginTablePrefix(pluginId, manifestId);
|
|
4663
|
+
const allowedPrefixes = listAllowedPluginTablePrefixes(pluginId, manifestId);
|
|
4664
|
+
const assertSql = (sql) => {
|
|
4665
|
+
try {
|
|
4666
|
+
assertPluginSqlAllowed({ sql: typeof sql === "string" ? sql : String(sql ?? ""), ownPrefix, allowedPrefixes });
|
|
4667
|
+
} catch (err) {
|
|
4668
|
+
const message = err?.message ? String(err.message) : String(err);
|
|
4669
|
+
const wrapped = new Error(`${SQL_GUARD_ERROR_PREFIX}\uFF1A${message}\uFF08\u63D2\u4EF6 "${pluginId}"\uFF09`);
|
|
4670
|
+
wrapped.code = "PLUGIN_SQL_REJECTED";
|
|
4671
|
+
throw wrapped;
|
|
4672
|
+
}
|
|
4673
|
+
};
|
|
4674
|
+
return new Proxy(db2, {
|
|
4675
|
+
get(target, prop, receiver) {
|
|
4676
|
+
const value = Reflect.get(target, prop, receiver);
|
|
4677
|
+
if (typeof value !== "function") return value;
|
|
4678
|
+
if (prop === "prepare") {
|
|
4679
|
+
return (sql, ...rest) => {
|
|
4680
|
+
assertSql(sql);
|
|
4681
|
+
return value.call(target, sql, ...rest);
|
|
4682
|
+
};
|
|
4683
|
+
}
|
|
4684
|
+
if (prop === "exec") {
|
|
4685
|
+
return (sql, ...rest) => {
|
|
4686
|
+
assertSql(sql);
|
|
4687
|
+
return value.call(target, sql, ...rest);
|
|
4688
|
+
};
|
|
4689
|
+
}
|
|
4690
|
+
if (prop === "pragma") {
|
|
4691
|
+
return (source, ...rest) => {
|
|
4692
|
+
assertSql(`PRAGMA ${String(source ?? "")}`);
|
|
4693
|
+
return value.call(target, source, ...rest);
|
|
4694
|
+
};
|
|
4695
|
+
}
|
|
4696
|
+
return value.bind(target);
|
|
4697
|
+
}
|
|
4698
|
+
});
|
|
4699
|
+
}
|
|
4700
|
+
var SQL_GUARD_ERROR_PREFIX;
|
|
4701
|
+
var init_inline_db_guard = __esm({
|
|
4702
|
+
"packages/core/plugin-host/inline-db-guard.ts"() {
|
|
4703
|
+
"use strict";
|
|
4704
|
+
init_sql_guard();
|
|
4705
|
+
init_plugin_tables();
|
|
4706
|
+
SQL_GUARD_ERROR_PREFIX = "[PluginHost] SQL \u88AB\u62D2\u7EDD";
|
|
4707
|
+
}
|
|
4708
|
+
});
|
|
4709
|
+
|
|
4710
|
+
// packages/core/command-bus/plugin-command.ts
|
|
4711
|
+
function bindPluginCommand(command, pluginActorId) {
|
|
4712
|
+
const actor = typeof pluginActorId === "string" ? pluginActorId.trim() : "";
|
|
4713
|
+
if (!actor) {
|
|
4714
|
+
throw new Error("[Security] Refusing to dispatch a plugin command without a host-assigned actor");
|
|
4715
|
+
}
|
|
4716
|
+
const raw = command && typeof command === "object" ? { ...command } : {};
|
|
4717
|
+
const metadata = raw.metadata && typeof raw.metadata === "object" ? { ...raw.metadata } : {};
|
|
4718
|
+
delete metadata.approved;
|
|
4719
|
+
return { ...raw, actorId: actor, metadata };
|
|
4720
|
+
}
|
|
4721
|
+
function pluginActorIdFor(manifestId, pluginId) {
|
|
4722
|
+
const id = (manifestId || pluginId || "").trim();
|
|
4723
|
+
if (!id) {
|
|
4724
|
+
throw new Error("[Security] Refusing to dispatch a plugin command without a host-assigned actor");
|
|
4725
|
+
}
|
|
4726
|
+
return id.startsWith("plugin:") ? id : `plugin:${id}`;
|
|
4727
|
+
}
|
|
4728
|
+
var init_plugin_command = __esm({
|
|
4729
|
+
"packages/core/command-bus/plugin-command.ts"() {
|
|
4730
|
+
"use strict";
|
|
4731
|
+
}
|
|
4732
|
+
});
|
|
4733
|
+
|
|
4734
|
+
// packages/core/plugin-host/plugin-namespace.ts
|
|
4735
|
+
function resolvePluginCommandType(type, pluginId) {
|
|
4736
|
+
const prefix = pluginId + ".";
|
|
4737
|
+
if (type.startsWith(prefix)) return type;
|
|
4738
|
+
if (UUID_V7_PREFIX.test(type)) return type;
|
|
4739
|
+
return prefix + type;
|
|
4740
|
+
}
|
|
4741
|
+
function stripPluginCommandPrefix(type, pluginId) {
|
|
4742
|
+
const prefix = pluginId + ".";
|
|
4743
|
+
if (type.startsWith(prefix)) return type.slice(prefix.length);
|
|
4744
|
+
if (UUID_V7_PREFIX.test(type)) {
|
|
4745
|
+
const idx = type.indexOf(".");
|
|
4746
|
+
return type.slice(idx + 1);
|
|
4747
|
+
}
|
|
4748
|
+
return type;
|
|
4749
|
+
}
|
|
4750
|
+
var UUID_V7_PREFIX;
|
|
4751
|
+
var init_plugin_namespace = __esm({
|
|
4752
|
+
"packages/core/plugin-host/plugin-namespace.ts"() {
|
|
4753
|
+
"use strict";
|
|
4754
|
+
UUID_V7_PREFIX = /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[0-9a-f]{4}-[0-9a-f]{12}\./i;
|
|
4755
|
+
}
|
|
4756
|
+
});
|
|
4757
|
+
|
|
4321
4758
|
// packages/core/plugin-host/context-builder.ts
|
|
4322
4759
|
var context_builder_exports = {};
|
|
4323
4760
|
__export(context_builder_exports, {
|
|
@@ -4720,15 +5157,51 @@ function wrapInteractionRuntimeService(service, tracker, pluginId) {
|
|
|
4720
5157
|
listActivityProviders: createSafeFunction(() => service.listActivityProviders())
|
|
4721
5158
|
};
|
|
4722
5159
|
}
|
|
4723
|
-
function wrapPluginRuntimeService(raw, tokenName, tracker, pluginId) {
|
|
5160
|
+
function wrapPluginRuntimeService(raw, tokenName, tracker, pluginId, manifestId) {
|
|
4724
5161
|
if (tokenName === IClassroomLifecycleServiceToken.name) {
|
|
4725
5162
|
return wrapClassroomLifecycleService(raw, tracker, pluginId);
|
|
4726
5163
|
}
|
|
4727
5164
|
if (tokenName === IInteractionRuntimeServiceToken.name) {
|
|
4728
5165
|
return wrapInteractionRuntimeService(raw, tracker, pluginId);
|
|
4729
5166
|
}
|
|
5167
|
+
if (tokenName === "@openlearn/core:IDatabase" && isSqlGuardAllModesEnabled()) {
|
|
5168
|
+
return createGuardedPluginDatabase(raw, pluginId, manifestId);
|
|
5169
|
+
}
|
|
4730
5170
|
return raw;
|
|
4731
5171
|
}
|
|
5172
|
+
function wrapRosterService(service, pluginId, capabilities) {
|
|
5173
|
+
const CAP_ROSTER_READ = "management:read";
|
|
5174
|
+
const safe = (fn) => createSafeFunction(fn);
|
|
5175
|
+
const assertReadable = (method, detail) => {
|
|
5176
|
+
const hasCap = hasCapability(capabilities ?? [], CAP_ROSTER_READ);
|
|
5177
|
+
recordSecurityAudit("plugin.roster_read", {
|
|
5178
|
+
pluginId,
|
|
5179
|
+
method,
|
|
5180
|
+
...detail,
|
|
5181
|
+
hasCapability: hasCap,
|
|
5182
|
+
...hasCap ? {} : { denied: true }
|
|
5183
|
+
});
|
|
5184
|
+
if (hasCap) return;
|
|
5185
|
+
console.error(
|
|
5186
|
+
`[Security] \u63D2\u4EF6 "${pluginId}" \u8C03\u7528 roster.${method} \u88AB\u62D2\uFF1A\u7F3A\u5C11\u80FD\u529B "${CAP_ROSTER_READ}"\u3002\u8BF7\u5728 manifest.capabilitiesProposed \u58F0\u660E\u5B83\uFF08\u6559\u5E08\u89D2\u8272\u9ED8\u8BA4\u5177\u5907\uFF09\u3002`
|
|
5187
|
+
);
|
|
5188
|
+
throw new PluginCapabilityDeniedError(pluginId, CAP_ROSTER_READ, "read", method);
|
|
5189
|
+
};
|
|
5190
|
+
return {
|
|
5191
|
+
listClasses: safe(async () => {
|
|
5192
|
+
assertReadable("listClasses", {});
|
|
5193
|
+
return service.listClasses();
|
|
5194
|
+
}),
|
|
5195
|
+
listClassStudents: safe(async (classId) => {
|
|
5196
|
+
assertReadable("listClassStudents", { classId });
|
|
5197
|
+
return service.listClassStudents(classId);
|
|
5198
|
+
}),
|
|
5199
|
+
getStudent: safe(async (studentId) => {
|
|
5200
|
+
assertReadable("getStudent", { studentId });
|
|
5201
|
+
return service.getStudent(studentId);
|
|
5202
|
+
})
|
|
5203
|
+
};
|
|
5204
|
+
}
|
|
4732
5205
|
function wrapPointsLedgerService(service, pluginId, capabilities) {
|
|
4733
5206
|
const auditAccess = (studentId, method, operation, requiredCap) => {
|
|
4734
5207
|
const hasCap = hasCapability(capabilities ?? [], requiredCap);
|
|
@@ -4741,6 +5214,9 @@ function wrapPointsLedgerService(service, pluginId, capabilities) {
|
|
|
4741
5214
|
...hasCap ? {} : { denied: true }
|
|
4742
5215
|
});
|
|
4743
5216
|
if (!hasCap) {
|
|
5217
|
+
console.error(
|
|
5218
|
+
`[Security] \u63D2\u4EF6 "${pluginId}" \u8C03\u7528 pointsLedger.${method} \u88AB\u62D2\uFF1A\u7F3A\u5C11\u80FD\u529B "${requiredCap}"\uFF08${operation} / student=${studentId}\uFF09\u3002\u8BF7\u5728 manifest.capabilitiesProposed \u58F0\u660E\u5B83\u3002`
|
|
5219
|
+
);
|
|
4744
5220
|
throw new PluginCapabilityDeniedError(pluginId, requiredCap, operation, method);
|
|
4745
5221
|
}
|
|
4746
5222
|
};
|
|
@@ -4857,6 +5333,7 @@ async function buildContext(serviceRegistry, tracker, pluginId, manifest, db2, s
|
|
|
4857
5333
|
const aiService = await serviceRegistry.resolve(IAIServiceToken);
|
|
4858
5334
|
const pointsDimensionRegistry = await serviceRegistry.tryResolve(IPointsDimensionRegistryToken);
|
|
4859
5335
|
const pointsLedgerService = await serviceRegistry.tryResolve(IPointsLedgerServiceToken);
|
|
5336
|
+
const rosterService = await serviceRegistry.tryResolve(IClassRosterServiceToken);
|
|
4860
5337
|
const wrappedCommandBus = wrapCommandBus(commandBusService, tracker, pluginId, manifest.id, actionRegistryService);
|
|
4861
5338
|
const wrappedEventBus = wrapEventBus(eventBusService, tracker, pluginId);
|
|
4862
5339
|
const wrappedProcessManager = wrapProcessManager(
|
|
@@ -4873,6 +5350,7 @@ async function buildContext(serviceRegistry, tracker, pluginId, manifest, db2, s
|
|
|
4873
5350
|
const wrappedAI = wrapAI(aiService, pluginId, tracker);
|
|
4874
5351
|
const wrappedPointsDimension = pointsDimensionRegistry ? wrapPointsDimensionRegistry(pointsDimensionRegistry, tracker, pluginId) : null;
|
|
4875
5352
|
const wrappedPointsLedger = pointsLedgerService ? wrapPointsLedgerService(pointsLedgerService, pluginId, manifest.capabilitiesProposed) : null;
|
|
5353
|
+
const wrappedRoster = rosterService ? wrapRosterService(rosterService, pluginId, manifest.capabilitiesProposed) : null;
|
|
4876
5354
|
Object.setPrototypeOf(wrappedCommandBus, null);
|
|
4877
5355
|
Object.setPrototypeOf(wrappedEventBus, null);
|
|
4878
5356
|
Object.setPrototypeOf(wrappedProcessManager, null);
|
|
@@ -4900,7 +5378,8 @@ async function buildContext(serviceRegistry, tracker, pluginId, manifest, db2, s
|
|
|
4900
5378
|
storage: wrappedStorage,
|
|
4901
5379
|
ai: wrappedAI,
|
|
4902
5380
|
pointsDimension: wrappedPointsDimension,
|
|
4903
|
-
pointsLedger: wrappedPointsLedger
|
|
5381
|
+
pointsLedger: wrappedPointsLedger,
|
|
5382
|
+
roster: wrappedRoster
|
|
4904
5383
|
};
|
|
4905
5384
|
if (skipTokens && skipTokens.size > 0) {
|
|
4906
5385
|
const TOKEN_TO_SERVICE_KEY = {
|
|
@@ -4912,7 +5391,8 @@ async function buildContext(serviceRegistry, tracker, pluginId, manifest, db2, s
|
|
|
4912
5391
|
"@openlearn/core:IStorageService": "storage",
|
|
4913
5392
|
"@openlearn/core:IAIService": "ai",
|
|
4914
5393
|
"@openlearn/core:IPointsDimensionRegistry": "pointsDimension",
|
|
4915
|
-
"@openlearn/core:IPointsLedgerService": "pointsLedger"
|
|
5394
|
+
"@openlearn/core:IPointsLedgerService": "pointsLedger",
|
|
5395
|
+
"@openlearn/core:IClassRosterService": "roster"
|
|
4916
5396
|
};
|
|
4917
5397
|
for (const tokenName of skipTokens) {
|
|
4918
5398
|
const serviceKey = TOKEN_TO_SERVICE_KEY[tokenName];
|
|
@@ -5023,9 +5503,9 @@ async function buildContext(serviceRegistry, tracker, pluginId, manifest, db2, s
|
|
|
5023
5503
|
`[PluginHost] Plugin "${manifest.id}" cannot resolve "${token}" by name. Use the Token object imported from @openlearn/plugin-sdk, or one of the SDK-exported Token names.`
|
|
5024
5504
|
);
|
|
5025
5505
|
}
|
|
5026
|
-
return serviceRegistry.resolveByName(token).then((raw) => wrapPluginRuntimeService(raw, token, tracker, pluginId));
|
|
5506
|
+
return serviceRegistry.resolveByName(token).then((raw) => wrapPluginRuntimeService(raw, token, tracker, pluginId, manifest.id));
|
|
5027
5507
|
}
|
|
5028
|
-
return serviceRegistry.resolve(token).then((raw) => wrapPluginRuntimeService(raw, token.name, tracker, pluginId));
|
|
5508
|
+
return serviceRegistry.resolve(token).then((raw) => wrapPluginRuntimeService(raw, token.name, tracker, pluginId, manifest.id));
|
|
5029
5509
|
},
|
|
5030
5510
|
// V3.2: 插件向 DI 容器提供服务(对应 manifest.provides)
|
|
5031
5511
|
provide: async (token, instance) => {
|
|
@@ -5125,6 +5605,7 @@ var init_context_builder = __esm({
|
|
|
5125
5605
|
init_audit_log();
|
|
5126
5606
|
init_method_policy();
|
|
5127
5607
|
init_errors2();
|
|
5608
|
+
init_inline_db_guard();
|
|
5128
5609
|
init_extension_points();
|
|
5129
5610
|
init_interfaces();
|
|
5130
5611
|
init_plugin_command();
|
|
@@ -7518,241 +7999,6 @@ var init_event_forwarder = __esm({
|
|
|
7518
7999
|
}
|
|
7519
8000
|
});
|
|
7520
8001
|
|
|
7521
|
-
// packages/core/plugin-host/sql-guard.ts
|
|
7522
|
-
function sanitizeSql(sql) {
|
|
7523
|
-
let inSingleQuote = false;
|
|
7524
|
-
let inDoubleQuote = false;
|
|
7525
|
-
let inBacktick = false;
|
|
7526
|
-
let inBracket = false;
|
|
7527
|
-
let result = "";
|
|
7528
|
-
const len = sql.length;
|
|
7529
|
-
for (let i = 0; i < len; i++) {
|
|
7530
|
-
const ch = sql[i];
|
|
7531
|
-
const next = i + 1 < len ? sql[i + 1] : "";
|
|
7532
|
-
if (!inSingleQuote && !inDoubleQuote && !inBacktick && !inBracket && ch === "-" && next === "-") {
|
|
7533
|
-
while (i < len && sql[i] !== "\n") i++;
|
|
7534
|
-
result += " ";
|
|
7535
|
-
continue;
|
|
7536
|
-
}
|
|
7537
|
-
if (!inSingleQuote && !inDoubleQuote && !inBacktick && !inBracket && ch === "/" && next === "*") {
|
|
7538
|
-
i += 2;
|
|
7539
|
-
while (i < len && !(sql[i] === "*" && i + 1 < len && sql[i + 1] === "/")) {
|
|
7540
|
-
i++;
|
|
7541
|
-
}
|
|
7542
|
-
i++;
|
|
7543
|
-
result += " ";
|
|
7544
|
-
continue;
|
|
7545
|
-
}
|
|
7546
|
-
if (ch === "'" && !inDoubleQuote && !inBacktick && !inBracket) {
|
|
7547
|
-
if (inSingleQuote) {
|
|
7548
|
-
if (next === "'") {
|
|
7549
|
-
i++;
|
|
7550
|
-
} else {
|
|
7551
|
-
inSingleQuote = false;
|
|
7552
|
-
}
|
|
7553
|
-
} else {
|
|
7554
|
-
inSingleQuote = true;
|
|
7555
|
-
}
|
|
7556
|
-
result += " ";
|
|
7557
|
-
continue;
|
|
7558
|
-
}
|
|
7559
|
-
if (inSingleQuote) {
|
|
7560
|
-
result += " ";
|
|
7561
|
-
continue;
|
|
7562
|
-
}
|
|
7563
|
-
if (ch === "[" && !inDoubleQuote && !inBacktick) {
|
|
7564
|
-
inBracket = true;
|
|
7565
|
-
result += " ";
|
|
7566
|
-
continue;
|
|
7567
|
-
}
|
|
7568
|
-
if (ch === "]" && inBracket) {
|
|
7569
|
-
inBracket = false;
|
|
7570
|
-
result += " ";
|
|
7571
|
-
continue;
|
|
7572
|
-
}
|
|
7573
|
-
if (ch === "`" && !inDoubleQuote) {
|
|
7574
|
-
inBacktick = !inBacktick;
|
|
7575
|
-
result += " ";
|
|
7576
|
-
continue;
|
|
7577
|
-
}
|
|
7578
|
-
if (ch === '"' && !inBacktick) {
|
|
7579
|
-
inDoubleQuote = !inDoubleQuote;
|
|
7580
|
-
result += " ";
|
|
7581
|
-
continue;
|
|
7582
|
-
}
|
|
7583
|
-
result += ch;
|
|
7584
|
-
}
|
|
7585
|
-
return result;
|
|
7586
|
-
}
|
|
7587
|
-
function extractFlatTables(flatSql, tables) {
|
|
7588
|
-
const ddlRegex = /\b(?:CREATE|DROP|ALTER)\s+(?:TEMP(?:ORARY)?\s+)?TABLE\s+(?:IF\s+(?:NOT\s+)?EXISTS\s+)?([a-zA-Z_][\w]*)/gi;
|
|
7589
|
-
for (const m of flatSql.matchAll(ddlRegex)) {
|
|
7590
|
-
if (m[1] && m[1].toLowerCase() !== SUBQUERY_PLACEHOLDER) {
|
|
7591
|
-
tables.add(m[1].toLowerCase());
|
|
7592
|
-
}
|
|
7593
|
-
}
|
|
7594
|
-
const renameToRegex = /\bRENAME\s+TO\s+([a-zA-Z_][\w]*)/gi;
|
|
7595
|
-
for (const m of flatSql.matchAll(renameToRegex)) {
|
|
7596
|
-
if (m[1] && m[1].toLowerCase() !== SUBQUERY_PLACEHOLDER) {
|
|
7597
|
-
tables.add(m[1].toLowerCase());
|
|
7598
|
-
}
|
|
7599
|
-
}
|
|
7600
|
-
const simpleClauseRegex = /\b(?:INTO|UPDATE(?:\s+OR\s+(?:ROLLBACK|ABORT|REPLACE|FAIL|IGNORE))?|JOIN)\s+([a-zA-Z_][\w]*)/gi;
|
|
7601
|
-
for (const m of flatSql.matchAll(simpleClauseRegex)) {
|
|
7602
|
-
if (m[1] && m[1].toLowerCase() !== SUBQUERY_PLACEHOLDER) {
|
|
7603
|
-
tables.add(m[1].toLowerCase());
|
|
7604
|
-
}
|
|
7605
|
-
}
|
|
7606
|
-
const fromRegex = /\bFROM\s+([^;]+?)(?=\bWHERE\b|\bGROUP\b|\bHAVING\b|\bORDER\b|\bLIMIT\b|\bUNION\b|\bEXCEPT\b|\bINTERSECT\b|\bWINDOW\b|\bVALUES\b|\bSET\b|\bINTO\b|\bJOIN\b|\bLEFT\b|\bRIGHT\b|\bINNER\b|\bOUTER\b|\bCROSS\b|\bNATURAL\b|\bON\b|\bUSING\b|\bSELECT\b|;|$)/gi;
|
|
7607
|
-
for (const match of flatSql.matchAll(fromRegex)) {
|
|
7608
|
-
const clause = match[1];
|
|
7609
|
-
if (!clause) continue;
|
|
7610
|
-
const parts = clause.split(",");
|
|
7611
|
-
for (const part of parts) {
|
|
7612
|
-
const trimmed2 = part.trim();
|
|
7613
|
-
if (!trimmed2) continue;
|
|
7614
|
-
const tokenMatch = /^[a-zA-Z_][\w]*/.exec(trimmed2);
|
|
7615
|
-
if (tokenMatch) {
|
|
7616
|
-
const tableName = tokenMatch[0].toLowerCase();
|
|
7617
|
-
if (tableName !== SUBQUERY_PLACEHOLDER) {
|
|
7618
|
-
tables.add(tableName);
|
|
7619
|
-
}
|
|
7620
|
-
}
|
|
7621
|
-
}
|
|
7622
|
-
}
|
|
7623
|
-
}
|
|
7624
|
-
function extractReferencedTables(cleanSql) {
|
|
7625
|
-
const tables = /* @__PURE__ */ new Set();
|
|
7626
|
-
let working = cleanSql;
|
|
7627
|
-
const parenRegex = /\(([^()]*)\)/g;
|
|
7628
|
-
let depth = 0;
|
|
7629
|
-
while (parenRegex.test(working) && depth++ < 100) {
|
|
7630
|
-
working = working.replace(parenRegex, (_full, inner) => {
|
|
7631
|
-
extractFlatTables(inner, tables);
|
|
7632
|
-
const trimmedInner = inner.trim();
|
|
7633
|
-
if (/^(?:SELECT|VALUES|WITH)\b/i.test(trimmedInner)) {
|
|
7634
|
-
return ` ${SUBQUERY_PLACEHOLDER} `;
|
|
7635
|
-
}
|
|
7636
|
-
return ` ${inner} `;
|
|
7637
|
-
});
|
|
7638
|
-
}
|
|
7639
|
-
working = working.replace(/[()]/g, " ");
|
|
7640
|
-
extractFlatTables(working, tables);
|
|
7641
|
-
return tables;
|
|
7642
|
-
}
|
|
7643
|
-
function assertPluginSqlAllowed(options) {
|
|
7644
|
-
const { sql, ownPrefix, allowedPrefixes = [], researchAllowed = false } = options;
|
|
7645
|
-
if (typeof sql !== "string" || sql.trim() === "") return;
|
|
7646
|
-
for (const opRegex of FORBIDDEN_OPERATIONS) {
|
|
7647
|
-
if (opRegex.test(sql)) {
|
|
7648
|
-
throw new Error(`Dangerous statement is forbidden: "${sql.trim()}"`);
|
|
7649
|
-
}
|
|
7650
|
-
}
|
|
7651
|
-
const clean = sanitizeSql(sql);
|
|
7652
|
-
const tables = extractReferencedTables(clean);
|
|
7653
|
-
if (/\b(?:FROM|JOIN|INTO|UPDATE|TABLE)\b/i.test(clean) && tables.size === 0) {
|
|
7654
|
-
throw new Error(`Worker plugin SQL rejected: unable to resolve target table in "${sql.trim()}"`);
|
|
7655
|
-
}
|
|
7656
|
-
const isDdl = /^\s*(DROP|CREATE|ALTER|TRUNCATE)\b/i.test(clean);
|
|
7657
|
-
const words = clean.toLowerCase().match(/\b[a-zA-Z_][\w]*\b/g) || [];
|
|
7658
|
-
for (const word of words) {
|
|
7659
|
-
if (FORBIDDEN_CORE_TABLES_SET.has(word)) {
|
|
7660
|
-
if (tables.has(word)) {
|
|
7661
|
-
if (isDdl) {
|
|
7662
|
-
throw new Error(
|
|
7663
|
-
`Worker plugin is not permitted to perform DDL on table "${word}" (core security table is strictly forbidden)`
|
|
7664
|
-
);
|
|
7665
|
-
}
|
|
7666
|
-
throw new Error(
|
|
7667
|
-
`Worker plugin is not permitted to access table "${word}" (core security table is strictly forbidden)`
|
|
7668
|
-
);
|
|
7669
|
-
}
|
|
7670
|
-
}
|
|
7671
|
-
}
|
|
7672
|
-
const ownPrefixLower = ownPrefix ? ownPrefix.toLowerCase() : "";
|
|
7673
|
-
const validPrefixes = [ownPrefixLower, ...allowedPrefixes.map((p) => p.toLowerCase())].filter(Boolean);
|
|
7674
|
-
for (const table of tables) {
|
|
7675
|
-
if (table === "plugin_migrations") continue;
|
|
7676
|
-
if (researchAllowed && table.startsWith("plugin_research_")) continue;
|
|
7677
|
-
const isOwn = validPrefixes.some((p) => table.startsWith(p));
|
|
7678
|
-
if (isOwn) continue;
|
|
7679
|
-
if (table === "sqlite_master" || table === "sqlite_schema" || table === "sqlite_temp_master") {
|
|
7680
|
-
const strippedComments = sql.replace(/--.*$/gm, " ").replace(/\/\*[\s\S]*?\*\//g, " ").trim();
|
|
7681
|
-
const isAllowedMetaQuery = /^\s*SELECT\s+name\s+FROM\s+(?:sqlite_master|sqlite_schema)\s+WHERE\s+type\s*=\s*['"]table['"]\s+AND\s+name\s+LIKE\s+['"][A-Za-z0-9_%]+['"]\s*$/i.test(
|
|
7682
|
-
strippedComments
|
|
7683
|
-
) && ownPrefixLower && strippedComments.toLowerCase().includes(ownPrefixLower);
|
|
7684
|
-
if (isAllowedMetaQuery) continue;
|
|
7685
|
-
throw new Error(`Direct access to sqlite metadata table "${table}" is forbidden`);
|
|
7686
|
-
}
|
|
7687
|
-
if (isDdl) {
|
|
7688
|
-
throw new Error(
|
|
7689
|
-
`Worker plugin is not permitted to perform DDL on table "${table}" (only ${ownPrefix || "<none>"} namespace is allowed)`
|
|
7690
|
-
);
|
|
7691
|
-
}
|
|
7692
|
-
throw new Error(
|
|
7693
|
-
`Worker plugin is not permitted to access table "${table}" (only ${ownPrefix || "<none>"} namespace is allowed)`
|
|
7694
|
-
);
|
|
7695
|
-
}
|
|
7696
|
-
}
|
|
7697
|
-
var FORBIDDEN_CORE_TABLES, FORBIDDEN_CORE_TABLES_SET, FORBIDDEN_OPERATIONS, SUBQUERY_PLACEHOLDER;
|
|
7698
|
-
var init_sql_guard = __esm({
|
|
7699
|
-
"packages/core/plugin-host/sql-guard.ts"() {
|
|
7700
|
-
"use strict";
|
|
7701
|
-
FORBIDDEN_CORE_TABLES = Object.freeze([
|
|
7702
|
-
"users",
|
|
7703
|
-
"client_sessions",
|
|
7704
|
-
"plugins",
|
|
7705
|
-
"plugin_storage",
|
|
7706
|
-
"ai_providers",
|
|
7707
|
-
"processes",
|
|
7708
|
-
"pending_commands",
|
|
7709
|
-
"classes",
|
|
7710
|
-
"students",
|
|
7711
|
-
"class_students",
|
|
7712
|
-
"lessons",
|
|
7713
|
-
"assignments",
|
|
7714
|
-
"assignment_submissions",
|
|
7715
|
-
"attendance",
|
|
7716
|
-
"schedules",
|
|
7717
|
-
"class_grade_weights",
|
|
7718
|
-
"exams",
|
|
7719
|
-
"exam_scores",
|
|
7720
|
-
"student_semester_reports",
|
|
7721
|
-
"student_rollcalls",
|
|
7722
|
-
"student_seats",
|
|
7723
|
-
"student_read_notifications",
|
|
7724
|
-
"student_lesson_progress",
|
|
7725
|
-
"courseware",
|
|
7726
|
-
"courseware_attempt",
|
|
7727
|
-
"system_resources",
|
|
7728
|
-
"events",
|
|
7729
|
-
"_migrations",
|
|
7730
|
-
"site_settings",
|
|
7731
|
-
"agent_conversations",
|
|
7732
|
-
"classroom_sessions",
|
|
7733
|
-
"lesson_quiz_submissions",
|
|
7734
|
-
"courseware_score_config",
|
|
7735
|
-
"demo_data_registry",
|
|
7736
|
-
"whiteboard_elements",
|
|
7737
|
-
"student_point_logs",
|
|
7738
|
-
"lianyun_research_activities",
|
|
7739
|
-
"lianyun_research_groups",
|
|
7740
|
-
"lianyun_research_submissions",
|
|
7741
|
-
"lianyun_research_reviews"
|
|
7742
|
-
]);
|
|
7743
|
-
FORBIDDEN_CORE_TABLES_SET = new Set(FORBIDDEN_CORE_TABLES.map((t) => t.toLowerCase()));
|
|
7744
|
-
FORBIDDEN_OPERATIONS = [
|
|
7745
|
-
/\bATTACH(?:\s+DATABASE)?\b/i,
|
|
7746
|
-
/\bDETACH(?:\s+DATABASE)?\b/i,
|
|
7747
|
-
/\bPRAGMA\b/i,
|
|
7748
|
-
/\bVACUUM\b/i,
|
|
7749
|
-
/\bCREATE\s+(?:TEMP(?:ORARY)?\s+)?(?:TRIGGER|VIEW)\b/i,
|
|
7750
|
-
/\bDROP\s+(?:TEMP(?:ORARY)?\s+)?(?:TRIGGER|VIEW)\b/i
|
|
7751
|
-
];
|
|
7752
|
-
SUBQUERY_PLACEHOLDER = "__ol_subquery__";
|
|
7753
|
-
}
|
|
7754
|
-
});
|
|
7755
|
-
|
|
7756
8002
|
// packages/core/worker-runtime/service-host.ts
|
|
7757
8003
|
function sanitizeClonableValue(val, depth = 0, seen = /* @__PURE__ */ new WeakSet()) {
|
|
7758
8004
|
if (depth > 12) return null;
|
|
@@ -9050,6 +9296,7 @@ var init_di = __esm({
|
|
|
9050
9296
|
init_storage_service();
|
|
9051
9297
|
init_ai_service();
|
|
9052
9298
|
init_courseware_runtime_script_registry();
|
|
9299
|
+
init_ai_provider_resolver();
|
|
9053
9300
|
init_container();
|
|
9054
9301
|
}
|
|
9055
9302
|
});
|
|
@@ -10309,6 +10556,15 @@ var CommandBus = class {
|
|
|
10309
10556
|
hasHandler(commandType) {
|
|
10310
10557
|
return this.handlers.has(commandType) || this.legacyHandlers.has(commandType);
|
|
10311
10558
|
}
|
|
10559
|
+
/**
|
|
10560
|
+
* 只读枚举已注册的命令类型(modern + legacy)。
|
|
10561
|
+
*
|
|
10562
|
+
* 供插件激活后的「handler 有、ActionDescriptor 没有」自检使用(Issue #369):
|
|
10563
|
+
* 自检要能看清「这次激活新增了哪些命令」,而不是去读私有 Map。
|
|
10564
|
+
*/
|
|
10565
|
+
listRegisteredCommandTypes() {
|
|
10566
|
+
return Array.from(/* @__PURE__ */ new Set([...this.handlers.keys(), ...this.legacyHandlers.keys()]));
|
|
10567
|
+
}
|
|
10312
10568
|
async execute(command) {
|
|
10313
10569
|
const actorId = typeof command.actorId === "string" ? command.actorId.trim() : "";
|
|
10314
10570
|
if (!actorId) {
|
|
@@ -10411,6 +10667,20 @@ var ActionRegistry = class {
|
|
|
10411
10667
|
getActionByCommandType(commandType) {
|
|
10412
10668
|
return Array.from(this.actions.values()).find((a) => a.commandType === commandType);
|
|
10413
10669
|
}
|
|
10670
|
+
/** 已登记描述符覆盖的 commandType(去重、只读)。 */
|
|
10671
|
+
listRegisteredCommandTypes() {
|
|
10672
|
+
return Array.from(new Set(Array.from(this.actions.values()).map((a) => a.commandType))).sort();
|
|
10673
|
+
}
|
|
10674
|
+
/**
|
|
10675
|
+
* 给定命令类型里**没有** ActionDescriptor 的那些。
|
|
10676
|
+
*
|
|
10677
|
+
* 命令总线是 default-deny:这种命令会在用户点击时被 403 `COMMAND_NOT_REGISTERED` 拒绝。
|
|
10678
|
+
* 插件激活后用它把「注册了 handler 却忘了登记描述符」提前打成一条 WARN(Issue #369)。
|
|
10679
|
+
*/
|
|
10680
|
+
listMissingDescriptors(commandTypes) {
|
|
10681
|
+
const registered = new Set(Array.from(this.actions.values()).map((a) => a.commandType));
|
|
10682
|
+
return Array.from(new Set(commandTypes)).filter((type) => !registered.has(type)).sort();
|
|
10683
|
+
}
|
|
10414
10684
|
};
|
|
10415
10685
|
|
|
10416
10686
|
// packages/core/capability/event/capability-event-bus.ts
|
|
@@ -10678,6 +10948,7 @@ var InvocationEngine = class {
|
|
|
10678
10948
|
};
|
|
10679
10949
|
|
|
10680
10950
|
// packages/core/capability/guard/capability-guard.ts
|
|
10951
|
+
init_catalog();
|
|
10681
10952
|
var CapabilityGuard = class _CapabilityGuard {
|
|
10682
10953
|
constructor() {
|
|
10683
10954
|
this.actorCapabilities = /* @__PURE__ */ new Map();
|
|
@@ -10693,20 +10964,20 @@ var CapabilityGuard = class _CapabilityGuard {
|
|
|
10693
10964
|
]);
|
|
10694
10965
|
this.actorCapabilities.set("anonymous", []);
|
|
10695
10966
|
this.actorCapabilities.set("agent-system-0", [
|
|
10696
|
-
|
|
10697
|
-
|
|
10698
|
-
|
|
10967
|
+
CAP.LESSON_WRITE,
|
|
10968
|
+
CAP.LESSON_DELETE,
|
|
10969
|
+
CAP.WHITEBOARD_WRITE,
|
|
10699
10970
|
"quiz:write",
|
|
10700
|
-
|
|
10701
|
-
|
|
10702
|
-
|
|
10703
|
-
|
|
10704
|
-
|
|
10705
|
-
|
|
10706
|
-
|
|
10971
|
+
CAP.PLUGIN_READ,
|
|
10972
|
+
CAP.VFS_READ,
|
|
10973
|
+
CAP.VFS_WRITE,
|
|
10974
|
+
CAP.MANAGEMENT_WRITE,
|
|
10975
|
+
CAP.MANAGEMENT_READ,
|
|
10976
|
+
CAP.PROCESS_WRITE,
|
|
10977
|
+
CAP.PROCESS_READ
|
|
10707
10978
|
]);
|
|
10708
10979
|
this.actorCapabilities.set("teacher-demo", ["lesson:*", "whiteboard:*", "management:*", "quiz:*", "vfs:*"]);
|
|
10709
|
-
this.actorCapabilities.set("student-demo", [
|
|
10980
|
+
this.actorCapabilities.set("student-demo", [CAP.STUDENT_WRITE, CAP.LESSON_READ, CAP.WHITEBOARD_READ]);
|
|
10710
10981
|
}
|
|
10711
10982
|
grant(actorId, cap) {
|
|
10712
10983
|
const caps = this.actorCapabilities.get(actorId) || [];
|
|
@@ -10740,11 +11011,11 @@ var CapabilityGuard = class _CapabilityGuard {
|
|
|
10740
11011
|
"vfs:*",
|
|
10741
11012
|
"process:*",
|
|
10742
11013
|
"plugin:*",
|
|
10743
|
-
|
|
10744
|
-
|
|
10745
|
-
|
|
10746
|
-
|
|
10747
|
-
|
|
11014
|
+
CAP.STUDENT_WRITE,
|
|
11015
|
+
CAP.ASSIGNMENT_READ,
|
|
11016
|
+
CAP.ASSIGNMENT_SUBMIT,
|
|
11017
|
+
CAP.ASSIGNMENT_REVIEW,
|
|
11018
|
+
CAP.ASSIGNMENT_MANAGE
|
|
10748
11019
|
];
|
|
10749
11020
|
const [reqRes2, reqAct2] = requiredCap.split(":");
|
|
10750
11021
|
if (teacherCaps.some((c) => {
|
|
@@ -10756,12 +11027,12 @@ var CapabilityGuard = class _CapabilityGuard {
|
|
|
10756
11027
|
}
|
|
10757
11028
|
if (role === "student") {
|
|
10758
11029
|
const studentCaps = [
|
|
10759
|
-
|
|
10760
|
-
|
|
10761
|
-
|
|
10762
|
-
|
|
10763
|
-
|
|
10764
|
-
|
|
11030
|
+
CAP.STUDENT_WRITE,
|
|
11031
|
+
CAP.LESSON_READ,
|
|
11032
|
+
CAP.WHITEBOARD_READ,
|
|
11033
|
+
CAP.ASSIGNMENT_READ,
|
|
11034
|
+
CAP.ASSIGNMENT_SUBMIT,
|
|
11035
|
+
CAP.ASSIGNMENT_REVIEW
|
|
10765
11036
|
];
|
|
10766
11037
|
const [reqRes2, reqAct2] = requiredCap.split(":");
|
|
10767
11038
|
if (studentCaps.some((c) => {
|
|
@@ -12100,18 +12371,25 @@ function generateUnambiguousPassword(length = 20) {
|
|
|
12100
12371
|
}
|
|
12101
12372
|
return out;
|
|
12102
12373
|
}
|
|
12374
|
+
function resolveSeedPasswordMode(env = process.env) {
|
|
12375
|
+
return env.OPENLEARN_SEED_PASSWORD_MODE?.trim().toLowerCase() === "random" ? "random" : "default";
|
|
12376
|
+
}
|
|
12103
12377
|
function resolveSeedCredential(username, options) {
|
|
12104
12378
|
if (options?.allowWellKnown) {
|
|
12105
|
-
return { password: username, mustChange: false, generated: false };
|
|
12379
|
+
return { password: username, mustChange: false, generated: false, source: "playwright" };
|
|
12106
12380
|
}
|
|
12107
12381
|
const envValue = options?.envValue?.trim();
|
|
12108
12382
|
if (envValue) {
|
|
12109
|
-
return { password: envValue, mustChange: envValue === username, generated: false };
|
|
12383
|
+
return { password: envValue, mustChange: envValue === username, generated: false, source: "env" };
|
|
12110
12384
|
}
|
|
12111
12385
|
if (options?.vitestFallback) {
|
|
12112
|
-
return { password: `Vitest-${username}-NotDefault9`, mustChange: false, generated: false };
|
|
12386
|
+
return { password: `Vitest-${username}-NotDefault9`, mustChange: false, generated: false, source: "vitest" };
|
|
12387
|
+
}
|
|
12388
|
+
const mode = options?.mode ?? resolveSeedPasswordMode();
|
|
12389
|
+
if (mode === "random") {
|
|
12390
|
+
return { password: generateUnambiguousPassword(20), mustChange: true, generated: true, source: "random" };
|
|
12113
12391
|
}
|
|
12114
|
-
return { password:
|
|
12392
|
+
return { password: username, mustChange: true, generated: false, source: "default" };
|
|
12115
12393
|
}
|
|
12116
12394
|
function resolveBootstrapCredentialDir() {
|
|
12117
12395
|
if (process.env.OPENLEARN_DATA_DIR?.trim()) {
|
|
@@ -12135,6 +12413,11 @@ function persistGeneratedSeedCredentials(lines) {
|
|
|
12135
12413
|
console.warn(`[SECURITY] \u65E0\u6CD5\u5199\u5165 ${filePath}\uFF0C\u8BF7\u4ECE\u4E0A\u65B9\u65E5\u5FD7\u6284\u5F55\u521D\u59CB\u53E3\u4EE4\u3002`, err);
|
|
12136
12414
|
}
|
|
12137
12415
|
}
|
|
12416
|
+
function logDefaultSeedCredentials(usernames) {
|
|
12417
|
+
console.warn(
|
|
12418
|
+
"[SECURITY] \u9996\u6B21\u542F\u52A8\u5DF2\u521B\u5EFA\u79CD\u5B50\u8D26\u53F7\uFF0C\u521D\u59CB\u53E3\u4EE4 = \u7528\u6237\u540D\uFF08\u9996\u6B21\u767B\u5F55\u540E\u5FC5\u987B\u4FEE\u6539\uFF09\uFF1A\n" + usernames.map((name) => ` ${name} / ${name}`).join("\n") + "\n \u6539\u5BC6\u524D\uFF0C\u9664\u300C\u6539\u5BC6 / \u767B\u51FA\u300D\u4EE5\u5916\u7684\u6240\u6709\u63A5\u53E3\u90FD\u4F1A\u88AB\u62D2\u7EDD\uFF08403 FORBIDDEN_DEFAULT_PASSWORD\uFF09\u3002\n \u5982\u9700\u968F\u673A\u521D\u59CB\u53E3\u4EE4\uFF0C\u8BBE\u7F6E OPENLEARN_SEED_PASSWORD_MODE=random \u540E\u91CD\u5EFA\u6570\u636E\u5E93\u3002"
|
|
12419
|
+
);
|
|
12420
|
+
}
|
|
12138
12421
|
function verifyPassword(pwd, storedHash) {
|
|
12139
12422
|
if (storedHash.startsWith("$2")) {
|
|
12140
12423
|
return { valid: import_bcryptjs.default.compareSync(pwd, storedHash), needsUpgrade: false };
|
|
@@ -12248,16 +12531,19 @@ function initializeDatabase(instance) {
|
|
|
12248
12531
|
const countObj = instance.prepare("SELECT COUNT(*) as cnt FROM users").get();
|
|
12249
12532
|
const allowWellKnown = isPlaywrightTestFlag(process.env.PLAYWRIGHT_TEST);
|
|
12250
12533
|
const vitestFallback = Boolean(process.env.VITEST) && !allowWellKnown;
|
|
12534
|
+
const seedMode = resolveSeedPasswordMode();
|
|
12251
12535
|
if (countObj && countObj.cnt === 0) {
|
|
12252
12536
|
const adminCred = resolveSeedCredential("admin", {
|
|
12253
12537
|
envValue: process.env.OPENLEARN_ADMIN_PASSWORD,
|
|
12254
12538
|
allowWellKnown,
|
|
12255
|
-
vitestFallback
|
|
12539
|
+
vitestFallback,
|
|
12540
|
+
mode: seedMode
|
|
12256
12541
|
});
|
|
12257
12542
|
const teacherCred = resolveSeedCredential("teacher", {
|
|
12258
12543
|
envValue: process.env.OPENLEARN_TEACHER_PASSWORD,
|
|
12259
12544
|
allowWellKnown,
|
|
12260
|
-
vitestFallback
|
|
12545
|
+
vitestFallback,
|
|
12546
|
+
mode: seedMode
|
|
12261
12547
|
});
|
|
12262
12548
|
const insertStmt = instance.prepare(
|
|
12263
12549
|
"INSERT INTO users (id, username, password_hash, role, name, created_at, must_change_password) VALUES (?, ?, ?, ?, ?, ?, ?)"
|
|
@@ -12284,8 +12570,12 @@ function initializeDatabase(instance) {
|
|
|
12284
12570
|
if (adminCred.generated) generated.push(`admin ${adminCred.password}`);
|
|
12285
12571
|
if (teacherCred.generated) generated.push(`teacher ${teacherCred.password}`);
|
|
12286
12572
|
if (generated.length > 0) persistGeneratedSeedCredentials(generated);
|
|
12287
|
-
|
|
12288
|
-
|
|
12573
|
+
const defaulted = [];
|
|
12574
|
+
if (adminCred.source === "default") defaulted.push("admin");
|
|
12575
|
+
if (teacherCred.source === "default") defaulted.push("teacher");
|
|
12576
|
+
if (defaulted.length > 0) logDefaultSeedCredentials(defaulted);
|
|
12577
|
+
} else {
|
|
12578
|
+
const seeded = allowWellKnown ? [] : instance.prepare("SELECT id, username, password_hash FROM users WHERE username IN (?, ?)").all("admin", "teacher");
|
|
12289
12579
|
for (const user of seeded) {
|
|
12290
12580
|
if (!verifyPassword(user.username, user.password_hash).valid) continue;
|
|
12291
12581
|
instance.prepare("UPDATE users SET must_change_password = 1 WHERE id = ?").run(user.id);
|
|
@@ -13205,8 +13495,8 @@ var ManagementPlugin = {
|
|
|
13205
13495
|
});
|
|
13206
13496
|
await commandBus.registerHandler(classTemplateDownloadCmd, {
|
|
13207
13497
|
async execute() {
|
|
13208
|
-
const headers = "Class Name,Class Desc,Student Name,Student Email";
|
|
13209
|
-
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";
|
|
13210
13500
|
const content = `${headers}
|
|
13211
13501
|
${sampleRow}`;
|
|
13212
13502
|
const fileId = (0, import_uuid4.v7)();
|
|
@@ -13238,8 +13528,8 @@ ${sampleRow}`;
|
|
|
13238
13528
|
});
|
|
13239
13529
|
await commandBus.registerHandler(studentTemplateDownloadCmd, {
|
|
13240
13530
|
async execute() {
|
|
13241
|
-
const headers = "Student Name,Student Email";
|
|
13242
|
-
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";
|
|
13243
13533
|
const content = `${headers}
|
|
13244
13534
|
${sampleRow}`;
|
|
13245
13535
|
const fileId = (0, import_uuid4.v7)();
|
|
@@ -14920,6 +15210,45 @@ var BuiltinPlugin = {
|
|
|
14920
15210
|
"page_meta"
|
|
14921
15211
|
// 分页与大纲元数据
|
|
14922
15212
|
];
|
|
15213
|
+
const normalizePluginElementData = (dataObj) => {
|
|
15214
|
+
const pluginId = typeof dataObj.pluginId === "string" ? dataObj.pluginId.trim() : "";
|
|
15215
|
+
if (!pluginId) {
|
|
15216
|
+
console.warn("[Whiteboard] plugin \u5143\u7D20\u672A\u58F0\u660E pluginId\uFF0C\u5C06\u53EA\u80FD\u6309 widgetId \u67E5\u627E\u7EC4\u4EF6\uFF1B\u5EFA\u8BAE\u8865\u4E0A manifest id\u3002");
|
|
15217
|
+
}
|
|
15218
|
+
const teacher = typeof dataObj.teacherWidgetId === "string" ? dataObj.teacherWidgetId.trim() : "";
|
|
15219
|
+
const student = typeof dataObj.studentWidgetId === "string" ? dataObj.studentWidgetId.trim() : "";
|
|
15220
|
+
if (!teacher && !student) {
|
|
15221
|
+
throw new Error(
|
|
15222
|
+
"plugin \u5143\u7D20\u5FC5\u987B\u81F3\u5C11\u58F0\u660E teacherWidgetId \u6216 studentWidgetId \u4E4B\u4E00\uFF0C\u5426\u5219\u6559\u5E08\u7AEF / \u5B66\u751F\u7AEF\u90FD\u4F1A\u505C\u5728\u52A0\u8F7D\u6001\u3002"
|
|
15223
|
+
);
|
|
15224
|
+
}
|
|
15225
|
+
if (!teacher) {
|
|
15226
|
+
dataObj.teacherWidgetId = student;
|
|
15227
|
+
console.warn(`[Whiteboard] plugin \u5143\u7D20\u53EA\u58F0\u660E\u4E86 studentWidgetId\uFF0C\u5DF2\u56DE\u9000 teacherWidgetId="${student}"\u3002`);
|
|
15228
|
+
} else if (!student) {
|
|
15229
|
+
dataObj.studentWidgetId = teacher;
|
|
15230
|
+
console.warn(`[Whiteboard] plugin \u5143\u7D20\u53EA\u58F0\u660E\u4E86 teacherWidgetId\uFF0C\u5DF2\u56DE\u9000 studentWidgetId="${teacher}"\u3002`);
|
|
15231
|
+
}
|
|
15232
|
+
};
|
|
15233
|
+
const buildElementData = (type, rawData, segmentId, page) => {
|
|
15234
|
+
let dataObj = null;
|
|
15235
|
+
try {
|
|
15236
|
+
const parsed = JSON.parse(rawData);
|
|
15237
|
+
dataObj = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
|
|
15238
|
+
} catch {
|
|
15239
|
+
if (type === "plugin") {
|
|
15240
|
+
throw new Error(
|
|
15241
|
+
'plugin \u5143\u7D20\u7684 data \u5FC5\u987B\u662F\u5408\u6CD5 JSON \u5B57\u7B26\u4E32\uFF08\u4F8B\u5982 {"pluginId":"ext-x","teacherWidgetId":"w"}\uFF09\u3002'
|
|
15242
|
+
);
|
|
15243
|
+
}
|
|
15244
|
+
}
|
|
15245
|
+
if (!dataObj) return rawData;
|
|
15246
|
+
if (segmentId && !dataObj.segmentId) dataObj.segmentId = segmentId;
|
|
15247
|
+
if (page !== void 0 && dataObj.page === void 0) dataObj.page = page;
|
|
15248
|
+
else if (dataObj.page === void 0) dataObj.page = 0;
|
|
15249
|
+
if (type === "plugin") normalizePluginElementData(dataObj);
|
|
15250
|
+
return JSON.stringify(dataObj);
|
|
15251
|
+
};
|
|
14923
15252
|
const isAllowedElementType = (type) => {
|
|
14924
15253
|
if (!type || typeof type !== "string") return false;
|
|
14925
15254
|
if (KNOWN_ELEMENT_TYPES2.includes(type)) return true;
|
|
@@ -14968,20 +15297,7 @@ var BuiltinPlugin = {
|
|
|
14968
15297
|
throw new Error(`\u4E0D\u652F\u6301\u7684\u5143\u7D20\u7C7B\u578B "${payload.type}"\u3002${hint}`);
|
|
14969
15298
|
}
|
|
14970
15299
|
const elementId = (0, import_uuid7.v7)();
|
|
14971
|
-
|
|
14972
|
-
try {
|
|
14973
|
-
const dataObj = JSON.parse(dataStr);
|
|
14974
|
-
if (payload.segmentId && !dataObj.segmentId) {
|
|
14975
|
-
dataObj.segmentId = payload.segmentId;
|
|
14976
|
-
}
|
|
14977
|
-
if (payload.page !== void 0 && dataObj.page === void 0) {
|
|
14978
|
-
dataObj.page = payload.page;
|
|
14979
|
-
} else if (dataObj.page === void 0) {
|
|
14980
|
-
dataObj.page = 0;
|
|
14981
|
-
}
|
|
14982
|
-
dataStr = JSON.stringify(dataObj);
|
|
14983
|
-
} catch {
|
|
14984
|
-
}
|
|
15300
|
+
const dataStr = buildElementData(payload.type, payload.data, payload.segmentId, payload.page);
|
|
14985
15301
|
const stmt = db2.prepare(
|
|
14986
15302
|
"INSERT INTO whiteboard_elements (id, lesson_id, type, data, created_at) VALUES (?, ?, ?, ?, ?)"
|
|
14987
15303
|
);
|
|
@@ -15210,15 +15526,7 @@ var BuiltinPlugin = {
|
|
|
15210
15526
|
throw new Error(`\u4E0D\u652F\u6301\u7684\u5143\u7D20\u7C7B\u578B "${el.type}"\u3002\u5DF2\u77E5\u7C7B\u578B\uFF1A${KNOWN_ELEMENT_TYPES2.join(", ")}`);
|
|
15211
15527
|
}
|
|
15212
15528
|
const elementId = (0, import_uuid7.v7)();
|
|
15213
|
-
|
|
15214
|
-
try {
|
|
15215
|
-
const dataObj = JSON.parse(dataStr);
|
|
15216
|
-
if (el.segmentId && !dataObj.segmentId) dataObj.segmentId = el.segmentId;
|
|
15217
|
-
if (el.page !== void 0 && dataObj.page === void 0) dataObj.page = el.page;
|
|
15218
|
-
else if (dataObj.page === void 0) dataObj.page = 0;
|
|
15219
|
-
dataStr = JSON.stringify(dataObj);
|
|
15220
|
-
} catch {
|
|
15221
|
-
}
|
|
15529
|
+
const dataStr = buildElementData(el.type, el.data, el.segmentId, el.page);
|
|
15222
15530
|
insertStmt.run(elementId, payload.lessonId, el.type, dataStr, Date.now());
|
|
15223
15531
|
elementIds.push(elementId);
|
|
15224
15532
|
}
|
|
@@ -18042,6 +18350,40 @@ var SemesterGradeService = class {
|
|
|
18042
18350
|
}
|
|
18043
18351
|
};
|
|
18044
18352
|
|
|
18353
|
+
// packages/core/di/roster-service.ts
|
|
18354
|
+
var ClassRosterService = class {
|
|
18355
|
+
constructor(db2) {
|
|
18356
|
+
this.db = db2;
|
|
18357
|
+
}
|
|
18358
|
+
async listClasses() {
|
|
18359
|
+
const rows = this.db.prepare(
|
|
18360
|
+
`SELECT c.id AS id, c.name AS name, COUNT(cs.student_id) AS student_count
|
|
18361
|
+
FROM classes c
|
|
18362
|
+
LEFT JOIN class_students cs ON cs.class_id = c.id
|
|
18363
|
+
GROUP BY c.id, c.name
|
|
18364
|
+
ORDER BY c.created_at DESC, c.name ASC`
|
|
18365
|
+
).all();
|
|
18366
|
+
return rows.map((row) => ({ id: row.id, name: row.name, studentCount: Number(row.student_count) }));
|
|
18367
|
+
}
|
|
18368
|
+
async listClassStudents(classId) {
|
|
18369
|
+
const rows = this.db.prepare(
|
|
18370
|
+
`SELECT s.id AS id, s.name AS name, s.student_number AS student_number
|
|
18371
|
+
FROM class_students cs
|
|
18372
|
+
JOIN students s ON s.id = cs.student_id
|
|
18373
|
+
WHERE cs.class_id = ?
|
|
18374
|
+
ORDER BY s.name ASC`
|
|
18375
|
+
).all(classId);
|
|
18376
|
+
return rows.map(toStudentRef);
|
|
18377
|
+
}
|
|
18378
|
+
async getStudent(studentId) {
|
|
18379
|
+
const row = this.db.prepare("SELECT id, name, student_number FROM students WHERE id = ?").get(studentId);
|
|
18380
|
+
return row ? toStudentRef(row) : null;
|
|
18381
|
+
}
|
|
18382
|
+
};
|
|
18383
|
+
function toStudentRef(row) {
|
|
18384
|
+
return { id: row.id, name: row.name, studentNumber: row.student_number ?? null };
|
|
18385
|
+
}
|
|
18386
|
+
|
|
18045
18387
|
// packages/core/di/points-dimension-registry.ts
|
|
18046
18388
|
var PointsDimensionRegistry = class {
|
|
18047
18389
|
constructor() {
|
|
@@ -20253,6 +20595,7 @@ init_types();
|
|
|
20253
20595
|
init_errors2();
|
|
20254
20596
|
init_interfaces();
|
|
20255
20597
|
init_plugin_grants();
|
|
20598
|
+
init_catalog();
|
|
20256
20599
|
var PluginHostLifecycle = class extends PluginHostHttp {
|
|
20257
20600
|
/**
|
|
20258
20601
|
* 激活插件。
|
|
@@ -20310,12 +20653,47 @@ var PluginHostLifecycle = class extends PluginHostHttp {
|
|
|
20310
20653
|
const currentState = this.pluginStates.get(pluginId) ?? "installed" /* INSTALLED */;
|
|
20311
20654
|
this.validateTransition(pluginId, currentState, "activating" /* ACTIVATING */);
|
|
20312
20655
|
this.setPluginState(pluginId, "activating" /* ACTIVATING */);
|
|
20656
|
+
const commandTypesBefore = this.listHostCommandTypes();
|
|
20313
20657
|
const preloaded = this.preloadedPlugins.get(pluginId);
|
|
20314
20658
|
if (preloaded) {
|
|
20315
20659
|
await this.activatePreloaded(pluginId, preloaded);
|
|
20660
|
+
this.auditMissingActionDescriptors(pluginId, commandTypesBefore);
|
|
20316
20661
|
return;
|
|
20317
20662
|
}
|
|
20318
20663
|
await this.activateFromDatabase(pluginId, currentState);
|
|
20664
|
+
this.auditMissingActionDescriptors(pluginId, commandTypesBefore);
|
|
20665
|
+
}
|
|
20666
|
+
/** 宿主 CommandBus 当前注册的命令类型;解析失败返回空集(自检不能影响激活)。 */
|
|
20667
|
+
listHostCommandTypes() {
|
|
20668
|
+
try {
|
|
20669
|
+
const bus = this.serviceRegistry.resolve(ICommandBusServiceToken4);
|
|
20670
|
+
return new Set(bus.listRegisteredCommandTypes?.() ?? []);
|
|
20671
|
+
} catch {
|
|
20672
|
+
return /* @__PURE__ */ new Set();
|
|
20673
|
+
}
|
|
20674
|
+
}
|
|
20675
|
+
/**
|
|
20676
|
+
* 插件激活后的命令契约自检(Issue #369)。
|
|
20677
|
+
*
|
|
20678
|
+
* 命令总线是 default-deny:handler 有、ActionDescriptor 没有的命令会在用户点击时
|
|
20679
|
+
* 403 `COMMAND_NOT_REGISTERED`。静态扫描抓不到循环注册 / 对象表 / `regHandler()` 包装,
|
|
20680
|
+
* 所以改为激活后做一次运行时差集,把缺口提前打成立即可见的 WARN。
|
|
20681
|
+
*
|
|
20682
|
+
* 自检只服务可观测性:任何异常都不能影响激活结果,故整体 try/catch 且只打日志。
|
|
20683
|
+
*/
|
|
20684
|
+
auditMissingActionDescriptors(pluginId, before) {
|
|
20685
|
+
try {
|
|
20686
|
+
if (this.inflightActivate.size > 1) return;
|
|
20687
|
+
const added = Array.from(this.listHostCommandTypes()).filter((type) => !before.has(type));
|
|
20688
|
+
if (added.length === 0) return;
|
|
20689
|
+
const registry4 = this.serviceRegistry.resolve(IActionRegistryServiceToken4);
|
|
20690
|
+
const missing = registry4.listMissingDescriptors?.(added) ?? [];
|
|
20691
|
+
if (missing.length === 0) return;
|
|
20692
|
+
console.warn(
|
|
20693
|
+
`[PluginHost] \u63D2\u4EF6 "${pluginId}" \u6709 ${missing.length} \u4E2A\u547D\u4EE4 handler \u6CA1\u6709 ActionDescriptor\uFF1A${missing.join(", ")}\u3002\u8FD9\u4E9B\u547D\u4EE4\u4F1A\u88AB default-deny \u4EE5 403 COMMAND_NOT_REGISTERED \u62D2\u7EDD\uFF0C\u8BF7\u4E3A\u6BCF\u4E2A registerHandler \u540C\u65F6\u6CE8\u518C actionRegistry.register(...)\u3002`
|
|
20694
|
+
);
|
|
20695
|
+
} catch {
|
|
20696
|
+
}
|
|
20319
20697
|
}
|
|
20320
20698
|
/**
|
|
20321
20699
|
* preloaded 插件的激活(Phase 8,内置插件路径)。
|
|
@@ -20566,6 +20944,12 @@ var PluginHostLifecycle = class extends PluginHostHttp {
|
|
|
20566
20944
|
async grantCapabilities(actorId, caps, opts) {
|
|
20567
20945
|
if (caps.length === 0) return;
|
|
20568
20946
|
try {
|
|
20947
|
+
const { unknown } = validateManifestCapabilities(caps);
|
|
20948
|
+
if (unknown.length > 0) {
|
|
20949
|
+
console.error(
|
|
20950
|
+
`[PluginHost] "${actorId}" \u7684 manifest \u58F0\u660E\u4E86\u672A\u77E5\u80FD\u529B\uFF1A${unknown.join(", ")}\u3002\u8FD9\u4E9B\u4E32\u4E0D\u4F1A\u751F\u6548\uFF08\u5B88\u536B\u53EA\u8BA4 ActionDescriptor.capabilityRequired\uFF09\u3002\u5408\u6CD5\u6E05\u5355\u89C1 packages/core/capability/catalog.ts \u4E0E docs/reference/plugin-capability-matrix.md\u3002`
|
|
20951
|
+
);
|
|
20952
|
+
}
|
|
20569
20953
|
const capService = await this.serviceRegistry.resolve(ICapabilityServiceToken);
|
|
20570
20954
|
for (const cap of caps) {
|
|
20571
20955
|
if (typeof cap !== "string" || isBlockedPluginCapability(cap)) {
|
|
@@ -20906,7 +21290,7 @@ var import_node_fs11 = __toESM(require("node:fs"), 1);
|
|
|
20906
21290
|
var import_node_path13 = __toESM(require("node:path"), 1);
|
|
20907
21291
|
var import_node_url5 = require("node:url");
|
|
20908
21292
|
var import_meta7 = {};
|
|
20909
|
-
var FALLBACK_VERSION = "0.7.
|
|
21293
|
+
var FALLBACK_VERSION = "0.7.12";
|
|
20910
21294
|
function readPlatformVersion() {
|
|
20911
21295
|
const bases = [];
|
|
20912
21296
|
if (typeof __dirname !== "undefined") bases.push(__dirname);
|
|
@@ -30615,6 +30999,7 @@ var Kernel = class {
|
|
|
30615
30999
|
this.serviceRegistry.register(ISemesterGradeServiceToken, new SemesterGradeService(this.db));
|
|
30616
31000
|
this.serviceRegistry.register(IPointsDimensionRegistryToken, new PointsDimensionRegistry());
|
|
30617
31001
|
this.serviceRegistry.register(IPointsLedgerServiceToken, new PointsLedgerService(this.db));
|
|
31002
|
+
this.serviceRegistry.register(IClassRosterServiceToken, new ClassRosterService(this.db));
|
|
30618
31003
|
this.serviceRegistry.register(ILessonEngineServiceToken, { getRuntime: async () => this.lessonRuntime });
|
|
30619
31004
|
this.serviceRegistry.register(IClassroomRuntimeServiceToken, {
|
|
30620
31005
|
getRuntimeKernel: async () => this.classroomRuntime
|
|
@@ -32470,6 +32855,299 @@ var import_helmet = __toESM(require("helmet"), 1);
|
|
|
32470
32855
|
|
|
32471
32856
|
// server/security/rate-limit.ts
|
|
32472
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
|
|
32473
33151
|
function loginRateLimitKey(req) {
|
|
32474
33152
|
const body = req.body ?? {};
|
|
32475
33153
|
const raw = body.username ?? body.studentId ?? body.studentName ?? "";
|
|
@@ -32486,6 +33164,18 @@ function writeRateLimitKey(req) {
|
|
|
32486
33164
|
return ip;
|
|
32487
33165
|
}
|
|
32488
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
|
+
}
|
|
32489
33179
|
var limiterValidation = {
|
|
32490
33180
|
xForwardedForHeader: false,
|
|
32491
33181
|
trustProxy: false,
|
|
@@ -32524,6 +33214,17 @@ function createAiLimiter(max) {
|
|
|
32524
33214
|
validate: limiterValidation
|
|
32525
33215
|
});
|
|
32526
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
|
+
}
|
|
32527
33228
|
|
|
32528
33229
|
// server/security/trust-proxy.ts
|
|
32529
33230
|
function resolveTrustProxy(raw) {
|
|
@@ -32808,21 +33509,29 @@ function validateStartupEnv(env = process.env) {
|
|
|
32808
33509
|
if (isProduction) {
|
|
32809
33510
|
const adminPassword = trimmed(env, "OPENLEARN_ADMIN_PASSWORD");
|
|
32810
33511
|
if (adminPassword && adminPassword.toLowerCase() === "admin") {
|
|
32811
|
-
|
|
33512
|
+
warnings.push({
|
|
32812
33513
|
key: "OPENLEARN_ADMIN_PASSWORD",
|
|
32813
|
-
severity: "
|
|
32814
|
-
message:
|
|
33514
|
+
severity: "warning",
|
|
33515
|
+
message: "OPENLEARN_ADMIN_PASSWORD \u4E0E\u7528\u6237\u540D\u76F8\u540C\u3002\u8FD9\u4E0D\u6BD4\u7559\u7A7A\u66F4\u5F31\uFF08\u7559\u7A7A\u65F6\u521D\u59CB\u53E3\u4EE4\u540C\u6837\u662F admin\uFF09\uFF0C\u4F46\u8BF7\u786E\u8BA4\u9996\u6B21\u767B\u5F55\u540E\u7ACB\u5373\u6539\u5BC6\uFF1B\u751F\u4EA7\u90E8\u7F72\u5EFA\u8BAE\u76F4\u63A5\u8BBE\u7F6E\u4E3A\u5F3A\u53E3\u4EE4\u3002"
|
|
32815
33516
|
});
|
|
32816
33517
|
}
|
|
32817
33518
|
const teacherPassword = trimmed(env, "OPENLEARN_TEACHER_PASSWORD");
|
|
32818
33519
|
if (teacherPassword && teacherPassword.toLowerCase() === "teacher") {
|
|
32819
|
-
|
|
33520
|
+
warnings.push({
|
|
32820
33521
|
key: "OPENLEARN_TEACHER_PASSWORD",
|
|
32821
|
-
severity: "
|
|
32822
|
-
message:
|
|
33522
|
+
severity: "warning",
|
|
33523
|
+
message: "OPENLEARN_TEACHER_PASSWORD \u4E0E\u7528\u6237\u540D\u76F8\u540C\u3002\u8BF7\u786E\u8BA4\u9996\u6B21\u767B\u5F55\u540E\u7ACB\u5373\u6539\u5BC6\uFF0C\u751F\u4EA7\u90E8\u7F72\u5EFA\u8BAE\u8BBE\u7F6E\u4E3A\u5F3A\u53E3\u4EE4\u3002"
|
|
32823
33524
|
});
|
|
32824
33525
|
}
|
|
32825
33526
|
}
|
|
33527
|
+
const seedMode = trimmed(env, "OPENLEARN_SEED_PASSWORD_MODE");
|
|
33528
|
+
if (seedMode && !["default", "random"].includes(seedMode.toLowerCase())) {
|
|
33529
|
+
errors.push({
|
|
33530
|
+
key: "OPENLEARN_SEED_PASSWORD_MODE",
|
|
33531
|
+
severity: "error",
|
|
33532
|
+
message: `\u53EA\u652F\u6301 "default"\uFF08\u53E3\u4EE4 = \u7528\u6237\u540D\uFF0C\u9996\u6B21\u767B\u5F55\u5F3A\u5236\u6539\u5BC6\uFF09\u6216 "random"\uFF08\u968F\u673A\u53E3\u4EE4\u5199\u5165 bootstrap-credentials.txt\uFF09\uFF0C\u6536\u5230 "${seedMode}"\u3002`
|
|
33533
|
+
});
|
|
33534
|
+
}
|
|
32826
33535
|
const metricsToken = trimmed(env, "METRICS_TOKEN");
|
|
32827
33536
|
if (metricsToken && metricsToken.length < 16) {
|
|
32828
33537
|
warnings.push({
|
|
@@ -33892,297 +34601,6 @@ function startBackupScheduler(deps) {
|
|
|
33892
34601
|
};
|
|
33893
34602
|
}
|
|
33894
34603
|
|
|
33895
|
-
// server/security/route-scope.ts
|
|
33896
|
-
var AUTH_MIDDLEWARE_MARKER = Symbol.for("openlearn.routeScope.authMiddleware");
|
|
33897
|
-
var DATA_SCOPE_MARKER = Symbol.for("openlearn.routeScope.dataScope");
|
|
33898
|
-
function markAuthMiddleware(middleware, roles) {
|
|
33899
|
-
Object.defineProperty(middleware, AUTH_MIDDLEWARE_MARKER, {
|
|
33900
|
-
value: { roles: [...roles] },
|
|
33901
|
-
enumerable: false,
|
|
33902
|
-
configurable: true
|
|
33903
|
-
});
|
|
33904
|
-
return middleware;
|
|
33905
|
-
}
|
|
33906
|
-
function markDataScopeMiddleware(middleware, scope) {
|
|
33907
|
-
Object.defineProperty(middleware, DATA_SCOPE_MARKER, {
|
|
33908
|
-
value: scope,
|
|
33909
|
-
enumerable: false,
|
|
33910
|
-
configurable: true
|
|
33911
|
-
});
|
|
33912
|
-
return middleware;
|
|
33913
|
-
}
|
|
33914
|
-
function readMarker(handle, marker) {
|
|
33915
|
-
if (typeof handle !== "function") return void 0;
|
|
33916
|
-
return handle[marker];
|
|
33917
|
-
}
|
|
33918
|
-
function collectAuthRoutes(app) {
|
|
33919
|
-
const router = app._router ?? app.router;
|
|
33920
|
-
const stack = router?.stack ?? [];
|
|
33921
|
-
const result = [];
|
|
33922
|
-
for (const layer of stack) {
|
|
33923
|
-
const route = layer?.route;
|
|
33924
|
-
if (!route) continue;
|
|
33925
|
-
const handles = (route.stack ?? []).map((entry) => entry.handle);
|
|
33926
|
-
const authMeta = handles.map((handle) => readMarker(handle, AUTH_MIDDLEWARE_MARKER)).find(Boolean);
|
|
33927
|
-
if (!authMeta) continue;
|
|
33928
|
-
const scope = handles.map((handle) => readMarker(handle, DATA_SCOPE_MARKER)).find(Boolean);
|
|
33929
|
-
const methods = Object.keys(route.methods ?? {}).filter((method) => method !== "_all" && route.methods[method]).map((method) => method.toUpperCase());
|
|
33930
|
-
for (const method of methods) {
|
|
33931
|
-
result.push({
|
|
33932
|
-
method,
|
|
33933
|
-
path: String(route.path),
|
|
33934
|
-
scope,
|
|
33935
|
-
roles: [...authMeta.roles]
|
|
33936
|
-
});
|
|
33937
|
-
}
|
|
33938
|
-
}
|
|
33939
|
-
return result;
|
|
33940
|
-
}
|
|
33941
|
-
function isWhitelisted(route, whitelist) {
|
|
33942
|
-
return whitelist.some(
|
|
33943
|
-
(entry) => entry.method.toUpperCase() === route.method && normalizePath2(entry.path) === normalizePath2(route.path)
|
|
33944
|
-
);
|
|
33945
|
-
}
|
|
33946
|
-
function normalizePath2(path48) {
|
|
33947
|
-
if (!path48) return "/";
|
|
33948
|
-
return path48.endsWith("/") && path48.length > 1 ? path48.slice(0, -1) : path48;
|
|
33949
|
-
}
|
|
33950
|
-
function auditRouteScopes(app, whitelist = []) {
|
|
33951
|
-
const declared = [];
|
|
33952
|
-
const roleGated = [];
|
|
33953
|
-
const undeclared = [];
|
|
33954
|
-
for (const route of collectAuthRoutes(app)) {
|
|
33955
|
-
if (route.scope) {
|
|
33956
|
-
declared.push(route);
|
|
33957
|
-
continue;
|
|
33958
|
-
}
|
|
33959
|
-
if (route.roles.length > 0) {
|
|
33960
|
-
roleGated.push(route);
|
|
33961
|
-
continue;
|
|
33962
|
-
}
|
|
33963
|
-
if (isWhitelisted(route, whitelist)) continue;
|
|
33964
|
-
undeclared.push(route);
|
|
33965
|
-
}
|
|
33966
|
-
return { declared, roleGated, undeclared };
|
|
33967
|
-
}
|
|
33968
|
-
function formatRouteScopeViolation(route) {
|
|
33969
|
-
return `${route.method} ${route.path}`;
|
|
33970
|
-
}
|
|
33971
|
-
function assertRouteDataScopes(app, options = {}) {
|
|
33972
|
-
const logger2 = options.logger ?? console;
|
|
33973
|
-
const audit = auditRouteScopes(app, options.whitelist ?? []);
|
|
33974
|
-
if (audit.undeclared.length > 0) {
|
|
33975
|
-
const lines = audit.undeclared.map(formatRouteScopeViolation);
|
|
33976
|
-
const message = `[RouteScope] ${audit.undeclared.length} \u4E2A\u5DF2\u8BA4\u8BC1\u8DEF\u7531\u672A\u58F0\u660E\u6570\u636E\u8303\u56F4\uFF08requireScopedAuth\uFF09\uFF1A
|
|
33977
|
-
- ${lines.join("\n - ")}`;
|
|
33978
|
-
if (options.enforce) {
|
|
33979
|
-
logger2.error(message);
|
|
33980
|
-
throw new Error(message);
|
|
33981
|
-
}
|
|
33982
|
-
logger2.warn(message);
|
|
33983
|
-
}
|
|
33984
|
-
if (audit.declared.length > 0) {
|
|
33985
|
-
logger2.info?.(
|
|
33986
|
-
`[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`
|
|
33987
|
-
);
|
|
33988
|
-
}
|
|
33989
|
-
return audit;
|
|
33990
|
-
}
|
|
33991
|
-
|
|
33992
|
-
// server/utils/password-gate.ts
|
|
33993
|
-
var PASSWORD_GATE_SKIP_ENV = "OPENLEARN_SKIP_PASSWORD_CHANGE_GATE";
|
|
33994
|
-
var PASSWORD_GATE_SKIP_ENV_ALIAS = "DEV_BYPASS_PASSWORD_CHANGE_GATE";
|
|
33995
|
-
var productionWarningEmitted = false;
|
|
33996
|
-
function isTruthyFlag2(value) {
|
|
33997
|
-
if (typeof value !== "string") return false;
|
|
33998
|
-
const normalized = value.trim().toLowerCase();
|
|
33999
|
-
return normalized === "1" || normalized === "true";
|
|
34000
|
-
}
|
|
34001
|
-
function isPasswordChangeGateSkipped(env = process.env) {
|
|
34002
|
-
const requested = isTruthyFlag2(env[PASSWORD_GATE_SKIP_ENV]) || isTruthyFlag2(env[PASSWORD_GATE_SKIP_ENV_ALIAS]);
|
|
34003
|
-
if (!requested) return false;
|
|
34004
|
-
if ((env.NODE_ENV ?? "") === "production") {
|
|
34005
|
-
if (!productionWarningEmitted) {
|
|
34006
|
-
productionWarningEmitted = true;
|
|
34007
|
-
console.warn(
|
|
34008
|
-
`[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`
|
|
34009
|
-
);
|
|
34010
|
-
}
|
|
34011
|
-
return false;
|
|
34012
|
-
}
|
|
34013
|
-
return true;
|
|
34014
|
-
}
|
|
34015
|
-
|
|
34016
|
-
// server/services/session-role.ts
|
|
34017
|
-
var SESSION_ROLE_WHITELIST = /* @__PURE__ */ new Set(["administrator", "teacher", "student", "anonymous"]);
|
|
34018
|
-
function resolveSessionRole(session) {
|
|
34019
|
-
if (!session) return "anonymous";
|
|
34020
|
-
for (const candidate of [session.subRole, session.role]) {
|
|
34021
|
-
if (typeof candidate !== "string" || candidate.length === 0) continue;
|
|
34022
|
-
const normalized = candidate === "admin" ? "administrator" : candidate;
|
|
34023
|
-
if (SESSION_ROLE_WHITELIST.has(normalized)) return normalized;
|
|
34024
|
-
}
|
|
34025
|
-
return "anonymous";
|
|
34026
|
-
}
|
|
34027
|
-
function isAdministrator(session) {
|
|
34028
|
-
return resolveSessionRole(session) === "administrator";
|
|
34029
|
-
}
|
|
34030
|
-
|
|
34031
|
-
// server/middleware/auth.ts
|
|
34032
|
-
var SESSION_REFRESH_INTERVAL_MS = 5 * 60 * 1e3;
|
|
34033
|
-
function getValidSession(token) {
|
|
34034
|
-
let sessionRow;
|
|
34035
|
-
try {
|
|
34036
|
-
sessionRow = kernelContainer.db.prepare("SELECT * FROM client_sessions WHERE id = ?").get(token);
|
|
34037
|
-
} catch {
|
|
34038
|
-
return null;
|
|
34039
|
-
}
|
|
34040
|
-
if (!sessionRow) return null;
|
|
34041
|
-
const now = Date.now();
|
|
34042
|
-
if (sessionRow.expires_at && sessionRow.expires_at < now) {
|
|
34043
|
-
kernelContainer.db.prepare("DELETE FROM client_sessions WHERE id = ?").run(token);
|
|
34044
|
-
return null;
|
|
34045
|
-
}
|
|
34046
|
-
const idleTimeout = 24 * 60 * 60 * 1e3;
|
|
34047
|
-
if (sessionRow.updated_at && now - sessionRow.updated_at > idleTimeout) {
|
|
34048
|
-
kernelContainer.db.prepare("DELETE FROM client_sessions WHERE id = ?").run(token);
|
|
34049
|
-
return null;
|
|
34050
|
-
}
|
|
34051
|
-
if (!sessionRow.updated_at || now - sessionRow.updated_at > SESSION_REFRESH_INTERVAL_MS) {
|
|
34052
|
-
kernelContainer.db.prepare("UPDATE client_sessions SET updated_at = ? WHERE id = ?").run(now, token);
|
|
34053
|
-
}
|
|
34054
|
-
let session;
|
|
34055
|
-
try {
|
|
34056
|
-
session = JSON.parse(sessionRow.session_data);
|
|
34057
|
-
} catch {
|
|
34058
|
-
return null;
|
|
34059
|
-
}
|
|
34060
|
-
if (session && !session.userId && session.studentId) {
|
|
34061
|
-
session.userId = session.studentId;
|
|
34062
|
-
}
|
|
34063
|
-
if (session.role && !["teacher", "student", "administrator", "admin"].includes(session.role)) {
|
|
34064
|
-
session.role = "anonymous";
|
|
34065
|
-
}
|
|
34066
|
-
return session;
|
|
34067
|
-
}
|
|
34068
|
-
function getCookieToken(req) {
|
|
34069
|
-
const rc = req.headers.cookie;
|
|
34070
|
-
if (!rc) return null;
|
|
34071
|
-
const parts = rc.split(";");
|
|
34072
|
-
for (const part of parts) {
|
|
34073
|
-
const trimmed2 = part.trim();
|
|
34074
|
-
if (trimmed2.startsWith("edu_os_token=")) {
|
|
34075
|
-
return trimmed2.substring("edu_os_token=".length);
|
|
34076
|
-
}
|
|
34077
|
-
}
|
|
34078
|
-
return null;
|
|
34079
|
-
}
|
|
34080
|
-
function getActorId(req) {
|
|
34081
|
-
const token = getCookieToken(req);
|
|
34082
|
-
if (!token) return "anonymous";
|
|
34083
|
-
try {
|
|
34084
|
-
const session = getValidSession(token);
|
|
34085
|
-
if (!session) return "anonymous";
|
|
34086
|
-
const role = resolveSessionRole(session);
|
|
34087
|
-
const rawUserId = session.userId || session.studentId || "demo";
|
|
34088
|
-
const safeUserId = String(rawUserId).replace(/[:\s]/g, "_").slice(0, 64);
|
|
34089
|
-
return `user:${safeUserId}:${role}`;
|
|
34090
|
-
} catch {
|
|
34091
|
-
return "anonymous";
|
|
34092
|
-
}
|
|
34093
|
-
}
|
|
34094
|
-
function requireAuth(...roles) {
|
|
34095
|
-
const middleware = (req, res, next) => {
|
|
34096
|
-
const token = getCookieToken(req);
|
|
34097
|
-
if (!token) return res.status(401).json({ success: false, error: "Authentication required" });
|
|
34098
|
-
const session = getValidSession(token);
|
|
34099
|
-
if (!session) return res.status(401).json({ success: false, error: "Session expired or invalid" });
|
|
34100
|
-
if (roles.length > 0) {
|
|
34101
|
-
const userRole = resolveSessionRole(session);
|
|
34102
|
-
const effectiveRoles = roles.map((r) => r === "admin" ? "administrator" : r);
|
|
34103
|
-
if (!effectiveRoles.includes(userRole)) {
|
|
34104
|
-
return res.status(403).json({ success: false, error: `Role ${session.role} not allowed. Required: ${roles.join(", ")}` });
|
|
34105
|
-
}
|
|
34106
|
-
}
|
|
34107
|
-
req.session = session;
|
|
34108
|
-
next();
|
|
34109
|
-
};
|
|
34110
|
-
return markAuthMiddleware(middleware, roles);
|
|
34111
|
-
}
|
|
34112
|
-
var PASSWORD_CHANGE_EXEMPT_PATHS = /* @__PURE__ */ new Set([
|
|
34113
|
-
"/api/auth/change-password",
|
|
34114
|
-
"/api/auth/logout",
|
|
34115
|
-
"/api/auth/session",
|
|
34116
|
-
"/api/auth/me",
|
|
34117
|
-
"/api/client-errors"
|
|
34118
|
-
]);
|
|
34119
|
-
function enforcePasswordChanged(req, res, next) {
|
|
34120
|
-
if (process.env.PLAYWRIGHT_TEST) {
|
|
34121
|
-
return next();
|
|
34122
|
-
}
|
|
34123
|
-
if (isPasswordChangeGateSkipped()) {
|
|
34124
|
-
return next();
|
|
34125
|
-
}
|
|
34126
|
-
const path48 = req.path || "";
|
|
34127
|
-
if (!path48.startsWith("/api/")) {
|
|
34128
|
-
return next();
|
|
34129
|
-
}
|
|
34130
|
-
if (PASSWORD_CHANGE_EXEMPT_PATHS.has(path48)) {
|
|
34131
|
-
return next();
|
|
34132
|
-
}
|
|
34133
|
-
const token = getCookieToken(req);
|
|
34134
|
-
if (token) {
|
|
34135
|
-
const session = getValidSession(token);
|
|
34136
|
-
if (session && session.mustChangePassword) {
|
|
34137
|
-
res.status(403).json({
|
|
34138
|
-
success: false,
|
|
34139
|
-
code: "FORBIDDEN_DEFAULT_PASSWORD",
|
|
34140
|
-
error: "Default password must be changed first"
|
|
34141
|
-
});
|
|
34142
|
-
return;
|
|
34143
|
-
}
|
|
34144
|
-
}
|
|
34145
|
-
next();
|
|
34146
|
-
}
|
|
34147
|
-
function socketAuthMiddleware(socket, next) {
|
|
34148
|
-
try {
|
|
34149
|
-
const cookieHeader = socket.handshake.headers.cookie;
|
|
34150
|
-
let token = null;
|
|
34151
|
-
if (cookieHeader) {
|
|
34152
|
-
const parts = cookieHeader.split(";");
|
|
34153
|
-
for (const part of parts) {
|
|
34154
|
-
const trimmed2 = part.trim();
|
|
34155
|
-
if (trimmed2.startsWith("edu_os_token=")) {
|
|
34156
|
-
token = trimmed2.substring("edu_os_token=".length);
|
|
34157
|
-
break;
|
|
34158
|
-
}
|
|
34159
|
-
}
|
|
34160
|
-
}
|
|
34161
|
-
if (!token && socket.handshake.auth?.token) {
|
|
34162
|
-
token = socket.handshake.auth.token;
|
|
34163
|
-
}
|
|
34164
|
-
if (process.env.NODE_ENV === "test" && !token) {
|
|
34165
|
-
return next();
|
|
34166
|
-
}
|
|
34167
|
-
if (!token) {
|
|
34168
|
-
return next(new Error("Authentication required: missing edu_os_token"));
|
|
34169
|
-
}
|
|
34170
|
-
const session = getValidSession(token);
|
|
34171
|
-
if (!session) {
|
|
34172
|
-
return next(new Error("Authentication required: session expired or invalid"));
|
|
34173
|
-
}
|
|
34174
|
-
if (session.mustChangePassword && !isPasswordChangeGateSkipped()) {
|
|
34175
|
-
return next(new Error("FORBIDDEN_DEFAULT_PASSWORD"));
|
|
34176
|
-
}
|
|
34177
|
-
socket.data.session = session;
|
|
34178
|
-
socket.data.userId = session.userId;
|
|
34179
|
-
socket.data.role = session.role;
|
|
34180
|
-
next();
|
|
34181
|
-
} catch (err) {
|
|
34182
|
-
next(new Error(`Authentication error: ${err.message}`));
|
|
34183
|
-
}
|
|
34184
|
-
}
|
|
34185
|
-
|
|
34186
34604
|
// server/middleware/csrf.ts
|
|
34187
34605
|
var WRITE_METHODS = /* @__PURE__ */ new Set(["POST", "PUT", "PATCH", "DELETE"]);
|
|
34188
34606
|
var EXEMPT_PREFIXES = [
|
|
@@ -38099,9 +38517,12 @@ var generateStudentNumber = (db2) => {
|
|
|
38099
38517
|
return `S${nextSeq.toString().padStart(3, "0")}`;
|
|
38100
38518
|
};
|
|
38101
38519
|
|
|
38520
|
+
// server/services/ai-provider-resolver.ts
|
|
38521
|
+
init_ai_provider_resolver();
|
|
38522
|
+
|
|
38102
38523
|
// server/routes/os.ts
|
|
38103
38524
|
function registerOsRoutes(ctx) {
|
|
38104
|
-
const { app,
|
|
38525
|
+
const { app, runOpenAIAgentChat: runOpenAIAgentChat2, activityRegistry, aiLimiter } = ctx;
|
|
38105
38526
|
app.get("/api/ping", (_req, res) => {
|
|
38106
38527
|
res.json({ ok: true, ts: Date.now() });
|
|
38107
38528
|
});
|
|
@@ -38320,13 +38741,10 @@ function registerOsRoutes(ctx) {
|
|
|
38320
38741
|
}
|
|
38321
38742
|
let callerRole;
|
|
38322
38743
|
let userId;
|
|
38323
|
-
const
|
|
38324
|
-
if (
|
|
38325
|
-
|
|
38326
|
-
|
|
38327
|
-
callerRole = session.role;
|
|
38328
|
-
userId = session.userId;
|
|
38329
|
-
}
|
|
38744
|
+
const session = req.session || (getCookieToken(req) ? getValidSession(getCookieToken(req)) : null);
|
|
38745
|
+
if (session) {
|
|
38746
|
+
callerRole = session.role;
|
|
38747
|
+
userId = session.userId || session.username;
|
|
38330
38748
|
}
|
|
38331
38749
|
const convKey = `agent:${userId || "anonymous"}:${currentLessonId || "global"}`;
|
|
38332
38750
|
const MEMORY_TURNS = 20;
|
|
@@ -38336,19 +38754,17 @@ function registerOsRoutes(ctx) {
|
|
|
38336
38754
|
content: r.content
|
|
38337
38755
|
}));
|
|
38338
38756
|
let provider;
|
|
38339
|
-
|
|
38340
|
-
provider = kernelContainer.db
|
|
38341
|
-
|
|
38342
|
-
|
|
38343
|
-
|
|
38344
|
-
)
|
|
38345
|
-
}
|
|
38346
|
-
|
|
38347
|
-
if (!provider || !provider.api_key || !provider.api_key.trim()) {
|
|
38348
|
-
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({
|
|
38349
38765
|
success: false,
|
|
38350
|
-
error: "NO_AI_PROVIDER",
|
|
38351
|
-
message:
|
|
38766
|
+
error: err.code || "NO_AI_PROVIDER",
|
|
38767
|
+
message: err.message
|
|
38352
38768
|
});
|
|
38353
38769
|
}
|
|
38354
38770
|
const actorId = getActorId(req);
|
|
@@ -41812,7 +42228,7 @@ Provide a short, friendly, and helpful hint (1-2 sentences) directly related to
|
|
|
41812
42228
|
* 的按学科硬编码字符串拼接,与课题内容无关。此方法用真实的 LLM 调用取代它。
|
|
41813
42229
|
*/
|
|
41814
42230
|
async generateOutlineWithAi(params) {
|
|
41815
|
-
const { title, category, description, timeline, lang = "zh" } = params;
|
|
42231
|
+
const { title, category, description, timeline, lang = "zh", providerId, userId } = params;
|
|
41816
42232
|
const trimmedTitle = (title || "").trim();
|
|
41817
42233
|
if (!trimmedTitle) {
|
|
41818
42234
|
const err = new Error(lang === "zh" ? "\u8BFE\u7A0B\u8BFE\u9898\u4E0D\u80FD\u4E3A\u7A7A" : "Course title is required");
|
|
@@ -41852,7 +42268,12 @@ ${timelineText}` : "- \u8BFE\u5802\u65F6\u95F4\u8F74\u73AF\u8282\uFF1A\uFF08\u65
|
|
|
41852
42268
|
- \`## \u{1F4E5} \u8BFE\u540E\u4F5C\u4E1A\`\uFF1A\u4E00\u4EFD\u660E\u786E\u7684\u8BFE\u540E\u4EFB\u52A1\u4E0E\u63D0\u4EA4\u8981\u6C42\u3002
|
|
41853
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
|
|
41854
42270
|
5. \u7BC7\u5E45\u63A7\u5236\u5728 600-1000 \u5B57\uFF0C\u8BED\u8A00${lang === "zh" ? "\u4F7F\u7528\u7B80\u4F53\u4E2D\u6587" : "use English"}\u3002`;
|
|
41855
|
-
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
|
+
});
|
|
41856
42277
|
const outline = stripReasoningArtifacts(stripOuterCodeFence(String(raw || "").trim()));
|
|
41857
42278
|
if (!outline) {
|
|
41858
42279
|
const err = new Error(
|
|
@@ -42116,13 +42537,17 @@ function registerLessonsRoutes(ctx) {
|
|
|
42116
42537
|
safeAiLimiter,
|
|
42117
42538
|
async (req, res) => {
|
|
42118
42539
|
try {
|
|
42119
|
-
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;
|
|
42120
42543
|
const content = await lessonService.generateOutlineWithAi({
|
|
42121
42544
|
title,
|
|
42122
42545
|
category,
|
|
42123
42546
|
description,
|
|
42124
42547
|
timeline,
|
|
42125
|
-
lang
|
|
42548
|
+
lang,
|
|
42549
|
+
providerId,
|
|
42550
|
+
userId
|
|
42126
42551
|
});
|
|
42127
42552
|
res.json({ success: true, content });
|
|
42128
42553
|
} catch (e) {
|
|
@@ -45167,6 +45592,26 @@ function resolveWritableStudentId(session, rawId) {
|
|
|
45167
45592
|
if (isTeacher(session) && teacherCanSeeStudent(kernelContainer.getReadDb(), session, studentId)) return studentId;
|
|
45168
45593
|
return null;
|
|
45169
45594
|
}
|
|
45595
|
+
var SEED_STATUS_TTL_MS = 15e3;
|
|
45596
|
+
var seedStatusCache = null;
|
|
45597
|
+
function readSeedDefaultStatus() {
|
|
45598
|
+
const now = Date.now();
|
|
45599
|
+
if (seedStatusCache && seedStatusCache.expiresAt > now) return seedStatusCache.payload;
|
|
45600
|
+
const accounts = [];
|
|
45601
|
+
try {
|
|
45602
|
+
const rows = kernelContainer.db.prepare("SELECT username, password_hash FROM users WHERE username IN (?, ?)").all("admin", "teacher");
|
|
45603
|
+
for (const row of rows) {
|
|
45604
|
+
if (verifyPassword(row.username, row.password_hash).valid) accounts.push(row.username);
|
|
45605
|
+
}
|
|
45606
|
+
} catch {
|
|
45607
|
+
}
|
|
45608
|
+
const payload = { active: accounts.length > 0, accounts };
|
|
45609
|
+
seedStatusCache = { payload, expiresAt: now + SEED_STATUS_TTL_MS };
|
|
45610
|
+
return payload;
|
|
45611
|
+
}
|
|
45612
|
+
function invalidateSeedStatusCache() {
|
|
45613
|
+
seedStatusCache = null;
|
|
45614
|
+
}
|
|
45170
45615
|
function registerRosterRoutes(ctx) {
|
|
45171
45616
|
const { app, io, loginLimiter } = ctx;
|
|
45172
45617
|
const rosterService = new RosterService(kernelContainer.db, kernelContainer.getReadDb());
|
|
@@ -45448,6 +45893,7 @@ function registerRosterRoutes(ctx) {
|
|
|
45448
45893
|
return res.status(400).json({ error: "New password must be different from the username" });
|
|
45449
45894
|
}
|
|
45450
45895
|
kernelContainer.db.prepare("UPDATE users SET password_hash = ?, must_change_password = 0 WHERE id = ?").run(hashPassword(newPassword), session.userId);
|
|
45896
|
+
invalidateSeedStatusCache();
|
|
45451
45897
|
kernelContainer.db.prepare("DELETE FROM client_sessions WHERE id != ? AND session_data LIKE ?").run(token, `%${session.userId}%`);
|
|
45452
45898
|
try {
|
|
45453
45899
|
const updatedSession = { ...session, mustChangePassword: false };
|
|
@@ -45615,6 +46061,13 @@ function registerRosterRoutes(ctx) {
|
|
|
45615
46061
|
sendSafeError(res, e);
|
|
45616
46062
|
}
|
|
45617
46063
|
});
|
|
46064
|
+
app.get("/api/auth/seed-status", (_req, res) => {
|
|
46065
|
+
try {
|
|
46066
|
+
res.json(readSeedDefaultStatus());
|
|
46067
|
+
} catch (e) {
|
|
46068
|
+
sendSafeError(res, e);
|
|
46069
|
+
}
|
|
46070
|
+
});
|
|
45618
46071
|
app.post("/api/auth/login", loginLimiter, (req, res) => {
|
|
45619
46072
|
try {
|
|
45620
46073
|
const { entrance, username, password, studentId } = req.body;
|
|
@@ -48180,7 +48633,7 @@ var ScheduleService = class {
|
|
|
48180
48633
|
* Timetable OCR 课表图像识别与数据清洗
|
|
48181
48634
|
*/
|
|
48182
48635
|
async processTimetableOcr(imageBase64, options = {}) {
|
|
48183
|
-
const { lang = "zh", providerId } = options;
|
|
48636
|
+
const { lang = "zh", providerId, userId } = options;
|
|
48184
48637
|
if (!imageBase64) {
|
|
48185
48638
|
throw new Error("imageBase64 is required");
|
|
48186
48639
|
}
|
|
@@ -48211,21 +48664,11 @@ var ScheduleService = class {
|
|
|
48211
48664
|
|
|
48212
48665
|
\u8FD4\u56DE\u683C\u5F0F\u793A\u4F8B\uFF1A
|
|
48213
48666
|
[{"dayOfWeek":1,"periodNumber":1,"className":"\u9AD8\u4E00(13)","subject":"\u4FE1\u606F","timeSlot":"08:00-08:40","location":"312","teacherName":""}]`;
|
|
48214
|
-
|
|
48215
|
-
|
|
48216
|
-
|
|
48217
|
-
|
|
48218
|
-
|
|
48219
|
-
provider = this.db.prepare(
|
|
48220
|
-
"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"
|
|
48221
|
-
).get();
|
|
48222
|
-
}
|
|
48223
|
-
if (provider?.api_key) provider.api_key = decryptApiKey(provider.api_key);
|
|
48224
|
-
if (!provider || !provider.api_key || !provider.api_key.trim()) {
|
|
48225
|
-
throw new Error(
|
|
48226
|
-
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".'
|
|
48227
|
-
);
|
|
48228
|
-
}
|
|
48667
|
+
const provider = resolveAccessibleAiProvider(this.db, {
|
|
48668
|
+
providerId,
|
|
48669
|
+
userId,
|
|
48670
|
+
lang
|
|
48671
|
+
});
|
|
48229
48672
|
let chatUrl = provider.api_url.trim();
|
|
48230
48673
|
if (!chatUrl.endsWith("/chat/completions")) {
|
|
48231
48674
|
chatUrl = chatUrl.endsWith("/") ? chatUrl + "chat/completions" : chatUrl + "/chat/completions";
|
|
@@ -48441,7 +48884,9 @@ function registerSchedulesRoutes(ctx) {
|
|
|
48441
48884
|
const startTime = Date.now();
|
|
48442
48885
|
try {
|
|
48443
48886
|
const { imageBase64, lang = "zh", providerId } = req.body;
|
|
48444
|
-
const
|
|
48887
|
+
const session = req.session;
|
|
48888
|
+
const userId = session?.userId || session?.username;
|
|
48889
|
+
const result = await scheduleService.processTimetableOcr(imageBase64, { lang, providerId, userId });
|
|
48445
48890
|
res.json({
|
|
48446
48891
|
success: true,
|
|
48447
48892
|
entries: result.entries,
|
|
@@ -48827,7 +49272,7 @@ var GradingService = class {
|
|
|
48827
49272
|
}
|
|
48828
49273
|
// ── 6. AI 学期综合总评评语生成 ────────────────────────────────────────────────
|
|
48829
49274
|
async generateSemesterAiEvaluation(params) {
|
|
48830
|
-
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;
|
|
48831
49276
|
const student = this.db.prepare("SELECT name FROM students WHERE id = ?").get(studentId);
|
|
48832
49277
|
if (!student) {
|
|
48833
49278
|
throw new Error("Student not found");
|
|
@@ -48885,13 +49330,10 @@ ${examsText}
|
|
|
48885
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
|
|
48886
49331
|
- \u5BF9\u672A\u6765\u7684\u671F\u8BB8\uFF0C\u6FC0\u52B1\u5B66\u751F\u5728\u4E0B\u5B66\u671F\u7EE7\u7EED\u52AA\u529B\u3002
|
|
48887
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`;
|
|
48888
|
-
const provider =
|
|
48889
|
-
|
|
48890
|
-
|
|
48891
|
-
|
|
48892
|
-
if (!provider || !provider.api_key || !provider.api_key.trim()) {
|
|
48893
|
-
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");
|
|
48894
|
-
}
|
|
49333
|
+
const provider = resolveAccessibleAiProvider(this.db, {
|
|
49334
|
+
providerId,
|
|
49335
|
+
userId
|
|
49336
|
+
});
|
|
48895
49337
|
let chatUrl = provider.api_url.trim();
|
|
48896
49338
|
if (!chatUrl.endsWith("/chat/completions")) {
|
|
48897
49339
|
chatUrl = chatUrl.endsWith("/") ? chatUrl + "chat/completions" : chatUrl + "/chat/completions";
|
|
@@ -49164,11 +49606,14 @@ function registerGradingRoutes(ctx) {
|
|
|
49164
49606
|
const { classId, studentId } = req.params;
|
|
49165
49607
|
if (gate(req, res, classId, "staff")) return;
|
|
49166
49608
|
const { semesterName = "2026\u5E74\u6625\u5B63\u5B66\u671F", providerId } = req.body;
|
|
49609
|
+
const session = req.session;
|
|
49610
|
+
const userId = session?.userId || session?.username;
|
|
49167
49611
|
const aiEvaluation = await gradingService.generateSemesterAiEvaluation({
|
|
49168
49612
|
classId,
|
|
49169
49613
|
studentId,
|
|
49170
49614
|
semesterName,
|
|
49171
|
-
providerId
|
|
49615
|
+
providerId,
|
|
49616
|
+
userId
|
|
49172
49617
|
});
|
|
49173
49618
|
res.json({ success: true, aiEvaluation });
|
|
49174
49619
|
} catch (e) {
|
|
@@ -49596,7 +50041,8 @@ async function pluginApiGatewayMiddleware(req, res, next) {
|
|
|
49596
50041
|
}
|
|
49597
50042
|
}
|
|
49598
50043
|
const clientIp = req.ip || req.socket?.remoteAddress || "unknown-ip";
|
|
49599
|
-
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}`;
|
|
49600
50046
|
const windowMs = matchedRule?.rateLimit?.windowMs ?? 60 * 1e3;
|
|
49601
50047
|
const maxRequests = matchedRule?.rateLimit?.max ?? 120;
|
|
49602
50048
|
if (!rateLimiter.consume(rateLimitKey, maxRequests, windowMs)) {
|
|
@@ -50585,16 +51031,9 @@ function listInstalledManifestVersions() {
|
|
|
50585
51031
|
return installed;
|
|
50586
51032
|
}
|
|
50587
51033
|
function registerPluginsRoutes(ctx) {
|
|
50588
|
-
const {
|
|
50589
|
-
|
|
50590
|
-
|
|
50591
|
-
buildOpenAITools: buildOpenAITools2,
|
|
50592
|
-
executeAgentToolCall: executeAgentToolCall2,
|
|
50593
|
-
buildOpenAIChatUrl: buildOpenAIChatUrl2,
|
|
50594
|
-
runGeminiAgentChat: runGeminiAgentChat2,
|
|
50595
|
-
runOpenAIAgentChat: runOpenAIAgentChat2
|
|
50596
|
-
} = ctx;
|
|
50597
|
-
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;
|
|
50598
51037
|
app.get("/api/docs/plugin-guide", (req, res) => {
|
|
50599
51038
|
try {
|
|
50600
51039
|
const docPath = import_path22.default.join(process.cwd(), "docs_plugin_guide.md");
|
|
@@ -51630,9 +52069,13 @@ function registerPluginsRoutes(ctx) {
|
|
|
51630
52069
|
}
|
|
51631
52070
|
});
|
|
51632
52071
|
app.all("/api/plugins/:pluginId/*", pluginApiGatewayMiddleware);
|
|
51633
|
-
app.get("/api/ai-providers",
|
|
52072
|
+
app.get("/api/ai-providers", requireScopedAuth("self", "teacher", "administrator"), (req, res) => {
|
|
51634
52073
|
try {
|
|
51635
|
-
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);
|
|
51636
52079
|
const masked = providers.map((p) => {
|
|
51637
52080
|
let apiKey;
|
|
51638
52081
|
try {
|
|
@@ -51640,54 +52083,122 @@ function registerPluginsRoutes(ctx) {
|
|
|
51640
52083
|
} catch {
|
|
51641
52084
|
apiKey = "[UNDECRYPTABLE]";
|
|
51642
52085
|
}
|
|
51643
|
-
return {
|
|
52086
|
+
return {
|
|
52087
|
+
...p,
|
|
52088
|
+
api_key: apiKey,
|
|
52089
|
+
is_shared: Number(p.is_shared) === 1
|
|
52090
|
+
};
|
|
51644
52091
|
});
|
|
51645
52092
|
res.json(masked);
|
|
51646
52093
|
} catch (e) {
|
|
51647
52094
|
sendSafeError(res, e);
|
|
51648
52095
|
}
|
|
51649
52096
|
});
|
|
51650
|
-
app.post("/api/ai-providers",
|
|
52097
|
+
app.post("/api/ai-providers", requireScopedAuth("self", "teacher", "administrator"), (req, res) => {
|
|
51651
52098
|
try {
|
|
51652
|
-
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;
|
|
51653
52103
|
if (!name || !api_url || !model_name) {
|
|
51654
52104
|
return res.status(400).json({ error: "Missing name, api_url or model_name" });
|
|
51655
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
|
+
}
|
|
51656
52110
|
const id = "prov_" + Date.now();
|
|
51657
52111
|
const now = Date.now();
|
|
51658
52112
|
const encryptedKey = api_key ? encryptApiKey(api_key) : "";
|
|
51659
52113
|
kernelContainer.db.prepare(
|
|
51660
|
-
"INSERT INTO ai_providers (id, name, api_url, api_key, model_name, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)"
|
|
51661
|
-
).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
|
+
);
|
|
51662
52126
|
res.json({ success: true, id });
|
|
51663
52127
|
} catch (e) {
|
|
51664
52128
|
sendSafeError(res, e);
|
|
51665
52129
|
}
|
|
51666
52130
|
});
|
|
51667
|
-
app.put("/api/ai-providers/:id",
|
|
52131
|
+
app.put("/api/ai-providers/:id", requireScopedAuth("self", "teacher", "administrator"), (req, res) => {
|
|
51668
52132
|
try {
|
|
51669
|
-
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;
|
|
51670
52137
|
if (!name || !api_url || !model_name) {
|
|
51671
52138
|
return res.status(400).json({ error: "Missing name, api_url or model_name" });
|
|
51672
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
|
+
}
|
|
51673
52166
|
const now = Date.now();
|
|
51674
52167
|
let finalKey;
|
|
51675
52168
|
if (api_key && api_key.trim() !== "" && !api_key.includes("****")) {
|
|
51676
52169
|
finalKey = encryptApiKey(api_key);
|
|
51677
52170
|
} else {
|
|
51678
|
-
const existing = kernelContainer.db.prepare("SELECT api_key FROM ai_providers WHERE id = ?").get(req.params.id);
|
|
51679
52171
|
finalKey = existing?.api_key || "";
|
|
51680
52172
|
}
|
|
51681
52173
|
kernelContainer.db.prepare(
|
|
51682
|
-
"UPDATE ai_providers SET name = ?, api_url = ?, api_key = ?, model_name = ?, updated_at = ? WHERE id = ?"
|
|
51683
|
-
).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);
|
|
51684
52176
|
res.json({ success: true });
|
|
51685
52177
|
} catch (e) {
|
|
51686
52178
|
sendSafeError(res, e);
|
|
51687
52179
|
}
|
|
51688
52180
|
});
|
|
51689
|
-
app.delete("/api/ai-providers/:id",
|
|
52181
|
+
app.delete("/api/ai-providers/:id", requireScopedAuth("self", "teacher", "administrator"), (req, res) => {
|
|
51690
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
|
+
}
|
|
51691
52202
|
kernelContainer.db.prepare("DELETE FROM ai_providers WHERE id = ?").run(req.params.id);
|
|
51692
52203
|
res.json({ success: true });
|
|
51693
52204
|
} catch (e) {
|
|
@@ -51770,65 +52281,82 @@ function registerPluginsRoutes(ctx) {
|
|
|
51770
52281
|
sendSafeError(res, e);
|
|
51771
52282
|
}
|
|
51772
52283
|
});
|
|
51773
|
-
app.post(
|
|
51774
|
-
|
|
51775
|
-
|
|
51776
|
-
|
|
51777
|
-
|
|
51778
|
-
}
|
|
51779
|
-
let api_key = "";
|
|
51780
|
-
if (providedKey && providedKey.includes("****")) {
|
|
51781
|
-
const existing = kernelContainer.db.prepare("SELECT api_key FROM ai_providers WHERE api_url = ? AND model_name = ? LIMIT 1").get(api_url, model_name);
|
|
51782
|
-
api_key = existing ? decryptApiKey(existing.api_key) : "";
|
|
51783
|
-
} else if (providedKey) {
|
|
51784
|
-
api_key = providedKey.includes(":") ? decryptApiKey(providedKey) : providedKey;
|
|
51785
|
-
}
|
|
51786
|
-
let cleanUrl = api_url.trim();
|
|
51787
|
-
if (!cleanUrl.endsWith("/chat/completions")) {
|
|
51788
|
-
cleanUrl = cleanUrl.endsWith("/") ? cleanUrl + "chat/completions" : cleanUrl + "/chat/completions";
|
|
51789
|
-
}
|
|
51790
|
-
const urlSafety = isSafeExternalUrl(cleanUrl);
|
|
51791
|
-
if (!urlSafety.safe) {
|
|
51792
|
-
return res.status(400).json({ error: `Blocked unsafe AI provider URL: ${urlSafety.reason}` });
|
|
51793
|
-
}
|
|
51794
|
-
let response;
|
|
52284
|
+
app.post(
|
|
52285
|
+
"/api/ai-providers/test",
|
|
52286
|
+
requireScopedAuth("self", "teacher", "administrator"),
|
|
52287
|
+
safeAiLimiter,
|
|
52288
|
+
async (req, res) => {
|
|
51795
52289
|
try {
|
|
51796
|
-
|
|
51797
|
-
|
|
51798
|
-
|
|
51799
|
-
|
|
51800
|
-
|
|
51801
|
-
|
|
51802
|
-
|
|
51803
|
-
|
|
51804
|
-
|
|
51805
|
-
|
|
51806
|
-
|
|
51807
|
-
|
|
51808
|
-
|
|
51809
|
-
|
|
51810
|
-
|
|
51811
|
-
|
|
51812
|
-
|
|
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({
|
|
51813
52351
|
success: false,
|
|
51814
|
-
error:
|
|
52352
|
+
error: `API responded with status ${response.status}: ${responseText.slice(0, 200)}`
|
|
51815
52353
|
});
|
|
51816
52354
|
}
|
|
51817
|
-
|
|
51818
|
-
|
|
51819
|
-
const responseText = await response.text();
|
|
51820
|
-
if (response.ok) {
|
|
51821
|
-
res.json({ success: true, message: "Successfully connected and received response." });
|
|
51822
|
-
} else {
|
|
51823
|
-
res.status(response.status).json({
|
|
51824
|
-
success: false,
|
|
51825
|
-
error: `API responded with status ${response.status}: ${responseText.slice(0, 200)}`
|
|
51826
|
-
});
|
|
52355
|
+
} catch (e) {
|
|
52356
|
+
sendSafeError(res, e, 500, "Connection failed");
|
|
51827
52357
|
}
|
|
51828
|
-
} catch (e) {
|
|
51829
|
-
sendSafeError(res, e, 500, "Connection failed");
|
|
51830
52358
|
}
|
|
51831
|
-
|
|
52359
|
+
);
|
|
51832
52360
|
}
|
|
51833
52361
|
|
|
51834
52362
|
// server/services/assessment-service.ts
|
|
@@ -58273,12 +58801,16 @@ async function startServer() {
|
|
|
58273
58801
|
const defaultWriteLimit = process.env.PLAYWRIGHT_TEST ? 1e4 : isDev ? 600 : 180;
|
|
58274
58802
|
const writeLimiter = createWriteLimiter(defaultWriteLimit);
|
|
58275
58803
|
const viewStateLimiter = createWriteLimiter(process.env.PLAYWRIGHT_TEST ? 1e4 : isDev ? 600 : 120);
|
|
58804
|
+
const pluginApiLimiter = createSessionWriteLimiter(process.env.PLAYWRIGHT_TEST ? 1e4 : 600);
|
|
58276
58805
|
const aiLimiter = createAiLimiter(process.env.PLAYWRIGHT_TEST ? 1e3 : 10);
|
|
58277
58806
|
app.use((req, res, next) => {
|
|
58278
58807
|
if (req.method === "POST" || req.method === "PUT" || req.method === "DELETE") {
|
|
58279
58808
|
if (/^\/api\/classroom\/sessions\/[^/]+\/view-state\/?$/.test(req.path)) {
|
|
58280
58809
|
return viewStateLimiter(req, res, next);
|
|
58281
58810
|
}
|
|
58811
|
+
if (req.path.startsWith("/api/plugins/")) {
|
|
58812
|
+
return pluginApiLimiter(req, res, next);
|
|
58813
|
+
}
|
|
58282
58814
|
return writeLimiter(req, res, next);
|
|
58283
58815
|
}
|
|
58284
58816
|
next();
|