feat: add auth and dual-stack tls management
This commit is contained in:
parent
cc19ee1936
commit
fdec8960c2
@ -1,4 +1,7 @@
|
||||
PORT=3000
|
||||
HTTPS_PORT=3443
|
||||
APP_DB_PATH=./data/app.sqlite
|
||||
UPLOAD_DIR=./data/sources
|
||||
TLS_DIR=./data/tls
|
||||
PREVIEW_MAX_BYTES=16777216
|
||||
AUTH_SESSION_TTL_DAYS=30
|
||||
|
||||
@ -4,8 +4,10 @@ FROM ${NODE_IMAGE}
|
||||
|
||||
ENV NODE_ENV=production \
|
||||
PORT=3000 \
|
||||
HTTPS_PORT=3443 \
|
||||
APP_DB_PATH=/app/data/app.sqlite \
|
||||
UPLOAD_DIR=/app/data/sources \
|
||||
TLS_DIR=/app/data/tls \
|
||||
PREVIEW_MAX_BYTES=16777216
|
||||
|
||||
WORKDIR /app
|
||||
@ -18,13 +20,13 @@ COPY src ./src
|
||||
COPY sql ./sql
|
||||
COPY README.md ./
|
||||
|
||||
RUN mkdir -p /app/data/sources \
|
||||
RUN mkdir -p /app/data/sources /app/data/tls \
|
||||
&& chown -R node:node /app
|
||||
|
||||
USER node
|
||||
|
||||
VOLUME ["/app/data"]
|
||||
|
||||
EXPOSE 3000
|
||||
EXPOSE 3000 3443
|
||||
|
||||
CMD ["node", "src/server.js"]
|
||||
|
||||
62
README.md
62
README.md
@ -9,6 +9,7 @@ Thin `Node.js + Express` metadata service for Duplicati restores. The backend ow
|
||||
- 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:
|
||||
- `/login.html` for first-run setup and sign-in
|
||||
- `/` for source management
|
||||
- `/source.html?sourceId=...` for a source workbench
|
||||
- `/file.html?sourceId=...&id=...` for video preview and browser-direct downloads
|
||||
@ -24,6 +25,14 @@ Thin `Node.js + Express` metadata service for Duplicati restores. The backend ow
|
||||
- `GET /api/sources`
|
||||
- `GET /api/sources/:sourceId`
|
||||
- `POST /api/sources/upload`
|
||||
- `GET /api/auth/state`
|
||||
- `POST /api/auth/setup`
|
||||
- `POST /api/auth/login`
|
||||
- `POST /api/auth/logout`
|
||||
- `GET /api/system/tls`
|
||||
- `PUT /api/system/tls`
|
||||
- `POST /api/system/tls/upload`
|
||||
- `DELETE /api/system/tls/custom-certificate`
|
||||
- `GET /api/server-db`
|
||||
- `POST /api/server-db/upload`
|
||||
- `PUT /api/sources/:sourceId/secrets`
|
||||
@ -51,9 +60,62 @@ npm start
|
||||
Environment variables:
|
||||
|
||||
- `PORT`: HTTP port, default `3000`
|
||||
- `HTTPS_PORT`: HTTPS port, default `3443`
|
||||
- `APP_DB_PATH`: service-owned SQLite path, default `./data/app.sqlite`
|
||||
- `UPLOAD_DIR`: source storage root, default `./data/sources`
|
||||
- `TLS_DIR`: certificate storage root, default `./data/tls`
|
||||
- `PREVIEW_MAX_BYTES`: max file size flagged as safe for in-memory preview, default `16777216`
|
||||
- `AUTH_SESSION_TTL_DAYS`: login session lifetime in days, default `30`
|
||||
|
||||
## Authentication
|
||||
|
||||
The system now requires username/password login before any source-management or restore API can be used.
|
||||
|
||||
- On first run, open `/login.html` and create the initial administrator account.
|
||||
- After setup, the protected pages `/`, `/source.html`, and `/file.html` redirect unauthenticated users back to the login page.
|
||||
- API routes return:
|
||||
- `401 AUTH_SETUP_REQUIRED` when no administrator exists yet
|
||||
- `401 AUTH_REQUIRED` when there is no valid session
|
||||
- `401 SESSION_EXPIRED` when a saved session has expired
|
||||
|
||||
Implementation notes:
|
||||
|
||||
- credentials are stored in the service-owned `app.sqlite`
|
||||
- passwords are hashed with Node's built-in `scrypt`
|
||||
- sessions are stored server-side in SQLite and issued as `HttpOnly` cookies
|
||||
- the same cookie is intentionally usable over both HTTP and HTTPS, so it is not forced to `Secure`
|
||||
- `/healthz` stays public so Docker/Jenkins health checks do not need to log in
|
||||
|
||||
## HTTPS and certificates
|
||||
|
||||
The service now starts both listeners at the same time:
|
||||
|
||||
- HTTP on `PORT`
|
||||
- HTTPS on `HTTPS_PORT`
|
||||
|
||||
There is no forced redirect. You can open either protocol directly.
|
||||
|
||||
TLS behavior:
|
||||
|
||||
- if no custom certificate is configured, the app automatically generates and keeps a self-signed certificate so HTTPS always works
|
||||
- the self-signed certificate is regenerated from the configured primary domain and SAN list when you save self-signed TLS settings
|
||||
- custom certificates can be configured from the homepage either by pasting PEM text or by uploading PEM files
|
||||
- deleting the custom certificate switches the service back to the generated self-signed certificate immediately
|
||||
|
||||
Certificate files are stored under `TLS_DIR`, while certificate metadata and the active mode are stored in `app.sqlite`.
|
||||
|
||||
## Docker deployment note
|
||||
|
||||
Expose both ports when running the container:
|
||||
|
||||
- `3000` for HTTP
|
||||
- `3443` for HTTPS
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
docker run --rm -p 3000:3000 -p 3443:3443 duplicati-preview
|
||||
```
|
||||
|
||||
## Multi-source model
|
||||
|
||||
|
||||
273
package-lock.json
generated
273
package-lock.json
generated
@ -10,12 +10,182 @@
|
||||
"dependencies": {
|
||||
"aes-js": "^3.1.2",
|
||||
"express": "^4.22.1",
|
||||
"mime-types": "^2.1.35"
|
||||
"mime-types": "^2.1.35",
|
||||
"selfsigned": "^5.5.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/hashes": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmmirror.com/@noble/hashes/-/hashes-1.4.0.tgz",
|
||||
"integrity": "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-cms": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmmirror.com/@peculiar/asn1-cms/-/asn1-cms-2.7.0.tgz",
|
||||
"integrity": "sha512-hew63shtzzvBcSHbhm+cyAmKe6AIfinT9hzEqSPjDC6opTTMKmTkQ0gHuN2KsWlvqiKw1S/fS94fhag/FJkioQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.7.0",
|
||||
"@peculiar/asn1-x509": "^2.7.0",
|
||||
"@peculiar/asn1-x509-attr": "^2.7.0",
|
||||
"asn1js": "^3.0.6",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-csr": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmmirror.com/@peculiar/asn1-csr/-/asn1-csr-2.7.0.tgz",
|
||||
"integrity": "sha512-VVsAyGqErT9D1SY4aEqozThXMVI+ssVRiv2DDeYuvpBKLIgZ3hYs3Ay3u/VSoKq6ESFi9cf6rf3IOOzfwh7oMA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.7.0",
|
||||
"@peculiar/asn1-x509": "^2.7.0",
|
||||
"asn1js": "^3.0.6",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-ecc": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmmirror.com/@peculiar/asn1-ecc/-/asn1-ecc-2.7.0.tgz",
|
||||
"integrity": "sha512-n7KEs/Q/wrB415cxy4fHOBhegp4NdJ15fkJPwcB/3/8iNBQC2L/N7SChJPKDJPZGYH0jD4Tg4/0vnHmwghnbKw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.7.0",
|
||||
"@peculiar/asn1-x509": "^2.7.0",
|
||||
"asn1js": "^3.0.6",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-pfx": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmmirror.com/@peculiar/asn1-pfx/-/asn1-pfx-2.7.0.tgz",
|
||||
"integrity": "sha512-V/nrlQVmhg7lYAsM7E13UDL5erAwFv6kCIVFqNaMIHSVi7dngcT839JkRTkQBqznMG98l2XjxYk74ZztAohZzA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-cms": "^2.7.0",
|
||||
"@peculiar/asn1-pkcs8": "^2.7.0",
|
||||
"@peculiar/asn1-rsa": "^2.7.0",
|
||||
"@peculiar/asn1-schema": "^2.7.0",
|
||||
"asn1js": "^3.0.6",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-pkcs8": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmmirror.com/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.7.0.tgz",
|
||||
"integrity": "sha512-9GTl1nE8Mx1kTZ+7QyYatDyKsm34QcWRBFkY1iPvWC3X4Dona5s/tlLiQsx5WzVdZqiMBZNYT0buyw4/vbhnjw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.7.0",
|
||||
"@peculiar/asn1-x509": "^2.7.0",
|
||||
"asn1js": "^3.0.6",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-pkcs9": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmmirror.com/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.7.0.tgz",
|
||||
"integrity": "sha512-Bh7m+OuIaSEllPQcSd9OSp93F4ROWH7sbITWV8MI+8dwsjE5111/87VxiWVvYFKyww3vp39geLv9ENqhwWHcew==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-cms": "^2.7.0",
|
||||
"@peculiar/asn1-pfx": "^2.7.0",
|
||||
"@peculiar/asn1-pkcs8": "^2.7.0",
|
||||
"@peculiar/asn1-schema": "^2.7.0",
|
||||
"@peculiar/asn1-x509": "^2.7.0",
|
||||
"@peculiar/asn1-x509-attr": "^2.7.0",
|
||||
"asn1js": "^3.0.6",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-rsa": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmmirror.com/@peculiar/asn1-rsa/-/asn1-rsa-2.7.0.tgz",
|
||||
"integrity": "sha512-/qvENQrXyTZURjMqSeofHul0JJt2sNSzSwk36pl2olkHbaioMQgrASDZAlHXl0xUlnVbHj0uGgOrBMTb5x2aJQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.7.0",
|
||||
"@peculiar/asn1-x509": "^2.7.0",
|
||||
"asn1js": "^3.0.6",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-schema": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmmirror.com/@peculiar/asn1-schema/-/asn1-schema-2.7.0.tgz",
|
||||
"integrity": "sha512-W8ZfWzLmQnrcky+eh3tni4IozMdqBDiHWU0N+vve/UGjMaUs8c0L7A2oEdkBXS8rTpWDpK/aoI3DG/L/hxmxPg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/utils": "^2.0.2",
|
||||
"asn1js": "^3.0.6",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-x509": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmmirror.com/@peculiar/asn1-x509/-/asn1-x509-2.7.0.tgz",
|
||||
"integrity": "sha512-mUn9RRrkGDnG4ALfunDmzyRW5dg+sWCj/pfnCCqEHYbkGxEpvUt6iVJv8Yw1cyp6SWZ26ZE5oSmI5SqEaen15g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.7.0",
|
||||
"@peculiar/utils": "^2.0.2",
|
||||
"asn1js": "^3.0.6",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-x509-attr": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmmirror.com/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.7.0.tgz",
|
||||
"integrity": "sha512-NS8e7SOgXipkzUPLF/sce7ukpMpWjhxYsH0n6Y+bHYo4TTxOb95Zv7hqwSuL212mj5YxovjdOKQOgH1As3E94w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.7.0",
|
||||
"@peculiar/asn1-x509": "^2.7.0",
|
||||
"asn1js": "^3.0.6",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/utils": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmmirror.com/@peculiar/utils/-/utils-2.0.3.tgz",
|
||||
"integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/x509": {
|
||||
"version": "1.14.3",
|
||||
"resolved": "https://registry.npmmirror.com/@peculiar/x509/-/x509-1.14.3.tgz",
|
||||
"integrity": "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-cms": "^2.6.0",
|
||||
"@peculiar/asn1-csr": "^2.6.0",
|
||||
"@peculiar/asn1-ecc": "^2.6.0",
|
||||
"@peculiar/asn1-pkcs9": "^2.6.0",
|
||||
"@peculiar/asn1-rsa": "^2.6.0",
|
||||
"@peculiar/asn1-schema": "^2.6.0",
|
||||
"@peculiar/asn1-x509": "^2.6.0",
|
||||
"pvtsutils": "^1.3.6",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"tslib": "^2.8.1",
|
||||
"tsyringe": "^4.10.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/accepts": {
|
||||
"version": "1.3.8",
|
||||
"resolved": "https://registry.npmmirror.com/accepts/-/accepts-1.3.8.tgz",
|
||||
@ -41,6 +211,20 @@
|
||||
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/asn1js": {
|
||||
"version": "3.0.10",
|
||||
"resolved": "https://registry.npmmirror.com/asn1js/-/asn1js-3.0.10.tgz",
|
||||
"integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"pvtsutils": "^1.3.6",
|
||||
"pvutils": "^1.1.5",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "1.20.5",
|
||||
"resolved": "https://registry.npmmirror.com/body-parser/-/body-parser-1.20.5.tgz",
|
||||
@ -89,6 +273,15 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/bytestreamjs": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/bytestreamjs/-/bytestreamjs-2.0.1.tgz",
|
||||
"integrity": "sha512-U1Z/ob71V/bXfVABvNr/Kumf5VyeQRBEm6Txb0PQ6S7V5GpBM3w4Cbqz/xPDicR5tN0uvDifng8C+5qECeGwyQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"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",
|
||||
@ -590,6 +783,23 @@
|
||||
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pkijs": {
|
||||
"version": "3.4.0",
|
||||
"resolved": "https://registry.npmmirror.com/pkijs/-/pkijs-3.4.0.tgz",
|
||||
"integrity": "sha512-emEcLuomt2j03vxD54giVB4SxTjnsqkU692xZOZXHDVoYyypEm+b3jpiTcc+Cf+myooc+/Ly0z01jqeNHVgJGw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "1.4.0",
|
||||
"asn1js": "^3.0.6",
|
||||
"bytestreamjs": "^2.0.1",
|
||||
"pvtsutils": "^1.3.6",
|
||||
"pvutils": "^1.1.3",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-addr": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmmirror.com/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
||||
@ -603,6 +813,24 @@
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/pvtsutils": {
|
||||
"version": "1.3.6",
|
||||
"resolved": "https://registry.npmmirror.com/pvtsutils/-/pvtsutils-1.3.6.tgz",
|
||||
"integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/pvutils": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmmirror.com/pvutils/-/pvutils-1.1.5.tgz",
|
||||
"integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.14.2",
|
||||
"resolved": "https://registry.npmmirror.com/qs/-/qs-6.14.2.tgz",
|
||||
@ -642,6 +870,12 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/reflect-metadata": {
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmmirror.com/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
|
||||
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
@ -668,6 +902,19 @@
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/selfsigned": {
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmmirror.com/selfsigned/-/selfsigned-5.5.0.tgz",
|
||||
"integrity": "sha512-ftnu3TW4+3eBfLRFnDEkzGxSF/10BJBkaLJuBHZX0kiPS7bRdlpZGu6YGt4KngMkdTwJE6MbjavFpqHvqVt+Ew==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/x509": "^1.14.2",
|
||||
"pkijs": "^3.3.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/send": {
|
||||
"version": "0.19.2",
|
||||
"resolved": "https://registry.npmmirror.com/send/-/send-0.19.2.tgz",
|
||||
@ -809,6 +1056,30 @@
|
||||
"node": ">=0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/tsyringe": {
|
||||
"version": "4.10.0",
|
||||
"resolved": "https://registry.npmmirror.com/tsyringe/-/tsyringe-4.10.0.tgz",
|
||||
"integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^1.9.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tsyringe/node_modules/tslib": {
|
||||
"version": "1.14.1",
|
||||
"resolved": "https://registry.npmmirror.com/tslib/-/tslib-1.14.1.tgz",
|
||||
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/type-is": {
|
||||
"version": "1.6.18",
|
||||
"resolved": "https://registry.npmmirror.com/type-is/-/type-is-1.6.18.tgz",
|
||||
|
||||
@ -11,11 +11,12 @@
|
||||
"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"
|
||||
"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/services/authService.js && node --check src/services/tlsService.js && node --check src/lib/aesCrypt.js && node --check src/lib/zip.js && node --check src/lib/auth.js && node --check public/app.js && node --check public/source.js && node --check public/file.js && node --check public/login.js && node --check public/media-sw.js && node --check public/modules/common.js && node --check public/modules/auth-client.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"
|
||||
"mime-types": "^2.1.35",
|
||||
"selfsigned": "^5.5.0"
|
||||
}
|
||||
}
|
||||
|
||||
375
public/app.js
375
public/app.js
@ -5,13 +5,15 @@ import {
|
||||
linkToSource,
|
||||
renderSourceSummaryCard
|
||||
} from './modules/common.js';
|
||||
import { bindLogoutButton, loadAuthState } from './modules/auth-client.js';
|
||||
|
||||
const CONTROL_VIEWS = new Set(['overview', 'sources', 'upload', 'server-db', 'defaults']);
|
||||
const CONTROL_VIEWS = new Set(['overview', 'sources', 'upload', 'server-db', 'defaults', 'tls']);
|
||||
|
||||
const state = {
|
||||
sources: [],
|
||||
serverDb: null,
|
||||
globalDefaults: null,
|
||||
tls: null,
|
||||
currentView: 'overview'
|
||||
};
|
||||
|
||||
@ -23,11 +25,19 @@ const serverDbUploadButton = document.querySelector('#serverDbUploadButton');
|
||||
const serverDbInput = document.querySelector('#serverDbInput');
|
||||
const globalDefaultsForm = document.querySelector('#globalDefaultsForm');
|
||||
const globalDefaultsSaveButton = document.querySelector('#globalDefaultsSaveButton');
|
||||
const tlsJsonForm = document.querySelector('#tlsJsonForm');
|
||||
const tlsUploadForm = document.querySelector('#tlsUploadForm');
|
||||
const tlsSaveButton = document.querySelector('#tlsSaveButton');
|
||||
const tlsUploadButton = document.querySelector('#tlsUploadButton');
|
||||
const tlsDeleteCustomButton = document.querySelector('#tlsDeleteCustomButton');
|
||||
const refreshButton = document.querySelector('#refreshButton');
|
||||
const serverDbSummaryElement = document.querySelector('#serverDbSummary');
|
||||
const globalDefaultsSummaryElement = document.querySelector('#globalDefaultsSummary');
|
||||
const tlsSummaryElement = document.querySelector('#tlsSummary');
|
||||
const overviewSummaryElement = document.querySelector('#overviewSummary');
|
||||
const sourceListElement = document.querySelector('#sourceList');
|
||||
const authStatusElement = document.querySelector('#authStatus');
|
||||
const logoutButton = document.querySelector('#logoutButton');
|
||||
const viewPanels = [...document.querySelectorAll('[data-view-panel]')];
|
||||
const viewLinks = [...document.querySelectorAll('[data-view-link]')];
|
||||
const feedback = createFeedbackController(document.querySelector('#feedback'));
|
||||
@ -62,6 +72,23 @@ function setCurrentView(nextView, { syncHash = true, replace = false } = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
function renderSummaryRows(rows) {
|
||||
return `
|
||||
<div class="summary-stack">
|
||||
${rows
|
||||
.map(
|
||||
(row) => `
|
||||
<div class="summary-row">
|
||||
<span>${escapeHtml(row.label)}</span>
|
||||
<strong>${escapeHtml(row.value)}</strong>
|
||||
</div>
|
||||
`
|
||||
)
|
||||
.join('')}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function readGlobalDefaultsForm() {
|
||||
return {
|
||||
webdavBaseUrl: document.querySelector('#globalWebdavBaseUrl')?.value?.trim() ?? '',
|
||||
@ -80,131 +107,115 @@ function writeGlobalDefaultsForm(defaults) {
|
||||
document.querySelector('#globalPassphrase').value = '';
|
||||
}
|
||||
|
||||
function readTlsJsonForm() {
|
||||
return {
|
||||
mode: document.querySelector('#tlsMode')?.value ?? 'self-signed',
|
||||
primaryDomain: document.querySelector('#tlsPrimaryDomain')?.value?.trim() ?? '',
|
||||
subjectAltNames: document.querySelector('#tlsSubjectAltNames')?.value ?? '',
|
||||
certPem: document.querySelector('#tlsCertPem')?.value ?? '',
|
||||
keyPem: document.querySelector('#tlsKeyPem')?.value ?? '',
|
||||
chainPem: document.querySelector('#tlsChainPem')?.value ?? ''
|
||||
};
|
||||
}
|
||||
|
||||
function writeTlsForm(tlsSummary) {
|
||||
document.querySelector('#tlsMode').value = tlsSummary?.mode ?? 'self-signed';
|
||||
document.querySelector('#tlsPrimaryDomain').value = tlsSummary?.primaryDomain ?? '';
|
||||
document.querySelector('#tlsSubjectAltNames').value = Array.isArray(tlsSummary?.subjectAltNames)
|
||||
? tlsSummary.subjectAltNames.join('\n')
|
||||
: '';
|
||||
document.querySelector('#tlsCertPem').value = '';
|
||||
document.querySelector('#tlsKeyPem').value = '';
|
||||
document.querySelector('#tlsChainPem').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>
|
||||
`;
|
||||
overviewSummaryElement.innerHTML = renderSummaryRows([
|
||||
{ label: 'Total sources', value: String(sourceCount) },
|
||||
{ label: 'Enhanced sources', value: String(readyCount) },
|
||||
{ label: 'Failed enhancements', value: String(failedCount) },
|
||||
{ label: 'Mapped task names', value: String(mappedCount) },
|
||||
{ label: 'Server DB', value: state.serverDb?.available ? state.serverDb.originalFilename : 'Not uploaded' },
|
||||
{ label: 'Global WebDAV', value: state.globalDefaults?.configured ? 'Configured' : 'Not configured' },
|
||||
{ label: 'Active certificate', value: state.tls?.activeSource ?? 'Unavailable' }
|
||||
]);
|
||||
}
|
||||
|
||||
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>
|
||||
`;
|
||||
if (!state.serverDb?.available) {
|
||||
serverDbSummaryElement.innerHTML = renderSummaryRows([
|
||||
{ label: 'Status', value: 'No server database uploaded yet' },
|
||||
{ label: 'Note', value: 'Task names and inferred target URLs stay unavailable until you upload Duplicati-server.sqlite.' }
|
||||
]);
|
||||
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>
|
||||
`;
|
||||
serverDbSummaryElement.innerHTML = renderSummaryRows([
|
||||
{ label: 'Current file', value: state.serverDb.originalFilename },
|
||||
{ label: 'Uploaded at', value: state.serverDb.uploadedAt },
|
||||
{ label: 'Backup entries', value: String(state.serverDb.backupCount) }
|
||||
]);
|
||||
}
|
||||
|
||||
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);
|
||||
globalDefaultsSummaryElement.innerHTML = renderSummaryRows([
|
||||
{ label: 'Status', value: 'No global defaults saved' },
|
||||
{ label: 'Note', value: 'Save shared credentials here if most tasks use the same WebDAV account and passphrase.' }
|
||||
]);
|
||||
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>
|
||||
`;
|
||||
globalDefaultsSummaryElement.innerHTML = renderSummaryRows([
|
||||
{ label: 'Fallback URL', value: defaults.webdavBaseUrl ?? 'Not set' },
|
||||
{ label: 'Auth mode', value: defaults.authMode ?? 'Auto' },
|
||||
{
|
||||
label: 'Stored secrets',
|
||||
value: `username=${defaults.hasUsername ? 'yes' : 'no'}, password=${defaults.hasPassword ? 'yes' : 'no'}, passphrase=${defaults.hasPassphrase ? 'yes' : 'no'}`
|
||||
},
|
||||
{ label: 'Updated at', value: defaults.updatedAt ?? 'Unknown' }
|
||||
]);
|
||||
}
|
||||
|
||||
function renderTlsSummary() {
|
||||
if (!state.tls) {
|
||||
tlsSummaryElement.innerHTML = renderSummaryRows([{ label: 'Status', value: 'TLS settings not loaded yet' }]);
|
||||
return;
|
||||
}
|
||||
|
||||
writeTlsForm(state.tls);
|
||||
tlsDeleteCustomButton.disabled = !state.tls.hasCustomCertificate;
|
||||
tlsSummaryElement.innerHTML = renderSummaryRows([
|
||||
{ label: 'HTTP endpoint', value: state.tls.access?.httpBaseUrl ?? 'Unknown' },
|
||||
{ label: 'HTTPS endpoint', value: state.tls.access?.httpsBaseUrl ?? 'Unknown' },
|
||||
{ label: 'Configured mode', value: state.tls.mode ?? 'self-signed' },
|
||||
{ label: 'Active certificate', value: state.tls.activeSource ?? 'self-signed' },
|
||||
{ label: 'Primary domain', value: state.tls.primaryDomain || 'localhost' },
|
||||
{ label: 'Extra SAN entries', value: (state.tls.subjectAltNames ?? []).join(', ') || 'None' },
|
||||
{
|
||||
label: 'Validity',
|
||||
value: state.tls.certificate ? `${state.tls.certificate.validFrom} -> ${state.tls.certificate.validTo}` : 'Unknown'
|
||||
},
|
||||
{ label: 'Fingerprint', value: state.tls.certificate?.fingerprint256 ?? 'Unknown' },
|
||||
{ label: 'Last error', value: state.tls.lastErrorMessage ?? 'None' },
|
||||
{ label: 'Cookie policy', value: state.tls.cookieNote ?? 'Shared between HTTP and HTTPS' }
|
||||
]);
|
||||
}
|
||||
|
||||
function renderSources() {
|
||||
if (state.sources.length === 0) {
|
||||
sourceListElement.innerHTML = `
|
||||
<div class="empty-state-card">
|
||||
<p>还没有任何任务数据源。</p>
|
||||
<p>先上传一个随机名任务库,然后再进入工作台浏览目录、做增强或进入文件页预览下载。</p>
|
||||
<p>No task sources uploaded yet.</p>
|
||||
<p>Upload a Duplicati task database first, then open the source workbench to browse, enhance, preview, or download.</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
@ -213,16 +224,16 @@ function renderSources() {
|
||||
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>';
|
||||
? `<div class="summary-chip">Default target URL source: ${escapeHtml(source.webdav.effectiveWebdavBaseUrlSource ?? 'unknown')}</div>`
|
||||
: '<div class="summary-chip">No effective default target URL yet</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>
|
||||
<a class="ghost-button button-link" href="${linkToSource(source.id)}#overview">Open workspace</a>
|
||||
<button class="ghost-button danger-button" type="button" data-delete-source-id="${escapeHtml(source.id)}">Delete source</button>
|
||||
</div>
|
||||
`
|
||||
);
|
||||
@ -244,6 +255,13 @@ async function loadGlobalDefaults() {
|
||||
renderOverviewSummary();
|
||||
}
|
||||
|
||||
async function loadTlsSummary() {
|
||||
const payload = await fetchJson('/api/system/tls');
|
||||
state.tls = payload.tls;
|
||||
renderTlsSummary();
|
||||
renderOverviewSummary();
|
||||
}
|
||||
|
||||
async function loadSources() {
|
||||
const payload = await fetchJson('/api/sources');
|
||||
state.sources = payload.sources;
|
||||
@ -257,13 +275,13 @@ async function handleTaskDbUpload(event) {
|
||||
|
||||
const file = databaseInput.files?.[0];
|
||||
if (!file) {
|
||||
feedback.set('error', '请先选择一个任务 SQLite 文件。');
|
||||
feedback.set('error', 'Choose a task SQLite file first.');
|
||||
return;
|
||||
}
|
||||
|
||||
uploadButton.disabled = true;
|
||||
refreshButton.disabled = true;
|
||||
feedback.set('info', '正在上传任务数据库...');
|
||||
feedback.set('info', 'Uploading task database...');
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
@ -284,8 +302,8 @@ async function handleTaskDbUpload(event) {
|
||||
feedback.set(
|
||||
payload.reused ? 'info' : 'success',
|
||||
payload.reused
|
||||
? '这个任务库之前已经上传过,系统复用了现有 source。'
|
||||
: '任务数据库上传成功,可以进入对应工作台继续操作。'
|
||||
? 'That task database was already uploaded, so the existing source was reused.'
|
||||
: 'Task database uploaded successfully.'
|
||||
);
|
||||
} catch (error) {
|
||||
feedback.set('error', error.message);
|
||||
@ -301,12 +319,12 @@ async function handleServerDbUpload(event) {
|
||||
|
||||
const file = serverDbInput.files?.[0];
|
||||
if (!file) {
|
||||
feedback.set('error', '请先选择 Duplicati-server.sqlite。');
|
||||
feedback.set('error', 'Choose Duplicati-server.sqlite first.');
|
||||
return;
|
||||
}
|
||||
|
||||
serverDbUploadButton.disabled = true;
|
||||
feedback.set('info', '正在上传 Server DB 并刷新任务名与目标 URL 映射...');
|
||||
feedback.set('info', 'Uploading Server DB and refreshing task-name mappings...');
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
@ -323,7 +341,7 @@ async function handleServerDbUpload(event) {
|
||||
|
||||
await Promise.all([loadSources(), loadServerDbSummary()]);
|
||||
serverDbForm.reset();
|
||||
feedback.set('success', 'Server DB 上传成功,任务名和默认目标 URL 已刷新。');
|
||||
feedback.set('success', 'Server DB uploaded successfully.');
|
||||
} catch (error) {
|
||||
feedback.set('error', error.message);
|
||||
} finally {
|
||||
@ -335,7 +353,7 @@ async function handleGlobalDefaultsSave(event) {
|
||||
event.preventDefault();
|
||||
feedback.clear();
|
||||
globalDefaultsSaveButton.disabled = true;
|
||||
feedback.set('info', '正在保存全局 WebDAV 默认值...');
|
||||
feedback.set('info', 'Saving global WebDAV defaults...');
|
||||
|
||||
try {
|
||||
const payload = await fetchJson('/api/webdav-defaults', {
|
||||
@ -352,7 +370,7 @@ async function handleGlobalDefaultsSave(event) {
|
||||
await loadSources();
|
||||
feedback.set(
|
||||
payload.defaults.configured ? 'success' : 'info',
|
||||
payload.defaults.configured ? '全局默认值已保存。' : '全局默认值已清空。'
|
||||
payload.defaults.configured ? 'Global defaults saved.' : 'Global defaults cleared.'
|
||||
);
|
||||
} catch (error) {
|
||||
feedback.set('error', error.message);
|
||||
@ -361,23 +379,125 @@ async function handleGlobalDefaultsSave(event) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTlsJsonSave(event) {
|
||||
event.preventDefault();
|
||||
feedback.clear();
|
||||
tlsSaveButton.disabled = true;
|
||||
tlsUploadButton.disabled = true;
|
||||
feedback.set('info', 'Saving TLS configuration and applying certificate...');
|
||||
|
||||
try {
|
||||
const payload = await fetchJson('/api/system/tls', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(readTlsJsonForm())
|
||||
});
|
||||
state.tls = payload.tls;
|
||||
renderTlsSummary();
|
||||
renderOverviewSummary();
|
||||
feedback.set('success', `TLS settings applied. Active certificate source: ${payload.tls.activeSource}.`);
|
||||
} catch (error) {
|
||||
feedback.set('error', error.message);
|
||||
} finally {
|
||||
tlsSaveButton.disabled = false;
|
||||
tlsUploadButton.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTlsUpload(event) {
|
||||
event.preventDefault();
|
||||
feedback.clear();
|
||||
|
||||
const certificateFile = document.querySelector('#tlsCertificateInput')?.files?.[0];
|
||||
const privateKeyFile = document.querySelector('#tlsPrivateKeyInput')?.files?.[0];
|
||||
const chainFile = document.querySelector('#tlsChainInput')?.files?.[0] ?? null;
|
||||
|
||||
if (!certificateFile || !privateKeyFile) {
|
||||
feedback.set('error', 'Upload both the certificate file and the private key file.');
|
||||
return;
|
||||
}
|
||||
|
||||
tlsSaveButton.disabled = true;
|
||||
tlsUploadButton.disabled = true;
|
||||
feedback.set('info', 'Uploading custom certificate...');
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.set('primaryDomain', document.querySelector('#tlsPrimaryDomain')?.value?.trim() ?? '');
|
||||
formData.set('subjectAltNames', document.querySelector('#tlsSubjectAltNames')?.value ?? '');
|
||||
formData.set('certificate', certificateFile);
|
||||
formData.set('privateKey', privateKeyFile);
|
||||
if (chainFile) {
|
||||
formData.set('chain', chainFile);
|
||||
}
|
||||
|
||||
const response = await fetch('/api/system/tls/upload', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(`${payload.error?.code ?? 'TLS_UPLOAD_FAILED'}: ${payload.error?.message ?? 'Upload failed.'}`);
|
||||
}
|
||||
|
||||
state.tls = payload.tls;
|
||||
renderTlsSummary();
|
||||
renderOverviewSummary();
|
||||
tlsUploadForm.reset();
|
||||
feedback.set('success', 'Custom certificate uploaded and applied.');
|
||||
} catch (error) {
|
||||
feedback.set('error', error.message);
|
||||
} finally {
|
||||
tlsSaveButton.disabled = false;
|
||||
tlsUploadButton.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteCustomTls() {
|
||||
feedback.clear();
|
||||
if (!window.confirm('Delete the stored custom certificate and fall back to a self-signed certificate?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
tlsDeleteCustomButton.disabled = true;
|
||||
feedback.set('info', 'Deleting custom certificate and switching back to self-signed...');
|
||||
|
||||
try {
|
||||
const payload = await fetchJson('/api/system/tls/custom-certificate', {
|
||||
method: 'DELETE'
|
||||
});
|
||||
state.tls = payload.tls;
|
||||
renderTlsSummary();
|
||||
renderOverviewSummary();
|
||||
feedback.set('success', 'Custom certificate removed. The service is back on a self-signed certificate.');
|
||||
} catch (error) {
|
||||
feedback.set('error', error.message);
|
||||
} finally {
|
||||
tlsDeleteCustomButton.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。`);
|
||||
const confirmed = window.confirm(
|
||||
`Delete this source?\n\n${label}\n\nThis removes only the selected source and its local enhancement data. It does not delete Duplicati-server.sqlite.`
|
||||
);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
feedback.set('info', `正在删除 ${label}...`);
|
||||
feedback.set('info', `Deleting ${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 保持不变。`);
|
||||
feedback.set('success', `Deleted ${payload.deleted.originalFilename}. Server DB was kept untouched.`);
|
||||
} catch (error) {
|
||||
feedback.set('error', error.message);
|
||||
}
|
||||
@ -395,10 +515,22 @@ globalDefaultsForm.addEventListener('submit', (event) => {
|
||||
void handleGlobalDefaultsSave(event);
|
||||
});
|
||||
|
||||
tlsJsonForm.addEventListener('submit', (event) => {
|
||||
void handleTlsJsonSave(event);
|
||||
});
|
||||
|
||||
tlsUploadForm.addEventListener('submit', (event) => {
|
||||
void handleTlsUpload(event);
|
||||
});
|
||||
|
||||
tlsDeleteCustomButton.addEventListener('click', () => {
|
||||
void handleDeleteCustomTls();
|
||||
});
|
||||
|
||||
refreshButton.addEventListener('click', () => {
|
||||
feedback.clear();
|
||||
sourceListElement.textContent = '正在刷新数据源列表...';
|
||||
void Promise.all([loadSources(), loadServerDbSummary(), loadGlobalDefaults()]).catch((error) => {
|
||||
sourceListElement.textContent = 'Refreshing sources...';
|
||||
void Promise.all([loadSources(), loadServerDbSummary(), loadGlobalDefaults(), loadTlsSummary()]).catch((error) => {
|
||||
feedback.set('error', error.message);
|
||||
});
|
||||
});
|
||||
@ -436,14 +568,21 @@ window.addEventListener('hashchange', () => {
|
||||
async function bootstrap() {
|
||||
const initialView = normalizeView(window.location.hash.slice(1) || 'overview');
|
||||
setCurrentView(initialView, { replace: true });
|
||||
await Promise.all([loadServerDbSummary(), loadGlobalDefaults(), loadSources()]);
|
||||
|
||||
const authentication = await loadAuthState();
|
||||
authStatusElement.textContent = authentication.authenticated
|
||||
? `Signed in as ${authentication.username}`
|
||||
: 'Not signed in';
|
||||
bindLogoutButton(logoutButton, authStatusElement);
|
||||
|
||||
await Promise.all([loadServerDbSummary(), loadGlobalDefaults(), loadTlsSummary(), loadSources()]);
|
||||
}
|
||||
|
||||
void bootstrap().catch((error) => {
|
||||
feedback.set('error', error.message);
|
||||
sourceListElement.innerHTML = `
|
||||
<div class="empty-state-card">
|
||||
<p>无法读取数据源列表。</p>
|
||||
<p>Could not load the control center.</p>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
@ -22,10 +22,15 @@
|
||||
<h1 id="fileTitle">文件预览与下载</h1>
|
||||
</div>
|
||||
<p class="lead">
|
||||
这里负责浏览器直连 WebDAV 的视频预览、下载、本地缓存和预览图回写。大流量文件流仍然只走前端,不经过后端文件代理。
|
||||
这里负责浏览器直连 WebDAV 的视频预览、下载、本地缓存和预览图回写。大流量文件流依然只走前端,不经过后端文件代理。
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div class="auth-toolbar">
|
||||
<span id="authStatus" class="auth-status">正在读取登录状态...</span>
|
||||
<button id="logoutButton" class="ghost-button" type="button">退出登录</button>
|
||||
</div>
|
||||
|
||||
<div id="feedback" class="feedback" hidden></div>
|
||||
|
||||
<section class="workspace-shell">
|
||||
@ -154,7 +159,7 @@
|
||||
<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">
|
||||
|
||||
@ -7,6 +7,7 @@ import {
|
||||
linkToSource,
|
||||
optionalQueryParam
|
||||
} from './modules/common.js';
|
||||
import { bindLogoutButton, loadAuthState } from './modules/auth-client.js';
|
||||
import {
|
||||
loadClientSecrets,
|
||||
loadGlobalClientSecrets,
|
||||
@ -87,6 +88,8 @@ const previewThumbnailHintElement = document.querySelector('#previewThumbnailHin
|
||||
const thumbnailStatusElement = document.querySelector('#thumbnailStatus');
|
||||
const thumbnailPreviewElement = document.querySelector('#thumbnailPreview');
|
||||
const thumbnailCaptureCanvas = document.querySelector('#thumbnailCaptureCanvas');
|
||||
const authStatusElement = document.querySelector('#authStatus');
|
||||
const logoutButton = document.querySelector('#logoutButton');
|
||||
const viewPanels = [...document.querySelectorAll('[data-view-panel]')];
|
||||
const viewLinks = [...document.querySelectorAll('[data-view-link]')];
|
||||
|
||||
@ -1277,6 +1280,11 @@ window.addEventListener('beforeunload', () => {
|
||||
async function bootstrap() {
|
||||
const initialView = normalizeView(window.location.hash.slice(1) || 'overview');
|
||||
setCurrentView(initialView, { replace: true });
|
||||
const authentication = await loadAuthState();
|
||||
authStatusElement.textContent = authentication.authenticated
|
||||
? `已登录:${authentication.username}`
|
||||
: '未登录';
|
||||
bindLogoutButton(logoutButton, authStatusElement);
|
||||
|
||||
if (!state.sourceId || !state.fileId) {
|
||||
renderMissingRouteContext();
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>数据源控制台</title>
|
||||
<title>Duplicati Control Center</title>
|
||||
<link rel="stylesheet" href="/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
@ -11,22 +11,29 @@
|
||||
<section class="hero hero-compact">
|
||||
<div>
|
||||
<p class="eyebrow">Zero-Bandwidth Duplicati Web Client</p>
|
||||
<h1>数据源控制台</h1>
|
||||
<h1>Duplicati Control Center</h1>
|
||||
</div>
|
||||
<p class="lead">
|
||||
首页现在只负责数据源级管理。任务上传、Server DB、全局 WebDAV 默认值和源列表已经拆成菜单式子面板,不再全部挤在同一屏。
|
||||
Manage task databases, Server DB metadata, global WebDAV defaults, and site-wide HTTPS/TLS settings here.
|
||||
Browsing, enhancement, preview, and download stay in the per-source and per-file workspaces.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div class="auth-toolbar">
|
||||
<span id="authStatus" class="auth-status">Loading login state...</span>
|
||||
<button id="logoutButton" class="ghost-button" type="button">Sign out</button>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<aside class="workspace-nav" aria-label="Control Center menu">
|
||||
<a class="workspace-nav-link" href="#overview" data-view-link="overview">Overview</a>
|
||||
<a class="workspace-nav-link" href="#sources" data-view-link="sources">Sources</a>
|
||||
<a class="workspace-nav-link" href="#upload" data-view-link="upload">Upload task DB</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>
|
||||
<a class="workspace-nav-link" href="#defaults" data-view-link="defaults">Global WebDAV</a>
|
||||
<a class="workspace-nav-link" href="#tls" data-view-link="tls">HTTPS / TLS</a>
|
||||
</aside>
|
||||
|
||||
<div class="workspace-content">
|
||||
@ -35,10 +42,10 @@
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<p class="section-label">Overview</p>
|
||||
<h2>控制台总览</h2>
|
||||
<h2>System summary</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div id="overviewSummary" class="summary-card">正在汇总控制台状态...</div>
|
||||
<div id="overviewSummary" class="summary-card">Loading overview...</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@ -47,14 +54,15 @@
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<p class="section-label">Sources</p>
|
||||
<h2>数据源列表</h2>
|
||||
<h2>Uploaded task databases</h2>
|
||||
</div>
|
||||
<button id="refreshButton" class="ghost-button" type="button">刷新列表</button>
|
||||
<button id="refreshButton" class="ghost-button" type="button">Refresh</button>
|
||||
</div>
|
||||
<p class="panel-copy">
|
||||
这里显示当前所有任务库 source。点击“进入工作台”会进入该任务自己的管理页;删除只会删除该 source,不会删除 <code>Duplicati-server.sqlite</code>。
|
||||
Each uploaded Duplicati task database becomes one source. Open a source workbench to browse files,
|
||||
start enhancement, or jump into the file workspace.
|
||||
</p>
|
||||
<div id="sourceList" class="source-list empty-state">正在读取数据源列表...</div>
|
||||
<div id="sourceList" class="source-list empty-state">Loading sources...</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@ -63,19 +71,20 @@
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<p class="section-label">Task DB</p>
|
||||
<h2>新增任务数据源</h2>
|
||||
<h2>Add a new source</h2>
|
||||
</div>
|
||||
</div>
|
||||
<p class="panel-copy">
|
||||
上传随机名任务库,例如 <code>TMQRJYNADS.sqlite</code>。每个任务库都会保留成独立 source,不会互相覆盖。
|
||||
Upload a Duplicati task database such as <code>TMQRJYNADS.sqlite</code>. Re-uploading the same file
|
||||
reuses the existing source instead of creating a duplicate.
|
||||
</p>
|
||||
<form id="uploadForm" class="upload-form">
|
||||
<label class="file-picker">
|
||||
<span>选择任务 SQLite 文件</span>
|
||||
<span>Select task SQLite file</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>
|
||||
<button id="uploadButton" class="primary-button" type="submit">Upload as new source</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@ -86,22 +95,23 @@
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<p class="section-label">Server DB</p>
|
||||
<h2>上传服务器数据库</h2>
|
||||
<h2>Upload Duplicati-server.sqlite</h2>
|
||||
</div>
|
||||
</div>
|
||||
<p class="panel-copy">
|
||||
上传 <code>Duplicati-server.sqlite</code> 后,系统会把随机任务库名映射回友好的任务名,并尝试推导每个任务的默认 WebDAV 目标 URL。
|
||||
The server database lets the UI resolve friendly task names and infer default target URLs from
|
||||
Duplicati metadata.
|
||||
</p>
|
||||
<form id="serverDbForm" class="upload-form">
|
||||
<label class="file-picker">
|
||||
<span>选择服务器数据库</span>
|
||||
<span>Select server database</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>
|
||||
<button id="serverDbUploadButton" class="ghost-button" type="submit">Upload Server DB</button>
|
||||
</div>
|
||||
</form>
|
||||
<div id="serverDbSummary" class="summary-card">正在读取当前 Server DB 状态...</div>
|
||||
<div id="serverDbSummary" class="summary-card">Loading Server DB status...</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@ -110,42 +120,110 @@
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<p class="section-label">Global WebDAV</p>
|
||||
<h2>保存全局默认值</h2>
|
||||
<h2>Shared defaults</h2>
|
||||
</div>
|
||||
</div>
|
||||
<p class="panel-copy">
|
||||
WebDAV 用户名、密码和备份口令如果大多数任务都共用,可以在这里全局保存一次。单个任务的目标 URL 会优先从 <code>Duplicati-server.sqlite</code> 自动推导。
|
||||
Save credentials and a fallback base URL once when most tasks share the same WebDAV settings.
|
||||
</p>
|
||||
<form id="globalDefaultsForm" class="secret-form">
|
||||
<label>
|
||||
<span>Fallback WebDAV Base URL</span>
|
||||
<input id="globalWebdavBaseUrl" name="webdavBaseUrl" type="url" placeholder="可选,全局兜底 URL">
|
||||
<input id="globalWebdavBaseUrl" name="webdavBaseUrl" type="url" placeholder="Optional shared base URL">
|
||||
</label>
|
||||
<label>
|
||||
<span>认证方式</span>
|
||||
<span>Authentication mode</span>
|
||||
<select id="globalAuthMode" name="authMode">
|
||||
<option value="">自动 / 继承</option>
|
||||
<option value="">Auto / inherited</option>
|
||||
<option value="basic">Basic</option>
|
||||
<option value="anonymous">Anonymous</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>用户名</span>
|
||||
<input id="globalUsername" name="username" type="text" placeholder="可选,共用用户名">
|
||||
<span>Username</span>
|
||||
<input id="globalUsername" name="username" type="text" placeholder="Optional shared username">
|
||||
</label>
|
||||
<label>
|
||||
<span>密码</span>
|
||||
<input id="globalPassword" name="password" type="password" placeholder="可选,共用密码">
|
||||
<span>Password</span>
|
||||
<input id="globalPassword" name="password" type="password" placeholder="Optional shared password">
|
||||
</label>
|
||||
<label>
|
||||
<span>备份口令</span>
|
||||
<input id="globalPassphrase" name="passphrase" type="password" placeholder="共用的备份口令">
|
||||
<span>Backup passphrase</span>
|
||||
<input id="globalPassphrase" name="passphrase" type="password" placeholder="Optional shared passphrase">
|
||||
</label>
|
||||
<div class="form-actions">
|
||||
<button id="globalDefaultsSaveButton" class="ghost-button" type="submit">保存全局默认值</button>
|
||||
<button id="globalDefaultsSaveButton" class="ghost-button" type="submit">Save defaults</button>
|
||||
</div>
|
||||
</form>
|
||||
<div id="globalDefaultsSummary" class="summary-card">Loading WebDAV defaults...</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="workspace-panel" data-view-panel="tls" hidden>
|
||||
<div class="panel stack-gap">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<p class="section-label">HTTPS / TLS</p>
|
||||
<h2>Domain and certificate settings</h2>
|
||||
</div>
|
||||
</div>
|
||||
<p class="panel-copy">
|
||||
HTTP and HTTPS stay enabled side-by-side. If no custom certificate is configured, the service keeps a
|
||||
self-signed certificate ready so HTTPS is always available.
|
||||
</p>
|
||||
<div id="tlsSummary" class="summary-card">Loading TLS configuration...</div>
|
||||
|
||||
<form id="tlsJsonForm" class="secret-form">
|
||||
<label>
|
||||
<span>Certificate mode</span>
|
||||
<select id="tlsMode" name="mode">
|
||||
<option value="self-signed">Self-signed</option>
|
||||
<option value="custom-pem">Custom PEM</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Primary domain</span>
|
||||
<input id="tlsPrimaryDomain" name="primaryDomain" type="text" placeholder="Example: preview.example.com">
|
||||
</label>
|
||||
<label>
|
||||
<span>Additional SAN entries</span>
|
||||
<textarea id="tlsSubjectAltNames" name="subjectAltNames" rows="3" placeholder="One hostname or IP per line. Comma-separated values also work."></textarea>
|
||||
</label>
|
||||
<label>
|
||||
<span>Certificate PEM</span>
|
||||
<textarea id="tlsCertPem" name="certPem" rows="8" placeholder="-----BEGIN CERTIFICATE-----"></textarea>
|
||||
</label>
|
||||
<label>
|
||||
<span>Private key PEM</span>
|
||||
<textarea id="tlsKeyPem" name="keyPem" rows="8" placeholder="-----BEGIN PRIVATE KEY-----"></textarea>
|
||||
</label>
|
||||
<label>
|
||||
<span>Optional chain PEM</span>
|
||||
<textarea id="tlsChainPem" name="chainPem" rows="6" placeholder="Optional intermediate certificates"></textarea>
|
||||
</label>
|
||||
<div class="form-actions">
|
||||
<button id="tlsSaveButton" class="primary-button" type="submit">Save and apply</button>
|
||||
<button id="tlsDeleteCustomButton" class="ghost-button danger-button" type="button">Delete custom cert and fall back</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form id="tlsUploadForm" class="upload-form">
|
||||
<label class="file-picker">
|
||||
<span>Upload certificate file</span>
|
||||
<input id="tlsCertificateInput" name="certificate" type="file" accept=".pem,.crt,.cer,text/plain">
|
||||
</label>
|
||||
<label class="file-picker">
|
||||
<span>Upload private key file</span>
|
||||
<input id="tlsPrivateKeyInput" name="privateKey" type="file" accept=".pem,.key,text/plain">
|
||||
</label>
|
||||
<label class="file-picker">
|
||||
<span>Upload optional chain file</span>
|
||||
<input id="tlsChainInput" name="chain" type="file" accept=".pem,.crt,.cer,text/plain">
|
||||
</label>
|
||||
<div class="form-actions">
|
||||
<button id="tlsUploadButton" class="ghost-button" type="submit">Apply uploaded certificate</button>
|
||||
</div>
|
||||
</form>
|
||||
<div id="globalDefaultsSummary" class="summary-card">正在读取全局默认值...</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
71
public/login.html
Normal file
71
public/login.html
Normal file
@ -0,0 +1,71 @@
|
||||
<!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 shell-auth">
|
||||
<section class="hero hero-compact">
|
||||
<div>
|
||||
<p class="eyebrow">Authentication</p>
|
||||
<h1 id="authHeading">系统登录</h1>
|
||||
</div>
|
||||
<p id="authLead" class="lead">
|
||||
登录后才能访问数据源管理、增强任务、视频预览和文件下载。首次启用时,需要先设置管理员账号。
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div id="feedback" class="feedback" hidden></div>
|
||||
|
||||
<section class="auth-layout">
|
||||
<div class="panel stack-gap">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<p class="section-label">Account</p>
|
||||
<h2 id="authFormTitle">正在检查系统状态...</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="authSummary" class="summary-card">正在读取认证状态...</div>
|
||||
|
||||
<form id="loginForm" class="secret-form" hidden>
|
||||
<label>
|
||||
<span>用户名</span>
|
||||
<input id="loginUsername" name="username" type="text" autocomplete="username">
|
||||
</label>
|
||||
<label>
|
||||
<span>密码</span>
|
||||
<input id="loginPassword" name="password" type="password" autocomplete="current-password">
|
||||
</label>
|
||||
<div class="form-actions">
|
||||
<button id="loginButton" class="primary-button" type="submit">登录</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form id="setupForm" class="secret-form" hidden>
|
||||
<label>
|
||||
<span>管理员用户名</span>
|
||||
<input id="setupUsername" name="username" type="text" autocomplete="username">
|
||||
</label>
|
||||
<label>
|
||||
<span>管理员密码</span>
|
||||
<input id="setupPassword" name="password" type="password" autocomplete="new-password">
|
||||
</label>
|
||||
<label>
|
||||
<span>再次输入密码</span>
|
||||
<input id="setupPasswordConfirm" name="passwordConfirm" type="password" autocomplete="new-password">
|
||||
</label>
|
||||
<div class="form-actions">
|
||||
<button id="setupButton" class="primary-button" type="submit">完成初始化并登录</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script type="module" src="/login.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
160
public/login.js
Normal file
160
public/login.js
Normal file
@ -0,0 +1,160 @@
|
||||
import { createFeedbackController, escapeHtml } from './modules/common.js';
|
||||
import { loadAuthState, redirectToNextPath } from './modules/auth-client.js';
|
||||
|
||||
const feedback = createFeedbackController(document.querySelector('#feedback'));
|
||||
const authHeadingElement = document.querySelector('#authHeading');
|
||||
const authLeadElement = document.querySelector('#authLead');
|
||||
const authFormTitleElement = document.querySelector('#authFormTitle');
|
||||
const authSummaryElement = document.querySelector('#authSummary');
|
||||
const loginForm = document.querySelector('#loginForm');
|
||||
const setupForm = document.querySelector('#setupForm');
|
||||
const loginButton = document.querySelector('#loginButton');
|
||||
const setupButton = document.querySelector('#setupButton');
|
||||
|
||||
const state = {
|
||||
auth: null
|
||||
};
|
||||
|
||||
function setSummaryRows(rows) {
|
||||
authSummaryElement.innerHTML = `
|
||||
<div class="summary-stack">
|
||||
${rows
|
||||
.map(
|
||||
(row) => `
|
||||
<div class="summary-row">
|
||||
<span>${escapeHtml(row.label)}</span>
|
||||
<strong>${escapeHtml(row.value)}</strong>
|
||||
</div>
|
||||
`
|
||||
)
|
||||
.join('')}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderAuthMode() {
|
||||
const auth = state.auth;
|
||||
if (!auth) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (auth.authenticated) {
|
||||
authHeadingElement.textContent = '登录成功';
|
||||
authLeadElement.textContent = '当前会话已验证,正在跳转到你的目标页面。';
|
||||
authFormTitleElement.textContent = '正在跳转...';
|
||||
setSummaryRows([
|
||||
{ label: '状态', value: `已登录:${auth.username}` },
|
||||
{ label: '会话有效期', value: auth.expiresAt ?? '未知' }
|
||||
]);
|
||||
loginForm.hidden = true;
|
||||
setupForm.hidden = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (auth.setupRequired) {
|
||||
authHeadingElement.textContent = '初始化管理员账号';
|
||||
authLeadElement.textContent = '系统第一次运行时,需要先创建管理员用户名和密码。创建完成后会自动登录。';
|
||||
authFormTitleElement.textContent = '首次初始化';
|
||||
setSummaryRows([
|
||||
{ label: '系统状态', value: '尚未配置管理员账号' },
|
||||
{ label: '下一步', value: '创建管理员并进入系统' }
|
||||
]);
|
||||
loginForm.hidden = true;
|
||||
setupForm.hidden = false;
|
||||
return;
|
||||
}
|
||||
|
||||
authHeadingElement.textContent = '系统登录';
|
||||
authLeadElement.textContent = '登录后才能访问数据源管理、增强任务、视频预览和文件下载。';
|
||||
authFormTitleElement.textContent = '请输入账号密码';
|
||||
setSummaryRows([
|
||||
{ label: '系统状态', value: '管理员账号已配置' },
|
||||
{ label: '当前状态', value: '未登录' }
|
||||
]);
|
||||
loginForm.hidden = false;
|
||||
setupForm.hidden = true;
|
||||
}
|
||||
|
||||
async function postJson(url, payload) {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
const body = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(`${body.error?.code ?? 'REQUEST_FAILED'}: ${body.error?.message ?? 'Request failed.'}`);
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
async function refreshAuthState() {
|
||||
state.auth = await loadAuthState();
|
||||
renderAuthMode();
|
||||
if (state.auth.authenticated) {
|
||||
redirectToNextPath('/');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogin(event) {
|
||||
event.preventDefault();
|
||||
feedback.clear();
|
||||
loginButton.disabled = true;
|
||||
|
||||
try {
|
||||
await postJson('/api/auth/login', {
|
||||
username: document.querySelector('#loginUsername').value.trim(),
|
||||
password: document.querySelector('#loginPassword').value
|
||||
});
|
||||
await refreshAuthState();
|
||||
} catch (error) {
|
||||
feedback.set('error', error.message);
|
||||
} finally {
|
||||
loginButton.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSetup(event) {
|
||||
event.preventDefault();
|
||||
feedback.clear();
|
||||
|
||||
const password = document.querySelector('#setupPassword').value;
|
||||
const passwordConfirm = document.querySelector('#setupPasswordConfirm').value;
|
||||
if (password !== passwordConfirm) {
|
||||
feedback.set('error', '两次输入的密码不一致。');
|
||||
return;
|
||||
}
|
||||
|
||||
setupButton.disabled = true;
|
||||
|
||||
try {
|
||||
await postJson('/api/auth/setup', {
|
||||
username: document.querySelector('#setupUsername').value.trim(),
|
||||
password
|
||||
});
|
||||
await refreshAuthState();
|
||||
} catch (error) {
|
||||
feedback.set('error', error.message);
|
||||
} finally {
|
||||
setupButton.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
loginForm.addEventListener('submit', (event) => {
|
||||
void handleLogin(event);
|
||||
});
|
||||
|
||||
setupForm.addEventListener('submit', (event) => {
|
||||
void handleSetup(event);
|
||||
});
|
||||
|
||||
void refreshAuthState().catch((error) => {
|
||||
feedback.set('error', error.message);
|
||||
setSummaryRows([
|
||||
{ label: '状态', value: '无法读取认证信息' },
|
||||
{ label: '错误', value: error.message }
|
||||
]);
|
||||
});
|
||||
75
public/modules/auth-client.js
Normal file
75
public/modules/auth-client.js
Normal file
@ -0,0 +1,75 @@
|
||||
export function getCurrentRelativeUrl() {
|
||||
return `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
||||
}
|
||||
|
||||
export function buildLoginUrl(nextPath = getCurrentRelativeUrl()) {
|
||||
const loginUrl = new URL('/login.html', window.location.origin);
|
||||
if (nextPath) {
|
||||
loginUrl.searchParams.set('next', nextPath);
|
||||
}
|
||||
|
||||
return `${loginUrl.pathname}${loginUrl.search}`;
|
||||
}
|
||||
|
||||
export function redirectToLogin(nextPath = getCurrentRelativeUrl()) {
|
||||
window.location.assign(buildLoginUrl(nextPath));
|
||||
}
|
||||
|
||||
export function readNextPath(fallback = '/') {
|
||||
const nextPath = new URLSearchParams(window.location.search).get('next');
|
||||
if (!nextPath || !nextPath.startsWith('/') || nextPath.startsWith('//')) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return nextPath;
|
||||
}
|
||||
|
||||
export function redirectToNextPath(fallback = '/') {
|
||||
window.location.assign(readNextPath(fallback));
|
||||
}
|
||||
|
||||
export async function loadAuthState() {
|
||||
const response = await fetch('/api/auth/state');
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(`${payload.error?.code ?? 'AUTH_STATE_FAILED'}: ${payload.error?.message ?? 'Failed to load auth state.'}`);
|
||||
}
|
||||
|
||||
return payload.auth;
|
||||
}
|
||||
|
||||
export async function logoutCurrentSession() {
|
||||
const response = await fetch('/api/auth/logout', {
|
||||
method: 'POST'
|
||||
});
|
||||
const payload = await response.json().catch(() => ({ ok: false }));
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`${payload.error?.code ?? 'LOGOUT_FAILED'}: ${payload.error?.message ?? 'Logout failed.'}`);
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
export function bindLogoutButton(button, labelElement, fallbackLabel = 'Signed in') {
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
|
||||
button.addEventListener('click', async () => {
|
||||
button.disabled = true;
|
||||
if (labelElement) {
|
||||
labelElement.textContent = 'Signing out...';
|
||||
}
|
||||
|
||||
try {
|
||||
await logoutCurrentSession();
|
||||
} finally {
|
||||
redirectToLogin('/');
|
||||
}
|
||||
});
|
||||
|
||||
if (labelElement && !labelElement.textContent.trim()) {
|
||||
labelElement.textContent = fallbackLabel;
|
||||
}
|
||||
}
|
||||
@ -1,3 +1,5 @@
|
||||
import { redirectToLogin } from './auth-client.js';
|
||||
|
||||
export function escapeHtml(value) {
|
||||
return String(value)
|
||||
.replaceAll('&', '&')
|
||||
@ -34,6 +36,10 @@ export async function fetchJson(url, options = {}) {
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401 && window.location.pathname !== '/login.html') {
|
||||
redirectToLogin();
|
||||
}
|
||||
|
||||
throw new Error(`${payload.error?.code ?? 'REQUEST_FAILED'}: ${payload.error?.message ?? 'Request failed.'}`);
|
||||
}
|
||||
|
||||
@ -81,15 +87,15 @@ export function linkToFile(sourceId, fileId) {
|
||||
export function inferEnhancementLabel(source) {
|
||||
switch (source?.enhancement?.status) {
|
||||
case 'ready':
|
||||
return '已增强';
|
||||
return 'Enhanced';
|
||||
case 'running':
|
||||
return '增强中';
|
||||
return 'Running';
|
||||
case 'queued':
|
||||
return '排队中';
|
||||
return 'Queued';
|
||||
case 'failed':
|
||||
return '增强失败';
|
||||
return 'Failed';
|
||||
default:
|
||||
return '未增强';
|
||||
return 'Not ready';
|
||||
}
|
||||
}
|
||||
|
||||
@ -110,7 +116,7 @@ export function inferEnhancementClass(source) {
|
||||
export function renderSourceSummaryCard(source, extraActionsHtml = '') {
|
||||
const title = escapeHtml(source.displayName || source.originalFilename);
|
||||
const originalFilename = escapeHtml(source.originalFilename);
|
||||
const matchedHint = source.matchedBackupName ? '任务名来自 Server DB' : '未匹配任务名';
|
||||
const matchedHint = source.matchedBackupName ? 'Task name resolved from Server DB' : 'Task name not mapped yet';
|
||||
const progress =
|
||||
source.enhancement.totalVolumes > 0
|
||||
? `${source.enhancement.processedVolumes}/${source.enhancement.totalVolumes}`
|
||||
@ -121,8 +127,8 @@ export function renderSourceSummaryCard(source, extraActionsHtml = '') {
|
||||
<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>
|
||||
<p class="source-meta">Database file: ${originalFilename}</p>
|
||||
<p class="source-meta">Source ID: ${escapeHtml(source.id)}</p>
|
||||
</div>
|
||||
<span class="status-pill ${inferEnhancementClass(source)}">${inferEnhancementLabel(source)}</span>
|
||||
</div>
|
||||
@ -131,19 +137,19 @@ export function renderSourceSummaryCard(source, extraActionsHtml = '') {
|
||||
|
||||
<div class="card-grid">
|
||||
<div class="stat">
|
||||
<span>上传大小</span>
|
||||
<span>Uploaded size</span>
|
||||
<strong>${formatBytes(source.fileSize)}</strong>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span>最近快照</span>
|
||||
<strong>${escapeHtml(source.latestSnapshot?.timestamp ?? '无快照')}</strong>
|
||||
<span>Latest snapshot</span>
|
||||
<strong>${escapeHtml(source.latestSnapshot?.timestamp ?? 'None')}</strong>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span>目录浏览</span>
|
||||
<strong>${source.canBrowse ? '可用' : '不可用'}</strong>
|
||||
<span>Browse</span>
|
||||
<strong>${source.canBrowse ? 'Available' : 'Unavailable'}</strong>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span>增强进度</span>
|
||||
<span>Enhancement</span>
|
||||
<strong>${escapeHtml(progress)}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -20,10 +20,15 @@
|
||||
<h1 id="pageTitle">数据源工作台</h1>
|
||||
</div>
|
||||
<p class="lead">
|
||||
这里处理单个数据源的浏览、增强和预览图缓存。页面改成了左侧菜单式工作台,避免所有功能继续堆在一个长页面里。
|
||||
这里负责单个数据源的目录浏览、增强配置和预览图缓存管理。页面已经拆成菜单式工作台,避免所有功能都堆在一个长页面里。
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div class="auth-toolbar">
|
||||
<span id="authStatus" class="auth-status">正在读取登录状态...</span>
|
||||
<button id="logoutButton" class="ghost-button" type="button">退出登录</button>
|
||||
</div>
|
||||
|
||||
<div id="feedback" class="feedback" hidden></div>
|
||||
|
||||
<section class="workspace-shell">
|
||||
@ -76,8 +81,8 @@
|
||||
</div>
|
||||
</div>
|
||||
<p class="panel-copy">
|
||||
默认情况下,增强会优先使用全局默认值,并尽量从 <code>Duplicati-server.sqlite</code> 自动推导当前任务的目标 URL。
|
||||
这里只需要填写与全局不同的部分;全部留空并保存,会清除当前 source 的覆盖设置。
|
||||
默认情况下,增强会优先使用全局 WebDAV 默认值,并尽量从 <code>Duplicati-server.sqlite</code> 自动推导当前任务的目标 URL。
|
||||
这里只需要填写与全局不同的部分;全部留空并保存,会清掉当前 source 的覆盖设置。
|
||||
</p>
|
||||
<div id="webdavHint" class="summary-card">正在计算这个 source 的默认 WebDAV 目标...</div>
|
||||
<form id="secretForm" class="secret-form">
|
||||
@ -123,7 +128,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<p class="panel-copy">
|
||||
这里展示当前 source 已缓存到后端的预览图数量。清空后,目录里的视频文件会退回占位图;后续再次成功预览时会自动重新生成。
|
||||
这里展示当前 source 已缓存到后端的预览图数量。清空后,目录里的视频文件会退回占位图,后续再次成功预览时会自动重建。
|
||||
</p>
|
||||
<div id="sourceThumbnailSummary" class="summary-card">正在统计这个 source 的预览图缓存...</div>
|
||||
<div class="form-actions">
|
||||
|
||||
@ -8,6 +8,7 @@ import {
|
||||
linkToFile,
|
||||
optionalQueryParam
|
||||
} from './modules/common.js';
|
||||
import { bindLogoutButton, loadAuthState } from './modules/auth-client.js';
|
||||
|
||||
const SOURCE_VIEWS = new Set(['overview', 'browse', 'enhance', 'thumbnails']);
|
||||
|
||||
@ -33,6 +34,8 @@ const webdavHintElement = document.querySelector('#webdavHint');
|
||||
const deleteSourceButton = document.querySelector('#deleteSourceButton');
|
||||
const sourceThumbnailSummaryElement = document.querySelector('#sourceThumbnailSummary');
|
||||
const clearSourceThumbnailsButton = document.querySelector('#clearSourceThumbnailsButton');
|
||||
const authStatusElement = document.querySelector('#authStatus');
|
||||
const logoutButton = document.querySelector('#logoutButton');
|
||||
const viewPanels = [...document.querySelectorAll('[data-view-panel]')];
|
||||
const viewLinks = [...document.querySelectorAll('[data-view-link]')];
|
||||
|
||||
@ -554,6 +557,12 @@ window.addEventListener('hashchange', () => {
|
||||
});
|
||||
|
||||
async function bootstrap() {
|
||||
const authentication = await loadAuthState();
|
||||
authStatusElement.textContent = authentication.authenticated
|
||||
? `已登录:${authentication.username}`
|
||||
: '未登录';
|
||||
bindLogoutButton(logoutButton, authStatusElement);
|
||||
|
||||
if (!state.sourceId) {
|
||||
renderMissingRouteContext();
|
||||
return;
|
||||
|
||||
@ -51,6 +51,10 @@ code {
|
||||
padding: 28px 0 72px;
|
||||
}
|
||||
|
||||
.shell-auth {
|
||||
width: min(720px, calc(100vw - 30px));
|
||||
}
|
||||
|
||||
.crumbs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@ -164,6 +168,25 @@ h3 {
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.auth-layout {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.auth-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin: -4px 0 18px;
|
||||
}
|
||||
|
||||
.auth-status {
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.workspace-content,
|
||||
.workspace-panel {
|
||||
display: grid;
|
||||
@ -329,7 +352,8 @@ h3 {
|
||||
}
|
||||
|
||||
input,
|
||||
select {
|
||||
select,
|
||||
textarea {
|
||||
width: 100%;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(26, 36, 31, 0.14);
|
||||
@ -339,8 +363,14 @@ select {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
min-height: 110px;
|
||||
}
|
||||
|
||||
input:focus,
|
||||
select:focus {
|
||||
select:focus,
|
||||
textarea:focus {
|
||||
outline: 2px solid rgba(15, 118, 110, 0.18);
|
||||
border-color: rgba(15, 118, 110, 0.4);
|
||||
}
|
||||
|
||||
@ -11,11 +11,16 @@ function parsePositiveInteger(value, fallback) {
|
||||
|
||||
export const config = {
|
||||
port: parsePositiveInteger(process.env.PORT, 3000),
|
||||
httpsPort: parsePositiveInteger(process.env.HTTPS_PORT, 3443),
|
||||
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)
|
||||
tlsDir:
|
||||
process.env.TLS_DIR ??
|
||||
path.resolve(process.cwd(), 'data', 'tls'),
|
||||
previewMaxBytes: parsePositiveInteger(process.env.PREVIEW_MAX_BYTES, 16 * 1024 * 1024),
|
||||
authSessionTtlDays: parsePositiveInteger(process.env.AUTH_SESSION_TTL_DAYS, 30)
|
||||
};
|
||||
|
||||
@ -77,6 +77,42 @@ CREATE TABLE IF NOT EXISTS "global_webdav_defaults" (
|
||||
"updated_at" TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "tls_settings" (
|
||||
"id" INTEGER PRIMARY KEY CHECK ("id" = 1),
|
||||
"mode" TEXT NOT NULL DEFAULT 'self-signed',
|
||||
"primary_domain" TEXT NOT NULL DEFAULT '',
|
||||
"subject_alt_names_json" TEXT NOT NULL DEFAULT '[]',
|
||||
"custom_cert_path" TEXT NULL,
|
||||
"custom_key_path" TEXT NULL,
|
||||
"custom_chain_path" TEXT NULL,
|
||||
"active_source" TEXT NOT NULL DEFAULT 'self-signed',
|
||||
"last_error_code" TEXT NULL,
|
||||
"last_error_message" TEXT NULL,
|
||||
"updated_at" TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "auth_users" (
|
||||
"id" TEXT PRIMARY KEY,
|
||||
"username" TEXT NOT NULL UNIQUE,
|
||||
"password_hash" TEXT NOT NULL,
|
||||
"password_salt" TEXT NOT NULL,
|
||||
"password_n" INTEGER NOT NULL,
|
||||
"password_r" INTEGER NOT NULL,
|
||||
"password_p" INTEGER NOT NULL,
|
||||
"created_at" TEXT NOT NULL,
|
||||
"updated_at" TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "auth_sessions" (
|
||||
"id" TEXT PRIMARY KEY,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"token_hash" TEXT NOT NULL,
|
||||
"created_at" TEXT NOT NULL,
|
||||
"last_seen_at" TEXT NOT NULL,
|
||||
"expires_at" TEXT NOT NULL,
|
||||
FOREIGN KEY ("user_id") REFERENCES "auth_users"("id") ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "preview_thumbnails" (
|
||||
"thumbnail_id" TEXT PRIMARY KEY,
|
||||
"source_id" TEXT NOT NULL,
|
||||
@ -90,6 +126,8 @@ CREATE TABLE IF NOT EXISTS "preview_thumbnails" (
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "sources_created_at_idx" ON "sources" ("created_at" DESC);
|
||||
CREATE INDEX IF NOT EXISTS "sources_enhancement_status_idx" ON "sources" ("enhancement_status");
|
||||
CREATE INDEX IF NOT EXISTS "auth_sessions_user_idx" ON "auth_sessions" ("user_id");
|
||||
CREATE INDEX IF NOT EXISTS "auth_sessions_expires_idx" ON "auth_sessions" ("expires_at");
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "preview_thumbnails_source_file_idx"
|
||||
ON "preview_thumbnails" ("source_id", "file_id");
|
||||
CREATE INDEX IF NOT EXISTS "preview_thumbnails_source_idx"
|
||||
@ -290,6 +328,85 @@ function buildThumbnailCacheSummary(row) {
|
||||
};
|
||||
}
|
||||
|
||||
function mapTlsSettingsRow(row) {
|
||||
if (!row) {
|
||||
return {
|
||||
mode: 'self-signed',
|
||||
primaryDomain: '',
|
||||
subjectAltNames: [],
|
||||
customCertPath: null,
|
||||
customKeyPath: null,
|
||||
customChainPath: null,
|
||||
activeSource: 'self-signed',
|
||||
lastErrorCode: null,
|
||||
lastErrorMessage: null,
|
||||
updatedAt: null
|
||||
};
|
||||
}
|
||||
|
||||
let subjectAltNames = [];
|
||||
try {
|
||||
const rawSubjectAltNames = row.subject_alt_names_json ?? row.subjectAltNamesJson ?? row.subjectAltNames ?? '[]';
|
||||
const parsed =
|
||||
Array.isArray(rawSubjectAltNames)
|
||||
? rawSubjectAltNames
|
||||
: JSON.parse(String(rawSubjectAltNames));
|
||||
|
||||
if (Array.isArray(parsed)) {
|
||||
subjectAltNames = parsed.map((value) => String(value ?? '').trim()).filter(Boolean);
|
||||
}
|
||||
} catch (error) {
|
||||
subjectAltNames = [];
|
||||
}
|
||||
|
||||
return {
|
||||
mode: row.mode ?? 'self-signed',
|
||||
primaryDomain: row.primary_domain ?? row.primaryDomain ?? '',
|
||||
subjectAltNames,
|
||||
customCertPath: row.custom_cert_path ?? row.customCertPath ?? null,
|
||||
customKeyPath: row.custom_key_path ?? row.customKeyPath ?? null,
|
||||
customChainPath: row.custom_chain_path ?? row.customChainPath ?? null,
|
||||
activeSource: row.active_source ?? row.activeSource ?? 'self-signed',
|
||||
lastErrorCode: row.last_error_code ?? row.lastErrorCode ?? null,
|
||||
lastErrorMessage: row.last_error_message ?? row.lastErrorMessage ?? null,
|
||||
updatedAt: row.updated_at ?? row.updatedAt ?? null
|
||||
};
|
||||
}
|
||||
|
||||
function mapAuthUserRow(row) {
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
username: row.username,
|
||||
passwordHash: row.password_hash ?? row.passwordHash,
|
||||
passwordSalt: row.password_salt ?? row.passwordSalt,
|
||||
passwordN: Number(row.password_n ?? row.passwordN ?? 0),
|
||||
passwordR: Number(row.password_r ?? row.passwordR ?? 0),
|
||||
passwordP: Number(row.password_p ?? row.passwordP ?? 0),
|
||||
createdAt: row.created_at ?? row.createdAt ?? null,
|
||||
updatedAt: row.updated_at ?? row.updatedAt ?? null
|
||||
};
|
||||
}
|
||||
|
||||
function mapAuthSessionRow(row) {
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
userId: row.user_id ?? row.userId,
|
||||
username: row.username ?? null,
|
||||
tokenHash: row.token_hash ?? row.tokenHash,
|
||||
createdAt: row.created_at ?? row.createdAt ?? null,
|
||||
lastSeenAt: row.last_seen_at ?? row.lastSeenAt ?? null,
|
||||
expiresAt: row.expires_at ?? row.expiresAt ?? null
|
||||
};
|
||||
}
|
||||
|
||||
function pickFirstNonEmpty(...values) {
|
||||
for (const value of values) {
|
||||
const normalized = asNonEmptyString(value);
|
||||
@ -701,6 +818,7 @@ export class SourceCatalog {
|
||||
}
|
||||
|
||||
const catalog = new SourceCatalog({ appDbPath, uploadDir });
|
||||
await catalog.purgeExpiredAuthSessions();
|
||||
await catalog.reconcileEnhancementJobs();
|
||||
await catalog.reapplyAllSourceDisplayNames();
|
||||
await catalog.cleanupOrphanedUploads();
|
||||
@ -711,6 +829,156 @@ export class SourceCatalog {
|
||||
return openWritableSqlite(this.appDbPath);
|
||||
}
|
||||
|
||||
async getAuthUserCount() {
|
||||
const database = await this.openAppDatabase();
|
||||
|
||||
try {
|
||||
const row = await database.get('SELECT COUNT(*) AS "count" FROM "auth_users"');
|
||||
return Number(row?.count ?? 0);
|
||||
} finally {
|
||||
await database.close();
|
||||
}
|
||||
}
|
||||
|
||||
async getAuthUserByUsername(username) {
|
||||
const database = await this.openAppDatabase();
|
||||
|
||||
try {
|
||||
const row = await database.get(
|
||||
`
|
||||
SELECT
|
||||
"id",
|
||||
"username",
|
||||
"password_hash",
|
||||
"password_salt",
|
||||
"password_n",
|
||||
"password_r",
|
||||
"password_p",
|
||||
"created_at",
|
||||
"updated_at"
|
||||
FROM "auth_users"
|
||||
WHERE "username" = ?
|
||||
LIMIT 1
|
||||
`,
|
||||
[username]
|
||||
);
|
||||
return mapAuthUserRow(row);
|
||||
} finally {
|
||||
await database.close();
|
||||
}
|
||||
}
|
||||
|
||||
async createInitialAuthUser({ id, username, passwordHash, passwordSalt, passwordN, passwordR, passwordP }) {
|
||||
const database = await this.openAppDatabase();
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
try {
|
||||
await database.exec('BEGIN IMMEDIATE');
|
||||
const existing = await database.get('SELECT COUNT(*) AS "count" FROM "auth_users"');
|
||||
if (Number(existing?.count ?? 0) > 0) {
|
||||
throw new HttpError(409, 'AUTH_ALREADY_CONFIGURED', 'An administrator account already exists.');
|
||||
}
|
||||
|
||||
await database.run(
|
||||
`
|
||||
INSERT INTO "auth_users" (
|
||||
"id",
|
||||
"username",
|
||||
"password_hash",
|
||||
"password_salt",
|
||||
"password_n",
|
||||
"password_r",
|
||||
"password_p",
|
||||
"created_at",
|
||||
"updated_at"
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
[id, username, passwordHash, passwordSalt, passwordN, passwordR, passwordP, timestamp, timestamp]
|
||||
);
|
||||
await database.exec('COMMIT');
|
||||
} catch (error) {
|
||||
try {
|
||||
await database.exec('ROLLBACK');
|
||||
} catch (rollbackError) {
|
||||
// ignore rollback cleanup failures
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
await database.close();
|
||||
}
|
||||
|
||||
return this.getAuthUserByUsername(username);
|
||||
}
|
||||
|
||||
async createAuthSession({ id, userId, tokenHash, createdAt, expiresAt }) {
|
||||
const database = await this.openAppDatabase();
|
||||
|
||||
try {
|
||||
await database.run(
|
||||
`
|
||||
INSERT INTO "auth_sessions" (
|
||||
"id",
|
||||
"user_id",
|
||||
"token_hash",
|
||||
"created_at",
|
||||
"last_seen_at",
|
||||
"expires_at"
|
||||
) VALUES (?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
[id, userId, tokenHash, createdAt, createdAt, expiresAt]
|
||||
);
|
||||
} finally {
|
||||
await database.close();
|
||||
}
|
||||
}
|
||||
|
||||
async getAuthSessionById(sessionId) {
|
||||
const database = await this.openAppDatabase();
|
||||
|
||||
try {
|
||||
const row = await database.get(
|
||||
`
|
||||
SELECT
|
||||
"auth_sessions"."id",
|
||||
"auth_sessions"."user_id",
|
||||
"auth_sessions"."token_hash",
|
||||
"auth_sessions"."created_at",
|
||||
"auth_sessions"."last_seen_at",
|
||||
"auth_sessions"."expires_at",
|
||||
"auth_users"."username"
|
||||
FROM "auth_sessions"
|
||||
INNER JOIN "auth_users" ON "auth_users"."id" = "auth_sessions"."user_id"
|
||||
WHERE "auth_sessions"."id" = ?
|
||||
LIMIT 1
|
||||
`,
|
||||
[sessionId]
|
||||
);
|
||||
return mapAuthSessionRow(row);
|
||||
} finally {
|
||||
await database.close();
|
||||
}
|
||||
}
|
||||
|
||||
async deleteAuthSession(sessionId) {
|
||||
const database = await this.openAppDatabase();
|
||||
|
||||
try {
|
||||
await database.run('DELETE FROM "auth_sessions" WHERE "id" = ?', [sessionId]);
|
||||
} finally {
|
||||
await database.close();
|
||||
}
|
||||
}
|
||||
|
||||
async purgeExpiredAuthSessions(referenceTime = new Date().toISOString()) {
|
||||
const database = await this.openAppDatabase();
|
||||
|
||||
try {
|
||||
await database.run('DELETE FROM "auth_sessions" WHERE "expires_at" <= ?', [referenceTime]);
|
||||
} finally {
|
||||
await database.close();
|
||||
}
|
||||
}
|
||||
|
||||
getThumbnailDirectory(sourceId) {
|
||||
return path.join(this.thumbnailRootDir, String(sourceId));
|
||||
}
|
||||
@ -816,6 +1084,71 @@ ON CONFLICT("id") DO UPDATE SET
|
||||
return this.getGlobalWebdavDefaultsSummary();
|
||||
}
|
||||
|
||||
async getTlsSettingsRecord() {
|
||||
const database = await this.openAppDatabase();
|
||||
|
||||
try {
|
||||
const row = await database.get('SELECT * FROM "tls_settings" WHERE "id" = 1 LIMIT 1');
|
||||
return mapTlsSettingsRow(row);
|
||||
} finally {
|
||||
await database.close();
|
||||
}
|
||||
}
|
||||
|
||||
async saveTlsSettings(settings) {
|
||||
const database = await this.openAppDatabase();
|
||||
const normalized = mapTlsSettingsRow(settings);
|
||||
const updatedAt = normalized.updatedAt ?? new Date().toISOString();
|
||||
|
||||
try {
|
||||
await database.run(
|
||||
`
|
||||
INSERT INTO "tls_settings" (
|
||||
"id",
|
||||
"mode",
|
||||
"primary_domain",
|
||||
"subject_alt_names_json",
|
||||
"custom_cert_path",
|
||||
"custom_key_path",
|
||||
"custom_chain_path",
|
||||
"active_source",
|
||||
"last_error_code",
|
||||
"last_error_message",
|
||||
"updated_at"
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT("id") DO UPDATE SET
|
||||
"mode" = excluded."mode",
|
||||
"primary_domain" = excluded."primary_domain",
|
||||
"subject_alt_names_json" = excluded."subject_alt_names_json",
|
||||
"custom_cert_path" = excluded."custom_cert_path",
|
||||
"custom_key_path" = excluded."custom_key_path",
|
||||
"custom_chain_path" = excluded."custom_chain_path",
|
||||
"active_source" = excluded."active_source",
|
||||
"last_error_code" = excluded."last_error_code",
|
||||
"last_error_message" = excluded."last_error_message",
|
||||
"updated_at" = excluded."updated_at"
|
||||
`,
|
||||
[
|
||||
1,
|
||||
normalized.mode,
|
||||
normalized.primaryDomain ?? '',
|
||||
JSON.stringify(normalized.subjectAltNames ?? []),
|
||||
normalized.customCertPath ?? null,
|
||||
normalized.customKeyPath ?? null,
|
||||
normalized.customChainPath ?? null,
|
||||
normalized.activeSource ?? 'self-signed',
|
||||
normalized.lastErrorCode ?? null,
|
||||
normalized.lastErrorMessage ?? null,
|
||||
updatedAt
|
||||
]
|
||||
);
|
||||
} finally {
|
||||
await database.close();
|
||||
}
|
||||
|
||||
return this.getTlsSettingsRecord();
|
||||
}
|
||||
|
||||
async loadServerBackupMappings() {
|
||||
const record = await this.getServerDatabaseRecord();
|
||||
if (!record?.storage_path) {
|
||||
|
||||
109
src/lib/auth.js
Normal file
109
src/lib/auth.js
Normal file
@ -0,0 +1,109 @@
|
||||
import { createHash, randomBytes, scrypt as scryptCallback, timingSafeEqual } from 'node:crypto';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
import { HttpError } from '../errors.js';
|
||||
|
||||
const scrypt = promisify(scryptCallback);
|
||||
|
||||
export const PASSWORD_HASH_PARAMS = {
|
||||
N: 16384,
|
||||
r: 8,
|
||||
p: 1,
|
||||
keyLength: 64,
|
||||
maxmem: 64 * 1024 * 1024
|
||||
};
|
||||
|
||||
export function normalizeAuthUsername(value) {
|
||||
const username = String(value ?? '').trim();
|
||||
if (!username) {
|
||||
throw new HttpError(400, 'MISSING_USERNAME', 'Username is required.');
|
||||
}
|
||||
|
||||
if (!/^[A-Za-z0-9_.@-]{3,64}$/.test(username)) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
'INVALID_USERNAME',
|
||||
'Username must be 3-64 characters and may contain letters, numbers, dot, underscore, hyphen, or @.'
|
||||
);
|
||||
}
|
||||
|
||||
return username;
|
||||
}
|
||||
|
||||
export function validateAuthPassword(value) {
|
||||
const password = String(value ?? '');
|
||||
if (!password) {
|
||||
throw new HttpError(400, 'MISSING_PASSWORD', 'Password is required.');
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
throw new HttpError(400, 'WEAK_PASSWORD', 'Password must be at least 8 characters long.');
|
||||
}
|
||||
|
||||
return password;
|
||||
}
|
||||
|
||||
export async function hashPassword(password) {
|
||||
const normalizedPassword = validateAuthPassword(password);
|
||||
const salt = randomBytes(16);
|
||||
const derived = await scrypt(normalizedPassword, salt, PASSWORD_HASH_PARAMS.keyLength, {
|
||||
N: PASSWORD_HASH_PARAMS.N,
|
||||
r: PASSWORD_HASH_PARAMS.r,
|
||||
p: PASSWORD_HASH_PARAMS.p,
|
||||
maxmem: PASSWORD_HASH_PARAMS.maxmem
|
||||
});
|
||||
|
||||
return {
|
||||
passwordHash: Buffer.from(derived).toString('hex'),
|
||||
passwordSalt: salt.toString('hex'),
|
||||
passwordN: PASSWORD_HASH_PARAMS.N,
|
||||
passwordR: PASSWORD_HASH_PARAMS.r,
|
||||
passwordP: PASSWORD_HASH_PARAMS.p
|
||||
};
|
||||
}
|
||||
|
||||
export async function verifyPassword(password, record) {
|
||||
const normalizedPassword = validateAuthPassword(password);
|
||||
const salt = Buffer.from(String(record.passwordSalt ?? record.password_salt ?? ''), 'hex');
|
||||
const expectedHash = Buffer.from(String(record.passwordHash ?? record.password_hash ?? ''), 'hex');
|
||||
|
||||
if (salt.length === 0 || expectedHash.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const derived = await scrypt(normalizedPassword, salt, expectedHash.length, {
|
||||
N: Number(record.passwordN ?? record.password_n ?? PASSWORD_HASH_PARAMS.N),
|
||||
r: Number(record.passwordR ?? record.password_r ?? PASSWORD_HASH_PARAMS.r),
|
||||
p: Number(record.passwordP ?? record.password_p ?? PASSWORD_HASH_PARAMS.p),
|
||||
maxmem: PASSWORD_HASH_PARAMS.maxmem
|
||||
});
|
||||
|
||||
const actualHash = Buffer.from(derived);
|
||||
if (actualHash.length !== expectedHash.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return timingSafeEqual(actualHash, expectedHash);
|
||||
}
|
||||
|
||||
export function hashSessionToken(token) {
|
||||
return createHash('sha256').update(String(token ?? ''), 'utf8').digest('hex');
|
||||
}
|
||||
|
||||
export function buildOpaqueSessionToken() {
|
||||
const rawToken = randomBytes(32).toString('base64url');
|
||||
return {
|
||||
rawToken,
|
||||
tokenHash: hashSessionToken(rawToken)
|
||||
};
|
||||
}
|
||||
|
||||
export function compareTokenHash(rawToken, expectedHash) {
|
||||
const actual = Buffer.from(hashSessionToken(rawToken), 'hex');
|
||||
const expected = Buffer.from(String(expectedHash ?? ''), 'hex');
|
||||
if (actual.length !== expected.length || expected.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return timingSafeEqual(actual, expected);
|
||||
}
|
||||
264
src/server.js
264
src/server.js
@ -1,3 +1,5 @@
|
||||
import http from 'node:http';
|
||||
import https from 'node:https';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
@ -7,7 +9,9 @@ import { config } from './config.js';
|
||||
import { SourceCatalog } from './db/sourceCatalog.js';
|
||||
import { HttpError, isHttpError } from './errors.js';
|
||||
import { ActiveSourceService } from './services/activeSourceService.js';
|
||||
import { AuthService } from './services/authService.js';
|
||||
import { SourceEnhancementService } from './services/sourceEnhancementService.js';
|
||||
import { TlsService } from './services/tlsService.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
@ -31,6 +35,15 @@ function toPublicServerDb(serverDb) {
|
||||
};
|
||||
}
|
||||
|
||||
function toPublicAuthState(authentication) {
|
||||
return {
|
||||
setupRequired: Boolean(authentication?.setupRequired),
|
||||
authenticated: Boolean(authentication?.authenticated),
|
||||
username: authentication?.username ?? null,
|
||||
expiresAt: authentication?.expiresAt ?? null
|
||||
};
|
||||
}
|
||||
|
||||
function toWebRequest(request) {
|
||||
const host = request.headers.host ?? '127.0.0.1';
|
||||
return new Request(`http://${host}${request.originalUrl}`, {
|
||||
@ -101,14 +114,79 @@ export async function createServerApp(overrides = {}) {
|
||||
sourceCatalog,
|
||||
previewMaxBytes: runtimeConfig.previewMaxBytes
|
||||
});
|
||||
const authService = new AuthService({
|
||||
sourceCatalog,
|
||||
sessionTtlDays: runtimeConfig.authSessionTtlDays
|
||||
});
|
||||
const tlsService = new TlsService({
|
||||
sourceCatalog,
|
||||
tlsDir: runtimeConfig.tlsDir,
|
||||
httpPort: runtimeConfig.port,
|
||||
httpsPort: runtimeConfig.httpsPort
|
||||
});
|
||||
const sourceEnhancementService = new SourceEnhancementService({
|
||||
sourceCatalog
|
||||
});
|
||||
await tlsService.initialize();
|
||||
|
||||
const app = express();
|
||||
app.set('trust proxy', true);
|
||||
app.use(express.json({ limit: '1mb' }));
|
||||
|
||||
function sendPage(pageName) {
|
||||
return (request, response) => {
|
||||
response.sendFile(path.join(publicDir, pageName));
|
||||
};
|
||||
}
|
||||
|
||||
async function requireApiAuth(request, response, next) {
|
||||
try {
|
||||
const authentication = await authService.requireAuthenticatedRequest(request);
|
||||
request.auth = authentication;
|
||||
next();
|
||||
} catch (error) {
|
||||
const normalized = isHttpError(error)
|
||||
? error
|
||||
: new HttpError(500, 'AUTH_INTERNAL_ERROR', error.message || 'Unexpected authentication error.');
|
||||
|
||||
if (normalized.code === 'SESSION_EXPIRED' || normalized.code === 'AUTH_REQUIRED' || normalized.code === 'AUTH_SETUP_REQUIRED') {
|
||||
authService.clearSessionCookie(response, request);
|
||||
}
|
||||
|
||||
next(normalized);
|
||||
}
|
||||
}
|
||||
|
||||
async function requirePageAuth(request, response, next) {
|
||||
try {
|
||||
const authentication = await authService.resolveRequestAuth(request);
|
||||
if (authentication.authenticated) {
|
||||
request.auth = authentication;
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
authService.clearSessionCookie(response, request);
|
||||
response.redirect(302, authService.buildLoginRedirectUrl(request));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
app.get('/login', (request, response) => {
|
||||
const nextTarget = request.query.next ? `?next=${encodeURIComponent(String(request.query.next))}` : '';
|
||||
response.redirect(302, `/login.html${nextTarget}`);
|
||||
});
|
||||
|
||||
app.get('/login.html', sendPage('login.html'));
|
||||
app.get('/', requirePageAuth, sendPage('index.html'));
|
||||
app.get('/index.html', requirePageAuth, sendPage('index.html'));
|
||||
app.get('/source.html', requirePageAuth, sendPage('source.html'));
|
||||
app.get('/file.html', requirePageAuth, sendPage('file.html'));
|
||||
|
||||
app.use(
|
||||
express.static(publicDir, {
|
||||
index: false,
|
||||
etag: false,
|
||||
lastModified: false,
|
||||
maxAge: 0,
|
||||
@ -132,6 +210,121 @@ export async function createServerApp(overrides = {}) {
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/auth/state', async (request, response, next) => {
|
||||
try {
|
||||
const authentication = await authService.getPublicAuthState(request);
|
||||
if (authentication.shouldClearCookie) {
|
||||
authService.clearSessionCookie(response, request);
|
||||
}
|
||||
|
||||
response.json({
|
||||
auth: toPublicAuthState(authentication.state)
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/auth/setup', async (request, response, next) => {
|
||||
try {
|
||||
const payload = await parseJsonBody(request);
|
||||
const result = await authService.setupInitialUser(payload);
|
||||
authService.writeSessionCookie(response, request, result.session);
|
||||
response.status(201).json({
|
||||
auth: toPublicAuthState(result.auth)
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/auth/login', async (request, response, next) => {
|
||||
try {
|
||||
const payload = await parseJsonBody(request);
|
||||
const result = await authService.login(payload);
|
||||
authService.writeSessionCookie(response, request, result.session);
|
||||
response.json({
|
||||
auth: toPublicAuthState(result.auth)
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/auth/logout', async (request, response, next) => {
|
||||
try {
|
||||
await authService.logout(request);
|
||||
authService.clearSessionCookie(response, request);
|
||||
response.json({
|
||||
ok: true
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.use('/api', requireApiAuth);
|
||||
|
||||
app.get('/api/system/tls', async (request, response, next) => {
|
||||
try {
|
||||
response.json({
|
||||
tls: await tlsService.getTlsSummary()
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/system/tls', async (request, response, next) => {
|
||||
try {
|
||||
const payload = await parseJsonBody(request);
|
||||
response.json({
|
||||
tls: await tlsService.saveSettingsFromJson(payload)
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/system/tls/upload', async (request, response, next) => {
|
||||
try {
|
||||
const formData = await parseMultipartFormData(request);
|
||||
const certificateFile = formData.get('certificate');
|
||||
const privateKeyFile = formData.get('privateKey');
|
||||
const chainFile = formData.get('chain');
|
||||
|
||||
if (!certificateFile || typeof certificateFile !== 'object' || typeof certificateFile.arrayBuffer !== 'function') {
|
||||
throw new HttpError(400, 'MISSING_TLS_CERTIFICATE', 'Form field "certificate" is required.');
|
||||
}
|
||||
|
||||
if (!privateKeyFile || typeof privateKeyFile !== 'object' || typeof privateKeyFile.arrayBuffer !== 'function') {
|
||||
throw new HttpError(400, 'MISSING_TLS_PRIVATE_KEY', 'Form field "privateKey" is required.');
|
||||
}
|
||||
|
||||
response.json({
|
||||
tls: await tlsService.saveSettingsFromUpload({
|
||||
primaryDomain: formData.get('primaryDomain'),
|
||||
subjectAltNames: formData.get('subjectAltNames'),
|
||||
certificateFile,
|
||||
privateKeyFile,
|
||||
chainFile
|
||||
})
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/system/tls/custom-certificate', async (request, response, next) => {
|
||||
try {
|
||||
response.json({
|
||||
tls: await tlsService.deleteCustomCertificate()
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/sources', async (request, response, next) => {
|
||||
try {
|
||||
const sources = await sourceCatalog.listSources();
|
||||
@ -420,6 +613,7 @@ export async function createServerApp(overrides = {}) {
|
||||
|
||||
return {
|
||||
app,
|
||||
tlsService,
|
||||
async close() {
|
||||
await sourceCatalog.cleanupOrphanedUploads();
|
||||
}
|
||||
@ -433,16 +627,59 @@ export async function startServer(overrides = {}) {
|
||||
...overrides
|
||||
};
|
||||
|
||||
const server = runtime.app.listen(runtimeConfig.port, () => {
|
||||
console.log(`Duplicati metadata API listening on http://127.0.0.1:${runtimeConfig.port}`);
|
||||
});
|
||||
const httpServer = http.createServer(runtime.app);
|
||||
const httpsServer = https.createServer(runtime.tlsService.getHttpsServerOptions(), runtime.app);
|
||||
runtime.tlsService.attachHttpsServer(httpsServer);
|
||||
|
||||
async function shutdown(signal) {
|
||||
console.log(`Received ${signal}, shutting down...`);
|
||||
server.close(async () => {
|
||||
await runtime.close();
|
||||
process.exit(0);
|
||||
await Promise.all([
|
||||
new Promise((resolve) => {
|
||||
httpServer.listen(runtimeConfig.port, () => resolve());
|
||||
}),
|
||||
new Promise((resolve) => {
|
||||
httpsServer.listen(runtimeConfig.httpsPort, () => resolve());
|
||||
})
|
||||
]);
|
||||
|
||||
const httpAddress = httpServer.address();
|
||||
const httpsAddress = httpsServer.address();
|
||||
if (httpAddress && typeof httpAddress === 'object' && httpsAddress && typeof httpsAddress === 'object') {
|
||||
runtime.tlsService.setResolvedPorts({
|
||||
httpPort: httpAddress.port,
|
||||
httpsPort: httpsAddress.port
|
||||
});
|
||||
console.log(`Duplicati metadata API listening on http://127.0.0.1:${httpAddress.port}`);
|
||||
console.log(`Duplicati metadata API listening on https://127.0.0.1:${httpsAddress.port}`);
|
||||
} else {
|
||||
console.log(`Duplicati metadata API listening on HTTP ${runtimeConfig.port} and HTTPS ${runtimeConfig.httpsPort}`);
|
||||
}
|
||||
|
||||
function closeServer(server) {
|
||||
return new Promise((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
let shuttingDown = false;
|
||||
async function shutdown(signal) {
|
||||
if (shuttingDown) {
|
||||
return;
|
||||
}
|
||||
|
||||
shuttingDown = true;
|
||||
console.log(`Received ${signal}, shutting down...`);
|
||||
try {
|
||||
await Promise.allSettled([closeServer(httpServer), closeServer(httpsServer)]);
|
||||
await runtime.close();
|
||||
} finally {
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
@ -453,7 +690,16 @@ export async function startServer(overrides = {}) {
|
||||
void shutdown('SIGTERM');
|
||||
});
|
||||
|
||||
return { ...runtime, server };
|
||||
return {
|
||||
...runtime,
|
||||
server: httpServer,
|
||||
httpServer,
|
||||
httpsServer,
|
||||
async close() {
|
||||
await Promise.allSettled([closeServer(httpServer), closeServer(httpsServer)]);
|
||||
await runtime.close();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const isMainModule =
|
||||
|
||||
250
src/services/authService.js
Normal file
250
src/services/authService.js
Normal file
@ -0,0 +1,250 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { HttpError } from '../errors.js';
|
||||
import {
|
||||
buildOpaqueSessionToken,
|
||||
compareTokenHash,
|
||||
hashPassword,
|
||||
normalizeAuthUsername,
|
||||
validateAuthPassword,
|
||||
verifyPassword
|
||||
} from '../lib/auth.js';
|
||||
|
||||
const DEFAULT_COOKIE_NAME = 'duplicati_session';
|
||||
|
||||
function parseCookies(cookieHeader) {
|
||||
const cookies = new Map();
|
||||
for (const segment of String(cookieHeader ?? '').split(';')) {
|
||||
const [rawName, ...rawValueParts] = segment.trim().split('=');
|
||||
if (!rawName) {
|
||||
continue;
|
||||
}
|
||||
|
||||
cookies.set(rawName, rawValueParts.join('='));
|
||||
}
|
||||
|
||||
return cookies;
|
||||
}
|
||||
|
||||
function parseSessionCookie(cookieValue) {
|
||||
const [sessionId, rawToken] = String(cookieValue ?? '').split('.', 2);
|
||||
if (!sessionId || !rawToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
rawToken
|
||||
};
|
||||
}
|
||||
|
||||
function buildPublicAuthState(resolution) {
|
||||
return {
|
||||
setupRequired: Boolean(resolution.setupRequired),
|
||||
authenticated: Boolean(resolution.authenticated),
|
||||
username: resolution.username ?? null,
|
||||
expiresAt: resolution.expiresAt ?? null
|
||||
};
|
||||
}
|
||||
|
||||
export class AuthService {
|
||||
constructor({ sourceCatalog, sessionTtlDays, cookieName = DEFAULT_COOKIE_NAME }) {
|
||||
this.sourceCatalog = sourceCatalog;
|
||||
this.sessionTtlMs = Number(sessionTtlDays) * 24 * 60 * 60 * 1000;
|
||||
this.cookieName = cookieName;
|
||||
}
|
||||
|
||||
async resolveRequestAuth(request) {
|
||||
const userCount = await this.sourceCatalog.getAuthUserCount();
|
||||
if (userCount === 0) {
|
||||
return {
|
||||
setupRequired: true,
|
||||
authenticated: false,
|
||||
username: null,
|
||||
expiresAt: null,
|
||||
shouldClearCookie: Boolean(parseCookies(request.headers.cookie).get(this.cookieName)),
|
||||
failureCode: 'AUTH_SETUP_REQUIRED'
|
||||
};
|
||||
}
|
||||
|
||||
const cookieJar = parseCookies(request.headers.cookie);
|
||||
const sessionCookie = parseSessionCookie(cookieJar.get(this.cookieName));
|
||||
if (!sessionCookie) {
|
||||
return {
|
||||
setupRequired: false,
|
||||
authenticated: false,
|
||||
username: null,
|
||||
expiresAt: null,
|
||||
shouldClearCookie: false,
|
||||
failureCode: 'AUTH_REQUIRED'
|
||||
};
|
||||
}
|
||||
|
||||
const session = await this.sourceCatalog.getAuthSessionById(sessionCookie.sessionId);
|
||||
if (!session) {
|
||||
return {
|
||||
setupRequired: false,
|
||||
authenticated: false,
|
||||
username: null,
|
||||
expiresAt: null,
|
||||
shouldClearCookie: true,
|
||||
failureCode: 'AUTH_REQUIRED'
|
||||
};
|
||||
}
|
||||
|
||||
if (!compareTokenHash(sessionCookie.rawToken, session.tokenHash)) {
|
||||
await this.sourceCatalog.deleteAuthSession(session.id);
|
||||
return {
|
||||
setupRequired: false,
|
||||
authenticated: false,
|
||||
username: null,
|
||||
expiresAt: null,
|
||||
shouldClearCookie: true,
|
||||
failureCode: 'AUTH_REQUIRED'
|
||||
};
|
||||
}
|
||||
|
||||
if (Date.parse(session.expiresAt) <= Date.now()) {
|
||||
await this.sourceCatalog.deleteAuthSession(session.id);
|
||||
return {
|
||||
setupRequired: false,
|
||||
authenticated: false,
|
||||
username: null,
|
||||
expiresAt: null,
|
||||
shouldClearCookie: true,
|
||||
failureCode: 'SESSION_EXPIRED'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
setupRequired: false,
|
||||
authenticated: true,
|
||||
userId: session.userId,
|
||||
username: session.username,
|
||||
expiresAt: session.expiresAt,
|
||||
sessionId: session.id,
|
||||
shouldClearCookie: false,
|
||||
failureCode: null
|
||||
};
|
||||
}
|
||||
|
||||
async getPublicAuthState(request) {
|
||||
const resolution = await this.resolveRequestAuth(request);
|
||||
return {
|
||||
state: buildPublicAuthState(resolution),
|
||||
shouldClearCookie: resolution.shouldClearCookie
|
||||
};
|
||||
}
|
||||
|
||||
async requireAuthenticatedRequest(request) {
|
||||
const resolution = await this.resolveRequestAuth(request);
|
||||
if (!resolution.authenticated) {
|
||||
if (resolution.setupRequired) {
|
||||
throw new HttpError(
|
||||
401,
|
||||
'AUTH_SETUP_REQUIRED',
|
||||
'No administrator account exists yet. Complete the first-run setup before using the app.'
|
||||
);
|
||||
}
|
||||
|
||||
if (resolution.failureCode === 'SESSION_EXPIRED') {
|
||||
throw new HttpError(401, 'SESSION_EXPIRED', 'Your session has expired. Please sign in again.');
|
||||
}
|
||||
|
||||
throw new HttpError(401, 'AUTH_REQUIRED', 'Please sign in before using this page.');
|
||||
}
|
||||
|
||||
return resolution;
|
||||
}
|
||||
|
||||
async setupInitialUser(payload) {
|
||||
const username = normalizeAuthUsername(payload.username);
|
||||
const password = validateAuthPassword(payload.password);
|
||||
const hashedPassword = await hashPassword(password);
|
||||
const user = await this.sourceCatalog.createInitialAuthUser({
|
||||
id: randomUUID(),
|
||||
username,
|
||||
...hashedPassword
|
||||
});
|
||||
|
||||
return this.createSessionForUser(user);
|
||||
}
|
||||
|
||||
async login(payload) {
|
||||
const username = normalizeAuthUsername(payload.username);
|
||||
const password = validateAuthPassword(payload.password);
|
||||
const user = await this.sourceCatalog.getAuthUserByUsername(username);
|
||||
if (!user) {
|
||||
throw new HttpError(401, 'INVALID_CREDENTIALS', 'Username or password is incorrect.');
|
||||
}
|
||||
|
||||
const passwordMatches = await verifyPassword(password, user);
|
||||
if (!passwordMatches) {
|
||||
throw new HttpError(401, 'INVALID_CREDENTIALS', 'Username or password is incorrect.');
|
||||
}
|
||||
|
||||
return this.createSessionForUser(user);
|
||||
}
|
||||
|
||||
async logout(request) {
|
||||
const cookieJar = parseCookies(request.headers.cookie);
|
||||
const sessionCookie = parseSessionCookie(cookieJar.get(this.cookieName));
|
||||
if (!sessionCookie?.sessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.sourceCatalog.deleteAuthSession(sessionCookie.sessionId);
|
||||
}
|
||||
|
||||
async createSessionForUser(user) {
|
||||
const sessionId = randomUUID();
|
||||
const { rawToken, tokenHash } = buildOpaqueSessionToken();
|
||||
const createdAt = new Date().toISOString();
|
||||
const expiresAt = new Date(Date.now() + this.sessionTtlMs).toISOString();
|
||||
|
||||
await this.sourceCatalog.createAuthSession({
|
||||
id: sessionId,
|
||||
userId: user.id,
|
||||
tokenHash,
|
||||
createdAt,
|
||||
expiresAt
|
||||
});
|
||||
|
||||
return {
|
||||
auth: {
|
||||
setupRequired: false,
|
||||
authenticated: true,
|
||||
username: user.username,
|
||||
expiresAt
|
||||
},
|
||||
session: {
|
||||
cookieValue: `${sessionId}.${rawToken}`,
|
||||
expiresAt
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
writeSessionCookie(response, request, session) {
|
||||
response.cookie(this.cookieName, session.cookieValue, {
|
||||
httpOnly: true,
|
||||
sameSite: 'strict',
|
||||
path: '/',
|
||||
maxAge: this.sessionTtlMs,
|
||||
secure: false
|
||||
});
|
||||
}
|
||||
|
||||
clearSessionCookie(response, request) {
|
||||
response.clearCookie(this.cookieName, {
|
||||
httpOnly: true,
|
||||
sameSite: 'strict',
|
||||
path: '/',
|
||||
secure: false
|
||||
});
|
||||
}
|
||||
|
||||
buildLoginRedirectUrl(request) {
|
||||
const nextTarget = encodeURIComponent(request.originalUrl || '/');
|
||||
return `/login.html?next=${nextTarget}`;
|
||||
}
|
||||
}
|
||||
552
src/services/tlsService.js
Normal file
552
src/services/tlsService.js
Normal file
@ -0,0 +1,552 @@
|
||||
import { createPublicKey, X509Certificate } from 'node:crypto';
|
||||
import fs from 'node:fs/promises';
|
||||
import net from 'node:net';
|
||||
import path from 'node:path';
|
||||
import tls from 'node:tls';
|
||||
|
||||
import selfsigned from 'selfsigned';
|
||||
|
||||
import { HttpError } from '../errors.js';
|
||||
|
||||
const SELF_SIGNED_CERT_FILENAME = 'self-signed.cert.pem';
|
||||
const SELF_SIGNED_KEY_FILENAME = 'self-signed.key.pem';
|
||||
const CUSTOM_CERT_FILENAME = 'custom.cert.pem';
|
||||
const CUSTOM_KEY_FILENAME = 'custom.key.pem';
|
||||
const CUSTOM_CHAIN_FILENAME = 'custom.chain.pem';
|
||||
|
||||
function asTrimmedString(value) {
|
||||
if (value === null || value === undefined) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return String(value).trim();
|
||||
}
|
||||
|
||||
function normalizeTlsMode(value) {
|
||||
const normalized = asTrimmedString(value).toLowerCase();
|
||||
if (!normalized || normalized === 'self-signed') {
|
||||
return 'self-signed';
|
||||
}
|
||||
|
||||
if (normalized === 'custom-pem') {
|
||||
return 'custom-pem';
|
||||
}
|
||||
|
||||
throw new HttpError(400, 'INVALID_TLS_MODE', 'TLS mode must be "self-signed" or "custom-pem".');
|
||||
}
|
||||
|
||||
function normalizePrimaryDomain(value) {
|
||||
return asTrimmedString(value);
|
||||
}
|
||||
|
||||
function normalizeSubjectAltNames(value) {
|
||||
const rawValues = Array.isArray(value)
|
||||
? value
|
||||
: String(value ?? '')
|
||||
.split(/[\r\n,]+/g)
|
||||
.map((entry) => entry.trim());
|
||||
|
||||
const seen = new Set();
|
||||
const normalized = [];
|
||||
|
||||
for (const entry of rawValues) {
|
||||
const candidate = asTrimmedString(entry);
|
||||
if (!candidate) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = candidate.toLowerCase();
|
||||
if (seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seen.add(key);
|
||||
normalized.push(candidate);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function formatHostForUrl(hostname) {
|
||||
return net.isIP(hostname) === 6 ? `[${hostname}]` : hostname;
|
||||
}
|
||||
|
||||
function buildBaseUrl(protocol, hostname, port) {
|
||||
const normalizedHost = formatHostForUrl(hostname || 'localhost');
|
||||
return `${protocol}://${normalizedHost}:${port}`;
|
||||
}
|
||||
|
||||
function buildDefaultAltNameValues(primaryDomain, subjectAltNames) {
|
||||
const merged = new Set(['localhost', '127.0.0.1', '::1']);
|
||||
|
||||
if (primaryDomain) {
|
||||
merged.add(primaryDomain);
|
||||
}
|
||||
|
||||
for (const entry of subjectAltNames) {
|
||||
merged.add(entry);
|
||||
}
|
||||
|
||||
return [...merged];
|
||||
}
|
||||
|
||||
function buildSelfSignedAltNames(primaryDomain, subjectAltNames) {
|
||||
return buildDefaultAltNameValues(primaryDomain, subjectAltNames).map((value) => {
|
||||
if (net.isIP(value)) {
|
||||
return {
|
||||
type: 7,
|
||||
ip: value
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: 2,
|
||||
value
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function parseCertificateSummary(certPem) {
|
||||
const certificate = new X509Certificate(certPem);
|
||||
return {
|
||||
subject: certificate.subject,
|
||||
issuer: certificate.issuer,
|
||||
validFrom: certificate.validFrom,
|
||||
validTo: certificate.validTo,
|
||||
fingerprint256: certificate.fingerprint256,
|
||||
subjectAltName: certificate.subjectAltName ?? '',
|
||||
serialNumber: certificate.serialNumber
|
||||
};
|
||||
}
|
||||
|
||||
function ensurePemText(value, label) {
|
||||
const pem = asTrimmedString(value);
|
||||
if (!pem) {
|
||||
throw new HttpError(400, 'MISSING_TLS_PEM', `${label} is required.`);
|
||||
}
|
||||
|
||||
return pem.endsWith('\n') ? pem : `${pem}\n`;
|
||||
}
|
||||
|
||||
function normalizePemKey(keyValue) {
|
||||
return String(keyValue).replaceAll('\r\n', '\n').trim();
|
||||
}
|
||||
|
||||
function validateCustomCertificateMaterial({ certPem, keyPem, chainPem }) {
|
||||
const fullCertPem = `${certPem}${chainPem ?? ''}`;
|
||||
|
||||
try {
|
||||
tls.createSecureContext({
|
||||
key: keyPem,
|
||||
cert: fullCertPem
|
||||
});
|
||||
} catch (error) {
|
||||
throw new HttpError(400, 'INVALID_CUSTOM_CERTIFICATE', `Failed to parse certificate or private key: ${error.message}`);
|
||||
}
|
||||
|
||||
let certificate;
|
||||
try {
|
||||
certificate = new X509Certificate(certPem);
|
||||
} catch (error) {
|
||||
throw new HttpError(400, 'INVALID_CUSTOM_CERTIFICATE', `Failed to parse certificate: ${error.message}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const privateKeyPublic = normalizePemKey(
|
||||
createPublicKey(keyPem).export({ type: 'spki', format: 'pem' })
|
||||
);
|
||||
const certificatePublic = normalizePemKey(
|
||||
certificate.publicKey.export({ type: 'spki', format: 'pem' })
|
||||
);
|
||||
|
||||
if (privateKeyPublic !== certificatePublic) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
'TLS_KEY_MISMATCH',
|
||||
'The private key does not match the public key in the certificate.'
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof HttpError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new HttpError(400, 'TLS_KEY_MISMATCH', `Failed to verify key pair: ${error.message}`);
|
||||
}
|
||||
|
||||
return parseCertificateSummary(certPem);
|
||||
}
|
||||
|
||||
async function readOptionalUtf8File(filename) {
|
||||
if (!filename) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return fs.readFile(filename, 'utf8');
|
||||
}
|
||||
|
||||
async function writeUtf8File(filename, contents) {
|
||||
await fs.writeFile(filename, contents, 'utf8');
|
||||
}
|
||||
|
||||
async function removeFileIfPresent(filename) {
|
||||
if (!filename) {
|
||||
return;
|
||||
}
|
||||
|
||||
await fs.rm(filename, { force: true }).catch(() => {});
|
||||
}
|
||||
|
||||
async function readFileFieldText(file, label) {
|
||||
if (!file) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof file !== 'object' || typeof file.arrayBuffer !== 'function') {
|
||||
throw new HttpError(400, 'INVALID_TLS_UPLOAD', `${label} must be uploaded as a file.`);
|
||||
}
|
||||
|
||||
const bytes = await file.arrayBuffer();
|
||||
const text = Buffer.from(bytes).toString('utf8');
|
||||
return ensurePemText(text, label);
|
||||
}
|
||||
|
||||
export class TlsService {
|
||||
constructor({ sourceCatalog, tlsDir, httpPort, httpsPort }) {
|
||||
this.sourceCatalog = sourceCatalog;
|
||||
this.tlsDir = tlsDir;
|
||||
this.httpPort = httpPort;
|
||||
this.httpsPort = httpsPort;
|
||||
this.selfSignedCertPath = path.join(tlsDir, SELF_SIGNED_CERT_FILENAME);
|
||||
this.selfSignedKeyPath = path.join(tlsDir, SELF_SIGNED_KEY_FILENAME);
|
||||
this.customCertPath = path.join(tlsDir, CUSTOM_CERT_FILENAME);
|
||||
this.customKeyPath = path.join(tlsDir, CUSTOM_KEY_FILENAME);
|
||||
this.customChainPath = path.join(tlsDir, CUSTOM_CHAIN_FILENAME);
|
||||
this.httpsServer = null;
|
||||
this.currentMaterial = null;
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
await fs.mkdir(this.tlsDir, { recursive: true });
|
||||
await this.applyPersistedConfiguration();
|
||||
}
|
||||
|
||||
attachHttpsServer(server) {
|
||||
this.httpsServer = server;
|
||||
if (this.currentMaterial?.serverOptions) {
|
||||
this.httpsServer.setSecureContext(this.currentMaterial.serverOptions);
|
||||
}
|
||||
}
|
||||
|
||||
setResolvedPorts({ httpPort, httpsPort }) {
|
||||
if (Number.isFinite(Number(httpPort)) && Number(httpPort) > 0) {
|
||||
this.httpPort = Number(httpPort);
|
||||
}
|
||||
|
||||
if (Number.isFinite(Number(httpsPort)) && Number(httpsPort) > 0) {
|
||||
this.httpsPort = Number(httpsPort);
|
||||
}
|
||||
}
|
||||
|
||||
getHttpsServerOptions() {
|
||||
if (!this.currentMaterial?.serverOptions) {
|
||||
throw new HttpError(500, 'TLS_NOT_READY', 'HTTPS certificate material has not been initialized yet.');
|
||||
}
|
||||
|
||||
return this.currentMaterial.serverOptions;
|
||||
}
|
||||
|
||||
async getTlsSummary() {
|
||||
const settings = await this.sourceCatalog.getTlsSettingsRecord();
|
||||
const certificate = this.currentMaterial?.summary ?? null;
|
||||
const activeSource = this.currentMaterial?.activeSource ?? settings.activeSource;
|
||||
const primaryHostname = settings.primaryDomain || 'localhost';
|
||||
|
||||
return {
|
||||
mode: settings.mode,
|
||||
activeSource,
|
||||
primaryDomain: settings.primaryDomain,
|
||||
subjectAltNames: settings.subjectAltNames,
|
||||
hasCustomCertificate: Boolean(settings.customCertPath && settings.customKeyPath),
|
||||
access: {
|
||||
httpBaseUrl: buildBaseUrl('http', primaryHostname, this.httpPort),
|
||||
httpsBaseUrl: buildBaseUrl('https', primaryHostname, this.httpsPort),
|
||||
httpPort: this.httpPort,
|
||||
httpsPort: this.httpsPort
|
||||
},
|
||||
certificate,
|
||||
lastErrorCode: settings.lastErrorCode,
|
||||
lastErrorMessage: settings.lastErrorMessage,
|
||||
updatedAt: settings.updatedAt,
|
||||
cookieNote: 'HTTP and HTTPS share the same login session, so the session cookie is intentionally not forced to Secure.'
|
||||
};
|
||||
}
|
||||
|
||||
async saveSettingsFromJson(payload) {
|
||||
const mode = normalizeTlsMode(payload.mode);
|
||||
const primaryDomain = normalizePrimaryDomain(payload.primaryDomain);
|
||||
const subjectAltNames = normalizeSubjectAltNames(payload.subjectAltNames);
|
||||
|
||||
if (mode === 'self-signed') {
|
||||
const settings = await this.sourceCatalog.getTlsSettingsRecord();
|
||||
await this.sourceCatalog.saveTlsSettings({
|
||||
...settings,
|
||||
mode,
|
||||
primaryDomain,
|
||||
subjectAltNames,
|
||||
activeSource: 'self-signed',
|
||||
lastErrorCode: null,
|
||||
lastErrorMessage: null,
|
||||
updatedAt: new Date().toISOString()
|
||||
});
|
||||
|
||||
await this.applySelfSignedConfiguration({ primaryDomain, subjectAltNames, forceRegenerate: true });
|
||||
return this.getTlsSummary();
|
||||
}
|
||||
|
||||
const certPem = ensurePemText(payload.certPem, 'certPem');
|
||||
const keyPem = ensurePemText(payload.keyPem, 'keyPem');
|
||||
const chainPem = asTrimmedString(payload.chainPem) ? ensurePemText(payload.chainPem, 'chainPem') : null;
|
||||
|
||||
return this.saveCustomPemConfiguration({
|
||||
primaryDomain,
|
||||
subjectAltNames,
|
||||
certPem,
|
||||
keyPem,
|
||||
chainPem
|
||||
});
|
||||
}
|
||||
|
||||
async saveSettingsFromUpload({ primaryDomain, subjectAltNames, certificateFile, privateKeyFile, chainFile }) {
|
||||
const certPem = await readFileFieldText(certificateFile, 'certificate');
|
||||
const keyPem = await readFileFieldText(privateKeyFile, 'privateKey');
|
||||
const chainPem = chainFile ? await readFileFieldText(chainFile, 'chain') : null;
|
||||
|
||||
return this.saveCustomPemConfiguration({
|
||||
primaryDomain: normalizePrimaryDomain(primaryDomain),
|
||||
subjectAltNames: normalizeSubjectAltNames(subjectAltNames),
|
||||
certPem,
|
||||
keyPem,
|
||||
chainPem
|
||||
});
|
||||
}
|
||||
|
||||
async deleteCustomCertificate() {
|
||||
const settings = await this.sourceCatalog.getTlsSettingsRecord();
|
||||
await removeFileIfPresent(settings.customCertPath ?? this.customCertPath);
|
||||
await removeFileIfPresent(settings.customKeyPath ?? this.customKeyPath);
|
||||
await removeFileIfPresent(settings.customChainPath ?? this.customChainPath);
|
||||
|
||||
await this.sourceCatalog.saveTlsSettings({
|
||||
...settings,
|
||||
mode: 'self-signed',
|
||||
customCertPath: null,
|
||||
customKeyPath: null,
|
||||
customChainPath: null,
|
||||
activeSource: 'self-signed',
|
||||
lastErrorCode: null,
|
||||
lastErrorMessage: null,
|
||||
updatedAt: new Date().toISOString()
|
||||
});
|
||||
|
||||
await this.applySelfSignedConfiguration({
|
||||
primaryDomain: settings.primaryDomain,
|
||||
subjectAltNames: settings.subjectAltNames,
|
||||
forceRegenerate: true
|
||||
});
|
||||
|
||||
return this.getTlsSummary();
|
||||
}
|
||||
|
||||
async applyPersistedConfiguration() {
|
||||
const settings = await this.sourceCatalog.getTlsSettingsRecord();
|
||||
|
||||
if (settings.mode === 'custom-pem' && settings.customCertPath && settings.customKeyPath) {
|
||||
try {
|
||||
const certPem = await readOptionalUtf8File(settings.customCertPath);
|
||||
const keyPem = await readOptionalUtf8File(settings.customKeyPath);
|
||||
const chainPem = await readOptionalUtf8File(settings.customChainPath);
|
||||
|
||||
if (!certPem || !keyPem) {
|
||||
throw new HttpError(500, 'TLS_CUSTOM_MISSING', 'Custom certificate files are missing.');
|
||||
}
|
||||
|
||||
await this.applyCertificateMaterial({
|
||||
activeSource: 'custom-pem',
|
||||
certPem,
|
||||
keyPem,
|
||||
chainPem,
|
||||
summary: validateCustomCertificateMaterial({ certPem, keyPem, chainPem })
|
||||
});
|
||||
|
||||
await this.sourceCatalog.saveTlsSettings({
|
||||
...settings,
|
||||
activeSource: 'custom-pem',
|
||||
lastErrorCode: null,
|
||||
lastErrorMessage: null,
|
||||
updatedAt: new Date().toISOString()
|
||||
});
|
||||
return;
|
||||
} catch (error) {
|
||||
await this.sourceCatalog.saveTlsSettings({
|
||||
...settings,
|
||||
activeSource: 'self-signed',
|
||||
lastErrorCode: error.code ?? 'TLS_CUSTOM_LOAD_FAILED',
|
||||
lastErrorMessage: error.message ?? 'Failed to load the stored custom certificate.',
|
||||
updatedAt: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await this.applySelfSignedConfiguration({
|
||||
primaryDomain: settings.primaryDomain,
|
||||
subjectAltNames: settings.subjectAltNames,
|
||||
forceRegenerate: !(await this.selfSignedFilesExist())
|
||||
});
|
||||
}
|
||||
|
||||
async saveCustomPemConfiguration({ primaryDomain, subjectAltNames, certPem, keyPem, chainPem }) {
|
||||
const summary = validateCustomCertificateMaterial({ certPem, keyPem, chainPem });
|
||||
const tempCertPath = `${this.customCertPath}.tmp`;
|
||||
const tempKeyPath = `${this.customKeyPath}.tmp`;
|
||||
const tempChainPath = `${this.customChainPath}.tmp`;
|
||||
|
||||
await writeUtf8File(tempCertPath, certPem);
|
||||
await writeUtf8File(tempKeyPath, keyPem);
|
||||
if (chainPem) {
|
||||
await writeUtf8File(tempChainPath, chainPem);
|
||||
} else {
|
||||
await removeFileIfPresent(tempChainPath);
|
||||
}
|
||||
|
||||
await fs.rename(tempCertPath, this.customCertPath);
|
||||
await fs.rename(tempKeyPath, this.customKeyPath);
|
||||
if (chainPem) {
|
||||
await fs.rename(tempChainPath, this.customChainPath);
|
||||
} else {
|
||||
await removeFileIfPresent(this.customChainPath);
|
||||
}
|
||||
|
||||
const settings = await this.sourceCatalog.getTlsSettingsRecord();
|
||||
await this.sourceCatalog.saveTlsSettings({
|
||||
...settings,
|
||||
mode: 'custom-pem',
|
||||
primaryDomain,
|
||||
subjectAltNames,
|
||||
customCertPath: this.customCertPath,
|
||||
customKeyPath: this.customKeyPath,
|
||||
customChainPath: chainPem ? this.customChainPath : null,
|
||||
activeSource: 'custom-pem',
|
||||
lastErrorCode: null,
|
||||
lastErrorMessage: null,
|
||||
updatedAt: new Date().toISOString()
|
||||
});
|
||||
|
||||
await this.applyCertificateMaterial({
|
||||
activeSource: 'custom-pem',
|
||||
certPem,
|
||||
keyPem,
|
||||
chainPem,
|
||||
summary
|
||||
});
|
||||
|
||||
return this.getTlsSummary();
|
||||
}
|
||||
|
||||
async applySelfSignedConfiguration({ primaryDomain, subjectAltNames, forceRegenerate = false }) {
|
||||
let certPem = null;
|
||||
let keyPem = null;
|
||||
let shouldGenerate = forceRegenerate || !(await this.selfSignedFilesExist());
|
||||
|
||||
if (!shouldGenerate) {
|
||||
try {
|
||||
certPem = await fs.readFile(this.selfSignedCertPath, 'utf8');
|
||||
keyPem = await fs.readFile(this.selfSignedKeyPath, 'utf8');
|
||||
parseCertificateSummary(certPem);
|
||||
tls.createSecureContext({ key: keyPem, cert: certPem });
|
||||
} catch (error) {
|
||||
shouldGenerate = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldGenerate) {
|
||||
const generated = await selfsigned.generate(
|
||||
[
|
||||
{
|
||||
name: 'commonName',
|
||||
value: primaryDomain || 'localhost'
|
||||
}
|
||||
],
|
||||
{
|
||||
algorithm: 'sha256',
|
||||
keySize: 2048,
|
||||
days: 825,
|
||||
extensions: [
|
||||
{
|
||||
name: 'basicConstraints',
|
||||
cA: false
|
||||
},
|
||||
{
|
||||
name: 'keyUsage',
|
||||
digitalSignature: true,
|
||||
keyEncipherment: true
|
||||
},
|
||||
{
|
||||
name: 'extKeyUsage',
|
||||
serverAuth: true
|
||||
},
|
||||
{
|
||||
name: 'subjectAltName',
|
||||
altNames: buildSelfSignedAltNames(primaryDomain, subjectAltNames)
|
||||
}
|
||||
]
|
||||
}
|
||||
);
|
||||
|
||||
certPem = ensurePemText(generated.cert, 'self-signed certificate');
|
||||
keyPem = ensurePemText(generated.private, 'self-signed private key');
|
||||
await writeUtf8File(this.selfSignedCertPath, certPem);
|
||||
await writeUtf8File(this.selfSignedKeyPath, keyPem);
|
||||
}
|
||||
|
||||
await this.applyCertificateMaterial({
|
||||
activeSource: 'self-signed',
|
||||
certPem,
|
||||
keyPem,
|
||||
chainPem: null,
|
||||
summary: parseCertificateSummary(certPem)
|
||||
});
|
||||
}
|
||||
|
||||
async applyCertificateMaterial({ activeSource, certPem, keyPem, chainPem, summary }) {
|
||||
const cert = chainPem ? `${certPem}${chainPem}` : certPem;
|
||||
const serverOptions = {
|
||||
key: keyPem,
|
||||
cert
|
||||
};
|
||||
|
||||
tls.createSecureContext(serverOptions);
|
||||
this.currentMaterial = {
|
||||
activeSource,
|
||||
certPem,
|
||||
keyPem,
|
||||
chainPem,
|
||||
summary,
|
||||
serverOptions
|
||||
};
|
||||
|
||||
if (this.httpsServer) {
|
||||
this.httpsServer.setSecureContext(serverOptions);
|
||||
}
|
||||
}
|
||||
|
||||
async selfSignedFilesExist() {
|
||||
try {
|
||||
await fs.stat(this.selfSignedCertPath);
|
||||
await fs.stat(this.selfSignedKeyPath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -51,27 +51,70 @@ async function startTestServer() {
|
||||
};
|
||||
}
|
||||
|
||||
async function uploadFixture(baseUrl, fixture) {
|
||||
return uploadDatabase(baseUrl, '/api/sources/upload', fixture);
|
||||
async function uploadFixture(baseUrl, fixture, client = null) {
|
||||
return uploadDatabase(baseUrl, '/api/sources/upload', fixture, client);
|
||||
}
|
||||
|
||||
async function uploadServerDbFixture(baseUrl, fixture) {
|
||||
return uploadDatabase(baseUrl, '/api/server-db/upload', fixture);
|
||||
async function uploadServerDbFixture(baseUrl, fixture, client = null) {
|
||||
return uploadDatabase(baseUrl, '/api/server-db/upload', fixture, client);
|
||||
}
|
||||
|
||||
async function uploadDatabase(baseUrl, endpoint, fixture) {
|
||||
async function uploadDatabase(baseUrl, endpoint, fixture, client = null) {
|
||||
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 response = client
|
||||
? await client.fetch(endpoint, {
|
||||
method: 'POST',
|
||||
body: form
|
||||
})
|
||||
: await fetch(`${baseUrl}${endpoint}`, {
|
||||
method: 'POST',
|
||||
body: form
|
||||
});
|
||||
const payload = await response.json();
|
||||
return { response, payload };
|
||||
}
|
||||
|
||||
function attachCookie(headers, cookie) {
|
||||
const normalizedHeaders = new Headers(headers ?? {});
|
||||
normalizedHeaders.set('Cookie', cookie);
|
||||
return normalizedHeaders;
|
||||
}
|
||||
|
||||
async function setupAuthenticatedClient(baseUrl) {
|
||||
const setupResponse = await fetch(`${baseUrl}/api/auth/setup`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username: 'admin',
|
||||
password: 'Password123!'
|
||||
})
|
||||
});
|
||||
const setupPayload = await setupResponse.json();
|
||||
const sessionCookie = setupResponse.headers.get('set-cookie')?.split(';', 1)?.[0] ?? '';
|
||||
assert.equal(setupResponse.status, 201);
|
||||
assert.equal(setupPayload.auth.authenticated, true);
|
||||
assert.ok(sessionCookie);
|
||||
|
||||
return {
|
||||
cookie: sessionCookie,
|
||||
async fetch(pathname, options = {}) {
|
||||
return fetch(`${baseUrl}${pathname}`, {
|
||||
...options,
|
||||
headers: attachCookie(options.headers, sessionCookie)
|
||||
});
|
||||
},
|
||||
async json(pathname, options = {}) {
|
||||
const response = await this.fetch(pathname, options);
|
||||
return response.json();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function createBasicHeader(username, password) {
|
||||
return `Basic ${Buffer.from(`${username}:${password}`, 'utf8').toString('base64')}`;
|
||||
}
|
||||
@ -123,11 +166,11 @@ async function startRemoteVolumeServer({ remoteDir, username, password }) {
|
||||
};
|
||||
}
|
||||
|
||||
async function waitForEnhancementStatus(baseUrl, sourceId, allowedStatuses) {
|
||||
async function waitForEnhancementStatus(baseUrl, sourceId, allowedStatuses, client = null) {
|
||||
for (let attempt = 0; attempt < 120; attempt += 1) {
|
||||
const payload = await fetch(`${baseUrl}/api/sources/${sourceId}/enhance/status`).then((response) =>
|
||||
response.json()
|
||||
);
|
||||
const payload = client
|
||||
? await client.json(`/api/sources/${sourceId}/enhance/status`)
|
||||
: await fetch(`${baseUrl}/api/sources/${sourceId}/enhance/status`).then((response) => response.json());
|
||||
if (allowedStatuses.includes(payload.enhancement.status)) {
|
||||
return payload.enhancement;
|
||||
}
|
||||
@ -156,40 +199,46 @@ test('multi-source uploads coexist, duplicate uploads reuse the existing source,
|
||||
});
|
||||
|
||||
try {
|
||||
const emptySources = await fetch(`${server.baseUrl}/api/sources`).then((response) => response.json());
|
||||
const authStateBeforeSetup = await fetch(`${server.baseUrl}/api/auth/state`).then((response) => response.json());
|
||||
assert.equal(authStateBeforeSetup.auth.setupRequired, true);
|
||||
assert.equal(authStateBeforeSetup.auth.authenticated, false);
|
||||
|
||||
const unauthenticatedSources = await fetch(`${server.baseUrl}/api/sources`).then((response) => response.json());
|
||||
assert.equal(unauthenticatedSources.error.code, 'AUTH_SETUP_REQUIRED');
|
||||
|
||||
const client = await setupAuthenticatedClient(server.baseUrl);
|
||||
const emptySources = await client.json('/api/sources');
|
||||
assert.equal(emptySources.sources.length, 0);
|
||||
|
||||
const firstUpload = await uploadFixture(server.baseUrl, firstFixture);
|
||||
const firstUpload = await uploadFixture(server.baseUrl, firstFixture, client);
|
||||
assert.equal(firstUpload.response.status, 201);
|
||||
assert.equal(firstUpload.payload.reused, false);
|
||||
|
||||
const duplicateUpload = await uploadFixture(server.baseUrl, firstFixture);
|
||||
const duplicateUpload = await uploadFixture(server.baseUrl, firstFixture, client);
|
||||
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);
|
||||
const secondUpload = await uploadFixture(server.baseUrl, secondFixture, client);
|
||||
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 compatCurrent = await client.fetch('/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()
|
||||
);
|
||||
const missingSourceId = await client.json('/api/ls?path=/source/media/movies');
|
||||
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());
|
||||
const firstListing = await client.json(
|
||||
`/api/ls?sourceId=${firstUpload.payload.source.id}&path=/source/media/movies`
|
||||
);
|
||||
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());
|
||||
const secondListing = await client.json(
|
||||
`/api/ls?sourceId=${secondUpload.payload.source.id}&path=/source/media/movies`
|
||||
);
|
||||
assert.equal(secondListing.sourceId, secondUpload.payload.source.id);
|
||||
|
||||
const filesInSourceRoot = await fs.promises.readdir(server.uploadDir, { withFileTypes: true });
|
||||
@ -228,35 +277,34 @@ test('deleting a single source removes only that source and keeps the server dat
|
||||
});
|
||||
|
||||
try {
|
||||
const uploadA = await uploadFixture(server.baseUrl, sourceA);
|
||||
const uploadB = await uploadFixture(server.baseUrl, sourceB);
|
||||
const client = await setupAuthenticatedClient(server.baseUrl);
|
||||
const uploadA = await uploadFixture(server.baseUrl, sourceA, client);
|
||||
const uploadB = await uploadFixture(server.baseUrl, sourceB, client);
|
||||
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);
|
||||
const serverDbUpload = await uploadServerDbFixture(server.baseUrl, serverDb, client);
|
||||
assert.equal(serverDbUpload.response.status, 201);
|
||||
|
||||
const deleteResponse = await fetch(`${server.baseUrl}/api/sources/${uploadA.payload.source.id}`, {
|
||||
const deleteResponse = await client.fetch(`/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());
|
||||
const remainingSources = await client.json('/api/sources');
|
||||
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()
|
||||
);
|
||||
const deletedLookup = await client.json(`/api/sources/${uploadA.payload.source.id}`);
|
||||
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());
|
||||
const serverDbSummary = await client.json('/api/server-db');
|
||||
assert.equal(serverDbSummary.serverDb.available, true);
|
||||
assert.equal(serverDbSummary.serverDb.backupCount, 2);
|
||||
} finally {
|
||||
@ -297,21 +345,22 @@ test('server db upload maps task names onto existing sources and replacement rem
|
||||
});
|
||||
|
||||
try {
|
||||
const invalidServerDbUpload = await uploadDatabase(server.baseUrl, '/api/sources/upload', firstServerDb);
|
||||
const client = await setupAuthenticatedClient(server.baseUrl);
|
||||
const invalidServerDbUpload = await uploadDatabase(server.baseUrl, '/api/sources/upload', firstServerDb, client);
|
||||
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);
|
||||
const uploadA = await uploadFixture(server.baseUrl, sourceA, client);
|
||||
const uploadB = await uploadFixture(server.baseUrl, sourceB, client);
|
||||
assert.equal(uploadA.payload.source.displayName, 'TMQRJYNADS.sqlite');
|
||||
assert.equal(uploadA.payload.source.displayNameSource, 'filename');
|
||||
|
||||
const firstServerDbUpload = await uploadServerDbFixture(server.baseUrl, firstServerDb);
|
||||
const firstServerDbUpload = await uploadServerDbFixture(server.baseUrl, firstServerDb, client);
|
||||
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 sourcesAfterMapping = await client.json('/api/sources');
|
||||
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, '四川农场主');
|
||||
@ -319,14 +368,14 @@ test('server db upload maps task names onto existing sources and replacement rem
|
||||
assert.equal(mappedA.matchedBackupName, '四川农场主');
|
||||
assert.equal(mappedB.displayName, '小白');
|
||||
|
||||
const serverDbSummary = await fetch(`${server.baseUrl}/api/server-db`).then((response) => response.json());
|
||||
const serverDbSummary = await client.json('/api/server-db');
|
||||
assert.equal(serverDbSummary.serverDb.available, true);
|
||||
assert.equal(serverDbSummary.serverDb.backupCount, 2);
|
||||
|
||||
const replacementUpload = await uploadServerDbFixture(server.baseUrl, replacementServerDb);
|
||||
const replacementUpload = await uploadServerDbFixture(server.baseUrl, replacementServerDb, client);
|
||||
assert.equal(replacementUpload.response.status, 201);
|
||||
|
||||
const sourcesAfterReplacement = await fetch(`${server.baseUrl}/api/sources`).then((response) => response.json());
|
||||
const sourcesAfterReplacement = await client.json('/api/sources');
|
||||
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, '四川农场主-新');
|
||||
@ -366,10 +415,11 @@ test('global WebDAV defaults plus server-db target URL allow enhancement without
|
||||
});
|
||||
|
||||
try {
|
||||
const upload = await uploadFixture(server.baseUrl, fixture);
|
||||
const client = await setupAuthenticatedClient(server.baseUrl);
|
||||
const upload = await uploadFixture(server.baseUrl, fixture, client);
|
||||
const sourceId = upload.payload.source.id;
|
||||
|
||||
const defaultsResponse = await fetch(`${server.baseUrl}/api/webdav-defaults`, {
|
||||
const defaultsResponse = await client.fetch('/api/webdav-defaults', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
@ -386,26 +436,22 @@ test('global WebDAV defaults plus server-db target URL allow enhancement without
|
||||
assert.equal(defaultsPayload.defaults.configured, true);
|
||||
assert.equal(defaultsPayload.defaults.hasPassphrase, true);
|
||||
|
||||
const serverDbUpload = await uploadServerDbFixture(server.baseUrl, serverDb);
|
||||
const serverDbUpload = await uploadServerDbFixture(server.baseUrl, serverDb, client);
|
||||
assert.equal(serverDbUpload.response.status, 201);
|
||||
|
||||
const sourceDetail = await fetch(`${server.baseUrl}/api/sources/${sourceId}`).then((response) =>
|
||||
response.json()
|
||||
);
|
||||
const sourceDetail = await client.json(`/api/sources/${sourceId}`);
|
||||
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()
|
||||
);
|
||||
const browserSecrets = await client.json(`/api/sources/${sourceId}/browser-secrets`);
|
||||
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`, {
|
||||
const enhanceResponse = await client.fetch(`/api/sources/${sourceId}/enhance`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
@ -414,7 +460,7 @@ test('global WebDAV defaults plus server-db target URL allow enhancement without
|
||||
});
|
||||
assert.equal(enhanceResponse.status, 202);
|
||||
|
||||
const enhancement = await waitForEnhancementStatus(server.baseUrl, sourceId, ['ready', 'failed']);
|
||||
const enhancement = await waitForEnhancementStatus(server.baseUrl, sourceId, ['ready', 'failed'], client);
|
||||
assert.equal(enhancement.status, 'ready');
|
||||
} finally {
|
||||
await remote.close();
|
||||
@ -434,22 +480,21 @@ test('raw source keeps browsing available, gates file-info, and does not leak sa
|
||||
});
|
||||
|
||||
try {
|
||||
const upload = await uploadFixture(server.baseUrl, rawFixture);
|
||||
const client = await setupAuthenticatedClient(server.baseUrl);
|
||||
const upload = await uploadFixture(server.baseUrl, rawFixture, client);
|
||||
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 listing = await client.json(`/api/ls?sourceId=${sourceId}&path=/source/media/movies`);
|
||||
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());
|
||||
const fileInfoBeforeEnhance = await client.json(
|
||||
`/api/file-info?sourceId=${sourceId}&id=${encodeURIComponent(fileId)}`
|
||||
);
|
||||
assert.equal(fileInfoBeforeEnhance.error.code, 'ENHANCEMENT_NOT_READY');
|
||||
|
||||
const saveSecretsResponse = await fetch(`${server.baseUrl}/api/sources/${sourceId}/secrets`, {
|
||||
const saveSecretsResponse = await client.fetch(`/api/sources/${sourceId}/secrets`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
@ -468,9 +513,7 @@ test('raw source keeps browsing available, gates file-info, and does not leak sa
|
||||
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()
|
||||
);
|
||||
const sourceDetail = await client.json(`/api/sources/${sourceId}`);
|
||||
assert.equal(sourceDetail.source.credentialsSaved, true);
|
||||
assert.equal(sourceDetail.source.username, undefined);
|
||||
assert.equal(sourceDetail.source.password, undefined);
|
||||
@ -497,16 +540,15 @@ test('enhancement job builds archive indexes from Basic-auth WebDAV volumes and
|
||||
});
|
||||
|
||||
try {
|
||||
const upload = await uploadFixture(server.baseUrl, fixture);
|
||||
const client = await setupAuthenticatedClient(server.baseUrl);
|
||||
const upload = await uploadFixture(server.baseUrl, fixture, client);
|
||||
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 listing = await client.json(`/api/ls?sourceId=${sourceId}&path=/source/media/movies`);
|
||||
const fileId = listing.entries.find((entry) => entry.type === 'file')?.id;
|
||||
assert.ok(fileId);
|
||||
|
||||
const enhanceResponse = await fetch(`${server.baseUrl}/api/sources/${sourceId}/enhance`, {
|
||||
const enhanceResponse = await client.fetch(`/api/sources/${sourceId}/enhance`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
@ -521,20 +563,18 @@ test('enhancement job builds archive indexes from Basic-auth WebDAV volumes and
|
||||
});
|
||||
assert.equal(enhanceResponse.status, 202);
|
||||
|
||||
const enhancement = await waitForEnhancementStatus(server.baseUrl, sourceId, ['ready', 'failed']);
|
||||
const enhancement = await waitForEnhancementStatus(server.baseUrl, sourceId, ['ready', 'failed'], client);
|
||||
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()
|
||||
);
|
||||
const sourceDetail = await client.json(`/api/sources/${sourceId}`);
|
||||
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());
|
||||
const fileInfo = await client.json(
|
||||
`/api/file-info?sourceId=${sourceId}&id=${encodeURIComponent(fileId)}`
|
||||
);
|
||||
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');
|
||||
@ -562,10 +602,11 @@ test('video thumbnails can be uploaded, listed, fetched, deleted, and cleared pe
|
||||
});
|
||||
|
||||
try {
|
||||
const upload = await uploadFixture(server.baseUrl, fixture);
|
||||
const client = await setupAuthenticatedClient(server.baseUrl);
|
||||
const upload = await uploadFixture(server.baseUrl, fixture, client);
|
||||
const sourceId = upload.payload.source.id;
|
||||
|
||||
const enhanceResponse = await fetch(`${server.baseUrl}/api/sources/${sourceId}/enhance`, {
|
||||
const enhanceResponse = await client.fetch(`/api/sources/${sourceId}/enhance`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
@ -580,19 +621,17 @@ test('video thumbnails can be uploaded, listed, fetched, deleted, and cleared pe
|
||||
});
|
||||
assert.equal(enhanceResponse.status, 202);
|
||||
|
||||
const enhancement = await waitForEnhancementStatus(server.baseUrl, sourceId, ['ready', 'failed']);
|
||||
const enhancement = await waitForEnhancementStatus(server.baseUrl, sourceId, ['ready', 'failed'], client);
|
||||
assert.equal(enhancement.status, 'ready');
|
||||
|
||||
const listingBefore = await fetch(
|
||||
`${server.baseUrl}/api/ls?sourceId=${sourceId}&path=/source/media/movies`
|
||||
).then((response) => response.json());
|
||||
const listingBefore = await client.json(`/api/ls?sourceId=${sourceId}&path=/source/media/movies`);
|
||||
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());
|
||||
const fileInfoBefore = await client.json(
|
||||
`/api/file-info?sourceId=${sourceId}&id=${encodeURIComponent(fileEntry.id)}`
|
||||
);
|
||||
assert.equal(fileInfoBefore.file.thumbnail.available, false);
|
||||
|
||||
const thumbnailForm = new FormData();
|
||||
@ -604,7 +643,7 @@ test('video thumbnails can be uploaded, listed, fetched, deleted, and cleared pe
|
||||
})
|
||||
);
|
||||
|
||||
const thumbnailUploadResponse = await fetch(`${server.baseUrl}/api/sources/${sourceId}/thumbnails`, {
|
||||
const thumbnailUploadResponse = await client.fetch(`/api/sources/${sourceId}/thumbnails`, {
|
||||
method: 'POST',
|
||||
body: thumbnailForm
|
||||
});
|
||||
@ -613,30 +652,26 @@ test('video thumbnails can be uploaded, listed, fetched, deleted, and cleared pe
|
||||
assert.equal(thumbnailUploadPayload.thumbnail.available, true);
|
||||
assert.ok(thumbnailUploadPayload.thumbnail.thumbnailId);
|
||||
|
||||
const thumbnailFetchResponse = await fetch(`${server.baseUrl}${thumbnailUploadPayload.thumbnail.thumbnailUrl}`);
|
||||
const thumbnailFetchResponse = await client.fetch(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 listingAfterUpload = await client.json(`/api/ls?sourceId=${sourceId}&path=/source/media/movies`);
|
||||
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());
|
||||
const fileInfoAfterUpload = await client.json(
|
||||
`/api/file-info?sourceId=${sourceId}&id=${encodeURIComponent(fileEntry.id)}`
|
||||
);
|
||||
assert.equal(fileInfoAfterUpload.file.thumbnail.available, true);
|
||||
|
||||
const sourceDetailAfterUpload = await fetch(`${server.baseUrl}/api/sources/${sourceId}`).then((response) =>
|
||||
response.json()
|
||||
);
|
||||
const sourceDetailAfterUpload = await client.json(`/api/sources/${sourceId}`);
|
||||
assert.equal(sourceDetailAfterUpload.source.thumbnailCache.count, 1);
|
||||
|
||||
const singleDeleteResponse = await fetch(
|
||||
`${server.baseUrl}/api/sources/${sourceId}/thumbnails/${encodeURIComponent(thumbnailUploadPayload.thumbnail.thumbnailId)}`,
|
||||
const singleDeleteResponse = await client.fetch(
|
||||
`/api/sources/${sourceId}/thumbnails/${encodeURIComponent(thumbnailUploadPayload.thumbnail.thumbnailId)}`,
|
||||
{
|
||||
method: 'DELETE'
|
||||
}
|
||||
@ -645,9 +680,7 @@ test('video thumbnails can be uploaded, listed, fetched, deleted, and cleared pe
|
||||
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 listingAfterSingleDelete = await client.json(`/api/ls?sourceId=${sourceId}&path=/source/media/movies`);
|
||||
const listedFileAfterDelete = listingAfterSingleDelete.entries.find((entry) => entry.id === fileEntry.id);
|
||||
assert.equal(listedFileAfterDelete.thumbnail.available, false);
|
||||
|
||||
@ -659,22 +692,20 @@ test('video thumbnails can be uploaded, listed, fetched, deleted, and cleared pe
|
||||
type: 'image/webp'
|
||||
})
|
||||
);
|
||||
const secondUploadResponse = await fetch(`${server.baseUrl}/api/sources/${sourceId}/thumbnails`, {
|
||||
const secondUploadResponse = await client.fetch(`/api/sources/${sourceId}/thumbnails`, {
|
||||
method: 'POST',
|
||||
body: secondThumbnailForm
|
||||
});
|
||||
assert.equal(secondUploadResponse.status, 201);
|
||||
|
||||
const clearResponse = await fetch(`${server.baseUrl}/api/sources/${sourceId}/thumbnails`, {
|
||||
const clearResponse = await client.fetch(`/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()
|
||||
);
|
||||
const sourceDetailAfterClear = await client.json(`/api/sources/${sourceId}`);
|
||||
assert.equal(sourceDetailAfterClear.source.thumbnailCache.count, 0);
|
||||
} finally {
|
||||
await remote.close();
|
||||
@ -699,16 +730,15 @@ test('failed enhancement keeps directory browsing available and surfaces ENHANCE
|
||||
});
|
||||
|
||||
try {
|
||||
const upload = await uploadFixture(server.baseUrl, fixture);
|
||||
const client = await setupAuthenticatedClient(server.baseUrl);
|
||||
const upload = await uploadFixture(server.baseUrl, fixture, client);
|
||||
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 listingBefore = await client.json(`/api/ls?sourceId=${sourceId}&path=/source/media/movies`);
|
||||
const fileId = listingBefore.entries.find((entry) => entry.type === 'file')?.id;
|
||||
assert.ok(fileId);
|
||||
|
||||
const enhanceResponse = await fetch(`${server.baseUrl}/api/sources/${sourceId}/enhance`, {
|
||||
const enhanceResponse = await client.fetch(`/api/sources/${sourceId}/enhance`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
@ -723,17 +753,13 @@ test('failed enhancement keeps directory browsing available and surfaces ENHANCE
|
||||
});
|
||||
assert.equal(enhanceResponse.status, 202);
|
||||
|
||||
const enhancement = await waitForEnhancementStatus(server.baseUrl, sourceId, ['ready', 'failed']);
|
||||
const enhancement = await waitForEnhancementStatus(server.baseUrl, sourceId, ['ready', 'failed'], client);
|
||||
assert.equal(enhancement.status, 'failed');
|
||||
|
||||
const listingAfter = await fetch(
|
||||
`${server.baseUrl}/api/ls?sourceId=${sourceId}&path=/source/media/movies`
|
||||
).then((response) => response.json());
|
||||
const listingAfter = await client.json(`/api/ls?sourceId=${sourceId}&path=/source/media/movies`);
|
||||
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());
|
||||
const fileInfo = await client.json(`/api/file-info?sourceId=${sourceId}&id=${encodeURIComponent(fileId)}`);
|
||||
assert.equal(fileInfo.error.code, 'ENHANCEMENT_FAILED');
|
||||
} finally {
|
||||
await remote.close();
|
||||
|
||||
247
test/tls.integration.test.js
Normal file
247
test/tls.integration.test.js
Normal file
@ -0,0 +1,247 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import { rm } from 'node:fs/promises';
|
||||
import https from 'node:https';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import selfsigned from 'selfsigned';
|
||||
|
||||
import { startServer } from '../src/server.js';
|
||||
|
||||
async function startTlsRuntime() {
|
||||
const tempDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'duplicati-tls-'));
|
||||
const runtime = await startServer({
|
||||
port: 0,
|
||||
httpsPort: 0,
|
||||
appDbPath: path.join(tempDirectory, 'app.sqlite'),
|
||||
uploadDir: path.join(tempDirectory, 'sources'),
|
||||
tlsDir: path.join(tempDirectory, 'tls'),
|
||||
previewMaxBytes: 1024
|
||||
});
|
||||
|
||||
const httpAddress = runtime.httpServer.address();
|
||||
const httpsAddress = runtime.httpsServer.address();
|
||||
const httpBaseUrl = `http://127.0.0.1:${httpAddress.port}`;
|
||||
const httpsBaseUrl = `https://127.0.0.1:${httpsAddress.port}`;
|
||||
|
||||
return {
|
||||
runtime,
|
||||
httpBaseUrl,
|
||||
httpsBaseUrl,
|
||||
async close() {
|
||||
await runtime.close();
|
||||
await rm(tempDirectory, { recursive: true, force: true });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function httpsRequestJson(baseUrl, pathname, { method = 'GET', headers = {}, body = null } = {}) {
|
||||
const target = new URL(pathname, baseUrl);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = https.request(
|
||||
target,
|
||||
{
|
||||
method,
|
||||
headers,
|
||||
rejectUnauthorized: false
|
||||
},
|
||||
(response) => {
|
||||
const chunks = [];
|
||||
response.on('data', (chunk) => chunks.push(chunk));
|
||||
response.on('end', () => {
|
||||
const payloadText = Buffer.concat(chunks).toString('utf8');
|
||||
let payload = {};
|
||||
try {
|
||||
payload = payloadText ? JSON.parse(payloadText) : {};
|
||||
} catch (error) {
|
||||
payload = { raw: payloadText };
|
||||
}
|
||||
|
||||
resolve({
|
||||
status: response.statusCode ?? 0,
|
||||
headers: response.headers,
|
||||
payload
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
request.on('error', reject);
|
||||
if (body) {
|
||||
request.write(body);
|
||||
}
|
||||
request.end();
|
||||
});
|
||||
}
|
||||
|
||||
async function setupAuthenticatedClient(baseUrl) {
|
||||
const response = await fetch(`${baseUrl}/api/auth/setup`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username: 'admin',
|
||||
password: 'Password123!'
|
||||
})
|
||||
});
|
||||
const payload = await response.json();
|
||||
const cookie = response.headers.get('set-cookie')?.split(';', 1)?.[0] ?? '';
|
||||
assert.equal(response.status, 201);
|
||||
assert.equal(payload.auth.authenticated, true);
|
||||
assert.ok(cookie);
|
||||
|
||||
return {
|
||||
cookie,
|
||||
async json(pathname, options = {}) {
|
||||
const responseValue = await fetch(`${baseUrl}${pathname}`, {
|
||||
...options,
|
||||
headers: {
|
||||
...(options.headers ?? {}),
|
||||
Cookie: cookie
|
||||
}
|
||||
});
|
||||
const payloadValue = await responseValue.json();
|
||||
return {
|
||||
response: responseValue,
|
||||
payload: payloadValue
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
test('dual-stack startup auto-generates a self-signed certificate and serves health checks over HTTP and HTTPS', async () => {
|
||||
const runtime = await startTlsRuntime();
|
||||
|
||||
try {
|
||||
const httpHealth = await fetch(`${runtime.httpBaseUrl}/healthz`).then((response) => response.json());
|
||||
assert.equal(httpHealth.ok, true);
|
||||
|
||||
const httpsHealth = await httpsRequestJson(runtime.httpsBaseUrl, '/healthz');
|
||||
assert.equal(httpsHealth.status, 200);
|
||||
assert.equal(httpsHealth.payload.ok, true);
|
||||
|
||||
const authState = await fetch(`${runtime.httpBaseUrl}/api/auth/state`).then((response) => response.json());
|
||||
assert.equal(authState.auth.setupRequired, true);
|
||||
|
||||
const client = await setupAuthenticatedClient(runtime.httpBaseUrl);
|
||||
const tlsSummary = await httpsRequestJson(runtime.httpsBaseUrl, '/api/system/tls', {
|
||||
headers: {
|
||||
Cookie: client.cookie
|
||||
}
|
||||
});
|
||||
assert.equal(tlsSummary.status, 200);
|
||||
assert.equal(tlsSummary.payload.tls.mode, 'self-signed');
|
||||
assert.equal(tlsSummary.payload.tls.activeSource, 'self-signed');
|
||||
assert.ok(tlsSummary.payload.tls.certificate.fingerprint256);
|
||||
} finally {
|
||||
await runtime.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('saving self-signed TLS settings regenerates certificate metadata and keeps HTTP/HTTPS both available', async () => {
|
||||
const runtime = await startTlsRuntime();
|
||||
|
||||
try {
|
||||
const client = await setupAuthenticatedClient(runtime.httpBaseUrl);
|
||||
const before = await client.json('/api/system/tls');
|
||||
const previousFingerprint = before.payload.tls.certificate.fingerprint256;
|
||||
|
||||
const updated = await client.json('/api/system/tls', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
mode: 'self-signed',
|
||||
primaryDomain: 'preview.example.com',
|
||||
subjectAltNames: ['preview.internal', '192.168.5.10']
|
||||
})
|
||||
});
|
||||
assert.equal(updated.response.status, 200);
|
||||
assert.equal(updated.payload.tls.primaryDomain, 'preview.example.com');
|
||||
assert.equal(updated.payload.tls.activeSource, 'self-signed');
|
||||
assert.notEqual(updated.payload.tls.certificate.fingerprint256, previousFingerprint);
|
||||
|
||||
const httpsState = await httpsRequestJson(runtime.httpsBaseUrl, '/api/system/tls', {
|
||||
headers: {
|
||||
Cookie: client.cookie
|
||||
}
|
||||
});
|
||||
assert.equal(httpsState.status, 200);
|
||||
assert.equal(httpsState.payload.tls.primaryDomain, 'preview.example.com');
|
||||
assert.deepEqual(httpsState.payload.tls.subjectAltNames, ['preview.internal', '192.168.5.10']);
|
||||
|
||||
const httpHealth = await fetch(`${runtime.httpBaseUrl}/healthz`).then((response) => response.json());
|
||||
assert.equal(httpHealth.ok, true);
|
||||
const httpsHealth = await httpsRequestJson(runtime.httpsBaseUrl, '/healthz');
|
||||
assert.equal(httpsHealth.status, 200);
|
||||
} finally {
|
||||
await runtime.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('custom PEM upload activates custom certificate and delete falls back to self-signed', async () => {
|
||||
const runtime = await startTlsRuntime();
|
||||
|
||||
try {
|
||||
const client = await setupAuthenticatedClient(runtime.httpBaseUrl);
|
||||
const generated = await selfsigned.generate(
|
||||
[{ name: 'commonName', value: 'custom.example.com' }],
|
||||
{
|
||||
algorithm: 'sha256',
|
||||
keySize: 2048,
|
||||
days: 365,
|
||||
extensions: [
|
||||
{
|
||||
name: 'subjectAltName',
|
||||
altNames: [
|
||||
{ type: 2, value: 'custom.example.com' },
|
||||
{ type: 2, value: 'cdn.custom.example.com' }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
);
|
||||
|
||||
const uploadForm = new FormData();
|
||||
uploadForm.set('primaryDomain', 'custom.example.com');
|
||||
uploadForm.set('subjectAltNames', 'cdn.custom.example.com');
|
||||
uploadForm.set('certificate', new File([generated.cert], 'fullchain.pem', { type: 'text/plain' }));
|
||||
uploadForm.set('privateKey', new File([generated.private], 'privkey.pem', { type: 'text/plain' }));
|
||||
|
||||
const uploadResponse = await fetch(`${runtime.httpBaseUrl}/api/system/tls/upload`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Cookie: client.cookie
|
||||
},
|
||||
body: uploadForm
|
||||
});
|
||||
const uploadPayload = await uploadResponse.json();
|
||||
assert.equal(uploadResponse.status, 200);
|
||||
assert.equal(uploadPayload.tls.mode, 'custom-pem');
|
||||
assert.equal(uploadPayload.tls.activeSource, 'custom-pem');
|
||||
const customFingerprint = uploadPayload.tls.certificate.fingerprint256;
|
||||
|
||||
const httpsState = await httpsRequestJson(runtime.httpsBaseUrl, '/api/system/tls', {
|
||||
headers: {
|
||||
Cookie: client.cookie
|
||||
}
|
||||
});
|
||||
assert.equal(httpsState.status, 200);
|
||||
assert.equal(httpsState.payload.tls.activeSource, 'custom-pem');
|
||||
|
||||
const deleteResult = await client.json('/api/system/tls/custom-certificate', {
|
||||
method: 'DELETE'
|
||||
});
|
||||
assert.equal(deleteResult.response.status, 200);
|
||||
assert.equal(deleteResult.payload.tls.mode, 'self-signed');
|
||||
assert.equal(deleteResult.payload.tls.activeSource, 'self-signed');
|
||||
assert.notEqual(deleteResult.payload.tls.certificate.fingerprint256, customFingerprint);
|
||||
} finally {
|
||||
await runtime.close();
|
||||
}
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user