compressed backups; more webui features

This commit is contained in:
Ludwig Lehnert
2026-08-01 10:29:25 +00:00
parent 69eacc14f3
commit 79cd02695a
25 changed files with 8836 additions and 69 deletions
+1
View File
@@ -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 \
+35 -14
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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() {
+2
View File
@@ -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>
+348
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+447
View File
@@ -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/
+29
View File
@@ -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`.
Binary file not shown.
+6189
View File
File diff suppressed because it is too large Load Diff
+197 -13
View File
@@ -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()
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
+18
View File
@@ -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
+99
View File
@@ -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)");
+126
View File
@@ -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:
+25
View File
@@ -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")
+196
View File
@@ -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: