Compare commits
No commits in common. "main" and "master" have entirely different histories.
23
.env.example
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
# Dragon Wind — example .env
|
||||
# Let setup.sh generate .env automatically (randomized ports), or copy this file:
|
||||
# cp .env.example .env
|
||||
|
||||
# ---- Ports (randomized by setup.sh on first run) ------------
|
||||
WEB_PORT=3000
|
||||
RELAY_TCP_PORT=3001
|
||||
RELAY_UDP_PORT=5000
|
||||
|
||||
# ---- Auth ---------------------------------------------------
|
||||
AUTH_USER=admin
|
||||
AUTH_PASS=DragonWind2026!
|
||||
|
||||
# ---- S3 (can also be configured via Admin UI) ---------------
|
||||
S3_ENDPOINT=
|
||||
S3_REGION=us-east-1
|
||||
S3_BUCKET=
|
||||
S3_ACCESS_KEY=
|
||||
S3_SECRET_KEY=
|
||||
|
||||
# ---- AMPP (can also be configured via Admin UI) -------------
|
||||
AMPP_BASE_URL=https://us-east-1.gvampp.com
|
||||
AMPP_API_KEY=
|
||||
8
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
node_modules/
|
||||
.env
|
||||
/data/
|
||||
/tmp/
|
||||
*.log
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
udp-relay/node_modules/
|
||||
20
Dockerfile
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
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/
|
||||
COPY chrome-extension/ ./chrome-extension/
|
||||
|
||||
# Data volume for persistent config
|
||||
VOLUME ["/data"]
|
||||
|
||||
# HTTP port
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
152
README.md
|
|
@ -1,3 +1,151 @@
|
|||
# DragonWind
|
||||
# 🌪️ Dragon Wind
|
||||
|
||||
Dragon Wind - Dual-mode broadcast uploader with HTTP and UDP acceleration. Fast, reliable file transfer for professional broadcast workflows.
|
||||
**Fast, dual-mode broadcast file uploader with S3 integration, UDP acceleration, and a Chrome extension.**
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
- **HTTP Upload** — Direct S3 presigned uploads, 50–200 MB/s on LAN
|
||||
- **UDP Upload** — Relay-accelerated transfers, 4–8× 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 | 5–10 min | **3–6×** |
|
||||
|
||||
---
|
||||
|
||||
Built for Grass Valley AMPP / FramelightX broadcast workflows.
|
||||
|
|
|
|||
41
chrome-extension/background.js
Normal 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 (_) {}
|
||||
});
|
||||
73
chrome-extension/content.js
Normal 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);
|
||||
}
|
||||
})();
|
||||
BIN
chrome-extension/images/icon-128.png
Normal file
|
After Width: | Height: | Size: 600 B |
BIN
chrome-extension/images/icon-16.png
Normal file
|
After Width: | Height: | Size: 118 B |
BIN
chrome-extension/images/icon-48.png
Normal file
|
After Width: | Height: | Size: 234 B |
36
chrome-extension/manifest.json
Normal 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",
|
||||
"alarms"
|
||||
],
|
||||
"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
|
|
@ -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">🔗 HTTP<br/><small>Reliable</small></button>
|
||||
<button class="mode-btn" id="btn-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">
|
||||
<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/>
|
||||
|
||||
<!-- File List -->
|
||||
<div class="file-list" id="file-list"></div>
|
||||
|
||||
<!-- Upload Button -->
|
||||
<button class="upload-btn http" id="upload-btn" 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" id="save-btn">Save & Connect</button>
|
||||
<div class="status-bar" id="settings-status" style="margin-top:.5rem"></div>
|
||||
</div>
|
||||
|
||||
<script src="popup.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
391
chrome-extension/popup.js
Normal file
|
|
@ -0,0 +1,391 @@
|
|||
"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 && stored.token !== null) authToken = stored.token;
|
||||
if (stored.mode) uploadMode = stored.mode;
|
||||
// Default to HTTP if no mode saved
|
||||
if (!stored.mode) uploadMode = 'http';
|
||||
|
||||
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);
|
||||
|
||||
// If we have a saved token, try using it; otherwise open settings
|
||||
if (authToken) {
|
||||
await tryConnect();
|
||||
} else {
|
||||
// No saved session — open settings panel so user can enter password
|
||||
document.getElementById('settings-panel').classList.add('open');
|
||||
setConnStatus('grey', 'Enter credentials in settings below');
|
||||
}
|
||||
})();
|
||||
|
||||
// ==================== EVENT LISTENERS ====================
|
||||
document.getElementById('settings-toggle').addEventListener('click', () => {
|
||||
document.getElementById('settings-panel').classList.toggle('open');
|
||||
});
|
||||
|
||||
document.getElementById('save-btn').addEventListener('click', saveSettings);
|
||||
document.getElementById('upload-btn').addEventListener('click', startUpload);
|
||||
document.getElementById('btn-http').addEventListener('click', () => setMode('http'));
|
||||
document.getElementById('btn-udp').addEventListener('click', () => setMode('udp'));
|
||||
|
||||
const dropZone = document.getElementById('drop-zone');
|
||||
dropZone.addEventListener('click', () => document.getElementById('file-input').click());
|
||||
dropZone.addEventListener('dragover', (e) => { e.preventDefault(); dropZone.classList.add('over'); });
|
||||
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('over'));
|
||||
dropZone.addEventListener('drop', onDrop);
|
||||
|
||||
document.getElementById('file-input').addEventListener('change', onFileInputChange);
|
||||
|
||||
// ==================== 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');
|
||||
showSettingsStatus(`❌ ${r.error || 'Auth failed'}`, 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
setConnStatus('red', 'Connection failed');
|
||||
showSettingsStatus(`❌ ${e.message || 'Connection failed'}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
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; }
|
||||
if (!username) { showSettingsStatus('Username required', 'error'); return; }
|
||||
if (!password) { showSettingsStatus('Password required', 'error'); return; }
|
||||
|
||||
config = { serverUrl, username, password };
|
||||
authToken = null;
|
||||
connected = false;
|
||||
// Clear old token and save new config (don't save password to storage for security)
|
||||
await chrome.storage.local.remove('token');
|
||||
await chrome.storage.local.set({ config: { serverUrl, username } });
|
||||
document.getElementById('conn-server').textContent = serverUrl.replace(/https?:\/\//, '');
|
||||
|
||||
showSettingsStatus('Saved — connecting…', 'loading');
|
||||
try {
|
||||
await login();
|
||||
if (connected) {
|
||||
showSettingsStatus('✅ Connected successfully!', 'success');
|
||||
}
|
||||
} catch (e) {
|
||||
showSettingsStatus(`❌ ${e.message || 'Unknown error'}`, 'error');
|
||||
setConnStatus('red', e.message || 'Connection failed');
|
||||
}
|
||||
}
|
||||
|
||||
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" data-idx="${i}">×</button>
|
||||
`;
|
||||
list.appendChild(el);
|
||||
});
|
||||
}
|
||||
|
||||
// Delegated listener for remove buttons (avoids inline onclick — CSP compliant)
|
||||
document.getElementById('file-list').addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('.fi-rm');
|
||||
if (btn) removeFile(parseInt(btn.dataset.idx, 10));
|
||||
});
|
||||
|
||||
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; }
|
||||
// If UDP mode selected but relay not configured, fall back to HTTP silently
|
||||
if (uploadMode === 'udp') {
|
||||
try {
|
||||
const health = await apiFetch('GET', '/api/health');
|
||||
if (!health.relayConfigured) {
|
||||
showStatus('⚠️ UDP relay not configured — switching to HTTP', 'loading');
|
||||
setMode('http');
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
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);
|
||||
// Use the content type from the server response (matches what was signed)
|
||||
// NOT item.file.type which may differ from what the server determined
|
||||
const signedContentType = presigned.contentType || item.file.type || 'application/octet-stream';
|
||||
console.log(`[DW] Upload ${item.name} → ${presigned.url.substring(0, 60)}... (${signedContentType})`);
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('PUT', presigned.url);
|
||||
xhr.setRequestHeader('Content-Type', signedContentType);
|
||||
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 = () => {
|
||||
console.log(`[DW] S3 PUT ${item.name} → ${xhr.status}`);
|
||||
xhr.status < 300 ? resolve() : reject(new Error(`S3 error ${xhr.status}: ${xhr.responseText?.substring(0, 200)}`));
|
||||
};
|
||||
xhr.onerror = () => {
|
||||
console.error(`[DW] S3 PUT ${item.name} network error`);
|
||||
reject(new Error('Network error — check console for details'));
|
||||
};
|
||||
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;
|
||||
console.log(`[DW] ${method} ${url}`);
|
||||
const opts = { method, headers: { 'Content-Type': 'application/json' } };
|
||||
if (authToken) opts.headers['x-auth-token'] = authToken;
|
||||
if (body) opts.body = JSON.stringify(body);
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 10000);
|
||||
opts.signal = controller.signal;
|
||||
try {
|
||||
const r = await fetch(url, opts);
|
||||
clearTimeout(timeout);
|
||||
console.log(`[DW] ${method} ${path} → ${r.status}`);
|
||||
// Don't treat 401 on /api/login as session expiry — it's just bad credentials
|
||||
if (r.status === 401 && !path.includes('/api/login')) {
|
||||
authToken = null;
|
||||
await chrome.storage.local.remove('token');
|
||||
throw new Error('Session expired');
|
||||
}
|
||||
return r.json();
|
||||
} catch (e) {
|
||||
clearTimeout(timeout);
|
||||
if (e.name === 'AbortError') throw new Error('Request timed out — check server URL');
|
||||
console.error(`[DW] ${method} ${path} failed:`, e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
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,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||
|
||||
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 '📄';
|
||||
}
|
||||
41
docker-compose.yml
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
version: "3.9"
|
||||
|
||||
# =============================================================
|
||||
# Dragon Wind — Upload Portal
|
||||
# =============================================================
|
||||
|
||||
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:-}
|
||||
# AMPP (optional)
|
||||
- AMPP_BASE_URL=${AMPP_BASE_URL:-https://us-east-1.gvampp.com}
|
||||
- AMPP_API_KEY=${AMPP_API_KEY:-}
|
||||
networks:
|
||||
- dragon-wind-net
|
||||
|
||||
volumes:
|
||||
dragon-wind-data:
|
||||
|
||||
networks:
|
||||
dragon-wind-net:
|
||||
driver: bridge
|
||||
207
lib/ampp-folder-placer.js
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
"use strict";
|
||||
|
||||
// ================================================================
|
||||
// AMPP Folder Placer — lib/ampp-folder-placer.js
|
||||
//
|
||||
// placeAsset() — original v3.5 port (filename -- parsing)
|
||||
// placeAssetInFolderById() — direct placement by known folder ID
|
||||
// getOrCreateFolderByPath() — hierarchy lookup + create, returns folder ID
|
||||
// listAmppFolders() — fetch AMPP virtual folder tree (probes endpoints)
|
||||
// amppRequest() — shared low-level REST helper
|
||||
// ================================================================
|
||||
|
||||
const PREFIX_DELIM = "--";
|
||||
|
||||
// ── Low-level REST helper ─────────────────────────────────────────────────────
|
||||
async function amppRequest(method, baseUrl, token, apiPath, body = null) {
|
||||
const url = `${baseUrl.replace(/\/$/, "")}/${apiPath}`;
|
||||
const opts = {
|
||||
method,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
signal: AbortSignal.timeout(15000),
|
||||
};
|
||||
if (body !== null) {
|
||||
opts.body = typeof body === "string" ? body : JSON.stringify(body);
|
||||
}
|
||||
const r = await fetch(url, opts);
|
||||
if (!r.ok) {
|
||||
const text = await r.text().catch(() => "");
|
||||
throw new Error(
|
||||
`AMPP API ${method} ${apiPath} → HTTP ${r.status}: ${text.slice(0, 200)}`
|
||||
);
|
||||
}
|
||||
return r.json();
|
||||
}
|
||||
|
||||
// ── Get or create a folder by slash-separated path ───────────────────────────
|
||||
// e.g. "NEWS/PACKAGES" → looks up hierarchy, creates missing levels, returns ID.
|
||||
async function getOrCreateFolderByPath(baseUrl, folderPath, token) {
|
||||
const parts = folderPath.split("/").map(s => s.trim()).filter(Boolean);
|
||||
if (!parts.length) throw new Error("folderPath is empty");
|
||||
|
||||
let parentId = null;
|
||||
let currentPath = "";
|
||||
let expectedDepth = 0;
|
||||
|
||||
for (const fname of parts) {
|
||||
currentPath = currentPath ? `${currentPath}/${fname}` : fname;
|
||||
expectedDepth++;
|
||||
let folderId = null;
|
||||
|
||||
// Try hierarchy lookup first
|
||||
try {
|
||||
const encoded = currentPath.split("/").map(encodeURIComponent).join("/");
|
||||
const resp = await amppRequest("GET", baseUrl, token,
|
||||
`api/v1/store/folder/folders/hierarchy?path=${encoded}`);
|
||||
const hlist = resp?.["hierarchy:list"];
|
||||
if (Array.isArray(hlist) && hlist.length === expectedDepth) {
|
||||
const last = hlist[hlist.length - 1];
|
||||
const candidateId = (last?.["folder:id"] ?? "").toString().trim();
|
||||
if (candidateId) folderId = candidateId;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`[AMPP] Hierarchy lookup for '${currentPath}': ${err.message}`);
|
||||
}
|
||||
|
||||
// Create the folder if not found
|
||||
if (!folderId) {
|
||||
const createBody = { "name:text": fname };
|
||||
if (parentId) createBody["parentFolders:tags"] = [parentId];
|
||||
const fresp = await amppRequest("POST", baseUrl, token,
|
||||
"api/v1/store/folder/folders", createBody);
|
||||
const createdId = (fresp?.["folder:id"] ?? "").toString().trim();
|
||||
if (!createdId) throw new Error(`Folder create response missing folder:id for '${fname}'`);
|
||||
folderId = createdId;
|
||||
}
|
||||
|
||||
parentId = folderId;
|
||||
}
|
||||
|
||||
return parentId; // ID of the deepest folder
|
||||
}
|
||||
|
||||
// ── List AMPP virtual folders ────────────────────────────────────────────────
|
||||
// Probes multiple candidate endpoints in order; uses the first that responds.
|
||||
// Returns: { folders: [{id, name, parentId, path}], endpoint }
|
||||
async function listAmppFolders(baseUrl, token) {
|
||||
let lastError = null;
|
||||
|
||||
// Helper: normalise raw array of folder objects
|
||||
function normaliseFolders(raw) {
|
||||
if (!Array.isArray(raw)) return null;
|
||||
const folders = raw.map((f) => ({
|
||||
id: (f["folder:id"] ?? f.id ?? "").toString().trim(),
|
||||
name: (f["name:text"] ?? f.name ?? f["folder:name"] ?? "").toString().trim(),
|
||||
parentId: (f["parentFolder:id"] ?? f.parentId ?? f["parent:id"] ?? "").toString().trim(),
|
||||
path: (f["folder:path"] ?? f.path ?? "").toString().trim(),
|
||||
})).filter(f => f.id && f.name);
|
||||
return folders;
|
||||
}
|
||||
|
||||
// 1. Try POST querypage (same pattern as the jobs API)
|
||||
const queryPagePaths = [
|
||||
"api/v1/store/folder/folders/querypage?skip=0&limit=500&sort=name:text&asc=true",
|
||||
"api/v1/store/folder/folders/querypage",
|
||||
];
|
||||
for (const candidate of queryPagePaths) {
|
||||
try {
|
||||
const resp = await amppRequest("POST", baseUrl, token, candidate, "{}");
|
||||
const raw = Array.isArray(resp) ? resp
|
||||
: (resp?.["folder:list"] ?? resp?.items ?? resp?.results ?? resp?.folders ?? []);
|
||||
const folders = normaliseFolders(raw);
|
||||
if (folders && folders.length > 0) {
|
||||
console.log(`[AMPP folders] ${folders.length} folders via POST ${candidate}`);
|
||||
return { folders, endpoint: `POST ${candidate}` };
|
||||
}
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
console.warn(`[AMPP folders] POST ${candidate} failed: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Try GET candidates
|
||||
const getCandidates = [
|
||||
"api/v1/store/folder/folders?limit=500",
|
||||
"api/v1/store/folder/folders",
|
||||
"api/v1/store/folder/folder/list",
|
||||
"api/v1/store/folder/folders/list",
|
||||
];
|
||||
for (const candidate of getCandidates) {
|
||||
try {
|
||||
const resp = await amppRequest("GET", baseUrl, token, candidate);
|
||||
const raw = Array.isArray(resp) ? resp
|
||||
: (resp?.["folder:list"] ?? resp?.folders ?? resp?.items ?? resp?.results ?? []);
|
||||
const folders = normaliseFolders(raw);
|
||||
if (folders && folders.length > 0) {
|
||||
console.log(`[AMPP folders] ${folders.length} folders via GET ${candidate}`);
|
||||
return { folders, endpoint: candidate };
|
||||
}
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
console.warn(`[AMPP folders] GET ${candidate} failed: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Hierarchy walk: call hierarchy with empty path to probe root-level folders
|
||||
try {
|
||||
const resp = await amppRequest("GET", baseUrl, token, "api/v1/store/folder/folders/hierarchy?path=");
|
||||
const hlist = resp?.["hierarchy:list"] ?? resp?.["folder:list"];
|
||||
if (Array.isArray(hlist) && hlist.length > 0) {
|
||||
const folders = normaliseFolders(hlist);
|
||||
if (folders && folders.length > 0) {
|
||||
console.log(`[AMPP folders] ${folders.length} root folders via hierarchy walk`);
|
||||
return { folders, endpoint: "hierarchy-root" };
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
console.warn(`[AMPP folders] hierarchy root probe failed: ${err.message}`);
|
||||
}
|
||||
|
||||
// All endpoints failed — caller should fall back to manual path entry
|
||||
throw new Error(
|
||||
`Could not list AMPP folders — no list endpoint available. ${lastError?.message ?? ""}`
|
||||
);
|
||||
}
|
||||
|
||||
// ── Direct placement by known folder ID ──────────────────────────────────────
|
||||
async function placeAssetInFolderById(baseUrl, assetId, folderId, token) {
|
||||
if (!assetId) throw new Error("assetId is required");
|
||||
if (!folderId) throw new Error("folderId is required");
|
||||
|
||||
const linkBody = JSON.stringify(
|
||||
{ "folder:id": folderId, "asset:id": assetId },
|
||||
null, 0
|
||||
);
|
||||
await amppRequest("POST", baseUrl, token, "api/v1/store/folder/references", linkBody);
|
||||
return { placed: true, folderId, assetId };
|
||||
}
|
||||
|
||||
// ── Original v3.5 placement (filename -- parsing) ────────────────────────────
|
||||
async function placeAsset(baseUrl, assetName, assetId, token) {
|
||||
if (!assetName.includes(PREFIX_DELIM)) {
|
||||
return { placed: false, folderId: null, path: "", reason: `no "${PREFIX_DELIM}" delimiter in filename` };
|
||||
}
|
||||
|
||||
const parts = assetName.split(PREFIX_DELIM);
|
||||
if (parts.length < 2) {
|
||||
return { placed: false, folderId: null, path: "", reason: "fewer than 2 parts after split" };
|
||||
}
|
||||
|
||||
const folderNames = parts.slice(0, -1);
|
||||
const folderPath = folderNames.map(s => s.trim()).filter(Boolean).join("/");
|
||||
|
||||
const folderId = await getOrCreateFolderByPath(baseUrl, folderPath, token);
|
||||
|
||||
if (folderId) {
|
||||
const linkBody = JSON.stringify({ "folder:id": folderId, "asset:id": assetId }, null, 0);
|
||||
await amppRequest("POST", baseUrl, token, "api/v1/store/folder/references", linkBody);
|
||||
}
|
||||
|
||||
return { placed: true, folderId, path: folderPath };
|
||||
}
|
||||
|
||||
module.exports = { placeAsset, placeAssetInFolderById, getOrCreateFolderByPath, listAmppFolders, amppRequest };
|
||||
195
lib/ampp-placement-worker.js
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
"use strict";
|
||||
|
||||
const { placeAssetInFolderById, getOrCreateFolderByPath } = require("./ampp-folder-placer");
|
||||
|
||||
// ================================================================
|
||||
// AMPP Placement Worker — lib/ampp-placement-worker.js
|
||||
//
|
||||
// Runs inside DragonWind as a background setInterval loop.
|
||||
// Every POLL_INTERVAL_MS it:
|
||||
// 1. Finds pending placements in db.pendingPlacements
|
||||
// 2. Fetches recent completed AMPP ingest jobs
|
||||
// 3. Matches jobs to placements by filename (broad field search)
|
||||
// 4. Calls placeAssetInFolderById() with the known folder ID
|
||||
// 5. Updates placement status in db
|
||||
//
|
||||
// Job field matching:
|
||||
// AMPP job field names vary by version. The worker searches ALL
|
||||
// string values in the job object for a match against the filename.
|
||||
// It also logs the full key list of the first matched job so you
|
||||
// can identify the exact field name and configure ASSET_ID_FIELDS.
|
||||
//
|
||||
// Tune ASSET_ID_FIELDS below once you know your AMPP's field names.
|
||||
// ================================================================
|
||||
|
||||
// Fields to check for the asset:id — in priority order
|
||||
const ASSET_ID_FIELDS = [
|
||||
"asset:id",
|
||||
"assetId",
|
||||
"job:assetId",
|
||||
"asset:uuid",
|
||||
"id",
|
||||
];
|
||||
|
||||
// How old a pending placement can be before we give up on it (24h)
|
||||
const EXPIRY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
// Default poll interval (overridable via start())
|
||||
const DEFAULT_POLL_MS = 30_000;
|
||||
|
||||
class AmppPlacementWorker {
|
||||
/**
|
||||
* @param {object} opts
|
||||
* @param {() => string} opts.getAmppBase — returns current AMPP base URL
|
||||
* @param {() => Promise<string>} opts.getAmppToken — returns valid Bearer token
|
||||
* @param {object} opts.db — shared db object (mutated in place)
|
||||
* @param {() => void} opts.saveData — persists db to disk
|
||||
*/
|
||||
constructor({ getAmppBase, getAmppToken, db, saveData }) {
|
||||
this._getAmppBase = getAmppBase;
|
||||
this._getAmppToken = getAmppToken;
|
||||
this._db = db;
|
||||
this._saveData = saveData;
|
||||
this._timer = null;
|
||||
this._busy = false;
|
||||
}
|
||||
|
||||
start(intervalMs = DEFAULT_POLL_MS) {
|
||||
if (this._timer) return; // already running
|
||||
this._timer = setInterval(() => this._tick(), intervalMs);
|
||||
console.log(`[placement-worker] Started — polling every ${intervalMs / 1000}s`);
|
||||
// Run one cycle immediately so the first upload doesn't wait a full interval
|
||||
setImmediate(() => this._tick());
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this._timer) { clearInterval(this._timer); this._timer = null; }
|
||||
console.log("[placement-worker] Stopped");
|
||||
}
|
||||
|
||||
// ── Main poll cycle ────────────────────────────────────────────────────────
|
||||
async _tick() {
|
||||
if (this._busy) return; // skip overlapping runs
|
||||
this._busy = true;
|
||||
try {
|
||||
await this._processPending();
|
||||
} catch (err) {
|
||||
console.error("[placement-worker] Tick error:", err.message);
|
||||
} finally {
|
||||
this._busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async _processPending() {
|
||||
const pending = (this._db.pendingPlacements || []).filter(p => p.status === "waiting");
|
||||
|
||||
// Expire stale placements first (before any API calls)
|
||||
let dirty = false;
|
||||
for (const p of pending) {
|
||||
if (Date.now() - new Date(p.createdAt).getTime() > EXPIRY_MS) {
|
||||
p.status = "expired";
|
||||
console.warn(`[placement-worker] Expired: ${p.filename} (created ${p.createdAt})`);
|
||||
dirty = true;
|
||||
}
|
||||
}
|
||||
if (dirty) this._saveData(this._db);
|
||||
|
||||
const active = pending.filter(p => p.status === "waiting");
|
||||
if (!active.length) return;
|
||||
|
||||
console.log(`[placement-worker] ${active.length} pending placement(s) — checking AMPP jobs…`);
|
||||
|
||||
let token, jobs;
|
||||
try {
|
||||
token = await this._getAmppToken();
|
||||
jobs = await this._fetchRecentJobs(token);
|
||||
} catch (err) {
|
||||
console.error("[placement-worker] Could not reach AMPP:", err.message);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[placement-worker] ${jobs.length} recent AMPP job(s) fetched`);
|
||||
|
||||
// Log keys of first job to help identify field names (only once per session)
|
||||
if (jobs.length > 0 && !this._loggedJobKeys) {
|
||||
console.log("[placement-worker] Sample job keys:", Object.keys(jobs[0]));
|
||||
console.log("[placement-worker] Sample job (first 800 chars):", JSON.stringify(jobs[0]).slice(0, 800));
|
||||
this._loggedJobKeys = true;
|
||||
}
|
||||
|
||||
let changed = false;
|
||||
for (const placement of active) {
|
||||
const job = this._matchJob(jobs, placement.filename);
|
||||
if (!job) continue;
|
||||
|
||||
const assetId = this._extractAssetId(job);
|
||||
if (!assetId) {
|
||||
console.warn(`[placement-worker] Matched job for '${placement.filename}' but could not extract asset:id. Job keys: ${Object.keys(job).join(", ")}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
let targetFolderId = placement.amppFolderId;
|
||||
if (!targetFolderId && placement.amppFolderName) {
|
||||
try { targetFolderId = await getOrCreateFolderByPath(this._getAmppBase(), placement.amppFolderName, token); }
|
||||
catch(e) { placement.status='failed'; placement.error=e.message; changed=true; continue; }
|
||||
}
|
||||
console.log(`[placement-worker] Placing '${placement.filename}' → folder ${targetFolderId} (asset ${assetId})`);
|
||||
try {
|
||||
await placeAssetInFolderById(this._getAmppBase(), assetId, targetFolderId, token);
|
||||
placement.status = "placed";
|
||||
placement.assetId = assetId;
|
||||
placement.placedAt = new Date().toISOString();
|
||||
console.log(`[placement-worker] ✓ Placed '${placement.filename}' in folder '${placement.amppFolderName}'`);
|
||||
} catch (err) {
|
||||
placement.status = "failed";
|
||||
placement.error = err.message;
|
||||
console.error(`[placement-worker] ✗ Failed to place '${placement.filename}':`, err.message);
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (changed) this._saveData(this._db);
|
||||
}
|
||||
|
||||
// ── Fetch recent AMPP jobs ─────────────────────────────────────────────────
|
||||
async _fetchRecentJobs(token) {
|
||||
const base = this._getAmppBase();
|
||||
const url = `${base}/api/v1/queue/job/jobs/querypage?skip=0&limit=200&sort=created:dateTime&asc=false`;
|
||||
const r = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
||||
body: "{}",
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
if (!r.ok) throw new Error(`AMPP jobs query returned HTTP ${r.status}`);
|
||||
const data = await r.json();
|
||||
return Array.isArray(data) ? data : (data?.items ?? data?.results ?? []);
|
||||
}
|
||||
|
||||
// ── Match a job to a filename ──────────────────────────────────────────────
|
||||
// Searches ALL string values in the job object — broad but reliable while
|
||||
// we don't yet know the exact field name. Once confirmed, you can tighten
|
||||
// this to specific fields for performance.
|
||||
_matchJob(jobs, filename) {
|
||||
const lc = filename.toLowerCase();
|
||||
return jobs.find(job => {
|
||||
return Object.values(job).some(v => {
|
||||
if (typeof v !== "string") return false;
|
||||
const vl = v.toLowerCase();
|
||||
// Match exact filename or filename at end of a path/key
|
||||
return vl === lc || vl.endsWith("/" + lc) || vl.endsWith("\\" + lc);
|
||||
});
|
||||
}) ?? null;
|
||||
}
|
||||
|
||||
// ── Extract asset ID from a job ────────────────────────────────────────────
|
||||
_extractAssetId(job) {
|
||||
for (const field of ASSET_ID_FIELDS) {
|
||||
const v = job[field];
|
||||
if (v && typeof v === "string") return v.trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = AmppPlacementWorker;
|
||||
113
lib/upload-manager.js
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
"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");
|
||||
// Normalize prefix: UI uses "/" for nested folders, FLX expects "--" as delimiter
|
||||
const normalized = prefix ? prefix.replace(/\//g, "--").replace(/[-]+$/, "") : "";
|
||||
const key = normalized ? `${normalized}--${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;
|
||||
22
package.json
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"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",
|
||||
"archiver": "^7.0.1",
|
||||
"express": "^4.21.0",
|
||||
"multer": "^1.4.5-lts.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
}
|
||||
BIN
public/dragon-anim.gif
Executable file
|
After Width: | Height: | Size: 428 KiB |
BIN
public/dragon-icon.png
Executable file
|
After Width: | Height: | Size: 61 KiB |
1901
public/index.html
Normal file
BIN
public/logo.png
Executable file
|
After Width: | Height: | Size: 12 KiB |
198
public/share.html
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1"/>
|
||||
<title>Dragon Wind · Upload</title>
|
||||
<link rel="icon" href="/dragon-icon.png" type="image/png"/>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;600&display=swap" rel="stylesheet"/>
|
||||
<style>
|
||||
:root{
|
||||
--bg:#03040a;--bg-card:#0d1117;--border:#1e2535;--border-bright:#2d3a50;
|
||||
--text:#e8eaf0;--text-dim:#4a5568;--text-secondary:#8892a4;
|
||||
--blue:#1e4bd8;--blue-bright:#3060ff;
|
||||
--success:#22c55e;--success-bg:rgba(34,197,94,.08);
|
||||
--error:#ef4444;--error-bg:rgba(239,68,68,.08);
|
||||
--dragon:#e05c1a;--dragon-bright:#ff7733;
|
||||
}
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
body{background:var(--bg);color:var(--text);font-family:'Outfit',sans-serif;min-height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:2rem}
|
||||
.card{width:100%;max-width:520px;background:var(--bg-card);border:1px solid var(--border);border-radius:20px;padding:2.5rem 2rem;box-shadow:0 24px 80px rgba(0,0,0,.5)}
|
||||
.brand{display:flex;flex-direction:column;align-items:center;gap:.5rem;margin-bottom:2rem;text-align:center}
|
||||
.brand-icon{width:64px;height:64px;object-fit:contain;filter:drop-shadow(0 4px 16px rgba(30,75,216,.4))}
|
||||
.brand-title{font-size:1.3rem;font-weight:700;background:linear-gradient(135deg,#fff,#8892a4);-webkit-background-clip:text;-webkit-text-fill-color:transparent}
|
||||
.brand-label{font-size:.78rem;color:var(--text-secondary);margin-top:.1rem}
|
||||
.brand-folder{font-size:.7rem;color:var(--blue-bright);font-family:'JetBrains Mono',monospace;background:rgba(30,75,216,.1);border:1px solid rgba(30,75,216,.2);border-radius:5px;padding:.15rem .5rem;margin-top:.3rem}
|
||||
.drop-zone{border:2px dashed var(--border-bright);border-radius:14px;padding:2.5rem 1.5rem;text-align:center;cursor:pointer;transition:all .2s;margin-bottom:1.2rem;position:relative}
|
||||
.drop-zone.dragover{border-color:var(--blue-bright);background:rgba(30,75,216,.07)}
|
||||
.drop-zone.has-files{border-color:var(--blue);background:rgba(30,75,216,.05)}
|
||||
.drop-icon{font-size:2.2rem;margin-bottom:.75rem}
|
||||
.drop-text{font-size:.9rem;color:var(--text-secondary)}
|
||||
.drop-sub{font-size:.73rem;color:var(--text-dim);margin-top:.35rem}
|
||||
#file-input{display:none}
|
||||
.file-list{margin-bottom:1.2rem;display:flex;flex-direction:column;gap:.4rem;max-height:200px;overflow-y:auto}
|
||||
.file-item{display:flex;align-items:center;justify-content:space-between;padding:.45rem .75rem;background:rgba(255,255,255,.03);border:1px solid var(--border);border-radius:8px;font-size:.78rem}
|
||||
.file-item-name{color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;margin-right:.5rem}
|
||||
.file-item-size{color:var(--text-dim);font-family:'JetBrains Mono',monospace;font-size:.68rem;flex-shrink:0}
|
||||
.file-item-rm{background:none;border:none;color:var(--text-dim);cursor:pointer;padding:0 .25rem;font-size:.9rem;line-height:1;flex-shrink:0}
|
||||
.file-item-rm:hover{color:var(--error)}
|
||||
.btn-upload{width:100%;padding:.85rem;font-family:'Outfit',sans-serif;font-size:.95rem;font-weight:700;color:#fff;background:linear-gradient(135deg,var(--blue),var(--blue-bright));border:none;border-radius:11px;cursor:pointer;transition:all .2s}
|
||||
.btn-upload:hover:not(:disabled){transform:translateY(-1px);box-shadow:0 6px 24px rgba(30,75,216,.4)}
|
||||
.btn-upload:disabled{opacity:.4;cursor:not-allowed;transform:none}
|
||||
.progress-wrap{height:6px;background:var(--border);border-radius:3px;margin-top:1rem;overflow:hidden;display:none}
|
||||
.progress-bar{height:100%;width:0;background:linear-gradient(90deg,var(--blue),var(--blue-bright));border-radius:3px;transition:width .3s}
|
||||
.status{padding:.7rem 1rem;border-radius:9px;font-size:.82rem;font-weight:600;margin-top:.9rem;display:none;text-align:center}
|
||||
.status.success{background:var(--success-bg);color:var(--success);border:1px solid rgba(34,197,94,.2);display:block}
|
||||
.status.error{background:var(--error-bg);color:var(--error);border:1px solid rgba(239,68,68,.2);display:block}
|
||||
.footer{margin-top:2rem;font-size:.65rem;color:var(--text-dim);text-align:center;line-height:1.7}
|
||||
.footer strong{color:var(--text-secondary)}
|
||||
.expired{text-align:center;padding:2rem 0}
|
||||
.expired-icon{font-size:3rem;margin-bottom:1rem}
|
||||
.expired-title{font-size:1.1rem;font-weight:700;color:var(--text);margin-bottom:.5rem}
|
||||
.expired-msg{font-size:.83rem;color:var(--text-secondary)}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card" id="card">
|
||||
<div style="text-align:center;padding:2rem 0" id="loading">
|
||||
<div style="font-size:1.5rem;margin-bottom:.5rem">🌪️</div>
|
||||
<div style="color:var(--text-dim);font-size:.85rem">Loading…</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
Built by <strong>Zac Gaetano</strong> & <strong>Wild Dragon LLC</strong> ·
|
||||
In partnership with <strong>Broadcast Management Group</strong>
|
||||
</div>
|
||||
<script>
|
||||
const token = location.pathname.split('/share/')[1];
|
||||
let linkInfo = null;
|
||||
let files = [];
|
||||
|
||||
async function init() {
|
||||
try {
|
||||
const r = await fetch(`/api/sharelinks/${token}/info`);
|
||||
const d = await r.json();
|
||||
if (!d.success) return showExpired(d.error);
|
||||
linkInfo = d;
|
||||
renderUploader();
|
||||
} catch(e) { showExpired('Could not load upload link.'); }
|
||||
}
|
||||
|
||||
function showExpired(msg) {
|
||||
document.getElementById('card').innerHTML = `
|
||||
<div class="expired">
|
||||
<div class="expired-icon">⏰</div>
|
||||
<div class="expired-title">Link Unavailable</div>
|
||||
<div class="expired-msg">${msg || 'This upload link is no longer valid.'}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderUploader() {
|
||||
const expiry = linkInfo.expiresAt
|
||||
? `Expires ${new Date(linkInfo.expiresAt).toLocaleString()}` : 'No expiry';
|
||||
document.getElementById('card').innerHTML = `
|
||||
<div class="brand">
|
||||
<img class="brand-icon" src="/dragon-icon.png" alt="Dragon Wind"/>
|
||||
<div class="brand-title">Dragon Wind Upload</div>
|
||||
<div class="brand-label">${esc(linkInfo.label)}</div>
|
||||
${linkInfo.folder ? `<div class="brand-folder">📁 ${esc(linkInfo.folder)}</div>` : ''}
|
||||
</div>
|
||||
<div class="drop-zone" id="drop-zone" onclick="document.getElementById('file-input').click()">
|
||||
<div class="drop-icon">📂</div>
|
||||
<div class="drop-text">Drop files here or click to browse</div>
|
||||
<div class="drop-sub">${esc(expiry)}</div>
|
||||
</div>
|
||||
<input type="file" id="file-input" multiple/>
|
||||
<div class="file-list" id="file-list"></div>
|
||||
<button class="btn-upload" id="upload-btn" disabled onclick="doUpload()">Upload Files</button>
|
||||
<div class="progress-wrap" id="progress-wrap"><div class="progress-bar" id="progress-bar"></div></div>
|
||||
<div class="status" id="status"></div>`;
|
||||
|
||||
const dz = document.getElementById('drop-zone');
|
||||
const fi = document.getElementById('file-input');
|
||||
dz.addEventListener('dragover', e => { e.preventDefault(); dz.classList.add('dragover'); });
|
||||
dz.addEventListener('dragleave', () => dz.classList.remove('dragover'));
|
||||
dz.addEventListener('drop', e => { e.preventDefault(); dz.classList.remove('dragover'); addFiles(e.dataTransfer.files); });
|
||||
fi.addEventListener('change', () => addFiles(fi.files));
|
||||
}
|
||||
|
||||
function addFiles(newFiles) {
|
||||
for (const f of newFiles) files.push(f);
|
||||
renderFiles();
|
||||
}
|
||||
|
||||
function removeFile(i) { files.splice(i, 1); renderFiles(); }
|
||||
|
||||
function renderFiles() {
|
||||
const list = document.getElementById('file-list');
|
||||
const btn = document.getElementById('upload-btn');
|
||||
const dz = document.getElementById('drop-zone');
|
||||
list.innerHTML = files.map((f, i) => `
|
||||
<div class="file-item">
|
||||
<span class="file-item-name">${esc(f.name)}</span>
|
||||
<span class="file-item-size">${fmtSize(f.size)}</span>
|
||||
<button class="file-item-rm" onclick="removeFile(${i})">✕</button>
|
||||
</div>`).join('');
|
||||
btn.disabled = files.length === 0;
|
||||
dz.classList.toggle('has-files', files.length > 0);
|
||||
}
|
||||
|
||||
async function doUpload() {
|
||||
if (!files.length) return;
|
||||
const btn = document.getElementById('upload-btn');
|
||||
const status = document.getElementById('status');
|
||||
const progressWrap = document.getElementById('progress-wrap');
|
||||
const progressBar = document.getElementById('progress-bar');
|
||||
btn.disabled = true; btn.textContent = 'Uploading…';
|
||||
status.className = 'status'; status.textContent = '';
|
||||
progressWrap.style.display = 'block'; progressBar.style.width = '0%';
|
||||
|
||||
const form = new FormData();
|
||||
for (const f of files) form.append('files', f);
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', `/api/sharelinks/${token}/upload`);
|
||||
xhr.upload.onprogress = e => {
|
||||
if (e.lengthComputable) progressBar.style.width = (e.loaded/e.total*100) + '%';
|
||||
};
|
||||
xhr.onload = () => {
|
||||
progressBar.style.width = '100%';
|
||||
try {
|
||||
const d = JSON.parse(xhr.responseText);
|
||||
if (d.success) {
|
||||
status.className = 'status success';
|
||||
status.textContent = `✅ ${d.uploaded.length} file${d.uploaded.length!==1?'s':''} uploaded successfully!`;
|
||||
files = []; renderFiles();
|
||||
btn.textContent = 'Upload Files';
|
||||
} else {
|
||||
status.className = 'status error';
|
||||
status.textContent = `❌ ${d.error}`;
|
||||
btn.disabled = false; btn.textContent = 'Upload Files';
|
||||
}
|
||||
} catch(e) {
|
||||
status.className = 'status error';
|
||||
status.textContent = '❌ Upload failed — unexpected response';
|
||||
btn.disabled = false; btn.textContent = 'Upload Files';
|
||||
}
|
||||
};
|
||||
xhr.onerror = () => {
|
||||
status.className = 'status error';
|
||||
status.textContent = '❌ Network error — please try again';
|
||||
btn.disabled = false; btn.textContent = 'Upload Files';
|
||||
};
|
||||
xhr.send(form);
|
||||
}
|
||||
|
||||
function esc(s) { return (s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||
function fmtSize(b) {
|
||||
if (b < 1024) return b + ' B';
|
||||
if (b < 1048576) return (b/1024).toFixed(1) + ' KB';
|
||||
if (b < 1073741824) return (b/1048576).toFixed(1) + ' MB';
|
||||
return (b/1073741824).toFixed(2) + ' GB';
|
||||
}
|
||||
|
||||
init();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
BIN
public/vpm-logo.png
Executable file
|
After Width: | Height: | Size: 12 KiB |
BIN
public/wilddragon-logo.png
Executable file
|
After Width: | Height: | Size: 91 KiB |
|
|
@ -997,7 +997,7 @@ app.post("/api/desktop/multipart/init", requireAuth, async (req, res) => {
|
|||
if (!filename || !size || !totalParts) return res.status(400).json({ error: "Missing fields" });
|
||||
if (!s3Client) return res.status(503).json({ error: "S3 not configured" });
|
||||
const s3cfg = db.s3Config || {};
|
||||
const key = buildS3Key(prefix, filename);
|
||||
const key = prefix ? prefix.replace(/\/+$/, "") + "/" + filename : filename;
|
||||
try {
|
||||
const cr = await s3Client.send(new CreateMultipartUploadCommand({ Bucket: s3cfg.bucket, Key: key }));
|
||||
const uid = cr.UploadId;
|
||||
|
|
|
|||
81
setup.sh
Executable file
|
|
@ -0,0 +1,81 @@
|
|||
#!/usr/bin/env bash
|
||||
# =============================================================
|
||||
# Dragon Wind — First-Run Setup
|
||||
# Generates a .env file with randomized ports if one doesn't
|
||||
# already exist, then optionally starts the stack.
|
||||
# =============================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ENV_FILE="$(dirname "$0")/.env"
|
||||
|
||||
# ---- Random port helper (avoids well-known ports) -----------
|
||||
rand_port() {
|
||||
# Pick a random port in range 10000–59999
|
||||
echo $(( RANDOM % 50000 + 10000 ))
|
||||
}
|
||||
|
||||
# ---- Only generate .env if it doesn't exist -----------------
|
||||
if [ -f "$ENV_FILE" ]; then
|
||||
echo "✅ .env already exists — skipping generation."
|
||||
echo " Edit $ENV_FILE to change ports or credentials."
|
||||
else
|
||||
WEB_PORT=$(rand_port)
|
||||
RELAY_TCP_PORT=$(rand_port)
|
||||
RELAY_UDP_PORT=$(rand_port)
|
||||
|
||||
# Ensure all three are distinct
|
||||
while [ "$RELAY_TCP_PORT" -eq "$WEB_PORT" ]; do RELAY_TCP_PORT=$(rand_port); done
|
||||
while [ "$RELAY_UDP_PORT" -eq "$WEB_PORT" ] || [ "$RELAY_UDP_PORT" -eq "$RELAY_TCP_PORT" ]; do
|
||||
RELAY_UDP_PORT=$(rand_port)
|
||||
done
|
||||
|
||||
cat > "$ENV_FILE" <<EOF
|
||||
# Dragon Wind — auto-generated by setup.sh
|
||||
# Ports are randomized on first run. Edit freely.
|
||||
|
||||
# ---- Ports --------------------------------------------------
|
||||
WEB_PORT=$WEB_PORT
|
||||
RELAY_TCP_PORT=$RELAY_TCP_PORT
|
||||
RELAY_UDP_PORT=$RELAY_UDP_PORT
|
||||
|
||||
# ---- Auth (change these!) -----------------------------------
|
||||
AUTH_USER=admin
|
||||
AUTH_PASS=DragonWind2026!
|
||||
|
||||
# ---- S3 (set via Admin UI or here) --------------------------
|
||||
S3_ENDPOINT=
|
||||
S3_REGION=us-east-1
|
||||
S3_BUCKET=
|
||||
S3_ACCESS_KEY=
|
||||
S3_SECRET_KEY=
|
||||
|
||||
# ---- AMPP (set via Admin UI or here) ------------------------
|
||||
AMPP_BASE_URL=https://us-east-1.gvampp.com
|
||||
AMPP_API_KEY=
|
||||
EOF
|
||||
|
||||
echo ""
|
||||
echo "🐉 Dragon Wind setup complete!"
|
||||
echo ""
|
||||
echo " Ports assigned:"
|
||||
echo " Web UI → http://localhost:$WEB_PORT"
|
||||
echo " Relay TCP → :$RELAY_TCP_PORT"
|
||||
echo " UDP Data → :$RELAY_UDP_PORT/udp"
|
||||
echo ""
|
||||
echo " ⚠️ Change AUTH_PASS in .env before going live!"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# ---- Optionally start the stack -----------------------------
|
||||
if [ "${1:-}" = "--start" ] || [ "${1:-}" = "-s" ]; then
|
||||
echo "🚀 Starting Dragon Wind..."
|
||||
docker compose up -d --build
|
||||
echo ""
|
||||
# Re-read the port in case .env was pre-existing
|
||||
WEB_PORT_LIVE=$(grep '^WEB_PORT=' "$ENV_FILE" | cut -d= -f2)
|
||||
echo "✅ Running at http://localhost:${WEB_PORT_LIVE}"
|
||||
else
|
||||
echo " To start: docker compose up -d --build"
|
||||
echo " Or run: bash setup.sh --start"
|
||||
fi
|
||||
8
udp-relay/Dockerfile
Normal 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
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
351
udp-relay/server.js
Normal file
|
|
@ -0,0 +1,351 @@
|
|||
"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();
|
||||
|
||||
// CORS — browsers (Chrome extension) send chunks via HTTP fallback
|
||||
app.use((req, res, next) => {
|
||||
res.setHeader("Access-Control-Allow-Origin", "*");
|
||||
res.setHeader("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS");
|
||||
res.setHeader("Access-Control-Allow-Headers", "Content-Type,Authorization");
|
||||
if (req.method === "OPTIONS") return res.sendStatus(204);
|
||||
next();
|
||||
});
|
||||
|
||||
app.use(express.json());
|
||||
app.use(express.raw({ type: "application/octet-stream", limit: "100mb" }));
|
||||
|
||||
// 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 });
|
||||
});
|
||||
|
||||
// HTTP chunk fallback — Chrome extensions can't send raw UDP, so they POST chunks over HTTP
|
||||
app.post("/session/:id/chunk/:index", async (req, res) => {
|
||||
const session = sessions.get(req.params.id);
|
||||
if (!session) return res.status(404).json({ error: "Session not found" });
|
||||
const chunkIndex = parseInt(req.params.index);
|
||||
if (isNaN(chunkIndex)) return res.status(400).json({ error: "Invalid chunk index" });
|
||||
|
||||
try {
|
||||
// Initialize multipart upload on first chunk
|
||||
if (session.status === "pending") {
|
||||
await session.initMultipart();
|
||||
}
|
||||
await session.receiveChunk(chunkIndex, req.body);
|
||||
|
||||
// Auto-complete when all chunks received
|
||||
if (session.chunks.size === session.expectedChunks && session.status === "receiving") {
|
||||
await session.complete();
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
chunkIndex,
|
||||
receivedChunks: session.chunks.size,
|
||||
expectedChunks: session.expectedChunks,
|
||||
percent: session.progress().percent,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`[HTTP] Chunk ${chunkIndex} error for ${req.params.id.slice(0, 8)}:`, err.message);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 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));
|
||||