diff --git a/.env.example b/.env.example index 22ad8a7..52e10a6 100644 --- a/.env.example +++ b/.env.example @@ -18,8 +18,39 @@ COOKIE_SAME_SITE=Strict # Optional: set to your public domain if needed (e.g., example.com) COOKIE_DOMAIN= -# Bot protection +# Bot protection (Legacy - use new captcha config below) TURNSTILE_SECRET_KEY="your-turnstile-secret" # Development bypass token (set to empty in production) TURNSTILE_BYPASS_TOKEN="turnstile-disabled" +# Captcha Configuration +# Supported providers: turnstile, hcaptcha, recaptcha-v2, recaptcha-v3, none +# Each verification point can use a different provider and be independently enabled/disabled + +# Login Captcha (appears when logging in with existing account) +CAPTCHA_LOGIN_ENABLED=false +CAPTCHA_LOGIN_PROVIDER=turnstile +CAPTCHA_LOGIN_SECRET=your-secret-key +CAPTCHA_LOGIN_BYPASS_TOKEN= + +# Registration Captcha (appears when creating new account) +CAPTCHA_REGISTER_ENABLED=false +CAPTCHA_REGISTER_PROVIDER=hcaptcha +CAPTCHA_REGISTER_SECRET=your-secret-key +CAPTCHA_REGISTER_BYPASS_TOKEN= + +# Google OAuth Captcha (appears before OAuth flow) +CAPTCHA_GOOGLE_OAUTH_ENABLED=false +CAPTCHA_GOOGLE_OAUTH_PROVIDER=turnstile +CAPTCHA_GOOGLE_OAUTH_SECRET=your-secret-key +CAPTCHA_GOOGLE_OAUTH_BYPASS_TOKEN= + +# Paint Captcha (appears when painting pixels) +CAPTCHA_PAINT_ENABLED=false +CAPTCHA_PAINT_PROVIDER=recaptcha-v3 +CAPTCHA_PAINT_SECRET=your-secret-key +CAPTCHA_PAINT_BYPASS_TOKEN= +# Paint Captcha Interval: Require captcha every N paint actions (0 = always require, empty = always require) +# Example: 5 means captcha is required every 5 paint actions +CAPTCHA_PAINT_INTERVAL=5 + diff --git a/DOCKER_PLUGINS_SETUP.md b/DOCKER_PLUGINS_SETUP.md new file mode 100644 index 0000000..0f9b30f --- /dev/null +++ b/DOCKER_PLUGINS_SETUP.md @@ -0,0 +1,145 @@ +# Docker Plugin Setup - Summary of Changes + +This document summarizes the changes made to ensure the plugin system works correctly in Docker. + +## Changes Made + +### 1. Backend Route (`src/routes/plugins.ts`) + +**Problem**: The route was hardcoded to use `frontend-backup` directory, which doesn't exist in Docker when `USE_FRONTEND_BACKUP=false`. + +**Solution**: Updated to respect the `USE_FRONTEND_BACKUP` environment variable: + +```typescript +// Determine which frontend directory to use (matches index.ts logic) +const frontendDir = process.env['USE_FRONTEND_BACKUP'] === 'true' ? 'frontend-backup' : 'frontend'; +``` + +### 2. Dockerfile + +**Problem**: The plugins directory might not be created when building from source. + +**Solution**: Updated the build step to ensure the plugins directory exists: + +```dockerfile +RUN if [ "$USE_FRONTEND_BACKUP" = "true" ]; then \ + rm -rf /app/frontend && mkdir -p /app/frontend && cp -R /app/frontend-backup/. /app/frontend/; \ + else \ + cd frontend-src && npm install && npm run build && \ + mkdir -p /app/frontend/plugins && \ + echo "Plugins directory created for frontend build"; \ + fi +``` + +### 3. Git Configuration + +Created `.gitignore` in `frontend-backup/plugins/` to: +- Keep the example plugin in version control +- Prevent user plugins from being committed +- Keep the README documentation + +## Testing in Docker + +### Option 1: Using Frontend Backup (Recommended for Development) + +```bash +# Build with frontend-backup (includes SDK and example plugin) +docker build --build-arg USE_FRONTEND_BACKUP=true -t furryplace . + +# Run +docker run -p 3000:3000 -e USE_FRONTEND_BACKUP=true furryplace +``` + +### Option 2: Building from Source + +```bash +# Build from source (requires frontend-src directory) +docker build --build-arg USE_FRONTEND_BACKUP=false -t furryplace . + +# Run +docker run -p 3000:3000 -e USE_FRONTEND_BACKUP=false furryplace +``` + +### Option 3: Runtime Plugin Mounting + +```bash +# Mount plugins directory at runtime +docker run -d \ + -p 3000:3000 \ + -v ./my-plugins:/app/frontend/plugins \ + -e USE_FRONTEND_BACKUP=false \ + furryplace +``` + +## Verification Steps + +1. **Build the image**: `docker build --build-arg USE_FRONTEND_BACKUP=true -t furryplace .` +2. **Run the container**: `docker run -p 3000:3000 -e USE_FRONTEND_BACKUP=true furryplace` +3. **Open browser**: Navigate to `http://localhost:3000` +4. **Check console**: Should see: + ``` + [FurryPlace SDK] Loaded v1.0.0 + [FurryPlace SDK] Auto-discovering plugins... + [FurryPlace SDK] Found 1 plugin(s): ['example-button.js'] + [FurryPlace SDK] Loaded plugin: example-button.js + ``` +5. **Test API**: `curl http://localhost:3000/api/plugins` + ```json + { + "plugins": [ + { + "name": "example-button.js", + "path": "/plugins/example-button.js", + "size": 12345, + "modified": "2025-10-04T..." + } + ] + } + ``` + +## File Structure in Container + +When `USE_FRONTEND_BACKUP=true`: +``` +/app/ +├── dist/ # Compiled backend +├── frontend/ # Frontend files (copied from frontend-backup) +│ ├── furryplace-sdk.js # SDK file +│ ├── plugins/ # Plugins directory +│ │ ├── .gitignore +│ │ ├── README.md +│ │ └── example-button.js +│ ├── index.html # (with SDK script tag injected) +│ └── ... +└── node_modules/ +``` + +## Environment Variables + +| Variable | Value | Effect | +|----------|-------|--------| +| `USE_FRONTEND_BACKUP` | `true` | Uses `frontend-backup/` directory (includes SDK) | +| `USE_FRONTEND_BACKUP` | `false` | Uses `frontend/` directory (built from source) | + +## Troubleshooting + +**Plugins not loading?** +- Check that the plugins directory exists: `docker exec ls -la /app/frontend/plugins` +- Check API response: `curl http://localhost:3000/api/plugins` +- Check container logs: `docker logs ` + +**SDK not found?** +- Verify SDK was injected: `docker exec grep furryplace-sdk.js /app/frontend/index.html` +- Check if file exists: `docker exec ls -la /app/frontend/furryplace-sdk.js` + +**Wrong frontend directory?** +- Verify environment variable: `docker exec env | grep USE_FRONTEND_BACKUP` +- Check which directory is being used in logs + +## Next Steps + +After verifying the setup works: +1. Add your custom plugins to `frontend-backup/plugins/` +2. Rebuild the Docker image +3. Deploy to production +4. Users will see the new buttons automatically! diff --git a/frontend-backup/captcha-config.js b/frontend-backup/captcha-config.js new file mode 100644 index 0000000..c8337e0 --- /dev/null +++ b/frontend-backup/captcha-config.js @@ -0,0 +1,26 @@ +/** + * FurryPlace Captcha Configuration + * This file is automatically loaded before plugins to configure captcha providers + * + * The login-captcha.js plugin will show a single captcha in the login modal + * that unlocks all login methods (username/password, Google OAuth, etc.) + */ + +window.FURRYPLACE_CAPTCHA_CONFIG = { + // Login Modal - hCaptcha (covers login, register, and Google OAuth) + login: { + enabled: true, + siteKey: '52562422-3e32-4230-9f70-b496c4acea66', + theme: 'dark', + size: 'normal' + }, + + // Paint - Turnstile (optional, for painting verification) + paint: { + enabled: false, // Set to true to require captcha when painting + siteKey: '0x4AAAAAAB44tMjFejnRfoKl', + theme: 'auto' + } +}; + +console.log('[FurryPlace] Captcha configuration loaded'); diff --git a/frontend-backup/furryplace-sdk.js b/frontend-backup/furryplace-sdk.js index 9fa5a19..482d518 100644 --- a/frontend-backup/furryplace-sdk.js +++ b/frontend-backup/furryplace-sdk.js @@ -1,32 +1,110 @@ /** - * FurryPlace SDK - Button Extension API + * FurryPlace SDK - Button Extension, User State, Info Modal & Shop API * - * This SDK allows external scripts to add custom buttons to the FurryPlace UI. + * This SDK allows external scripts to: + * - Add custom buttons to the FurryPlace UI + * - Access user state (login status, charges, droplets, level, etc.) + * - Add custom sections to the info modal + * - Add custom items to the shop + * - Purchase store items programmatically + * - React to user state changes + * - Access site content and social links * - * @example - * // Register a custom button + * @example Basic Button * window.FurryPlaceSDK.registerButton({ * id: 'my-custom-button', * title: 'My Button', * icon: '...', - * position: 'before-leaderboard', // or 'after-leaderboard', 'top', 'bottom' + * position: 'before-leaderboard', * onClick: (context) => { * console.log('Button clicked!', context); * }, - * condition: (context) => { - * // Optional: only show when certain conditions are met - * return context.user?.isLoggedIn; - * } + * condition: (context) => context.user?.isLoggedIn * }); + * + * @example Accessing User State + * // Check if logged in + * if (window.FurryPlaceSDK.isLoggedIn()) { + * console.log('User is logged in!'); + * console.log('Droplets:', window.FurryPlaceSDK.getDroplets()); + * console.log('Charges:', window.FurryPlaceSDK.getCharges()); + * console.log('Level:', window.FurryPlaceSDK.getLevel()); + * } + * + * @example Async User Data + * const user = await window.FurryPlaceSDK.getUser(); + * if (user) { + * console.log('User name:', user.name); + * console.log('Pixels painted:', user.pixelsPainted); + * } + * + * @example Info Modal Customization + * window.FurryPlaceSDK.addInfoSection({ + * id: 'my-section', + * title: 'Custom Section', + * content: '

Custom HTML content here

', + * position: 'after-video' + * }); + * + * @example Adding Custom Shop Items + * window.FurryPlaceSDK.addShopItem({ + * id: 'custom-powerup', + * name: 'Super Paint Boost', + * description: 'Double your paint speed for 1 hour', + * price: 1000, + * position: 'top', + * onPurchase: async (context) => { + * // Check if user has enough droplets + * if (context.user.droplets < 1000) { + * alert('Not enough droplets!'); + * return; + * } + * // Your custom purchase logic here + * const success = await yourCustomPurchaseAPI(); + * if (success) { + * alert('Purchase successful!'); + * } + * }, + * condition: (context) => context.user?.isLoggedIn + * }); + * + * @example Purchasing Store Items + * // Purchase +5 max charges + * const success = await window.FurryPlaceSDK.purchaseStoreItem(70, 1); + * + * // Purchase +30 paint charges + * await window.FurryPlaceSDK.purchaseStoreItem(80, 2); // Buy 2x + * + * // Unlock a specific paid color (variant 32-63) + * await window.FurryPlaceSDK.purchaseStoreItem(100, 1, 35); + * + * // Unlock a flag (variant 1-251) + * await window.FurryPlaceSDK.purchaseStoreItem(110, 1, 42); + * + * @example Accessing Site Content and Social Links + * // Get all site content + * const content = await window.FurryPlaceSDK.getSiteContent('en'); + * console.log('Site title:', content['site.title']); + * + * // Get social links + * const socials = await window.FurryPlaceSDK.getSocialLinks('en'); + * console.log('Twitter URL:', socials.twitter.url); + * console.log('Bluesky URL:', socials.bluesky.url); + * console.log('Discord URL:', socials.discord.url); */ (function() { 'use strict'; - const SDK_VERSION = '1.0.0'; + const SDK_VERSION = '2.2.0'; const registeredButtons = []; + const infoModalSections = []; + const shopItems = []; + const captchaCallbacks = {}; let isInitialized = false; let injectionPoint = null; + let infoModalObserver = null; + let shopModalObserver = null; // Find the button container in the DOM function findButtonContainer() { @@ -95,16 +173,445 @@ return wrapper; } + // User state cache + let userState = null; + let userStatePromise = null; + + // Site content cache + let siteContent = null; + let siteContentPromise = null; + + // Fetch and cache user state + async function fetchUserState() { + try { + const response = await fetch('/api/me', { + credentials: 'include' + }); + if (response.ok) { + userState = await response.json(); + return userState; + } + return null; + } catch (error) { + console.warn('[FurryPlace SDK] Failed to fetch user state:', error); + return null; + } + } + + // Get user state (cached) + async function getUserState() { + if (userState) return userState; + if (!userStatePromise) { + userStatePromise = fetchUserState(); + } + userState = await userStatePromise; + userStatePromise = null; + return userState; + } + + // Refresh user state + function refreshUserState() { + userState = null; + userStatePromise = null; + return getUserState(); + } + + // Fetch and cache site content + async function fetchSiteContent(locale = 'en') { + try { + const response = await fetch(`/api/site-content?locale=${locale}`); + if (response.ok) { + const data = await response.json(); + siteContent = data.content; + return siteContent; + } + return null; + } catch (error) { + console.warn('[FurryPlace SDK] Failed to fetch site content:', error); + return null; + } + } + + // Get site content (cached) + async function getSiteContent(locale = 'en') { + if (siteContent) return siteContent; + if (!siteContentPromise) { + siteContentPromise = fetchSiteContent(locale); + } + siteContent = await siteContentPromise; + siteContentPromise = null; + return siteContent; + } + + // Refresh site content + function refreshSiteContent(locale = 'en') { + siteContent = null; + siteContentPromise = null; + return getSiteContent(locale); + } + + // Find info modal content area + function findInfoModal() { + // Look for modal with specific structure (has iframe for YouTube) + const modals = document.querySelectorAll('dialog.modal'); + for (const modal of modals) { + const iframe = modal.querySelector('iframe[title*="YouTube"]'); + if (iframe) { + return modal.querySelector('.modal-box'); + } + } + return null; + } + + // Create a custom section element + function createInfoSection(config) { + const section = document.createElement('section'); + section.setAttribute('data-furryplace-info', config.id); + + if (config.className) { + section.className = config.className; + } + + // Add title if provided + if (config.title) { + const title = document.createElement('h3'); + title.className = 'text-lg font-semibold mb-2'; + title.textContent = config.title; + section.appendChild(title); + } + + // Add content + if (typeof config.content === 'string') { + const contentDiv = document.createElement('div'); + contentDiv.innerHTML = config.content; + section.appendChild(contentDiv); + } else if (config.content instanceof HTMLElement) { + section.appendChild(config.content); + } else if (typeof config.content === 'function') { + const contentResult = config.content(); + if (contentResult instanceof HTMLElement) { + section.appendChild(contentResult); + } else if (typeof contentResult === 'string') { + const contentDiv = document.createElement('div'); + contentDiv.innerHTML = contentResult; + section.appendChild(contentDiv); + } + } + + return section; + } + + // Inject custom sections into info modal + function injectInfoSections() { + const modalBox = findInfoModal(); + if (!modalBox) return; + + // Remove old custom sections + const oldSections = modalBox.querySelectorAll('[data-furryplace-info]'); + oldSections.forEach(section => section.remove()); + + // Find the content container (first div inside modal-box) + let contentContainer = modalBox.querySelector('div[class*="flex"]'); + + // If no container found, create one + if (!contentContainer) { + contentContainer = document.createElement('div'); + contentContainer.className = 'flex h-full flex-col gap-5'; + modalBox.appendChild(contentContainer); + } + + // Find insertion point (before the last section which contains links/footer) + const sections = contentContainer.querySelectorAll('section'); + const lastSection = sections[sections.length - 1]; + + // Inject each custom section + infoModalSections.forEach(config => { + const section = createInfoSection(config); + + if (config.position === 'top') { + contentContainer.insertBefore(section, contentContainer.firstChild); + } else if (config.position === 'bottom' || !config.position) { + if (lastSection && lastSection.classList.contains('text-center')) { + // Insert before footer section + contentContainer.insertBefore(section, lastSection); + } else { + contentContainer.appendChild(section); + } + } else if (config.position === 'after-video') { + // Find YouTube iframe section + const videoSection = Array.from(sections).find(s => s.querySelector('iframe[title*="YouTube"]')); + if (videoSection && videoSection.nextSibling) { + contentContainer.insertBefore(section, videoSection.nextSibling); + } else { + contentContainer.appendChild(section); + } + } + }); + } + + // Watch for info modal opening + function watchInfoModal() { + if (infoModalObserver) return; + + const observer = new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + // Watch for new dialogs being added + mutation.addedNodes.forEach((node) => { + if (node.nodeName === 'DIALOG' && node.classList.contains('modal')) { + // Check if this is the info modal + setTimeout(() => { + const modalBox = findInfoModal(); + if (modalBox && node.open) { + injectInfoSections(); + } + }, 50); + } + }); + + // Also watch for 'open' attribute changes on existing dialogs + if (mutation.type === 'attributes' && mutation.attributeName === 'open') { + const target = mutation.target; + if (target.nodeName === 'DIALOG' && target.open) { + setTimeout(() => { + const modalBox = findInfoModal(); + if (modalBox) { + injectInfoSections(); + } + }, 50); + } + } + }); + }); + + observer.observe(document.body, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ['open'] + }); + + infoModalObserver = observer; + } + + // Find shop modal content area + function findShopModal() { + // Look for modal with shop content (contains "Shop" text or store items) + const modals = document.querySelectorAll('dialog.modal'); + for (const modal of modals) { + const modalBox = modal.querySelector('.modal-box'); + if (modalBox && modal.open) { + // Check if this is the shop modal by looking for typical shop elements + const hasShopContent = modalBox.querySelector('[data-shop-content]') || + modalBox.querySelector('.shop-items') || + (modalBox.textContent && modalBox.textContent.includes('Droplets')); + if (hasShopContent) { + return modalBox; + } + } + } + return null; + } + + // Create a custom shop item element + function createShopItem(config) { + const item = document.createElement('div'); + item.setAttribute('data-furryplace-shop-item', config.id); + item.className = config.className || 'flex items-center justify-between p-4 border rounded-lg hover:bg-base-200 transition-colors'; + + // Item info container + const info = document.createElement('div'); + info.className = 'flex-1'; + + // Item title + const title = document.createElement('div'); + title.className = 'font-semibold text-lg'; + title.textContent = config.name; + info.appendChild(title); + + // Item description + if (config.description) { + const desc = document.createElement('div'); + desc.className = 'text-sm text-base-content/70'; + desc.textContent = config.description; + info.appendChild(desc); + } + + item.appendChild(info); + + // Price and purchase button container + const actionContainer = document.createElement('div'); + actionContainer.className = 'flex items-center gap-3'; + + // Price display + const priceDiv = document.createElement('div'); + priceDiv.className = 'text-lg font-bold'; + priceDiv.textContent = config.price + ' 💧'; + actionContainer.appendChild(priceDiv); + + // Purchase button + const button = document.createElement('button'); + button.className = config.buttonClassName || 'btn btn-primary'; + button.textContent = config.buttonText || 'Purchase'; + + if (config.disabled) { + button.disabled = true; + } + + // Add click handler + if (config.onPurchase) { + button.addEventListener('click', async (e) => { + e.preventDefault(); + e.stopPropagation(); + + const context = getContext(); + + // Disable button during purchase + button.disabled = true; + const originalText = button.textContent; + button.textContent = 'Purchasing...'; + + try { + await config.onPurchase(context, e); + // Refresh user state after purchase + await refreshUserState(); + } catch (error) { + console.error('[FurryPlace SDK] Purchase failed:', error); + } finally { + button.disabled = config.disabled || false; + button.textContent = originalText; + } + }); + } + + actionContainer.appendChild(button); + item.appendChild(actionContainer); + + return item; + } + + // Inject custom shop items + function injectShopItems() { + const modalBox = findShopModal(); + if (!modalBox) return; + + // Remove old custom items + const oldItems = modalBox.querySelectorAll('[data-furryplace-shop-item]'); + oldItems.forEach(item => item.remove()); + + // Find or create shop items container + let itemsContainer = modalBox.querySelector('[data-shop-content]'); + if (!itemsContainer) { + // Try to find a suitable container + itemsContainer = modalBox.querySelector('.flex.flex-col.gap-4'); + if (!itemsContainer) { + // Create one if needed + itemsContainer = document.createElement('div'); + itemsContainer.className = 'flex flex-col gap-4'; + itemsContainer.setAttribute('data-shop-content', 'true'); + modalBox.appendChild(itemsContainer); + } + } + + // Inject each custom shop item + shopItems.forEach(config => { + // Check condition + if (config.condition) { + const context = getContext(); + if (!config.condition(context)) { + return; // Skip this item + } + } + + const item = createShopItem(config); + + // Determine insertion position + if (config.position === 'top') { + itemsContainer.insertBefore(item, itemsContainer.firstChild); + } else if (config.position === 'bottom' || !config.position) { + itemsContainer.appendChild(item); + } + }); + } + + // Watch for shop modal opening + function watchShopModal() { + if (shopModalObserver) return; + + const observer = new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + // Watch for new dialogs being added + mutation.addedNodes.forEach((node) => { + if (node.nodeName === 'DIALOG' && node.classList.contains('modal')) { + setTimeout(() => { + const modalBox = findShopModal(); + if (modalBox && node.open) { + injectShopItems(); + } + }, 50); + } + }); + + // Also watch for 'open' attribute changes on existing dialogs + if (mutation.type === 'attributes' && mutation.attributeName === 'open') { + const target = mutation.target; + if (target.nodeName === 'DIALOG' && target.open) { + setTimeout(() => { + const modalBox = findShopModal(); + if (modalBox) { + injectShopItems(); + } + }, 50); + } + } + }); + }); + + observer.observe(document.body, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ['open'] + }); + + shopModalObserver = observer; + } + // Get context information for button callbacks function getContext() { + const isLoggedIn = !!userState; + return { sdk: { version: SDK_VERSION, + refreshUserState: refreshUserState, }, user: { - // This would need to be populated from the app's state - // For now, return null - plugins can access app state directly if needed - isLoggedIn: false, + isLoggedIn: isLoggedIn, + id: userState?.id || null, + name: userState?.name || null, + discord: userState?.discord || null, + country: userState?.country || null, + banned: userState?.banned || false, + role: userState?.role || null, + level: userState?.level || 0, + pixelsPainted: userState?.pixelsPainted || 0, + droplets: userState?.droplets || 0, + picture: userState?.picture || null, + equippedFlag: userState?.equippedFlag || null, + allianceId: userState?.allianceId || null, + allianceRole: userState?.allianceRole || null, + charges: { + current: userState?.charges?.count || 0, + max: userState?.charges?.max || 0, + cooldownMs: userState?.charges?.cooldownMs || 0, + percentage: userState?.charges?.max > 0 + ? (userState.charges.count / userState.charges.max) * 100 + : 0 + }, + hasChargesToPaint: (userState?.charges?.count || 0) >= 1, + needsPhoneVerification: userState?.needsPhoneVerification || false, + isTimedOut: !!userState?.timeoutUntil, + timeoutUntil: userState?.timeoutUntil || null, }, map: { // Map context if available @@ -181,7 +688,7 @@ } // Initialize the SDK - function initialize() { + async function initialize() { if (isInitialized) { console.warn('[FurryPlace SDK] Already initialized'); return; @@ -189,19 +696,26 @@ console.log('[FurryPlace SDK] Initializing v' + SDK_VERSION); + // Fetch user state first + await getUserState(); + waitForButtonContainer((container) => { console.log('[FurryPlace SDK] Found button container, injecting buttons...'); injectButtons(container); isInitialized = true; + // DISABLED: The mutation observer was causing infinite loops + // Buttons will be injected once and stay there + // If the app re-renders the container, buttons may disappear + // but this is safer than causing freezes + + /* // Set up mutation observer to re-inject on DOM changes const observer = new MutationObserver(() => { const currentContainer = findButtonContainer(); if (currentContainer && currentContainer !== injectionPoint) { - // Container changed, re-inject injectButtons(currentContainer); } else if (currentContainer && injectionPoint) { - // Check if our buttons are still there const ourButtons = currentContainer.querySelectorAll('[data-furryplace-button]'); if (ourButtons.length !== registeredButtons.length) { reinjectButtons(); @@ -213,7 +727,19 @@ childList: true, subtree: true, }); + */ }); + + // Refresh user state periodically (every 5 seconds) + setInterval(() => { + refreshUserState(); + }, 5000); + + // Start watching for info modal + watchInfoModal(); + + // Start watching for shop modal + watchShopModal(); } // Public API @@ -294,6 +820,382 @@ */ getContext() { return getContext(); + }, + + /** + * Get user state (async) + * @returns {Promise} User data or null if not logged in + */ + async getUser() { + return await getUserState(); + }, + + /** + * Refresh user state from server + * @returns {Promise} Updated user data or null + */ + async refreshUser() { + return await refreshUserState(); + }, + + /** + * Check if user is logged in + * @returns {boolean} + */ + isLoggedIn() { + return !!userState; + }, + + /** + * Get user's current droplets (coins) + * @returns {number} + */ + getDroplets() { + return userState?.droplets || 0; + }, + + /** + * Get user's current charges + * @returns {Object} { current, max, cooldownMs, percentage } + */ + getCharges() { + return { + current: userState?.charges?.count || 0, + max: userState?.charges?.max || 0, + cooldownMs: userState?.charges?.cooldownMs || 0, + percentage: userState?.charges?.max > 0 + ? (userState.charges.count / userState.charges.max) * 100 + : 0 + }; + }, + + /** + * Check if user has charges to paint + * @returns {boolean} + */ + hasChargesToPaint() { + return (userState?.charges?.count || 0) >= 1; + }, + + /** + * Get user's level + * @returns {number} + */ + getLevel() { + return userState?.level || 0; + }, + + /** + * Get user's total pixels painted + * @returns {number} + */ + getPixelsPainted() { + return userState?.pixelsPainted || 0; + }, + + /** + * Get user's alliance ID + * @returns {number|null} + */ + getAllianceId() { + return userState?.allianceId || null; + }, + + /** + * Check if user is in an alliance + * @returns {boolean} + */ + isInAlliance() { + return !!userState?.allianceId; + }, + + /** + * Add a custom section to the info modal + * @param {Object} config - Section configuration + * @param {string} config.id - Unique identifier + * @param {string} [config.title] - Section title + * @param {string|HTMLElement|Function} config.content - Section content + * @param {string} [config.position] - Position: 'top', 'bottom', 'after-video' + * @param {string} [config.className] - Custom CSS classes + */ + addInfoSection(config) { + if (!config.id) { + console.error('[FurryPlace SDK] Info section must have an id'); + return; + } + + if (infoModalSections.find(s => s.id === config.id)) { + console.error('[FurryPlace SDK] Info section with id "' + config.id + '" already registered'); + return; + } + + infoModalSections.push(config); + console.log('[FurryPlace SDK] Registered info section:', config.id); + + // Inject immediately if modal is open + const modalBox = findInfoModal(); + if (modalBox) { + injectInfoSections(); + } + }, + + /** + * Remove a custom section from the info modal + * @param {string} id - Section ID to remove + */ + removeInfoSection(id) { + const index = infoModalSections.findIndex(s => s.id === id); + if (index !== -1) { + infoModalSections.splice(index, 1); + + // Remove from DOM if present + const section = document.querySelector('[data-furryplace-info="' + id + '"]'); + if (section) { + section.remove(); + } + + console.log('[FurryPlace SDK] Removed info section:', id); + } + }, + + /** + * Get list of registered info sections + */ + getInfoSections() { + return infoModalSections.map(s => ({ id: s.id, title: s.title })); + }, + + /** + * Add a custom item to the shop + * @param {Object} config - Shop item configuration + * @param {string} config.id - Unique identifier + * @param {string} config.name - Item name + * @param {string} [config.description] - Item description + * @param {number} config.price - Price in droplets + * @param {Function} config.onPurchase - Purchase handler (context, event) => Promise + * @param {string} [config.position] - Position: 'top', 'bottom' + * @param {string} [config.className] - Custom CSS classes for item container + * @param {string} [config.buttonClassName] - Custom CSS classes for button + * @param {string} [config.buttonText] - Button text (default: 'Purchase') + * @param {Function} [config.condition] - Optional condition (context) => boolean + * @param {boolean} [config.disabled=false] - Whether button is disabled + */ + addShopItem(config) { + if (!config.id) { + console.error('[FurryPlace SDK] Shop item must have an id'); + return; + } + + if (!config.name) { + console.error('[FurryPlace SDK] Shop item must have a name'); + return; + } + + if (typeof config.price !== 'number') { + console.error('[FurryPlace SDK] Shop item must have a numeric price'); + return; + } + + if (shopItems.find(s => s.id === config.id)) { + console.error('[FurryPlace SDK] Shop item with id "' + config.id + '" already registered'); + return; + } + + shopItems.push(config); + console.log('[FurryPlace SDK] Registered shop item:', config.id); + + // Inject immediately if shop modal is open + const modalBox = findShopModal(); + if (modalBox) { + injectShopItems(); + } + }, + + /** + * Remove a custom item from the shop + * @param {string} id - Shop item ID to remove + */ + removeShopItem(id) { + const index = shopItems.findIndex(s => s.id === id); + if (index !== -1) { + shopItems.splice(index, 1); + + // Remove from DOM if present + const item = document.querySelector('[data-furryplace-shop-item="' + id + '"]'); + if (item) { + item.remove(); + } + + console.log('[FurryPlace SDK] Removed shop item:', id); + } + }, + + /** + * Get list of registered shop items + */ + getShopItems() { + return shopItems.map(s => ({ id: s.id, name: s.name, price: s.price })); + }, + + /** + * Purchase a default store item + * @param {number} productId - Product ID (70, 80, 100, 110) + * @param {number} [amount=1] - Amount to purchase + * @param {number} [variant] - Variant ID (for colors and flags) + * @returns {Promise} True if purchase was successful + */ + async purchaseStoreItem(productId, amount = 1, variant = null) { + try { + const payload = { + product: { + id: productId, + amount: amount + } + }; + + if (variant !== null) { + payload.product.variant = variant; + } + + const response = await fetch('/api/store/purchase', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + credentials: 'include', + body: JSON.stringify(payload) + }); + + if (response.ok) { + const data = await response.json(); + // Refresh user state after purchase + await refreshUserState(); + return data.success === true; + } + + return false; + } catch (error) { + console.error('[FurryPlace SDK] Purchase failed:', error); + return false; + } + }, + + /** + * Register a captcha callback for a specific verification point + * @param {string} verificationPoint - One of: 'login', 'register', 'googleOAuth', 'paint' + * @param {Function} callback - async () => string - Function that returns a captcha token + */ + registerCaptchaCallback(verificationPoint, callback) { + if (!['login', 'register', 'googleOAuth', 'paint'].includes(verificationPoint)) { + console.error('[FurryPlace SDK] Invalid verification point:', verificationPoint); + return; + } + + if (typeof callback !== 'function') { + console.error('[FurryPlace SDK] Captcha callback must be a function'); + return; + } + + captchaCallbacks[verificationPoint] = callback; + console.log('[FurryPlace SDK] Registered captcha callback for:', verificationPoint); + }, + + /** + * Unregister a captcha callback + * @param {string} verificationPoint - Verification point to remove + */ + unregisterCaptchaCallback(verificationPoint) { + delete captchaCallbacks[verificationPoint]; + console.log('[FurryPlace SDK] Unregistered captcha callback for:', verificationPoint); + }, + + /** + * Request a captcha token for a specific verification point + * @param {string} verificationPoint - One of: 'login', 'register', 'googleOAuth', 'paint' + * @returns {Promise} Captcha token or null if no callback registered + */ + async requestCaptcha(verificationPoint) { + const callback = captchaCallbacks[verificationPoint]; + if (!callback) { + console.warn('[FurryPlace SDK] No captcha callback registered for:', verificationPoint); + return null; + } + + try { + const token = await callback(); + return token; + } catch (error) { + console.error('[FurryPlace SDK] Captcha callback failed:', error); + return null; + } + }, + + /** + * Check if a captcha callback is registered for a verification point + * @param {string} verificationPoint - Verification point to check + * @returns {boolean} + */ + hasCaptchaCallback(verificationPoint) { + return !!captchaCallbacks[verificationPoint]; + }, + + /** + * Get list of registered captcha callbacks + * @returns {string[]} Array of verification points with registered callbacks + */ + getCaptchaCallbacks() { + return Object.keys(captchaCallbacks); + }, + + /** + * Get site content (async) + * @param {string} [locale='en'] - Locale to fetch content for + * @returns {Promise} Site content object or null + */ + async getSiteContent(locale = 'en') { + return await getSiteContent(locale); + }, + + /** + * Refresh site content from server + * @param {string} [locale='en'] - Locale to fetch content for + * @returns {Promise} Updated site content or null + */ + async refreshSiteContent(locale = 'en') { + return await refreshSiteContent(locale); + }, + + /** + * Get social links from site content + * @param {string} [locale='en'] - Locale to fetch content for + * @returns {Promise} Social links object + */ + async getSocialLinks(locale = 'en') { + const content = await getSiteContent(locale); + if (!content) return {}; + + return { + twitter: { + url: content['social.twitter.url'], + text: content['social.twitter.text'] + }, + bluesky: { + url: content['social.bluesky.url'], + text: content['social.bluesky.text'] + }, + discord: { + url: content['modal.footer.discord.url'], + text: content['modal.footer.discord.text'] + }, + instagram: { + url: content['modal.footer.instagram.url'], + text: content['modal.footer.instagram.text'] + }, + github: { + url: content['modal.footer.github.url'], + text: content['modal.footer.github.text'] + } + }; } }; diff --git a/frontend-backup/index.html b/frontend-backup/index.html index 6e91b75..d731b5e 100644 --- a/frontend-backup/index.html +++ b/frontend-backup/index.html @@ -83,7 +83,7 @@ diff --git a/frontend-backup/plugins/README.md b/frontend-backup/plugins/README.md index e21da2a..899ae31 100644 --- a/frontend-backup/plugins/README.md +++ b/frontend-backup/plugins/README.md @@ -10,18 +10,139 @@ This directory contains plugins that extend the FurryPlace UI using the FurryPla > **✨ Auto-Loading:** Plugins are automatically discovered from the `frontend-backup/plugins/` directory. Any `.js` file you add here will be automatically loaded when the page loads. No need to manually edit HTML files! -## Example Plugin +## Example Plugins -See [example-button.js](./example-button.js) for a complete example showing how to: +### [example-button.js](./example-button.js) +Complete example showing how to: - Add custom buttons with icons - Handle click events - Use conditional rendering - Apply custom styling - Open external links +### [example-user-state.js](./example-user-state.js) +Demonstrates user state access: +- Display user stats and information +- Show droplet counter +- Monitor paint charge status +- Display level progress +- Conditional rendering based on login status +- Refresh user data from server + +### [example-info-modal.js](./example-info-modal.js) +Demonstrates info modal customization: +- Add custom sections to the info modal +- Display announcements and updates +- Show server statistics +- Add custom links and resources +- Display user-specific information +- Create interactive collapsible sections +- Add tips, tricks, and changelogs + +## Captcha Plugins + +FurryPlace includes built-in support for captcha verification at different points: +- **Login**: When logging into an existing account +- **Registration**: When creating a new account +- **Paint**: When painting pixels (can be configured to require every N paints) +- **Google OAuth**: Before initiating Google OAuth flow + +### [captcha-turnstile.js](./captcha-turnstile.js) +Cloudflare Turnstile captcha integration: +- Free and privacy-friendly +- Lightweight and fast +- Automatic theme detection +- Configure per verification point + +**Configuration:** +```javascript +window.FURRYPLACE_CAPTCHA_CONFIG = { + login: { + enabled: true, + siteKey: 'your-turnstile-site-key', + theme: 'auto', // 'light', 'dark', or 'auto' + }, + register: { + enabled: true, + siteKey: 'your-turnstile-site-key', + theme: 'auto', + }, + paint: { + enabled: true, + siteKey: 'your-turnstile-site-key', + theme: 'dark', + }, + googleOAuth: { + enabled: true, + siteKey: 'your-turnstile-site-key', + theme: 'light', + } +}; +``` + +### [captcha-hcaptcha.js](./captcha-hcaptcha.js) +hCaptcha integration: +- Privacy-focused alternative +- Customizable themes and sizes +- Supports compact mode + +**Configuration:** +```javascript +window.FURRYPLACE_CAPTCHA_CONFIG = { + login: { + enabled: true, + siteKey: 'your-hcaptcha-site-key', + theme: 'light', // 'light' or 'dark' + size: 'normal', // 'normal', 'compact', or 'invisible' + }, + register: { + enabled: true, + siteKey: 'your-hcaptcha-site-key', + theme: 'dark', + size: 'normal', + }, + paint: { + enabled: true, + siteKey: 'your-hcaptcha-site-key', + theme: 'dark', + size: 'compact', + } +}; +``` + +### [captcha-recaptcha.js](./captcha-recaptcha.js) +Google reCAPTCHA v2 and v3 integration: +- Support for both v2 (checkbox) and v3 (invisible) +- Per-action configuration for v3 +- Customizable themes for v2 + +**Configuration:** +```javascript +window.FURRYPLACE_CAPTCHA_CONFIG = { + login: { + enabled: true, + siteKey: 'your-recaptcha-site-key', + version: 'v2', // 'v2' or 'v3' + theme: 'light', // v2 only + size: 'normal', // v2 only + action: 'login', // v3 only + }, + paint: { + enabled: true, + siteKey: 'your-recaptcha-v3-site-key', + version: 'v3', + action: 'paint', + } +}; +``` + +**Important:** Only load ONE captcha plugin at a time. Choose the one that matches your backend configuration. + ## FurryPlace SDK API -### `window.FurryPlaceSDK.registerButton(config)` +### Button Registration + +#### `window.FurryPlaceSDK.registerButton(config)` Register a custom button in the UI. @@ -52,15 +173,147 @@ Register a custom button in the UI. ```javascript { sdk: { - version: string // SDK version + version: string, // SDK version + refreshUserState: Function // Function to refresh user state }, user: { - isLoggedIn: boolean // User login status (not yet implemented) + isLoggedIn: boolean, // User login status + id: number|null, // User ID + name: string|null, // Display name + discord: string|null, // Discord username + country: string|null, // User's country + banned: boolean, // Is user banned + role: string|null, // User role (admin/moderator/user) + level: number, // Current level + pixelsPainted: number, // Total pixels painted + droplets: number, // Currency amount + picture: string|null, // Profile picture URL + equippedFlag: string|null, // Equipped country flag + allianceId: number|null, // Alliance ID + allianceRole: string|null, // Alliance role + charges: { + current: number, // Current paint charges + max: number, // Maximum charges + cooldownMs: number, // Cooldown in milliseconds + percentage: number // Charge percentage (0-100) + }, + hasChargesToPaint: boolean, // Can paint (>= 1 charge) + needsPhoneVerification: boolean, // Needs phone verification + isTimedOut: boolean, // Currently timed out + timeoutUntil: string|null // Timeout expiry ISO string }, - map: {} // Map context (not yet implemented) + map: {} // Map context (future use) } ``` +### User State Methods + +#### `await FurryPlaceSDK.getUser()` +Get full user data (async). Returns user object or `null` if not logged in. + +#### `await FurryPlaceSDK.refreshUser()` +Refresh user state from server and return updated data. + +#### `FurryPlaceSDK.isLoggedIn()` +Returns `true` if user is logged in. + +#### `FurryPlaceSDK.getDroplets()` +Get user's droplet count (currency). + +#### `FurryPlaceSDK.getCharges()` +Get paint charge information: +```javascript +{ + current: number, // Current charges + max: number, // Max capacity + cooldownMs: number, // Recharge cooldown + percentage: number // Fill percentage (0-100) +} +``` + +#### `FurryPlaceSDK.hasChargesToPaint()` +Returns `true` if user has at least 1 charge. + +#### `FurryPlaceSDK.getLevel()` +Get user's current level. + +#### `FurryPlaceSDK.getPixelsPainted()` +Get total pixels painted by user. + +#### `FurryPlaceSDK.getAllianceId()` +Get user's alliance ID or `null`. + +#### `FurryPlaceSDK.isInAlliance()` +Returns `true` if user is in an alliance. + +### Info Modal Customization + +#### `FurryPlaceSDK.addInfoSection(config)` +Add a custom section to the info modal (the one with rules and YouTube video). + +**Configuration Object:** +```javascript +{ + id: string, // Required: Unique identifier + title: string, // Optional: Section title + content: string|HTMLElement|Function, // Required: HTML string, DOM element, or function + position: string, // Optional: 'top', 'bottom', 'after-video' + className: string // Optional: Custom CSS classes for section +} +``` + +**Positions:** +- `'top'` - At the beginning of the modal +- `'bottom'` - At the end (before footer) +- `'after-video'` - Right after the YouTube video section + +**Content Types:** +- **String**: HTML string that will be inserted +- **HTMLElement**: DOM element to append +- **Function**: Function that returns a string or HTMLElement + +#### `FurryPlaceSDK.removeInfoSection(id)` +Remove a custom section from the info modal. + +#### `FurryPlaceSDK.getInfoSections()` +Get list of all registered info sections. + +### Captcha Methods + +#### `FurryPlaceSDK.registerCaptchaCallback(verificationPoint, callback)` +Register a callback function that provides captcha tokens for a specific verification point. + +**Parameters:** +- `verificationPoint`: One of `'login'`, `'register'`, `'googleOAuth'`, or `'paint'` +- `callback`: Async function that returns a captcha token string + +**Example:** +```javascript +window.FurryPlaceSDK.registerCaptchaCallback('login', async () => { + // Show captcha UI and wait for user to complete it + const token = await showCaptchaWidget(); + return token; +}); +``` + +#### `await FurryPlaceSDK.requestCaptcha(verificationPoint)` +Request a captcha token for a specific verification point. This calls the registered callback. + +**Returns:** Promise that resolves to a captcha token string or `null` if no callback is registered. + +#### `FurryPlaceSDK.hasCaptchaCallback(verificationPoint)` +Check if a captcha callback is registered for a verification point. + +**Returns:** `true` if callback is registered, `false` otherwise. + +#### `FurryPlaceSDK.unregisterCaptchaCallback(verificationPoint)` +Remove a captcha callback for a verification point. + +#### `FurryPlaceSDK.getCaptchaCallbacks()` +Get list of all verification points with registered callbacks. + +**Returns:** Array of strings (verification point names). + ### Other SDK Methods - `FurryPlaceSDK.unregisterButton(id)` - Remove a registered button @@ -70,11 +323,12 @@ Register a custom button in the UI. ## Example Usage +### Basic Button + ```javascript (function() { 'use strict'; - // Wait for SDK to load function waitForSDK(callback) { if (window.FurryPlaceSDK) { callback(); @@ -84,27 +338,132 @@ Register a custom button in the UI. } waitForSDK(() => { - // Register a button window.FurryPlaceSDK.registerButton({ id: 'my-custom-button', title: 'My Button', position: 'bottom', - icon: ` - - - - `, + icon: ` + + `, onClick: (context) => { alert('Button clicked!'); - console.log('Context:', context); } }); - - console.log('[My Plugin] Loaded!'); }); })(); ``` +### Accessing User State + +```javascript +waitForSDK(() => { + // Synchronous methods + console.log('Logged in:', window.FurryPlaceSDK.isLoggedIn()); + console.log('Droplets:', window.FurryPlaceSDK.getDroplets()); + console.log('Level:', window.FurryPlaceSDK.getLevel()); + + // Async method for full user data + window.FurryPlaceSDK.getUser().then(user => { + if (user) { + console.log('User name:', user.name); + console.log('Alliance:', user.allianceId); + } + }); +}); +``` + +### Button with User State Condition + +```javascript +// Only show button if user is logged in and has charges +window.FurryPlaceSDK.registerButton({ + id: 'paint-helper', + title: 'Quick Paint', + position: 'top', + icon: `...`, + onClick: async (context) => { + const charges = context.user.charges; + alert(`You have ${charges.current}/${charges.max} charges`); + }, + condition: (context) => { + return context.user?.isLoggedIn && context.user?.hasChargesToPaint; + } +}); +``` + +### Charge Monitor Example + +```javascript +window.FurryPlaceSDK.registerButton({ + id: 'charge-monitor', + title: 'Charge Monitor', + position: 'bottom', + icon: `...`, + onClick: () => { + const charges = window.FurryPlaceSDK.getCharges(); + alert(` + Current: ${charges.current}/${charges.max} + Percentage: ${charges.percentage.toFixed(1)}% + Can Paint: ${window.FurryPlaceSDK.hasChargesToPaint() ? 'Yes' : 'No'} + `); + }, + condition: (context) => context.user?.isLoggedIn +}); +``` + +### Info Modal Section Examples + +#### Simple HTML Section +```javascript +window.FurryPlaceSDK.addInfoSection({ + id: 'welcome-message', + title: 'Welcome!', + position: 'top', + content: '

Welcome to our server!

' +}); +``` + +#### Dynamic Content with Function +```javascript +window.FurryPlaceSDK.addInfoSection({ + id: 'user-stats', + title: 'Your Stats', + position: 'after-video', + content: () => { + const level = window.FurryPlaceSDK.getLevel(); + const droplets = window.FurryPlaceSDK.getDroplets(); + + return ` +
+

Level: ${level}

+

Droplets: ${droplets}

+
+ `; + } +}); +``` + +#### Interactive Section with DOM Elements +```javascript +window.FurryPlaceSDK.addInfoSection({ + id: 'interactive-section', + title: 'Custom Links', + position: 'bottom', + content: () => { + const div = document.createElement('div'); + div.className = 'flex gap-2'; + + const button = document.createElement('button'); + button.className = 'btn btn-sm btn-primary'; + button.textContent = 'Click Me'; + button.onclick = () => alert('Button clicked!'); + + div.appendChild(button); + return div; + } +}); +``` + ## Finding SVG Icons You can find Material Design icons at: diff --git a/frontend-backup/plugins/example-button.js b/frontend-backup/plugins/example-button.js index 0f8a584..ff2ca3a 100644 --- a/frontend-backup/plugins/example-button.js +++ b/frontend-backup/plugins/example-button.js @@ -11,6 +11,9 @@ (function() { 'use strict'; + // Set this to true to disable this example plugin (for reference only) + const EXAMPLE_DISABLED = true; + // Wait for SDK to be available function waitForSDK(callback) { if (window.FurryPlaceSDK) { @@ -108,6 +111,11 @@ // Initialize plugin waitForSDK(() => { + if (EXAMPLE_DISABLED) { + console.log('[Example Plugin] Disabled - set EXAMPLE_DISABLED to false to enable'); + return; + } + console.log('[Example Plugin] Initializing...'); // Register all example buttons diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d9f7835..9e45146 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -34,6 +34,7 @@ model User { maxFavoriteLocations Int @default(15) picture String? level Float @default(1) + paintsSinceCaptcha Int @default(0) allianceId Int? allianceRole String @default("member") alliance Alliance? @relation(fields: [allianceId], references: [id]) diff --git a/src/config/captcha.ts b/src/config/captcha.ts new file mode 100644 index 0000000..f5684a7 --- /dev/null +++ b/src/config/captcha.ts @@ -0,0 +1,80 @@ +/** + * Captcha configuration + * Supports Turnstile, hCaptcha, and reCAPTCHA v2/v3 + */ + +export type CaptchaProvider = "turnstile" | "hcaptcha" | "recaptcha-v2" | "recaptcha-v3" | "none"; + +export interface CaptchaConfig { + enabled: boolean; + provider: CaptchaProvider; + secretKey: string; + bypassToken?: string; + paintInterval?: number | undefined; // For paint captcha: require captcha every N paints (0 = always require) +} + +export interface CaptchaVerificationPoints { + login: CaptchaConfig; + register: CaptchaConfig; + googleOAuth: CaptchaConfig; + paint: CaptchaConfig; +} + +// Load environment variables with proper defaults +const createCaptchaConfig = ( + enabledEnv: string | undefined, + provider: string | undefined, + secretKey: string | undefined, + bypassToken: string | undefined, + paintInterval?: string | undefined +): CaptchaConfig => { + const enabled = enabledEnv?.toLowerCase() === "true"; + const normalizedProvider = (provider?.toLowerCase() || "none") as CaptchaProvider; + + return { + enabled, + provider: normalizedProvider, + secretKey: secretKey || "", + bypassToken: bypassToken || "", + paintInterval: paintInterval ? Number.parseInt(paintInterval, 10) : undefined + }; +}; + +// Captcha configuration for different verification points +export const captchaConfig: CaptchaVerificationPoints = { + login: createCaptchaConfig( + process.env["CAPTCHA_LOGIN_ENABLED"], + process.env["CAPTCHA_LOGIN_PROVIDER"], + process.env["CAPTCHA_LOGIN_SECRET"], + process.env["CAPTCHA_LOGIN_BYPASS_TOKEN"] + ), + register: createCaptchaConfig( + process.env["CAPTCHA_REGISTER_ENABLED"], + process.env["CAPTCHA_REGISTER_PROVIDER"], + process.env["CAPTCHA_REGISTER_SECRET"], + process.env["CAPTCHA_REGISTER_BYPASS_TOKEN"] + ), + googleOAuth: createCaptchaConfig( + process.env["CAPTCHA_GOOGLE_OAUTH_ENABLED"], + process.env["CAPTCHA_GOOGLE_OAUTH_PROVIDER"], + process.env["CAPTCHA_GOOGLE_OAUTH_SECRET"], + process.env["CAPTCHA_GOOGLE_OAUTH_BYPASS_TOKEN"] + ), + paint: createCaptchaConfig( + process.env["CAPTCHA_PAINT_ENABLED"], + process.env["CAPTCHA_PAINT_PROVIDER"], + process.env["CAPTCHA_PAINT_SECRET"], + process.env["CAPTCHA_PAINT_BYPASS_TOKEN"], + process.env["CAPTCHA_PAINT_INTERVAL"] + ) +}; + +// Backward compatibility with old TURNSTILE_* environment variables +if (!captchaConfig.googleOAuth.secretKey && process.env["TURNSTILE_SECRET_KEY"]) { + captchaConfig.googleOAuth = { + enabled: true, + provider: "turnstile", + secretKey: process.env["TURNSTILE_SECRET_KEY"], + bypassToken: process.env["TURNSTILE_BYPASS_TOKEN"] || "" + }; +} diff --git a/src/index.ts b/src/index.ts index 2e77f8a..1992ab3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -232,6 +232,16 @@ app.use(async (req, res, next) => { res.setHeader("Pragma", "no-cache"); res.setHeader("Expires", "0"); + // Add CSP header for HTML files to allow captcha scripts + if (ext === ".html") { + res.setHeader("Content-Security-Policy", + "script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval' https://challenges.cloudflare.com https://js.hcaptcha.com https://*.hcaptcha.com https://www.google.com https://www.gstatic.com blob:; " + + "frame-src 'self' https://challenges.cloudflare.com https://*.hcaptcha.com https://www.google.com https://www.gstatic.com; " + + "style-src 'self' 'unsafe-inline' https://*.hcaptcha.com https://www.gstatic.com; " + + "connect-src 'self' https://*.hcaptcha.com https://www.google.com https://www.gstatic.com https://tiles.openfreemap.org;" + ); + } + if (method === "HEAD") { res.setHeader("Content-Length", content.length.toString()); return res.status(200).end(); diff --git a/src/middleware/captcha.ts b/src/middleware/captcha.ts new file mode 100644 index 0000000..4f1a74c --- /dev/null +++ b/src/middleware/captcha.ts @@ -0,0 +1,118 @@ +/** + * Captcha verification middleware + */ + +import type { CaptchaConfig } from "../config/captcha.js"; +import { verifyCaptcha, getRemoteAddress } from "../utils/captcha.js"; +import { createErrorResponse, HTTP_STATUS } from "../utils/response.js"; +import { prisma } from "../config/database.js"; + +/** + * Creates a captcha verification middleware for a specific verification point + */ +export function captchaMiddleware(config: CaptchaConfig, tokenField = "captchaToken") { + return async (req: any, res: any, next: any) => { + // If captcha is disabled, skip verification + if (!config.enabled || config.provider === "none") { + return next(); + } + + // Extract token from request + // Check query params first (for GET requests like OAuth), then body, then headers + const token = req.query[tokenField] + || req.body?.[tokenField] + || req.headers["x-captcha-token"] + || ""; + + if (!token) { + return res.status(HTTP_STATUS.BAD_REQUEST) + .json(createErrorResponse("Captcha token required", HTTP_STATUS.BAD_REQUEST)); + } + + // Get remote IP for verification + const remoteIp = getRemoteAddress(req); + + // Verify the captcha + const verified = await verifyCaptcha(token, config, remoteIp); + + if (!verified) { + return res.status(HTTP_STATUS.BAD_REQUEST) + .json(createErrorResponse("Captcha verification failed", HTTP_STATUS.BAD_REQUEST)); + } + + // Verification successful, proceed + return next(); + }; +} + +/** + * Creates a conditional captcha middleware for paint endpoint + * Only requires captcha every N paints based on config.paintInterval + */ +export function conditionalPaintCaptchaMiddleware(config: CaptchaConfig, tokenField = "captchaToken") { + return async (req: any, res: any, next: any) => { + // If captcha is disabled, skip verification + if (!config.enabled || config.provider === "none") { + return next(); + } + + // If no user is authenticated, skip (auth middleware will handle this) + if (!req.user?.id) { + return next(); + } + + // Get user's current paint count + const user = await prisma.user.findUnique({ + where: { id: req.user.id }, + select: { paintsSinceCaptcha: true } + }); + + if (!user) { + return next(); + } + + // Determine if captcha is required + let captchaRequired = false; + + if (config.paintInterval === undefined || config.paintInterval === 0) { + // Always require captcha if interval is 0 or not set + captchaRequired = true; + } else if (config.paintInterval > 0) { + // Require captcha every N paints + captchaRequired = user.paintsSinceCaptcha >= config.paintInterval; + } + + // If captcha is not required yet, let them through and increment counter + if (!captchaRequired) { + // Store in request that we don't need to reset counter + req.skipCaptchaCounterReset = true; + return next(); + } + + // Captcha is required - verify token + const token = req.query[tokenField] + || req.body?.[tokenField] + || req.headers["x-captcha-token"] + || ""; + + if (!token) { + return res.status(HTTP_STATUS.BAD_REQUEST) + .json(createErrorResponse("Captcha token required", HTTP_STATUS.BAD_REQUEST)); + } + + // Get remote IP for verification + const remoteIp = getRemoteAddress(req); + + // Verify the captcha + const verified = await verifyCaptcha(token, config, remoteIp); + + if (!verified) { + return res.status(HTTP_STATUS.BAD_REQUEST) + .json(createErrorResponse("Captcha verification failed", HTTP_STATUS.BAD_REQUEST)); + } + + // Verification successful - mark that counter should be reset + req.shouldResetCaptchaCounter = true; + return next(); + }; +} diff --git a/src/routes/auth.ts b/src/routes/auth.ts index 93ab01a..741061d 100644 --- a/src/routes/auth.ts +++ b/src/routes/auth.ts @@ -3,6 +3,8 @@ import bcrypt from "bcryptjs"; import { JWT_SECRET } from "../config/auth.js"; import { prisma } from "../config/database.js"; import { authMiddleware } from "../middleware/auth.js"; +import { captchaMiddleware } from "../middleware/captcha.js"; +import { captchaConfig } from "../config/captcha.js"; import jwt from "jsonwebtoken"; import fs from "fs/promises"; import passport from "passport"; @@ -20,14 +22,6 @@ const SHOULD_USE_SECURE_COOKIES = ( process.env["COOKIE_SECURE"] ?? "" ).toLowerCase() !== "false" && (process.env["NODE_ENV"] ?? "production").toLowerCase() !== "development"; -const TURNSTILE_SECRET_KEY = process.env["TURNSTILE_SECRET_KEY"] || ""; -const TURNSTILE_BYPASS_TOKEN = process.env["TURNSTILE_BYPASS_TOKEN"] || ""; - -interface TurnstileVerifyResponse { - success: boolean; - "error-codes"?: string[]; -} - function buildSessionCookie(value: string, maxAgeSeconds: number, expiresAt: Date) { const encodedValue = encodeURIComponent(value); const attributes = [ @@ -59,56 +53,6 @@ function clearSessionCookie(res: any) { res.setHeader("Set-Cookie", buildSessionCookie("", 0, expiresAt)); } -function getRemoteAddress(req: any) { - return ( - req.headers["cf-connecting-ip"] - || req.headers["x-real-ip"] - || req.ip - || req.socket?.remoteAddress - || "" - ) as string; -} - -async function verifyTurnstileToken(token: string, remoteIp: string) { - const payload = new URLSearchParams({ - secret: TURNSTILE_SECRET_KEY, - response: token - }); - - if (remoteIp) { - payload.set("remoteip", remoteIp); - } - - try { - const fetchFn = typeof globalThis.fetch === "function" ? globalThis.fetch : undefined; - if (!fetchFn) { - console.error("Fetch API is not available for Turnstile verification"); - return false; - } - - const response = await fetchFn("https://challenges.cloudflare.com/turnstile/v0/siteverify", { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded" - }, - body: payload - }); - - if (!response.ok) { - console.error("Turnstile verification failed with status", response.status); - return false; - } - - const data = (await response.json()) as TurnstileVerifyResponse; - if (!data.success) { - console.warn("Turnstile verification rejected", data["error-codes"]); - } - return data.success; - } catch (error) { - console.error("Error verifying Turnstile token", error); - return false; - } -} // Configure Google OAuth Strategy if (GOOGLE_CLIENT_ID && GOOGLE_CLIENT_SECRET) { @@ -157,31 +101,10 @@ export default function (app: App) { app.use(passport.initialize() as any); // Google OAuth routes - app.get("/auth/google", async (req: any, res: any, next: any) => { - const token = typeof req.query.token === "string" ? req.query.token : ""; - - if (!token) { - return res.status(400).json({ error: "Token required" }); - } - - if (TURNSTILE_BYPASS_TOKEN && token === TURNSTILE_BYPASS_TOKEN) { - return passport.authenticate("google", { session: false })(req, res, next); - } - - if (!TURNSTILE_SECRET_KEY) { - console.error("TURNSTILE_SECRET_KEY is not configured; rejecting OAuth attempt"); - return res.status(500).json({ error: "Bot verification unavailable" }); - } - - const remoteIp = getRemoteAddress(req); - const verified = await verifyTurnstileToken(token, remoteIp); - - if (!verified) { - return res.status(400).json({ error: "Failed bot verification" }); - } - - return passport.authenticate("google", { session: false })(req, res, next); - }); + app.get("/auth/google", + captchaMiddleware(captchaConfig.login, "token"), + passport.authenticate("google", { session: false }) + ); app.get("/auth/google/callback", passport.authenticate("google", { @@ -232,7 +155,7 @@ export default function (app: App) { return res.send(loginHtml); }); - app.post("/login", async (req: any, res: any) => { + app.post("/login", captchaMiddleware(captchaConfig.login), async (req: any, res: any) => { try { const { username, password } = req.body; @@ -252,6 +175,24 @@ export default function (app: App) { .json({ error: "Invalid username or password" }); } } else { + // For new user registration, check register captcha if enabled + if (captchaConfig.register.enabled && captchaConfig.register.provider !== "none") { + const registerCaptchaToken = req.body?.registerCaptchaToken || req.body?.captchaToken || ""; + if (!registerCaptchaToken) { + return res.status(400) + .json({ error: "Registration requires captcha verification" }); + } + + const { verifyCaptcha, getRemoteAddress } = await import("../utils/captcha.js"); + const remoteIp = getRemoteAddress(req); + const verified = await verifyCaptcha(registerCaptchaToken, captchaConfig.register, remoteIp); + + if (!verified) { + return res.status(400) + .json({ error: "Registration captcha verification failed" }); + } + } + const passwordHash = await bcrypt.hash(password, 10); user = await prisma.user.create({ diff --git a/src/routes/me.ts b/src/routes/me.ts index c79b4e3..d11b0a6 100644 --- a/src/routes/me.ts +++ b/src/routes/me.ts @@ -9,14 +9,18 @@ import { prisma } from "../config/database.js"; const userService = new UserService(prisma); export default function (app: App) { - app.get("/me", authMiddleware, async (req: any, res: any) => { + // Support both /me and /api/me for compatibility + const getUserHandler = async (req: any, res: any) => { try { const result = await userService.getUserProfile(req.user!.id); return res.json(result); } catch (error) { return handleServiceError(error as Error, res); } - }); + }; + + app.get("/me", authMiddleware, getUserHandler); + app.get("/api/me", authMiddleware, getUserHandler); app.post("/me/update", authMiddleware, async (req: any, res: any) => { try { diff --git a/src/routes/pixel.ts b/src/routes/pixel.ts index 0db022b..90238c2 100644 --- a/src/routes/pixel.ts +++ b/src/routes/pixel.ts @@ -1,5 +1,7 @@ import { App } from "@tinyhttp/app"; import { authMiddleware } from "../middleware/auth.js"; +import { conditionalPaintCaptchaMiddleware } from "../middleware/captcha.js"; +import { captchaConfig } from "../config/captcha.js"; import { handleServiceError } from "../middleware/errorHandler.js"; import { PixelService } from "../services/pixel.js"; import { validateSeason, validateTileCoordinates } from "../validators/common.js"; @@ -80,23 +82,37 @@ export default function (app: App) { } }); - app.post("/:season/pixel/:tileX/:tileY", authMiddleware, async (req: any, res: any) => { - try { - const season = req.params["season"]; - const tileX = Number.parseInt(req.params["tileX"]); - const tileY = Number.parseInt(req.params["tileY"]); - const { colors, coords } = req.body; + app.post("/:season/pixel/:tileX/:tileY", + authMiddleware, + conditionalPaintCaptchaMiddleware(captchaConfig.paint), + async (req: any, res: any) => { + try { + const season = req.params["season"]; + const tileX = Number.parseInt(req.params["tileX"]); + const tileY = Number.parseInt(req.params["tileY"]); + const { colors, coords } = req.body; - const validationError = validatePaintPixels({ season, tileX, tileY, colors, coords }); - if (validationError) { - return res.status(HTTP_STATUS.BAD_REQUEST) - .json(createErrorResponse(validationError, HTTP_STATUS.BAD_REQUEST)); + const validationError = validatePaintPixels({ season, tileX, tileY, colors, coords }); + if (validationError) { + return res.status(HTTP_STATUS.BAD_REQUEST) + .json(createErrorResponse(validationError, HTTP_STATUS.BAD_REQUEST)); + } + + // Get captcha counter flags from middleware + const shouldResetCaptchaCounter = req.shouldResetCaptchaCounter || false; + const skipCaptchaCounterReset = req.skipCaptchaCounterReset || false; + + const result = await pixelService.paintPixels( + req.user!.id, + { tileX, tileY, colors, coords }, + 0, + shouldResetCaptchaCounter, + skipCaptchaCounterReset + ); + return res.json(result); + } catch (error) { + return handleServiceError(error as Error, res); } - - const result = await pixelService.paintPixels(req.user!.id, { tileX, tileY, colors, coords }); - return res.json(result); - } catch (error) { - return handleServiceError(error as Error, res); } - }); + ); } diff --git a/src/routes/site-content.ts b/src/routes/site-content.ts index ce2479f..3ac998a 100644 --- a/src/routes/site-content.ts +++ b/src/routes/site-content.ts @@ -291,6 +291,10 @@ export function setupSiteContentRoutes(app: App) { value: 'https://wplace.live/terms/privacy', locale: 'en', }, + { key: 'social.twitter.url', value: 'https://twitter.com/wplacelive', locale: 'en' }, + { key: 'social.twitter.text', value: 'X/Twitter', locale: 'en' }, + { key: 'social.bluesky.url', value: 'https://bsky.app/profile/wplace.live', locale: 'en' }, + { key: 'social.bluesky.text', value: 'Bluesky', locale: 'en' }, // Chinese content { key: 'site.title', value: 'FurryPlace', locale: 'zh' }, @@ -360,6 +364,10 @@ export function setupSiteContentRoutes(app: App) { value: 'https://wplace.live/terms/privacy', locale: 'zh', }, + { key: 'social.twitter.url', value: 'https://twitter.com/wplacelive', locale: 'zh' }, + { key: 'social.twitter.text', value: 'X/Twitter', locale: 'zh' }, + { key: 'social.bluesky.url', value: 'https://bsky.app/profile/wplace.live', locale: 'zh' }, + { key: 'social.bluesky.text', value: 'Bluesky', locale: 'zh' }, ]; // Use bulk upsert to avoid duplicates diff --git a/src/services/pixel.ts b/src/services/pixel.ts index 338a973..34e9ad0 100644 --- a/src/services/pixel.ts +++ b/src/services/pixel.ts @@ -204,7 +204,7 @@ export class PixelService { return imageData; } - async paintPixels(userId: number, input: PaintPixelsInput, season: number = 0): Promise { + async paintPixels(userId: number, input: PaintPixelsInput, season: number = 0, shouldResetCaptchaCounter = false, skipCaptchaCounterReset = false): Promise { const { tileX, tileY, colors, coords } = input; if (!colors || !coords || !Array.isArray(colors) || !Array.isArray(coords)) { @@ -351,6 +351,17 @@ export class PixelService { // Update max charges based on new level const newMaxCharges = calculateMaxChargesForLevel(newLevel); + // Calculate new captcha counter value + let newPaintsSinceCaptcha = user.paintsSinceCaptcha; + if (shouldResetCaptchaCounter) { + // User passed captcha, reset counter + newPaintsSinceCaptcha = 0; + } else if (!skipCaptchaCounterReset) { + // Increment counter (default behavior) + newPaintsSinceCaptcha += 1; + } + // If skipCaptchaCounterReset is true, keep the current value + await this.prisma.user.update({ where: { id: userId }, data: { @@ -359,7 +370,8 @@ export class PixelService { level: newLevel, droplets: newDroplets, maxCharges: newMaxCharges, - chargesLastUpdatedAt: new Date() + chargesLastUpdatedAt: new Date(), + paintsSinceCaptcha: newPaintsSinceCaptcha } }); diff --git a/src/utils/captcha.ts b/src/utils/captcha.ts new file mode 100644 index 0000000..bfa5315 --- /dev/null +++ b/src/utils/captcha.ts @@ -0,0 +1,227 @@ +/** + * Captcha verification utilities + * Supports Cloudflare Turnstile, hCaptcha, and Google reCAPTCHA (v2 and v3) + */ + +import type { CaptchaConfig } from "../config/captcha.js"; + +interface TurnstileVerifyResponse { + success: boolean; + "error-codes"?: string[]; + challenge_ts?: string; + hostname?: string; +} + +interface HCaptchaVerifyResponse { + success: boolean; + "error-codes"?: string[]; + challenge_ts?: string; + hostname?: string; +} + +interface RecaptchaVerifyResponse { + success: boolean; + "error-codes"?: string[]; + challenge_ts?: string; + hostname?: string; + score?: number; // For reCAPTCHA v3 + action?: string; // For reCAPTCHA v3 +} + +/** + * Verify Cloudflare Turnstile token + */ +async function verifyTurnstile(token: string, secretKey: string, remoteIp?: string): Promise { + const payload = new URLSearchParams({ + secret: secretKey, + response: token + }); + + if (remoteIp) { + payload.set("remoteip", remoteIp); + } + + try { + const fetchFn = typeof globalThis.fetch === "function" ? globalThis.fetch : undefined; + if (!fetchFn) { + console.error("Fetch API is not available for Turnstile verification"); + return false; + } + + const response = await fetchFn("https://challenges.cloudflare.com/turnstile/v0/siteverify", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded" + }, + body: payload + }); + + if (!response.ok) { + console.error("Turnstile verification failed with status", response.status); + return false; + } + + const data = (await response.json()) as TurnstileVerifyResponse; + if (!data.success) { + console.warn("Turnstile verification rejected", data["error-codes"]); + } + return data.success; + } catch (error) { + console.error("Error verifying Turnstile token", error); + return false; + } +} + +/** + * Verify hCaptcha token + */ +async function verifyHCaptcha(token: string, secretKey: string, remoteIp?: string): Promise { + const payload = new URLSearchParams({ + secret: secretKey, + response: token + }); + + if (remoteIp) { + payload.set("remoteip", remoteIp); + } + + try { + const fetchFn = typeof globalThis.fetch === "function" ? globalThis.fetch : undefined; + if (!fetchFn) { + console.error("Fetch API is not available for hCaptcha verification"); + return false; + } + + const response = await fetchFn("https://hcaptcha.com/siteverify", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded" + }, + body: payload + }); + + if (!response.ok) { + console.error("hCaptcha verification failed with status", response.status); + return false; + } + + const data = (await response.json()) as HCaptchaVerifyResponse; + if (!data.success) { + console.warn("hCaptcha verification rejected", data["error-codes"]); + } + return data.success; + } catch (error) { + console.error("Error verifying hCaptcha token", error); + return false; + } +} + +/** + * Verify Google reCAPTCHA token (works for both v2 and v3) + */ +async function verifyRecaptcha(token: string, secretKey: string, remoteIp?: string, minScore = 0.5): Promise { + const payload = new URLSearchParams({ + secret: secretKey, + response: token + }); + + if (remoteIp) { + payload.set("remoteip", remoteIp); + } + + try { + const fetchFn = typeof globalThis.fetch === "function" ? globalThis.fetch : undefined; + if (!fetchFn) { + console.error("Fetch API is not available for reCAPTCHA verification"); + return false; + } + + const response = await fetchFn("https://www.google.com/recaptcha/api/siteverify", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded" + }, + body: payload + }); + + if (!response.ok) { + console.error("reCAPTCHA verification failed with status", response.status); + return false; + } + + const data = (await response.json()) as RecaptchaVerifyResponse; + if (!data.success) { + console.warn("reCAPTCHA verification rejected", data["error-codes"]); + return false; + } + + // For reCAPTCHA v3, check the score + if (data.score !== undefined) { + if (data.score < minScore) { + console.warn(`reCAPTCHA v3 score too low: ${data.score} (min: ${minScore})`); + return false; + } + } + + return true; + } catch (error) { + console.error("Error verifying reCAPTCHA token", error); + return false; + } +} + +/** + * Main captcha verification function + * Automatically selects the correct provider based on config + */ +export async function verifyCaptcha( + token: string, + config: CaptchaConfig, + remoteIp?: string +): Promise { + // If captcha is disabled, always pass + if (!config.enabled || config.provider === "none") { + return true; + } + + // Check bypass token first + if (config.bypassToken && token === config.bypassToken) { + return true; + } + + // Validate that secret key is configured + if (!config.secretKey) { + console.error(`Captcha provider ${config.provider} is enabled but no secret key is configured`); + return false; + } + + // Verify based on provider + switch (config.provider) { + case "turnstile": + return verifyTurnstile(token, config.secretKey, remoteIp); + case "hcaptcha": + return verifyHCaptcha(token, config.secretKey, remoteIp); + case "recaptcha-v2": + case "recaptcha-v3": + // Use different minimum scores for v2 and v3 + const minScore = config.provider === "recaptcha-v3" ? 0.5 : 0.0; + return verifyRecaptcha(token, config.secretKey, remoteIp, minScore); + default: + console.error(`Unknown captcha provider: ${config.provider}`); + return false; + } +} + +/** + * Extract remote IP address from request + */ +export function getRemoteAddress(req: any): string { + return ( + req.headers["cf-connecting-ip"] + || req.headers["x-real-ip"] + || req.headers["x-forwarded-for"]?.split(",")[0] + || req.ip + || req.socket?.remoteAddress + || "" + ) as string; +}