Skip to content
Open
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
3 changes: 2 additions & 1 deletion app/api/snippets/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,8 @@ export async function PUT(
}

const body = await req.json();
const snippet = await service.updateSnippet(id, body);
const { storeOnIpfs = false, ...snippetData } = body;
const snippet = await service.updateSnippet(id, snippetData, storeOnIpfs);

// Log update
if (walletAddress) {
Expand Down
73 changes: 73 additions & 0 deletions app/api/snippets/ipfs/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { NextRequest, NextResponse } from 'next/server';
import { SnippetService } from '../snippet.service';
import { SnippetRepository } from '../snippet.repository';
import { storeOnIPFS, retrieveFromIPFS } from '@/lib/ipfs';

const snippetRepository = new SnippetRepository();
const snippetService = new SnippetService(snippetRepository);

// Store content on IPFS
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { content } = body;

if (!content) {
return NextResponse.json(
{ error: 'Content is required' },
{ status: 400 }
);
}

const cid = await storeOnIPFS(content);

return NextResponse.json({
success: true,
cid,
});
} catch (error) {
console.error('[API] Error storing content on IPFS:', error);
return NextResponse.json(
{ error: 'Failed to store content on IPFS' },
{ status: 500 }
);
}
}

// Retrieve content from IPFS
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams;
const cid = searchParams.get('cid');

if (!cid) {
return NextResponse.json(
{ error: 'CID is required' },
{ status: 400 }
);
}

const content = await retrieveFromIPFS(cid);

// Also try to get the snippet from database if it exists
let snippet = null;
try {
snippet = await snippetService.getSnippetByIpfsCid(cid);
} catch (err) {
// Snippet not found in database is okay
}

return NextResponse.json({
success: true,
cid,
content,
snippet,
});
} catch (error) {
console.error('[API] Error retrieving content from IPFS:', error);
return NextResponse.json(
{ error: 'Failed to retrieve content from IPFS' },
{ status: 500 }
);
}
}
9 changes: 5 additions & 4 deletions app/api/snippets/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,14 +113,15 @@ export async function POST(req: NextRequest) {
}

const body = await req.json();
const { storeOnIpfs = false, ...snippetData } = body;

// Extract and inject the wallet address securely from headers
const walletAddress = await OwnershipMiddleware.extractWalletAddress(req);
if (walletAddress) {
body.ownerWalletAddress = walletAddress;
snippetData.ownerWalletAddress = walletAddress;
}

const snippet = await service.createSnippet(body);
const snippet = await service.createSnippet(snippetData, storeOnIpfs);

// Log transaction if wallet address provided
if (walletAddress) {
Expand All @@ -129,7 +130,7 @@ export async function POST(req: NextRequest) {
walletAddress,
"snippet_create",
`Created snippet ${snippet.id}`,
{ snippetId: snippet.id },
{ snippetId: snippet.id, ipfsCid: snippet.ipfs_cid },
);
} catch (err) {
console.error("[transactions] Failed to log snippet_create:", err);
Expand All @@ -139,7 +140,7 @@ export async function POST(req: NextRequest) {
await appendActivityLog("snippet.created", "snippet", {
actorWallet: walletAddress,
resourceId: snippet.id,
metadata: { title: snippet.title, language: snippet.language, tags: snippet.tags },
metadata: { title: snippet.title, language: snippet.language, tags: snippet.tags, ipfsCid: snippet.ipfs_cid },
ipAddress: extractIp(req.headers),
userAgent: extractUserAgent(req.headers),
});
Expand Down
48 changes: 12 additions & 36 deletions app/api/snippets/snippet.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,53 +127,21 @@ export class SnippetRepository {
return result[0] || null;
}

async create(data: CreateSnippetDTO) {
async create(data: CreateSnippetDTO & { ipfsCid?: string }) {
const id = crypto.randomUUID();
const createdAt = new Date();

const result = await this.sql`
INSERT INTO snippets (id, title, description, code, language, tags, owner_wallet_address, created_at, updated_at)
VALUES (${id}, ${data.title}, ${data.description}, ${data.code}, ${data.language}, ${data.tags}, ${data.ownerWalletAddress}, ${createdAt}, ${createdAt})
INSERT INTO snippets (id, title, description, code, language, tags, owner_wallet_address, ipfs_cid, created_at, updated_at)
VALUES (${id}, ${data.title}, ${data.description}, ${data.code}, ${data.language}, ${data.tags}, ${data.ownerWalletAddress}, ${data.ipfsCid}, ${createdAt}, ${createdAt})
RETURNING *
`;
return result[0];
}

async update(id: string, data: UpdateSnippetDTO) {
async update(id: string, data: UpdateSnippetDTO & { ipfsCid?: string }) {
const updatedAt = new Date();

// Build dynamic update query using tagged template
const updates: string[] = [];
const values: any[] = [];

if (data.title !== undefined) {
updates.push("title = ${value}");
values.push(data.title);
}
if (data.description !== undefined) {
updates.push("description = ${value}");
values.push(data.description);
}
if (data.code !== undefined) {
updates.push("code = ${value}");
values.push(data.code);
}
if (data.language !== undefined) {
updates.push("language = ${value}");
values.push(data.language);
}
if (data.tags !== undefined) {
updates.push("tags = ${value}");
values.push(data.tags);
}

if (updates.length === 0) {
return this.findById(id);
}

// Build the SET clause with proper parameter placeholders
const setClause = updates.join(", ");

// Use raw SQL for dynamic updates
const result = await this.sql`
UPDATE snippets
Expand All @@ -182,13 +150,21 @@ export class SnippetRepository {
code = COALESCE(${data.code}, code),
language = COALESCE(${data.language}, language),
tags = COALESCE(${data.tags}, tags),
ipfs_cid = COALESCE(${data.ipfsCid}, ipfs_cid),
updated_at = ${updatedAt}
WHERE id = ${id}
RETURNING *
`;
return result[0] || null;
}

async findByIpfsCid(ipfsCid: string) {
const result = await this.sql`
SELECT * FROM snippets WHERE ipfs_cid = ${ipfsCid} AND is_deleted = false
`;
return result[0] || null;
}

async delete(id: string) {
const result = await this.sql`
DELETE FROM snippets WHERE id = ${id} RETURNING *
Expand Down
60 changes: 54 additions & 6 deletions app/api/snippets/snippet.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
} from "./snippet.repository";
import { createSnippetSchema, updateSnippetSchema } from "./snippet.validator";
import { ActivityLogger } from "@/lib/activity-logger";
import { storeOnIPFS, retrieveFromIPFS } from "@/lib/ipfs";

export class SnippetService {
constructor(private snippetRepository: SnippetRepository) {}
Expand Down Expand Up @@ -43,20 +44,51 @@ export class SnippetService {
}
}

async createSnippet(data: unknown) {
async getSnippetByIpfsCid(ipfsCid: string) {
try {
const snippet = await this.snippetRepository.findByIpfsCid(ipfsCid);
if (!snippet) {
throw new Error("Snippet not found for this IPFS CID");
}
return snippet;
} catch (error) {
console.error("[Service] Error fetching snippet by IPFS CID:", error);
throw error instanceof Error
? error
: new Error("Failed to fetch snippet");
}
}

async createSnippet(data: unknown, storeOnIpfs: boolean = false) {
// 1. Validation (Throws ZodError if invalid)
const validatedData = createSnippetSchema.parse(data);

// 2. Database interaction via Repository
let ipfsCid: string | undefined;

// 2. Store code on IPFS if requested
if (storeOnIpfs && validatedData.code) {
try {
ipfsCid = await storeOnIPFS(validatedData.code);
console.log("[Service] Snippet stored on IPFS with CID:", ipfsCid);
} catch (error) {
console.error("[Service] Error storing snippet on IPFS:", error);
// Continue even if IPFS fails - we'll still store in database
}
}

// 3. Database interaction via Repository
try {
return await this.snippetRepository.create(validatedData);
return await this.snippetRepository.create({
...validatedData,
ipfsCid,
});
} catch (error) {
console.error("[Service] Error creating snippet:", error);
throw new Error("Failed to create snippet");
}
}

async updateSnippet(id: string, data: unknown) {
async updateSnippet(id: string, data: unknown, storeOnIpfs: boolean = false) {
// 1. Validation
const validatedData = updateSnippetSchema.parse(data);

Expand All @@ -66,9 +98,25 @@ export class SnippetService {
throw new Error("Snippet not found");
}

// 3. Database interaction via Repository
let ipfsCid: string | undefined = existing.ipfs_cid;

// 3. Store updated code on IPFS if requested
if (storeOnIpfs && validatedData.code) {
try {
ipfsCid = await storeOnIPFS(validatedData.code);
console.log("[Service] Updated snippet stored on IPFS with CID:", ipfsCid);
} catch (error) {
console.error("[Service] Error storing updated snippet on IPFS:", error);
// Continue even if IPFS fails
}
}

// 4. Database interaction via Repository
try {
return await this.snippetRepository.update(id, validatedData);
return await this.snippetRepository.update(id, {
...validatedData,
ipfsCid,
});
} catch (error) {
console.error("[Service] Error updating snippet:", error);
throw new Error("Failed to update snippet");
Expand Down
78 changes: 78 additions & 0 deletions lib/ipfs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { createHelia } from 'helia';
import { unixfs } from '@helia/unixfs';
import { CID } from 'multiformats/cid';

// Singleton instance to avoid creating multiple Helia nodes
let heliaInstance: any = null;
let unixfsInstance: any = null;

/**
* Initialize and get the Helia instance
*/
async function getHelia() {
if (!heliaInstance) {
heliaInstance = await createHelia();
unixfsInstance = unixfs(heliaInstance);
}
return { helia: heliaInstance, fs: unixfsInstance };
}

/**
* Store content on IPFS and return the CID
*/
export async function storeOnIPFS(content: string): Promise<string> {
try {
const { fs } = await getHelia();
const encoder = new TextEncoder();
const bytes = encoder.encode(content);

// Add the content to IPFS
const cid = await fs.addBytes(bytes);
return cid.toString();
} catch (error) {
console.error('[IPFS] Error storing content:', error);
throw new Error('Failed to store content on IPFS');
}
}

/**
* Retrieve content from IPFS using a CID
*/
export async function retrieveFromIPFS(cidString: string): Promise<string> {
try {
const { fs } = await getHelia();
const cid = CID.parse(cidString);

// Read the content from IPFS
const chunks: Uint8Array[] = [];
for await (const chunk of fs.cat(cid)) {
chunks.push(chunk);
}

// Concatenate chunks and decode to string
const totalLength = chunks.reduce((acc, chunk) => acc + chunk.length, 0);
const result = new Uint8Array(totalLength);
let offset = 0;
for (const chunk of chunks) {
result.set(chunk, offset);
offset += chunk.length;
}

const decoder = new TextDecoder();
return decoder.decode(result);
} catch (error) {
console.error('[IPFS] Error retrieving content:', error);
throw new Error('Failed to retrieve content from IPFS');
}
}

/**
* Shutdown the Helia node (useful for cleanup)
*/
export async function shutdownIPFS() {
if (heliaInstance) {
await heliaInstance.stop();
heliaInstance = null;
unixfsInstance = null;
}
}
5 changes: 5 additions & 0 deletions scripts/add-ipfs-cid.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
-- Add ipfs_cid column to snippets table
ALTER TABLE snippets ADD COLUMN IF NOT EXISTS ipfs_cid VARCHAR(255);

-- Create index for faster lookups by ipfs_cid
CREATE INDEX IF NOT EXISTS idx_snippets_ipfs_cid ON snippets(ipfs_cid);
2 changes: 2 additions & 0 deletions scripts/init-db.sql
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ CREATE TABLE snippets (
description TEXT,
language VARCHAR(50) NOT NULL,
code TEXT NOT NULL,
ipfs_cid VARCHAR(255),
tags JSONB DEFAULT '[]'::jsonb,
owner_wallet_address VARCHAR(255),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
Expand All @@ -31,3 +32,4 @@ CREATE INDEX IF NOT EXISTS idx_snippets_search_vector ON snippets USING GIN (
setweight(jsonb_to_tsvector('simple', COALESCE(tags, '[]'::jsonb), '["string"]'), 'B')
)
);
CREATE INDEX IF NOT EXISTS idx_snippets_ipfs_cid ON snippets(ipfs_cid);