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
428 changes: 428 additions & 0 deletions app/api/gists/route.test.ts

Large diffs are not rendered by default.

192 changes: 192 additions & 0 deletions app/api/gists/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { StorageOperations } from "@/lib/storage-operations";
import { FILE_LIMITS } from "@/lib/constants";
import { AppError } from "@/types/errors";
import { generateSalt, hashPin } from "@/lib/auth";
import { errorResponse, ApiErrors, validationError } from "@/lib/api-errors";
import type { CreateGistResponse } from "@/types/api";
import type { GistMetadata } from "@/types/models";

export const runtime = "edge";

// Validation schema for gist metadata
const metadataSchema = z.object({
expires_at: z.string().datetime().nullable().optional(),
one_time_view: z.boolean().optional(),
file_count: z.number().int().positive().optional(),
blob_count: z.number().int().positive().optional(),
});

/**
* Parse multipart form data from request
*/
async function parseMultipartFormData(request: NextRequest): Promise<{
metadata: Record<string, unknown>;
blob: Uint8Array;
password?: string;
}> {
const formData = await request.formData();

// Get required parts
const metadataFile = formData.get("metadata") as File | null;
const blobFile = formData.get("blob") as File | null;
const passwordValue = formData.get("password") as string | null;

if (!metadataFile || !blobFile) {
throw ApiErrors.badRequest(
"Missing required form data parts: metadata and blob"
);
}

// Parse metadata JSON
let metadata: Record<string, unknown>;
try {
const metadataText = await metadataFile.text();
metadata = JSON.parse(metadataText) as Record<string, unknown>;
} catch {
throw ApiErrors.badRequest("Invalid metadata JSON");
}

// Read blob data
const blobBuffer = await blobFile.arrayBuffer();
const blob = new Uint8Array(blobBuffer);

return {
metadata,
blob,
password: passwordValue || undefined,
};
}

/**
* POST /api/gists
* Creates a new encrypted gist
*/
export async function POST(request: NextRequest) {
try {
// Check content type
const contentType = request.headers.get("content-type");
if (!contentType?.includes("multipart/form-data")) {
return errorResponse(
ApiErrors.badRequest("Content-Type must be multipart/form-data")
);
}

// Parse multipart form data
let formParts: {
metadata: Record<string, unknown>;
blob: Uint8Array;
password?: string;
};
try {
formParts = await parseMultipartFormData(request);
} catch (error) {
if (error instanceof AppError) {
return errorResponse(error);
}
return errorResponse(
ApiErrors.badRequest("Failed to parse multipart form data")
);
}

const { metadata: rawMetadata, blob, password } = formParts;

// Validate metadata
const validationResult = metadataSchema.safeParse(rawMetadata);
if (!validationResult.success) {
const errors = validationResult.error.flatten();
return errorResponse(
validationError("Invalid metadata", errors.fieldErrors)
);
}

const validatedMetadata = validationResult.data;

// Check size limits
if (blob.length > FILE_LIMITS.MAX_TOTAL_SIZE) {
return errorResponse(
ApiErrors.payloadTooLarge(
`Total size exceeds ${FILE_LIMITS.MAX_TOTAL_SIZE / 1024 / 1024}MB limit`
)
);
}

// Hash password if provided
let editPinHash: string | undefined;
let editPinSalt: string | undefined;
if (password) {
editPinSalt = await generateSalt();
editPinHash = await hashPin(password, editPinSalt);
}

// Prepare metadata for storage
const metadata: Omit<
GistMetadata,
"id" | "created_at" | "updated_at" | "version" | "current_version"
> = {
expires_at: validatedMetadata.expires_at ?? undefined,
one_time_view: validatedMetadata.one_time_view,
edit_pin_hash: editPinHash,
edit_pin_salt: editPinSalt,
total_size: blob.length,
blob_count: validatedMetadata.blob_count || 1,
// We'll need to set encrypted_metadata in the actual implementation
encrypted_metadata: { iv: "", data: "" }, // Placeholder for now
};

// Create gist using storage operations
try {
const { id } = await StorageOperations.createGist(metadata, blob);

// Build response
const response: CreateGistResponse = {
id,
url: `${process.env.NEXT_PUBLIC_APP_URL || "https://ghostpaste.dev"}/g/${id}`,
createdAt: new Date().toISOString(),
expiresAt: metadata.expires_at ?? null,
isOneTimeView: metadata.one_time_view ?? false,
};

return NextResponse.json<CreateGistResponse>(response, {
status: 201,
headers: {
Location: response.url,
"Cache-Control": "no-store",
},
});
} catch (error) {
// Handle storage errors
if (error instanceof AppError) {
return errorResponse(error);
}

// Log unexpected errors
console.error("Storage error:", error);
return errorResponse(ApiErrors.storageError("Failed to store gist data"));
}
} catch (error) {
// Handle unexpected errors
console.error("Unexpected error in POST /api/gists:", error);
return errorResponse(
error instanceof Error ? error : new Error("Unknown error")
);
}
}

/**
* OPTIONS /api/gists
* Handle preflight requests
*/
export async function OPTIONS() {
return new NextResponse(null, {
status: 200,
headers: {
"Access-Control-Allow-Origin":
process.env.NEXT_PUBLIC_APP_URL || "https://ghostpaste.dev",
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
"Access-Control-Max-Age": "86400",
},
});
}
Loading