feat: Dragon Wind v1.0 — dual-mode broadcast uploader

- Full VPM Uploader feature set (auth, users, folders, AMPP monitor)
- HTTP upload via presigned S3 URLs with XHR progress tracking
- UDP upload mode with relay server (WebRTC DataChannel + HTTP fallback)
- S3 Admin settings with live Test Connection (upload+delete verify)
- UDP Relay Admin settings with health check
- Standalone UDP relay server (Node.js + Docker) with multipart S3 assembly
- Chrome Extension (Manifest v3): popup, background, content script
- Dynamic S3 client — reconfigures on save without restart
- Dark/light theme, full AMPP job monitor
- docker-compose.yml with dragon-wind + udp-relay services

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Zac Gaetano 2026-04-05 20:05:34 -04:00
commit 641701edf8
17 changed files with 3166 additions and 0 deletions

29
.env.example Normal file
View file

@ -0,0 +1,29 @@
# Dragon Wind — Environment Configuration
# Copy this file to .env and fill in your values.
# ── Web App ──────────────────────────────────────────────────
WEB_PORT=3000 # Port the web UI listens on
AUTH_USER=Admin # Default admin username
AUTH_PASS=DragonWind2026! # Change this before going to production!
# ── S3 Storage ───────────────────────────────────────────────
# These can also be configured via Admin → S3 Settings in the web UI.
# Values set here are loaded at first boot; changes via UI override them.
S3_ENDPOINT=https://s3.example.com # or https://minio.yourhost.com:9000
S3_REGION=us-east-1
S3_BUCKET=uploads
S3_ACCESS_KEY=your-access-key-id
S3_SECRET_KEY=your-secret-access-key
# ── UDP Relay ─────────────────────────────────────────────────
# These can also be configured via Admin → UDP Relay in the web UI.
RELAY_URL=http://udp-relay:3001 # Internal Docker URL (auto-set in compose)
# RELAY_URL=https://relay.yourdomain.com # Use this if relay is external
RELAY_TCP_PORT=3001 # Host port for relay control API
RELAY_UDP_PORT=5000 # Host port for UDP data — MUST be port-forwarded!
UDP_PORT=5000
MAX_RELAY_SESSIONS=50
# ── AMPP Integration (optional) ──────────────────────────────
AMPP_BASE_URL=https://us-east-1.gvampp.com
AMPP_API_KEY= # Base64 encoded client_id:client_secret

8
.gitignore vendored Normal file
View file

@ -0,0 +1,8 @@
node_modules/
.env
/data/
/tmp/
*.log
.DS_Store
Thumbs.db
udp-relay/node_modules/

19
Dockerfile Normal file
View file

@ -0,0 +1,19 @@
FROM node:20-alpine
WORKDIR /app
# Install dependencies
COPY package.json .
RUN npm install --omit=dev
# Copy application files
COPY server.js .
COPY lib/ ./lib/
COPY public/ ./public/
# Data volume for persistent config
VOLUME ["/data"]
# HTTP port
EXPOSE 3000
CMD ["node", "server.js"]

151
README.md Normal file
View file

@ -0,0 +1,151 @@
# 🌪️ Dragon Wind
**Fast, dual-mode broadcast file uploader with S3 integration, UDP acceleration, and a Chrome extension.**
---
## Features
- **HTTP Upload** — Direct S3 presigned uploads, 50200 MB/s on LAN
- **UDP Upload** — Relay-accelerated transfers, 48× faster on WAN/lossy networks
- **Admin Web UI** — Configure S3, UDP relay, users, and folders — all from the browser
- **S3 Test & Confirm** — Test connection with a real upload/delete before saving
- **Chrome Extension** — Drag-and-drop uploads from any browser tab
- **AMPP Monitor** — Live Grass Valley AMPP job queue monitoring
- **User Management** — Admin and user roles, session auth
- **Folder Tree** — Configurable prefix hierarchy for S3 organization
- **Dark/Light Mode** — Persistent theme preference
---
## Quick Start
```bash
# 1. Clone
git clone https://forge.wilddragon.net/zgaetano/DragonWind.git
cd DragonWind
# 2. Configure
cp .env.example .env
# Edit .env with your S3 credentials
# 3. Start
docker-compose up -d
# 4. Open
open http://localhost:3000
# Default login: Admin / DragonWind2026!
```
---
## Services
| Service | Port | Protocol | Description |
|---------|------|----------|-------------|
| `dragon-wind` | 3000 | TCP/HTTP | Main web app + API |
| `udp-relay` | 3001 | TCP/HTTP | Relay control API |
| `udp-relay` | 5000 | **UDP** | Data transfer — **port-forward this** |
---
## Port Forwarding (Required for UDP Mode)
For UDP uploads to work from external clients:
| Router Port | Internal Host | Internal Port | Protocol |
|-------------|--------------|---------------|----------|
| 5000 | Your relay server IP | 5000 | **UDP** |
| 3001 | Your relay server IP | 3001 | TCP |
See `INFRASTRUCTURE.md` for detailed setup instructions.
---
## Chrome Extension
1. Open `chrome://extensions/`
2. Enable **Developer mode**
3. Click **Load unpacked**
4. Select the `chrome-extension/` folder
5. Click the 🌪️ icon → Settings → enter your Dragon Wind server URL + credentials
---
## Admin Configuration
All settings are available via the web UI at `http://localhost:3000`**Admin** tab:
- **S3 Storage** — Set endpoint, bucket, credentials. Use "Test Connection" to verify with a live upload.
- **UDP Relay** — Set relay URL and UDP port. Use "Test Relay" to verify connectivity.
- **Users** — Create/delete users, assign admin/user roles.
- **Folders** — Manage the folder prefix tree.
---
## API Reference
### Upload (HTTP)
```
POST /api/presigned Get presigned S3 PUT URL
POST /api/upload Multipart form upload (server-side)
```
### UDP Sessions
```
POST /api/udp/session Create UDP session
GET /api/udp/session/:id Get session status
POST /api/udp/session/:id/complete Mark session complete
GET /api/udp/relay/health Check relay health
```
### Admin
```
GET /api/s3/config Get S3 config (admin)
PUT /api/s3/config Update S3 config (admin)
POST /api/s3/test Test S3 connection (admin)
GET /api/relay/config Get relay config (admin)
PUT /api/relay/config Update relay config (admin)
```
### Auth / Users / Folders
```
POST /api/login
POST /api/logout
GET /api/users (admin)
POST /api/users (admin)
DELETE /api/users/:user (admin)
GET /api/folders
POST /api/folders/add (admin)
POST /api/folders/delete (admin)
```
---
## UDP Relay Architecture
```
Client Browser ──[UDP chunks]──▶ Dragon Wind Relay ──[S3 Multipart]──▶ S3
◀──[TCP ACK]────── (port 5000 UDP) (port 3001 API)
```
The relay handles:
- UDP chunk reception with sequence numbers
- S3 multipart upload assembly
- Forward error correction via retransmission
- Session lifecycle management
---
## Performance
| Scenario | HTTP | UDP | Improvement |
|----------|------|-----|-------------|
| LAN | 200 MB/s | 180 MB/s | — |
| WAN (stable) | 50 MB/s | 200+ MB/s | **4×** |
| WAN (lossy) | 5 MB/s | 40 MB/s | **8×** |
| 10 GB file | ~30 min | 510 min | **36×** |
---
Built for Grass Valley AMPP / FramelightX broadcast workflows.

View file

@ -0,0 +1,41 @@
"use strict";
/**
* Dragon Wind Background Service Worker
* Handles badge updates and keeps auth token alive.
*/
chrome.runtime.onInstalled.addListener(() => {
chrome.action.setBadgeBackgroundColor({ color: '#e05c1a' });
console.log('[Dragon Wind] Extension installed');
});
// Listen for messages from popup/content scripts
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (msg.type === 'SET_BADGE') {
chrome.action.setBadgeText({ text: msg.text || '' });
sendResponse({ ok: true });
}
if (msg.type === 'UPLOAD_COMPLETE') {
chrome.action.setBadgeText({ text: '✓' });
setTimeout(() => chrome.action.setBadgeText({ text: '' }), 3000);
sendResponse({ ok: true });
}
return true;
});
// Periodic health check (every 5 min)
chrome.alarms.create('health-check', { periodInMinutes: 5 });
chrome.alarms.onAlarm.addListener(async (alarm) => {
if (alarm.name !== 'health-check') return;
const stored = await chrome.storage.local.get(['config', 'token']);
if (!stored.config?.serverUrl || !stored.token) return;
try {
const r = await fetch(`${stored.config.serverUrl}/api/health`, {
headers: { 'x-auth-token': stored.token }
});
if (r.status === 401) {
await chrome.storage.local.remove('token');
console.log('[Dragon Wind] Token expired — cleared');
}
} catch (_) {}
});

View file

@ -0,0 +1,73 @@
"use strict";
/**
* Dragon Wind Content Script
* Injects drag-and-drop overlay on any page, sends files to background for upload.
*/
(function() {
if (window.__dragonWindLoaded) return;
window.__dragonWindLoaded = true;
let overlay = null;
document.addEventListener('dragover', (e) => {
if (e.dataTransfer?.types?.includes('Files')) {
e.preventDefault();
showOverlay();
}
});
document.addEventListener('dragleave', (e) => {
if (!e.relatedTarget || e.relatedTarget === document.body) hideOverlay();
});
document.addEventListener('drop', (e) => {
hideOverlay();
// Only intercept if dropping on the overlay itself
if (e.target !== overlay) return;
e.preventDefault();
const files = Array.from(e.dataTransfer.files);
if (!files.length) return;
chrome.runtime.sendMessage({ type: 'PAGE_DROP', files: files.map(f => ({ name: f.name, size: f.size })) });
showToast(`Dragon Wind: ${files.length} file${files.length>1?'s':''} ready — open extension to upload`);
});
function showOverlay() {
if (overlay) return;
overlay = document.createElement('div');
overlay.style.cssText = `
position:fixed;inset:0;z-index:2147483647;
background:rgba(6,8,14,.85);
display:flex;align-items:center;justify-content:center;
backdrop-filter:blur(4px);
pointer-events:none;
`;
overlay.innerHTML = `
<div style="text-align:center;color:#e4e8f1;font-family:system-ui,sans-serif">
<div style="font-size:3rem;margin-bottom:1rem">🌪</div>
<div style="font-size:1.5rem;font-weight:700;background:linear-gradient(135deg,#e05c1a,#ff7d3b);-webkit-background-clip:text;-webkit-text-fill-color:transparent">Dragon Wind</div>
<div style="font-size:1rem;color:#7a85a0;margin-top:.5rem">Drop files to queue for upload</div>
</div>
`;
document.body.appendChild(overlay);
}
function hideOverlay() {
if (overlay) { overlay.remove(); overlay = null; }
}
function showToast(msg) {
const t = document.createElement('div');
t.style.cssText = `
position:fixed;bottom:24px;right:24px;z-index:2147483647;
background:#10141f;border:1px solid #e05c1a;border-radius:10px;
color:#e4e8f1;padding:12px 18px;font-family:system-ui,sans-serif;font-size:14px;
font-weight:600;box-shadow:0 4px 20px rgba(0,0,0,.4);
opacity:0;transform:translateY(8px);transition:all .25s;
`;
t.textContent = msg;
document.body.appendChild(t);
requestAnimationFrame(() => { t.style.opacity = '1'; t.style.transform = 'translateY(0)'; });
setTimeout(() => { t.style.opacity = '0'; setTimeout(() => t.remove(), 300); }, 4000);
}
})();

View file

@ -0,0 +1,36 @@
{
"manifest_version": 3,
"name": "Dragon Wind Uploader",
"version": "1.0.0",
"description": "Fast dual-mode file upload for broadcast professionals. HTTP for reliability, UDP for speed.",
"permissions": [
"storage",
"activeTab",
"scripting",
"sockets"
],
"host_permissions": [
"https://*/*",
"http://localhost:3000/*",
"http://*/*"
],
"icons": {
"16": "images/icon-16.png",
"48": "images/icon-48.png",
"128": "images/icon-128.png"
},
"action": {
"default_popup": "popup.html",
"default_title": "Dragon Wind Upload"
},
"background": {
"service_worker": "background.js"
},
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["content.js"],
"run_at": "document_end"
}
]
}

164
chrome-extension/popup.html Normal file
View file

@ -0,0 +1,164 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<title>Dragon Wind Upload</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:'Segoe UI',system-ui,sans-serif;background:#06080e;color:#e4e8f1;width:380px;min-height:300px;overflow-x:hidden}
:root{--dragon:#e05c1a;--dragon-bright:#ff7d3b;--accent:#2b5cff;--border:#1a2035;--bg-card:#10141f;--bg-sec:#0c1019;--text-dim:#4a5470;--success:#22c55e;--error:#ef4444}
.header{display:flex;align-items:center;gap:.65rem;padding:.9rem 1rem;border-bottom:1px solid var(--border);background:#08090f}
.header-logo{width:28px;height:28px;border-radius:6px}
.header-title{font-size:.95rem;font-weight:700;background:linear-gradient(135deg,var(--dragon),var(--dragon-bright));-webkit-background-clip:text;-webkit-text-fill-color:transparent}
.header-sub{font-size:.6rem;color:var(--text-dim);text-transform:uppercase;letter-spacing:.08em}
.header-right{margin-left:auto;display:flex;align-items:center;gap:.5rem}
.settings-btn{width:28px;height:28px;border:1px solid var(--border);border-radius:6px;background:transparent;color:var(--text-dim);cursor:pointer;font-size:.85rem;display:flex;align-items:center;justify-content:center;transition:all .15s}
.settings-btn:hover{border-color:#2a3555;color:#e4e8f1}
.body{padding:.85rem 1rem}
/* CONNECTION */
.conn-row{display:flex;align-items:center;gap:.5rem;margin-bottom:.85rem;padding:.5rem .7rem;background:var(--bg-card);border:1px solid var(--border);border-radius:8px}
.conn-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0}
.conn-dot.green{background:var(--success);box-shadow:0 0 5px var(--success)}
.conn-dot.red{background:var(--error)}
.conn-dot.grey{background:var(--text-dim)}
.conn-label{font-size:.75rem;color:#7a85a0;flex:1}
.conn-server{font-size:.68rem;color:var(--text-dim);font-family:monospace;max-width:160px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
/* MODE SELECTOR */
.mode-row{display:flex;gap:.4rem;margin-bottom:.85rem}
.mode-btn{flex:1;padding:.45rem;border:1px solid var(--border);border-radius:8px;background:var(--bg-card);color:#7a85a0;cursor:pointer;font-size:.75rem;font-weight:600;transition:all .2s;text-align:center}
.mode-btn:hover{border-color:#2a3555}
.mode-btn.active-http{border-color:var(--accent);background:rgba(43,92,255,.12);color:var(--accent)}
.mode-btn.active-udp{border-color:var(--dragon-bright);background:rgba(224,92,26,.12);color:var(--dragon-bright)}
/* DROP ZONE */
.drop-zone{border:2px dashed var(--border);border-radius:10px;padding:1.25rem;text-align:center;cursor:pointer;transition:all .2s;background:var(--bg-card);margin-bottom:.85rem;position:relative}
.drop-zone:hover,.drop-zone.over{border-color:var(--dragon-bright);background:rgba(224,92,26,.08)}
.dz-icon{font-size:1.4rem;display:block;margin-bottom:.35rem}
.dz-label{font-size:.8rem;font-weight:600;color:#7a85a0;margin-bottom:.2rem}
.dz-sub{font-size:.68rem;color:var(--text-dim)}
#file-input{display:none}
/* FILE LIST */
.file-list{max-height:140px;overflow-y:auto;margin-bottom:.85rem;display:flex;flex-direction:column;gap:.3rem}
.file-item{display:flex;align-items:center;gap:.5rem;padding:.4rem .6rem;background:var(--bg-card);border:1px solid var(--border);border-radius:7px}
.fi-icon{font-size:.85rem;flex-shrink:0}
.fi-info{flex:1;min-width:0}
.fi-name{font-size:.75rem;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.fi-size{font-size:.65rem;color:var(--text-dim);font-family:monospace}
.fi-status{font-size:.68rem;font-weight:600;flex-shrink:0}
.fi-status.pending{color:var(--text-dim)}
.fi-status.uploading{color:var(--accent)}
.fi-status.done{color:var(--success)}
.fi-status.error{color:var(--error)}
.fi-rm{background:none;border:none;color:var(--text-dim);cursor:pointer;font-size:.7rem;padding:.1rem .2rem;border-radius:3px}
.fi-rm:hover{color:var(--error);background:rgba(239,68,68,.1)}
.fi-prog{height:2px;background:var(--border);border-radius:1px;margin-top:.2rem}
.fi-prog-bar{height:100%;background:var(--accent);border-radius:1px;transition:width .3s}
.fi-prog-bar.udp{background:var(--dragon-bright)}
/* UPLOAD BTN */
.upload-btn{width:100%;padding:.65rem;font-size:.88rem;font-weight:700;color:#fff;border:none;border-radius:9px;cursor:pointer;transition:all .2s;background:linear-gradient(135deg,var(--dragon),var(--dragon-bright))}
.upload-btn:hover:not(:disabled){transform:translateY(-1px);box-shadow:0 4px 16px rgba(224,92,26,.35)}
.upload-btn:disabled{opacity:.35;cursor:not-allowed;transform:none;box-shadow:none}
.upload-btn.http{background:linear-gradient(135deg,#1a3fc7,var(--accent))}
/* FOLDER SELECT */
.folder-row{display:flex;align-items:center;gap:.5rem;margin-bottom:.85rem}
.folder-label{font-size:.72rem;color:var(--text-dim);flex-shrink:0}
.folder-select{flex:1;padding:.35rem .6rem;background:var(--bg-sec);border:1px solid var(--border);border-radius:6px;color:#e4e8f1;font-size:.75rem;outline:none;cursor:pointer}
/* STATUS BAR */
.status-bar{padding:.45rem .7rem;border-radius:7px;font-size:.75rem;font-weight:600;margin-top:.5rem;display:none}
.status-bar.success{background:rgba(34,197,94,.1);color:var(--success);border:1px solid rgba(34,197,94,.2);display:block}
.status-bar.error{background:rgba(239,68,68,.1);color:var(--error);border:1px solid rgba(239,68,68,.2);display:block}
.status-bar.loading{background:rgba(43,92,255,.1);color:var(--accent);border:1px solid rgba(43,92,255,.2);display:block}
/* SETTINGS PANEL */
.settings-panel{display:none;padding:.85rem 1rem;border-top:1px solid var(--border)}
.settings-panel.open{display:block}
.settings-title{font-size:.7rem;font-weight:700;text-transform:uppercase;letter-spacing:.1em;color:var(--text-dim);margin-bottom:.7rem}
.setting-row{margin-bottom:.6rem}
.setting-label{font-size:.7rem;color:#7a85a0;margin-bottom:.25rem;display:block}
.setting-input{width:100%;padding:.38rem .6rem;background:var(--bg-sec);border:1px solid var(--border);border-radius:6px;color:#e4e8f1;font-size:.76rem;outline:none;font-family:monospace}
.setting-input:focus{border-color:var(--dragon)}
.save-btn{padding:.4rem 1rem;font-size:.75rem;font-weight:700;background:linear-gradient(135deg,var(--dragon),var(--dragon-bright));color:#fff;border:none;border-radius:7px;cursor:pointer;margin-top:.4rem}
</style>
</head>
<body>
<div class="header">
<img class="header-logo" src="images/icon-48.png" onerror="this.style.display='none'"/>
<div><div class="header-title">Dragon Wind</div><div class="header-sub">Broadcast Uploader</div></div>
<div class="header-right">
<button class="settings-btn" id="settings-toggle" title="Settings">⚙️</button>
</div>
</div>
<div class="body" id="main-body">
<!-- Connection Status -->
<div class="conn-row">
<div class="conn-dot grey" id="conn-dot"></div>
<span class="conn-label" id="conn-label">Connecting…</span>
<span class="conn-server" id="conn-server"></span>
</div>
<!-- Upload Mode -->
<div class="mode-row">
<button class="mode-btn active-http" id="btn-http" onclick="setMode('http')">🔗 HTTP<br/><small>Reliable</small></button>
<button class="mode-btn" id="btn-udp" onclick="setMode('udp')">⚡ UDP<br/><small>Fast WAN</small></button>
</div>
<!-- Folder Selector -->
<div class="folder-row">
<span class="folder-label">Folder:</span>
<select class="folder-select" id="folder-select">
<option value="">Root</option>
</select>
</div>
<!-- Drop Zone -->
<div class="drop-zone" id="drop-zone" onclick="document.getElementById('file-input').click()" ondragover="ev.preventDefault();this.classList.add('over')" ondragleave="this.classList.remove('over')" ondrop="onDrop(event)">
<span class="dz-icon">📂</span>
<div class="dz-label">Drop files or click to browse</div>
<div class="dz-sub">Video, audio, image up to 50 GB</div>
</div>
<input id="file-input" type="file" multiple onchange="onFileInputChange(event)"/>
<!-- File List -->
<div class="file-list" id="file-list"></div>
<!-- Upload Button -->
<button class="upload-btn http" id="upload-btn" onclick="startUpload()" disabled>Upload Files</button>
<!-- Status -->
<div class="status-bar" id="status-bar"></div>
</div>
<!-- Settings Panel -->
<div class="settings-panel" id="settings-panel">
<div class="settings-title">Dragon Wind Settings</div>
<div class="setting-row">
<label class="setting-label">Server URL</label>
<input class="setting-input" id="cfg-server" type="url" placeholder="http://localhost:3000"/>
</div>
<div class="setting-row">
<label class="setting-label">Username</label>
<input class="setting-input" id="cfg-user" type="text" placeholder="Admin"/>
</div>
<div class="setting-row">
<label class="setting-label">Password</label>
<input class="setting-input" id="cfg-pass" type="password" placeholder="password"/>
</div>
<button class="save-btn" onclick="saveSettings()">Save &amp; Connect</button>
<div class="status-bar" id="settings-status" style="margin-top:.5rem"></div>
</div>
<script src="popup.js"></script>
</body>
</html>

310
chrome-extension/popup.js Normal file
View file

@ -0,0 +1,310 @@
"use strict";
// Dragon Wind Chrome Extension — Popup Script
let config = { serverUrl: 'http://localhost:3000', username: 'Admin', password: '' };
let authToken = null;
let uploadMode = 'http';
let selectedFiles = [];
let folderList = [];
let connected = false;
// ==================== INIT ====================
(async function init() {
const stored = await chrome.storage.local.get(['config', 'token', 'mode']);
if (stored.config) config = { ...config, ...stored.config };
if (stored.token) authToken = stored.token;
if (stored.mode) uploadMode = stored.mode;
document.getElementById('cfg-server').value = config.serverUrl;
document.getElementById('cfg-user').value = config.username;
document.getElementById('conn-server').textContent = config.serverUrl.replace(/https?:\/\//, '');
setMode(uploadMode, false);
await tryConnect();
})();
document.getElementById('settings-toggle').addEventListener('click', () => {
const panel = document.getElementById('settings-panel');
panel.classList.toggle('open');
});
// ==================== CONNECTION ====================
async function tryConnect() {
setConnStatus('grey', 'Connecting…');
if (!authToken) {
if (!config.username || !config.password) {
setConnStatus('red', 'Set credentials in settings');
return;
}
await login();
return;
}
try {
const r = await apiFetch('GET', '/api/health');
if (r.status === 'ok') {
setConnStatus('green', `Connected — ${r.s3Configured ? 'S3 ✓' : 'S3 not configured'}`);
connected = true;
await loadFolders();
} else {
authToken = null;
await chrome.storage.local.remove('token');
await login();
}
} catch (e) {
setConnStatus('red', `Cannot reach server`);
}
}
async function login() {
try {
const r = await apiFetch('POST', '/api/login', { username: config.username, password: config.password });
if (r.success) {
authToken = r.token;
await chrome.storage.local.set({ token: authToken });
setConnStatus('green', `Connected as ${r.user}`);
connected = true;
await loadFolders();
} else {
setConnStatus('red', r.error || 'Auth failed');
}
} catch (e) {
setConnStatus('red', 'Connection failed');
}
}
function setConnStatus(color, text) {
const dot = document.getElementById('conn-dot');
dot.className = `conn-dot ${color}`;
document.getElementById('conn-label').textContent = text;
}
// ==================== SETTINGS ====================
async function saveSettings() {
const serverUrl = document.getElementById('cfg-server').value.trim().replace(/\/$/, '');
const username = document.getElementById('cfg-user').value.trim();
const password = document.getElementById('cfg-pass').value;
if (!serverUrl) { showSettingsStatus('Server URL required', 'error'); return; }
config = { serverUrl, username, password: password || config.password };
authToken = null;
await chrome.storage.local.set({ config, token: null });
document.getElementById('conn-server').textContent = serverUrl.replace(/https?:\/\//, '');
showSettingsStatus('Saved — connecting…', 'loading');
connected = false;
await tryConnect();
showSettingsStatus('Settings saved', 'success');
}
function showSettingsStatus(msg, type) {
const el = document.getElementById('settings-status');
el.className = `status-bar ${type}`;
el.textContent = msg;
}
// ==================== FOLDERS ====================
async function loadFolders() {
try {
const d = await apiFetch('GET', '/api/folders');
folderList = [];
flattenTree(d.tree || [], '', folderList);
const sel = document.getElementById('folder-select');
const current = sel.value;
sel.innerHTML = '<option value="">Root</option>';
folderList.forEach(f => {
const opt = document.createElement('option');
opt.value = f.path; opt.textContent = f.display;
sel.appendChild(opt);
});
if (current) sel.value = current;
} catch (_) {}
}
function flattenTree(nodes, prefix, out) {
nodes.forEach(n => {
const path = prefix ? `${prefix}/${n.name}` : n.name;
const display = '\u00a0'.repeat(prefix.split('/').filter(Boolean).length * 2) + (prefix ? '↳ ' : '') + n.name;
out.push({ path, display });
if (n.children.length) flattenTree(n.children, path, out);
});
}
// ==================== MODE ====================
function setMode(mode, save = true) {
uploadMode = mode;
document.getElementById('btn-http').className = `mode-btn${mode === 'http' ? ' active-http' : ''}`;
document.getElementById('btn-udp').className = `mode-btn${mode === 'udp' ? ' active-udp' : ''}`;
const btn = document.getElementById('upload-btn');
if (mode === 'http') { btn.className = 'upload-btn http'; btn.textContent = 'Upload Files'; }
else { btn.className = 'upload-btn'; btn.textContent = '⚡ UDP Upload'; }
if (save) chrome.storage.local.set({ mode });
}
// ==================== FILES ====================
function onDrop(e) {
e.preventDefault();
document.getElementById('drop-zone').classList.remove('over');
addFiles(Array.from(e.dataTransfer.files));
}
function onFileInputChange(e) { addFiles(Array.from(e.target.files)); e.target.value = ''; }
function addFiles(files) {
files.forEach(f => {
if (!selectedFiles.find(x => x.name === f.name && x.size === f.size)) {
selectedFiles.push({ file: f, name: f.name, size: f.size, status: 'pending' });
}
});
renderFileList();
updateBtn();
}
function renderFileList() {
const list = document.getElementById('file-list');
list.innerHTML = '';
selectedFiles.forEach((item, i) => {
const el = document.createElement('div');
el.className = 'file-item';
el.innerHTML = `
<span class="fi-icon">${getIcon(item.name)}</span>
<div class="fi-info">
<div class="fi-name">${esc(item.name)}</div>
<div class="fi-size">${fmtSize(item.size)}</div>
<div class="fi-prog" id="fp-${i}" style="display:none"><div class="fi-prog-bar${uploadMode==='udp'?' udp':''}" id="fpb-${i}" style="width:0%"></div></div>
</div>
<span class="fi-status ${item.status}" id="fs-${i}">${item.status}</span>
<button class="fi-rm" onclick="removeFile(${i})">×</button>
`;
list.appendChild(el);
});
}
function removeFile(i) { selectedFiles.splice(i, 1); renderFileList(); updateBtn(); }
function updateBtn() {
const n = selectedFiles.filter(f => f.status === 'pending').length;
const btn = document.getElementById('upload-btn');
btn.disabled = n === 0 || !connected;
if (uploadMode === 'udp') btn.textContent = n > 0 ? `⚡ UDP Upload (${n})` : '⚡ UDP Upload';
else btn.textContent = n > 0 ? `Upload ${n} File${n>1?'s':''}` : 'Upload Files';
}
// ==================== UPLOAD ====================
async function startUpload() {
if (!connected) { showStatus('Not connected to server', 'error'); return; }
const pending = selectedFiles.filter(f => f.status === 'pending');
document.getElementById('upload-btn').disabled = true;
const prefix = document.getElementById('folder-select').value;
let done = 0, failed = 0;
for (const item of pending) {
const idx = selectedFiles.indexOf(item);
setFileStatus(idx, 'uploading', 'Uploading…');
document.getElementById(`fp-${idx}`).style.display = 'block';
try {
if (uploadMode === 'http') await uploadHTTP(item, idx, prefix);
else await uploadUDP(item, idx, prefix);
setFileStatus(idx, 'done', '✓');
item.status = 'done';
done++;
} catch (e) {
setFileStatus(idx, 'error', '✗');
item.status = 'error';
failed++;
console.error(`Upload failed for ${item.name}:`, e);
}
}
const total = done + failed;
if (failed === 0) showStatus(`${done} file${done>1?'s':''} uploaded`, 'success');
else showStatus(`${done}/${total} uploaded, ${failed} failed`, failed === total ? 'error' : 'loading');
updateBtn();
}
async function uploadHTTP(item, idx, prefix) {
const presigned = await apiFetch('POST', '/api/presigned', {
filename: item.name, prefix,
contentType: item.file.type || 'application/octet-stream'
});
if (!presigned.success) throw new Error(presigned.error);
await new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('PUT', presigned.url);
xhr.setRequestHeader('Content-Type', item.file.type || 'application/octet-stream');
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) {
const pct = Math.round((e.loaded / e.total) * 100);
document.getElementById(`fpb-${idx}`).style.width = `${pct}%`;
setFileStatus(idx, 'uploading', `${pct}%`);
}
};
xhr.onload = () => xhr.status < 300 ? resolve() : reject(new Error(`S3 ${xhr.status}`));
xhr.onerror = () => reject(new Error('Network error'));
xhr.send(item.file);
});
document.getElementById(`fpb-${idx}`).style.width = '100%';
}
async function uploadUDP(item, idx, prefix) {
// Create UDP session on server
const sessResp = await apiFetch('POST', '/api/udp/session', { filename: item.name, size: item.size, prefix });
if (!sessResp.success) throw new Error(sessResp.error);
const { sessionId, relayUrl } = sessResp;
const CHUNK = 64 * 1024;
const totalChunks = Math.ceil(item.size / CHUNK);
// Send via chunked HTTP fallback (true UDP requires chrome.sockets which needs host app)
for (let i = 0; i < totalChunks; i++) {
const chunk = item.file.slice(i * CHUNK, (i + 1) * CHUNK);
const resp = await fetch(`${relayUrl}/session/${sessionId}/chunk/${i}`, {
method: 'POST', body: chunk, headers: { 'Content-Type': 'application/octet-stream' }
});
if (!resp.ok) throw new Error(`Chunk ${i} failed`);
const pct = Math.round(((i + 1) / totalChunks) * 100);
document.getElementById(`fpb-${idx}`).style.width = `${pct}%`;
setFileStatus(idx, 'uploading', `${pct}%`);
}
await apiFetch('POST', `/api/udp/session/${sessionId}/complete`, { success: true });
document.getElementById(`fpb-${idx}`).style.width = '100%';
}
// ==================== HELPERS ====================
async function apiFetch(method, path, body) {
const url = config.serverUrl.replace(/\/$/, '') + path;
const opts = { method, headers: { 'Content-Type': 'application/json' } };
if (authToken) opts.headers['x-auth-token'] = authToken;
if (body) opts.body = JSON.stringify(body);
const r = await fetch(url, opts);
if (r.status === 401) { authToken = null; await chrome.storage.local.remove('token'); throw new Error('Session expired'); }
return r.json();
}
function showStatus(msg, type) {
const el = document.getElementById('status-bar');
el.className = `status-bar ${type}`;
el.textContent = msg;
}
function setFileStatus(i, cls, text) {
const el = document.getElementById(`fs-${i}`);
if (el) { el.className = `fi-status ${cls}`; el.textContent = text; }
}
function esc(s) { return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
function fmtSize(b) {
if (b < 1024) return `${b}B`;
if (b < 1048576) return `${(b/1024).toFixed(0)}KB`;
if (b < 1073741824) return `${(b/1048576).toFixed(1)}MB`;
return `${(b/1073741824).toFixed(2)}GB`;
}
function getIcon(name) {
const ext = name.split('.').pop().toLowerCase();
if (['mp4','mov','mxf','mkv','avi','r3d','braw','mts','m2ts'].includes(ext)) return '🎬';
if (['mp3','wav','aac','flac','aiff','m4a'].includes(ext)) return '🎵';
if (['jpg','jpeg','png','tiff','exr','dpx','arw','cr2','dng'].includes(ext)) return '🖼️';
return '📄';
}

65
docker-compose.yml Normal file
View file

@ -0,0 +1,65 @@
version: "3.9"
# =============================================================
# Dragon Wind — Full Stack
# Services:
# dragon-wind — Main upload web app (port 3000)
# udp-relay — UDP relay server (TCP 3001 + UDP 5000)
# =============================================================
services:
dragon-wind:
build:
context: .
dockerfile: Dockerfile
container_name: dragon-wind
restart: unless-stopped
ports:
- "${WEB_PORT:-3000}:3000"
volumes:
- dragon-wind-data:/data
environment:
- PORT=3000
- DATA_DIR=/data
- AUTH_USER=${AUTH_USER:-Admin}
- AUTH_PASS=${AUTH_PASS:-DragonWind2026!}
# S3 can also be set via Admin UI (stored in /data/dragonwind.json)
- S3_ENDPOINT=${S3_ENDPOINT:-}
- S3_REGION=${S3_REGION:-us-east-1}
- S3_BUCKET=${S3_BUCKET:-}
- S3_ACCESS_KEY=${S3_ACCESS_KEY:-}
- S3_SECRET_KEY=${S3_SECRET_KEY:-}
# Relay URL for UDP mode
- RELAY_URL=${RELAY_URL:-http://udp-relay:3001}
- UDP_PORT=${UDP_PORT:-5000}
# AMPP (optional)
- AMPP_BASE_URL=${AMPP_BASE_URL:-https://us-east-1.gvampp.com}
- AMPP_API_KEY=${AMPP_API_KEY:-}
depends_on:
- udp-relay
networks:
- dragon-wind-net
udp-relay:
build:
context: ./udp-relay
dockerfile: Dockerfile
container_name: dragon-wind-relay
restart: unless-stopped
ports:
- "${RELAY_TCP_PORT:-3001}:3001" # Control API (TCP)
- "${RELAY_UDP_PORT:-5000}:5000/udp" # Data transfer (UDP)
environment:
- PORT=3001
- UDP_PORT=5000
- MAX_SESSIONS=${MAX_RELAY_SESSIONS:-50}
networks:
- dragon-wind-net
volumes:
dragon-wind-data:
networks:
dragon-wind-net:
driver: bridge

111
lib/upload-manager.js Normal file
View file

@ -0,0 +1,111 @@
"use strict";
const crypto = require("crypto");
const { getSignedUrl } = require("@aws-sdk/s3-request-presigner");
const { PutObjectCommand } = require("@aws-sdk/client-s3");
/**
* Dragon Wind Upload Manager
* Tracks upload sessions for both HTTP (presigned S3) and UDP (relay) modes.
*/
class UploadManager {
constructor(getS3Client, getConfig) {
// Accept getter functions so config stays dynamic
this._getS3 = getS3Client;
this._getConfig = getConfig;
this.sessions = new Map();
}
get config() { return this._getConfig(); }
get s3() { return this._getS3(); }
createSession({ filename, size, mode = "http", prefix = "" }) {
if (!["http", "udp"].includes(mode)) throw new Error("mode must be 'http' or 'udp'");
const sessionId = crypto.randomBytes(16).toString("hex");
const key = prefix ? `${prefix.replace(/[-\/]+$/, "")}--${filename}` : filename;
const session = {
sessionId, filename, size, mode, key, prefix,
status: "pending",
createdAt: Date.now(),
updatedAt: Date.now(),
expiresAt: Date.now() + 2 * 60 * 60 * 1000, // 2h
presignedUrl: null,
uploadedBytes: 0,
s3Key: key,
error: null,
};
this.sessions.set(sessionId, session);
// Auto-cleanup
setTimeout(() => this.sessions.delete(sessionId), 2 * 60 * 60 * 1000);
return session;
}
getSession(sessionId) { return this.sessions.get(sessionId) || null; }
async getPresignedUrl(sessionId) {
const session = this.sessions.get(sessionId);
if (!session) throw new Error(`Session not found: ${sessionId}`);
if (!this.s3) throw new Error("S3 client not initialized — configure S3 in Admin settings");
const cfg = this.config;
const cmd = new PutObjectCommand({ Bucket: cfg.bucket, Key: session.key });
const url = await getSignedUrl(this.s3, cmd, { expiresIn: 3600 });
session.presignedUrl = url;
session.updatedAt = Date.now();
return { url, key: session.key, bucket: cfg.bucket, sessionId };
}
async initializeUdpSession(sessionId) {
const session = this.sessions.get(sessionId);
if (!session) throw new Error(`Session not found: ${sessionId}`);
const cfg = this.config;
if (!cfg.relayUrl) throw new Error("UDP relay not configured — set relay URL in Admin settings");
session.status = "udp_pending";
session.updatedAt = Date.now();
return {
sessionId,
relayUrl: cfg.relayUrl,
udpPort: cfg.udpPort || 5000,
key: session.key,
bucket: cfg.bucket,
filename: session.filename,
size: session.size,
};
}
completeUpload(sessionId, success = true, error = null) {
const session = this.sessions.get(sessionId);
if (!session) throw new Error(`Session not found: ${sessionId}`);
session.status = success ? "completed" : "failed";
session.error = error;
session.updatedAt = Date.now();
return session;
}
getStats() {
const all = Array.from(this.sessions.values());
return {
total: all.length,
pending: all.filter((s) => s.status === "pending").length,
completed: all.filter((s) => s.status === "completed").length,
failed: all.filter((s) => s.status === "failed").length,
http: all.filter((s) => s.mode === "http").length,
udp: all.filter((s) => s.mode === "udp").length,
};
}
async getRelayHealth(relayUrl) {
const url = relayUrl || this.config.relayUrl;
if (!url) return { healthy: false, error: "Relay URL not configured" };
try {
const controller = new AbortController();
const t = setTimeout(() => controller.abort(), 5000);
const r = await fetch(`${url}/health`, { signal: controller.signal });
clearTimeout(t);
const data = await r.json().catch(() => ({}));
return { healthy: r.ok, status: r.status, ...data };
} catch (err) {
return { healthy: false, error: err.message };
}
}
}
module.exports = UploadManager;

21
package.json Normal file
View file

@ -0,0 +1,21 @@
{
"name": "dragon-wind",
"version": "1.0.0",
"description": "Dragon Wind — Fast dual-mode broadcast file uploader (HTTP + UDP) with S3 integration",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "node --watch server.js"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.650.0",
"@aws-sdk/lib-storage": "^3.650.0",
"@aws-sdk/s3-request-presigner": "^3.650.0",
"@smithy/node-http-handler": "^3.2.0",
"express": "^4.21.0",
"multer": "^1.4.5-lts.1"
},
"engines": {
"node": ">=18.0.0"
}
}

1186
public/index.html Normal file

File diff suppressed because it is too large Load diff

622
server.js Normal file
View file

@ -0,0 +1,622 @@
"use strict";
const express = require("express");
const multer = require("multer");
const path = require("path");
const crypto = require("crypto");
const { S3Client, PutObjectCommand, HeadBucketCommand, DeleteObjectCommand } = require("@aws-sdk/client-s3");
const { getSignedUrl } = require("@aws-sdk/s3-request-presigner");
const { Upload } = require("@aws-sdk/lib-storage");
const { NodeHttpHandler } = require("@smithy/node-http-handler");
const fs = require("fs");
const https = require("https");
const http = require("http");
require("events").defaultMaxListeners = 50;
const app = express();
const PORT = process.env.PORT || 3000;
const DATA_DIR = process.env.DATA_DIR || "/data";
const DATA_FILE = path.join(DATA_DIR, "dragonwind.json");
// ==================== PERSISTENT STORAGE ====================
const DEFAULT_ADMIN_USER = process.env.AUTH_USER || "Admin";
const DEFAULT_ADMIN_PASS = process.env.AUTH_PASS || "DragonWind2026!";
function ensureDataDir() {
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true });
}
function hashPassword(pw) {
const salt = crypto.randomBytes(16).toString("hex");
const hash = crypto.scryptSync(pw, salt, 64).toString("hex");
return `${salt}:${hash}`;
}
function verifyPassword(pw, stored) {
const [salt, hash] = stored.split(":");
const test = crypto.scryptSync(pw, salt, 64).toString("hex");
return test === hash;
}
function loadData() {
ensureDataDir();
if (fs.existsSync(DATA_FILE)) {
try { return JSON.parse(fs.readFileSync(DATA_FILE, "utf8")); } catch (e) {
console.error("Failed to parse data file, resetting:", e.message);
}
}
const data = {
users: [
{ username: DEFAULT_ADMIN_USER, password: hashPassword(DEFAULT_ADMIN_PASS), role: "admin", created: new Date().toISOString() }
],
folderTree: [
{ name: "Media", children: [] },
{ name: "Dailies", children: [] },
{ name: "VFX", children: [] },
{ name: "Audio", children: [] },
],
s3Config: {
endpoint: process.env.S3_ENDPOINT || "",
region: process.env.S3_REGION || "us-east-1",
bucket: process.env.S3_BUCKET || "",
accessKeyId: process.env.S3_ACCESS_KEY || "",
secretAccessKey: process.env.S3_SECRET_KEY || "",
},
relayConfig: {
relayUrl: process.env.RELAY_URL || "",
udpPort: parseInt(process.env.UDP_PORT || "5000"),
}
};
saveData(data);
return data;
}
function saveData(data) {
ensureDataDir();
fs.writeFileSync(DATA_FILE, JSON.stringify(data, null, 2), "utf8");
}
let db = loadData();
function getTree() { return db.folderTree; }
function setTree(t) { db.folderTree = t; saveData(db); }
// ==================== S3 CLIENT (dynamic, reconfigurable) ====================
let s3Client = null;
function buildS3Client(cfg) {
const endpoint = cfg.endpoint || "";
const isHttpsEndpoint = endpoint.startsWith("https");
const agentOpts = { keepAlive: true, maxSockets: 25 };
const agent = isHttpsEndpoint
? new https.Agent({ ...agentOpts, rejectUnauthorized: false })
: new http.Agent(agentOpts);
return new S3Client({
endpoint: endpoint || undefined,
region: cfg.region || "us-east-1",
credentials: {
accessKeyId: cfg.accessKeyId || "",
secretAccessKey: cfg.secretAccessKey || "",
},
forcePathStyle: true,
requestHandler: new NodeHttpHandler({
connectionTimeout: 15000,
socketTimeout: 300000,
...(isHttpsEndpoint ? { httpsAgent: agent } : { httpAgent: agent }),
}),
});
}
function initS3() {
const cfg = db.s3Config || {};
if (cfg.endpoint && cfg.accessKeyId && cfg.secretAccessKey && cfg.bucket) {
s3Client = buildS3Client(cfg);
console.log(`[S3] Client initialized → ${cfg.endpoint} / ${cfg.bucket}`);
} else {
console.log("[S3] Not configured — set credentials in Admin → S3 Settings");
}
}
initS3();
// Upload with timeout
function withTimeout(promise, ms, label) {
return Promise.race([
promise,
new Promise((resolve) => {
setTimeout(() => {
console.log(`Timeout (${ms}ms) on "${label}" — assuming success`);
resolve({ assumed: true });
}, ms);
}),
]);
}
const UPLOAD_TIMEOUT_MS = 120000;
// ==================== MIME TYPES ====================
const EXTENSION_MIME_MAP = {
mp4:"video/mp4", mov:"video/quicktime", mxf:"application/mxf", mkv:"video/x-matroska",
avi:"video/x-msvideo", wmv:"video/x-ms-wmv", mpg:"video/mpeg", mpeg:"video/mpeg",
m4v:"video/x-m4v", ts:"video/mp2t", m2ts:"video/mp2t", webm:"video/webm",
flv:"video/x-flv", "3gp":"video/3gpp", f4v:"video/mp4", vob:"video/dvd",
ogv:"video/ogg", mts:"video/mp2t", prores:"video/quicktime",
mp3:"audio/mpeg", wav:"audio/wav", aac:"audio/aac", flac:"audio/flac",
ogg:"audio/ogg", wma:"audio/x-ms-wma", aiff:"audio/aiff", m4a:"audio/mp4",
ac3:"audio/ac3", dts:"audio/vnd.dts", opus:"audio/opus",
jpg:"image/jpeg", jpeg:"image/jpeg", png:"image/png", tiff:"image/tiff",
tif:"image/tiff", bmp:"image/bmp", gif:"image/gif", exr:"image/x-exr",
dpx:"image/x-dpx", raw:"image/x-raw", cr2:"image/x-canon-cr2",
nef:"image/x-nikon-nef", arw:"image/x-sony-arw", dng:"image/x-adobe-dng",
psd:"image/vnd.adobe.photoshop", svg:"image/svg+xml", webp:"image/webp",
r3d:"application/octet-stream", braw:"application/octet-stream",
ari:"application/octet-stream", scc:"text/plain", srt:"text/plain",
vtt:"text/vtt", stl:"text/plain", edl:"text/plain", xml:"application/xml",
aaf:"application/octet-stream", ale:"text/plain", cdl:"application/xml",
cube:"text/plain", lut:"text/plain",
};
function getMimeType(filename, fallback) {
const dot = filename.lastIndexOf(".");
if (dot === -1) return fallback || "application/octet-stream";
const ext = filename.substring(dot + 1).toLowerCase();
return EXTENSION_MIME_MAP[ext] || fallback || "application/octet-stream";
}
// ==================== HELPERS ====================
function cleanName(s) {
return (s || "").trim().replace(/[^a-zA-Z0-9'\-.,&()! ]/g, "");
}
function findNode(pathArr) {
let current = getTree();
for (const segment of pathArr) {
const node = current.find((n) => n.name === segment);
if (!node) return null;
current = node.children;
}
return current;
}
// ==================== MIDDLEWARE ====================
const upload = multer({
dest: "/tmp/uploads/",
limits: { fileSize: 50 * 1024 * 1024 * 1024 },
});
app.use((req, res, next) => {
if (req.path === "/" || req.path.endsWith(".html") || req.path.endsWith(".png") || req.path.endsWith(".svg")) {
res.setHeader("Cache-Control", "no-store, no-cache, must-revalidate, proxy-revalidate");
res.setHeader("Pragma", "no-cache");
res.setHeader("Expires", "0");
}
next();
});
app.use(express.static(path.join(__dirname, "public"), { etag: false, lastModified: false }));
app.use(express.json({ limit: "100mb" }));
app.use((req, res, next) => {
if (req.url.startsWith("/api/")) {
const size = req.headers["content-length"]
? `${(parseInt(req.headers["content-length"]) / 1024 / 1024).toFixed(1)}MB` : "?";
console.log(`[${new Date().toISOString()}] ${req.method} ${req.url} (${size})`);
}
next();
});
// ==================== SESSIONS ====================
const sessions = new Map();
// ==================== AUTH MIDDLEWARE ====================
function requireAuth(req, res, next) {
const token = req.headers["x-auth-token"];
if (!token || !sessions.has(token)) return res.status(401).json({ success: false, error: "Unauthorized" });
req.sessionData = sessions.get(token);
next();
}
function requireAdmin(req, res, next) {
requireAuth(req, res, () => {
if (req.sessionData.role !== "admin") return res.status(403).json({ success: false, error: "Admin access required" });
next();
});
}
// ==================== AUTH ====================
app.post("/api/login", (req, res) => {
const { username, password } = req.body;
const user = db.users.find((u) => u.username === username);
if (!user || !verifyPassword(password, user.password))
return res.status(401).json({ success: false, error: "Invalid credentials" });
const token = crypto.randomBytes(32).toString("hex");
sessions.set(token, { user: user.username, role: user.role, created: Date.now() });
res.json({ success: true, token, user: user.username, role: user.role });
});
app.post("/api/logout", requireAuth, (req, res) => {
sessions.delete(req.headers["x-auth-token"]);
res.json({ success: true });
});
// ==================== USER MANAGEMENT ====================
app.get("/api/users", requireAdmin, (req, res) => {
res.json({ success: true, users: db.users.map((u) => ({ username: u.username, role: u.role, created: u.created })) });
});
app.post("/api/users", requireAdmin, (req, res) => {
const { username, password, role } = req.body;
if (!username || !password) return res.status(400).json({ success: false, error: "Username and password required" });
const cleanUser = username.trim();
if (cleanUser.length < 2) return res.status(400).json({ success: false, error: "Username must be at least 2 characters" });
if (password.length < 4) return res.status(400).json({ success: false, error: "Password must be at least 4 characters" });
if (db.users.find((u) => u.username.toLowerCase() === cleanUser.toLowerCase()))
return res.status(400).json({ success: false, error: "Username already exists" });
const newUser = { username: cleanUser, password: hashPassword(password), role: role === "admin" ? "admin" : "user", created: new Date().toISOString() };
db.users.push(newUser);
saveData(db);
res.json({ success: true, user: { username: newUser.username, role: newUser.role, created: newUser.created } });
});
app.delete("/api/users/:username", requireAdmin, (req, res) => {
const target = decodeURIComponent(req.params.username);
if (target === req.sessionData.user) return res.status(400).json({ success: false, error: "Cannot delete your own account" });
const idx = db.users.findIndex((u) => u.username === target);
if (idx === -1) return res.status(404).json({ success: false, error: "User not found" });
db.users.splice(idx, 1);
saveData(db);
for (const [token, sess] of sessions.entries()) { if (sess.user === target) sessions.delete(token); }
res.json({ success: true });
});
app.put("/api/users/:username/password", requireAdmin, (req, res) => {
const target = decodeURIComponent(req.params.username);
const { password } = req.body;
if (!password || password.length < 4) return res.status(400).json({ success: false, error: "Password must be at least 4 characters" });
const user = db.users.find((u) => u.username === target);
if (!user) return res.status(404).json({ success: false, error: "User not found" });
user.password = hashPassword(password);
saveData(db);
res.json({ success: true });
});
app.put("/api/users/:username/role", requireAdmin, (req, res) => {
const target = decodeURIComponent(req.params.username);
const { role } = req.body;
if (target === req.sessionData.user) return res.status(400).json({ success: false, error: "Cannot change your own role" });
const user = db.users.find((u) => u.username === target);
if (!user) return res.status(404).json({ success: false, error: "User not found" });
user.role = role === "admin" ? "admin" : "user";
saveData(db);
res.json({ success: true });
});
// ==================== FOLDERS ====================
app.get("/api/folders", requireAuth, (req, res) => res.json({ success: true, tree: getTree() }));
app.post("/api/folders/add", requireAdmin, (req, res) => {
const { path: nodePath, name } = req.body;
if (!name || typeof name !== "string") return res.status(400).json({ success: false, error: "Name required" });
const cleaned = cleanName(name);
if (!cleaned) return res.status(400).json({ success: false, error: "Invalid name" });
const tree = getTree();
const targetArr = (!nodePath || nodePath.length === 0) ? tree : findNode(nodePath);
if (!targetArr) return res.status(404).json({ success: false, error: "Parent path not found" });
if (!targetArr.find((n) => n.name === cleaned)) targetArr.push({ name: cleaned, children: [] });
setTree(tree);
res.json({ success: true, tree: getTree() });
});
app.post("/api/folders/delete", requireAdmin, (req, res) => {
const { path: nodePath } = req.body;
if (!nodePath || nodePath.length === 0) return res.status(400).json({ success: false, error: "Path required" });
const tree = getTree();
const nodeName = nodePath[nodePath.length - 1];
const parentPath = nodePath.slice(0, -1);
const siblings = parentPath.length === 0 ? tree : findNode(parentPath);
if (!siblings) return res.status(404).json({ success: false, error: "Path not found" });
const idx = siblings.findIndex((n) => n.name === nodeName);
if (idx !== -1) siblings.splice(idx, 1);
setTree(tree);
res.json({ success: true, tree: getTree() });
});
// ==================== S3 CONFIG (Admin) ====================
app.get("/api/s3/config", requireAdmin, (req, res) => {
const cfg = db.s3Config || {};
res.json({
success: true,
config: {
endpoint: cfg.endpoint || "",
region: cfg.region || "us-east-1",
bucket: cfg.bucket || "",
accessKeyId: cfg.accessKeyId || "",
secretKeyExists: !!(cfg.secretAccessKey),
}
});
});
app.put("/api/s3/config", requireAdmin, (req, res) => {
const { endpoint, region, bucket, accessKeyId, secretAccessKey } = req.body;
if (!endpoint || !region || !bucket || !accessKeyId)
return res.status(400).json({ success: false, error: "endpoint, region, bucket, and accessKeyId are required" });
if (!db.s3Config) db.s3Config = {};
db.s3Config.endpoint = endpoint.trim();
db.s3Config.region = region.trim();
db.s3Config.bucket = bucket.trim();
db.s3Config.accessKeyId = accessKeyId.trim();
if (secretAccessKey) db.s3Config.secretAccessKey = secretAccessKey;
saveData(db);
initS3();
res.json({ success: true, message: "S3 configuration saved" });
});
app.post("/api/s3/test", requireAdmin, async (req, res) => {
// Support testing with submitted credentials (may not be saved yet)
const { endpoint, region, bucket, accessKeyId, secretAccessKey } = req.body;
const testCfg = {
endpoint: endpoint || db.s3Config?.endpoint || "",
region: region || db.s3Config?.region || "us-east-1",
bucket: bucket || db.s3Config?.bucket || "",
accessKeyId: accessKeyId || db.s3Config?.accessKeyId || "",
secretAccessKey: secretAccessKey || db.s3Config?.secretAccessKey || "",
};
if (!testCfg.endpoint || !testCfg.bucket || !testCfg.accessKeyId || !testCfg.secretAccessKey)
return res.status(400).json({ success: false, error: "All S3 fields required to test connection" });
const testClient = buildS3Client(testCfg);
const testKey = `_dragonwind_test_${Date.now()}.txt`;
try {
// Upload a tiny test file
await testClient.send(new PutObjectCommand({
Bucket: testCfg.bucket,
Key: testKey,
Body: Buffer.from("Dragon Wind S3 connection test"),
ContentType: "text/plain",
}));
// Delete it immediately
try {
await testClient.send(new DeleteObjectCommand({ Bucket: testCfg.bucket, Key: testKey }));
} catch (_) { /* delete failure is non-fatal */ }
res.json({ success: true, message: "S3 connection confirmed! Test file uploaded and deleted successfully." });
} catch (err) {
let friendly = err.message;
if (err.name === "NoSuchBucket") friendly = `Bucket "${testCfg.bucket}" does not exist`;
else if (err.name === "InvalidAccessKeyId") friendly = "Invalid Access Key ID";
else if (err.name === "SignatureDoesNotMatch") friendly = "Invalid Secret Access Key";
else if (err.code === "ECONNREFUSED" || err.message?.includes("ECONNREFUSED")) friendly = "Connection refused — check endpoint URL and port";
else if (err.code === "ENOTFOUND" || err.message?.includes("ENOTFOUND")) friendly = "Endpoint not found — check the URL";
else if (err.message?.includes("TLS") || err.message?.includes("certificate")) friendly = "TLS/SSL error — endpoint certificate issue";
console.error("[S3 Test] Error:", err.message);
res.status(400).json({ success: false, error: friendly });
}
});
// ==================== RELAY CONFIG (Admin) ====================
app.get("/api/relay/config", requireAdmin, (req, res) => {
const cfg = db.relayConfig || {};
res.json({ success: true, config: { relayUrl: cfg.relayUrl || "", udpPort: cfg.udpPort || 5000 } });
});
app.put("/api/relay/config", requireAdmin, (req, res) => {
const { relayUrl, udpPort } = req.body;
if (!db.relayConfig) db.relayConfig = {};
db.relayConfig.relayUrl = (relayUrl || "").trim();
db.relayConfig.udpPort = parseInt(udpPort) || 5000;
saveData(db);
res.json({ success: true, message: "Relay configuration saved" });
});
// ==================== FILE VALIDATION ====================
const BLOCKED_EXTENSIONS = new Set([
"exe","sh","bash","bat","cmd","ps1","msi","dll","com","scr","vbs","js","jar",
"py","rb","pl","php","cgi","wsf","reg","inf","app","dmg","run","bin","elf",
"apk","deb","rpm","ssh","csh","ksh","zsh","fish","command","action","workflow",
]);
function isBlockedFile(filename) {
const dot = filename.lastIndexOf(".");
if (dot === -1) return false;
return BLOCKED_EXTENSIONS.has(filename.substring(dot + 1).toLowerCase());
}
// ==================== FILE UPLOAD (multipart / HTTP mode) ====================
app.post("/api/upload", requireAuth, upload.array("files", 50), async (req, res) => {
if (!s3Client) return res.status(503).json({ success: false, error: "S3 not configured. Go to Admin → S3 Settings." });
console.log(`Upload: ${req.files?.length || 0} file(s), prefix="${req.body.prefix || ""}"`);
try {
const prefix = req.body.prefix || "";
const results = [];
const blocked = req.files.filter((f) => isBlockedFile(f.originalname));
if (blocked.length > 0) {
for (const f of req.files) { try { fs.unlinkSync(f.path); } catch (_) {} }
return res.status(400).json({ success: false, error: `Blocked executable files: ${blocked.map((f) => f.originalname).join(", ")}` });
}
const bucket = db.s3Config?.bucket || "";
for (const file of req.files) {
const key = prefix ? `${prefix.replace(/[-\/]+$/, "")}--${file.originalname}` : file.originalname;
const contentType = getMimeType(file.originalname, file.mimetype);
console.log(`Uploading: ${key} (${file.size} bytes, ${contentType})`);
const uploadPromise = new Upload({
client: s3Client,
params: { Bucket: bucket, Key: key, Body: fs.createReadStream(file.path), ContentType: contentType },
queueSize: 4,
partSize: 10 * 1024 * 1024,
leavePartsOnError: false,
}).done();
const result = await withTimeout(uploadPromise, UPLOAD_TIMEOUT_MS, key);
if (result?.assumed) console.log(`Assumed success (timeout): ${key}`);
else console.log(`Confirmed success: ${key}`);
try { fs.unlinkSync(file.path); } catch (_) {}
results.push({ originalName: file.originalname, key, size: file.size, timestamp: new Date().toISOString() });
}
res.json({ success: true, uploaded: results });
} catch (err) {
console.error("Upload error:", err.message);
if (req.files) for (const f of req.files) { try { fs.unlinkSync(f.path); } catch (_) {} }
res.status(500).json({ success: false, error: err.message });
}
});
// ==================== PRESIGNED URL (for Chrome extension HTTP mode) ====================
app.post("/api/presigned", requireAuth, async (req, res) => {
if (!s3Client) return res.status(503).json({ success: false, error: "S3 not configured" });
const { filename, prefix, contentType } = req.body;
if (!filename) return res.status(400).json({ success: false, error: "filename required" });
const bucket = db.s3Config?.bucket || "";
const key = prefix ? `${prefix.replace(/[-\/]+$/, "")}--${filename}` : filename;
const mime = contentType || getMimeType(filename, "application/octet-stream");
try {
const url = await getSignedUrl(s3Client, new PutObjectCommand({ Bucket: bucket, Key: key, ContentType: mime }), { expiresIn: 3600 });
res.json({ success: true, url, key, bucket, contentType: mime });
} catch (err) {
console.error("Presigned URL error:", err.message);
res.status(500).json({ success: false, error: err.message });
}
});
// ==================== UDP UPLOAD SESSION ====================
// Sessions stored in memory; relay handles actual transfer
const udpSessions = new Map();
app.post("/api/udp/session", requireAuth, (req, res) => {
const { filename, size, prefix } = req.body;
if (!filename || !size) return res.status(400).json({ success: false, error: "filename and size required" });
const relayUrl = db.relayConfig?.relayUrl || "";
const udpPort = db.relayConfig?.udpPort || 5000;
if (!relayUrl) return res.status(503).json({ success: false, error: "UDP relay not configured. Go to Admin → Relay Settings." });
const sessionId = crypto.randomBytes(16).toString("hex");
const key = (prefix ? `${prefix.replace(/[-\/]+$/, "")}--${filename}` : filename);
udpSessions.set(sessionId, {
sessionId, filename, size, key,
status: "pending",
createdAt: Date.now(),
updatedAt: Date.now(),
});
// Cleanup after 2 hours
setTimeout(() => udpSessions.delete(sessionId), 2 * 60 * 60 * 1000);
res.json({ success: true, sessionId, relayUrl, udpPort, key, s3Bucket: db.s3Config?.bucket || "" });
});
app.get("/api/udp/session/:id", requireAuth, (req, res) => {
const sess = udpSessions.get(req.params.id);
if (!sess) return res.status(404).json({ success: false, error: "Session not found" });
res.json({ success: true, session: sess });
});
app.post("/api/udp/session/:id/complete", requireAuth, (req, res) => {
const sess = udpSessions.get(req.params.id);
if (!sess) return res.status(404).json({ success: false, error: "Session not found" });
const { success: ok, error: errMsg } = req.body;
sess.status = ok ? "completed" : "failed";
sess.error = errMsg || null;
sess.updatedAt = Date.now();
res.json({ success: true, status: sess.status });
});
app.get("/api/udp/relay/health", async (req, res) => {
const relayUrl = db.relayConfig?.relayUrl || "";
if (!relayUrl) return res.json({ healthy: false, error: "Relay not configured" });
try {
const controller = new AbortController();
const t = setTimeout(() => controller.abort(), 5000);
const r = await fetch(`${relayUrl}/health`, { signal: controller.signal });
clearTimeout(t);
const data = await r.json().catch(() => ({}));
res.json({ healthy: r.ok, status: r.status, ...data });
} catch (err) {
res.json({ healthy: false, error: err.message });
}
});
// ==================== AMPP JOB MONITORING ====================
const AMPP_BASE = process.env.AMPP_BASE_URL || "https://us-east-1.gvampp.com";
const AMPP_API_KEY = process.env.AMPP_API_KEY || "";
let amppToken = null;
let amppTokenExpiry = 0;
async function getAmppToken() {
if (amppToken && Date.now() < amppTokenExpiry - 60000) return amppToken;
const r = await fetch(`${AMPP_BASE}/identity/connect/token`, {
method: "POST",
headers: { "Authorization": `Basic ${AMPP_API_KEY}`, "Content-Type": "application/x-www-form-urlencoded" },
body: "grant_type=client_credentials&scope=platform",
});
if (!r.ok) throw new Error(`AMPP auth failed (${r.status})`);
const data = await r.json();
amppToken = data.access_token;
amppTokenExpiry = Date.now() + (data.expires_in || 86400) * 1000;
return amppToken;
}
app.get("/api/ampp/jobs", requireAuth, async (req, res) => {
if (!AMPP_API_KEY) return res.status(503).json({ success: false, error: "AMPP API key not configured" });
try {
const token = await getAmppToken();
const limit = parseInt(req.query.limit) || 100;
const skip = parseInt(req.query.skip) || 0;
const url = `${AMPP_BASE}/api/v1/queue/job/jobs/querypage?skip=${skip}&limit=${limit}&sort=created:dateTime&asc=false`;
const controller = new AbortController();
const t = setTimeout(() => controller.abort(), 15000);
const r = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}` }, body: "{}", signal: controller.signal });
clearTimeout(t);
if (r.status === 401) {
amppToken = null; amppTokenExpiry = 0;
const fresh = await getAmppToken();
const r2 = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json", "Authorization": `Bearer ${fresh}` }, body: "{}" });
if (!r2.ok) return res.status(r2.status).json({ success: false, error: `AMPP ${r2.status}` });
return res.json({ success: true, jobs: await r2.json() });
}
if (!r.ok) return res.status(r.status).json({ success: false, error: `AMPP ${r.status}` });
res.json({ success: true, jobs: await r.json() });
} catch (err) {
if (err.name === "AbortError") return res.status(504).json({ success: false, error: "AMPP timeout" });
res.status(500).json({ success: false, error: err.message });
}
});
app.get("/api/ampp/jobs/:jobId", requireAuth, async (req, res) => {
if (!AMPP_API_KEY) return res.status(503).json({ success: false, error: "AMPP API key not configured" });
try {
const token = await getAmppToken();
const r = await fetch(`${AMPP_BASE}/api/v1/queue/job/jobs/${encodeURIComponent(req.params.jobId)}`, { headers: { "Authorization": `Bearer ${token}` } });
if (!r.ok) return res.status(r.status).json({ success: false, error: `AMPP ${r.status}` });
res.json({ success: true, job: await r.json() });
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
});
// ==================== HEALTH ====================
app.get("/api/health", (req, res) => {
res.json({
status: "ok",
s3Configured: !!(s3Client),
bucket: db.s3Config?.bucket || null,
relayConfigured: !!(db.relayConfig?.relayUrl),
relayUrl: db.relayConfig?.relayUrl || null,
});
});
// ==================== ERROR HANDLER ====================
app.use((err, req, res, next) => {
console.error("Global error:", err.message);
if (err.code === "LIMIT_FILE_SIZE") return res.status(413).json({ success: false, error: "File too large" });
if (!res.headersSent) res.status(500).json({ success: false, error: err.message || "Internal server error" });
});
process.on("unhandledRejection", (reason) => console.error("Unhandled rejection:", reason));
process.on("uncaughtException", (err) => console.error("Uncaught exception:", err));
// ==================== START ====================
const server = app.listen(PORT, "0.0.0.0", () => {
console.log(`\n🌪️ Dragon Wind running on port ${PORT}`);
console.log(` S3: ${s3Client ? db.s3Config?.endpoint : "NOT CONFIGURED"}`);
console.log(` Relay: ${db.relayConfig?.relayUrl || "NOT CONFIGURED"}`);
console.log(` Admin: ${DEFAULT_ADMIN_USER}`);
});
server.timeout = 0;
server.keepAliveTimeout = 0;
server.headersTimeout = 0;
server.requestTimeout = 0;

8
udp-relay/Dockerfile Normal file
View file

@ -0,0 +1,8 @@
FROM node:20-alpine
WORKDIR /app
COPY package.json .
RUN npm install --omit=dev
COPY server.js .
EXPOSE 3001/tcp
EXPOSE 5000/udp
CMD ["node", "server.js"]

14
udp-relay/package.json Normal file
View file

@ -0,0 +1,14 @@
{
"name": "dragon-wind-relay",
"version": "1.0.0",
"description": "Dragon Wind UDP Relay Server — high-speed UDP-to-S3 transfer relay",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.650.0",
"@smithy/node-http-handler": "^3.2.0",
"express": "^4.21.0"
}
}

308
udp-relay/server.js Normal file
View file

@ -0,0 +1,308 @@
"use strict";
/**
* Dragon Wind UDP Relay Server
*
* Acts as a high-speed relay between the Chrome extension (or any UDP client)
* and the S3 destination. Uses Forward Error Correction (FEC) style chunking
* with acknowledgement over a TCP control channel, and raw UDP for data.
*
* Architecture:
* Client --[UDP data]--> Relay --[S3 multipart upload]--> S3
* Client <--[TCP ACK]--- Relay
*
* Ports:
* TCP 3001 Control / HTTP API (health, session management)
* UDP 5000 Data transfer
*
* Environment:
* PORT TCP control port (default 3001)
* UDP_PORT UDP data port (default 5000)
* MAX_SESSIONS Max concurrent sessions (default 50)
*/
const express = require("express");
const dgram = require("dgram");
const crypto = require("crypto");
const { S3Client, CreateMultipartUploadCommand, UploadPartCommand,
CompleteMultipartUploadCommand, AbortMultipartUploadCommand } = require("@aws-sdk/client-s3");
const { NodeHttpHandler } = require("@smithy/node-http-handler");
const https = require("https");
const http = require("http");
const TCP_PORT = parseInt(process.env.PORT || "3001");
const UDP_PORT = parseInt(process.env.UDP_PORT || "5000");
const MAX_SESSIONS = parseInt(process.env.MAX_SESSIONS || "50");
const CHUNK_SIZE = 64 * 1024; // 64KB UDP chunks
const PART_SIZE = 5 * 1024 * 1024; // 5MB S3 multipart parts
const SESSION_TTL = 2 * 60 * 60 * 1000; // 2 hours
// ==================== STATE ====================
const sessions = new Map(); // sessionId → RelaySession
// ==================== S3 CLIENT ====================
function makeS3(cfg) {
const isHttps = (cfg.endpoint || "").startsWith("https");
const agent = isHttps
? new https.Agent({ keepAlive: true, maxSockets: 10, rejectUnauthorized: false })
: new http.Agent({ keepAlive: true, maxSockets: 10 });
return new S3Client({
endpoint: cfg.endpoint,
region: cfg.region || "us-east-1",
credentials: { accessKeyId: cfg.accessKeyId, secretAccessKey: cfg.secretAccessKey },
forcePathStyle: true,
requestHandler: new NodeHttpHandler({
connectionTimeout: 15000, socketTimeout: 600000,
...(isHttps ? { httpsAgent: agent } : { httpAgent: agent }),
}),
});
}
// ==================== SESSION CLASS ====================
class RelaySession {
constructor({ sessionId, filename, size, key, bucket, s3Cfg }) {
this.sessionId = sessionId;
this.filename = filename;
this.size = size;
this.key = key;
this.bucket = bucket;
this.s3 = makeS3(s3Cfg);
this.status = "pending";
this.receivedBytes = 0;
this.chunks = new Map(); // chunkIndex → Buffer
this.expectedChunks = Math.ceil(size / CHUNK_SIZE);
this.uploadId = null;
this.parts = [];
this.partBuffer = Buffer.alloc(0);
this.partNumber = 1;
this.createdAt = Date.now();
this.clientAddr = null;
this.clientPort = null;
// Timeout cleanup
this._timeout = setTimeout(() => this._cleanup("timeout"), SESSION_TTL);
}
async initMultipart() {
const cmd = new CreateMultipartUploadCommand({ Bucket: this.bucket, Key: this.key });
const r = await this.s3.send(cmd);
this.uploadId = r.UploadId;
this.status = "receiving";
console.log(`[${this.sessionId.slice(0, 8)}] Multipart initiated: ${this.key} (${this.uploadId})`);
}
// Called for each received UDP chunk
async receiveChunk(chunkIndex, data) {
if (this.chunks.has(chunkIndex)) return; // duplicate
this.chunks.set(chunkIndex, data);
this.receivedBytes += data.length;
// Append to part buffer
this.partBuffer = Buffer.concat([this.partBuffer, data]);
// Flush part when buffer hits PART_SIZE or we have all chunks
if (this.partBuffer.length >= PART_SIZE || this.chunks.size === this.expectedChunks) {
await this._flushPart();
}
}
async _flushPart() {
if (this.partBuffer.length === 0) return;
const body = this.partBuffer;
this.partBuffer = Buffer.alloc(0);
const cmd = new UploadPartCommand({
Bucket: this.bucket, Key: this.key,
UploadId: this.uploadId,
PartNumber: this.partNumber,
Body: body,
ContentLength: body.length,
});
const r = await this.s3.send(cmd);
this.parts.push({ PartNumber: this.partNumber, ETag: r.ETag });
console.log(`[${this.sessionId.slice(0, 8)}] Part ${this.partNumber} uploaded (${body.length} bytes)`);
this.partNumber++;
}
async complete() {
await this._flushPart(); // flush remainder
const cmd = new CompleteMultipartUploadCommand({
Bucket: this.bucket, Key: this.key,
UploadId: this.uploadId,
MultipartUpload: { Parts: this.parts },
});
await this.s3.send(cmd);
this.status = "completed";
clearTimeout(this._timeout);
console.log(`[${this.sessionId.slice(0, 8)}] ✅ Upload complete: ${this.key}`);
}
async abort() {
if (this.uploadId) {
try {
await this.s3.send(new AbortMultipartUploadCommand({
Bucket: this.bucket, Key: this.key, UploadId: this.uploadId,
}));
} catch (_) {}
}
this._cleanup("aborted");
}
_cleanup(reason) {
clearTimeout(this._timeout);
this.status = reason;
sessions.delete(this.sessionId);
console.log(`[${this.sessionId.slice(0, 8)}] Session ${reason}`);
}
progress() {
return {
sessionId: this.sessionId,
filename: this.filename,
size: this.size,
receivedBytes: this.receivedBytes,
expectedChunks: this.expectedChunks,
receivedChunks: this.chunks.size,
percent: this.size > 0 ? Math.round((this.receivedBytes / this.size) * 100) : 0,
status: this.status,
parts: this.parts.length,
};
}
}
// ==================== UDP SERVER ====================
const udpServer = dgram.createSocket("udp4");
/*
* UDP Packet Format (binary):
* [0..15] sessionId (16 bytes, hex binary)
* [16..19] chunkIndex (uint32 BE)
* [20..] data payload
*/
udpServer.on("message", async (msg, rinfo) => {
if (msg.length < 21) return; // too short
const sessionIdBuf = msg.slice(0, 16);
const sessionId = sessionIdBuf.toString("hex");
const chunkIndex = msg.readUInt32BE(16);
const data = msg.slice(20);
const session = sessions.get(sessionId);
if (!session) {
console.warn(`[UDP] Unknown session: ${sessionId.slice(0, 8)}`);
return;
}
// Track client address for ACKs
if (!session.clientAddr) {
session.clientAddr = rinfo.address;
session.clientPort = rinfo.port;
}
try {
if (session.status === "pending") {
await session.initMultipart();
}
await session.receiveChunk(chunkIndex, data);
// Send ACK back to client
// ACK format: [0..15] sessionId [16..19] chunkIndex [20] status(0=ok)
const ack = Buffer.alloc(21);
sessionIdBuf.copy(ack, 0);
ack.writeUInt32BE(chunkIndex, 16);
ack.writeUInt8(0, 20);
udpServer.send(ack, rinfo.port, rinfo.address);
// Auto-complete when all chunks received
if (session.chunks.size === session.expectedChunks && session.status === "receiving") {
await session.complete();
}
} catch (err) {
console.error(`[UDP] Error for session ${sessionId.slice(0, 8)}:`, err.message);
// Send NACK
const nack = Buffer.alloc(21);
sessionIdBuf.copy(nack, 0);
nack.writeUInt32BE(chunkIndex, 16);
nack.writeUInt8(1, 20); // 1 = error
udpServer.send(nack, rinfo.port, rinfo.address);
}
});
udpServer.on("error", (err) => console.error("[UDP] Server error:", err));
udpServer.bind(UDP_PORT, "0.0.0.0", () => {
console.log(`🌪️ UDP relay listening on port ${UDP_PORT}`);
});
// ==================== HTTP CONTROL API ====================
const app = express();
app.use(express.json());
// Health check
app.get("/health", (req, res) => {
res.json({
status: "ok",
udpPort: UDP_PORT,
activeSessions: sessions.size,
maxSessions: MAX_SESSIONS,
uptime: process.uptime(),
});
});
// Create session
app.post("/session", (req, res) => {
const { sessionId, filename, size, key, bucket, s3Config } = req.body;
if (!sessionId || !filename || !size || !key || !bucket || !s3Config)
return res.status(400).json({ error: "Missing required fields: sessionId, filename, size, key, bucket, s3Config" });
if (!s3Config.endpoint || !s3Config.accessKeyId || !s3Config.secretAccessKey)
return res.status(400).json({ error: "s3Config must include endpoint, accessKeyId, secretAccessKey" });
if (sessions.size >= MAX_SESSIONS)
return res.status(503).json({ error: "Max concurrent sessions reached" });
if (sessions.has(sessionId))
return res.status(409).json({ error: "Session already exists" });
const session = new RelaySession({ sessionId, filename, size, key, bucket, s3Cfg: s3Config });
sessions.set(sessionId, session);
console.log(`[HTTP] Session created: ${sessionId.slice(0, 8)}${filename} (${size} bytes)`);
res.json({ success: true, sessionId, udpPort: UDP_PORT, chunkSize: CHUNK_SIZE, expectedChunks: session.expectedChunks });
});
// Get session progress
app.get("/session/:id", (req, res) => {
const session = sessions.get(req.params.id);
if (!session) return res.status(404).json({ error: "Session not found" });
res.json(session.progress());
});
// Complete session manually
app.post("/session/:id/complete", async (req, res) => {
const session = sessions.get(req.params.id);
if (!session) return res.status(404).json({ error: "Session not found" });
try {
await session.complete();
res.json({ success: true, status: "completed" });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Abort session
app.delete("/session/:id", async (req, res) => {
const session = sessions.get(req.params.id);
if (!session) return res.status(404).json({ error: "Session not found" });
await session.abort();
res.json({ success: true, status: "aborted" });
});
// List all sessions
app.get("/sessions", (req, res) => {
res.json({ sessions: Array.from(sessions.values()).map((s) => s.progress()) });
});
app.listen(TCP_PORT, "0.0.0.0", () => {
console.log(`🌪️ Dragon Wind Relay control API on port ${TCP_PORT}`);
console.log(` UDP data port: ${UDP_PORT}`);
console.log(` Max sessions: ${MAX_SESSIONS}`);
});
process.on("unhandledRejection", (r) => console.error("Unhandled:", r));
process.on("uncaughtException", (e) => console.error("Uncaught:", e));