we
This commit is contained in:
+32
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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 <container> ls -la /app/frontend/plugins`
|
||||
- Check API response: `curl http://localhost:3000/api/plugins`
|
||||
- Check container logs: `docker logs <container>`
|
||||
|
||||
**SDK not found?**
|
||||
- Verify SDK was injected: `docker exec <container> grep furryplace-sdk.js /app/frontend/index.html`
|
||||
- Check if file exists: `docker exec <container> ls -la /app/frontend/furryplace-sdk.js`
|
||||
|
||||
**Wrong frontend directory?**
|
||||
- Verify environment variable: `docker exec <container> 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!
|
||||
@@ -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');
|
||||
File diff suppressed because it is too large
Load Diff
@@ -83,7 +83,7 @@
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval' https://challenges.cloudflare.com blob:"
|
||||
content="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;"
|
||||
/>
|
||||
|
||||
<script type="application/ld+json">
|
||||
@@ -110,6 +110,8 @@
|
||||
<link rel="manifest" href="./site.webmanifest" />
|
||||
|
||||
<!-- FurryPlace SDK for custom button extensions -->
|
||||
<!-- Captcha configuration - loaded before SDK -->
|
||||
<script src="/captcha-config.js"></script>
|
||||
<script src="/furryplace-sdk.js"></script>
|
||||
</head>
|
||||
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
map: {} // Map context (not yet implemented)
|
||||
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 (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: `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" fill="currentColor" class="size-5">
|
||||
icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" fill="currentColor" class="size-5">
|
||||
<path d="M480-80q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Z"/>
|
||||
</svg>
|
||||
`,
|
||||
</svg>`,
|
||||
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: '<p class="text-sm">Welcome to our server!</p>'
|
||||
});
|
||||
```
|
||||
|
||||
#### 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 `
|
||||
<div class="text-sm">
|
||||
<p>Level: ${level}</p>
|
||||
<p>Droplets: ${droplets}</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
#### 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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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"] || ""
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
+25
-84
@@ -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({
|
||||
|
||||
+6
-2
@@ -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 {
|
||||
|
||||
+19
-3
@@ -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,7 +82,10 @@ export default function (app: App) {
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/:season/pixel/:tileX/:tileY", authMiddleware, async (req: any, res: any) => {
|
||||
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"]);
|
||||
@@ -93,10 +98,21 @@ export default function (app: App) {
|
||||
.json(createErrorResponse(validationError, HTTP_STATUS.BAD_REQUEST));
|
||||
}
|
||||
|
||||
const result = await pixelService.paintPixels(req.user!.id, { tileX, tileY, colors, coords });
|
||||
// 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+14
-2
@@ -204,7 +204,7 @@ export class PixelService {
|
||||
return imageData;
|
||||
}
|
||||
|
||||
async paintPixels(userId: number, input: PaintPixelsInput, season: number = 0): Promise<PaintPixelsResult> {
|
||||
async paintPixels(userId: number, input: PaintPixelsInput, season: number = 0, shouldResetCaptchaCounter = false, skipCaptchaCounterReset = false): Promise<PaintPixelsResult> {
|
||||
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
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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<boolean> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
// 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;
|
||||
}
|
||||
Reference in New Issue
Block a user