| import os |
| import shutil |
| import uuid |
| import string |
| import random |
| import zipfile |
| import io |
| from datetime import datetime, timedelta |
| from pathlib import Path |
| from contextlib import asynccontextmanager |
|
|
| from fastapi import FastAPI, UploadFile, File, HTTPException, Request, Form, Query |
| from fastapi.responses import HTMLResponse, FileResponse, StreamingResponse |
| import aiofiles |
| from apscheduler.schedulers.background import BackgroundScheduler |
|
|
| |
| BASE_DIR = Path("/tmp/file_sharer") |
| UPLOAD_DIR = BASE_DIR / "uploads" |
| CHUNK_DIR = BASE_DIR / "chunks" |
| SQLITE_DB = BASE_DIR / "database.db" |
|
|
| BASE_DIR.mkdir(parents=True, exist_ok=True) |
| UPLOAD_DIR.mkdir(exist_ok=True) |
| CHUNK_DIR.mkdir(exist_ok=True) |
|
|
| CHUNK_SIZE = 5 * 1024 * 1024 |
| MAX_FILE_SIZE = 50 * 1024 * 1024 * 1024 |
|
|
| |
| import sqlite3 |
|
|
| def init_db(): |
| conn = sqlite3.connect(str(SQLITE_DB)) |
| conn.execute(""" |
| CREATE TABLE IF NOT EXISTS uploads ( |
| id TEXT PRIMARY KEY, |
| short_code TEXT UNIQUE, |
| delete_token TEXT UNIQUE, |
| total_size INTEGER DEFAULT 0, |
| file_count INTEGER DEFAULT 0, |
| created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, |
| expires_at TIMESTAMP DEFAULT (datetime('now', '+7 days')), |
| is_complete INTEGER DEFAULT 0, |
| is_deleted INTEGER DEFAULT 0 |
| ) |
| """) |
| conn.execute(""" |
| CREATE TABLE IF NOT EXISTS files ( |
| id INTEGER PRIMARY KEY AUTOINCREMENT, |
| upload_id TEXT, |
| original_name TEXT, |
| stored_name TEXT, |
| size INTEGER, |
| FOREIGN KEY(upload_id) REFERENCES uploads(id) |
| ) |
| """) |
| conn.commit() |
| conn.close() |
|
|
| def get_conn(): |
| conn = sqlite3.connect(str(SQLITE_DB)) |
| conn.row_factory = sqlite3.Row |
| return conn |
|
|
| def generate_short_code(length=6): |
| chars = string.ascii_lowercase + string.digits |
| while True: |
| code = ''.join(random.choices(chars, k=length)) |
| if not get_conn().execute("SELECT 1 FROM uploads WHERE short_code=?", (code,)).fetchone(): |
| return code |
|
|
| |
| async def cleanup_expired(): |
| conn = get_conn() |
| expired = conn.execute( |
| "SELECT id FROM uploads WHERE expires_at < datetime('now') AND is_deleted=0" |
| ).fetchall() |
| for row in expired: |
| await delete_upload(row['id'], conn=conn, manual=False) |
| conn.close() |
|
|
| async def delete_upload(upload_id: str, conn=None, manual: bool = True): |
| if conn is None: |
| conn = get_conn() |
| own_conn = True |
| else: |
| own_conn = False |
| try: |
| upload_path = UPLOAD_DIR / upload_id |
| chunk_path = CHUNK_DIR / upload_id |
| for path in (upload_path, chunk_path): |
| if path.exists(): |
| shutil.rmtree(path, ignore_errors=True) |
| conn.execute("UPDATE uploads SET is_deleted=1 WHERE id=?", (upload_id,)) |
| conn.execute("DELETE FROM files WHERE upload_id=?", (upload_id,)) |
| conn.commit() |
| finally: |
| if own_conn: |
| conn.close() |
|
|
| |
| @asynccontextmanager |
| async def lifespan(app: FastAPI): |
| init_db() |
| scheduler = BackgroundScheduler() |
| scheduler.add_job(cleanup_expired, 'interval', hours=1) |
| scheduler.start() |
| app.state.scheduler = scheduler |
| yield |
| scheduler.shutdown() |
|
|
| app = FastAPI(title="Free File Sharing", lifespan=lifespan) |
|
|
| |
| def error_html(message: str, status_code: int = 404) -> str: |
| return f""" |
| <!DOCTYPE html> |
| <html> |
| <head> |
| <title>Error {status_code}</title> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <style> |
| body {{ |
| font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; |
| background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); |
| min-height: 100vh; |
| display: flex; |
| justify-content: center; |
| align-items: center; |
| margin: 0; |
| padding: 20px; |
| }} |
| .error-card {{ |
| background: white; |
| border-radius: 20px; |
| padding: 40px; |
| text-align: center; |
| box-shadow: 0 20px 60px rgba(0,0,0,0.3); |
| max-width: 400px; |
| }} |
| h1 {{ |
| font-size: 5em; |
| color: #ff5252; |
| margin: 0; |
| }} |
| p {{ |
| color: #555; |
| font-size: 1.1em; |
| margin: 20px 0; |
| }} |
| a {{ |
| color: #667eea; |
| text-decoration: none; |
| font-weight: bold; |
| }} |
| </style> |
| </head> |
| <body> |
| <div class="error-card"> |
| <h1>{status_code}</h1> |
| <p>{message}</p> |
| <a href="/">← Back to main page</a> |
| </div> |
| </body> |
| </html> |
| """ |
|
|
| |
| @app.get("/", response_class=HTMLResponse) |
| async def index(): |
| return HTMLResponse(""" |
| <!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>Free File Sharing</title> |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script> |
| <script src="https://cdn.jsdelivr.net/npm/qrcodejs@1.0.0/qrcode.min.js"></script> |
| <style> |
| * { box-sizing: border-box; margin: 0; padding: 0; } |
| body { |
| font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; |
| background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); |
| min-height: 100vh; |
| display: flex; |
| justify-content: center; |
| align-items: center; |
| padding: 20px; |
| } |
| .container { width: 100%; max-width: 560px; } |
| .card { |
| background: white; |
| border-radius: 20px; |
| box-shadow: 0 20px 60px rgba(0,0,0,0.3); |
| padding: 40px 30px; |
| } |
| h1 { text-align: center; color: #333; margin-bottom: 15px; font-size: 2em; } |
| .logo { |
| margin: 0 auto 20px; |
| width: 80px; height: 80px; |
| background: linear-gradient(135deg, #667eea, #764ba2); |
| border-radius: 20px; |
| display: flex; align-items: center; justify-content: center; |
| font-size: 2.5em; color: white; |
| box-shadow: 0 5px 15px rgba(0,0,0,0.2); |
| } |
| .drop-zone { |
| border: 2px dashed #ccc; border-radius: 15px; padding: 30px; |
| text-align: center; background: #f9f9ff; margin: 25px 0; |
| transition: all 0.3s; cursor: pointer; |
| } |
| .drop-zone.dragover { border-color: #667eea; background: #eef0ff; transform: scale(1.02); } |
| .drop-zone p { color: #555; font-size: 0.95em; } |
| .drop-zone label { color: #667eea; font-weight: bold; text-decoration: underline; cursor: pointer; } |
| .progress-container { margin: 20px 0; } |
| .progress-bar-wrapper { background: #e0e0e0; border-radius: 20px; overflow: hidden; height: 25px; margin: 10px 0; } |
| .progress-fill { width: 0%; height: 100%; background: linear-gradient(90deg, #667eea, #764ba2); border-radius: 20px; transition: width 0.2s; } |
| .progress-text { display: flex; justify-content: space-between; font-size: 0.9em; color: #555; margin-bottom: 5px; } |
| .time-remaining { text-align: center; font-size: 0.9em; color: #555; } |
| .btn { |
| display: inline-block; padding: 12px 25px; border: none; border-radius: 30px; |
| font-weight: bold; cursor: pointer; transition: all 0.2s; background: #f0f0f0; color: #333; |
| } |
| .btn-primary { background: linear-gradient(135deg, #667eea, #764ba2); color: white; box-shadow: 0 4px 10px rgba(102,126,234,0.4); } |
| .btn-primary:hover { transform: translateY(-2px); box-shadow: 0 6px 15px rgba(102,126,234,0.6); } |
| .btn-danger { background: #ff5252; color: white; } |
| .btn-danger:hover { background: #ff1744; } |
| .result-box { background: #f0fff0; border-radius: 15px; padding: 20px; margin: 20px 0; border: 1px solid #b8e6b8; text-align: center; } |
| .share-link { |
| display: flex; align-items: center; justify-content: space-between; |
| background: white; border-radius: 30px; padding: 8px 20px; margin: 10px 0; border: 1px solid #ccc; |
| } |
| .share-link a { color: #333; text-decoration: none; font-weight: 500; word-break: break-all; } |
| .copy-btn { background: none; border: none; color: #667eea; cursor: pointer; font-size: 1.2em; margin-left: 10px; } |
| .copy-btn:hover { color: #764ba2; } |
| .hidden { display: none; } |
| .error-message { color: #ff5252; text-align: center; margin-top: 10px; } |
| .success-message { color: #2e7d32; text-align: center; margin-top: 10px; } |
| #qrcode { margin-top: 15px; display: inline-block; } |
| @media (max-width: 600px) { |
| .card { padding: 25px 20px; } |
| h1 { font-size: 1.5em; } |
| .logo { width: 60px; height: 60px; font-size: 2em; } |
| } |
| </style> |
| </head> |
| <body> |
| <div class="container"> |
| <div class="card"> |
| <div class="logo">📦</div> |
| <h1>File Sharing</h1> |
| <p style="text-align:center; color:#666;">Free, no registration, up to 50 GB</p> |
| |
| <div class="drop-zone" id="drop-zone"> |
| <p>📂 Drag & drop files or folders here</p> |
| <p>or <label for="file-input">choose files</label></p> |
| <input type="file" id="file-input" multiple hidden /> |
| </div> |
| |
| <div id="progress-container" class="progress-container hidden"> |
| <div class="progress-text"> |
| <span id="progress-label">Uploading... 0%</span> |
| <span id="progress-percent">0%</span> |
| </div> |
| <div class="progress-bar-wrapper"> |
| <div id="progress-fill" class="progress-fill" style="width:0%"></div> |
| </div> |
| <div id="time-remaining" class="time-remaining"></div> |
| <button id="cancel-btn" class="btn" style="margin-top:10px;">Cancel</button> |
| </div> |
| |
| <div id="result" class="result-box hidden"> |
| <p style="color:#2e7d32; font-weight:bold;">✅ Link ready:</p> |
| <div class="share-link"> |
| <a id="share-link" href="#" target="_blank"></a> |
| <button class="copy-btn" id="copy-btn" title="Copy link">📋</button> |
| </div> |
| <div id="qrcode"></div> |
| <p style="margin-top:15px; font-size:0.9em; color:#555;">Files available for 7 days</p> |
| <button id="delete-btn" class="btn btn-danger" style="margin-top:10px;">🗑 Delete now</button> |
| </div> |
| |
| <div id="message"></div> |
| </div> |
| </div> |
| |
| <script> |
| let currentUploadId = null; |
| let deleteToken = null; |
| let abortController = null; |
| let uploadStartTime = null; |
| |
| const dropZone = document.getElementById('drop-zone'); |
| const fileInput = document.getElementById('file-input'); |
| const progressContainer = document.getElementById('progress-container'); |
| const progressFill = document.getElementById('progress-fill'); |
| const progressPercent = document.getElementById('progress-percent'); |
| const progressLabel = document.getElementById('progress-label'); |
| const timeRemaining = document.getElementById('time-remaining'); |
| const cancelBtn = document.getElementById('cancel-btn'); |
| const resultDiv = document.getElementById('result'); |
| const shareLink = document.getElementById('share-link'); |
| const copyBtn = document.getElementById('copy-btn'); |
| const deleteBtn = document.getElementById('delete-btn'); |
| const messageDiv = document.getElementById('message'); |
| const qrDiv = document.getElementById('qrcode'); |
| |
| dropZone.addEventListener('dragover', (e) => { e.preventDefault(); dropZone.classList.add('dragover'); }); |
| dropZone.addEventListener('dragleave', () => dropZone.classList.remove('dragover')); |
| dropZone.addEventListener('drop', (e) => { |
| e.preventDefault(); |
| dropZone.classList.remove('dragover'); |
| const items = e.dataTransfer.items; |
| if (items) handleItems(items); |
| }); |
| dropZone.addEventListener('click', () => fileInput.click()); |
| |
| fileInput.addEventListener('change', (e) => { |
| if (e.target.files.length > 0) handleFileList(e.target.files); |
| }); |
| |
| async function handleItems(items) { |
| let files = []; |
| for (let i = 0; i < items.length; i++) { |
| const item = items[i].webkitGetAsEntry(); |
| if (item) await traverseEntry(item, '', files); |
| } |
| if (files.length > 0) handleFileList(files); |
| } |
| |
| function traverseEntry(entry, path, files) { |
| return new Promise((resolve) => { |
| if (entry.isFile) { |
| entry.file((file) => { |
| file.relativePath = path + file.name; |
| files.push(file); |
| resolve(); |
| }); |
| } else if (entry.isDirectory) { |
| const dirReader = entry.createReader(); |
| dirReader.readEntries(async (entries) => { |
| for (const ent of entries) { |
| await traverseEntry(ent, path + entry.name + '/', files); |
| } |
| resolve(); |
| }); |
| } |
| }); |
| } |
| |
| function handleFileList(fileList) { |
| const files = Array.from(fileList); |
| if (files.length === 0) return; |
| if (files.length === 1) { |
| uploadSingleFile(files[0]); |
| } else { |
| uploadAsZip(files); |
| } |
| } |
| |
| async function uploadSingleFile(file) { |
| if (file.size > 50 * 1024 * 1024 * 1024) { |
| showError(`File "${file.name}" is too large (>50 GB)`); |
| return; |
| } |
| const initRes = await fetch('/upload/init', { |
| method: 'POST', |
| headers: {'Content-Type': 'application/json'}, |
| body: JSON.stringify({ file_count: 1, total_size: file.size }) |
| }); |
| if (!initRes.ok) { showError('Initialization error'); return; } |
| const { upload_id, short_code, delete_token } = await initRes.json(); |
| prepareUI(upload_id, short_code, delete_token); |
| await uploadFileInChunks(file, file.relativePath || file.name, file.size); |
| await finishUpload(); |
| } |
| |
| async function uploadAsZip(files) { |
| const zip = new JSZip(); |
| let totalSize = 0; |
| for (const file of files) { |
| if (file.size > 50 * 1024 * 1024 * 1024) { |
| showError(`File "${file.name}" is too large (>50 GB)`); |
| return; |
| } |
| totalSize += file.size; |
| zip.file(file.relativePath || file.name, file); |
| } |
| let zipBlob; |
| try { |
| zipBlob = await zip.generateAsync({type: "blob"}); |
| } catch (e) { |
| showError('Error creating archive'); |
| return; |
| } |
| const initRes = await fetch('/upload/init', { |
| method: 'POST', |
| headers: {'Content-Type': 'application/json'}, |
| body: JSON.stringify({ file_count: 1, total_size: zipBlob.size }) |
| }); |
| if (!initRes.ok) { showError('Initialization error'); return; } |
| const { upload_id, short_code, delete_token } = await initRes.json(); |
| prepareUI(upload_id, short_code, delete_token); |
| const zipFile = new File([zipBlob], "archive.zip", {type: "application/zip"}); |
| await uploadFileInChunks(zipFile, "archive.zip", zipBlob.size); |
| await finishUpload(); |
| } |
| |
| function prepareUI(upload_id, short_code, delete_token) { |
| currentUploadId = upload_id; |
| deleteToken = delete_token; |
| shareLink.href = '/' + short_code; |
| shareLink.textContent = window.location.origin + '/' + short_code; |
| resultDiv.classList.remove('hidden'); |
| progressContainer.classList.remove('hidden'); |
| progressFill.style.width = '0%'; |
| progressPercent.textContent = '0%'; |
| progressLabel.textContent = 'Uploading... 0%'; |
| timeRemaining.textContent = 'Time remaining: calculating...'; |
| qrDiv.innerHTML = ''; |
| new QRCode(qrDiv, { |
| text: shareLink.textContent, |
| width: 128, |
| height: 128, |
| }); |
| } |
| |
| function formatSpeed(bytesPerSec) { |
| if (bytesPerSec === 0) return '0 KB/s'; |
| if (bytesPerSec < 1024) return bytesPerSec.toFixed(0) + ' B/s'; |
| const kb = bytesPerSec / 1024; |
| if (kb < 1024) return kb.toFixed(1) + ' KB/s'; |
| const mb = kb / 1024; |
| return mb.toFixed(1) + ' MB/s'; |
| } |
| |
| function formatTime(seconds) { |
| if (!isFinite(seconds) || seconds < 0) return '...'; |
| if (seconds < 60) return Math.round(seconds) + 's'; |
| const mins = Math.floor(seconds / 60); |
| const secs = Math.round(seconds % 60); |
| if (mins < 60) return mins + 'm ' + secs + 's'; |
| const hours = Math.floor(mins / 60); |
| const remainingMins = mins % 60; |
| return hours + 'h ' + remainingMins + 'm'; |
| } |
| |
| async function uploadFileInChunks(file, fileName, fileSize) { |
| const chunkCount = Math.ceil(fileSize / (5 * 1024 * 1024)); |
| abortController = new AbortController(); |
| let uploaded = 0; |
| uploadStartTime = Date.now(); |
| try { |
| for (let i = 0; i < chunkCount; i++) { |
| if (abortController.signal.aborted) throw new Error('Cancelled'); |
| const start = i * 5 * 1024 * 1024; |
| const end = Math.min(start + 5 * 1024 * 1024, fileSize); |
| const blob = file.slice(start, end); |
| const formData = new FormData(); |
| formData.append('chunk', blob, fileName); |
| formData.append('file_name', fileName); |
| formData.append('file_size', fileSize); |
| formData.append('chunk_index', i); |
| formData.append('total_chunks', chunkCount); |
| |
| const res = await fetch(`/upload/${currentUploadId}/${i}`, { |
| method: 'PUT', |
| body: formData, |
| signal: abortController.signal |
| }); |
| if (!res.ok) throw new Error('Error'); |
| uploaded += (end - start); |
| const percent = Math.round((uploaded / fileSize) * 100); |
| const elapsed = (Date.now() - uploadStartTime) / 1000; |
| const speed = uploaded / (elapsed || 1); |
| const remaining = (fileSize - uploaded) / (speed || 1); |
| progressFill.style.width = percent + '%'; |
| progressPercent.textContent = percent + '%'; |
| progressLabel.textContent = `Uploaded ${percent}% · ${formatSpeed(speed)}`; |
| timeRemaining.textContent = `Time remaining: ${formatTime(remaining)}`; |
| } |
| } catch (err) { |
| if (err.message !== 'Cancelled') showError(err.message); |
| progressContainer.classList.add('hidden'); |
| abortController = null; |
| throw err; |
| } |
| abortController = null; |
| } |
| |
| async function finishUpload() { |
| try { |
| await fetch(`/upload/${currentUploadId}/complete`, {method: 'POST'}); |
| progressContainer.classList.add('hidden'); |
| showSuccess('Upload complete!'); |
| } catch (e) { |
| showError('Error completing upload'); |
| } |
| } |
| |
| cancelBtn.addEventListener('click', () => { |
| if (abortController) { |
| abortController.abort(); |
| showError('Upload cancelled'); |
| progressContainer.classList.add('hidden'); |
| abortController = null; |
| } |
| }); |
| |
| deleteBtn.addEventListener('click', async () => { |
| if (!deleteToken) return; |
| if (confirm('Are you sure you want to delete all files?')) { |
| const res = await fetch('/delete/' + deleteToken, {method: 'DELETE'}); |
| if (res.ok) { |
| resultDiv.classList.add('hidden'); |
| showSuccess('Files deleted'); |
| } else { |
| showError('Deletion error'); |
| } |
| } |
| }); |
| |
| copyBtn.addEventListener('click', () => { |
| const link = shareLink.textContent; |
| navigator.clipboard.writeText(link).then(() => { |
| copyBtn.textContent = '✅'; |
| setTimeout(() => { copyBtn.textContent = '📋'; }, 2000); |
| }).catch(() => { |
| alert('Could not copy'); |
| }); |
| }); |
| |
| function showMessage(msg, isError = false) { |
| messageDiv.innerHTML = `<p class="${isError ? 'error-message' : 'success-message'}">${msg}</p>`; |
| } |
| function showError(msg) { showMessage(msg, true); } |
| function showSuccess(msg) { showMessage(msg, false); } |
| </script> |
| </body> |
| </html> |
| """) |
|
|
| |
| def format_size(size_bytes): |
| if size_bytes < 1024: |
| return f"{size_bytes} B" |
| elif size_bytes < 1024*1024: |
| return f"{size_bytes/1024:.1f} KB" |
| elif size_bytes < 1024*1024*1024: |
| return f"{size_bytes/(1024*1024):.1f} MB" |
| else: |
| return f"{size_bytes/(1024*1024*1024):.2f} GB" |
|
|
| @app.get("/{short_code}") |
| async def download_page(short_code: str, request: Request, direct: bool = Query(False)): |
| conn = get_conn() |
| upload = conn.execute( |
| "SELECT id, is_complete, is_deleted, created_at, expires_at FROM uploads WHERE short_code=?", |
| (short_code,) |
| ).fetchone() |
| if not upload or upload['is_deleted']: |
| conn.close() |
| return HTMLResponse(status_code=404, content=error_html("Link not found or files have been deleted.")) |
| if not upload['is_complete']: |
| conn.close() |
| return HTMLResponse("<h3>Files are still uploading, please try later</h3>", status_code=202) |
|
|
| files = conn.execute("SELECT original_name, size FROM files WHERE upload_id=?", (upload['id'],)).fetchall() |
| conn.close() |
|
|
| |
| if direct: |
| return await serve_direct_download(upload['id'], short_code, files) |
|
|
| |
| total_size = sum(f['size'] for f in files) |
| file_names = [f['original_name'] for f in files] |
| display_name = file_names[0] if len(files) == 1 else f"{len(files)} files" |
| created = upload['created_at'] |
| expires = upload['expires_at'] |
| |
| html = f""" |
| <!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>Download - {display_name}</title> |
| <script src="https://cdn.jsdelivr.net/npm/qrcodejs@1.0.0/qrcode.min.js"></script> |
| <style> |
| body {{ |
| font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; |
| background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); |
| min-height: 100vh; |
| display: flex; |
| justify-content: center; |
| align-items: center; |
| padding: 20px; |
| margin: 0; |
| }} |
| .download-card {{ |
| background: white; |
| border-radius: 20px; |
| padding: 30px; |
| max-width: 500px; |
| width: 100%; |
| box-shadow: 0 20px 60px rgba(0,0,0,0.3); |
| text-align: center; |
| }} |
| .file-icon {{ |
| font-size: 4em; |
| margin-bottom: 15px; |
| }} |
| h2 {{ |
| color: #333; |
| word-break: break-all; |
| margin-bottom: 15px; |
| }} |
| .info {{ |
| color: #555; |
| font-size: 0.95em; |
| margin: 10px 0; |
| }} |
| .btn-download {{ |
| display: inline-block; |
| padding: 15px 40px; |
| background: linear-gradient(135deg, #667eea, #764ba2); |
| color: white; |
| border: none; |
| border-radius: 30px; |
| font-weight: bold; |
| font-size: 1.1em; |
| cursor: pointer; |
| text-decoration: none; |
| transition: transform 0.2s, box-shadow 0.2s; |
| box-shadow: 0 4px 10px rgba(102,126,234,0.4); |
| margin: 15px 0; |
| }} |
| .btn-download:hover {{ |
| transform: translateY(-2px); |
| box-shadow: 0 6px 15px rgba(102,126,234,0.6); |
| }} |
| .qr-section {{ |
| margin-top: 20px; |
| position: relative; |
| }} |
| .qr-container {{ |
| display: inline-block; |
| cursor: pointer; |
| position: relative; |
| }} |
| .qr-hint {{ |
| position: absolute; |
| bottom: 100%; |
| left: 50%; |
| transform: translateX(-50%); |
| background: #333; |
| color: white; |
| padding: 5px 12px; |
| border-radius: 20px; |
| font-size: 0.8em; |
| white-space: nowrap; |
| opacity: 0; |
| transition: opacity 0.3s; |
| pointer-events: none; |
| }} |
| .qr-container:hover .qr-hint {{ |
| opacity: 1; |
| }} |
| .direct-link {{ |
| margin-top: 10px; |
| font-size: 0.9em; |
| color: #666; |
| }} |
| .direct-link a {{ |
| color: #667eea; |
| text-decoration: none; |
| }} |
| .back-link {{ |
| margin-top: 20px; |
| display: block; |
| color: #667eea; |
| text-decoration: none; |
| }} |
| </style> |
| </head> |
| <body> |
| <div class="download-card"> |
| <div class="file-icon">📁</div> |
| <h2>{display_name}</h2> |
| <div class="info">Size: {format_size(total_size)}</div> |
| <div class="info">Uploaded: {created}</div> |
| <div class="info">Expires: {expires}</div> |
| <a href="/dl/{short_code}" class="btn-download" id="downloadBtn">⬇ Download ({format_size(total_size)})</a> |
| <div class="qr-section"> |
| <div class="qr-container" id="qrContainer"> |
| <div id="qrcode"></div> |
| <div class="qr-hint">Click to switch to direct link</div> |
| </div> |
| </div> |
| <div class="direct-link"> |
| Direct: <a href="/dl/{short_code}" id="directLink">/dl/{short_code}</a> |
| </div> |
| <a href="/" class="back-link">← Upload more files</a> |
| </div> |
| <script> |
| const pageUrl = window.location.href.split('?')[0]; |
| const directUrl = pageUrl.replace(/\/[^\/]+$/, '/dl/{short_code}'); |
| let showingDirect = false; |
| const qrDiv = document.getElementById('qrcode'); |
| const qrContainer = document.getElementById('qrContainer'); |
| const directLink = document.getElementById('directLink'); |
| |
| function makeQR(url) {{ |
| qrDiv.innerHTML = ''; |
| new QRCode(qrDiv, {{ |
| text: url, |
| width: 128, |
| height: 128, |
| }}); |
| }} |
| |
| makeQR(pageUrl); |
| |
| qrContainer.addEventListener('click', () => {{ |
| showingDirect = !showingDirect; |
| if (showingDirect) {{ |
| makeQR(directUrl); |
| directLink.textContent = directUrl; |
| }} else {{ |
| makeQR(pageUrl); |
| directLink.textContent = '/dl/{short_code}'; |
| }} |
| }}); |
| </script> |
| </body> |
| </html> |
| """ |
| return HTMLResponse(html) |
|
|
| @app.get("/dl/{short_code}") |
| async def direct_download(short_code: str, request: Request): |
| conn = get_conn() |
| upload = conn.execute( |
| "SELECT id, is_complete, is_deleted FROM uploads WHERE short_code=?", |
| (short_code,) |
| ).fetchone() |
| if not upload or upload['is_deleted'] or not upload['is_complete']: |
| conn.close() |
| return HTMLResponse(status_code=404, content=error_html("File not found.")) |
| |
| files = conn.execute("SELECT original_name, size FROM files WHERE upload_id=?", (upload['id'],)).fetchall() |
| conn.close() |
| return await serve_direct_download(upload['id'], short_code, files) |
|
|
| async def serve_direct_download(upload_id: str, short_code: str, files): |
| upload_dir = UPLOAD_DIR / upload_id |
| if not upload_dir.exists(): |
| return HTMLResponse(status_code=404, content=error_html("Files missing.")) |
|
|
| if len(files) == 1: |
| file_path = upload_dir / files[0]['original_name'] |
| if not file_path.exists(): |
| return HTMLResponse(status_code=404, content=error_html("File missing.")) |
| return FileResponse(file_path, filename=files[0]['original_name']) |
| else: |
| zip_filename = f"files_{short_code}.zip" |
| async def zip_stream(): |
| zip_buffer = io.BytesIO() |
| with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zf: |
| for f in files: |
| file_path = upload_dir / f['original_name'] |
| if file_path.exists(): |
| zf.write(file_path, f['original_name']) |
| zip_buffer.seek(0) |
| while True: |
| data = zip_buffer.read(8192) |
| if not data: |
| break |
| yield data |
| return StreamingResponse(zip_stream(), media_type="application/zip", headers={ |
| "Content-Disposition": f'attachment; filename="{zip_filename}"' |
| }) |
|
|
| |
| @app.post("/upload/init") |
| async def init_upload(data: dict): |
| file_count = data.get("file_count", 1) |
| upload_id = str(uuid.uuid4()) |
| short_code = generate_short_code() |
| delete_token = str(uuid.uuid4()) |
| conn = get_conn() |
| conn.execute( |
| "INSERT INTO uploads (id, short_code, delete_token, file_count, created_at, expires_at) VALUES (?,?,?,?, datetime('now'), datetime('now', '+7 days'))", |
| (upload_id, short_code, delete_token, file_count) |
| ) |
| conn.commit() |
| conn.close() |
| (CHUNK_DIR / upload_id).mkdir(exist_ok=True) |
| return {"upload_id": upload_id, "short_code": short_code, "delete_token": delete_token} |
|
|
| @app.put("/upload/{upload_id}/{chunk_index}") |
| async def upload_chunk( |
| upload_id: str, |
| chunk_index: int, |
| chunk: UploadFile = File(...), |
| file_name: str = Form(...), |
| file_size: int = Form(...), |
| total_chunks: int = Form(...) |
| ): |
| conn = get_conn() |
| upload = conn.execute("SELECT id FROM uploads WHERE id=? AND is_complete=0 AND is_deleted=0", (upload_id,)).fetchone() |
| conn.close() |
| if not upload: |
| raise HTTPException(status_code=404, detail="Upload not found or already completed") |
|
|
| chunk_dir = CHUNK_DIR / upload_id |
| chunk_path = chunk_dir / f"{file_name}.part{chunk_index}" |
| async with aiofiles.open(chunk_path, 'wb') as out_file: |
| content = await chunk.read() |
| await out_file.write(content) |
|
|
| if chunk_index == 0: |
| conn = get_conn() |
| conn.execute( |
| "INSERT OR REPLACE INTO files (upload_id, original_name, stored_name, size) VALUES (?,?,?,?)", |
| (upload_id, file_name, file_name, file_size) |
| ) |
| conn.commit() |
| conn.close() |
| return {"status": "chunk_uploaded"} |
|
|
| @app.post("/upload/{upload_id}/complete") |
| async def complete_upload(upload_id: str): |
| conn = get_conn() |
| upload = conn.execute("SELECT id, short_code FROM uploads WHERE id=? AND is_complete=0 AND is_deleted=0", (upload_id,)).fetchone() |
| if not upload: |
| conn.close() |
| raise HTTPException(status_code=404, detail="Upload not found") |
|
|
| chunk_dir = CHUNK_DIR / upload_id |
| target_dir = UPLOAD_DIR / upload_id |
| target_dir.mkdir(exist_ok=True) |
|
|
| chunks = {} |
| for chunk_file in chunk_dir.iterdir(): |
| parts = chunk_file.name.rsplit('.part', 1) |
| if len(parts) != 2: |
| continue |
| orig_name = parts[0] |
| idx = int(parts[1]) |
| chunks.setdefault(orig_name, {})[idx] = chunk_file |
|
|
| for orig_name, parts_dict in chunks.items(): |
| sorted_indices = sorted(parts_dict.keys()) |
| target_path = target_dir / orig_name |
| async with aiofiles.open(target_path, 'wb') as outfile: |
| for i in sorted_indices: |
| part_path = parts_dict[i] |
| async with aiofiles.open(part_path, 'rb') as infile: |
| while True: |
| data = await infile.read(1024*1024) |
| if not data: |
| break |
| await outfile.write(data) |
| for part_path in parts_dict.values(): |
| part_path.unlink(missing_ok=True) |
|
|
| conn.execute("UPDATE uploads SET is_complete=1 WHERE id=?", (upload_id,)) |
| conn.commit() |
| conn.close() |
| shutil.rmtree(chunk_dir, ignore_errors=True) |
| return {"status": "completed", "short_code": upload['short_code']} |
|
|
| @app.delete("/delete/{delete_token}") |
| async def delete_by_token(delete_token: str): |
| conn = get_conn() |
| upload = conn.execute("SELECT id FROM uploads WHERE delete_token=? AND is_deleted=0", (delete_token,)).fetchone() |
| conn.close() |
| if not upload: |
| raise HTTPException(status_code=404, detail="Invalid token or files already deleted") |
| await delete_upload(upload['id']) |
| return {"status": "deleted"} |