From 4047d02086644c71544966b31a5675e12832fa0d Mon Sep 17 00:00:00 2001 From: Reece Browne <74901996+reecebrowne@users.noreply.github.com> Date: Tue, 26 May 2026 14:12:05 +0100 Subject: [PATCH 01/30] chore: address restructure PR review feedback (#6423) --- .taskfiles/desktop.yml | 41 ++++++++++++------- app/core/build.gradle | 20 +++++---- frontend/.prettierignore | 6 ++- frontend/editor/scripts/setup-env.mts | 17 +++++--- frontend/eslint.config.mjs | 3 +- frontend/package.json | 2 +- frontend/{editor => }/scripts/update-minor.js | 0 7 files changed, 57 insertions(+), 32 deletions(-) rename frontend/{editor => }/scripts/update-minor.js (100%) diff --git a/.taskfiles/desktop.yml b/.taskfiles/desktop.yml index 4615c977a5..46a06abd83 100644 --- a/.taskfiles/desktop.yml +++ b/.taskfiles/desktop.yml @@ -31,52 +31,60 @@ tasks: provisioner: desc: "Build installer provisioner" platforms: [windows] + dir: editor cmds: - - node editor/scripts/build-provisioner.mjs + - node scripts/build-provisioner.mjs dev: desc: "Start Tauri desktop dev mode" deps: [prepare] ignore_error: true + dir: editor cmds: - - cd editor && npx tauri dev --no-watch + - npx tauri dev --no-watch build: desc: "Build Tauri desktop app (production)" deps: [prepare] + dir: editor cmds: - - cd editor && npx tauri build + - npx tauri build build:dev: desc: "Build Tauri desktop app (dev, no bundling)" deps: [prepare] + dir: editor cmds: - - cd editor && npx tauri build --no-bundle + - npx tauri build --no-bundle build:dev:mac: desc: "Build Tauri desktop .app bundle (macOS)" deps: [prepare] + dir: editor cmds: - - cd editor && npx tauri build --bundles app + - npx tauri build --bundles app build:dev:windows: desc: "Build Tauri desktop NSIS installer (Windows)" deps: [prepare] + dir: editor cmds: - - cd editor && npx tauri build --bundles nsis + - npx tauri build --bundles nsis build:dev:linux: desc: "Build Tauri desktop AppImage (Linux)" deps: [prepare] + dir: editor cmds: - - cd editor && npx tauri build --bundles appimage + - npx tauri build --bundles appimage clean: desc: "Clean Tauri/Cargo build artifacts" + dir: editor cmds: - task: jlink:clean - - cd editor/src-tauri && cargo clean - - rm -rf editor/dist editor/build + - cd src-tauri && cargo clean + - rm -rf dist build # ============================================================ # JLink β€” Build bundled Java runtime for Tauri @@ -106,9 +114,10 @@ tasks: jlink:runtime: desc: "Create custom JRE with jlink" deps: [jlink:jar] + dir: editor/src-tauri cmds: - - rm -rf editor/src-tauri/runtime/jre - - mkdir -p editor/src-tauri/runtime + - rm -rf runtime/jre + - mkdir -p runtime - >- jlink --add-modules {{.JLINK_MODULES}} @@ -116,14 +125,15 @@ tasks: --compress=zip-6 --no-header-files --no-man-pages - --output editor/src-tauri/runtime/jre + --output runtime/jre status: - test -d editor/src-tauri/runtime/jre jlink:clean: desc: "Remove JLink runtime and bundled JARs" + dir: editor/src-tauri cmds: - - rm -rf editor/src-tauri/libs editor/src-tauri/runtime + - rm -rf libs runtime # macOS-only. Replaces jlink:runtime's single-arch JRE with a universal # (arm64 + x86_64) one for the universal Tauri shell. Runs the x86_64 @@ -134,8 +144,9 @@ tasks: desc: "Create universal (arm64+x86_64) JRE for the macOS Tauri build" deps: [jlink:jar] platforms: [darwin] + dir: editor env: JLINK_MODULES: "{{.JLINK_MODULES}}" - OUTPUT_DIR: editor/src-tauri/runtime/jre + OUTPUT_DIR: src-tauri/runtime/jre cmds: - - editor/scripts/build-universal-mac-jre.sh + - scripts/build-universal-mac-jre.sh diff --git a/app/core/build.gradle b/app/core/build.gradle index b85b4544f3..54bdd7bd76 100644 --- a/app/core/build.gradle +++ b/app/core/build.gradle @@ -196,9 +196,13 @@ if (buildPrototypes) { } def frontendBuildTask = "frontend:build:${frontendMode}" +// Workspace root holds package.json and node_modules (shared across editor / +// future portal). Editor-specific paths (src, public, dist, tauri) live one +// level deeper under frontend/editor/. When the portal lands as an embedded +// app, add a sibling frontendPortalDir / frontendPortalDistDir alongside. def frontendDir = file('../../frontend') def frontendEditorDir = file('../../frontend/editor') -def frontendDistDir = file('../../frontend/editor/dist') +def frontendEditorDistDir = file('../../frontend/editor/dist') def resourcesStaticDir = file('src/main/resources/static') def generatedFrontendPaths = [ 'assets', @@ -263,13 +267,13 @@ tasks.register('npmBuild', Exec) { doNotTrackState("Frontend build depends on untracked npmInstall task") enabled = buildWithFrontend group = 'frontend' - description = 'Build frontend application' + description = 'Build editor frontend application' workingDir file('../..') commandLine = ['task', frontendBuildTask] inputs.dir(new File(frontendEditorDir, 'src')) inputs.dir(new File(frontendEditorDir, 'public')) inputs.file(new File(frontendDir, 'package.json')) - outputs.dir(frontendDistDir) + outputs.dir(frontendEditorDistDir) // Show live output standardOutput = System.out @@ -280,17 +284,17 @@ tasks.register('npmBuild', Exec) { environment 'VITE_API_BASE_URL', '/' doFirst { - println "Building frontend application for production (mode=${frontendMode}, VITE_API_BASE_URL=/)" + println "Building editor frontend application for production (mode=${frontendMode}, VITE_API_BASE_URL=/)" } } tasks.register('copyFrontendAssets', Copy) { enabled = buildWithFrontend group = 'frontend' - description = 'Copy frontend build to static resources' + description = 'Copy editor frontend build to static resources' dependsOn npmBuild dependsOn cleanFrontendAssets - from(frontendDistDir) { + from(frontendEditorDistDir) { // Exclude files that conflict with backend static resources exclude 'robots.txt' // Backend already has this exclude 'favicon.ico' // Backend already has this @@ -298,7 +302,7 @@ tasks.register('copyFrontendAssets', Copy) { into resourcesStaticDir duplicatesStrategy = DuplicatesStrategy.INCLUDE // Let frontend overwrite when needed doFirst { - println "Copying frontend build from ${frontendDistDir} to ${resourcesStaticDir}..." + println "Copying frontend build from ${frontendEditorDistDir} to ${resourcesStaticDir}..." println "Backend static resources will be preserved" } doLast { @@ -330,7 +334,7 @@ tasks.named('copyFrontendAssets').configure { } if (buildWithFrontend) { - println "Frontend build enabled - JAR will include React frontend (mode=${frontendMode})" + println "Editor frontend build enabled - JAR will include React frontend (mode=${frontendMode})" processResources.dependsOn copyFrontendAssets } else { println "Frontend build disabled - JAR will be backend-only with API landing page" diff --git a/frontend/.prettierignore b/frontend/.prettierignore index 6bada9283f..c8240371d1 100644 --- a/frontend/.prettierignore +++ b/frontend/.prettierignore @@ -1,8 +1,10 @@ dist/ dist-portal/ editor/dist/ -# Tauri/Cargo build output (binary assets named *.js etc. confuse Prettier) -editor/src-tauri/target/ +# Tauri/Cargo build output (binary assets named *.js etc. confuse Prettier). +# Match nested target/ dirs too β€” provisioner/ and thumbnail-handler/ each +# have their own Cargo workspace under src-tauri/. +editor/src-tauri/**/target/ editor/src-tauri/gen/ node_modules/ editor/public/vendor/ diff --git a/frontend/editor/scripts/setup-env.mts b/frontend/editor/scripts/setup-env.mts index c955ba3aee..553ba181ed 100644 --- a/frontend/editor/scripts/setup-env.mts +++ b/frontend/editor/scripts/setup-env.mts @@ -6,9 +6,18 @@ * Vite automatically layers these `.local` files on top of the committed ones. * * Usage: - * tsx scripts/setup-env.ts # ensures .env.local - * tsx scripts/setup-env.ts --desktop # also ensures .env.desktop.local - * tsx scripts/setup-env.ts --saas # also ensures .env.saas.local + * tsx scripts/setup-env.mts # ensures .env.local + * tsx scripts/setup-env.mts --desktop # also ensures .env.desktop.local + * tsx scripts/setup-env.mts --saas # also ensures .env.saas.local + * + * Why .mts (and not .ts)? + * This script needs `import.meta.url` to resolve paths relative to itself, + * because Task invokes it from the workspace root (frontend/) but the .env + * files live one level deeper at frontend/editor/. `import.meta` is only + * valid in ESM output; `editor/scripts/tsconfig.json` extends the editor + * tsconfig which uses `module: node16`, treating plain .ts as CommonJS + * (TS1470 error on `import.meta`). The .mts extension explicitly marks + * the file as ESM, which tsx already runs at runtime anyway. */ import { existsSync, writeFileSync } from "fs"; @@ -17,8 +26,6 @@ import { fileURLToPath } from "url"; // .env files live next to the editor's vite.config.ts (frontend/editor/). // Resolve relative to this script regardless of where the build was invoked. -// `import.meta.dirname` would be tidier but isn't available under tsx's CJS -// transpilation today, so go via fileURLToPath for portability. const scriptDir = dirname(fileURLToPath(import.meta.url)); const root = resolve(scriptDir, ".."); const args = process.argv.slice(2); diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs index 6b42ad5855..8641e41201 100644 --- a/frontend/eslint.config.mjs +++ b/frontend/eslint.config.mjs @@ -7,7 +7,8 @@ import tseslint from "typescript-eslint"; const srcGlobs = ["editor/src/**/*.{js,mjs,jsx,ts,tsx}"]; const nodeGlobs = [ - "editor/scripts/**/*.{js,ts,mjs}", + "scripts/**/*.{js,ts,mjs,mts}", + "editor/scripts/**/*.{js,ts,mjs,mts}", "editor/*.config.{js,ts,mjs}", "*.config.{js,ts,mjs}", ]; diff --git a/frontend/package.json b/frontend/package.json index 3567411f2f..a23ee45d33 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -82,7 +82,7 @@ "web-vitals": "^5.1.0" }, "scripts": { - "update:minor": "node editor/scripts/update-minor.js", + "update:minor": "node scripts/update-minor.js", "update:major": "npx npm-check-updates -u && npm install", "update:interactive": "npx npm-check-updates -i", "update:minor-strict": "npx npm-check-updates -u --target minor && npm install" diff --git a/frontend/editor/scripts/update-minor.js b/frontend/scripts/update-minor.js similarity index 100% rename from frontend/editor/scripts/update-minor.js rename to frontend/scripts/update-minor.js From 05b80fbe4fa55ba723d7725fc4bb3d5ccbc4608a Mon Sep 17 00:00:00 2001 From: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com> Date: Tue, 26 May 2026 15:40:44 +0100 Subject: [PATCH 02/30] Working local Saas (#6450) Env file for setting backend saas and taskfile for running it --- .env.saas.example | 34 ++++++++++++++++++++++++++++++++++ .gitignore | 1 + .taskfiles/backend.yml | 19 +++++++++++++++++++ 3 files changed, 54 insertions(+) create mode 100644 .env.saas.example diff --git a/.env.saas.example b/.env.saas.example new file mode 100644 index 0000000000..d9d787fe07 --- /dev/null +++ b/.env.saas.example @@ -0,0 +1,34 @@ +############################################################################### +# Stirling-PDF SaaS local environment template. +# +# Copy this file to `.env.saas.local` (gitignored) and fill in real values. +# Loaded by `task backend:dev:saas` via Taskfile's `dotenv:` directive, then +# read by Spring Boot's `${...}` placeholders in application-saas.properties +# and application-dev.properties. +# +# DO NOT commit `.env.saas.local`. Only `.env.saas.example` is checked in. +############################################################################### + +# ---------- Supabase project ---------- +# Project reference (the subdomain part of .supabase.co). Required. +# Example dev project: +SAAS_DB_PROJECT_REF= + +# Edge function secret used by billing/license rollup calls. +SUPABASE_EDGE_FUNCTION_SECRET= + +# ---------- Database (saas profile) ---------- +# Direct JDBC URL to the Supabase Postgres. Required when running the plain +# `saas` profile (i.e. without `--spring.profiles.include=dev`). +# Example: jdbc:postgresql://db..supabase.co:5432/postgres +SAAS_DB_URL= +SAAS_DB_USERNAME=postgres +SAAS_DB_PASSWORD= + +# ---------- Database (dev profile overrides) ---------- +# Used when `--spring.profiles.include=dev` is active. The dev profile +# defaults the URL/username to the shared dev Supabase project, but the +# password must still be provided here. +SAAS_DEV_DB_URL= +SAAS_DEV_DB_USERNAME=postgres +SAAS_DEV_DB_PASSWORD= diff --git a/.gitignore b/.gitignore index 261b2a40ae..5c3bd57cc9 100644 --- a/.gitignore +++ b/.gitignore @@ -174,6 +174,7 @@ venv.bak/ # Env files (secrets / local overrides). Subproject .gitignore files whitelist any committed defaults. .env* +!.env.saas.example # VS Code /.vscode/**/* diff --git a/.taskfiles/backend.yml b/.taskfiles/backend.yml index 56db8eb4ac..ccf3a77753 100644 --- a/.taskfiles/backend.yml +++ b/.taskfiles/backend.yml @@ -39,6 +39,25 @@ tasks: - cmd: ./gradlew clean bootRun -PbuildWithFrontend=true platforms: [linux, darwin] + dev:saas: + desc: "Start backend in SaaS flavor against Supabase (loads .env.saas.local)" + # `dotenv:` reads from the root Taskfile's directory (".") because this + # subtaskfile is included with `dir: .`. Drop the file at the repo root. + dotenv: ['.env.saas.local'] + ignore_error: true + vars: + PORT: '{{.PORT | default "8080"}}' + # Override to "" to run the pure `saas` profile against your own SAAS_DB_*. + PROFILES: '{{.PROFILES | default "dev"}}' + env: + SERVER_PORT: '{{.PORT}}' + STIRLING_FLAVOR: saas + cmds: + - cmd: cmd /c ".\gradlew.bat :stirling-pdf:bootRun {{if .PROFILES}}--args=\"--spring.profiles.include={{.PROFILES}}\"{{end}}" + platforms: [windows] + - cmd: ./gradlew :stirling-pdf:bootRun {{if .PROFILES}}--args='--spring.profiles.include={{.PROFILES}}'{{end}} + platforms: [linux, darwin] + build: desc: "Full backend build" cmds: From 56ff1e50920a4339e9a8550555099602866394a6 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Tue, 26 May 2026 17:50:13 +0100 Subject: [PATCH 03/30] impl migration to pdfium for split (#6410) ## Summary Migrates `SplitPDFController`, `SplitPdfByChaptersController`, `SplitPdfBySizeController` from PDFBox to JPDFium. `SplitPdfBySectionsController` and `AutoSplitPdfController` are intentionally left on PDFBox (require JPDFium 1.0.2 features that don't exist yet). ## Benchmark (audited on `audit/jpdfium-split`, file `app/core/src/test/java/stirling/software/SPDF/bench/SplitBenchmark.java`) | Workload | PDFBox heap | JPDFium heap | PDFBox wall | JPDFium wall | |---|---|---|---|---| | 100 pp, chunk 10 | +21-26 MB | **+0.02 MB** | 80-106 ms | **25 ms** | | 300 pp, chunk 10 | +59 MB | **+1.0 MB** | 232 ms | **76 ms** | **98-99.9% heap reduction. 3-4.2x faster wall.** ## Hybrid - AcroForm-bearing splits keep PDFBox `FormUtils.pruneOrphanedFormFields` post-pass (FPDF_ImportPagesByIndex drops AcroForm dict). Sub-bench shows +1.0 MB / +27 ms - tightly bounded. - Metadata extraction stays on PDFBox. - `SplitPdfBySectionsController` - JPDFium `PdfPageSplitter` only does 2-up halving, not arbitrary MxN. - `AutoSplitPdfController` - needs PDFRenderer + zxing for QR markers. ## Test plan - [ ] 30/30 unit tests pass with PDFBox `Loader.loadPDF` as the oracle (assert page counts + document totals) - [ ] Existing cucumber feature `split.feature` continues to pass - [ ] AcroForm-bearing PDF round-trips without orphaned widgets (covered by existing FormUtils tests) --- .dockerignore | 4 +- .github/labeler-config-srvaroa.yml | 4 +- .github/workflows/tauri-build.yml | 4 + .gitignore | 2 +- app/common/build.gradle | 4 +- .../controller/api/SplitPDFController.java | 59 ++-- .../api/SplitPdfByChaptersController.java | 324 +++++++++++------- .../api/SplitPdfBySizeController.java | 270 ++++++++------- .../api/SplitPDFControllerTest.java | 180 ++++++++-- .../api/SplitPdfByChaptersControllerTest.java | 93 +++-- .../api/SplitPdfBySizeControllerTest.java | 198 +++++++++-- .../editor/scripts/build-universal-mac-jre.sh | 4 +- .../src-tauri/thumbnail-handler/README.md | 2 +- .../scripts/sign-jpdfium-dylibs-in-bootjar.sh | 4 +- testing/test.sh | 5 +- 15 files changed, 762 insertions(+), 395 deletions(-) diff --git a/.dockerignore b/.dockerignore index 4ecd420aa0..85355537d7 100644 --- a/.dockerignore +++ b/.dockerignore @@ -34,8 +34,8 @@ frontend/playwright-report/ # Tauri/desktop builds src-tauri/target/ src-tauri/dist/ -frontend/src-tauri/target/ -frontend/src-tauri/dist/ +frontend/editor/src-tauri/target/ +frontend/editor/src-tauri/dist/ # IDE and editor .idea/ diff --git a/.github/labeler-config-srvaroa.yml b/.github/labeler-config-srvaroa.yml index 6fe125c376..9eb38bde3d 100644 --- a/.github/labeler-config-srvaroa.yml +++ b/.github/labeler-config-srvaroa.yml @@ -69,8 +69,8 @@ labels: - label: 'Tauri' files: - - 'frontend/src-tauri/**' - - 'frontend/src-tauri/.*' + - 'frontend/editor/src-tauri/**' + - 'frontend/editor/src-tauri/.*' - label: 'engine' files: diff --git a/.github/workflows/tauri-build.yml b/.github/workflows/tauri-build.yml index 8828d00d1c..ee0b28c88c 100644 --- a/.github/workflows/tauri-build.yml +++ b/.github/workflows/tauri-build.yml @@ -4,6 +4,10 @@ name: Build Tauri Applications # Linux). Called from build.yml on PRs that touch desktop sources (gated # via the `tauri` filter in .github/config/.files.yaml). Also runnable # on demand via workflow_dispatch with a per-platform selector. +# +# Note: editing this file is itself enough to make the `tauri` path filter +# match, which is how non-desktop PRs (e.g. backend-only fixes) opt into a +# desktop smoke build. on: workflow_call: inputs: diff --git a/.gitignore b/.gitignore index 5c3bd57cc9..40be551559 100644 --- a/.gitignore +++ b/.gitignore @@ -157,7 +157,7 @@ app/proprietary/build common/build proprietary/build stirling-pdf/build -frontend/src-tauri/provisioner/target +frontend/editor/src-tauri/provisioner/target # Byte-compiled / optimized / DLL files __pycache__/ diff --git a/app/common/build.gradle b/app/common/build.gradle index 95f555759e..508a978255 100644 --- a/app/common/build.gradle +++ b/app/common/build.gradle @@ -60,7 +60,7 @@ dependencies { exclude group: 'com.google.code.gson', module: 'gson' } - api 'com.stirling:jpdfium:1.0.0' + api 'com.stirling:jpdfium:1.0.1' // -PjpdfiumPlatforms=all| def jpdfiumPlatformsProp = (project.findProperty('jpdfiumPlatforms') ?: 'all').toString().trim() @@ -75,7 +75,7 @@ dependencies { } logger.lifecycle("JPDFium native platforms: ${jpdfiumPlatforms.join(', ')}") jpdfiumPlatforms.each { platform -> - runtimeOnly "com.stirling:jpdfium-natives-${platform}:1.0.0" + runtimeOnly "com.stirling:jpdfium-natives-${platform}:1.0.1" } // ArchUnit: enforces module dependency direction (see ArchitectureTest) diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/SplitPDFController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/SplitPDFController.java index 0d37cea558..d4516c0ed6 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/SplitPDFController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/SplitPDFController.java @@ -3,6 +3,7 @@ package stirling.software.SPDF.controller.api; import java.io.File; import java.io.IOException; import java.nio.file.Files; +import java.nio.file.Path; import java.nio.file.StandardCopyOption; import java.util.ArrayList; import java.util.HashSet; @@ -30,12 +31,13 @@ import stirling.software.common.annotations.AutoJobPostMapping; import stirling.software.common.annotations.api.GeneralApi; import stirling.software.common.enumeration.ResourceWeight; import stirling.software.common.service.CustomPDFDocumentFactory; -import stirling.software.common.util.ExceptionUtils; import stirling.software.common.util.FormUtils; import stirling.software.common.util.GeneralUtils; import stirling.software.common.util.TempFile; import stirling.software.common.util.TempFileManager; import stirling.software.common.util.WebResponseUtils; +import stirling.software.jpdfium.PdfDocument; +import stirling.software.jpdfium.PdfSplit; @GeneralApi @Slf4j @@ -91,10 +93,14 @@ public class SplitPDFController { try (ZipOutputStream zipOut = new ZipOutputStream(Files.newOutputStream(outputTempFile.getPath()))) { if (hasForm) { - writeSplitsViaReload( + // JPDFium's FPDF_ImportPagesByIndex drops the AcroForm dictionary, which + // breaks Fill Forms downstream. Fall back to the PDFBox load-and-remove + // path so the AcroForm (with only fields whose widgets remain on kept + // pages) is preserved. + writeSplitsViaPdfBox( sourceTempFile.getFile(), pageNumbers, baseFilename, zipOut); } else { - writeSplitsViaSharedSource( + writeSplitsViaJpdfium( sourceTempFile.getFile(), pageNumbers, baseFilename, zipOut); } } @@ -109,7 +115,26 @@ public class SplitPDFController { } } - private void writeSplitsViaReload( + private void writeSplitsViaJpdfium( + File source, List pageNumbers, String baseFilename, ZipOutputStream zipOut) + throws IOException { + try (PdfDocument sourceDoc = PdfDocument.open(source.toPath())) { + int previousPageNumber = 0; + for (int splitIndex = 0; splitIndex < pageNumbers.size(); splitIndex++) { + int splitPoint = pageNumbers.get(splitIndex); + try (TempFile splitTemp = new TempFile(tempFileManager, ".pdf")) { + try (PdfDocument splitDoc = + PdfSplit.extractPageRange(sourceDoc, previousPageNumber, splitPoint)) { + splitDoc.save(splitTemp.getPath()); + } + writeEntry(zipOut, baseFilename, splitIndex + 1, splitTemp.getPath()); + } + previousPageNumber = splitPoint + 1; + } + } + } + + private void writeSplitsViaPdfBox( File source, List pageNumbers, String baseFilename, ZipOutputStream zipOut) throws IOException { int previousPageNumber = 0; @@ -129,33 +154,15 @@ public class SplitPDFController { } FormUtils.pruneOrphanedFormFields(splitDoc); writeEntry(zipOut, baseFilename, splitIndex + 1, splitDoc); - } catch (Exception e) { - ExceptionUtils.logException("document splitting and saving", e); - throw e; } } } - private void writeSplitsViaSharedSource( - File source, List pageNumbers, String baseFilename, ZipOutputStream zipOut) + private void writeEntry(ZipOutputStream zipOut, String baseFilename, int index, Path pdfPath) throws IOException { - try (PDDocument sourceDoc = pdfDocumentFactory.load(source)) { - int previousPageNumber = 0; - for (int splitIndex = 0; splitIndex < pageNumbers.size(); splitIndex++) { - int splitPoint = pageNumbers.get(splitIndex); - try (PDDocument splitDoc = - pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDoc)) { - for (int i = previousPageNumber; i <= splitPoint; i++) { - splitDoc.addPage(sourceDoc.getPage(i)); - } - previousPageNumber = splitPoint + 1; - writeEntry(zipOut, baseFilename, splitIndex + 1, splitDoc); - } catch (Exception e) { - ExceptionUtils.logException("document splitting and saving", e); - throw e; - } - } - } + zipOut.putNextEntry(new ZipEntry(baseFilename + "_" + index + ".pdf")); + Files.copy(pdfPath, zipOut); + zipOut.closeEntry(); } private void writeEntry(ZipOutputStream zipOut, String baseFilename, int index, PDDocument doc) diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/SplitPdfByChaptersController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/SplitPdfByChaptersController.java index 27d5330820..6ad85d0c92 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/SplitPdfByChaptersController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/SplitPdfByChaptersController.java @@ -1,6 +1,10 @@ package stirling.software.SPDF.controller.api; +import java.io.File; +import java.io.IOException; import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; import java.util.ArrayList; import java.util.List; import java.util.Locale; @@ -8,9 +12,7 @@ import java.util.zip.ZipEntry; import java.util.zip.ZipOutputStream; import org.apache.pdfbox.pdmodel.PDDocument; -import org.apache.pdfbox.pdmodel.PDPage; -import org.apache.pdfbox.pdmodel.interactive.documentnavigation.outline.PDDocumentOutline; -import org.apache.pdfbox.pdmodel.interactive.documentnavigation.outline.PDOutlineItem; +import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm; import org.springframework.core.io.Resource; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; @@ -35,10 +37,13 @@ import stirling.software.common.model.PdfMetadata; import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.service.PdfMetadataService; import stirling.software.common.util.ExceptionUtils; +import stirling.software.common.util.FormUtils; import stirling.software.common.util.GeneralUtils; import stirling.software.common.util.TempFile; import stirling.software.common.util.TempFileManager; import stirling.software.common.util.WebResponseUtils; +import stirling.software.jpdfium.PdfDocument; +import stirling.software.jpdfium.PdfSplit; @GeneralApi @Slf4j @@ -51,72 +56,36 @@ public class SplitPdfByChaptersController { private final TempFileManager tempFileManager; - private static List extractOutlineItems( - PDDocument sourceDocument, - PDOutlineItem current, - List bookmarks, - PDOutlineItem nextParent, + private static void collectBookmarks( + List source, + List out, int level, - int maxLevel) - throws Exception { - - while (current != null) { - - String currentTitle = current.getTitle().replace("/", ""); - int firstPage = - sourceDocument.getPages().indexOf(current.findDestinationPage(sourceDocument)); - PDOutlineItem child = current.getFirstChild(); - PDOutlineItem nextSibling = current.getNextSibling(); - int endPage; - if (child != null && level < maxLevel) { - endPage = - sourceDocument - .getPages() - .indexOf(child.findDestinationPage(sourceDocument)); - } else if (nextSibling != null) { - endPage = - sourceDocument - .getPages() - .indexOf(nextSibling.findDestinationPage(sourceDocument)); - } else if (nextParent != null) { - - endPage = - sourceDocument - .getPages() - .indexOf(nextParent.findDestinationPage(sourceDocument)); - } else { - endPage = -2; - /* - happens when we have something like this: - Outline Item 2 - Outline Item 2.1 - Outline Item 2.1.1 - Outline Item 2.2 - Outline 2.2.1 - Outline 2.2.2 <--- this item neither has an immediate next parent nor an immediate next sibling - Outline Item 3 - */ + int maxLevel) { + for (stirling.software.jpdfium.doc.Bookmark bm : source) { + if (!bm.isInternal()) { + continue; } - if (!bookmarks.isEmpty() - && bookmarks.get(bookmarks.size() - 1).getEndPage() == -2 - && firstPage - >= bookmarks - .get(bookmarks.size() - 1) - .getStartPage()) { // for handling the above-mentioned case - Bookmark previousBookmark = bookmarks.get(bookmarks.size() - 1); - previousBookmark.setEndPage(firstPage); + String title = bm.title() == null ? "" : bm.title().replace("/", ""); + int firstPage = Math.max(0, bm.pageIndex()); + out.add(new Bookmark(title, firstPage, -2)); + if (bm.hasChildren() && level < maxLevel) { + collectBookmarks(bm.children(), out, level + 1, maxLevel); } - bookmarks.add(new Bookmark(currentTitle, firstPage, endPage)); - - // Recursively process children - if (child != null && level < maxLevel) { - extractOutlineItems( - sourceDocument, child, bookmarks, nextSibling, level + 1, maxLevel); - } - - current = nextSibling; } - return bookmarks; + } + + private static void assignEndPages(List bookmarks, int totalPages) { + for (int i = 0; i < bookmarks.size(); i++) { + Bookmark current = bookmarks.get(i); + int next = -1; + for (int j = i + 1; j < bookmarks.size(); j++) { + if (bookmarks.get(j).getStartPage() >= current.getStartPage()) { + next = bookmarks.get(j).getStartPage(); + break; + } + } + current.setEndPage(next == -1 ? totalPages : next); + } } @AutoJobPostMapping( @@ -134,46 +103,41 @@ public class SplitPdfByChaptersController { MultipartFile file = request.getFileInput(); boolean includeMetadata = Boolean.TRUE.equals(request.getIncludeMetadata()); - Integer bookmarkLevel = - request.getBookmarkLevel(); // levels start from 0 (top most bookmarks) + Integer bookmarkLevel = request.getBookmarkLevel(); if (bookmarkLevel < 0) { throw ExceptionUtils.createIllegalArgumentException( "error.invalidArgument", "Invalid argument: {0}", "bookmark level"); } - try (PDDocument sourceDocument = pdfDocumentFactory.load(file)) { - PDDocumentOutline outline = sourceDocument.getDocumentCatalog().getDocumentOutline(); + try (TempFile sourceTempFile = new TempFile(tempFileManager, ".pdf")) { + Files.copy( + file.getInputStream(), + sourceTempFile.getPath(), + StandardCopyOption.REPLACE_EXISTING); - if (outline == null) { - log.warn("No outline found for {}", file.getOriginalFilename()); - throw ExceptionUtils.createIllegalArgumentException( - "error.pdfBookmarksNotFound", "No PDF bookmarks/outline found in document"); - } List bookmarks = new ArrayList<>(); - try { - bookmarks = - extractOutlineItems( - sourceDocument, - outline.getFirstChild(), - bookmarks, - outline.getFirstChild().getNextSibling(), - 0, - bookmarkLevel); - // to handle last page edge case - bookmarks.get(bookmarks.size() - 1).setEndPage(sourceDocument.getNumberOfPages()); - - } catch (Exception e) { - ExceptionUtils.logException("outline extraction", e); - throw e; + int totalPages; + try (PdfDocument sourceDocument = PdfDocument.open(sourceTempFile.getPath())) { + totalPages = sourceDocument.pageCount(); + List roots = sourceDocument.bookmarks(); + if (roots == null || roots.isEmpty()) { + log.warn("No outline found for {}", file.getOriginalFilename()); + throw ExceptionUtils.createIllegalArgumentException( + "error.pdfBookmarksNotFound", + "No PDF bookmarks/outline found in document"); + } + collectBookmarks(roots, bookmarks, 0, bookmarkLevel); + if (bookmarks.isEmpty()) { + log.warn("No outline found for {}", file.getOriginalFilename()); + throw ExceptionUtils.createIllegalArgumentException( + "error.pdfBookmarksNotFound", + "No PDF bookmarks/outline found in document"); + } + assignEndPages(bookmarks, totalPages); } boolean allowDuplicates = Boolean.TRUE.equals(request.getAllowDuplicates()); if (!allowDuplicates) { - /* - duplicates are generated when multiple bookmarks correspond to the same page, - if the user doesn't want duplicates mergeBookmarksThatCorrespondToSamePage() method will merge the titles of all - the bookmarks that correspond to the same page, and treat them as a single bookmark - */ bookmarks = mergeBookmarksThatCorrespondToSamePage(bookmarks); } for (Bookmark bookmark : bookmarks) { @@ -184,7 +148,23 @@ public class SplitPdfByChaptersController { bookmark.getEndPage()); } - TempFile zipTempFile = createZipFile(sourceDocument, bookmarks, includeMetadata); + PdfMetadata metadata = null; + boolean hasForm = false; + if (includeMetadata) { + try (PDDocument metaDoc = pdfDocumentFactory.load(sourceTempFile.getFile())) { + metadata = pdfMetadataService.extractMetadataFromPdf(metaDoc); + PDAcroForm acroForm = metaDoc.getDocumentCatalog().getAcroForm(null); + hasForm = acroForm != null; + } + } else { + try (PDDocument acroDoc = pdfDocumentFactory.load(sourceTempFile.getFile(), true)) { + hasForm = acroDoc.getDocumentCatalog().getAcroForm(null) != null; + } + } + + TempFile zipTempFile = + createZipFile( + sourceTempFile.getFile(), bookmarks, metadata, totalPages, hasForm); String filename = GeneralUtils.generateFilename(file.getOriginalFilename(), ""); return WebResponseUtils.zipFileToWebResponse(zipTempFile, filename + ".zip"); } @@ -216,43 +196,40 @@ public class SplitPdfByChaptersController { } private TempFile createZipFile( - PDDocument sourceDocument, List bookmarks, boolean includeMetadata) + File sourceFile, + List bookmarks, + PdfMetadata metadata, + int totalPages, + boolean hasForm) throws Exception { - PdfMetadata metadata = - includeMetadata ? pdfMetadataService.extractMetadataFromPdf(sourceDocument) : null; String fileNumberFormatter = "%0" + (Integer.toString(bookmarks.size()).length()) + "d "; TempFile zipTempFile = new TempFile(tempFileManager, ".zip"); - try { - try (ZipOutputStream zipOut = - new ZipOutputStream(Files.newOutputStream(zipTempFile.getPath()))) { + try (ZipOutputStream zipOut = + new ZipOutputStream(Files.newOutputStream(zipTempFile.getPath()))) { + if (hasForm) { + // JPDFium's FPDF_ImportPagesByIndex drops the AcroForm dictionary. For form + // PDFs, do the per-chapter extract via PDFBox so form fields survive the split. for (int i = 0; i < bookmarks.size(); i++) { - Bookmark bookmark = bookmarks.get(i); - try (PDDocument splitDocument = new PDDocument()) { - boolean isSinglePage = (bookmark.getStartPage() == bookmark.getEndPage()); - - for (int pg = bookmark.getStartPage(); - pg < bookmark.getEndPage() + (isSinglePage ? 1 : 0); - pg++) { - PDPage page = sourceDocument.getPage(pg); - splitDocument.addPage(page); - log.debug("Adding page {} to split document", pg); - } - if (includeMetadata) { - pdfMetadataService.setMetadataToPdf(splitDocument, metadata); - } - - // split files will be named as "[FILE_NUMBER] [BOOKMARK_TITLE].pdf" - String fileName = - String.format(Locale.ROOT, fileNumberFormatter, i) - + bookmark.getTitle() - + ".pdf"; - zipOut.putNextEntry(new ZipEntry(fileName)); - splitDocument.save(zipOut); - zipOut.closeEntry(); - log.debug("Wrote split document {} to zip file", fileName); - } catch (Exception e) { - ExceptionUtils.logException("document splitting and saving", e); - throw e; + writeChapterViaPdfBox( + sourceFile, + bookmarks.get(i), + i, + fileNumberFormatter, + metadata, + zipOut, + totalPages); + } + } else { + try (PdfDocument sourceDocument = PdfDocument.open(sourceFile.toPath())) { + for (int i = 0; i < bookmarks.size(); i++) { + writeChapterViaJpdfium( + sourceDocument, + bookmarks.get(i), + i, + fileNumberFormatter, + metadata, + zipOut, + totalPages); } } } @@ -265,6 +242,97 @@ public class SplitPdfByChaptersController { throw e; } } + + private void writeChapterViaJpdfium( + PdfDocument sourceDocument, + Bookmark bookmark, + int index, + String fileNumberFormatter, + PdfMetadata metadata, + ZipOutputStream zipOut, + int totalPages) + throws Exception { + int[] range = clampRange(bookmark, totalPages); + int from = range[0]; + int to = range[1]; + try (TempFile splitTemp = new TempFile(tempFileManager, ".pdf")) { + try (PdfDocument splitDoc = PdfSplit.extractPageRange(sourceDocument, from, to)) { + splitDoc.save(splitTemp.getPath()); + } + Path finalPath = splitTemp.getPath(); + TempFile metaTemp = null; + try { + if (metadata != null) { + metaTemp = new TempFile(tempFileManager, ".pdf"); + try (PDDocument doc = pdfDocumentFactory.load(splitTemp.getFile())) { + pdfMetadataService.setMetadataToPdf(doc, metadata); + doc.save(metaTemp.getFile()); + } + finalPath = metaTemp.getPath(); + } + writeZipEntry(zipOut, fileNumberFormatter, index, bookmark.getTitle(), finalPath); + } finally { + if (metaTemp != null) { + metaTemp.close(); + } + } + } + } + + private void writeChapterViaPdfBox( + File sourceFile, + Bookmark bookmark, + int index, + String fileNumberFormatter, + PdfMetadata metadata, + ZipOutputStream zipOut, + int totalPages) + throws Exception { + int[] range = clampRange(bookmark, totalPages); + int from = range[0]; + int to = range[1]; + try (PDDocument doc = pdfDocumentFactory.load(sourceFile)) { + for (int p = doc.getNumberOfPages() - 1; p >= 0; p--) { + if (p < from || p > to) { + doc.removePage(p); + } + } + FormUtils.pruneOrphanedFormFields(doc); + if (metadata != null) { + pdfMetadataService.setMetadataToPdf(doc, metadata); + } + String fileName = + String.format(Locale.ROOT, fileNumberFormatter, index) + + bookmark.getTitle() + + ".pdf"; + zipOut.putNextEntry(new ZipEntry(fileName)); + doc.save(zipOut); + zipOut.closeEntry(); + log.debug("Wrote split document {} to zip file", fileName); + } + } + + private void writeZipEntry( + ZipOutputStream zipOut, + String fileNumberFormatter, + int index, + String title, + Path pdfPath) + throws IOException { + String fileName = String.format(Locale.ROOT, fileNumberFormatter, index) + title + ".pdf"; + zipOut.putNextEntry(new ZipEntry(fileName)); + Files.copy(pdfPath, zipOut); + zipOut.closeEntry(); + log.debug("Wrote split document {} to zip file", fileName); + } + + private static int[] clampRange(Bookmark bookmark, int totalPages) { + boolean isSinglePage = bookmark.getStartPage() == bookmark.getEndPage(); + int from = Math.min(Math.max(0, bookmark.getStartPage()), totalPages - 1); + int rawEnd = isSinglePage ? bookmark.getEndPage() : bookmark.getEndPage() - 1; + int to = Math.min(Math.max(from, rawEnd), totalPages - 1); + return new int[] {from, to}; + } } @Data diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/SplitPdfBySizeController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/SplitPdfBySizeController.java index ead2f12e14..e8ff5d9e23 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/SplitPdfBySizeController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/SplitPdfBySizeController.java @@ -1,9 +1,9 @@ package stirling.software.SPDF.controller.api; -import java.io.ByteArrayOutputStream; import java.io.File; import java.io.IOException; import java.nio.file.Files; +import java.nio.file.Path; import java.nio.file.StandardCopyOption; import java.util.ArrayList; import java.util.HashSet; @@ -13,7 +13,6 @@ import java.util.zip.ZipEntry; import java.util.zip.ZipOutputStream; import org.apache.pdfbox.pdmodel.PDDocument; -import org.apache.pdfbox.pdmodel.PDPage; import org.springframework.core.io.Resource; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; @@ -37,6 +36,8 @@ import stirling.software.common.util.GeneralUtils; import stirling.software.common.util.TempFile; import stirling.software.common.util.TempFileManager; import stirling.software.common.util.WebResponseUtils; +import stirling.software.jpdfium.PdfDocument; +import stirling.software.jpdfium.PdfSplit; @GeneralApi @Slf4j @@ -75,23 +76,27 @@ public class SplitPdfBySizeController { sourceTempFile.getPath(), StandardCopyOption.REPLACE_EXISTING); - try (PDDocument sourceDocument = - pdfDocumentFactory.load(sourceTempFile.getFile(), true)) { - boolean hasForm = sourceDocument.getDocumentCatalog().getAcroForm(null) != null; - List> ranges = computeRanges(request, sourceDocument); + boolean hasForm; + try (PDDocument acroDoc = pdfDocumentFactory.load(sourceTempFile.getFile(), true)) { + hasForm = acroDoc.getDocumentCatalog().getAcroForm(null) != null; + } + + try (PdfDocument sourceDocument = PdfDocument.open(sourceTempFile.getPath())) { + List ranges = computeRanges(request, sourceDocument); int fileIndex = 1; - for (List range : ranges) { - if (range.isEmpty()) { + for (int[] range : ranges) { + if (range.length == 0) { continue; } - if (hasForm) { - writeRangeViaReload( - sourceTempFile.getFile(), range, zipOut, filename, fileIndex++); - } else { - writeRangeViaSharedSource( - sourceDocument, range, zipOut, filename, fileIndex++); - } + writeRange( + sourceDocument, + sourceTempFile.getFile(), + range, + zipOut, + filename, + fileIndex++, + hasForm); } } } @@ -104,29 +109,54 @@ public class SplitPdfBySizeController { } } - private List> computeRanges( - SplitPdfBySizeOrCountRequest request, PDDocument sourceDocument) throws IOException { + private List computeRanges(SplitPdfBySizeOrCountRequest request, PdfDocument sourceDoc) + throws IOException { int type = request.getSplitType(); String value = request.getSplitValue(); if (type == 0) { - return computeSizeRanges(sourceDocument, GeneralUtils.convertSizeToBytes(value)); + return computeSizeRanges(sourceDoc, GeneralUtils.convertSizeToBytes(value)); } else if (type == 1) { - return computePageCountRanges(sourceDocument, Integer.parseInt(value)); + return computePageCountRanges(sourceDoc, Integer.parseInt(value)); } else if (type == 2) { - return computeDocCountRanges(sourceDocument, Integer.parseInt(value)); + return computeDocCountRanges(sourceDoc, Integer.parseInt(value)); } throw ExceptionUtils.createIllegalArgumentException( "error.invalidArgument", "Invalid argument: {0}", "split type: " + type); } - private void writeRangeViaReload( + private void writeRange( + PdfDocument sourceDoc, File sourceFile, - List keepIndices, + int[] range, + ZipOutputStream zipOut, + String baseFilename, + int fileIndex, + boolean hasForm) + throws IOException { + if (hasForm) { + // JPDFium's FPDF_ImportPagesByIndex drops the AcroForm dictionary, breaking form + // fields downstream. For form-bearing PDFs, do the extract via PDFBox so the + // AcroForm survives (pruneOrphanedFormFields removes references to dropped pages). + writeRangeViaPdfBox(sourceFile, range, zipOut, baseFilename, fileIndex); + } else { + try (TempFile splitTemp = new TempFile(tempFileManager, ".pdf")) { + extractRangeToFile(sourceDoc, range, splitTemp.getPath()); + writeEntry(zipOut, baseFilename, fileIndex, splitTemp.getPath()); + } + } + } + + private void writeRangeViaPdfBox( + File sourceFile, + int[] range, ZipOutputStream zipOut, String baseFilename, int fileIndex) throws IOException { - Set keep = new HashSet<>(keepIndices); + Set keep = new HashSet<>(); + for (int p : range) { + keep.add(p); + } try (PDDocument doc = pdfDocumentFactory.load(sourceFile)) { for (int i = doc.getNumberOfPages() - 1; i >= 0; i--) { if (!keep.contains(i)) { @@ -134,50 +164,41 @@ public class SplitPdfBySizeController { } } FormUtils.pruneOrphanedFormFields(doc); - writeEntry(zipOut, baseFilename, fileIndex, doc); + zipOut.putNextEntry(new ZipEntry(baseFilename + "_" + fileIndex + ".pdf")); + doc.save(zipOut); + zipOut.closeEntry(); } } - private void writeRangeViaSharedSource( - PDDocument sourceDocument, - List keepIndices, - ZipOutputStream zipOut, - String baseFilename, - int fileIndex) + private void extractRangeToFile(PdfDocument sourceDoc, int[] range, Path outputPath) throws IOException { - try (PDDocument doc = - pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDocument)) { - for (int p : keepIndices) { - doc.addPage(sourceDocument.getPage(p)); - } - writeEntry(zipOut, baseFilename, fileIndex, doc); + int from = range[0]; + int to = range[range.length - 1]; + try (PdfDocument split = PdfSplit.extractPageRange(sourceDoc, from, to)) { + split.save(outputPath); } } private void writeEntry( - ZipOutputStream zipOut, String baseFilename, int fileIndex, PDDocument doc) + ZipOutputStream zipOut, String baseFilename, int fileIndex, Path pdfPath) throws IOException { zipOut.putNextEntry(new ZipEntry(baseFilename + "_" + fileIndex + ".pdf")); - doc.save(zipOut); + Files.copy(pdfPath, zipOut); zipOut.closeEntry(); } - /** Page-index ranges each output should contain. AcroForm overhead isn't modeled. */ - private List> computeSizeRanges(PDDocument sourceDocument, long maxBytes) - throws IOException { - List> ranges = new ArrayList<>(); - List currentRange = new ArrayList<>(); - int totalPages = sourceDocument.getNumberOfPages(); + /** Returns contiguous page-index ranges fitting within {@code maxBytes}. */ + private List computeSizeRanges(PdfDocument sourceDoc, long maxBytes) throws IOException { + List ranges = new ArrayList<>(); + int totalPages = sourceDoc.pageCount(); int baseCheckFrequency = 5; - - PDDocument scratch = new PDDocument(); - try { + int rangeStart = 0; + int rangeEnd = -1; + try (TempFile probe = new TempFile(tempFileManager, ".pdf")) { + File probeFile = probe.getFile(); for (int pageIndex = 0; pageIndex < totalPages; pageIndex++) { - PDPage page = sourceDocument.getPage(pageIndex); - scratch.addPage(new PDPage(page.getCOSObject())); - currentRange.add(pageIndex); - - int pageAdded = currentRange.size(); + rangeEnd = pageIndex; + int pageAdded = rangeEnd - rangeStart + 1; boolean shouldCheckSize = (pageAdded % baseCheckFrequency == 0) || (pageIndex == totalPages - 1) @@ -185,117 +206,110 @@ public class SplitPdfBySizeController { if (!shouldCheckSize) { continue; } - - long actualSize; - try (ByteArrayOutputStream out = new ByteArrayOutputStream()) { - scratch.save(out); - actualSize = out.size(); - } + long actualSize = saveRange(sourceDoc, rangeStart, rangeEnd, probeFile); if (actualSize > maxBytes) { - if (scratch.getNumberOfPages() > 1) { - scratch.removePage(scratch.getNumberOfPages() - 1); - currentRange.remove(currentRange.size() - 1); - pageIndex--; // retry this page in the next chunk + if (pageAdded > 1) { + rangeEnd = pageIndex - 1; + pageIndex--; } - ranges.add(new ArrayList<>(currentRange)); - currentRange.clear(); - scratch.close(); - scratch = new PDDocument(); + ranges.add(buildRange(rangeStart, rangeEnd)); + rangeStart = rangeEnd + 1; + rangeEnd = rangeStart - 1; } else if (pageIndex < totalPages - 1 && actualSize < maxBytes * 0.75) { - int extraPagesAdded = - lookAheadFit(scratch, sourceDocument, pageIndex, maxBytes); - for (int i = 0; i < extraPagesAdded; i++) { - int extra = pageIndex + 1 + i; - scratch.addPage(new PDPage(sourceDocument.getPage(extra).getCOSObject())); - currentRange.add(extra); - } - pageIndex += extraPagesAdded; + int extra = + lookAheadFit( + sourceDoc, + rangeStart, + pageIndex, + maxBytes, + totalPages, + probeFile); + pageIndex += extra; + rangeEnd = pageIndex; } } - - if (!currentRange.isEmpty()) { - ranges.add(new ArrayList<>(currentRange)); - } - } finally { - scratch.close(); + } + if (rangeEnd >= rangeStart) { + ranges.add(buildRange(rangeStart, rangeEnd)); } return ranges; } - /** Speculatively tries up to 5 next pages; returns how many fit under {@code maxBytes}. */ - private int lookAheadFit(PDDocument scratch, PDDocument source, int pageIndex, long maxBytes) + private long saveRange(PdfDocument sourceDoc, int from, int to, File output) throws IOException { - int totalPages = source.getNumberOfPages(); - int pagesToLookAhead = Math.min(5, totalPages - pageIndex - 1); - if (pagesToLookAhead == 0) { - return 0; + try (PdfDocument split = PdfSplit.extractPageRange(sourceDoc, from, to)) { + split.save(output.toPath()); } - - int extraPagesAdded = 0; - try (PDDocument testDoc = new PDDocument()) { - for (int i = 0; i < scratch.getNumberOfPages(); i++) { - testDoc.addPage(new PDPage(scratch.getPage(i).getCOSObject())); - } - for (int i = 0; i < pagesToLookAhead; i++) { - testDoc.addPage(new PDPage(source.getPage(pageIndex + 1 + i).getCOSObject())); - long testSize; - try (ByteArrayOutputStream out = new ByteArrayOutputStream()) { - testDoc.save(out); - testSize = out.size(); - } - if (testSize > maxBytes) { - break; - } - extraPagesAdded++; - } - } - return extraPagesAdded; + return output.length(); } - private List> computePageCountRanges(PDDocument sourceDocument, int pageCount) { + private int lookAheadFit( + PdfDocument sourceDoc, + int rangeStart, + int currentEnd, + long maxBytes, + int totalPages, + File probeFile) + throws IOException { + int pagesToLookAhead = Math.min(5, totalPages - currentEnd - 1); + int extra = 0; + for (int i = 0; i < pagesToLookAhead; i++) { + int trialEnd = currentEnd + 1 + i; + long size = saveRange(sourceDoc, rangeStart, trialEnd, probeFile); + if (size > maxBytes) { + break; + } + extra++; + } + return extra; + } + + private List computePageCountRanges(PdfDocument sourceDoc, int pageCount) { if (pageCount <= 0) { throw ExceptionUtils.createIllegalArgumentException( "error.invalidArgument", "Invalid argument: {0}", "page count: " + pageCount); } - int totalPages = sourceDocument.getNumberOfPages(); - List> ranges = new ArrayList<>(); - List current = new ArrayList<>(pageCount); - for (int i = 0; i < totalPages; i++) { - current.add(i); - if (current.size() == pageCount) { - ranges.add(current); - current = new ArrayList<>(pageCount); - } - } - if (!current.isEmpty()) { - ranges.add(current); + int totalPages = sourceDoc.pageCount(); + List ranges = new ArrayList<>(); + int start = 0; + while (start < totalPages) { + int end = Math.min(start + pageCount - 1, totalPages - 1); + ranges.add(buildRange(start, end)); + start = end + 1; } return ranges; } - private List> computeDocCountRanges( - PDDocument sourceDocument, int documentCount) { + private List computeDocCountRanges(PdfDocument sourceDoc, int documentCount) { if (documentCount <= 0) { throw ExceptionUtils.createIllegalArgumentException( "error.invalidArgument", "Invalid argument: {0}", "document count: " + documentCount); } - int totalPages = sourceDocument.getNumberOfPages(); + int totalPages = sourceDoc.pageCount(); int pagesPerDocument = totalPages / documentCount; int extraPages = totalPages % documentCount; - - List> ranges = new ArrayList<>(); + List ranges = new ArrayList<>(); int cursor = 0; for (int i = 0; i < documentCount; i++) { int pagesToAdd = pagesPerDocument + (i < extraPages ? 1 : 0); - List range = new ArrayList<>(pagesToAdd); - for (int j = 0; j < pagesToAdd; j++) { - range.add(cursor++); + if (pagesToAdd == 0) { + continue; } - ranges.add(range); + int end = cursor + pagesToAdd - 1; + ranges.add(buildRange(cursor, end)); + cursor = end + 1; } return ranges; } + + private static int[] buildRange(int start, int end) { + int[] range = new int[end - start + 1]; + for (int i = 0; i < range.length; i++) { + range[i] = start + i; + } + return range; + } } diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/SplitPDFControllerTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/SplitPDFControllerTest.java index 49a8d8bfc4..1cceefadca 100644 --- a/app/core/src/test/java/stirling/software/SPDF/controller/api/SplitPDFControllerTest.java +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/SplitPDFControllerTest.java @@ -4,17 +4,28 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.when; +import java.io.ByteArrayInputStream; import java.io.File; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; import org.apache.pdfbox.Loader; import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.pdmodel.PDPage; import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotation; +import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget; +import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm; +import org.apache.pdfbox.pdmodel.interactive.form.PDField; +import org.apache.pdfbox.pdmodel.interactive.form.PDTextField; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -23,8 +34,12 @@ import org.junit.jupiter.api.io.TempDir; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.core.io.Resource; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; import org.springframework.mock.web.MockMultipartFile; import stirling.software.SPDF.model.api.SplitPagesRequest; @@ -32,6 +47,7 @@ import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.util.TempFileManager; @ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) class SplitPDFControllerTest { @TempDir Path tempDir; @@ -47,6 +63,12 @@ class SplitPDFControllerTest { String suffix = invocation.getArgument(0); return Files.createTempFile(tempDir, "test", suffix).toFile(); }); + lenient() + .when(pdfDocumentFactory.load(any(File.class), eq(true))) + .thenAnswer(inv -> Loader.loadPDF((File) inv.getArgument(0))); + lenient() + .when(pdfDocumentFactory.load(any(File.class))) + .thenAnswer(inv -> Loader.loadPDF((File) inv.getArgument(0))); } private byte[] createPdf(int numPages) throws IOException { @@ -60,17 +82,79 @@ class SplitPDFControllerTest { } } - private void setupFactory() throws IOException { - when(pdfDocumentFactory.load(any(File.class), eq(true))) - .thenAnswer(inv -> Loader.loadPDF((File) inv.getArgument(0))); - when(pdfDocumentFactory.load(any(File.class))) - .thenAnswer(inv -> Loader.loadPDF((File) inv.getArgument(0))); - when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(any(PDDocument.class))) - .thenAnswer(inv -> new PDDocument()); + private byte[] createPdfWithForm(int numPages) throws IOException { + try (PDDocument doc = new PDDocument()) { + PDAcroForm acroForm = new PDAcroForm(doc); + doc.getDocumentCatalog().setAcroForm(acroForm); + for (int i = 0; i < numPages; i++) { + PDPage page = new PDPage(PDRectangle.A4); + doc.addPage(page); + PDTextField field = new PDTextField(acroForm); + field.setPartialName("text_p" + (i + 1)); + PDAnnotationWidget widget = new PDAnnotationWidget(); + widget.setRectangle(new PDRectangle(100, 700, 200, 20)); + widget.setPage(page); + field.setWidgets(java.util.List.of(widget)); + page.getAnnotations().add(widget); + acroForm.getFields().add(field); + } + Path pdfPath = tempDir.resolve("input.pdf"); + doc.save(pdfPath.toFile()); + return Files.readAllBytes(pdfPath); + } + } + + private List fieldNamesOf(byte[] pdfBytes) throws IOException { + List names = new ArrayList<>(); + try (PDDocument doc = Loader.loadPDF(pdfBytes)) { + PDAcroForm acroForm = doc.getDocumentCatalog().getAcroForm(null); + if (acroForm == null) { + return names; + } + for (PDField field : acroForm.getFields()) { + names.add(field.getFullyQualifiedName()); + } + } + return names; + } + + private int widgetCountOnPage(byte[] pdfBytes, int pageIndex) throws IOException { + try (PDDocument doc = Loader.loadPDF(pdfBytes)) { + int count = 0; + for (PDAnnotation a : doc.getPage(pageIndex).getAnnotations()) { + if (a instanceof PDAnnotationWidget) { + count++; + } + } + return count; + } + } + + private List unzip(Resource zipResource) throws IOException { + List entries = new ArrayList<>(); + try (ZipInputStream zis = + new ZipInputStream(new ByteArrayInputStream(zipResource.getContentAsByteArray()))) { + ZipEntry entry; + while ((entry = zis.getNextEntry()) != null) { + entries.add(zis.readAllBytes()); + zis.closeEntry(); + } + } + return entries; + } + + private int[] pageCountsOf(List entries) throws IOException { + int[] counts = new int[entries.size()]; + for (int i = 0; i < entries.size(); i++) { + try (PDDocument doc = Loader.loadPDF(entries.get(i))) { + counts[i] = doc.getNumberOfPages(); + } + } + return counts; } @Test - @DisplayName("Should split 6-page PDF at page 3") + @DisplayName("Should split 6-page PDF at page 3 into 2 parts") void shouldSplitAtPage3() throws Exception { byte[] pdfBytes = createPdf(6); MockMultipartFile file = @@ -81,11 +165,12 @@ class SplitPDFControllerTest { request.setFileInput(file); request.setPageNumbers("3"); - setupFactory(); - - var response = controller.splitPdf(request); + ResponseEntity response = controller.splitPdf(request); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + List outputs = unzip(response.getBody()); + assertThat(outputs).hasSize(2); + assertThat(pageCountsOf(outputs)).containsExactly(3, 3); } @Test @@ -100,11 +185,12 @@ class SplitPDFControllerTest { request.setFileInput(file); request.setPageNumbers("1,2,3"); - setupFactory(); - - var response = controller.splitPdf(request); + ResponseEntity response = controller.splitPdf(request); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + List outputs = unzip(response.getBody()); + assertThat(outputs).hasSize(3); + assertThat(pageCountsOf(outputs)).containsExactly(1, 1, 1); } @Test @@ -119,15 +205,16 @@ class SplitPDFControllerTest { request.setFileInput(file); request.setPageNumbers("1"); - setupFactory(); - - var response = controller.splitPdf(request); + ResponseEntity response = controller.splitPdf(request); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + List outputs = unzip(response.getBody()); + assertThat(outputs).hasSize(1); + assertThat(pageCountsOf(outputs)).containsExactly(1); } @Test - @DisplayName("Should split with range notation") + @DisplayName("Should split with multiple split points") void shouldSplitWithRange() throws Exception { byte[] pdfBytes = createPdf(10); MockMultipartFile file = @@ -138,11 +225,12 @@ class SplitPDFControllerTest { request.setFileInput(file); request.setPageNumbers("3,7"); - setupFactory(); - - var response = controller.splitPdf(request); + ResponseEntity response = controller.splitPdf(request); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + List outputs = unzip(response.getBody()); + assertThat(outputs).hasSize(3); + assertThat(pageCountsOf(outputs)).containsExactly(3, 4, 3); } @Test @@ -157,13 +245,14 @@ class SplitPDFControllerTest { request.setFileInput(file); request.setPageNumbers("2"); - setupFactory(); - - var response = controller.splitPdf(request); + ResponseEntity response = controller.splitPdf(request); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(response.getHeaders().getContentType()) .isEqualTo(MediaType.APPLICATION_OCTET_STREAM); + List outputs = unzip(response.getBody()); + assertThat(outputs).hasSize(2); + assertThat(pageCountsOf(outputs)).containsExactly(2, 2); } @Test @@ -178,11 +267,12 @@ class SplitPDFControllerTest { request.setFileInput(file); request.setPageNumbers("5"); - setupFactory(); - - var response = controller.splitPdf(request); + ResponseEntity response = controller.splitPdf(request); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + List outputs = unzip(response.getBody()); + assertThat(outputs).hasSize(1); + assertThat(pageCountsOf(outputs)).containsExactly(5); } @Test @@ -197,29 +287,43 @@ class SplitPDFControllerTest { request.setFileInput(file); request.setPageNumbers("all"); - setupFactory(); - - var response = controller.splitPdf(request); + ResponseEntity response = controller.splitPdf(request); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + List outputs = unzip(response.getBody()); + assertThat(outputs).hasSize(3); + assertThat(pageCountsOf(outputs)).containsExactly(1, 1, 1); } @Test - @DisplayName("Should handle file without extension in original name") - void shouldHandleFileWithoutExtension() throws Exception { - byte[] pdfBytes = createPdf(2); + @DisplayName("Should preserve AcroForm and per-page widgets when splitting form PDF") + void shouldSplitFormPdf() throws Exception { + byte[] pdfBytes = createPdfWithForm(4); MockMultipartFile file = new MockMultipartFile( - "fileInput", "no_extension", MediaType.APPLICATION_PDF_VALUE, pdfBytes); + "fileInput", "input.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes); SplitPagesRequest request = new SplitPagesRequest(); request.setFileInput(file); - request.setPageNumbers("1"); + request.setPageNumbers("2"); - setupFactory(); - - var response = controller.splitPdf(request); + ResponseEntity response = controller.splitPdf(request); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + List outputs = unzip(response.getBody()); + assertThat(outputs).hasSize(2); + assertThat(pageCountsOf(outputs)).containsExactly(2, 2); + + assertThat(fieldNamesOf(outputs.get(0))) + .as("first split keeps fields whose widgets are on pages 1-2") + .containsExactlyInAnyOrder("text_p1", "text_p2"); + assertThat(fieldNamesOf(outputs.get(1))) + .as("second split keeps fields whose widgets are on pages 3-4") + .containsExactlyInAnyOrder("text_p3", "text_p4"); + + assertThat(widgetCountOnPage(outputs.get(0), 0)).isEqualTo(1); + assertThat(widgetCountOnPage(outputs.get(0), 1)).isEqualTo(1); + assertThat(widgetCountOnPage(outputs.get(1), 0)).isEqualTo(1); + assertThat(widgetCountOnPage(outputs.get(1), 1)).isEqualTo(1); } } diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/SplitPdfByChaptersControllerTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/SplitPdfByChaptersControllerTest.java index 39e711b314..c9c9b618f4 100644 --- a/app/core/src/test/java/stirling/software/SPDF/controller/api/SplitPdfByChaptersControllerTest.java +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/SplitPdfByChaptersControllerTest.java @@ -4,11 +4,19 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.when; +import java.io.ByteArrayInputStream; +import java.io.File; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; import org.apache.pdfbox.Loader; import org.apache.pdfbox.pdmodel.PDDocument; @@ -27,10 +35,11 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoSettings; import org.mockito.quality.Strictness; +import org.springframework.core.io.Resource; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; import org.springframework.mock.web.MockMultipartFile; -import org.springframework.web.multipart.MultipartFile; import stirling.software.SPDF.model.api.SplitPdfByChaptersRequest; import stirling.software.common.service.CustomPDFDocumentFactory; @@ -55,6 +64,12 @@ class SplitPdfByChaptersControllerTest { String suffix = inv.getArgument(0); return Files.createTempFile(tempDir, "test", suffix).toFile(); }); + lenient() + .when(pdfDocumentFactory.load(any(File.class))) + .thenAnswer(inv -> Loader.loadPDF((File) inv.getArgument(0))); + lenient() + .when(pdfDocumentFactory.load(any(File.class), eq(true))) + .thenAnswer(inv -> Loader.loadPDF((File) inv.getArgument(0))); } private byte[] createPdfWithBookmarks(int numPages, String... chapterNames) throws IOException { @@ -83,6 +98,29 @@ class SplitPdfByChaptersControllerTest { } } + private List unzip(Resource zipResource) throws IOException { + List entries = new ArrayList<>(); + try (ZipInputStream zis = + new ZipInputStream(new ByteArrayInputStream(zipResource.getContentAsByteArray()))) { + ZipEntry entry; + while ((entry = zis.getNextEntry()) != null) { + entries.add(zis.readAllBytes()); + zis.closeEntry(); + } + } + return entries; + } + + private int totalPagesOf(List entries) throws IOException { + int total = 0; + for (byte[] data : entries) { + try (PDDocument doc = Loader.loadPDF(data)) { + total += doc.getNumberOfPages(); + } + } + return total; + } + @Test @DisplayName("Should split PDF by chapters") void shouldSplitByChapters() throws Exception { @@ -97,12 +135,12 @@ class SplitPdfByChaptersControllerTest { request.setIncludeMetadata(false); request.setAllowDuplicates(false); - when(pdfDocumentFactory.load(any(MultipartFile.class))) - .thenAnswer(inv -> Loader.loadPDF(((MultipartFile) inv.getArgument(0)).getBytes())); - - var response = controller.splitPdf(request); + ResponseEntity response = controller.splitPdf(request); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + List outputs = unzip(response.getBody()); + assertThat(outputs).hasSize(3); + assertThat(totalPagesOf(outputs)).isEqualTo(6); } @Test @@ -119,12 +157,12 @@ class SplitPdfByChaptersControllerTest { request.setIncludeMetadata(false); request.setAllowDuplicates(true); - when(pdfDocumentFactory.load(any(MultipartFile.class))) - .thenAnswer(inv -> Loader.loadPDF(((MultipartFile) inv.getArgument(0)).getBytes())); - - var response = controller.splitPdf(request); + ResponseEntity response = controller.splitPdf(request); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + List outputs = unzip(response.getBody()); + assertThat(outputs).hasSize(2); + assertThat(totalPagesOf(outputs)).isEqualTo(4); } @Test @@ -163,10 +201,6 @@ class SplitPdfByChaptersControllerTest { request.setIncludeMetadata(false); request.setAllowDuplicates(false); - when(pdfDocumentFactory.load(any(MultipartFile.class))) - .thenAnswer( - inv -> Loader.loadPDF(((MultipartFile) inv.getArgument(0)).getBytes())); - assertThrows(IllegalArgumentException.class, () -> controller.splitPdf(request)); } } @@ -185,12 +219,12 @@ class SplitPdfByChaptersControllerTest { request.setIncludeMetadata(false); request.setAllowDuplicates(false); - when(pdfDocumentFactory.load(any(MultipartFile.class))) - .thenAnswer(inv -> Loader.loadPDF(((MultipartFile) inv.getArgument(0)).getBytes())); - - var response = controller.splitPdf(request); + ResponseEntity response = controller.splitPdf(request); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + List outputs = unzip(response.getBody()); + assertThat(outputs).hasSize(1); + assertThat(totalPagesOf(outputs)).isEqualTo(3); } @Test @@ -207,14 +241,15 @@ class SplitPdfByChaptersControllerTest { request.setIncludeMetadata(true); request.setAllowDuplicates(false); - when(pdfDocumentFactory.load(any(MultipartFile.class))) - .thenAnswer(inv -> Loader.loadPDF(((MultipartFile) inv.getArgument(0)).getBytes())); - when(pdfMetadataService.extractMetadataFromPdf(any(PDDocument.class))) + lenient() + .when(pdfMetadataService.extractMetadataFromPdf(any(PDDocument.class))) .thenReturn(new stirling.software.common.model.PdfMetadata()); - var response = controller.splitPdf(request); + ResponseEntity response = controller.splitPdf(request); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + List outputs = unzip(response.getBody()); + assertThat(totalPagesOf(outputs)).isEqualTo(4); } @Test @@ -231,12 +266,12 @@ class SplitPdfByChaptersControllerTest { request.setIncludeMetadata(false); request.setAllowDuplicates(false); - when(pdfDocumentFactory.load(any(MultipartFile.class))) - .thenAnswer(inv -> Loader.loadPDF(((MultipartFile) inv.getArgument(0)).getBytes())); - - var response = controller.splitPdf(request); + ResponseEntity response = controller.splitPdf(request); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + List outputs = unzip(response.getBody()); + assertThat(outputs).hasSize(3); + assertThat(totalPagesOf(outputs)).isEqualTo(6); } @Test @@ -253,11 +288,11 @@ class SplitPdfByChaptersControllerTest { request.setIncludeMetadata(false); request.setAllowDuplicates(true); - when(pdfDocumentFactory.load(any(MultipartFile.class))) - .thenAnswer(inv -> Loader.loadPDF(((MultipartFile) inv.getArgument(0)).getBytes())); - - var response = controller.splitPdf(request); + ResponseEntity response = controller.splitPdf(request); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + List outputs = unzip(response.getBody()); + assertThat(outputs).hasSize(5); + assertThat(totalPagesOf(outputs)).isEqualTo(10); } } diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/SplitPdfBySizeControllerTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/SplitPdfBySizeControllerTest.java index 1e47652185..51523eced2 100644 --- a/app/core/src/test/java/stirling/software/SPDF/controller/api/SplitPdfBySizeControllerTest.java +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/SplitPdfBySizeControllerTest.java @@ -4,17 +4,27 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.when; +import java.io.ByteArrayInputStream; import java.io.File; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; import org.apache.pdfbox.Loader; import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.pdmodel.PDPage; import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget; +import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm; +import org.apache.pdfbox.pdmodel.interactive.form.PDField; +import org.apache.pdfbox.pdmodel.interactive.form.PDTextField; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -23,6 +33,9 @@ import org.junit.jupiter.api.io.TempDir; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.core.io.Resource; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; @@ -33,6 +46,7 @@ import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.util.TempFileManager; @ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) class SplitPdfBySizeControllerTest { @TempDir Path tempDir; @@ -48,69 +62,189 @@ class SplitPdfBySizeControllerTest { String suffix = invocation.getArgument(0); return Files.createTempFile(tempDir, "test", suffix).toFile(); }); + lenient() + .when(pdfDocumentFactory.load(any(File.class), eq(true))) + .thenAnswer(inv -> Loader.loadPDF((File) inv.getArgument(0))); + lenient() + .when(pdfDocumentFactory.load(any(File.class))) + .thenAnswer(inv -> Loader.loadPDF((File) inv.getArgument(0))); } - @Test - @DisplayName("Should split by page count successfully") - void shouldSplitByPageCount() throws Exception { - byte[] pdfBytes; + private byte[] createPdf(int numPages) throws IOException { try (PDDocument doc = new PDDocument()) { - for (int i = 0; i < 5; i++) { + for (int i = 0; i < numPages; i++) { doc.addPage(new PDPage(PDRectangle.A4)); } Path pdfPath = tempDir.resolve("input.pdf"); doc.save(pdfPath.toFile()); - pdfBytes = Files.readAllBytes(pdfPath); + return Files.readAllBytes(pdfPath); } + } + private List unzip(Resource zipResource) throws IOException { + List entries = new ArrayList<>(); + try (ZipInputStream zis = + new ZipInputStream(new ByteArrayInputStream(zipResource.getContentAsByteArray()))) { + ZipEntry entry; + while ((entry = zis.getNextEntry()) != null) { + entries.add(zis.readAllBytes()); + zis.closeEntry(); + } + } + return entries; + } + + private int[] pageCountsOf(List entries) throws IOException { + int[] counts = new int[entries.size()]; + for (int i = 0; i < entries.size(); i++) { + try (PDDocument doc = Loader.loadPDF(entries.get(i))) { + counts[i] = doc.getNumberOfPages(); + } + } + return counts; + } + + @Test + @DisplayName("Should split by page count into 2-page chunks") + void shouldSplitByPageCount() throws Exception { + byte[] pdfBytes = createPdf(5); MockMultipartFile file = new MockMultipartFile( "fileInput", "input.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes); SplitPdfBySizeOrCountRequest request = new SplitPdfBySizeOrCountRequest(); request.setFileInput(file); - request.setSplitType(1); // Page count + request.setSplitType(1); request.setSplitValue("2"); - when(pdfDocumentFactory.load(any(File.class), eq(true))) - .thenAnswer(inv -> Loader.loadPDF((File) inv.getArgument(0))); - when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(any(PDDocument.class))) - .thenAnswer(inv -> new PDDocument()); - - ResponseEntity response = controller.autoSplitPdf(request); + ResponseEntity response = controller.autoSplitPdf(request); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(response.getHeaders().getContentType()) .isEqualTo(MediaType.APPLICATION_OCTET_STREAM); + List outputs = unzip(response.getBody()); + assertThat(outputs).hasSize(3); + assertThat(pageCountsOf(outputs)).containsExactly(2, 2, 1); } @Test - @DisplayName("Should split by document count successfully") + @DisplayName("Should split by document count into 3 even documents") void shouldSplitByDocCount() throws Exception { - byte[] pdfBytes; - try (PDDocument doc = new PDDocument()) { - for (int i = 0; i < 6; i++) { - doc.addPage(new PDPage(PDRectangle.A4)); - } - Path pdfPath = tempDir.resolve("input.pdf"); - doc.save(pdfPath.toFile()); - pdfBytes = Files.readAllBytes(pdfPath); - } - + byte[] pdfBytes = createPdf(6); MockMultipartFile file = new MockMultipartFile( "fileInput", "input.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes); SplitPdfBySizeOrCountRequest request = new SplitPdfBySizeOrCountRequest(); request.setFileInput(file); - request.setSplitType(2); // Document count - request.setSplitValue("3"); // Split into 3 docs (2 pages each) + request.setSplitType(2); + request.setSplitValue("3"); - when(pdfDocumentFactory.load(any(File.class), eq(true))) - .thenAnswer(inv -> Loader.loadPDF((File) inv.getArgument(0))); - when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(any(PDDocument.class))) - .thenAnswer(inv -> new PDDocument()); - - ResponseEntity response = controller.autoSplitPdf(request); + ResponseEntity response = controller.autoSplitPdf(request); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + List outputs = unzip(response.getBody()); + assertThat(outputs).hasSize(3); + assertThat(pageCountsOf(outputs)).containsExactly(2, 2, 2); + } + + @Test + @DisplayName("Should split by document count distributing extras") + void shouldSplitByDocCountWithRemainder() throws Exception { + byte[] pdfBytes = createPdf(7); + MockMultipartFile file = + new MockMultipartFile( + "fileInput", "input.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes); + SplitPdfBySizeOrCountRequest request = new SplitPdfBySizeOrCountRequest(); + request.setFileInput(file); + request.setSplitType(2); + request.setSplitValue("3"); + + ResponseEntity response = controller.autoSplitPdf(request); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + List outputs = unzip(response.getBody()); + assertThat(outputs).hasSize(3); + assertThat(pageCountsOf(outputs)).containsExactly(3, 2, 2); + } + + private byte[] createPdfWithForm(int numPages) throws IOException { + try (PDDocument doc = new PDDocument()) { + PDAcroForm acroForm = new PDAcroForm(doc); + doc.getDocumentCatalog().setAcroForm(acroForm); + for (int i = 0; i < numPages; i++) { + PDPage page = new PDPage(PDRectangle.A4); + doc.addPage(page); + PDTextField field = new PDTextField(acroForm); + field.setPartialName("text_p" + (i + 1)); + PDAnnotationWidget widget = new PDAnnotationWidget(); + widget.setRectangle(new PDRectangle(100, 700, 200, 20)); + widget.setPage(page); + field.setWidgets(java.util.List.of(widget)); + page.getAnnotations().add(widget); + acroForm.getFields().add(field); + } + Path pdfPath = tempDir.resolve("input.pdf"); + doc.save(pdfPath.toFile()); + return Files.readAllBytes(pdfPath); + } + } + + private List fieldNamesOf(byte[] pdfBytes) throws IOException { + List names = new ArrayList<>(); + try (PDDocument doc = Loader.loadPDF(pdfBytes)) { + PDAcroForm acroForm = doc.getDocumentCatalog().getAcroForm(null); + if (acroForm == null) { + return names; + } + for (PDField field : acroForm.getFields()) { + names.add(field.getFullyQualifiedName()); + } + } + return names; + } + + @Test + @DisplayName("Should preserve AcroForm when splitting form PDF by page count") + void shouldPreserveFormFieldsWhenSplitting() throws Exception { + byte[] pdfBytes = createPdfWithForm(4); + MockMultipartFile file = + new MockMultipartFile( + "fileInput", "input.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes); + SplitPdfBySizeOrCountRequest request = new SplitPdfBySizeOrCountRequest(); + request.setFileInput(file); + request.setSplitType(1); + request.setSplitValue("2"); + + ResponseEntity response = controller.autoSplitPdf(request); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + List outputs = unzip(response.getBody()); + assertThat(outputs).hasSize(2); + assertThat(pageCountsOf(outputs)).containsExactly(2, 2); + assertThat(fieldNamesOf(outputs.get(0))).containsExactlyInAnyOrder("text_p1", "text_p2"); + assertThat(fieldNamesOf(outputs.get(1))).containsExactlyInAnyOrder("text_p3", "text_p4"); + } + + @Test + @DisplayName("Should split by size into multiple files") + void shouldSplitBySize() throws Exception { + byte[] pdfBytes = createPdf(20); + MockMultipartFile file = + new MockMultipartFile( + "fileInput", "input.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes); + SplitPdfBySizeOrCountRequest request = new SplitPdfBySizeOrCountRequest(); + request.setFileInput(file); + request.setSplitType(0); + request.setSplitValue("3KB"); + + ResponseEntity response = controller.autoSplitPdf(request); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + List outputs = unzip(response.getBody()); + assertThat(outputs).isNotEmpty(); + int total = 0; + for (int count : pageCountsOf(outputs)) { + total += count; + } + assertThat(total).isEqualTo(20); } } diff --git a/frontend/editor/scripts/build-universal-mac-jre.sh b/frontend/editor/scripts/build-universal-mac-jre.sh index a8fa6ce4d2..baf66016bb 100755 --- a/frontend/editor/scripts/build-universal-mac-jre.sh +++ b/frontend/editor/scripts/build-universal-mac-jre.sh @@ -11,7 +11,7 @@ # X64_JAVA_HOME path to an x86_64 JDK with jmods/ # JLINK_MODULES comma-separated module list (matches desktop.yml) # OUTPUT_DIR target directory (will be wiped); defaults to -# frontend/src-tauri/runtime/jre +# frontend/editor/src-tauri/runtime/jre set -euo pipefail @@ -19,7 +19,7 @@ set -euo pipefail : "${X64_JAVA_HOME:?X64_JAVA_HOME must be set}" : "${JLINK_MODULES:?JLINK_MODULES must be set}" -OUTPUT_DIR="${OUTPUT_DIR:-frontend/src-tauri/runtime/jre}" +OUTPUT_DIR="${OUTPUT_DIR:-frontend/editor/src-tauri/runtime/jre}" if [[ "$(uname -s)" != "Darwin" ]]; then echo "build-universal-mac-jre.sh only runs on macOS" >&2 diff --git a/frontend/editor/src-tauri/thumbnail-handler/README.md b/frontend/editor/src-tauri/thumbnail-handler/README.md index 3deffaab96..27c1ffdfc7 100644 --- a/frontend/editor/src-tauri/thumbnail-handler/README.md +++ b/frontend/editor/src-tauri/thumbnail-handler/README.md @@ -51,7 +51,7 @@ npm run tauri-build To build the DLL standalone: ```bash -cd frontend/src-tauri/thumbnail-handler +cd frontend/editor/src-tauri/thumbnail-handler cargo build --release # Output: target/release/stirling_thumbnail_handler.dll ``` diff --git a/frontend/scripts/sign-jpdfium-dylibs-in-bootjar.sh b/frontend/scripts/sign-jpdfium-dylibs-in-bootjar.sh index f65d02f836..bed635a479 100644 --- a/frontend/scripts/sign-jpdfium-dylibs-in-bootjar.sh +++ b/frontend/scripts/sign-jpdfium-dylibs-in-bootjar.sh @@ -31,14 +31,14 @@ if [ -n "${1:-}" ]; then BOOTJARS+=("$1") else for cand in app/core/build/libs/stirling-pdf-*.jar \ - frontend/src-tauri/libs/stirling-pdf-*.jar; do + frontend/editor/src-tauri/libs/stirling-pdf-*.jar; do [ -f "$cand" ] || continue BOOTJARS+=("$cand") done fi if [ "${#BOOTJARS[@]:-0}" = 0 ]; then echo "bootJar not found (expected app/core/build/libs/stirling-pdf-*.jar" \ - "or frontend/src-tauri/libs/stirling-pdf-*.jar)" + "or frontend/editor/src-tauri/libs/stirling-pdf-*.jar)" exit 0 fi diff --git a/testing/test.sh b/testing/test.sh index a9146c06ce..1ab2717044 100644 --- a/testing/test.sh +++ b/testing/test.sh @@ -442,9 +442,10 @@ compare_file_lists() { echo "New files created during test:" cat "${diff_file}.added" | sed 's/^> //' - # Exclude JPDFium native cache (deleteOnExit-registered, not a leak). + # Exclude JPDFium native cache + merge seed temp files + # (both deleteOnExit-registered, not leaks). grep -i "tmp\|temp" "${diff_file}.added" \ - | grep -v '/jpdfium-[0-9]\+/' \ + | grep -v '/jpdfium-' \ > "${diff_file}.tmp" || true if [ -s "${diff_file}.tmp" ]; then echo "WARNING: Temporary files detected:" From 730ed43af92b79f21116b0562b56ffc63097170c Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Tue, 26 May 2026 20:05:25 +0100 Subject: [PATCH 04/30] Fix login loop (#6402) --- .../editor/src-tauri/src/commands/auth.rs | 119 +++++++++++------- frontend/editor/src-tauri/src/lib.rs | 2 +- .../src-tauri/tests/refresh_token_fallback.rs | 94 ++++++++++++++ 3 files changed, 172 insertions(+), 43 deletions(-) create mode 100644 frontend/src-tauri/tests/refresh_token_fallback.rs diff --git a/frontend/editor/src-tauri/src/commands/auth.rs b/frontend/editor/src-tauri/src/commands/auth.rs index 528f6d8de5..28d2ddd376 100644 --- a/frontend/editor/src-tauri/src/commands/auth.rs +++ b/frontend/editor/src-tauri/src/commands/auth.rs @@ -1,7 +1,7 @@ use keyring::{Entry}; use serde::{Deserialize, Serialize}; use std::sync::{Arc, Mutex}; -use tauri::AppHandle; +use tauri::{AppHandle, Runtime}; use tauri_plugin_store::StoreExt; use tiny_http::{Response, Server}; use sha2::{Sha256, Digest}; @@ -18,6 +18,9 @@ const KEYRING_SERVICE: &str = "stirling-pdf"; const KEYRING_TOKEN_KEY: &str = "auth-token"; const KEYRING_REFRESH_TOKEN_KEY: &str = "refresh-token"; +pub const TOKENS_STORE_FILE_FOR_TESTS: &str = TOKENS_STORE_FILE; +pub const REFRESH_TOKEN_STORE_KEY_FOR_TESTS: &str = REFRESH_TOKEN_STORE_KEY; + #[derive(Debug, Serialize, Deserialize, Clone)] pub struct UserInfo { pub username: String, @@ -25,6 +28,9 @@ pub struct UserInfo { } fn get_keyring_entry() -> Result { + if std::env::var("STIRLING_PDF_TEST_FORCE_AUTH_KEYRING_FAIL").is_ok() { + return Err("Forced keyring failure for tests".to_string()); + } log::debug!("Creating keyring entry with service='{}' username='{}'", KEYRING_SERVICE, KEYRING_TOKEN_KEY); let entry = Entry::new(KEYRING_SERVICE, KEYRING_TOKEN_KEY) .map_err(|e| { @@ -35,11 +41,28 @@ fn get_keyring_entry() -> Result { Ok(entry) } -fn get_refresh_token_keyring_entry() -> Result { +pub fn get_refresh_token_keyring_entry() -> Result { + if std::env::var("STIRLING_PDF_TEST_FORCE_REFRESH_KEYRING_FAIL").is_ok() { + return Err("Forced keyring failure for tests".to_string()); + } Entry::new(KEYRING_SERVICE, KEYRING_REFRESH_TOKEN_KEY) .map_err(|e| format!("Failed to access keyring: {}", e)) } +/// Returns Ok(true) when keyring round-tripped the token; Ok(false) otherwise (caller must fall through to disk). +pub fn try_save_refresh_token_to_keyring(token: &str) -> Result { + match get_refresh_token_keyring_entry() { + Ok(entry) => match entry.set_password(token) { + Ok(_) => match entry.get_password() { + Ok(saved) if saved == token => Ok(true), + _ => Ok(false), + }, + Err(_) => Ok(false), + }, + Err(_) => Ok(false), + } +} + #[tauri::command] pub async fn save_auth_token(app_handle: AppHandle, token: String) -> Result<(), String> { let trimmed = token.trim(); @@ -172,31 +195,30 @@ pub async fn clear_auth_token(app_handle: AppHandle) -> Result<(), String> { } #[tauri::command] -pub async fn save_refresh_token(app_handle: AppHandle, token: String) -> Result<(), String> { +pub async fn save_refresh_token(app_handle: AppHandle, token: String) -> Result<(), String> { log::info!("Saving refresh token - trying keyring first"); - let entry = get_refresh_token_keyring_entry()?; - - // Try keyring (works in production with code signing) - match entry.set_password(&token) { - Ok(_) => { - // Verify it persists (fails in unsigned dev builds) - match entry.get_password() { - Ok(saved) if saved == token => { - log::info!("βœ… Refresh token saved to keyring (production mode)"); - return Ok(()); - } - _ => { - log::info!("Keyring doesn't persist - using Tauri Store fallback (dev mode)"); + match try_save_refresh_token_to_keyring(&token) { + Ok(true) => { + // Clear any stale fallback copy so the keyring stays authoritative. + if let Ok(store) = app_handle.store(TOKENS_STORE_FILE) { + if store.get(REFRESH_TOKEN_STORE_KEY).is_some() { + store.delete(REFRESH_TOKEN_STORE_KEY); + let _ = store.save(); } } + log::info!("Refresh token saved to keyring"); + return Ok(()); + } + Ok(false) => { + log::info!("Keyring did not persist refresh token - using Tauri Store fallback"); } Err(e) => { - log::info!("Keyring failed: {} - using Tauri Store fallback", e); + log::info!("Keyring error for refresh token: {} - using Tauri Store fallback", e); } } - // Fallback to Tauri Store (dev mode without code signing) + // Fallback to Tauri Store (dev mode without code signing, or restricted environments) let store = app_handle .store(TOKENS_STORE_FILE) .map_err(|e| format!("Failed to access tokens store: {}", e))?; @@ -211,28 +233,33 @@ pub async fn save_refresh_token(app_handle: AppHandle, token: String) -> Result< .save() .map_err(|e| format!("Failed to save tokens store: {}", e))?; - log::info!("βœ… Refresh token saved to Tauri Store (fallback)"); + log::info!("Refresh token saved to Tauri Store (fallback)"); Ok(()) } #[tauri::command] -pub async fn get_refresh_token(app_handle: AppHandle) -> Result, String> { - // Try keyring first (production) - let entry = get_refresh_token_keyring_entry()?; - match entry.get_password() { - Ok(token) => { - log::info!("βœ… Refresh token retrieved from keyring"); - return Ok(Some(token)); - } - Err(keyring::Error::NoEntry) => { - log::debug!("No token in keyring, trying Tauri Store"); - } +pub async fn get_refresh_token(app_handle: AppHandle) -> Result, String> { + // Try keyring first (production / unrestricted environments). Any failure - + // including entry creation - falls through to the Tauri Store fallback below. + match get_refresh_token_keyring_entry() { + Ok(entry) => match entry.get_password() { + Ok(token) => { + log::info!("Refresh token retrieved from keyring"); + return Ok(Some(token)); + } + Err(keyring::Error::NoEntry) => { + log::debug!("No refresh token in keyring, trying Tauri Store"); + } + Err(e) => { + log::warn!("Keyring error reading refresh token: {} - trying Tauri Store", e); + } + }, Err(e) => { - log::warn!("Keyring error: {} - trying Tauri Store", e); + log::warn!("Keyring entry unavailable for refresh token: {} - trying Tauri Store", e); } } - // Fallback to Tauri Store (dev) + // Fallback to Tauri Store (dev or restricted environments) let store = app_handle .store(TOKENS_STORE_FILE) .map_err(|e| format!("Failed to access tokens store: {}", e))?; @@ -242,7 +269,7 @@ pub async fn get_refresh_token(app_handle: AppHandle) -> Result, .and_then(|v| serde_json::from_value(v.clone()).ok()); if token.is_some() { - log::info!("βœ… Refresh token retrieved from Tauri Store"); + log::info!("Refresh token retrieved from Tauri Store"); } else { log::info!("No refresh token found"); } @@ -251,18 +278,26 @@ pub async fn get_refresh_token(app_handle: AppHandle) -> Result, } #[tauri::command] -pub async fn clear_refresh_token(app_handle: AppHandle) -> Result<(), String> { +pub async fn clear_refresh_token(app_handle: AppHandle) -> Result<(), String> { log::info!("Clearing refresh token from all storage"); - // Clear from keyring - let entry = get_refresh_token_keyring_entry()?; - match entry.delete_credential() { - Ok(_) => log::info!("Cleared from keyring"), - Err(keyring::Error::NoEntry) => log::debug!("Not in keyring"), - Err(e) => log::warn!("Keyring clear error: {}", e), + // Best-effort keyring clear; never blocks disk clear. + match get_refresh_token_keyring_entry() { + Ok(entry) => match entry.delete_credential() { + Ok(_) | Err(keyring::Error::NoEntry) => {} + Err(e) => { + log::warn!("Failed to delete keyring refresh token: {}. Attempting overwrite with empty token.", e); + if let Err(e2) = entry.set_password("") { + log::warn!("Failed to overwrite keyring refresh token: {}", e2); + } + } + }, + Err(e) => { + log::warn!("Keyring entry unavailable while clearing refresh token: {} - clearing Tauri Store fallback only", e); + } } - // Clear from Tauri Store + // Clear from Tauri Store fallback let store = app_handle .store(TOKENS_STORE_FILE) .map_err(|e| format!("Failed to access tokens store: {}", e))?; @@ -273,7 +308,7 @@ pub async fn clear_refresh_token(app_handle: AppHandle) -> Result<(), String> { .save() .map_err(|e| format!("Failed to save tokens store: {}", e))?; - log::info!("βœ… Refresh token cleared"); + log::info!("Refresh token cleared"); Ok(()) } diff --git a/frontend/editor/src-tauri/src/lib.rs b/frontend/editor/src-tauri/src/lib.rs index 08986b2d37..f06633c22d 100644 --- a/frontend/editor/src-tauri/src/lib.rs +++ b/frontend/editor/src-tauri/src/lib.rs @@ -1,7 +1,7 @@ use tauri::{AppHandle, Emitter, Manager, RunEvent, WindowEvent}; mod utils; -mod commands; +pub mod commands; mod state; use commands::{ diff --git a/frontend/src-tauri/tests/refresh_token_fallback.rs b/frontend/src-tauri/tests/refresh_token_fallback.rs new file mode 100644 index 0000000000..8172e792d9 --- /dev/null +++ b/frontend/src-tauri/tests/refresh_token_fallback.rs @@ -0,0 +1,94 @@ +//! Asserts the refresh-token keyring helper falls through (Ok(false)) +//! instead of bailing (Err) when keyring access fails. This was the +//! original login-loop bug. + +use app_lib::commands::auth::{ + get_refresh_token_keyring_entry, try_save_refresh_token_to_keyring, + REFRESH_TOKEN_STORE_KEY_FOR_TESTS, TOKENS_STORE_FILE_FOR_TESTS, +}; +use serde_json::json; + +const ENV_FORCE_FAIL: &str = "STIRLING_PDF_TEST_FORCE_REFRESH_KEYRING_FAIL"; + +// Single test fn β€” env var is process-global; parallel tests would race. +#[test] +fn refresh_token_fallback_proof() { + // ===== Step 1: env-var injection forces keyring entry-creation to Err ===== + std::env::set_var(ENV_FORCE_FAIL, "1"); + + let entry_result = get_refresh_token_keyring_entry(); + assert!( + entry_result.is_err(), + "with {} set, get_refresh_token_keyring_entry must return Err", + ENV_FORCE_FAIL + ); + let err_msg = entry_result.err().unwrap(); + assert!( + err_msg.contains("Forced keyring failure for tests"), + "expected forced-failure marker; got: {}", + err_msg + ); + + // ===== Step 2: the bug-fix proof ===== + // Original bug: `let entry = get_refresh_token_keyring_entry()?;` would + // propagate Err and short-circuit save_refresh_token, never reaching the + // disk-store fallback. Fixed behaviour: the same keyring failure must be + // converted into a "fall through" signal (Ok(false)), NOT propagated as Err. + let outcome = try_save_refresh_token_to_keyring("test-token-xyz-456"); + assert!( + matches!(outcome, Ok(false)), + "REGRESSION: keyring entry-creation failure must convert to Ok(false) \ + so save_refresh_token falls through to the disk store. Got {:?}. \ + This means the function would short-circuit and the user's refresh \ + token would never be persisted - exactly the original login-loop bug.", + outcome + ); + + std::env::remove_var(ENV_FORCE_FAIL); + + // ===== Step 3: env-var unset does NOT carry the forced-failure marker ===== + let result_unset = get_refresh_token_keyring_entry(); + if let Err(e) = &result_unset { + assert!( + !e.contains("Forced keyring failure for tests"), + "with {} unset, error must NOT contain forced-failure marker; got: {}", + ENV_FORCE_FAIL, + e + ); + } + + // ===== Step 4: disk-fallback JSON round-trips ===== + // tauri_plugin_store writes via serde_json::to_vec_pretty(&HashMap). Reproduce that format and verify it round-trips, proving + // the file save_refresh_token's disk fallback writes is readable by + // get_refresh_token's disk fallback. + let tmp = std::env::temp_dir().join(format!( + "stirling_pdf_refresh_token_test_{}.json", + std::process::id() + )); + let token = "test-refresh-token-disk-roundtrip-abc-123"; + let mut map = std::collections::HashMap::::new(); + map.insert(REFRESH_TOKEN_STORE_KEY_FOR_TESTS.to_string(), json!(token)); + let bytes = serde_json::to_vec_pretty(&map).expect("serialize tokens map"); + std::fs::write(&tmp, &bytes).expect("write tokens file"); + + let read_bytes = std::fs::read(&tmp).expect("read tokens file"); + let read_map: std::collections::HashMap = + serde_json::from_slice(&read_bytes).expect("parse tokens json"); + let retrieved: Option = read_map + .get(REFRESH_TOKEN_STORE_KEY_FOR_TESTS) + .and_then(|v| serde_json::from_value(v.clone()).ok()); + + let _ = std::fs::remove_file(&tmp); + + assert_eq!( + retrieved, + Some(token.to_string()), + "disk-fallback JSON format must round-trip the refresh token (file: {})", + tmp.display() + ); + + // ===== Step 5: constants match production strings ===== + assert_eq!(TOKENS_STORE_FILE_FOR_TESTS, "tokens.json"); + assert_eq!(REFRESH_TOKEN_STORE_KEY_FOR_TESTS, "refresh_token"); +} From c0374266e7e1e97ccf632d1fc72ae71894c4993e Mon Sep 17 00:00:00 2001 From: Ludy Date: Wed, 27 May 2026 00:26:20 +0200 Subject: [PATCH 05/30] ci: harden workflow runners and update checkout action pins (#6445) # Description of Changes Add runner hardening and housekeeping across workflows. - .github/dependabot.yml: add /docker/unoserver and /engine to Dependabot update paths. - .github/workflows/_runner-pick.yml and .github/workflows/build.yml: add step-security/harden-runner (egress-policy: audit) to audit outbound calls from runners. - .github/workflows/ai-engine.yml, .github/workflows/aur-publish.yml, .github/workflows/package-managers.yml: update actions/checkout usage to the newer v6.0.2 reference. - .github/workflows/package-managers.yml: minor YAML formatting tidy (release types array). These changes improve CI security by auditing egress, update the checkout action to a newer release, and expand Dependabot coverage. --- ## 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/dependabot.yml | 2 ++ .github/workflows/_runner-pick.yml | 5 +++++ .github/workflows/ai-engine.yml | 2 +- .github/workflows/aur-publish.yml | 2 +- .github/workflows/build.yml | 5 +++++ .github/workflows/package-managers.yml | 4 ++-- .pre-commit-config.yaml | 2 +- 7 files changed, 17 insertions(+), 5 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 6e70e0365f..3ab69765a9 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -25,6 +25,8 @@ updates: - "/docker/frontend" - "/docker/base" - "/docker/engine" + - "/docker/unoserver" + - "/engine" schedule: interval: "weekly" cooldown: diff --git a/.github/workflows/_runner-pick.yml b/.github/workflows/_runner-pick.yml index 43be06555a..023325d383 100644 --- a/.github/workflows/_runner-pick.yml +++ b/.github/workflows/_runner-pick.yml @@ -37,6 +37,11 @@ jobs: outputs: is_fork: ${{ steps.decide.outputs.is_fork }} steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 + with: + egress-policy: audit + - name: Classify the trigger id: decide env: diff --git a/.github/workflows/ai-engine.yml b/.github/workflows/ai-engine.yml index e5f9703319..e542d1effa 100644 --- a/.github/workflows/ai-engine.yml +++ b/.github/workflows/ai-engine.yml @@ -26,7 +26,7 @@ jobs: egress-policy: audit - name: Checkout code - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Install uv uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 diff --git a/.github/workflows/aur-publish.yml b/.github/workflows/aur-publish.yml index b6dcd479c9..8371695856 100644 --- a/.github/workflows/aur-publish.yml +++ b/.github/workflows/aur-publish.yml @@ -75,7 +75,7 @@ jobs: egress-policy: audit - name: Checkout repository (for PKGBUILD templates) - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Update stirling-pdf-desktop PKGBUILD env: diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ade9e277e4..05029d2aab 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -198,6 +198,11 @@ jobs: - dependency-review runs-on: ubuntu-latest steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 + with: + egress-policy: audit + - name: Verify every required job passed (or was legitimately skipped) env: RESULTS: | diff --git a/.github/workflows/package-managers.yml b/.github/workflows/package-managers.yml index 58e4d882f0..018cc61b67 100644 --- a/.github/workflows/package-managers.yml +++ b/.github/workflows/package-managers.yml @@ -2,7 +2,7 @@ name: Update Package Manager Manifests on: release: - types: [ released ] + types: [released] workflow_dispatch: inputs: version: @@ -84,7 +84,7 @@ jobs: egress-policy: audit - name: Checkout homebrew-stirling-pdf tap (also hosts Scoop bucket) - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: repository: Stirling-Tools/homebrew-stirling-pdf token: ${{ secrets.HOMEBREW_TAP_TOKEN }} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ae9ed62530..2490b4ae6e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.15.11 + rev: v0.15.14 hooks: - id: ruff args: From dd24440b73c186ea45950650a9e53b4a58cfdd5a Mon Sep 17 00:00:00 2001 From: Saul Ifshin <97627272+saul1310@users.noreply.github.com> Date: Tue, 26 May 2026 15:28:17 -0700 Subject: [PATCH 06/30] test(frontend): cover MIME precedence in non-PDF type detection (#6438) # Description of Changes --- ## Checklist ### General - [x] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [x] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [x] 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) - [x] I have performed a self-review of my own code - [x] My changes generate no new warnings ### Documentation - [x] 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) - [x] 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) - [x] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [x] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [x] I have run `task check` to verify linters, typechecks, and tests pass - [x] 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. --- frontend/editor/src/core/utils/fileUtils.test.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/frontend/editor/src/core/utils/fileUtils.test.ts b/frontend/editor/src/core/utils/fileUtils.test.ts index 8b1a1afd95..fcdb539264 100644 --- a/frontend/editor/src/core/utils/fileUtils.test.ts +++ b/frontend/editor/src/core/utils/fileUtils.test.ts @@ -3,6 +3,7 @@ import { isPdfFile, detectFileExtension, formatFileSize, + detectNonPdfFileType, } from "@app/utils/fileUtils"; describe("fileUtils", () => { @@ -94,4 +95,18 @@ describe("fileUtils", () => { expect(formatFileSize(1024 * 1024 * 1024)).toBe("1 GB"); }); }); + + describe("detectNonPdfFileType", () => { + it("should prioritize MIME type over extension when they conflict", () => { + expect( + detectNonPdfFileType({ + name: "financial-summary.pdf", + type: "text/csv", + }), + ).toBe("csv"); + expect( + detectNonPdfFileType({ name: "diagram.png", type: "application/json" }), + ).toBe("json"); + }); + }); }); From 620f246ca4491518ca3320abe981c3292cc0dd91 Mon Sep 17 00:00:00 2001 From: Saul Ifshin <97627272+saul1310@users.noreply.github.com> Date: Tue, 26 May 2026 15:29:32 -0700 Subject: [PATCH 07/30] test(frontend): add URL-encoded filename parsing case (#6436) # Description of Changes --- ## Checklist ### General - [x] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [x] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [x] 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) - [x] I have performed a self-review of my own code - [x] My changes generate no new warnings ### Documentation - [x] 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) - [x] 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) - [x] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [x] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [x] I have run `task check` to verify linters, typechecks, and tests pass - [x] 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. --- frontend/editor/src/core/utils/fileResponseUtils.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/frontend/editor/src/core/utils/fileResponseUtils.test.ts b/frontend/editor/src/core/utils/fileResponseUtils.test.ts index ce2b6cd6d5..5fec0e955f 100644 --- a/frontend/editor/src/core/utils/fileResponseUtils.test.ts +++ b/frontend/editor/src/core/utils/fileResponseUtils.test.ts @@ -63,6 +63,14 @@ describe("fileResponseUtils", () => { expect(filename).toBe("email_content.html"); }); + + test("should decode URL-encoded filenames from content-disposition header", () => { + const contentDisposition = + 'attachment; filename="Quarterly%20Report%20(EN)%20v2.pdf"'; + const filename = getFilenameFromHeaders(contentDisposition); + + expect(filename).toBe("Quarterly Report (EN) v2.pdf"); + }); }); describe("createFileFromApiResponse", () => { From 7743720f0ab0ef74185f6a0302318393dd691dd2 Mon Sep 17 00:00:00 2001 From: Matt Van Horn Date: Tue, 26 May 2026 15:30:57 -0700 Subject: [PATCH 08/30] chore: fix "Endoints" typo in health route comment (#6446) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java:175` section comment said "Health Endoints" β†’ "Health Endpoints". Comment-only. Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> --- .../java/stirling/software/common/util/RequestUriUtils.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java b/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java index ce8e05dc80..28fe2d188f 100644 --- a/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java +++ b/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java @@ -172,7 +172,7 @@ public class RequestUriUtils { "/api/v1/ui-data/footer-info") // Public footer configuration || trimmedUri.startsWith("/api/v1/invite/validate") || trimmedUri.startsWith("/api/v1/invite/accept") - // Health Endoints + // Health Endpoints || trimmedUri.startsWith("/actuator/health") || trimmedUri.startsWith("/health") || trimmedUri.startsWith("/healthz") From 4564ed5bec18e8cd665667321906a381e2c3d5f8 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Wed, 27 May 2026 11:54:59 +0100 Subject: [PATCH 09/30] Add cluster backplane abstraction and interfaces (#6449) --- app/common/build.gradle | 3 + .../common/cluster/ClusterBackplane.java | 24 ++ .../common/cluster/ClusterConfig.java | 63 ++++ .../software/common/cluster/ClusterNode.java | 12 + .../common/cluster/DistributedLock.java | 21 ++ .../software/common/cluster/FileStore.java | 45 +++ .../common/cluster/InstanceRegistry.java | 18 + .../software/common/cluster/JobStore.java | 24 ++ .../common/cluster/JobStoreEntry.java | 30 ++ .../common/cluster/KeyValueCache.java | 16 + .../common/cluster/RateLimitStore.java | 18 + .../common/cluster/StickyMissRecorder.java | 7 + .../inprocess/InProcessClusterBackplane.java | 31 ++ .../InProcessClusterConfiguration.java | 65 ++++ .../inprocess/InProcessDistributedLock.java | 128 +++++++ .../inprocess/InProcessInstanceRegistry.java | 42 +++ .../cluster/inprocess/InProcessJobStore.java | 96 +++++ .../inprocess/InProcessKeyValueCache.java | 55 +++ .../inprocess/InProcessRateLimitStore.java | 49 +++ .../cluster/inprocess/LocalDiskFileStore.java | 128 +++++++ .../LocalDiskFileStoreConfiguration.java | 29 ++ .../common/model/ApplicationProperties.java | 102 ++++++ .../software/common/service/FileStorage.java | 331 ++++++------------ .../software/common/service/TaskManager.java | 108 +++++- .../common/architecture/ArchitectureTest.java | 13 + .../BackplaneContractCompilationTest.java | 51 +++ .../cluster/ClusterConfigValidationTest.java | 65 ++++ .../common/cluster/ClusterPropertiesTest.java | 62 ++++ ...InProcessConfigurationConditionalTest.java | 90 +++++ .../InProcessDistributedLockTest.java | 167 +++++++++ .../InProcessInstanceRegistryTest.java | 28 ++ .../inprocess/InProcessJobStoreTest.java | 91 +++++ .../inprocess/InProcessKeyValueCacheTest.java | 41 +++ .../InProcessRateLimitStoreTest.java | 56 +++ .../inprocess/LocalDiskFileStoreTest.java | 42 +++ .../service/FileStorageDelegationTest.java | 27 ++ .../common/service/FileStorageTest.java | 30 +- .../TaskManagerJobStoreDelegationTest.java | 120 +++++++ .../common/service/TaskManagerTest.java | 70 ++++ .../src/main/resources/settings.yml.template | 16 + 40 files changed, 2167 insertions(+), 247 deletions(-) create mode 100644 app/common/src/main/java/stirling/software/common/cluster/ClusterBackplane.java create mode 100644 app/common/src/main/java/stirling/software/common/cluster/ClusterConfig.java create mode 100644 app/common/src/main/java/stirling/software/common/cluster/ClusterNode.java create mode 100644 app/common/src/main/java/stirling/software/common/cluster/DistributedLock.java create mode 100644 app/common/src/main/java/stirling/software/common/cluster/FileStore.java create mode 100644 app/common/src/main/java/stirling/software/common/cluster/InstanceRegistry.java create mode 100644 app/common/src/main/java/stirling/software/common/cluster/JobStore.java create mode 100644 app/common/src/main/java/stirling/software/common/cluster/JobStoreEntry.java create mode 100644 app/common/src/main/java/stirling/software/common/cluster/KeyValueCache.java create mode 100644 app/common/src/main/java/stirling/software/common/cluster/RateLimitStore.java create mode 100644 app/common/src/main/java/stirling/software/common/cluster/StickyMissRecorder.java create mode 100644 app/common/src/main/java/stirling/software/common/cluster/inprocess/InProcessClusterBackplane.java create mode 100644 app/common/src/main/java/stirling/software/common/cluster/inprocess/InProcessClusterConfiguration.java create mode 100644 app/common/src/main/java/stirling/software/common/cluster/inprocess/InProcessDistributedLock.java create mode 100644 app/common/src/main/java/stirling/software/common/cluster/inprocess/InProcessInstanceRegistry.java create mode 100644 app/common/src/main/java/stirling/software/common/cluster/inprocess/InProcessJobStore.java create mode 100644 app/common/src/main/java/stirling/software/common/cluster/inprocess/InProcessKeyValueCache.java create mode 100644 app/common/src/main/java/stirling/software/common/cluster/inprocess/InProcessRateLimitStore.java create mode 100644 app/common/src/main/java/stirling/software/common/cluster/inprocess/LocalDiskFileStore.java create mode 100644 app/common/src/main/java/stirling/software/common/cluster/inprocess/LocalDiskFileStoreConfiguration.java create mode 100644 app/common/src/test/java/stirling/software/common/cluster/BackplaneContractCompilationTest.java create mode 100644 app/common/src/test/java/stirling/software/common/cluster/ClusterConfigValidationTest.java create mode 100644 app/common/src/test/java/stirling/software/common/cluster/ClusterPropertiesTest.java create mode 100644 app/common/src/test/java/stirling/software/common/cluster/InProcessConfigurationConditionalTest.java create mode 100644 app/common/src/test/java/stirling/software/common/cluster/inprocess/InProcessDistributedLockTest.java create mode 100644 app/common/src/test/java/stirling/software/common/cluster/inprocess/InProcessInstanceRegistryTest.java create mode 100644 app/common/src/test/java/stirling/software/common/cluster/inprocess/InProcessJobStoreTest.java create mode 100644 app/common/src/test/java/stirling/software/common/cluster/inprocess/InProcessKeyValueCacheTest.java create mode 100644 app/common/src/test/java/stirling/software/common/cluster/inprocess/InProcessRateLimitStoreTest.java create mode 100644 app/common/src/test/java/stirling/software/common/cluster/inprocess/LocalDiskFileStoreTest.java create mode 100644 app/common/src/test/java/stirling/software/common/service/FileStorageDelegationTest.java create mode 100644 app/common/src/test/java/stirling/software/common/service/TaskManagerJobStoreDelegationTest.java diff --git a/app/common/build.gradle b/app/common/build.gradle index 508a978255..245a162346 100644 --- a/app/common/build.gradle +++ b/app/common/build.gradle @@ -78,6 +78,9 @@ dependencies { runtimeOnly "com.stirling:jpdfium-natives-${platform}:1.0.1" } + // Bucket4j (local in-process token bucket for RateLimitStore default impl) + implementation 'com.bucket4j:bucket4j_jdk17-core:8.19.0' + // ArchUnit: enforces module dependency direction (see ArchitectureTest) testImplementation 'com.tngtech.archunit:archunit-junit5:1.4.2' } diff --git a/app/common/src/main/java/stirling/software/common/cluster/ClusterBackplane.java b/app/common/src/main/java/stirling/software/common/cluster/ClusterBackplane.java new file mode 100644 index 0000000000..098608270b --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/cluster/ClusterBackplane.java @@ -0,0 +1,24 @@ +package stirling.software.common.cluster; + +/** Health and identity facade for the active cluster backplane. */ +public interface ClusterBackplane { + + /** Returns {@code true} when the backplane is reachable; used for health endpoints. */ + boolean isHealthy(); + + /** Returns {@code "inprocess"} or {@code "valkey"}. */ + String backplaneType(); + + /** Returns this JVM's stable node id (matches {@code Cluster.resolvedNodeId()}). */ + String localNodeId(); + + /** + * Whether this JVM should run the local {@link + * stirling.software.common.service.TaskManager#cleanupOldJobs()} loop. Distributed backplanes + * own job expiry via their own TTL, so they should override this to return {@code false}. + * Defaults to {@code true} so in-process behavior is preserved without an explicit override. + */ + default boolean shouldRunLocalCleanup() { + return true; + } +} diff --git a/app/common/src/main/java/stirling/software/common/cluster/ClusterConfig.java b/app/common/src/main/java/stirling/software/common/cluster/ClusterConfig.java new file mode 100644 index 0000000000..127fbfbfbd --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/cluster/ClusterConfig.java @@ -0,0 +1,63 @@ +package stirling.software.common.cluster; + +import org.springframework.context.annotation.Configuration; + +import jakarta.annotation.PostConstruct; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.model.ApplicationProperties.Cluster; + +/** + * Validates that cluster mode is internally consistent. + * + *

Cluster settings are bound on the central {@link ApplicationProperties} under {@code + * cluster.*}; this class reads {@link ApplicationProperties#getCluster()} and runs guards in {@link + * PostConstruct}. When {@code cluster.enabled=false} (the default) all checks are skipped so a + * single-instance install needs no new config. + */ +@Slf4j +@Configuration +@RequiredArgsConstructor +public class ClusterConfig { + + private final ApplicationProperties applicationProperties; + + @PostConstruct + void validate() { + Cluster cluster = applicationProperties.getCluster(); + if (!cluster.isEnabled()) { + return; + } + String backplane = cluster.getBackplane(); + if ("valkey".equalsIgnoreCase(backplane)) { + String url = cluster.getValkey() == null ? null : cluster.getValkey().getUrl(); + if (url == null || url.isBlank()) { + throw new IllegalStateException( + "cluster.enabled=true with backplane=valkey requires" + + " cluster.valkey.url to be set (e.g." + + " redis://valkey:6379)."); + } + } else if ("inprocess".equalsIgnoreCase(backplane)) { + // enabled+inprocess only coordinates the local JVM; cross-node lookups will 410. + log.warn( + "cluster.enabled=true with backplane=inprocess - only the local" + + " JVM is coordinated. Cross-node lookups and the file proxy will fail." + + " Use backplane=valkey for real multi-node deployments."); + } else { + // Fail fast on typos like "valky" so Spring doesn't later report a cryptic + // "no ClusterBackplane bean" - the operator-facing error names the bad value. + throw new IllegalStateException( + "cluster.enabled=true with unknown backplane '" + + backplane + + "'. Valid values: inprocess | valkey."); + } + log.info( + "Cluster mode enabled (backplane={}, nodeRole={}, nodeId={}).", + backplane, + cluster.resolvedRole(), + cluster.resolvedNodeId()); + } +} diff --git a/app/common/src/main/java/stirling/software/common/cluster/ClusterNode.java b/app/common/src/main/java/stirling/software/common/cluster/ClusterNode.java new file mode 100644 index 0000000000..c008d0e991 --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/cluster/ClusterNode.java @@ -0,0 +1,12 @@ +package stirling.software.common.cluster; + +import java.time.Instant; + +/** + * Snapshot of a peer node as recorded in the {@link InstanceRegistry}. + * + * @param internalAddress {@code host:port} the node listens on for {@code /internal/cluster/**} + * @param role one of {@code WEB}, {@code WORKER}, {@code BOTH} + */ +public record ClusterNode( + String nodeId, String internalAddress, Instant lastHeartbeat, String role) {} diff --git a/app/common/src/main/java/stirling/software/common/cluster/DistributedLock.java b/app/common/src/main/java/stirling/software/common/cluster/DistributedLock.java new file mode 100644 index 0000000000..4c77e0f93a --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/cluster/DistributedLock.java @@ -0,0 +1,21 @@ +package stirling.software.common.cluster; + +import java.time.Duration; +import java.util.Optional; + +/** Cluster-wide mutual exclusion primitive; non-reentrant by contract. */ +public interface DistributedLock { + + Optional tryAcquire(String lockKey, Duration leaseTime); + + interface LockHandle extends AutoCloseable { + void release(); + + boolean renew(Duration leaseTime); + + @Override + default void close() { + release(); + } + } +} diff --git a/app/common/src/main/java/stirling/software/common/cluster/FileStore.java b/app/common/src/main/java/stirling/software/common/cluster/FileStore.java new file mode 100644 index 0000000000..f576e1e974 --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/cluster/FileStore.java @@ -0,0 +1,45 @@ +package stirling.software.common.cluster; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; + +/** Low-level storage seam for result/job files. */ +public interface FileStore { + + /** Stored file record. */ + record Stored(String fileId, long size) {} + + /** Store the given stream and return a generated file id and total bytes written. */ + Stored store(InputStream in, String originalName) throws IOException; + + /** + * Store the file at {@code source} and return a generated file id and total bytes written. + * + *

Default implementation opens {@code source} as a stream and delegates to {@link + * #store(InputStream, String)}. Local-disk implementations should override to use a direct + * file-to-file copy ({@code Files.copy(source, dest)} can use {@code sendfile(2)} on Linux), + * which avoids the two-memory-copy hit of streaming a disk-backed upload through the JVM heap. + */ + default Stored store(Path source, String originalName) throws IOException { + try (InputStream in = Files.newInputStream(source)) { + return store(in, originalName); + } + } + + /** Open the stored file for streaming reads. Caller closes. */ + InputStream retrieve(String fileId) throws IOException; + + /** Load the stored file into a byte array. */ + byte[] retrieveBytes(String fileId) throws IOException; + + /** Size of the stored file in bytes. */ + long size(String fileId) throws IOException; + + /** Delete the stored file. Returns true if a file was removed. */ + boolean delete(String fileId); + + /** Whether the file id exists in the store. */ + boolean exists(String fileId); +} diff --git a/app/common/src/main/java/stirling/software/common/cluster/InstanceRegistry.java b/app/common/src/main/java/stirling/software/common/cluster/InstanceRegistry.java new file mode 100644 index 0000000000..289a352690 --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/cluster/InstanceRegistry.java @@ -0,0 +1,18 @@ +package stirling.software.common.cluster; + +import java.time.Duration; +import java.util.Collection; +import java.util.Optional; + +/** Maps {@code nodeId} to its internal cluster address, with TTL'd heartbeats. */ +public interface InstanceRegistry { + + /** Register or refresh this node. Idempotent so a wiped backplane self-heals on next tick. */ + void register(ClusterNode node, Duration heartbeatTtl); + + Optional lookup(String nodeId); + + Collection activeNodes(); + + void deregister(String nodeId); +} diff --git a/app/common/src/main/java/stirling/software/common/cluster/JobStore.java b/app/common/src/main/java/stirling/software/common/cluster/JobStore.java new file mode 100644 index 0000000000..03083c06ec --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/cluster/JobStore.java @@ -0,0 +1,24 @@ +package stirling.software.common.cluster; + +import java.time.Duration; +import java.util.Collection; +import java.util.Optional; + +/** Cluster-visible storage for job status and result metadata, with TTL'd entries. */ +public interface JobStore { + + /** Persist or overwrite a job entry. {@code ttl} sets the lifetime of the entry. */ + void put(JobStoreEntry entry, Duration ttl); + + Optional get(String jobId); + + void delete(String jobId); + + boolean exists(String jobId); + + /** Reverse lookup: which job owns this result file id? */ + Optional findJobIdByFileId(String fileId); + + /** Snapshot of every active entry. Used by admin/stats endpoints; may be O(n). */ + Collection all(); +} diff --git a/app/common/src/main/java/stirling/software/common/cluster/JobStoreEntry.java b/app/common/src/main/java/stirling/software/common/cluster/JobStoreEntry.java new file mode 100644 index 0000000000..e730938b6d --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/cluster/JobStoreEntry.java @@ -0,0 +1,30 @@ +package stirling.software.common.cluster; + +import java.time.Instant; +import java.util.List; +import java.util.Map; + +/** + * Cluster-visible projection of a job's status and result metadata, as persisted in {@link + * JobStore}. + * + * @param owningNodeId the node id that originally executed the job + */ +public record JobStoreEntry( + String jobId, + JobState state, + String owningNodeId, + Instant createdAt, + Instant completedAt, + String error, + List fileIds, + Map resultMeta) { + + /** Lifecycle states for a job as observed by the cluster. */ + public enum JobState { + PENDING, + RUNNING, + COMPLETE, + FAILED + } +} diff --git a/app/common/src/main/java/stirling/software/common/cluster/KeyValueCache.java b/app/common/src/main/java/stirling/software/common/cluster/KeyValueCache.java new file mode 100644 index 0000000000..fae44ad827 --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/cluster/KeyValueCache.java @@ -0,0 +1,16 @@ +package stirling.software.common.cluster; + +import java.time.Duration; +import java.util.Optional; + +/** Short-TTL namespaced key/value cache backed by the cluster backplane. */ +public interface KeyValueCache { + + void put(String namespace, String key, String value, Duration ttl); + + Optional get(String namespace, String key); + + void evict(String namespace, String key); + + void evictNamespace(String namespace); +} diff --git a/app/common/src/main/java/stirling/software/common/cluster/RateLimitStore.java b/app/common/src/main/java/stirling/software/common/cluster/RateLimitStore.java new file mode 100644 index 0000000000..82351961a2 --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/cluster/RateLimitStore.java @@ -0,0 +1,18 @@ +package stirling.software.common.cluster; + +import java.time.Duration; + +/** Token-bucket rate limiting backed by the cluster backplane. */ +public interface RateLimitStore { + + /** + * Attempt to consume one token from the bucket identified by {@code bucketKey}. + * + * @param bucketKey opaque key identifying the bucket (e.g. {@code api:user:123}) + * @param capacity bucket capacity + * @param refillPeriod time window over which {@code capacity} tokens refill + */ + RateLimitDecision tryConsume(String bucketKey, long capacity, Duration refillPeriod); + + record RateLimitDecision(boolean allowed, long remainingTokens, long nanosToWaitForRefill) {} +} diff --git a/app/common/src/main/java/stirling/software/common/cluster/StickyMissRecorder.java b/app/common/src/main/java/stirling/software/common/cluster/StickyMissRecorder.java new file mode 100644 index 0000000000..cba9b3ceb4 --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/cluster/StickyMissRecorder.java @@ -0,0 +1,7 @@ +package stirling.software.common.cluster; + +/** Records one increment per sticky-session miss (a 410 Gone for a job owned by another node). */ +@FunctionalInterface +public interface StickyMissRecorder { + void recordStickyMiss(); +} diff --git a/app/common/src/main/java/stirling/software/common/cluster/inprocess/InProcessClusterBackplane.java b/app/common/src/main/java/stirling/software/common/cluster/inprocess/InProcessClusterBackplane.java new file mode 100644 index 0000000000..8cc147b89a --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/cluster/inprocess/InProcessClusterBackplane.java @@ -0,0 +1,31 @@ +package stirling.software.common.cluster.inprocess; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.cluster.ClusterBackplane; +import stirling.software.common.model.ApplicationProperties; + +@Slf4j +public class InProcessClusterBackplane implements ClusterBackplane { + + private final ApplicationProperties applicationProperties; + + public InProcessClusterBackplane(ApplicationProperties applicationProperties) { + this.applicationProperties = applicationProperties; + } + + @Override + public boolean isHealthy() { + return true; + } + + @Override + public String backplaneType() { + return "inprocess"; + } + + @Override + public String localNodeId() { + return applicationProperties.getCluster().resolvedNodeId(); + } +} diff --git a/app/common/src/main/java/stirling/software/common/cluster/inprocess/InProcessClusterConfiguration.java b/app/common/src/main/java/stirling/software/common/cluster/inprocess/InProcessClusterConfiguration.java new file mode 100644 index 0000000000..005ec319c3 --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/cluster/inprocess/InProcessClusterConfiguration.java @@ -0,0 +1,65 @@ +package stirling.software.common.cluster.inprocess; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.cluster.ClusterBackplane; +import stirling.software.common.cluster.DistributedLock; +import stirling.software.common.cluster.InstanceRegistry; +import stirling.software.common.cluster.JobStore; +import stirling.software.common.cluster.KeyValueCache; +import stirling.software.common.cluster.RateLimitStore; +import stirling.software.common.model.ApplicationProperties; + +/** + * Default cluster backplane wiring: every interface gets an {@code InProcess*} bean. Active when + * cluster mode is off or {@code cluster.backplane=inprocess}. + */ +@Slf4j +@Configuration +@ConditionalOnExpression( + "!${cluster.enabled:false} ||" + + " '${cluster.backplane:inprocess}'.equalsIgnoreCase('inprocess')") +public class InProcessClusterConfiguration { + + @Bean + @ConditionalOnMissingBean + public ClusterBackplane clusterBackplane(ApplicationProperties applicationProperties) { + log.info("Cluster backplane: in-process (single node)"); + return new InProcessClusterBackplane(applicationProperties); + } + + @Bean + @ConditionalOnMissingBean + public JobStore jobStore() { + return new InProcessJobStore(); + } + + @Bean + @ConditionalOnMissingBean + public RateLimitStore rateLimitStore() { + return new InProcessRateLimitStore(); + } + + @Bean + @ConditionalOnMissingBean + public DistributedLock distributedLock() { + return new InProcessDistributedLock(); + } + + @Bean + @ConditionalOnMissingBean + public KeyValueCache keyValueCache() { + return new InProcessKeyValueCache(); + } + + @Bean + @ConditionalOnMissingBean + public InstanceRegistry instanceRegistry() { + return new InProcessInstanceRegistry(); + } +} diff --git a/app/common/src/main/java/stirling/software/common/cluster/inprocess/InProcessDistributedLock.java b/app/common/src/main/java/stirling/software/common/cluster/inprocess/InProcessDistributedLock.java new file mode 100644 index 0000000000..d937b3f39f --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/cluster/inprocess/InProcessDistributedLock.java @@ -0,0 +1,128 @@ +package stirling.software.common.cluster.inprocess; + +import java.time.Duration; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; + +import stirling.software.common.cluster.DistributedLock; + +/** + * In-process {@link DistributedLock}, non-reentrant per the interface contract, with lease-expiry + * semantics that mirror a SET-NX-EX style distributed backend. + * + *

Each lock state carries a per-acquire {@code ownerToken} and an {@code expiryNanos}; another + * caller can take over once the lease has elapsed even if the original holder never called {@link + * LockHandle#release()}. This matters mostly for parity with the Valkey-backed implementation + * (Redis {@code SETEX} auto-expires the key); within a single JVM a crashed holder takes its lock + * state with it, but tests and code that rely on the {@code leaseTime} parameter still need it to + * be honored. + * + *

Expiry is lazy: an expired lock state lingers in the map until the next acquire attempt for + * the same key replaces it. Per-key cleanup also happens on explicit {@link LockHandle#release()}, + * so a balanced acquire/release workload keeps the map size bounded. + */ +public class InProcessDistributedLock implements DistributedLock { + + private final ConcurrentHashMap locks = new ConcurrentHashMap<>(); + private final AtomicLong tokenSeq = new AtomicLong(); + + /** + * Lease state for a single acquired lock. {@code ownerToken} prevents a former holder from + * releasing or renewing a lock now owned by someone else after lease expiry; {@code + * expiryNanos} is read/written only inside {@link ConcurrentHashMap#compute} so the bin lock + * provides the necessary happens-before guarantee. + */ + private static final class LockState { + final long ownerToken; + long expiryNanos; + + LockState(long ownerToken, long expiryNanos) { + this.ownerToken = ownerToken; + this.expiryNanos = expiryNanos; + } + } + + @Override + public Optional tryAcquire(String lockKey, Duration leaseTime) { + long token = tokenSeq.incrementAndGet(); + long nowNanos = System.nanoTime(); + long expiryNanos = nowNanos + leaseTime.toNanos(); + boolean[] acquired = {false}; + locks.compute( + lockKey, + (k, existing) -> { + if (existing == null || existing.expiryNanos - nowNanos <= 0L) { + // No lock, or the previous lease has expired - we take it. Subtraction + // form avoids the long-overflow trap that would bite a naive + // expiryNanos <= nowNanos comparison around System.nanoTime() rollover. + acquired[0] = true; + return new LockState(token, expiryNanos); + } + return existing; + }); + if (!acquired[0]) { + return Optional.empty(); + } + return Optional.of(new InProcessHandle(lockKey, token)); + } + + private void releaseInternal(String lockKey, long token) { + locks.compute( + lockKey, + (k, existing) -> { + if (existing == null || existing.ownerToken != token) { + // Already removed, expired-and-replaced, or never ours. + return existing; + } + return null; + }); + } + + private boolean renewInternal(String lockKey, long token, Duration leaseTime) { + long nowNanos = System.nanoTime(); + boolean[] renewed = {false}; + locks.compute( + lockKey, + (k, existing) -> { + if (existing == null + || existing.ownerToken != token + || existing.expiryNanos - nowNanos <= 0L) { + // Lock is gone or expired; renewal is a no-op so the caller can detect it. + return existing; + } + existing.expiryNanos = nowNanos + leaseTime.toNanos(); + renewed[0] = true; + return existing; + }); + return renewed[0]; + } + + private final class InProcessHandle implements LockHandle { + private final String lockKey; + private final long token; + private boolean released; + + InProcessHandle(String lockKey, long token) { + this.lockKey = lockKey; + this.token = token; + } + + @Override + public synchronized void release() { + if (released) { + return; + } + released = true; + releaseInternal(lockKey, token); + } + + @Override + public synchronized boolean renew(Duration leaseTime) { + if (released) { + return false; + } + return renewInternal(lockKey, token, leaseTime); + } + } +} diff --git a/app/common/src/main/java/stirling/software/common/cluster/inprocess/InProcessInstanceRegistry.java b/app/common/src/main/java/stirling/software/common/cluster/inprocess/InProcessInstanceRegistry.java new file mode 100644 index 0000000000..d46801f8b6 --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/cluster/inprocess/InProcessInstanceRegistry.java @@ -0,0 +1,42 @@ +package stirling.software.common.cluster.inprocess; + +import java.time.Duration; +import java.util.Collection; +import java.util.Collections; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicReference; + +import stirling.software.common.cluster.ClusterNode; +import stirling.software.common.cluster.InstanceRegistry; + +public class InProcessInstanceRegistry implements InstanceRegistry { + + private final AtomicReference self = new AtomicReference<>(); + + @Override + public void register(ClusterNode node, Duration heartbeatTtl) { + self.set(node); + } + + @Override + public Optional lookup(String nodeId) { + ClusterNode current = self.get(); + return current != null && current.nodeId().equals(nodeId) + ? Optional.of(current) + : Optional.empty(); + } + + @Override + public Collection activeNodes() { + ClusterNode current = self.get(); + return current == null ? Collections.emptyList() : Collections.singletonList(current); + } + + @Override + public void deregister(String nodeId) { + ClusterNode current = self.get(); + if (current != null && current.nodeId().equals(nodeId)) { + self.set(null); + } + } +} diff --git a/app/common/src/main/java/stirling/software/common/cluster/inprocess/InProcessJobStore.java b/app/common/src/main/java/stirling/software/common/cluster/inprocess/InProcessJobStore.java new file mode 100644 index 0000000000..500716e8e9 --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/cluster/inprocess/InProcessJobStore.java @@ -0,0 +1,96 @@ +package stirling.software.common.cluster.inprocess; + +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.cluster.JobStore; +import stirling.software.common.cluster.JobStoreEntry; + +@Slf4j +public class InProcessJobStore implements JobStore { + + private final ConcurrentHashMap entries = new ConcurrentHashMap<>(); + + @Override + public void put(JobStoreEntry entry, Duration ttl) { + Instant expiry = ttl == null ? Instant.MAX : Instant.now().plus(ttl); + entries.put(entry.jobId(), new Holder(entry, expiry)); + } + + @Override + public Optional get(String jobId) { + Holder h = entries.get(jobId); + if (h == null) { + return Optional.empty(); + } + if (h.isExpired()) { + entries.remove(jobId, h); + return Optional.empty(); + } + return Optional.of(h.entry); + } + + @Override + public void delete(String jobId) { + entries.remove(jobId); + } + + @Override + public boolean exists(String jobId) { + return get(jobId).isPresent(); + } + + @Override + public Optional findJobIdByFileId(String fileId) { + for (Map.Entry e : entries.entrySet()) { + Holder h = e.getValue(); + if (h.isExpired()) { + continue; + } + List fileIds = h.entry.fileIds(); + if (fileIds != null && fileIds.contains(fileId)) { + return Optional.of(e.getKey()); + } + } + return Optional.empty(); + } + + @Override + public Collection all() { + List result = new ArrayList<>(entries.size()); + for (Holder h : entries.values()) { + if (!h.isExpired()) { + result.add(h.entry); + } + } + return result; + } + + /** Drop entries whose TTL has elapsed. Called by the {@code TaskManager} cleanup scheduler. */ + public int purgeExpired() { + int removed = 0; + Instant now = Instant.now(); + for (Map.Entry e : entries.entrySet()) { + if (!e.getValue().expiry.equals(Instant.MAX) && e.getValue().expiry.isBefore(now)) { + if (entries.remove(e.getKey(), e.getValue())) { + removed++; + } + } + } + return removed; + } + + private record Holder(JobStoreEntry entry, Instant expiry) { + boolean isExpired() { + return !expiry.equals(Instant.MAX) && expiry.isBefore(Instant.now()); + } + } +} diff --git a/app/common/src/main/java/stirling/software/common/cluster/inprocess/InProcessKeyValueCache.java b/app/common/src/main/java/stirling/software/common/cluster/inprocess/InProcessKeyValueCache.java new file mode 100644 index 0000000000..845d272881 --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/cluster/inprocess/InProcessKeyValueCache.java @@ -0,0 +1,55 @@ +package stirling.software.common.cluster.inprocess; + +import java.time.Duration; +import java.time.Instant; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +import stirling.software.common.cluster.KeyValueCache; + +public class InProcessKeyValueCache implements KeyValueCache { + + private final ConcurrentHashMap> namespaces = + new ConcurrentHashMap<>(); + + @Override + public void put(String namespace, String key, String value, Duration ttl) { + Instant expiry = ttl == null ? Instant.MAX : Instant.now().plus(ttl); + namespaces + .computeIfAbsent(namespace, n -> new ConcurrentHashMap<>()) + .put(key, new Expiring(value, expiry)); + } + + @Override + public Optional get(String namespace, String key) { + Map ns = namespaces.get(namespace); + if (ns == null) { + return Optional.empty(); + } + Expiring e = ns.get(key); + if (e == null) { + return Optional.empty(); + } + if (e.expiry.isBefore(Instant.now())) { + ns.remove(key, e); + return Optional.empty(); + } + return Optional.of(e.value); + } + + @Override + public void evict(String namespace, String key) { + Map ns = namespaces.get(namespace); + if (ns != null) { + ns.remove(key); + } + } + + @Override + public void evictNamespace(String namespace) { + namespaces.remove(namespace); + } + + private record Expiring(String value, Instant expiry) {} +} diff --git a/app/common/src/main/java/stirling/software/common/cluster/inprocess/InProcessRateLimitStore.java b/app/common/src/main/java/stirling/software/common/cluster/inprocess/InProcessRateLimitStore.java new file mode 100644 index 0000000000..b2cd4cd74a --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/cluster/inprocess/InProcessRateLimitStore.java @@ -0,0 +1,49 @@ +package stirling.software.common.cluster.inprocess; + +import java.time.Duration; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +import io.github.bucket4j.Bandwidth; +import io.github.bucket4j.Bucket; +import io.github.bucket4j.ConsumptionProbe; +import io.github.bucket4j.local.LocalBucketBuilder; + +import stirling.software.common.cluster.RateLimitStore; + +/** Bucket4j-backed token bucket implementation of {@link RateLimitStore}. */ +public class InProcessRateLimitStore implements RateLimitStore { + + /** Cap to bound memory; oldest accessed buckets are evicted. */ + private static final int MAX_BUCKETS = 10_000; + + private final Map buckets = + Collections.synchronizedMap( + new LinkedHashMap(256, 0.75f, true) { + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + return size() > MAX_BUCKETS; + } + }); + + @Override + public RateLimitDecision tryConsume(String bucketKey, long capacity, Duration refillPeriod) { + String compositeKey = bucketKey + "|" + capacity + "|" + refillPeriod.toNanos(); + Bucket bucket = + buckets.computeIfAbsent(compositeKey, k -> buildBucket(capacity, refillPeriod)); + ConsumptionProbe probe = bucket.tryConsumeAndReturnRemaining(1); + return new RateLimitDecision( + probe.isConsumed(), + probe.getRemainingTokens(), + probe.isConsumed() ? 0L : probe.getNanosToWaitForRefill()); + } + + private static Bucket buildBucket(long capacity, Duration refillPeriod) { + Bandwidth limit = + Bandwidth.builder().capacity(capacity).refillGreedy(capacity, refillPeriod).build(); + LocalBucketBuilder builder = Bucket.builder(); + builder.addLimit(limit); + return builder.build(); + } +} diff --git a/app/common/src/main/java/stirling/software/common/cluster/inprocess/LocalDiskFileStore.java b/app/common/src/main/java/stirling/software/common/cluster/inprocess/LocalDiskFileStore.java new file mode 100644 index 0000000000..6d8d0d91fa --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/cluster/inprocess/LocalDiskFileStore.java @@ -0,0 +1,128 @@ +package stirling.software.common.cluster.inprocess; + +import java.io.BufferedInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.UUID; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.cluster.FileStore; + +/** Local-disk {@link FileStore} storing files under a base directory keyed by a UUID file id. */ +@Slf4j +public class LocalDiskFileStore implements FileStore { + + private final String baseDirPath; + + public LocalDiskFileStore(String baseDirPath) { + this.baseDirPath = baseDirPath; + } + + @Override + public Stored store(InputStream in, String originalName) throws IOException { + String fileId = UUID.randomUUID().toString(); + Path filePath = resolve(fileId); + Files.createDirectories(filePath.getParent()); + boolean success = false; + try { + long size = Files.copy(in, filePath); + success = true; + return new Stored(fileId, size); + } finally { + if (!success) { + try { + Files.deleteIfExists(filePath); + } catch (IOException cleanupEx) { + log.warn( + "Failed to clean up partial file {} after store failure", + filePath, + cleanupEx); + } + } + } + } + + /** + * File-to-file copy. {@link Files#copy(Path, Path, java.nio.file.CopyOption...)} can use {@code + * sendfile(2)} on Linux for a zero-copy kernel transfer when source and destination share a + * filesystem, avoiding the streaming overhead of pulling the bytes through the JVM heap. Reads + * the source size before copying so the post-copy stat is unnecessary. + */ + @Override + public Stored store(Path source, String originalName) throws IOException { + String fileId = UUID.randomUUID().toString(); + Path filePath = resolve(fileId); + Files.createDirectories(filePath.getParent()); + long size = Files.size(source); + boolean success = false; + try { + Files.copy(source, filePath); + success = true; + return new Stored(fileId, size); + } finally { + if (!success) { + try { + Files.deleteIfExists(filePath); + } catch (IOException cleanupEx) { + log.warn( + "Failed to clean up partial file {} after store failure", + filePath, + cleanupEx); + } + } + } + } + + @Override + public InputStream retrieve(String fileId) throws IOException { + return new BufferedInputStream(Files.newInputStream(resolve(fileId))); + } + + @Override + public byte[] retrieveBytes(String fileId) throws IOException { + Path filePath = resolve(fileId); + if (!Files.exists(filePath)) { + throw new IOException("File not found with ID: " + fileId); + } + return Files.readAllBytes(filePath); + } + + @Override + public long size(String fileId) throws IOException { + Path filePath = resolve(fileId); + if (!Files.exists(filePath)) { + throw new IOException("File not found with ID: " + fileId); + } + return Files.size(filePath); + } + + @Override + public boolean delete(String fileId) { + try { + return Files.deleteIfExists(resolve(fileId)); + } catch (IOException e) { + log.error("Error deleting file with ID: {}", fileId, e); + return false; + } + } + + @Override + public boolean exists(String fileId) { + return Files.exists(resolve(fileId)); + } + + public Path resolve(String fileId) { + if (fileId.contains("..") || fileId.contains("/") || fileId.contains("\\")) { + throw new IllegalArgumentException("Invalid file ID"); + } + Path basePath = Path.of(baseDirPath).normalize().toAbsolutePath(); + Path resolvedPath = basePath.resolve(fileId).normalize(); + if (!resolvedPath.startsWith(basePath)) { + throw new IllegalArgumentException("File ID resolves to an invalid path"); + } + return resolvedPath; + } +} diff --git a/app/common/src/main/java/stirling/software/common/cluster/inprocess/LocalDiskFileStoreConfiguration.java b/app/common/src/main/java/stirling/software/common/cluster/inprocess/LocalDiskFileStoreConfiguration.java new file mode 100644 index 0000000000..2df8246639 --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/cluster/inprocess/LocalDiskFileStoreConfiguration.java @@ -0,0 +1,29 @@ +package stirling.software.common.cluster.inprocess; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import stirling.software.common.cluster.FileStore; + +/** + * Always-on wiring for the per-node local-disk {@link FileStore}. Active when {@code + * cluster.artifactStore=local} (the default; {@code matchIfMissing=true}). The S3 artifact-store + * supplies its own bean when {@code cluster.artifactStore=s3}. + */ +@Configuration +@ConditionalOnProperty( + prefix = "cluster", + name = "artifactStore", + havingValue = "local", + matchIfMissing = true) +public class LocalDiskFileStoreConfiguration { + + @Bean + @ConditionalOnMissingBean + public FileStore fileStore(@Value("${stirling.tempDir:/tmp/stirling-files}") String tempDir) { + return new LocalDiskFileStore(tempDir); + } +} diff --git a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java index 227ba7709d..e298edd074 100644 --- a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java +++ b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java @@ -13,6 +13,7 @@ import java.util.Arrays; import java.util.Collection; import java.util.List; import java.util.Locale; +import java.util.UUID; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.context.annotation.Bean; @@ -77,6 +78,7 @@ public class ApplicationProperties { private PdfEditor pdfEditor = new PdfEditor(); private AiEngine aiEngine = new AiEngine(); private InternalApi internalApi = new InternalApi(); + private Cluster cluster = new Cluster(); @Bean public PropertySource dynamicYamlPropertySource(ConfigurableEnvironment environment) @@ -254,6 +256,106 @@ public class ApplicationProperties { private int longRunningTimeoutSeconds = 600; } + /** + * Cluster backplane configuration. All keys live under the top-level {@code cluster.*} prefix + * (e.g. env var {@code CLUSTER_ENABLED}). The master switch is {@link #enabled} and defaults to + * off; when off the in-process backplane is wired and no other cluster keys are required. + */ + @Data + public static class Cluster { + + /** Master switch. When {@code false} (default) the in-process backplane is wired. */ + private boolean enabled = false; + + /** Backplane implementation selector. Valid values: {@code inprocess} | {@code valkey}. */ + private String backplane = "inprocess"; + + /** + * Transient cluster job-artifact store selector. Valid values: {@code local} | {@code s3}. + * + *

This is distinct from {@code storage.provider}, which selects the backend for + * persistent user-uploaded files. The two switches exist because the user-facing storage + * feature is optional ({@code storage.enabled=false} is common) but every multi-node + * cluster still needs a shared artifact store to serve cross-node downloads. Both + * implementations share credentials from {@code storage.s3.*} when set to {@code s3}. + */ + private String artifactStore = "local"; + + private Valkey valkey = new Valkey(); + private Node node = new Node(); + + private transient String cachedNodeId; + + public NodeRole resolvedRole() { + if (node == null || node.getRole() == null) { + return NodeRole.BOTH; + } + String value = node.getRole().trim().toUpperCase(Locale.ROOT); + try { + return NodeRole.valueOf(value); + } catch (IllegalArgumentException ex) { + return NodeRole.BOTH; + } + } + + public synchronized String resolvedNodeId() { + if (node != null && node.getId() != null && !node.getId().isBlank()) { + return node.getId(); + } + if (cachedNodeId == null) { + cachedNodeId = UUID.randomUUID().toString(); + } + return cachedNodeId; + } + + public enum NodeRole { + WEB, + WORKER, + BOTH + } + + @Data + public static class Valkey { + /** + * {@code redis://host:6379} or {@code rediss://...} for TLS. Required when cluster mode + * is on and backplane is valkey. + */ + private String url = ""; + + private Tls tls = new Tls(); + + @Data + public static class Tls { + /** + * When {@code true}, skip Valkey/Redis TLS certificate verification (dev/test + * only). Leave {@code false} in production. + */ + private boolean skipCertVerification = false; + } + } + + @Data + public static class Node { + /** Optional explicit node id. Blank = auto-generated UUID at startup. */ + private String id = ""; + + /** {@code web} | {@code worker} | {@code both}. */ + private String role = "both"; + + /** + * Internal cluster address advertised in the instance registry (host:port). Blank = + * derived at startup. + */ + private String internalAddress = ""; + + /** {@code http} | {@code https} - scheme used when peers call this node. */ + private String scheme = "http"; + + /** Heartbeat publish interval for the instance registry, in milliseconds. */ + private long heartbeatIntervalMs = 5000; + } + } + /** * HTTP timeouts for loopback calls to internal Stirling API endpoints, used by the AI workflow * executor and the pipeline processor. A bounded read timeout prevents a hung tool (e.g. an diff --git a/app/common/src/main/java/stirling/software/common/service/FileStorage.java b/app/common/src/main/java/stirling/software/common/service/FileStorage.java index beb8028f70..e77e6d6fc6 100644 --- a/app/common/src/main/java/stirling/software/common/service/FileStorage.java +++ b/app/common/src/main/java/stirling/software/common/service/FileStorage.java @@ -1,15 +1,13 @@ package stirling.software.common.service; -import java.io.BufferedInputStream; -import java.io.BufferedOutputStream; +import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; -import java.io.OutputStream; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.UUID; +import java.io.PipedInputStream; +import java.io.PipedOutputStream; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicReference; -import org.springframework.beans.factory.annotation.Value; import org.springframework.core.io.Resource; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; @@ -18,9 +16,11 @@ import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBo import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import stirling.software.common.cluster.FileStore; + /** * Service for storing and retrieving files with unique file IDs. Used by the AutoJobPostMapping - * system to handle file references. + * system to handle file references. Disk I/O is delegated to the injected {@link FileStore} bean. */ @Service @RequiredArgsConstructor @@ -30,251 +30,150 @@ public class FileStorage { /** Holds the result of a stream-to-disk store operation: the file ID and the bytes written. */ public record StoredFile(String fileId, long size) {} - @Value("${stirling.tempDir:/tmp/stirling-files}") - private String tempDirPath; - private final FileOrUploadService fileOrUploadService; + private final FileStore fileStore; - /** - * Store a file and return its unique ID - * - * @param file The file to store - * @return The unique ID assigned to the file - * @throws IOException If there is an error storing the file - */ public String storeFile(MultipartFile file) throws IOException { - String fileId = generateFileId(); - Path filePath = getFilePath(fileId); - - // Ensure the directory exists - Files.createDirectories(filePath.getParent()); - - // Transfer the file to the storage location - file.transferTo(filePath.toFile()); - - log.debug("Stored file with ID: {}", fileId); - return fileId; - } - - /** - * Store a byte array as a file and return its unique ID - * - * @param bytes The byte array to store - * @param originalName The original name of the file (for extension) - * @return The unique ID assigned to the file - * @throws IOException If there is an error storing the file - */ - public String storeBytes(byte[] bytes, String originalName) throws IOException { - String fileId = generateFileId(); - Path filePath = getFilePath(fileId); - - // Ensure the directory exists - Files.createDirectories(filePath.getParent()); - - // Write the bytes to the file - Files.write(filePath, bytes); - - log.debug("Stored byte array with ID: {}", fileId); - return fileId; - } - - /** - * Retrieve a file by its ID as a MultipartFile - * - * @param fileId The ID of the file to retrieve - * @return The file as a MultipartFile - * @throws IOException If the file doesn't exist or can't be read - */ - public MultipartFile retrieveFile(String fileId) throws IOException { - Path filePath = getFilePath(fileId); - - if (!Files.exists(filePath)) { - throw new IOException("File not found with ID: " + fileId); + // Fast path: when Spring buffered the multipart to disk (typical for large uploads), the + // backing Resource exposes a real File. Hand the Path to the FileStore so it can do a + // file-to-file copy (Linux sendfile, no copy through Java heap) rather than streaming + // the bytes through an 8K buffer. Falls back to the InputStream path for in-memory + // multiparts, exotic Resource impls, and anything that does not back onto a File. + Resource res; + try { + res = file.getResource(); + } catch (RuntimeException ignored) { + res = null; } + if (res != null && res.isFile()) { + try { + FileStore.Stored stored = + fileStore.store(res.getFile().toPath(), file.getOriginalFilename()); + log.debug("Stored file with ID: {} (fast path)", stored.fileId()); + return stored.fileId(); + } catch (IOException ex) { + // Some Resource impls advertise isFile()=true but throw on getFile(); fall through. + log.debug("Resource fast path failed, falling back to stream copy", ex); + } + } + try (InputStream in = file.getInputStream()) { + FileStore.Stored stored = fileStore.store(in, file.getOriginalFilename()); + log.debug("Stored file with ID: {}", stored.fileId()); + return stored.fileId(); + } + } - byte[] fileData = Files.readAllBytes(filePath); + public String storeBytes(byte[] bytes, String originalName) throws IOException { + FileStore.Stored stored = fileStore.store(new ByteArrayInputStream(bytes), originalName); + log.debug("Stored byte array with ID: {}", stored.fileId()); + return stored.fileId(); + } + + public MultipartFile retrieveFile(String fileId) throws IOException { + byte[] fileData = fileStore.retrieveBytes(fileId); return fileOrUploadService.toMockMultipartFile(fileId, fileData); } - /** - * Retrieve a file by its ID as a byte array - * - * @param fileId The ID of the file to retrieve - * @return The file as a byte array - * @throws IOException If the file doesn't exist or can't be read - */ public byte[] retrieveBytes(String fileId) throws IOException { - Path filePath = getFilePath(fileId); - - if (!Files.exists(filePath)) { - throw new IOException("File not found with ID: " + fileId); - } - - return Files.readAllBytes(filePath); + return fileStore.retrieveBytes(fileId); } - /** - * Retrieve a file by its ID as a streaming InputStream. The caller is responsible for closing - * the returned stream. - * - * @param fileId The ID of the file to retrieve - * @return A buffered InputStream for the file - * @throws IOException If the file doesn't exist or can't be read - */ public InputStream retrieveInputStream(String fileId) throws IOException { - Path filePath = getFilePath(fileId); - // Let Files.newInputStream throw NoSuchFileException naturally β€” avoids TOCTOU race - // between exists-check and open when another thread may delete concurrently. - return new BufferedInputStream(Files.newInputStream(filePath)); + return fileStore.retrieve(fileId); } - /** - * Store data from an InputStream as a file and return its unique ID and byte count. Streams - * directly to disk without buffering the entire content in heap. - * - * @param inputStream The input stream to read from - * @param originalName The original name of the file (unused, kept for API symmetry) - * @return A {@link StoredFile} containing the file ID and the number of bytes written - * @throws IOException If there is an error storing the file - */ public StoredFile storeInputStream(InputStream inputStream, String originalName) throws IOException { - String fileId = generateFileId(); - Path filePath = getFilePath(fileId); - Files.createDirectories(filePath.getParent()); - long size = Files.copy(inputStream, filePath); - log.debug("Stored input stream with ID: {}", fileId); - return new StoredFile(fileId, size); + FileStore.Stored stored = fileStore.store(inputStream, originalName); + log.debug("Stored input stream with ID: {}", stored.fileId()); + return new StoredFile(stored.fileId(), stored.size()); } public String storeFromStreamingBody(StreamingResponseBody body, String originalName) throws IOException { - String fileId = generateFileId(); - Path filePath = getFilePath(fileId); - Files.createDirectories(filePath.getParent()); - boolean success = false; - try (OutputStream os = new BufferedOutputStream(Files.newOutputStream(filePath))) { - body.writeTo(os); - success = true; - } finally { - if (!success) { + // Hold Throwable not IOException: an unchecked failure (NPE, IllegalState, OOM, etc.) + // from the body writer would otherwise close the pipe with EOF and the consumer would + // return a truncated file with no error surfaced to the caller. + AtomicReference bodyError = new AtomicReference<>(); + try (PipedOutputStream out = new PipedOutputStream(); + PipedInputStream in = new PipedInputStream(out, 8192)) { + var executor = Executors.newSingleThreadExecutor(Thread.ofVirtual().factory()); + java.util.concurrent.Future task = null; + try { + task = + executor.submit( + () -> { + try { + body.writeTo(out); + } catch (Throwable ex) { + bodyError.set(ex); + } finally { + try { + out.close(); + } catch (IOException ignored) { + // closed on the consumer side too + } + } + }); + FileStore.Stored stored = fileStore.store(in, originalName); + Throwable writerErr = bodyError.get(); + if (writerErr != null) { + // Body failed mid-write: the FileStore persisted a truncated entry. + // Best-effort delete so we don't leak partial files; never let cleanup + // mask the original writer error. + try { + fileStore.delete(stored.fileId()); + } catch (RuntimeException cleanupEx) { + log.warn( + "Failed to delete partial file {} after writer error: {}", + stored.fileId(), + cleanupEx.getMessage()); + } + if (writerErr instanceof IOException ioe) { + throw ioe; + } + throw new IOException( + "StreamingResponseBody writer failed: " + writerErr.getMessage(), + writerErr); + } + log.debug("Stored StreamingResponseBody with ID: {}", stored.fileId()); + return stored.fileId(); + } finally { + // Interrupt and join the writer task: shutdown() alone returns immediately and a + // failed store leaves the writer running, leaking a thread per failed upload. + if (task != null && !task.isDone()) { + task.cancel(true); + } + executor.shutdown(); try { - Files.deleteIfExists(filePath); - } catch (IOException cleanupEx) { - log.warn( - "Failed to clean up partial file {} after store failure", - filePath, - cleanupEx); + if (!executor.awaitTermination(5, java.util.concurrent.TimeUnit.SECONDS)) { + executor.shutdownNow(); + } + } catch (InterruptedException ie) { + executor.shutdownNow(); + Thread.currentThread().interrupt(); } } } - log.debug("Stored StreamingResponseBody with ID: {}", fileId); - return fileId; } - /** - * Persist a {@link Resource} body to disk, returning the generated file ID. Used by the async - * job pipeline to capture {@code ResponseEntity} results produced by controllers. - */ public String storeFromResource(Resource resource, String originalName) throws IOException { - String fileId = generateFileId(); - Path filePath = getFilePath(fileId); - Files.createDirectories(filePath.getParent()); - boolean success = false; try (InputStream in = resource.getInputStream()) { - Files.copy(in, filePath); - success = true; - } finally { - if (!success) { - try { - Files.deleteIfExists(filePath); - } catch (IOException cleanupEx) { - log.warn( - "Failed to clean up partial file {} after store failure", - filePath, - cleanupEx); - } - } + FileStore.Stored stored = fileStore.store(in, originalName); + log.debug("Stored Resource with ID: {}", stored.fileId()); + return stored.fileId(); } - log.debug("Stored Resource with ID: {}", fileId); - return fileId; } - /** - * Delete a file by its ID - * - * @param fileId The ID of the file to delete - * @return true if the file was deleted, false otherwise - */ public boolean deleteFile(String fileId) { - try { - Path filePath = getFilePath(fileId); - return Files.deleteIfExists(filePath); - } catch (IOException e) { - log.error("Error deleting file with ID: {}", fileId, e); - return false; - } + return fileStore.delete(fileId); } - /** - * Check if a file exists by its ID - * - * @param fileId The ID of the file to check - * @return true if the file exists, false otherwise - */ public boolean fileExists(String fileId) { - Path filePath = getFilePath(fileId); - return Files.exists(filePath); + return fileStore.exists(fileId); } - /** - * Get the size of a file by its ID without loading the content into memory - * - * @param fileId The ID of the file - * @return The size of the file in bytes - * @throws IOException If the file doesn't exist or can't be read - */ public long getFileSize(String fileId) throws IOException { - Path filePath = getFilePath(fileId); - - if (!Files.exists(filePath)) { - throw new IOException("File not found with ID: " + fileId); - } - - return Files.size(filePath); - } - - /** - * Get the path for a file ID - * - * @param fileId The ID of the file - * @return The path to the file - * @throws IllegalArgumentException if fileId contains path traversal characters or resolves - * outside base directory - */ - private Path getFilePath(String fileId) { - // Validate fileId to prevent path traversal - if (fileId.contains("..") || fileId.contains("/") || fileId.contains("\\")) { - throw new IllegalArgumentException("Invalid file ID"); - } - - Path basePath = Path.of(tempDirPath).normalize().toAbsolutePath(); - Path resolvedPath = basePath.resolve(fileId).normalize(); - - // Ensure resolved path is within the base directory - if (!resolvedPath.startsWith(basePath)) { - throw new IllegalArgumentException("File ID resolves to an invalid path"); - } - - return resolvedPath; - } - - /** - * Generate a unique file ID - * - * @return A unique file ID - */ - private String generateFileId() { - return UUID.randomUUID().toString(); + return fileStore.size(fileId); } } diff --git a/app/common/src/main/java/stirling/software/common/service/TaskManager.java b/app/common/src/main/java/stirling/software/common/service/TaskManager.java index 36a1d1857d..790e0626ac 100644 --- a/app/common/src/main/java/stirling/software/common/service/TaskManager.java +++ b/app/common/src/main/java/stirling/software/common/service/TaskManager.java @@ -3,9 +3,13 @@ package stirling.software.common.service; import java.io.BufferedInputStream; import java.io.IOException; import java.io.InputStream; +import java.time.Duration; +import java.time.Instant; import java.time.LocalDateTime; +import java.time.ZoneId; import java.time.temporal.ChronoUnit; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; import java.util.Locale; import java.util.Map; @@ -16,6 +20,7 @@ import java.util.concurrent.TimeUnit; import java.util.zip.ZipEntry; import java.util.zip.ZipInputStream; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.MediaType; import org.springframework.stereotype.Service; @@ -26,6 +31,10 @@ import jakarta.annotation.PreDestroy; import lombok.extern.slf4j.Slf4j; +import stirling.software.common.cluster.ClusterBackplane; +import stirling.software.common.cluster.JobStore; +import stirling.software.common.cluster.JobStoreEntry; +import stirling.software.common.cluster.JobStoreEntry.JobState; import stirling.software.common.model.job.JobResult; import stirling.software.common.model.job.JobStats; import stirling.software.common.model.job.ResultFile; @@ -40,20 +49,20 @@ public class TaskManager { private int jobResultExpiryMinutes = 30; private final FileStorage fileStorage; + private final JobStore jobStore; + private final ClusterBackplane clusterBackplane; private final ScheduledExecutorService cleanupExecutor = Executors.newSingleThreadScheduledExecutor( Thread.ofVirtual().name("task-cleanup-", 0).factory()); - /** Initialize the task manager and start the cleanup scheduler */ - public TaskManager(FileStorage fileStorage) { + @Autowired + public TaskManager( + FileStorage fileStorage, JobStore jobStore, ClusterBackplane clusterBackplane) { this.fileStorage = fileStorage; + this.jobStore = jobStore; + this.clusterBackplane = clusterBackplane; - // Schedule periodic cleanup of old job results - cleanupExecutor.scheduleAtFixedRate( - this::cleanupOldJobs, - 10, // Initial delay - 10, // Interval - TimeUnit.MINUTES); + cleanupExecutor.scheduleAtFixedRate(this::cleanupOldJobs, 10, 10, TimeUnit.MINUTES); log.debug( "Task manager initialized with job result expiry of {} minutes", @@ -66,7 +75,9 @@ public class TaskManager { * @param jobId The job ID */ public void createTask(String jobId) { - jobResults.put(jobId, JobResult.createNew(jobId)); + JobResult result = JobResult.createNew(jobId); + jobResults.put(jobId, result); + writeThrough(jobId, result); log.debug("Created task with job ID: {}", jobId); } @@ -79,6 +90,7 @@ public class TaskManager { public void setResult(String jobId, Object result) { JobResult jobResult = getOrCreateJobResult(jobId); jobResult.completeWithResult(result); + writeThrough(jobId, jobResult); log.debug("Set result for job ID: {}", jobId); } @@ -101,6 +113,7 @@ public class TaskManager { extractZipToIndividualFiles(fileId, originalFileName); if (!extractedFiles.isEmpty()) { jobResult.completeWithFiles(extractedFiles); + writeThrough(jobId, jobResult); log.debug( "Set multiple file results for job ID: {} with {} files extracted from" + " ZIP", @@ -127,6 +140,7 @@ public class TaskManager { "Failed to get file size for job {}: {}. Using size 0.", jobId, e.getMessage()); jobResult.completeWithSingleFile(fileId, originalFileName, contentType, 0); } + writeThrough(jobId, jobResult); } /** @@ -138,6 +152,7 @@ public class TaskManager { public void setMultipleFileResults(String jobId, List resultFiles) { JobResult jobResult = getOrCreateJobResult(jobId); jobResult.completeWithFiles(resultFiles); + writeThrough(jobId, jobResult); log.debug( "Set multiple file results for job ID: {} with {} files", jobId, @@ -153,6 +168,7 @@ public class TaskManager { public void setError(String jobId, String error) { JobResult jobResult = getOrCreateJobResult(jobId); jobResult.failWithError(error); + writeThrough(jobId, jobResult); log.debug("Set error for job ID: {}: {}", jobId, error); } @@ -169,6 +185,7 @@ public class TaskManager { // If no result or error has been set, mark it as complete with an empty result jobResult.completeWithResult("Task completed successfully"); } + writeThrough(jobId, jobResult); log.debug("Marked job ID: {} as complete", jobId); } @@ -205,6 +222,7 @@ public class TaskManager { JobResult jobResult = jobResults.get(jobId); if (jobResult != null) { jobResult.addNote(note); + writeThrough(jobId, jobResult); log.debug("Added note to job ID: {}: {}", jobId, note); return true; } @@ -295,8 +313,11 @@ public class TaskManager { return jobResults.computeIfAbsent(jobId, JobResult::createNew); } - /** Clean up old completed job results */ + /** Clean up old completed job results. No-op in cluster mode; the backplane TTL owns expiry. */ public void cleanupOldJobs() { + if (clusterBackplane != null && !clusterBackplane.shouldRunLocalCleanup()) { + return; + } LocalDateTime expiryThreshold = LocalDateTime.now().minus(jobResultExpiryMinutes, ChronoUnit.MINUTES); int removedCount = 0; @@ -315,6 +336,9 @@ public class TaskManager { // Remove the job result jobResults.remove(entry.getKey()); + if (jobStore != null) { + jobStore.delete(entry.getKey()); + } removedCount++; } } @@ -327,6 +351,53 @@ public class TaskManager { } } + /** Mirror the in-memory {@code JobResult} into the cluster-visible {@link JobStore}. */ + private void writeThrough(String jobId, JobResult result) { + if (jobStore == null) { + return; + } + try { + jobStore.put(toEntry(jobId, result), Duration.ofMinutes(jobResultExpiryMinutes)); + } catch (RuntimeException ex) { + log.warn("JobStore write-through failed for job {}: {}", jobId, ex.getMessage()); + } + } + + private JobStoreEntry toEntry(String jobId, JobResult result) { + JobState state; + if (result.isComplete()) { + state = result.getError() != null ? JobState.FAILED : JobState.COMPLETE; + } else { + state = JobState.PENDING; + } + Instant createdAt = toInstant(result.getCreatedAt()); + Instant completedAt = toInstant(result.getCompletedAt()); + List fileIds = new ArrayList<>(); + if (result.hasFiles()) { + for (ResultFile rf : result.getAllResultFiles()) { + fileIds.add(rf.getFileId()); + } + } + Map meta = new HashMap<>(); + if (result.getNotes() != null && !result.getNotes().isEmpty()) { + meta.put("notesCount", Integer.toString(result.getNotes().size())); + } + String owningNodeId = clusterBackplane == null ? "local" : clusterBackplane.localNodeId(); + return new JobStoreEntry( + jobId, + state, + owningNodeId, + createdAt, + completedAt, + result.getError(), + fileIds, + meta); + } + + private Instant toInstant(LocalDateTime ldt) { + return ldt == null ? null : ldt.atZone(ZoneId.systemDefault()).toInstant(); + } + /** Shutdown the cleanup executor */ @PreDestroy public void shutdown() { @@ -370,7 +441,7 @@ public class TaskManager { while ((entry = zipIn.getNextEntry()) != null) { if (!entry.isDirectory()) { String contentType = determineContentType(entry.getName()); - // storeInputStream returns the fileId and byte count β€” no extra stat needed + // storeInputStream returns the fileId and byte count - no extra stat needed FileStorage.StoredFile stored = fileStorage.storeInputStream(zipIn, entry.getName()); @@ -458,7 +529,8 @@ public class TaskManager { } /** - * Find the job key that owns a given file ID. + * Find the job key that owns a given file ID. Checks the local in-memory map first, then falls + * back to the cluster-visible {@link JobStore}. * * @param fileId file identifier to look up * @return scoped job key if found, otherwise null @@ -474,6 +546,18 @@ public class TaskManager { } } } + if (jobStore != null) { + // Propagate JobStore failures: returning null on a backplane outage would conflate + // "no such file" with "lookup unavailable" and the caller would respond 404 to a + // transient blip that should be retried. Let Spring's exception handler surface a + // 5xx so clients know to retry. + try { + return jobStore.findJobIdByFileId(fileId).orElse(null); + } catch (RuntimeException e) { + log.warn("JobStore findJobIdByFileId failed for {}: {}", fileId, e.getMessage()); + throw e; + } + } return null; } } diff --git a/app/common/src/test/java/stirling/software/common/architecture/ArchitectureTest.java b/app/common/src/test/java/stirling/software/common/architecture/ArchitectureTest.java index 68f22dfe84..285407dca3 100644 --- a/app/common/src/test/java/stirling/software/common/architecture/ArchitectureTest.java +++ b/app/common/src/test/java/stirling/software/common/architecture/ArchitectureTest.java @@ -56,4 +56,17 @@ class ArchitectureTest { .resideInAPackage("stirling.software.saas.."); rule.check(commonClasses); } + + @Test + void clusterInterfacesHaveNoImplementationDependencies() { + ArchRule rule = + noClasses() + .that() + .resideInAPackage("stirling.software.common.cluster..") + .should() + .dependOnClassesThat() + .resideInAnyPackage( + "stirling.software.proprietary..", "stirling.software.saas.."); + rule.check(commonClasses); + } } diff --git a/app/common/src/test/java/stirling/software/common/cluster/BackplaneContractCompilationTest.java b/app/common/src/test/java/stirling/software/common/cluster/BackplaneContractCompilationTest.java new file mode 100644 index 0000000000..9da2c41cf7 --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/cluster/BackplaneContractCompilationTest.java @@ -0,0 +1,51 @@ +package stirling.software.common.cluster; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.time.Instant; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +class BackplaneContractCompilationTest { + + @Test + void jobStoreEntryRecordRoundTrips() { + Instant now = Instant.now(); + JobStoreEntry entry = + new JobStoreEntry( + "job-1", + JobStoreEntry.JobState.PENDING, + "node-a", + now, + null, + null, + List.of("file-1"), + Map.of("k", "v")); + assertEquals("job-1", entry.jobId()); + assertEquals(JobStoreEntry.JobState.PENDING, entry.state()); + assertEquals("node-a", entry.owningNodeId()); + assertEquals(now, entry.createdAt()); + assertEquals(List.of("file-1"), entry.fileIds()); + assertEquals("v", entry.resultMeta().get("k")); + } + + @Test + void clusterNodeRecordRoundTrips() { + Instant heartbeat = Instant.now(); + ClusterNode node = new ClusterNode("node-a", "10.0.0.1:8080", heartbeat, "BOTH"); + assertEquals("node-a", node.nodeId()); + assertEquals("10.0.0.1:8080", node.internalAddress()); + assertEquals(heartbeat, node.lastHeartbeat()); + assertEquals("BOTH", node.role()); + } + + @Test + void rateLimitDecisionRecordRoundTrips() { + RateLimitStore.RateLimitDecision d = new RateLimitStore.RateLimitDecision(true, 7, 0L); + assertEquals(true, d.allowed()); + assertEquals(7, d.remainingTokens()); + assertEquals(0L, d.nanosToWaitForRefill()); + } +} diff --git a/app/common/src/test/java/stirling/software/common/cluster/ClusterConfigValidationTest.java b/app/common/src/test/java/stirling/software/common/cluster/ClusterConfigValidationTest.java new file mode 100644 index 0000000000..d87e2e8bbe --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/cluster/ClusterConfigValidationTest.java @@ -0,0 +1,65 @@ +package stirling.software.common.cluster; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.lang.reflect.Method; + +import org.junit.jupiter.api.Test; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.model.ApplicationProperties.Cluster; + +class ClusterConfigValidationTest { + + @Test + void validationPassesWhenDisabled() { + ApplicationProperties props = new ApplicationProperties(); + ClusterConfig config = new ClusterConfig(props); + assertDoesNotThrow(() -> invokeValidate(config)); + } + + @Test + void validationFailsWhenValkeyEnabledWithoutUrl() { + ApplicationProperties props = new ApplicationProperties(); + Cluster cluster = props.getCluster(); + cluster.setEnabled(true); + cluster.setBackplane("valkey"); + ClusterConfig config = new ClusterConfig(props); + assertThrows(IllegalStateException.class, () -> invokeValidate(config)); + } + + @Test + void validationPassesWhenValkeyEnabledWithUrl() { + ApplicationProperties props = new ApplicationProperties(); + Cluster cluster = props.getCluster(); + cluster.setEnabled(true); + cluster.setBackplane("valkey"); + cluster.getValkey().setUrl("redis://localhost:6379"); + ClusterConfig config = new ClusterConfig(props); + assertDoesNotThrow(() -> invokeValidate(config)); + } + + @Test + void validationPassesWhenInProcessEnabled() { + ApplicationProperties props = new ApplicationProperties(); + Cluster cluster = props.getCluster(); + cluster.setEnabled(true); + cluster.setBackplane("inprocess"); + ClusterConfig config = new ClusterConfig(props); + assertDoesNotThrow(() -> invokeValidate(config)); + } + + private void invokeValidate(ClusterConfig config) throws Exception { + Method m = ClusterConfig.class.getDeclaredMethod("validate"); + m.setAccessible(true); + try { + m.invoke(config); + } catch (java.lang.reflect.InvocationTargetException ex) { + if (ex.getCause() instanceof RuntimeException re) { + throw re; + } + throw ex; + } + } +} diff --git a/app/common/src/test/java/stirling/software/common/cluster/ClusterPropertiesTest.java b/app/common/src/test/java/stirling/software/common/cluster/ClusterPropertiesTest.java new file mode 100644 index 0000000000..8237a2252a --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/cluster/ClusterPropertiesTest.java @@ -0,0 +1,62 @@ +package stirling.software.common.cluster; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import org.junit.jupiter.api.Test; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.model.ApplicationProperties.Cluster; + +class ClusterPropertiesTest { + + @Test + void defaultsAreDisabledAndInprocess() { + Cluster props = new ApplicationProperties().getCluster(); + assertFalse(props.isEnabled()); + assertEquals("inprocess", props.getBackplane()); + assertEquals("local", props.getArtifactStore()); + assertEquals(Cluster.NodeRole.BOTH, props.resolvedRole()); + assertEquals("", props.getValkey().getUrl()); + assertFalse(props.getValkey().getTls().isSkipCertVerification()); + assertEquals("both", props.getNode().getRole()); + assertEquals("http", props.getNode().getScheme()); + assertEquals(5000L, props.getNode().getHeartbeatIntervalMs()); + } + + @Test + void resolvedRoleParsesCaseInsensitively() { + Cluster props = new ApplicationProperties().getCluster(); + props.getNode().setRole("WEB"); + assertEquals(Cluster.NodeRole.WEB, props.resolvedRole()); + + props.getNode().setRole("web"); + assertEquals(Cluster.NodeRole.WEB, props.resolvedRole()); + + props.getNode().setRole("Worker"); + assertEquals(Cluster.NodeRole.WORKER, props.resolvedRole()); + + props.getNode().setRole("garbage"); + assertEquals(Cluster.NodeRole.BOTH, props.resolvedRole()); + + props.getNode().setRole(null); + assertEquals(Cluster.NodeRole.BOTH, props.resolvedRole()); + } + + @Test + void resolvedNodeIdIsStableAcrossCalls() { + Cluster props = new ApplicationProperties().getCluster(); + String first = props.resolvedNodeId(); + String second = props.resolvedNodeId(); + assertNotNull(first); + assertEquals(first, second); + } + + @Test + void resolvedNodeIdHonoursExplicitId() { + Cluster props = new ApplicationProperties().getCluster(); + props.getNode().setId("abc"); + assertEquals("abc", props.resolvedNodeId()); + } +} diff --git a/app/common/src/test/java/stirling/software/common/cluster/InProcessConfigurationConditionalTest.java b/app/common/src/test/java/stirling/software/common/cluster/InProcessConfigurationConditionalTest.java new file mode 100644 index 0000000000..4a5a73ce37 --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/cluster/InProcessConfigurationConditionalTest.java @@ -0,0 +1,90 @@ +package stirling.software.common.cluster; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.context.PropertyPlaceholderAutoConfiguration; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import stirling.software.common.cluster.inprocess.InProcessClusterConfiguration; +import stirling.software.common.model.ApplicationProperties; + +/** + * Verifies the {@link InProcessClusterConfiguration} conditional wiring: in-process beans wire when + * cluster mode is off or {@code backplane=inprocess}, and are skipped when {@code + * backplane=valkey}. + */ +class InProcessConfigurationConditionalTest { + + private final ApplicationContextRunner runner = + new ApplicationContextRunner() + .withConfiguration( + org.springframework.boot.autoconfigure.AutoConfigurations.of( + PropertyPlaceholderAutoConfiguration.class)) + .withUserConfiguration( + TestAppPropertiesConfig.class, + ClusterConfig.class, + InProcessClusterConfiguration.class); + + @Test + void inProcessBeansWireWhenClusterDisabled() { + runner.run( + context -> + assertThat(context) + .hasNotFailed() + .hasSingleBean(ClusterBackplane.class) + .hasSingleBean(JobStore.class) + .hasSingleBean(RateLimitStore.class) + .hasSingleBean(DistributedLock.class) + .hasSingleBean(KeyValueCache.class) + .hasSingleBean(InstanceRegistry.class)); + } + + @Test + void inProcessBeansWireWhenEnabledWithInProcessBackplane() { + runner.withPropertyValues("cluster.enabled=true", "cluster.backplane=inprocess") + .run( + context -> + assertThat(context) + .hasNotFailed() + .hasSingleBean(ClusterBackplane.class) + .hasSingleBean(JobStore.class) + .hasSingleBean(RateLimitStore.class) + .hasSingleBean(DistributedLock.class) + .hasSingleBean(KeyValueCache.class) + .hasSingleBean(InstanceRegistry.class)); + } + + @Test + void inProcessBeansSkippedWhenEnabledWithDistributedBackplane() { + runner.withPropertyValues( + "cluster.enabled=true", + "cluster.backplane=valkey", + "cluster.valkey.url=redis://localhost:6379") + .run( + context -> + assertThat(context) + .hasNotFailed() + .doesNotHaveBean(ClusterBackplane.class) + .doesNotHaveBean(JobStore.class) + .doesNotHaveBean(RateLimitStore.class) + .doesNotHaveBean(DistributedLock.class) + .doesNotHaveBean(KeyValueCache.class) + .doesNotHaveBean(InstanceRegistry.class)); + } + + /** + * Hand-rolled {@link ApplicationProperties} bean: the production class loads YAML at startup + * via a {@code @PostConstruct} hook that isn't appropriate for the slice runner, so we wire a + * defaults-only instance here. + */ + @Configuration + static class TestAppPropertiesConfig { + @Bean + ApplicationProperties applicationProperties() { + return new ApplicationProperties(); + } + } +} diff --git a/app/common/src/test/java/stirling/software/common/cluster/inprocess/InProcessDistributedLockTest.java b/app/common/src/test/java/stirling/software/common/cluster/inprocess/InProcessDistributedLockTest.java new file mode 100644 index 0000000000..30b738a3b9 --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/cluster/inprocess/InProcessDistributedLockTest.java @@ -0,0 +1,167 @@ +package stirling.software.common.cluster.inprocess; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.Duration; +import java.util.Optional; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +import org.junit.jupiter.api.Test; + +import stirling.software.common.cluster.DistributedLock; + +class InProcessDistributedLockTest { + + @Test + void acquireReleaseAcquire() { + DistributedLock lock = new InProcessDistributedLock(); + DistributedLock.LockHandle h1 = lock.tryAcquire("k", Duration.ofSeconds(30)).orElseThrow(); + h1.release(); + assertTrue(lock.tryAcquire("k", Duration.ofSeconds(30)).isPresent()); + } + + @Test + void reentryFromSameThreadFails() { + DistributedLock lock = new InProcessDistributedLock(); + DistributedLock.LockHandle h1 = lock.tryAcquire("k", Duration.ofSeconds(30)).orElseThrow(); + Optional reentry = lock.tryAcquire("k", Duration.ofSeconds(30)); + assertFalse(reentry.isPresent(), "in-process lock must be non-reentrant"); + h1.release(); + // After release, anyone can acquire again. + assertTrue(lock.tryAcquire("k", Duration.ofSeconds(30)).isPresent()); + } + + @Test + void secondAcquireFromAnotherThreadFails() throws InterruptedException { + DistributedLock lock = new InProcessDistributedLock(); + DistributedLock.LockHandle h1 = lock.tryAcquire("k", Duration.ofSeconds(30)).orElseThrow(); + + CountDownLatch done = new CountDownLatch(1); + AtomicBoolean acquired = new AtomicBoolean(true); + Thread t = + new Thread( + () -> { + Optional attempt = + lock.tryAcquire("k", Duration.ofSeconds(30)); + acquired.set(attempt.isPresent()); + attempt.ifPresent(DistributedLock.LockHandle::release); + done.countDown(); + }); + t.start(); + assertTrue(done.await(2, TimeUnit.SECONDS)); + assertFalse(acquired.get()); + h1.release(); + } + + @Test + void leaseExpiryAllowsTakeoverEvenWithoutRelease() throws InterruptedException { + // Acquire with a short lease, never call release, then try to acquire again after the + // lease has elapsed. Matches Redis SET-NX-EX semantics - the second caller gets the lock + // because the first lease auto-expired. 250ms lease + 350ms wait gives CI generous slack. + DistributedLock lock = new InProcessDistributedLock(); + DistributedLock.LockHandle h1 = lock.tryAcquire("k", Duration.ofMillis(250)).orElseThrow(); + Thread.sleep(350); + Optional takeover = + lock.tryAcquire("k", Duration.ofSeconds(30)); + assertTrue( + takeover.isPresent(), + "expired lease must release the lock so a new caller can take over"); + // Calling release() on the original handle after takeover must be a no-op (token check). + h1.release(); + // The takeover holder is still the legitimate owner. + assertFalse(lock.tryAcquire("k", Duration.ofSeconds(30)).isPresent()); + takeover.get().release(); + } + + @Test + void renewExtendsLease() throws InterruptedException { + // Acquire with a short lease, renew it before it expires, then verify the lock is still + // held past the original expiry point. 200ms initial + renew to 2s + wait 350ms. + DistributedLock lock = new InProcessDistributedLock(); + DistributedLock.LockHandle h1 = lock.tryAcquire("k", Duration.ofMillis(200)).orElseThrow(); + assertTrue(h1.renew(Duration.ofSeconds(2)), "renew on a held lease must succeed"); + Thread.sleep(350); + assertFalse( + lock.tryAcquire("k", Duration.ofSeconds(30)).isPresent(), + "renew should have pushed expiry well past the original 200ms"); + h1.release(); + } + + @Test + void renewAfterReleaseFails() { + DistributedLock lock = new InProcessDistributedLock(); + DistributedLock.LockHandle h1 = lock.tryAcquire("k", Duration.ofSeconds(30)).orElseThrow(); + h1.release(); + assertFalse(h1.renew(Duration.ofSeconds(30)), "renew on a released handle must fail"); + } + + /** + * Concurrency stress: many threads contending on the same key with each holder respecting the + * lease (hold << lease). The lock behaves as a strict mutex in this regime so asserting + * mutual exclusion is meaningful. A separate test ({@link + * #leaseExpiryAllowsTakeoverEvenWithoutRelease}) covers the takeover-across-expiry branch, + * which legitimately allows two holders momentarily and is split-brain behaviour inherent to + * any lease-based lock. + */ + @Test + void concurrentContentionPreservesMutualExclusion() throws InterruptedException { + DistributedLock lock = new InProcessDistributedLock(); + int threads = 16; + int attemptsPerThread = 200; + // Lease far exceeds any plausible hold time, so the takeover branch never triggers in + // this test and the lock acts as a strict mutex. + Duration lease = Duration.ofSeconds(5); + java.util.concurrent.atomic.AtomicInteger concurrentHolders = + new java.util.concurrent.atomic.AtomicInteger(); + java.util.concurrent.atomic.AtomicInteger maxConcurrent = + new java.util.concurrent.atomic.AtomicInteger(); + java.util.concurrent.atomic.AtomicInteger acquires = + new java.util.concurrent.atomic.AtomicInteger(); + java.util.concurrent.atomic.AtomicReference firstFailure = + new java.util.concurrent.atomic.AtomicReference<>(); + CountDownLatch start = new CountDownLatch(1); + CountDownLatch done = new CountDownLatch(threads); + + for (int i = 0; i < threads; i++) { + new Thread( + () -> { + try { + start.await(); + for (int j = 0; j < attemptsPerThread; j++) { + Optional h = + lock.tryAcquire("hot", lease); + if (h.isPresent()) { + int now = concurrentHolders.incrementAndGet(); + maxConcurrent.accumulateAndGet(now, Math::max); + acquires.incrementAndGet(); + // Trivial critical section; well within lease. + concurrentHolders.decrementAndGet(); + h.get().release(); + } + } + } catch (Throwable t) { + firstFailure.compareAndSet(null, t); + } finally { + done.countDown(); + } + }, + "lock-stress-" + i) + .start(); + } + start.countDown(); + assertTrue(done.await(30, TimeUnit.SECONDS), "stress workers must finish in time"); + org.junit.jupiter.api.Assertions.assertNull(firstFailure.get(), "no worker may throw"); + org.junit.jupiter.api.Assertions.assertEquals( + 1, + maxConcurrent.get(), + "mutual exclusion violated: more than one holder observed simultaneously"); + assertTrue( + acquires.get() > 0, + "at least some acquires must succeed under contention (saw " + + acquires.get() + + ")"); + } +} diff --git a/app/common/src/test/java/stirling/software/common/cluster/inprocess/InProcessInstanceRegistryTest.java b/app/common/src/test/java/stirling/software/common/cluster/inprocess/InProcessInstanceRegistryTest.java new file mode 100644 index 0000000000..c19eb444f7 --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/cluster/inprocess/InProcessInstanceRegistryTest.java @@ -0,0 +1,28 @@ +package stirling.software.common.cluster.inprocess; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.Duration; +import java.time.Instant; + +import org.junit.jupiter.api.Test; + +import stirling.software.common.cluster.ClusterNode; + +class InProcessInstanceRegistryTest { + + @Test + void registerThenLookupAndActiveNodes() { + InProcessInstanceRegistry registry = new InProcessInstanceRegistry(); + ClusterNode node = new ClusterNode("node-1", "127.0.0.1:8080", Instant.now(), "BOTH"); + registry.register(node, Duration.ofSeconds(30)); + + assertTrue(registry.lookup("node-1").isPresent()); + assertEquals("node-1", registry.lookup("node-1").get().nodeId()); + assertEquals(1, registry.activeNodes().size()); + + registry.deregister("node-1"); + assertTrue(registry.lookup("node-1").isEmpty()); + } +} diff --git a/app/common/src/test/java/stirling/software/common/cluster/inprocess/InProcessJobStoreTest.java b/app/common/src/test/java/stirling/software/common/cluster/inprocess/InProcessJobStoreTest.java new file mode 100644 index 0000000000..33eaebe35c --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/cluster/inprocess/InProcessJobStoreTest.java @@ -0,0 +1,91 @@ +package stirling.software.common.cluster.inprocess; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import stirling.software.common.cluster.JobStoreEntry; + +class InProcessJobStoreTest { + + private final InProcessJobStore store = new InProcessJobStore(); + + @Test + void putGetDeleteExistsRoundTrip() { + JobStoreEntry entry = entry("job-1"); + store.put(entry, Duration.ofMinutes(30)); + + assertTrue(store.exists("job-1")); + assertEquals(entry, store.get("job-1").orElseThrow()); + + store.delete("job-1"); + assertFalse(store.exists("job-1")); + } + + @Test + void ttlExpiry() throws InterruptedException { + store.put(entry("job-2"), Duration.ofMillis(50)); + Thread.sleep(100); + assertFalse(store.get("job-2").isPresent()); + } + + @Test + void purgeExpiredRemovesOnlyStaleEntries() throws InterruptedException { + store.put(entry("job-fresh"), Duration.ofMinutes(30)); + store.put(entry("job-stale"), Duration.ofMillis(20)); + Thread.sleep(80); + + int removed = store.purgeExpired(); + assertEquals(1, removed); + assertTrue(store.exists("job-fresh")); + } + + @Test + void findJobIdByFileIdReturnsTheRightJob() { + store.put( + new JobStoreEntry( + "job-a", + JobStoreEntry.JobState.COMPLETE, + "node-1", + Instant.now(), + Instant.now(), + null, + List.of("file-1", "file-2"), + Map.of()), + Duration.ofMinutes(30)); + store.put( + new JobStoreEntry( + "job-b", + JobStoreEntry.JobState.COMPLETE, + "node-1", + Instant.now(), + Instant.now(), + null, + List.of("file-3"), + Map.of()), + Duration.ofMinutes(30)); + + assertEquals("job-a", store.findJobIdByFileId("file-1").orElseThrow()); + assertEquals("job-b", store.findJobIdByFileId("file-3").orElseThrow()); + assertFalse(store.findJobIdByFileId("missing").isPresent()); + } + + private JobStoreEntry entry(String id) { + return new JobStoreEntry( + id, + JobStoreEntry.JobState.PENDING, + "node-1", + Instant.now(), + null, + null, + List.of(), + Map.of()); + } +} diff --git a/app/common/src/test/java/stirling/software/common/cluster/inprocess/InProcessKeyValueCacheTest.java b/app/common/src/test/java/stirling/software/common/cluster/inprocess/InProcessKeyValueCacheTest.java new file mode 100644 index 0000000000..f0afc97890 --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/cluster/inprocess/InProcessKeyValueCacheTest.java @@ -0,0 +1,41 @@ +package stirling.software.common.cluster.inprocess; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; + +import java.time.Duration; + +import org.junit.jupiter.api.Test; + +import stirling.software.common.cluster.KeyValueCache; + +class InProcessKeyValueCacheTest { + + @Test + void putGetEvict() { + KeyValueCache cache = new InProcessKeyValueCache(); + cache.put("apikey", "a", "userA", Duration.ofMinutes(1)); + assertEquals("userA", cache.get("apikey", "a").orElseThrow()); + + cache.evict("apikey", "a"); + assertFalse(cache.get("apikey", "a").isPresent()); + } + + @Test + void ttlExpiry() throws InterruptedException { + KeyValueCache cache = new InProcessKeyValueCache(); + cache.put("ns", "k", "v", Duration.ofMillis(40)); + Thread.sleep(80); + assertFalse(cache.get("ns", "k").isPresent()); + } + + @Test + void evictNamespace() { + KeyValueCache cache = new InProcessKeyValueCache(); + cache.put("ns", "a", "1", Duration.ofMinutes(1)); + cache.put("ns", "b", "2", Duration.ofMinutes(1)); + cache.evictNamespace("ns"); + assertFalse(cache.get("ns", "a").isPresent()); + assertFalse(cache.get("ns", "b").isPresent()); + } +} diff --git a/app/common/src/test/java/stirling/software/common/cluster/inprocess/InProcessRateLimitStoreTest.java b/app/common/src/test/java/stirling/software/common/cluster/inprocess/InProcessRateLimitStoreTest.java new file mode 100644 index 0000000000..8aecf259bc --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/cluster/inprocess/InProcessRateLimitStoreTest.java @@ -0,0 +1,56 @@ +package stirling.software.common.cluster.inprocess; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.Duration; + +import org.junit.jupiter.api.Test; + +import stirling.software.common.cluster.RateLimitStore; +import stirling.software.common.cluster.RateLimitStore.RateLimitDecision; + +class InProcessRateLimitStoreTest { + + @Test + void firstNConsumesAllowed() { + RateLimitStore store = new InProcessRateLimitStore(); + for (int i = 0; i < 5; i++) { + assertTrue(store.tryConsume("k", 5, Duration.ofSeconds(60)).allowed(), "i=" + i); + } + assertFalse(store.tryConsume("k", 5, Duration.ofSeconds(60)).allowed()); + } + + @Test + void remainingTokensDecrements() { + RateLimitStore store = new InProcessRateLimitStore(); + RateLimitDecision d1 = store.tryConsume("k", 5, Duration.ofSeconds(60)); + RateLimitDecision d2 = store.tryConsume("k", 5, Duration.ofSeconds(60)); + assertTrue(d1.allowed()); + assertTrue(d2.allowed()); + assertEquals(4, d1.remainingTokens()); + assertEquals(3, d2.remainingTokens()); + } + + @Test + void refillRestoresTokens() throws InterruptedException { + RateLimitStore store = new InProcessRateLimitStore(); + // Capacity 2 with smooth refill over 100 ms -> ~1 token per 50 ms. + for (int i = 0; i < 2; i++) { + assertTrue(store.tryConsume("k", 2, Duration.ofMillis(100)).allowed()); + } + assertFalse(store.tryConsume("k", 2, Duration.ofMillis(100)).allowed()); + Thread.sleep(150); + assertTrue(store.tryConsume("k", 2, Duration.ofMillis(100)).allowed()); + } + + @Test + void deniedConsumeReportsWaitNanos() { + RateLimitStore store = new InProcessRateLimitStore(); + assertTrue(store.tryConsume("wait", 1, Duration.ofSeconds(10)).allowed()); + RateLimitDecision denied = store.tryConsume("wait", 1, Duration.ofSeconds(10)); + assertFalse(denied.allowed()); + assertTrue(denied.nanosToWaitForRefill() > 0L); + } +} diff --git a/app/common/src/test/java/stirling/software/common/cluster/inprocess/LocalDiskFileStoreTest.java b/app/common/src/test/java/stirling/software/common/cluster/inprocess/LocalDiskFileStoreTest.java new file mode 100644 index 0000000000..296d012c8f --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/cluster/inprocess/LocalDiskFileStoreTest.java @@ -0,0 +1,42 @@ +package stirling.software.common.cluster.inprocess; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.file.Path; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import stirling.software.common.cluster.FileStore; + +class LocalDiskFileStoreTest { + + @Test + void storeRetrieveSizeDeleteExistsRoundTrip(@TempDir Path dir) throws IOException { + LocalDiskFileStore store = new LocalDiskFileStore(dir.toString()); + byte[] payload = "hello-bytes".getBytes(); + + FileStore.Stored stored = store.store(new ByteArrayInputStream(payload), "x.txt"); + assertEquals(payload.length, stored.size()); + assertTrue(store.exists(stored.fileId())); + assertEquals(payload.length, store.size(stored.fileId())); + assertArrayEquals(payload, store.retrieveBytes(stored.fileId())); + + assertTrue(store.delete(stored.fileId())); + assertFalse(store.exists(stored.fileId())); + } + + @Test + void traversalIdsAreRejected(@TempDir Path dir) { + LocalDiskFileStore store = new LocalDiskFileStore(dir.toString()); + assertThrows(IllegalArgumentException.class, () -> store.resolve("../foo")); + assertThrows(IllegalArgumentException.class, () -> store.resolve("a/b")); + assertThrows(IllegalArgumentException.class, () -> store.resolve("a\\b")); + } +} diff --git a/app/common/src/test/java/stirling/software/common/service/FileStorageDelegationTest.java b/app/common/src/test/java/stirling/software/common/service/FileStorageDelegationTest.java new file mode 100644 index 0000000000..a929a15af2 --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/service/FileStorageDelegationTest.java @@ -0,0 +1,27 @@ +package stirling.software.common.service; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.mockito.Mockito.mock; + +import java.io.IOException; +import java.nio.file.Path; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import stirling.software.common.cluster.inprocess.LocalDiskFileStore; + +class FileStorageDelegationTest { + + @Test + void storeBytesThenRetrieveBytesRoundTripsThroughFileStore(@TempDir Path tempDir) + throws IOException { + FileStorage fs = + new FileStorage( + mock(FileOrUploadService.class), + new LocalDiskFileStore(tempDir.toString())); + byte[] payload = "round-trip".getBytes(); + String id = fs.storeBytes(payload, "x.bin"); + assertArrayEquals(payload, fs.retrieveBytes(id)); + } +} diff --git a/app/common/src/test/java/stirling/software/common/service/FileStorageTest.java b/app/common/src/test/java/stirling/software/common/service/FileStorageTest.java index 5b06747a57..ace0dfa567 100644 --- a/app/common/src/test/java/stirling/software/common/service/FileStorageTest.java +++ b/app/common/src/test/java/stirling/software/common/service/FileStorageTest.java @@ -3,6 +3,7 @@ package stirling.software.common.service; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; +import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; import java.nio.charset.StandardCharsets; @@ -13,29 +14,30 @@ import java.util.stream.Stream; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; -import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.MockitoAnnotations; import org.springframework.core.io.ByteArrayResource; import org.springframework.core.io.Resource; import org.springframework.http.MediaType; -import org.springframework.test.util.ReflectionTestUtils; import org.springframework.web.multipart.MultipartFile; +import stirling.software.common.cluster.inprocess.LocalDiskFileStore; + class FileStorageTest { @TempDir Path tempDir; @Mock private FileOrUploadService fileOrUploadService; - @InjectMocks private FileStorage fileStorage; + private FileStorage fileStorage; private MultipartFile mockFile; @BeforeEach - void setUp() { + void setUp() throws IOException { MockitoAnnotations.openMocks(this); - ReflectionTestUtils.setField(fileStorage, "tempDirPath", tempDir.toString()); + fileStorage = + new FileStorage(fileOrUploadService, new LocalDiskFileStore(tempDir.toString())); // Create a mock MultipartFile mockFile = mock(MultipartFile.class); @@ -47,17 +49,7 @@ class FileStorageTest { void testStoreFile() throws IOException { // Arrange byte[] fileContent = "Test PDF content".getBytes(); - when(mockFile.getBytes()).thenReturn(fileContent); - - // Set up mock to handle transferTo by writing the file - doAnswer( - invocation -> { - java.io.File file = invocation.getArgument(0); - Files.write(file.toPath(), fileContent); - return null; - }) - .when(mockFile) - .transferTo(any(java.io.File.class)); + when(mockFile.getInputStream()).thenReturn(new ByteArrayInputStream(fileContent)); // Act String fileId = fileStorage.storeFile(mockFile); @@ -65,7 +57,7 @@ class FileStorageTest { // Assert assertNotNull(fileId); assertTrue(Files.exists(tempDir.resolve(fileId))); - verify(mockFile).transferTo(any(java.io.File.class)); + assertArrayEquals(fileContent, Files.readAllBytes(tempDir.resolve(fileId))); } @Test @@ -247,11 +239,11 @@ class FileStorageTest { filesBefore = s.count(); } - // Act + Assert: IOException must propagate out β€” not be swallowed. + // Act + Assert: IOException must propagate out - not be swallowed. assertThrows( IOException.class, () -> fileStorage.storeFromResource(flakyResource, "n.pdf")); - // Assert: no partial file lingers under the storage directory β€” the finally + // Assert: no partial file lingers under the storage directory - the finally // branch's deleteIfExists must have cleaned it up. long filesAfter; try (Stream s = Files.list(tempDir)) { diff --git a/app/common/src/test/java/stirling/software/common/service/TaskManagerJobStoreDelegationTest.java b/app/common/src/test/java/stirling/software/common/service/TaskManagerJobStoreDelegationTest.java new file mode 100644 index 0000000000..1316c3c137 --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/service/TaskManagerJobStoreDelegationTest.java @@ -0,0 +1,120 @@ +package stirling.software.common.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; + +import java.time.LocalDateTime; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.test.util.ReflectionTestUtils; + +import stirling.software.common.cluster.ClusterBackplane; +import stirling.software.common.cluster.JobStoreEntry; +import stirling.software.common.cluster.JobStoreEntry.JobState; +import stirling.software.common.cluster.inprocess.InProcessClusterBackplane; +import stirling.software.common.cluster.inprocess.InProcessJobStore; +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.model.job.JobResult; + +class TaskManagerJobStoreDelegationTest { + + @Mock private FileStorage fileStorage; + + private InProcessJobStore jobStore; + private ClusterBackplane backplane; + private TaskManager taskManager; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + jobStore = spy(new InProcessJobStore()); + backplane = new InProcessClusterBackplane(new ApplicationProperties()); + taskManager = new TaskManager(fileStorage, jobStore, backplane); + ReflectionTestUtils.setField(taskManager, "jobResultExpiryMinutes", 30); + } + + @Test + void createTaskWritesPendingEntry() { + taskManager.createTask("job-1"); + JobStoreEntry entry = jobStore.get("job-1").orElseThrow(); + assertEquals(JobState.PENDING, entry.state()); + assertEquals(backplane.localNodeId(), entry.owningNodeId()); + } + + @Test + void setCompleteFlipsToComplete() { + taskManager.createTask("job-2"); + taskManager.setResult("job-2", "ok"); + taskManager.setComplete("job-2"); + JobStoreEntry entry = jobStore.get("job-2").orElseThrow(); + assertEquals(JobState.COMPLETE, entry.state()); + } + + @Test + void setErrorFlipsToFailed() { + taskManager.createTask("job-3"); + taskManager.setError("job-3", "boom"); + JobStoreEntry entry = jobStore.get("job-3").orElseThrow(); + assertEquals(JobState.FAILED, entry.state()); + assertEquals("boom", entry.error()); + } + + @Test + void cleanupOldJobsIsNoopWhenBackplaneIsNotInProcess() { + ClusterBackplane mockedValkeyBackplane = + new ClusterBackplane() { + @Override + public boolean isHealthy() { + return true; + } + + @Override + public String backplaneType() { + return "valkey"; + } + + @Override + public String localNodeId() { + return "node-1"; + } + + @Override + public boolean shouldRunLocalCleanup() { + return false; + } + }; + TaskManager tm = new TaskManager(fileStorage, jobStore, mockedValkeyBackplane); + ReflectionTestUtils.setField(tm, "jobResultExpiryMinutes", 30); + tm.createTask("job-4"); + tm.setComplete("job-4"); + ageJobPastExpiry(tm, "job-4"); + tm.cleanupOldJobs(); + // cleanup must short-circuit before touching jobStore in cluster mode; the backplane + // TTL owns expiry there. If the gate fired correctly, delete is never called. + verify(jobStore, never()).delete(any()); + } + + @Test + void cleanupOldJobsDeletesFromJobStoreWhenBackplaneIsInProcess() { + taskManager.createTask("job-5"); + taskManager.setComplete("job-5"); + ageJobPastExpiry(taskManager, "job-5"); + taskManager.cleanupOldJobs(); + verify(jobStore).delete("job-5"); + } + + @SuppressWarnings("unchecked") + private static void ageJobPastExpiry(TaskManager tm, String jobId) { + var jobResults = + (java.util.Map) ReflectionTestUtils.getField(tm, "jobResults"); + JobResult result = jobResults.get(jobId); + ReflectionTestUtils.setField(result, "completedAt", LocalDateTime.now().minusHours(2)); + ReflectionTestUtils.setField(result, "complete", true); + } +} diff --git a/app/common/src/test/java/stirling/software/common/service/TaskManagerTest.java b/app/common/src/test/java/stirling/software/common/service/TaskManagerTest.java index 7a61270d1d..9d880d3451 100644 --- a/app/common/src/test/java/stirling/software/common/service/TaskManagerTest.java +++ b/app/common/src/test/java/stirling/software/common/service/TaskManagerTest.java @@ -1,20 +1,28 @@ package stirling.software.common.service; import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.*; import java.time.LocalDateTime; import java.util.Map; +import java.util.Optional; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.MockitoAnnotations; import org.springframework.http.MediaType; import org.springframework.test.util.ReflectionTestUtils; +import stirling.software.common.cluster.ClusterBackplane; +import stirling.software.common.cluster.JobStore; +import stirling.software.common.cluster.JobStoreEntry; +import stirling.software.common.cluster.JobStoreEntry.JobState; import stirling.software.common.model.job.JobResult; import stirling.software.common.model.job.JobStats; import stirling.software.common.model.job.ResultFile; @@ -22,6 +30,8 @@ import stirling.software.common.model.job.ResultFile; class TaskManagerTest { @Mock private FileStorage fileStorage; + @Mock private JobStore jobStore; + @Mock private ClusterBackplane clusterBackplane; @InjectMocks private TaskManager taskManager; @@ -30,6 +40,10 @@ class TaskManagerTest { @BeforeEach void setUp() { closeable = MockitoAnnotations.openMocks(this); + // Treat the backplane as in-process so cleanupOldJobs is not short-circuited. + lenient().when(clusterBackplane.backplaneType()).thenReturn("inprocess"); + lenient().when(clusterBackplane.localNodeId()).thenReturn("test-node"); + lenient().when(clusterBackplane.shouldRunLocalCleanup()).thenReturn(true); ReflectionTestUtils.setField(taskManager, "jobResultExpiryMinutes", 30); } @@ -270,6 +284,33 @@ class TaskManagerTest { verify(fileStorage).deleteFile("file-id"); } + @Test + void testCleanupOldJobs_NoOpWhenBackplaneOwnsExpiry() { + // When the backplane reports it should NOT run local cleanup (e.g. a distributed + // backplane with its own TTL), the cleanup loop must leave local state untouched. + when(clusterBackplane.shouldRunLocalCleanup()).thenReturn(false); + + // Seed an old completed job that would normally be removed. + String oldJobId = "old-job-distributed"; + taskManager.createTask(oldJobId); + JobResult oldJob = taskManager.getJobResult(oldJobId); + ReflectionTestUtils.setField(oldJob, "completedAt", LocalDateTime.now().minusHours(1)); + ReflectionTestUtils.setField(oldJob, "complete", true); + + Map jobResultsMap = + (Map) ReflectionTestUtils.getField(taskManager, "jobResults"); + assertNotNull(jobResultsMap); + assertTrue(jobResultsMap.containsKey(oldJobId)); + + // Act + taskManager.cleanupOldJobs(); + + // Assert: nothing was removed locally, and no jobStore.delete was issued. + assertTrue(jobResultsMap.containsKey(oldJobId)); + verify(jobStore, never()).delete(anyString()); + verify(fileStorage, never()).deleteFile(anyString()); + } + @Test void testShutdown() { // This mainly tests that the shutdown method doesn't throw exceptions @@ -310,4 +351,33 @@ class TaskManagerTest { // Assert assertFalse(result); } + + @Test + void testWriteThroughOnUpdate() { + // Mutating calls must write through to the injected JobStore. + String jobId = "write-through-job"; + taskManager.createTask(jobId); + taskManager.setResult(jobId, "done"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(JobStoreEntry.class); + verify(jobStore, atLeast(2)).put(captor.capture(), any()); + + JobStoreEntry last = captor.getValue(); + assertEquals(jobId, last.jobId()); + assertEquals(JobState.COMPLETE, last.state()); + assertEquals("test-node", last.owningNodeId()); + } + + @Test + void testFindJobKeyByFileId_FallsBackToJobStore() { + // When the file id is not in the local map, TaskManager delegates to JobStore. + String fileId = "remote-file-id"; + String expectedJobKey = "remote-job-key"; + when(jobStore.findJobIdByFileId(fileId)).thenReturn(Optional.of(expectedJobKey)); + + String actual = taskManager.findJobKeyByFileId(fileId); + + assertEquals(expectedJobKey, actual); + verify(jobStore).findJobIdByFileId(fileId); + } } diff --git a/app/core/src/main/resources/settings.yml.template b/app/core/src/main/resources/settings.yml.template index 2540c2f512..b6eca43321 100644 --- a/app/core/src/main/resources/settings.yml.template +++ b/app/core/src/main/resources/settings.yml.template @@ -330,6 +330,22 @@ aiEngine: url: http://localhost:5001 # URL of the Python AI engine timeoutSeconds: 120 # Timeout in seconds for AI engine requests +# Cluster configuration. NOT YET ENABLED - scaffolding for later work. Leave at defaults. +cluster: + enabled: false # Master switch. 'false' (default) wires the in-process backplane and skips all cluster checks. Single-instance installs do not need to change anything here. + backplane: inprocess # Backplane implementation: 'inprocess' (single JVM only) or 'valkey' (multi-node via Valkey/Redis) + artifactStore: local # Transient cluster job-artifact backend: 'local' (per-node disk; single-node only) or 's3' (shared object store; required for multi-node). Distinct from 'storage.provider' which controls persistent user uploads - when both are 's3' they share the storage.s3.* credentials block. Multi-node deployments MUST set this to 's3'. + valkey: + url: "" # Valkey/Redis URL, e.g. 'redis://valkey:6379' or 'rediss://...' for TLS. Required when enabled=true and backplane=valkey. + tls: + skipCertVerification: false # set to 'true' to skip TLS certificate verification on Valkey connections (dev/test only) + node: + id: "" # Optional explicit node id. Blank = auto-generated UUID at startup. + role: both # 'web' (serves HTTP), 'worker' (runs jobs), or 'both' (default) + internalAddress: "" # host:port advertised in the instance registry for peer-to-peer cluster traffic. Blank = derived at startup. + scheme: http # 'http' or 'https' - scheme peers use to call this node's /internal/cluster/** endpoints + heartbeatIntervalMs: 5000 # Heartbeat publish interval for the instance registry (ms) + pdfEditor: fallback-font: classpath:/static/fonts/NotoSans-Regular.ttf # Override to point at a custom fallback font cache: From d42b7796443ca1c738097f4aebb74c49e475b37f Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Wed, 27 May 2026 12:52:46 +0100 Subject: [PATCH 10/30] Add server-side folders and files page UI (#6383) --- .github/workflows/PR-Auto-Deploy-V2.yml | 3 +- .gitignore | 3 + .../software/common/util/RequestUriUtils.java | 14 +- .../common/util/RequestUriUtilsTest.java | 15 +- .../web/ReactRoutingController.java | 9 +- .../exception/GlobalExceptionHandler.java | 42 +- .../FileFolderPlacementController.java | 91 + .../storage/controller/FolderController.java | 70 + .../proprietary/storage/model/Folder.java | 104 + .../proprietary/storage/model/StoredFile.java | 19 +- .../model/api/CreateFolderRequest.java | 43 + .../storage/model/api/FolderResponse.java | 38 + .../storage/model/api/StoredFileResponse.java | 7 + .../model/api/UpdateFolderRequest.java | 58 + .../storage/repository/FolderRepository.java | 35 + .../repository/StoredFileRepository.java | 7 + .../storage/service/FileStorageService.java | 1 + .../storage/service/FolderService.java | 417 ++++ .../storage/service/FolderServiceTest.java | 298 +++ docker/base/Dockerfile | 3 +- docker/embedded/Dockerfile | 3 +- docker/embedded/Dockerfile.fat | 3 +- docker/embedded/Dockerfile.ultra-lite | 4 +- .../docker-compose-latest-fat-security.yml | 2 +- docker/embedded/compose/test_cicd.yml | 6 + frontend/editor/playwright.config.ts | 16 +- .../public/locales/en-GB/translation.toml | 362 +++- .../src/core/components/AppProviders.tsx | 69 +- .../src/core/components/FileManager.tsx | 9 +- .../filesPage/DeleteFolderDialog.tsx | 130 ++ .../components/filesPage/FileDetailsPanel.tsx | 658 +++++++ .../core/components/filesPage/FileGrid.tsx | 1380 ++++++++++++++ .../components/filesPage/FileManagerView.tsx | 1698 +++++++++++++++++ .../components/filesPage/FileOriginBadge.tsx | 97 + .../core/components/filesPage/FilesPage.css | 1406 ++++++++++++++ .../filesPage/FolderAppearancePicker.tsx | 178 ++ .../components/filesPage/FolderNameDialog.tsx | 111 ++ .../components/filesPage/FolderThumbnail.tsx | 170 ++ .../components/filesPage/FolderTreePanel.css | 198 ++ .../components/filesPage/FolderTreePanel.tsx | 169 ++ .../filesPage/FolderTreeSidebar.tsx | 490 +++++ .../filesPage/MoveToFolderDialog.tsx | 357 ++++ .../src/core/components/filesPage/dragDrop.ts | 39 + .../core/components/filesPage/fileOrigin.ts | 24 + .../filesPage/filesPageReturnRoute.ts | 91 + .../core/components/filesPage/folderIcons.ts | 41 + .../components/filesPage/folderTreeWidth.ts | 83 + .../components/filesPage/useDropTarget.ts | 121 ++ .../src/core/components/layout/Workbench.tsx | 52 +- .../core/components/shared/AppConfigModal.tsx | 37 +- .../core/components/shared/FileSidebar.css | 56 +- .../core/components/shared/FileSidebar.tsx | 471 +++-- .../core/components/shared/QuickAccessBar.tsx | 8 +- .../core/components/shared/WorkbenchBar.css | 20 +- .../core/components/shared/WorkbenchBar.tsx | 61 +- .../editor/src/core/contexts/FileContext.tsx | 2 + .../src/core/contexts/FilesPageContext.tsx | 559 ++++++ .../src/core/contexts/FolderContext.test.tsx | 154 ++ .../src/core/contexts/FolderContext.tsx | 645 +++++++ .../src/core/contexts/IndexedDBContext.tsx | 86 +- .../src/core/contexts/file/fileActions.ts | 7 +- .../editor/src/core/hooks/useFileHandler.ts | 7 +- frontend/editor/src/core/pages/HomePage.css | 10 + frontend/editor/src/core/pages/HomePage.tsx | 468 +++-- .../editor/src/core/services/fileStorage.ts | 175 +- .../src/core/services/fileSyncService.ts | 498 +++++ .../editor/src/core/services/folderStorage.ts | 119 ++ .../src/core/services/folderSyncService.ts | 146 ++ .../indexedDBManager.migration.test.ts | 196 ++ .../src/core/services/indexedDBManager.ts | 163 +- .../src/core/tests/helpers/ui-helpers.ts | 32 +- .../live/encrypted-unlock-then-tool.spec.ts | 6 +- .../src/core/tests/stubbed/convert.spec.ts | 22 +- .../stubbed/encrypted-pdf-unlock.spec.ts | 16 +- .../stubbed/file-state-across-tools.spec.ts | 9 +- .../stubbed/files-page-screenshots.spec.ts | 554 ++++++ .../src/core/tests/stubbed/files-page.spec.ts | 819 ++++++++ .../tests/stubbed/pdf-text-search.spec.ts | 13 +- .../src/core/tests/stubbed/settings.spec.ts | 37 + .../tests/stubbed/tour-onboarding.spec.ts | 44 +- .../stubbed/unsaved-changes-guard.spec.ts | 4 +- frontend/editor/src/core/types/file.ts | 13 + frontend/editor/src/core/types/folder.ts | 104 + frontend/editor/src/core/types/workbench.ts | 1 + .../src/core/utils/homePageNavigation.ts | 6 + frontend/package-lock.json | 11 + frontend/package.json | 4 + scripts/init-without-ocr.sh | 8 +- .../features/folders_and_files.feature | 147 ++ .../steps/folders_step_definitions.py | 401 ++++ 90 files changed, 14720 insertions(+), 663 deletions(-) create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/storage/controller/FileFolderPlacementController.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/storage/controller/FolderController.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/storage/model/Folder.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/storage/model/api/CreateFolderRequest.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/storage/model/api/FolderResponse.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/storage/model/api/UpdateFolderRequest.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/storage/repository/FolderRepository.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/storage/service/FolderService.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/storage/service/FolderServiceTest.java create mode 100644 frontend/editor/src/core/components/filesPage/DeleteFolderDialog.tsx create mode 100644 frontend/editor/src/core/components/filesPage/FileDetailsPanel.tsx create mode 100644 frontend/editor/src/core/components/filesPage/FileGrid.tsx create mode 100644 frontend/editor/src/core/components/filesPage/FileManagerView.tsx create mode 100644 frontend/editor/src/core/components/filesPage/FileOriginBadge.tsx create mode 100644 frontend/editor/src/core/components/filesPage/FilesPage.css create mode 100644 frontend/editor/src/core/components/filesPage/FolderAppearancePicker.tsx create mode 100644 frontend/editor/src/core/components/filesPage/FolderNameDialog.tsx create mode 100644 frontend/editor/src/core/components/filesPage/FolderThumbnail.tsx create mode 100644 frontend/editor/src/core/components/filesPage/FolderTreePanel.css create mode 100644 frontend/editor/src/core/components/filesPage/FolderTreePanel.tsx create mode 100644 frontend/editor/src/core/components/filesPage/FolderTreeSidebar.tsx create mode 100644 frontend/editor/src/core/components/filesPage/MoveToFolderDialog.tsx create mode 100644 frontend/editor/src/core/components/filesPage/dragDrop.ts create mode 100644 frontend/editor/src/core/components/filesPage/fileOrigin.ts create mode 100644 frontend/editor/src/core/components/filesPage/filesPageReturnRoute.ts create mode 100644 frontend/editor/src/core/components/filesPage/folderIcons.ts create mode 100644 frontend/editor/src/core/components/filesPage/folderTreeWidth.ts create mode 100644 frontend/editor/src/core/components/filesPage/useDropTarget.ts create mode 100644 frontend/editor/src/core/contexts/FilesPageContext.tsx create mode 100644 frontend/editor/src/core/contexts/FolderContext.test.tsx create mode 100644 frontend/editor/src/core/contexts/FolderContext.tsx create mode 100644 frontend/editor/src/core/services/fileSyncService.ts create mode 100644 frontend/editor/src/core/services/folderStorage.ts create mode 100644 frontend/editor/src/core/services/folderSyncService.ts create mode 100644 frontend/editor/src/core/services/indexedDBManager.migration.test.ts create mode 100644 frontend/editor/src/core/tests/stubbed/files-page-screenshots.spec.ts create mode 100644 frontend/editor/src/core/tests/stubbed/files-page.spec.ts create mode 100644 frontend/editor/src/core/types/folder.ts create mode 100644 testing/cucumber/features/folders_and_files.feature create mode 100644 testing/cucumber/features/steps/folders_step_definitions.py diff --git a/.github/workflows/PR-Auto-Deploy-V2.yml b/.github/workflows/PR-Auto-Deploy-V2.yml index 0571d416cd..71071262cc 100644 --- a/.github/workflows/PR-Auto-Deploy-V2.yml +++ b/.github/workflows/PR-Auto-Deploy-V2.yml @@ -287,6 +287,7 @@ jobs: - /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}/data:/usr/share/tessdata:rw - /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}/config:/configs:rw - /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}/logs:/logs:rw + - /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}/storage:/storage:rw environment: DISABLE_ADDITIONAL_FEATURES: "false" SECURITY_ENABLELOGIN: "true" @@ -309,7 +310,7 @@ jobs: ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }} << ENDSSH # Create V2 PR-specific directories - mkdir -p /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}/{data,config,logs} + mkdir -p /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}/{data,config,logs,storage} # Move docker-compose file to correct location mv /tmp/docker-compose-v2.yml /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}/docker-compose.yml diff --git a/.gitignore b/.gitignore index 40be551559..c67ec629c2 100644 --- a/.gitignore +++ b/.gitignore @@ -274,3 +274,6 @@ docs/type3/signatures/ # Playwright MCP screenshots / traces .playwright-mcp/ *.playwright-mcp.png + +# Local screenshot artifacts from *-screenshots.spec.ts +frontend/screenshots/ diff --git a/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java b/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java index 28fe2d188f..74c713b6db 100644 --- a/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java +++ b/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java @@ -83,7 +83,16 @@ public class RequestUriUtils { return false; } - // Blocklist of backend/non-frontend paths that should still go through filters + // Blocklist of backend/non-frontend paths that should still go through filters. + // + // `/files` was historically a backend route; it is now a frontend route + // owned by HomePage / FileManagerView. Direct-nav or refresh on /files + // (or /files/) was returning the Spring auth filter's 401 + // JSON instead of serving index.html, so the SPA never got a chance to + // mount and the user saw a raw error response. There are no `/files` + // backend mappings at the servlet root - the real storage endpoints + // live under `/api/v1/storage/files`, which is filtered out a few lines + // up by the `startsWith("/api/")` guard. String[] backendOnlyPrefixes = { "/register", "/pipeline", @@ -91,7 +100,6 @@ public class RequestUriUtils { "/pdfjs-legacy", "/fonts", "/images", - "/files", "/css", "/js", "/swagger", @@ -181,7 +189,7 @@ public class RequestUriUtils { || trimmedUri.startsWith( "/api/v1/mobile-scanner/") // Mobile scanner endpoints (no auth) || trimmedUri.startsWith("/v1/api-docs") - // Workflow participant endpoints β€” access controlled by share tokens, not login + // Workflow participant endpoints - access controlled by share tokens, not login || trimmedUri.startsWith("/api/v1/workflow/participant/") // Share-link SPA bootstrap; data APIs remain protected || trimmedUri.matches("^/share/[^/]+/?$"); diff --git a/app/common/src/test/java/stirling/software/common/util/RequestUriUtilsTest.java b/app/common/src/test/java/stirling/software/common/util/RequestUriUtilsTest.java index f1ee3fa36b..4f0f3e372a 100644 --- a/app/common/src/test/java/stirling/software/common/util/RequestUriUtilsTest.java +++ b/app/common/src/test/java/stirling/software/common/util/RequestUriUtilsTest.java @@ -98,6 +98,17 @@ class RequestUriUtilsTest { assertTrue(RequestUriUtils.isFrontendRoute("", "/split-pdf")); } + @Test + void testIsFrontendRoute_filesRouteOwnedByFrontend() { + // /files and /files/ are FileManagerView routes - they + // must fall through to the SPA index.html, not get blocked by the + // backend auth filter. Regression test for direct-nav/refresh on + // the file manager returning a 401 JSON. + assertTrue(RequestUriUtils.isFrontendRoute("", "/files")); + assertTrue( + RequestUriUtils.isFrontendRoute("", "/files/3331910a-4155-4f71-8111-e38c896bc458")); + } + @Test void testIsFrontendRoute_pathWithExtension() { assertFalse(RequestUriUtils.isFrontendRoute("", "/some/file.pdf")); @@ -183,7 +194,7 @@ class RequestUriUtilsTest { @Test void testIsPublicAuthEndpoint_shareRootNotPublic() { - // Avoid matching bare "/share" or "/share/" β€” must have a token segment + // Avoid matching bare "/share" or "/share/" - must have a token segment assertFalse(RequestUriUtils.isPublicAuthEndpoint("/share", "")); assertFalse(RequestUriUtils.isPublicAuthEndpoint("/share/", "")); } @@ -197,7 +208,7 @@ class RequestUriUtilsTest { @Test void testIsPublicAuthEndpoint_shareApiStillProtected() { - // Share-link data APIs must NOT be public β€” they enforce auth + access checks + // Share-link data APIs must NOT be public - they enforce auth + access checks assertFalse(RequestUriUtils.isPublicAuthEndpoint("/api/v1/storage/share-links/abc123", "")); assertFalse( RequestUriUtils.isPublicAuthEndpoint( diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/web/ReactRoutingController.java b/app/core/src/main/java/stirling/software/SPDF/controller/web/ReactRoutingController.java index 02fa9b8169..7a0779a4e4 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/web/ReactRoutingController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/web/ReactRoutingController.java @@ -160,14 +160,19 @@ public class ReactRoutingController { return ResponseEntity.ok().contentType(MediaType.TEXT_HTML).body(cachedCallbackHtml); } + // `files` was historically a backend static-asset directory and was therefore + // in the exclusion list - removing it lets /files and /files/ + // forward to the SPA index.html, which is what FileManagerView expects. + // (Real storage endpoints live under /api/v1/storage/files, already + // excluded by the leading `api` token in the same regex.) @GetMapping( - "/{path:^(?!api|static|robots\\.txt|favicon\\.ico|manifest.*\\.json|pipeline|pdfjs|pdfjs-legacy|pdfium|vendor|fonts|images|files|css|js|assets|locales|modern-logo|classic-logo|Login|og_images|samples)[^\\.]*$}") + "/{path:^(?!api|static|robots\\.txt|favicon\\.ico|manifest.*\\.json|pipeline|pdfjs|pdfjs-legacy|pdfium|vendor|fonts|images|css|js|assets|locales|modern-logo|classic-logo|Login|og_images|samples)[^\\.]*$}") public ResponseEntity forwardRootPaths(HttpServletRequest request) throws IOException { return serveIndexHtml(request); } @GetMapping( - "/{path:^(?!api|static|pipeline|pdfjs|pdfjs-legacy|pdfium|vendor|fonts|images|files|css|js|assets|locales|modern-logo|classic-logo|Login|og_images|samples)[^\\.]*}/{subpath:^(?!.*\\.).*$}") + "/{path:^(?!api|static|pipeline|pdfjs|pdfjs-legacy|pdfium|vendor|fonts|images|css|js|assets|locales|modern-logo|classic-logo|Login|og_images|samples)[^\\.]*}/{subpath:^(?!.*\\.).*$}") public ResponseEntity forwardNestedPaths(HttpServletRequest request) throws IOException { return serveIndexHtml(request); diff --git a/app/core/src/main/java/stirling/software/SPDF/exception/GlobalExceptionHandler.java b/app/core/src/main/java/stirling/software/SPDF/exception/GlobalExceptionHandler.java index ffe3fb0f42..6b43b66ff6 100644 --- a/app/core/src/main/java/stirling/software/SPDF/exception/GlobalExceptionHandler.java +++ b/app/core/src/main/java/stirling/software/SPDF/exception/GlobalExceptionHandler.java @@ -22,6 +22,7 @@ import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.multipart.MaxUploadSizeExceededException; import org.springframework.web.multipart.support.MissingServletRequestPartException; +import org.springframework.web.server.ResponseStatusException; import org.springframework.web.servlet.NoHandlerFoundException; import jakarta.servlet.http.HttpServletRequest; @@ -196,12 +197,12 @@ public class GlobalExceptionHandler { /** * Checks whether the given IOException indicates that the client disconnected before the * response could be written (broken pipe, connection reset, etc.). When this happens there is - * no point in serialising a {@link ProblemDetail} body because the socket is already closed β€” + * no point in serialising a {@link ProblemDetail} body because the socket is already closed - * and attempting to do so may trigger a secondary {@code HttpMessageNotWritableException} if * the response Content-Type was already committed as a non-JSON type (e.g. image/png). */ private static boolean isClientDisconnectException(IOException ex) { - // Walk the causal chain β€” Jetty/Tomcat may wrap the low-level SocketException + // Walk the causal chain - Jetty/Tomcat may wrap the low-level SocketException Throwable current = ex; while (current != null) { String msg = current.getMessage(); @@ -1040,6 +1041,43 @@ public class GlobalExceptionHandler { * @param request the HTTP servlet request * @return ProblemDetail with appropriate HTTP status */ + /** + * Handle ResponseStatusException explicitly so its embedded HTTP status reaches the client + * instead of being swallowed by the {@code RuntimeException} catch-all (which would downgrade + * every controller-thrown 400/404/409 to a generic 500). Folder/file storage controllers and + * any other code that throws {@code ResponseStatusException} relies on this handler taking + * precedence. + */ + @ExceptionHandler(ResponseStatusException.class) + public ResponseEntity handleResponseStatusException( + ResponseStatusException ex, HttpServletRequest request) { + HttpStatus status = + HttpStatus.resolve(ex.getStatusCode().value()) != null + ? HttpStatus.valueOf(ex.getStatusCode().value()) + : HttpStatus.INTERNAL_SERVER_ERROR; + String reason = ex.getReason() != null ? ex.getReason() : status.getReasonPhrase(); + ProblemDetail problemDetail = createBaseProblemDetail(status, reason, request); + problemDetail.setType(URI.create("/errors/" + status.value())); + problemDetail.setTitle(status.getReasonPhrase()); + problemDetail.setProperty("title", status.getReasonPhrase()); + // 5xx is operator-relevant; 4xx is a normal client-rejection - log at the right level. + if (status.is5xxServerError()) { + log.error( + "ResponseStatusException {} at {}: {}", + status.value(), + request.getRequestURI(), + reason, + ex); + } else { + log.debug( + "ResponseStatusException {} at {}: {}", + status.value(), + request.getRequestURI(), + reason); + } + return ResponseEntity.status(status).contentType(PROBLEM_JSON).body(problemDetail); + } + @ExceptionHandler(RuntimeException.class) public ResponseEntity handleRuntimeException( RuntimeException ex, HttpServletRequest request) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/controller/FileFolderPlacementController.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/controller/FileFolderPlacementController.java new file mode 100644 index 0000000000..b2f2cd0933 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/controller/FileFolderPlacementController.java @@ -0,0 +1,91 @@ +package stirling.software.proprietary.storage.controller; + +import java.util.List; +import java.util.UUID; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.RequiredArgsConstructor; + +import stirling.software.proprietary.storage.service.FolderService; + +/** + * Folder placement endpoints for existing stored files. Thin adapter: validates the request shape, + * delegates the transaction to {@link FolderService}, then maps the result onto the HTTP status. + * Authentication, storage-gate, ownership checks, and the bulk cap all live on the service (where + * {@code @Transactional} also lives) so the JDBC connection isn't held through JSON serialization. + */ +@RestController +@RequestMapping("/api/v1/storage/files") +@RequiredArgsConstructor +public class FileFolderPlacementController { + + private static final int BULK_MOVE_MAX_FILES = 1000; + + private final FolderService folderService; + + /** Move a single file to a folder (or to root when folderId is null). */ + @PatchMapping("/{fileId}/folder") + public ResponseEntity moveFileToFolder( + @PathVariable Long fileId, @Valid @RequestBody FolderPlacement body) { + folderService.moveFileToFolder(fileId, body.getFolderId()); + return ResponseEntity.noContent().build(); + } + + /** + * Bulk move - fewer round-trips than calling the single endpoint N times. Returns 200 on full + * success, 207 (Multi-Status) when some files were skipped (typically because they don't belong + * to the caller). + */ + @PatchMapping("/folder") + public ResponseEntity bulkMove(@Valid @RequestBody BulkMoveRequest body) { + FolderService.BulkMoveResult result = + folderService.bulkMoveFilesToFolder(body.getFolderId(), body.getFileIds()); + HttpStatus status = + result.skippedFileIds().isEmpty() ? HttpStatus.OK : HttpStatus.MULTI_STATUS; + return ResponseEntity.status(status) + .body(new BulkMoveResponse(result.movedFileIds(), result.skippedFileIds())); + } + + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class FolderPlacement { + private UUID folderId; + } + + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class BulkMoveRequest { + private UUID folderId; + + @NotNull + @Size( + min = 1, + max = BULK_MOVE_MAX_FILES, + message = "fileIds must contain between 1 and 1000 entries") + private List fileIds; + } + + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class BulkMoveResponse { + private List movedFileIds; + private List skippedFileIds; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/controller/FolderController.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/controller/FolderController.java new file mode 100644 index 0000000000..3b4485a086 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/controller/FolderController.java @@ -0,0 +1,70 @@ +package stirling.software.proprietary.storage.controller; + +import java.net.URI; +import java.util.List; +import java.util.UUID; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import jakarta.validation.Valid; + +import lombok.RequiredArgsConstructor; + +import stirling.software.proprietary.storage.model.api.CreateFolderRequest; +import stirling.software.proprietary.storage.model.api.FolderResponse; +import stirling.software.proprietary.storage.model.api.UpdateFolderRequest; +import stirling.software.proprietary.storage.service.FolderService; + +/** + * REST endpoints for user-owned folders. Phase A - no folder-level sharing yet (Phase 3). + * + *

All operations are scoped to the authenticated user; existing single-file storage endpoints in + * {@link FileStorageController} are left alone so the cert-signing and standard upload flows are + * unaffected. + */ +@RestController +@RequestMapping("/api/v1/storage/folders") +@RequiredArgsConstructor +public class FolderController { + + private final FolderService folderService; + + @GetMapping + public List listFolders() { + return folderService.listFolders(); + } + + @PostMapping + public ResponseEntity createFolder( + @Valid @RequestBody CreateFolderRequest request) { + FolderResponse response = folderService.createFolder(request); + // 201 Created with Location header - conventional REST. The idempotent re-return path + // (same id resubmitted) also lands here; treating it as 201 keeps wire semantics simple. + return ResponseEntity.status(HttpStatus.CREATED) + .location(URI.create("/api/v1/storage/folders/" + response.id())) + .body(response); + } + + @PatchMapping("/{folderId}") + public ResponseEntity updateFolder( + @PathVariable UUID folderId, @Valid @RequestBody UpdateFolderRequest request) { + return ResponseEntity.ok(folderService.updateFolder(folderId, request)); + } + + @DeleteMapping("/{folderId}") + public ResponseEntity deleteFolder(@PathVariable UUID folderId) { + List removed = folderService.deleteFolder(folderId); + return ResponseEntity.ok(new DeleteFolderResponse(removed)); + } + + public record DeleteFolderResponse(List removedFolderIds) {} +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/Folder.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/Folder.java new file mode 100644 index 0000000000..5145b756a9 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/Folder.java @@ -0,0 +1,104 @@ +package stirling.software.proprietary.storage.model; + +import java.io.Serializable; +import java.time.LocalDateTime; +import java.util.UUID; + +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.OnDelete; +import org.hibernate.annotations.OnDeleteAction; +import org.hibernate.annotations.UpdateTimestamp; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.Version; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import stirling.software.proprietary.security.model.User; + +/** + * A user-owned folder used by the file manager UI to organise stored files. Phase A entity - no + * folder-level sharing yet (Phase 3). + * + *

The id is a UUID rather than a numeric auto-increment so it round-trips with the + * client-generated {@code FolderId} and survives cross-device sync without re-keying. + */ +@Entity +@Table( + name = "folders", + indexes = { + @Index(name = "idx_folders_owner", columnList = "owner_id"), + @Index(name = "idx_folders_parent", columnList = "parent_folder_id"), + @Index(name = "idx_folders_owner_parent", columnList = "owner_id, parent_folder_id") + }) +@NoArgsConstructor +@Getter +@Setter +public class Folder implements Serializable { + + private static final long serialVersionUID = 1L; + + /** + * Dialect-portable UUID column. The previous {@code columnDefinition = "uuid"} was + * Postgres-specific and broke on H2/MariaDB. Hibernate's {@code UUID} mapping picks the right + * native type per dialect (BINARY(16) on H2/MariaDB, uuid on Postgres) when no explicit + * columnDefinition is set. + */ + @Id + @Column(name = "folder_id", nullable = false) + private UUID id; + + /** + * {@code OnDeleteAction.CASCADE} so deleting the owning {@code User} cascades to this row at + * the DB level - UserService.deleteUserRelatedData doesn't enumerate folders today, and leaving + * the FK without an action throws a constraint violation on user delete. + */ + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "owner_id", nullable = false) + @OnDelete(action = OnDeleteAction.CASCADE) + private User owner; + + /** + * Parent folder; null = root. {@code OnDeleteAction.CASCADE} so a backend-side parent delete + * cleans children automatically, matching the service-layer recursive-delete contract. + */ + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "parent_folder_id") + @OnDelete(action = OnDeleteAction.CASCADE) + private Folder parent; + + @Column(name = "name", nullable = false, length = 255) + private String name; + + @Column(name = "color", length = 32) + private String color; + + @Column(name = "icon", length = 64) + private String icon; + + /** + * Optimistic-locking version. Cross-PC sync without this lets last-write-win silently. The + * column is nullable so existing rows from a pre-version deployment can be backfilled by + * Hibernate's update-on-write rather than failing the ddl-auto upgrade. + */ + @Version + @Column(name = "version") + private Long version; + + @CreationTimestamp + @Column(name = "created_at", updatable = false) + private LocalDateTime createdAt; + + @UpdateTimestamp + @Column(name = "updated_at") + private LocalDateTime updatedAt; +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/StoredFile.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/StoredFile.java index 0128a2dfdf..cbf5316db6 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/StoredFile.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/StoredFile.java @@ -6,6 +6,8 @@ import java.util.HashSet; import java.util.Set; import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.OnDelete; +import org.hibernate.annotations.OnDeleteAction; import org.hibernate.annotations.UpdateTimestamp; import jakarta.persistence.CascadeType; @@ -35,7 +37,8 @@ import stirling.software.proprietary.workflow.model.WorkflowSession; name = "stored_files", indexes = { @Index(name = "idx_stored_files_owner", columnList = "owner_id"), - @Index(name = "idx_stored_files_workflow", columnList = "workflow_session_id") + @Index(name = "idx_stored_files_workflow", columnList = "workflow_session_id"), + @Index(name = "idx_stored_files_folder", columnList = "folder_id") }) @NoArgsConstructor @Getter @@ -106,6 +109,20 @@ public class StoredFile implements Serializable { orphanRemoval = true) private Set shares = new HashSet<>(); + /** + * Optional folder placement for the file manager UI. Null = root. Hibernate ddl-auto will add + * this as a nullable column on upgrade so existing records continue to work untouched. + * + *

{@code OnDeleteAction.SET_NULL} so any backend that drops a folder row (admin script, + * future cleanup job, cascading user delete) cleanly orphans files to root rather than leaving + * dangling FK references. The application path ({@code FolderRepository.clearFolderForFiles}) + * still runs first as a belt-and-braces. + */ + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "folder_id") + @OnDelete(action = OnDeleteAction.SET_NULL) + private Folder folder; + @CreationTimestamp @Column(name = "created_at", updatable = false) private LocalDateTime createdAt; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/api/CreateFolderRequest.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/api/CreateFolderRequest.java new file mode 100644 index 0000000000..1dafdf1c98 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/api/CreateFolderRequest.java @@ -0,0 +1,43 @@ +package stirling.software.proprietary.storage.model.api; + +import java.util.UUID; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class CreateFolderRequest { + + /** + * Client-generated UUID - lets the caller round-trip the same id it stored locally. Optional; + * the server generates one when missing. + */ + private UUID id; + + @NotBlank + @Size(max = 255) + private String name; + + private UUID parentFolderId; + + /** Hex colour string (#rrggbb or #rrggbbaa) - matches the frontend palette format. */ + @Size(max = 32) + @Pattern( + regexp = "^#[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$", + message = "color must be a #RRGGBB or #RRGGBBAA hex value") + private String color; + + /** Icon identifier - lowercase alphanumerics, hyphens, underscores only. */ + @Size(max = 64) + @Pattern( + regexp = "^[a-z0-9_-]+$", + message = "icon must be a lowercase id (a-z, 0-9, '-' or '_')") + private String icon; +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/api/FolderResponse.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/api/FolderResponse.java new file mode 100644 index 0000000000..d1892b6612 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/api/FolderResponse.java @@ -0,0 +1,38 @@ +package stirling.software.proprietary.storage.model.api; + +import java.time.LocalDateTime; +import java.util.UUID; + +import stirling.software.proprietary.storage.model.Folder; + +/** + * Outbound DTO for folder responses. Records are immutable, value-equality-based, and far less + * accident-prone than a {@code @Data} class with public setters. + */ +public record FolderResponse( + UUID id, + String name, + UUID parentFolderId, + String color, + String icon, + Long version, + LocalDateTime createdAt, + LocalDateTime updatedAt) { + + public static FolderResponse from(Folder folder) { + // {@code folder.getParent().getId()} on a lazy proxy returns the FK value cached at the + // join column WITHOUT initialising the proxy under standard Hibernate, so this does + // not N+1. If a future Hibernate update changes that, switch the JPQL list query to a + // constructor projection. + UUID parentId = folder.getParent() == null ? null : folder.getParent().getId(); + return new FolderResponse( + folder.getId(), + folder.getName(), + parentId, + folder.getColor(), + folder.getIcon(), + folder.getVersion(), + folder.getCreatedAt(), + folder.getUpdatedAt()); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/api/StoredFileResponse.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/api/StoredFileResponse.java index 1cf6a20c8e..1d92745d6f 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/api/StoredFileResponse.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/api/StoredFileResponse.java @@ -2,6 +2,7 @@ package stirling.software.proprietary.storage.model.api; import java.time.LocalDateTime; import java.util.List; +import java.util.UUID; import lombok.Builder; import lombok.Getter; @@ -22,4 +23,10 @@ public class StoredFileResponse { private final List sharedUsers; private final List shareLinks; private final String filePurpose; + + /** + * Optional folder placement (Phase A). Null when the file lives at the root or when the server + * build doesn't have the folders feature enabled - existing clients should treat null as root. + */ + private final UUID folderId; } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/api/UpdateFolderRequest.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/api/UpdateFolderRequest.java new file mode 100644 index 0000000000..26f920819a --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/api/UpdateFolderRequest.java @@ -0,0 +1,58 @@ +package stirling.software.proprietary.storage.model.api; + +import java.util.UUID; + +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * PATCH-style update - every field is optional. Send only the fields you want to change. + * + *

The {@code reparent} flag distinguishes "do not change parent" from "move to root" since + * {@code parentFolderId == null} alone is ambiguous in a sparse body. We use a boxed {@link + * Boolean} so a missing field deserialises to {@code null} (= "do not reparent") rather than to + * primitive {@code false}, removing a class of "I PATCHed only the name but the server reset my + * parent" footguns. + * + *

When the trimmed name is empty (e.g. {@code " "}) the service rejects the request with HTTP + * 400 - silent drops are too easy to mistake for a successful rename. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class UpdateFolderRequest { + + /** When provided, must contain at least one non-whitespace character. */ + @Size(max = 255) + @Pattern(regexp = "\\S.*", message = "name must not be blank") + private String name; + + private Boolean reparent; + private UUID parentFolderId; + + @Size(max = 32) + @Pattern( + regexp = "^(|#[0-9a-fA-F]{6}([0-9a-fA-F]{2})?)$", + message = "color must be empty or a #RRGGBB / #RRGGBBAA hex value") + private String color; + + @Size(max = 64) + @Pattern( + regexp = "^([a-z0-9_-]+)?$", + message = "icon must be a lowercase id (a-z, 0-9, '-' or '_') or empty") + private String icon; + + /** + * Convenience accessor - treats null as "do not reparent". Named differently from the + * Lombok-generated {@code getReparent()} so callers don't accidentally use one for the other + * (the getter is nullable {@code Boolean}; this method collapses to primitive). + */ + @com.fasterxml.jackson.annotation.JsonIgnore + public boolean shouldReparent() { + return Boolean.TRUE.equals(reparent); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/repository/FolderRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/repository/FolderRepository.java new file mode 100644 index 0000000000..141223d92a --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/repository/FolderRepository.java @@ -0,0 +1,35 @@ +package stirling.software.proprietary.storage.repository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.storage.model.Folder; + +public interface FolderRepository extends JpaRepository { + + Optional findByIdAndOwner(UUID id, User owner); + + List findAllByOwnerOrderByName(User owner); + + long countByOwner(User owner); + + /** + * Clear the folder reference on every file currently inside any of the given folders. Used when + * a folder subtree is deleted - files fall back to the root rather than dangling. + * + *

{@code flushAutomatically + clearAutomatically} forces Hibernate to flush any cached dirty + * {@code StoredFile} entities before the bulk UPDATE runs, and clears the persistence context + * afterwards so a subsequent {@code deleteAllByIdInBatch} on the parent folders doesn't see + * stale entity state referencing the about-to-be-deleted folder. + */ + @Modifying(flushAutomatically = true, clearAutomatically = true) + @Query("UPDATE StoredFile sf SET sf.folder = null WHERE sf.folder.id IN :folderIds") + void clearFolderForFiles(@Param("folderIds") List folderIds); +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/repository/StoredFileRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/repository/StoredFileRepository.java index bebe65c684..47545ab58a 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/storage/repository/StoredFileRepository.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/repository/StoredFileRepository.java @@ -59,6 +59,13 @@ public interface StoredFileRepository extends JpaRepository { List findAllByOwner(User owner); + /** + * Bulk lookup used by the folder-placement controller. Returns only files owned by {@code + * owner}; ids that don't exist or that belong to another user are silently dropped so the + * caller can compute the "skipped" set by subtraction. + */ + List findAllByIdInAndOwner(List ids, User owner); + @Modifying @Transactional @Query( diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/service/FileStorageService.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/service/FileStorageService.java index ba94cab79d..03c3a9178f 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/storage/service/FileStorageService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/service/FileStorageService.java @@ -459,6 +459,7 @@ public class FileStorageService { file.getPurpose() != null ? file.getPurpose().name().toLowerCase(Locale.ROOT) : null) + .folderId(file.getFolder() != null ? file.getFolder().getId() : null) .build(); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/service/FolderService.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/service/FolderService.java new file mode 100644 index 0000000000..c0f07f8ceb --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/service/FolderService.java @@ -0,0 +1,417 @@ +package stirling.software.proprietary.storage.service; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.storage.model.Folder; +import stirling.software.proprietary.storage.model.StoredFile; +import stirling.software.proprietary.storage.model.api.CreateFolderRequest; +import stirling.software.proprietary.storage.model.api.FolderResponse; +import stirling.software.proprietary.storage.model.api.UpdateFolderRequest; +import stirling.software.proprietary.storage.repository.FolderRepository; +import stirling.software.proprietary.storage.repository.StoredFileRepository; + +/** + * Phase A folder operations. Each call is scoped to the authenticated user - folders are private to + * their owner. Folder-level sharing is a Phase 3 feature. + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class FolderService { + + /** + * Hard cap on folders per user. Beyond this {@link #createFolder} rejects with 409 - guards + * against per-account folder-explosion DoS and bounds the in-memory subtree walk in {@link + * #deleteFolder}. + */ + private static final long MAX_FOLDERS_PER_USER = 5_000L; + + /** + * Hard cap on chain depth from the root to any folder. Bounds the lazy-proxy walk in {@link + * #enforceDepthAndCycle} - otherwise a user could build a chain up to MAX_FOLDERS_PER_USER deep + * and force one Hibernate SELECT per ancestor on every reparent (5,000+ SELECTs == seconds of + * DB time per request, per-account weaponizable as DoS). + */ + private static final int MAX_FOLDER_DEPTH = 64; + + /** + * Hard cap on bulk-move payload size, mirroring the request-validation cap on {@code + * FileFolderPlacementController.BulkMoveRequest.fileIds}. Re-asserted at the service layer + * because controller-level @Valid bounds aren't enforced when the service is called directly + * (e.g. by future internal callers or tests). + */ + private static final int BULK_MOVE_MAX_FILES = 1000; + + private final FolderRepository folderRepository; + private final StoredFileRepository storedFileRepository; + private final ApplicationProperties applicationProperties; + + /** + * Gate every public method on storage being enabled, mirroring {@code + * FileStorageService.ensureStorageEnabled}. Without this, folder CRUD still works when {@code + * storage.enabled=false} or {@code security.enableLogin=false}, defeating the operator's intent + * to disable storage end-to-end. + */ + private void ensureStorageEnabled() { + if (!applicationProperties.getSecurity().isEnableLogin()) { + throw new ResponseStatusException( + HttpStatus.FORBIDDEN, "Storage requires login to be enabled"); + } + if (!applicationProperties.getStorage().isEnabled()) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Storage is disabled"); + } + } + + /** List every folder owned by the current user, alphabetical. */ + @Transactional(readOnly = true) + public List listFolders() { + ensureStorageEnabled(); + User user = requireAuthenticatedUser(); + return folderRepository.findAllByOwnerOrderByName(user).stream() + .map(FolderResponse::from) + .toList(); + } + + @Transactional + public FolderResponse createFolder(CreateFolderRequest request) { + ensureStorageEnabled(); + User user = requireAuthenticatedUser(); + // Reject self-parenting up-front. Without this, a client posting + // {id: X, parentFolderId: X} for a folder X they already own would silently + // get the existing folder back (idempotent path) and never learn that the + // parentFolderId they sent was ignored. For new ids the parent lookup would + // 404, but the message is misleading. + if (request.getId() != null && request.getId().equals(request.getParentFolderId())) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, "A folder cannot be its own parent"); + } + Folder parent = resolveParent(request.getParentFolderId(), user, null); + + UUID id = request.getId() != null ? request.getId() : UUID.randomUUID(); + + // Idempotent: if this user already owns a folder with the supplied id, return it + // unchanged. Single fetch (the previous code did findByIdAndOwner twice with a race + // window between the two lookups). + java.util.Optional existing = folderRepository.findByIdAndOwner(id, user); + if (existing.isPresent()) { + return FolderResponse.from(existing.get()); + } + + // The id is a global primary key. If the id exists for a *different* user, surfacing 500 + // with a constraint-violation stack trace leaks far too much; convert to 409 Conflict so + // the caller can pick a fresh id. + if (folderRepository.existsById(id)) { + throw new ResponseStatusException( + HttpStatus.CONFLICT, + "A folder with this id already exists; choose a different id"); + } + + if (folderRepository.countByOwner(user) >= MAX_FOLDERS_PER_USER) { + throw new ResponseStatusException( + HttpStatus.CONFLICT, + "Folder limit reached (max " + MAX_FOLDERS_PER_USER + " per user)"); + } + + Folder folder = new Folder(); + folder.setId(id); + folder.setOwner(user); + folder.setParent(parent); + folder.setName(request.getName().trim()); + folder.setColor(request.getColor()); + folder.setIcon(request.getIcon()); + + // saveAndFlush forces the INSERT now so @CreationTimestamp populates + // createdAt/updatedAt before we build the response. Plain save defers + // the SQL until @Transactional commit, and the response would carry + // null timestamps that the frontend trust-boundary parser then rejects. + Folder saved = folderRepository.saveAndFlush(folder); + log.info( + "Folder created: user={} id={} parent={}", + user.getId(), + saved.getId(), + parent == null ? "root" : parent.getId()); + return FolderResponse.from(saved); + } + + @Transactional + public FolderResponse updateFolder(UUID id, UpdateFolderRequest request) { + ensureStorageEnabled(); + User user = requireAuthenticatedUser(); + Folder folder = requireOwnedFolder(id, user); + + if (request.getName() != null) { + String trimmed = request.getName().trim(); + if (trimmed.isEmpty()) { + // Bean validation should already catch this via @Pattern, but be explicit so + // an empty-after-trim payload reaches the user as a 400 instead of being + // silently dropped. + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, "Folder name cannot be blank"); + } + folder.setName(trimmed); + } + + if (request.shouldReparent()) { + Folder newParent = resolveParent(request.getParentFolderId(), user, folder.getId()); + folder.setParent(newParent); + } + + if (request.getColor() != null) { + folder.setColor(request.getColor().isEmpty() ? null : request.getColor()); + } + + if (request.getIcon() != null) { + folder.setIcon(request.getIcon().isEmpty() ? null : request.getIcon()); + } + + // saveAndFlush so @UpdateTimestamp populates updatedAt before the + // response is serialized (same reason as createFolder). + return FolderResponse.from(folderRepository.saveAndFlush(folder)); + } + + /** + * Recursive delete. Returns the ids of every folder that was removed so the caller can purge + * them from its local cache. Files inside those folders are detached (folder_id set to null) - + * never deleted. + */ + @Transactional + public List deleteFolder(UUID id) { + ensureStorageEnabled(); + User user = requireAuthenticatedUser(); + Folder folder = requireOwnedFolder(id, user); + + // Build the parent β†’ children map once. Project to id-only via the + // existing entity list (Hibernate already has the column loaded - + // we only access f.getParent().getId() on a managed proxy, which + // does NOT initialize the proxy because Hibernate has the FK + // value cached at the join column). + Map> childIdsByParent = new HashMap<>(); + for (Folder f : folderRepository.findAllByOwnerOrderByName(user)) { + UUID parentId = f.getParent() == null ? null : f.getParent().getId(); + childIdsByParent.computeIfAbsent(parentId, k -> new ArrayList<>()).add(f.getId()); + } + + // Iterative subtree collection - prior recursive form blew the JVM + // stack on deeply nested chains a malicious caller could create. + List removed = new ArrayList<>(); + Set seen = new HashSet<>(); + Deque stack = new ArrayDeque<>(); + stack.push(folder.getId()); + while (!stack.isEmpty()) { + UUID cur = stack.pop(); + if (!seen.add(cur)) continue; + removed.add(cur); + List children = childIdsByParent.get(cur); + if (children != null) { + for (UUID childId : children) stack.push(childId); + } + } + + if (!removed.isEmpty()) { + folderRepository.clearFolderForFiles(removed); + folderRepository.deleteAllByIdInBatch(removed); + log.info( + "Folder subtree deleted: user={} root={} count={}", + user.getId(), + folder.getId(), + removed.size()); + } + + return removed; + } + + /** + * Move a single owned file to a folder (or root when {@code folderId} is null). Owns its + * own @Transactional rather than relying on the caller so the JDBC connection is released as + * soon as the writes commit, not held through controller-side JSON serialization. + */ + @Transactional + public void moveFileToFolder(Long fileId, UUID folderId) { + ensureStorageEnabled(); + User user = requireAuthenticatedUser(); + StoredFile file = + storedFileRepository + .findByIdAndOwner(fileId, user) + .orElseThrow( + () -> + new ResponseStatusException( + HttpStatus.NOT_FOUND, + "File not found or not owned by current user")); + file.setFolder(resolveOwnedFolder(folderId, user)); + storedFileRepository.save(file); + } + + /** + * Bulk move that returns the moved + skipped split. Skipped == file ids the caller doesn't own + * (or that no longer exist); the controller surfaces this as 207 Multi-Status. + */ + @Transactional + public BulkMoveResult bulkMoveFilesToFolder(UUID folderId, List fileIds) { + ensureStorageEnabled(); + if (fileIds == null || fileIds.isEmpty()) { + return new BulkMoveResult(List.of(), List.of()); + } + if (fileIds.size() > BULK_MOVE_MAX_FILES) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, + "fileIds must contain between 1 and " + BULK_MOVE_MAX_FILES + " entries"); + } + User user = requireAuthenticatedUser(); + Folder target = resolveOwnedFolder(folderId, user); + + List owned = storedFileRepository.findAllByIdInAndOwner(fileIds, user); + Set ownedIds = new HashSet<>(owned.size()); + for (StoredFile f : owned) { + f.setFolder(target); + ownedIds.add(f.getId()); + } + // If the target folder was deleted concurrently between resolveOwnedFolder and the + // flush, the FK constraint fires as DataIntegrityViolationException. Surface that as + // 409 Conflict so the caller sees an actionable error instead of a 500 stack. + try { + storedFileRepository.saveAll(owned); + storedFileRepository.flush(); + } catch (DataIntegrityViolationException ex) { + throw new ResponseStatusException( + HttpStatus.CONFLICT, + "Target folder no longer exists; refresh and try again", + ex); + } + + List moved = owned.stream().map(StoredFile::getId).toList(); + List skipped = fileIds.stream().filter(id -> !ownedIds.contains(id)).toList(); + if (!skipped.isEmpty()) { + log.warn( + "bulkMove: user {} skipped {} of {} files (not owned or missing)", + user.getId(), + skipped.size(), + fileIds.size()); + } + return new BulkMoveResult(moved, skipped); + } + + /** Result of {@link #bulkMoveFilesToFolder}. Records are immutable + auto-serializable. */ + public record BulkMoveResult(List movedFileIds, List skippedFileIds) {} + + // ─── helpers ──────────────────────────────────────────────────── + + /** + * Resolve a placement-target folder. Distinct from {@link #resolveParent} because move targets + * don't carry the parent-cycle semantics - we only need the folder to exist AND belong to the + * caller. Returns null for null input (root). + */ + private Folder resolveOwnedFolder(UUID folderId, User user) { + if (folderId == null) return null; + return folderRepository + .findByIdAndOwner(folderId, user) + .orElseThrow( + () -> + new ResponseStatusException( + HttpStatus.BAD_REQUEST, + "Folder does not exist or is not owned by you")); + } + + private Folder requireOwnedFolder(UUID id, User user) { + return folderRepository + .findByIdAndOwner(id, user) + .orElseThrow( + () -> + new ResponseStatusException( + HttpStatus.NOT_FOUND, + "Folder not found or not owned by current user")); + } + + private Folder resolveParent(UUID parentId, User user, UUID forbidId) { + if (parentId == null) return null; + if (forbidId != null && parentId.equals(forbidId)) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, "A folder cannot be its own parent"); + } + Folder parent = + folderRepository + .findByIdAndOwner(parentId, user) + .orElseThrow( + () -> + new ResponseStatusException( + HttpStatus.BAD_REQUEST, + "Parent folder does not exist or is not owned by you")); + // Reject before the child is created/moved if attaching it would push the chain past the + // depth cap. Done in one pass that also returns the cycle answer so we don't walk the + // lazy-proxy chain twice. + enforceDepthAndCycle(parent, user, forbidId); + return parent; + } + + /** + * Single pass that walks the parent chain to root and (a) rejects if attaching a child here + * would exceed MAX_FOLDER_DEPTH, (b) rejects if {@code forbidId} appears in the chain (cycle on + * reparent), (c) rejects on a broken graph, and (d) rejects if any ancestor is owned by a + * different user (defense-in-depth: callers always pass a parent already ownership-checked, but + * the parent chain is followed via lazy proxy without re-checking ownership at each hop, so any + * stray cross-owner edge in the database would otherwise leak ancestor folder ids through the + * cycle error message). The walk is hard-bounded at MAX_FOLDER_DEPTH so a corrupted database + * (chain longer than the API would allow) can never produce an unbounded SELECT loop. + */ + private void enforceDepthAndCycle(Folder candidateParent, User user, UUID forbidId) { + Folder cursor = candidateParent; + Set seen = new HashSet<>(); + int depth = 0; + while (cursor != null) { + if (cursor.getOwner() == null || !cursor.getOwner().getId().equals(user.getId())) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, "Folder hierarchy is corrupted; contact support"); + } + if (forbidId != null && cursor.getId().equals(forbidId)) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, + "Cannot move a folder inside one of its descendants"); + } + if (!seen.add(cursor.getId())) { + // broken graph (cycle in stored data) + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, "Folder hierarchy is corrupted; contact support"); + } + depth += 1; + // candidateParent is at depth 1 from the new child's perspective. After the walk, + // `depth` equals the number of ancestors including candidateParent, which is the + // depth at which the new child would live. Reject before exceeding the cap. + if (depth >= MAX_FOLDER_DEPTH) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, + "Folder nesting limit reached (max " + MAX_FOLDER_DEPTH + " levels)"); + } + cursor = cursor.getParent(); + } + } + + private User requireAuthenticatedUser() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null + || !authentication.isAuthenticated() + || !(authentication.getPrincipal() instanceof User user)) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Authentication required"); + } + return user; + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/storage/service/FolderServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/storage/service/FolderServiceTest.java new file mode 100644 index 0000000000..13bcfbfdac --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/storage/service/FolderServiceTest.java @@ -0,0 +1,298 @@ +package stirling.software.proprietary.storage.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.Optional; +import java.util.UUID; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.server.ResponseStatusException; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.storage.model.Folder; +import stirling.software.proprietary.storage.model.api.CreateFolderRequest; +import stirling.software.proprietary.storage.repository.FolderRepository; +import stirling.software.proprietary.storage.repository.StoredFileRepository; + +/** + * Unit tests for {@link FolderService}. Covers the regressions Connor flagged in PR #6383: + * + *

    + *
  • storage-enabled gate must be enforced (added in the same PR) + *
  • cross-user folder access must 404, not leak existence + *
  • cycle detection on reparent must 400 + *
  • depth cap must reject chains past MAX_FOLDER_DEPTH + *
  • per-user folder count cap must 409 + *
+ * + * Hibernate is mocked: this is a pure-Mockito unit test, not a slice test. Adequate for the + * service-layer behaviors above; full DB integration belongs in a separate {@code @DataJpaTest}. + */ +@ExtendWith(MockitoExtension.class) +class FolderServiceTest { + + @Mock private FolderRepository folderRepository; + @Mock private StoredFileRepository storedFileRepository; + @Mock private ApplicationProperties applicationProperties; + @Mock private ApplicationProperties.Security security; + @Mock private ApplicationProperties.Storage storage; + + private FolderService service; + private User user; + + @BeforeEach + void setUp() { + // Default to "storage enabled" so the unrelated tests don't have to repeat the wiring. + // Individual tests override with disabled state. + lenient().when(applicationProperties.getSecurity()).thenReturn(security); + lenient().when(applicationProperties.getStorage()).thenReturn(storage); + lenient().when(security.isEnableLogin()).thenReturn(true); + lenient().when(storage.isEnabled()).thenReturn(true); + + service = new FolderService(folderRepository, storedFileRepository, applicationProperties); + + user = new User(); + user.setId(42L); + user.setUsername("alice"); + SecurityContext ctx = SecurityContextHolder.createEmptyContext(); + ctx.setAuthentication( + new UsernamePasswordAuthenticationToken(user, null, java.util.List.of())); + SecurityContextHolder.setContext(ctx); + } + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + @Test + void listFolders_rejects_when_login_disabled() { + when(security.isEnableLogin()).thenReturn(false); + assertThatThrownBy(() -> service.listFolders()) + .isInstanceOf(ResponseStatusException.class) + .satisfies( + e -> + assertThat(((ResponseStatusException) e).getStatusCode().value()) + .isEqualTo(403)); + } + + @Test + void listFolders_rejects_when_storage_disabled() { + when(storage.isEnabled()).thenReturn(false); + assertThatThrownBy(() -> service.listFolders()) + .isInstanceOf(ResponseStatusException.class) + .satisfies( + e -> + assertThat(((ResponseStatusException) e).getStatusCode().value()) + .isEqualTo(403)); + } + + @Test + void createFolder_under_unknown_parent_returns_400_without_leaking_existence() { + // Parent UUID exists for ANOTHER user; current-user lookup misses it. The repository + // returns Optional.empty() and the service must surface a generic 400, not a 404 that + // could be used to probe for existence by id-guessing. + UUID foreignParentId = UUID.randomUUID(); + when(folderRepository.findByIdAndOwner(eq(foreignParentId), eq(user))) + .thenReturn(Optional.empty()); + + CreateFolderRequest req = new CreateFolderRequest(); + req.setName("Child"); + req.setParentFolderId(foreignParentId); + + assertThatThrownBy(() -> service.createFolder(req)) + .isInstanceOf(ResponseStatusException.class) + .satisfies( + e -> { + ResponseStatusException rse = (ResponseStatusException) e; + assertThat(rse.getStatusCode().value()).isEqualTo(400); + assertThat(rse.getReason()).doesNotContain(foreignParentId.toString()); + }); + } + + @Test + void createFolder_409_when_user_at_folder_cap() { + // Stub out an existing-id miss so we reach the cap check (no Mockito unnecessary-stub + // warnings from the OTHER paths because we exit at the cap before the existsById call). + UUID newId = UUID.randomUUID(); + when(folderRepository.findByIdAndOwner(eq(newId), eq(user))).thenReturn(Optional.empty()); + when(folderRepository.existsById(eq(newId))).thenReturn(false); + when(folderRepository.countByOwner(eq(user))).thenReturn(5_000L); + + CreateFolderRequest req = new CreateFolderRequest(); + req.setName("Overflow"); + req.setId(newId); + + assertThatThrownBy(() -> service.createFolder(req)) + .isInstanceOf(ResponseStatusException.class) + .satisfies( + e -> + assertThat(((ResponseStatusException) e).getStatusCode().value()) + .isEqualTo(409)); + } + + @Test + void resolveParent_rejects_when_chain_exceeds_depth_cap() { + // Build a chain Hibernate-proxy-style: 64 ancestor stubs reachable via getParent(). The + // 65th createFolder attempt under the deepest existing folder should be rejected with + // 400 before any further work. + Folder root = makeFolder(UUID.randomUUID(), null); + Folder cursor = root; + for (int i = 0; i < 63; i++) { + Folder child = makeFolder(UUID.randomUUID(), cursor); + cursor = child; + } + // cursor is at depth 64 from root. Attempting to add another folder under cursor pushes + // the new child to depth 65 - past the cap. resolveParent walks cursor->root counting + // ancestors, which is exactly 64, and rejects. + Folder deepest = cursor; + when(folderRepository.findByIdAndOwner(eq(deepest.getId()), eq(user))) + .thenReturn(Optional.of(deepest)); + + CreateFolderRequest req = new CreateFolderRequest(); + req.setName("Too deep"); + req.setParentFolderId(deepest.getId()); + + assertThatThrownBy(() -> service.createFolder(req)) + .isInstanceOf(ResponseStatusException.class) + .satisfies( + e -> { + ResponseStatusException rse = (ResponseStatusException) e; + assertThat(rse.getStatusCode().value()).isEqualTo(400); + assertThat(rse.getReason()).containsIgnoringCase("nesting limit"); + }); + } + + @Test + void updateFolder_rejects_cycle_on_reparent() { + // A -> B -> C. Attempt to reparent A under C (i.e. set A.parent = C). C's chain to root + // includes B which includes A, so the cycle check must fire with 400. + Folder a = makeFolder(UUID.randomUUID(), null); + Folder b = makeFolder(UUID.randomUUID(), a); + Folder c = makeFolder(UUID.randomUUID(), b); + + when(folderRepository.findByIdAndOwner(eq(a.getId()), eq(user))).thenReturn(Optional.of(a)); + when(folderRepository.findByIdAndOwner(eq(c.getId()), eq(user))).thenReturn(Optional.of(c)); + + stirling.software.proprietary.storage.model.api.UpdateFolderRequest req = + new stirling.software.proprietary.storage.model.api.UpdateFolderRequest(); + req.setParentFolderId(c.getId()); + // shouldReparent() requires the explicit reparent flag - without it the + // parent change is silently skipped (PATCH-style semantics). + req.setReparent(true); + + assertThatThrownBy(() -> service.updateFolder(a.getId(), req)) + .isInstanceOf(ResponseStatusException.class) + .satisfies( + e -> { + ResponseStatusException rse = (ResponseStatusException) e; + assertThat(rse.getStatusCode().value()).isEqualTo(400); + assertThat(rse.getReason()).containsIgnoringCase("descendants"); + }); + } + + @Test + void updateFolder_rejects_when_folder_not_owned() { + // Owner mismatch surfaces as 404, NOT 403 - 403 would confirm the folder exists, leaking + // ids to probing users. Stays consistent with the createFolder-under-unknown-parent test + // above. + UUID foreignId = UUID.randomUUID(); + when(folderRepository.findByIdAndOwner(eq(foreignId), eq(user))) + .thenReturn(Optional.empty()); + + stirling.software.proprietary.storage.model.api.UpdateFolderRequest req = + new stirling.software.proprietary.storage.model.api.UpdateFolderRequest(); + req.setName("Renamed"); + + assertThatThrownBy(() -> service.updateFolder(foreignId, req)) + .isInstanceOf(ResponseStatusException.class) + .satisfies( + e -> + assertThat(((ResponseStatusException) e).getStatusCode().value()) + .isEqualTo(404)); + } + + @Test + void moveFileToFolder_rejects_when_target_folder_not_owned() { + // File belongs to current user but target folder belongs to someone else. Service must + // 400, not move the file. + UUID foreignFolderId = UUID.randomUUID(); + stirling.software.proprietary.storage.model.StoredFile file = + mock(stirling.software.proprietary.storage.model.StoredFile.class); + when(storedFileRepository.findByIdAndOwner(eq(100L), eq(user))) + .thenReturn(Optional.of(file)); + when(folderRepository.findByIdAndOwner(eq(foreignFolderId), eq(user))) + .thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.moveFileToFolder(100L, foreignFolderId)) + .isInstanceOf(ResponseStatusException.class) + .satisfies( + e -> + assertThat(((ResponseStatusException) e).getStatusCode().value()) + .isEqualTo(400)); + } + + @Test + void bulkMove_rejects_oversized_payload() { + // Bypass the @Valid bound by calling the service directly - the cap must hold here too, + // not just at the controller's request validator. + java.util.List tooMany = new java.util.ArrayList<>(); + for (int i = 0; i < 1001; i++) tooMany.add((long) i); + + assertThatThrownBy(() -> service.bulkMoveFilesToFolder(null, tooMany)) + .isInstanceOf(ResponseStatusException.class) + .satisfies( + e -> + assertThat(((ResponseStatusException) e).getStatusCode().value()) + .isEqualTo(400)); + } + + @Test + void bulkMove_returns_moved_and_skipped_split() { + // Ownership filter on the repository returns a subset; the rest land in skippedFileIds. + Folder target = makeFolder(UUID.randomUUID(), null); + when(folderRepository.findByIdAndOwner(eq(target.getId()), eq(user))) + .thenReturn(Optional.of(target)); + + stirling.software.proprietary.storage.model.StoredFile fileA = + mock(stirling.software.proprietary.storage.model.StoredFile.class); + when(fileA.getId()).thenReturn(1L); + stirling.software.proprietary.storage.model.StoredFile fileB = + mock(stirling.software.proprietary.storage.model.StoredFile.class); + when(fileB.getId()).thenReturn(2L); + when(storedFileRepository.findAllByIdInAndOwner(any(), eq(user))) + .thenReturn(java.util.List.of(fileA, fileB)); + + FolderService.BulkMoveResult result = + service.bulkMoveFilesToFolder(target.getId(), java.util.List.of(1L, 2L, 3L, 4L)); + + assertThat(result.movedFileIds()).containsExactly(1L, 2L); + assertThat(result.skippedFileIds()).containsExactly(3L, 4L); + } + + // ─── helpers ──────────────────────────────────────────────────────────────── + + private Folder makeFolder(UUID id, Folder parent) { + Folder f = new Folder(); + f.setId(id); + f.setOwner(user); + f.setName("f-" + id.toString().substring(0, 8)); + f.setParent(parent); + return f; + } +} diff --git a/docker/base/Dockerfile b/docker/base/Dockerfile index 6275bac775..c3a2c0d551 100644 --- a/docker/base/Dockerfile +++ b/docker/base/Dockerfile @@ -630,9 +630,10 @@ RUN set -eux; \ RUN set -eux; \ mkdir -p /configs /configs/cache /configs/heap_dumps /logs /customFiles \ /pipeline/watchedFolders /pipeline/finishedFolders \ + /storage \ /tmp/stirling-pdf/heap_dumps; \ chown -R stirlingpdfuser:stirlingpdfgroup \ - /home/stirlingpdfuser /configs /logs /customFiles /pipeline \ + /home/stirlingpdfuser /configs /logs /customFiles /pipeline /storage \ /tmp/stirling-pdf; \ chmod 750 /tmp/stirling-pdf; \ chmod 750 /tmp/stirling-pdf/heap_dumps diff --git a/docker/embedded/Dockerfile b/docker/embedded/Dockerfile index 1462bec52a..89913af09a 100644 --- a/docker/embedded/Dockerfile +++ b/docker/embedded/Dockerfile @@ -84,7 +84,8 @@ RUN set -eux; \ ln -s /configs /app/configs; \ ln -s /customFiles /app/customFiles; \ ln -s /pipeline /app/pipeline; \ - chown -h stirlingpdfuser:stirlingpdfgroup /app/logs /app/configs /app/customFiles /app/pipeline; \ + ln -s /storage /app/storage; \ + chown -h stirlingpdfuser:stirlingpdfgroup /app/logs /app/configs /app/customFiles /app/pipeline /app/storage; \ chown stirlingpdfuser:stirlingpdfgroup /app; \ chmod 750 /tmp/stirling-pdf; \ chmod 750 /tmp/stirling-pdf/heap_dumps; \ diff --git a/docker/embedded/Dockerfile.fat b/docker/embedded/Dockerfile.fat index 81c10943f6..f9754b4721 100644 --- a/docker/embedded/Dockerfile.fat +++ b/docker/embedded/Dockerfile.fat @@ -80,7 +80,8 @@ RUN set -eux; \ ln -s /configs /app/configs; \ ln -s /customFiles /app/customFiles; \ ln -s /pipeline /app/pipeline; \ - chown -h stirlingpdfuser:stirlingpdfgroup /app/logs /app/configs /app/customFiles /app/pipeline; \ + ln -s /storage /app/storage; \ + chown -h stirlingpdfuser:stirlingpdfgroup /app/logs /app/configs /app/customFiles /app/pipeline /app/storage; \ chown stirlingpdfuser:stirlingpdfgroup /app; \ chmod 750 /tmp/stirling-pdf; \ chmod 750 /tmp/stirling-pdf/heap_dumps; \ diff --git a/docker/embedded/Dockerfile.ultra-lite b/docker/embedded/Dockerfile.ultra-lite index b748dbab82..d091d7b3fa 100644 --- a/docker/embedded/Dockerfile.ultra-lite +++ b/docker/embedded/Dockerfile.ultra-lite @@ -99,11 +99,11 @@ RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/a curl \ shadow \ util-linux && \ - mkdir -p $HOME /configs /logs /customFiles /pipeline/watchedFolders /pipeline/finishedFolders /tmp/stirling-pdf /tmp/stirling-pdf/heap_dumps && \ + mkdir -p $HOME /configs /logs /customFiles /pipeline/watchedFolders /pipeline/finishedFolders /storage /tmp/stirling-pdf /tmp/stirling-pdf/heap_dumps && \ mkdir -p /usr/share/fonts/opentype/noto && \ # User permissions addgroup -S stirlingpdfgroup && adduser -S stirlingpdfuser -G stirlingpdfgroup && \ - chown -R stirlingpdfuser:stirlingpdfgroup $HOME /configs /customFiles /pipeline /tmp/stirling-pdf + chown -R stirlingpdfuser:stirlingpdfgroup $HOME /configs /customFiles /pipeline /storage /tmp/stirling-pdf # Copy scripts and built artifacts after OS package layer to maximize cache reuse. COPY --chown=1000:1000 scripts/init-without-ocr.sh /scripts/init-without-ocr.sh diff --git a/docker/embedded/compose/docker-compose-latest-fat-security.yml b/docker/embedded/compose/docker-compose-latest-fat-security.yml index 7abd1acd6c..7efa65a718 100644 --- a/docker/embedded/compose/docker-compose-latest-fat-security.yml +++ b/docker/embedded/compose/docker-compose-latest-fat-security.yml @@ -20,6 +20,7 @@ services: - ../../../stirling/latest/data:/usr/share/tessdata:rw - ../../../stirling/latest/config:/configs:rw - ../../../stirling/latest/logs:/logs:rw + - ../../../stirling/latest/storage:/storage:rw environment: DISABLE_ADDITIONAL_FEATURES: "false" SECURITY_ENABLELOGIN: "false" @@ -36,5 +37,4 @@ services: METRICS_ENABLED: "true" SYSTEM_GOOGLEVISIBILITY: "true" SHOW_SURVEY: "true" - STORAGE_LOCAL_BASEPATH: /configs/storage restart: unless-stopped diff --git a/docker/embedded/compose/test_cicd.yml b/docker/embedded/compose/test_cicd.yml index fe165c8644..f42087291f 100644 --- a/docker/embedded/compose/test_cicd.yml +++ b/docker/embedded/compose/test_cicd.yml @@ -16,6 +16,7 @@ services: - ../../../stirling/latest/data:/usr/share/tessdata:rw - ../../../stirling/latest/config:/configs:rw - ../../../stirling/latest/logs:/logs:rw + - ../../../stirling/latest/storage:/storage:rw environment: DISABLE_ADDITIONAL_FEATURES: "false" SECURITY_ENABLELOGIN: "true" @@ -31,4 +32,9 @@ services: SYSTEM_GOOGLEVISIBILITY: "true" SYSTEM_ENABLEMOBILESCANNER: "true" SECURITY_CUSTOMGLOBALAPIKEY: "123456789" + # Folder management + file-storage features the cucumber + # `folders_and_files.feature` suite needs to upload PDFs against. + # The folder endpoints and the storage upload endpoint short-circuit + # to 403 "Storage is disabled" when this is left at its default false. + STORAGE_ENABLED: "true" restart: on-failure:5 diff --git a/frontend/editor/playwright.config.ts b/frontend/editor/playwright.config.ts index 1817a1361a..7a049e2632 100644 --- a/frontend/editor/playwright.config.ts +++ b/frontend/editor/playwright.config.ts @@ -4,10 +4,10 @@ import { defineConfig, devices } from "@playwright/test"; * Stirling-PDF E2E Test Configuration * * The suite is split into two projects: - * - `stubbed` β€” backend-free specs that mock `/api/v1/*` via `page.route()`. + * - `stubbed` - backend-free specs that mock `/api/v1/*` via `page.route()`. * Safe to run in CI without the Spring Boot server. Lives in * `src/core/tests/stubbed/**`. - * - `live` β€” specs that require a real backend on `localhost:8080` + * - `live` - specs that require a real backend on `localhost:8080` * (auth, admin mutation, real tool round-trips). Lives in * `src/core/tests/live/**`. * @@ -35,7 +35,7 @@ export default defineConfig({ expect: { timeout: 10_000 }, use: { - baseURL: "http://localhost:5173", + baseURL: process.env.PLAYWRIGHT_BASE_URL ?? "http://localhost:5173", trace: "on-first-retry", screenshot: "only-on-failure", video: "on-first-retry", @@ -44,14 +44,14 @@ export default defineConfig({ }, projects: [ - // Stubbed β€” no backend required, chromium-only for CI speed + // Stubbed - no backend required, chromium-only for CI speed { name: "stubbed", testDir: "./src/core/tests/stubbed", use: chromiumViewport, }, - // Live setup β€” runs once before the live suite to perform the real + // Live setup - runs once before the live suite to perform the real // forced-password-change first-login flow against a freshly-booted // backend. The live project depends on it. { @@ -61,7 +61,7 @@ export default defineConfig({ use: chromiumViewport, }, - // Live backend β€” auth + admin-mutation + real-tool smoke + // Live backend - auth + admin-mutation + real-tool smoke { name: "live", testDir: "./src/core/tests/live", @@ -69,7 +69,7 @@ export default defineConfig({ dependencies: ["live-setup"], }, - // Enterprise β€” license-gated SSO/SAML/audit/teams against keycloak compose + // Enterprise - license-gated SSO/SAML/audit/teams against keycloak compose // Uses port 8080 directly (the docker compose stack publishes the // backend's built-in frontend there); the Vite dev server is bypassed // because the OAuth/SAML callback URLs are registered against 8080. @@ -98,7 +98,7 @@ export default defineConfig({ webServer: { // In CI, serve a pre-built `dist/` via `vite preview` so the heavy tool // pages don't pay vite's on-demand transform cost on first hit (which - // blew the 30s navigationTimeout under --workers=3 β€” see + // blew the 30s navigationTimeout under --workers=3 - see // all-tool-pages-load.spec.ts). Locally, keep `vite` dev for HMR. command: process.env.CI ? "npx vite preview --port 5173 --strictPort" diff --git a/frontend/editor/public/locales/en-GB/translation.toml b/frontend/editor/public/locales/en-GB/translation.toml index 5ae0cfc435..547a6cd674 100644 --- a/frontend/editor/public/locales/en-GB/translation.toml +++ b/frontend/editor/public/locales/en-GB/translation.toml @@ -1467,7 +1467,7 @@ settingsOverview = "This is the Settings Panel. Admin settings systemCustomization = "We have extensive ways to customise the UI: System Settings let you change the app name and languages, Features allows server certificate management, and Endpoints lets you enable or disable specific tools for your users." teamsAndUsers = "Manage Teams and individual users here. You can invite new users via email, shareable links, or create custom accounts for them yourself." welcome = "Welcome to the Admin Tour! Let's explore the powerful enterprise features and settings available to system administrators." -wrapUp = "That's the admin tour! You've seen the enterprise features that make Stirling PDF a powerful, customisable solution for organisations. You can replay it anytime β€” just open Settings and find it here in the Tours section under Help." +wrapUp = "That's the admin tour! You've seen the enterprise features that make Stirling PDF a powerful, customisable solution for organisations. You can replay it anytime - just open Settings and find it here in the Tours section under Help." [adminUserSettings] actions = "Actions" @@ -1525,7 +1525,7 @@ comment = "Comment" comments = "Comments" contents = "Text" delete = "Delete" -desc = "Use highlight, pen, text, and notes. Changes stay liveβ€”no flattening required." +desc = "Use highlight, pen, text, and notes. Changes stay live-no flattening required." drawing = "Drawing" duplicate = "Duplicate" editCircle = "Edit Circle" @@ -1851,6 +1851,13 @@ label = "Open menu for {{title}}" [automate.files] placeholder = "Select files to process with this automation" +[automate.folderScanWarning] +advice = "You can still download the file (e.g. to inspect or hand-edit it), but the unsupported steps will need to be removed before the backend can run it." +cancel = "Cancel" +confirm = "Export anyway" +intro = "Folder scanning runs on the backend, so it can only execute tools that have a backend endpoint. The following step(s) in this automation do not, and will fail when the pipeline runs:" +title = "Some steps cannot run in folder scanning" + [automate.importModal] cancel = "Cancel" confirm = "Import" @@ -2082,9 +2089,9 @@ placeholder = "Number of pages" title = "Last N Pages" [bulkSelection.operators] -and = "AND: & or \"and\" β€” require both conditions (e.g., 1-50 & even)" -comma = "Comma: , or | β€” combine selections (e.g., 1-10, 20)" -not = "NOT: ! or \"not\" β€” exclude pages (e.g., 3n & not 30)" +and = "AND: & or \"and\" - require both conditions (e.g., 1-50 & even)" +comma = "Comma: , or | - combine selections (e.g., 1-10, 20)" +not = "NOT: ! or \"not\" - exclude pages (e.g., 3n & not 30)" text = "AND has higher precedence than comma. NOT applies within the document range." title = "Operators" @@ -2780,8 +2787,8 @@ title = "These PDFs look highly different" [compare.edited] label = "Edited PDF" -selectBaseFirst = "Select original PDF first" placeholder = "Select the edited PDF" +selectBaseFirst = "Select original PDF first" [compare.error] filesMissing = "Unable to locate the selected files. Please re-select them." @@ -2862,9 +2869,9 @@ pageLabel = "Page" [compare.swap] confirm = "Swap and Re-run" -label = "Swap" confirmBody = "This will rerun the tool. Are you sure you want to swap the order of Original and Edited?" confirmTitle = "Re-run comparison?" +label = "Swap" [compare.toasts] unlinkedBody = "Tip: Arrow Up/Down scroll both panes; panning only moves the active pane." @@ -3184,7 +3191,7 @@ showPreferencesBtn = "Manage preferences" title = "How we use Cookies" [cookieBanner.popUp.description] -1 = "We use cookies and other technologies to make Stirling PDF work better for youβ€”helping us improve our tools and keep building features you'll love." +1 = "We use cookies and other technologies to make Stirling PDF work better for you-helping us improve our tools and keep building features you'll love." 2 = "If you'd rather not, clicking 'No Thanks' will only enable the essential cookies needed to keep things running smoothly." [cookieBanner.preferencesModal] @@ -3197,16 +3204,16 @@ subtitle = "Cookie Usage" title = "Consent Preferences Center" [cookieBanner.preferencesModal.analytics] -description = "These cookies help us understand how our tools are being used, so we can focus on building the features our community values most. Rest assuredβ€”Stirling PDF cannot and will never track the content of the documents you work with." +description = "These cookies help us understand how our tools are being used, so we can focus on building the features our community values most. Rest assured-Stirling PDF cannot and will never track the content of the documents you work with." title = "Analytics" [cookieBanner.preferencesModal.description] 1 = "Stirling PDF uses cookies and similar technologies to enhance your experience and understand how our tools are used. This helps us improve performance, develop the features you care about, and provide ongoing support to our users." -2 = "Stirling PDF cannotβ€”and will neverβ€”track or access the content of the documents you use." +2 = "Stirling PDF cannot-and will never-track or access the content of the documents you use." 3 = "Your privacy and trust are at the core of what we do." [cookieBanner.preferencesModal.necessary] -description = "These cookies are essential for the website to function properly. They enable core features like setting your privacy preferences, logging in, and filling out formsβ€”which is why they can't be turned off." +description = "These cookies are essential for the website to function properly. They enable core features like setting your privacy preferences, logging in, and filling out forms-which is why they can't be turned off." [cookieBanner.preferencesModal.necessary.title] 1 = "Strictly Necessary Cookies" @@ -3759,13 +3766,209 @@ expand = "Expand sidebar" files = "Files" googleDrive = "Google Drive" googleDriveDisabled = "Google Drive is not configured" +leaveMyFiles = "Leave My Files" +myFiles = "My Files" noFiles = "No files yet" -openFileManager = "Open file manager" +openFileManager = "Browse all files & folders" openFromComputer = "Open from computer" openSettings = "Open settings" search = "Search" searchPlaceholder = "Search files..." +[filesPage] +addToWorkspace = "Add to workspace" +addToWorkspaceCount = "Add {{count}} to workspace" +allFiles = "All files" +back = "Back" +backToFolder = "Back to {{folder}}" +backToMyFiles = "Back to My Files" +breadcrumbs = "Folder path" +cancel = "Cancel" +clearSearch = "Clear search" +clearSelection = "Clear selection" +closeDetails = "Close details" +create = "Create" +cycleBlocked = "Can't move a folder into one of its own subfolders." +delete = "Delete" +deleteFolder = "Delete folder" +deleteFolderBody = "Delete folder \"{{name}}\"?" +deleteFolderConfirm = "Delete folder \"{{name}}\"? Files inside will be moved to All files. {{count}} file(s) affected." +deleteFolderContents = "Also delete {{count}} file(s) inside the folder" +deleteFolderContentsWarning = "Files will be permanently removed and cannot be recovered." +deleteFolderError = "Could not delete the folder. Try again." +deleteFolderKeepHint = "Files inside will be moved to All files." +deleteFolderTitle = "Delete folder?" +deselectAll = "Clear selection" +details = "Details" +detailsCount = "{{count}} files selected" +dismissError = "Dismiss" +download = "Download" +downloadAll = "Download all" +downloadVersion = "Download this version" +dropOverlay = "Drop files to upload" +dropOverlaySub = "Files start in Local. Use 'Move to' or 'Save to cloud' to organise them into a folder." +file = "File" +fileMenu = "File actions" +folder = "Folder" +folderItems = "{{count}} items" +folderMenu = "Folder actions" +inPath = "in {{path}}" +inWorkspace = "Open" +inWorkspaceAria = "Already in workspace" +loading = "Loading…" +localFoldersUnavailable = "Folders are cloud-only - save a file to the cloud to organise it." +moreActions = "More folder actions" +moveLocalToCloudBlocked = "Local-only files can't be moved into cloud folders. Save them to the cloud first." +moveSkippedRemote = "{{count}} file(s) couldn't be moved on the server (no permission or already deleted)." +moveTo = "Move to…" +myFiles = "My Files" +newFolder = "New folder" +newFolderStorageDisabled = "Server folder storage isn't enabled. Ask your admin to turn it on." +newFolderTabUnavailable = "Switch to All or Cloud to create folders." +newRootFolder = "New folder at root" +offlineNoFolderEdits = "Server folder sync unavailable - folder changes are disabled. Check sign-in and storage configuration." +open = "Open" +openInWorkbench = "Open in workbench" +openVersionInWorkspace = "Open in workspace" +originFilter = "Filter by source" +quickView = "Quick view" +refresh = "Refresh from server" +remove = "Delete" +removeConfirm = "Delete {{count}} file(s)? This cannot be undone." +removeVersion = "Remove this version" +rename = "Rename" +renameFolder = "Rename folder" +resizeFolderTree = "Resize folder tree (arrow keys, Shift for bigger steps; double-click to auto-fit)" +save = "Save" +saveToServer = "Save to server" +search = "Search" +searchPlaceholder = "Search this folder & subfolders" +selectAll = "Select all" +selectAllHint = "Click to select all. Tip: hold Ctrl (or Cmd) to add files one at a time, Shift to select a range." +selectedCount = "{{count}} selected" +selectFile = "Select file {{name}}" +shareDisabledHint = "File sharing isn't enabled on this server. Ask your admin to set storage.sharing.enabled=true to turn on share links and per-user access." +shareManage = "Manage sharing" +showDetails = "Show details" +summary = "{{count}} items" +syncFailed = "Folder sync failed: {{message}}" +syncPartial = "Folder sync partial: {{failed}} of {{total}} folders could not be merged." +tree = "Folders" +upload = "Upload" +uploadedToLocal = "Uploaded files start in Local. Use 'Save to cloud' to put them in a folder." +versionActions = "Version actions" +versionCollapse = "Collapse middle versions" +versionOrigin = "Original upload" +versionsCount = "{{count}} versions" +versionShowHidden = "Show {{count}} earlier versions" +viewVersion = "View this version" +appearance.colour = "Colour" +appearance.icon = "Icon" +appearance.title = "Appearance" +appearance.useColour = "Use colour {{c}}" +column.modified = "Modified" +column.name = "Name" +column.size = "Size" +column.type = "Type" +empty.hint = "Drop PDFs anywhere on this page to upload, or use the New folder button to organise your files." +empty.newFolderCta = "Create folder" +empty.title = "This folder is empty" +empty.uploadCta = "Upload files" +empty.cloud.hint = "Upload a file to start, or create a folder to organise." +empty.cloud.offlineHint = "Reconnect to load your cloud library." +empty.cloud.offlineTitle = "No cached cloud files" +empty.cloud.title = "No cloud files yet" +empty.imSharing.hint = "Invite a teammate to one of your files to see it listed here." +empty.imSharing.title = "Not sharing with anyone yet" +empty.local.hint = "Files saved without uploading stay here. Drop a file to add one." +empty.local.title = "No local-only files" +empty.recent.hint = "Files you open or edit will appear here." +empty.recent.title = "Nothing modified yet" +empty.shared.hint = "When someone shares a file via link, it appears here." +empty.shared.title = "Nothing shared with you" +empty.sharedByMe.hint = "Create a share link on any of your files to surface it here." +empty.sharedByMe.title = "No share links yet" +error.actionFailed = "Could not {{action}}." +error.actionFailedDetail = "Could not {{action}}: {{message}}" +error.deleteFolderFailed = "Could not delete folder." +error.deleteFolderFailedDetail = "Could not delete folder: {{message}}" +error.folderAppearanceFailed = "Could not update folder appearance." +error.folderAppearanceFailedDetail = "Could not update folder appearance: {{message}}" +error.moveFilesFailed = "Could not move files." +error.moveFilesFailedDetail = "Could not move files: {{message}}" +error.moveFolderFailed = "Could not move folder." +error.moveFolderFailedDetail = "Could not move folder: {{message}}" +error.removeFilesFailed = "Could not remove files." +error.removeFilesFailedDetail = "Could not remove files: {{message}}" +error.uploadFilesFailed = "Could not upload files." +error.uploadFilesFailedDetail = "Could not upload files: {{message}}" +field.added = "Added" +field.count = "Files" +field.folder = "Folder" +field.modified = "Modified" +field.name = "Name" +field.size = "Size" +field.toolHistory = "Tool history" +field.toolHistoryAtVersion = "Cumulative tool chain" +field.totalSize = "Total size" +field.type = "Type" +field.versionHistory = "Version journey" +folderName.cancel = "Cancel" +folderName.error = "Could not save folder. Try again." +folderName.label = "Folder name" +folderName.placeholder = "Folder name" +moveDialog.cancel = "Cancel" +moveDialog.confirm = "Move here" +moveDialog.error = "Could not move. Try again." +moveDialog.hint = "Pick a destination folder. Tip: you can also drag and drop files onto folders in the tree on the left." +moveDialog.newFolderCancel = "Discard" +moveDialog.newFolderCreate = "Create" +moveDialog.newFolderError = "Could not create folder. Try again." +moveDialog.newFolderLabel = "New folder name" +moveDialog.newFolderPlaceholder = "Folder name" +moveDialog.newFolderToggle = "Create new folder…" +moveDialog.title = "Move to folder" +origin.all = "All sources" +origin.cloud = "Cloud" +origin.cloudHint = "Stored on the Stirling server" +origin.local = "Local" +origin.localHint = "Only stored in this browser" +origin.shared = "Shared" +origin.sharedHint = "Shared with you via link" +sort.modifiedAsc = "Oldest first" +sort.modifiedDesc = "Recent first" +sort.nameAsc = "Name Aβ†’Z" +sort.nameDesc = "Name Zβ†’A" +sort.sizeAsc = "Smallest first" +sort.sizeDesc = "Largest first" +syncError.client = "Folder sync failed." +syncError.network = "Could not reach the server." +syncError.server = "Server error during folder sync." +tabName.imSharing = "I'm sharing" +tabName.local = "Local" +tabName.recent = "Recent" +tabName.shared = "Shared with me" +tabName.sharedByMe = "Shared by me" +tabs.all = "All" +tabs.ariaLabel = "File views" +tabs.cloud = "Cloud" +tabs.imSharing = "I'm sharing" +tabs.local = "Local" +tabs.recent = "Recent" +tabs.shared = "Shared with me" +tabs.sharedByMe = "Shared by me" +treeMenu.actions = "Folder actions for {{name}}" +treeMenu.collapse = "Collapse folder" +treeMenu.delete = "Delete folder" +treeMenu.expand = "Expand folder" +treeMenu.newSubfolder = "New subfolder" +treeMenu.rename = "Rename" +typeFilter.allTypes = "All types" +typeFilter.label = "Filter by type" +viewMode.grid = "Grid view" +viewMode.label = "View mode" +viewMode.list = "List view" + [fileToPDF] credit = "This service uses LibreOffice and Unoconv for file conversion." header = "Convert any file to PDF" @@ -4904,7 +5107,7 @@ toolInterface = "This is the Crop tool interface. As you can se viewer = "The Viewer lets you read and annotate your PDFs." viewSwitcher = "Use these controls to select how you want to view your PDFs." workbench = "This is the Workbench - the main area where you view and edit your PDFs." -wrapUp = "You're all set! You can replay this tour anytime β€” just open Settings and find it here in the Tours section under Help." +wrapUp = "You're all set! You can replay this tour anytime - just open Settings and find it here in the Tours section under Help." [onboarding.buttons] back = "Back" @@ -5244,9 +5447,9 @@ description = "Use n in formulas for patterns." title = "Mathematical Functions" [pageSelection.tooltip.operators] -and = "AND: & or \"and\" β€” require both conditions (e.g., 1-50 & even)" -comma = "Comma: , or | β€” combine selections (e.g., 1-10, 20)" -not = "NOT: ! or \"not\" β€” exclude pages (e.g., 3n & not 30)" +and = "AND: & or \"and\" - require both conditions (e.g., 1-50 & even)" +comma = "Comma: , or | - combine selections (e.g., 1-10, 20)" +not = "NOT: ! or \"not\" - exclude pages (e.g., 3n & not 30)" text = "AND has higher precedence than comma. NOT applies within the document range." title = "Operators" @@ -5434,7 +5637,7 @@ modified = "Edited" unsaved = "Edited" [pdfTextEditor.disclaimer] -alpha = "This alpha viewer is still evolvingβ€”certain fonts, colours, transparency effects, and layout details may shift slightly. Please double-check the generated PDF before sharing." +alpha = "This alpha viewer is still evolving-certain fonts, colours, transparency effects, and layout details may shift slightly. Please double-check the generated PDF before sharing." heading = "Preview Limitations" previewVariance = "Some visuals (such as table borders, shapes, or annotation appearances) may not display exactly in the preview. The exported PDF keeps the original drawing commands whenever possible." textFocus = "This workspace focuses on editing text and repositioning embedded images. Complex page artwork, form widgets, and layered graphics are preserved for export but are not fully editable here." @@ -5512,7 +5715,7 @@ paragraph = "Paragraph page" sparse = "Sparse text" [pdfTextEditor.tooltip.alpha] -text = "This alpha viewer is still evolvingβ€”certain fonts, colours, transparency effects, and layout details may shift slightly. Please double-check the generated PDF before sharing." +text = "This alpha viewer is still evolving-certain fonts, colours, transparency effects, and layout details may shift slightly. Please double-check the generated PDF before sharing." title = "Alpha Viewer" [pdfTextEditor.tooltip.header] @@ -6569,51 +6772,6 @@ title = "High Contrast" text = "Completely invert all colours in the PDF, creating a negative-like effect. Useful for creating dark mode versions of documents or reducing eye strain in low-light conditions." title = "Invert All Colours" -[workbenchBar] -annotations = "Annotations" -applyRedactionsFirst = "Apply redactions first" -closeAll = "Close All Files" -closePdf = "Close PDF" -closeSelected = "Close Selected Files" -deleteSelected = "Delete Selected Pages" -deselectAll = "Deselect All" -downloadAll = "Download All" -downloadSelected = "Download Selected Files" -draw = "Draw" -exitRedaction = "Exit Redaction Mode" -exportAll = "Export PDF" -exportSelected = "Export Selected Pages" -formFill = "Fill Form" -language = "Language" -panMode = "Pan Mode" -print = "Print PDF" -readAloud = "Read Aloud" -readAloudLanguage = "Language" -readAloudSpeed = "Speed" -redact = "Redact" -rotateLeft = "Rotate Left" -rotateRight = "Rotate Right" -ruler = "Ruler / Measure" -save = "Save" -saveAll = "Save All" -saveAs = "Save As" -saveChanges = "Save Changes" -search = "Search PDF" -selectAll = "Select All" -selectByNumber = "Select by Page Numbers" -selectLanguage = "Select language" -share = "Share" -toggleAnnotations = "Toggle Annotations Visibility" -toggleAttachments = "Toggle Attachments" -toggleBookmarks = "Toggle Bookmarks" -toggleComments = "Comments" -toggleLayers = "Toggle Layers" -toggleSidebar = "Toggle Sidebar" -toggleTheme = "Toggle Theme" -activeFiles = "Active Files" -multiTool = "Multi-Tool" -viewer = "Viewer" - [rotate] rotateLeft = "Rotate Anticlockwise" rotateRight = "Rotate Clockwise" @@ -6752,7 +6910,7 @@ useCase2 = "Split flatbed batches into separate files" useCase3 = "Break collages into individual photos" useCase4 = "Pull photos from documents" whatThisDoes = "What this does" -whatThisDoesDesc = "Automatically finds and extracts each photo from a scanned page or composite imageβ€”no manual cropping." +whatThisDoesDesc = "Automatically finds and extracts each photo from a scanned page or composite image-no manual cropping." whenToUse = "When to use" [search] @@ -6907,6 +7065,20 @@ auto = "Auto" fitPage = "Fit page" fitWidth = "Fit width" +[settings.help] +label = "Tours" +title = "Help" + +[settings.help.adminTour] +description = "Explore team management, system settings, and enterprise features." +start = "Start" +title = "Admin Tour" + +[settings.help.toolsTour] +description = "Walk through uploading files, picking a tool, and reviewing results." +start = "Start" +title = "Tools Tour" + [settings.hotkeys] capturing = "Press keys… (Esc to cancel)" change = "Change shortcut" @@ -7038,20 +7210,6 @@ title = "Team" enableLoginFirst = "Enable login mode first" requiresEnterprise = "Requires Enterprise license" -[settings.help] -label = "Tours" -title = "Help" - -[settings.help.adminTour] -description = "Explore team management, system settings, and enterprise features." -start = "Start" -title = "Admin Tour" - -[settings.help.toolsTour] -description = "Walk through uploading files, picking a tool, and reviewing results." -start = "Start" -title = "Tools Tour" - [settings.workspace] people = "People" teams = "Teams" @@ -8249,8 +8407,8 @@ csvStats = "{{rows}} rows Β· {{columns}} columns Β· {{size}}" emptyFile = "Empty file" fileTypeBadge = "{{type}} File" htmlPreview = "HTML preview" -htmlPreviewWarning = "HTML preview β€” external resources may not load Β· {{size}}" -invalidJson = "Invalid JSON β€” showing raw content" +htmlPreviewWarning = "HTML preview - external resources may not load Β· {{size}}" +invalidJson = "Invalid JSON - showing raw content" lineNumbers = "Line numbers" loading = "Loading..." renderMarkdown = "Render markdown" @@ -8460,6 +8618,50 @@ bullet3 = "Image will be resized to fit signature area" description = "Upload a pre-created signature image. Ideal if you have a scanned signature or company logo." title = "Upload Signature Image" +[workbenchBar] +activeFiles = "Active Files" +annotations = "Annotations" +applyRedactionsFirst = "Apply redactions first" +closeAll = "Close All Files" +closePdf = "Close PDF" +closeSelected = "Close Selected Files" +deleteSelected = "Delete Selected Pages" +deselectAll = "Deselect All" +downloadAll = "Download All" +downloadSelected = "Download Selected Files" +draw = "Draw" +exitRedaction = "Exit Redaction Mode" +exportAll = "Export PDF" +exportSelected = "Export Selected Pages" +formFill = "Fill Form" +language = "Language" +multiTool = "Multi-Tool" +panMode = "Pan Mode" +print = "Print PDF" +readAloud = "Read Aloud" +readAloudLanguage = "Language" +readAloudSpeed = "Speed" +redact = "Redact" +rotateLeft = "Rotate Left" +rotateRight = "Rotate Right" +ruler = "Ruler / Measure" +save = "Save" +saveAll = "Save All" +saveAs = "Save As" +saveChanges = "Save Changes" +search = "Search PDF" +selectAll = "Select All" +selectByNumber = "Select by Page Numbers" +selectLanguage = "Select language" +share = "Share" +toggleAnnotations = "Toggle Annotations Visibility" +toggleAttachments = "Toggle Attachments" +toggleBookmarks = "Toggle Bookmarks" +toggleComments = "Comments" +toggleLayers = "Toggle Layers" +toggleSidebar = "Toggle Sidebar" +toggleTheme = "Toggle Theme" +viewer = "Viewer" [workspace] title = "Workspace" diff --git a/frontend/editor/src/core/components/AppProviders.tsx b/frontend/editor/src/core/components/AppProviders.tsx index 4f6f5566a5..3501658b98 100644 --- a/frontend/editor/src/core/components/AppProviders.tsx +++ b/frontend/editor/src/core/components/AppProviders.tsx @@ -32,6 +32,7 @@ import { useLogoAssets } from "@app/hooks/useLogoAssets"; import AppConfigLoader from "@app/components/shared/AppConfigLoader"; import { RedactionProvider } from "@app/contexts/RedactionContext"; import { FormFillProvider } from "@app/tools/formFill/FormFillContext"; +import { FolderProvider } from "@app/contexts/FolderContext"; // Component to initialize scarf tracking (must be inside AppConfigProvider) function ScarfTrackingInitializer() { @@ -125,39 +126,41 @@ export function AppProviders({ enableUrlSync={true} enablePersistence={true} > - - - - - - - - - - - - - - - - - - {children} - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + {children} + + + + + + + + + + + + + + + + diff --git a/frontend/editor/src/core/components/FileManager.tsx b/frontend/editor/src/core/components/FileManager.tsx index ea54093dcd..16cded856c 100644 --- a/frontend/editor/src/core/components/FileManager.tsx +++ b/frontend/editor/src/core/components/FileManager.tsx @@ -6,7 +6,6 @@ import type { FileId } from "@app/types/file"; import { useFileManager } from "@app/hooks/useFileManager"; import { useFilesModalContext } from "@app/contexts/FilesModalContext"; import { useAppConfig } from "@app/contexts/AppConfigContext"; -import { Tool } from "@app/types/tool"; import MobileLayout from "@app/components/fileManager/MobileLayout"; import DesktopLayout from "@app/components/fileManager/DesktopLayout"; import DragOverlay from "@app/components/fileManager/DragOverlay"; @@ -20,8 +19,14 @@ import { loadScript } from "@app/utils/scriptLoader"; import { useAllFiles } from "@app/contexts/FileContext"; import { useFileActions } from "@app/contexts/file/fileHooks"; +/** + * Structural prop: anything that exposes an optional `supportedFormats` + * string array. Both `Tool` (from `@app/types/tool`) and `ToolRegistryEntry` + * (from `@app/data/toolsTaxonomy`) satisfy this, so callers can pass either + * without an `as any` cast. + */ interface FileManagerProps { - selectedTool?: Tool | null; + selectedTool?: { supportedFormats?: string[] } | null; } const FileManager: React.FC = ({ selectedTool }) => { diff --git a/frontend/editor/src/core/components/filesPage/DeleteFolderDialog.tsx b/frontend/editor/src/core/components/filesPage/DeleteFolderDialog.tsx new file mode 100644 index 0000000000..e2d7edc438 --- /dev/null +++ b/frontend/editor/src/core/components/filesPage/DeleteFolderDialog.tsx @@ -0,0 +1,130 @@ +import React, { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + Alert, + Button, + Checkbox, + Group, + Modal, + Stack, + Text, +} from "@mantine/core"; +import ErrorOutlineIcon from "@mui/icons-material/ErrorOutlined"; + +import { FolderRecord } from "@app/types/folder"; + +interface DeleteFolderDialogProps { + opened: boolean; + folder: FolderRecord | null; + /** Number of files inside the folder (and subtree). */ + fileCount: number; + onClose: () => void; + /** Confirm; `deleteContents` is true when the user opted in to delete files. */ + onConfirm: (deleteContents: boolean) => void | Promise; +} + +export function DeleteFolderDialog({ + opened, + folder, + fileCount, + onClose, + onConfirm, +}: DeleteFolderDialogProps) { + const { t } = useTranslation(); + const [deleteContents, setDeleteContents] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (opened) { + setDeleteContents(false); + setSubmitting(false); + setError(null); + } + }, [opened]); + + if (!folder) return null; + + return ( + + + + {t("filesPage.deleteFolderBody", 'Delete folder "{{name}}"?', { + name: folder.name, + })} + + {fileCount > 0 && ( + setDeleteContents(e.currentTarget.checked)} + disabled={submitting} + label={t( + "filesPage.deleteFolderContents", + "Also delete {{count}} file(s) inside the folder", + { count: fileCount }, + )} + /> + )} + {fileCount > 0 && ( + + {deleteContents + ? t( + "filesPage.deleteFolderContentsWarning", + "Files will be permanently removed and cannot be recovered.", + ) + : t( + "filesPage.deleteFolderKeepHint", + "Files inside will be moved to All files.", + )} + + )} + {error && ( + } + variant="light" + role="alert" + > + {error} + + )} + + + + + + + ); +} diff --git a/frontend/editor/src/core/components/filesPage/FileDetailsPanel.tsx b/frontend/editor/src/core/components/filesPage/FileDetailsPanel.tsx new file mode 100644 index 0000000000..e7086f2fc6 --- /dev/null +++ b/frontend/editor/src/core/components/filesPage/FileDetailsPanel.tsx @@ -0,0 +1,658 @@ +import React, { useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { ActionIcon, Badge, Button, Menu, Tooltip } from "@mantine/core"; +import CloseIcon from "@mui/icons-material/Close"; +import OpenInNewIcon from "@mui/icons-material/OpenInNew"; +import VisibilityIcon from "@mui/icons-material/Visibility"; +import DriveFileMoveIcon from "@mui/icons-material/DriveFileMove"; +import DeleteIcon from "@mui/icons-material/Delete"; +import DownloadIcon from "@mui/icons-material/Download"; +import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf"; +import HistoryIcon from "@mui/icons-material/History"; +import MoreVertIcon from "@mui/icons-material/MoreVert"; +import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; +import LinkIcon from "@mui/icons-material/Link"; +import CloudUploadIcon from "@mui/icons-material/CloudUpload"; + +import { FileId, ToolOperation } from "@app/types/file"; +import { ToolId } from "@app/types/toolId"; +import { FolderRecord } from "@app/types/folder"; +import { StirlingFileStub } from "@app/types/fileContext"; +import { formatFileSize, getFileDate } from "@app/utils/fileUtils"; +import { + downloadFileFromStorage, + downloadMultipleFiles, +} from "@app/utils/downloadUtils"; +import ToolChain from "@app/components/shared/ToolChain"; +import ShareManagementModal from "@app/components/shared/ShareManagementModal"; +import { useSharingEnabled } from "@app/hooks/useSharingEnabled"; +import { fileStorage } from "@app/services/fileStorage"; + +interface FileDetailsPanelProps { + selectedFileIds: FileId[]; + fileMap: Map; + currentFolder: FolderRecord | null; + onClose: () => void; + onAddToWorkspace: (fileIds: FileId[]) => void; + onQuickView: (fileId: FileId) => void; + onMove: (fileIds: FileId[]) => void; + onRemove: (fileIds: FileId[]) => void; + /** Save to server; only shown when at least one selected file is local-only. */ + onSaveToServer?: (files: StirlingFileStub[]) => void; +} + +export function FileDetailsPanel({ + selectedFileIds, + fileMap, + currentFolder, + onClose, + onAddToWorkspace, + onQuickView, + onMove, + onRemove, + onSaveToServer, +}: FileDetailsPanelProps) { + const { t } = useTranslation(); + const { sharingEnabled } = useSharingEnabled(); + const files = useMemo( + () => + selectedFileIds + .map((id) => fileMap.get(id)) + .filter((f): f is StirlingFileStub => Boolean(f)), + [selectedFileIds, fileMap], + ); + + // Hooks must run before any early return. + const [downloading, setDownloading] = useState(false); + const [shareModalOpen, setShareModalOpen] = useState(false); + // Version chain for the selected file; empty for v1 or multi-select. + const [versionChain, setVersionChain] = useState([]); + const singleFileForChain = files.length === 1 ? files[0] : null; + useEffect(() => { + if (!singleFileForChain) { + setVersionChain([]); + return; + } + let cancelled = false; + const rootId = (singleFileForChain.originalFileId ?? + singleFileForChain.id) as FileId; + fileStorage + .getHistoryChainStubs(rootId) + .then((chain) => { + if (!cancelled) setVersionChain(chain); + }) + .catch((err) => { + console.error("Failed to load version history", err); + if (!cancelled) setVersionChain([]); + }); + return () => { + cancelled = true; + }; + }, [singleFileForChain]); + + if (files.length === 0) { + return null; + } + + const single = files.length === 1 ? files[0]! : null; + const totalSize = files.reduce((sum, f) => sum + f.size, 0); + const ext = single ? (single.name.split(".").pop() ?? "").toUpperCase() : ""; + // Files still needing a server upload; drives Save-to-server visibility. + const localOnlyFiles = files.filter((f) => f.remoteStorageId == null); + + const handleDownload = async () => { + setDownloading(true); + try { + if (single) { + await downloadFileFromStorage(single); + } else { + await downloadMultipleFiles(files); + } + } catch (err) { + console.error("Download failed", err); + } finally { + setDownloading(false); + } + }; + + return ( + + ); +} + +function DetailField({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value} +
+ ); +} + +/** Tool that produced `version` from `prior`; null for v1. */ +function deltaToolFor( + version: StirlingFileStub, + prior: StirlingFileStub | null, +): ToolOperation | null { + if (!prior) return null; + const priorLen = prior.toolHistory?.length ?? 0; + const curr = version.toolHistory ?? []; + return curr[priorLen] ?? null; +} + +interface VersionTimelineProps { + /** Chain sorted oldest-first. */ + chain: StirlingFileStub[]; + /** Currently selected version. */ + currentId: FileId; + onQuickView: (fileId: FileId) => void; + onAddToWorkspace: (fileIds: FileId[]) => void; + onRemove: (fileIds: FileId[]) => void; +} + +/** Version timeline with per-row tool deltas and collapse-when-long. */ +function VersionTimeline({ + chain, + currentId, + onQuickView, + onAddToWorkspace, + onRemove, +}: VersionTimelineProps) { + const { t } = useTranslation(); + const [expandedIds, setExpandedIds] = useState>(new Set()); + const [showAllCollapsed, setShowAllCollapsed] = useState(false); + + // Newest-first ordering. + const ordered = useMemo( + () => + [...chain].sort( + (a, b) => (b.versionNumber ?? 1) - (a.versionNumber ?? 1), + ), + [chain], + ); + + // Index by versionNumber for prior-version lookup. + const byVersionNumber = useMemo(() => { + const map = new Map(); + for (const v of chain) { + map.set(v.versionNumber ?? 1, v); + } + return map; + }, [chain]); + + // Collapse middle when long: 3 newest + ellipsis + 2 oldest. + const COLLAPSE_THRESHOLD = 6; + const collapsible = ordered.length > COLLAPSE_THRESHOLD; + type Row = + | { kind: "version"; version: StirlingFileStub } + | { + kind: "ellipsis"; + hidden: number; + }; + const rows: Row[] = useMemo(() => { + if (!collapsible || showAllCollapsed) { + return ordered.map((v) => ({ kind: "version", version: v }) as Row); + } + const head = ordered + .slice(0, 3) + .map((v) => ({ kind: "version", version: v }) as Row); + const tail = ordered + .slice(-2) + .map((v) => ({ kind: "version", version: v }) as Row); + const hidden = ordered.length - 5; + return [...head, { kind: "ellipsis", hidden }, ...tail]; + }, [collapsible, showAllCollapsed, ordered]); + + const toggleExpand = (id: FileId) => { + setExpandedIds((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; + + return ( +
+
+ + {t("filesPage.field.versionHistory", "Version journey")} + + {t("filesPage.versionsCount", "{{count}} versions", { + count: ordered.length, + })} + +
+
    + {rows.map((row, idx) => { + const isLast = idx === rows.length - 1; + if (row.kind === "ellipsis") { + return ( +
  1. +
    + + {!isLast && ( + + )} +
    + +
  2. + ); + } + const v = row.version; + const isActive = v.id === currentId; + const isExpanded = expandedIds.has(v.id); + const prior = byVersionNumber.get((v.versionNumber ?? 1) - 1) ?? null; + const delta = deltaToolFor(v, prior); + return ( +
  3. +
    + + {!isLast && ( + + )} +
    +
    + +
    + {formatFileSize(v.size)} + {v.lastModified ? ( + <> + Β· + + {getFileDate({ lastModified: v.lastModified })} + + + ) : null} + {!isActive && ( + <> + + + + e.stopPropagation()} + > + + + + + } + onClick={() => onQuickView(v.id)} + > + {t("filesPage.viewVersion", "View this version")} + + } + onClick={() => onAddToWorkspace([v.id])} + > + {t( + "filesPage.openVersionInWorkspace", + "Open in workspace", + )} + + } + onClick={() => { + void downloadFileFromStorage(v); + }} + > + {t( + "filesPage.downloadVersion", + "Download this version", + )} + + + } + onClick={() => onRemove([v.id])} + > + {t( + "filesPage.removeVersion", + "Remove this version", + )} + + + + + )} +
    + {isExpanded && ( + // Filename + full cumulative tool chain. +
    + + {v.toolHistory && v.toolHistory.length > 0 && ( +
    + + {t( + "filesPage.field.toolHistoryAtVersion", + "Cumulative tool chain", + )} + + +
    + )} +
    + )} +
    +
  4. + ); + })} +
+ {collapsible && showAllCollapsed && ( + + )} +
+ ); +} + +/** Translated tool name via `home.{toolId}.title`. */ +function ToolLabel({ toolId }: { toolId: ToolId }) { + const { t } = useTranslation(); + return {t(`home.${toolId}.title`, toolId)}; +} diff --git a/frontend/editor/src/core/components/filesPage/FileGrid.tsx b/frontend/editor/src/core/components/filesPage/FileGrid.tsx new file mode 100644 index 0000000000..832d0463a4 --- /dev/null +++ b/frontend/editor/src/core/components/filesPage/FileGrid.tsx @@ -0,0 +1,1380 @@ +import React, { useCallback, useMemo, useRef } from "react"; +import { useTranslation } from "react-i18next"; +import { ActionIcon, Button, Checkbox, Menu, Tooltip } from "@mantine/core"; +import MoreVertIcon from "@mui/icons-material/MoreVert"; +import FolderIcon from "@mui/icons-material/Folder"; +import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf"; +import InsertDriveFileIcon from "@mui/icons-material/InsertDriveFile"; +import DriveFileMoveIcon from "@mui/icons-material/DriveFileMove"; +import DeleteIcon from "@mui/icons-material/Delete"; +import OpenInNewIcon from "@mui/icons-material/OpenInNew"; +import VisibilityIcon from "@mui/icons-material/Visibility"; +import DriveFileRenameOutlineIcon from "@mui/icons-material/DriveFileRenameOutline"; +import CloudUploadIcon from "@mui/icons-material/CloudUpload"; +import UploadFileIcon from "@mui/icons-material/UploadFile"; +import CreateNewFolderIcon from "@mui/icons-material/CreateNewFolder"; + +import { FileId } from "@app/types/file"; +import { FolderId, FolderRecord, ROOT_FOLDER_ID } from "@app/types/folder"; +import { useFolders } from "@app/contexts/FolderContext"; +import { StirlingFileStub } from "@app/types/fileContext"; +import { formatFileSize, getFileDate } from "@app/utils/fileUtils"; +import { + FILES_PAGE_DRAG_TYPE, + parseFilesPageDragPayload, + serialiseFilesPageDragPayload, +} from "@app/components/filesPage/dragDrop"; +import { useDropTarget } from "@app/components/filesPage/useDropTarget"; +import { getFileOrigin } from "@app/components/filesPage/fileOrigin"; +import { FileOriginBadge } from "@app/components/filesPage/FileOriginBadge"; +import { FolderThumbnail } from "@app/components/filesPage/FolderThumbnail"; +import { findFolderIcon } from "@app/components/filesPage/folderIcons"; +import { FolderAppearancePicker } from "@app/components/filesPage/FolderAppearancePicker"; +import type { FilesPageSortMode } from "@app/contexts/FilesPageContext"; + +export type FilesPageViewMode = "grid" | "list"; + +export interface FilesPageEntry { + kind: "folder" | "file"; + folder?: FolderRecord; + /** Number of files inside this folder (folder entries only). */ + folderFileCount?: number; + file?: StirlingFileStub; + /** Parent breadcrumb path for search results outside the current folder. */ + parentPath?: string; +} + +interface FileGridProps { + entries: FilesPageEntry[]; + selectedFileIds: Set; + /** Ids of files loaded in the active workspace. */ + activeWorkspaceFileIds?: Set; + viewMode: FilesPageViewMode; + onSelectFile: (id: FileId, shiftKey: boolean, ctrlKey: boolean) => void; + /** Replace the entire selection set. */ + onSetSelection?: (ids: Set) => void; + onOpenFolder: (id: FolderId) => void; + /** "Add to workspace". */ + onOpenFile: (file: StirlingFileStub) => void; + /** "Quick view". */ + onQuickView: (file: StirlingFileStub) => void; + onMoveFiles: ( + fileIds: FileId[], + targetFolderId: FolderId | null, + ) => void | Promise; + onMoveFolder: ( + folderId: FolderId, + newParentId: FolderId | null, + ) => void | Promise; + onRenameFolder: (folder: FolderRecord) => void; + onDeleteFolder: (folder: FolderRecord) => void; + onChangeFolderAppearance: ( + folderId: FolderId, + appearance: { color?: string; icon?: string | null }, + ) => void; + onRemoveFiles: (fileIds: FileId[]) => void; + onPromptMoveFiles: (fileIds: FileId[]) => void; + /** Per-file Save to server; hidden when file already has remoteStorageId. */ + onSaveToServer?: (file: StirlingFileStub) => void; + /** When supplied the list-view column headers become sortable. */ + sortMode?: FilesPageSortMode; + onChangeSortMode?: (mode: FilesPageSortMode) => void; + /** Drives the empty-state copy. */ + currentTab?: + | "all" + | "local" + | "cloud" + | "recent" + | "shared" + | "sharedByMe" + | "imSharing"; + /** Cloud reachability; switches the cloud empty-state copy. */ + serverReachable?: boolean; + /** Empty-state CTA handlers; if absent the matching button hides. */ + onEmptyUpload?: () => void; + onEmptyCreateFolder?: () => void; + /** Non-null disables the New folder CTA with this reason as tooltip. */ + newFolderDisabledReason?: string | null; +} + +export function FileGrid(props: FileGridProps & { loading?: boolean }) { + const { + viewMode, + entries, + loading, + currentTab, + serverReachable, + onEmptyUpload, + onEmptyCreateFolder, + newFolderDisabledReason, + } = props; + + if (loading && entries.length === 0) { + return ; + } + + if (entries.length === 0) { + return ( + + ); + } + + if (viewMode === "list") { + return ; + } + return ; +} + +function SkeletonGrid({ viewMode }: { viewMode: FilesPageViewMode }) { + // Six placeholders mirroring the card layout while IDB resolves. + const placeholders = Array.from({ length: 6 }); + if (viewMode === "list") { + return ( +
+ {placeholders.map((_, i) => ( +
+ + + + + + +
+ ))} +
+ ); + } + return ( +
+ {placeholders.map((_, i) => ( +
+
+
+
+
+
+
+ ))} +
+ ); +} + +interface EmptyStateProps { + /** Drives copy + iconography. */ + tab?: + | "all" + | "local" + | "cloud" + | "recent" + | "shared" + | "sharedByMe" + | "imSharing"; + /** Switches the cloud empty-state copy. */ + serverReachable?: boolean; + /** CTA handlers; absent => button hidden. */ + onUpload?: () => void; + onCreateFolder?: () => void; + /** Non-null disables New folder CTA with this reason. */ + newFolderDisabledReason?: string | null; +} + +function EmptyState({ + tab = "all", + serverReachable = true, + onUpload, + onCreateFolder, + newFolderDisabledReason, +}: EmptyStateProps) { + const { t } = useTranslation(); + const { titleKey, titleFallback, hintKey, hintFallback } = (() => { + switch (tab) { + case "local": + return { + titleKey: "filesPage.empty.local.title", + titleFallback: "No local-only files", + hintKey: "filesPage.empty.local.hint", + hintFallback: + "Files saved without uploading stay here. Drop a file to add one.", + }; + case "cloud": + return serverReachable + ? { + titleKey: "filesPage.empty.cloud.title", + titleFallback: "No cloud files yet", + hintKey: "filesPage.empty.cloud.hint", + hintFallback: + "Upload a file to start, or create a folder to organise.", + } + : { + titleKey: "filesPage.empty.cloud.offlineTitle", + titleFallback: "No cached cloud files", + hintKey: "filesPage.empty.cloud.offlineHint", + hintFallback: "Reconnect to load your cloud library.", + }; + case "recent": + return { + titleKey: "filesPage.empty.recent.title", + titleFallback: "Nothing modified yet", + hintKey: "filesPage.empty.recent.hint", + hintFallback: "Files you open or edit will appear here.", + }; + case "shared": + return { + titleKey: "filesPage.empty.shared.title", + titleFallback: "Nothing shared with you", + hintKey: "filesPage.empty.shared.hint", + hintFallback: "When someone shares a file via link, it appears here.", + }; + case "sharedByMe": + return { + titleKey: "filesPage.empty.sharedByMe.title", + titleFallback: "No share links yet", + hintKey: "filesPage.empty.sharedByMe.hint", + hintFallback: + "Create a share link on any of your files to surface it here.", + }; + case "imSharing": + return { + titleKey: "filesPage.empty.imSharing.title", + titleFallback: "Not sharing with anyone yet", + hintKey: "filesPage.empty.imSharing.hint", + hintFallback: + "Invite a teammate to one of your files to see it listed here.", + }; + case "all": + default: + return { + titleKey: "filesPage.empty.title", + titleFallback: "This folder is empty", + hintKey: "filesPage.empty.hint", + hintFallback: + "Drop PDFs anywhere on this page to upload, or use the New folder button to organise your files.", + }; + } + })(); + // Recent/Shared tabs are read-only filters; Local is cloud-only for folders. + const readOnlyTab = + tab === "recent" || + tab === "shared" || + tab === "sharedByMe" || + tab === "imSharing"; + const showUpload = Boolean(onUpload) && !readOnlyTab; + const showCreateFolder = + Boolean(onCreateFolder) && !readOnlyTab && tab !== "local"; + const showCtas = showUpload || showCreateFolder; + return ( +
+ + + +
{t(titleKey, titleFallback)}
+
{t(hintKey, hintFallback)}
+ {showCtas && ( +
+ {showUpload && ( + + )} + {showCreateFolder && + (newFolderDisabledReason ? ( + + {/* Wrap so tooltip hovers while button is disabled. */} + + + + + ) : ( + + ))} +
+ )} +
+ ); +} + +function GridView({ + entries, + selectedFileIds, + activeWorkspaceFileIds, + onSelectFile, + onOpenFolder, + onOpenFile, + onQuickView, + onMoveFiles, + onMoveFolder, + onRenameFolder, + onDeleteFolder, + onChangeFolderAppearance, + onRemoveFiles, + onPromptMoveFiles, + onSaveToServer, +}: FileGridProps) { + return ( +
+ {entries.map((entry) => { + if (entry.kind === "folder" && entry.folder) { + return ( + onOpenFolder(entry.folder!.id)} + onRename={() => onRenameFolder(entry.folder!)} + onDelete={() => onDeleteFolder(entry.folder!)} + onChangeAppearance={(appearance) => + onChangeFolderAppearance(entry.folder!.id, appearance) + } + onMoveFiles={(fileIds) => onMoveFiles(fileIds, entry.folder!.id)} + onMoveFolder={(folderId) => + onMoveFolder(folderId, entry.folder!.id) + } + /> + ); + } + if (entry.kind === "file" && entry.file) { + return ( + = 2} + onClick={(e) => + onSelectFile(entry.file!.id, e.shiftKey, e.metaKey || e.ctrlKey) + } + onDoubleClick={() => onOpenFile(entry.file!)} + onQuickView={() => onQuickView(entry.file!)} + onRemove={() => onRemoveFiles([entry.file!.id])} + onMove={() => { + const target = selectedFileIds.has(entry.file!.id) + ? Array.from(selectedFileIds) + : [entry.file!.id]; + onPromptMoveFiles(target); + }} + onSaveToServer={ + onSaveToServer ? () => onSaveToServer(entry.file!) : undefined + } + /> + ); + } + return null; + })} +
+ ); +} + +interface FolderCardProps { + folder: FolderRecord; + fileCount: number; + /** Subtitle for search results outside current folder. */ + parentPath?: string; + selectedFileIds: Set; + onOpen: () => void; + onRename: () => void; + onDelete: () => void; + onChangeAppearance: (appearance: { + color?: string; + icon?: string | null; + }) => void; + onMoveFiles: (fileIds: FileId[]) => void | Promise; + onMoveFolder: (folderId: FolderId) => void | Promise; +} + +function FolderCard({ + folder, + fileCount, + parentPath, + onOpen, + onRename, + onDelete, + onChangeAppearance, + onMoveFiles, + onMoveFolder, +}: FolderCardProps) { + const { t } = useTranslation(); + const { serverReachable, setError } = useFolders(); + const offlineHint = t( + "filesPage.offlineNoFolderEdits", + "Offline - folder changes are disabled.", + ); + const surfaceDrop = (err: unknown, label: string) => { + console.error(`[FolderCard] ${label}`, err); + setError( + err instanceof Error + ? `Could not ${label}: ${err.message}` + : `Could not ${label}.`, + ); + }; + const kebabRef = useRef(null); + const { handlers: dropHandlers, isOver: isDropTarget } = useDropTarget({ + dragType: FILES_PAGE_DRAG_TYPE, + onDrop: (e) => { + const payload = parseFilesPageDragPayload(e.dataTransfer); + if (!payload) return; + // Surface rejections instead of silent no-op on IDB failures. + if (payload.kind === "files") { + Promise.resolve(onMoveFiles(payload.fileIds)).catch((err) => + surfaceDrop(err, "move files into folder"), + ); + } else if (payload.kind === "folder") { + Promise.resolve(onMoveFolder(payload.folderId)).catch((err) => + surfaceDrop(err, "move folder"), + ); + } + }, + }); + + return ( +
{ + e.dataTransfer.setData( + FILES_PAGE_DRAG_TYPE, + serialiseFilesPageDragPayload({ + kind: "folder", + folderId: folder.id, + }), + ); + e.dataTransfer.effectAllowed = "move"; + }} + {...dropHandlers} + className={`files-page-card is-folder${ + isDropTarget ? " is-drop-target" : "" + }`} + onDoubleClick={onOpen} + onContextMenu={(e) => { + e.preventDefault(); + kebabRef.current?.click(); + }} + onKeyDown={(e) => { + if (e.key === "Enter") onOpen(); + }} + > +
+ +
+
+
+ {folder.name} +
+ {parentPath && ( +
+ {t("filesPage.inPath", "in {{path}}", { path: parentPath })} +
+ )} +
+ {fileCount === 0 + ? t("filesPage.folder", "Folder") + : t("filesPage.folderItems", "{{count}} items", { + count: fileCount, + })} +
+
+
+ + + e.stopPropagation()} + aria-label={t("filesPage.folderMenu", "Folder actions")} + > + + + + + } + onClick={onOpen} + > + {t("filesPage.open", "Open")} + + } + onClick={onRename} + disabled={!serverReachable} + title={!serverReachable ? offlineHint : undefined} + > + {t("filesPage.rename", "Rename")} + + + + {t("filesPage.appearance.title", "Appearance")} + + + + } + onClick={onDelete} + disabled={!serverReachable} + title={!serverReachable ? offlineHint : undefined} + > + {t("filesPage.deleteFolder", "Delete folder")} + + + +
+
+ ); +} + +interface FileCardProps { + file: StirlingFileStub; + isSelected: boolean; + isInWorkspace: boolean; + /** Subtitle for search results outside current folder. */ + parentPath?: string; + selectedFileIds: Set; + /** Shows the checkbox once 2+ files are selected. */ + multiSelectActive: boolean; + onClick: (e: React.MouseEvent) => void; + onDoubleClick: () => void; + onQuickView: () => void; + onRemove: () => void; + onMove: () => void; + /** Kebab Save to server; only fires when file is local-only. */ + onSaveToServer?: () => void; +} + +function FileCard({ + file, + parentPath, + isSelected, + isInWorkspace, + selectedFileIds, + multiSelectActive, + onClick, + onDoubleClick, + onQuickView, + onRemove, + onMove, + onSaveToServer, +}: FileCardProps) { + const { t } = useTranslation(); + const cardRef = useRef(null); + const fileSize = useMemo(() => formatFileSize(file.size), [file.size]); + const fileDate = useMemo( + () => getFileDate({ lastModified: file.lastModified }), + [file.lastModified], + ); + + const handleDragStart = useCallback( + (e: React.DragEvent) => { + const fileIds = isSelected ? Array.from(selectedFileIds) : [file.id]; + e.dataTransfer.setData( + FILES_PAGE_DRAG_TYPE, + serialiseFilesPageDragPayload({ kind: "files", fileIds }), + ); + e.dataTransfer.effectAllowed = "move"; + }, + [file.id, isSelected, selectedFileIds], + ); + + const extension = file.name.split(".").pop()?.toUpperCase() ?? ""; + const isPdf = extension === "PDF"; + + const kebabRef = useRef(null); + const handleContextMenu = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + // Right-click on unselected card selects first, then opens menu. + if (!isSelected) onClick(e); + kebabRef.current?.click(); + }, + [isSelected, onClick], + ); + + return ( +
{ + if (e.key === "Enter") onDoubleClick(); + }} + className={`files-page-card${isSelected ? " is-selected" : ""}${ + isInWorkspace ? " is-in-workspace" : "" + }`} + > + {isInWorkspace && ( + + + {t("filesPage.inWorkspace", "Open")} + + )} + {/* Checkbox only renders once the user is explicitly in multi-select + mode (2+ files chosen via Ctrl/Shift-click, or one file then + another). For single-select the highlight border on the card is + the only state indicator - avoids the always-on-checkbox + visual noise and matches the file-explorer model. */} + {multiSelectActive && ( +
+ { + // Synthesise ctrl-click so parent takes the toggle branch. + e.stopPropagation(); + onClick({ + ...e, + shiftKey: false, + ctrlKey: true, + metaKey: true, + } as unknown as React.MouseEvent); + }} + onChange={() => { + /* handled by onClick */ + }} + aria-label={t("filesPage.selectFile", "Select file {{name}}", { + name: file.name, + })} + /> +
+ )} +
+ {file.thumbnailUrl ? ( + // draggable={false} so card's onDragStart fires, not native image drag. + + ) : ( +
+ {isPdf ? ( + + ) : ( + + )} + {extension || "FILE"} +
+ )} +
+ +
+
+
+
+ {file.name} +
+ {parentPath && ( +
+ {t("filesPage.inPath", "in {{path}}", { path: parentPath })} +
+ )} +
+ {fileSize} + Β· + {fileDate} +
+
+
+ + + e.stopPropagation()} + aria-label={t("filesPage.fileMenu", "File actions")} + > + + + + + } + onClick={(e) => { + e.stopPropagation(); + onDoubleClick(); + }} + > + {t("filesPage.addToWorkspace", "Add to workspace")} + + } + onClick={(e) => { + e.stopPropagation(); + onQuickView(); + }} + > + {t("filesPage.quickView", "Quick view")} + + } + onClick={(e) => { + e.stopPropagation(); + onMove(); + }} + > + {t("filesPage.moveTo", "Move to…")} + + {/* Per-file Save to server; hidden when already on server. */} + {onSaveToServer && file.remoteStorageId == null && ( + } + onClick={(e) => { + e.stopPropagation(); + onSaveToServer(); + }} + > + {t("filesPage.saveToServer", "Save to server")} + + )} + + } + onClick={(e) => { + e.stopPropagation(); + onRemove(); + }} + > + {t("filesPage.remove", "Delete")} + + + +
+
+ ); +} + +function ListView({ + entries, + selectedFileIds, + activeWorkspaceFileIds, + onSelectFile, + onSetSelection, + onOpenFolder, + onOpenFile, + onQuickView, + onMoveFiles, + onMoveFolder, + onRenameFolder, + onDeleteFolder, + onSaveToServer, + onChangeFolderAppearance, + onRemoveFiles, + onPromptMoveFiles, + sortMode, + onChangeSortMode, +}: FileGridProps & { + sortMode?: FilesPageSortMode; + onChangeSortMode?: (next: FilesPageSortMode) => void; +}) { + const { t } = useTranslation(); + + // Tri-state header checkbox state - computed from current entries. + const visibleFileIds = useMemo( + () => + entries + .filter( + (e): e is FilesPageEntry & { file: StirlingFileStub } => + e.kind === "file" && !!e.file, + ) + .map((e) => e.file.id), + [entries], + ); + const allSelected = + visibleFileIds.length > 0 && + visibleFileIds.every((id) => selectedFileIds.has(id)); + const someSelected = + !allSelected && visibleFileIds.some((id) => selectedFileIds.has(id)); + + const sortIndicator = (asc: FilesPageSortMode, desc: FilesPageSortMode) => { + if (sortMode === asc) return " ↑"; + if (sortMode === desc) return " ↓"; + return ""; + }; + + const headerProps = (asc: FilesPageSortMode, desc: FilesPageSortMode) => ({ + role: "button", + tabIndex: onChangeSortMode ? 0 : undefined, + "data-sortable": onChangeSortMode ? "true" : undefined, + onClick: () => { + if (!onChangeSortMode) return; + onChangeSortMode(sortMode === asc ? desc : asc); + }, + onKeyDown: (e: React.KeyboardEvent) => { + if (!onChangeSortMode) return; + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onChangeSortMode(sortMode === asc ? desc : asc); + } + }, + }); + + return ( +
+
+ {onSetSelection && visibleFileIds.length > 0 ? ( + { + onSetSelection(allSelected ? new Set() : new Set(visibleFileIds)); + }} + aria-label={ + allSelected + ? t("filesPage.deselectAll", "Clear selection") + : t("filesPage.selectAll", "Select all") + } + /> + ) : ( +
+ {entries.map((entry) => { + if (entry.kind === "folder" && entry.folder) { + return ( + onOpenFolder(entry.folder!.id)} + onRename={() => onRenameFolder(entry.folder!)} + onDelete={() => onDeleteFolder(entry.folder!)} + onChangeAppearance={(appearance) => + onChangeFolderAppearance(entry.folder!.id, appearance) + } + onDropFiles={(fileIds) => onMoveFiles(fileIds, entry.folder!.id)} + onDropFolder={(folderId) => + onMoveFolder(folderId, entry.folder!.id) + } + /> + ); + } + if (entry.kind === "file" && entry.file) { + return ( + = 2} + onClick={(e) => + onSelectFile(entry.file!.id, e.shiftKey, e.metaKey || e.ctrlKey) + } + onOpen={() => onOpenFile(entry.file!)} + onQuickView={() => onQuickView(entry.file!)} + onRemove={() => onRemoveFiles([entry.file!.id])} + onMove={() => { + const target = selectedFileIds.has(entry.file!.id) + ? Array.from(selectedFileIds) + : [entry.file!.id]; + onPromptMoveFiles(target); + }} + onSaveToServer={ + onSaveToServer ? () => onSaveToServer(entry.file!) : undefined + } + /> + ); + } + return null; + })} +
+ ); +} + +interface FolderRowProps { + folder: FolderRecord; + fileCount: number; + parentPath?: string; + onOpen: () => void; + onRename: () => void; + onDelete: () => void; + onChangeAppearance: (appearance: { + color?: string; + icon?: string | null; + }) => void; + onDropFiles: (fileIds: FileId[]) => void | Promise; + onDropFolder: (folderId: FolderId) => void | Promise; +} + +function FolderRow({ + folder, + fileCount, + parentPath, + onOpen, + onRename, + onDelete, + onChangeAppearance, + onDropFiles, + onDropFolder, +}: FolderRowProps) { + const { t } = useTranslation(); + const { serverReachable, setError } = useFolders(); + const offlineHint = t( + "filesPage.offlineNoFolderEdits", + "Offline - folder changes are disabled.", + ); + const surfaceDrop = (err: unknown, label: string) => { + console.error(`[FolderRow] ${label}`, err); + setError( + err instanceof Error + ? t("filesPage.error.actionFailedDetail", { + action: label, + message: err.message, + defaultValue: `Could not ${label}: ${err.message}`, + }) + : t("filesPage.error.actionFailed", { + action: label, + defaultValue: `Could not ${label}.`, + }), + ); + }; + const kebabRef = useRef(null); + const { handlers: dropHandlers, isOver: isDropTarget } = useDropTarget({ + dragType: FILES_PAGE_DRAG_TYPE, + onDrop: (e) => { + const payload = parseFilesPageDragPayload(e.dataTransfer); + if (!payload) return; + if (payload.kind === "files") { + Promise.resolve(onDropFiles(payload.fileIds)).catch((err) => + surfaceDrop(err, "move files into folder"), + ); + } else if (payload.kind === "folder") { + Promise.resolve(onDropFolder(payload.folderId)).catch((err) => + surfaceDrop(err, "move folder"), + ); + } + }, + }); + return ( +
{ + e.dataTransfer.setData( + FILES_PAGE_DRAG_TYPE, + serialiseFilesPageDragPayload({ + kind: "folder", + folderId: folder.id, + }), + ); + e.dataTransfer.effectAllowed = "move"; + }} + {...dropHandlers} + onDoubleClick={onOpen} + onContextMenu={(e) => { + e.preventDefault(); + kebabRef.current?.click(); + }} + onKeyDown={(e) => { + if (e.key === "Enter") onOpen(); + }} + className={`files-page-list-row${isDropTarget ? " is-drop-target" : ""}`} + > +
+ ); +} + +interface FileRowProps { + file: StirlingFileStub; + isSelected: boolean; + isInWorkspace: boolean; + parentPath?: string; + selectedFileIds: Set; + /** Shows the checkbox once 2+ files are selected. */ + multiSelectActive: boolean; + onClick: (e: React.MouseEvent) => void; + onOpen: () => void; + onQuickView: () => void; + onRemove: () => void; + onMove: () => void; + /** Kebab Save to server; only fires when file is local-only. */ + onSaveToServer?: () => void; +} + +function FileRow({ + file, + isSelected, + isInWorkspace, + parentPath, + selectedFileIds, + multiSelectActive, + onClick, + onOpen, + onQuickView, + onRemove, + onMove, + onSaveToServer, +}: FileRowProps) { + const { t } = useTranslation(); + const kebabRef = useRef(null); + const fileSize = useMemo(() => formatFileSize(file.size), [file.size]); + const fileDate = useMemo( + () => getFileDate({ lastModified: file.lastModified }), + [file.lastModified], + ); + const ext = (file.name.split(".").pop() ?? "").toUpperCase(); + return ( +
{ + const fileIds = isSelected ? Array.from(selectedFileIds) : [file.id]; + e.dataTransfer.setData( + FILES_PAGE_DRAG_TYPE, + serialiseFilesPageDragPayload({ kind: "files", fileIds }), + ); + e.dataTransfer.effectAllowed = "move"; + }} + onClick={onClick} + onDoubleClick={onOpen} + onContextMenu={(e) => { + e.preventDefault(); + if (!isSelected) onClick(e); + kebabRef.current?.click(); + }} + onKeyDown={(e) => { + if (e.key === "Enter") onOpen(); + }} + className={`files-page-list-row${isSelected ? " is-selected" : ""}${ + isInWorkspace ? " is-in-workspace" : "" + }`} + > + {/* Checkbox only shows in multi-select mode (see FileCard). When the + checkbox is hidden the first grid column collapses, but the row's + CSS grid keeps the columns aligned via the named template, so no + empty cell shows. */} + {multiSelectActive ? ( + { + // Toggle this file in/out of the selection without modifier keys. + e.stopPropagation(); + onClick({ + ...e, + shiftKey: false, + ctrlKey: true, + metaKey: true, + } as unknown as React.MouseEvent); + }} + onChange={() => { + /* handled by onClick */ + }} + aria-label={t("filesPage.selectFile", "Select file {{name}}", { + name: file.name, + })} + /> + ) : ( + // Empty cell preserves grid column alignment. +
+ ); +} + +// Re-export root constant for caller convenience +export { ROOT_FOLDER_ID }; diff --git a/frontend/editor/src/core/components/filesPage/FileManagerView.tsx b/frontend/editor/src/core/components/filesPage/FileManagerView.tsx new file mode 100644 index 0000000000..2c1233a606 --- /dev/null +++ b/frontend/editor/src/core/components/filesPage/FileManagerView.tsx @@ -0,0 +1,1698 @@ +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { useTranslation } from "react-i18next"; +import { useLocation, useNavigate } from "react-router-dom"; +import { + ActionIcon, + Button, + Drawer, + Group, + MultiSelect, + SegmentedControl, + Select, + Tooltip, +} from "@mantine/core"; +import { useMediaQuery } from "@mantine/hooks"; +import SearchIcon from "@mui/icons-material/Search"; +import CloseIcon from "@mui/icons-material/Close"; +import UploadFileIcon from "@mui/icons-material/UploadFile"; +import CreateNewFolderIcon from "@mui/icons-material/CreateNewFolder"; +import GridViewIcon from "@mui/icons-material/GridView"; +import ViewListIcon from "@mui/icons-material/ViewList"; +import DeleteIcon from "@mui/icons-material/Delete"; +import DriveFileMoveIcon from "@mui/icons-material/DriveFileMove"; +import OpenInNewIcon from "@mui/icons-material/OpenInNew"; +import VisibilityIcon from "@mui/icons-material/Visibility"; +import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined"; +import CloudUploadIcon from "@mui/icons-material/CloudUpload"; +import KeyboardArrowRightIcon from "@mui/icons-material/KeyboardArrowRight"; +import RefreshIcon from "@mui/icons-material/Refresh"; + +import { useSharingEnabled } from "@app/hooks/useSharingEnabled"; +import { useFolders } from "@app/contexts/FolderContext"; +import { useFileActions } from "@app/contexts/file/fileHooks"; +import { useAllFiles } from "@app/contexts/FileContext"; +import { useFileHandler } from "@app/hooks/useFileHandler"; +import { + useNavigationActions, + useNavigationGuard, +} from "@app/contexts/NavigationContext"; +import { useViewer } from "@app/contexts/ViewerContext"; +import { + FILES_PAGE_VIEW_MODES, + FilesPageOriginFilter, + FilesPageSortMode, + useFilesPage, +} from "@app/contexts/FilesPageContext"; +import { getFileOrigin } from "@app/components/filesPage/fileOrigin"; + +import { FileId } from "@app/types/file"; +import { StirlingFileStub } from "@app/types/fileContext"; +import { FolderId, ROOT_FOLDER_ID } from "@app/types/folder"; + +import { FileGrid, FilesPageEntry } from "@app/components/filesPage/FileGrid"; +import { FileDetailsPanel } from "@app/components/filesPage/FileDetailsPanel"; +import BulkUploadToServerModal from "@app/components/shared/BulkUploadToServerModal"; +import { MoveToFolderDialog } from "@app/components/filesPage/MoveToFolderDialog"; +import { FolderNameDialog } from "@app/components/filesPage/FolderNameDialog"; +import { DeleteFolderDialog } from "@app/components/filesPage/DeleteFolderDialog"; +import { materializeServerStubs } from "@app/services/fileSyncService"; +import { + FILES_PAGE_DRAG_TYPE, + parseFilesPageDragPayload, +} from "@app/components/filesPage/dragDrop"; +import { + clearFilesPageReturnRoute, + setFilesPageReturnRoute, +} from "@app/components/filesPage/filesPageReturnRoute"; +import "@app/components/filesPage/FilesPage.css"; + +export default function FileManagerView() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const location = useLocation(); + + // Hide Shared tab when storageSharingEnabled is false. + const { sharingEnabled } = useSharingEnabled(); + + // ≀800px hosts the details panel in a button-triggered Drawer. + const isCompactDetailsViewport = useMediaQuery("(max-width: 800px)") ?? false; + // Phones get a full-screen drawer; tablets get a smaller one. + const useFullScreenDrawer = useMediaQuery("(max-width: 640px)") ?? false; + const [mobileDetailsOpen, setMobileDetailsOpen] = useState(false); + // Save-to-server modal target. Bulk button uses local-only selection; + // per-file kebab uses [file]. Targets root; folder placement is via drop. + const [saveToServerTarget, setSaveToServerTarget] = useState< + StirlingFileStub[] | null + >(null); + const folders = useFolders(); + const { actions: fileActions } = useFileActions(); + const { fileIds: activeWorkspaceFileIds } = useAllFiles(); + const activeWorkspaceFileIdSet = useMemo( + () => new Set(activeWorkspaceFileIds.map((id) => id as string)), + [activeWorkspaceFileIds], + ); + const { addFiles } = useFileHandler(); + const { actions: navActions } = useNavigationActions(); + const { requestNavigation } = useNavigationGuard(); + const { setActiveFileId } = useViewer(); + + const filesPage = useFilesPage(); + const { + allFiles, + fileMap, + loading, + refresh, + selectedFileIds, + setSelectedFileIds, + clearSelection, + viewMode, + setViewMode, + sortMode, + setSortMode, + search, + setSearch, + originFilter, + setOriginFilter, + typeFilter, + setTypeFilter, + currentTab, + setCurrentTab, + folderNameDialog, + openNewFolderDialog, + openRenameFolderDialog, + closeFolderNameDialog, + submitFolderName, + moveDialog, + promptMoveFiles, + closeMoveDialog, + moveFilesTo, + moveFolderTo, + removeFiles, + promptDeleteFolder, + deleteFolder, + deleteFolderDialog, + closeDeleteFolderDialog, + setFolderAppearance, + } = filesPage; + + const setCurrentFolderId = folders.setCurrentFolderId; + const foldersById = folders.foldersById; + const currentFolderId = folders.currentFolderId; + + // Sync the URL into FolderContext. + useEffect(() => { + const match = location.pathname.match(/^\/files\/([^/]+)/); + const param = match?.[1] ?? null; + if (param === null) { + setCurrentFolderId(ROOT_FOLDER_ID); + } else if (foldersById.has(param as FolderId)) { + setCurrentFolderId(param as FolderId); + } else { + setCurrentFolderId(ROOT_FOLDER_ID); + } + }, [location.pathname, foldersById, setCurrentFolderId]); + + // Bounce off any share-related tab when sharing isn't enabled. + useEffect(() => { + if ( + !sharingEnabled && + (currentTab === "shared" || + currentTab === "sharedByMe" || + currentTab === "imSharing") + ) { + setCurrentTab("all"); + } + }, [sharingEnabled, currentTab, setCurrentTab]); + + // Push folder selection into the URL while still on /files. + useEffect(() => { + if (!window.location.pathname.startsWith("/files")) return; + const target = + currentFolderId === null ? "/files" : `/files/${currentFolderId}`; + if (window.location.pathname !== target) { + navigate(target, { replace: true }); + } + }, [currentFolderId, navigate]); + + // ─── visible items (current folder + sort + search) ───────────────────── + + /** currentFolderId + all descendants. Includes `null` when at root. */ + const subtreeFolderIds = useMemo(() => { + const set = new Set(); + set.add(currentFolderId); + const childMap = new Map(); + for (const f of folders.folders) { + const list = childMap.get(f.parentFolderId) ?? []; + list.push(f.id); + childMap.set(f.parentFolderId, list); + } + // Iterative DFS to avoid stack overflow on deep chains. + const stack: (FolderId | null)[] = [currentFolderId]; + while (stack.length > 0) { + const cur = stack.pop()!; + for (const childId of childMap.get(cur) ?? []) { + if (set.has(childId)) continue; + set.add(childId); + stack.push(childId); + } + } + return set; + }, [folders.folders, currentFolderId]); + + const visibleFolders = useMemo(() => { + // Folders only appear in cloud-rooted tabs. + if ( + currentTab === "local" || + currentTab === "recent" || + currentTab === "shared" || + currentTab === "sharedByMe" || + currentTab === "imSharing" + ) { + return []; + } + const lc = search.toLowerCase(); + const matched = folders.folders.filter((f) => { + if (search) { + // Subtree-wide name match; exclude the current folder itself. + return ( + f.id !== currentFolderId && + subtreeFolderIds.has(f.parentFolderId) && + f.name.toLowerCase().includes(lc) + ); + } + // Direct children only. + return f.parentFolderId === currentFolderId; + }); + return matched.sort((a, b) => + a.name.localeCompare(b.name, undefined, { sensitivity: "base" }), + ); + }, [folders.folders, currentFolderId, search, currentTab, subtreeFolderIds]); + + // Files in current folder, pre-filter. Drives the type-filter dropdown. + const filesInCurrentFolder = useMemo(() => { + // Tab overrides folder navigation for Local/Recent/Shared. + switch (currentTab) { + case "local": + // Local = files with no server copy. folderId is forced null on this + // path (cf. file.ts comment), but we check remoteStorageId too so + // stale local-folder rows from a pre-pivot DB don't slip through. + return allFiles.filter((f) => f.remoteStorageId == null); + case "cloud": + // Cloud bucket; search widens to subtree, else direct-folder match. + return allFiles.filter((f) => { + if (f.remoteStorageId == null) return false; + if (search) return subtreeFolderIds.has(f.folderId ?? null); + return (f.folderId ?? null) === (currentFolderId ?? null); + }); + case "recent": { + // Last 50 modified across local + cloud, folder context ignored. + const sorted = [...allFiles].sort( + (a, b) => (b.lastModified ?? 0) - (a.lastModified ?? 0), + ); + return sorted.slice(0, 50); + } + case "shared": + return allFiles.filter((f) => f.remoteOwnedByCurrentUser === false); + case "sharedByMe": + // Files I own that have at least one outgoing public share link. + return allFiles.filter( + (f) => + f.remoteOwnedByCurrentUser !== false && + f.remoteHasShareLinks === true, + ); + case "imSharing": + // Files I own that I've shared directly with specific users. + return allFiles.filter( + (f) => + f.remoteOwnedByCurrentUser !== false && + f.remoteHasUserShares === true, + ); + case "all": + default: + // Search widens to the subtree. + return allFiles.filter((f) => { + if (search) return subtreeFolderIds.has(f.folderId ?? null); + return (f.folderId ?? null) === (currentFolderId ?? null); + }); + } + }, [allFiles, currentFolderId, currentTab, search, subtreeFolderIds]); + + const availableTypes = useMemo(() => { + const set = new Set(); + for (const f of filesInCurrentFolder) { + const ext = (f.name.split(".").pop() ?? "").toUpperCase(); + if (ext) set.add(ext); + } + return Array.from(set).sort(); + }, [filesInCurrentFolder]); + + // Drop any active type filters that no longer appear in this folder + // (e.g. when the user navigates between folders). + useEffect(() => { + if (typeFilter.length === 0) return; + const stillValid = typeFilter.filter((t) => availableTypes.includes(t)); + if (stillValid.length !== typeFilter.length) { + setTypeFilter(stillValid); + } + }, [availableTypes, typeFilter, setTypeFilter]); + + const visibleFiles = useMemo(() => { + const filtered = filesInCurrentFolder + .filter((f) => + search ? f.name.toLowerCase().includes(search.toLowerCase()) : true, + ) + .filter((f) => + originFilter === "all" ? true : getFileOrigin(f) === originFilter, + ) + .filter((f) => { + if (typeFilter.length === 0) return true; + const ext = (f.name.split(".").pop() ?? "").toUpperCase(); + return typeFilter.includes(ext); + }); + const sorted = [...filtered]; + sorted.sort((a, b) => { + switch (sortMode) { + case "name-asc": + return a.name.localeCompare(b.name); + case "name-desc": + return b.name.localeCompare(a.name); + case "modified-asc": + return (a.lastModified ?? 0) - (b.lastModified ?? 0); + case "size-desc": + return (b.size ?? 0) - (a.size ?? 0); + case "size-asc": + return (a.size ?? 0) - (b.size ?? 0); + case "modified-desc": + default: + return (b.lastModified ?? 0) - (a.lastModified ?? 0); + } + }); + return sorted; + }, [filesInCurrentFolder, search, sortMode, originFilter, typeFilter]); + + /** + * Resolve a folder id to its breadcrumb path (e.g. "Receipts / 2024 / Q1"). + * Returns empty string for root / unknown. Used for the search-result + * "where does this live?" subtitle. + */ + const pathForFolderId = useCallback( + (folderId: FolderId | null | undefined): string => { + if (folderId == null) return ""; + const parts: string[] = []; + let cursor: FolderId | null = folderId; + const seen = new Set(); + while (cursor !== null) { + if (seen.has(cursor)) break; + seen.add(cursor); + const f = foldersById.get(cursor); + if (!f) break; + parts.unshift(f.name); + cursor = f.parentFolderId; + } + return parts.join(" / "); + }, + [foldersById], + ); + + const entries = useMemo(() => { + // When searching, items may come from anywhere in the subtree, so we + // expose a "parentPath" subtitle whenever the item's parent differs from + // currentFolderId. When no search is active, every item is in the + // current folder by definition and the subtitle is suppressed. + const inSearch = search.length > 0; + return [ + ...visibleFolders.map((folder) => ({ + kind: "folder", + folder, + folderFileCount: filesPage.fileCountsByFolder.get(folder.id) ?? 0, + parentPath: + inSearch && folder.parentFolderId !== currentFolderId + ? pathForFolderId(folder.parentFolderId) || undefined + : undefined, + })), + ...visibleFiles.map((file) => ({ + kind: "file", + file, + parentPath: + inSearch && (file.folderId ?? null) !== (currentFolderId ?? null) + ? pathForFolderId(file.folderId ?? null) || undefined + : undefined, + })), + ]; + }, [ + visibleFolders, + visibleFiles, + filesPage.fileCountsByFolder, + search, + currentFolderId, + pathForFolderId, + ]); + + // ─── selection ────────────────────────────────────────────────────────── + const lastClickedFileRef = useRef(null); + const handleSelectFile = useCallback( + (fileId: FileId, shift: boolean, ctrl: boolean) => { + setSelectedFileIds((prev) => { + const next = new Set(prev); + if (shift && lastClickedFileRef.current) { + const idx = visibleFiles.findIndex((f) => f.id === fileId); + const lastIdx = visibleFiles.findIndex( + (f) => f.id === lastClickedFileRef.current, + ); + if (idx >= 0 && lastIdx >= 0) { + const [a, b] = idx < lastIdx ? [idx, lastIdx] : [lastIdx, idx]; + for (let i = a; i <= b; i += 1) { + next.add(visibleFiles[i]!.id); + } + return next; + } + } + // Once the user has 2+ files selected they're explicitly in + // multi-select mode (they checked a box, or shift-range'd, or + // ctrl-clicked) - in that mode plain clicks toggle add/remove + // instead of collapsing the whole selection back to one file. + // This is the Google Drive pattern: the "selection mode" sticks + // until the user explicitly exits via the X clear button or by + // clicking the empty background of the grid. + const inMultiSelectMode = prev.size >= 2; + if (ctrl || inMultiSelectMode) { + if (next.has(fileId)) next.delete(fileId); + else next.add(fileId); + } else { + // 0 or 1 selected: plain click replaces (Finder/Explorer + // behaviour). Clicking the already-only-selected file + // deselects it, so single-file selection still toggles. + const isSoleSelection = prev.size === 1 && prev.has(fileId); + next.clear(); + if (!isSoleSelection) next.add(fileId); + } + return next; + }); + lastClickedFileRef.current = fileId; + }, + [visibleFiles, setSelectedFileIds], + ); + + // Background click on the content area clears the selection. + const handleContentBackgroundClick = useCallback( + (e: React.MouseEvent) => { + // Only react when the click target is the scroll container itself + // (not a card, row, or drop overlay). + if (e.target === e.currentTarget) { + clearSelection(); + } + }, + [clearSelection], + ); + + // ─── upload (drag-from-desktop or button) ─────────────────────────────── + const fileInputRef = useRef(null); + const [isDraggingExternal, setIsDraggingExternal] = useState(false); + const [refreshing, setRefreshing] = useState(false); + + const handleNativeUpload = useCallback( + async (files: File[]) => { + if (files.length === 0) return; + // skipWorkspaceDispatch: the user is in the file manager, not opening + // files for work. Persist to IDB so the file appears in the grid (via + // FilesPageContext's independent IDB scan) but DON'T pollute workspace + // state - otherwise the file pops up the next time the user navigates + // to /viewer or /tools, which reads as "auto-opened" and surprised + // people every time. The grid will repaint via refresh() below. + const added = await addFiles(files, { + selectFiles: false, + skipWorkspaceDispatch: true, + }); + const fileIds = added.map((f) => f.fileId); + const target = currentFolderId; + // Uploaded files land in Local (folderId stays null). + if ( + target !== null && + fileIds.length > 0 && + (currentTab === "all" || currentTab === "cloud") + ) { + folders.setError( + t( + "filesPage.uploadedToLocal", + "Uploaded files start in Local. Use 'Save to cloud' to put them in a folder.", + ), + ); + } + await refresh(); + }, + [addFiles, currentFolderId, currentTab, folders, refresh, t], + ); + + const onFileInputChange = useCallback( + async (e: React.ChangeEvent) => { + const list = Array.from(e.target.files ?? []); + e.target.value = ""; + if (list.length === 0) return; + await handleNativeUpload(list); + }, + [handleNativeUpload], + ); + + // ─── add to workspace vs quick view ───────────────────────────────────── + // addToWorkspace: commit; no back affordance. + // quickView: peek; "Back to My Files" pill in WorkbenchBar. + const openFilesInWorkbench = useCallback( + async (fileIds: FileId[], options: { trackReturn: boolean }) => { + const stubs = fileIds + .map((id) => fileMap.get(id)) + .filter((s): s is StirlingFileStub => Boolean(s)); + if (stubs.length === 0) return; + + const proceed = async () => { + if (options.trackReturn) { + const returnRoute = + currentFolderId === null ? "/files" : `/files/${currentFolderId}`; + const folderRecord = currentFolderId + ? (foldersById.get(currentFolderId) ?? null) + : null; + const returnLabel = folderRecord + ? folderRecord.name + : t("filesPage.myFiles", "My Files"); + setFilesPageReturnRoute(returnRoute, returnLabel); + } else { + clearFilesPageReturnRoute(); + } + + // Server-only stubs have no bytes in IDB; download + ingest first. + const materialized = await materializeServerStubs(stubs, { + addFiles: fileActions.addFilesWithOptions, + updateStub: fileActions.updateStirlingFileStub, + }); + if (materialized.length !== stubs.length) { + // At least one server download failed; refresh so the grid + // reflects any successful ingests and the user can retry. + await refresh(); + return; + } + + await fileActions.addStirlingFileStubs(materialized, { + selectFiles: false, + }); + // Branch on requested stubs so already-active files still activate. + if (materialized.length === 1) { + setActiveFileId(materialized[0]!.id); + navActions.setWorkbench("viewer"); + } else if (materialized.length > 1) { + navActions.setWorkbench("fileEditor"); + } + navigate("/"); + }; + + requestNavigation(() => { + void proceed(); + }); + }, + [ + fileMap, + fileActions, + setActiveFileId, + navActions, + navigate, + requestNavigation, + currentFolderId, + foldersById, + t, + ], + ); + + const handleAddToWorkspace = useCallback( + (fileIds: FileId[]) => + openFilesInWorkbench(fileIds, { trackReturn: false }), + [openFilesInWorkbench], + ); + + const handleQuickView = useCallback( + (fileId: FileId) => openFilesInWorkbench([fileId], { trackReturn: true }), + [openFilesInWorkbench], + ); + + const handleOpenFile = useCallback( + (file: StirlingFileStub) => { + // Double-click commits to workspace. + void handleAddToWorkspace([file.id]); + }, + [handleAddToWorkspace], + ); + + const handleOpenFolder = useCallback( + (id: FolderId) => { + folders.setCurrentFolderId(id); + clearSelection(); + }, + [folders, clearSelection], + ); + + // ─── full-page drag-and-drop for OS uploads ───────────────────────────── + const dropZoneRef = useRef(null); + useEffect(() => { + const node = dropZoneRef.current; + if (!node) return; + let counter = 0; + const isExternalFileDrag = (e: DragEvent) => + Array.from(e.dataTransfer?.types ?? []).includes("Files"); + + const onEnter = (e: DragEvent) => { + if (!isExternalFileDrag(e)) return; + e.preventDefault(); + counter += 1; + setIsDraggingExternal(true); + }; + const onOver = (e: DragEvent) => { + if (!isExternalFileDrag(e)) return; + e.preventDefault(); + }; + const onLeave = () => { + counter -= 1; + if (counter <= 0) { + counter = 0; + setIsDraggingExternal(false); + } + }; + const onDrop = (e: DragEvent) => { + if (!isExternalFileDrag(e)) return; + e.preventDefault(); + counter = 0; + setIsDraggingExternal(false); + const dropped = Array.from(e.dataTransfer?.files ?? []); + if (dropped.length > 0) { + handleNativeUpload(dropped).catch((err) => + folders.setError( + err instanceof Error + ? t("filesPage.error.uploadFilesFailedDetail", { + message: err.message, + defaultValue: `Could not upload files: ${err.message}`, + }) + : t( + "filesPage.error.uploadFilesFailed", + "Could not upload files.", + ), + ), + ); + } + }; + node.addEventListener("dragenter", onEnter); + node.addEventListener("dragover", onOver); + node.addEventListener("dragleave", onLeave); + node.addEventListener("drop", onDrop); + return () => { + node.removeEventListener("dragenter", onEnter); + node.removeEventListener("dragover", onOver); + node.removeEventListener("dragleave", onLeave); + node.removeEventListener("drop", onDrop); + }; + }, [handleNativeUpload]); + + // ─── close / exit ─────────────────────────────────────────────────────── + const handleClose = useCallback(() => { + // Drop the return-route hint so the workbench doesn't show a stale back. + clearFilesPageReturnRoute(); + navigate("/"); + }, [navigate]); + + // ─── keyboard shortcuts ───────────────────────────────────────────────── + const searchInputRef = useRef(null); + // External focus trigger (used by the FileSidebar rail Search button). + useEffect(() => { + const onFocus = () => searchInputRef.current?.focus(); + window.addEventListener("files-page:focus-search", onFocus); + return () => window.removeEventListener("files-page:focus-search", onFocus); + }, []); + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + const active = document.activeElement as HTMLElement | null; + const inInput = + active && + (active.tagName === "INPUT" || + active.tagName === "TEXTAREA" || + active.isContentEditable); + + // Cmd/Ctrl + A - select every visible file in the current folder. + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "a" && !inInput) { + e.preventDefault(); + setSelectedFileIds(new Set(visibleFiles.map((f) => f.id))); + return; + } + + // Delete / Backspace - remove selected files. + if ( + (e.key === "Delete" || e.key === "Backspace") && + !inInput && + selectedFileIds.size > 0 + ) { + e.preventDefault(); + removeFiles(Array.from(selectedFileIds)).catch((err) => + folders.setError( + err instanceof Error + ? t("filesPage.error.removeFilesFailedDetail", { + message: err.message, + defaultValue: `Could not remove files: ${err.message}`, + }) + : t( + "filesPage.error.removeFilesFailed", + "Could not remove files.", + ), + ), + ); + return; + } + + // "/" focuses the search field. + if (e.key === "/" && !inInput) { + e.preventDefault(); + searchInputRef.current?.focus(); + return; + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [visibleFiles, selectedFileIds, removeFiles, setSelectedFileIds]); + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key !== "Escape") return; + const active = document.activeElement as HTMLElement | null; + if ( + active && + (active.tagName === "INPUT" || + active.tagName === "TEXTAREA" || + active.isContentEditable) + ) { + return; + } + const overlays = document.querySelectorAll(".mantine-Modal-overlay"); + for (const overlay of overlays) { + if ((overlay as HTMLElement).offsetWidth > 0) return; + } + // Esc-once cancels selection before closing the workbench. + if (selectedFileIds.size > 0) { + clearSelection(); + return; + } + handleClose(); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [handleClose, selectedFileIds, clearSelection]); + + // ─── remove via context wrapper (clears selection state too) ──────────── + const handleRemoveFiles = useCallback( + async (fileIds: FileId[]) => { + await removeFiles(fileIds); + }, + [removeFiles], + ); + + // ─── derived UI bits ──────────────────────────────────────────────────── + const currentFolderRecord = currentFolderId + ? (foldersById.get(currentFolderId) ?? null) + : null; + const totalCount = entries.length; + const selectedFiles = useMemo( + () => Array.from(selectedFileIds), + [selectedFileIds], + ); + + // Local-only subset of selection; drives Save-to-server visibility. + const localOnlySelectedStubs = useMemo( + () => + selectedFiles + .map((id) => fileMap.get(id)) + .filter( + (s): s is StirlingFileStub => + Boolean(s) && s!.remoteStorageId == null, + ), + [selectedFiles, fileMap], + ); + + // null = New folder actionable; string = disabled tooltip reason. + const newFolderDisabledReason: string | null = useMemo(() => { + if (currentTab === "local") { + return t( + "filesPage.localFoldersUnavailable", + "Folders are cloud-only - save a file to the cloud to organise it.", + ); + } + if ( + currentTab === "recent" || + currentTab === "shared" || + currentTab === "sharedByMe" || + currentTab === "imSharing" + ) { + return t( + "filesPage.newFolderTabUnavailable", + "Switch to All or Cloud to create folders.", + ); + } + if (!folders.serverReachable) { + return t( + "filesPage.newFolderStorageDisabled", + "Server folder storage isn't enabled. Ask your admin to turn it on.", + ); + } + return null; + }, [currentTab, folders.serverReachable, t]); + + return ( +
+
+ {/* Breadcrumb only for folder-rooted tabs. */} + {(currentTab === "all" || currentTab === "cloud") && } + {(currentTab === "local" || + currentTab === "recent" || + currentTab === "shared" || + currentTab === "sharedByMe" || + currentTab === "imSharing") && ( +
+ {currentTab === "local" + ? t("filesPage.tabName.local", "Local") + : currentTab === "recent" + ? t("filesPage.tabName.recent", "Recent") + : currentTab === "shared" + ? t("filesPage.tabName.shared", "Shared with me") + : currentTab === "sharedByMe" + ? t("filesPage.tabName.sharedByMe", "Shared by me") + : t("filesPage.tabName.imSharing", "Sharing")} +
+ )} + {(() => { + // Both the inline desktop buttons and the mobile kebab menu need + // these handlers - extract once so we don't drift two copies. + const handleRefresh = async () => { + setRefreshing(true); + try { + // pullFromServer bumps the folder revision, which the + // FolderProvider's effect reacts to by re-running refresh() - + // no need to await folders.refresh() manually. + const result = await folders.pullFromServer(); + if (!result.ok && result.reason !== "endpoint-missing") { + folders.setError( + result.reason === "network" + ? t( + "filesPage.syncError.network", + "Could not reach the server.", + ) + : result.reason === "server" + ? t( + "filesPage.syncError.server", + "Server error during folder sync.", + ) + : t("filesPage.syncError.client", "Folder sync failed."), + ); + } + await refresh(); + } finally { + setRefreshing(false); + } + }; + return ( + <> + +
+ + + + + + {newFolderDisabledReason ? ( + + + + + + ) : ( + + )} + + +
+ + ); + })()} +
+ + {folders.error && ( +
+ {folders.error} + folders.setError(null)} + > + + +
+ )} + + {/* No offline banner: when the folder API is unreachable the user + still sees their cached local files (the IDB read survives), and + folder-mutation controls are individually disabled with their own + tooltips. Banner removed per UX feedback. */} + +
+
+ {/* Tab strip filters the file list; ARIA Tabs keyboard model. */} + {(() => { + const TAB_DEFS = [ + { id: "all", label: t("filesPage.tabs.all", "All") }, + { id: "recent", label: t("filesPage.tabs.recent", "Recent") }, + // Sharing tabs only when sharingEnabled. + ...(sharingEnabled + ? [ + { + id: "shared" as const, + label: t("filesPage.tabs.shared", "Shared with me"), + }, + { + id: "sharedByMe" as const, + label: t("filesPage.tabs.sharedByMe", "Shared by me"), + }, + { + id: "imSharing" as const, + label: t("filesPage.tabs.imSharing", "Sharing"), + }, + ] + : []), + ] as const; + const focusTab = (id: string) => { + const el = document.getElementById(`filesPage-tab-${id}`); + el?.focus(); + }; + return ( +
{ + const idx = TAB_DEFS.findIndex((t2) => t2.id === currentTab); + if (idx < 0) return; + let next: number; + if (e.key === "ArrowRight") + next = (idx + 1) % TAB_DEFS.length; + else if (e.key === "ArrowLeft") + next = (idx - 1 + TAB_DEFS.length) % TAB_DEFS.length; + else if (e.key === "Home") next = 0; + else if (e.key === "End") next = TAB_DEFS.length - 1; + else return; + e.preventDefault(); + const target = TAB_DEFS[next]!; + setCurrentTab(target.id); + focusTab(target.id); + }} + style={{ + display: "flex", + gap: "0.1rem", + padding: "0.2rem 1rem 0.2rem", + }} + > + {TAB_DEFS.map((tab) => ( + + ))} +
+ ); + })()} + +
+ + {loading + ? t("filesPage.loading", "Loading…") + : t("filesPage.summary", "{{count}} items", { + count: totalCount, + })} + {selectedFiles.length > 0 && ( + + {" "} + Β·{" "} + {t("filesPage.selectedCount", "{{count}} selected", { + count: selectedFiles.length, + })} + + )} + + {(() => { + // Select all / Clear toggle over visible files. + if (visibleFiles.length === 0) return null; + const allSelected = visibleFiles.every((f) => + selectedFileIds.has(f.id), + ); + const someSelected = !allSelected && selectedFiles.length > 0; + return ( + + + + ); + })()} +
+ {selectedFiles.length > 0 && + (() => { + // Bulk-action labels; CSS collapses to icon-only below 900px. + const addLabel = + selectedFiles.length === 1 + ? t("filesPage.addToWorkspace", "Add to workspace") + : t( + "filesPage.addToWorkspaceCount", + "Add {{count}} to workspace", + { count: selectedFiles.length }, + ); + const moveLabel = t("filesPage.moveTo", "Move to…"); + const removeLabel = t("filesPage.remove", "Remove"); + const quickViewLabel = t("filesPage.quickView", "Quick view"); + return ( + // wrap="nowrap" keeps the row single-line. + + + + + {selectedFiles.length === 1 && ( + + + + )} + {/* Save to server; hidden when no local-only file selected. */} + {localOnlySelectedStubs.length > 0 && ( + + + + )} + {/* Show details button on compact viewports. */} + {selectedFiles.length === 1 && + isCompactDetailsViewport && ( + + + + )} + + + + + + + + clearSelection()} + aria-label={t( + "filesPage.clearSelection", + "Clear selection", + )} + > + + + + + ); + })()} + {selectedFiles.length > 0 && ( +
+
+ +
+ handleQuickView(file.id)} + onMoveFiles={moveFilesTo} + onMoveFolder={moveFolderTo} + onRenameFolder={openRenameFolderDialog} + onDeleteFolder={promptDeleteFolder} + onChangeFolderAppearance={(folderId, appearance) => { + setFolderAppearance(folderId, appearance).catch((err) => + folders.setError( + err instanceof Error + ? t("filesPage.error.folderAppearanceFailedDetail", { + message: err.message, + defaultValue: `Could not update folder appearance: ${err.message}`, + }) + : t( + "filesPage.error.folderAppearanceFailed", + "Could not update folder appearance.", + ), + ), + ); + }} + onRemoveFiles={handleRemoveFiles} + onPromptMoveFiles={promptMoveFiles} + onSaveToServer={(file) => setSaveToServerTarget([file])} + // Center-of-grid CTAs when the empty state shows - same + // handlers the corner header buttons use so behaviour + // (disabled tooltips, native file picker, dialog) is + // identical regardless of where the user clicks from. + onEmptyUpload={() => fileInputRef.current?.click()} + onEmptyCreateFolder={() => openNewFolderDialog()} + newFolderDisabledReason={newFolderDisabledReason} + /> + {isDraggingExternal && ( +
+ + + + + {t("filesPage.dropOverlay", "Drop files to upload")} + + + {/* Behavior contract: per handleNativeUpload above, all + newly-uploaded files start in Local (folderId stays + null) regardless of the current folder view. Saying + "will land in {folder}" was a lie; tell the truth + so the user reaches for Save-to-cloud / Move-to when + they actually want a folder placement. */} + {t( + "filesPage.dropOverlaySub", + "Files start in Local. Use 'Move to' or 'Save to cloud' to organise them into a folder.", + )} + +
+ )} +
+
+ + {/* Inline aside on desktop. */} + {selectedFiles.length > 0 && !isCompactDetailsViewport && ( + clearSelection()} + onAddToWorkspace={handleAddToWorkspace} + onQuickView={handleQuickView} + onMove={promptMoveFiles} + onRemove={handleRemoveFiles} + onSaveToServer={(files) => setSaveToServerTarget(files)} + /> + )} +
+ + {/* Drawer hosts the details panel on ≀800px viewports. */} + {isCompactDetailsViewport && ( + setMobileDetailsOpen(false)} + position="right" + size={useFullScreenDrawer ? "100%" : "sm"} + padding={0} + withCloseButton={false} + overlayProps={{ opacity: 0.45 }} + > + {mobileDetailsOpen && selectedFiles.length === 1 && ( + setMobileDetailsOpen(false)} + onAddToWorkspace={handleAddToWorkspace} + onQuickView={handleQuickView} + onMove={promptMoveFiles} + onRemove={handleRemoveFiles} + onSaveToServer={(files) => setSaveToServerTarget(files)} + /> + )} + + )} + + { + if (moveDialog.fileIds && moveDialog.fileIds.length > 0) { + await moveFilesTo(moveDialog.fileIds, target); + } else if (moveDialog.folderId) { + await moveFolderTo(moveDialog.folderId, target); + } + }} + // Inline-create folder; gated on serverReachable. + onCreateFolder={ + folders.serverReachable + ? (name, parentFolderId) => + folders.createFolder(name, parentFolderId) + : undefined + } + /> + + + + { + const target = deleteFolderDialog.folder; + if (!target) return; + try { + await deleteFolder(target, deleteContents); + } catch (err) { + folders.setError( + err instanceof Error + ? t("filesPage.error.deleteFolderFailedDetail", { + message: err.message, + defaultValue: `Could not delete folder: ${err.message}`, + }) + : t( + "filesPage.error.deleteFolderFailed", + "Could not delete folder.", + ), + ); + throw err; + } + }} + /> + + {/* Save-to-server modal; keyed on target so updates don't retarget. */} + s.id).join(",")}`} + opened={Boolean(saveToServerTarget && saveToServerTarget.length > 0)} + onClose={() => setSaveToServerTarget(null)} + files={saveToServerTarget ?? []} + onUploaded={refresh} + /> +
+ ); +} + +const SearchField = React.forwardRef< + HTMLInputElement, + { value: string; onChange: (v: string) => void } +>(function SearchField({ value, onChange }, ref) { + const { t } = useTranslation(); + return ( +
+ + onChange(e.currentTarget.value)} + placeholder={t( + "filesPage.searchPlaceholder", + "Search this folder & subfolders", + )} + aria-label={t("filesPage.search", "Search")} + /> + {value && ( + onChange("")} + aria-label={t("filesPage.clearSearch", "Clear search")} + > + + + )} +
+ ); +}); + +function Breadcrumbs() { + const { t } = useTranslation(); + const folders = useFolders(); + const filesPage = useFilesPage(); + const trail = folders.breadcrumbs; + return ( + + ); +} diff --git a/frontend/editor/src/core/components/filesPage/FileOriginBadge.tsx b/frontend/editor/src/core/components/filesPage/FileOriginBadge.tsx new file mode 100644 index 0000000000..91a2bc0dba --- /dev/null +++ b/frontend/editor/src/core/components/filesPage/FileOriginBadge.tsx @@ -0,0 +1,97 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { Tooltip } from "@mantine/core"; +import ComputerIcon from "@mui/icons-material/Computer"; +import CloudDoneIcon from "@mui/icons-material/CloudDone"; +import GroupIcon from "@mui/icons-material/Group"; + +import { FileOrigin } from "@app/components/filesPage/fileOrigin"; + +interface FileOriginBadgeProps { + origin: FileOrigin; + /** Compact (icon-only) vs full (icon + text). */ + compact?: boolean; +} + +const styles = { + base: { + display: "inline-flex", + alignItems: "center", + gap: "0.25rem", + padding: "0.1rem 0.4rem", + borderRadius: "999px", + fontSize: "0.68rem", + fontWeight: 600, + textTransform: "uppercase" as const, + letterSpacing: "0.04em", + lineHeight: 1.2, + }, + local: { + background: + "color-mix(in srgb, var(--text-muted, #6b7280) 16%, transparent)", + color: "var(--text-secondary)", + }, + cloud: { + background: + "color-mix(in srgb, var(--accent-interactive, #6366f1) 16%, transparent)", + color: "var(--accent-interactive, #6366f1)", + }, + shared: { + background: + "color-mix(in srgb, var(--mantine-color-orange-6, #f97316) 16%, transparent)", + color: "var(--mantine-color-orange-6, #f97316)", + }, +}; + +export function FileOriginBadge({ + origin, + compact = false, +}: FileOriginBadgeProps) { + const { t } = useTranslation(); + + const config = (() => { + switch (origin) { + case "cloud": + return { + label: t("filesPage.origin.cloud", "Cloud"), + icon: , + style: styles.cloud, + tooltip: t( + "filesPage.origin.cloudHint", + "Stored on the Stirling server", + ), + }; + case "shared-with-me": + return { + label: t("filesPage.origin.shared", "Shared"), + icon: , + style: styles.shared, + tooltip: t("filesPage.origin.sharedHint", "Shared with you via link"), + }; + case "local": + default: + return { + label: t("filesPage.origin.local", "Local"), + icon: , + style: styles.local, + tooltip: t( + "filesPage.origin.localHint", + "Only stored in this browser", + ), + }; + } + })(); + + const badge = ( + + {config.icon} + {!compact && config.label} + + ); + + return ( + + {badge} + + ); +} diff --git a/frontend/editor/src/core/components/filesPage/FilesPage.css b/frontend/editor/src/core/components/filesPage/FilesPage.css new file mode 100644 index 0000000000..9b3b7b206f --- /dev/null +++ b/frontend/editor/src/core/components/filesPage/FilesPage.css @@ -0,0 +1,1406 @@ +.files-page { + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + background: var(--bg-background); + color: var(--text-primary); + overflow: hidden; +} + +.files-page-header { + /* 3-column grid keeps the search bar position-stable regardless of + * breadcrumb length. Left column flexes for the breadcrumb (truncating + * via overflow:hidden), middle column holds the search, right column + * holds the action buttons right-aligned. */ + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(min(14rem, 100%), 40rem) minmax( + 0, + 1fr + ); + align-items: center; + gap: 1rem; + min-height: 48px; + padding: 0 0.75rem; + border-bottom: 1px solid var(--border-subtle); + background: var(--bg-toolbar); + flex-shrink: 0; +} + +.files-page-header-actions { + justify-self: end; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.files-page-breadcrumbs { + display: flex; + align-items: center; + gap: 0.25rem; + font-size: 0.95rem; + min-width: 0; + overflow-x: auto; + scrollbar-width: none; +} +.files-page-breadcrumbs::-webkit-scrollbar { + display: none; +} + +.files-page-breadcrumb { + background: none; + border: none; + padding: 0.25rem 0.5rem; + border-radius: 0.375rem; + color: var(--text-secondary); + cursor: pointer; + font-size: inherit; + font-weight: 500; + transition: + background-color 0.12s ease, + color 0.12s ease; + white-space: nowrap; + max-width: 18rem; + overflow: hidden; + text-overflow: ellipsis; +} + +.files-page-breadcrumb:hover { + background: var(--hover-bg); + color: var(--text-primary); +} + +.files-page-breadcrumb.is-current { + color: var(--text-primary); + background: var(--hover-bg); +} + +.files-page-breadcrumb-sep { + color: var(--text-muted); + font-size: 1rem !important; + width: 1rem; + height: 1rem; + opacity: 0.55; + flex-shrink: 0; +} + +.files-page-search { + display: flex; + align-items: center; + gap: 0.35rem; + background: var(--bg-surface); + border: 1px solid var(--border-subtle); + border-radius: 999px; + padding: 0.2rem 0.75rem; + /* Fills its grid cell; the cell's minmax(...) clamps to a sensible range. */ + width: 100%; + min-width: 0; +} + +.files-page-search input { + background: transparent; + border: none; + outline: none; + flex: 1; + color: var(--text-primary); + font-size: 0.9rem; +} + +.files-page-body { + display: flex; + flex: 1 1 auto; + min-height: 0; + overflow: hidden; +} + +.files-page-tree { + width: 17rem; + flex-shrink: 0; + border-right: 1px solid var(--border-subtle); + background: var(--bg-toolbar); + overflow-y: auto; + display: flex; + flex-direction: column; +} + +.files-page-tree[data-collapsed="true"] { + width: 3.25rem; +} + +.files-page-tree-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.75rem 0.75rem 0.5rem; + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-muted); +} + +/* Tree node styles moved to FolderTreePanel.css - that's where the + .files-page-tree-* elements actually render (the slide-out panel from + HomePage), so the styles need to load whether or not the standalone + /files route is mounted. */ + +.files-page-main { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + position: relative; +} + +.files-page-toolbar { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.55rem 1.25rem; + border-bottom: 1px solid var(--border-subtle); + background: var(--bg-background); + flex-shrink: 0; + flex-wrap: wrap; + min-height: 3rem; +} + +.files-page-toolbar-info { + font-size: 0.85rem; + color: var(--text-secondary); + font-weight: 500; +} + +/* Upload + New folder at the start of the toolbar. */ +.files-page-toolbar-create { + display: inline-flex; + align-items: center; + gap: 0.4rem; + flex-shrink: 0; +} + +.files-page-toolbar-info span { + color: var(--text-muted); +} + +.files-page-toolbar-actions { + margin-left: auto; + display: flex; + align-items: center; + gap: 0.5rem; + /* Allow this container to shrink below its intrinsic content width so + the bulk-action group + filter selects + view toggle can compete for + space without bursting out of the toolbar (which previously bled + under the right-hand details panel at wider viewports). */ + min-width: 0; +} + +/* Bulk-action buttons (Add to workspace, Quick view, Move to, Remove) + are ICON-ONLY at every viewport. Even at 1280px when both side panels + are visible, the main area is only ~616px wide - the labelled versions + of these 4 buttons plus the 2 filter selects + view toggle simply + don't fit. Each button has a Tooltip + aria-label, so the icon alone + is accessible. */ +.files-page-toolbar-actions .mantine-Button-root { + padding-left: 0.55rem; + padding-right: 0.55rem; + flex-shrink: 0; +} +.files-page-toolbar-actions .mantine-Button-section[data-position="left"] { + margin-right: 0; +} +.files-page-toolbar-actions .mantine-Button-label { + display: none; +} +/* Pin the view toggle: never let it clip off the right. flex-shrink:0 + keeps its width fixed; the rest of the row shrinks around it. */ +.files-page-toolbar-actions .mantine-SegmentedControl-root { + flex-shrink: 0; +} +/* Clip overflow on the toolbar itself as a safety net - the content + should always fit thanks to the rules above, but if a future addition + ever breaks that, this prevents the action group from bleeding into + sibling areas (notably the details panel on the right). */ +.files-page-toolbar { + overflow-x: hidden; +} + +.files-page-toolbar-divider { + width: 1px; + height: 1.4rem; + background: var(--border-subtle); + margin: 0 0.15rem; +} + +/* SegmentedControl in the toolbar uses icon-only options; this centers the + MUI icon inside Mantine's `.mantine-SegmentedControl-innerLabel` (which + defaults to `display: inline` and baseline-aligns its children, leaving + the icon visually pinned to the top of the label box). */ +.files-page-view-toggle-icon { + display: inline-flex; + align-items: center; + justify-content: center; + line-height: 0; + vertical-align: middle; +} + +/* Visually-hidden text that screen readers still announce. Lets us put + localised labels next to icon-only buttons (SegmentedControl options, + icon-only action rows) without growing the visible UI. Standard + "sr-only" / "visually-hidden" recipe. */ +.files-page-sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.files-page-content { + flex: 1 1 auto; + overflow: auto; + padding: 1.25rem; + position: relative; + scrollbar-width: thin; + scrollbar-color: var(--border-subtle) transparent; +} + +.files-page-content::-webkit-scrollbar { + width: 0.5rem; + height: 0.5rem; +} + +.files-page-content::-webkit-scrollbar-thumb { + background: var(--border-subtle); + border-radius: 999px; + border: 2px solid transparent; + background-clip: content-box; +} + +.files-page-content::-webkit-scrollbar-thumb:hover { + background: var(--text-muted); + background-clip: content-box; +} + +.files-page-tree-list, +.folder-tree-panel-inner, +.files-page-details-body { + scrollbar-width: thin; + scrollbar-color: var(--border-subtle) transparent; +} + +.files-page-tree-list::-webkit-scrollbar, +.folder-tree-panel-inner::-webkit-scrollbar, +.files-page-details-body::-webkit-scrollbar { + width: 0.4rem; +} + +.files-page-tree-list::-webkit-scrollbar-thumb, +.folder-tree-panel-inner::-webkit-scrollbar-thumb, +.files-page-details-body::-webkit-scrollbar-thumb { + background: var(--border-subtle); + border-radius: 999px; +} + +.files-page-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr)); + gap: 1rem; +} + +.files-page-list { + display: flex; + flex-direction: column; + border: 1px solid var(--border-subtle); + border-radius: 0.6rem; + overflow: hidden; + background: var(--bg-surface); +} + +.files-page-list-row { + display: grid; + grid-template-columns: 2.25rem minmax(0, 3fr) 1fr 1fr 1fr 2.5rem; + gap: 0.5rem; + align-items: center; + padding: 0.5rem 0.75rem; + border-bottom: 1px solid var(--border-subtle); + cursor: pointer; + font-size: 0.9rem; + transition: background-color 0.12s ease; +} + +.files-page-list-row:last-child { + border-bottom: none; +} + +.files-page-list-row.is-header { + background: var(--bg-toolbar); + color: var(--text-muted); + text-transform: uppercase; + font-size: 0.72rem; + letter-spacing: 0.05em; + cursor: default; + user-select: none; +} + +.files-page-list-row.is-header [data-sortable="true"] { + cursor: pointer; + padding: 0.2rem 0.4rem; + margin: -0.2rem -0.4rem; + border-radius: 0.3rem; + transition: + background-color 0.12s ease, + color 0.12s ease; +} + +.files-page-list-row.is-header [data-sortable="true"]:hover { + background: var(--hover-bg); + color: var(--text-primary); +} + +.files-page-list-row:not(.is-header):hover { + background: var(--hover-bg); +} + +.files-page-list-row.is-selected { + background: var(--hover-bg); +} + +.files-page-list-row.is-drop-target { + background: color-mix( + in srgb, + var(--accent-interactive, #6366f1) 12%, + transparent + ); + box-shadow: inset 0 0 0 1px var(--accent-interactive, #6366f1); +} + +.files-page-card { + position: relative; + display: flex; + flex-direction: column; + background: var(--bg-surface); + border: 1px solid var(--border-subtle); + border-radius: 0.85rem; + overflow: hidden; + cursor: pointer; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04); + transition: + transform 0.16s cubic-bezier(0.32, 0.72, 0.32, 1), + border-color 0.14s ease, + box-shadow 0.18s ease; +} + +.files-page-card:hover { + transform: translateY(-2px); + border-color: color-mix( + in srgb, + var(--accent-interactive, #6366f1) 35%, + var(--border-subtle) + ); + box-shadow: + 0 4px 6px -1px rgba(0, 0, 0, 0.08), + 0 8px 24px -8px rgba(0, 0, 0, 0.18); +} + +.files-page-card:focus-visible { + outline: 2px solid var(--accent-interactive, #6366f1); + outline-offset: 2px; +} + +.files-page-card.is-selected { + border-color: var(--accent-interactive, #6366f1); + box-shadow: + 0 0 0 2px var(--accent-interactive, #6366f1), + 0 6px 18px -6px + color-mix(in srgb, var(--accent-interactive, #6366f1) 35%, transparent); +} + +.files-page-card.is-folder { + cursor: pointer; +} + +.files-page-card-thumb { + aspect-ratio: 4 / 3; + background: linear-gradient( + 180deg, + color-mix(in srgb, var(--text-muted) 5%, var(--bg-surface)), + var(--bg-surface) + ); + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + position: relative; +} + +.files-page-card-thumb img { + width: 100%; + height: 100%; + object-fit: cover; + transition: transform 0.4s cubic-bezier(0.32, 0.72, 0.32, 1); +} + +.files-page-card:hover .files-page-card-thumb img { + transform: scale(1.03); +} + +.files-page-card-thumb-fallback { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.4rem; + color: var(--text-muted); + font-size: 0.72rem; + font-weight: 600; + letter-spacing: 0.04em; +} + +.files-page-card-body { + padding: 0.65rem 0.85rem 0.7rem; + display: flex; + flex-direction: column; + gap: 0.2rem; + min-width: 0; +} + +.files-page-card-name { + font-size: 0.9rem; + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: var(--text-primary); + letter-spacing: -0.005em; +} + +.files-page-card-meta { + font-size: 0.76rem; + color: var(--text-muted); + display: flex; + align-items: center; + gap: 0.4rem; +} + +/* Parent-folder breadcrumb shown on cards/rows during recursive search so + the user can tell which folder each hit lives in without navigating. */ +.files-page-card-path { + font-size: 0.72rem; + color: var(--text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-top: 0.15rem; + font-style: italic; +} + +.files-page-card .files-page-card-selector { + position: absolute; + top: 0.5rem; + left: 0.5rem; + z-index: 2; + opacity: 0; + pointer-events: none; +} + +/* Reveal on hover, on focus-within (keyboard navigation), and when the card is selected. + :focus-within is what makes the checkbox + kebab discoverable without a pointer device. */ +.files-page-card:hover .files-page-card-selector, +.files-page-card:focus-within .files-page-card-selector, +.files-page-card.is-selected .files-page-card-selector { + opacity: 1; + pointer-events: auto; +} + +/* Style the Mantine checkbox so it reads as a soft pill rather than a + bare form control. */ +.files-page-card-selector :where(.mantine-Checkbox-input) { + background: var(--bg-surface); + border-color: var(--border-subtle); + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.18); +} + +.files-page-card-actions { + position: absolute; + top: 0.4rem; + right: 0.4rem; + opacity: 0; + transition: opacity 0.12s ease; + z-index: 4; +} + +/* Make the kebab a high-contrast pill that works on dark thumbnails, + white PDFs, and any background underneath. + Use stronger specificity + !important to win over Mantine's + variant=filled inline styles. */ +.files-page-card .files-page-card-actions button.mantine-ActionIcon-root { + background-color: var(--bg-raised, var(--bg-surface)) !important; + color: var(--text-primary) !important; + border: 1px solid var(--border-default, var(--border-subtle)) !important; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.25); + backdrop-filter: blur(6px); +} + +.files-page-card .files-page-card-actions button.mantine-ActionIcon-root:hover { + background-color: var(--hover-bg) !important; +} + +/* Row-view kebab: same high-contrast treatment so it's readable + against selected (tinted) and in-workspace rows. */ +.files-page-list-row .mantine-ActionIcon-root[aria-label$="actions"] { + color: var(--text-secondary); +} + +.files-page-list-row .mantine-ActionIcon-root[aria-label$="actions"]:hover { + background-color: var(--hover-bg) !important; + color: var(--text-primary); +} + +.files-page-card-origin { + position: absolute; + bottom: 0.4rem; + left: 0.4rem; + pointer-events: none; +} + +/* "Open" badge - file is currently loaded in the active workspace. + Solid pill with white text so it reads against any thumbnail + (white PDFs, dark covers, photos). */ +.files-page-card-open-badge { + position: absolute; + top: 0.5rem; + right: 0.5rem; + z-index: 3; + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.22rem 0.55rem; + border-radius: 999px; + background: var(--mantine-color-teal-6, #10b981); + color: #ffffff; + font-size: 0.68rem; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + box-shadow: + 0 0 0 2px rgba(255, 255, 255, 0.85), + 0 2px 6px rgba(0, 0, 0, 0.18); + pointer-events: none; +} + +.files-page-card-open-dot { + width: 0.45rem; + height: 0.45rem; + border-radius: 50%; + background: #ffffff; + box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.35); + animation: files-page-open-pulse 1.8s ease-in-out infinite; +} + +@keyframes files-page-open-pulse { + 0%, + 100% { + opacity: 1; + transform: scale(1); + } + 50% { + opacity: 0.6; + transform: scale(0.85); + } +} + +.files-page-card.is-in-workspace { + border-color: color-mix( + in srgb, + var(--mantine-color-teal-6, #10b981) 35%, + var(--border-subtle) + ); +} + +.files-page-card.is-in-workspace::before { + content: ""; + position: absolute; + inset: 0; + border-radius: inherit; + pointer-events: none; + background: linear-gradient( + 180deg, + color-mix(in srgb, var(--mantine-color-teal-6, #10b981) 6%, transparent) 0%, + transparent 40% + ); + z-index: 0; +} + +.files-page-row-open-pill { + display: inline-flex; + align-items: center; + gap: 0.3rem; + padding: 0.12rem 0.5rem; + border-radius: 999px; + background: var(--mantine-color-teal-6, #10b981); + color: #ffffff; + font-size: 0.68rem; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + flex-shrink: 0; + margin-left: 0.25rem; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.12); +} + +.files-page-list-row.is-in-workspace { + background: color-mix( + in srgb, + var(--mantine-color-teal-6, #10b981) 4%, + transparent + ); +} + +.files-page-card:hover .files-page-card-actions, +.files-page-card:focus-within .files-page-card-actions, +.files-page-card.is-selected .files-page-card-actions { + opacity: 1; +} + +.files-page-card.is-folder .files-page-card-thumb { + aspect-ratio: 1.6 / 1; + transition: + filter 0.16s ease, + transform 0.18s cubic-bezier(0.32, 0.72, 0.32, 1); +} + +.files-page-card.is-folder:hover .files-page-card-thumb { + filter: brightness(1.04); +} + +.files-page-card.is-folder.is-drop-target { + transform: translateY(-3px); +} + +.files-page-card.is-folder.is-drop-target .files-page-card-thumb { + filter: brightness(1.08); + transform: scale(1.03); +} + +.files-page-card.is-drop-target { + background: color-mix( + in srgb, + var(--accent-interactive, #6366f1) 10%, + var(--bg-surface) + ); + box-shadow: 0 0 0 2px var(--accent-interactive, #6366f1); + border-color: var(--accent-interactive, #6366f1); +} + +@keyframes files-page-skeleton-pulse { + 0% { + background-position: 200% 0; + } + 100% { + background-position: -200% 0; + } +} + +.files-page-skeleton-bar { + display: block; + background: linear-gradient( + 90deg, + color-mix(in srgb, var(--text-muted) 12%, transparent) 25%, + color-mix(in srgb, var(--text-muted) 22%, transparent) 50%, + color-mix(in srgb, var(--text-muted) 12%, transparent) 75% + ); + background-size: 200% 100%; + animation: files-page-skeleton-pulse 1.4s ease-in-out infinite; + border-radius: 0.4rem; +} + +.files-page-skeleton-card { + pointer-events: none; + border-color: var(--border-subtle); + box-shadow: none; +} + +.files-page-skeleton-card:hover { + transform: none; + box-shadow: none; + border-color: var(--border-subtle); +} + +.files-page-skeleton-row { + pointer-events: none; +} + +.files-page-skeleton-row .files-page-skeleton-bar { + height: 0.7rem; +} + +.files-page-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + gap: 0.75rem; + color: var(--text-muted); + padding: 4rem 1.5rem; + text-align: center; +} + +.files-page-empty-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 5rem; + height: 5rem; + border-radius: 50%; + background: color-mix( + in srgb, + var(--accent-interactive, #6366f1) 12%, + transparent + ); + color: var(--accent-interactive, #6366f1); + margin-bottom: 0.25rem; +} + +.files-page-empty-title { + font-size: 1.15rem; + color: var(--text-primary); + font-weight: 600; + letter-spacing: -0.01em; +} + +.files-page-empty-hint { + font-size: 0.9rem; + color: var(--text-muted); + max-width: 30rem; + line-height: 1.5; +} + +/* Empty-state CTA row; stacks on phones. */ +.files-page-empty-actions { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + justify-content: center; + margin-top: 0.75rem; +} + +@media (max-width: 480px) { + .files-page-empty-actions { + flex-direction: column; + width: min(100%, 16rem); + } +} + +.files-page-details { + width: 22rem; + flex-shrink: 0; + border-left: 1px solid var(--border-subtle); + background: var(--bg-toolbar); + display: flex; + flex-direction: column; + overflow: hidden; +} + +.files-page-details-header { + /* Tight vertical padding so the "DETAILS" label hugs the body content + below it - it's a quiet section header, not a banner, and the old + ~16px top + 18px body padding wasted nearly two character heights + of vertical space before the user saw the file thumbnail. */ + padding: 0.35rem 1.1rem; + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + font-size: 0.78rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-muted); +} + +.files-page-details-header strong { + font-weight: 600; +} + +.files-page-details-body { + /* Smaller top padding to pair with the tightened header above. Side + and bottom padding stays at 1.1rem so the content still breathes + against the panel edges. */ + padding: 0.3rem 1.1rem 1.1rem; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 0.65rem; +} + +.files-page-details-thumb { + aspect-ratio: 4 / 3; + background: var(--bg-surface); + border: 1px solid var(--border-subtle); + border-radius: 0.6rem; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04); + /* Inside an overflow-y:auto flex column, `aspect-ratio` alone doesn't + guarantee intrinsic height - flex-shrink:1 (default) can squash the + thumb to a 2px sliver when the column gets cramped (notably after + tightening the body padding above). Pin shrink so the thumb keeps + its 4:3 box. */ + flex-shrink: 0; +} + +.files-page-details-thumb img { + width: 100%; + height: 100%; + object-fit: contain; +} + +.files-page-details-fieldlist { + display: flex; + flex-direction: column; + gap: 0.55rem; + font-size: 0.85rem; + padding: 0.65rem 0.75rem; + background: var(--bg-surface); + border: 1px solid var(--border-subtle); + border-radius: 0.5rem; +} + +.files-page-details-field { + display: flex; + justify-content: space-between; + gap: 0.5rem; +} + +.files-page-details-field-label { + color: var(--text-muted); + font-size: 0.78rem; +} + +.files-page-details-field-value { + color: var(--text-primary); + text-align: right; + word-break: break-word; + font-weight: 500; +} + +.files-page-details-tool-history { + display: flex; + flex-direction: column; + gap: 0.3rem; + padding: 0.5rem 0.6rem; + background: var(--bg-surface); + border: 1px solid var(--border-subtle); + border-radius: 0.5rem; +} + +.files-page-details-tool-history-label { + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-muted); +} + +/* Filename chip next to the title - "PDF", "DOCX", etc. + Mantine's rendered effectively invisible + in dark mode; project CSS vars track the active color scheme. */ +.files-page-details-ext-tag { + display: inline-flex; + align-items: center; + padding: 0.12rem 0.5rem; + border-radius: 0.4rem; + background: var(--bg-surface); + border: 1px solid var(--border-subtle); + color: var(--text-secondary, var(--text-primary)); + font-size: 0.65rem; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; + line-height: 1.2; +} + +/* Vertical timeline of version history. + Each row has a left rail (dot + connector) and a body (summary + meta + + expandable detail). The rail visually threads versions together + like a commit graph; clicking a row's summary toggles its expanded + detail (cumulative tool chain, full meta). Long chains (> 6) collapse + the middle behind a "Show N earlier versions" button. */ +.files-page-details-version-timeline { + display: flex; + flex-direction: column; + gap: 0.4rem; + padding: 0.55rem 0.7rem 0.7rem; + background: var(--bg-surface); + border: 1px solid var(--border-subtle); + border-radius: 0.5rem; +} +.files-page-details-version-timeline-label { + display: flex; + align-items: center; + gap: 0.35rem; + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-muted); +} +.files-page-details-version-timeline-count { + margin-left: auto; + font-weight: 600; + color: var(--text-secondary, var(--text-muted)); + text-transform: none; + letter-spacing: 0; +} +.files-page-details-version-timeline-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; +} +.files-page-details-version-timeline-row, +.files-page-details-version-timeline-ellipsis { + display: flex; + gap: 0.6rem; + padding: 0.15rem 0; + position: relative; +} +.files-page-details-version-timeline-rail { + display: flex; + flex-direction: column; + align-items: center; + flex-shrink: 0; + width: 0.8rem; + padding-top: 0.45rem; +} +.files-page-details-version-timeline-rail-dot { + width: 0.55rem; + height: 0.55rem; + border-radius: 50%; + background: var(--bg-toolbar); + border: 2px solid var(--border-strong, var(--border-subtle)); + z-index: 1; + flex-shrink: 0; +} +.files-page-details-version-timeline-rail-dot.is-active { + background: var(--accent-interactive, #6366f1); + border-color: var(--accent-interactive, #6366f1); + box-shadow: 0 0 0 3px + color-mix(in srgb, var(--accent-interactive, #6366f1) 25%, transparent); +} +.files-page-details-version-timeline-rail-dot.is-ellipsis { + width: 0.35rem; + height: 0.35rem; + background: var(--text-muted); + border-color: transparent; +} +.files-page-details-version-timeline-rail-line { + width: 2px; + flex: 1; + background: var(--border-subtle); + min-height: 0.6rem; + margin-top: 2px; +} +.files-page-details-version-timeline-body { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 0.2rem; + padding: 0.25rem 0.3rem 0.4rem; + border-radius: 0.35rem; + transition: background-color 0.12s ease; +} +.files-page-details-version-timeline-row.is-active + .files-page-details-version-timeline-body { + background: color-mix( + in srgb, + var(--accent-interactive, #6366f1) 10%, + transparent + ); +} +.files-page-details-version-timeline-summary { + appearance: none; + background: none; + border: 0; + padding: 0; + margin: 0; + display: flex; + align-items: center; + gap: 0.4rem; + cursor: pointer; + text-align: left; + color: inherit; + font: inherit; +} +.files-page-details-version-timeline-summary:hover + .files-page-details-version-timeline-chevron { + color: var(--text-primary); +} +.files-page-details-version-timeline-delta { + font-size: 0.82rem; + color: var(--text-primary); + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + display: inline-flex; + align-items: baseline; + gap: 0.25rem; +} +.files-page-details-version-timeline-delta.is-origin { + font-weight: 400; + color: var(--text-muted); + font-style: italic; +} +.files-page-details-version-timeline-delta-plus { + color: var(--accent-interactive, #6366f1); + font-weight: 700; +} +.files-page-details-version-timeline-spacer { + flex: 1; +} +.files-page-details-version-timeline-chevron { + color: var(--text-muted); + transition: transform 0.15s ease; +} +.files-page-details-version-timeline-chevron.is-expanded { + transform: rotate(180deg); + color: var(--text-primary); +} +.files-page-details-version-timeline-meta-line { + display: flex; + align-items: center; + gap: 0.35rem; + font-size: 0.7rem; + color: var(--text-muted); +} +.files-page-details-version-timeline-expanded { + display: flex; + flex-direction: column; + gap: 0.4rem; + margin-top: 0.45rem; + padding-top: 0.5rem; + border-top: 1px dashed var(--border-subtle); +} +.files-page-details-version-timeline-toolchain { + display: flex; + flex-direction: column; + gap: 0.2rem; +} +.files-page-details-version-timeline-toolchain-label { + font-size: 0.65rem; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; +} +.files-page-details-version-timeline-ellipsis-btn, +.files-page-details-version-timeline-collapse-btn { + appearance: none; + background: none; + border: 1px dashed var(--border-subtle); + border-radius: 0.3rem; + padding: 0.25rem 0.5rem; + margin: 0.1rem 0; + font-size: 0.72rem; + color: var(--text-muted); + cursor: pointer; + text-align: left; + transition: + border-color 0.12s ease, + color 0.12s ease; +} +.files-page-details-version-timeline-ellipsis-btn:hover, +.files-page-details-version-timeline-collapse-btn:hover { + color: var(--text-primary); + border-color: var(--border-strong, var(--text-muted)); +} +.files-page-details-version-timeline-collapse-btn { + align-self: flex-start; + margin-top: 0.2rem; +} + +.files-page-drop-overlay { + position: absolute; + inset: 1rem; + background: color-mix( + in srgb, + var(--accent-interactive, #6366f1) 14%, + var(--bg-background) + ); + border: 2px dashed var(--accent-interactive, #6366f1); + border-radius: 1rem; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 1rem; + pointer-events: none; + color: var(--accent-interactive, #6366f1); + font-weight: 600; + font-size: 1.1rem; + z-index: 10; + animation: files-page-drop-overlay-pulse 1.6s ease-in-out infinite; + backdrop-filter: blur(2px); +} + +.files-page-drop-overlay-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 4.5rem; + height: 4.5rem; + border-radius: 50%; + background: color-mix( + in srgb, + var(--accent-interactive, #6366f1) 30%, + transparent + ); +} + +.files-page-drop-overlay-icon svg { + font-size: 2.5rem !important; + color: var(--bg-background); +} + +.files-page-drop-overlay-sub { + font-size: 0.85rem; + font-weight: 500; + color: color-mix( + in srgb, + var(--accent-interactive, #6366f1) 80%, + var(--text-secondary) + ); +} + +@keyframes files-page-drop-overlay-pulse { + 0%, + 100% { + border-color: var(--accent-interactive, #6366f1); + transform: scale(1); + } + 50% { + border-color: color-mix( + in srgb, + var(--accent-interactive, #6366f1) 60%, + transparent + ); + transform: scale(1.005); + } +} + +@media (max-width: 1024px) { + .files-page-tree { + width: 14rem; + } + .files-page-details { + width: 18rem; + } +} + +@media (max-width: 800px) { + /* Tree slot inside the inline layout can't fit at this width - the + tree itself is still reachable via the FolderTreePanel slide-out. + Details panel visibility is controlled by JSX (conditional Drawer) + at this width, not CSS, so we don't blanket-hide .files-page-details + here - that would also hide it when it lives inside the Drawer's + portaled overlay tree. */ + .files-page-tree { + display: none; + } + .files-page-content { + padding: 0.75rem; + } +} + +/* ── Tablet / narrow desktop (≀900px) ──────────────────────────────── + As soon as the right-hand details panel disappears (at 800px) and + even slightly before, the toolbar gets crowded - the bulk-action + strip ("Add to workspace", "Quick view", "Move to…", "Remove") + + filter selects + grid/list toggle is too wide to fit. This block + collapses bulk-action button labels to icon-only, shrinks the + filter selects, and pins the view toggle so it never clips off the + right edge. The header stays in desktop layout here (it has fewer + items and the side panels disappearing actually GIVES it room). */ +.files-page-header [data-desktop-hide="true"] { + /* The mobile-only overflow menu trigger. Hidden by default; shown + inside the mobile breakpoint below. Live as inline-flex so it + sits next to the Upload button without breaking the action row. */ + display: none; +} +@media (max-width: 900px) { + .files-page-toolbar { + /* nowrap so "7 items" + "Select all" sit on the same row as the + filter dropdowns and view-toggle instead of stacking on three + separate lines. Per-child min-width:0 lets them shrink as needed. + Used to only kick in at ≀640px which left a broken zone where + both side panels were hidden but the toolbar was still wrapping + to multiple rows. */ + flex-wrap: nowrap; + gap: 0.35rem; + padding: 0.35rem 0.5rem; + min-height: auto; + overflow-x: hidden; + } + .files-page-toolbar-info { + /* Was `flex-basis: 100%` which forced a row break. Let it share + the row, shrink hard if needed, and ellipsize so the count line + collapses gracefully (was overlapping the bulk-action buttons + at ~400px because no truncation rule existed). */ + flex: 0 1 auto; + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + .files-page-toolbar-actions { + flex-wrap: nowrap; + gap: 0.3rem; + } + .files-page-toolbar-actions .mantine-Select-root, + .files-page-toolbar-actions .mantine-Select-input { + /* Mantine Select defaults to ~12rem which is huge for narrow widths. + The inline `style={{ width: 140|160 }}` on the Selects gets + overridden here so they actually shrink. */ + min-width: 0; + max-width: 7.5rem; + width: auto !important; + } + /* The "Select all" / "Clear selection" toggle is a Mantine Button + directly inside .files-page-toolbar (NOT inside the actions group), + so the icon-only collapse from base styles doesn't apply to it. + Pin it to full text + nowrap so it never truncates to "Select al" + mid-word. */ + .files-page-toolbar > .mantine-Button-root { + flex-shrink: 0; + white-space: nowrap; + } +} + +/* ── Mobile (≀640px) ──────────────────────────────────────────────── + Tighten the chrome so the file manager fits inside the mobile + workspace slide. The slide-rail's bottom tab bar already provides + navigation, so the in-header Home/Apps/Close trio is duplicated + and the first to go. Same for "Upload" - the user can use the + centre drop overlay. */ +@media (max-width: 640px) { + /* Drop the 3-column grid on phones; flex-wrap lets the search slip onto + * its own row when chrome is too cramped to share. */ + .files-page-header { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + padding: 0 0.4rem; + overflow-x: hidden; + } + .files-page-header [data-mobile-hide="true"] { + display: none !important; + } + .files-page-header [data-desktop-hide="true"] { + display: inline-flex !important; + } + /* Mobile-hide for sub-toolbar create buttons. */ + .files-page-toolbar [data-mobile-hide="true"] { + display: none !important; + } + .files-page-header-actions { + margin-left: auto; + gap: 0.3rem; + flex-wrap: nowrap; + } + .files-page-search { + flex: 1 1 100%; + } + .files-page-breadcrumbs { + font-size: 0.85rem; + flex-wrap: nowrap; + overflow-x: auto; + min-width: 0; + } + .files-page-search { + /* Shrink hard so the search bar doesn't eat the whole action row. + Users still see the icon + a few chars of the placeholder. + `overflow: hidden` clips the input's natural intrinsic width so + placeholder text never leaks outside the rounded pill. */ + min-width: 0; + flex: 0 1 5.5rem; + max-width: 6.5rem; + overflow: hidden; + } + .files-page-search input { + /* `min-width: 0` lets flex actually shrink the input below its + default ~20-char intrinsic size - without this, the placeholder + extends beyond the parent's clip box and bleeds onto neighbours. */ + min-width: 0; + text-overflow: ellipsis; + } + /* Upload becomes an icon-only square button on mobile so the action + row stops getting clipped. Scoped to `.files-page-header-actions` + so the Back button at the header level keeps its visible "Back" + label (Back has no other on-screen indicator that it's about leaving). */ + .files-page-header-actions .mantine-Button-root { + padding-left: 0.55rem; + padding-right: 0.55rem; + } + .files-page-header-actions .mantine-Button-section[data-position="left"] { + margin-right: 0; + } + .files-page-header-actions .mantine-Button-label { + display: none; + } + /* Grid: single column on very narrow phones; two columns from ~440px */ + .files-page-grid { + grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr)); + gap: 0.75rem; + } + .files-page-content { + padding: 0.5rem; + } +} + +@media (max-width: 420px) { + /* Don't force 1fr - auto-fill with the 9rem floor already keeps two cards + per row on a 360px phone, which feels right for one-handed taps. The + real fix at this width is the list view, which had 6 columns that wouldn't + fit at all. */ + .files-page-grid { + grid-template-columns: repeat(auto-fill, minmax(8rem, 1fr)); + gap: 0.5rem; + } + /* List view at tiny widths becomes cramped - drop the size + type + modified + columns and let name + actions take the room. */ + .files-page-list-row { + grid-template-columns: 1.75rem minmax(0, 1fr) 2rem; + } + .files-page-list-row > :nth-child(3), + .files-page-list-row > :nth-child(4), + .files-page-list-row > :nth-child(5) { + display: none; + } +} + +/* Honour user preference for reduced motion (WCAG 2.3.3). Disables the + open-badge pulse, drop-overlay pulse + scale, and the card hover lift - + keeps focus rings and selection borders so state is still visible. */ +@media (prefers-reduced-motion: reduce) { + .files-page-card-open-dot, + .files-page-drop-overlay { + animation: none; + } + .files-page-card, + .files-page-card-thumb img { + transition: none; + } + .files-page-card:hover { + transform: none; + } + .files-page-card.is-folder.is-drop-target { + transform: none; + } + .files-page-card.is-folder.is-drop-target .files-page-card-thumb { + transform: none; + } +} diff --git a/frontend/editor/src/core/components/filesPage/FolderAppearancePicker.tsx b/frontend/editor/src/core/components/filesPage/FolderAppearancePicker.tsx new file mode 100644 index 0000000000..c5f3479038 --- /dev/null +++ b/frontend/editor/src/core/components/filesPage/FolderAppearancePicker.tsx @@ -0,0 +1,178 @@ +/** + * Inline colour + icon picker for a folder. Rendered inside the folder + * kebab menu (Mantine Menu.Item with a custom body) so the menu can + * still own close-on-outside-click behaviour. + */ + +import React from "react"; +import { useTranslation } from "react-i18next"; +import { Tooltip } from "@mantine/core"; + +import { FolderRecord, FOLDER_COLOR_PALETTE } from "@app/types/folder"; +import { + FOLDER_ICONS, + FolderIconOption, +} from "@app/components/filesPage/folderIcons"; + +interface FolderAppearancePickerProps { + folder: FolderRecord; + onChange: (next: { color?: string; icon?: string | null }) => void; + /** When true, all colour + icon buttons are unresponsive (e.g. while offline). */ + disabled?: boolean; +} + +export function FolderAppearancePicker({ + folder, + onChange, + disabled = false, +}: FolderAppearancePickerProps) { + const { t } = useTranslation(); + + return ( +
+
+
+ {FOLDER_COLOR_PALETTE.map((c) => ( +
+
+ +
+
+ {FOLDER_ICONS.map((icon) => ( + + onChange({ icon: icon.id === "none" ? null : icon.id }) + } + /> + ))} +
+
+
+ ); +} + +function Section({ + label, + children, +}: { + label: string; + children: React.ReactNode; +}) { + return ( +
+ + {label} + + {children} +
+ ); +} + +function IconButton({ + icon, + selected, + onClick, + disabled = false, +}: { + icon: FolderIconOption; + selected: boolean; + onClick: () => void; + disabled?: boolean; +}) { + return ( + + + + ); +} diff --git a/frontend/editor/src/core/components/filesPage/FolderNameDialog.tsx b/frontend/editor/src/core/components/filesPage/FolderNameDialog.tsx new file mode 100644 index 0000000000..4197d652ed --- /dev/null +++ b/frontend/editor/src/core/components/filesPage/FolderNameDialog.tsx @@ -0,0 +1,111 @@ +import React, { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Alert, Button, Group, Modal, Stack, TextInput } from "@mantine/core"; +import ErrorOutlineIcon from "@mui/icons-material/ErrorOutlined"; + +interface FolderNameDialogProps { + opened: boolean; + title: string; + initialName?: string; + submitLabel: string; + onClose: () => void; + onSubmit: (name: string) => void | Promise; +} + +export function FolderNameDialog({ + opened, + title, + initialName = "", + submitLabel, + onClose, + onSubmit, +}: FolderNameDialogProps) { + const { t } = useTranslation(); + const [value, setValue] = useState(initialName); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (opened) { + setValue(initialName); + setSubmitting(false); + setError(null); + } + }, [opened, initialName]); + + const submit = async () => { + const name = value.trim(); + if (!name) return; + setSubmitting(true); + setError(null); + try { + await onSubmit(name); + onClose(); + } catch (err) { + // Keep dialog open so the user can retry. Closing on error was a + // silent failure (the dialog vanished, but the folder was never + // created - user thinks success, sees no folder). + setError( + err instanceof Error + ? err.message + : t( + "filesPage.folderName.error", + "Could not save folder. Try again.", + ), + ); + } finally { + setSubmitting(false); + } + }; + + return ( + + + setValue(e.currentTarget.value)} + placeholder={t("filesPage.folderName.placeholder", "Folder name")} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + void submit(); + } + }} + maxLength={120} + aria-label={t("filesPage.folderName.label", "Folder name")} + /> + {error && ( + } + variant="light" + role="alert" + > + {error} + + )} + + + + + + + ); +} diff --git a/frontend/editor/src/core/components/filesPage/FolderThumbnail.tsx b/frontend/editor/src/core/components/filesPage/FolderThumbnail.tsx new file mode 100644 index 0000000000..b8e68f9f7f --- /dev/null +++ b/frontend/editor/src/core/components/filesPage/FolderThumbnail.tsx @@ -0,0 +1,170 @@ +/** + * Stylised folder thumbnail. + * + * A custom SVG folder shape that takes its accent colour from + * `FolderRecord.color` and shows the contained-file count as a small badge + * in the corner. Renders proportionally inside whatever container it's + * placed in (file card thumb, list-row icon). + */ + +import React, { useId } from "react"; + +interface FolderThumbnailProps { + color?: string; + fileCount?: number; + /** Visual scale - "thumb" for cards, "row" for list rows, "tree" for nav. */ + size?: "thumb" | "row" | "tree"; + /** Optional glyph (emoji) overlaid in the centre of the front pocket. */ + iconGlyph?: string; +} + +const SIZE_PX: Record, number> = { + thumb: 96, + row: 22, + tree: 18, +}; + +export function FolderThumbnail({ + color, + fileCount, + size = "thumb", + iconGlyph, +}: FolderThumbnailProps) { + const accent = color ?? "var(--accent-interactive, #6366f1)"; + const px = SIZE_PX[size]; + const showBadge = size === "thumb" && (fileCount ?? 0) > 0; + // Per-instance unique ids - `${color}` previously embedded `#` and CSS + // function syntax in the id, which broke `url(#...)` references (Safari + // would parse the inner `#` as a new fragment start and the lookup + // would miss entirely, leaving the folder shape unfilled). + const reactId = useId(); + const backId = `${reactId}-back`; + const frontId = `${reactId}-front`; + + return ( + + ); +} diff --git a/frontend/editor/src/core/components/filesPage/FolderTreePanel.css b/frontend/editor/src/core/components/filesPage/FolderTreePanel.css new file mode 100644 index 0000000000..2b75efe685 --- /dev/null +++ b/frontend/editor/src/core/components/filesPage/FolderTreePanel.css @@ -0,0 +1,198 @@ +/* Secondary navigator panel that slides out from the main FileSidebar + when the user enters the My Files workbench. + + Pattern: + - Outer panel is a flex item whose `width` transitions. The inner + content keeps a fixed natural width and the outer `overflow: hidden` + clips it during the slide so the user sees the panel grow from the + edge rather than the content shrinking. + - Inner content separately fades + nudges in for an attentive feel. + + Styled to mirror the main FileSidebar - same toolbar background, same + section-header treatment, same icon weight - so the two read as one + unified surface. */ + +.folder-tree-panel { + width: 0; + flex-shrink: 0; + background: var(--bg-toolbar); + border-right: 0 solid var(--border-subtle); + overflow: hidden; + height: 100%; + pointer-events: none; + position: relative; +} + +.folder-tree-panel[data-active="true"] { + width: var(--folder-tree-panel-width, 16rem); + border-right-width: 1px; + pointer-events: auto; +} + +.folder-tree-panel-inner { + display: flex; + flex-direction: column; + height: 100%; + width: 100%; + overflow-y: auto; + opacity: 0; + transform: translateX(-1rem); + /* Only fade/slide; width is driven by the inline custom property so + * dragging the resizer doesn't animate. */ + transition: + opacity 0.18s ease, + transform 0.26s cubic-bezier(0.22, 0.61, 0.36, 1); +} + +.folder-tree-panel[data-active="true"] .folder-tree-panel-inner { + opacity: 1; + transform: translateX(0); + transition-delay: 0.04s; +} + +/* Drag handle on the right edge. */ +.folder-tree-panel-resizer { + position: absolute; + top: 0; + right: -3px; + width: 6px; + height: 100%; + cursor: col-resize; + z-index: 2; + background: transparent; + transition: background-color 0.15s ease; +} + +.folder-tree-panel-resizer:hover, +.folder-tree-panel-resizer:focus-visible { + background: color-mix( + in srgb, + var(--accent-interactive, #6366f1) 35%, + transparent + ); + outline: none; +} + +/* Section header - matches .file-sidebar-section-header in FileSidebar.css */ +.folder-tree-panel-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 18px 6px 18px; + margin-top: 4px; + flex-shrink: 0; +} + +.folder-tree-panel-title { + font-size: 13px; + font-weight: 600; + letter-spacing: 0.02em; + color: var(--text-muted); + text-transform: uppercase; +} + +/* Tree rows - mirror .file-sidebar-action-row from FileSidebar.css so the + slide-out folder navigator reads as a continuation of the main sidebar's + design language. Same row height, padding, font weight, muted icon + treatment. The hover/active state uses the same --hover-bg pill that + the sidebar's other rows do, with no heavy accent bar. */ + +.files-page-tree-list { + display: flex; + flex-direction: column; + padding: 4px 0 12px; +} + +.files-page-tree-node { + display: flex; + align-items: center; + height: 32px; + padding: 0 14px; + border-radius: 4px; + margin: 0 8px; + cursor: pointer; + user-select: none; + color: var(--text-secondary); + font-size: 14px; + position: relative; + transition: background-color 0.15s ease; + flex-shrink: 0; +} + +.files-page-tree-node:hover { + background: var(--hover-bg); +} + +.files-page-tree-node.is-active { + background: var(--hover-bg); + color: var(--text-primary); + font-weight: 500; +} + +.files-page-tree-node.is-drop-target { + background: color-mix( + in srgb, + var(--accent-interactive, #6366f1) 12%, + transparent + ); + box-shadow: inset 0 0 0 1px var(--accent-interactive, #6366f1); + color: var(--text-primary); +} + +.files-page-tree-toggle { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + color: var(--text-muted); + flex-shrink: 0; +} + +.files-page-tree-toggle svg { + font-size: 16px !important; +} + +.files-page-tree-spacer { + display: inline-block; + width: 16px; + height: 16px; + flex-shrink: 0; +} + +.files-page-tree-icon { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + color: var(--text-muted); + margin-left: 8px; + font-size: 18px; +} + +.files-page-tree-icon svg { + font-size: 18px !important; +} + +.files-page-tree-name { + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-left: 12px; +} + +.files-page-tree-count { + color: var(--text-muted); + font-size: 12px; + flex-shrink: 0; + margin-left: 8px; +} + +@media (max-width: 900px) { + /* Cap the user width on narrow viewports so the tree can't squeeze + * out the file grid. The custom property is still honoured but capped. */ + .folder-tree-panel[data-active="true"] { + width: min(var(--folder-tree-panel-width, 14rem), 14rem); + } +} diff --git a/frontend/editor/src/core/components/filesPage/FolderTreePanel.tsx b/frontend/editor/src/core/components/filesPage/FolderTreePanel.tsx new file mode 100644 index 0000000000..eb7f19170c --- /dev/null +++ b/frontend/editor/src/core/components/filesPage/FolderTreePanel.tsx @@ -0,0 +1,169 @@ +/** Folder tree navigator panel rendered next to FileSidebar on /files. */ + +import React, { useCallback, useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { FolderTreeSidebar } from "@app/components/filesPage/FolderTreeSidebar"; +import { useFilesPage } from "@app/contexts/FilesPageContext"; +import { useFolders } from "@app/contexts/FolderContext"; +import { FileId } from "@app/types/file"; +import { FolderId, FolderRecord } from "@app/types/folder"; +import { + MIN_WIDTH, + MAX_WIDTH, + clamp, + computeAutoFitWidth, + loadPersistedWidth, + savePersistedWidth, +} from "@app/components/filesPage/folderTreeWidth"; + +import "@app/components/filesPage/FolderTreePanel.css"; + +interface FolderTreePanelProps { + active: boolean; +} + +export function FolderTreePanel({ active }: FolderTreePanelProps) { + const { t } = useTranslation(); + const { + fileCountsByFolder, + openNewFolderDialog, + openRenameFolderDialog, + promptDeleteFolder, + moveFilesTo, + } = useFilesPage(); + const folders = useFolders(); + const rootLabel = t("filesPage.allFiles", "All files"); + + const [width, setWidth] = useState(() => { + const persisted = loadPersistedWidth(); + return persisted ?? 256; + }); + const userSetRef = useRef(loadPersistedWidth() !== null); + + // Auto-fit to the longest folder name on first render and whenever the + // folder list grows; skipped once the user manually resizes. + useEffect(() => { + if (userSetRef.current) return; + const auto = computeAutoFitWidth(folders.folders, rootLabel); + setWidth(auto); + }, [folders.folders, rootLabel]); + + const dragStateRef = useRef<{ + startX: number; + startWidth: number; + } | null>(null); + + const onMouseMove = useCallback((e: MouseEvent) => { + const state = dragStateRef.current; + if (!state) return; + const next = clamp(state.startWidth + (e.clientX - state.startX)); + setWidth(next); + }, []); + + const onMouseUp = useCallback(() => { + const state = dragStateRef.current; + if (!state) return; + dragStateRef.current = null; + document.removeEventListener("mousemove", onMouseMove); + document.removeEventListener("mouseup", onMouseUp); + document.body.style.removeProperty("cursor"); + document.body.style.removeProperty("user-select"); + userSetRef.current = true; + setWidth((current) => { + savePersistedWidth(current); + return current; + }); + }, [onMouseMove]); + + const onMouseDown = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + dragStateRef.current = { startX: e.clientX, startWidth: width }; + document.addEventListener("mousemove", onMouseMove); + document.addEventListener("mouseup", onMouseUp); + document.body.style.cursor = "col-resize"; + document.body.style.userSelect = "none"; + }, + [onMouseMove, onMouseUp, width], + ); + + const onKeyDown = useCallback( + (e: React.KeyboardEvent) => { + const step = e.shiftKey ? 32 : 8; + let next: number | null = null; + if (e.key === "ArrowLeft") next = clamp(width - step); + else if (e.key === "ArrowRight") next = clamp(width + step); + else if (e.key === "Home") next = MIN_WIDTH; + else if (e.key === "End") next = MAX_WIDTH; + if (next === null) return; + e.preventDefault(); + userSetRef.current = true; + setWidth(next); + savePersistedWidth(next); + }, + [width], + ); + + return ( +
+
+
+ + {t("filesPage.myFiles", "My Files")} + +
+ + + openRenameFolderDialog(folder) + } + onDeleteFolder={promptDeleteFolder} + onMoveFilesIntoFolder={async ( + targetId: FolderId | null, + fileIds: FileId[], + ) => { + if (fileIds.length === 0) return; + await moveFilesTo(fileIds, targetId); + }} + /> +
+ {active && ( +
{ + const auto = computeAutoFitWidth(folders.folders, rootLabel); + userSetRef.current = false; + setWidth(auto); + savePersistedWidth(auto); + }} + /> + )} +
+ ); +} diff --git a/frontend/editor/src/core/components/filesPage/FolderTreeSidebar.tsx b/frontend/editor/src/core/components/filesPage/FolderTreeSidebar.tsx new file mode 100644 index 0000000000..72bd4186ec --- /dev/null +++ b/frontend/editor/src/core/components/filesPage/FolderTreeSidebar.tsx @@ -0,0 +1,490 @@ +import React, { useCallback, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { ActionIcon, Menu } from "@mantine/core"; +import KeyboardArrowRightIcon from "@mui/icons-material/KeyboardArrowRight"; +import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; +import HomeIcon from "@mui/icons-material/Home"; +import DevicesOtherIcon from "@mui/icons-material/DevicesOther"; +import CreateNewFolderIcon from "@mui/icons-material/CreateNewFolder"; +import EditIcon from "@mui/icons-material/Edit"; +import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined"; +import MoreVertIcon from "@mui/icons-material/MoreVert"; +import { FolderThumbnail } from "@app/components/filesPage/FolderThumbnail"; + +import { useFolders } from "@app/contexts/FolderContext"; +import { FileId } from "@app/types/file"; +import { + FolderId, + FolderRecord, + FolderTreeNode, + ROOT_FOLDER_ID, +} from "@app/types/folder"; +import { useFilesPage } from "@app/contexts/FilesPageContext"; +import { + FILES_PAGE_DRAG_TYPE, + parseFilesPageDragPayload, + serialiseFilesPageDragPayload, +} from "@app/components/filesPage/dragDrop"; +import { useDropTarget } from "@app/components/filesPage/useDropTarget"; + +/** + * Hard cap on folder-tree render depth. The backend already enforces an + * application-level depth limit via cycle detection + folder-count cap, + * and React's render stack handles ~50 nested components comfortably, + * so this is purely defensive against a corrupted IDB cache producing + * a chain deeper than the server would allow. + */ +const MAX_TREE_DEPTH = 50; + +interface FolderTreeSidebarProps { + fileCounts: Map; + onRequestNewFolder: (parentId: FolderId | null) => void; + onRenameFolder: (folder: FolderRecord) => void; + onDeleteFolder: (folder: FolderRecord) => void; + /** + * Move the *dragged* files (from the drop payload) into the target folder. + * Earlier signature took only the folder id and the parent then used the + * current selection - which silently moved the wrong files whenever the + * user dragged something that wasn't in the selection. + */ + onMoveFilesIntoFolder: ( + folderId: FolderId | null, + fileIds: FileId[], + ) => Promise | void; +} + +// This component is always rendered inside FolderTreePanel, which supplies +// its own