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
18 changes: 18 additions & 0 deletions docs/storage.md
Original file line number Diff line number Diff line change
@@ -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.
180 changes: 180 additions & 0 deletions frontend/app/api/notes/[id]/download/route.ts
Original file line number Diff line number Diff line change
@@ -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<ResourceDownloadRow>();

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",
},
});
}
25 changes: 13 additions & 12 deletions frontend/app/api/notes/route.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -30,15 +30,14 @@ 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." },
{ status: 500 },
);
}

const adminClient = createSupabaseClient(supabaseUrl, supabaseServiceRoleKey);
const supabase = await createClient(bearerToken);
const {
data: { user },
Expand Down Expand Up @@ -70,7 +69,7 @@ export async function GET(req: Request) {

let query = supabase
.from("resources")
.select<ResourceRow>(
.select(
`
id,
title,
Expand All @@ -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<ResourceRow[]>();

if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
Expand All @@ -111,8 +110,9 @@ export async function GET(req: Request) {
if (ids.length > 0) {
const { data: statsData } = await supabase
.from("resource_vote_stats")
.select<VoteStatRow>("resource_id, upvotes, downvotes, score")
.in("resource_id", ids);
.select("resource_id, upvotes, downvotes, score")
.in("resource_id", ids)
.returns<VoteStatRow[]>();
voteStats = statsData ?? [];
}

Expand Down Expand Up @@ -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 };
}),
)
Expand Down
27 changes: 27 additions & 0 deletions frontend/app/dashboard/dashboard.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading