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