Skip to content
Open
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
115 changes: 115 additions & 0 deletions src/pages/api/upload/file.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { getServerSession } from "next-auth";
import cloudinary from "@/lib/cloudinary";
import { options as authOptions } from "../auth/options";

export const config = {
api: {
bodyParser: {
sizeLimit: "15mb",
},
},
};

// Wider allow-list than /api/upload/image — covers what students typically
// submit alongside (or instead of) a GitHub link.
const DEFAULT_ALLOWED_FORMATS = [
"pdf",
"zip",
"jpg",
"jpeg",
"png",
"gif",
"webp",
"txt",
"md",
"doc",
"docx",
];

interface UploadedFile {
name?: string;
data: string;
}

interface UploadRequestBody {
file?: string;
files?: UploadedFile[] | string[];
folder?: string;
public_id?: string;
allowedFormats?: string[];
}

interface FileResult {
url: string;
public_id: string;
format?: string;
bytes?: number;
original_filename?: string;
}

interface UploadResponse {
success: boolean;
files?: FileResult[];
error?: string;
}

async function uploadOne(
file: string,
folder: string,
allowedFormats: string[],
public_id?: string
): Promise<FileResult> {
const result = await cloudinary.uploader.upload(file, {
folder,
public_id,
resource_type: "auto",
allowed_formats: allowedFormats,
});
return {
url: result.secure_url,
public_id: result.public_id,
format: result.format,
bytes: result.bytes,
original_filename: result.original_filename,
};
}

export default async function handler(req: NextApiRequest, res: NextApiResponse<UploadResponse>) {
if (req.method !== "POST") {
return res.status(405).json({ success: false, error: "Method not allowed" });
}

const session = await getServerSession(req, res, authOptions);
if (!session) {
return res.status(401).json({ success: false, error: "Unauthorized" });
}

try {
const { file, files, folder, public_id, allowedFormats } = req.body as UploadRequestBody;
const allow = allowedFormats?.length ? allowedFormats : DEFAULT_ALLOWED_FORMATS;
const targetFolder = folder || "vets-who-code/uploads";

if (file) {
const result = await uploadOne(file, targetFolder, allow, public_id);
return res.status(200).json({ success: true, files: [result] });
}

if (Array.isArray(files) && files.length > 0) {
// Accept both shapes — bare base64 strings or { name, data } pairs.
const dataStrings: string[] = files.map((f) => (typeof f === "string" ? f : f.data));
const results = await Promise.all(
dataStrings.map((data) => uploadOne(data, targetFolder, allow))
);
return res.status(200).json({ success: true, files: results });
}

return res.status(400).json({ success: false, error: "No file or files provided" });
} catch (error) {
console.error("[upload/file] Upload error:", error);
return res.status(500).json({
success: false,
error: error instanceof Error ? error.message : "Failed to upload",
});
}
}
96 changes: 90 additions & 6 deletions src/pages/assignments/submit/[assignmentId].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,88 @@ type PageWithLayout = NextPage<PageProps> & {
Layout?: typeof Layout01;
};

const MAX_FILE_BYTES = 10 * 1024 * 1024; // 10 MB per file — matches the upload route's body limit
const ALLOWED_EXTENSIONS = [
"pdf",
"zip",
"jpg",
"jpeg",
"png",
"gif",
"webp",
"txt",
"md",
"doc",
"docx",
];

function fileToBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = () => reject(reader.error || new Error("Failed to read file"));
reader.readAsDataURL(file);
});
}

function getExtension(name: string): string {
const idx = name.lastIndexOf(".");
return idx === -1 ? "" : name.slice(idx + 1).toLowerCase();
}

type UploadedFileMeta = {
name: string;
size: number;
url: string;
public_id: string;
format?: string;
};

async function uploadAttachments(fileList: FileList, folder: string): Promise<UploadedFileMeta[]> {
const fileArr = Array.from(fileList);

// Pre-flight validation so users get a specific message instead of a
// generic Cloudinary 4xx after the upload kicks off.
for (const f of fileArr) {
if (f.size > MAX_FILE_BYTES) {
throw new Error(`${f.name} exceeds the 10 MB per-file limit.`);
}
const ext = getExtension(f.name);
if (!ALLOWED_EXTENSIONS.includes(ext)) {
throw new Error(
`${f.name}: ".${ext}" is not an accepted file type. Allowed: ${ALLOWED_EXTENSIONS.join(", ")}.`
);
}
}

const payloads = await Promise.all(
fileArr.map(async (f) => ({ name: f.name, data: await fileToBase64(f) }))
);

const uploadRes = await fetch("/api/upload/file", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ files: payloads, folder }),
});
const uploadData = await uploadRes.json();
if (!uploadRes.ok || !uploadData.success) {
throw new Error(uploadData.error || "Failed to upload attachments.");
}

return uploadData.files.map(
(
result: { url: string; public_id: string; format?: string },
idx: number
): UploadedFileMeta => ({
name: fileArr[idx]?.name ?? "file",
size: fileArr[idx]?.size ?? 0,
url: result.url,
public_id: result.public_id,
format: result.format,
})
);
}

const AssignmentSubmissionPage: PageWithLayout = ({ assignment }) => {
const { data: session, status } = useSession();
const router = useRouter();
Expand All @@ -56,13 +138,13 @@ const AssignmentSubmissionPage: PageWithLayout = ({ assignment }) => {
setError(null);

try {
// TODO: Upload files to Cloudinary if provided
let filesJson = null;
let filesJson: string | null = null;
if (files && files.length > 0) {
// For now, we'll store file names until Cloudinary integration is complete
filesJson = JSON.stringify(
Array.from(files).map((f) => ({ name: f.name, size: f.size }))
const uploaded = await uploadAttachments(
files,
`assignments/${assignment.course.id}/${assignment.id}`
);
filesJson = JSON.stringify(uploaded);
}

const response = await fetch("/api/lms/submissions", {
Expand Down Expand Up @@ -326,6 +408,7 @@ const AssignmentSubmissionPage: PageWithLayout = ({ assignment }) => {
type="file"
id="files"
multiple={true}
accept=".pdf,.zip,.jpg,.jpeg,.png,.gif,.webp,.txt,.md,.doc,.docx"
onChange={handleFileChange}
className="tw-sr-only"
/>
Expand All @@ -345,7 +428,8 @@ const AssignmentSubmissionPage: PageWithLayout = ({ assignment }) => {
or drag and drop
</p>
<p className="tw-text-xs tw-text-gray-500">
Common formats: ZIP, PDF, images
PDF, ZIP, DOC/DOCX, TXT/MD, or images — 10
MB max per file.
</p>
</div>
</label>
Expand Down
Loading