This commit is contained in:
2025-10-05 00:58:08 -07:00
parent 785032a8ad
commit f746913297
17 changed files with 2028 additions and 138 deletions
+32 -1
View File
@@ -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
+145
View File
@@ -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!
+26
View File
@@ -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
+3 -1
View File
@@ -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>
+375 -16
View File
@@ -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: `
<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>
`,
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>`,
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
+1
View File
@@ -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])
+80
View File
@@ -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"] || ""
};
}
+10
View File
@@ -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();
+118
View File
@@ -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
View File
@@ -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
View File
@@ -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 {
+32 -16
View File
@@ -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);
}
});
);
}
+8
View File
@@ -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
View File
@@ -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
}
});
+227
View File
@@ -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;
}