const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const { request, Agent } = require('undici'); const BASE_URL = 'https://clouddrop.cc'; const API_BASE = `${BASE_URL}/api/cloud`; const USER_AGENT = 'multi-hoster-uploader/1.0'; const SIMPLE_UPLOAD_LIMIT = 16 * 1024 * 1024; // 16 MB const CHUNK_SIZE = 16 * 1024 * 1024; // 16 MB — server's fixed chunk size const INIT_TIMEOUT = 60_000; const CHUNK_TIMEOUT = 30 * 60_000; // 30 min per chunk const COMPLETE_TIMEOUT = 5 * 60_000; const SHARE_TIMEOUT = 30_000; const SIMPLE_UPLOAD_TIMEOUT = 30 * 60_000; // Cap concurrent TCP connections to clouddrop.cc at 50 to stay well under // the server's per-IP limit of 100 concurrent connections (cd_conn). // Shared across all ClouddropUploader instances via module-level agent. const clouddropAgent = new Agent({ connections: 50, pipelining: 1, keepAliveTimeout: 30_000, keepAliveMaxTimeout: 60_000 }); /** * Clouddrop.cc uploader — uses API Key (Bearer) authentication. * Files > 16 MB use the chunked protocol, smaller files use simple upload. * After upload, a share link is created and returned as download_url. */ class ClouddropUploader { constructor(apiKey) { this.apiKey = String(apiKey || '').trim(); } _headers(extra) { return { 'Authorization': `Bearer ${this.apiKey}`, 'User-Agent': USER_AGENT, 'Accept': 'application/json', ...(extra || {}) }; } async _parseJsonResponse(res) { const text = await res.body.text(); let payload = null; try { payload = text ? JSON.parse(text) : {}; } catch { throw new Error(`Clouddrop: API-Antwort war kein JSON (HTTP ${res.statusCode}): ${text.slice(0, 200)}`); } if (res.statusCode < 200 || res.statusCode >= 300) { const msg = (payload && (payload.error || payload.message)) || `HTTP ${res.statusCode}`; const err = new Error(`Clouddrop: ${msg}`); err.status = res.statusCode; throw err; } return payload; } /** * Upload a file. Returns { download_url, embed_url, file_code }. */ async upload(filePath, progressCb, signal, throttle) { if (!this.apiKey) throw new Error('Clouddrop: API-Key fehlt'); const fileName = path.basename(filePath); let fileSize = 0; try { fileSize = fs.statSync(filePath).size; } catch { throw new Error(`Clouddrop: Datei nicht lesbar: ${fileName}`); } if (fileSize <= 0) throw new Error('Clouddrop: Datei ist leer'); let fileId; if (fileSize <= SIMPLE_UPLOAD_LIMIT) { fileId = await this._uploadSimple(filePath, fileName, fileSize, progressCb, signal, throttle); } else { fileId = await this._uploadChunked(filePath, fileName, fileSize, progressCb, signal, throttle); } // Create a share link for the uploaded file const shareUrl = await this._createShareLink(fileId, signal); // Extract slug from share URL (format: https://clouddrop.cc/share/SLUG) const slugMatch = String(shareUrl || '').match(/\/share\/([a-zA-Z0-9]+)/); const fileCode = slugMatch ? slugMatch[1] : fileId; return { download_url: shareUrl || `${BASE_URL}/share/${fileCode}`, embed_url: null, file_code: fileCode }; } /** * Simple upload for files < 16 MB — single multipart POST. */ async _uploadSimple(filePath, fileName, fileSize, progressCb, signal, throttle) { const boundary = '----FormBoundary' + crypto.randomBytes(16).toString('hex'); const safeFileName = fileName.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); const preamble = `--${boundary}\r\n` + `Content-Disposition: form-data; name="file"; filename="${safeFileName}"\r\n` + `Content-Type: application/octet-stream\r\n\r\n`; const epilogue = `\r\n--${boundary}--\r\n`; const preambleBuf = Buffer.from(preamble, 'utf-8'); const epilogueBuf = Buffer.from(epilogue, 'utf-8'); const totalSize = preambleBuf.length + fileSize + epilogueBuf.length; let bytesRead = 0; async function* generate() { yield preambleBuf; const fileStream = fs.createReadStream(filePath, { highWaterMark: 256 * 1024 }); for await (const chunk of fileStream) { if (signal && signal.aborted) throw new Error('Aborted'); if (throttle) await throttle.consume(chunk.length, signal); bytesRead += chunk.length; yield chunk; if (progressCb) progressCb(bytesRead, fileSize); } yield epilogueBuf; } const res = await request(`${API_BASE}/upload?mode=rename`, { method: 'POST', dispatcher: clouddropAgent, body: generate(), signal, headers: this._headers({ 'Content-Type': `multipart/form-data; boundary=${boundary}`, 'Content-Length': String(totalSize) }), headersTimeout: SIMPLE_UPLOAD_TIMEOUT, bodyTimeout: SIMPLE_UPLOAD_TIMEOUT }); const payload = await this._parseJsonResponse(res); if (!payload.fileId) throw new Error(`Clouddrop: Keine fileId in Upload-Antwort`); return payload.fileId; } /** * Chunked upload for files > 16 MB. * Flow: POST /upload/init → PUT /upload/:sessionId/chunk/:n (0-based) → POST /upload/:sessionId/complete */ async _uploadChunked(filePath, fileName, fileSize, progressCb, signal, throttle) { // 1. Init session const initRes = await request(`${API_BASE}/upload/init`, { method: 'POST', dispatcher: clouddropAgent, signal, headers: this._headers({ 'Content-Type': 'application/json' }), body: JSON.stringify({ filename: fileName, size: fileSize, parentId: null }), headersTimeout: INIT_TIMEOUT, bodyTimeout: INIT_TIMEOUT }); const initPayload = await this._parseJsonResponse(initRes); const sessionId = initPayload.sessionId; const chunkSize = initPayload.chunkSize || CHUNK_SIZE; const totalChunks = initPayload.totalChunks || Math.ceil(fileSize / chunkSize); if (!sessionId) throw new Error('Clouddrop: Keine sessionId von /upload/init'); // 2. Read file and PUT chunks sequentially const fd = fs.openSync(filePath, 'r'); let bytesSent = 0; try { for (let i = 0; i < totalChunks; i++) { if (signal && signal.aborted) throw new Error('Aborted'); const offset = i * chunkSize; const remaining = fileSize - offset; const thisChunkSize = Math.min(chunkSize, remaining); const buf = Buffer.alloc(thisChunkSize); fs.readSync(fd, buf, 0, thisChunkSize, offset); if (throttle) await throttle.consume(thisChunkSize, signal); const chunkRes = await request(`${API_BASE}/upload/${sessionId}/chunk/${i}`, { method: 'PUT', dispatcher: clouddropAgent, signal, body: buf, headers: this._headers({ 'Content-Type': 'application/octet-stream', 'Content-Length': String(thisChunkSize) }), headersTimeout: CHUNK_TIMEOUT, bodyTimeout: CHUNK_TIMEOUT }); await this._parseJsonResponse(chunkRes); bytesSent += thisChunkSize; if (progressCb) progressCb(bytesSent, fileSize); } } finally { try { fs.closeSync(fd); } catch {} } // 3. Complete session const completeRes = await request(`${API_BASE}/upload/${sessionId}/complete`, { method: 'POST', dispatcher: clouddropAgent, signal, headers: this._headers({ 'Content-Type': 'application/json' }), body: '{}', headersTimeout: COMPLETE_TIMEOUT, bodyTimeout: COMPLETE_TIMEOUT }); const completePayload = await this._parseJsonResponse(completeRes); const fileId = completePayload.fileId || completePayload.id; if (!fileId) throw new Error('Clouddrop: Keine fileId in /upload/complete Antwort'); return fileId; } /** * Create a permanent share link for the uploaded file. */ async _createShareLink(fileId, signal) { const res = await request(`${API_BASE}/share-link`, { method: 'POST', dispatcher: clouddropAgent, signal, headers: this._headers({ 'Content-Type': 'application/json' }), body: JSON.stringify({ fileId, expiry: 'never' }), headersTimeout: SHARE_TIMEOUT, bodyTimeout: SHARE_TIMEOUT }); const payload = await this._parseJsonResponse(res); return payload.url || (payload.slug ? `${BASE_URL}/share/${payload.slug}` : null); } /** * Lightweight auth check — GET /api/cloud/files (list root, small response). */ async checkAuth(signal) { if (!this.apiKey) throw new Error('Clouddrop: API-Key fehlt'); const res = await request(`${API_BASE}/files?limit=1`, { method: 'GET', dispatcher: clouddropAgent, signal, headers: this._headers(), headersTimeout: 15_000, bodyTimeout: 15_000 }); await this._parseJsonResponse(res); return true; } } module.exports = ClouddropUploader;