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
115 changes: 115 additions & 0 deletions app/api/snippets/[id]/share/route.ts
Original file line number Diff line number Diff line change
@@ -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 },
);
}
}
107 changes: 107 additions & 0 deletions app/api/snippets/share.repository.ts
Original file line number Diff line number Diff line change
@@ -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<SnippetShare> {
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<SnippetShare | null> {
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<SnippetShare | null> {
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<boolean> {
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<boolean> {
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<SnippetShare | null> {
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;
}
}
108 changes: 108 additions & 0 deletions app/api/snippets/share.service.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
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}`;
}
}
45 changes: 45 additions & 0 deletions app/api/snippets/shared/[token]/route.ts
Original file line number Diff line number Diff line change
@@ -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 },
);
}
}
Loading