Compare commits
3 Commits
d2f903b8ba
...
287ebde1f5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
287ebde1f5 | ||
|
|
9ae5d312e1 | ||
|
|
d24fd54e83 |
@ -247,7 +247,10 @@ class DoodstreamUploader {
|
||||
} else {
|
||||
_debugLog('upload_server: form action found but no sess_id on page; keeping existing sessId');
|
||||
}
|
||||
_debugLog(`upload_server: using form action node=${url} sess=${this.sessId}`);
|
||||
// Capture the form's real fields so upload() submits exactly what the
|
||||
// browser would (file_title, submit_btn, …) instead of stale hardcoded ones.
|
||||
this._uploadFormFields = this._parseUploadFormFields(html);
|
||||
_debugLog(`upload_server: using form action node=${url} sess=${this.sessId} fields=${Object.keys(this._uploadFormFields).join(',')}`);
|
||||
return url;
|
||||
}
|
||||
|
||||
@ -269,6 +272,35 @@ class DoodstreamUploader {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replicate the non-file fields of doodstream's CURRENT upload form so our
|
||||
* POST matches what the browser actually submits. Doodstream dropped the old
|
||||
* `utype` field and added file_title / fakefilepc / submit_btn; submitting a
|
||||
* stale/incomplete field set can make the node accept the bytes but skip
|
||||
* registration (→ empty result form). We parse the live form rather than
|
||||
* hardcode, so we track whatever fields doodstream uses now. The file input
|
||||
* (type=file) is excluded — the file is streamed separately.
|
||||
*/
|
||||
_parseUploadFormFields(html) {
|
||||
const fields = {};
|
||||
if (!html) return fields;
|
||||
// Narrow to the upload form (its action points at a /upload/ node).
|
||||
const formMatch = html.match(/<form[^>]*\baction=["'][^"']*\/upload\/[^"']*["'][\s\S]*?<\/form>/i);
|
||||
const scope = formMatch ? formMatch[0] : html;
|
||||
const re = /<(?:input|button)\b([^>]*)>/gi;
|
||||
let m;
|
||||
while ((m = re.exec(scope)) !== null) {
|
||||
const attrs = m[1];
|
||||
const typeM = attrs.match(/\btype=["']([^"']*)["']/i);
|
||||
if (typeM && typeM[1].toLowerCase() === 'file') continue;
|
||||
const nameM = attrs.match(/\bname=["']([^"']+)["']/i);
|
||||
if (!nameM) continue;
|
||||
const valM = attrs.match(/\bvalue=["']([^"']*)["']/i);
|
||||
fields[nameM[1]] = valM ? valM[1] : '';
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload file using web session
|
||||
*/
|
||||
@ -285,10 +317,16 @@ class DoodstreamUploader {
|
||||
// Build multipart form
|
||||
const boundary = `----WebKitFormBoundary${crypto.randomBytes(16).toString('hex')}`;
|
||||
|
||||
// Build form parts
|
||||
// Build form parts. Submit the live form's fields (parsed in
|
||||
// _getUploadServer) so our POST matches the browser; merge in sess_id (the
|
||||
// fresh node token) and keep utype=reg as a harmless compatibility extra.
|
||||
// Falls back to the minimal known-good set if the form wasn't parsed.
|
||||
const formFields = { utype: 'reg', ...(this._uploadFormFields || {}) };
|
||||
formFields.sess_id = this.sessId;
|
||||
let preamble = '';
|
||||
preamble += `--${boundary}\r\nContent-Disposition: form-data; name="sess_id"\r\n\r\n${this.sessId}\r\n`;
|
||||
preamble += `--${boundary}\r\nContent-Disposition: form-data; name="utype"\r\n\r\nreg\r\n`;
|
||||
for (const [name, value] of Object.entries(formFields)) {
|
||||
preamble += `--${boundary}\r\nContent-Disposition: form-data; name="${name}"\r\n\r\n${value}\r\n`;
|
||||
}
|
||||
const safeFileName = fileName.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||
preamble += `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${safeFileName}"\r\nContent-Type: application/octet-stream\r\n\r\n`;
|
||||
|
||||
|
||||
@ -468,6 +468,8 @@ async function _fetchDoodstreamFileList(apiKey, signal) {
|
||||
}
|
||||
}
|
||||
|
||||
const DOODSTREAM_POLL = { attempts: 12, delayMs: 2500 }; // test-tunable via __test
|
||||
|
||||
async function _resolveDoodstreamUploadByName(apiKey, fileName, baselineCodes, signal) {
|
||||
// Same recovery byse uses: the upload POST returned no filecode, but the file
|
||||
// may register in the account a little later. Poll the list for a NEW file
|
||||
@ -475,8 +477,8 @@ async function _resolveDoodstreamUploadByName(apiKey, fileName, baselineCodes, s
|
||||
// (never "take the only new one") so parallel doodstream uploads can't claim
|
||||
// each other's files.
|
||||
const expected = _normalizeFileTitle(fileName);
|
||||
const POLL_ATTEMPTS = 12;
|
||||
const POLL_DELAY_MS = 2500;
|
||||
const POLL_ATTEMPTS = DOODSTREAM_POLL.attempts;
|
||||
const POLL_DELAY_MS = DOODSTREAM_POLL.delayMs;
|
||||
for (let i = 0; i < POLL_ATTEMPTS; i++) {
|
||||
if (signal && signal.aborted) return null;
|
||||
const list = await _fetchDoodstreamFileList(apiKey, signal);
|
||||
@ -638,6 +640,7 @@ module.exports = {
|
||||
extractUploadServerUrl,
|
||||
parseVoeResult,
|
||||
parseDoodstreamResult,
|
||||
parseByseResult
|
||||
parseByseResult,
|
||||
DOODSTREAM_POLL
|
||||
}
|
||||
};
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "multi-hoster-uploader",
|
||||
"version": "3.3.32",
|
||||
"version": "3.3.33",
|
||||
"description": "Upload files to doodstream, voe, vidmoly, byse simultaneously",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
|
||||
105
tests/doodstream-api-upload.test.js
Normal file
105
tests/doodstream-api-upload.test.js
Normal file
@ -0,0 +1,105 @@
|
||||
const { test, before, after } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
// Mock the undici transport BEFORE requiring hosters so the destructured
|
||||
// `request` picks up our stub. apiGet (getUploadServer) uses global fetch, which
|
||||
// we override per-test. This exercises the FULL doodstream API upload + recovery
|
||||
// orchestration against the doc-verified response shapes — the gap between the
|
||||
// already-tested parseDoodstreamResult helper and the real uploadFile path.
|
||||
// (mock.module needs an experimental flag npm test doesn't pass, so we reassign
|
||||
// undici.request on the module object and refresh the hosters cache instead.)
|
||||
let requestRouter = async () => ({ statusCode: 200, headers: {}, body: { text: async () => '{}' } });
|
||||
const undici = require('undici');
|
||||
const _origUndiciRequest = undici.request;
|
||||
undici.request = (...a) => requestRouter(...a);
|
||||
delete require.cache[require.resolve('../lib/hosters')];
|
||||
const hostersMod = require('../lib/hosters');
|
||||
const { uploadFile } = hostersMod;
|
||||
|
||||
let tmpFile;
|
||||
let origFetch;
|
||||
before(() => {
|
||||
tmpFile = path.join(os.tmpdir(), `dood-itest-${process.pid}.mkv`);
|
||||
fs.writeFileSync(tmpFile, Buffer.alloc(2048, 7));
|
||||
origFetch = global.fetch;
|
||||
// Keep the "never appears" recovery test fast (real default is 12 × 2.5 s).
|
||||
hostersMod.__test.DOODSTREAM_POLL.attempts = 3;
|
||||
hostersMod.__test.DOODSTREAM_POLL.delayMs = 5;
|
||||
});
|
||||
after(() => {
|
||||
global.fetch = origFetch;
|
||||
undici.request = _origUndiciRequest; // restore real transport for other test files
|
||||
delete require.cache[require.resolve('../lib/hosters')];
|
||||
try { fs.unlinkSync(tmpFile); } catch {}
|
||||
});
|
||||
|
||||
// getUploadServer hits /api/upload/server via global fetch.
|
||||
function stubUploadServer() {
|
||||
global.fetch = async (url) => {
|
||||
if (/upload\/server/.test(String(url))) {
|
||||
return { status: 200, text: async () => JSON.stringify({ status: 200, result: 'https://node1.cloudatacdn.com/upload/01' }) };
|
||||
}
|
||||
return { status: 200, text: async () => '{"status":200}' };
|
||||
};
|
||||
}
|
||||
|
||||
// Build an undici-style router. uploadBody is the POST result; listBodies is a
|
||||
// queue consumed by successive /api/file/list calls (baseline, then polls).
|
||||
function routeWith(uploadBody, listBodies = []) {
|
||||
return async (url, opts) => {
|
||||
const u = String(url);
|
||||
if (/\/api\/file\/list/.test(u)) {
|
||||
const body = listBodies.length ? listBodies.shift() : '{"status":200,"result":{"files":[]}}';
|
||||
return { statusCode: 200, headers: {}, body: { text: async () => body } };
|
||||
}
|
||||
// Upload POST: drain the streamed body so the file handle closes.
|
||||
if (opts && opts.body && typeof opts.body[Symbol.asyncIterator] === 'function') {
|
||||
for await (const chunk of opts.body) { if (chunk && chunk.length === -1) break; }
|
||||
}
|
||||
return { statusCode: uploadBody.status, headers: { 'content-type': 'application/json' }, body: { text: async () => uploadBody.body } };
|
||||
};
|
||||
}
|
||||
|
||||
test('doodstream API upload: filecode returned directly is used', async () => {
|
||||
stubUploadServer();
|
||||
requestRouter = routeWith({
|
||||
status: 200,
|
||||
body: JSON.stringify({ status: 200, result: [{ filecode: 'DOODCODE1234', download_url: 'https://doodstream.com/d/DOODCODE1234', protected_embed: 'https://doodstream.com/e/DOODCODE1234' }] })
|
||||
});
|
||||
const res = await uploadFile('doodstream.com', tmpFile, 'VALIDKEY', null, null, null);
|
||||
assert.equal(res.file_code, 'DOODCODE1234');
|
||||
assert.equal(res.download_url, 'https://doodstream.com/d/DOODCODE1234');
|
||||
});
|
||||
|
||||
test('doodstream API upload: codeless result recovered via file-list name match', async () => {
|
||||
stubUploadServer();
|
||||
const fileName = path.basename(tmpFile).replace(/\.[^.]+$/, ''); // title doodstream stores
|
||||
requestRouter = routeWith(
|
||||
{ status: 200, body: JSON.stringify({ status: 200, msg: 'OK' }) }, // codeless upload
|
||||
[
|
||||
'{"status":200,"result":{"files":[]}}', // baseline (pre-upload)
|
||||
`{"status":200,"result":{"files":[{"file_code":"RECOVER9999","title":"${fileName}"}]}}` // poll finds it
|
||||
]
|
||||
);
|
||||
const res = await uploadFile('doodstream.com', tmpFile, 'VALIDKEY', null, null, null);
|
||||
assert.equal(res.file_code, 'RECOVER9999');
|
||||
assert.equal(res.download_url, 'https://doodstream.com/d/RECOVER9999');
|
||||
});
|
||||
|
||||
test('doodstream API upload: codeless + file never appears → throws hosterTransient (no account poison)', async () => {
|
||||
stubUploadServer();
|
||||
requestRouter = routeWith(
|
||||
{ status: 200, body: JSON.stringify({ status: 200, msg: 'OK' }) },
|
||||
[] // every file/list returns empty
|
||||
);
|
||||
await assert.rejects(
|
||||
() => uploadFile('doodstream.com', tmpFile, 'VALIDKEY', null, null, null),
|
||||
(err) => {
|
||||
assert.equal(err.hosterTransient, true, 'codeless result must be tagged hosterTransient');
|
||||
return true;
|
||||
}
|
||||
);
|
||||
});
|
||||
@ -64,6 +64,31 @@ test('happy path: link in result page wins', async () => {
|
||||
assert.equal(res.file_code, 'jjsuhr931ds9');
|
||||
});
|
||||
|
||||
// --- _parseUploadFormFields: replicate the current upload form faithfully ---
|
||||
test('_parseUploadFormFields extracts the real form fields and excludes the file input', () => {
|
||||
const up = new DoodstreamUploader();
|
||||
const html = `
|
||||
<form name="file" enctype="multipart/form-data" action="https://uxg.cloudatacdn.com/upload/01?TOK" method="post">
|
||||
<input type="hidden" name="sess_id" value="TOK">
|
||||
<input name="file" type="file" size="30" id="filepc">
|
||||
<input name="fakefilepc" class="d-none" type="text" id="fakefilepc">
|
||||
<input type="text" name="file_title" class="form-control">
|
||||
<button type="submit" name="submit_btn" class="btn">Upload</button>
|
||||
</form>`;
|
||||
const f = up._parseUploadFormFields(html);
|
||||
assert.equal(f.sess_id, 'TOK');
|
||||
assert.equal(f.fakefilepc, '');
|
||||
assert.equal(f.file_title, '');
|
||||
assert.ok('submit_btn' in f);
|
||||
assert.ok(!('file' in f), 'the file input must be excluded (streamed separately)');
|
||||
});
|
||||
|
||||
test('_parseUploadFormFields returns {} for markup without a form', () => {
|
||||
const up = new DoodstreamUploader();
|
||||
assert.deepEqual(up._parseUploadFormFields('<div>no form here</div>'), {});
|
||||
assert.deepEqual(up._parseUploadFormFields(''), {});
|
||||
});
|
||||
|
||||
// --- deriveApiKey: pull + validate the account API key from the web session ---
|
||||
test('_extractApiKeyCandidates finds the key in an input value and ranks api-context first', () => {
|
||||
const up = new DoodstreamUploader();
|
||||
|
||||
Loading…
Reference in New Issue
Block a user