diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md new file mode 100644 index 000000000..723c8fddb --- /dev/null +++ b/.claude/CLAUDE.md @@ -0,0 +1,23 @@ +# Share Project Guidelines + +## Git Workflow +- Always create feature branches before major work: `feature/` +- Keep main branch clean; merge via pull requests +- Commit messages: concise, imperative ("Add feature", "Fix bug") + +## Architecture +- **State management**: Zustand with slice pattern (`src/store/*Slice.js`) +- **Components**: React functional components, MUI for UI +- **Auth**: Auth0 via `src/Auth0/Auth0Proxy.js`, `useAuth0()` hook +- **Privacy/cookies**: `js-cookie` + `src/privacy/Expires.js` (365-day expiry) +- **Navigation**: `navigateToModel()` does full page reload; `navWith()` for SPA nav +- **Dialogs**: Extend `src/Components/Dialog.jsx` base component + +## Testing +- `yarn test` for unit tests (Jest + React Testing Library) +- `yarn build` for production build verification + +## Key Patterns +- Store slices: export default function `create*Slice(set, get)` returning state + setters +- New UI dialogs: visibility controlled via Zustand store state +- Rate limiting: client-side via `src/privacy/usageTracking.js` (localStorage + cookie backup) diff --git a/.gitignore b/.gitignore index f3acaccf6..503aad473 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,10 @@ test-results # symlink for test-models repo public/test-models +# local environment +.env +.env.local + # misc .DS_Store .vscode diff --git a/package.json b/package.json index 7c75ec254..391d1c25c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bldrs", - "version": "1.0.1894", + "version": "1.0.1900", "main": "src/index.jsx", "license": "AGPL-3.0", "homepage": "https://github.com/bldrs-ai/Share", diff --git a/src/BaseRoutes.jsx b/src/BaseRoutes.jsx index 708f2571c..327770ea2 100644 --- a/src/BaseRoutes.jsx +++ b/src/BaseRoutes.jsx @@ -7,6 +7,7 @@ import {useAuth0} from './Auth0/Auth0Proxy' import PopupAuth from './Components/Auth/PopupAuth' import PopupCallback from './Components/Auth/PopupCallback' import {checkOPFSAvailability, setUpGlobalDebugFunctions} from './OPFS/utils' +import {initUsageFromOPFS} from './privacy/usageTracking' import ShareRoutes from './ShareRoutes' import Styles from './Styles' import About from './pages/About' @@ -72,6 +73,7 @@ export default function BaseRoutes({testElt = null}) { if (available) { setUpGlobalDebugFunctions() + initUsageFromOPFS() } setIsOpfsAvailable(available) diff --git a/src/Components/Onboarding/OnboardingOverlay.jsx b/src/Components/Onboarding/OnboardingOverlay.jsx index 190ca3042..df73147a1 100644 --- a/src/Components/Onboarding/OnboardingOverlay.jsx +++ b/src/Components/Onboarding/OnboardingOverlay.jsx @@ -3,6 +3,8 @@ import {useNavigate} from 'react-router-dom' import {Box, Fade, Paper, Stack, Typography} from '@mui/material' import {useTheme} from '@mui/material/styles' import {FileUpload as FileUploadIcon} from '@mui/icons-material' +import {useAuth0} from '../../Auth0/Auth0Proxy' +import {getUserTier} from '../../privacy/usageTracking' import useStore from '../../store/useStore' import {handleFileDrop, handleDragOverOrEnter, handleDragLeave} from '../../utils/dragAndDrop' @@ -21,9 +23,13 @@ export default function OnboardingOverlay({isVisible, onClose}) { // Store state and navigation const appPrefix = useStore((state) => state.appPrefix) + const appMetadata = useStore((state) => state.appMetadata) const isOpfsAvailable = useStore((state) => state.isOpfsAvailable) const setAlert = useStore((state) => state.setAlert) + const setIsUsageLimitDialogVisible = useStore((state) => state.setIsUsageLimitDialogVisible) + const {isAuthenticated} = useAuth0() const navigate = useNavigate() + const userTier = getUserTier(isAuthenticated, appMetadata) // Find actual button positions when overlay becomes visible useEffect(() => { @@ -73,6 +79,12 @@ export default function OnboardingOverlay({isVisible, onClose}) { isOpfsAvailable, setAlert, () => onClose(true), // onSuccess callback - close overlay and skip help dialog + undefined, + userTier, + (info) => { + onClose(false) + setIsUsageLimitDialogVisible(true, info) + }, ) } diff --git a/src/Components/Open/GitHubFileBrowser.jsx b/src/Components/Open/GitHubFileBrowser.jsx index 1ff4ca4fb..65187c6c5 100644 --- a/src/Components/Open/GitHubFileBrowser.jsx +++ b/src/Components/Open/GitHubFileBrowser.jsx @@ -3,6 +3,7 @@ import React, {ReactElement, useState} from 'react' import {Button, Stack, Typography} from '@mui/material' import {navigateBaseOnModelPath} from '../../utils/location' import {navigateToModel} from '../../utils/navigate' +import {canLoadModel, recordModelLoad} from '../../privacy/usageTracking' import {useAuth0} from '../../Auth0/Auth0Proxy' import {pathSuffixSupported} from '../../Filetype' import {getFilesAndFolders} from '../../net/github/Files' @@ -23,6 +24,8 @@ export default function GitHubFileBrowser({ navigate, orgNamesArr, setIsDialogDisplayed, + userTier, + onLimitReached, }) { const [currentPath, setCurrentPath] = useState('') const [foldersArr, setFoldersArr] = useState(['']) @@ -122,6 +125,16 @@ export default function GitHubFileBrowser({ const navigateToFile = () => { if (pathSuffixSupported(fileName)) { + if (userTier && userTier !== 'pro') { + const check = canLoadModel(userTier) + if (!check.allowed) { + if (onLimitReached) { + onLimitReached(check) + } + return + } + recordModelLoad() + } const branch = branchName || 'main' navigateToModel({pathname: navigateBaseOnModelPath(orgName, repoName, branch, `${currentPath}/${fileName}`)}, navigate) setIsDialogDisplayed(false) diff --git a/src/Components/Open/OpenModelDialog.jsx b/src/Components/Open/OpenModelDialog.jsx index 2e2f4508a..85923ca0a 100644 --- a/src/Components/Open/OpenModelDialog.jsx +++ b/src/Components/Open/OpenModelDialog.jsx @@ -3,6 +3,7 @@ import {Button, Stack, Typography, TextField} from '@mui/material' import {useAuth0} from '../../Auth0/Auth0Proxy' import {checkOPFSAvailability} from '../../OPFS/utils' import {looksLikeLink, githubUrlOrPathToSharePath} from '../../net/github/utils' +import {getUserTier, canLoadModel, recordModelLoad} from '../../privacy/usageTracking' import useStore from '../../store/useStore' import {loadLocalFile, loadLocalFileFallback} from '../../utils/loader' import {disablePageReloadApprovalCheck} from '../../utils/event' @@ -33,14 +34,35 @@ export default function OpenModelDialog({ const tabLabels = [LABEL_LOCAL, LABEL_GITHUB, LABEL_SAMPLES] const {isAuthenticated, user} = useAuth0() const appPrefix = useStore((state) => state.appPrefix) + const appMetadata = useStore((state) => state.appMetadata) const setCurrentTab = useStore((state) => state.setCurrentTab) const currentTab = useStore((state) => state.currentTab) + const setIsUsageLimitDialogVisible = useStore((state) => state.setIsUsageLimitDialogVisible) const isOpfsAvailable = checkOPFSAvailability() const isMobile = useIsMobile() + const userTier = getUserTier(isAuthenticated, appMetadata) + /** + * Show usage limit dialog and close this dialog. + * + * @param {object} check Rate limit check result + */ + const showLimitDialog = (check) => { + setIsDialogDisplayed(false) + setIsUsageLimitDialogVisible(true, {reason: check.reason, stats: check.stats}) + } const openFile = () => { + // Rate-limit check for local file open + if (userTier !== 'pro') { + const check = canLoadModel(userTier) + if (!check.allowed) { + showLimitDialog(check) + return + } + } const onLoad = (filename) => { + recordModelLoad() // Use full reload when opening a new local file disablePageReloadApprovalCheck() navigateToModel(`${appPrefix}/v/new/${filename}`, navigate) @@ -106,6 +128,14 @@ export default function OpenModelDialog({ onChange={(event) => { const ghPath = event.target.value if (looksLikeLink(ghPath)) { + if (userTier !== 'pro') { + const check = canLoadModel(userTier) + if (!check.allowed) { + showLimitDialog(check) + return + } + recordModelLoad() + } setIsDialogDisplayed(false) navigateToModel(githubUrlOrPathToSharePath(ghPath), navigate) } @@ -117,6 +147,8 @@ export default function OpenModelDialog({ orgNamesArr={orgNamesArr} user={user} setIsDialogDisplayed={setIsDialogDisplayed} + userTier={userTier} + onLimitReached={(check) => showLimitDialog(check)} />} {!isAuthenticated && } diff --git a/src/Components/Search/SearchBar.jsx b/src/Components/Search/SearchBar.jsx index 7bac39009..64f303685 100644 --- a/src/Components/Search/SearchBar.jsx +++ b/src/Components/Search/SearchBar.jsx @@ -2,8 +2,12 @@ import React, {ReactElement, useRef, useEffect, useState} from 'react' import {useLocation, useNavigate, useSearchParams} from 'react-router-dom' import {Autocomplete, TextField} from '@mui/material' import {Close as CloseIcon} from '@mui/icons-material' +import {useAuth0} from '../../Auth0/Auth0Proxy' import {looksLikeLink, githubUrlOrPathToSharePath} from '../../net/github/utils' +import {getUserTier, canLoadModel, recordModelLoad} from '../../privacy/usageTracking' +import {isSampleOrExemptPath} from '../../privacy/sampleModelPaths' import {processExternalUrl} from '../../routes/routes' +import useStore from '../../store/useStore' import {disablePageReloadApprovalCheck} from '../../utils/event' import {navWithSearchParamRemoved, navigateToModel} from '../../utils/navigate' import {assertDefined} from '../../utils/assert' @@ -28,11 +32,39 @@ export default function SearchBar({ assertDefined(placeholder, isGitHubSearch) const location = useLocation() const navigate = useNavigate() + const {isAuthenticated} = useAuth0() + const appMetadata = useStore((state) => state.appMetadata) + const setIsUsageLimitDialogVisible = useStore((state) => state.setIsUsageLimitDialogVisible) + const userTier = getUserTier(isAuthenticated, appMetadata) const [searchParams, setSearchParams] = useSearchParams() const [inputText, setInputText] = useState('') const [error, setError] = useState('') const searchInputRef = useRef(null) + /** + * Check rate limit and show dialog if exceeded. + * + * @param {string|object} modelPath The model path to check + * @return {boolean} true if rate-limited (blocked) + */ + const checkRateLimit = (modelPath) => { + if (userTier === 'pro') { + return false + } + // Sample/exempt paths don't count + const pathStr = typeof modelPath === 'string' ? modelPath : modelPath?.pathname || '' + if (isSampleOrExemptPath(pathStr)) { + return false + } + const check = canLoadModel(userTier) + if (!check.allowed) { + setIsUsageLimitDialogVisible(true, {reason: check.reason, stats: check.stats}) + return true + } + recordModelLoad() + return false + } + useEffect(() => { if (location.search) { @@ -68,6 +100,9 @@ export default function SearchBar({ if (looksLikeLink(inputText)) { try { const modelPath = githubUrlOrPathToSharePath(inputText) + if (checkRateLimit(modelPath)) { + return + } disablePageReloadApprovalCheck() navigateToModel(modelPath, navigate) if (onSuccess) { @@ -82,6 +117,9 @@ export default function SearchBar({ const result = processExternalUrl(window.location.href, inputText) if (result) { try { + if (checkRateLimit(`/share/v/u/${inputText}`)) { + return + } disablePageReloadApprovalCheck() navigate(`/share/v/u/${inputText}`) if (onSuccess) { diff --git a/src/Components/UsageLimitDialog.jsx b/src/Components/UsageLimitDialog.jsx new file mode 100644 index 000000000..6268df9af --- /dev/null +++ b/src/Components/UsageLimitDialog.jsx @@ -0,0 +1,122 @@ +import React, {ReactElement} from 'react' +import {useNavigate} from 'react-router-dom' +import {Box, Button, LinearProgress, Stack, Typography} from '@mui/material' +import {Lock as LockIcon} from '@mui/icons-material' +import {useAuth0} from '../Auth0/Auth0Proxy' +import useStore from '../store/useStore' +import Dialog from './Dialog' + + +/** + * Dialog shown when a user hits their model load rate limit. + * Anonymous users are prompted to sign in; free users are prompted to upgrade. + * + * @return {ReactElement} + */ +export default function UsageLimitDialog() { + const navigate = useNavigate() + const {isAuthenticated} = useAuth0() + const isVisible = useStore((state) => state.isUsageLimitDialogVisible) + const info = useStore((state) => state.usageLimitInfo) + const setIsUsageLimitDialogVisible = useStore((state) => state.setIsUsageLimitDialogVisible) + const setIsLoginVisible = useStore((state) => state.setIsLoginVisible) + + if (!isVisible || !info) { + return null + } + + const {stats} = info + const headerText = isAuthenticated ? 'Model limit reached' : 'Sign in to load more models' + + const onClose = () => setIsUsageLimitDialogVisible(false) + + const onPrimaryCTA = () => { + onClose() + if (isAuthenticated) { + window.location.href = '/subscribe/' + } else { + setIsLoginVisible(true) + } + } + + const onLearnMore = () => { + onClose() + navigate('/pricing') + } + + return ( + } + headerText={headerText} + isDialogDisplayed={isVisible} + setIsDialogDisplayed={onClose} + > + + + + + + + + + + Sample models are always free to browse + + + + ) +} + + +const FULL_PERCENT = 100 + +/** + * A simple labeled progress bar for usage display. + * + * @property {string} label Display label for the bar + * @property {number} used Current usage count + * @property {number} limit Maximum allowed count + * @return {ReactElement} + */ +function UsageBar({label, used, limit}) { + const percent = limit === Infinity ? 0 : Math.min((used / limit) * FULL_PERCENT, FULL_PERCENT) + const limitDisplay = limit === Infinity ? '\u221E' : limit + return ( + + + {label} + {used}/{limitDisplay} + + + + ) +} diff --git a/src/Containers/ViewerContainer.jsx b/src/Containers/ViewerContainer.jsx index ca6d855ad..853de62af 100644 --- a/src/Containers/ViewerContainer.jsx +++ b/src/Containers/ViewerContainer.jsx @@ -1,8 +1,10 @@ import React, {ReactElement, useState} from 'react' import {useNavigate} from 'react-router-dom' import {Box} from '@mui/material' +import {useAuth0} from '../Auth0/Auth0Proxy' import {useIsMobile} from '../Components/Hooks' import {PlacemarkHandlers as placemarkHandlers} from '../Components/Markers/MarkerControl' +import {getUserTier} from '../privacy/usageTracking' import useStore from '../store/useStore' import {handleFileDrop, handleDragOverOrEnter, handleDragLeave} from '../utils/dragAndDrop' @@ -10,16 +12,20 @@ import {handleFileDrop, handleDragOverOrEnter, handleDragLeave} from '../utils/d /** @return {ReactElement} */ export default function ViewerContainer() { const appPrefix = useStore((state) => state.appPrefix) + const appMetadata = useStore((state) => state.appMetadata) const isModelReady = useStore((state) => state.isModelReady) const isOpfsAvailable = useStore((state) => state.isOpfsAvailable) const setAlert = useStore((state) => state.setAlert) + const setIsUsageLimitDialogVisible = useStore((state) => state.setIsUsageLimitDialogVisible) const {onSceneSingleTap, onSceneDoubleTap} = placemarkHandlers() + const {isAuthenticated} = useAuth0() const vh = useStore((state) => state.vh) const isMobile = useIsMobile() const [, setIsDragActive] = useState(false) const navigate = useNavigate() + const userTier = getUserTier(isAuthenticated, appMetadata) /** * Handles file drop into drag-n-drop area @@ -28,7 +34,12 @@ export default function ViewerContainer() { */ async function onDrop(event) { setIsDragActive(false) - await handleFileDrop(event, navigate, appPrefix, isOpfsAvailable, setAlert) + await handleFileDrop( + event, navigate, appPrefix, isOpfsAvailable, setAlert, + undefined, undefined, + userTier, + (info) => setIsUsageLimitDialogVisible(true, info), + ) } diff --git a/src/Containers/ViewerContainer.test.jsx b/src/Containers/ViewerContainer.test.jsx index 0274319b9..b360f309e 100644 --- a/src/Containers/ViewerContainer.test.jsx +++ b/src/Containers/ViewerContainer.test.jsx @@ -17,16 +17,24 @@ jest.mock('../store/useStore', () => { // For simplicity, we can just assume they're all "happy path" default values const state = { appPrefix: '/app', + appMetadata: {}, isModelReady: true, isOpfsAvailable: true, // or toggle this for specific tests vh: 800, setAlert: jest.fn(), + setIsUsageLimitDialogVisible: jest.fn(), } return selector(state) }) }) jest.mock('react-router-dom', () => ({useNavigate: jest.fn()})) +jest.mock('../Auth0/Auth0Proxy', () => ({useAuth0: jest.fn().mockReturnValue({isAuthenticated: false})})) +jest.mock('../privacy/usageTracking', () => ({ + getUserTier: jest.fn().mockReturnValue('pro'), + canLoadModel: jest.fn().mockReturnValue({allowed: true, reason: null, stats: {}}), + recordModelLoad: jest.fn(), +})) jest.mock('../Filetype', () => ({guessTypeFromFile: jest.fn()})) jest.mock('../OPFS/utils', () => ({saveDnDFileToOpfs: jest.fn()})) jest.mock('../utils/loader', () => ({saveDnDFileToOpfsFallback: jest.fn()})) @@ -53,10 +61,12 @@ describe('ViewerContainer', () => { useStore.mockImplementation((selector) => { const state = { appPrefix: '/app', + appMetadata: {}, isModelReady: true, isOpfsAvailable: true, vh: 800, setAlert: mockSetAlert, + setIsUsageLimitDialogVisible: jest.fn(), } return selector(state) }) @@ -186,10 +196,12 @@ describe('ViewerContainer', () => { useStore.mockImplementation((selector) => { const state = { appPrefix: '/app', + appMetadata: {}, isModelReady: true, isOpfsAvailable: false, // now false vh: 800, setAlert: mockSetAlert, + setIsUsageLimitDialogVisible: jest.fn(), } return selector(state) }) diff --git a/src/Share.jsx b/src/Share.jsx index eec702817..a2b225465 100644 --- a/src/Share.jsx +++ b/src/Share.jsx @@ -1,6 +1,7 @@ import React, {ReactElement, useEffect, useRef} from 'react' import {Helmet} from 'react-helmet-async' import {useNavigate, useParams} from 'react-router-dom' +import UsageLimitDialog from './Components/UsageLimitDialog' import CadView from './Containers/CadView' import WidgetApi from './WidgetApi/WidgetApi' import useStore from './store/useStore' @@ -110,6 +111,7 @@ export default function Share({installPrefix, appPrefix, pathPrefix}) { appPrefix={appPrefix} pathPrefix={pathPrefix} /> + ) } diff --git a/src/privacy/sampleModelPaths.js b/src/privacy/sampleModelPaths.js new file mode 100644 index 000000000..7fc8a376a --- /dev/null +++ b/src/privacy/sampleModelPaths.js @@ -0,0 +1,39 @@ +/** + * Allowlist of known sample model org/repo prefixes. + * Extracted from src/Components/Open/SampleModels.jsx + */ +const SAMPLE_PREFIXES = [ + '/share/v/gh/Swiss-Property-AG/', + '/share/v/gh/bldrs-ai/test-models/', + '/share/v/gh/OlegMoshkovich/Bldrs_Plaza/', +] + +/** Built-in project files are always exempt */ +const EXEMPT_PREFIXES = ['/share/v/p/'] + + +/** + * Check whether a path is a sample model or exempt built-in file. + * + * @param {string} path URL path to check + * @return {boolean} true if the path is exempt from rate limiting + */ +export function isSampleOrExemptPath(path) { + if (!path || typeof path !== 'string') { + return false + } + for (const prefix of SAMPLE_PREFIXES) { + if (path.startsWith(prefix)) { + return true + } + } + for (const prefix of EXEMPT_PREFIXES) { + if (path.startsWith(prefix)) { + return true + } + } + return false +} + + +export {SAMPLE_PREFIXES, EXEMPT_PREFIXES} diff --git a/src/privacy/usageTracking.js b/src/privacy/usageTracking.js new file mode 100644 index 000000000..cc8ec80e6 --- /dev/null +++ b/src/privacy/usageTracking.js @@ -0,0 +1,341 @@ +import Cookies from 'js-cookie' +import Expires from './Expires' + + +const STORAGE_KEY = 'bldrs_model_usage' +const COOKIE_NAME = 'mu' +const OPFS_DIR = '.bldrs' +const OPFS_FILE = 'usage.json' +const VERSION = 1 + + +/** Module-level cache populated asynchronously by initUsageFromOPFS() */ +let _opfsCache = null + + +/** Rate limits by user tier */ +const LIMITS = { + anonymous: {daily: 1, monthly: 5}, + free: {daily: 5, monthly: 25}, + pro: {daily: Infinity, monthly: Infinity}, +} + + +/** + * Determine user tier from authentication state and app metadata. + * + * @param {boolean} isAuthenticated + * @param {object} appMetadata + * @return {'anonymous'|'free'|'pro'} + */ +export function getUserTier(isAuthenticated, appMetadata) { + if (!isAuthenticated) { + return 'anonymous' + } + if (appMetadata && appMetadata.subscriptionStatus === 'sharePro') { + return 'pro' + } + return 'free' +} + + +/** + * Check whether a model load is allowed for the given user tier. + * + * @param {string} userTier 'anonymous'|'free'|'pro' + * @return {object} Result with allowed, reason, and stats properties + */ +export function canLoadModel(userTier) { + if (userTier === 'pro') { + return {allowed: true, reason: null, stats: getUsageStats(userTier)} + } + const usage = loadUsage() + const limits = LIMITS[userTier] + if (usage.daily.count >= limits.daily) { + return {allowed: false, reason: 'daily', stats: getUsageStats(userTier)} + } + if (usage.monthly.count >= limits.monthly) { + return {allowed: false, reason: 'monthly', stats: getUsageStats(userTier)} + } + return {allowed: true, reason: null, stats: getUsageStats(userTier)} +} + + +/** + * Record a model load — increment daily and monthly counters. + */ +export function recordModelLoad() { + const usage = loadUsage() + usage.daily.count += 1 + usage.monthly.count += 1 + saveUsage(usage) +} + + +/** + * Get formatted usage stats for UI display. + * + * @param {string} userTier + * @return {{dailyUsed: number, dailyLimit: number, monthlyUsed: number, monthlyLimit: number}} + */ +export function getUsageStats(userTier) { + const usage = loadUsage() + const limits = LIMITS[userTier] || LIMITS.anonymous + return { + dailyUsed: usage.daily.count, + dailyLimit: limits.daily, + monthlyUsed: usage.monthly.count, + monthlyLimit: limits.monthly, + } +} + + +/** + * Initialize usage data from OPFS. Call once at app startup. + * Populates _opfsCache so loadUsage() can read it synchronously. + * If OPFS has data but localStorage/cookie are empty, restores to those layers. + */ +export async function initUsageFromOPFS() { + const opfsData = await loadFromOPFS() + if (opfsData) { + _opfsCache = opfsData + + // Restore to localStorage/cookie if they're empty + const hasLocalStorage = (() => { + try { + return !!localStorage.getItem(STORAGE_KEY) + } catch { + return false + } + })() + const hasCookie = !!Cookies.get(COOKIE_NAME) + + if (!hasLocalStorage || !hasCookie) { + saveUsage(resetIfNeeded(opfsData)) + } + } +} + + +// --- Internal helpers --- + + +/** @return {string} Today's date in UTC as YYYY-MM-DD */ +function todayUTC() { + return new Date().toISOString().slice(0, 10) +} + + +/** @return {string} Current month in UTC as YYYY-MM */ +function monthUTC() { + return new Date().toISOString().slice(0, 7) +} + + +/** + * Simple checksum for tamper detection. + * + * @param {object} usage + * @return {string} + */ +function computeChecksum(usage) { + const raw = `${VERSION}:${usage.daily.date}:${usage.daily.count}:${usage.monthly.month}:${usage.monthly.count}` + let hash = 0 + for (let i = 0; i < raw.length; i++) { + const chr = raw.charCodeAt(i) + hash = ((hash << 5) - hash) + chr + hash |= 0 // Convert to 32bit integer + } + const HEX_RADIX = 16 + return Math.abs(hash).toString(HEX_RADIX) +} + + +/** + * Reset counters if the day or month has rolled over. + * + * @param {object} usage + * @return {object} Possibly-reset usage + */ +function resetIfNeeded(usage) { + const today = todayUTC() + const month = monthUTC() + if (usage.daily.date !== today) { + usage.daily = {date: today, count: 0} + } + if (usage.monthly.month !== month) { + usage.monthly = {month: month, count: 0} + } + return usage +} + + +/** @return {object} Default empty usage */ +function defaultUsage() { + return { + version: VERSION, + daily: {date: todayUTC(), count: 0}, + monthly: {month: monthUTC(), count: 0}, + } +} + + +/** + * Read usage data from OPFS (.bldrs/usage.json). + * + * @return {Promise} Parsed usage object or null + */ +async function loadFromOPFS() { + try { + const root = await navigator.storage.getDirectory() + const dir = await root.getDirectoryHandle(OPFS_DIR) + const fileHandle = await dir.getFileHandle(OPFS_FILE) + const file = await fileHandle.getFile() + const text = await file.text() + const parsed = JSON.parse(text) + if (parsed && parsed.version === VERSION) { + return parsed + } + } catch { + // OPFS unavailable, directory/file doesn't exist, or parse error + } + return null +} + + +/** + * Write usage data to OPFS (.bldrs/usage.json). Creates directory if needed. + * + * @param {object} usage + */ +async function saveToOPFS(usage) { + try { + const root = await navigator.storage.getDirectory() + const dir = await root.getDirectoryHandle(OPFS_DIR, {create: true}) + const fileHandle = await dir.getFileHandle(OPFS_FILE, {create: true}) + const writable = await fileHandle.createWritable() + await writable.write(JSON.stringify(usage)) + await writable.close() + } catch { + // OPFS unavailable or write error — silently ignore + } +} + + +/** + * Parse the compact cookie string back to a usage object. + * Cookie format: d:3,m:15,dt:2026-02-23,mt:2026-02 + * + * @param {string} cookieStr + * @return {object|null} + */ +function parseCookie(cookieStr) { + if (!cookieStr) { + return null + } + try { + const parts = {} + cookieStr.split(',').forEach((pair) => { + const [key, val] = pair.split(':') + parts[key] = val + }) + if (parts.d !== undefined && parts.m !== undefined && parts.dt && parts.mt) { + return { + version: VERSION, + daily: {date: parts.dt, count: parseInt(parts.d, 10)}, + monthly: {month: parts.mt, count: parseInt(parts.m, 10)}, + } + } + } catch { + // Invalid cookie format + } + return null +} + + +/** + * Encode a usage object to compact cookie string. + * + * @param {object} usage + * @return {string} + */ +function toCookieStr(usage) { + return `d:${usage.daily.count},m:${usage.monthly.count},dt:${usage.daily.date},mt:${usage.monthly.month}` +} + + +/** + * Load usage from OPFS cache, localStorage, or cookie (in priority order). + * If the checksum is invalid, treat as max usage (limits reached). + * + * @return {object} usage + */ +function loadUsage() { + let usage = null + + // Try OPFS cache first (populated async at startup) + if (_opfsCache && _opfsCache.version === VERSION) { + usage = _opfsCache + } + + // Try localStorage + if (!usage) { + try { + const stored = localStorage.getItem(STORAGE_KEY) + if (stored) { + const parsed = JSON.parse(stored) + if (parsed && parsed.version === VERSION) { + const expected = computeChecksum(parsed) + if (parsed.checksum === expected) { + usage = parsed + } + // Checksum mismatch — fall through to cookie or treat as tampered + } + } + } catch { + // localStorage unavailable or corrupted + } + } + + // Fallback to cookie + if (!usage) { + const cookieStr = Cookies.get(COOKIE_NAME) + const fromCookie = parseCookie(cookieStr) + if (fromCookie) { + usage = fromCookie + } + } + + // Still nothing — fresh usage + if (!usage) { + usage = defaultUsage() + } + + return resetIfNeeded(usage) +} + + +/** + * Save usage to localStorage, cookie, and OPFS. + * + * @param {object} usage + */ +function saveUsage(usage) { + usage.checksum = computeChecksum(usage) + + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(usage)) + } catch { + // localStorage full or unavailable + } + + Cookies.set(COOKIE_NAME, toCookieStr(usage), {expires: Expires.DAYS}) + + // Fire-and-forget write to OPFS + _opfsCache = usage + saveToOPFS(usage) +} + + +// Exported for testing +export {STORAGE_KEY, COOKIE_NAME, OPFS_DIR, OPFS_FILE, LIMITS} diff --git a/src/store/UsageLimitSlice.js b/src/store/UsageLimitSlice.js new file mode 100644 index 000000000..b589c3765 --- /dev/null +++ b/src/store/UsageLimitSlice.js @@ -0,0 +1,17 @@ +/** + * Data stored in Zustand for usage limit dialog state. + * + * @param {Function} set + * @param {Function} get + * @return {object} Zustand slice. + */ +export default function createUsageLimitSlice(set, get) { + return { + isUsageLimitDialogVisible: false, + usageLimitInfo: null, // {reason, stats} from canLoadModel() + setIsUsageLimitDialogVisible: (isVisible, info = null) => set({ + isUsageLimitDialogVisible: isVisible, + usageLimitInfo: info, + }), + } +} diff --git a/src/store/useStore.js b/src/store/useStore.js index 0b5a57036..0a206c693 100644 --- a/src/store/useStore.js +++ b/src/store/useStore.js @@ -16,6 +16,7 @@ import createShareSlice from './ShareSlice' import createSideDrawerSlice from './SideDrawerSlice' import createUIEnabledSlice from './UIEnabledSlice' import createUISlice from './UISlice' +import createUsageLimitSlice from './UsageLimitSlice' import createVersionsSlice from './VersionsSlice' @@ -37,6 +38,7 @@ const useStore = create((set, get) => ({ ...createSideDrawerSlice(set, get), ...createUIEnabledSlice(set, get), ...createUISlice(set, get), + ...createUsageLimitSlice(set, get), ...createVersionsSlice(set, get), })) diff --git a/src/utils/dragAndDrop.js b/src/utils/dragAndDrop.js index b4110f706..1a32227b1 100644 --- a/src/utils/dragAndDrop.js +++ b/src/utils/dragAndDrop.js @@ -1,5 +1,6 @@ import {guessTypeFromFile} from '../Filetype' import {saveDnDFileToOpfs} from '../OPFS/utils' +import {canLoadModel, recordModelLoad} from '../privacy/usageTracking' import {disablePageReloadApprovalCheck} from './event' import {trackAlert} from './alertTracking' import {navigateToModel} from './navigate' @@ -17,9 +18,23 @@ import debug from './debug' * @param {Function} setAlert Function to set alert messages * @param {Function} [onSuccess] Optional callback when file is successfully processed * @param {Function} [onError] Optional callback when an error occurs + * @param {string} [userTier] User tier for rate limiting ('anonymous'|'free'|'pro') + * @param {Function} [onLimitReached] Callback when rate limit is exceeded, receives {reason, stats} */ -export async function handleFileDrop(event, navigate, appPrefix, isOpfsAvailable, setAlert, onSuccess, onError) { +export async function handleFileDrop(event, navigate, appPrefix, isOpfsAvailable, setAlert, onSuccess, onError, userTier, onLimitReached) { event.preventDefault() + + // Rate-limit check for file drops (always user-initiated, never sample models) + if (userTier && userTier !== 'pro') { + const check = canLoadModel(userTier) + if (!check.allowed) { + if (onLimitReached) { + onLimitReached({reason: check.reason, stats: check.stats}) + } + return + } + } + const files = event.dataTransfer.files if (files.length === 0) { @@ -56,6 +71,7 @@ export async function handleFileDrop(event, navigate, appPrefix, isOpfsAvailable /** @param {string} fileName The filename the upload was given */ function onWritten(fileName) { + recordModelLoad() disablePageReloadApprovalCheck() debug().log('handleFileDrop: navigate to:', fileName) navigateToModel(`${appPrefix}/v/new/${fileName}`, navigate)