@moodle-next/next 0.0.1
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/dist/client.cjs +35 -0
- package/dist/client.d.cts +19 -0
- package/dist/client.d.ts +19 -0
- package/dist/client.mjs +33 -0
- package/dist/index.cjs +535 -0
- package/dist/index.d.cts +153 -0
- package/dist/index.d.ts +153 -0
- package/dist/index.mjs +513 -0
- package/package.json +80 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
import { randomBytes, createCipheriv, createDecipheriv, createHash } from 'crypto';
|
|
2
|
+
import { cookies } from 'next/headers';
|
|
3
|
+
import { redirect } from 'next/navigation';
|
|
4
|
+
import { getMoodleSiteUrl, getAdminToken, resolveAbsoluteMoodleUrl, isAllowedMoodleUrl, isAuthenticationError, getMoodleMediaCandidates, isMoodleAuthenticationFailureResponse, appendMoodleToken } from '@moodle-next/core';
|
|
5
|
+
import { cache } from 'react';
|
|
6
|
+
|
|
7
|
+
// src/session.ts
|
|
8
|
+
var IV_LENGTH = 12;
|
|
9
|
+
var AUTH_TAG_LENGTH = 16;
|
|
10
|
+
var sessionConfig = {
|
|
11
|
+
cookieName: "moodle_session",
|
|
12
|
+
durationSeconds: 60 * 60 * 8,
|
|
13
|
+
loginPath: "/",
|
|
14
|
+
expiredSessionPath: "/auth/session-expired",
|
|
15
|
+
sessionSecret: ""
|
|
16
|
+
};
|
|
17
|
+
function configureMoodleSession(config) {
|
|
18
|
+
sessionConfig = { ...sessionConfig, ...config };
|
|
19
|
+
}
|
|
20
|
+
function getSessionSecret() {
|
|
21
|
+
const secret = sessionConfig.sessionSecret || process.env.APP_SESSION_SECRET?.trim();
|
|
22
|
+
if (!secret) {
|
|
23
|
+
throw new Error(
|
|
24
|
+
"Missing session secret. Set APP_SESSION_SECRET or call configureMoodleSession({ sessionSecret })."
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
return createHash("sha256").update(secret).digest();
|
|
28
|
+
}
|
|
29
|
+
function encrypt(value) {
|
|
30
|
+
const iv = randomBytes(IV_LENGTH);
|
|
31
|
+
const cipher = createCipheriv("aes-256-gcm", getSessionSecret(), iv);
|
|
32
|
+
const encrypted = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]);
|
|
33
|
+
const authTag = cipher.getAuthTag();
|
|
34
|
+
return Buffer.concat([iv, authTag, encrypted]).toString("base64url");
|
|
35
|
+
}
|
|
36
|
+
function decrypt(value) {
|
|
37
|
+
const payload = Buffer.from(value, "base64url");
|
|
38
|
+
const iv = payload.subarray(0, IV_LENGTH);
|
|
39
|
+
const authTag = payload.subarray(IV_LENGTH, IV_LENGTH + AUTH_TAG_LENGTH);
|
|
40
|
+
const encrypted = payload.subarray(IV_LENGTH + AUTH_TAG_LENGTH);
|
|
41
|
+
const decipher = createDecipheriv("aes-256-gcm", getSessionSecret(), iv);
|
|
42
|
+
decipher.setAuthTag(authTag);
|
|
43
|
+
return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString("utf8");
|
|
44
|
+
}
|
|
45
|
+
var SESSION_RENEW_THRESHOLD_MS = 2 * 60 * 60 * 1e3;
|
|
46
|
+
async function createSession(session) {
|
|
47
|
+
const cookieStore = await cookies();
|
|
48
|
+
const { cookieName, durationSeconds } = sessionConfig;
|
|
49
|
+
const expiresAt = Date.now() + durationSeconds * 1e3;
|
|
50
|
+
const payload = { ...session, expiresAt };
|
|
51
|
+
cookieStore.set(cookieName, encrypt(JSON.stringify(payload)), {
|
|
52
|
+
httpOnly: true,
|
|
53
|
+
sameSite: "lax",
|
|
54
|
+
secure: process.env.NODE_ENV === "production",
|
|
55
|
+
path: "/",
|
|
56
|
+
maxAge: durationSeconds
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
async function getSession() {
|
|
60
|
+
const cookieStore = await cookies();
|
|
61
|
+
const { cookieName, durationSeconds } = sessionConfig;
|
|
62
|
+
const rawCookie = cookieStore.get(cookieName)?.value;
|
|
63
|
+
if (!rawCookie) return null;
|
|
64
|
+
try {
|
|
65
|
+
const parsed = JSON.parse(decrypt(rawCookie));
|
|
66
|
+
const now = Date.now();
|
|
67
|
+
if (parsed.expiresAt <= now) return null;
|
|
68
|
+
if (parsed.expiresAt - now < SESSION_RENEW_THRESHOLD_MS) {
|
|
69
|
+
const renewed = {
|
|
70
|
+
...parsed,
|
|
71
|
+
expiresAt: now + durationSeconds * 1e3
|
|
72
|
+
};
|
|
73
|
+
cookieStore.set(cookieName, encrypt(JSON.stringify(renewed)), {
|
|
74
|
+
httpOnly: true,
|
|
75
|
+
sameSite: "lax",
|
|
76
|
+
secure: process.env.NODE_ENV === "production",
|
|
77
|
+
path: "/",
|
|
78
|
+
maxAge: durationSeconds
|
|
79
|
+
});
|
|
80
|
+
return renewed;
|
|
81
|
+
}
|
|
82
|
+
return parsed;
|
|
83
|
+
} catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
async function requireSession() {
|
|
88
|
+
const session = await getSession();
|
|
89
|
+
if (!session) redirect(sessionConfig.loginPath);
|
|
90
|
+
return session;
|
|
91
|
+
}
|
|
92
|
+
async function clearSession() {
|
|
93
|
+
const cookieStore = await cookies();
|
|
94
|
+
cookieStore.delete(sessionConfig.cookieName);
|
|
95
|
+
}
|
|
96
|
+
function redirectToExpiredSession() {
|
|
97
|
+
redirect(sessionConfig.expiredSessionPath);
|
|
98
|
+
}
|
|
99
|
+
function redirectIfSessionExpired(error) {
|
|
100
|
+
if (isAuthenticationError(error)) redirectToExpiredSession();
|
|
101
|
+
}
|
|
102
|
+
async function clearSessionIfAuthenticationError(error) {
|
|
103
|
+
if (!isAuthenticationError(error)) return false;
|
|
104
|
+
await clearSession();
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
async function getSessionOrUnauthorizedResponse() {
|
|
108
|
+
const session = await getSession();
|
|
109
|
+
if (!session) return { session: null, response: new Response("Unauthorized", { status: 401 }) };
|
|
110
|
+
return { session, response: null };
|
|
111
|
+
}
|
|
112
|
+
async function clearSessionAndReturnUnauthorized() {
|
|
113
|
+
await clearSession();
|
|
114
|
+
return new Response("Unauthorized", { status: 401 });
|
|
115
|
+
}
|
|
116
|
+
function fallbackSiteName() {
|
|
117
|
+
const hostname = new URL(getMoodleSiteUrl()).hostname.replace(/^www\./, "");
|
|
118
|
+
const [subdomain, domain] = hostname.split(".");
|
|
119
|
+
if (subdomain && domain) {
|
|
120
|
+
return `${subdomain.charAt(0).toUpperCase()}${subdomain.slice(1)} ${domain.replace(/[-_]/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase())}`.trim();
|
|
121
|
+
}
|
|
122
|
+
return "Campus";
|
|
123
|
+
}
|
|
124
|
+
function normalizeUrl(rawUrl) {
|
|
125
|
+
if (!rawUrl) return void 0;
|
|
126
|
+
try {
|
|
127
|
+
const resolved = resolveAbsoluteMoodleUrl(rawUrl).toString();
|
|
128
|
+
return isAllowedMoodleUrl(resolved) ? resolved : void 0;
|
|
129
|
+
} catch {
|
|
130
|
+
return void 0;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
function isValidConfig(payload) {
|
|
134
|
+
return payload !== null && typeof payload === "object" && !("exception" in payload);
|
|
135
|
+
}
|
|
136
|
+
async function fetchPublicConfigAnonymous() {
|
|
137
|
+
const response = await fetch(`${getMoodleSiteUrl()}/webservice/rest/server.php`, {
|
|
138
|
+
method: "POST",
|
|
139
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
140
|
+
body: new URLSearchParams({
|
|
141
|
+
wsfunction: "tool_mobile_get_public_config",
|
|
142
|
+
moodlewsrestformat: "json"
|
|
143
|
+
}),
|
|
144
|
+
cache: "force-cache",
|
|
145
|
+
// @ts-expect-error — Next.js fetch extension
|
|
146
|
+
next: { revalidate: 3600 }
|
|
147
|
+
});
|
|
148
|
+
if (!response.ok) return null;
|
|
149
|
+
const payload = await response.json().catch(() => null);
|
|
150
|
+
return isValidConfig(payload) ? payload : null;
|
|
151
|
+
}
|
|
152
|
+
async function fetchPublicConfigViaRest() {
|
|
153
|
+
let token;
|
|
154
|
+
try {
|
|
155
|
+
token = getAdminToken();
|
|
156
|
+
} catch {
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
const response = await fetch(`${getMoodleSiteUrl()}/webservice/rest/server.php`, {
|
|
160
|
+
method: "POST",
|
|
161
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
162
|
+
body: new URLSearchParams({
|
|
163
|
+
wstoken: token,
|
|
164
|
+
wsfunction: "tool_mobile_get_public_config",
|
|
165
|
+
moodlewsrestformat: "json"
|
|
166
|
+
}),
|
|
167
|
+
cache: "force-cache",
|
|
168
|
+
// @ts-expect-error — Next.js fetch extension
|
|
169
|
+
next: { revalidate: 3600 }
|
|
170
|
+
});
|
|
171
|
+
if (!response.ok) return null;
|
|
172
|
+
const payload = await response.json().catch(() => null);
|
|
173
|
+
return isValidConfig(payload) ? payload : null;
|
|
174
|
+
}
|
|
175
|
+
var getMoodlePublicConfig = cache(async () => {
|
|
176
|
+
const anonymous = await fetchPublicConfigAnonymous().catch(() => null);
|
|
177
|
+
if (anonymous) return anonymous;
|
|
178
|
+
return fetchPublicConfigViaRest().catch(() => null);
|
|
179
|
+
});
|
|
180
|
+
async function fetchSiteDetails() {
|
|
181
|
+
let token;
|
|
182
|
+
try {
|
|
183
|
+
token = getAdminToken();
|
|
184
|
+
} catch {
|
|
185
|
+
return {};
|
|
186
|
+
}
|
|
187
|
+
try {
|
|
188
|
+
const response = await fetch(`${getMoodleSiteUrl()}/webservice/rest/server.php`, {
|
|
189
|
+
method: "POST",
|
|
190
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
191
|
+
body: new URLSearchParams({
|
|
192
|
+
wstoken: token,
|
|
193
|
+
wsfunction: "core_webservice_get_site_info",
|
|
194
|
+
moodlewsrestformat: "json"
|
|
195
|
+
}),
|
|
196
|
+
cache: "force-cache",
|
|
197
|
+
// @ts-expect-error — Next.js fetch extension
|
|
198
|
+
next: { revalidate: 3600 }
|
|
199
|
+
});
|
|
200
|
+
if (!response.ok) return {};
|
|
201
|
+
const payload = await response.json().catch(() => null);
|
|
202
|
+
const rawSummary = payload?.summary?.trim() || void 0;
|
|
203
|
+
const description = rawSummary ? rawSummary.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim() || void 0 : void 0;
|
|
204
|
+
const advancedFeatures = payload?.advancedfeatures ?? [];
|
|
205
|
+
const featureEnabled = (name, defaultValue) => {
|
|
206
|
+
const feature = advancedFeatures.find((f) => f.name === name);
|
|
207
|
+
return feature ? Number(feature.value) > 0 : defaultValue;
|
|
208
|
+
};
|
|
209
|
+
return {
|
|
210
|
+
name: payload?.sitename?.trim() || void 0,
|
|
211
|
+
description,
|
|
212
|
+
globalSearchEnabled: featureEnabled("enableglobalsearch", false),
|
|
213
|
+
messagingEnabled: featureEnabled("messaging", true)
|
|
214
|
+
};
|
|
215
|
+
} catch {
|
|
216
|
+
return {};
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
var getMoodleBranding = cache(async () => {
|
|
220
|
+
const [config, siteDetails] = await Promise.all([getMoodlePublicConfig(), fetchSiteDetails()]);
|
|
221
|
+
if (config) {
|
|
222
|
+
return {
|
|
223
|
+
siteName: siteDetails.name || config.sitename?.trim() || fallbackSiteName(),
|
|
224
|
+
siteDescription: siteDetails.description,
|
|
225
|
+
siteUrl: normalizeUrl(config.siteurl) || getMoodleSiteUrl(),
|
|
226
|
+
logoUrl: normalizeUrl(config.logourl || config.logo),
|
|
227
|
+
compactLogoUrl: normalizeUrl(
|
|
228
|
+
config.compactlogourl || config.compactlogo || config.logocompact || config.logourl
|
|
229
|
+
)
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
return {
|
|
233
|
+
siteName: siteDetails.name || fallbackSiteName(),
|
|
234
|
+
siteDescription: siteDetails.description,
|
|
235
|
+
siteUrl: getMoodleSiteUrl(),
|
|
236
|
+
logoUrl: normalizeUrl("/favicon.ico"),
|
|
237
|
+
compactLogoUrl: normalizeUrl("/favicon.ico")
|
|
238
|
+
};
|
|
239
|
+
});
|
|
240
|
+
var detectForceLoginViaRedirect = cache(async () => {
|
|
241
|
+
try {
|
|
242
|
+
const res = await fetch(`${getMoodleSiteUrl()}/course/index.php`, {
|
|
243
|
+
// @ts-expect-error — Next.js fetch extension
|
|
244
|
+
next: { revalidate: 3600 }
|
|
245
|
+
});
|
|
246
|
+
return new URL(res.url).pathname.startsWith("/login");
|
|
247
|
+
} catch {
|
|
248
|
+
return false;
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
async function getSiteForceLogin() {
|
|
252
|
+
const config = await getMoodlePublicConfig().catch(() => null);
|
|
253
|
+
if (config && config.forcelogin !== void 0) return Boolean(config.forcelogin);
|
|
254
|
+
return detectForceLoginViaRedirect();
|
|
255
|
+
}
|
|
256
|
+
async function getSiteGlobalSearchEnabled() {
|
|
257
|
+
const details = await fetchSiteDetails();
|
|
258
|
+
return details.globalSearchEnabled ?? false;
|
|
259
|
+
}
|
|
260
|
+
async function getSiteMessagingEnabled() {
|
|
261
|
+
const details = await fetchSiteDetails();
|
|
262
|
+
return details.messagingEnabled ?? false;
|
|
263
|
+
}
|
|
264
|
+
var DEFAULT_ON_AUTH_FAILURE = () => new Response("Unauthorized", { status: 401 });
|
|
265
|
+
function createMoodleMediaHandler(getSession2, onAuthFailure = DEFAULT_ON_AUTH_FAILURE) {
|
|
266
|
+
async function handleRequest(request, method) {
|
|
267
|
+
const session = await getSession2();
|
|
268
|
+
if (!session) return onAuthFailure();
|
|
269
|
+
const { searchParams } = new URL(request.url);
|
|
270
|
+
const rawUrl = searchParams.get("url");
|
|
271
|
+
if (!rawUrl) return new Response("Missing url", { status: 400 });
|
|
272
|
+
if (!isAllowedMoodleUrl(rawUrl)) return new Response("Forbidden", { status: 403 });
|
|
273
|
+
const upstreamHeaders = new Headers();
|
|
274
|
+
const range = request.headers.get("range");
|
|
275
|
+
const ifNoneMatch = request.headers.get("if-none-match");
|
|
276
|
+
const ifModifiedSince = request.headers.get("if-modified-since");
|
|
277
|
+
if (range) upstreamHeaders.set("range", range);
|
|
278
|
+
if (ifNoneMatch) upstreamHeaders.set("if-none-match", ifNoneMatch);
|
|
279
|
+
if (ifModifiedSince) upstreamHeaders.set("if-modified-since", ifModifiedSince);
|
|
280
|
+
let upstream = null;
|
|
281
|
+
let sawAuthFailure = false;
|
|
282
|
+
for (const candidateUrl of getMoodleMediaCandidates(rawUrl, session.token)) {
|
|
283
|
+
const response = await fetch(candidateUrl, {
|
|
284
|
+
method,
|
|
285
|
+
headers: upstreamHeaders,
|
|
286
|
+
cache: range ? "no-store" : "force-cache"
|
|
287
|
+
});
|
|
288
|
+
if (isMoodleAuthenticationFailureResponse(response)) {
|
|
289
|
+
sawAuthFailure = true;
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
if (response.ok || response.status === 206 || response.status === 304) {
|
|
293
|
+
upstream = response;
|
|
294
|
+
break;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
if (!upstream) {
|
|
298
|
+
if (sawAuthFailure) return onAuthFailure();
|
|
299
|
+
return new Response("Upstream error", { status: 502 });
|
|
300
|
+
}
|
|
301
|
+
const headers = new Headers();
|
|
302
|
+
const passthrough = [
|
|
303
|
+
"accept-ranges",
|
|
304
|
+
"cache-control",
|
|
305
|
+
"content-disposition",
|
|
306
|
+
"content-length",
|
|
307
|
+
"content-range",
|
|
308
|
+
"content-type",
|
|
309
|
+
"etag",
|
|
310
|
+
"last-modified"
|
|
311
|
+
];
|
|
312
|
+
for (const name of passthrough) {
|
|
313
|
+
const value = upstream.headers.get(name);
|
|
314
|
+
if (value) headers.set(name, value);
|
|
315
|
+
}
|
|
316
|
+
if (!headers.has("cache-control")) {
|
|
317
|
+
headers.set(
|
|
318
|
+
"cache-control",
|
|
319
|
+
range ? "private, no-store" : "public, max-age=3600, stale-while-revalidate=86400"
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
if (!headers.has("accept-ranges")) headers.set("accept-ranges", "bytes");
|
|
323
|
+
headers.set("x-proxied-from", resolveAbsoluteMoodleUrl(rawUrl).origin);
|
|
324
|
+
return new Response(method === "HEAD" ? null : upstream.body, {
|
|
325
|
+
status: upstream.status,
|
|
326
|
+
headers
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
return {
|
|
330
|
+
GET: (request) => handleRequest(request, "GET"),
|
|
331
|
+
HEAD: (request) => handleRequest(request, "HEAD")
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
var DEFAULT_ON_AUTH_FAILURE2 = () => new Response("Unauthorized", { status: 401 });
|
|
335
|
+
function decodeBaseSegment(base) {
|
|
336
|
+
try {
|
|
337
|
+
return Buffer.from(base, "base64url").toString("utf8");
|
|
338
|
+
} catch {
|
|
339
|
+
return null;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
function buildUpstreamUrl(baseUrl, pathParts, requestUrl) {
|
|
343
|
+
const joinedPath = pathParts.map((part) => encodeURIComponent(part)).join("/");
|
|
344
|
+
const target = new URL(joinedPath || ".", baseUrl);
|
|
345
|
+
for (const [key, value] of requestUrl.searchParams.entries()) {
|
|
346
|
+
target.searchParams.append(key, value);
|
|
347
|
+
}
|
|
348
|
+
return target.toString();
|
|
349
|
+
}
|
|
350
|
+
function createMoodleScormHandler(getSession2, options = {}) {
|
|
351
|
+
const { onAuthFailure = DEFAULT_ON_AUTH_FAILURE2, injectIntoHtml } = options;
|
|
352
|
+
async function handleRequest(request, params, method) {
|
|
353
|
+
const session = await getSession2();
|
|
354
|
+
if (!session) return onAuthFailure();
|
|
355
|
+
const { base, path = [] } = params;
|
|
356
|
+
const decodedBase = decodeBaseSegment(base);
|
|
357
|
+
if (!decodedBase) return new Response("Invalid base", { status: 400 });
|
|
358
|
+
if (!isAllowedMoodleUrl(decodedBase)) return new Response("Forbidden", { status: 403 });
|
|
359
|
+
const requestUrl = new URL(request.url);
|
|
360
|
+
const upstreamUrl = buildUpstreamUrl(decodedBase, path, requestUrl);
|
|
361
|
+
if (!isAllowedMoodleUrl(upstreamUrl)) return new Response("Forbidden", { status: 403 });
|
|
362
|
+
const upstreamHeaders = new Headers();
|
|
363
|
+
const range = request.headers.get("range");
|
|
364
|
+
const ifNoneMatch = request.headers.get("if-none-match");
|
|
365
|
+
const ifModifiedSince = request.headers.get("if-modified-since");
|
|
366
|
+
if (range) upstreamHeaders.set("range", range);
|
|
367
|
+
if (ifNoneMatch) upstreamHeaders.set("if-none-match", ifNoneMatch);
|
|
368
|
+
if (ifModifiedSince) upstreamHeaders.set("if-modified-since", ifModifiedSince);
|
|
369
|
+
const upstream = await fetch(appendMoodleToken(upstreamUrl, session.token), {
|
|
370
|
+
method,
|
|
371
|
+
headers: upstreamHeaders,
|
|
372
|
+
cache: range ? "no-store" : "force-cache"
|
|
373
|
+
});
|
|
374
|
+
if (isMoodleAuthenticationFailureResponse(upstream)) return onAuthFailure();
|
|
375
|
+
if (!upstream.ok && upstream.status !== 206 && upstream.status !== 304) {
|
|
376
|
+
return new Response("Upstream error", { status: 502 });
|
|
377
|
+
}
|
|
378
|
+
const headers = new Headers({ "x-frame-options": "SAMEORIGIN" });
|
|
379
|
+
const passthrough = [
|
|
380
|
+
"accept-ranges",
|
|
381
|
+
"cache-control",
|
|
382
|
+
"content-disposition",
|
|
383
|
+
"content-length",
|
|
384
|
+
"content-range",
|
|
385
|
+
"content-type",
|
|
386
|
+
"etag",
|
|
387
|
+
"last-modified"
|
|
388
|
+
];
|
|
389
|
+
for (const name of passthrough) {
|
|
390
|
+
const value = upstream.headers.get(name);
|
|
391
|
+
if (value) headers.set(name, value);
|
|
392
|
+
}
|
|
393
|
+
if (!headers.has("cache-control")) {
|
|
394
|
+
headers.set(
|
|
395
|
+
"cache-control",
|
|
396
|
+
range ? "private, no-store" : "private, max-age=300, stale-while-revalidate=3600"
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
if (!headers.has("accept-ranges")) headers.set("accept-ranges", "bytes");
|
|
400
|
+
headers.set("x-proxied-from", resolveAbsoluteMoodleUrl(upstreamUrl).origin);
|
|
401
|
+
const contentType = headers.get("content-type") ?? "";
|
|
402
|
+
if (method === "GET" && injectIntoHtml && contentType.startsWith("text/html")) {
|
|
403
|
+
const html = await upstream.text();
|
|
404
|
+
const modified = await injectIntoHtml(html, requestUrl);
|
|
405
|
+
headers.delete("content-length");
|
|
406
|
+
return new Response(modified, { status: upstream.status, headers });
|
|
407
|
+
}
|
|
408
|
+
return new Response(method === "HEAD" ? null : upstream.body, {
|
|
409
|
+
status: upstream.status,
|
|
410
|
+
headers
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
return {
|
|
414
|
+
GET: (request, context) => context.params.then((params) => handleRequest(request, params, "GET")),
|
|
415
|
+
HEAD: (request, context) => context.params.then((params) => handleRequest(request, params, "HEAD"))
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
var DEFAULT_CACHE_CONTROL = "public, max-age=3600, stale-while-revalidate=86400";
|
|
419
|
+
function escapeXml(value) {
|
|
420
|
+
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
|
421
|
+
}
|
|
422
|
+
function getBrandInitials(siteName) {
|
|
423
|
+
const initials = siteName.split(/\s+/).filter(Boolean).slice(0, 2).map((part) => part.charAt(0).toUpperCase()).join("");
|
|
424
|
+
return initials || "C";
|
|
425
|
+
}
|
|
426
|
+
function buildFallbackSvg(siteName, variant) {
|
|
427
|
+
const safeSiteName = escapeXml(siteName);
|
|
428
|
+
const initials = escapeXml(getBrandInitials(siteName));
|
|
429
|
+
const showLabel = variant === "full" && safeSiteName.length <= 24;
|
|
430
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" role="img" aria-label="${safeSiteName}">
|
|
431
|
+
<defs>
|
|
432
|
+
<linearGradient id="brand-gradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
|
433
|
+
<stop offset="0%" stop-color="#0f172a" />
|
|
434
|
+
<stop offset="100%" stop-color="#1d4ed8" />
|
|
435
|
+
</linearGradient>
|
|
436
|
+
</defs>
|
|
437
|
+
<rect width="128" height="128" rx="28" fill="url(#brand-gradient)" />
|
|
438
|
+
<circle cx="64" cy="52" r="28" fill="rgba(255,255,255,0.14)" />
|
|
439
|
+
<text x="64" y="${showLabel ? "61" : "66"}" fill="#f8fafc" font-family="ui-sans-serif, system-ui, sans-serif" font-size="${showLabel ? "34" : "40"}" font-weight="700" text-anchor="middle">${initials}</text>
|
|
440
|
+
${showLabel ? `<text x="64" y="100" fill="#dbeafe" font-family="ui-sans-serif, system-ui, sans-serif" font-size="12" font-weight="600" text-anchor="middle">${safeSiteName}</text>` : ""}
|
|
441
|
+
</svg>`.trim();
|
|
442
|
+
}
|
|
443
|
+
function createFallbackResponse(siteName, variant, method) {
|
|
444
|
+
const svg = buildFallbackSvg(siteName, variant);
|
|
445
|
+
const headers = new Headers({
|
|
446
|
+
"cache-control": DEFAULT_CACHE_CONTROL,
|
|
447
|
+
"content-length": Buffer.byteLength(svg).toString(),
|
|
448
|
+
"content-type": "image/svg+xml; charset=utf-8",
|
|
449
|
+
"x-brand-logo-fallback": "true"
|
|
450
|
+
});
|
|
451
|
+
return new Response(method === "HEAD" ? null : svg, { status: 200, headers });
|
|
452
|
+
}
|
|
453
|
+
function createMoodleBrandLogoHandler() {
|
|
454
|
+
async function handleRequest(request, method) {
|
|
455
|
+
const variant = new URL(request.url).searchParams.get("variant") === "compact" ? "compact" : "full";
|
|
456
|
+
const branding = await getMoodleBranding();
|
|
457
|
+
const sourceUrl = (variant === "compact" ? branding.compactLogoUrl : branding.logoUrl) || branding.logoUrl || branding.compactLogoUrl;
|
|
458
|
+
if (!sourceUrl) return createFallbackResponse(branding.siteName, variant, method);
|
|
459
|
+
const upstream = await fetch(resolveAbsoluteMoodleUrl(sourceUrl).toString(), {
|
|
460
|
+
method,
|
|
461
|
+
cache: "force-cache",
|
|
462
|
+
// @ts-expect-error — Next.js fetch extension
|
|
463
|
+
next: { revalidate: 3600 }
|
|
464
|
+
}).catch(() => null);
|
|
465
|
+
if (!upstream?.ok) return createFallbackResponse(branding.siteName, variant, method);
|
|
466
|
+
const headers = new Headers();
|
|
467
|
+
const passthrough = ["cache-control", "content-length", "content-type", "etag", "last-modified"];
|
|
468
|
+
for (const name of passthrough) {
|
|
469
|
+
const value = upstream.headers.get(name);
|
|
470
|
+
if (value) headers.set(name, value);
|
|
471
|
+
}
|
|
472
|
+
if (!headers.has("cache-control")) headers.set("cache-control", DEFAULT_CACHE_CONTROL);
|
|
473
|
+
return new Response(method === "HEAD" ? null : upstream.body, {
|
|
474
|
+
status: upstream.status,
|
|
475
|
+
headers
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
return {
|
|
479
|
+
GET: (request) => handleRequest(request, "GET"),
|
|
480
|
+
HEAD: (request) => handleRequest(request, "HEAD")
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
function toBase64Url(value) {
|
|
484
|
+
return Buffer.from(value).toString("base64url");
|
|
485
|
+
}
|
|
486
|
+
function getMoodleMediaProxyUrl(rawUrl, basePath = "/api/moodle-media") {
|
|
487
|
+
if (!rawUrl) return void 0;
|
|
488
|
+
return `${basePath}?url=${encodeURIComponent(rawUrl)}`;
|
|
489
|
+
}
|
|
490
|
+
function getMoodleScormProxyUrl(rawUrl, basePath = "/api/moodle-scorm") {
|
|
491
|
+
if (!rawUrl) return void 0;
|
|
492
|
+
const absoluteUrl = resolveAbsoluteMoodleUrl(rawUrl);
|
|
493
|
+
const pathnameParts = absoluteUrl.pathname.split("/").filter(Boolean);
|
|
494
|
+
if (pathnameParts.length === 0) return void 0;
|
|
495
|
+
const filename = pathnameParts[pathnameParts.length - 1];
|
|
496
|
+
const baseUrl = new URL("./", absoluteUrl).toString();
|
|
497
|
+
const encodedBase = toBase64Url(baseUrl);
|
|
498
|
+
return `${basePath}/${encodedBase}/${filename}${absoluteUrl.search}`;
|
|
499
|
+
}
|
|
500
|
+
function getMoodleBrandLogoProxyUrl(variant = "full", basePath = "/api/moodle-brand-logo") {
|
|
501
|
+
return `${basePath}?variant=${variant}`;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// src/safe-redirect.ts
|
|
505
|
+
function sanitizeReturnPath(rawPath, fallback, allowedPrefixes = ["/"]) {
|
|
506
|
+
const path = rawPath?.trim() || fallback;
|
|
507
|
+
if (path.startsWith("/") && !path.startsWith("//") && allowedPrefixes.some((prefix) => path.startsWith(prefix))) {
|
|
508
|
+
return path;
|
|
509
|
+
}
|
|
510
|
+
return fallback;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
export { clearSession, clearSessionAndReturnUnauthorized, clearSessionIfAuthenticationError, configureMoodleSession, createMoodleBrandLogoHandler, createMoodleMediaHandler, createMoodleScormHandler, createSession, getMoodleBrandLogoProxyUrl, getMoodleBranding, getMoodleMediaProxyUrl, getMoodleScormProxyUrl, getSession, getSessionOrUnauthorizedResponse, getSiteForceLogin, getSiteGlobalSearchEnabled, getSiteMessagingEnabled, redirectIfSessionExpired, redirectToExpiredSession, requireSession, sanitizeReturnPath };
|
package/package.json
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@moodle-next/next",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Next.js adapter for @moodle-next/core — session, route handlers, server actions",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"moodle",
|
|
7
|
+
"nextjs",
|
|
8
|
+
"next.js",
|
|
9
|
+
"server-actions"
|
|
10
|
+
],
|
|
11
|
+
"license": "MIT",
|
|
12
|
+
"type": "module",
|
|
13
|
+
"exports": {
|
|
14
|
+
".": {
|
|
15
|
+
"types": "./dist/index.d.ts",
|
|
16
|
+
"import": "./dist/index.mjs",
|
|
17
|
+
"require": "./dist/index.cjs"
|
|
18
|
+
},
|
|
19
|
+
"./client": {
|
|
20
|
+
"types": "./dist/client.d.ts",
|
|
21
|
+
"import": "./dist/client.mjs",
|
|
22
|
+
"require": "./dist/client.cjs"
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"main": "./dist/index.cjs",
|
|
26
|
+
"module": "./dist/index.mjs",
|
|
27
|
+
"types": "./dist/index.d.ts",
|
|
28
|
+
"files": [
|
|
29
|
+
"dist"
|
|
30
|
+
],
|
|
31
|
+
"sideEffects": [
|
|
32
|
+
"./dist/client.mjs",
|
|
33
|
+
"./dist/client.cjs"
|
|
34
|
+
],
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"@moodle-next/core": "0.0.1"
|
|
37
|
+
},
|
|
38
|
+
"peerDependencies": {
|
|
39
|
+
"next": ">=14.0.0",
|
|
40
|
+
"react": ">=19.0.0",
|
|
41
|
+
"react-dom": ">=19.0.0",
|
|
42
|
+
"zod": ">=4.0.0",
|
|
43
|
+
"react-hook-form": ">=7.0.0",
|
|
44
|
+
"@hookform/resolvers": ">=3.0.0"
|
|
45
|
+
},
|
|
46
|
+
"peerDependenciesMeta": {
|
|
47
|
+
"zod": {
|
|
48
|
+
"optional": true
|
|
49
|
+
},
|
|
50
|
+
"react-hook-form": {
|
|
51
|
+
"optional": true
|
|
52
|
+
},
|
|
53
|
+
"@hookform/resolvers": {
|
|
54
|
+
"optional": true
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
"devDependencies": {
|
|
58
|
+
"@hookform/resolvers": "^3.10.0",
|
|
59
|
+
"@types/node": "^22",
|
|
60
|
+
"@types/react": "^19",
|
|
61
|
+
"@types/react-dom": "^19",
|
|
62
|
+
"next": "16.2.2",
|
|
63
|
+
"react": "19.2.4",
|
|
64
|
+
"react-dom": "19.2.4",
|
|
65
|
+
"tsup": "^8.4.0",
|
|
66
|
+
"typescript": "^5",
|
|
67
|
+
"zod": "^4.3.6",
|
|
68
|
+
"react-hook-form": "^7.72.1",
|
|
69
|
+
"@moodle-next/core": "0.0.1"
|
|
70
|
+
},
|
|
71
|
+
"engines": {
|
|
72
|
+
"node": ">=18"
|
|
73
|
+
},
|
|
74
|
+
"scripts": {
|
|
75
|
+
"build": "tsup",
|
|
76
|
+
"dev": "tsup --watch",
|
|
77
|
+
"typecheck": "tsc --noEmit",
|
|
78
|
+
"clean": "rm -rf dist"
|
|
79
|
+
}
|
|
80
|
+
}
|