mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 03:20:46 +02:00
Policies: enforce on upload or export (#6614)
Follow-up to #6604 (merged). Builds the Security policy out so it actually enforces, driven from the editor. ## What it does - **Run on upload or export** — a single choice in the wizard: enforce when a file is uploaded, or just before it's exported. - **Output** — enforced result is a **new version** of the file (default) or a **new file**, with optional filename prefix/suffix/auto-number ("Output filename" subsection; auto-number only for new files). - **Export enforcement** — exporting an export-mode file runs the policy first and downloads the enforced result; never hard-blocks (on failure the original downloads). For new-version policies the in-editor file is versioned too. Covers every export path incl. multi-file ZIP. A toast (glowing in the policy's accent while it runs) reports progress and fades after ~10s. - **Affordances** — a freshly enforced file briefly glows its policy accent and carries a shield badge. - **Config tidy-up** — removed the unwired Security setting fields + the wizard's review step; "Upgrade to enterprise" on locked categories; category accent in the detail/wizard headers. ## Notes - Builds on the manual-only (client-driven) policy model from #6587 (`trigger: null`, metadata in `output.options`), adding the `runOn` field + export-time enforcement. - The page-editor merge-export (no single source file) enforces + downloads but doesn't version in place. ## Verification typecheck (core + proprietary), eslint, prettier; proprietary suite (105) green; flows checked in-app.
This commit is contained in:
@@ -17,7 +17,7 @@ import AddFileCard from "@app/components/fileEditor/AddFileCard";
|
||||
import FilePickerModal from "@app/components/shared/FilePickerModal";
|
||||
import { FileId, StirlingFile } from "@app/types/fileContext";
|
||||
import { alert } from "@app/components/toast";
|
||||
import { downloadFile } from "@app/services/downloadService";
|
||||
import { downloadFileWithPolicy as downloadFile } from "@app/services/exportWithPolicy";
|
||||
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
||||
|
||||
interface FileEditorProps {
|
||||
@@ -256,6 +256,7 @@ const FileEditor = ({
|
||||
data: file,
|
||||
filename: file.name,
|
||||
localPath: record.localFilePath,
|
||||
fileId,
|
||||
});
|
||||
console.log("[FileEditor] Download complete, checking dirty state:", {
|
||||
localFilePath: record.localFilePath,
|
||||
|
||||
@@ -36,7 +36,7 @@ import ToolChain from "@app/components/shared/ToolChain";
|
||||
import HoverActionMenu, {
|
||||
HoverAction,
|
||||
} from "@app/components/shared/HoverActionMenu";
|
||||
import { downloadFile } from "@app/services/downloadService";
|
||||
import { downloadFileWithPolicy as downloadFile } from "@app/services/exportWithPolicy";
|
||||
import { PrivateContent } from "@app/components/shared/PrivateContent";
|
||||
import UploadToServerModal from "@app/components/shared/UploadToServerModal";
|
||||
import ShareFileModal from "@app/components/shared/ShareFileModal";
|
||||
@@ -248,6 +248,7 @@ const FileEditorThumbnail = ({
|
||||
data: fileToSave,
|
||||
filename: file.name,
|
||||
localPath: file.localFilePath,
|
||||
fileId: file.id,
|
||||
});
|
||||
if (!result.cancelled && result.savedPath) {
|
||||
fileActions.updateStirlingFileStub(file.id, {
|
||||
|
||||
@@ -23,7 +23,7 @@ import { FileId } from "@app/types/file";
|
||||
import { PrivateContent } from "@app/components/shared/PrivateContent";
|
||||
import { useFileActionTerminology } from "@app/hooks/useFileActionTerminology";
|
||||
import { useFileActionIcons } from "@app/hooks/useFileActionIcons";
|
||||
import { downloadFile } from "@app/services/downloadService";
|
||||
import { downloadFileWithPolicy as downloadFile } from "@app/services/exportWithPolicy";
|
||||
|
||||
interface FileItem {
|
||||
id: FileId;
|
||||
@@ -98,6 +98,7 @@ const FileThumbnail = ({
|
||||
void downloadFile({
|
||||
data: maybeFile,
|
||||
filename: maybeFile.name || file.name || "download",
|
||||
fileId: file.id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -36,6 +36,32 @@
|
||||
background-color: rgba(59, 130, 246, 0.06);
|
||||
}
|
||||
|
||||
/* A policy-enforced file glows in that policy's accent colour so it's obvious
|
||||
the file has had a policy applied: it pulses a few times to catch the eye and
|
||||
then fades out (no lingering glow). */
|
||||
.file-sidebar-file-item.policy-enforced {
|
||||
animation: policy-glow-fade 4.5s ease-in-out forwards;
|
||||
}
|
||||
@keyframes policy-glow-fade {
|
||||
0%,
|
||||
24%,
|
||||
48% {
|
||||
box-shadow:
|
||||
inset 0 0 0 1px color-mix(in srgb, var(--policy-glow) 30%, transparent),
|
||||
0 0 6px -2px var(--policy-glow);
|
||||
}
|
||||
12%,
|
||||
36%,
|
||||
60% {
|
||||
box-shadow:
|
||||
inset 0 0 0 1px color-mix(in srgb, var(--policy-glow) 75%, transparent),
|
||||
0 0 16px 0 var(--policy-glow);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.file-sidebar-file-item.selected {
|
||||
background-color: rgba(59, 130, 246, 0.12);
|
||||
}
|
||||
|
||||
@@ -132,6 +132,9 @@ export interface FileItemPolicyRef {
|
||||
name: string;
|
||||
/** CSS colour for the badge (matches the policy's accent). */
|
||||
accentColor: string;
|
||||
/** True only just after the policy was applied — drives the one-off glow, so
|
||||
* it doesn't replay on every reload of an already-enforced file. */
|
||||
recent: boolean;
|
||||
}
|
||||
|
||||
export interface FileItemProps {
|
||||
@@ -201,6 +204,9 @@ export function FileItem({
|
||||
|
||||
const handleMouseLeave = useCallback(() => setHoverRect(null), []);
|
||||
|
||||
// A just-applied policy (recent run) drives the one-off row glow.
|
||||
const recentPolicy = policies.find((p) => p.recent);
|
||||
|
||||
// Reactive: tooltip appears as soon as both hover rect and thumbnail are ready
|
||||
const thumbPos =
|
||||
hoverRect && resolvedThumbnail
|
||||
@@ -214,7 +220,14 @@ export function FileItem({
|
||||
<>
|
||||
<div
|
||||
ref={itemRef}
|
||||
className={`file-sidebar-file-item${isSelected ? " selected" : ""}${isActive ? " active" : ""}${isViewedInViewer ? " viewed" : ""}`}
|
||||
className={`file-sidebar-file-item${isSelected ? " selected" : ""}${isActive ? " active" : ""}${isViewedInViewer ? " viewed" : ""}${recentPolicy ? " policy-enforced" : ""}`}
|
||||
style={
|
||||
recentPolicy
|
||||
? ({
|
||||
"--policy-glow": recentPolicy.accentColor,
|
||||
} as React.CSSProperties)
|
||||
: undefined
|
||||
}
|
||||
onClick={() => onClick(fileId)}
|
||||
draggable={draggable}
|
||||
onDragStart={
|
||||
|
||||
@@ -29,7 +29,7 @@ import { ViewerContext, useViewer } from "@app/contexts/ViewerContext";
|
||||
import { WorkbenchType, isBaseWorkbench } from "@app/types/workbench";
|
||||
import { Tooltip } from "@app/components/shared/Tooltip";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import { downloadFile } from "@app/services/downloadService";
|
||||
import { downloadFileWithPolicy as downloadFile } from "@app/services/exportWithPolicy";
|
||||
import {
|
||||
WorkbenchBarButtonConfig,
|
||||
WorkbenchBarRenderContext,
|
||||
@@ -150,6 +150,7 @@ export default function WorkbenchBar({
|
||||
data: new Blob([buffer], { type: "application/pdf" }),
|
||||
filename: fileToExport.name,
|
||||
localPath: forceNewFile ? undefined : stub?.localFilePath,
|
||||
fileId: stub?.id,
|
||||
});
|
||||
if (!forceNewFile && !result.cancelled && stub && result.savedPath) {
|
||||
fileActions.updateStirlingFileStub(stub.id, {
|
||||
@@ -179,6 +180,7 @@ export default function WorkbenchBar({
|
||||
data: file,
|
||||
filename: file.name,
|
||||
localPath: forceNewFile ? undefined : stub?.localFilePath,
|
||||
fileId: stub?.id,
|
||||
});
|
||||
if (result.cancelled) continue;
|
||||
if (!forceNewFile && stub && result.savedPath) {
|
||||
|
||||
@@ -51,6 +51,27 @@
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* A toast tied to a policy pulses a glow in that policy's accent (var set
|
||||
inline as --toast-glow), so it's obvious which policy is being applied. */
|
||||
.toast-item--glow {
|
||||
animation: toast-glow-pulse 1.8s ease-in-out infinite;
|
||||
}
|
||||
@keyframes toast-glow-pulse {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow:
|
||||
var(--shadow-lg),
|
||||
0 0 0 1px color-mix(in srgb, var(--toast-glow) 35%, transparent),
|
||||
0 0 10px -2px var(--toast-glow);
|
||||
}
|
||||
50% {
|
||||
box-shadow:
|
||||
var(--shadow-lg),
|
||||
0 0 0 1px color-mix(in srgb, var(--toast-glow) 80%, transparent),
|
||||
0 0 20px 0 var(--toast-glow);
|
||||
}
|
||||
}
|
||||
|
||||
/* Toast Alert Type Colors */
|
||||
.toast-item--success {
|
||||
background: var(--color-green-100);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { CSSProperties } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useToast } from "@app/components/toast/ToastContext";
|
||||
import { ToastInstance, ToastLocation } from "@app/components/toast/types";
|
||||
@@ -64,7 +65,16 @@ export default function ToastRenderer() {
|
||||
<div key={loc} className={`toast-container ${locationToClass[loc]}`}>
|
||||
{grouped[loc].map((t) => {
|
||||
return (
|
||||
<div key={t.id} role="status" className={getToastItemClass(t)}>
|
||||
<div
|
||||
key={t.id}
|
||||
role="status"
|
||||
className={`${getToastItemClass(t)}${t.glowColor ? " toast-item--glow" : ""}`}
|
||||
style={
|
||||
t.glowColor
|
||||
? ({ "--toast-glow": t.glowColor } as CSSProperties)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{/* Top row: Icon + Title + Controls */}
|
||||
<div className="toast-header">
|
||||
{/* Icon */}
|
||||
|
||||
@@ -25,6 +25,10 @@ export interface ToastOptions {
|
||||
id?: string;
|
||||
/** If true, show chevron and collapse/expand animation. Defaults to true. */
|
||||
expandable?: boolean;
|
||||
/** Optional accent colour (any CSS colour, e.g. a `var(--color-…)`). When set,
|
||||
* the toast pulses a glow in this colour — used to tie a toast to a policy's
|
||||
* accent. */
|
||||
glowColor?: string;
|
||||
}
|
||||
|
||||
export interface ToastInstance extends Omit<
|
||||
|
||||
@@ -2,6 +2,9 @@ export interface DownloadRequest {
|
||||
data: Blob | File;
|
||||
filename: string;
|
||||
localPath?: string;
|
||||
/** Workspace fileId of the file being exported, when known. Lets export-time
|
||||
* policy enforcement version the in-editor file (not just the download). */
|
||||
fileId?: string;
|
||||
}
|
||||
|
||||
export interface DownloadResult {
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Export entry points call {@link downloadFileWithPolicy} instead of
|
||||
* {@link downloadFile} so any "export"-triggered policy enforces on the file
|
||||
* before it's downloaded. The enforcement itself is proprietary (a no-op in the
|
||||
* core build via the `@app/services/policyExport` stub), and never hard-blocks:
|
||||
* on failure the original file is downloaded.
|
||||
*/
|
||||
|
||||
import {
|
||||
downloadFile,
|
||||
type DownloadRequest,
|
||||
type DownloadResult,
|
||||
} from "@app/services/downloadService";
|
||||
import { enforceExportPolicies } from "@app/services/policyExport";
|
||||
|
||||
export async function downloadFileWithPolicy(
|
||||
request: DownloadRequest,
|
||||
): Promise<DownloadResult> {
|
||||
// enforceExportPolicies only touches PDFs and is a no-op without an active
|
||||
// export policy, so non-PDF / non-policy downloads pass straight through.
|
||||
const input =
|
||||
request.data instanceof File
|
||||
? request.data
|
||||
: new File([request.data], request.filename, {
|
||||
type: request.data.type,
|
||||
});
|
||||
const [enforced] = await enforceExportPolicies([input], [request.fileId]);
|
||||
return downloadFile({ ...request, data: enforced ?? request.data });
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
setPageRotation,
|
||||
addNewPage,
|
||||
} from "@app/services/pdfiumService";
|
||||
import { downloadFile } from "@app/services/downloadService";
|
||||
import { downloadFileWithPolicy } from "@app/services/exportWithPolicy";
|
||||
import { PDFDocument, PDFPage } from "@app/types/pageEditor";
|
||||
|
||||
// A4 dimensions in PDF points (72 dpi)
|
||||
@@ -259,10 +259,10 @@ export class PDFExportService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a single file
|
||||
* Download a single file, applying any export-triggered policy first.
|
||||
*/
|
||||
downloadFile(blob: Blob, filename: string): void {
|
||||
void downloadFile({ data: blob, filename });
|
||||
void downloadFileWithPolicy({ data: blob, filename });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Open-source stub for export-time policy enforcement. Policies are a proprietary
|
||||
* feature, so in the core build there's nothing to enforce — this returns the
|
||||
* files unchanged. The proprietary build shadows this module via the `@app/*`
|
||||
* alias with the real implementation.
|
||||
*/
|
||||
export async function enforceExportPolicies(
|
||||
files: File[],
|
||||
_fileIds?: (string | undefined)[],
|
||||
): Promise<File[]> {
|
||||
return files;
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import { StirlingFileStub } from "@app/types/fileContext";
|
||||
import { fileStorage } from "@app/services/fileStorage";
|
||||
import { zipFileService } from "@app/services/zipFileService";
|
||||
import { downloadFile } from "@app/services/downloadService";
|
||||
import { downloadFileWithPolicy } from "@app/services/exportWithPolicy";
|
||||
import { enforceExportPolicies } from "@app/services/policyExport";
|
||||
|
||||
/**
|
||||
* Downloads a blob as a file using browser download API
|
||||
@@ -27,10 +29,11 @@ export async function downloadFileFromStorage(
|
||||
throw new Error(`File "${file.name}" not found in storage`);
|
||||
}
|
||||
|
||||
await downloadFile({
|
||||
await downloadFileWithPolicy({
|
||||
data: stirlingFile,
|
||||
filename: stirlingFile.name,
|
||||
localPath: file.localFilePath,
|
||||
fileId: file.id,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -59,15 +62,15 @@ export async function downloadFilesAsZip(
|
||||
throw new Error("No files provided for ZIP download");
|
||||
}
|
||||
|
||||
// Convert stored files to File objects
|
||||
// Convert stored files to File objects (tracking ids so export policies can
|
||||
// version the in-editor file).
|
||||
const filesToZip: File[] = [];
|
||||
const fileIds: (string | undefined)[] = [];
|
||||
for (const fileWithUrl of files) {
|
||||
const lookupKey = fileWithUrl.id;
|
||||
const stirlingFile = await fileStorage.getStirlingFile(lookupKey);
|
||||
|
||||
const stirlingFile = await fileStorage.getStirlingFile(fileWithUrl.id);
|
||||
if (stirlingFile) {
|
||||
// StirlingFile is already a File object!
|
||||
filesToZip.push(stirlingFile);
|
||||
fileIds.push(fileWithUrl.id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,6 +78,9 @@ export async function downloadFilesAsZip(
|
||||
throw new Error("No valid files found in storage for ZIP download");
|
||||
}
|
||||
|
||||
// Enforce any export-triggered policy on each PDF before they're zipped.
|
||||
const enforced = await enforceExportPolicies(filesToZip, fileIds);
|
||||
|
||||
// Generate default filename if not provided
|
||||
const finalZipFilename =
|
||||
zipFilename ||
|
||||
@@ -82,7 +88,7 @@ export async function downloadFilesAsZip(
|
||||
|
||||
// Create and download ZIP
|
||||
const { zipFile } = await zipFileService.createZipFromFiles(
|
||||
filesToZip,
|
||||
enforced,
|
||||
finalZipFilename,
|
||||
);
|
||||
await downloadFile({ data: zipFile, filename: finalZipFilename });
|
||||
@@ -125,7 +131,7 @@ export async function downloadFiles(
|
||||
* @param filename - Optional custom filename
|
||||
*/
|
||||
export function downloadFileObject(file: File, filename?: string): void {
|
||||
void downloadFile({ data: file, filename: filename || file.name });
|
||||
void downloadFileWithPolicy({ data: file, filename: filename || file.name });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user