This commit is contained in:
+5
-3
@@ -26,7 +26,8 @@ Multi-Hoster-Upload ist eine Electron-Desktopanwendung für Windows, die große
|
||||
- Ein nicht vorhandener Ordnerüberwachungspfad bleibt nach dem Import sichtbar gespeichert, die Überwachung wird aber deaktiviert und der Nutzer erhält eine Warnung. Ein nicht vorhandener Log-Ordner wird ebenfalls gemeldet, ohne den konfigurierten Pfad still zu löschen.
|
||||
- Upload-Status-Badges und ihre Textlabels sind nicht markierbar; kopierbare Fehlerdetails, Logs und Eingabefelder behalten ihre Textauswahl.
|
||||
- VOE-Fehler mit der Meldung `Maximum storage space of the account used up.` gelten als temporärer Accountfehler. Die Retry-Schleife bricht auch nach einem bereits erfolgten Account-Wechsel sofort ab und setzt die Fallback-Kette Account für Account fort, bis ein Upload gelingt oder kein weiterer Account verfügbar ist.
|
||||
- Version `2.1.44` ist als GitHub- und Forgejo-Release veröffentlicht; Backup-API `2.0.4` blieb bei diesem reinen Desktop-Release unverändert aktiv.
|
||||
- Der DoodStream-Weblogin folgt dem aktuellen Browservertrag über `GET /?op=login_ajax`, behandelt `otp_sent` und `redirect` ausdrücklich und übernimmt `sess_id` auch aus den aktuellen Vue-Daten mit URL-sicheren Sonderzeichen. Die Upload-Server-Ermittlung verwendet `/?op=upload_get_srv` und versteht dessen `server.srv_url`-/`server.disk_id`-Antwort.
|
||||
- Version `2.1.44` ist als GitHub- und Forgejo-Release veröffentlicht; Backup-API `2.0.4` blieb bei dieser reinen Veröffentlichung der Desktopanwendung unverändert aktiv.
|
||||
- Der eingebaute Updater liest Releases und Binärdateien von Forgejo; GitHub liefert ergänzend die öffentlichen Release Notes. Ein Release ist deshalb erst vollständig, wenn die vier Assets auch im Forgejo-Release vorhanden sind.
|
||||
- Forgejo bewahrt Leerzeichen in Asset-Namen, GitHub normalisiert sie zu Punkten. Das Forgejo-`latest.yml` und der Release-Plan verwenden Namen wie `Multi-Hoster-Upload Setup 2.1.44.exe`; das GitHub-Manifest muss auf den dort tatsächlich veröffentlichten Punktnamen zeigen.
|
||||
- `forgejo/master` besitzt eine getrennte ältere Historie. Die aktuelle GitHub-Arbeitslinie wird deshalb zerstörungsfrei unter `forgejo/sync/github-master` gespiegelt.
|
||||
@@ -62,12 +63,13 @@ npm audit --omit=dev
|
||||
|
||||
## Zuletzt verifiziert
|
||||
|
||||
Stand: 07.09.2026
|
||||
Stand: 12.09.2026
|
||||
|
||||
- Lint: erfolgreich, 0 Warnungen und 0 Fehler.
|
||||
- Haupttests: 807 erfolgreich, 0 fehlgeschlagen.
|
||||
- Haupttests: 808 erfolgreich, 0 fehlgeschlagen.
|
||||
- Backup-API-Tests: 17 erfolgreich, 0 fehlgeschlagen.
|
||||
- Der Regressionstest für die VOE-Fallback-Kette bestätigt bei deaktivierter normaler Rotation genau einen Versuch auf jedem vollen Account und anschließend den erfolgreichen Wechsel auf den vierten Account.
|
||||
- Der öffentliche DoodStream-Webablauf wurde am 12.09.2026 direkt gegen die Startseite und deren aktuelle Browser-Skripte geprüft. Regressionstests bilden den neuen GET-Login, `otp_sent`, `redirect`, Vue-Sessiontokens mit `_`/`-` und die aktuelle `upload_get_srv`-Antwort nach.
|
||||
- Das Support-Bundle vom 07.09.2026 bestätigt als Ursache der gemeldeten Datei: Wechsel vom Primäraccount auf `Fallback #1`, dort vier unnötige Versuche, anschließend `skip-account-pause` und Abbruch mit `override-same-as-current` statt Weiterschaltung.
|
||||
- Der vollständige opt-in UI-Smoke bestätigte zusätzlich, dass Upload-Status-Badges und deren Labels nicht markierbar sind; die 16 bekannten themenfremden Abweichungen blieben unverändert.
|
||||
- Produktionsabhängigkeiten: `npm audit --omit=dev` meldet 0 Schwachstellen.
|
||||
|
||||
+36
-32
@@ -127,9 +127,8 @@ class DoodstreamUploader {
|
||||
await homeRes.text();
|
||||
}
|
||||
|
||||
// POST login via AJAX (op in body, XHR header required for JSON response)
|
||||
// GET login via AJAX (XHR header required for JSON response)
|
||||
const loginData = new URLSearchParams({
|
||||
op: 'login_ajax',
|
||||
login: username,
|
||||
password: password,
|
||||
loginotp: otp || ''
|
||||
@@ -137,7 +136,6 @@ class DoodstreamUploader {
|
||||
|
||||
// Use raw fetch with redirect: 'manual' to detect success redirects
|
||||
const headers = {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Referer': BASE_URL + '/',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'User-Agent': USER_AGENT
|
||||
@@ -146,9 +144,8 @@ class DoodstreamUploader {
|
||||
headers['Cookie'] = this._cookieHeader();
|
||||
}
|
||||
|
||||
const res = await fetch(BASE_URL + '/', {
|
||||
method: 'POST',
|
||||
body: loginData.toString(),
|
||||
const res = await fetch(`${BASE_URL}/?op=login_ajax&${loginData.toString()}`, {
|
||||
method: 'GET',
|
||||
headers,
|
||||
redirect: 'manual'
|
||||
});
|
||||
@@ -164,11 +161,11 @@ class DoodstreamUploader {
|
||||
let json;
|
||||
try { json = JSON.parse(body); } catch { json = null; }
|
||||
|
||||
if (json && json.status === 'success') {
|
||||
if (json && ['success', 'redirect'].includes(json.status)) {
|
||||
// Explicit success response
|
||||
} else if (json && json.message && /otp/i.test(json.message)) {
|
||||
} else if (json && (json.status === 'otp_sent' || (json.message && /otp|verification code/i.test(json.message)))) {
|
||||
// OTP required — signal caller to collect OTP from user
|
||||
const err = new Error(`Doodstream Login: ${json.message}`);
|
||||
const err = new Error(`Doodstream Login: ${json.message || 'OTP erforderlich'}`);
|
||||
err.otpRequired = true;
|
||||
throw err;
|
||||
} else if (json && json.status === 'fail') {
|
||||
@@ -188,46 +185,53 @@ class DoodstreamUploader {
|
||||
async _extractSessId() {
|
||||
const res = await this._fetch(BASE_URL + '/?op=upload');
|
||||
const html = await res.text();
|
||||
const sessId = this._findSessId(html);
|
||||
|
||||
// Hidden input: <input type="hidden" name="sess_id" value="xxx">
|
||||
const hiddenMatch = html.match(/name=["']sess_id["'][^>]*value=["']([a-zA-Z0-9]+)["']/);
|
||||
if (hiddenMatch) {
|
||||
this.sessId = hiddenMatch[1];
|
||||
return;
|
||||
}
|
||||
|
||||
// Vue component prop or JS: sess_id: "xxx" or sess_id="xxx"
|
||||
const sessMatch = html.match(/sess_id['":\s]+['"]([a-zA-Z0-9]+)['"]/);
|
||||
if (sessMatch) {
|
||||
this.sessId = sessMatch[1];
|
||||
return;
|
||||
}
|
||||
|
||||
// Assignment: sess_id = 'xxx'
|
||||
const altMatch = html.match(/sess_id\s*=\s*['"]([a-zA-Z0-9]+)['"]/);
|
||||
if (altMatch) {
|
||||
this.sessId = altMatch[1];
|
||||
if (sessId) {
|
||||
this.sessId = sessId;
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error('Doodstream: sess_id nicht gefunden nach Login');
|
||||
}
|
||||
|
||||
_findSessId(html) {
|
||||
if (!html) return '';
|
||||
const fields = this._extractHiddenFields(html);
|
||||
if (fields.sess_id) return String(fields.sess_id).trim();
|
||||
const decoded = String(html)
|
||||
.replace(/"|"|"/gi, '"')
|
||||
.replace(/'|'|'/gi, "'")
|
||||
.replace(/&/gi, '&');
|
||||
const match = decoded.match(/(?:["'])?\bsess_id\b(?:["'])?\s*[:=]\s*(["'])([^"']+)\1/i);
|
||||
return match ? match[2].trim() : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get upload server URL from web interface
|
||||
*/
|
||||
async _getUploadServer() {
|
||||
// Use the standard upload server endpoint
|
||||
const res = await this._fetch(BASE_URL + '/?op=upload_server');
|
||||
const res = await this._fetch(BASE_URL + '/?op=upload_get_srv', {
|
||||
headers: {
|
||||
'Referer': BASE_URL + '/?op=upload',
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
}
|
||||
});
|
||||
const text = await res.text();
|
||||
const ctype = (res.headers && res.headers.get) ? (res.headers.get('content-type') || '') : '';
|
||||
_debugLog(`upload_server: status=${res.status} ctype=${ctype} body(800)=${(text || '').slice(0, 800)}`);
|
||||
_debugLog(`upload_get_srv: status=${res.status} ctype=${ctype} body(800)=${(text || '').slice(0, 800)}`);
|
||||
let json;
|
||||
try { json = JSON.parse(text); } catch { json = null; }
|
||||
|
||||
if (json && json.result && /^https?:\/\//i.test(json.result)) {
|
||||
return json.result;
|
||||
}
|
||||
if (json && json.server && /^https?:\/\//i.test(json.server.srv_url || '') && json.server.disk_id !== undefined) {
|
||||
const server = String(json.server.srv_url).replace(/\/$/, '');
|
||||
const diskId = encodeURIComponent(String(json.server.disk_id));
|
||||
return `${server}/upload/${diskId}?t=${Date.now()}`;
|
||||
}
|
||||
|
||||
// Fallback: try fetching from upload page HTML
|
||||
const pageRes = await this._fetch(BASE_URL + '/?op=upload');
|
||||
@@ -246,9 +250,9 @@ class DoodstreamUploader {
|
||||
const actionMatch = html.match(/action=["'](https?:\/\/[^"']+\/upload\/[^"']*)["']/i);
|
||||
if (actionMatch) {
|
||||
const url = actionMatch[1].replace(/&/g, '&'); // un-escape HTML entities in query
|
||||
const freshSess = html.match(/name=["']sess_id["'][^>]*value=["']([a-zA-Z0-9]+)["']/);
|
||||
const freshSess = this._findSessId(html);
|
||||
if (freshSess) {
|
||||
this.sessId = freshSess[1];
|
||||
this.sessId = freshSess;
|
||||
} else {
|
||||
_debugLog('upload_server: form action found but no sess_id on page; keeping existing sessId');
|
||||
}
|
||||
@@ -272,7 +276,7 @@ class DoodstreamUploader {
|
||||
_debugLog(`upload_server: NO SERVER. upload-page html(2000)=${(html || '').slice(0, 2000)}`);
|
||||
throw new Error(
|
||||
`Doodstream: konnte Upload-Server nicht ermitteln (Endpoint geändert?). ` +
|
||||
`op=upload_server status=${res.status} ctype=${ctype} body=${(text || '').slice(0, 300)} ` +
|
||||
`op=upload_get_srv status=${res.status} ctype=${ctype} body=${(text || '').slice(0, 300)} ` +
|
||||
`| upload-page URL-Treffer: ${urlHints || 'keine'}`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -168,24 +168,27 @@ test('OTP verification keeps the challenged cookie session without another boots
|
||||
let loginCalls = 0;
|
||||
up._fetch = async () => {
|
||||
bootstrapCalls++;
|
||||
return fakeRes(bootstrapCalls === 1 ? 'ok' : '<input type="hidden" name="sess_id" value="SESSION456">');
|
||||
return fakeRes(bootstrapCalls === 1 ? 'ok' : `<home-upload :upload="{ utype: 'reg', sess_id: 'SESSION_456-X' }"></home-upload>`);
|
||||
};
|
||||
globalThis.fetch = async (_url, options) => {
|
||||
globalThis.fetch = async (url, options) => {
|
||||
loginCalls++;
|
||||
assert.match(url, /\?op=login_ajax&/u);
|
||||
assert.equal(options.method, 'GET');
|
||||
assert.equal(options.body, undefined);
|
||||
if (loginCalls === 1) {
|
||||
assert.equal(options.headers.Cookie, undefined);
|
||||
return {
|
||||
status: 200,
|
||||
headers: { getSetCookie: () => ['otp_session=SESSION123; Path=/'], get: () => null },
|
||||
text: async () => JSON.stringify({ status: 'fail', message: 'OTP required' })
|
||||
text: async () => JSON.stringify({ status: 'otp_sent', message: 'Verification code has been sent' })
|
||||
};
|
||||
}
|
||||
assert.equal(options.headers.Cookie, 'otp_session=SESSION123');
|
||||
assert.match(options.body, /loginotp=123456/u);
|
||||
assert.match(url, /loginotp=123456/u);
|
||||
return {
|
||||
status: 302,
|
||||
headers: { getSetCookie: () => [], get: () => '/dashboard' },
|
||||
text: async () => ''
|
||||
status: 200,
|
||||
headers: { getSetCookie: () => [], get: () => null },
|
||||
text: async () => JSON.stringify({ status: 'redirect', message: '/?op=my_account' })
|
||||
};
|
||||
};
|
||||
try {
|
||||
@@ -193,24 +196,38 @@ test('OTP verification keeps the challenged cookie session without another boots
|
||||
await up.login('user', 'secret', '123456');
|
||||
assert.equal(bootstrapCalls, 2);
|
||||
assert.equal(loginCalls, 2);
|
||||
assert.equal(up.sessId, 'SESSION_456-X');
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test('getUploadServer: returns JSON result when present', async () => {
|
||||
test('_findSessId accepts the current Vue upload data and URL-safe tokens', () => {
|
||||
const up = new DoodstreamUploader();
|
||||
assert.equal(
|
||||
up._findSessId(`<home-upload :upload="{ utype: 'reg', sess_id: 'abc_DEF-123' }"></home-upload>`),
|
||||
'abc_DEF-123'
|
||||
);
|
||||
assert.equal(
|
||||
up._findSessId('<home-upload :upload="{ "sess_id": "abc_DEF-456" }"></home-upload>'),
|
||||
'abc_DEF-456'
|
||||
);
|
||||
assert.equal(up._findSessId(`<home-upload :upload="{ utype: 'anon', sess_id: '' }"></home-upload>`), '');
|
||||
});
|
||||
|
||||
test('getUploadServer: parses the current upload_get_srv response', async () => {
|
||||
const up = new DoodstreamUploader();
|
||||
up._fetch = async (url) => {
|
||||
assert.match(url, /op=upload_server/);
|
||||
return fakeRes(JSON.stringify({ result: 'https://node42.cloudatacdn.com/upload/01' }), { ctype: 'application/json' });
|
||||
assert.match(url, /op=upload_get_srv/);
|
||||
return fakeRes(JSON.stringify({ success: true, server: { srv_url: 'https://node42.cloudatacdn.com', disk_id: '01' } }), { ctype: 'application/json' });
|
||||
};
|
||||
assert.equal(await up._getUploadServer(), 'https://node42.cloudatacdn.com/upload/01');
|
||||
assert.match(await up._getUploadServer(), /^https:\/\/node42\.cloudatacdn\.com\/upload\/01\?t=\d+$/u);
|
||||
});
|
||||
|
||||
test('getUploadServer: falls back to srv_url in upload-page HTML', async () => {
|
||||
const up = new DoodstreamUploader();
|
||||
up._fetch = async (url) => {
|
||||
if (/op=upload_server/.test(url)) return fakeRes('<html>not json</html>');
|
||||
if (/op=upload_get_srv/.test(url)) return fakeRes('<html>not json</html>');
|
||||
return fakeRes('<script>var srv_url: "https://node7.cloudatacdn.com/upload/01";</script>');
|
||||
};
|
||||
assert.equal(await up._getUploadServer(), 'https://node7.cloudatacdn.com/upload/01');
|
||||
@@ -220,18 +237,18 @@ test('getUploadServer: parses current form-action node and refreshes sess_id fro
|
||||
const up = new DoodstreamUploader();
|
||||
up.sessId = 'stale-from-login';
|
||||
up._fetch = async (url) => {
|
||||
if (/op=upload_server/.test(url)) return fakeRes('<html>not json</html>');
|
||||
return fakeRes('<form name="file" enctype="multipart/form-data" action="https://n9.cloudatacdn.com/upload/01?FRESH123" method="post"><input type="hidden" name="sess_id" value="FRESH123"></form>');
|
||||
if (/op=upload_get_srv/.test(url)) return fakeRes('<html>not json</html>');
|
||||
return fakeRes('<form name="file" enctype="multipart/form-data" action="https://n9.cloudatacdn.com/upload/01?FRESH_123-X" method="post"><input type="hidden" name="sess_id" value="FRESH_123-X"></form>');
|
||||
};
|
||||
const url = await up._getUploadServer();
|
||||
assert.equal(url, 'https://n9.cloudatacdn.com/upload/01?FRESH123');
|
||||
assert.equal(up.sessId, 'FRESH123'); // critical: form-field token must match the node URL token
|
||||
assert.equal(url, 'https://n9.cloudatacdn.com/upload/01?FRESH_123-X');
|
||||
assert.equal(up.sessId, 'FRESH_123-X'); // critical: form-field token must match the node URL token
|
||||
});
|
||||
|
||||
test('getUploadServer: un-escapes & in the form-action query string', async () => {
|
||||
const up = new DoodstreamUploader();
|
||||
up._fetch = async (url) => {
|
||||
if (/op=upload_server/.test(url)) return fakeRes('<html>not json</html>');
|
||||
if (/op=upload_get_srv/.test(url)) return fakeRes('<html>not json</html>');
|
||||
return fakeRes('<form name="file" enctype="multipart/form-data" action="https://n9.cloudatacdn.com/upload/01?a=1&b=2" method="post"></form>');
|
||||
};
|
||||
assert.equal(await up._getUploadServer(), 'https://n9.cloudatacdn.com/upload/01?a=1&b=2');
|
||||
|
||||
Reference in New Issue
Block a user