From 963fe6c0cfc5bcaaf1ea554d6a4322394038560e Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Thu, 16 Jul 2026 11:12:36 +0100 Subject: [PATCH] Portal: add docs + auto-synced with full-text search (#6985) # Description of Changes --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have run `task check` to verify linters, typechecks, and tests pass - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing) for more details. --- .github/workflows/sync-portal-docs.yml | 92 ++ frontend/.prettierignore | 2 + .../public/locales/en-GB/translation.toml | 14 +- .../public/locales/en-US/translation.toml | 14 +- frontend/editor/scripts/sync-portal-docs.mts | 159 +++ frontend/editor/scripts/tsconfig.json | 4 +- frontend/editor/src/portal/ViewRouter.tsx | 18 +- .../src/portal/components/docs/DocsNav.tsx | 206 +++- .../src/portal/components/docs/DocsSearch.tsx | 137 +++ .../src/portal/components/docs/DocsToc.tsx | 80 ++ .../portal/components/docs/MarkdownDoc.tsx | 131 +++ .../src/portal/components/sidebarGroups.tsx | 6 +- .../src/portal/contexts/ViewContext.tsx | 2 +- .../editor/src/portal/docs/headings.test.ts | 47 + frontend/editor/src/portal/docs/headings.ts | 48 + .../src/portal/docs/manifest/registry.ts | 38 + .../portal/docs/manifest/transform.test.ts | 208 ++++ .../src/portal/docs/manifest/transform.ts | 481 +++++++++ .../editor/src/portal/docs/search.test.ts | 100 ++ frontend/editor/src/portal/docs/search.ts | 153 +++ .../src/portal/generated/docsManifest.json | 989 ++++++++++++++++++ .../editor/src/portal/views/DeveloperDocs.css | 533 +++++++++- .../portal/views/DeveloperDocs.stories.tsx | 24 + .../src/portal/views/DeveloperDocs.test.tsx | 80 ++ .../editor/src/portal/views/DeveloperDocs.tsx | 201 ++-- frontend/package.json | 1 + 26 files changed, 3594 insertions(+), 174 deletions(-) create mode 100644 .github/workflows/sync-portal-docs.yml create mode 100644 frontend/editor/scripts/sync-portal-docs.mts create mode 100644 frontend/editor/src/portal/components/docs/DocsSearch.tsx create mode 100644 frontend/editor/src/portal/components/docs/DocsToc.tsx create mode 100644 frontend/editor/src/portal/components/docs/MarkdownDoc.tsx create mode 100644 frontend/editor/src/portal/docs/headings.test.ts create mode 100644 frontend/editor/src/portal/docs/headings.ts create mode 100644 frontend/editor/src/portal/docs/manifest/registry.ts create mode 100644 frontend/editor/src/portal/docs/manifest/transform.test.ts create mode 100644 frontend/editor/src/portal/docs/manifest/transform.ts create mode 100644 frontend/editor/src/portal/docs/search.test.ts create mode 100644 frontend/editor/src/portal/docs/search.ts create mode 100644 frontend/editor/src/portal/generated/docsManifest.json create mode 100644 frontend/editor/src/portal/views/DeveloperDocs.stories.tsx create mode 100644 frontend/editor/src/portal/views/DeveloperDocs.test.tsx diff --git a/.github/workflows/sync-portal-docs.yml b/.github/workflows/sync-portal-docs.yml new file mode 100644 index 0000000000..6eea2988c6 --- /dev/null +++ b/.github/workflows/sync-portal-docs.yml @@ -0,0 +1,92 @@ +name: Sync Portal Docs + +# Regenerates the portal Developer Docs manifest from the Stirling docs repo and +# opens a PR when it changes. Runs weekly, on manual dispatch, or when the docs +# repo fires a `docs-updated` repository_dispatch. +on: + schedule: + - cron: "0 6 * * 1" + workflow_dispatch: + inputs: + ref: + description: "Docs repo ref (branch or tag) to sync from" + required: false + default: "main" + repository_dispatch: + types: [docs-updated] + +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + sync: + name: Sync docs manifest + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write + pull-requests: write + steps: + - name: Harden Runner + uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 + with: + egress-policy: audit + + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Setup GitHub App Bot + id: setup-bot + uses: ./.github/actions/setup-bot + with: + app-id: ${{ secrets.GH_APP_ID }} + private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} + + - name: Set up Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: "22" + cache: "npm" + cache-dependency-path: frontend/package-lock.json + + - name: Install frontend dependencies + working-directory: frontend + env: + NPM_CONFIG_IGNORE_SCRIPTS: "true" + run: npm ci --ignore-scripts --audit=false --fund=false + + - name: Regenerate docs manifest + working-directory: frontend + env: + DOCS_REF: ${{ github.event.inputs.ref || github.event.client_payload.ref || 'main' }} + GITHUB_TOKEN: ${{ steps.setup-bot.outputs.token }} + run: npm run docs:sync + + - name: Create Pull Request + id: cpr + uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 + with: + token: ${{ steps.setup-bot.outputs.token }} + commit-message: "Sync portal docs from docs repo" + committer: ${{ steps.setup-bot.outputs.committer }} + author: ${{ steps.setup-bot.outputs.committer }} + signoff: true + branch: sync-portal-docs + base: main + title: "Sync portal docs from docs repo" + body: | + Auto-generated by ${{ steps.setup-bot.outputs.app-slug }}[bot]. + + Regenerates `frontend/editor/src/portal/generated/docsManifest.json` + from the Stirling docs repo via `npm run docs:sync`. + labels: documentation,github-actions,frontend + add-paths: frontend/editor/src/portal/generated/docsManifest.json + delete-branch: true + sign-commits: true diff --git a/frontend/.prettierignore b/frontend/.prettierignore index 25caf7cd10..ccbc95322f 100644 --- a/frontend/.prettierignore +++ b/frontend/.prettierignore @@ -12,6 +12,8 @@ editor/public/mockServiceWorker.js # Auto-generated OG/social-preview metadata (scripts/generate-og-metadata.mjs); regenerated verbatim. editor/public/og-metadata.json editor/src/core/data/ogImageMap.json +# Auto-generated portal docs manifest (scripts/sync-portal-docs.mts); regenerated verbatim. +editor/src/portal/generated/docsManifest.json editor/public/pdfjs*/ editor/public/js/thirdParty/ editor/public/css/cookieconsent.css diff --git a/frontend/editor/public/locales/en-GB/translation.toml b/frontend/editor/public/locales/en-GB/translation.toml index ff3a30a66c..10b90e2f8a 100644 --- a/frontend/editor/public/locales/en-GB/translation.toml +++ b/frontend/editor/public/locales/en-GB/translation.toml @@ -6534,6 +6534,10 @@ signature = "signature" beta = "Beta" ga = "GA" +[portal.docs] +browse = "Browse docs" +viewSource = "View source on GitHub" + [portal.docs.authentication] codeCaption = "every request" eyebrow = "GETTING STARTED" @@ -6619,11 +6623,19 @@ title = "Official SDKs" beta = "Beta" deprecated = "Deprecated" +[portal.docs.search] +empty = "No matching docs" +placeholder = "Search docs" +results = "{{count}} results" + [portal.docs.skills] eyebrow = "SKILLS" lead = "Bundled, named capabilities your agent invokes as a single tool. Each skill is a deterministic op chain with evals attached." title = "Agent skills" +[portal.docs.toc] +title = "On this page" + [portal.docs.webhooks] codeCaption = "document.processed" eyebrow = "API REFERENCE" @@ -7177,7 +7189,7 @@ storage = "Storage" [portal.nav] agent-builder = "Agent Builder" components = "Components" -docs = "Developer Docs" +docs = "Documentation" documents = "Documents" editor = "Editor" home = "Home" diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index 9745842c52..6be49df2e0 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -6463,6 +6463,10 @@ bucket = "Bucket" name = "Name" region = "Region" +[portal.docs] +browse = "Browse docs" +viewSource = "View source on GitHub" + [portal.docs.authentication] codeCaption = "every request" eyebrow = "GETTING STARTED" @@ -6548,11 +6552,19 @@ title = "Official SDKs" beta = "Beta" deprecated = "Deprecated" +[portal.docs.search] +empty = "No matching docs" +placeholder = "Search docs" +results = "{{count}} results" + [portal.docs.skills] eyebrow = "SKILLS" lead = "Bundled, named capabilities your agent invokes as a single tool. Each skill is a deterministic op chain with evals attached." title = "Agent skills" +[portal.docs.toc] +title = "On this page" + [portal.docs.webhooks] codeCaption = "document.processed" eyebrow = "API REFERENCE" @@ -7217,7 +7229,7 @@ storage = "Storage" [portal.nav] agent-builder = "Agent Builder" components = "Components" -docs = "Developer Docs" +docs = "Documentation" documents = "Documents" editor = "Editor" home = "Home" diff --git a/frontend/editor/scripts/sync-portal-docs.mts b/frontend/editor/scripts/sync-portal-docs.mts new file mode 100644 index 0000000000..08ba00137b --- /dev/null +++ b/frontend/editor/scripts/sync-portal-docs.mts @@ -0,0 +1,159 @@ +/** + * Sync the portal Developer Docs from the Stirling docs repo. + * + * Fetches the docs repo tarball, extracts `docs/**` in-process (no external tar + * binary, no per-file GitHub rate limits), shapes it with the pure transforms in + * src/portal/docs/manifest/transform.ts, and writes the committed manifest that + * the portal docs view renders. Re-run with `npm run docs:sync`. + * + * Env: DOCS_REPO, DOCS_REF, DOCS_ROOT override the defaults below. + */ +import { gunzipSync } from "node:zlib"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +// tsx/node16 can't resolve the @portal alias here, so import by relative .ts path. +// eslint-disable-next-line no-restricted-imports +import { + buildManifest, + type CategoryMap, + type RawDoc, +} from "../src/portal/docs/manifest/transform.ts"; + +const REPO = process.env.DOCS_REPO ?? "Stirling-Tools/Stirling-Tools.github.io"; +const REF = process.env.DOCS_REF ?? "main"; +const ROOT = process.env.DOCS_ROOT ?? "docs"; +const SITE = "https://docs.stirlingpdf.com"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const OUT = resolve(HERE, "../src/portal/generated/docsManifest.json"); + +/* ── Minimal tar reader (ustar + pax/GNU long names) ─────────────────────── */ + +interface TarEntry { + name: string; + type: string; + data: Buffer; +} + +function readTar(buf: Buffer): TarEntry[] { + const entries: TarEntry[] = []; + let offset = 0; + let longName: string | null = null; + let paxPath: string | null = null; + + const str = (start: number, len: number) => { + const slice = buf.subarray(start, start + len); + const end = slice.indexOf(0); + return slice.toString("utf8", 0, end === -1 ? len : end); + }; + + while (offset + 512 <= buf.length) { + const header = buf.subarray(offset, offset + 512); + // Two consecutive zero blocks mark the end of the archive. + if (header.every((b) => b === 0)) break; + + const name = str(offset, 100); + const prefix = str(offset + 345, 155); + const sizeStr = str(offset + 124, 12).trim(); + const size = parseInt(sizeStr, 8) || 0; + const type = String.fromCharCode(header[156]); + const dataStart = offset + 512; + const data = buf.subarray(dataStart, dataStart + size); + + let fullName = prefix ? `${prefix}/${name}` : name; + if (longName) { + fullName = longName; + longName = null; + } + if (paxPath) { + fullName = paxPath; + paxPath = null; + } + + if (type === "L") { + // GNU long name: the payload is the real name of the next entry. + longName = data.toString("utf8").replace(/\0+$/, ""); + } else if (type === "x") { + // pax extended header: pull a `path=` record for the next entry. + const record = /(?:^|\n)\d+ path=([^\n]+)\n/.exec(data.toString("utf8")); + if (record) paxPath = record[1]; + } else if (type === "0" || type === "\0" || type === "") { + entries.push({ name: fullName, type, data: Buffer.from(data) }); + } + + offset = dataStart + Math.ceil(size / 512) * 512; + } + return entries; +} + +/* ── Fetch + shape ───────────────────────────────────────────────────────── */ + +async function main(): Promise { + const url = `https://api.github.com/repos/${REPO}/tarball/${REF}`; + console.log(`Fetching ${REPO}@${REF} …`); + const res = await fetch(url, { + headers: { + Accept: "application/vnd.github+json", + "User-Agent": "stirling-portal-docs-sync", + ...(process.env.GITHUB_TOKEN + ? { Authorization: `Bearer ${process.env.GITHUB_TOKEN}` } + : {}), + }, + }); + if (!res.ok) { + throw new Error( + `GitHub tarball fetch failed: ${res.status} ${res.statusText}`, + ); + } + const gz = Buffer.from(await res.arrayBuffer()); + const entries = readTar(gunzipSync(gz)); + + // Strip the "-/" wrapper dir and keep only files under docs root. + const prefix = `${ROOT}/`; + const rawDocs: RawDoc[] = []; + const categories: CategoryMap = {}; + for (const entry of entries) { + const rel = entry.name.replace(/^[^/]+\//, ""); + if (!rel.startsWith(prefix)) continue; + const inner = rel.slice(prefix.length); + if (!inner) continue; + if (inner.endsWith("/_category_.json")) { + const dir = inner.slice(0, -"/_category_.json".length); + try { + categories[dir] = JSON.parse(entry.data.toString("utf8")); + } catch { + console.warn(` skipping unparseable _category_.json in ${dir}`); + } + } else if (/\.mdx?$/i.test(inner)) { + rawDocs.push({ relPath: inner, content: entry.data.toString("utf8") }); + } + } + + if (rawDocs.length === 0) { + throw new Error(`No markdown found under ${ROOT}/ — wrong repo/ref/root?`); + } + + const manifest = buildManifest(rawDocs, categories, { + repo: REPO, + ref: REF, + root: ROOT, + siteBaseUrl: SITE, + }); + + mkdirSync(dirname(OUT), { recursive: true }); + writeFileSync(OUT, JSON.stringify(manifest, null, 2) + "\n", "utf8"); + + const items = manifest.nav.reduce((n, s) => n + s.items.length, 0); + console.log( + `Wrote ${manifest.nav.length} sections, ${items} docs → ${OUT.replace(/.*[/\\]frontend[/\\]/, "frontend/")}`, + ); + for (const s of manifest.nav) { + console.log(` ${s.icon} ${s.label} (${s.items.length})`); + } +} + +main().catch((err) => { + console.error(err instanceof Error ? err.message : err); + process.exitCode = 1; +}); diff --git a/frontend/editor/scripts/tsconfig.json b/frontend/editor/scripts/tsconfig.json index 38e4f505a3..d80666471b 100644 --- a/frontend/editor/scripts/tsconfig.json +++ b/frontend/editor/scripts/tsconfig.json @@ -4,7 +4,9 @@ "module": "node16", "moduleResolution": "node16", "types": ["node"], - "noEmit": true + "noEmit": true, + // sync-portal-docs.mts imports the shared transform by its .ts path (run via tsx). + "allowImportingTsExtensions": true }, "include": ["./**/*.ts", "./**/*.mts"] } diff --git a/frontend/editor/src/portal/ViewRouter.tsx b/frontend/editor/src/portal/ViewRouter.tsx index 44d370ff49..dc580a3a2b 100644 --- a/frontend/editor/src/portal/ViewRouter.tsx +++ b/frontend/editor/src/portal/ViewRouter.tsx @@ -1,3 +1,4 @@ +import { lazy, Suspense } from "react"; import { Navigate, Route, Routes } from "react-router-dom"; import { Home } from "@portal/views/Home"; import { Users } from "@portal/views/Users"; @@ -11,10 +12,16 @@ import { Policies } from "@portal/views/Policies"; import { EditorAdmin } from "@portal/views/EditorAdmin"; import { Infrastructure } from "@portal/views/Infrastructure"; import { PortalBillingGate } from "@portal/components/billing/PortalBillingGate"; -import { DeveloperDocs } from "@portal/views/DeveloperDocs"; import { Procurement } from "@portal/views/Procurement"; import { VIEW_PATHS, toPortalPath } from "@portal/contexts/ViewContext"; +// Lazy so the generated docs manifest (bundled JSON) lands in its own chunk. +const DeveloperDocs = lazy(() => + import("@portal/views/DeveloperDocs").then((m) => ({ + default: m.DeveloperDocs, + })), +); + // The portal mounts as a route-set under /processor/* in the editor app, so these // child routes are relative to that base: strip the leading slash from the // logical VIEW_PATHS, and home is the index route. Redirects use toPortalPath @@ -57,7 +64,14 @@ export function ViewRouter() { /> } /> } /> - } /> + + + + } + /> {/* Account-link is now a Settings panel; redirect legacy bookmarks home. */} ( + sections.map((s) => [s.id, { section: s, children: [] }]), + ); + const roots: NavNode[] = []; + for (const node of byId.values()) { + const pid = parentId(node.section.id); + const parent = pid ? byId.get(pid) : undefined; + if (parent) parent.children.push(node); + else roots.push(node); + } + return roots; +} + export function DocsNav({ sections, active, @@ -13,50 +50,125 @@ export function DocsNav({ onSelect: (id: string) => void; }) { const { t } = useTranslation(); + // Per-section manual open/close, overriding the "active branch only" default. + const [toggled, setToggled] = useState>({}); + const activeRef = useRef(null); + + const activeSectionId = useMemo( + () => sections.find((s) => s.items.some((i) => i.id === active))?.id, + [sections, active], + ); + + const tree = useMemo(() => buildTree(sections), [sections]); + + // Keep the active item in view when navigating (e.g. via a cross-link). + useEffect(() => { + activeRef.current?.scrollIntoView?.({ block: "nearest" }); + }, [active]); + + const isOpen = (id: string): boolean => { + if (id === STATIC_SECTION_ID) return true; + // Default-open the branch containing the active doc (self or ancestor). + const onActivePath = + !!activeSectionId && + (activeSectionId === id || activeSectionId.startsWith(id + "/")); + return toggled[id] ?? onActivePath; + }; + + const renderNode = (node: NavNode) => { + const { section, children } = node; + const isStatic = section.id === STATIC_SECTION_ID; + const open = isOpen(section.id); + return ( +
+ {isStatic ? ( +
{section.label}
+ ) : ( + + )} + + {open && ( + <> + {section.items.length > 0 && ( +
    + {section.items.map((item) => { + const isActive = item.id === active; + return ( +
  • + +
  • + ); + })} +
+ )} + {children.length > 0 && ( +
+ {children.map(renderNode)} +
+ )} + + )} +
+ ); + }; + return ( ); } @@ -64,14 +176,16 @@ export function DocsNav({ export function DocsNavSkeleton() { return ( diff --git a/frontend/editor/src/portal/components/docs/DocsSearch.tsx b/frontend/editor/src/portal/components/docs/DocsSearch.tsx new file mode 100644 index 0000000000..49bdb3101f --- /dev/null +++ b/frontend/editor/src/portal/components/docs/DocsSearch.tsx @@ -0,0 +1,137 @@ +import { useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@app/ui"; +import type { SearchResult, Segment } from "@portal/docs/search"; + +/** Render highlighted segments, wrapping matched runs in . */ +function Highlighted({ segments }: { segments: Segment[] }) { + return ( + <> + {segments.map((s, i) => + s.hit ? ( + + {s.text} + + ) : ( + {s.text} + ), + )} + + ); +} + +/** + * Docs search box + results. While a query is active it shows a ranked list of + * matching docs — each with its section, a highlighted title, and a content + * snippet — that navigates on click (or Enter). Arrow keys move the selection. + */ +export function DocsSearch({ + query, + onQueryChange, + results, + onSelect, +}: { + query: string; + onQueryChange: (q: string) => void; + results: SearchResult[]; + onSelect: (docId: string) => void; +}) { + const { t } = useTranslation(); + // -1 = nothing pre-selected; arrow keys drive this, the mouse uses CSS :hover. + const [activeIndex, setActiveIndex] = useState(-1); + const listRef = useRef(null); + const hasQuery = query.trim().length > 0; + + useEffect(() => setActiveIndex(-1), [query]); + + useEffect(() => { + listRef.current + ?.querySelector('[data-active="true"]') + ?.scrollIntoView?.({ block: "nearest" }); + }, [activeIndex]); + + const onKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Escape") { + onQueryChange(""); + return; + } + if (!results.length) return; + if (e.key === "ArrowDown") { + e.preventDefault(); + setActiveIndex((i) => Math.min(i + 1, results.length - 1)); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + setActiveIndex((i) => Math.max(i - 1, 0)); + } else if (e.key === "Enter") { + e.preventDefault(); + const hit = results[activeIndex >= 0 ? activeIndex : 0]; + if (hit) onSelect(hit.id); + } + }; + + return ( +
+
+ + ⌕ + + onQueryChange(e.target.value)} + onKeyDown={onKeyDown} + aria-label={t("portal.docs.search.placeholder")} + /> +
+ + {hasQuery && ( +
+ {results.length === 0 ? ( +

+ {t("portal.docs.search.empty")} +

+ ) : ( + <> +
+ {t("portal.docs.search.results", { count: results.length })} +
+
    + {results.map((r, i) => ( +
  • + +
  • + ))} +
+ + )} +
+ )} +
+ ); +} diff --git a/frontend/editor/src/portal/components/docs/DocsToc.tsx b/frontend/editor/src/portal/components/docs/DocsToc.tsx new file mode 100644 index 0000000000..1f6ee6ebad --- /dev/null +++ b/frontend/editor/src/portal/components/docs/DocsToc.tsx @@ -0,0 +1,80 @@ +import { useEffect, useState, type RefObject } from "react"; +import { useTranslation } from "react-i18next"; +import type { Heading } from "@portal/docs/headings"; + +/** + * "On this page" table of contents. Lists the current doc's H2/H3 headings, + * scrolls the reading pane to a heading on click, and highlights the section + * currently in view (scroll-spy against the pane's scroll container). + */ +export function DocsToc({ + headings, + scrollRef, +}: { + headings: Heading[]; + scrollRef: RefObject; +}) { + const { t } = useTranslation(); + const [active, setActive] = useState(headings[0]?.slug ?? ""); + + useEffect(() => { + const root = scrollRef.current; + if (!root || headings.length === 0) return; + setActive(headings[0].slug); + + const visible = new Set(); + const observer = new IntersectionObserver( + (entries) => { + for (const e of entries) { + if (e.isIntersecting) visible.add(e.target.id); + else visible.delete(e.target.id); + } + // The topmost heading currently within the active zone wins. + const current = headings.find((h) => visible.has(h.slug)); + if (current) setActive(current.slug); + }, + // Active zone = the top ~30% of the reading pane. + { root, rootMargin: "0px 0px -70% 0px", threshold: 0 }, + ); + + const els = headings + .map((h) => root.querySelector(`[id="${h.slug}"]`)) + .filter((el): el is Element => el !== null); + els.forEach((el) => observer.observe(el)); + return () => observer.disconnect(); + }, [headings, scrollRef]); + + const onSelect = (slug: string) => { + scrollRef.current + ?.querySelector(`[id="${slug}"]`) + ?.scrollIntoView({ block: "start", behavior: "smooth" }); + setActive(slug); + }; + + return ( + + ); +} diff --git a/frontend/editor/src/portal/components/docs/MarkdownDoc.tsx b/frontend/editor/src/portal/components/docs/MarkdownDoc.tsx new file mode 100644 index 0000000000..faf3339478 --- /dev/null +++ b/frontend/editor/src/portal/components/docs/MarkdownDoc.tsx @@ -0,0 +1,131 @@ +import { isValidElement, useState, type ReactNode } from "react"; +import ReactMarkdown, { + defaultUrlTransform, + type Components, +} from "react-markdown"; +import remarkGfm from "remark-gfm"; +import { Button } from "@app/ui"; +import { makeSlugger } from "@portal/docs/headings"; + +/** Flatten a heading's React children to plain text for its anchor id. */ +function childText(node: ReactNode): string { + if (typeof node === "string" || typeof node === "number") return String(node); + if (Array.isArray(node)) return node.map(childText).join(""); + if (isValidElement(node)) { + return childText((node.props as { children?: ReactNode }).children); + } + return ""; +} + +// Keep our internal `doc:` scheme; sanitize every other URL as react-markdown +// would by default (it strips unknown protocols, which would kill doc: links). +function urlTransform(url: string): string { + return url.startsWith("doc:") ? url : defaultUrlTransform(url); +} + +/** + * Renders a doc's normalised markdown. Internal cross-doc links carry the + * `doc:` scheme (see the sync transform) and are intercepted here so they + * navigate within the portal instead of leaving the app. + */ + +function CopyButton({ text }: { text: string }) { + const [copied, setCopied] = useState(false); + return ( + + ); +} + +function buildComponents( + onNavigate: (docId: string) => void, + slug: (text: string) => string, +): Components { + return { + h2: ({ children }) =>

{children}

, + h3: ({ children }) =>

{children}

, + a: ({ href, children }) => { + if (href?.startsWith("doc:")) { + const id = href.slice(4); + return ( + { + e.preventDefault(); + onNavigate(id); + }} + > + {children} + + ); + } + const external = /^https?:/i.test(href ?? ""); + return ( + + {children} + + ); + }, + // Eager, not lazy: lazy-loading inside the docs' own scroll container isn't + // reliably triggered, and docs pages have only a handful of images. + img: ({ node: _node, ...props }) => ( + + ), + pre: ({ children }) => { + const code = isValidElement(children) + ? String( + (children.props as { children?: unknown }).children ?? "", + ).replace(/\n$/, "") + : String(children ?? ""); + return ( +
+
{children}
+ +
+ ); + }, + table: ({ children }) => ( +
+ {children}
+
+ ), + }; +} + +export function MarkdownDoc({ + markdown, + onNavigate, +}: { + markdown: string; + onNavigate: (docId: string) => void; +}) { + // A fresh de-duping slugger per render; react-markdown invokes h2/h3 in + // document order, so ids line up with the TOC's extractHeadings slugs. + const slug = makeSlugger(); + return ( +
+ + {markdown} + +
+ ); +} diff --git a/frontend/editor/src/portal/components/sidebarGroups.tsx b/frontend/editor/src/portal/components/sidebarGroups.tsx index 32e1ce116c..9bf2626b34 100644 --- a/frontend/editor/src/portal/components/sidebarGroups.tsx +++ b/frontend/editor/src/portal/components/sidebarGroups.tsx @@ -19,10 +19,6 @@ export interface NavEntry { externalUrl?: string; } -// Developer docs has no built-in portal page yet, so the tab opens the hosted docs -// site in a new tab rather than routing to an empty page. -const DEVELOPER_DOCS_URL = "https://docs.stirlingpdf.com/"; - // Sidebar nav groups. This is a flavor seam: the SaaS build shadows this file to // drop sections not yet shipped there (see src/portal-saas/components/sidebarGroups). export const GROUP_PRIMARY: NavEntry[] = [{ id: "home", icon: }]; @@ -38,5 +34,5 @@ export const GROUP_OPERATIONAL: NavEntry[] = [ export const GROUP_PLATFORM: NavEntry[] = [ { id: "infrastructure", icon: }, { id: "usage", icon: }, - { id: "docs", icon: , externalUrl: DEVELOPER_DOCS_URL }, + { id: "docs", icon: }, ]; diff --git a/frontend/editor/src/portal/contexts/ViewContext.tsx b/frontend/editor/src/portal/contexts/ViewContext.tsx index 6fc7a6eba2..3c9ebc9fa8 100644 --- a/frontend/editor/src/portal/contexts/ViewContext.tsx +++ b/frontend/editor/src/portal/contexts/ViewContext.tsx @@ -28,7 +28,7 @@ export const VIEW_LABELS: Record = { documents: "Documents", infrastructure: "Infrastructure", usage: "Usage & Billing", - docs: "Developer Docs", + docs: "Documentation", procurement: "Procurement", settings: "Settings", }; diff --git a/frontend/editor/src/portal/docs/headings.test.ts b/frontend/editor/src/portal/docs/headings.test.ts new file mode 100644 index 0000000000..8069fa2fef --- /dev/null +++ b/frontend/editor/src/portal/docs/headings.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { extractHeadings, slugify } from "@portal/docs/headings"; + +describe("slugify", () => { + it("lowercases, hyphenates, and trims punctuation", () => { + expect(slugify("How it Works!")).toBe("how-it-works"); + expect(slugify(" Trailing & spaces ")).toBe("trailing-spaces"); + }); +}); + +describe("extractHeadings", () => { + it("extracts H2/H3 only, with slugs matching the rendered ids", () => { + const md = [ + "# Page Title", + "## How it Works", + "text", + "### Sub Section", + "#### Too Deep", + "## Operations", + ].join("\n"); + expect(extractHeadings(md)).toEqual([ + { level: 2, text: "How it Works", slug: "how-it-works" }, + { level: 3, text: "Sub Section", slug: "sub-section" }, + { level: 2, text: "Operations", slug: "operations" }, + ]); + }); + + it("de-duplicates repeated heading text into unique slugs", () => { + const md = ["## What Changed", "### What Changed", "## What Changed"].join( + "\n", + ); + expect(extractHeadings(md).map((h) => h.slug)).toEqual([ + "what-changed", + "what-changed-1", + "what-changed-2", + ]); + }); + + it("ignores headings inside fenced code and strips inline marks", () => { + const md = ["```", "## not a heading", "```", "## `Code` and *em*"].join( + "\n", + ); + expect(extractHeadings(md)).toEqual([ + { level: 2, text: "Code and em", slug: "code-and-em" }, + ]); + }); +}); diff --git a/frontend/editor/src/portal/docs/headings.ts b/frontend/editor/src/portal/docs/headings.ts new file mode 100644 index 0000000000..d99b385476 --- /dev/null +++ b/frontend/editor/src/portal/docs/headings.ts @@ -0,0 +1,48 @@ +/** + * Heading extraction for the "On this page" table of contents. The same + * `slugify` is used here and in MarkdownDoc's heading renderer, so the TOC links + * and the rendered heading ids always match. + */ + +export interface Heading { + /** 2 or 3 (H2/H3). */ + level: number; + text: string; + slug: string; +} + +/** "How it Works!" → "how-it-works" (the base id, before de-duplication). */ +export function slugify(text: string): string { + return text + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); +} + +/** + * A stateful slugger that de-duplicates: repeated heading text gets `-1`, `-2`, … + * The TOC extraction and MarkdownDoc's heading renderer each make one and feed it + * headings in document order, so their slugs (and thus link ↔ id) always match. + */ +export function makeSlugger(): (text: string) => string { + const seen = new Map(); + return (text: string) => { + const base = slugify(text) || "section"; + const n = seen.get(base) ?? 0; + seen.set(base, n + 1); + return n === 0 ? base : `${base}-${n}`; + }; +} + +/** Extract H2/H3 headings from a doc body, skipping fenced code blocks. */ +export function extractHeadings(markdown: string): Heading[] { + const noCode = markdown.replace(/^(```|~~~)[\s\S]*?^\1[ \t]*$/gm, ""); + const slug = makeSlugger(); + const headings: Heading[] = []; + for (const m of noCode.matchAll(/^ {0,3}(#{2,3})[ \t]+(.+?)[ \t]*#*$/gm)) { + const text = m[2].replace(/[`*_]/g, "").trim(); + if (text) headings.push({ level: m[1].length, text, slug: slug(text) }); + } + return headings; +} diff --git a/frontend/editor/src/portal/docs/manifest/registry.ts b/frontend/editor/src/portal/docs/manifest/registry.ts new file mode 100644 index 0000000000..02d608f264 --- /dev/null +++ b/frontend/editor/src/portal/docs/manifest/registry.ts @@ -0,0 +1,38 @@ +/** + * Runtime accessors over the generated docs manifest. This is the only module + * that imports the (large) JSON, so lazy-loading the docs view keeps it in its + * own chunk. Regenerate the JSON with `npm run docs:sync`. + */ +// Imported as a raw string (not a JSON module) so tsc doesn't infer a ~half-MB +// literal type; parsed once here into the typed manifest. +import manifestRaw from "@portal/generated/docsManifest.json?raw"; +import type { + DocEntry, + DocsManifest, + DocsNavSection, +} from "@portal/docs/manifest/transform"; + +const manifest = JSON.parse(manifestRaw) as DocsManifest; + +/** Provenance of the current manifest (repo + ref it was generated from). */ +export const docsSource = manifest.source; + +/** The auto-sorted nav tree (sections → items). */ +export function loadDocsNav(): DocsNavSection[] { + return manifest.nav; +} + +/** A single doc by id, or undefined if it isn't in the manifest. */ +export function loadDoc(id: string): DocEntry | undefined { + return manifest.docs[id]; +} + +/** Every doc, for building the search index. */ +export function allDocs(): DocEntry[] { + return Object.values(manifest.docs); +} + +/** The first doc id (first item of the first section) — the default landing. */ +export function firstDocId(): string | undefined { + return manifest.nav[0]?.items[0]?.id; +} diff --git a/frontend/editor/src/portal/docs/manifest/transform.test.ts b/frontend/editor/src/portal/docs/manifest/transform.test.ts new file mode 100644 index 0000000000..5bc105f7ef --- /dev/null +++ b/frontend/editor/src/portal/docs/manifest/transform.test.ts @@ -0,0 +1,208 @@ +import { describe, expect, it } from "vitest"; +import { + buildManifest, + convertAdmonitions, + demoteHeadings, + docIdForPath, + humanize, + parseFrontmatter, + resolveRelative, + rewriteReferences, + sectionIcon, + stripJsxTags, + stripMdxImports, + stripRedundantH1, + type CategoryMap, + type RawDoc, +} from "@portal/docs/manifest/transform"; + +const OPTS = { + repo: "Owner/Repo", + ref: "main", + root: "docs", + siteBaseUrl: "https://docs.example.com", +}; + +describe("parseFrontmatter", () => { + it("splits scalar YAML frontmatter from the body", () => { + const { data, body } = parseFrontmatter( + "---\ntitle: OCR\nsidebar_position: 7\n---\n# Heading\ntext", + ); + expect(data.title).toBe("OCR"); + expect(data.sidebar_position).toBe(7); + expect(body).toBe("# Heading\ntext"); + }); + + it("returns the whole content as body when there is no frontmatter", () => { + const { data, body } = parseFrontmatter("# Just a doc\nbody"); + expect(data).toEqual({}); + expect(body).toBe("# Just a doc\nbody"); + }); + + it("normalises CRLF line endings", () => { + const { data } = parseFrontmatter("---\r\nid: x\r\n---\r\nbody"); + expect(data.id).toBe("x"); + }); +}); + +describe("id + label helpers", () => { + it("slugifies nested paths", () => { + expect(docIdForPath("Configuration/OCR.md")).toBe("configuration/ocr"); + expect(docIdForPath("Getting Started.md")).toBe("getting-started"); + }); + + it("humanises file/dir names", () => { + expect(humanize("Getting-Started.md")).toBe("Getting Started"); + }); + + it("picks a section icon from the label", () => { + expect(sectionIcon("Configuration")).toBe("⚙"); + expect(sectionIcon("Totally Unknown")).toBe("◇"); + }); +}); + +describe("MDX normalisation", () => { + it("converts admonitions to titled blockquotes", () => { + const out = convertAdmonitions(":::tip Upgrading?\nread this\n:::"); + expect(out).toContain("> **💡 Tip: Upgrading?**"); + expect(out).toContain("> read this"); + }); + + it("strips import/export statements", () => { + const out = stripMdxImports("import Tabs from '@theme/Tabs';\n# Keep"); + expect(out).toBe("# Keep"); + }); + + it("removes JSX component tags but keeps inner content", () => { + expect( + stripJsxTags("\nkeep\n"), + ).toContain("keep"); + expect(stripJsxTags("x")).not.toMatch(/ { + const md = "# Title\n\n```bash\n# a shell comment\n```"; + const out = demoteHeadings(md); + expect(out).toContain("## Title"); + expect(out).toContain("# a shell comment"); + }); + + it("strips a leading H1 that duplicates the page title", () => { + expect(stripRedundantH1("# OCR\nbody", "OCR")).toBe("body"); + expect(stripRedundantH1("# Other\nbody", "OCR")).toBe("# Other\nbody"); + }); +}); + +describe("resolveRelative", () => { + it("collapses ./ and ../ against a base dir", () => { + expect(resolveRelative("Configuration", "./OCR.md")).toBe( + "Configuration/OCR.md", + ); + expect( + resolveRelative("Configuration", "../Functionality/Compare.md"), + ).toBe("Functionality/Compare.md"); + }); +}); + +describe("rewriteReferences", () => { + const ctx = { + dir: "Configuration", + // Keys are lowercased file paths (spaces preserved), as buildManifest builds them. + pathToId: new Map([ + [ + "configuration/system and security", + "configuration/system-and-security", + ], + ]), + rawBase: "https://raw.example.com/Owner/Repo/main", + siteBaseUrl: "https://docs.example.com", + }; + + it("rewrites resolvable internal links to the doc: scheme (decoded + case-insensitive)", () => { + const out = rewriteReferences( + "see [sec](./System%20and%20Security.md)", + ctx, + "docs", + ); + expect(out).toBe("see [sec](doc:configuration/system-and-security)"); + }); + + it("falls back to the live docs site for unresolved internal links", () => { + const out = rewriteReferences("[x](./Missing.md)", ctx, "docs"); + expect(out).toBe("[x](https://docs.example.com/Configuration/Missing)"); + }); + + it("leaves absolute and anchor links untouched", () => { + const md = "[a](https://x.com) and [b](#top)"; + expect(rewriteReferences(md, ctx, "docs")).toBe(md); + }); + + it("rewrites relative images to absolute raw URLs", () => { + expect(rewriteReferences("![a](./img/x.png)", ctx, "docs")).toBe( + "![a](https://raw.example.com/Owner/Repo/main/docs/Configuration/img/x.png)", + ); + expect(rewriteReferences("![a](/img/y.png)", ctx, "docs")).toBe( + "![a](https://raw.example.com/Owner/Repo/main/static/img/y.png)", + ); + }); + + it("does not rewrite inside fenced code blocks", () => { + const md = "```\n[x](./y.md)\n```"; + expect(rewriteReferences(md, ctx, "docs")).toBe(md); + }); +}); + +describe("buildManifest", () => { + const rawDocs: RawDoc[] = [ + { + relPath: "Getting Started.md", + content: "---\nsidebar_position: 0\n---\nintro", + }, + { + relPath: "Configuration/OCR.md", + content: "---\ntitle: OCR\nsidebar_position: 7\n---\n# OCR\nbody", + }, + { + relPath: "Configuration/DATABASE.md", + content: "---\nsidebar_position: 1\n---\n# Database\nsee [ocr](./OCR.md)", + }, + ]; + const categories: CategoryMap = { + Configuration: { label: "Configuration", position: 5 }, + }; + + it("auto-sorts sections (root Overview first, then by category position)", () => { + const m = buildManifest(rawDocs, categories, OPTS); + expect(m.nav.map((s) => s.id)).toEqual(["overview", "configuration"]); + expect(m.nav[0].label).toBe("Overview"); + expect(m.nav[1].label).toBe("Configuration"); + }); + + it("orders nav items by sidebar_position", () => { + const m = buildManifest(rawDocs, categories, OPTS); + const config = m.nav.find((s) => s.id === "configuration")!; + expect(config.items.map((i) => i.id)).toEqual([ + "configuration/database", + "configuration/ocr", + ]); + }); + + it("derives titles from frontmatter, heading, then filename", () => { + const m = buildManifest(rawDocs, categories, OPTS); + expect(m.docs["configuration/ocr"].title).toBe("OCR"); + expect(m.docs["getting-started"].title).toBe("Getting Started"); + }); + + it("resolves cross-doc links and records source/edit urls", () => { + const m = buildManifest(rawDocs, categories, OPTS); + expect(m.docs["configuration/database"].markdown).toContain( + "[ocr](doc:configuration/ocr)", + ); + expect(m.docs["configuration/ocr"].sourcePath).toBe( + "docs/Configuration/OCR.md", + ); + expect(m.docs["configuration/ocr"].editUrl).toBe( + "https://github.com/Owner/Repo/blob/main/docs/Configuration/OCR.md", + ); + }); +}); diff --git a/frontend/editor/src/portal/docs/manifest/transform.ts b/frontend/editor/src/portal/docs/manifest/transform.ts new file mode 100644 index 0000000000..60ced60ee4 --- /dev/null +++ b/frontend/editor/src/portal/docs/manifest/transform.ts @@ -0,0 +1,481 @@ +/** + * Pure transforms that turn the Docusaurus docs repo into the portal docs + * manifest. No I/O and no external deps so `tsx` (the sync CLI) and vitest can + * both use it. The sync CLI does the fetching; this module does the shaping. + * + * The auto-sort rules: + * - every directory that directly holds markdown becomes a nav section, + * labelled + ordered by its `_category_.json` (root files → "Overview"), + * - each `.md`/`.mdx` file becomes a nav item, ordered by frontmatter + * `sidebar_position` then title, + * - Docusaurus MDX is normalised to plain GitHub-flavoured markdown that + * react-markdown can render (admonitions, JSX, relative links, images). + */ + +/* ──────────────────────────────────────────────────────────────────────── */ +/* Manifest shape (mirrored structurally by @portal/api/docs) */ +/* ──────────────────────────────────────────────────────────────────────── */ + +export interface DocsNavItem { + id: string; + label: string; + badge?: string; +} + +export interface DocsNavSection { + id: string; + label: string; + icon: string; + items: DocsNavItem[]; +} + +export interface DocEntry { + id: string; + title: string; + description?: string; + section: string; + markdown: string; + sourcePath: string; + editUrl: string; +} + +export interface DocsManifest { + source: { repo: string; ref: string; root: string }; + nav: DocsNavSection[]; + docs: Record; +} + +/** One markdown file read from the repo, before shaping. */ +export interface RawDoc { + /** Posix path relative to the docs root, e.g. "Configuration/OCR.md". */ + relPath: string; + content: string; +} + +/** `_category_.json` contents, keyed by posix dir path relative to docs root. */ +export type CategoryMap = Record; + +export interface BuildOptions { + repo: string; + ref: string; + /** Docs root within the repo, e.g. "docs". */ + root: string; + /** Live docs site base, used as the fallback for unresolved internal links. */ + siteBaseUrl: string; +} + +/* ──────────────────────────────────────────────────────────────────────── */ +/* Small helpers */ +/* ──────────────────────────────────────────────────────────────────────── */ + +const SECTION_ICONS: Array<[RegExp, string]> = [ + [/overview|getting started|start/i, "▶"], + [/config/i, "⚙"], + [/function|tool|feature/i, "▤"], + [/install|deploy/i, "⤓"], + [/migrat|upgrade/i, "⇄"], + [/security|sign|auth/i, "🛡"], + [/convert/i, "⇋"], + [/page/i, "▦"], + [/api|develop/i, "{ }"], +]; + +/** A single-glyph icon for a section, chosen from its label. */ +export function sectionIcon(label: string): string { + for (const [re, glyph] of SECTION_ICONS) if (re.test(label)) return glyph; + return "◇"; +} + +/** "Getting-Started_Guide" → "Getting Started Guide". */ +export function humanize(name: string): string { + return name + .replace(/\.mdx?$/i, "") + .replace(/[-_]+/g, " ") + .replace(/\s+/g, " ") + .trim() + .replace(/\b\w/g, (c) => c.toUpperCase()); +} + +/** Lowercase, hyphenated, url-safe id for a path segment. */ +export function slugifySegment(name: string): string { + return name + .replace(/\.mdx?$/i, "") + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); +} + +/** Docs-root-relative posix path → stable doc id, e.g. "configuration/ocr". */ +export function docIdForPath(relPath: string): string { + return relPath.split("/").map(slugifySegment).filter(Boolean).join("/"); +} + +/** Posix dirname ("" for a root-level file). */ +export function dirOf(relPath: string): string { + const i = relPath.lastIndexOf("/"); + return i === -1 ? "" : relPath.slice(0, i); +} + +/* ──────────────────────────────────────────────────────────────────────── */ +/* Frontmatter */ +/* ──────────────────────────────────────────────────────────────────────── */ + +export interface Frontmatter { + data: Record; + body: string; +} + +/** Split leading `--- ... ---` YAML frontmatter (scalar keys only) from body. */ +export function parseFrontmatter(content: string): Frontmatter { + const normalised = content.replace(/\r\n/g, "\n"); + if (!normalised.startsWith("---\n")) return { data: {}, body: normalised }; + const end = normalised.indexOf("\n---", 4); + if (end === -1) return { data: {}, body: normalised }; + const raw = normalised.slice(4, end); + const rest = normalised.slice(end + 4).replace(/^\n/, ""); + const data: Record = {}; + for (const line of raw.split("\n")) { + const m = /^([A-Za-z0-9_]+):\s*(.*)$/.exec(line); + if (!m) continue; + let value: string | number = m[2].trim().replace(/^["']|["']$/g, ""); + if (/^-?\d+(\.\d+)?$/.test(value)) value = Number(value); + data[m[1]] = value; + } + return { data, body: rest }; +} + +/** First `# H1` heading text in a body, if any. */ +export function firstHeading(body: string): string | undefined { + const m = /^#\s+(.+?)\s*$/m.exec(stripCodeFences(body)); + return m ? m[1].trim() : undefined; +} + +/** Blank out fenced code blocks so heading/link scans ignore their contents. */ +function stripCodeFences(md: string): string { + return md.replace(/^(```|~~~)[\s\S]*?^\1\s*$/gm, ""); +} + +/* ──────────────────────────────────────────────────────────────────────── */ +/* Body normalisation (MDX → plain markdown) */ +/* ──────────────────────────────────────────────────────────────────────── */ + +/** Run `fn` over the non-fenced-code spans of `md`, leaving code blocks intact. */ +function mapOutsideCode(md: string, fn: (text: string) => string): string { + const parts = md.split(/(^(?:```|~~~)[\s\S]*?^(?:```|~~~)\s*$)/gm); + return parts.map((part, i) => (i % 2 === 0 ? fn(part) : part)).join(""); +} + +const ADMONITION_META: Record = { + tip: { icon: "💡", label: "Tip" }, + note: { icon: "📝", label: "Note" }, + info: { icon: "ℹ️", label: "Info" }, + warning: { icon: "⚠️", label: "Warning" }, + caution: { icon: "⚠️", label: "Caution" }, + danger: { icon: "🚫", label: "Danger" }, +}; + +/** `:::tip Title\n…\n:::` → a blockquote with a bold titled first line. */ +export function convertAdmonitions(md: string): string { + const re = /^:::(\w+)[ \t]*(.*)\n([\s\S]*?)^:::[ \t]*$/gm; + return md.replace(re, (_all, type: string, title: string, body: string) => { + const meta = ADMONITION_META[type.toLowerCase()] ?? { + icon: "•", + label: humanize(type), + }; + const heading = title.trim() + ? `${meta.label}: ${title.trim()}` + : meta.label; + const quoted = body + .replace(/\s+$/, "") + .split("\n") + .map((l) => (l ? `> ${l}` : ">")) + .join("\n"); + return `> **${meta.icon} ${heading}**\n>\n${quoted}\n`; + }); +} + +/** Drop MDX `import`/`export` statement lines (outside code). */ +export function stripMdxImports(md: string): string { + return md + .split("\n") + .filter((l) => !/^\s*(import|export)\s.+from\s.+;?\s*$/.test(l)) + .filter((l) => !/^\s*import\s+['"][^'"]+['"];?\s*$/.test(l)) + .join("\n"); +} + +/** Remove JSX component tags (Capitalised), keeping any inner content. */ +export function stripJsxTags(md: string): string { + return md.replace(/<\/?[A-Z][A-Za-z0-9.]*(?:\s[^>]*?)?\/?>/g, ""); +} + +/** Resolve a relative posix path against a base dir, collapsing `.`/`..`. */ +export function resolveRelative(baseDir: string, target: string): string { + const stack = baseDir ? baseDir.split("/") : []; + for (const seg of target.split("/")) { + if (seg === "" || seg === ".") continue; + if (seg === "..") stack.pop(); + else stack.push(seg); + } + return stack.join("/"); +} + +interface LinkContext { + dir: string; + pathToId: Map; + rawBase: string; + siteBaseUrl: string; +} + +/** Split "path#anchor" → [path, "#anchor" | ""]. */ +function splitAnchor(target: string): [string, string] { + const i = target.indexOf("#"); + return i === -1 ? [target, ""] : [target.slice(0, i), target.slice(i)]; +} + +/** Percent-decode a link path, tolerating malformed escapes. */ +function decodePath(p: string): string { + try { + return decodeURIComponent(p); + } catch { + return p; + } +} + +/** + * Look up the doc id for a relative link target. Keys in pathToId are lowercased + * so links work whether they use the filename or a slug, in any case, and with + * percent-encoded spaces (the repo links to "System%20and%20Security"). + */ +function resolveDocId(ctx: LinkContext, rawPath: string): string | undefined { + const resolved = resolveRelative(ctx.dir, decodePath(rawPath)).toLowerCase(); + const noExt = resolved.replace(/\.mdx?$/i, ""); + const candidates = [ + resolved, + noExt, + `${noExt.replace(/\/$/, "")}/index`, + noExt.replace(/\/index$/i, ""), + ]; + for (const c of candidates) { + const id = ctx.pathToId.get(c); + if (id) return id; + } + return undefined; +} + +/** Rewrite a single markdown link target to a portal-usable href. */ +function rewriteLinkTarget(ctx: LinkContext, target: string): string { + const trimmed = target.trim(); + if (/^(https?:|mailto:|tel:|#|doc:)/i.test(trimmed)) return trimmed; + const [path, anchor] = splitAnchor(trimmed); + if (!path) return trimmed; + const id = resolveDocId(ctx, path); + if (id) return `doc:${id}`; + // Unresolved internal link → fall back to the live docs site (encode spaces). + const slug = resolveRelative(ctx.dir, decodePath(path)).replace( + /\.mdx?$/i, + "", + ); + const encoded = slug.split("/").map(encodeURIComponent).join("/"); + return `${ctx.siteBaseUrl}/${encoded}${anchor}`; +} + +/** Rewrite a relative image src to an absolute raw-content URL. */ +function rewriteImageSrc(ctx: LinkContext, src: string, root: string): string { + const trimmed = src.trim(); + if (/^(https?:|data:)/i.test(trimmed)) return trimmed; + if (trimmed.startsWith("/")) return `${ctx.rawBase}/static${trimmed}`; + const resolved = resolveRelative(`${root}/${ctx.dir}`, trimmed); + return `${ctx.rawBase}/${resolved}`; +} + +/** Rewrite markdown links + images (outside code) to portal/absolute targets. */ +export function rewriteReferences( + md: string, + ctx: LinkContext, + root: string, +): string { + return mapOutsideCode(md, (text) => { + // Images first so their `!` prefix isn't eaten by the link pattern. + let out = text.replace( + /!\[([^\]]*)\]\(([^)\s]+)([^)]*)\)/g, + (_m, alt: string, src: string, tail: string) => + `![${alt}](${rewriteImageSrc(ctx, src, root)}${tail})`, + ); + out = out.replace( + /(^|[^!])\[([^\]]+)\]\(([^)\s]+)([^)]*)\)/g, + (_m, pre: string, label: string, href: string, tail: string) => + `${pre}[${label}](${rewriteLinkTarget(ctx, href)}${tail})`, + ); + return out; + }); +} + +/** Drop a leading `# H1` whose text equals the page title (avoids a dup head). */ +export function stripRedundantH1(md: string, title: string): string { + const m = /^\s*#\s+(.+?)\s*(\n|$)/.exec(md); + if (m && m[1].trim().toLowerCase() === title.trim().toLowerCase()) { + return md.slice(m[0].length).replace(/^\n+/, ""); + } + return md; +} + +/** Demote body `# H1` headings to `## H2` so the page title is the sole H1. */ +export function demoteHeadings(md: string): string { + return mapOutsideCode(md, (text) => text.replace(/^# (?=\S)/gm, "## ")); +} + +/* ──────────────────────────────────────────────────────────────────────── */ +/* Section ordering */ +/* ──────────────────────────────────────────────────────────────────────── */ + +const ROOT_SECTION_ID = "overview"; + +/** Composite order key: `_category_.json.position` down the dir path. */ +function sectionOrderKey(dir: string, categories: CategoryMap): number[] { + if (dir === "") return [-1]; + const key: number[] = []; + const segs = dir.split("/"); + for (let i = 0; i < segs.length; i++) { + const sub = segs.slice(0, i + 1).join("/"); + key.push(categories[sub]?.position ?? 999); + } + return key; +} + +function compareKeys(a: number[], b: number[]): number { + const n = Math.max(a.length, b.length); + for (let i = 0; i < n; i++) { + const d = (a[i] ?? 0) - (b[i] ?? 0); + if (d !== 0) return d; + } + return 0; +} + +/* ──────────────────────────────────────────────────────────────────────── */ +/* Build */ +/* ──────────────────────────────────────────────────────────────────────── */ + +interface ShapedDoc extends DocEntry { + navLabel: string; + sidebarPosition: number; + orderKey: number[]; + sectionLabel: string; +} + +/** Turn raw docs + category metadata into the full portal docs manifest. */ +export function buildManifest( + rawDocs: RawDoc[], + categories: CategoryMap, + opts: BuildOptions, +): DocsManifest { + const rawBase = `https://raw.githubusercontent.com/${opts.repo}/${opts.ref}`; + const editBase = `https://github.com/${opts.repo}/blob/${opts.ref}`; + + // First pass: assign stable ids so links between docs can resolve. Keys are + // lowercased (case-insensitive link matching); both with and without ext. + const pathToId = new Map(); + for (const doc of rawDocs) { + const id = docIdForPath(doc.relPath); + const lower = doc.relPath.toLowerCase(); + pathToId.set(lower, id); + pathToId.set(lower.replace(/\.mdx?$/i, ""), id); + } + + const shaped: ShapedDoc[] = rawDocs.map((doc) => { + const dir = dirOf(doc.relPath); + const { data, body } = parseFrontmatter(doc.content); + const title = + (typeof data.title === "string" && data.title) || + firstHeading(body) || + humanize(doc.relPath.split("/").pop() ?? doc.relPath); + const navLabel = + (typeof data.sidebar_label === "string" && data.sidebar_label) || title; + const description = + typeof data.description === "string" ? data.description : undefined; + + const ctx: LinkContext = { + dir, + pathToId, + rawBase, + siteBaseUrl: opts.siteBaseUrl.replace(/\/$/, ""), + }; + let markdown = body; + markdown = stripMdxImports(markdown); + markdown = convertAdmonitions(markdown); + markdown = stripJsxTags(markdown); + markdown = rewriteReferences(markdown, ctx, opts.root); + markdown = stripRedundantH1(markdown, title); + markdown = demoteHeadings(markdown).trim(); + + const sectionId = dir === "" ? ROOT_SECTION_ID : docIdForPath(dir); + const sectionLabel = + dir === "" + ? "Overview" + : (categories[dir]?.label ?? humanize(dir.split("/").pop() ?? dir)); + + return { + id: docIdForPath(doc.relPath), + title, + navLabel, + description, + section: sectionId, + markdown, + sourcePath: `${opts.root}/${doc.relPath}`, + editUrl: `${editBase}/${opts.root}/${doc.relPath}`, + sidebarPosition: + typeof data.sidebar_position === "number" ? data.sidebar_position : 999, + orderKey: sectionOrderKey(dir, categories), + sectionLabel, + }; + }); + + // Group into sections and order everything deterministically. + const bySection = new Map(); + for (const doc of shaped) { + const list = bySection.get(doc.section) ?? []; + list.push(doc); + bySection.set(doc.section, list); + } + + const nav: DocsNavSection[] = [...bySection.entries()] + .map(([id, docs]) => { + const first = docs[0]; + const items = [...docs] + .sort( + (a, b) => + a.sidebarPosition - b.sidebarPosition || + a.navLabel.localeCompare(b.navLabel), + ) + .map((d) => ({ id: d.id, label: d.navLabel })); + return { + id, + label: first.sectionLabel, + icon: sectionIcon(first.sectionLabel), + items, + _key: first.orderKey, + }; + }) + .sort( + (a, b) => compareKeys(a._key, b._key) || a.label.localeCompare(b.label), + ) + .map(({ _key, ...section }) => section); + + const docs: Record = {}; + for (const d of shaped) { + docs[d.id] = { + id: d.id, + title: d.title, + description: d.description, + section: d.section, + markdown: d.markdown, + sourcePath: d.sourcePath, + editUrl: d.editUrl, + }; + } + + return { + source: { repo: opts.repo, ref: opts.ref, root: opts.root }, + nav, + docs, + }; +} diff --git a/frontend/editor/src/portal/docs/search.test.ts b/frontend/editor/src/portal/docs/search.test.ts new file mode 100644 index 0000000000..84d7c53c76 --- /dev/null +++ b/frontend/editor/src/portal/docs/search.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from "vitest"; +import { + buildSnippet, + highlight, + searchDocs, + toPlainText, + type SearchDoc, +} from "@portal/docs/search"; + +const DOCS: SearchDoc[] = [ + { + id: "ocr", + title: "OCR Guide", + sectionLabel: "Configuration", + text: "Stirling PDF uses Tesseract for its text recognition and language packs.", + }, + { + id: "docker", + title: "Docker Install", + sectionLabel: "Installation", + text: "Run Stirling with docker compose up to start the container.", + }, + { + id: "ranky", + title: "Something else", + sectionLabel: "Misc", + text: "docker docker docker appears many times in the body here.", + }, +]; + +describe("toPlainText", () => { + it("strips headings, links, inline code, and code fences", () => { + const md = + "# Title\n\nSee [the guide](doc:x) and run `npm i`.\n\n```bash\nnpm run build\n```"; + const out = toPlainText(md); + expect(out).toContain("Title"); + expect(out).toContain("the guide"); + expect(out).toContain("npm i"); + expect(out).toContain("npm run build"); // code text kept, fences dropped + expect(out).not.toContain("#"); + expect(out).not.toContain("```"); + expect(out).not.toContain("]("); + }); +}); + +describe("highlight", () => { + it("splits text into hit/non-hit segments", () => { + expect(highlight("Hello world", ["world"])).toEqual([ + { text: "Hello ", hit: false }, + { text: "world", hit: true }, + ]); + }); + + it("is safe against regex metacharacters in terms", () => { + expect(() => highlight("a (b) c", ["("])).not.toThrow(); + }); +}); + +describe("buildSnippet", () => { + it("windows around the first match and marks it", () => { + const text = + "lorem ipsum ".repeat(20) + "the TARGET keyword " + "dolor ".repeat(20); + const segs = buildSnippet(text, ["target"]); + expect(segs.some((s) => s.hit && /target/i.test(s.text))).toBe(true); + // Windowed, so it should be far shorter than the full text. + expect(segs.map((s) => s.text).join("").length).toBeLessThan(text.length); + }); +}); + +describe("searchDocs", () => { + it("returns nothing for an empty query", () => { + expect(searchDocs(DOCS, " ")).toEqual([]); + }); + + it("matches body content, not just titles (with a snippet)", () => { + const res = searchDocs(DOCS, "tesseract"); + expect(res.map((r) => r.id)).toEqual(["ocr"]); + expect(res[0].snippet.some((s) => s.hit && /tesseract/i.test(s.text))).toBe( + true, + ); + }); + + it("ranks title matches above body matches", () => { + const res = searchDocs(DOCS, "docker"); + // "Docker Install" (title hit) outranks "Something else" (body-only hits). + expect(res[0].id).toBe("docker"); + expect(res.map((r) => r.id)).toContain("ranky"); + }); + + it("requires every term to match (AND)", () => { + expect(searchDocs(DOCS, "docker compose").map((r) => r.id)).toEqual([ + "docker", + ]); + expect(searchDocs(DOCS, "docker tesseract")).toEqual([]); + }); + + it("does not throw on regex-special-character queries", () => { + expect(() => searchDocs(DOCS, "a(b")).not.toThrow(); + }); +}); diff --git a/frontend/editor/src/portal/docs/search.ts b/frontend/editor/src/portal/docs/search.ts new file mode 100644 index 0000000000..17b586b4d8 --- /dev/null +++ b/frontend/editor/src/portal/docs/search.ts @@ -0,0 +1,153 @@ +/** + * Full-text search over the docs manifest. Pure + dependency-free so it's unit + * testable. Indexes each doc's title + plaintext body; ranks title matches above + * body matches; returns highlighted title + a content snippet per hit. + */ + +export interface SearchDoc { + id: string; + title: string; + sectionLabel: string; + /** Plaintext body (markdown stripped), original case. */ + text: string; +} + +/** A run of result text, flagged when it matches a query term (for ). */ +export interface Segment { + text: string; + hit: boolean; +} + +export interface SearchResult { + id: string; + title: string; + sectionLabel: string; + titleSegments: Segment[]; + snippet: Segment[]; + score: number; +} + +/** Strip markdown/MDX down to readable plaintext for indexing + snippets. */ +export function toPlainText(md: string): string { + return md + .replace(/^(```|~~~).*$/gm, " ") // fence delimiters (keep the code text) + .replace(/`([^`]+)`/g, "$1") // inline code + .replace(/!\[[^\]]*\]\([^)]*\)/g, " ") // images + .replace(/\[([^\]]+)\]\([^)]*\)/g, "$1") // links → their text + .replace(/^\s{0,3}>\s?/gm, "") // blockquotes + .replace(/^\s{0,3}#{1,6}\s+/gm, "") // headings + .replace(/^\s*[-*+]\s+/gm, "") // list bullets + .replace(/[*_~]/g, "") // emphasis marks + .replace(/\|/g, " ") // table pipes + .replace(/\s+/g, " ") // collapse whitespace + .trim(); +} + +function escapeRegExp(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function countOccurrences(haystack: string, needle: string): number { + let count = 0; + let i = haystack.indexOf(needle); + while (i !== -1) { + count++; + i = haystack.indexOf(needle, i + needle.length); + } + return count; +} + +/** Split `text` into segments, flagging any run that matches a query term. */ +export function highlight(text: string, terms: string[]): Segment[] { + const cleaned = terms.map(escapeRegExp).filter(Boolean); + if (!cleaned.length) return [{ text, hit: false }]; + const re = new RegExp(`(${cleaned.join("|")})`, "gi"); + const segments: Segment[] = []; + let last = 0; + let m: RegExpExecArray | null; + while ((m = re.exec(text)) !== null) { + if (m.index > last) { + segments.push({ text: text.slice(last, m.index), hit: false }); + } + segments.push({ text: m[0], hit: true }); + last = m.index + m[0].length; + if (m.index === re.lastIndex) re.lastIndex++; // guard against zero-width + } + if (last < text.length) segments.push({ text: text.slice(last), hit: false }); + return segments.length ? segments : [{ text, hit: false }]; +} + +/** Build a ~context-window snippet around the earliest term match. */ +export function buildSnippet( + text: string, + terms: string[], + radius = 90, +): Segment[] { + const lower = text.toLowerCase(); + let pos = -1; + for (const term of terms) { + const i = lower.indexOf(term); + if (i !== -1 && (pos === -1 || i < pos)) pos = i; + } + if (pos === -1) { + const head = text.slice(0, radius * 2); + return highlight(head + (text.length > head.length ? "…" : ""), terms); + } + let start = Math.max(0, pos - radius); + let end = Math.min(text.length, pos + radius); + // Snap to word boundaries so we don't slice mid-word. + if (start > 0) { + const space = text.indexOf(" ", start); + if (space !== -1 && space < pos) start = space + 1; + } + if (end < text.length) { + const space = text.lastIndexOf(" ", end); + if (space > pos) end = space; + } + let snippet = text.slice(start, end).trim(); + if (start > 0) snippet = "…" + snippet; + if (end < text.length) snippet = snippet + "…"; + return highlight(snippet, terms); +} + +/** + * Rank docs against a query. A doc matches when every term appears in its title + * or body; title hits score highest. + */ +export function searchDocs( + docs: SearchDoc[], + query: string, + limit = 40, +): SearchResult[] { + const terms = query.trim().toLowerCase().split(/\s+/).filter(Boolean); + if (!terms.length) return []; + + const results: SearchResult[] = []; + for (const doc of docs) { + const titleLower = doc.title.toLowerCase(); + const textLower = doc.text.toLowerCase(); + const matchesAll = terms.every( + (term) => titleLower.includes(term) || textLower.includes(term), + ); + if (!matchesAll) continue; + + let score = 0; + for (const term of terms) { + if (titleLower.includes(term)) score += 10; + if (titleLower.startsWith(term)) score += 5; + score += Math.min(countOccurrences(textLower, term), 5); + } + + results.push({ + id: doc.id, + title: doc.title, + sectionLabel: doc.sectionLabel, + titleSegments: highlight(doc.title, terms), + snippet: buildSnippet(doc.text, terms), + score, + }); + } + + results.sort((a, b) => b.score - a.score || a.title.localeCompare(b.title)); + return results.slice(0, limit); +} diff --git a/frontend/editor/src/portal/generated/docsManifest.json b/frontend/editor/src/portal/generated/docsManifest.json new file mode 100644 index 0000000000..1931a4d9af --- /dev/null +++ b/frontend/editor/src/portal/generated/docsManifest.json @@ -0,0 +1,989 @@ +{ + "source": { + "repo": "Stirling-Tools/Stirling-Tools.github.io", + "ref": "main", + "root": "docs" + }, + "nav": [ + { + "id": "overview", + "label": "Overview", + "icon": "▶", + "items": [ + { + "id": "getting-started", + "label": "Getting Started" + }, + { + "id": "server-admin-onboarding", + "label": "Production Deployment Guide" + }, + { + "id": "paid-offerings", + "label": "Paid Offerings" + }, + { + "id": "modes-and-licensing", + "label": "Modes" + }, + { + "id": "api", + "label": "API" + }, + { + "id": "contribute", + "label": "Contribution guidelines" + }, + { + "id": "faq", + "label": "FAQ" + }, + { + "id": "analytics-and-telemetry", + "label": "Analytics and Telemetry" + } + ] + }, + { + "id": "functionality", + "label": "Functionality", + "icon": "▤", + "items": [ + { + "id": "functionality/functionality", + "label": "PDF Tools" + }, + { + "id": "functionality/recommended-tools", + "label": "Recommended Tools" + }, + { + "id": "functionality/compare", + "label": "Compare PDFs" + }, + { + "id": "functionality/compress", + "label": "Compress PDF" + }, + { + "id": "functionality/features-pipeline", + "label": "Features - Pipeline / Automate" + }, + { + "id": "functionality/ocr", + "label": "OCR (Optical Character Recognition)" + }, + { + "id": "functionality/advanced-tools", + "label": "Advanced Tools" + }, + { + "id": "functionality/multi-tool", + "label": "Multi-Tool Workbench" + }, + { + "id": "functionality/read-and-annotate", + "label": "Read & Annotate PDFs" + }, + { + "id": "functionality/fill-form", + "label": "Fill Form" + }, + { + "id": "functionality/mobile-scanner", + "label": "Mobile Scanner" + }, + { + "id": "functionality/the-technologies", + "label": "Third-Party Credits" + } + ] + }, + { + "id": "functionality/convert", + "label": "Convert", + "icon": "⇋", + "items": [ + { + "id": "functionality/convert/convert", + "label": "Convert" + } + ] + }, + { + "id": "functionality/page-operations", + "label": "Page Operations", + "icon": "▦", + "items": [ + { + "id": "functionality/page-operations/page-operations", + "label": "Page Operations" + }, + { + "id": "functionality/page-operations/redact", + "label": "Redaction" + } + ] + }, + { + "id": "functionality/security", + "label": "Security", + "icon": "🛡", + "items": [ + { + "id": "functionality/security/certificate-signing", + "label": "Certificate Signing" + }, + { + "id": "functionality/security/security", + "label": "Features - Security" + }, + { + "id": "functionality/security/sign", + "label": "Sign PDF (Handwritten Signatures)" + }, + { + "id": "functionality/security/shared-signing", + "label": "Shared Signing" + } + ] + }, + { + "id": "functionality/content-editing", + "label": "Content & Editing", + "icon": "◇", + "items": [ + { + "id": "functionality/content-editing/content-editing", + "label": "Content & Editing" + } + ] + }, + { + "id": "installation", + "label": "Installation", + "icon": "⤓", + "items": [ + { + "id": "installation/versions", + "label": "Versions" + }, + { + "id": "installation/docker-install", + "label": "Docker Guide" + }, + { + "id": "installation/kubernetes", + "label": "Kubernetes Guide" + }, + { + "id": "installation/mac", + "label": "Mac Installation Guide" + }, + { + "id": "installation/unix", + "label": "Unix Installation Guide" + }, + { + "id": "installation/windows", + "label": "Windows Guide" + }, + { + "id": "installation/development-setup", + "label": "Development Setup Guide" + }, + { + "id": "installation/managed-deployment", + "label": "Managed Desktop Deployment" + }, + { + "id": "installation/path-structure", + "label": "Path Structure" + } + ] + }, + { + "id": "migration", + "label": "Migration from V1 to V2", + "icon": "⇄", + "items": [ + { + "id": "migration/overview", + "label": "Migrating from V1 to V2" + }, + { + "id": "migration/settings-changes", + "label": "Settings Changes from V1 to V2" + }, + { + "id": "migration/new-features", + "label": "New Features in V2" + }, + { + "id": "migration/breaking-changes", + "label": "Breaking Changes in V2" + } + ] + }, + { + "id": "configuration", + "label": "Configuration", + "icon": "⚙", + "items": [ + { + "id": "configuration/configuration", + "label": "Configuration Guide" + }, + { + "id": "configuration/system-and-security", + "label": "Login, System and Security" + }, + { + "id": "configuration/oauth-sso-configuration", + "label": "OAuth 2.0 Single Sign-On Configuration" + }, + { + "id": "configuration/single-sign-on-configuration", + "label": "Single Sign-On (SSO) Overview" + }, + { + "id": "configuration/ui-customisation", + "label": "UI Customisation" + }, + { + "id": "configuration/google-drive-file-picker", + "label": "Google Drive File Picker" + }, + { + "id": "configuration/usage-monitoring", + "label": "Usage Monitoring" + }, + { + "id": "configuration/endpoint-or-feature-customisation", + "label": "Endpoints Customisation" + }, + { + "id": "configuration/folderscanning", + "label": "Folder Scanning" + }, + { + "id": "configuration/ocr", + "label": "OCR (Optical Character Recognition)" + }, + { + "id": "configuration/fail2ban", + "label": "Fail2Ban Integration" + }, + { + "id": "configuration/external-database", + "label": "External Database" + }, + { + "id": "configuration/database", + "label": "Database Backups" + }, + { + "id": "configuration/pipeline", + "label": "Pipeline Automation (Automate)" + }, + { + "id": "configuration/extra-settings", + "label": "Custom Settings Configuration" + }, + { + "id": "configuration/sign-with-custom-files", + "label": "Visual Sign with Custom File Storage" + }, + { + "id": "configuration/file-sharing-and-storage", + "label": "File Sharing and Storage" + }, + { + "id": "configuration/other-customisations", + "label": "Other Customisations" + }, + { + "id": "configuration/telegram-bot", + "label": "Telegram Bot Integration" + }, + { + "id": "configuration/ssrf-protection", + "label": "SSRF Protection" + }, + { + "id": "configuration/process-limits", + "label": "Process Limits" + }, + { + "id": "configuration/audit-logging", + "label": "Audit Logging" + }, + { + "id": "configuration/keyboard-shortcuts", + "label": "Keyboard Shortcuts" + }, + { + "id": "configuration/mobile-scanner", + "label": "Mobile Scanner Configuration" + }, + { + "id": "configuration/diagnostics", + "label": "Diagnostics & Reporting Issues" + }, + { + "id": "configuration/libreoffice-parallel-processing", + "label": "LibreOffice Parallel Processing" + }, + { + "id": "configuration/performance-optimization", + "label": "Performance Optimization & Sizing" + } + ] + }, + { + "id": "configuration/saml-sso-configuration", + "label": "SAML SSO Configuration", + "icon": "⚙", + "items": [ + { + "id": "configuration/saml-sso-configuration/saml-sso-configuration", + "label": "SAML 2.0 Single Sign-On Configuration" + } + ] + }, + { + "id": "advanced-configuration", + "label": "Advanced Configuration", + "icon": "⚙", + "items": [ + { + "id": "advanced-configuration/other-customisations", + "label": "Other Customisations" + }, + { + "id": "advanced-configuration/mcp-server", + "label": "MCP Server" + }, + { + "id": "advanced-configuration/pdf-to-cbr-conversion", + "label": "Enabling PDF to CBR Conversion in Stirling PDF" + } + ] + } + ], + "docs": { + "api": { + "id": "api", + "title": "API", + "description": "Overview of API offering in S-PDF", + "section": "overview", + "markdown": "## Stirling PDF API\n\nStirling PDF exposes a simple API for easy integration with external scripts. You can access the API documentation in two ways:\n\n1. Local Swagger UI at `/swagger-ui.html` on your Stirling PDF instance\n2. Online [Swagger Documentation](https://app.swaggerhub.com/apis-docs/Frooodle/Stirling-PDF/)\n\nYou can also access the documentation through the settings menu (gear icon in the top-right corner).\n\n## Accessing API Documentation\n\n### Local Swagger UI\nYour Stirling PDF instance includes built-in API documentation:\n1. Navigate to `http://your-instance:port/swagger-ui.html`\n2. Or append `/swagger-ui.html` to your Stirling PDF URL\n3. This provides an interactive documentation interface where you can:\n - View all available endpoints\n - Test API calls directly\n - See request/response schemas\n - View authentication requirements\n\n### Settings Menu Access\n1. Click the gear icon (⚙️) in the top-right corner\n2. Look for the \"API Documentation\" or \"API\" link\n3. This will take you to the local Swagger UI\n\n## API Authentication\n\nWhen security is enabled, all API requests require authentication. There are two ways to handle API authentication:\n\n### User-Specific API Keys\n1. Obtain your API key:\n - Log into Stirling PDF\n - Go to Account Settings (via the gear icon)\n - Find your API key in the account details\n\n### Global API Key\nYou can set a custom global API key using the environment variable:\n```bash\nSECURITY_CUSTOMGLOBALAPIKEY=your-custom-api-key\n```\nThis allows you to set a single API key that works regardless of user authentication.\n\n2. Include the API key in all requests:\n ```http\n X-API-KEY: your-api-key-here\n ```\n\n3. Example authenticated request:\n ```bash\n curl -X POST \"http://localhost:8080/api/v1/security/add-watermark\" \\\n -H \"X-API-KEY: your-api-key-here\" \\\n -H \"Content-Type: multipart/form-data\" \\\n ...\n ```\n\n## Endpoint Paths\n\n> **ℹ️ Info**\n>\n> Every operation lives under `/api/v1//`, where the category is one of `security`, `general`, `misc`, `convert`, etc. For example, \"Add Watermark\" is at `/api/v1/security/add-watermark`. The exact path for any operation is shown in the [Swagger UI](#local-swagger-ui).\n\n\n### AI assistants / MCP\n\nTo drive these endpoints from an AI assistant (Claude Desktop, Cursor, etc.) over the Model Context Protocol, see [MCP Server](doc:advanced-configuration/mcp-server).\n\n## API Limitations\n\nStirling PDF's feature set is not entirely confined to the backend, hence not all functionalities are accessible via the API. Certain operations, such as the \"view-pdf\" or \"visually sign\", are executed exclusively on the front-end, and as such, they are only available through the Web-UI. If you encounter a situation where some API endpoints appear to be absent, it is likely attributable to these front-end exclusive features.\n\nStirling PDF also has statistic and health endpoints to integrate with monitoring/dashboard applications.\n\n## Example CURL Commands\n\n\n \n ```bash\n curl -X POST \"http://localhost:8080/api/v1/security/add-watermark\" \\\n -H \"Content-Type: multipart/form-data\" \\\n -F \"fileInput=@/Users/username/Downloads/sample-1_cropped.pdf\" \\\n -F \"watermarkType=text\" \\\n -F \"watermarkText=YOUR_WATERMARK_TEXT\" \\\n -F \"alphabet=roman\" \\\n -F \"fontSize=30\" \\\n -F \"rotation=0\" \\\n -F \"opacity=0.5\" \\\n -F \"widthSpacer=50\" \\\n -F \"heightSpacer=50\" \\\n > \"/Users/username/Downloads/output.pdf\"\n ```\n \n \n ```bash\n curl -X POST \"http://localhost:8080/api/v1/security/add-watermark\" ^\n -H \"Content-Type: multipart/form-data\" ^\n -F \"fileInput=@C:\\Users\\systo\\Downloads\\sample-1_cropped.pdf\" ^\n -F \"watermarkType=text\" ^\n -F \"watermarkText=YOUR_WATERMARK_TEXT\" ^\n -F \"alphabet=roman\" ^\n -F \"fontSize=30\" ^\n -F \"rotation=0\" ^\n -F \"opacity=0.5\" ^\n -F \"widthSpacer=50\" ^\n -F \"heightSpacer=50\" ^\n > \"C:\\Users\\systo\\Downloads\\output.pdf\"\n ```\n \n\n\n## Integrations (n8n, Zapier, Make, Power Automate, etc.)\n\nStirling PDF does not ship dedicated plugins or nodes for any specific automation platform. Integration is **via the REST API** documented above, which works with any tool that can make an authenticated HTTP request.\n\n### What this looks like in practice\n\n- **n8n**: use the built-in [HTTP Request node](https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.httprequest/) pointed at your Stirling PDF instance. Set method `POST`, content type `multipart/form-data`, attach the binary as `fileInput`, add the `X-API-KEY` header, and connect the binary output to a `Read/Write Binary File` node or onward to your storage.\n- **Zapier / Make / Power Automate**: use the generic HTTP / Webhooks action with the same multipart pattern.\n- **Home Assistant**: a `rest_command` definition pointing at the appropriate Stirling endpoint.\n- **Bash / Python / JavaScript**: any HTTP client (`curl`, `requests`, `fetch`) - the curl examples on this page translate directly.\n\n### Common automation recipe: chain multiple operations in one call\n\nRather than wiring 5 separate HTTP nodes for \"OCR then compress then watermark then sign...\", you could use the **pipeline endpoint** to chain everything in one request:\n\n- Endpoint: `POST /api/v1/pipeline/handleData`\n- Request: multipart with one or more `fileInput` parts plus a `json` field containing the full pipeline configuration\n- Response: a single processed file, or a ZIP if the pipeline produced multiple outputs\n\nFull schema, operation list, parameter reference, and curl examples: see **[Pipeline Automation](doc:configuration/pipeline)**.\n\n### Building the pipeline JSON\n\nThe fastest path is:\n1. Build the workflow visually in the **Automate** tool inside Stirling PDF.\n2. Click **Export for Folder Scanning** in the save panel - this produces the JSON in the format the API expects.\n3. Drop that JSON into your automation tool's HTTP request as the `json` form field.\n\nRe-import the same file later via the Automate UI's import dialog to round-trip workflows between machines.\n\n### Authentication for automation tools\n\nWhen security is enabled, set a global API key once via `SECURITY_CUSTOMGLOBALAPIKEY=` and reference it from your automation tool as the `X-API-KEY` header. This avoids needing per-user logins from headless scripts.", + "sourcePath": "docs/API.md", + "editUrl": "https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/API.md" + }, + "advanced-configuration/mcp-server": { + "id": "advanced-configuration/mcp-server", + "title": "MCP Server", + "description": "Expose Stirling PDF's tools to MCP clients over a built-in Model Context Protocol server", + "section": "advanced-configuration", + "markdown": "Stirling PDF ships a built-in [Model Context Protocol (MCP)](https://modelcontextprotocol.io) server. MCP is the open standard MCP clients (Claude Desktop, the MCP Inspector, IDE agents, and custom tools) use to discover and call tools on a remote server. When enabled, Stirling PDF exposes its PDF operations as MCP tools so an MCP-capable assistant can run them on your behalf.\n\nThe MCP server is built into the Stirling PDF self-hosted server and the desktop app in Local / Self-hosted modes. It is **off by default** and must be enabled and configured per deployment.\n\n> **ℹ️ Info: Self-hosted capability**\n>\n> This page documents the MCP server you run on your own Stirling PDF instance. The per-user MCP tab in Stirling Cloud is a separate, cloud-only surface and is not covered here. For where each deployment mode applies, see [Modes](doc:modes-and-licensing).\n\n\n---\n\n## Enable the server\n\nThe MCP server runs only when `mcp.enabled` is `true`. While off, there is no `/mcp` endpoint and no MCP metadata.\n\nTurn it on either way:\n\n- **Settings file / environment variable**: set `mcp.enabled: true` in `settings.yml`, or the environment variable `MCP_ENABLED=true`. A restart applies file edits.\n- **Admin UI**: open **Admin Settings → MCP Server**. This page is shown to admins, and on instances where login is disabled. Saving prompts for a restart.\n\nEnabling alone is not enough - you also need to choose and configure an [authentication mode](#authentication) before clients can call tools.\n\n---\n\n## Transport and protocol\n\n- **Endpoint**: `POST /mcp` on the same host and port as the rest of Stirling PDF.\n- **Protocol**: JSON-RPC 2.0 over streamable-HTTP.\n- **Supported MCP protocol versions**: `2025-06-18` (preferred), `2025-03-26`, and `2024-11-05`. The server echoes the client's requested version when it is supported, otherwise it advertises the preferred version.\n\n---\n\n## Tools exposed\n\nThe server presents a small set of category tools rather than one tool per operation. An MCP client lists them, then drills into a specific PDF operation using `stirling_describe_operation`.\n\n| Tool | Purpose |\n|---|---|\n| `stirling_describe_operation` | Look up the parameters and JSON schema for a specific operation id. |\n| `stirling_pages` | Page-level operations (merge, split, rotate, reorder, add blank pages, and similar). |\n| `stirling_convert` | Conversions to and from PDF (images, office formats, and similar). |\n| `stirling_misc` | Miscellaneous utilities (compress, flatten, repair, and similar). |\n| `stirling_security` | Security operations (encrypt, decrypt, permissions, and similar). |\n| `stirling_upload` | Store a file server-side and get back a `fileId` for large inputs. |\n| `stirling_download` | Fetch a result that was returned by reference rather than inline. |\n| `stirling_ai` | AI-engine capabilities. **Not usable in self-hosted - see the caveat below.** |\n\n> **⚠️ Warning: `stirling_ai` requires a Stirling Cloud AI engine**\n>\n> `stirling_ai` only has capabilities when a Stirling AI engine is configured. The AI engine is a Stirling Cloud feature and is **not available in self-hosted** today, so on a self-hosted server `stirling_ai` exposes nothing and only the PDF tools above are usable over MCP.\n\n\n---\n\n## Authentication\n\nPick one of two modes with `mcp.auth.mode`.\n\n### OAuth2 resource server (`oauth`, default)\n\nIn OAuth mode the `/mcp` endpoint runs as an OAuth2 resource server: it validates incoming JWTs (signature, issuer, expiry, and audience) and binds each token to an existing Stirling account. It publishes RFC 9728 protected-resource metadata at `/.well-known/oauth-protected-resource` so MCP clients can discover the authorization server.\n\n| Key | Env | Default | Purpose |\n|---|---|---|---|\n| `mcp.auth.issuerUri` | `MCP_AUTH_ISSUERURI` | empty | OAuth2 issuer URI (e.g. `http://localhost:9000`). **Required** in OAuth mode; every token is rejected until it is set. |\n| `mcp.auth.jwksUri` | `MCP_AUTH_JWKSURI` | empty | JWKS URI. Blank means it is derived from the issuer's `/.well-known/openid-configuration`. |\n| `mcp.auth.resourceId` | `MCP_AUTH_RESOURCEID` | empty | RFC 8707 resource identifier of this server. Must equal the public `/mcp` URL clients call and **end in `/mcp`** (e.g. `http://localhost:8080/mcp`). Tokens that do not list it in `aud` are rejected. |\n| `mcp.auth.acceptedAudiences` | `MCP_AUTH_ACCEPTEDAUDIENCES` | `[]` | Extra `aud` values accepted on top of `resourceId`. Empty keeps strict RFC 8707 binding. Use this for IdPs that cannot mint a resource-specific audience (e.g. Supabase always issues `aud=authenticated`). |\n| `mcp.auth.usernameClaim` | `MCP_AUTH_USERNAMECLAIM` | `sub` | JWT claim matched against a Stirling username. Set to `email` or `preferred_username` if your IdP maps users differently. |\n| `mcp.auth.requireExistingAccount` | `MCP_AUTH_REQUIREEXISTINGACCOUNT` | `true` | Reject tokens whose subject has no enabled Stirling account. Keep `true` unless you intend open access for any IdP-valid token. |\n| `mcp.scopesEnabled` | `MCP_SCOPESENABLED` | `true` | Enforce the `mcp.tools.read` / `mcp.tools.write` scopes. Read-style tools require `mcp.tools.read`; mutating operations require `mcp.tools.write`. Set `false` only if your IdP can issue a single coarse token. |\n\nEach validated token is bound to the matching Stirling account so that audit and attribution are correct.\n\n### API key (`apikey`)\n\nAPI-key mode is the low-friction option for self-hosters with no external identity provider. Set `mcp.auth.mode: apikey` (env `MCP_AUTH_MODE=apikey`) and clients authenticate with an existing per-user Stirling API key.\n\nSend the key as either header:\n\n```text\nX-API-KEY: \n```\n\nor\n\n```text\nAuthorization: Bearer \n```\n\nThe key must belong to an existing, enabled account (generate one under **Account → API Keys** - see [API documentation](doc:api)). No external IdP, OAuth, or JWKS configuration is needed.\n\n---\n\n## Restrict which operations are exposed\n\nTwo MCP-level lists control which operations clients can see and call. They use the same kebab-case operation ids as the [Endpoint or Feature Customisation](doc:configuration/endpoint-or-feature-customisation) page (e.g. `compress-pdf`).\n\n| Key | Env | Default | Behaviour |\n|---|---|---|---|\n| `mcp.allowedOperations` | `MCP_ALLOWEDOPERATIONS` | `[]` | When **non-empty**, acts as a strict allow-list - only these operations are exposed over MCP; everything else is hidden, undescribable, and uninvocable. Empty means allow all. |\n| `mcp.blockedOperations` | `MCP_BLOCKEDOPERATIONS` | `[]` | A deny-list. Anything listed is always removed, applied **after** the allow-list, so a blocked id wins even if it is also allowed. |\n\nThese lists layer **on top of** the global [`endpoints.toRemove` / `endpoints.groupsToRemove`](doc:configuration/endpoint-or-feature-customisation) configuration. An operation disabled globally is never exposed over MCP regardless of these lists.\n\n---\n\n## Limits\n\n| Key | Env | Default | Purpose |\n|---|---|---|---|\n| `mcp.maxRequestBytes` | `MCP_MAXREQUESTBYTES` | 10 MB | Maximum MCP request body size. Inline file uploads ride in the JSON-RPC body, so this caps how large an inline input can be. |\n| `mcp.maxInlineResponseBytes` | `MCP_MAXINLINERESPONSEBYTES` | 10 MB | Results up to this size return inline as base64; larger results return a `fileId` instead, which the client fetches with `stirling_download`. |\n| `mcp.engineCapabilityRefreshMinutes` | `MCP_ENGINECAPABILITYREFRESHMINUTES` | `5` | How often the AI capabilities manifest is refreshed from the engine (only relevant when an AI engine is available). |\n\n---\n\n## Troubleshooting and operability\n\n- **Startup config validation**: when MCP is enabled, the server validates the resolved config at boot and logs findings. Misconfiguration (missing issuer, a `resourceId` that does not end in `/mcp`, a `username-claim` of `sub` with `require-existing-account=true`, and similar) is logged as a warning so it surfaces in the logs instead of as a later rejected-token `401`.\n- **Meaningful `401` responses**: a rejected OAuth token returns a `WWW-Authenticate` header carrying a real `error_description` (audience, issuer, or expiry mismatch), plus a `resource_metadata` pointer for discovery. A tokenless `401` is the normal discovery handshake, not an error.\n- **Audit logging**: MCP calls are attributed to the bound Stirling account, and no secret is written to the audit log.\n\n---\n\n## Connect a client\n\nPoint any MCP client at `http://your-host:8080/mcp` (use your real host, port, and scheme).\n\n**MCP Inspector** (quick manual testing):\n\n```bash\nnpx @modelcontextprotocol/inspector\n```\n\nThen set the transport to streamable-HTTP and the URL to your `/mcp` endpoint, adding the appropriate auth header (`X-API-KEY` in API-key mode, or a Bearer token in OAuth mode).\n\n**Claude Desktop** via the `mcp-remote` bridge - add to your Claude Desktop config:\n\n```json\n{\n \"mcpServers\": {\n \"stirling-pdf\": {\n \"command\": \"npx\",\n \"args\": [\n \"-y\",\n \"mcp-remote\",\n \"http://your-host:8080/mcp\",\n \"--header\",\n \"X-API-KEY:your-stirling-api-key\"\n ]\n }\n }\n}\n```\n\nIn OAuth mode, drop the `X-API-KEY` header and let `mcp-remote` complete the OAuth flow against your configured issuer.\n\n---\n\n## Related Documentation\n\n- **[API documentation](doc:api)** - generate the per-user API key used in API-key mode\n- **[Endpoint or Feature Customisation](doc:configuration/endpoint-or-feature-customisation)** - the operation ids and global enable/disable config the MCP lists build on\n- **[Modes](doc:modes-and-licensing)** - where each deployment mode and feature applies", + "sourcePath": "docs/Advanced Configuration/MCP-Server.md", + "editUrl": "https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/Advanced Configuration/MCP-Server.md" + }, + "advanced-configuration/other-customisations": { + "id": "advanced-configuration/other-customisations", + "title": "Other Customisations", + "section": "advanced-configuration", + "markdown": "Stirling PDF offers various other customisation options, such as:\n\n### Defaulting Language\nDefault language selection via the `SYSTEM_DEFAULTLOCALE` environment variable. Accepted values include `de-DE`, `fr-FR`, `ar-AR` and all other languages codes that are within Stirling PDFs current list.\n\n### Google Search Visibility (robots.txt)\nEnable or disable search engine visibility with the `ALLOW_GOOGLE_VISIBILITY` variable.\n\n### Custom Root path\nRedirect the root path of the application using `APP_ROOT_PATH`.\nThis is for changing websites like stirlingtools.com to instead host the interface at stirlingtools.com/`APP_ROOT_PATH` like stirlingtools.com/demo\n\n### Enable/Disable Analytics\nAnalytics can be enabled/disabled with ``SYSTEM_ENABLEANALYTICS`` or\n```yaml\nsystem:\n enableAnalytics: 'true'\n```\nIn configs/Settings.yml\n\n### Using an outgoing HTTP(S) proxy\nTo make Stirling PDF use an outgoing proxy server (e.g. for checking the license validity):\n\n\n \n ```bash\n JAVA_CUSTOM_OPTS=\"-Dhttp.proxyHost=proxyserver -Dhttp.proxyPort=8888 -Dhttp.nonProxyHosts='localhost|127.0.0.1|127.0.1.1|127.0.0.0/8|::1|10.0.0.0/8|.svc|.cluster.local' -Dhttps.proxyHost=proxyserver -Dhttps.proxyPort=8888 -Dhttps.nonProxyHosts='localhost|127.0.0.1|127.0.1.1|127.0.0.0/8|::1|10.0.0.0/8|.svc|.cluster.local'\"\n ```\n \n \n ```bash\n docker run -d \\\n -p 8080:8080 \\\n -e JAVA_CUSTOM_OPTS=\"-Dhttp.proxyHost=proxyserver -Dhttp.proxyPort=8888 -Dhttp.nonProxyHosts='localhost|127.0.0.1|127.0.1.1|127.0.0.0/8|::1|10.0.0.0/8|.svc|.cluster.local' -Dhttps.proxyHost=proxyserver -Dhttps.proxyPort=8888 -Dhttps.nonProxyHosts='localhost|127.0.0.1|127.0.1.1|127.0.0.0/8|::1|10.0.0.0/8|.svc|.cluster.local'\" \\\n stirlingtools/stirling-pdf:latest\n ```\n \n \n ```yaml\n services:\n stirling-pdf:\n image: stirlingtools/stirling-pdf:latest\n environment:\n JAVA_CUSTOM_OPTS: \"-Dhttp.proxyHost=proxyserver -Dhttp.proxyPort=8888 -Dhttp.nonProxyHosts='localhost|127.0.0.1|127.0.1.1|127.0.0.0/8|::1|10.0.0.0/8|.svc|.cluster.local' -Dhttps.proxyHost=proxyserver -Dhttps.proxyPort=8888 -Dhttps.nonProxyHosts='localhost|127.0.0.1|127.0.1.1|127.0.0.0/8|::1|10.0.0.0/8|.svc|.cluster.local'\"\n ```", + "sourcePath": "docs/Advanced Configuration/Other Customisations.md", + "editUrl": "https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/Advanced Configuration/Other Customisations.md" + }, + "advanced-configuration/pdf-to-cbr-conversion": { + "id": "advanced-configuration/pdf-to-cbr-conversion", + "title": "Enabling PDF to CBR Conversion in Stirling PDF", + "section": "advanced-configuration", + "markdown": "## Overview\n\nStirling PDF can convert PDF files into the Comic Book RAR (`.cbr`) format. This process relies on an external command-line utility, `rar`, which is not included by default. To enable this feature, you must first install the `rar` utility on your system and then make it accessible to Stirling PDF.\n\n### What is a CBR file?\n\nA CBR (Comic Book RAR) file is an archive used for distributing digital comic books. It is essentially a collection of sequential image files (e.g., JPEG, PNG) compressed into a single file using RAR compression.\n\nWhile CBR is a popular format, it requires the proprietary `rar` utility for creation. Its more common, open-standard alternative is CBZ (Comic Book ZIP), which is supported by Stirling PDF out of the box.\n\n-----\n\n## Step 1: Install the `rar` Command-Line Utility\n\nThis is a mandatory prerequisite for both Docker and non-Docker setups. The `rar` executable must be installed on the host machine.\n\n### Linux\n\nThe easiest method is to use your distribution's package manager.\n\n**Debian / Ubuntu:**\nThe `rar` package is available in the `non-free` repository.\n\n```bash\nsudo apt update\nsudo apt install rar\n```\n\n**Fedora / CentOS / RHEL:**\nThe `rar` package is available in the RPM Fusion \"non-free\" repository.\n\n```bash\n# First, enable the RPM Fusion non-free repository for your system.\n# See https://rpmfusion.org/Configuration for instructions.\n\n# Then, install rar\nsudo dnf install rar # For Fedora, RHEL 8+, CentOS Stream\n# or\nsudo yum install rar # For CentOS 7\n```\n\n**Manual Installation (Any Linux Distribution):**\n\n1. Visit the official download page: [rarlab.com/download.htm](https://www.rarlab.com/download.htm).\n2. Download the \"RAR for Linux x64\" command-line version.\n3. Extract the archive and install the binary:\n ```bash\n # The version number (e.g., 712, as of writing this guide) will change.\n # Use the actual filename.\n tar -xzf rarlinux-x64-*.tar.gz\n\n # Move the binary to a standard location in your system's PATH\n sudo mv rar/rar /usr/local/bin/\n\n # Ensure it has execute permissions\n sudo chmod +x /usr/local/bin/rar\n ```\n\n### Windows\n\n1. Download the \"WinRAR and RAR command line tools\" from [rarlab.com/download.htm](https://www.rarlab.com/download.htm).\n2. Extract the downloaded archive.\n3. Copy the `rar.exe` file to a folder that is included in your system's `PATH` environment variable. A common and reliable location is `C:\\Windows\\System32`.\n4. If Stirling PDF is already running, restart it to ensure it recognizes the updated `PATH`.\n\n### macOS\n\nThe recommended method is to use the [Homebrew](https://brew.sh/) package manager.\n\n```bash\nbrew install rar\n```\n\n-----\n\n## Step 2: Configure Stirling PDF\n\nAfter installing `rar` on your host system, follow the appropriate instructions for your environment.\n\n### For Non-Docker Users\n\nIf you installed Stirling PDF directly on your operating system (without Docker), no further configuration is needed. As long as the `rar` command is available in your system's `PATH`, Stirling PDF will automatically (after restart) detect and use it.\n\n### For Docker Users\n\nFor the binary to be accessible inside the container, you have to mount the binary as a volume.\n\nUpdate your `docker-compose.yml` to include the volume mount. The path on the host side must match where you installed `rar`.\n\n```yaml\nservices:\n stirling-pdf:\n image: docker.stirlingpdf.com/stirlingtools/stirling-pdf:latest\n ports:\n - '8080:8080'\n volumes:\n - ./StirlingPDF/trainingData:/usr/share/tessdata\n - ./StirlingPDF/extraConfigs:/configs\n - ./StirlingPDF/customFiles:/customFiles/\n - ./StirlingPDF/logs:/logs/\n - ./StirlingPDF/pipeline:/pipeline/\n # Add the following line to mount the rar binary\n - /usr/local/bin/rar:/usr/local/bin/rar:ro\n```\n\n**Note for Windows Docker Users:**\nThe host path must use forward slashes. For example, if you placed `rar.exe` in `C:\\Program Files\\RAR`, your volume mount would look like this:\n\n```yaml\n# Example for Windows host path\n- \"C:/Program Files/RAR/rar.exe:/usr/local/bin/rar:ro\"\n```\n\n-----\n\n## Step 3: Verification\n\nConfirm that Stirling PDF can access the `rar` command.\n\n* **For Docker Users:** Execute a command inside the running container.\n\n ```bash\n docker exec -it stirling-pdf rar\n ```\n\n* **For Non-Docker Users:** Check if the `rar` command is recognized in your terminal.\n\n ```bash\n # On Linux and macOS\n which rar\n\n # On Windows\n where rar\n ```\n\nIn both cases, a successful setup will display the RAR version and usage information. An error like \"command not found\" means there is a problem with the installation or `PATH`.\n\n-----\n\n## Important Considerations\n\n### License Note\n\nRAR is shareware. While it is free to use for personal, non-commercial purposes, business or commercial use may require purchasing a license. Please review the official RAR license terms on the RARLAB website for complete details.\n\n### Alternative: Use the CBZ Format\n\nFor broader compatibility and to avoid proprietary software, using the **CBZ (Comic Book ZIP)** format is highly recommended.\n\n* CBZ uses the open and universal ZIP standard.\n* The **PDF to CBZ** tool is enabled in Stirling PDF by default and requires no extra software.\n* CBZ is supported by virtually all modern comic book reader applications.", + "sourcePath": "docs/Advanced Configuration/PDF to CBR Conversion.md", + "editUrl": "https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/Advanced Configuration/PDF to CBR Conversion.md" + }, + "analytics-and-telemetry": { + "id": "analytics-and-telemetry", + "title": "Analytics and Telemetry", + "section": "overview", + "markdown": "> Please note all the following applies to version 1.5.0 onward due to be released 16th October\n\n\nStirling‑PDF uses analytics to understand usage patterns and improve the application. This page explains what data is collected, why we collect it, and how to disable analytics if desired.\n\n> **User control**: All analytics are **opt‑in via a consent banner** (disabled until a user allows it). A self‑hosted administrator can also turn all analytics off system‑wide. If analytics are disabled system‑wide, no banner is shown.\n\n## Overview\n\nStirling‑PDF uses two analytics services:\n\n1. **[Scarf](https://scarf.sh)** - a privacy‑friendly tool designed for open‑source projects.\n2. **[PostHog](https://posthog.com)** - an open‑source product analytics platform for detailed usage insights.\n\nBoth services are designed with privacy in mind and can be completely disabled.\n\n---\n\n## PostHog Analytics\n\n### What is PostHog?\n\nPostHog is an open‑source product analytics platform that provides detailed insights into how users interact with Stirling‑PDF. It's hosted on PostHog's European servers (`eu.i.posthog.com`) for GDPR alignment.\n\n### Data collected by PostHog\n\nPostHog collects comprehensive system and usage information **only when analytics are enabled and consented**:\n\n#### System information\n- Operating system name and version\n- Java version and vendor\n- CPU cores and memory allocation\n- Deployment type (Docker, JAR, EXE)\n- Docker/Kubernetes environment details (if applicable)\n- Timezone and locale settings\n\n#### Application configuration\n- Security settings (login enabled, OAuth/SAML configuration status)\n- UI customization settings\n- Feature flags and enabled functionality\n- Legal document URLs (terms, privacy policy, etc.)\n- System limits and quotas\n\n#### Usage data\n- Aggregate counts (e.g., total number of user accounts created)\n- Feature/tool usage (which tools/operations are used)\n- Error tracking\n- Browser and device information (for the web interface)\n\n**Important privacy notes**:\n- **No document content, PDF data, or file metadata is ever collected or transmitted.**\n- PostHog is configured with:\n - `opt_out_capturing_by_default: true`\n - `mask_all_text: true`\n - `mask_all_element_attributes: true`\n- Users must accept cookies before any data is captured.\n- Data is stored on EU servers.\n- Each instance has a unique UUID (not tied to individuals).\n\n### Why we use PostHog\n\nPostHog shows us which features get used, helps us catch bugs, and guides what to build next.\n\n---\n\n\n## Scarf\n\n### What is Scarf?\n\n[Scarf](https://scarf.sh) provides a simple tracking pixel (`pixel.stirling.com`) that collects basic, non‑personally identifiable information about Stirling‑PDF usage.\n\n### Data collected by Scarf\n\nThe Scarf pixel collects the following information:\n\n- **Machine Type**: Deployment type (Docker, JAR, or EXE)\n- **App Version**: The version of Stirling‑PDF you're running\n- **License Type**: Whether you're using Community or Enterprise edition\n- **Login Enabled**: Whether authentication is enabled\n- **Page Endpoint Loaded**: Which Stirling‑PDF page was loaded (e.g., `/split-pdf`)\n\n**Important**: The Scarf pixel does **not** collect or store:\n- Personal information (PII)\n- IP addresses (IP addresses are not stored)\n- User‑specific identifiers\n- Document content or file metadata\n- Fine‑grained user behavior beyond which page/endpoint was loaded\n\n### Why we use Scarf\n\nScarf gives us a rough idea of how Stirling-PDF is deployed and which pages are reached, so we can prioritise work and keep it compatible across setups.\n\n### How to disable Scarf\n\nScarf is opt‑in by default (via the cookie consent banner). To disable the Scarf tracking pixel **system‑wide** and suppress the banner for it:\n\n**Environment variable**\n```bash\nSYSTEM_ENABLESCARF=false\n```\n\n**settings.yml**\n```yaml\nsystem:\n enableScarf: false\n```\n\n---\n\n\n\n## Configuration and Control\n\nAnalytics are governed by a **global master toggle**, **component toggles**, and the **cookie consent banner**.\n\n### 1) Global analytics toggle (master switch)\n\nControls **all** analytics and whether a consent banner appears.\n\n\n \n ```yaml\n system:\n enableAnalytics: false # true | false | null (unset)\n ```\n \n \n ```bash\n SYSTEM_ENABLEANALYTICS=false # true | false | (unset = null)\n ```\n \n \n ```yaml\n services:\n stirling-pdf:\n environment:\n SYSTEM_ENABLEANALYTICS: false\n ```\n \n\n\n**Behavior**\n- `false`: Disables **all** analytics (no consent banner; PostHog & Scarf are off).\n- `true`: Allows analytics (banner still required for user consent before any capture).\n- `null`/unset: **First‑run admin choice** - on the first ever connection to a self‑hosted instance, the first visitor (assumed admin) is prompted to choose, and that choice sets the global behavior for all users (either disabling analytics or enabling the consent banner for others).\n\n### 2) Component toggles\n\nUse these to selectively enable/disable providers **in addition** to the global toggle.\n\n**PostHog:**\n\n\n \n ```yaml\n system:\n enablePosthog: false # true | false | null\n ```\n \n \n ```bash\n SYSTEM_ENABLEPOSTHOG=false\n ```\n \n\n\n**Scarf tracking pixel:**\n\n\n \n ```yaml\n system:\n enableScarf: false # true | false | null\n ```\n \n \n ```bash\n SYSTEM_ENABLESCARF=false\n ```\n \n\n\n**Interaction**\n- If `enableAnalytics` is `false`, everything is off regardless of component toggles.\n- If `enableAnalytics` is `true`/`null`, the consent banner is shown (see below). After consent:\n - PostHog runs only if `enablePosthog` is `true`/`null` **and** the user consented.\n - Scarf runs only if `enableScarf` is `true`/`null` **and** the user consented.\n\n### 3) Cookie consent banner\n\nWhen analytics are allowed globally (`system.enableAnalytics: true` or resolved via the first‑run admin choice), users see a cookie consent banner on their first visit. Users can:\n\n- **Accept all** → enables PostHog (if `enablePosthog` is `true`/`null`) and Scarf (if `enableScarf` is `true`/`null`)\n- **Accept only necessary** → disables PostHog and Scarf\n- **Customize** → granular selection where applicable\n\n**User control details**\n- Users can change preferences at any time.\n- Consent choices are stored locally in the user's browser.\n- PostHog and Scarf respect the consent decision immediately.\n- No tracking occurs until explicit consent is given.\n\n---\n\n## Complete analytics disable (shortcut)\n\nIf you want to disable **all** analytics and telemetry (and suppress any consent prompts) at once:\n\n\n \n ```yaml\n system:\n enableAnalytics: false\n ```\n \n \n ```bash\n SYSTEM_ENABLEANALYTICS=false\n ```\n \n \n ```yaml\n services:\n stirling-pdf:\n environment:\n SYSTEM_ENABLEANALYTICS: false\n ```\n \n\n\n---\n\n## Privacy and Data Security\n\n### Data retention\n- PostHog data is retained according to PostHog’s configured retention policies.\n- Scarf pixel data is aggregated and anonymized.\n- No personal documents or content are ever transmitted.\n\n### GDPR alignment\n- PostHog servers are located in the EU.\n- Cookie consent is required before tracking.\n- Users can opt out at any time.\n- No cross‑site tracking or fingerprinting.\n- Text and element masking helps prevent accidental PII collection.\n\n### Transparency\n- All analytics‑related code is open source and visible in the repository.\n- Analytics can be completely disabled with simple configuration changes.\n- Users have full control over their data via cookie preferences (when analytics are allowed globally).\n\n---\n\n## For Self‑Hosted Instances\n\nIf you're running Stirling‑PDF on your own infrastructure:\n\n1. **Private networks**: Analytics from self‑hosted instances help us understand deployment patterns but don't expose your internal network.\n2. **Air‑gapped environments**: Disable analytics; the application works perfectly without external connections.\n3. **Corporate environments**: Disable analytics if your security policy requires it, or allow it to help improve the product.\n\n---\n\n## Support\n\nIf you have questions or concerns about analytics:\n\n- Check our [Privacy Policy](https://www.stirling.com/privacy-policy)\n- Review the [source code](https://github.com/Stirling-Tools/Stirling-PDF)\n- Ask questions on [Discord](https://discord.gg/HYmhKj45pU)\n- Open an issue on [GitHub](https://github.com/Stirling-Tools/Stirling-PDF/issues)", + "sourcePath": "docs/Analytics-and-telemetry.md", + "editUrl": "https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/Analytics-and-telemetry.md" + }, + "configuration/audit-logging": { + "id": "configuration/audit-logging", + "title": "Audit Logging", + "section": "configuration", + "markdown": "> **Tier**: Enterprise\n\nLogs every operation, who ran it, what tool, which files, when. All data is stored in the database.\nWe recommend external database setup when using this feature due to the potential volume.\nPlease note the data stored is customisable based on you and your organisations needs and legal requirements.\n\n\nSettings are under `premium.enterpriseFeatures.audit`.\n\n| Setting | Default | Description |\n|---|---|---|\n| `enabled` | `true` | Turn audit logging on or off |\n| `level` | `2` | Verbosity: `0` = off, `1` = basic, `2` = standard, `3` = verbose |\n| `retentionDays` | `90` | Days to keep audit records before purging (`0` = infinite retention) |\n| `captureFileHash` | `false` | Store a SHA-256 hash of each processed file |\n| `capturePdfAuthor` | `false` | Extract and store PDF author metadata |\n| `captureOperationResults` | `false` | Store operation return values - high volume, use sparingly |\n\n## Audit levels\n\n| Level | What's recorded |\n|---|---|\n| `0` - OFF | Nothing |\n| `1` - BASIC | File modifications only - PDF operations (compress, split, merge, etc.) and settings changes |\n| `2` - STANDARD | BASIC + user actions (login/logout, account changes, general GET requests) |\n| `3` - VERBOSE | STANDARD + continuous polling calls and all GET requests |\n\n## Example\n\n\n \n ```yaml\n premium:\n enabled: true\n key: your-enterprise-license-key\n enterpriseFeatures:\n audit:\n enabled: true\n level: 2\n retentionDays: 365\n captureFileHash: true\n capturePdfAuthor: false\n captureOperationResults: false\n ```\n \n \n ```bash\n PREMIUM_ENABLED=true\n PREMIUM_KEY=your-enterprise-license-key\n PREMIUM_ENTERPRISEFEATURES_AUDIT_ENABLED=true\n PREMIUM_ENTERPRISEFEATURES_AUDIT_LEVEL=2\n PREMIUM_ENTERPRISEFEATURES_AUDIT_RETENTIONDAYS=365\n PREMIUM_ENTERPRISEFEATURES_AUDIT_CAPTUREFILEHASH=true\n PREMIUM_ENTERPRISEFEATURES_AUDIT_CAPTUREPDFAUTHOR=false\n PREMIUM_ENTERPRISEFEATURES_AUDIT_CAPTUREOPERATIONRESULTS=false\n ```\n \n \n ```yaml\n services:\n stirling-pdf:\n image: docker.stirlingpdf.com/stirlingtools/stirling-pdf:latest\n environment:\n PREMIUM_ENABLED: true\n PREMIUM_KEY: your-enterprise-license-key\n PREMIUM_ENTERPRISEFEATURES_AUDIT_ENABLED: true\n PREMIUM_ENTERPRISEFEATURES_AUDIT_LEVEL: 2\n PREMIUM_ENTERPRISEFEATURES_AUDIT_RETENTIONDAYS: 365\n PREMIUM_ENTERPRISEFEATURES_AUDIT_CAPTUREFILEHASH: true\n ```\n \n\n\n> **Note on performance:** `captureFileHash` adds a SHA-256 calculation for every file processed - noticeable overhead at high volume. `captureOperationResults` stores full operation output in the database and can grow very large; only enable it when specifically needed.", + "sourcePath": "docs/Configuration/Audit Logging.md", + "editUrl": "https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/Configuration/Audit Logging.md" + }, + "configuration/configuration": { + "id": "configuration/configuration", + "title": "Configuration Guide", + "description": "Configure Stirling PDF using environment variables, settings files, or in-app settings", + "section": "configuration", + "markdown": "Stirling PDF can be configured in three ways, depending on your deployment and preferences.\n\n## Configuration Methods\n\n### 1. In-App Settings (Recommended)\n\nIf you have login enabled, admins can configure everything through the Settings menu in the application.\n\n**To use:**\n1. Set `SECURITY_ENABLELOGIN=true`\n2. Log in as admin\n3. Go to Settings → configure through UI\n4. Changes apply immediately, no restart needed\n\n**Best for:** Production deployments with admin users\n\n---\n\n### 2. Environment Variables\n\nConfigure via Docker environment variables or system environment variables.\n\n**To use:**\n```bash\ndocker run -d \\\n -e SECURITY_ENABLELOGIN=true \\\n -e SYSTEM_DEFAULTLOCALE=en-US \\\n stirlingtools/stirling-pdf:latest\n```\n\n**Best for:** Docker deployments, infrastructure-as-code, initial setup\n\n---\n\n### 3. Settings File (settings.yml)\n\nEdit `/configs/settings.yml` directly for advanced configuration.\n\n**To use:**\n```yaml\nsecurity:\n enableLogin: true\nsystem:\n defaultLocale: en-US\n```\n\n**Best for:** Complex configurations, when you prefer file-based config\n\n---\n\n## Common Settings\n\n### Authentication\n\n**Note:** Authentication and additional features are included by default in:\n- **Docker**: All images except ultra-lite (authentication is enabled by default)\n- **JAR**: [Stirling-PDF-with-login.jar](https://files.stirlingpdf.com/Stirling-PDF-with-login.jar) **(Recommended)**\n\nThe plain [Stirling-PDF.jar](https://files.stirlingpdf.com/Stirling-PDF.jar) does not include authentication or additional features.\n\nConfigure user login:\n\n\n \n ```yaml\n security:\n enableLogin: true\n initialLogin:\n username: admin\n password: changeme123\n ```\n \n \n ```bash\n SECURITY_ENABLELOGIN=true\n SECURITY_INITIALLOGIN_USERNAME=admin\n SECURITY_INITIALLOGIN_PASSWORD=changeme123\n ```\n \n\n\nDefault credentials: `admin` / `stirling` (change immediately after first login)\n\nFor more details, see [System and Security Configuration](doc:configuration/system-and-security).\n\n### Language & Localization\n\n\n \n ```yaml\n ui:\n languages: [] # Available languages (empty = all enabled), e.g. [\"en_US\", \"de_DE\"]\n system:\n defaultLocale: en-US # Default language for new users\n ```\n \n \n ```bash\n UI_LANGUAGES=en_US,de_DE # Restrict available languages (omit to enable all)\n SYSTEM_DEFAULTLOCALE=en-US # Default language\n ```\n \n\n\nLeaving `defaultLocale` empty (the default) auto-detects the language from the browser and falls back to `en-US` if no preference is found.\n\n**How language selection works:**\n\nStirling PDF determines the interface language using this priority order:\n\n1. **User's manual selection** (highest priority)\n - When a user clicks the language globe icon and selects a language\n - Choice is stored in browser's localStorage (persists across sessions)\n - Storage key: `i18nextLng`\n\n2. **System default locale**\n - Set via `SYSTEM_DEFAULTLOCALE` or `system.defaultLocale`\n - When configured, it overrides the browser's detected language for users who have not made a manual selection\n\n3. **Browser's language preference**\n - Automatically detected from the browser's language setting\n - Example: Firefox set to Swedish (sv-SE) shows Swedish UI when no `defaultLocale` is configured\n\n4. **Fallback** (lowest priority)\n - `en-US` is used when none of the above resolve to an available language\n\n**Example:**\n- Config: `SYSTEM_DEFAULTLOCALE=en-US`\n- Browser: Swedish (sv-SE)\n- Result: UI shows English (US) (the configured default overrides the browser preference)\n\nIf `defaultLocale` is left empty (the default), the browser-detected language is used instead. Users can always override either choice by manually selecting a language via the language globe icon.\n\n> **Tip**: Set `SYSTEM_DEFAULTLOCALE` to your organization's primary language. Users can always override it using the language selector in the top-right corner.\n\n### File Upload Limits\n\n\n \n ```yaml\n system:\n fileUploadLimit: \"500MB\" # Number (0-999) followed by KB, MB, or GB. Empty = no limit\n spring:\n servlet:\n multipart:\n max-file-size: 2000MB\n max-request-size: 2000MB\n ```\n \n \n ```bash\n SYSTEM_MAXFILESIZE=500 # Size in MB (valid range 1-999)\n SPRING_SERVLET_MULTIPART_MAX_FILE_SIZE=2000MB\n SPRING_SERVLET_MULTIPART_MAX_REQUEST_SIZE=2000MB\n ```\n \n\n\n### Memory Management\n\n```bash\nJAVA_TOOL_OPTIONS=\"-Xms512m -Xmx4g\" # Min 512MB, Max 4GB RAM\n```\n\n---\n\n## Specialized Configuration Guides\n\nFor advanced features and specific use cases, see these detailed guides:\n\n### Authentication & Security\n\n**[Single Sign-On (SSO)](doc:configuration/single-sign-on-configuration)**\n- OAuth2 (Google, GitHub, Keycloak, OIDC) - Server tier\n- SAML2 (Okta, Azure AD) - Enterprise tier\n- Complete configuration examples\n\n**[System and Security](doc:configuration/system-and-security)**\n- Server certificates\n- JWT configuration\n\n**[Fail2Ban Integration](doc:configuration/fail2ban)**\n- Protect against brute-force attacks\n- Auto-ban after failed login attempts\n\n---\n\n### Features & Customization\n\n**[UI Customization](doc:configuration/ui-customisation)**\n- Branding and logos\n- Theme customization\n- Custom styling\n\n**[Endpoint/Feature Control](doc:configuration/endpoint-or-feature-customisation)**\n- Enable/disable specific tools\n- Control feature availability by user/role\n\n**[Pipeline (Automation)](doc:configuration/pipeline)**\n- Automated workflows\n- Folder scanning\n- Batch processing\n- Multi-step operations\n\n---\n\n### Integration & Storage\n\n**[External Database](doc:configuration/external-database)**\n- PostgreSQL configuration (Pro/Enterprise)\n- Database migration\n- Backup strategies\n\n**[Google Drive File Picker](doc:configuration/google-drive-file-picker)**\n- Direct Google Drive integration\n- OAuth setup\n\n**[MCP Server](doc:advanced-configuration/mcp-server)**\n- Expose Stirling PDF tools over the Model Context Protocol\n- OAuth2 or API-key authentication\n- Operation allow/deny lists\n\n**[S3 / Object Storage](doc:configuration/file-sharing-and-storage)**\n- Store uploads and job artifacts in S3-compatible object storage\n- Shared storage for multi-node deployments\n\n**[Telegram Bot](doc:configuration/telegram-bot)**\n- Run a Telegram bot that processes PDFs sent in chat\n\n**[OCR Configuration](doc:configuration/ocr)**\n- Tesseract language packs\n- OCR optimization\n\n**[Usage Monitoring](doc:configuration/usage-monitoring)**\n- Prometheus metrics (Pro/Enterprise)\n- Application monitoring\n- Performance tracking\n\n---\n\n### Performance & Scaling\n\n**[Performance Optimization & Sizing](doc:configuration/performance-optimization)**\n- Resource sizing, JVM tuning, memory model, and scaling guidance\n\n**[Process Limits](doc:configuration/process-limits)**\n- Session limits and timeouts for external tools\n\n**[LibreOffice Parallel Processing](doc:configuration/libreoffice-parallel-processing)**\n- Configure multiple LibreOffice instances for faster document conversion\n- Local UNO server pool and remote UNO server endpoints\n\n---\n\n### Diagnostics & Support\n\n**[Diagnostics & Reporting Issues](doc:configuration/diagnostics)**\n- Built-in diagnostics tool for Docker containers\n- How to report issues via GitHub, Discord, and email\n\n---\n\n### Other Configuration\n\n**[Folder Scanning](doc:configuration/folderscanning)**\n- Watch folders for automatic processing\n\n**[Custom Signature Files](doc:configuration/sign-with-custom-files)**\n- Pre-loaded signatures for quick signing\n\n**[Extra Settings](doc:configuration/extra-settings)**\n- Logging configuration\n- Server settings (port, SSL/TLS)\n- Advanced Spring Boot settings\n\n---\n\n## Configuration Priority\n\nWhen the same setting is defined in multiple places, this is the order of precedence (highest to lowest):\n\n1. **Environment Variables**\n2. **settings.yml / In-App Settings**\n3. **Default values**\n\n---\n\n## Environment Variable Format\n\nConvert YAML paths to environment variables:\n\n```yaml\n# settings.yml\nsecurity:\n enableLogin: true\n```\n\nBecomes:\n```bash\nSECURITY_ENABLELOGIN=true\n```\n\n**Rules:**\n- Uppercase everything\n- Replace `.` with `_`\n- Nested properties become `PARENT_CHILD`\n\n---\n\n## Troubleshooting\n\n### Settings Not Applied\n\n1. Check configuration priority (env vars override settings.yml)\n2. Restart container after changing environment variables\n3. Check logs: `docker logs stirling-pdf | grep ERROR`\n4. Verify file permissions on `/configs` volume\n\n### Database Issues\n\nDefault database location: `/configs/stirling-pdf-DB-.mv.db` (the schema version is part of the filename, e.g. `/configs/stirling-pdf-DB-2.3.232.mv.db`).\n\nIf missing:\n- Ensure `/configs` volume is mounted\n- Check write permissions\n- Review startup logs\n\n---\n\n## Next Steps\n\n- **Production Deployment:** See [Production Deployment Guide](doc:server-admin-onboarding)\n- **API Usage:** See [API Documentation](doc:api)\n- **Tool Reference:** See [Functionality](doc:functionality/functionality)\n- **Troubleshooting:** See [Diagnostics & Reporting Issues](doc:configuration/diagnostics)", + "sourcePath": "docs/Configuration/Configuration.md", + "editUrl": "https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/Configuration/Configuration.md" + }, + "configuration/database": { + "id": "configuration/database", + "title": "Database Backups", + "section": "configuration", + "markdown": "> **Tier**: Server\n\n## Functionality Overview\n\nThe newly introduced feature enhances the application with robust database backup and import capabilities. This feature is designed to ensure data integrity and provide a straightforward way to manage database backups. Here's how it works:\n\n1. Automatic Backup Creation\n - The system automatically creates a database backup every day at midnight. This ensures that there is always a recent backup available, minimizing the risk of data loss.\n2. Manual Backup Export\n - Admin actions that modify the user database trigger a manual export of the database. This keeps the backup up-to-date with the latest changes and provides an extra layer of data security.\n3. Importing Database Backups\n - Admin users can import a database backup either via the web interface or API endpoints. This allows for easy restoration of the database to a previous state in case of data corruption or other issues.\n - The import process ensures that the database structure and data are correctly restored, maintaining the integrity of the application.\n4. Managing Backup Files\n - Admins can view a list of all existing backup files, along with their creation dates and sizes. This helps in managing storage and identifying the most recent or relevant backups.\n - Backup files can be downloaded for offline storage or transferred to other environments, providing flexibility in database management.\n - Unnecessary backup files can be deleted through the interface to free up storage space and maintain an organized backup directory.\n\n## User Interface\n\n### Web Interface\n\n1. Upload SQL files to import database backups.\n2. View details of existing backups, such as file names, creation dates, and sizes.\n3. Download backup files for offline storage.\n4. Delete outdated or unnecessary backup files.\n\n### API Endpoints\n\n1. Import database backups by uploading SQL files.\n2. Download backup files.\n3. Delete backup files.\n\nThis new functionality streamlines database management, ensuring that backups are always available and easy to manage, thus improving the reliability and resilience of the application.", + "sourcePath": "docs/Configuration/DATABASE.md", + "editUrl": "https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/Configuration/DATABASE.md" + }, + "configuration/diagnostics": { + "id": "configuration/diagnostics", + "title": "Diagnostics & Reporting Issues", + "description": "Use the built-in diagnostics tool and learn how to report issues effectively", + "section": "configuration", + "markdown": "Stirling PDF includes a built-in diagnostics tool inside Docker containers that collects logs, configuration, system information, and application metrics into a single archive. This is the fastest way to gather the information needed when troubleshooting or reporting issues.\n\n---\n\n## Running the Diagnostics Tool\n\nOpen an interactive shell inside the running container and invoke the tool:\n\n```bash\ndocker exec -it diag\n```\n\nThe following aliases all work identically: `diag`, `debug`, `diagnostic`, `diagnostics`, `stirling-diagnostics`.\n\n> **⚠️ Caution: Interactive Terminal Required**\n>\n> The diagnostics tool requires an interactive terminal (`-it` flag). It will not run in non-interactive or headless sessions.\n\n\n---\n\n## Collection Modes\n\nWhen you run the tool, you'll be prompted to choose a collection mode.\n\n### Auto Mode (Recommended)\n\nSelect option **1** when prompted. Auto mode collects:\n\n- Application logs from the last 24 hours\n- Configuration files from `/configs`\n- System information (OS, CPU, memory, disk, Java version)\n- Application metrics and health endpoints\n\nThis is sufficient for most issue reports.\n\n### Custom Mode\n\nSelect option **2** for granular control over what gets collected:\n\n| Prompt | Default | What It Collects |\n|---|---|---|\n| Output directory | `/configs` | Where to save the archive |\n| Days of logs | 1 | How many days of logs to include |\n| Include /configs | Yes | Configuration files |\n| Include /customFiles | No | Custom files (excluding PDFs and images) |\n| Include /pipeline | No | Pipeline working files (excluding PDFs) |\n| Include /tmp/stirling-pdf | No | Temporary processing files |\n| Include system information | Yes | OS, CPU, RAM, disk, Java/Python versions |\n| Include environment variables | No | Full environment dump |\n| Fetch metrics endpoints | Yes | Application status, health, and load data |\n| Include UI data endpoints | No | Sign, pipeline, and OCR endpoint data |\n| Redact sensitive information | Yes | Apply redaction filters (see below) |\n\n### Redaction Options\n\nWhen redaction is enabled, you can selectively mask:\n\n- **Secrets/tokens/passwords** - Redacts Authorization headers, API keys, passwords, and similar credentials\n- **URL hosts/domains** - Masks hostnames in URLs\n- **Email addresses** - Replaces email addresses with `[REDACTED_EMAIL]`\n- **Host/Domain/Server fields** - Masks values in host-related configuration fields\n\n> **⚠️ Caution**\n>\n> Always enable redaction if you plan to share the diagnostics bundle publicly (for example, in a GitHub issue). However, redaction is not perfect and may miss some sensitive values - always review the output manually before sharing publicly. You can disable redaction for private support channels if full detail is needed.\n\n\n---\n\n## What Gets Collected\n\nThe diagnostics bundle is a `.tar.gz` archive saved to the output directory (default: `/configs`). It contains:\n\n```\nstirling-diagnostics-YYYYMMDD-HHMMSS.tar.gz\n├── summary.txt # Collection metadata and settings\n├── bundle/\n│ ├── logs/ # Application log files\n│ ├── configs/ # Configuration files (settings.yml, etc.)\n│ ├── system/ # System information\n│ │ ├── uname.txt # Kernel version\n│ │ ├── os-release # OS distribution info\n│ │ ├── meminfo.txt # Memory details\n│ │ ├── cpuinfo.txt # CPU details\n│ │ ├── df.txt # Disk usage\n│ │ ├── free.txt # Memory summary\n│ │ ├── ps.txt # Running processes\n│ │ ├── java-version.txt # Java runtime version\n│ │ └── python-version.txt # Python version\n│ ├── metrics/ # Application metrics\n│ │ ├── api/v1/info/status.json\n│ │ ├── api/v1/info/uptime.json\n│ │ ├── api/v1/info/health.json\n│ │ ├── api/v1/info/requests.json\n│ │ ├── api/v1/info/load.json\n│ │ ├── actuator/health.json\n│ │ └── actuator/prometheus.txt\n│ ├── env/ # Environment variables (if requested)\n│ └── tree/ # Directory listings\n│ ├── logs.txt\n│ ├── configs.txt\n│ ├── customFiles.txt\n│ ├── pipeline.txt\n│ ├── tessdata.txt # Installed OCR language packs\n│ └── tessdata-mount.txt\n```\n\nPDFs, images, and compressed archives are always excluded from collection.\n\n### Retrieving the Bundle\n\nAfter the tool finishes, copy the archive out of the container:\n\n```bash\ndocker cp :/configs/stirling-diagnostics-*.tar.gz ./\n```\n\n---\n\n## AOT Diagnostics\n\nIf you are running with AOT (Ahead-of-Time) compilation enabled (`STIRLING_AOT_ENABLE=true`), an additional diagnostics tool is available:\n\n```bash\ndocker exec -it aot-diag\n```\n\nThis tool diagnoses AOT cache generation failures, particularly on ARM64/aarch64 platforms. It checks cache integrity, JVM compatibility, and can run smoke tests.\n\nAliases: `aot-diag`, `aot-diagnostics`\n\n---\n\n## How to Report Issues\n\nWhen you encounter a problem with Stirling PDF, choose the right channel depending on the nature of your issue.\n\n### GitHub Issues - Bug Reports & Feature Requests\n\nFor reproducible bugs and feature requests, open an issue at:\n**https://github.com/Stirling-Tools/Stirling-PDF/issues**\n\nThe repository includes issue templates for bug reports and feature requests that will guide you through providing the right information.\n\nWhen submitting a bug report, include as much detail as possible: the diagnostics bundle (run `diag` in your container first), steps to reproduce the issue, expected vs. actual behavior, your deployment method (Docker, bare metal, Kubernetes), Stirling PDF version (visible in the UI footer or in `summary.txt` from the diagnostics bundle), and any commands, API requests, or actions you were performing when the issue occurred. The more context you provide, the faster it can be resolved.\n\n### Discord Community - Questions & Discussion\n\nFor quick questions, troubleshooting help, and community discussion:\n**https://discord.gg/HYmhKj45pU**\n\nDiscord is the best place for configuration help, setup questions, sharing workarounds with other users, general discussion about features and usage, and getting faster informal feedback before filing a formal issue. It's also great for following up on GitHub issues and having deeper conversations with the community.\n\n### Email Support\n\nFor enterprise customers and licensing inquiries:\n**support@stirlingpdf.com**\n\nFor security vulnerabilities:\n**security@stirlingpdf.com** or use the [GitHub Security Advisory](https://github.com/Stirling-Tools/Stirling-PDF/security) process.", + "sourcePath": "docs/Configuration/Diagnostics.md", + "editUrl": "https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/Configuration/Diagnostics.md" + }, + "configuration/endpoint-or-feature-customisation": { + "id": "configuration/endpoint-or-feature-customisation", + "title": "Endpoints Customisation", + "section": "configuration", + "markdown": "You can selectively disable and remove endpoints and functionalities from Stirling PDF as per your requirements.\nThere are many use-cases for this such as\n- Avoid confusion for users for functionality you/your business don't use.\n- Running a reduced version of Stirling PDF that doesn't have the necessary server power to support the more advanced features.\n- Cleanup interface for features you don't use\n\nYou have two ways to disable endpoints:\n\n1. **Environment Variables** (`ENDPOINTS_TOREMOVE` and `ENDPOINTS_GROUPSTOREMOVE`):\n - Example: `ENDPOINTS_TOREMOVE=merge-pdfs,remove-pages` disables merge and remove page tools\n - Example: `ENDPOINTS_GROUPSTOREMOVE=LibreOffice` disables all LibreOffice-dependent tools\n\n2. **Settings File** (`settings.yml` under `endpoints.toRemove` and `endpoints.groupsToRemove`):\n - Example: `toRemove: [merge-pdfs, remove-pages]`\n - Example: `groupsToRemove: [LibreOffice]`\n\n## Available Endpoint Groups\n\nYou can disable entire groups of related endpoints using `ENDPOINTS_GROUPSTOREMOVE`:\n\n| Group Name | What It Disables | Example |\n|------------|------------------|---------|\n| `LibreOffice` | All office document conversions (DOCX, XLSX, PPTX to/from PDF) | `ENDPOINTS_GROUPSTOREMOVE=LibreOffice` |\n| `Python` | Python-backed features (scan extraction and some file/HTML/URL conversions) | `ENDPOINTS_GROUPSTOREMOVE=Python` |\n| `OpenCV` | Advanced image processing operations | `ENDPOINTS_GROUPSTOREMOVE=OpenCV` |\n| `OCRmyPDF` | OCR (Optical Character Recognition) features | `ENDPOINTS_GROUPSTOREMOVE=OCRmyPDF` |\n| `Weasyprint` | HTML to PDF conversion | `ENDPOINTS_GROUPSTOREMOVE=Weasyprint` |\n| `Calibre` | E-book format conversions | `ENDPOINTS_GROUPSTOREMOVE=Calibre` |\n| `qpdf` | Various PDF operations powered by QPDF | `ENDPOINTS_GROUPSTOREMOVE=qpdf` |\n| `Ghostscript` | Compression, repair and related operations powered by Ghostscript | `ENDPOINTS_GROUPSTOREMOVE=Ghostscript` |\n| `Automation` | Automation/pipeline endpoints (`handleData`, `automate`, `pipeline`) | `ENDPOINTS_GROUPSTOREMOVE=Automation` |\n| `DeveloperTools` | Developer tools such as Show JavaScript | `ENDPOINTS_GROUPSTOREMOVE=DeveloperTools` |\n| `DeveloperDocs` | In-app developer doc links (API docs, folder scanning, SSO, air-gapped) | `ENDPOINTS_GROUPSTOREMOVE=DeveloperDocs` |\n\n**Example - Disable multiple groups:**\n```bash\nENDPOINTS_GROUPSTOREMOVE=LibreOffice,Calibre,Weasyprint\n```\n\n## Usage Examples\n\n### Environment Variables\n\n**Disable specific tools:**\n```bash\n# Docker Run\ndocker run -e ENDPOINTS_TOREMOVE=sign,add-watermark,add-stamp stirlingtools/stirling-pdf:latest\n\n# Docker Compose\nenvironment:\n - ENDPOINTS_TOREMOVE=sign,add-watermark,add-stamp\n```\n\n**Disable entire groups:**\n```bash\n# Disable all office conversions and OCR\nENDPOINTS_GROUPSTOREMOVE=LibreOffice,OCRmyPDF\n```\n\n**Combine both methods:**\n```bash\n# Disable groups AND specific tools\nENDPOINTS_GROUPSTOREMOVE=LibreOffice,Calibre\nENDPOINTS_TOREMOVE=sign,compare,multi-tool\n```\n\n### Settings File (settings.yml)\n\nIf you're editing `settings.yml` directly, use the kebab-case endpoint IDs:\n\n**Disable specific tools:**\n```yaml\nendpoints:\n toRemove:\n - sign\n - add-watermark\n - add-stamp\n - compare\n - merge-pdfs\n```\n\n**Disable entire groups:**\n```yaml\nendpoints:\n groupsToRemove:\n - LibreOffice\n - OCRmyPDF\n```\n\n**Combine both methods:**\n```yaml\nendpoints:\n toRemove:\n - sign\n - compare\n - multi-tool\n - merge-pdfs\n groupsToRemove:\n - LibreOffice\n - Calibre\n```\n\n## Complete Endpoint Reference for settings.yml\n\nUse these exact kebab-case IDs with `endpoints.toRemove` in settings.yml.\n\n### Page Operations\n- `merge-pdfs` - Merge PDFs\n- `split-pages` - Split PDFs\n- `extract-pages` - Extract Pages\n- `remove-pages` - Remove Pages\n- `rearrange-pages` - Rearrange Pages\n- `rotate-pdf` - Rotate PDFs\n- `crop` - Crop Pages\n- `scale-pages` - Scale Pages\n- `add-page-numbers` - Add Page Numbers\n- `pdf-to-single-page` - PDF to Single Page\n- `multi-page-layout` - Multi-Page Layout\n- `booklet-imposition` - Booklet Imposition\n- `overlay-pdf` - Overlay PDFs\n- `split-pdf-by-sections` - Split by Sections\n- `split-pdf-by-chapters` - Split by Chapters\n- `auto-split-pdf` - Auto Split PDF\n- `split-by-size-or-count` - Split by Size/Count\n- `add-attachments` - Add Attachments\n\n### Conversion\n- `pdf-to-img` - PDF to Image\n- `img-to-pdf` - Image to PDF\n- `file-to-pdf` - File to PDF\n- `pdf-to-word` - PDF to Word\n- `pdf-to-presentation` - PDF to Presentation\n- `pdf-to-text` - PDF to Text\n- `pdf-to-html` - PDF to HTML\n- `pdf-to-xml` - PDF to XML\n- `pdf-to-markdown` - PDF to Markdown\n- `pdf-to-csv` - PDF to CSV\n- `pdf-to-epub` - PDF to EPUB\n- `pdf-to-vector` - PDF to Vector\n- `pdf-to-json` - PDF to JSON\n- `pdf-to-rtf` - PDF to RTF\n- `pdf-to-cbz` - PDF to CBZ\n- `pdf-to-cbr` - PDF to CBR\n- `pdf-to-pdfa` - PDF to PDF/A\n- `html-to-pdf` - HTML to PDF\n- `url-to-pdf` - URL to PDF\n- `markdown-to-pdf` - Markdown to PDF\n- `eml-to-pdf` - Email to PDF\n- `cbz-to-pdf` - CBZ to PDF\n- `json-to-pdf` - JSON to PDF\n- `vector-to-pdf` - Vector to PDF\n\n### Security & Signing\n- `add-password` - Add Password Protection\n- `remove-password` - Remove Password\n- `change-permissions` - Change Permissions\n- `add-watermark` - Add Watermark\n- `add-stamp` - Add Stamp\n- `sanitize-pdf` - Sanitize PDF\n- `flatten` - Flatten Form Fields\n- `unlock-pdf-forms` - Unlock PDF Forms\n- `cert-sign` - Certificate Sign\n- `sign` - Draw/Text/Image Signature\n- `timestamp-pdf` - Add Trusted Timestamp\n- `remove-cert-sign` - Remove Certificate Signature\n- `validate-signature` - Validate Signature\n- `verify-pdf` - Verify PDF\n- `redact` - Redact Information\n- `auto-redact` - Auto Redact\n\n### Content Extraction & Removal\n- `extract-images` - Extract Images\n- `extract-image-scans` - Extract Image Scans\n- `remove-image-pdf` - Remove Images\n- `remove-annotations` - Remove Annotations\n- `remove-blanks` - Remove Blank Pages\n- `ocr-pdf` - OCR\n\n### Document Editing & Analysis\n- `text-editor-pdf` - Text Editor\n- `edit-table-of-contents` - Edit Table of Contents\n- `update-metadata` - Change Metadata\n- `get-info-on-pdf` - Get PDF Info\n- `compare` - Compare PDFs\n- `adjust-contrast` - Adjust Contrast\n- `replace-invert-pdf` - Replace/Invert Colors\n- `scanner-effect` - Scanner Effect\n- `repair` - Repair PDF\n- `add-image` - Add Image to PDF\n\n### Form Fields\n- `fields` - Form Fields\n- `fill` - Fill Form Fields\n- `modify-fields` - Modify Form Fields\n- `delete-fields` - Delete Form Fields\n\n### Multi-Tool & Automation\n- `multi-tool` - Multi-Tool Workbench\n- `compare` - Compare PDFs\n- `compress-pdf` - Compress PDFs\n- `automate` - Automation/Pipeline\n- `pipeline` - Pipeline\n- `auto-rename` - Auto Rename\n\n### Viewing & Display\n- `view-pdf` - PDF Viewer\n- `show-javascript` - Show JavaScript in PDF\n\n### Developer Tools\n- `dev-api-docs` - API Documentation\n- `dev-folder-scanning-docs` - Folder Scanning Guide\n- `dev-sso-guide-docs` - SSO Guide\n- `dev-airgapped-docs` - Air-gapped Setup Guide\n\n### Internal\n- `handleData` - Handle Data\n\n## Notes\n\n- Tool IDs are case-sensitive (use exact names from the reference above)\n- Group IDs are also case-sensitive - note the lowercase `qpdf`\n- Disabling a tool removes it completely from the UI and API\n- Some tools may depend on others - test your configuration\n- Changes require container restart to take effect\n- The [MCP server](doc:advanced-configuration/mcp-server) applies its own allow/deny filtering on top of these removals, so an endpoint can be enabled here yet still be hidden from MCP clients", + "sourcePath": "docs/Configuration/Endpoint or Feature Customisation.md", + "editUrl": "https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/Configuration/Endpoint or Feature Customisation.md" + }, + "configuration/external-database": { + "id": "configuration/external-database", + "title": "External Database", + "section": "configuration", + "markdown": "## Using an External Database\n> **Tier**: Server\n\nIt is possible to use your own external database with Stirling PDF rather than the default H2 database if you wish.\nPostgreSQL is currently the only supported variant, others will be added on request.\n\n### Setting Up External Database Configuration\nYou can configure the new `Datasource` property in your `settings.yml` to connect to your external database:\n\n> #### ⚠️ Note\n> _To use the external database feature, you will need to have a valid enterprise license and set the environment variable `DISABLE_ADDITIONAL_FEATURES` to `false`._\n\n\n \n\n```yaml\n datasource:\n enableCustomDatabase: false\n customDatabaseUrl: jdbc:postgresql://localhost:5432/postgres\n username: postgres\n password: postgres\n type: postgresql\n hostName: localhost\n port: 5432\n name: postgres\n```\n\n- `enableCustomDatabase`: Set this property to `true` to enable use of the custom database **Note: An enterprise license to use this feature**\n- `customDatabaseUrl`: Enter the database connection url for the database here. **Note: If you set the `customDatabaseUrl` you do not need to set the type, hostName, port and name, they will all be automatically derived from the url.**\n- `username`: The username for the database\n- `password`: The password for the database\n\nIf you would like more fine-grained control of the database connection, you can also use the following properties:\n\n#### Fine-grained Database Configuration\n- `type`: The database type. Available options are `h2` and `postgresql`\n- `hostName`: The host name of the database connection url (e.g. 'localhost')\n- `port`: The port number of the database connection url (e.g. 8080)\n- `name`: The name of the custom database. This should match the name you have set for your database\n\n\n \n\n```yaml\nservices:\n db:\n image: 'postgres:17.2-alpine'\n container_name: db\n ports:\n - \"5432:5432\"\n environment:\n POSTGRES_DB: \"stirling_pdf\"\n POSTGRES_USER: \"admin\"\n POSTGRES_PASSWORD: \"stirling\"\n```\n\n- `container_name`: This is the name of your database container. This should match the name of the container under `services` as this is what Docker will use to refer to your database\n- `ports`: Specify the port number for your database. The number on the left is the port number the container will access the database internally. The number on the right is the port number the Stirling PDF app will use to connect to the database externally. Ensure this matches the port number in the connection url for your database otherwise the app will not be able to access it.\n- `POSTGRES_DB`: An environment variable for the database container. Specify the name of the custom database here\n- `POSTGRES_USER`: An environment variable for the database container. Specify the username for the database\n- `POSTGRES_PASSWORD`: An environment variable for the database container. Specify the password for the database\n\nYou will also need to update the Docker configuration in your app in order to connect to the database:\n\n```yaml\nservices:\n stirling-pdf:\n depends_on:\n - db\n environment:\n DISABLE_ADDITIONAL_FEATURES: \"false\" \"true\"\n SYSTEM_DATASOURCE_ENABLECUSTOMDATABASE: \"true\"\n SYSTEM_DATASOURCE_CUSTOMDATABASEURL: \"jdbc:postgresql://db:5432/stirling_pdf\"\n SYSTEM_DATASOURCE_USERNAME: \"admin\"\n SYSTEM_DATASOURCE_PASSWORD: \"stirling\"\n # further configuration\n```\n\n- `depends_on`: This specifies any services that your app will need in order to run. Ensure the name matches the container name for your database\n- `DISABLE_ADDITIONAL_FEATURES`: Set this to `false` to enable security features\n- `SYSTEM_DATASOURCE_ENABLECUSTOMDATABASE`: An environment variable to connect to the database container. Set this to `true` to enable use of the external database\n- `SYSTEM_DATASOURCE_CUSTOMDATABASEURL`: An environment variable to connect to the database container. Set the connection url for the database here. **Note: If you set this url you do not need to set the type, hostName, port and name (namely `SYSTEM_DATASOURCE_TYPE`, `SYSTEM_DATASOURCE_HOSTNAME`, `SYSTEM_DATASOURCE_PORT`, `SYSTEM_DATASOURCE_NAME`), they will all be automatically derived from the url.**\n- `SYSTEM_DATASOURCE_USERNAME`: An environment variable to connect to the database container. Set the username for the database. Ensure this matches the corresponding property in your database container\n- `SYSTEM_DATASOURCE_PASSWORD`: An environment variable to connect to the database container. Set the password for the database. Ensure this matches the corresponding property in your database container\n\nBelow is an example of what your configuration should look like after configuring the custom database:\n\n```yaml\nservices:\n stirling-pdf:\n depends_on:\n - db\n environment:\n DISABLE_ADDITIONAL_FEATURES: \"false\" \"true\"\n SYSTEM_DATASOURCE_ENABLECUSTOMDATABASE: \"true\"\n SYSTEM_DATASOURCE_CUSTOMDATABASEURL: \"jdbc:postgresql://db:5432/stirling_pdf\"\n SYSTEM_DATASOURCE_USERNAME: \"admin\"\n SYSTEM_DATASOURCE_PASSWORD: \"stirling\"\n # further configuration\n\n db:\n image: 'postgres:17.2-alpine'\n container_name: db\n ports:\n - \"5432:5432\"\n environment:\n POSTGRES_DB: \"stirling_pdf\"\n POSTGRES_USER: \"admin\"\n POSTGRES_PASSWORD: \"stirling\"\n```\n\n \n\n\n*Example configuration can be found in [exampleYmlFiles/docker-compose-latest-fat-security-postgres.yml](https://github.com/Stirling-Tools/Stirling-PDF/blob/428b4238e3a7280d71697d994a66174a250387a7/exampleYmlFiles/docker-compose-latest-fat-security-postgres.yml)*", + "sourcePath": "docs/Configuration/External Database.md", + "editUrl": "https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/Configuration/External Database.md" + }, + "configuration/extra-settings": { + "id": "configuration/extra-settings", + "title": "Custom Settings Configuration", + "section": "configuration", + "markdown": "Stirling PDF provides a `/configs/custom_settings.yml` file where users can configure additional settings beyond the standard configuration. This file follows standard YAML format and supports Spring Boot application properties, allowing you to customize the application without modifying core files.\n\n## Logging Configuration\n\nControl the verbosity of logs by adjusting log levels for different components:\n\n```yaml\nlogging:\n level:\n root: INFO\n org.springframework: WARN\n org.hibernate: WARN\n org.eclipse.jetty: WARN\n stirling.software.SPDF: INFO\n # Enable debug logging for specific components when troubleshooting\n # org.springframework.security.saml2: TRACE\n # org.springframework.security: DEBUG\n # org.opensaml: DEBUG\n```\n\n## Server Configuration\n\nConfigure server behavior including port, address binding, and session timeout:\n\n```yaml\nserver:\n port: 8080 # Default port\n address: 0.0.0.0 # Bind to all interfaces\n servlet:\n context-path: / # Application context path\n session:\n timeout: 30m # Session timeout\n jetty:\n threads:\n max: 200 # Maximum number of request processing threads\n min: 10 # Minimum number of threads always kept running\n connection-idle-timeout: 30000 # Connection idle timeout in milliseconds\n max-http-request-header-size: 65536 # Maximum size of request headers in bytes\n```\n\n### HTTP 431 \"Request Header Fields Too Large\" during SSO/OAuth login\n\nSome SSO/OAuth providers send very large request headers (for example, large cookies or JWTs), which can trigger an **HTTP 431 Request Header Fields Too Large** error during login. Stirling PDF's default limit is `32768` (32 KB); raise it to resolve the error.\n\n\n \n ```yaml\n server:\n jetty:\n max-http-request-header-size: 65536 # bytes (Stirling default: 32768)\n ```\n \n \n ```yaml\n environment:\n SERVER_JETTY_MAX_HTTP_REQUEST_HEADER_SIZE: \"65536\"\n ```\n \n \n ```bash\n docker run -d \\\n -p 8080:8080 \\\n -e SERVER_JETTY_MAX_HTTP_REQUEST_HEADER_SIZE=65536 \\\n stirlingtools/stirling-pdf:latest\n ```\n \n\n\nRaise the value further (for example `131072`) if the error persists, then restart Stirling PDF.\n\n## SSL/TLS Configuration\n\nConfigure HTTPS for secure connections:\n\n\n \n ```yaml\n server:\n port: 8443 # Standard HTTPS port\n ssl:\n enabled: true\n key-store: classpath:keystore.p12 # Path to keystore file\n key-store-password: your-keystore-password\n key-store-type: PKCS12 # Type of keystore\n key-alias: tomcat # Alias of the certificate\n ```\n \n \n ```bash\n SERVER_PORT=8443\n SERVER_SSL_ENABLED=true\n SERVER_SSL_KEY-STORE=classpath:keystore.p12\n SERVER_SSL_KEY-STORE-PASSWORD=your-keystore-password\n SERVER_SSL_KEY-STORE-TYPE=PKCS12\n SERVER_SSL_KEY-ALIAS=tomcat\n ```\n \n \n ```yaml\n services:\n stirling-pdf:\n image: stirlingtools/stirling-pdf:latest\n environment:\n SERVER_PORT: 8443\n SERVER_SSL_ENABLED: true\n SERVER_SSL_KEY-STORE: classpath:keystore.p12\n SERVER_SSL_KEY-STORE-PASSWORD: your-keystore-password\n SERVER_SSL_KEY-STORE-TYPE: PKCS12\n SERVER_SSL_KEY-ALIAS: tomcat\n ```\n \n\n\n### Creating a Self-Signed Certificate\n\nTo generate a self-signed certificate for development or testing:\n\n```shell\nkeytool -genkeypair -alias tomcat -keyalg RSA -keysize 2048 -storetype PKCS12 -keystore keystore.p12 -validity 365\n```\n\n> #### ⚠️ Note\n> _For production use, it's recommended to use a certificate from a trusted Certificate Authority._\n\n## Configuration Examples\n\n### Basic Configuration\n\nA simple configuration that changes the port and adjusts logging levels:\n\n```yaml\n# custom_settings.yml\nserver:\n port: 9000\n\nlogging:\n level:\n root: INFO\n org.springframework: WARN\n org.hibernate: WARN\n stirling.software.SPDF: INFO\n```\n\n### HTTPS Configuration\n\nEnable HTTPS with a custom certificate:\n\n```yaml\n# custom_settings.yml\nserver:\n port: 8443\n ssl:\n enabled: true\n key-store: classpath:keystore.p12\n key-store-password: your-keystore-password\n key-store-type: PKCS12\n key-alias: tomcat\n\nlogging:\n level:\n root: INFO\n org.springframework: WARN\n```\n\n## Troubleshooting Common Issues\n\n### Authentication Issues\n\nIncrease security logging to diagnose authentication problems:\n\n```yaml\nlogging:\n level:\n org.springframework.security: DEBUG\n stirling.software.SPDF.config.security: DEBUG\n```\n\n### SAML/OAuth Issues\n\nIncrease SAML-related logging for SSO troubleshooting:\n\n```yaml\nlogging:\n level:\n org.springframework.security.saml2: TRACE\n org.springframework.security.oauth2: DEBUG\n org.opensaml: DEBUG\n```\n\n### General Application Issues\n\nFor general application issues:\n\n```yaml\nlogging:\n level:\n stirling.software.SPDF: DEBUG\n```\n\n> #### ⚠️ Note\n> _Debug-level logging can significantly increase log volume and may impact performance in production environments. Return logging to normal levels after troubleshooting._", + "sourcePath": "docs/Configuration/Extra-Settings.md", + "editUrl": "https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/Configuration/Extra-Settings.md" + }, + "configuration/fail2ban": { + "id": "configuration/fail2ban", + "title": "Fail2Ban Integration", + "section": "configuration", + "markdown": "## Fail2Ban Setup for Stirling PDF\nThis document provides instructions on how to set up Fail2Ban with Stirling PDF to protect against unauthorized login attempts. (Note Stirling PDF blocks IPs after a set retry count regardless of Fail2Ban, This configuration is only useful for users specifically wanting Fail2Ban configuration)\n\n## How does Fail2Ban Work with Stirling PDF\nStirling PDF logs failed authentication attempts to a log file which Fail2Ban monitors. When it detects multiple failed login attempts from the same IP address, Fail2Ban automatically blocks that IP address for a configured period of time.\n\n\n## Prerequisites\n- Fail2Ban installed on your system\n- Access to Stirling PDF log directory\n- Security settings configured:\n\n\n \n ```yaml\n security:\n enableLogin: true # Login must be enabled for Fail2Ban integration\n loginAttemptCount: -1 # Set to -1 when using Fail2Ban recommended but not required\n ```\n \n \n ```bash\n SECURITY_ENABLELOGIN=true\n SECURITY_LOGINATTEMPTCOUNT=-1\n ```\n \n \n ```yaml\n services:\n stirling-pdf:\n environment:\n SECURITY_ENABLELOGIN: true\n SECURITY_LOGINATTEMPTCOUNT: -1\n ```\n \n\n\n### Important Configuration Notes\n- The `enableLogin` setting must be set to `true` as Fail2Ban integration requires authentication to be active\n- When using Fail2Ban, set `loginAttemptCount` to `-1` to disable the built-in account locking mechanism and let Fail2Ban handle login attempt management\n- For more details on security configuration options, refer to the [System and Security](doc:configuration/system-and-security) documentation\n\n## Configuration\n\n### Log File Location\nThe log file location containing the failed authentication messages depends on your installation type:\n\n- **Default/Docker Installation**: ``./logs/invalid-auths.log``\n- **Windows Desktop**: ``%APPDATA%\\Stirling-PDF\\logs\\invalid-auths.log``\n- **MacOS Desktop**: ``~/Library/Application Support/Stirling-PDF/logs/invalid-auths.log``\n- **Linux Desktop**: ``~/.config/Stirling-PDF/logs/invalid-auths.log``\n\n### Example Fail2Ban Filter\n`/etc/fail2ban/filter.d/stirling-pdf.conf`\n```ini\n[Definition]\nfailregex = Failed login attempt from IP: \n```\n\n### Example Jail Configuration\n`/etc/fail2ban/jail.local`\n```ini\n[stirling-pdf]\nenabled = true\nfilter = stirling-pdf\nlogpath = /logs/invalid-auths.log\nmaxretry = 5\nfindtime = 300\nbantime = 3600\n```\n\nConfiguration parameters:\n- `maxretry`: Number of failed attempts before ban (default: 5)\n- `findtime`: Time window for failed attempts in seconds (default: 300 seconds / 5 minutes)\n- `bantime`: Duration of the ban in seconds (default: 3600 seconds / 1 hour)\n\n\n### Ensure access to Logs path\n\n \n Modify your `docker-compose.yml` to expose the log directory:\n ```yaml\n services:\n stirling-pdf:\n volumes:\n - ./logs:/logs\n ```\n \n \n Add the volume mount to your Docker run command:\n ```bash\n -v ./logs:/logs\n ```", + "sourcePath": "docs/Configuration/Fail2Ban.md", + "editUrl": "https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/Configuration/Fail2Ban.md" + }, + "configuration/file-sharing-and-storage": { + "id": "configuration/file-sharing-and-storage", + "title": "File Sharing and Storage", + "description": "Configure server-side file storage, sharing, and storage quotas", + "section": "configuration", + "markdown": "> **⚠️ Warning: [Alpha Feature]**\n>\n> File Sharing and Storage is currently in **alpha**. Functionality may change, and some features are incomplete. Use in production at your own risk.\n\n\nStirling PDF can store files on the server and let users share them with each other. Files can be shared directly with specific users or via shareable links. Admins can set storage quotas to control disk usage.\n\nBasic local-disk storage and sharing need **no license** - just turn on `security.enableLogin` and `storage.enabled`. Only the **database** and **s3** storage providers require a Pro/Enterprise license. See [Modes](doc:modes-and-licensing) for what each deploy mode includes; self-hosted instances never use credits.\n\n> **💡 Tip: Already set up?**\n>\n> If your admin has turned storage on and you just want to use it, skip ahead to [The My Files Page](#the-my-files-page) and [Sharing Files](#sharing-files). The setup sections in between (storage providers, S3, quotas) are for whoever runs the server.\n\n\n---\n\n## What You Can Do\n\n- **Store files server-side** -- upload PDFs and other files that persist across sessions\n- **Share with specific users** -- grant other registered users access to your files with configurable permissions\n- **Share via link** -- generate a shareable link that any logged-in user with the link can access\n- **Control access levels** -- assign Editor, Commenter, or Viewer roles to shared users\n- **Set storage quotas** -- limit storage per user, per file, or system-wide\n- **Audit access** -- see who accessed your shared links and when\n- **Automatic cleanup** -- expired share links and orphaned files are cleaned up daily\n\n---\n\n## Prerequisites\n\n- **Authentication must be enabled** (`security.enableLogin: true`)\n- For share links: `system.frontendUrl` must be set to your instance URL\n- For email notifications when sharing: `mail.enabled: true` with valid SMTP configuration\n\n---\n\n## Enabling File Storage\n\n\n \n ```yaml\n storage:\n enabled: true\n provider: local # 'local', 'database', or 's3'\n local:\n basePath: './storage' # Filesystem path (local provider only)\n ```\n \n \n ```bash\n STORAGE_ENABLED=true\n STORAGE_PROVIDER=local\n STORAGE_LOCAL_BASEPATH=./storage\n ```\n \n\n\n### Storage Providers\n\n| Provider | Config Value | License | Description | Best For |\n|----------|-------------|---------|-------------|----------|\n| **Local Filesystem** | `local` | Free | Files stored on disk under `basePath` | Most deployments, large files |\n| **Database** | `database` | Pro/Enterprise | Files stored as BLOBs in the database | Simple setups where you want everything in one place |\n| **S3-Compatible** | `s3` | Pro/Enterprise | Files stored in an S3-compatible object store | Multi-node clusters, cloud object storage |\n\n> **💡 Tip**\n>\n> The **local** provider is recommended for most deployments and requires no license. It handles large files well and keeps database size manageable. The **database** provider is convenient but uses more memory with large files. The **s3** provider is for object-storage and multi-node deployments. The **database** and **s3** providers require a Pro/Enterprise license.\n\n\n### Docker Volume Mount\n\nWhen using the local provider with Docker, mount the storage directory so files persist across container restarts:\n\n```yaml\nvolumes:\n - ./stirling-storage:/storage\n```\n\n---\n\n## S3-Compatible Object Storage\n\n> **ℹ️ Info: [Pro/Enterprise]**\n>\n> The **s3** storage provider requires a valid Pro or Enterprise license.\n\n\nSet `storage.provider: s3` to store user uploads in any S3-compatible object store. The same `storage.s3.*` block is also used by the cluster artifact store (see below).\n\n\n \n ```yaml\n storage:\n enabled: true\n provider: s3\n s3:\n endpoint: \"\" # blank = AWS regional default; otherwise full URL incl. https://\n bucket: my-bucket # required\n region: us-east-1\n accessKey: \"\" # blank = fall back to AWS DefaultCredentialsProvider (env / profile / IMDS)\n secretKey: \"\"\n pathStyleAccess: false # true for MinIO and Supabase; false for AWS/R2/most CDNs\n allowPrivateEndpoints: false # SSRF guard - see below\n requestChecksumCalculation: WHEN_SUPPORTED # WHEN_SUPPORTED | WHEN_REQUIRED | DISABLED\n responseChecksumValidation: WHEN_SUPPORTED # WHEN_SUPPORTED | WHEN_REQUIRED | DISABLED\n ```\n \n \n ```bash\n STORAGE_ENABLED=true\n STORAGE_PROVIDER=s3\n STORAGE_S3_ENDPOINT=\n STORAGE_S3_BUCKET=my-bucket\n STORAGE_S3_REGION=us-east-1\n STORAGE_S3_ACCESSKEY=\n STORAGE_S3_SECRETKEY=\n STORAGE_S3_PATHSTYLEACCESS=false\n STORAGE_S3_ALLOWPRIVATEENDPOINTS=false\n STORAGE_S3_REQUESTCHECKSUMCALCULATION=WHEN_SUPPORTED\n STORAGE_S3_RESPONSECHECKSUMVALIDATION=WHEN_SUPPORTED\n ```\n \n\n\n### S3 Configuration Keys\n\n| Key | Default | Description |\n|-----|---------|-------------|\n| `endpoint` | _(blank)_ | Blank uses the AWS regional default. For other vendors, the full URL including `https://`. |\n| `bucket` | _(blank)_ | Required. The bucket that holds the stored files. |\n| `region` | `us-east-1` | Region of the bucket. |\n| `accessKey` | _(blank)_ | Static access key. Blank falls back to the AWS `DefaultCredentialsProvider` (env vars, profile, or IMDS). |\n| `secretKey` | _(blank)_ | Static secret key. Used together with `accessKey`. |\n| `pathStyleAccess` | `false` | Use path-style (`endpoint/bucket/key`) instead of virtual-host addressing. `true` for MinIO and Supabase. |\n| `allowPrivateEndpoints` | `false` | SSRF guard. When `false`, an endpoint that resolves to a loopback, link-local, or private (RFC1918) address is rejected at startup. Set `true` to opt in (for example in-cluster MinIO). Leave `false` for any internet-facing vendor. |\n| `requestChecksumCalculation` | `WHEN_SUPPORTED` | `WHEN_SUPPORTED`, `WHEN_REQUIRED`, or `DISABLED`. Set `WHEN_REQUIRED` if your vendor rejects auto-added `x-amz-checksum-*` headers (older Backblaze B2, some R2 corner cases). |\n| `responseChecksumValidation` | `WHEN_SUPPORTED` | `WHEN_SUPPORTED`, `WHEN_REQUIRED`, or `DISABLED`. Set `WHEN_REQUIRED` if you see false-positive checksum-mismatch errors on GET from a vendor that never returns checksum headers. |\n\n> **⚠️ Warning: [SSRF guard]**\n>\n> `allowPrivateEndpoints` defaults to `false`. The server resolves the configured `endpoint` host and refuses to start if it points at a loopback, link-local, or private IP. This blocks an admin-supplied endpoint from being pointed at the cloud metadata service (for example `http://169.254.169.254/`) to exfiltrate instance-role credentials. Only set it to `true` for a trusted in-cluster store such as MinIO.\n\n\n### Per-Vendor Cheat Sheet\n\n| Vendor | `endpoint` | `region` | `pathStyleAccess` | Notes |\n|--------|-----------|----------|-------------------|-------|\n| **AWS S3** | _(blank)_ | your region | `false` | Uses the AWS regional default. |\n| **MinIO** (in-cluster) | `http://minio:9000` | `us-east-1` | `true` | Also set `allowPrivateEndpoints: true`. |\n| **Cloudflare R2** | `https://.r2.cloudflarestorage.com` | `auto` | `false` | If uploads fail with `unsupported header x-amz-checksum-*`, set `requestChecksumCalculation: WHEN_REQUIRED`. |\n| **Supabase Storage** | `https://.supabase.co/storage/v1/s3` | your project region | `true` | Non-ASCII display filenames are fine - the storage key is opaque. |\n| **Backblaze B2** | `https://s3..backblazeb2.com` | your region | `false` | On B2 deployments older than July 2025, if uploads return `Unsupported header x-amz-checksum-crc32`, set `requestChecksumCalculation: WHEN_REQUIRED`. |\n| **DigitalOcean Spaces** | `https://.digitaloceanspaces.com` | your region | `false` | 5 GB per-object cap (regardless of multipart). |\n\n### Sharing Credentials with the Cluster Artifact Store\n\nThe `storage.s3.*` block is used in two places: the **s3** storage provider (persistent user uploads) and the cluster artifact store when `cluster.artifactStore: s3` (transient multi-node job artifacts). When both use S3 they reuse the same credentials and bucket. The cluster store writes under a separate key prefix (`cluster.s3.keyPrefix`, default `transient/`) so a single bucket can host both persistent uploads and transient artifacts without collisions. Multi-node deployments must set `cluster.artifactStore: s3`.\n\n---\n\n## Enabling File Sharing\n\n\n \n ```yaml\n storage:\n enabled: true\n sharing:\n enabled: true # Master switch for all sharing\n linkEnabled: true # Enable shareable links\n emailEnabled: true # Send email notifications when sharing\n linkExpirationDays: 3 # Days until share links expire\n ```\n \n \n ```bash\n STORAGE_SHARING_ENABLED=true\n STORAGE_SHARING_LINKENABLED=true\n STORAGE_SHARING_EMAILENABLED=true\n STORAGE_SHARING_LINKEXPIRATIONDAYS=3\n ```\n \n\n\n### Feature Dependencies\n\n| Feature | What It Needs |\n|---------|---------------|\n| File Storage | `security.enableLogin: true` |\n| File Sharing | Storage enabled |\n| Shareable Links | Sharing enabled + `system.frontendUrl` set |\n| Email Notifications | Sharing enabled + `mail.enabled: true` |\n| Shared Signing | Storage enabled + `storage.signing.enabled: true` |\n\n---\n\n## Storage Quotas\n\nControl how much storage space is available.\n\n\n \n ```yaml\n storage:\n quotas:\n maxStorageMbPerUser: -1 # Per-user cap in MB (-1 = unlimited)\n maxStorageMbTotal: -1 # Total system cap in MB (-1 = unlimited)\n maxFileMb: -1 # Max size per upload in MB (-1 = unlimited)\n ```\n \n \n ```bash\n STORAGE_QUOTAS_MAXSTORAGEMBPERUSER=500\n STORAGE_QUOTAS_MAXSTORAGEMBTOTAL=10000\n STORAGE_QUOTAS_MAXFILEMB=100\n ```\n \n\n\nQuotas are checked before a file is stored. When replacing an existing file, only the size difference counts against the quota.\n\n---\n\n## Access Roles\n\nWhen sharing a file, you choose what level of access to grant:\n\n| Role | Can View/Download | Can Replace File | In Signing Workflows |\n|------|-------------------|-----------------|---------------------|\n| **Editor** | Yes | Yes | Can sign |\n| **Commenter** | Yes | No | Can sign |\n| **Viewer** | Yes | No | Read-only |\n\nThe file **owner** always has full access. The default role is **Editor**.\n\nThe difference between Commenter and Viewer only matters in [Shared Signing](doc:functionality/security/shared-signing) workflows -- both are read-only for regular file sharing.\n\n---\n\n## The My Files Page\n\nWhen your admin has turned storage on, you get a **My Files** page (find it at `/files` once you are logged in). It is your personal space on the server for keeping documents that stick around between sessions, instead of living only in your browser tab.\n\nOn the My Files page you can:\n\n- **Upload** documents to keep them on the server\n- **Organize them into folders**, including folders inside folders, and drag files between them\n- **Preview** a stored file right in the browser without downloading it\n- **Rename, move, and delete** files and folders\n- **Jump around quickly** using the folder sidebar on the left\n- **Personalize folders** with colors and thumbnails so they are easy to spot\n- **See where each file lives** - every item shows a small badge telling you whether it is in your current browser session or saved on the server\n\nYour folders are private to you. Folders themselves are not shared; you share individual files instead (see [Sharing Files](#sharing-files) below).\n\n---\n\n## Sharing Files\n\nYou can share a file from the **My Files** page, or from the **Share** button in the top bar of the editor workbench while a file is open.\n\n### Share with a Specific User\n\nFrom the file manager, select a file and share it with another user by their username or email address. You can choose the access role when sharing.\n\nIf you enter an email address for someone who doesn't have an account, the system will create a share link and email it to them (if email notifications are enabled).\n\n### Share via Link\n\nGenerate a shareable link for any file you own. Anyone who is logged in and has the link can access the file. Links expire automatically based on your `linkExpirationDays` setting (default: 3 days).\n\nYou can revoke a share link at any time, which immediately removes access and deletes all access records for that link.\n\nThe share link URL follows the format:\n```\nhttps://your-stirling-instance.com/share/{token}\n```\n\n> **ℹ️ Info**\n>\n> Share links require the recipient to be logged in. There is no anonymous or public access -- the link is an additional credential on top of authentication.\n\n\n### Access History\n\nFor any share link you've created, you can view who accessed it, whether they viewed or downloaded the file, and when.\n\n---\n\n## Security\n\n- **All endpoints require authentication** -- there is no anonymous file access\n- **Owner-only controls** -- only the file owner can update, delete, or manage sharing\n- **Random tokens** -- share link tokens are cryptographically random UUIDs\n- **Automatic expiration** -- expired links return an error and are cleaned up daily\n- **Revocation** -- owners can revoke any share link immediately\n- **Access auditing** -- every share link access is recorded with user, action type, and timestamp\n\n---\n\n## Known Limitations\n\n- Share links require `system.frontendUrl` to be configured\n- Share links require the user to be logged in -- there is no public/anonymous access\n- The database storage provider uses more memory with large files (use the local provider for large deployments)\n\n---\n\n## Troubleshooting\n\n### \"Storage is disabled\"\n- Verify `storage.enabled: true` in your settings\n- Verify `security.enableLogin: true`\n\n### \"Share links are disabled\"\n- Verify `storage.sharing.linkEnabled: true`\n- Verify `system.frontendUrl` is set to your instance URL\n\n### \"Email sharing is disabled\"\n- Verify `storage.sharing.emailEnabled: true`\n- Verify `mail.enabled: true` with valid SMTP settings\n\n### Share link returns 404 Not Found\n- The link has expired or does not exist. Expired and missing links both return **404 Not Found**. The file owner needs to create a new one.\n\n### Quota exceeded (HTTP 413)\n- A file or upload that exceeds a configured quota is rejected with **413 Payload Too Large** (per-file, per-user, and total-storage caps all use this status)\n- Increase `maxStorageMbPerUser`, `maxStorageMbTotal`, or `maxFileMb`, or delete unused files to free up space\n\n---\n\n## Developer Reference: Storage API\n\nThis section is for developers and admins automating storage outside the web app. If you just want to upload, organize, and share files, everything above is done from the **My Files** page - you do not need any of this.\n\nThe full storage and sharing endpoints are listed below. See [API Documentation](doc:api) for authentication and general usage.\n\n| Method | Endpoint | Description |\n|--------|----------|-------------|\n| POST | `/api/v1/storage/files` | Upload file |\n| PUT | `/api/v1/storage/files/{id}` | Update file (owner only) |\n| GET | `/api/v1/storage/files` | List accessible files |\n| GET | `/api/v1/storage/files/{id}` | Get file metadata |\n| GET | `/api/v1/storage/files/{id}/download` | Download file |\n| DELETE | `/api/v1/storage/files/{id}` | Delete file (owner only) |\n| POST | `/api/v1/storage/files/{id}/shares/users` | Share with user |\n| DELETE | `/api/v1/storage/files/{id}/shares/users/{username}` | Revoke user share |\n| DELETE | `/api/v1/storage/files/{id}/shares/self` | Leave shared file |\n| POST | `/api/v1/storage/files/{id}/shares/links` | Create share link |\n| DELETE | `/api/v1/storage/files/{id}/shares/links/{token}` | Revoke share link |\n| GET | `/api/v1/storage/share-links/{token}` | Access via share link |\n| GET | `/api/v1/storage/share-links/{token}/metadata` | Get share link info |\n| GET | `/api/v1/storage/share-links/accessed` | List your accessed links |\n| GET | `/api/v1/storage/files/{id}/shares/links/{token}/accesses` | Access history (owner only) |\n\n### Folder Endpoints\n\nThese are the endpoints behind the folders on the **My Files** page. All operations are scoped to the authenticated user.\n\n| Method | Endpoint | Description |\n|--------|----------|-------------|\n| GET | `/api/v1/storage/folders` | List your folders |\n| POST | `/api/v1/storage/folders` | Create a folder |\n| PATCH | `/api/v1/storage/folders/{folderId}` | Update a folder (name, appearance) |\n| DELETE | `/api/v1/storage/folders/{folderId}` | Delete a folder |\n| PATCH | `/api/v1/storage/files/{fileId}/folder` | Move a single file to a folder (or to root when `folderId` is null) |\n| PATCH | `/api/v1/storage/files/folder` | Bulk-move files to a folder (up to 1000 per request) |\n\n---\n\n## Related\n\n- [Shared Signing](doc:functionality/security/shared-signing) -- Collaborative multi-participant document signing\n- [Certificate Signing](doc:functionality/security/certificate-signing) -- Individual certificate signing\n- [System and Security Settings](doc:configuration/system-and-security) -- JWT, sessions, server certificates", + "sourcePath": "docs/Configuration/File Sharing and Storage.md", + "editUrl": "https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/Configuration/File Sharing and Storage.md" + }, + "configuration/folderscanning": { + "id": "configuration/folderscanning", + "title": "Folder Scanning", + "section": "configuration", + "markdown": "## User Guide for Local Directory Scanning and File Processing\n\nFolder scanning uses settings configured from our pipeline tool, it is advised you first read the [Pipeline Guide](doc:configuration/pipeline)\n### Setting Up Watched Folders\n\n- Create a folder where you want your files to be monitored. This is your 'watched folder'.\n- The default directory for this is `./pipeline/watchedFolders/`.\n- Place any directories you want to be scanned into this folder. This folder should contain multiple folders, each for their own tasks and pipelines.\n\n### Configuring Processing with JSON Files\n\n- In each directory you want processed (e.g., `./pipeline/watchedFolders/officePrinter`), include a JSON configuration file.\n- This JSON file should specify how you want the files in the directory to be handled (e.g., what operations to perform on them). This can be made, configured, and downloaded from the Stirling PDF Pipeline interface. For JSON creation guide please see [Pipeline setup](doc:configuration/pipeline)\n\n### Automatic Scanning and Processing\n\n- The system automatically checks the watched folder every minute for new directories and files to process.\n- When a directory with a valid JSON configuration file is found, it begins processing the files inside according to the configuration.\n\n### Processing Steps\n\n- Files in each directory are processed according to the instructions in the JSON file.\n- This might involve file conversions, data filtering, renaming files, etc. If the output of a step is a zip, this zip will be automatically unzipped as it passes to the next process.\n\n### Results and Output\n\n- After processing, the results are saved in a specified output location. This could be a different folder or location as defined in the JSON file or the default location `./pipeline/finishedFolders/`.\n- Each processed file is named and organized according to the rules set in the JSON configuration.\n\n### Completion and Cleanup\n\n- Once processing is complete, the original files in the watched folder's directory are removed.\n- You can find the processed files in the designated output location.\n\n### Error Handling\n\n- If there's an error during processing, the system will not delete the original files, allowing you to check and retry if necessary.\n\n### User Interaction\n\n- As a user, your main tasks are to set up the watched folders, place directories with files for processing, and create the corresponding JSON configuration files.\n- The system handles the rest, including scanning, processing, and outputting results.", + "sourcePath": "docs/Configuration/FolderScanning.md", + "editUrl": "https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/Configuration/FolderScanning.md" + }, + "configuration/google-drive-file-picker": { + "id": "configuration/google-drive-file-picker", + "title": "Google Drive File Picker", + "section": "configuration", + "markdown": "> **Tier**: Server\n\nStirling PDF allows users to select Files for processing through tools via google drive.\n\n## Google Api Access\nTo enable this features for your users, you must first set up your Google environment. This includes creating a Google Cloud project. Follow the **Setting up your environment** section of [this guide](https://developers.google.com/workspace/drive/picker/guides/overview#setup) to do so.\n\n## Stirling PDF configuration\n\n```yaml\npremium:\n ...\n enabled: true # Enable license key checks for pro/enterprise features\n proFeatures:\n ...\n googleDrive:\n enabled: true\n clientId: \n apiKey: \n appId: \n```\n- `premium.enabled`: Set to `true` to enable premium features. \n- `googleDrive.enabled`: Set to `true` to enable google drive file picker features. \n- `googleDrive.clientId`: Your Google web app's client ID. [Go to Credentials](https://console.cloud.google.com/apis/credentials) and Click **Create credentials > OAuth client ID**.\n- `googleDrive.apiKey`: API key for google api access. [Go to Credentials](https://console.cloud.google.com/apis/credentials) and Click **Create credentials > API key**.\n- `googleDrive.appId`: Google drive app ID also known as your Project Number Found in your [IAM&Admin Project Settings](https://console.cloud.google.com/iam-admin/settings)\n\n\n > #### ⚠️ Note\n> _You must set the Authorized Javascript origins for your OAuth client ID to include your Stirling PDF host domain or IP address._\n\n## Configurations Examples\nBelow are examples of the full configuration for enabling the google Drive Picker:\n\n\n \n ```yaml\n premium:\n enabled: true # Enable license key checks for pro/enterprise features\n proFeatures:\n googleDrive:\n enabled: true\n clientId: \n apiKey: \n appId: \n ```\n \n \n ```bash\n export PREMIUM_ENABLED=true\n export PREMIUM_PRO_FEATURES_GOOGLE_DRIVE_ENABLED=true\n export PREMIUM_PRO_FEATURES_GOOGLE_DRIVE_CLIENT_ID=\"\"\n export PREMIUM_PRO_FEATURES_GOOGLE_DRIVE_API_KEY=\"\"\n export PREMIUM_PRO_FEATURES_GOOGLE_DRIVE_APP_ID=\"\"\n ```\n \n \n ```bash\n -e PREMIUM_ENABLED=true \\\n -e PREMIUM_PRO_FEATURES_GOOGLE_DRIVE_ENABLED=true \\\n -e PREMIUM_PRO_FEATURES_GOOGLE_DRIVE_CLIENT_ID=\"\" \\\n -e PREMIUM_PRO_FEATURES_GOOGLE_DRIVE_API_KEY=\"\" \\\n -e PREMIUM_PRO_FEATURES_GOOGLE_DRIVE_APP_ID=\"\" \\\n ```\n \n \n ```yaml\n environment:\n PREMIUM_ENABLED: true\n PREMIUM_PRO_FEATURES_GOOGLE_DRIVE_ENABLED: true\n PREMIUM_PRO_FEATURES_GOOGLE_DRIVE_CLIENT_ID: \n PREMIUM_PRO_FEATURES_GOOGLE_DRIVE_API_KEY: \n PREMIUM_PRO_FEATURES_GOOGLE_DRIVE_APP_ID: \n ```", + "sourcePath": "docs/Configuration/Google Drive File Picker.md", + "editUrl": "https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/Configuration/Google Drive File Picker.md" + }, + "configuration/keyboard-shortcuts": { + "id": "configuration/keyboard-shortcuts", + "title": "Keyboard Shortcuts", + "section": "configuration", + "markdown": "Stirling PDF supports keyboard shortcuts for quick tool access and PDF viewer navigation. Custom shortcuts are saved in your browser's local storage, with plans to bind to your account in the future.\n\n## Tool Shortcuts\n\n### Default shortcuts\n\nTools in the Quick Access bar (the \"Recommended Tools\" category) automatically receive shortcuts:\n\n| Windows / Linux | Mac | Action |\n|---|---|---|\n| `Ctrl + Alt + 1` | `⌘ + ⌥ + 1` | First Quick Access tool |\n| `Ctrl + Alt + 2` | `⌘ + ⌥ + 2` | Second Quick Access tool |\n| `Ctrl + Alt + 3` | `⌘ + ⌥ + 3` | Third Quick Access tool |\n| … up to `9` | | |\n\nAll other tools have no default shortcut but can be assigned one.\n\n### Customising shortcuts\n\n1. Open **Settings** (gear icon at the bottom of the Quick Access bar)\n2. Go to **Keyboard Shortcuts**\n3. Find the tool you want\n4. Click **Change shortcut**, then press your key combination\n5. Press **Esc** to cancel\n\nYour shortcut must include at least one modifier key (`Ctrl`, `Alt`, or `Cmd`). `Shift` alone does not count as a modifier. If the combination is already taken by another tool, you'll see a conflict warning.\n\n### Resetting a shortcut\n\nClick **Reset** next to any tool to restore its default. If a tool has no default, Reset clears the custom binding.\n\n## PDF Viewer Shortcuts\n\nThese shortcuts work when the PDF viewer is active (hovered), except for Print, Select all text, and zoom which work whenever the viewer is open.\n\n### Modifier shortcuts (Ctrl / Cmd + key)\n\n| Windows / Linux | Mac | Action |\n|---|---|---|\n| `Ctrl + P` | `⌘ + P` | Print (works globally when viewer is mounted) |\n| `Ctrl + A` | `⌘ + A` | Select all text (works globally when viewer is open) |\n| `Ctrl + F` | `⌘ + F` | Open / focus search |\n| `Ctrl + S` | `⌘ + S` | Save / apply changes |\n| `Ctrl + Z` | `⌘ + Z` | Undo |\n| `Ctrl + Shift + Z` | `⌘ + ⇧ + Z` | Redo |\n| `Ctrl + Y` | `⌘ + Y` | Redo (alternative) |\n| `Ctrl + =` / `Ctrl + +` | `⌘ + =` / `⌘ + +` | Zoom in |\n| `Ctrl + -` | `⌘ + -` | Zoom out |\n| `Ctrl + 0` | `⌘ + 0` | Reset zoom (fit width) |\n\n### Navigation shortcuts (no modifier needed)\n\n| Key | Action |\n|---|---|\n| `Home` | Jump to first page |\n| `End` | Jump to last page |\n| `Page Up` | Previous page |\n| `Page Down` | Next page |\n| `Escape` | Close search |\n\n### Rotate tool\n\nWhen the Rotate tool is active:\n\n| Key | Action |\n|---|---|\n| `←` (Left Arrow) | Rotate left |\n| `→` (Right Arrow) | Rotate right |\n\n## Desktop App\n\nThe desktop app adds one extra shortcut:\n\n| Windows / Linux | Mac | Action |\n|---|---|---|\n| `Ctrl + S` | `⌘ + S` | Save selected files to disk |", + "sourcePath": "docs/Configuration/Keyboard-Shortcuts.md", + "editUrl": "https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/Configuration/Keyboard-Shortcuts.md" + }, + "configuration/libreoffice-parallel-processing": { + "id": "configuration/libreoffice-parallel-processing", + "title": "LibreOffice Parallel Processing", + "description": "Configure multiple LibreOffice instances for parallel document conversion", + "section": "configuration", + "markdown": "Stirling PDF uses LibreOffice for converting office documents (DOCX, XLSX, PPTX, etc.) to PDF and other formats. LibreOffice processes each conversion in a single thread, meaning one conversion uses one CPU core at 100% regardless of how many cores are available. To process multiple conversions at the same time, you need to run multiple LibreOffice instances.\n\n---\n\n## Local UNO Server Pool\n\nBy default, Stirling PDF manages a local pool of UNO (Universal Network Objects) server instances. The number of instances is controlled by the `libreOfficeSessionLimit` setting.\n\n\n \n ```yaml\n processExecutor:\n autoUnoServer: true\n sessionLimit:\n libreOfficeSessionLimit: 4 # Run 4 LibreOffice instances\n ```\n \n \n ```bash\n PROCESS_EXECUTOR_SESSION_LIMIT_LIBRE_OFFICE_SESSION_LIMIT=4\n ```\n \n \n ```yaml\n services:\n stirling-pdf:\n image: docker.stirlingpdf.com/stirlingtools/stirling-pdf:latest\n environment:\n PROCESS_EXECUTOR_SESSION_LIMIT_LIBRE_OFFICE_SESSION_LIMIT: 4\n ```\n \n\n\nA reasonable starting point is one instance per 2 CPU cores. See [Host resource requirements](#host-resource-requirements) for memory and storage sizing.\n\n> **ℹ️ Info**\n>\n> The default `libreOfficeSessionLimit` is `1`, meaning only one conversion runs at a time. If you see conversions queuing up or running slowly, increasing this is the first thing to try.\n\n\n### Throughput expectations\n\nPer-conversion time varies from sub-second (small DOCX) to tens of seconds (complex PPTX, large spreadsheets). Pool throughput scales roughly linearly with worker count up to host CPU saturation - benchmark a representative document before sizing.\n\n---\n\n## Remote unoservers\n\nFor larger deployments, or when you want to isolate LibreOffice from the main application, run UNO servers as separate containers and configure Stirling PDF to connect to them remotely. This is a two-step setup: start the unoserver containers, then point Stirling PDF at them.\n\n### Starting unoserver Containers\n\nEach container is a single worker that listens internally on port `2003`. Expose it on a different host port per instance if you want to reach them from outside Docker or from another host.\n\n\n \n ```yaml\n services:\n unoserver1:\n image: ghcr.io/stirling-tools/stirling-unoserver:latest\n ports:\n - \"2003:2003\"\n\n unoserver2:\n image: ghcr.io/stirling-tools/stirling-unoserver:latest\n ports:\n - \"2004:2003\"\n ```\n Add these alongside your `stirling-pdf` service. Host-port mappings are only required if Stirling PDF runs outside Docker, on a different host, or on a separate Docker network.\n \n \n ```bash\n docker run -d --name unoserver1 -p 2003:2003 \\\n ghcr.io/stirling-tools/stirling-unoserver:latest\n\n docker run -d --name unoserver2 -p 2004:2003 \\\n ghcr.io/stirling-tools/stirling-unoserver:latest\n ```\n \n\n\nFor tunable options (timeouts, periodic recycling, CJK fonts), see [The `stirling-unoserver` Image](#the-stirling-unoserver-image) below.\n\n### Connecting Stirling PDF to Remote Endpoints\n\nOnce your unoserver containers are running, set `autoUnoServer` to `false` and point Stirling PDF at them:\n\n\n \n ```yaml\n processExecutor:\n autoUnoServer: false\n unoServerEndpoints:\n - host: \"unoserver1\"\n port: 2003\n hostLocation: \"remote\"\n protocol: \"http\"\n - host: \"unoserver2\"\n port: 2003\n hostLocation: \"remote\"\n protocol: \"http\"\n - host: \"unoserver3\"\n port: 2003\n hostLocation: \"remote\"\n protocol: \"http\"\n ```\n \n \n ```bash\n PROCESS_EXECUTOR_AUTO_UNO_SERVER=false\n PROCESS_EXECUTOR_UNO_SERVER_ENDPOINTS_0_HOST=unoserver1\n PROCESS_EXECUTOR_UNO_SERVER_ENDPOINTS_0_PORT=2003\n PROCESS_EXECUTOR_UNO_SERVER_ENDPOINTS_0_HOST_LOCATION=remote\n PROCESS_EXECUTOR_UNO_SERVER_ENDPOINTS_0_PROTOCOL=http\n PROCESS_EXECUTOR_UNO_SERVER_ENDPOINTS_1_HOST=unoserver2\n PROCESS_EXECUTOR_UNO_SERVER_ENDPOINTS_1_PORT=2003\n PROCESS_EXECUTOR_UNO_SERVER_ENDPOINTS_1_HOST_LOCATION=remote\n PROCESS_EXECUTOR_UNO_SERVER_ENDPOINTS_1_PROTOCOL=http\n ```\n \n \n ```yaml\n services:\n stirling-pdf:\n image: docker.stirlingpdf.com/stirlingtools/stirling-pdf:latest\n ports:\n - \"8080:8080\"\n environment:\n PROCESS_EXECUTOR_AUTO_UNO_SERVER: \"false\"\n PROCESS_EXECUTOR_UNO_SERVER_ENDPOINTS_0_HOST: \"unoserver1\"\n PROCESS_EXECUTOR_UNO_SERVER_ENDPOINTS_0_PORT: \"2003\"\n PROCESS_EXECUTOR_UNO_SERVER_ENDPOINTS_0_HOST_LOCATION: \"remote\"\n PROCESS_EXECUTOR_UNO_SERVER_ENDPOINTS_1_HOST: \"unoserver2\"\n PROCESS_EXECUTOR_UNO_SERVER_ENDPOINTS_1_PORT: \"2003\"\n PROCESS_EXECUTOR_UNO_SERVER_ENDPOINTS_1_HOST_LOCATION: \"remote\"\n ```\n \n\n\nTo add more endpoints, add additional entries to the `unoServerEndpoints` list in settings.yml, or for environment variables, increment the index number (e.g. `_0_` for the first, `_1_` for the second, `_2_` for the third, and so on).\n\n> **💡 Tip**\n>\n> Set `libreOfficeSessionLimit` to match your endpoint count so the pool uses all of them concurrently. With 3 endpoints and a session limit of 1, you'll only ever use one at a time.\n\n\n#### Endpoint Configuration\n\nThe `host` field accepts a Docker service name (e.g. `unoserver1`), a DNS hostname (e.g. `uno.internal.example.com`), or an IP address (e.g. `192.168.1.50`). The default is `127.0.0.1`.\n\nThe `hostLocation` setting controls how files are transferred between Stirling PDF and the UNO server:\n\n| Value | When to Use | How It Works |\n|---|---|---|\n| `auto` | Default, detects automatically | Checks if the host is local or remote |\n| `local` | UNO server is on the same machine | Files are passed via filesystem paths (fastest) |\n| `remote` | UNO server is a separate container or machine | Files are transferred over HTTP |\n\n> **⚠️ Caution**\n>\n> Use `remote` when running UNO servers in separate Docker containers, even if the containers are on the same host machine. The containers don't share a filesystem, so `local` will not work.\n\n\n---\n\n## The `stirling-unoserver` Image\n\n`ghcr.io/stirling-tools/stirling-unoserver` is the official standalone worker image.\n\n> **⚠️ Caution: Alpha release**\n>\n> The `stirling-unoserver` image is currently in **alpha**. Only the `:alpha` tag is published today. `:latest` and versioned tags (`:1.0.0`, `:1.0.1`, etc.) will follow once we cut the first stable release. The compose examples on this page reference `:latest` for forward-compatibility, so for now substitute `:alpha` until the stable release is announced. Configuration variables and behaviour are not expected to change between alpha and 1.0.\n\n\n| Tag | Status | Use |\n|---|---|---|\n| `:alpha` | **Available now** | All deployments while the image is in alpha |\n| `:latest` | Coming soon | Production once 1.0 ships |\n| `:1.0.0`, `:1.0.1`, etc. | Coming soon | Pinned version once 1.0 ships |\n\nThe latest `stirling-unoserver` image is always compatible with the latest Stirling PDF, so if you track `:latest` (or `:alpha` today) on both you don't need to coordinate upgrades. The image will be versioned independently from Stirling PDF, so you can also pin a specific version and update it on its own cadence. Any compatibility breaks will be called out in release notes.\n\n### Configuration\n\n| Variable | Default | Purpose |\n|---|---|---|\n| `UNOSERVER_PORT` | `2003` | Listen port. |\n| `UNOSERVER_INTERFACE` | `0.0.0.0` | Listen address; use `127.0.0.1` to restrict to the same host. |\n| `UNOSERVER_CONVERSION_TIMEOUT` | `1800` (s) | Max time per conversion. Set ≥ `libreOfficeTimeoutMinutes`. |\n| `UNOSERVER_RECYCLE_INTERVAL_SECONDS` | `0` (off) | Periodic restart to bound LibreOffice memory growth. Minimum 60 s; e.g. `3600` for hourly. |\n\n### CPU allocation\n\nCore allocation between instances should be handled automatically by the Linux scheduler. However, if you see uneven core usage or want to cap how much CPU each worker can use, you have two options:\n\n**Soft cap (recommended).** Limit each container to a CPU budget. The kernel still picks which cores to use.\n\n```yaml\nservices:\n unoserver1:\n image: ghcr.io/stirling-tools/stirling-unoserver:alpha\n deploy:\n resources:\n limits:\n cpus: \"2.0\" # up to 2 cores worth of CPU time\n memory: 1g\n```\n\nFor `docker run`: `--cpus=\"2.0\"`.\n\n**Hard pinning.** Bind each container to specific cores. Use this only if the soft cap isn't enough.\n\n```yaml\nservices:\n unoserver1:\n image: ghcr.io/stirling-tools/stirling-unoserver:alpha\n cpuset: \"0,1\"\n unoserver2:\n image: ghcr.io/stirling-tools/stirling-unoserver:alpha\n cpuset: \"2,3\"\n```\n\nFor `docker run`: `--cpuset-cpus=\"0,1\"`. For systemd-managed unoservers, the equivalent is `CPUAffinity=0 1` in the service unit.\n\n### CJK fonts\n\nThe default image covers European languages with hyphenation for EN/FR/DE/ES/IT/PT/NL/PL/RU. For Chinese/Japanese/Korean, rebuild with `--build-arg INSTALL_CJK_FONTS=true` (~120 MB extra).\n\n---\n\n## Running UNO Servers Without Docker\n\nIf you are running Stirling PDF without Docker (bare metal or systemd), you can start additional UNO server instances manually using the `unoserver` Python package:\n\n```bash\n# Install unoserver (included in Docker images)\npip install unoserver\n\n# Start instances on different ports\nunoserver --port 2003 &\nunoserver --port 2004 &\nunoserver --port 2005 &\n```\n\nThen configure Stirling PDF to connect to these instances at `127.0.0.1` on the respective ports with `hostLocation: \"local\"`.\n\n---\n\n## Timeout Configuration\n\nLibreOffice conversion has a default timeout of **30 minutes**. For very large or complex documents, you may need to increase this:\n\n\n \n ```yaml\n processExecutor:\n timeoutMinutes:\n libreOfficetimeoutMinutes: 60\n ```\n \n \n ```bash\n PROCESS_EXECUTOR_TIMEOUT_MINUTES_LIBRE_OFFICETIMEOUT_MINUTES=60\n ```\n \n\n\nIf conversions are consistently timing out, this usually indicates the system is under-resourced rather than needing a longer timeout. Check CPU and memory usage first.\n\n---\n\n## Host resource requirements\n\n- **Memory** - ~70 MB idle, 140–250 MB during conversion, per worker. Add headroom for the OS and Stirling PDF itself.\n- **CPU** - each active conversion saturates roughly one CPU core (LibreOffice is single-threaded per document). Start with one worker per two cores; the kernel handles core distribution automatically. See [CPU allocation](#cpu-allocation) if you want to cap or pin workers explicitly.\n\n---\n\n## Related\n\n- [Process Limits](doc:configuration/process-limits) - Configure session limits and timeouts for all external tools\n- [Production Deployment Guide](doc:server-admin-onboarding) - Sizing recommendations for different workloads\n- [Diagnostics](doc:configuration/diagnostics) - Collect system and application diagnostics for troubleshooting", + "sourcePath": "docs/Configuration/LibreOffice-Parallel-Processing.md", + "editUrl": "https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/Configuration/LibreOffice-Parallel-Processing.md" + }, + "configuration/mobile-scanner": { + "id": "configuration/mobile-scanner", + "title": "Mobile Scanner Configuration", + "description": "Enable and configure Mobile Scanner for document scanning via phone camera", + "section": "configuration", + "markdown": "Enable and configure the Mobile Scanner feature, which lets users scan documents with their phone camera and upload them directly to Stirling PDF via QR code.\n\n## Settings\n\n\n \n ```yaml\n system:\n enableMobileScanner: true\n mobileScannerSettings:\n convertToPdf: true # Convert images to PDF (true/false)\n imageResolution: full # 'full' (original size) or 'reduced' (max 1200px)\n pageFormat: A4 # 'keep' (original dimensions), 'A4', or 'letter'\n stretchToFit: false # Stretch images to fill page (may distort)\n ```\n \n \n ```bash\n SYSTEM_ENABLEMOBILESCANNER=true\n SYSTEM_MOBILESCANNERSETTINGS_CONVERTTOPDF=true\n SYSTEM_MOBILESCANNERSETTINGS_IMAGERESOLUTION=full\n SYSTEM_MOBILESCANNERSETTINGS_PAGEFORMAT=A4\n SYSTEM_MOBILESCANNERSETTINGS_STRETCHTOFIT=false\n ```\n \n \n ```bash\n docker run -d \\\n -p 8080:8080 \\\n -e SYSTEM_ENABLEMOBILESCANNER=true \\\n -e SYSTEM_MOBILESCANNERSETTINGS_CONVERTTOPDF=true \\\n -e SYSTEM_MOBILESCANNERSETTINGS_IMAGERESOLUTION=full \\\n -e SYSTEM_MOBILESCANNERSETTINGS_PAGEFORMAT=A4 \\\n -e SYSTEM_MOBILESCANNERSETTINGS_STRETCHTOFIT=false \\\n stirlingtools/stirling-pdf:latest\n ```\n \n \n ```yaml\n environment:\n SYSTEM_ENABLEMOBILESCANNER: true\n SYSTEM_MOBILESCANNERSETTINGS_CONVERTTOPDF: true\n SYSTEM_MOBILESCANNERSETTINGS_IMAGERESOLUTION: full\n SYSTEM_MOBILESCANNERSETTINGS_PAGEFORMAT: A4\n SYSTEM_MOBILESCANNERSETTINGS_STRETCHTOFIT: false\n ```\n \n\n\n## Configuration Options\n\n| Setting | Values | Description |\n|---------|--------|-------------|\n| `enableMobileScanner` | `true` / `false` | Enable/disable Mobile Scanner feature |\n| `convertToPdf` | `true` / `false` | Automatically convert uploaded images to PDF. If false, images are kept as-is. |\n| `imageResolution` | `full` / `reduced` | Image resolution for PDF conversion: `full` = original size, `reduced` = max 1200px on longest side. Only applies when `convertToPdf` is true. |\n| `pageFormat` | `keep` / `A4` / `letter` | Page format for converted PDFs: `keep` = original image dimensions, `A4` = A4 page size, `letter` = US Letter page size. Only applies when `convertToPdf` is true. |\n| `stretchToFit` | `true` / `false` | Stretch images to fill entire page (may distort aspect ratio). If false, images are centered with preserved aspect ratio. Only applies when `convertToPdf` is true. |\n\n## Desktop app behaviour\n\nThe Stirling PDF desktop app has built-in support for Mobile Scanner. For the end-user walkthrough, see [Mobile Scanner](doc:functionality/mobile-scanner).\n\nAdmin note specific to the desktop app:\n\n- In desktop mode the app serves its own simple upload page (controlled by the `STIRLING_PDF_TAURI_MODE` setting) instead of the standard web `/mobile-scanner` page, because a phone cannot load the desktop app's own window.", + "sourcePath": "docs/Configuration/Mobile-Scanner.md", + "editUrl": "https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/Configuration/Mobile-Scanner.md" + }, + "configuration/oauth-sso-configuration": { + "id": "configuration/oauth-sso-configuration", + "title": "OAuth 2.0 Single Sign-On Configuration", + "section": "configuration", + "markdown": "> **Tier**: Server\n\nStirling PDF supports Single Sign-On (SSO) using OAuth 2.0 OpenID Connect (OIDC). This allows users to log in using accounts from external providers such as Google, GitHub, Keycloak, Authentik, and others.\n\n> **Looking for SAML 2.0 SSO?** See [SAML SSO Configuration](doc:configuration/saml-sso-configuration/saml-sso-configuration) (Enterprise tier).\n\n## Prerequisites\n\nBefore configuring OAuth 2.0 SSO, ensure you have:\n\n- [ ] Stirling PDF with login enabled (`security.enableLogin: true`)\n- [ ] Valid license for the Server tier or higher\n- [ ] An OAuth 2.0 provider account (Google, GitHub, Keycloak, etc.)\n- [ ] Registered OAuth application with your provider\n- [ ] OAuth Client ID and Client Secret from your provider\n- [ ] Public HTTPS URL configured with `system.backendUrl` set to your public backend API URL (often same as frontend, verify `https://your-domain.com/api/v1/info/status` is accessible)\n- [ ] Callback URL added to provider: `https://your-domain.com/login/oauth2/code/`\n\n> **Tip**: Start with `loginMethod: all` during initial setup to allow both username/password and OAuth login. This ensures you can always access the admin account if SSO configuration needs adjustment.\n\n## Setup Guide\n\n### Step 1: Configure Login Settings\n\nEnable login and set the login method to allow both standard and OAuth authentication during initial setup.\n\n\n \n ```yaml\n security:\n enableLogin: true\n loginMethod: all # Allows both username/password and OAuth login\n ```\n \n \n ```bash\n SECURITY_ENABLELOGIN=true\n SECURITY_LOGINMETHOD=all\n ```\n \n\n\n**Login Method Options:**\n- `all`: Enables all login methods (username/password + OAuth 2)\n- `normal`: Username/password only\n- `oauth2`: OAuth 2 SSO only (disables username/password login)\n- `saml2`: SAML 2 SSO only (Enterprise tier)\n\n### Step 2: Create Initial Admin Account\n\nBefore enabling OAuth, create an initial admin account using one of these methods:\n\n**Option A: Use initialLogin credentials** (recommended for first setup)\n\n\n \n ```yaml\n security:\n initialLogin:\n username: 'admin'\n password: 'yourSecurePassword123'\n ```\n \n \n ```bash\n SECURITY_INITIALLOGIN_USERNAME=admin\n SECURITY_INITIALLOGIN_PASSWORD=yourSecurePassword123\n ```\n \n\n\n**Option B: Create admin manually**\n1. Access Stirling PDF with OAuth disabled\n2. Create an admin user through the UI\n3. Then enable OAuth\n\n### Step 3: Configure OAuth Provider\n\nSet `security.oauth2.enabled` to `true` and configure your chosen provider.\n\n\n \n \n \n ```yaml\n security:\n oauth2:\n enabled: true\n client:\n google:\n clientId: \n clientSecret: \n scopes: email, profile\n useAsUsername: email\n provider: google\n autoCreateUser: true\n blockRegistration: false\n ```\n \n \n ```bash\n SECURITY_OAUTH2_ENABLED=true\n SECURITY_OAUTH2_CLIENT_GOOGLE_CLIENTID=\n SECURITY_OAUTH2_CLIENT_GOOGLE_CLIENTSECRET=\n SECURITY_OAUTH2_CLIENT_GOOGLE_SCOPES=email, profile\n SECURITY_OAUTH2_CLIENT_GOOGLE_USEASUSERNAME=email\n SECURITY_OAUTH2_PROVIDER=google\n SECURITY_OAUTH2_AUTOCREATEUSER=true\n SECURITY_OAUTH2_BLOCKREGISTRATION=false\n ```\n \n \n\n **Provider Setup:**\n 1. Go to [Google Cloud Console](https://console.cloud.google.com/)\n 2. Create a new project or select existing\n 3. Enable Google+ API\n 4. Create OAuth 2.0 credentials (Web application)\n 5. Add authorized redirect URI: `https://your-domain.com/login/oauth2/code/google`\n 6. Copy Client ID and Client Secret\n \n \n \n \n ```yaml\n security:\n oauth2:\n enabled: true\n client:\n github:\n clientId: \n clientSecret: \n scopes: read:user\n useAsUsername: login\n provider: github\n autoCreateUser: true\n blockRegistration: false\n ```\n \n \n ```bash\n SECURITY_OAUTH2_ENABLED=true\n SECURITY_OAUTH2_CLIENT_GITHUB_CLIENTID=\n SECURITY_OAUTH2_CLIENT_GITHUB_CLIENTSECRET=\n SECURITY_OAUTH2_CLIENT_GITHUB_SCOPES=read:user\n SECURITY_OAUTH2_CLIENT_GITHUB_USEASUSERNAME=login\n SECURITY_OAUTH2_PROVIDER=github\n SECURITY_OAUTH2_AUTOCREATEUSER=true\n SECURITY_OAUTH2_BLOCKREGISTRATION=false\n ```\n \n \n\n **Provider Setup:**\n 1. Go to [GitHub Developer Settings](https://github.com/settings/developers)\n 2. Create new OAuth App\n 3. Set Authorization callback URL: `https://your-domain.com/login/oauth2/code/github`\n 4. Copy Client ID and generate Client Secret\n \n \n \n \n ```yaml\n security:\n oauth2:\n enabled: true\n issuer: https://your-keycloak.com/realms/your-realm\n clientId: \n clientSecret: \n scopes: openid, profile, email\n useAsUsername: preferred_username\n provider: keycloak\n autoCreateUser: true\n blockRegistration: false\n ```\n \n \n ```bash\n SECURITY_OAUTH2_ENABLED=true\n SECURITY_OAUTH2_ISSUER=https://your-keycloak.com/realms/your-realm\n SECURITY_OAUTH2_CLIENTID=\n SECURITY_OAUTH2_CLIENTSECRET=\n SECURITY_OAUTH2_SCOPES=openid, profile, email\n SECURITY_OAUTH2_USEASUSERNAME=preferred_username\n SECURITY_OAUTH2_PROVIDER=keycloak\n SECURITY_OAUTH2_AUTOCREATEUSER=true\n SECURITY_OAUTH2_BLOCKREGISTRATION=false\n ```\n \n \n\n **Provider Setup:**\n 1. Access your Keycloak admin console\n 2. Select your realm\n 3. Create new client (OpenID Connect)\n 4. Set Valid Redirect URIs: `https://your-domain.com/login/oauth2/code/keycloak`\n 5. Enable \"Client authentication\" for confidential access\n 6. Copy Client ID and Client Secret from Credentials tab\n \n \n \n \n ```yaml\n security:\n oauth2:\n enabled: true\n issuer: https://your-authentik.com/application/o/stirling-pdf/\n clientId: \n clientSecret: \n scopes: openid, profile, email\n useAsUsername: preferred_username\n provider: authentik\n autoCreateUser: true\n blockRegistration: false\n ```\n \n \n ```bash\n SECURITY_OAUTH2_ENABLED=true\n SECURITY_OAUTH2_ISSUER=https://your-authentik.com/application/o/stirling-pdf/\n SECURITY_OAUTH2_CLIENTID=\n SECURITY_OAUTH2_CLIENTSECRET=\n SECURITY_OAUTH2_SCOPES=openid, profile, email\n SECURITY_OAUTH2_USEASUSERNAME=preferred_username\n SECURITY_OAUTH2_PROVIDER=authentik\n SECURITY_OAUTH2_AUTOCREATEUSER=true\n SECURITY_OAUTH2_BLOCKREGISTRATION=false\n ```\n \n \n\n **Provider Setup:**\n 1. Create new Provider (OAuth2/OpenID)\n 2. Create new Application\n 3. Set Redirect URIs: `https://your-domain.com/login/oauth2/code/authentik`\n 4. Copy Client ID and Client Secret\n \n \n \n \n ```yaml\n security:\n oauth2:\n enabled: true\n issuer: \n clientId: \n clientSecret: \n scopes: openid, profile, email\n useAsUsername: email\n provider: \n autoCreateUser: true\n blockRegistration: false\n ```\n \n \n ```bash\n SECURITY_OAUTH2_ENABLED=true\n SECURITY_OAUTH2_ISSUER=\n SECURITY_OAUTH2_CLIENTID=\n SECURITY_OAUTH2_CLIENTSECRET=\n SECURITY_OAUTH2_SCOPES=openid, profile, email\n SECURITY_OAUTH2_USEASUSERNAME=email\n SECURITY_OAUTH2_PROVIDER=\n SECURITY_OAUTH2_AUTOCREATEUSER=true\n SECURITY_OAUTH2_BLOCKREGISTRATION=false\n ```\n \n \n\n **Requirements:**\n - Provider must support OpenID Connect Discovery\n - Must expose `/.well-known/openid-configuration` endpoint\n \n\n\n### Step 4: Configure Callback URL\n\nWhen registering your application with the OAuth provider, use this callback URL format:\n\n```\nhttps:///login/oauth2/code/\n```\n\n**Understanding the Provider Slug:**\n\nThe `` portion of the callback URL must exactly match your `security.oauth2.provider` configuration value:\n\n```yaml\nsecurity:\n oauth2:\n provider: authentik # This becomes part of the callback URL\n```\n\nWith the above configuration, your callback URL becomes:\n```\nhttps://your-domain.com/login/oauth2/code/authentik\n```\n\n**Examples:**\n- Google: `https://stirling.example.com/login/oauth2/code/google`\n- GitHub: `https://stirling.example.com/login/oauth2/code/github`\n- Keycloak: `https://stirling.example.com/login/oauth2/code/keycloak`\n- Custom provider: `https://stirling.example.com/login/oauth2/code/mycompany`\n\n> **Important**: If the provider slug in the callback URL doesn't match your `security.oauth2.provider` value, OAuth login will fail with redirect errors.\n\n> **Tip**: For generic OIDC providers (not Google/GitHub/Keycloak), you can set `provider` to any lowercase alphanumeric value that makes sense for your organization.\n\n### Step 5: Test OAuth Login and Promote User\n\n1. Restart Stirling PDF\n2. Test OAuth login in an incognito/private browser window\n3. Verify you can log in with your OAuth provider\n4. Log in with your initial admin account (username/password)\n5. Go to **Settings** → **User Management**\n6. Find the OAuth user account (created during test login)\n7. Change role to **Admin**\n\n### Step 6: (Optional) Switch to SSO-Only Mode\n\nOnce you've verified OAuth works and promoted an OAuth user to admin, you can disable username/password login:\n\n\n \n ```yaml\n security:\n loginMethod: oauth2 # Disables username/password login\n ```\n \n \n ```bash\n SECURITY_LOGINMETHOD=oauth2\n ```\n \n\n\n> **Important**: If you set `loginMethod: oauth2` before creating an OAuth admin user, you will only be able to log in via OAuth, and all new OAuth users will have regular user permissions. Keep `loginMethod: all` until you have at least one OAuth user with admin privileges.\n\n## Configuration Reference\n\n### Required Properties\n\n| Property | Description | Example |\n|----------|-------------|---------|\n| `security.oauth2.enabled` | Enable OAuth 2 login | `true` |\n| `security.oauth2.clientId` | Client ID from your OAuth provider | `stirling-pdf-client` |\n| `security.oauth2.clientSecret` | Client Secret from your OAuth provider | `your-secret-key` |\n| `security.oauth2.provider` | Provider name | `google`, `github`, `keycloak`, `authentik` |\n\n### Optional Properties\n\n| Property | Description | Default | Example |\n|----------|-------------|---------|---------|\n| `security.oauth2.issuer` | OIDC issuer URL (required for generic providers, must support `/.well-known/openid-configuration`) | - | `https://keycloak.example.com/realms/myrealm` |\n| `security.oauth2.autoCreateUser` | Auto-create users on first login | `true` | `false` |\n| `security.oauth2.blockRegistration` | Block new user registration, only allow pre-registered users | `false` | `true` |\n| `security.oauth2.scopes` | Space or comma-separated list of OAuth scopes | Provider-specific | `openid, profile, email` |\n| `security.oauth2.useAsUsername` | Claim to use as username (options depend on provider) | Provider-specific | `email`, `preferred_username`, `login` |\n\n### Provider-Specific Configuration\n\n**Named providers** (Google, GitHub, Keycloak):\n```yaml\noauth2:\n client:\n google: # or github, keycloak\n clientId: ...\n clientSecret: ...\n```\n\n**Generic providers** (Authentik, custom OIDC):\n```yaml\noauth2:\n issuer: # Must support OIDC discovery\n clientId: ...\n clientSecret: ...\n```\n\n### Username Claim Options\n\n**Google:**\n- `email`, `name`, `given_name`, `family_name`\n- See [Google OAuth Scopes](https://developers.google.com/identity/protocols/oauth2/scopes)\n\n**GitHub:**\n- `login`, `email`, `name`\n- See [GitHub OAuth Scopes](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/scopes-for-oauth-apps)\n\n**Keycloak/Generic OIDC:**\n- `email`, `preferred_username`, `nickname`, `name`\n\n## Advanced Configuration\n\n### Backend URL Configuration\n\nIf your Stirling PDF backend is accessible at a different URL than the frontend, configure the backend URL:\n\n\n \n ```yaml\n system:\n backendUrl: https://stirling-api.example.com\n ```\n \n \n ```bash\n SYSTEM_BACKENDURL=https://stirling-api.example.com\n ```\n \n\n\nVerify the backend URL is correct by checking that `https://your-domain.com/api/v1/info/status` is accessible.\n\n### Auto-Login Feature\n> **Tier**: Server\n\nAutomatically redirect users to OAuth login page, bypassing the Stirling PDF login screen.\n\n\n \n ```yaml\n premium:\n proFeatures:\n ssoAutoLogin: true\n ```\n \n \n ```bash\n PREMIUM_PROFEATURES_SSOAUTOLOGIN=true\n ```\n \n\n\n**Auto-login Activation Requirements:**\n\nAuto-login only triggers when **ALL** of the following conditions are met:\n\n1. `ssoAutoLogin` is enabled (as configured above)\n2. `loginMethod` is NOT `'all'` and NOT `'normal'` (i.e., SSO-only mode required)\n3. Exactly one OAuth provider is configured\n\n**Behavior:**\n- When all conditions are met: Users are automatically redirected to OAuth provider login\n- When conditions are not met: Standard login page is displayed\n- If the SSO redirect fails, the browser stops auto-redirecting for the current session so the login page stays reachable\n- After logging out, auto-redirect is suppressed for that session so you can sign in as a different user\n\n### User Interface\n\nOnce OAuth is configured, users will see the SSO login button:\n\n| ![login-page.png](https://raw.githubusercontent.com/Stirling-Tools/Stirling-Tools.github.io/main/static/img/login-page.png) | ![sso-login-option.png](https://raw.githubusercontent.com/Stirling-Tools/Stirling-Tools.github.io/main/static/img/sso-login-option.png) |\n|----------------------------------------|---------------------------------------------------|\n\n## Troubleshooting\n\n### Common Issues\n\n**\"OAuth2 authentication error\"**\n- Verify callback URL matches exactly (including provider slug)\n- Check client ID and secret are correct\n- Ensure provider allows the configured redirect URI\n- Confirm `security.oauth2.provider` matches the provider slug in callback URL\n\n**\"Invalid issuer\"**\n- Confirm issuer URL is correct\n- Test `https://your-issuer/.well-known/openid-configuration` returns valid JSON\n- Check network connectivity from Stirling PDF container to provider\n\n**\"User not created\"**\n- Set `autoCreateUser: true`\n- Check `blockRegistration` is `false` or user is pre-registered\n- Verify license allows user count\n\n**Users redirected to wrong URL**\n- Verify `system.backendUrl` is configured correctly\n- Test that `https://your-domain.com/api/v1/info/status` is accessible\n- Check provider's registered redirect URIs match your domain\n\n### Debug Logging\n\nEnable OAuth debug logging to troubleshoot authentication issues.\n\n\n \n ```yaml\n logging:\n level:\n org.springframework.security.oauth2: DEBUG\n ```\n \n \n ```bash\n LOGGING_LEVEL_ORG_SPRINGFRAMEWORK_SECURITY_OAUTH2=DEBUG\n ```\n \n\n\n### Logging the Provider's Claims (\"Attribute value for email cannot be null\")\n\nIf login fails with **\"Attribute value for email cannot be null\"** (common with ADFS and Azure AD), the provider is not returning the claim named by `useAsUsername`. Enable `security.oauth2.debugLogging` to log the full ID-token / UserInfo claim set and the resolved username, so you can see exactly which claims the provider sends and pick the right `useAsUsername` value.\n\n\n \n ```yaml\n security:\n oauth2:\n debugLogging: true\n ```\n \n \n ```bash\n SECURITY_OAUTH2_DEBUGLOGGING=true\n ```\n \n\n\nThe claims are logged at `INFO` level on each login (and again at `ERROR` level when the username attribute cannot be resolved).\n\n**⚠️ Disable `debugLogging` again as soon as you are done.** It writes personally identifiable information (such as `sub`, `email`, and `name`) to the application logs.\n\n## Known Limitations\n\n- OAuth users must be manually promoted to admin role after first login\n- Provider discovery requires `/.well-known/openid-configuration` endpoint support\n- Auto-login feature requires the Server tier (or higher)\n\n## See Also\n\n- [SAML SSO Configuration](doc:configuration/saml-sso-configuration/saml-sso-configuration) - Enterprise SAML 2.0 setup\n- [System and Security](doc:configuration/system-and-security) - Additional security settings\n- [External Database](doc:configuration/external-database) - User storage configuration", + "sourcePath": "docs/Configuration/OAuth SSO Configuration.md", + "editUrl": "https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/Configuration/OAuth SSO Configuration.md" + }, + "configuration/ocr": { + "id": "configuration/ocr", + "title": "OCR (Optical Character Recognition)", + "section": "configuration", + "markdown": "## OCR Language Packs and Setup\nThis document provides instructions on how to add additional language packs for the OCR tab in Stirling PDF, both inside and outside of Docker.\n\n## How does the OCR Work\nStirling PDF uses Tesseract for its text recognition. All credit goes to them for this awesome work! Note that OCR recognizes text only - it does not perform table-structure or formula recognition.\n\n> **📝 Note: Requires a server backend**\n>\n> OCR runs on a server-side backend with Tesseract installed. The desktop app cannot OCR in local-only mode - connect it to Stirling Cloud or a self-hosted server that has OCR available. To OCR non-English documents, install the matching language pack as described below.\n\n\n## Language Packs\n\nTesseract OCR supports a variety of languages. You can find additional language packs in the Tesseract GitHub repositories:\n\n- [tessdata_fast](https://github.com/tesseract-ocr/tessdata_fast): These language packs are smaller and faster to load but may provide lower recognition accuracy.\n- [tessdata](https://github.com/tesseract-ocr/tessdata): These language packs are larger and provide better recognition accuracy, but may take longer to load.\n\nDepending on your requirements, you can choose the appropriate language pack for your use case. By default, Stirling PDF uses `tessdata_fast` for English, but this can be replaced.\n\n### Installing Language Packs manually\n\n1. Download the desired language pack(s) by selecting the `.traineddata` file(s) for the language(s) you need.\n2. Place the `.traineddata` files in the Tesseract tessdata directory: `/usr/share/tessdata` (or equivalent)\n\n**DO NOT REMOVE EXISTING `eng.traineddata`, IT'S REQUIRED.**\n\n### Docker Setup\n\nIf you are using Docker, you need to expose the Tesseract tessdata directory as a volume in order to use the additional language packs.\n\n\n \n Modify your `docker-compose.yml` file to include the following volume configuration:\n\n ```yaml\n services:\n your_service_name:\n image: your_docker_image_name\n volumes:\n - /location/of/trainingData:/usr/share/tessdata\n ```\n \n \n Add the following to your existing Docker run command:\n\n ```bash\n -v /location/of/trainingData:/usr/share/tessdata\n ```\n \n\n\n### Non-Docker Setup\n\n\n \n For Debian-based systems, use the following commands to manage Tesseract languages:\n\n ```bash\n sudo apt update &&\\\n # All languages\n # sudo apt install -y 'tesseract-ocr-*'\n \n # Find available languages:\n apt search tesseract-ocr-\n \n # View installed languages:\n dpkg-query -W tesseract-ocr- | sed 's/tesseract-ocr-//g'\n ```\n \n \n For Fedora systems, use the following commands:\n\n ```bash\n # All languages\n # sudo dnf install -y tesseract-langpack-*\n \n # Find available languages:\n dnf search -C tesseract-langpack-\n \n # View installed languages:\n rpm -qa | grep tesseract-langpack | sed 's/tesseract-langpack-//g'\n ```\n \n \n Follow these steps to set up Tesseract languages on Windows:\n\n 1. Download desired `.traineddata` files from [tessdata](https://github.com/tesseract-ocr/tessdata) or [tessdata_fast](https://github.com/tesseract-ocr/tessdata_fast)\n \n 2. Place them in the tessdata folder within your Tesseract installation directory:\n ```\n C:\\Program Files\\Tesseract-OCR\\tessdata\n ```\n \n 3. Verify the installation by running:\n ```powershell\n tesseract --list-langs\n ```\n \n 4. Edit your `/configs/settings.yml` and update the `system.tessdataDir`:\n ```yaml\n system:\n tessdataDir: C:/Program Files/Tesseract-OCR/tessdata # path to Tessdata files\n ```", + "sourcePath": "docs/Configuration/OCR.md", + "editUrl": "https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/Configuration/OCR.md" + }, + "configuration/other-customisations": { + "id": "configuration/other-customisations", + "title": "Other Customisations", + "section": "configuration", + "markdown": "Stirling PDF offers various other customisation options, such as:\n\n## Static File Overrides\n\nYou can override static files (logos, images, favicons, etc.) by placing custom versions in the `customFiles/static/` directory.\n\n### How It Works\n\nStirling PDF checks for files in this order:\n1. **First:** `customFiles/static/` (your custom files)\n2. **Fallback:** Built-in static files embedded in the application\n\nThis means you can replace any static resource by placing a file with the matching path in `customFiles/static/`.\n\n### Finding File Paths to Override\n\nMost static files in the application come from the `frontend/editor/public/` folder in the source code (brand logos live in `frontend/shared/assets/brand/`). To override a file, place it under `customFiles/static/` matching the same path it is served at. The mapping is direct:\n\n**Frontend source → Your override path:**\n- `frontend/editor/public/manifest.json` → `customFiles/static/manifest.json`\n- `frontend/shared/assets/brand/modern-logo/StirlingPDFLogoBlackText.svg` → `customFiles/static/modern-logo/StirlingPDFLogoBlackText.svg`\n- `frontend/shared/assets/brand/classic-logo/StirlingPDFLogoBlackText.svg` → `customFiles/static/classic-logo/StirlingPDFLogoBlackText.svg`\n\n**To see what files you can override:**\n1. Browse the [frontend/editor/public folder on GitHub](https://github.com/Stirling-Tools/Stirling-PDF/tree/main/frontend/editor/public)\n2. Match the directory structure in your `customFiles/static/` folder\n\nCommon files you might want to override:\n\n**Favicons & Icons** (override by placing a matching file at the root of `customFiles/static/`):\n- `favicon.svg`, `favicon.ico` - Browser favicons\n- `apple-touch-icon.png` - iOS home screen icon\n- `android-chrome-192x192.png` - Android icon (192x192)\n- `android-chrome-512x512.png` - Android icon (512x512)\n\n**Logo Variants (Both classic-logo/ and modern-logo/):**\n\nBoth logo directories contain the same file structure - just replace `classic-logo/` or `modern-logo/` with whichever style you're using:\n\n- `{style}/StirlingPDFLogoBlackText.svg` - Logo with black text (light mode)\n- `{style}/StirlingPDFLogoWhiteText.svg` - Logo with white text (dark mode)\n- `{style}/StirlingPDFLogoGreyText.svg` - Logo with grey text\n- `{style}/StirlingPDFLogoNoTextDark.svg` - Logo without text (dark variant)\n- `{style}/StirlingPDFLogoNoTextLight.svg` - Logo without text (light variant)\n- `{style}/logo-tooltip.svg` - Small logo for tooltips\n- `{style}/favicon.ico` - Style-specific favicon\n- `{style}/logo192.png`, `{style}/logo512.png` - PNG versions at different sizes\n- `{style}/Firstpage.png` - First page preview image\n\nWhere `{style}` is either `classic-logo` or `modern-logo` depending on your logo style setting:\n\n**Settings file (configs/settings.yml):**\n```yaml\nui:\n logoStyle: classic # Options: 'classic' or 'modern'\n```\n\n**Environment variable (Docker):**\n```bash\nUI_LOGOSTYLE=classic\n```\n\n**In-app configuration:**\nSettings → Configuration → System Settings → Logo Style (requires login enabled)\n\n**Other Assets:**\n- `robots.txt` - Search engine directives\n- `manifest.json`, `manifest-classic.json` - Web app manifests\n- Images, fonts, and locales\n\n### Example: Custom Favicon\n\n```bash\n# Your directory structure\ncustomFiles/\n └── static/\n ├── favicon.svg\n └── favicon.ico\n```\n\nDocker compose:\n```yaml\nvolumes:\n - ./customFiles:/customFiles:rw\n```\n\nRestart the container - your custom favicons will be used!\n\n### Example: Custom Logo (Simple)\n\n```bash\ncustomFiles/\n └── static/\n └── classic-logo/\n └── StirlingPDFLogoBlackText.svg\n```\n\nThis overrides the classic logo with black text (used in light mode).\n\n**Important:** Make sure your logo style is set to `classic` in your configuration:\n```yaml\nui:\n logoStyle: classic # Must match the directory you're overriding!\n```\n\nOr via environment variable:\n```bash\nUI_LOGOSTYLE=classic\n```\n\nIf you have `logoStyle: modern` set, override files in `modern-logo/` instead!\n\n### Example: Complete Branding Customization\n\nTo fully rebrand Stirling PDF with your company logo, override multiple variants:\n\n```bash\ncustomFiles/\n └── static/\n ├── favicon.svg # Main favicon\n ├── favicon.ico # Legacy favicon\n └── classic-logo/ # Or modern-logo/ if using modern style\n ├── StirlingPDFLogoBlackText.svg # Light mode with text\n ├── StirlingPDFLogoWhiteText.svg # Dark mode with text\n ├── StirlingPDFLogoNoTextLight.svg # Light mode icon only\n ├── StirlingPDFLogoNoTextDark.svg # Dark mode icon only\n ├── logo-tooltip.svg # Small icon\n ├── favicon.ico # Style-specific favicon\n └── Firstpage.png # Homepage preview\n```\n\n**Important:** Set your logo style to match the directory:\n```yaml\nui:\n logoStyle: classic # Use 'classic' if overriding classic-logo/, 'modern' if overriding modern-logo/\n```\n\nOr via environment variable: `UI_LOGOSTYLE=classic`\n\n**Tips:**\n- For consistent branding across light/dark modes, provide both:\n - `StirlingPDFLogoBlackText.svg` (shows on light backgrounds)\n - `StirlingPDFLogoWhiteText.svg` (shows on dark backgrounds)\n- You can also configure this in-app: Settings → Configuration → System Settings → Logo Style (if you have login enabled)\n\n### Advanced: Overriding Built Files (HTML, JS, CSS)\n\n**⚠️ For developers only!**\n\nFiles like `index.html`, JavaScript bundles, and CSS are **generated** by the build process from `frontend/editor/src/`. To override these:\n\n1. Clone the Stirling PDF repository\n2. Make your changes to the React source code in `frontend/editor/src/`\n3. Build the frontend: `task frontend:build` (from the repository root)\n4. The built files appear in `frontend/editor/dist/`\n5. Copy the specific files you want to override to `customFiles/static/` matching the path structure\n\n**Example:** To override `index.html`:\n```bash\n# After building the frontend\ncp frontend/editor/dist/index.html customFiles/static/index.html\n```\n\n**Warning:** Built files may include hashed filenames (e.g., `assets/index-abc123.js`) that change with each build. Overriding these requires matching the exact filename from your build and is not recommended for most users.\n\n---\n\n## Defaulting Language\nDefault language selection via the `SYSTEM_DEFAULTLOCALE` environment variable. Accepted values include `de-DE`, `fr-FR`, `ar-AR` and all other languages codes that are within Stirling PDFs current list.\n\n## Google Search Visibility (robots.txt)\nEnable or disable search engine visibility (via `robots.txt`) with the `SYSTEM_GOOGLEVISIBILITY` environment variable, or in `configs/settings.yml`:\n```yaml\nsystem:\n googlevisibility: true # 'true' to allow Google visibility, 'false' to disallow\n```\n\n## Custom Root path\nHost the interface under a sub-path with the `SYSTEM_ROOTURIPATH` environment variable.\nThis is for changing websites like stirlingtools.com to instead host the interface at stirlingtools.com/demo:\n```bash\nSYSTEM_ROOTURIPATH=/demo\n```\nThe setting can also be written in `configs/settings.yml`:\n```yaml\nserver:\n servlet:\n context-path: /demo\n```\n\n## Enable/Disable Analytics\nAnalytics can be enabled/disabled with ``SYSTEM_ENABLEANALYTICS`` or\n```yaml\nsystem:\n enableAnalytics: 'true'\n```\nIn configs/settings.yml", + "sourcePath": "docs/Configuration/Other Customisations.md", + "editUrl": "https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/Configuration/Other Customisations.md" + }, + "configuration/performance-optimization": { + "id": "configuration/performance-optimization", + "title": "Performance Optimization & Sizing", + "description": "Resource sizing and scaling guidance for Stirling PDF deployments", + "section": "configuration", + "markdown": "PDF processing is memory-intensive - a single large PDF can expand to many times its file size in memory during processing. This guide helps you size your deployment correctly.\n\n---\n\n## How Stirling PDF Uses Memory\n\nStirling PDF loads PDFs into memory using a tiered strategy based on file size:\n\n| File Size | Strategy | Memory Impact |\n|---|---|---|\n| Up to 10 MB | Loaded entirely into memory | Fast, but consumes memory proportional to file size |\n| 10 MB to 50 MB | Partially in memory, remainder stored on disk | Moderate memory usage with disk spillover |\n| Over 50 MB | Fully stored on disk during processing | Minimal memory usage, but requires adequate disk space |\n\nThe application also monitors memory pressure. If available memory drops too low, all operations are forced into disk-backed mode regardless of file size.\n\n> **⚠️ Caution: Memory-Intensive Operations**\n>\n> A 50 MB PDF with complex vector graphics, embedded fonts, and many pages can expand to 200-500 MB in memory during processing. Operations that render pages (such as PDF-to-image conversion) and OCR are particularly memory-intensive. Plan for several times the maximum expected file size per concurrent operation.\n\n\n---\n\n## Resource Recommendations\n\n\n\n\n**Recommended specifications:**\n- **CPU:** 2 cores (4+ recommended)\n- **RAM:** 4 GB\n- **Disk:** 10 GB free space\n\n**Docker Compose:**\n```yaml\nservices:\n stirling-pdf:\n image: docker.stirlingpdf.com/stirlingtools/stirling-pdf:latest\n deploy:\n resources:\n limits:\n memory: 4G\n cpus: '2.0'\n```\n\n\n\n\n**Recommended specifications:**\n- **CPU:** 4-8 cores\n- **RAM:** 8-16 GB\n- **Disk:** 50 GB (SSD recommended)\n\n**Docker Compose:**\n```yaml\nservices:\n stirling-pdf:\n image: docker.stirlingpdf.com/stirlingtools/stirling-pdf:latest\n environment:\n PROCESS_EXECUTOR_SESSION_LIMIT_LIBRE_OFFICE_SESSION_LIMIT: 2\n deploy:\n resources:\n limits:\n memory: 8G\n cpus: '4.0'\n```\n\n**Consider:**\n- Increase LibreOffice session limit for faster document conversions - see [LibreOffice Parallel Processing](doc:configuration/libreoffice-parallel-processing)\n- External PostgreSQL database for reliability\n\n\n\n\n**Recommended specifications:**\n- **CPU:** 8+ cores\n- **RAM:** 16-32 GB\n- **Disk:** 100+ GB, SSD strongly recommended\n\n**Docker Compose:**\n```yaml\nservices:\n stirling-pdf:\n image: docker.stirlingpdf.com/stirlingtools/stirling-pdf:latest\n environment:\n PROCESS_EXECUTOR_SESSION_LIMIT_LIBRE_OFFICE_SESSION_LIMIT: 4\n PROCESS_EXECUTOR_SESSION_LIMIT_TESSERACT_SESSION_LIMIT: 2\n deploy:\n resources:\n limits:\n memory: 16G\n cpus: '8.0'\n```\n\n**Architecture considerations:**\n- Multiple instances behind a load balancer with session affinity\n- Remote UNO servers for LibreOffice scaling - see [LibreOffice Parallel Processing](doc:configuration/libreoffice-parallel-processing)\n- External PostgreSQL database (enterprise feature)\n- Shared `/configs` volume across instances for consistent settings\n\n> **💡 Tip: Server/Enterprise Recommended**\n>\n> For large organizations, **Server or Enterprise plans** provide SSO, external database support, advanced monitoring, and dedicated support.\n>\n> [Learn more](doc:server-admin-onboarding)\n\n\n\n\n\n---\n\n## Fine Tuning\n\nFor most deployments, Stirling PDF's defaults work well and no manual tuning is needed. If you are experiencing performance issues with large files or high concurrency, you can adjust the memory allocated to the application using the `JAVA_TOOL_OPTIONS` environment variable:\n\n```yaml\nservices:\n stirling-pdf:\n environment:\n JAVA_TOOL_OPTIONS: \"-Xms512m -Xmx4g\"\n```\n\n`-Xms` sets the initial memory allocation and `-Xmx` sets the maximum. If running in Docker or Kubernetes with memory limits, set the container limit to **at least 1.5x the `-Xmx` value** to leave room for background processes like LibreOffice and Tesseract.\n\n---\n\n## Resource-Intensive Operations\n\nSome operations require significantly more resources than others. If your organization primarily uses specific tools, you should size your deployment based on the most resource-heavy operations your users will perform.\n\n| Operation | CPU Impact | Memory Impact | Notes |\n|---|---|---|---|\n| Merge / Split | Low | Proportional to total file sizes | Lightweight file operations |\n| OCR (Tesseract) | Very High | High | CPU-bound image analysis |\n| File Conversion (LibreOffice) | High | High | Single-threaded per instance - see [LibreOffice Parallel Processing](doc:configuration/libreoffice-parallel-processing) to scale. |\n| PDF-to-Image | Moderate | Very High | Page rendering expands memory significantly |\n| PDF/A Conversion | Moderate | High | Font embedding and color profiles |\n| Compression | Moderate | High | Rewriting internal PDF structures |\n\nFor example, if your team primarily uses OCR and document conversion, you will need significantly more resources than a team that mainly merges and splits PDFs. Adjust your [Process Limits](doc:configuration/process-limits) and resource allocation accordingly.\n\n---\n\n## Related\n\n- [Process Limits](doc:configuration/process-limits) - Configure session limits and timeouts for all external tools\n- [LibreOffice Parallel Processing](doc:configuration/libreoffice-parallel-processing) - Scale document conversions with multiple instances\n- [Production Deployment Guide](doc:server-admin-onboarding) - Full production setup walkthrough\n- [Diagnostics](doc:configuration/diagnostics) - Collect system and application diagnostics for troubleshooting", + "sourcePath": "docs/Configuration/Performance-Optimization.md", + "editUrl": "https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/Configuration/Performance-Optimization.md" + }, + "configuration/pipeline": { + "id": "configuration/pipeline", + "title": "Pipeline Automation (Automate)", + "description": "Create automated multi-step PDF workflows with the Automate tool", + "section": "configuration", + "markdown": "Create powerful automated workflows that combine multiple PDF operations into sequential processes. The Automate tool (formerly called \"Pipeline\") lets you build, save, and reuse complex PDF processing workflows.\n\n> **ℹ️ Info: V2.0 Update - New \"Automate\" Feature**\n>\n> In V2.0, the pipeline frontend interface has been redesigned as the **\"Automate\"** feature with an improved user experience for creating and managing automation workflows. The backend pipeline system (JSON configuration and folder scanning) continues to work the same way.\n>\n> **What changed:**\n> - Backend pipeline processing - **No changes**\n> - JSON pipeline configurations - **Still work exactly the same**\n> - Folder scanning with pipelines - **Still works the same**\n> - Frontend interface - **Now called \"Automate\" with better UX**\n>\n> If you have existing pipeline JSON files, they continue to work in V2.0's Automate feature.\n\n\n---\n\n## What is Pipeline Automation?\n\nPipeline automation allows you to:\n- **Chain operations** - Combine multiple PDF tools in sequence\n- **Save workflows** - Reuse common operation sequences\n- **Automate processing** - Process files automatically with folder scanning\n- **Standardize procedures** - Ensure consistent processing across teams\n- **Batch process** - Apply same workflow to multiple files\n\nThink of it as **\"macros for PDFs\"** - record your steps once, replay them unlimited times.\n\n---\n\n## Why Use Pipelines?\n\n### Without Pipelines:\n1. Upload PDF to Split tool, download split files\n2. Upload each split file to Watermark tool, download watermarked files\n3. Upload each watermarked file to Compress tool, download final files\n4. Repeat for every batch of documents\n\n### With Pipelines:\n1. Create \"Split-Watermark-Compress\" pipeline once\n2. Upload PDFs, automatic processing, download results\n3. Reuse same pipeline for all future batches\n\n**Time saved:** Minutes per file, hours per day.\n\n---\n\n## Key Concepts\n\n### Operations\nIndividual PDF tools that perform specific tasks:\n- Split, Merge, Compress, Watermark, etc.\n- Each operation has configurable parameters\n- Operations execute in the order you define\n\n### Pipeline\nA sequence of operations with saved configurations:\n- Named workflow (e.g., \"Invoice Processing\")\n- Ordered list of operations\n- Pre-configured settings for each operation\n- Reusable across multiple files\n\n### Pipeline Configuration (JSON)\nText file that defines your pipeline:\n- Lists operations in order\n- Specifies parameters for each operation\n- Can be shared, versioned, and backed up\n- Human-readable and editable\n\n### Folder Scanning\nAutomated processing mode:\n- Watch a folder for new files\n- Automatically apply pipeline to new files\n- Move processed files to output folder\n- Unattended batch processing\n\n---\n\n## Getting Started with Automate\n\n### Accessing the Automate Tool\n\n1. **From Home Page**\n - Click \"Automate\" in the Advanced Tools section\n - Or search for \"automate\" or \"pipeline\"\n\n2. **Open the Automation Builder**\n - Click \"Create New Automation\"\n - The automation builder opens\n\n---\n\n## Building Your First Pipeline\n\n## Steps to Configure and Use Your Pipeline\n\n1. **Start a New Automation**\n - On the Automate screen, click **Create New Automation**.\n\n2. **Enter Automation Name**\n - Provide a name for your automation in the designated field (you can also add an optional description and icon).\n\n3. **Add Tools**\n - Choose the tools for your automation (e.g., **Split Pages**) with **Add Tool**. Tools run in the order you add them.\n\n4. **Configure Tool Settings**\n - Configure each added tool. A tool shows a **! Not Configured** marker until its settings are saved.\n\n5. **Add More Tools**\n - You can add and reorder multiple tools. Make sure each tool is configured.\n\n6. **Save Tool Settings**\n - Click **Save Configuration** in each tool's settings dialog after customizing it.\n\n7. **Save the Automation**\n - Click **Save Automation** once all tools are configured. The Save button stays disabled until the automation is complete.\n\n8. **Download Pipeline Configuration**\n - The Automate UI offers two download buttons:\n - **Export** - downloads `.automate.json` in the **native Automate format** with frontend tool IDs. Use this for re-importing into another Stirling PDF instance via the UI.\n - **Export for Folder Scanning** - downloads `.folder-scan.json` in the **backend format** with full endpoint paths. Use this for the REST API and for folder scanning.\n - To pre-load a pipeline for all users, place a folder-scanning-format JSON file in `/pipeline/defaultWebUIConfigs/` - it will appear in the dropdown.\n\n9. **Run the Automation**\n - Once saved, select the automation from the list, add your files, and run it.\n\n10. **Note on Web UI Limitations**\n - The current web UI version does not support operations that require multiple different types of inputs, such as adding a separate image to a PDF.\n\n### Current Limitations\n\n- Cannot have more than one of the same operation.\n- Cannot input additional files via UI.\n- All files and operations run in serial mode.\n\n---\n\n## Example Pipelines\n\n### Example 1: Invoice Processing\n**Goal:** Process scanned invoices for archival\n\n**Pipeline Steps:**\n1. **OCR** - Make invoices searchable\n - Language: English\n - Preserve formatting: Yes\n2. **Crop** - Remove scanner edges\n - Margins: 0.5 inches all sides\n3. **Add Watermark** - Mark as processed\n - Text: \"PROCESSED [DATE]\"\n - Position: Bottom right\n - Opacity: 50%\n4. **Compress** - Reduce file size\n - Level: Medium\n5. **Add Password** - Secure documents\n - Password: [configured per run]\n\n**Use Case:** Accounting department processing hundreds of invoices monthly\n\n---\n\n### Example 2: Report Distribution\n**Goal:** Prepare reports for external sharing\n\n**Pipeline Steps:**\n1. **Remove Pages** - Remove internal pages\n - Pages: 2,3 (remove cover sheets)\n2. **Add Page Numbers** - Number all pages\n - Position: Bottom center\n - Format: \"Page X of Y\"\n3. **Add Stamp** - Add \"CONFIDENTIAL\" stamp\n - Position: Top right\n - Color: Red\n4. **Change Permissions** - Restrict editing\n - Allow printing: Yes\n - Allow editing: No\n5. **Compress** - Optimize for email\n - Level: High\n\n**Use Case:** Monthly reports sent to clients\n\n---\n\n### Example 3: Document Standardization\n**Goal:** Standardize format of received documents\n\n**Pipeline Steps:**\n1. **Rotate** - Fix orientation\n - Mode: Auto-detect\n2. **Scale Pages** - Standardize to Letter size\n - Target: 8.5 x 11 inches\n3. **Add Metadata** - Tag documents\n - Title: [Auto-extracted]\n - Author: \"Company Name\"\n - Keywords: \"Standardized, Processed\"\n4. **Remove Annotations** - Clean markup\n5. **Flatten** - Remove form fields\n\n**Use Case:** HR department standardizing employee submissions\n\n---\n\n### Example 4: Batch Conversion\n**Goal:** Convert and optimize image scans\n\n**Pipeline Steps:**\n1. **Convert** - Images to PDF\n - Source: JPG, PNG\n2. **OCR** - Add text layer\n - Language: Multiple\n3. **Remove Blanks** - Delete empty pages\n - Threshold: 95%\n4. **Compress** - Optimize size\n - Level: Medium\n5. **PDF/A** - Convert for archival\n - Version: PDF/A-2b\n\n**Use Case:** Digitization project for paper archives\n\n---\n\n## Common Pipeline Patterns\n\n### Quality Enhancement Pipeline\n**Pattern:** Improve scanned document quality\n```\nOCR → Remove Blanks → Adjust Contrast → Compress → Add Metadata\n```\n\n### Security Pipeline\n**Pattern:** Secure documents for distribution\n```\nRemove Metadata → Add Watermark → Add Password → Change Permissions\n```\n\n### Compression Pipeline\n**Pattern:** Reduce file sizes for storage/email\n```\nRemove Annotations → Remove Images (optional) → Compress → Validate\n```\n\n### Branding Pipeline\n**Pattern:** Add company branding to documents\n```\nAdd Watermark → Add Stamp → Add Page Numbers → Add Metadata\n```\n\n### Preparation Pipeline\n**Pattern:** Prepare documents for printing\n```\nRotate → Scale Pages → Booklet Imposition → Remove Annotations\n```\n\n---\n\n## JSON Configuration\n\n### Basic Structure\n\nA pipeline JSON file has a `name` and a `pipeline` array. Each entry has an `operation` (the full API endpoint path) and a `parameters` object:\n\n```json\n{\n \"name\": \"My Pipeline\",\n \"pipeline\": [\n {\n \"operation\": \"/api/v1/general/split-pages\",\n \"parameters\": {\n \"pageNumbers\": \"5\"\n }\n },\n {\n \"operation\": \"/api/v1/misc/compress-pdf\",\n \"parameters\": {\n \"optimizeLevel\": 5,\n \"expectedOutputSize\": \"\"\n }\n }\n ]\n}\n```\n\n> **📝 Note: Operation names are full endpoint paths**\n>\n> Pipeline operation names use the **full REST API path**, not short names. For example, use `/api/v1/general/split-pages` (not just `split-pages`). The Folder-Scanning export from the Automate UI produces these paths automatically.\n>\n> If you have older pipeline JSONs that used short names, regenerate them from the Automate UI using **Export for Folder Scanning**.\n\n\n### Optional fields\n\nFor folder scanning (not used by the REST API):\n\n```json\n{\n \"name\": \"...\",\n \"pipeline\": [ ... ],\n \"outputDir\": \"{outputFolder}/{folderName}\",\n \"outputFileName\": \"{filename}-{pipelineName}-{date}-{time}\"\n}\n```\n\n`outputDir` and `outputFileName` accept the placeholders `{outputFolder}`, `{folderName}`, `{filename}`, `{pipelineName}`, `{date}`, `{time}`.\n\n---\n\n## Operation and parameter reference\n\nPipeline operations use the **full endpoint paths** of Stirling PDF's REST API, with the same field names. So once you know the underlying endpoint, you know the pipeline operation - no separate vocabulary to learn.\n\nFor the canonical list of operations and the full parameter schema for each, see:\n\n- **Local Swagger UI** at `/swagger-ui.html` on your instance - includes every endpoint, parameter types, and lets you try requests live\n- **Online API reference** - the [Stirling PDF API documentation](https://app.swaggerhub.com/apis-docs/Frooodle/Stirling-PDF/) and the [Scalar API registry](https://registry.scalar.com/@stirlingpdf/apis/stirling-pdf-processing-api/)\n\nSee [API Documentation](doc:api) for authentication and general API usage.\n\nPipelines can only call endpoints under `/api/v1/general/...`, `/api/v1/misc/...`, `/api/v1/security/...`, `/api/v1/convert/...`, `/api/v1/filter/...`, and `/api/v1/ai/tools/...`. Anything outside those namespaces is rejected by the pipeline processor with a `SecurityException` - this includes `/api/v1/info/...`, `/api/v1/auth/...`, `/api/v1/admin/...`, and `/api/v1/pipeline/handleData` itself (pipelines cannot recursively call themselves).\n\nThe `/api/v1/ai/tools/...` namespace currently exposes proprietary AI features (e.g. `math-auditor-agent`, `pdf-comment-agent`) and is only available with the corresponding paid license.\n\n> **💡 Tip: Build it in the UI, export it as JSON**\n>\n> The fastest way to get a correct pipeline JSON for any combination of operations is to build it visually in the **Automate** tool and click **Export for Folder Scanning**. The exported file uses exactly the format the API expects, with the right operation paths and parameters already filled in for you.\n\n\n---\n\n## Filter / conditional operations\n\nFilter operations let you **branch a pipeline**. Each one checks a property of the file and either lets the file **continue to the next steps** or **drops it** so the rest of the pipeline never sees it. This is how you say \"only keep processing files that match X\" inside an automation - for example, only run OCR on scans that have no text yet, or only watermark documents over a certain page count.\n\nA file that does not match a filter is simply removed from the rest of the pipeline. It is not treated as an error.\n\nThese operation names go in your pipeline configuration:\n\n| Operation name | Keeps the file when... |\n|---|---|\n| `filter-contains-text` | the PDF contains a given piece of text (you can limit the check to specific pages) |\n| `filter-contains-image` | the PDF contains an image (you can limit the check to specific pages) |\n| `filter-page-count` | the page count is greater than, equal to, or less than a value you set |\n| `filter-page-size` | the first page's size compares to a standard page size you choose |\n| `filter-file-size` | the file size compares to a value you set |\n| `filter-page-rotation` | the first page's rotation compares to a value you set |\n\nThe four comparison filters (`filter-page-count`, `filter-page-size`, `filter-file-size`, `filter-page-rotation`) take a `comparator` of `Greater`, `Equal`, or `Less`.\n\n**Example - only OCR files that are image-only scans:** detect scans with no text layer using `filter-contains-image`, then route the matching files through the OCR operation. Files that already contain text are dropped before the OCR step, so you only spend processing time on the scans that need it.\n\n```json\n{\n \"name\": \"OCR only image-only scans\",\n \"pipeline\": [\n {\"operation\": \"/api/v1/filter/filter-contains-image\", \"parameters\": {\"pageNumbers\": \"all\"}},\n {\"operation\": \"/api/v1/misc/ocr-pdf\", \"parameters\": {\"languages\": [\"eng\"], \"ocrType\": \"skip-text\"}}\n ]\n}\n```\n\nThese filter operations work both in the REST API and in folder scanning.\n\n---\n\n\n## REST API: `POST /api/v1/pipeline/handleData`\n\nTrigger a pipeline programmatically via the REST API. Use this from scripts, automation platforms (n8n, Zapier, Make, Power Automate), or your own integrations.\n\n### Request\n\n- **Method**: `POST`\n- **URL**: `/api/v1/pipeline/handleData`\n- **Content-Type**: `multipart/form-data`\n- **Authentication**: When security is enabled, set the `X-API-KEY` header. See [API Documentation](doc:api) for details.\n\n### Multipart fields\n\n| Field | Type | Required | Purpose |\n|---|---|---|---|\n| `fileInput` | file | yes | One or more PDF files. Repeat the field for multiple files. |\n| `json` | string | yes | The pipeline configuration JSON. |\n\nYou don't need to include `fileInput` inside the `parameters` object - the pipeline processor injects each uploaded file automatically. The Automate UI's \"Export for Folder Scanning\" includes `\"fileInput\": \"automated\"` as a marker in every step, which the backend ignores; you can leave it in or strip it out, both work.\n\n### Optional query parameters\n\n- `?async=true` - run the pipeline asynchronously and return a job ID instead of the file. Poll `GET /api/v1/general/job/{id}` for progress.\n\n### Response\n\n- **Single output file**: returned directly as `application/octet-stream` with `Content-Disposition: attachment; filename=...`.\n- **Multiple output files**: returned as `output.zip`.\n- **Async mode**: returns a JSON body with the job ID.\n\n### Working curl example\n\n```bash\ncurl -X POST \"http://localhost:8080/api/v1/pipeline/handleData\" \\\n -H \"X-API-KEY: $STIRLING_API_KEY\" \\\n -F \"fileInput=@/path/to/input.pdf\" \\\n -F 'json={\n \"name\": \"Repair-then-compress\",\n \"pipeline\": [\n {\"operation\": \"/api/v1/misc/repair\", \"parameters\": {}},\n {\"operation\": \"/api/v1/misc/compress-pdf\", \"parameters\": {\"optimizeLevel\": 2}}\n ]\n }' \\\n --output result.pdf\n```\n\nFor multiple files use repeated `-F \"fileInput=@...\"` flags; the response will be `output.zip`. For the full parameter list for each operation, see the API docs linked above.\n\n### Error responses\n\n| Situation | HTTP status | Body |\n|---|---|---|\n| Auth required and no key supplied | 401 | `{\"error\":\"Unauthorized\",\"message\":\"Authentication required...\",\"status\":401}` |\n| Multipart parsing failed (missing field, bad JSON) | 400 | Spring's standard error JSON |\n| Invalid operation name, disallowed endpoint, or missing required parameter | 200 with empty body | The server logs an `IllegalArgumentException` but returns an empty response. |\n| Downstream endpoint returned non-2xx | 200 with partial/empty body | The error is logged but does not surface in the HTTP response. |\n\n> **⚠️ Warning: Validate response bodies**\n>\n> Errors that occur after multipart parsing currently collapse to `HTTP 200` with an empty body. Always check that the response is a non-empty PDF (starts with `%PDF-`) or a ZIP (starts with `PK\\x03\\x04`) before treating the call as successful.\n\n\n### Tips\n\n- **Build in the UI, export the JSON.** The fastest way to get a correct JSON is to build the pipeline in the Automate UI, then click **Export for Folder Scanning**. The exported file works directly with `handleData`. The other button, **Export**, produces a different \"native Automate\" format (uses an `operations` key with frontend tool IDs like `\"merge\"`) that is only for re-importing into another Automate UI, not for the API.\n- **No image / file parameters.** Operations that take an additional file input (image watermarks, separate overlay PDFs, attaching files) cannot be expressed in pipeline JSON via the REST API. Call those endpoints directly instead.\n- **List parameters become repeated form fields.** Internally the processor expands `[\"eng\",\"deu\"]` into two `languages=eng` and `languages=deu` form parts, which is what the underlying endpoints expect.\n- **Filters drop files.** A filter step that doesn't match keeps the file out of later steps. Useful for \"process only PDFs that contain X\".\n- **Multi-input operations batch.** Operations marked multi-input (e.g. `merge-pdfs`) receive every matching file in a single call. If no files in the working set match the operation's expected extension, the step logs `No files with extension X found for operation Y...` and continues with the other files.\n- **Unknown JSON fields are ignored.** The pipeline parser silently drops fields it doesn't recognise, so you can add `description`, `icon`, or other metadata at the top level without breaking anything.\n\n---\n\n## Folder Scanning Setup\n\nAutomate processing of files placed in watched folders.\n\n### How Folder Scanning Works\n\n1. **Watch Input Folder** - Monitor for new files\n2. **Detect New Files** - Identify PDFs added to folder\n3. **Apply Pipeline** - Process with configured pipeline\n4. **Output Results** - Save to output folder\n5. **Archive Originals** - Move processed files (optional)\n\n### Directory Structure\n\n```\n/pipeline/\n ├── watchedFolders/\n │ ├── invoice-processing/\n │ │ ├── my-pipeline.json # any *.json file in the folder is the pipeline config\n │ │ ├── invoice-001.pdf # drop PDFs directly into the folder root\n │ │ ├── invoice-002.pdf\n │ │ └── processing/ # auto-created by the scanner while a file is in flight\n │ └── report-prep/\n │ └── ...\n ├── finishedFolders/ # outputs appear here by default (per `outputDir` placeholder)\n └── defaultWebUIConfigs/ # pre-loaded pipelines exposed in the Automate UI dropdown\n ├── invoice.json\n └── reports.json\n```\n\n### Configuration File\n\nDrop a `.json` file (any name) into each watched folder. The first `.json` the scanner finds is used as the pipeline:\n\n```json\n{\n \"name\": \"Invoice Processing\",\n \"pipeline\": [\n {\"operation\": \"/api/v1/misc/ocr-pdf\", \"parameters\": {\n \"languages\": [\"eng\"], \"ocrType\": \"skip-text\",\n \"ocrRenderType\": \"hocr\", \"deskew\": true, \"clean\": false,\n \"cleanFinal\": false, \"sidecar\": false, \"removeImagesAfter\": false}}\n ],\n \"outputDir\": \"{outputFolder}/{folderName}\",\n \"outputFileName\": \"{filename}-processed-{date}\"\n}\n```\n\nPDFs go directly in the watched folder root (NOT in an `input/` subdirectory). The scanner auto-creates a `processing/` subfolder while a file is being worked on, and writes outputs to wherever `outputDir` resolves to (typically `/pipeline/finishedFolders/...` via the `{outputFolder}` placeholder).\n\nThe watched-folder scanner runs every 60 seconds.\n\n**Learn more:** [Folder Scanning Guide](doc:configuration/folderscanning)\n\n---\n\n## Best Practices\n\n### Pipeline Design\n\n1. **Test Incrementally**\n - Build pipeline one operation at a time\n - Test each step before adding the next\n - Verify output at each stage\n\n2. **Order Operations Logically**\n - Do OCR before text-based operations\n - Remove pages before processing remaining pages\n - Compress last to optimize final output\n\n3. **Use Descriptive Names**\n - Name pipelines clearly: \"Invoice-OCR-Watermark-Archive\"\n - Add descriptions in comments\n - Version your pipeline files\n\n4. **Handle Errors Gracefully**\n - Test with various file types\n - Consider edge cases (empty PDFs, locked files)\n - Monitor logs for errors\n\n### Performance Optimization\n\n1. **Minimize Operations**\n - Combine similar operations when possible\n - Remove unnecessary steps\n - Don't duplicate efforts\n\n2. **Optimize Compression**\n - Compress once at the end, not multiple times\n - Choose appropriate compression level\n - Balance quality vs. file size\n\n3. **Batch Intelligently**\n - Group similar files together\n - Process during off-peak hours\n - Monitor system resources\n\n### Maintenance\n\n1. **Version Control**\n - Keep pipeline JSONs in git repository\n - Track changes over time\n - Document modifications\n\n2. **Regular Review**\n - Audit pipelines quarterly\n - Remove unused pipelines\n - Update for new requirements\n\n3. **Monitor Performance**\n - Check processing times\n - Review error logs\n - Optimize slow operations\n\n---\n\n## Troubleshooting\n\n### Pipeline Fails to Execute\n\n**Symptoms:** Pipeline starts but doesn't complete\n\n**Common Causes:**\n- Invalid parameter values\n- Unsupported file format\n- Missing dependencies (OCR languages, fonts)\n- File permissions issues\n\n**Solutions:**\n1. Validate JSON configuration\n2. Test each operation individually\n3. Check server logs for errors\n4. Verify required dependencies installed\n\n---\n\n### `handleData` Returns Empty Response\n\n**Symptoms:** REST API call returns HTTP 200 with an empty body.\n\n**Cause:** Errors after multipart parsing (invalid operation name, missing required parameter, downstream endpoint failure) currently collapse to `200 OK` with no body. Check the server logs for the actual error.\n\n**Common reasons:**\n- Operation name used short form (e.g. `compress-pdf`) instead of full path (`/api/v1/misc/compress-pdf`)\n- Operation references an endpoint outside the allowed namespaces (only `general`, `misc`, `security`, `convert`, `filter`, `ai/tools` are permitted)\n- A required parameter was omitted (check the schema for the underlying endpoint in the [Swagger UI / API reference](#operation-and-parameter-reference))\n- The pipeline tries to call `/api/v1/pipeline/handleData` recursively\n\n---\n\n### Folder Scanning Not Working\n\n**Symptoms:** Files not processed automatically\n\n**Possible Issues:**\n- Folder permissions incorrect\n- Pipeline configuration invalid\n- Folder scanning not enabled\n\n**Solutions:**\n1. Check folder permissions (read/write access)\n2. Test pipeline manually first\n3. Check `docker logs` for errors\n4. Ensure folder scanning feature enabled\n5. The scanner runs once every 60 seconds - allow that long after dropping a file\n\n---\n\n### Operation Parameters Not Applying\n\n**Symptoms:** Pipeline runs but doesn't use specified settings\n\n**Causes:**\n- Incorrect parameter names\n- Wrong parameter data types\n- Parameters not supported in operation\n\n**Solutions:**\n1. Check parameter names against the endpoint's schema in the [Swagger UI / API reference](#operation-and-parameter-reference)\n2. Verify parameter value types (string, number, boolean)\n3. Test the same parameters by calling the endpoint directly first\n\n---\n\n### Results Not as Expected\n\n**Symptoms:** Pipeline completes but output incorrect\n\n**Debugging Steps:**\n1. Test each operation individually\n2. Check intermediate outputs\n3. Verify operation order makes sense\n4. Review parameter values\n5. Test with simpler input files\n\n---\n\n## Pipeline vs. Multi-Tool vs. Manual\n\n### Use Pipeline/Automate When:\n- Same workflow repeated frequently\n- Predictable, consistent operations\n- Automated folder processing needed\n- No manual intervention required\n- Standardizing team processes\n- Large batch processing\n- Scheduled/unattended processing\n\n### Use Multi-Tool When:\n- Workflow varies per file\n- Need visual feedback at each step\n- Experimenting with different settings\n- Manual decision points in workflow\n- One-time complex tasks\n\n### Use Individual Tools When:\n- Single, simple operation\n- Quick one-off task\n- Learning how operations work\n- No need for automation\n\n---\n\n## Security Considerations\n\n### Pipeline Files\n- **Protect JSON configs** - May contain passwords or sensitive settings\n- **Restrict folder access** - Limit who can create/modify pipelines\n- **Review before deploying** - Audit pipelines for security issues\n\n### Folder Scanning\n- **Isolate watched folders** - Don't expose to untrusted users\n- **Monitor activity** - Log all processing for audit trail\n- **Secure output folders** - Protect processed documents appropriately\n\n### Automated Processing\n- **Validate inputs** - Ensure only expected files processed\n- **Error handling** - Don't expose sensitive error messages\n- **Resource limits** - Prevent resource exhaustion attacks\n\n---\n\n## Related Documentation\n\n- **[Folder Scanning Setup](doc:configuration/folderscanning)** - Detailed folder scanning guide\n- **[Multi-Tool](doc:functionality/multi-tool)** - Interactive multi-operation tool\n- **[Endpoint Customisation](doc:configuration/endpoint-or-feature-customisation)** - Operation names and IDs\n- **[API Documentation](doc:api)** - Programmatic pipeline execution\n- **[Advanced Tools](doc:functionality/advanced-tools)** - Other automation features\n\n---\n\n## Summary\n\nPipeline automation (Automate tool) transforms Stirling PDF into a workflow engine:\n\n- **Chain operations** - Combine multiple PDF tools sequentially\n- **Save workflows** - Reusable pipeline configurations\n- **Folder scanning** - Automated unattended processing\n- **REST API** - Trigger pipelines from any external system\n- **Standardization** - Consistent processing across teams\n- **Efficiency** - Minutes saved per file, hours per day\n\n**Perfect for:** Repetitive workflows, batch processing, automated document preparation, and standardized procedures.\n\nReady to automate? Create your first pipeline and transform how you process PDFs.", + "sourcePath": "docs/Configuration/Pipeline.md", + "editUrl": "https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/Configuration/Pipeline.md" + }, + "configuration/process-limits": { + "id": "configuration/process-limits", + "title": "Process Limits", + "section": "configuration", + "markdown": "Stirling PDF sometimes runs external tools to handle tools such as conversions or advanced operations\nTools like LibreOffice, Tesseract, Ghostscript, and others. All these tools are optional to Stirling PDFs general operation.\n\n> Some tools listed here may not be actively used in the current version of Stirling PDF. Their configuration is kept in place for potential re-introduction in future updates.\n\nTwo types of limits are customised for every external tool:\n\n- **Session limits** - how many of a given process can run at the same time\n- **Timeouts** - how long a single process can run before it's killed\n\nBoth sit under `processExecutor` in `settings.yml`. A value of `0` means \"use the default.\"\n\n---\n\n## Session limits\n\nControls how many concurrent instances of each process are allowed. Extra requests queue up and wait.\n\n| Setting | Default | What it controls |\n|---|---|---|\n| `sessionLimit.libreOfficeSessionLimit` | `1` | Word/Excel/PowerPoint/HTML → PDF |\n| `sessionLimit.tesseractSessionLimit` | `1` | OCR (Tesseract is single-threaded) |\n| `sessionLimit.pdfToHtmlSessionLimit` | `1` | PDF → HTML |\n| `sessionLimit.ghostscriptSessionLimit` | `8` | PDF compression, repair, manipulation |\n| `sessionLimit.pythonOpenCvSessionLimit` | `8` | Image processing |\n| `sessionLimit.imageMagickSessionLimit` | `4` | Image conversion |\n| `sessionLimit.qpdfSessionLimit` | `4` | PDF/A conversion, repair, compression |\n| `sessionLimit.ocrMyPdfSessionLimit` | `2` | Add OCR overlay to existing PDFs |\n| `sessionLimit.weasyPrintSessionLimit` | `16` | HTML/CSS → PDF (WeasyPrint) |\n| `sessionLimit.calibreSessionLimit` | `1` | E-book conversions |\n| `sessionLimit.installAppSessionLimit` | `1` | Internal install tasks |\n\n**Increase** limits on a beefy server with concurrent users. **Decrease** them on low-RAM servers - LibreOffice in particular is memory-hungry.\n\nFor LibreOffice specifically, you can also scale by running multiple remote UNO server instances - see [LibreOffice Parallel Processing](doc:configuration/libreoffice-parallel-processing) for details.\n\n> **ℹ️ Info**\n>\n> Be mindful of memory and CPU usage when raising session limits. Each concurrent process consumes resources, and setting limits too high can starve the host or cause out-of-memory issues possibly killing the instance. Start with the defaults and increase gradually while monitoring your server.\n\n\n---\n\n## Timeouts\n\nHow long (in minutes) a process can run before it's forcibly killed and an error is returned.\n\n| Setting | Default |\n|---|---|\n| `timeoutMinutes.libreOfficeTimeoutMinutes` | `30` |\n| `timeoutMinutes.tesseractTimeoutMinutes` | `30` |\n| `timeoutMinutes.ghostscriptTimeoutMinutes` | `30` |\n| `timeoutMinutes.pythonOpenCvTimeoutMinutes` | `30` |\n| `timeoutMinutes.imageMagickTimeoutMinutes` | `30` |\n| `timeoutMinutes.qpdfTimeoutMinutes` | `30` |\n| `timeoutMinutes.ocrMyPdfTimeoutMinutes` | `30` |\n| `timeoutMinutes.weasyPrintTimeoutMinutes` | `30` |\n| `timeoutMinutes.calibreTimeoutMinutes` | `30` |\n| `timeoutMinutes.pdfToHtmlTimeoutMinutes` | `20` |\n| `timeoutMinutes.installAppTimeoutMinutes` | `60` |\n\n**Increase** timeouts if users process very large files that legitimately take longer. **Decrease** them if you want faster failure and tighter resource control.\n\n---\n\n## Examples\n\n### Conservative - low-resource server\n\n\n \n ```yaml\n processExecutor:\n sessionLimit:\n libreOfficeSessionLimit: 1\n tesseractSessionLimit: 1\n ghostscriptSessionLimit: 2\n imageMagickSessionLimit: 2\n pythonOpenCvSessionLimit: 2\n weasyPrintSessionLimit: 4\n qpdfSessionLimit: 1\n ocrMyPdfSessionLimit: 1\n timeoutMinutes:\n libreOfficeTimeoutMinutes: 10\n tesseractTimeoutMinutes: 15\n ```\n \n \n ```bash\n PROCESSEXECUTOR_SESSIONLIMIT_LIBREOFFICESESSIONLIMIT=1\n PROCESSEXECUTOR_SESSIONLIMIT_TESSERACTSESSIONLIMIT=1\n PROCESSEXECUTOR_SESSIONLIMIT_GHOSTSCRIPTSESSIONLIMIT=2\n PROCESSEXECUTOR_TIMEOUTMINUTES_LIBREOFFICETIMEOUTMINUTES=10\n PROCESSEXECUTOR_TIMEOUTMINUTES_TESSERACTTIMEOUTMINUTES=15\n ```\n \n \n ```yaml\n services:\n stirling-pdf:\n image: docker.stirlingpdf.com/stirlingtools/stirling-pdf:latest\n environment:\n PROCESSEXECUTOR_SESSIONLIMIT_LIBREOFFICESESSIONLIMIT: 1\n PROCESSEXECUTOR_SESSIONLIMIT_TESSERACTSESSIONLIMIT: 1\n PROCESSEXECUTOR_TIMEOUTMINUTES_LIBREOFFICETIMEOUTMINUTES: 10\n ```\n \n\n\n### High-throughput - powerful server\n\n\n \n ```yaml\n processExecutor:\n sessionLimit:\n libreOfficeSessionLimit: 4\n tesseractSessionLimit: 4\n ghostscriptSessionLimit: 16\n imageMagickSessionLimit: 8\n pythonOpenCvSessionLimit: 16\n qpdfSessionLimit: 8\n ocrMyPdfSessionLimit: 4\n timeoutMinutes:\n libreOfficeTimeoutMinutes: 60\n tesseractTimeoutMinutes: 60\n ocrMyPdfTimeoutMinutes: 60\n ```\n \n \n ```bash\n PROCESSEXECUTOR_SESSIONLIMIT_LIBREOFFICESESSIONLIMIT=4\n PROCESSEXECUTOR_SESSIONLIMIT_TESSERACTSESSIONLIMIT=4\n PROCESSEXECUTOR_TIMEOUTMINUTES_LIBREOFFICETIMEOUTMINUTES=60\n ```", + "sourcePath": "docs/Configuration/Process-Limits.md", + "editUrl": "https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/Configuration/Process-Limits.md" + }, + "configuration/saml-sso-configuration/saml-sso-configuration": { + "id": "configuration/saml-sso-configuration/saml-sso-configuration", + "title": "SAML 2.0 Single Sign-On Configuration", + "section": "configuration/saml-sso-configuration", + "markdown": "> **Tier**: Enterprise\n\nStirling PDF supports SAML 2.0 Single Sign-On for enterprise deployments. This allows integration with Identity Providers (IdP) like Okta, Azure AD, Google Workspace, OneLogin, Authentik, and others.\n\n> **Looking for OAuth 2.0 SSO?** See [OAuth SSO Configuration](doc:configuration/oauth-sso-configuration) (Server tier).\n\n## Prerequisites\n\nBefore starting, ensure you have:\n\n- [ ] **Enterprise license active** - SAML requires Enterprise tier\n- [ ] **Configs directory mounted** - Docker volume mounted (e.g., `./configs:/configs:ro`)\n- [ ] **Public backend URL configured** - Set `system.backendUrl` to your public backend API URL (often same as frontend, verify `https://your-domain.com/api/v1/info/status` is accessible)\n- [ ] **Reverse proxy configured** - Nginx/Traefik/Caddy with X-Forwarded-* headers forwarding\n- [ ] **Login enabled** - `security.enableLogin: true` in settings\n- [ ] **Admin account ready** - Either existing admin or plan to use `security.initialLogin` credentials\n- [ ] **IdP admin access** - Access to your SAML Identity Provider (Okta, Azure AD, etc.)\n\n> ⚠️ **License Requirement**: SAML 2.0 authentication requires an **ENTERPRISE** license. Existing users created before SAML was license-gated are grandfathered and can continue using SAML with any license tier.\n\n> 💡 **Tip**: Start with `loginMethod: all` during initial setup to allow both username/password and SAML login. This ensures you can always access the admin account if SAML configuration needs adjustment.\n\n## Setup Guide\n\nFollow these steps in order to configure SAML SSO:\n\n### Step 1: Setup Certificates\n\nSAML requires 3 certificate files for mutual trust:\n\n| Certificate | Purpose | Action |\n|-------------|---------|--------|\n| **SP Private Key** | Sign SAML requests to IdP | Generate with OpenSSL |\n| **SP Certificate** | Prove identity to IdP | Generate with OpenSSL, upload to IdP |\n| **IdP Certificate** | Verify SAML responses from IdP | Download from your IdP |\n\n#### 1a. Generate Service Provider (SP) Keypair\n\nStirling PDF needs a keypair to sign SAML requests and verify responses.\n\n> ℹ️ **If you don't have a keypair**, generate one using OpenSSL:\n>\n> ```bash\n> openssl req -newkey rsa:2048 -nodes \\\n> -keyout private_key.key \\\n> -x509 -days 365 \\\n> -out certificate.crt \\\n> -subj \"/C=US/ST=State/L=City/O=Stirling-PDF/CN=your-domain.com\"\n> ```\n>\n> **Command explanation:**\n> - `rsa:2048`: Generates 2048-bit RSA key (secure standard)\n> - `-nodes`: No passphrase (required for automated systems)\n> - `-days 365`: Certificate valid for 1 year\n> - Creates two files: `private_key.key` (private) and `certificate.crt` (public)\n\nIf you already have a keypair, ensure you have both the private key and certificate files ready.\n\n#### 1b. Download IdP Certificate\n\n1. Go to your IdP admin panel\n2. Find SAML app/provider settings\n3. Download signing certificate (PEM format)\n4. Save as `idp-certificate.pem`\n\n#### 1c. Place Certificates in Mounted Directory\n\nPlace all 3 certificates inside your mounted configs directory:\n\n```\n./configs/\n ├── private_key.key ← SP private key (keep secure!)\n ├── certificate.crt ← SP certificate (will upload to IdP)\n └── idp-certificate.pem ← IdP certificate (downloaded)\n```\n\n> ⚠️ **Critical**: Use absolute paths in configuration: `/configs/filename.pem` (no `file:` or `classpath:` prefix for Docker)\n\n### Step 2: Configure Stirling PDF\n\nConfigure SAML authentication by providing:\n- **IdP URLs and certificate** - Get these from your Identity Provider (obtained from IdP admin panel)\n- **SP certificates** - Point to the certificate files created in Step 1\n- **Backend URL** - Your public backend API URL for SAML callbacks (often same as frontend, e.g., `https://stirling.example.com`)\n- **Login settings** - Enable login and set method to `all` (allows both username/password and SAML during setup)\n\n**Key configuration options:**\n- `autoCreateUser: true` - Automatically create user accounts on first SAML login\n- `blockRegistration: false` - Allow new SAML users (set to `true` to require admin pre-approval)\n- `registrationId: stirling` - Identifier used in SAML URLs (must match across all URLs)\n\n\n \n Edit `/configs/settings.yml`:\n\n ```yaml\n security:\n enableLogin: true\n loginMethod: all # Keep 'all' during initial setup\n\n saml2:\n enabled: true\n autoCreateUser: true\n blockRegistration: false\n registrationId: stirling\n\n # Identity Provider (IdP) URLs (get from your IdP)\n idpSingleLoginUrl: https://idp.example.com/saml/login\n idpSingleLogoutUrl: https://idp.example.com/saml/logout\n idpIssuer: https://idp.example.com/entityid\n idpCert: /configs/idp-certificate.pem\n\n # Service Provider (SP) Certificates\n privateKey: /configs/private_key.key\n spCert: /configs/certificate.crt\n\n # Required for SAML callback URLs\n system:\n backendUrl: https://stirling.example.com\n ```\n \n \n Add to your `docker-compose.yml` or Docker run command:\n\n ```yaml\n environment:\n SYSTEM_BACKENDURL: https://stirling.example.com\n SECURITY_ENABLELOGIN: true\n SECURITY_LOGINMETHOD: all\n SECURITY_SAML2_ENABLED: true\n SECURITY_SAML2_AUTOCREATEUSER: true\n SECURITY_SAML2_BLOCKREGISTRATION: false\n SECURITY_SAML2_REGISTRATIONID: stirling\n SECURITY_SAML2_IDPSINGLELOGINURL: https://idp.example.com/saml/login\n SECURITY_SAML2_IDPSINGLELOGOUTURL: https://idp.example.com/saml/logout\n SECURITY_SAML2_IDPISSUER: https://idp.example.com/entityid\n SECURITY_SAML2_IDPCERT: /configs/idp-certificate.pem\n SECURITY_SAML2_PRIVATEKEY: /configs/private_key.key\n SECURITY_SAML2_SPCERT: /configs/certificate.crt\n ```\n \n\n\n> 💡 **Tip**: Replace the example URLs (`idp.example.com`) with actual values from your Identity Provider.\n\n#### Public URL for SAML SLO\n\nFor Single Logout (SLO) to work correctly in production, Stirling PDF uses your `system.backendUrl` setting to tell the Identity Provider where to send the logout response. Make sure that value is set to your public-facing URL (the same setting used for the SAML callbacks above):\n\n\n \n Set in `/configs/settings.yml`:\n\n ```yaml\n system:\n backendUrl: https://your-domain.com\n ```\n \n \n Set in your Docker Compose environment variables:\n\n ```yaml\n environment:\n SYSTEM_BACKENDURL: https://your-domain.com\n ```\n \n\n\n> ⚠️ **Important**: `system.backendUrl` must be set to your public-facing URL for SAML (including Single Logout) to work correctly in production.\n\n### Step 3: Configure Your Identity Provider\n\nProvide your IdP with these Service Provider details:\n\n**Entity ID / SP Metadata URL:**\n```\nhttps://your-domain.com/saml2/service-provider-metadata/stirling\n```\n\n**Assertion Consumer Service (ACS) URL:**\n```\nhttps://your-domain.com/login/saml2/sso/stirling\n```\n\n**Single Logout (SLO) URL:**\n```\nhttps://your-domain.com/logout\n```\n\n> 📌 **Important**: Replace `stirling` with your `registrationId` value if you changed it. The registration ID must match in all URLs.\n\n**Upload SP Certificate to IdP** (Critical Step):\n1. Open `certificate.crt` (your SP public certificate)\n2. In your IdP's SAML app configuration, find \"Verification Certificate\" or \"SP Certificate\" field\n3. Upload or paste `certificate.crt` contents\n4. Save IdP configuration\n\n> Without uploading the SP certificate, your IdP cannot verify requests from Stirling PDF.\n\n**Configure NameID and Attributes:**\n- NameID format: `email` or `unspecified`\n- Ensure at least one username attribute is sent: `username`, `emailaddress`, `name`, `upn`, or `uid`\n\n### Step 4: Test SAML Login\n\n1. Open an incognito/private browser window\n2. Navigate to `https://your-domain.com`\n3. Click \"Login via Single Sign-On\" button\n4. You'll be redirected to your IdP login page\n5. Enter your IdP credentials\n6. You'll be redirected back to Stirling PDF\n7. A new user account is automatically created (if `autoCreateUser: true`)\n\n> ⚠️ **If login fails**, check application logs for SAML errors. See [Troubleshooting](#troubleshooting) section.\n\n### Step 5: Promote SAML User to Admin\n\n1. Log in with your initial admin account (username/password)\n2. Go to **Settings** → **User Management**\n3. Find the SAML user account (created during test login)\n4. Change **Role** to **Admin**\n5. **Save**\n\n> ⚠️ **Important**: Keep at least one SAML user with admin privileges before switching to SSO-only mode.\n\n### Step 6 (Optional): Switch to SSO-Only Mode\n\nOnce you've verified SAML works and have a SAML admin user:\n\n```yaml\nsecurity:\n loginMethod: saml2 # Disables username/password login\n```\n\nRestart Stirling PDF.\n\n## Configuration Reference\n\n### Required Properties\n\n| Property | Description | Example |\n|----------|-------------|---------|\n| `security.saml2.enabled` | Enable SAML 2 authentication | `true` |\n| `security.saml2.idpSingleLoginUrl` | IdP's Single Sign-On URL | `https://idp.example.com/sso` |\n| `security.saml2.idpSingleLogoutUrl` | IdP's Single Logout URL | `https://idp.example.com/slo` |\n| `security.saml2.idpIssuer` | IdP's Entity ID / Issuer | `https://idp.example.com` |\n| `security.saml2.idpCert` | IdP's signing certificate (PEM format) | `/configs/idp-cert.pem` |\n| `security.saml2.privateKey` | Your SP private key | `/configs/private_key.key` |\n| `security.saml2.spCert` | Your SP certificate | `/configs/certificate.crt` |\n| `system.backendUrl` | Public HTTPS URL for callbacks | `https://stirling.example.com` |\n\n### Optional Properties\n\n| Property | Default | Description |\n|----------|---------|-------------|\n| `security.saml2.autoCreateUser` | `true` | Auto-create users on first SAML login |\n| `security.saml2.blockRegistration` | `false` | Block new users (only allow pre-registered) |\n| `security.saml2.registrationId` | `stirling` | Registration ID (must match ACS URL path) |\n| `security.saml2.provider` | `null` | Optional provider name for logging |\n| `security.loginMethod` | `all` | Login method: `all`, `normal`, `oauth2`, `saml2` |\n\n## Advanced Configuration\n\n### SAML Attribute Mapping\n\nStirling PDF attempts to determine the username in the following priority order:\n\n1. **`username`** attribute\n2. **`emailaddress`** attribute (or full URI: `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress`)\n3. **`name`** attribute (or full URI: `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name`)\n4. **`upn`** attribute (User Principal Name - often used in Active Directory)\n5. **`uid`** attribute (Unix user ID)\n6. **NameID** from SAML Subject (fallback if no attributes provided)\n\n**Minimum requirement:** NameID in the SAML Subject (used as fallback identifier)\n\n**Recommended:** At least one username attribute from the priority list above\n\n#### Attribute Debugging\n\nEnable debug logging to see what attributes your IdP is sending:\n\n\n \n Add to `/configs/custom_settings.yml`:\n\n ```yaml\n logging:\n level:\n stirling.software.proprietary.security.saml2: DEBUG\n ```\n \n \n ```bash\n LOGGING_LEVEL_STIRLING_SOFTWARE_PROPRIETARY_SECURITY_SAML2=DEBUG\n ```\n \n\n\nCheck logs for:\n```\nExtracted SAML Attributes: {username=[john.doe], emailaddress=[john.doe@example.com], ...}\n```\n\n> 💡 **Note**: Currently, Stirling PDF only uses attributes for username identification. Other attributes (first name, last name, groups, roles) are extracted but not used.\n\n### Understanding Registration ID\n\nThe `registrationId` is a critical configuration value that becomes part of your SAML URLs:\n\n```yaml\nsecurity:\n saml2:\n registrationId: stirling # Default value\n```\n\nWith `registrationId: stirling`, your URLs are:\n- Metadata: `https://your-domain.com/saml2/service-provider-metadata/stirling`\n- ACS: `https://your-domain.com/login/saml2/sso/stirling`\n\n**If you change the registration ID:**\n```yaml\nsecurity:\n saml2:\n registrationId: mycompany # Custom value\n```\n\nYour URLs become:\n- Metadata: `https://your-domain.com/saml2/service-provider-metadata/mycompany`\n- ACS: `https://your-domain.com/login/saml2/sso/mycompany`\n\n> ⚠️ **Critical**: If you change `registrationId` after configuring your IdP, you must update ALL URLs in your IdP configuration. The registration ID must match exactly in all places, or SAML login will fail.\n\n> 💡 **Recommendation**: Keep the default `stirling` value unless you have a specific reason to change it (e.g., running multiple Stirling PDF instances with the same IdP).\n\n### Auto-Login Feature\n> **Tier**: Enterprise\n\nAutomatically redirect users to SAML login, bypassing the Stirling PDF login screen:\n\n```yaml\npremium:\n proFeatures:\n ssoAutoLogin: true\n```\n\n**Auto-login Activation Requirements:**\n\nAuto-login only triggers when **ALL** of the following conditions are met:\n\n1. `ssoAutoLogin` is enabled (as configured above)\n2. `loginMethod` is NOT `'all'` and NOT `'normal'` (i.e., SSO-only mode required)\n3. Exactly one SAML provider is configured\n\n**Behavior:**\n- When all conditions are met: Users are automatically redirected to IdP\n- When conditions are not met: Standard login page is displayed\n- If a single sign-on attempt fails, the automatic redirect is suppressed for the rest of that browser session so the login page stays visible (this is separate from the `security.loginAttemptCount` account-lockout setting, which locks the user account after repeated failures)\n- Users can still manually access `/login` for form login if `loginMethod: all`\n\n## Troubleshooting\n\n### \"SAML requires Enterprise license\"\n**Cause**: SAML authentication requires Enterprise tier license.\n\n**Solution**:\n- Verify valid Enterprise license is configured\n- Check `premium.enabled=true` in settings\n- Existing users created before license enforcement are grandfathered\n\n### \"Invalid SAML response signature\"\n**Cause**: IdP certificate mismatch or incorrect format.\n\n**Solution**:\n- Verify `idpCert` file matches certificate from IdP\n- Ensure certificate is in PEM format (starts with `-----BEGIN CERTIFICATE-----`)\n- Re-download certificate from IdP\n- Check certificate hasn't expired\n\n### \"ACS URL mismatch\"\n**Cause**: Redirect URL doesn't match IdP configuration.\n\n**Solution**:\n- Verify `SYSTEM_BACKENDURL` is set to public HTTPS URL\n- Check reverse proxy sends X-Forwarded-Proto, X-Forwarded-Host, X-Forwarded-Port headers\n- Ensure `registrationId` matches in both URL and configuration\n- Update ACS URL in IdP to match: `https://your-domain.com/login/saml2/sso/stirling`\n\n### \"File not found: idp-certificate.pem\"\n**Cause**: Certificate file path is incorrect.\n\n**Solution**:\n- Verify file exists at specified path\n- For Docker: ensure volume is mounted correctly\n- Use absolute paths like `/configs/filename.pem` (no `file:` prefix)\n- Check file permissions (must be readable by application)\n\n### \"Cannot auto-create user\"\n**Cause**: User doesn't exist and auto-creation is disabled.\n\n**Solution**:\n- Set `autoCreateUser: true` to allow new users\n- Or pre-create user accounts as admin\n- Check license allows additional users\n\n### \"Redirect loop after SAML login\"\n**Cause**: Session or cookie issues.\n\n**Solution**:\n- Clear browser cookies\n- Check `SYSTEM_BACKENDURL` matches actual access URL\n- Verify cookies are allowed for domain\n- Ensure SameSite cookie settings are compatible\n\n### \"Invalid username\" error\n**Cause**: No valid username found in assertion.\n\n**Solution**:\n1. Enable debug logging to see what attributes are received\n2. Ensure IdP sends at least one of: `username`, `emailaddress`, `name`, `upn`, `uid`\n3. Verify NameID is present in SAML Subject as fallback\n4. Check attribute name format (short name vs full URI)\n\n### Debug Logging\n\nEnable SAML debug logging to see detailed authentication flow:\n\n\n \n Add to `/configs/custom_settings.yml`:\n\n ```yaml\n logging:\n level:\n org.springframework.security.saml2: DEBUG\n org.opensaml: DEBUG\n stirling.software.proprietary.security: DEBUG\n ```\n \n \n ```bash\n LOGGING_LEVEL_ORG_SPRINGFRAMEWORK_SECURITY_SAML2=DEBUG\n LOGGING_LEVEL_ORG_OPENSAML=DEBUG\n LOGGING_LEVEL_STIRLING_SOFTWARE_PROPRIETARY_SECURITY=DEBUG\n ```\n \n\n\nCheck logs for SAML attribute information:\n```\nExtracted SAML Attributes: {username=[john.doe], emailaddress=[john.doe@example.com], ...}\n```\n\n### Inspect SAML Assertions\n\nUse browser developer tools:\n1. Open Network tab\n2. Clear network log\n3. Attempt SAML login\n4. Look for POST to `/login/saml2/sso/stirling`\n5. Decode SAMLResponse parameter (Base64 + inflate)\n\n**Tools:**\n- [SAML-tracer](https://addons.mozilla.org/en-US/firefox/addon/saml-tracer/) (Firefox/Chrome extension)\n- [SAML Decoder](https://www.samltool.com/decode.php) (online tool)\n\n## Known Limitations\n\n### idpMetadataUri Not Auto-Populating\n\nThe `idpMetadataUri` configuration field exists but is **not currently used** to auto-populate IdP settings. You must manually configure:\n- `idpSingleLoginUrl`\n- `idpSingleLogoutUrl`\n- `idpIssuer`\n- `idpCert`\n\n**Workaround**: Manually extract values from IdP metadata XML.\n\n> 💡 **Note**: Auto-populating IdP settings from metadata URI is a planned enhancement coming soon.\n\n## See Also\n\n- [OAuth SSO Configuration](doc:configuration/oauth-sso-configuration) - OAuth 2.0 / OIDC setup\n- [System and Security](doc:configuration/system-and-security) - Additional security settings\n- [External Database](doc:configuration/external-database) - User storage configuration\n- [Paid Offerings](doc:paid-offerings) - Enterprise tier and licensing information", + "sourcePath": "docs/Configuration/SAML SSO Configuration/SAML SSO Configuration.md", + "editUrl": "https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/Configuration/SAML SSO Configuration/SAML SSO Configuration.md" + }, + "configuration/ssrf-protection": { + "id": "configuration/ssrf-protection", + "title": "SSRF Protection", + "section": "configuration", + "markdown": "## What is SSRF and why does it matter?\n\nSSRF (Server-Side Request Forgery) is when someone tricks your server into making HTTP requests on their behalf - to your internal network, cloud metadata endpoints, or other places they shouldn't be able to reach.\n\nIn Stirling PDF, the risk is tools like **URL to PDF**: a user could supply `http://192.168.1.1/admin` or `http://169.254.169.254/` and your server would fetch it. For self-hosted deployments on a private network, that's a real concern.\n\nSSRF protection is **enabled by default** at `MEDIUM` level. For most deployments, you don't need to touch it.\n\n> **⚠️ Warning**\n>\n> The URL to PDF feature is **disabled by default** (`system.enableUrlToPDF: false`) due to the SSRF risks described above. It is intended for internal use only and should not be exposed externally. If you enable it, make sure SSRF protection is properly configured.\n\n\n---\n\n## Settings\n\nAll settings are under `system.html.urlSecurity` in `settings.yml`.\n\n| Setting | Default | Description |\n|---|---|---|\n| `enabled` | `true` | Master on/off switch |\n| `level` | `MEDIUM` | `OFF`, `MEDIUM`, or `MAX` - see below |\n| `allowedDomains` | `[]` | Domains to always allow |\n| `blockedDomains` | `[]` | Domains to always block |\n| `internalTlds` | `.local`, `.internal`, `.corp`, `.home` | TLD suffixes treated as internal |\n| `blockPrivateNetworks` | `true` | Block RFC1918 private IP ranges |\n| `blockLocalhost` | `true` | Block 127.x / ::1 |\n| `blockLinkLocal` | `true` | Block 169.254.x.x / fe80:: |\n| `blockCloudMetadata` | `true` | Block AWS/GCP/Azure/Oracle/IBM metadata IPs |\n\n### Protection levels\n\n**`MEDIUM`** (default) - Blocks private IPs, localhost, cloud metadata, and internal TLDs. Public internet URLs are allowed by default.\n\n**`MAX`** - Only URLs explicitly listed in `allowedDomains` are allowed. Everything else is blocked. Unlike MEDIUM, subdomain matching is not supported in MAX mode - each domain and subdomain must be listed individually. Use this if you know exactly which external domains your users need.\n\n**`OFF`** - No SSRF checking at all. Only appropriate if you have network-level controls elsewhere.\n\n### Domain allow and block lists\n\nThe `allowedDomains` and `blockedDomains` settings work alongside whichever protection level you choose.\n\n- **`allowedDomains`** - When set at MEDIUM level, only these domains (and their subdomains) are permitted in addition to the default public-internet access. At MAX level, this is the exclusive list of permitted domains (no subdomain matching).\n- **`blockedDomains`** - Domains to always deny, regardless of level. Uses exact matching - blocking `example.com` will not block `sub.example.com`.", + "sourcePath": "docs/Configuration/SSRF-Protection.md", + "editUrl": "https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/Configuration/SSRF-Protection.md" + }, + "configuration/sign-with-custom-files": { + "id": "configuration/sign-with-custom-files", + "title": "Visual Sign with Custom File Storage", + "section": "configuration", + "markdown": "Stirling PDF provides functionality to store and reuse files across sessions, particularly useful for features like signatures and commonly used assets. This guide explains how to set up and use custom file storage.\n\n## Overview\n\nThe custom file storage system allows you to:\n- Store files persistently across sessions\n- Share files between all users or restrict them to specific users\n- Access stored files through the web UI\n- Organize files in a structured way\n\n## Storage Location\n\nAll custom files should be stored in the `/customFiles/` directory. For features like signatures, the specific path is:\n\n```\n/customFiles/signatures/\n```\n\n## Access Levels\n\nThe system supports two types of access levels for stored files:\n\n### 1. All Users Access\nFiles that should be accessible to all users should be placed in:\n```\n/customFiles/signatures/ALL_USERS/\n```\nThis is useful for:\n- Organization-wide templates\n- Shared assets\n- Default signatures or watermarks\n- Environments where authentication isn't used\n\n### 2. User-Specific Access\nFiles that should only be accessible to specific users should be placed in user-specific directories:\n```\n/customFiles/signatures/{username}/\n```\nFor example:\n```\n/customFiles/signatures/john_doe/\n```\nThese files will only be accessible to the specified user when logged in.\n\n## Usage in Docker\n\nWhen using Docker, make sure to mount the customFiles directory as a volume to persist the files:\n\n```yaml\nvolumes:\n - ./customFiles:/customFiles/\n```\n\n## Best Practices\n\n1. File Organization:\n - Keep files organized in appropriate subdirectories\n - Use clear, descriptive filenames\n - Consider using date-based or category-based organization for large numbers of files\n\n2. Security:\n - Only place files in ALL_USERS if they truly need to be accessible to everyone\n - Regularly review and clean up unused files\n - Monitor storage usage to prevent excessive accumulation of files\n\n3. Supported File Types:\n - For signatures: common image formats (PNG, JPG, SVG)\n - Ensure files are of appropriate size and format for their intended use\n\n## Example Structure\n\nHere's an example of how your custom files directory might look:\n\n```\n/customFiles/\n├── signatures/\n│ ├── ALL_USERS/\n│ │ ├── company-logo.png\n│ │ └── default-signature.png\n│ ├── john_doe/\n│ │ ├── personal-signature.png\n│ │ └── department-stamp.png\n│ └── jane_smith/\n│ └── signature-2024.png\n```\n\n## Accessing Files\n\nFiles stored in these locations will automatically be available in the relevant features of the Stirling PDF web interface. For example, saved signatures will appear in the signature selection interface when using the Sign feature.", + "sourcePath": "docs/Configuration/Sign with custom files.md", + "editUrl": "https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/Configuration/Sign with custom files.md" + }, + "configuration/single-sign-on-configuration": { + "id": "configuration/single-sign-on-configuration", + "title": "Single Sign-On (SSO) Overview", + "section": "configuration", + "markdown": "Stirling PDF supports Single Sign-On (SSO) authentication through two protocols:\n\n## OAuth 2.0 / OpenID Connect (OIDC)\n> **Tier**: Server\n\nOAuth 2.0 SSO allows login via popular identity providers like:\n- Google\n- GitHub\n- Keycloak\n- Authentik\n- Any OIDC-compliant provider\n\n**[→ Configure OAuth 2.0 SSO](doc:configuration/oauth-sso-configuration)**\n\n**Key Features:**\n- Easy setup with major providers\n- Auto-discovery via `.well-known/openid-configuration`\n- Social login support\n- Suitable for small to medium organizations\n\n---\n\n## SAML 2.0\n> **Tier**: Enterprise\n\nSAML 2.0 SSO provides enterprise-grade authentication with:\n- Okta\n- Azure AD (Entra ID)\n- Google Workspace\n- OneLogin\n- Any SAML 2.0-compliant IdP\n\n**[→ Configure SAML 2.0 SSO](doc:configuration/saml-sso-configuration/saml-sso-configuration)**\n\n**Key Features:**\n- Enterprise identity provider integration\n- Advanced security controls\n- Single Logout (SLO) support\n- Suitable for large organizations", + "sourcePath": "docs/Configuration/Single Sign-On Configuration.md", + "editUrl": "https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/Configuration/Single Sign-On Configuration.md" + }, + "configuration/system-and-security": { + "id": "configuration/system-and-security", + "title": "Login, System and Security", + "section": "configuration", + "markdown": "Stirling PDF allows customization of system and security settings. For security features to be enabled, you must use the security jar. For Docker users, this means setting `DISABLE_ADDITIONAL_FEATURES` to `false` via an environment variable.\n\n## Basic Security Settings\n\n- `enableLogin`: Enables or disables the login functionality (only available in Stirling-PDF-with-login.jar or when `DISABLE_ADDITIONAL_FEATURES=false`)\n- `defaultLocale`: Set the default language (e.g. 'de-DE', 'fr-FR', etc)\n- `googlevisibility`: 'true' to allow Google visibility (via robots.txt), 'false' to disallow\n- `xFrameOptions`: Controls whether your instance can be embedded in an iframe. Set to `DENY` to prevent clickjacking. Use `SAMEORIGIN` only if you embed the UI in your own application.\n- `loginAttemptCount`: Number of failed login attempts before an account is locked (e.g. `5`)\n- `loginResetTimeMinutes`: Minutes before a locked account is automatically unlocked (e.g. `10`)\n\n## Authentication Setup\n\n**Important:** Authentication and additional features are included by default in:\n- **Docker**: All images except ultra-lite (authentication is **enabled by default**)\n- **JAR files**: Use [Stirling-PDF-with-login.jar](https://files.stirlingpdf.com/Stirling-PDF-with-login.jar) **(Recommended)**\n\n**Not included in:**\n- **Docker ultra-lite**: Minimal build without authentication (set `DISABLE_ADDITIONAL_FEATURES=false` to enable)\n- **Plain JAR**: [Stirling-PDF.jar](https://files.stirlingpdf.com/Stirling-PDF.jar) - Basic build without authentication or additional features\n\n### Prerequisites\n1. Ensure the `/configs` directory is mounted as a volume in Docker for persistence across updates\n2. Use the appropriate build:\n - **JAR**: Download Stirling-PDF-with-login.jar\n - **Docker**: Set `DISABLE_ADDITIONAL_FEATURES=false` in environment variables\n\n### Initial Login Credentials\n- Default Username: `admin`\n- Default Password: `stirling`\n- Note: Users will be forced to change their password on first login\n- Custom initial credentials can be set using:\n - `SECURITY_INITIALLOGIN_USERNAME`\n - `SECURITY_INITIALLOGIN_PASSWORD`\n\n### Database Location\nUpon successful setup, a new `stirling-pdf-DB-.mv.db` file (the version number is part of the filename, e.g. `stirling-pdf-DB-2.3.232.mv.db`) will be created in your configured storage location. This file contains user data and should be backed up regularly.\n\n### Account Management\n1. Access Account Settings:\n - Click the settings cog menu in the top right navbar\n - Select \"Account Settings\"\n - Here you can manage your profile and find your API key\n\n2. Adding New Users:\n - Navigate to Account Settings\n - Scroll to bottom and click 'Admin Settings'\n - Use the user management interface to add new users\n\n### Role-Based Access Control\nCurrently, roles are primarily used for rate limiting purposes. The role system is under active development and will be expanded with additional features in future updates.\n\n### API Authentication\nWhen using the API:\n- Each user has a unique API key found in their Account Settings\n- Include the API key in requests using the `X-API-KEY` header\n- Example:\n ```\n X-API-KEY: your-api-key-here\n ```\n\n## Running Without Authentication\n\nIf you need to run without authentication (note: this also disables additional features), you have two options:\n\n### Option 1: Disable Login in With-Login Version (Recommended)\n\nDisable authentication while keeping additional features:\n\n\n \n ```yaml\n security:\n enableLogin: false\n ```\n \n \n ```bash\n SECURITY_ENABLELOGIN=false\n ```\n \n \n ```bash\n docker run -d \\\n -p 8080:8080 \\\n -e SECURITY_ENABLELOGIN=false \\\n -e DISABLE_ADDITIONAL_FEATURES=false \\\n stirlingtools/stirling-pdf:latest\n ```\n \n \n ```yaml\n services:\n stirling-pdf:\n image: stirlingtools/stirling-pdf:latest\n environment:\n SECURITY_ENABLELOGIN: false\n DISABLE_ADDITIONAL_FEATURES: false\n ```\n \n \n ```bash\n java -jar Stirling-PDF-with-login.jar -DSECURITY_ENABLELOGIN=false\n ```\n \n \n ```bash\n export SECURITY_ENABLELOGIN=false\n java -jar Stirling-PDF-with-login.jar\n ```\n \n\n\n### Option 2: Use Open Source JAR\n\nUse [Stirling-PDF.jar](https://files.stirlingpdf.com/Stirling-PDF.jar) which has no authentication (note: also excludes additional features):\n\n```bash\njava -jar Stirling-PDF.jar\n```\n\n### Recommendation\n\n**We recommend Stirling-PDF-with-login.jar for all deployments** because it includes additional features beyond just authentication. You can always disable authentication if needed while keeping the extra functionality.\n\n---\n\n## Login Agreement / Disclaimer\n\nShow a disclaimer that users must accept before they can use the app. It appears as a blocking dialog after a successful login (or on launch when login is disabled), and works on every edition.\n\n```yaml\nlegal:\n loginAgreement:\n enabled: false # Master on/off switch\n showInAnonymousMode: true # When login is disabled, set false to hide the dialog\n fallbackText: \"\" # Markdown shown when no per-language file is found\n```\n\n**Environment Variables:**\n```bash\nLEGAL_LOGINAGREEMENT_ENABLED=true\nLEGAL_LOGINAGREEMENT_SHOWINANONYMOUSMODE=true\nLEGAL_LOGINAGREEMENT_FALLBACKTEXT=\"By signing in you agree to the terms...\"\n```\n\nThe disclaimer is written in **Markdown**. Provide per-language versions as files at `customFiles/disclaimer/.md` (for example `en-US.md` or `de-DE.md`); the text shown follows each user's interface language and falls back to `fallbackText` when no matching file exists. If no text resolves at all (no files and no `fallbackText`), the dialog is not shown even when `enabled` is `true`. For a single-language or headless install, set `fallbackText` (env `LEGAL_LOGINAGREEMENT_FALLBACKTEXT`) and skip the per-language files. Editing the text takes effect on the next login with no restart; turning `enabled` on or off requires a restart.\n\nAdmins can also edit the text in-app from **Admin Settings → Legal**, which writes the same per-language files.\n\nIn the desktop app, the dialog can be enabled per machine through MDM - see [Managed Desktop Deployment](doc:installation/managed-deployment).\n\n---\n\n## Server Certificates\n\nStirling PDF can auto-generate certificates for the \"Sign with Stirling PDF\" feature.\n\n### Configuration\n\n```yaml\nsystem:\n serverCertificate:\n enabled: true # Enable auto-generation\n organizationName: Stirling-PDF # Certificate organization name\n validity: 365 # Days until expiration\n regenerateOnStartup: false # Keep same cert across restarts\n```\n\n**Environment Variables:**\n```bash\nSYSTEM_SERVERCERTIFICATE_ENABLED=true\nSYSTEM_SERVERCERTIFICATE_ORGANIZATIONNAME=\"My Company\"\nSYSTEM_SERVERCERTIFICATE_VALIDITY=365\nSYSTEM_SERVERCERTIFICATE_REGENERATEONSTARTUP=false\n```\n\n### How It Works\n\n1. **First Startup:** Server generates a self-signed certificate, stored as `/configs/server-certificate.p12`\n2. **Subsequent Startups:** Reuses existing certificate (unless `regenerateOnStartup: true`)\n3. **User Signs:** PDFs signed using this certificate via \"Sign with Stirling-PDF\" option\n\n---\n\n## Signature Validation\n\nConfigure how PDF certificate signatures are validated.\n\n### Trust Sources\n\n```yaml\nsecurity:\n validation:\n trust:\n serverAsAnchor: true # Trust server-generated certificates\n useSystemTrust: true # Use OS certificate store\n useMozillaBundle: true # Mozilla CA bundle\n useAATL: false # Adobe Approved Trust List\n useEUTL: false # EU Trusted List (eIDAS)\n```\n\n**Environment Variables:**\n```bash\nSECURITY_VALIDATION_TRUST_SERVERASANCHOR=true\nSECURITY_VALIDATION_TRUST_USESYSTEMTRUST=true\nSECURITY_VALIDATION_TRUST_USEMOZILLABUNDLE=true\nSECURITY_VALIDATION_TRUST_USEAATL=false\nSECURITY_VALIDATION_TRUST_USEEUTL=false\n```\n\n### Trust List URLs\n\nConfigure external trust list locations:\n\n```yaml\nsecurity:\n validation:\n aatl:\n url: https://trustlist.adobe.com/tl.pdf\n eutl:\n lotlUrl: https://ec.europa.eu/tools/lotl/eu-lotl.xml\n acceptTransitional: false\n```\n\n### Revocation Checking\n\nVerify certificates haven't been revoked:\n\n```yaml\nsecurity:\n validation:\n revocation:\n mode: none # Options: none, ocsp, crl, ocsp+crl\n hardFail: false # Fail validation if revocation check fails\n```\n\n**Revocation Modes:**\n- `none`: No revocation checking (not recommended for production)\n- `ocsp`: Online Certificate Status Protocol (fast, requires network)\n- `crl`: Certificate Revocation Lists (slower, works offline)\n- `ocsp+crl`: Try OCSP first, fall back to CRL (recommended)\n\n**Environment Variables:**\n```bash\nSECURITY_VALIDATION_REVOCATION_MODE=ocsp+crl\nSECURITY_VALIDATION_REVOCATION_HARDFAIL=false\n```\n\n### Authority Information Access (AIA)\n\nAllow automatic fetching of intermediate certificates:\n\n```yaml\nsecurity:\n validation:\n allowAIA: false # Set true to enable (requires network access)\n```\n\n**⚠️ Security Note:** Disabled by default. Only enable in controlled environments where outbound HTTPS is secure.\n\n**Learn more:** [Certificate Signing - Validation](doc:functionality/security/certificate-signing)\n\n---\n\n## JWT Authentication\n\nLogins use JSON Web Tokens. The main thing to configure is how long a session lasts before a user has to sign in again.\n\n```yaml\nsecurity:\n jwt:\n tokenExpiryMinutes: 1440 # Web login lifetime (default 24 hours)\n desktopTokenExpiryMinutes: 43200 # Desktop login lifetime (default 30 days)\n```\n\nEnvironment variables: `SECURITY_JWT_TOKENEXPIRYMINUTES` and `SECURITY_JWT_DESKTOPTOKENEXPIRYMINUTES`.\n\nLower these for tighter security (users sign in more often) or raise them for convenience.\n\n---\n\n## Email Configuration\n\nConfigure SMTP for sending email invitations and notifications. Enable `mail.enableInvites` to allow invitation links.\n\n> 💡 **When is email configuration required?**\n>\n> Email configuration is **OPTIONAL** and only needed for:\n> - **Email invitations**: Admins can send invite links to users via email\n> - **Password reset emails**: Users can reset forgotten passwords (if implemented)\n>\n> Email is **NOT required** for:\n> - Basic username/password login\n> - SSO authentication (OAuth 2.0 or SAML 2.0)\n> - Manual user creation by admins\n> - Normal application operation\n>\n> You can run Stirling PDF without any email configuration if you create users manually or use SSO.\n\n### Email Invites\n\nEnable email-based user invitations:\n\n```yaml\nmail:\n enabled: true\n enableInvites: true\n host: smtp.example.com\n port: 587\n username: noreply@example.com\n password: ${MAIL_PASSWORD}\n from: noreply@example.com\n startTlsEnable: true\n```\n\n**Environment Variables:**\n```bash\nMAIL_ENABLED=true\nMAIL_ENABLEINVITES=true\nMAIL_HOST=smtp.gmail.com\nMAIL_PORT=587\nMAIL_USERNAME=your-email@gmail.com\nMAIL_PASSWORD=your-app-password\nMAIL_FROM=noreply@example.com\nMAIL_STARTTLSENABLE=true\n```\n\n**Requirements:**\n- `mail.enabled: true`\n- `mail.enableInvites: true` for invitation flows\n- `security.enableLogin: true`\n- Valid SMTP configuration\n- `system.frontendUrl` configured (for invite links)\n\n---\n\n## UI Customization\n\n### Logo Style\n\nChoose between logo styles:\n\n```yaml\nui:\n logoStyle: classic # Options: 'classic' or 'modern'\n```\n\n**Environment Variable:**\n```bash\nUI_LOGOSTYLE=modern\n```\n\n**Styles:**\n- `classic`: Traditional \"S\" icon logo\n- `modern`: Minimalist design\n\n### Custom Logo\n\nYou can also override the bundled logo by dropping your own files into the matching style subdirectory:\n\n```bash\ncustomFiles/\n └── static/\n ├── classic-logo/\n │ └── logo.svg # Overrides the classic logo\n └── modern-logo/\n └── logo.svg # Overrides the modern logo\n```\n\n**Learn more:** [UI Customisation](doc:configuration/ui-customisation)\n\n---\n\n## Configuration Examples\n\n\n \n ```yaml\n security:\n enableLogin: true # Only works with Stirling-PDF-with-login.jar or DISABLE_ADDITIONAL_FEATURES=false\n jwt:\n tokenExpiryMinutes: 1440\n validation:\n trust:\n serverAsAnchor: true\n useSystemTrust: true\n useMozillaBundle: true\n revocation:\n mode: ocsp\n hardFail: false\n\n system:\n defaultLocale: 'en-US' # Set the default language (e.g. 'de-DE', 'fr-FR', etc)\n googlevisibility: false # 'true' to allow Google visibility (via robots.txt), 'false' to disallow\n serverCertificate:\n enabled: true\n organizationName: Stirling-PDF\n validity: 365\n\n mail:\n enabled: false\n enableInvites: false\n\n ui:\n logoStyle: classic\n ```\n \n \n You can configure these settings in two ways when running locally:\n\n **Option 1: Using Java Properties**\n ```bash\n java -jar Stirling-PDF.jar -DDISABLE_ADDITIONAL_FEATURES=false -DSECURITY_ENABLELOGIN=true\n ```\n\n **Option 2: Using Environment Variables**\n ```bash\n export DISABLE_ADDITIONAL_FEATURES=false\n export SECURITY_ENABLELOGIN=true\n ```\n \n \n ```bash\n -e DISABLE_ADDITIONAL_FEATURES=false \\\n -e SECURITY_ENABLELOGIN=true \\\n -e SYSTEM_CORSALLOWEDORIGINS=https://pdf.example.com \\\n -e SYSTEM_FRONTENDURL=https://pdf.example.com \\\n -e SECURITY_JWT_ENABLEKEYSTORE=true \\\n ```\n \n \n ```yaml\n environment:\n DISABLE_ADDITIONAL_FEATURES: false\n SECURITY_ENABLELOGIN: true\n SECURITY_JWT_ENABLEKEYSTORE: true\n SYSTEM_SERVERCERTIFICATE_ENABLED: true\n ```\n \n\n\n---\n\n## Related Documentation\n\n- **[Security Features](doc:functionality/security/security)** - PDF security tools, CORS, signature validation\n- **[Certificate Signing](doc:functionality/security/certificate-signing)** - Comprehensive signing and validation guide\n- **[Single Sign-On](doc:configuration/single-sign-on-configuration)** - Enterprise authentication\n- **[UI Customisation](doc:configuration/ui-customisation)** - Branding and appearance\n- **[Migration Guide](doc:migration/settings-changes)** - Upgrading from V1", + "sourcePath": "docs/Configuration/System and Security.md", + "editUrl": "https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/Configuration/System and Security.md" + }, + "configuration/telegram-bot": { + "id": "configuration/telegram-bot", + "title": "Telegram Bot Integration", + "description": "Process PDFs through a Telegram bot powered by Stirling PDF pipelines", + "section": "configuration", + "markdown": "Stirling PDF can run a Telegram bot that accepts PDF files from chats and returns processed results using your saved [Automate / Pipeline](doc:configuration/pipeline) configurations. Send a PDF to the bot, get a processed PDF back.\n\nFree on all license tiers.\n\n> **⚠️ Warning: Community feature - not recommended for Enterprise use**\n>\n> The Telegram bot integration is a **community-built feature** in **beta**. It is not built, tested, or supported by Stirling Tools and is not covered by the Server / Enterprise support SLA. **We do not recommend it for Enterprise or production-critical deployments.** Raise issues on GitHub or in the community Discord. Behaviour and config keys may change in future releases.\n\n\n---\n\n## How it works\n\n1. A user, channel, or group sends a PDF file to your Telegram bot.\n2. The bot saves the file into a watched pipeline inbox folder.\n3. Stirling PDF processes the file using the pipeline JSON in that folder (see [Folder Scanning](doc:configuration/folderscanning)).\n4. The bot sends the result back to the chat.\n\nThe bot talks to Telegram outbound only - no inbound port or webhook setup required, just outbound HTTPS to `api.telegram.org`.\n\n---\n\n## Setup overview\n\n1. Create the bot in Telegram and get a bot token.\n2. Set your token and username in Stirling PDF.\n3. Drop a pipeline JSON into the inbox folder.\n4. Send a PDF to the bot.\n\n---\n\n## 1. Create the bot in Telegram\n\n1. Open [@BotFather](https://t.me/BotFather) in Telegram.\n2. Send `/newbot`.\n3. Pick a display name and a username ending in `bot`.\n4. Copy the token BotFather returns.\n\n**If you plan to add the bot to groups:** also send `/setprivacy`, select your bot, choose **Disable**. With privacy mode on (the default), the bot only sees commands and direct mentions in groups.\n\n---\n\n## 2. Configure Stirling PDF\n\nThe `telegram:` block is already present in the shipped `settings.yml` - find it and update the values (set `enabled: true`, fill in `botToken` and `botUsername`), or set the equivalent environment variables. Restart Stirling PDF after saving via file edits; the Admin UI hot-applies.\n\n### Minimal config\n\n```yaml\ntelegram:\n enabled: true\n botToken: \"your-token-from-botfather\"\n botUsername: \"your_bot_username\"\n```\n\n### Recommended config\n\n```yaml\ntelegram:\n enabled: true\n botToken: \"your-token-from-botfather\"\n botUsername: \"your_bot_username\"\n customFolderSuffix: true # one inbox per chat\n enableAllowUserIDs: true # restrict to known users\n allowUserIDs: [123456789]\n processingTimeoutSeconds: 180 # max time to wait for a result\n```\n\n### Admin UI\n\nIf you have login enabled, configure the bot from **Admin Settings → Connections → Telegram Bot**. Changes saved through the UI apply without a restart.\n\n---\n\n## 3. Drop a pipeline JSON into the inbox\n\nThe bot will not process uploads until at least one `.json` pipeline file exists in the chat's inbox folder.\n\nDefault inbox path: `/pipeline/watchedFolders/telegram/`. With `customFolderSuffix: true` (recommended), each chat gets its own subfolder named after its Telegram chat ID.\n\n### How to get a chat ID\n\nThe folder is created the first time a chat messages the bot. Easiest bootstrap:\n\n1. Send any message from the chat to the bot.\n2. Look at the new subfolder name under `/pipeline/watchedFolders/telegram/`.\n3. That subfolder name is the chat ID.\n\nOr chat with [@userinfobot](https://t.me/userinfobot) which echoes your user ID.\n\n### Build the pipeline JSON\n\nOpen the **Automate** tool, build the workflow you want, then click **Export for Folder Scanning** to download the JSON. Drop the file into the chat's inbox folder. The filename does not matter - any `.json` is picked up.\n\nSee [Pipeline Automation](doc:configuration/pipeline) for details.\n\n---\n\n## 4. Use the bot\n\nIn Telegram, send the bot a PDF. The bot acknowledges, processes it via the pipeline, and sends the result back. Typical end-to-end time is 1-3 minutes (depending on what the pipeline does).\n\nOnly files with MIME type `application/pdf` are accepted.\n\n`/start` in a private chat returns a welcome message.\n\n---\n\n## Configuration reference\n\nAll options live under the top-level `telegram:` block. Environment variables use the `TELEGRAM_*` form (Spring Boot relaxed binding: dots become underscores, camelCase joins stay glued).\n\n| YAML key | Default | Purpose |\n|---|---|---|\n| `enabled` | `false` | Master toggle. |\n| `botToken` | empty | BotFather token. |\n| `botUsername` | empty | Bot username, without the `@`. |\n| `pipelineInboxFolder` | `\"telegram\"` | Subfolder name under `/pipeline/watchedFolders/`. |\n| `customFolderSuffix` | `true` | Appends the chat ID as a subdirectory so each chat has its own inbox. |\n| `enableAllowUserIDs` | `true` | Turn on user ID allowlist (for private chats). |\n| `allowUserIDs` | `[]` | Allowed user IDs. |\n| `enableAllowChannelIDs` | `true` | Turn on channel ID allowlist. |\n| `allowChannelIDs` | `[]` | Allowed channel IDs (typically negative, e.g. `-1001234567890`). |\n| `processingTimeoutSeconds` | `180` | Max wait for a pipeline result. Keep ≥ 90s. |\n| `pollingIntervalMillis` | `2000` | How often to check for results. |\n| `feedback.user.*` | all `true` | Per-message-type replies in private chats (`noValidDocument`, `errorMessage`, `errorProcessing`, `processing`). |\n| `feedback.channel.*` | all `true` | Same for channels. |\n\n---\n\n## Access control\n\n- **Private chats**: controlled by `enableAllowUserIDs` + `allowUserIDs`.\n- **Channels**: controlled by `enableAllowChannelIDs` + `allowChannelIDs`.\n- **Groups and supergroups**: **always allowed** - the allowlist does not apply. To restrict group access, either don't add the bot to groups, or use BotFather's `/setjoingroups → Disable` so it can't be invited.\n\nFor production deployments, always enable the user or channel allowlist.\n\n---\n\n## Limitations\n\n- **20 MB upload limit** (a Telegram bot API constraint, not Stirling).\n- **One JSON per chat folder** when `customFolderSuffix: true`. Create the folder by messaging the bot first, then drop the JSON in.\n- **Output is sent file-by-file** - if your pipeline emits N files, the user gets N Telegram messages, no zipping.\n\n---\n\n## Recommended deployment patterns\n\n- **Personal use**: private-chat allowlist with your own user ID. Single pipeline JSON in your chat's subfolder.\n- **Team-shared inbox**: a Telegram group, with the bot's privacy mode disabled in BotFather. One pipeline JSON for the group.\n- **Per-user pipelines**: `customFolderSuffix: true` plus a tailored pipeline JSON per chat ID.\n- **Channel posting**: add the bot as a channel admin with \"Post Messages\" permission, restrict via `allowChannelIDs`.\n\n---\n\n## Related Documentation\n\n- **[Pipeline Automation](doc:configuration/pipeline)** - Build the pipeline JSONs the bot uses\n- **[Folder Scanning](doc:configuration/folderscanning)** - The processing engine the bot relies on\n- **[API Documentation](doc:api)** - Trigger pipelines without Telegram", + "sourcePath": "docs/Configuration/Telegram Bot.md", + "editUrl": "https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/Configuration/Telegram Bot.md" + }, + "configuration/ui-customisation": { + "id": "configuration/ui-customisation", + "title": "UI Customisation", + "section": "configuration", + "markdown": "Stirling PDF allows straightforward customization of the application name and appearance to make Stirling PDF your own.\n\n## Application Name Settings\nThis setting controls the application name:\n- `appNameNavbar` - Used as the browser tab title and as the issuer name shown in authenticator apps for two-factor (TOTP) login. Despite its name it is not shown in the navigation bar (which displays the logo), so do not leave it blank if you use TOTP. Empty falls back to \"Stirling PDF\".\n\n## Show update notifications\nThese settings (in Settings.yml) control system behavior and customization capabilities:\n- `showUpdate` - Controls whether update notifications are displayed\n- `showUpdateOnlyAdmin` - When true, restricts update notifications to admin users only (requires `showUpdate: true`)\n\n## UI Customization Options\n\n### In-App Settings Management (Recommended)\n\nIf you have login enabled and are logged in as an admin, you can configure all settings directly in the application through the **Settings** menu. No need to edit `settings.yml` manually!\n\n**How to access:**\n1. Enable login: `SECURITY_ENABLELOGIN=true`\n2. Log in as an admin user\n3. Navigate to **Settings** in the application\n4. Configure all options through the UI\n5. Changes apply immediately\n\n**Available customizations:**\n- Application name and branding\n- Update notification settings\n- Language settings\n- Theme preferences\n- Logo style (classic/modern)\n\nTo replace the bundled logo with your own, see [Static File Overrides](doc:configuration/other-customisations) - you drop your logo files into `customFiles/static/