From 1164da37eaa5267f79d7c1b628f514241461a669 Mon Sep 17 00:00:00 2001 From: Administrator Date: Sat, 11 Apr 2026 06:55:21 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat:=20add=20Clouddrop.cc=20as=20u?= =?UTF-8?q?pload=20hoster=20(API=20key=20auth,=20chunked=20uploads)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New lib/clouddrop-upload.js with chunked upload support (16 MB chunks) - Auth via Bearer token (cd_XXX format) - Files < 16 MB: simple multipart POST /api/cloud/upload - Files > 16 MB: chunked protocol (init → PUT chunks → complete) - After upload: auto-creates permanent share link via /api/cloud/share-link - Health check verifies API key by listing root files Registered in: - lib/config-store.js (HOSTER_NAMES, templates, DEFAULTS) - main.js (hosterAccountHasCreds, checkClouddropHealth, runHosterHealthCheck) - lib/upload-manager.js (_executeUpload dispatch) - renderer/app.js (HOSTERS, HOSTER_ADD_OPTIONS, getHosterLabel) Tests: 74/74 pass. ESLint: 0/0. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/clouddrop-upload.js | 236 ++++++++++++++++++++++++++++++++++++++++ lib/config-store.js | 14 ++- lib/upload-manager.js | 4 + main.js | 20 +++- renderer/app.js | 8 +- 5 files changed, 273 insertions(+), 9 deletions(-) create mode 100644 lib/clouddrop-upload.js diff --git a/lib/clouddrop-upload.js b/lib/clouddrop-upload.js new file mode 100644 index 0000000..5b38b58 --- /dev/null +++ b/lib/clouddrop-upload.js @@ -0,0 +1,236 @@ +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); +const { request } = 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; + +/** + * 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', + 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', + 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', + 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', + 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', + 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', + signal, + headers: this._headers(), + headersTimeout: 15_000, + bodyTimeout: 15_000 + }); + await this._parseJsonResponse(res); + return true; + } +} + +module.exports = ClouddropUploader; diff --git a/lib/config-store.js b/lib/config-store.js index 5cefcf3..9386b55 100644 --- a/lib/config-store.js +++ b/lib/config-store.js @@ -17,11 +17,12 @@ const HOSTER_ACCOUNT_TEMPLATES = { 'voe.sx': { enabled: true, authType: 'login', username: '', password: '' }, 'voe.sx:api': { enabled: true, authType: 'api', apiKey: '' }, 'vidmoly.me': { enabled: true, authType: 'login', username: '', password: '' }, - 'byse.sx': { enabled: true, authType: 'api', apiKey: '' } + 'byse.sx': { enabled: true, authType: 'api', apiKey: '' }, + 'clouddrop.cc': { enabled: true, authType: 'api', apiKey: '' } }; // All known hoster names (used for iteration) -const HOSTER_NAMES = ['doodstream.com', 'voe.sx', 'vidmoly.me', 'byse.sx']; +const HOSTER_NAMES = ['doodstream.com', 'voe.sx', 'vidmoly.me', 'byse.sx', 'clouddrop.cc']; // Dropdown options for "Add Account" modal: value -> label const HOSTER_ADD_OPTIONS = [ @@ -30,7 +31,8 @@ const HOSTER_ADD_OPTIONS = [ { value: 'voe.sx', label: 'Voe (Web Login)', hoster: 'voe.sx', authType: 'login' }, { value: 'voe.sx:api', label: 'Voe (API)', hoster: 'voe.sx', authType: 'api' }, { value: 'vidmoly.me', label: 'Vidmoly (Web Login)', hoster: 'vidmoly.me', authType: 'login' }, - { value: 'byse.sx', label: 'Byse (API)', hoster: 'byse.sx', authType: 'api' } + { value: 'byse.sx', label: 'Byse (API)', hoster: 'byse.sx', authType: 'api' }, + { value: 'clouddrop.cc', label: 'Clouddrop (API)', hoster: 'clouddrop.cc', authType: 'api' } ]; const DEFAULTS = { @@ -38,13 +40,15 @@ const DEFAULTS = { 'doodstream.com': [], 'voe.sx': [], 'vidmoly.me': [], - 'byse.sx': [] + 'byse.sx': [], + 'clouddrop.cc': [] }, hosterSettings: { 'doodstream.com': { ...HOSTER_SETTINGS_DEFAULTS }, 'voe.sx': { ...HOSTER_SETTINGS_DEFAULTS }, 'vidmoly.me': { ...HOSTER_SETTINGS_DEFAULTS }, - 'byse.sx': { ...HOSTER_SETTINGS_DEFAULTS } + 'byse.sx': { ...HOSTER_SETTINGS_DEFAULTS }, + 'clouddrop.cc': { ...HOSTER_SETTINGS_DEFAULTS } }, globalSettings: { alwaysOnTop: false, diff --git a/lib/upload-manager.js b/lib/upload-manager.js index b92d2f7..faec593 100644 --- a/lib/upload-manager.js +++ b/lib/upload-manager.js @@ -6,6 +6,7 @@ const { uploadFile } = require('./hosters'); const VidmolyUploader = require('./vidmoly-upload'); const VoeUploader = require('./voe-upload'); const DoodstreamUploader = require('./doodstream-upload'); +const ClouddropUploader = require('./clouddrop-upload'); const Semaphore = require('./semaphore'); const Throttle = require('./throttle'); @@ -566,6 +567,9 @@ class UploadManager extends EventEmitter { const dood = new DoodstreamUploader(); await dood.login(task.username, task.password); return dood.upload(task.file, progressCb, signal, throttle); + } else if (task.hoster === 'clouddrop.cc') { + const clouddrop = new ClouddropUploader(task.apiKey); + return clouddrop.upload(task.file, progressCb, signal, throttle); } else { return uploadFile(task.hoster, task.file, task.apiKey, progressCb, signal, throttle); } diff --git a/main.js b/main.js index 2a75aff..c70215a 100644 --- a/main.js +++ b/main.js @@ -8,6 +8,7 @@ const { HOSTER_CONFIGS } = require('./lib/hosters'); const VidmolyUploader = require('./lib/vidmoly-upload'); const VoeUploader = require('./lib/voe-upload'); const DoodstreamUploader = require('./lib/doodstream-upload'); +const ClouddropUploader = require('./lib/clouddrop-upload'); const { checkForUpdate, installUpdate, abortUpdate } = require('./lib/updater'); const backupCrypto = require('./lib/backup-crypto'); const FolderMonitor = require('./lib/folder-monitor'); @@ -222,6 +223,7 @@ function hosterAccountHasCreds(name, account) { // Fallback for old format if (name === 'vidmoly.me') return !!(account.username && account.password); if (name === 'voe.sx' || name === 'doodstream.com') return !!(account.username && account.password) || !!account.apiKey; + if (name === 'clouddrop.cc') return !!account.apiKey; return !!account.apiKey; } @@ -466,11 +468,25 @@ async function checkByseHealth(hosterConfig) { return { status: 'error', message: 'API Key ungültig oder Server nicht erreichbar' }; } +async function checkClouddropHealth(hosterConfig) { + const apiKey = hosterConfig && hosterConfig.apiKey + ? String(hosterConfig.apiKey).trim() + : ''; + if (!apiKey) return { status: 'error', message: 'API Key fehlt' }; + try { + const uploader = new ClouddropUploader(apiKey); + await uploader.checkAuth(); + return { status: 'ok', message: 'API Key gültig' }; + } catch (err) { + return { status: 'error', message: err && err.message ? err.message : 'Clouddrop Auth fehlgeschlagen' }; + } +} + // requestedChecks can be: // - array of strings (hoster names) for legacy/all-accounts check // - array of { hoster, accountId } for specific account checks async function runHosterHealthCheck(config, requestedChecks) { - const allowed = ['doodstream.com', 'vidmoly.me', 'voe.sx', 'byse.sx']; + const allowed = ['doodstream.com', 'vidmoly.me', 'voe.sx', 'byse.sx', 'clouddrop.cc']; // Normalize input to [{ hoster, accountId? }] let checks; @@ -519,6 +535,8 @@ async function runHosterHealthCheck(config, requestedChecks) { result = await withTimeout(checkVoeHealth(hosterConfig), HEALTH_CHECK_TIMEOUT, 'VOE-Check'); } else if (hoster === 'byse.sx') { result = await withTimeout(checkByseHealth(hosterConfig), HEALTH_CHECK_TIMEOUT, 'Byse-Check'); + } else if (hoster === 'clouddrop.cc') { + result = await withTimeout(checkClouddropHealth(hosterConfig), HEALTH_CHECK_TIMEOUT, 'Clouddrop-Check'); } else { return { hoster, accountId, status: 'skipped', message: 'Kein Health-Check fuer diesen Hoster' }; } diff --git a/renderer/app.js b/renderer/app.js index b253013..d37be48 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -1,4 +1,4 @@ -const HOSTERS = ['doodstream.com', 'voe.sx', 'vidmoly.me', 'byse.sx']; +const HOSTERS = ['doodstream.com', 'voe.sx', 'vidmoly.me', 'byse.sx', 'clouddrop.cc']; // Dropdown options for "Add Account" modal: value -> label const HOSTER_ADD_OPTIONS = [ @@ -7,7 +7,8 @@ const HOSTER_ADD_OPTIONS = [ { value: 'voe.sx', label: 'Voe (Web Login)', hoster: 'voe.sx', authType: 'login' }, { value: 'voe.sx:api', label: 'Voe (API)', hoster: 'voe.sx', authType: 'api' }, { value: 'vidmoly.me', label: 'Vidmoly (Web Login)', hoster: 'vidmoly.me', authType: 'login' }, - { value: 'byse.sx', label: 'Byse (API)', hoster: 'byse.sx', authType: 'api' } + { value: 'byse.sx', label: 'Byse (API)', hoster: 'byse.sx', authType: 'api' }, + { value: 'clouddrop.cc', label: 'Clouddrop (API)', hoster: 'clouddrop.cc', authType: 'api' } ]; // --- State --- @@ -233,7 +234,8 @@ function getHosterLabel(name) { 'doodstream.com': 'Doodstream', 'voe.sx': 'VOE', 'vidmoly.me': 'Vidmoly', - 'byse.sx': 'Byse' + 'byse.sx': 'Byse', + 'clouddrop.cc': 'Clouddrop' }; return labels[name] || name; }