feat: add docker pipeline and preview fixes

This commit is contained in:
西街长安 2026-05-06 12:59:50 +08:00
commit 9ba85b01f3
44 changed files with 13894 additions and 0 deletions

13
.dockerignore Normal file
View File

@ -0,0 +1,13 @@
node_modules/
data/
.env
.env.*
.git
.gitignore
*.sqlite
*.sqlite-journal
*.sqlite-shm
*.sqlite-wal
coverage/
dist/
npm-debug.log*

4
.env.example Normal file
View File

@ -0,0 +1,4 @@
PORT=3000
APP_DB_PATH=./data/app.sqlite
UPLOAD_DIR=./data/sources
PREVIEW_MAX_BYTES=16777216

7
.gitignore vendored Normal file
View File

@ -0,0 +1,7 @@
node_modules/
.env
data/
*.sqlite
*.sqlite-journal
*.sqlite-shm
*.sqlite-wal

30
Dockerfile Normal file
View File

@ -0,0 +1,30 @@
ARG NODE_IMAGE=docker.m.daocloud.io/library/node:24-alpine
FROM ${NODE_IMAGE}
ENV NODE_ENV=production \
PORT=3000 \
APP_DB_PATH=/app/data/app.sqlite \
UPLOAD_DIR=/app/data/sources \
PREVIEW_MAX_BYTES=16777216
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY public ./public
COPY src ./src
COPY sql ./sql
COPY README.md ./
RUN mkdir -p /app/data/sources \
&& chown -R node:node /app
USER node
VOLUME ["/app/data"]
EXPOSE 3000
CMD ["node", "src/server.js"]

164
Jenkinsfile vendored Normal file
View File

@ -0,0 +1,164 @@
pipeline {
agent { label '构建机1' }
options {
timestamps()
disableConcurrentBuilds()
skipDefaultCheckout(true)
}
environment {
REGISTRY_URL = 'reg.nxsir.cn'
APP_IMAGE_NAME = 'duplicati_preview/app'
IMAGE_TAG = "${env.BUILD_ID}"
DOCKER_CREDS = 'harbor_key'
TARGET_PLATFORMS = 'linux/amd64,linux/arm64'
BUILDER_NAME = 'duplicati-preview-buildx'
NODE_IMAGE = 'docker.m.daocloud.io/library/node:24-alpine'
GIT_REPO_URL = 'https://gitea.nxsir.cn/nanxun/duplicati_preview.git'
GIT_BRANCH = 'main'
GIT_CREDS = ''
APP_IMAGE_TAGGED = "${REGISTRY_URL}/${APP_IMAGE_NAME}:${IMAGE_TAG}"
APP_IMAGE_LATEST = "${REGISTRY_URL}/${APP_IMAGE_NAME}:latest"
}
stages {
stage('Checkout') {
steps {
script {
def userRemoteConfig = [url: env.GIT_REPO_URL]
if (env.GIT_CREDS?.trim()) {
userRemoteConfig.credentialsId = env.GIT_CREDS.trim()
}
checkout([
$class: 'GitSCM',
branches: [[name: "*/${env.GIT_BRANCH}"]],
userRemoteConfigs: [userRemoteConfig]
])
}
}
}
stage('Prepare Buildx') {
steps {
sh '''
set -e
sudo docker buildx version
sudo docker run --privileged --rm tonistiigi/binfmt --install arm64
'''
}
}
stage('Login Registry') {
steps {
withCredentials([
usernamePassword(
credentialsId: "${DOCKER_CREDS}",
usernameVariable: 'DOCKER_USERNAME',
passwordVariable: 'DOCKER_PASSWORD'
)
]) {
sh '''
set -e
echo "$DOCKER_PASSWORD" | sudo docker login ${REGISTRY_URL} -u "$DOCKER_USERNAME" --password-stdin
'''
}
}
}
stage('Build And Push Image') {
steps {
sh '''
set -e
run_with_heartbeat() {
log_file="$1"
shift
rm -f "$log_file"
: > "$log_file"
"$@" >"$log_file" 2>&1 &
cmd_pid=$!
last_line=0
while kill -0 "$cmd_pid" >/dev/null 2>&1; do
if [ -f "$log_file" ]; then
total_lines=$(wc -l < "$log_file" || echo 0)
else
total_lines=0
fi
if [ "$total_lines" -gt "$last_line" ]; then
sed -n "$((last_line + 1)),${total_lines}p" "$log_file"
last_line=$total_lines
else
echo "[heartbeat] Duplicati Preview multi-arch build still running at $(date -u +%Y-%m-%dT%H:%M:%SZ)"
fi
sleep 20
done
wait "$cmd_pid"
if [ -f "$log_file" ]; then
total_lines=$(wc -l < "$log_file" || echo 0)
if [ "$total_lines" -gt "$last_line" ]; then
sed -n "$((last_line + 1)),${total_lines}p" "$log_file"
fi
fi
}
if ! sudo docker buildx inspect --builder ${BUILDER_NAME} >/dev/null 2>&1; then
sudo docker buildx create \
--name ${BUILDER_NAME} \
--driver docker-container \
--driver-opt network=host \
--driver-opt 'env.HTTP_PROXY=http://192.168.5.200:7890' \
--driver-opt 'env.HTTPS_PROXY=http://192.168.5.200:7890' \
--driver-opt 'env.NO_PROXY=reg.nxsir.cn' \
--driver-opt 'env.http_proxy=http://192.168.5.200:7890' \
--driver-opt 'env.https_proxy=http://192.168.5.200:7890' \
--driver-opt 'env.no_proxy=reg.nxsir.cn' \
--use
fi
sudo docker buildx inspect --builder ${BUILDER_NAME} --bootstrap >/dev/null
echo "Building and pushing multi-arch image: ${APP_IMAGE_TAGGED}"
run_with_heartbeat /tmp/duplicati-preview-buildx-${IMAGE_TAG}.log \
sudo docker buildx build \
--builder ${BUILDER_NAME} \
--platform ${TARGET_PLATFORMS} \
--network host \
--progress=plain \
--provenance=false \
--build-arg NODE_IMAGE=${NODE_IMAGE} \
--build-arg HTTP_PROXY=http://192.168.5.200:7890 \
--build-arg HTTPS_PROXY=http://192.168.5.200:7890 \
--build-arg NO_PROXY=127.0.0.1,localhost,reg.nxsir.cn,gitea.nxsir.cn \
--build-arg http_proxy=http://192.168.5.200:7890 \
--build-arg https_proxy=http://192.168.5.200:7890 \
--build-arg no_proxy=127.0.0.1,localhost,reg.nxsir.cn,gitea.nxsir.cn \
-f Dockerfile \
-t ${APP_IMAGE_TAGGED} \
-t ${APP_IMAGE_LATEST} \
--push \
.
'''
}
}
}
post {
success {
echo 'Pipeline completed successfully.'
}
failure {
echo 'Pipeline failed. Please check the build log.'
}
always {
sh '''
set +e
sudo docker logout ${REGISTRY_URL} >/dev/null 2>&1 || true
true
'''
deleteDir()
}
}
}

142
README.md Normal file
View File

@ -0,0 +1,142 @@
# Zero-Bandwidth Duplicati API
Thin `Node.js + Express` metadata service for Duplicati restores. The backend owns only SQLite metadata and enhancement jobs; file bytes stay on WebDAV and are never proxied through the runtime restore APIs.
## What changed
- Multiple uploaded Duplicati task databases can now coexist at the same time.
- Every uploaded task library becomes its own `sourceId`.
- A single uploaded `Duplicati-server.sqlite` can be stored separately and used to map friendly task names onto task database files.
- Directory browsing and file metadata APIs are now `sourceId`-scoped.
- The frontend is now split into three pages:
- `/` for source management
- `/source.html?sourceId=...` for a source workbench
- `/file.html?sourceId=...&id=...` for video preview and browser-direct downloads
- Video preview and downloads now pull encrypted dblocks directly from WebDAV in the browser, decrypt them locally, and never proxy the file stream through the backend.
- The backend can build an enhanced SQLite copy per source by fetching remote `*.dblock.zip.aes` files, decrypting them, and writing:
- `archive_entry_index`
- `volume_crypto_cache`
- `volume_scan_inventory`
- `enhancement_meta`
## Endpoints
- `GET /api/sources`
- `GET /api/sources/:sourceId`
- `POST /api/sources/upload`
- `GET /api/server-db`
- `POST /api/server-db/upload`
- `PUT /api/sources/:sourceId/secrets`
- `POST /api/sources/:sourceId/enhance`
- `GET /api/sources/:sourceId/enhance/status`
- `GET /api/ls?sourceId=...&path=/&snapshot=latest`
- `GET /api/file-info?sourceId=...&id=...`
- `GET /healthz`
Compatibility routes:
- `POST /api/source/upload`
- `GET /api/source/current`
`/api/source/current` only works when the system currently contains exactly one source. If there are multiple sources, it returns `409 SOURCE_ID_REQUIRED`.
## Quick start
```bash
npm install
copy .env.example .env
npm start
```
Environment variables:
- `PORT`: HTTP port, default `3000`
- `APP_DB_PATH`: service-owned SQLite path, default `./data/app.sqlite`
- `UPLOAD_DIR`: source storage root, default `./data/sources`
- `PREVIEW_MAX_BYTES`: max file size flagged as safe for in-memory preview, default `16777216`
## Multi-source model
Each upload is stored under its own directory:
- `data/sources/<sourceId>/raw.sqlite`
- `data/sources/<sourceId>/enhanced.sqlite`
- `data/sources/<sourceId>/enhanced.sqlite.tmp`
- `data/sources/<sourceId>/work/*`
Duplicate uploads are deduplicated by SHA-256. If the same task database is uploaded again, the service returns the existing `sourceId` instead of creating a second copy.
The optional `Duplicati-server.sqlite` upload is stored separately under the upload root and never appears in `/api/sources`. It is only used to resolve friendly task names by matching `path.basename(Backup.DBPath)` against each uploaded task database filename.
When a server DB is available:
- source cards prefer `Backup.Name` as `displayName`
- `displayNameSource` becomes `server-db`
- `matchedBackupName` returns the matched task name
If no match exists, the source keeps using its original filename as `displayName`.
## Upload model
There are now two upload paths:
1. Task DB upload: `POST /api/sources/upload`
2. Server DB upload: `POST /api/server-db/upload`
Use the task DB upload for random-name Duplicati job databases such as `TMQRJYNADS.sqlite`.
Use the server DB upload only for `Duplicati-server.sqlite`. Uploading that file to the task DB endpoint is rejected with `422 INVALID_SOURCE_SCHEMA`.
## Enhancement flow
Raw Duplicati task databases can browse files immediately, but `/api/file-info` stays gated until archive indexes exist.
To enhance a source:
1. Upload a raw task database.
2. Save WebDAV base URL, auth mode, username/password, and backup passphrase.
3. Start enhancement for that `sourceId`.
4. The backend serially downloads each `*.dblock.zip.aes`, decrypts the AES Crypt v2 container, scans the ZIP central directory, and writes supplemental tables into an enhanced SQLite copy.
5. After success, the source flips to `ready` and `/api/file-info` starts returning ordered segments and dblock mappings.
Current runtime assumptions:
- WebDAV auth supports `basic` and `anonymous`
- AES enhancement currently supports AES Crypt v2 volumes
- enhancement jobs run serially in-process
- credentials are stored in `app.sqlite` and are not returned by the API
If enhancement fails, source browsing still works, and `/api/file-info` returns `409 ENHANCEMENT_FAILED`.
## Frontend restore flow
The runtime restore path stays "thick frontend, thin backend":
1. `source.html` browses the selected source with `/api/ls`
2. `file.html` fetches `/api/file-info`
3. the browser asks WebDAV directly for the required `*.dblock.zip.aes`
4. the browser decrypts AES Crypt v2 locally
5. the browser caches plain ZIP volumes in OPFS
6. a root-scope `Service Worker` serves pseudo-Range responses for `<video>`
7. downloads write restored bytes directly to disk with `showSaveFilePicker()`
Important constraints:
- the backend still does not proxy file bytes
- browser-side WebDAV credentials are entered separately on `file.html`
- the current v1 range model fetches each needed dblock as a whole encrypted file, then decrypts it in the browser
- remote random access is therefore "logical range over cached dblocks", not true O(1) random access inside `.aes`
Supported browser target:
- desktop Chromium with `Service Worker`, `OPFS`, `IndexedDB`, `showSaveFilePicker()`, `Web Crypto`, and `DecompressionStream`
## Supplemental schema
The enhancement job writes the SQL shape described in [sql/supplemental-schema.sql](C:/Users/nanxun/Documents/jiami/sql/supplemental-schema.sql).
If you already have a pre-enhanced SQLite file, uploading it is also supported. In that case the source is immediately marked `ready`.
## Runtime note
This implementation uses Node 24's built-in `node:sqlite` module. In this Windows environment, native `sqlite3` compilation is not reliable enough, so the service uses the built-in adapter instead.

853
package-lock.json generated Normal file
View File

@ -0,0 +1,853 @@
{
"name": "zero-bandwidth-duplicati-api",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "zero-bandwidth-duplicati-api",
"version": "0.1.0",
"dependencies": {
"aes-js": "^3.1.2",
"express": "^4.22.1",
"mime-types": "^2.1.35"
},
"engines": {
"node": ">=24.0.0"
}
},
"node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmmirror.com/accepts/-/accepts-1.3.8.tgz",
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
"license": "MIT",
"dependencies": {
"mime-types": "~2.1.34",
"negotiator": "0.6.3"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/aes-js": {
"version": "3.1.2",
"resolved": "https://registry.npmmirror.com/aes-js/-/aes-js-3.1.2.tgz",
"integrity": "sha512-e5pEa2kBnBOgR4Y/p20pskXI74UEz7de8ZGVo58asOtvSVG5YAbJeELPZxOmt+Bnz3rX753YKhfIn4X4l1PPRQ==",
"license": "MIT"
},
"node_modules/array-flatten": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/array-flatten/-/array-flatten-1.1.1.tgz",
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT"
},
"node_modules/body-parser": {
"version": "1.20.5",
"resolved": "https://registry.npmmirror.com/body-parser/-/body-parser-1.20.5.tgz",
"integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"content-type": "~1.0.5",
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "~1.2.0",
"http-errors": "~2.0.1",
"iconv-lite": "~0.4.24",
"on-finished": "~2.4.1",
"qs": "~6.15.1",
"raw-body": "~2.5.3",
"type-is": "~1.6.18",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/body-parser/node_modules/qs": {
"version": "6.15.1",
"resolved": "https://registry.npmmirror.com/qs/-/qs-6.15.1.tgz",
"integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmmirror.com/bytes/-/bytes-3.1.2.tgz",
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/call-bound": {
"version": "1.0.4",
"resolved": "https://registry.npmmirror.com/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"get-intrinsic": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/content-disposition": {
"version": "0.5.4",
"resolved": "https://registry.npmmirror.com/content-disposition/-/content-disposition-0.5.4.tgz",
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
"license": "MIT",
"dependencies": {
"safe-buffer": "5.2.1"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/content-type": {
"version": "1.0.5",
"resolved": "https://registry.npmmirror.com/content-type/-/content-type-1.0.5.tgz",
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmmirror.com/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie-signature": {
"version": "1.0.7",
"resolved": "https://registry.npmmirror.com/cookie-signature/-/cookie-signature-1.0.7.tgz",
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
"license": "MIT"
},
"node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmmirror.com/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/depd/-/depd-2.0.0.tgz",
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/destroy": {
"version": "1.2.0",
"resolved": "https://registry.npmmirror.com/destroy/-/destroy-1.2.0.tgz",
"integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
"license": "MIT",
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/ee-first/-/ee-first-1.1.1.tgz",
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
"license": "MIT"
},
"node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/encodeurl/-/encodeurl-2.0.0.tgz",
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmmirror.com/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT"
},
"node_modules/etag": {
"version": "1.8.1",
"resolved": "https://registry.npmmirror.com/etag/-/etag-1.8.1.tgz",
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/express": {
"version": "4.22.1",
"resolved": "https://registry.npmmirror.com/express/-/express-4.22.1.tgz",
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
"license": "MIT",
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
"body-parser": "~1.20.3",
"content-disposition": "~0.5.4",
"content-type": "~1.0.4",
"cookie": "~0.7.1",
"cookie-signature": "~1.0.6",
"debug": "2.6.9",
"depd": "2.0.0",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"finalhandler": "~1.3.1",
"fresh": "~0.5.2",
"http-errors": "~2.0.0",
"merge-descriptors": "1.0.3",
"methods": "~1.1.2",
"on-finished": "~2.4.1",
"parseurl": "~1.3.3",
"path-to-regexp": "~0.1.12",
"proxy-addr": "~2.0.7",
"qs": "~6.14.0",
"range-parser": "~1.2.1",
"safe-buffer": "5.2.1",
"send": "~0.19.0",
"serve-static": "~1.16.2",
"setprototypeof": "1.2.0",
"statuses": "~2.0.1",
"type-is": "~1.6.18",
"utils-merge": "1.0.1",
"vary": "~1.1.2"
},
"engines": {
"node": ">= 0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/finalhandler": {
"version": "1.3.2",
"resolved": "https://registry.npmmirror.com/finalhandler/-/finalhandler-1.3.2.tgz",
"integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
"license": "MIT",
"dependencies": {
"debug": "2.6.9",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"on-finished": "~2.4.1",
"parseurl": "~1.3.3",
"statuses": "~2.0.2",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmmirror.com/forwarded/-/forwarded-0.2.0.tgz",
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/fresh": {
"version": "0.5.2",
"resolved": "https://registry.npmmirror.com/fresh/-/fresh-0.5.2.tgz",
"integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.3",
"resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.3.tgz",
"integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmmirror.com/http-errors/-/http-errors-2.0.1.tgz",
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
"license": "MIT",
"dependencies": {
"depd": "~2.0.0",
"inherits": "~2.0.4",
"setprototypeof": "~1.2.0",
"statuses": "~2.0.2",
"toidentifier": "~1.0.1"
},
"engines": {
"node": ">= 0.8"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.4.24.tgz",
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmmirror.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
"license": "MIT",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/media-typer": {
"version": "0.3.0",
"resolved": "https://registry.npmmirror.com/media-typer/-/media-typer-0.3.0.tgz",
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/merge-descriptors": {
"version": "1.0.3",
"resolved": "https://registry.npmmirror.com/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
"integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/methods": {
"version": "1.1.2",
"resolved": "https://registry.npmmirror.com/methods/-/methods-1.1.2.tgz",
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime": {
"version": "1.6.0",
"resolved": "https://registry.npmmirror.com/mime/-/mime-1.6.0.tgz",
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
"license": "MIT",
"bin": {
"mime": "cli.js"
},
"engines": {
"node": ">=4"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/negotiator": {
"version": "0.6.3",
"resolved": "https://registry.npmmirror.com/negotiator/-/negotiator-0.6.3.tgz",
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmmirror.com/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmmirror.com/on-finished/-/on-finished-2.4.1.tgz",
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
"license": "MIT",
"dependencies": {
"ee-first": "1.1.1"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmmirror.com/parseurl/-/parseurl-1.3.3.tgz",
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/path-to-regexp": {
"version": "0.1.13",
"resolved": "https://registry.npmmirror.com/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"license": "MIT"
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmmirror.com/proxy-addr/-/proxy-addr-2.0.7.tgz",
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
"license": "MIT",
"dependencies": {
"forwarded": "0.2.0",
"ipaddr.js": "1.9.1"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/qs": {
"version": "6.14.2",
"resolved": "https://registry.npmmirror.com/qs/-/qs-6.14.2.tgz",
"integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmmirror.com/range-parser/-/range-parser-1.2.1.tgz",
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/raw-body": {
"version": "2.5.3",
"resolved": "https://registry.npmmirror.com/raw-body/-/raw-body-2.5.3.tgz",
"integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"http-errors": "~2.0.1",
"iconv-lite": "~0.4.24",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/send": {
"version": "0.19.2",
"resolved": "https://registry.npmmirror.com/send/-/send-0.19.2.tgz",
"integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
"license": "MIT",
"dependencies": {
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "1.2.0",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"fresh": "~0.5.2",
"http-errors": "~2.0.1",
"mime": "1.6.0",
"ms": "2.1.3",
"on-finished": "~2.4.1",
"range-parser": "~1.2.1",
"statuses": "~2.0.2"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/send/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/serve-static": {
"version": "1.16.3",
"resolved": "https://registry.npmmirror.com/serve-static/-/serve-static-1.16.3.tgz",
"integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
"license": "MIT",
"dependencies": {
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"parseurl": "~1.3.3",
"send": "~0.19.1"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmmirror.com/setprototypeof/-/setprototypeof-1.2.0.tgz",
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
"node_modules/side-channel": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/side-channel/-/side-channel-1.1.0.tgz",
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3",
"side-channel-list": "^1.0.0",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-list": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/side-channel-list/-/side-channel-list-1.0.1.tgz",
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.4"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-map": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-weakmap": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3",
"side-channel-map": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmmirror.com/statuses/-/statuses-2.0.2.tgz",
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/toidentifier/-/toidentifier-1.0.1.tgz",
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
"license": "MIT",
"engines": {
"node": ">=0.6"
}
},
"node_modules/type-is": {
"version": "1.6.18",
"resolved": "https://registry.npmmirror.com/type-is/-/type-is-1.6.18.tgz",
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
"license": "MIT",
"dependencies": {
"media-typer": "0.3.0",
"mime-types": "~2.1.24"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/unpipe/-/unpipe-1.0.0.tgz",
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/utils-merge": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/utils-merge/-/utils-merge-1.0.1.tgz",
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
"license": "MIT",
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmmirror.com/vary/-/vary-1.1.2.tgz",
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
}
}
}

21
package.json Normal file
View File

@ -0,0 +1,21 @@
{
"name": "zero-bandwidth-duplicati-api",
"version": "0.1.0",
"private": true,
"description": "Thin JSON API over a Duplicati SQLite database for zero-bandwidth browser restores.",
"type": "module",
"engines": {
"node": ">=24.0.0"
},
"scripts": {
"start": "node src/server.js",
"dev": "node --watch src/server.js",
"test": "node --test",
"check": "node --check src/server.js && node --check src/db/duplicatiRepository.js && node --check src/db/sourceCatalog.js && node --check src/services/activeSourceService.js && node --check src/services/sourceEnhancementService.js && node --check src/lib/aesCrypt.js && node --check src/lib/zip.js && node --check public/app.js && node --check public/source.js && node --check public/file.js && node --check public/media-sw.js && node --check public/modules/common.js && node --check public/modules/clientSecrets.js && node --check public/modules/media-core.js && node --check public/modules/thumbnail-utils.js && node --check public/vendor/aes-js-esm.js"
},
"dependencies": {
"aes-js": "^3.1.2",
"express": "^4.22.1",
"mime-types": "^2.1.35"
}
}

449
public/app.js Normal file
View File

@ -0,0 +1,449 @@
import {
createFeedbackController,
escapeHtml,
fetchJson,
linkToSource,
renderSourceSummaryCard
} from './modules/common.js';
const CONTROL_VIEWS = new Set(['overview', 'sources', 'upload', 'server-db', 'defaults']);
const state = {
sources: [],
serverDb: null,
globalDefaults: null,
currentView: 'overview'
};
const uploadForm = document.querySelector('#uploadForm');
const uploadButton = document.querySelector('#uploadButton');
const databaseInput = document.querySelector('#databaseInput');
const serverDbForm = document.querySelector('#serverDbForm');
const serverDbUploadButton = document.querySelector('#serverDbUploadButton');
const serverDbInput = document.querySelector('#serverDbInput');
const globalDefaultsForm = document.querySelector('#globalDefaultsForm');
const globalDefaultsSaveButton = document.querySelector('#globalDefaultsSaveButton');
const refreshButton = document.querySelector('#refreshButton');
const serverDbSummaryElement = document.querySelector('#serverDbSummary');
const globalDefaultsSummaryElement = document.querySelector('#globalDefaultsSummary');
const overviewSummaryElement = document.querySelector('#overviewSummary');
const sourceListElement = document.querySelector('#sourceList');
const viewPanels = [...document.querySelectorAll('[data-view-panel]')];
const viewLinks = [...document.querySelectorAll('[data-view-link]')];
const feedback = createFeedbackController(document.querySelector('#feedback'));
function normalizeView(value) {
return CONTROL_VIEWS.has(value) ? value : 'overview';
}
function setCurrentView(nextView, { syncHash = true, replace = false } = {}) {
state.currentView = normalizeView(nextView);
for (const panel of viewPanels) {
panel.hidden = panel.dataset.viewPanel !== state.currentView;
}
for (const link of viewLinks) {
const active = link.dataset.viewLink === state.currentView;
link.classList.toggle('is-active', active);
link.setAttribute('aria-current', active ? 'page' : 'false');
}
if (!syncHash) {
return;
}
const url = new URL(window.location.href);
url.hash = `#${state.currentView}`;
if (replace) {
window.history.replaceState(null, '', url);
} else {
window.history.pushState(null, '', url);
}
}
function readGlobalDefaultsForm() {
return {
webdavBaseUrl: document.querySelector('#globalWebdavBaseUrl')?.value?.trim() ?? '',
authMode: document.querySelector('#globalAuthMode')?.value ?? '',
username: document.querySelector('#globalUsername')?.value?.trim() ?? '',
password: document.querySelector('#globalPassword')?.value ?? '',
passphrase: document.querySelector('#globalPassphrase')?.value ?? ''
};
}
function writeGlobalDefaultsForm(defaults) {
document.querySelector('#globalWebdavBaseUrl').value = defaults?.webdavBaseUrl ?? '';
document.querySelector('#globalAuthMode').value = defaults?.authMode ?? '';
document.querySelector('#globalUsername').value = '';
document.querySelector('#globalPassword').value = '';
document.querySelector('#globalPassphrase').value = '';
}
function renderOverviewSummary() {
const sourceCount = state.sources.length;
const readyCount = state.sources.filter((source) => source.enhancement?.status === 'ready').length;
const failedCount = state.sources.filter((source) => source.enhancement?.status === 'failed').length;
const mappedCount = state.sources.filter((source) => source.displayNameSource === 'server-db').length;
const serverDbAvailable = Boolean(state.serverDb?.available);
const defaultsConfigured = Boolean(state.globalDefaults?.configured);
overviewSummaryElement.innerHTML = `
<div class="summary-stack">
<div class="summary-row">
<span>当前 source 数量</span>
<strong>${escapeHtml(String(sourceCount))}</strong>
</div>
<div class="summary-row">
<span>已增强完成</span>
<strong>${escapeHtml(String(readyCount))}</strong>
</div>
<div class="summary-row">
<span>增强失败</span>
<strong>${escapeHtml(String(failedCount))}</strong>
</div>
<div class="summary-row">
<span>已映射任务名</span>
<strong>${escapeHtml(String(mappedCount))}</strong>
</div>
<div class="summary-row">
<span>Server DB</span>
<strong>${serverDbAvailable ? escapeHtml(state.serverDb.originalFilename) : '未上传'}</strong>
</div>
<div class="summary-row">
<span>全局 WebDAV 默认值</span>
<strong>${defaultsConfigured ? '已配置' : '未配置'}</strong>
</div>
</div>
`;
}
function renderServerDbSummary() {
if (!state.serverDb || !state.serverDb.available) {
serverDbSummaryElement.innerHTML = `
<div class="summary-stack">
<div class="summary-row">
<span>当前状态</span>
<strong>尚未上传 Duplicati-server.sqlite</strong>
</div>
<div class="summary-row">
<span>说明</span>
<strong>没有它也能浏览和增强只是任务名和默认目标 URL 无法自动映射</strong>
</div>
</div>
`;
return;
}
serverDbSummaryElement.innerHTML = `
<div class="summary-stack">
<div class="summary-row">
<span>当前文件</span>
<strong>${escapeHtml(state.serverDb.originalFilename)}</strong>
</div>
<div class="summary-row">
<span>上传时间</span>
<strong>${escapeHtml(state.serverDb.uploadedAt)}</strong>
</div>
<div class="summary-row">
<span>任务数量</span>
<strong>${escapeHtml(String(state.serverDb.backupCount))}</strong>
</div>
</div>
`;
}
function renderGlobalDefaultsSummary() {
const defaults = state.globalDefaults;
if (!defaults?.configured) {
globalDefaultsSummaryElement.innerHTML = `
<div class="summary-stack">
<div class="summary-row">
<span>当前状态</span>
<strong>尚未保存全局默认值</strong>
</div>
<div class="summary-row">
<span>说明</span>
<strong>保存一次后后续 source 可以继承这里的认证信息和备份口令</strong>
</div>
</div>
`;
writeGlobalDefaultsForm(null);
return;
}
writeGlobalDefaultsForm(defaults);
globalDefaultsSummaryElement.innerHTML = `
<div class="summary-stack">
<div class="summary-row">
<span>Fallback URL</span>
<strong>${escapeHtml(defaults.webdavBaseUrl ?? '未设置')}</strong>
</div>
<div class="summary-row">
<span>认证方式</span>
<strong>${escapeHtml(defaults.authMode ?? '自动')}</strong>
</div>
<div class="summary-row">
<span>已保存凭据</span>
<strong>
username=${defaults.hasUsername ? 'yes' : 'no'},
password=${defaults.hasPassword ? 'yes' : 'no'},
passphrase=${defaults.hasPassphrase ? 'yes' : 'no'}
</strong>
</div>
<div class="summary-row">
<span>更新时间</span>
<strong>${escapeHtml(defaults.updatedAt ?? '未知')}</strong>
</div>
</div>
`;
}
function renderSources() {
if (state.sources.length === 0) {
sourceListElement.innerHTML = `
<div class="empty-state-card">
<p>还没有任何任务数据源</p>
<p>先上传一个随机名任务库然后再进入工作台浏览目录做增强或进入文件页预览下载</p>
</div>
`;
return;
}
sourceListElement.innerHTML = state.sources
.map((source) => {
const targetHint = source.webdav?.effectiveWebdavBaseUrl
? `<div class="summary-chip">默认目标 URL 来源: ${escapeHtml(source.webdav.effectiveWebdavBaseUrlSource ?? 'unknown')}</div>`
: '<div class="summary-chip">当前还没有可用的默认目标 URL</div>';
return renderSourceSummaryCard(
source,
`
${targetHint}
<div class="card-actions">
<a class="ghost-button button-link" href="${linkToSource(source.id)}#overview">进入工作台</a>
<button class="ghost-button danger-button" type="button" data-delete-source-id="${escapeHtml(source.id)}">删除 source</button>
</div>
`
);
})
.join('');
}
async function loadServerDbSummary() {
const payload = await fetchJson('/api/server-db');
state.serverDb = payload.serverDb;
renderServerDbSummary();
renderOverviewSummary();
}
async function loadGlobalDefaults() {
const payload = await fetchJson('/api/webdav-defaults');
state.globalDefaults = payload.defaults;
renderGlobalDefaultsSummary();
renderOverviewSummary();
}
async function loadSources() {
const payload = await fetchJson('/api/sources');
state.sources = payload.sources;
renderSources();
renderOverviewSummary();
}
async function handleTaskDbUpload(event) {
event.preventDefault();
feedback.clear();
const file = databaseInput.files?.[0];
if (!file) {
feedback.set('error', '请先选择一个任务 SQLite 文件。');
return;
}
uploadButton.disabled = true;
refreshButton.disabled = true;
feedback.set('info', '正在上传任务数据库...');
try {
const formData = new FormData();
formData.set('database', file);
const response = await fetch('/api/sources/upload', {
method: 'POST',
body: formData
});
const payload = await response.json();
if (!response.ok) {
throw new Error(`${payload.error?.code ?? 'UPLOAD_FAILED'}: ${payload.error?.message ?? 'Upload failed.'}`);
}
await Promise.all([loadSources(), loadServerDbSummary()]);
uploadForm.reset();
setCurrentView('sources');
feedback.set(
payload.reused ? 'info' : 'success',
payload.reused
? '这个任务库之前已经上传过,系统复用了现有 source。'
: '任务数据库上传成功,可以进入对应工作台继续操作。'
);
} catch (error) {
feedback.set('error', error.message);
} finally {
uploadButton.disabled = false;
refreshButton.disabled = false;
}
}
async function handleServerDbUpload(event) {
event.preventDefault();
feedback.clear();
const file = serverDbInput.files?.[0];
if (!file) {
feedback.set('error', '请先选择 Duplicati-server.sqlite。');
return;
}
serverDbUploadButton.disabled = true;
feedback.set('info', '正在上传 Server DB 并刷新任务名与目标 URL 映射...');
try {
const formData = new FormData();
formData.set('database', file);
const response = await fetch('/api/server-db/upload', {
method: 'POST',
body: formData
});
const payload = await response.json();
if (!response.ok) {
throw new Error(`${payload.error?.code ?? 'UPLOAD_FAILED'}: ${payload.error?.message ?? 'Upload failed.'}`);
}
await Promise.all([loadSources(), loadServerDbSummary()]);
serverDbForm.reset();
feedback.set('success', 'Server DB 上传成功,任务名和默认目标 URL 已刷新。');
} catch (error) {
feedback.set('error', error.message);
} finally {
serverDbUploadButton.disabled = false;
}
}
async function handleGlobalDefaultsSave(event) {
event.preventDefault();
feedback.clear();
globalDefaultsSaveButton.disabled = true;
feedback.set('info', '正在保存全局 WebDAV 默认值...');
try {
const payload = await fetchJson('/api/webdav-defaults', {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(readGlobalDefaultsForm())
});
state.globalDefaults = payload.defaults;
renderGlobalDefaultsSummary();
renderOverviewSummary();
await loadSources();
feedback.set(
payload.defaults.configured ? 'success' : 'info',
payload.defaults.configured ? '全局默认值已保存。' : '全局默认值已清空。'
);
} catch (error) {
feedback.set('error', error.message);
} finally {
globalDefaultsSaveButton.disabled = false;
}
}
async function deleteSource(sourceId) {
feedback.clear();
const source = state.sources.find((item) => item.id === sourceId);
const label = source?.displayName || source?.originalFilename || sourceId;
const confirmed = window.confirm(`确定要删除这个 source 吗?\n\n${label}\n\n这只会删除当前任务库和本地增强副本,不会删除 Duplicati-server.sqlite。`);
if (!confirmed) {
return;
}
feedback.set('info', `正在删除 ${label}...`);
try {
const payload = await fetchJson(`/api/sources/${encodeURIComponent(sourceId)}`, {
method: 'DELETE'
});
await Promise.all([loadSources(), loadServerDbSummary()]);
feedback.set('success', `已删除 ${payload.deleted.originalFilename}。Server DB 保持不变。`);
} catch (error) {
feedback.set('error', error.message);
}
}
uploadForm.addEventListener('submit', (event) => {
void handleTaskDbUpload(event);
});
serverDbForm.addEventListener('submit', (event) => {
void handleServerDbUpload(event);
});
globalDefaultsForm.addEventListener('submit', (event) => {
void handleGlobalDefaultsSave(event);
});
refreshButton.addEventListener('click', () => {
feedback.clear();
sourceListElement.textContent = '正在刷新数据源列表...';
void Promise.all([loadSources(), loadServerDbSummary(), loadGlobalDefaults()]).catch((error) => {
feedback.set('error', error.message);
});
});
sourceListElement.addEventListener('click', (event) => {
const target = event.target;
if (!(target instanceof HTMLElement)) {
return;
}
const actionElement = target.closest('[data-delete-source-id]');
if (!(actionElement instanceof HTMLElement)) {
return;
}
const sourceId = actionElement.dataset.deleteSourceId;
if (!sourceId) {
return;
}
void deleteSource(sourceId);
});
for (const link of viewLinks) {
link.addEventListener('click', (event) => {
event.preventDefault();
setCurrentView(normalizeView(link.dataset.viewLink ?? 'overview'));
});
}
window.addEventListener('hashchange', () => {
setCurrentView(normalizeView(window.location.hash.slice(1)), { syncHash: false });
});
async function bootstrap() {
const initialView = normalizeView(window.location.hash.slice(1) || 'overview');
setCurrentView(initialView, { replace: true });
await Promise.all([loadServerDbSummary(), loadGlobalDefaults(), loadSources()]);
}
void bootstrap().catch((error) => {
feedback.set('error', error.message);
sourceListElement.innerHTML = `
<div class="empty-state-card">
<p>无法读取数据源列表</p>
</div>
`;
});

171
public/file.html Normal file
View File

@ -0,0 +1,171 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>文件预览与下载</title>
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<main class="shell">
<nav class="crumbs">
<a href="/">数据源控制台</a>
<span>/</span>
<a id="sourceCrumb" href="/">数据源工作台</a>
<span>/</span>
<span>文件页</span>
</nav>
<section class="hero hero-compact">
<div>
<p class="eyebrow">File Workspace</p>
<h1 id="fileTitle">文件预览与下载</h1>
</div>
<p class="lead">
这里负责浏览器直连 WebDAV 的视频预览、下载、本地缓存和预览图回写。大流量文件流仍然只走前端,不经过后端文件代理。
</p>
</section>
<div id="feedback" class="feedback" hidden></div>
<section class="workspace-shell">
<aside class="workspace-nav" aria-label="文件工作台菜单">
<a class="workspace-nav-link" href="#overview" data-view-link="overview">文件概况</a>
<a class="workspace-nav-link" href="#preview" data-view-link="preview">视频预览</a>
<a class="workspace-nav-link" href="#download" data-view-link="download">下载</a>
<a class="workspace-nav-link" href="#cache" data-view-link="cache">本地缓存</a>
<a class="workspace-nav-link" href="#thumbnail" data-view-link="thumbnail">预览图</a>
</aside>
<div class="workspace-content">
<section class="workspace-panel" data-view-panel="overview">
<div class="panel">
<div class="panel-header">
<div>
<p class="section-label">File</p>
<h2>文件概况</h2>
</div>
</div>
<div id="fileSummary" class="empty-state-card">正在读取文件摘要...</div>
</div>
</section>
<section class="workspace-panel" data-view-panel="preview" hidden>
<div class="panel stack-gap">
<div class="panel-header">
<div>
<p class="section-label">Preview</p>
<h2>视频预览</h2>
</div>
<div class="form-actions workspace-inline-actions">
<button id="startPreviewButton" class="primary-button" type="button">启动视频预览</button>
<button id="deleteThumbnailFromPreviewButton" class="ghost-button danger-button" type="button">删除当前预览图</button>
</div>
</div>
<p id="videoHint" class="panel-copy">
只有增强完成、并且 MIME 为视频的文件,才会在这里启用浏览器端 Range 播放。
</p>
<div id="previewThumbnailHint" class="summary-card">
这里删除的是后端缩略图缓存,不会删除浏览器本地视频缓存,也不会影响当前视频文件本身。
</div>
<div id="clientSecretHint" class="summary-card">正在计算当前任务的浏览器默认值...</div>
<form id="clientSecretForm" class="secret-form">
<label>
<span>WebDAV Base URL</span>
<input id="clientWebdavBaseUrl" name="webdavBaseUrl" type="url" placeholder="默认会自动带出当前任务的目标 URL">
</label>
<label>
<span>认证方式</span>
<select id="clientAuthMode" name="authMode">
<option value="basic">Basic</option>
<option value="anonymous">Anonymous</option>
</select>
</label>
<label>
<span>用户名</span>
<input id="clientUsername" name="username" type="text" placeholder="默认继承浏览器全局默认值">
</label>
<label>
<span>密码</span>
<input id="clientPassword" name="password" type="password" placeholder="默认继承浏览器全局默认值">
</label>
<label>
<span>备份口令</span>
<input id="clientPassphrase" name="passphrase" type="password" placeholder="默认继承浏览器全局默认值">
</label>
<label class="checkbox-row">
<input id="rememberOnDevice" name="rememberOnDevice" type="checkbox">
<span>记住为当前 source 的浏览器本地覆盖</span>
</label>
<div class="form-actions">
<button id="saveClientSecretsButton" class="ghost-button" type="submit">保存当前 source 覆盖</button>
<button id="saveClientGlobalsButton" class="ghost-button" type="button">保存为浏览器全局默认值</button>
</div>
</form>
<div class="video-frame">
<video id="previewPlayer" controls preload="metadata"></video>
</div>
<canvas id="thumbnailCaptureCanvas" hidden></canvas>
</div>
</section>
<section class="workspace-panel" data-view-panel="download" hidden>
<div class="panel">
<div class="panel-header">
<div>
<p class="section-label">Download</p>
<h2>前端直连下载</h2>
</div>
<button id="downloadButton" class="ghost-button" type="button">下载到本地</button>
</div>
<p class="panel-copy">
下载会复用视频预览相同的浏览器直连配置和 dblock 缓存,顺序读取所需分段并直接写盘,不把整个文件拼进内存。
</p>
<div id="downloadStatus" class="summary-card">尚未开始下载。</div>
</div>
</section>
<section class="workspace-panel" data-view-panel="cache" hidden>
<div class="panel stack-gap">
<div class="panel-header">
<div>
<p class="section-label">Cache</p>
<h2>本地缓存与会话状态</h2>
</div>
</div>
<div id="sessionStatus" class="summary-card">尚未注册媒体会话。</div>
<div id="cacheStatus" class="summary-card">正在统计浏览器本地缓存...</div>
<div class="form-actions">
<button id="clearSourceCacheButton" class="ghost-button danger-button" type="button">清理当前 source 缓存</button>
<button id="clearAllCacheButton" class="ghost-button danger-button" type="button">清理全部浏览器缓存</button>
</div>
</div>
</section>
<section class="workspace-panel" data-view-panel="thumbnail" hidden>
<div class="panel stack-gap">
<div class="panel-header">
<div>
<p class="section-label">Preview Thumbnail</p>
<h2>预览图</h2>
</div>
<button id="deleteThumbnailButton" class="ghost-button danger-button" type="button">删除当前预览图</button>
</div>
<p class="panel-copy">
视频第一次成功预览后,会自动截取首个已解码画面并回写到后端。目录浏览页会直接使用这里缓存的缩略图。
</p>
<div id="thumbnailStatus" class="summary-card">正在读取预览图状态...</div>
<div id="thumbnailPreview" class="thumbnail-card">
<div class="thumbnail-placeholder">暂无预览图</div>
</div>
</div>
</section>
</div>
</section>
</main>
<script type="module" src="/file.js"></script>
</body>
</html>

1309
public/file.js Normal file

File diff suppressed because it is too large Load Diff

157
public/index.html Normal file
View File

@ -0,0 +1,157 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>数据源控制台</title>
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<main class="shell">
<section class="hero hero-compact">
<div>
<p class="eyebrow">Zero-Bandwidth Duplicati Web Client</p>
<h1>数据源控制台</h1>
</div>
<p class="lead">
首页现在只负责数据源级管理。任务上传、Server DB、全局 WebDAV 默认值和源列表已经拆成菜单式子面板,不再全部挤在同一屏。
</p>
</section>
<div id="feedback" class="feedback" hidden></div>
<section class="workspace-shell">
<aside class="workspace-nav" aria-label="数据源控制台菜单">
<a class="workspace-nav-link" href="#overview" data-view-link="overview">总览</a>
<a class="workspace-nav-link" href="#sources" data-view-link="sources">数据源列表</a>
<a class="workspace-nav-link" href="#upload" data-view-link="upload">新增任务库</a>
<a class="workspace-nav-link" href="#server-db" data-view-link="server-db">Server DB</a>
<a class="workspace-nav-link" href="#defaults" data-view-link="defaults">全局 WebDAV</a>
</aside>
<div class="workspace-content">
<section class="workspace-panel" data-view-panel="overview">
<div class="panel stack-gap">
<div class="panel-header">
<div>
<p class="section-label">Overview</p>
<h2>控制台总览</h2>
</div>
</div>
<div id="overviewSummary" class="summary-card">正在汇总控制台状态...</div>
</div>
</section>
<section class="workspace-panel" data-view-panel="sources" hidden>
<div class="panel panel-spacious">
<div class="panel-header">
<div>
<p class="section-label">Sources</p>
<h2>数据源列表</h2>
</div>
<button id="refreshButton" class="ghost-button" type="button">刷新列表</button>
</div>
<p class="panel-copy">
这里显示当前所有任务库 source。点击“进入工作台”会进入该任务自己的管理页删除只会删除该 source不会删除 <code>Duplicati-server.sqlite</code>
</p>
<div id="sourceList" class="source-list empty-state">正在读取数据源列表...</div>
</div>
</section>
<section class="workspace-panel" data-view-panel="upload" hidden>
<div class="panel">
<div class="panel-header">
<div>
<p class="section-label">Task DB</p>
<h2>新增任务数据源</h2>
</div>
</div>
<p class="panel-copy">
上传随机名任务库,例如 <code>TMQRJYNADS.sqlite</code>。每个任务库都会保留成独立 source不会互相覆盖。
</p>
<form id="uploadForm" class="upload-form">
<label class="file-picker">
<span>选择任务 SQLite 文件</span>
<input id="databaseInput" name="database" type="file" accept=".sqlite,.db,application/octet-stream">
</label>
<div class="form-actions">
<button id="uploadButton" class="primary-button" type="submit">上传为新数据源</button>
</div>
</form>
</div>
</section>
<section class="workspace-panel" data-view-panel="server-db" hidden>
<div class="panel stack-gap">
<div class="panel-header">
<div>
<p class="section-label">Server DB</p>
<h2>上传服务器数据库</h2>
</div>
</div>
<p class="panel-copy">
上传 <code>Duplicati-server.sqlite</code> 后,系统会把随机任务库名映射回友好的任务名,并尝试推导每个任务的默认 WebDAV 目标 URL。
</p>
<form id="serverDbForm" class="upload-form">
<label class="file-picker">
<span>选择服务器数据库</span>
<input id="serverDbInput" name="database" type="file" accept=".sqlite,.db,application/octet-stream">
</label>
<div class="form-actions">
<button id="serverDbUploadButton" class="ghost-button" type="submit">上传 Server DB</button>
</div>
</form>
<div id="serverDbSummary" class="summary-card">正在读取当前 Server DB 状态...</div>
</div>
</section>
<section class="workspace-panel" data-view-panel="defaults" hidden>
<div class="panel stack-gap">
<div class="panel-header">
<div>
<p class="section-label">Global WebDAV</p>
<h2>保存全局默认值</h2>
</div>
</div>
<p class="panel-copy">
WebDAV 用户名、密码和备份口令如果大多数任务都共用,可以在这里全局保存一次。单个任务的目标 URL 会优先从 <code>Duplicati-server.sqlite</code> 自动推导。
</p>
<form id="globalDefaultsForm" class="secret-form">
<label>
<span>Fallback WebDAV Base URL</span>
<input id="globalWebdavBaseUrl" name="webdavBaseUrl" type="url" placeholder="可选,全局兜底 URL">
</label>
<label>
<span>认证方式</span>
<select id="globalAuthMode" name="authMode">
<option value="">自动 / 继承</option>
<option value="basic">Basic</option>
<option value="anonymous">Anonymous</option>
</select>
</label>
<label>
<span>用户名</span>
<input id="globalUsername" name="username" type="text" placeholder="可选,共用用户名">
</label>
<label>
<span>密码</span>
<input id="globalPassword" name="password" type="password" placeholder="可选,共用密码">
</label>
<label>
<span>备份口令</span>
<input id="globalPassphrase" name="passphrase" type="password" placeholder="共用的备份口令">
</label>
<div class="form-actions">
<button id="globalDefaultsSaveButton" class="ghost-button" type="submit">保存全局默认值</button>
</div>
</form>
<div id="globalDefaultsSummary" class="summary-card">正在读取全局默认值...</div>
</div>
</section>
</div>
</section>
</main>
<script type="module" src="/app.js"></script>
</body>
</html>

500
public/media-sw.js Normal file
View File

@ -0,0 +1,500 @@
import {
buildContentRange,
buildVolumeMap,
ensurePlainZipVolume,
listCachedVolumeNames,
parseSingleRange,
readCachedZipEntryBytes,
selectSegmentsForRange
} from './modules/media-core.js';
const STATUS_CHANNEL_NAME = 'duplicati-media-status';
const SEGMENT_CACHE_LIMIT = 64;
const SESSION_PATH_PREFIX = '/__media__/session/';
const sessions = new Map();
const statusChannel = typeof BroadcastChannel === 'function' ? new BroadcastChannel(STATUS_CHANNEL_NAME) : null;
function createAbortError() {
const error = new Error('The active media request was aborted.');
error.code = 'MEDIA_REQUEST_ABORTED';
error.name = 'AbortError';
return error;
}
function throwIfAborted(signal) {
if (!signal?.aborted) {
return;
}
throw createAbortError();
}
function isAbortError(error) {
return error?.name === 'AbortError' || error?.code === 'MEDIA_REQUEST_ABORTED';
}
function emitStatus(session) {
if (!statusChannel) {
return;
}
statusChannel.postMessage({
type: 'status',
sessionId: session.id,
status: {
phase: session.phase,
currentVolume: session.currentVolume,
cachedVolumes: [...session.cachedVolumes].sort(),
lastError: session.lastError,
registeredAt: session.registeredAt,
requestVersion: session.requestVersion
}
});
}
function setSessionStatus(session, patch, { requestVersion = null, force = false } = {}) {
if (!force && requestVersion !== null && requestVersion !== session.requestVersion) {
return;
}
Object.assign(session, patch);
emitStatus(session);
}
function snapshotSession(session) {
return {
phase: session.phase,
currentVolume: session.currentVolume,
cachedVolumes: [...session.cachedVolumes].sort(),
lastError: session.lastError,
registeredAt: session.registeredAt,
requestVersion: session.requestVersion
};
}
function createHeaders(session, contentLength) {
return {
'Accept-Ranges': 'bytes',
'Content-Type': session.fileInfo.file.mime || 'application/octet-stream',
'Content-Length': String(contentLength),
'Cache-Control': 'no-store'
};
}
function cacheSegment(session, key, bytes) {
if (session.segmentCache.has(key)) {
session.segmentCache.delete(key);
}
session.segmentCache.set(key, bytes);
while (session.segmentCache.size > SEGMENT_CACHE_LIMIT) {
const oldestKey = session.segmentCache.keys().next().value;
session.segmentCache.delete(oldestKey);
}
}
function trackController(session, controller) {
session.activeControllers.add(controller);
controller.signal.addEventListener(
'abort',
() => {
session.activeControllers.delete(controller);
},
{ once: true }
);
return controller;
}
function abortActiveWork(session) {
for (const controller of session.activeControllers) {
controller.abort();
}
session.activeControllers.clear();
session.volumeInflight = new Map();
session.segmentInflight = new Map();
}
function createRequestController(session, request) {
const controller = trackController(session, new AbortController());
if (request.signal) {
if (request.signal.aborted) {
controller.abort();
} else {
request.signal.addEventListener(
'abort',
() => {
controller.abort();
},
{ once: true }
);
}
}
return controller;
}
async function ensureSessionVolume(session, volume, requestVersion, signal) {
throwIfAborted(signal);
const cacheKey = `${session.sourceId}::${volume.name}`;
const inflightMap = session.volumeInflight;
if (inflightMap.has(cacheKey)) {
return inflightMap.get(cacheKey);
}
const promise = ensurePlainZipVolume({
sourceId: session.sourceId,
volume,
secrets: session.secrets,
signal,
onStatus: ({ phase, currentVolume }) => {
setSessionStatus(
session,
{
phase,
currentVolume: currentVolume ?? volume.name
},
{ requestVersion }
);
}
})
.then(({ cached }) => {
throwIfAborted(signal);
session.cachedVolumes.add(volume.name);
setSessionStatus(
session,
{
phase: cached ? 'cache-hit' : 'ready',
currentVolume: volume.name,
lastError: null
},
{ requestVersion }
);
})
.catch((error) => {
if (!isAbortError(error)) {
setSessionStatus(
session,
{
phase: 'error',
currentVolume: volume.name,
lastError: error.message
},
{ requestVersion }
);
}
throw error;
})
.finally(() => {
inflightMap.delete(cacheKey);
});
inflightMap.set(cacheKey, promise);
return promise;
}
async function restoreSegmentBytesForSession(session, segment, requestVersion, signal) {
throwIfAborted(signal);
const cacheKey = `${segment.segmentIndex}`;
if (session.segmentCache.has(cacheKey)) {
const bytes = session.segmentCache.get(cacheKey);
session.segmentCache.delete(cacheKey);
session.segmentCache.set(cacheKey, bytes);
return bytes;
}
const inflightMap = session.segmentInflight;
if (inflightMap.has(cacheKey)) {
return inflightMap.get(cacheKey);
}
const promise = (async () => {
const volume = session.volumeByRef.get(segment.volumeRef);
if (!volume) {
throw new Error(`Missing volume metadata for ${segment.volumeRef}.`);
}
await ensureSessionVolume(session, volume, requestVersion, signal);
throwIfAborted(signal);
setSessionStatus(
session,
{
phase: 'serving',
currentVolume: volume.name
},
{ requestVersion }
);
const bytes = await readCachedZipEntryBytes({
sourceId: session.sourceId,
volumeName: volume.name,
zip: segment.zip,
signal
});
throwIfAborted(signal);
cacheSegment(session, cacheKey, bytes);
return bytes;
})().finally(() => {
inflightMap.delete(cacheKey);
});
inflightMap.set(cacheKey, promise);
return promise;
}
function createRangeStream(session, rangeInfo, requestVersion, requestController) {
const overlaps = selectSegmentsForRange(session.fileInfo.segments, rangeInfo.start, rangeInfo.endExclusive);
return new ReadableStream({
async start(controller) {
try {
for (const overlap of overlaps) {
throwIfAborted(requestController.signal);
const segmentBytes = await restoreSegmentBytesForSession(
session,
overlap.segment,
requestVersion,
requestController.signal
);
throwIfAborted(requestController.signal);
controller.enqueue(
segmentBytes.subarray(overlap.offsetWithinSegmentStart, overlap.offsetWithinSegmentEnd)
);
}
controller.close();
setSessionStatus(
session,
{
phase: 'ready',
currentVolume: null,
lastError: null
},
{ requestVersion }
);
} catch (error) {
if (!isAbortError(error)) {
setSessionStatus(
session,
{
phase: 'error',
currentVolume: session.currentVolume,
lastError: error.message
},
{ requestVersion }
);
}
controller.error(error);
} finally {
session.activeControllers.delete(requestController);
}
},
cancel() {
requestController.abort();
}
});
}
function jsonResponse(payload, status = 200) {
return new Response(JSON.stringify(payload), {
status,
headers: {
'Content-Type': 'application/json'
}
});
}
async function handleMessage(event) {
const { data, ports } = event;
const replyPort = ports?.[0] ?? null;
if (!data?.type) {
return;
}
try {
if (data.type === 'register-session') {
const session = {
id: data.sessionId,
sourceId: data.sourceId,
fileInfo: data.fileInfo,
secrets: data.secrets,
volumeByRef: buildVolumeMap(data.fileInfo?.volumes ?? []),
cachedVolumes: new Set(await listCachedVolumeNames(data.sourceId)),
segmentCache: new Map(),
segmentInflight: new Map(),
volumeInflight: new Map(),
activeControllers: new Set(),
requestVersion: 0,
phase: 'registered',
currentVolume: null,
lastError: null,
registeredAt: new Date().toISOString()
};
const previous = sessions.get(data.sessionId);
if (previous) {
abortActiveWork(previous);
}
sessions.set(data.sessionId, session);
emitStatus(session);
replyPort?.postMessage({
ok: true,
status: snapshotSession(session)
});
return;
}
if (data.type === 'unregister-session') {
const session = sessions.get(data.sessionId);
if (session) {
abortActiveWork(session);
}
sessions.delete(data.sessionId);
replyPort?.postMessage({ ok: true });
return;
}
if (data.type === 'get-session-status') {
const session = sessions.get(data.sessionId);
if (!session) {
replyPort?.postMessage({
ok: false,
error: 'MEDIA_SESSION_NOT_FOUND'
});
return;
}
replyPort?.postMessage({
ok: true,
status: snapshotSession(session)
});
return;
}
} catch (error) {
replyPort?.postMessage({
ok: false,
error: error.message
});
}
}
async function handleMediaRequest(request) {
const url = new URL(request.url);
const sessionId = decodeURIComponent(url.pathname.slice(SESSION_PATH_PREFIX.length));
const session = sessions.get(sessionId);
if (!session) {
return jsonResponse(
{
error: {
code: 'MEDIA_SESSION_NOT_FOUND',
message: 'The requested media session is not registered.'
}
},
404
);
}
const totalSize = Number(session.fileInfo.file.size ?? 0);
if (!Number.isFinite(totalSize) || totalSize < 0) {
return jsonResponse(
{
error: {
code: 'MEDIA_SIZE_INVALID',
message: 'The registered media session did not include a valid file size.'
}
},
500
);
}
let rangeInfo;
try {
rangeInfo = parseSingleRange(request.headers.get('range'), totalSize);
} catch (error) {
return new Response(null, {
status: 416,
headers: {
'Content-Range': `bytes */${totalSize}`
}
});
}
const headers = createHeaders(session, rangeInfo.contentLength);
if (request.method === 'HEAD') {
return new Response(null, {
status: rangeInfo.partial ? 206 : 200,
headers: {
...headers,
...(rangeInfo.partial
? {
'Content-Range': buildContentRange(rangeInfo.start, rangeInfo.endInclusive, totalSize)
}
: {})
}
});
}
abortActiveWork(session);
session.requestVersion += 1;
const requestVersion = session.requestVersion;
const requestController = createRequestController(session, request);
setSessionStatus(
session,
{
phase: 'seeking',
currentVolume: null,
lastError: null
},
{ requestVersion }
);
return new Response(createRangeStream(session, rangeInfo, requestVersion, requestController), {
status: rangeInfo.partial ? 206 : 200,
headers: {
...headers,
...(rangeInfo.partial
? {
'Content-Range': buildContentRange(rangeInfo.start, rangeInfo.endInclusive, totalSize)
}
: {})
}
});
}
self.addEventListener('install', (event) => {
event.waitUntil(self.skipWaiting());
});
self.addEventListener('activate', (event) => {
event.waitUntil(self.clients.claim());
});
self.addEventListener('message', (event) => {
event.waitUntil(handleMessage(event));
});
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
if (url.origin !== self.location.origin) {
return;
}
if (!url.pathname.startsWith(SESSION_PATH_PREFIX)) {
return;
}
if (event.request.method !== 'GET' && event.request.method !== 'HEAD') {
event.respondWith(new Response(null, { status: 405 }));
return;
}
event.respondWith(handleMediaRequest(event.request));
});

View File

@ -0,0 +1,98 @@
const DB_NAME = 'duplicati-browser-secrets';
const STORE_NAME = 'source-secrets';
const DB_VERSION = 1;
const GLOBAL_SOURCE_ID = '__global_defaults__';
function openDatabase() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onerror = () => reject(request.error ?? new Error('Failed to open IndexedDB.'));
request.onupgradeneeded = () => {
const database = request.result;
if (!database.objectStoreNames.contains(STORE_NAME)) {
database.createObjectStore(STORE_NAME, { keyPath: 'sourceId' });
}
};
request.onsuccess = () => resolve(request.result);
});
}
async function withStore(mode, callback) {
const database = await openDatabase();
return new Promise((resolve, reject) => {
const transaction = database.transaction(STORE_NAME, mode);
const store = transaction.objectStore(STORE_NAME);
let callbackResult;
try {
callbackResult = callback(store);
} catch (error) {
transaction.abort();
database.close();
reject(error);
return;
}
transaction.onerror = () => {
database.close();
reject(transaction.error ?? new Error('IndexedDB transaction failed.'));
};
transaction.oncomplete = () => {
database.close();
resolve(callbackResult);
};
});
}
export async function loadClientSecrets(sourceId) {
const database = await openDatabase();
return new Promise((resolve, reject) => {
const transaction = database.transaction(STORE_NAME, 'readonly');
const store = transaction.objectStore(STORE_NAME);
const request = store.get(sourceId);
request.onerror = () => {
database.close();
reject(request.error ?? new Error('Failed to load browser secrets.'));
};
request.onsuccess = () => {
database.close();
resolve(request.result ?? null);
};
});
}
export async function loadGlobalClientSecrets() {
return loadClientSecrets(GLOBAL_SOURCE_ID);
}
export async function saveClientSecrets(sourceId, secrets) {
const record = {
sourceId,
webdavBaseUrl: secrets.webdavBaseUrl,
authMode: secrets.authMode,
username: secrets.username,
password: secrets.password,
passphrase: secrets.passphrase,
updatedAt: new Date().toISOString()
};
return withStore('readwrite', (store) => store.put(record));
}
export async function saveGlobalClientSecrets(secrets) {
return saveClientSecrets(GLOBAL_SOURCE_ID, secrets);
}
export async function removeClientSecrets(sourceId) {
return withStore('readwrite', (store) => store.delete(sourceId));
}
export async function removeGlobalClientSecrets() {
return removeClientSecrets(GLOBAL_SOURCE_ID);
}

160
public/modules/common.js Normal file
View File

@ -0,0 +1,160 @@
export function escapeHtml(value) {
return String(value)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
export function formatBytes(bytes) {
if (bytes === null || bytes === undefined || Number.isNaN(Number(bytes))) {
return 'Unknown';
}
const numeric = Number(bytes);
if (numeric === 0) {
return '0 B';
}
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let value = numeric;
let unitIndex = 0;
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024;
unitIndex += 1;
}
return `${value.toFixed(value >= 10 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`;
}
export async function fetchJson(url, options = {}) {
const response = await fetch(url, options);
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(`${payload.error?.code ?? 'REQUEST_FAILED'}: ${payload.error?.message ?? 'Request failed.'}`);
}
return payload;
}
export function requireQueryParam(name) {
const params = new URLSearchParams(window.location.search);
const value = params.get(name)?.trim();
if (!value) {
throw new Error(`MISSING_${name.toUpperCase()}: Query parameter "${name}" is required.`);
}
return value;
}
export function optionalQueryParam(name, fallback = '') {
const params = new URLSearchParams(window.location.search);
return params.get(name)?.trim() || fallback;
}
export function createFeedbackController(element) {
return {
clear() {
element.hidden = true;
element.className = 'feedback';
element.textContent = '';
},
set(kind, message) {
element.hidden = false;
element.className = `feedback feedback-${kind}`;
element.textContent = message;
}
};
}
export function linkToSource(sourceId) {
return `/source.html?sourceId=${encodeURIComponent(sourceId)}`;
}
export function linkToFile(sourceId, fileId) {
return `/file.html?sourceId=${encodeURIComponent(sourceId)}&id=${encodeURIComponent(fileId)}`;
}
export function inferEnhancementLabel(source) {
switch (source?.enhancement?.status) {
case 'ready':
return '已增强';
case 'running':
return '增强中';
case 'queued':
return '排队中';
case 'failed':
return '增强失败';
default:
return '未增强';
}
}
export function inferEnhancementClass(source) {
switch (source?.enhancement?.status) {
case 'ready':
return 'status-ready';
case 'running':
case 'queued':
return 'status-busy';
case 'failed':
return 'status-error';
default:
return 'status-idle';
}
}
export function renderSourceSummaryCard(source, extraActionsHtml = '') {
const title = escapeHtml(source.displayName || source.originalFilename);
const originalFilename = escapeHtml(source.originalFilename);
const matchedHint = source.matchedBackupName ? '任务名来自 Server DB' : '未匹配任务名';
const progress =
source.enhancement.totalVolumes > 0
? `${source.enhancement.processedVolumes}/${source.enhancement.totalVolumes}`
: '0/0';
return `
<article class="source-card">
<div class="source-main">
<div>
<p class="source-name">${title}</p>
<p class="source-meta">原始库名: ${originalFilename}</p>
<p class="source-meta">sourceId: ${escapeHtml(source.id)}</p>
</div>
<span class="status-pill ${inferEnhancementClass(source)}">${inferEnhancementLabel(source)}</span>
</div>
<div class="summary-chip">${escapeHtml(matchedHint)}</div>
<div class="card-grid">
<div class="stat">
<span>上传大小</span>
<strong>${formatBytes(source.fileSize)}</strong>
</div>
<div class="stat">
<span>最近快照</span>
<strong>${escapeHtml(source.latestSnapshot?.timestamp ?? '无快照')}</strong>
</div>
<div class="stat">
<span>目录浏览</span>
<strong>${source.canBrowse ? '可用' : '不可用'}</strong>
</div>
<div class="stat">
<span>增强进度</span>
<strong>${escapeHtml(progress)}</strong>
</div>
</div>
${extraActionsHtml}
</article>
`;
}
export function assertBrowserFeature(condition, message) {
if (!condition) {
throw new Error(message);
}
}

1218
public/modules/media-core.js Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,76 @@
export function buildThumbnailCandidateTimes(durationSeconds) {
const duration = Number(durationSeconds);
if (!Number.isFinite(duration) || duration <= 0) {
return [0.15];
}
const upperBound = Math.max(0.05, duration - 0.05);
const baseCandidates = [0.15, 0.5, 1, 2, 3.5, 5];
const candidates = [];
for (const candidate of baseCandidates) {
const clamped = Math.max(0, Math.min(candidate, upperBound));
if (candidates.length === 0 || Math.abs(candidates[candidates.length - 1] - clamped) > 0.08) {
candidates.push(Number(clamped.toFixed(3)));
}
}
if (upperBound > 0.25) {
const fallback = Number(upperBound.toFixed(3));
if (Math.abs(candidates[candidates.length - 1] - fallback) > 0.08) {
candidates.push(fallback);
}
}
return candidates;
}
export function analyzeFrameLuma(data, options = {}) {
const bytes = data instanceof Uint8ClampedArray ? data : new Uint8ClampedArray(data ?? []);
const pixelCount = Math.floor(bytes.length / 4);
if (pixelCount === 0) {
return {
sampleCount: 0,
averageLuma: 0,
darkRatio: 1,
isBlackFrame: true
};
}
const {
darknessThreshold = 18,
maxDarkRatio = 0.985,
averageThreshold = 22,
stride = 16
} = options;
let samples = 0;
let darkSamples = 0;
let lumaTotal = 0;
for (let index = 0; index < pixelCount; index += stride) {
const offset = index * 4;
const red = bytes[offset];
const green = bytes[offset + 1];
const blue = bytes[offset + 2];
const alpha = bytes[offset + 3];
const luma = alpha === 0 ? 0 : 0.2126 * red + 0.7152 * green + 0.0722 * blue;
lumaTotal += luma;
samples += 1;
if (luma <= darknessThreshold) {
darkSamples += 1;
}
}
const averageLuma = samples > 0 ? lumaTotal / samples : 0;
const darkRatio = samples > 0 ? darkSamples / samples : 1;
const isBlackFrame = averageLuma <= averageThreshold && darkRatio >= maxDarkRatio;
return {
sampleCount: samples,
averageLuma,
darkRatio,
isBlackFrame
};
}

140
public/source.html Normal file
View File

@ -0,0 +1,140 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>数据源工作台</title>
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<main class="shell">
<nav class="crumbs">
<a href="/">数据源控制台</a>
<span>/</span>
<span>数据源工作台</span>
</nav>
<section class="hero hero-compact">
<div>
<p class="eyebrow">Source Workbench</p>
<h1 id="pageTitle">数据源工作台</h1>
</div>
<p class="lead">
这里处理单个数据源的浏览、增强和预览图缓存。页面改成了左侧菜单式工作台,避免所有功能继续堆在一个长页面里。
</p>
</section>
<div id="feedback" class="feedback" hidden></div>
<section class="workspace-shell">
<aside class="workspace-nav" aria-label="数据源工作台菜单">
<a class="workspace-nav-link" href="#overview" data-view-link="overview">概况</a>
<a class="workspace-nav-link" href="#browse" data-view-link="browse">目录浏览</a>
<a class="workspace-nav-link" href="#enhance" data-view-link="enhance">增强配置</a>
<a class="workspace-nav-link" href="#thumbnails" data-view-link="thumbnails">预览图缓存</a>
</aside>
<div class="workspace-content">
<section class="workspace-panel" data-view-panel="overview">
<div class="panel">
<div class="panel-header">
<div>
<p class="section-label">Source</p>
<h2>源概况</h2>
</div>
</div>
<div id="sourceSummary" class="empty-state-card">正在读取数据源概况...</div>
</div>
</section>
<section class="workspace-panel" data-view-panel="browse" hidden>
<div class="panel">
<div class="panel-header">
<div>
<p class="section-label">Directory</p>
<h2>目录浏览</h2>
</div>
</div>
<div class="toolbar-cluster">
<div class="file-toolbar">
<button id="browseUpButton" class="ghost-button" type="button">上一级</button>
<button id="browseRootButton" class="ghost-button" type="button">根目录</button>
<button id="browseRefreshButton" class="ghost-button" type="button">刷新当前目录</button>
</div>
<div id="pathTrail" class="path-trail">/</div>
</div>
<div id="browseResult" class="browse-shell">正在读取目录...</div>
</div>
</section>
<section class="workspace-panel" data-view-panel="enhance" hidden>
<div class="panel">
<div class="panel-header">
<div>
<p class="section-label">Enhancement</p>
<h2>增强配置</h2>
</div>
</div>
<p class="panel-copy">
默认情况下,增强会优先使用全局默认值,并尽量从 <code>Duplicati-server.sqlite</code> 自动推导当前任务的目标 URL。
这里只需要填写与全局不同的部分;全部留空并保存,会清除当前 source 的覆盖设置。
</p>
<div id="webdavHint" class="summary-card">正在计算这个 source 的默认 WebDAV 目标...</div>
<form id="secretForm" class="secret-form">
<label>
<span>Source override: WebDAV Base URL</span>
<input id="webdavBaseUrl" name="webdavBaseUrl" type="url" placeholder="留空则自动使用全局默认或 Server DB 推导值">
</label>
<label>
<span>认证方式</span>
<select id="authMode" name="authMode">
<option value="">自动 / 继承</option>
<option value="basic">Basic</option>
<option value="anonymous">Anonymous</option>
</select>
</label>
<label>
<span>用户名</span>
<input id="username" name="username" type="text" placeholder="留空则继承全局默认">
</label>
<label>
<span>密码</span>
<input id="password" name="password" type="password" placeholder="留空则继承全局默认">
</label>
<label>
<span>备份口令</span>
<input id="passphrase" name="passphrase" type="password" placeholder="留空则继承全局默认">
</label>
<div class="form-actions">
<button id="saveSecretsButton" class="ghost-button" type="submit">保存覆盖</button>
<button id="runEnhancementButton" class="primary-button" type="button">按当前规则启动增强</button>
<button id="deleteSourceButton" class="ghost-button danger-button" type="button">删除这个 source</button>
</div>
</form>
</div>
</section>
<section class="workspace-panel" data-view-panel="thumbnails" hidden>
<div class="panel">
<div class="panel-header">
<div>
<p class="section-label">Preview Thumbnails</p>
<h2>预览图缓存</h2>
</div>
</div>
<p class="panel-copy">
这里展示当前 source 已缓存到后端的预览图数量。清空后,目录里的视频文件会退回占位图;后续再次成功预览时会自动重新生成。
</p>
<div id="sourceThumbnailSummary" class="summary-card">正在统计这个 source 的预览图缓存...</div>
<div class="form-actions">
<button id="clearSourceThumbnailsButton" class="ghost-button danger-button" type="button">清空当前 source 预览图</button>
</div>
</div>
</section>
</div>
</section>
</main>
<script type="module" src="/source.js"></script>
</body>
</html>

576
public/source.js Normal file
View File

@ -0,0 +1,576 @@
import {
createFeedbackController,
escapeHtml,
fetchJson,
formatBytes,
inferEnhancementClass,
inferEnhancementLabel,
linkToFile,
optionalQueryParam
} from './modules/common.js';
const SOURCE_VIEWS = new Set(['overview', 'browse', 'enhance', 'thumbnails']);
const state = {
sourceId: optionalQueryParam('sourceId', ''),
source: null,
currentPath: '/',
currentParent: null,
currentView: 'overview'
};
const feedback = createFeedbackController(document.querySelector('#feedback'));
const pageTitleElement = document.querySelector('#pageTitle');
const sourceSummaryElement = document.querySelector('#sourceSummary');
const browseResultElement = document.querySelector('#browseResult');
const pathTrailElement = document.querySelector('#pathTrail');
const browseUpButton = document.querySelector('#browseUpButton');
const browseRootButton = document.querySelector('#browseRootButton');
const browseRefreshButton = document.querySelector('#browseRefreshButton');
const secretForm = document.querySelector('#secretForm');
const runEnhancementButton = document.querySelector('#runEnhancementButton');
const webdavHintElement = document.querySelector('#webdavHint');
const deleteSourceButton = document.querySelector('#deleteSourceButton');
const sourceThumbnailSummaryElement = document.querySelector('#sourceThumbnailSummary');
const clearSourceThumbnailsButton = document.querySelector('#clearSourceThumbnailsButton');
const viewPanels = [...document.querySelectorAll('[data-view-panel]')];
const viewLinks = [...document.querySelectorAll('[data-view-link]')];
function normalizeView(value) {
return SOURCE_VIEWS.has(value) ? value : 'overview';
}
function setControlsDisabled(disabled) {
for (const element of [
browseUpButton,
browseRootButton,
browseRefreshButton,
runEnhancementButton,
deleteSourceButton,
clearSourceThumbnailsButton,
...secretForm.querySelectorAll('input, select, button')
]) {
if (element) {
element.disabled = disabled;
}
}
}
function setCurrentView(nextView, { syncHash = true, replace = false } = {}) {
state.currentView = normalizeView(nextView);
for (const panel of viewPanels) {
panel.hidden = panel.dataset.viewPanel !== state.currentView;
}
for (const link of viewLinks) {
const active = link.dataset.viewLink === state.currentView;
link.classList.toggle('is-active', active);
link.setAttribute('aria-current', active ? 'page' : 'false');
}
if (!syncHash) {
return;
}
const url = new URL(window.location.href);
url.hash = `#${state.currentView}`;
if (replace) {
window.history.replaceState(null, '', url);
} else {
window.history.pushState(null, '', url);
}
}
function renderMissingRouteContext() {
setControlsDisabled(true);
pageTitleElement.textContent = '数据源工作台未就绪';
sourceSummaryElement.innerHTML = `
<div class="empty-state-card">
<p>这个页面缺少必要的地址参数</p>
<p>请从首页的数据源列表进入或检查当前地址是否包含 <code>sourceId</code></p>
</div>
`;
sourceThumbnailSummaryElement.innerHTML = `
<div class="summary-stack">
<div class="summary-row">
<span>状态</span>
<strong>缺少 sourceId</strong>
</div>
</div>
`;
pathTrailElement.textContent = '/';
browseResultElement.innerHTML = `
<div class="empty-state-card">
<p>缺少 <code>sourceId</code></p>
</div>
`;
webdavHintElement.innerHTML = `
<div class="summary-stack">
<div class="summary-row">
<span>状态</span>
<strong>缺少 sourceId</strong>
</div>
</div>
`;
setCurrentView('overview', { replace: true });
}
function renderPathTrail(pathValue) {
const segments = pathValue === '/' ? [] : pathValue.split('/').filter(Boolean);
const crumbs = [
'<button class="path-chip" data-path="/" type="button">根</button>'
];
let currentPath = '';
for (const segment of segments) {
currentPath += `/${segment}`;
crumbs.push('<span class="path-separator">/</span>');
crumbs.push(
`<button class="path-chip" data-path="${escapeHtml(currentPath)}" type="button">${escapeHtml(segment)}</button>`
);
}
pathTrailElement.innerHTML = crumbs.join('');
}
function renderSourceSummary() {
const source = state.source;
if (!source) {
sourceSummaryElement.innerHTML = '<div class="empty-state-card">未找到对应数据源。</div>';
return;
}
pageTitleElement.textContent = source.displayName || source.originalFilename;
const enhancementError = source.enhancement.lastErrorMessage
? `<div class="feedback feedback-error">最近失败: ${escapeHtml(source.enhancement.lastErrorMessage)}</div>`
: '';
sourceSummaryElement.innerHTML = `
<article class="source-card">
<div class="source-main">
<div>
<p class="source-name">${escapeHtml(source.displayName || source.originalFilename)}</p>
<p class="source-meta">原始库名: ${escapeHtml(source.originalFilename)}</p>
<p class="source-meta">sourceId: ${escapeHtml(source.id)}</p>
</div>
<span class="status-pill ${inferEnhancementClass(source)}">${inferEnhancementLabel(source)}</span>
</div>
<div class="stats-grid">
<div class="stat">
<span>任务名来源</span>
<strong>${escapeHtml(source.displayNameSource)}</strong>
</div>
<div class="stat">
<span>匹配任务名</span>
<strong>${escapeHtml(source.matchedBackupName ?? '未匹配')}</strong>
</div>
<div class="stat">
<span>源布局</span>
<strong>${escapeHtml(source.sourceLayout)}</strong>
</div>
<div class="stat">
<span>上传大小</span>
<strong>${formatBytes(source.fileSize)}</strong>
</div>
<div class="stat">
<span>目录浏览</span>
<strong>${source.canBrowse ? '可用' : '不可用'}</strong>
</div>
<div class="stat">
<span>预览图缓存</span>
<strong>${escapeHtml(String(source.thumbnailCache?.count ?? 0))} </strong>
</div>
<div class="stat">
<span>archive_entry_index</span>
<strong>${source.capabilities.archiveEntryIndex ? 'Yes' : 'No'}</strong>
</div>
<div class="stat">
<span>volume_crypto_cache</span>
<strong>${source.capabilities.volumeCryptoCache ? 'Yes' : 'No'}</strong>
</div>
</div>
<div class="summary-chip">最新快照: ${escapeHtml(source.latestSnapshot?.timestamp ?? '暂无')}</div>
${enhancementError}
</article>
`;
}
function renderWebdavHint() {
const webdav = state.source?.webdav;
if (!webdav) {
webdavHintElement.innerHTML = `
<div class="summary-stack">
<div class="summary-row">
<span>默认目标 URL</span>
<strong>未知</strong>
</div>
</div>
`;
return;
}
webdavHintElement.innerHTML = `
<div class="summary-stack">
<div class="summary-row">
<span>任务默认 URL</span>
<strong>${escapeHtml(webdav.effectiveWebdavBaseUrl ?? webdav.derivedWebdavBaseUrl ?? '未推导到')}</strong>
</div>
<div class="summary-row">
<span>默认 URL 来源</span>
<strong>${escapeHtml(webdav.effectiveWebdavBaseUrlSource ?? 'none')}</strong>
</div>
<div class="summary-row">
<span>Server DB 推导</span>
<strong>${escapeHtml(webdav.derivedTargetUrl ?? '无')}</strong>
</div>
<div class="summary-row">
<span>全局默认值</span>
<strong>${webdav.globalDefaultsConfigured ? '已配置' : '未配置'}</strong>
</div>
<div class="summary-row">
<span>当前 source 覆盖</span>
<strong>${webdav.sourceOverrideSaved ? '已保存' : '未保存'}</strong>
</div>
</div>
`;
}
function renderSourceThumbnailSummary() {
const thumbnailCache = state.source?.thumbnailCache ?? {
count: 0,
totalBytes: 0
};
sourceThumbnailSummaryElement.innerHTML = `
<div class="summary-stack">
<div class="summary-row">
<span>当前 source 预览图数量</span>
<strong>${escapeHtml(String(thumbnailCache.count))}</strong>
</div>
<div class="summary-row">
<span>总占用空间</span>
<strong>${formatBytes(thumbnailCache.totalBytes)}</strong>
</div>
</div>
`;
}
function renderBrowse(payload) {
state.currentPath = payload.path;
state.currentParent = payload.parent;
renderPathTrail(payload.path);
if (payload.entries.length === 0) {
browseResultElement.innerHTML = `
<div class="empty-state-card">
<p>这个目录目前没有内容</p>
</div>
`;
return;
}
browseResultElement.innerHTML = `
<div class="browse-list">
${payload.entries
.map((entry) => {
if (entry.type === 'dir') {
return `
<article class="browse-row browse-row-clickable" data-path="${escapeHtml(entry.path)}" data-entry-type="dir">
<div class="browse-row-main">
<div class="browse-thumbnail browse-thumbnail-placeholder browse-thumbnail-folder">DIR</div>
<div>
<strong>${escapeHtml(entry.name)}</strong>
<span class="browse-type">dir</span>
</div>
</div>
<div class="browse-meta">
<span>${escapeHtml(entry.path)}</span>
<span>${entry.mtime ? escapeHtml(entry.mtime) : '目录'}</span>
<span>点击进入目录</span>
</div>
</article>
`;
}
const thumbnail = entry.thumbnail?.available
? `
<img
class="browse-thumbnail-image"
src="${escapeHtml(entry.thumbnail.thumbnailUrl)}"
alt="${escapeHtml(entry.name)} 的预览图"
loading="lazy"
>
`
: '<div class="browse-thumbnail browse-thumbnail-placeholder">FILE</div>';
return `
<article class="browse-row browse-row-clickable" data-file-id="${escapeHtml(entry.id)}" data-entry-type="file">
<div class="browse-row-main">
<div class="browse-thumbnail">${thumbnail}</div>
<div>
<strong>${escapeHtml(entry.name)}</strong>
<span class="browse-type">${escapeHtml(entry.mime ?? 'file')}</span>
</div>
</div>
<div class="browse-meta">
<span>${escapeHtml(entry.path)}</span>
<span>${formatBytes(entry.size)}</span>
<span>${entry.thumbnail?.available ? '已缓存预览图' : '点击打开文件页'}</span>
</div>
</article>
`;
})
.join('')}
</div>
`;
}
function renderBrowseError(pathValue, error) {
renderPathTrail(pathValue);
browseResultElement.innerHTML = `
<div class="empty-state-card">
<p>无法列出目录内容</p>
<p>${escapeHtml(error.message)}</p>
</div>
`;
}
function readEnhancementForm() {
return {
webdavBaseUrl: document.querySelector('#webdavBaseUrl')?.value?.trim() ?? '',
authMode: document.querySelector('#authMode')?.value ?? '',
username: document.querySelector('#username')?.value?.trim() ?? '',
password: document.querySelector('#password')?.value ?? '',
passphrase: document.querySelector('#passphrase')?.value ?? ''
};
}
async function loadSource() {
const payload = await fetchJson(`/api/sources/${encodeURIComponent(state.sourceId)}`);
state.source = payload.source;
renderSourceSummary();
renderWebdavHint();
renderSourceThumbnailSummary();
}
async function loadDirectory(pathValue) {
state.currentPath = pathValue;
browseResultElement.innerHTML = `
<div class="empty-state-card">
<p>正在读取目录...</p>
</div>
`;
try {
const payload = await fetchJson(
`/api/ls?sourceId=${encodeURIComponent(state.sourceId)}&path=${encodeURIComponent(pathValue)}`
);
renderBrowse(payload);
} catch (error) {
renderBrowseError(pathValue, error);
throw error;
}
}
async function saveEnhancementSecrets(event) {
event.preventDefault();
feedback.set('info', '正在保存当前 source 的覆盖规则...');
try {
await fetchJson(`/api/sources/${encodeURIComponent(state.sourceId)}/secrets`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(readEnhancementForm())
});
await loadSource();
secretForm.reset();
feedback.set('success', '覆盖规则已保存。留空项会继续继承全局默认值或 Server DB 推导结果。');
} catch (error) {
feedback.set('error', error.message);
}
}
async function runEnhancement() {
feedback.set('info', '正在提交增强任务...');
try {
await fetchJson(`/api/sources/${encodeURIComponent(state.sourceId)}/enhance`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(readEnhancementForm())
});
await loadSource();
secretForm.reset();
feedback.set('success', '增强任务已开始。当前规则会自动合并全局默认值、Server DB 推导 URL 和单源覆盖。');
} catch (error) {
feedback.set('error', error.message);
}
}
async function clearSourceThumbnails() {
const confirmed = window.confirm(
'确定要清空这个 source 的全部预览图吗?\n\n这不会删除任务库、增强索引或 Duplicati-server.sqlite只会删除后端缩略图缓存。'
);
if (!confirmed) {
return;
}
feedback.set('info', '正在清空当前 source 的预览图缓存...');
try {
const payload = await fetchJson(`/api/sources/${encodeURIComponent(state.sourceId)}/thumbnails`, {
method: 'DELETE'
});
await loadSource();
await loadDirectory(state.currentPath);
feedback.set(
'success',
`当前 source 的预览图已清空,删除了 ${payload.cleared.removedCount} 张,共 ${formatBytes(payload.cleared.removedBytes)}`
);
} catch (error) {
feedback.set('error', error.message);
}
}
async function deleteCurrentSource() {
if (!state.source) {
return;
}
const label = state.source.displayName || state.source.originalFilename || state.source.id;
const confirmed = window.confirm(
`确定要删除这个 source 吗?\n\n${label}\n\n这会删除当前任务库、本地增强副本和该 source 的预览图缓存,但不会删除 Duplicati-server.sqlite。`
);
if (!confirmed) {
return;
}
feedback.set('info', `正在删除 ${label}...`);
try {
await fetchJson(`/api/sources/${encodeURIComponent(state.sourceId)}`, {
method: 'DELETE'
});
window.location.href = '/';
} catch (error) {
feedback.set('error', error.message);
}
}
browseUpButton.addEventListener('click', () => {
feedback.clear();
void loadDirectory(state.currentParent ?? '/').catch((error) => {
feedback.set('error', error.message);
});
});
browseRootButton.addEventListener('click', () => {
feedback.clear();
void loadDirectory('/').catch((error) => {
feedback.set('error', error.message);
});
});
browseRefreshButton.addEventListener('click', () => {
feedback.clear();
void loadDirectory(state.currentPath).catch((error) => {
feedback.set('error', error.message);
});
});
browseResultElement.addEventListener('click', (event) => {
const target = event.target;
if (!(target instanceof HTMLElement)) {
return;
}
const rowElement = target.closest('.browse-row-clickable');
if (rowElement instanceof HTMLElement) {
const entryType = rowElement.dataset.entryType;
if (entryType === 'dir' && rowElement.dataset.path) {
feedback.clear();
void loadDirectory(rowElement.dataset.path).catch((error) => {
feedback.set('error', error.message);
});
return;
}
if (entryType === 'file' && rowElement.dataset.fileId) {
window.location.href = linkToFile(state.sourceId, rowElement.dataset.fileId);
}
}
});
pathTrailElement.addEventListener('click', (event) => {
const target = event.target;
if (!(target instanceof HTMLElement)) {
return;
}
const actionElement = target.closest('[data-path]');
if (!(actionElement instanceof HTMLElement) || !actionElement.dataset.path) {
return;
}
feedback.clear();
void loadDirectory(actionElement.dataset.path).catch((error) => {
feedback.set('error', error.message);
});
});
secretForm.addEventListener('submit', (event) => {
void saveEnhancementSecrets(event);
});
runEnhancementButton.addEventListener('click', () => {
void runEnhancement();
});
clearSourceThumbnailsButton.addEventListener('click', () => {
void clearSourceThumbnails();
});
deleteSourceButton.addEventListener('click', () => {
void deleteCurrentSource();
});
for (const link of viewLinks) {
link.addEventListener('click', (event) => {
event.preventDefault();
const nextView = normalizeView(link.dataset.viewLink ?? 'browse');
setCurrentView(nextView);
});
}
window.addEventListener('hashchange', () => {
setCurrentView(normalizeView(window.location.hash.slice(1)), { syncHash: false });
});
async function bootstrap() {
if (!state.sourceId) {
renderMissingRouteContext();
return;
}
const initialView = normalizeView(window.location.hash.slice(1) || 'overview');
setCurrentView(initialView, { replace: true });
await loadSource();
await loadDirectory('/');
}
void bootstrap().catch((error) => {
feedback.set('error', error.message);
browseResultElement.innerHTML = `
<div class="empty-state-card">
<p>无法进入数据源工作台</p>
</div>
`;
});

709
public/styles.css Normal file
View File

@ -0,0 +1,709 @@
:root {
color-scheme: light;
--bg: #efe7dc;
--bg-strong: #f8f4ec;
--panel: rgba(255, 251, 246, 0.94);
--panel-strong: rgba(255, 255, 255, 0.85);
--line: rgba(30, 35, 31, 0.1);
--line-strong: rgba(30, 35, 31, 0.18);
--ink: #1a241f;
--muted: #5f6a63;
--accent: #0f766e;
--accent-strong: #0c5f59;
--gold: #9b6b18;
--danger: #b24637;
--success: #1f7a45;
--shadow: 0 28px 60px rgba(31, 35, 32, 0.14);
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
min-height: 100%;
}
body {
font-family: "Segoe UI Variable Text", "Segoe UI", "Noto Sans SC", sans-serif;
color: var(--ink);
background:
radial-gradient(circle at top left, rgba(15, 118, 110, 0.18), transparent 24%),
radial-gradient(circle at right top, rgba(155, 107, 24, 0.16), transparent 22%),
linear-gradient(180deg, #faf6f0 0%, var(--bg) 100%);
}
a {
color: inherit;
}
code {
padding: 0.15em 0.35em;
border-radius: 0.45em;
background: rgba(15, 118, 110, 0.08);
}
.shell {
width: min(1260px, calc(100vw - 30px));
margin: 0 auto;
padding: 28px 0 72px;
}
.crumbs {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 20px;
color: var(--muted);
font-size: 14px;
}
.crumbs a {
text-decoration: none;
color: var(--accent-strong);
}
.hero {
display: grid;
gap: 16px;
margin-bottom: 26px;
}
.hero-home {
grid-template-columns: minmax(0, 1.2fr) minmax(320px, 0.8fr);
align-items: end;
}
.hero-compact {
grid-template-columns: minmax(0, 1fr);
}
.eyebrow,
.section-label {
margin: 0 0 12px;
text-transform: uppercase;
letter-spacing: 0.16em;
font-size: 12px;
color: var(--accent-strong);
}
h1,
h2,
h3,
.source-name {
margin: 0;
font-family: Georgia, "Times New Roman", serif;
}
h1 {
font-size: clamp(34px, 5vw, 56px);
line-height: 0.98;
}
h2 {
font-size: 30px;
line-height: 1.05;
}
h3 {
font-size: 24px;
}
.lead,
.panel-copy,
.source-meta,
.browse-summary,
.browse-meta,
.summary-row span,
.summary-row strong,
.empty-state,
.empty-state-card,
.video-frame::before {
color: var(--muted);
}
.lead {
width: min(780px, 100%);
margin: 0;
font-size: 17px;
line-height: 1.7;
}
.panel-copy {
margin: 0 0 18px;
line-height: 1.65;
}
.page-grid,
.stack-column,
.stack-gap,
.source-list,
.summary-stack,
.stats-grid,
.card-grid,
.detail-stack,
.browse-list,
.upload-form,
.secret-form,
.browse-form {
display: grid;
gap: 18px;
}
.page-grid-home {
grid-template-columns: repeat(2, minmax(0, 1fr));
margin-bottom: 24px;
}
.workspace-shell {
display: grid;
grid-template-columns: 250px minmax(0, 1fr);
gap: 22px;
align-items: start;
}
.workspace-content,
.workspace-panel {
display: grid;
gap: 18px;
}
.workspace-panel[hidden] {
display: none !important;
}
.workspace-nav {
position: sticky;
top: 18px;
display: grid;
gap: 10px;
padding: 18px;
border-radius: 26px;
background: var(--panel);
border: 1px solid var(--line);
box-shadow: var(--shadow);
}
.workspace-nav-link {
display: block;
padding: 12px 14px;
border-radius: 16px;
text-decoration: none;
color: var(--muted);
background: rgba(255, 255, 255, 0.58);
border: 1px solid transparent;
transition: transform 140ms ease, background 140ms ease, border-color 140ms ease, color 140ms ease;
}
.workspace-nav-link:hover {
transform: translateY(-1px);
color: var(--ink);
}
.workspace-nav-link.is-active {
color: var(--accent-strong);
background: rgba(15, 118, 110, 0.12);
border-color: rgba(15, 118, 110, 0.22);
font-weight: 700;
}
.page-grid-source {
grid-template-columns: minmax(320px, 420px) minmax(0, 1fr);
}
.page-grid-file {
grid-template-columns: minmax(320px, 420px) minmax(0, 1fr);
}
.stack-column {
align-content: start;
}
.toolbar-cluster {
display: grid;
gap: 14px;
margin-bottom: 18px;
}
.file-toolbar {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.path-trail {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
padding: 12px 14px;
border-radius: 18px;
background: rgba(255, 255, 255, 0.72);
border: 1px solid rgba(26, 36, 31, 0.08);
}
.path-chip {
appearance: none;
border: none;
background: rgba(15, 118, 110, 0.1);
color: var(--accent-strong);
border-radius: 999px;
padding: 8px 12px;
font: inherit;
cursor: pointer;
}
.path-separator {
color: var(--muted);
}
.panel,
.source-card,
.summary-card,
.empty-state-card,
.browse-row,
.video-frame {
background: var(--panel);
border: 1px solid var(--line);
box-shadow: var(--shadow);
}
.panel,
.source-card,
.summary-card,
.empty-state-card,
.video-frame {
border-radius: 26px;
}
.panel,
.source-card {
padding: 24px;
}
.panel-spacious {
min-height: 320px;
}
.summary-card,
.empty-state-card {
padding: 18px;
}
.panel-header,
.source-main,
.form-actions,
.card-actions,
.browse-toolbar {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: flex-start;
}
.panel-header {
margin-bottom: 18px;
}
.file-picker,
.secret-form label,
.browse-field {
display: grid;
gap: 10px;
}
.file-picker {
padding: 18px;
border-radius: 18px;
border: 1px dashed rgba(15, 118, 110, 0.34);
background: rgba(255, 255, 255, 0.7);
}
.file-picker span,
.secret-form span,
.browse-field span,
.checkbox-row span {
font-weight: 600;
}
input,
select {
width: 100%;
border-radius: 14px;
border: 1px solid rgba(26, 36, 31, 0.14);
background: rgba(255, 255, 255, 0.92);
padding: 12px 14px;
font: inherit;
color: var(--ink);
}
input:focus,
select:focus {
outline: 2px solid rgba(15, 118, 110, 0.18);
border-color: rgba(15, 118, 110, 0.4);
}
.checkbox-row {
display: flex;
gap: 10px;
align-items: center;
}
.checkbox-row input {
width: auto;
}
.ghost-button,
.primary-button,
.button-link {
appearance: none;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
padding: 12px 18px;
font: inherit;
text-decoration: none;
cursor: pointer;
transition: transform 140ms ease, opacity 140ms ease, background 140ms ease;
}
.ghost-button,
.button-link {
border: 1px solid var(--line-strong);
background: transparent;
color: var(--ink);
}
.primary-button {
border: none;
background: linear-gradient(135deg, var(--accent) 0%, #138579 100%);
color: white;
font-weight: 700;
}
.danger-button {
border-color: rgba(178, 70, 55, 0.3);
color: var(--danger);
}
.ghost-button:hover,
.primary-button:hover,
.button-link:hover {
transform: translateY(-1px);
}
.danger-button:hover {
background: rgba(178, 70, 55, 0.08);
}
.ghost-button:disabled,
.primary-button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.feedback {
margin-bottom: 22px;
padding: 14px 16px;
border-radius: 16px;
font-size: 14px;
}
.feedback-info {
background: rgba(15, 118, 110, 0.1);
color: var(--accent-strong);
}
.feedback-success {
background: rgba(31, 122, 69, 0.12);
color: var(--success);
}
.feedback-error {
background: rgba(178, 70, 55, 0.12);
color: var(--danger);
}
.source-name {
font-size: 28px;
}
.source-meta {
margin: 8px 0 0;
word-break: break-word;
}
.summary-chip {
display: inline-flex;
width: fit-content;
padding: 8px 12px;
border-radius: 999px;
background: rgba(15, 118, 110, 0.1);
color: var(--accent-strong);
font-size: 13px;
font-weight: 700;
}
.status-pill {
display: inline-flex;
align-items: center;
padding: 8px 14px;
border-radius: 999px;
font-size: 13px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.status-ready {
background: rgba(31, 122, 69, 0.12);
color: var(--success);
}
.status-idle {
background: rgba(15, 118, 110, 0.1);
color: var(--accent-strong);
}
.status-busy {
background: rgba(155, 107, 24, 0.14);
color: var(--gold);
}
.status-error {
background: rgba(178, 70, 55, 0.12);
color: var(--danger);
}
.stats-grid,
.card-grid {
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
}
.stat,
.summary-row,
.browse-row {
border-radius: 18px;
background: var(--panel-strong);
border: 1px solid rgba(26, 36, 31, 0.06);
}
.stat,
.summary-row {
padding: 14px 16px;
}
.stat span,
.summary-row span {
display: block;
margin-bottom: 8px;
font-size: 12px;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.stat strong,
.summary-row strong {
display: block;
font-size: 15px;
line-height: 1.45;
word-break: break-word;
}
.browse-shell {
min-height: 220px;
}
.browse-list {
margin-top: 14px;
}
.browse-toolbar {
flex-wrap: wrap;
}
.browse-row {
display: flex;
justify-content: space-between;
gap: 16px;
padding: 16px 18px;
}
.browse-row-main {
display: flex;
align-items: center;
gap: 14px;
min-width: 0;
}
.browse-row-main strong {
display: inline-block;
word-break: break-word;
}
.browse-row-clickable {
cursor: pointer;
transition: transform 140ms ease, border-color 140ms ease, background 140ms ease;
}
.browse-row-clickable:hover {
transform: translateY(-1px);
border-color: rgba(15, 118, 110, 0.26);
background: rgba(255, 255, 255, 0.92);
}
.browse-type {
display: inline-block;
margin-left: 10px;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--accent-strong);
}
.browse-thumbnail {
width: 84px;
height: 56px;
flex-shrink: 0;
border-radius: 14px;
overflow: hidden;
border: 1px solid rgba(26, 36, 31, 0.08);
background: rgba(255, 255, 255, 0.82);
display: grid;
place-items: center;
}
.browse-thumbnail-placeholder {
color: var(--muted);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.browse-thumbnail-folder {
background:
linear-gradient(135deg, rgba(15, 118, 110, 0.14), rgba(15, 118, 110, 0.04)),
rgba(255, 255, 255, 0.85);
}
.browse-thumbnail-image,
.thumbnail-preview-image {
width: 100%;
height: 100%;
display: block;
object-fit: cover;
}
.browse-meta {
display: grid;
gap: 6px;
align-items: end;
text-align: right;
font-size: 13px;
}
.browse-nav-button {
justify-self: end;
}
.video-frame {
position: relative;
min-height: 320px;
overflow: hidden;
background:
radial-gradient(circle at top left, rgba(15, 118, 110, 0.18), transparent 24%),
linear-gradient(160deg, rgba(17, 24, 39, 0.92), rgba(38, 48, 62, 0.95));
}
video {
width: 100%;
min-height: 320px;
background: transparent;
}
.thumbnail-card {
min-height: 260px;
padding: 18px;
border-radius: 26px;
background: var(--panel);
border: 1px solid var(--line);
box-shadow: var(--shadow);
display: grid;
place-items: center;
}
.thumbnail-placeholder {
width: 100%;
min-height: 220px;
display: grid;
place-items: center;
text-align: center;
padding: 20px;
border-radius: 20px;
border: 1px dashed rgba(15, 118, 110, 0.28);
background: rgba(255, 255, 255, 0.7);
color: var(--muted);
}
@media (max-width: 1080px) {
.hero-home,
.page-grid-home,
.page-grid-source,
.page-grid-file,
.workspace-shell {
grid-template-columns: 1fr;
}
.workspace-nav {
position: static;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
}
}
@media (max-width: 760px) {
.shell {
width: min(100vw - 18px, 1260px);
padding-top: 20px;
}
.panel,
.source-card {
padding: 18px;
border-radius: 20px;
}
.panel-header,
.source-main,
.form-actions,
.card-actions,
.browse-toolbar,
.browse-row,
.browse-row-main {
flex-direction: column;
}
.browse-meta {
align-items: start;
text-align: left;
}
.ghost-button,
.primary-button,
.button-link,
.workspace-nav-link {
width: 100%;
}
.workspace-nav {
grid-template-columns: 1fr;
}
video,
.video-frame {
min-height: 220px;
}
}

808
public/vendor/aes-js-esm.js vendored Normal file
View File

@ -0,0 +1,808 @@
/*! MIT License. Copyright 2015-2018 Richard Moore <me@ricmoo.com>. See LICENSE.txt. */
(function(root) {
"use strict";
function checkInt(value) {
return (parseInt(value) === value);
}
function checkInts(arrayish) {
if (!checkInt(arrayish.length)) { return false; }
for (var i = 0; i < arrayish.length; i++) {
if (!checkInt(arrayish[i]) || arrayish[i] < 0 || arrayish[i] > 255) {
return false;
}
}
return true;
}
function coerceArray(arg, copy) {
// ArrayBuffer view
if (arg.buffer && arg.name === 'Uint8Array') {
if (copy) {
if (arg.slice) {
arg = arg.slice();
} else {
arg = Array.prototype.slice.call(arg);
}
}
return arg;
}
// It's an array; check it is a valid representation of a byte
if (Array.isArray(arg)) {
if (!checkInts(arg)) {
throw new Error('Array contains invalid value: ' + arg);
}
return new Uint8Array(arg);
}
// Something else, but behaves like an array (maybe a Buffer? Arguments?)
if (checkInt(arg.length) && checkInts(arg)) {
return new Uint8Array(arg);
}
throw new Error('unsupported array-like object');
}
function createArray(length) {
return new Uint8Array(length);
}
function copyArray(sourceArray, targetArray, targetStart, sourceStart, sourceEnd) {
if (sourceStart != null || sourceEnd != null) {
if (sourceArray.slice) {
sourceArray = sourceArray.slice(sourceStart, sourceEnd);
} else {
sourceArray = Array.prototype.slice.call(sourceArray, sourceStart, sourceEnd);
}
}
targetArray.set(sourceArray, targetStart);
}
var convertUtf8 = (function() {
function toBytes(text) {
var result = [], i = 0;
text = encodeURI(text);
while (i < text.length) {
var c = text.charCodeAt(i++);
// if it is a % sign, encode the following 2 bytes as a hex value
if (c === 37) {
result.push(parseInt(text.substr(i, 2), 16))
i += 2;
// otherwise, just the actual byte
} else {
result.push(c)
}
}
return coerceArray(result);
}
function fromBytes(bytes) {
var result = [], i = 0;
while (i < bytes.length) {
var c = bytes[i];
if (c < 128) {
result.push(String.fromCharCode(c));
i++;
} else if (c > 191 && c < 224) {
result.push(String.fromCharCode(((c & 0x1f) << 6) | (bytes[i + 1] & 0x3f)));
i += 2;
} else {
result.push(String.fromCharCode(((c & 0x0f) << 12) | ((bytes[i + 1] & 0x3f) << 6) | (bytes[i + 2] & 0x3f)));
i += 3;
}
}
return result.join('');
}
return {
toBytes: toBytes,
fromBytes: fromBytes,
}
})();
var convertHex = (function() {
function toBytes(text) {
var result = [];
for (var i = 0; i < text.length; i += 2) {
result.push(parseInt(text.substr(i, 2), 16));
}
return result;
}
// http://ixti.net/development/javascript/2011/11/11/base64-encodedecode-of-utf8-in-browser-with-js.html
var Hex = '0123456789abcdef';
function fromBytes(bytes) {
var result = [];
for (var i = 0; i < bytes.length; i++) {
var v = bytes[i];
result.push(Hex[(v & 0xf0) >> 4] + Hex[v & 0x0f]);
}
return result.join('');
}
return {
toBytes: toBytes,
fromBytes: fromBytes,
}
})();
// Number of rounds by keysize
var numberOfRounds = {16: 10, 24: 12, 32: 14}
// Round constant words
var rcon = [0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x1b, 0x36, 0x6c, 0xd8, 0xab, 0x4d, 0x9a, 0x2f, 0x5e, 0xbc, 0x63, 0xc6, 0x97, 0x35, 0x6a, 0xd4, 0xb3, 0x7d, 0xfa, 0xef, 0xc5, 0x91];
// S-box and Inverse S-box (S is for Substitution)
var S = [0x63, 0x7c, 0x77, 0x7b, 0xf2, 0x6b, 0x6f, 0xc5, 0x30, 0x01, 0x67, 0x2b, 0xfe, 0xd7, 0xab, 0x76, 0xca, 0x82, 0xc9, 0x7d, 0xfa, 0x59, 0x47, 0xf0, 0xad, 0xd4, 0xa2, 0xaf, 0x9c, 0xa4, 0x72, 0xc0, 0xb7, 0xfd, 0x93, 0x26, 0x36, 0x3f, 0xf7, 0xcc, 0x34, 0xa5, 0xe5, 0xf1, 0x71, 0xd8, 0x31, 0x15, 0x04, 0xc7, 0x23, 0xc3, 0x18, 0x96, 0x05, 0x9a, 0x07, 0x12, 0x80, 0xe2, 0xeb, 0x27, 0xb2, 0x75, 0x09, 0x83, 0x2c, 0x1a, 0x1b, 0x6e, 0x5a, 0xa0, 0x52, 0x3b, 0xd6, 0xb3, 0x29, 0xe3, 0x2f, 0x84, 0x53, 0xd1, 0x00, 0xed, 0x20, 0xfc, 0xb1, 0x5b, 0x6a, 0xcb, 0xbe, 0x39, 0x4a, 0x4c, 0x58, 0xcf, 0xd0, 0xef, 0xaa, 0xfb, 0x43, 0x4d, 0x33, 0x85, 0x45, 0xf9, 0x02, 0x7f, 0x50, 0x3c, 0x9f, 0xa8, 0x51, 0xa3, 0x40, 0x8f, 0x92, 0x9d, 0x38, 0xf5, 0xbc, 0xb6, 0xda, 0x21, 0x10, 0xff, 0xf3, 0xd2, 0xcd, 0x0c, 0x13, 0xec, 0x5f, 0x97, 0x44, 0x17, 0xc4, 0xa7, 0x7e, 0x3d, 0x64, 0x5d, 0x19, 0x73, 0x60, 0x81, 0x4f, 0xdc, 0x22, 0x2a, 0x90, 0x88, 0x46, 0xee, 0xb8, 0x14, 0xde, 0x5e, 0x0b, 0xdb, 0xe0, 0x32, 0x3a, 0x0a, 0x49, 0x06, 0x24, 0x5c, 0xc2, 0xd3, 0xac, 0x62, 0x91, 0x95, 0xe4, 0x79, 0xe7, 0xc8, 0x37, 0x6d, 0x8d, 0xd5, 0x4e, 0xa9, 0x6c, 0x56, 0xf4, 0xea, 0x65, 0x7a, 0xae, 0x08, 0xba, 0x78, 0x25, 0x2e, 0x1c, 0xa6, 0xb4, 0xc6, 0xe8, 0xdd, 0x74, 0x1f, 0x4b, 0xbd, 0x8b, 0x8a, 0x70, 0x3e, 0xb5, 0x66, 0x48, 0x03, 0xf6, 0x0e, 0x61, 0x35, 0x57, 0xb9, 0x86, 0xc1, 0x1d, 0x9e, 0xe1, 0xf8, 0x98, 0x11, 0x69, 0xd9, 0x8e, 0x94, 0x9b, 0x1e, 0x87, 0xe9, 0xce, 0x55, 0x28, 0xdf, 0x8c, 0xa1, 0x89, 0x0d, 0xbf, 0xe6, 0x42, 0x68, 0x41, 0x99, 0x2d, 0x0f, 0xb0, 0x54, 0xbb, 0x16];
var Si =[0x52, 0x09, 0x6a, 0xd5, 0x30, 0x36, 0xa5, 0x38, 0xbf, 0x40, 0xa3, 0x9e, 0x81, 0xf3, 0xd7, 0xfb, 0x7c, 0xe3, 0x39, 0x82, 0x9b, 0x2f, 0xff, 0x87, 0x34, 0x8e, 0x43, 0x44, 0xc4, 0xde, 0xe9, 0xcb, 0x54, 0x7b, 0x94, 0x32, 0xa6, 0xc2, 0x23, 0x3d, 0xee, 0x4c, 0x95, 0x0b, 0x42, 0xfa, 0xc3, 0x4e, 0x08, 0x2e, 0xa1, 0x66, 0x28, 0xd9, 0x24, 0xb2, 0x76, 0x5b, 0xa2, 0x49, 0x6d, 0x8b, 0xd1, 0x25, 0x72, 0xf8, 0xf6, 0x64, 0x86, 0x68, 0x98, 0x16, 0xd4, 0xa4, 0x5c, 0xcc, 0x5d, 0x65, 0xb6, 0x92, 0x6c, 0x70, 0x48, 0x50, 0xfd, 0xed, 0xb9, 0xda, 0x5e, 0x15, 0x46, 0x57, 0xa7, 0x8d, 0x9d, 0x84, 0x90, 0xd8, 0xab, 0x00, 0x8c, 0xbc, 0xd3, 0x0a, 0xf7, 0xe4, 0x58, 0x05, 0xb8, 0xb3, 0x45, 0x06, 0xd0, 0x2c, 0x1e, 0x8f, 0xca, 0x3f, 0x0f, 0x02, 0xc1, 0xaf, 0xbd, 0x03, 0x01, 0x13, 0x8a, 0x6b, 0x3a, 0x91, 0x11, 0x41, 0x4f, 0x67, 0xdc, 0xea, 0x97, 0xf2, 0xcf, 0xce, 0xf0, 0xb4, 0xe6, 0x73, 0x96, 0xac, 0x74, 0x22, 0xe7, 0xad, 0x35, 0x85, 0xe2, 0xf9, 0x37, 0xe8, 0x1c, 0x75, 0xdf, 0x6e, 0x47, 0xf1, 0x1a, 0x71, 0x1d, 0x29, 0xc5, 0x89, 0x6f, 0xb7, 0x62, 0x0e, 0xaa, 0x18, 0xbe, 0x1b, 0xfc, 0x56, 0x3e, 0x4b, 0xc6, 0xd2, 0x79, 0x20, 0x9a, 0xdb, 0xc0, 0xfe, 0x78, 0xcd, 0x5a, 0xf4, 0x1f, 0xdd, 0xa8, 0x33, 0x88, 0x07, 0xc7, 0x31, 0xb1, 0x12, 0x10, 0x59, 0x27, 0x80, 0xec, 0x5f, 0x60, 0x51, 0x7f, 0xa9, 0x19, 0xb5, 0x4a, 0x0d, 0x2d, 0xe5, 0x7a, 0x9f, 0x93, 0xc9, 0x9c, 0xef, 0xa0, 0xe0, 0x3b, 0x4d, 0xae, 0x2a, 0xf5, 0xb0, 0xc8, 0xeb, 0xbb, 0x3c, 0x83, 0x53, 0x99, 0x61, 0x17, 0x2b, 0x04, 0x7e, 0xba, 0x77, 0xd6, 0x26, 0xe1, 0x69, 0x14, 0x63, 0x55, 0x21, 0x0c, 0x7d];
// Transformations for encryption
var T1 = [0xc66363a5, 0xf87c7c84, 0xee777799, 0xf67b7b8d, 0xfff2f20d, 0xd66b6bbd, 0xde6f6fb1, 0x91c5c554, 0x60303050, 0x02010103, 0xce6767a9, 0x562b2b7d, 0xe7fefe19, 0xb5d7d762, 0x4dababe6, 0xec76769a, 0x8fcaca45, 0x1f82829d, 0x89c9c940, 0xfa7d7d87, 0xeffafa15, 0xb25959eb, 0x8e4747c9, 0xfbf0f00b, 0x41adadec, 0xb3d4d467, 0x5fa2a2fd, 0x45afafea, 0x239c9cbf, 0x53a4a4f7, 0xe4727296, 0x9bc0c05b, 0x75b7b7c2, 0xe1fdfd1c, 0x3d9393ae, 0x4c26266a, 0x6c36365a, 0x7e3f3f41, 0xf5f7f702, 0x83cccc4f, 0x6834345c, 0x51a5a5f4, 0xd1e5e534, 0xf9f1f108, 0xe2717193, 0xabd8d873, 0x62313153, 0x2a15153f, 0x0804040c, 0x95c7c752, 0x46232365, 0x9dc3c35e, 0x30181828, 0x379696a1, 0x0a05050f, 0x2f9a9ab5, 0x0e070709, 0x24121236, 0x1b80809b, 0xdfe2e23d, 0xcdebeb26, 0x4e272769, 0x7fb2b2cd, 0xea75759f, 0x1209091b, 0x1d83839e, 0x582c2c74, 0x341a1a2e, 0x361b1b2d, 0xdc6e6eb2, 0xb45a5aee, 0x5ba0a0fb, 0xa45252f6, 0x763b3b4d, 0xb7d6d661, 0x7db3b3ce, 0x5229297b, 0xdde3e33e, 0x5e2f2f71, 0x13848497, 0xa65353f5, 0xb9d1d168, 0x00000000, 0xc1eded2c, 0x40202060, 0xe3fcfc1f, 0x79b1b1c8, 0xb65b5bed, 0xd46a6abe, 0x8dcbcb46, 0x67bebed9, 0x7239394b, 0x944a4ade, 0x984c4cd4, 0xb05858e8, 0x85cfcf4a, 0xbbd0d06b, 0xc5efef2a, 0x4faaaae5, 0xedfbfb16, 0x864343c5, 0x9a4d4dd7, 0x66333355, 0x11858594, 0x8a4545cf, 0xe9f9f910, 0x04020206, 0xfe7f7f81, 0xa05050f0, 0x783c3c44, 0x259f9fba, 0x4ba8a8e3, 0xa25151f3, 0x5da3a3fe, 0x804040c0, 0x058f8f8a, 0x3f9292ad, 0x219d9dbc, 0x70383848, 0xf1f5f504, 0x63bcbcdf, 0x77b6b6c1, 0xafdada75, 0x42212163, 0x20101030, 0xe5ffff1a, 0xfdf3f30e, 0xbfd2d26d, 0x81cdcd4c, 0x180c0c14, 0x26131335, 0xc3ecec2f, 0xbe5f5fe1, 0x359797a2, 0x884444cc, 0x2e171739, 0x93c4c457, 0x55a7a7f2, 0xfc7e7e82, 0x7a3d3d47, 0xc86464ac, 0xba5d5de7, 0x3219192b, 0xe6737395, 0xc06060a0, 0x19818198, 0x9e4f4fd1, 0xa3dcdc7f, 0x44222266, 0x542a2a7e, 0x3b9090ab, 0x0b888883, 0x8c4646ca, 0xc7eeee29, 0x6bb8b8d3, 0x2814143c, 0xa7dede79, 0xbc5e5ee2, 0x160b0b1d, 0xaddbdb76, 0xdbe0e03b, 0x64323256, 0x743a3a4e, 0x140a0a1e, 0x924949db, 0x0c06060a, 0x4824246c, 0xb85c5ce4, 0x9fc2c25d, 0xbdd3d36e, 0x43acacef, 0xc46262a6, 0x399191a8, 0x319595a4, 0xd3e4e437, 0xf279798b, 0xd5e7e732, 0x8bc8c843, 0x6e373759, 0xda6d6db7, 0x018d8d8c, 0xb1d5d564, 0x9c4e4ed2, 0x49a9a9e0, 0xd86c6cb4, 0xac5656fa, 0xf3f4f407, 0xcfeaea25, 0xca6565af, 0xf47a7a8e, 0x47aeaee9, 0x10080818, 0x6fbabad5, 0xf0787888, 0x4a25256f, 0x5c2e2e72, 0x381c1c24, 0x57a6a6f1, 0x73b4b4c7, 0x97c6c651, 0xcbe8e823, 0xa1dddd7c, 0xe874749c, 0x3e1f1f21, 0x964b4bdd, 0x61bdbddc, 0x0d8b8b86, 0x0f8a8a85, 0xe0707090, 0x7c3e3e42, 0x71b5b5c4, 0xcc6666aa, 0x904848d8, 0x06030305, 0xf7f6f601, 0x1c0e0e12, 0xc26161a3, 0x6a35355f, 0xae5757f9, 0x69b9b9d0, 0x17868691, 0x99c1c158, 0x3a1d1d27, 0x279e9eb9, 0xd9e1e138, 0xebf8f813, 0x2b9898b3, 0x22111133, 0xd26969bb, 0xa9d9d970, 0x078e8e89, 0x339494a7, 0x2d9b9bb6, 0x3c1e1e22, 0x15878792, 0xc9e9e920, 0x87cece49, 0xaa5555ff, 0x50282878, 0xa5dfdf7a, 0x038c8c8f, 0x59a1a1f8, 0x09898980, 0x1a0d0d17, 0x65bfbfda, 0xd7e6e631, 0x844242c6, 0xd06868b8, 0x824141c3, 0x299999b0, 0x5a2d2d77, 0x1e0f0f11, 0x7bb0b0cb, 0xa85454fc, 0x6dbbbbd6, 0x2c16163a];
var T2 = [0xa5c66363, 0x84f87c7c, 0x99ee7777, 0x8df67b7b, 0x0dfff2f2, 0xbdd66b6b, 0xb1de6f6f, 0x5491c5c5, 0x50603030, 0x03020101, 0xa9ce6767, 0x7d562b2b, 0x19e7fefe, 0x62b5d7d7, 0xe64dabab, 0x9aec7676, 0x458fcaca, 0x9d1f8282, 0x4089c9c9, 0x87fa7d7d, 0x15effafa, 0xebb25959, 0xc98e4747, 0x0bfbf0f0, 0xec41adad, 0x67b3d4d4, 0xfd5fa2a2, 0xea45afaf, 0xbf239c9c, 0xf753a4a4, 0x96e47272, 0x5b9bc0c0, 0xc275b7b7, 0x1ce1fdfd, 0xae3d9393, 0x6a4c2626, 0x5a6c3636, 0x417e3f3f, 0x02f5f7f7, 0x4f83cccc, 0x5c683434, 0xf451a5a5, 0x34d1e5e5, 0x08f9f1f1, 0x93e27171, 0x73abd8d8, 0x53623131, 0x3f2a1515, 0x0c080404, 0x5295c7c7, 0x65462323, 0x5e9dc3c3, 0x28301818, 0xa1379696, 0x0f0a0505, 0xb52f9a9a, 0x090e0707, 0x36241212, 0x9b1b8080, 0x3ddfe2e2, 0x26cdebeb, 0x694e2727, 0xcd7fb2b2, 0x9fea7575, 0x1b120909, 0x9e1d8383, 0x74582c2c, 0x2e341a1a, 0x2d361b1b, 0xb2dc6e6e, 0xeeb45a5a, 0xfb5ba0a0, 0xf6a45252, 0x4d763b3b, 0x61b7d6d6, 0xce7db3b3, 0x7b522929, 0x3edde3e3, 0x715e2f2f, 0x97138484, 0xf5a65353, 0x68b9d1d1, 0x00000000, 0x2cc1eded, 0x60402020, 0x1fe3fcfc, 0xc879b1b1, 0xedb65b5b, 0xbed46a6a, 0x468dcbcb, 0xd967bebe, 0x4b723939, 0xde944a4a, 0xd4984c4c, 0xe8b05858, 0x4a85cfcf, 0x6bbbd0d0, 0x2ac5efef, 0xe54faaaa, 0x16edfbfb, 0xc5864343, 0xd79a4d4d, 0x55663333, 0x94118585, 0xcf8a4545, 0x10e9f9f9, 0x06040202, 0x81fe7f7f, 0xf0a05050, 0x44783c3c, 0xba259f9f, 0xe34ba8a8, 0xf3a25151, 0xfe5da3a3, 0xc0804040, 0x8a058f8f, 0xad3f9292, 0xbc219d9d, 0x48703838, 0x04f1f5f5, 0xdf63bcbc, 0xc177b6b6, 0x75afdada, 0x63422121, 0x30201010, 0x1ae5ffff, 0x0efdf3f3, 0x6dbfd2d2, 0x4c81cdcd, 0x14180c0c, 0x35261313, 0x2fc3ecec, 0xe1be5f5f, 0xa2359797, 0xcc884444, 0x392e1717, 0x5793c4c4, 0xf255a7a7, 0x82fc7e7e, 0x477a3d3d, 0xacc86464, 0xe7ba5d5d, 0x2b321919, 0x95e67373, 0xa0c06060, 0x98198181, 0xd19e4f4f, 0x7fa3dcdc, 0x66442222, 0x7e542a2a, 0xab3b9090, 0x830b8888, 0xca8c4646, 0x29c7eeee, 0xd36bb8b8, 0x3c281414, 0x79a7dede, 0xe2bc5e5e, 0x1d160b0b, 0x76addbdb, 0x3bdbe0e0, 0x56643232, 0x4e743a3a, 0x1e140a0a, 0xdb924949, 0x0a0c0606, 0x6c482424, 0xe4b85c5c, 0x5d9fc2c2, 0x6ebdd3d3, 0xef43acac, 0xa6c46262, 0xa8399191, 0xa4319595, 0x37d3e4e4, 0x8bf27979, 0x32d5e7e7, 0x438bc8c8, 0x596e3737, 0xb7da6d6d, 0x8c018d8d, 0x64b1d5d5, 0xd29c4e4e, 0xe049a9a9, 0xb4d86c6c, 0xfaac5656, 0x07f3f4f4, 0x25cfeaea, 0xafca6565, 0x8ef47a7a, 0xe947aeae, 0x18100808, 0xd56fbaba, 0x88f07878, 0x6f4a2525, 0x725c2e2e, 0x24381c1c, 0xf157a6a6, 0xc773b4b4, 0x5197c6c6, 0x23cbe8e8, 0x7ca1dddd, 0x9ce87474, 0x213e1f1f, 0xdd964b4b, 0xdc61bdbd, 0x860d8b8b, 0x850f8a8a, 0x90e07070, 0x427c3e3e, 0xc471b5b5, 0xaacc6666, 0xd8904848, 0x05060303, 0x01f7f6f6, 0x121c0e0e, 0xa3c26161, 0x5f6a3535, 0xf9ae5757, 0xd069b9b9, 0x91178686, 0x5899c1c1, 0x273a1d1d, 0xb9279e9e, 0x38d9e1e1, 0x13ebf8f8, 0xb32b9898, 0x33221111, 0xbbd26969, 0x70a9d9d9, 0x89078e8e, 0xa7339494, 0xb62d9b9b, 0x223c1e1e, 0x92158787, 0x20c9e9e9, 0x4987cece, 0xffaa5555, 0x78502828, 0x7aa5dfdf, 0x8f038c8c, 0xf859a1a1, 0x80098989, 0x171a0d0d, 0xda65bfbf, 0x31d7e6e6, 0xc6844242, 0xb8d06868, 0xc3824141, 0xb0299999, 0x775a2d2d, 0x111e0f0f, 0xcb7bb0b0, 0xfca85454, 0xd66dbbbb, 0x3a2c1616];
var T3 = [0x63a5c663, 0x7c84f87c, 0x7799ee77, 0x7b8df67b, 0xf20dfff2, 0x6bbdd66b, 0x6fb1de6f, 0xc55491c5, 0x30506030, 0x01030201, 0x67a9ce67, 0x2b7d562b, 0xfe19e7fe, 0xd762b5d7, 0xabe64dab, 0x769aec76, 0xca458fca, 0x829d1f82, 0xc94089c9, 0x7d87fa7d, 0xfa15effa, 0x59ebb259, 0x47c98e47, 0xf00bfbf0, 0xadec41ad, 0xd467b3d4, 0xa2fd5fa2, 0xafea45af, 0x9cbf239c, 0xa4f753a4, 0x7296e472, 0xc05b9bc0, 0xb7c275b7, 0xfd1ce1fd, 0x93ae3d93, 0x266a4c26, 0x365a6c36, 0x3f417e3f, 0xf702f5f7, 0xcc4f83cc, 0x345c6834, 0xa5f451a5, 0xe534d1e5, 0xf108f9f1, 0x7193e271, 0xd873abd8, 0x31536231, 0x153f2a15, 0x040c0804, 0xc75295c7, 0x23654623, 0xc35e9dc3, 0x18283018, 0x96a13796, 0x050f0a05, 0x9ab52f9a, 0x07090e07, 0x12362412, 0x809b1b80, 0xe23ddfe2, 0xeb26cdeb, 0x27694e27, 0xb2cd7fb2, 0x759fea75, 0x091b1209, 0x839e1d83, 0x2c74582c, 0x1a2e341a, 0x1b2d361b, 0x6eb2dc6e, 0x5aeeb45a, 0xa0fb5ba0, 0x52f6a452, 0x3b4d763b, 0xd661b7d6, 0xb3ce7db3, 0x297b5229, 0xe33edde3, 0x2f715e2f, 0x84971384, 0x53f5a653, 0xd168b9d1, 0x00000000, 0xed2cc1ed, 0x20604020, 0xfc1fe3fc, 0xb1c879b1, 0x5bedb65b, 0x6abed46a, 0xcb468dcb, 0xbed967be, 0x394b7239, 0x4ade944a, 0x4cd4984c, 0x58e8b058, 0xcf4a85cf, 0xd06bbbd0, 0xef2ac5ef, 0xaae54faa, 0xfb16edfb, 0x43c58643, 0x4dd79a4d, 0x33556633, 0x85941185, 0x45cf8a45, 0xf910e9f9, 0x02060402, 0x7f81fe7f, 0x50f0a050, 0x3c44783c, 0x9fba259f, 0xa8e34ba8, 0x51f3a251, 0xa3fe5da3, 0x40c08040, 0x8f8a058f, 0x92ad3f92, 0x9dbc219d, 0x38487038, 0xf504f1f5, 0xbcdf63bc, 0xb6c177b6, 0xda75afda, 0x21634221, 0x10302010, 0xff1ae5ff, 0xf30efdf3, 0xd26dbfd2, 0xcd4c81cd, 0x0c14180c, 0x13352613, 0xec2fc3ec, 0x5fe1be5f, 0x97a23597, 0x44cc8844, 0x17392e17, 0xc45793c4, 0xa7f255a7, 0x7e82fc7e, 0x3d477a3d, 0x64acc864, 0x5de7ba5d, 0x192b3219, 0x7395e673, 0x60a0c060, 0x81981981, 0x4fd19e4f, 0xdc7fa3dc, 0x22664422, 0x2a7e542a, 0x90ab3b90, 0x88830b88, 0x46ca8c46, 0xee29c7ee, 0xb8d36bb8, 0x143c2814, 0xde79a7de, 0x5ee2bc5e, 0x0b1d160b, 0xdb76addb, 0xe03bdbe0, 0x32566432, 0x3a4e743a, 0x0a1e140a, 0x49db9249, 0x060a0c06, 0x246c4824, 0x5ce4b85c, 0xc25d9fc2, 0xd36ebdd3, 0xacef43ac, 0x62a6c462, 0x91a83991, 0x95a43195, 0xe437d3e4, 0x798bf279, 0xe732d5e7, 0xc8438bc8, 0x37596e37, 0x6db7da6d, 0x8d8c018d, 0xd564b1d5, 0x4ed29c4e, 0xa9e049a9, 0x6cb4d86c, 0x56faac56, 0xf407f3f4, 0xea25cfea, 0x65afca65, 0x7a8ef47a, 0xaee947ae, 0x08181008, 0xbad56fba, 0x7888f078, 0x256f4a25, 0x2e725c2e, 0x1c24381c, 0xa6f157a6, 0xb4c773b4, 0xc65197c6, 0xe823cbe8, 0xdd7ca1dd, 0x749ce874, 0x1f213e1f, 0x4bdd964b, 0xbddc61bd, 0x8b860d8b, 0x8a850f8a, 0x7090e070, 0x3e427c3e, 0xb5c471b5, 0x66aacc66, 0x48d89048, 0x03050603, 0xf601f7f6, 0x0e121c0e, 0x61a3c261, 0x355f6a35, 0x57f9ae57, 0xb9d069b9, 0x86911786, 0xc15899c1, 0x1d273a1d, 0x9eb9279e, 0xe138d9e1, 0xf813ebf8, 0x98b32b98, 0x11332211, 0x69bbd269, 0xd970a9d9, 0x8e89078e, 0x94a73394, 0x9bb62d9b, 0x1e223c1e, 0x87921587, 0xe920c9e9, 0xce4987ce, 0x55ffaa55, 0x28785028, 0xdf7aa5df, 0x8c8f038c, 0xa1f859a1, 0x89800989, 0x0d171a0d, 0xbfda65bf, 0xe631d7e6, 0x42c68442, 0x68b8d068, 0x41c38241, 0x99b02999, 0x2d775a2d, 0x0f111e0f, 0xb0cb7bb0, 0x54fca854, 0xbbd66dbb, 0x163a2c16];
var T4 = [0x6363a5c6, 0x7c7c84f8, 0x777799ee, 0x7b7b8df6, 0xf2f20dff, 0x6b6bbdd6, 0x6f6fb1de, 0xc5c55491, 0x30305060, 0x01010302, 0x6767a9ce, 0x2b2b7d56, 0xfefe19e7, 0xd7d762b5, 0xababe64d, 0x76769aec, 0xcaca458f, 0x82829d1f, 0xc9c94089, 0x7d7d87fa, 0xfafa15ef, 0x5959ebb2, 0x4747c98e, 0xf0f00bfb, 0xadadec41, 0xd4d467b3, 0xa2a2fd5f, 0xafafea45, 0x9c9cbf23, 0xa4a4f753, 0x727296e4, 0xc0c05b9b, 0xb7b7c275, 0xfdfd1ce1, 0x9393ae3d, 0x26266a4c, 0x36365a6c, 0x3f3f417e, 0xf7f702f5, 0xcccc4f83, 0x34345c68, 0xa5a5f451, 0xe5e534d1, 0xf1f108f9, 0x717193e2, 0xd8d873ab, 0x31315362, 0x15153f2a, 0x04040c08, 0xc7c75295, 0x23236546, 0xc3c35e9d, 0x18182830, 0x9696a137, 0x05050f0a, 0x9a9ab52f, 0x0707090e, 0x12123624, 0x80809b1b, 0xe2e23ddf, 0xebeb26cd, 0x2727694e, 0xb2b2cd7f, 0x75759fea, 0x09091b12, 0x83839e1d, 0x2c2c7458, 0x1a1a2e34, 0x1b1b2d36, 0x6e6eb2dc, 0x5a5aeeb4, 0xa0a0fb5b, 0x5252f6a4, 0x3b3b4d76, 0xd6d661b7, 0xb3b3ce7d, 0x29297b52, 0xe3e33edd, 0x2f2f715e, 0x84849713, 0x5353f5a6, 0xd1d168b9, 0x00000000, 0xeded2cc1, 0x20206040, 0xfcfc1fe3, 0xb1b1c879, 0x5b5bedb6, 0x6a6abed4, 0xcbcb468d, 0xbebed967, 0x39394b72, 0x4a4ade94, 0x4c4cd498, 0x5858e8b0, 0xcfcf4a85, 0xd0d06bbb, 0xefef2ac5, 0xaaaae54f, 0xfbfb16ed, 0x4343c586, 0x4d4dd79a, 0x33335566, 0x85859411, 0x4545cf8a, 0xf9f910e9, 0x02020604, 0x7f7f81fe, 0x5050f0a0, 0x3c3c4478, 0x9f9fba25, 0xa8a8e34b, 0x5151f3a2, 0xa3a3fe5d, 0x4040c080, 0x8f8f8a05, 0x9292ad3f, 0x9d9dbc21, 0x38384870, 0xf5f504f1, 0xbcbcdf63, 0xb6b6c177, 0xdada75af, 0x21216342, 0x10103020, 0xffff1ae5, 0xf3f30efd, 0xd2d26dbf, 0xcdcd4c81, 0x0c0c1418, 0x13133526, 0xecec2fc3, 0x5f5fe1be, 0x9797a235, 0x4444cc88, 0x1717392e, 0xc4c45793, 0xa7a7f255, 0x7e7e82fc, 0x3d3d477a, 0x6464acc8, 0x5d5de7ba, 0x19192b32, 0x737395e6, 0x6060a0c0, 0x81819819, 0x4f4fd19e, 0xdcdc7fa3, 0x22226644, 0x2a2a7e54, 0x9090ab3b, 0x8888830b, 0x4646ca8c, 0xeeee29c7, 0xb8b8d36b, 0x14143c28, 0xdede79a7, 0x5e5ee2bc, 0x0b0b1d16, 0xdbdb76ad, 0xe0e03bdb, 0x32325664, 0x3a3a4e74, 0x0a0a1e14, 0x4949db92, 0x06060a0c, 0x24246c48, 0x5c5ce4b8, 0xc2c25d9f, 0xd3d36ebd, 0xacacef43, 0x6262a6c4, 0x9191a839, 0x9595a431, 0xe4e437d3, 0x79798bf2, 0xe7e732d5, 0xc8c8438b, 0x3737596e, 0x6d6db7da, 0x8d8d8c01, 0xd5d564b1, 0x4e4ed29c, 0xa9a9e049, 0x6c6cb4d8, 0x5656faac, 0xf4f407f3, 0xeaea25cf, 0x6565afca, 0x7a7a8ef4, 0xaeaee947, 0x08081810, 0xbabad56f, 0x787888f0, 0x25256f4a, 0x2e2e725c, 0x1c1c2438, 0xa6a6f157, 0xb4b4c773, 0xc6c65197, 0xe8e823cb, 0xdddd7ca1, 0x74749ce8, 0x1f1f213e, 0x4b4bdd96, 0xbdbddc61, 0x8b8b860d, 0x8a8a850f, 0x707090e0, 0x3e3e427c, 0xb5b5c471, 0x6666aacc, 0x4848d890, 0x03030506, 0xf6f601f7, 0x0e0e121c, 0x6161a3c2, 0x35355f6a, 0x5757f9ae, 0xb9b9d069, 0x86869117, 0xc1c15899, 0x1d1d273a, 0x9e9eb927, 0xe1e138d9, 0xf8f813eb, 0x9898b32b, 0x11113322, 0x6969bbd2, 0xd9d970a9, 0x8e8e8907, 0x9494a733, 0x9b9bb62d, 0x1e1e223c, 0x87879215, 0xe9e920c9, 0xcece4987, 0x5555ffaa, 0x28287850, 0xdfdf7aa5, 0x8c8c8f03, 0xa1a1f859, 0x89898009, 0x0d0d171a, 0xbfbfda65, 0xe6e631d7, 0x4242c684, 0x6868b8d0, 0x4141c382, 0x9999b029, 0x2d2d775a, 0x0f0f111e, 0xb0b0cb7b, 0x5454fca8, 0xbbbbd66d, 0x16163a2c];
// Transformations for decryption
var T5 = [0x51f4a750, 0x7e416553, 0x1a17a4c3, 0x3a275e96, 0x3bab6bcb, 0x1f9d45f1, 0xacfa58ab, 0x4be30393, 0x2030fa55, 0xad766df6, 0x88cc7691, 0xf5024c25, 0x4fe5d7fc, 0xc52acbd7, 0x26354480, 0xb562a38f, 0xdeb15a49, 0x25ba1b67, 0x45ea0e98, 0x5dfec0e1, 0xc32f7502, 0x814cf012, 0x8d4697a3, 0x6bd3f9c6, 0x038f5fe7, 0x15929c95, 0xbf6d7aeb, 0x955259da, 0xd4be832d, 0x587421d3, 0x49e06929, 0x8ec9c844, 0x75c2896a, 0xf48e7978, 0x99583e6b, 0x27b971dd, 0xbee14fb6, 0xf088ad17, 0xc920ac66, 0x7dce3ab4, 0x63df4a18, 0xe51a3182, 0x97513360, 0x62537f45, 0xb16477e0, 0xbb6bae84, 0xfe81a01c, 0xf9082b94, 0x70486858, 0x8f45fd19, 0x94de6c87, 0x527bf8b7, 0xab73d323, 0x724b02e2, 0xe31f8f57, 0x6655ab2a, 0xb2eb2807, 0x2fb5c203, 0x86c57b9a, 0xd33708a5, 0x302887f2, 0x23bfa5b2, 0x02036aba, 0xed16825c, 0x8acf1c2b, 0xa779b492, 0xf307f2f0, 0x4e69e2a1, 0x65daf4cd, 0x0605bed5, 0xd134621f, 0xc4a6fe8a, 0x342e539d, 0xa2f355a0, 0x058ae132, 0xa4f6eb75, 0x0b83ec39, 0x4060efaa, 0x5e719f06, 0xbd6e1051, 0x3e218af9, 0x96dd063d, 0xdd3e05ae, 0x4de6bd46, 0x91548db5, 0x71c45d05, 0x0406d46f, 0x605015ff, 0x1998fb24, 0xd6bde997, 0x894043cc, 0x67d99e77, 0xb0e842bd, 0x07898b88, 0xe7195b38, 0x79c8eedb, 0xa17c0a47, 0x7c420fe9, 0xf8841ec9, 0x00000000, 0x09808683, 0x322bed48, 0x1e1170ac, 0x6c5a724e, 0xfd0efffb, 0x0f853856, 0x3daed51e, 0x362d3927, 0x0a0fd964, 0x685ca621, 0x9b5b54d1, 0x24362e3a, 0x0c0a67b1, 0x9357e70f, 0xb4ee96d2, 0x1b9b919e, 0x80c0c54f, 0x61dc20a2, 0x5a774b69, 0x1c121a16, 0xe293ba0a, 0xc0a02ae5, 0x3c22e043, 0x121b171d, 0x0e090d0b, 0xf28bc7ad, 0x2db6a8b9, 0x141ea9c8, 0x57f11985, 0xaf75074c, 0xee99ddbb, 0xa37f60fd, 0xf701269f, 0x5c72f5bc, 0x44663bc5, 0x5bfb7e34, 0x8b432976, 0xcb23c6dc, 0xb6edfc68, 0xb8e4f163, 0xd731dcca, 0x42638510, 0x13972240, 0x84c61120, 0x854a247d, 0xd2bb3df8, 0xaef93211, 0xc729a16d, 0x1d9e2f4b, 0xdcb230f3, 0x0d8652ec, 0x77c1e3d0, 0x2bb3166c, 0xa970b999, 0x119448fa, 0x47e96422, 0xa8fc8cc4, 0xa0f03f1a, 0x567d2cd8, 0x223390ef, 0x87494ec7, 0xd938d1c1, 0x8ccaa2fe, 0x98d40b36, 0xa6f581cf, 0xa57ade28, 0xdab78e26, 0x3fadbfa4, 0x2c3a9de4, 0x5078920d, 0x6a5fcc9b, 0x547e4662, 0xf68d13c2, 0x90d8b8e8, 0x2e39f75e, 0x82c3aff5, 0x9f5d80be, 0x69d0937c, 0x6fd52da9, 0xcf2512b3, 0xc8ac993b, 0x10187da7, 0xe89c636e, 0xdb3bbb7b, 0xcd267809, 0x6e5918f4, 0xec9ab701, 0x834f9aa8, 0xe6956e65, 0xaaffe67e, 0x21bccf08, 0xef15e8e6, 0xbae79bd9, 0x4a6f36ce, 0xea9f09d4, 0x29b07cd6, 0x31a4b2af, 0x2a3f2331, 0xc6a59430, 0x35a266c0, 0x744ebc37, 0xfc82caa6, 0xe090d0b0, 0x33a7d815, 0xf104984a, 0x41ecdaf7, 0x7fcd500e, 0x1791f62f, 0x764dd68d, 0x43efb04d, 0xccaa4d54, 0xe49604df, 0x9ed1b5e3, 0x4c6a881b, 0xc12c1fb8, 0x4665517f, 0x9d5eea04, 0x018c355d, 0xfa877473, 0xfb0b412e, 0xb3671d5a, 0x92dbd252, 0xe9105633, 0x6dd64713, 0x9ad7618c, 0x37a10c7a, 0x59f8148e, 0xeb133c89, 0xcea927ee, 0xb761c935, 0xe11ce5ed, 0x7a47b13c, 0x9cd2df59, 0x55f2733f, 0x1814ce79, 0x73c737bf, 0x53f7cdea, 0x5ffdaa5b, 0xdf3d6f14, 0x7844db86, 0xcaaff381, 0xb968c43e, 0x3824342c, 0xc2a3405f, 0x161dc372, 0xbce2250c, 0x283c498b, 0xff0d9541, 0x39a80171, 0x080cb3de, 0xd8b4e49c, 0x6456c190, 0x7bcb8461, 0xd532b670, 0x486c5c74, 0xd0b85742];
var T6 = [0x5051f4a7, 0x537e4165, 0xc31a17a4, 0x963a275e, 0xcb3bab6b, 0xf11f9d45, 0xabacfa58, 0x934be303, 0x552030fa, 0xf6ad766d, 0x9188cc76, 0x25f5024c, 0xfc4fe5d7, 0xd7c52acb, 0x80263544, 0x8fb562a3, 0x49deb15a, 0x6725ba1b, 0x9845ea0e, 0xe15dfec0, 0x02c32f75, 0x12814cf0, 0xa38d4697, 0xc66bd3f9, 0xe7038f5f, 0x9515929c, 0xebbf6d7a, 0xda955259, 0x2dd4be83, 0xd3587421, 0x2949e069, 0x448ec9c8, 0x6a75c289, 0x78f48e79, 0x6b99583e, 0xdd27b971, 0xb6bee14f, 0x17f088ad, 0x66c920ac, 0xb47dce3a, 0x1863df4a, 0x82e51a31, 0x60975133, 0x4562537f, 0xe0b16477, 0x84bb6bae, 0x1cfe81a0, 0x94f9082b, 0x58704868, 0x198f45fd, 0x8794de6c, 0xb7527bf8, 0x23ab73d3, 0xe2724b02, 0x57e31f8f, 0x2a6655ab, 0x07b2eb28, 0x032fb5c2, 0x9a86c57b, 0xa5d33708, 0xf2302887, 0xb223bfa5, 0xba02036a, 0x5ced1682, 0x2b8acf1c, 0x92a779b4, 0xf0f307f2, 0xa14e69e2, 0xcd65daf4, 0xd50605be, 0x1fd13462, 0x8ac4a6fe, 0x9d342e53, 0xa0a2f355, 0x32058ae1, 0x75a4f6eb, 0x390b83ec, 0xaa4060ef, 0x065e719f, 0x51bd6e10, 0xf93e218a, 0x3d96dd06, 0xaedd3e05, 0x464de6bd, 0xb591548d, 0x0571c45d, 0x6f0406d4, 0xff605015, 0x241998fb, 0x97d6bde9, 0xcc894043, 0x7767d99e, 0xbdb0e842, 0x8807898b, 0x38e7195b, 0xdb79c8ee, 0x47a17c0a, 0xe97c420f, 0xc9f8841e, 0x00000000, 0x83098086, 0x48322bed, 0xac1e1170, 0x4e6c5a72, 0xfbfd0eff, 0x560f8538, 0x1e3daed5, 0x27362d39, 0x640a0fd9, 0x21685ca6, 0xd19b5b54, 0x3a24362e, 0xb10c0a67, 0x0f9357e7, 0xd2b4ee96, 0x9e1b9b91, 0x4f80c0c5, 0xa261dc20, 0x695a774b, 0x161c121a, 0x0ae293ba, 0xe5c0a02a, 0x433c22e0, 0x1d121b17, 0x0b0e090d, 0xadf28bc7, 0xb92db6a8, 0xc8141ea9, 0x8557f119, 0x4caf7507, 0xbbee99dd, 0xfda37f60, 0x9ff70126, 0xbc5c72f5, 0xc544663b, 0x345bfb7e, 0x768b4329, 0xdccb23c6, 0x68b6edfc, 0x63b8e4f1, 0xcad731dc, 0x10426385, 0x40139722, 0x2084c611, 0x7d854a24, 0xf8d2bb3d, 0x11aef932, 0x6dc729a1, 0x4b1d9e2f, 0xf3dcb230, 0xec0d8652, 0xd077c1e3, 0x6c2bb316, 0x99a970b9, 0xfa119448, 0x2247e964, 0xc4a8fc8c, 0x1aa0f03f, 0xd8567d2c, 0xef223390, 0xc787494e, 0xc1d938d1, 0xfe8ccaa2, 0x3698d40b, 0xcfa6f581, 0x28a57ade, 0x26dab78e, 0xa43fadbf, 0xe42c3a9d, 0x0d507892, 0x9b6a5fcc, 0x62547e46, 0xc2f68d13, 0xe890d8b8, 0x5e2e39f7, 0xf582c3af, 0xbe9f5d80, 0x7c69d093, 0xa96fd52d, 0xb3cf2512, 0x3bc8ac99, 0xa710187d, 0x6ee89c63, 0x7bdb3bbb, 0x09cd2678, 0xf46e5918, 0x01ec9ab7, 0xa8834f9a, 0x65e6956e, 0x7eaaffe6, 0x0821bccf, 0xe6ef15e8, 0xd9bae79b, 0xce4a6f36, 0xd4ea9f09, 0xd629b07c, 0xaf31a4b2, 0x312a3f23, 0x30c6a594, 0xc035a266, 0x37744ebc, 0xa6fc82ca, 0xb0e090d0, 0x1533a7d8, 0x4af10498, 0xf741ecda, 0x0e7fcd50, 0x2f1791f6, 0x8d764dd6, 0x4d43efb0, 0x54ccaa4d, 0xdfe49604, 0xe39ed1b5, 0x1b4c6a88, 0xb8c12c1f, 0x7f466551, 0x049d5eea, 0x5d018c35, 0x73fa8774, 0x2efb0b41, 0x5ab3671d, 0x5292dbd2, 0x33e91056, 0x136dd647, 0x8c9ad761, 0x7a37a10c, 0x8e59f814, 0x89eb133c, 0xeecea927, 0x35b761c9, 0xede11ce5, 0x3c7a47b1, 0x599cd2df, 0x3f55f273, 0x791814ce, 0xbf73c737, 0xea53f7cd, 0x5b5ffdaa, 0x14df3d6f, 0x867844db, 0x81caaff3, 0x3eb968c4, 0x2c382434, 0x5fc2a340, 0x72161dc3, 0x0cbce225, 0x8b283c49, 0x41ff0d95, 0x7139a801, 0xde080cb3, 0x9cd8b4e4, 0x906456c1, 0x617bcb84, 0x70d532b6, 0x74486c5c, 0x42d0b857];
var T7 = [0xa75051f4, 0x65537e41, 0xa4c31a17, 0x5e963a27, 0x6bcb3bab, 0x45f11f9d, 0x58abacfa, 0x03934be3, 0xfa552030, 0x6df6ad76, 0x769188cc, 0x4c25f502, 0xd7fc4fe5, 0xcbd7c52a, 0x44802635, 0xa38fb562, 0x5a49deb1, 0x1b6725ba, 0x0e9845ea, 0xc0e15dfe, 0x7502c32f, 0xf012814c, 0x97a38d46, 0xf9c66bd3, 0x5fe7038f, 0x9c951592, 0x7aebbf6d, 0x59da9552, 0x832dd4be, 0x21d35874, 0x692949e0, 0xc8448ec9, 0x896a75c2, 0x7978f48e, 0x3e6b9958, 0x71dd27b9, 0x4fb6bee1, 0xad17f088, 0xac66c920, 0x3ab47dce, 0x4a1863df, 0x3182e51a, 0x33609751, 0x7f456253, 0x77e0b164, 0xae84bb6b, 0xa01cfe81, 0x2b94f908, 0x68587048, 0xfd198f45, 0x6c8794de, 0xf8b7527b, 0xd323ab73, 0x02e2724b, 0x8f57e31f, 0xab2a6655, 0x2807b2eb, 0xc2032fb5, 0x7b9a86c5, 0x08a5d337, 0x87f23028, 0xa5b223bf, 0x6aba0203, 0x825ced16, 0x1c2b8acf, 0xb492a779, 0xf2f0f307, 0xe2a14e69, 0xf4cd65da, 0xbed50605, 0x621fd134, 0xfe8ac4a6, 0x539d342e, 0x55a0a2f3, 0xe132058a, 0xeb75a4f6, 0xec390b83, 0xefaa4060, 0x9f065e71, 0x1051bd6e, 0x8af93e21, 0x063d96dd, 0x05aedd3e, 0xbd464de6, 0x8db59154, 0x5d0571c4, 0xd46f0406, 0x15ff6050, 0xfb241998, 0xe997d6bd, 0x43cc8940, 0x9e7767d9, 0x42bdb0e8, 0x8b880789, 0x5b38e719, 0xeedb79c8, 0x0a47a17c, 0x0fe97c42, 0x1ec9f884, 0x00000000, 0x86830980, 0xed48322b, 0x70ac1e11, 0x724e6c5a, 0xfffbfd0e, 0x38560f85, 0xd51e3dae, 0x3927362d, 0xd9640a0f, 0xa621685c, 0x54d19b5b, 0x2e3a2436, 0x67b10c0a, 0xe70f9357, 0x96d2b4ee, 0x919e1b9b, 0xc54f80c0, 0x20a261dc, 0x4b695a77, 0x1a161c12, 0xba0ae293, 0x2ae5c0a0, 0xe0433c22, 0x171d121b, 0x0d0b0e09, 0xc7adf28b, 0xa8b92db6, 0xa9c8141e, 0x198557f1, 0x074caf75, 0xddbbee99, 0x60fda37f, 0x269ff701, 0xf5bc5c72, 0x3bc54466, 0x7e345bfb, 0x29768b43, 0xc6dccb23, 0xfc68b6ed, 0xf163b8e4, 0xdccad731, 0x85104263, 0x22401397, 0x112084c6, 0x247d854a, 0x3df8d2bb, 0x3211aef9, 0xa16dc729, 0x2f4b1d9e, 0x30f3dcb2, 0x52ec0d86, 0xe3d077c1, 0x166c2bb3, 0xb999a970, 0x48fa1194, 0x642247e9, 0x8cc4a8fc, 0x3f1aa0f0, 0x2cd8567d, 0x90ef2233, 0x4ec78749, 0xd1c1d938, 0xa2fe8cca, 0x0b3698d4, 0x81cfa6f5, 0xde28a57a, 0x8e26dab7, 0xbfa43fad, 0x9de42c3a, 0x920d5078, 0xcc9b6a5f, 0x4662547e, 0x13c2f68d, 0xb8e890d8, 0xf75e2e39, 0xaff582c3, 0x80be9f5d, 0x937c69d0, 0x2da96fd5, 0x12b3cf25, 0x993bc8ac, 0x7da71018, 0x636ee89c, 0xbb7bdb3b, 0x7809cd26, 0x18f46e59, 0xb701ec9a, 0x9aa8834f, 0x6e65e695, 0xe67eaaff, 0xcf0821bc, 0xe8e6ef15, 0x9bd9bae7, 0x36ce4a6f, 0x09d4ea9f, 0x7cd629b0, 0xb2af31a4, 0x23312a3f, 0x9430c6a5, 0x66c035a2, 0xbc37744e, 0xcaa6fc82, 0xd0b0e090, 0xd81533a7, 0x984af104, 0xdaf741ec, 0x500e7fcd, 0xf62f1791, 0xd68d764d, 0xb04d43ef, 0x4d54ccaa, 0x04dfe496, 0xb5e39ed1, 0x881b4c6a, 0x1fb8c12c, 0x517f4665, 0xea049d5e, 0x355d018c, 0x7473fa87, 0x412efb0b, 0x1d5ab367, 0xd25292db, 0x5633e910, 0x47136dd6, 0x618c9ad7, 0x0c7a37a1, 0x148e59f8, 0x3c89eb13, 0x27eecea9, 0xc935b761, 0xe5ede11c, 0xb13c7a47, 0xdf599cd2, 0x733f55f2, 0xce791814, 0x37bf73c7, 0xcdea53f7, 0xaa5b5ffd, 0x6f14df3d, 0xdb867844, 0xf381caaf, 0xc43eb968, 0x342c3824, 0x405fc2a3, 0xc372161d, 0x250cbce2, 0x498b283c, 0x9541ff0d, 0x017139a8, 0xb3de080c, 0xe49cd8b4, 0xc1906456, 0x84617bcb, 0xb670d532, 0x5c74486c, 0x5742d0b8];
var T8 = [0xf4a75051, 0x4165537e, 0x17a4c31a, 0x275e963a, 0xab6bcb3b, 0x9d45f11f, 0xfa58abac, 0xe303934b, 0x30fa5520, 0x766df6ad, 0xcc769188, 0x024c25f5, 0xe5d7fc4f, 0x2acbd7c5, 0x35448026, 0x62a38fb5, 0xb15a49de, 0xba1b6725, 0xea0e9845, 0xfec0e15d, 0x2f7502c3, 0x4cf01281, 0x4697a38d, 0xd3f9c66b, 0x8f5fe703, 0x929c9515, 0x6d7aebbf, 0x5259da95, 0xbe832dd4, 0x7421d358, 0xe0692949, 0xc9c8448e, 0xc2896a75, 0x8e7978f4, 0x583e6b99, 0xb971dd27, 0xe14fb6be, 0x88ad17f0, 0x20ac66c9, 0xce3ab47d, 0xdf4a1863, 0x1a3182e5, 0x51336097, 0x537f4562, 0x6477e0b1, 0x6bae84bb, 0x81a01cfe, 0x082b94f9, 0x48685870, 0x45fd198f, 0xde6c8794, 0x7bf8b752, 0x73d323ab, 0x4b02e272, 0x1f8f57e3, 0x55ab2a66, 0xeb2807b2, 0xb5c2032f, 0xc57b9a86, 0x3708a5d3, 0x2887f230, 0xbfa5b223, 0x036aba02, 0x16825ced, 0xcf1c2b8a, 0x79b492a7, 0x07f2f0f3, 0x69e2a14e, 0xdaf4cd65, 0x05bed506, 0x34621fd1, 0xa6fe8ac4, 0x2e539d34, 0xf355a0a2, 0x8ae13205, 0xf6eb75a4, 0x83ec390b, 0x60efaa40, 0x719f065e, 0x6e1051bd, 0x218af93e, 0xdd063d96, 0x3e05aedd, 0xe6bd464d, 0x548db591, 0xc45d0571, 0x06d46f04, 0x5015ff60, 0x98fb2419, 0xbde997d6, 0x4043cc89, 0xd99e7767, 0xe842bdb0, 0x898b8807, 0x195b38e7, 0xc8eedb79, 0x7c0a47a1, 0x420fe97c, 0x841ec9f8, 0x00000000, 0x80868309, 0x2bed4832, 0x1170ac1e, 0x5a724e6c, 0x0efffbfd, 0x8538560f, 0xaed51e3d, 0x2d392736, 0x0fd9640a, 0x5ca62168, 0x5b54d19b, 0x362e3a24, 0x0a67b10c, 0x57e70f93, 0xee96d2b4, 0x9b919e1b, 0xc0c54f80, 0xdc20a261, 0x774b695a, 0x121a161c, 0x93ba0ae2, 0xa02ae5c0, 0x22e0433c, 0x1b171d12, 0x090d0b0e, 0x8bc7adf2, 0xb6a8b92d, 0x1ea9c814, 0xf1198557, 0x75074caf, 0x99ddbbee, 0x7f60fda3, 0x01269ff7, 0x72f5bc5c, 0x663bc544, 0xfb7e345b, 0x4329768b, 0x23c6dccb, 0xedfc68b6, 0xe4f163b8, 0x31dccad7, 0x63851042, 0x97224013, 0xc6112084, 0x4a247d85, 0xbb3df8d2, 0xf93211ae, 0x29a16dc7, 0x9e2f4b1d, 0xb230f3dc, 0x8652ec0d, 0xc1e3d077, 0xb3166c2b, 0x70b999a9, 0x9448fa11, 0xe9642247, 0xfc8cc4a8, 0xf03f1aa0, 0x7d2cd856, 0x3390ef22, 0x494ec787, 0x38d1c1d9, 0xcaa2fe8c, 0xd40b3698, 0xf581cfa6, 0x7ade28a5, 0xb78e26da, 0xadbfa43f, 0x3a9de42c, 0x78920d50, 0x5fcc9b6a, 0x7e466254, 0x8d13c2f6, 0xd8b8e890, 0x39f75e2e, 0xc3aff582, 0x5d80be9f, 0xd0937c69, 0xd52da96f, 0x2512b3cf, 0xac993bc8, 0x187da710, 0x9c636ee8, 0x3bbb7bdb, 0x267809cd, 0x5918f46e, 0x9ab701ec, 0x4f9aa883, 0x956e65e6, 0xffe67eaa, 0xbccf0821, 0x15e8e6ef, 0xe79bd9ba, 0x6f36ce4a, 0x9f09d4ea, 0xb07cd629, 0xa4b2af31, 0x3f23312a, 0xa59430c6, 0xa266c035, 0x4ebc3774, 0x82caa6fc, 0x90d0b0e0, 0xa7d81533, 0x04984af1, 0xecdaf741, 0xcd500e7f, 0x91f62f17, 0x4dd68d76, 0xefb04d43, 0xaa4d54cc, 0x9604dfe4, 0xd1b5e39e, 0x6a881b4c, 0x2c1fb8c1, 0x65517f46, 0x5eea049d, 0x8c355d01, 0x877473fa, 0x0b412efb, 0x671d5ab3, 0xdbd25292, 0x105633e9, 0xd647136d, 0xd7618c9a, 0xa10c7a37, 0xf8148e59, 0x133c89eb, 0xa927eece, 0x61c935b7, 0x1ce5ede1, 0x47b13c7a, 0xd2df599c, 0xf2733f55, 0x14ce7918, 0xc737bf73, 0xf7cdea53, 0xfdaa5b5f, 0x3d6f14df, 0x44db8678, 0xaff381ca, 0x68c43eb9, 0x24342c38, 0xa3405fc2, 0x1dc37216, 0xe2250cbc, 0x3c498b28, 0x0d9541ff, 0xa8017139, 0x0cb3de08, 0xb4e49cd8, 0x56c19064, 0xcb84617b, 0x32b670d5, 0x6c5c7448, 0xb85742d0];
// Transformations for decryption key expansion
var U1 = [0x00000000, 0x0e090d0b, 0x1c121a16, 0x121b171d, 0x3824342c, 0x362d3927, 0x24362e3a, 0x2a3f2331, 0x70486858, 0x7e416553, 0x6c5a724e, 0x62537f45, 0x486c5c74, 0x4665517f, 0x547e4662, 0x5a774b69, 0xe090d0b0, 0xee99ddbb, 0xfc82caa6, 0xf28bc7ad, 0xd8b4e49c, 0xd6bde997, 0xc4a6fe8a, 0xcaaff381, 0x90d8b8e8, 0x9ed1b5e3, 0x8ccaa2fe, 0x82c3aff5, 0xa8fc8cc4, 0xa6f581cf, 0xb4ee96d2, 0xbae79bd9, 0xdb3bbb7b, 0xd532b670, 0xc729a16d, 0xc920ac66, 0xe31f8f57, 0xed16825c, 0xff0d9541, 0xf104984a, 0xab73d323, 0xa57ade28, 0xb761c935, 0xb968c43e, 0x9357e70f, 0x9d5eea04, 0x8f45fd19, 0x814cf012, 0x3bab6bcb, 0x35a266c0, 0x27b971dd, 0x29b07cd6, 0x038f5fe7, 0x0d8652ec, 0x1f9d45f1, 0x119448fa, 0x4be30393, 0x45ea0e98, 0x57f11985, 0x59f8148e, 0x73c737bf, 0x7dce3ab4, 0x6fd52da9, 0x61dc20a2, 0xad766df6, 0xa37f60fd, 0xb16477e0, 0xbf6d7aeb, 0x955259da, 0x9b5b54d1, 0x894043cc, 0x87494ec7, 0xdd3e05ae, 0xd33708a5, 0xc12c1fb8, 0xcf2512b3, 0xe51a3182, 0xeb133c89, 0xf9082b94, 0xf701269f, 0x4de6bd46, 0x43efb04d, 0x51f4a750, 0x5ffdaa5b, 0x75c2896a, 0x7bcb8461, 0x69d0937c, 0x67d99e77, 0x3daed51e, 0x33a7d815, 0x21bccf08, 0x2fb5c203, 0x058ae132, 0x0b83ec39, 0x1998fb24, 0x1791f62f, 0x764dd68d, 0x7844db86, 0x6a5fcc9b, 0x6456c190, 0x4e69e2a1, 0x4060efaa, 0x527bf8b7, 0x5c72f5bc, 0x0605bed5, 0x080cb3de, 0x1a17a4c3, 0x141ea9c8, 0x3e218af9, 0x302887f2, 0x223390ef, 0x2c3a9de4, 0x96dd063d, 0x98d40b36, 0x8acf1c2b, 0x84c61120, 0xaef93211, 0xa0f03f1a, 0xb2eb2807, 0xbce2250c, 0xe6956e65, 0xe89c636e, 0xfa877473, 0xf48e7978, 0xdeb15a49, 0xd0b85742, 0xc2a3405f, 0xccaa4d54, 0x41ecdaf7, 0x4fe5d7fc, 0x5dfec0e1, 0x53f7cdea, 0x79c8eedb, 0x77c1e3d0, 0x65daf4cd, 0x6bd3f9c6, 0x31a4b2af, 0x3fadbfa4, 0x2db6a8b9, 0x23bfa5b2, 0x09808683, 0x07898b88, 0x15929c95, 0x1b9b919e, 0xa17c0a47, 0xaf75074c, 0xbd6e1051, 0xb3671d5a, 0x99583e6b, 0x97513360, 0x854a247d, 0x8b432976, 0xd134621f, 0xdf3d6f14, 0xcd267809, 0xc32f7502, 0xe9105633, 0xe7195b38, 0xf5024c25, 0xfb0b412e, 0x9ad7618c, 0x94de6c87, 0x86c57b9a, 0x88cc7691, 0xa2f355a0, 0xacfa58ab, 0xbee14fb6, 0xb0e842bd, 0xea9f09d4, 0xe49604df, 0xf68d13c2, 0xf8841ec9, 0xd2bb3df8, 0xdcb230f3, 0xcea927ee, 0xc0a02ae5, 0x7a47b13c, 0x744ebc37, 0x6655ab2a, 0x685ca621, 0x42638510, 0x4c6a881b, 0x5e719f06, 0x5078920d, 0x0a0fd964, 0x0406d46f, 0x161dc372, 0x1814ce79, 0x322bed48, 0x3c22e043, 0x2e39f75e, 0x2030fa55, 0xec9ab701, 0xe293ba0a, 0xf088ad17, 0xfe81a01c, 0xd4be832d, 0xdab78e26, 0xc8ac993b, 0xc6a59430, 0x9cd2df59, 0x92dbd252, 0x80c0c54f, 0x8ec9c844, 0xa4f6eb75, 0xaaffe67e, 0xb8e4f163, 0xb6edfc68, 0x0c0a67b1, 0x02036aba, 0x10187da7, 0x1e1170ac, 0x342e539d, 0x3a275e96, 0x283c498b, 0x26354480, 0x7c420fe9, 0x724b02e2, 0x605015ff, 0x6e5918f4, 0x44663bc5, 0x4a6f36ce, 0x587421d3, 0x567d2cd8, 0x37a10c7a, 0x39a80171, 0x2bb3166c, 0x25ba1b67, 0x0f853856, 0x018c355d, 0x13972240, 0x1d9e2f4b, 0x47e96422, 0x49e06929, 0x5bfb7e34, 0x55f2733f, 0x7fcd500e, 0x71c45d05, 0x63df4a18, 0x6dd64713, 0xd731dcca, 0xd938d1c1, 0xcb23c6dc, 0xc52acbd7, 0xef15e8e6, 0xe11ce5ed, 0xf307f2f0, 0xfd0efffb, 0xa779b492, 0xa970b999, 0xbb6bae84, 0xb562a38f, 0x9f5d80be, 0x91548db5, 0x834f9aa8, 0x8d4697a3];
var U2 = [0x00000000, 0x0b0e090d, 0x161c121a, 0x1d121b17, 0x2c382434, 0x27362d39, 0x3a24362e, 0x312a3f23, 0x58704868, 0x537e4165, 0x4e6c5a72, 0x4562537f, 0x74486c5c, 0x7f466551, 0x62547e46, 0x695a774b, 0xb0e090d0, 0xbbee99dd, 0xa6fc82ca, 0xadf28bc7, 0x9cd8b4e4, 0x97d6bde9, 0x8ac4a6fe, 0x81caaff3, 0xe890d8b8, 0xe39ed1b5, 0xfe8ccaa2, 0xf582c3af, 0xc4a8fc8c, 0xcfa6f581, 0xd2b4ee96, 0xd9bae79b, 0x7bdb3bbb, 0x70d532b6, 0x6dc729a1, 0x66c920ac, 0x57e31f8f, 0x5ced1682, 0x41ff0d95, 0x4af10498, 0x23ab73d3, 0x28a57ade, 0x35b761c9, 0x3eb968c4, 0x0f9357e7, 0x049d5eea, 0x198f45fd, 0x12814cf0, 0xcb3bab6b, 0xc035a266, 0xdd27b971, 0xd629b07c, 0xe7038f5f, 0xec0d8652, 0xf11f9d45, 0xfa119448, 0x934be303, 0x9845ea0e, 0x8557f119, 0x8e59f814, 0xbf73c737, 0xb47dce3a, 0xa96fd52d, 0xa261dc20, 0xf6ad766d, 0xfda37f60, 0xe0b16477, 0xebbf6d7a, 0xda955259, 0xd19b5b54, 0xcc894043, 0xc787494e, 0xaedd3e05, 0xa5d33708, 0xb8c12c1f, 0xb3cf2512, 0x82e51a31, 0x89eb133c, 0x94f9082b, 0x9ff70126, 0x464de6bd, 0x4d43efb0, 0x5051f4a7, 0x5b5ffdaa, 0x6a75c289, 0x617bcb84, 0x7c69d093, 0x7767d99e, 0x1e3daed5, 0x1533a7d8, 0x0821bccf, 0x032fb5c2, 0x32058ae1, 0x390b83ec, 0x241998fb, 0x2f1791f6, 0x8d764dd6, 0x867844db, 0x9b6a5fcc, 0x906456c1, 0xa14e69e2, 0xaa4060ef, 0xb7527bf8, 0xbc5c72f5, 0xd50605be, 0xde080cb3, 0xc31a17a4, 0xc8141ea9, 0xf93e218a, 0xf2302887, 0xef223390, 0xe42c3a9d, 0x3d96dd06, 0x3698d40b, 0x2b8acf1c, 0x2084c611, 0x11aef932, 0x1aa0f03f, 0x07b2eb28, 0x0cbce225, 0x65e6956e, 0x6ee89c63, 0x73fa8774, 0x78f48e79, 0x49deb15a, 0x42d0b857, 0x5fc2a340, 0x54ccaa4d, 0xf741ecda, 0xfc4fe5d7, 0xe15dfec0, 0xea53f7cd, 0xdb79c8ee, 0xd077c1e3, 0xcd65daf4, 0xc66bd3f9, 0xaf31a4b2, 0xa43fadbf, 0xb92db6a8, 0xb223bfa5, 0x83098086, 0x8807898b, 0x9515929c, 0x9e1b9b91, 0x47a17c0a, 0x4caf7507, 0x51bd6e10, 0x5ab3671d, 0x6b99583e, 0x60975133, 0x7d854a24, 0x768b4329, 0x1fd13462, 0x14df3d6f, 0x09cd2678, 0x02c32f75, 0x33e91056, 0x38e7195b, 0x25f5024c, 0x2efb0b41, 0x8c9ad761, 0x8794de6c, 0x9a86c57b, 0x9188cc76, 0xa0a2f355, 0xabacfa58, 0xb6bee14f, 0xbdb0e842, 0xd4ea9f09, 0xdfe49604, 0xc2f68d13, 0xc9f8841e, 0xf8d2bb3d, 0xf3dcb230, 0xeecea927, 0xe5c0a02a, 0x3c7a47b1, 0x37744ebc, 0x2a6655ab, 0x21685ca6, 0x10426385, 0x1b4c6a88, 0x065e719f, 0x0d507892, 0x640a0fd9, 0x6f0406d4, 0x72161dc3, 0x791814ce, 0x48322bed, 0x433c22e0, 0x5e2e39f7, 0x552030fa, 0x01ec9ab7, 0x0ae293ba, 0x17f088ad, 0x1cfe81a0, 0x2dd4be83, 0x26dab78e, 0x3bc8ac99, 0x30c6a594, 0x599cd2df, 0x5292dbd2, 0x4f80c0c5, 0x448ec9c8, 0x75a4f6eb, 0x7eaaffe6, 0x63b8e4f1, 0x68b6edfc, 0xb10c0a67, 0xba02036a, 0xa710187d, 0xac1e1170, 0x9d342e53, 0x963a275e, 0x8b283c49, 0x80263544, 0xe97c420f, 0xe2724b02, 0xff605015, 0xf46e5918, 0xc544663b, 0xce4a6f36, 0xd3587421, 0xd8567d2c, 0x7a37a10c, 0x7139a801, 0x6c2bb316, 0x6725ba1b, 0x560f8538, 0x5d018c35, 0x40139722, 0x4b1d9e2f, 0x2247e964, 0x2949e069, 0x345bfb7e, 0x3f55f273, 0x0e7fcd50, 0x0571c45d, 0x1863df4a, 0x136dd647, 0xcad731dc, 0xc1d938d1, 0xdccb23c6, 0xd7c52acb, 0xe6ef15e8, 0xede11ce5, 0xf0f307f2, 0xfbfd0eff, 0x92a779b4, 0x99a970b9, 0x84bb6bae, 0x8fb562a3, 0xbe9f5d80, 0xb591548d, 0xa8834f9a, 0xa38d4697];
var U3 = [0x00000000, 0x0d0b0e09, 0x1a161c12, 0x171d121b, 0x342c3824, 0x3927362d, 0x2e3a2436, 0x23312a3f, 0x68587048, 0x65537e41, 0x724e6c5a, 0x7f456253, 0x5c74486c, 0x517f4665, 0x4662547e, 0x4b695a77, 0xd0b0e090, 0xddbbee99, 0xcaa6fc82, 0xc7adf28b, 0xe49cd8b4, 0xe997d6bd, 0xfe8ac4a6, 0xf381caaf, 0xb8e890d8, 0xb5e39ed1, 0xa2fe8cca, 0xaff582c3, 0x8cc4a8fc, 0x81cfa6f5, 0x96d2b4ee, 0x9bd9bae7, 0xbb7bdb3b, 0xb670d532, 0xa16dc729, 0xac66c920, 0x8f57e31f, 0x825ced16, 0x9541ff0d, 0x984af104, 0xd323ab73, 0xde28a57a, 0xc935b761, 0xc43eb968, 0xe70f9357, 0xea049d5e, 0xfd198f45, 0xf012814c, 0x6bcb3bab, 0x66c035a2, 0x71dd27b9, 0x7cd629b0, 0x5fe7038f, 0x52ec0d86, 0x45f11f9d, 0x48fa1194, 0x03934be3, 0x0e9845ea, 0x198557f1, 0x148e59f8, 0x37bf73c7, 0x3ab47dce, 0x2da96fd5, 0x20a261dc, 0x6df6ad76, 0x60fda37f, 0x77e0b164, 0x7aebbf6d, 0x59da9552, 0x54d19b5b, 0x43cc8940, 0x4ec78749, 0x05aedd3e, 0x08a5d337, 0x1fb8c12c, 0x12b3cf25, 0x3182e51a, 0x3c89eb13, 0x2b94f908, 0x269ff701, 0xbd464de6, 0xb04d43ef, 0xa75051f4, 0xaa5b5ffd, 0x896a75c2, 0x84617bcb, 0x937c69d0, 0x9e7767d9, 0xd51e3dae, 0xd81533a7, 0xcf0821bc, 0xc2032fb5, 0xe132058a, 0xec390b83, 0xfb241998, 0xf62f1791, 0xd68d764d, 0xdb867844, 0xcc9b6a5f, 0xc1906456, 0xe2a14e69, 0xefaa4060, 0xf8b7527b, 0xf5bc5c72, 0xbed50605, 0xb3de080c, 0xa4c31a17, 0xa9c8141e, 0x8af93e21, 0x87f23028, 0x90ef2233, 0x9de42c3a, 0x063d96dd, 0x0b3698d4, 0x1c2b8acf, 0x112084c6, 0x3211aef9, 0x3f1aa0f0, 0x2807b2eb, 0x250cbce2, 0x6e65e695, 0x636ee89c, 0x7473fa87, 0x7978f48e, 0x5a49deb1, 0x5742d0b8, 0x405fc2a3, 0x4d54ccaa, 0xdaf741ec, 0xd7fc4fe5, 0xc0e15dfe, 0xcdea53f7, 0xeedb79c8, 0xe3d077c1, 0xf4cd65da, 0xf9c66bd3, 0xb2af31a4, 0xbfa43fad, 0xa8b92db6, 0xa5b223bf, 0x86830980, 0x8b880789, 0x9c951592, 0x919e1b9b, 0x0a47a17c, 0x074caf75, 0x1051bd6e, 0x1d5ab367, 0x3e6b9958, 0x33609751, 0x247d854a, 0x29768b43, 0x621fd134, 0x6f14df3d, 0x7809cd26, 0x7502c32f, 0x5633e910, 0x5b38e719, 0x4c25f502, 0x412efb0b, 0x618c9ad7, 0x6c8794de, 0x7b9a86c5, 0x769188cc, 0x55a0a2f3, 0x58abacfa, 0x4fb6bee1, 0x42bdb0e8, 0x09d4ea9f, 0x04dfe496, 0x13c2f68d, 0x1ec9f884, 0x3df8d2bb, 0x30f3dcb2, 0x27eecea9, 0x2ae5c0a0, 0xb13c7a47, 0xbc37744e, 0xab2a6655, 0xa621685c, 0x85104263, 0x881b4c6a, 0x9f065e71, 0x920d5078, 0xd9640a0f, 0xd46f0406, 0xc372161d, 0xce791814, 0xed48322b, 0xe0433c22, 0xf75e2e39, 0xfa552030, 0xb701ec9a, 0xba0ae293, 0xad17f088, 0xa01cfe81, 0x832dd4be, 0x8e26dab7, 0x993bc8ac, 0x9430c6a5, 0xdf599cd2, 0xd25292db, 0xc54f80c0, 0xc8448ec9, 0xeb75a4f6, 0xe67eaaff, 0xf163b8e4, 0xfc68b6ed, 0x67b10c0a, 0x6aba0203, 0x7da71018, 0x70ac1e11, 0x539d342e, 0x5e963a27, 0x498b283c, 0x44802635, 0x0fe97c42, 0x02e2724b, 0x15ff6050, 0x18f46e59, 0x3bc54466, 0x36ce4a6f, 0x21d35874, 0x2cd8567d, 0x0c7a37a1, 0x017139a8, 0x166c2bb3, 0x1b6725ba, 0x38560f85, 0x355d018c, 0x22401397, 0x2f4b1d9e, 0x642247e9, 0x692949e0, 0x7e345bfb, 0x733f55f2, 0x500e7fcd, 0x5d0571c4, 0x4a1863df, 0x47136dd6, 0xdccad731, 0xd1c1d938, 0xc6dccb23, 0xcbd7c52a, 0xe8e6ef15, 0xe5ede11c, 0xf2f0f307, 0xfffbfd0e, 0xb492a779, 0xb999a970, 0xae84bb6b, 0xa38fb562, 0x80be9f5d, 0x8db59154, 0x9aa8834f, 0x97a38d46];
var U4 = [0x00000000, 0x090d0b0e, 0x121a161c, 0x1b171d12, 0x24342c38, 0x2d392736, 0x362e3a24, 0x3f23312a, 0x48685870, 0x4165537e, 0x5a724e6c, 0x537f4562, 0x6c5c7448, 0x65517f46, 0x7e466254, 0x774b695a, 0x90d0b0e0, 0x99ddbbee, 0x82caa6fc, 0x8bc7adf2, 0xb4e49cd8, 0xbde997d6, 0xa6fe8ac4, 0xaff381ca, 0xd8b8e890, 0xd1b5e39e, 0xcaa2fe8c, 0xc3aff582, 0xfc8cc4a8, 0xf581cfa6, 0xee96d2b4, 0xe79bd9ba, 0x3bbb7bdb, 0x32b670d5, 0x29a16dc7, 0x20ac66c9, 0x1f8f57e3, 0x16825ced, 0x0d9541ff, 0x04984af1, 0x73d323ab, 0x7ade28a5, 0x61c935b7, 0x68c43eb9, 0x57e70f93, 0x5eea049d, 0x45fd198f, 0x4cf01281, 0xab6bcb3b, 0xa266c035, 0xb971dd27, 0xb07cd629, 0x8f5fe703, 0x8652ec0d, 0x9d45f11f, 0x9448fa11, 0xe303934b, 0xea0e9845, 0xf1198557, 0xf8148e59, 0xc737bf73, 0xce3ab47d, 0xd52da96f, 0xdc20a261, 0x766df6ad, 0x7f60fda3, 0x6477e0b1, 0x6d7aebbf, 0x5259da95, 0x5b54d19b, 0x4043cc89, 0x494ec787, 0x3e05aedd, 0x3708a5d3, 0x2c1fb8c1, 0x2512b3cf, 0x1a3182e5, 0x133c89eb, 0x082b94f9, 0x01269ff7, 0xe6bd464d, 0xefb04d43, 0xf4a75051, 0xfdaa5b5f, 0xc2896a75, 0xcb84617b, 0xd0937c69, 0xd99e7767, 0xaed51e3d, 0xa7d81533, 0xbccf0821, 0xb5c2032f, 0x8ae13205, 0x83ec390b, 0x98fb2419, 0x91f62f17, 0x4dd68d76, 0x44db8678, 0x5fcc9b6a, 0x56c19064, 0x69e2a14e, 0x60efaa40, 0x7bf8b752, 0x72f5bc5c, 0x05bed506, 0x0cb3de08, 0x17a4c31a, 0x1ea9c814, 0x218af93e, 0x2887f230, 0x3390ef22, 0x3a9de42c, 0xdd063d96, 0xd40b3698, 0xcf1c2b8a, 0xc6112084, 0xf93211ae, 0xf03f1aa0, 0xeb2807b2, 0xe2250cbc, 0x956e65e6, 0x9c636ee8, 0x877473fa, 0x8e7978f4, 0xb15a49de, 0xb85742d0, 0xa3405fc2, 0xaa4d54cc, 0xecdaf741, 0xe5d7fc4f, 0xfec0e15d, 0xf7cdea53, 0xc8eedb79, 0xc1e3d077, 0xdaf4cd65, 0xd3f9c66b, 0xa4b2af31, 0xadbfa43f, 0xb6a8b92d, 0xbfa5b223, 0x80868309, 0x898b8807, 0x929c9515, 0x9b919e1b, 0x7c0a47a1, 0x75074caf, 0x6e1051bd, 0x671d5ab3, 0x583e6b99, 0x51336097, 0x4a247d85, 0x4329768b, 0x34621fd1, 0x3d6f14df, 0x267809cd, 0x2f7502c3, 0x105633e9, 0x195b38e7, 0x024c25f5, 0x0b412efb, 0xd7618c9a, 0xde6c8794, 0xc57b9a86, 0xcc769188, 0xf355a0a2, 0xfa58abac, 0xe14fb6be, 0xe842bdb0, 0x9f09d4ea, 0x9604dfe4, 0x8d13c2f6, 0x841ec9f8, 0xbb3df8d2, 0xb230f3dc, 0xa927eece, 0xa02ae5c0, 0x47b13c7a, 0x4ebc3774, 0x55ab2a66, 0x5ca62168, 0x63851042, 0x6a881b4c, 0x719f065e, 0x78920d50, 0x0fd9640a, 0x06d46f04, 0x1dc37216, 0x14ce7918, 0x2bed4832, 0x22e0433c, 0x39f75e2e, 0x30fa5520, 0x9ab701ec, 0x93ba0ae2, 0x88ad17f0, 0x81a01cfe, 0xbe832dd4, 0xb78e26da, 0xac993bc8, 0xa59430c6, 0xd2df599c, 0xdbd25292, 0xc0c54f80, 0xc9c8448e, 0xf6eb75a4, 0xffe67eaa, 0xe4f163b8, 0xedfc68b6, 0x0a67b10c, 0x036aba02, 0x187da710, 0x1170ac1e, 0x2e539d34, 0x275e963a, 0x3c498b28, 0x35448026, 0x420fe97c, 0x4b02e272, 0x5015ff60, 0x5918f46e, 0x663bc544, 0x6f36ce4a, 0x7421d358, 0x7d2cd856, 0xa10c7a37, 0xa8017139, 0xb3166c2b, 0xba1b6725, 0x8538560f, 0x8c355d01, 0x97224013, 0x9e2f4b1d, 0xe9642247, 0xe0692949, 0xfb7e345b, 0xf2733f55, 0xcd500e7f, 0xc45d0571, 0xdf4a1863, 0xd647136d, 0x31dccad7, 0x38d1c1d9, 0x23c6dccb, 0x2acbd7c5, 0x15e8e6ef, 0x1ce5ede1, 0x07f2f0f3, 0x0efffbfd, 0x79b492a7, 0x70b999a9, 0x6bae84bb, 0x62a38fb5, 0x5d80be9f, 0x548db591, 0x4f9aa883, 0x4697a38d];
function convertToInt32(bytes) {
var result = [];
for (var i = 0; i < bytes.length; i += 4) {
result.push(
(bytes[i ] << 24) |
(bytes[i + 1] << 16) |
(bytes[i + 2] << 8) |
bytes[i + 3]
);
}
return result;
}
var AES = function(key) {
if (!(this instanceof AES)) {
throw Error('AES must be instanitated with `new`');
}
Object.defineProperty(this, 'key', {
value: coerceArray(key, true)
});
this._prepare();
}
AES.prototype._prepare = function() {
var rounds = numberOfRounds[this.key.length];
if (rounds == null) {
throw new Error('invalid key size (must be 16, 24 or 32 bytes)');
}
// encryption round keys
this._Ke = [];
// decryption round keys
this._Kd = [];
for (var i = 0; i <= rounds; i++) {
this._Ke.push([0, 0, 0, 0]);
this._Kd.push([0, 0, 0, 0]);
}
var roundKeyCount = (rounds + 1) * 4;
var KC = this.key.length / 4;
// convert the key into ints
var tk = convertToInt32(this.key);
// copy values into round key arrays
var index;
for (var i = 0; i < KC; i++) {
index = i >> 2;
this._Ke[index][i % 4] = tk[i];
this._Kd[rounds - index][i % 4] = tk[i];
}
// key expansion (fips-197 section 5.2)
var rconpointer = 0;
var t = KC, tt;
while (t < roundKeyCount) {
tt = tk[KC - 1];
tk[0] ^= ((S[(tt >> 16) & 0xFF] << 24) ^
(S[(tt >> 8) & 0xFF] << 16) ^
(S[ tt & 0xFF] << 8) ^
S[(tt >> 24) & 0xFF] ^
(rcon[rconpointer] << 24));
rconpointer += 1;
// key expansion (for non-256 bit)
if (KC != 8) {
for (var i = 1; i < KC; i++) {
tk[i] ^= tk[i - 1];
}
// key expansion for 256-bit keys is "slightly different" (fips-197)
} else {
for (var i = 1; i < (KC / 2); i++) {
tk[i] ^= tk[i - 1];
}
tt = tk[(KC / 2) - 1];
tk[KC / 2] ^= (S[ tt & 0xFF] ^
(S[(tt >> 8) & 0xFF] << 8) ^
(S[(tt >> 16) & 0xFF] << 16) ^
(S[(tt >> 24) & 0xFF] << 24));
for (var i = (KC / 2) + 1; i < KC; i++) {
tk[i] ^= tk[i - 1];
}
}
// copy values into round key arrays
var i = 0, r, c;
while (i < KC && t < roundKeyCount) {
r = t >> 2;
c = t % 4;
this._Ke[r][c] = tk[i];
this._Kd[rounds - r][c] = tk[i++];
t++;
}
}
// inverse-cipher-ify the decryption round key (fips-197 section 5.3)
for (var r = 1; r < rounds; r++) {
for (var c = 0; c < 4; c++) {
tt = this._Kd[r][c];
this._Kd[r][c] = (U1[(tt >> 24) & 0xFF] ^
U2[(tt >> 16) & 0xFF] ^
U3[(tt >> 8) & 0xFF] ^
U4[ tt & 0xFF]);
}
}
}
AES.prototype.encrypt = function(plaintext) {
if (plaintext.length != 16) {
throw new Error('invalid plaintext size (must be 16 bytes)');
}
var rounds = this._Ke.length - 1;
var a = [0, 0, 0, 0];
// convert plaintext to (ints ^ key)
var t = convertToInt32(plaintext);
for (var i = 0; i < 4; i++) {
t[i] ^= this._Ke[0][i];
}
// apply round transforms
for (var r = 1; r < rounds; r++) {
for (var i = 0; i < 4; i++) {
a[i] = (T1[(t[ i ] >> 24) & 0xff] ^
T2[(t[(i + 1) % 4] >> 16) & 0xff] ^
T3[(t[(i + 2) % 4] >> 8) & 0xff] ^
T4[ t[(i + 3) % 4] & 0xff] ^
this._Ke[r][i]);
}
t = a.slice();
}
// the last round is special
var result = createArray(16), tt;
for (var i = 0; i < 4; i++) {
tt = this._Ke[rounds][i];
result[4 * i ] = (S[(t[ i ] >> 24) & 0xff] ^ (tt >> 24)) & 0xff;
result[4 * i + 1] = (S[(t[(i + 1) % 4] >> 16) & 0xff] ^ (tt >> 16)) & 0xff;
result[4 * i + 2] = (S[(t[(i + 2) % 4] >> 8) & 0xff] ^ (tt >> 8)) & 0xff;
result[4 * i + 3] = (S[ t[(i + 3) % 4] & 0xff] ^ tt ) & 0xff;
}
return result;
}
AES.prototype.decrypt = function(ciphertext) {
if (ciphertext.length != 16) {
throw new Error('invalid ciphertext size (must be 16 bytes)');
}
var rounds = this._Kd.length - 1;
var a = [0, 0, 0, 0];
// convert plaintext to (ints ^ key)
var t = convertToInt32(ciphertext);
for (var i = 0; i < 4; i++) {
t[i] ^= this._Kd[0][i];
}
// apply round transforms
for (var r = 1; r < rounds; r++) {
for (var i = 0; i < 4; i++) {
a[i] = (T5[(t[ i ] >> 24) & 0xff] ^
T6[(t[(i + 3) % 4] >> 16) & 0xff] ^
T7[(t[(i + 2) % 4] >> 8) & 0xff] ^
T8[ t[(i + 1) % 4] & 0xff] ^
this._Kd[r][i]);
}
t = a.slice();
}
// the last round is special
var result = createArray(16), tt;
for (var i = 0; i < 4; i++) {
tt = this._Kd[rounds][i];
result[4 * i ] = (Si[(t[ i ] >> 24) & 0xff] ^ (tt >> 24)) & 0xff;
result[4 * i + 1] = (Si[(t[(i + 3) % 4] >> 16) & 0xff] ^ (tt >> 16)) & 0xff;
result[4 * i + 2] = (Si[(t[(i + 2) % 4] >> 8) & 0xff] ^ (tt >> 8)) & 0xff;
result[4 * i + 3] = (Si[ t[(i + 1) % 4] & 0xff] ^ tt ) & 0xff;
}
return result;
}
/**
* Mode Of Operation - Electonic Codebook (ECB)
*/
var ModeOfOperationECB = function(key) {
if (!(this instanceof ModeOfOperationECB)) {
throw Error('AES must be instanitated with `new`');
}
this.description = "Electronic Code Block";
this.name = "ecb";
this._aes = new AES(key);
}
ModeOfOperationECB.prototype.encrypt = function(plaintext) {
plaintext = coerceArray(plaintext);
if ((plaintext.length % 16) !== 0) {
throw new Error('invalid plaintext size (must be multiple of 16 bytes)');
}
var ciphertext = createArray(plaintext.length);
var block = createArray(16);
for (var i = 0; i < plaintext.length; i += 16) {
copyArray(plaintext, block, 0, i, i + 16);
block = this._aes.encrypt(block);
copyArray(block, ciphertext, i);
}
return ciphertext;
}
ModeOfOperationECB.prototype.decrypt = function(ciphertext) {
ciphertext = coerceArray(ciphertext);
if ((ciphertext.length % 16) !== 0) {
throw new Error('invalid ciphertext size (must be multiple of 16 bytes)');
}
var plaintext = createArray(ciphertext.length);
var block = createArray(16);
for (var i = 0; i < ciphertext.length; i += 16) {
copyArray(ciphertext, block, 0, i, i + 16);
block = this._aes.decrypt(block);
copyArray(block, plaintext, i);
}
return plaintext;
}
/**
* Mode Of Operation - Cipher Block Chaining (CBC)
*/
var ModeOfOperationCBC = function(key, iv) {
if (!(this instanceof ModeOfOperationCBC)) {
throw Error('AES must be instanitated with `new`');
}
this.description = "Cipher Block Chaining";
this.name = "cbc";
if (!iv) {
iv = createArray(16);
} else if (iv.length != 16) {
throw new Error('invalid initialation vector size (must be 16 bytes)');
}
this._lastCipherblock = coerceArray(iv, true);
this._aes = new AES(key);
}
ModeOfOperationCBC.prototype.encrypt = function(plaintext) {
plaintext = coerceArray(plaintext);
if ((plaintext.length % 16) !== 0) {
throw new Error('invalid plaintext size (must be multiple of 16 bytes)');
}
var ciphertext = createArray(plaintext.length);
var block = createArray(16);
for (var i = 0; i < plaintext.length; i += 16) {
copyArray(plaintext, block, 0, i, i + 16);
for (var j = 0; j < 16; j++) {
block[j] ^= this._lastCipherblock[j];
}
this._lastCipherblock = this._aes.encrypt(block);
copyArray(this._lastCipherblock, ciphertext, i);
}
return ciphertext;
}
ModeOfOperationCBC.prototype.decrypt = function(ciphertext) {
ciphertext = coerceArray(ciphertext);
if ((ciphertext.length % 16) !== 0) {
throw new Error('invalid ciphertext size (must be multiple of 16 bytes)');
}
var plaintext = createArray(ciphertext.length);
var block = createArray(16);
for (var i = 0; i < ciphertext.length; i += 16) {
copyArray(ciphertext, block, 0, i, i + 16);
block = this._aes.decrypt(block);
for (var j = 0; j < 16; j++) {
plaintext[i + j] = block[j] ^ this._lastCipherblock[j];
}
copyArray(ciphertext, this._lastCipherblock, 0, i, i + 16);
}
return plaintext;
}
/**
* Mode Of Operation - Cipher Feedback (CFB)
*/
var ModeOfOperationCFB = function(key, iv, segmentSize) {
if (!(this instanceof ModeOfOperationCFB)) {
throw Error('AES must be instanitated with `new`');
}
this.description = "Cipher Feedback";
this.name = "cfb";
if (!iv) {
iv = createArray(16);
} else if (iv.length != 16) {
throw new Error('invalid initialation vector size (must be 16 size)');
}
if (!segmentSize) { segmentSize = 1; }
this.segmentSize = segmentSize;
this._shiftRegister = coerceArray(iv, true);
this._aes = new AES(key);
}
ModeOfOperationCFB.prototype.encrypt = function(plaintext) {
if ((plaintext.length % this.segmentSize) != 0) {
throw new Error('invalid plaintext size (must be segmentSize bytes)');
}
var encrypted = coerceArray(plaintext, true);
var xorSegment;
for (var i = 0; i < encrypted.length; i += this.segmentSize) {
xorSegment = this._aes.encrypt(this._shiftRegister);
for (var j = 0; j < this.segmentSize; j++) {
encrypted[i + j] ^= xorSegment[j];
}
// Shift the register
copyArray(this._shiftRegister, this._shiftRegister, 0, this.segmentSize);
copyArray(encrypted, this._shiftRegister, 16 - this.segmentSize, i, i + this.segmentSize);
}
return encrypted;
}
ModeOfOperationCFB.prototype.decrypt = function(ciphertext) {
if ((ciphertext.length % this.segmentSize) != 0) {
throw new Error('invalid ciphertext size (must be segmentSize bytes)');
}
var plaintext = coerceArray(ciphertext, true);
var xorSegment;
for (var i = 0; i < plaintext.length; i += this.segmentSize) {
xorSegment = this._aes.encrypt(this._shiftRegister);
for (var j = 0; j < this.segmentSize; j++) {
plaintext[i + j] ^= xorSegment[j];
}
// Shift the register
copyArray(this._shiftRegister, this._shiftRegister, 0, this.segmentSize);
copyArray(ciphertext, this._shiftRegister, 16 - this.segmentSize, i, i + this.segmentSize);
}
return plaintext;
}
/**
* Mode Of Operation - Output Feedback (OFB)
*/
var ModeOfOperationOFB = function(key, iv) {
if (!(this instanceof ModeOfOperationOFB)) {
throw Error('AES must be instanitated with `new`');
}
this.description = "Output Feedback";
this.name = "ofb";
if (!iv) {
iv = createArray(16);
} else if (iv.length != 16) {
throw new Error('invalid initialation vector size (must be 16 bytes)');
}
this._lastPrecipher = coerceArray(iv, true);
this._lastPrecipherIndex = 16;
this._aes = new AES(key);
}
ModeOfOperationOFB.prototype.encrypt = function(plaintext) {
var encrypted = coerceArray(plaintext, true);
for (var i = 0; i < encrypted.length; i++) {
if (this._lastPrecipherIndex === 16) {
this._lastPrecipher = this._aes.encrypt(this._lastPrecipher);
this._lastPrecipherIndex = 0;
}
encrypted[i] ^= this._lastPrecipher[this._lastPrecipherIndex++];
}
return encrypted;
}
// Decryption is symetric
ModeOfOperationOFB.prototype.decrypt = ModeOfOperationOFB.prototype.encrypt;
/**
* Counter object for CTR common mode of operation
*/
var Counter = function(initialValue) {
if (!(this instanceof Counter)) {
throw Error('Counter must be instanitated with `new`');
}
// We allow 0, but anything false-ish uses the default 1
if (initialValue !== 0 && !initialValue) { initialValue = 1; }
if (typeof(initialValue) === 'number') {
this._counter = createArray(16);
this.setValue(initialValue);
} else {
this.setBytes(initialValue);
}
}
Counter.prototype.setValue = function(value) {
if (typeof(value) !== 'number' || parseInt(value) != value) {
throw new Error('invalid counter value (must be an integer)');
}
// We cannot safely handle numbers beyond the safe range for integers
if (value > Number.MAX_SAFE_INTEGER) {
throw new Error('integer value out of safe range');
}
for (var index = 15; index >= 0; --index) {
this._counter[index] = value % 256;
value = parseInt(value / 256);
}
}
Counter.prototype.setBytes = function(bytes) {
bytes = coerceArray(bytes, true);
if (bytes.length != 16) {
throw new Error('invalid counter bytes size (must be 16 bytes)');
}
this._counter = bytes;
};
Counter.prototype.increment = function() {
for (var i = 15; i >= 0; i--) {
if (this._counter[i] === 255) {
this._counter[i] = 0;
} else {
this._counter[i]++;
break;
}
}
}
/**
* Mode Of Operation - Counter (CTR)
*/
var ModeOfOperationCTR = function(key, counter) {
if (!(this instanceof ModeOfOperationCTR)) {
throw Error('AES must be instanitated with `new`');
}
this.description = "Counter";
this.name = "ctr";
if (!(counter instanceof Counter)) {
counter = new Counter(counter)
}
this._counter = counter;
this._remainingCounter = null;
this._remainingCounterIndex = 16;
this._aes = new AES(key);
}
ModeOfOperationCTR.prototype.encrypt = function(plaintext) {
var encrypted = coerceArray(plaintext, true);
for (var i = 0; i < encrypted.length; i++) {
if (this._remainingCounterIndex === 16) {
this._remainingCounter = this._aes.encrypt(this._counter._counter);
this._remainingCounterIndex = 0;
this._counter.increment();
}
encrypted[i] ^= this._remainingCounter[this._remainingCounterIndex++];
}
return encrypted;
}
// Decryption is symetric
ModeOfOperationCTR.prototype.decrypt = ModeOfOperationCTR.prototype.encrypt;
///////////////////////
// Padding
// See:https://tools.ietf.org/html/rfc2315
function pkcs7pad(data) {
data = coerceArray(data, true);
var padder = 16 - (data.length % 16);
var result = createArray(data.length + padder);
copyArray(data, result);
for (var i = data.length; i < result.length; i++) {
result[i] = padder;
}
return result;
}
function pkcs7strip(data) {
data = coerceArray(data, true);
if (data.length < 16) { throw new Error('PKCS#7 invalid length'); }
var padder = data[data.length - 1];
if (padder > 16) { throw new Error('PKCS#7 padding byte out of range'); }
var length = data.length - padder;
for (var i = 0; i < padder; i++) {
if (data[length + i] !== padder) {
throw new Error('PKCS#7 invalid padding byte');
}
}
var result = createArray(length);
copyArray(data, result, 0, 0, length);
return result;
}
///////////////////////
// Exporting
// The block cipher
var aesjs = {
AES: AES,
Counter: Counter,
ModeOfOperation: {
ecb: ModeOfOperationECB,
cbc: ModeOfOperationCBC,
cfb: ModeOfOperationCFB,
ofb: ModeOfOperationOFB,
ctr: ModeOfOperationCTR
},
utils: {
hex: convertHex,
utf8: convertUtf8
},
padding: {
pkcs7: {
pad: pkcs7pad,
strip: pkcs7strip
}
},
_arrayTest: {
coerceArray: coerceArray,
createArray: createArray,
copyArray: copyArray,
}
};
// node.js
if (typeof exports !== 'undefined') {
module.exports = aesjs
// RequireJS/AMD
// http://www.requirejs.org/docs/api.html
// https://github.com/amdjs/amdjs-api/wiki/AMD
} else if (typeof(define) === 'function' && define.amd) {
define([], function() { return aesjs; });
// Web Browsers
} else {
// If there was an existing library at "aesjs" make sure it's still available
if (root.aesjs) {
aesjs._aesjs = root.aesjs;
}
root.aesjs = aesjs;
}
})(globalThis);
const aesjs = globalThis.aesjs;
export default aesjs;
export const { AES, Counter, ModeOfOperation, utils, padding } = aesjs;

View File

@ -0,0 +1,48 @@
CREATE TABLE IF NOT EXISTS "archive_entry_index" (
"volume_name" TEXT NOT NULL,
"entry_name" TEXT NOT NULL,
"local_header_offset_plain" INTEGER NOT NULL,
"data_offset_plain" INTEGER NOT NULL,
"compressed_size" INTEGER NOT NULL,
"uncompressed_size" INTEGER NOT NULL,
"compression_method" TEXT NOT NULL,
"crc32" TEXT NOT NULL,
PRIMARY KEY ("volume_name", "entry_name")
);
CREATE INDEX IF NOT EXISTS "archive_entry_index_volume_offset"
ON "archive_entry_index" ("volume_name", "data_offset_plain");
CREATE TABLE IF NOT EXISTS "volume_crypto_cache" (
"volume_name" TEXT PRIMARY KEY,
"stream_format" TEXT NULL,
"header_probe_bytes" INTEGER NULL,
"kdf_iterations" INTEGER NULL,
"salt_hex" TEXT NULL,
"iv_hex" TEXT NULL,
"cipher" TEXT NULL,
"integrity" TEXT NULL
);
CREATE TABLE IF NOT EXISTS "volume_scan_inventory" (
"volume_name" TEXT PRIMARY KEY,
"remote_type" TEXT NULL,
"remote_size" INTEGER NULL,
"remote_hash" TEXT NULL,
"plain_zip_size" INTEGER NULL,
"entry_count" INTEGER NULL,
"scan_status" TEXT NOT NULL,
"scanned_at" TEXT NOT NULL,
"error_code" TEXT NULL
);
CREATE TABLE IF NOT EXISTS "enhancement_meta" (
"id" INTEGER PRIMARY KEY CHECK ("id" = 1),
"schema_version" TEXT NOT NULL,
"generator_version" TEXT NOT NULL,
"created_at" TEXT NOT NULL,
"source_layout" TEXT NOT NULL,
"blocksize" TEXT NULL,
"blockhash" TEXT NULL,
"filehash" TEXT NULL
);

21
src/config.js Normal file
View File

@ -0,0 +1,21 @@
import path from 'node:path';
function parsePositiveInteger(value, fallback) {
if (value === undefined || value === null || value === '') {
return fallback;
}
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
export const config = {
port: parsePositiveInteger(process.env.PORT, 3000),
appDbPath:
process.env.APP_DB_PATH ??
path.resolve(process.cwd(), 'data', 'app.sqlite'),
uploadDir:
process.env.UPLOAD_DIR ??
path.resolve(process.cwd(), 'data', 'sources'),
previewMaxBytes: parsePositiveInteger(process.env.PREVIEW_MAX_BYTES, 16 * 1024 * 1024)
};

View File

@ -0,0 +1,515 @@
import mimeTypes from 'mime-types';
import { HttpError } from '../errors.js';
import { decodeOpaqueId, encodeOpaqueId } from '../lib/fileId.js';
import {
buildResolvedFilesetId,
computeCanonicalBlockSize,
inferPreviewKind,
normalizeCompressionMethod,
normalizeCrc32,
stripAesSuffix,
toZipEntryName
} from '../lib/duplicati.js';
import {
getImmediateChildName,
getNameFromApiPath,
getParentApiPath,
isSameOrDescendant,
joinApiPath,
normalizeApiPath,
toApiPath
} from '../lib/paths.js';
import { duplicatiTicksToIso, unixSecondsToIso } from '../lib/time.js';
function buildFileRowProjectionSql(sourceLayout) {
if (sourceLayout === 'file_lookup') {
return `
SELECT
"FileLookup"."ID" AS "fileId",
${buildPathExpressionSql(sourceLayout)} AS "dbPath",
"FileLookup"."BlocksetID" AS "blocksetId",
"FileLookup"."MetadataID" AS "metadataId",
"FilesetEntry"."Lastmodified" AS "lastModifiedTicks",
"Blockset"."Length" AS "contentLength",
"Blockset"."FullHash" AS "fullHash"
`;
}
return `
SELECT
"File"."ID" AS "fileId",
"File"."Path" AS "dbPath",
"File"."BlocksetID" AS "blocksetId",
"File"."MetadataID" AS "metadataId",
"FilesetEntry"."Lastmodified" AS "lastModifiedTicks",
"Blockset"."Length" AS "contentLength",
"Blockset"."FullHash" AS "fullHash"
`;
}
function buildPathExpressionSql(sourceLayout) {
if (sourceLayout === 'file_lookup') {
return `(COALESCE("PathPrefix"."Prefix", '') || "FileLookup"."Path")`;
}
return `"File"."Path"`;
}
function buildFileRowSourceSql(sourceLayout) {
if (sourceLayout === 'file_lookup') {
return `
FROM source."FilesetEntry"
JOIN source."FileLookup" ON "FileLookup"."ID" = "FilesetEntry"."FileID"
LEFT JOIN source."PathPrefix" ON "PathPrefix"."ID" = "FileLookup"."PrefixID"
LEFT JOIN source."Blockset" ON "Blockset"."ID" = "FileLookup"."BlocksetID"
`;
}
return `
FROM source."FilesetEntry"
JOIN source."File" ON "File"."ID" = "FilesetEntry"."FileID"
LEFT JOIN source."Blockset" ON "Blockset"."ID" = "File"."BlocksetID"
`;
}
function buildListSnapshotRowsSql(sourceLayout) {
return `
${buildFileRowProjectionSql(sourceLayout)}
${buildFileRowSourceSql(sourceLayout)}
WHERE "FilesetEntry"."FilesetID" = ?
ORDER BY "dbPath" ASC
`;
}
function buildGetFileRowSql(sourceLayout) {
return `
${buildFileRowProjectionSql(sourceLayout)}
${buildFileRowSourceSql(sourceLayout)}
WHERE "FilesetEntry"."FilesetID" = ?
AND ${buildPathExpressionSql(sourceLayout)} = ?
ORDER BY "FilesetEntry"."Lastmodified" DESC
LIMIT 1
`;
}
function buildSegmentRowsSql(includeVolumeCryptoCache) {
return `
SELECT
"BlocksetEntry"."Index" AS "segmentIndex",
"Block"."Hash" AS "blockHashBase64",
"Block"."Size" AS "logicalSize",
"Block"."VolumeID" AS "remoteVolumeId",
"Remotevolume"."Name" AS "volumeName",
"archive_entry_index"."entry_name" AS "entryName",
"archive_entry_index"."local_header_offset_plain" AS "localHeaderOffsetPlain",
"archive_entry_index"."data_offset_plain" AS "dataOffsetPlain",
"archive_entry_index"."compressed_size" AS "compressedSize",
"archive_entry_index"."uncompressed_size" AS "uncompressedSize",
"archive_entry_index"."compression_method" AS "compressionMethod",
"archive_entry_index"."crc32" AS "crc32",
${
includeVolumeCryptoCache
? `source."volume_crypto_cache"."stream_format" AS "streamFormat",
source."volume_crypto_cache"."header_probe_bytes" AS "headerProbeBytes",
source."volume_crypto_cache"."kdf_iterations" AS "kdfIterations",
source."volume_crypto_cache"."salt_hex" AS "saltHex",
source."volume_crypto_cache"."iv_hex" AS "ivHex"`
: `NULL AS "streamFormat",
NULL AS "headerProbeBytes",
NULL AS "kdfIterations",
NULL AS "saltHex",
NULL AS "ivHex"`
}
FROM source."BlocksetEntry"
JOIN source."Block" ON "Block"."ID" = "BlocksetEntry"."BlockID"
JOIN source."Remotevolume" ON "Remotevolume"."ID" = "Block"."VolumeID"
LEFT JOIN source."archive_entry_index"
ON "archive_entry_index"."volume_name" = "Remotevolume"."Name"
AND "archive_entry_index"."entry_name" = REPLACE(REPLACE("Block"."Hash", '/', '_'), '+', '-')
${includeVolumeCryptoCache ? 'LEFT JOIN source."volume_crypto_cache" ON source."volume_crypto_cache"."volume_name" = "Remotevolume"."Name"' : ''}
WHERE "BlocksetEntry"."BlocksetID" = ?
ORDER BY "BlocksetEntry"."Index" ASC
`;
}
function resolveMime(apiPath, type) {
if (type !== 'file') {
return null;
}
const result = mimeTypes.lookup(apiPath);
return result || null;
}
function buildSnapshotShape(selection, row) {
const timestamp = unixSecondsToIso(row.Timestamp ?? row.timestamp);
return {
id: selection,
filesetDbId: Number(row.ID ?? row.id),
resolvedFilesetId: buildResolvedFilesetId(timestamp),
timestamp
};
}
function encodeEntryId(snapshot, kind, source, dbPath, apiPath) {
return encodeOpaqueId({
kind,
sourceId: source.id,
snapshotId: snapshot.id,
filesetDbId: snapshot.filesetDbId,
resolvedFilesetId: snapshot.resolvedFilesetId,
timestamp: snapshot.timestamp,
dbPath,
apiPath
});
}
function makeDirectoryEntry(snapshot, source, basePath, childName, existing = undefined) {
const path = joinApiPath(basePath, childName);
return {
id:
existing?.id ??
encodeEntryId(snapshot, 'dir', source, existing?.dbPath ?? null, path),
type: 'dir',
name: childName,
path,
size: null,
mtime: existing?.mtime ?? null,
mime: null
};
}
function makeFileEntry(snapshot, source, row) {
const path = toApiPath(row.dbPath);
const mime = resolveMime(path, 'file');
const preview = inferPreviewKind(mime);
const size = row.contentLength === null || row.contentLength === undefined ? null : Number(row.contentLength);
const fullHash = row.fullHash ?? null;
return {
id: encodeEntryId(snapshot, 'file', source, row.dbPath, path),
type: 'file',
name: getNameFromApiPath(path),
path,
size,
mtime: duplicatiTicksToIso(row.lastModifiedTicks),
mime,
...(fullHash
? {
hash: {
algo: 'sha256',
base64: fullHash
}
}
: {}),
hints: {
preview,
download: true
}
};
}
function sortEntries(entries) {
return entries.sort((left, right) => {
if (left.type !== right.type) {
return left.type === 'dir' ? -1 : 1;
}
return left.name.localeCompare(right.name, 'en');
});
}
export class DuplicatiRepository {
constructor({ database, previewMaxBytes, source, sourceLayout }) {
this.database = database;
this.previewMaxBytes = previewMaxBytes;
this.source = source;
this.sourceLayout = sourceLayout;
}
async resolveSnapshot(selection = 'latest') {
if (selection === 'latest') {
const row = await this.database.get(
'SELECT "ID", "Timestamp" FROM source."Fileset" ORDER BY "Timestamp" DESC LIMIT 1'
);
if (!row) {
throw new HttpError(404, 'SNAPSHOT_NOT_FOUND', 'No filesets were found in the uploaded database.');
}
return buildSnapshotShape('latest', row);
}
if (/^\d+$/.test(selection)) {
const row = await this.database.get(
'SELECT "ID", "Timestamp" FROM source."Fileset" WHERE "ID" = ?',
[selection]
);
if (!row) {
throw new HttpError(404, 'SNAPSHOT_NOT_FOUND', `Fileset ${selection} was not found.`);
}
return buildSnapshotShape(selection, row);
}
throw new HttpError(
400,
'INVALID_SNAPSHOT',
'Snapshot must be "latest" or a numeric Fileset ID.'
);
}
async listDirectory({ apiPath = '/', snapshotId = 'latest' }) {
const normalizedPath = normalizeApiPath(apiPath);
const snapshot = await this.resolveSnapshot(snapshotId);
const rows = await this.database.all(buildListSnapshotRowsSql(this.sourceLayout), [snapshot.filesetDbId]);
const entriesByPath = new Map();
let directoryExists = normalizedPath === '/';
for (const row of rows) {
const candidateApiPath = toApiPath(row.dbPath);
const directMatch = candidateApiPath === normalizedPath;
if (directMatch && Number(row.blocksetId) < 0) {
directoryExists = true;
}
if (!isSameOrDescendant(normalizedPath, candidateApiPath) || directMatch) {
continue;
}
directoryExists = true;
const childName = getImmediateChildName(normalizedPath, candidateApiPath);
if (!childName) {
continue;
}
const childPath = joinApiPath(normalizedPath, childName);
const isDirectChild = childPath === candidateApiPath;
const isDirectoryRow = Number(row.blocksetId) < 0;
if (!isDirectChild || isDirectoryRow) {
const existing = entriesByPath.get(childPath);
entriesByPath.set(
childPath,
makeDirectoryEntry(snapshot, this.source, normalizedPath, childName, {
...existing,
dbPath: row.dbPath,
mtime: existing?.mtime ?? duplicatiTicksToIso(row.lastModifiedTicks)
})
);
continue;
}
entriesByPath.set(childPath, makeFileEntry(snapshot, this.source, row));
}
if (!directoryExists) {
throw new HttpError(404, 'PATH_NOT_FOUND', `Backup path "${normalizedPath}" was not found.`);
}
return {
apiVersion: '1',
sourceId: this.source.id,
snapshot: {
id: snapshot.id,
resolvedFilesetId: snapshot.resolvedFilesetId,
timestamp: snapshot.timestamp
},
path: normalizedPath,
parent: getParentApiPath(normalizedPath),
entries: sortEntries([...entriesByPath.values()])
};
}
async getFileInfo(id) {
let token;
try {
token = decodeOpaqueId(id);
} catch (error) {
throw new HttpError(400, 'INVALID_FILE_ID', 'The provided file id is malformed.');
}
if (token.kind !== 'file') {
throw new HttpError(400, 'NOT_A_FILE', 'The provided id does not point to a file.');
}
if (!token.sourceId) {
throw new HttpError(400, 'INVALID_FILE_ID', 'The provided file id is missing source metadata.');
}
if (token.sourceId !== this.source.id) {
throw new HttpError(
409,
'STALE_SOURCE_ID',
'The requested file id belongs to a different source. Refresh the file list and try again.'
);
}
const snapshot = {
id: token.snapshotId ?? 'latest',
filesetDbId: Number(token.filesetDbId),
resolvedFilesetId: token.resolvedFilesetId,
timestamp: token.timestamp
};
if (!snapshot.filesetDbId || !token.dbPath) {
throw new HttpError(400, 'INVALID_FILE_ID', 'The provided file id is missing required fields.');
}
const fileRow = await this.database.get(buildGetFileRowSql(this.sourceLayout), [snapshot.filesetDbId, token.dbPath]);
if (!fileRow) {
throw new HttpError(404, 'FILE_NOT_FOUND', 'The requested file was not found in the selected snapshot.');
}
if (Number(fileRow.blocksetId) < 0) {
throw new HttpError(400, 'NOT_A_FILE', 'The provided id points to a directory entry.');
}
const fileSize = Number(fileRow.contentLength ?? 0);
const apiPath = token.apiPath ?? toApiPath(fileRow.dbPath);
const mime = resolveMime(apiPath, 'file');
const segmentRows =
fileSize === 0
? []
: await this.database.all(
buildSegmentRowsSql(this.source.capabilities.volumeCryptoCache),
[Number(fileRow.blocksetId)]
);
if (fileSize > 0 && !segmentRows.length) {
throw new HttpError(
409,
'BLOCKSET_EMPTY',
'The file has non-zero length but no block rows were found in BlocksetEntry.'
);
}
const missingZipIndex = segmentRows
.filter((row) => row.dataOffsetPlain === null || row.entryName === null)
.map((row) => ({
volumeName: row.volumeName,
blockHashBase64: row.blockHashBase64
}));
if (missingZipIndex.length > 0) {
throw new HttpError(
409,
'ZIP_INDEX_MISSING',
'archive_entry_index is incomplete for one or more required dblock entries.',
{ missing: missingZipIndex }
);
}
const volumes = [];
const volumesByRemoteId = new Map();
const segments = [];
let logicalOffset = 0;
for (const row of segmentRows) {
const remoteVolumeId = Number(row.remoteVolumeId);
const volumeRef = `rv_${remoteVolumeId}`;
if (!volumesByRemoteId.has(remoteVolumeId)) {
const volume = {
volumeId: volumeRef,
remoteVolumeId,
name: row.volumeName,
plainZipName: stripAesSuffix(row.volumeName),
encryption: {
module: 'duplicati-aes',
container: 'AESCrypt',
streamFormat: row.streamFormat ?? 'v2-or-v3',
cipher: 'AES-256-CBC',
integrity: 'HMAC-SHA256',
headerSource: 'remote-file-header',
headerProbeBytes: Number(row.headerProbeBytes ?? 256),
salt: this.source.capabilities.volumeCryptoCache ? row.saltHex ?? null : null,
iv: this.source.capabilities.volumeCryptoCache ? row.ivHex ?? null : null
},
access: {
ciphertextRandomAccess: false,
requiresDecryptFromVolumeStart: true
}
};
volumesByRemoteId.set(remoteVolumeId, volume);
volumes.push(volume);
}
const logicalSize = Number(row.logicalSize);
segments.push({
segmentIndex: Number(row.segmentIndex),
logicalOffset,
logicalSize,
blockHashBase64: row.blockHashBase64,
zipEntryName: row.entryName ?? toZipEntryName(row.blockHashBase64),
volumeRef,
zip: {
entryType: 'data',
localHeaderOffsetPlain: Number(row.localHeaderOffsetPlain),
dataOffsetPlain: Number(row.dataOffsetPlain),
compressedSize: Number(row.compressedSize),
uncompressedSize: Number(row.uncompressedSize),
compressionMethod: normalizeCompressionMethod(row.compressionMethod),
crc32: normalizeCrc32(row.crc32)
}
});
logicalOffset += logicalSize;
}
if (fileSize !== logicalOffset) {
throw new HttpError(
409,
'BLOCKSET_LENGTH_MISMATCH',
'The blockset byte count does not match the declared file length.',
{
declaredSize: fileSize,
expandedSize: logicalOffset
}
);
}
return {
apiVersion: '1',
sourceId: this.source.id,
snapshot: {
id: snapshot.id,
resolvedFilesetId: snapshot.resolvedFilesetId,
timestamp: snapshot.timestamp
},
file: {
id,
path: apiPath,
name: getNameFromApiPath(apiPath),
size: fileSize,
mime,
mtime: duplicatiTicksToIso(fileRow.lastModifiedTicks),
...(fileRow.fullHash
? {
hash: {
algo: 'sha256',
base64: fileRow.fullHash
}
}
: {})
},
restorePlan: {
type: 'ordered-segments',
blockSize: computeCanonicalBlockSize(segments),
segmentCount: segments.length
},
volumes,
segments,
requiredDblocks: volumes.map((volume) => volume.name),
hints: {
canPreviewInMemory: fileSize <= this.previewMaxBytes,
canStreamDownload: true,
rangeReady: segments.length === 0 || missingZipIndex.length === 0
}
};
}
}

2125
src/db/sourceCatalog.js Normal file

File diff suppressed because it is too large Load Diff

70
src/db/sqlite.js Normal file
View File

@ -0,0 +1,70 @@
import { DatabaseSync } from 'node:sqlite';
import { pathToFileURL } from 'node:url';
function assertAlias(alias) {
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(alias)) {
throw new Error(`Invalid SQLite alias: ${alias}`);
}
}
export class SqliteDatabase {
constructor(database) {
this.database = database;
}
async get(sql, params = []) {
const statement = this.database.prepare(sql);
statement.setReadBigInts(true);
return statement.get(...params) ?? null;
}
async all(sql, params = []) {
const statement = this.database.prepare(sql);
statement.setReadBigInts(true);
return statement.all(...params) ?? [];
}
async run(sql, params = []) {
const statement = this.database.prepare(sql);
statement.setReadBigInts(true);
return statement.run(...params);
}
async exec(sql) {
this.database.exec(sql);
}
async attachDatabase(alias, filename, options = {}) {
assertAlias(alias);
const target = options.readOnly
? `${pathToFileURL(filename).href}?mode=ro`
: filename;
this.database.prepare(`ATTACH DATABASE ? AS "${alias}"`).run(target);
}
async detachDatabase(alias) {
assertAlias(alias);
this.database.prepare(`DETACH DATABASE "${alias}"`).run();
}
async close() {
this.database.close();
}
}
export async function openSqlite(filename, options = {}) {
const database = new DatabaseSync(filename, {
readOnly: Boolean(options.readOnly)
});
return new SqliteDatabase(database);
}
export async function openReadOnlySqlite(filename) {
return openSqlite(filename, { readOnly: true });
}
export async function openWritableSqlite(filename) {
return openSqlite(filename, { readOnly: false });
}

13
src/errors.js Normal file
View File

@ -0,0 +1,13 @@
export class HttpError extends Error {
constructor(status, code, message, details = undefined) {
super(message);
this.name = 'HttpError';
this.status = status;
this.code = code;
this.details = details;
}
}
export function isHttpError(error) {
return error instanceof HttpError;
}

242
src/lib/aesCrypt.js Normal file
View File

@ -0,0 +1,242 @@
import { createDecipheriv, createHash, createHmac, timingSafeEqual } from 'node:crypto';
import fs from 'node:fs';
import fsp from 'node:fs/promises';
import { HttpError } from '../errors.js';
const AES_BLOCK_SIZE = 16;
const HEADER_MAGIC = Buffer.from('AES', 'utf8');
const ENCRYPTED_IV_AND_KEY_LENGTH = 48;
const HEADER_HMAC_LENGTH = 32;
const FOOTER_SIZE_LENGTH = 1;
const FOOTER_HMAC_LENGTH = 32;
const FOOTER_TOTAL_LENGTH = FOOTER_SIZE_LENGTH + FOOTER_HMAC_LENGTH;
const VERSION_2_ITERATIONS = 8192;
function createAesError(code, message, details = undefined) {
return new HttpError(422, code, message, details);
}
function stretchPasswordV2(passphrase, externalIv) {
const passwordBytes = Buffer.from(String(passphrase), 'utf16le');
let digest = Buffer.concat([externalIv, Buffer.alloc(16, 0)]);
for (let index = 0; index < VERSION_2_ITERATIONS; index += 1) {
const hash = createHash('sha256');
hash.update(digest);
hash.update(passwordBytes);
digest = hash.digest();
}
return digest;
}
function readUInt16BE(buffer, offset) {
return buffer.readUInt16BE(offset);
}
async function parseAesCryptHeader(handle) {
const magic = Buffer.alloc(3);
await handle.read(magic, 0, magic.length, 0);
if (!magic.equals(HEADER_MAGIC)) {
throw createAesError('AESC_HEADER_INVALID', 'The remote volume does not start with an AES Crypt header.');
}
const versionBuffer = Buffer.alloc(2);
await handle.read(versionBuffer, 0, versionBuffer.length, 3);
const version = versionBuffer[0];
const reserved = versionBuffer[1];
if (reserved !== 0) {
throw createAesError('AESC_HEADER_INVALID', 'The AES Crypt header reserved byte was not zero.');
}
if (version !== 2) {
throw createAesError(
'AESC_VERSION_UNSUPPORTED',
`AES Crypt version ${version} is not supported by the current enhancement job.`
);
}
let offset = 5;
while (true) {
const extensionLengthBytes = Buffer.alloc(2);
await handle.read(extensionLengthBytes, 0, extensionLengthBytes.length, offset);
const extensionLength = readUInt16BE(extensionLengthBytes, 0);
offset += 2;
if (extensionLength === 0) {
break;
}
if (extensionLength < 2) {
throw createAesError('AESC_HEADER_INVALID', 'An AES Crypt extension record length was invalid.');
}
offset += extensionLength;
}
const externalIv = Buffer.alloc(AES_BLOCK_SIZE);
await handle.read(externalIv, 0, externalIv.length, offset);
offset += externalIv.length;
const encryptedIvAndKey = Buffer.alloc(ENCRYPTED_IV_AND_KEY_LENGTH);
await handle.read(encryptedIvAndKey, 0, encryptedIvAndKey.length, offset);
offset += encryptedIvAndKey.length;
const headerHmac = Buffer.alloc(HEADER_HMAC_LENGTH);
await handle.read(headerHmac, 0, headerHmac.length, offset);
offset += headerHmac.length;
return {
headerLength: offset,
streamFormat: 'v2',
externalIv,
encryptedIvAndKey,
headerHmac,
kdfIterations: VERSION_2_ITERATIONS
};
}
async function readFooter(handle, size) {
if (size < FOOTER_TOTAL_LENGTH) {
throw createAesError('AESC_FILE_TOO_SMALL', 'The AES volume is too small to contain a valid footer.');
}
const footer = Buffer.alloc(FOOTER_TOTAL_LENGTH);
await handle.read(footer, 0, footer.length, size - footer.length);
return {
fileSizeModulo: footer.readUInt8(0),
payloadHmac: footer.subarray(1)
};
}
function validateHeaderAndDeriveSession(passphrase, header) {
const stretchedKey = stretchPasswordV2(passphrase, header.externalIv);
const actualHeaderHmac = createHmac('sha256', stretchedKey)
.update(header.encryptedIvAndKey)
.digest();
if (!timingSafeEqual(actualHeaderHmac, header.headerHmac)) {
throw createAesError('AESC_INVALID_PASSPHRASE', 'The AES volume header HMAC did not match. Check the passphrase.');
}
const decipher = createDecipheriv('aes-256-cbc', stretchedKey, header.externalIv);
decipher.setAutoPadding(false);
const sessionBytes = Buffer.concat([
decipher.update(header.encryptedIvAndKey),
decipher.final()
]);
if (sessionBytes.length !== ENCRYPTED_IV_AND_KEY_LENGTH) {
throw createAesError('AESC_HEADER_INVALID', 'The AES volume session payload length was invalid.');
}
return {
internalIv: sessionBytes.subarray(0, 16),
internalKey: sessionBytes.subarray(16)
};
}
export async function decryptAesCryptFileToZip({ encryptedPath, outputPath, passphrase }) {
const handle = await fsp.open(encryptedPath, 'r');
try {
const stats = await handle.stat();
const header = await parseAesCryptHeader(handle);
const footer = await readFooter(handle, stats.size);
const session = validateHeaderAndDeriveSession(passphrase, header);
const ciphertextLength = stats.size - header.headerLength - FOOTER_TOTAL_LENGTH;
if (ciphertextLength < 0 || ciphertextLength % AES_BLOCK_SIZE !== 0) {
throw createAesError(
'AESC_CIPHERTEXT_INVALID',
'The AES volume ciphertext length was invalid for AES-CBC decryption.'
);
}
const decipher = createDecipheriv('aes-256-cbc', session.internalKey, session.internalIv);
decipher.setAutoPadding(false);
const payloadHmac = createHmac('sha256', session.internalKey);
const input = fs.createReadStream(encryptedPath, {
start: header.headerLength,
end: stats.size - FOOTER_TOTAL_LENGTH - 1,
highWaterMark: 64 * 1024
});
const output = fs.createWriteStream(outputPath, { flags: 'wx' });
let trailingBlock = Buffer.alloc(0);
let sawPlaintext = false;
await new Promise((resolve, reject) => {
input.on('error', reject);
output.on('error', reject);
input.on('data', (chunk) => {
payloadHmac.update(chunk);
const plainChunk = decipher.update(chunk);
const combined = trailingBlock.length > 0
? Buffer.concat([trailingBlock, plainChunk])
: plainChunk;
if (combined.length > AES_BLOCK_SIZE) {
output.write(combined.subarray(0, combined.length - AES_BLOCK_SIZE));
}
trailingBlock = combined.subarray(Math.max(0, combined.length - AES_BLOCK_SIZE));
sawPlaintext = true;
});
input.on('end', () => {
try {
const finalChunk = decipher.final();
const combined = finalChunk.length > 0
? Buffer.concat([trailingBlock, finalChunk])
: trailingBlock;
if (!timingSafeEqual(payloadHmac.digest(), footer.payloadHmac)) {
throw createAesError(
'AESC_PAYLOAD_HMAC_MISMATCH',
'The AES volume ciphertext HMAC did not match. The remote file may be corrupted.'
);
}
if (combined.length > 0) {
if (combined.length < AES_BLOCK_SIZE) {
throw createAesError(
'AESC_DECRYPTION_INVALID',
'The AES volume did not yield a complete final plaintext block.'
);
}
const finalLength = footer.fileSizeModulo === 0 ? combined.length : footer.fileSizeModulo;
if (finalLength < 0 || finalLength > combined.length) {
throw createAesError(
'AESC_FOOTER_INVALID',
'The AES volume footer declared an invalid plaintext block remainder.'
);
}
output.write(combined.subarray(0, finalLength));
} else if (sawPlaintext) {
throw createAesError(
'AESC_DECRYPTION_INVALID',
'The AES volume produced no final plaintext block after decryption.'
);
}
output.end(() => resolve());
} catch (error) {
reject(error);
}
});
});
return {
streamFormat: header.streamFormat,
headerProbeBytes: header.headerLength,
kdfIterations: header.kdfIterations,
saltHex: null,
ivHex: header.externalIv.toString('hex')
};
} finally {
await handle.close();
}
}

85
src/lib/duplicati.js Normal file
View File

@ -0,0 +1,85 @@
export function buildResolvedFilesetId(timestampIso) {
return `fs_${timestampIso}`;
}
export function toZipEntryName(blockHashBase64) {
return String(blockHashBase64).replaceAll('/', '_').replaceAll('+', '-');
}
export function stripAesSuffix(volumeName) {
return volumeName.endsWith('.aes') ? volumeName.slice(0, -4) : volumeName;
}
export function inferPreviewKind(mime) {
if (!mime) {
return null;
}
if (mime.startsWith('image/')) {
return 'image';
}
if (mime.startsWith('video/')) {
return 'video';
}
if (mime.startsWith('text/') || mime === 'application/json') {
return 'text';
}
return null;
}
export function normalizeCompressionMethod(value) {
if (value === null || value === undefined) {
return null;
}
const normalized = String(value).toLowerCase();
if (normalized === '0') {
return 'store';
}
if (normalized === '8') {
return 'deflate';
}
return normalized;
}
export function normalizeCrc32(value) {
if (value === null || value === undefined) {
return null;
}
const raw = String(value).trim().toLowerCase().replace(/^0x/, '');
if (/^[0-9a-f]{1,8}$/.test(raw)) {
return raw.padStart(8, '0');
}
return raw;
}
export function computeCanonicalBlockSize(segments) {
if (!segments.length) {
return 0;
}
const counts = new Map();
for (const segment of segments) {
const size = Number(segment.logicalSize);
counts.set(size, (counts.get(size) ?? 0) + 1);
}
let winner = Number(segments[0].logicalSize);
let winnerCount = counts.get(winner) ?? 0;
for (const [size, count] of counts.entries()) {
if (count > winnerCount || (count === winnerCount && size > winner)) {
winner = size;
winnerCount = count;
}
}
return winner;
}

23
src/lib/fileId.js Normal file
View File

@ -0,0 +1,23 @@
const TOKEN_VERSION = 3;
export function encodeOpaqueId(payload) {
const token = {
v: TOKEN_VERSION,
...payload
};
return Buffer.from(JSON.stringify(token), 'utf8').toString('base64url');
}
export function decodeOpaqueId(id) {
try {
const parsed = JSON.parse(Buffer.from(id, 'base64url').toString('utf8'));
if (parsed?.v !== TOKEN_VERSION) {
throw new Error('Unsupported token version');
}
return parsed;
} catch (error) {
throw new Error('Invalid opaque id');
}
}

69
src/lib/paths.js Normal file
View File

@ -0,0 +1,69 @@
export function normalizeApiPath(inputPath = '/') {
const source = String(inputPath || '/').replaceAll('\\', '/');
let normalized = source.startsWith('/') ? source : `/${source}`;
normalized = normalized.replace(/\/{2,}/g, '/');
if (normalized.length > 1 && normalized.endsWith('/')) {
normalized = normalized.slice(0, -1);
}
return normalized || '/';
}
export function toApiPath(dbPath) {
return normalizeApiPath(dbPath);
}
export function getParentApiPath(apiPath) {
const normalized = normalizeApiPath(apiPath);
if (normalized === '/') {
return null;
}
const index = normalized.lastIndexOf('/');
return index <= 0 ? '/' : normalized.slice(0, index);
}
export function isSameOrDescendant(basePath, candidatePath) {
const normalizedBase = normalizeApiPath(basePath);
const normalizedCandidate = normalizeApiPath(candidatePath);
if (normalizedBase === '/') {
return true;
}
return (
normalizedCandidate === normalizedBase ||
normalizedCandidate.startsWith(`${normalizedBase}/`)
);
}
export function getImmediateChildName(basePath, candidatePath) {
const normalizedBase = normalizeApiPath(basePath);
const normalizedCandidate = normalizeApiPath(candidatePath);
if (!isSameOrDescendant(normalizedBase, normalizedCandidate) || normalizedBase === normalizedCandidate) {
return null;
}
const relative =
normalizedBase === '/'
? normalizedCandidate.slice(1)
: normalizedCandidate.slice(normalizedBase.length + 1);
return relative.split('/')[0] || null;
}
export function joinApiPath(basePath, childName) {
const normalizedBase = normalizeApiPath(basePath);
return normalizedBase === '/' ? `/${childName}` : `${normalizedBase}/${childName}`;
}
export function getNameFromApiPath(apiPath) {
const normalized = normalizeApiPath(apiPath);
if (normalized === '/') {
return '/';
}
return normalized.slice(normalized.lastIndexOf('/') + 1);
}

33
src/lib/time.js Normal file
View File

@ -0,0 +1,33 @@
const UNIX_EPOCH_TICKS = 621355968000000000n;
const TICKS_PER_MILLISECOND = 10000n;
export function unixSecondsToIso(value) {
if (value === null || value === undefined) {
return null;
}
const seconds = Number(value);
if (!Number.isFinite(seconds)) {
return null;
}
return new Date(seconds * 1000).toISOString();
}
export function duplicatiTicksToIso(value) {
if (value === null || value === undefined) {
return null;
}
try {
const ticks = BigInt(value);
const milliseconds = Number((ticks - UNIX_EPOCH_TICKS) / TICKS_PER_MILLISECOND);
if (!Number.isFinite(milliseconds)) {
return null;
}
return new Date(milliseconds).toISOString();
} catch (error) {
return null;
}
}

36
src/lib/uploadedFile.js Normal file
View File

@ -0,0 +1,36 @@
import { createHash, randomUUID } from 'node:crypto';
import fs from 'node:fs';
import { mkdir, rm, stat } from 'node:fs/promises';
import path from 'node:path';
import { Readable, Transform } from 'node:stream';
import { pipeline } from 'node:stream/promises';
export async function stageUploadedFile(file, uploadDir) {
await mkdir(uploadDir, { recursive: true });
const stagedPath = path.join(uploadDir, `staging-${randomUUID()}.sqlite.upload`);
const hash = createHash('sha256');
const hashingStream = new Transform({
transform(chunk, encoding, callback) {
hash.update(chunk);
callback(null, chunk);
}
});
await pipeline(
Readable.fromWeb(file.stream()),
hashingStream,
fs.createWriteStream(stagedPath, { flags: 'wx' })
);
const details = await stat(stagedPath);
return {
stagedPath,
fileSize: details.size,
sha256: hash.digest('hex')
};
}
export async function removeFileIfPresent(filename) {
await rm(filename, { force: true });
}

132
src/lib/zip.js Normal file
View File

@ -0,0 +1,132 @@
import fs from 'node:fs/promises';
import { HttpError } from '../errors.js';
const EOCD_SIGNATURE = 0x06054b50;
const CENTRAL_DIRECTORY_SIGNATURE = 0x02014b50;
const LOCAL_FILE_HEADER_SIGNATURE = 0x04034b50;
const MAX_EOCD_SEARCH = 65_557;
function readUInt32LE(buffer, offset) {
return buffer.readUInt32LE(offset);
}
function readUInt16LE(buffer, offset) {
return buffer.readUInt16LE(offset);
}
function createZipError(code, message, details = undefined) {
return new HttpError(422, code, message, details);
}
async function findEndOfCentralDirectory(handle, size) {
const searchLength = Math.min(MAX_EOCD_SEARCH, size);
const searchOffset = size - searchLength;
const buffer = Buffer.alloc(searchLength);
await handle.read(buffer, 0, searchLength, searchOffset);
for (let index = searchLength - 22; index >= 0; index -= 1) {
if (readUInt32LE(buffer, index) === EOCD_SIGNATURE) {
return {
absoluteOffset: searchOffset + index,
buffer,
offset: index
};
}
}
throw createZipError('ZIP_EOCD_NOT_FOUND', 'Failed to locate the ZIP end-of-central-directory record.');
}
async function readLocalHeaderOffset(handle, localHeaderOffset) {
const localHeader = Buffer.alloc(30);
await handle.read(localHeader, 0, localHeader.length, localHeaderOffset);
if (readUInt32LE(localHeader, 0) !== LOCAL_FILE_HEADER_SIGNATURE) {
throw createZipError(
'ZIP_LOCAL_HEADER_INVALID',
'A ZIP entry local header did not match the expected PK signature.'
);
}
const fileNameLength = readUInt16LE(localHeader, 26);
const extraFieldLength = readUInt16LE(localHeader, 28);
return localHeaderOffset + 30 + fileNameLength + extraFieldLength;
}
export async function scanZipEntries(zipPath) {
const handle = await fs.open(zipPath, 'r');
try {
const stats = await handle.stat();
if (stats.size < 22) {
throw createZipError('ZIP_TOO_SMALL', 'The decrypted dblock ZIP file is too small to be valid.');
}
const eocd = await findEndOfCentralDirectory(handle, stats.size);
const buffer = eocd.buffer;
const offset = eocd.offset;
const entryCount = readUInt16LE(buffer, offset + 10);
const centralDirectorySize = readUInt32LE(buffer, offset + 12);
const centralDirectoryOffset = readUInt32LE(buffer, offset + 16);
if (
entryCount === 0xffff ||
centralDirectorySize === 0xffffffff ||
centralDirectoryOffset === 0xffffffff
) {
throw createZipError(
'ZIP64_UNSUPPORTED',
'ZIP64 volumes are not supported by the current enhancement scanner.'
);
}
let currentOffset = centralDirectoryOffset;
const entries = [];
for (let index = 0; index < entryCount; index += 1) {
const header = Buffer.alloc(46);
await handle.read(header, 0, header.length, currentOffset);
if (readUInt32LE(header, 0) !== CENTRAL_DIRECTORY_SIGNATURE) {
throw createZipError(
'ZIP_CENTRAL_DIRECTORY_INVALID',
'A ZIP central-directory record did not match the expected PK signature.'
);
}
const compressionMethod = readUInt16LE(header, 10);
const crc32 = readUInt32LE(header, 16).toString(16).padStart(8, '0');
const compressedSize = readUInt32LE(header, 20);
const uncompressedSize = readUInt32LE(header, 24);
const fileNameLength = readUInt16LE(header, 28);
const extraFieldLength = readUInt16LE(header, 30);
const commentLength = readUInt16LE(header, 32);
const localHeaderOffset = readUInt32LE(header, 42);
const fileNameBuffer = Buffer.alloc(fileNameLength);
await handle.read(fileNameBuffer, 0, fileNameLength, currentOffset + 46);
const entryName = fileNameBuffer.toString('utf8');
const dataOffset = await readLocalHeaderOffset(handle, localHeaderOffset);
entries.push({
entryName,
localHeaderOffset,
dataOffset,
compressedSize,
uncompressedSize,
compressionMethod,
crc32
});
currentOffset += 46 + fileNameLength + extraFieldLength + commentLength;
}
return {
plainZipSize: stats.size,
entryCount: entries.length,
entries
};
} finally {
await handle.close();
}
}

465
src/server.js Normal file
View File

@ -0,0 +1,465 @@
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import express from 'express';
import { config } from './config.js';
import { SourceCatalog } from './db/sourceCatalog.js';
import { HttpError, isHttpError } from './errors.js';
import { ActiveSourceService } from './services/activeSourceService.js';
import { SourceEnhancementService } from './services/sourceEnhancementService.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const publicDir = path.resolve(__dirname, '..', 'public');
function toPublicSource(source) {
if (!source) {
return null;
}
const { sourceDir, rawDbPath, enhancedDbPath, rawSha256, ...rest } = source;
return rest;
}
function toPublicServerDb(serverDb) {
return serverDb ?? {
available: false,
originalFilename: null,
uploadedAt: null,
backupCount: 0
};
}
function toWebRequest(request) {
const host = request.headers.host ?? '127.0.0.1';
return new Request(`http://${host}${request.originalUrl}`, {
method: request.method,
headers: request.headers,
body: request,
duplex: 'half'
});
}
async function parseJsonBody(request) {
if (!request.is('application/json')) {
throw new HttpError(415, 'UNSUPPORTED_MEDIA_TYPE', 'Request body must be application/json.');
}
return request.body ?? {};
}
async function parseMultipartDatabaseFile(request) {
const formData = await parseMultipartFormData(request);
const databaseFile = formData.get('database');
if (!databaseFile || typeof databaseFile !== 'object' || typeof databaseFile.stream !== 'function') {
throw new HttpError(
400,
'MISSING_DATABASE_FILE',
'Form field "database" is required and must be a file.'
);
}
return databaseFile;
}
async function parseMultipartFormData(request) {
const contentType = String(request.headers['content-type'] ?? '').toLowerCase();
if (!contentType.startsWith('multipart/form-data')) {
throw new HttpError(
415,
'UNSUPPORTED_MEDIA_TYPE',
'Upload requests must use multipart/form-data.'
);
}
let formData;
try {
formData = await toWebRequest(request).formData();
} catch (error) {
throw new HttpError(
400,
'INVALID_MULTIPART_UPLOAD',
'The upload body could not be parsed as multipart/form-data.'
);
}
return formData;
}
export async function createServerApp(overrides = {}) {
const runtimeConfig = {
...config,
...overrides
};
const sourceCatalog = await SourceCatalog.create({
appDbPath: runtimeConfig.appDbPath,
uploadDir: runtimeConfig.uploadDir
});
const activeSourceService = new ActiveSourceService({
sourceCatalog,
previewMaxBytes: runtimeConfig.previewMaxBytes
});
const sourceEnhancementService = new SourceEnhancementService({
sourceCatalog
});
const app = express();
app.use(express.json({ limit: '1mb' }));
app.use(
express.static(publicDir, {
etag: false,
lastModified: false,
maxAge: 0,
setHeaders(response) {
response.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
response.setHeader('Pragma', 'no-cache');
response.setHeader('Expires', '0');
}
})
);
app.get('/healthz', async (request, response, next) => {
try {
const sources = await sourceCatalog.listSources();
response.json({
ok: true,
sourceCount: sources.length
});
} catch (error) {
next(error);
}
});
app.get('/api/sources', async (request, response, next) => {
try {
const sources = await sourceCatalog.listSources();
response.json({
sources: sources.map((source) => toPublicSource(source))
});
} catch (error) {
next(error);
}
});
app.get('/api/server-db', async (request, response, next) => {
try {
const serverDb = await sourceCatalog.getServerDatabaseSummary();
response.json({
serverDb: toPublicServerDb(serverDb)
});
} catch (error) {
next(error);
}
});
app.post('/api/server-db/upload', async (request, response, next) => {
try {
const databaseFile = await parseMultipartDatabaseFile(request);
const serverDb = await sourceCatalog.ingestServerDatabase(databaseFile);
response.status(201).json({
serverDb: toPublicServerDb(serverDb)
});
} catch (error) {
next(error);
}
});
app.get('/api/webdav-defaults', async (request, response, next) => {
try {
const defaults = await sourceCatalog.getGlobalWebdavDefaultsSummary();
response.json({
defaults
});
} catch (error) {
next(error);
}
});
app.put('/api/webdav-defaults', async (request, response, next) => {
try {
const payload = await parseJsonBody(request);
const defaults = await sourceCatalog.saveGlobalWebdavDefaults(payload);
response.json({
defaults
});
} catch (error) {
next(error);
}
});
app.get('/api/sources/:sourceId', async (request, response, next) => {
try {
const source = await sourceCatalog.getSource(request.params.sourceId);
if (!source) {
throw new HttpError(404, 'SOURCE_NOT_FOUND', `Source ${request.params.sourceId} was not found.`);
}
response.json({
source: toPublicSource(source)
});
} catch (error) {
next(error);
}
});
app.get('/api/sources/:sourceId/browser-secrets', async (request, response, next) => {
try {
const secrets = await sourceCatalog.resolveEffectiveSourceSecrets(request.params.sourceId);
response.json({
secrets
});
} catch (error) {
next(error);
}
});
app.post('/api/sources/:sourceId/thumbnails', async (request, response, next) => {
try {
const formData = await parseMultipartFormData(request);
const fileId = String(formData.get('fileId') ?? '').trim();
const image = formData.get('image');
if (!fileId) {
throw new HttpError(400, 'MISSING_FILE_ID', 'Form field "fileId" is required.');
}
if (!image || typeof image !== 'object' || typeof image.stream !== 'function') {
throw new HttpError(400, 'MISSING_THUMBNAIL_IMAGE', 'Form field "image" is required and must be a file.');
}
const fileInfo = await activeSourceService.getFileInfo({
sourceId: request.params.sourceId,
id: fileId
});
if (!String(fileInfo.file.mime ?? '').toLowerCase().startsWith('video/')) {
throw new HttpError(409, 'THUMBNAIL_VIDEO_ONLY', 'Preview thumbnails are only supported for video files.');
}
const thumbnail = await sourceCatalog.savePreviewThumbnail({
sourceId: request.params.sourceId,
fileId,
imageFile: image
});
response.status(201).json({
thumbnail: {
available: true,
thumbnailId: thumbnail.thumbnailId,
thumbnailUrl: sourceCatalog.buildThumbnailUrl(request.params.sourceId, thumbnail.thumbnailId)
}
});
} catch (error) {
next(error);
}
});
app.get('/api/sources/:sourceId/thumbnails/:thumbnailId', async (request, response, next) => {
try {
const thumbnail = await sourceCatalog.getThumbnailById(request.params.sourceId, request.params.thumbnailId);
if (!thumbnail) {
throw new HttpError(404, 'THUMBNAIL_NOT_FOUND', `Thumbnail ${request.params.thumbnailId} was not found.`);
}
response.set('Content-Type', thumbnail.contentType);
response.set('Content-Length', String(thumbnail.byteSize));
response.set('Cache-Control', 'public, max-age=300');
response.sendFile(thumbnail.storagePath);
} catch (error) {
next(error);
}
});
app.delete('/api/sources/:sourceId/thumbnails/:thumbnailId', async (request, response, next) => {
try {
const deleted = await sourceCatalog.deleteThumbnailById(request.params.sourceId, request.params.thumbnailId);
response.json({
deleted
});
} catch (error) {
next(error);
}
});
app.delete('/api/sources/:sourceId/thumbnails', async (request, response, next) => {
try {
const cleared = await sourceCatalog.clearSourceThumbnails(request.params.sourceId);
response.json({
cleared
});
} catch (error) {
next(error);
}
});
app.delete('/api/sources/:sourceId', async (request, response, next) => {
try {
const deleted = await sourceCatalog.deleteSource(request.params.sourceId);
response.json({
deleted
});
} catch (error) {
next(error);
}
});
app.put('/api/sources/:sourceId/secrets', async (request, response, next) => {
try {
const payload = await parseJsonBody(request);
const source = await sourceEnhancementService.saveSecrets(request.params.sourceId, payload);
response.json({
source: toPublicSource(source)
});
} catch (error) {
next(error);
}
});
app.post('/api/sources/:sourceId/enhance', async (request, response, next) => {
try {
const contentType = String(request.headers['content-type'] ?? '').toLowerCase();
const payload = contentType.startsWith('application/json') ? await parseJsonBody(request) : null;
const source = await sourceEnhancementService.scheduleEnhancement(
request.params.sourceId,
payload && Object.keys(payload).length > 0 ? payload : null
);
response.status(202).json({
source: toPublicSource(source)
});
} catch (error) {
next(error);
}
});
app.get('/api/sources/:sourceId/enhance/status', async (request, response, next) => {
try {
const enhancement = await sourceCatalog.getEnhancementJob(request.params.sourceId);
response.json({
sourceId: request.params.sourceId,
enhancement
});
} catch (error) {
next(error);
}
});
async function handleSourceUpload(request, response, next) {
try {
const databaseFile = await parseMultipartDatabaseFile(request);
const { source, reused } = await sourceCatalog.ingestUploadedDatabase(databaseFile);
response.status(reused ? 200 : 201).json({
reused,
source: toPublicSource(source)
});
} catch (error) {
next(error);
}
}
app.post('/api/sources/upload', handleSourceUpload);
app.post('/api/source/upload', handleSourceUpload);
app.get('/api/source/current', async (request, response, next) => {
try {
const source = await sourceCatalog.getCompatCurrentSource();
response.json({
source: toPublicSource(source)
});
} catch (error) {
next(error);
}
});
app.get('/api/ls', async (request, response, next) => {
try {
const result = await activeSourceService.listDirectory({
sourceId: request.query.sourceId ?? null,
apiPath: request.query.path ?? '/',
snapshotId: request.query.snapshot ?? 'latest'
});
response.json(result);
} catch (error) {
next(error);
}
});
app.get('/api/file-info', async (request, response, next) => {
try {
const id = request.query.id;
if (!id) {
throw new HttpError(400, 'MISSING_ID', 'Query parameter "id" is required.');
}
const result = await activeSourceService.getFileInfo({
sourceId: request.query.sourceId ?? null,
id
});
response.json(result);
} catch (error) {
next(error);
}
});
app.use((request, response, next) => {
next(new HttpError(404, 'NOT_FOUND', `No route matched ${request.method} ${request.path}`));
});
app.use((error, request, response, next) => {
const normalized = isHttpError(error)
? error
: new HttpError(500, 'INTERNAL_ERROR', error.message || 'Unexpected server error');
response.status(normalized.status).json({
error: {
code: normalized.code,
message: normalized.message,
...(normalized.details ? { details: normalized.details } : {})
}
});
});
return {
app,
async close() {
await sourceCatalog.cleanupOrphanedUploads();
}
};
}
export async function startServer(overrides = {}) {
const runtime = await createServerApp(overrides);
const runtimeConfig = {
...config,
...overrides
};
const server = runtime.app.listen(runtimeConfig.port, () => {
console.log(`Duplicati metadata API listening on http://127.0.0.1:${runtimeConfig.port}`);
});
async function shutdown(signal) {
console.log(`Received ${signal}, shutting down...`);
server.close(async () => {
await runtime.close();
process.exit(0);
});
}
process.on('SIGINT', () => {
void shutdown('SIGINT');
});
process.on('SIGTERM', () => {
void shutdown('SIGTERM');
});
return { ...runtime, server };
}
const isMainModule =
process.argv[1] &&
path.resolve(process.argv[1]) === path.resolve(__filename);
if (isMainModule) {
await startServer();
}

View File

@ -0,0 +1,115 @@
import { HttpError } from '../errors.js';
import { DuplicatiRepository } from '../db/duplicatiRepository.js';
function buildThumbnailShape(sourceCatalog, sourceId, thumbnail) {
if (!thumbnail) {
return {
available: false,
thumbnailId: null,
thumbnailUrl: null
};
}
return {
available: true,
thumbnailId: thumbnail.thumbnailId,
thumbnailUrl: sourceCatalog.buildThumbnailUrl(sourceId, thumbnail.thumbnailId)
};
}
export class ActiveSourceService {
constructor({ sourceCatalog, previewMaxBytes }) {
this.sourceCatalog = sourceCatalog;
this.previewMaxBytes = previewMaxBytes;
}
async withRepository(sourceId, callback) {
const source = await this.sourceCatalog.resolveSourceForQuery(sourceId);
const database = await this.sourceCatalog.openAppDatabase();
let attached = false;
try {
try {
await database.attachDatabase('source', source.queryDbPath, { readOnly: true });
attached = true;
} catch (error) {
throw new HttpError(
409,
'SOURCE_UNAVAILABLE',
'The selected source database could not be attached. Re-upload the database and try again.'
);
}
const repository = new DuplicatiRepository({
database,
previewMaxBytes: this.previewMaxBytes,
source,
sourceLayout: source.sourceLayout
});
return await callback(repository, source);
} finally {
if (attached) {
try {
await database.detachDatabase('source');
} catch (error) {
// ignore detach failures during shutdown of a per-request connection
}
}
await database.close();
}
}
async listDirectory({ sourceId, apiPath, snapshotId }) {
const result = await this.withRepository(sourceId, (repository) =>
repository.listDirectory({ apiPath, snapshotId })
);
const fileIds = result.entries.filter((entry) => entry.type === 'file').map((entry) => entry.id);
const thumbnailsByFileId = await this.sourceCatalog.getThumbnailsForFileIds(result.sourceId, fileIds);
return {
...result,
entries: result.entries.map((entry) =>
entry.type === 'file'
? {
...entry,
thumbnail: buildThumbnailShape(this.sourceCatalog, result.sourceId, thumbnailsByFileId.get(entry.id) ?? null)
}
: entry
)
};
}
async getFileInfo({ sourceId, id }) {
const source = await this.sourceCatalog.resolveSourceForQuery(sourceId);
if (source.enhancement.status === 'failed') {
throw new HttpError(
409,
'ENHANCEMENT_FAILED',
'This source failed to build archive indexes. Fix the credentials or remote volumes and rerun enhancement.'
);
}
if (source.enhancement.status !== 'ready') {
throw new HttpError(
409,
'ENHANCEMENT_NOT_READY',
'This source has not finished building archive indexes yet.'
);
}
const result = await this.withRepository(source.id, (repository) => repository.getFileInfo(id));
const thumbnail = await this.sourceCatalog.getThumbnailByFileId(source.id, id);
return {
...result,
file: {
...result.file,
thumbnail: buildThumbnailShape(this.sourceCatalog, source.id, thumbnail)
}
};
}
}

View File

@ -0,0 +1,526 @@
import fs from 'node:fs';
import fsp from 'node:fs/promises';
import path from 'node:path';
import { Buffer } from 'node:buffer';
import { Readable } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import { decryptAesCryptFileToZip } from '../lib/aesCrypt.js';
import { scanZipEntries } from '../lib/zip.js';
import { openReadOnlySqlite, openWritableSqlite } from '../db/sqlite.js';
const SUPPLEMENTAL_TABLES_SQL = `
DROP TABLE IF EXISTS "archive_entry_index";
DROP TABLE IF EXISTS "volume_crypto_cache";
DROP TABLE IF EXISTS "volume_scan_inventory";
DROP TABLE IF EXISTS "enhancement_meta";
CREATE TABLE "archive_entry_index" (
"volume_name" TEXT NOT NULL,
"entry_name" TEXT NOT NULL,
"local_header_offset_plain" INTEGER NOT NULL,
"data_offset_plain" INTEGER NOT NULL,
"compressed_size" INTEGER NOT NULL,
"uncompressed_size" INTEGER NOT NULL,
"compression_method" TEXT NOT NULL,
"crc32" TEXT NOT NULL,
PRIMARY KEY ("volume_name", "entry_name")
);
CREATE INDEX "archive_entry_index_volume_offset"
ON "archive_entry_index" ("volume_name", "data_offset_plain");
CREATE TABLE "volume_crypto_cache" (
"volume_name" TEXT PRIMARY KEY,
"stream_format" TEXT NULL,
"header_probe_bytes" INTEGER NULL,
"kdf_iterations" INTEGER NULL,
"salt_hex" TEXT NULL,
"iv_hex" TEXT NULL,
"cipher" TEXT NULL,
"integrity" TEXT NULL
);
CREATE TABLE "volume_scan_inventory" (
"volume_name" TEXT PRIMARY KEY,
"remote_type" TEXT NULL,
"remote_size" INTEGER NULL,
"remote_hash" TEXT NULL,
"plain_zip_size" INTEGER NULL,
"entry_count" INTEGER NULL,
"scan_status" TEXT NOT NULL,
"scanned_at" TEXT NOT NULL,
"error_code" TEXT NULL
);
CREATE TABLE "enhancement_meta" (
"id" INTEGER PRIMARY KEY CHECK ("id" = 1),
"schema_version" TEXT NOT NULL,
"generator_version" TEXT NOT NULL,
"created_at" TEXT NOT NULL,
"source_layout" TEXT NOT NULL,
"blocksize" TEXT NULL,
"blockhash" TEXT NULL,
"filehash" TEXT NULL
);
`;
class EnhancementJobError extends Error {
constructor(code, message, details = {}) {
super(message);
this.name = 'EnhancementJobError';
this.code = code;
this.currentVolume = details.currentVolume ?? null;
this.processedVolumes = details.processedVolumes ?? 0;
this.totalVolumes = details.totalVolumes ?? 0;
}
}
function buildBasicAuthorization(username, password) {
return `Basic ${Buffer.from(`${username ?? ''}:${password ?? ''}`, 'utf8').toString('base64')}`;
}
function buildRemoteUrl(baseUrl, volumeName) {
return new URL(volumeName, baseUrl).toString();
}
async function downloadResponseBodyToFile(response, targetPath) {
if (!response.body) {
throw new EnhancementJobError(
'WEBDAV_EMPTY_BODY',
'The remote WebDAV response did not include a response body.'
);
}
await pipeline(
Readable.fromWeb(response.body),
fs.createWriteStream(targetPath, { flags: 'wx' })
);
}
async function removeIfPresent(targetPath) {
await fsp.rm(targetPath, { recursive: true, force: true });
}
async function loadSourceConfiguration(rawDbPath) {
const database = await openReadOnlySqlite(rawDbPath);
try {
const tables = await database.all(
'SELECT "name" FROM "sqlite_master" WHERE "type" = ? AND "name" = ?',
['table', 'Configuration']
);
if (tables.length === 0) {
return {
blocksize: null,
blockhash: null,
filehash: null
};
}
const rows = await database.all('SELECT "Key", "Value" FROM "Configuration"');
const map = new Map(rows.map((row) => [String(row.Key ?? row.key).toLowerCase(), row.Value ?? row.value]));
return {
blocksize: map.get('blocksize') ?? null,
blockhash: map.get('blockhash') ?? null,
filehash: map.get('filehash') ?? null
};
} finally {
await database.close();
}
}
async function loadBlockVolumes(rawDbPath) {
const database = await openReadOnlySqlite(rawDbPath);
try {
const rows = await database.all(`
SELECT
"ID" AS "id",
"Name" AS "name",
"Type" AS "type",
"Size" AS "size",
"Hash" AS "hash"
FROM "Remotevolume"
WHERE COALESCE("Type", 'Blocks') = 'Blocks'
AND "Name" LIKE '%.dblock.zip.aes'
ORDER BY "ID" ASC
`);
return rows.map((row) => ({
id: Number(row.id),
name: row.name,
type: row.type ?? 'Blocks',
size: row.size === null || row.size === undefined ? null : Number(row.size),
hash: row.hash ?? null
}));
} finally {
await database.close();
}
}
async function initializeEnhancedDatabase(enhancedTmpPath, sourceLayout, configuration) {
const database = await openWritableSqlite(enhancedTmpPath);
try {
await database.exec(SUPPLEMENTAL_TABLES_SQL);
await database.run(
`
INSERT INTO "enhancement_meta" (
"id",
"schema_version",
"generator_version",
"created_at",
"source_layout",
"blocksize",
"blockhash",
"filehash"
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`,
[
1,
'1',
'zero-bandwidth-duplicati-api/0.2.0',
new Date().toISOString(),
sourceLayout,
configuration.blocksize,
configuration.blockhash,
configuration.filehash
]
);
} finally {
await database.close();
}
}
async function insertVolumeScan(database, volume, scanResult, cryptoMeta) {
for (const entry of scanResult.entries) {
await database.run(
`
INSERT INTO "archive_entry_index" (
"volume_name",
"entry_name",
"local_header_offset_plain",
"data_offset_plain",
"compressed_size",
"uncompressed_size",
"compression_method",
"crc32"
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`,
[
volume.name,
entry.entryName,
entry.localHeaderOffset,
entry.dataOffset,
entry.compressedSize,
entry.uncompressedSize,
String(entry.compressionMethod),
entry.crc32
]
);
}
await database.run(
`
INSERT INTO "volume_crypto_cache" (
"volume_name",
"stream_format",
"header_probe_bytes",
"kdf_iterations",
"salt_hex",
"iv_hex",
"cipher",
"integrity"
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`,
[
volume.name,
cryptoMeta.streamFormat,
cryptoMeta.headerProbeBytes,
cryptoMeta.kdfIterations,
cryptoMeta.saltHex,
cryptoMeta.ivHex,
'AES-256-CBC',
'HMAC-SHA256'
]
);
await database.run(
`
INSERT INTO "volume_scan_inventory" (
"volume_name",
"remote_type",
"remote_size",
"remote_hash",
"plain_zip_size",
"entry_count",
"scan_status",
"scanned_at",
"error_code"
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
[
volume.name,
volume.type,
volume.size,
volume.hash,
scanResult.plainZipSize,
scanResult.entryCount,
'ready',
new Date().toISOString(),
null
]
);
}
async function withWritableEnhancedDatabase(enhancedTmpPath, callback) {
const database = await openWritableSqlite(enhancedTmpPath);
try {
await callback(database);
} finally {
await database.close();
}
}
export class SourceEnhancementService {
constructor({ sourceCatalog, fetchImpl = globalThis.fetch }) {
this.sourceCatalog = sourceCatalog;
this.fetchImpl = fetchImpl;
this.queue = [];
this.pending = new Set();
this.running = false;
}
async saveSecrets(sourceId, secrets) {
return this.sourceCatalog.saveSourceSecrets(sourceId, secrets);
}
async scheduleEnhancement(sourceId, secrets = null) {
if (secrets) {
await this.sourceCatalog.saveSourceSecrets(sourceId, secrets);
}
await this.sourceCatalog.getSourceForEnhancement(sourceId);
await this.sourceCatalog.markEnhancementQueued(sourceId);
if (!this.pending.has(sourceId)) {
this.pending.add(sourceId);
this.queue.push(sourceId);
void this.drainQueue();
}
return this.sourceCatalog.getSource(sourceId);
}
async drainQueue() {
if (this.running) {
return;
}
this.running = true;
try {
while (this.queue.length > 0) {
const sourceId = this.queue.shift();
if (!sourceId) {
continue;
}
try {
await this.runEnhancement(sourceId);
} finally {
this.pending.delete(sourceId);
}
}
} finally {
this.running = false;
}
}
async runEnhancement(sourceId) {
let source;
let volumes = [];
let processedVolumes = 0;
let workDir = null;
let enhancedTmpPath = null;
let enhancedDbPath = null;
try {
const prepared = await this.sourceCatalog.getSourceForEnhancement(sourceId);
source = prepared.source;
const secrets = prepared.secrets;
workDir = path.join(source.sourceDir, 'work');
enhancedTmpPath = path.join(source.sourceDir, 'enhanced.sqlite.tmp');
enhancedDbPath = path.join(source.sourceDir, 'enhanced.sqlite');
const configuration = await loadSourceConfiguration(source.rawDbPath);
volumes = await loadBlockVolumes(source.rawDbPath);
if (volumes.length === 0) {
throw new EnhancementJobError(
'NO_DBLOCK_VOLUMES',
'No dblock volumes were found in Remotevolume, so enhancement cannot continue.'
);
}
await this.sourceCatalog.updateEnhancementState(sourceId, {
sourceStatus: 'running',
jobStatus: 'running',
phase: 'preparing',
processedVolumes: 0,
totalVolumes: volumes.length,
currentVolume: null,
lastErrorCode: null,
lastErrorMessage: null
});
await fsp.mkdir(workDir, { recursive: true });
await removeIfPresent(enhancedTmpPath);
await fs.promises.copyFile(source.rawDbPath, enhancedTmpPath);
await initializeEnhancedDatabase(enhancedTmpPath, source.sourceLayout, configuration);
await withWritableEnhancedDatabase(enhancedTmpPath, async (database) => {
await database.exec('BEGIN IMMEDIATE');
try {
for (const volume of volumes) {
const encryptedPath = path.join(workDir, `${volume.name}.download`);
const plainZipPath = path.join(workDir, `${volume.name}.zip.tmp`);
await this.sourceCatalog.updateEnhancementState(sourceId, {
sourceStatus: 'running',
jobStatus: 'running',
phase: 'fetching',
processedVolumes,
totalVolumes: volumes.length,
currentVolume: volume.name,
lastErrorCode: null,
lastErrorMessage: null
});
await removeIfPresent(encryptedPath);
await removeIfPresent(plainZipPath);
const response = await this.fetchImpl(buildRemoteUrl(secrets.webdavBaseUrl, volume.name), {
headers:
secrets.authMode === 'basic'
? {
Authorization: buildBasicAuthorization(secrets.username, secrets.password)
}
: {}
});
if (!response.ok) {
throw new EnhancementJobError(
'WEBDAV_FETCH_FAILED',
`Fetching ${volume.name} failed with HTTP ${response.status}.`,
{
currentVolume: volume.name,
processedVolumes,
totalVolumes: volumes.length
}
);
}
await downloadResponseBodyToFile(response, encryptedPath);
await this.sourceCatalog.updateEnhancementState(sourceId, {
sourceStatus: 'running',
jobStatus: 'running',
phase: 'decrypting',
processedVolumes,
totalVolumes: volumes.length,
currentVolume: volume.name,
lastErrorCode: null,
lastErrorMessage: null
});
const cryptoMeta = await decryptAesCryptFileToZip({
encryptedPath,
outputPath: plainZipPath,
passphrase: secrets.passphrase
});
await this.sourceCatalog.updateEnhancementState(sourceId, {
sourceStatus: 'running',
jobStatus: 'running',
phase: 'scanning',
processedVolumes,
totalVolumes: volumes.length,
currentVolume: volume.name,
lastErrorCode: null,
lastErrorMessage: null
});
const scanResult = await scanZipEntries(plainZipPath);
await insertVolumeScan(database, volume, scanResult, cryptoMeta);
processedVolumes += 1;
await this.sourceCatalog.updateEnhancementState(sourceId, {
sourceStatus: 'running',
jobStatus: 'running',
phase: 'scanning',
processedVolumes,
totalVolumes: volumes.length,
currentVolume: volume.name,
lastErrorCode: null,
lastErrorMessage: null
});
await removeIfPresent(encryptedPath);
await removeIfPresent(plainZipPath);
}
await database.exec('COMMIT');
} catch (error) {
try {
await database.exec('ROLLBACK');
} catch (rollbackError) {
// ignore cleanup failures after rollback
}
throw error;
}
});
await this.sourceCatalog.updateEnhancementState(sourceId, {
sourceStatus: 'running',
jobStatus: 'running',
phase: 'finalizing',
processedVolumes,
totalVolumes: volumes.length,
currentVolume: null,
lastErrorCode: null,
lastErrorMessage: null
});
await removeIfPresent(enhancedDbPath);
await fsp.rename(enhancedTmpPath, enhancedDbPath);
await this.sourceCatalog.finalizeEnhancementSuccess(sourceId, {
enhancedDbPath,
capabilities: {
archiveEntryIndex: true,
volumeCryptoCache: true
}
});
} catch (error) {
if (enhancedTmpPath) {
await removeIfPresent(enhancedTmpPath);
}
if (workDir) {
await removeIfPresent(workDir);
}
const normalized =
error instanceof EnhancementJobError
? error
: new EnhancementJobError('ENHANCEMENT_FAILED', error.message || 'Enhancement failed.', {
currentVolume: error.currentVolume ?? null,
processedVolumes,
totalVolumes: volumes.length
});
await this.sourceCatalog.finalizeEnhancementFailure(sourceId, normalized);
return;
}
if (workDir) {
await removeIfPresent(workDir);
}
}
}

19
test/fileId.test.js Normal file
View File

@ -0,0 +1,19 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { decodeOpaqueId, encodeOpaqueId } from '../src/lib/fileId.js';
test('opaque ids round-trip metadata', () => {
const token = encodeOpaqueId({
kind: 'file',
filesetDbId: 12,
dbPath: 'C:\\Data\\demo.mp4'
});
assert.deepEqual(decodeOpaqueId(token), {
v: 3,
kind: 'file',
filesetDbId: 12,
dbPath: 'C:\\Data\\demo.mp4'
});
});

45
test/helpers.test.js Normal file
View File

@ -0,0 +1,45 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
buildResolvedFilesetId,
computeCanonicalBlockSize,
normalizeCompressionMethod,
toZipEntryName
} from '../src/lib/duplicati.js';
import { normalizeApiPath, toApiPath } from '../src/lib/paths.js';
import { duplicatiTicksToIso, unixSecondsToIso } from '../src/lib/time.js';
test('db paths normalize to API paths', () => {
assert.equal(toApiPath('C:\\Users\\me\\movie.mp4'), '/C:/Users/me/movie.mp4');
assert.equal(normalizeApiPath('/movies/demo.mp4/'), '/movies/demo.mp4');
});
test('hashes convert to duplicati zip entry names', () => {
assert.equal(toZipEntryName('abc+/def='), 'abc-_def=');
});
test('compression methods normalize from zip numeric codes', () => {
assert.equal(normalizeCompressionMethod(8), 'deflate');
assert.equal(normalizeCompressionMethod('0'), 'store');
});
test('resolved fileset id is deterministic', () => {
assert.equal(buildResolvedFilesetId('2026-05-05T08:00:00.000Z'), 'fs_2026-05-05T08:00:00.000Z');
});
test('canonical block size chooses the dominant chunk size', () => {
assert.equal(
computeCanonicalBlockSize([
{ logicalSize: 102400 },
{ logicalSize: 102400 },
{ logicalSize: 4096 }
]),
102400
);
});
test('timestamps convert from unix seconds and .NET ticks', () => {
assert.equal(unixSecondsToIso(0), '1970-01-01T00:00:00.000Z');
assert.equal(duplicatiTicksToIso('621355968000000000'), '1970-01-01T00:00:00.000Z');
});

358
test/media-core.test.js Normal file
View File

@ -0,0 +1,358 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import {
clearAllCachedVolumes,
clearCachedVolumesForSource,
decryptAesCryptV2Bytes,
decryptAesCryptV2StreamToWriter,
extractZipEntryBytesFromBuffer,
getCachedVolumeStats,
getGlobalCacheStats,
listCachedVolumeNames,
parseSingleRange,
selectSegmentsForRange
} from '../public/modules/media-core.js';
import { scanZipEntries } from '../src/lib/zip.js';
import { createDuplicatiFixture } from './support/duplicatiFixture.js';
function createNotFoundError(message = 'Not found.') {
const error = new Error(message);
error.name = 'NotFoundError';
return error;
}
class FakeFile {
constructor(bytes) {
this.bytes = Uint8Array.from(bytes);
this.size = this.bytes.length;
}
slice(start = 0, end = this.bytes.length) {
const bytes = this.bytes.slice(start, end);
return {
async arrayBuffer() {
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
}
};
}
async arrayBuffer() {
return this.bytes.buffer.slice(this.bytes.byteOffset, this.bytes.byteOffset + this.bytes.byteLength);
}
}
class FakeFileHandle {
constructor(name, bytes) {
this.kind = 'file';
this.name = name;
this.file = new FakeFile(bytes);
}
async getFile() {
return this.file;
}
}
class FakeDirectoryHandle {
constructor(name = '') {
this.kind = 'directory';
this.name = name;
this.children = new Map();
}
setFile(name, bytes) {
this.children.set(name, new FakeFileHandle(name, bytes));
return this;
}
setDirectory(name, directoryHandle) {
this.children.set(name, directoryHandle);
return this;
}
async getDirectoryHandle(name, options = {}) {
const existing = this.children.get(name);
if (existing?.kind === 'directory') {
return existing;
}
if (options.create) {
const directory = new FakeDirectoryHandle(name);
this.children.set(name, directory);
return directory;
}
throw createNotFoundError(`Directory ${name} was not found.`);
}
async getFileHandle(name, options = {}) {
const existing = this.children.get(name);
if (existing?.kind === 'file') {
return existing;
}
if (options.create) {
const fileHandle = new FakeFileHandle(name, new Uint8Array(0));
this.children.set(name, fileHandle);
return fileHandle;
}
throw createNotFoundError(`File ${name} was not found.`);
}
async removeEntry(name) {
if (!this.children.has(name)) {
throw createNotFoundError(`Entry ${name} was not found.`);
}
this.children.delete(name);
}
async *entries() {
for (const entry of this.children.entries()) {
yield entry;
}
}
}
test('parseSingleRange supports explicit, open, and suffix byte ranges', () => {
assert.deepEqual(parseSingleRange('bytes=0-99', 1000), {
start: 0,
endInclusive: 99,
endExclusive: 100,
contentLength: 100,
partial: true
});
assert.deepEqual(parseSingleRange('bytes=200-', 1000), {
start: 200,
endInclusive: 999,
endExclusive: 1000,
contentLength: 800,
partial: true
});
assert.deepEqual(parseSingleRange('bytes=-128', 1000), {
start: 872,
endInclusive: 999,
endExclusive: 1000,
contentLength: 128,
partial: true
});
});
test('selectSegmentsForRange preserves cross-block slicing metadata', () => {
const overlaps = selectSegmentsForRange(
[
{ segmentIndex: 0, logicalOffset: 0, logicalSize: 8 },
{ segmentIndex: 1, logicalOffset: 8, logicalSize: 8 },
{ segmentIndex: 2, logicalOffset: 16, logicalSize: 8 }
],
4,
20
);
assert.deepEqual(
overlaps.map((entry) => ({
segmentIndex: entry.segment.segmentIndex,
offsetWithinSegmentStart: entry.offsetWithinSegmentStart,
offsetWithinSegmentEnd: entry.offsetWithinSegmentEnd
})),
[
{ segmentIndex: 0, offsetWithinSegmentStart: 4, offsetWithinSegmentEnd: 8 },
{ segmentIndex: 1, offsetWithinSegmentStart: 0, offsetWithinSegmentEnd: 8 },
{ segmentIndex: 2, offsetWithinSegmentStart: 0, offsetWithinSegmentEnd: 4 }
]
);
});
test('browser media helpers decrypt AES v2 volumes and restore zip entries', async () => {
const fixture = createDuplicatiFixture({
withRemoteVolumes: true,
includeArchiveEntryIndex: false,
includeVolumeCryptoCache: false
});
const tempDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'media-core-'));
const plainZipPath = path.join(tempDirectory, 'demo.zip');
try {
const encryptedDemoBytes = await fs.readFile(fixture.remoteFilesByName['duplicati-demo.dblock.zip.aes']);
const decryptedDemo = await decryptAesCryptV2Bytes(encryptedDemoBytes, fixture.passphrase);
await fs.writeFile(plainZipPath, decryptedDemo.plainBytes);
const scanResult = await scanZipEntries(plainZipPath);
const firstEntry = scanResult.entries.find(
(entry) => entry.entryName === '0td8NEaS7SMrQc5Gs0Sdxjb_1MXEEuwkyxRpguDiWsY='
);
assert.ok(firstEntry);
const firstBlock = await extractZipEntryBytesFromBuffer(decryptedDemo.plainBytes, {
dataOffsetPlain: firstEntry.dataOffset,
compressedSize: firstEntry.compressedSize,
uncompressedSize: firstEntry.uncompressedSize,
compressionMethod: firstEntry.compressionMethod
});
assert.equal(firstBlock.length, 102400);
assert.equal(firstBlock[0], 0x11);
assert.equal(firstBlock[firstBlock.length - 1], 0x11);
const encryptedTextBytes = await fs.readFile(fixture.remoteFilesByName['duplicati-text.dblock.zip.aes']);
const decryptedText = await decryptAesCryptV2Bytes(encryptedTextBytes, fixture.passphrase);
await fs.writeFile(plainZipPath, decryptedText.plainBytes);
const textScanResult = await scanZipEntries(plainZipPath);
const textEntry = textScanResult.entries.find((entry) => entry.entryName === 'ySjwbaRrk-rm6Vx0W0xP8A==');
assert.ok(textEntry);
const textBytes = await extractZipEntryBytesFromBuffer(decryptedText.plainBytes, {
dataOffsetPlain: textEntry.dataOffset,
compressedSize: textEntry.compressedSize,
uncompressedSize: textEntry.uncompressedSize,
compressionMethod: textEntry.compressionMethod
});
assert.equal(new TextDecoder().decode(textBytes), 'hello');
} finally {
fixture.cleanup();
await fs.rm(tempDirectory, { recursive: true, force: true });
}
});
test('streaming AES v2 decrypt writes plain zip bytes without buffering the whole response first', async () => {
const fixture = createDuplicatiFixture({
withRemoteVolumes: true,
includeArchiveEntryIndex: false,
includeVolumeCryptoCache: false
});
try {
const encryptedBytes = await fs.readFile(fixture.remoteFilesByName['duplicati-demo.dblock.zip.aes']);
const expected = await decryptAesCryptV2Bytes(encryptedBytes, fixture.passphrase);
const writtenChunks = [];
const writer = {
async write(bytes) {
writtenChunks.push(Uint8Array.from(bytes));
},
async close() {},
async abort() {
writtenChunks.length = 0;
}
};
const streamed = await decryptAesCryptV2StreamToWriter(
new Blob([encryptedBytes]).stream(),
fixture.passphrase,
writer
);
const actual = Uint8Array.from(writtenChunks.flatMap((chunk) => [...chunk]));
assert.deepEqual(actual, expected.plainBytes);
assert.deepEqual(streamed.meta, expected.meta);
} finally {
fixture.cleanup();
}
});
test('streaming AES v2 decrypt stops when the caller aborts the active request', async () => {
const fixture = createDuplicatiFixture({
withRemoteVolumes: true,
includeArchiveEntryIndex: false,
includeVolumeCryptoCache: false
});
try {
const encryptedBytes = await fs.readFile(fixture.remoteFilesByName['duplicati-demo.dblock.zip.aes']);
const controller = new AbortController();
controller.abort();
await assert.rejects(
() =>
decryptAesCryptV2StreamToWriter(
new Blob([encryptedBytes]).stream(),
fixture.passphrase,
{
async write() {},
async close() {},
async abort() {}
},
() => {},
controller.signal
),
(error) => error?.name === 'AbortError' && error?.code === 'MEDIA_REQUEST_ABORTED'
);
} finally {
fixture.cleanup();
}
});
test('browser media cache helpers summarize and clear source-scoped OPFS entries', async () => {
const root = new FakeDirectoryHandle('root');
const cacheRoot = new FakeDirectoryHandle('duplicati-media-cache');
const sourceA = new FakeDirectoryHandle('source-a')
.setFile('vol-1.zip', new Uint8Array(10))
.setFile('vol-2.zip', new Uint8Array(22));
const sourceB = new FakeDirectoryHandle('source-b')
.setFile('vol-3.zip', new Uint8Array(5));
cacheRoot.setDirectory('source-a', sourceA);
cacheRoot.setDirectory('source-b', sourceB);
root.setDirectory('duplicati-media-cache', cacheRoot);
const originalNavigator = globalThis.navigator;
Object.defineProperty(globalThis, 'navigator', {
configurable: true,
writable: true,
value: {
storage: {
async getDirectory() {
return root;
}
}
}
});
try {
assert.deepEqual(await listCachedVolumeNames('source-a'), ['vol-1.zip', 'vol-2.zip']);
assert.deepEqual(await getCachedVolumeStats('source-a'), {
sourceCount: 0,
volumeCount: 2,
totalBytes: 32
});
assert.deepEqual(await getGlobalCacheStats(), {
sourceCount: 2,
volumeCount: 3,
totalBytes: 37
});
assert.deepEqual(await clearCachedVolumesForSource('source-a'), {
removedSources: 1,
removedVolumes: 2,
removedBytes: 32
});
assert.deepEqual(await getGlobalCacheStats(), {
sourceCount: 1,
volumeCount: 1,
totalBytes: 5
});
assert.deepEqual(await clearAllCachedVolumes(), {
removedSources: 1,
removedVolumes: 1,
removedBytes: 5
});
assert.deepEqual(await getGlobalCacheStats(), {
sourceCount: 0,
volumeCount: 0,
totalBytes: 0
});
} finally {
Object.defineProperty(globalThis, 'navigator', {
configurable: true,
writable: true,
value: originalNavigator
});
}
});

View File

@ -0,0 +1,137 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { DuplicatiRepository } from '../src/db/duplicatiRepository.js';
import { openWritableSqlite } from '../src/db/sqlite.js';
import { createDuplicatiFixture } from './support/duplicatiFixture.js';
function createAppDatabasePath() {
const tempDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'duplicati-app-'));
return {
appDbPath: path.join(tempDirectory, 'app.sqlite'),
cleanup() {
fs.rmSync(tempDirectory, { recursive: true, force: true });
}
};
}
async function openAttachedRepository(sourceOverrides = {}) {
const fixture = createDuplicatiFixture(sourceOverrides);
const appDb = createAppDatabasePath();
const database = await openWritableSqlite(appDb.appDbPath);
await database.attachDatabase('source', fixture.databasePath, { readOnly: true });
const sourceLayout = sourceOverrides.layout ?? 'file';
const repository = new DuplicatiRepository({
database,
previewMaxBytes: 1024,
source: {
id: 'source-1',
storagePath: fixture.databasePath,
sha256: 'sha-demo',
capabilities: {
archiveEntryIndex: true,
volumeCryptoCache: true
}
},
sourceLayout
});
return {
fixture,
appDb,
repository,
database
};
}
test('listDirectory returns immediate directory and file children', async () => {
const session = await openAttachedRepository();
try {
const result = await session.repository.listDirectory({
apiPath: '/C:/media',
snapshotId: 'latest'
});
assert.equal(result.path, '/C:/media');
assert.equal(result.entries.length, 2);
assert.deepEqual(
result.entries.map((entry) => ({
type: entry.type,
path: entry.path
})),
[
{ type: 'dir', path: '/C:/media/movies' },
{ type: 'file', path: '/C:/media/readme.txt' }
]
);
} finally {
await session.database.detachDatabase('source');
await session.database.close();
session.fixture.cleanup();
session.appDb.cleanup();
}
});
test('getFileInfo expands ordered segments and required dblocks', async () => {
const session = await openAttachedRepository();
try {
const listing = await session.repository.listDirectory({
apiPath: '/C:/media/movies',
snapshotId: 'latest'
});
const fileId = listing.entries.find((entry) => entry.type === 'file')?.id;
assert.ok(fileId);
const result = await session.repository.getFileInfo(fileId);
assert.equal(result.file.path, '/C:/media/movies/demo.mp4');
assert.equal(result.restorePlan.segmentCount, 3);
assert.equal(result.restorePlan.blockSize, 102400);
assert.equal(result.requiredDblocks.length, 1);
assert.equal(result.requiredDblocks[0], 'duplicati-demo.dblock.zip.aes');
assert.equal(result.volumes[0].encryption.streamFormat, 'v2');
assert.equal(result.segments[0].logicalOffset, 0);
assert.equal(result.segments[1].logicalOffset, 102400);
assert.equal(result.segments[2].zip.compressionMethod, 'deflate');
} finally {
await session.database.detachDatabase('source');
await session.database.close();
session.fixture.cleanup();
session.appDb.cleanup();
}
});
test('listDirectory supports FileLookup plus PathPrefix based databases', async () => {
const session = await openAttachedRepository({ layout: 'file_lookup' });
try {
const result = await session.repository.listDirectory({
apiPath: '/source/media',
snapshotId: 'latest'
});
assert.equal(result.path, '/source/media');
assert.equal(result.entries.length, 2);
assert.deepEqual(
result.entries.map((entry) => ({
type: entry.type,
path: entry.path
})),
[
{ type: 'dir', path: '/source/media/movies' },
{ type: 'file', path: '/source/media/readme.txt' }
]
);
} finally {
await session.database.detachDatabase('source');
await session.database.close();
session.fixture.cleanup();
session.appDb.cleanup();
}
});

View File

@ -0,0 +1,743 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import { createServer } from 'node:http';
import { rm } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { createServerApp } from '../src/server.js';
import {
createDuplicatiFixture,
createDuplicatiServerDbFixture
} from './support/duplicatiFixture.js';
async function startTestServer() {
const tempDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'duplicati-server-'));
const appDbPath = path.join(tempDirectory, 'app.sqlite');
const uploadDir = path.join(tempDirectory, 'sources');
const runtime = await createServerApp({
appDbPath,
uploadDir,
previewMaxBytes: 1024
});
const server = await new Promise((resolve) => {
const instance = runtime.app.listen(0, () => resolve(instance));
});
const address = server.address();
const baseUrl = `http://127.0.0.1:${address.port}`;
return {
baseUrl,
appDbPath,
uploadDir,
runtime,
server,
async close() {
await new Promise((resolve, reject) => {
server.close((error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
await runtime.close();
await rm(tempDirectory, { recursive: true, force: true });
}
};
}
async function uploadFixture(baseUrl, fixture) {
return uploadDatabase(baseUrl, '/api/sources/upload', fixture);
}
async function uploadServerDbFixture(baseUrl, fixture) {
return uploadDatabase(baseUrl, '/api/server-db/upload', fixture);
}
async function uploadDatabase(baseUrl, endpoint, fixture) {
const form = new FormData();
const bytes = await fs.promises.readFile(fixture.databasePath);
form.set('database', new File([bytes], path.basename(fixture.databasePath), { type: 'application/octet-stream' }));
const response = await fetch(`${baseUrl}${endpoint}`, {
method: 'POST',
body: form
});
const payload = await response.json();
return { response, payload };
}
function createBasicHeader(username, password) {
return `Basic ${Buffer.from(`${username}:${password}`, 'utf8').toString('base64')}`;
}
async function startRemoteVolumeServer({ remoteDir, username, password }) {
const expectedAuth = createBasicHeader(username, password);
const server = createServer(async (request, response) => {
if ((request.headers.authorization ?? '') !== expectedAuth) {
response.writeHead(401, { 'WWW-Authenticate': 'Basic realm="test"' });
response.end('unauthorized');
return;
}
const filename = decodeURIComponent(new URL(request.url, 'http://127.0.0.1').pathname.slice(1));
const targetPath = path.join(remoteDir, filename);
try {
const stat = await fs.promises.stat(targetPath);
response.writeHead(200, {
'Content-Length': stat.size,
'Content-Type': 'application/octet-stream'
});
fs.createReadStream(targetPath).pipe(response);
} catch (error) {
response.writeHead(404);
response.end('missing');
}
});
await new Promise((resolve) => server.listen(0, resolve));
const address = server.address();
const baseUrl = `http://127.0.0.1:${address.port}/`;
return {
baseUrl,
server,
async close() {
await new Promise((resolve, reject) => {
server.close((error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
}
};
}
async function waitForEnhancementStatus(baseUrl, sourceId, allowedStatuses) {
for (let attempt = 0; attempt < 120; attempt += 1) {
const payload = await fetch(`${baseUrl}/api/sources/${sourceId}/enhance/status`).then((response) =>
response.json()
);
if (allowedStatuses.includes(payload.enhancement.status)) {
return payload.enhancement;
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
throw new Error(`Timed out waiting for enhancement status ${allowedStatuses.join(', ')}`);
}
test('multi-source uploads coexist, duplicate uploads reuse the existing source, and sourceId becomes required', async () => {
const server = await startTestServer();
const firstFixture = createDuplicatiFixture({
filename: 'first.sqlite',
layout: 'file_lookup',
includeArchiveEntryIndex: false,
includeVolumeCryptoCache: false
});
const secondFixture = createDuplicatiFixture({
filename: 'second.sqlite',
layout: 'file_lookup',
includeArchiveEntryIndex: false,
includeVolumeCryptoCache: false,
demoFileName: 'demo-v2.mp4',
demoFileHash: 'filehash-demo-v2=='
});
try {
const emptySources = await fetch(`${server.baseUrl}/api/sources`).then((response) => response.json());
assert.equal(emptySources.sources.length, 0);
const firstUpload = await uploadFixture(server.baseUrl, firstFixture);
assert.equal(firstUpload.response.status, 201);
assert.equal(firstUpload.payload.reused, false);
const duplicateUpload = await uploadFixture(server.baseUrl, firstFixture);
assert.equal(duplicateUpload.response.status, 200);
assert.equal(duplicateUpload.payload.reused, true);
assert.equal(duplicateUpload.payload.source.id, firstUpload.payload.source.id);
const secondUpload = await uploadFixture(server.baseUrl, secondFixture);
assert.equal(secondUpload.response.status, 201);
assert.notEqual(secondUpload.payload.source.id, firstUpload.payload.source.id);
const compatCurrent = await fetch(`${server.baseUrl}/api/source/current`);
const compatPayload = await compatCurrent.json();
assert.equal(compatCurrent.status, 409);
assert.equal(compatPayload.error.code, 'SOURCE_ID_REQUIRED');
const missingSourceId = await fetch(`${server.baseUrl}/api/ls?path=/source/media/movies`).then((response) =>
response.json()
);
assert.equal(missingSourceId.error.code, 'SOURCE_ID_REQUIRED');
const firstListing = await fetch(
`${server.baseUrl}/api/ls?sourceId=${firstUpload.payload.source.id}&path=/source/media/movies`
).then((response) => response.json());
assert.equal(firstListing.sourceId, firstUpload.payload.source.id);
const secondListing = await fetch(
`${server.baseUrl}/api/ls?sourceId=${secondUpload.payload.source.id}&path=/source/media/movies`
).then((response) => response.json());
assert.equal(secondListing.sourceId, secondUpload.payload.source.id);
const filesInSourceRoot = await fs.promises.readdir(server.uploadDir, { withFileTypes: true });
const sourceDirectories = filesInSourceRoot.filter(
(entry) => entry.isDirectory() && entry.name !== '_staging' && entry.name !== '_server'
);
assert.equal(sourceDirectories.length, 2);
} finally {
firstFixture.cleanup();
secondFixture.cleanup();
await server.close();
}
});
test('deleting a single source removes only that source and keeps the server database intact', async () => {
const server = await startTestServer();
const sourceA = createDuplicatiFixture({
filename: 'delete-a.sqlite',
layout: 'file_lookup',
includeArchiveEntryIndex: false,
includeVolumeCryptoCache: false
});
const sourceB = createDuplicatiFixture({
filename: 'delete-b.sqlite',
layout: 'file_lookup',
includeArchiveEntryIndex: false,
includeVolumeCryptoCache: false,
demoFileName: 'delete-b.mp4',
demoFileHash: 'delete-b-hash=='
});
const serverDb = createDuplicatiServerDbFixture({
backups: [
{ id: 21, name: 'Delete A', dbPath: '/data/Duplicati/delete-a.sqlite' },
{ id: 22, name: 'Delete B', dbPath: '/data/Duplicati/delete-b.sqlite' }
]
});
try {
const uploadA = await uploadFixture(server.baseUrl, sourceA);
const uploadB = await uploadFixture(server.baseUrl, sourceB);
const deleteSourceDir = path.join(server.uploadDir, uploadA.payload.source.id);
const keepSourceDir = path.join(server.uploadDir, uploadB.payload.source.id);
const serverDbUpload = await uploadServerDbFixture(server.baseUrl, serverDb);
assert.equal(serverDbUpload.response.status, 201);
const deleteResponse = await fetch(`${server.baseUrl}/api/sources/${uploadA.payload.source.id}`, {
method: 'DELETE'
});
const deletePayload = await deleteResponse.json();
assert.equal(deleteResponse.status, 200);
assert.equal(deletePayload.deleted.id, uploadA.payload.source.id);
const remainingSources = await fetch(`${server.baseUrl}/api/sources`).then((response) => response.json());
assert.equal(remainingSources.sources.length, 1);
assert.equal(remainingSources.sources[0].id, uploadB.payload.source.id);
const deletedLookup = await fetch(`${server.baseUrl}/api/sources/${uploadA.payload.source.id}`).then((response) =>
response.json()
);
assert.equal(deletedLookup.error.code, 'SOURCE_NOT_FOUND');
await assert.rejects(() => fs.promises.stat(deleteSourceDir));
const keptStat = await fs.promises.stat(keepSourceDir);
assert.equal(keptStat.isDirectory(), true);
const serverDbSummary = await fetch(`${server.baseUrl}/api/server-db`).then((response) => response.json());
assert.equal(serverDbSummary.serverDb.available, true);
assert.equal(serverDbSummary.serverDb.backupCount, 2);
} finally {
sourceA.cleanup();
sourceB.cleanup();
serverDb.cleanup();
await server.close();
}
});
test('server db upload maps task names onto existing sources and replacement remaps them', async () => {
const server = await startTestServer();
const sourceA = createDuplicatiFixture({
filename: 'TMQRJYNADS.sqlite',
layout: 'file_lookup',
includeArchiveEntryIndex: false,
includeVolumeCryptoCache: false
});
const sourceB = createDuplicatiFixture({
filename: 'ENVPLWAIWJ.sqlite',
layout: 'file_lookup',
includeArchiveEntryIndex: false,
includeVolumeCryptoCache: false,
demoFileName: 'env-demo.mp4',
demoFileHash: 'env-demo-hash=='
});
const firstServerDb = createDuplicatiServerDbFixture({
backups: [
{ id: 2, name: '四川农场主', dbPath: '/data/Duplicati/TMQRJYNADS.sqlite' },
{ id: 3, name: '小白', dbPath: '/data/Duplicati/ENVPLWAIWJ.sqlite' }
]
});
const replacementServerDb = createDuplicatiServerDbFixture({
backups: [
{ id: 2, name: '四川农场主-新', dbPath: '/data/Duplicati/TMQRJYNADS.sqlite' },
{ id: 3, name: '小白-新', dbPath: '/data/Duplicati/ENVPLWAIWJ.sqlite' }
]
});
try {
const invalidServerDbUpload = await uploadDatabase(server.baseUrl, '/api/sources/upload', firstServerDb);
assert.equal(invalidServerDbUpload.response.status, 422);
assert.equal(invalidServerDbUpload.payload.error.code, 'INVALID_SOURCE_SCHEMA');
const uploadA = await uploadFixture(server.baseUrl, sourceA);
const uploadB = await uploadFixture(server.baseUrl, sourceB);
assert.equal(uploadA.payload.source.displayName, 'TMQRJYNADS.sqlite');
assert.equal(uploadA.payload.source.displayNameSource, 'filename');
const firstServerDbUpload = await uploadServerDbFixture(server.baseUrl, firstServerDb);
assert.equal(firstServerDbUpload.response.status, 201);
assert.equal(firstServerDbUpload.payload.serverDb.available, true);
assert.equal(firstServerDbUpload.payload.serverDb.backupCount, 2);
const sourcesAfterMapping = await fetch(`${server.baseUrl}/api/sources`).then((response) => response.json());
const mappedA = sourcesAfterMapping.sources.find((source) => source.id === uploadA.payload.source.id);
const mappedB = sourcesAfterMapping.sources.find((source) => source.id === uploadB.payload.source.id);
assert.equal(mappedA.displayName, '四川农场主');
assert.equal(mappedA.displayNameSource, 'server-db');
assert.equal(mappedA.matchedBackupName, '四川农场主');
assert.equal(mappedB.displayName, '小白');
const serverDbSummary = await fetch(`${server.baseUrl}/api/server-db`).then((response) => response.json());
assert.equal(serverDbSummary.serverDb.available, true);
assert.equal(serverDbSummary.serverDb.backupCount, 2);
const replacementUpload = await uploadServerDbFixture(server.baseUrl, replacementServerDb);
assert.equal(replacementUpload.response.status, 201);
const sourcesAfterReplacement = await fetch(`${server.baseUrl}/api/sources`).then((response) => response.json());
const remappedA = sourcesAfterReplacement.sources.find((source) => source.id === uploadA.payload.source.id);
const remappedB = sourcesAfterReplacement.sources.find((source) => source.id === uploadB.payload.source.id);
assert.equal(remappedA.displayName, '四川农场主-新');
assert.equal(remappedB.displayName, '小白-新');
} finally {
sourceA.cleanup();
sourceB.cleanup();
firstServerDb.cleanup();
replacementServerDb.cleanup();
await server.close();
}
});
test('global WebDAV defaults plus server-db target URL allow enhancement without per-source credential re-entry', async () => {
const server = await startTestServer();
const fixture = createDuplicatiFixture({
filename: 'global-defaults.sqlite',
layout: 'file_lookup',
includeArchiveEntryIndex: false,
includeVolumeCryptoCache: false,
withRemoteVolumes: true
});
const remote = await startRemoteVolumeServer({
remoteDir: fixture.remoteDir,
username: 'demo',
password: 'secret'
});
const serverDb = createDuplicatiServerDbFixture({
backups: [
{
id: 7,
name: 'Global Defaults Demo',
dbPath: '/data/Duplicati/global-defaults.sqlite',
targetUrl: remote.baseUrl.replace('http://', 'webdav://')
}
]
});
try {
const upload = await uploadFixture(server.baseUrl, fixture);
const sourceId = upload.payload.source.id;
const defaultsResponse = await fetch(`${server.baseUrl}/api/webdav-defaults`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
authMode: 'basic',
username: 'demo',
password: 'secret',
passphrase: fixture.passphrase
})
});
const defaultsPayload = await defaultsResponse.json();
assert.equal(defaultsResponse.status, 200);
assert.equal(defaultsPayload.defaults.configured, true);
assert.equal(defaultsPayload.defaults.hasPassphrase, true);
const serverDbUpload = await uploadServerDbFixture(server.baseUrl, serverDb);
assert.equal(serverDbUpload.response.status, 201);
const sourceDetail = await fetch(`${server.baseUrl}/api/sources/${sourceId}`).then((response) =>
response.json()
);
assert.equal(sourceDetail.source.webdav.derivedWebdavBaseUrl, remote.baseUrl);
assert.equal(sourceDetail.source.webdav.effectiveWebdavBaseUrl, remote.baseUrl);
assert.equal(sourceDetail.source.webdav.effectiveWebdavBaseUrlSource, 'server-db');
const browserSecrets = await fetch(`${server.baseUrl}/api/sources/${sourceId}/browser-secrets`).then((response) =>
response.json()
);
assert.equal(browserSecrets.secrets.webdavBaseUrl, remote.baseUrl);
assert.equal(browserSecrets.secrets.username, 'demo');
assert.equal(browserSecrets.secrets.password, 'secret');
assert.equal(browserSecrets.secrets.passphrase, fixture.passphrase);
assert.equal(browserSecrets.secrets.authMode, 'basic');
const enhanceResponse = await fetch(`${server.baseUrl}/api/sources/${sourceId}/enhance`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({})
});
assert.equal(enhanceResponse.status, 202);
const enhancement = await waitForEnhancementStatus(server.baseUrl, sourceId, ['ready', 'failed']);
assert.equal(enhancement.status, 'ready');
} finally {
await remote.close();
fixture.cleanup();
serverDb.cleanup();
await server.close();
}
});
test('raw source keeps browsing available, gates file-info, and does not leak saved secrets', async () => {
const server = await startTestServer();
const rawFixture = createDuplicatiFixture({
filename: 'raw-job.sqlite',
layout: 'file_lookup',
includeArchiveEntryIndex: false,
includeVolumeCryptoCache: false
});
try {
const upload = await uploadFixture(server.baseUrl, rawFixture);
assert.equal(upload.response.status, 201);
const sourceId = upload.payload.source.id;
const listing = await fetch(
`${server.baseUrl}/api/ls?sourceId=${sourceId}&path=/source/media/movies`
).then((response) => response.json());
const fileId = listing.entries.find((entry) => entry.type === 'file')?.id;
assert.ok(fileId);
const fileInfoBeforeEnhance = await fetch(
`${server.baseUrl}/api/file-info?sourceId=${sourceId}&id=${encodeURIComponent(fileId)}`
).then((response) => response.json());
assert.equal(fileInfoBeforeEnhance.error.code, 'ENHANCEMENT_NOT_READY');
const saveSecretsResponse = await fetch(`${server.baseUrl}/api/sources/${sourceId}/secrets`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
webdavBaseUrl: 'http://127.0.0.1:9999/remote/',
username: 'demo',
password: 'secret',
passphrase: 'passphrase',
authMode: 'basic'
})
});
const saveSecretsPayload = await saveSecretsResponse.json();
assert.equal(saveSecretsResponse.status, 200);
assert.equal(saveSecretsPayload.source.credentialsSaved, true);
assert.equal(saveSecretsPayload.source.password, undefined);
assert.equal(saveSecretsPayload.source.passphrase, undefined);
const sourceDetail = await fetch(`${server.baseUrl}/api/sources/${sourceId}`).then((response) =>
response.json()
);
assert.equal(sourceDetail.source.credentialsSaved, true);
assert.equal(sourceDetail.source.username, undefined);
assert.equal(sourceDetail.source.password, undefined);
} finally {
rawFixture.cleanup();
await server.close();
}
});
test('enhancement job builds archive indexes from Basic-auth WebDAV volumes and old file ids stay valid', async () => {
const server = await startTestServer();
const fixture = createDuplicatiFixture({
filename: 'enhance.sqlite',
layout: 'file_lookup',
includeArchiveEntryIndex: false,
includeVolumeCryptoCache: false,
withRemoteVolumes: true,
includeAesExtensions: true
});
const remote = await startRemoteVolumeServer({
remoteDir: fixture.remoteDir,
username: 'demo',
password: 'secret'
});
try {
const upload = await uploadFixture(server.baseUrl, fixture);
const sourceId = upload.payload.source.id;
const listing = await fetch(
`${server.baseUrl}/api/ls?sourceId=${sourceId}&path=/source/media/movies`
).then((response) => response.json());
const fileId = listing.entries.find((entry) => entry.type === 'file')?.id;
assert.ok(fileId);
const enhanceResponse = await fetch(`${server.baseUrl}/api/sources/${sourceId}/enhance`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
webdavBaseUrl: remote.baseUrl,
username: 'demo',
password: 'secret',
passphrase: fixture.passphrase,
authMode: 'basic'
})
});
assert.equal(enhanceResponse.status, 202);
const enhancement = await waitForEnhancementStatus(server.baseUrl, sourceId, ['ready', 'failed']);
assert.equal(enhancement.status, 'ready');
assert.equal(enhancement.processedVolumes, 2);
assert.equal(enhancement.totalVolumes, 2);
const sourceDetail = await fetch(`${server.baseUrl}/api/sources/${sourceId}`).then((response) =>
response.json()
);
assert.equal(sourceDetail.source.capabilities.archiveEntryIndex, true);
assert.equal(sourceDetail.source.capabilities.volumeCryptoCache, true);
const fileInfo = await fetch(
`${server.baseUrl}/api/file-info?sourceId=${sourceId}&id=${encodeURIComponent(fileId)}`
).then((response) => response.json());
assert.equal(fileInfo.file.path, '/source/media/movies/demo.mp4');
assert.equal(fileInfo.restorePlan.segmentCount, 3);
assert.equal(fileInfo.requiredDblocks[0], 'duplicati-demo.dblock.zip.aes');
assert.equal(fileInfo.volumes[0].encryption.streamFormat, 'v2');
} finally {
await remote.close();
fixture.cleanup();
await server.close();
}
});
test('video thumbnails can be uploaded, listed, fetched, deleted, and cleared per source', async () => {
const server = await startTestServer();
const fixture = createDuplicatiFixture({
filename: 'thumbs.sqlite',
layout: 'file_lookup',
includeArchiveEntryIndex: false,
includeVolumeCryptoCache: false,
withRemoteVolumes: true
});
const remote = await startRemoteVolumeServer({
remoteDir: fixture.remoteDir,
username: 'demo',
password: 'secret'
});
try {
const upload = await uploadFixture(server.baseUrl, fixture);
const sourceId = upload.payload.source.id;
const enhanceResponse = await fetch(`${server.baseUrl}/api/sources/${sourceId}/enhance`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
webdavBaseUrl: remote.baseUrl,
username: 'demo',
password: 'secret',
passphrase: fixture.passphrase,
authMode: 'basic'
})
});
assert.equal(enhanceResponse.status, 202);
const enhancement = await waitForEnhancementStatus(server.baseUrl, sourceId, ['ready', 'failed']);
assert.equal(enhancement.status, 'ready');
const listingBefore = await fetch(
`${server.baseUrl}/api/ls?sourceId=${sourceId}&path=/source/media/movies`
).then((response) => response.json());
const fileEntry = listingBefore.entries.find((entry) => entry.type === 'file');
assert.ok(fileEntry);
assert.equal(fileEntry.thumbnail.available, false);
const fileInfoBefore = await fetch(
`${server.baseUrl}/api/file-info?sourceId=${sourceId}&id=${encodeURIComponent(fileEntry.id)}`
).then((response) => response.json());
assert.equal(fileInfoBefore.file.thumbnail.available, false);
const thumbnailForm = new FormData();
thumbnailForm.set('fileId', fileEntry.id);
thumbnailForm.set(
'image',
new File([Uint8Array.from([0x52, 0x49, 0x46, 0x46, 0x10, 0x00, 0x00, 0x00])], 'preview.webp', {
type: 'image/webp'
})
);
const thumbnailUploadResponse = await fetch(`${server.baseUrl}/api/sources/${sourceId}/thumbnails`, {
method: 'POST',
body: thumbnailForm
});
const thumbnailUploadPayload = await thumbnailUploadResponse.json();
assert.equal(thumbnailUploadResponse.status, 201);
assert.equal(thumbnailUploadPayload.thumbnail.available, true);
assert.ok(thumbnailUploadPayload.thumbnail.thumbnailId);
const thumbnailFetchResponse = await fetch(`${server.baseUrl}${thumbnailUploadPayload.thumbnail.thumbnailUrl}`);
assert.equal(thumbnailFetchResponse.status, 200);
assert.equal(thumbnailFetchResponse.headers.get('content-type'), 'image/webp');
assert.deepEqual(new Uint8Array(await thumbnailFetchResponse.arrayBuffer()), Uint8Array.from([0x52, 0x49, 0x46, 0x46, 0x10, 0x00, 0x00, 0x00]));
const listingAfterUpload = await fetch(
`${server.baseUrl}/api/ls?sourceId=${sourceId}&path=/source/media/movies`
).then((response) => response.json());
const listedFileAfterUpload = listingAfterUpload.entries.find((entry) => entry.id === fileEntry.id);
assert.equal(listedFileAfterUpload.thumbnail.available, true);
assert.equal(listedFileAfterUpload.thumbnail.thumbnailId, thumbnailUploadPayload.thumbnail.thumbnailId);
const fileInfoAfterUpload = await fetch(
`${server.baseUrl}/api/file-info?sourceId=${sourceId}&id=${encodeURIComponent(fileEntry.id)}`
).then((response) => response.json());
assert.equal(fileInfoAfterUpload.file.thumbnail.available, true);
const sourceDetailAfterUpload = await fetch(`${server.baseUrl}/api/sources/${sourceId}`).then((response) =>
response.json()
);
assert.equal(sourceDetailAfterUpload.source.thumbnailCache.count, 1);
const singleDeleteResponse = await fetch(
`${server.baseUrl}/api/sources/${sourceId}/thumbnails/${encodeURIComponent(thumbnailUploadPayload.thumbnail.thumbnailId)}`,
{
method: 'DELETE'
}
);
const singleDeletePayload = await singleDeleteResponse.json();
assert.equal(singleDeleteResponse.status, 200);
assert.equal(singleDeletePayload.deleted.fileId, fileEntry.id);
const listingAfterSingleDelete = await fetch(
`${server.baseUrl}/api/ls?sourceId=${sourceId}&path=/source/media/movies`
).then((response) => response.json());
const listedFileAfterDelete = listingAfterSingleDelete.entries.find((entry) => entry.id === fileEntry.id);
assert.equal(listedFileAfterDelete.thumbnail.available, false);
const secondThumbnailForm = new FormData();
secondThumbnailForm.set('fileId', fileEntry.id);
secondThumbnailForm.set(
'image',
new File([Uint8Array.from([0x57, 0x45, 0x42, 0x50])], 'preview-2.webp', {
type: 'image/webp'
})
);
const secondUploadResponse = await fetch(`${server.baseUrl}/api/sources/${sourceId}/thumbnails`, {
method: 'POST',
body: secondThumbnailForm
});
assert.equal(secondUploadResponse.status, 201);
const clearResponse = await fetch(`${server.baseUrl}/api/sources/${sourceId}/thumbnails`, {
method: 'DELETE'
});
const clearPayload = await clearResponse.json();
assert.equal(clearResponse.status, 200);
assert.equal(clearPayload.cleared.removedCount, 1);
const sourceDetailAfterClear = await fetch(`${server.baseUrl}/api/sources/${sourceId}`).then((response) =>
response.json()
);
assert.equal(sourceDetailAfterClear.source.thumbnailCache.count, 0);
} finally {
await remote.close();
fixture.cleanup();
await server.close();
}
});
test('failed enhancement keeps directory browsing available and surfaces ENHANCEMENT_FAILED', async () => {
const server = await startTestServer();
const fixture = createDuplicatiFixture({
filename: 'enhance-fail.sqlite',
layout: 'file_lookup',
includeArchiveEntryIndex: false,
includeVolumeCryptoCache: false,
withRemoteVolumes: true
});
const remote = await startRemoteVolumeServer({
remoteDir: fixture.remoteDir,
username: 'demo',
password: 'secret'
});
try {
const upload = await uploadFixture(server.baseUrl, fixture);
const sourceId = upload.payload.source.id;
const listingBefore = await fetch(
`${server.baseUrl}/api/ls?sourceId=${sourceId}&path=/source/media/movies`
).then((response) => response.json());
const fileId = listingBefore.entries.find((entry) => entry.type === 'file')?.id;
assert.ok(fileId);
const enhanceResponse = await fetch(`${server.baseUrl}/api/sources/${sourceId}/enhance`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
webdavBaseUrl: remote.baseUrl,
username: 'demo',
password: 'secret',
passphrase: 'wrong-passphrase',
authMode: 'basic'
})
});
assert.equal(enhanceResponse.status, 202);
const enhancement = await waitForEnhancementStatus(server.baseUrl, sourceId, ['ready', 'failed']);
assert.equal(enhancement.status, 'failed');
const listingAfter = await fetch(
`${server.baseUrl}/api/ls?sourceId=${sourceId}&path=/source/media/movies`
).then((response) => response.json());
assert.equal(listingAfter.entries.length, listingBefore.entries.length);
const fileInfo = await fetch(
`${server.baseUrl}/api/file-info?sourceId=${sourceId}&id=${encodeURIComponent(fileId)}`
).then((response) => response.json());
assert.equal(fileInfo.error.code, 'ENHANCEMENT_FAILED');
} finally {
await remote.close();
fixture.cleanup();
await server.close();
}
});

View File

@ -0,0 +1,429 @@
import { createCipheriv, createHash, createHmac, randomBytes } from 'node:crypto';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { deflateRawSync } from 'node:zlib';
import { DatabaseSync } from 'node:sqlite';
function createCrc32Table() {
const table = new Uint32Array(256);
for (let index = 0; index < 256; index += 1) {
let value = index;
for (let bit = 0; bit < 8; bit += 1) {
value = (value & 1) === 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1;
}
table[index] = value >>> 0;
}
return table;
}
const CRC32_TABLE = createCrc32Table();
function crc32(buffer) {
let value = 0xffffffff;
for (const byte of buffer) {
value = CRC32_TABLE[(value ^ byte) & 0xff] ^ (value >>> 8);
}
return (value ^ 0xffffffff) >>> 0;
}
function stretchPasswordV2(passphrase, externalIv) {
const passwordBytes = Buffer.from(String(passphrase), 'utf16le');
let digest = Buffer.concat([externalIv, Buffer.alloc(16, 0)]);
for (let index = 0; index < 8192; index += 1) {
const hash = createHash('sha256');
hash.update(digest);
hash.update(passwordBytes);
digest = hash.digest();
}
return digest;
}
function createAesCryptV2Buffer(plainBuffer, passphrase, options = {}) {
const externalIv = randomBytes(16);
const internalIv = randomBytes(16);
const internalKey = randomBytes(32);
const stretchedKey = stretchPasswordV2(passphrase, externalIv);
const sessionBytes = Buffer.concat([internalIv, internalKey]);
const headerCipher = createCipheriv('aes-256-cbc', stretchedKey, externalIv);
headerCipher.setAutoPadding(false);
const encryptedSession = Buffer.concat([
headerCipher.update(sessionBytes),
headerCipher.final()
]);
const headerHmac = createHmac('sha256', stretchedKey).update(encryptedSession).digest();
const fileSizeModulo = plainBuffer.length % 16;
const padLength = fileSizeModulo === 0 ? 0 : 16 - fileSizeModulo;
const paddedPlain = padLength > 0
? Buffer.concat([plainBuffer, Buffer.alloc(padLength, padLength)])
: plainBuffer;
const payloadCipher = createCipheriv('aes-256-cbc', internalKey, internalIv);
payloadCipher.setAutoPadding(false);
const ciphertext = Buffer.concat([
payloadCipher.update(paddedPlain),
payloadCipher.final()
]);
const payloadHmac = createHmac('sha256', internalKey).update(ciphertext).digest();
const extensionRecords = options.extensionRecords ?? [];
const extensionBytes = extensionRecords.flatMap((record) => {
const id = Buffer.from(record.id);
const data = Buffer.from(record.data ?? []);
const payload = Buffer.concat([id, data]);
const length = Buffer.alloc(2);
length.writeUInt16BE(payload.length, 0);
return [length, payload];
});
return Buffer.concat([
Buffer.from('AES', 'utf8'),
Buffer.from([2, 0]),
...extensionBytes,
Buffer.from([0, 0]),
externalIv,
encryptedSession,
headerHmac,
ciphertext,
Buffer.from([fileSizeModulo]),
payloadHmac
]);
}
function createZipBuffer(entries) {
const localParts = [];
const centralParts = [];
let localOffset = 0;
for (const entry of entries) {
const nameBuffer = Buffer.from(entry.name, 'utf8');
const input = Buffer.from(entry.data);
const compressedData =
entry.compression === 'store' ? input : deflateRawSync(input);
const compressionMethod = entry.compression === 'store' ? 0 : 8;
const crc = crc32(input);
const localHeader = Buffer.alloc(30);
localHeader.writeUInt32LE(0x04034b50, 0);
localHeader.writeUInt16LE(20, 4);
localHeader.writeUInt16LE(0, 6);
localHeader.writeUInt16LE(compressionMethod, 8);
localHeader.writeUInt16LE(0, 10);
localHeader.writeUInt16LE(0, 12);
localHeader.writeUInt32LE(crc, 14);
localHeader.writeUInt32LE(compressedData.length, 18);
localHeader.writeUInt32LE(input.length, 22);
localHeader.writeUInt16LE(nameBuffer.length, 26);
localHeader.writeUInt16LE(0, 28);
localParts.push(localHeader, nameBuffer, compressedData);
const centralHeader = Buffer.alloc(46);
centralHeader.writeUInt32LE(0x02014b50, 0);
centralHeader.writeUInt16LE(20, 4);
centralHeader.writeUInt16LE(20, 6);
centralHeader.writeUInt16LE(0, 8);
centralHeader.writeUInt16LE(compressionMethod, 10);
centralHeader.writeUInt16LE(0, 12);
centralHeader.writeUInt16LE(0, 14);
centralHeader.writeUInt32LE(crc, 16);
centralHeader.writeUInt32LE(compressedData.length, 20);
centralHeader.writeUInt32LE(input.length, 24);
centralHeader.writeUInt16LE(nameBuffer.length, 28);
centralHeader.writeUInt16LE(0, 30);
centralHeader.writeUInt16LE(0, 32);
centralHeader.writeUInt16LE(0, 34);
centralHeader.writeUInt16LE(0, 36);
centralHeader.writeUInt32LE(0, 38);
centralHeader.writeUInt32LE(localOffset, 42);
centralParts.push(centralHeader, nameBuffer);
localOffset += localHeader.length + nameBuffer.length + compressedData.length;
}
const centralDirectory = Buffer.concat(centralParts);
const localData = Buffer.concat(localParts);
const eocd = Buffer.alloc(22);
eocd.writeUInt32LE(0x06054b50, 0);
eocd.writeUInt16LE(0, 4);
eocd.writeUInt16LE(0, 6);
eocd.writeUInt16LE(entries.length, 8);
eocd.writeUInt16LE(entries.length, 10);
eocd.writeUInt32LE(centralDirectory.length, 12);
eocd.writeUInt32LE(localData.length, 16);
eocd.writeUInt16LE(0, 20);
return Buffer.concat([localData, centralDirectory, eocd]);
}
function buildRemoteVolumeBuffers() {
const demoEntries = [
{
name: '0td8NEaS7SMrQc5Gs0Sdxjb_1MXEEuwkyxRpguDiWsY=',
data: Buffer.alloc(102400, 0x11),
compression: 'deflate'
},
{
name: 'PN2oO6eQudCRSdx3zgk6SJvlI5BquP6djt5hG4ZfRCQ=',
data: Buffer.alloc(102400, 0x22),
compression: 'deflate'
},
{
name: 'uS_2KMSmm2IWlZ77JiHH1p_yp7Cvhr8CKmRHJNMRqwA=',
data: Buffer.alloc(10240, 0x33),
compression: 'deflate'
}
];
const textEntries = [
{
name: 'ySjwbaRrk-rm6Vx0W0xP8A==',
data: Buffer.from('hello', 'utf8'),
compression: 'store'
}
];
return {
'duplicati-demo.dblock.zip.aes': createZipBuffer(demoEntries),
'duplicati-text.dblock.zip.aes': createZipBuffer(textEntries)
};
}
export function createDuplicatiFixture(options = {}) {
const tempDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'duplicati-api-'));
const databasePath = path.join(tempDirectory, options.filename ?? 'fixture.sqlite');
const database = new DatabaseSync(databasePath);
const includeArchiveEntryIndex = options.includeArchiveEntryIndex !== false;
const includeVolumeCryptoCache = options.includeVolumeCryptoCache !== false;
const layout = options.layout ?? 'file';
const passphrase = options.passphrase ?? 'test-passphrase';
const withRemoteVolumes = options.withRemoteVolumes === true;
const includeAesExtensions = options.includeAesExtensions === true;
database.exec(`
CREATE TABLE "Fileset" (
"ID" INTEGER PRIMARY KEY,
"Timestamp" INTEGER NOT NULL
);
CREATE TABLE "Configuration" (
"Key" TEXT PRIMARY KEY,
"Value" TEXT NOT NULL
);
${layout === 'file'
? `CREATE TABLE "File" (
"ID" INTEGER PRIMARY KEY,
"Path" TEXT NOT NULL,
"BlocksetID" INTEGER NOT NULL,
"MetadataID" INTEGER NULL
);`
: `CREATE TABLE "FileLookup" (
"ID" INTEGER PRIMARY KEY,
"PrefixID" INTEGER NOT NULL,
"Path" TEXT NOT NULL,
"BlocksetID" INTEGER NOT NULL,
"MetadataID" INTEGER NULL
);
CREATE TABLE "PathPrefix" (
"ID" INTEGER PRIMARY KEY,
"Prefix" TEXT NOT NULL
);`}
CREATE TABLE "FilesetEntry" (
"FilesetID" INTEGER NOT NULL,
"FileID" INTEGER NOT NULL,
"Lastmodified" INTEGER NOT NULL
);
CREATE TABLE "Blockset" (
"ID" INTEGER PRIMARY KEY,
"Length" INTEGER NOT NULL,
"FullHash" TEXT NULL
);
CREATE TABLE "Block" (
"ID" INTEGER PRIMARY KEY,
"Hash" TEXT NOT NULL,
"Size" INTEGER NOT NULL,
"VolumeID" INTEGER NOT NULL
);
CREATE TABLE "BlocksetEntry" (
"BlocksetID" INTEGER NOT NULL,
"Index" INTEGER NOT NULL,
"BlockID" INTEGER NOT NULL
);
CREATE TABLE "Remotevolume" (
"ID" INTEGER PRIMARY KEY,
"OperationID" INTEGER NULL,
"Name" TEXT NOT NULL,
"Type" TEXT NULL,
"Size" INTEGER NULL,
"Hash" TEXT NULL,
"State" TEXT NULL,
"VerificationCount" INTEGER NULL,
"DeleteGraceTime" INTEGER NULL,
"ArchiveTime" INTEGER NULL,
"LockExpirationTime" INTEGER NULL
);
${includeArchiveEntryIndex ? `CREATE TABLE "archive_entry_index" (
"volume_name" TEXT NOT NULL,
"entry_name" TEXT NOT NULL,
"local_header_offset_plain" INTEGER NOT NULL,
"data_offset_plain" INTEGER NOT NULL,
"compressed_size" INTEGER NOT NULL,
"uncompressed_size" INTEGER NOT NULL,
"compression_method" TEXT NOT NULL,
"crc32" TEXT NOT NULL,
PRIMARY KEY ("volume_name", "entry_name")
);` : ''}
${includeVolumeCryptoCache ? `CREATE TABLE "volume_crypto_cache" (
"volume_name" TEXT PRIMARY KEY,
"stream_format" TEXT NULL,
"header_probe_bytes" INTEGER NULL,
"kdf_iterations" INTEGER NULL,
"salt_hex" TEXT NULL,
"iv_hex" TEXT NULL
);` : ''}
`);
const fileName = options.demoFileName ?? 'demo.mp4';
const fileHash = options.demoFileHash ?? 'filehash-demo==';
const fileEntriesSql =
layout === 'file'
? `
INSERT INTO "File" ("ID", "Path", "BlocksetID", "MetadataID") VALUES
(10, 'C:\\\\media\\\\movies', -1, NULL),
(11, 'C:\\\\media\\\\movies\\\\${fileName}', 101, NULL),
(12, 'C:\\\\media\\\\readme.txt', 102, NULL);
`
: `
INSERT INTO "PathPrefix" ("ID", "Prefix") VALUES
(1, '/source/media/'),
(2, '/source/media/movies/');
INSERT INTO "FileLookup" ("ID", "PrefixID", "Path", "BlocksetID", "MetadataID") VALUES
(10, 1, 'movies/', -100, NULL),
(11, 2, '${fileName}', 101, NULL),
(12, 1, 'readme.txt', 102, NULL);
`;
database.exec(`
INSERT INTO "Fileset" ("ID", "Timestamp") VALUES (1, 1714896000);
INSERT INTO "Configuration" ("Key", "Value") VALUES
('blocksize', '102400'),
('blockhash', 'SHA256'),
('filehash', 'SHA256');
${fileEntriesSql}
INSERT INTO "FilesetEntry" ("FilesetID", "FileID", "Lastmodified") VALUES
(1, 10, 638504448000000000),
(1, 11, 638504448110000000),
(1, 12, 638504448220000000);
INSERT INTO "Blockset" ("ID", "Length", "FullHash") VALUES
(101, 215040, '${fileHash}'),
(102, 5, 'filehash-readme==');
INSERT INTO "Block" ("ID", "Hash", "Size", "VolumeID") VALUES
(201, '0td8NEaS7SMrQc5Gs0Sdxjb/1MXEEuwkyxRpguDiWsY=', 102400, 301),
(202, 'PN2oO6eQudCRSdx3zgk6SJvlI5BquP6djt5hG4ZfRCQ=', 102400, 301),
(203, 'uS/2KMSmm2IWlZ77JiHH1p/yp7Cvhr8CKmRHJNMRqwA=', 10240, 301),
(204, 'ySjwbaRrk+rm6Vx0W0xP8A==', 5, 302);
INSERT INTO "BlocksetEntry" ("BlocksetID", "Index", "BlockID") VALUES
(101, 0, 201),
(101, 1, 202),
(101, 2, 203),
(102, 0, 204);
INSERT INTO "Remotevolume" ("ID", "OperationID", "Name", "Type", "Size", "Hash", "State", "VerificationCount", "DeleteGraceTime", "ArchiveTime", "LockExpirationTime") VALUES
(301, 1, 'duplicati-demo.dblock.zip.aes', 'Blocks', NULL, NULL, 'Verified', 0, 0, 0, 0),
(302, 1, 'duplicati-text.dblock.zip.aes', 'Blocks', NULL, NULL, 'Verified', 0, 0, 0, 0);
${includeArchiveEntryIndex ? `INSERT INTO "archive_entry_index"
("volume_name", "entry_name", "local_header_offset_plain", "data_offset_plain", "compressed_size", "uncompressed_size", "compression_method", "crc32")
VALUES
('duplicati-demo.dblock.zip.aes', '0td8NEaS7SMrQc5Gs0Sdxjb_1MXEEuwkyxRpguDiWsY=', 32768, 32852, 86124, 102400, '8', '6b4f0d2a'),
('duplicati-demo.dblock.zip.aes', 'PN2oO6eQudCRSdx3zgk6SJvlI5BquP6djt5hG4ZfRCQ=', 119104, 119188, 85571, 102400, '8', '0fbd3c11'),
('duplicati-demo.dblock.zip.aes', 'uS_2KMSmm2IWlZ77JiHH1p_yp7Cvhr8CKmRHJNMRqwA=', 205824, 205908, 9941, 10240, '8', '2a739d5b'),
('duplicati-text.dblock.zip.aes', 'ySjwbaRrk-rm6Vx0W0xP8A==', 4096, 4176, 7, 5, '0', '00abc123');` : ''}
${includeVolumeCryptoCache ? `INSERT INTO "volume_crypto_cache"
("volume_name", "stream_format", "header_probe_bytes", "kdf_iterations", "salt_hex", "iv_hex")
VALUES
('duplicati-demo.dblock.zip.aes', 'v2', 103, 8192, NULL, '0123456789abcdef'),
('duplicati-text.dblock.zip.aes', 'v2', 103, 8192, NULL, 'fedcba9876543210');` : ''}
`);
database.close();
let remoteDir = null;
let remoteFilesByName = {};
if (withRemoteVolumes) {
remoteDir = path.join(tempDirectory, 'remote');
fs.mkdirSync(remoteDir, { recursive: true });
const plainZips = buildRemoteVolumeBuffers();
remoteFilesByName = Object.fromEntries(
Object.entries(plainZips).map(([volumeName, plainZipBuffer]) => {
const encryptedBuffer = createAesCryptV2Buffer(plainZipBuffer, passphrase, {
extensionRecords: includeAesExtensions
? [
{
id: Buffer.from([0x10, 0x20]),
data: Buffer.from('duplicati-test-extension', 'utf8')
}
]
: []
});
const filename = path.join(remoteDir, volumeName);
fs.writeFileSync(filename, encryptedBuffer);
return [volumeName, filename];
})
);
}
return {
databasePath,
tempDirectory,
remoteDir,
remoteFilesByName,
passphrase,
cleanup() {
fs.rmSync(tempDirectory, { recursive: true, force: true });
}
};
}
export function createDuplicatiServerDbFixture(options = {}) {
const tempDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'duplicati-server-db-'));
const databasePath = path.join(tempDirectory, options.filename ?? 'Duplicati-server.sqlite');
const database = new DatabaseSync(databasePath);
const backups = options.backups ?? [
{
id: 1,
name: 'Example Backup',
dbPath: '/data/Duplicati/EXAMPLE.sqlite'
}
];
database.exec(`
CREATE TABLE "Backup" (
"ID" INTEGER PRIMARY KEY,
"Name" TEXT NOT NULL,
"DBPath" TEXT NOT NULL,
"TargetURL" TEXT NULL
);
`);
for (const backup of backups) {
database
.prepare('INSERT INTO "Backup" ("ID", "Name", "DBPath", "TargetURL") VALUES (?, ?, ?, ?)')
.run(backup.id, backup.name, backup.dbPath, backup.targetUrl ?? null);
}
database.close();
return {
databasePath,
tempDirectory,
backups,
cleanup() {
fs.rmSync(tempDirectory, { recursive: true, force: true });
}
};
}

View File

@ -0,0 +1,40 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
analyzeFrameLuma,
buildThumbnailCandidateTimes
} from '../public/modules/thumbnail-utils.js';
test('buildThumbnailCandidateTimes prefers early seconds and appends a bounded fallback', () => {
assert.deepEqual(buildThumbnailCandidateTimes(0), [0.15]);
assert.deepEqual(buildThumbnailCandidateTimes(1.2), [0.15, 0.5, 1, 1.15]);
assert.deepEqual(buildThumbnailCandidateTimes(6), [0.15, 0.5, 1, 2, 3.5, 5, 5.95]);
});
test('analyzeFrameLuma marks near-black frames as black and bright frames as usable', () => {
const blackFrame = new Uint8ClampedArray(64 * 4).fill(0);
for (let index = 3; index < blackFrame.length; index += 4) {
blackFrame[index] = 255;
}
const brightFrame = new Uint8ClampedArray(64 * 4);
for (let index = 0; index < brightFrame.length; index += 4) {
brightFrame[index] = 200;
brightFrame[index + 1] = 180;
brightFrame[index + 2] = 120;
brightFrame[index + 3] = 255;
}
const blackAnalysis = analyzeFrameLuma(blackFrame, {
stride: 1
});
const brightAnalysis = analyzeFrameLuma(brightFrame, {
stride: 1
});
assert.equal(blackAnalysis.isBlackFrame, true);
assert.equal(brightAnalysis.isBlackFrame, false);
assert.ok(blackAnalysis.darkRatio > brightAnalysis.darkRatio);
assert.ok(brightAnalysis.averageLuma > blackAnalysis.averageLuma);
});