diff --git a/docs/R2_SETUP.md b/docs/R2_SETUP.md index 8280e84..0ff734e 100644 --- a/docs/R2_SETUP.md +++ b/docs/R2_SETUP.md @@ -87,11 +87,82 @@ curl -X DELETE "http://localhost:8788/api/r2-test?key=test-file" ## Storage Structure -GhostPaste will use the following R2 object structure: +GhostPaste uses a versioned storage structure following the SPEC.md design: ``` -metadata/{gistId}.json # Unencrypted metadata -blobs/{gistId}.bin # Encrypted binary data +metadata/{gistId}.json # Unencrypted metadata (points to current version) +versions/{gistId}/{timestamp}.bin # Encrypted blob versions +temp/{gistId} # Temporary storage (optional) +``` + +Key points: + +- All blobs are stored as versioned files under `versions/` +- Metadata tracks the `current_version` timestamp +- No separate `blobs/` directory - everything is versioned +- New versions just add a timestamp file +- Last 50 versions are kept (older ones pruned) + +## R2 Storage Client + +GhostPaste includes a type-safe R2 storage client wrapper (`lib/storage.ts`) that provides: + +### Features + +- **Type-safe operations**: Strongly typed methods for all R2 operations +- **Error handling**: Custom error types with detailed error messages +- **Singleton pattern**: Efficient connection reuse across requests +- **Binary support**: Handle both JSON metadata and binary blobs + +### Usage + +```typescript +import { getR2Storage } from "@/lib/storage"; + +// Get storage instance (automatically initialized) +const storage = await getR2Storage(); + +// Store metadata +await storage.putMetadata(gistId, metadata); + +// Retrieve metadata +const metadata = await storage.getMetadata(gistId); + +// Store encrypted blob (returns timestamp for the version) +const timestamp = await storage.putBlob(gistId, encryptedData); + +// Retrieve specific version +const blob = await storage.getBlob(gistId, timestamp); + +// Retrieve current version +const currentBlob = await storage.getCurrentBlob(gistId); + +// List all versions for a gist +const versions = await storage.listVersions(gistId); + +// Prune old versions (keep last 50) +const deletedCount = await storage.pruneVersions(gistId, 50); + +// Check if gist exists +const exists = await storage.exists(gistId); + +// Delete gist (metadata and all versions) +await storage.deleteGist(gistId); + +// List gists with pagination +const { gists, cursor } = await storage.listGists({ limit: 100 }); +``` + +### Key Structure + +The storage client uses consistent key patterns: + +```typescript +const StorageKeys = { + metadata: (id: string) => `metadata/${id}.json`, + version: (id: string, timestamp: string) => `versions/${id}/${timestamp}.bin`, + temp: (id: string) => `temp/${id}`, +}; ``` ## Important Notes diff --git a/lib/storage.test.ts b/lib/storage.test.ts new file mode 100644 index 0000000..19f86a1 --- /dev/null +++ b/lib/storage.test.ts @@ -0,0 +1,561 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { + R2Storage, + getR2Storage, + StorageKeys, + isR2NotFoundError, + resetStorageInstance, +} from "./storage"; +import { AppError, ErrorCode } from "@/types/errors"; +import type { GistMetadata } from "@/types/models"; + +// Mock getCloudflareContext +vi.mock("@opennextjs/cloudflare", () => ({ + getCloudflareContext: vi.fn(), +})); + +// Import the mocked module +import { getCloudflareContext } from "@opennextjs/cloudflare"; + +describe("R2Storage", () => { + let storage: R2Storage; + let mockBucket: any; + + beforeEach(() => { + // Reset mocks + vi.clearAllMocks(); + + // Create mock R2 bucket + mockBucket = { + put: vi.fn(), + get: vi.fn(), + delete: vi.fn(), + head: vi.fn(), + list: vi.fn(), + }; + + // Mock the getCloudflareContext function + vi.mocked(getCloudflareContext).mockResolvedValue({ + env: { + GHOSTPASTE_BUCKET: mockBucket, + NEXT_PUBLIC_APP_URL: "http://localhost:3000", + ENVIRONMENT: "test", + } as any, + cf: {} as any, + ctx: {} as any, + }); + + storage = new R2Storage(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("initialize", () => { + it("should initialize successfully with bucket binding", async () => { + await expect(storage.initialize()).resolves.toBeUndefined(); + }); + + it("should throw error if bucket binding not found", async () => { + vi.mocked(getCloudflareContext).mockResolvedValue({ + env: { + NEXT_PUBLIC_APP_URL: "http://localhost:3000", + ENVIRONMENT: "test", + } as any, + cf: {} as any, + ctx: {} as any, + }); + + await expect(storage.initialize()).rejects.toThrow(AppError); + await expect(storage.initialize()).rejects.toMatchObject({ + code: ErrorCode.STORAGE_ERROR, + statusCode: 500, + message: "R2 bucket binding not found", + }); + }); + + it("should handle initialization errors", async () => { + vi.mocked(getCloudflareContext).mockRejectedValue( + new Error("Context error") + ); + + await expect(storage.initialize()).rejects.toThrow(AppError); + await expect(storage.initialize()).rejects.toMatchObject({ + code: ErrorCode.STORAGE_ERROR, + statusCode: 500, + message: "Failed to initialize R2 storage", + }); + }); + }); + + describe("putMetadata", () => { + const mockMetadata: GistMetadata = { + id: "test-id", + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", + version: 1, + current_version: "2024-01-01T00:00:00Z", + total_size: 1000, + blob_count: 1, + encrypted_metadata: { + iv: "test-iv", + data: "test-data", + }, + }; + + it("should store metadata successfully", async () => { + await storage.initialize(); + await storage.putMetadata("test-id", mockMetadata); + + expect(mockBucket.put).toHaveBeenCalledWith( + "metadata/test-id.json", + JSON.stringify(mockMetadata), + { + httpMetadata: { contentType: "application/json" }, + customMetadata: { + type: "metadata", + version: "1", + createdAt: "2024-01-01T00:00:00Z", + }, + } + ); + }); + + it("should throw error if not initialized", async () => { + await expect( + storage.putMetadata("test-id", mockMetadata) + ).rejects.toThrow("R2 storage not initialized"); + }); + + it("should handle put errors", async () => { + await storage.initialize(); + mockBucket.put.mockRejectedValue(new Error("Put failed")); + + await expect( + storage.putMetadata("test-id", mockMetadata) + ).rejects.toThrow(AppError); + await expect( + storage.putMetadata("test-id", mockMetadata) + ).rejects.toMatchObject({ + code: ErrorCode.STORAGE_ERROR, + message: "Failed to store metadata for gist test-id", + }); + }); + }); + + describe("getMetadata", () => { + const mockMetadata: GistMetadata = { + id: "test-id", + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", + version: 1, + current_version: "2024-01-01T00:00:00Z", + total_size: 1000, + blob_count: 1, + encrypted_metadata: { + iv: "test-iv", + data: "test-data", + }, + }; + + it("should retrieve metadata successfully", async () => { + await storage.initialize(); + mockBucket.get.mockResolvedValue({ + text: vi.fn().mockResolvedValue(JSON.stringify(mockMetadata)), + }); + + const result = await storage.getMetadata("test-id"); + expect(result).toEqual(mockMetadata); + expect(mockBucket.get).toHaveBeenCalledWith("metadata/test-id.json"); + }); + + it("should return null if metadata not found", async () => { + await storage.initialize(); + mockBucket.get.mockResolvedValue(null); + + const result = await storage.getMetadata("test-id"); + expect(result).toBeNull(); + }); + + it("should handle invalid JSON", async () => { + await storage.initialize(); + mockBucket.get.mockResolvedValue({ + text: vi.fn().mockResolvedValue("invalid json"), + }); + + await expect(storage.getMetadata("test-id")).rejects.toThrow(AppError); + await expect(storage.getMetadata("test-id")).rejects.toMatchObject({ + code: ErrorCode.STORAGE_ERROR, + message: "Invalid metadata format for gist test-id", + }); + }); + }); + + describe("putBlob", () => { + it("should store blob successfully and return timestamp", async () => { + await storage.initialize(); + const data = new Uint8Array([1, 2, 3, 4]); + const timestamp = await storage.putBlob("test-id", data); + + expect(timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/); + expect(mockBucket.put).toHaveBeenCalledWith( + expect.stringMatching(/^versions\/test-id\/.*\.bin$/), + data, + { + httpMetadata: { contentType: "application/octet-stream" }, + customMetadata: { + type: "version", + size: "4", + timestamp: expect.any(String), + }, + } + ); + }); + + it("should handle put errors", async () => { + await storage.initialize(); + mockBucket.put.mockRejectedValue(new Error("Put failed")); + + await expect( + storage.putBlob("test-id", new Uint8Array()) + ).rejects.toThrow(AppError); + }); + }); + + describe("getBlob", () => { + it("should retrieve blob by timestamp successfully", async () => { + await storage.initialize(); + const mockData = new Uint8Array([1, 2, 3, 4]); + mockBucket.get.mockResolvedValue({ + arrayBuffer: vi.fn().mockResolvedValue(mockData.buffer), + }); + + const timestamp = "2024-01-01T00:00:00Z"; + const result = await storage.getBlob("test-id", timestamp); + expect(result).toEqual(mockData); + expect(mockBucket.get).toHaveBeenCalledWith( + "versions/test-id/2024-01-01T00:00:00Z.bin" + ); + }); + + it("should return null if blob not found", async () => { + await storage.initialize(); + mockBucket.get.mockResolvedValue(null); + + const result = await storage.getBlob("test-id", "2024-01-01T00:00:00Z"); + expect(result).toBeNull(); + }); + }); + + describe("getCurrentBlob", () => { + const mockMetadata: GistMetadata = { + id: "test-id", + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", + version: 1, + current_version: "2024-01-01T00:00:00Z", + total_size: 1000, + blob_count: 1, + encrypted_metadata: { + iv: "test-iv", + data: "test-data", + }, + }; + + it("should retrieve current blob using metadata", async () => { + await storage.initialize(); + const mockData = new Uint8Array([1, 2, 3, 4]); + + // Mock metadata get + mockBucket.get + .mockResolvedValueOnce({ + text: vi.fn().mockResolvedValue(JSON.stringify(mockMetadata)), + }) + // Mock blob get + .mockResolvedValueOnce({ + arrayBuffer: vi.fn().mockResolvedValue(mockData.buffer), + }); + + const result = await storage.getCurrentBlob("test-id"); + expect(result).toEqual(mockData); + }); + + it("should return null if metadata not found", async () => { + await storage.initialize(); + mockBucket.get.mockResolvedValue(null); + + const result = await storage.getCurrentBlob("test-id"); + expect(result).toBeNull(); + }); + }); + + describe("deleteGist", () => { + it("should delete metadata and all versions", async () => { + await storage.initialize(); + mockBucket.list.mockResolvedValue({ + objects: [ + { key: "versions/test-id/2024-01-01T00:00:00Z.bin" }, + { key: "versions/test-id/2024-01-02T00:00:00Z.bin" }, + ], + truncated: false, + }); + + await storage.deleteGist("test-id"); + + expect(mockBucket.delete).toHaveBeenCalledWith("metadata/test-id.json"); + expect(mockBucket.delete).toHaveBeenCalledWith( + "versions/test-id/2024-01-01T00:00:00Z.bin" + ); + expect(mockBucket.delete).toHaveBeenCalledWith( + "versions/test-id/2024-01-02T00:00:00Z.bin" + ); + expect(mockBucket.delete).toHaveBeenCalledTimes(3); + }); + + it("should handle delete errors", async () => { + await storage.initialize(); + mockBucket.list.mockResolvedValue({ objects: [], truncated: false }); + mockBucket.delete.mockRejectedValue(new Error("Delete failed")); + + await expect(storage.deleteGist("test-id")).rejects.toThrow(AppError); + }); + }); + + describe("exists", () => { + it("should return true if gist exists", async () => { + await storage.initialize(); + mockBucket.head.mockResolvedValue({}); + + const result = await storage.exists("test-id"); + expect(result).toBe(true); + expect(mockBucket.head).toHaveBeenCalledWith("metadata/test-id.json"); + }); + + it("should return false if gist does not exist", async () => { + await storage.initialize(); + mockBucket.head.mockResolvedValue(null); + + const result = await storage.exists("test-id"); + expect(result).toBe(false); + }); + + it("should return false on head error", async () => { + await storage.initialize(); + mockBucket.head.mockRejectedValue(new Error("Not found")); + + const result = await storage.exists("test-id"); + expect(result).toBe(false); + }); + }); + + describe("listGists", () => { + const mockMetadata: GistMetadata = { + id: "test-id", + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", + version: 1, + current_version: "2024-01-01T00:00:00Z", + total_size: 1000, + blob_count: 1, + encrypted_metadata: { + iv: "test-iv", + data: "test-data", + }, + }; + + it("should list gists successfully", async () => { + await storage.initialize(); + mockBucket.list.mockResolvedValue({ + objects: [{ key: "metadata/test-id.json" }], + truncated: false, + }); + mockBucket.get.mockResolvedValue({ + text: vi.fn().mockResolvedValue(JSON.stringify(mockMetadata)), + }); + + const result = await storage.listGists(); + expect(result.gists).toHaveLength(1); + expect(result.gists[0]).toEqual({ + id: "test-id", + metadata: mockMetadata, + }); + expect(result.truncated).toBe(false); + }); + + it("should handle pagination", async () => { + await storage.initialize(); + mockBucket.list.mockResolvedValue({ + objects: [], + truncated: true, + cursor: "next-cursor", + }); + + const result = await storage.listGists({ limit: 10 }); + expect(result.cursor).toBe("next-cursor"); + expect(result.truncated).toBe(true); + }); + }); + + describe("getStorageStats", () => { + it("should calculate storage statistics", async () => { + await storage.initialize(); + mockBucket.list.mockResolvedValue({ + objects: [ + { key: "metadata/gist1.json", size: 100 }, + { key: "blobs/gist1", size: 1000 }, + { key: "metadata/gist2.json", size: 150 }, + { key: "blobs/gist2", size: 2000 }, + ], + truncated: false, + }); + + const stats = await storage.getStorageStats(); + expect(stats.totalGists).toBe(2); + expect(stats.totalSize).toBe(3250); + }); + + it("should handle paginated results", async () => { + await storage.initialize(); + mockBucket.list + .mockResolvedValueOnce({ + objects: [{ key: "metadata/gist1.json", size: 100 }], + truncated: true, + cursor: "cursor1", + }) + .mockResolvedValueOnce({ + objects: [{ key: "metadata/gist2.json", size: 200 }], + truncated: false, + }); + + const stats = await storage.getStorageStats(); + expect(stats.totalGists).toBe(2); + expect(stats.totalSize).toBe(300); + }); + }); + + describe("listVersions", () => { + it("should list all versions for a gist", async () => { + await storage.initialize(); + mockBucket.list.mockResolvedValue({ + objects: [ + { key: "versions/test-id/2024-01-02T00:00:00Z.bin", size: 200 }, + { key: "versions/test-id/2024-01-01T00:00:00Z.bin", size: 100 }, + ], + truncated: false, + }); + + const versions = await storage.listVersions("test-id"); + + expect(versions).toHaveLength(2); + expect(versions[0]).toEqual({ + timestamp: "2024-01-02T00:00:00Z", + size: 200, + }); + expect(versions[1]).toEqual({ + timestamp: "2024-01-01T00:00:00Z", + size: 100, + }); + }); + + it("should handle list errors", async () => { + await storage.initialize(); + mockBucket.list.mockRejectedValue(new Error("List failed")); + + await expect(storage.listVersions("test-id")).rejects.toThrow(AppError); + }); + }); + + describe("pruneVersions", () => { + it("should delete old versions beyond limit", async () => { + await storage.initialize(); + mockBucket.list.mockResolvedValue({ + objects: [ + { key: "versions/test-id/2024-01-05T00:00:00Z.bin", size: 100 }, + { key: "versions/test-id/2024-01-04T00:00:00Z.bin", size: 100 }, + { key: "versions/test-id/2024-01-03T00:00:00Z.bin", size: 100 }, + { key: "versions/test-id/2024-01-02T00:00:00Z.bin", size: 100 }, + { key: "versions/test-id/2024-01-01T00:00:00Z.bin", size: 100 }, + ], + truncated: false, + }); + + const deleted = await storage.pruneVersions("test-id", 3); + + expect(deleted).toBe(2); + expect(mockBucket.delete).toHaveBeenCalledWith( + "versions/test-id/2024-01-02T00:00:00Z.bin" + ); + expect(mockBucket.delete).toHaveBeenCalledWith( + "versions/test-id/2024-01-01T00:00:00Z.bin" + ); + }); + + it("should not delete if under limit", async () => { + await storage.initialize(); + mockBucket.list.mockResolvedValue({ + objects: [ + { key: "versions/test-id/2024-01-02T00:00:00Z.bin", size: 100 }, + { key: "versions/test-id/2024-01-01T00:00:00Z.bin", size: 100 }, + ], + truncated: false, + }); + + const deleted = await storage.pruneVersions("test-id", 50); + + expect(deleted).toBe(0); + expect(mockBucket.delete).not.toHaveBeenCalled(); + }); + }); +}); + +describe("StorageKeys", () => { + it("should generate correct keys", () => { + expect(StorageKeys.metadata("test-id")).toBe("metadata/test-id.json"); + expect(StorageKeys.version("test-id", "2024-01-01T00:00:00Z")).toBe( + "versions/test-id/2024-01-01T00:00:00Z.bin" + ); + expect(StorageKeys.temp("test-id")).toBe("temp/test-id"); + }); +}); + +describe("getR2Storage", () => { + beforeEach(() => { + // Reset singleton before test + resetStorageInstance(); + }); + + it("should return singleton instance", async () => { + // Mock bucket for singleton test + const mockBucket = { + put: vi.fn(), + get: vi.fn(), + delete: vi.fn(), + head: vi.fn(), + list: vi.fn(), + }; + + vi.mocked(getCloudflareContext).mockResolvedValue({ + env: { + GHOSTPASTE_BUCKET: mockBucket, + NEXT_PUBLIC_APP_URL: "http://localhost:3000", + ENVIRONMENT: "test", + } as any, + cf: {} as any, + ctx: {} as any, + }); + + const storage1 = await getR2Storage(); + const storage2 = await getR2Storage(); + expect(storage1).toBe(storage2); + }); +}); + +describe("isR2NotFoundError", () => { + it("should identify R2 not found errors", () => { + expect(isR2NotFoundError(new Error("R2ObjectNotFound"))).toBe(true); + expect(isR2NotFoundError(new Error("NoSuchKey"))).toBe(true); + expect(isR2NotFoundError(new Error("Other error"))).toBe(false); + expect(isR2NotFoundError("not an error")).toBe(false); + }); +}); diff --git a/lib/storage.ts b/lib/storage.ts new file mode 100644 index 0000000..a10c6bd --- /dev/null +++ b/lib/storage.ts @@ -0,0 +1,410 @@ +import { getCloudflareContext } from "@opennextjs/cloudflare"; +import { AppError, ErrorCode } from "@/types/errors"; +import type { GistMetadata } from "@/types/models"; + +/** + * R2 storage key structure + */ +export const StorageKeys = { + metadata: (id: string) => `metadata/${id}.json`, + version: (id: string, timestamp: string) => `versions/${id}/${timestamp}.bin`, + temp: (id: string) => `temp/${id}`, +} as const; + +/** + * R2 storage client wrapper + * Provides type-safe operations for storing and retrieving encrypted gists + */ +export class R2Storage { + private bucket: R2Bucket | null = null; + + /** + * Initialize the R2 bucket connection + * Must be called before any operations + */ + async initialize(): Promise { + try { + const { env } = await getCloudflareContext({ async: true }); + this.bucket = env.GHOSTPASTE_BUCKET; + + if (!this.bucket) { + throw new AppError( + ErrorCode.STORAGE_ERROR, + 500, + "R2 bucket binding not found", + { binding: "GHOSTPASTE_BUCKET" } + ); + } + } catch (error) { + if (error instanceof AppError) throw error; + throw new AppError( + ErrorCode.STORAGE_ERROR, + 500, + "Failed to initialize R2 storage", + { error: error instanceof Error ? error.message : "Unknown error" } + ); + } + } + + /** + * Ensure bucket is initialized + */ + private ensureBucket(): R2Bucket { + if (!this.bucket) { + throw new AppError( + ErrorCode.STORAGE_ERROR, + 500, + "R2 storage not initialized. Call initialize() first." + ); + } + return this.bucket; + } + + /** + * Store gist metadata + */ + async putMetadata(id: string, metadata: GistMetadata): Promise { + const bucket = this.ensureBucket(); + const key = StorageKeys.metadata(id); + + try { + await bucket.put(key, JSON.stringify(metadata), { + httpMetadata: { + contentType: "application/json", + }, + customMetadata: { + type: "metadata", + version: metadata.version.toString(), + createdAt: metadata.created_at, + }, + }); + } catch (error) { + throw new AppError( + ErrorCode.STORAGE_ERROR, + 500, + `Failed to store metadata for gist ${id}`, + { error: error instanceof Error ? error.message : "Unknown error" } + ); + } + } + + /** + * Retrieve gist metadata + */ + async getMetadata(id: string): Promise { + const bucket = this.ensureBucket(); + const key = StorageKeys.metadata(id); + + try { + const object = await bucket.get(key); + if (!object) return null; + + const text = await object.text(); + return JSON.parse(text) as GistMetadata; + } catch (error) { + if (error instanceof SyntaxError) { + throw new AppError( + ErrorCode.STORAGE_ERROR, + 500, + `Invalid metadata format for gist ${id}`, + { error: error.message } + ); + } + throw new AppError( + ErrorCode.STORAGE_ERROR, + 500, + `Failed to retrieve metadata for gist ${id}`, + { error: error instanceof Error ? error.message : "Unknown error" } + ); + } + } + + /** + * Store encrypted blob as a new version + * Returns the timestamp used for this version + */ + async putBlob(id: string, data: Uint8Array): Promise { + const bucket = this.ensureBucket(); + const timestamp = new Date().toISOString(); + const key = StorageKeys.version(id, timestamp); + + try { + await bucket.put(key, data, { + httpMetadata: { + contentType: "application/octet-stream", + }, + customMetadata: { + type: "version", + size: data.length.toString(), + timestamp, + }, + }); + return timestamp; + } catch (error) { + throw new AppError( + ErrorCode.STORAGE_ERROR, + 500, + `Failed to store blob version for gist ${id}`, + { error: error instanceof Error ? error.message : "Unknown error" } + ); + } + } + + /** + * Retrieve encrypted blob by version timestamp + */ + async getBlob(id: string, timestamp: string): Promise { + const bucket = this.ensureBucket(); + const key = StorageKeys.version(id, timestamp); + + try { + const object = await bucket.get(key); + if (!object) return null; + + const arrayBuffer = await object.arrayBuffer(); + return new Uint8Array(arrayBuffer); + } catch (error) { + throw new AppError( + ErrorCode.STORAGE_ERROR, + 500, + `Failed to retrieve blob version ${timestamp} for gist ${id}`, + { error: error instanceof Error ? error.message : "Unknown error" } + ); + } + } + + /** + * Get the current blob for a gist by reading metadata + */ + async getCurrentBlob(id: string): Promise { + const metadata = await this.getMetadata(id); + if (!metadata || !metadata.current_version) return null; + + return this.getBlob(id, metadata.current_version); + } + + /** + * Delete gist (metadata and all versions) + */ + async deleteGist(id: string): Promise { + const bucket = this.ensureBucket(); + const metadataKey = StorageKeys.metadata(id); + + try { + // Delete metadata + await bucket.delete(metadataKey); + + // List and delete all versions + const versionsPrefix = `versions/${id}/`; + const versions = await bucket.list({ prefix: versionsPrefix }); + + if (versions.objects.length > 0) { + await Promise.all( + versions.objects.map((obj) => bucket.delete(obj.key)) + ); + } + } catch (error) { + throw new AppError( + ErrorCode.STORAGE_ERROR, + 500, + `Failed to delete gist ${id}`, + { error: error instanceof Error ? error.message : "Unknown error" } + ); + } + } + + /** + * Check if gist exists + */ + async exists(id: string): Promise { + const bucket = this.ensureBucket(); + const key = StorageKeys.metadata(id); + + try { + const object = await bucket.head(key); + return object !== null; + } catch { + // R2 throws an error for non-existent objects in head() + return false; + } + } + + /** + * List gists (with pagination) + */ + async listGists(options?: { limit?: number; cursor?: string }): Promise<{ + gists: Array<{ id: string; metadata: GistMetadata }>; + cursor?: string; + truncated: boolean; + }> { + const bucket = this.ensureBucket(); + const limit = options?.limit ?? 100; + + try { + const result = await bucket.list({ + prefix: "metadata/", + limit, + cursor: options?.cursor, + }); + + const gists = await Promise.all( + result.objects.map(async (obj) => { + const id = obj.key.replace("metadata/", "").replace(".json", ""); + const metadata = await this.getMetadata(id); + if (!metadata) { + throw new Error(`Metadata not found for ${id}`); + } + return { id, metadata }; + }) + ); + + return { + gists, + cursor: result.truncated ? result.cursor : undefined, + truncated: result.truncated, + }; + } catch (error) { + throw new AppError(ErrorCode.STORAGE_ERROR, 500, "Failed to list gists", { + error: error instanceof Error ? error.message : "Unknown error", + }); + } + } + + /** + * List all versions for a gist + */ + async listVersions(id: string): Promise< + Array<{ + timestamp: string; + size: number; + }> + > { + const bucket = this.ensureBucket(); + const prefix = `versions/${id}/`; + + try { + const result = await bucket.list({ prefix, limit: 1000 }); + + return result.objects + .map((obj) => ({ + timestamp: obj.key.replace(prefix, "").replace(".bin", ""), + size: obj.size, + })) + .sort((a, b) => b.timestamp.localeCompare(a.timestamp)); // Newest first + } catch (error) { + throw new AppError( + ErrorCode.STORAGE_ERROR, + 500, + `Failed to list versions for gist ${id}`, + { error: error instanceof Error ? error.message : "Unknown error" } + ); + } + } + + /** + * Delete old versions beyond the limit (default: 50) + */ + async pruneVersions(id: string, keepCount: number = 50): Promise { + const versions = await this.listVersions(id); + + if (versions.length <= keepCount) { + return 0; + } + + const bucket = this.ensureBucket(); + const toDelete = versions.slice(keepCount); + + try { + await Promise.all( + toDelete.map((v) => bucket.delete(StorageKeys.version(id, v.timestamp))) + ); + + return toDelete.length; + } catch (error) { + throw new AppError( + ErrorCode.STORAGE_ERROR, + 500, + `Failed to prune versions for gist ${id}`, + { error: error instanceof Error ? error.message : "Unknown error" } + ); + } + } + + /** + * Get storage usage statistics + */ + async getStorageStats(): Promise<{ + totalGists: number; + totalSize: number; + }> { + const bucket = this.ensureBucket(); + + try { + let totalGists = 0; + let totalSize = 0; + let cursor: string | undefined; + + // Count all objects + do { + const result = await bucket.list({ + limit: 1000, + cursor, + }); + + for (const obj of result.objects) { + if (obj.key.startsWith("metadata/")) { + totalGists++; + } + totalSize += obj.size; + } + + cursor = result.truncated ? result.cursor : undefined; + } while (cursor); + + return { totalGists, totalSize }; + } catch (error) { + throw new AppError( + ErrorCode.STORAGE_ERROR, + 500, + "Failed to get storage statistics", + { error: error instanceof Error ? error.message : "Unknown error" } + ); + } + } +} + +/** + * Singleton instance + */ +let storageInstance: R2Storage | null = null; + +/** + * Get or create R2 storage instance + */ +export async function getR2Storage(): Promise { + if (!storageInstance) { + storageInstance = new R2Storage(); + await storageInstance.initialize(); + } + return storageInstance; +} + +/** + * Reset storage instance (for testing) + */ +export function resetStorageInstance(): void { + storageInstance = null; +} + +/** + * Handle R2-specific errors + */ +export function isR2NotFoundError(error: unknown): boolean { + // R2 typically returns null for non-existent objects rather than throwing + // but some operations might throw specific errors + return ( + error instanceof Error && + (error.message.includes("R2ObjectNotFound") || + error.message.includes("NoSuchKey")) + ); +} diff --git a/scripts/create-phase-4-issues.sh b/scripts/create-phase-4-issues.sh deleted file mode 100755 index cabbc72..0000000 --- a/scripts/create-phase-4-issues.sh +++ /dev/null @@ -1,541 +0,0 @@ -#!/bin/bash - -# Script to create all Phase 4 GitHub issues for GhostPaste -# Requires GitHub CLI (gh) to be installed and authenticated - -echo "Creating Phase 4: UI Components issues..." - -# Function to create an issue -create_issue() { - local title=$1 - local body=$2 - local labels=$3 - local issue_number=$4 - - echo "Creating issue #$issue_number: $title" - - gh issue create \ - --title "$title" \ - --body "$body" \ - --label "$labels" \ - --project "👻 GhostPaste" \ - --milestone "Phase 4: UI Components" -} - -# Layout Components -create_issue \ - "feat: create header component with navigation" \ - "Create the main header component for GhostPaste with navigation and branding. - -## Requirements -- Logo/brand on the left -- Navigation items (Create, About, GitHub) -- Theme toggle button on the right -- Mobile-responsive hamburger menu -- Sticky header on scroll -- Use shadcn/ui navigation components - -## Acceptance Criteria -- [ ] Header displays logo and navigation -- [ ] Theme toggle works -- [ ] Mobile menu functions correctly -- [ ] Keyboard navigation support -- [ ] ARIA labels for accessibility - -## Technical Notes -- Use Next.js Link for navigation -- Implement with shadcn/ui NavigationMenu -- Support both light and dark themes -- Test on mobile devices" \ - "ui,component,priority: high" \ - 42 - -create_issue \ - "feat: create footer component" \ - "Create a simple footer component with links and copyright. - -## Requirements -- Copyright notice -- Links to GitHub, Privacy, Terms -- Social links (optional) -- Responsive layout - -## Acceptance Criteria -- [ ] Footer displays all required information -- [ ] Links are accessible -- [ ] Responsive on all screen sizes - -## Technical Notes -- Keep it simple and clean -- Ensure sufficient contrast in both themes -- Use semantic HTML" \ - "ui,component,priority: low" \ - 43 - -create_issue \ - "feat: create container component for consistent spacing" \ - "Create a reusable container component for consistent page margins and max-width. - -## Requirements -- Max-width constraint (1280px) -- Responsive padding -- Center alignment -- Support for different variants (narrow, wide, full) - -## Acceptance Criteria -- [ ] Container constrains content width -- [ ] Responsive padding works -- [ ] Different variants available - -## Technical Notes -- Use Tailwind's container as base -- Add custom padding scale -- Support prose variant for content" \ - "ui,component,priority: medium" \ - 44 - -create_issue \ - "feat: implement design system tokens" \ - "Set up design tokens for consistent spacing, typography, and breakpoints. - -## Requirements -- Define spacing scale (4, 8, 16, 24, 32, 48, 64) -- Typography scale (text-xs through text-6xl) -- Breakpoints (sm: 640px, md: 768px, lg: 1024px, xl: 1280px) -- Color tokens for themes -- Add to Tailwind config - -## Acceptance Criteria -- [ ] Design tokens documented -- [ ] Tailwind config updated -- [ ] Usage examples provided - -## Technical Notes -- Extend Tailwind config -- Document in README -- Create TypeScript constants" \ - "ui,design-system,priority: high" \ - 45 - -# Editor Components -create_issue \ - "feat: create CodeMirror editor wrapper component" \ - "Create a React wrapper for CodeMirror 6 with all required features. - -## Requirements -- Support for all installed language modes -- Theme switching (light/dark) -- Line numbers toggle -- Word wrap toggle -- Syntax highlighting -- Placeholder text -- Read-only mode -- Custom styling to match design system -- Performance optimization for large files - -## Technical Details -- Use CodeMirror 6 with React -- Implement proper cleanup on unmount -- Handle theme changes dynamically -- Support controlled and uncontrolled modes - -## Acceptance Criteria -- [ ] Editor renders and accepts input -- [ ] Syntax highlighting works -- [ ] Theme switching works -- [ ] All language modes supported -- [ ] Performance acceptable for 500KB files -- [ ] Proper TypeScript types - -## Performance Requirements -- Initial render <50ms -- Theme switch <16ms -- Handle 500KB files smoothly" \ - "ui,component,priority: critical" \ - 46 - -create_issue \ - "feat: create single file editor component" \ - "Create the FileEditor component that combines filename input, language selector, and code editor. - -## Requirements -- Filename input with validation -- Language dropdown (auto-detect from extension) -- Remove button (conditional display) -- Integration with CodeEditor component -- Error states for invalid filenames -- Responsive layout - -## Acceptance Criteria -- [ ] Component renders all sub-components -- [ ] Filename validation works -- [ ] Language auto-detection works -- [ ] Remove button conditional logic works -- [ ] Error states display correctly -- [ ] Keyboard navigation between fields - -## Technical Notes -- Use composition with CodeEditor -- Implement filename validation -- Auto-detect language from extension -- Handle error states gracefully" \ - "ui,component,priority: critical" \ - 47 - -create_issue \ - "feat: create multi-file editor container" \ - "Create the container component that manages multiple FileEditor instances. - -## Requirements -- State management for file array -- Add/remove file functionality -- Filename duplicate prevention -- Auto-generate filenames (file1.txt, file2.txt) -- Enforce min 1, max 20 files -- Calculate total size -- Auto-scroll to new files -- Performance optimization - -## Acceptance Criteria -- [ ] Can add up to 20 files -- [ ] Can remove files (min 1) -- [ ] Duplicate names prevented -- [ ] Auto-scroll works -- [ ] State updates efficiently -- [ ] Total size calculated - -## Technical Notes -- Consider useReducer for state -- Implement proper memoization -- Handle array updates immutably -- Profile performance with many files" \ - "ui,component,priority: critical" \ - 48 - -create_issue \ - "feat: create add file button component" \ - "Create the button component for adding new files. - -## Requirements -- Plus icon with text -- Disabled state at 20 files -- Loading state while adding -- Tooltip for disabled state -- Consistent with design system - -## Acceptance Criteria -- [ ] Button renders correctly -- [ ] Disabled at file limit -- [ ] Tooltip shows reason -- [ ] Click handler works - -## Technical Notes -- Use shadcn/ui Button -- Add proper loading state -- Implement tooltip component" \ - "ui,component,priority: medium" \ - 49 - -# Form Components -create_issue \ - "feat: create expiry time selector component" \ - "Create a component for selecting gist expiration time. - -## Requirements -- Predefined options (1 hour, 1 day, 1 week, 30 days, Never) -- Custom time input (optional) -- Clear visual indication of selection -- Accessible dropdown/radio group -- Show human-readable format - -## Acceptance Criteria -- [ ] All time options available -- [ ] Selection updates parent state -- [ ] Accessible with keyboard -- [ ] Clear visual feedback - -## Technical Notes -- Use shadcn/ui Select or RadioGroup -- Format times clearly -- Handle timezone considerations" \ - "ui,component,priority: medium" \ - 50 - -create_issue \ - "feat: create PIN input component" \ - "Create a secure PIN input component for edit protection. - -## Requirements -- Masked input by default -- Show/hide toggle -- Strength indicator -- Validation messages -- Character counter -- Confirm PIN field -- Accessible labels - -## Acceptance Criteria -- [ ] PIN input masks characters -- [ ] Show/hide toggle works -- [ ] Validation displays errors -- [ ] Strength indicator accurate -- [ ] Confirm field matches - -## Technical Notes -- Use controlled input -- Implement strength algorithm -- Show helpful error messages -- Consider using OTP input pattern" \ - "ui,component,priority: medium" \ - 51 - -create_issue \ - "feat: create share dialog with copy functionality" \ - "Create a dialog that displays the shareable URL after gist creation. - -## Requirements -- Modal/dialog overlay -- Display full URL -- Copy button with feedback -- QR code generation (optional) -- Social share buttons (optional) -- Close on escape/click outside -- Success animation - -## Acceptance Criteria -- [ ] Dialog displays URL -- [ ] Copy button works -- [ ] Visual feedback on copy -- [ ] Keyboard accessible -- [ ] Mobile-friendly layout - -## Technical Notes -- Use shadcn/ui Dialog -- Implement copy with fallback -- Add success animation -- Test on mobile devices" \ - "ui,component,priority: high" \ - 52 - -# Display Components -create_issue \ - "feat: create gist viewer component" \ - "Create the read-only gist viewer component. - -## Requirements -- Display multiple files -- Syntax highlighting -- File tabs or list -- Copy button per file -- Download button -- Print-friendly view -- Loading states -- Error states - -## Acceptance Criteria -- [ ] Displays all files -- [ ] Syntax highlighting works -- [ ] Copy/download functional -- [ ] Responsive layout -- [ ] Loading states smooth - -## Technical Notes -- Reuse CodeEditor in read-only mode -- Implement file navigation -- Add print styles -- Handle loading states" \ - "ui,component,priority: high" \ - 53 - -create_issue \ - "feat: create file list/tab component" \ - "Create a component to display and navigate between files in view mode. - -## Requirements -- List view for many files -- Tab view for few files -- File icons by type -- Active state indication -- Keyboard navigation -- Mobile-friendly - -## Acceptance Criteria -- [ ] Shows all files -- [ ] Navigation works -- [ ] Active state clear -- [ ] Responsive design -- [ ] Keyboard accessible - -## Technical Notes -- Auto-switch between tabs/list -- Use appropriate icons -- Implement keyboard navigation -- Test with many files" \ - "ui,component,priority: medium" \ - 54 - -create_issue \ - "feat: create version history dropdown" \ - "Create a dropdown to select and view different versions. - -## Requirements -- Dropdown with version list -- Show timestamp and number -- Load version on selection -- Visual diff indicator (optional) -- Current version highlighted - -## Acceptance Criteria -- [ ] Lists all versions -- [ ] Selection loads version -- [ ] Current version marked -- [ ] Timestamps readable - -## Technical Notes -- Use shadcn/ui Select -- Format timestamps nicely -- Handle loading states -- Consider diff visualization" \ - "ui,component,priority: low" \ - 55 - -create_issue \ - "feat: create loading state components" \ - "Create consistent loading states for the application. - -## Requirements -- Skeleton screens for editor -- Spinner for actions -- Progress bar for uploads -- Shimmer effects -- Accessible loading announcements - -## Acceptance Criteria -- [ ] Multiple loading states -- [ ] Smooth animations -- [ ] Accessible to screen readers -- [ ] Consistent design - -## Technical Notes -- Create reusable components -- Use CSS animations -- Add ARIA live regions -- Test performance" \ - "ui,component,priority: medium" \ - 56 - -create_issue \ - "feat: create error boundary component" \ - "Create an error boundary to catch and display errors gracefully. - -## Requirements -- Catch React errors -- User-friendly error message -- Retry button -- Report issue link -- Dev mode stack trace -- Log errors properly - -## Acceptance Criteria -- [ ] Catches errors -- [ ] Shows friendly message -- [ ] Retry functionality -- [ ] Logs errors -- [ ] Dev mode details - -## Technical Notes -- Implement React Error Boundary -- Add error logging -- Show stack trace in dev -- Provide helpful actions" \ - "ui,component,priority: high" \ - 57 - -# UI Features -create_issue \ - "feat: add toast notification system" \ - "Implement a toast notification system for user feedback. - -## Requirements -- Success, error, warning, info variants -- Auto-dismiss with timer -- Manual dismiss button -- Queue multiple toasts -- Position configuration -- Animation in/out -- Use shadcn/ui toast - -## Acceptance Criteria -- [ ] All variants work -- [ ] Auto-dismiss works -- [ ] Queue system works -- [ ] Accessible announcements -- [ ] Smooth animations - -## Technical Notes -- Use shadcn/ui Toast -- Implement toast provider -- Add queue management -- Test accessibility" \ - "ui,feature,priority: medium" \ - 58 - -create_issue \ - "feat: implement keyboard shortcuts" \ - "Add keyboard shortcuts for common actions. - -## Requirements -- Cmd/Ctrl+S to save -- Cmd/Ctrl+Enter to create/update -- Cmd/Ctrl+K for command palette (future) -- Escape to close dialogs -- Tab navigation -- Help dialog with shortcut list - -## Acceptance Criteria -- [ ] Shortcuts work correctly -- [ ] Don't conflict with browser -- [ ] Help dialog available -- [ ] Works across platforms - -## Technical Notes -- Use key event handlers -- Check platform differences -- Document shortcuts -- Add help dialog" \ - "ui,feature,priority: low" \ - 59 - -create_issue \ - "feat: add copy to clipboard functionality" \ - "Implement copy functionality throughout the app. - -## Requirements -- Copy share URL -- Copy individual files -- Copy all files -- Visual feedback on copy -- Fallback for older browsers -- Keyboard shortcut support - -## Acceptance Criteria -- [ ] Copy works everywhere needed -- [ ] Visual feedback clear -- [ ] Fallback works -- [ ] Accessible to keyboard - -## Technical Notes -- Use Clipboard API -- Implement fallback -- Add success feedback -- Test browser support" \ - "ui,feature,priority: high" \ - 60 - -echo "Phase 4 issues created successfully!" -echo "Total issues: 19" -echo "Next steps:" -echo "1. Review issues on GitHub" -echo "2. Add to project board" -echo "3. Assign to developers" -echo "4. Begin implementation with critical path items" \ No newline at end of file diff --git a/types/models.ts b/types/models.ts index 546586a..eea0426 100644 --- a/types/models.ts +++ b/types/models.ts @@ -12,7 +12,8 @@ export interface GistMetadata { created_at: string; // ISO 8601 updated_at: string; // ISO 8601 expires_at?: string; // ISO 8601, optional expiry - version: 1; // Format version for future compatibility + version: number; // Version number, incremented on updates + current_version: string; // Timestamp of current blob version // Size information (unencrypted) total_size: number; // Total size in bytes