-
Notifications
You must be signed in to change notification settings - Fork 58
feat: add file-sharing example with shareable download links #32
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| SHELBY_ACCOUNT_PRIVATE_KEY=your_private_key_here | ||
| SHELBY_API_KEY=your_api_key_here | ||
| SHELBY_ACCOUNT_ADDRESS=your_account_address_here | ||
| PORT=3000 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,65 @@ | ||
| # Shelby File Sharing Example | ||
|
|
||
| A full-stack example demonstrating decentralized file sharing built on the Shelby SDK. Upload any file, receive a unique shareable link, and let anyone download it — every file is SHA-256 hashed and stored on Shelby decentralized hot storage with Aptos blockchain provenance. | ||
|
|
||
| Think of it as a decentralized alternative to WeTransfer. | ||
|
|
||
| ## Prerequisites | ||
|
|
||
| - Node.js >= 22 | ||
| - pnpm | ||
| - A Shelby account with testnet tokens | ||
| - Shelby API key | ||
|
|
||
| ## Installation | ||
|
|
||
| Navigate to the file-sharing directory and install dependencies: | ||
|
|
||
| cd apps/file-sharing | ||
| pnpm install | ||
|
|
||
| ## Environment Variables | ||
|
|
||
| Copy the example file and fill in your credentials: | ||
|
|
||
| cp .env.example .env | ||
|
|
||
| Required variables: | ||
|
|
||
| SHELBY_ACCOUNT_PRIVATE_KEY=your_private_key_here | ||
| SHELBY_API_KEY=your_api_key_here | ||
| PORT=3000 | ||
|
|
||
| ## Usage | ||
|
|
||
| pnpm dev | ||
|
|
||
| Open http://localhost:3000 in your browser. | ||
|
|
||
| ## How It Works | ||
|
|
||
| 1. Upload — User selects a file; the server pushes it to Shelby via client.upload() | ||
| 2. Hash — A SHA-256 digest is computed locally before upload for integrity verification | ||
| 3. Link — A unique drop ID is generated and stored in drops.json | ||
| 4. Share — Anyone with the link can hit /drop/:id/download to stream the file from Shelby via client.download() | ||
| 5. Verify — The SHA-256 hash is displayed on the download page so recipients can verify file integrity | ||
|
|
||
| ## API | ||
|
|
||
| | Method | Path | Description | | ||
| |--------|------|-------------| | ||
| | POST | /upload | Upload a file multipart/form-data | | ||
| | GET | /drop/:id | Get drop metadata | | ||
| | GET | /drop/:id/download | Download file from Shelby | | ||
| | GET | /drops | List all drops | | ||
|
|
||
| ## Project Structure | ||
|
|
||
| apps/file-sharing/ | ||
| src/ | ||
| server.ts # Express server + Shelby SDK integration | ||
| public/ # Static frontend | ||
| .env.example | ||
| package.json | ||
| README.md | ||
| tsconfig.json |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| { | ||
| "name": "@shelby-protocol/file-sharing-example", | ||
| "version": "1.0.0", | ||
| "description": "An example app demonstrating decentralized file sharing with shareable download links using the Shelby SDK", | ||
| "type": "module", | ||
| "scripts": { | ||
| "dev": "tsx --env-file=.env src/server.ts", | ||
| "lint": "biome check .", | ||
| "fmt": "biome check . --write" | ||
| }, | ||
| "keywords": [ | ||
| "shelby", | ||
| "sdk", | ||
| "blob", | ||
| "storage", | ||
| "file-sharing" | ||
| ], | ||
| "author": "erhnysr", | ||
| "license": "MIT", | ||
| "dependencies": { | ||
| "@aptos-labs/ts-sdk": "^5.1.1", | ||
| "@shelby-protocol/sdk": "latest", | ||
| "express": "^4.21.2", | ||
| "multer": "^2.0.1", | ||
| "uuid": "^11.1.0" | ||
| }, | ||
| "devDependencies": { | ||
| "@biomejs/biome": "2.2.4", | ||
| "@types/express": "^5.0.3", | ||
| "@types/multer": "^1.4.12", | ||
| "@types/uuid": "^10.0.0", | ||
| "tsx": "^4.20.5", | ||
| "typescript": "^5.9.2" | ||
| }, | ||
| "engines": { | ||
| "node": ">=22" | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,187 @@ | ||
| import crypto from "node:crypto"; | ||
| import fs from "node:fs"; | ||
| import path from "node:path"; | ||
| import { Readable } from "node:stream"; | ||
| import { pipeline } from "node:stream/promises"; | ||
| import type { ReadableStream } from "node:stream/web"; | ||
| import { Account, AccountAddress, Ed25519PrivateKey, Network } from "@aptos-labs/ts-sdk"; | ||
| import { ShelbyNodeClient } from "@shelby-protocol/sdk/node"; | ||
| import express from "express"; | ||
| import multer from "multer"; | ||
| import { v4 as uuidv4 } from "uuid"; | ||
|
|
||
| const PORT = process.env.PORT ?? 3000; | ||
| const DB_PATH = path.join(process.cwd(), "drops.json"); | ||
| const UPLOAD_DIR = path.join(process.cwd(), "uploads"); | ||
| const TTL_MICROS = 30 * 24 * 60 * 60 * 1_000_000; | ||
|
|
||
| if (!process.env.SHELBY_ACCOUNT_PRIVATE_KEY) { | ||
| throw new Error("Missing SHELBY_ACCOUNT_PRIVATE_KEY"); | ||
| } | ||
| if (!process.env.SHELBY_API_KEY) { | ||
| throw new Error("Missing SHELBY_API_KEY"); | ||
| } | ||
| if (!process.env.SHELBY_ACCOUNT_ADDRESS) { | ||
| throw new Error("Missing SHELBY_ACCOUNT_ADDRESS"); | ||
| } | ||
|
|
||
| const client = new ShelbyNodeClient({ | ||
| network: Network.TESTNET, | ||
| apiKey: process.env.SHELBY_API_KEY, | ||
| }); | ||
|
|
||
| const signer = Account.fromPrivateKey({ | ||
| privateKey: new Ed25519PrivateKey(process.env.SHELBY_ACCOUNT_PRIVATE_KEY), | ||
| }); | ||
|
|
||
| const accountAddress = AccountAddress.fromString(process.env.SHELBY_ACCOUNT_ADDRESS); | ||
|
|
||
| interface Drop { | ||
| id: string; | ||
| fileName: string; | ||
| fileSize: number; | ||
| blobName: string; | ||
| sha256: string; | ||
| uploadedAt: string; | ||
| expiresAt: string; | ||
| downloads: number; | ||
| } | ||
|
|
||
| // Simple async mutex to prevent concurrent drops.json writes | ||
| let dbWriteLock: Promise<void> = Promise.resolve(); | ||
|
|
||
| function loadDB(): Record<string, Drop> { | ||
| if (!fs.existsSync(DB_PATH)) return {}; | ||
| return JSON.parse(fs.readFileSync(DB_PATH, "utf8")) as Record<string, Drop>; | ||
| } | ||
|
|
||
| function saveDB(db: Record<string, Drop>): void { | ||
| fs.writeFileSync(DB_PATH, JSON.stringify(db, null, 2)); | ||
| } | ||
|
|
||
| function updateDB(fn: (db: Record<string, Drop>) => void): Promise<void> { | ||
| dbWriteLock = dbWriteLock.then(() => { | ||
| const db = loadDB(); | ||
| fn(db); | ||
| saveDB(db); | ||
| }); | ||
| return dbWriteLock; | ||
| } | ||
|
|
||
| function sanitizeFilename(name: string): string { | ||
| return name.replace(/[^\w.\-]/g, "_"); | ||
| } | ||
|
|
||
| if (!fs.existsSync(UPLOAD_DIR)) fs.mkdirSync(UPLOAD_DIR, { recursive: true }); | ||
|
|
||
| const upload = multer({ dest: UPLOAD_DIR }); | ||
| const app = express(); | ||
|
|
||
| app.use(express.json()); | ||
| app.use(express.static(path.join(import.meta.dirname, "..", "public"))); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This middleware mounts Useful? React with 👍 / 👎. |
||
|
|
||
| // Serve the download page for shareable drop links | ||
| app.get("/d/:id", (_req, res) => { | ||
| res.sendFile(path.join(import.meta.dirname, "..", "public", "index.html")); | ||
| }); | ||
|
|
||
| app.post("/upload", upload.single("file"), async (req, res) => { | ||
| if (!req.file) { | ||
| res.status(400).json({ error: "No file provided" }); | ||
| return; | ||
| } | ||
|
|
||
| const { originalname, path: tmpPath, size } = req.file; | ||
| const safeName = originalname.replace(/[^a-zA-Z0-9._-]/g, "_"); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Duplicate filename sanitization logic not using shared functionLow Severity Line 95 applies an inline regex ( Additional Locations (1)Reviewed by Cursor Bugbot for commit f660210. Configure here. |
||
| const id = uuidv4().split("-")[0]; | ||
| const blobName = `file-sharing/${id}-${safeName}`; | ||
| const fileData = fs.readFileSync(tmpPath); | ||
| const sha256 = crypto.createHash("sha256").update(fileData).digest("hex"); | ||
| const expirationMicros = Date.now() * 1000 + TTL_MICROS; | ||
|
|
||
| try { | ||
| await client.upload({ | ||
| blobData: fileData, | ||
| signer, | ||
| blobName, | ||
| expirationMicros, | ||
| }); | ||
|
|
||
| fs.unlinkSync(tmpPath); | ||
|
|
||
| const drop: Drop = { | ||
| id, | ||
| fileName: originalname, | ||
| fileSize: size, | ||
| blobName, | ||
| sha256, | ||
| uploadedAt: new Date().toISOString(), | ||
| expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(), | ||
| downloads: 0, | ||
| }; | ||
|
|
||
| await updateDB((db) => { db[id] = drop; }); | ||
|
|
||
| console.log(`✓ Uploaded ${originalname} → ${blobName}`); | ||
| res.json({ success: true, id, sha256, expiresAt: drop.expiresAt }); | ||
| } catch (err) { | ||
| if (fs.existsSync(tmpPath)) fs.unlinkSync(tmpPath); | ||
| console.error("Upload error:", err); | ||
| res.status(500).json({ error: "Upload failed" }); | ||
| } | ||
| }); | ||
|
|
||
| app.get("/drop/:id", (req, res) => { | ||
| const db = loadDB(); | ||
| const drop = db[req.params.id]; | ||
| if (!drop) { | ||
| res.status(404).json({ error: "Drop not found" }); | ||
| return; | ||
| } | ||
| res.json(drop); | ||
| }); | ||
|
|
||
| app.get("/drop/:id/download", async (req, res) => { | ||
| const db = loadDB(); | ||
| const drop = db[req.params.id]; | ||
| if (!drop) { | ||
| res.status(404).json({ error: "Drop not found" }); | ||
| return; | ||
| } | ||
|
|
||
| const safeFileName = sanitizeFilename(drop.fileName); | ||
|
|
||
| try { | ||
| const { readable } = await client.download({ | ||
| account: accountAddress, | ||
| blobName: drop.blobName, | ||
| }); | ||
|
|
||
| res.setHeader("Content-Disposition", `attachment; filename="${safeFileName}"`); | ||
| res.setHeader("Content-Type", "application/octet-stream"); | ||
|
|
||
| await updateDB((db) => { db[drop.id].downloads += 1; }); | ||
|
|
||
| await pipeline( | ||
| Readable.fromWeb(readable as ReadableStream<Uint8Array>), | ||
| res, | ||
| ); | ||
| } catch (err) { | ||
| if (!res.headersSent) { | ||
| console.error("Download error:", err); | ||
| res.status(500).json({ error: "Download failed" }); | ||
| } | ||
| } | ||
|
cursor[bot] marked this conversation as resolved.
|
||
| }); | ||
|
|
||
| app.get("/drops", (_req, res) => { | ||
| const db = loadDB(); | ||
| const list = Object.values(db).sort( | ||
| (a, b) => new Date(b.uploadedAt).getTime() - new Date(a.uploadedAt).getTime(), | ||
| ); | ||
| res.json(list); | ||
| }); | ||
|
|
||
| app.listen(PORT, () => { | ||
| console.log(`\n⚡ Shelby File Sharing running at http://localhost:${PORT}\n`); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| { | ||
| "compilerOptions": { | ||
| "target": "ES2022", | ||
| "module": "NodeNext", | ||
| "moduleResolution": "NodeNext", | ||
| "strict": true, | ||
| "esModuleInterop": true, | ||
| "skipLibCheck": true, | ||
| "outDir": "dist" | ||
| }, | ||
| "include": ["src/**/*"] | ||
| } |


There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Mutex promise chain permanently breaks on any error
Medium Severity
The
updateDBfunction chains operations via.then()ondbWriteLock, but if any operation in the chain throws (e.g., corrupteddrops.jsoncausingJSON.parseto fail, ordb[drop.id]beingundefinedon line 163), the resulting rejected promise becomes the newdbWriteLock. All subsequent.then()calls on a rejected promise skip execution, so every futureupdateDBcall silently fails. This means after a single error, new uploads still push files to Shelby but their metadata is never persisted — download links become permanently broken. Adding a.catch()to reset the chain would prevent this cascading failure.Additional Locations (1)
apps/file-sharing/src/server.ts#L162-L163Reviewed by Cursor Bugbot for commit f660210. Configure here.