diff --git a/lib/doodstream-upload.js b/lib/doodstream-upload.js index 962b7eb..b39b939 100644 --- a/lib/doodstream-upload.js +++ b/lib/doodstream-upload.js @@ -10,15 +10,29 @@ const UPLOAD_TIMEOUT = 1800000; // 30 min // Cap doodstream's per-hoster debug log alongside the main log files so // dev-mode sessions don't accumulate gigabytes of upload trace. const { maybeRotateLogFile } = require('./log-rotation'); -const _DOODSTREAM_LOG_PATH = path.join(__dirname, '..', 'doodstream-debug.log'); const _DOODSTREAM_LOG_MAX_BYTES = 10 * 1024 * 1024; const _DOODSTREAM_LOG_MAX_BACKUPS = 1; +// Resolve the log path at write-time. In a packaged build __dirname lives +// inside app.asar (read-only) — writing there fails silently and we lose every +// production trace. Prefer Electron's writable userData dir, fall back to the +// repo root only when running outside Electron (tests / plain node). +function _doodstreamLogPath() { + try { + const { app } = require('electron'); + if (app && typeof app.getPath === 'function') { + return path.join(app.getPath('userData'), 'doodstream-debug.log'); + } + } catch { /* not running under Electron */ } + return path.join(__dirname, '..', 'doodstream-debug.log'); +} + function _debugLog(msg) { try { - maybeRotateLogFile(_DOODSTREAM_LOG_PATH, _DOODSTREAM_LOG_MAX_BYTES, _DOODSTREAM_LOG_MAX_BACKUPS); + const logPath = _doodstreamLogPath(); + maybeRotateLogFile(logPath, _DOODSTREAM_LOG_MAX_BYTES, _DOODSTREAM_LOG_MAX_BACKUPS); const ts = new Date().toISOString(); - fs.appendFileSync(_DOODSTREAM_LOG_PATH, `[${ts}] ${msg}\n`); + fs.appendFileSync(logPath, `[${ts}] ${msg}\n`); } catch {} } @@ -210,6 +224,9 @@ class DoodstreamUploader { // Get upload server const uploadUrl = await this._getUploadServer(); + // Remember which CDN node handled this upload so a later parse failure can + // report it — failures sometimes correlate with a specific node. + this._lastUploadUrl = uploadUrl; // Build multipart form const boundary = `----WebKitFormBoundary${crypto.randomBytes(16).toString('hex')}`; @@ -379,7 +396,19 @@ class DoodstreamUploader { return this._buildResult(dlMatch[1]); } - throw new Error(`Doodstream Upload: upload_result Seite hat keinen filecode (${followText.slice(0, 150)})`); + // No filecode anywhere. Surface WHY: XFileSharing puts the real reason + // in the `st` field (anything other than "OK" means the backend refused + // the file — copyright/hash match, duplicate, size, quota, …). The + // download link being empty while the page structure is unchanged points + // at doodstream's backend, not at a parsing bug on our side. + const st = hiddenFields.st || ''; + const fnInfo = fnCode ? `"${fnCode}"(len ${fnCode.length})` : 'fehlt/leer'; + const node = this._lastUploadUrl || '?'; + _debugLog(`No filecode. st=${st} fn=${fnInfo} node=${node} CDN-body=${(resText || '').slice(0, 400)}`); + if (st && st !== 'OK') { + throw new Error(`Doodstream lehnt Datei ab (Server-Status: ${st}). CDN=${node}`); + } + throw new Error(`Doodstream Upload: kein Filecode — Server gab leeren Link zurueck (st=${st || '?'}, fn=${fnInfo}, CDN=${node}). CDN-Antwort: ${(resText || '').slice(0, 200)}`); } // 4. Fallback: follow form action as-is (for non-XFS forms) diff --git a/tests/doodstream-upload.test.js b/tests/doodstream-upload.test.js new file mode 100644 index 0000000..ea541e0 --- /dev/null +++ b/tests/doodstream-upload.test.js @@ -0,0 +1,65 @@ +const { test } = require('node:test'); +const assert = require('node:assert'); +const DoodstreamUploader = require('../lib/doodstream-upload'); + +// The CDN hands back an XFileSharing form. `fn` is the filecode, `st` is the +// status ("OK" on success, an error string when the backend refuses the file). +// These tests pin the parse/error behaviour of _parseUploadResponse without +// touching the network — _fetch is stubbed to return the upload_result page. +function cdnForm({ fn = '', st = 'OK' } = {}) { + return `
` + + `` + + `` + + `` + + `
`; +} + +const EMPTY_RESULT = ''; +const LINK_RESULT = (code) => ``; + +function uploaderWithResult(resultHtml) { + const up = new DoodstreamUploader(); + up._lastUploadUrl = 'https://cdn.example/upload/01'; + // Stub the second-step submit so no real request goes out. + up._fetch = async () => ({ text: async () => resultHtml }); + return up; +} + +test('rejected file: empty fn + non-OK st surfaces the real status', async () => { + const up = uploaderWithResult(EMPTY_RESULT); + await assert.rejects( + () => up._parseUploadResponse(cdnForm({ fn: '', st: 'Error: file already exists' })), + (err) => { + assert.match(err.message, /lehnt Datei ab/); + assert.match(err.message, /file already exists/); + return true; + } + ); +}); + +test('empty fn + st OK: generic error still reports st, fn-state and CDN node', async () => { + const up = uploaderWithResult(EMPTY_RESULT); + await assert.rejects( + () => up._parseUploadResponse(cdnForm({ fn: '', st: 'OK' })), + (err) => { + assert.match(err.message, /kein Filecode/); + assert.match(err.message, /st=OK/); + assert.match(err.message, /fehlt\/leer/); + assert.match(err.message, /cdn\.example/); + return true; + } + ); +}); + +test('valid fn but empty result page: still resolves via fn (no regression)', async () => { + const up = uploaderWithResult(EMPTY_RESULT); + const res = await up._parseUploadResponse(cdnForm({ fn: '7mnp8xna3123', st: 'OK' })); + assert.equal(res.file_code, '7mnp8xna3123'); + assert.equal(res.download_url, 'https://doodstream.com/d/7mnp8xna3123'); +}); + +test('happy path: link in result page wins', async () => { + const up = uploaderWithResult(LINK_RESULT('jjsuhr931ds9')); + const res = await up._parseUploadResponse(cdnForm({ fn: 'jjsuhr931ds9', st: 'OK' })); + assert.equal(res.file_code, 'jjsuhr931ds9'); +});