diff --git a/.env.example b/.env.example index ed69ec1..945ffce 100644 --- a/.env.example +++ b/.env.example @@ -1 +1,12 @@ VITE_PLIMI_REPO_URL=https://github.com/your-org/plimi +VITE_SITE_URL=https://plimi.example.com +VITE_LEGAL_NAME=Achraf ACHKARI-BEGDOURI +VITE_LEGAL_COUNTRY=France +VITE_PRIVACY_EMAIL=privacy@example.com +VITE_PRIVACY_EFFECTIVE_DATE=2026-06-07 +VITE_ANALYTICS_PROVIDER_NAME=Umami +VITE_ANALYTICS_SCRIPT_URL=https://u.achraf.app/script.js +VITE_ANALYTICS_WEBSITE_ID=89d0f8d2-5b59-438c-b1f6-ab2655e97f6e +VITE_ANALYTICS_HOST=u.achraf.app +VITE_ANALYTICS_HOST_COUNTRY=France +VITE_ANALYTICS_RETENTION_MONTHS=13 diff --git a/.gitignore b/.gitignore index ecb13c8..ab25981 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,9 @@ node_modules dist dist-ssr *.local +.env +.env.* +!.env.example # Editor directories and files .vscode/* @@ -23,4 +26,4 @@ dist-ssr *.sln *.sw? -ignored \ No newline at end of file +ignored diff --git a/Dockerfile b/Dockerfile index 4689fd3..67ba335 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,11 +9,9 @@ RUN pnpm install --frozen-lockfile FROM node:22-alpine AS build WORKDIR /app RUN corepack enable && corepack prepare pnpm@10.33.2 --activate -ARG VITE_PLIMI_REPO_URL=https://github.com/your-org/plimi -ENV VITE_PLIMI_REPO_URL=$VITE_PLIMI_REPO_URL COPY --from=deps /app/node_modules ./node_modules COPY . . -RUN pnpm build +RUN --mount=type=secret,id=vite_env,target=/app/.env,required=true pnpm build FROM nginx:1.27-alpine AS runtime COPY nginx.conf /etc/nginx/conf.d/default.conf diff --git a/README.md b/README.md index 86b4ad6..938102d 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ # Plimi: Privacy-First Browser Toolbox -Plimi is a modern, modular, zero-backend utility suite that executes all tools directly inside your browser. No data ever leaves your device. +Plimi is a modern, modular utility suite that executes tool processing directly inside your browser. Tool inputs and outputs are not uploaded by Plimi. ## Features -- **Privacy-First Design**: Completely offline-capable. No data is sent to external servers. +- **Privacy-First Design**: Tool processing is offline-capable. Optional audience analytics load only after consent. - **Plugin Architecture**: Modular, strictly-typed plugin ecosystem supporting custom UI or generated UI based on schema. - **Web Workers & WASM**: Supports off-loading expensive operations (e.g. PDF manipulation, media processing) into separate threads. - **High Performance**: Built with React, Vite, and Tailwind CSS v4. @@ -36,9 +36,9 @@ npm run build Build and run the static production app with Nginx: ```bash -docker build \ - --build-arg VITE_PLIMI_REPO_URL=https://github.com/your-org/plimi \ - -t plimi:local . +cp .env.example .env +# Edit .env with the real deployment and legal values. +docker build --secret id=vite_env,src=.env -t plimi:local . docker run --rm -p 8080:80 --name plimi plimi:local ``` @@ -46,13 +46,33 @@ docker run --rm -p 8080:80 --name plimi plimi:local Or use Docker Compose: ```bash -VITE_PLIMI_REPO_URL=https://github.com/your-org/plimi PLIMI_PORT=8080 docker compose up --build +cp .env.example .env +# Edit .env, then: +docker compose up --build ``` Environment: - `VITE_PLIMI_REPO_URL`: build-time public repository URL shown on the Contribute page. +- `VITE_SITE_URL`: canonical public URL used to restrict analytics to the deployed hostname. +- `VITE_LEGAL_NAME`, `VITE_LEGAL_COUNTRY`: controller identity shown in the privacy notice. +- `VITE_PRIVACY_EMAIL`: contact address for privacy and data-subject requests. +- `VITE_PRIVACY_EFFECTIVE_DATE`: effective date displayed on the privacy notice. +- `VITE_ANALYTICS_SCRIPT_URL`, `VITE_ANALYTICS_WEBSITE_ID`: Umami tracker configuration. +- `VITE_ANALYTICS_HOST`, `VITE_ANALYTICS_HOST_COUNTRY`: analytics recipient and hosting location disclosed to visitors. +- `VITE_ANALYTICS_RETENTION_MONTHS`: disclosed analytics retention period. Configure Umami/database deletion to match it. - `PLIMI_PORT`: host port used by Docker Compose, default `8080`. +Analytics is consent-first. The tracker is injected only after acceptance and is +configured to exclude URL search parameters and hashes and honor Do Not Track. +The privacy page is available at `/privacy`; deployers must replace all example +controller and hosting values before publishing. + +Docker mounts `.env` as a BuildKit secret only while Vite builds the static +bundle. The file is ignored by Git and excluded from the regular Docker context. +Remember that `VITE_*` values are public build-time configuration and can be +read from the resulting browser JavaScript; do not put passwords or API secrets +in them. + --- ## How to Create a Tool (Plugin) diff --git a/docker-compose.yml b/docker-compose.yml index 913c26d..85b47e2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,10 +2,14 @@ services: plimi: build: context: . - args: - VITE_PLIMI_REPO_URL: ${VITE_PLIMI_REPO_URL:-https://github.com/your-org/plimi} + secrets: + - vite_env image: plimi:local container_name: plimi ports: - "${PLIMI_PORT:-8080}:80" restart: unless-stopped + +secrets: + vite_env: + file: ./.env diff --git a/index.html b/index.html index 9492f06..711b815 100644 --- a/index.html +++ b/index.html @@ -4,10 +4,6 @@ - - - - Plimi diff --git a/src/app/router.tsx b/src/app/router.tsx index dd3d495..47d511a 100644 --- a/src/app/router.tsx +++ b/src/app/router.tsx @@ -5,6 +5,7 @@ import { ToolsPage } from "../pages/ToolsPage"; import { ToolDetailPage } from "../pages/ToolDetailPage"; import { HowItWorksPage } from "../pages/HowItWorksPage"; import { ContributePage } from "../pages/ContributePage"; +import { PrivacyPage } from "../pages/PrivacyPage"; export const router = createBrowserRouter([ { @@ -31,6 +32,10 @@ export const router = createBrowserRouter([ path: "contribute", element: , }, + { + path: "privacy", + element: , + }, ], }, ]); diff --git a/src/components/layout/AppShell.tsx b/src/components/layout/AppShell.tsx index 25c4a38..c68730a 100644 --- a/src/components/layout/AppShell.tsx +++ b/src/components/layout/AppShell.tsx @@ -1,13 +1,18 @@ import { Outlet } from "react-router-dom"; import { Header } from "./Header"; +import { Footer } from "./Footer"; +import { AnalyticsConsentProvider } from "../../core/privacy/AnalyticsConsent"; export function AppShell() { return ( -
-
-
- -
-
+ +
+
+
+ +
+
+
+
); } diff --git a/src/components/layout/Footer.tsx b/src/components/layout/Footer.tsx new file mode 100644 index 0000000..6b0135a --- /dev/null +++ b/src/components/layout/Footer.tsx @@ -0,0 +1,30 @@ +import { Link } from "react-router-dom"; +import { useAnalyticsConsent } from "../../core/privacy/analytics-consent-context"; + +export function Footer() { + const { choice, openSettings } = useAnalyticsConsent(); + + return ( +
+
+ + Plimi processes tool inputs locally in your browser. + + +
+
+ ); +} diff --git a/src/core/privacy/AnalyticsConsent.tsx b/src/core/privacy/AnalyticsConsent.tsx new file mode 100644 index 0000000..a3d7a0e --- /dev/null +++ b/src/core/privacy/AnalyticsConsent.tsx @@ -0,0 +1,187 @@ +import { + useCallback, + useEffect, + useMemo, + useState, + type ReactNode, +} from "react"; +import { Link } from "react-router-dom"; +import { + hasAnalyticsConfiguration, + privacyConfig, +} from "./privacy-config"; +import { + AnalyticsConsentContext, + type AnalyticsConsentValue, + type ConsentChoice, +} from "./analytics-consent-context"; + +const CONSENT_KEY = "plimi.analytics-consent.v1"; +const SCRIPT_ID = "plimi-umami-script"; + +function storedChoice(): ConsentChoice { + const value = window.localStorage.getItem(CONSENT_KEY); + return value === "granted" || value === "denied" ? value : "pending"; +} + +function removeAnalyticsScript() { + document.getElementById(SCRIPT_ID)?.remove(); +} + +function loadAnalyticsScript() { + if (!hasAnalyticsConfiguration || document.getElementById(SCRIPT_ID)) return; + + const script = document.createElement("script"); + script.id = SCRIPT_ID; + script.defer = true; + script.src = privacyConfig.analyticsScriptUrl; + script.dataset.websiteId = privacyConfig.analyticsWebsiteId; + script.dataset.domains = new URL(privacyConfig.siteUrl).hostname; + script.dataset.doNotTrack = "true"; + script.dataset.excludeSearch = "true"; + script.dataset.excludeHash = "true"; + document.head.appendChild(script); +} + +function ConsentPanel({ + settings, + onAccept, + onReject, + onClose, +}: { + settings: boolean; + onAccept: () => void; + onReject: () => void; + onClose?: () => void; +}) { + return ( +
+
+
+
+ privacy choice +
+

+ Optional, cookieless analytics +

+
+ {settings && onClose && ( + + )} +
+ +

+ With your permission, Plimi loads {privacyConfig.analyticsProvider} from{" "} + {privacyConfig.analyticsHost} to measure page visits and device/browser + categories. Tool inputs, files, and outputs are never included. +

+ +
+ + Read the privacy notice + + + +
+
+ ); +} + +export function AnalyticsConsentProvider({ + children, +}: { + children: ReactNode; +}) { + const [choice, setChoice] = useState(storedChoice); + const [isSettingsOpen, setSettingsOpen] = useState(false); + + useEffect(() => { + if (choice === "granted") { + loadAnalyticsScript(); + } else { + removeAnalyticsScript(); + } + }, [choice]); + + const saveChoice = useCallback( + (nextChoice: Exclude) => { + const wasGranted = choice === "granted"; + window.localStorage.setItem(CONSENT_KEY, nextChoice); + setChoice(nextChoice); + setSettingsOpen(false); + + if (wasGranted && nextChoice === "denied") { + window.location.reload(); + } + }, + [choice] + ); + + const value = useMemo( + () => ({ + choice, + isSettingsOpen, + accept: () => saveChoice("granted"), + reject: () => saveChoice("denied"), + openSettings: () => setSettingsOpen(true), + closeSettings: () => setSettingsOpen(false), + }), + [choice, isSettingsOpen, saveChoice] + ); + + return ( + + {children} + {choice === "pending" && ( + + )} + {isSettingsOpen && ( + <> + + + Current choice: {choice === "granted" ? "accepted" : choice === "denied" ? "rejected" : "not chosen"} + + + + +
+

+ Plimi stores your theme preference and analytics choice in browser + local storage. These values are necessary to remember the settings + you selected. Plimi does not use advertising cookies. +

+
+ +
+

+ When analytics are accepted, analytics metadata is received by the + operator of {privacyConfig.analyticsHost} and any infrastructure + providers used to host it. The controller must maintain appropriate + processor agreements and transfer safeguards where a provider + processes data outside the EEA. +

+
+ +
+

+ Depending on applicable law, you may request access, correction, + deletion, restriction, or portability of your personal data. You may + withdraw analytics consent at any time through Analytics settings; + withdrawal does not affect prior lawful processing. +

+

+ Contact {privacyConfig.privacyEmail} to exercise a right. You may also + lodge a complaint with the data-protection authority in your country + of residence, work, or the place of an alleged infringement. +

+
+ +
+

+ Plimi uses HTTPS and access controls appropriate to the services it + operates. No internet service can guarantee absolute security. This + notice may be updated when processing or providers change; the + effective date above identifies the current version. +

+
+ + ); +} diff --git a/src/pages/ToolsPage.tsx b/src/pages/ToolsPage.tsx index f7de2a9..6138cde 100644 --- a/src/pages/ToolsPage.tsx +++ b/src/pages/ToolsPage.tsx @@ -79,7 +79,7 @@ export function ToolsPage() { Big trust.

- {pluginRegistry.length} utilities for files, text and code — running entirely in your browser. No upload. No account. No server. + {pluginRegistry.length} utilities for files, text and code — running entirely in your browser. No tool-data upload. No account.