5 Commits

12 changed files with 233 additions and 29 deletions

8
.env.example Normal file
View File

@@ -0,0 +1,8 @@
# Optional overrides for compose.yaml. Its defaults already match production.
TOOLBOX_HOST=toolbox.add-ideas.de
TRAEFIK_NETWORK=internal
TRAEFIK_CERT_RESOLVER=netcup
# Keep these two values paired when deploying a newer published toolbox release.
TOOLBOX_RELEASE_VERSION=0.3.0
TOOLBOX_RELEASE_SHA256=718a3c8b891e66f5601115270f25acaec9dbe1b9640a72c17954353bb10db93e

1
.gitignore vendored
View File

@@ -5,4 +5,5 @@ artifacts/
coverage/ coverage/
public/THIRD_PARTY_LICENSES.txt public/THIRD_PARTY_LICENSES.txt
*.log *.log
.env
.DS_Store .DS_Store

View File

@@ -2,5 +2,13 @@
"singleQuote": true, "singleQuote": true,
"semi": true, "semi": true,
"trailingComma": "es5", "trailingComma": "es5",
"printWidth": 80 "printWidth": 80,
"overrides": [
{
"files": "compose.yaml",
"options": {
"singleQuote": false
}
}
]
} }

34
Containerfile.release Normal file
View File

@@ -0,0 +1,34 @@
ARG NGINX_IMAGE=nginxinc/nginx-unprivileged:1.31.3-alpine@sha256:18d67281256ded39ff65e010ae4f831be18f19356f83c60bc546492c7eb6dd23
FROM ${NGINX_IMAGE} AS release
ARG TOOLBOX_RELEASE_VERSION=0.3.0
ARG TOOLBOX_RELEASE_SHA256=718a3c8b891e66f5601115270f25acaec9dbe1b9640a72c17954353bb10db93e
ARG TOOLBOX_RELEASE_BASE_URL=https://git.add-ideas.de/zemion/toolbox-portal/releases/download
RUN set -eu; \
archive="add-ideas-toolbox-${TOOLBOX_RELEASE_VERSION}.zip"; \
curl --fail --location --silent --show-error --proto '=https' \
--proto-redir '=https' --tlsv1.2 \
--output "/tmp/${archive}" \
"${TOOLBOX_RELEASE_BASE_URL}/v${TOOLBOX_RELEASE_VERSION}/${archive}"; \
echo "${TOOLBOX_RELEASE_SHA256} /tmp/${archive}" | sha256sum -c -; \
mkdir -p /tmp/toolbox; \
unzip -q "/tmp/${archive}" -d /tmp/toolbox; \
test -f /tmp/toolbox/index.html; \
test -f /tmp/toolbox/toolbox.catalog.json; \
test -f /tmp/toolbox/toolbox.release.json; \
test -f /tmp/toolbox/apps/pdf/toolbox-app.json; \
test -f /tmp/toolbox/apps/xslt/toolbox-app.json; \
test -f /tmp/toolbox/apps/onenote/toolbox-app.json; \
rm "/tmp/${archive}"
FROM ${NGINX_IMAGE}
COPY --chown=101:101 deploy/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=release --chown=101:101 /tmp/toolbox/ /usr/share/nginx/html/
USER 101:101
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --quiet --spider http://127.0.0.1:8080/toolbox.catalog.json || exit 1

View File

@@ -63,6 +63,43 @@ transmit preferences. Because Toolbox apps share an origin, code explicitly
trusted in an app can access same-origin browser storage; review each app's trusted in an app can access same-origin browser storage; review each app's
privacy and executable-code warning. Light, dark, and system modes are supported. privacy and executable-code warning. Light, dark, and system modes are supported.
## Production deployment behind Traefik
The committed `compose.yaml` is the shortest production path. Its release
container downloads the immutable Toolbox 0.3.0 ZIP during the image build,
verifies SHA-256 before extracting it, and then copies only the verified static
files into the pinned unprivileged nginx image. Node.js and a local portal
assembly are not required on the deployment host.
Prerequisites:
- Docker with the Compose plugin;
- Traefik attached to the existing external Docker network `internal`;
- a Traefik HTTPS entrypoint named `websecure`;
- a certificate resolver named `netcup`;
- DNS for `toolbox.add-ideas.de` pointing to that Traefik instance; and
- outbound HTTPS access to `git.add-ideas.de` while the image is built.
Deploy a fresh clone with:
```sh
git clone https://git.add-ideas.de/zemion/toolbox-portal.git
cd toolbox-portal
docker compose up -d --build
docker compose ps
```
No host port is published. Traefik routes
`https://toolbox.add-ideas.de` to nginx on the shared network at port 8080 and
obtains its TLS certificate through `netcup`. The hostname, network, resolver,
release version, and matching checksum can be overridden through environment
variables; copy `.env.example` to `.env` only when an override is needed. For
example, set `TOOLBOX_HOST=staging.toolbox.add-ideas.de` for a staging host.
The external network is deliberately not created by this project. Creating a
private project-local network would prevent an independently managed Traefik
container from reaching the service.
## Exact release assembly ## Exact release assembly
Every app release ZIP must put these items at its archive root: Every app release ZIP must put these items at its archive root:
@@ -80,8 +117,8 @@ The app release should also publish a matching `.sha256` sidecar. The manifest
must declare the pinned reverse-DNS id and version. Application and portal must declare the pinned reverse-DNS id and version. Application and portal
versions are independent. versions are independent.
`release/toolbox.lock.json` is the reviewed v0.2.0 lock. Its URLs and checksums `release/toolbox.lock.json` is the reviewed v0.3.0 lock. Its URLs and checksums
pin the published PDF Tools 0.3.4, XSLT Tools 0.3.2, and OneNote Tools 0.1.0 pin the published PDF Tools 0.3.4, XSLT Tools 0.3.2, and OneNote Tools 0.2.0
release bytes. Use release bytes. Use
`release/toolbox.lock.example.json` as the template for a future release and `release/toolbox.lock.example.json` as the template for a future release and
verify every downloaded asset before committing updated values. Artifacts may be: verify every downloaded asset before committing updated values. Artifacts may be:
@@ -103,7 +140,7 @@ npm run assemble -- \
--lock release/toolbox.lock.json \ --lock release/toolbox.lock.json \
--portal-dist dist \ --portal-dist dist \
--output build/toolbox \ --output build/toolbox \
--archive build/add-ideas-toolbox-0.2.0.zip --archive build/add-ideas-toolbox-0.3.0.zip
``` ```
Existing output is refused. Pass `--force` only when replacing those exact Existing output is refused. Pass `--force` only when replacing those exact
@@ -123,8 +160,8 @@ build/toolbox/
├── xslt/ ├── xslt/
└── onenote/ └── onenote/
build/add-ideas-toolbox-0.2.0.zip build/add-ideas-toolbox-0.3.0.zip
build/add-ideas-toolbox-0.2.0.zip.sha256 build/add-ideas-toolbox-0.3.0.zip.sha256
``` ```
The assembler verifies SHA-256 before opening an artifact, validates every app The assembler verifies SHA-256 before opening an artifact, validates every app
@@ -145,12 +182,12 @@ directory separately:
```sh ```sh
npm run package:static -- \ npm run package:static -- \
--input build/toolbox \ --input build/toolbox \
--output artifacts/add-ideas-toolbox-0.2.0.zip --output artifacts/add-ideas-toolbox-0.3.0.zip
``` ```
This also emits a `.sha256` sidecar. This also emits a `.sha256` sidecar.
## Container image ## Local assembled container and publication
`Containerfile` serves only the assembled files with unprivileged nginx on port 8080. It adds restrictive browser headers, same-origin isolation, immutable `Containerfile` serves only the assembled files with unprivileged nginx on port 8080. It adds restrictive browser headers, same-origin isolation, immutable
caching only for Vite-hashed assets, and revalidation for all other files. caching only for Vite-hashed assets, and revalidation for all other files.
@@ -162,13 +199,16 @@ normal local XML/XSLT transformations do not require that permission.
```sh ```sh
podman build -f Containerfile \ podman build -f Containerfile \
-t git.add-ideas.de/zemion/toolbox:0.2.0 . -t git.add-ideas.de/zemion/toolbox:0.3.0 .
podman run --rm -p 8080:8080 \ podman run --rm -p 8080:8080 \
git.add-ideas.de/zemion/toolbox:0.2.0 git.add-ideas.de/zemion/toolbox:0.3.0
``` ```
For a local deployment, copy `compose.example.yaml` to `compose.yaml` and run For a direct-port local deployment after assembly, use the separate example:
`docker compose up --build -d` (or the Podman Compose equivalent).
```sh
docker compose -f compose.example.yaml up -d --build
```
Publish both common architectures from a buildx-enabled workstation or Gitea Publish both common architectures from a buildx-enabled workstation or Gitea
runner: runner:
@@ -177,9 +217,9 @@ runner:
docker login git.add-ideas.de docker login git.add-ideas.de
docker buildx build \ docker buildx build \
--platform linux/amd64,linux/arm64 \ --platform linux/amd64,linux/arm64 \
--file Containerfile \ --file Containerfile.release \
--tag git.add-ideas.de/zemion/toolbox:0.2.0 \ --tag git.add-ideas.de/zemion/toolbox:0.3.0 \
--tag git.add-ideas.de/zemion/toolbox:0.2 \ --tag git.add-ideas.de/zemion/toolbox:0.3 \
--push . --push .
``` ```
@@ -204,8 +244,8 @@ permission to push code, releases, and container packages.
all three apps, switch between them, and confirm the encoded `toolbox` all three apps, switch between them, and confirm the encoded `toolbox`
context. context.
5. Verify the distribution with 5. Verify the distribution with
`(cd build && sha256sum -c add-ideas-toolbox-0.2.0.zip.sha256)`. `(cd build && sha256sum -c add-ideas-toolbox-0.3.0.zip.sha256)`.
6. Commit the reviewed lock, tag the toolbox release (for example `v0.2.0`), and 6. Commit the reviewed lock, tag the toolbox release (for example `v0.3.0`), and
push the branch and tag to Gitea. push the branch and tag to Gitea.
7. In Gitea, open **Releases → New release**, select the tag, and upload the 7. In Gitea, open **Releases → New release**, select the tag, and upload the
static ZIP plus its `.sha256` file. Do not use a mutable “latest” URL in a static ZIP plus its `.sha256` file. Do not use a mutable “latest” URL in a
@@ -231,7 +271,7 @@ must not re-resolve app versions or substitute a newer release.
| **`barcode-tools`** | Planned; dedicated repository | Generate, inspect and decode QR and barcodes | QR, common one- and two-dimensional codes; camera/image decoding; GS1 assistance; CSV batch generation; quiet-zone and print-size checks; structured payload builders | Treat decoded payloads as untrusted; never open links automatically; camera permission must be optional; integrate with `label-tools`, `contact-tools` and `crypto-tools` | | **`barcode-tools`** | Planned; dedicated repository | Generate, inspect and decode QR and barcodes | QR, common one- and two-dimensional codes; camera/image decoding; GS1 assistance; CSV batch generation; quiet-zone and print-size checks; structured payload builders | Treat decoded payloads as untrusted; never open links automatically; camera permission must be optional; integrate with `label-tools`, `contact-tools` and `crypto-tools` |
| **`av-tools`** | Planned; large dedicated repository | Light audio and video editing and transcoding | Trim, split, concatenate, crop, resize, normalize, fades, waveform, metadata, chapters, subtitles, thumbnail/contact-sheet generation and export presets | Heavy WASM and worker workloads; temporary-file storage; configurable memory limits; possibly special cross-origin-isolation headers; integrate with `subtitle-tools` and `image-tools` | | **`av-tools`** | Planned; large dedicated repository | Light audio and video editing and transcoding | Trim, split, concatenate, crop, resize, normalize, fades, waveform, metadata, chapters, subtitles, thumbnail/contact-sheet generation and export presets | Heavy WASM and worker workloads; temporary-file storage; configurable memory limits; possibly special cross-origin-isolation headers; integrate with `subtitle-tools` and `image-tools` |
| **`crypto-tools`** | Planned; dedicated repository | Inspect keys, certificates and signatures; validate chains | X.509 PEM/DER, CSR, CRL, PKCS #8, encrypted PKCS #8, PKCS #12/PFX, JWK/JWKS; key/certificate matching; path construction; hostname, purpose and time checks; CMS/JWS later | Inspection-first; explicit trust anchors; no implication that browser/OS trust stores are used; no private-key persistence; offline revocation first; network checks opt-in; restrictive CSP and worker isolation | | **`crypto-tools`** | Planned; dedicated repository | Inspect keys, certificates and signatures; validate chains | X.509 PEM/DER, CSR, CRL, PKCS #8, encrypted PKCS #8, PKCS #12/PFX, JWK/JWKS; key/certificate matching; path construction; hostname, purpose and time checks; CMS/JWS later | Inspection-first; explicit trust anchors; no implication that browser/OS trust stores are used; no private-key persistence; offline revocation first; network checks opt-in; restrictive CSP and worker isolation |
| **`onenote-tools`** | Existing MVP2 subset; dedicated repository | Open and browse OneNote sections and packages locally | Tested desktop `.one`; LZX/uncompressed `.onepkg`; section/page navigation, titles, timestamps, plain text and diagnostics; visual rendering and export later | `.onepkg` is CAB; native TypeScript worker with no Wasm; untrusted content does not execute; bounded decompression; no editing | | **`onenote-tools`** | Existing rich-reader release; dedicated repository | Open, browse, inspect and export OneNote sections and packages locally | Desktop/unfragmented FSSHTTPB `.one`/`.onetoc2`; none/LZX/MSZIP/Quantum and multi-cabinet `.onepkg`; trees, rich text, images, tables, ink, attachments and export | Native TypeScript worker with no Wasm; bounded parsing, rendering and export; fragmented FSSHTTP fails safely; no editing or pixel-perfect fidelity |
| **`data-tools`** | High priority; dedicated repository | Inspect, edit, validate, query and convert structured data | JSON, YAML, TOML, XML, CSV and NDJSON; tree/table/source views; schema inference; flattening; filtering; field mapping; JSONPath-style queries; conversion recipes | Every conversion should disclose information loss, coercion and round-trip instability; XML-specialist workflows hand off to `xslt-tools` | | **`data-tools`** | High priority; dedicated repository | Inspect, edit, validate, query and convert structured data | JSON, YAML, TOML, XML, CSV and NDJSON; tree/table/source views; schema inference; flattening; filtering; field mapping; JSONPath-style queries; conversion recipes | Every conversion should disclose information loss, coercion and round-trip instability; XML-specialist workflows hand off to `xslt-tools` |
| **`image-tools`** | High priority; dedicated repository | Edit and batch-process raster images | Crop, resize, rotate, compress, convert, contact sheets, sprites, responsive sets, favicon generation, metadata removal and visual quality comparison | Use workers and off-main-thread rendering; preserve or deliberately remove colour profiles; integrate with colour, SVG, PDF and privacy tools | | **`image-tools`** | High priority; dedicated repository | Edit and batch-process raster images | Crop, resize, rotate, compress, convert, contact sheets, sprites, responsive sets, favicon generation, metadata removal and visual quality comparison | Use workers and off-main-thread rendering; preserve or deliberately remove colour profiles; integrate with colour, SVG, PDF and privacy tools |
| **`diff-tools`** | High priority; dedicated repository or shared engine plus UI | Compare documents and files semantically | Text; JSON object-aware comparison; XML normalization; CSV keyed comparison; images with overlay/heatmap; archives and directories; PDF pages; optional audio waveform comparison | Normalization and ignored properties must be visible; produce portable reports and standard patch formats where possible | | **`diff-tools`** | High priority; dedicated repository or shared engine plus UI | Compare documents and files semantically | Text; JSON object-aware comparison; XML normalization; CSV keyed comparison; images with overlay/heatmap; archives and directories; PDF pages; optional audio waveform comparison | Normalization and ignored properties must be visible; produce portable reports and standard patch formats where possible |

View File

@@ -3,7 +3,7 @@ services:
build: build:
context: . context: .
dockerfile: Containerfile dockerfile: Containerfile
image: git.add-ideas.de/zemion/toolbox:0.2.0 image: git.add-ideas.de/zemion/toolbox:0.3.0
restart: unless-stopped restart: unless-stopped
read_only: true read_only: true
ports: ports:

37
compose.yaml Normal file
View File

@@ -0,0 +1,37 @@
name: addideas-toolbox
services:
toolbox:
build:
context: .
dockerfile: Containerfile.release
args:
TOOLBOX_RELEASE_VERSION: "${TOOLBOX_RELEASE_VERSION:-0.3.0}"
TOOLBOX_RELEASE_SHA256: "${TOOLBOX_RELEASE_SHA256:-718a3c8b891e66f5601115270f25acaec9dbe1b9640a72c17954353bb10db93e}"
image: "git.add-ideas.de/zemion/toolbox:${TOOLBOX_RELEASE_VERSION:-0.3.0}"
restart: unless-stopped
read_only: true
tmpfs:
- /tmp:size=16m,mode=1777
- /var/cache/nginx:size=16m,uid=101,gid=101
- /var/run:size=1m,uid=101,gid=101
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
networks:
- internal
labels:
traefik.enable: "true"
traefik.docker.network: "${TRAEFIK_NETWORK:-internal}"
traefik.http.routers.addideas-toolbox.rule: "Host(`${TOOLBOX_HOST:-toolbox.add-ideas.de}`)"
traefik.http.routers.addideas-toolbox.entrypoints: websecure
traefik.http.routers.addideas-toolbox.tls: "true"
traefik.http.routers.addideas-toolbox.tls.certresolver: "${TRAEFIK_CERT_RESOLVER:-netcup}"
traefik.http.routers.addideas-toolbox.service: addideas-toolbox
traefik.http.services.addideas-toolbox.loadbalancer.server.port: "8080"
networks:
internal:
external: true
name: "${TRAEFIK_NETWORK:-internal}"

View File

@@ -1,6 +1,6 @@
map $uri $toolbox_cache_control { map $uri $toolbox_cache_control {
default "no-cache"; default "no-cache";
~^/(?:.*/)?assets/.*-[A-Za-z0-9_-]{8,}\.[^/]+$ "public, max-age=31536000, immutable"; "~^/(?:.*/)?assets/.*-[A-Za-z0-9_-]{8,}\.[^/]+$" "public, max-age=31536000, immutable";
} }
server { server {

View File

@@ -7,7 +7,7 @@
<meta name="theme-color" content="#172554" /> <meta name="theme-color" content="#172554" />
<meta <meta
name="description" name="description"
content="A privacy-transparent browser toolbox for PDF and XSLT workflows." content="A privacy-transparent browser toolbox for PDF, XSLT, and OneNote workflows."
/> />
<link rel="icon" href="./favicon.svg" type="image/svg+xml" /> <link rel="icon" href="./favicon.svg" type="image/svg+xml" />
<title>add·ideas Toolbox</title> <title>add·ideas Toolbox</title>

View File

@@ -1,7 +1,7 @@
{ {
"$schema": "./toolbox-release-lock.schema.json", "$schema": "./toolbox-release-lock.schema.json",
"schemaVersion": 1, "schemaVersion": 1,
"releaseVersion": "0.2.0", "releaseVersion": "0.3.0",
"portalVersion": "0.1.0", "portalVersion": "0.1.0",
"catalogue": { "catalogue": {
"id": "de.add-ideas.toolbox", "id": "de.add-ideas.toolbox",
@@ -29,9 +29,9 @@
}, },
{ {
"id": "de.add-ideas.onenote-tools", "id": "de.add-ideas.onenote-tools",
"version": "0.1.0", "version": "0.2.0",
"artifact": "https://git.add-ideas.de/zemion/onenote-tools/releases/download/v0.1.0/onenote-tools-0.1.0.zip", "artifact": "https://git.add-ideas.de/zemion/onenote-tools/releases/download/v0.2.0/onenote-tools-0.2.0.zip",
"sha256": "cc0e3bb08a8f8d8a47a3e91257d9be4b324242417b63555de4aa060301db40b8", "sha256": "660c8c53bfd2f21875d41910d208d6638995f8dc31ba150ffa57d5591f56b76b",
"target": "onenote" "target": "onenote"
} }
] ]

View File

@@ -1,7 +1,7 @@
{ {
"$schema": "./toolbox-release-lock.schema.json", "$schema": "./toolbox-release-lock.schema.json",
"schemaVersion": 1, "schemaVersion": 1,
"releaseVersion": "0.2.0", "releaseVersion": "0.3.0",
"portalVersion": "0.1.0", "portalVersion": "0.1.0",
"catalogue": { "catalogue": {
"id": "de.add-ideas.toolbox", "id": "de.add-ideas.toolbox",
@@ -29,9 +29,9 @@
}, },
{ {
"id": "de.add-ideas.onenote-tools", "id": "de.add-ideas.onenote-tools",
"version": "0.1.0", "version": "0.2.0",
"artifact": "https://git.add-ideas.de/zemion/onenote-tools/releases/download/v0.1.0/onenote-tools-0.1.0.zip", "artifact": "https://git.add-ideas.de/zemion/onenote-tools/releases/download/v0.2.0/onenote-tools-0.2.0.zip",
"sha256": "cc0e3bb08a8f8d8a47a3e91257d9be4b324242417b63555de4aa060301db40b8", "sha256": "660c8c53bfd2f21875d41910d208d6638995f8dc31ba150ffa57d5591f56b76b",
"target": "onenote" "target": "onenote"
} }
] ]

View File

@@ -0,0 +1,76 @@
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { describe, expect, it } from 'vitest';
const projectRoot = process.cwd();
const compose = readFileSync(path.join(projectRoot, 'compose.yaml'), 'utf8');
const containerfile = readFileSync(
path.join(projectRoot, 'Containerfile.release'),
'utf8'
);
const nginxConfig = readFileSync(
path.join(projectRoot, 'deploy', 'nginx.conf'),
'utf8'
);
const releaseLock = JSON.parse(
readFileSync(path.join(projectRoot, 'release', 'toolbox.lock.json'), 'utf8')
) as { releaseVersion: string };
describe('production deployment', () => {
it('keeps the release version and checksum pins synchronized', () => {
const containerVersion = containerfile.match(
/^ARG TOOLBOX_RELEASE_VERSION=(\S+)$/mu
)?.[1];
const composeVersion = compose.match(
/\$\{TOOLBOX_RELEASE_VERSION:-([^}]+)\}/u
)?.[1];
const containerChecksum = containerfile.match(
/^ARG TOOLBOX_RELEASE_SHA256=([a-f0-9]{64})$/mu
)?.[1];
const composeChecksum = compose.match(
/\$\{TOOLBOX_RELEASE_SHA256:-([a-f0-9]{64})\}/u
)?.[1];
expect(containerVersion).toBe(releaseLock.releaseVersion);
expect(composeVersion).toBe(containerVersion);
expect(composeChecksum).toBe(containerChecksum);
});
it('verifies the release before extraction and retains a rootless final image', () => {
expect(containerfile).toContain("--proto-redir '=https' --tlsv1.2");
expect(containerfile.indexOf('sha256sum -c -')).toBeGreaterThan(0);
expect(containerfile.indexOf('unzip -q')).toBeGreaterThan(
containerfile.indexOf('sha256sum -c -')
);
expect(containerfile).toContain(
'COPY --from=release --chown=101:101 /tmp/toolbox/'
);
expect(containerfile).toMatch(/\nUSER 101:101\nEXPOSE 8080\n/u);
});
it('uses only the requested external Traefik network and HTTPS router', () => {
expect(compose).toContain('dockerfile: Containerfile.release');
expect(compose).toContain('traefik.enable: "true"');
expect(compose).toContain(
'traefik.docker.network: "${TRAEFIK_NETWORK:-internal}"'
);
expect(compose).toContain(
'traefik.http.routers.addideas-toolbox.rule: "Host(`${TOOLBOX_HOST:-toolbox.add-ideas.de}`)"'
);
expect(compose).toContain(
'traefik.http.routers.addideas-toolbox.entrypoints: websecure'
);
expect(compose).toContain(
'traefik.http.routers.addideas-toolbox.tls.certresolver: "${TRAEFIK_CERT_RESOLVER:-netcup}"'
);
expect(compose).toContain('external: true');
expect(compose).not.toMatch(/^\s+ports:/mu);
});
it('quotes nginx regular expressions that contain brace quantifiers', () => {
expect(nginxConfig).toContain(
'"~^/(?:.*/)?assets/.*-[A-Za-z0-9_-]{8,}\\.[^/]+$"'
);
expect(nginxConfig).not.toMatch(/^\s*~[^\n]*\{\d/mu);
});
});