diff --git a/docs/storage.md b/docs/storage.md new file mode 100644 index 0000000..ba42b10 --- /dev/null +++ b/docs/storage.md @@ -0,0 +1,18 @@ +# Storage and Signed URLs + +Note Sharer uses Supabase Storage with signed URLs for private file access. + +## Buckets +- `resources`: PDFs and current preview images (fallback). +- `previews`: preview images when stored separately from PDFs. + +## Signed URLs +- Generated server-side only via `frontend/lib/storage.ts`. +- Default TTL is 3600 seconds (1 hour) for previews. +- Downloads are streamed from the API after auth checks (no signed URL returned). +- Preview URLs in `frontend/app/api/notes/route.ts` return `null` if a signed URL + cannot be generated (expired or missing files). + +## Usage Notes +- Prefer `generateSignedUrl(bucket, filePath)` for any private file access. +- Download endpoints should reuse the same helper to keep TTL and error handling consistent. diff --git a/frontend/app/api/notes/[id]/download/route.ts b/frontend/app/api/notes/[id]/download/route.ts new file mode 100644 index 0000000..6947e47 --- /dev/null +++ b/frontend/app/api/notes/[id]/download/route.ts @@ -0,0 +1,180 @@ +import { NextResponse } from "next/server"; +import { headers } from "next/headers"; +import { createClient as createSupabaseClient } from "@supabase/supabase-js"; +import { createClient } from "@/utils/supabaseServerClient"; + +type ResourceDownloadRow = { + id: string; + file_key: string; + profile_id: string; + status: string; + download_cost: number; +}; + +export async function GET( + _req: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const { id: resourceId } = await params; + if (!resourceId) { + return NextResponse.json({ error: "Resource id is required." }, { status: 400 }); + } + + const headerStore = await headers(); + const authHeader = headerStore.get("authorization"); + const bearerToken = authHeader?.toLowerCase().startsWith("bearer ") + ? authHeader.split(" ")[1]?.trim() + : null; + + const supabase = await createClient(bearerToken); + const { + data: { session }, + error: sessionError, + } = await supabase.auth.getSession(); + + let user = session?.user ?? null; + if (!user) { + const { + data: { user: fetchedUser }, + error: userError, + } = await supabase.auth.getUser(); + if (userError) { + const status = userError.status === 401 ? 401 : 500; + const message = + userError.status === 401 || userError.message === "Auth session missing!" + ? "Not authenticated" + : userError.message; + return NextResponse.json({ error: message }, { status }); + } + user = fetchedUser ?? null; + } + + if (sessionError && !user) { + return NextResponse.json({ error: sessionError.message }, { status: 500 }); + } + + if (!user) { + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); + } + + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; + const supabaseServiceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY; + if (!supabaseUrl || !supabaseServiceRoleKey) { + return NextResponse.json( + { error: "Supabase environment variables are not configured." }, + { status: 500 }, + ); + } + + const adminClient = createSupabaseClient(supabaseUrl, supabaseServiceRoleKey); + + const { data: resource, error: resourceError } = await supabase + .from("resources") + .select("id, file_key, profile_id, status, download_cost") + .eq("id", resourceId) + .single() + .returns(); + + if (resourceError || !resource) { + return NextResponse.json({ error: "Resource not found." }, { status: 404 }); + } + + if (resource.status !== "active") { + return NextResponse.json({ error: "Resource is not available for download." }, { status: 403 }); + } + + const isOwner = resource.profile_id === user.id; + let alreadyOwned = isOwner; + + if (!alreadyOwned) { + const { data: download, error: downloadError } = await supabase + .from("resource_downloads") + .select("id") + .eq("resource_id", resourceId) + .eq("profile_id", user.id) + .maybeSingle() + .returns<{ id: number }>(); + + if (downloadError && downloadError.code !== "PGRST116") { + return NextResponse.json({ error: downloadError.message }, { status: 500 }); + } + + alreadyOwned = Boolean(download?.id); + } + + if (!alreadyOwned) { + const { data: profile, error: profileError } = await supabase + .from("profiles") + .select("credit_score") + .eq("id", user.id) + .single(); + + if (profileError || !profile) { + return NextResponse.json( + { error: "Failed to read credits.", details: profileError?.message }, + { status: 500 }, + ); + } + + const cost = resource.download_cost ?? 0; + const currentCredits = profile.credit_score ?? 0; + + if (currentCredits < cost) { + return NextResponse.json( + { error: `Insufficient credits: have ${currentCredits}, need ${cost}.` }, + { status: 402 }, + ); + } + + const newCredits = currentCredits - cost; + const { error: updateError } = await adminClient + .from("profiles") + .update({ credit_score: newCredits }) + .eq("id", user.id); + + if (updateError) { + return NextResponse.json( + { error: "Failed to update credits.", details: updateError.message }, + { status: 500 }, + ); + } + + const { error: downloadError } = await adminClient + .from("resource_downloads") + .insert({ + resource_id: resourceId, + profile_id: user.id, + credits_spent: cost, + }); + + if (downloadError) { + return NextResponse.json( + { error: "Failed to record download.", details: downloadError.message }, + { status: 500 }, + ); + } + } + + const { data: fileData, error: fileError } = await adminClient.storage + .from("resources") + .download(resource.file_key); + + if (fileError || !fileData) { + return NextResponse.json( + { error: "Failed to download file.", details: fileError?.message }, + { status: 500 }, + ); + } + + const fileName = resource.file_key.split("/").pop() || "note.pdf"; + const fileBuffer = Buffer.from(await fileData.arrayBuffer()); + + return new NextResponse(fileBuffer, { + status: 200, + headers: { + "Content-Type": fileData.type || "application/pdf", + "Content-Disposition": `attachment; filename="${fileName}"`, + "Cache-Control": "no-store", + }, + }); +} diff --git a/frontend/app/api/notes/route.ts b/frontend/app/api/notes/route.ts index aade199..4b5c33f 100644 --- a/frontend/app/api/notes/route.ts +++ b/frontend/app/api/notes/route.ts @@ -1,7 +1,7 @@ import { NextResponse } from "next/server"; import { headers } from "next/headers"; +import { generateSignedUrl } from "@/lib/storage"; import { createClient } from "@/utils/supabaseServerClient"; -import { createClient as createSupabaseClient } from "@supabase/supabase-js"; type ResourceRow = { id: string; @@ -30,7 +30,7 @@ export async function GET(req: Request) { : null; const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; - const supabaseServiceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY; + const supabaseServiceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY; if (!supabaseUrl || !supabaseServiceRoleKey) { return NextResponse.json( { error: "Supabase environment variables are not configured." }, @@ -38,7 +38,6 @@ export async function GET(req: Request) { ); } - const adminClient = createSupabaseClient(supabaseUrl, supabaseServiceRoleKey); const supabase = await createClient(bearerToken); const { data: { user }, @@ -70,7 +69,7 @@ export async function GET(req: Request) { let query = supabase .from("resources") - .select( + .select( ` id, title, @@ -97,7 +96,7 @@ export async function GET(req: Request) { query = query.range(from, to); - const { data, error, count } = await query; + const { data, error, count } = await query.returns(); if (error) { return NextResponse.json({ error: error.message }, { status: 500 }); @@ -111,8 +110,9 @@ export async function GET(req: Request) { if (ids.length > 0) { const { data: statsData } = await supabase .from("resource_vote_stats") - .select("resource_id, upvotes, downvotes, score") - .in("resource_id", ids); + .select("resource_id, upvotes, downvotes, score") + .in("resource_id", ids) + .returns(); voteStats = statsData ?? []; } @@ -146,11 +146,12 @@ export async function GET(req: Request) { return { ...base, previewUrl: null }; } - const { data: signed, error: signedError } = await adminClient.storage - .from("resources") - .createSignedUrl(path, 3600); - - const previewUrl = signedError ? null : signed?.signedUrl ?? null; + let previewUrl: string | null = null; + try { + previewUrl = await generateSignedUrl("resources", path); + } catch { + previewUrl = null; + } return { ...base, previewUrl }; }), ) diff --git a/frontend/app/dashboard/dashboard.css b/frontend/app/dashboard/dashboard.css index e6d8861..6d6f496 100644 --- a/frontend/app/dashboard/dashboard.css +++ b/frontend/app/dashboard/dashboard.css @@ -252,6 +252,33 @@ color: #f0ebe3; } +.dashboard-download-row { + margin-top: 8px; +} + +.dashboard-download { + border: 1px solid #f5f1eb; + background: transparent; + color: #f5f1eb; + font-size: 12px; + padding: 6px 12px; + border-radius: 999px; + cursor: pointer; + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.dashboard-download:hover { + transform: translateY(-1px); + box-shadow: 0 10px 22px -16px rgba(0, 0, 0, 0.6); +} + +.dashboard-download:disabled { + cursor: not-allowed; + opacity: 0.6; + transform: none; + box-shadow: none; +} + .dashboard-votes-up { color: #9be2b2; font-weight: 600; diff --git a/frontend/app/dashboard/page.tsx b/frontend/app/dashboard/page.tsx index 269f16f..27ed6f8 100644 --- a/frontend/app/dashboard/page.tsx +++ b/frontend/app/dashboard/page.tsx @@ -75,6 +75,8 @@ export default function DashboardPage() { const [classesError, setClassesError] = useState(null); const [credits, setCredits] = useState(null); const [freeDownloads, setFreeDownloads] = useState(null); + const [downloadError, setDownloadError] = useState(null); + const [downloadingId, setDownloadingId] = useState(null); const [selectedNote, setSelectedNote] = useState(null); const [isNoteModalOpen, setIsNoteModalOpen] = useState(false); @@ -354,6 +356,54 @@ export default function DashboardPage() { } }; + const handleDownload = async (noteId: string) => { + if (!accessToken) { + setDownloadError("Not authenticated. Please sign in again."); + return; + } + + if (downloadingId) return; + + setDownloadError(null); + setDownloadingId(noteId); + + try { + const res = await fetch(`/api/notes/${noteId}/download`, { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }); + + if (!res.ok) { + const payload = await res.json().catch(() => null); + const message = + payload && typeof payload === "object" && "error" in payload + ? String(payload.error) + : "Failed to download note."; + setDownloadError(message); + return; + } + + const blob = await res.blob(); + const url = window.URL.createObjectURL(blob); + const link = document.createElement("a"); + const disposition = res.headers.get("content-disposition"); + const match = disposition?.match(/filename="([^"]+)"/); + const fallbackName = `${noteId}.pdf`; + link.href = url; + link.download = match?.[1] || fallbackName; + document.body.appendChild(link); + link.click(); + link.remove(); + window.URL.revokeObjectURL(url); + refreshCredits(); + } catch { + setDownloadError("Failed to download note. Try again."); + } finally { + setDownloadingId(null); + } + }; + const handleOpenNoteModal = (note: Note) => { setSelectedNote(note); setIsNoteModalOpen(true); @@ -365,7 +415,7 @@ export default function DashboardPage() { }; const handleReportNote = () => { - + // TODO: wire report flow }; const selectedClassLabel = @@ -522,6 +572,8 @@ export default function DashboardPage() { {notesError &&

{notesError}

} + {downloadError &&

{downloadError}

} +
    {notes.map((note) => (
  • +
    + +
  • diff --git a/frontend/lib/storage.ts b/frontend/lib/storage.ts new file mode 100644 index 0000000..cd5313f --- /dev/null +++ b/frontend/lib/storage.ts @@ -0,0 +1,51 @@ +import "server-only"; + +import { createClient } from "@supabase/supabase-js"; + +const DEFAULT_SIGNED_URL_TTL_SECONDS = 3600; + +let adminClient: ReturnType | null = null; + +const getAdminClient = () => { + if (adminClient) return adminClient; + + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; + const supabaseServiceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY; + + if (!supabaseUrl || !supabaseServiceRoleKey) { + throw new Error("Supabase service role credentials are not configured."); + } + + adminClient = createClient(supabaseUrl, supabaseServiceRoleKey, { + auth: { + persistSession: false, + autoRefreshToken: false, + detectSessionInUrl: false, + }, + }); + + return adminClient; +}; + +export const SIGNED_URL_TTL_SECONDS = DEFAULT_SIGNED_URL_TTL_SECONDS; + +export async function generateSignedUrl( + bucket: string, + filePath: string, + expiresIn: number = DEFAULT_SIGNED_URL_TTL_SECONDS, +): Promise { + if (!bucket || !filePath) { + throw new Error("Bucket and file path are required to generate a signed URL."); + } + + const { data, error } = await getAdminClient() + .storage + .from(bucket) + .createSignedUrl(filePath, expiresIn); + + if (error || !data?.signedUrl) { + throw new Error(`Failed to generate signed URL: ${error?.message ?? "Unknown error"}`); + } + + return data.signedUrl; +} diff --git a/supabase/migrations/202503070000_update_resource_vote_stats_view.sql b/supabase/migrations/202503070000_update_resource_vote_stats_view.sql new file mode 100644 index 0000000..1b09faf --- /dev/null +++ b/supabase/migrations/202503070000_update_resource_vote_stats_view.sql @@ -0,0 +1,10 @@ +-- Ensure resource_vote_stats includes all resources (even with zero votes) +create or replace view public.resource_vote_stats as +select + r.id as resource_id, + coalesce(sum(case when v.value = 1 then 1 end), 0) as upvotes, + coalesce(sum(case when v.value = -1 then 1 end), 0) as downvotes, + coalesce(sum(v.value), 0) as score +from public.resources r +left join public.votes v on v.resource_id = r.id +group by r.id;