Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions LearningPlatform/app/(student)/(shell)/courses/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
serializeCatalogQuery,
} from '@/lib/courses-catalog'
import { CourseCatalogCard } from '@/components/courses/course-catalog-card'
import { studentGlassCard } from '@/lib/student-glass-styles'
import { studentGlassCard, studentGlassFooterNavButton } from '@/lib/student-glass-styles'
import { cn } from '@/lib/utils'

export const dynamic = 'force-dynamic'
Expand Down Expand Up @@ -65,7 +65,12 @@ export default async function CoursesPage({ searchParams }: PageProps) {
<div className="mx-auto max-w-6xl space-y-10">
<div className="flex w-full flex-col gap-0">
<div className="flex w-full justify-start">
<Button variant="outline" size="sm" asChild className="shrink-0">
<Button
variant="outline"
size="sm"
asChild
className={cn('min-w-[10rem] shrink-0', studentGlassFooterNavButton)}
>
<Link href="/dashboard" className="inline-flex items-center gap-2">
<ArrowLeft className="h-4 w-4" aria-hidden />
Back to dashboard
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { cn } from '@/lib/utils'
import { studentGlassCard } from '@/lib/student-glass-styles'
import { studentGlassCard, studentGlassFooterNavButton } from '@/lib/student-glass-styles'

type CatalogDeck = {
id: string
Expand Down Expand Up @@ -186,7 +186,12 @@ export default function StandaloneFlashcardBrowsePage() {
<div className="mx-auto max-w-6xl space-y-10">
<div className="flex w-full flex-col gap-0">
<div className="flex w-full justify-start">
<Button variant="outline" size="sm" asChild className="shrink-0">
<Button
variant="outline"
size="sm"
asChild
className={cn('min-w-[10rem] shrink-0', studentGlassFooterNavButton)}
>
<Link href="/dashboard" className="inline-flex items-center gap-2">
<ArrowLeft className="h-4 w-4" aria-hidden />
Back to dashboard
Expand Down
283 changes: 250 additions & 33 deletions LearningPlatform/app/(student)/(shell)/dashboard/flashcards/page.tsx

Large diffs are not rendered by default.

Large diffs are not rendered by default.

50 changes: 28 additions & 22 deletions LearningPlatform/app/api/flashcards/[id]/review/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { prisma } from '@/lib/prisma'
import { requireAuth } from '@/lib/auth-helpers'
import { z } from 'zod'
import { calculateNextReview, parseSettings, DEFAULT_SETTINGS, type FlashcardState } from '@/lib/srs'
import { attachResolvedMediaUrls } from '@/lib/flashcard-media-urls'

// ─── Validation ───────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -44,10 +45,13 @@ export async function POST(req: Request, ctx: RouteContext) {
const card = await prisma.flashcard.findUnique({
where: { id },
select: {
id: true,
question: true,
answer: true,
tags: { select: { id: true, name: true, slug: true } },
id: true,
question: true,
answer: true,
questionImageId: true,
answerImageId: true,
tags: { select: { id: true, name: true, slug: true } },
deck: { select: { id: true, name: true, slug: true } },
},
})

Expand Down Expand Up @@ -117,24 +121,26 @@ export async function POST(req: Request, ctx: RouteContext) {
},
})

// Return combined flashcard content + updated per-user SRS state
return NextResponse.json({
flashcard: {
id: card.id,
question: card.question,
answer: card.answer,
tags: card.tags,
// SRS state (per-user)
state: updatedProgress.state,
interval: updatedProgress.interval,
easeFactor: updatedProgress.easeFactor,
repetition: updatedProgress.repetition,
stepIndex: updatedProgress.stepIndex,
nextReviewAt: updatedProgress.nextReviewAt,
lastReviewedAt: updatedProgress.lastReviewedAt,
lastResult: updatedProgress.lastResult,
},
})
const flashcardBase = {
id: card.id,
question: card.question,
answer: card.answer,
questionImageId: card.questionImageId,
answerImageId: card.answerImageId,
tags: card.tags,
deck: card.deck,
state: updatedProgress.state,
interval: updatedProgress.interval,
easeFactor: updatedProgress.easeFactor,
repetition: updatedProgress.repetition,
stepIndex: updatedProgress.stepIndex,
nextReviewAt: updatedProgress.nextReviewAt,
lastReviewedAt: updatedProgress.lastReviewedAt,
lastResult: updatedProgress.lastResult,
}
const [flashcard] = await attachResolvedMediaUrls([flashcardBase])

return NextResponse.json({ flashcard })
} catch (error) {
if (error instanceof Error && (error.message === 'Unauthorized' || error.message === 'Forbidden')) {
return NextResponse.json({ error: error.message }, { status: error.message === 'Unauthorized' ? 401 : 403 })
Expand Down
15 changes: 11 additions & 4 deletions LearningPlatform/app/api/flashcards/study/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { isCardDue, parseSettings, getCardUrgency } from '@/lib/srs'
import { getUserWeakTags } from '@/lib/analytics'
import { getSortedFreeStudyCardsForUser } from '@/lib/flashcards-study-free'
import { assertUserCanStudyDeckScope } from '@/lib/flashcards-study-access'
import { attachResolvedMediaUrls } from '@/lib/flashcard-media-urls'

/**
* GET /api/flashcards/study
Expand Down Expand Up @@ -61,7 +62,8 @@ export async function GET(req: Request) {

if (mode === 'free' && !tagSlug && !subject && !deckFilterSlug && !mainDeckSlugQ) {
const sorted = await getSortedFreeStudyCardsForUser(user.id)
return NextResponse.json({ cards: sorted, mode, total: sorted.length })
const cards = await attachResolvedMediaUrls(sorted)
return NextResponse.json({ cards, mode, total: cards.length })
}

// Kick off weak-tag fetch in parallel with settings failure is graceful.
Expand Down Expand Up @@ -212,7 +214,8 @@ export async function GET(req: Request) {
if (mode === 'free') {
// Filtered free learn (tag / subject / deck): same ordering as unfiltered free mode.
const sorted = sortWithWeakBonus(mergedCards)
return NextResponse.json({ cards: sorted, mode, total: sorted.length })
const cards = await attachResolvedMediaUrls(sorted)
return NextResponse.json({ cards, mode, total: cards.length })
}

// -- SRS mode filtering ----------------------------------------------------
Expand Down Expand Up @@ -253,10 +256,14 @@ export async function GET(req: Request) {

// Sort the final eligible set by urgency + weak-tag bonus
const combined = sortWithWeakBonus([...cappedActive, ...cappedNew])
const cards = await attachResolvedMediaUrls(combined)

return NextResponse.json({
cards: combined, mode, total: combined.length,
newCount: cappedNew.length, reviewCount: cappedActive.length,
cards,
mode,
total: cards.length,
newCount: cappedNew.length,
reviewCount: cappedActive.length,
})
} catch (error) {
if (error instanceof Error && (error.message === 'Unauthorized' || error.message === 'Forbidden')) {
Expand Down
91 changes: 66 additions & 25 deletions LearningPlatform/app/api/media/serve/[filename]/route.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,42 @@
import { NextResponse } from 'next/server'
import fs from 'fs'
import path from 'path'
import { isS3Configured, getSignedMediaUrl } from '@/lib/s3'
import { getPrivateMediaBody, isS3Configured, mediaExistsInS3 } from '@/lib/s3'

const PUBLIC_MEDIA_DIR = path.join(process.cwd(), 'public', 'media')

/**
* Decode the URL segment first, reject path separators, then take a single filename segment.
* Avoids decode-after-basename tricks (e.g. %2F) escaping `public/media`.
*/
function safeMediaBasename(raw: string): string | null {
let decoded: string
try {
decoded = decodeURIComponent(raw)
} catch {
return null
}
if (decoded.includes('\0')) return null
if (decoded.includes('/') || decoded.includes('\\')) return null
const base = path.posix.basename(decoded.replace(/\\/g, '/'))
if (!base || base === '.' || base === '..') return null
if (base.includes('/') || base.includes('\\')) return null
return base
}

/** Ensures `join(PUBLIC_MEDIA_DIR, name)` resolves under the media directory (Windows-safe). */
function resolvedMediaFilePath(name: string): string | null {
const resolvedDir = path.resolve(PUBLIC_MEDIA_DIR)
const joined = path.join(PUBLIC_MEDIA_DIR, name)
const resolvedFile = path.resolve(joined)
const prefix = resolvedDir.endsWith(path.sep) ? resolvedDir : `${resolvedDir}${path.sep}`
const safe =
process.platform === 'win32'
? resolvedFile.toLowerCase().startsWith(prefix.toLowerCase())
: resolvedFile.startsWith(prefix)
return safe ? joined : null
}

const mimeForExt = (ext: string) => {
switch (ext.toLowerCase()) {
case '.png': return 'image/png'
Expand All @@ -25,13 +57,14 @@ export async function GET(request: Request, props: any) {
const { filename: raw } = (await props.params) as { filename: string }
if (!raw) return NextResponse.json({ error: 'Missing filename' }, { status: 400 })

// Prevent path traversal
const name = decodeURIComponent(path.basename(raw))
const name = safeMediaBasename(raw)
if (!name) return NextResponse.json({ error: 'Invalid filename' }, { status: 400 })

// --- Local disk path takes priority ---
// Files uploaded before S3 was configured only exist on disk.
// Always serve from disk if present, regardless of S3 config.
const target = path.join(PUBLIC_MEDIA_DIR, name)
const target = resolvedMediaFilePath(name)
if (!target) return NextResponse.json({ error: 'Invalid path' }, { status: 400 })
if (fs.existsSync(target)) {
const ext = path.extname(name)
const mime = mimeForExt(ext)
Expand All @@ -46,20 +79,23 @@ export async function GET(request: Request, props: any) {
})
}

// --- S3 path: file not on disk, try signed URL redirect ---
// --- S3 path: stream through this origin so `<img src="/api/media/serve/...">` works
// under a strict CSP (browser img-src does not include every S3-compatible hostname).
if (isS3Configured()) {
const signedUrl = await getSignedMediaUrl(name)
if (signedUrl) {
// 307 Temporary Redirect - client follows to signed URL
// Short cache so the redirect itself isn't cached longer than the signed URL TTL
return NextResponse.redirect(signedUrl, {
status: 307,
headers: {
'Cache-Control': 'private, max-age=300', // 5 min - matches typical presigned URL usage
},
})
const streamed = await getPrivateMediaBody(name)
if (streamed) {
Comment thread
Z4phxr marked this conversation as resolved.
const headers: Record<string, string> = {
'Content-Type': streamed.contentType,
'Cache-Control': 'private, max-age=300',
}
if (streamed.body instanceof Uint8Array) {
const buf = Buffer.from(streamed.body)
headers['Content-Length'] = String(buf.length)
return new NextResponse(buf, { status: 200, headers })
}
return new NextResponse(streamed.body, { status: 200, headers })
}
console.warn(`[serve] S3 configured but failed to get signed URL for: ${name}`)
console.warn(`[serve] S3 configured but object not readable: ${name}`)
}

// File not on disk and not in S3 (or S3 not configured)
Expand All @@ -77,20 +113,25 @@ export async function HEAD(request: Request, props: any) {
const { filename: raw } = (await props.params) as { filename: string }
if (!raw) return NextResponse.json({ error: 'Missing filename' }, { status: 400 })

const name = decodeURIComponent(path.basename(raw))
const name = safeMediaBasename(raw)
if (!name) return NextResponse.json({ error: 'Invalid filename' }, { status: 400 })

// For S3: just confirm the object exists by attempting to generate a signed URL
if (isS3Configured()) {
const signedUrl = await getSignedMediaUrl(name, 60)
if (signedUrl) {
return new NextResponse(null, {
status: 200,
headers: { 'Cache-Control': 'private, max-age=60' },
})
try {
if (await mediaExistsInS3(name)) {
return new NextResponse(null, {
status: 200,
headers: { 'Cache-Control': 'private, max-age=60' },
})
}
} catch {
return NextResponse.json({ error: 'Server error' }, { status: 500 })
}
}

const target = path.join(PUBLIC_MEDIA_DIR, name)
const target = resolvedMediaFilePath(name)
if (!target) return NextResponse.json({ error: 'Invalid path' }, { status: 400 })

if (!fs.existsSync(target)) {
return NextResponse.json({ error: 'Not found' }, { status: 404 })
}
Expand Down
35 changes: 12 additions & 23 deletions LearningPlatform/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono, Outfit } from "next/font/google";
import { headers } from "next/headers";
import { cookies } from "next/headers";
import "./globals.css";
import { NavigationMetrics } from '@/components/perf/navigation-metrics'
import { PrefetchRoutes } from '@/components/perf/prefetch-routes'
import { ThemePreferenceProvider } from '@/components/theme-preference-provider'
import { THEME_COOKIE_NAME, readThemeFromCookie } from '@/lib/theme-cookie'

const geistSans = Geist({
variable: "--font-geist-sans",
Expand Down Expand Up @@ -34,37 +36,24 @@ export default async function RootLayout({
}: Readonly<{
children: React.ReactNode;
}>) {
// The nonce is generated per-request in middleware.ts and forwarded via the
// x-nonce request header. Applying it to the inline theme-init script allows
// the Content-Security-Policy to block all OTHER inline scripts while still
// permitting this one - eliminating the need for 'unsafe-inline' in script-src.
// Use `undefined` when the header is absent so React omits the attribute
// (rendering `nonce=""` causes hydration mismatches if the client
// later applies a real nonce). `headers().get` may return null during
// certain server-rendering scenarios, so prefer `undefined` over an
// empty string to avoid emitting an empty attribute.
const rawNonce = (await headers()).get('x-nonce')
// Never pass '' — React/Next can serialize that as nonce="" and mismatch a later render that has a real nonce.
const nonce = typeof rawNonce === 'string' && rawNonce.length > 0 ? rawNonce : undefined
const themeCookie = (await cookies()).get(THEME_COOKIE_NAME)?.value
const themePref = readThemeFromCookie(themeCookie)
const isDark = themePref === 'dark'

const themeInitScript = `(function(){try{var k='theme';var v=localStorage.getItem(k);var r=document.documentElement;if(v==='dark'){r.classList.add('dark');}else if(v==='light'){r.classList.remove('dark');}else{r.classList.add('dark');}}catch(e){try{document.documentElement.classList.add('dark');}catch(_){}} })()`
return (
<html lang="en" suppressHydrationWarning>
<html lang="en" className={isDark ? 'dark' : undefined} suppressHydrationWarning>
<head>
{/* Explicit favicon link to ensure the app/favicon.ico is used */}
<link rel="icon" href="/favicon.ico" />
<script
suppressHydrationWarning
{...(nonce ? { nonce } : {})}
dangerouslySetInnerHTML={{ __html: themeInitScript }}
/>
</head>
<body
className={`${geistSans.variable} ${geistMono.variable} ${outfit.variable} antialiased`}
>
<NavigationMetrics />
<PrefetchRoutes />
{children}
<ThemePreferenceProvider initialPreference={themePref}>
<NavigationMetrics />
<PrefetchRoutes />
{children}
</ThemePreferenceProvider>
</body>
</html>
);
Expand Down
6 changes: 3 additions & 3 deletions LearningPlatform/components/admin/add-flashcard-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -472,8 +472,8 @@ export function FlashcardDialog({
Question <span className="text-red-500">*</span>
</Label>
<p className="mb-2 text-xs text-gray-500">
Supports LaTeX — wrap inline math in <code>$…$</code> and display math in{' '}
<code>$$…$$</code>.
Markdown (e.g. <code>**bold**</code>, lists, triple-backtick code fences) and LaTeX (
<code>$…$</code>, <code>$$…$$</code>) render on the study page like lesson theory blocks.
</p>
<Textarea
id="question"
Expand Down Expand Up @@ -504,7 +504,7 @@ export function FlashcardDialog({
Answer <span className="text-red-500">*</span>
</Label>
<p className="mb-2 text-xs text-gray-500">
Supports LaTeX — use the same <code>$…$</code> / <code>$$…$$</code> syntax.
Same Markdown and LaTeX rules as the question (GFM tables and task lists are supported).
</p>
<Textarea
id="answer"
Expand Down
Loading
Loading