diff --git a/app/api/gists/[id]/route.ts b/app/api/gists/[id]/route.ts index f5e8ad9..ff708fa 100644 --- a/app/api/gists/[id]/route.ts +++ b/app/api/gists/[id]/route.ts @@ -82,7 +82,8 @@ export async function GET( indent_size: metadata.indent_size, wrap_mode: metadata.wrap_mode, theme: metadata.theme, - // Note: We exclude edit_pin_hash, edit_pin_salt, and encrypted_metadata for security + encrypted_metadata: metadata.encrypted_metadata, + // Note: We exclude edit_pin_hash and edit_pin_salt for security }; return NextResponse.json(responseMetadata, { diff --git a/app/create/page.tsx b/app/create/page.tsx index af1846f..69969cd 100644 --- a/app/create/page.tsx +++ b/app/create/page.tsx @@ -205,12 +205,12 @@ export default function CreateGistPage() { }; return ( - +
-

Create New Gist

+

Create New Gist

- Share code snippets with zero-knowledge encryption. Your files are - encrypted in your browser before being uploaded. + Share code snippets securely with zero-knowledge encryption. Your + files are encrypted locally in your browser before upload.

@@ -220,13 +220,13 @@ export default function CreateGistPage() { Description - Add an optional description for your gist + Give your gist a memorable title or description setDescription(e.target.value)} className="w-full" @@ -239,7 +239,7 @@ export default function CreateGistPage() { Files - Add up to 20 files. Each file can be up to 500KB. + Add your code, configs, or notes. Up to 20 files, 500KB each. @@ -259,7 +259,7 @@ export default function CreateGistPage() { Options - Configure expiration and edit protection for your gist. + Control who can edit and when your gist expires @@ -268,7 +268,7 @@ export default function CreateGistPage() {

- The gist will be automatically deleted after this time. + Your gist will self-destruct at the selected time

@@ -283,7 +283,7 @@ export default function CreateGistPage() { showConfirm={false} />

- If set, this PIN will be required to edit or delete the gist. + Lock down your gist - only you can edit or delete it

diff --git a/app/g/[id]/page.tsx b/app/g/[id]/page.tsx index 757fe76..9af6cf7 100644 --- a/app/g/[id]/page.tsx +++ b/app/g/[id]/page.tsx @@ -1,19 +1,345 @@ +"use client"; + +import { useEffect, useState, useCallback } from "react"; +import { useParams } from "next/navigation"; import { Container } from "@/components/ui/container"; +import { GistViewer } from "@/components/gist-viewer"; +import { LoadingState } from "@/components/ui/loading-state"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { CopyButton } from "@/components/ui/copy-button"; +import { + ExternalLink, + AlertTriangle, + KeyRound, + Clock, + FileCode, + Trash2, +} from "lucide-react"; +import { extractKeyFromUrl, unpackAndDecrypt, decrypt } from "@/lib/crypto"; +import { decodeFiles } from "@/lib/binary"; +import { formatDistanceToNow } from "date-fns"; +import { base64Decode } from "@/lib/base64"; +import type { GistMetadata, UserMetadata } from "@/types/models"; +import type { File } from "@/types"; -export default async function ViewGistPage({ - params, -}: { +interface ViewGistPageProps { params: Promise<{ id: string }>; -}) { - const { id } = await params; +} + +export default function ViewGistPage({ params: _params }: ViewGistPageProps) { + const params = useParams(); + const id = params.id as string; + + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [warning, setWarning] = useState(null); + const [metadata, setMetadata] = useState(null); + const [files, setFiles] = useState([]); + const [decryptionFailed, setDecryptionFailed] = useState(false); + const [notFound, setNotFound] = useState(false); + const [viewUrl, setViewUrl] = useState(""); + const [description, setDescription] = useState(""); + + // Fetch and decrypt gist + const fetchAndDecryptGist = useCallback(async () => { + try { + setLoading(true); + setError(null); + setWarning(null); + setDecryptionFailed(false); + setNotFound(false); + + // Get the encryption key from URL fragment + const key = await extractKeyFromUrl(window.location.href); + if (!key) { + setError("🔐 No encryption key found in URL"); + setDecryptionFailed(true); + setLoading(false); + return; + } + + // Fetch gist metadata + const metadataResponse = await fetch(`/api/gists/${id}`, { + headers: { + "X-Requested-With": "GhostPaste", + }, + }); + + if (!metadataResponse.ok) { + if (metadataResponse.status === 404) { + setNotFound(true); + setError("🤷 Gist not found. It may have expired or been deleted."); + } else { + const errorData = (await metadataResponse.json()) as { + message?: string; + }; + setError(errorData.message || "Failed to fetch gist"); + } + setLoading(false); + return; + } + + const gistData = (await metadataResponse.json()) as GistMetadata; + setMetadata(gistData); + + // Check if gist has expired + if (gistData.expires_at && new Date(gistData.expires_at) < new Date()) { + setError("⏰ This gist has expired and is no longer available."); + setNotFound(true); + setLoading(false); + return; + } + + // Handle one-time view warning + if (gistData.one_time_view && !gistData.viewed_at) { + setWarning( + "⚠️ This is a one-time view gist. It will be deleted after viewing." + ); + } + + // Fetch encrypted blob + const blobResponse = await fetch(`/api/blobs/${id}`, { + headers: { + "X-Requested-With": "GhostPaste", + }, + }); + + if (!blobResponse.ok) { + setError("Failed to fetch gist content"); + setLoading(false); + return; + } + + const blob = await blobResponse.arrayBuffer(); + + // Decrypt the blob + try { + const decryptedData = await unpackAndDecrypt(new Uint8Array(blob), key); + const decodedFiles = decodeFiles(decryptedData); + setFiles(decodedFiles); + + // Decrypt the metadata if present + if ( + gistData.encrypted_metadata?.iv && + gistData.encrypted_metadata?.data + ) { + try { + // Convert base64 to Uint8Array for decryption + const metadataIv = base64Decode(gistData.encrypted_metadata.iv); + const metadataCiphertext = base64Decode( + gistData.encrypted_metadata.data + ); + + const decryptedMetadata = await decrypt( + { iv: metadataIv, ciphertext: metadataCiphertext }, + key + ); + const metadataJson = new TextDecoder().decode(decryptedMetadata); + const userMetadata = JSON.parse(metadataJson) as UserMetadata; + + if (userMetadata.description) { + setDescription(userMetadata.description); + } + } catch (metadataError) { + console.warn("Failed to decrypt metadata:", metadataError); + // Non-critical error, continue without description + } + } + + // For one-time view gists, mark as viewed + if (gistData.one_time_view && !gistData.viewed_at) { + // The GET request should have already marked it as viewed + // Just show a message + setWarning( + "🗑️ This gist has been viewed and will be deleted shortly." + ); + } + } catch (decryptError) { + console.error("Decryption failed:", decryptError); + setError( + "🔓 Failed to decrypt gist. Invalid encryption key or corrupted data." + ); + setDecryptionFailed(true); + } + } catch (err) { + console.error("Error fetching gist:", err); + setError("An unexpected error occurred"); + } finally { + setLoading(false); + } + }, [id]); + + // Set view URL on mount + useEffect(() => { + setViewUrl(window.location.href); + }, []); + + // Fetch gist on mount + useEffect(() => { + fetchAndDecryptGist(); + }, [fetchAndDecryptGist]); + + if (loading) { + return ( + + + + ); + } + + if (error) { + return ( + + + + {error} + + + {decryptionFailed && ( + + + + + Missing Encryption Key + + + +

+ This gist is encrypted and requires a decryption key. The key + should be included in the URL after the # symbol. +

+
+

A valid URL looks like:

+ + {window.location.origin}/g/{id}#key=your-encryption-key + +
+

+ If you don't have the complete URL with the key, you + won't be able to view this gist. +

+
+
+ )} + + {notFound && ( +
+ +
+ )} +
+ ); + } return ( -

View Gist

-

- This page will display the gist viewer for viewing encrypted gists. -

-

Gist ID: {id}

+ {/* Warning Alert */} + {warning && ( + + + {warning} + + )} + + {/* Gist Header */} + {metadata && ( +
+ {/* Title and metadata */} +
+
+

+ {description || "✨ Anonymous Gist"} +

+
+ + + {files.length} {files.length === 1 ? "file" : "files"} + + + + + Created{" "} + {formatDistanceToNow(new Date(metadata.created_at), { + addSuffix: true, + })} + + {metadata.expires_at && ( + <> + + + Expires{" "} + {formatDistanceToNow(new Date(metadata.expires_at), { + addSuffix: true, + })} + + + )} +
+
+ + {/* Action buttons */} +
+ {metadata.one_time_view && ( + + + One-time view + + )} + + Copy URL + + +
+
+ + {/* Editor preferences */} + {(metadata.theme || metadata.indent_mode) && ( +
+ {metadata.theme && ( + + Theme: {metadata.theme} + + )} + {metadata.indent_mode && ( + + {metadata.indent_mode === "tabs" + ? "Tabs" + : `${metadata.indent_size || 2} Spaces`} + + )} +
+ )} +
+ )} + + {/* Files Viewer */} +
); } diff --git a/components/gist-viewer.tsx b/components/gist-viewer.tsx index efa3671..93bf04d 100644 --- a/components/gist-viewer.tsx +++ b/components/gist-viewer.tsx @@ -189,7 +189,7 @@ function FileContent({ readOnly={true} showLineNumbers={showLineNumbers} wordWrap={wordWrap} - className="min-h-[200px]" + height="auto" /> ); diff --git a/components/share-dialog.tsx b/components/share-dialog.tsx index 293d1ed..32e1d6e 100644 --- a/components/share-dialog.tsx +++ b/components/share-dialog.tsx @@ -1,7 +1,8 @@ "use client"; +import * as React from "react"; import { Button } from "@/components/ui/button"; -import { CopyIconButton, CopyTextButton } from "@/components/ui/copy-button"; +import { CopyButton, CopyIconButton } from "@/components/ui/copy-button"; import { Dialog, DialogClose, @@ -136,13 +137,14 @@ export function ShareDialog({ Download as Text -