diff --git a/package.json b/package.json index 453c893..cdc1500 100644 --- a/package.json +++ b/package.json @@ -4,10 +4,11 @@ "description": "REST API wrapper for Akave IPC CLI", "main": "server.js", "scripts": { - "start": "node server.js", - "dev": "nodemon server.js", + "start": "node dist/server.js", + "dev": "ts-node-dev --respawn --transpile-only src/server.ts", "test": "jest", - "test:integration": "jest --config=jest.config.integration.js" + "test:integration": "jest --config=jest.config.integration.js", + "build": "tsc" }, "files": [ "index.js", @@ -26,7 +27,14 @@ "@types/jest": "^29.5.12", "form-data": "^4.0.0", "jest": "^29.7.0", - "nodemon": "^3.0.3" + "nodemon": "^3.0.3", + "typescript": "^5.3.3", + "@types/express": "^4.17.21", + "@types/multer": "^1.4.11", + "@types/cors": "^2.8.17", + "@types/node": "^20.11.19", + "ts-node": "^10.9.2", + "ts-node-dev": "^2.0.0" }, "engines": { "node": ">=18.0.0" diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..86ba52d --- /dev/null +++ b/src/index.ts @@ -0,0 +1,304 @@ +import { spawn } from "child_process" +import { getLatestTransaction } from "./web3-utils" +import { privateKeyToAccount } from "viem/accounts" +import { Bucket, File, UploadResult, ClientConfig, AkaveError } from "./types" + +interface ParsedOutput { + [key: string]: string | number +} + +class AkaveIPCClient { + private nodeAddress: string + private privateKey: string + private address: string + + constructor({ nodeAddress, privateKey }: ClientConfig) { + this.nodeAddress = nodeAddress + this.privateKey = privateKey.startsWith("0x") + ? privateKey.slice(2) + : privateKey + this.address = privateKeyToAccount(`0x${this.privateKey}`).address + } + + private async executeCommand( + args: string[], + parser: keyof typeof this.parsers = "default", + trackTransaction = false + ): Promise { + const commandId = Math.random().toString(36).substring(7) + console.log(`[${commandId}] Executing command: akavecli ${args.join(" ")}`) + + const result = await new Promise((resolve, reject) => { + const process = spawn("akavecli", args) + let stdout = "" + let stderr = "" + + process.stdout.on("data", (data: Buffer) => { + stdout += data.toString() + console.log(`[${commandId}] stdout: ${data.toString().trim()}`) + }) + + process.stderr.on("data", (data: Buffer) => { + stderr += data.toString() + if (!data.toString().includes("File uploaded successfully:")) { + console.error(`[${commandId}] stderr: ${data.toString().trim()}`) + } + }) + + process.on("close", (code: number) => { + const output = (stdout + stderr).trim() + + if (code === 0) { + console.log(`[${commandId}] Command completed successfully`) + } else { + console.error(`[${commandId}] Command failed with code: ${code}`) + } + + try { + const result = this.parsers[parser](output) + resolve(result as T) + } catch (error) { + console.error( + `[${commandId}] Failed to parse output:`, + error instanceof Error ? error.message : error + ) + reject(error) + } + }) + + process.on("error", (err: Error) => { + console.error(`[${commandId}] Process error:`, err) + reject(err) + }) + }) + + if (trackTransaction) { + try { + console.log(`[${commandId}] Fetching transaction hash...`) + const txHash = await getLatestTransaction(this.address, commandId) + + if (txHash) { + console.log(`[${commandId}] Transaction hash found: ${txHash}`) + return { ...result, transactionHash: txHash } as T + } + console.warn(`[${commandId}] No transaction hash found`) + } catch (error) { + console.error(`[${commandId}] Failed to get transaction hash:`, error) + } + } + + return result + } + + private parsers = { + createBucket: (output: string): Bucket => { + if (!output.startsWith("Bucket created:")) { + throw new AkaveError("Unexpected output format for bucket creation") + } + return this.parseKeyValuePairs( + output.substring("Bucket created:".length) + ) as unknown as Bucket + }, + + listBuckets: (output: string): Bucket[] => { + const buckets: Bucket[] = [] + const lines = output.split("\n") + for (const line of lines) { + if (line.startsWith("Bucket:")) { + buckets.push( + this.parseKeyValuePairs(line.substring(8)) as unknown as Bucket + ) + } + } + return buckets + }, + + viewBucket: (output: string): Bucket => { + if (!output.startsWith("Bucket:")) { + throw new AkaveError("Unexpected output format for bucket view") + } + return this.parseKeyValuePairs(output.substring(8)) as unknown as Bucket + }, + + deleteBucket: (output: string): { Name: string } => { + if (!output.startsWith("Bucket deleted:")) { + throw new AkaveError("Unexpected output format for bucket deletion") + } + const bucketInfo = output + .substring("Bucket deleted:".length) + .trim() + .split("=") + if (bucketInfo.length !== 2 || !bucketInfo[0].trim().startsWith("Name")) { + throw new AkaveError("Invalid bucket deletion output format") + } + return { Name: bucketInfo[1].trim() } + }, + + listFiles: (output: string): File[] => { + const files: File[] = [] + const lines = output.split("\n") + for (const line of lines) { + if (line.startsWith("File:")) { + files.push( + this.parseKeyValuePairs(line.substring(6)) as unknown as File + ) + } + } + return files + }, + + fileInfo: (output: string): File => { + if (!output.startsWith("File:")) { + throw new AkaveError("Unexpected output format for file info") + } + return this.parseKeyValuePairs(output.substring(6)) as unknown as File + }, + + uploadFile: (output: string): UploadResult => { + const lines = output.split("\n") + const successLine = lines.find((line) => + line.includes("File uploaded successfully:") + ) + + if (!successLine) { + throw new AkaveError("File upload failed: " + output) + } + + return this.parseKeyValuePairs( + successLine.substring( + successLine.indexOf("File uploaded successfully:") + + "File uploaded successfully:".length + ) + ) as unknown as UploadResult + }, + + downloadFile: (output: string): string => output, + + default: (output: string): unknown => { + try { + return JSON.parse(output) + } catch { + return output + } + }, + } as const + + private parseKeyValuePairs(input: string): ParsedOutput { + const result: ParsedOutput = {} + const pairs = input.trim().split(", ") + pairs.forEach((pair) => { + const [key, value] = pair.split("=") + result[key.trim()] = value.trim() + }) + return result + } + + // Public methods + async createBucket(bucketName: string): Promise { + const args = [ + "ipc", + "bucket", + "create", + bucketName, + `--node-address=${this.nodeAddress}`, + `--private-key=${this.privateKey}`, + ] + return this.executeCommand(args, "createBucket", true) + } + + async deleteBucket(bucketName: string): Promise<{ Name: string }> { + const args = [ + "ipc", + "bucket", + "delete", + bucketName, + `--node-address=${this.nodeAddress}`, + `--private-key=${this.privateKey}`, + ] + return this.executeCommand(args, "deleteBucket", true) + } + + async viewBucket(bucketName: string): Promise { + const args = [ + "ipc", + "bucket", + "view", + bucketName, + `--node-address=${this.nodeAddress}`, + `--private-key=${this.privateKey}`, + ] + return this.executeCommand(args, "viewBucket") + } + + async listBuckets(): Promise { + const args = [ + "ipc", + "bucket", + "list", + `--node-address=${this.nodeAddress}`, + `--private-key=${this.privateKey}`, + ] + return this.executeCommand(args, "listBuckets") + } + + async listFiles(bucketName: string): Promise { + const args = [ + "ipc", + "file", + "list", + bucketName, + `--node-address=${this.nodeAddress}`, + `--private-key=${this.privateKey}`, + ] + return this.executeCommand(args, "listFiles") + } + + async getFileInfo(bucketName: string, fileName: string): Promise { + const args = [ + "ipc", + "file", + "info", + bucketName, + fileName, + `--node-address=${this.nodeAddress}`, + `--private-key=${this.privateKey}`, + ] + return this.executeCommand(args, "fileInfo") + } + + async uploadFile( + bucketName: string, + filePath: string + ): Promise { + const args = [ + "ipc", + "file", + "upload", + bucketName, + filePath, + `--node-address=${this.nodeAddress}`, + `--private-key=${this.privateKey}`, + ] + return this.executeCommand(args, "uploadFile", true) + } + + async downloadFile( + bucketName: string, + fileName: string, + destinationPath: string + ): Promise { + const args = [ + "ipc", + "file", + "download", + bucketName, + fileName, + destinationPath, + `--node-address=${this.nodeAddress}`, + `--private-key=${this.privateKey}`, + ] + return this.executeCommand(args, "downloadFile") + } +} + +export default AkaveIPCClient diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 0000000..36263ed --- /dev/null +++ b/src/server.ts @@ -0,0 +1,228 @@ +import express, { Request, Response } from "express" +import multer from "multer" +import { promises as fs, constants as fsConstants, createReadStream } from "fs" +import path from "path" +import os from "os" +import dotenv from "dotenv" +import cors from "cors" +import AkaveIPCClient from "./index" + +dotenv.config() + +// Types +interface Logger { + info: (id: string, message: string, data?: object) => void + error: (id: string, message: string, error?: object) => void + warn: (id: string, message: string, data?: object) => void +} + +// Initialize express app +const app = express() + +// Configure CORS +const corsOptions = { + origin: process.env.CORS_ORIGIN || "*", + methods: "GET,HEAD,PUT,PATCH,POST,DELETE", + credentials: true, + optionsSuccessStatus: 204, +} + +app.use(cors(corsOptions)) +app.use(express.json()) + +// Configure multer for file upload handling +const upload = multer({ + storage: multer.memoryStorage(), + limits: { + fileSize: 50 * 1024 * 1024, // 50MB limit + }, +}).fields([ + { name: "file", maxCount: 1 }, + { name: "file1", maxCount: 1 }, +]) + +// Initialize Akave IPC client +const client = new AkaveIPCClient({ + nodeAddress: process.env.NODE_ADDRESS!, + privateKey: process.env.PRIVATE_KEY!, +}) + +// Add a simple logger +const logger: Logger = { + info: (id, message, data = {}) => { + console.log(`[${id}] 🔵 ${message}`, data) + }, + error: (id, message, error = {}) => { + console.error(`[${id}] 🔴 ${message}`, error) + }, + warn: (id, message, data = {}) => { + console.warn(`[${id}] 🟡 ${message}`, data) + }, +} + +// After client initialization +logger.info("INIT", "Initializing client", { + nodeAddress: process.env.NODE_ADDRESS, + privateKeyLength: process.env.PRIVATE_KEY?.length || 0, +}) + +// Health check endpoint +app.get("/health", (_req: Request, res: Response) => { + res.json({ status: "ok" }) +}) + +// Bucket endpoints +app.post("/buckets", async (req: Request, res: Response) => { + try { + const { bucketName } = req.body + const result = await client.createBucket(bucketName) + res.json({ success: true, data: result }) + } catch (error) { + res.status(500).json({ + success: false, + error: error instanceof Error ? error.message : error, + }) + } +}) + +app.get("/buckets", async (_req: Request, res: Response) => { + try { + const result = await client.listBuckets() + res.json({ success: true, data: result }) + } catch (error) { + res.status(500).json({ + success: false, + error: error instanceof Error ? error.message : error, + }) + } +}) + +// File upload endpoint +app.post( + "/buckets/:bucketName/files", + upload, + async (req: any, res: Response) => { + const requestId = Math.random().toString(36).substring(7) + try { + logger.info(requestId, "Processing file upload request", { + bucket: req.params.bucketName, + }) + + let result + const uploadedFile = req.files?.file?.[0] || req.files?.file1?.[0] + + if (uploadedFile) { + logger.info(requestId, "Handling buffer upload", { + filename: uploadedFile.originalname, + }) + // Handle buffer upload + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "akave-")) + const sanitizedFileName = normalizeFileName(uploadedFile.originalname) + const tempFilePath = path.join(tempDir, sanitizedFileName) + + try { + await fs.writeFile(tempFilePath, uploadedFile.buffer) + result = await client.uploadFile(req.params.bucketName, tempFilePath) + } finally { + await fs.rm(tempDir, { recursive: true, force: true }) + } + } else if (req.body.filePath) { + logger.info(requestId, "Handling file path upload", { + path: req.body.filePath, + }) + result = await client.uploadFile( + req.params.bucketName, + req.body.filePath + ) + } else { + throw new Error("No file or filePath provided") + } + + logger.info(requestId, "File upload completed", { result }) + res.json({ success: true, data: result }) + } catch (error) { + logger.error(requestId, "File upload failed", { + error: error instanceof Error ? error.message : error, + }) + res.status(500).json({ + success: false, + error: error instanceof Error ? error.message : error, + }) + } + } +) + +// File download endpoint +app.get( + "/buckets/:bucketName/files/:fileName/download", + async (req: Request, res: Response) => { + const requestId = Math.random().toString(36).substring(7) + try { + logger.info(requestId, "Processing download request", { + bucket: req.params.bucketName, + file: req.params.fileName, + }) + + const downloadDir = path.join(process.cwd(), "downloads") + await fs.mkdir(downloadDir, { recursive: true }) + + const normalizedFileName = normalizeFileName(req.params.fileName) + const destinationPath = path.join(downloadDir, normalizedFileName) + + await client.downloadFile( + req.params.bucketName, + req.params.fileName, + downloadDir + ) + + try { + await fs.access(destinationPath, fsConstants.R_OK) + } catch (err) { + throw new Error("File download failed or file is not readable") + } + + const stats = await fs.stat(destinationPath) + + res.setHeader( + "Content-Disposition", + `attachment; filename="${req.params.fileName}"` + ) + res.setHeader("Content-Type", "application/octet-stream") + res.setHeader("Content-Length", stats.size) + + const fileStream = createReadStream(destinationPath) + + fileStream.on("error", (err) => { + logger.error(requestId, "Stream error occurred", err) + if (!res.headersSent) { + res.status(500).json({ + success: false, + error: err instanceof Error ? err.message : err, + }) + } + }) + + logger.info(requestId, "Starting file stream") + fileStream.pipe(res) + } catch (error) { + logger.error(requestId, "Download failed", { + error: error instanceof Error ? error.message : error, + }) + res.status(500).json({ + success: false, + error: error instanceof Error ? error.message : error, + }) + } + } +) + +// Utility function +function normalizeFileName(fileName: string): string { + return fileName.replace(/[^a-zA-Z0-9.-]/g, "_") +} + +// Start server +const PORT = process.env.PORT || 3000 +app.listen(PORT, () => { + console.log(`Server running on port ${PORT}`) +}) diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..9226e4f --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,57 @@ +// Core Data Structures +export interface Bucket { + Name: string + Created: string +} + +export interface File { + Name: string + Size: number + Created: string + Hash?: string +} + +export interface UploadResult { + Name: string + Size: number + Hash: string + transactionHash?: string +} + +// API Request Types +export interface CreateBucketRequest { + bucketName: string +} + +export interface UploadFileRequest { + file?: Express.Multer.File + filePath?: string +} + +// API Response Types +export interface ApiResponse { + success: boolean + data?: T + error?: string +} + +// Logger Types +export interface Logger { + info: (id: string, message: string, data?: object) => void + error: (id: string, message: string, error?: object) => void + warn: (id: string, message: string, data?: object) => void +} + +// Client Configuration +export interface ClientConfig { + nodeAddress: string + privateKey: string +} + +// Error Types +export class AkaveError extends Error { + constructor(message: string, public code?: string) { + super(message) + this.name = "AkaveError" + } +} diff --git a/src/utils/web3-utils.ts b/src/utils/web3-utils.ts new file mode 100644 index 0000000..41d9203 --- /dev/null +++ b/src/utils/web3-utils.ts @@ -0,0 +1,119 @@ +import { + createPublicClient, + http, + PublicClient, + Block, + Hash, + Transaction, +} from "viem" + +// Define Akave Fuji chain type +interface AkaveFujiChain { + id: number + name: string + nativeCurrency: { + decimals: number + name: string + symbol: string + } + rpcUrls: { + default: { + http: string[] + } + } +} + +// Define Akave Fuji chain +const akaveFuji: AkaveFujiChain = { + id: 78964, + name: "Akave Testnet", + nativeCurrency: { + decimals: 18, + name: "AKAVE", + symbol: "AKVT", + }, + rpcUrls: { + default: { + http: [ + "https://n1-us.akave.ai/ext/bc/2JMWNmZbYvWcJRPPy1siaDBZaDGTDAaqXoY5UBKh4YrhNFzEce/rpc", + ], + }, + }, +} + +// Initialize client +const publicClient: PublicClient = createPublicClient({ + chain: akaveFuji, + transport: http(), +}) + +/** + * Gets the latest transaction hash for a given address + * @note This function highly depends on Akave Fuji's block time and transaction confirmation time. + * If the transaction is not confirmed in the first block, it will wait for 5 seconds and try again. + * If the transaction is not confirmed in the second block, it will return null. + * @TODO Find a better way to get the related transaction hash. + */ +async function getLatestTransaction( + address: string, + commandId: string +): Promise { + try { + // First attempt + const blockNumber = await publicClient.getBlockNumber() + console.log( + `[${commandId}] 🔍 Checking block ${blockNumber} for transactions` + ) + + const block = await publicClient.getBlock({ + blockNumber, + includeTransactions: true, + }) + + let transactions: any = (block as Block).transactions.filter( + (tx: any) => tx.from?.toLowerCase() === address.toLowerCase() + ) + + if (transactions.length > 0) { + const hash = transactions[transactions.length - 1].hash + console.log( + `[${commandId}] ✅ Found transaction hash in first attempt: ${hash}` + ) + return hash + } + + console.log(`[${commandId}] ⏳ No transaction found, waiting 5 seconds...`) + await new Promise((resolve) => setTimeout(resolve, 5000)) + + // Second attempt + const newBlockNumber = await publicClient.getBlockNumber() + console.log( + `[${commandId}] 🔍 Checking block ${newBlockNumber} for transactions` + ) + + const newBlock = await publicClient.getBlock({ + blockNumber: newBlockNumber, + includeTransactions: true, + }) + + transactions = (newBlock as Block).transactions.filter( + (tx: any) => tx.from?.toLowerCase() === address.toLowerCase() + ) + + const hash = transactions[transactions.length - 1]?.hash + if (hash) { + console.log( + `[${commandId}] ✅ Found transaction hash in second attempt: ${hash}` + ) + } else { + console.log(`[${commandId}] ❌ No transaction found after retrying`) + } + + return hash || null + } catch (error) { + console.error(`[${commandId}] 🔴 Error getting latest transaction:`, error) + return null + } +} + +export { getLatestTransaction } diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..9a78681 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "lib": ["ES2020"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "moduleResolution": "node", + "resolveJsonModule": true, + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "tests"] +}