compressed backups; more webui features
This commit is contained in:
@@ -14,6 +14,7 @@ RUN apt-get update \
|
|||||||
libnss-winbind \
|
libnss-winbind \
|
||||||
libpam-winbind \
|
libpam-winbind \
|
||||||
python3 \
|
python3 \
|
||||||
|
p7zip-full \
|
||||||
rclone \
|
rclone \
|
||||||
rsync \
|
rsync \
|
||||||
samba \
|
samba \
|
||||||
|
|||||||
@@ -24,16 +24,17 @@ This repository provides a production-oriented Samba file server container that
|
|||||||
- Startup resolves those SIDs to NSS group names via winbind, then uses those resolved groups in Samba `valid users` rules.
|
- Startup resolves those SIDs to NSS group names via winbind, then uses those resolved groups in Samba `valid users` rules.
|
||||||
- Samba `full_audit` is restricted to successful and failed reads, writes, renames, and deletions.
|
- Samba `full_audit` is restricted to successful and failed reads, writes, renames, and deletions.
|
||||||
- A collector normalizes those four actions and persists them in indexed SQLite tables; activity is never automatically deleted.
|
- A collector normalizes those four actions and persists them in indexed SQLite tables; activity is never automatically deleted.
|
||||||
- A read-only HTTPS web console provides group membership trees, storage usage, searchable activity, live backup progress, and system health.
|
- A plain HTTPS administration console provides read-only statistics and logs plus narrowly scoped actions for manual backups and share reconciliation. It also includes a fully client-side Typst PDF report.
|
||||||
- Web sign-in validates the submitted username/password with Kerberos, permits only users whose winbind group SID set contains `DOMAIN_ADMINS_SID`, and issues an expiring JWT in a Secure, HttpOnly, SameSite=Strict cookie. The browser does not use NTLM/SPNEGO or Kerberos negotiation.
|
- Web sign-in validates the submitted username/password with Kerberos, permits only users whose winbind group SID set contains `DOMAIN_ADMINS_SID`, and issues an expiring JWT in a Secure, HttpOnly, SameSite=Strict cookie. The browser does not use NTLM/SPNEGO or Kerberos negotiation.
|
||||||
- HTTPS certificates are requested from a configured local Smallstep CA and renewed automatically. Pre-issued certificate files are also supported.
|
- HTTPS certificates are requested from a configured local Smallstep CA and renewed automatically. Pre-issued certificate files are also supported.
|
||||||
- Optional remote backups run when `BACKUP_DESTINATION` is configured.
|
- Optional remote backups run when `BACKUP_DESTINATION` and `BACKUP_ARCHIVE_PASSWORD` are configured; each active or archived group folder is uploaded as its own encrypted, non-solid 7z archive.
|
||||||
- Private home creation skips well-known/service accounts by default (including `krbtgt`, `msol_*`, `FileShare_ServiceAcc`).
|
- Private home creation skips well-known/service accounts by default (including `krbtgt`, `msol_*`, `FileShare_ServiceAcc`).
|
||||||
- Reconciliation is executed:
|
- Reconciliation is executed:
|
||||||
- once on startup
|
- once on startup
|
||||||
- every 5 minutes via cron
|
- every 5 minutes via cron
|
||||||
- Backup is executed:
|
- Backup is executed:
|
||||||
- daily at `BACKUP_START_HOUR` in UTC (default: `2`, i.e. 02:00 UTC)
|
- manually through the Domain Admin web UI or CLI
|
||||||
|
- daily at `BACKUP_START_HOUR` in UTC when `BACKUP_AUTO_ENABLED=true` (default: `true`; the switch is environment-only)
|
||||||
|
|
||||||
## Data Folder Lifecycle
|
## Data Folder Lifecycle
|
||||||
|
|
||||||
@@ -155,6 +156,8 @@ Useful overrides:
|
|||||||
| `DEV_SEED_MB` | `8` | MiB per large seed file |
|
| `DEV_SEED_MB` | `8` | MiB per large seed file |
|
||||||
| `DEV_ACTIVITY_INTERVAL_SECONDS` | `4` | Delay between SMB activity batches |
|
| `DEV_ACTIVITY_INTERVAL_SECONDS` | `4` | Delay between SMB activity batches |
|
||||||
| `DEV_BACKUP_INTERVAL_SECONDS` | `120` | Delay between completed preview backups |
|
| `DEV_BACKUP_INTERVAL_SECONDS` | `120` | Delay between completed preview backups |
|
||||||
|
| `DEV_ARCHIVE_PASSWORD` | `PreviewArchive123!` | Dummy password for encrypted group archives |
|
||||||
|
| `DEV_BACKUP_AUTO_ENABLED` | `true` | Environment-only automatic-backup setting passed to the file server |
|
||||||
|
|
||||||
`DEV_REALM`, `DEV_DNS_DOMAIN`, and `DEV_BASE_DN` describe the same domain and must be changed together.
|
`DEV_REALM`, `DEV_DNS_DOMAIN`, and `DEV_BASE_DN` describe the same domain and must be changed together.
|
||||||
|
|
||||||
@@ -175,8 +178,10 @@ The E2E suite verifies:
|
|||||||
- Data, Private, and FSLogix usage aggregation;
|
- Data, Private, and FSLogix usage aggregation;
|
||||||
- high-level `full_audit` ingestion for all four actions, service-account exclusion, filters, facets, and pagination;
|
- high-level `full_audit` ingestion for all four actions, service-account exclusion, filters, facets, and pagination;
|
||||||
- shared SQLite schema, integrity, indexes, legacy-log removal, and ordered read deduplication;
|
- shared SQLite schema, integrity, indexes, legacy-log removal, and ordered read deduplication;
|
||||||
- real rsync transfer progress, completed backup status, log output, and remote snapshot marker;
|
- real rsync transfer progress, completed backup status, log output, remote snapshot marker, and per-group encrypted non-solid 7z archives;
|
||||||
- overview and system-health aggregation.
|
- anonymous action rejection plus authenticated manual backup and reconciliation actions, terminal progress, and live reconciliation output;
|
||||||
|
- overview and system-health aggregation;
|
||||||
|
- the log-free PDF report snapshot plus local Typst wrapper, WebAssembly, font MIME types, and immutable caching.
|
||||||
|
|
||||||
The runner returns non-zero on the first failed assertion, prints bounded logs from every run-scoped service, and always removes its containers, volumes, network, and temporary CA root. Set `DEV_SKIP_BUILD=1` for a fast rerun against existing local images.
|
The runner returns non-zero on the first failed assertion, prints bounded logs from every run-scoped service, and always removes its containers, volumes, network, and temporary CA root. Set `DEV_SKIP_BUILD=1` for a fast rerun against existing local images.
|
||||||
|
|
||||||
@@ -199,7 +204,9 @@ The runner returns non-zero on the first failed assertion, prints bounded logs f
|
|||||||
- `DOMAIN_ADMINS_SID`
|
- `DOMAIN_ADMINS_SID`
|
||||||
- optional `FSLOGIX_GROUP_SID` (defaults to `DOMAIN_USERS_SID`)
|
- optional `FSLOGIX_GROUP_SID` (defaults to `DOMAIN_USERS_SID`)
|
||||||
- optional `BACKUP_DESTINATION` (empty disables backup)
|
- optional `BACKUP_DESTINATION` (empty disables backup)
|
||||||
- optional `BACKUP_START_HOUR` (0-23, default `2`)
|
- `BACKUP_ARCHIVE_PASSWORD` when a backup destination is configured
|
||||||
|
- optional environment-only `BACKUP_AUTO_ENABLED` (`true` or `false`, default `true`)
|
||||||
|
- optional `BACKUP_START_HOUR` (0-23, default `2`; used only when automatic backups are enabled)
|
||||||
- optional `BACKUP_RETENTION_DAILY` (default `3`)
|
- optional `BACKUP_RETENTION_DAILY` (default `3`)
|
||||||
- optional `BACKUP_RETENTION_WEEKLY` (default `2`)
|
- optional `BACKUP_RETENTION_WEEKLY` (default `2`)
|
||||||
- optional `BACKUP_RETENTION_MONTHLY` (default `2`)
|
- optional `BACKUP_RETENTION_MONTHLY` (default `2`)
|
||||||
@@ -275,7 +282,7 @@ The runner returns non-zero on the first failed assertion, prints bounded logs f
|
|||||||
- Semantics intentionally differ from `Data`: only the share root is reconciled (`03770` + ACL defaults), while user-created profile container folders/files are not recursively normalized.
|
- Semantics intentionally differ from `Data`: only the share root is reconciled (`03770` + ACL defaults), while user-created profile container folders/files are not recursively normalized.
|
||||||
- Samba masks are profile-container oriented (`create mask = 0600`, `directory mask = 0700`) so profile payload stays user-private by default.
|
- Samba masks are profile-container oriented (`create mask = 0600`, `directory mask = 0700`) so profile payload stays user-private by default.
|
||||||
|
|
||||||
## Read-only Web Console
|
## Web Administration Console
|
||||||
|
|
||||||
Open `https://<WEB_HOSTNAME>/` after setup. Only members of the group identified by `DOMAIN_ADMINS_SID` can sign in. The form accepts `DOMAIN\username`, `username@realm`, or an unqualified username (which is qualified with `WORKGROUP`).
|
Open `https://<WEB_HOSTNAME>/` after setup. Only members of the group identified by `DOMAIN_ADMINS_SID` can sign in. The form accepts `DOMAIN\username`, `username@realm`, or an unqualified username (which is qualified with `WORKGROUP`).
|
||||||
|
|
||||||
@@ -286,10 +293,14 @@ The console is intentionally operational and plain:
|
|||||||
- **Data usage**: cached recursive size of every top-level `/Data` group folder.
|
- **Data usage**: cached recursive size of every top-level `/Data` group folder.
|
||||||
- **User usage**: per-user `/Private + /FSLogix` totals with component sizes.
|
- **User usage**: per-user `/Private + /FSLogix` totals with component sizes.
|
||||||
- **Activity**: dynamic date, user, share, action, result, and path filters with pagination.
|
- **Activity**: dynamic date, user, share, action, result, and path filters with pagination.
|
||||||
- **Backups**: read-only live progress, active transfer rows, snapshot name, and recent backup output.
|
- **Share reconciliation**: manually force reconciliation and follow its phase, progress bar, current group, and live output.
|
||||||
|
- **Backups**: manually start a backup and follow live progress, active transfer rows, snapshot name, trigger, and recent output.
|
||||||
|
- **PDF report**: storage totals and every storage row, complete group/folder membership hierarchies, current backup state, system checks, and TLS certificate data.
|
||||||
- **System**: domain trust, Samba configuration, TLS certificate, scanner, and activity database health.
|
- **System**: domain trust, Samba configuration, TLS certificate, scanner, and activity database health.
|
||||||
|
|
||||||
The web API has no mutation endpoint other than session login/logout. Files, groups, ACLs, backup schedules, and retention cannot be changed from the console.
|
The PDF report deliberately excludes the activity log and backup log. Its dedicated snapshot endpoint removes those fields before returning data. Typst, its WebAssembly compiler, and the report fonts are shipped with the application; Typst source and PDF bytes are created only in the authenticated browser and are never uploaded to another service. The first export downloads roughly 22 MiB of compiler/font assets, which are then cached as immutable files. The CSP grants only `'wasm-unsafe-eval'` for WebAssembly compilation and does not enable JavaScript `'unsafe-eval'`.
|
||||||
|
|
||||||
|
The only operational mutation endpoints start an immediate backup or share reconciliation, and both require the same Domain Admin JWT as every protected page. The console cannot edit files, groups, ACL rules, backup schedules, retention, or `BACKUP_AUTO_ENABLED`. Automatic backups can be enabled or disabled only through the environment and therefore require a redeployment/restart.
|
||||||
|
|
||||||
### Authentication and sessions
|
### Authentication and sessions
|
||||||
|
|
||||||
@@ -298,7 +309,7 @@ The web API has no mutation endpoint other than session login/logout. Files, gro
|
|||||||
- Authentication is form-based; the browser never performs NTLM, SPNEGO, or Kerberos negotiation.
|
- Authentication is form-based; the browser never performs NTLM, SPNEGO, or Kerberos negotiation.
|
||||||
- JWTs use HMAC-SHA256, default to eight hours, and are accepted from the protected cookie or an `Authorization: Bearer` header.
|
- JWTs use HMAC-SHA256, default to eight hours, and are accepted from the protected cookie or an `Authorization: Bearer` header.
|
||||||
- Login attempts are rate-limited per client address.
|
- Login attempts are rate-limited per client address.
|
||||||
- Responses set HSTS, a restrictive Content Security Policy, clickjacking protection, MIME sniffing protection, and no-store caching.
|
- Responses set HSTS, a restrictive Content Security Policy, clickjacking protection, and MIME sniffing protection. APIs and ordinary UI assets use no-store caching; pinned Typst compiler/font assets use immutable long-term caching.
|
||||||
|
|
||||||
### TLS with an internal ACME CA
|
### TLS with an internal ACME CA
|
||||||
|
|
||||||
@@ -393,12 +404,13 @@ Collection starts even when the web UI is disabled. On the first collector start
|
|||||||
|
|
||||||
## Backups
|
## Backups
|
||||||
|
|
||||||
- Backups are enabled only if `BACKUP_DESTINATION` is non-empty.
|
- Backups are available only if `BACKUP_DESTINATION` is non-empty and `BACKUP_ARCHIVE_PASSWORD` is set.
|
||||||
- Each run creates a timestamped snapshot under `snapshots/YYYYMMDDTHHMMSSZ` at the destination.
|
- Each run creates a timestamped snapshot under `snapshots/YYYYMMDDTHHMMSSZ` at the destination.
|
||||||
- Backup job is scheduled daily at `BACKUP_START_HOUR` in UTC. The container and Compose service force `TZ=Etc/UTC`.
|
- `BACKUP_AUTO_ENABLED=true` schedules the job daily at `BACKUP_START_HOUR` in UTC. With `false`, no backup cron entry is installed; manual web and CLI runs remain available. This setting is environment-only and cannot be changed through the web API.
|
||||||
|
- The container and Compose service force `TZ=Etc/UTC`.
|
||||||
- Sources synced to destination on each run:
|
- Sources synced to destination on each run:
|
||||||
- `/data/private` -> `data/private`
|
- `/data/private` -> `data/private`
|
||||||
- `/data/groups` -> `data/groups`
|
- `/data/groups` -> `data/groups`; direct group directories below `data` and `archive` become one `<group>.7z` each
|
||||||
- `/data/fslogix` -> `data/fslogix`
|
- `/data/fslogix` -> `data/fslogix`
|
||||||
- `/state` -> `state` (the live WAL database is replaced by a consistent SQLite online snapshot)
|
- `/state` -> `state` (the live WAL database is replaced by a consistent SQLite online snapshot)
|
||||||
- `/var/lib/samba/private` -> `samba/private`
|
- `/var/lib/samba/private` -> `samba/private`
|
||||||
@@ -408,7 +420,11 @@ Collection starts even when the web UI is disabled. On the first collector start
|
|||||||
- `BACKUP_RETENTION_WEEKLY=2`
|
- `BACKUP_RETENTION_WEEKLY=2`
|
||||||
- `BACKUP_RETENTION_DAILY=3`
|
- `BACKUP_RETENTION_DAILY=3`
|
||||||
- The backup script writes directly to `BACKUP_LOG_FILE` (default: `/var/log/backup.log`). Cron does not redirect backup output into the logfile.
|
- The backup script writes directly to `BACKUP_LOG_FILE` (default: `/var/log/backup.log`). Cron does not redirect backup output into the logfile.
|
||||||
- Before uploading, the backup script creates a temporary, integrity-checked SQLite online snapshot of the shared state database, then measures all source files so it can report total upload progress.
|
- Before uploading, the backup script creates a temporary, integrity-checked SQLite online snapshot of the shared state database.
|
||||||
|
- Each direct active and archived group folder is then compressed with LZMA2 at level 5 and multithreading into one non-solid (`-ms=off`) 7z archive. Header/data encryption is enabled (`-mhe=on`); the password comes only from `BACKUP_ARCHIVE_PASSWORD` and is supplied to 7z through stdin, never as a process argument. Non-group entries below `/data/groups` are preserved unchanged.
|
||||||
|
- `BACKUP_ARCHIVE_TEMP_DIR` selects the local staging directory (default `/tmp`). It must have enough free space for the compressed group archives. Staging data is removed after success or failure.
|
||||||
|
- Losing `BACKUP_ARCHIVE_PASSWORD` makes the group archives unrecoverable; keep it in the same secret-management system as the remote-backup credentials.
|
||||||
|
- After staging, the script measures all upload sources so it can report total transfer progress.
|
||||||
- Upload progress is logged per file every `BACKUP_PROGRESS_INTERVAL_SECONDS` seconds and again when a file reaches 100%, including percentage and transferred/remaining bytes with auto-scaled units.
|
- Upload progress is logged per file every `BACKUP_PROGRESS_INTERVAL_SECONDS` seconds and again when a file reaches 100%, including percentage and transferred/remaining bytes with auto-scaled units.
|
||||||
- `BACKUP_PROGRESS=auto` shows an interactive multi-line progress view only for TTY/manual runs. Current file uploads are shown as separate rows, capped at 12 rows, with the total progress row at the bottom. Use `always` to force it or `never` to suppress the bar. File and total progress are still logged.
|
- `BACKUP_PROGRESS=auto` shows an interactive multi-line progress view only for TTY/manual runs. Current file uploads are shown as separate rows, capped at 12 rows, with the total progress row at the bottom. Use `always` to force it or `never` to suppress the bar. File and total progress are still logged.
|
||||||
- Every run atomically updates `BACKUP_STATUS_FILE` (default `/state/backup-status.json`) with its state, current source, active files, byte totals, percentage, snapshot, and final result for the live web view.
|
- Every run atomically updates `BACKUP_STATUS_FILE` (default `/state/backup-status.json`) with its state, current source, active files, byte totals, percentage, snapshot, and final result for the live web view.
|
||||||
@@ -430,7 +446,10 @@ Collection starts even when the web UI is disabled. On the first collector start
|
|||||||
|
|
||||||
```env
|
```env
|
||||||
BACKUP_DESTINATION=sftp://backupuser:StrongPassword@sftp.example.com/exports/samba
|
BACKUP_DESTINATION=sftp://backupuser:StrongPassword@sftp.example.com/exports/samba
|
||||||
|
BACKUP_ARCHIVE_PASSWORD=use-a-long-random-secret
|
||||||
|
BACKUP_AUTO_ENABLED=true
|
||||||
BACKUP_START_HOUR=2
|
BACKUP_START_HOUR=2
|
||||||
|
BACKUP_ARCHIVE_TEMP_DIR=/tmp
|
||||||
BACKUP_RETENTION_DAILY=3
|
BACKUP_RETENTION_DAILY=3
|
||||||
BACKUP_RETENTION_WEEKLY=2
|
BACKUP_RETENTION_WEEKLY=2
|
||||||
BACKUP_RETENTION_MONTHLY=2
|
BACKUP_RETENTION_MONTHLY=2
|
||||||
@@ -446,12 +465,14 @@ Collection starts even when the web UI is disabled. On the first collector start
|
|||||||
docker compose logs -f samba
|
docker compose logs -f samba
|
||||||
docker compose logs -f samba | grep -E '\\[web\\]|\\[audit\\]'
|
docker compose logs -f samba | grep -E '\\[web\\]|\\[audit\\]'
|
||||||
docker compose exec samba python3 /app/reconcile_shares.py
|
docker compose exec samba python3 /app/reconcile_shares.py
|
||||||
|
docker compose exec samba python3 /app/backup_to_destination.py
|
||||||
docker compose exec samba sqlite3 /state/shares.db 'PRAGMA quick_check;'
|
docker compose exec samba sqlite3 /state/shares.db 'PRAGMA quick_check;'
|
||||||
docker compose exec samba sqlite3 /state/shares.db 'SELECT action, count(*) FROM audit_events GROUP BY action;'
|
docker compose exec samba sqlite3 /state/shares.db 'SELECT action, count(*) FROM audit_events GROUP BY action;'
|
||||||
docker compose exec samba testparm -s
|
docker compose exec samba testparm -s
|
||||||
docker compose exec samba sh -lc 'tail -n 200 /var/log/samba/log.*'
|
docker compose exec samba sh -lc 'tail -n 200 /var/log/samba/log.*'
|
||||||
docker compose exec samba sh -lc 'tail -n 200 /var/log/backup.log'
|
docker compose exec samba sh -lc 'tail -n 200 /var/log/backup.log'
|
||||||
docker compose exec samba python3 -m json.tool /state/backup-status.json
|
docker compose exec samba python3 -m json.tool /state/backup-status.json
|
||||||
|
docker compose exec samba python3 -m json.tool /state/reconcile-status.json
|
||||||
```
|
```
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|||||||
+160
-10
@@ -24,6 +24,9 @@ STATE_DB_PATH = os.getenv(
|
|||||||
LOCK_PATH = os.path.join(STATE_ROOT, "backup.lock")
|
LOCK_PATH = os.path.join(STATE_ROOT, "backup.lock")
|
||||||
DEFAULT_BACKUP_LOG_FILE = "/var/log/backup.log"
|
DEFAULT_BACKUP_LOG_FILE = "/var/log/backup.log"
|
||||||
DEFAULT_BACKUP_STATUS_FILE = "/state/backup-status.json"
|
DEFAULT_BACKUP_STATUS_FILE = "/state/backup-status.json"
|
||||||
|
DEFAULT_ARCHIVE_TEMP_DIR = "/tmp"
|
||||||
|
GROUPS_SOURCE_PATH = "/data/groups"
|
||||||
|
GROUP_ARCHIVE_CATEGORIES = ("data", "archive")
|
||||||
DEFAULT_PROGRESS_MODE = "auto"
|
DEFAULT_PROGRESS_MODE = "auto"
|
||||||
DEFAULT_PROGRESS_INTERVAL_SECONDS = 10
|
DEFAULT_PROGRESS_INTERVAL_SECONDS = 10
|
||||||
PROGRESS_BAR_WIDTH = 28
|
PROGRESS_BAR_WIDTH = 28
|
||||||
@@ -48,7 +51,7 @@ RSYNC_IGNORED_RECORD_PREFIXES = (
|
|||||||
|
|
||||||
BACKUP_SOURCES: List[Tuple[str, str]] = [
|
BACKUP_SOURCES: List[Tuple[str, str]] = [
|
||||||
("/data/private", "data/private"),
|
("/data/private", "data/private"),
|
||||||
("/data/groups", "data/groups"),
|
(GROUPS_SOURCE_PATH, "data/groups"),
|
||||||
("/data/fslogix", "data/fslogix"),
|
("/data/fslogix", "data/fslogix"),
|
||||||
(STATE_ROOT, "state"),
|
(STATE_ROOT, "state"),
|
||||||
("/var/lib/samba/private", "samba/private"),
|
("/var/lib/samba/private", "samba/private"),
|
||||||
@@ -197,9 +200,10 @@ class BackupStatus:
|
|||||||
flush=True,
|
flush=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
def begin(self, destination: str) -> None:
|
def begin(self, destination: str, trigger: str) -> None:
|
||||||
self.write(
|
self.write(
|
||||||
state="starting",
|
state="starting",
|
||||||
|
trigger=trigger,
|
||||||
startedAt=dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds"),
|
startedAt=dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds"),
|
||||||
finishedAt=None,
|
finishedAt=None,
|
||||||
destination=destination,
|
destination=destination,
|
||||||
@@ -226,7 +230,7 @@ class BackupStatus:
|
|||||||
)
|
)
|
||||||
self.write(
|
self.write(
|
||||||
state="running",
|
state="running",
|
||||||
currentSource=reporter.source_path,
|
currentSource=reporter.destination_path,
|
||||||
percent=round(reporter.overall_progress.percent, 2),
|
percent=round(reporter.overall_progress.percent, 2),
|
||||||
transferredBytes=reporter.overall_progress.transferred_bytes,
|
transferredBytes=reporter.overall_progress.transferred_bytes,
|
||||||
totalBytes=reporter.overall_progress.total_bytes,
|
totalBytes=reporter.overall_progress.total_bytes,
|
||||||
@@ -275,6 +279,7 @@ def run_command(
|
|||||||
env: Optional[Dict[str, str]] = None,
|
env: Optional[Dict[str, str]] = None,
|
||||||
input_text: Optional[str] = None,
|
input_text: Optional[str] = None,
|
||||||
check: bool = True,
|
check: bool = True,
|
||||||
|
cwd: Optional[str] = None,
|
||||||
) -> subprocess.CompletedProcess:
|
) -> subprocess.CompletedProcess:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
command,
|
command,
|
||||||
@@ -282,6 +287,7 @@ def run_command(
|
|||||||
text=True,
|
text=True,
|
||||||
env=env,
|
env=env,
|
||||||
input=input_text,
|
input=input_text,
|
||||||
|
cwd=cwd,
|
||||||
)
|
)
|
||||||
if check and result.returncode != 0:
|
if check and result.returncode != 0:
|
||||||
output = result.stderr.strip() or result.stdout.strip()
|
output = result.stderr.strip() or result.stdout.strip()
|
||||||
@@ -981,16 +987,141 @@ def prepare_state_snapshot(
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def archive_password() -> str:
|
||||||
|
password = os.getenv("BACKUP_ARCHIVE_PASSWORD", "")
|
||||||
|
if not password:
|
||||||
|
raise RuntimeError(
|
||||||
|
"BACKUP_ARCHIVE_PASSWORD must be set when group data is backed up"
|
||||||
|
)
|
||||||
|
if any(char in password for char in "\r\n\0"):
|
||||||
|
raise RuntimeError("BACKUP_ARCHIVE_PASSWORD contains unsupported characters")
|
||||||
|
return password
|
||||||
|
|
||||||
|
|
||||||
|
def create_group_archive(source_path: str, archive_path: str, password: str) -> None:
|
||||||
|
os.makedirs(os.path.dirname(archive_path), exist_ok=True)
|
||||||
|
source_parent = os.path.dirname(source_path)
|
||||||
|
source_name = os.path.basename(source_path)
|
||||||
|
result = run_command(
|
||||||
|
[
|
||||||
|
"7z",
|
||||||
|
"a",
|
||||||
|
"-t7z",
|
||||||
|
"-m0=lzma2",
|
||||||
|
"-mx=5",
|
||||||
|
"-mmt=on",
|
||||||
|
"-ms=off",
|
||||||
|
"-mhe=on",
|
||||||
|
"-p",
|
||||||
|
"-y",
|
||||||
|
archive_path,
|
||||||
|
"--",
|
||||||
|
source_name,
|
||||||
|
],
|
||||||
|
input_text=f"{password}\n",
|
||||||
|
check=False,
|
||||||
|
env=os.environ.copy(),
|
||||||
|
cwd=source_parent,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
output = result.stderr.strip() or result.stdout.strip()
|
||||||
|
raise RuntimeError(f"Unable to archive group {source_name}: {output}")
|
||||||
|
|
||||||
|
|
||||||
|
def copy_backup_entry(entry: os.DirEntry, destination: str) -> None:
|
||||||
|
if entry.is_symlink():
|
||||||
|
os.symlink(os.readlink(entry.path), destination)
|
||||||
|
elif entry.is_dir(follow_symlinks=False):
|
||||||
|
shutil.copytree(entry.path, destination, symlinks=True)
|
||||||
|
else:
|
||||||
|
shutil.copy2(entry.path, destination, follow_symlinks=False)
|
||||||
|
|
||||||
|
|
||||||
|
def prepare_group_archives(
|
||||||
|
source_root: str = GROUPS_SOURCE_PATH,
|
||||||
|
password: Optional[str] = None,
|
||||||
|
temporary_parent: Optional[str] = None,
|
||||||
|
) -> Tuple[str, str, int]:
|
||||||
|
"""Stage every active and archived group directory as one encrypted 7z."""
|
||||||
|
archive_secret = password if password is not None else archive_password()
|
||||||
|
temp_parent = (
|
||||||
|
temporary_parent
|
||||||
|
if temporary_parent is not None
|
||||||
|
else os.getenv("BACKUP_ARCHIVE_TEMP_DIR", DEFAULT_ARCHIVE_TEMP_DIR).strip()
|
||||||
|
)
|
||||||
|
if temp_parent:
|
||||||
|
os.makedirs(temp_parent, exist_ok=True)
|
||||||
|
temporary_root = tempfile.mkdtemp(
|
||||||
|
prefix="backup-groups-",
|
||||||
|
dir=temp_parent or None,
|
||||||
|
)
|
||||||
|
staged_root = os.path.join(temporary_root, "groups")
|
||||||
|
archive_count = 0
|
||||||
|
try:
|
||||||
|
os.makedirs(staged_root, exist_ok=True)
|
||||||
|
entries = sorted(os.scandir(source_root), key=lambda entry: entry.name.casefold())
|
||||||
|
by_name = {entry.name: entry for entry in entries}
|
||||||
|
for category in GROUP_ARCHIVE_CATEGORIES:
|
||||||
|
source_category = by_name.pop(category, None)
|
||||||
|
if source_category is None:
|
||||||
|
continue
|
||||||
|
target_category = os.path.join(staged_root, category)
|
||||||
|
if (
|
||||||
|
source_category.is_symlink()
|
||||||
|
or not source_category.is_dir(follow_symlinks=False)
|
||||||
|
):
|
||||||
|
copy_backup_entry(source_category, target_category)
|
||||||
|
continue
|
||||||
|
|
||||||
|
os.makedirs(target_category, exist_ok=True)
|
||||||
|
members = sorted(
|
||||||
|
os.scandir(source_category.path),
|
||||||
|
key=lambda entry: entry.name.casefold(),
|
||||||
|
)
|
||||||
|
for entry in members:
|
||||||
|
target = os.path.join(target_category, entry.name)
|
||||||
|
if entry.is_dir(follow_symlinks=False) and not entry.is_symlink():
|
||||||
|
relative_name = f"{category}/{entry.name}"
|
||||||
|
log(f"Creating encrypted non-solid archive for {relative_name}")
|
||||||
|
if BACKUP_STATUS is not None:
|
||||||
|
BACKUP_STATUS.write(
|
||||||
|
state="running",
|
||||||
|
currentSource=relative_name,
|
||||||
|
message=f"Archiving group {relative_name}",
|
||||||
|
activeFiles=[],
|
||||||
|
)
|
||||||
|
create_group_archive(
|
||||||
|
entry.path,
|
||||||
|
f"{target}.7z",
|
||||||
|
archive_secret,
|
||||||
|
)
|
||||||
|
archive_count += 1
|
||||||
|
else:
|
||||||
|
log(f"Preserving non-group entry {category}/{entry.name}")
|
||||||
|
copy_backup_entry(entry, target)
|
||||||
|
|
||||||
|
for entry in sorted(by_name.values(), key=lambda value: value.name.casefold()):
|
||||||
|
log(f"Preserving additional groups source entry {entry.name}")
|
||||||
|
copy_backup_entry(entry, os.path.join(staged_root, entry.name))
|
||||||
|
|
||||||
|
return temporary_root, staged_root, archive_count
|
||||||
|
except Exception:
|
||||||
|
shutil.rmtree(temporary_root, ignore_errors=True)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
def available_sources(
|
def available_sources(
|
||||||
state_snapshot: Optional[str] = None,
|
state_snapshot: Optional[str] = None,
|
||||||
|
groups_snapshot: Optional[str] = None,
|
||||||
) -> List[Tuple[str, str]]:
|
) -> List[Tuple[str, str]]:
|
||||||
sources: List[Tuple[str, str]] = []
|
sources: List[Tuple[str, str]] = []
|
||||||
for source_path, destination_path in BACKUP_SOURCES:
|
for source_path, destination_path in BACKUP_SOURCES:
|
||||||
effective_source = (
|
if destination_path == "state" and state_snapshot is not None:
|
||||||
state_snapshot
|
effective_source = state_snapshot
|
||||||
if destination_path == "state" and state_snapshot is not None
|
elif destination_path == "data/groups" and groups_snapshot is not None:
|
||||||
else source_path
|
effective_source = groups_snapshot
|
||||||
)
|
else:
|
||||||
|
effective_source = source_path
|
||||||
if os.path.isdir(effective_source):
|
if os.path.isdir(effective_source):
|
||||||
sources.append((effective_source, destination_path))
|
sources.append((effective_source, destination_path))
|
||||||
else:
|
else:
|
||||||
@@ -1539,15 +1670,32 @@ def run_backup() -> int:
|
|||||||
BACKUP_STATUS = BackupStatus(
|
BACKUP_STATUS = BackupStatus(
|
||||||
os.getenv("BACKUP_STATUS_FILE", DEFAULT_BACKUP_STATUS_FILE).strip()
|
os.getenv("BACKUP_STATUS_FILE", DEFAULT_BACKUP_STATUS_FILE).strip()
|
||||||
)
|
)
|
||||||
BACKUP_STATUS.begin(redact_destination(destination.raw_url))
|
BACKUP_STATUS.begin(
|
||||||
|
redact_destination(destination.raw_url),
|
||||||
|
os.getenv("BACKUP_TRIGGER", "manual").strip() or "manual",
|
||||||
|
)
|
||||||
backend = None
|
backend = None
|
||||||
state_snapshot_root = None
|
state_snapshot_root = None
|
||||||
|
group_archive_root = None
|
||||||
try:
|
try:
|
||||||
state_snapshot = None
|
state_snapshot = None
|
||||||
if os.path.isdir(STATE_ROOT):
|
if os.path.isdir(STATE_ROOT):
|
||||||
BACKUP_STATUS.write(message="Creating consistent state database snapshot")
|
BACKUP_STATUS.write(message="Creating consistent state database snapshot")
|
||||||
state_snapshot_root, state_snapshot = prepare_state_snapshot()
|
state_snapshot_root, state_snapshot = prepare_state_snapshot()
|
||||||
sources = available_sources(state_snapshot)
|
|
||||||
|
groups_snapshot = None
|
||||||
|
if os.path.isdir(GROUPS_SOURCE_PATH):
|
||||||
|
BACKUP_STATUS.write(
|
||||||
|
state="running",
|
||||||
|
currentSource="data/groups",
|
||||||
|
message="Creating encrypted group archives",
|
||||||
|
)
|
||||||
|
group_archive_root, groups_snapshot, archive_count = prepare_group_archives(
|
||||||
|
password=archive_password()
|
||||||
|
)
|
||||||
|
log(f"Created {archive_count} encrypted non-solid group archive(s)")
|
||||||
|
|
||||||
|
sources = available_sources(state_snapshot, groups_snapshot)
|
||||||
if not sources:
|
if not sources:
|
||||||
log("No backup sources are available, skipping backup")
|
log("No backup sources are available, skipping backup")
|
||||||
return 0
|
return 0
|
||||||
@@ -1608,6 +1756,8 @@ def run_backup() -> int:
|
|||||||
backend.close()
|
backend.close()
|
||||||
if state_snapshot_root is not None:
|
if state_snapshot_root is not None:
|
||||||
shutil.rmtree(state_snapshot_root, ignore_errors=True)
|
shutil.rmtree(state_snapshot_root, ignore_errors=True)
|
||||||
|
if group_archive_root is not None:
|
||||||
|
shutil.rmtree(group_archive_root, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
def with_lock() -> int:
|
def with_lock() -> int:
|
||||||
|
|||||||
+23
-8
@@ -217,6 +217,19 @@ write_runtime_env_file() {
|
|||||||
if [[ -n "${BACKUP_LOG_FILE:-}" ]]; then
|
if [[ -n "${BACKUP_LOG_FILE:-}" ]]; then
|
||||||
printf 'export BACKUP_LOG_FILE=%q\n' "$BACKUP_LOG_FILE"
|
printf 'export BACKUP_LOG_FILE=%q\n' "$BACKUP_LOG_FILE"
|
||||||
fi
|
fi
|
||||||
|
printf 'export BACKUP_AUTO_ENABLED=%q\n' "${BACKUP_AUTO_ENABLED:-true}"
|
||||||
|
if [[ -n "${BACKUP_ARCHIVE_PASSWORD:-}" ]]; then
|
||||||
|
printf 'export BACKUP_ARCHIVE_PASSWORD=%q\n' "$BACKUP_ARCHIVE_PASSWORD"
|
||||||
|
fi
|
||||||
|
if [[ -n "${BACKUP_ARCHIVE_TEMP_DIR:-}" ]]; then
|
||||||
|
printf 'export BACKUP_ARCHIVE_TEMP_DIR=%q\n' "$BACKUP_ARCHIVE_TEMP_DIR"
|
||||||
|
fi
|
||||||
|
if [[ -n "${RECONCILE_LOG_FILE:-}" ]]; then
|
||||||
|
printf 'export RECONCILE_LOG_FILE=%q\n' "$RECONCILE_LOG_FILE"
|
||||||
|
fi
|
||||||
|
if [[ -n "${RECONCILE_STATUS_FILE:-}" ]]; then
|
||||||
|
printf 'export RECONCILE_STATUS_FILE=%q\n' "$RECONCILE_STATUS_FILE"
|
||||||
|
fi
|
||||||
if [[ -n "${BACKUP_PROGRESS:-}" ]]; then
|
if [[ -n "${BACKUP_PROGRESS:-}" ]]; then
|
||||||
printf 'export BACKUP_PROGRESS=%q\n' "$BACKUP_PROGRESS"
|
printf 'export BACKUP_PROGRESS=%q\n' "$BACKUP_PROGRESS"
|
||||||
fi
|
fi
|
||||||
@@ -542,7 +555,7 @@ start_observability_services() {
|
|||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
fi
|
fi
|
||||||
log "Starting read-only web UI for https://${WEB_HOSTNAME}"
|
log "Starting administration web UI for https://${WEB_HOSTNAME}"
|
||||||
python3 /app/web_ui.py &
|
python3 /app/web_ui.py &
|
||||||
else
|
else
|
||||||
log 'WEB_ENABLED is false; web UI disabled'
|
log 'WEB_ENABLED is false; web UI disabled'
|
||||||
@@ -553,12 +566,12 @@ install_cron_job() {
|
|||||||
cat > /etc/cron.d/reconcile-shares <<'EOF'
|
cat > /etc/cron.d/reconcile-shares <<'EOF'
|
||||||
SHELL=/bin/bash
|
SHELL=/bin/bash
|
||||||
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||||
*/5 * * * * root source /app/runtime.env && /usr/bin/python3 /app/reconcile_shares.py >> /var/log/reconcile.log 2>&1
|
*/5 * * * * root source /app/runtime.env && RECONCILE_TRIGGER=automatic /usr/bin/python3 /app/reconcile_shares.py
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
if [[ -n "${BACKUP_DESTINATION:-}" ]]; then
|
if [[ -n "${BACKUP_DESTINATION:-}" ]] && env_is_true "${BACKUP_AUTO_ENABLED:-true}"; then
|
||||||
cat >> /etc/cron.d/reconcile-shares <<EOF
|
cat >> /etc/cron.d/reconcile-shares <<EOF
|
||||||
0 ${BACKUP_START_HOUR} * * * root source /app/runtime.env && /usr/bin/python3 /app/backup_to_destination.py
|
0 ${BACKUP_START_HOUR} * * * root source /app/runtime.env && BACKUP_TRIGGER=automatic /usr/bin/python3 /app/backup_to_destination.py
|
||||||
EOF
|
EOF
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -607,14 +620,16 @@ render_smb_conf
|
|||||||
write_runtime_env_file
|
write_runtime_env_file
|
||||||
|
|
||||||
log 'Running startup reconciliation'
|
log 'Running startup reconciliation'
|
||||||
python3 /app/reconcile_shares.py
|
RECONCILE_TRIGGER=startup python3 /app/reconcile_shares.py
|
||||||
|
|
||||||
start_observability_services
|
start_observability_services
|
||||||
|
|
||||||
if [[ -n "${BACKUP_DESTINATION:-}" ]]; then
|
if [[ -n "${BACKUP_DESTINATION:-}" ]] && env_is_true "${BACKUP_AUTO_ENABLED:-true}"; then
|
||||||
log "Backups enabled: daily at ${BACKUP_START_HOUR}:00 UTC."
|
log "Backups enabled: automatic daily run at ${BACKUP_START_HOUR}:00 UTC and manual runs available."
|
||||||
|
elif [[ -n "${BACKUP_DESTINATION:-}" ]]; then
|
||||||
|
log 'Automatic backups disabled; manual runs remain available through the web UI and CLI.'
|
||||||
else
|
else
|
||||||
log 'BACKUP_DESTINATION is unset; scheduled backup disabled'
|
log 'BACKUP_DESTINATION is unset; backups disabled'
|
||||||
fi
|
fi
|
||||||
|
|
||||||
install_cron_job
|
install_cron_job
|
||||||
|
|||||||
+160
-11
@@ -23,6 +23,8 @@ except ImportError:
|
|||||||
|
|
||||||
DB_PATH = STATE_DB_PATH
|
DB_PATH = STATE_DB_PATH
|
||||||
LOCK_PATH = "/state/reconcile.lock"
|
LOCK_PATH = "/state/reconcile.lock"
|
||||||
|
DEFAULT_RECONCILE_LOG_FILE = "/var/log/reconcile.log"
|
||||||
|
DEFAULT_RECONCILE_STATUS_FILE = "/state/reconcile-status.json"
|
||||||
GROUP_ROOT = "/data/groups/data"
|
GROUP_ROOT = "/data/groups/data"
|
||||||
GROUP_ARCHIVE_ROOT = "/data/groups/archive"
|
GROUP_ARCHIVE_ROOT = "/data/groups/archive"
|
||||||
PRIVATE_ROOT = "/data/private"
|
PRIVATE_ROOT = "/data/private"
|
||||||
@@ -100,8 +102,127 @@ def now_utc() -> str:
|
|||||||
return dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds")
|
return dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds")
|
||||||
|
|
||||||
|
|
||||||
|
RECONCILE_LOG_HANDLE = None
|
||||||
|
RECONCILE_STATUS = None
|
||||||
|
|
||||||
|
|
||||||
|
class ReconcileStatus:
|
||||||
|
def __init__(self, path: str):
|
||||||
|
self.path = path
|
||||||
|
self.value: Dict[str, object] = {
|
||||||
|
"state": "starting",
|
||||||
|
"percent": 0.0,
|
||||||
|
"phase": "starting",
|
||||||
|
}
|
||||||
|
|
||||||
|
def write(self, **changes: object) -> None:
|
||||||
|
self.value.update(changes)
|
||||||
|
self.value["updatedAt"] = now_utc()
|
||||||
|
try:
|
||||||
|
status_dir = os.path.dirname(self.path)
|
||||||
|
if status_dir:
|
||||||
|
os.makedirs(status_dir, exist_ok=True)
|
||||||
|
temp_path = f"{self.path}.tmp"
|
||||||
|
with open(temp_path, "w", encoding="utf-8") as handle:
|
||||||
|
json.dump(self.value, handle, separators=(",", ":"), sort_keys=True)
|
||||||
|
handle.flush()
|
||||||
|
os.fsync(handle.fileno())
|
||||||
|
os.replace(temp_path, self.path)
|
||||||
|
except OSError as exc:
|
||||||
|
print(
|
||||||
|
f"[reconcile] WARNING: unable to update status file {self.path}: {exc}",
|
||||||
|
file=sys.stderr,
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def begin(self, trigger: str) -> None:
|
||||||
|
self.write(
|
||||||
|
state="starting",
|
||||||
|
trigger=trigger,
|
||||||
|
phase="starting",
|
||||||
|
percent=0.0,
|
||||||
|
startedAt=now_utc(),
|
||||||
|
finishedAt=None,
|
||||||
|
currentItem=None,
|
||||||
|
message="Starting reconciliation",
|
||||||
|
)
|
||||||
|
|
||||||
|
def progress(
|
||||||
|
self,
|
||||||
|
percent: float,
|
||||||
|
phase: str,
|
||||||
|
message: str,
|
||||||
|
current_item: Optional[str] = None,
|
||||||
|
) -> None:
|
||||||
|
self.write(
|
||||||
|
state="running",
|
||||||
|
percent=max(0.0, min(99.0, float(percent))),
|
||||||
|
phase=phase,
|
||||||
|
message=message,
|
||||||
|
currentItem=current_item,
|
||||||
|
)
|
||||||
|
|
||||||
|
def complete(self, message: str) -> None:
|
||||||
|
self.write(
|
||||||
|
state="completed",
|
||||||
|
phase="completed",
|
||||||
|
percent=100.0,
|
||||||
|
finishedAt=now_utc(),
|
||||||
|
currentItem=None,
|
||||||
|
message=message,
|
||||||
|
)
|
||||||
|
|
||||||
|
def fail(self, message: str) -> None:
|
||||||
|
self.write(
|
||||||
|
state="failed",
|
||||||
|
phase="failed",
|
||||||
|
finishedAt=now_utc(),
|
||||||
|
currentItem=None,
|
||||||
|
message=message,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def configure_logging() -> None:
|
||||||
|
global RECONCILE_LOG_HANDLE
|
||||||
|
path = os.getenv("RECONCILE_LOG_FILE", DEFAULT_RECONCILE_LOG_FILE).strip()
|
||||||
|
if not path:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
directory_name = os.path.dirname(path)
|
||||||
|
if directory_name:
|
||||||
|
os.makedirs(directory_name, exist_ok=True)
|
||||||
|
RECONCILE_LOG_HANDLE = open(path, "a", encoding="utf-8")
|
||||||
|
except OSError as exc:
|
||||||
|
print(
|
||||||
|
f"[reconcile] WARNING: unable to open log file {path}: {exc}",
|
||||||
|
file=sys.stderr,
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def close_logging() -> None:
|
||||||
|
global RECONCILE_LOG_HANDLE
|
||||||
|
if RECONCILE_LOG_HANDLE is not None:
|
||||||
|
RECONCILE_LOG_HANDLE.close()
|
||||||
|
RECONCILE_LOG_HANDLE = None
|
||||||
|
|
||||||
|
|
||||||
def log(message: str) -> None:
|
def log(message: str) -> None:
|
||||||
print(f"[reconcile] {message}", flush=True)
|
line = f"[reconcile] {message}"
|
||||||
|
print(line, flush=True)
|
||||||
|
if RECONCILE_LOG_HANDLE is not None:
|
||||||
|
RECONCILE_LOG_HANDLE.write(f"{now_utc()} {line}\n")
|
||||||
|
RECONCILE_LOG_HANDLE.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def status_progress(
|
||||||
|
percent: float,
|
||||||
|
phase: str,
|
||||||
|
message: str,
|
||||||
|
current_item: Optional[str] = None,
|
||||||
|
) -> None:
|
||||||
|
if RECONCILE_STATUS is not None:
|
||||||
|
RECONCILE_STATUS.progress(percent, phase, message, current_item)
|
||||||
|
|
||||||
|
|
||||||
def ensure_required_env() -> None:
|
def ensure_required_env() -> None:
|
||||||
@@ -1444,11 +1565,19 @@ def sync_dynamic_directory_permissions(
|
|||||||
if force_recursive_repair:
|
if force_recursive_repair:
|
||||||
log(f"Data ACL recursive repair is forced by {DATA_ACL_REPAIR_ENV}")
|
log(f"Data ACL recursive repair is forced by {DATA_ACL_REPAIR_ENV}")
|
||||||
|
|
||||||
for row in rows:
|
row_count = len(rows)
|
||||||
|
for row_index, row in enumerate(rows):
|
||||||
share_started = time.monotonic()
|
share_started = time.monotonic()
|
||||||
guid = row["objectGUID"]
|
guid = row["objectGUID"]
|
||||||
sam = row["samAccountName"]
|
sam = row["samAccountName"]
|
||||||
path = row["path"]
|
path = row["path"]
|
||||||
|
progress = 34.0 + (40.0 * row_index / max(1, row_count))
|
||||||
|
status_progress(
|
||||||
|
progress,
|
||||||
|
"data-permissions",
|
||||||
|
"Syncing data folder permissions",
|
||||||
|
str(sam),
|
||||||
|
)
|
||||||
ad_group = ad_groups_by_guid.get(guid)
|
ad_group = ad_groups_by_guid.get(guid)
|
||||||
if ad_group is None:
|
if ad_group is None:
|
||||||
log(f"No AD data available for {sam}; leaving existing ACLs")
|
log(f"No AD data available for {sam}; leaving existing ACLs")
|
||||||
@@ -1562,11 +1691,16 @@ def with_lock() -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
if RECONCILE_STATUS is not None:
|
||||||
|
RECONCILE_STATUS.begin(
|
||||||
|
os.getenv("RECONCILE_TRIGGER", "manual").strip() or "manual"
|
||||||
|
)
|
||||||
ensure_required_env()
|
ensure_required_env()
|
||||||
os.makedirs(GROUP_ROOT, exist_ok=True)
|
os.makedirs(GROUP_ROOT, exist_ok=True)
|
||||||
os.makedirs(GROUP_ARCHIVE_ROOT, exist_ok=True)
|
os.makedirs(GROUP_ARCHIVE_ROOT, exist_ok=True)
|
||||||
|
|
||||||
reconcile_started = time.monotonic()
|
reconcile_started = time.monotonic()
|
||||||
|
status_progress(2, "winbind", "Refreshing winbind cache")
|
||||||
log("Refreshing winbind cache")
|
log("Refreshing winbind cache")
|
||||||
phase_started = time.monotonic()
|
phase_started = time.monotonic()
|
||||||
refresh_winbind_cache()
|
refresh_winbind_cache()
|
||||||
@@ -1577,6 +1711,7 @@ def with_lock() -> bool:
|
|||||||
|
|
||||||
conn = open_db()
|
conn = open_db()
|
||||||
try:
|
try:
|
||||||
|
status_progress(10, "directory", "Reading data folder groups from AD")
|
||||||
phase_started = time.monotonic()
|
phase_started = time.monotonic()
|
||||||
groups = fetch_fileshare_groups()
|
groups = fetch_fileshare_groups()
|
||||||
log(
|
log(
|
||||||
@@ -1584,6 +1719,7 @@ def with_lock() -> bool:
|
|||||||
f"in {format_duration(time.monotonic() - phase_started)}"
|
f"in {format_duration(time.monotonic() - phase_started)}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
status_progress(22, "database", "Reconciling data folder database")
|
||||||
log("Reconciling data folder DB")
|
log("Reconciling data folder DB")
|
||||||
phase_started = time.monotonic()
|
phase_started = time.monotonic()
|
||||||
reconcile_db(conn, groups)
|
reconcile_db(conn, groups)
|
||||||
@@ -1592,6 +1728,7 @@ def with_lock() -> bool:
|
|||||||
f"{format_duration(time.monotonic() - phase_started)}"
|
f"{format_duration(time.monotonic() - phase_started)}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
status_progress(32, "data-permissions", "Syncing data folder permissions")
|
||||||
log("Syncing data folder permissions")
|
log("Syncing data folder permissions")
|
||||||
phase_started = time.monotonic()
|
phase_started = time.monotonic()
|
||||||
sync_dynamic_directory_permissions(conn, groups)
|
sync_dynamic_directory_permissions(conn, groups)
|
||||||
@@ -1602,6 +1739,7 @@ def with_lock() -> bool:
|
|||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
status_progress(76, "fslogix", "Syncing FSLogix root")
|
||||||
log("Syncing FSLogix root")
|
log("Syncing FSLogix root")
|
||||||
phase_started = time.monotonic()
|
phase_started = time.monotonic()
|
||||||
sync_fslogix_directory()
|
sync_fslogix_directory()
|
||||||
@@ -1610,6 +1748,7 @@ def with_lock() -> bool:
|
|||||||
f"{format_duration(time.monotonic() - phase_started)}"
|
f"{format_duration(time.monotonic() - phase_started)}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
status_progress(84, "private", "Syncing private directories")
|
||||||
log("Syncing private directories")
|
log("Syncing private directories")
|
||||||
phase_started = time.monotonic()
|
phase_started = time.monotonic()
|
||||||
sync_private_directories()
|
sync_private_directories()
|
||||||
@@ -1618,6 +1757,7 @@ def with_lock() -> bool:
|
|||||||
f"{format_duration(time.monotonic() - phase_started)}"
|
f"{format_duration(time.monotonic() - phase_started)}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
status_progress(96, "samba", "Reloading Samba config")
|
||||||
log("Reloading Samba config")
|
log("Reloading Samba config")
|
||||||
phase_started = time.monotonic()
|
phase_started = time.monotonic()
|
||||||
reload_samba()
|
reload_samba()
|
||||||
@@ -1625,22 +1765,31 @@ def with_lock() -> bool:
|
|||||||
f"Reloaded Samba config in "
|
f"Reloaded Samba config in "
|
||||||
f"{format_duration(time.monotonic() - phase_started)}"
|
f"{format_duration(time.monotonic() - phase_started)}"
|
||||||
)
|
)
|
||||||
log(
|
elapsed = format_duration(time.monotonic() - reconcile_started)
|
||||||
f"Reconciliation completed in "
|
log(f"Reconciliation completed in {elapsed}")
|
||||||
f"{format_duration(time.monotonic() - reconcile_started)}"
|
if RECONCILE_STATUS is not None:
|
||||||
)
|
RECONCILE_STATUS.complete(f"Reconciliation completed in {elapsed}")
|
||||||
return True
|
return True
|
||||||
finally:
|
finally:
|
||||||
lock_file.close()
|
lock_file.close()
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
|
global RECONCILE_STATUS
|
||||||
|
configure_logging()
|
||||||
|
RECONCILE_STATUS = ReconcileStatus(
|
||||||
|
os.getenv("RECONCILE_STATUS_FILE", DEFAULT_RECONCILE_STATUS_FILE).strip()
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
ok = with_lock()
|
try:
|
||||||
return 0 if ok else 0
|
ok = with_lock()
|
||||||
except Exception as exc: # pylint: disable=broad-except
|
return 0 if ok else 0
|
||||||
log(f"ERROR: {exc}")
|
except Exception as exc: # pylint: disable=broad-except
|
||||||
return 1
|
log(f"ERROR: {exc}")
|
||||||
|
RECONCILE_STATUS.fail(str(exc))
|
||||||
|
return 1
|
||||||
|
finally:
|
||||||
|
close_logging()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
+229
-10
@@ -29,8 +29,15 @@ const nodeTypeLabel = value => ({group: "Gruppe", user: "Benutzer", computer: "C
|
|||||||
const backupStateLabel = value => ({starting: "Startet", running: "Läuft", completed: "Abgeschlossen", failed: "Fehlgeschlagen", waiting: "Wartet", disabled: "Deaktiviert"}[value] || value || "Unbekannt");
|
const backupStateLabel = value => ({starting: "Startet", running: "Läuft", completed: "Abgeschlossen", failed: "Fehlgeschlagen", waiting: "Wartet", disabled: "Deaktiviert"}[value] || value || "Unbekannt");
|
||||||
function backupMessage(data) {
|
function backupMessage(data) {
|
||||||
const message = String(data.message || "");
|
const message = String(data.message || "");
|
||||||
if (!message) return data.enabled ? `Täglich um ${String(data.scheduledHour).padStart(2, "0")}:00 UTC geplant.` : "Sicherungen sind nicht eingerichtet.";
|
if (!message) {
|
||||||
|
if (!data.enabled) return "Sicherungen sind nicht eingerichtet.";
|
||||||
|
if (!data.automaticEnabled) return "Automatische Sicherungen sind ausgeschaltet; manueller Start ist verfügbar.";
|
||||||
|
return `Täglich um ${String(data.scheduledHour).padStart(2, "0")}:00 UTC geplant.`;
|
||||||
|
}
|
||||||
if (message === "Starting backup") return "Sicherung wird gestartet.";
|
if (message === "Starting backup") return "Sicherung wird gestartet.";
|
||||||
|
if (message === "Creating consistent state database snapshot") return "Konsistenter Datenbankstand wird erstellt.";
|
||||||
|
if (message === "Creating encrypted group archives") return "Verschlüsselte Gruppenarchive werden erstellt.";
|
||||||
|
if (message.startsWith("Archiving group ")) return `Gruppenarchiv ${message.slice(16)} wird erstellt.`;
|
||||||
if (message === "Measuring backup payload") return "Sicherungsumfang wird ermittelt.";
|
if (message === "Measuring backup payload") return "Sicherungsumfang wird ermittelt.";
|
||||||
if (message.startsWith("Backup payload: ")) return `Sicherungsumfang: ${message.slice(16)}`;
|
if (message.startsWith("Backup payload: ")) return `Sicherungsumfang: ${message.slice(16)}`;
|
||||||
if (message.startsWith("Syncing ")) return `${message.slice(8)} wird synchronisiert.`;
|
if (message.startsWith("Syncing ")) return `${message.slice(8)} wird synchronisiert.`;
|
||||||
@@ -94,8 +101,10 @@ function routeFor(path) {
|
|||||||
if (path.startsWith("/storage/data")) return "storage-data";
|
if (path.startsWith("/storage/data")) return "storage-data";
|
||||||
if (path.startsWith("/storage/users")) return "storage-users";
|
if (path.startsWith("/storage/users")) return "storage-users";
|
||||||
if (path.startsWith("/shares")) return "shares";
|
if (path.startsWith("/shares")) return "shares";
|
||||||
|
if (path.startsWith("/reconciliation")) return "reconciliation";
|
||||||
if (path.startsWith("/activity")) return "activity";
|
if (path.startsWith("/activity")) return "activity";
|
||||||
if (path.startsWith("/backup")) return "backup";
|
if (path.startsWith("/backup")) return "backup";
|
||||||
|
if (path.startsWith("/report")) return "report";
|
||||||
if (path.startsWith("/system")) return "system";
|
if (path.startsWith("/system")) return "system";
|
||||||
return "overview";
|
return "overview";
|
||||||
}
|
}
|
||||||
@@ -111,10 +120,12 @@ async function navigate(path, replace = false) {
|
|||||||
try {
|
try {
|
||||||
if (route === "overview") await renderOverview();
|
if (route === "overview") await renderOverview();
|
||||||
if (route === "shares") await renderShares();
|
if (route === "shares") await renderShares();
|
||||||
|
if (route === "reconciliation") await renderReconciliation();
|
||||||
if (route === "storage-data") await renderStorage("data");
|
if (route === "storage-data") await renderStorage("data");
|
||||||
if (route === "storage-users") await renderStorage("users");
|
if (route === "storage-users") await renderStorage("users");
|
||||||
if (route === "activity") await renderActivity();
|
if (route === "activity") await renderActivity();
|
||||||
if (route === "backup") await renderBackup();
|
if (route === "backup") await renderBackup();
|
||||||
|
if (route === "report") await renderReport();
|
||||||
if (route === "system") await renderSystem();
|
if (route === "system") await renderSystem();
|
||||||
content.focus();
|
content.focus();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -270,33 +281,241 @@ async function renderActivity() {
|
|||||||
await load(false);
|
await load(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function triggerLabel(value) {
|
||||||
|
return ({automatic: "Automatisch", web: "Weboberfläche", manual: "Befehlszeile", startup: "Systemstart"}[value] || value || "—");
|
||||||
|
}
|
||||||
|
|
||||||
|
function processIsActive(data) {
|
||||||
|
return ["starting", "running"].includes(data?.state);
|
||||||
|
}
|
||||||
|
|
||||||
function backupMarkup(data) {
|
function backupMarkup(data) {
|
||||||
const stateName = data.state || (data.enabled ? "waiting" : "disabled");
|
const stateName = data.enabled ? (data.state || "waiting") : "disabled";
|
||||||
const active = data.activeFiles || [];
|
const active = data.activeFiles || [];
|
||||||
|
const automatic = data.automaticEnabled
|
||||||
|
? `Täglich um ${String(data.scheduledHour).padStart(2, "0")}:00 UTC`
|
||||||
|
: data.enabled ? "Ausgeschaltet" : "Nicht eingerichtet";
|
||||||
return `
|
return `
|
||||||
<section class="cards">
|
<section class="cards">
|
||||||
<article class="card"><span class="label">Status</span><span class="value">${esc(backupStateLabel(stateName))}</span></article>
|
<article class="card"><span class="label">Status</span><span class="value">${esc(backupStateLabel(stateName))}</span></article>
|
||||||
<article class="card"><span class="label">Fortschritt</span><span class="value">${decimal(data.percent)} %</span></article>
|
<article class="card"><span class="label">Fortschritt</span><span class="value">${decimal(data.percent)} %</span></article>
|
||||||
<article class="card"><span class="label">Übertragen</span><span class="value">${bytes(data.transferredBytes)}</span></article>
|
<article class="card"><span class="label">Übertragen</span><span class="value">${bytes(data.transferredBytes)}</span></article>
|
||||||
<article class="card"><span class="label">Gestartet</span><span class="value">${data.startedAt ? esc(utcTime(data.startedAt)) : "—"}</span></article>
|
<article class="card"><span class="label">Automatik</span><span class="value">${esc(automatic)}</span></article>
|
||||||
</section>
|
</section>
|
||||||
<section class="panel"><div class="panel-head"><h2>Aktueller Lauf</h2>${badge(backupStateLabel(stateName), stateName === "failed" ? "error" : "")}</div>
|
<section class="panel"><div class="panel-head"><h2>Aktueller Lauf</h2>${badge(backupStateLabel(stateName), stateName === "failed" ? "error" : "")}</div>
|
||||||
<p>${esc(backupMessage(data))}</p>
|
<p>${esc(backupMessage(data))}</p>
|
||||||
<progress class="progress-large" max="100" value="${Number(data.percent || 0)}"></progress>
|
<progress class="progress-large" max="100" value="${Number(data.percent || 0)}"></progress>
|
||||||
<div class="status-line"><strong>${bytes(data.transferredBytes)} / ${bytes(data.totalBytes)}</strong><span class="muted">${esc(data.currentSource || "")}</span><span class="muted">${esc(data.snapshot || "")}</span></div>
|
<div class="status-line"><strong>${bytes(data.transferredBytes)} / ${bytes(data.totalBytes)}</strong><span class="muted">${esc(data.currentSource || "")}</span><span class="muted">${esc(data.snapshot || "")}</span></div>
|
||||||
|
<dl><dt>Auslöser</dt><dd>${esc(triggerLabel(data.trigger))}</dd><dt>Gestartet</dt><dd>${esc(utcTime(data.startedAt))}</dd><dt>Beendet</dt><dd>${esc(utcTime(data.finishedAt))}</dd></dl>
|
||||||
${active.length ? `<h3>Dateien in Bearbeitung</h3><div class="table-wrap"><table><thead><tr><th>Datei</th><th>Fortschritt</th><th>Übertragen</th></tr></thead><tbody>${active.map(file => `<tr><td class="path">${esc(file.path)}</td><td><progress max="100" value="${Number(file.percent || 0)}"></progress></td><td class="numeric">${bytes(file.transferredBytes)} / ${bytes(file.totalBytes)}</td></tr>`).join("")}</tbody></table></div>` : ""}
|
${active.length ? `<h3>Dateien in Bearbeitung</h3><div class="table-wrap"><table><thead><tr><th>Datei</th><th>Fortschritt</th><th>Übertragen</th></tr></thead><tbody>${active.map(file => `<tr><td class="path">${esc(file.path)}</td><td><progress max="100" value="${Number(file.percent || 0)}"></progress></td><td class="numeric">${bytes(file.transferredBytes)} / ${bytes(file.totalBytes)}</td></tr>`).join("")}</tbody></table></div>` : ""}
|
||||||
</section>
|
</section>
|
||||||
<section class="panel"><div class="panel-head"><h2>Sicherungsprotokoll</h2><span class="muted">Letzte ${data.log?.length || 0} Zeilen</span></div><pre class="log">${esc((data.log || []).join("\n") || "Noch keine Ausgabe vorhanden.")}</pre></section>`;
|
<section class="panel"><div class="panel-head"><h2>Sicherungsprotokoll</h2><span class="muted">Letzte ${data.log?.length || 0} Zeilen</span></div><pre id="backup-log" class="log">${esc((data.log || []).join("\n") || "Noch keine Ausgabe vorhanden.")}</pre></section>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function renderBackup() {
|
async function renderBackup() {
|
||||||
content.innerHTML = pageHead("Sicherungen", "Live-Fortschritt und letzte Sicherungsausgabe. Auf dieser Seite können Sicherungen weder gestartet noch geändert werden.") + '<div id="backup-body"></div>';
|
content.innerHTML = pageHead(
|
||||||
const refresh = async () => {
|
"Sicherungen",
|
||||||
try { document.querySelector("#backup-body").innerHTML = backupMarkup(await api("/api/backup")); }
|
"Sicherungen manuell starten sowie Fortschritt und Ausgabe verfolgen.",
|
||||||
catch (error) { notice(error.message); }
|
'<button id="start-backup" type="button">Sicherung jetzt starten</button>'
|
||||||
|
) + '<div id="backup-body"></div>';
|
||||||
|
const button = document.querySelector("#start-backup");
|
||||||
|
let logLines = [];
|
||||||
|
let previousActive = false;
|
||||||
|
let polling = false;
|
||||||
|
const refresh = async includeLog => {
|
||||||
|
try {
|
||||||
|
const currentLog = document.querySelector("#backup-log");
|
||||||
|
const previousScrollTop = currentLog?.scrollTop || 0;
|
||||||
|
const data = await api(includeLog ? "/api/backup" : "/api/backup?log=0");
|
||||||
|
if (Array.isArray(data.log)) logLines = data.log;
|
||||||
|
data.log = logLines;
|
||||||
|
document.querySelector("#backup-body").innerHTML = backupMarkup(data);
|
||||||
|
button.disabled = !data.manualEnabled || processIsActive(data);
|
||||||
|
button.title = data.manualEnabled ? "" : "Es ist kein Sicherungsziel eingerichtet.";
|
||||||
|
const logView = document.querySelector("#backup-log");
|
||||||
|
if (logView) {
|
||||||
|
logView.scrollTop = includeLog ? logView.scrollHeight : previousScrollTop;
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
notice(error.message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
await refresh();
|
const poll = async () => {
|
||||||
state.timer = window.setInterval(refresh, 2000);
|
if (polling) return;
|
||||||
|
polling = true;
|
||||||
|
try {
|
||||||
|
const status = await refresh(false);
|
||||||
|
if (!status) return;
|
||||||
|
const active = processIsActive(status);
|
||||||
|
if (active || previousActive) {
|
||||||
|
const complete = await refresh(true);
|
||||||
|
previousActive = complete ? processIsActive(complete) : active;
|
||||||
|
} else {
|
||||||
|
previousActive = false;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
polling = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
button.addEventListener("click", async () => {
|
||||||
|
if (!window.confirm("Sicherung jetzt starten?")) return;
|
||||||
|
button.disabled = true;
|
||||||
|
try {
|
||||||
|
await api("/api/actions/backup", {method: "POST", body: "{}"});
|
||||||
|
notice("Sicherung wurde gestartet.");
|
||||||
|
await poll();
|
||||||
|
} catch (error) {
|
||||||
|
notice(error.message);
|
||||||
|
button.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const initial = await refresh(true);
|
||||||
|
previousActive = processIsActive(initial);
|
||||||
|
state.timer = window.setInterval(() => { void poll(); }, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
const reconciliationStateLabel = value => ({
|
||||||
|
starting: "Startet",
|
||||||
|
running: "Läuft",
|
||||||
|
completed: "Abgeschlossen",
|
||||||
|
failed: "Fehlgeschlagen",
|
||||||
|
waiting: "Wartet",
|
||||||
|
}[value] || value || "Unbekannt");
|
||||||
|
|
||||||
|
const reconciliationPhaseLabel = value => ({
|
||||||
|
starting: "Vorbereitung",
|
||||||
|
winbind: "Domänenzwischenspeicher",
|
||||||
|
directory: "Active Directory",
|
||||||
|
database: "Freigabendatenbank",
|
||||||
|
"data-permissions": "Datenberechtigungen",
|
||||||
|
fslogix: "FSLogix",
|
||||||
|
private: "Private Ordner",
|
||||||
|
samba: "Samba-Konfiguration",
|
||||||
|
completed: "Abgeschlossen",
|
||||||
|
failed: "Fehlgeschlagen",
|
||||||
|
waiting: "Wartet",
|
||||||
|
}[value] || value || "—");
|
||||||
|
|
||||||
|
function reconciliationMarkup(data) {
|
||||||
|
const stateName = data.state || "waiting";
|
||||||
|
const phase = reconciliationPhaseLabel(data.phase);
|
||||||
|
const current = data.currentItem ? ` · ${data.currentItem}` : "";
|
||||||
|
const message = stateName === "failed"
|
||||||
|
? (data.message || "Der Freigabenabgleich ist fehlgeschlagen.")
|
||||||
|
: `${phase}${current}`;
|
||||||
|
return `
|
||||||
|
<section class="cards">
|
||||||
|
<article class="card"><span class="label">Status</span><span class="value">${esc(reconciliationStateLabel(stateName))}</span></article>
|
||||||
|
<article class="card"><span class="label">Fortschritt</span><span class="value">${decimal(data.percent)} %</span></article>
|
||||||
|
<article class="card"><span class="label">Gestartet</span><span class="value">${esc(utcTime(data.startedAt))}</span></article>
|
||||||
|
<article class="card"><span class="label">Automatik</span><span class="value">Alle ${esc(data.scheduledIntervalMinutes || 5)} Minuten</span></article>
|
||||||
|
</section>
|
||||||
|
<section class="panel"><div class="panel-head"><h2>Aktueller Abgleich</h2>${badge(reconciliationStateLabel(stateName), stateName === "failed" ? "error" : "")}</div>
|
||||||
|
<p>${esc(message)}</p>
|
||||||
|
<progress class="progress-large" max="100" value="${Number(data.percent || 0)}"></progress>
|
||||||
|
<dl><dt>Phase</dt><dd>${esc(phase)}</dd><dt>Auslöser</dt><dd>${esc(triggerLabel(data.trigger))}</dd><dt>Beendet</dt><dd>${esc(utcTime(data.finishedAt))}</dd></dl>
|
||||||
|
</section>
|
||||||
|
<section class="panel"><div class="panel-head"><h2>Abgleichsprotokoll</h2><span class="muted">Letzte ${data.log?.length || 0} Zeilen</span></div><pre id="reconciliation-log" class="log">${esc((data.log || []).join("\n") || "Noch keine Ausgabe vorhanden.")}</pre></section>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderReconciliation() {
|
||||||
|
content.innerHTML = pageHead(
|
||||||
|
"Freigabenabgleich",
|
||||||
|
"Freigaben, Gruppenordner und Berechtigungen sofort mit Active Directory abgleichen.",
|
||||||
|
'<button id="start-reconciliation" type="button">Freigaben jetzt abgleichen</button>'
|
||||||
|
) + '<div id="reconciliation-body"></div>';
|
||||||
|
const button = document.querySelector("#start-reconciliation");
|
||||||
|
let logLines = [];
|
||||||
|
let previousActive = false;
|
||||||
|
let polling = false;
|
||||||
|
const refresh = async includeLog => {
|
||||||
|
try {
|
||||||
|
const currentLog = document.querySelector("#reconciliation-log");
|
||||||
|
const previousScrollTop = currentLog?.scrollTop || 0;
|
||||||
|
const data = await api(
|
||||||
|
includeLog ? "/api/reconciliation" : "/api/reconciliation?log=0"
|
||||||
|
);
|
||||||
|
if (Array.isArray(data.log)) logLines = data.log;
|
||||||
|
data.log = logLines;
|
||||||
|
document.querySelector("#reconciliation-body").innerHTML = reconciliationMarkup(data);
|
||||||
|
button.disabled = processIsActive(data);
|
||||||
|
const logView = document.querySelector("#reconciliation-log");
|
||||||
|
if (logView) {
|
||||||
|
logView.scrollTop = includeLog ? logView.scrollHeight : previousScrollTop;
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
notice(error.message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const poll = async () => {
|
||||||
|
if (polling) return;
|
||||||
|
polling = true;
|
||||||
|
try {
|
||||||
|
const status = await refresh(false);
|
||||||
|
if (!status) return;
|
||||||
|
const active = processIsActive(status);
|
||||||
|
if (active || previousActive) {
|
||||||
|
const complete = await refresh(true);
|
||||||
|
previousActive = complete ? processIsActive(complete) : active;
|
||||||
|
} else {
|
||||||
|
previousActive = false;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
polling = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
button.addEventListener("click", async () => {
|
||||||
|
if (!window.confirm("Freigaben jetzt mit Active Directory abgleichen?")) return;
|
||||||
|
button.disabled = true;
|
||||||
|
try {
|
||||||
|
await api("/api/actions/reconciliation", {method: "POST", body: "{}"});
|
||||||
|
notice("Freigabenabgleich wurde gestartet.");
|
||||||
|
await poll();
|
||||||
|
} catch (error) {
|
||||||
|
notice(error.message);
|
||||||
|
button.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const initial = await refresh(true);
|
||||||
|
previousActive = processIsActive(initial);
|
||||||
|
state.timer = window.setInterval(() => { void poll(); }, 1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderReport() {
|
||||||
|
content.innerHTML = pageHead("PDF-Bericht", "Vollständiger, druckbarer Stand der Dateiserver-Konfiguration und Belegung.") + `
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Enthaltene Informationen</h2>
|
||||||
|
<ul>
|
||||||
|
<li>Speicherbelegung von Daten-, Private- und FSLogix-Bereichen</li>
|
||||||
|
<li>Alle Dateifreigabegruppen mit vollständiger Mitgliedschaftshierarchie</li>
|
||||||
|
<li>Aktueller Sicherungsstatus und laufende Dateiübertragungen</li>
|
||||||
|
<li>Systemprüfungen und TLS-Zertifikatsdaten</li>
|
||||||
|
</ul>
|
||||||
|
<p>Aktivitätsprotokoll und Sicherungsprotokoll sind ausdrücklich nicht enthalten.</p>
|
||||||
|
<p><button id="create-report" type="button">PDF erstellen</button></p>
|
||||||
|
<p id="report-status" class="muted" role="status">Die Erstellung erfolgt vollständig in diesem Browser mit Typst.</p>
|
||||||
|
</section>`;
|
||||||
|
const button = document.querySelector("#create-report");
|
||||||
|
const status = document.querySelector("#report-status");
|
||||||
|
button.addEventListener("click", async () => {
|
||||||
|
button.disabled = true;
|
||||||
|
try {
|
||||||
|
status.textContent = "Berichtsdaten werden geladen…";
|
||||||
|
const data = await api("/api/report");
|
||||||
|
status.textContent = "Typst wird geladen…";
|
||||||
|
const {createPdfReport} = await import("/assets/report.mjs");
|
||||||
|
const result = await createPdfReport(data, message => { status.textContent = message; });
|
||||||
|
status.textContent = `PDF erstellt: ${result.filename} (${bytes(result.size)})`;
|
||||||
|
} catch (error) {
|
||||||
|
status.textContent = `PDF konnte nicht erstellt werden: ${error.message}`;
|
||||||
|
} finally {
|
||||||
|
button.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function renderSystem() {
|
async function renderSystem() {
|
||||||
|
|||||||
@@ -28,10 +28,12 @@
|
|||||||
<nav id="navigation" aria-label="Hauptnavigation">
|
<nav id="navigation" aria-label="Hauptnavigation">
|
||||||
<a href="/overview" data-route="overview">Übersicht</a>
|
<a href="/overview" data-route="overview">Übersicht</a>
|
||||||
<a href="/shares" data-route="shares">Dateifreigaben</a>
|
<a href="/shares" data-route="shares">Dateifreigaben</a>
|
||||||
|
<a href="/reconciliation" data-route="reconciliation">Freigabenabgleich</a>
|
||||||
<a href="/storage/data" data-route="storage-data">Datenbelegung</a>
|
<a href="/storage/data" data-route="storage-data">Datenbelegung</a>
|
||||||
<a href="/storage/users" data-route="storage-users">Benutzerbelegung</a>
|
<a href="/storage/users" data-route="storage-users">Benutzerbelegung</a>
|
||||||
<a href="/activity" data-route="activity">Aktivitätsprotokoll</a>
|
<a href="/activity" data-route="activity">Aktivitätsprotokoll</a>
|
||||||
<a href="/backup" data-route="backup">Sicherungen</a>
|
<a href="/backup" data-route="backup">Sicherungen</a>
|
||||||
|
<a href="/report" data-route="report">PDF-Bericht</a>
|
||||||
<a href="/system" data-route="system">System</a>
|
<a href="/system" data-route="system">System</a>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="sidebar-footer"><span id="session-user"></span><button id="logout" class="link-button">Abmelden</button></div>
|
<div class="sidebar-footer"><span id="session-user"></span><button id="logout" class="link-button">Abmelden</button></div>
|
||||||
|
|||||||
@@ -0,0 +1,348 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
const TYPE_LABELS = {group: "Gruppe", user: "Benutzer", computer: "Computer", unknown: "Unbekannt"};
|
||||||
|
const BACKUP_LABELS = {starting: "Startet", running: "Läuft", completed: "Abgeschlossen", failed: "Fehlgeschlagen", waiting: "Wartet", disabled: "Deaktiviert"};
|
||||||
|
|
||||||
|
function typstString(value) {
|
||||||
|
return '"' + String(value ?? "")
|
||||||
|
.replace(/\\/g, "\\\\")
|
||||||
|
.replace(/"/g, '\\"')
|
||||||
|
.replace(/\r/g, "\\r")
|
||||||
|
.replace(/\n/g, "\\n")
|
||||||
|
.replace(/\t/g, "\\t") + '"';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(value) {
|
||||||
|
let number = Number(value || 0);
|
||||||
|
const units = ["B", "kB", "MB", "GB", "TB", "PB"];
|
||||||
|
let unit = 0;
|
||||||
|
while (Math.abs(number) >= 1024 && unit < units.length - 1) {
|
||||||
|
number /= 1024;
|
||||||
|
unit += 1;
|
||||||
|
}
|
||||||
|
const digits = number >= 100 || unit === 0 ? 0 : 1;
|
||||||
|
return number.toLocaleString("de-DE", {
|
||||||
|
minimumFractionDigits: digits,
|
||||||
|
maximumFractionDigits: digits,
|
||||||
|
}) + " " + units[unit];
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDecimal(value, digits = 1) {
|
||||||
|
return Number(value || 0).toLocaleString("de-DE", {
|
||||||
|
minimumFractionDigits: digits,
|
||||||
|
maximumFractionDigits: digits,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatUtc(value) {
|
||||||
|
if (!value) return "—";
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return "—";
|
||||||
|
const pad = number => String(number).padStart(2, "0");
|
||||||
|
return [
|
||||||
|
pad(date.getUTCDate()),
|
||||||
|
".",
|
||||||
|
pad(date.getUTCMonth() + 1),
|
||||||
|
".",
|
||||||
|
date.getUTCFullYear(),
|
||||||
|
" ",
|
||||||
|
pad(date.getUTCHours()),
|
||||||
|
":",
|
||||||
|
pad(date.getUTCMinutes()),
|
||||||
|
":",
|
||||||
|
pad(date.getUTCSeconds()),
|
||||||
|
" UTC",
|
||||||
|
].join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileTimestamp(value) {
|
||||||
|
const date = new Date(value);
|
||||||
|
const valid = Number.isNaN(date.getTime()) ? new Date() : date;
|
||||||
|
const pad = number => String(number).padStart(2, "0");
|
||||||
|
return [
|
||||||
|
valid.getUTCFullYear(),
|
||||||
|
"-",
|
||||||
|
pad(valid.getUTCMonth() + 1),
|
||||||
|
"-",
|
||||||
|
pad(valid.getUTCDate()),
|
||||||
|
"T",
|
||||||
|
pad(valid.getUTCHours()),
|
||||||
|
pad(valid.getUTCMinutes()),
|
||||||
|
pad(valid.getUTCSeconds()),
|
||||||
|
"Z",
|
||||||
|
].join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function backupLabel(value, enabled) {
|
||||||
|
const state = value || (enabled ? "waiting" : "disabled");
|
||||||
|
return BACKUP_LABELS[state] || state || "Unbekannt";
|
||||||
|
}
|
||||||
|
|
||||||
|
function backupMessage(data) {
|
||||||
|
const message = String(data.message || "");
|
||||||
|
if (!message) {
|
||||||
|
if (!data.enabled) return "Sicherungen sind nicht eingerichtet.";
|
||||||
|
if (!data.automaticEnabled) {
|
||||||
|
return "Automatische Sicherungen sind ausgeschaltet; manueller Start ist verfügbar.";
|
||||||
|
}
|
||||||
|
return "Täglich um " + String(data.scheduledHour).padStart(2, "0") + ":00 UTC geplant.";
|
||||||
|
}
|
||||||
|
if (message === "Starting backup") return "Sicherung wird gestartet.";
|
||||||
|
if (message === "Creating consistent state database snapshot") {
|
||||||
|
return "Konsistenter Datenbankstand wird erstellt.";
|
||||||
|
}
|
||||||
|
if (message === "Creating encrypted group archives") {
|
||||||
|
return "Verschlüsselte Gruppenarchive werden erstellt.";
|
||||||
|
}
|
||||||
|
if (message.startsWith("Archiving group ")) {
|
||||||
|
return "Gruppenarchiv " + message.slice(16) + " wird erstellt.";
|
||||||
|
}
|
||||||
|
if (message === "Measuring backup payload") return "Sicherungsumfang wird ermittelt.";
|
||||||
|
if (message.startsWith("Backup payload: ")) return "Sicherungsumfang: " + message.slice(16);
|
||||||
|
if (message.startsWith("Syncing ")) return message.slice(8) + " wird synchronisiert.";
|
||||||
|
const completed = message.match(/^Backup completed; (\d+) snapshot\(s\) retained$/);
|
||||||
|
if (completed) return "Sicherung abgeschlossen; " + completed[1] + " Sicherungsstände werden aufbewahrt.";
|
||||||
|
if (data.state === "failed") return "Sicherung fehlgeschlagen.";
|
||||||
|
if (data.state === "completed") return "Sicherung abgeschlossen.";
|
||||||
|
if (data.state === "running") return "Sicherung läuft.";
|
||||||
|
if (data.state === "starting") return "Sicherung wird gestartet.";
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
function textCell(value, weight = "") {
|
||||||
|
const option = weight ? 'weight: "' + weight + '", ' : "";
|
||||||
|
return "[#text(" + option + typstString(value) + ")]";
|
||||||
|
}
|
||||||
|
|
||||||
|
function reportTable(columns, aligns, headers, rows) {
|
||||||
|
const cells = [];
|
||||||
|
for (const row of rows) {
|
||||||
|
for (const value of row) cells.push(textCell(value));
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
"#table(",
|
||||||
|
" columns: (" + columns.join(", ") + ",),",
|
||||||
|
" align: (" + aligns.join(", ") + ",),",
|
||||||
|
" inset: (x: 4pt, y: 3pt),",
|
||||||
|
" stroke: 0.35pt + luma(75%),",
|
||||||
|
" table.header(" + headers.map(value => textCell(value, "semibold")).join(", ") + "),",
|
||||||
|
cells.length ? " " + cells.join(",\n ") + "," : "",
|
||||||
|
")",
|
||||||
|
].filter(Boolean).join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function keyValueTable(rows) {
|
||||||
|
return reportTable(["34%", "66%"], ["left", "left"], ["Angabe", "Wert"], rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
function treeRows(nodes, depth = 0, result = []) {
|
||||||
|
for (const node of nodes || []) {
|
||||||
|
const type = TYPE_LABELS[node.type] || node.type || "Unbekannt";
|
||||||
|
result.push(
|
||||||
|
"#tree-row(" +
|
||||||
|
depth + ", " +
|
||||||
|
typstString(type) + ", " +
|
||||||
|
typstString(node.name || node.sam || "Unbekannt") + ", " +
|
||||||
|
typstString(node.sam || "") + ", " +
|
||||||
|
(node.cycle ? "true" : "false") +
|
||||||
|
")"
|
||||||
|
);
|
||||||
|
treeRows(node.members || [], depth + 1, result);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildReportSource(data) {
|
||||||
|
const generatedAt = data.generatedAt || new Date().toISOString();
|
||||||
|
const storage = data.storage || {};
|
||||||
|
const totals = storage.totals || {};
|
||||||
|
const groupsSnapshot = data.groups || {};
|
||||||
|
const groups = groupsSnapshot.groups || [];
|
||||||
|
const backup = data.backup || {};
|
||||||
|
const system = data.system || {};
|
||||||
|
const tls = system.tls || {};
|
||||||
|
const checks = system.checks || {};
|
||||||
|
const totalUserBytes = Number(totals.privateBytes || 0) + Number(totals.fslogixBytes || 0);
|
||||||
|
const totalBytes = Number(totals.dataBytes || 0) + totalUserBytes;
|
||||||
|
const sans = (tls.sans || [])
|
||||||
|
.map(value => Array.isArray(value) ? value[1] : value)
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(", ") || "—";
|
||||||
|
|
||||||
|
const parts = [
|
||||||
|
'#set document(title: "Dateiserver-Bericht", author: "Dateiserver")',
|
||||||
|
'#set page(paper: "a4", margin: (x: 15mm, top: 14mm, bottom: 17mm), footer: context align(center, text(size: 7pt, fill: luma(45%))[Seite #counter(page).display("1")]))',
|
||||||
|
'#set text(font: "Libertinus Serif", size: 9pt, lang: "de")',
|
||||||
|
"#set par(leading: 0.58em)",
|
||||||
|
"#set heading(numbering: none)",
|
||||||
|
'#let tree-row(depth, kind, name, sam, cycle) = block(width: 100%, above: 1.8pt, below: 1.8pt, breakable: false)[#grid(columns: (depth * 9pt + 8pt, 44pt, 1fr), column-gutter: 4pt, align: (left, left, left), [#text(size: 7.5pt, fill: luma(45%))[#if depth == 0 [•] else [>]]], [#text(size: 7.5pt, fill: luma(40%))[#kind]], [#name#if sam != "" and sam != name [#text(size: 7.5pt, fill: luma(40%))[ (#sam)]]#if cycle [#text(size: 7.5pt)[ (Zyklus)]]])]',
|
||||||
|
"",
|
||||||
|
'#text(size: 22pt, weight: "semibold")[Dateiserver-Bericht]',
|
||||||
|
"#v(5pt)",
|
||||||
|
keyValueTable([
|
||||||
|
["Server", system.hostname || "—"],
|
||||||
|
["Erstellt", formatUtc(generatedAt)],
|
||||||
|
["Verzeichnisstand", formatUtc(groupsSnapshot.fetchedAt)],
|
||||||
|
["Dateifreigabegruppen", String(groups.length)],
|
||||||
|
["Verzeichnisabfrage gekürzt", groupsSnapshot.truncated ? "Ja" : "Nein"],
|
||||||
|
["Speicherstand", formatUtc(storage.scannedAt)],
|
||||||
|
["Serverzeit", formatUtc(system.serverTime)],
|
||||||
|
]),
|
||||||
|
"",
|
||||||
|
"#v(8pt)",
|
||||||
|
"#text(size: 8pt)[Aktivitätsprotokoll und Sicherungsprotokoll sind nicht Bestandteil dieses Berichts.]",
|
||||||
|
"",
|
||||||
|
"= Speicherbelegung",
|
||||||
|
keyValueTable([
|
||||||
|
["Daten", formatBytes(totals.dataBytes)],
|
||||||
|
["Private", formatBytes(totals.privateBytes)],
|
||||||
|
["FSLogix", formatBytes(totals.fslogixBytes)],
|
||||||
|
["Private + FSLogix", formatBytes(totalUserBytes)],
|
||||||
|
["Gesamt", formatBytes(totalBytes)],
|
||||||
|
["Prüfdauer", formatDecimal(storage.scanSeconds, 3) + " s"],
|
||||||
|
]),
|
||||||
|
"",
|
||||||
|
"== Datenbelegung je Gruppenordner",
|
||||||
|
(storage.groups || []).length
|
||||||
|
? reportTable(
|
||||||
|
["1fr", "auto"],
|
||||||
|
["left", "right"],
|
||||||
|
["Gruppenordner", "Belegung"],
|
||||||
|
storage.groups.map(row => [row.name || "—", formatBytes(row.bytes)])
|
||||||
|
)
|
||||||
|
: "Noch keine Speicherprüfung vorhanden.",
|
||||||
|
"",
|
||||||
|
"== Benutzerbelegung",
|
||||||
|
(storage.users || []).length
|
||||||
|
? reportTable(
|
||||||
|
["1fr", "auto", "auto", "auto"],
|
||||||
|
["left", "right", "right", "right"],
|
||||||
|
["Benutzer", "Private", "FSLogix", "Gesamt"],
|
||||||
|
storage.users.map(row => [
|
||||||
|
row.name || "—",
|
||||||
|
formatBytes(row.privateBytes),
|
||||||
|
formatBytes(row.fslogixBytes),
|
||||||
|
formatBytes(row.totalBytes),
|
||||||
|
])
|
||||||
|
)
|
||||||
|
: "Noch keine Speicherprüfung vorhanden.",
|
||||||
|
"",
|
||||||
|
"= Dateifreigaben und Mitgliedschaften",
|
||||||
|
groupsSnapshot.truncated
|
||||||
|
? "#text(weight: \"semibold\")[Hinweis: Die Verzeichnisabfrage wurde gekürzt; die nachfolgende Hierarchie ist nicht vollständig.]"
|
||||||
|
: "Die nachfolgenden Hierarchien zeigen alle wirksamen verschachtelten Mitgliedschaften.",
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const group of groups) {
|
||||||
|
parts.push("");
|
||||||
|
parts.push("#heading(level: 2, " + typstString(group.folder || group.name || group.sam || "Unbekannt") + ")");
|
||||||
|
parts.push(keyValueTable([
|
||||||
|
["Freigabename", group.name || "—"],
|
||||||
|
["Ordner", group.folder || "—"],
|
||||||
|
["Ordnergruppe", group.sam || "—"],
|
||||||
|
["Objekt-GUID", group.guid || "—"],
|
||||||
|
["Status", group.active ? "Aktiv" : "Archiviert"],
|
||||||
|
["Wirksame Benutzer", String(group.userCount || 0)],
|
||||||
|
["Enthaltene Gruppen", String(group.groupCount || 0)],
|
||||||
|
]));
|
||||||
|
const rows = treeRows(group.members || []);
|
||||||
|
parts.push(rows.length ? rows.join("\n") : "Keine direkten Mitglieder.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const backupState = backupLabel(backup.state, backup.enabled);
|
||||||
|
parts.push("");
|
||||||
|
parts.push("= Sicherung");
|
||||||
|
parts.push(keyValueTable([
|
||||||
|
["Eingerichtet", backup.enabled ? "Ja" : "Nein"],
|
||||||
|
["Automatik", backup.automaticEnabled ? "Aktiv" : "Ausgeschaltet"],
|
||||||
|
[
|
||||||
|
"Geplanter Start",
|
||||||
|
backup.automaticEnabled
|
||||||
|
? String(backup.scheduledHour ?? 0).padStart(2, "0") + ":00 UTC"
|
||||||
|
: "—",
|
||||||
|
],
|
||||||
|
["Status", backupState],
|
||||||
|
["Fortschritt", formatDecimal(backup.percent) + " %"],
|
||||||
|
["Übertragen", formatBytes(backup.transferredBytes) + " / " + formatBytes(backup.totalBytes)],
|
||||||
|
["Gestartet", formatUtc(backup.startedAt)],
|
||||||
|
["Beendet", formatUtc(backup.finishedAt)],
|
||||||
|
["Sicherungsstand", backup.snapshot || "—"],
|
||||||
|
["Aktuelle Quelle", backup.currentSource || "—"],
|
||||||
|
["Statusmeldung", backupMessage(backup)],
|
||||||
|
]));
|
||||||
|
|
||||||
|
if ((backup.activeFiles || []).length) {
|
||||||
|
parts.push("");
|
||||||
|
parts.push("== Dateien in Bearbeitung");
|
||||||
|
parts.push(reportTable(
|
||||||
|
["1fr", "auto", "auto"],
|
||||||
|
["left", "right", "right"],
|
||||||
|
["Datei", "Fortschritt", "Übertragen"],
|
||||||
|
backup.activeFiles.map(file => [
|
||||||
|
file.path || "—",
|
||||||
|
formatDecimal(file.percent) + " %",
|
||||||
|
formatBytes(file.transferredBytes) + " / " + formatBytes(file.totalBytes),
|
||||||
|
])
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
parts.push("");
|
||||||
|
parts.push("= System");
|
||||||
|
parts.push(keyValueTable([
|
||||||
|
["Domänenvertrauen", checks.domainTrust ? "In Ordnung" : "Fehlgeschlagen"],
|
||||||
|
["Samba-Konfiguration", checks.sambaConfig ? "Gültig" : "Fehlgeschlagen"],
|
||||||
|
]));
|
||||||
|
parts.push("");
|
||||||
|
parts.push("== TLS-Zertifikat");
|
||||||
|
parts.push(keyValueTable([
|
||||||
|
["Allgemeiner Name", (tls.subject || {}).commonName || "—"],
|
||||||
|
["Aussteller", (tls.issuer || {}).commonName || "—"],
|
||||||
|
["Gültig bis", formatUtc(tls.notAfter)],
|
||||||
|
["Namen", sans],
|
||||||
|
]));
|
||||||
|
parts.push("");
|
||||||
|
parts.push("#text(size: 8pt)[Dieser Bericht wurde vollständig im Browser mit Typst erzeugt.]");
|
||||||
|
|
||||||
|
return parts.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
let compilerPromise;
|
||||||
|
|
||||||
|
async function getCompiler() {
|
||||||
|
if (!compilerPromise) {
|
||||||
|
compilerPromise = (async () => {
|
||||||
|
const module = await import("/assets/vendor/typst/0.6.0-csp1/typst.mjs");
|
||||||
|
module.$typst.setCompilerInitOptions({
|
||||||
|
getModule: () => ({module_or_path: "/assets/vendor/typst/0.6.0-csp1/compiler.wasm"}),
|
||||||
|
});
|
||||||
|
module.$typst.use(
|
||||||
|
module.TypstSnippet.disableDefaultFontAssets(),
|
||||||
|
module.TypstSnippet.preloadFontFromUrl("/assets/vendor/typst/0.6.0-csp1/LibertinusSerif-Regular.otf"),
|
||||||
|
module.TypstSnippet.preloadFontFromUrl("/assets/vendor/typst/0.6.0-csp1/LibertinusSerif-Semibold.otf")
|
||||||
|
);
|
||||||
|
return module.$typst;
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
return compilerPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createPdfReport(data, progress = () => {}) {
|
||||||
|
progress("Typst-Compiler wird im Browser geladen…");
|
||||||
|
const compiler = await getCompiler();
|
||||||
|
progress("PDF wird im Browser gesetzt…");
|
||||||
|
const pdf = await compiler.pdf({mainContent: buildReportSource(data)});
|
||||||
|
if (!(pdf instanceof Uint8Array) || pdf.length < 5) {
|
||||||
|
throw new Error("Typst hat keine gültige PDF-Datei erzeugt.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const filename = "dateiserver-bericht-" + fileTimestamp(data.generatedAt) + ".pdf";
|
||||||
|
const url = URL.createObjectURL(new Blob([pdf], {type: "application/pdf"}));
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.download = filename;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
link.remove();
|
||||||
|
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||||
|
return {filename, size: pdf.length};
|
||||||
|
}
|
||||||
+176
@@ -0,0 +1,176 @@
|
|||||||
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
|
the copyright owner that is granting the License.
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
|
on behalf of whom a Contribution has been received by Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or
|
||||||
|
Derivative Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding those notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and
|
||||||
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
+201
@@ -0,0 +1,201 @@
|
|||||||
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
|
the copyright owner that is granting the License.
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
|
on behalf of whom a Contribution has been received by Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or
|
||||||
|
Derivative Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding those notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and
|
||||||
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
APPENDIX: How to apply the Apache License to your work.
|
||||||
|
|
||||||
|
To apply the Apache License to your work, attach the following
|
||||||
|
boilerplate notice, with the fields enclosed by brackets "{}"
|
||||||
|
replaced with your own identifying information. (Don't include
|
||||||
|
the brackets!) The text should be enclosed in the appropriate
|
||||||
|
comment syntax for the file format. We also recommend that a
|
||||||
|
file or class name and description of purpose be included on the
|
||||||
|
same "printed page" as the copyright notice for easier
|
||||||
|
identification within third-party archives.
|
||||||
|
|
||||||
|
Copyright 2023-2025 Myriad-Dreamin
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
Binary file not shown.
Binary file not shown.
Vendored
+447
@@ -0,0 +1,447 @@
|
|||||||
|
Licenses for third party components bundled by this project can be found below.
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
The SIL Open Font License Version 1.1 applies to:
|
||||||
|
|
||||||
|
* Libertinus Serif fonts in files/fonts/LibertinusSerif-*.otf
|
||||||
|
Copyright © 2012-2024 The Libertinus Project Authors,
|
||||||
|
with Reserved Font Name "Linux Libertine", "Biolinum", "STIX Fonts".
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
The GUST Font License Version 1.0 applies to:
|
||||||
|
|
||||||
|
* NewComputerModern fonts in files/fonts/NewCM*.otf
|
||||||
|
|
||||||
|
% This is version 1.0, dated 22 June 2009, of the GUST Font License.
|
||||||
|
% (GUST is the Polish TeX Users Group, http://www.gust.org.pl)
|
||||||
|
%
|
||||||
|
% For the most recent version of this license see
|
||||||
|
% http://www.gust.org.pl/fonts/licenses/GUST-FONT-LICENSE.txt
|
||||||
|
% or
|
||||||
|
% http://tug.org/fonts/licenses/GUST-FONT-LICENSE.txt
|
||||||
|
%
|
||||||
|
% This work may be distributed and/or modified under the conditions
|
||||||
|
% of the LaTeX Project Public License, either version 1.3c of this
|
||||||
|
% license or (at your option) any later version.
|
||||||
|
%
|
||||||
|
% Please also observe the following clause:
|
||||||
|
% 1) it is requested, but not legally required, that derived works be
|
||||||
|
% distributed only after changing the names of the fonts comprising this
|
||||||
|
% work and given in an accompanying "manifest", and that the
|
||||||
|
% files comprising the Work, as listed in the manifest, also be given
|
||||||
|
% new names. Any exceptions to this request are also given in the
|
||||||
|
% manifest.
|
||||||
|
%
|
||||||
|
% We recommend the manifest be given in a separate file named
|
||||||
|
% MANIFEST-<fontid>.txt, where <fontid> is some unique identification
|
||||||
|
% of the font family. If a separate "readme" file accompanies the Work,
|
||||||
|
% we recommend a name of the form README-<fontid>.txt.
|
||||||
|
%
|
||||||
|
% The latest version of the LaTeX Project Public License is in
|
||||||
|
% http://www.latex-project.org/lppl.txt and version 1.3c or later
|
||||||
|
% is part of all distributions of LaTeX version 2006/05/20 or later.
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
The terms below apply to:
|
||||||
|
|
||||||
|
* DejaVu fonts in files/fonts/DejaVu*.ttf
|
||||||
|
(https://github.com/dejavu-fonts/dejavu-fonts)
|
||||||
|
|
||||||
|
Fonts are (c) Bitstream (see below). DejaVu changes are in public domain.
|
||||||
|
Glyphs imported from Arev fonts are (c) Tavmjong Bah (see below)
|
||||||
|
|
||||||
|
|
||||||
|
Bitstream Vera Fonts Copyright
|
||||||
|
------------------------------
|
||||||
|
|
||||||
|
Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera is
|
||||||
|
a trademark of Bitstream, Inc.
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of the fonts accompanying this license ("Fonts") and associated
|
||||||
|
documentation files (the "Font Software"), to reproduce and distribute the
|
||||||
|
Font Software, including without limitation the rights to use, copy, merge,
|
||||||
|
publish, distribute, and/or sell copies of the Font Software, and to permit
|
||||||
|
persons to whom the Font Software is furnished to do so, subject to the
|
||||||
|
following conditions:
|
||||||
|
|
||||||
|
The above copyright and trademark notices and this permission notice shall
|
||||||
|
be included in all copies of one or more of the Font Software typefaces.
|
||||||
|
|
||||||
|
The Font Software may be modified, altered, or added to, and in particular
|
||||||
|
the designs of glyphs or characters in the Fonts may be modified and
|
||||||
|
additional glyphs or characters may be added to the Fonts, only if the fonts
|
||||||
|
are renamed to names not containing either the words "Bitstream" or the word
|
||||||
|
"Vera".
|
||||||
|
|
||||||
|
This License becomes null and void to the extent applicable to Fonts or Font
|
||||||
|
Software that has been modified and is distributed under the "Bitstream
|
||||||
|
Vera" names.
|
||||||
|
|
||||||
|
The Font Software may be sold as part of a larger software package but no
|
||||||
|
copy of one or more of the Font Software typefaces may be sold by itself.
|
||||||
|
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
|
||||||
|
OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT,
|
||||||
|
TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME
|
||||||
|
FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING
|
||||||
|
ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES,
|
||||||
|
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF
|
||||||
|
THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE
|
||||||
|
FONT SOFTWARE.
|
||||||
|
|
||||||
|
Except as contained in this notice, the names of Gnome, the Gnome
|
||||||
|
Foundation, and Bitstream Inc., shall not be used in advertising or
|
||||||
|
otherwise to promote the sale, use or other dealings in this Font Software
|
||||||
|
without prior written authorization from the Gnome Foundation or Bitstream
|
||||||
|
Inc., respectively. For further information, contact: fonts at gnome dot
|
||||||
|
org.
|
||||||
|
|
||||||
|
Arev Fonts Copyright
|
||||||
|
------------------------------
|
||||||
|
|
||||||
|
Copyright (c) 2006 by Tavmjong Bah. All Rights Reserved.
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the fonts accompanying this license ("Fonts") and
|
||||||
|
associated documentation files (the "Font Software"), to reproduce
|
||||||
|
and distribute the modifications to the Bitstream Vera Font Software,
|
||||||
|
including without limitation the rights to use, copy, merge, publish,
|
||||||
|
distribute, and/or sell copies of the Font Software, and to permit
|
||||||
|
persons to whom the Font Software is furnished to do so, subject to
|
||||||
|
the following conditions:
|
||||||
|
|
||||||
|
The above copyright and trademark notices and this permission notice
|
||||||
|
shall be included in all copies of one or more of the Font Software
|
||||||
|
typefaces.
|
||||||
|
|
||||||
|
The Font Software may be modified, altered, or added to, and in
|
||||||
|
particular the designs of glyphs or characters in the Fonts may be
|
||||||
|
modified and additional glyphs or characters may be added to the
|
||||||
|
Fonts, only if the fonts are renamed to names not containing either
|
||||||
|
the words "Tavmjong Bah" or the word "Arev".
|
||||||
|
|
||||||
|
This License becomes null and void to the extent applicable to Fonts
|
||||||
|
or Font Software that has been modified and is distributed under the
|
||||||
|
"Tavmjong Bah Arev" names.
|
||||||
|
|
||||||
|
The Font Software may be sold as part of a larger software package but
|
||||||
|
no copy of one or more of the Font Software typefaces may be sold by
|
||||||
|
itself.
|
||||||
|
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL
|
||||||
|
TAVMJONG BAH BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
|
|
||||||
|
Except as contained in this notice, the name of Tavmjong Bah shall not
|
||||||
|
be used in advertising or otherwise to promote the sale, use or other
|
||||||
|
dealings in this Font Software without prior written authorization
|
||||||
|
from Tavmjong Bah. For further information, contact: tavmjong @ free
|
||||||
|
. fr.
|
||||||
|
|
||||||
|
TeX Gyre DJV Math
|
||||||
|
-----------------
|
||||||
|
Fonts are (c) Bitstream (see below). DejaVu changes are in public domain.
|
||||||
|
|
||||||
|
Math extensions done by B. Jackowski, P. Strzelczyk and P. Pianowski
|
||||||
|
(on behalf of TeX users groups) are in public domain.
|
||||||
|
|
||||||
|
Letters imported from Euler Fraktur from AMSfonts are (c) American
|
||||||
|
Mathematical Society (see below).
|
||||||
|
Bitstream Vera Fonts Copyright
|
||||||
|
Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera
|
||||||
|
is a trademark of Bitstream, Inc.
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of the fonts accompanying this license (“Fonts”) and associated
|
||||||
|
documentation
|
||||||
|
files (the “Font Software”), to reproduce and distribute the Font Software,
|
||||||
|
including without limitation the rights to use, copy, merge, publish,
|
||||||
|
distribute,
|
||||||
|
and/or sell copies of the Font Software, and to permit persons to whom
|
||||||
|
the Font Software is furnished to do so, subject to the following
|
||||||
|
conditions:
|
||||||
|
|
||||||
|
The above copyright and trademark notices and this permission notice
|
||||||
|
shall be
|
||||||
|
included in all copies of one or more of the Font Software typefaces.
|
||||||
|
|
||||||
|
The Font Software may be modified, altered, or added to, and in particular
|
||||||
|
the designs of glyphs or characters in the Fonts may be modified and
|
||||||
|
additional
|
||||||
|
glyphs or characters may be added to the Fonts, only if the fonts are
|
||||||
|
renamed
|
||||||
|
to names not containing either the words “Bitstream” or the word “Vera”.
|
||||||
|
|
||||||
|
This License becomes null and void to the extent applicable to Fonts or
|
||||||
|
Font Software
|
||||||
|
that has been modified and is distributed under the “Bitstream Vera”
|
||||||
|
names.
|
||||||
|
|
||||||
|
The Font Software may be sold as part of a larger software package but
|
||||||
|
no copy
|
||||||
|
of one or more of the Font Software typefaces may be sold by itself.
|
||||||
|
|
||||||
|
THE FONT SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS
|
||||||
|
OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT,
|
||||||
|
TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME
|
||||||
|
FOUNDATION
|
||||||
|
BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL,
|
||||||
|
SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN
|
||||||
|
ACTION
|
||||||
|
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR
|
||||||
|
INABILITY TO USE
|
||||||
|
THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
|
Except as contained in this notice, the names of GNOME, the GNOME
|
||||||
|
Foundation,
|
||||||
|
and Bitstream Inc., shall not be used in advertising or otherwise to promote
|
||||||
|
the sale, use or other dealings in this Font Software without prior written
|
||||||
|
authorization from the GNOME Foundation or Bitstream Inc., respectively.
|
||||||
|
For further information, contact: fonts at gnome dot org.
|
||||||
|
|
||||||
|
AMSFonts (v. 2.2) copyright
|
||||||
|
|
||||||
|
The PostScript Type 1 implementation of the AMSFonts produced by and
|
||||||
|
previously distributed by Blue Sky Research and Y&Y, Inc. are now freely
|
||||||
|
available for general use. This has been accomplished through the
|
||||||
|
cooperation
|
||||||
|
of a consortium of scientific publishers with Blue Sky Research and Y&Y.
|
||||||
|
Members of this consortium include:
|
||||||
|
|
||||||
|
Elsevier Science IBM Corporation Society for Industrial and Applied
|
||||||
|
Mathematics (SIAM) Springer-Verlag American Mathematical Society (AMS)
|
||||||
|
|
||||||
|
In order to assure the authenticity of these fonts, copyright will be
|
||||||
|
held by
|
||||||
|
the American Mathematical Society. This is not meant to restrict in any way
|
||||||
|
the legitimate use of the fonts, such as (but not limited to) electronic
|
||||||
|
distribution of documents containing these fonts, inclusion of these fonts
|
||||||
|
into other public domain or commercial font collections or computer
|
||||||
|
applications, use of the outline data to create derivative fonts and/or
|
||||||
|
faces, etc. However, the AMS does require that the AMS copyright notice be
|
||||||
|
removed from any derivative versions of the fonts which have been altered in
|
||||||
|
any way. In addition, to ensure the fidelity of TeX documents using Computer
|
||||||
|
Modern fonts, Professor Donald Knuth, creator of the Computer Modern faces,
|
||||||
|
has requested that any alterations which yield different font metrics be
|
||||||
|
given a different name.
|
||||||
|
|
||||||
|
$Id$
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
The Creative Commons Zero v1.0 Universal License applies to:
|
||||||
|
* The ICC profiles found in `files/icc/*-v4.icc` and
|
||||||
|
|
||||||
|
CC0 1.0 Universal
|
||||||
|
|
||||||
|
Statement of Purpose
|
||||||
|
|
||||||
|
The laws of most jurisdictions throughout the world automatically confer
|
||||||
|
exclusive Copyright and Related Rights (defined below) upon the creator and
|
||||||
|
subsequent owner(s) (each and all, an "owner") of an original work of
|
||||||
|
authorship and/or a database (each, a "Work").
|
||||||
|
|
||||||
|
Certain owners wish to permanently relinquish those rights to a Work for the
|
||||||
|
purpose of contributing to a commons of creative, cultural and scientific
|
||||||
|
works ("Commons") that the public can reliably and without fear of later
|
||||||
|
claims of infringement build upon, modify, incorporate in other works, reuse
|
||||||
|
and redistribute as freely as possible in any form whatsoever and for any
|
||||||
|
purposes, including without limitation commercial purposes. These owners may
|
||||||
|
contribute to the Commons to promote the ideal of a free culture and the
|
||||||
|
further production of creative, cultural and scientific works, or to gain
|
||||||
|
reputation or greater distribution for their Work in part through the use and
|
||||||
|
efforts of others.
|
||||||
|
|
||||||
|
For these and/or other purposes and motivations, and without any expectation
|
||||||
|
of additional consideration or compensation, the person associating CC0 with a
|
||||||
|
Work (the "Affirmer"), to the extent that he or she is an owner of Copyright
|
||||||
|
and Related Rights in the Work, voluntarily elects to apply CC0 to the Work
|
||||||
|
and publicly distribute the Work under its terms, with knowledge of his or her
|
||||||
|
Copyright and Related Rights in the Work and the meaning and intended legal
|
||||||
|
effect of CC0 on those rights.
|
||||||
|
|
||||||
|
1. Copyright and Related Rights. A Work made available under CC0 may be
|
||||||
|
protected by copyright and related or neighboring rights ("Copyright and
|
||||||
|
Related Rights"). Copyright and Related Rights include, but are not limited
|
||||||
|
to, the following:
|
||||||
|
|
||||||
|
i. the right to reproduce, adapt, distribute, perform, display, communicate,
|
||||||
|
and translate a Work;
|
||||||
|
|
||||||
|
ii. moral rights retained by the original author(s) and/or performer(s);
|
||||||
|
|
||||||
|
iii. publicity and privacy rights pertaining to a person's image or likeness
|
||||||
|
depicted in a Work;
|
||||||
|
|
||||||
|
iv. rights protecting against unfair competition in regards to a Work,
|
||||||
|
subject to the limitations in paragraph 4(a), below;
|
||||||
|
|
||||||
|
v. rights protecting the extraction, dissemination, use and reuse of data in
|
||||||
|
a Work;
|
||||||
|
|
||||||
|
vi. database rights (such as those arising under Directive 96/9/EC of the
|
||||||
|
European Parliament and of the Council of 11 March 1996 on the legal
|
||||||
|
protection of databases, and under any national implementation thereof,
|
||||||
|
including any amended or successor version of such directive); and
|
||||||
|
|
||||||
|
vii. other similar, equivalent or corresponding rights throughout the world
|
||||||
|
based on applicable law or treaty, and any national implementations thereof.
|
||||||
|
|
||||||
|
2. Waiver. To the greatest extent permitted by, but not in contravention of,
|
||||||
|
applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and
|
||||||
|
unconditionally waives, abandons, and surrenders all of Affirmer's Copyright
|
||||||
|
and Related Rights and associated claims and causes of action, whether now
|
||||||
|
known or unknown (including existing as well as future claims and causes of
|
||||||
|
action), in the Work (i) in all territories worldwide, (ii) for the maximum
|
||||||
|
duration provided by applicable law or treaty (including future time
|
||||||
|
extensions), (iii) in any current or future medium and for any number of
|
||||||
|
copies, and (iv) for any purpose whatsoever, including without limitation
|
||||||
|
commercial, advertising or promotional purposes (the "Waiver"). Affirmer makes
|
||||||
|
the Waiver for the benefit of each member of the public at large and to the
|
||||||
|
detriment of Affirmer's heirs and successors, fully intending that such Waiver
|
||||||
|
shall not be subject to revocation, rescission, cancellation, termination, or
|
||||||
|
any other legal or equitable action to disrupt the quiet enjoyment of the Work
|
||||||
|
by the public as contemplated by Affirmer's express Statement of Purpose.
|
||||||
|
|
||||||
|
3. Public License Fallback. Should any part of the Waiver for any reason be
|
||||||
|
judged legally invalid or ineffective under applicable law, then the Waiver
|
||||||
|
shall be preserved to the maximum extent permitted taking into account
|
||||||
|
Affirmer's express Statement of Purpose. In addition, to the extent the Waiver
|
||||||
|
is so judged Affirmer hereby grants to each affected person a royalty-free,
|
||||||
|
non transferable, non sublicensable, non exclusive, irrevocable and
|
||||||
|
unconditional license to exercise Affirmer's Copyright and Related Rights in
|
||||||
|
the Work (i) in all territories worldwide, (ii) for the maximum duration
|
||||||
|
provided by applicable law or treaty (including future time extensions), (iii)
|
||||||
|
in any current or future medium and for any number of copies, and (iv) for any
|
||||||
|
purpose whatsoever, including without limitation commercial, advertising or
|
||||||
|
promotional purposes (the "License"). The License shall be deemed effective as
|
||||||
|
of the date CC0 was applied by Affirmer to the Work. Should any part of the
|
||||||
|
License for any reason be judged legally invalid or ineffective under
|
||||||
|
applicable law, such partial invalidity or ineffectiveness shall not
|
||||||
|
invalidate the remainder of the License, and in such case Affirmer hereby
|
||||||
|
affirms that he or she will not (i) exercise any of his or her remaining
|
||||||
|
Copyright and Related Rights in the Work or (ii) assert any associated claims
|
||||||
|
and causes of action with respect to the Work, in either case contrary to
|
||||||
|
Affirmer's express Statement of Purpose.
|
||||||
|
|
||||||
|
4. Limitations and Disclaimers.
|
||||||
|
|
||||||
|
a. No trademark or patent rights held by Affirmer are waived, abandoned,
|
||||||
|
surrendered, licensed or otherwise affected by this document.
|
||||||
|
|
||||||
|
b. Affirmer offers the Work as-is and makes no representations or warranties
|
||||||
|
of any kind concerning the Work, express, implied, statutory or otherwise,
|
||||||
|
including without limitation warranties of title, merchantability, fitness
|
||||||
|
for a particular purpose, non infringement, or the absence of latent or
|
||||||
|
other defects, accuracy, or the present or absence of errors, whether or not
|
||||||
|
discoverable, all to the greatest extent permissible under applicable law.
|
||||||
|
|
||||||
|
c. Affirmer disclaims responsibility for clearing rights of other persons
|
||||||
|
that may apply to the Work or any use thereof, including without limitation
|
||||||
|
any person's Copyright and Related Rights in the Work. Further, Affirmer
|
||||||
|
disclaims responsibility for obtaining any necessary consents, permissions
|
||||||
|
or other rights required for any use of the Work.
|
||||||
|
|
||||||
|
d. Affirmer understands and acknowledges that Creative Commons is not a
|
||||||
|
party to this document and has no duty or obligation with respect to this
|
||||||
|
CC0 or use of the Work.
|
||||||
|
|
||||||
|
For more information, please see
|
||||||
|
http://creativecommons.org/publicdomain/zero/1.0/
|
||||||
Vendored
+29
@@ -0,0 +1,29 @@
|
|||||||
|
# Vendored Typst client
|
||||||
|
|
||||||
|
The PDF report is compiled entirely in the browser. These files are kept local so report data, Typst source, fonts, and PDF output never need a third-party service or CDN.
|
||||||
|
|
||||||
|
Pinned upstream components:
|
||||||
|
|
||||||
|
- `@myriaddreamin/typst.ts@0.6.0`, `dist/esm/contrib/all-in-one-lite.bundle.js`
|
||||||
|
- npm integrity: `sha512-IUpetG0NyF2H6eXRm4j+NsbanJHIvyrffHEijqYb6q128sLWQgT1FYJS+h7dtjXmrBEfnIl1mI80DyfDR6kB/w==`
|
||||||
|
- upstream file SHA-256: `e884db7b1dbb3d13b85a728509e4f0b65ca36b2ee36f950406a9f77d54461cd7`
|
||||||
|
- vendored file SHA-256: `24d3bec0e8bcf34666425c08859a1b0f15af45d5f5ad27f67132256d6ad0d958`
|
||||||
|
- `@myriaddreamin/typst-ts-web-compiler@0.6.0`, `pkg/typst_ts_web_compiler_bg.wasm`
|
||||||
|
- npm integrity: `sha512-P/eIJ5RnfElj0NYzn5PI296t/IwWtgqUyyTMi5Jm5X3V5kZfskkH+LI7mSQe8tEyxwgCvxbxvFe5adinA3K8Gg==`
|
||||||
|
- local SHA-256: `52995fcbcda9287b97b27996fe9b91057e4ca87fadb41b36d100bc45d33d9454`
|
||||||
|
- Libertinus Serif Regular and Semibold from `typst/typst-assets@v0.13.1`
|
||||||
|
- Regular SHA-256: `fcf06307a77367394fcb0ccb241e59eea70dba3d732be309647611224679c733`
|
||||||
|
- Semibold SHA-256: `a4b3f28e85881db34695c1f005e4c79233a6caf3a2bd286c9b418c025fb99308`
|
||||||
|
|
||||||
|
The immutable browser URL is revisioned as `0.6.0-csp1`; change that URL revision whenever any vendored client asset changes.
|
||||||
|
|
||||||
|
Local modification: `typst.mjs` replaces the generated `new Function` import helpers and the five fixed compiler-construction functions with native imports and explicit closures. Unknown function bodies fail closed. This lets the application keep JavaScript `'unsafe-eval'` disabled while granting only CSP `'wasm-unsafe-eval'` for the compiler itself.
|
||||||
|
|
||||||
|
Upstream sources:
|
||||||
|
|
||||||
|
- <https://github.com/Myriad-Dreamin/typst.ts>
|
||||||
|
- <https://www.npmjs.com/package/@myriaddreamin/typst.ts/v/0.6.0>
|
||||||
|
- <https://www.npmjs.com/package/@myriaddreamin/typst-ts-web-compiler/v/0.6.0>
|
||||||
|
- <https://github.com/typst/typst-assets/tree/v0.13.1>
|
||||||
|
|
||||||
|
The Typst wrapper and compiler are Apache-2.0; see `LICENSE-typst-ts`. The asset repository license is in `LICENSE-typst-assets`. The Libertinus fonts are SIL Open Font License 1.1; the applicable copyright and full license text are retained in `NOTICE-fonts`.
|
||||||
Vendored
BIN
Binary file not shown.
Vendored
+6189
File diff suppressed because it is too large
Load Diff
+197
-13
@@ -1,8 +1,9 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Read-only HTTPS administration UI for the AD-integrated file server."""
|
"""HTTPS administration UI for the AD-integrated file server."""
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
import datetime as dt
|
import datetime as dt
|
||||||
|
import fcntl
|
||||||
import hashlib
|
import hashlib
|
||||||
import hmac
|
import hmac
|
||||||
import http.cookies
|
import http.cookies
|
||||||
@@ -59,6 +60,12 @@ STATIC_ROOT = os.getenv("WEB_STATIC_DIR", "/app/web")
|
|||||||
STATE_DB = STATE_DB_PATH
|
STATE_DB = STATE_DB_PATH
|
||||||
BACKUP_STATUS_FILE = os.getenv("BACKUP_STATUS_FILE", "/state/backup-status.json")
|
BACKUP_STATUS_FILE = os.getenv("BACKUP_STATUS_FILE", "/state/backup-status.json")
|
||||||
BACKUP_LOG_FILE = os.getenv("BACKUP_LOG_FILE", "/var/log/backup.log")
|
BACKUP_LOG_FILE = os.getenv("BACKUP_LOG_FILE", "/var/log/backup.log")
|
||||||
|
BACKUP_LOCK_FILE = "/state/backup.lock"
|
||||||
|
RECONCILE_STATUS_FILE = os.getenv(
|
||||||
|
"RECONCILE_STATUS_FILE", "/state/reconcile-status.json"
|
||||||
|
)
|
||||||
|
RECONCILE_LOG_FILE = os.getenv("RECONCILE_LOG_FILE", "/var/log/reconcile.log")
|
||||||
|
RECONCILE_LOCK_FILE = "/state/reconcile.lock"
|
||||||
TLS_CERT_FILE = os.getenv("WEB_TLS_CERT_FILE", "/state/tls/web.crt")
|
TLS_CERT_FILE = os.getenv("WEB_TLS_CERT_FILE", "/state/tls/web.crt")
|
||||||
TLS_KEY_FILE = os.getenv("WEB_TLS_KEY_FILE", "/state/tls/web.key")
|
TLS_KEY_FILE = os.getenv("WEB_TLS_KEY_FILE", "/state/tls/web.key")
|
||||||
JWT_COOKIE = "adfs_session"
|
JWT_COOKIE = "adfs_session"
|
||||||
@@ -68,6 +75,7 @@ DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
|||||||
SID_RE = re.compile(r"S-\d+(?:-\d+)+", re.IGNORECASE)
|
SID_RE = re.compile(r"S-\d+(?:-\d+)+", re.IGNORECASE)
|
||||||
LOGIN_LIMIT: Dict[str, deque] = {}
|
LOGIN_LIMIT: Dict[str, deque] = {}
|
||||||
LOGIN_LIMIT_LOCK = threading.Lock()
|
LOGIN_LIMIT_LOCK = threading.Lock()
|
||||||
|
ACTION_LAUNCH_LOCK = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
def log(message: str) -> None:
|
def log(message: str) -> None:
|
||||||
@@ -85,6 +93,81 @@ def env_int(name: str, default: int, minimum: int, maximum: int) -> int:
|
|||||||
return default
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def env_bool(name: str, default: bool) -> bool:
|
||||||
|
raw = os.getenv(name)
|
||||||
|
if raw is None or not raw.strip():
|
||||||
|
return default
|
||||||
|
return raw.strip().casefold() in {"1", "true", "yes", "on"}
|
||||||
|
|
||||||
|
|
||||||
|
def query_includes_log(params: Dict[str, List[str]]) -> bool:
|
||||||
|
raw = params.get("log", ["1"])[0].strip().casefold()
|
||||||
|
return raw not in {"0", "false", "no", "off"}
|
||||||
|
|
||||||
|
|
||||||
|
def lock_is_held(path: str) -> bool:
|
||||||
|
try:
|
||||||
|
with open(path, "r+", encoding="utf-8") as handle:
|
||||||
|
try:
|
||||||
|
fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||||
|
except BlockingIOError:
|
||||||
|
return True
|
||||||
|
fcntl.flock(handle, fcntl.LOCK_UN)
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class ActionConflict(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def launch_background(command: List[str], extra_env: Dict[str, str]) -> None:
|
||||||
|
environment = os.environ.copy()
|
||||||
|
environment.update(extra_env)
|
||||||
|
try:
|
||||||
|
process = subprocess.Popen(
|
||||||
|
command,
|
||||||
|
stdin=subprocess.DEVNULL,
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
env=environment,
|
||||||
|
close_fds=True,
|
||||||
|
start_new_session=True,
|
||||||
|
)
|
||||||
|
except OSError as exc:
|
||||||
|
raise RuntimeError(f"Aktion konnte nicht gestartet werden: {exc}") from exc
|
||||||
|
threading.Thread(target=process.wait, name="action-reaper", daemon=True).start()
|
||||||
|
|
||||||
|
|
||||||
|
def start_backup_action(username: str) -> Dict[str, object]:
|
||||||
|
if not os.getenv("BACKUP_DESTINATION", "").strip():
|
||||||
|
raise ActionConflict("Es ist kein Sicherungsziel eingerichtet")
|
||||||
|
if not os.getenv("BACKUP_ARCHIVE_PASSWORD", ""):
|
||||||
|
raise ActionConflict("BACKUP_ARCHIVE_PASSWORD ist nicht gesetzt")
|
||||||
|
with ACTION_LAUNCH_LOCK:
|
||||||
|
if lock_is_held(BACKUP_LOCK_FILE):
|
||||||
|
raise ActionConflict("Eine Sicherung läuft bereits")
|
||||||
|
launch_background(
|
||||||
|
[sys.executable, "/app/backup_to_destination.py"],
|
||||||
|
{"BACKUP_TRIGGER": "web"},
|
||||||
|
)
|
||||||
|
log(f"{username} started a manual backup")
|
||||||
|
return {"accepted": True, "action": "backup"}
|
||||||
|
|
||||||
|
|
||||||
|
def start_reconciliation_action(username: str) -> Dict[str, object]:
|
||||||
|
with ACTION_LAUNCH_LOCK:
|
||||||
|
if lock_is_held(RECONCILE_LOCK_FILE):
|
||||||
|
raise ActionConflict("Ein Freigabenabgleich läuft bereits")
|
||||||
|
launch_background(
|
||||||
|
[sys.executable, "/app/reconcile_shares.py"],
|
||||||
|
{"RECONCILE_TRIGGER": "web"},
|
||||||
|
)
|
||||||
|
log(f"{username} started a manual share reconciliation")
|
||||||
|
return {"accepted": True, "action": "reconciliation"}
|
||||||
|
|
||||||
|
|
||||||
def read_json(path: str, default):
|
def read_json(path: str, default):
|
||||||
try:
|
try:
|
||||||
with open(path, encoding="utf-8") as handle:
|
with open(path, encoding="utf-8") as handle:
|
||||||
@@ -662,11 +745,39 @@ def tail_lines(path: str, count: int) -> List[str]:
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
def backup_payload() -> Dict[str, object]:
|
def backup_payload(include_log: bool = True) -> Dict[str, object]:
|
||||||
value = read_json(BACKUP_STATUS_FILE, {})
|
value = read_json(BACKUP_STATUS_FILE, {})
|
||||||
value["enabled"] = bool(os.getenv("BACKUP_DESTINATION", "").strip())
|
value.pop("log", None)
|
||||||
|
configured = bool(os.getenv("BACKUP_DESTINATION", "").strip())
|
||||||
|
automatic = configured and env_bool("BACKUP_AUTO_ENABLED", True)
|
||||||
|
value["enabled"] = configured
|
||||||
|
value["manualEnabled"] = configured
|
||||||
|
value["automaticEnabled"] = automatic
|
||||||
value["scheduledHour"] = env_int("BACKUP_START_HOUR", 2, 0, 23)
|
value["scheduledHour"] = env_int("BACKUP_START_HOUR", 2, 0, 23)
|
||||||
value["log"] = tail_lines(BACKUP_LOG_FILE, 100)
|
if "state" not in value:
|
||||||
|
value["state"] = "waiting" if configured else "disabled"
|
||||||
|
value["percent"] = 0.0
|
||||||
|
if include_log:
|
||||||
|
value["log"] = tail_lines(BACKUP_LOG_FILE, 100)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def reconciliation_payload(include_log: bool = True) -> Dict[str, object]:
|
||||||
|
value = read_json(RECONCILE_STATUS_FILE, {})
|
||||||
|
value.pop("log", None)
|
||||||
|
if "state" not in value:
|
||||||
|
value.update(
|
||||||
|
{
|
||||||
|
"state": "waiting",
|
||||||
|
"phase": "waiting",
|
||||||
|
"percent": 0.0,
|
||||||
|
"message": "No reconciliation has run yet",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
value["automaticEnabled"] = True
|
||||||
|
value["scheduledIntervalMinutes"] = 5
|
||||||
|
if include_log:
|
||||||
|
value["log"] = tail_lines(RECONCILE_LOG_FILE, 150)
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
@@ -728,15 +839,46 @@ class App:
|
|||||||
recent = query_audit({"limit": ["12"]})
|
recent = query_audit({"limit": ["12"]})
|
||||||
return {"usage": usage, "activeGroups": share_count(), "recentEvents": recent["events"], "eventCount": recent["matched"], "backup": backup_payload(), "audit": audit_archive_summary()}
|
return {"usage": usage, "activeGroups": share_count(), "recentEvents": recent["events"], "eventCount": recent["matched"], "backup": backup_payload(), "audit": audit_archive_summary()}
|
||||||
|
|
||||||
def system(self) -> Dict[str, object]:
|
def system_summary(self) -> Dict[str, object]:
|
||||||
checks = {}
|
checks = {}
|
||||||
for name, command in {"domainTrust": ["wbinfo", "-t"], "sambaConfig": ["testparm", "-s"]}.items():
|
commands = {
|
||||||
|
"domainTrust": ["wbinfo", "-t"],
|
||||||
|
"sambaConfig": ["testparm", "-s"],
|
||||||
|
}
|
||||||
|
for name, command in commands.items():
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(command, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=10, check=False)
|
result = subprocess.run(
|
||||||
|
command,
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
timeout=10,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
checks[name] = result.returncode == 0
|
checks[name] = result.returncode == 0
|
||||||
except (OSError, subprocess.TimeoutExpired):
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
checks[name] = False
|
checks[name] = False
|
||||||
return {"hostname": os.getenv("WEB_HOSTNAME", ""), "checks": checks, "tls": tls_summary(), "audit": audit_archive_summary(), "usage": self.usage.snapshot(), "serverTime": now_utc().isoformat(timespec="seconds")}
|
return {
|
||||||
|
"hostname": os.getenv("WEB_HOSTNAME", ""),
|
||||||
|
"checks": checks,
|
||||||
|
"tls": tls_summary(),
|
||||||
|
"serverTime": now_utc().isoformat(timespec="seconds"),
|
||||||
|
}
|
||||||
|
|
||||||
|
def system(self) -> Dict[str, object]:
|
||||||
|
value = self.system_summary()
|
||||||
|
value["audit"] = audit_archive_summary()
|
||||||
|
value["usage"] = self.usage.snapshot()
|
||||||
|
return value
|
||||||
|
|
||||||
|
def report(self) -> Dict[str, object]:
|
||||||
|
"""Return every reportable snapshot without reading either log."""
|
||||||
|
return {
|
||||||
|
"generatedAt": now_utc().isoformat(timespec="seconds"),
|
||||||
|
"groups": self.directory.get(),
|
||||||
|
"storage": self.usage.snapshot(),
|
||||||
|
"backup": backup_payload(include_log=False),
|
||||||
|
"system": self.system_summary(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
APP: Optional[App] = None
|
APP: Optional[App] = None
|
||||||
@@ -748,13 +890,18 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
def log_message(self, fmt: str, *args) -> None:
|
def log_message(self, fmt: str, *args) -> None:
|
||||||
log(f"{self.client_address[0]} {fmt % args}")
|
log(f"{self.client_address[0]} {fmt % args}")
|
||||||
|
|
||||||
def security_headers(self) -> None:
|
def security_headers(self, cache_control: str = "no-store") -> None:
|
||||||
self.send_header("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
|
self.send_header("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
|
||||||
self.send_header("Content-Security-Policy", "default-src 'self'; connect-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'")
|
self.send_header(
|
||||||
|
"Content-Security-Policy",
|
||||||
|
"default-src 'self'; connect-src 'self'; img-src 'self' data:; "
|
||||||
|
"style-src 'self'; script-src 'self' 'wasm-unsafe-eval'; "
|
||||||
|
"base-uri 'none'; frame-ancestors 'none'; form-action 'self'",
|
||||||
|
)
|
||||||
self.send_header("X-Content-Type-Options", "nosniff")
|
self.send_header("X-Content-Type-Options", "nosniff")
|
||||||
self.send_header("Referrer-Policy", "no-referrer")
|
self.send_header("Referrer-Policy", "no-referrer")
|
||||||
self.send_header("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
|
self.send_header("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
|
||||||
self.send_header("Cache-Control", "no-store")
|
self.send_header("Cache-Control", cache_control)
|
||||||
|
|
||||||
def send_json(self, value: object, status: int = 200, cookie: Optional[str] = None) -> None:
|
def send_json(self, value: object, status: int = 200, cookie: Optional[str] = None) -> None:
|
||||||
body = json.dumps(value, separators=(",", ":")).encode()
|
body = json.dumps(value, separators=(",", ":")).encode()
|
||||||
@@ -836,6 +983,25 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
cookie = f"{JWT_COOKIE}=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Strict"
|
cookie = f"{JWT_COOKIE}=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Strict"
|
||||||
self.send_json({"ok": True}, cookie=cookie)
|
self.send_json({"ok": True}, cookie=cookie)
|
||||||
return
|
return
|
||||||
|
if parsed.path.startswith("/api/actions/"):
|
||||||
|
user = self.require_user()
|
||||||
|
if user is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
if parsed.path == "/api/actions/backup":
|
||||||
|
result = start_backup_action(str(user["sub"]))
|
||||||
|
elif parsed.path == "/api/actions/reconciliation":
|
||||||
|
result = start_reconciliation_action(str(user["sub"]))
|
||||||
|
else:
|
||||||
|
self.send_error_json(HTTPStatus.NOT_FOUND, "Nicht gefunden")
|
||||||
|
return
|
||||||
|
self.send_json(result, HTTPStatus.ACCEPTED)
|
||||||
|
except ActionConflict as exc:
|
||||||
|
self.send_error_json(HTTPStatus.CONFLICT, str(exc))
|
||||||
|
except RuntimeError as exc:
|
||||||
|
log(f"Action {parsed.path} failed: {exc}")
|
||||||
|
self.send_error_json(HTTPStatus.INTERNAL_SERVER_ERROR, str(exc))
|
||||||
|
return
|
||||||
self.send_error_json(HTTPStatus.NOT_FOUND, "Nicht gefunden")
|
self.send_error_json(HTTPStatus.NOT_FOUND, "Nicht gefunden")
|
||||||
|
|
||||||
def do_GET(self) -> None: # pylint: disable=invalid-name,too-many-return-statements
|
def do_GET(self) -> None: # pylint: disable=invalid-name,too-many-return-statements
|
||||||
@@ -861,9 +1027,17 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
elif path == "/api/activity":
|
elif path == "/api/activity":
|
||||||
self.send_json(query_audit(params))
|
self.send_json(query_audit(params))
|
||||||
elif path == "/api/backup":
|
elif path == "/api/backup":
|
||||||
self.send_json(backup_payload())
|
self.send_json(
|
||||||
|
backup_payload(include_log=query_includes_log(params))
|
||||||
|
)
|
||||||
|
elif path == "/api/reconciliation":
|
||||||
|
self.send_json(
|
||||||
|
reconciliation_payload(include_log=query_includes_log(params))
|
||||||
|
)
|
||||||
elif path == "/api/system":
|
elif path == "/api/system":
|
||||||
self.send_json(APP.system())
|
self.send_json(APP.system())
|
||||||
|
elif path == "/api/report":
|
||||||
|
self.send_json(APP.report())
|
||||||
else:
|
else:
|
||||||
self.send_error_json(HTTPStatus.NOT_FOUND, "Nicht gefunden")
|
self.send_error_json(HTTPStatus.NOT_FOUND, "Nicht gefunden")
|
||||||
except (ValueError, OSError, RuntimeError) as exc:
|
except (ValueError, OSError, RuntimeError) as exc:
|
||||||
@@ -875,7 +1049,12 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
def serve_static(self, path: str) -> None:
|
def serve_static(self, path: str) -> None:
|
||||||
files = {
|
files = {
|
||||||
"/assets/app.js": ("app.js", "text/javascript; charset=utf-8"),
|
"/assets/app.js": ("app.js", "text/javascript; charset=utf-8"),
|
||||||
|
"/assets/report.mjs": ("report.mjs", "text/javascript; charset=utf-8"),
|
||||||
"/assets/styles.css": ("styles.css", "text/css; charset=utf-8"),
|
"/assets/styles.css": ("styles.css", "text/css; charset=utf-8"),
|
||||||
|
"/assets/vendor/typst/0.6.0-csp1/typst.mjs": ("vendor/typst/typst.mjs", "text/javascript; charset=utf-8"),
|
||||||
|
"/assets/vendor/typst/0.6.0-csp1/compiler.wasm": ("vendor/typst/compiler.wasm", "application/wasm"),
|
||||||
|
"/assets/vendor/typst/0.6.0-csp1/LibertinusSerif-Regular.otf": ("vendor/typst/LibertinusSerif-Regular.otf", "font/otf"),
|
||||||
|
"/assets/vendor/typst/0.6.0-csp1/LibertinusSerif-Semibold.otf": ("vendor/typst/LibertinusSerif-Semibold.otf", "font/otf"),
|
||||||
"/favicon.svg": ("favicon.svg", "image/svg+xml"),
|
"/favicon.svg": ("favicon.svg", "image/svg+xml"),
|
||||||
}
|
}
|
||||||
if path in files:
|
if path in files:
|
||||||
@@ -889,7 +1068,12 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
self.send_error_json(HTTPStatus.NOT_FOUND, "Statische Datei nicht gefunden")
|
self.send_error_json(HTTPStatus.NOT_FOUND, "Statische Datei nicht gefunden")
|
||||||
return
|
return
|
||||||
self.send_response(HTTPStatus.OK)
|
self.send_response(HTTPStatus.OK)
|
||||||
self.security_headers()
|
cache_control = (
|
||||||
|
"public, max-age=31536000, immutable"
|
||||||
|
if path.startswith("/assets/vendor/typst/")
|
||||||
|
else "no-store"
|
||||||
|
)
|
||||||
|
self.security_headers(cache_control)
|
||||||
self.send_header("Content-Type", content_type)
|
self.send_header("Content-Type", content_type)
|
||||||
self.send_header("Content-Length", str(len(body)))
|
self.send_header("Content-Length", str(len(body)))
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ FROM debian:12-slim
|
|||||||
ENV DEBIAN_FRONTEND=noninteractive
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends rsync tini \
|
&& apt-get install -y --no-install-recommends p7zip-full rsync tini \
|
||||||
&& rm -rf /var/lib/apt/lists/* \
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
&& mkdir -p /backup
|
&& mkdir -p /backup
|
||||||
|
|
||||||
|
|||||||
+168
-1
@@ -159,10 +159,51 @@ def main() -> int:
|
|||||||
check(b"[hidden]" in styles.body and b"display: none !important" in styles.body, "hidden views can be made visible by author CSS")
|
check(b"[hidden]" in styles.body and b"display: none !important" in styles.body, "hidden views can be made visible by author CSS")
|
||||||
script = http("/assets/app.js")
|
script = http("/assets/app.js")
|
||||||
check(b"getUTCHours()" in script.body and b" UTC`" in script.body, "UI does not format timestamps explicitly in UTC")
|
check(b"getUTCHours()" in script.body and b" UTC`" in script.body, "UI does not format timestamps explicitly in UTC")
|
||||||
|
check(
|
||||||
|
b"Sicherung jetzt starten" in script.body
|
||||||
|
and b"Freigaben jetzt abgleichen" in script.body
|
||||||
|
and b'href="/reconciliation"' in index.body,
|
||||||
|
"manual actions or the top-level reconciliation navigation are missing",
|
||||||
|
)
|
||||||
check(b"localTime" not in script.body and b"eyebrow" not in script.body, "obsolete local-time or decorative UI code remains")
|
check(b"localTime" not in script.body and b"eyebrow" not in script.body, "obsolete local-time or decorative UI code remains")
|
||||||
|
report_script = http("/assets/report.mjs")
|
||||||
|
check(
|
||||||
|
report_script.status == 200
|
||||||
|
and b"compiler.pdf({mainContent:" in report_script.body
|
||||||
|
and b"getUTCHours()" in report_script.body,
|
||||||
|
"client-side Typst report module is missing or does not use UTC",
|
||||||
|
)
|
||||||
|
typst_script = http("/assets/vendor/typst/0.6.0-csp1/typst.mjs")
|
||||||
|
check(
|
||||||
|
typst_script.status == 200 and b"TypstSnippet" in typst_script.body,
|
||||||
|
"vendored Typst browser wrapper is missing",
|
||||||
|
)
|
||||||
|
typst_wasm = http("/assets/vendor/typst/0.6.0-csp1/compiler.wasm")
|
||||||
|
check(
|
||||||
|
typst_wasm.status == 200
|
||||||
|
and typst_wasm.body.startswith(b"\x00asm")
|
||||||
|
and typst_wasm.headers.get("Content-Type") == "application/wasm",
|
||||||
|
"vendored Typst compiler WASM is missing or has the wrong MIME type",
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
"immutable" in typst_wasm.headers.get("Cache-Control", ""),
|
||||||
|
"large immutable Typst assets are not browser-cacheable",
|
||||||
|
)
|
||||||
|
typst_font = http("/assets/vendor/typst/0.6.0-csp1/LibertinusSerif-Regular.otf")
|
||||||
|
check(
|
||||||
|
typst_font.status == 200
|
||||||
|
and typst_font.body.startswith(b"OTTO")
|
||||||
|
and typst_font.headers.get("Content-Type") == "font/otf",
|
||||||
|
"vendored Typst report font is missing or has the wrong MIME type",
|
||||||
|
)
|
||||||
check(b"brand-mark" not in index.body, "decorative brand mark remains")
|
check(b"brand-mark" not in index.body, "decorative brand mark remains")
|
||||||
check("max-age=" in index.headers.get("Strict-Transport-Security", ""), "HSTS missing")
|
check("max-age=" in index.headers.get("Strict-Transport-Security", ""), "HSTS missing")
|
||||||
check("default-src 'self'" in index.headers.get("Content-Security-Policy", ""), "CSP missing")
|
csp = index.headers.get("Content-Security-Policy", "")
|
||||||
|
check("default-src 'self'" in csp, "CSP missing")
|
||||||
|
check(
|
||||||
|
"script-src 'self' 'wasm-unsafe-eval'" in csp and "'unsafe-eval'" not in csp,
|
||||||
|
"CSP does not narrowly permit the local WebAssembly compiler",
|
||||||
|
)
|
||||||
with socket.create_connection(("localhost", HTTPS_PORT), timeout=10) as raw:
|
with socket.create_connection(("localhost", HTTPS_PORT), timeout=10) as raw:
|
||||||
with TLS_CONTEXT.wrap_socket(raw, server_hostname=HTTPS_HOST) as secured:
|
with TLS_CONTEXT.wrap_socket(raw, server_hostname=HTTPS_HOST) as secured:
|
||||||
certificate = secured.getpeercert()
|
certificate = secured.getpeercert()
|
||||||
@@ -173,6 +214,14 @@ def main() -> int:
|
|||||||
announce("JWT boundary, real Kerberos credentials, and Domain Admin authorization")
|
announce("JWT boundary, real Kerberos credentials, and Domain Admin authorization")
|
||||||
unauthenticated = http("/api/session")
|
unauthenticated = http("/api/session")
|
||||||
check(unauthenticated.status == 401, "protected API accepted an anonymous request")
|
check(unauthenticated.status == 401, "protected API accepted an anonymous request")
|
||||||
|
check(
|
||||||
|
http("/api/actions/backup", method="POST", value={}).status == 401,
|
||||||
|
"anonymous backup action was accepted",
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
http("/api/actions/reconciliation", method="POST", value={}).status == 401,
|
||||||
|
"anonymous reconciliation action was accepted",
|
||||||
|
)
|
||||||
non_admin = http(
|
non_admin = http(
|
||||||
"/api/login",
|
"/api/login",
|
||||||
method="POST",
|
method="POST",
|
||||||
@@ -386,6 +435,95 @@ def main() -> int:
|
|||||||
)
|
)
|
||||||
check(marker.returncode == 0, "backup target has no completed snapshot marker")
|
check(marker.returncode == 0, "backup target has no completed snapshot marker")
|
||||||
|
|
||||||
|
announce("encrypted non-solid per-group archives at the backup target")
|
||||||
|
archive_check = engine_run(
|
||||||
|
"exec",
|
||||||
|
BACKUP_CONTAINER,
|
||||||
|
"sh",
|
||||||
|
"-ec",
|
||||||
|
"""
|
||||||
|
archive=$(find /backup/fileserver/snapshots -path '*/data/groups/data/Finance.7z' -type f | sort | tail -n 1)
|
||||||
|
test -n "$archive"
|
||||||
|
archive_dir=${archive%/*}
|
||||||
|
archive_count=$(find "$archive_dir" -maxdepth 1 -type f -name '*.7z' | wc -l | tr -d ' ')
|
||||||
|
test "$archive_count" -eq 3
|
||||||
|
test ! -d "$archive_dir/Finance"
|
||||||
|
listing=$(printf '%s\n' "$PREVIEW_ARCHIVE_PASSWORD" | 7z l -slt "$archive")
|
||||||
|
printf '%s\n' "$listing" | grep -q '^Solid = -$'
|
||||||
|
printf '%s\n' "$listing" | grep -q '^Encrypted = +$'
|
||||||
|
if printf '%s\n' 'wrong archive password' | 7z l -slt "$archive" >/dev/null 2>&1; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
""",
|
||||||
|
check_result=False,
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
archive_check.returncode == 0,
|
||||||
|
"group archive is missing, solid, unencrypted, or accepted a wrong password: "
|
||||||
|
+ (archive_check.stderr.strip() or archive_check.stdout.strip()),
|
||||||
|
)
|
||||||
|
|
||||||
|
announce("authenticated manual backup action and terminal status")
|
||||||
|
manual_backup_start = eventually(
|
||||||
|
"manual backup action acceptance",
|
||||||
|
lambda: http("/api/actions/backup", method="POST", value={}, token=token),
|
||||||
|
lambda response: response.status == 202,
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
manual_backup_start.json().get("action") == "backup",
|
||||||
|
"manual backup action returned the wrong payload",
|
||||||
|
)
|
||||||
|
manual_backup = eventually(
|
||||||
|
"manual web backup completion",
|
||||||
|
lambda: http("/api/backup", token=token),
|
||||||
|
lambda response: (
|
||||||
|
response.status == 200
|
||||||
|
and response.json().get("trigger") == "web"
|
||||||
|
and response.json().get("state") in {"completed", "failed"}
|
||||||
|
),
|
||||||
|
timeout=240,
|
||||||
|
interval=2,
|
||||||
|
).json()
|
||||||
|
check(manual_backup.get("state") == "completed", f"manual web backup failed: {manual_backup}")
|
||||||
|
check(float(manual_backup.get("percent", 0)) == 100.0, "manual web backup is not at 100%")
|
||||||
|
|
||||||
|
announce("authenticated reconciliation action, progress status, and live log")
|
||||||
|
reconciliation_start = eventually(
|
||||||
|
"manual reconciliation action acceptance",
|
||||||
|
lambda: http("/api/actions/reconciliation", method="POST", value={}, token=token),
|
||||||
|
lambda response: response.status == 202,
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
reconciliation_start.json().get("action") == "reconciliation",
|
||||||
|
"manual reconciliation action returned the wrong payload",
|
||||||
|
)
|
||||||
|
reconciliation = eventually(
|
||||||
|
"manual reconciliation completion",
|
||||||
|
lambda: http("/api/reconciliation", token=token),
|
||||||
|
lambda response: (
|
||||||
|
response.status == 200
|
||||||
|
and response.json().get("trigger") == "web"
|
||||||
|
and response.json().get("state") in {"completed", "failed"}
|
||||||
|
),
|
||||||
|
timeout=240,
|
||||||
|
interval=1,
|
||||||
|
).json()
|
||||||
|
check(
|
||||||
|
reconciliation.get("state") == "completed",
|
||||||
|
f"manual reconciliation failed: {reconciliation}",
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
float(reconciliation.get("percent", 0)) == 100.0
|
||||||
|
and reconciliation.get("phase") == "completed",
|
||||||
|
"completed reconciliation status is incomplete",
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
any("completed" in line.casefold() for line in reconciliation.get("log", [])),
|
||||||
|
"reconciliation completion is absent from the live log",
|
||||||
|
)
|
||||||
|
|
||||||
announce("overview and system health aggregation")
|
announce("overview and system health aggregation")
|
||||||
overview = http("/api/overview", token=token)
|
overview = http("/api/overview", token=token)
|
||||||
check(overview.status == 200 and overview.json().get("activeGroups") == 3, "overview group count is wrong")
|
check(overview.status == 200 and overview.json().get("activeGroups") == 3, "overview group count is wrong")
|
||||||
@@ -397,6 +535,35 @@ def main() -> int:
|
|||||||
check(system_payload.get("tls", {}).get("sans"), "system TLS summary is empty")
|
check(system_payload.get("tls", {}).get("sans"), "system TLS summary is empty")
|
||||||
check(system_payload.get("audit", {}).get("days", 0) >= 1, "system activity database summary is incomplete")
|
check(system_payload.get("audit", {}).get("days", 0) >= 1, "system activity database summary is incomplete")
|
||||||
|
|
||||||
|
announce("log-free PDF report snapshot")
|
||||||
|
report = http("/api/report", token=token)
|
||||||
|
check(report.status == 200, f"report endpoint failed: {report.body!r}")
|
||||||
|
report_payload = report.json()
|
||||||
|
check(
|
||||||
|
set(report_payload) == {"generatedAt", "groups", "storage", "backup", "system"},
|
||||||
|
f"report snapshot has unexpected sections: {sorted(report_payload)}",
|
||||||
|
)
|
||||||
|
check("log" not in report_payload["backup"], "backup log leaked into report snapshot")
|
||||||
|
check("audit" not in report_payload["system"], "activity metadata leaked into report snapshot")
|
||||||
|
check("usage" not in report_payload["system"], "duplicate usage leaked into report snapshot")
|
||||||
|
check(
|
||||||
|
report_payload["groups"].get("groups") == groups_payload.get("groups"),
|
||||||
|
"report membership hierarchy differs from the group API",
|
||||||
|
)
|
||||||
|
report_totals = report_payload["storage"].get("totals", {})
|
||||||
|
check(
|
||||||
|
all(int(report_totals.get(field, 0)) > 0 for field in (
|
||||||
|
"dataBytes",
|
||||||
|
"privateBytes",
|
||||||
|
"fslogixBytes",
|
||||||
|
)),
|
||||||
|
"report storage snapshot is incomplete",
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
report_payload["backup"].get("state") == backup_payload.get("state"),
|
||||||
|
"report backup status differs from the backup API",
|
||||||
|
)
|
||||||
|
|
||||||
logout = http("/api/logout", method="POST", value={}, token=token)
|
logout = http("/api/logout", method="POST", value={}, token=token)
|
||||||
check(logout.status == 200 and "Max-Age=0" in logout.headers.get("Set-Cookie", ""), "logout did not clear session cookie")
|
check(logout.status == 200 and "Max-Age=0" in logout.headers.get("Set-Cookie", ""), "logout did not clear session cookie")
|
||||||
|
|
||||||
|
|||||||
+6
-1
@@ -41,6 +41,8 @@ dev_admin_user=${DEV_ADMIN_USER:-previewadmin}
|
|||||||
dev_admin_password=${DEV_ADMIN_PASSWORD:-PreviewAdmin123!}
|
dev_admin_password=${DEV_ADMIN_PASSWORD:-PreviewAdmin123!}
|
||||||
dev_backup_user=${DEV_BACKUP_USER:-preview}
|
dev_backup_user=${DEV_BACKUP_USER:-preview}
|
||||||
dev_backup_password=${DEV_BACKUP_PASSWORD:-PreviewBackup123!}
|
dev_backup_password=${DEV_BACKUP_PASSWORD:-PreviewBackup123!}
|
||||||
|
dev_archive_password=${DEV_ARCHIVE_PASSWORD:-PreviewArchive123!}
|
||||||
|
dev_backup_auto_enabled=${DEV_BACKUP_AUTO_ENABLED:-true}
|
||||||
dev_ca_password=${DEV_CA_PASSWORD:-PreviewCa123!}
|
dev_ca_password=${DEV_CA_PASSWORD:-PreviewCa123!}
|
||||||
dev_ca_provisioner=${DEV_CA_PROVISIONER:-preview}
|
dev_ca_provisioner=${DEV_CA_PROVISIONER:-preview}
|
||||||
dev_https_port=${DEV_HTTPS_PORT:-8443}
|
dev_https_port=${DEV_HTTPS_PORT:-8443}
|
||||||
@@ -250,6 +252,7 @@ printf 'starting disposable rsync backup target\n'
|
|||||||
--ip "$backup_ip" \
|
--ip "$backup_ip" \
|
||||||
-e "BACKUP_USERNAME=${dev_backup_user}" \
|
-e "BACKUP_USERNAME=${dev_backup_user}" \
|
||||||
-e "BACKUP_PASSWORD=${dev_backup_password}" \
|
-e "BACKUP_PASSWORD=${dev_backup_password}" \
|
||||||
|
-e "PREVIEW_ARCHIVE_PASSWORD=${dev_archive_password}" \
|
||||||
-v "$backup_volume:/backup" \
|
-v "$backup_volume:/backup" \
|
||||||
"$backup_image" >/dev/null
|
"$backup_image" >/dev/null
|
||||||
|
|
||||||
@@ -326,6 +329,8 @@ printf 'starting actual file server and HTTPS web UI\n'
|
|||||||
-e "WEB_DIRECTORY_CACHE_SECONDS=30" \
|
-e "WEB_DIRECTORY_CACHE_SECONDS=30" \
|
||||||
-e "AUDIT_POLL_SECONDS=0.25" \
|
-e "AUDIT_POLL_SECONDS=0.25" \
|
||||||
-e "BACKUP_DESTINATION=rsync://${dev_backup_user}:${dev_backup_password}@backup.${dev_dns_domain}/backups/fileserver" \
|
-e "BACKUP_DESTINATION=rsync://${dev_backup_user}:${dev_backup_password}@backup.${dev_dns_domain}/backups/fileserver" \
|
||||||
|
-e "BACKUP_ARCHIVE_PASSWORD=${dev_archive_password}" \
|
||||||
|
-e "BACKUP_AUTO_ENABLED=${dev_backup_auto_enabled}" \
|
||||||
-e "BACKUP_PROGRESS=never" \
|
-e "BACKUP_PROGRESS=never" \
|
||||||
-e "BACKUP_PROGRESS_INTERVAL_SECONDS=1" \
|
-e "BACKUP_PROGRESS_INTERVAL_SECONDS=1" \
|
||||||
-e "BACKUP_RETENTION_DAILY=3" \
|
-e "BACKUP_RETENTION_DAILY=3" \
|
||||||
@@ -389,7 +394,7 @@ run_backup_loop() {
|
|||||||
local elapsed
|
local elapsed
|
||||||
while "$engine" inspect "$files_container" >/dev/null 2>&1; do
|
while "$engine" inspect "$files_container" >/dev/null 2>&1; do
|
||||||
"$engine" exec "$files_container" /bin/bash -lc \
|
"$engine" exec "$files_container" /bin/bash -lc \
|
||||||
'source /app/runtime.env && exec /usr/bin/python3 /app/backup_to_destination.py' || true
|
'source /app/runtime.env && BACKUP_TRIGGER=automatic exec /usr/bin/python3 /app/backup_to_destination.py' || true
|
||||||
elapsed=0
|
elapsed=0
|
||||||
while (( elapsed < dev_backup_interval )); do
|
while (( elapsed < dev_backup_interval )); do
|
||||||
"$engine" inspect "$files_container" >/dev/null 2>&1 || return 0
|
"$engine" inspect "$files_container" >/dev/null 2>&1 || return 0
|
||||||
|
|||||||
@@ -124,6 +124,9 @@ write_env_file() {
|
|||||||
local domain_admins_sid=""
|
local domain_admins_sid=""
|
||||||
local fslogix_group_sid=""
|
local fslogix_group_sid=""
|
||||||
local backup_destination=""
|
local backup_destination=""
|
||||||
|
local backup_archive_password=""
|
||||||
|
local backup_auto_enabled="true"
|
||||||
|
local backup_auto_input=""
|
||||||
local backup_start_hour="2"
|
local backup_start_hour="2"
|
||||||
local backup_retention_daily="3"
|
local backup_retention_daily="3"
|
||||||
local backup_retention_weekly="2"
|
local backup_retention_weekly="2"
|
||||||
@@ -205,6 +208,14 @@ write_env_file() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
read -r -p "BACKUP_DESTINATION (optional URL, press Enter to disable): " backup_destination
|
read -r -p "BACKUP_DESTINATION (optional URL, press Enter to disable): " backup_destination
|
||||||
|
if [[ -n "$backup_destination" ]]; then
|
||||||
|
prompt_value backup_archive_password "BACKUP_ARCHIVE_PASSWORD (encrypts group archives)" true
|
||||||
|
read -r -p "Enable automatic daily backups? [Y/n]: " backup_auto_input
|
||||||
|
case "${backup_auto_input,,}" in
|
||||||
|
n|no) backup_auto_enabled="false" ;;
|
||||||
|
*) backup_auto_enabled="true" ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
read -r -p "BACKUP_START_HOUR [2]: " backup_start_hour
|
read -r -p "BACKUP_START_HOUR [2]: " backup_start_hour
|
||||||
backup_start_hour="${backup_start_hour:-2}"
|
backup_start_hour="${backup_start_hour:-2}"
|
||||||
read -r -p "BACKUP_RETENTION_DAILY [3]: " backup_retention_daily
|
read -r -p "BACKUP_RETENTION_DAILY [3]: " backup_retention_daily
|
||||||
@@ -294,6 +305,8 @@ AD_DNS_IP=${ad_dns_ip}
|
|||||||
AD_DNS_NAME=${ad_dns_name}
|
AD_DNS_NAME=${ad_dns_name}
|
||||||
AD_DNS_IP_AUTO=${ad_dns_ip_auto}
|
AD_DNS_IP_AUTO=${ad_dns_ip_auto}
|
||||||
BACKUP_DESTINATION=${backup_destination}
|
BACKUP_DESTINATION=${backup_destination}
|
||||||
|
BACKUP_ARCHIVE_PASSWORD=${backup_archive_password}
|
||||||
|
BACKUP_AUTO_ENABLED=${backup_auto_enabled}
|
||||||
BACKUP_START_HOUR=${backup_start_hour}
|
BACKUP_START_HOUR=${backup_start_hour}
|
||||||
BACKUP_RETENTION_DAILY=${backup_retention_daily}
|
BACKUP_RETENTION_DAILY=${backup_retention_daily}
|
||||||
BACKUP_RETENTION_WEEKLY=${backup_retention_weekly}
|
BACKUP_RETENTION_WEEKLY=${backup_retention_weekly}
|
||||||
@@ -373,6 +386,8 @@ AD_DNS_IP=${ad_dns_ip}
|
|||||||
AD_DNS_NAME=${ad_dns_name}
|
AD_DNS_NAME=${ad_dns_name}
|
||||||
AD_DNS_IP_AUTO=${ad_dns_ip_auto}
|
AD_DNS_IP_AUTO=${ad_dns_ip_auto}
|
||||||
BACKUP_DESTINATION=${backup_destination}
|
BACKUP_DESTINATION=${backup_destination}
|
||||||
|
BACKUP_ARCHIVE_PASSWORD=${backup_archive_password}
|
||||||
|
BACKUP_AUTO_ENABLED=${backup_auto_enabled}
|
||||||
BACKUP_START_HOUR=${backup_start_hour}
|
BACKUP_START_HOUR=${backup_start_hour}
|
||||||
BACKUP_RETENTION_DAILY=${backup_retention_daily}
|
BACKUP_RETENTION_DAILY=${backup_retention_daily}
|
||||||
BACKUP_RETENTION_WEEKLY=${backup_retention_weekly}
|
BACKUP_RETENTION_WEEKLY=${backup_retention_weekly}
|
||||||
@@ -400,6 +415,9 @@ ACME_HTTP_PORT=${acme_http_port}
|
|||||||
# BACKUP_DESTINATION=smb://DOMAIN%5Cuser:pass@backup.example.com/Backups/samba
|
# BACKUP_DESTINATION=smb://DOMAIN%5Cuser:pass@backup.example.com/Backups/samba
|
||||||
# BACKUP_DESTINATION=davfs://user:pass@webdav.example.com/remote.php/dav/files/backup
|
# BACKUP_DESTINATION=davfs://user:pass@webdav.example.com/remote.php/dav/files/backup
|
||||||
# BACKUP_DESTINATION=sftp://user:pass@sftp.example.com/exports/samba
|
# BACKUP_DESTINATION=sftp://user:pass@sftp.example.com/exports/samba
|
||||||
|
# BACKUP_ARCHIVE_PASSWORD=use-a-long-random-secret
|
||||||
|
# BACKUP_AUTO_ENABLED=true
|
||||||
|
# BACKUP_ARCHIVE_TEMP_DIR=/tmp
|
||||||
# BACKUP_START_HOUR=2
|
# BACKUP_START_HOUR=2
|
||||||
# BACKUP_RETENTION_DAILY=3
|
# BACKUP_RETENTION_DAILY=3
|
||||||
# BACKUP_RETENTION_WEEKLY=2
|
# BACKUP_RETENTION_WEEKLY=2
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
|
||||||
|
globalThis.window = globalThis;
|
||||||
|
|
||||||
|
const report = await import(new URL("../app/web/report.mjs", import.meta.url));
|
||||||
|
const typst = await import(new URL("../app/web/vendor/typst/typst.mjs", import.meta.url));
|
||||||
|
globalThis.Function = () => {
|
||||||
|
throw new Error("JavaScript dynamic code generation is disabled by CSP");
|
||||||
|
};
|
||||||
|
const vendor = new URL("../app/web/vendor/typst/", import.meta.url);
|
||||||
|
const binary = name => new Uint8Array(fs.readFileSync(new URL(name, vendor)));
|
||||||
|
|
||||||
|
typst.$typst.setCompilerInitOptions({
|
||||||
|
getModule: () => ({module_or_path: binary("compiler.wasm")}),
|
||||||
|
});
|
||||||
|
typst.$typst.use(
|
||||||
|
typst.TypstSnippet.disableDefaultFontAssets(),
|
||||||
|
typst.TypstSnippet.preloadFontData(binary("LibertinusSerif-Regular.otf")),
|
||||||
|
typst.TypstSnippet.preloadFontData(binary("LibertinusSerif-Semibold.otf"))
|
||||||
|
);
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
generatedAt: "2026-08-01T20:27:05+00:00",
|
||||||
|
storage: {
|
||||||
|
scannedAt: "2026-08-01T20:20:00+00:00",
|
||||||
|
scanSeconds: 1.25,
|
||||||
|
totals: {dataBytes: 1048576, privateBytes: 2048, fslogixBytes: 4096},
|
||||||
|
groups: [{name: "Forschung & Entwicklung", bytes: 1048576}],
|
||||||
|
users: [{name: "ludwig.lehnert", privateBytes: 2048, fslogixBytes: 4096, totalBytes: 6144}],
|
||||||
|
},
|
||||||
|
groups: {
|
||||||
|
fetchedAt: "2026-08-01T20:21:00+00:00",
|
||||||
|
truncated: false,
|
||||||
|
groups: [{
|
||||||
|
folder: "Forschung [2026] #1",
|
||||||
|
sam: "FS_Forschung",
|
||||||
|
active: true,
|
||||||
|
userCount: 1,
|
||||||
|
groupCount: 2,
|
||||||
|
members: [{
|
||||||
|
type: "group",
|
||||||
|
name: "Domänen-Benutzer",
|
||||||
|
sam: "Domain Users",
|
||||||
|
members: [{
|
||||||
|
type: "group",
|
||||||
|
name: "Forschung und Entwicklung – interne Projektmitglieder",
|
||||||
|
sam: "Forschung_Entwicklung_Intern",
|
||||||
|
members: [{type: "user", name: "Ludwig \"Test\" Lehnert", sam: "ludwig.lehnert", members: []}],
|
||||||
|
}],
|
||||||
|
}],
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
backup: {
|
||||||
|
enabled: true,
|
||||||
|
scheduledHour: 2,
|
||||||
|
state: "completed",
|
||||||
|
percent: 100,
|
||||||
|
transferredBytes: 1054720,
|
||||||
|
totalBytes: 1054720,
|
||||||
|
startedAt: "2026-08-01T02:00:00+00:00",
|
||||||
|
finishedAt: "2026-08-01T02:01:00+00:00",
|
||||||
|
snapshot: "2026-08-01T020000Z",
|
||||||
|
message: "Backup completed; 3 snapshot(s) retained",
|
||||||
|
activeFiles: [],
|
||||||
|
log: ["DARF NICHT IN DIE PDF"],
|
||||||
|
},
|
||||||
|
system: {
|
||||||
|
hostname: "files.example.test",
|
||||||
|
serverTime: "2026-08-01T20:27:05+00:00",
|
||||||
|
checks: {domainTrust: true, sambaConfig: true},
|
||||||
|
tls: {
|
||||||
|
subject: {commonName: "files.example.test"},
|
||||||
|
issuer: {commonName: "Interne CA"},
|
||||||
|
notAfter: "2027-08-01T00:00:00+00:00",
|
||||||
|
sans: [["DNS", "files.example.test"]],
|
||||||
|
},
|
||||||
|
audit: {secret: "DARF EBENFALLS NICHT IN DIE PDF"},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const source = report.buildReportSource(data);
|
||||||
|
if (source.includes("DARF NICHT") || source.includes("DARF EBENFALLS NICHT")) {
|
||||||
|
throw new Error("log data leaked into Typst source");
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
source.includes("[- ]") ||
|
||||||
|
!source.includes("columns: (depth * 9pt + 8pt, 44pt, 1fr)") ||
|
||||||
|
!source.includes("above: 1.8pt, below: 1.8pt")
|
||||||
|
) {
|
||||||
|
throw new Error("group hierarchy is not rendered as an indented row grid");
|
||||||
|
}
|
||||||
|
const pdf = await typst.$typst.pdf({mainContent: source});
|
||||||
|
if (!pdf || new TextDecoder().decode(pdf.slice(0, 5)) !== "%PDF-") {
|
||||||
|
throw new Error("Typst did not produce a PDF");
|
||||||
|
}
|
||||||
|
if (process.env.REPORT_SMOKE_OUTPUT) {
|
||||||
|
fs.writeFileSync(process.env.REPORT_SMOKE_OUTPUT, pdf);
|
||||||
|
}
|
||||||
|
console.log("client Typst PDF smoke test passed (" + pdf.length + " bytes)");
|
||||||
@@ -2,6 +2,7 @@ import io
|
|||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
@@ -188,6 +189,131 @@ class ProgressParsingTests(unittest.TestCase):
|
|||||||
self.assertEqual(total, 8)
|
self.assertEqual(total, 8)
|
||||||
|
|
||||||
|
|
||||||
|
class GroupArchiveTests(unittest.TestCase):
|
||||||
|
def test_archive_password_is_required_and_rejects_control_characters(self):
|
||||||
|
with mock.patch.dict(os.environ, {}, clear=True):
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "BACKUP_ARCHIVE_PASSWORD"):
|
||||||
|
backup.archive_password()
|
||||||
|
|
||||||
|
with mock.patch.dict(
|
||||||
|
os.environ, {"BACKUP_ARCHIVE_PASSWORD": "secret\nsecond-line"}, clear=True
|
||||||
|
):
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "unsupported characters"):
|
||||||
|
backup.archive_password()
|
||||||
|
|
||||||
|
def test_groups_are_staged_as_encrypted_non_solid_archives(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
source_root = os.path.join(tmpdir, "groups")
|
||||||
|
os.makedirs(os.path.join(source_root, "data", "Finance"))
|
||||||
|
os.makedirs(os.path.join(source_root, "archive", "Former"))
|
||||||
|
os.makedirs(os.path.join(source_root, "metadata"))
|
||||||
|
with open(
|
||||||
|
os.path.join(source_root, "data", "Finance", "report.txt"),
|
||||||
|
"w",
|
||||||
|
encoding="utf-8",
|
||||||
|
) as handle:
|
||||||
|
handle.write("finance")
|
||||||
|
with open(
|
||||||
|
os.path.join(source_root, "archive", "Former", "old.txt"),
|
||||||
|
"w",
|
||||||
|
encoding="utf-8",
|
||||||
|
) as handle:
|
||||||
|
handle.write("former")
|
||||||
|
with open(
|
||||||
|
os.path.join(source_root, "data", "README.txt"),
|
||||||
|
"w",
|
||||||
|
encoding="utf-8",
|
||||||
|
) as handle:
|
||||||
|
handle.write("preserve me")
|
||||||
|
with open(
|
||||||
|
os.path.join(source_root, "metadata", "index.txt"),
|
||||||
|
"w",
|
||||||
|
encoding="utf-8",
|
||||||
|
) as handle:
|
||||||
|
handle.write("metadata")
|
||||||
|
|
||||||
|
commands = []
|
||||||
|
|
||||||
|
def fake_run(command, **kwargs):
|
||||||
|
commands.append((command, kwargs))
|
||||||
|
archive_path = command[-3]
|
||||||
|
with open(archive_path, "wb") as handle:
|
||||||
|
handle.write(b"dummy-7z")
|
||||||
|
return backup.subprocess.CompletedProcess(command, 0, "", "")
|
||||||
|
|
||||||
|
staged_temp = None
|
||||||
|
try:
|
||||||
|
with mock.patch.object(backup, "run_command", side_effect=fake_run):
|
||||||
|
staged_temp, staged_root, count = backup.prepare_group_archives(
|
||||||
|
source_root, "archive secret", tmpdir
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(count, 2)
|
||||||
|
self.assertTrue(
|
||||||
|
os.path.isfile(os.path.join(staged_root, "data", "Finance.7z"))
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
os.path.isfile(os.path.join(staged_root, "archive", "Former.7z"))
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
os.path.isfile(os.path.join(staged_root, "data", "README.txt"))
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
os.path.isfile(os.path.join(staged_root, "metadata", "index.txt"))
|
||||||
|
)
|
||||||
|
self.assertFalse(
|
||||||
|
os.path.exists(os.path.join(staged_root, "data", "Finance"))
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(len(commands), 2)
|
||||||
|
for command, kwargs in commands:
|
||||||
|
self.assertEqual(command[:3], ["7z", "a", "-t7z"])
|
||||||
|
self.assertIn("-m0=lzma2", command)
|
||||||
|
self.assertIn("-mx=5", command)
|
||||||
|
self.assertIn("-mmt=on", command)
|
||||||
|
self.assertIn("-ms=off", command)
|
||||||
|
self.assertIn("-mhe=on", command)
|
||||||
|
self.assertIn("-p", command)
|
||||||
|
self.assertNotIn("archive secret", command)
|
||||||
|
self.assertEqual(kwargs["input_text"], "archive secret\n")
|
||||||
|
self.assertFalse(kwargs["check"])
|
||||||
|
self.assertTrue(os.path.isdir(kwargs["cwd"]))
|
||||||
|
finally:
|
||||||
|
if staged_temp is not None:
|
||||||
|
shutil.rmtree(staged_temp, ignore_errors=True)
|
||||||
|
|
||||||
|
@unittest.skipUnless(shutil.which("7z"), "7z is needed for the archive smoke test")
|
||||||
|
def test_real_archive_is_encrypted_and_non_solid(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
source = os.path.join(tmpdir, "Finance")
|
||||||
|
archive_path = os.path.join(tmpdir, "Finance.7z")
|
||||||
|
os.mkdir(source)
|
||||||
|
with open(os.path.join(source, "report.txt"), "w", encoding="utf-8") as handle:
|
||||||
|
handle.write("classified")
|
||||||
|
|
||||||
|
backup.create_group_archive(source, archive_path, "correct secret")
|
||||||
|
|
||||||
|
correct = subprocess.run(
|
||||||
|
[shutil.which("7z"), "l", "-slt", archive_path],
|
||||||
|
input="correct secret\n",
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
wrong = subprocess.run(
|
||||||
|
[shutil.which("7z"), "l", "-slt", archive_path],
|
||||||
|
input="wrong secret\n",
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(correct.returncode, 0, correct.stdout + correct.stderr)
|
||||||
|
self.assertIn("Solid = -", correct.stdout)
|
||||||
|
self.assertIn("Path = Finance/report.txt", correct.stdout)
|
||||||
|
self.assertNotEqual(wrong.returncode, 0)
|
||||||
|
|
||||||
|
|
||||||
class StateSnapshotTests(unittest.TestCase):
|
class StateSnapshotTests(unittest.TestCase):
|
||||||
def test_online_snapshot_includes_wal_commits_and_other_state(self):
|
def test_online_snapshot_includes_wal_commits_and_other_state(self):
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import base64
|
import base64
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import tempfile
|
import tempfile
|
||||||
@@ -30,6 +31,30 @@ def principal(dn, sam, classes, members=(), member_of=(), sid=""):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ReconcileStatusTests(unittest.TestCase):
|
||||||
|
def test_status_tracks_web_trigger_progress_and_completion_in_utc(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
status_path = os.path.join(tmpdir, "reconcile-status.json")
|
||||||
|
status = rs.ReconcileStatus(status_path)
|
||||||
|
|
||||||
|
status.begin("web")
|
||||||
|
status.progress(57.5, "data-permissions", "Syncing permissions", "FS_Finance")
|
||||||
|
status.complete("Reconciliation completed")
|
||||||
|
|
||||||
|
with open(status_path, encoding="utf-8") as handle:
|
||||||
|
payload = json.load(handle)
|
||||||
|
|
||||||
|
self.assertEqual(payload["state"], "completed")
|
||||||
|
self.assertEqual(payload["trigger"], "web")
|
||||||
|
self.assertEqual(payload["phase"], "completed")
|
||||||
|
self.assertEqual(payload["percent"], 100.0)
|
||||||
|
self.assertEqual(payload["message"], "Reconciliation completed")
|
||||||
|
self.assertIsNone(payload["currentItem"])
|
||||||
|
self.assertRegex(payload["startedAt"], r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+00:00$")
|
||||||
|
self.assertRegex(payload["finishedAt"], r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+00:00$")
|
||||||
|
self.assertFalse(os.path.exists(f"{status_path}.tmp"))
|
||||||
|
|
||||||
|
|
||||||
class GroupFolderNameTests(unittest.TestCase):
|
class GroupFolderNameTests(unittest.TestCase):
|
||||||
def test_sanitizer_preserves_leading_dot(self):
|
def test_sanitizer_preserves_leading_dot(self):
|
||||||
self.assertEqual(rs.sanitize_group_folder_name(".Finance"), ".Finance")
|
self.assertEqual(rs.sanitize_group_folder_name(".Finance"), ".Finance")
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import datetime as dt
|
import datetime as dt
|
||||||
import os
|
import os
|
||||||
|
import shutil
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
@@ -34,6 +36,167 @@ class TokenManagerTests(unittest.TestCase):
|
|||||||
web_ui.TokenManager("short", 600)
|
web_ui.TokenManager("short", 600)
|
||||||
|
|
||||||
|
|
||||||
|
class ReportPayloadTests(unittest.TestCase):
|
||||||
|
@mock.patch("app.web_ui.backup_payload")
|
||||||
|
def test_report_snapshot_excludes_all_log_data(self, backup_payload):
|
||||||
|
backup_payload.return_value = {"state": "completed"}
|
||||||
|
app = mock.Mock()
|
||||||
|
app.directory.get.return_value = {"groups": [], "fetchedAt": None}
|
||||||
|
app.usage.snapshot.return_value = {"groups": [], "users": [], "totals": {}}
|
||||||
|
app.system_summary.return_value = {
|
||||||
|
"hostname": "files.example.test",
|
||||||
|
"checks": {},
|
||||||
|
"tls": {},
|
||||||
|
"serverTime": "2026-08-01T12:00:00+00:00",
|
||||||
|
}
|
||||||
|
|
||||||
|
payload = web_ui.App.report(app)
|
||||||
|
|
||||||
|
backup_payload.assert_called_once_with(include_log=False)
|
||||||
|
self.assertNotIn("log", payload["backup"])
|
||||||
|
self.assertNotIn("audit", payload["system"])
|
||||||
|
self.assertNotIn("usage", payload["system"])
|
||||||
|
self.assertEqual(payload["system"]["hostname"], "files.example.test")
|
||||||
|
|
||||||
|
@mock.patch("app.web_ui.tail_lines")
|
||||||
|
@mock.patch("app.web_ui.read_json")
|
||||||
|
def test_log_free_backup_snapshot_does_not_read_log_file(self, read_json, tail_lines):
|
||||||
|
read_json.return_value = {
|
||||||
|
"state": "completed",
|
||||||
|
"log": ["GEHEIME SICHERUNGSAUSGABE"],
|
||||||
|
}
|
||||||
|
|
||||||
|
payload = web_ui.backup_payload(include_log=False)
|
||||||
|
|
||||||
|
self.assertNotIn("log", payload)
|
||||||
|
tail_lines.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
class AdministrationActionTests(unittest.TestCase):
|
||||||
|
def test_log_query_flag_defaults_on_and_accepts_explicit_off_values(self):
|
||||||
|
self.assertTrue(web_ui.query_includes_log({}))
|
||||||
|
self.assertTrue(web_ui.query_includes_log({"log": ["1"]}))
|
||||||
|
for value in ("0", "false", "NO", "off"):
|
||||||
|
self.assertFalse(web_ui.query_includes_log({"log": [value]}))
|
||||||
|
|
||||||
|
@mock.patch("app.web_ui.tail_lines", return_value=["backup log"])
|
||||||
|
@mock.patch("app.web_ui.read_json", return_value={})
|
||||||
|
def test_backup_payload_exposes_manual_mode_when_automatic_runs_are_off(
|
||||||
|
self, _read_json, _tail_lines
|
||||||
|
):
|
||||||
|
with mock.patch.dict(
|
||||||
|
os.environ,
|
||||||
|
{
|
||||||
|
"BACKUP_DESTINATION": "rsync://backup.example.test/target",
|
||||||
|
"BACKUP_AUTO_ENABLED": "false",
|
||||||
|
"BACKUP_START_HOUR": "4",
|
||||||
|
},
|
||||||
|
clear=True,
|
||||||
|
):
|
||||||
|
payload = web_ui.backup_payload()
|
||||||
|
|
||||||
|
self.assertTrue(payload["enabled"])
|
||||||
|
self.assertTrue(payload["manualEnabled"])
|
||||||
|
self.assertFalse(payload["automaticEnabled"])
|
||||||
|
self.assertEqual(payload["scheduledHour"], 4)
|
||||||
|
self.assertEqual(payload["state"], "waiting")
|
||||||
|
self.assertEqual(payload["log"], ["backup log"])
|
||||||
|
|
||||||
|
@mock.patch("app.web_ui.tail_lines", return_value=["reconcile log"])
|
||||||
|
@mock.patch("app.web_ui.read_json", return_value={})
|
||||||
|
def test_reconciliation_payload_has_progress_schedule_and_log(
|
||||||
|
self, _read_json, _tail_lines
|
||||||
|
):
|
||||||
|
payload = web_ui.reconciliation_payload()
|
||||||
|
|
||||||
|
self.assertEqual(payload["state"], "waiting")
|
||||||
|
self.assertEqual(payload["phase"], "waiting")
|
||||||
|
self.assertEqual(payload["percent"], 0.0)
|
||||||
|
self.assertTrue(payload["automaticEnabled"])
|
||||||
|
self.assertEqual(payload["scheduledIntervalMinutes"], 5)
|
||||||
|
self.assertEqual(payload["log"], ["reconcile log"])
|
||||||
|
|
||||||
|
@mock.patch("app.web_ui.tail_lines")
|
||||||
|
@mock.patch("app.web_ui.read_json", return_value={"state": "completed"})
|
||||||
|
def test_log_free_reconciliation_snapshot_does_not_read_log_file(
|
||||||
|
self, _read_json, tail_lines
|
||||||
|
):
|
||||||
|
payload = web_ui.reconciliation_payload(include_log=False)
|
||||||
|
|
||||||
|
self.assertNotIn("log", payload)
|
||||||
|
tail_lines.assert_not_called()
|
||||||
|
|
||||||
|
@mock.patch("app.web_ui.log")
|
||||||
|
@mock.patch("app.web_ui.launch_background")
|
||||||
|
@mock.patch("app.web_ui.lock_is_held", return_value=False)
|
||||||
|
def test_manual_backup_launches_with_web_trigger(
|
||||||
|
self, _lock_is_held, launch_background, _log
|
||||||
|
):
|
||||||
|
with mock.patch.dict(
|
||||||
|
os.environ,
|
||||||
|
{
|
||||||
|
"BACKUP_DESTINATION": "rsync://backup.example.test/target",
|
||||||
|
"BACKUP_ARCHIVE_PASSWORD": "archive secret",
|
||||||
|
},
|
||||||
|
clear=True,
|
||||||
|
):
|
||||||
|
result = web_ui.start_backup_action("EXAMPLE\\alice")
|
||||||
|
|
||||||
|
self.assertEqual(result, {"accepted": True, "action": "backup"})
|
||||||
|
launch_background.assert_called_once_with(
|
||||||
|
[web_ui.sys.executable, "/app/backup_to_destination.py"],
|
||||||
|
{"BACKUP_TRIGGER": "web"},
|
||||||
|
)
|
||||||
|
|
||||||
|
@mock.patch("app.web_ui.launch_background")
|
||||||
|
@mock.patch("app.web_ui.lock_is_held", return_value=False)
|
||||||
|
def test_manual_backup_requires_archive_password(
|
||||||
|
self, _lock_is_held, launch_background
|
||||||
|
):
|
||||||
|
with mock.patch.dict(
|
||||||
|
os.environ,
|
||||||
|
{"BACKUP_DESTINATION": "rsync://backup.example.test/target"},
|
||||||
|
clear=True,
|
||||||
|
):
|
||||||
|
with self.assertRaisesRegex(
|
||||||
|
web_ui.ActionConflict, "BACKUP_ARCHIVE_PASSWORD"
|
||||||
|
):
|
||||||
|
web_ui.start_backup_action("EXAMPLE\\alice")
|
||||||
|
|
||||||
|
launch_background.assert_not_called()
|
||||||
|
|
||||||
|
@mock.patch("app.web_ui.log")
|
||||||
|
@mock.patch("app.web_ui.launch_background")
|
||||||
|
@mock.patch("app.web_ui.lock_is_held", return_value=False)
|
||||||
|
def test_manual_reconciliation_launches_with_web_trigger(
|
||||||
|
self, _lock_is_held, launch_background, _log
|
||||||
|
):
|
||||||
|
result = web_ui.start_reconciliation_action("EXAMPLE\\alice")
|
||||||
|
|
||||||
|
self.assertEqual(result, {"accepted": True, "action": "reconciliation"})
|
||||||
|
launch_background.assert_called_once_with(
|
||||||
|
[web_ui.sys.executable, "/app/reconcile_shares.py"],
|
||||||
|
{"RECONCILE_TRIGGER": "web"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ReportCompilerTests(unittest.TestCase):
|
||||||
|
@unittest.skipUnless(shutil.which("node"), "Node.js is needed for the browser Typst smoke test")
|
||||||
|
def test_vendored_browser_typst_compiles_report_to_pdf(self):
|
||||||
|
root = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||||
|
result = subprocess.run(
|
||||||
|
[shutil.which("node"), os.path.join(root, "tests", "report_pdf_smoke.mjs")],
|
||||||
|
cwd=root,
|
||||||
|
check=False,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.STDOUT,
|
||||||
|
text=True,
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
self.assertEqual(result.returncode, 0, result.stdout)
|
||||||
|
self.assertIn("Typst PDF smoke test passed", result.stdout)
|
||||||
|
|
||||||
|
|
||||||
class DomainAuthenticationTests(unittest.TestCase):
|
class DomainAuthenticationTests(unittest.TestCase):
|
||||||
@mock.patch.dict(os.environ, {"WORKGROUP": "EXAMPLE"})
|
@mock.patch.dict(os.environ, {"WORKGROUP": "EXAMPLE"})
|
||||||
def test_bare_username_defaults_to_configured_netbios_domain(self):
|
def test_bare_username_defaults_to_configured_netbios_domain(self):
|
||||||
@@ -462,6 +625,8 @@ class WebPresentationTests(unittest.TestCase):
|
|||||||
self.assertNotIn("nav-group", html)
|
self.assertNotIn("nav-group", html)
|
||||||
self.assertIn('>Datenbelegung</a>', html)
|
self.assertIn('>Datenbelegung</a>', html)
|
||||||
self.assertIn('>Benutzerbelegung</a>', html)
|
self.assertIn('>Benutzerbelegung</a>', html)
|
||||||
|
self.assertIn('href="/reconciliation" data-route="reconciliation">Freigabenabgleich</a>', html)
|
||||||
|
self.assertIn('href="/report" data-route="report">PDF-Bericht</a>', html)
|
||||||
self.assertNotIn("brand-mark", html)
|
self.assertNotIn("brand-mark", html)
|
||||||
self.assertNotIn("eyebrow", html + script)
|
self.assertNotIn("eyebrow", html + script)
|
||||||
self.assertIn("getUTCHours()", script)
|
self.assertIn("getUTCHours()", script)
|
||||||
@@ -482,6 +647,37 @@ class WebPresentationTests(unittest.TestCase):
|
|||||||
self.assertIn(".shares-split .list", css)
|
self.assertIn(".shares-split .list", css)
|
||||||
self.assertIn(".shares-split .tree", css)
|
self.assertIn(".shares-split .tree", css)
|
||||||
|
|
||||||
|
def test_admin_actions_are_german_and_backup_automatic_setting_is_env_only(self):
|
||||||
|
script = self.asset("app.js")
|
||||||
|
|
||||||
|
self.assertIn("Sicherung jetzt starten", script)
|
||||||
|
self.assertIn("Freigaben jetzt abgleichen", script)
|
||||||
|
self.assertIn('api("/api/actions/backup"', script)
|
||||||
|
self.assertIn('api("/api/actions/reconciliation"', script)
|
||||||
|
self.assertIn('includeLog ? "/api/reconciliation"', script)
|
||||||
|
self.assertIn('"/api/backup?log=0"', script)
|
||||||
|
self.assertIn('"/api/reconciliation?log=0"', script)
|
||||||
|
self.assertEqual(script.count("if (active || previousActive)"), 2)
|
||||||
|
self.assertNotIn("BACKUP_AUTO_ENABLED", script)
|
||||||
|
|
||||||
|
def test_pdf_report_is_client_side_and_uses_only_log_free_endpoint(self):
|
||||||
|
script = self.asset("app.js")
|
||||||
|
report = self.asset("report.mjs")
|
||||||
|
typst = self.asset(os.path.join("vendor", "typst", "typst.mjs"))
|
||||||
|
|
||||||
|
self.assertIn('api("/api/report")', script)
|
||||||
|
self.assertIn('import("/assets/report.mjs")', script)
|
||||||
|
self.assertNotIn('api("/api/activity', report)
|
||||||
|
self.assertNotIn('api("/api/backup', report)
|
||||||
|
self.assertIn('compiler.pdf({mainContent:', report)
|
||||||
|
self.assertIn('/assets/vendor/typst/0.6.0-csp1/compiler.wasm', report)
|
||||||
|
self.assertIn("getUTCHours()", report)
|
||||||
|
self.assertIn("above: 1.8pt, below: 1.8pt", report)
|
||||||
|
self.assertIn('["Automatik", backup.automaticEnabled ? "Aktiv" : "Ausgeschaltet"]', report)
|
||||||
|
self.assertIn("Aktivitätsprotokoll und Sicherungsprotokoll sind nicht Bestandteil", report)
|
||||||
|
self.assertNotIn("new Function", typst)
|
||||||
|
self.assertIn("createCspSafeFunction", typst)
|
||||||
|
|
||||||
def test_samba_audits_only_supported_file_operations(self):
|
def test_samba_audits_only_supported_file_operations(self):
|
||||||
path = os.path.join(os.path.dirname(__file__), "..", "etc", "samba", "smb.conf")
|
path = os.path.join(os.path.dirname(__file__), "..", "etc", "samba", "smb.conf")
|
||||||
with open(path, encoding="utf-8") as handle:
|
with open(path, encoding="utf-8") as handle:
|
||||||
|
|||||||
Reference in New Issue
Block a user