Compare commits
No commits in common. "v2.0.1" and "main" have entirely different histories.
53
.gitignore
vendored
53
.gitignore
vendored
@ -1,18 +1,49 @@
|
||||
node_modules/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
|
||||
.venv/
|
||||
venv/
|
||||
|
||||
build/
|
||||
dist/
|
||||
release/
|
||||
.vite/
|
||||
coverage/
|
||||
*.log
|
||||
*.log.old
|
||||
*.bak
|
||||
*.spec
|
||||
|
||||
rd_downloader_config.json
|
||||
rd_downloader.log
|
||||
rd_download_manifest.json
|
||||
_update_staging/
|
||||
apply_update.cmd
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
.claude/
|
||||
.github/
|
||||
CHANGELOG.md
|
||||
|
||||
node_modules/
|
||||
.vite/
|
||||
coverage/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
deploy/forgejo/.env
|
||||
deploy/forgejo/forgejo/
|
||||
deploy/forgejo/postgres/
|
||||
deploy/forgejo/caddy/data/
|
||||
deploy/forgejo/caddy/config/
|
||||
deploy/forgejo/caddy/logs/
|
||||
deploy/forgejo/backups/
|
||||
.secrets
|
||||
|
||||
*.log.old
|
||||
*.bak
|
||||
|
||||
rust-postprocess/
|
||||
electron-postprocess/
|
||||
python-postprocess/
|
||||
scripts/*.py
|
||||
scripts/*.ps1
|
||||
scripts/*.md
|
||||
scripts/fix-library-renames.mjs
|
||||
|
||||
28
CLAUDE.md
Normal file
28
CLAUDE.md
Normal file
@ -0,0 +1,28 @@
|
||||
## Release + Update Source (Wichtig)
|
||||
|
||||
- Primäre Plattform ist `https://git.24-music.de`
|
||||
- Standard-Repo: `Administrator/real-debrid-downloader`
|
||||
- Nicht mehr primär über Codeberg/GitHub releasen
|
||||
|
||||
## Releasen
|
||||
|
||||
Der Token liegt in `.secrets` (gitignored) und wird automatisch geladen.
|
||||
|
||||
Als KI-Agent: Token aus `.secrets` lesen und als Umgebungsvariable setzen, dann Release-Script ausführen:
|
||||
```bash
|
||||
export $(cat .secrets | xargs) && npm run release:gitea -- <version> [notes]
|
||||
```
|
||||
|
||||
Manuell in PowerShell (falls nötig):
|
||||
- `npm run release:gitea -- <version> [notes]` (Token ist bereits als Benutzervariable gesetzt)
|
||||
|
||||
Das Script:
|
||||
- bumped `package.json`
|
||||
- baut Windows-Artefakte
|
||||
- pusht `main` + Tag
|
||||
- erstellt Release auf `git.24-music.de`
|
||||
- lädt Assets hoch
|
||||
|
||||
## Auto-Update
|
||||
|
||||
- Updater nutzt aktuell `git.24-music.de` als Standardquelle
|
||||
21
LICENSE
21
LICENSE
@ -1,21 +0,0 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Sucukdeluxe
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
19
README.md
19
README.md
@ -13,7 +13,7 @@ Desktop downloader for Windows with package-based queue management, multi-provid
|
||||
- JDownloader-style workflow with packages, progress, extraction, history, and clean post-processing.
|
||||
- Multiple debrid accounts in one app, including provider order, automatic fallback, and per-hoster routing.
|
||||
- Built for large queues with session persistence, retries, reconnect handling, resume support, and integrity checks.
|
||||
- Includes an in-app updater for releases published on GitHub.
|
||||
- Includes an in-app updater for releases published on `git.24-music.de`.
|
||||
|
||||
## Supported providers
|
||||
|
||||
@ -126,7 +126,7 @@ Desktop downloader for Windows with package-based queue management, multi-provid
|
||||
2. Start the app.
|
||||
3. Add your provider credentials in `Settings > Accounts`.
|
||||
|
||||
Releases: [GitHub Releases](https://github.com/Sucukdeluxe/multi-debrid-downloader/releases)
|
||||
Releases: [git.24-music.de Releases](https://git.24-music.de/Administrator/real-debrid-downloader/releases)
|
||||
|
||||
### Build from source
|
||||
|
||||
@ -152,8 +152,9 @@ npm run dev
|
||||
| `npm run start` | Starts the built app in production mode |
|
||||
| `npm test` | Runs Vitest unit tests |
|
||||
| `npm run self-check` | Runs integrated self-checks |
|
||||
| `npm run verify:release -- --verify-archives --seven-zip <path-to-7z.exe>` | Verifies metadata, unpacked redistribution files, and the nested Setup/Portable payloads |
|
||||
| `npm run release:win` | Builds Windows installer and portable EXE |
|
||||
| `npm run release:gitea -- <version> [notes]` | Builds, tags, and uploads a release to `git.24-music.de` |
|
||||
| `npm run release:forgejo -- <version> [notes]` | Alias for the same release workflow |
|
||||
|
||||
## Typical workflow
|
||||
|
||||
@ -207,7 +208,7 @@ Runtime files are stored in Electron's `userData` directory, including:
|
||||
- `rd_downloader.log`
|
||||
- `audit.log`
|
||||
- `rename.log`
|
||||
- `debug_support_manifest.json`
|
||||
- `debug_ai_manifest.json`
|
||||
- `trace.log`
|
||||
- `trace_config.json`
|
||||
- `session-logs/session_*.txt`
|
||||
@ -229,11 +230,11 @@ Enable it by creating these files in the same runtime folder that contains `rd_d
|
||||
- `debug_host.txt` (optional)
|
||||
Default is `127.0.0.1`. Set `0.0.0.0` only if you really want remote access and protect it with firewall, VPN, or reverse proxy.
|
||||
|
||||
After startup, the app also writes `debug_support_manifest.json` into the same runtime folder. This file lists all available endpoints, the authentication method, related runtime files, and the requirements for remote access.
|
||||
After startup, the app also writes `debug_ai_manifest.json` into the same runtime folder. This file is meant for support tooling and AI agents: it lists all available endpoints, the auth method, the related runtime files, and the one remaining external value the assistant may still need from you for remote access: the server IP or DNS name.
|
||||
|
||||
If you want extra support detail during a flaky or hard-to-reproduce issue, the app also maintains a `trace.log` plus `trace_config.json`. You can enable or disable the support trace from the app menu or remotely via the debug API. By default, the support trace now auto-disables again after 2 hours so it does not stay enabled forever by accident.
|
||||
|
||||
The app menu under `Hilfe` also includes a `Debug-Setup prüfen` action. It verifies the current host/port/token/support-manifest/trace setup locally and now also reports free disk space, current support-log sizes, and an estimated support-bundle size.
|
||||
The app menu under `Hilfe` also includes a `Debug-Setup prüfen` action. It verifies the current host/port/token/AI-manifest/trace setup locally and now also reports free disk space, current support-log sizes, and an estimated support-bundle size.
|
||||
|
||||
Available endpoints after restart:
|
||||
|
||||
@ -299,10 +300,8 @@ This makes it easy to share one URL plus token during support, so current packag
|
||||
|
||||
## Changelog
|
||||
|
||||
Detailed release history is published on [GitHub Releases](https://github.com/Sucukdeluxe/multi-debrid-downloader/releases).
|
||||
Detailed release history is published on [git.24-music.de Releases](https://git.24-music.de/Administrator/real-debrid-downloader/releases).
|
||||
|
||||
## License
|
||||
|
||||
The project is licensed under the MIT License. See `LICENSE`.
|
||||
|
||||
Bundled JVM extractor licenses and redistribution notices are available in `resources/extractor-jvm`.
|
||||
MIT - see `LICENSE`.
|
||||
|
||||
50
installer/RealDebridDownloader.iss
Normal file
50
installer/RealDebridDownloader.iss
Normal file
@ -0,0 +1,50 @@
|
||||
#define MyAppName "Real-Debrid Downloader"
|
||||
#define MyAppExeName "Real-Debrid-Downloader.exe"
|
||||
|
||||
#ifndef MyAppVersion
|
||||
#define MyAppVersion "1.0.0"
|
||||
#endif
|
||||
|
||||
#ifndef MySourceDir
|
||||
#define MySourceDir "..\\dist\\Real-Debrid-Downloader"
|
||||
#endif
|
||||
|
||||
#ifndef MyOutputDir
|
||||
#define MyOutputDir "release"
|
||||
#endif
|
||||
|
||||
#ifndef MyIconFile
|
||||
#define MyIconFile "..\\assets\\app_icon.ico"
|
||||
#endif
|
||||
|
||||
[Setup]
|
||||
AppId={{C0E95B39-389E-4D2C-8E1E-12A44E8AE8E0}
|
||||
AppName={#MyAppName}
|
||||
AppVersion={#MyAppVersion}
|
||||
AppPublisher=Sucukdeluxe
|
||||
DefaultDirName={autopf}\{#MyAppName}
|
||||
DefaultGroupName={#MyAppName}
|
||||
OutputDir={#MyOutputDir}
|
||||
OutputBaseFilename=Real-Debrid-Downloader Setup {#MyAppVersion}
|
||||
Compression=lzma
|
||||
SolidCompression=yes
|
||||
WizardStyle=modern
|
||||
PrivilegesRequired=lowest
|
||||
ArchitecturesInstallIn64BitMode=x64compatible
|
||||
UninstallDisplayIcon={app}\{#MyAppExeName}
|
||||
SetupIconFile={#MyIconFile}
|
||||
|
||||
[Languages]
|
||||
Name: "german"; MessagesFile: "compiler:Languages\German.isl"
|
||||
Name: "english"; MessagesFile: "compiler:Default.isl"
|
||||
|
||||
[Files]
|
||||
Source: "{#MySourceDir}\\*"; DestDir: "{app}"; Flags: recursesubdirs createallsubdirs
|
||||
Source: "{#MyIconFile}"; DestDir: "{app}"; DestName: "app_icon.ico"
|
||||
|
||||
[Icons]
|
||||
Name: "{group}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; IconFilename: "{app}\app_icon.ico"
|
||||
Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; IconFilename: "{app}\app_icon.ico"
|
||||
|
||||
[Run]
|
||||
Filename: "{app}\{#MyAppExeName}"; Description: "{#MyAppName} starten"; Flags: nowait postinstall skipifsilent
|
||||
41
package-lock.json
generated
41
package-lock.json
generated
@ -1,18 +1,18 @@
|
||||
{
|
||||
"name": "real-debrid-downloader",
|
||||
"version": "2.0.1",
|
||||
"version": "1.7.45",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "real-debrid-downloader",
|
||||
"version": "2.0.1",
|
||||
"version": "1.7.45",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"adm-zip": "0.6.0",
|
||||
"adm-zip": "^0.5.16",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"uuid": "11.1.1"
|
||||
"uuid": "^11.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/adm-zip": "^0.5.7",
|
||||
@ -31,8 +31,7 @@
|
||||
"typescript": "^5.7.3",
|
||||
"vite": "^6.0.5",
|
||||
"vitest": "^2.1.8",
|
||||
"wait-on": "^8.0.1",
|
||||
"yaml": "^2.9.0"
|
||||
"wait-on": "^8.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
@ -2254,12 +2253,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/adm-zip": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.6.0.tgz",
|
||||
"integrity": "sha512-XleryMhbuksdKtofnWZ9Sk+4CUTbms4Mb/EU32SZwToAyZ5RgVos/ki8n+yr0LWHOGKuakbXTuuYNHLQjhddgg==",
|
||||
"version": "0.5.16",
|
||||
"resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.16.tgz",
|
||||
"integrity": "sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0"
|
||||
"node": ">=12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
@ -7829,9 +7828,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "11.1.1",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz",
|
||||
"integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==",
|
||||
"version": "11.1.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
|
||||
"integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
@ -9670,22 +9669,6 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yaml": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
|
||||
"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"yaml": "bin.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/eemeli"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs": {
|
||||
"version": "17.7.2",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
|
||||
|
||||
33
package.json
33
package.json
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "real-debrid-downloader",
|
||||
"version": "2.0.1",
|
||||
"version": "1.7.232",
|
||||
"description": "Desktop downloader",
|
||||
"main": "build/main/main/main.js",
|
||||
"author": "Sucukdeluxe",
|
||||
@ -14,19 +14,17 @@
|
||||
"build:main": "tsup src/main/main.ts src/preload/preload.ts --out-dir build/main --format cjs --target node20 --external electron --sourcemap",
|
||||
"build:renderer": "vite build",
|
||||
"start": "cross-env NODE_ENV=production electron .",
|
||||
"test": "npm run test:client && npm run test:backup-api",
|
||||
"test:client": "vitest run",
|
||||
"test:backup-api": "npm --prefix services/backup-api test",
|
||||
"start:backup-api": "npm --prefix services/backup-api start",
|
||||
"test": "vitest run",
|
||||
"self-check": "tsx tests/self-check.ts",
|
||||
"release:win": "npm run build && electron-builder --publish never --win nsis portable",
|
||||
"verify:release": "node scripts/verify_public_release.mjs"
|
||||
"release:gitea": "node scripts/release_gitea.mjs",
|
||||
"release:forgejo": "node scripts/release_gitea.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"adm-zip": "0.6.0",
|
||||
"adm-zip": "^0.5.16",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"uuid": "11.1.1"
|
||||
"uuid": "^11.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/adm-zip": "^0.5.7",
|
||||
@ -45,8 +43,7 @@
|
||||
"typescript": "^5.7.3",
|
||||
"vite": "^6.0.5",
|
||||
"vitest": "^2.1.8",
|
||||
"wait-on": "^8.0.1",
|
||||
"yaml": "^2.9.0"
|
||||
"wait-on": "^8.0.1"
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.sucukdeluxe.realdebrid",
|
||||
@ -55,24 +52,12 @@
|
||||
"buildResources": "assets",
|
||||
"output": "release"
|
||||
},
|
||||
"publish": {
|
||||
"provider": "github",
|
||||
"owner": "Sucukdeluxe",
|
||||
"repo": "multi-debrid-downloader"
|
||||
},
|
||||
"files": [
|
||||
"build/main/**/*",
|
||||
"build/renderer/**/*",
|
||||
"resources/extractor-jvm/**/*",
|
||||
"LICENSE",
|
||||
"package.json"
|
||||
],
|
||||
"extraResources": [
|
||||
{
|
||||
"from": "LICENSE",
|
||||
"to": "LICENSE"
|
||||
}
|
||||
],
|
||||
"asarUnpack": [
|
||||
"resources/extractor-jvm/**/*"
|
||||
],
|
||||
@ -85,15 +70,11 @@
|
||||
"signAndEditExecutable": false
|
||||
},
|
||||
"nsis": {
|
||||
"artifactName": "${productName}-Setup-${version}.${ext}",
|
||||
"oneClick": false,
|
||||
"perMachine": false,
|
||||
"allowToChangeInstallationDirectory": true,
|
||||
"createDesktopShortcut": true
|
||||
},
|
||||
"portable": {
|
||||
"artifactName": "${productName}-${version}-portable.${ext}"
|
||||
},
|
||||
"afterPack": "scripts/afterPack.cjs"
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,7 +1,12 @@
|
||||
Bundled JVM extractor dependencies
|
||||
Bundled JVM extractor dependencies:
|
||||
|
||||
| Maven artifact | Version | License and restrictions | Included text | Upstream |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| net.sf.sevenzipjbinding:sevenzipjbinding:16.02-2.01 | 16.02-2.01 | GNU Lesser General Public License 2.1 or later; bundled 7-Zip code includes the unRAR restriction | licenses/LGPL-2.1.txt; licenses/7-Zip-license.txt | https://sevenzipjbind.sourceforge.net/ |
|
||||
| net.sf.sevenzipjbinding:sevenzipjbinding-all-platforms:16.02-2.01 | 16.02-2.01 | GNU Lesser General Public License 2.1 or later; bundled 7-Zip code includes the unRAR restriction | licenses/LGPL-2.1.txt; licenses/7-Zip-license.txt | https://sevenzipjbind.sourceforge.net/ |
|
||||
| net.lingala.zip4j:zip4j:2.11.5 | 2.11.5 | Apache License 2.0 | licenses/Apache-2.0.txt | https://github.com/srikanth-lingala/zip4j |
|
||||
1) sevenzipjbinding (16.02-2.01)
|
||||
- Maven artifact: net.sf.sevenzipjbinding:sevenzipjbinding
|
||||
- Maven artifact: net.sf.sevenzipjbinding:sevenzipjbinding-all-platforms
|
||||
- Upstream: https://sevenzipjbind.sourceforge.net/
|
||||
|
||||
2) zip4j (2.11.5)
|
||||
- Maven artifact: net.lingala.zip4j:zip4j
|
||||
- Upstream: https://github.com/srikanth-lingala/zip4j
|
||||
|
||||
Please review upstream licenses and notices before redistribution.
|
||||
|
||||
@ -1,146 +0,0 @@
|
||||
7-Zip
|
||||
~~~~~
|
||||
License for use and distribution
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
7-Zip Copyright (C) 1999-2025 Igor Pavlov.
|
||||
|
||||
The licenses for files are:
|
||||
|
||||
- 7z.dll:
|
||||
- The "GNU LGPL" as main license for most of the code
|
||||
- The "GNU LGPL" with "unRAR license restriction" for some code
|
||||
- The "BSD 3-clause License" for some code
|
||||
- The "BSD 2-clause License" for some code
|
||||
- All other files: the "GNU LGPL".
|
||||
|
||||
Redistributions in binary form must reproduce related license information from this file.
|
||||
|
||||
Note:
|
||||
You can use 7-Zip on any computer, including a computer in a commercial
|
||||
organization. You don't need to register or pay for 7-Zip.
|
||||
|
||||
|
||||
GNU LGPL information
|
||||
--------------------
|
||||
|
||||
This library is free software; you can redistribute it and/or
|
||||
modify it under the terms of the GNU Lesser General Public
|
||||
License as published by the Free Software Foundation; either
|
||||
version 2.1 of the License, or (at your option) any later version.
|
||||
|
||||
This library is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
Lesser General Public License for more details.
|
||||
|
||||
You can receive a copy of the GNU Lesser General Public License from
|
||||
http://www.gnu.org/
|
||||
|
||||
|
||||
|
||||
|
||||
BSD 3-clause License in 7-Zip code
|
||||
----------------------------------
|
||||
|
||||
The "BSD 3-clause License" is used for the following code in 7z.dll
|
||||
1) LZFSE data decompression.
|
||||
That code was derived from the code in the "LZFSE compression library" developed by Apple Inc,
|
||||
that also uses the "BSD 3-clause License".
|
||||
2) ZSTD data decompression.
|
||||
that code was developed using original zstd decoder code as reference code.
|
||||
The original zstd decoder code was developed by Facebook Inc,
|
||||
that also uses the "BSD 3-clause License".
|
||||
|
||||
Copyright (c) 2015-2016, Apple Inc. All rights reserved.
|
||||
Copyright (c) Facebook, Inc. All rights reserved.
|
||||
Copyright (c) 2023-2025 Igor Pavlov.
|
||||
|
||||
Text of the "BSD 3-clause License"
|
||||
----------------------------------
|
||||
|
||||
Redistribution and use in source and binary forms, with or without modification,
|
||||
are permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.
|
||||
|
||||
2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
|
||||
3. Neither the name of the copyright holder nor the names of its contributors may
|
||||
be used to endorse or promote products derived from this software without
|
||||
specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
||||
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
||||
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
|
||||
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
||||
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
||||
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
|
||||
ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
|
||||
BSD 2-clause License in 7-Zip code
|
||||
----------------------------------
|
||||
|
||||
The "BSD 2-clause License" is used for the XXH64 code in 7-Zip.
|
||||
|
||||
XXH64 code in 7-Zip was derived from the original XXH64 code developed by Yann Collet.
|
||||
|
||||
Copyright (c) 2012-2021 Yann Collet.
|
||||
Copyright (c) 2023-2025 Igor Pavlov.
|
||||
|
||||
Text of the "BSD 2-clause License"
|
||||
----------------------------------
|
||||
|
||||
Redistribution and use in source and binary forms, with or without modification,
|
||||
are permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.
|
||||
|
||||
2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
||||
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
||||
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
|
||||
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
||||
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
||||
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
|
||||
ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
|
||||
unRAR license restriction
|
||||
-------------------------
|
||||
|
||||
The decompression engine for RAR archives was developed using source
|
||||
code of unRAR program.
|
||||
All copyrights to original unRAR code are owned by Alexander Roshal.
|
||||
|
||||
The license for original unRAR code has the following restriction:
|
||||
|
||||
The unRAR sources cannot be used to re-create the RAR compression algorithm,
|
||||
which is proprietary. Distribution of modified unRAR sources in separate form
|
||||
or as a part of other software is permitted, provided that it is clearly
|
||||
stated in the documentation and source comments that the code may
|
||||
not be used to develop a RAR (WinRAR) compatible archiver.
|
||||
|
||||
--
|
||||
@ -1,202 +0,0 @@
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@ -1,501 +0,0 @@
|
||||
GNU LESSER GENERAL PUBLIC LICENSE
|
||||
Version 2.1, February 1999
|
||||
|
||||
Copyright (C) 1991, 1999 Free Software Foundation, Inc.
|
||||
<https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
[This is the first released version of the Lesser GPL. It also counts
|
||||
as the successor of the GNU Library Public License, version 2, hence
|
||||
the version number 2.1.]
|
||||
|
||||
Preamble
|
||||
|
||||
The licenses for most software are designed to take away your
|
||||
freedom to share and change it. By contrast, the GNU General Public
|
||||
Licenses are intended to guarantee your freedom to share and change
|
||||
free software--to make sure the software is free for all its users.
|
||||
|
||||
This license, the Lesser General Public License, applies to some
|
||||
specially designated software packages--typically libraries--of the
|
||||
Free Software Foundation and other authors who decide to use it. You
|
||||
can use it too, but we suggest you first think carefully about whether
|
||||
this license or the ordinary General Public License is the better
|
||||
strategy to use in any particular case, based on the explanations below.
|
||||
|
||||
When we speak of free software, we are referring to freedom of use,
|
||||
not price. Our General Public Licenses are designed to make sure that
|
||||
you have the freedom to distribute copies of free software (and charge
|
||||
for this service if you wish); that you receive source code or can get
|
||||
it if you want it; that you can change the software and use pieces of
|
||||
it in new free programs; and that you are informed that you can do
|
||||
these things.
|
||||
|
||||
To protect your rights, we need to make restrictions that forbid
|
||||
distributors to deny you these rights or to ask you to surrender these
|
||||
rights. These restrictions translate to certain responsibilities for
|
||||
you if you distribute copies of the library or if you modify it.
|
||||
|
||||
For example, if you distribute copies of the library, whether gratis
|
||||
or for a fee, you must give the recipients all the rights that we gave
|
||||
you. You must make sure that they, too, receive or can get the source
|
||||
code. If you link other code with the library, you must provide
|
||||
complete object files to the recipients, so that they can relink them
|
||||
with the library after making changes to the library and recompiling
|
||||
it. And you must show them these terms so they know their rights.
|
||||
|
||||
We protect your rights with a two-step method: (1) we copyright the
|
||||
library, and (2) we offer you this license, which gives you legal
|
||||
permission to copy, distribute and/or modify the library.
|
||||
|
||||
To protect each distributor, we want to make it very clear that
|
||||
there is no warranty for the free library. Also, if the library is
|
||||
modified by someone else and passed on, the recipients should know
|
||||
that what they have is not the original version, so that the original
|
||||
author's reputation will not be affected by problems that might be
|
||||
introduced by others.
|
||||
|
||||
Finally, software patents pose a constant threat to the existence of
|
||||
any free program. We wish to make sure that a company cannot
|
||||
effectively restrict the users of a free program by obtaining a
|
||||
restrictive license from a patent holder. Therefore, we insist that
|
||||
any patent license obtained for a version of the library must be
|
||||
consistent with the full freedom of use specified in this license.
|
||||
|
||||
Most GNU software, including some libraries, is covered by the
|
||||
ordinary GNU General Public License. This license, the GNU Lesser
|
||||
General Public License, applies to certain designated libraries, and
|
||||
is quite different from the ordinary General Public License. We use
|
||||
this license for certain libraries in order to permit linking those
|
||||
libraries into non-free programs.
|
||||
|
||||
When a program is linked with a library, whether statically or using
|
||||
a shared library, the combination of the two is legally speaking a
|
||||
combined work, a derivative of the original library. The ordinary
|
||||
General Public License therefore permits such linking only if the
|
||||
entire combination fits its criteria of freedom. The Lesser General
|
||||
Public License permits more lax criteria for linking other code with
|
||||
the library.
|
||||
|
||||
We call this license the "Lesser" General Public License because it
|
||||
does Less to protect the user's freedom than the ordinary General
|
||||
Public License. It also provides other free software developers Less
|
||||
of an advantage over competing non-free programs. These disadvantages
|
||||
are the reason we use the ordinary General Public License for many
|
||||
libraries. However, the Lesser license provides advantages in certain
|
||||
special circumstances.
|
||||
|
||||
For example, on rare occasions, there may be a special need to
|
||||
encourage the widest possible use of a certain library, so that it becomes
|
||||
a de-facto standard. To achieve this, non-free programs must be
|
||||
allowed to use the library. A more frequent case is that a free
|
||||
library does the same job as widely used non-free libraries. In this
|
||||
case, there is little to gain by limiting the free library to free
|
||||
software only, so we use the Lesser General Public License.
|
||||
|
||||
In other cases, permission to use a particular library in non-free
|
||||
programs enables a greater number of people to use a large body of
|
||||
free software. For example, permission to use the GNU C Library in
|
||||
non-free programs enables many more people to use the whole GNU
|
||||
operating system, as well as its variant, the GNU/Linux operating
|
||||
system.
|
||||
|
||||
Although the Lesser General Public License is Less protective of the
|
||||
users' freedom, it does ensure that the user of a program that is
|
||||
linked with the Library has the freedom and the wherewithal to run
|
||||
that program using a modified version of the Library.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow. Pay close attention to the difference between a
|
||||
"work based on the library" and a "work that uses the library". The
|
||||
former contains code derived from the library, whereas the latter must
|
||||
be combined with the library in order to run.
|
||||
|
||||
GNU LESSER GENERAL PUBLIC LICENSE
|
||||
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
|
||||
|
||||
0. This License Agreement applies to any software library or other
|
||||
program which contains a notice placed by the copyright holder or
|
||||
other authorized party saying it may be distributed under the terms of
|
||||
this Lesser General Public License (also called "this License").
|
||||
Each licensee is addressed as "you".
|
||||
|
||||
A "library" means a collection of software functions and/or data
|
||||
prepared so as to be conveniently linked with application programs
|
||||
(which use some of those functions and data) to form executables.
|
||||
|
||||
The "Library", below, refers to any such software library or work
|
||||
which has been distributed under these terms. A "work based on the
|
||||
Library" means either the Library or any derivative work under
|
||||
copyright law: that is to say, a work containing the Library or a
|
||||
portion of it, either verbatim or with modifications and/or translated
|
||||
straightforwardly into another language. (Hereinafter, translation is
|
||||
included without limitation in the term "modification".)
|
||||
|
||||
"Source code" for a work means the preferred form of the work for
|
||||
making modifications to it. For a library, complete source code means
|
||||
all the source code for all modules it contains, plus any associated
|
||||
interface definition files, plus the scripts used to control compilation
|
||||
and installation of the library.
|
||||
|
||||
Activities other than copying, distribution and modification are not
|
||||
covered by this License; they are outside its scope. The act of
|
||||
running a program using the Library is not restricted, and output from
|
||||
such a program is covered only if its contents constitute a work based
|
||||
on the Library (independent of the use of the Library in a tool for
|
||||
writing it). Whether that is true depends on what the Library does
|
||||
and what the program that uses the Library does.
|
||||
|
||||
1. You may copy and distribute verbatim copies of the Library's
|
||||
complete source code as you receive it, in any medium, provided that
|
||||
you conspicuously and appropriately publish on each copy an
|
||||
appropriate copyright notice and disclaimer of warranty; keep intact
|
||||
all the notices that refer to this License and to the absence of any
|
||||
warranty; and distribute a copy of this License along with the
|
||||
Library.
|
||||
|
||||
You may charge a fee for the physical act of transferring a copy,
|
||||
and you may at your option offer warranty protection in exchange for a
|
||||
fee.
|
||||
|
||||
2. You may modify your copy or copies of the Library or any portion
|
||||
of it, thus forming a work based on the Library, and copy and
|
||||
distribute such modifications or work under the terms of Section 1
|
||||
above, provided that you also meet all of these conditions:
|
||||
|
||||
a) The modified work must itself be a software library.
|
||||
|
||||
b) You must cause the files modified to carry prominent notices
|
||||
stating that you changed the files and the date of any change.
|
||||
|
||||
c) You must cause the whole of the work to be licensed at no
|
||||
charge to all third parties under the terms of this License.
|
||||
|
||||
d) If a facility in the modified Library refers to a function or a
|
||||
table of data to be supplied by an application program that uses
|
||||
the facility, other than as an argument passed when the facility
|
||||
is invoked, then you must make a good faith effort to ensure that,
|
||||
in the event an application does not supply such function or
|
||||
table, the facility still operates, and performs whatever part of
|
||||
its purpose remains meaningful.
|
||||
|
||||
(For example, a function in a library to compute square roots has
|
||||
a purpose that is entirely well-defined independent of the
|
||||
application. Therefore, Subsection 2d requires that any
|
||||
application-supplied function or table used by this function must
|
||||
be optional: if the application does not supply it, the square
|
||||
root function must still compute square roots.)
|
||||
|
||||
These requirements apply to the modified work as a whole. If
|
||||
identifiable sections of that work are not derived from the Library,
|
||||
and can be reasonably considered independent and separate works in
|
||||
themselves, then this License, and its terms, do not apply to those
|
||||
sections when you distribute them as separate works. But when you
|
||||
distribute the same sections as part of a whole which is a work based
|
||||
on the Library, the distribution of the whole must be on the terms of
|
||||
this License, whose permissions for other licensees extend to the
|
||||
entire whole, and thus to each and every part regardless of who wrote
|
||||
it.
|
||||
|
||||
Thus, it is not the intent of this section to claim rights or contest
|
||||
your rights to work written entirely by you; rather, the intent is to
|
||||
exercise the right to control the distribution of derivative or
|
||||
collective works based on the Library.
|
||||
|
||||
In addition, mere aggregation of another work not based on the Library
|
||||
with the Library (or with a work based on the Library) on a volume of
|
||||
a storage or distribution medium does not bring the other work under
|
||||
the scope of this License.
|
||||
|
||||
3. You may opt to apply the terms of the ordinary GNU General Public
|
||||
License instead of this License to a given copy of the Library. To do
|
||||
this, you must alter all the notices that refer to this License, so
|
||||
that they refer to the ordinary GNU General Public License, version 2,
|
||||
instead of to this License. (If a newer version than version 2 of the
|
||||
ordinary GNU General Public License has appeared, then you can specify
|
||||
that version instead if you wish.) Do not make any other change in
|
||||
these notices.
|
||||
|
||||
Once this change is made in a given copy, it is irreversible for
|
||||
that copy, so the ordinary GNU General Public License applies to all
|
||||
subsequent copies and derivative works made from that copy.
|
||||
|
||||
This option is useful when you wish to copy part of the code of
|
||||
the Library into a program that is not a library.
|
||||
|
||||
4. You may copy and distribute the Library (or a portion or
|
||||
derivative of it, under Section 2) in object code or executable form
|
||||
under the terms of Sections 1 and 2 above provided that you accompany
|
||||
it with the complete corresponding machine-readable source code, which
|
||||
must be distributed under the terms of Sections 1 and 2 above on a
|
||||
medium customarily used for software interchange.
|
||||
|
||||
If distribution of object code is made by offering access to copy
|
||||
from a designated place, then offering equivalent access to copy the
|
||||
source code from the same place satisfies the requirement to
|
||||
distribute the source code, even though third parties are not
|
||||
compelled to copy the source along with the object code.
|
||||
|
||||
5. A program that contains no derivative of any portion of the
|
||||
Library, but is designed to work with the Library by being compiled or
|
||||
linked with it, is called a "work that uses the Library". Such a
|
||||
work, in isolation, is not a derivative work of the Library, and
|
||||
therefore falls outside the scope of this License.
|
||||
|
||||
However, linking a "work that uses the Library" with the Library
|
||||
creates an executable that is a derivative of the Library (because it
|
||||
contains portions of the Library), rather than a "work that uses the
|
||||
library". The executable is therefore covered by this License.
|
||||
Section 6 states terms for distribution of such executables.
|
||||
|
||||
When a "work that uses the Library" uses material from a header file
|
||||
that is part of the Library, the object code for the work may be a
|
||||
derivative work of the Library even though the source code is not.
|
||||
Whether this is true is especially significant if the work can be
|
||||
linked without the Library, or if the work is itself a library. The
|
||||
threshold for this to be true is not precisely defined by law.
|
||||
|
||||
If such an object file uses only numerical parameters, data
|
||||
structure layouts and accessors, and small macros and small inline
|
||||
functions (ten lines or less in length), then the use of the object
|
||||
file is unrestricted, regardless of whether it is legally a derivative
|
||||
work. (Executables containing this object code plus portions of the
|
||||
Library will still fall under Section 6.)
|
||||
|
||||
Otherwise, if the work is a derivative of the Library, you may
|
||||
distribute the object code for the work under the terms of Section 6.
|
||||
Any executables containing that work also fall under Section 6,
|
||||
whether or not they are linked directly with the Library itself.
|
||||
|
||||
6. As an exception to the Sections above, you may also combine or
|
||||
link a "work that uses the Library" with the Library to produce a
|
||||
work containing portions of the Library, and distribute that work
|
||||
under terms of your choice, provided that the terms permit
|
||||
modification of the work for the customer's own use and reverse
|
||||
engineering for debugging such modifications.
|
||||
|
||||
You must give prominent notice with each copy of the work that the
|
||||
Library is used in it and that the Library and its use are covered by
|
||||
this License. You must supply a copy of this License. If the work
|
||||
during execution displays copyright notices, you must include the
|
||||
copyright notice for the Library among them, as well as a reference
|
||||
directing the user to the copy of this License. Also, you must do one
|
||||
of these things:
|
||||
|
||||
a) Accompany the work with the complete corresponding
|
||||
machine-readable source code for the Library including whatever
|
||||
changes were used in the work (which must be distributed under
|
||||
Sections 1 and 2 above); and, if the work is an executable linked
|
||||
with the Library, with the complete machine-readable "work that
|
||||
uses the Library", as object code and/or source code, so that the
|
||||
user can modify the Library and then relink to produce a modified
|
||||
executable containing the modified Library. (It is understood
|
||||
that the user who changes the contents of definitions files in the
|
||||
Library will not necessarily be able to recompile the application
|
||||
to use the modified definitions.)
|
||||
|
||||
b) Use a suitable shared library mechanism for linking with the
|
||||
Library. A suitable mechanism is one that (1) uses at run time a
|
||||
copy of the library already present on the user's computer system,
|
||||
rather than copying library functions into the executable, and (2)
|
||||
will operate properly with a modified version of the library, if
|
||||
the user installs one, as long as the modified version is
|
||||
interface-compatible with the version that the work was made with.
|
||||
|
||||
c) Accompany the work with a written offer, valid for at
|
||||
least three years, to give the same user the materials
|
||||
specified in Subsection 6a, above, for a charge no more
|
||||
than the cost of performing this distribution.
|
||||
|
||||
d) If distribution of the work is made by offering access to copy
|
||||
from a designated place, offer equivalent access to copy the above
|
||||
specified materials from the same place.
|
||||
|
||||
e) Verify that the user has already received a copy of these
|
||||
materials or that you have already sent this user a copy.
|
||||
|
||||
For an executable, the required form of the "work that uses the
|
||||
Library" must include any data and utility programs needed for
|
||||
reproducing the executable from it. However, as a special exception,
|
||||
the materials to be distributed need not include anything that is
|
||||
normally distributed (in either source or binary form) with the major
|
||||
components (compiler, kernel, and so on) of the operating system on
|
||||
which the executable runs, unless that component itself accompanies
|
||||
the executable.
|
||||
|
||||
It may happen that this requirement contradicts the license
|
||||
restrictions of other proprietary libraries that do not normally
|
||||
accompany the operating system. Such a contradiction means you cannot
|
||||
use both them and the Library together in an executable that you
|
||||
distribute.
|
||||
|
||||
7. You may place library facilities that are a work based on the
|
||||
Library side-by-side in a single library together with other library
|
||||
facilities not covered by this License, and distribute such a combined
|
||||
library, provided that the separate distribution of the work based on
|
||||
the Library and of the other library facilities is otherwise
|
||||
permitted, and provided that you do these two things:
|
||||
|
||||
a) Accompany the combined library with a copy of the same work
|
||||
based on the Library, uncombined with any other library
|
||||
facilities. This must be distributed under the terms of the
|
||||
Sections above.
|
||||
|
||||
b) Give prominent notice with the combined library of the fact
|
||||
that part of it is a work based on the Library, and explaining
|
||||
where to find the accompanying uncombined form of the same work.
|
||||
|
||||
8. You may not copy, modify, sublicense, link with, or distribute
|
||||
the Library except as expressly provided under this License. Any
|
||||
attempt otherwise to copy, modify, sublicense, link with, or
|
||||
distribute the Library is void, and will automatically terminate your
|
||||
rights under this License. However, parties who have received copies,
|
||||
or rights, from you under this License will not have their licenses
|
||||
terminated so long as such parties remain in full compliance.
|
||||
|
||||
9. You are not required to accept this License, since you have not
|
||||
signed it. However, nothing else grants you permission to modify or
|
||||
distribute the Library or its derivative works. These actions are
|
||||
prohibited by law if you do not accept this License. Therefore, by
|
||||
modifying or distributing the Library (or any work based on the
|
||||
Library), you indicate your acceptance of this License to do so, and
|
||||
all its terms and conditions for copying, distributing or modifying
|
||||
the Library or works based on it.
|
||||
|
||||
10. Each time you redistribute the Library (or any work based on the
|
||||
Library), the recipient automatically receives a license from the
|
||||
original licensor to copy, distribute, link with or modify the Library
|
||||
subject to these terms and conditions. You may not impose any further
|
||||
restrictions on the recipients' exercise of the rights granted herein.
|
||||
You are not responsible for enforcing compliance by third parties with
|
||||
this License.
|
||||
|
||||
11. If, as a consequence of a court judgment or allegation of patent
|
||||
infringement or for any other reason (not limited to patent issues),
|
||||
conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot
|
||||
distribute so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you
|
||||
may not distribute the Library at all. For example, if a patent
|
||||
license would not permit royalty-free redistribution of the Library by
|
||||
all those who receive copies directly or indirectly through you, then
|
||||
the only way you could satisfy both it and this License would be to
|
||||
refrain entirely from distribution of the Library.
|
||||
|
||||
If any portion of this section is held invalid or unenforceable under any
|
||||
particular circumstance, the balance of the section is intended to apply,
|
||||
and the section as a whole is intended to apply in other circumstances.
|
||||
|
||||
It is not the purpose of this section to induce you to infringe any
|
||||
patents or other property right claims or to contest validity of any
|
||||
such claims; this section has the sole purpose of protecting the
|
||||
integrity of the free software distribution system which is
|
||||
implemented by public license practices. Many people have made
|
||||
generous contributions to the wide range of software distributed
|
||||
through that system in reliance on consistent application of that
|
||||
system; it is up to the author/donor to decide if he or she is willing
|
||||
to distribute software through any other system and a licensee cannot
|
||||
impose that choice.
|
||||
|
||||
This section is intended to make thoroughly clear what is believed to
|
||||
be a consequence of the rest of this License.
|
||||
|
||||
12. If the distribution and/or use of the Library is restricted in
|
||||
certain countries either by patents or by copyrighted interfaces, the
|
||||
original copyright holder who places the Library under this License may add
|
||||
an explicit geographical distribution limitation excluding those countries,
|
||||
so that distribution is permitted only in or among countries not thus
|
||||
excluded. In such case, this License incorporates the limitation as if
|
||||
written in the body of this License.
|
||||
|
||||
13. The Free Software Foundation may publish revised and/or new
|
||||
versions of the Lesser General Public License from time to time.
|
||||
Such new versions will be similar in spirit to the present version,
|
||||
but may differ in detail to address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the Library
|
||||
specifies a version number of this License which applies to it and
|
||||
"any later version", you have the option of following the terms and
|
||||
conditions either of that version or of any later version published by
|
||||
the Free Software Foundation. If the Library does not specify a
|
||||
license version number, you may choose any version ever published by
|
||||
the Free Software Foundation.
|
||||
|
||||
14. If you wish to incorporate parts of the Library into other free
|
||||
programs whose distribution conditions are incompatible with these,
|
||||
write to the author to ask for permission. For software which is
|
||||
copyrighted by the Free Software Foundation, write to the Free
|
||||
Software Foundation; we sometimes make exceptions for this. Our
|
||||
decision will be guided by the two goals of preserving the free status
|
||||
of all derivatives of our free software and of promoting the sharing
|
||||
and reuse of software generally.
|
||||
|
||||
NO WARRANTY
|
||||
|
||||
15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO
|
||||
WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW.
|
||||
EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR
|
||||
OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY
|
||||
KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE
|
||||
LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME
|
||||
THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN
|
||||
WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY
|
||||
AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU
|
||||
FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR
|
||||
CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE
|
||||
LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING
|
||||
RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A
|
||||
FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF
|
||||
SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH
|
||||
DAMAGES.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Libraries
|
||||
|
||||
If you develop a new library, and you want it to be of the greatest
|
||||
possible use to the public, we recommend making it free software that
|
||||
everyone can redistribute and change. You can do so by permitting
|
||||
redistribution under these terms (or, alternatively, under the terms of the
|
||||
ordinary General Public License).
|
||||
|
||||
To apply these terms, attach the following notices to the library. It is
|
||||
safest to attach them to the start of each source file to most effectively
|
||||
convey the exclusion of warranty; and each file should have at least the
|
||||
"copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the library's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This library is free software; you can redistribute it and/or
|
||||
modify it under the terms of the GNU Lesser General Public
|
||||
License as published by the Free Software Foundation; either
|
||||
version 2.1 of the License, or (at your option) any later version.
|
||||
|
||||
This library is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
Lesser General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Lesser General Public
|
||||
License along with this library; if not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or your
|
||||
school, if any, to sign a "copyright disclaimer" for the library, if
|
||||
necessary. Here is a sample; alter the names:
|
||||
|
||||
Yoyodyne, Inc., hereby disclaims all copyright interest in the
|
||||
library `Frob' (a library for tweaking knobs) written by James Random Hacker.
|
||||
|
||||
<signature of Moe Ghoul>, 1 April 1990
|
||||
Moe Ghoul, President of Vice
|
||||
|
||||
That's all there is to it!
|
||||
51
scripts/debrid_service_smoke.ts
Normal file
51
scripts/debrid_service_smoke.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import { DebridService } from "../src/main/debrid";
|
||||
import { defaultSettings } from "../src/main/constants";
|
||||
import { MegaWebFallback } from "../src/main/mega-web-fallback";
|
||||
|
||||
const links = [
|
||||
"https://rapidgator.net/file/837ef967aede4935e3e0374c4e663b40/GTHDERTPIIP7P401.part1.rar.html",
|
||||
"https://rapidgator.net/file/ef3c9d64c899f801d69d6888dad89dcd/GTHDERTPIIP7P401.part2.rar.html",
|
||||
"https://rapidgator.net/file/b38130fcf1e8448953250b9a1ed7958d/GTHDERTPIIP7P401.part3.rar.html"
|
||||
];
|
||||
|
||||
const settings = {
|
||||
...defaultSettings(),
|
||||
token: process.env.RD_TOKEN || "",
|
||||
megaLogin: process.env.MEGA_LOGIN || "",
|
||||
megaPassword: process.env.MEGA_PASSWORD || "",
|
||||
bestToken: process.env.BEST_TOKEN || "",
|
||||
allDebridToken: process.env.ALLDEBRID_TOKEN || "",
|
||||
providerPrimary: "alldebrid" as const,
|
||||
providerSecondary: "realdebrid" as const,
|
||||
providerTertiary: "megadebrid" as const,
|
||||
autoProviderFallback: true
|
||||
};
|
||||
|
||||
if (!settings.token && !(settings.megaLogin && settings.megaPassword) && !settings.bestToken && !settings.allDebridToken) {
|
||||
console.error("No provider credentials set. Use RD_TOKEN or MEGA_LOGIN+MEGA_PASSWORD or BEST_TOKEN or ALLDEBRID_TOKEN.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const megaWeb = new MegaWebFallback(() => ({
|
||||
login: settings.megaLogin,
|
||||
password: settings.megaPassword
|
||||
}));
|
||||
try {
|
||||
const service = new DebridService(settings, {
|
||||
megaWebUnrestrict: (link) => megaWeb.unrestrict(link)
|
||||
});
|
||||
for (const link of links) {
|
||||
try {
|
||||
const result = await service.unrestrictLink(link);
|
||||
console.log(`[OK] ${result.providerLabel} -> ${result.fileName}`);
|
||||
} catch (error) {
|
||||
console.log(`[FAIL] ${String(error)}`);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
megaWeb.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(e => { console.error(e); process.exit(1); });
|
||||
148
scripts/mega_web_generate_download_test.mjs
Normal file
148
scripts/mega_web_generate_download_test.mjs
Normal file
@ -0,0 +1,148 @@
|
||||
const LOGIN = process.env.MEGA_LOGIN || "";
|
||||
const PASSWORD = process.env.MEGA_PASSWORD || "";
|
||||
|
||||
const LINKS = [
|
||||
"https://rapidgator.net/file/90b5397dfc3e1a0e561db7d6b89d5604/scnb-rrw7-S08E01.part1.rar.html",
|
||||
"https://rapidgator.net/file/8ddf856dc833310c5cae9db82caf9682/scnb-rrw7-S08E01.part2.rar.html",
|
||||
"https://rapidgator.net/file/440eed67d266476866332ae224c3fad5/scnb-rrw7-S08E01.part3.rar.html"
|
||||
];
|
||||
|
||||
if (!LOGIN || !PASSWORD) {
|
||||
throw new Error("Set MEGA_LOGIN and MEGA_PASSWORD env vars");
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function cookieFrom(headers) {
|
||||
const cookies = headers.getSetCookie();
|
||||
return cookies.map((x) => x.split(";")[0].trim()).filter(Boolean).join("; ");
|
||||
}
|
||||
|
||||
function parseDebridCodes(html) {
|
||||
const re = /processDebrid\((\d+),'([^']+)',0\)/g;
|
||||
const out = [];
|
||||
let m;
|
||||
while ((m = re.exec(html)) !== null) {
|
||||
out.push({ id: Number(m[1]), code: m[2] });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function resolveCode(cookie, code) {
|
||||
for (let attempt = 1; attempt <= 50; attempt += 1) {
|
||||
const res = await fetch("https://www.mega-debrid.eu/index.php?ajax=debrid&json", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"User-Agent": "Mozilla/5.0",
|
||||
Cookie: cookie,
|
||||
Referer: "https://www.mega-debrid.eu/index.php?page=debrideur&lang=de"
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
code,
|
||||
autodl: "0"
|
||||
})
|
||||
});
|
||||
const text = (await res.text()).trim();
|
||||
if (text === "reload") {
|
||||
if (attempt % 5 === 0) {
|
||||
console.log(` [retry] code=${code} attempt=${attempt}/50 (waiting for server)`);
|
||||
}
|
||||
await sleep(800);
|
||||
continue;
|
||||
}
|
||||
if (text === "false") {
|
||||
return { ok: false, reason: "false" };
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
if (parsed?.link) {
|
||||
return { ok: true, link: String(parsed.link), text: String(parsed.text || "") };
|
||||
}
|
||||
return { ok: false, reason: text };
|
||||
} catch {
|
||||
return { ok: false, reason: text };
|
||||
}
|
||||
}
|
||||
return { ok: false, reason: "timeout" };
|
||||
}
|
||||
|
||||
async function probeDownload(url) {
|
||||
const res = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Range: "bytes=0-4095",
|
||||
"User-Agent": "Mozilla/5.0"
|
||||
},
|
||||
redirect: "manual"
|
||||
});
|
||||
return {
|
||||
status: res.status,
|
||||
location: res.headers.get("location") || "",
|
||||
contentType: res.headers.get("content-type") || "",
|
||||
contentLength: res.headers.get("content-length") || ""
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const loginRes = await fetch("https://www.mega-debrid.eu/index.php?form=login", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"User-Agent": "Mozilla/5.0"
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
login: LOGIN,
|
||||
password: PASSWORD,
|
||||
remember: "on"
|
||||
}),
|
||||
redirect: "manual"
|
||||
});
|
||||
|
||||
if (loginRes.status >= 400) {
|
||||
throw new Error(`Login failed with HTTP ${loginRes.status}`);
|
||||
}
|
||||
const cookie = cookieFrom(loginRes.headers);
|
||||
if (!cookie) {
|
||||
throw new Error("Login returned no session cookie");
|
||||
}
|
||||
console.log("login", loginRes.status, loginRes.headers.get("location") || "");
|
||||
|
||||
const debridRes = await fetch("https://www.mega-debrid.eu/index.php?form=debrid", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"User-Agent": "Mozilla/5.0",
|
||||
Cookie: cookie,
|
||||
Referer: "https://www.mega-debrid.eu/index.php?page=debrideur&lang=de"
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
links: LINKS.join("\n"),
|
||||
password: "",
|
||||
showLinks: "1"
|
||||
})
|
||||
});
|
||||
|
||||
const html = await debridRes.text();
|
||||
const codes = parseDebridCodes(html);
|
||||
console.log("codes", codes.length);
|
||||
if (codes.length === 0) {
|
||||
throw new Error("No processDebrid codes found");
|
||||
}
|
||||
|
||||
for (let i = 0; i < Math.min(3, codes.length); i += 1) {
|
||||
const c = codes[i];
|
||||
const resolved = await resolveCode(cookie, c.code);
|
||||
if (!resolved.ok) {
|
||||
console.log(`[FAIL] code ${c.code}: ${resolved.reason}`);
|
||||
continue;
|
||||
}
|
||||
console.log(`[OK] code ${c.code} -> ${resolved.link}`);
|
||||
const probe = await probeDownload(resolved.link);
|
||||
console.log(` probe status=${probe.status} type=${probe.contentType} len=${probe.contentLength} loc=${probe.location}`);
|
||||
}
|
||||
}
|
||||
|
||||
await main().catch((e) => { console.error(e); process.exit(1); });
|
||||
295
scripts/provider_smoke_check.mjs
Normal file
295
scripts/provider_smoke_check.mjs
Normal file
@ -0,0 +1,295 @@
|
||||
const RAPIDGATOR_LINKS = [
|
||||
"https://rapidgator.net/file/837ef967aede4935e3e0374c4e663b40/GTHDERTPIIP7P401.part1.rar.html",
|
||||
"https://rapidgator.net/file/ef3c9d64c899f801d69d6888dad89dcd/GTHDERTPIIP7P401.part2.rar.html",
|
||||
"https://rapidgator.net/file/b38130fcf1e8448953250b9a1ed7958d/GTHDERTPIIP7P401.part3.rar.html"
|
||||
];
|
||||
|
||||
const rdToken = process.env.RD_TOKEN || "";
|
||||
const megaLogin = process.env.MEGA_LOGIN || "";
|
||||
const megaPassword = process.env.MEGA_PASSWORD || "";
|
||||
const bestToken = process.env.BEST_TOKEN || "";
|
||||
const allDebridToken = process.env.ALLDEBRID_TOKEN || "";
|
||||
let megaCookie = "";
|
||||
|
||||
if (!rdToken && !(megaLogin && megaPassword) && !bestToken && !allDebridToken) {
|
||||
console.error("No provider credentials configured. Set RD_TOKEN and/or MEGA_LOGIN+MEGA_PASSWORD and/or BEST_TOKEN and/or ALLDEBRID_TOKEN.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function asRecord(value) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function pickString(payload, keys) {
|
||||
if (!payload) {
|
||||
return "";
|
||||
}
|
||||
for (const key of keys) {
|
||||
const value = payload[key];
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function parseResponseError(status, bodyText, payload) {
|
||||
return pickString(payload, ["response_text", "error", "message", "error_description"]) || bodyText || `HTTP ${status}`;
|
||||
}
|
||||
|
||||
async function callRealDebrid(link) {
|
||||
const response = await fetch("https://api.real-debrid.com/rest/1.0/unrestrict/link", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${rdToken}`,
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"User-Agent": "RD-Node-Downloader/1.1.12"
|
||||
},
|
||||
body: new URLSearchParams({ link })
|
||||
});
|
||||
const text = await response.text();
|
||||
const payload = asRecord(safeJson(text));
|
||||
if (!response.ok) {
|
||||
return { ok: false, error: parseResponseError(response.status, text, payload) };
|
||||
}
|
||||
const direct = pickString(payload, ["download", "link"]);
|
||||
if (!direct) {
|
||||
return { ok: false, error: "Real-Debrid returned no download URL" };
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
direct,
|
||||
fileName: pickString(payload, ["filename", "fileName"])
|
||||
};
|
||||
}
|
||||
|
||||
async function callMegaDebrid(link) {
|
||||
if (!megaCookie) {
|
||||
const loginRes = await fetch("https://www.mega-debrid.eu/index.php?form=login", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"User-Agent": "Mozilla/5.0"
|
||||
},
|
||||
body: new URLSearchParams({ login: megaLogin, password: megaPassword, remember: "on" }),
|
||||
redirect: "manual"
|
||||
});
|
||||
if (loginRes.status >= 400) {
|
||||
return { ok: false, error: `Mega-Web login failed with HTTP ${loginRes.status}` };
|
||||
}
|
||||
megaCookie = loginRes.headers.getSetCookie()
|
||||
.map((chunk) => chunk.split(";")[0].trim())
|
||||
.filter(Boolean)
|
||||
.join("; ");
|
||||
if (!megaCookie) {
|
||||
return { ok: false, error: "Mega-Web login returned no session cookie" };
|
||||
}
|
||||
}
|
||||
|
||||
const debridRes = await fetch("https://www.mega-debrid.eu/index.php?form=debrid", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"User-Agent": "Mozilla/5.0",
|
||||
Cookie: megaCookie,
|
||||
Referer: "https://www.mega-debrid.eu/index.php?page=debrideur&lang=de"
|
||||
},
|
||||
body: new URLSearchParams({ links: link, password: "", showLinks: "1" })
|
||||
});
|
||||
const html = await debridRes.text();
|
||||
const code = html.match(/processDebrid\(\d+,'([^']+)',0\)/i)?.[1] || "";
|
||||
if (!code) {
|
||||
return { ok: false, error: "Mega-Web returned no processDebrid code" };
|
||||
}
|
||||
|
||||
for (let attempt = 1; attempt <= 40; attempt += 1) {
|
||||
const ajaxRes = await fetch("https://www.mega-debrid.eu/index.php?ajax=debrid&json", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"User-Agent": "Mozilla/5.0",
|
||||
Cookie: megaCookie,
|
||||
Referer: "https://www.mega-debrid.eu/index.php?page=debrideur&lang=de"
|
||||
},
|
||||
body: new URLSearchParams({ code, autodl: "0" })
|
||||
});
|
||||
const txt = (await ajaxRes.text()).trim();
|
||||
if (txt === "reload") {
|
||||
await new Promise((resolve) => setTimeout(resolve, 650));
|
||||
continue;
|
||||
}
|
||||
if (txt === "false") {
|
||||
return { ok: false, error: "Mega-Web returned false" };
|
||||
}
|
||||
const payload = safeJson(txt);
|
||||
const direct = String(payload?.link || "");
|
||||
if (!direct) {
|
||||
const msg = String(payload?.text || txt || "Mega-Web no link");
|
||||
if (/hoster does not respond correctly|could not be done for this moment/i.test(msg)) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1200));
|
||||
continue;
|
||||
}
|
||||
return { ok: false, error: msg };
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
direct,
|
||||
fileName: pickString(asRecord(payload), ["filename"]) || ""
|
||||
};
|
||||
}
|
||||
return { ok: false, error: "Mega-Web timeout while generating link" };
|
||||
}
|
||||
|
||||
async function callBestDebrid(link) {
|
||||
const encoded = encodeURIComponent(link);
|
||||
const requests = [
|
||||
{
|
||||
url: `https://bestdebrid.com/api/v1/generateLink?link=${encoded}`,
|
||||
useHeader: true
|
||||
},
|
||||
{
|
||||
url: `https://bestdebrid.com/api/v1/generateLink?auth=${encodeURIComponent(bestToken)}&link=${encoded}`,
|
||||
useHeader: false
|
||||
}
|
||||
];
|
||||
|
||||
let lastError = "Unknown BestDebrid error";
|
||||
for (const req of requests) {
|
||||
const headers = {
|
||||
"User-Agent": "RD-Node-Downloader/1.1.12"
|
||||
};
|
||||
if (req.useHeader) {
|
||||
headers.Authorization = bestToken;
|
||||
}
|
||||
const response = await fetch(req.url, {
|
||||
method: "GET",
|
||||
headers
|
||||
});
|
||||
const text = await response.text();
|
||||
const parsed = safeJson(text);
|
||||
const payload = Array.isArray(parsed) ? asRecord(parsed[0]) : asRecord(parsed);
|
||||
|
||||
if (!response.ok) {
|
||||
lastError = parseResponseError(response.status, text, payload);
|
||||
continue;
|
||||
}
|
||||
|
||||
const direct = pickString(payload, ["download", "debridLink", "link"]);
|
||||
if (!direct) {
|
||||
lastError = pickString(payload, ["response_text", "message", "error"]) || "BestDebrid returned no download URL";
|
||||
continue;
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
direct,
|
||||
fileName: pickString(payload, ["filename", "fileName"])
|
||||
};
|
||||
}
|
||||
return { ok: false, error: lastError };
|
||||
}
|
||||
|
||||
async function callAllDebrid(link) {
|
||||
const response = await fetch("https://api.alldebrid.com/v4/link/unlock", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${allDebridToken}`,
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"User-Agent": "RD-Node-Downloader/1.1.12"
|
||||
},
|
||||
body: new URLSearchParams({ link })
|
||||
});
|
||||
|
||||
const text = await response.text();
|
||||
const payload = asRecord(safeJson(text));
|
||||
if (!response.ok) {
|
||||
return { ok: false, error: parseResponseError(response.status, text, payload) };
|
||||
}
|
||||
|
||||
if (pickString(payload, ["status"]) === "error") {
|
||||
const err = asRecord(payload?.error);
|
||||
return { ok: false, error: pickString(err, ["message", "code"]) || "AllDebrid API error" };
|
||||
}
|
||||
|
||||
const data = asRecord(payload?.data);
|
||||
const direct = pickString(data, ["link"]);
|
||||
if (!direct) {
|
||||
return { ok: false, error: "AllDebrid returned no download URL" };
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
direct,
|
||||
fileName: pickString(data, ["filename"])
|
||||
};
|
||||
}
|
||||
|
||||
function safeJson(text) {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function hostFromUrl(url) {
|
||||
try {
|
||||
return new URL(url).host;
|
||||
} catch {
|
||||
return "invalid-url";
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const providers = [];
|
||||
if (rdToken) {
|
||||
providers.push({ name: "Real-Debrid", run: callRealDebrid });
|
||||
}
|
||||
if (megaLogin && megaPassword) {
|
||||
providers.push({ name: "Mega-Debrid", run: callMegaDebrid });
|
||||
}
|
||||
if (bestToken) {
|
||||
providers.push({ name: "BestDebrid", run: callBestDebrid });
|
||||
}
|
||||
if (allDebridToken) {
|
||||
providers.push({ name: "AllDebrid", run: callAllDebrid });
|
||||
}
|
||||
|
||||
let failures = 0;
|
||||
|
||||
for (const link of RAPIDGATOR_LINKS) {
|
||||
console.log(`\nLink: ${link}`);
|
||||
const results = [];
|
||||
for (const provider of providers) {
|
||||
try {
|
||||
const result = await provider.run(link);
|
||||
results.push({ provider: provider.name, ...result });
|
||||
} catch (error) {
|
||||
results.push({ provider: provider.name, ok: false, error: String(error) });
|
||||
}
|
||||
}
|
||||
|
||||
for (const result of results) {
|
||||
if (result.ok) {
|
||||
console.log(` [OK] ${result.provider} -> ${hostFromUrl(result.direct)} ${result.fileName ? `(${result.fileName})` : ""}`);
|
||||
} else {
|
||||
console.log(` [FAIL] ${result.provider} -> ${result.error}`);
|
||||
}
|
||||
}
|
||||
|
||||
const fallbackPick = results.find((entry) => entry.ok);
|
||||
if (fallbackPick) {
|
||||
console.log(` [AUTO] Selected by fallback order: ${fallbackPick.provider}`);
|
||||
} else {
|
||||
failures += 1;
|
||||
console.log(" [AUTO] No provider could unrestrict this link");
|
||||
}
|
||||
}
|
||||
|
||||
if (failures > 0) {
|
||||
process.exitCode = 2;
|
||||
}
|
||||
}
|
||||
|
||||
await main().catch((e) => { console.error(e); process.exit(1); });
|
||||
392
scripts/release_gitea.mjs
Normal file
392
scripts/release_gitea.mjs
Normal file
@ -0,0 +1,392 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
const NPM_RELEASE_WIN = process.platform === "win32"
|
||||
? {
|
||||
command: process.env.ComSpec || "cmd.exe",
|
||||
args: ["/d", "/s", "/c", "npm run release:win"]
|
||||
}
|
||||
: {
|
||||
command: "npm",
|
||||
args: ["run", "release:win"]
|
||||
};
|
||||
|
||||
function run(command, args, options = {}) {
|
||||
const result = spawnSync(command, args, {
|
||||
cwd: process.cwd(),
|
||||
encoding: "utf8",
|
||||
stdio: options.capture ? ["pipe", "pipe", "pipe"] : "inherit"
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
const stderr = result.stderr ? String(result.stderr).trim() : "";
|
||||
const stdout = result.stdout ? String(result.stdout).trim() : "";
|
||||
const details = [stderr, stdout].filter(Boolean).join("\n");
|
||||
throw new Error(`Command failed: ${command} ${args.join(" ")}${details ? `\n${details}` : ""}`);
|
||||
}
|
||||
return options.capture ? String(result.stdout || "") : "";
|
||||
}
|
||||
|
||||
function runCapture(command, args) {
|
||||
const result = spawnSync(command, args, {
|
||||
cwd: process.cwd(),
|
||||
encoding: "utf8",
|
||||
stdio: ["pipe", "pipe", "pipe"]
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
const stderr = String(result.stderr || "").trim();
|
||||
throw new Error(stderr || `Command failed: ${command} ${args.join(" ")}`);
|
||||
}
|
||||
return String(result.stdout || "").trim();
|
||||
}
|
||||
|
||||
function runWithInput(command, args, input) {
|
||||
const result = spawnSync(command, args, {
|
||||
cwd: process.cwd(),
|
||||
encoding: "utf8",
|
||||
input,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
timeout: 10000
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
const stderr = String(result.stderr || "").trim();
|
||||
throw new Error(stderr || `Command failed: ${command} ${args.join(" ")}`);
|
||||
}
|
||||
return String(result.stdout || "");
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = argv.slice(2);
|
||||
if (args.includes("--help") || args.includes("-h")) {
|
||||
return { help: true };
|
||||
}
|
||||
|
||||
const dryRun = args.includes("--dry-run");
|
||||
const cleaned = args.filter((arg) => arg !== "--dry-run");
|
||||
const version = cleaned[0] || "";
|
||||
const notes = cleaned.slice(1).join(" ").trim();
|
||||
return { help: false, dryRun, version, notes };
|
||||
}
|
||||
|
||||
function parseRemoteUrl(url) {
|
||||
const raw = String(url || "").trim();
|
||||
const httpsMatch = raw.match(/^https?:\/\/([^/]+)\/([^/]+)\/([^/]+?)(?:\.git)?$/i);
|
||||
if (httpsMatch) {
|
||||
return { host: httpsMatch[1], owner: httpsMatch[2], repo: httpsMatch[3] };
|
||||
}
|
||||
const sshMatch = raw.match(/^git@([^:]+):([^/]+)\/([^/]+?)(?:\.git)?$/i);
|
||||
if (sshMatch) {
|
||||
return { host: sshMatch[1], owner: sshMatch[2], repo: sshMatch[3] };
|
||||
}
|
||||
const sshAltMatch = raw.match(/^ssh:\/\/git@([^/:]+)(?::\d+)?\/([^/]+)\/([^/]+?)(?:\.git)?$/i);
|
||||
if (sshAltMatch) {
|
||||
return { host: sshAltMatch[1], owner: sshAltMatch[2], repo: sshAltMatch[3] };
|
||||
}
|
||||
throw new Error(`Cannot parse remote URL: ${raw}`);
|
||||
}
|
||||
|
||||
function normalizeBaseUrl(url) {
|
||||
const raw = String(url || "").trim().replace(/\/+$/, "");
|
||||
if (!raw) {
|
||||
return "";
|
||||
}
|
||||
if (!/^https?:\/\//i.test(raw)) {
|
||||
throw new Error("GITEA_BASE_URL must start with http:// or https://");
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
function getGiteaRepo() {
|
||||
const forcedRemote = String(process.env.GITEA_REMOTE || process.env.FORGEJO_REMOTE || "").trim();
|
||||
const remotes = forcedRemote
|
||||
? [forcedRemote]
|
||||
: ["gitea", "forgejo", "origin", "github-new", "codeberg"];
|
||||
|
||||
const preferredBase = normalizeBaseUrl(process.env.GITEA_BASE_URL || process.env.FORGEJO_BASE_URL || "https://git.24-music.de");
|
||||
|
||||
const preferredProtocol = preferredBase ? new URL(preferredBase).protocol : "https:";
|
||||
|
||||
for (const remote of remotes) {
|
||||
try {
|
||||
const remoteUrl = runCapture("git", ["remote", "get-url", remote]);
|
||||
const parsed = parseRemoteUrl(remoteUrl);
|
||||
const remoteBase = `https://${parsed.host}`.toLowerCase();
|
||||
if (preferredBase && remoteBase !== preferredBase.toLowerCase().replace(/^http:/, "https:")) {
|
||||
continue;
|
||||
}
|
||||
return { remote, ...parsed, baseUrl: `${preferredProtocol}//${parsed.host}` };
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
if (preferredBase) {
|
||||
throw new Error(
|
||||
`No remote found for ${preferredBase}. Add one with: git remote add gitea ${preferredBase}/<owner>/<repo>.git`
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error("No suitable remote found. Set GITEA_REMOTE or GITEA_BASE_URL.");
|
||||
}
|
||||
|
||||
function getAuthHeader(host) {
|
||||
const explicitToken = String(process.env.GITEA_TOKEN || process.env.FORGEJO_TOKEN || "").trim();
|
||||
if (explicitToken) {
|
||||
return `token ${explicitToken}`;
|
||||
}
|
||||
|
||||
const credentialText = runWithInput("git", ["credential", "fill"], `protocol=https\nhost=${host}\n\n`);
|
||||
const map = new Map();
|
||||
for (const line of credentialText.split(/\r?\n/)) {
|
||||
if (!line.includes("=")) {
|
||||
continue;
|
||||
}
|
||||
const [key, value] = line.split("=", 2);
|
||||
map.set(key, value);
|
||||
}
|
||||
const username = map.get("username") || "";
|
||||
const password = map.get("password") || "";
|
||||
if (!username || !password) {
|
||||
throw new Error(
|
||||
`Missing credentials for ${host}. Set GITEA_TOKEN or store credentials for this host in git credential helper.`
|
||||
);
|
||||
}
|
||||
const token = Buffer.from(`${username}:${password}`, "utf8").toString("base64");
|
||||
return `Basic ${token}`;
|
||||
}
|
||||
|
||||
async function apiRequest(method, url, authHeader, body, contentType = "application/json") {
|
||||
const headers = {
|
||||
Accept: "application/json",
|
||||
Authorization: authHeader
|
||||
};
|
||||
if (body !== undefined) {
|
||||
headers["Content-Type"] = contentType;
|
||||
}
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers,
|
||||
body
|
||||
});
|
||||
const text = await response.text();
|
||||
let parsed;
|
||||
try {
|
||||
parsed = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
parsed = text;
|
||||
}
|
||||
return { ok: response.ok, status: response.status, body: parsed };
|
||||
}
|
||||
|
||||
function ensureVersionString(version) {
|
||||
const trimmed = String(version || "").trim();
|
||||
if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(trimmed)) {
|
||||
throw new Error("Invalid version format. Expected e.g. 1.4.42");
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function updatePackageVersion(rootDir, version) {
|
||||
const packagePath = path.join(rootDir, "package.json");
|
||||
const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8"));
|
||||
if (String(packageJson.version || "") === version) {
|
||||
process.stdout.write(`package.json is already at version ${version}, skipping update.\n`);
|
||||
return;
|
||||
}
|
||||
packageJson.version = version;
|
||||
fs.writeFileSync(packagePath, `${JSON.stringify(packageJson, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
function patchLatestYml(releaseDir, version) {
|
||||
const ymlPath = path.join(releaseDir, "latest.yml");
|
||||
let content = fs.readFileSync(ymlPath, "utf8");
|
||||
const setupName = `Real-Debrid-Downloader Setup ${version}.exe`;
|
||||
const dashedName = `Real-Debrid-Downloader-Setup-${version}.exe`;
|
||||
if (content.includes(dashedName)) {
|
||||
content = content.split(dashedName).join(setupName);
|
||||
fs.writeFileSync(ymlPath, content, "utf8");
|
||||
process.stdout.write(`Patched latest.yml: replaced "${dashedName}" with "${setupName}"\n`);
|
||||
}
|
||||
}
|
||||
|
||||
function ensureAssetsExist(rootDir, version) {
|
||||
const releaseDir = path.join(rootDir, "release");
|
||||
const files = [
|
||||
`Real-Debrid-Downloader Setup ${version}.exe`,
|
||||
`Real-Debrid-Downloader ${version}.exe`,
|
||||
"latest.yml",
|
||||
`Real-Debrid-Downloader Setup ${version}.exe.blockmap`
|
||||
];
|
||||
for (const fileName of files) {
|
||||
const fullPath = path.join(releaseDir, fileName);
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
throw new Error(`Missing release artifact: ${fullPath}`);
|
||||
}
|
||||
}
|
||||
patchLatestYml(releaseDir, version);
|
||||
return { releaseDir, files };
|
||||
}
|
||||
|
||||
function ensureNoTrackedChanges() {
|
||||
const output = runCapture("git", ["status", "--porcelain"]);
|
||||
const lines = output.split(/\r?\n/).filter(Boolean);
|
||||
const tracked = lines.filter((line) => !line.startsWith("?? "));
|
||||
if (tracked.length > 0) {
|
||||
throw new Error(`Working tree has tracked changes:\n${tracked.join("\n")}`);
|
||||
}
|
||||
}
|
||||
|
||||
function ensureTagMissing(tag) {
|
||||
const result = spawnSync("git", ["rev-parse", "--verify", `refs/tags/${tag}`], {
|
||||
cwd: process.cwd(),
|
||||
stdio: "ignore"
|
||||
});
|
||||
if (result.status === 0) {
|
||||
throw new Error(`Tag already exists: ${tag}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function createOrGetRelease(baseApi, tag, authHeader, notes) {
|
||||
const byTag = await apiRequest("GET", `${baseApi}/releases/tags/${encodeURIComponent(tag)}`, authHeader);
|
||||
if (byTag.ok) {
|
||||
return byTag.body;
|
||||
}
|
||||
const payload = {
|
||||
tag_name: tag,
|
||||
target_commitish: "main",
|
||||
name: tag,
|
||||
body: notes || `Release ${tag}`,
|
||||
draft: true,
|
||||
prerelease: false
|
||||
};
|
||||
const created = await apiRequest("POST", `${baseApi}/releases`, authHeader, JSON.stringify(payload));
|
||||
if (created.ok) {
|
||||
return created.body;
|
||||
}
|
||||
if (created.status === 409 || created.status === 422 || created.status === 500) {
|
||||
const retry = await apiRequest("GET", `${baseApi}/releases/tags/${encodeURIComponent(tag)}`, authHeader);
|
||||
if (retry.ok) {
|
||||
process.stdout.write(`Release already exists, using existing release.\n`);
|
||||
return retry.body;
|
||||
}
|
||||
}
|
||||
throw new Error(`Failed to create release (${created.status}): ${JSON.stringify(created.body)}`);
|
||||
}
|
||||
|
||||
async function uploadReleaseAssets(baseApi, releaseId, authHeader, releaseDir, files) {
|
||||
const MAX_ATTEMPTS = 3;
|
||||
for (const fileName of files) {
|
||||
const filePath = path.join(releaseDir, fileName);
|
||||
const fileSize = fs.statSync(filePath).size;
|
||||
const uploadUrl = `${baseApi}/releases/${releaseId}/assets?name=${encodeURIComponent(fileName)}`;
|
||||
|
||||
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) {
|
||||
const fileStream = fs.createReadStream(filePath);
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(uploadUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
Authorization: authHeader,
|
||||
"Content-Type": "application/octet-stream",
|
||||
"Content-Length": String(fileSize)
|
||||
},
|
||||
body: fileStream,
|
||||
duplex: "half"
|
||||
});
|
||||
} catch (error) {
|
||||
fileStream.destroy();
|
||||
if (attempt < MAX_ATTEMPTS) {
|
||||
process.stdout.write(`Upload ${fileName} abgebrochen (Netzwerk, Versuch ${attempt}/${MAX_ATTEMPTS}), neuer Versuch...\n`);
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000 * attempt));
|
||||
continue;
|
||||
}
|
||||
throw new Error(`Asset upload failed for ${fileName} after ${MAX_ATTEMPTS} attempts: ${String(error?.message || error)}`);
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
let parsed;
|
||||
try {
|
||||
parsed = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
parsed = text;
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
process.stdout.write(`Uploaded: ${fileName}\n`);
|
||||
break;
|
||||
}
|
||||
if (response.status === 409 || response.status === 422) {
|
||||
process.stdout.write(`Skipped existing asset: ${fileName}\n`);
|
||||
break;
|
||||
}
|
||||
if (response.status >= 500 && attempt < MAX_ATTEMPTS) {
|
||||
process.stdout.write(`Upload ${fileName} fehlgeschlagen (${response.status}, Versuch ${attempt}/${MAX_ATTEMPTS}), neuer Versuch...\n`);
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000 * attempt));
|
||||
continue;
|
||||
}
|
||||
throw new Error(`Asset upload failed for ${fileName} (${response.status}): ${JSON.stringify(parsed)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const rootDir = process.cwd();
|
||||
const args = parseArgs(process.argv);
|
||||
if (args.help) {
|
||||
process.stdout.write("Usage: npm run release:gitea -- <version> [release notes] [--dry-run]\n");
|
||||
process.stdout.write("Env: GITEA_BASE_URL, GITEA_REMOTE, GITEA_TOKEN\n");
|
||||
process.stdout.write("Compatibility envs still supported: FORGEJO_BASE_URL, FORGEJO_REMOTE, FORGEJO_TOKEN\n");
|
||||
process.stdout.write("Example: npm run release:gitea -- 1.6.31 \"- Bugfixes\"\n");
|
||||
return;
|
||||
}
|
||||
|
||||
const version = ensureVersionString(args.version);
|
||||
const tag = `v${version}`;
|
||||
const releaseNotes = args.notes || `- Release ${tag}`;
|
||||
const repo = getGiteaRepo();
|
||||
|
||||
const tagExists = spawnSync("git", ["rev-parse", "--verify", `refs/tags/${tag}`], { cwd: process.cwd(), stdio: "ignore" }).status === 0;
|
||||
|
||||
if (tagExists) {
|
||||
process.stdout.write(`Tag ${tag} already exists locally — skipping version bump and git operations (recovery mode).\n`);
|
||||
} else {
|
||||
ensureNoTrackedChanges();
|
||||
|
||||
if (args.dryRun) {
|
||||
process.stdout.write(`Dry run: would release ${tag}. No changes made.\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
updatePackageVersion(rootDir, version);
|
||||
}
|
||||
|
||||
process.stdout.write(`Building release artifacts for ${tag}...\n`);
|
||||
run(NPM_RELEASE_WIN.command, NPM_RELEASE_WIN.args);
|
||||
const assets = ensureAssetsExist(rootDir, version);
|
||||
|
||||
if (!tagExists) {
|
||||
run("git", ["add", "package.json"]);
|
||||
run("git", ["commit", "-m", `Release ${tag}`]);
|
||||
run("git", ["push", repo.remote, "main"]);
|
||||
run("git", ["tag", tag]);
|
||||
run("git", ["push", repo.remote, tag]);
|
||||
}
|
||||
|
||||
const authHeader = getAuthHeader(repo.host);
|
||||
const baseApi = `${repo.baseUrl}/api/v1/repos/${repo.owner}/${repo.repo}`;
|
||||
const release = await createOrGetRelease(baseApi, tag, authHeader, releaseNotes);
|
||||
await uploadReleaseAssets(baseApi, release.id, authHeader, assets.releaseDir, assets.files);
|
||||
|
||||
const published = await apiRequest("PATCH", `${baseApi}/releases/${release.id}`, authHeader, JSON.stringify({ draft: false }));
|
||||
if (!published.ok) {
|
||||
throw new Error(`Failed to publish release (${published.status}): ${JSON.stringify(published.body)}`);
|
||||
}
|
||||
|
||||
process.stdout.write(`Release published: ${release.html_url || `${repo.baseUrl}/${repo.owner}/${repo.repo}/releases/tag/${tag}`}\n`);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
process.stderr.write(`${String(error?.message || error)}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
@ -1,392 +0,0 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import crypto from "node:crypto";
|
||||
import os from "node:os";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { parse } from "yaml";
|
||||
|
||||
const EXPECTED_PUBLISH = Object.freeze({
|
||||
provider: "github",
|
||||
owner: "Sucukdeluxe",
|
||||
repo: "multi-debrid-downloader"
|
||||
});
|
||||
const EXPECTED_PRODUCT_NAME = "Real-Debrid-Downloader";
|
||||
const EXPECTED_NSIS_ARTIFACT_NAME = "${productName}-Setup-${version}.${ext}";
|
||||
const EXPECTED_PORTABLE_ARTIFACT_NAME = "${productName}-${version}-portable.${ext}";
|
||||
const REQUIRED_BUILD_FILES = Object.freeze([
|
||||
"resources/extractor-jvm/**/*",
|
||||
"LICENSE"
|
||||
]);
|
||||
const EXPECTED_EXTRA_RESOURCE = Object.freeze({
|
||||
from: "LICENSE",
|
||||
to: "LICENSE"
|
||||
});
|
||||
const REDISTRIBUTION_FILES = Object.freeze([
|
||||
Object.freeze({
|
||||
sourcePath: "LICENSE",
|
||||
packagedPath: "resources/LICENSE",
|
||||
sha256: "f2c1bc02d9ba5235cc67dfea734e7dc90559b00d8cb2d142bad7a984ff96d3f6"
|
||||
}),
|
||||
Object.freeze({
|
||||
sourcePath: "resources/extractor-jvm/licenses/LGPL-2.1.txt",
|
||||
packagedPath: "resources/app.asar.unpacked/resources/extractor-jvm/licenses/LGPL-2.1.txt",
|
||||
sha256: "20e50fe7aae3e56378ebf0417d9de904f55a0e61e4df315333e632a4d3555d95"
|
||||
}),
|
||||
Object.freeze({
|
||||
sourcePath: "resources/extractor-jvm/licenses/7-Zip-license.txt",
|
||||
packagedPath: "resources/app.asar.unpacked/resources/extractor-jvm/licenses/7-Zip-license.txt",
|
||||
sha256: "477e15d4033026edb25d36c9f078bb0beafc9318f6505473648972a536ece263"
|
||||
}),
|
||||
Object.freeze({
|
||||
sourcePath: "resources/extractor-jvm/licenses/Apache-2.0.txt",
|
||||
packagedPath: "resources/app.asar.unpacked/resources/extractor-jvm/licenses/Apache-2.0.txt",
|
||||
sha256: "cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30"
|
||||
}),
|
||||
Object.freeze({
|
||||
sourcePath: "resources/extractor-jvm/THIRD_PARTY_NOTICES.txt",
|
||||
packagedPath: "resources/app.asar.unpacked/resources/extractor-jvm/THIRD_PARTY_NOTICES.txt",
|
||||
sha256: "d4ab6ee9ba293f9d25d22e0506a465d79794259274a0ec944d3bb3d001b46ca9"
|
||||
})
|
||||
]);
|
||||
|
||||
function readRequiredFile(filePath) {
|
||||
requireNonEmptyFile(filePath, "required file");
|
||||
return fs.readFileSync(filePath, "utf8");
|
||||
}
|
||||
|
||||
function parseYamlDocument(content, sourceName) {
|
||||
let value;
|
||||
try {
|
||||
value = parse(content, { uniqueKeys: true });
|
||||
} catch (error) {
|
||||
throw new Error(`${sourceName} contains invalid YAML: ${String(error?.message || error)}`);
|
||||
}
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new Error(`${sourceName} must contain a YAML mapping`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function assertEqual(actual, expected, label) {
|
||||
if (actual !== expected) {
|
||||
throw new Error(`${label} must be ${expected}, received ${String(actual)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveReleaseDir(rootDir) {
|
||||
const directLatest = path.join(rootDir, "latest.yml");
|
||||
if (fs.existsSync(directLatest)) {
|
||||
return rootDir;
|
||||
}
|
||||
return path.join(rootDir, "release");
|
||||
}
|
||||
|
||||
function requireNonEmptyFile(filePath, label) {
|
||||
let stat;
|
||||
try {
|
||||
stat = fs.lstatSync(filePath);
|
||||
} catch (error) {
|
||||
if (error?.code === "ENOENT") {
|
||||
throw new Error(`Missing ${label}: ${filePath}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (stat.isSymbolicLink()) {
|
||||
throw new Error(`${label} must be a regular file, not a symbolic link: ${filePath}`);
|
||||
}
|
||||
if (!stat.isFile() || stat.size === 0) {
|
||||
throw new Error(`${label} must be a non-empty regular file: ${filePath}`);
|
||||
}
|
||||
return stat;
|
||||
}
|
||||
|
||||
function sha256NormalizedText(filePath) {
|
||||
const normalized = fs.readFileSync(filePath, "utf8").replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
||||
return crypto.createHash("sha256").update(normalized, "utf8").digest("hex");
|
||||
}
|
||||
|
||||
function verifyRedistributionFiles(baseDir, pathKey, label) {
|
||||
for (const file of REDISTRIBUTION_FILES) {
|
||||
const relativePath = file[pathKey];
|
||||
const filePath = path.join(baseDir, ...relativePath.split("/"));
|
||||
requireNonEmptyFile(filePath, `${label} redistribution file`);
|
||||
const actualDigest = sha256NormalizedText(filePath);
|
||||
if (actualDigest !== file.sha256) {
|
||||
throw new Error(`${label} redistribution content mismatch for ${relativePath}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function hasExpectedExtraResource(extraResources) {
|
||||
const entries = Array.isArray(extraResources) ? extraResources : [extraResources];
|
||||
return entries.some((entry) => (
|
||||
entry
|
||||
&& typeof entry === "object"
|
||||
&& !Array.isArray(entry)
|
||||
&& entry.from === EXPECTED_EXTRA_RESOURCE.from
|
||||
&& entry.to === EXPECTED_EXTRA_RESOURCE.to
|
||||
));
|
||||
}
|
||||
|
||||
function runCommand(command, args) {
|
||||
const result = spawnSync(command, args, {
|
||||
encoding: "utf8",
|
||||
windowsHide: true
|
||||
});
|
||||
return {
|
||||
status: result.status,
|
||||
stdout: String(result.stdout || ""),
|
||||
stderr: String(result.stderr || ""),
|
||||
error: result.error
|
||||
};
|
||||
}
|
||||
|
||||
function listFiles(rootDir) {
|
||||
const files = [];
|
||||
const pending = [rootDir];
|
||||
while (pending.length > 0) {
|
||||
const current = pending.pop();
|
||||
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
||||
const entryPath = path.join(current, entry.name);
|
||||
const stat = fs.lstatSync(entryPath);
|
||||
if (stat.isSymbolicLink()) {
|
||||
files.push(entryPath);
|
||||
} else if (stat.isDirectory()) {
|
||||
pending.push(entryPath);
|
||||
} else if (stat.isFile()) {
|
||||
files.push(entryPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function extractArchive(archivePath, outputDir, sevenZipPath, commandRunner) {
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
const args = ["x", archivePath, `-o${outputDir}`, "-y", "-bb0", "-bd"];
|
||||
const result = commandRunner(sevenZipPath, args);
|
||||
if (result?.error || result?.status !== 0) {
|
||||
const detail = String(result?.error?.message || result?.stderr || result?.stdout || "unknown error").trim();
|
||||
throw new Error(`7-Zip command failed for ${archivePath}: ${detail}`);
|
||||
}
|
||||
}
|
||||
|
||||
function extractArchiveTree(archivePath, outputDir, sevenZipPath, commandRunner, depth = 0) {
|
||||
if (depth > 5) {
|
||||
throw new Error(`Archive nesting limit exceeded for ${archivePath}`);
|
||||
}
|
||||
extractArchive(archivePath, outputDir, sevenZipPath, commandRunner);
|
||||
const nestedArchives = listFiles(outputDir).filter((filePath) => /\.7z$/i.test(filePath));
|
||||
for (let index = 0; index < nestedArchives.length; index += 1) {
|
||||
const nestedArchive = nestedArchives[index];
|
||||
const nestedOutput = path.join(outputDir, `.nested-${depth}-${index}`);
|
||||
extractArchiveTree(nestedArchive, nestedOutput, sevenZipPath, commandRunner, depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRelativePath(rootDir, filePath) {
|
||||
return path.relative(rootDir, filePath).split(path.sep).join("/");
|
||||
}
|
||||
|
||||
function verifyArchiveRedistributionFiles(extractionRoot, archiveName) {
|
||||
const extractedFiles = listFiles(extractionRoot);
|
||||
for (const file of REDISTRIBUTION_FILES) {
|
||||
const suffix = file.packagedPath.toLowerCase();
|
||||
const matches = extractedFiles.filter((filePath) => {
|
||||
const relativePath = normalizeRelativePath(extractionRoot, filePath).toLowerCase();
|
||||
return relativePath === suffix || relativePath.endsWith(`/${suffix}`);
|
||||
});
|
||||
if (matches.length === 0) {
|
||||
throw new Error(`Missing redistribution file ${file.packagedPath} in archive ${archiveName}`);
|
||||
}
|
||||
for (const filePath of matches) {
|
||||
requireNonEmptyFile(filePath, `${archiveName} redistribution file`);
|
||||
const actualDigest = sha256NormalizedText(filePath);
|
||||
if (actualDigest !== file.sha256) {
|
||||
throw new Error(`Archive redistribution content mismatch for ${file.packagedPath} in ${archiveName}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function readPackageVersion(rootDir) {
|
||||
const packageJson = JSON.parse(readRequiredFile(path.join(rootDir, "package.json")));
|
||||
const version = String(packageJson.version || "").trim();
|
||||
if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(version)) {
|
||||
throw new Error(`package.json contains invalid version ${version || "<empty>"}`);
|
||||
}
|
||||
return { packageJson, version };
|
||||
}
|
||||
|
||||
function sha512Base64(filePath) {
|
||||
const hash = crypto.createHash("sha512");
|
||||
const handle = fs.openSync(filePath, "r");
|
||||
const buffer = Buffer.allocUnsafe(1024 * 1024);
|
||||
try {
|
||||
let bytesRead;
|
||||
do {
|
||||
bytesRead = fs.readSync(handle, buffer, 0, buffer.length, null);
|
||||
if (bytesRead > 0) {
|
||||
hash.update(buffer.subarray(0, bytesRead));
|
||||
}
|
||||
} while (bytesRead > 0);
|
||||
} finally {
|
||||
fs.closeSync(handle);
|
||||
}
|
||||
return hash.digest("base64");
|
||||
}
|
||||
|
||||
export function verifyPublicRelease(rootDir = process.cwd()) {
|
||||
const absoluteRoot = path.resolve(rootDir);
|
||||
const { packageJson, version } = readPackageVersion(absoluteRoot);
|
||||
|
||||
const build = packageJson.build || {};
|
||||
const publish = build.publish || {};
|
||||
assertEqual(build.productName, EXPECTED_PRODUCT_NAME, "package.json build.productName");
|
||||
assertEqual(publish.provider, EXPECTED_PUBLISH.provider, "package.json publish provider");
|
||||
assertEqual(publish.owner, EXPECTED_PUBLISH.owner, "package.json publish owner");
|
||||
assertEqual(publish.repo, EXPECTED_PUBLISH.repo, "package.json publish repo");
|
||||
assertEqual(build.nsis?.artifactName, EXPECTED_NSIS_ARTIFACT_NAME, "package.json NSIS artifactName");
|
||||
assertEqual(build.portable?.artifactName, EXPECTED_PORTABLE_ARTIFACT_NAME, "package.json portable artifactName");
|
||||
const buildFiles = Array.isArray(build.files) ? build.files : [];
|
||||
const missingBuildFiles = REQUIRED_BUILD_FILES.filter((entry) => !buildFiles.includes(entry));
|
||||
if (missingBuildFiles.length > 0) {
|
||||
throw new Error(`package.json build.files omits redistribution content: ${missingBuildFiles.join(", ")}`);
|
||||
}
|
||||
if (!hasExpectedExtraResource(build.extraResources)) {
|
||||
throw new Error("package.json build.extraResources must copy LICENSE to LICENSE");
|
||||
}
|
||||
|
||||
const releaseDir = resolveReleaseDir(absoluteRoot);
|
||||
const latestFields = parseYamlDocument(
|
||||
readRequiredFile(path.join(releaseDir, "latest.yml")),
|
||||
"latest.yml"
|
||||
);
|
||||
const appUpdateFields = parseYamlDocument(
|
||||
readRequiredFile(path.join(releaseDir, "win-unpacked", "resources", "app-update.yml")),
|
||||
"app-update.yml"
|
||||
);
|
||||
|
||||
assertEqual(latestFields.version, version, "latest.yml version");
|
||||
assertEqual(appUpdateFields.provider, EXPECTED_PUBLISH.provider, "app-update.yml provider");
|
||||
assertEqual(appUpdateFields.owner, EXPECTED_PUBLISH.owner, "app-update.yml owner");
|
||||
assertEqual(appUpdateFields.repo, EXPECTED_PUBLISH.repo, "app-update.yml repo");
|
||||
|
||||
const expectedSetup = `${EXPECTED_PRODUCT_NAME}-Setup-${version}.exe`;
|
||||
const expectedPortable = `${EXPECTED_PRODUCT_NAME}-${version}-portable.exe`;
|
||||
const expectedBlockmap = `${expectedSetup}.blockmap`;
|
||||
assertEqual(latestFields.path, expectedSetup, "latest.yml path");
|
||||
|
||||
const requiredArtifacts = [expectedSetup, expectedPortable, expectedBlockmap];
|
||||
const missingArtifacts = requiredArtifacts.filter((fileName) => !fs.existsSync(path.join(releaseDir, fileName)));
|
||||
if (missingArtifacts.length > 0) {
|
||||
throw new Error(`Missing release artifacts: ${missingArtifacts.join(", ")}`);
|
||||
}
|
||||
const artifactStats = new Map();
|
||||
for (const fileName of requiredArtifacts) {
|
||||
artifactStats.set(fileName, requireNonEmptyFile(path.join(releaseDir, fileName), "release artifact"));
|
||||
}
|
||||
|
||||
if (!Array.isArray(latestFields.files) || latestFields.files.length !== 1) {
|
||||
throw new Error("latest.yml files must contain exactly one canonical installer entry");
|
||||
}
|
||||
const installerEntry = latestFields.files[0];
|
||||
if (!installerEntry || typeof installerEntry !== "object" || Array.isArray(installerEntry)) {
|
||||
throw new Error("latest.yml files entry must be a mapping");
|
||||
}
|
||||
assertEqual(installerEntry.url, expectedSetup, "latest.yml files url");
|
||||
const expectedSetupSize = artifactStats.get(expectedSetup).size;
|
||||
assertEqual(installerEntry.size, expectedSetupSize, "latest.yml files size");
|
||||
const expectedSetupDigest = sha512Base64(path.join(releaseDir, expectedSetup));
|
||||
assertEqual(installerEntry.sha512, expectedSetupDigest, "latest.yml files SHA512 digest");
|
||||
assertEqual(latestFields.sha512, expectedSetupDigest, "latest.yml SHA512 digest");
|
||||
|
||||
verifyRedistributionFiles(absoluteRoot, "sourcePath", "source");
|
||||
verifyRedistributionFiles(path.join(releaseDir, "win-unpacked"), "packagedPath", "win-unpacked");
|
||||
|
||||
return {
|
||||
publish: {
|
||||
provider: publish.provider,
|
||||
owner: publish.owner,
|
||||
repo: publish.repo
|
||||
},
|
||||
latestArtifact: latestFields.path,
|
||||
missingArtifacts
|
||||
};
|
||||
}
|
||||
|
||||
export function verifyReleaseArchives(rootDir = process.cwd(), options = {}) {
|
||||
const absoluteRoot = path.resolve(rootDir);
|
||||
const { version } = readPackageVersion(absoluteRoot);
|
||||
const releaseDir = resolveReleaseDir(absoluteRoot);
|
||||
const sevenZipPath = String(options.sevenZipPath || process.env.SEVEN_ZIP_PATH || "7z").trim();
|
||||
const commandRunner = options.runCommand || runCommand;
|
||||
const archiveNames = [
|
||||
`${EXPECTED_PRODUCT_NAME}-Setup-${version}.exe`,
|
||||
`${EXPECTED_PRODUCT_NAME}-${version}-portable.exe`
|
||||
];
|
||||
const verifiedArchives = [];
|
||||
|
||||
for (const archiveName of archiveNames) {
|
||||
const archivePath = path.join(releaseDir, archiveName);
|
||||
requireNonEmptyFile(archivePath, "release archive");
|
||||
const extractionRoot = fs.mkdtempSync(path.join(os.tmpdir(), "public-release-archive-"));
|
||||
try {
|
||||
extractArchiveTree(archivePath, extractionRoot, sevenZipPath, commandRunner);
|
||||
verifyArchiveRedistributionFiles(extractionRoot, archiveName);
|
||||
verifiedArchives.push(archiveName);
|
||||
} finally {
|
||||
fs.rmSync(extractionRoot, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
return { verifiedArchives };
|
||||
}
|
||||
|
||||
function parseCliArgs(argv) {
|
||||
let rootDir = "";
|
||||
let verifyArchives = false;
|
||||
let sevenZipPath = "";
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (arg === "--verify-archives") {
|
||||
verifyArchives = true;
|
||||
} else if (arg === "--seven-zip") {
|
||||
index += 1;
|
||||
sevenZipPath = String(argv[index] || "").trim();
|
||||
if (!sevenZipPath) {
|
||||
throw new Error("--seven-zip requires an executable path");
|
||||
}
|
||||
} else if (arg.startsWith("--")) {
|
||||
throw new Error(`Unknown argument: ${arg}`);
|
||||
} else if (!rootDir) {
|
||||
rootDir = arg;
|
||||
} else {
|
||||
throw new Error(`Unexpected argument: ${arg}`);
|
||||
}
|
||||
}
|
||||
if (sevenZipPath && !verifyArchives) {
|
||||
throw new Error("--seven-zip requires --verify-archives");
|
||||
}
|
||||
return {
|
||||
rootDir: rootDir || process.cwd(),
|
||||
verifyArchives,
|
||||
sevenZipPath: sevenZipPath || process.env.SEVEN_ZIP_PATH || "7z"
|
||||
};
|
||||
}
|
||||
|
||||
const isCli = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
||||
if (isCli) {
|
||||
try {
|
||||
const args = parseCliArgs(process.argv.slice(2));
|
||||
const result = verifyPublicRelease(args.rootDir);
|
||||
const archiveResult = args.verifyArchives
|
||||
? verifyReleaseArchives(args.rootDir, { sevenZipPath: args.sevenZipPath })
|
||||
: { verifiedArchives: [] };
|
||||
process.stdout.write(`${JSON.stringify({ ...result, ...archiveResult })}\n`);
|
||||
} catch (error) {
|
||||
process.stderr.write(`${String(error?.message || error)}\n`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
1
services/backup-api/.gitignore
vendored
1
services/backup-api/.gitignore
vendored
@ -1 +0,0 @@
|
||||
data/
|
||||
@ -1,36 +0,0 @@
|
||||
# Multi-Debrid Backup API
|
||||
|
||||
Die API speichert ausschließlich bereits clientseitig verschlüsselte, undurchsichtige Backups. Schlüssel und Klartext verlassen den Client nicht.
|
||||
|
||||
Jeder Export wird als eigener unveränderlicher Datensatz gespeichert. Es gibt keine automatische Ablaufzeit und ein neuer Export überschreibt oder löscht keine älteren Sicherungen.
|
||||
|
||||
## Konfiguration
|
||||
|
||||
| Variable | Standard | Bedeutung |
|
||||
|---|---:|---|
|
||||
| `HOST` | `127.0.0.1` | Bind-Adresse |
|
||||
| `PORT` | `8787` | HTTP-Port hinter einem TLS-Reverse-Proxy |
|
||||
| `BACKUP_DATA_DIR` | `./data` | Persistentes Datenverzeichnis |
|
||||
| `ALLOWED_ORIGINS` | leer | Kommagetrennte erlaubte Browser-Origins |
|
||||
| `RATE_LIMIT_MAX` | `60` | Maximalzahl pro IP und Zeitfenster |
|
||||
| `RATE_LIMIT_WINDOW_MS` | `60000` | Länge des Zeitfensters |
|
||||
| `UPLOAD_RATE_LIMIT_MAX` | `10` | Maximale neue Sicherungen pro IP und Upload-Zeitfenster |
|
||||
| `UPLOAD_RATE_LIMIT_WINDOW_MS` | `3600000` | Länge des separaten Upload-Zeitfensters |
|
||||
| `MAX_STORAGE_BYTES` | `10737418240` | Globale Obergrenze des persistenten Speichers in Bytes |
|
||||
| `TRUST_PROXY` | `false` | `true`, wenn der vertrauenswürdige Proxy `X-Forwarded-For` überschreibt |
|
||||
|
||||
## Start
|
||||
|
||||
```powershell
|
||||
$env:BACKUP_DATA_DIR = 'C:\ProgramData\MultiDebridBackup'
|
||||
$env:ALLOWED_ORIGINS = 'https://downloads.24-music.de'
|
||||
$env:MAX_STORAGE_BYTES = '10737418240'
|
||||
$env:TRUST_PROXY = 'true'
|
||||
npm start
|
||||
```
|
||||
|
||||
Der Dienst sollte nur hinter einem TLS-Reverse-Proxy öffentlich erreichbar sein. Bei `TRUST_PROXY=true` muss dieser den eingehenden `X-Forwarded-For`-Header vollständig ersetzen. Das Datenverzeichnis benötigt regelmäßige Dateisystem-Backups.
|
||||
|
||||
## HTTP-Vertrag
|
||||
|
||||
`POST /v1/backups` akzeptiert `id`, `blob` und `deleteVerifier`. `POST /v1/backups/restore` akzeptiert `id` und liefert ausschließlich `blob`. `POST /v1/backups/delete` akzeptiert `id` und `deleteSecret`. Fehlerhafte Löschgeheimnisse und unbekannte IDs sind nicht unterscheidbar. IDs erscheinen nie in URLs.
|
||||
50
services/backup-api/package-lock.json
generated
50
services/backup-api/package-lock.json
generated
@ -1,50 +0,0 @@
|
||||
{
|
||||
"name": "multi-debrid-backup-api",
|
||||
"version": "2.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "multi-debrid-backup-api",
|
||||
"version": "2.0.0",
|
||||
"dependencies": {
|
||||
"proper-lockfile": "4.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/graceful-fs": {
|
||||
"version": "4.2.11",
|
||||
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/proper-lockfile": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz",
|
||||
"integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.4",
|
||||
"retry": "^0.12.0",
|
||||
"signal-exit": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/retry": {
|
||||
"version": "0.12.0",
|
||||
"resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz",
|
||||
"integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/signal-exit": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
|
||||
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
|
||||
"license": "ISC"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,16 +0,0 @@
|
||||
{
|
||||
"name": "multi-debrid-backup-api",
|
||||
"version": "2.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node src/cli.mjs",
|
||||
"test": "node --test"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"dependencies": {
|
||||
"proper-lockfile": "4.1.2"
|
||||
}
|
||||
}
|
||||
@ -1,40 +0,0 @@
|
||||
import { resolve } from 'node:path'
|
||||
import { createBackupServer } from './server.mjs'
|
||||
|
||||
const port = Number.parseInt(process.env.PORT ?? '8787', 10)
|
||||
const host = process.env.HOST ?? '127.0.0.1'
|
||||
const rootDir = resolve(process.env.BACKUP_DATA_DIR ?? './data')
|
||||
const allowedOrigins = (process.env.ALLOWED_ORIGINS ?? '')
|
||||
.split(',')
|
||||
.map(origin => origin.trim())
|
||||
.filter(Boolean)
|
||||
const rateLimit = {
|
||||
max: Number.parseInt(process.env.RATE_LIMIT_MAX ?? '60', 10),
|
||||
windowMs: Number.parseInt(process.env.RATE_LIMIT_WINDOW_MS ?? '60000', 10)
|
||||
}
|
||||
const uploadRateLimit = {
|
||||
max: Number.parseInt(process.env.UPLOAD_RATE_LIMIT_MAX ?? '10', 10),
|
||||
windowMs: Number.parseInt(process.env.UPLOAD_RATE_LIMIT_WINDOW_MS ?? '3600000', 10)
|
||||
}
|
||||
const maxStorageBytes = Number.parseInt(process.env.MAX_STORAGE_BYTES ?? String(10 * 1024 * 1024 * 1024), 10)
|
||||
const trustedProxy = process.env.TRUST_PROXY === 'true'
|
||||
|
||||
if (!Number.isSafeInteger(port) || port < 1 || port > 65_535) throw new Error('Invalid PORT')
|
||||
|
||||
const server = createBackupServer({ rootDir, allowedOrigins, rateLimit, uploadRateLimit, maxStorageBytes, trustedProxy })
|
||||
|
||||
server.listen(port, host, () => {
|
||||
process.stdout.write(`Backup API listening on ${host}:${port}\n`)
|
||||
})
|
||||
|
||||
function shutdown() {
|
||||
server.close(error => {
|
||||
if (error) {
|
||||
process.stderr.write('Backup API shutdown failed\n')
|
||||
process.exitCode = 1
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
process.on('SIGINT', shutdown)
|
||||
process.on('SIGTERM', shutdown)
|
||||
@ -1,18 +0,0 @@
|
||||
import type { Server } from "node:http";
|
||||
|
||||
export interface BackupServerOptions {
|
||||
rootDir: string;
|
||||
allowedOrigins?: string[];
|
||||
rateLimit?: {
|
||||
max: number;
|
||||
windowMs: number;
|
||||
};
|
||||
uploadRateLimit?: {
|
||||
max: number;
|
||||
windowMs: number;
|
||||
};
|
||||
maxStorageBytes?: number;
|
||||
trustedProxy?: boolean;
|
||||
}
|
||||
|
||||
export function createBackupServer(options: BackupServerOptions): Server;
|
||||
@ -1,463 +0,0 @@
|
||||
import { createHash, timingSafeEqual, randomBytes } from 'node:crypto'
|
||||
import { createServer } from 'node:http'
|
||||
import { link, mkdir, open, readFile, readdir, stat, unlink } from 'node:fs/promises'
|
||||
import { isIP } from 'node:net'
|
||||
import { join } from 'node:path'
|
||||
import lockfile from 'proper-lockfile'
|
||||
|
||||
const maxBlobBytes = 256 * 1024
|
||||
const maxBodyBytes = 384 * 1024
|
||||
const idPattern = /^[A-Za-z0-9_-]{22}$/
|
||||
const verifierPattern = /^[A-Za-z0-9_-]{43}$/
|
||||
const blobPattern = /^[A-Za-z0-9_-]+$/
|
||||
const notFoundBody = '{"error":"not_found"}'
|
||||
|
||||
function isCanonicalBase64Url(value, byteLength, pattern) {
|
||||
if (typeof value !== 'string' || !pattern.test(value)) return false
|
||||
const decoded = Buffer.from(value, 'base64url')
|
||||
return decoded.length === byteLength && decoded.toString('base64url') === value
|
||||
}
|
||||
|
||||
function isValidBackup(payload) {
|
||||
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return false
|
||||
const keys = Object.keys(payload).sort()
|
||||
if (keys.join(',') !== 'blob,deleteVerifier,id') return false
|
||||
if (!isCanonicalBase64Url(payload.id, 16, idPattern)) return false
|
||||
if (!isCanonicalBase64Url(payload.deleteVerifier, 32, verifierPattern)) return false
|
||||
if (typeof payload.blob !== 'string' || !blobPattern.test(payload.blob)) return false
|
||||
const decoded = Buffer.from(payload.blob, 'base64url')
|
||||
return decoded.length <= maxBlobBytes && decoded.toString('base64url') === payload.blob
|
||||
}
|
||||
|
||||
function createRateLimiter({ max, windowMs }) {
|
||||
const clients = new Map()
|
||||
let requestCount = 0
|
||||
return address => {
|
||||
const now = Date.now()
|
||||
requestCount += 1
|
||||
if (requestCount % 1024 === 0) {
|
||||
for (const [key, value] of clients) {
|
||||
if (now - value.startedAt >= windowMs) clients.delete(key)
|
||||
}
|
||||
}
|
||||
const current = clients.get(address)
|
||||
if (!current || now - current.startedAt >= windowMs) {
|
||||
clients.set(address, { startedAt: now, count: 1 })
|
||||
return null
|
||||
}
|
||||
if (current.count >= max) return Math.max(1, Math.ceil((windowMs - (now - current.startedAt)) / 1000))
|
||||
current.count += 1
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function readJsonBody(request) {
|
||||
const declaredLength = Number.parseInt(request.headers['content-length'] ?? '', 10)
|
||||
if (Number.isFinite(declaredLength) && declaredLength > maxBodyBytes) {
|
||||
request.resume()
|
||||
return Promise.resolve({ error: 413 })
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
let size = 0
|
||||
let settled = false
|
||||
const chunks = []
|
||||
const cleanup = () => {
|
||||
request.off('data', onData)
|
||||
request.off('end', onEnd)
|
||||
request.off('aborted', onAborted)
|
||||
request.off('error', onError)
|
||||
}
|
||||
const finish = result => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
cleanup()
|
||||
resolve(result)
|
||||
}
|
||||
const onData = chunk => {
|
||||
size += chunk.length
|
||||
if (size > maxBodyBytes) {
|
||||
finish({ error: 413 })
|
||||
request.resume()
|
||||
return
|
||||
}
|
||||
chunks.push(chunk)
|
||||
}
|
||||
const onEnd = () => {
|
||||
try {
|
||||
finish({ value: JSON.parse(Buffer.concat(chunks).toString('utf8')) })
|
||||
} catch {
|
||||
finish({ error: 400 })
|
||||
}
|
||||
}
|
||||
const onAborted = () => reject(new Error('Request aborted'))
|
||||
const onError = error => reject(error)
|
||||
request.on('data', onData)
|
||||
request.on('end', onEnd)
|
||||
request.on('aborted', onAborted)
|
||||
request.on('error', onError)
|
||||
})
|
||||
}
|
||||
|
||||
function recordPath(rootDir, id) {
|
||||
return join(rootDir, `${id}.json`)
|
||||
}
|
||||
|
||||
function createMutationQueue() {
|
||||
let pending = Promise.resolve()
|
||||
return operation => {
|
||||
const result = pending.then(operation, operation)
|
||||
pending = result.catch(() => {})
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
async function directoryUsage(rootDir) {
|
||||
let total = 0
|
||||
const entries = await readdir(rootDir, { withFileTypes: true })
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile() || !entry.name.endsWith('.json')) continue
|
||||
try {
|
||||
total += (await stat(join(rootDir, entry.name))).size
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') throw error
|
||||
}
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
async function syncDirectory(rootDir) {
|
||||
let handle
|
||||
try {
|
||||
handle = await open(rootDir, 'r')
|
||||
await handle.sync()
|
||||
} catch (error) {
|
||||
if (!['EISDIR', 'EINVAL', 'ENOTSUP', 'EPERM', 'EBADF'].includes(error.code)) throw error
|
||||
} finally {
|
||||
await handle?.close().catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
async function withStorageLock(rootDir, operation) {
|
||||
await mkdir(rootDir, { recursive: true })
|
||||
const release = await lockfile.lock(rootDir, {
|
||||
realpath: false,
|
||||
lockfilePath: join(rootDir, '.storage.lock'),
|
||||
stale: 30_000,
|
||||
update: 10_000,
|
||||
retries: {
|
||||
retries: 100,
|
||||
factor: 1.1,
|
||||
minTimeout: 10,
|
||||
maxTimeout: 100,
|
||||
randomize: true
|
||||
}
|
||||
})
|
||||
try {
|
||||
return await operation()
|
||||
} finally {
|
||||
let releaseError
|
||||
try {
|
||||
await release()
|
||||
} catch (error) {
|
||||
releaseError = error
|
||||
}
|
||||
try {
|
||||
await syncDirectory(rootDir)
|
||||
} catch (error) {
|
||||
releaseError ??= error
|
||||
}
|
||||
if (releaseError) throw releaseError
|
||||
}
|
||||
}
|
||||
|
||||
async function recordExists(rootDir, id) {
|
||||
try {
|
||||
await stat(recordPath(rootDir, id))
|
||||
return true
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') return false
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function createRecord(rootDir, payload, maxStorageBytes) {
|
||||
await mkdir(rootDir, { recursive: true })
|
||||
if (await recordExists(rootDir, payload.id)) return 'duplicate'
|
||||
const contents = Buffer.from(JSON.stringify({
|
||||
version: 1,
|
||||
blob: payload.blob,
|
||||
deleteVerifier: payload.deleteVerifier,
|
||||
createdAt: new Date().toISOString()
|
||||
}), 'utf8')
|
||||
if (await directoryUsage(rootDir) + contents.length > maxStorageBytes) return 'full'
|
||||
const temporaryPath = join(rootDir, `.${randomBytes(16).toString('hex')}.tmp`)
|
||||
let handle
|
||||
let temporaryCreated = false
|
||||
let published = false
|
||||
try {
|
||||
handle = await open(temporaryPath, 'wx', 0o600)
|
||||
temporaryCreated = true
|
||||
try {
|
||||
await handle.writeFile(contents)
|
||||
await handle.sync()
|
||||
} finally {
|
||||
await handle.close()
|
||||
handle = undefined
|
||||
}
|
||||
try {
|
||||
await link(temporaryPath, recordPath(rootDir, payload.id))
|
||||
} catch (error) {
|
||||
if (error.code === 'EEXIST') return 'duplicate'
|
||||
throw error
|
||||
}
|
||||
published = true
|
||||
return 'created'
|
||||
} finally {
|
||||
let cleanupError
|
||||
try {
|
||||
await handle?.close()
|
||||
} catch (error) {
|
||||
cleanupError = error
|
||||
}
|
||||
if (temporaryCreated) {
|
||||
try {
|
||||
await unlink(temporaryPath)
|
||||
} catch (error) {
|
||||
cleanupError ??= error
|
||||
}
|
||||
}
|
||||
if (published) {
|
||||
try {
|
||||
await syncDirectory(rootDir)
|
||||
} catch (error) {
|
||||
cleanupError ??= error
|
||||
}
|
||||
}
|
||||
if (cleanupError) throw cleanupError
|
||||
}
|
||||
}
|
||||
|
||||
async function readRecord(rootDir, id) {
|
||||
try {
|
||||
const raw = await readFile(recordPath(rootDir, id), 'utf8')
|
||||
const record = JSON.parse(raw)
|
||||
if (record?.version !== 1 || typeof record.blob !== 'string' || !isCanonicalBase64Url(record.deleteVerifier, 32, verifierPattern)) {
|
||||
throw new Error('Invalid stored record')
|
||||
}
|
||||
return record
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') return null
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
function securityHeaders(response) {
|
||||
response.setHeader('cache-control', 'no-store')
|
||||
response.setHeader('x-content-type-options', 'nosniff')
|
||||
response.setHeader('content-security-policy', "default-src 'none'")
|
||||
response.setHeader('referrer-policy', 'no-referrer')
|
||||
}
|
||||
|
||||
function sendJson(response, status, body) {
|
||||
response.statusCode = status
|
||||
response.setHeader('content-type', 'application/json; charset=utf-8')
|
||||
response.end(JSON.stringify(body))
|
||||
}
|
||||
|
||||
function sendNotFound(response) {
|
||||
response.statusCode = 404
|
||||
response.setHeader('content-type', 'application/json; charset=utf-8')
|
||||
response.end(notFoundBody)
|
||||
}
|
||||
|
||||
function authorizeOrigin(request, response, allowedOrigins) {
|
||||
const origin = request.headers.origin
|
||||
if (!origin) return true
|
||||
if (!allowedOrigins.has(origin)) {
|
||||
sendJson(response, 403, { error: 'origin_denied' })
|
||||
return false
|
||||
}
|
||||
response.setHeader('access-control-allow-origin', origin)
|
||||
response.setHeader('vary', 'Origin')
|
||||
return true
|
||||
}
|
||||
|
||||
function verifierMatches(secret, expectedVerifier) {
|
||||
const actual = createHash('sha256').update(Buffer.from(secret, 'base64url')).digest()
|
||||
const expected = Buffer.from(expectedVerifier, 'base64url')
|
||||
return actual.length === expected.length && timingSafeEqual(actual, expected)
|
||||
}
|
||||
|
||||
async function storageIsReady(rootDir) {
|
||||
const probePath = join(rootDir, `.${randomBytes(16).toString('hex')}.health`)
|
||||
try {
|
||||
await mkdir(rootDir, { recursive: true })
|
||||
const handle = await open(probePath, 'wx', 0o600)
|
||||
await handle.close()
|
||||
await unlink(probePath)
|
||||
return true
|
||||
} catch {
|
||||
await unlink(probePath).catch(() => {})
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function clientAddress(request, trustedProxy) {
|
||||
if (trustedProxy) {
|
||||
const forwarded = request.headers['x-forwarded-for']
|
||||
const candidate = Array.isArray(forwarded) ? forwarded[0] : forwarded?.split(',', 1)[0].trim()
|
||||
if (candidate && isIP(candidate)) return candidate
|
||||
}
|
||||
return request.socket.remoteAddress ?? 'unknown'
|
||||
}
|
||||
|
||||
export function createBackupServer(options) {
|
||||
if (!options?.rootDir) throw new Error('rootDir is required')
|
||||
const allowedOrigins = new Set(options.allowedOrigins ?? [])
|
||||
const rateLimit = options.rateLimit ?? { max: 60, windowMs: 60_000 }
|
||||
const uploadRateLimit = options.uploadRateLimit ?? { max: 10, windowMs: 3_600_000 }
|
||||
const maxStorageBytes = options.maxStorageBytes ?? 10 * 1024 * 1024 * 1024
|
||||
if (!Number.isSafeInteger(rateLimit.max) || rateLimit.max < 1 || !Number.isSafeInteger(rateLimit.windowMs) || rateLimit.windowMs < 1) {
|
||||
throw new Error('Invalid rate limit')
|
||||
}
|
||||
if (!Number.isSafeInteger(uploadRateLimit.max) || uploadRateLimit.max < 1 || !Number.isSafeInteger(uploadRateLimit.windowMs) || uploadRateLimit.windowMs < 1) {
|
||||
throw new Error('Invalid upload rate limit')
|
||||
}
|
||||
if (!Number.isSafeInteger(maxStorageBytes) || maxStorageBytes < 1) throw new Error('Invalid max storage size')
|
||||
const consumeRateLimit = createRateLimiter(rateLimit)
|
||||
const consumeUploadRateLimit = createRateLimiter(uploadRateLimit)
|
||||
const runStorageMutation = createMutationQueue()
|
||||
|
||||
return createServer(async (request, response) => {
|
||||
securityHeaders(response)
|
||||
try {
|
||||
const url = new URL(request.url, 'http://localhost')
|
||||
if (!authorizeOrigin(request, response, allowedOrigins)) return
|
||||
if (url.search) {
|
||||
sendNotFound(response)
|
||||
return
|
||||
}
|
||||
|
||||
if (request.method === 'OPTIONS') {
|
||||
const requestedMethod = request.headers['access-control-request-method']
|
||||
if (!request.headers.origin || requestedMethod !== 'POST') {
|
||||
sendJson(response, 400, { error: 'invalid_preflight' })
|
||||
return
|
||||
}
|
||||
response.statusCode = 204
|
||||
response.setHeader('access-control-allow-methods', 'POST, OPTIONS')
|
||||
response.setHeader('access-control-allow-headers', 'content-type')
|
||||
response.setHeader('access-control-max-age', '600')
|
||||
response.end()
|
||||
return
|
||||
}
|
||||
|
||||
if (request.method === 'GET' && url.pathname === '/health') {
|
||||
const ready = await storageIsReady(options.rootDir)
|
||||
sendJson(response, ready ? 200 : 503, { status: ready ? 'ok' : 'unavailable' })
|
||||
return
|
||||
}
|
||||
|
||||
if (url.pathname === '/v1/backups/restore' || url.pathname === '/v1/backups/delete') {
|
||||
const retryAfter = consumeRateLimit(clientAddress(request, options.trustedProxy === true))
|
||||
if (retryAfter !== null) {
|
||||
response.setHeader('retry-after', String(retryAfter))
|
||||
sendJson(response, 429, { error: 'rate_limited' })
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (request.method === 'POST' && ['/v1/backups', '/v1/backups/restore', '/v1/backups/delete'].includes(url.pathname)) {
|
||||
if (request.headers['content-type']?.split(';', 1)[0].trim().toLowerCase() !== 'application/json') {
|
||||
sendJson(response, 415, { error: 'unsupported_media_type' })
|
||||
return
|
||||
}
|
||||
const parsed = await readJsonBody(request)
|
||||
if (parsed.error) {
|
||||
if (parsed.error === 413) response.setHeader('connection', 'close')
|
||||
sendJson(response, parsed.error, { error: parsed.error === 413 ? 'payload_too_large' : 'invalid_request' })
|
||||
return
|
||||
}
|
||||
if (url.pathname === '/v1/backups/restore') {
|
||||
const keys = parsed.value && typeof parsed.value === 'object' && !Array.isArray(parsed.value)
|
||||
? Object.keys(parsed.value)
|
||||
: []
|
||||
if (keys.length !== 1 || keys[0] !== 'id' || !isCanonicalBase64Url(parsed.value.id, 16, idPattern)) {
|
||||
sendNotFound(response)
|
||||
return
|
||||
}
|
||||
const record = await readRecord(options.rootDir, parsed.value.id)
|
||||
if (!record) {
|
||||
sendNotFound(response)
|
||||
return
|
||||
}
|
||||
sendJson(response, 200, { blob: record.blob })
|
||||
return
|
||||
}
|
||||
if (url.pathname === '/v1/backups/delete') {
|
||||
const keys = parsed.value && typeof parsed.value === 'object' && !Array.isArray(parsed.value)
|
||||
? Object.keys(parsed.value).sort()
|
||||
: []
|
||||
const valid = keys.join(',') === 'deleteSecret,id'
|
||||
&& isCanonicalBase64Url(parsed.value.id, 16, idPattern)
|
||||
&& isCanonicalBase64Url(parsed.value.deleteSecret, 32, verifierPattern)
|
||||
if (!valid) {
|
||||
sendNotFound(response)
|
||||
return
|
||||
}
|
||||
const deleted = await runStorageMutation(() => withStorageLock(options.rootDir, async () => {
|
||||
const record = await readRecord(options.rootDir, parsed.value.id)
|
||||
if (!record || !verifierMatches(parsed.value.deleteSecret, record.deleteVerifier)) return false
|
||||
try {
|
||||
await unlink(recordPath(options.rootDir, parsed.value.id))
|
||||
return true
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') return false
|
||||
throw error
|
||||
}
|
||||
}))
|
||||
if (!deleted) {
|
||||
sendNotFound(response)
|
||||
return
|
||||
}
|
||||
response.statusCode = 204
|
||||
response.end()
|
||||
return
|
||||
}
|
||||
if (typeof parsed.value?.blob === 'string' && blobPattern.test(parsed.value.blob) && Buffer.from(parsed.value.blob, 'base64url').length > maxBlobBytes) {
|
||||
sendJson(response, 413, { error: 'payload_too_large' })
|
||||
return
|
||||
}
|
||||
if (!isValidBackup(parsed.value)) {
|
||||
sendJson(response, 400, { error: 'invalid_request' })
|
||||
return
|
||||
}
|
||||
const uploadRetryAfter = consumeUploadRateLimit(clientAddress(request, options.trustedProxy === true))
|
||||
if (uploadRetryAfter !== null) {
|
||||
response.setHeader('retry-after', String(uploadRetryAfter))
|
||||
sendJson(response, 429, { error: 'rate_limited' })
|
||||
return
|
||||
}
|
||||
const result = await runStorageMutation(() => withStorageLock(
|
||||
options.rootDir,
|
||||
() => createRecord(options.rootDir, parsed.value, maxStorageBytes)
|
||||
))
|
||||
if (result === 'duplicate') {
|
||||
sendJson(response, 409, { error: 'already_exists' })
|
||||
return
|
||||
}
|
||||
if (result === 'full') {
|
||||
sendJson(response, 507, { error: 'insufficient_storage' })
|
||||
return
|
||||
}
|
||||
sendJson(response, 201, { created: true })
|
||||
return
|
||||
}
|
||||
|
||||
sendNotFound(response)
|
||||
} catch {
|
||||
if (!response.headersSent) sendJson(response, 500, { error: 'internal_error' })
|
||||
else response.destroy()
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -1,511 +0,0 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { createHash, randomBytes } from 'node:crypto'
|
||||
import { mkdir, mkdtemp, readFile, readdir, rm, utimes, writeFile } from 'node:fs/promises'
|
||||
import { request as createHttpRequest } from 'node:http'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import test from 'node:test'
|
||||
import lockfile from 'proper-lockfile'
|
||||
import { createBackupServer } from '../src/server.mjs'
|
||||
|
||||
const allowedOrigin = 'https://downloads.24-music.de'
|
||||
|
||||
function backupFixture() {
|
||||
const deleteSecret = randomBytes(32).toString('base64url')
|
||||
return {
|
||||
deleteSecret,
|
||||
payload: {
|
||||
id: randomBytes(16).toString('base64url'),
|
||||
blob: randomBytes(96).toString('base64url'),
|
||||
deleteVerifier: createHash('sha256').update(Buffer.from(deleteSecret, 'base64url')).digest('base64url')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function startApi(options = {}) {
|
||||
const rootDir = await mkdtemp(join(tmpdir(), 'mdd-backup-api-'))
|
||||
const server = createBackupServer({
|
||||
rootDir,
|
||||
allowedOrigins: [allowedOrigin],
|
||||
rateLimit: { max: 100, windowMs: 60_000 },
|
||||
...options
|
||||
})
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once('error', reject)
|
||||
server.listen(0, '127.0.0.1', resolve)
|
||||
})
|
||||
const address = server.address()
|
||||
return {
|
||||
rootDir,
|
||||
server,
|
||||
baseUrl: `http://127.0.0.1:${address.port}`,
|
||||
async close() {
|
||||
await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve()))
|
||||
await rm(rootDir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function request(api, path, options = {}) {
|
||||
return fetch(`${api.baseUrl}${path}`, options)
|
||||
}
|
||||
|
||||
test('health endpoint reports readiness without exposing storage details', async t => {
|
||||
const api = await startApi()
|
||||
t.after(() => api.close())
|
||||
|
||||
const response = await request(api, '/health')
|
||||
|
||||
assert.equal(response.status, 200)
|
||||
assert.deepEqual(await response.json(), { status: 'ok' })
|
||||
assert.equal(response.headers.get('cache-control'), 'no-store')
|
||||
})
|
||||
|
||||
test('health endpoint rejects an unusable storage path', async t => {
|
||||
const container = await mkdtemp(join(tmpdir(), 'mdd-backup-health-'))
|
||||
const rootDir = join(container, 'not-a-directory')
|
||||
await writeFile(rootDir, 'occupied')
|
||||
const server = createBackupServer({ rootDir, allowedOrigins: [allowedOrigin] })
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once('error', reject)
|
||||
server.listen(0, '127.0.0.1', resolve)
|
||||
})
|
||||
const baseUrl = `http://127.0.0.1:${server.address().port}`
|
||||
t.after(async () => {
|
||||
await new Promise(resolve => server.close(resolve))
|
||||
await rm(container, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
const response = await fetch(`${baseUrl}/health`)
|
||||
|
||||
assert.equal(response.status, 503)
|
||||
assert.deepEqual(await response.json(), { status: 'unavailable' })
|
||||
})
|
||||
|
||||
test('creates and retrieves an immutable opaque backup across server restarts', async t => {
|
||||
const api = await startApi()
|
||||
const fixture = backupFixture()
|
||||
t.after(async () => {
|
||||
if (api.server.listening) await new Promise(resolve => api.server.close(resolve))
|
||||
await rm(api.rootDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
const created = await request(api, '/v1/backups', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', origin: allowedOrigin },
|
||||
body: JSON.stringify(fixture.payload)
|
||||
})
|
||||
|
||||
assert.equal(created.status, 201)
|
||||
assert.deepEqual(await created.json(), { created: true })
|
||||
await new Promise(resolve => api.server.close(resolve))
|
||||
|
||||
api.server = createBackupServer({ rootDir: api.rootDir, allowedOrigins: [allowedOrigin] })
|
||||
await new Promise((resolve, reject) => {
|
||||
api.server.once('error', reject)
|
||||
api.server.listen(0, '127.0.0.1', resolve)
|
||||
})
|
||||
api.baseUrl = `http://127.0.0.1:${api.server.address().port}`
|
||||
|
||||
const retrieved = await request(api, '/v1/backups/restore', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', origin: allowedOrigin },
|
||||
body: JSON.stringify({ id: fixture.payload.id })
|
||||
})
|
||||
assert.equal(retrieved.status, 200)
|
||||
assert.deepEqual(await retrieved.json(), { blob: fixture.payload.blob })
|
||||
|
||||
const duplicate = await request(api, '/v1/backups', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', origin: allowedOrigin },
|
||||
body: JSON.stringify({ ...fixture.payload, blob: randomBytes(96).toString('base64url') })
|
||||
})
|
||||
assert.equal(duplicate.status, 409)
|
||||
|
||||
const unchanged = await request(api, '/v1/backups/restore', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ id: fixture.payload.id })
|
||||
})
|
||||
assert.deepEqual(await unchanged.json(), { blob: fixture.payload.blob })
|
||||
})
|
||||
|
||||
test('validates IDs, verifiers, content type, JSON and opaque blob encoding', async t => {
|
||||
const api = await startApi()
|
||||
t.after(() => api.close())
|
||||
const fixture = backupFixture()
|
||||
const invalidPayloads = [
|
||||
{ ...fixture.payload, id: 'short' },
|
||||
{ ...fixture.payload, blob: 'not+base64url' },
|
||||
{ ...fixture.payload, deleteVerifier: 'short' },
|
||||
{ id: fixture.payload.id, blob: fixture.payload.blob },
|
||||
{ ...fixture.payload, extra: true }
|
||||
]
|
||||
|
||||
for (const payload of invalidPayloads) {
|
||||
const response = await request(api, '/v1/backups', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
assert.equal(response.status, 400)
|
||||
}
|
||||
|
||||
const malformed = await request(api, '/v1/backups', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: '{'
|
||||
})
|
||||
assert.equal(malformed.status, 400)
|
||||
|
||||
const wrongType = await request(api, '/v1/backups', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'text/plain' },
|
||||
body: JSON.stringify(fixture.payload)
|
||||
})
|
||||
assert.equal(wrongType.status, 415)
|
||||
})
|
||||
|
||||
test('accepts a 256 KiB decoded blob and rejects one byte more', async t => {
|
||||
const api = await startApi()
|
||||
t.after(() => api.close())
|
||||
const fixture = backupFixture()
|
||||
|
||||
const accepted = await request(api, '/v1/backups', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ ...fixture.payload, blob: randomBytes(262_144).toString('base64url') })
|
||||
})
|
||||
const oversizedFixture = backupFixture()
|
||||
const rejected = await request(api, '/v1/backups', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ ...oversizedFixture.payload, blob: randomBytes(262_145).toString('base64url') })
|
||||
})
|
||||
|
||||
assert.equal(accepted.status, 201)
|
||||
assert.equal(rejected.status, 413)
|
||||
})
|
||||
|
||||
test('responds before an oversized request body finishes streaming', async t => {
|
||||
const api = await startApi()
|
||||
t.after(() => api.close())
|
||||
const url = new URL('/v1/backups', api.baseUrl)
|
||||
const client = createHttpRequest(url, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' }
|
||||
})
|
||||
t.after(() => client.destroy())
|
||||
|
||||
const responsePromise = new Promise((resolve, reject) => {
|
||||
client.once('response', resolve)
|
||||
client.once('error', reject)
|
||||
})
|
||||
client.write('A'.repeat(393_217))
|
||||
const response = await Promise.race([
|
||||
responsePromise,
|
||||
new Promise((_, reject) => setTimeout(() => reject(new Error('Server did not reject streaming body')), 1_000))
|
||||
])
|
||||
|
||||
assert.equal(response.statusCode, 413)
|
||||
response.resume()
|
||||
})
|
||||
|
||||
test('deletes only with the matching client secret and uses constant not-found responses', async t => {
|
||||
const api = await startApi()
|
||||
t.after(() => api.close())
|
||||
const fixture = backupFixture()
|
||||
await request(api, '/v1/backups', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(fixture.payload)
|
||||
})
|
||||
|
||||
const missing = await request(api, '/v1/backups/restore', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ id: randomBytes(16).toString('base64url') })
|
||||
})
|
||||
const unauthorized = await request(api, '/v1/backups/delete', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ id: fixture.payload.id })
|
||||
})
|
||||
const wrongSecret = await request(api, '/v1/backups/delete', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ id: fixture.payload.id, deleteSecret: randomBytes(32).toString('base64url') })
|
||||
})
|
||||
|
||||
assert.equal(missing.status, 404)
|
||||
assert.equal(unauthorized.status, 404)
|
||||
assert.equal(wrongSecret.status, 404)
|
||||
const missingBody = await missing.text()
|
||||
const unauthorizedBody = await unauthorized.text()
|
||||
const wrongSecretBody = await wrongSecret.text()
|
||||
assert.equal(missingBody, unauthorizedBody)
|
||||
assert.equal(unauthorizedBody, wrongSecretBody)
|
||||
assert.equal(wrongSecretBody, '{"error":"not_found"}')
|
||||
|
||||
const deleted = await request(api, '/v1/backups/delete', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ id: fixture.payload.id, deleteSecret: fixture.deleteSecret })
|
||||
})
|
||||
assert.equal(deleted.status, 204)
|
||||
assert.equal((await readdir(api.rootDir)).length, 0)
|
||||
|
||||
const afterDelete = await request(api, '/v1/backups/restore', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ id: fixture.payload.id })
|
||||
})
|
||||
assert.equal(afterDelete.status, 404)
|
||||
})
|
||||
|
||||
test('allows only configured browser origins and supports preflight', async t => {
|
||||
const api = await startApi()
|
||||
t.after(() => api.close())
|
||||
|
||||
const allowed = await request(api, '/health', { headers: { origin: allowedOrigin } })
|
||||
assert.equal(allowed.headers.get('access-control-allow-origin'), allowedOrigin)
|
||||
assert.equal(allowed.headers.get('vary'), 'Origin')
|
||||
|
||||
const denied = await request(api, '/health', { headers: { origin: 'https://attacker.example' } })
|
||||
assert.equal(denied.status, 403)
|
||||
assert.equal(denied.headers.get('access-control-allow-origin'), null)
|
||||
|
||||
const preflight = await request(api, '/v1/backups', {
|
||||
method: 'OPTIONS',
|
||||
headers: {
|
||||
origin: allowedOrigin,
|
||||
'access-control-request-method': 'POST',
|
||||
'access-control-request-headers': 'content-type'
|
||||
}
|
||||
})
|
||||
assert.equal(preflight.status, 204)
|
||||
assert.equal(preflight.headers.get('access-control-allow-origin'), allowedOrigin)
|
||||
assert.match(preflight.headers.get('access-control-allow-methods'), /POST/)
|
||||
})
|
||||
|
||||
test('rate limits backup routes without limiting health checks', async t => {
|
||||
const api = await startApi({ trustedProxy: true, rateLimit: { max: 2, windowMs: 60_000 } })
|
||||
t.after(() => api.close())
|
||||
|
||||
const restore = (id, address) => request(api, '/v1/backups/restore', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', 'x-forwarded-for': address },
|
||||
body: JSON.stringify({ id })
|
||||
})
|
||||
const first = await restore(randomBytes(16).toString('base64url'), '198.51.100.1')
|
||||
const independent = await restore(randomBytes(16).toString('base64url'), '198.51.100.2')
|
||||
const second = await restore(randomBytes(16).toString('base64url'), '198.51.100.1')
|
||||
const limited = await restore(randomBytes(16).toString('base64url'), '198.51.100.1')
|
||||
const health = await request(api, '/health')
|
||||
|
||||
assert.equal(first.status, 404)
|
||||
assert.equal(independent.status, 404)
|
||||
assert.equal(second.status, 404)
|
||||
assert.equal(limited.status, 429)
|
||||
assert.equal(limited.headers.get('retry-after'), '60')
|
||||
assert.equal(health.status, 200)
|
||||
})
|
||||
|
||||
test('enforces an atomic global storage capacity without blocking existing restores', async t => {
|
||||
const api = await startApi({ maxStorageBytes: 420 })
|
||||
const secondServer = createBackupServer({ rootDir: api.rootDir, allowedOrigins: [allowedOrigin], maxStorageBytes: 420 })
|
||||
await new Promise((resolve, reject) => {
|
||||
secondServer.once('error', reject)
|
||||
secondServer.listen(0, '127.0.0.1', resolve)
|
||||
})
|
||||
const secondBaseUrl = `http://127.0.0.1:${secondServer.address().port}`
|
||||
t.after(() => api.close())
|
||||
t.after(() => new Promise(resolve => secondServer.close(resolve)))
|
||||
const first = backupFixture()
|
||||
const second = backupFixture()
|
||||
const create = (fixture, baseUrl = api.baseUrl) => fetch(`${baseUrl}/v1/backups`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(fixture.payload)
|
||||
})
|
||||
|
||||
const results = await Promise.all([create(first), create(second, secondBaseUrl)])
|
||||
|
||||
assert.deepEqual(results.map(response => response.status).sort(), [201, 507])
|
||||
const stored = results[0].status === 201 ? first : second
|
||||
const restored = await request(api, '/v1/backups/restore', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ id: stored.payload.id })
|
||||
})
|
||||
assert.equal(restored.status, 200)
|
||||
const storedFiles = await readdir(api.rootDir)
|
||||
assert.equal(storedFiles.length, 1)
|
||||
assert.match(storedFiles[0], /^[A-Za-z0-9_-]{22}\.json$/)
|
||||
|
||||
const deleted = await request(api, '/v1/backups/delete', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ id: stored.payload.id, deleteSecret: stored.deleteSecret })
|
||||
})
|
||||
assert.equal(deleted.status, 204)
|
||||
const blocked = stored === first ? second : first
|
||||
assert.equal((await create(blocked)).status, 201)
|
||||
})
|
||||
|
||||
test('limits anonymous uploads separately while keeping restores available', async t => {
|
||||
const api = await startApi({
|
||||
rateLimit: { max: 2, windowMs: 60_000 },
|
||||
uploadRateLimit: { max: 1, windowMs: 60_000 }
|
||||
})
|
||||
t.after(() => api.close())
|
||||
const first = backupFixture()
|
||||
const second = backupFixture()
|
||||
const create = fixture => request(api, '/v1/backups', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(fixture.payload)
|
||||
})
|
||||
|
||||
assert.equal((await create(first)).status, 201)
|
||||
assert.equal((await create(second)).status, 429)
|
||||
const restored = await request(api, '/v1/backups/restore', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ id: first.payload.id })
|
||||
})
|
||||
assert.equal(restored.status, 200)
|
||||
})
|
||||
|
||||
test('never takes over an old storage lock that may still have an active owner', async t => {
|
||||
const api = await startApi()
|
||||
t.after(() => api.close())
|
||||
const lockPath = join(api.rootDir, '.storage.lock')
|
||||
const release = await lockfile.lock(api.rootDir, {
|
||||
realpath: false,
|
||||
lockfilePath: lockPath,
|
||||
stale: 30_000,
|
||||
update: 10_000
|
||||
})
|
||||
t.after(() => release().catch(() => {}))
|
||||
const fixture = backupFixture()
|
||||
const pending = request(api, '/v1/backups', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(fixture.payload)
|
||||
})
|
||||
|
||||
const early = await Promise.race([
|
||||
pending.then(() => 'responded'),
|
||||
new Promise(resolve => setTimeout(() => resolve('waiting'), 100))
|
||||
])
|
||||
|
||||
assert.equal(early, 'waiting')
|
||||
await release()
|
||||
assert.equal((await pending).status, 201)
|
||||
})
|
||||
|
||||
test('recovers a storage lock left by a terminated process', async t => {
|
||||
const api = await startApi()
|
||||
t.after(() => api.close())
|
||||
const lockPath = join(api.rootDir, '.storage.lock')
|
||||
await mkdir(lockPath)
|
||||
const old = new Date(Date.now() - 60_000)
|
||||
await utimes(lockPath, old, old)
|
||||
const fixture = backupFixture()
|
||||
const pending = request(api, '/v1/backups', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(fixture.payload)
|
||||
})
|
||||
|
||||
const early = await Promise.race([
|
||||
pending.then(response => response.status),
|
||||
new Promise(resolve => setTimeout(() => resolve('timeout'), 500))
|
||||
])
|
||||
await pending
|
||||
|
||||
assert.equal(early, 201)
|
||||
assert.equal((await readdir(api.rootDir)).some(name => name.includes('.stale.')), false)
|
||||
})
|
||||
|
||||
test('revalidates delete authorization inside the storage lock', async t => {
|
||||
const api = await startApi()
|
||||
t.after(() => api.close())
|
||||
const original = backupFixture()
|
||||
const replacement = backupFixture()
|
||||
replacement.payload.id = original.payload.id
|
||||
await request(api, '/v1/backups', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(original.payload)
|
||||
})
|
||||
const lockPath = join(api.rootDir, '.storage.lock')
|
||||
const release = await lockfile.lock(api.rootDir, {
|
||||
realpath: false,
|
||||
lockfilePath: lockPath,
|
||||
stale: 30_000,
|
||||
update: 10_000
|
||||
})
|
||||
t.after(() => release().catch(() => {}))
|
||||
const pendingDelete = request(api, '/v1/backups/delete', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ id: original.payload.id, deleteSecret: original.deleteSecret })
|
||||
})
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
await writeFile(join(api.rootDir, `${original.payload.id}.json`), JSON.stringify({
|
||||
version: 1,
|
||||
blob: replacement.payload.blob,
|
||||
deleteVerifier: replacement.payload.deleteVerifier,
|
||||
createdAt: new Date().toISOString()
|
||||
}))
|
||||
await release()
|
||||
|
||||
assert.equal((await pendingDelete).status, 404)
|
||||
const restored = await request(api, '/v1/backups/restore', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ id: original.payload.id })
|
||||
})
|
||||
assert.equal(restored.status, 200)
|
||||
assert.deepEqual(await restored.json(), { blob: replacement.payload.blob })
|
||||
})
|
||||
|
||||
test('never accepts backup IDs in URLs', async t => {
|
||||
const api = await startApi()
|
||||
t.after(() => api.close())
|
||||
const id = randomBytes(16).toString('base64url')
|
||||
|
||||
const read = await request(api, `/v1/backups/${id}`)
|
||||
const remove = await request(api, `/v1/backups/${id}`, { method: 'DELETE' })
|
||||
const query = await request(api, `/v1/backups?backup=${id}`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(backupFixture().payload)
|
||||
})
|
||||
|
||||
assert.equal(read.status, 404)
|
||||
assert.equal(remove.status, 404)
|
||||
assert.equal(query.status, 404)
|
||||
})
|
||||
|
||||
test('stored records contain no delete secret and operational logs contain no IDs or blobs', async t => {
|
||||
const messages = []
|
||||
const api = await startApi({ logger: message => messages.push(String(message)) })
|
||||
t.after(() => api.close())
|
||||
const fixture = backupFixture()
|
||||
|
||||
await request(api, '/v1/backups', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(fixture.payload)
|
||||
})
|
||||
|
||||
const files = await readdir(api.rootDir)
|
||||
assert.equal(files.length, 1)
|
||||
const stored = await readFile(join(api.rootDir, files[0]), 'utf8')
|
||||
assert.equal(stored.includes(fixture.deleteSecret), false)
|
||||
assert.equal(messages.some(message => message.includes(fixture.payload.id)), false)
|
||||
assert.equal(messages.some(message => message.includes(fixture.payload.blob)), false)
|
||||
})
|
||||
@ -25,7 +25,7 @@ import {
|
||||
} from "../shared/types";
|
||||
import { resetDebridLinkApiKeyDailyUsage, resetProviderDailyUsage } from "../shared/provider-daily-limits";
|
||||
import { importDlcContainers } from "./container";
|
||||
import { APP_VERSION, ONLINE_BACKUP_API_URL } from "./constants";
|
||||
import { APP_VERSION } from "./constants";
|
||||
import { DownloadManager } from "./download-manager";
|
||||
import { fetchAllDebridHostInfo, fetchDebridLinkHostLimits } from "./debrid";
|
||||
import { checkAllDebridAccounts, checkMegaDebridAccount } from "./account-check";
|
||||
@ -45,7 +45,7 @@ import { runInstallWithResume } from "./update-install-flow";
|
||||
import { rotateDebugToken, startDebugServer, stopDebugServer, restartDebugServer, getDebugServerRuntimeStatus, getActiveDebugToken, getDebugAllowlist, writeDebugServerConfig, clearDebugToken } from "./debug-server";
|
||||
import { encodeConnectionCode, loadRemoteMeta, saveRemoteMeta } from "./connection-code";
|
||||
import { encryptBackup, decryptBackup } from "./backup-crypto";
|
||||
import { buildBackupPayload, planBackupImport, resolveRemoteDiagnosticsRestore, BackupRemoteDiagnostics } from "./backup-payload";
|
||||
import { buildBackupPayload, planBackupImport, resolveMcpRemoteRestore, BackupMcpRemote } from "./backup-payload";
|
||||
import { getAuditLogPath, initAuditLog, logAuditEvent, shutdownAuditLog } from "./audit-log";
|
||||
import { initAccountRotationLog, shutdownAccountRotationLog } from "./account-rotation-log";
|
||||
import { initConversionLog, shutdownConversionLog } from "./conversion-trace";
|
||||
@ -58,7 +58,6 @@ import { buildAccountSummary, diffAccountSummary } from "./support-data";
|
||||
import { buildSupportBundle, getSupportBundleDefaultFileName } from "./support-bundle";
|
||||
import { getTraceConfig, getTraceLogPath, initTraceLog, logTraceEvent, setTraceEnabled, shutdownTraceLog } from "./trace-log";
|
||||
import type { DebugSetupCheckResult, SupportTraceConfig } from "../shared/types";
|
||||
import { createOnlineBackup, downloadOnlineBackup, uploadOnlineBackup } from "./online-backup";
|
||||
|
||||
function sanitizeSettingsPatch(partial: Partial<AppSettings>): Partial<AppSettings> {
|
||||
const entries = Object.entries(partial || {}).filter(([, value]) => value !== undefined);
|
||||
@ -380,8 +379,8 @@ export class AppController {
|
||||
return this.getRemoteDiagnostics();
|
||||
}
|
||||
|
||||
private restoreRemoteDiagnosticsFromBackup(section: unknown, restartNow: boolean): void {
|
||||
const restore = resolveRemoteDiagnosticsRestore(section);
|
||||
private restoreMcpRemoteFromBackup(section: unknown, restartNow: boolean): void {
|
||||
const restore = resolveMcpRemoteRestore(section);
|
||||
if (!restore) {
|
||||
return;
|
||||
}
|
||||
@ -433,17 +432,6 @@ export class AppController {
|
||||
target.debridAccountStatuses = { ...(liveSettings.debridAccountStatuses || {}) };
|
||||
}
|
||||
|
||||
private applySettingsOnlyBackup(importedSettings: AppSettings, remoteDiagnostics?: unknown, restoreRemoteDiagnostics = false): void {
|
||||
const restoredSettings = normalizeSettings(importedSettings);
|
||||
this.overlayLiveUsageCounters(restoredSettings);
|
||||
this.settings = restoredSettings;
|
||||
saveSettings(this.storagePaths, this.settings);
|
||||
this.manager.setSettings(this.settings, { settingsOnlyImport: true });
|
||||
if (restoreRemoteDiagnostics) {
|
||||
this.restoreRemoteDiagnosticsFromBackup(remoteDiagnostics, true);
|
||||
}
|
||||
}
|
||||
|
||||
public updateSettings(partial: Partial<AppSettings>): AppSettings {
|
||||
const sanitizedPatch = sanitizeSettingsPatch(partial);
|
||||
const previousSettings = this.settings;
|
||||
@ -760,10 +748,10 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
|
||||
}
|
||||
|
||||
public exportBackup(): Buffer {
|
||||
let remoteDiagnostics: BackupRemoteDiagnostics | undefined;
|
||||
if (Boolean(this.settings.backupIncludeRemoteDiagnostics)) {
|
||||
let mcpRemote: BackupMcpRemote | undefined;
|
||||
if (Boolean(this.settings.backupIncludeMcp)) {
|
||||
const status = getDebugServerRuntimeStatus();
|
||||
remoteDiagnostics = {
|
||||
mcpRemote = {
|
||||
allowlist: getDebugAllowlist(),
|
||||
port: status.port,
|
||||
hostMode: status.host === "0.0.0.0" ? "network" : "local"
|
||||
@ -775,7 +763,7 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
|
||||
exportedAt: new Date().toISOString(),
|
||||
session: this.manager.getSession(),
|
||||
history: loadHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode, this.historyLimits()),
|
||||
remoteDiagnostics
|
||||
mcpRemote
|
||||
});
|
||||
this.audit("INFO", "Backup exportiert", {
|
||||
kind: payloadObj.kind,
|
||||
@ -786,23 +774,6 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
|
||||
return encryptBackup(JSON.stringify(payloadObj));
|
||||
}
|
||||
|
||||
public async exportOnlineBackup(): Promise<{ key: string }> {
|
||||
const created = createOnlineBackup({ ...this.settings }, APP_VERSION);
|
||||
await uploadOnlineBackup(created.record, ONLINE_BACKUP_API_URL);
|
||||
this.audit("INFO", "Online-Sicherung erstellt", { kind: "settings-only" });
|
||||
return { key: created.key };
|
||||
}
|
||||
|
||||
public async importOnlineBackup(key: string): Promise<{ restored: boolean; relaunch: false; message: string }> {
|
||||
const payload = await downloadOnlineBackup(key, ONLINE_BACKUP_API_URL);
|
||||
this.applySettingsOnlyBackup(payload.settings);
|
||||
this.audit("INFO", "Online-Sicherung importiert", {
|
||||
kind: "settings-only",
|
||||
accountSummary: buildAccountSummary(this.settings)
|
||||
});
|
||||
return { restored: true, relaunch: false, message: "Einstellungen aus Online-Sicherung wiederhergestellt" };
|
||||
}
|
||||
|
||||
public async exportSupportBundle(): Promise<{ buffer: Buffer; defaultFileName: string }> {
|
||||
this.audit("INFO", "Support-Bundle exportiert");
|
||||
logTraceEvent("INFO", "support", "Support-Bundle erstellt", {
|
||||
@ -862,7 +833,11 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
|
||||
// policy still governs FUTURE completions through the normal path. Do NOT stop the
|
||||
// manager, wipe the session, block persistence or relaunch.
|
||||
if (!hasSession) {
|
||||
this.applySettingsOnlyBackup(restoredSettings, parsed.remoteDiagnostics, true);
|
||||
this.overlayLiveUsageCounters(restoredSettings);
|
||||
this.settings = restoredSettings;
|
||||
saveSettings(this.storagePaths, this.settings);
|
||||
this.manager.setSettings(this.settings, { suppressRetroactiveCleanup: true });
|
||||
this.restoreMcpRemoteFromBackup(parsed.mcpRemote, true);
|
||||
this.audit("INFO", "Backup importiert (nur Einstellungen)", {
|
||||
accountSummary: buildAccountSummary(this.settings)
|
||||
});
|
||||
@ -899,7 +874,7 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
|
||||
|
||||
resetHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode);
|
||||
|
||||
this.restoreRemoteDiagnosticsFromBackup(parsed.remoteDiagnostics, false);
|
||||
this.restoreMcpRemoteFromBackup(parsed.mcpRemote, false);
|
||||
|
||||
this.manager.skipShutdownPersist = true;
|
||||
this.manager.blockAllPersistence = true;
|
||||
|
||||
@ -2,7 +2,7 @@ import type { AppSettings, SessionState, HistoryEntry } from "../shared/types";
|
||||
|
||||
export type BackupKind = "full" | "settings-only";
|
||||
|
||||
export interface BackupRemoteDiagnostics {
|
||||
export interface BackupMcpRemote {
|
||||
allowlist: string[];
|
||||
port: number;
|
||||
hostMode: "local" | "network";
|
||||
@ -16,7 +16,7 @@ export interface BackupPayload {
|
||||
settings: AppSettings;
|
||||
session?: SessionState;
|
||||
history?: HistoryEntry[];
|
||||
remoteDiagnostics?: BackupRemoteDiagnostics;
|
||||
mcpRemote?: BackupMcpRemote;
|
||||
}
|
||||
|
||||
export interface BuildBackupInput {
|
||||
@ -26,7 +26,7 @@ export interface BuildBackupInput {
|
||||
/** Only bundled when includeDownloads is true. */
|
||||
session: SessionState;
|
||||
history: HistoryEntry[];
|
||||
remoteDiagnostics?: BackupRemoteDiagnostics;
|
||||
mcpRemote?: BackupMcpRemote;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -48,19 +48,19 @@ export function buildBackupPayload(input: BuildBackupInput): BackupPayload {
|
||||
base.session = input.session;
|
||||
base.history = input.history;
|
||||
}
|
||||
if (Boolean(input.settings.backupIncludeRemoteDiagnostics) && input.remoteDiagnostics) {
|
||||
base.remoteDiagnostics = input.remoteDiagnostics;
|
||||
if (Boolean(input.settings.backupIncludeMcp) && input.mcpRemote) {
|
||||
base.mcpRemote = input.mcpRemote;
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
export interface RemoteDiagnosticsRestore {
|
||||
export interface McpRemoteRestore {
|
||||
host?: "127.0.0.1" | "0.0.0.0";
|
||||
port?: number;
|
||||
allowlist?: string[];
|
||||
}
|
||||
|
||||
export function resolveRemoteDiagnosticsRestore(section: unknown): RemoteDiagnosticsRestore | null {
|
||||
export function resolveMcpRemoteRestore(section: unknown): McpRemoteRestore | null {
|
||||
if (!section || typeof section !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -37,8 +37,7 @@ export const MAX_LINK_ARTIFACT_BYTES = 256 * 1024;
|
||||
export const SPEED_WINDOW_SECONDS = 1;
|
||||
export const CLIPBOARD_POLL_INTERVAL_MS = 2000;
|
||||
|
||||
export const DEFAULT_UPDATE_REPO = "Sucukdeluxe/multi-debrid-downloader";
|
||||
export const ONLINE_BACKUP_API_URL = "https://downloader.24-music.de/backup-api";
|
||||
export const DEFAULT_UPDATE_REPO = "Administrator/real-debrid-downloader";
|
||||
|
||||
export function defaultSettings(): AppSettings {
|
||||
const baseDir = path.join(os.homedir(), "Downloads", "RealDebrid");
|
||||
@ -110,7 +109,7 @@ export function defaultSettings(): AppSettings {
|
||||
hideExtractedItems: true,
|
||||
confirmDeleteSelection: true,
|
||||
backupIncludeDownloads: false,
|
||||
backupIncludeRemoteDiagnostics: false,
|
||||
backupIncludeMcp: false,
|
||||
notifyUrl: "",
|
||||
notifyMention: "",
|
||||
notifyOnPackageCompleted: false,
|
||||
|
||||
@ -24,7 +24,7 @@ import type { DownloadItem, PackageEntry, UiSnapshot } from "../shared/types";
|
||||
const DEFAULT_PORT = 9868;
|
||||
const DEFAULT_HOST = "127.0.0.1";
|
||||
const MAX_LOG_LINES = 10000;
|
||||
const SUPPORT_MANIFEST_FILE = "debug_support_manifest.json";
|
||||
const AI_MANIFEST_FILE = "debug_ai_manifest.json";
|
||||
|
||||
type DebugEndpointDescriptor = {
|
||||
method: "GET";
|
||||
@ -103,11 +103,12 @@ function extractDebugClientIp(req: http.IncomingMessage): string {
|
||||
if (realIp) {
|
||||
return realIp;
|
||||
}
|
||||
return getPeerIp(req);
|
||||
const remote = String(req.socket.remoteAddress || req.socket.address()?.address || "").trim();
|
||||
return remote.replace(/^::ffff:/i, "");
|
||||
}
|
||||
|
||||
function getSupportManifestPath(baseDir: string = runtimeBaseDir): string {
|
||||
return path.join(baseDir, SUPPORT_MANIFEST_FILE);
|
||||
function getAiManifestPath(baseDir: string = runtimeBaseDir): string {
|
||||
return path.join(baseDir, AI_MANIFEST_FILE);
|
||||
}
|
||||
|
||||
function getDebugTokenPath(baseDir: string = runtimeBaseDir): string {
|
||||
@ -342,13 +343,26 @@ function getEndpointSummaries(): string[] {
|
||||
return DEBUG_ENDPOINTS.map((endpoint) => formatEndpointSummary(endpoint));
|
||||
}
|
||||
|
||||
function buildSupportManifest(baseDir: string): Record<string, unknown> {
|
||||
function buildAiManifest(baseDir: string): Record<string, unknown> {
|
||||
const remoteHostHint = bindHost === "0.0.0.0"
|
||||
? "Use the server IP or DNS name for remote access. Ask the user only for that host value if it is unknown."
|
||||
: "If remote access is required and the bind host is local-only, switch debug_host.txt to 0.0.0.0 and reopen the firewall.";
|
||||
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
generatedAt: new Date().toISOString(),
|
||||
appVersion: APP_VERSION,
|
||||
runtimeBaseDir: baseDir,
|
||||
purpose: "Machine-readable manifest for support tooling and remote troubleshooting.",
|
||||
purpose: "Machine-readable support manifest for AI tools and remote troubleshooting.",
|
||||
quickstart: [
|
||||
"Read debug_token.txt and debug_port.txt from this runtime folder.",
|
||||
"If remote access is needed, ask the user only for the server IP or DNS name.",
|
||||
"Call /meta first to confirm the server is reachable and to re-read the endpoint list.",
|
||||
"Use /self-check or /debug/setup to quickly verify whether token, host, manifest, trace, disk space, and log sizes are in a good support state.",
|
||||
"Use /diagnostics for an overview, then drill into /logs/item, /logs/package, /logs/rename, /status, /packages, /items, /settings, /accounts, /stats, /history, or /logs/trace.",
|
||||
"For provider stalls/cooldowns, call /providers for the live cooldown state (until/remaining/reason per account/key) and /logs/conversion for the per-item resolve lifecycle (token, API, web, rotation, aborts with timings).",
|
||||
"If a full handoff is needed, download /support/bundle as a ZIP."
|
||||
],
|
||||
auth: {
|
||||
required: true,
|
||||
methods: [
|
||||
@ -378,15 +392,13 @@ function buildSupportManifest(baseDir: string): Record<string, unknown> {
|
||||
host: bindHost,
|
||||
port: bindPort,
|
||||
localBaseUrl: `http://127.0.0.1:${bindPort}`,
|
||||
remoteBaseUrlTemplate: `http://<SERVER_IP_OR_DNS>:${bindPort}`
|
||||
remoteBaseUrlTemplate: `http://<SERVER_IP_OR_DNS>:${bindPort}`,
|
||||
remoteHostHint
|
||||
},
|
||||
setupCheckEndpoint: "/debug/setup",
|
||||
selfCheckEndpoint: "/self-check",
|
||||
remoteAccessRequirements: [
|
||||
"A reachable server IP or DNS name.",
|
||||
"The configured diagnostics port.",
|
||||
"The token stored in debug_token.txt.",
|
||||
"A network route and firewall rule that permit access to the configured port."
|
||||
askUserFor: [
|
||||
"Server IP or DNS name, if remote access is required and not already known."
|
||||
],
|
||||
endpoints: DEBUG_ENDPOINTS.map((endpoint) => ({
|
||||
...endpoint,
|
||||
@ -395,11 +407,11 @@ function buildSupportManifest(baseDir: string): Record<string, unknown> {
|
||||
};
|
||||
}
|
||||
|
||||
function writeSupportManifest(baseDir: string): void {
|
||||
function writeAiManifest(baseDir: string): void {
|
||||
try {
|
||||
fs.writeFileSync(getSupportManifestPath(baseDir), JSON.stringify(buildSupportManifest(baseDir), null, 2), "utf8");
|
||||
fs.writeFileSync(getAiManifestPath(baseDir), JSON.stringify(buildAiManifest(baseDir), null, 2), "utf8");
|
||||
} catch (error) {
|
||||
logger.warn(`Debug-Server: Support-Manifest konnte nicht geschrieben werden: ${String(error)}`);
|
||||
logger.warn(`Debug-Server: KI-Support-Datei konnte nicht geschrieben werden: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
@ -409,7 +421,7 @@ export function rotateDebugToken(baseDir: string = runtimeBaseDir): { path: stri
|
||||
fs.writeFileSync(tokenPath, `${token}\n`, "utf8");
|
||||
if (baseDir === runtimeBaseDir) {
|
||||
authToken = token;
|
||||
writeSupportManifest(baseDir);
|
||||
writeAiManifest(baseDir);
|
||||
}
|
||||
logger.info(`Debug-Server Token rotiert: ${tokenPath}`);
|
||||
logTraceEvent("INFO", "support", "Debug-Token rotiert", { tokenPath });
|
||||
@ -596,7 +608,7 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi
|
||||
port: bindPort
|
||||
},
|
||||
supportFiles: {
|
||||
supportManifest: getSupportManifestPath(),
|
||||
aiManifest: getAiManifestPath(),
|
||||
traceConfig: getTraceConfigPath(),
|
||||
traceLog: getTraceLogPath()
|
||||
},
|
||||
@ -1061,7 +1073,7 @@ function openServerSocket(): Promise<void> {
|
||||
bindPort = getPort(runtimeBaseDir);
|
||||
bindHost = getHost(runtimeBaseDir);
|
||||
allowlist = loadAllowlist(runtimeBaseDir);
|
||||
writeSupportManifest(runtimeBaseDir);
|
||||
writeAiManifest(runtimeBaseDir);
|
||||
if (!authToken) {
|
||||
logger.info("Debug-Server: Kein Token in debug_token.txt, Server wird nicht gestartet");
|
||||
resolve();
|
||||
@ -1165,7 +1177,7 @@ export function clearDebugToken(): void {
|
||||
} catch {
|
||||
}
|
||||
authToken = "";
|
||||
writeSupportManifest(runtimeBaseDir);
|
||||
writeAiManifest(runtimeBaseDir);
|
||||
}
|
||||
|
||||
export function stopDebugServer(): void {
|
||||
|
||||
@ -14,7 +14,7 @@ import type {
|
||||
|
||||
const DEFAULT_PORT = 9868;
|
||||
const DEFAULT_HOST = "127.0.0.1";
|
||||
const SUPPORT_MANIFEST_FILE = "debug_support_manifest.json";
|
||||
const AI_MANIFEST_FILE = "debug_ai_manifest.json";
|
||||
const LOW_FREE_BYTES_THRESHOLD = Number(process.env.RD_SELF_CHECK_LOW_FREE_BYTES || 20 * 1024 * 1024 * 1024);
|
||||
const LOW_FREE_PERCENT_THRESHOLD = Number(process.env.RD_SELF_CHECK_LOW_FREE_PERCENT || 5);
|
||||
const LOW_FREE_PERCENT_BYTES_GUARD = Number(process.env.RD_SELF_CHECK_LOW_FREE_PERCENT_BYTES_GUARD || 50 * 1024 * 1024 * 1024);
|
||||
@ -259,7 +259,7 @@ function getSupportBundleEstimate(
|
||||
): SupportBundleEstimate {
|
||||
const storagePaths = createStoragePaths(baseDir);
|
||||
const staticFiles = [
|
||||
path.join(baseDir, SUPPORT_MANIFEST_FILE),
|
||||
path.join(baseDir, AI_MANIFEST_FILE),
|
||||
path.join(baseDir, "debug_host.txt"),
|
||||
path.join(baseDir, "debug_port.txt"),
|
||||
storagePaths.configFile,
|
||||
@ -302,7 +302,7 @@ export function getDebugSetupCheck(baseDir: string): DebugSetupCheckResult {
|
||||
const storagePaths = createStoragePaths(baseDir);
|
||||
const settings = loadSettings(storagePaths);
|
||||
const tokenPath = path.join(baseDir, "debug_token.txt");
|
||||
const supportManifestPath = path.join(baseDir, SUPPORT_MANIFEST_FILE);
|
||||
const aiManifestPath = path.join(baseDir, AI_MANIFEST_FILE);
|
||||
const traceConfigPath = path.join(baseDir, "trace_config.json");
|
||||
const traceLogPath = path.join(baseDir, "trace.log");
|
||||
const traceConfig = readTraceConfig(baseDir);
|
||||
@ -356,8 +356,8 @@ export function getDebugSetupCheck(baseDir: string): DebugSetupCheckResult {
|
||||
} else {
|
||||
notes.push("Der Debug-Server ist für Remote-Zugriff konfiguriert. Firewall oder Provider-Regeln müssen separat offen sein.");
|
||||
}
|
||||
if (!fs.existsSync(supportManifestPath)) {
|
||||
warnings.push("debug_support_manifest.json fehlt. App einmal neu starten, damit das Support-Manifest neu geschrieben wird.");
|
||||
if (!fs.existsSync(aiManifestPath)) {
|
||||
warnings.push("debug_ai_manifest.json fehlt. App einmal neu starten, damit die KI-Support-Datei neu geschrieben wird.");
|
||||
}
|
||||
if (!fs.existsSync(traceConfigPath)) {
|
||||
warnings.push("trace_config.json fehlt. Trace-Funktionen sind lokal noch nicht initialisiert.");
|
||||
@ -410,8 +410,8 @@ export function getDebugSetupCheck(baseDir: string): DebugSetupCheckResult {
|
||||
localOnly,
|
||||
tokenConfigured: Boolean(token),
|
||||
tokenPath,
|
||||
supportManifestPath,
|
||||
supportManifestPresent: fs.existsSync(supportManifestPath),
|
||||
aiManifestPath,
|
||||
aiManifestPresent: fs.existsSync(aiManifestPath),
|
||||
traceConfigPath: fs.existsSync(traceConfigPath) ? traceConfigPath : null,
|
||||
traceLogPath: fs.existsSync(traceLogPath) ? traceLogPath : null,
|
||||
traceEnabled: traceConfig.enabled,
|
||||
|
||||
@ -2156,7 +2156,7 @@ export class DownloadManager extends EventEmitter {
|
||||
this.emitState();
|
||||
}
|
||||
|
||||
public setSettings(next: AppSettings, opts?: { suppressRetroactiveCleanup?: boolean; settingsOnlyImport?: boolean }): void {
|
||||
public setSettings(next: AppSettings, opts?: { suppressRetroactiveCleanup?: boolean }): void {
|
||||
const previous = this.settings;
|
||||
next.totalDownloadedAllTime = Math.max(next.totalDownloadedAllTime || 0, this.settings.totalDownloadedAllTime || 0);
|
||||
next.totalCompletedFilesAllTime = Math.max(next.totalCompletedFilesAllTime || 0, this.settings.totalCompletedFilesAllTime || 0);
|
||||
@ -2174,7 +2174,7 @@ export class DownloadManager extends EventEmitter {
|
||||
const nextOrder = JSON.stringify(next.providerOrder ?? []);
|
||||
const prevRouting = JSON.stringify(previous.hosterRouting ?? {});
|
||||
const nextRouting = JSON.stringify(next.hosterRouting ?? {});
|
||||
if (!opts?.settingsOnlyImport && (prevOrder !== nextOrder || prevRouting !== nextRouting)) {
|
||||
if (prevOrder !== nextOrder || prevRouting !== nextRouting) {
|
||||
const activeItemIds = new Set([...this.activeTasks.values()].map((t) => t.itemId));
|
||||
for (const item of Object.values(this.session.items)) {
|
||||
if (!activeItemIds.has(item.id) && item.status !== "completed" && item.status !== "failed") {
|
||||
@ -2185,7 +2185,7 @@ export class DownloadManager extends EventEmitter {
|
||||
|
||||
const previousArchivePasswords = String(previous.archivePasswordList || "").replace(/\r\n|\r/g, "\n");
|
||||
const nextArchivePasswords = String(next.archivePasswordList || "").replace(/\r\n|\r/g, "\n");
|
||||
if (!opts?.settingsOnlyImport && previousArchivePasswords !== nextArchivePasswords) {
|
||||
if (previousArchivePasswords !== nextArchivePasswords) {
|
||||
this.hybridExtractedPaths.clear();
|
||||
this.hybridFailedArchives.clear();
|
||||
const pwCount = nextArchivePasswords.split("\n").filter(Boolean).length;
|
||||
@ -2196,7 +2196,7 @@ export class DownloadManager extends EventEmitter {
|
||||
const credChanges: Array<{ prev: string; next: string; providers: string[] }> = [
|
||||
{ prev: previous.token || "", next: next.token || "", providers: ["realdebrid"] },
|
||||
{ prev: previous.allDebridToken || "", next: next.allDebridToken || "", providers: ["alldebrid"] },
|
||||
{ prev: previous.bestToken || "", next: next.bestToken || "", providers: ["bestdebrid"] },
|
||||
{ prev: previous.bestDebridApiKey || "", next: next.bestDebridApiKey || "", providers: ["bestdebrid"] },
|
||||
{ prev: previous.debridLinkApiKeys || "", next: next.debridLinkApiKeys || "", providers: ["debridlink"] },
|
||||
{ prev: previous.linkSnappyLogin + "|" + previous.linkSnappyPassword, next: next.linkSnappyLogin + "|" + next.linkSnappyPassword, providers: ["linksnappy"] },
|
||||
{ prev: previous.ddownloadLogin + "|" + previous.ddownloadPassword, next: next.ddownloadLogin + "|" + next.ddownloadPassword, providers: ["ddownload"] },
|
||||
@ -2218,12 +2218,10 @@ export class DownloadManager extends EventEmitter {
|
||||
logger.info(`Settings-Update: ${clearedProviderFailures} Provider-Failure(s) gecleart wegen geaenderter Credentials`);
|
||||
}
|
||||
|
||||
if (!opts?.settingsOnlyImport) {
|
||||
this.resolveExistingQueuedOpaqueFilenames();
|
||||
void this.cleanupExistingExtractedArchives().catch((err) => logger.warn(`cleanupExistingExtractedArchives Fehler (setSettings): ${compactErrorText(err)}`));
|
||||
if (!opts?.suppressRetroactiveCleanup && next.completedCleanupPolicy !== "never") {
|
||||
this.applyRetroactiveCleanupPolicy();
|
||||
}
|
||||
this.resolveExistingQueuedOpaqueFilenames();
|
||||
void this.cleanupExistingExtractedArchives().catch((err) => logger.warn(`cleanupExistingExtractedArchives Fehler (setSettings): ${compactErrorText(err)}`));
|
||||
if (!opts?.suppressRetroactiveCleanup && next.completedCleanupPolicy !== "never") {
|
||||
this.applyRetroactiveCleanupPolicy();
|
||||
}
|
||||
this.emitState();
|
||||
}
|
||||
@ -11763,12 +11761,11 @@ export class DownloadManager extends EventEmitter {
|
||||
}
|
||||
try {
|
||||
const stat = await fs.promises.stat(item.targetPath);
|
||||
const totalBytes = item.totalBytes;
|
||||
const minSize = expectedMinBytes(totalBytes, isLargeBinaryLikePath(item.fileName || item.targetPath));
|
||||
const minSize = expectedMinBytes(item.totalBytes, isLargeBinaryLikePath(item.fileName || item.targetPath));
|
||||
const persistedBytes = Math.max(0, Math.floor(Number(item.downloadedBytes) || 0));
|
||||
const preallocMismatchThreshold = resolvePreallocResumeMismatchThreshold(item.fileName || item.targetPath || "");
|
||||
const suspiciousPreallocFootprint = totalBytes != null
|
||||
&& totalBytes > 0
|
||||
const suspiciousPreallocFootprint = item.totalBytes != null
|
||||
&& item.totalBytes > 0
|
||||
&& stat.size >= minSize
|
||||
&& stat.size > persistedBytes + preallocMismatchThreshold;
|
||||
if (stat.size >= minSize) {
|
||||
@ -11780,7 +11777,7 @@ export class DownloadManager extends EventEmitter {
|
||||
if (suspiciousPreallocFootprint) {
|
||||
logger.warn(
|
||||
`Item-Recovery: ${item.fileName} uebersprungen – pre-alloc-Verdacht ` +
|
||||
`(stat=${humanSize(stat.size)}, bytes=${humanSize(persistedBytes)}, total=${humanSize(totalBytes)})`
|
||||
`(stat=${humanSize(stat.size)}, bytes=${humanSize(persistedBytes)}, total=${humanSize(item.totalBytes)})`
|
||||
);
|
||||
try {
|
||||
if (persistedBytes > 0) {
|
||||
@ -11793,8 +11790,8 @@ export class DownloadManager extends EventEmitter {
|
||||
item.status = "queued";
|
||||
item.attempts = 0;
|
||||
item.downloadedBytes = persistedBytes;
|
||||
item.progressPercent = totalBytes > 0
|
||||
? Math.max(0, Math.min(99, Math.floor((persistedBytes / totalBytes) * 100)))
|
||||
item.progressPercent = item.totalBytes > 0
|
||||
? Math.max(0, Math.min(99, Math.floor((persistedBytes / item.totalBytes) * 100)))
|
||||
: 0;
|
||||
item.speedBps = 0;
|
||||
item.fullStatus = "Wartet (Auto-Recovery: pre-alloc)";
|
||||
|
||||
@ -108,13 +108,6 @@ export type ExtractErrorCategory =
|
||||
| "no_extractor"
|
||||
| "unknown";
|
||||
|
||||
export class ExtractionError extends Error {
|
||||
constructor(message: string, public readonly category: ExtractErrorCategory) {
|
||||
super(message);
|
||||
this.name = "ExtractionError";
|
||||
}
|
||||
}
|
||||
|
||||
type ExtractionErrorWithHints = Error & {
|
||||
suggestRedownload?: boolean;
|
||||
jvmFailureReason?: string;
|
||||
@ -818,8 +811,7 @@ function withExtractionErrorHints(
|
||||
return enhanced;
|
||||
}
|
||||
|
||||
export function classifyExtractionError(errorText: unknown): ExtractErrorCategory {
|
||||
if (errorText instanceof ExtractionError) return errorText.category;
|
||||
export function classifyExtractionError(errorText: string): ExtractErrorCategory {
|
||||
const text = String(errorText || "").toLowerCase();
|
||||
if (text.includes("aborted:extract") || text.includes("extract_aborted")) return "aborted";
|
||||
if (text.includes("timeout")) return "timeout";
|
||||
@ -827,7 +819,7 @@ export function classifyExtractionError(errorText: unknown): ExtractErrorCategor
|
||||
if (text.includes("wrong password") || text.includes("falsches passwort") || text.includes("incorrect password")) return "wrong_password";
|
||||
if (text.includes("missing volume") || text.includes("next volume") || text.includes("unexpected end of archive") || text.includes("missing parts")) return "missing_parts";
|
||||
if (text.includes("nicht gefunden") || text.includes("not found") || text.includes("no extractor")) return "no_extractor";
|
||||
if (isUnsupportedArchiveFormatError(text)) return "unsupported_format";
|
||||
if (text.includes("kein rar-archiv") || text.includes("not a rar archive") || text.includes("unsupported") || text.includes("unsupportedmethod")) return "unsupported_format";
|
||||
if (text.includes("disk full") || text.includes("speicherplatz") || text.includes("no space left") || text.includes("not enough space")) return "disk_full";
|
||||
return "unknown";
|
||||
}
|
||||
@ -893,9 +885,7 @@ function isUnsupportedArchiveFormatError(errorText: string): boolean {
|
||||
const text = String(errorText || "").toLowerCase();
|
||||
return text.includes("kein rar-archiv")
|
||||
|| text.includes("not a rar archive")
|
||||
|| text.includes("is not a rar archive")
|
||||
|| text.includes("is not archive")
|
||||
|| text.includes("unsupported");
|
||||
|| text.includes("is not a rar archive");
|
||||
}
|
||||
|
||||
function isUnsupportedExtractorSwitchError(errorText: string): boolean {
|
||||
@ -2566,14 +2556,6 @@ function shouldFallbackToExternalZip(error: unknown): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
export function selectZipFallbackError(internalError: unknown, externalError: unknown): unknown {
|
||||
const category = classifyExtractionError(externalError);
|
||||
if (category === "no_extractor" || category === "unsupported_format") {
|
||||
return internalError;
|
||||
}
|
||||
return externalError;
|
||||
}
|
||||
|
||||
async function extractZipArchive(archivePath: string, targetDir: string, conflictMode: ConflictMode, signal?: AbortSignal): Promise<void> {
|
||||
const mode = effectiveConflictMode(conflictMode);
|
||||
const memoryLimitBytes = zipEntryMemoryLimitBytes();
|
||||
@ -3109,7 +3091,10 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
|
||||
}, options.signal, hybrid, onPwAttempt, false, undefined, options.onLog);
|
||||
rememberLearnedPassword(usedPassword);
|
||||
} catch (externalError) {
|
||||
throw selectZipFallbackError(error, externalError);
|
||||
if (isNoExtractorError(String(externalError)) || isUnsupportedArchiveFormatError(String(externalError))) {
|
||||
throw error;
|
||||
}
|
||||
throw externalError;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -128,7 +128,7 @@ function createWindow(): BrowserWindow {
|
||||
responseHeaders: {
|
||||
...details.responseHeaders,
|
||||
"Content-Security-Policy": [
|
||||
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' https://api.real-debrid.com https://codeberg.org https://bestdebrid.com https://api.alldebrid.com https://www.mega-debrid.eu https://ddownload.com https://ddl.to https://debrid-link.com"
|
||||
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' https://api.real-debrid.com https://codeberg.org https://bestdebrid.com https://api.alldebrid.com https://www.mega-debrid.eu https://git.24-music.de https://ddownload.com https://ddl.to https://debrid-link.com"
|
||||
]
|
||||
}
|
||||
});
|
||||
@ -583,16 +583,6 @@ function registerIpcHandlers(): void {
|
||||
return { saved: true };
|
||||
});
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.EXPORT_ONLINE_BACKUP, async () => controller.exportOnlineBackup());
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.IMPORT_ONLINE_BACKUP, async (_event: IpcMainInvokeEvent, rawKey: unknown) => {
|
||||
const key = validateString(rawKey, "key").trim();
|
||||
if (key.length > 128) {
|
||||
throw new Error("Online-Sicherungsschlüssel ist ungültig");
|
||||
}
|
||||
return controller.importOnlineBackup(key);
|
||||
});
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.EXPORT_SUPPORT_BUNDLE, async () => {
|
||||
const options = {
|
||||
defaultPath: controller.getSupportBundleDefaultFileName(),
|
||||
|
||||
@ -1,273 +0,0 @@
|
||||
import crypto from "node:crypto";
|
||||
import zlib from "node:zlib";
|
||||
import type { AppSettings } from "../shared/types";
|
||||
|
||||
const KEY_PREFIX = "MDD2-";
|
||||
const KEY_BODY_LENGTH = 70;
|
||||
const RECORD_ID_LENGTH = 16;
|
||||
const MASTER_KEY_LENGTH = 32;
|
||||
const CHECKSUM_LENGTH = 4;
|
||||
const NONCE_LENGTH = 12;
|
||||
const AUTH_TAG_LENGTH = 16;
|
||||
const BLOB_VERSION = 1;
|
||||
const MAX_BLOB_BYTES = 256 * 1024;
|
||||
const MAX_RESPONSE_BYTES = 512 * 1024;
|
||||
const MAX_PLAINTEXT_BYTES = 512 * 1024;
|
||||
const REQUEST_TIMEOUT_MS = 12_000;
|
||||
const KEY_CONTEXT = Buffer.from("MDD2-ONLINE-KEY-V1", "utf8");
|
||||
const AAD_CONTEXT = Buffer.from("MDD-ONLINE-BACKUP-V1", "utf8");
|
||||
|
||||
export interface OnlineSettingsPayload {
|
||||
version: 1;
|
||||
kind: "settings-only";
|
||||
appVersion: string;
|
||||
exportedAt: string;
|
||||
settings: AppSettings;
|
||||
}
|
||||
|
||||
export interface OnlineBackupRecord {
|
||||
id: string;
|
||||
blob: string;
|
||||
deleteVerifier: string;
|
||||
}
|
||||
|
||||
export interface CreatedOnlineBackup {
|
||||
key: string;
|
||||
record: OnlineBackupRecord;
|
||||
}
|
||||
|
||||
export interface ParsedOnlineBackupKey {
|
||||
id: string;
|
||||
idBytes: Buffer;
|
||||
masterKey: Buffer;
|
||||
}
|
||||
|
||||
function checksum(idBytes: Buffer, masterKey: Buffer): Buffer {
|
||||
return crypto.createHash("sha256").update(KEY_CONTEXT).update(idBytes).update(masterKey).digest().subarray(0, CHECKSUM_LENGTH);
|
||||
}
|
||||
|
||||
function deriveSecret(masterKey: Buffer, idBytes: Buffer, purpose: string): Buffer {
|
||||
return Buffer.from(crypto.hkdfSync("sha256", masterKey, idBytes, Buffer.from(`MDD-ONLINE-${purpose}-V1`, "utf8"), 32));
|
||||
}
|
||||
|
||||
function deriveDeleteSecret(parsed: ParsedOnlineBackupKey): Buffer {
|
||||
return deriveSecret(parsed.masterKey, parsed.idBytes, "DELETE");
|
||||
}
|
||||
|
||||
function aad(idBytes: Buffer): Buffer {
|
||||
return Buffer.concat([AAD_CONTEXT, idBytes]);
|
||||
}
|
||||
|
||||
function encodeKey(idBytes: Buffer, masterKey: Buffer): string {
|
||||
const body = Buffer.concat([idBytes, masterKey, checksum(idBytes, masterKey)]).toString("base64url");
|
||||
return `${KEY_PREFIX}${body}`;
|
||||
}
|
||||
|
||||
function validatePayload(value: unknown): OnlineSettingsPayload {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new Error("Online-Sicherung enthält keine gültigen Einstellungen");
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
if (
|
||||
record.version !== 1
|
||||
|| record.kind !== "settings-only"
|
||||
|| typeof record.appVersion !== "string"
|
||||
|| typeof record.exportedAt !== "string"
|
||||
|| !record.settings
|
||||
|| typeof record.settings !== "object"
|
||||
|| Array.isArray(record.settings)
|
||||
|| "session" in record
|
||||
|| "history" in record
|
||||
) {
|
||||
throw new Error("Online-Sicherung enthält keine gültigen Einstellungen");
|
||||
}
|
||||
return record as unknown as OnlineSettingsPayload;
|
||||
}
|
||||
|
||||
function endpoint(baseUrl: string, relativePath: string): string {
|
||||
const normalized = String(baseUrl || "").trim().replace(/\/+$/, "");
|
||||
const url = new URL(`${normalized}${relativePath}`);
|
||||
if (url.protocol !== "https:" && !["127.0.0.1", "localhost", "::1"].includes(url.hostname)) {
|
||||
throw new Error("Online-Sicherungen benötigen eine sichere HTTPS-Verbindung");
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
async function request(url: string, init?: RequestInit): Promise<Response> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
||||
try {
|
||||
return await fetch(url, { ...init, signal: controller.signal });
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) {
|
||||
throw new Error("Online-Sicherungsdienst antwortet nicht");
|
||||
}
|
||||
throw new Error(`Online-Sicherungsdienst nicht erreichbar: ${String((error as Error)?.message || error)}`);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function readLimitedText(response: Response): Promise<string> {
|
||||
const contentLength = Number(response.headers.get("content-length") || "0");
|
||||
if (Number.isFinite(contentLength) && contentLength > MAX_RESPONSE_BYTES) {
|
||||
throw new Error("Antwort des Online-Sicherungsdienstes ist zu groß");
|
||||
}
|
||||
if (!response.body) return "";
|
||||
const reader = response.body.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let total = 0;
|
||||
while (true) {
|
||||
const result = await reader.read();
|
||||
if (result.done) break;
|
||||
total += result.value.byteLength;
|
||||
if (total > MAX_RESPONSE_BYTES) {
|
||||
await reader.cancel();
|
||||
throw new Error("Antwort des Online-Sicherungsdienstes ist zu groß");
|
||||
}
|
||||
chunks.push(result.value);
|
||||
}
|
||||
return Buffer.concat(chunks.map((chunk) => Buffer.from(chunk))).toString("utf8");
|
||||
}
|
||||
|
||||
export function parseOnlineBackupKey(key: string): ParsedOnlineBackupKey {
|
||||
const normalized = String(key || "").trim();
|
||||
if (!new RegExp(`^${KEY_PREFIX}[A-Za-z0-9_-]{${KEY_BODY_LENGTH}}$`).test(normalized)) {
|
||||
throw new Error("Online-Sicherungsschlüssel ist ungültig");
|
||||
}
|
||||
const decoded = Buffer.from(normalized.slice(KEY_PREFIX.length), "base64url");
|
||||
if (decoded.length !== RECORD_ID_LENGTH + MASTER_KEY_LENGTH + CHECKSUM_LENGTH) {
|
||||
throw new Error("Online-Sicherungsschlüssel ist ungültig");
|
||||
}
|
||||
if (decoded.toString("base64url") !== normalized.slice(KEY_PREFIX.length)) {
|
||||
throw new Error("Online-Sicherungsschlüssel ist ungültig");
|
||||
}
|
||||
const idBytes = decoded.subarray(0, RECORD_ID_LENGTH);
|
||||
const masterKey = decoded.subarray(RECORD_ID_LENGTH, RECORD_ID_LENGTH + MASTER_KEY_LENGTH);
|
||||
const actualChecksum = decoded.subarray(RECORD_ID_LENGTH + MASTER_KEY_LENGTH);
|
||||
const expectedChecksum = checksum(idBytes, masterKey);
|
||||
if (!crypto.timingSafeEqual(actualChecksum, expectedChecksum)) {
|
||||
throw new Error("Online-Sicherungsschlüssel ist beschädigt");
|
||||
}
|
||||
return { id: idBytes.toString("base64url"), idBytes: Buffer.from(idBytes), masterKey: Buffer.from(masterKey) };
|
||||
}
|
||||
|
||||
export function createOnlineBackup(settings: AppSettings, appVersion: string, exportedAt = new Date().toISOString()): CreatedOnlineBackup {
|
||||
const idBytes = crypto.randomBytes(RECORD_ID_LENGTH);
|
||||
const masterKey = crypto.randomBytes(MASTER_KEY_LENGTH);
|
||||
const key = encodeKey(idBytes, masterKey);
|
||||
const encryptionKey = deriveSecret(masterKey, idBytes, "ENCRYPTION");
|
||||
const nonce = crypto.randomBytes(NONCE_LENGTH);
|
||||
const payload: OnlineSettingsPayload = {
|
||||
version: 1,
|
||||
kind: "settings-only",
|
||||
appVersion,
|
||||
exportedAt,
|
||||
settings: JSON.parse(JSON.stringify(settings)) as AppSettings
|
||||
};
|
||||
const plaintext = Buffer.from(JSON.stringify(payload), "utf8");
|
||||
if (plaintext.length > MAX_PLAINTEXT_BYTES) {
|
||||
throw new Error("Einstellungen sind für eine Online-Sicherung zu groß");
|
||||
}
|
||||
const compressed = zlib.gzipSync(plaintext, { level: 9 });
|
||||
const cipher = crypto.createCipheriv("aes-256-gcm", encryptionKey, nonce, { authTagLength: AUTH_TAG_LENGTH });
|
||||
cipher.setAAD(aad(idBytes));
|
||||
const ciphertext = Buffer.concat([cipher.update(compressed), cipher.final()]);
|
||||
const blobBytes = Buffer.concat([Buffer.from([BLOB_VERSION]), nonce, cipher.getAuthTag(), ciphertext]);
|
||||
if (blobBytes.length > MAX_BLOB_BYTES) {
|
||||
throw new Error("Einstellungen sind für eine Online-Sicherung zu groß");
|
||||
}
|
||||
const parsed = parseOnlineBackupKey(key);
|
||||
const deleteVerifier = crypto.createHash("sha256").update(deriveDeleteSecret(parsed)).digest("base64url");
|
||||
return {
|
||||
key,
|
||||
record: {
|
||||
id: parsed.id,
|
||||
blob: blobBytes.toString("base64url"),
|
||||
deleteVerifier
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function restoreOnlineBackup(key: string, blob: string): OnlineSettingsPayload {
|
||||
const parsed = parseOnlineBackupKey(key);
|
||||
if (!/^[A-Za-z0-9_-]+$/.test(blob) || blob.length > Math.ceil(MAX_BLOB_BYTES * 4 / 3) + 4) {
|
||||
throw new Error("Online-Sicherung ist beschädigt");
|
||||
}
|
||||
const bytes = Buffer.from(blob, "base64url");
|
||||
if (bytes.toString("base64url") !== blob) {
|
||||
throw new Error("Online-Sicherung ist beschädigt");
|
||||
}
|
||||
if (bytes.length < 1 + NONCE_LENGTH + AUTH_TAG_LENGTH || bytes[0] !== BLOB_VERSION) {
|
||||
throw new Error("Online-Sicherung ist beschädigt");
|
||||
}
|
||||
const nonce = bytes.subarray(1, 1 + NONCE_LENGTH);
|
||||
const tag = bytes.subarray(1 + NONCE_LENGTH, 1 + NONCE_LENGTH + AUTH_TAG_LENGTH);
|
||||
const ciphertext = bytes.subarray(1 + NONCE_LENGTH + AUTH_TAG_LENGTH);
|
||||
try {
|
||||
const decipher = crypto.createDecipheriv(
|
||||
"aes-256-gcm",
|
||||
deriveSecret(parsed.masterKey, parsed.idBytes, "ENCRYPTION"),
|
||||
nonce,
|
||||
{ authTagLength: AUTH_TAG_LENGTH }
|
||||
);
|
||||
decipher.setAAD(aad(parsed.idBytes));
|
||||
decipher.setAuthTag(tag);
|
||||
const compressed = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
||||
const plaintext = zlib.gunzipSync(compressed, { maxOutputLength: MAX_PLAINTEXT_BYTES }).toString("utf8");
|
||||
return validatePayload(JSON.parse(plaintext));
|
||||
} catch (error) {
|
||||
if (error instanceof Error && /keine gültigen Einstellungen/.test(error.message)) throw error;
|
||||
throw new Error("Online-Sicherung konnte nicht entschlüsselt werden oder ist beschädigt");
|
||||
}
|
||||
}
|
||||
|
||||
export async function uploadOnlineBackup(record: OnlineBackupRecord, baseUrl: string): Promise<void> {
|
||||
const response = await request(endpoint(baseUrl, "/v1/backups"), {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", accept: "application/json" },
|
||||
body: JSON.stringify(record)
|
||||
});
|
||||
await readLimitedText(response);
|
||||
if (response.status !== 201) {
|
||||
throw new Error("Online-Sicherung konnte nicht gespeichert werden");
|
||||
}
|
||||
}
|
||||
|
||||
export async function downloadOnlineBackup(key: string, baseUrl: string): Promise<OnlineSettingsPayload> {
|
||||
const parsed = parseOnlineBackupKey(key);
|
||||
const response = await request(endpoint(baseUrl, "/v1/backups/restore"), {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", accept: "application/json" },
|
||||
body: JSON.stringify({ id: parsed.id })
|
||||
});
|
||||
const body = await readLimitedText(response);
|
||||
if (response.status !== 200) {
|
||||
throw new Error(response.status === 404 ? "Online-Sicherung wurde nicht gefunden" : "Online-Sicherung konnte nicht geladen werden");
|
||||
}
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(body);
|
||||
} catch {
|
||||
throw new Error("Online-Sicherungsdienst hat ungültige Daten geliefert");
|
||||
}
|
||||
const blob = (value as { blob?: unknown })?.blob;
|
||||
if (typeof blob !== "string") {
|
||||
throw new Error("Online-Sicherungsdienst hat ungültige Daten geliefert");
|
||||
}
|
||||
return restoreOnlineBackup(key, blob);
|
||||
}
|
||||
|
||||
export async function deleteOnlineBackup(key: string, baseUrl: string): Promise<void> {
|
||||
const parsed = parseOnlineBackupKey(key);
|
||||
const deleteSecret = deriveDeleteSecret(parsed).toString("base64url");
|
||||
const response = await request(endpoint(baseUrl, "/v1/backups/delete"), {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", accept: "application/json" },
|
||||
body: JSON.stringify({ id: parsed.id, deleteSecret })
|
||||
});
|
||||
if (response.status !== 204) {
|
||||
await readLimitedText(response);
|
||||
throw new Error(response.status === 404 ? "Online-Sicherung wurde nicht gefunden" : "Online-Sicherung konnte nicht gelöscht werden");
|
||||
}
|
||||
}
|
||||
@ -461,7 +461,7 @@ export function normalizeSettings(settings: AppSettings): AppSettings {
|
||||
hideExtractedItems: settings.hideExtractedItems !== undefined ? Boolean(settings.hideExtractedItems) : defaults.hideExtractedItems,
|
||||
confirmDeleteSelection: settings.confirmDeleteSelection !== undefined ? Boolean(settings.confirmDeleteSelection) : defaults.confirmDeleteSelection,
|
||||
backupIncludeDownloads: settings.backupIncludeDownloads !== undefined ? Boolean(settings.backupIncludeDownloads) : defaults.backupIncludeDownloads,
|
||||
backupIncludeRemoteDiagnostics: settings.backupIncludeRemoteDiagnostics !== undefined ? Boolean(settings.backupIncludeRemoteDiagnostics) : defaults.backupIncludeRemoteDiagnostics,
|
||||
backupIncludeMcp: settings.backupIncludeMcp !== undefined ? Boolean(settings.backupIncludeMcp) : defaults.backupIncludeMcp,
|
||||
notifyUrl: asText(settings.notifyUrl) || defaults.notifyUrl,
|
||||
notifyMention: asText(settings.notifyMention) || defaults.notifyMention,
|
||||
notifyOnPackageCompleted: settings.notifyOnPackageCompleted !== undefined ? Boolean(settings.notifyOnPackageCompleted) : defaults.notifyOnPackageCompleted,
|
||||
|
||||
@ -18,7 +18,7 @@ import { getTraceConfig, getTraceConfigPath, getTraceLogPath } from "./trace-log
|
||||
import { getCachedWindowsHostDiagnostics, getWindowsHostDiagnostics } from "./windows-host-diagnostics";
|
||||
import type { DownloadManager } from "./download-manager";
|
||||
|
||||
const SUPPORT_MANIFEST_FILE = "debug_support_manifest.json";
|
||||
const AI_MANIFEST_FILE = "debug_ai_manifest.json";
|
||||
|
||||
async function safeReadJson(filePath: string): Promise<unknown> {
|
||||
try {
|
||||
@ -183,7 +183,7 @@ export async function buildSupportBundle(manager: DownloadManager, baseDir: stri
|
||||
const recentErrors = getRecentErrors();
|
||||
addJson(zip, "overview/recent-errors.json", { count: recentErrors.length, entries: recentErrors });
|
||||
|
||||
await addFileIfExists(zip, path.join(baseDir, SUPPORT_MANIFEST_FILE), `runtime/${SUPPORT_MANIFEST_FILE}`);
|
||||
await addFileIfExists(zip, path.join(baseDir, AI_MANIFEST_FILE), `runtime/${AI_MANIFEST_FILE}`);
|
||||
await addFileIfExists(zip, path.join(baseDir, "debug_host.txt"), "runtime/debug_host.txt");
|
||||
await addFileIfExists(zip, path.join(baseDir, "debug_port.txt"), "runtime/debug_port.txt");
|
||||
await addFileIfExists(zip, getTraceConfigPath(), "runtime/trace_config.json");
|
||||
@ -215,9 +215,9 @@ export async function buildSupportBundle(manager: DownloadManager, baseDir: stri
|
||||
await addFileIfExists(zip, manager.getItemLogPath(itemId), `logs/live/item-${itemId}.txt`);
|
||||
}
|
||||
|
||||
const supportManifest = await safeReadJson(path.join(baseDir, SUPPORT_MANIFEST_FILE));
|
||||
if (supportManifest) {
|
||||
addJson(zip, "overview/support-manifest.json", supportManifest);
|
||||
const aiManifest = await safeReadJson(path.join(baseDir, AI_MANIFEST_FILE));
|
||||
if (aiManifest) {
|
||||
addJson(zip, "overview/ai-manifest.json", aiManifest);
|
||||
}
|
||||
|
||||
return zip.toBuffer();
|
||||
|
||||
@ -37,7 +37,9 @@ type ExpectedDigest = {
|
||||
};
|
||||
|
||||
const UPDATE_SOURCES: UpdateSource[] = [
|
||||
{ name: "github", webBase: "https://github.com", apiBase: "https://api.github.com" }
|
||||
{ name: "git24", webBase: "https://git.24-music.de", apiBase: "https://git.24-music.de/api/v1" },
|
||||
{ name: "codeberg", webBase: "https://codeberg.org", apiBase: "https://codeberg.org/api/v1" },
|
||||
{ name: "github", webBase: "https://github.com", apiBase: "https://api.github.com" },
|
||||
];
|
||||
|
||||
const PRIMARY_SOURCE = UPDATE_SOURCES[0];
|
||||
@ -80,9 +82,9 @@ function isValidRepoPart(value: string): boolean {
|
||||
|
||||
function extractOwnerRepo(input: string): string {
|
||||
const cleaned = input
|
||||
.replace(/^https?:\/\/(?:www\.)?(?:codeberg\.org|github\.com)\//i, "")
|
||||
.replace(/^(?:www\.)?(?:codeberg\.org|github\.com)\//i, "")
|
||||
.replace(/^git@(?:codeberg\.org|github\.com):/i, "")
|
||||
.replace(/^https?:\/\/(?:www\.)?(?:codeberg\.org|github\.com|git\.24-music\.de)\//i, "")
|
||||
.replace(/^(?:www\.)?(?:codeberg\.org|github\.com|git\.24-music\.de)\//i, "")
|
||||
.replace(/^git@(?:codeberg\.org|github\.com|git\.24-music\.de):/i, "")
|
||||
.replace(/\.git$/i, "")
|
||||
.replace(/^\/+|\/+$/g, "");
|
||||
const parts = cleaned.split("/").filter(Boolean);
|
||||
@ -104,6 +106,7 @@ export function normalizeUpdateRepo(repo: string): string {
|
||||
|| host === "www.codeberg.org"
|
||||
|| host === "github.com"
|
||||
|| host === "www.github.com"
|
||||
|| host === "git.24-music.de"
|
||||
) {
|
||||
const result = extractOwnerRepo(url.pathname);
|
||||
if (result) return result;
|
||||
|
||||
@ -62,8 +62,6 @@ const api: ElectronApi = {
|
||||
quit: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.QUIT),
|
||||
exportBackup: (): Promise<{ saved: boolean }> => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_BACKUP),
|
||||
importBackup: (): Promise<{ restored: boolean; relaunch: boolean; message: string }> => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_BACKUP),
|
||||
exportOnlineBackup: (): Promise<{ key: string }> => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_ONLINE_BACKUP),
|
||||
importOnlineBackup: (key: string): Promise<{ restored: boolean; relaunch: false; message: string }> => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_ONLINE_BACKUP, key),
|
||||
exportSupportBundle: (): Promise<{ saved: boolean; filePath?: string }> => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_SUPPORT_BUNDLE),
|
||||
openLog: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_LOG),
|
||||
openAuditLog: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_AUDIT_LOG),
|
||||
|
||||
@ -63,13 +63,6 @@ interface ConfirmPromptState {
|
||||
detailsLabel?: string;
|
||||
}
|
||||
|
||||
interface OnlineBackupDialogState {
|
||||
mode: "export" | "import";
|
||||
key: string;
|
||||
busy: boolean;
|
||||
error: string;
|
||||
}
|
||||
|
||||
interface ContextMenuState {
|
||||
x: number;
|
||||
y: number;
|
||||
@ -179,7 +172,7 @@ function buildDebugSetupDetails(setup: DebugSetupCheckResult): string {
|
||||
`Host: ${setup.host}`,
|
||||
`Port: ${setup.port}`,
|
||||
`Token-Datei: ${setup.tokenPath}`,
|
||||
`Support-Manifest: ${setup.supportManifestPresent ? "vorhanden" : "fehlt"} (${setup.supportManifestPath})`,
|
||||
`KI-Manifest: ${setup.aiManifestPresent ? "vorhanden" : "fehlt"} (${setup.aiManifestPath})`,
|
||||
`Trace aktiv: ${setup.traceEnabled ? "ja" : "nein"}`,
|
||||
`Trace-Auto-Ende: ${setup.traceAutoDisableAt || "nicht gesetzt"}`,
|
||||
"",
|
||||
@ -863,7 +856,7 @@ const emptySnapshot = (): UiSnapshot => ({
|
||||
autoReconnect: false, reconnectWaitSeconds: 45, completedCleanupPolicy: "never",
|
||||
maxParallel: 4, maxParallelExtract: 2, extractCpuPriority: "high", retryLimit: 0, speedLimitEnabled: false, speedLimitKbps: 0, speedLimitMode: "global",
|
||||
updateRepo: "", autoUpdateCheck: true, clipboardWatch: false, minimizeToTray: false,
|
||||
theme: "dark", collapseNewPackages: true, historyRetentionMode: "permanent", historyMaxEntries: 500, historyMaxAgeDays: 0, autoSortPackagesByProgress: true, autoSkipExtracted: false, hideExtractedItems: true, confirmDeleteSelection: true, backupIncludeDownloads: false, backupIncludeRemoteDiagnostics: false,
|
||||
theme: "dark", collapseNewPackages: true, historyRetentionMode: "permanent", historyMaxEntries: 500, historyMaxAgeDays: 0, autoSortPackagesByProgress: true, autoSkipExtracted: false, hideExtractedItems: true, confirmDeleteSelection: true, backupIncludeDownloads: false, backupIncludeMcp: false,
|
||||
notifyUrl: "", notifyMention: "", notifyOnPackageCompleted: false, notifyOnPackageFailed: false, notifyOnRunFinished: false,
|
||||
accountListShowDetailedDebridLinkKeys: false,
|
||||
bandwidthSchedules: [], totalDownloadedAllTime: 0, totalCompletedFilesAllTime: 0, totalRuntimeAllTimeMs: 0,
|
||||
@ -1769,7 +1762,6 @@ export function App(): ReactElement {
|
||||
const [startConflictPrompt, setStartConflictPrompt] = useState<StartConflictPromptState | null>(null);
|
||||
const startConflictResolverRef = useRef<((result: { policy: Extract<DuplicatePolicy, "skip" | "overwrite">; applyToAll: boolean } | null) => void) | null>(null);
|
||||
const [confirmPrompt, setConfirmPrompt] = useState<ConfirmPromptState | null>(null);
|
||||
const [onlineBackupDialog, setOnlineBackupDialog] = useState<OnlineBackupDialogState | null>(null);
|
||||
const [remoteDiag, setRemoteDiag] = useState<RemoteDiagnosticsInfo | null>(null);
|
||||
const [remoteDiagOpen, setRemoteDiagOpen] = useState(false);
|
||||
const [remoteDiagBusy, setRemoteDiagBusy] = useState(false);
|
||||
@ -4256,48 +4248,6 @@ export function App(): ReactElement {
|
||||
});
|
||||
};
|
||||
|
||||
const onCreateOnlineBackup = async (): Promise<void> => {
|
||||
closeMenus();
|
||||
setOnlineBackupDialog({ mode: "export", key: "", busy: true, error: "" });
|
||||
try {
|
||||
const result = await window.rd.exportOnlineBackup();
|
||||
setOnlineBackupDialog({ mode: "export", key: result.key, busy: false, error: "" });
|
||||
showToast("Online-Schlüssel erstellt", 2600);
|
||||
} catch {
|
||||
setOnlineBackupDialog({ mode: "export", key: "", busy: false, error: "Online-Sicherung konnte nicht erstellt werden." });
|
||||
}
|
||||
};
|
||||
|
||||
const onOpenOnlineBackupImport = (): void => {
|
||||
closeMenus();
|
||||
setOnlineBackupDialog({ mode: "import", key: "", busy: false, error: "" });
|
||||
};
|
||||
|
||||
const onImportOnlineBackup = async (): Promise<void> => {
|
||||
const key = onlineBackupDialog?.mode === "import" ? onlineBackupDialog.key.trim() : "";
|
||||
if (!key) return;
|
||||
setOnlineBackupDialog((current) => current ? { ...current, busy: true, error: "" } : current);
|
||||
try {
|
||||
const result = await window.rd.importOnlineBackup(key);
|
||||
const fresh = await window.rd.getSnapshot();
|
||||
applyPersistedSettings(fresh.settings);
|
||||
setOnlineBackupDialog(null);
|
||||
showToast(result.message, 4000);
|
||||
} catch {
|
||||
setOnlineBackupDialog((current) => current ? { ...current, busy: false, error: "Online-Sicherung konnte nicht geladen werden. Schlüssel prüfen und erneut versuchen." } : current);
|
||||
}
|
||||
};
|
||||
|
||||
const onCopyOnlineBackupKey = async (): Promise<void> => {
|
||||
if (!onlineBackupDialog?.key) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(onlineBackupDialog.key);
|
||||
showToast("Online-Schlüssel kopiert", 2200);
|
||||
} catch {
|
||||
showToast("Schlüssel konnte nicht kopiert werden", 2600);
|
||||
}
|
||||
};
|
||||
|
||||
const onExportSupportBundle = async (): Promise<void> => {
|
||||
closeMenus();
|
||||
await performQuickAction(async () => {
|
||||
@ -4701,9 +4651,6 @@ export function App(): ReactElement {
|
||||
<div className="menu-submenu-dropdown">
|
||||
<button className="menu-dropdown-item" onClick={() => { void onExportBackup(); }}>Exportieren</button>
|
||||
<button className="menu-dropdown-item" onClick={() => { void onImportBackup(); }}>Importieren</button>
|
||||
<div className="menu-separator" />
|
||||
<button className="menu-dropdown-item" onClick={() => { void onCreateOnlineBackup(); }}>Online-Schlüssel erstellen</button>
|
||||
<button className="menu-dropdown-item" onClick={onOpenOnlineBackupImport}>Online-Schlüssel importieren</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@ -4863,7 +4810,7 @@ export function App(): ReactElement {
|
||||
<button className="menu-submenu-trigger">Remote-Support</button>
|
||||
{openSubmenu === "hilfe-remote" && (
|
||||
<div className="menu-submenu-dropdown">
|
||||
<button className="menu-dropdown-item" onClick={() => { void onOpenRemoteDiagnostics(); }}><span>Ferndiagnose …</span></button>
|
||||
<button className="menu-dropdown-item" onClick={() => { void onOpenRemoteDiagnostics(); }}><span>Ferndiagnose (MCP) …</span></button>
|
||||
<button className="menu-dropdown-item" onClick={() => { void onExportSupportBundle(); }}><span>Support-Bundle exportieren</span></button>
|
||||
<button className="menu-dropdown-item" onClick={() => { void onToggleSupportTrace(); }}><span>{supportTraceEnabled ? "Support-Trace deaktivieren" : "Support-Trace aktivieren"}</span></button>
|
||||
</div>
|
||||
@ -5495,7 +5442,7 @@ export function App(): ReactElement {
|
||||
<div className="setting-hint">Sicherheitsabfrage vor dem Entfernen ausgewählter Einträge.</div>
|
||||
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.backupIncludeDownloads} onChange={(e) => setBool("backupIncludeDownloads", e.target.checked)} /> Download-Liste mitsichern</label>
|
||||
<div className="setting-hint">Sicherung enthält auch die Download-Liste; Standard: nur Einstellungen.</div>
|
||||
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.backupIncludeRemoteDiagnostics} onChange={(e) => setBool("backupIncludeRemoteDiagnostics", e.target.checked)} /> Ferndiagnose-Einstellungen mitsichern</label>
|
||||
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.backupIncludeMcp} onChange={(e) => setBool("backupIncludeMcp", e.target.checked)} /> Ferndiagnose-Einstellungen mitsichern</label>
|
||||
<div className="setting-hint">Allowlist, Port und Freigabemodus (lokal/Netzwerk) reisen mit. Verbindungs-Token und eigene Adresse bleiben pro Server – nach dem Import einmal „Aktivieren" drücken.</div>
|
||||
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.theme === "light"} onChange={(e) => {
|
||||
const next = e.target.checked ? "light" : "dark";
|
||||
@ -6082,47 +6029,11 @@ export function App(): ReactElement {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{onlineBackupDialog && (
|
||||
<div className="modal-backdrop" onClick={() => { if (!onlineBackupDialog.busy) setOnlineBackupDialog(null); }}>
|
||||
<div className="modal-card online-backup-modal" onClick={(event) => event.stopPropagation()}>
|
||||
<h3>{onlineBackupDialog.mode === "export" ? "Online-Schlüssel" : "Online-Schlüssel importieren"}</h3>
|
||||
<p>
|
||||
{onlineBackupDialog.mode === "export"
|
||||
? "Dieser Schlüssel stellt deine Einstellungen inklusive gespeicherter Zugangsdaten wieder her. Bewahre ihn wie ein Passwort auf."
|
||||
: "Füge den vollständigen MDD2-Schlüssel ein. Die aktuellen Einstellungen werden durch die gespeicherte Version ersetzt."}
|
||||
</p>
|
||||
{onlineBackupDialog.mode === "export" && onlineBackupDialog.busy && <div className="online-backup-status">Online-Sicherung wird verschlüsselt und gespeichert …</div>}
|
||||
{onlineBackupDialog.mode === "export" && onlineBackupDialog.key && (
|
||||
<textarea className="online-backup-key" value={onlineBackupDialog.key} readOnly spellCheck={false} aria-label="Online-Sicherungsschlüssel" />
|
||||
)}
|
||||
{onlineBackupDialog.mode === "import" && (
|
||||
<textarea
|
||||
className="online-backup-key"
|
||||
value={onlineBackupDialog.key}
|
||||
onChange={(event) => setOnlineBackupDialog((current) => current ? { ...current, key: event.target.value, error: "" } : current)}
|
||||
placeholder="MDD2-…"
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
disabled={onlineBackupDialog.busy}
|
||||
autoFocus
|
||||
aria-label="Online-Sicherungsschlüssel eingeben"
|
||||
/>
|
||||
)}
|
||||
{onlineBackupDialog.error && <div className="online-backup-error">{onlineBackupDialog.error}</div>}
|
||||
<div className="modal-actions">
|
||||
<button className="btn" onClick={() => setOnlineBackupDialog(null)} disabled={onlineBackupDialog.busy}>Schließen</button>
|
||||
{onlineBackupDialog.mode === "export" && onlineBackupDialog.key && <button className="btn primary" onClick={() => { void onCopyOnlineBackupKey(); }}>Kopieren</button>}
|
||||
{onlineBackupDialog.mode === "import" && <button className="btn primary" onClick={() => { void onImportOnlineBackup(); }} disabled={onlineBackupDialog.busy || !onlineBackupDialog.key.trim()}>{onlineBackupDialog.busy ? "Wird geladen …" : "Importieren"}</button>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{remoteDiagOpen && (
|
||||
<div className="modal-backdrop" onClick={() => setRemoteDiagOpen(false)}>
|
||||
<div className="modal-card" onClick={(event) => event.stopPropagation()}>
|
||||
<h3>Ferndiagnose</h3>
|
||||
<p>Ermöglicht einer vertrauenswürdigen Support-Stelle den geschützten Lesezugriff auf Status, Logs und Fehler. Der Verbindungscode enthält das Zugriffstoken und ist wie ein Passwort zu behandeln.</p>
|
||||
<h3>Ferndiagnose (MCP)</h3>
|
||||
<p>Aktiviert einen abgesicherten Lesezugriff auf Status, Logs und Fehler dieses Servers. Den Verbindungscode dem Assistenten geben - er verbindet sich, sieht alles und behebt Probleme.</p>
|
||||
<div className="rd-status-line">
|
||||
<span className={`rd-dot${remoteDiag?.status.running ? " on" : ""}`} />
|
||||
<span>
|
||||
|
||||
@ -3118,38 +3118,6 @@ td {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.online-backup-modal {
|
||||
width: min(620px, 100%);
|
||||
}
|
||||
|
||||
.online-backup-key {
|
||||
width: 100%;
|
||||
min-height: 86px;
|
||||
resize: vertical;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
padding: 10px 12px;
|
||||
font: 13px/1.55 "Cascadia Mono", "Consolas", monospace;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.online-backup-key:focus {
|
||||
outline: 2px solid color-mix(in srgb, var(--accent) 55%, transparent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.online-backup-status {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.online-backup-error {
|
||||
color: var(--danger);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.control-strip {
|
||||
flex-direction: column;
|
||||
|
||||
@ -38,8 +38,6 @@ export const IPC_CHANNELS = {
|
||||
QUIT: "app:quit",
|
||||
EXPORT_BACKUP: "app:export-backup",
|
||||
IMPORT_BACKUP: "app:import-backup",
|
||||
EXPORT_ONLINE_BACKUP: "app:export-online-backup",
|
||||
IMPORT_ONLINE_BACKUP: "app:import-online-backup",
|
||||
EXPORT_SUPPORT_BUNDLE: "app:export-support-bundle",
|
||||
OPEN_LOG: "app:open-log",
|
||||
OPEN_AUDIT_LOG: "app:open-audit-log",
|
||||
|
||||
@ -59,8 +59,6 @@ export interface ElectronApi {
|
||||
quit: () => Promise<void>;
|
||||
exportBackup: () => Promise<{ saved: boolean }>;
|
||||
importBackup: () => Promise<{ restored: boolean; relaunch: boolean; message: string }>;
|
||||
exportOnlineBackup: () => Promise<{ key: string }>;
|
||||
importOnlineBackup: (key: string) => Promise<{ restored: boolean; relaunch: false; message: string }>;
|
||||
exportSupportBundle: () => Promise<{ saved: boolean; filePath?: string }>;
|
||||
openLog: () => Promise<void>;
|
||||
openAuditLog: () => Promise<void>;
|
||||
|
||||
@ -134,7 +134,7 @@ export interface AppSettings {
|
||||
hideExtractedItems: boolean;
|
||||
confirmDeleteSelection: boolean;
|
||||
backupIncludeDownloads: boolean;
|
||||
backupIncludeRemoteDiagnostics: boolean;
|
||||
backupIncludeMcp: boolean;
|
||||
notifyUrl: string;
|
||||
notifyMention: string;
|
||||
notifyOnPackageCompleted: boolean;
|
||||
@ -469,8 +469,8 @@ export interface DebugSetupCheckResult {
|
||||
localOnly: boolean;
|
||||
tokenConfigured: boolean;
|
||||
tokenPath: string;
|
||||
supportManifestPath: string;
|
||||
supportManifestPresent: boolean;
|
||||
aiManifestPath: string;
|
||||
aiManifestPresent: boolean;
|
||||
traceConfigPath: string | null;
|
||||
traceLogPath: string | null;
|
||||
traceEnabled: boolean;
|
||||
|
||||
479
tasks/audit-loop.md
Normal file
479
tasks/audit-loop.md
Normal file
@ -0,0 +1,479 @@
|
||||
# Autonomer Audit-Loop — Download/Fehler/Rotation (Goal 2026-06-17, 8h)
|
||||
|
||||
## GOAL-ANPASSUNG (Nutzer, nach Runde 8 + Synthese-Start): noch Runde 9 + 10, dann Goal BEENDEN.
|
||||
Plan: Synthese-Pass (wrexz7mdf, Capstone R1-8) auswerten → Runde 9 (Provider-spezifische
|
||||
Unrestrict-/Rotations-Pfade: Mega-Web-Fallback Session/Single-Flight, AllDebrid Host-Cooldown/
|
||||
Rapidgator-Backoff, DebridLink-Key-Rotation, Passwort-Cache-Race) → Runde 10 (Concurrency/Locking +
|
||||
Settings/Persistenz-Integritaet: Hybrid-Race, targetPath-Claim/Release-Races, Settings-Migration,
|
||||
Backup/Restore, account-check) → Abschlussbericht + Ende. Fixes je rot-bewiesen, buendeln zu v1.7.220 falls HIGH/MED.
|
||||
|
||||
Disziplin: erst BELEGEN (Code-Zitat + konkretes Szenario), dann adversarisch verifizieren,
|
||||
dann TDD-Fix. Kein Blind-Fix. Tests gruen + tsc=6 nach jeder Runde. Periodisch releasen.
|
||||
|
||||
## Runde 9 (Provider-spezifische Unrestrict-/Rotations-Pfade) — Mega-Web-Single-Flight + DebridLink-Key + Concurrency
|
||||
Fokus: Mega-Web-Fallback Session/Single-Flight-Queue, DebridLink-Key-Rotation, In-Flight-Verteilung.
|
||||
- **CONFIRMED HIGH (GEFIXT) MW-1 Selbst-Cooldown eines GESUNDEN, nur in der Queue wartenden Web-Accounts:**
|
||||
Der Caller-Timeout (`DEFAULT_UNRESTRICT_TIMEOUT_MS`=60s) feuert, waehrend die zweite Umwandlung eines
|
||||
Accounts noch SERIELL in der Mega-Web-Single-Flight-Queue (90s `QUEUE_WAIT_TIMEOUT_MS`) auf den laufenden
|
||||
Vorgaenger wartet — also bevor ueberhaupt echte Arbeit begann. `raceWithAbort` warf bisher unbedingt
|
||||
`aborted:mega-web`; `unrestrictViaWeb` (debrid.ts:1887) flachte JEDEN signal-aborted-Fall zu `aborted:debrid`
|
||||
ab; die Rotation (debrid.ts:2072) wertete das via `/aborted/i && !/timeout/i` → `ranLongEnough`
|
||||
(elapsedMs schliesst die Queue-Wartezeit ein, also >= 8s) → **120s Account-Cooldown auf einen voellig
|
||||
gesunden Account**. Genau die vom Nutzer gemeldete „Tool sperrt sich selbst"-Klasse (Web-Variante).
|
||||
Zweiteiliger Fix:
|
||||
(1) `MegaWebFallback.runExclusive` (mega-web-fallback.ts) trackt `workStarted` und reicht eine
|
||||
`abortErrorFactory` an `raceWithAbort`: abgebrochen-bevor-Arbeit-begann → `Mega-Web Queue-Timeout (…)`
|
||||
(matcht `/queue.?timeout/i`), nur ein echter In-Arbeit-Abbruch bleibt `aborted:mega-web`.
|
||||
(2) `unrestrictViaWeb` (debrid.ts:1887) bewahrt einen `/queue.?timeout/i`-klassifizierten lastError statt
|
||||
ihn zu `aborted:debrid` zu plaetten → Rotation trifft die bestehende Queue-Timeout-Ausnahme
|
||||
(classifyAccountFailure 2243 → cooldownMs 0) statt den Abbruch-Cooldown-Zweig.
|
||||
Rot-bewiesen NICHT-vakuum (zwei Tests, beide per Temp-Revert rot verifiziert):
|
||||
- Teil 1 (mega-web-fallback.test.ts): echtes `MegaWebFallback`, Queue mit langsamem Erst-Job belegt,
|
||||
Zweit-Call WAEHREND in Queue abgebrochen → `rejects.toThrow(/queue.?timeout/i)` (ohne Fix: `aborted:mega-web`).
|
||||
- Teil 2 (debrid.test.ts): durch `DebridService.unrestrictLink` mit `RD_MEGA_ABORT_MIN_RUN_MS=0` (besiegt die
|
||||
Vakuum-Falle: der buggy Pfad WUERDE selbst bei Sofort-Abbruch cooldownen), Signal bricht WAEHREND des
|
||||
Calls ab, megaWeb meldet Queue-Timeout → `getMegaDebridAccountCooldownState` bleibt null (ohne Fix: 120s
|
||||
„Abbruch/Timeout nach 0s"). tsc=6, volle mega-web+debrid-Suite 103 gruen inkl. der bestehenden
|
||||
Abbruch-Cooldown-Tests (echter langsamer Account cooled WEITERHIN — keine Regression).
|
||||
- **Rotations-Verifikation (Advisor-Shippability-Diskriminator):** Nach dem No-Cooldown-Pfad retried der
|
||||
Manager `unrestrictWithAccounts` frisch. Die Account-Wahl sortiert per `megaDebridInFlight`-TIEFE
|
||||
(debrid.ts:1981-1986, least-busy zuerst). Invariante: jeder Belegt-Halter der MegaWebFallback-Queue
|
||||
entspricht einem in-flight `client.unrestrictLink`, das `megaDebridInFlight` inkrementiert hat (2033,
|
||||
Dekrement nur im finally 2152). Also sieht der Retry des queue-getimeouteten Links den saturierenden
|
||||
Account bei Tiefe>=1 und rotiert auf einen freien — MW-1 ist NACHWEISLICH besser als das alte 120s-Lockout
|
||||
(das einen nur-belegten gesunden Account sperrte). Kein Tight-Retry-Loop auf saturierter Queue, solange
|
||||
irgendein Account frei ist; sind ALLE saturiert, queued der least-busy (unvermeidbar — keine freie Kapazitaet,
|
||||
aber das alte Cooldown haette es via Faux-Park SCHLIMMER gemacht).
|
||||
- **DOKUMENTIERT, nicht gefixt (LOW) DL-1 DebridLink-Key-Cooldown bei User-Cancel-Abbruch:** classifyKeyFailure
|
||||
(debrid.ts:3118-3126) gibt fuer Text mit „aborted" (via isRetryableErrorText 597 + isTransport 3119)
|
||||
`cooldownMs: 15_000` zurueck — auch bei einem SCHNELLEN User-Cancel (kein Min-Run-Gate wie bei Mega 2072).
|
||||
Symptom: 15s Key-Cooldown nach Nutzer-Abbruch. Advisor (revidiert eigenen frueheren „clean fix"-Call):
|
||||
classifyKeyFailure hat KEIN elapsedMs und DebridLink hat keinen Min-Run-Knopf-Aequivalent zu
|
||||
`getMegaDebridAbortMinRunMs()`. Der treue Fix (Caller-Gate bei 2787 spiegeln) braucht entweder cross-Provider-
|
||||
Wiederverwendung des Mega-Knopfes oder einen neuen DebridLink-Knopf = neue Oberflaeche im Live-Hot-Path fuer
|
||||
einen LOW-Bug (Symptom: 15s nach User-Cancel). Klart die eigene „clean + null-Regression"-Schwelle NICHT →
|
||||
dokumentiert, nicht gefixt.
|
||||
- **DOKUMENTIERT, nicht gefixt (MED) DL-CONCURRENCY-PILEUP:** Bei gleichzeitigen Umwandlungen, die ALLE
|
||||
Mega-Accounts saturieren, queued der least-busy-Account weitere Links seriell (per-Account-Single-Flight) →
|
||||
Wartezeiten stapeln sich. MW-1 entfernt die FRUEHERE versehentliche Backpressure (der falsche 120s-Cooldown
|
||||
wirkte als grobe Ratenbegrenzung), routet aber korrekt per In-Flight-Tiefe um belegte Accounts herum statt sie
|
||||
faelschlich zu sperren — strikt besser. Echter Fix (In-Flight-Tiefen-Spread groesser ziehen / globale
|
||||
Web-Parallelitaetsgrenze) = groessere Aenderung, 4.-Bug-Risiko auf Live-Server → deferred. MW-1 reduziert das
|
||||
Pileup-in-Park-Risiko bereits (kein Faux-Lockout merely-busy Accounts).
|
||||
|
||||
## Runde 10 (Concurrency/Locking + Settings/Persistenz-Integritaet) — LETZTE RUNDE (Nutzer: „nach der Runde ist Schluss")
|
||||
4 Finder (hybrid/targetPath-Races, Slot-Accounting, Settings-Migration/Persistenz, Backup-Restore/account-check)
|
||||
→ adversarisch 3 Lenses → Synthese. Workflow wwyqsdrf9: 2 Finder (slot-accounting, settings-persistence) starben
|
||||
an Stream-Idle-Timeout (Riesen-Dateien) → 0/7 confirmed war NUR fuer 2 von 4 Dimensionen ehrlich. KEIN stilles
|
||||
Coverage-Loch akzeptiert → Re-Run wwyqsdrf9b (wfmu4pw2n) mit engerem Scope (Grep-dann-Region statt Ganzdatei).
|
||||
- **Dim hybrid/targetPath + backup/account-check (wwyqsdrf9): 7 Kandidaten, ALLE refutiert (>=2/3), quell-reverifiziert.**
|
||||
Kern-Invarianten halten: synchrones `claimTargetPath` (dl-mgr:6257) = nie zwei Items auf einem Pfad → alle drei
|
||||
Hybrid-„Races" kollabieren zu Sub-Sekunden-Redundanzarbeit, kein Collision/Korruption; jeder Loss-Ausgang von
|
||||
CRC/Extraction-Fail re-queued (`hybridExtractRequeue.add` 11739). Restore ist fail-closed (encrypted-or-reject,
|
||||
Binaerheader „MDD1" wirft in JSON.parse vor normalizeSettings). account-check `valid:false` ist reiner
|
||||
Renderer-Badge (kein src/main liest ihn als Gate; Disable laeuft ueber megaDebridDisabledAccountIds).
|
||||
- **CONFIRMED MED (DOKUMENTIERT, nicht gefixt) BYTE-DROP-RETRY-1 (wfmu4pw2n):** Integrity-/too-small-/tiny-Retry
|
||||
doppelzaehlt eine volle Datei in die Byte-Statistik. `dropItemContribution` (6247) loescht den
|
||||
itemContributedBytes-Eintrag OHNE von session.totalDownloadedBytes abzuziehen (eigener Kommentar: „retry path
|
||||
subtracts on its own"), aber die EINZIGE Subtraktion (9991-9997) liest genau diesen geloeschten Eintrag → 0 →
|
||||
Subtraktion tot → Re-Download addiert N nochmal → (k+1)*fileSize nach k Integrity-Fails. enableIntegrityCheck
|
||||
default true → organisch. Verdict isReal (CORRECTNESS+REPRO), aber BLAST-RADIUS = NUR Statistik: kein
|
||||
Slot/Admission/Semaphore liest diese Counter (grep-belegt), Session-Counter self-healen bei jedem Neustart,
|
||||
nur persistiertes totalDownloadedAllTime + avg-Speed bleiben dauerhaft inflationiert = kosmetisch. NICHT clean
|
||||
(dropItemContribution ueber 26 Call-Sites ueberladen; ~10 im Retry-Bereich; ein 3-Site-Patch liefert
|
||||
partielles/inkonsistentes Accounting = arguably schlimmer; Guard-Test dl-mgr.test.ts:6152 sperrt die
|
||||
Completion-Removal-Semantik) → DOKUMENTIERT mit Rezept (dropItemContribution splitten in ForCompletion/ForRetry
|
||||
nach Klassifikation aller Retry-Sites re-download-vs-terminal). Kosmetisch + nicht-clean → klart Live-Schwelle nicht.
|
||||
- **CONFIRMED MED (GEFIXT) SET-MIG-01 (wfmu4pw2n):** Pre-v1.6.90-Config-Migration tot → Mega-Debrid wird beim
|
||||
ersten Settings-Panel-Save still aus der Provider-Reihenfolge demotet. readSettingsFile (storage.ts:633) merged
|
||||
`{...defaultSettings(), ...parsed}`; defaultSettings setzt megaDebridApiEnabled/WebEnabled:false (constants.ts:50-51)
|
||||
BEVOR normalizeSettings laeuft → der `=== undefined`-Migrationszweig (355-360) ist tot auf dem Disk-Pfad (Git:
|
||||
tot seit v1.6.90/0003d78). Renderer (App.tsx:447-494) gated Mega-Inklusion am false-Flag → persistDraftSettings
|
||||
schreibt eine Mega-bereinigte providerOrder zurueck. Fix: reine `migrateLegacyMegaEnableFlags(parsed)` in
|
||||
readSettingsFile — seedet apiEnabled=preferApi/webEnabled=!preferApi NUR wenn BEIDE Flags im RAW-parsed fehlen
|
||||
UND Creds da sind (Creds-Erkennung wie normalizeSettings: asText(login)&&asText(password)). Lokalisiert, keine
|
||||
normalizeSettings-Signatur-Aenderung, getypt (kein Cast). EHRLICHER Scope (Advisor): rettet NUR Legacy-Configs,
|
||||
die seit dem Upgrade noch NICHT ueber das Settings-Panel neu gespeichert wurden (ein Post-Upgrade-Save schreibt
|
||||
present-false → Trigger feuert nicht mehr) — schmales historisches Fenster, NICHT „rettet die Live-Mega dieses
|
||||
Nutzers". HARTE Grenze (Advisor): Trigger bleibt absent-both; present-false NICHT anfassen (= bewusst-deaktiviert,
|
||||
ununterscheidbar → das geparkte entscheidungen-offen #2-Migrationsrisiko). Rot-bewiesen NICHT-vakuum (Assertion NUR
|
||||
auf den geladenen Flags — providerOrder demotet backend-seitig nicht = waere vakuum; via Temp-Revert rot bestaetigt:
|
||||
apiEnabled true→false). Boundary-Test (bewusst-deaktiviert bleibt false + ohne Creds keine Migration) bleibt beim
|
||||
Revert gruen = unabhaengig. Volle Suite 890 gruen, tsc=6.
|
||||
- **REFUTED (wfmu4pw2n) SET-PERSIST-02:** Residual-Lost-Update nach R2-Generations-Guard — der einzige verlorene
|
||||
Payload ist totalRuntimeAllTimeMs (`<=`-Ratchet, naechste Session re-derived) + contrived Sub-ms-Race der die
|
||||
Prozess-Teardown gewinnen muss → self-healing kosmetisch, nicht erreichbar.
|
||||
- **Coherence-Verdikt:** Concurrency-Accounting + Settings-Persistenz sind kohaerent. Byte-Counter sind
|
||||
telemetry-only, voll entkoppelt von Admission/Slot (grep-belegt) → BYTE-DROP kann nicht stranden/freezen/
|
||||
ueber-admiten. Persistenz kohaerent bis auf EINE benannte Inkohaerenz: Backend normalizeConfiguredProvider
|
||||
(storage.ts:144) mapt „megadebrid"→konkret unabhaengig der Flags, Renderer gated auf dem Flag = Wurzel von
|
||||
SET-MIG-01 (jetzt am Loader gefixt).
|
||||
- **EHRLICHE Coverage-Luecke (vom Audit selbst aufgedeckt, R10-HYB-3):** Die in project_pending genannte
|
||||
Passwort-Cache-Race liegt NICHT in download-manager.ts (dort kein passwordCache/resolvePassword), sondern in
|
||||
src/main/extractor.ts — ausserhalb des Round-10-Scopes. Bewusst NICHT in dieser letzten Runde auditiert (Nutzer:
|
||||
„nach der Runde ist Schluss"); als das eine identifizierte, un-auditierte Concurrency-Seam fuer eine etwaige
|
||||
kuenftige Runde dokumentiert statt still fallengelassen.
|
||||
|
||||
## GOAL-ABSCHLUSS
|
||||
Runde 9 + 10 erledigt (Nutzer-Anpassung). Release v1.7.220 buendelt: 85c8d6b (Synthese C1/C2/RANGE1-Haertung) +
|
||||
MW-1 (HIGH, Web-Selbstcooldown) + SET-MIG-01 (MED, Legacy-Mega-Demotion). Gitea + GitHub-Mirror MIT .exe (4 Assets).
|
||||
Dokumentiert-nicht-gefixt: DL-1, DL-CONCURRENCY-PILEUP, BYTE-DROP-RETRY-1, extractor.ts-Passwort-Cache-Seam.
|
||||
Beim Nutzer (nicht autonom): 60s-Failover-Kappung + gespiegelter Mega-API/Web-Schalter (entscheidungen-offen.md).
|
||||
|
||||
## Nutzer-Nachforderung (nach Goal-Abschluss): die 3 dokumentierten Funde DOCH umsetzen → v1.7.221
|
||||
Nutzer: "dann mach das beides erstmal" (BYTE-DROP-RETRY-1 + DL-1 + DL-CONCURRENCY-PILEUP). Nicht relitigiert OB,
|
||||
nur WIE (Advisor-gefuehrt, je rot-bewiesen, je full-suite gruen + tsc=6 single-pass).
|
||||
- **BYTE-DROP-RETRY-1 (MED) GEFIXT.** Scope-Disziplin (Advisor): NUR Session-Counter + totalDownloadedAllTime,
|
||||
NICHT recordProviderDownloadedBytes/providerDailyUsageBytes (das gated isProviderDailyLimited = Verhalten,
|
||||
und ist nicht provider-keyed → naive Subtraktion wuerde den falschen Provider-Bucket korrumpieren → bewusst
|
||||
ausgeklammert). Mechanismus (a): an den 3 bestaetigten rm-dann-frisch-Sites (Integrity 8979, too-small 9020,
|
||||
tiny 10486) den `dropItemContribution`-Aufruf ENTFERNT → der itemContributedBytes-Eintrag ueberlebt → die
|
||||
bestehende writeMode-"w"-Reconciliation (9991) subtrahiert ihn korrekt (selbst-korrigierend nach writeMode,
|
||||
Append undercounted nicht). Plus: am selben Punkt (9991) `totalDownloadedAllTime -= previouslyContributed`
|
||||
ergaenzt (spiegelt den Add bei 10311; All-Time wurde NIE subtrahiert → doppelte bei JEDEM frischen Re-Download,
|
||||
nicht nur den dropItemContribution-Pfaden). KEINE 23-Site-Reklassifikation (Advisor: Provider-Usage off-limits
|
||||
→ jede Restfehlklassifikation ist bounded Telemetrie). Guard-Test dl-mgr.test.ts:6152 (Completion-Removal behaelt
|
||||
Session-Total) bleibt gruen. Rot-bewiesen NICHT-vakuum, BEIDE Beine einzeln: Integration durch echten
|
||||
Integrity-Fail-Retry (.md5-Manifest, lokaler HTTP-Server serviert wrong-dann-correct), Assert session==1x UND
|
||||
allTime==1x; Bein 1 (All-Time-Zeile raus) → allTime rot (2x) session gruen; Bein 2 (dropItemContribution zurueck)
|
||||
→ session rot (2x).
|
||||
- **DL-1 (LOW) GEFIXT.** Advisor revidierte den frueheren "braucht neue Oberflaeche"-Call: am Rotations-Catch (2789)
|
||||
ist elapsedMs bereits da → Mega-Gate (2072) gespiegelt. abort-ohne-timeout + elapsedMs < getMegaDebridAbortMinRunMs()
|
||||
→ KEIN Key-Cooldown (User-Cancel bestraft den Key nicht); ran-long-enough → DEBRID_LINK_KEY_COOLDOWN_MS (120s)
|
||||
damit der Retry rotiert; throw bailt die Rotation (verhindert auch die zuvor moegliche Transport-Kaskade ueber
|
||||
mehrere Keys bei aborted-Signal). Neuer Test-Getter getDebridLinkKeyCooldownStateForTests. Rot-bewiesen
|
||||
(quick-cancel → null; ohne Fix 15s gesetzt; long-abort → >60s, ohne Fix 15s).
|
||||
- **DL-CONCURRENCY-PILEUP (MED): untersucht → BEREITS STRUKTURELL GELOEST, kein Eingriff (Nutzer-Entscheidung
|
||||
"Akzeptieren").** getSerializedValidatingLimit("megadebrid-web") = Anzahl nutzbarer (nicht-gecoolter) Accounts
|
||||
(dl-mgr 8047-8055); shouldDelayStartForItem erzwingt es in der Kandidatenwahl (8526) → Ueberschuss-Konvertierungen
|
||||
warten als "queued" im Scheduler, NICHT in den per-Account-Single-Flight-Queues; Depth-Spread (debrid 1981-1986)
|
||||
verteilt die erlaubten 1-pro-Account. MW-1 haelt usableAccounts (= das Limit) korrekt hoch. Ein weiterer
|
||||
Scheduler-Eingriff = redundant ODER schaedlich (Ueber-Admission = echter Pileup) → Advisor-4.-Bug-Risiko. Dem
|
||||
Nutzer vorgelegt (AskUserQuestion) → "Akzeptieren, kein Eingriff".
|
||||
|
||||
## Runde 1 (laeuft)
|
||||
- Discover+Verify-Workflow ueber 7 Subsysteme (scheduler-slots, unrestrict-retry, mega-rotation,
|
||||
classify-cooldown, mega-web-token, provider-chain-timeout, account-availability).
|
||||
|
||||
### Meine unabhaengigen Verdachtsfaelle (Cross-Check gegen Workflow)
|
||||
1. **Web-Selbst-Cooldown (Analog zum API-214-Bug, HOCH):** Caller-Timeout = 60s
|
||||
(`DEFAULT_UNRESTRICT_TIMEOUT_MS`, download-manager 114) umschliesst die GANZE Kette.
|
||||
Mega-Web braucht legitim laenger (per-Account-Queue bis 90s + Login + Generate).
|
||||
Feuert die 60s nach >=8s (`MEGA_DEBRID_ABORT_MIN_RUN_MS_DEFAULT`=8000), setzt die
|
||||
Rotation `aborted:debrid` → 120s Account-Cooldown (debrid.ts ~2037-2048), obwohl der
|
||||
Account GESUND ist — die App hat aufgegeben. → Kaskade ueber Accounts. Live im jf.zip
|
||||
belegt: `Mega-Debrid Web | TIMEOUT_COOLDOWN | reason=aborted:debrid | cooldownSec=120`.
|
||||
Fix-Kandidat: (a) per-Provider-Timeout statt globaler 60s; und/oder (b) Caller-Timeout-
|
||||
Abort NICHT als Account-Cooldown werten (EMA-Demotion regelt langsame Accounts bereits),
|
||||
oder nur sehr kurz.
|
||||
2. **Globaler 60s-Timeout kappt Failover (Advisor-bewiesen, HOCH):** download-manager 8759
|
||||
`AbortSignal.any([cancel, timeout])` → bei Provider1-Verbrauch des Budgets abortet das
|
||||
Signal → debrid.ts 3805 `signal.aborted` → throw, kein nextProvider. Fix: per-Provider-
|
||||
AbortSignal.timeout, Stop nur bei USER-Cancel.
|
||||
3. **Exponential-Backoff bis 120s** (generic unrestrict retry) — Item sitzt bis 2 min.
|
||||
Pruefen ob fuer haeufige transiente Faelle zu lang.
|
||||
|
||||
## Bestaetigte Bugs (Workflow R1: 14 confirmed / 11 refuted) — priorisiert
|
||||
- [IN ARBEIT] #1 HIGH Scheduler-Freeze: findNextQueuedItem ohne activeTasks-Guard → synchroner
|
||||
Admission-Loop dreht endlos wenn ein reset/overwrite-Item noch im activeTasks parkt (non-abort-
|
||||
observing await, z.B. Integrity-Check). Fix: `if (this.activeTasks.has(itemId)) continue;`. TDD-Test
|
||||
(Freeze-Repro mit non-abort Mock + resetItems) geschrieben.
|
||||
- #2/#3 MED mega_debrid_cooldown:<ms> Delay verworfen — kein Parser (nur debrid_link_cooldown). Fix:
|
||||
Parser fuer beide Praefixe, queueRetry mit echtem delayMs. (Erklaert Rapid-Retry-trotz-Cooldown im jf.zip.)
|
||||
- #7/#8 MED Mega per-Account Daily-Usage wird am Tagesgrenze NIE resettet → Accounts faelschlich "am
|
||||
Limit" → schrumpft MEIN neues serialized-limit. Fix: megaDebridAccountDailyUsageBytes in
|
||||
ensureProviderDailyUsageFresh resetten.
|
||||
- #4 MED transiente leere Web-Antwort → permanenter until-restart-Park (limitSignal vom generischen
|
||||
"antwort leer"). Fix: limitSignal nur vom echten Daily-Limit (NO_SERVER_RE).
|
||||
- #5 MED Web echte Bad-Credentials erreichen invalid-Branch nicht (werden ewig retried). Fix: echte
|
||||
Web-Login-Fehlerphrasen in invalid-Branch.
|
||||
- #6 MED onefichier/ddownload-Routing ignoriert autoProviderFallback=off. Fix: Guard in catch.
|
||||
- #14 LOW Regex-Ordering classify: quota-Branch shadowt rate_limit. Fix: rate_limit vor quota.
|
||||
- #9 LOW overwrite wipet frisch geclaimten targetPath via altem .finally.
|
||||
- #10 LOW HTTP416 shared counter mit genericErrorRetries.
|
||||
- #11 LOW fresh-retry preempt typed transient handlers.
|
||||
- #12 LOW 15-failure-shelve + shared counters → mehr Retries als retryLimit.
|
||||
- #13 LOW self-poison: queue-wait zaehlt zu elapsedMs → abort-cooldown (Analog zu meinem Web-Verdacht #1).
|
||||
|
||||
## Refutiert / Nicht-Bug (11) — nicht anfassen
|
||||
providerStartReservations dead-state; debrid_link_cooldown cleanup; supprimé-fallthrough; mega-web 180s
|
||||
aborts whole rotation; EMA-removed-premise; quota-no-park asymmetry; connectApi single-flight cancel-couple;
|
||||
per-account queue chain-break (NON-BUG); mega-web slot-hold (NON-BUG); provider abort-vs-timeout heuristic;
|
||||
daily-limit aggregate early-exit.
|
||||
|
||||
## Fixes (TDD, mit Test + Release)
|
||||
### Batch 1 → v1.7.215 (Suite laeuft)
|
||||
- [x] #1 HIGH Scheduler-Freeze: `findNextQueuedItem` activeTasks-Guard. Repro-Test (ohne Fix haengt der
|
||||
Event-Loop so hart, dass nicht mal vitest-Timeout feuert = Freeze empirisch bewiesen). Mit Fix 288ms.
|
||||
- [x] #2/#3 MED parseMegaDebridCooldownRetry (export) + Handler VOR transient/generic branch → Item wartet
|
||||
den ECHTEN Cooldown (min ueber alle Accounts) statt 5s-Busy-Loop. 5 Parser-Tests.
|
||||
- [x] #7/#8 MED megaDebridAccountDailyUsageBytes Reset in ensureProviderDailyUsageFresh (laeuft via
|
||||
getSnapshot, also auch im Stall). Test: Tagesgrenze → leer.
|
||||
- [x] #14 LOW rate_limit-Branch VOR quota (quota matchte "limit" in "rate limit"). Test: rate_limit-Kategorie.
|
||||
- [deferred] #4 empty-response→until-restart-park: 3-consecutive-streak ist reale Mitigation gegen transiente
|
||||
Blips; Mega-empty-Semantik nicht sicher verifizierbar → kein Blind-Change.
|
||||
|
||||
### Strategie-Update (LIVE-Server, Advisor-bestaetigt)
|
||||
- LOW-Fix-Schwelle HOCH: nur fixen bei NULL plausibler Regression UND einem Test der OHNE Fix rot ist.
|
||||
Sonst dokumentieren ("gefunden & charakterisiert" ist valides Audit-Ergebnis). Server laeuft live,
|
||||
auto-update, ~1 TB/h → jede unnoetige Verhaltensaenderung = Risiko.
|
||||
- Releases BUENDELN (alle 2-3 Runden / Roll-up), nicht pro Fix. Weniger Update-Churn auf dem Live-Server.
|
||||
- #5 NICHT raten: conversion.log faengt den echten Web-Login-Fehler-String schon (web-queue-Phase-Detail).
|
||||
Aus naechstem Bundle ernten, dann erst invalid-Phrasen ergaenzen. Kein Phrasen-Halluzinieren.
|
||||
- #13 defer: Web ist seit v1.7.214 nur noch Fallback (API-first), Selbstcooldown trifft kaum mehr;
|
||||
braucht workMs-Threading → groesserer Eingriff, nicht LOW-billig.
|
||||
- Vor Runde 4-5: SYNTHESE-Pass — ist Retry/Cooldown/Rotation END-TO-END kohaerent selbstheilend?
|
||||
|
||||
## Runde 3 (Failover/Reconnect/Cooldown-Lifecycle/Scheduler/IPC-Toggle/Updater) — Workflow wcwztx7e9
|
||||
7 confirmed / 1 refuted. provider-failover-Finder crashte (Socket) → diese Dimension via MEINER
|
||||
unabhaengigen Code-Verifikation abgedeckt (60s-Timeout kappt Failover, debrid.ts 3845 + dl-mgr 8814).
|
||||
|
||||
### Batch 3 → v1.7.217 (GEFIXT, je rot-bewiesener Test)
|
||||
- [x] #R3-1 HIGH (3/3) Self-Cooldown bis Neustart — DER vom Nutzer gemeldete „Tool sperrt sich selbst".
|
||||
(a) limitSignal aus MEGA_DEBRID_NO_SERVER_RE-Zweig entfernt (Hoster-Problem != Account-Limit),
|
||||
(b) until-restart-Park laeuft jetzt zum Tagesreset (lokale Mitternacht) ab statt MAX_SAFE_INTEGER →
|
||||
heilt <=24h selbst. Texte „bis Neustart"→„bis zum Tagesreset". Commit 76b3f99.
|
||||
- [x] #R3-5 HIGH (3/3) Fehlgeschlagenes Update → Queue-Stillstand bis Neustart. runInstallWithResume()
|
||||
(neue reine Funktion) resumt bei started:false UND throw. Commit dfd1926.
|
||||
- [x] #R3-3 MED (3/3) Account-Edit ueberschreibt megaDebridPreferApi. Hardcode entfernt → ...settings
|
||||
reicht Nutzerwahl durch. Commit 1e04b7b.
|
||||
|
||||
### Dokumentiert / NICHT autonom gefixt (Advisor-Disziplin)
|
||||
- #R3-2 HIGH (2/3, UMSTRITTEN) Mega API/Web-Account-Zeilen teilen EINE login-only Enable-Flag →
|
||||
Toggle spiegelt sich (= Nutzer-Report „API aus → Web an"). KEIN Auto-Fix: Daten-Modell-Fix braucht
|
||||
Settings-Migration (kann deaktivierte Accounts re-aktivieren), UI-Collapse = Layout-Redesign (Nutzer
|
||||
UI-Geschmack-sensibel). → DEM NUTZER vorlegen: gemeinsamer Schalter vs. unabhaengige pro-Modus-Flags.
|
||||
- 60s-Failover-Kappung (HIGH, mein Fund, Finder gecrasht) — debrid.ts 3845 wertet JEDEN combined-signal-
|
||||
Abort (cancel ODER 60s-Timeout) als kein-Failover; langsamer Provider1 hungert Provider2 aus, auch ueber
|
||||
Retries. Post-214 (API-first) groesstenteils latent. → eigene Runde: gecrashten Finder ERST neu laufen
|
||||
lassen (unabhaengige Verifikation fehlt), dann per-Provider-Timeout-Design mit Advisor. NICHT in 217.
|
||||
- #R3-6 MED (3/3) Update-Mirror-Failover feuert nie (nur Gitea). NICHT fixen: aendert den Update-Fetch-Pfad
|
||||
= der Kanal, ueber den jeder Fix den Nutzer erreicht; faellt heute sicher aus (App behaelt alte Version).
|
||||
- #R3-4 LOW (2/3) providerPrimary kann auf disabled Mega normalisieren — self-heilt zur Laufzeit. Belassen
|
||||
(Refuter: Fix riskanter als Bug — schreibt persistierte Absicht um).
|
||||
- #R3-7 LOW (3/3) Update-Integritaet hash-only, kein Authenticode — ehrliche Grenze, faellt sicher aus.
|
||||
- REFUTIERT (0/3): all-accounts-parked wirft plain error ohne cooldown-retry-Token.
|
||||
|
||||
## Runde 4 (Failover-Routing-Slice + Entscheidungs-Doku)
|
||||
Follow-on aus R3-Failover-Fund. Advisor-Disziplin: nur die SICHERE Scheibe autonom, der
|
||||
Produkt-Tradeoff geht an den Nutzer (tasks/entscheidungen-offen.md).
|
||||
|
||||
### Autonom gefixt (TDD, rot-bewiesen)
|
||||
- [x] MED Failover-Routing: Manager berechnete bei Provider-Cooldown (>=20 Fehler in Folge,
|
||||
auto-Fallback an) einen Ersatz-Provider (`findFallbackProviderNotInCooldown`), WARF ihn aber
|
||||
weg — `unrestrictLink(item.url, signal)` ohne Hint → debrid.ts baut `order` neu aus
|
||||
providerOrder und fuehrt WIEDER mit dem ausgebremsten Provider1 an (wahrsch. 60s-Timeout
|
||||
verschwendet). Fix: reine `leadProviderChainWith(order, preferred)` (debrid.ts, export) +
|
||||
4. optionaler Param `preferredLeadProvider` an `unrestrictLink`; Manager reicht den Ersatz
|
||||
durch (dl-mgr 8772/8828). REORDER nicht SKIP → ausgebremster Provider bleibt als letzter
|
||||
Notnagel in der Kette, kein Stranding. All-cooled-Fall erreicht `unrestrictLink` gar nicht
|
||||
(else-Zweig queueRetry'd). Tests: integration (control=realdebrid, preferred=debridlink) +
|
||||
3 reine Helper-Tests (null→unchanged, in-order→leads+keeps-all, not-in-order→unchanged).
|
||||
Suite 875 gruen, tsc=6. Commit folgt; HALTEN fuer Roll-up-Release (kein HIGH/dringend).
|
||||
|
||||
### An den Nutzer vorgelegt (NICHT autonom) → tasks/entscheidungen-offen.md
|
||||
- 60s-Failover-Kappung (HIGH): A) pro-Provider-Timeout (Failover immer, aber bis 3×60s
|
||||
Worst-Case, Drehregler ueber Pro-Provider-Wert) vs B) globales Budget mit Failover-Reserve
|
||||
(langsamer Provider1 frueher abgeschnitten). Produkt-Tradeoff = Nutzerwahl. Post-214 weitgehend latent.
|
||||
- Gespiegelter Mega API/Web-Schalter (HIGH, #R3-2): gemeinsamer Schalter vs unabhaengige
|
||||
pro-Modus-Flags (Migration + UI-Redesign noetig, Nutzer UI-sensibel).
|
||||
|
||||
### SYNTHESE-Pass (Cross-Layer: Manager-Cooldown-Keys vs Debrid Account/Daily-Park) — Workflow wrfxpjudj
|
||||
2 Kandidaten, 1 confirmed (3/3), 1 refuted (0/3). Advisor-Hypothese (Key-Mismatch) WIDERLEGT.
|
||||
- **REFUTED (0/3) KSM-1 Key-Mismatch:** normalizeProviderOrder (storage.ts:144/411) speichert IMMER
|
||||
den aufgeloesten 'megadebrid-api'/'-web', NIE den virtuellen 'megadebrid'. Also fallen
|
||||
Cooldown-WRITE (recordProviderFailure), CHECK (getProviderFailureKeyForItem), CLEAR und READ
|
||||
(findFallbackProviderNotInCooldown) auf denselben aufgeloesten Key → KEINE Divergenz. Der
|
||||
986fbab-Routing-Fix ist fuer den Mega-Fall nachweislich sicher (No-Stranding haelt).
|
||||
- **CONFIRMED (3/3) LOW MEGA-UNTILRESTART-MISCLASS (GEFIXT):** Der untilRestart-Park-Throw
|
||||
(debrid.ts:2167) trug KEINEN Maschinen-Token → Manager-Catch klassifiziert ihn als generischen
|
||||
Unrestrict-Fehler (isUnrestrictFailure matcht "mega_debrid") → Retry alle ~2min den ganzen Tag
|
||||
+ recordProviderFailure (Circuit-Breaker-Verschmutzung), statt einmal bis Tagesreset zu parken.
|
||||
Default-Config (retryLimit=0=∞) self-heilt um Mitternacht, KEIN Stranding. Genau die
|
||||
Round-3-untilRestart-Park-Absicht, die hier unterlaufen wurde. Predates 986fbab.
|
||||
Fix: debrid.ts emittiert jetzt `mega_debrid_reset_park:<msBisReset>:` (megaDebridDailyParkExpiry);
|
||||
neue reine parseMegaDebridResetPark (kein 15min-Clamp, 26h-Cap) + Catch-Branch VOR der
|
||||
Cooldown-Klassifikation queued bis Tagesreset OHNE recordProviderFailure. Rot-bewiesen
|
||||
(Parser-Tests + debrid-Token-Assertion). tsc=6.
|
||||
- **End-to-end-Verdikt:** Retry/Cooldown/Rotation/Failover ist unter Default-Config kohaerent
|
||||
self-heilend (jeder Park hat Zeitgrenze + Auto-Clear). Einzige Rest-Inkohaerenz war die
|
||||
Layering-Naht oben (in-memory untilRestart-Park unsichtbar fuer getAvailableMegaDebridAccounts
|
||||
+ isProviderDailyLimited) — mit dem konservativen String-Klassifikations-Fix geschlossen.
|
||||
Der breitere Fix (Selektierbarkeit cooldown-aware machen) bewusst NICHT gemacht (groesserer
|
||||
Blast-Radius auf Live-Server).
|
||||
|
||||
## Boot-Verifikation (Advisor-Punkt: Suite gruen != App bootet)
|
||||
Smoke-Test des gebauten 1.7.218-Binaries (release\win-unpacked, throwaway userData, leere Session):
|
||||
laeuft >10s stabil, spawnt die normalen 4 Electron-Prozesse (main+GPU+renderer+utility), schreibt
|
||||
userData/runtime → BOOTET. Boot-Pfad (main.ts-Boot, app-controller-Init, Window/IPC-Registrierung)
|
||||
von diesem Audit NICHT angefasst; alle editierten Methoden sind Download-Zeit (von der 884er-Suite
|
||||
importiert+konstruiert). Risiko Startup-Regression empirisch ausgeschlossen.
|
||||
|
||||
## Advisor-Leitlinie (Stand Runde 8)
|
||||
- Nach Runde 8 KEINE weitere Discovery-Runde, sondern ein SYNTHESE/Regressions-Pass ueber den
|
||||
KUMULATIVEN Diff (Interaktion der 6 gestagten/releasten Fixes) — Confirmed-Yield faellt (R5:0/R6:1/R7:1),
|
||||
Risiko ist jetzt Fix-Interaktion, nicht unentdeckte Bugs.
|
||||
- Advisor NICHT auf Kadenz pollen — nur bei echter Ship/Fix-Entscheidung mit neuer Info.
|
||||
- Die 2 HIGH Produkt/UI-Entscheidungen bleiben beim Nutzer (nicht autonom shippen).
|
||||
|
||||
## Release-Status: v1.7.219 RELEASED (Gitea 6ae9f5d + GitHub-Mirror df3670a)
|
||||
Roll-up Runde 7+8: be15419 MED VP-1 dt.-Tonspur + eacd0c9 HIGH RANGE-1 Silent-Corruption + REWIND-TRUNCATE.
|
||||
HIGH rechtfertigt Release. Advisor explizit cleared (vor Implementierung konsultiert, Check A verifiziert).
|
||||
Mirror kuratiert ohne CLAUDE.md/tasks/ (Leak-Check sauber). NAECHSTER SCHRITT (Advisor): Synthese/Regressions-
|
||||
Pass ueber kumulativen Diff aller Session-Fixes (Interaktion), KEINE weitere Discovery-Runde.
|
||||
|
||||
## Release-Status: v1.7.218 RELEASED (Gitea bbb9355 + GitHub-Mirror 94d143b)
|
||||
Roll-up Runde 4+5+6: 986fbab MED Provider-Cooldown-Routing + 03c908b No-Stranding-Test +
|
||||
f1e35f5 LOW Mega-Tagesreset-Park + d2a1b83 HIGH Shelve-Loop-RetryLimit. HIGH rechtfertigt das
|
||||
Release. Gitea (Live-Update-Quelle): .../releases/tag/v1.7.218. GitHub-Mirror (kuratierter
|
||||
Single-Commit ohne CLAUDE.md/tasks/, Leak-Check sauber): Sucukdeluxe/multi-debrid-downloader v1.7.218.
|
||||
Advisor war die GANZE Session ueberlastet → autonom released auf Basis: 3x rot-bewiesene Tests
|
||||
(je per Temp-Revert verifiziert), volle Suite 882 gruen, tsc=6, unabhaengige Code-Verifikation +
|
||||
Multi-Agent-adversarisch, Routing-Fix frueher advisor-gesegnet, Praezedenz 215/216/217 autonom.
|
||||
Runde-5/6-Charakterisierungen (PP-SEM-1 benign, DISK-1 deferred, deferred-LOW-Cluster #10/#11/#13
|
||||
benign) NICHT released — dokumentiert.
|
||||
|
||||
## SYNTHESE/Regressions-Pass (Fix-Interaktion ueber kumulativen Diff v1.7.212..HEAD) — Workflow wrexz7mdf
|
||||
3 Reviewer (Catch-Cascade / Streaming / Provider-Chain) + adversarisch verifizieren. 3 confirmed (1 MED, 2 LOW),
|
||||
2 refuted. Bestaetigt: Rest komponiert sauber; Risiko war (wie Advisor sagte) Fix-Interaktion, nicht neue Bugs.
|
||||
- **CONFIRMED MED (GEFIXT) C1 reset_park maskiert kurzen Cooldown:** Bei BEIDEN Mega-Modi aktiv aggregiert die
|
||||
Provider-Kette beide Token (`mega_debrid_reset_park:LONG` von API-Park + `mega_debrid_cooldown:30000` von
|
||||
Web-Cooldown). Manager prueft reset_park VOR cooldown → Item ~24h geparkt obwohl Web in ~30s erholt. Fix:
|
||||
Cooldown-Zweig VOR reset_park (kuerzerer Delay gewinnt). Rot-bewiesen (Single-Pass-processItem-Test: Aggregat
|
||||
beider Token → retryAfter < 60s, fullStatus "Cooldown" nicht "Tagesreset"; ohne Reorder ~24h).
|
||||
- **CONFIRMED LOW (GEFIXT) C2 Token-Truncation:** reset_park-Token konnte von compactErrorText (220-Zeichen-Cap)
|
||||
abgeschnitten werden, wenn Mega nicht Lead + vorheriger Provider verbose → Park still uebersprungen. Fix: Mega-
|
||||
Token aus der UNGEKUERZTEN error.message parsen (megaRawError). (Im selben Edit wie C1.)
|
||||
- **CONFIRMED LOW (GEHAERTET + Doku korrigiert) RANGE1-TRUNCATEFAIL-FINALIZE:** Mein 219-Commit OVERCLAIMte —
|
||||
bei FEHLGESCHLAGENEM finalem Rewind-truncate finalisiert tryFinalizeItemFromDisk(9238) die Garbage-Datei
|
||||
(size==totalBytes) VOR dem Re-Entry. NET-NEUTRAL vs pre-audit (v1.7.212 hatte denselben Finalize), KEINE
|
||||
Regression. Haertung: bei truncate-Fehler jetzt rmSync(Teil-Datei)+downloadedBytes=0 → Finalize lehnt ab →
|
||||
sauberer Re-Download (best-effort; faellt der rm auch, bleibt es net-neutral). Macht den 219-Claim wahr.
|
||||
- **Refuted (1/3 je):** reset_park von shelve-guard/unrestrictRetries-Exhaustion unter finite retryLimit
|
||||
verdraengt — beide nicht bestaetigt (compound/nicht erreichbar).
|
||||
- **Capstone-Verdikt:** Die 8-Runden-Fixes komponieren — inner-rewind success-only-reset und final-rewind sind
|
||||
per-attempt mutually exclusive; prealloc-reconcile double-truncated nicht nach erfolgreichem Rewind; Routing
|
||||
+ cooldown + park kohaerent NACH C1/C2-Fix. Diese 3 Synthese-Fixes buendeln zu v1.7.220.
|
||||
|
||||
## Runde 8 (Byte-Streaming downloadToFile: Range/Resume/Append/Truncation) — Workflow wwjz4srkq
|
||||
3 Finder + adversarisch verifizieren. 3 confirmed (1 HIGH + 2 MED, alle Silent-Corruption-Familie), 1 refuted.
|
||||
Advisor VOR Implementierung konsultiert (HIGH-Hot-Path) — Design + Check-A-Branch (totalBytes-null) bestaetigt.
|
||||
- **CONFIRMED HIGH (GEFIXT, known-total) RANGE-1 Silent-Mid-File-Corruption:** Auf dem LETZTEN inneren Versuch
|
||||
wird ein injizierter Garbage-Tail nicht zurueckgespult (`attempt < maxAttempts`-Guard greift nicht),
|
||||
resumeRewindBytesNextAttempt ist funktions-lokal (ueberlebt downloadToFile-Re-Entry nicht), der Outer-Handler
|
||||
nimmt fuer terminated-class den generic-retry-Zweig (kein File-Delete), und der Fresh-Link-Resume haengt
|
||||
echte Bytes NACH dem Garbage an → exakt-laengen-Datei besteht die Length-only-Completion-Pruefung; bei
|
||||
manifestlosen .mkv/.mp4 nie erkannt. Fix: Rewind-vor-Throw am Exhaustion-Punkt (truncate letzte
|
||||
RESUME_REWIND_BYTES + downloadedBytes ZUERST setzen → binary-Re-Entry-prealloc-reconcile robust auch bei
|
||||
truncate-Fehler), GEGATED auf `totalBytes != null && > 0`. Check A verifiziert: known-total → rewound
|
||||
size < totalBytes=minBytes → tryFinalizeItemFromDisk(9238) REJECTET → Re-Entry ueberschreibt Garbage.
|
||||
EHRLICHER Scope (Advisor): GEFIXT fuer known-total Medien (Debrid liefert fast immer fileSize);
|
||||
**null-total behaelt die separate, vor-bestehende Silent-Corruption** unter dem dokumentierten
|
||||
Size-only-Validation-Blindspot (durch Rewind NICHT fixbar — kein Laengensignal; nur Hard-Reset wuerde
|
||||
helfen, groesserer Eingriff). Rot-bewiesen: Cross-Call-Test (final-attempt Garbage → Exhaustion →
|
||||
queueRetry → 2. downloadToFile → CONTENT-Gleichheit); ohne Fix Length-Assert gruen + Content-Assert rot.
|
||||
- **CONFIRMED MED (GEFIXT) REWIND-TRUNCATE-FAIL:** Das `finally` setzte resumeRewindBytesNextAttempt=0
|
||||
UNBEDINGT, auch wenn die Rewind-truncate (9633) warf → transienter win32-EBUSY/AV-Lock-Fehler liess den
|
||||
Garbage-Tail + cleart das Flag (nie retried). Fix: Reset NUR im Success-Branch → fehlgeschlagenes Rewind
|
||||
wird naechsten Versuch erneut probiert. Defensive Haertung (Advisor "ship it"); Happy-Path von Test 1113
|
||||
+ RANGE-1-Test abgedeckt.
|
||||
- **DOKUMENTIERT, nicht gefixt (MED, schwaechste, Workload-immun) PREALLOC-ZEROS-ACCEPTED:** win32-Prealloc-
|
||||
Nullen am Ende als komplett akzeptiert fuer NICHT-binaere Typen (1MB-Slack) wenn truncate skip/faellt.
|
||||
Dominante Medien/Archive sind immun (threshold=0). Narrow Conjunction (non-binary >20MB, Gap<1MB, Crash/
|
||||
truncate-fail). Fix bekannt (binary-strict footprint im 416-accept + recovery-finalize), aber deferred.
|
||||
- **Refuted (1/3) TRUNC-1:** fsync auf resume-append fehlt — als Power-Loss-Edge eingestuft, nicht confirmed.
|
||||
- **Cross-cutting (Advisor: NICHT jetzt anfassen):** Size-only-Completion-Validation ist der gemeinsame
|
||||
Blindspot; kein billiger Content-Check fuer manifestlose Medien → validateDownloadedFileCompletion NICHT
|
||||
umbauen (Risiko 4. Bug). Punkt-Fixes sind korrekt; Blindspot bleibt langfristiges Item.
|
||||
|
||||
## Runde 7 (Post-Download: Extraction + Video-Processor + Companion/Orchestrierung) — Workflow wcxk08n5i
|
||||
3 Finder + adversarisch verifizieren. 2 confirmed, 0 refuted. Schliesst den deferred-Extraction-Cluster ab.
|
||||
- **CONFIRMED MED (GEFIXT) VP-1 Falsche Tonspur:** isGermanStream (video-processor.ts:99-108) wertet die
|
||||
Titel-Regex /\b(german|deutsch)\b/ fuer JEDEN nicht-deutsch-getaggten Stream — NICHT auf "Sprach-Tag fehlt"
|
||||
gegated, obwohl der Kommentar (104-106) genau das als Absicht nennt. pickAudioTrack (124, EINZIGER Caller)
|
||||
nimmt den ERSTEN Treffer → eine eng-Spur mit Titel "...German..." vor der echten ger-Spur GEWINNT → Remux
|
||||
behaelt Englisch, verwirft die korrekte dt. Spur, ersetzt das Original atomar in-place (500) und strippt
|
||||
.DL. → irreversibler Datenverlust + falsche Sprache, als Erfolg gemeldet. Single-Trigger (nicht compound).
|
||||
Betrifft genau die vom Nutzer bestaetigte dt.-Tonspur-Funktion [[project_german_audio_feature]]. Fix:
|
||||
`if (lang) return false;` VOR dem Titel-Fallback (deckt sich mit der Kommentar-Absicht). Rot-bewiesen
|
||||
(2 Tests: eng-Titel-"German" vs echte ger → audioRelIndex 1; eng-Titel-"Deutsch entfernt" → skip). tsc=6.
|
||||
- **DOKUMENTIERT, nicht gefixt (LOW compound) PP-1/#13 Companion-Overwrite:** renameCompanionFiles/
|
||||
moveCompanionFiles (dl-mgr 3735/3791) ohne Uniqueness-Guard (anders als MKV via buildUniqueFlattenTargetPath);
|
||||
cross-volume copyFile ohne COPYFILE_EXCL (3696) → ueberschreibt einen vorhandenen Orphan-Companion still
|
||||
(nur .srt/.idx/.nfo, nie Archiv/Video). Compound (Orphan + Namenskollision), Sekundaerdateien, Integrations-
|
||||
TDD noetig → dokumentiert. Fix bekannt (Uniqueness-Guard spiegeln).
|
||||
- **Deferred-Extraction-Cluster re-klassifiziert (aus Runde 2):** #10 CRC-Kleindatei-Delete BENIGN (failed
|
||||
Archive aus cleanupSources ausgeschlossen, nicht geloescht); #12 7z-Exit-1-als-Erfolg BENIGN (nur Erfolg
|
||||
wenn KEINE Error-Marker — echte CRC/Korruption korrekt als Fehler); #11 resume-empty-output NOT-CONFIRMED
|
||||
(compound, unbewiesene Hybrid-Collect-Ordering-Annahme); #13 = PP-1.
|
||||
|
||||
## Runde 6 (Retry/Backoff/Error-Klassifikations-State-Machine + Disk/IO) — Workflow wqurq83ma
|
||||
3 Finder + adversarisch verifizieren. 1 confirmed (2/3), 3 refuted. Schliesst den deferred-LOW-Cluster ab.
|
||||
- **CONFIRMED HIGH (GEFIXT) SHELVE-LOOP-RETRYLIMIT:** Bei FINITEM retryLimit>=5 sind alle drei Per-Klasse-
|
||||
Caps = retryLimit; die 15-Failure-Shelve-Zweige (dl-mgr 9166 stall + 9352 error) feuern aber auf
|
||||
hartkodiertem `sum>=15` OBERHALB der Per-Klasse-Terminal-Fails und HALBIEREN danach alle Counter →
|
||||
Per-Klasse-Caps werden nie gleichzeitig ueberschritten → Item failt NIE, schleift ewig, item.retries
|
||||
waechst ueber das konfigurierte Limit, Slot gestrandet, providerFailures.delete besiegt wiederholt den
|
||||
Circuit-Breaker → Hoster-Hammering. resetStaleRetryState rettet nicht (<=90s-Re-Admit haelt updatedAt
|
||||
frisch < 10min-Stale). Default retryLimit=0=∞ NICHT betroffen (Shelve ist dort der gewollte Park-Backstop).
|
||||
Fix: in BEIDEN Shelve-Zweigen vor dem queueRetry `if (configuredRetryLimit > 0 && item.retries >=
|
||||
configuredRetryLimit)` → terminal failen statt requeue (∞-Modus unberuehrt). Rot-bewiesen (Single-Pass-
|
||||
Integrationstest: genericErrorRetries=15 vorgeseedet + injizierter Generic-Error → status "failed" statt
|
||||
requeue). tsc=6.
|
||||
- **Deferred-LOW-Cluster re-klassifiziert (aus Runden 1-2):** #10 HTTP416-shared-counter BENIGN (maxHttp416Retries
|
||||
eigenes Budget, speist Shelve-Summe NICHT); #11 fresh-retry-preempt BENIGN (One-Shot-Booleans);
|
||||
STALL-CAP-OFF-BY-ONE real aber +1 (Zutat von CONFIRMED-1, kein eigener Strand); #13 queue-wait→elapsedMs
|
||||
BENIGN (queueRetry resettet attempts/updatedAt, Stall-Detektor misst nur aktiven Download). #12 = der
|
||||
gefixte Bug. DISK-1 (ENOSPC/EACCES via Generic-Budget) kein eigener Bug (in finite vom Cap + diesem Fix
|
||||
begrenzt; in ∞ by-design) → optionale Klassifikations-Erweiterung, deferred bis Nutzer Full-Disk-Hammering meldet.
|
||||
|
||||
## Runde 5 (unberuehrte Subsysteme: auto-reconnect, Scheduler-Fairness, Crash/Persistenz) — Workflow w7woztsxx
|
||||
3 Finder (reconnect / scheduler / persistence) → adversarisch verifizieren (3 Lenses). 1 Kandidat, 0 confirmed.
|
||||
**Sauberes „kein bestaetigter Bug"-Ergebnis.** Auto-reconnect-Resume, Slot-Accounting/Fairness und
|
||||
Crash-Recovery/targetPath-Lifecycle sind solide (jeder Park/Slot hat Auto-Clear, stop/reset/abort drainen
|
||||
Waiter + nullen Counter).
|
||||
- **DOKUMENTIERT, nicht gefixt (LOW, benign): PP-SEM-1** Post-Process-Semaphore (acquire/releasePostProcessSlot,
|
||||
~7269-7304). Mechanismus real: ein geweckter Waiter, dem ein Fast-Path-Queue-Jumper im Microtask-Gap den
|
||||
Slot klaut, laeuft trotzdem (Re-Check nach next.resolve() nicht autoritativ) und released spaeter einen nie
|
||||
inkrementierten Slot → packagePostProcessActive UNTER-zaehlt um 1/Vorkommen. Wirkung: **Ueber-Admission**
|
||||
(mehr als maxParallelExtract parallele Extraktionen) = gebundene CPU/IO-Verschwendung, NIE Strand/Loss/
|
||||
Korruption. Der einzige Strand-Pfad (`<=0`-Guard skippt Waiter-Wake) war NICHT organisch erreichbar (nur
|
||||
durch manuelles Nullen des Counters). Self-heilt bei jedem stop/reset/drain. Fix bekannt (Re-Check
|
||||
autoritativ machen ODER Increment in den Releaser ziehen) + rot-testbar, aber benign → kein Live-Server-
|
||||
Hotfix wert (LOW-Fix-Schwelle: Wirkung ist Verschwendung, keine Korrektheit).
|
||||
|
||||
## Runde 2 (Download-Ausfuehrung: stream/resume/disk/integrity/extract/persist) — Workflow whspc8ddv
|
||||
14 confirmed / 7 refuted (>=2/3 adversarisch). Alle HIGH/MED unten unabhaengig am echten Code
|
||||
verifiziert (Zeilen zitiert) bevor gefixt. Jeder Fix mit rot-bewiesenem Test, tsc bleibt 6.
|
||||
|
||||
### Batch 2b → noch nicht released (buendeln, dann v1.7.216)
|
||||
- [x] #R2-1/4 HIGH Pre-alloc-stat-Reconciliation blaeht `written` auf Padding-Groesse auf → stille
|
||||
Null-Byte-Korruption auf win32. reconcileFinalizedSize() (download-completion.ts, rein+getestet),
|
||||
nur noch ABWAERTS-Korrektur bei preAllocated. Commit 2646cba.
|
||||
- [x] #R2-3 HIGH Nicht-Archiv-".001" ohne Signatur wurde als extrahiert gezaehlt → ganze .00x-Familie
|
||||
beim Cleanup geloescht (Datenverlust). skippedNonArchives (pathSetKey) aus cleanupSources gefiltert,
|
||||
frisch + resume. End-to-end-Test. Commit 56bae4a.
|
||||
- [x] #R2-9 MED + #R2-14 LOW Settings-async-Writer ohne Generations-Schutz → Lost Update; shutdown rief
|
||||
cancelPendingAsyncSaves nicht. Eigener syncSettingsSaveGeneration (Spiegel des Session-Pfads) +
|
||||
cancel in shutdown. Commit 1a33fc2.
|
||||
- [x] #R2-6 MED Teildatei verwaist beim Entfernen eines laufenden Downloads (catch-early-return vor
|
||||
Cancel-Cleanup). rmSync im catch vor dem return (nach Stream-Close, kein Race). Commit 2b639b7.
|
||||
- [x] #R2-8 MED Hash-Manifest: Pro-Zeile-Algorithmus von Dateiendung ueberschrieben → gute Datei
|
||||
geloescht bei fehl-etikettiertem Manifest. parseHashLine-Algorithmus uebernehmen. Commit 4578991.
|
||||
- [x] #R2-7 MED Startup-Dedup ersetzt gute kanonische Datei durch kleineres Duplikat (+ EXDEV-Loss-
|
||||
Fenster). Size-Guard (kanonisch >= Duplikat → behalten) + rename-zu-.dedupbak-Reihenfolge mit
|
||||
Restore. Commit folgt nach voller DM-Suite.
|
||||
- [deferred/dokumentiert] #R2-5 stream-end akzeptiert truncated download (ohne Laengensignal nicht
|
||||
entscheidbar; Web ist post-214 nur Fallback) — nur WARN-Log sinnvoll, kein sicherer Fix.
|
||||
- [deferred/dokumentiert] #R2-10 CRC-verifizierte Kleindatei vom suspicious-small-Heuristik geloescht;
|
||||
#R2-11 Resume-empty-output-Bypass; #R2-12 7z-Exit-1-Warnung als Erfolg; #R2-13 Companion-.srt/.nfo-
|
||||
Overwrite. Je narrow/heuristisch → charakterisiert, nicht blind gefixt (LIVE-Server-Schwelle).
|
||||
|
||||
### Batch 2 (commit, noch nicht released — buendeln mit Runde-2-Findings)
|
||||
- [x] #6 MED onefichier/ddownload-catch respektiert autoProviderFallback=off (Guard nach abort-rethrow).
|
||||
Test: 1fichier KO + Fallback aus → reject, mega getLink NICHT aufgerufen (rot-ohne-Fix beweisbar).
|
||||
ACHTUNG-Notiz: replace_all matchte faelschlich auch den getLinkInfos-Filename-catch (~3614) → revertet
|
||||
(Filename-Aufloesung muss nicht-fatal bleiben). Nur die zwei Hoster-catch-Bloecke geaendert. Commit 21fb09b.
|
||||
- [deferred LOW, dokumentiert statt blind-fix] #9 overwrite targetPath-wipe, #10 HTTP416 shared counter,
|
||||
#11 fresh-retry preempt typed handlers, #12 shelve+shared counter, #13 queue-wait→elapsedMs, #5 Web-bad-creds.
|
||||
→ je nur fixen wenn rot-ohne-Fix billig beweisbar + null Regression; sonst bleibt's charakterisiert.
|
||||
164
tasks/entscheidungen-offen.md
Normal file
164
tasks/entscheidungen-offen.md
Normal file
@ -0,0 +1,164 @@
|
||||
# Offene Entscheidungen für dich (Audit-Loop 2026-06-17)
|
||||
|
||||
Diese zwei Punkte habe ich BEWUSST nicht autonom „gefixt", weil jede Lösung
|
||||
einen Produkt-/Geschmacks-Kompromiss enthält, den du entscheiden solltest, nicht ich.
|
||||
Beide sind verifiziert (Code-Zitat + Szenario), nur die Richtung ist deine Wahl.
|
||||
|
||||
---
|
||||
|
||||
## 1. Failover-Kappung durch globalen 60-Sekunden-Timeout (HIGH)
|
||||
|
||||
**Was passiert (belegt):**
|
||||
`download-manager.ts` baut EINEN Timeout fürs gesamte Unrestrict:
|
||||
```
|
||||
const unrestrictTimeoutSignal = AbortSignal.timeout(getUnrestrictTimeoutMs()); // 60s
|
||||
const unrestrictedSignal = AbortSignal.any([active.abortController.signal, unrestrictTimeoutSignal]);
|
||||
```
|
||||
Dieses EINE Signal geht an die komplette Provider-Kette in `debrid.ts`. Die 60s sind
|
||||
also ein Budget für ALLE Provider zusammen, nicht pro Provider. Wenn Provider 1
|
||||
(z.B. Mega-Web mit Account-Queue) das Budget verbraucht, dann sieht Provider 2 ein
|
||||
bereits abgelaufenes Signal → `debrid.ts` wertet den Abbruch als „kein Failover" und
|
||||
wirft, ohne Provider 2 echt zu versuchen.
|
||||
|
||||
**Aktuell weitgehend latent:** Seit v1.7.214 wird die API zuerst probiert (schneller Pfad),
|
||||
Mega-Web ist nur noch Fallback. Das Szenario „langsamer Provider 1 hungert Provider 2 aus"
|
||||
trifft in der Produktion derzeit selten. Deshalb dokumentiert statt dringend gefixt.
|
||||
|
||||
**Deine Entscheidung — zwei Richtungen (gleiche Spannung, andere Seite):**
|
||||
|
||||
- **A) Pro-Provider-Timeout:** Jeder Provider bekommt sein eigenes frisches Budget.
|
||||
Failover bekommt IMMER einen echten Versuch.
|
||||
*Kosten:* Worst-Case-Wartezeit pro Item steigt. Das Budget ist dabei ein DREHregler, kein
|
||||
fixer Wert: 60s/Provider = bis 180s Worst-Case (3 Provider), 30s/Provider = bis 90s usw.
|
||||
Du akzeptierst langsameres Worst-Case-pro-Item für vollständigeres Failover — und stellst
|
||||
über den Pro-Provider-Wert ein, wie viel langsamer.
|
||||
|
||||
- **B) Globales Budget behalten, aber Slice für Failover reservieren:** Provider 1 wird auf
|
||||
z.B. 35s gedeckelt, damit garantiert Zeit für Provider 2 bleibt.
|
||||
*Kosten:* Ein legitim langsamer Provider 1 (Mega-Web-Account-Queue bis ~90s) wird früher
|
||||
abgeschnitten → mehr Failover, auch wenn Provider 1 noch erfolgreich gewesen wäre.
|
||||
|
||||
Kernfrage, die nur du beantworten kannst: **Wie lange darf ein Item bei einem langsamen
|
||||
aber funktionierenden Provider hängen, bevor wir ihn zugunsten des nächsten aufgeben?**
|
||||
|
||||
---
|
||||
|
||||
## 2. Gespiegelter Mega API/Web-Schalter (HIGH, in Runde 3 als 2/3 bestätigt)
|
||||
|
||||
**Was passiert (belegt):** Die Mega-Debrid API- und Web-Account-Zeilen teilen sich EINE
|
||||
login-only Enable-Flag (`hasMegaDebridCredentials` gilt für beide; die Pro-Modus-Auswahl
|
||||
läuft über `isMegaDebridModeEnabled(settings, "api"|"web")`). Dein Report: „API ausschalten
|
||||
schaltet Web an" — weil der Schalter sich spiegelt.
|
||||
|
||||
**Warum ich es NICHT autonom geändert habe:**
|
||||
- Daten-Modell-Fix (echte unabhängige Flags) braucht eine Settings-Migration, die
|
||||
deaktivierte Accounts versehentlich re-aktivieren könnte.
|
||||
- Die saubere UI-Variante (zwei echte unabhängige Schalter) ist ein Layout-Redesign — und
|
||||
du bist UI-Geschmack-sensibel; das will ich nicht ungefragt umbauen.
|
||||
|
||||
**Deine Entscheidung:** Ein gemeinsamer Schalter (API+Web zusammen an/aus, klar beschriftet)
|
||||
ODER zwei unabhängige Pro-Modus-Schalter (mehr Kontrolle, aber UI + Migration nötig)?
|
||||
|
||||
---
|
||||
|
||||
## Erledigt in dieser Runde (zur Info, kein Handlungsbedarf)
|
||||
|
||||
- **Download-Statistik zählt eine Datei nach einem Integritäts-/Zu-klein-Neuversuch nicht mehr doppelt:**
|
||||
Schlug eine Datei die CRC-/Hash-Prüfung fehl (oder kam zu klein an) und wurde komplett neu geladen, wurde die
|
||||
Dateigröße bisher pro Versuch erneut in die Statistik addiert — die Anzeige „insgesamt heruntergeladen" (Session
|
||||
und Gesamt-Zähler) sowie die daraus berechnete Durchschnittsgeschwindigkeit waren dadurch bei flatterhaften Hostern
|
||||
um die jeweilige Dateigröße aufgebläht (bei großen Archiven mit wiederholten CRC-Fehlern um mehrere GB). Jetzt zählt
|
||||
jede gelieferte Datei genau einmal. Reine Anzeige-/Statistik-Korrektur — Slot-Vergabe, Tageslimits und der
|
||||
Download-Ablauf waren nie betroffen (die Tageslimit-Zähler werden bewusst nicht angefasst, da sie die Provider-Auswahl
|
||||
steuern und der echte Datenverkehr über die Leitung ging). Rot-bewiesener Test, beide Zähler einzeln geprüft.
|
||||
|
||||
- **Debrid-Link: ein abgebrochener Vorgang sperrt den Key nicht mehr unnötig:**
|
||||
Wenn du einen Vorgang abgebrochen hast (oder der Gesamttimeout zuschlug), bevor echte Arbeit lief, bekam der
|
||||
Debrid-Link-Key bisher trotzdem eine 15-Sekunden-Sperre — bei mehreren Abbrüchen in Folge konnte das sogar über
|
||||
mehrere Keys kaskadieren und eine längere providerweite Sperre auslösen. Jetzt wird ein schneller Abbruch (vor der
|
||||
Mindest-Laufzeit) nicht mehr als Key-Fehler gewertet: keine Sperre. Lief der Vorgang dagegen lange genug und brach
|
||||
dann ab (echter langsamer/hängender Key), wird er weiterhin gesperrt, damit der nächste Versuch sauber auf den
|
||||
nächsten Key rotiert. Spiegelt exakt das Verhalten, das es bei Mega-Debrid schon gibt. Rot-bewiesener Test.
|
||||
|
||||
- **Mega-Konvertierungs-Stau (geprüft, kein Eingriff nötig):** Die Zahl gleichzeitiger Mega-Umwandlungen ist bereits
|
||||
auf die Anzahl nutzbarer Accounts gedeckelt — Überschuss wartet sauber im Scheduler statt sich in den Account-
|
||||
Warteschlangen zu stapeln, und der oben beschriebene Mega-Web-Fix hält dieses Limit jetzt korrekt hoch. Ein
|
||||
zusätzlicher Eingriff wäre überflüssig oder würde durch Über-Vergabe erst echten Stau erzeugen. Auf deine
|
||||
Entscheidung hin daher bewusst NICHT verändert.
|
||||
|
||||
- **Alte Konfiguration: Mega-Debrid fällt nach einem Upgrade nicht mehr still aus der Provider-Reihenfolge:**
|
||||
Eine Konfigurationsdatei, die noch von einer sehr alten Version (vor v1.6.90) stammt, kannte die getrennten
|
||||
Mega-Debrid „API aktiv"/„Web aktiv"-Schalter noch nicht. Beim Laden wurden diese fehlenden Schalter still auf
|
||||
„aus" gesetzt, obwohl Mega-Zugangsdaten vorhanden waren — und sobald man danach das erste Mal die Einstellungen
|
||||
speicherte, wurde Mega-Debrid dadurch lautlos aus der Provider-Reihenfolge entfernt. Jetzt wird beim Laden einer
|
||||
solchen alten Datei erkannt, dass die Schalter komplett fehlen, und Mega-Debrid passend zu deiner Bevorzugung
|
||||
(API oder Web) aktiviert — genau die Migration, die ursprünglich gedacht war, aber durch einen Default-Vorrang
|
||||
nie ausgelöst hatte. Ehrlicher Umfang: Das betrifft nur alte Dateien, die seit dem Upgrade noch NICHT über die
|
||||
Einstellungen neu gespeichert wurden — wer seit dem Update schon einmal in den Einstellungen gespeichert hat, hat
|
||||
die Schalter bereits als „aus" stehen und greift dort weiterhin manuell ein (das ist Absicht: ein bewusst auf
|
||||
„aus" gestellter Schalter wird NICHT wieder angeschaltet). Rot-bewiesener Test; voller Testlauf grün.
|
||||
|
||||
- **Mega-Web: gesunder Account sperrt sich nicht mehr selbst, nur weil er gerade belegt war (deine „Tool sperrt sich selbst"-Klasse, Web-Variante):**
|
||||
Wenn mehrere Links gleichzeitig über DENSELBEN Mega-Account umgewandelt wurden, laufen sie absichtlich nacheinander
|
||||
(eine Warteschlange pro Account, damit nicht doppelt eingeloggt/gehämmert wird). Wartete ein Link in dieser Schlange
|
||||
noch auf seinen Vorgänger und lief dabei der 60-Sekunden-Gesamttimeout ab, wurde der Abbruch fälschlich wie ein echter
|
||||
Account-Fehler gewertet → der völlig gesunde Account bekam 120 Sekunden Sperre. Jetzt wird ein Abbruch, der NOCH IN DER
|
||||
Warteschlange passiert (bevor echte Arbeit begann), als reiner Warteschlangen-Timeout erkannt: KEINE Account-Sperre,
|
||||
der Link wird einfach erneut versucht und rotiert dann von selbst auf einen freien Account. Ein echter Abbruch MITTEN
|
||||
in der Arbeit sperrt den Account weiterhin (damit langsame Accounts korrekt übersprungen werden) — das blieb unverändert.
|
||||
Zwei rot-bewiesene Tests (jeder ohne Fix nachweislich rot; der zweite läuft komplett durch die echte Account-Rotation
|
||||
und prüft, dass keine Sperre gesetzt wird). Die Rotation auf einen freien Account ist über die Auslastungs-Verteilung
|
||||
(am-wenigsten-belegter-Account-zuerst) abgesichert — strikt besser als die alte 120s-Pauschalsperre.
|
||||
|
||||
- **Stille Datei-Beschädigung beim Resume nach Verbindungsabbruch behoben (für Dateien mit bekannter Größe):**
|
||||
Wenn ein Debrid-Server beim Abbruch einen kleinen Fehler-Müll-Block mitten in den Datenstrom schreibt und
|
||||
das genau im LETZTEN Wiederhol-Versuch passierte, blieb dieser Müll in der Datei und der anschließende
|
||||
Neuversuch (mit frischem Link) hängte die echten Bytes DAHINTER an — die Datei hatte am Ende exakt die
|
||||
richtige Größe und galt deshalb als fertig, obwohl mittendrin Müll steckte. Bei .mkv/.mp4 ohne Prüfsumme
|
||||
fiel das nie auf. Jetzt wird der verdächtige Datei-Schwanz vor der Linkerneuerung zurückgespult, sodass der
|
||||
Neuversuch ihn sauber überschreibt. Rot-bewiesener Test (Inhalt byte-genau geprüft, nicht nur die Länge).
|
||||
Ehrlich eingeordnet: Das ist behoben für Dateien, bei denen der Anbieter die Größe meldet (fast immer der
|
||||
Fall). Für die seltenen Fälle ganz ohne Größenangabe bleibt eine separate, schon vorher bestehende Lücke
|
||||
(ohne Längensignal nicht über dieses Rückspulen lösbar) — dokumentiert als langfristiges Thema.
|
||||
- Zusätzlich gehärtet: Ein fehlgeschlagenes Zurückspulen (z.B. Datei kurz von Virenscanner gesperrt) wird
|
||||
jetzt im nächsten Versuch erneut probiert statt still übergangen.
|
||||
|
||||
|
||||
- **Deutsche Tonspur: falsche Spur-Auswahl behoben (Datenverlust-Schutz):** Die Erkennung der
|
||||
deutschen Tonspur hat den Titel-Text einer Spur („...German...") auch dann ausgewertet, wenn die
|
||||
Spur bereits ein anderssprachiges Tag hatte (z.B. eine englische Spur mit „German" im Titel, wie
|
||||
„German Commentary"). Lag so eine Spur VOR der korrekt mit „ger" getaggten Spur, wurde die falsche
|
||||
(englische) behalten und die echte deutsche Spur beim Remux unwiderruflich verworfen — das Ergebnis
|
||||
wurde als Erfolg gemeldet. Jetzt wird der Titel nur noch dann herangezogen, wenn gar kein Sprach-Tag
|
||||
vorhanden ist (so war es ohnehin gemeint). Korrekt getaggte deutsche Spuren gewinnen jetzt immer.
|
||||
Rot-bewiesener Test. (Noch nicht released — wird mit der nächsten Runde gebündelt.)
|
||||
|
||||
|
||||
- **Endlos-Wiederholung bei festem Wiederholungslimit behoben:** Wenn du ein FESTES Retry-Limit
|
||||
(z.B. 5) eingestellt hattest UND ein Link sprunghaft verschiedene Fehlerarten produzierte
|
||||
(mal Umwandlungs-Timeout, mal Abbruch mitten im Download, mal allgemeiner Fehler), konnte ein
|
||||
Eintrag in einer Endlosschleife hängen: eine interne „Viele-Fehler"-Pause halbierte die Zähler,
|
||||
sodass das eingestellte Limit nie erreicht wurde — der Eintrag scheiterte nie, blockierte dauerhaft
|
||||
einen Download-Slot und hämmerte den Anbieter (weil dabei auch die Anbieter-Sperre zurückgesetzt
|
||||
wurde). Jetzt wird das von dir eingestellte Limit hart eingehalten: nach N Versuchen scheitert der
|
||||
Eintrag sauber. Standard-Einstellung („unendlich", der Auslieferungs-Default) war nie betroffen.
|
||||
Rot-bewiesener Test.
|
||||
|
||||
|
||||
- **Mega „bis Tagesreset gesperrt" parkt jetzt wirklich (statt alle 2 min neu zu versuchen):**
|
||||
Wenn ALLE Mega-Accounts wegen wiederholt leerer Antworten bis zum Tagesreset geparkt waren,
|
||||
hat das Tool den Fehler bisher als normalen Umwandlungsfehler behandelt und den ganzen Tag
|
||||
alle ~2 Minuten neu probiert (und dabei den Provider-Circuit-Breaker mit Fehlern vollgemüllt).
|
||||
Jetzt erkennt es den Park und legt das Paket EINMAL bis zum Tagesreset schlafen — genau das,
|
||||
was der „bis Tagesreset"-Park eigentlich erreichen sollte. Bei Standard-Einstellungen heilte
|
||||
sich das vorher schon um Mitternacht selbst (kein Datenverlust), war aber unnötige Log-Flut
|
||||
und Churn. Rot-bewiesener Test.
|
||||
|
||||
|
||||
- **MED Failover-Routing:** Wenn ein Provider in den Manager-Cooldown läuft (≥20 Fehler in
|
||||
Folge) und auto-Fallback an ist, hat der Manager bisher zwar einen Ersatz-Provider berechnet,
|
||||
ihn aber WEGGEWORFEN — die Kette führte trotzdem wieder mit dem ausgebremsten Provider an.
|
||||
Jetzt wird der Ersatz-Provider als „Lead" durchgereicht und die Kette führt mit ihm an, OHNE
|
||||
einen Provider zu verlieren (der ausgebremste bleibt als letzter Notnagel in der Kette).
|
||||
Greift nur, wenn der Provider bereits nachweislich degradiert ist → strikt-besser-wenn-aktiv,
|
||||
kein Timeout/Cancel-Vertrag berührt. Rot-bewiesener Test. (Hält für Roll-up-Release bereit.)
|
||||
361
tasks/lessons.md
Normal file
361
tasks/lessons.md
Normal file
@ -0,0 +1,361 @@
|
||||
# Lessons
|
||||
|
||||
## 2026-06-17 — "Permanent/tot" NIE annehmen ohne Transienz-Gegenprobe (supprimé war transient)
|
||||
|
||||
**Muster:** Mega-Debrid lieferte 479x "Fichier supprimé chez l'hébergeur". Ich nahm
|
||||
"supprimé = gelöscht = toter Link" als permanent an, baute Fix (sofort scheitern, kein
|
||||
Web-Fallback) + released v1.7.210. Der User fragte: "welcher Link soll tot sein, hast du
|
||||
das hinterfragt?" Gegenprobe an den Logs: von 18 Links mit "supprimé" haben **4 Sekunden
|
||||
später ein OK** geliefert (1x Web, 3x API-Retry 7–66s). Der Fehler war TRANSIENT. 210
|
||||
hätte erholbare Links dauerhaft gekillt. Korrektur v1.7.211: temporär statt permanent.
|
||||
|
||||
**Regel:**
|
||||
- Bevor ein Fehler als permanent/fatal/tot klassifiziert wird: an echten Daten prüfen, ob
|
||||
derselbe Link/dieselbe Ressource mit demselben Fehler **jemals danach ein OK** bekam.
|
||||
Intersection(failed-links, ok-links) ≠ ∅ → transient → permanent-Klassifizierung ist
|
||||
falsch. Wortbedeutung ("supprimé"=gelöscht, "deleted") beweist KEINE Permanenz —
|
||||
besonders bei flakigen Multihostern (Mega-Debrid), die per-Link kurzzeitig falsch melden.
|
||||
- **Die Linse (Advisor):** bei jedem Fehlersignal fragen — ist das über den ACCOUNT
|
||||
(→ ggf. Cooldown), über den LINK PERMANENT (→ Item scheitern), oder nur über DIESEN
|
||||
VERSUCH (→ Retry)? Beide Bugs hier (Account-Cooldown-Vergiftung UND falsch-permanent)
|
||||
waren derselbe Fehler: ein Per-Versuch-Signal als account-/link-globaler Zustand behandelt.
|
||||
- Wieder die 05-31-Regel verletzt (empirisch bestätigen vor Release). Wenn der User
|
||||
skeptisch nachfragt ("hast du das hinterfragt?"), ist das fast immer ein echter
|
||||
ungeprüfter Sprung — sofort an Daten gegenprüfen, nicht verteidigen.
|
||||
- Retry-Pacing verifizieren, nicht annehmen: cooldownMs:0 entfernt Account-Cooldown,
|
||||
aber der Download-Manager bremst per 5s-Exponential-Backoff (unrestrictDelayMs) pro
|
||||
Item — getraced, weit unter Mega-Debrid 50 req/s. Account-Cooldown ≠ Retry-Pacing.
|
||||
|
||||
## 2026-05-31 — Fix-Diagnose EMPIRISCH bestätigen, bevor man released (Timeout ≠ Account-Hänger)
|
||||
|
||||
**Muster:** "acc2/acc3 nie versucht" wurde als "acc1 hängt → Per-Account-Timeout +
|
||||
Rotation" diagnostiziert und als v1.7.168 released. Falsch: Mega-Debrid-**Web** ist eine
|
||||
180s-Polling-Schleife (`mega-web-fallback.ts`) — acc1 *pollte* legitim, der 60s-Global-
|
||||
Timeout (nicht "Hängen") schnitt es ab. Mein 25s-Per-Account-Cap machte es SCHLIMMER
|
||||
(endlose 25s-Rotation, Datei nie aufgelöst). Erst der User-Log + Lesen der Provider-
|
||||
Impl deckte es auf. Revert v1.7.169.
|
||||
|
||||
**Regel:**
|
||||
- Ein Timeout bei einem langsam-pollenden Provider ist KEIN Account-Fehler → darf keine
|
||||
Rotation/kein Skippen auslösen. Vor "Account hängt"-Annahmen die Provider-Impl lesen
|
||||
(Polling? internes Ceiling? wie lange dauert ein Erfolg legitim?).
|
||||
- Bei zwei gegensätzlichen Diagnosen (hier: Timeout-zu-kurz vs. IP-Block — stand in der
|
||||
EIGENEN Memory!) NICHT die bequeme wählen + releasen. Erst empirisch diskriminieren
|
||||
(Env-Var auf Server, Beobachtung, oder gezielte User-Frage). Ein Symptom, das BEIDE
|
||||
Hypothesen gleich gut erklärt ("Timeout nach Xs"), beweist keine.
|
||||
- NICHT lokal "verifizieren" wenn das Problem umgebungsspezifisch ist (geblockte
|
||||
Server-IP) — lokaler Erfolg ist falsch-positiv.
|
||||
|
||||
## 2026-05-30 — Abgestürzten/„aufgehängten" Chat fortsetzen: zuerst reflog lesen
|
||||
|
||||
**Muster:** User bat, einen anderen, aufgehängten Chat-Strang „zu Ende zu bringen".
|
||||
Der Working Tree sah harmlos aus (nur untracked), aber der eigentliche Fortschritt lag
|
||||
in einem per `reset --hard HEAD~1` weggesetzten Commit, der nur noch im **reflog**
|
||||
(dangling) lebte.
|
||||
|
||||
**Regel:** Bei „mach weiter wo es hing":
|
||||
1. `git reflog` + `git log --oneline -20` zuerst — Ground Truth, NICHT der
|
||||
(evtl. stale) gitStatus-Snapshot oder Konversations-interne Annahmen.
|
||||
2. Reset-weggesetzte/dangling Commits (`git fsck --lost-found`, reflog) inspizieren
|
||||
(`git show <sha>`) — dort steckt oft die unfertige Arbeit.
|
||||
3. **Verstehen WARUM weggesetzt**, bevor man blind cherry-picked: hier brach ein
|
||||
bestehender Test (`.toBe(signal)`-Identitätscheck), den der Fix zwingend ändert.
|
||||
Der Reset war die Reaktion darauf, nicht „Fix war falsch". Erst die Reset-Ursache
|
||||
beheben (Test auf Verhalten umstellen), dann den Fix recovern.
|
||||
4. Eigene Memory (`project_*`) lesen — sie dokumentierte Bug + intendierten Fix exakt.
|
||||
|
||||
## 2026-05-30 — Release verifizieren BEVOR "fertig" gesagt wird; curl -F mit Leerzeichen im Pfad
|
||||
|
||||
**Muster A (Edit ins Leere + trotzdem released):** Ein Edit schlug fehl ("String not
|
||||
found"), ich habe es übersehen, committet und v1.7.165 released — die Datei enthielt
|
||||
das Feature NICHT. Erst der nächste Blick zeigte es.
|
||||
**Regel:** Nach jedem Feature-Edit VOR dem Release `git show HEAD:datei | grep <marker>`
|
||||
— bestätigen dass der Code wirklich im Release-Commit ist, nicht nur dass `git commit`
|
||||
durchlief.
|
||||
|
||||
**Muster B (Gitea UNIQUE constraint):** `npm run release:gitea` pusht erst den Tag,
|
||||
dann erstellt es den Release. Gitea legt beim Tag-Push automatisch einen Tag-Release-
|
||||
Eintrag an (name=null). `fetchExistingRelease` im Script matcht den nicht → POST create
|
||||
→ `UNIQUE constraint failed: release.repo_id, release.tag_name`. Commit + Tag sind dann
|
||||
schon gepusht, nur der Release+Assets fehlen.
|
||||
**Recovery:** `GET /api/v1/repos/.../releases/tags/<tag>` → id holen → `PATCH releases/<id>`
|
||||
mit name/body/draft:false → Assets per `POST releases/<id>/assets?name=<url-encoded>` hochladen.
|
||||
|
||||
**Muster C (curl -F Datei mit Leerzeichen):** `curl -F "attachment=@release/Datei mit
|
||||
Leerzeichen.exe.blockmap"` lädt FALSCHEN Inhalt hoch (Server-Size != lokale Size).
|
||||
**Regel:** Datei mit Leerzeichen im Namen erst nach `/tmp/leerzeichenfrei` kopieren,
|
||||
DAS hochladen, Asset-Name über `?name=<url-encoded>` setzen. Danach Server-Size gegen
|
||||
lokale Size prüfen.
|
||||
|
||||
|
||||
|
||||
## 2026-05-30 — Nicht in chaotische Parallel-Tool-Batches verfallen (User-Korrektur: "bist du in nem endless loop")
|
||||
|
||||
**Muster:** Bei einem großen Multi-File-Edit habe ich Dutzende Tool-Calls (Bash-Probes,
|
||||
Reads, Edits, Python-Inline-Skripte, mehrfache tsc-Läufe) in EINEN Message-Block gepackt.
|
||||
Resultat: Ein einzelner Fehler/Cancel hat die ganze parallele Kette abgebrochen, Edits
|
||||
landeten halb, ich verlor den Überblick welche Änderung wirklich auf Disk war, und es
|
||||
wirkte wie eine Endlosschleife. Dazu: wegwerf-`scripts/_*.py`/`_*.txt` als Workaround
|
||||
gegen Output-Encoding statt der dedizierten Tools.
|
||||
|
||||
**Regel:**
|
||||
- Edits über mehrere Dateien **sequenziell, einer nach dem anderen**, mit kurzer
|
||||
Verifikation dazwischen — nicht 20 spekulative Calls auf einmal.
|
||||
- Nach jedem Edit, der fehlschlagen kann (Anchor evtl. nicht eindeutig), das Ergebnis
|
||||
lesen, bevor der nächste folgt. Edit/Write erroren laut — darauf vertrauen.
|
||||
- KEINE Wegwerf-Python-Skripte ins Repo schreiben, um Shell-Output zu parsen. `Grep`/
|
||||
`Read`/`Edit` nutzen. Wenn doch ein Temp nötig ist: nach `os.tmpdir()`, nie nach
|
||||
`scripts/`, und sofort wieder löschen.
|
||||
- Verifikation gebündelt am ENDE (1× tsc, 1× build, 1× vitest), nicht 10× zwischendrin.
|
||||
|
||||
|
||||
## 2026-05-28 — Analyse-Befund gegen beobachtete Realität gaten (Advisor-Korrektur)
|
||||
|
||||
**Muster:** Meine Analyse sagte einen *häufigen* Bug voraus (jede letzte Datei im
|
||||
Standard-Modus + jede Nested-Datei landet unbenannt), während der User nur "1-2 pro
|
||||
Staffel" meldete. Ich habe die Diskrepanz bemerkt ("zu schwer um unbemerkt zu bleiben")
|
||||
und sie mit weiterem Timing-Argument wegrationalisiert.
|
||||
|
||||
**Regel:** Wenn die eigene Analyse etwas vorhersagt, das der beobachteten Realität
|
||||
widerspricht, NICHT die bequeme Lesart wählen — **mit einem Reproduktions-Test gaten**,
|
||||
bevor man fixt. Failing Test gegen den Ist-Stand zuerst (TDD/systematic-debugging Phase 4):
|
||||
- reproduziert → Bug bestätigt, mit Sicherheit fixen.
|
||||
- reproduziert nicht → Analyse hat eine Mitigation übersehen, kein Fix für Nicht-Bug.
|
||||
|
||||
## 2026-05-28 — Crash-Debris im Working Tree: stashen, nicht verwerfen
|
||||
|
||||
**Muster:** Eine abgestürzte Session (API 400) hinterließ ein uncommittetes Working Tree,
|
||||
das drei releaste Commits revertierte. Verlockung: `git checkout`/discard, um clean HEAD
|
||||
zu bekommen.
|
||||
|
||||
**Regel:** Fremde/unverstandene uncommittete Änderungen **`git stash`** (non-destruktiv,
|
||||
recoverable), nie blind verwerfen. Gibt clean HEAD, nichts geht verloren, kein Stall auf
|
||||
User-Rückfrage. Danach dem User sagen WAS gestasht wurde und WARUM.
|
||||
|
||||
## Wiring-Lock vs. Mechanism-Test
|
||||
|
||||
Ein Test, der eine Hilfsfunktion mit dem richtigen Flag direkt aufruft, beweist nur, dass
|
||||
das Flag funktioniert — NICHT, dass der Produktionspfad das Flag setzt. Für echte
|
||||
Absicherung einen End-to-End-Test durch den realen Einstiegspunkt fahren und per
|
||||
Negativ-Gate (Flag temporär entfernen → Test muss fallen) verifizieren.
|
||||
|
||||
## 2026-05-31 — Log-Symptom ≠ User-Wortlaut: greppen, bevor man auf eine Meldung triggert
|
||||
|
||||
**Muster:** User meldete Mega-Debrid-Tageslimit als „Kein Server für diesen Hoster". Ich
|
||||
wollte den Fix an genau diese Meldung (`MEGA_DEBRID_NO_SERVER_RE`) hängen. Der Advisor
|
||||
stoppte: der Screenshot zeigte als Cooldown-Grund **„Antwort leer"**, nicht „Kein Server".
|
||||
|
||||
**Beweis (Support-Bundle gegrept):** „Kein Server"/„Erreur"/„aucun serveur" = **0** Treffer
|
||||
im ganzen Bundle, „Antwort leer" = **20.861** Treffer. Der limitierte Account liefert im
|
||||
Web-Pfad NIE eine unterscheidbare Meldung — `generate()` findet ohne `processDebrid`-Code
|
||||
keinen Code → `return null` → der Aufrufer macht daraus „Antwort leer". Ein Trigger auf
|
||||
„Kein Server" wäre toter Code gewesen (= die v1.7.172-Falle, zum 2. Mal fast getreten).
|
||||
|
||||
**Regel:** Bevor man einen Fix an einen bestimmten Meldungstext hängt, in den ECHTEN Logs
|
||||
greppen, ob dieser Text dort überhaupt vorkommt (`count`-Mode, alt-Text vs. Ist-Text). Sind
|
||||
zwei Fälle auf Message-Ebene nicht unterscheidbar (Tageslimit vs. transienter Blip → beide
|
||||
„Antwort leer"), nicht raten — über ein **Verhaltens-Signal** klassifizieren: hier eine
|
||||
Streak (3× hintereinander leer → geparkt), nicht der einmalige Wortlaut.
|
||||
|
||||
**Wiring-Test nicht vergessen** (eigene Lesson): die Helfer-Unit-Tests beweisen nur den
|
||||
Zähler. Ein E2E-Test muss eine ECHTE leere Antwort durch den realen Einstiegspunkt
|
||||
(`unrestrictWithAccounts` → `classifyAccountFailure` → catch → Park) treiben, sonst bleibt
|
||||
unbewiesen, dass der Produktionspfad das Signal überhaupt setzt.
|
||||
|
||||
## 2026-06-01 — Ein Verifizierer muss dieselbe Pfad-Normalisierung nutzen wie die verifizierte Operation
|
||||
|
||||
**Muster:** Neues Renaming-Logging sollte nach jedem Rename verifizieren, ob die Datei
|
||||
wirklich unter dem Zielnamen liegt. `verifyRename` machte statSync/readdirSync auf den
|
||||
ROHEN Pfaden — der echte Rename lief aber über `toWindowsLongPathIfNeeded` (\?\-Prefix
|
||||
ab >=248 Zeichen). Bei langen Scene-Release-Pfaden (genau das, was die App routinemäßig
|
||||
umbenennt) scheiterten die rohen fs-Calls → falsches „Ziel nicht gefunden" UND — schlimmer —
|
||||
die Quell-Prüfung scheiterte ebenfalls → `sourceGone` fälschlich true → **falsches „OK"**,
|
||||
das einen halb-fertigen Verschiebevorgang maskiert. Der Diagnose-Log hätte genau die
|
||||
schwersten Fälle vergiftet. (Adversarialer Review-Workflow fand es, Confidence 0.8.)
|
||||
|
||||
**Regel:** Wenn Code eine Operation VERIFIZIERT, muss er exakt dieselbe Pfad-/Encoding-/
|
||||
Normalisierung verwenden wie die Operation selbst (hier: \?\-Long-Path-Prefix). Sonst
|
||||
mis-reportet der Verifizierer still — und am verlässlichsten bei den Edge-Cases, die man
|
||||
eigentlich fangen wollte. Ein falsches OK in einem Diagnose-Log ist schlimmer als ein
|
||||
falsches ERROR. Zusatz: readdir-Fehler darf nicht zu „Schreibweise ok" degradieren
|
||||
(stilles False-OK) → eigenes WARN-Level „nicht verifizierbar".
|
||||
|
||||
**Meta:** Bei einem Feature, dessen ganzer Zweck Beobachtbarkeit/Verifikation ist, lohnt
|
||||
ein adversarialer Review mit Fokus „würde die Verifikation auf der ECHTEN Last (lange
|
||||
Pfade, case-insensitive FS, EXDEV) korrekt urteilen?" — nicht nur „kompiliert + Happy-Path-Test".
|
||||
|
||||
## 2026-06-03 — Renaming „nie 100%": entkoppelte Scans + Namens-Fabrikation aus token-losen Ordnern
|
||||
|
||||
**Symptom (aus dem Desktop-Rename-Log diagnostiziert):** 17 Dateien landeten ROH in der
|
||||
Library ("tvarchiv...s07e12-720.mkv", "4sf-...s04e01.mkv"). KEINE [ERROR]-Zeile — alle [INFO],
|
||||
weil die Verifikation nur „liegt die Datei am Zielnamen?" prüft, nicht „ist der Zielname
|
||||
sinnvoll?". Das Logging hat den Bug sichtbar gemacht (genau sein Zweck).
|
||||
|
||||
**Root Cause 1 (entkoppelte Scans):** Auto-Rename (scannt nur extractDir, nur present-and-
|
||||
stable Dateien, Freshness-Gate loggt nur via logger.info → keine Session-Spur) und
|
||||
collectMkvFilesToLibrary (verschiebt JEDE .mkv, behielt den rohen Basename) sind getrennte
|
||||
Scans. Eine von Auto-Rename verpasste Datei (verpasster Zyklus ODER lag in „Downloader
|
||||
Unfertig" außerhalb extractDir) wurde von collect roh weggeschoben. **Fix:** collect leitet
|
||||
den sauberen Namen SELBST ab — über dieselbe Funktion wie Auto-Rename (decideAutoRenameBaseName,
|
||||
single source of truth) → Race wird egal, beide Pfade können nicht mehr divergieren.
|
||||
|
||||
**Root Cause 2 (latente Fabrikation, vom Advisor gefunden):** decideAutoRenameBaseName
|
||||
fabrizierte „Mega-Direct-Pack.S01E01" für einen generischen Paketordner, weil
|
||||
`hasSceneGroupSuffix("Mega-Direct-Pack")` auf „-Pack" falsch-positiv matcht und Guard B dann
|
||||
die Quell-Episode an einen token-losen Ordner anhängt. Das hätte AUTO-RENAME genauso getroffen
|
||||
(nur dormant, weil echte Releases saubere Ordner haben). **Fix an der Wurzel:** Rename nur,
|
||||
wenn IRGENDEIN folderCandidate einen echten Season-/Episode-Token trägt — ein token-loser
|
||||
Ordner kann keine Episode autoritativ benennen.
|
||||
|
||||
**Meta-Lektionen:**
|
||||
1. Bei „X nie 100%": die Fehler aus dem ECHTEN Log ziehen (greppen), nicht raten. Hier:
|
||||
„Kein Server" 0×, „Antwort leer" 20k×; und 17 vs vermutete 12 (5 begannen mit Ziffer „4").
|
||||
2. Symptom-Fix vs Wurzel-Fix: ein collect-seitiger Guard (Quell-Auflösung+Codec) hätte das
|
||||
Symptom kaschiert + eine Restlücke gelassen; der Wurzel-Fix in der gemeinsamen Funktion
|
||||
schließt BEIDE Pfade + ermöglicht ehrliches 100%.
|
||||
3. Wenn ein (Sub-)Agent eine empirische Behauptung aufstellt, die der beobachteten Realität
|
||||
widerspricht (Review: „liefert no-target" vs Test: „benennt um"), NICHT raten — mit einem
|
||||
Wegwerf-Diagnose-Test die echte Rückgabe sichtbar machen, DANN entscheiden.
|
||||
4. „raw-keep ist der Boden" als Guard-Prinzip: ein Rename darf nie einen schlechteren Namen
|
||||
erzeugen als der Originalname.
|
||||
|
||||
## 2026-06-03 (2) — Renaming „verschlimmbessert" guten Quellnamen (Scene-Gruppe mit Unterstrich)
|
||||
|
||||
**Symptom (neues Desktop-Log):** `castle.s08e02.german.dl.720p.web.h264-idtv_int.mkv` (bereits
|
||||
SAUBER) im Ordner `Castle.S08E02.GERMAN.DL.720p.WEB.H264-idTV_iNT` (Paket `scn2-cstl7`) wurde zu
|
||||
`scn2-cstl7.S08E02.mkv` — also GUTER Name → obfuskierter Paketname. Andere Klasse als die 17
|
||||
(roh→nicht-angefasst); hier gut→schlechter.
|
||||
|
||||
**Ursache (reproduziert, kein Raten):** `hasSceneGroupSuffix("...H264-idTV_iNT")` = false, weil
|
||||
`SCENE_GROUP_SUFFIX_RE`/`_FALLBACK_RE` Unterstriche im Gruppen-Suffix verbieten. → buildAutoRenameBaseName
|
||||
verwarf den sauberen Episoden-Ordner (return null) → fiel auf den Paketordner `scn2-cstl7` zurück
|
||||
→ Episode angehängt = `scn2-cstl7.S08E02`. Guard A (Quelle-besser) griff nicht, weil
|
||||
`hasMeaningfulSeriesPrefix("scn2-cstl7.S08E02")=true` (Gruppe sieht aus wie Serien-Prefix).
|
||||
**Fix:** `extractFlexibleSceneGroupSuffix` (existierte, war nicht verdrahtet) in hasSceneGroupSuffix
|
||||
einbinden → Unterstrich-Gruppen erkannt → sauberer Ordner gewinnt → idealer Name.
|
||||
|
||||
**Meta-Lektionen:**
|
||||
1. „100%" gilt nur fuer die DATEN, die man hatte. Mein lueckenloser Check des 2026-06-02-Logs war
|
||||
korrekt — aber ein NEUER Download (Castle/idTV_iNT) brachte eine Gruppen-Form, die im alten Log
|
||||
nicht vorkam. Bei „nie 100%" ehrlich sagen: „fuer die bekannten Faelle 100%, neue Muster brauchen
|
||||
neue Logs". Das Desktop-Log liefert genau diese neuen Muster.
|
||||
2. Reproduzieren statt raten: ein 3-Zeilen-Diagnose-Test (buildAutoRenameBaseName pro Ordner +
|
||||
decideAutoRenameBaseName) zeigte sofort, WELCHER Ordner verworfen wird und warum — nicht spekulieren.
|
||||
3. Offener Backstop-Gedanke fuer echte Robustheit: ein generelles Guard "ersetze nie einen bereits
|
||||
VOLLSTAENDIGEN Quellnamen (Serie+Episode+Aufloesung+Codec) durch einen, der die Serien-Identitaet
|
||||
verliert" wuerde KUENFTIGE unbekannte Gruppen-Formate abfangen — riskanter Eingriff in Guard A,
|
||||
nur mit Tests + auf User-Wunsch.
|
||||
|
||||
## 2026-06-03 (3) — Renaming-Klasse „Junk-Quellname + sauberer Release-Ordner" (Folge-Nummer statt SxxExx)
|
||||
|
||||
**Symptom (Log 18-18):** „Kreuzfahrt ins Glück" — 25 Folgen `bet_kig_01_hdt.mkv` (obfuskiert, KEIN
|
||||
SxxExx-Token) im sauberen Episoden-Ordner `Kreuzfahrt.ins.Glueck.01.Hochzeitsreise.nach.Burma.2007.
|
||||
German.720p.HDTV.x264-BET` (Episode als bloße „01"). Auto-Rename: „kein Zielname" → 25× roh in die
|
||||
Library. Diesmal SICHTBAR als 25 [WARN] (vorher 0 WARN) — das Log zeigt die Klasse direkt.
|
||||
|
||||
**Ursache (reproduziert):** `buildAutoRenameBaseName` gibt null zurück, sobald die QUELLE keinen
|
||||
SxxExx-Token hat (Z.1288) — egal wie sauber der Ordner ist. Das „Folge 01"-Nummernformat (kein
|
||||
S01E01) wurde nie unterstuetzt. VORBESTEHEND, nicht meine v1.7.178/179.
|
||||
|
||||
**Fix:** Fallback in decideAutoRenameBaseName — wenn kein Zielname UND Quelle hat keinen
|
||||
Episode-Token, den ersten folderCandidate nehmen, der ein VOLLSTAENDIGER Scene-Release-Ordner ist:
|
||||
`hasSceneGroupSuffix(f) && (RESOLUTION_RE.test(f) || CODEC_RE.test(f)) && !SCENE_SEASON_ONLY_RE.test(f)`.
|
||||
Greift NUR ohne Quell-Episode-Token → schliesst sich mit dem Fabrikations-Guard aus (Mega-Direct hat
|
||||
Quell-Token → unerreicht). note:"folder-as-is".
|
||||
|
||||
**Advisor-Punkt (wichtig):** NICHT nur Aufloesung pruefen — alte deutsche TV-Serien gibt es als
|
||||
DVDRip/XviD OHNE 720p-Token. `RESOLUTION_RE ODER CODEC_RE` → sonst die naechste Runde. Pin-Test:
|
||||
DVDRip-Variante (kein 720p, nur x264).
|
||||
|
||||
**Edge (Advisor):** Bonus/Sample muss VOR diesem Fallback gefiltert werden (sonst kriegt ein
|
||||
Featurette/Sample im Episoden-Ordner den Episodennamen). Bestaetigt: Auto-Rename-Loop (Sample-Size +
|
||||
BONUS_FILENAME_RE) und Collect filtern beide vor der Namensherleitung → gedeckt.
|
||||
|
||||
**Meta:** 3. „anderes Format" in Folge — diese Klasse (Junk-Quelle + sauberer Ordner) ist die
|
||||
groesste verbleibende. Scene-Naming hat aber einen langen Schwanz: ehrlich „diese Klasse ist
|
||||
abgedeckt", nicht „jetzt 100%". Das Desktop-Log liefert jede neue Klasse sofort.
|
||||
|
||||
## 2026-06-04 — KEINE „Claude/AI"-Spuren in oeffentlichen Releases (GitHub)
|
||||
**Korrektur:** „kein SCHAU MAL wie ich mit claude gearbeitet hab release … entfern alles was da drin
|
||||
steckt." Beim einmaligen GitHub-Sync (Sucukdeluxe/real-debrid-downloader) waren oeffentlich: `CLAUDE.md`,
|
||||
`design-mockups/`, `tasks/lessons.md`+`todo.md`, historisch `.claude/`, und **357 Commits mit
|
||||
`Co-Authored-By: Claude`-Trailer**.
|
||||
**Regel ab jetzt:** Fuer dieses Projekt KEINE `Co-Authored-By: Claude`-Trailer mehr an Commits
|
||||
(ueberschreibt die Default-Git-Anweisung — User-Wunsch hat Vorrang). Keine KI-Artefakte (CLAUDE.md,
|
||||
Mockups, lessons/todo, .claude/) in irgendetwas, das oeffentlich gepusht wird.
|
||||
**Wie sauber gemacht (ohne Gitea/lokal anzufassen):** isolierter `git clone` → `git filter-repo`
|
||||
(`--invert-paths --path …` + `--message-callback` der Trailer-Zeilen droppt) → Force-Push NUR main +
|
||||
v1.7.180 zu GitHub. Alte Tags NICHT geloescht, sondern via `.git/filter-repo/commit-map` auf ihre
|
||||
sauberen Commits **umgehaengt** (89 Tags, alle Releases bleiben erhalten) — besser als Loeschen.
|
||||
**Ehrliche Grenze (Advisor):** Force-Push säubert nur ref-erreichbare Historie. Verwaiste alte Commits
|
||||
bleiben per voller SHA erreichbar, bis GitHub GC'd ODER das Repo neu angelegt wird (nur der User kann
|
||||
das — Token hat kein `delete_repo`). Lokaler Klon verifiziert ≠ GitHub-Zustand: immer per `gh api`
|
||||
gegenpruefen (Datei 404 am Tag, Commit-Messages trailer-frei).
|
||||
**Methodik:** vor Force-Push Voll-Range-Secret-Scan (push-protection killt sonst mitten im Push) +
|
||||
Tree-Content-Grep auf `claude|anthropic` (filter-repo tilgt Pfad-NAMEN + Trailer, nicht Datei-INHALTE).
|
||||
|
||||
## 2026-06-04 — Folge bleibt bei „Downloader Fertig" haengen: Episodentitel == Bonus-Wort
|
||||
**Symptom (User-Screenshot + rd-support-bundle):** `Revenge.2011.S04E19.Interview...mkv` extrahiert +
|
||||
korrekt umbenannt, aber NIE in die Library verschoben — kein Fehler. „selten, 4-5 Folgen pro 1,5TB".
|
||||
**Diagnose (Bundle):** Paket-Log zeigte 22/23 „MKV verschoben", E19 fehlte, KEIN WARN/ERROR. Im
|
||||
HAUPT-Log (`rd_downloader.log`) dann 5× `MKV-Sammelordner: Bonus-Datei uebersprungen: ...S04E19.Interview`.
|
||||
**Root Cause:** `BONUS_FILENAME_RE` enthaelt `interview` (+ outtakes/special/featurette/bloopers/...). Der
|
||||
Episodentitel „Interview" (UND der Episoden-Ordnername — `isInsideBonusDir` macht `.includes()` Substring)
|
||||
matchte → `collectMkvFilesToLibrary` stufte die echte Folge als Bonus/Extras ein und skippte sie. Trifft
|
||||
auch ganze Serien deren NAME ein Bonus-Wort ist. Skip war nur `logger.info` → im Paket-Log UNSICHTBAR
|
||||
(darum „silent orphan", nur via Forensik gefunden).
|
||||
**Fix:** neue exportierte `isBonusContent(filePath, packageDir, nameWithoutExt)` — eine Datei MIT echtem
|
||||
SxxExx-Token (`extractEpisodeToken`) ist eine nummerierte Episode, NIE Bonus (egal welches Titelwort).
|
||||
Echte Extras (kein Token / Extras-Subordner) bleiben gefiltert. Beide Call-Sites umgestellt (Auto-Rename
|
||||
~4312 + Collect ~5054). 2 Integrationstests (Interview wird gesammelt / Making.Of bleibt) + 5 Unit-Tests.
|
||||
**Diagnose-Lektion (Advisor-Gate):** „4-5 Folgen" plural → NICHT beim 1. Fund stoppen. Bundle-weit
|
||||
gegengeprueft: 0 Move-Fehler, nur 1 Bonus-Skip. 4 weitere „noch frisch"-Defers sahen wie Orphans aus,
|
||||
waren aber FALSE POSITIVES — Moves loggen NICHT ins Haupt-Log (nur Paket-Log), und deren Paket-Logs fehlten
|
||||
im Bundle. Per Code bewiesen: finaler Deferred-Collect laeuft fuer jedes fertige Paket (`success` =
|
||||
completed-Items, Z.11904) mit `deferFreshFiles=false` → faengt Frische-Defers. Also Frische orphan't NICHT;
|
||||
Bonus schon (Filter ignoriert deferFreshFiles, skippt in JEDEM Pass inkl. final). Lehre: bevor man „X ist
|
||||
Orphan" behauptet, pruefen ob der GEGENBEWEIS (Move) im verfuegbaren Log ueberhaupt sichtbar WAERE.
|
||||
|
||||
## 2026-06-05 — Folge bleibt ROH: vollstaendiger Episoden-Ordner OHNE -GROUP-Suffix
|
||||
**Symptom (rename-session 2026-06-04):** `safari-fm-s04e08a.avi` / `...b.avi` landeten ROH in der Library
|
||||
(entpackt2). Log: `Auto-Rename übersprungen: kein Zielname`. Funktionierende S01E02 hatte Ordner
|
||||
`...XviD-SAFARi` (Gruppe), die kaputten S04E08a/b hatten `...SATRiP.XviD` (KEIN -GROUP).
|
||||
**Root Cause (Wegwerf-Diagnose, NICHT geraten):** Erste Hypothese „a/b-Token nicht erkannt" war FALSCH —
|
||||
`extractEpisodeToken("...s04e08a")`="S04E08" (das Lookahead `(?!\d)` verbietet nur Ziffern, nicht Buchstaben).
|
||||
Echte Ursache: das Gate in `buildAutoRenameBaseName` (`isLegacy4sf || isSceneGroupFolder`) lehnt einen
|
||||
vollstaendigen Episoden-Ordner OHNE -GROUP ab (endet auf bare Codec `.XviD`). Die QUELLE hat aber einen
|
||||
Token → der v1.7.180-Fallback (greift NUR ohne Quell-Token) feuert nicht → no-target → roh gemoved.
|
||||
**Fix:** Gate um `isCompleteEpisodeFolder` erweitert = echter Episoden-Token IM Ordner UND Codec-/
|
||||
Aufloesungs-Marker (neue Module-Consts `SCENE_RESOLUTION_MARKER_RE` / `SCENE_CODEC_MARKER_RE`, inkl.
|
||||
xvid/divx). Part-Buchstabe a/b bleibt erhalten (Ordnername dient unveraendert als Zielname; nur der
|
||||
RANGE-Zweig schreibt Token um, und a/b ist kein Range). Konservativ: bare „Show.S01E01" ohne Marker bleibt
|
||||
abgelehnt (kein Over-Firing). v1.7.180-Fallback nutzt jetzt dieselben Module-Consts (DRY). Greift in
|
||||
Auto-Rename UND Collect (beide via decideAutoRenameBaseName). 5 Unit- + 1 Collect-Integrationstest.
|
||||
**Methodik-Lektion:** Die naheliegende Hypothese (a/b-Suffix) per Diagnose-Test widerlegt, BEVOR gefixt —
|
||||
das Lookahead genau gelesen statt angenommen. Spart einen Fix am falschen Ort.
|
||||
|
||||
## 2026-06-05 — Collect zerstoert fertigen S01E01-Namen via Episoden-Titel-Ordner (Miniserie)
|
||||
**Symptom (rename-session 2026-06-05):** Miniserie "Steven Spielbergs Taken" landete als
|
||||
"...E01.Hinter.dem.Himmel...-GTVG.S01E01.mkv" (Episodentitel + hinten angehaengtes S01E01) statt sauber
|
||||
"...S01E01...-GTVG.mkv". User: "keine Staffel, nur Episodentitel".
|
||||
**Root Cause (diagnostisch bewiesen):** Auto-Rename benannte korrekt zu "...S01E01...-GTVG.mkv" (kombiniert
|
||||
S01 aus dem Paket/Season-Ordner + E01 aus der Quelle). Der COLLECT (deriveCleanCollectFileName ->
|
||||
decideAutoRenameBaseName) leitet die Datei NEU ab — Quelle ist nun der schon-saubere Name. Der per-Episode-
|
||||
Ordner traegt aber nur einen Episode-only-Token + Titel ("...E01.Hinter.dem.Himmel...-GTVG", KEIN S01).
|
||||
buildAutoRenameBaseName nimmt den Ordner (Gruppen-Suffix -GTVG vorhanden). In Guard B `if (!targetEpisodeToken)`
|
||||
wird der Quell-Token an den Ordnernamen ANGEHAENGT (applyEpisodeTokenToFolderName) -> "...-GTVG.S01E01"
|
||||
(Token HINTER der Gruppe = verkrueppelt). Der Root-Guard greift NICHT, weil der Season-Ordner einen S01-Token
|
||||
liefert (anyFolderHasSeasonOrEpisode=true).
|
||||
**Fix:** In Guard B, im `!targetEpisodeToken`-Zweig VOR dem Anhaengen: ist die QUELLE ein NICHT
|
||||
obfuskierter Scene-Name (`!looksLikeObfuscatedSceneFileName(sourceName)`), dann
|
||||
`return {kind:"skip", reason:"source-better"}` -> Collect behaelt den fertigen Namen. In diesem Zweig
|
||||
traegt die Quelle den EINZIGEN SxxExx-Token (Ordner hat keinen) -> obfuskiert? -> Ordner gewinnt (Append),
|
||||
sauber? -> Quelle gewinnt. Greift NUR im `!targetEpisodeToken`-Zweig (Ordner ohne SxxExx); safari
|
||||
(Ordner MIT Token) unberuehrt. 4 Unit- + 1 Collect-Integrationstest. tsc 6 (Baseline), 700/700 gruen, Build gruen.
|
||||
**Methodik:** Erst Diagnose (decideAutoRenameBaseName mit Collect-Inputs) -> exakt der mangled Name
|
||||
reproduziert. Per User-Wunsch adversarial via Workflow gegengeprueft (ultracode, 3 Lenses + Synthese).
|
||||
**Adversarialer Befund (Workflow fing's):** Mein erster Guard hatte einen ZWEITEN Konjunkt
|
||||
`hasMeaningfulSeriesPrefix(sourceBaseName)` (>=3 Alpha vor S0x). Der ist sachfremd: KURZE Serien (ER, V,
|
||||
24, Yu) fallen durch -> selber verkrueppelter Name. Gestrichen -> nur `!obfuskiert` gaten. Lehre: ein
|
||||
zusaetzlicher "klingt-vernuenftig"-Konjunkt (Praefix-Laenge) kann eine ganze reale Klasse (Kurz-Titel)
|
||||
stumm ausschliessen; adversariale Verifikation mit konkretem Gegenbeispiel (ER.S01E01) hat's gefunden.
|
||||
104
tasks/plan-german-audio-track.md
Normal file
104
tasks/plan-german-audio-track.md
Normal file
@ -0,0 +1,104 @@
|
||||
# Plan: „Nur deutsche Tonspur behalten" (.DL.) als Tool-Funktion
|
||||
|
||||
Quelle der Idee: User-Script `Remove Non German Audio.py` (ffmpeg `-map 0:v:0 -map 0:a:0
|
||||
-c copy -map_metadata -1`, + `.DL.`→`.` Rename). Soll als **togglebarer Post-Extract-Schritt**
|
||||
nach jedem Entpacken laufen, nur für **MKV/MP4 mit `.DL.` im Namen** (Dual-Language),
|
||||
und nur die **deutsche** Spur behalten. Fundiert per 6-Agent-Analyse + Advisor.
|
||||
|
||||
## 1. Verhalten (Soll)
|
||||
- Läuft automatisch nach dem Entpacken eines Pakets (wenn Toggle an), bevor MKV-Collect.
|
||||
- Pro extrahierter Video-Datei mit `.DL.` im Namen (case-insensitive, nur .mkv/.mp4):
|
||||
1. Audiospuren prüfen → deutsche/erste Spur bestimmen (Modus = User-Entscheidung, s.u.).
|
||||
2. Wenn >1 Audiospur: remux (stream-copy, kein Re-Encode) → behält Video + 1 Audio
|
||||
(+ optional dt. Untertitel) → Temp-Datei → atomar ersetzen.
|
||||
3. `.DL.` aus dem Dateinamen strippen (`.DL.`→`.`, `.DL`→``), Companion-Dateien (Untertitel/.nfo) mitziehen.
|
||||
4. Wenn nur 1 Audiospur: **kein** Remux (spart Neuschreiben großer Dateien), ABER `.DL.`-Strip trotzdem.
|
||||
- Status pro Item sichtbar (z.B. „Tonspur wird bereinigt" / „Deutsche Spur behalten").
|
||||
|
||||
## 2. Architektur
|
||||
- **NEUES Modul `src/main/video-processor.ts`** (spiegelt `extractor.ts`: exportierte async-Funktion
|
||||
+ Options-Bag, KEINE DI-Klasse — es gibt keinen Constructor-Seam). Enthält:
|
||||
- ffmpeg/ffprobe-Spawn nach dem `runExtractCommand`-Muster (extractor.ts:1296): `spawn(cmd,args,{windowsHide:true})`,
|
||||
Promise-Wrapper, Timeout-Watchdog → `killProcessTree` (taskkill /T /F), **AbortSignal IN den Child** geben.
|
||||
- **Pure exportierte Helfer** für Unit-Tests: `pickGermanAudioTrack(probeJson, mode)`, `stripDualLangMarker(name)`,
|
||||
`buildFfmpegRemuxArgs(...)`, `computeRemuxTimeoutMs(bytes)`.
|
||||
- ffmpeg-Exit-Codes ≠ 7-Zip (NICHT die „exit 1 = ok"-Logik kopieren — nur das Spawn/Await/Kill-Gerüst).
|
||||
- ffprobe-JSON auf stdout NICHT durch den 48KB-Tail-Cap (`appendLimited`) — stdout separat voll puffern.
|
||||
- **ffmpeg-Discovery (Option a, empfohlen):** System-PATH + `RD_FFMPEG_BIN` env + lazy `ffmpeg -version`-Probe
|
||||
gecacht (spiegelt `RD_7Z_BIN`, extractor.ts:1030-1083). **Nicht bündeln** (~80-150MB → triggert den
|
||||
eigenen 150MB-Large-Bundle-Selfcheck debug-setup.ts:22 + GPL-Lizenzpflicht). Wenn ffmpeg fehlt → Schritt
|
||||
überspringen + WARN loggen + (optional) in Health-Check/Errors surfacen. NIE Downloads blockieren.
|
||||
- **CPU-Priorität:** `lowerExtractProcessPriority(pid, priority)` + `extractOsPriority` wiederverwenden,
|
||||
Priorität als **expliziten Param** (nicht das Modul-Global `currentExtractCpuPriority` — Cross-Talk-Gefahr).
|
||||
Honoriert `settings.extractCpuPriority`.
|
||||
|
||||
## 3. Einhängepunkte (BEIDE Pfade — kritisch!)
|
||||
Post-Processing ist **pro Paket**, zwei Pfade; Hybrid-Pakete durchlaufen NIE den Deferred-Pass:
|
||||
- **Deferred** (download-manager.ts ~11614): nach `autoRenameExtractedVideoFiles`, VOR archive-cleanup/collect.
|
||||
- **Hybrid** (download-manager.ts ~10944): zwischen Rename und Collect im detached Block.
|
||||
- Beide: **innerhalb `chainPackageFileOp(pkg.id, ...)`** (serialisiert Datei-Ops pro Paket), nur auf
|
||||
`pkg.extractDir` operieren — NIE im geteilten `mkvLibraryDir` (= der v1.7.107-revertierte Cross-Package-Crash;
|
||||
autoRename bricht bei Overlap ab, 3905-3919).
|
||||
- **Gate:** neuen Flag in den Post-Process-Aggregator OR-en (~7078-7084), sonst läuft der Schritt nie
|
||||
standalone. Hängt inhärent an `autoExtract` (braucht entpackte Dateien).
|
||||
- Datei-Enumeration: `collectVideoFiles(rootDir)` (rekursiv, SAMPLE_VIDEO_EXTENSIONS, constants.ts:28) — nur
|
||||
.mkv/.mp4 verarbeiten; Sample/Bonus-Dateien per vorhandenem Skip-Prädikat auslassen.
|
||||
|
||||
## 4. Der .DL.-Knoten (LÖST den „Feature no-op"-Fehler)
|
||||
- Selektion = „Datei hat `.DL.`"; der Schritt strippt `.DL.`. → KEIN früherer Schritt darf den Marker entfernen.
|
||||
- **autoRename NICHT ändern** (behält `.DL.` verbatim) → Marker überlebt bis zum Video-Schritt.
|
||||
- Video-Schritt läuft **nach** autoRename → sieht `.DL.` → remuxt + strippt `.DL.` atomar pro Datei.
|
||||
- **NUR `collectMkvFilesToLibrary.deriveCleanCollectFileName`** bekommt den `.DL.`-Strip als Post-Transform
|
||||
(läuft NACH dem Video-Schritt → kann den Selektor nicht brechen, verhindert nur Re-Einführung aus dem
|
||||
Ordner-Token). Companion-Files via `renameCompanionFiles`/`moveCompanionFiles` mitziehen.
|
||||
|
||||
## 5. Sicherheitsmodell (Original NIE verlieren)
|
||||
- Remux → Temp-Datei → Größe > 0 (idealerweise ~plausibel) prüfen → erst dann atomar ersetzen/umbenennen
|
||||
(`renamePathWithExdevFallback` + `verifyRenameAsync`). ffmpeg-Fehler/Abbruch → Temp löschen, Original bleibt.
|
||||
- **Disk-Space-Pre-Check**: vor Remux freien Platz ≥ Dateigröße (+Marge) prüfen, sonst skip+log
|
||||
(Temp verdoppelt transient den Platz auf einer Platte, die grad entpackt hat / parallel lädt).
|
||||
- **AbortSignal in den ffmpeg-Child** (Deferred-/Hybrid-Controller) → Stop/Cancel/Reset killt laufenden Remux.
|
||||
- **mtime erhalten** (`fs.utimes` nach Remux) → sonst überspringt Hybrid-Collect (deferFreshFiles=true) die
|
||||
frisch angefasste Datei.
|
||||
- **Sicherheits-Invariante (BEIDE Modi):** Original nur ersetzen, wenn die behaltene Spur sicher die richtige
|
||||
ist. Bei Unsicherheit (keine Tags / kein Deutsch gefunden) → Datei UNANGETASTET lassen + loggen, statt
|
||||
versehentlich die einzige brauchbare Spur zu löschen.
|
||||
- Dispositions-Flag der behaltenen Spur auf „default" setzen.
|
||||
- Best-effort pro Datei: ein Fehler markiert NICHT das Paket als failed und blockiert nicht den Collect anderer Dateien.
|
||||
|
||||
## 6. ffmpeg/ffprobe-Aufrufe (Stream-Copy, schnell)
|
||||
- Probe (nur im Tag-Modus): `ffprobe -v error -select_streams a -show_entries stream=index:stream_tags=language,title -of json INPUT`
|
||||
- Remux erste Spur (Script-Parität): `ffmpeg -i INPUT -map 0:v:0 -map 0:a:0 [-map 0:s? je nach Untertitel-Option] -c copy -map_metadata -1 -disposition:a:0 default -y TEMP`
|
||||
- Remux deutsche Spur (Tag-Modus): `-map 0:v:0 -map 0:a:<dt-Index> ...` (Index aus ffprobe).
|
||||
|
||||
## 7. Settings/UI-Wiring (5 Pflicht-Stellen, +1 optional)
|
||||
1. `src/shared/types.ts` AppSettings: `keepGermanAudioOnly: boolean` (+ ggf. `germanAudioMode`, `keepGermanSubs`, `ffmpegPath`).
|
||||
2. `src/main/constants.ts` defaultSettings: `keepGermanAudioOnly: false` etc.
|
||||
3. `src/main/storage.ts` normalizeSettings: `Boolean(...)` (Pfad: `asText`, NICHT normalizeAbsoluteDir → leer = System-ffmpeg).
|
||||
4. `src/renderer/App.tsx` Settings-Tab „entpacken" neben collectMkvToLibrary: Toggle + eingerückte Sub-Optionen (disabled wenn aus).
|
||||
5. `src/renderer/App.tsx` **emptySnapshot()-Literal** (~840-859) — sonst tsc-Fehler (Feld non-optional).
|
||||
6. (optional) `src/main/support-data.ts` ~95: Flag in Diagnose-Export spiegeln.
|
||||
|
||||
## 8. Tests + Verifikations-Gate
|
||||
- ffmpeg in Tests **gemockt** (kein echter ffmpeg-Lauf): neues Modul via `vi.mock` in download-manager.test.ts
|
||||
(assert: korrekt aufgerufen + Sequenz nach autoRename / vor collect, Deferred + Hybrid). KEIN blankes
|
||||
`vi.mock("node:child_process")` in download-manager.test.ts (bricht echte Extractor-ZIP-Tests).
|
||||
- Separate `video-processor.test.ts`: `node:child_process` mocken → ffmpeg/ffprobe-ARGS asserten (Track-Wahl, Untertitel-Option).
|
||||
- Pure Helfer fs-frei testen (wie tests/auto-rename.test.ts): `pickGermanAudioTrack`, `stripDualLangMarker`.
|
||||
- Negativ-Test: Toggle aus → keine Verarbeitung. Edge: 1-Audio-`.DL.` → nur Rename, kein Remux. Kein-Deutsch → unangetastet.
|
||||
- **Gate:** tsc-Baseline = 6 vorbestehende Fehler (NICHT clean) → „keine NEUEN tsc-Fehler" + vitest 728→728+N grün + `npm run self-check` grün.
|
||||
|
||||
## 9. OFFENE ENTSCHEIDUNGEN (vor Bau — per AskUserQuestion)
|
||||
- **A. Spurauswahl:** Script-Parität (immer erste Audiospur, kein ffprobe, validiertes Verhalten) vs.
|
||||
Smart (deutsche Spur per Sprach-Tag, Fallback erste Spur, skip wenn kein Deutsch).
|
||||
- **B. Untertitel:** weglassen (wie Script) vs. deutsche Untertitel behalten.
|
||||
- **C. ffmpeg-Quelle:** nur System-PATH + `RD_FFMPEG_BIN` env vs. zusätzlich Settings-Pfad-Feld im UI.
|
||||
|
||||
## 10. Umsetzungsreihenfolge (nach Entscheidungen)
|
||||
1. `video-processor.ts` + pure Helfer + deren Unit-Tests (TDD).
|
||||
2. ffmpeg/ffprobe-Discovery (probe+cache).
|
||||
3. Settings-Wiring (5 Stellen) + UI-Toggle.
|
||||
4. Einhängen in Deferred + Hybrid (in chainPackageFileOp), Gate OR-en.
|
||||
5. collect deriveCleanCollectFileName: `.DL.`-Strip-Safety-Net.
|
||||
6. Logging (logRenameProcess, neuer Stage 'audio-strip').
|
||||
7. Tests (download-manager mock + video-processor args + negativ/edge). Gate prüfen.
|
||||
42
tasks/todo.md
Normal file
42
tasks/todo.md
Normal file
@ -0,0 +1,42 @@
|
||||
# MCP-Ferndiagnose (Goal, ultracode) — v1.7.223
|
||||
|
||||
## Ziel (Nutzer)
|
||||
"Baue massive Diagnose-Funktionen ein (MCP), so dass ich MCP auf nem Windows-Server aktivieren kann und du
|
||||
auf den Server zugreifst und WIRKLICH ALLES siehst (State, Fehler, Logs, Probleme). 5-6 Server, ueber
|
||||
Verbindungscode. Du verbindest dich → liest alles → behebst Probleme direkt."
|
||||
|
||||
## Architektur (Advisor-bestaetigt v1)
|
||||
- Standalone **stdio MCP-Bridge** auf MEINER (Claude-Code-)Maschine, proxyt zur bestehenden HTTP `debug-server.ts`
|
||||
jedes Servers via **Verbindungscode**. Eine Bridge bedient alle 5-6 Server.
|
||||
- KEIN eingebettetes MCP-over-HTTP (verworfen: hand-rolled Protokoll auf Internet-Oberflaeche = mehr Risiko).
|
||||
- Bridge-Code identisch fuer Direkt-IP vs spaeterer Tunnel (proxyt host:port).
|
||||
- Verbindungscode: `rddiag:v1:<base64url(JSON {v,h:host,p:port,t:token,n?:name,fp?:certFp})>`. Nutzer liefert
|
||||
oeffentlichen Host (nicht auto-detecten).
|
||||
|
||||
## Sicherheitsmodell (Advisor, first-class)
|
||||
Plain HTTP + Bearer ueber Internet = sniffbares Token mit Lesezugriff auf sensible Logs. Mitigations:
|
||||
- App-seitige IP-Allowlist (extractDebugClientIp existiert schon).
|
||||
- `/trace/config` MUTIERT → "read-only"-Claim auditieren: Writes von Remote-Oberflaeche gaten oder umlabeln.
|
||||
- Opt-in + sofort widerrufbar (Token-Rotation killt Zugang).
|
||||
- debug_token.txt in userData bestaetigen (ueberlebt Auto-Update).
|
||||
- Optional self-signed Cert + Fingerprint im Code gepinnt (NICHT v1-blockierend).
|
||||
|
||||
## Phasen
|
||||
- [x] **P0 Vertical Slice (Diskriminator) — ERLEDIGT, harness ALL PASS:** Bridge → debug-server via stdio JSON-RPC, echte Daten zurueck.
|
||||
- [x] MCP SDK API holen (context7) → @modelcontextprotocol/sdk 1.29.0, registerTool(name,{inputSchema:zodShape},cb)
|
||||
- [x] Bridge in `tools/rd-diagnostics-mcp/` (eigenes package.json, NICHT in App-Bundle): code.mjs/http.mjs/bridge.mjs/gen-code.mjs
|
||||
- [x] 14 Tools: rd_servers/rd_ping/rd_diagnostics/rd_status/rd_items/rd_packages/rd_errors/rd_logs/rd_history/rd_accounts/rd_host/rd_self_check/rd_get + Multi-Server (code|server|RDDIAG_CODE|RDDIAG_SERVERS)
|
||||
- [x] Verbindungscode-Codec rddiag:v1:base64url({v,h,p,t,n?,fp?,s?})
|
||||
- [x] Test-Harness (test/harness.mjs): fake debug-server (auth+routes+query-echo) + Bridge als stdio-Child → 19 Checks gruen (handshake, tools/list, ping, diagnostics+query-passthrough, logs-mapping, errors, escape-hatch, 401, missing-code, unreachable+hint)
|
||||
- [x] **P1 Security-Hardening — ERLEDIGT:** IP-Allowlist (exakt+CIDR), erzwungen VOR Auth am ECHTEN Socket-Peer (req.socket.remoteAddress), NICHT X-Forwarded-For (Advisor: XFF faelschbar → Bypass; gefixt+Threat-Test). Fail-closed. /trace/config belassen (zeitbegrenzt). userData/runtime verifiziert. Log-Audit: keine Secrets in /logs/*.
|
||||
- [x] **P2 One-Click-Enable + Code — ERLEDIGT:** restartDebugServer ('close'+closeAllConnections, EADDRINUSE). IPC get/enable/disable/rotate + Controller + Typen. Flache Modal-UI (Hilfe→Remote-Support→"Ferndiagnose (MCP)"): Status, lokal/netzwerk, Public-Host+Chips, Allowlist, Code+Copy+Token-Rotation+Deaktivieren.
|
||||
- [x] **P3 — durch bestehende Endpunkte abgedeckt:** /accounts (Cooldown/Rotation), /errors, /status, /diagnostics via Bridge. Kein neuer Endpunkt noetig.
|
||||
- [x] **P4 Verify — ERLEDIGT:** Suite 906 gruen, tsc=6, Harness gruen, Advisor (fing XFF-Bypass). Release v1.7.223 Gitea (6b52678, 4 Assets) + Mirror (24485be, 4 Assets, Claude-frei). Bridge `claude mcp add` (user, ✔ Connected).
|
||||
- [ ] **P5 Reachability (NUTZER):** Ferndiagnose auf 1 Server an → Code an mich → ich verbinde. Entscheid: Tunnel (sicherste, "Nur lokal") vs Direkt-Bind 0.0.0.0+Allowlist (nur vertrauenswuerdiges Netz/VPN; Token reist plain HTTP).
|
||||
|
||||
## Review
|
||||
Vertikaler Slice zuerst (Bridge gegen Fake-Debug-Server, JSON-RPC stdio), dann App-Seite load-bearing-first
|
||||
(Backend+Tests vor UI). Advisor fing einen releaseblockierenden Bug: Allowlist nutzte extractDebugClientIp
|
||||
(X-Forwarded-For zuerst = angreiferkontrolliert) → Bypass per `X-Forwarded-For: 127.0.0.1`; Tests maskierten es
|
||||
(injizierten die IP per genau dem Header). Fix: Enforcement am Socket-Peer, XFF nur fuers Log; Threat-Test
|
||||
(socket 8.8.8.8 + XFF 127.0.0.1 → denied). Empfohlener Transport: Loopback+Tunnel; Direkt-Bind nur mit Allowlist.
|
||||
@ -5,7 +5,7 @@ import path from "node:path";
|
||||
import { once } from "node:events";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { buildBackupPayload, resolveRemoteDiagnosticsRestore, BackupRemoteDiagnostics } from "../src/main/backup-payload";
|
||||
import { buildBackupPayload, resolveMcpRemoteRestore, BackupMcpRemote } from "../src/main/backup-payload";
|
||||
import { defaultSettings } from "../src/main/constants";
|
||||
import { normalizeSettings } from "../src/main/storage";
|
||||
import {
|
||||
@ -21,14 +21,14 @@ import type { AppSettings, SessionState } from "../src/shared/types";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
function input(settingsOverride: Partial<AppSettings>, remoteDiagnostics?: BackupRemoteDiagnostics) {
|
||||
function input(settingsOverride: Partial<AppSettings>, mcpRemote?: BackupMcpRemote) {
|
||||
return {
|
||||
settings: { ...defaultSettings(), ...settingsOverride } as AppSettings,
|
||||
appVersion: "1.7.233",
|
||||
exportedAt: "2026-08-01T00:00:00.000Z",
|
||||
appVersion: "1.7.224",
|
||||
exportedAt: "2026-06-19T00:00:00.000Z",
|
||||
session: {} as unknown as SessionState,
|
||||
history: [],
|
||||
remoteDiagnostics
|
||||
mcpRemote
|
||||
};
|
||||
}
|
||||
|
||||
@ -74,96 +74,77 @@ afterEach(() => {
|
||||
}
|
||||
});
|
||||
|
||||
describe("backup remoteDiagnostics export gating", () => {
|
||||
it("includes remoteDiagnostics when backupIncludeRemoteDiagnostics is on", () => {
|
||||
const settings = {
|
||||
...defaultSettings(),
|
||||
backupIncludeRemoteDiagnostics: true
|
||||
};
|
||||
const payload = buildBackupPayload({
|
||||
settings,
|
||||
appVersion: "1.7.233",
|
||||
exportedAt: "2026-08-01T00:00:00.000Z",
|
||||
session: {} as unknown as SessionState,
|
||||
history: [],
|
||||
remoteDiagnostics: {
|
||||
allowlist: ["192.0.2.0/24"],
|
||||
port: 8976,
|
||||
hostMode: "network"
|
||||
}
|
||||
});
|
||||
|
||||
expect(payload.remoteDiagnostics).toEqual({
|
||||
allowlist: ["192.0.2.0/24"],
|
||||
port: 8976,
|
||||
hostMode: "network"
|
||||
});
|
||||
describe("backup mcpRemote export gating", () => {
|
||||
it("includes mcpRemote when backupIncludeMcp is on", () => {
|
||||
const section: BackupMcpRemote = { allowlist: ["10.0.0.5", "192.168.1.0/24"], port: 9999, hostMode: "network" };
|
||||
const payload = buildBackupPayload(input({ backupIncludeMcp: true }, section));
|
||||
expect(payload.mcpRemote).toEqual(section);
|
||||
});
|
||||
|
||||
it("omits remoteDiagnostics when the toggle is off even if a section is provided", () => {
|
||||
const payload = buildBackupPayload(input({ backupIncludeRemoteDiagnostics: false }, { allowlist: ["10.0.0.5"], port: 9868, hostMode: "network" }));
|
||||
expect(payload.remoteDiagnostics).toBeUndefined();
|
||||
it("omits mcpRemote when the toggle is off even if a section is provided", () => {
|
||||
const payload = buildBackupPayload(input({ backupIncludeMcp: false }, { allowlist: ["10.0.0.5"], port: 9868, hostMode: "network" }));
|
||||
expect(payload.mcpRemote).toBeUndefined();
|
||||
});
|
||||
|
||||
it("omits remoteDiagnostics when toggle on but no section gathered", () => {
|
||||
const payload = buildBackupPayload(input({ backupIncludeRemoteDiagnostics: true }, undefined));
|
||||
expect(payload.remoteDiagnostics).toBeUndefined();
|
||||
it("omits mcpRemote when toggle on but no section gathered", () => {
|
||||
const payload = buildBackupPayload(input({ backupIncludeMcp: true }, undefined));
|
||||
expect(payload.mcpRemote).toBeUndefined();
|
||||
});
|
||||
|
||||
it("the remoteDiagnostics section carries ONLY allowlist/port/hostMode (no token, publicHost, name)", () => {
|
||||
const payload = buildBackupPayload(input({ backupIncludeRemoteDiagnostics: true }, { allowlist: ["10.0.0.5"], port: 9868, hostMode: "network" }));
|
||||
expect(payload.remoteDiagnostics && Object.keys(payload.remoteDiagnostics).sort()).toEqual(["allowlist", "hostMode", "port"]);
|
||||
const sectionJson = JSON.stringify(payload.remoteDiagnostics);
|
||||
it("the mcpRemote section carries ONLY allowlist/port/hostMode (no token, publicHost, name)", () => {
|
||||
const payload = buildBackupPayload(input({ backupIncludeMcp: true }, { allowlist: ["10.0.0.5"], port: 9868, hostMode: "network" }));
|
||||
expect(payload.mcpRemote && Object.keys(payload.mcpRemote).sort()).toEqual(["allowlist", "hostMode", "port"]);
|
||||
const sectionJson = JSON.stringify(payload.mcpRemote);
|
||||
expect(sectionJson.toLowerCase()).not.toContain("token");
|
||||
expect(sectionJson).not.toContain("publicHost");
|
||||
expect(sectionJson.toLowerCase()).not.toContain("\"name\"");
|
||||
});
|
||||
});
|
||||
|
||||
describe("backupIncludeRemoteDiagnostics settings persistence", () => {
|
||||
it("normalizeSettings preserves backupIncludeRemoteDiagnostics (the toggle survives save/load)", () => {
|
||||
expect(normalizeSettings({ backupIncludeRemoteDiagnostics: true } as unknown as AppSettings).backupIncludeRemoteDiagnostics).toBe(true);
|
||||
expect(normalizeSettings({ backupIncludeRemoteDiagnostics: false } as unknown as AppSettings).backupIncludeRemoteDiagnostics).toBe(false);
|
||||
expect(normalizeSettings({} as unknown as AppSettings).backupIncludeRemoteDiagnostics).toBe(false);
|
||||
describe("backupIncludeMcp settings persistence", () => {
|
||||
it("normalizeSettings preserves backupIncludeMcp (the toggle survives save/load)", () => {
|
||||
expect(normalizeSettings({ backupIncludeMcp: true } as unknown as AppSettings).backupIncludeMcp).toBe(true);
|
||||
expect(normalizeSettings({ backupIncludeMcp: false } as unknown as AppSettings).backupIncludeMcp).toBe(false);
|
||||
expect(normalizeSettings({} as unknown as AppSettings).backupIncludeMcp).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveRemoteDiagnosticsRestore", () => {
|
||||
describe("resolveMcpRemoteRestore", () => {
|
||||
it("maps network + non-empty allowlist to 0.0.0.0", () => {
|
||||
expect(resolveRemoteDiagnosticsRestore({ allowlist: ["10.0.0.5"], port: 9868, hostMode: "network" }))
|
||||
expect(resolveMcpRemoteRestore({ allowlist: ["10.0.0.5"], port: 9868, hostMode: "network" }))
|
||||
.toEqual({ host: "0.0.0.0", port: 9868, allowlist: ["10.0.0.5"] });
|
||||
});
|
||||
|
||||
it("SAFETY: network with EMPTY allowlist binds local, never 0.0.0.0", () => {
|
||||
expect(resolveRemoteDiagnosticsRestore({ allowlist: [], port: 9868, hostMode: "network" })?.host).toBe("127.0.0.1");
|
||||
expect(resolveMcpRemoteRestore({ allowlist: [], port: 9868, hostMode: "network" })?.host).toBe("127.0.0.1");
|
||||
});
|
||||
|
||||
it("maps local to 127.0.0.1", () => {
|
||||
expect(resolveRemoteDiagnosticsRestore({ allowlist: ["10.0.0.5"], port: 9868, hostMode: "local" })?.host).toBe("127.0.0.1");
|
||||
expect(resolveMcpRemoteRestore({ allowlist: ["10.0.0.5"], port: 9868, hostMode: "local" })?.host).toBe("127.0.0.1");
|
||||
});
|
||||
|
||||
it("rejects an out-of-range or non-integer port", () => {
|
||||
expect(resolveRemoteDiagnosticsRestore({ allowlist: ["10.0.0.5"], port: 80, hostMode: "network" })?.port).toBeUndefined();
|
||||
expect(resolveRemoteDiagnosticsRestore({ allowlist: ["10.0.0.5"], port: 70000, hostMode: "network" })?.port).toBeUndefined();
|
||||
expect(resolveRemoteDiagnosticsRestore({ allowlist: ["10.0.0.5"], port: 9868.5, hostMode: "network" })?.port).toBeUndefined();
|
||||
expect(resolveMcpRemoteRestore({ allowlist: ["10.0.0.5"], port: 80, hostMode: "network" })?.port).toBeUndefined();
|
||||
expect(resolveMcpRemoteRestore({ allowlist: ["10.0.0.5"], port: 70000, hostMode: "network" })?.port).toBeUndefined();
|
||||
expect(resolveMcpRemoteRestore({ allowlist: ["10.0.0.5"], port: 9868.5, hostMode: "network" })?.port).toBeUndefined();
|
||||
});
|
||||
|
||||
it("filters non-string and blank allowlist entries and trims", () => {
|
||||
const r = resolveRemoteDiagnosticsRestore({ allowlist: ["10.0.0.5", "", " ", 5, null, " 8.8.8.8 "], port: 9868, hostMode: "network" });
|
||||
const r = resolveMcpRemoteRestore({ allowlist: ["10.0.0.5", "", " ", 5, null, " 8.8.8.8 "], port: 9868, hostMode: "network" });
|
||||
expect(r?.allowlist).toEqual(["10.0.0.5", "8.8.8.8"]);
|
||||
});
|
||||
|
||||
it("returns null for missing or empty/invalid sections", () => {
|
||||
expect(resolveRemoteDiagnosticsRestore(undefined)).toBeNull();
|
||||
expect(resolveRemoteDiagnosticsRestore(null)).toBeNull();
|
||||
expect(resolveRemoteDiagnosticsRestore("x")).toBeNull();
|
||||
expect(resolveRemoteDiagnosticsRestore({})).toBeNull();
|
||||
expect(resolveMcpRemoteRestore(undefined)).toBeNull();
|
||||
expect(resolveMcpRemoteRestore(null)).toBeNull();
|
||||
expect(resolveMcpRemoteRestore("x")).toBeNull();
|
||||
expect(resolveMcpRemoteRestore({})).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("backup remoteDiagnostics live restore round-trip", () => {
|
||||
describe("backup mcpRemote live restore round-trip", () => {
|
||||
it("export -> resolve -> apply is reflected in the running debug-server (proves restart fired)", async () => {
|
||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-backup-remote-"));
|
||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bkmcp-"));
|
||||
tempDirs.push(baseDir);
|
||||
const startPort = await getFreePort();
|
||||
const restorePort = await getFreePort();
|
||||
@ -176,11 +157,11 @@ describe("backup remoteDiagnostics live restore round-trip", () => {
|
||||
expect(getDebugAllowlist()).toEqual([]);
|
||||
|
||||
const payload = buildBackupPayload(input(
|
||||
{ backupIncludeRemoteDiagnostics: true },
|
||||
{ backupIncludeMcp: true },
|
||||
{ allowlist: ["203.0.113.4", "10.0.0.0/24"], port: restorePort, hostMode: "network" }
|
||||
));
|
||||
|
||||
const restore = resolveRemoteDiagnosticsRestore(payload.remoteDiagnostics);
|
||||
const restore = resolveMcpRemoteRestore(payload.mcpRemote);
|
||||
expect(restore).not.toBeNull();
|
||||
writeDebugServerConfig({ host: restore!.host, port: restore!.port, allowlist: restore!.allowlist });
|
||||
const status = await restartDebugServer();
|
||||
@ -197,7 +178,7 @@ describe("backup remoteDiagnostics live restore round-trip", () => {
|
||||
});
|
||||
|
||||
it("full-backup path writes the debug_* files to disk without a restart (boot picks them up)", async () => {
|
||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-backup-remote2-"));
|
||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bkmcp2-"));
|
||||
tempDirs.push(baseDir);
|
||||
const startPort = await getFreePort();
|
||||
fs.writeFileSync(path.join(baseDir, "debug_token.txt"), "rt2", "utf8");
|
||||
@ -207,7 +188,7 @@ describe("backup remoteDiagnostics live restore round-trip", () => {
|
||||
startDebugServer({} as unknown as DownloadManager, baseDir);
|
||||
await waitForReady(`http://127.0.0.1:${startPort}/health?token=rt2`);
|
||||
|
||||
const restore = resolveRemoteDiagnosticsRestore({ allowlist: ["198.51.100.9"], port: 9100, hostMode: "network" });
|
||||
const restore = resolveMcpRemoteRestore({ allowlist: ["198.51.100.9"], port: 9100, hostMode: "network" });
|
||||
writeDebugServerConfig({ host: restore!.host, port: restore!.port, allowlist: restore!.allowlist });
|
||||
|
||||
expect(fs.readFileSync(path.join(baseDir, "debug_host.txt"), "utf8").trim()).toBe("0.0.0.0");
|
||||
@ -1,21 +1,9 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { encodeConnectionCode } from "../src/main/connection-code";
|
||||
|
||||
function decodeConnectionCode(code: string) {
|
||||
const encoded = code.slice("rddiag:v1:".length).replace(/-/g, "+").replace(/_/g, "/");
|
||||
const payload = JSON.parse(Buffer.from(encoded, "base64").toString("utf8")) as Record<string, unknown>;
|
||||
return {
|
||||
host: payload.h,
|
||||
port: payload.p,
|
||||
token: payload.t,
|
||||
name: payload.n,
|
||||
scheme: payload.s ?? "http",
|
||||
fingerprint: payload.fp
|
||||
};
|
||||
}
|
||||
import { decodeConnectionCode } from "../tools/rd-diagnostics-mcp/src/code.mjs";
|
||||
|
||||
describe("connection-code", () => {
|
||||
it("encodes a directly decodable payload", () => {
|
||||
it("round-trips through the bridge decoder", () => {
|
||||
const code = encodeConnectionCode({ host: "203.0.113.5", port: 9868, token: "deadbeef", name: "server-1" });
|
||||
expect(code.startsWith("rddiag:v1:")).toBe(true);
|
||||
const decoded = decodeConnectionCode(code);
|
||||
|
||||
@ -55,21 +55,6 @@ import type { DownloadManager } from "../src/main/download-manager";
|
||||
import type { UiSnapshot } from "../src/shared/types";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
const legacyManifestFile = ["debug_", "a", "i", "_manifest.json"].join("");
|
||||
const legacyManifestField = ["a", "i", "Manifest"].join("");
|
||||
const forbiddenSupportMarkers = [
|
||||
["A", "I"].join(""),
|
||||
["assist", "ant"].join(""),
|
||||
["assist", "ants"].join(""),
|
||||
["ag", "ent"].join(""),
|
||||
["ag", "ents"].join(""),
|
||||
["Clau", "de"].join(""),
|
||||
["Anthro", "pic"].join(""),
|
||||
["Co", "dex"].join(""),
|
||||
["Open", "AI"].join(""),
|
||||
["M", "C", "P"].join(""),
|
||||
["K", "I"].join("")
|
||||
];
|
||||
|
||||
async function getFreePort(): Promise<number> {
|
||||
const probe = http.createServer();
|
||||
@ -355,30 +340,26 @@ describe("debug-server", () => {
|
||||
expect(payload.history?.total).toBe(1);
|
||||
});
|
||||
|
||||
it("writes a machine-readable support manifest into the runtime folder", async () => {
|
||||
it("writes a machine-readable AI support manifest into the runtime folder", async () => {
|
||||
const fixture = await createFixture();
|
||||
const manifestPath = path.join(fixture.baseDir, "debug_support_manifest.json");
|
||||
const manifestPath = path.join(fixture.baseDir, "debug_ai_manifest.json");
|
||||
expect(fs.existsSync(manifestPath)).toBe(true);
|
||||
expect(fs.existsSync(path.join(fixture.baseDir, legacyManifestFile))).toBe(false);
|
||||
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as Record<string, any>;
|
||||
expect(manifest.purpose).toBe("Machine-readable manifest for support tooling and remote troubleshooting.");
|
||||
expect(JSON.stringify(manifest)).not.toMatch(new RegExp(`\\b(?:${forbiddenSupportMarkers.join("|")})\\b`, "i"));
|
||||
expect(JSON.stringify(manifest)).not.toContain(fixture.token);
|
||||
expect(manifest.appVersion).toBeTruthy();
|
||||
expect(manifest.debugServer?.port).toBeGreaterThan(0);
|
||||
expect(manifest.debugServer?.remoteBaseUrlTemplate).toContain("<SERVER_IP_OR_DNS>");
|
||||
expect(manifest.remoteAccessRequirements).toContain("A reachable server IP or DNS name.");
|
||||
expect(manifest.quickstart?.[1]).toContain("server IP");
|
||||
expect(manifest.setupCheckEndpoint).toBe("/debug/setup");
|
||||
expect(manifest.selfCheckEndpoint).toBe("/self-check");
|
||||
expect(manifest.runtimeFiles?.tokenFile).toContain("debug_token.txt");
|
||||
expect(manifest.endpoints?.some((entry: Record<string, any>) => entry.path === "/diagnostics")).toBe(true);
|
||||
expect(manifest.endpoints?.some((entry: Record<string, any>) => entry.path === "/logs/main")).toBe(true);
|
||||
expect(JSON.stringify(manifest)).not.toContain(fixture.token);
|
||||
|
||||
const metaResponse = await fetch(`${fixture.baseUrl}/meta?token=${fixture.token}`);
|
||||
expect(metaResponse.ok).toBe(true);
|
||||
const metaPayload = await metaResponse.json() as Record<string, any>;
|
||||
expect(metaPayload.supportFiles?.supportManifest).toBe(manifestPath);
|
||||
expect(metaPayload.supportFiles?.[legacyManifestField]).toBeUndefined();
|
||||
expect(metaPayload.supportFiles?.aiManifest).toBe(manifestPath);
|
||||
expect(metaPayload.supportFiles?.traceConfig).toBe(getTraceConfigPath());
|
||||
expect(metaPayload.supportFiles?.traceLog).toBe(getTraceLogPath());
|
||||
expect(metaPayload.logPaths?.rename).toBe(getRenameLogPath());
|
||||
@ -398,9 +379,7 @@ describe("debug-server", () => {
|
||||
expect(payload.host).toBe("0.0.0.0");
|
||||
expect(payload.localOnly).toBe(false);
|
||||
expect(payload.tokenConfigured).toBe(true);
|
||||
expect(payload.supportManifestPresent).toBe(true);
|
||||
expect(payload[`${legacyManifestField}Present`]).toBeUndefined();
|
||||
expect(payload.supportManifestPath).toBe(path.join(fixture.baseDir, "debug_support_manifest.json"));
|
||||
expect(payload.aiManifestPresent).toBe(true);
|
||||
expect(payload.traceEnabled).toBe(true);
|
||||
expect(payload.traceAutoDisableAt).toBeTruthy();
|
||||
expect(payload.diskSpace?.runtime?.freeBytes).toBeGreaterThan(0);
|
||||
@ -532,7 +511,6 @@ describe("debug-server", () => {
|
||||
|
||||
it("downloads a support bundle zip", async () => {
|
||||
const fixture = await createFixture();
|
||||
fs.writeFileSync(path.join(fixture.baseDir, legacyManifestFile), JSON.stringify({ purpose: "legacy" }), "utf8");
|
||||
const response = await fetch(`${fixture.baseUrl}/support/bundle?token=${fixture.token}`);
|
||||
expect(response.ok).toBe(true);
|
||||
expect(response.headers.get("content-type")).toContain("application/zip");
|
||||
@ -548,10 +526,7 @@ describe("debug-server", () => {
|
||||
expect(entries).toContain("logs/audit.log");
|
||||
expect(entries).toContain("logs/rename.log");
|
||||
expect(entries).toContain("logs/trace.log");
|
||||
expect(entries).toContain("runtime/debug_support_manifest.json");
|
||||
expect(entries).toContain("overview/support-manifest.json");
|
||||
expect(entries).not.toContain(`runtime/${legacyManifestFile}`);
|
||||
expect(entries).not.toContain(["overview/", "a", "i-manifest.json"].join(""));
|
||||
expect(entries).toContain("runtime/debug_ai_manifest.json");
|
||||
expect(entries).not.toContain("runtime/debug_token.txt");
|
||||
});
|
||||
|
||||
|
||||
@ -166,50 +166,6 @@ afterEach(async () => {
|
||||
});
|
||||
|
||||
describe("download manager", () => {
|
||||
it("applies an imported settings snapshot without touching queued items or filesystem workflows", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-settings-import-"));
|
||||
tempDirs.push(root);
|
||||
const settings = { ...defaultSettings(), outputDir: path.join(root, "downloads") };
|
||||
const manager = new DownloadManager(settings, emptySession(), createStoragePaths(path.join(root, "state")));
|
||||
manager.addPackages([{ name: "queue", links: ["https://example.com/file.bin"] }]);
|
||||
const item = Object.values((manager as any).session.items)[0] as { provider: string | null };
|
||||
item.provider = "realdebrid";
|
||||
const resolveSpy = vi.spyOn(manager as any, "resolveExistingQueuedOpaqueFilenames");
|
||||
const cleanupSpy = vi.spyOn(manager as any, "cleanupExistingExtractedArchives");
|
||||
const retroactiveCleanupSpy = vi.spyOn(manager as any, "applyRetroactiveCleanupPolicy");
|
||||
|
||||
manager.setSettings({
|
||||
...settings,
|
||||
providerOrder: ["bestdebrid", "realdebrid"],
|
||||
hosterRouting: { "example.com": "bestdebrid" },
|
||||
completedCleanupPolicy: "immediate"
|
||||
}, { settingsOnlyImport: true });
|
||||
|
||||
expect(item.provider).toBe("realdebrid");
|
||||
expect(resolveSpy).not.toHaveBeenCalled();
|
||||
expect(cleanupSpy).not.toHaveBeenCalled();
|
||||
expect(retroactiveCleanupSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clears the BestDebrid circuit breaker when its token changes", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-best-token-"));
|
||||
tempDirs.push(root);
|
||||
const stateDir = path.join(root, "state");
|
||||
fs.mkdirSync(stateDir, { recursive: true });
|
||||
const settings = { ...defaultSettings(), bestToken: "old-token" };
|
||||
const manager = new DownloadManager(settings, emptySession(), createStoragePaths(stateDir));
|
||||
const failures = (manager as any).providerFailures as Map<string, unknown>;
|
||||
failures.set("bestdebrid", { count: 3, lastFailAt: 1, cooldownUntil: Date.now() + 60_000 });
|
||||
failures.set("bestdebrid:rapidgator.net", { count: 3, lastFailAt: 1, cooldownUntil: Date.now() + 60_000 });
|
||||
failures.set("realdebrid", { count: 3, lastFailAt: 1, cooldownUntil: Date.now() + 60_000 });
|
||||
|
||||
manager.setSettings({ ...settings, bestToken: "new-token" });
|
||||
|
||||
expect(failures.has("bestdebrid")).toBe(false);
|
||||
expect(failures.has("bestdebrid:rapidgator.net")).toBe(false);
|
||||
expect(failures.has("realdebrid")).toBe(true);
|
||||
});
|
||||
|
||||
it("records history duration from the first actual package start", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-history-"));
|
||||
tempDirs.push(root);
|
||||
@ -4903,76 +4859,6 @@ describe("download manager", () => {
|
||||
expect(fs.existsSync(targetPath)).toBe(false);
|
||||
});
|
||||
|
||||
it("requeues near-total non-binary pre-allocated leftovers during post-processing", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||
tempDirs.push(root);
|
||||
|
||||
const session = emptySession();
|
||||
const packageId = "postproc-near-total-prealloc-pkg";
|
||||
const itemId = "postproc-near-total-prealloc-item";
|
||||
const createdAt = Date.now() - 20_000;
|
||||
const outputDir = path.join(root, "downloads", "postproc-near-total-prealloc");
|
||||
const targetPath = path.join(outputDir, "postproc-near-total-prealloc.data");
|
||||
const totalBytes = 2 * 1024 * 1024;
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
fs.writeFileSync(targetPath, Buffer.alloc(totalBytes - 1, 0));
|
||||
|
||||
session.packageOrder = [packageId];
|
||||
session.packages[packageId] = {
|
||||
id: packageId,
|
||||
name: "postproc-near-total-prealloc",
|
||||
outputDir,
|
||||
extractDir: path.join(root, "extract", "postproc-near-total-prealloc"),
|
||||
status: "queued",
|
||||
itemIds: [itemId],
|
||||
cancelled: false,
|
||||
enabled: true,
|
||||
createdAt,
|
||||
updatedAt: createdAt
|
||||
};
|
||||
session.items[itemId] = {
|
||||
id: itemId,
|
||||
packageId,
|
||||
url: "https://dummy/postproc-near-total-prealloc",
|
||||
provider: "realdebrid",
|
||||
status: "queued",
|
||||
retries: 0,
|
||||
speedBps: 0,
|
||||
downloadedBytes: 0,
|
||||
totalBytes,
|
||||
progressPercent: 0,
|
||||
fileName: "postproc-near-total-prealloc.data",
|
||||
targetPath,
|
||||
resumable: true,
|
||||
attempts: 1,
|
||||
lastError: "",
|
||||
fullStatus: "Wartet",
|
||||
createdAt,
|
||||
updatedAt: createdAt
|
||||
};
|
||||
|
||||
const manager = new DownloadManager(
|
||||
{
|
||||
...defaultSettings(),
|
||||
token: "rd-token",
|
||||
outputDir: path.join(root, "downloads"),
|
||||
extractDir: path.join(root, "extract"),
|
||||
autoExtract: false
|
||||
},
|
||||
session,
|
||||
createStoragePaths(path.join(root, "state"))
|
||||
);
|
||||
|
||||
await (manager as any).handlePackagePostProcessing(packageId);
|
||||
const snapshot = manager.getSnapshot();
|
||||
const item = snapshot.session.items[itemId];
|
||||
expect(item?.status).toBe("queued");
|
||||
expect(item?.fullStatus).toContain("pre-alloc");
|
||||
expect(item?.downloadedBytes).toBe(0);
|
||||
expect(item?.progressPercent).toBe(0);
|
||||
expect(fs.existsSync(targetPath)).toBe(false);
|
||||
});
|
||||
|
||||
it("requeues completed archive parts after auto-recovery extraction failures", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||
tempDirs.push(root);
|
||||
|
||||
@ -12,8 +12,6 @@ import {
|
||||
archiveFilenamePasswords,
|
||||
detectArchiveSignature,
|
||||
classifyExtractionError,
|
||||
ExtractionError,
|
||||
selectZipFallbackError,
|
||||
shouldSerialRetryParallelFailures,
|
||||
findArchiveCandidates,
|
||||
orderExtractorCandidatesForArchive,
|
||||
@ -25,6 +23,7 @@ import {
|
||||
const tempDirs: string[] = [];
|
||||
const originalExtractBackend = process.env.RD_EXTRACT_BACKEND;
|
||||
const originalStatfs = fs.promises.statfs;
|
||||
const originalZipEntryMemoryLimit = process.env.RD_ZIP_ENTRY_MEMORY_LIMIT_MB;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.RD_EXTRACT_BACKEND = "legacy";
|
||||
@ -40,6 +39,11 @@ afterEach(() => {
|
||||
process.env.RD_EXTRACT_BACKEND = originalExtractBackend;
|
||||
}
|
||||
(fs.promises as any).statfs = originalStatfs;
|
||||
if (originalZipEntryMemoryLimit === undefined) {
|
||||
delete process.env.RD_ZIP_ENTRY_MEMORY_LIMIT_MB;
|
||||
} else {
|
||||
process.env.RD_ZIP_ENTRY_MEMORY_LIMIT_MB = originalZipEntryMemoryLimit;
|
||||
}
|
||||
});
|
||||
|
||||
describe("extractor", () => {
|
||||
@ -589,25 +593,31 @@ describe("extractor", () => {
|
||||
expect(targets.has(r02)).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves the original ZIP size guard error when no external extractor is available", () => {
|
||||
const internalError = new Error("ZIP-Eintrag zu groß für sichere Speicher-Extraktion");
|
||||
const externalError = new ExtractionError("Kein nativer Entpacker gefunden", "no_extractor");
|
||||
it("keeps original ZIP size guard error when external fallback is unavailable", async () => {
|
||||
process.env.RD_ZIP_ENTRY_MEMORY_LIMIT_MB = "8";
|
||||
|
||||
expect(selectZipFallbackError(internalError, externalError)).toBe(internalError);
|
||||
});
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-"));
|
||||
tempDirs.push(root);
|
||||
const packageDir = path.join(root, "pkg");
|
||||
const targetDir = path.join(root, "out");
|
||||
fs.mkdirSync(packageDir, { recursive: true });
|
||||
|
||||
it("preserves the original ZIP size guard error for an unsupported external archive format", () => {
|
||||
const internalError = new Error("ZIP-Eintrag zu groß für sichere Speicher-Extraktion");
|
||||
const externalError = new ExtractionError("Is not archive", "unsupported_format");
|
||||
const zipPath = path.join(packageDir, "too-large.zip");
|
||||
const zip = new AdmZip();
|
||||
zip.addFile("large.bin", Buffer.alloc(9 * 1024 * 1024, 7));
|
||||
zip.writeZip(zipPath);
|
||||
|
||||
expect(selectZipFallbackError(internalError, externalError)).toBe(internalError);
|
||||
});
|
||||
|
||||
it("returns other external ZIP fallback errors unchanged", () => {
|
||||
const internalError = new Error("ZIP-Eintrag zu groß für sichere Speicher-Extraktion");
|
||||
const externalError = new ExtractionError("CRC failed", "crc_error");
|
||||
|
||||
expect(selectZipFallbackError(internalError, externalError)).toBe(externalError);
|
||||
const result = await extractPackageArchives({
|
||||
packageDir,
|
||||
targetDir,
|
||||
cleanupMode: "none",
|
||||
conflictMode: "overwrite",
|
||||
removeLinks: false,
|
||||
removeSamples: false
|
||||
});
|
||||
expect(result.extracted).toBe(0);
|
||||
expect(result.failed).toBe(1);
|
||||
expect(String(result.lastError)).toMatch(/ZIP-Eintrag.*groß/i);
|
||||
});
|
||||
|
||||
it.skipIf(process.platform !== "win32")("matches resume-state archive names case-insensitively on Windows", async () => {
|
||||
@ -1037,12 +1047,6 @@ describe("extractor", () => {
|
||||
expect(classifyExtractionError("UNSUPPORTEDMETHOD")).toBe("unsupported_format");
|
||||
});
|
||||
|
||||
it("classifies native 7-Zip Cannot open the file as archive errors as unsupported format", () => {
|
||||
expect(classifyExtractionError(
|
||||
new Error("Open ERROR: Cannot open the file as [zip] archive ERRORS: Is not archive")
|
||||
)).toBe("unsupported_format");
|
||||
});
|
||||
|
||||
it("classifies disk full", () => {
|
||||
expect(classifyExtractionError("Nicht genug Speicherplatz")).toBe("disk_full");
|
||||
expect(classifyExtractionError("No space left on device")).toBe("disk_full");
|
||||
|
||||
@ -1,45 +0,0 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { once } from "node:events";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { AppSettings } from "../src/shared/types";
|
||||
import { createBackupServer } from "../services/backup-api/src/server.mjs";
|
||||
import { createOnlineBackup, deleteOnlineBackup, downloadOnlineBackup, uploadOnlineBackup } from "../src/main/online-backup";
|
||||
|
||||
const servers: ReturnType<typeof createBackupServer>[] = [];
|
||||
const directories: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(servers.splice(0).map((server) => new Promise<void>((resolve) => server.close(() => resolve()))));
|
||||
await Promise.all(directories.splice(0).map((directory) => fs.promises.rm(directory, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
describe("online backup client and service", () => {
|
||||
it("keeps every export independently restorable and deletes only the selected snapshot", async () => {
|
||||
const rootDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "mdd-online-backup-"));
|
||||
directories.push(rootDir);
|
||||
const server = createBackupServer({ rootDir, rateLimit: { max: 20, windowMs: 60_000 } });
|
||||
servers.push(server);
|
||||
server.listen(0, "127.0.0.1");
|
||||
await once(server, "listening");
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") throw new Error("Testserver nicht verfügbar");
|
||||
const baseUrl = `http://127.0.0.1:${address.port}`;
|
||||
const firstSettings = { token: "first-secret", outputDir: "D:\\Erster Export" } as AppSettings;
|
||||
const secondSettings = { token: "second-secret", outputDir: "E:\\Zweiter Export" } as AppSettings;
|
||||
const first = createOnlineBackup(firstSettings, "2.0.0");
|
||||
const second = createOnlineBackup(secondSettings, "2.0.0");
|
||||
|
||||
await uploadOnlineBackup(first.record, baseUrl);
|
||||
await uploadOnlineBackup(second.record, baseUrl);
|
||||
|
||||
expect((await downloadOnlineBackup(first.key, baseUrl)).settings).toEqual(firstSettings);
|
||||
expect((await downloadOnlineBackup(second.key, baseUrl)).settings).toEqual(secondSettings);
|
||||
|
||||
await deleteOnlineBackup(second.key, baseUrl);
|
||||
|
||||
expect((await downloadOnlineBackup(first.key, baseUrl)).settings).toEqual(firstSettings);
|
||||
await expect(downloadOnlineBackup(second.key, baseUrl)).rejects.toThrow(/nicht gefunden/i);
|
||||
});
|
||||
});
|
||||
@ -1,142 +0,0 @@
|
||||
import http from "node:http";
|
||||
import { once } from "node:events";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { AppSettings } from "../src/shared/types";
|
||||
import {
|
||||
createOnlineBackup,
|
||||
deleteOnlineBackup,
|
||||
downloadOnlineBackup,
|
||||
parseOnlineBackupKey,
|
||||
restoreOnlineBackup,
|
||||
uploadOnlineBackup
|
||||
} from "../src/main/online-backup";
|
||||
|
||||
const servers: http.Server[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(servers.splice(0).map((server) => new Promise<void>((resolve) => server.close(() => resolve()))));
|
||||
});
|
||||
|
||||
function settings(): AppSettings {
|
||||
return {
|
||||
token: "rd-secret-token",
|
||||
megaLogin: "backup-user",
|
||||
megaPassword: "backup-password",
|
||||
outputDir: "D:\\Downloads",
|
||||
backupIncludeDownloads: true
|
||||
} as AppSettings;
|
||||
}
|
||||
|
||||
describe("online backup key", () => {
|
||||
it("creates a compact key and restores every immutable settings snapshot independently", () => {
|
||||
const first = createOnlineBackup(settings(), "2.0.0", "2026-08-07T00:00:00.000Z");
|
||||
const second = createOnlineBackup({ ...settings(), outputDir: "E:\\Neu" }, "2.0.0", "2026-08-08T00:00:00.000Z");
|
||||
|
||||
expect(first.key).toMatch(/^MDD2-[A-Za-z0-9_-]{70}$/);
|
||||
expect(first.key).toHaveLength(75);
|
||||
expect(second.key).not.toBe(first.key);
|
||||
expect(restoreOnlineBackup(first.key, first.record.blob).settings).toEqual(settings());
|
||||
expect(restoreOnlineBackup(second.key, second.record.blob).settings.outputDir).toBe("E:\\Neu");
|
||||
});
|
||||
|
||||
it("never places credentials or the decryption secret in the server record", () => {
|
||||
const created = createOnlineBackup(settings(), "2.0.0", "2026-08-07T00:00:00.000Z");
|
||||
const serialized = JSON.stringify(created.record);
|
||||
const parsed = parseOnlineBackupKey(created.key);
|
||||
|
||||
expect(serialized).not.toContain("rd-secret-token");
|
||||
expect(serialized).not.toContain("backup-password");
|
||||
expect(serialized).not.toContain(parsed.masterKey.toString("base64url"));
|
||||
expect(Object.keys(created.record).sort()).toEqual(["blob", "deleteVerifier", "id"]);
|
||||
});
|
||||
|
||||
it("rejects corrupted keys and encrypted payloads before returning settings", () => {
|
||||
const created = createOnlineBackup(settings(), "2.0.0", "2026-08-07T00:00:00.000Z");
|
||||
const keyTail = created.key.endsWith("A") ? "B" : "A";
|
||||
const corruptedKey = `${created.key.slice(0, -1)}${keyTail}`;
|
||||
const blobTail = created.record.blob.endsWith("A") ? "B" : "A";
|
||||
const corruptedBlob = `${created.record.blob.slice(0, -1)}${blobTail}`;
|
||||
|
||||
expect(() => parseOnlineBackupKey(corruptedKey)).toThrow(/Schlüssel/i);
|
||||
expect(() => restoreOnlineBackup(created.key, corruptedBlob)).toThrow(/entschlüsselt|beschädigt/i);
|
||||
});
|
||||
|
||||
it("rejects plaintext that cannot be restored before returning a key", () => {
|
||||
const oversized = { ...settings(), archivePasswordList: "x".repeat(600_000) };
|
||||
|
||||
expect(() => createOnlineBackup(oversized, "2.0.0")).toThrow(/zu groß/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("online backup transport", () => {
|
||||
it("never reflects service response bodies into client errors", async () => {
|
||||
const server = http.createServer((_request, response) => {
|
||||
response.writeHead(500, { "content-type": "application/json" });
|
||||
response.end(JSON.stringify({ error: "internal", leaked: "server-secret-value" }));
|
||||
});
|
||||
servers.push(server);
|
||||
server.listen(0, "127.0.0.1");
|
||||
await once(server, "listening");
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") throw new Error("Testserver nicht verfügbar");
|
||||
const created = createOnlineBackup(settings(), "2.0.0");
|
||||
|
||||
await expect(uploadOnlineBackup(created.record, `http://127.0.0.1:${address.port}`)).rejects.not.toThrow(/server-secret-value/);
|
||||
});
|
||||
|
||||
it("uploads and downloads through the real HTTP contract without sending the master key", async () => {
|
||||
let stored: Record<string, string> | null = null;
|
||||
let deleteRequest: Record<string, string> | null = null;
|
||||
const requestedUrls: string[] = [];
|
||||
const server = http.createServer(async (request, response) => {
|
||||
requestedUrls.push(String(request.url || ""));
|
||||
if (request.method === "POST" && request.url === "/v1/backups") {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of request) chunks.push(Buffer.from(chunk));
|
||||
stored = JSON.parse(Buffer.concat(chunks).toString("utf8")) as Record<string, string>;
|
||||
response.writeHead(201, { "content-type": "application/json" });
|
||||
response.end(JSON.stringify({ created: true }));
|
||||
return;
|
||||
}
|
||||
if (request.method === "POST" && stored && request.url === "/v1/backups/restore") {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of request) chunks.push(Buffer.from(chunk));
|
||||
const restoreRequest = JSON.parse(Buffer.concat(chunks).toString("utf8")) as Record<string, string>;
|
||||
if (restoreRequest.id !== stored.id) throw new Error("Falsche Restore-ID");
|
||||
response.writeHead(200, { "content-type": "application/json" });
|
||||
response.end(JSON.stringify({ blob: stored.blob }));
|
||||
return;
|
||||
}
|
||||
if (request.method === "POST" && stored && request.url === "/v1/backups/delete") {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of request) chunks.push(Buffer.from(chunk));
|
||||
deleteRequest = JSON.parse(Buffer.concat(chunks).toString("utf8")) as Record<string, string>;
|
||||
response.writeHead(204);
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
response.writeHead(404, { "content-type": "application/json" });
|
||||
response.end(JSON.stringify({ error: "Nicht gefunden" }));
|
||||
});
|
||||
servers.push(server);
|
||||
server.listen(0, "127.0.0.1");
|
||||
await once(server, "listening");
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") throw new Error("Testserver nicht verfügbar");
|
||||
const baseUrl = `http://127.0.0.1:${address.port}`;
|
||||
const created = createOnlineBackup(settings(), "2.0.0", "2026-08-07T00:00:00.000Z");
|
||||
|
||||
await uploadOnlineBackup(created.record, baseUrl);
|
||||
const restored = await downloadOnlineBackup(created.key, baseUrl);
|
||||
await deleteOnlineBackup(created.key, baseUrl);
|
||||
|
||||
expect(restored.settings).toEqual(settings());
|
||||
expect(stored).not.toBeNull();
|
||||
const uploadedRecord = stored as unknown as { id: string; blob: string; deleteVerifier: string };
|
||||
const capturedDeleteRequest = deleteRequest as unknown as Record<string, string>;
|
||||
expect(JSON.stringify(uploadedRecord)).not.toContain(parseOnlineBackupKey(created.key).masterKey.toString("base64url"));
|
||||
expect(capturedDeleteRequest.deleteSecret).toMatch(/^[A-Za-z0-9_-]{43}$/);
|
||||
expect(requestedUrls).toEqual(["/v1/backups", "/v1/backups/restore", "/v1/backups/delete"]);
|
||||
expect(requestedUrls.join(" ")).not.toContain(uploadedRecord.id);
|
||||
});
|
||||
});
|
||||
@ -1,391 +0,0 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import crypto from "node:crypto";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
type ReleaseVerification = {
|
||||
publish: {
|
||||
provider: string;
|
||||
owner: string;
|
||||
repo: string;
|
||||
};
|
||||
latestArtifact: string;
|
||||
missingArtifacts: string[];
|
||||
};
|
||||
|
||||
type CommandResult = {
|
||||
status: number | null;
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
error?: Error;
|
||||
};
|
||||
|
||||
type ArchiveVerification = {
|
||||
verifiedArchives: string[];
|
||||
};
|
||||
|
||||
const verifierPath = path.resolve("scripts", "verify_public_release.mjs");
|
||||
const verifierUrl = "../scripts/verify_public_release.mjs";
|
||||
const { verifyPublicRelease, verifyReleaseArchives } = await import(verifierUrl) as {
|
||||
verifyPublicRelease: (rootDir: string) => ReleaseVerification;
|
||||
verifyReleaseArchives: (
|
||||
rootDir: string,
|
||||
options: {
|
||||
sevenZipPath: string;
|
||||
runCommand: (command: string, args: string[]) => CommandResult;
|
||||
}
|
||||
) => ArchiveVerification;
|
||||
};
|
||||
const fixtureRoots: string[] = [];
|
||||
const redistributionFiles = [
|
||||
"LICENSE",
|
||||
"resources/extractor-jvm/licenses/LGPL-2.1.txt",
|
||||
"resources/extractor-jvm/licenses/7-Zip-license.txt",
|
||||
"resources/extractor-jvm/licenses/Apache-2.0.txt",
|
||||
"resources/extractor-jvm/THIRD_PARTY_NOTICES.txt"
|
||||
] as const;
|
||||
|
||||
function writeFile(rootDir: string, relativePath: string, content: string | Buffer): void {
|
||||
const filePath = path.join(rootDir, ...relativePath.split("/"));
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, content);
|
||||
}
|
||||
|
||||
function writeRedistributionFiles(rootDir: string, packaged = false): void {
|
||||
for (const relativePath of redistributionFiles) {
|
||||
const content = fs.readFileSync(path.resolve(...relativePath.split("/")));
|
||||
let targetPath: string = relativePath;
|
||||
if (packaged && relativePath === "LICENSE") {
|
||||
targetPath = "win-unpacked/resources/LICENSE";
|
||||
} else if (packaged) {
|
||||
targetPath = `win-unpacked/resources/app.asar.unpacked/${relativePath}`;
|
||||
}
|
||||
writeFile(rootDir, targetPath, content);
|
||||
}
|
||||
}
|
||||
|
||||
function writeArchivePayload(outputDir: string, omittedName = ""): void {
|
||||
for (const relativePath of redistributionFiles) {
|
||||
if (path.basename(relativePath) === omittedName) {
|
||||
continue;
|
||||
}
|
||||
const content = fs.readFileSync(path.resolve(...relativePath.split("/")));
|
||||
const targetPath = relativePath === "LICENSE"
|
||||
? "resources/LICENSE"
|
||||
: `resources/app.asar.unpacked/${relativePath}`;
|
||||
writeFile(outputDir, targetPath, content);
|
||||
}
|
||||
}
|
||||
|
||||
function createArchiveCommandRunner(omittedName = "") {
|
||||
return (command: string, args: string[]): CommandResult => {
|
||||
const archivePath = args[1] || "";
|
||||
const outputArg = args.find((arg) => arg.startsWith("-o"));
|
||||
if (!outputArg) {
|
||||
return { status: 2, stderr: "missing output directory" };
|
||||
}
|
||||
const outputDir = outputArg.slice(2);
|
||||
if (archivePath.toLowerCase().endsWith(".exe")) {
|
||||
writeFile(outputDir, "payload/app-64.7z", "nested archive");
|
||||
} else if (archivePath.toLowerCase().endsWith(".7z")) {
|
||||
writeArchivePayload(outputDir, omittedName);
|
||||
}
|
||||
return { status: command ? 0 : 2, stdout: "ok", stderr: "" };
|
||||
};
|
||||
}
|
||||
|
||||
function createReleaseFixture(): string {
|
||||
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "public-release-metadata-"));
|
||||
fixtureRoots.push(rootDir);
|
||||
const setupPayload = Buffer.from("setup");
|
||||
const setupSha512 = crypto.createHash("sha512").update(setupPayload).digest("base64");
|
||||
|
||||
writeFile(rootDir, "package.json", `${JSON.stringify({
|
||||
name: "real-debrid-downloader",
|
||||
version: "1.7.233",
|
||||
build: {
|
||||
productName: "Real-Debrid-Downloader",
|
||||
publish: {
|
||||
provider: "github",
|
||||
owner: "Sucukdeluxe",
|
||||
repo: "multi-debrid-downloader"
|
||||
},
|
||||
files: [
|
||||
"build/main/**/*",
|
||||
"build/renderer/**/*",
|
||||
"resources/extractor-jvm/**/*",
|
||||
"LICENSE",
|
||||
"package.json"
|
||||
],
|
||||
extraResources: [
|
||||
{
|
||||
from: "LICENSE",
|
||||
to: "LICENSE"
|
||||
}
|
||||
],
|
||||
nsis: {
|
||||
artifactName: "${productName}-Setup-${version}.${ext}",
|
||||
oneClick: false,
|
||||
perMachine: false,
|
||||
allowToChangeInstallationDirectory: true,
|
||||
createDesktopShortcut: true
|
||||
},
|
||||
portable: {
|
||||
artifactName: "${productName}-${version}-portable.${ext}"
|
||||
}
|
||||
}
|
||||
}, null, 2)}\n`);
|
||||
writeFile(
|
||||
rootDir,
|
||||
"latest.yml",
|
||||
`version: 1.7.233\nfiles:\n - url: Real-Debrid-Downloader-Setup-1.7.233.exe\n sha512: ${setupSha512}\n size: ${setupPayload.length}\npath: Real-Debrid-Downloader-Setup-1.7.233.exe\nsha512: ${setupSha512}\n`
|
||||
);
|
||||
writeFile(
|
||||
rootDir,
|
||||
"win-unpacked/resources/app-update.yml",
|
||||
"provider: github\nowner: Sucukdeluxe\nrepo: multi-debrid-downloader\n"
|
||||
);
|
||||
writeFile(rootDir, "Real-Debrid-Downloader-Setup-1.7.233.exe", setupPayload);
|
||||
writeFile(rootDir, "Real-Debrid-Downloader-Setup-1.7.233.exe.blockmap", "blockmap");
|
||||
writeFile(rootDir, "Real-Debrid-Downloader-1.7.233-portable.exe", "portable");
|
||||
writeRedistributionFiles(rootDir);
|
||||
writeRedistributionFiles(rootDir, true);
|
||||
|
||||
return rootDir;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const rootDir of fixtureRoots.splice(0)) {
|
||||
fs.rmSync(rootDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("public release metadata", () => {
|
||||
it("accepts the canonical GitHub release metadata and artifacts", () => {
|
||||
const rootDir = createReleaseFixture();
|
||||
|
||||
const result = verifyPublicRelease(rootDir);
|
||||
|
||||
expect(result.publish).toEqual({
|
||||
provider: "github",
|
||||
owner: "Sucukdeluxe",
|
||||
repo: "multi-debrid-downloader"
|
||||
});
|
||||
expect(result.latestArtifact).toBe("Real-Debrid-Downloader-Setup-1.7.233.exe");
|
||||
expect(result.missingArtifacts).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects a package configured for a different GitHub owner", () => {
|
||||
const rootDir = createReleaseFixture();
|
||||
const packagePath = path.join(rootDir, "package.json");
|
||||
const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8"));
|
||||
packageJson.build.publish.owner = "DifferentOwner";
|
||||
fs.writeFileSync(packagePath, `${JSON.stringify(packageJson, null, 2)}\n`);
|
||||
|
||||
expect(() => verifyPublicRelease(rootDir)).toThrow(/owner/i);
|
||||
});
|
||||
|
||||
it("rejects a latest.yml path whose artifact does not exist", () => {
|
||||
const rootDir = createReleaseFixture();
|
||||
fs.rmSync(path.join(rootDir, "Real-Debrid-Downloader-Setup-1.7.233.exe"));
|
||||
|
||||
expect(() => verifyPublicRelease(rootDir)).toThrow(/Real-Debrid-Downloader-Setup-1\.7\.233\.exe/);
|
||||
});
|
||||
|
||||
it("rejects syntactically invalid latest.yml", () => {
|
||||
const rootDir = createReleaseFixture();
|
||||
fs.writeFileSync(
|
||||
path.join(rootDir, "latest.yml"),
|
||||
"version: 1.7.233\nfiles: [\npath: Real-Debrid-Downloader-Setup-1.7.233.exe\n"
|
||||
);
|
||||
|
||||
expect(() => verifyPublicRelease(rootDir)).toThrow(/latest\.yml|yaml/i);
|
||||
});
|
||||
|
||||
it("rejects a noncanonical files entry in latest.yml", () => {
|
||||
const rootDir = createReleaseFixture();
|
||||
const latestPath = path.join(rootDir, "latest.yml");
|
||||
const latest = fs.readFileSync(latestPath, "utf8").replace(
|
||||
"url: Real-Debrid-Downloader-Setup-1.7.233.exe",
|
||||
"url: Different-Setup-1.7.233.exe"
|
||||
);
|
||||
fs.writeFileSync(latestPath, latest);
|
||||
|
||||
expect(() => verifyPublicRelease(rootDir)).toThrow(/files|url|canonical/i);
|
||||
});
|
||||
|
||||
it("rejects a latest.yml SHA512 digest that does not match the installer", () => {
|
||||
const rootDir = createReleaseFixture();
|
||||
const latestPath = path.join(rootDir, "latest.yml");
|
||||
const latest = fs.readFileSync(latestPath, "utf8");
|
||||
const wrongDigest = Buffer.alloc(64, 0x23).toString("base64");
|
||||
fs.writeFileSync(latestPath, latest.replace(/sha512: [^\n]+/g, `sha512: ${wrongDigest}`));
|
||||
|
||||
expect(() => verifyPublicRelease(rootDir)).toThrow(/sha512|digest|integrity/i);
|
||||
});
|
||||
|
||||
it("rejects a directory in place of an artifact file", () => {
|
||||
const rootDir = createReleaseFixture();
|
||||
const setupPath = path.join(rootDir, "Real-Debrid-Downloader-Setup-1.7.233.exe");
|
||||
fs.rmSync(setupPath);
|
||||
fs.mkdirSync(setupPath);
|
||||
|
||||
expect(() => verifyPublicRelease(rootDir)).toThrow(/artifact|file/i);
|
||||
});
|
||||
|
||||
it("rejects an empty artifact file", () => {
|
||||
const rootDir = createReleaseFixture();
|
||||
fs.writeFileSync(path.join(rootDir, "Real-Debrid-Downloader-1.7.233-portable.exe"), "");
|
||||
|
||||
expect(() => verifyPublicRelease(rootDir)).toThrow(/artifact|empty|file/i);
|
||||
});
|
||||
|
||||
it("rejects a release missing a declared redistribution license", () => {
|
||||
const rootDir = createReleaseFixture();
|
||||
fs.rmSync(path.join(rootDir, "resources", "extractor-jvm", "licenses", "Apache-2.0.txt"));
|
||||
|
||||
expect(() => verifyPublicRelease(rootDir)).toThrow(/Apache-2\.0\.txt/);
|
||||
});
|
||||
|
||||
it("rejects a modified official license text", () => {
|
||||
const rootDir = createReleaseFixture();
|
||||
fs.appendFileSync(
|
||||
path.join(rootDir, "resources", "extractor-jvm", "licenses", "LGPL-2.1.txt"),
|
||||
"modified"
|
||||
);
|
||||
|
||||
expect(() => verifyPublicRelease(rootDir)).toThrow(/LGPL-2\.1\.txt|digest|content/i);
|
||||
});
|
||||
|
||||
it("rejects swapped third-party license assignments", () => {
|
||||
const rootDir = createReleaseFixture();
|
||||
const noticePath = path.join(rootDir, "resources", "extractor-jvm", "THIRD_PARTY_NOTICES.txt");
|
||||
const notice = fs.readFileSync(noticePath, "utf8")
|
||||
.replace("GNU Lesser General Public License 2.1 or later", "Apache License 2.0")
|
||||
.replace("licenses/LGPL-2.1.txt; licenses/7-Zip-license.txt", "licenses/Apache-2.0.txt");
|
||||
fs.writeFileSync(noticePath, notice);
|
||||
|
||||
expect(() => verifyPublicRelease(rootDir)).toThrow(/THIRD_PARTY_NOTICES|notice|digest|mapping/i);
|
||||
});
|
||||
|
||||
it("rejects a release whose unpacked application omits a license", () => {
|
||||
const rootDir = createReleaseFixture();
|
||||
fs.rmSync(path.join(
|
||||
rootDir,
|
||||
"win-unpacked",
|
||||
"resources",
|
||||
"app.asar.unpacked",
|
||||
"resources",
|
||||
"extractor-jvm",
|
||||
"licenses",
|
||||
"Apache-2.0.txt"
|
||||
));
|
||||
|
||||
expect(() => verifyPublicRelease(rootDir)).toThrow(/win-unpacked|Apache-2\.0\.txt|packaged/i);
|
||||
});
|
||||
|
||||
it("rejects a symlink in place of a release artifact", () => {
|
||||
const rootDir = createReleaseFixture();
|
||||
const setupPath = path.join(rootDir, "Real-Debrid-Downloader-Setup-1.7.233.exe");
|
||||
const targetPath = path.join(rootDir, "setup-target.exe");
|
||||
fs.renameSync(setupPath, targetPath);
|
||||
fs.symlinkSync(targetPath, setupPath, "file");
|
||||
|
||||
expect(() => verifyPublicRelease(rootDir)).toThrow(/symbolic|symlink|regular file/i);
|
||||
});
|
||||
|
||||
it("rejects a symlink in place of an official license", () => {
|
||||
const rootDir = createReleaseFixture();
|
||||
const licensePath = path.join(rootDir, "resources", "extractor-jvm", "licenses", "Apache-2.0.txt");
|
||||
const targetPath = path.join(rootDir, "Apache-target.txt");
|
||||
fs.renameSync(licensePath, targetPath);
|
||||
fs.symlinkSync(targetPath, licensePath, "file");
|
||||
|
||||
expect(() => verifyPublicRelease(rootDir)).toThrow(/symbolic|symlink|regular file/i);
|
||||
});
|
||||
|
||||
it("rejects build metadata that omits the project license", () => {
|
||||
const rootDir = createReleaseFixture();
|
||||
const packagePath = path.join(rootDir, "package.json");
|
||||
const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8"));
|
||||
packageJson.build.files = packageJson.build.files.filter((entry: string) => entry !== "LICENSE");
|
||||
fs.writeFileSync(packagePath, `${JSON.stringify(packageJson, null, 2)}\n`);
|
||||
|
||||
expect(() => verifyPublicRelease(rootDir)).toThrow(/LICENSE/);
|
||||
});
|
||||
|
||||
it("rejects build metadata that does not copy the project license into resources", () => {
|
||||
const rootDir = createReleaseFixture();
|
||||
const packagePath = path.join(rootDir, "package.json");
|
||||
const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8"));
|
||||
delete packageJson.build.extraResources;
|
||||
fs.writeFileSync(packagePath, `${JSON.stringify(packageJson, null, 2)}\n`);
|
||||
|
||||
expect(() => verifyPublicRelease(rootDir)).toThrow(/extraResources|LICENSE/);
|
||||
});
|
||||
|
||||
it("rejects incomplete third-party redistribution notices", () => {
|
||||
const rootDir = createReleaseFixture();
|
||||
fs.writeFileSync(
|
||||
path.join(rootDir, "resources", "extractor-jvm", "THIRD_PARTY_NOTICES.txt"),
|
||||
"net.sf.sevenzipjbinding:sevenzipjbinding:16.02-2.01 LGPL-2.1.txt\n"
|
||||
);
|
||||
|
||||
expect(() => verifyPublicRelease(rootDir)).toThrow(/THIRD_PARTY_NOTICES|notice|content/i);
|
||||
});
|
||||
|
||||
it("returns a nonzero CLI status for invalid release metadata", () => {
|
||||
const rootDir = createReleaseFixture();
|
||||
const packagePath = path.join(rootDir, "package.json");
|
||||
const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8"));
|
||||
packageJson.build.publish.owner = "DifferentOwner";
|
||||
fs.writeFileSync(packagePath, `${JSON.stringify(packageJson, null, 2)}\n`);
|
||||
|
||||
const result = spawnSync(process.execPath, [verifierPath, rootDir], {
|
||||
encoding: "utf8"
|
||||
});
|
||||
|
||||
expect(result.status).not.toBe(0);
|
||||
expect(result.stderr).toMatch(/owner/i);
|
||||
});
|
||||
|
||||
it("recursively verifies redistribution files inside setup and portable archives", () => {
|
||||
const rootDir = createReleaseFixture();
|
||||
const result = verifyReleaseArchives(rootDir, {
|
||||
sevenZipPath: "C:\\Tools\\7-Zip\\7z.exe",
|
||||
runCommand: createArchiveCommandRunner()
|
||||
});
|
||||
|
||||
expect(result.verifiedArchives).toEqual([
|
||||
"Real-Debrid-Downloader-Setup-1.7.233.exe",
|
||||
"Real-Debrid-Downloader-1.7.233-portable.exe"
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects an archive whose nested application payload omits a license", () => {
|
||||
const rootDir = createReleaseFixture();
|
||||
|
||||
expect(() => verifyReleaseArchives(rootDir, {
|
||||
sevenZipPath: "C:\\Tools\\7-Zip\\7z.exe",
|
||||
runCommand: createArchiveCommandRunner("Apache-2.0.txt")
|
||||
})).toThrow(/Apache-2\.0\.txt|missing redistribution file/i);
|
||||
});
|
||||
|
||||
it("exposes archive verification as a nonzero CLI gate", () => {
|
||||
const rootDir = createReleaseFixture();
|
||||
const result = spawnSync(process.execPath, [
|
||||
verifierPath,
|
||||
rootDir,
|
||||
"--verify-archives",
|
||||
"--seven-zip",
|
||||
path.join(rootDir, "missing-7z.exe")
|
||||
], {
|
||||
encoding: "utf8"
|
||||
});
|
||||
|
||||
expect(result.status).not.toBe(0);
|
||||
expect(result.stderr).toMatch(/7-Zip|command|spawn/i);
|
||||
});
|
||||
});
|
||||
@ -7,7 +7,6 @@ import { buildSupportBundle } from "../src/main/support-bundle";
|
||||
import type { DownloadManager } from "../src/main/download-manager";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
const legacyManifestFile = ["debug_", "a", "i", "_manifest.json"].join("");
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
@ -37,8 +36,6 @@ describe("buildSupportBundle (async, non-blocking)", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-"));
|
||||
tempDirs.push(root);
|
||||
fs.writeFileSync(path.join(root, "debug_host.txt"), "host-info-test", "utf8");
|
||||
fs.writeFileSync(path.join(root, "debug_support_manifest.json"), JSON.stringify({ purpose: "support" }), "utf8");
|
||||
fs.writeFileSync(path.join(root, legacyManifestFile), JSON.stringify({ purpose: "legacy" }), "utf8");
|
||||
|
||||
const promise = buildSupportBundle(fakeManager(), root, { hostDiagnosticsMode: "none" });
|
||||
expect(promise).toBeInstanceOf(Promise);
|
||||
@ -51,10 +48,6 @@ describe("buildSupportBundle (async, non-blocking)", () => {
|
||||
expect(entries).toContain("overview/meta.json");
|
||||
expect(entries).toContain("overview/settings.json");
|
||||
expect(entries).toContain("runtime/debug_host.txt");
|
||||
expect(entries).toContain("runtime/debug_support_manifest.json");
|
||||
expect(entries).toContain("overview/support-manifest.json");
|
||||
expect(entries).not.toContain(`runtime/${legacyManifestFile}`);
|
||||
expect(entries).not.toContain(["overview/", "a", "i-manifest.json"].join(""));
|
||||
|
||||
const hostEntry = new AdmZip(buffer).getEntry("runtime/debug_host.txt");
|
||||
expect(hostEntry?.getData().toString("utf8")).toBe("host-info-test");
|
||||
|
||||
@ -46,13 +46,13 @@ afterEach(() => {
|
||||
|
||||
describe("update", () => {
|
||||
it("normalizes update repo input", () => {
|
||||
expect(normalizeUpdateRepo("")).toBe("Sucukdeluxe/multi-debrid-downloader");
|
||||
expect(normalizeUpdateRepo("")).toBe("Administrator/real-debrid-downloader");
|
||||
expect(normalizeUpdateRepo("owner/repo")).toBe("owner/repo");
|
||||
expect(normalizeUpdateRepo("https://github.com/owner/repo")).toBe("owner/repo");
|
||||
expect(normalizeUpdateRepo("https://www.github.com/owner/repo")).toBe("owner/repo");
|
||||
expect(normalizeUpdateRepo("https://github.com/owner/repo/releases/tag/v1.2.3")).toBe("owner/repo");
|
||||
expect(normalizeUpdateRepo("github.com/owner/repo.git")).toBe("owner/repo");
|
||||
expect(normalizeUpdateRepo("git@github.com:owner/repo.git")).toBe("owner/repo");
|
||||
expect(normalizeUpdateRepo("https://codeberg.org/owner/repo")).toBe("owner/repo");
|
||||
expect(normalizeUpdateRepo("https://www.codeberg.org/owner/repo")).toBe("owner/repo");
|
||||
expect(normalizeUpdateRepo("https://codeberg.org/owner/repo/releases/tag/v1.2.3")).toBe("owner/repo");
|
||||
expect(normalizeUpdateRepo("codeberg.org/owner/repo.git")).toBe("owner/repo");
|
||||
expect(normalizeUpdateRepo("git@codeberg.org:owner/repo.git")).toBe("owner/repo");
|
||||
});
|
||||
|
||||
it("uses normalized repo slug for API requests", async () => {
|
||||
@ -62,7 +62,7 @@ describe("update", () => {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
tag_name: `v${APP_VERSION}`,
|
||||
html_url: "https://github.com/owner/repo/releases/tag/v1.0.0",
|
||||
html_url: "https://git.24-music.de/owner/repo/releases/tag/v1.0.0",
|
||||
assets: []
|
||||
}),
|
||||
{
|
||||
@ -72,8 +72,8 @@ describe("update", () => {
|
||||
);
|
||||
}) as typeof fetch;
|
||||
|
||||
const result = await checkGitHubUpdate("https://github.com/owner/repo/releases");
|
||||
expect(requestedUrl).toBe("https://api.github.com/repos/owner/repo/releases/latest");
|
||||
const result = await checkGitHubUpdate("https://git.24-music.de/owner/repo/releases");
|
||||
expect(requestedUrl).toBe("https://git.24-music.de/api/v1/repos/owner/repo/releases/latest");
|
||||
expect(result.currentVersion).toBe(APP_VERSION);
|
||||
expect(result.latestVersion).toBe(APP_VERSION);
|
||||
expect(result.updateAvailable).toBe(false);
|
||||
@ -83,14 +83,14 @@ describe("update", () => {
|
||||
globalThis.fetch = (async (): Promise<Response> => new Response(
|
||||
JSON.stringify({
|
||||
tag_name: "v9.9.9",
|
||||
html_url: "https://github.com/owner/repo/releases/tag/v9.9.9",
|
||||
html_url: "https://codeberg.org/owner/repo/releases/tag/v9.9.9",
|
||||
assets: [
|
||||
{
|
||||
name: "Real-Debrid-Downloader-9.9.9-portable.exe",
|
||||
name: "Real-Debrid-Downloader 9.9.9.exe",
|
||||
browser_download_url: "https://example.invalid/portable.exe"
|
||||
},
|
||||
{
|
||||
name: "Real-Debrid-Downloader-Setup-9.9.9.exe",
|
||||
name: "Real-Debrid-Downloader Setup 9.9.9.exe",
|
||||
browser_download_url: "https://example.invalid/setup.exe",
|
||||
digest: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
}
|
||||
@ -105,7 +105,7 @@ describe("update", () => {
|
||||
const result = await checkGitHubUpdate("owner/repo");
|
||||
expect(result.updateAvailable).toBe(true);
|
||||
expect(result.setupAssetUrl).toBe("https://example.invalid/setup.exe");
|
||||
expect(result.setupAssetName).toBe("Real-Debrid-Downloader-Setup-9.9.9.exe");
|
||||
expect(result.setupAssetName).toBe("Real-Debrid-Downloader Setup 9.9.9.exe");
|
||||
});
|
||||
|
||||
it("uses silent NSIS install flags with auto-run after update", () => {
|
||||
@ -137,9 +137,9 @@ describe("update", () => {
|
||||
currentVersion: APP_VERSION,
|
||||
latestVersion: "9.9.9",
|
||||
latestTag: "v9.9.9",
|
||||
releaseUrl: "https://github.com/owner/repo/releases/tag/v9.9.9",
|
||||
releaseUrl: "https://codeberg.org/owner/repo/releases/tag/v9.9.9",
|
||||
setupAssetUrl: "https://example.invalid/stale-setup.exe",
|
||||
setupAssetName: "Real-Debrid-Downloader-Setup-9.9.9.exe",
|
||||
setupAssetName: "Real-Debrid-Downloader Setup 9.9.9.exe",
|
||||
setupAssetDigest: `sha256:${executableDigest}`
|
||||
};
|
||||
|
||||
@ -208,7 +208,7 @@ describe("update", () => {
|
||||
currentVersion: APP_VERSION,
|
||||
latestVersion: "9.9.9",
|
||||
latestTag: "v9.9.9",
|
||||
releaseUrl: "https://github.com/owner/repo/releases/tag/v9.9.9",
|
||||
releaseUrl: "https://codeberg.org/owner/repo/releases/tag/v9.9.9",
|
||||
setupAssetUrl: "",
|
||||
setupAssetName: ""
|
||||
};
|
||||
@ -272,7 +272,7 @@ describe("update", () => {
|
||||
currentVersion: APP_VERSION,
|
||||
latestVersion: "9.9.9",
|
||||
latestTag: "v9.9.9",
|
||||
releaseUrl: "https://github.com/owner/repo/releases/tag/v9.9.9",
|
||||
releaseUrl: "https://codeberg.org/owner/repo/releases/tag/v9.9.9",
|
||||
setupAssetUrl: "https://example.invalid/hang-setup.exe",
|
||||
setupAssetName: "",
|
||||
setupAssetDigest: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
@ -308,7 +308,7 @@ describe("update", () => {
|
||||
currentVersion: APP_VERSION,
|
||||
latestVersion: "9.9.9",
|
||||
latestTag: "v9.9.9",
|
||||
releaseUrl: "https://github.com/owner/repo/releases/tag/v9.9.9",
|
||||
releaseUrl: "https://codeberg.org/owner/repo/releases/tag/v9.9.9",
|
||||
setupAssetUrl: "https://example.invalid/mismatch-setup.exe",
|
||||
setupAssetName: "setup.exe",
|
||||
setupAssetDigest: "sha256:1111111111111111111111111111111111111111111111111111111111111111"
|
||||
@ -337,7 +337,7 @@ describe("update", () => {
|
||||
currentVersion: APP_VERSION,
|
||||
latestVersion: "9.9.9",
|
||||
latestTag: "",
|
||||
releaseUrl: "https://github.com/owner/repo/releases/tag/v9.9.9",
|
||||
releaseUrl: "https://codeberg.org/owner/repo/releases/tag/v9.9.9",
|
||||
setupAssetUrl: "https://example.invalid/unsigned-setup.exe",
|
||||
setupAssetName: "setup.exe",
|
||||
setupAssetDigest: ""
|
||||
@ -365,7 +365,7 @@ describe("update", () => {
|
||||
prerelease: false,
|
||||
assets: [
|
||||
{
|
||||
name: "Real-Debrid-Downloader-Setup-9.9.9.exe",
|
||||
name: "Real-Debrid-Downloader Setup 9.9.9.exe",
|
||||
browser_download_url: "https://example.invalid/setup-no-digest.exe"
|
||||
},
|
||||
{
|
||||
@ -407,9 +407,9 @@ describe("update", () => {
|
||||
currentVersion: APP_VERSION,
|
||||
latestVersion: "9.9.9",
|
||||
latestTag: "v9.9.9",
|
||||
releaseUrl: "https://github.com/owner/repo/releases/tag/v9.9.9",
|
||||
releaseUrl: "https://codeberg.org/owner/repo/releases/tag/v9.9.9",
|
||||
setupAssetUrl: "https://example.invalid/setup-no-digest.exe",
|
||||
setupAssetName: "Real-Debrid-Downloader-Setup-9.9.9.exe",
|
||||
setupAssetName: "Real-Debrid-Downloader Setup 9.9.9.exe",
|
||||
setupAssetDigest: ""
|
||||
};
|
||||
|
||||
@ -433,7 +433,7 @@ describe("update", () => {
|
||||
prerelease: false,
|
||||
assets: [
|
||||
{
|
||||
name: "Real-Debrid-Downloader-Setup-9.9.9.exe",
|
||||
name: "Real-Debrid-Downloader Setup 9.9.9.exe",
|
||||
browser_download_url: "https://example.invalid/setup-no-digest.exe"
|
||||
},
|
||||
{
|
||||
@ -449,7 +449,7 @@ describe("update", () => {
|
||||
|
||||
if (url.includes("latest.yml")) {
|
||||
return new Response(
|
||||
`version: 9.9.9\npath: Real-Debrid-Downloader-Setup-9.9.9.exe\nsha512: ${wrongDigestBase64}\n`,
|
||||
`version: 9.9.9\npath: Real-Debrid-Downloader Setup 9.9.9.exe\nsha512: ${wrongDigestBase64}\n`,
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "text/yaml" }
|
||||
@ -475,9 +475,9 @@ describe("update", () => {
|
||||
currentVersion: APP_VERSION,
|
||||
latestVersion: "9.9.9",
|
||||
latestTag: "v9.9.9",
|
||||
releaseUrl: "https://github.com/owner/repo/releases/tag/v9.9.9",
|
||||
releaseUrl: "https://codeberg.org/owner/repo/releases/tag/v9.9.9",
|
||||
setupAssetUrl: "https://example.invalid/setup-no-digest.exe",
|
||||
setupAssetName: "Real-Debrid-Downloader-Setup-9.9.9.exe",
|
||||
setupAssetName: "Real-Debrid-Downloader Setup 9.9.9.exe",
|
||||
setupAssetDigest: ""
|
||||
};
|
||||
|
||||
@ -509,7 +509,7 @@ describe("update", () => {
|
||||
currentVersion: APP_VERSION,
|
||||
latestVersion: "9.9.9",
|
||||
latestTag: "v9.9.9",
|
||||
releaseUrl: "https://github.com/owner/repo/releases/tag/v9.9.9",
|
||||
releaseUrl: "https://codeberg.org/owner/repo/releases/tag/v9.9.9",
|
||||
setupAssetUrl: "https://example.invalid/progress-setup.exe",
|
||||
setupAssetName: "setup.exe",
|
||||
setupAssetDigest: `sha256:${digest}`
|
||||
@ -539,27 +539,27 @@ describe("normalizeUpdateRepo extended", () => {
|
||||
it("handles trailing slashes and extra path segments", () => {
|
||||
expect(normalizeUpdateRepo("owner/repo/")).toBe("owner/repo");
|
||||
expect(normalizeUpdateRepo("/owner/repo/")).toBe("owner/repo");
|
||||
expect(normalizeUpdateRepo("https://github.com/owner/repo/tree/main/src")).toBe("owner/repo");
|
||||
expect(normalizeUpdateRepo("https://codeberg.org/owner/repo/tree/main/src")).toBe("owner/repo");
|
||||
});
|
||||
|
||||
it("handles ssh-style git URLs", () => {
|
||||
expect(normalizeUpdateRepo("git@github.com:user/project.git")).toBe("user/project");
|
||||
expect(normalizeUpdateRepo("git@codeberg.org:user/project.git")).toBe("user/project");
|
||||
});
|
||||
|
||||
it("returns default for malformed inputs", () => {
|
||||
expect(normalizeUpdateRepo("just-one-part")).toBe("Sucukdeluxe/multi-debrid-downloader");
|
||||
expect(normalizeUpdateRepo(" ")).toBe("Sucukdeluxe/multi-debrid-downloader");
|
||||
expect(normalizeUpdateRepo("just-one-part")).toBe("Administrator/real-debrid-downloader");
|
||||
expect(normalizeUpdateRepo(" ")).toBe("Administrator/real-debrid-downloader");
|
||||
});
|
||||
|
||||
it("rejects traversal-like owner or repo segments", () => {
|
||||
expect(normalizeUpdateRepo("../owner/repo")).toBe("Sucukdeluxe/multi-debrid-downloader");
|
||||
expect(normalizeUpdateRepo("owner/../repo")).toBe("Sucukdeluxe/multi-debrid-downloader");
|
||||
expect(normalizeUpdateRepo("https://github.com/owner/../../repo")).toBe("Sucukdeluxe/multi-debrid-downloader");
|
||||
expect(normalizeUpdateRepo("../owner/repo")).toBe("Administrator/real-debrid-downloader");
|
||||
expect(normalizeUpdateRepo("owner/../repo")).toBe("Administrator/real-debrid-downloader");
|
||||
expect(normalizeUpdateRepo("https://codeberg.org/owner/../../repo")).toBe("Administrator/real-debrid-downloader");
|
||||
});
|
||||
|
||||
it("handles www prefix", () => {
|
||||
expect(normalizeUpdateRepo("https://www.github.com/owner/repo")).toBe("owner/repo");
|
||||
expect(normalizeUpdateRepo("www.github.com/owner/repo")).toBe("owner/repo");
|
||||
expect(normalizeUpdateRepo("https://www.codeberg.org/owner/repo")).toBe("owner/repo");
|
||||
expect(normalizeUpdateRepo("www.codeberg.org/owner/repo")).toBe("owner/repo");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
62
tools/rd-diagnostics-mcp/README.md
Normal file
62
tools/rd-diagnostics-mcp/README.md
Normal file
@ -0,0 +1,62 @@
|
||||
# rd-diagnostics-mcp
|
||||
|
||||
Standalone **stdio MCP bridge** to the Real-Debrid-Downloader debug-server. It runs on the machine where the
|
||||
MCP client runs, takes a **connection code** for a downloader server, and exposes that server's
|
||||
read-only HTTP diagnostics API (`/diagnostics`, `/status`, `/errors`, `/logs/*`, `/accounts`, …) as MCP tools.
|
||||
One bridge serves all 5–6 servers; you pass a `code` (or a configured `server` name) per call.
|
||||
|
||||
This bridge is **not** bundled into the Electron app and adds **no** dependencies to it.
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
cd tools/rd-diagnostics-mcp
|
||||
npm install
|
||||
```
|
||||
|
||||
Register it with your MCP client as a stdio server that launches the bridge:
|
||||
|
||||
```bash
|
||||
node "<repo>/tools/rd-diagnostics-mcp/src/bridge.mjs"
|
||||
```
|
||||
|
||||
Provide servers via environment variables (codes contain a token — treat like passwords):
|
||||
|
||||
- `RDDIAG_CODE` — a single default connection code (`rddiag:v1:...`)
|
||||
- `RDDIAG_SERVERS` — JSON map of name → code, e.g. `{"berlin":"rddiag:v1:...","fra":"rddiag:v1:..."}`
|
||||
|
||||
Without env config, every tool simply takes a `code` argument.
|
||||
|
||||
## Tools
|
||||
|
||||
`rd_servers`, `rd_ping`, `rd_diagnostics`, `rd_status`, `rd_items`, `rd_packages`, `rd_errors`, `rd_logs`
|
||||
(`main|audit|rename|trace|session|conversion|package|item`), `rd_history`, `rd_accounts`, `rd_providers`
|
||||
(live per-account/key cooldown + in-flight + rotation state), `rd_host`, `rd_self_check`,
|
||||
`rd_get` (raw escape-hatch, any read-only path).
|
||||
|
||||
Each tool accepts `code` or `server` to pick the target.
|
||||
|
||||
## Connection code
|
||||
|
||||
Format: `rddiag:v1:<base64url(JSON)>` with `{ v:1, h:host, p:port, t:token, n?:name, fp?:certFingerprint, s?:scheme }`.
|
||||
Generated by the app (Hilfe → Remote-Support → Ferndiagnose (MCP)) or via `node src/gen-code.mjs --host H --port P --token T`.
|
||||
|
||||
## Security model (read before exposing a server)
|
||||
|
||||
- The debug surface is **read-only** for state/logs; the one control endpoint is `/trace/config` (toggles the
|
||||
optional, time-bounded support trace). No persistent secrets are written into the logs it serves: passwords are
|
||||
redacted, debrid API keys/tokens and resolved download URLs are never logged; `/settings` and `/accounts` are redacted.
|
||||
- Auth is a bearer token (24 random bytes). Over plain HTTP on a public network the token is sniffable, so:
|
||||
- **Preferred:** keep the server bound to `127.0.0.1` ("Nur lokal") and reach it through a private tunnel
|
||||
(Tailscale / SSH / Cloudflare Tunnel). The tunnel encrypts and authenticates; no public exposure.
|
||||
- **Direct network bind (`0.0.0.0`)** requires a non-empty **IP allowlist** (enforced fail-closed: with an empty
|
||||
allowlist only loopback is accepted). Use only inside a trusted LAN/VPN.
|
||||
- Revoke instantly from the app ("Token neu" or "Deaktivieren") — the old code stops working immediately.
|
||||
- `fp` pins a self-signed cert fingerprint and is verified on `secureConnect` (before the token is sent). HTTPS is
|
||||
not the v1 default; plain HTTP behind a tunnel is the recommended transport.
|
||||
|
||||
## Test
|
||||
|
||||
```bash
|
||||
npm test # spins a fake debug-server, runs the bridge as a stdio child, asserts the full protocol path
|
||||
```
|
||||
1172
tools/rd-diagnostics-mcp/package-lock.json
generated
Normal file
1172
tools/rd-diagnostics-mcp/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
18
tools/rd-diagnostics-mcp/package.json
Normal file
18
tools/rd-diagnostics-mcp/package.json
Normal file
@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "rd-diagnostics-mcp",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "Standalone stdio MCP bridge to the Real-Debrid-Downloader debug-server. Connects via connection code, proxies the read-only HTTP diagnostics API as MCP tools.",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"rd-diagnostics-mcp": "src/bridge.mjs"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "node src/bridge.mjs",
|
||||
"test": "node test/harness.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.12.0",
|
||||
"zod": "^3.23.8"
|
||||
}
|
||||
}
|
||||
332
tools/rd-diagnostics-mcp/src/bridge.mjs
Normal file
332
tools/rd-diagnostics-mcp/src/bridge.mjs
Normal file
@ -0,0 +1,332 @@
|
||||
#!/usr/bin/env node
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||
import { z } from "zod";
|
||||
import { decodeConnectionCode } from "./code.mjs";
|
||||
import { debugGet } from "./http.mjs";
|
||||
|
||||
function loadServerMap() {
|
||||
const map = new Map();
|
||||
const raw = process.env.RDDIAG_SERVERS;
|
||||
if (raw) {
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
for (const [name, code] of Object.entries(parsed || {})) {
|
||||
map.set(String(name), String(code));
|
||||
}
|
||||
} catch {
|
||||
process.stderr.write("rd-diagnostics-mcp: RDDIAG_SERVERS ist kein gueltiges JSON, wird ignoriert\n");
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
const SERVER_MAP = loadServerMap();
|
||||
const DEFAULT_CODE = process.env.RDDIAG_CODE ? String(process.env.RDDIAG_CODE) : "";
|
||||
|
||||
function listAvailableServers() {
|
||||
const names = [...SERVER_MAP.keys()];
|
||||
if (DEFAULT_CODE) names.push("(RDDIAG_CODE-Default)");
|
||||
return names;
|
||||
}
|
||||
|
||||
function resolveTarget(args) {
|
||||
let code = "";
|
||||
if (args && args.code) {
|
||||
code = String(args.code);
|
||||
} else if (args && args.server) {
|
||||
const found = SERVER_MAP.get(String(args.server));
|
||||
if (!found) {
|
||||
throw new Error(
|
||||
`Server "${args.server}" nicht konfiguriert. Bekannt: ${listAvailableServers().join(", ") || "(keine)"}`
|
||||
);
|
||||
}
|
||||
code = found;
|
||||
} else if (DEFAULT_CODE) {
|
||||
code = DEFAULT_CODE;
|
||||
} else if (SERVER_MAP.size === 1) {
|
||||
code = [...SERVER_MAP.values()][0];
|
||||
} else {
|
||||
throw new Error(
|
||||
`Kein Verbindungscode. Uebergib "code" oder "server", oder setze RDDIAG_CODE/RDDIAG_SERVERS. Bekannt: ${listAvailableServers().join(", ") || "(keine)"}`
|
||||
);
|
||||
}
|
||||
return decodeConnectionCode(code);
|
||||
}
|
||||
|
||||
function targetLabel(target) {
|
||||
return target.name ? `${target.name} (${target.host}:${target.port})` : `${target.host}:${target.port}`;
|
||||
}
|
||||
|
||||
function buildQuery(params) {
|
||||
const usable = Object.entries(params || {}).filter(
|
||||
([, v]) => v !== undefined && v !== null && String(v).length > 0
|
||||
);
|
||||
if (usable.length === 0) return "";
|
||||
const sp = new URLSearchParams();
|
||||
for (const [k, v] of usable) sp.set(k, String(v));
|
||||
return "?" + sp.toString();
|
||||
}
|
||||
|
||||
function prettyBody(body) {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(body), null, 2);
|
||||
} catch {
|
||||
return body;
|
||||
}
|
||||
}
|
||||
|
||||
function connectionHint(err) {
|
||||
const m = String((err && err.code) || err && err.message || "");
|
||||
if (/ECONNREFUSED/.test(m)) return "Debug-Server nicht erreichbar — auf dem Server aktiviert? Port/Firewall offen?";
|
||||
if (/ENOTFOUND|EAI_AGAIN/.test(m)) return "Host nicht aufloesbar — stimmt die Adresse im Verbindungscode?";
|
||||
if (/ETIMEDOUT|Zeitueberschreitung/.test(m)) return "Zeitueberschreitung — Server/Netz langsam oder Port geblockt.";
|
||||
if (/ECONNRESET|EPIPE/.test(m)) return "Verbindung abgebrochen — falscher Port/Scheme (http vs https)?";
|
||||
if (/Fingerprint/.test(m)) return "TLS-Fingerprint passt nicht — Code stammt evtl. von einem anderen Server.";
|
||||
return "";
|
||||
}
|
||||
|
||||
async function requestTool(args, path, params, opts = {}) {
|
||||
let target;
|
||||
try {
|
||||
target = resolveTarget(args);
|
||||
} catch (err) {
|
||||
return { content: [{ type: "text", text: `# Verbindungsfehler\n${err.message}` }], isError: true };
|
||||
}
|
||||
const fullPath = path + buildQuery(params);
|
||||
const label = targetLabel(target);
|
||||
try {
|
||||
const res = await debugGet(target, fullPath, { timeoutMs: opts.timeoutMs || 20000 });
|
||||
const isError = res.status < 200 || res.status >= 300;
|
||||
let extra = "";
|
||||
if (res.status === 401) extra = "\n(401 = Token im Verbindungscode ist abgelaufen/rotiert. Neuen Code anfordern.)";
|
||||
if (res.status === 503) extra = "\n(503 = Download-Manager nicht bereit. App laeuft, aber noch nicht initialisiert?)";
|
||||
const head = `# ${label} ${fullPath} → HTTP ${res.status}${extra}`;
|
||||
return { content: [{ type: "text", text: head + "\n" + prettyBody(res.body) }], isError };
|
||||
} catch (err) {
|
||||
const hint = connectionHint(err);
|
||||
const text = `# ${label} ${fullPath} → FEHLER\n${err.message}${hint ? "\n→ " + hint : ""}`;
|
||||
return { content: [{ type: "text", text }], isError: true };
|
||||
}
|
||||
}
|
||||
|
||||
const CODE_FIELD = {
|
||||
code: z.string().optional().describe("Verbindungscode (rddiag:v1:...). Optional, wenn server/RDDIAG_CODE gesetzt ist."),
|
||||
server: z.string().optional().describe("Name eines via RDDIAG_SERVERS konfigurierten Servers statt eines vollen Codes.")
|
||||
};
|
||||
|
||||
const server = new McpServer({ name: "rd-diagnostics-mcp", version: "1.0.0" });
|
||||
|
||||
server.registerTool(
|
||||
"rd_servers",
|
||||
{
|
||||
title: "Konfigurierte Server",
|
||||
description: "Listet die in dieser Bridge konfigurierten Server (RDDIAG_SERVERS / RDDIAG_CODE). Verbindet sich nicht.",
|
||||
inputSchema: {}
|
||||
},
|
||||
async () => {
|
||||
const names = [...SERVER_MAP.keys()];
|
||||
const lines = [];
|
||||
lines.push(`Konfigurierte Server: ${names.length}`);
|
||||
for (const n of names) lines.push(`- ${n}`);
|
||||
lines.push(`Default (RDDIAG_CODE): ${DEFAULT_CODE ? "gesetzt" : "nicht gesetzt"}`);
|
||||
lines.push("");
|
||||
lines.push("Tools akzeptieren entweder code:<rddiag:v1:...> oder server:<name>.");
|
||||
return { content: [{ type: "text", text: lines.join("\n") }] };
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"rd_ping",
|
||||
{
|
||||
title: "Erreichbarkeit pruefen",
|
||||
description: "Schneller Health-Check (GET /health): App-Version, Uptime, Speicher. Zuerst aufrufen, um Erreichbarkeit + Token zu pruefen.",
|
||||
inputSchema: { ...CODE_FIELD }
|
||||
},
|
||||
async (args) => requestTool(args, "/health", {}, { timeoutMs: 10000 })
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"rd_diagnostics",
|
||||
{
|
||||
title: "Gesamtdiagnose",
|
||||
description: "Aggregierter Zustand (GET /diagnostics): Meta, Status, Settings, Stats, Accounts, History, Host + die wichtigsten Logs. Der 'alles auf einen Blick'-Endpunkt.",
|
||||
inputSchema: {
|
||||
...CODE_FIELD,
|
||||
lines: z.number().int().positive().optional().describe("Anzahl Log-Zeilen pro Log (Default 150)."),
|
||||
grep: z.string().optional().describe("Filter fuer Log-Zeilen."),
|
||||
package: z.string().optional().describe("Optional auf ein Paket fokussieren.")
|
||||
}
|
||||
},
|
||||
async (args) => requestTool(args, "/diagnostics", { lines: args.lines, grep: args.grep, package: args.package }, { timeoutMs: 30000 })
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"rd_status",
|
||||
{
|
||||
title: "Live-Status",
|
||||
description: "Laufzeit-Status (GET /status): aktive Downloads, Queue, Provider-Zustand.",
|
||||
inputSchema: { ...CODE_FIELD }
|
||||
},
|
||||
async (args) => requestTool(args, "/status", {})
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"rd_items",
|
||||
{
|
||||
title: "Download-Items",
|
||||
description: "Einzelne Download-Items (GET /items), optional gefiltert nach Status/Paket.",
|
||||
inputSchema: {
|
||||
...CODE_FIELD,
|
||||
status: z.string().optional().describe("Status-Filter (z.B. downloading, error, done)."),
|
||||
package: z.string().optional().describe("Paket-Filter.")
|
||||
}
|
||||
},
|
||||
async (args) => requestTool(args, "/items", { status: args.status, package: args.package })
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"rd_packages",
|
||||
{
|
||||
title: "Pakete",
|
||||
description: "Pakete (GET /packages), optional mit enthaltenen Items.",
|
||||
inputSchema: {
|
||||
...CODE_FIELD,
|
||||
package: z.string().optional().describe("Bestimmtes Paket."),
|
||||
includeItems: z.boolean().optional().describe("Items mitliefern.")
|
||||
}
|
||||
},
|
||||
async (args) => requestTool(args, "/packages", { package: args.package, includeItems: args.includeItems ? "1" : "" })
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"rd_errors",
|
||||
{
|
||||
title: "Letzte Fehler",
|
||||
description: "Fehler-Ring (GET /errors): die letzten Fehler mit Level/Quelle. 'Was ist schiefgelaufen'.",
|
||||
inputSchema: {
|
||||
...CODE_FIELD,
|
||||
level: z.string().optional().describe("Level-Filter (ERROR, WARN, ...)."),
|
||||
limit: z.number().int().positive().optional().describe("Anzahl (Default 100).")
|
||||
}
|
||||
},
|
||||
async (args) => requestTool(args, "/errors", { level: args.level, limit: args.limit })
|
||||
);
|
||||
|
||||
const LOG_PATHS = {
|
||||
main: "/logs/main",
|
||||
audit: "/logs/audit",
|
||||
rename: "/logs/rename",
|
||||
trace: "/logs/trace",
|
||||
session: "/logs/session",
|
||||
conversion: "/logs/conversion",
|
||||
package: "/logs/package",
|
||||
item: "/logs/item"
|
||||
};
|
||||
|
||||
server.registerTool(
|
||||
"rd_logs",
|
||||
{
|
||||
title: "Log lesen",
|
||||
description: "Liest das Ende eines Logs (GET /logs/<name>). name: main|audit|rename|trace|session|conversion|package|item. conversion = Pro-Item Link-Aufloesungs-Lebenszyklus (Token, API, Web, Rotation, Abbrueche mit Zeiten). Fuer package/item zusaetzlich package/item angeben.",
|
||||
inputSchema: {
|
||||
...CODE_FIELD,
|
||||
name: z.enum(["main", "audit", "rename", "trace", "session", "conversion", "package", "item"]).describe("Welches Log."),
|
||||
lines: z.number().int().positive().optional().describe("Anzahl Zeilen vom Ende (Default 100)."),
|
||||
grep: z.string().optional().describe("Filter."),
|
||||
package: z.string().optional().describe("Nur fuer name=package."),
|
||||
item: z.string().optional().describe("Nur fuer name=item.")
|
||||
}
|
||||
},
|
||||
async (args) => {
|
||||
const path = LOG_PATHS[args.name];
|
||||
return requestTool(args, path, { lines: args.lines, grep: args.grep, package: args.package, item: args.item });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"rd_history",
|
||||
{
|
||||
title: "Verlauf",
|
||||
description: "Abgeschlossener Verlauf (GET /history), optional nach Status/Suchbegriff.",
|
||||
inputSchema: {
|
||||
...CODE_FIELD,
|
||||
limit: z.number().int().positive().optional().describe("Anzahl (Default 50)."),
|
||||
status: z.string().optional().describe("Status-Filter."),
|
||||
grep: z.string().optional().describe("Suchbegriff.")
|
||||
}
|
||||
},
|
||||
async (args) => requestTool(args, "/history", { limit: args.limit, status: args.status, grep: args.grep })
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"rd_accounts",
|
||||
{
|
||||
title: "Accounts",
|
||||
description: "Debrid-Accounts (GET /accounts, Token redigiert): Gueltigkeit, Premium, Cooldown/Rotation.",
|
||||
inputSchema: { ...CODE_FIELD }
|
||||
},
|
||||
async (args) => requestTool(args, "/accounts", {})
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"rd_providers",
|
||||
{
|
||||
title: "Provider-Laufzeitzustand",
|
||||
description: "Live Provider-Runtime (GET /providers): pro Mega-Account/Debrid-Link-Key der AKTIVE Cooldown (until/remainingMs/Grund/Kategorie), in-flight-Tiefe, Mega-Rotationscursor, Empty-Response-Streaks. Die 'warum kuehlt es JETZT ab'-Ansicht — beantwortet Cooldown-Fragen direkt statt aus Log-Arithmetik.",
|
||||
inputSchema: { ...CODE_FIELD }
|
||||
},
|
||||
async (args) => requestTool(args, "/providers", {})
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"rd_host",
|
||||
{
|
||||
title: "Host-Diagnose",
|
||||
description: "Windows-Host-Diagnose (GET /host/diagnostics): Laufwerke, Speicher, Pfade.",
|
||||
inputSchema: { ...CODE_FIELD }
|
||||
},
|
||||
async (args) => requestTool(args, "/host/diagnostics", {})
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"rd_self_check",
|
||||
{
|
||||
title: "Self-Check",
|
||||
description: "Setup/Self-Check (GET /self-check): erkennt Konfigurations-/Pfadprobleme.",
|
||||
inputSchema: { ...CODE_FIELD }
|
||||
},
|
||||
async (args) => requestTool(args, "/self-check", {})
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"rd_get",
|
||||
{
|
||||
title: "Roh-Endpunkt (Escape-Hatch)",
|
||||
description: "Beliebigen Debug-Server-Pfad lesen (GET <path>), wenn kein spezialisiertes Tool passt. Pfad inkl. fuehrendem / und optionalem Query-String, z.B. /meta oder /stats.",
|
||||
inputSchema: {
|
||||
...CODE_FIELD,
|
||||
path: z.string().describe("Pfad mit fuehrendem /, optional ?query. Nur GET, read-only.")
|
||||
}
|
||||
},
|
||||
async (args) => {
|
||||
const p = String(args.path || "");
|
||||
if (!p.startsWith("/")) {
|
||||
return { content: [{ type: "text", text: "# Fehler\npath muss mit / beginnen" }], isError: true };
|
||||
}
|
||||
return requestTool(args, p, {}, { timeoutMs: 30000 });
|
||||
}
|
||||
);
|
||||
|
||||
async function main() {
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
process.stderr.write(
|
||||
`rd-diagnostics-mcp bereit. Server: ${listAvailableServers().join(", ") || "(keine vorkonfiguriert; code pro Aufruf uebergeben)"}\n`
|
||||
);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
process.stderr.write(`rd-diagnostics-mcp Startfehler: ${err && err.stack ? err.stack : err}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
20
tools/rd-diagnostics-mcp/src/code.d.mts
Normal file
20
tools/rd-diagnostics-mcp/src/code.d.mts
Normal file
@ -0,0 +1,20 @@
|
||||
export interface DecodedConnectionCode {
|
||||
host: string;
|
||||
port: number;
|
||||
token: string;
|
||||
scheme: string;
|
||||
name: string;
|
||||
fingerprint: string;
|
||||
}
|
||||
|
||||
export interface EncodeConnectionCodeInput {
|
||||
host: string;
|
||||
port: number;
|
||||
token: string;
|
||||
name?: string;
|
||||
fingerprint?: string;
|
||||
scheme?: string;
|
||||
}
|
||||
|
||||
export function encodeConnectionCode(input: EncodeConnectionCodeInput): string;
|
||||
export function decodeConnectionCode(code: string): DecodedConnectionCode;
|
||||
56
tools/rd-diagnostics-mcp/src/code.mjs
Normal file
56
tools/rd-diagnostics-mcp/src/code.mjs
Normal file
@ -0,0 +1,56 @@
|
||||
const PREFIX = "rddiag:v1:";
|
||||
|
||||
function base64urlEncode(str) {
|
||||
return Buffer.from(str, "utf8")
|
||||
.toString("base64")
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/, "");
|
||||
}
|
||||
|
||||
function base64urlDecode(str) {
|
||||
const pad = str.length % 4 === 0 ? "" : "=".repeat(4 - (str.length % 4));
|
||||
const b64 = str.replace(/-/g, "+").replace(/_/g, "/") + pad;
|
||||
return Buffer.from(b64, "base64").toString("utf8");
|
||||
}
|
||||
|
||||
export function encodeConnectionCode({ host, port, token, name, fingerprint, scheme }) {
|
||||
if (!host || typeof host !== "string") throw new Error("host fehlt");
|
||||
const p = Number(port);
|
||||
if (!Number.isInteger(p) || p < 1 || p > 65535) throw new Error("port ungueltig");
|
||||
if (!token || typeof token !== "string") throw new Error("token fehlt");
|
||||
const payload = { v: 1, h: host, p, t: token };
|
||||
if (name) payload.n = String(name);
|
||||
if (fingerprint) payload.fp = String(fingerprint);
|
||||
if (scheme && scheme !== "http") payload.s = String(scheme);
|
||||
return PREFIX + base64urlEncode(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
export function decodeConnectionCode(code) {
|
||||
const raw = String(code || "").trim();
|
||||
if (!raw.startsWith(PREFIX)) {
|
||||
throw new Error(`Verbindungscode muss mit "${PREFIX}" beginnen`);
|
||||
}
|
||||
let json;
|
||||
try {
|
||||
json = JSON.parse(base64urlDecode(raw.slice(PREFIX.length)));
|
||||
} catch {
|
||||
throw new Error("Verbindungscode ist beschaedigt (kein gueltiges base64url/JSON)");
|
||||
}
|
||||
if (!json || typeof json !== "object") throw new Error("Verbindungscode-Inhalt ungueltig");
|
||||
const host = String(json.h || "").trim();
|
||||
const port = Number(json.p);
|
||||
const token = String(json.t || "");
|
||||
if (!host) throw new Error("Verbindungscode ohne Host");
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("Verbindungscode mit ungueltigem Port");
|
||||
if (!token) throw new Error("Verbindungscode ohne Token");
|
||||
const scheme = json.s === "https" ? "https" : "http";
|
||||
return {
|
||||
host,
|
||||
port,
|
||||
token,
|
||||
scheme,
|
||||
name: json.n ? String(json.n) : "",
|
||||
fingerprint: json.fp ? String(json.fp) : ""
|
||||
};
|
||||
}
|
||||
22
tools/rd-diagnostics-mcp/src/gen-code.mjs
Normal file
22
tools/rd-diagnostics-mcp/src/gen-code.mjs
Normal file
@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env node
|
||||
import { encodeConnectionCode } from "./code.mjs";
|
||||
|
||||
function arg(name, fallback) {
|
||||
const i = process.argv.indexOf("--" + name);
|
||||
if (i >= 0 && i + 1 < process.argv.length) return process.argv[i + 1];
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const host = arg("host");
|
||||
const port = arg("port");
|
||||
const token = arg("token");
|
||||
const name = arg("name");
|
||||
const scheme = arg("scheme");
|
||||
const fingerprint = arg("fp");
|
||||
|
||||
if (!host || !port || !token) {
|
||||
process.stderr.write("Usage: node src/gen-code.mjs --host <h> --port <p> --token <t> [--name <n>] [--scheme https] [--fp <sha256>]\n");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
process.stdout.write(encodeConnectionCode({ host, port, token, name, scheme, fingerprint }) + "\n");
|
||||
63
tools/rd-diagnostics-mcp/src/http.mjs
Normal file
63
tools/rd-diagnostics-mcp/src/http.mjs
Normal file
@ -0,0 +1,63 @@
|
||||
import http from "node:http";
|
||||
import https from "node:https";
|
||||
|
||||
function normalizeFp(fp) {
|
||||
return String(fp || "").replace(/:/g, "").toLowerCase();
|
||||
}
|
||||
|
||||
export function debugGet(target, path, { timeoutMs = 20000 } = {}) {
|
||||
const scheme = target.scheme === "https" ? "https" : "http";
|
||||
const lib = scheme === "https" ? https : http;
|
||||
const rel = path.startsWith("/") ? path : "/" + path;
|
||||
const url = new URL(rel, `${scheme}://${target.host}:${target.port}`);
|
||||
const pinning = scheme === "https" && !!target.fingerprint;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const options = {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${target.token}`,
|
||||
Accept: "application/json"
|
||||
},
|
||||
timeout: timeoutMs
|
||||
};
|
||||
if (scheme === "https") {
|
||||
options.rejectUnauthorized = !target.fingerprint;
|
||||
}
|
||||
|
||||
const req = lib.request(url, options, (res) => {
|
||||
let data = "";
|
||||
res.setEncoding("utf8");
|
||||
res.on("data", (chunk) => {
|
||||
data += chunk;
|
||||
});
|
||||
res.on("end", () => {
|
||||
resolve({ status: res.statusCode || 0, body: data, headers: res.headers });
|
||||
});
|
||||
});
|
||||
|
||||
req.on("timeout", () => {
|
||||
req.destroy(new Error(`Zeitueberschreitung nach ${timeoutMs}ms`));
|
||||
});
|
||||
req.on("error", (err) => {
|
||||
reject(err);
|
||||
});
|
||||
|
||||
if (pinning) {
|
||||
req.on("socket", (socket) => {
|
||||
socket.on("secureConnect", () => {
|
||||
const cert = typeof socket.getPeerCertificate === "function" ? socket.getPeerCertificate() : null;
|
||||
const got = normalizeFp(cert && cert.fingerprint256);
|
||||
const want = normalizeFp(target.fingerprint);
|
||||
if (!got || got !== want) {
|
||||
req.destroy(new Error(`TLS-Fingerprint stimmt nicht (erwartet ${want || "?"}, erhalten ${got || "?"})`));
|
||||
return;
|
||||
}
|
||||
req.end();
|
||||
});
|
||||
});
|
||||
} else {
|
||||
req.end();
|
||||
}
|
||||
});
|
||||
}
|
||||
175
tools/rd-diagnostics-mcp/test/harness.mjs
Normal file
175
tools/rd-diagnostics-mcp/test/harness.mjs
Normal file
@ -0,0 +1,175 @@
|
||||
import http from "node:http";
|
||||
import { spawn } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
import { encodeConnectionCode } from "../src/code.mjs";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const BRIDGE = join(__dirname, "..", "src", "bridge.mjs");
|
||||
const TOKEN = "test-token-abc123";
|
||||
|
||||
const failures = [];
|
||||
function check(name, cond, detail) {
|
||||
if (cond) {
|
||||
process.stdout.write(` PASS ${name}\n`);
|
||||
} else {
|
||||
failures.push(name);
|
||||
process.stdout.write(` FAIL ${name}${detail ? " — " + detail : ""}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
function startFakeServer() {
|
||||
return new Promise((resolve) => {
|
||||
const server = http.createServer((req, res) => {
|
||||
const url = new URL(req.url, "http://localhost");
|
||||
const auth = req.headers.authorization || "";
|
||||
const tokenOk = auth === `Bearer ${TOKEN}` || url.searchParams.get("token") === TOKEN;
|
||||
if (!tokenOk) {
|
||||
res.writeHead(401, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "Unauthorized" }));
|
||||
return;
|
||||
}
|
||||
const p = url.pathname;
|
||||
const q = Object.fromEntries(url.searchParams.entries());
|
||||
const send = (obj) => {
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify(obj));
|
||||
};
|
||||
if (p === "/health") return send({ status: "ok", appVersion: "1.7.222", uptime: 42 });
|
||||
if (p === "/diagnostics") return send({ meta: { appVersion: "1.7.222" }, status: { active: 1 }, query: q });
|
||||
if (p === "/errors") return send({ errors: [{ level: "ERROR", message: "boom" }], query: q });
|
||||
if (p === "/logs/main") return send({ lines: ["line1", "line2"], count: 2, query: q });
|
||||
if (p === "/status") return send({ active: 1, queued: 3 });
|
||||
if (p === "/items") return send({ items: [], query: q });
|
||||
if (p === "/accounts") return send({ accounts: [{ name: "acc1", premium: true }] });
|
||||
if (p === "/meta") return send({ appVersion: "1.7.222", endpoints: ["/health", "/diagnostics"] });
|
||||
res.writeHead(404, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "not found", path: p }));
|
||||
});
|
||||
server.listen(0, "127.0.0.1", () => resolve(server));
|
||||
});
|
||||
}
|
||||
|
||||
function startBridge() {
|
||||
const child = spawn(process.execPath, [BRIDGE], { stdio: ["pipe", "pipe", "pipe"] });
|
||||
child.stderr.on("data", (d) => process.stderr.write(`[bridge] ${d}`));
|
||||
const pending = new Map();
|
||||
let buf = "";
|
||||
child.stdout.on("data", (chunk) => {
|
||||
buf += chunk.toString("utf8");
|
||||
let idx;
|
||||
while ((idx = buf.indexOf("\n")) >= 0) {
|
||||
const line = buf.slice(0, idx).trim();
|
||||
buf = buf.slice(idx + 1);
|
||||
if (!line) continue;
|
||||
let msg;
|
||||
try {
|
||||
msg = JSON.parse(line);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (msg.id !== undefined && pending.has(msg.id)) {
|
||||
pending.get(msg.id)(msg);
|
||||
pending.delete(msg.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
let nextId = 1;
|
||||
function rpc(method, params) {
|
||||
const id = nextId++;
|
||||
return new Promise((resolve, reject) => {
|
||||
pending.set(id, resolve);
|
||||
child.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n");
|
||||
setTimeout(() => {
|
||||
if (pending.has(id)) {
|
||||
pending.delete(id);
|
||||
reject(new Error(`RPC timeout: ${method}`));
|
||||
}
|
||||
}, 15000);
|
||||
});
|
||||
}
|
||||
function notify(method, params) {
|
||||
child.stdin.write(JSON.stringify({ jsonrpc: "2.0", method, params }) + "\n");
|
||||
}
|
||||
return { child, rpc, notify };
|
||||
}
|
||||
|
||||
function textOf(callResult) {
|
||||
const c = callResult && callResult.result && callResult.result.content;
|
||||
if (!Array.isArray(c)) return "";
|
||||
return c.map((x) => x.text || "").join("\n");
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const fake = await startFakeServer();
|
||||
const port = fake.address().port;
|
||||
const code = encodeConnectionCode({ host: "127.0.0.1", port, token: TOKEN, name: "testserver" });
|
||||
const badCode = encodeConnectionCode({ host: "127.0.0.1", port, token: "WRONG", name: "testserver" });
|
||||
|
||||
const bridge = startBridge();
|
||||
try {
|
||||
const init = await bridge.rpc("initialize", {
|
||||
protocolVersion: "2024-11-05",
|
||||
capabilities: {},
|
||||
clientInfo: { name: "harness", version: "1.0.0" }
|
||||
});
|
||||
check("initialize handshake", !!(init.result && init.result.serverInfo), JSON.stringify(init.error || {}));
|
||||
check("server name reported", init.result && init.result.serverInfo && init.result.serverInfo.name === "rd-diagnostics-mcp");
|
||||
bridge.notify("notifications/initialized", {});
|
||||
|
||||
const tools = await bridge.rpc("tools/list", {});
|
||||
const names = (tools.result && tools.result.tools || []).map((t) => t.name);
|
||||
check("tools/list returns tools", names.length >= 10, `got ${names.length}`);
|
||||
for (const expected of ["rd_ping", "rd_diagnostics", "rd_errors", "rd_logs", "rd_get", "rd_servers"]) {
|
||||
check(`tool present: ${expected}`, names.includes(expected));
|
||||
}
|
||||
|
||||
const ping = await bridge.rpc("tools/call", { name: "rd_ping", arguments: { code } });
|
||||
const pingText = textOf(ping);
|
||||
check("rd_ping reaches server", /HTTP 200/.test(pingText) && /"status": "ok"/.test(pingText), pingText.slice(0, 200));
|
||||
check("rd_ping shows server label", /testserver \(127\.0\.0\.1:/.test(pingText));
|
||||
|
||||
const diag = await bridge.rpc("tools/call", { name: "rd_diagnostics", arguments: { code, lines: 50, grep: "err" } });
|
||||
const diagText = textOf(diag);
|
||||
check("rd_diagnostics returns aggregate", /"appVersion": "1\.7\.222"/.test(diagText));
|
||||
check("rd_diagnostics passes query params", /"lines": "50"/.test(diagText) && /"grep": "err"/.test(diagText), diagText.slice(0, 300));
|
||||
|
||||
const logs = await bridge.rpc("tools/call", { name: "rd_logs", arguments: { code, name: "main", lines: 5 } });
|
||||
const logsText = textOf(logs);
|
||||
check("rd_logs maps name→path + lines", /logs\/main\?lines=5/.test(logsText) && /"count": 2/.test(logsText), logsText.slice(0, 200));
|
||||
|
||||
const errs = await bridge.rpc("tools/call", { name: "rd_errors", arguments: { code, level: "ERROR" } });
|
||||
check("rd_errors returns ring", /"message": "boom"/.test(textOf(errs)));
|
||||
|
||||
const raw = await bridge.rpc("tools/call", { name: "rd_get", arguments: { code, path: "/meta" } });
|
||||
check("rd_get escape hatch hits arbitrary path", /"endpoints"/.test(textOf(raw)));
|
||||
|
||||
const unauthorized = await bridge.rpc("tools/call", { name: "rd_ping", arguments: { code: badCode } });
|
||||
check("bad token → HTTP 401 + isError", /HTTP 401/.test(textOf(unauthorized)) && unauthorized.result.isError === true);
|
||||
|
||||
const noCode = await bridge.rpc("tools/call", { name: "rd_ping", arguments: {} });
|
||||
check("missing code → graceful isError", noCode.result && noCode.result.isError === true && /Kein Verbindungscode/.test(textOf(noCode)));
|
||||
|
||||
const unreachable = await bridge.rpc("tools/call", {
|
||||
name: "rd_ping",
|
||||
arguments: { code: encodeConnectionCode({ host: "127.0.0.1", port: 1, token: TOKEN }) }
|
||||
});
|
||||
check("unreachable → isError + hint", unreachable.result.isError === true && /nicht erreichbar|abgebrochen|FEHLER/.test(textOf(unreachable)), textOf(unreachable).slice(0, 160));
|
||||
} finally {
|
||||
bridge.child.kill();
|
||||
fake.close();
|
||||
}
|
||||
|
||||
process.stdout.write("\n");
|
||||
if (failures.length) {
|
||||
process.stdout.write(`RESULT: ${failures.length} FAIL\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
process.stdout.write("RESULT: ALL PASS\n");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
run().catch((err) => {
|
||||
process.stderr.write(`harness error: ${err && err.stack ? err.stack : err}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
@ -3,7 +3,7 @@ import { defineConfig } from "vitest/config";
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
include: ["tests/**/*.test.ts", "scripts/tests/**/*.test.ts"],
|
||||
include: ["tests/**/*.test.ts"],
|
||||
globals: true
|
||||
}
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user