✨ feat: add Clouddrop.cc as upload hoster (API key auth, chunked uploads)
- 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) <noreply@anthropic.com>
This commit is contained in:
parent
eba85fc924
commit
1164da37ea
236
lib/clouddrop-upload.js
Normal file
236
lib/clouddrop-upload.js
Normal file
@ -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;
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
20
main.js
20
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' };
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user