Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ BERRYBRAIN_DONATION_URL=
NEXT_PUBLIC_BERRYBRAIN_API_URL=/berrybrain
NEXT_PUBLIC_BERRYBRAIN_BASE_PATH=/berrybrain
NEXT_PUBLIC_BERRYBRAIN_ASSET_PREFIX=/berrybrain
# Optional. Leave empty on self-hosted instances to disable Google Analytics.
NEXT_PUBLIC_GOOGLE_ANALYTICS_ID=
BERRYBRAIN_AUTH_RATE_LIMIT_WINDOW_SECONDS=900
BERRYBRAIN_AUTH_RATE_LIMIT_MAX_ATTEMPTS=8
BERRYBRAIN_AUTH_LOCKOUT_MINUTES=15
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ There is no central BerryBrain account, SaaS tenant, billing gate, demo mode, or
![Source Available](https://img.shields.io/badge/source--available-yes-3C8F5A)
![License](https://img.shields.io/badge/license-non--commercial-lightgrey)

[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/T5D823778G)

---

## Table of Contents
Expand Down Expand Up @@ -622,6 +624,7 @@ Edit `.env` and set at minimum:
| `BERRYBRAIN_INTERNAL_API_URL` | Server-side API origin used by the web proxy. Defaults to `http://api:8000`; use `http://127.0.0.1:8000` when running Web outside Docker. |
| `BERRYBRAIN_ENV_FILE` | Optional Compose environment file path. Defaults to `.env`; useful for isolated smoke tests or multiple self-hosted instances. |
| `BERRYBRAIN_DONATION_URL` | Optional donation link shown/documented by the operator; no payment processing is built in. |
| `NEXT_PUBLIC_GOOGLE_ANALYTICS_ID` | Optional analytics property for public pages. Empty by default on self-hosted instances; tracking still requires visitor consent. Never send note or account data as analytics events. |
| `BERRYBRAIN_PUBLIC_APP_URL` | Public base URL of the web app (used in emails/links). |
| `BERRYBRAIN_CORS_ORIGINS` | Comma-separated allowed web origins. |
| `SMTP_*` | Optional legacy email delivery settings. Not required for default self-hosted setup. |
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Metadata, Viewport } from "next";
import { PwaRegister } from "@/components/pwa-register";
import { ThirdPartyIntegrations } from "@/components/third-party-integrations";
import berrylogo from "../../public/berrylogo.png";
import "./globals.css";

Expand Down Expand Up @@ -50,6 +51,7 @@ export default function RootLayout({
<body suppressHydrationWarning>
<PwaRegister />
{children}
<ThirdPartyIntegrations />
</body>
</html>
);
Expand Down
30 changes: 28 additions & 2 deletions apps/web/src/components/public-site/public-pages.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ const legalContent: Record<string, { title: string; body: string[] }> = {
title: "Privacy",
body: [
"BerryBrain is local-first. User notes remain in the configured vault unless the user enables external providers.",
"Google Analytics is disabled by default on self-hosted instances and requires explicit visitor consent on the official website. Analytics is configured without advertising signals or ad personalization.",
"The Ko-fi donation widget is provided by an external service. Opening or using it is subject to Ko-fi's own privacy practices.",
"When cloud AI, email, or external enrichment is configured, BerryBrain records provider, model, purpose, status, and evidence so the user can understand what left the local system.",
"Account data is separated from note content. Security events may include timestamps, IP-derived request metadata, session state, and administrative actions needed to protect the service.",
"Knowledge data is processed to build notes, concepts, graph edges, insights, and retrieval indexes. The product should never hide whether a result came from local processing or a configured external provider.",
Expand All @@ -36,6 +38,7 @@ const legalContent: Record<string, { title: string; body: string[] }> = {
title: "GDPR and LGPD",
body: [
"BerryBrain is designed around data minimization, transparency, and user-controlled processing. Notes and graph data are treated as personal knowledge data.",
"Optional website analytics remains disabled until consent is granted. Consent can be declined without losing access to BerryBrain.",
"Self-hosted operators control access, correction, export, and deletion of their local instance data. Local vault files remain under the operator's storage control.",
"Processing purposes include local authentication, instance security, note indexing, graph construction, retrieval, insight generation, and optional provider integrations configured by the local owner.",
"For LGPD and GDPR requests, include enough context to verify ownership. Do not include passwords, API keys, tokens, or private notes in email.",
Expand Down Expand Up @@ -81,13 +84,15 @@ const footerGroups = [
["Docs", "/docs"],
["Open BerryBrain", "/brain"],
["GitHub", GITHUB_URL],
["Donate", "https://ko-fi.com/T5D823778G"],
],
},
{
title: "Trust",
links: [
["Security", "legal:security"],
["Privacy", "legal:privacy"],
["Privacy choices", "consent:analytics"],
["GDPR/LGPD", "legal:gdpr-lgpd"],
],
},
Expand Down Expand Up @@ -152,6 +157,7 @@ const mobileNavLinks = [
...nav,
["Security", "legal:security"],
["Privacy", "legal:privacy"],
["Privacy choices", "consent:analytics"],
["GDPR/LGPD", "legal:gdpr-lgpd"],
["Terms", "legal:terms"],
["Contact", "legal:contact"],
Expand Down Expand Up @@ -307,7 +313,17 @@ export function PublicShell({
<ul className="space-y-1">
{mobileNavLinks.map(([label, href]) => (
<li key={href}>
{href.startsWith("legal:") ? (
{href === "consent:analytics" ? (
<button
onClick={() => {
window.localStorage.removeItem("bb_analytics_consent");
window.dispatchEvent(new Event("bb:analytics-consent"));
}}
className="text-sm text-muted hover:text-foreground"
>
{label}
</button>
) : href.startsWith("legal:") ? (
<button
onClick={() => { openModal(href.slice(6)); setMobileMenuOpen(false); }}
className="block w-full rounded-md px-3 py-2 text-left text-sm text-muted hover:bg-surface hover:text-foreground"
Expand Down Expand Up @@ -1081,7 +1097,17 @@ function Footer({ onOpenModal }: { onOpenModal: (key: string) => void }) {
<ul className="mt-3 space-y-2">
{group.links.map(([label, href]) => (
<li key={href}>
{href.startsWith("legal:") ? (
{href === "consent:analytics" ? (
<button
onClick={() => {
window.localStorage.removeItem("bb_analytics_consent");
window.dispatchEvent(new Event("bb:analytics-consent"));
}}
className="text-sm text-muted hover:text-foreground"
>
{label}
</button>
) : href.startsWith("legal:") ? (
<button onClick={() => onOpenModal(href.slice(6))} className="text-sm text-muted hover:text-foreground">
{label}
</button>
Expand Down
162 changes: 162 additions & 0 deletions apps/web/src/components/third-party-integrations.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
"use client";

import Script from "next/script";
import { usePathname } from "next/navigation";
import { useCallback, useEffect, useRef, useState } from "react";

const OFFICIAL_GOOGLE_ANALYTICS_ID = "G-36YL9QLC5K";
const ANALYTICS_CONSENT_KEY = "bb_analytics_consent";

declare global {
interface Window {
dataLayer?: unknown[];
gtag?: (...args: unknown[]) => void;
kofiWidgetOverlay?: {
draw: (account: string, options: Record<string, string>) => void;
};
}
}

function analyticsIdForCurrentDeployment() {
const configuredId = process.env.NEXT_PUBLIC_GOOGLE_ANALYTICS_ID?.trim();
if (configuredId && /^G-[A-Z0-9]{6,20}$/i.test(configuredId)) return configuredId;
if (typeof window === "undefined") return "";
const hostname = window.location.hostname.toLowerCase();
return hostname === "optlabs.com.br" || hostname === "www.optlabs.com.br"
? OFFICIAL_GOOGLE_ANALYTICS_ID
: "";
}

function GoogleAnalytics({ enabled }: { enabled: boolean }) {
const [analyticsId, setAnalyticsId] = useState("");
const [consent, setConsent] = useState<"granted" | "denied" | null>(null);
const configured = useRef(false);

useEffect(() => {
setAnalyticsId(enabled ? analyticsIdForCurrentDeployment() : "");
const readConsent = () => {
const saved = window.localStorage.getItem(ANALYTICS_CONSENT_KEY);
setConsent(saved === "granted" || saved === "denied" ? saved : null);
};
readConsent();
window.addEventListener("bb:analytics-consent", readConsent);
return () => window.removeEventListener("bb:analytics-consent", readConsent);
}, [enabled]);

const choose = (value: "granted" | "denied") => {
const wasGranted = consent === "granted";
window.localStorage.setItem(ANALYTICS_CONSENT_KEY, value);
setConsent(value);
if (wasGranted && value === "denied") window.location.reload();
};

const configure = useCallback(() => {
if (!analyticsId || configured.current) return;
window.dataLayer = window.dataLayer || [];
window.gtag = (...args: unknown[]) => window.dataLayer?.push(args);
window.gtag("js", new Date());
window.gtag("config", analyticsId, {
anonymize_ip: true,
allow_google_signals: false,
allow_ad_personalization_signals: false,
});
configured.current = true;
}, [analyticsId]);

if (!analyticsId) return null;

return (
<>
{consent === "granted" && (
<>
<Script
id="berrybrain-google-analytics"
src={`https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(analyticsId)}`}
strategy="afterInteractive"
onLoad={configure}
onReady={configure}
/>
</>
)}
{consent === null && (
<aside
className="fixed bottom-4 left-4 z-[120] max-w-sm border border-black bg-panel p-4 text-foreground shadow-[4px_4px_0_var(--color-brand-red)]"
aria-label="Analytics consent"
>
<p className="text-sm font-semibold">Privacy choices</p>
<p className="mt-2 text-xs leading-5 text-muted">
BerryBrain uses optional Google Analytics on the official website to understand
aggregate usage. It stays disabled until you allow it.
</p>
<div className="mt-3 flex flex-wrap gap-2">
<button className="bb-action px-3 py-1.5 text-xs font-medium" onClick={() => choose("granted")}>
Allow analytics
</button>
<button className="bb-action px-3 py-1.5 text-xs font-medium" onClick={() => choose("denied")}>
Decline
</button>
</div>
</aside>
)}
</>
);
}

function KofiDonateWidget() {
const drawn = useRef(false);
const drawWidget = useCallback(() => {
if (drawn.current || !window.kofiWidgetOverlay) return;
window.kofiWidgetOverlay.draw("berrybrain", {
type: "floating-chat",
"floating-chat.donateButton.text": "Donate",
"floating-chat.donateButton.background-color": "#d9534f",
"floating-chat.donateButton.text-color": "#fff",
});
drawn.current = true;
}, []);

return (
<Script
id="berrybrain-kofi-widget"
src="https://storage.ko-fi.com/cdn/scripts/overlay-widget.js"
strategy="lazyOnload"
onLoad={drawWidget}
onReady={drawWidget}
/>
);
}

export function ThirdPartyIntegrations() {
const pathname = usePathname();
const privateRouteSegments = new Set([
"brain",
"activity",
"insights",
"notifications",
"reviews",
"account",
]);
const isKnowledgeWorkspace = pathname
.split("/")
.filter(Boolean)
.some((segment) => privateRouteSegments.has(segment));

return (
<>
<GoogleAnalytics enabled={!isKnowledgeWorkspace} />
{isKnowledgeWorkspace ? (
<a
href="https://ko-fi.com/T5D823778G"
target="_blank"
rel="noreferrer"
className="bb-action fixed bottom-4 right-4 z-[70] px-3 py-2 text-xs font-semibold"
aria-label="Donate to BerryBrain on Ko-fi"
>
Donate
</a>
) : (
<KofiDonateWidget />
)}
</>
);
}
Loading