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
4 changes: 4 additions & 0 deletions apps/file-sharing/.env.example
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
65 changes: 65 additions & 0 deletions apps/file-sharing/README.md
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
38 changes: 38 additions & 0 deletions apps/file-sharing/package.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"
}
}
187 changes: 187 additions & 0 deletions apps/file-sharing/src/server.ts
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;
}

Copy link
Copy Markdown

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 updateDB function chains operations via .then() on dbWriteLock, but if any operation in the chain throws (e.g., corrupted drops.json causing JSON.parse to fail, or db[drop.id] being undefined on line 163), the resulting rejected promise becomes the new dbWriteLock. All subsequent .then() calls on a rejected promise skip execution, so every future updateDB call 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)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit f660210. Configure here.


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")));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Serve an actual landing page for browser-based uploads

This middleware mounts ../public, but this commit does not add any apps/file-sharing/public files and there is no explicit GET / handler, so visiting http://localhost:3000 returns 404 and the documented browser upload flow is unavailable. Either include the frontend assets in this app or provide a route that returns an upload page.

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, "_");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicate filename sanitization logic not using shared function

Low Severity

Line 95 applies an inline regex (/[^a-zA-Z0-9._-]/g) to sanitize originalname, which is functionally identical to the sanitizeFilename function defined on line 71. Having two copies of the same sanitization logic means a future fix to one (e.g., tightening the allowed character set) could easily miss the other, risking inconsistent behavior.

Additional Locations (1)
Fix in Cursor Fix in Web

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" });
}
}
Comment thread
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`);
});
12 changes: 12 additions & 0 deletions apps/file-sharing/tsconfig.json
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/**/*"]
}
Loading