Initial commit

This commit is contained in:
besoeasy
2025-11-26 17:59:16 +05:30
commit b27577190e
8 changed files with 2434 additions and 0 deletions

139
.github/workflows/docker.yml vendored Normal file
View File

@@ -0,0 +1,139 @@
name: Docker
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
id-token: write
outputs:
version_changed: ${{ steps.version-check.outputs.version_changed }}
version: ${{ steps.version-check.outputs.version }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 2
- name: Check if package.json version changed
id: version-check
run: |
if [ $(git rev-list --count HEAD) -eq 1 ]; then
echo "version_changed=true" >> $GITHUB_OUTPUT
echo "version=$(jq -r .version package.json)" >> $GITHUB_OUTPUT
exit 0
fi
current_version=$(jq -r .version package.json)
previous_version=$(git show HEAD^:package.json | jq -r .version)
if [ "$previous_version" = "null" ] || [ "$current_version" != "$previous_version" ]; then
echo "version_changed=true" >> $GITHUB_OUTPUT
echo "version=$current_version" >> $GITHUB_OUTPUT
else
echo "version_changed=false" >> $GITHUB_OUTPUT
echo "version=$current_version" >> $GITHUB_OUTPUT
fi
- name: Install cosign
if: github.event_name != 'pull_request' && steps.version-check.outputs.version_changed == 'true'
uses: sigstore/cosign-installer@v3.5.0
with:
Irlanda-release: 'v2.2.4'
- name: Set up Docker Buildx
if: steps.version-check.outputs.version_changed == 'true'
uses: docker/setup-buildx-action@v3.0.0
- name: Log into registry ${{ env.REGISTRY }}
if: github.event_name != 'pull_request' && steps.version-check.outputs.version_changed == 'true'
uses: docker/login-action@v3.0.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract Docker metadata
if: steps.version-check.outputs.version_changed == 'true'
id: meta
uses: docker/metadata-action@v5.0.0
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=raw,value=main
type=raw,value=${{ steps.version-check.outputs.version }}
- name: Build and push Docker image
if: steps.version-check.outputs.version_changed == 'true'
id: build-and-push
uses: docker/build-push-action@v5.0.0
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
platforms: linux/amd64,linux/arm64
- name: Sign the published Docker image
if: github.event_name != 'pull_request' && steps.version-check.outputs.version_changed == 'true'
env:
TAGS: ${{ steps.meta.outputs.tags }}
DIGEST: ${{ steps.build-and-push.outputs.digest }}
run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST}
testing:
runs-on: ubuntu-latest
needs: build
if: ${{ needs.build.outputs.version_changed == 'false' }}
permissions:
contents: read
packages: write
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.0.0
- name: Log into registry ${{ env.REGISTRY }}
if: github.event_name != 'pull_request'
uses: docker/login-action@v3.0.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v5.0.0
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=raw,value=test
- name: Build and push Docker image (test)
uses: docker/build-push-action@v5.0.0
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
platforms: linux/amd64,linux/arm64

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
node_modules
temp_uploads/

37
Dockerfile Normal file
View File

@@ -0,0 +1,37 @@
FROM docker.io/node:lts-slim
ENV STORAGE_MAX=200GB
RUN apt-get update && \
apt-get install -y curl && \
rm -rf /var/lib/apt/lists/*
RUN curl -fsSL "https://dist.ipfs.tech/kubo/v0.38.2/kubo_v0.38.2_linux-$(dpkg --print-architecture).tar.gz" | \
tar -xz -C /tmp && \
mv /tmp/kubo/ipfs /usr/local/bin/ipfs && \
rm -rf /tmp/kubo
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm i
COPY . .
# Create uploads directory and set ownership
RUN mkdir -p /app/temp_uploads && chown -R node:node /app
# Switch to non-root user
USER node
EXPOSE 3232 4001/tcp 4001/udp
CMD ["sh", "-c", "\
if [ ! -d \"$HOME/.ipfs\" ]; then ipfs init; fi && \
ipfs config Datastore.StorageMax ${STORAGE_MAX} && \
ipfs daemon --enable-gc & \
until curl -s http://127.0.0.1:5001/api/v0/id > /dev/null; do \
echo 'Waiting for IPFS daemon...'; sleep 3; \
done && \
exec node app.js"]

68
README.md Normal file
View File

@@ -0,0 +1,68 @@
# File Drop
_A open-source solution for sharing images, videos, and any other files._
File Drop is a lightweight, peer-to-peer (P2P) application that allows users to share files directly between devices without relying on centralized servers. Built with privacy and simplicity in mind, it leverages Docker for easy deployment and P2P protocols for efficient, secure file transfers. With File Drop, you can upload anything without the fear of being censored since your data is relayed through your own computer. Even if your node loses its internet connection, your files persist across the entire IPFS network, ensuring availability.
![File Drop](https://github.com/user-attachments/assets/8d427693-8ee4-4c5f-a67c-6c2991c13f27)
File Drop is not built for permanent storage. Think of it as a way to share files temporarily on networks like Nostr, forums, and other apps. Since IPFS itself can be memory-intensive, weve designed File Drop to be lightweight. If you need permanent storage, you can edit 2-3 lines in the code and set pinning to `true`, but thats not our aim. File Drop supports any file type—be it images, videos, text files, or anything else—with a current limit of 250 MB per file, as the IPFS network isnt mature enough to handle larger files reliably. However, with a powerful enough computer, there are practically no limits.
![File Drop](https://github.com/user-attachments/assets/ff683fd8-d7c0-4378-81d4-a6342890cb86)
![File Drop](https://github.com/user-attachments/assets/0d7c6291-0194-470c-a07c-ef748b39337f)
## Installation
Run the following command to start File Drop:
```bash
docker run -d --restart unless-stopped \
-p 3232:3232 \
-p 4001:4001/tcp \
-p 4001:4001/udp \
--name file-drop \
-e STORAGE_MAX=50GB \
ghcr.io/besoeasy/file-drop:main
```
## Portainer Stack
For easy deployment with Portainer, use this stack configuration:
```yaml
version: "3.8"
services:
file-drop:
image: ghcr.io/besoeasy/file-drop:main
container_name: file-drop
restart: unless-stopped
ports:
- "3232:3232"
- "4001:4001/tcp"
- "4001:4001/udp"
environment:
- STORAGE_MAX=50GB
```
**Steps to deploy with Portainer:**
1. Open Portainer and navigate to **Stacks**
2. Click **Add stack** and give it a name (e.g., "file-drop")
3. Paste the above YAML configuration in the editor
4. Adjust environment variables as needed
5. Click **Deploy the stack**
## Configuration
### Environment Variables
- **STORAGE_MAX**: Sets the maximum storage limit for the IPFS repository.
- Default: `200GB`
- Example: `-e STORAGE_MAX=50GB` limits IPFS storage to 50 GB
- Formats: Supports standard units like `MB`, `GB`, `TB`
- Note: This controls how much disk space IPFS can use for storing files and metadata
## Usage
Access the application via `http://localhost:3232` in your browser (or your machines IP if remote).

272
app.js Normal file
View File

@@ -0,0 +1,272 @@
const express = require("express");
const multer = require("multer");
const axios = require("axios");
const FormData = require("form-data");
const path = require("path");
const cors = require("cors");
const compression = require("compression");
const fs = require("fs");
const { promisify } = require("util");
const unlinkAsync = promisify(fs.unlink);
// Constants
const IPFS_API = "http://127.0.0.1:5001";
const PORT = 3232;
const STORAGE_MAX = process.env.STORAGE_MAX || "200GB";
const HOST = "0.0.0.0";
const UPLOAD_TEMP_DIR = path.join(__dirname, "temp_uploads");
// Ensure temp directory exists
if (!fs.existsSync(UPLOAD_TEMP_DIR)) {
fs.mkdirSync(UPLOAD_TEMP_DIR, { recursive: true });
}
// Initialize Express app
const app = express();
// Middleware setup
app.use(compression()); // Enable gzip/deflate compression
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(cors());
app.use(express.static(path.join(__dirname, "public")));
// Configure multer for file uploads with disk storage for streaming
const storage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, UPLOAD_TEMP_DIR);
},
filename: (req, file, cb) => {
// Generate unique filename with timestamp
const uniqueSuffix = Date.now() + "-" + Math.round(Math.random() * 1e9);
cb(null, uniqueSuffix + "-" + file.originalname);
},
});
const upload = multer({
storage,
fileFilter: (req, file, cb) => {
cb(null, true);
},
});
// Error handling middleware
const errorHandler = (err, req, res, next) => {
console.error("Unexpected error:", err.stack);
res.status(500).json({ error: "Internal server error" });
};
// Enhanced status endpoint
app.get("/status", async (req, res) => {
try {
// Fetch multiple IPFS stats concurrently
const [bwResponse, repoResponse, idResponse] = await Promise.all([
axios.post(`${IPFS_API}/api/v0/stats/bw?interval=5m`, { timeout: 5000 }),
axios.post(`${IPFS_API}/api/v0/repo/stat`, { timeout: 5000 }),
axios.post(`${IPFS_API}/api/v0/id`, { timeout: 5000 }),
]);
// Format bandwidth data
const bandwidth = {
totalIn: bwResponse.data.TotalIn,
totalOut: bwResponse.data.TotalOut,
rateIn: bwResponse.data.RateIn,
rateOut: bwResponse.data.RateOut,
interval: "1h",
};
// Format repository stats
const repo = {
size: repoResponse.data.RepoSize,
storageMax: repoResponse.data.StorageMax,
numObjects: repoResponse.data.NumObjects,
path: repoResponse.data.RepoPath,
version: repoResponse.data.Version,
};
// Get GC configuration info
let gcInfo = {
enabled: true,
period: "200h",
lastRun: "Unknown",
};
try {
const configResponse = await axios.post(`${IPFS_API}/api/v0/config/show`, { timeout: 3000 });
if (configResponse.data && configResponse.data.Datastore) {
gcInfo.period = configResponse.data.Datastore.GCPeriod || "200h";
}
} catch (configErr) {
console.log("Could not fetch GC config:", configErr.message);
}
// Node identity info
const nodeInfo = {
id: idResponse.data.ID,
publicKey: idResponse.data.PublicKey,
addresses: idResponse.data.Addresses,
agentVersion: idResponse.data.AgentVersion,
protocolVersion: idResponse.data.ProtocolVersion,
};
const peersResponse = await axios.post(`${IPFS_API}/api/v0/swarm/peers`, {
timeout: 5000,
});
const connectedPeers = {
count: peersResponse.data.Peers.length,
list: peersResponse.data.Peers,
};
// Get app version from package.json
const { version: appVersion } = require("./package.json");
// Format file size limit in human readable form
const formatBytes = (bytes) => {
const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
if (bytes === 0) return "0 Bytes";
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return (bytes / Math.pow(1024, i)).toFixed(2) + " " + sizes[i];
};
res.json({
status: "success",
timestamp: new Date().toISOString(),
bandwidth,
repository: repo,
node: nodeInfo,
peers: connectedPeers,
garbageCollection: gcInfo,
storageLimit: {
configured: STORAGE_MAX,
current: formatBytes(repo.storageMax),
},
appVersion,
});
} catch (err) {
console.error("Status check error:", {
message: err.message,
stack: err.stack,
timestamp: new Date().toISOString(),
});
res.status(503).json({
error: "Failed to retrieve IPFS status",
details: err.message,
status: "failed",
timestamp: new Date().toISOString(),
});
}
});
// Upload endpoint
app.post("/upload", upload.single("file"), async (req, res) => {
let filePath = null;
try {
// Validate file presence
if (!req.file) {
return res.status(400).json({
error: "No file uploaded",
status: "error",
message: "No file uploaded",
timestamp: new Date().toISOString(),
});
}
filePath = req.file.path;
// Prepare file for IPFS using stream instead of buffer
const formData = new FormData();
const fileStream = fs.createReadStream(filePath);
formData.append("file", fileStream, {
filename: req.file.originalname,
contentType: req.file.mimetype,
knownLength: req.file.size,
});
// Upload to IPFS
const uploadStart = Date.now();
const response = await axios.post(`${IPFS_API}/api/v0/add`, formData, {
headers: { ...formData.getHeaders() },
timeout: 60000, // Increased to 60s for large files
maxContentLength: Infinity,
maxBodyLength: Infinity,
});
// Clean up temp file after successful upload
await unlinkAsync(filePath).catch((err) => console.warn("Failed to delete temp file:", err.message));
// Detailed logging
const uploadDetails = {
name: req.file.originalname,
size_bytes: req.file.size,
mime_type: req.file.mimetype,
cid: response.data.Hash,
upload_duration_ms: Date.now() - uploadStart,
timestamp: new Date().toISOString(),
};
console.log("File uploaded successfully:", uploadDetails);
// NIP-96 compatible response with your existing fields
res.json({
// NIP-96 required fields
status: "success",
message: "Upload successful.",
nip94_event: {
tags: [
["url", `https://dweb.link/ipfs/${response.data.Hash}`],
["m", req.file.mimetype || "application/octet-stream"],
["ox", response.data.Hash], // Original file hash (required)
["x", response.data.Hash], // Transformed file hash (same as original since no transformation)
["size", req.file.size.toString()],
],
content: "", // Required field, can be empty
},
// Your existing fields for compatibility
cid: response.data.Hash,
filename: req.file.originalname,
size: req.file.size,
details: uploadDetails,
});
} catch (err) {
// Clean up temp file on error
if (filePath) {
await unlinkAsync(filePath).catch((cleanupErr) => console.warn("Failed to delete temp file on error:", cleanupErr.message));
}
if (err instanceof multer.MulterError) {
return res.status(400).json({
error: err.message,
status: "error",
message: err.message,
timestamp: new Date().toISOString(),
});
}
console.error("IPFS upload error:", {
message: err.message,
stack: err.stack,
timestamp: new Date().toISOString(),
});
res.status(500).json({
error: "Failed to upload to IPFS",
details: err.message,
status: "error",
message: "Failed to upload to IPFS",
timestamp: new Date().toISOString(),
});
}
});
// Apply error handler
app.use(errorHandler);
// Start server
app.listen(PORT, HOST, () => {
console.log(`Server running on http://${HOST}:${PORT}`);
console.log(`IPFS API endpoint: ${IPFS_API}`);
});

1179
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

28
package.json Normal file
View File

@@ -0,0 +1,28 @@
{
"name": "file-drop",
"version": "1.1.1",
"description": "file drop",
"main": "app.js",
"scripts": {
"test": "podman run --name ipfs-host --rm --network host docker.io/ipfs/kubo:latest",
"start": "node app.js"
},
"repository": {
"type": "git",
"url": "git+https://github.com/besoeasy/file-drop.git"
},
"author": "besoeasy",
"license": "ISC",
"bugs": {
"url": "https://github.com/besoeasy/file-drop/issues"
},
"homepage": "https://github.com/besoeasy/file-drop#readme",
"dependencies": {
"axios": "^1.8.4",
"compression": "^1.8.1",
"cors": "^2.8.5",
"express": "^4.21.2",
"form-data": "^4.0.2",
"multer": "^1.4.5-lts.2"
}
}

709
public/index.html Normal file
View File

@@ -0,0 +1,709 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>File Drop</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet" />
<style>
/* Import modern font */
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap");
body {
font-family: "Inter", sans-serif;
}
/* Enhanced animations */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideInLeft {
from {
opacity: 0;
transform: translateX(-30px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes pulse {
0%,
100% {
transform: scale(1);
}
50% {
transform: scale(1.05);
}
}
@keyframes shimmer {
0% {
background-position: -200px 0;
}
100% {
background-position: calc(200px + 100%) 0;
}
}
.animate-fade-in {
animation: fadeIn 0.6s ease-out;
}
.animate-slide-in-left {
animation: slideInLeft 0.5s ease-out;
}
.animate-pulse-gentle {
animation: pulse 2s ease-in-out infinite;
}
.shimmer {
background: linear-gradient(90deg, #000 25%, #333 37%, #000 63%);
background-size: 400% 100%;
animation: shimmer 1.5s ease-in-out infinite;
}
/* Enhanced glassmorphism */
.glassmorphism {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(20px);
border: 1px solid rgba(0, 0, 0, 0.1);
}
.glassmorphism-dark {
background: rgba(0, 0, 0, 0.95);
backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.1);
}
/* Tab styles */
.tab-button {
transition: all 0.3s ease;
border-bottom: 2px solid transparent;
}
.tab-button.active {
border-bottom-color: #000;
background: #f9f9f9;
}
.tab-button:hover:not(.active) {
background: #f5f5f5;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
background: #888;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #555;
}
/* Enhanced hover effects */
.hover-lift {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.hover-lift:hover {
transform: translateY(-2px);
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
}
/* Status indicators */
.status-indicator {
position: relative;
}
.status-indicator::before {
content: "";
position: absolute;
top: 0;
right: 0;
width: 8px;
height: 8px;
background: #10b981;
border-radius: 50%;
animation: pulse 2s ease-in-out infinite;
}
</style>
</head>
<body class="min-h-screen bg-gray-100 flex items-center justify-center p-4">
<div id="app" class="w-full max-w-6xl glassmorphism rounded-2xl shadow-xl overflow-hidden hover-lift" tabindex="0"
@paste="handlePaste" @keydown.prevent>
<!-- Header -->
<header class="bg-black p-6 text-white text-center">
<div class="flex items-center justify-center gap-3 mb-2">
<i class="fas fa-cloud-upload-alt text-2xl"></i>
<h1 class="text-3xl font-bold">File Drop</h1>
</div>
<p class="text-gray-300 text-sm"> Anonymous File Sharing</p>
</header>
<div class="flex flex-col md:flex-row">
<!-- Sidebar: Status Panel -->
<aside class="w-full md:w-80 bg-gray-50 p-6 border-r border-gray-200">
<div class="flex items-center gap-2 mb-6">
<div class="status-indicator">
<i class="fas fa-server text-xl text-black"></i>
</div>
<h2 class="text-xl font-semibold text-black">Node Status</h2>
</div>
<div class="space-y-4 text-sm">
<div class="bg-white rounded-lg p-3 shadow-sm border border-gray-200">
<p class="text-gray-500 font-medium text-xs uppercase">Node ID</p>
<p class="text-black truncate font-mono text-sm">{{ status.nodeId }}</p>
</div>
<div class="bg-white rounded-lg p-3 shadow-sm border border-gray-200">
<p class="text-gray-500 font-medium text-xs uppercase">Connected Peers</p>
<p class="text-black font-semibold">{{ status.peerscount }} Nodes</p>
</div>
<div class="bg-white rounded-lg p-3 shadow-sm border border-gray-200">
<p class="text-gray-500 font-medium text-xs uppercase">Bandwidth (1h)</p>
<p class="text-black font-mono text-sm">{{ status.bandwidth }}</p>
</div>
<div class="bg-white rounded-lg p-3 shadow-sm border border-gray-200">
<p class="text-gray-500 font-medium text-xs uppercase">Repository Size</p>
<p class="text-black font-semibold">{{ status.repoSize }}</p>
</div>
<div class="bg-white rounded-lg p-3 shadow-sm border border-gray-200">
<p class="text-gray-500 font-medium text-xs uppercase">Objects</p>
<p class="text-black font-semibold">{{ status.repoObjects }}</p>
</div>
<div class="bg-white rounded-lg p-3 shadow-sm border border-gray-200">
<p class="text-gray-500 font-medium text-xs uppercase">Version</p>
<p class="text-black font-mono text-sm">{{ status.version }}</p>
</div>
<div class="bg-white rounded-lg p-3 shadow-sm border border-gray-200">
<p class="text-gray-500 font-medium text-xs uppercase">Storage Limit</p>
<p class="text-black font-semibold">{{ status.storageLimit }}</p>
</div>
<div class="bg-white rounded-lg p-3 mt-4 border border-gray-200">
<p class="text-gray-500 text-xs font-medium">Last updated: {{ status.timestamp }}</p>
</div>
</div>
</aside>
<!-- Main Content -->
<main class="flex-1 p-8">
<div class="space-y-6">
<!-- File Drop Zone -->
<div class="relative">
<label for="fileInput"
class="block w-full bg-gray-50 rounded-xl p-12 text-center cursor-pointer border-2 border-dashed border-gray-300 transition-all duration-300 hover:bg-gray-100 hover:border-black group"
@dragover.prevent="isDragOver = true" @dragleave.prevent="isDragOver = false"
@drop.prevent="handleDrop($event)" :class="{
'bg-gray-100 border-black': isDragOver,
'bg-green-50 border-green-500': isPasted
}">
<div class="flex flex-col items-center gap-4">
<i
class="fas fa-cloud-upload-alt text-4xl text-gray-400 group-hover:text-black transition-colors duration-300"></i>
<div>
<span
class="text-lg font-semibold text-gray-700 group-hover:text-black transition-colors duration-300">
{{ fileLabel }} </span>
<p class="text-sm text-gray-500 mt-2">Drag and drop your file here, click to browse, or paste anywhere
on the page</p>
</div>
</div>
</label>
<input type="file" id="fileInput" class="hidden" @change="updateFileLabel" />
</div>
<!-- Upload Button -->
<button @click="uploadFile" :disabled="uploading || !file"
class="w-full bg-black text-white py-3 rounded-xl font-semibold transition-all duration-300 hover:bg-gray-800 disabled:opacity-50 disabled:cursor-not-allowed">
<div class="flex items-center justify-center gap-2">
<i class="fas fa-upload" :class="{ 'animate-pulse': uploading }"></i>
<span v-if="!uploading">Upload</span>
<span v-else>Uploading...</span>
</div>
</button>
<!-- Progress -->
<div v-if="uploading" class="text-center animate-fade-in">
<div class="bg-white rounded-lg p-6 shadow-lg border border-gray-200">
<div class="flex items-center justify-center gap-3 mb-4">
<div class="animate-spin">
<i class="fas fa-circle-notch text-2xl text-black"></i>
</div>
<p class="text-black font-semibold text-lg">Uploading to IPFS...</p>
</div>
<div class="w-full bg-gray-200 rounded-full h-3 mb-4">
<div class="bg-black h-3 rounded-full transition-all duration-300 ease-out"
:style="{ width: uploadProgress + '%' }"></div>
</div>
<div class="flex justify-between items-center text-sm text-gray-600 mb-2">
<span>{{ uploadProgress.toFixed(1) }}% complete</span>
<span v-if="uploadProgress < 100">Uploading...</span>
<span v-else>Processing...</span>
</div>
<p class="text-gray-600 text-sm">Your file is being distributed across the anonymous network</p>
</div>
</div>
<!-- Result -->
<div v-if="result" class="mt-6 animate-fade-in">
<div :class="[
'p-6 rounded-xl shadow-lg border',
result.success ? 'bg-white border-gray-200' : 'bg-gray-50 border-gray-300'
]">
<template v-if="result.success">
<!-- Success Status - Moved to top -->
<div class="flex items-center gap-3 mb-6 bg-green-50 border border-green-200 rounded-lg p-4">
<div class="flex-shrink-0">
<i class="fas fa-check-circle text-green-600 text-2xl"></i>
</div>
<div>
<p class="font-semibold text-lg text-green-800">{{ result.message }}</p>
<p class="text-sm text-green-600 mt-1">Your file has been successfully uploaded to IPFS</p>
</div>
</div>
<!-- Tab Navigation -->
<div class="flex border-b border-gray-200 mb-4">
<button @click="activeTab = 'details'"
:class="['tab-button px-4 py-2 text-sm font-medium', activeTab === 'details' ? 'active' : 'text-gray-600 hover:text-gray-800']">
File Details
</button>
<button @click="activeTab = 'urls'"
:class="['tab-button px-4 py-2 text-sm font-medium', activeTab === 'urls' ? 'active' : 'text-gray-600 hover:text-gray-800']">
Access URLs
</button>
<button v-if="result.fileType" @click="activeTab = 'embed'"
:class="['tab-button px-4 py-2 text-sm font-medium', activeTab === 'embed' ? 'active' : 'text-gray-600 hover:text-gray-800']">
Embed Codes
</button>
</div>
<!-- Tab Content -->
<div class="min-h-[200px]" v-if="result.fileName">
<!-- File Details Tab -->
<div v-if="activeTab === 'details'" class="space-y-3">
<div class="grid grid-cols-2 gap-3 text-sm">
<div class="bg-gray-50 rounded p-3">
<p class="text-gray-500 text-xs font-medium uppercase">File Name</p>
<p class="text-black font-mono text-sm truncate" :title="result.fileName">{{ result.fileName }}
</p>
</div>
<div class="bg-gray-50 rounded p-3">
<p class="text-gray-500 text-xs font-medium uppercase">File Size</p>
<p class="text-black font-semibold">{{ result.size }}</p>
</div>
<div class="bg-gray-50 rounded p-3">
<p class="text-gray-500 text-xs font-medium uppercase">MIME Type</p>
<p class="text-black font-mono text-sm">{{ result.mimeType }}</p>
</div>
<div class="bg-gray-50 rounded p-3">
<p class="text-gray-500 text-xs font-medium uppercase">Media Type</p>
<p class="text-black font-semibold">
<span v-if="result.fileType"
class="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-gray-200 text-black">
{{ result.fileType }}
</span>
<span v-else
class="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-gray-200 text-black">
file </span>
</p>
</div>
<div class="col-span-2 bg-gray-50 rounded p-3">
<p class="text-gray-500 text-xs font-medium uppercase">Content Identifier (CID)</p>
<p
class="text-black font-mono text-sm break-all bg-white rounded px-2 py-1 border border-gray-200 mt-1">
{{ result.cid }}</p>
</div>
</div>
</div>
<!-- Access URLs Tab -->
<div v-if="activeTab === 'urls'" class="space-y-3">
<div class="space-y-3">
<div>
<label class="text-xs text-gray-600 font-medium block mb-1">IPFS Protocol</label>
<input type="text" :value="getIpfsUrlWithFilename(result.cid, result.fileName)" readonly
class="w-full px-3 py-2 text-sm border border-gray-300 rounded bg-gray-50 font-mono text-black cursor-pointer hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-black"
@click="$event.target.select()" />
</div>
<div>
<label class="text-xs text-gray-600 font-medium block mb-1">IPFS.io Gateway</label>
<input type="text"
:value="getIpfsUrlWithFilename(result.cid, result.fileName, 'https://ipfs.io/ipfs/')" readonly
class="w-full px-3 py-2 text-sm border border-gray-300 rounded bg-gray-50 font-mono text-black cursor-pointer hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-black"
@click="$event.target.select()" />
</div>
<div>
<label class="text-xs text-gray-600 font-medium block mb-1">Dweb.link Gateway</label>
<input type="text"
:value="getIpfsUrlWithFilename(result.cid, result.fileName, 'https://dweb.link/ipfs/')"
readonly
class="w-full px-3 py-2 text-sm border border-gray-300 rounded bg-gray-50 font-mono text-black cursor-pointer hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-black"
@click="$event.target.select()" />
</div>
<div>
<label class="text-xs text-gray-600 font-medium block mb-1">Network Status</label>
<div class="flex gap-2">
<a :href="`https://check.ipfs.network/?cid=${result.cid}`" target="_blank"
class="inline-flex items-center gap-2 px-4 py-2 bg-black text-white rounded font-medium text-sm hover:bg-gray-800 transition-colors duration-300">
Check Network Propagation
</a>
<a :href="getIpfsUrlWithFilename(result.cid, result.fileName, 'https://dweb.link/ipfs/')"
target="_blank"
class="inline-flex items-center gap-2 px-4 py-2 bg-gray-600 text-white rounded font-medium text-sm hover:bg-gray-700 transition-colors duration-300">
<i class="fas fa-external-link-alt"></i>
Open in Dweb.link
</a>
</div>
</div>
</div>
</div>
<!-- Embed Codes Tab -->
<div v-if="activeTab === 'embed' && result.fileType" class="space-y-4">
<div>
<label class="text-xs text-gray-600 font-medium block mb-1">HTML</label>
<textarea :value="getHtmlEmbed(result.cid, result.fileType)" readonly rows="3"
class="w-full px-3 py-2 text-sm border border-gray-300 rounded bg-gray-50 font-mono text-black cursor-pointer hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-black resize-none"
@click="$event.target.select()"></textarea>
</div>
<div>
<label class="text-xs text-gray-600 font-medium block mb-1">BBCode</label>
<textarea :value="getBBCodeEmbed(result.cid, result.fileType)" readonly rows="2"
class="w-full px-3 py-2 text-sm border border-gray-300 rounded bg-gray-50 font-mono text-black cursor-pointer hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-black resize-none"
@click="$event.target.select()"></textarea>
</div>
</div>
</div>
</template>
<!-- Error Status -->
<template v-else>
<div class="flex items-center gap-3 bg-red-50 border border-red-200 rounded-lg p-4">
<div class="flex-shrink-0">
<i class="fas fa-exclamation-circle text-red-600 text-2xl"></i>
</div>
<div>
<p class="font-semibold text-lg text-red-800">{{ result.message }}</p>
<p class="text-sm text-red-600 mt-1">Please try uploading your file again</p>
</div>
</div>
</template>
</div>
</div>
</div>
</main>
</div>
<footer class="bg-black p-4 text-center">
<div class="flex items-center justify-center gap-4">
<a href="https://github.com/besoeasy/file-drop" target="_blank"
class="flex items-center gap-2 text-white hover:text-gray-300 transition-colors duration-300">
<i class="fab fa-github"></i>
<span class="font-semibold">GitHub</span>
</a>
<span class="text-gray-400"></span>
<span class="text-gray-300 font-mono text-sm">v{{ status.appver }}</span>
</div>
</footer>
</div>
<script>
const { createApp } = Vue;
createApp({
data() {
return {
file: null,
fileLabel: "Drop or Choose File",
uploading: false,
uploadProgress: 0,
result: null,
isDragOver: false,
isPasted: false,
activeTab: "details",
status: {
nodeId: "Loading...",
bandwidth: "Loading...",
repoSize: "Loading...",
repoObjects: "Loading...",
version: "Loading...",
appver: "Loading...",
timestamp: "Loading...",
peerscount: 0,
storageLimit: "Loading...",
},
};
},
methods: {
formatBytes(bytes) {
const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
if (bytes === 0) return "0 Bytes";
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return (bytes / Math.pow(1024, i)).toFixed(2) + " " + sizes[i];
},
updateFileLabel(event) {
const fileInput = event.target;
this.file = fileInput.files[0];
this.fileLabel = this.file ? this.file.name : "Drop or Choose File";
},
handleDrop(event) {
this.isDragOver = false;
this.file = event.dataTransfer.files[0];
this.fileLabel = this.file ? this.file.name : "Drop or Choose File";
},
handlePaste(event) {
console.log("Paste event triggered", event);
// Check if clipboard contains files
const items = event.clipboardData?.items;
if (!items) {
console.log("No clipboard items found");
return;
}
console.log("Clipboard items:", items.length);
for (let i = 0; i < items.length; i++) {
const item = items[i];
console.log("Item", i, ":", item.kind, item.type);
if (item.kind === "file") {
event.preventDefault();
const file = item.getAsFile();
console.log("File pasted:", file);
if (file) {
this.file = file;
this.fileLabel = file.name || "Pasted File";
// Show visual feedback for paste
this.isPasted = true;
setTimeout(() => {
this.isPasted = false;
}, 1000);
console.log("File set successfully:", this.file.name);
break;
}
}
}
},
getFileTypeIcon(fileType) {
switch (fileType) {
case "audio":
return "fas fa-music";
case "video":
return "fas fa-video";
case "image":
return "fas fa-image";
default:
return "fas fa-file";
}
},
getFileType(mimeType) {
if (mimeType.startsWith("audio/")) return "audio";
if (mimeType.startsWith("video/")) return "video";
if (mimeType.startsWith("image/")) return "image";
return null;
},
getHtmlEmbed(cid, fileType) {
const url = `https://ipfs.io/ipfs/${cid}`;
switch (fileType) {
case "audio":
return `<audio controls>\n <source src="${url}" type="audio/mpeg">\n Your browser does not support the audio element.\n</audio>`;
case "video":
return `<video controls width="640" height="360">\n <source src="${url}" type="video/mp4">\n Your browser does not support the video element.\n</video>`;
case "image":
return `<img src="${url}" alt="IPFS Image" />`;
default:
return `<a href="${url}">Download File</a>`;
}
},
getBBCodeEmbed(cid, fileType) {
const url = `https://ipfs.io/ipfs/${cid}`;
switch (fileType) {
case "audio":
return `[audio]${url}[/audio]`;
case "video":
return `[video]${url}[/video]`;
case "image":
return `[img]${url}[/img]`;
default:
return `[url=${url}]Download File[/url]`;
}
},
getIpfsUrlWithFilename(cid, filename, gateway = '') {
if (!filename) return gateway ? `${gateway}${cid}` : `ipfs://${cid}`;
const encodedFilename = encodeURIComponent(filename);
if (gateway) {
return `${gateway}${cid}?filename=${encodedFilename}`;
}
return `ipfs://${cid}?filename=${encodedFilename}`;
},
async uploadFile() {
if (!this.file) {
this.result = { success: false, message: "Please select a file" };
return;
}
this.uploading = true;
this.uploadProgress = 0;
this.result = null;
const formData = new FormData();
formData.append("file", this.file);
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
// Track upload progress
xhr.upload.addEventListener("progress", (event) => {
if (event.lengthComputable) {
this.uploadProgress = (event.loaded / event.total) * 100;
}
});
// Handle completion
xhr.addEventListener("load", () => {
this.uploading = false;
this.uploadProgress = 100;
try {
const data = JSON.parse(xhr.responseText);
if (data.status === "success") {
this.result = {
success: true,
message: "Upload Successful",
cid: data.cid,
size: this.formatBytes(data.size),
fileType: this.getFileType(this.file.type),
fileName: this.file.name,
mimeType: this.file.type || "application/octet-stream",
};
} else {
this.result = {
success: false,
message: `Error: ${data.error}`,
};
}
resolve(data);
} catch (error) {
this.result = {
success: false,
message: "Upload failed. Invalid response from server.",
};
reject(error);
}
});
// Handle errors
xhr.addEventListener("error", () => {
this.uploading = false;
this.uploadProgress = 0;
this.result = {
success: false,
message: "Upload failed. Please check your connection and try again.",
};
reject(new Error("Upload failed"));
});
// Handle abort
xhr.addEventListener("abort", () => {
this.uploading = false;
this.uploadProgress = 0;
this.result = {
success: false,
message: "Upload was cancelled.",
};
reject(new Error("Upload cancelled"));
});
// Start the upload
xhr.open("POST", "/upload");
xhr.send(formData);
});
},
async fetchStatus() {
try {
const response = await fetch("/status");
const data = await response.json();
if (data.status === "success") {
this.status = {
nodeId: data.node.id.slice(0, 6) + "..." + data.node.id.slice(-6),
bandwidth: `In: ${this.formatBytes(data.bandwidth.totalIn)} | Out: ${this.formatBytes(data.bandwidth.totalOut)}`,
repoSize: `${this.formatBytes(data.repository.size)} / ${this.formatBytes(data.repository.storageMax)}`,
repoObjects: `${data.repository.numObjects}`,
version: `${data.node.agentVersion}`,
appver: `${data.appVersion}`,
timestamp: new Date(data.timestamp).toLocaleTimeString(),
peerscount: data.peers.count,
storageLimit: data.storageLimit?.configured || "Unknown",
};
}
} catch (error) {
this.status = {
nodeId: "Error",
bandwidth: "Error loading",
repoSize: "Error loading",
repoObjects: "Error",
version: "Error",
appver: "Error",
timestamp: "Error",
peerscount: 0,
storageLimit: "Error",
};
}
},
},
mounted() {
this.fetchStatus();
setInterval(this.fetchStatus, 10000);
// Add global paste event listener
document.addEventListener("paste", this.handlePaste);
// Focus the app to enable paste events
this.$el.focus();
},
beforeUnmount() {
// Clean up event listener
document.removeEventListener("paste", this.handlePaste);
},
}).mount("#app");
</script>
</body>
</html>