diff --git a/app/api/snippets/[id]/share/route.ts b/app/api/snippets/[id]/share/route.ts new file mode 100644 index 0000000..8658d3d --- /dev/null +++ b/app/api/snippets/[id]/share/route.ts @@ -0,0 +1,115 @@ +import { NextRequest, NextResponse } from "next/server"; +import { ShareService } from "../../share.service"; +import { ShareRepository } from "../../share.repository"; +import { SnippetRepository } from "../../snippet.repository"; +import { OwnershipMiddleware } from "../../ownership.middleware"; +import { z } from "zod"; + +const createShareSchema = z.object({ + isReadOnly: z.boolean().optional(), + expiresAt: z.string().optional().transform((val) => (val ? new Date(val) : null)), +}); + +const snippetRepository = new SnippetRepository(); +const shareRepository = new ShareRepository(); +const shareService = new ShareService(shareRepository, snippetRepository); + +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const { id } = await params; + const walletAddress = OwnershipMiddleware.extractWalletAddress(req); + + if (!walletAddress) { + return NextResponse.json( + { error: "Unauthorized", message: "Wallet address is required." }, + { status: 401 }, + ); + } + + const ownershipResult = await new OwnershipMiddleware().verifyOwnership( + id, + walletAddress, + ); + if (!ownershipResult.isOwner) { + return NextResponse.json( + { error: "Forbidden", message: "Only the snippet owner can share snippets." }, + { status: 403 }, + ); + } + + const body = await req.json(); + const parsed = createShareSchema.safeParse(body); + + if (!parsed.success) { + return NextResponse.json( + { error: "Validation failed", details: parsed.error.errors }, + { status: 400 }, + ); + } + + const { isReadOnly, expiresAt } = parsed.data; + + const result = await shareService.createShareLink({ + snippetId: id, + isReadOnly: isReadOnly ?? true, + expiresAt, + createdByWalletAddress: walletAddress, + }); + + return NextResponse.json(result, { status: 201 }); + } catch (error) { + console.error("[Share] POST error:", error); + return NextResponse.json( + { error: error instanceof Error ? error.message : "Failed to create share link" }, + { status: 500 }, + ); + } +} + +export async function DELETE( + req: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const { id } = await params; + const walletAddress = OwnershipMiddleware.extractWalletAddress(req); + + if (!walletAddress) { + return NextResponse.json( + { error: "Unauthorized", message: "Wallet address is required." }, + { status: 401 }, + ); + } + + const ownershipResult = await new OwnershipMiddleware().verifyOwnership( + id, + walletAddress, + ); + if (!ownershipResult.isOwner) { + return NextResponse.json( + { error: "Forbidden", message: "Only the snippet owner can revoke share links." }, + { status: 403 }, + ); + } + + const revoked = await shareService.revokeShare(id, walletAddress); + + if (!revoked) { + return NextResponse.json( + { error: "No active share link found for this snippet." }, + { status: 404 }, + ); + } + + return NextResponse.json({ message: "Share link revoked successfully" }); + } catch (error) { + console.error("[Share] DELETE error:", error); + return NextResponse.json( + { error: error instanceof Error ? error.message : "Failed to revoke share link" }, + { status: 500 }, + ); + } +} \ No newline at end of file diff --git a/app/api/snippets/share.repository.ts b/app/api/snippets/share.repository.ts new file mode 100644 index 0000000..5cb6570 --- /dev/null +++ b/app/api/snippets/share.repository.ts @@ -0,0 +1,107 @@ +import { neon } from "@neondatabase/serverless"; +import crypto from "crypto"; + +const sql = neon(process.env.DATABASE_URL!); + +export interface SnippetShare { + id: string; + snippet_id: string; + share_token: string; + is_read_only: boolean; + expires_at: string | null; + created_by_wallet_address: string; + revoked_at: string | null; + revoked_by_wallet_address: string | null; + created_at: string; +} + +export interface CreateShareDTO { + snippetId: string; + isReadOnly?: boolean; + expiresAt?: Date | null; + createdByWalletAddress: string; +} + +export class ShareRepository { + generateSecureToken(): string { + return crypto.randomBytes(32).toString("hex"); + } + + async createShare(data: CreateShareDTO): Promise { + const shareToken = this.generateSecureToken(); + const now = new Date(); + const expiresAt = data.expiresAt ?? null; + + const result = await sql` + INSERT INTO snippet_shares (snippet_id, share_token, is_read_only, expires_at, created_by_wallet_address, created_at) + VALUES (${data.snippetId}, ${shareToken}, ${data.isReadOnly ?? true}, ${expiresAt}, ${data.createdByWalletAddress}, ${now}) + RETURNING * + `; + + return result[0] as SnippetShare; + } + + async findByToken(shareToken: string): Promise { + const result = await sql` + SELECT * FROM snippet_shares + WHERE share_token = ${shareToken} + AND revoked_at IS NULL + AND (expires_at IS NULL OR expires_at > NOW()) + `; + return result[0] as SnippetShare | null; + } + + async findActiveShareBySnippet(snippetId: string): Promise { + const result = await sql` + SELECT * FROM snippet_shares + WHERE snippet_id = ${snippetId} + AND revoked_at IS NULL + AND (expires_at IS NULL OR expires_at > NOW()) + `; + return result[0] as SnippetShare | null; + } + + async revokeShare( + snippetId: string, + revokedByWalletAddress: string, + ): Promise { + const now = new Date(); + + const result = await sql` + UPDATE snippet_shares + SET revoked_at = ${now}, revoked_by_wallet_address = ${revokedByWalletAddress} + WHERE snippet_id = ${snippetId} + AND revoked_at IS NULL + RETURNING id + `; + + return result.length > 0; + } + + async revokeByToken( + shareToken: string, + revokedByWalletAddress: string, + ): Promise { + const now = new Date(); + + const result = await sql` + UPDATE snippet_shares + SET revoked_at = ${now}, revoked_by_wallet_address = ${revokedByWalletAddress} + WHERE share_token = ${shareToken} + AND revoked_at IS NULL + RETURNING id + `; + + return result.length > 0; + } + + async getShareDetails(snippetId: string): Promise { + const result = await sql` + SELECT * FROM snippet_shares + WHERE snippet_id = ${snippetId} + ORDER BY created_at DESC + LIMIT 1 + `; + return result[0] as SnippetShare | null; + } +} \ No newline at end of file diff --git a/app/api/snippets/share.service.ts b/app/api/snippets/share.service.ts new file mode 100644 index 0000000..8946734 --- /dev/null +++ b/app/api/snippets/share.service.ts @@ -0,0 +1,108 @@ +import { ShareRepository, CreateShareDTO } from "./share.repository"; +import { SnippetRepository } from "./snippet.repository"; +import { ActivityLogger } from "@/lib/activity-logger"; + +export class ShareService { + constructor( + private shareRepository: ShareRepository, + private snippetRepository: SnippetRepository, + ) {} + + async createShareLink(data: CreateShareDTO): Promise<{ + shareToken: string; + shareUrl: string; + isReadOnly: boolean; + expiresAt: string | null; + }> { + const snippet = await this.snippetRepository.findById(data.snippetId); + if (!snippet) { + throw new Error("Snippet not found"); + } + + const existingShare = await this.shareRepository.findActiveShareBySnippet( + data.snippetId, + ); + if (existingShare) { + return { + shareToken: existingShare.share_token, + shareUrl: this.buildShareUrl(existingShare.share_token), + isReadOnly: existingShare.is_read_only, + expiresAt: existingShare.expires_at, + }; + } + + const share = await this.shareRepository.createShare(data); + + await ActivityLogger.log( + data.snippetId, + "SHARE", + data.createdByWalletAddress, + { + shareToken: share.share_token, + isReadOnly: share.is_read_only, + expiresAt: share.expires_at, + }, + ); + + return { + shareToken: share.share_token, + shareUrl: this.buildShareUrl(share.share_token), + isReadOnly: share.is_read_only, + expiresAt: share.expires_at, + }; + } + + async getSharedSnippet( + shareToken: string, + ): Promise<{ snippet: any; isReadOnly: boolean } | null> { + const share = await this.shareRepository.findByToken(shareToken); + if (!share) { + return null; + } + + const snippet = await this.snippetRepository.findById(share.snippet_id); + if (!snippet) { + return null; + } + + return { + snippet, + isReadOnly: share.is_read_only, + }; + } + + async revokeShare( + snippetId: string, + revokedByWalletAddress: string, + ): Promise { + const existingShare = await this.shareRepository.findActiveShareBySnippet( + snippetId, + ); + if (!existingShare) { + throw new Error("No active share link found for this snippet"); + } + + const revoked = await this.shareRepository.revokeShare( + snippetId, + revokedByWalletAddress, + ); + + if (revoked) { + await ActivityLogger.log( + snippetId, + "REVOKESHARE", + revokedByWalletAddress, + { + shareToken: existingShare.share_token, + }, + ); + } + + return revoked; + } + + private buildShareUrl(shareToken: string): string { + const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || "http://localhost:3000"; + return `${baseUrl}/api/snippets/shared/${shareToken}`; + } +} \ No newline at end of file diff --git a/app/api/snippets/shared/[token]/route.ts b/app/api/snippets/shared/[token]/route.ts new file mode 100644 index 0000000..2f097dd --- /dev/null +++ b/app/api/snippets/shared/[token]/route.ts @@ -0,0 +1,45 @@ +import { NextRequest, NextResponse } from "next/server"; +import { ShareRepository } from "../share.repository"; +import { SnippetRepository } from "../snippet.repository"; +import { ShareService } from "../share.service"; + +const repository = new SnippetRepository(); +const shareRepository = new ShareRepository(); +const shareService = new ShareService(shareRepository, repository); + +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ token: string }> }, +) { + try { + const { token } = await params; + + if (!token) { + return NextResponse.json( + { error: "Share token is required" }, + { status: 400 }, + ); + } + + const result = await shareService.getSharedSnippet(token); + + if (!result) { + return NextResponse.json( + { error: "Invalid or expired share link" }, + { status: 404 }, + ); + } + + return NextResponse.json({ + snippet: result.snippet, + isReadOnly: result.isReadOnly, + shareUrl: `${process.env.NEXT_PUBLIC_BASE_URL || "http://localhost:3000"}/api/snippets/shared/${token}`, + }); + } catch (error) { + console.error("[Shared] GET error:", error); + return NextResponse.json( + { error: "Failed to fetch shared snippet" }, + { status: 500 }, + ); + } +} \ No newline at end of file diff --git a/lib/activity-logger.ts b/lib/activity-logger.ts index ef8fd2e..d8a0dcd 100644 --- a/lib/activity-logger.ts +++ b/lib/activity-logger.ts @@ -3,15 +3,55 @@ import { neon } from "@neondatabase/serverless"; // Initialise the Neon DB client const sql = neon(process.env.DATABASE_URL!); -/** Extract the client IP address from request headers. - * Supports the standard `x-forwarded-for` header (used when behind a reverse proxy) - * and the `x-real-ip` fallback. Returns `null` if no IP information is present. - */ -export function extractIp(headers: Headers): string | null { - const forwarded = headers.get("x-forwarded-for"); - if (forwarded) { - // The header may contain a comma‑separated list; the first entry is the client IP. - return forwarded.split(",")[0].trim(); +export type ActivityAction = "DELETE" | "RESTORE" | "CREATE" | "UPDATE" | "SHARE" | "REVOKESHARE"; + +export interface ActivityLogEntry { + id: string; + snippetId: string; + action: ActivityAction; + userWalletAddress: string | null; + details: Record; + createdAt: Date; +} + +export class ActivityLogger { + /** + * Log an activity action for audit trail + */ + static async log( + snippetId: string, + action: ActivityAction, + userWalletAddress: string | null = null, + details: Record = {}, + ): Promise { + try { + const id = crypto.randomUUID(); + const createdAt = new Date(); + + const result = await sql` + INSERT INTO activity_logs (id, snippet_id, action, user_wallet_address, details, created_at) + VALUES (${id}, ${snippetId}, ${action}, ${userWalletAddress}, ${JSON.stringify(details)}, ${createdAt}) + RETURNING * + `; + + console.log(`[ActivityLog] ${action} logged for snippet ${snippetId}`, { + id, + userWalletAddress, + details, + }); + + return { + id: result[0].id, + snippetId: result[0].snippet_id, + action: result[0].action, + userWalletAddress: result[0].user_wallet_address, + details: result[0].details, + createdAt: result[0].created_at, + }; + } catch (error) { + console.error("[ActivityLog] Error logging activity:", error); + throw error; + } } const realIp = headers.get("x-real-ip"); return realIp ?? null; diff --git a/lib/share.service.test.ts b/lib/share.service.test.ts new file mode 100644 index 0000000..2367a58 --- /dev/null +++ b/lib/share.service.test.ts @@ -0,0 +1,181 @@ +import { ShareService } from "../app/api/snippets/share.service"; +import { ShareRepository, CreateShareDTO } from "../app/api/snippets/share.repository"; +import { SnippetRepository } from "../app/api/snippets/snippet.repository"; + +const mockShareRepository = { + generateSecureToken: jest.fn(() => "test-token-123"), + createShare: jest.fn(), + findByToken: jest.fn(), + findActiveShareBySnippet: jest.fn(), + revokeShare: jest.fn(), + revokeByToken: jest.fn(), + getShareDetails: jest.fn(), +} as unknown as ShareRepository; + +const mockSnippetRepository = { + findAll: jest.fn(), + search: jest.fn(), + findById: jest.fn(), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + softDelete: jest.fn(), + restore: jest.fn(), + findDeletedByUser: jest.fn(), + findAllDeleted: jest.fn(), + permanentlyDelete: jest.fn(), +} as unknown as SnippetRepository; + +let consoleSpy: jest.SpyInstance; +beforeAll(() => { + consoleSpy = jest.spyOn(console, "error").mockImplementation(() => {}); +}); +afterAll(() => { + consoleSpy.mockRestore(); +}); + +describe("ShareService", () => { + let service: ShareService; + + beforeEach(() => { + service = new ShareService(mockShareRepository, mockSnippetRepository); + jest.clearAllMocks(); + }); + + describe("createShareLink", () => { + it("should throw error when snippet not found", async () => { + (mockSnippetRepository.findById as jest.Mock).mockResolvedValue(null); + + await expect( + service.createShareLink({ + snippetId: "non-existent", + createdByWalletAddress: "G1234567890123456789012345678901234567890123456789012345", + }), + ).rejects.toThrow("Snippet not found"); + }); + + it("should return existing share if one exists", async () => { + const existingShare = { + id: "1", + snippet_id: "1", + share_token: "existing-token", + is_read_only: true, + expires_at: null, + created_by_wallet_address: "G1234567890123456789012345678901234567890123456789012345", + revoked_at: null, + revoked_by_wallet_address: null, + created_at: new Date().toISOString(), + }; + (mockSnippetRepository.findById as jest.Mock).mockResolvedValue({ id: "1" }); + (mockShareRepository.findActiveShareBySnippet as jest.Mock).mockResolvedValue( + existingShare, + ); + + const result = await service.createShareLink({ + snippetId: "1", + createdByWalletAddress: "G1234567890123456789012345678901234567890123456789012345", + }); + + expect(result.shareToken).toBe("existing-token"); + expect(result.isReadOnly).toBe(true); + }); + + it("should create new share link when none exists", async () => { + const newShare = { + id: "1", + snippet_id: "1", + share_token: "test-token-123", + is_read_only: true, + expires_at: null, + created_by_wallet_address: "G1234567890123456789012345678901234567890123456789012345", + revoked_at: null, + revoked_by_wallet_address: null, + created_at: new Date().toISOString(), + }; + (mockSnippetRepository.findById as jest.Mock).mockResolvedValue({ id: "1" }); + (mockShareRepository.findActiveShareBySnippet as jest.Mock).mockResolvedValue( + null, + ); + (mockShareRepository.createShare as jest.Mock).mockResolvedValue(newShare); + + const result = await service.createShareLink({ + snippetId: "1", + isReadOnly: true, + createdByWalletAddress: "G1234567890123456789012345678901234567890123456789012345", + }); + + expect(result.shareToken).toBe("test-token-123"); + expect(result.isReadOnly).toBe(true); + expect(mockShareRepository.createShare).toHaveBeenCalled(); + }); + }); + + describe("getSharedSnippet", () => { + it("should return null when share token not found", async () => { + (mockShareRepository.findByToken as jest.Mock).mockResolvedValue(null); + + const result = await service.getSharedSnippet("invalid-token"); + + expect(result).toBeNull(); + }); + + it("should return snippet when share token is valid", async () => { + const share = { + id: "1", + snippet_id: "1", + share_token: "valid-token", + is_read_only: true, + expires_at: null, + created_by_wallet_address: "G1234567890123456789012345678901234567890123456789012345", + revoked_at: null, + revoked_by_wallet_address: null, + created_at: new Date().toISOString(), + }; + const snippet = { id: "1", title: "Test Snippet" }; + + (mockShareRepository.findByToken as jest.Mock).mockResolvedValue(share); + (mockSnippetRepository.findById as jest.Mock).mockResolvedValue(snippet); + + const result = await service.getSharedSnippet("valid-token"); + + expect(result).toEqual({ snippet, isReadOnly: true }); + }); + }); + + describe("revokeShare", () => { + it("should throw error when no active share exists", async () => { + (mockShareRepository.findActiveShareBySnippet as jest.Mock).mockResolvedValue( + null, + ); + + await expect( + service.revokeShare("1", "G1234567890123456789012345678901234567890123456789012345"), + ).rejects.toThrow("No active share link found for this snippet"); + }); + + it("should revoke share successfully", async () => { + const existingShare = { + id: "1", + snippet_id: "1", + share_token: "existing-token", + is_read_only: true, + expires_at: null, + created_by_wallet_address: "G1234567890123456789012345678901234567890123456789012345", + revoked_at: null, + revoked_by_wallet_address: null, + created_at: new Date().toISOString(), + }; + (mockShareRepository.findActiveShareBySnippet as jest.Mock).mockResolvedValue( + existingShare, + ); + (mockShareRepository.revokeShare as jest.Mock).mockResolvedValue(true); + + const result = await service.revokeShare( + "1", + "G1234567890123456789012345678901234567890123456789012345", + ); + + expect(result).toBe(true); + }); + }); +}); \ No newline at end of file diff --git a/scripts/add-snippet-shares.sql b/scripts/add-snippet-shares.sql new file mode 100644 index 0000000..2bb7108 --- /dev/null +++ b/scripts/add-snippet-shares.sql @@ -0,0 +1,19 @@ +-- Snippet shares table for public link sharing +-- Supports read-only access via unique share tokens +CREATE TABLE IF NOT EXISTS snippet_shares ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + snippet_id UUID NOT NULL REFERENCES snippets(id) ON DELETE CASCADE, + share_token VARCHAR(64) NOT NULL UNIQUE, + is_read_only BOOLEAN DEFAULT TRUE, + expires_at TIMESTAMP, + created_by_wallet_address VARCHAR(56) NOT NULL, + revoked_at TIMESTAMP, + revoked_by_wallet_address VARCHAR(56), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Indexes for performance +CREATE INDEX IF NOT EXISTS idx_snippet_shares_token ON snippet_shares(share_token); +CREATE INDEX IF NOT EXISTS idx_snippet_shares_snippet_id ON snippet_shares(snippet_id); +CREATE INDEX IF NOT EXISTS idx_snippet_shares_expires_at ON snippet_shares(expires_at); +CREATE INDEX IF NOT EXISTS idx_snippet_shares_active ON snippet_shares(snippet_id, revoked_at); \ No newline at end of file