DragonWind/lib/upload-manager.js
Zac Gaetano 84cf9cccbe Fix folder sorting, default selection, subfolder S3 keys, and HTTP mode description
Addresses feedback from Gavin (VPM):
- Sort folders alphabetically in both upload tree and admin tree views
- Auto-select VPM as default folder on login instead of Root
- Fix S3 key construction for nested folders: convert "/" to "--" so FLX
  correctly maps subfolders (e.g. Content/TEST - AMPP Demo now produces
  Content--TEST - AMPP Demo--file.ext instead of Content/TEST - AMPP Demo--file.ext)
- Clarify HTTP mode note: "Files are processed 6 at a time" instead of
  "up to 6 concurrent files" which implied a total file limit

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 21:40:44 -04:00

113 lines
4 KiB
JavaScript

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