-
Notifications
You must be signed in to change notification settings - Fork 7
Add button for downloading course completions #1307
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
1e538a2
9f11724
d36c7d5
83036a9
a3a0ac3
e79ac2b
313c77e
957dc11
601413d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -32,3 +32,5 @@ HY_ORGANIZATION_ID=x | |
|
|
||
| UPDATE_USER_SECRET=secret | ||
| BACKEND_URL=http://localhost:4000 | ||
|
|
||
| JWT_SECRET=supersecretkey | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,9 @@ | ||
| import { stringify } from "csv-stringify/sync" | ||
| import { Request, Response } from "express" | ||
| import JSONStream from "JSONStream" | ||
| import jwt, { Secret } from "jsonwebtoken" | ||
| import { chunk, omit } from "lodash" | ||
| import * as XLSX from "xlsx" | ||
| import * as yup from "yup" | ||
|
|
||
| import { | ||
|
|
@@ -14,13 +17,27 @@ import { | |
| import { generateUserCourseProgress } from "../../bin/kafkaConsumer/common/userCourseProgress/generateUserCourseProgress" | ||
| import { err, isDefined } from "../../util" | ||
| import { ApiContext, Controller } from "../types" | ||
| import { requireAdminOrCourseOwner } from "../utils" | ||
|
|
||
| const languageMap: Record<string, string> = { | ||
| en: "en_US", | ||
| sv: "sv_SE", | ||
| fi: "fi_FI", | ||
| } | ||
|
|
||
| // JWT secret for signing download tokens | ||
| const JWT_SECRET = process.env.JWT_SECRET as Secret | ||
|
|
||
| if (!JWT_SECRET) { | ||
| throw new Error("JWT_SECRET environment variable is required") | ||
| } | ||
|
|
||
| interface DownloadTokenPayload { | ||
| courseId: string | ||
| fromDate?: string | ||
| format?: "csv" | "excel" | ||
| } | ||
|
Comment on lines
28
to
39
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: # First, find and read the completions.ts file
find . -name "completions.ts" -type fRepository: rage/mooc.fi Length of output: 91 🏁 Script executed: # Get the file size and content around the specified lines
wc -l backend/api/routes/completions.tsRepository: rage/mooc.fi Length of output: 93 🏁 Script executed: # Read lines 26-36 to see the JWT secret handling
sed -n '26,36p' backend/api/routes/completions.tsRepository: rage/mooc.fi Length of output: 311 🏁 Script executed: # Read lines 113-144 to see what also applies
sed -n '113,144p' backend/api/routes/completions.tsRepository: rage/mooc.fi Length of output: 859 🏁 Script executed: # Check for JWT_SECRET usage throughout the codebase
rg "JWT_SECRET" --type ts --type tsx -B2 -A2Repository: rage/mooc.fi Length of output: 83 🏁 Script executed: # Look for environment variable configuration files and test setup
fd "\.env" -o "jest.config" -o "setup" -o "test"Repository: rage/mooc.fi Length of output: 197 🌐 Web query:
💡 Result: Short checklist / best practices for jsonwebtoken (v9) regarding expiresIn and required claims (iss, aud, sub) and short-lived tokens:
Practical verify call pattern (conceptual):
References: jsonwebtoken v9 security recommendations and README; RFC 8725 (JWT best practices); modern JWT implementation guides. (deepwiki.com) If you want, I can produce a short code snippet showing sign/verify options for your exact use case (access vs refresh). Citations:
🏁 Script executed: # Search for jwt.verify calls to see how tokens are verified
rg "jwt.verify" --max-count=20Repository: rage/mooc.fi Length of output: 161 🏁 Script executed: # Search for where fromDate is used (date validation)
rg "fromDate" --max-count=20Repository: rage/mooc.fi Length of output: 708 🏁 Script executed: # Read the complete completionsCSVToken endpoint to see CSV streaming
sed -n '113,180p' backend/api/routes/completions.tsRepository: rage/mooc.fi Length of output: 1837 🏁 Script executed: # Check for environment variable setup in test and deployment configs
find . -type f \( -name "*.env*" -o -name "*.yml" -o -name "docker*" \) | head -20Repository: rage/mooc.fi Length of output: 984 🏁 Script executed: # Look for any token verification endpoint
rg "completionsCSV" -A5 -B5Repository: rage/mooc.fi Length of output: 2000 🏁 Script executed: # Check the full date validation and CSV generation logic
sed -n '145,200p' backend/api/routes/completions.tsRepository: rage/mooc.fi Length of output: 1640 🏁 Script executed: # Check backend/.env.example for JWT_SECRET documentation
cat backend/.env.example | grep -A2 -B2 "JWT"Repository: rage/mooc.fi Length of output: 38 🏁 Script executed: # Check if there's any validation of the date
rg "new Date" -B3 -A3 backend/api/routes/completions.tsRepository: rage/mooc.fi Length of output: 315 🏁 Script executed: # Check the rest of the CSV generation to see if it's streaming
sed -n '190,250p' backend/api/routes/completions.tsRepository: rage/mooc.fi Length of output: 1660 🏁 Script executed: # Check package.json to confirm jsonwebtoken version
rg "jsonwebtoken" package.jsonRepository: rage/mooc.fi Length of output: 38 🏁 Script executed: # Look for JWT_SECRET in environment files
rg "JWT_SECRET" -B2 -A2 backend/Repository: rage/mooc.fi Length of output: 1140 🏁 Script executed: # Check package.json for jsonwebtoken version
cat package.json | grep -A2 -B2 "jsonwebtoken"Repository: rage/mooc.fi Length of output: 38 🏁 Script executed: # Look for test setup to see if JWT_SECRET is provisioned
fd "test\|spec\|jest" -type f | head -10Repository: rage/mooc.fi Length of output: 226 🏁 Script executed: # Check if there are any other JWT implementations or token types
rg "jwt\." --max-count=30Repository: rage/mooc.fi Length of output: 331 🏁 Script executed: # Find package.json files
find . -name "package.json" -type fRepository: rage/mooc.fi Length of output: 230 🏁 Script executed: # Check backend package.json for jsonwebtoken version
cat backend/package.json | grep "jsonwebtoken"Repository: rage/mooc.fi Length of output: 123 🏁 Script executed: # Check root package.json
cat package.json | grep "jsonwebtoken"Repository: rage/mooc.fi Length of output: 38 🏁 Script executed: # Look for environment setup in backend tests or docker
find backend -name "*.test.ts" -o -name "*.spec.ts" | head -5Repository: rage/mooc.fi Length of output: 304 🏁 Script executed: # Check docker-compose for JWT_SECRET
cat backend/docker-compose.yml | grep -A5 -B5 "JWT_SECRET"Repository: rage/mooc.fi Length of output: 38 🏁 Script executed: # Check completions test file
cat backend/api/routes/__test__/completions.test.ts | head -100Repository: rage/mooc.fi Length of output: 3283 🏁 Script executed: # Look for JWT_SECRET setup in test files
rg "JWT_SECRET" backend/api/routes/__test__/Repository: rage/mooc.fi Length of output: 38 🏁 Script executed: # Check for .env.test or similar
find backend -name ".env*" -o -name "setup*"Repository: rage/mooc.fi Length of output: 76 🏁 Script executed: # Check what's in backend/.env.example
cat backend/.env.exampleRepository: rage/mooc.fi Length of output: 708 🏁 Script executed: # Search for where completionsCSVToken tests are
rg "completionsCSVToken\|completionsCSV" backend/api/routes/__test__/completions.test.tsRepository: rage/mooc.fi Length of output: 38 🏁 Script executed: # Search the entire test file for CSV-related tests
rg "CSV\|csv" backend/api/routes/__test__/completions.test.ts -A10Repository: rage/mooc.fi Length of output: 38 🏁 Script executed: # Check if there's a global test setup file that sets JWT_SECRET
find backend -name "setup*" -o -name "config*" | grep -i testRepository: rage/mooc.fi Length of output: 38 🏁 Script executed: # Look for where getTestContext sets up environment
rg "getTestContext" backend/tests/ -A20 | head -50Repository: rage/mooc.fi Length of output: 1012 🏁 Script executed: # Check if JWT_SECRET is actually set in test environment
rg "process.env.JWT_SECRET\|JWT_SECRET =" backend/tests/ -B5 -A5Repository: rage/mooc.fi Length of output: 38 🏁 Script executed: # Look for where process.env variables are mocked in tests
rg "process.env\." backend/tests/index.ts | head -20Repository: rage/mooc.fi Length of output: 38 🏁 Script executed: # Check if there's a before hook that sets environment variables
cat backend/tests/index.ts | head -80Repository: rage/mooc.fi Length of output: 1772 🏁 Script executed: # Check if JWT_SECRET is set anywhere in the actual code startup
rg "JWT_SECRET" backend/ --type ts | grep -v test | grep -v node_modulesRepository: rage/mooc.fi Length of output: 483 🏁 Script executed: # Look at the server startup to see if JWT_SECRET is required
rg "process.env" backend/server.ts -A2 -B2 | head -40Repository: rage/mooc.fi Length of output: 38 🏁 Script executed: # Check if there's a date parsing issue - new Date() doesn't throw on invalid dates
node -e "console.log(new Date('invalid')); console.log(new Date('invalid') instanceof Date)"Repository: rage/mooc.fi Length of output: 73 🏁 Script executed: # Look for stringify import to see CSV approach
rg "stringify" backend/api/routes/completions.ts -B5 -A1Repository: rage/mooc.fi Length of output: 519 Ensure The startup check is good, but Additionally, the JWT token implementation needs security improvements:
🤖 Prompt for AI Agents |
||
|
|
||
| interface RegisterCompletionInput { | ||
| completion_id: string | ||
| student_number: string | ||
|
|
@@ -96,6 +113,174 @@ export class CompletionController extends Controller { | |
| return // NOSONAR | ||
| } | ||
|
|
||
| completionsCSVToken = async ( | ||
| req: Request<{ courseId: string }>, | ||
| res: Response, | ||
| ) => { | ||
| const { courseId } = req.params | ||
| const { fromDate, format } = req.query | ||
|
|
||
| const authRes = await requireAdminOrCourseOwner(courseId, this.ctx)( | ||
| req, | ||
| res, | ||
| ) | ||
|
|
||
| if (authRes.isErr()) { | ||
| return authRes.error | ||
| } | ||
|
|
||
| const course = await this.ctx.prisma.course.findUnique({ | ||
| where: { id: courseId }, | ||
| }) | ||
|
|
||
| if (!course) { | ||
| return res.status(404).json({ message: "Course not found" }) | ||
| } | ||
|
|
||
| // Generate a signed JWT token valid for 30 seconds | ||
| const payload: DownloadTokenPayload = { | ||
| courseId, | ||
| fromDate: typeof fromDate === "string" ? fromDate : undefined, | ||
| format: format === "excel" ? "excel" : "csv", | ||
| } | ||
|
|
||
| const token = jwt.sign(payload, JWT_SECRET, { | ||
| expiresIn: "30s", | ||
| }) | ||
|
|
||
| return res.status(200).json({ token }) | ||
| } | ||
|
|
||
| completionsCSV = async ( | ||
| req: Request<{ courseId: string }>, | ||
| res: Response, | ||
| ) => { | ||
| const { courseId } = req.params | ||
| const { token } = req.query | ||
| const { knex } = this.ctx | ||
|
|
||
| // Validate token | ||
| if (!token || typeof token !== "string") { | ||
| return res.status(401).json({ message: "Invalid or missing token" }) | ||
| } | ||
|
|
||
| let tokenData: DownloadTokenPayload | ||
| try { | ||
| tokenData = jwt.verify(token, JWT_SECRET) as DownloadTokenPayload | ||
| } catch (error) { | ||
| if (error instanceof jwt.TokenExpiredError) { | ||
| return res.status(401).json({ message: "Token expired" }) | ||
| } | ||
| return res.status(401).json({ message: "Invalid token" }) | ||
| } | ||
|
|
||
| if (tokenData.courseId !== courseId) { | ||
| return res | ||
| .status(403) | ||
| .json({ message: "Token not valid for this course" }) | ||
| } | ||
|
|
||
| const fromDate = tokenData.fromDate | ||
| const format = tokenData.format ?? "csv" | ||
|
|
||
| const course = await this.ctx.prisma.course.findUnique({ | ||
| where: { id: courseId }, | ||
| }) | ||
|
|
||
| if (!course) { | ||
| return res.status(404).json({ message: "Course not found" }) | ||
| } | ||
|
|
||
| let query = knex | ||
| .select<any, any[]>( | ||
| "u.id", | ||
| "com.email", | ||
| "u.first_name", | ||
| "u.last_name", | ||
| "com.completion_date", | ||
| "com.completion_language", | ||
| "com.grade", | ||
| ) | ||
| .from("completion as com") | ||
| .join("course as c", "com.course_id", "c.id") | ||
| .join("user as u", "com.user_id", "u.id") | ||
| .where("c.id", course.completions_handled_by_id ?? course.id) | ||
| .distinct("u.id", "com.course_id") | ||
| .orderBy("com.completion_date", "asc") | ||
| .orderBy("u.last_name", "asc") | ||
| .orderBy("u.first_name", "asc") | ||
| .orderBy("u.id", "asc") | ||
|
|
||
| if (fromDate && typeof fromDate === "string") { | ||
| try { | ||
| const date = new Date(fromDate) | ||
| query = query.where("com.completion_date", ">=", date) | ||
| } catch (e) { | ||
| return res.status(400).json({ message: "Invalid date format" }) | ||
| } | ||
| } | ||
|
|
||
| const completions = await query | ||
|
|
||
| const headers = [ | ||
| "User ID", | ||
| "Email", | ||
| "First Name", | ||
| "Last Name", | ||
| "Completion Date", | ||
| "Completion Language", | ||
| "Grade", | ||
| ] | ||
|
|
||
| const rows = completions.map((row) => [ | ||
| row.id, | ||
| row.email, | ||
| row.first_name, | ||
| row.last_name, | ||
| row.completion_date, | ||
| row.completion_language, | ||
| row.grade, | ||
| ]) | ||
|
|
||
| if (format === "excel") { | ||
| // Generate Excel file | ||
| const worksheet = XLSX.utils.aoa_to_sheet([headers, ...rows]) | ||
| const workbook = XLSX.utils.book_new() | ||
| XLSX.utils.book_append_sheet(workbook, worksheet, "Completions") | ||
|
|
||
| const excelBuffer = XLSX.write(workbook, { | ||
| type: "buffer", | ||
| bookType: "xlsx", | ||
| }) | ||
|
|
||
| res.setHeader( | ||
| "Content-Type", | ||
| "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", | ||
| ) | ||
| res.setHeader( | ||
| "Content-Disposition", | ||
| `attachment; filename="completions_${ | ||
| fromDate ? fromDate.toString().split("T")[0] : "all" | ||
| }.xlsx"`, | ||
| ) | ||
|
|
||
| return res.status(200).send(excelBuffer) | ||
| } | ||
|
|
||
| // Default CSV format | ||
| const csvContent = stringify([headers, ...rows]) | ||
|
|
||
| res.setHeader("Content-Type", "text/csv") | ||
| res.setHeader( | ||
| "Content-Disposition", | ||
| `attachment; filename="completions_${ | ||
| fromDate ? fromDate.toString().split("T")[0] : "all" | ||
| }.csv"`, | ||
| ) | ||
|
|
||
| return res.status(200).send(csvContent) | ||
| } | ||
|
|
||
| completionInstructions = async ( | ||
| req: Request<{ slug: string; language: string }>, | ||
| res: Response, | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.