feat: captcha

* Added Revis distributed cash to enhance our Captcha Verification system so that we prevent our system from replay attacks

* Fix: There was an error with the implementation of Redis, so I reverted to our previous version that uses in memory storage

* Integrated the captcha verification system into our sign in Form. The captcha verification system now works on both login and sign int

* Remove test files from captcha module

* Update src/backend/src/modules/captcha/middleware/captcha-middleware.js

Co-authored-by: Eric Dubé <eric.alex.dube@gmail.com>

* Update src/backend/src/modules/captcha/middleware/captcha-middleware.js

Co-authored-by: Eric Dubé <eric.alex.dube@gmail.com>

* Now the captcha can be requested on condition, this llaows extenstions to control wether a captcha should be required,
I fixed the code in CaptchaModule to use config
and got rid of the lines that made captcha middleware available since it wasn't used anywhre

* I split the middleware into two distinct parts, so that the frontend can now determine captach requirements. PuterHomePageService can set GUI parameters for captcha requirements. The /whoarewe endpoint provides captcha requirement information and the extensuo system integration is maintained

* Fix security issues with password handling in URL query parameters

* Made sure that the enter key, submits the login request instead of refreshing the captcha

* In development we can now disable the Captcha verification system by running it with CAPTCHA_ENABLED=false npm start

* Went back and modified checkCaptcha so that it checks at the start to check what CAPTCHA_ENABLED is equal to

* Refactor captcha system to use configuration values instead of environment variables

* Fix captcha verification and align with project standards

* Update src/backend/src/modules/captcha/README.md

Co-authored-by: Eric Dubé <eric.alex.dube@gmail.com>

* fix: incorrect service name

* dev: use Endpoint for captcha endpoints

Use Endpoint class, which uses eggspress behind the scenes, which handles
async errors in handlers automatically.

* dev: add extension support and simplify captcha

- removed extra error handling
- removed dormant code
- no distinction between login and signup (for now)

* clean: remove local files

* fix: undefined edge case

---------

Co-authored-by: Eric Dubé <eric.alex.dube@gmail.com>
This commit is contained in:
Jonathan Mahrt Guyou
2025-03-28 19:46:56 -04:00
committed by GitHub
parent f73958ee8c
commit ad4b3e7aeb
30 changed files with 9579 additions and 5551 deletions

2
.gitignore vendored
View File

@@ -29,4 +29,4 @@ dist/
# Local Netlify folder
.netlify
src/emulator/release/
src/emulator/release/

9203
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -49,6 +49,7 @@
"dependencies": {
"@heyputer/putility": "^1.0.2",
"dedent": "^1.5.3",
"ioredis": "^5.6.0",
"javascript-time-ago": "^2.5.11",
"json-colorizer": "^3.0.1",
"open": "^10.1.0",

View File

@@ -39,6 +39,7 @@ const { InternetModule } = require("./src/modules/internet/InternetModule.js");
const { PuterExecModule } = require("./src/modules/puterexec/PuterExecModule.js");
const { MailModule } = require("./src/modules/mail/MailModule.js");
const { ConvertModule } = require("./src/modules/convert/ConvertModule.js");
const { CaptchaModule } = require("./src/modules/captcha/CaptchaModule.js");
module.exports = {
helloworld: () => {
@@ -61,6 +62,7 @@ module.exports = {
WebModule,
TemplateModule,
AppsModule,
CaptchaModule,
],
// Pre-built modules
@@ -76,6 +78,7 @@ module.exports = {
InternetModule,
MailModule,
ConvertModule,
CaptchaModule,
// Development modules
PerfMonModule,

View File

@@ -24,7 +24,7 @@
"@smithy/node-http-handler": "^2.2.2",
"args": "^5.0.3",
"aws-sdk": "^2.1383.0",
"axios": "^1.4.0",
"axios": "^1.8.2",
"bcrypt": "^5.1.0",
"better-sqlite3": "^11.9.0",
"busboy": "^1.6.0",
@@ -73,6 +73,7 @@
"ssh2": "^1.13.0",
"string-hash": "^1.1.3",
"string-length": "^6.0.0",
"svg-captcha": "^1.4.0",
"svgo": "^3.0.2",
"tiktoken": "^1.0.16",
"together-ai": "^0.6.0-alpha.4",

View File

@@ -483,7 +483,17 @@ module.exports = class APIError {
'not_yet_supported': {
status: 400,
message: ({ message }) => message,
}
},
// Captcha errors
'captcha_required': {
status: 400,
message: ({ message }) => message || 'Captcha verification required',
},
'captcha_invalid': {
status: 400,
message: ({ message }) => message || 'Invalid captcha response',
},
};
/**

View File

@@ -51,6 +51,13 @@ config.require_email_verification_to_publish_website = false;
config.kv_max_key_size = 1024;
config.kv_max_value_size = 400 * 1024;
// Captcha configuration
config.captcha = {
enabled: false, // Enable captcha by default
expirationTime: 10 * 60 * 1000, // 10 minutes default expiration time
difficulty: 'medium' // Default difficulty level
};
config.monitor = {
metricsInterval: 60000,
windowSize: 30,

View File

@@ -20,10 +20,12 @@
const { Kernel } = require("./Kernel");
const CoreModule = require("./CoreModule");
const { CaptchaModule } = require("./modules/captcha/CaptchaModule"); // Add CaptchaModule
const testlaunch = () => {
const k = new Kernel();
k.add_module(new CoreModule());
k.add_module(new CaptchaModule()); // Register the CaptchaModule
k.boot();
}

View File

@@ -0,0 +1,58 @@
// METADATA // {"ai-commented":{"service":"claude"}}
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
const { AdvancedBase } = require("@heyputer/putility");
const CaptchaService = require('./services/CaptchaService');
/**
* @class CaptchaModule
* @extends AdvancedBase
* @description Module that provides captcha verification functionality to protect
* against automated abuse, particularly for login and signup flows. Registers
* a CaptchaService for generating and verifying captchas as well as middlewares
* that can be used to protect routes and determine captcha requirements.
*/
class CaptchaModule extends AdvancedBase {
async install(context) {
console.log('DIAGNOSTIC: CaptchaModule.install - Start of method');
// Get services from context
const services = context.get('services');
if (!services) {
throw new Error('Services not available in context');
}
// Register the captcha service
console.log('DIAGNOSTIC: CaptchaModule.install - Before service registration');
services.registerService('captcha', CaptchaService);
console.log('DIAGNOSTIC: CaptchaModule.install - After service registration');
// Log the captcha service status
try {
const captchaService = services.get('captcha');
console.log(`Captcha service registered and ${captchaService.enabled ? 'enabled' : 'disabled'}`);
console.log('TOKENS_TRACKING: Retrieved CaptchaService instance with ID:', captchaService.serviceId);
} catch (error) {
console.error('Failed to get captcha service after registration:', error);
}
}
}
module.exports = { CaptchaModule };

View File

@@ -0,0 +1,73 @@
# Captcha Module
This module provides captcha verification functionality to protect against automated abuse, particularly for login and signup flows.
## Components
- **CaptchaModule.js**: Registers the service and middleware
- **CaptchaService.js**: Provides captcha generation and verification functionality
- **captcha-middleware.js**: Express middleware for protecting routes with captcha verification
## Integration
The CaptchaService is registered by the CaptchaModule and can be accessed by other services:
```javascript
const captchaService = services.get('captcha');
```
### Example Usage
```javascript
// Generate a captcha
const captcha = captchaService.generateCaptcha();
// captcha.token - The token to verify later
// captcha.image - SVG image data to display to the user
// Verify a captcha
const isValid = captchaService.verifyCaptcha(token, userAnswer);
```
## Configuration
The CaptchaService can be configured with the following options in the configuration file (`config.json`):
- `captcha.enabled`: Whether the captcha service is enabled (default: false)
- `captcha.expirationTime`: How long captcha tokens are valid in milliseconds (default: 10 minutes)
- `captcha.difficulty`: The difficulty level of the captcha ('easy', 'medium', 'hard') (default: 'medium')
These options are set in the main configuration file. For example:
```json
{
"services": {
"captcha": {
"enabled": false,
"expirationTime": 600000,
"difficulty": "medium"
}
}
}
```
### Development Configuration
For local development, you can disable captcha by creating or modifying your local configuration file (e.g., in `volatile/config/config.json` or using a profile configuration):
```json
{
"$version": "v1.1.0",
"$requires": [
"config.json"
],
"config_name": "local",
"services": {
"captcha": {
"enabled": false
}
}
}
```
These options are set when registering the service in CaptchaModule.js.

View File

@@ -0,0 +1,160 @@
# Captcha Middleware
This middleware provides captcha verification for routes that need protection against automated abuse.
## Middleware Components
The captcha system is now split into two middleware components:
1. **checkCaptcha**: Determines if captcha verification is required but doesn't perform verification.
2. **requireCaptcha**: Performs actual captcha verification based on the result from checkCaptcha.
This split allows frontend applications to know in advance whether captcha verification will be needed for a particular action.
## Usage Patterns
### Using Both Middlewares (Recommended)
For best user experience, use both middlewares together:
```javascript
const express = require('express');
const router = express.Router();
// Get both middleware components from the context
const { checkCaptcha, requireCaptcha } = context.get('captcha-middleware');
// Determine if captcha is required for this route
router.post('/login', checkCaptcha({ eventType: 'login' }), (req, res, next) => {
// Set a flag in the response so frontend knows if captcha is needed
res.locals.captchaRequired = req.captchaRequired;
next();
}, requireCaptcha(), (req, res) => {
// Handle login logic
// If captcha was required, it has been verified at this point
});
```
### Using Individual Middlewares
You can also access each middleware separately:
```javascript
const checkCaptcha = context.get('check-captcha-middleware');
const requireCaptcha = context.get('require-captcha-middleware');
```
### Using Only requireCaptcha (Legacy Mode)
For backward compatibility, you can still use only the requireCaptcha middleware:
```javascript
const requireCaptcha = context.get('require-captcha-middleware');
// Always require captcha for this route
router.post('/sensitive-route', requireCaptcha({ always: true }), (req, res) => {
// Route handler
});
// Conditionally require captcha based on extensions
router.post('/normal-route', requireCaptcha(), (req, res) => {
// Route handler
});
```
## Configuration Options
### checkCaptcha Options
- `always` (boolean): Always require captcha regardless of other factors
- `strictMode` (boolean): If true, fails closed on errors (more secure)
- `eventType` (string): Type of event for extensions (e.g., 'login', 'signup')
### requireCaptcha Options
- `strictMode` (boolean): If true, fails closed on errors (more secure)
## Frontend Integration
There are two ways to integrate with the frontend:
### 1. Using the checkCaptcha Result in API Responses
You can include the captcha requirement in API responses:
```javascript
router.get('/whoarewe', checkCaptcha({ eventType: 'login' }), (req, res) => {
res.json({
// Other environment information
captchaRequired: {
login: req.captchaRequired
}
});
});
```
### 2. Setting GUI Parameters
For PuterHomepageService, you can add captcha requirements to GUI parameters:
```javascript
// In PuterHomepageService.js
gui_params: {
// Other parameters
captchaRequired: {
login: req.captchaRequired
}
}
```
## Client-Side Integration
To integrate with the captcha middleware, the client needs to:
1. Check if captcha is required for the action (using /whoarewe or GUI parameters)
2. If required, call the `/api/captcha/generate` endpoint to get a captcha token and image
3. Display the captcha image to the user and collect their answer
4. Include the captcha token and answer in the request body:
```javascript
// Example client-side code
async function submitWithCaptcha(formData) {
// Check if captcha is required
const envInfo = await fetch('/api/whoarewe').then(r => r.json());
if (envInfo.captchaRequired?.login) {
// Get and display captcha to user
const captcha = await getCaptchaFromServer();
showCaptchaToUser(captcha);
// Add captcha token and answer to the form data
formData.captchaToken = captcha.token;
formData.captchaAnswer = await getUserCaptchaAnswer();
}
// Submit the form
const response = await fetch('/api/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(formData)
});
// Handle response
const data = await response.json();
if (response.status === 400 && data.error === 'captcha_required') {
// Show captcha to the user if not already shown
showCaptcha();
}
}
```
## Error Handling
The middleware will throw the following errors:
- `captcha_required`: When captcha verification is required but no token or answer was provided.
- `captcha_invalid`: When the provided captcha answer is incorrect.
These errors can be caught by the API error handler and returned to the client.

View File

@@ -0,0 +1,134 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
const APIError = require("../../../api/APIError");
const { Context } = require("../../../util/context");
/**
* Middleware that checks if captcha verification is required
* This is the "first half" of the captcha verification process
* It determines if verification is needed but doesn't perform verification
*
* @param {Object} options - Configuration options
* @param {boolean} [options.strictMode=true] - If true, fails closed on errors (more secure)
* @returns {Function} Express middleware function
*/
const checkCaptcha = ({ svc_captcha }) => async (req, res, next) => {
// Get services from the Context
const services = Context.get('services');
if ( ! svc_captcha.enabled ) {
req.captchaRequired = false;
return next();
}
const svc_event = services.get('event');
const event = {
// By default, captcha always appears if enabled
required: true,
};
await svc_event.emit('captcha.check', event);
// Set captcha requirement based on service status
req.captchaRequired = event.required;
next();
};
/**
* Middleware that requires captcha verification
* This is the "second half" of the captcha verification process
* It uses the result from checkCaptcha to determine if verification is needed
*
* @param {Object} options - Configuration options
* @param {boolean} [options.strictMode=true] - If true, fails closed on errors (more secure)
* @returns {Function} Express middleware function
*/
const requireCaptcha = (options = {}) => async (req, res, next) => {
if ( ! req.captchaRequired ) {
return next();
}
const services = Context.get('services');
try {
let captchaService;
try {
captchaService = services.get('captcha');
} catch (error) {
console.warn('Captcha verification: required service not available', error);
return next(APIError.create('internal_error', null, {
message: 'Captcha service unavailable',
status: 503
}));
}
// Fail closed if captcha service doesn't exist or isn't properly initialized
if (!captchaService || typeof captchaService.verifyCaptcha !== 'function') {
return next(APIError.create('internal_error', null, {
message: 'Captcha service misconfigured',
status: 500
}));
}
// Check for captcha token and answer in request
const captchaToken = req.body.captchaToken;
const captchaAnswer = req.body.captchaAnswer;
if (!captchaToken || !captchaAnswer) {
return next(APIError.create('captcha_required', null, {
message: 'Captcha verification required',
status: 400
}));
}
// Verify the captcha
let isValid;
try {
isValid = captchaService.verifyCaptcha(captchaToken, captchaAnswer);
} catch (verifyError) {
console.error('Captcha verification: threw an error', verifyError);
return next(APIError.create('captcha_invalid', null, {
message: 'Captcha verification failed',
status: 400
}));
}
// Check verification result
if (!isValid) {
return next(APIError.create('captcha_invalid', null, {
message: 'Invalid captcha response',
status: 400
}));
}
// Captcha verified successfully, continue
next();
} catch (error) {
console.error('Captcha verification: unexpected error', error);
return next(APIError.create('internal_error', null, {
message: 'Captcha verification failed',
status: 500
}));
}
};
module.exports = {
checkCaptcha,
requireCaptcha
};

View File

@@ -0,0 +1,674 @@
// METADATA // {"ai-commented":{"service":"claude"}}
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
const BaseService = require('../../../services/BaseService');
const { Endpoint } = require('../../../util/expressutil');
const { checkCaptcha } = require('../middleware/captcha-middleware');
/**
* @class CaptchaService
* @extends BaseService
* @description Service that provides captcha generation and verification functionality
* to protect against automated abuse. Uses svg-captcha for generation and maintains
* a token-based verification system.
*/
class CaptchaService extends BaseService {
/**
* Initializes the captcha service with configuration and storage
*/
async _construct() {
console.log('DIAGNOSTIC: CaptchaService._construct called');
// Load dependencies
this.crypto = require('crypto');
this.svgCaptcha = require('svg-captcha');
// In-memory token storage with expiration
this.captchaTokens = new Map();
// Service instance diagnostic tracking
this.serviceId = Math.random().toString(36).substring(2, 10);
this.requestCounter = 0;
console.log('TOKENS_TRACKING: CaptchaService instance created with ID:', this.serviceId);
console.log('TOKENS_TRACKING: Process ID:', process.pid);
// Get configuration from service config
this.enabled = this.config.enabled === true;
this.expirationTime = this.config.expirationTime || (10 * 60 * 1000); // 10 minutes default
this.difficulty = this.config.difficulty || 'medium';
this.testMode = this.config.testMode === true;
console.log('CAPTCHA DIAGNOSTIC: Service initialized with config:', {
enabled: this.enabled,
expirationTime: this.expirationTime,
difficulty: this.difficulty,
testMode: this.testMode
});
// Add a static test token for diagnostic purposes
this.captchaTokens.set('test-static-token', {
text: 'testanswer',
expiresAt: Date.now() + (365 * 24 * 60 * 60 * 1000) // 1 year
});
// Flag to track if endpoints are registered
this.endpointsRegistered = false;
}
async ['__on_install.middlewares.context-aware'] (_, { app }) {
// Add express middleware
app.use(checkCaptcha({ svc_captcha: this }));
}
/**
* Sets up API endpoints and cleanup tasks
*/
async _init() {
console.log('TOKENS_TRACKING: CaptchaService._init called. Service ID:', this.serviceId);
if (!this.enabled) {
this.log.info('Captcha service is disabled');
return;
}
// Set up periodic cleanup
this.cleanupInterval = setInterval(() => this.cleanupExpiredTokens(), 15 * 60 * 1000);
// Register endpoints if not already done
if (!this.endpointsRegistered) {
this.registerEndpoints();
this.endpointsRegistered = true;
}
}
/**
* Cleanup method called when service is being destroyed
*/
async _destroy() {
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval);
}
this.captchaTokens.clear();
}
/**
* Registers the captcha API endpoints with the web service
* @private
*/
registerEndpoints() {
if (this.endpointsRegistered) {
return;
}
try {
// Try to get the web service
let webService = null;
try {
webService = this.services.get('web-service');
} catch (error) {
// Web service not available, try web-server
try {
webService = this.services.get('web-server');
} catch (innerError) {
this.log.warn('Neither web-service nor web-server are available yet');
return;
}
}
if (!webService || !webService.app) {
this.log.warn('Web service found but app is not available');
return;
}
const app = webService.app;
const api = this.require('express').Router();
app.use('/api/captcha', api);
// Generate captcha endpoint
Endpoint({
route: '/generate',
methods: ['GET'],
handler: async (req, res) => {
const captcha = this.generateCaptcha();
res.json({
token: captcha.token,
image: captcha.data
});
},
}).attach(api);
// Verify captcha endpoint
Endpoint({
route: '/verify',
methods: ['POST'],
handler: (req, res) => {
const { token, answer } = req.body;
if (!token || !answer) {
return res.status(400).json({
valid: false,
error: 'Missing token or answer'
});
}
const isValid = this.verifyCaptcha(token, answer);
res.json({ valid: isValid });
},
}).attach(api);
// Special endpoint for automated testing
// This should be disabled in production
if (this.testMode) {
app.post('/api/captcha/create-test-token', (req, res) => {
try {
const { token, answer } = req.body;
if (!token || !answer) {
return res.status(400).json({
error: 'Missing token or answer'
});
}
// Store the test token with the provided answer
this.captchaTokens.set(token, {
text: answer.toLowerCase(),
expiresAt: Date.now() + this.expirationTime
});
this.log.debug(`Created test token: ${token} with answer: ${answer}`);
res.json({ success: true });
} catch (error) {
this.log.error(`Error creating test token: ${error.message}`);
res.status(500).json({ error: 'Failed to create test token' });
}
});
}
// Diagnostic endpoint - should be used carefully and only during debugging
app.get('/api/captcha/diagnostic', (req, res) => {
try {
// Get information about the current state
const diagnosticInfo = {
serviceEnabled: this.enabled,
difficulty: this.difficulty,
expirationTime: this.expirationTime,
testMode: this.testMode,
activeTokenCount: this.captchaTokens.size,
serviceId: this.serviceId,
processId: process.pid,
requestCounter: this.requestCounter,
hasStaticTestToken: this.captchaTokens.has('test-static-token'),
tokensState: Array.from(this.captchaTokens).map(([token, data]) => ({
tokenPrefix: token.substring(0, 8) + '...',
expiresAt: new Date(data.expiresAt).toISOString(),
expired: data.expiresAt < Date.now(),
expectedAnswer: data.text
}))
};
res.json(diagnosticInfo);
} catch (error) {
this.log.error(`Error in diagnostic endpoint: ${error.message}`);
res.status(500).json({ error: 'Diagnostic error' });
}
});
// Advanced token debugging endpoint - allows testing
app.get('/api/captcha/debug-tokens', (req, res) => {
try {
// Check if we're the same service instance
const currentTimestamp = Date.now();
const currentTokens = Array.from(this.captchaTokens.keys()).map(t => t.substring(0, 8));
// Create a test token that won't expire soon
const debugToken = 'debug-' + this.crypto.randomBytes(8).toString('hex');
const debugAnswer = 'test123';
this.captchaTokens.set(debugToken, {
text: debugAnswer,
expiresAt: currentTimestamp + (60 * 60 * 1000) // 1 hour
});
// Information about the current service instance
const serviceInfo = {
message: 'Debug token created - use for testing captcha validation',
serviceId: this.serviceId,
debugToken: debugToken,
debugAnswer: debugAnswer,
tokensBefore: currentTokens,
tokensAfter: Array.from(this.captchaTokens.keys()).map(t => t.substring(0, 8)),
currentTokenCount: this.captchaTokens.size,
timestamp: currentTimestamp,
processId: process.pid
};
res.json(serviceInfo);
} catch (error) {
this.log.error(`Error in debug-tokens endpoint: ${error.message}`);
res.status(500).json({ error: 'Debug token creation error' });
}
});
// Configuration verification endpoint
app.get('/api/captcha/config-status', (req, res) => {
try {
// Information about configuration states
const configInfo = {
serviceEnabled: this.enabled,
serviceDifficulty: this.difficulty,
configSource: 'Service configuration',
centralConfig: {
enabled: this.enabled,
difficulty: this.difficulty,
expirationTime: this.expirationTime,
testMode: this.testMode
},
usingCentralizedConfig: true,
configConsistency: this.enabled === (this.enabled === true),
serviceId: this.serviceId,
processId: process.pid
};
res.json(configInfo);
} catch (error) {
this.log.error(`Error in config-status endpoint: ${error.message}`);
res.status(500).json({ error: 'Configuration status error' });
}
});
// Test endpoint to validate token lifecycle
app.get('/api/captcha/test-lifecycle', (req, res) => {
try {
console.log('TOKENS_TRACKING: Running token lifecycle test. Service ID:', this.serviceId);
console.log('TOKENS_TRACKING: Initial token count:', this.captchaTokens.size);
// Create a test captcha
const testText = 'test123';
const testToken = 'lifecycle-' + this.crypto.randomBytes(16).toString('hex');
// Store the test token
this.captchaTokens.set(testToken, {
text: testText,
expiresAt: Date.now() + this.expirationTime
});
console.log('TOKENS_TRACKING: Test token stored. New token count:', this.captchaTokens.size);
// Verify the token exists
const tokenExists = this.captchaTokens.has(testToken);
console.log('TOKENS_TRACKING: Test token exists in map:', tokenExists);
// Try to verify with correct answer
const correctVerification = this.verifyCaptcha(testToken, testText);
console.log('TOKENS_TRACKING: Verification with correct answer result:', correctVerification);
// Check if token was deleted after verification
const tokenAfterVerification = this.captchaTokens.has(testToken);
console.log('TOKENS_TRACKING: Token exists after verification:', tokenAfterVerification);
// Create another test token
const testToken2 = 'lifecycle2-' + this.crypto.randomBytes(16).toString('hex');
// Store the test token
this.captchaTokens.set(testToken2, {
text: testText,
expiresAt: Date.now() + this.expirationTime
});
console.log('TOKENS_TRACKING: Second test token stored. Token count:', this.captchaTokens.size);
res.json({
message: 'Token lifecycle test completed',
serviceId: this.serviceId,
initialTokens: this.captchaTokens.size - 2, // minus the two we added
tokenCreated: true,
tokenExisted: tokenExists,
verificationResult: correctVerification,
tokenRemovedAfterVerification: !tokenAfterVerification,
secondTokenCreated: this.captchaTokens.has(testToken2),
processId: process.pid
});
} catch (error) {
console.error('TOKENS_TRACKING: Error in test-lifecycle endpoint:', error);
res.status(500).json({ error: 'Test lifecycle error' });
}
});
this.endpointsRegistered = true;
this.log.info('Captcha service endpoints registered successfully');
// Emit an event that captcha service is ready
try {
const eventService = this.services.get('event');
if (eventService) {
eventService.emit('service-ready', 'captcha');
}
} catch (error) {
// Ignore errors with event service
}
} catch (error) {
this.log.warn(`Could not register captcha endpoints: ${error.message}`);
}
}
/**
* Generates a new captcha with a unique token
* @returns {Object} Object containing token and SVG image
*/
generateCaptcha() {
console.log('====== CAPTCHA GENERATION DIAGNOSTIC ======');
console.log('TOKENS_TRACKING: generateCaptcha called. Service ID:', this.serviceId);
console.log('TOKENS_TRACKING: Token map size before generation:', this.captchaTokens.size);
console.log('TOKENS_TRACKING: Static test token exists:', this.captchaTokens.has('test-static-token'));
// Increment request counter for diagnostics
this.requestCounter++;
console.log('TOKENS_TRACKING: Request counter value:', this.requestCounter);
console.log('generateCaptcha called, service enabled:', this.enabled);
if (!this.enabled) {
console.log('Generation SKIPPED: Captcha service is disabled');
throw new Error('Captcha service is disabled');
}
// Configure captcha options based on difficulty
const options = this._getCaptchaOptions();
console.log('Using captcha options for difficulty:', this.difficulty);
// Generate the captcha
const captcha = this.svgCaptcha.create(options);
console.log('Captcha created with text:', captcha.text);
// Generate a unique token
const token = this.crypto.randomBytes(32).toString('hex');
console.log('Generated token:', token.substring(0, 8) + '...');
// Store token with captcha text and expiration
const expirationTime = Date.now() + this.expirationTime;
console.log('Token will expire at:', new Date(expirationTime));
console.log('TOKENS_TRACKING: Token map size before storing new token:', this.captchaTokens.size);
this.captchaTokens.set(token, {
text: captcha.text.toLowerCase(),
expiresAt: expirationTime
});
console.log('TOKENS_TRACKING: Token map size after storing new token:', this.captchaTokens.size);
console.log('Token stored in captchaTokens. Current token count:', this.captchaTokens.size);
this.log.debug(`Generated captcha with token: ${token}`);
return {
token: token,
data: captcha.data
};
}
/**
* Verifies a captcha answer against a stored token
* @param {string} token - The captcha token
* @param {string} userAnswer - The user's answer to verify
* @returns {boolean} Whether the answer is valid
*/
verifyCaptcha(token, userAnswer) {
console.log('====== CAPTCHA SERVICE VERIFICATION DIAGNOSTIC ======');
console.log('TOKENS_TRACKING: verifyCaptcha called. Service ID:', this.serviceId);
console.log('TOKENS_TRACKING: Request counter during verification:', this.requestCounter);
console.log('TOKENS_TRACKING: Static test token exists:', this.captchaTokens.has('test-static-token'));
console.log('TOKENS_TRACKING: Trying to verify token:', token ? token.substring(0, 8) + '...' : 'undefined');
console.log('verifyCaptcha called with token:', token ? token.substring(0, 8) + '...' : 'undefined');
console.log('userAnswer:', userAnswer);
console.log('Service enabled:', this.enabled);
console.log('Number of tokens in captchaTokens:', this.captchaTokens.size);
// Service health check
this._checkServiceHealth();
if (!this.enabled) {
console.log('Verification SKIPPED: Captcha service is disabled');
this.log.warn('Captcha verification attempted while service is disabled');
throw new Error('Captcha service is disabled');
}
// Get captcha data for token
const captchaData = this.captchaTokens.get(token);
console.log('Captcha data found for token:', !!captchaData);
// Invalid token or expired
if (!captchaData) {
console.log('Verification FAILED: No data found for this token');
console.log('TOKENS_TRACKING: Available tokens (first 8 chars):',
Array.from(this.captchaTokens.keys()).map(t => t.substring(0, 8)));
this.log.debug(`Invalid captcha token: ${token}`);
return false;
}
if (captchaData.expiresAt < Date.now()) {
console.log('Verification FAILED: Token expired at:', new Date(captchaData.expiresAt));
this.log.debug(`Expired captcha token: ${token}`);
return false;
}
// Normalize and compare answers
const normalizedUserAnswer = userAnswer.toLowerCase().trim();
console.log('Expected answer:', captchaData.text);
console.log('User answer (normalized):', normalizedUserAnswer);
const isValid = captchaData.text === normalizedUserAnswer;
console.log('Answer comparison result:', isValid);
// Remove token after verification (one-time use)
this.captchaTokens.delete(token);
console.log('Token removed after verification (one-time use)');
console.log('TOKENS_TRACKING: Token map size after removing used token:', this.captchaTokens.size);
this.log.debug(`Verified captcha token: ${token}, valid: ${isValid}`);
return isValid;
}
/**
* Simple diagnostic method to check service health
* @private
*/
_checkServiceHealth() {
console.log('TOKENS_TRACKING: Service health check. ID:', this.serviceId, 'Token count:', this.captchaTokens.size);
return true;
}
/**
* Removes expired captcha tokens from memory
*/
cleanupExpiredTokens() {
console.log('TOKENS_TRACKING: Running token cleanup. Service ID:', this.serviceId);
console.log('TOKENS_TRACKING: Token map size before cleanup:', this.captchaTokens.size);
const now = Date.now();
let expiredCount = 0;
let validCount = 0;
// Log all tokens before cleanup
console.log('TOKENS_TRACKING: Current tokens before cleanup:');
for (const [token, data] of this.captchaTokens.entries()) {
const isExpired = data.expiresAt < now;
console.log(`TOKENS_TRACKING: Token ${token.substring(0, 8)}... expires: ${new Date(data.expiresAt).toISOString()}, expired: ${isExpired}`);
if (isExpired) {
expiredCount++;
} else {
validCount++;
}
}
// Only do the actual cleanup if we found expired tokens
if (expiredCount > 0) {
console.log(`TOKENS_TRACKING: Found ${expiredCount} expired tokens to remove and ${validCount} valid tokens to keep`);
// Clean up expired tokens
for (const [token, data] of this.captchaTokens.entries()) {
if (data.expiresAt < now) {
this.captchaTokens.delete(token);
console.log(`TOKENS_TRACKING: Deleted expired token: ${token.substring(0, 8)}...`);
}
}
} else {
console.log('TOKENS_TRACKING: No expired tokens found, skipping cleanup');
}
// Skip cleanup for the static test token
if (this.captchaTokens.has('test-static-token')) {
console.log('TOKENS_TRACKING: Static test token still exists after cleanup');
} else {
console.log('TOKENS_TRACKING: WARNING - Static test token was removed during cleanup');
// Restore the static test token for diagnostic purposes
this.captchaTokens.set('test-static-token', {
text: 'testanswer',
expiresAt: Date.now() + (365 * 24 * 60 * 60 * 1000) // 1 year
});
console.log('TOKENS_TRACKING: Restored static test token');
}
console.log('TOKENS_TRACKING: Token map size after cleanup:', this.captchaTokens.size);
if (expiredCount > 0) {
this.log.debug(`Cleaned up ${expiredCount} expired captcha tokens`);
}
}
/**
* Gets captcha options based on the configured difficulty
* @private
* @returns {Object} Captcha configuration options
*/
_getCaptchaOptions() {
const baseOptions = {
size: 6, // Default captcha length
ignoreChars: '0o1ilI', // Characters to avoid (confusing)
noise: 2, // Lines to add as noise
color: true,
background: '#f0f0f0'
};
switch (this.difficulty) {
case 'easy':
return {
...baseOptions,
size: 4,
width: 150,
height: 50,
noise: 1
};
case 'hard':
return {
...baseOptions,
size: 7,
width: 200,
height: 60,
noise: 3
};
case 'medium':
default:
return {
...baseOptions,
width: 180,
height: 50
};
}
}
/**
* Verifies that the captcha service is properly configured and working
* This is used during initialization and can be called to check system status
* @returns {boolean} Whether the service is properly configured and functioning
*/
verifySelfTest() {
try {
// Ensure required dependencies are available
if (!this.svgCaptcha) {
this.log.error('Captcha service self-test failed: svg-captcha module not available');
return false;
}
if (!this.enabled) {
this.log.warn('Captcha service self-test failed: service is disabled');
return false;
}
// Validate configuration
if (!this.expirationTime || typeof this.expirationTime !== 'number') {
this.log.error('Captcha service self-test failed: invalid expiration time configuration');
return false;
}
// Basic functionality test - generate a test captcha and verify storage
const testToken = 'test-' + this.crypto.randomBytes(8).toString('hex');
const testText = 'testcaptcha';
// Store the test captcha
this.captchaTokens.set(testToken, {
text: testText,
expiresAt: Date.now() + this.expirationTime
});
// Verify the test captcha
const correctVerification = this.verifyCaptcha(testToken, testText);
// Check if verification worked and token was removed
if (!correctVerification || this.captchaTokens.has(testToken)) {
this.log.error('Captcha service self-test failed: verification test failed');
return false;
}
this.log.debug('Captcha service self-test passed');
return true;
} catch (error) {
this.log.error(`Captcha service self-test failed with error: ${error.message}`);
return false;
}
}
/**
* Returns the service's diagnostic information
* @returns {Object} Diagnostic information about the service
*/
getDiagnosticInfo() {
return {
serviceId: this.serviceId,
enabled: this.enabled,
tokenCount: this.captchaTokens.size,
requestCounter: this.requestCounter,
config: {
enabled: this.enabled,
difficulty: this.difficulty,
expirationTime: this.expirationTime,
testMode: this.testMode
},
processId: process.pid,
testTokenExists: this.captchaTokens.has('test-static-token')
};
}
}
// Export both as a named export and as a default export for compatibility
module.exports = CaptchaService;
module.exports.CaptchaService = CaptchaService;

View File

@@ -22,6 +22,7 @@ const router = new express.Router();
const { get_user, body_parser_error_handler } = require('../helpers');
const config = require('../config');
const { DB_WRITE } = require('../services/database/consts');
const { requireCaptcha } = require('../modules/captcha/middleware/captcha-middleware');
const complete_ = async ({ req, res, user }) => {
@@ -55,7 +56,20 @@ const complete_ = async ({ req, res, user }) => {
// -----------------------------------------------------------------------//
// POST /file
// -----------------------------------------------------------------------//
router.post('/login', express.json(), body_parser_error_handler, async (req, res, next)=>{
router.post('/login', express.json(), body_parser_error_handler,
// Add diagnostic middleware to log captcha data
(req, res, next) => {
console.log('====== LOGIN CAPTCHA DIAGNOSTIC ======');
console.log('LOGIN REQUEST RECEIVED with captcha data:', {
hasCaptchaToken: !!req.body.captchaToken,
hasCaptchaAnswer: !!req.body.captchaAnswer,
captchaToken: req.body.captchaToken ? req.body.captchaToken.substring(0, 8) + '...' : undefined,
captchaAnswer: req.body.captchaAnswer
});
next();
},
requireCaptcha({ strictMode: true, eventType: 'login' }),
async (req, res, next)=>{
// either api. subdomain or no subdomain
if(require('../helpers').subdomain(req) !== 'api' && require('../helpers').subdomain(req) !== '')
next();
@@ -151,7 +165,7 @@ router.post('/login', express.json(), body_parser_error_handler, async (req, res
})
router.post('/login/otp', express.json(), body_parser_error_handler, async (req, res, next) => {
router.post('/login/otp', express.json(), body_parser_error_handler, requireCaptcha({ strictMode: true, eventType: 'login_otp' }), async (req, res, next) => {
// either api. subdomain or no subdomain
if(require('../helpers').subdomain(req) !== 'api' && require('../helpers').subdomain(req) !== '')
next();
@@ -207,7 +221,7 @@ router.post('/login/otp', express.json(), body_parser_error_handler, async (req,
return await complete_({ req, res, user });
});
router.post('/login/recovery-code', express.json(), body_parser_error_handler, async (req, res, next) => {
router.post('/login/recovery-code', express.json(), body_parser_error_handler, requireCaptcha({ strictMode: true, eventType: 'login_recovery' }), async (req, res, next) => {
// either api. subdomain or no subdomain
if(require('../helpers').subdomain(req) !== 'api' && require('../helpers').subdomain(req) !== '')
next();

View File

@@ -25,6 +25,7 @@ const { DB_WRITE } = require('../services/database/consts');
const { generate_identifier } = require('../util/identifier');
const { is_temp_users_disabled: lazy_temp_users,
is_user_signup_disabled: lazy_user_signup } = require("../helpers")
const { requireCaptcha } = require('../modules/captcha/middleware/captcha-middleware');
async function generate_random_username () {
let username;
@@ -48,6 +49,7 @@ module.exports = eggspress(['/signup'], {
res.status(400).send(`email username mismatch; please provide a password`);
}
},
mw: [requireCaptcha({ strictMode: true, eventType: 'signup' })], // Conditionally require captcha for signup
}, async (req, res, next) => {
// either api. subdomain or no subdomain
if(require('../helpers').subdomain(req) !== 'api' && require('../helpers').subdomain(req) !== '')

View File

@@ -21,6 +21,7 @@ const { PathBuilder } = require("../util/pathutil");
const BaseService = require("./BaseService");
const {is_valid_url} = require('../helpers');
const { Endpoint } = require("../util/expressutil");
const { Context } = require("../util/context");
/**
* PuterHomepageService serves the initial HTML page that loads the Puter GUI
@@ -72,10 +73,23 @@ class PuterHomepageService extends BaseService {
route: '/whoarewe',
methods: ['GET'],
handler: async (req, res) => {
res.json({
// Get basic configuration information
const responseData = {
disable_user_signup: this.global_config.disable_user_signup,
disable_temp_users: this.global_config.disable_temp_users,
});
environmentInfo: {
env: this.global_config.env,
version: process.env.VERSION || 'development'
}
};
// Add captcha requirement information
responseData.captchaRequired = {
login: req.captchaRequired,
signup: req.captchaRequired,
};
res.json(responseData);
}
}).attach(app);
}
@@ -106,6 +120,12 @@ class PuterHomepageService extends BaseService {
}));
}
// checkCaptcha middleware (in CaptchaService) sets req.captchaRequired
const captchaRequired = {
login: req.captchaRequired,
signup: req.captchaRequired,
};
return res.send(this.generate_puter_page_html({
env: config.env,
@@ -144,6 +164,8 @@ class PuterHomepageService extends BaseService {
long_description: config.long_description,
disable_temp_users: config.disable_temp_users,
co_isolation_enabled: req.co_isolation_enabled,
// Add captcha requirements to GUI parameters
captchaRequired: captchaRequired,
},
}));
}

View File

@@ -0,0 +1,268 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
const { describe, it, beforeEach, afterEach } = require('mocha');
const { expect } = require('chai');
const sinon = require('sinon');
// Mock the Context and services
const Context = {
get: sinon.stub()
};
// Mock the extension service
class ExtensionService {
constructor() {
this.extensions = new Map();
this.eventHandlers = new Map();
}
registerExtension(name, extension) {
this.extensions.set(name, extension);
}
on(event, handler) {
if (!this.eventHandlers.has(event)) {
this.eventHandlers.set(event, []);
}
this.eventHandlers.get(event).push(handler);
}
async emit(event, data) {
const handlers = this.eventHandlers.get(event) || [];
for (const handler of handlers) {
await handler(data);
}
}
}
describe('Extension Integration with Captcha', () => {
let extensionService, captchaService, services;
beforeEach(() => {
// Reset stubs
sinon.reset();
// Create fresh instances
extensionService = new ExtensionService();
captchaService = {
enabled: true,
verifyCaptcha: sinon.stub()
};
services = {
get: sinon.stub()
};
// Configure service mocks
services.get.withArgs('extension').returns(extensionService);
services.get.withArgs('captcha').returns(captchaService);
// Configure Context mock
Context.get.withArgs('services').returns(services);
});
describe('Extension Event Handling', () => {
it('should allow extensions to require captcha via event handler', async () => {
// Setup - create a test extension that requires captcha
const testExtension = {
name: 'test-extension',
onCaptchaValidate: async (event) => {
if (event.type === 'login' && event.ip === '1.2.3.4') {
event.require = true;
}
}
};
// Register extension and event handler
extensionService.registerExtension(testExtension.name, testExtension);
extensionService.on('captcha.validate', testExtension.onCaptchaValidate);
// Test event emission
const eventData = {
type: 'login',
ip: '1.2.3.4',
require: false
};
await extensionService.emit('captcha.validate', eventData);
// Assert
expect(eventData.require).to.be.true;
});
it('should allow extensions to disable captcha requirement', async () => {
// Setup - create a test extension that disables captcha
const testExtension = {
name: 'test-extension',
onCaptchaValidate: async (event) => {
if (event.type === 'login' && event.ip === 'trusted-ip') {
event.require = false;
}
}
};
// Register extension and event handler
extensionService.registerExtension(testExtension.name, testExtension);
extensionService.on('captcha.validate', testExtension.onCaptchaValidate);
// Test event emission
const eventData = {
type: 'login',
ip: 'trusted-ip',
require: true
};
await extensionService.emit('captcha.validate', eventData);
// Assert
expect(eventData.require).to.be.false;
});
it('should handle multiple extensions modifying captcha requirement', async () => {
// Setup - create two test extensions with different rules
const extension1 = {
name: 'extension-1',
onCaptchaValidate: async (event) => {
if (event.type === 'login') {
event.require = true;
}
}
};
const extension2 = {
name: 'extension-2',
onCaptchaValidate: async (event) => {
if (event.ip === 'trusted-ip') {
event.require = false;
}
}
};
// Register extensions and event handlers
extensionService.registerExtension(extension1.name, extension1);
extensionService.registerExtension(extension2.name, extension2);
extensionService.on('captcha.validate', extension1.onCaptchaValidate);
extensionService.on('captcha.validate', extension2.onCaptchaValidate);
// Test event emission - extension2 should override extension1
const eventData = {
type: 'login',
ip: 'trusted-ip',
require: false
};
await extensionService.emit('captcha.validate', eventData);
// Assert
expect(eventData.require).to.be.false;
});
it('should handle extension errors gracefully', async () => {
// Setup - create a test extension that throws an error
const testExtension = {
name: 'test-extension',
onCaptchaValidate: async () => {
throw new Error('Extension error');
}
};
// Register extension and event handler
extensionService.registerExtension(testExtension.name, testExtension);
extensionService.on('captcha.validate', testExtension.onCaptchaValidate);
// Test event emission
const eventData = {
type: 'login',
ip: '1.2.3.4',
require: false
};
// The emit should not throw
await extensionService.emit('captcha.validate', eventData);
// Assert - the original value should be preserved
expect(eventData.require).to.be.false;
});
});
describe('Backward Compatibility', () => {
it('should maintain backward compatibility with older extension APIs', async () => {
// Setup - create a test extension using the old API format
const legacyExtension = {
name: 'legacy-extension',
handleCaptcha: async (event) => {
event.require = true;
}
};
// Register legacy extension with old event name
extensionService.registerExtension(legacyExtension.name, legacyExtension);
extensionService.on('captcha.check', legacyExtension.handleCaptcha);
// Test both old and new event names
const eventData = {
type: 'login',
ip: '1.2.3.4',
require: false
};
// Should work with both old and new event names
await extensionService.emit('captcha.check', eventData);
await extensionService.emit('captcha.validate', eventData);
// Assert - the requirement should be set by the legacy extension
expect(eventData.require).to.be.true;
});
it('should support legacy extension configuration formats', async () => {
// Setup - create a test extension with legacy configuration
const legacyExtension = {
name: 'legacy-extension',
config: {
captcha: {
always: true,
types: ['login', 'signup']
}
},
onCaptchaValidate: async (event) => {
if (legacyExtension.config.captcha.types.includes(event.type)) {
event.require = legacyExtension.config.captcha.always;
}
}
};
// Register extension and event handler
extensionService.registerExtension(legacyExtension.name, legacyExtension);
extensionService.on('captcha.validate', legacyExtension.onCaptchaValidate);
// Test event emission
const eventData = {
type: 'login',
ip: '1.2.3.4',
require: false
};
await extensionService.emit('captcha.validate', eventData);
// Assert
expect(eventData.require).to.be.true;
});
});
});

View File

@@ -0,0 +1,279 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
const { describe, it, beforeEach, afterEach } = require('mocha');
const { expect } = require('chai');
const sinon = require('sinon');
// Mock the Context
const Context = {
get: sinon.stub()
};
// Mock the APIError
const APIError = {
create: sinon.stub().returns({ name: 'APIError' })
};
// Path is relative to where the test will be run
const { checkCaptcha, requireCaptcha } = require('../../../../src/modules/captcha/middleware/captcha-middleware');
describe('Captcha Middleware', () => {
let req, res, next, services, captchaService, eventService;
beforeEach(() => {
// Reset all stubs
sinon.reset();
// Mock request, response, and next function
req = {
ip: '127.0.0.1',
headers: {
'user-agent': 'test-agent'
},
body: {},
connection: {
remoteAddress: '127.0.0.1'
}
};
res = {
status: sinon.stub().returnsThis(),
json: sinon.stub().returnsThis()
};
next = sinon.stub();
// Mock services
captchaService = {
enabled: true,
verifyCaptcha: sinon.stub()
};
eventService = {
emit: sinon.stub().resolves()
};
services = {
get: sinon.stub()
};
// Configure service mocks
services.get.withArgs('captcha').returns(captchaService);
services.get.withArgs('event').returns(eventService);
// Configure Context mock
Context.get.withArgs('services').returns(services);
});
describe('checkCaptcha', () => {
it('should set captchaRequired to false when not required', async () => {
// Setup
const middleware = checkCaptcha({ strictMode: false });
// Test
await middleware(req, res, next);
// Assert
expect(req.captchaRequired).to.be.false;
expect(next.calledOnce).to.be.true;
});
it('should set captchaRequired to true when always option is true', async () => {
// Setup
const middleware = checkCaptcha({ always: true });
// Test
await middleware(req, res, next);
// Assert
expect(req.captchaRequired).to.be.true;
expect(next.calledOnce).to.be.true;
});
it('should set captchaRequired to true when requester.requireCaptcha is true', async () => {
// Setup
req.requester = { requireCaptcha: true };
const middleware = checkCaptcha();
// Test
await middleware(req, res, next);
// Assert
expect(req.captchaRequired).to.be.true;
expect(next.calledOnce).to.be.true;
});
it('should emit captcha.validate event with correct parameters', async () => {
// Setup
const middleware = checkCaptcha({ eventType: 'login' });
// Test
await middleware(req, res, next);
// Assert
expect(eventService.emit.calledOnce).to.be.true;
expect(eventService.emit.firstCall.args[0]).to.equal('captcha.validate');
const eventData = eventService.emit.firstCall.args[1];
expect(eventData.type).to.equal('login');
expect(eventData.ip).to.equal('127.0.0.1');
expect(eventData.userAgent).to.equal('test-agent');
expect(eventData.req).to.equal(req);
});
it('should respect extension decision to require captcha', async () => {
// Setup
eventService.emit.callsFake((event, data) => {
data.require = true;
return Promise.resolve();
});
const middleware = checkCaptcha({ strictMode: false });
// Test
await middleware(req, res, next);
// Assert
expect(req.captchaRequired).to.be.true;
expect(next.calledOnce).to.be.true;
});
it('should respect extension decision to not require captcha', async () => {
// Setup
eventService.emit.callsFake((event, data) => {
data.require = false;
return Promise.resolve();
});
const middleware = checkCaptcha({ always: true });
// Test
await middleware(req, res, next);
// Assert
expect(req.captchaRequired).to.be.false;
expect(next.calledOnce).to.be.true;
});
it('should default to strictMode value when services are not available', async () => {
// Setup
Context.get.withArgs('services').returns(null);
// Test with strictMode true
let middleware = checkCaptcha({ strictMode: true });
await middleware(req, res, next);
expect(req.captchaRequired).to.be.true;
// Reset
req = { headers: {}, connection: { remoteAddress: '127.0.0.1' } };
next = sinon.stub();
// Test with strictMode false
middleware = checkCaptcha({ strictMode: false });
await middleware(req, res, next);
expect(req.captchaRequired).to.be.false;
});
});
describe('requireCaptcha', () => {
it('should call next() when captchaRequired is false', async () => {
// Setup
req.captchaRequired = false;
const middleware = requireCaptcha();
// Test
await middleware(req, res, next);
// Assert
expect(next.calledOnce).to.be.true;
expect(next.firstCall.args.length).to.equal(0); // No error passed
});
it('should return error when captchaRequired is true but token/answer missing', async () => {
// Setup
req.captchaRequired = true;
const middleware = requireCaptcha();
// Test
await middleware(req, res, next);
// Assert
expect(next.calledOnce).to.be.true;
expect(next.firstCall.args.length).to.equal(1); // Error passed
expect(APIError.create.calledWith('captcha_required')).to.be.true;
});
it('should verify captcha when token and answer are provided', async () => {
// Setup
req.captchaRequired = true;
req.body.captchaToken = 'test-token';
req.body.captchaAnswer = 'test-answer';
captchaService.verifyCaptcha.returns(true);
const middleware = requireCaptcha();
// Test
await middleware(req, res, next);
// Assert
expect(captchaService.verifyCaptcha.calledWith('test-token', 'test-answer')).to.be.true;
expect(next.calledOnce).to.be.true;
expect(next.firstCall.args.length).to.equal(0); // No error passed
});
it('should return error when captcha verification fails', async () => {
// Setup
req.captchaRequired = true;
req.body.captchaToken = 'test-token';
req.body.captchaAnswer = 'test-answer';
captchaService.verifyCaptcha.returns(false);
const middleware = requireCaptcha();
// Test
await middleware(req, res, next);
// Assert
expect(captchaService.verifyCaptcha.calledWith('test-token', 'test-answer')).to.be.true;
expect(next.calledOnce).to.be.true;
expect(next.firstCall.args.length).to.equal(1); // Error passed
expect(APIError.create.calledWith('captcha_invalid')).to.be.true;
});
it('should handle errors during captcha verification', async () => {
// Setup
req.captchaRequired = true;
req.body.captchaToken = 'test-token';
req.body.captchaAnswer = 'test-answer';
captchaService.verifyCaptcha.throws(new Error('Verification error'));
const middleware = requireCaptcha();
// Test
await middleware(req, res, next);
// Assert
expect(captchaService.verifyCaptcha.calledWith('test-token', 'test-answer')).to.be.true;
expect(next.calledOnce).to.be.true;
expect(next.firstCall.args.length).to.equal(1); // Error passed
expect(APIError.create.calledWith('captcha_invalid')).to.be.true;
});
});
});

View File

@@ -12,6 +12,7 @@
},
"devDependencies": {
"@eslint/js": "^9.1.1",
"chai": "^4.3.7",
"chalk": "^4.1.0",
"clean-css": "^5.3.2",
"dotenv": "^16.4.5",
@@ -19,13 +20,15 @@
"express": "^4.18.2",
"globals": "^15.0.0",
"html-entities": "^2.3.3",
"jsdom": "^21.1.0",
"nodemon": "^3.1.0",
"sinon": "^15.0.1",
"uglify-js": "^3.17.4",
"webpack": "^5.88.2",
"webpack-cli": "^5.1.1"
},
"scripts": {
"test": "mocha ./packages/phoenix/test ./packages/phoenix/packages/contextlink/test",
"test": "mocha ./test/**/*.test.js",
"start=gui": "nodemon --exec \"node dev-server.js\" ",
"build": "node ./build.js",
"check-translations": "node tools/check-translations.js",

View File

@@ -0,0 +1,285 @@
/**
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
/**
* CaptchaView - A component for displaying and handling captcha challenges
*
* @param {Object} options - Configuration options
* @param {HTMLElement} options.container - The container element to attach the captcha to
* @param {Function} options.onReady - Callback when the captcha is ready
* @param {Function} options.onError - Callback for handling errors
* @param {boolean} options.required - Whether captcha is required (will not display if false)
* @param {Function} options.onRequiredChange - Callback when the required status changes
* @returns {Object} - Methods to interact with the captcha
*/
function CaptchaView(options = {}) {
// Internal state
const state = {
token: null,
image: null,
answer: '',
loading: false,
error: null,
container: options.container || document.createElement('div'),
required: options.required !== undefined ? options.required : true, // Default to required
initialized: false
};
// Create the initial DOM structure
const init = () => {
const container = state.container;
container.classList.add('captcha-view-container');
container.style.marginTop = '20px';
container.style.marginBottom = '20px';
// Add container CSS
container.style.display = state.required ? 'flex' : 'none';
container.style.flexDirection = 'column';
container.style.gap = '10px';
state.initialized = true;
// Render the initial HTML
render();
// Only fetch captcha if required
if (state.required) {
refresh();
}
};
// Set whether captcha is required
const setRequired = (required) => {
if (state.required === required) return; // No change
state.required = required;
if (state.initialized) {
// Update display
state.container.style.display = required ? 'flex' : 'none';
// If becoming required and no captcha loaded, fetch one
if (required && !state.token) {
refresh();
}
// Notify of change if callback provided
if (typeof options.onRequiredChange === 'function') {
options.onRequiredChange(required);
}
}
};
// Render the captcha HTML
const render = () => {
const container = state.container;
// Clear the container
container.innerHTML = '';
// Label
const label = document.createElement('label');
label.textContent = i18n('captcha_verification');
label.setAttribute('for', `captcha-input-${Date.now()}`);
container.appendChild(label);
// Captcha wrapper
const captchaWrapper = document.createElement('div');
captchaWrapper.classList.add('captcha-wrapper');
captchaWrapper.style.display = 'flex';
captchaWrapper.style.flexDirection = 'column';
captchaWrapper.style.gap = '10px';
container.appendChild(captchaWrapper);
// Captcha image and refresh button container
const imageContainer = document.createElement('div');
imageContainer.style.display = 'flex';
imageContainer.style.alignItems = 'center';
imageContainer.style.justifyContent = 'space-between';
imageContainer.style.gap = '10px';
imageContainer.style.border = '1px solid #ced7e1';
imageContainer.style.borderRadius = '4px';
imageContainer.style.padding = '10px';
captchaWrapper.appendChild(imageContainer);
// Captcha image
const imageElement = document.createElement('div');
imageElement.classList.add('captcha-image');
if (state.loading) {
imageElement.innerHTML = '<div style="display:flex;justify-content:center;align-items:center;height:50px;"><span style="font-size:14px;">Loading captcha...</span></div>';
} else if (state.error) {
imageElement.innerHTML = `<div style="color:red;padding:10px;">${state.error}</div>`;
} else if (state.image) {
imageElement.innerHTML = state.image;
// Make SVG responsive
const svgElement = imageElement.querySelector('svg');
if (svgElement) {
svgElement.style.width = '100%';
svgElement.style.height = 'auto';
}
} else {
imageElement.innerHTML = '<div style="display:flex;justify-content:center;align-items:center;height:50px;"><span style="font-size:14px;">No captcha loaded</span></div>';
}
imageContainer.appendChild(imageElement);
// Refresh button
const refreshButton = document.createElement('button');
refreshButton.classList.add('button', 'button-small');
refreshButton.innerHTML = '<i class="fas fa-sync-alt"></i>';
refreshButton.setAttribute('title', i18n('refresh_captcha'));
refreshButton.style.minWidth = '30px';
refreshButton.style.height = '30px';
refreshButton.setAttribute('type', 'button');
refreshButton.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
refresh();
});
imageContainer.appendChild(refreshButton);
// Input field
const inputField = document.createElement('input');
inputField.id = `captcha-input-${Date.now()}`;
inputField.classList.add('captcha-input');
inputField.type = 'text';
inputField.placeholder = i18n('enter_captcha_text');
inputField.setAttribute('autocomplete', 'off');
inputField.setAttribute('spellcheck', 'false');
inputField.setAttribute('autocorrect', 'off');
inputField.setAttribute('autocapitalize', 'off');
inputField.value = state.answer || '';
inputField.addEventListener('input', (e) => {
state.answer = e.target.value;
});
// Prevent Enter key from triggering refresh and allow it to submit the form
inputField.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
// Don't prevent default here - let Enter bubble up to the form
// Just make sure we don't refresh the captcha
e.stopPropagation();
}
});
captchaWrapper.appendChild(inputField);
// Helper text
const helperText = document.createElement('div');
helperText.classList.add('captcha-helper-text');
helperText.style.fontSize = '12px';
helperText.style.color = '#666';
helperText.textContent = i18n('captcha_case_sensitive');
captchaWrapper.appendChild(helperText);
};
// Fetch a new captcha
const refresh = async () => {
// Skip if not required
if (!state.required) {
return;
}
try {
state.loading = true;
state.error = null;
render();
const response = await fetch(window.gui_origin + '/api/captcha/generate');
if (!response.ok) {
throw new Error(`Failed to load captcha: ${response.status}`);
}
const data = await response.json();
state.token = data.token;
state.image = data.image;
state.loading = false;
render();
if (typeof options.onReady === 'function') {
options.onReady();
}
} catch (error) {
state.loading = false;
state.error = error.message || 'Failed to load captcha';
render();
if (typeof options.onError === 'function') {
options.onError(error);
}
}
};
// Public API
const api = {
/**
* Get the current captcha token
* @returns {string} The captcha token
*/
getToken: () => state.token,
/**
* Get the current captcha answer
* @returns {string} The user's answer
*/
getAnswer: () => state.answer,
/**
* Reset the captcha - clear answer and get a new challenge
*/
reset: () => {
state.answer = '';
refresh();
},
/**
* Get the container element
* @returns {HTMLElement} The container element
*/
getElement: () => state.container,
/**
* Check if captcha is required
* @returns {boolean} Whether captcha is required
*/
isRequired: () => state.required,
/**
* Set whether captcha is required
* @param {boolean} required - Whether captcha is required
*/
setRequired: setRequired
};
// Set initial required state from options
if (options.required !== undefined) {
state.required = options.required;
}
// Initialize the component
init();
return api;
}
export default CaptchaView;

View File

@@ -29,43 +29,61 @@ import StepView from './Components/StepView.js';
import Button from './Components/Button.js';
import RecoveryCodeEntryView from './Components/RecoveryCodeEntryView.js';
import play_startup_chime from '../helpers/play_startup_chime.js';
import CaptchaView from './Components/CaptchaView.js'
import { isCaptchaRequired } from '../helpers/captchaHelper.js';
async function UIWindowLogin(options){
options = options ?? {};
options.reload_on_success = options.reload_on_success ?? false;
options.has_head = options.has_head ?? true;
options.send_confirmation_code = options.send_confirmation_code ?? false;
options.show_password = options.show_password ?? false;
if(options.reload_on_success === undefined)
options.reload_on_success = true;
return new Promise(async (resolve) => {
const internal_id = window.uuidv4();
// Check if captcha is required for login
const captchaRequired = await isCaptchaRequired('login');
console.log('Login captcha required:', captchaRequired);
let h = ``;
h += `<div style="max-width: 500px; min-width: 340px;">`;
if(!options.has_head && options.show_close_button !== false)
h += `<div class="generic-close-window-button"> &times; </div>`;
h += `<div style="padding: 20px; border-bottom: 1px solid #ced7e1; width: 100%; box-sizing: border-box;">`;
// title
h += `<h1 class="login-form-title">${i18n('log_in')}</h1>`;
// login form
h += `<form class="login-form">`;
// error msg
h += `<div class="login-error-msg"></div>`;
// username/email
h += `<div style="overflow: hidden;">`;
h += `<label for="email_or_username-${internal_id}">${i18n('email_or_username')}</label>`;
h += `<input id="email_or_username-${internal_id}" class="email_or_username" type="text" name="email_or_username" spellcheck="false" autocorrect="off" autocapitalize="off" data-gramm_editor="false" autocomplete="username"/>`;
h += `<div style="max-width:100%; width:100%; height:100%; min-height:0; box-sizing:border-box; display:flex; flex-direction:column; justify-content:flex-start; align-items:stretch; padding:0; overflow:auto; color:var(--color-text);">`;
// logo
h += `<div class="logo-wrapper" style="display:flex; justify-content:center; padding:20px 20px 0 20px; margin-bottom: 0;">`;
h += `<img src="/dist/images/logo/logo.svg" style="height:45px;" />`;
h += `</div>`;
// title
h += `<div style="padding:10px 20px; text-align:center; margin-bottom:0;">`;
h += `<h1 style="font-size:18px; margin-bottom:0;">${i18n('log_in')}</h1>`;
h += `</div>`;
// form
h += `<div style="padding:20px; overflow-y:auto; overflow-x:hidden;">`;
h += `<form class="login-form" style="width:100%;">`;
// server messages
h += `<div class="login-error-msg" style="color:#e74c3c; display:none; margin-bottom:10px; line-height:15px; font-size:13px;"></div>`;
// email or username
h += `<div style="position: relative; margin-bottom: 20px;">`;
h += `<label style="display:block; margin-bottom:5px;">${i18n('email_or_username')}</label>`;
if(options.email_or_username){
h += `<input type="text" class="email_or_username" value="${options.email_or_username}" autocomplete="username"/>`;
}else{
h += `<input type="text" class="email_or_username" autocomplete="username"/>`;
}
h += `</div>`;
// password with conditional type based based on options.show_password
h += `<div style="overflow: hidden; margin-top: 20px; margin-bottom: 20px; position: relative;">`;
h += `<label for="password-${internal_id}">${i18n('password')}</label>`;
// password
h += `<div style="position: relative; margin-bottom: 20px;">`;
h += `<label style="display:block; margin-bottom:5px;">${i18n('password')}</label>`;
h += `<input id="password-${internal_id}" class="password" type="${options.show_password ? "text" : "password"}" name="password" autocomplete="current-password"/>`;
// show/hide icon
h += `<span style="position: absolute; right: 5%; top: 50%; cursor: pointer;" id="toggle-show-password-${internal_id}">
<img class="toggle-show-password-icon" src="${options.show_password ? window.icons["eye-closed.svg"] : window.icons["eye-open.svg"]}" width="20" height="20">
</span>`;
h += `</div>`;
// captcha placeholder - will be replaced with actual captcha component
h += `<div class="captcha-container"></div>`;
// captcha-specific error message
h += `<div class="captcha-error-msg" style="color: #e74c3c; font-size: 12px; margin-top: 5px; display: none;" aria-live="polite"></div>`;
// login
h += `<button class="login-btn button button-primary button-block button-normal">${i18n('log_in')}</button>`;
h += `<button type="submit" class="login-btn button button-primary button-block button-normal">${i18n('log_in')}</button>`;
// password recovery
h += `<p style="text-align:center; margin-bottom: 0;"><span class="forgot-password-link">${i18n('forgot_pass_c2a')}</span></p>`;
h += `</form>`;
@@ -124,6 +142,46 @@ async function UIWindowLogin(options){
}
})
// Initialize the captcha component with the required state
const captchaContainer = $(el_window).find('.captcha-container')[0];
const captcha = CaptchaView({
container: captchaContainer,
required: captchaRequired,
});
// Function to show captcha-specific error
const showCaptchaError = (message) => {
// Hide the general error message if shown
$(el_window).find('.login-error-msg').hide();
// Show captcha-specific error
const captchaError = $(el_window).find('.captcha-error-msg');
captchaError.html(message);
captchaError.fadeIn();
// Add visual indication of error to captcha container
$(captchaContainer).addClass('error');
$(captchaContainer).css('border', '1px solid #e74c3c');
$(captchaContainer).css('border-radius', '4px');
$(captchaContainer).css('padding', '10px');
// Focus on the captcha input for better UX
setTimeout(() => {
const captchaInput = $(captchaContainer).find('.captcha-input');
if (captchaInput.length) {
captchaInput.focus();
}
}, 100);
};
// Function to clear captcha errors
const clearCaptchaError = () => {
$(el_window).find('.captcha-error-msg').hide();
$(captchaContainer).removeClass('error');
$(captchaContainer).css('border', '');
$(captchaContainer).css('padding', '');
};
$(el_window).find('.forgot-password-link').on('click', function(e){
UIWindowRecoverPassword({
window_options: {
@@ -135,36 +193,105 @@ async function UIWindowLogin(options){
})
$(el_window).find('.login-btn').on('click', function(e){
// Prevent default button behavior (important for async requests)
e.preventDefault();
// Clear previous error states
$(el_window).find('.login-error-msg').hide();
clearCaptchaError();
const email_username = $(el_window).find('.email_or_username').val();
const password = $(el_window).find('.password').val();
// Basic validation for email/username and password
if(!email_username) {
$(el_window).find('.login-error-msg').html(i18n('email_or_username_required') || 'Email or username is required');
$(el_window).find('.login-error-msg').fadeIn();
return;
}
if(!password) {
$(el_window).find('.login-error-msg').html(i18n('password_required') || 'Password is required');
$(el_window).find('.login-error-msg').fadeIn();
return;
}
// Get captcha token and answer if required
let captchaToken = null;
let captchaAnswer = null;
if (captcha.isRequired()) {
captchaToken = captcha.getToken();
captchaAnswer = captcha.getAnswer();
// Validate captcha if it's required
if (!captcha || !captchaContainer) {
$(el_window).find('.login-error-msg').html(i18n('captcha_system_error') || 'Verification system error. Please refresh the page.');
$(el_window).find('.login-error-msg').fadeIn();
return;
}
if (!captchaToken) {
showCaptchaError(i18n('captcha_load_error') || 'Could not load verification code. Please refresh the page or try again later.');
return;
}
if (!captchaAnswer) {
showCaptchaError(i18n('captcha_required') || 'Please enter the verification code');
return;
}
if (captchaAnswer.trim().length < 3) {
showCaptchaError(i18n('captcha_too_short') || 'Verification code answer is too short.');
return;
}
if (captchaAnswer.trim().length > 12) {
showCaptchaError(i18n('captcha_too_long') || 'Verification code answer is too long.');
return;
}
}
// Prepare data for the request
let data;
if(window.is_email(email_username)){
data = JSON.stringify({
email: email_username,
password: password
})
}else{
password: password,
...(captchaToken && captchaAnswer ? {
captchaToken: captchaToken,
captchaAnswer: captchaAnswer
} : {})
});
} else {
data = JSON.stringify({
username: email_username,
password: password
})
password: password,
...(captchaToken && captchaAnswer ? {
captchaToken: captchaToken,
captchaAnswer: captchaAnswer
} : {})
});
}
$(el_window).find('.login-error-msg').hide();
let headers = {};
if(window.custom_headers)
headers = window.custom_headers;
// Disable the login button to prevent multiple submissions
$(el_window).find('.login-btn').prop('disabled', true);
console.log('Sending login AJAX request with async: true');
$.ajax({
url: window.gui_origin + "/login",
type: 'POST',
async: false,
async: true,
headers: headers,
contentType: "application/json",
data: data,
success: async function (data){
console.log('Login request successful');
// Keep the button disabled on success since we're redirecting or closing
let p = Promise.resolve();
if ( data.next_step === 'otp' ) {
p = new TeePromise();
@@ -334,12 +461,96 @@ async function UIWindowLogin(options){
if(options.reload_on_success){
window.onbeforeunload = null;
window.location.replace('/');
console.log('About to redirect, checking URL parameters:', window.location.search);
// Replace with a clean URL to prevent password leakage
const cleanUrl = window.location.origin + window.location.pathname;
window.location.replace(cleanUrl);
}else
resolve(true);
$(el_window).close();
},
error: function (err){
console.log('Login AJAX request error:', err.status, err.statusText);
// First, ensure URL is clean in case of error (prevent password leakage)
if (window.location.search && (
window.location.search.includes('password=') ||
window.location.search.includes('username=') ||
window.location.search.includes('email=')
)) {
console.log('Cleaning sensitive data from URL');
const cleanUrl = window.location.origin + window.location.pathname;
history.replaceState({}, document.title, cleanUrl);
}
// Enable 'Log In' button
$(el_window).find('.login-btn').prop('disabled', false);
// Handle captcha-specific errors
const errorText = err.responseText || '';
const errorStatus = err.status || 0;
// Try to parse error as JSON
try {
const errorJson = JSON.parse(errorText);
// Check for specific error codes
if (errorJson.code === 'captcha_required') {
// If captcha is now required but wasn't before, update the component
if (!captcha.isRequired()) {
captcha.setRequired(true);
showCaptchaError(i18n('captcha_now_required') || 'Verification is now required. Please complete the verification below.');
} else {
showCaptchaError(i18n('captcha_required') || 'Please enter the verification code');
}
return;
}
if (errorJson.code === 'captcha_invalid' || errorJson.code === 'captcha_error') {
showCaptchaError(i18n('captcha_invalid') || 'Invalid verification code');
// Refresh the captcha if it's invalid
captcha.reset();
return;
}
// If it's a message in the JSON, use that
if (errorJson.message) {
$(el_window).find('.login-error-msg').html(errorJson.message);
$(el_window).find('.login-error-msg').fadeIn();
return;
}
} catch (e) {
// Not JSON, continue with text analysis
}
// Check for specific captcha errors using more robust detection for text responses
if (
errorText.includes('captcha_required') ||
errorText.includes('Captcha verification required') ||
(errorText.includes('captcha') && errorText.includes('required'))
) {
// If captcha is now required but wasn't before, update the component
if (!captcha.isRequired()) {
captcha.setRequired(true);
showCaptchaError(i18n('captcha_now_required') || 'Verification is now required. Please complete the verification below.');
} else {
showCaptchaError(i18n('captcha_required') || 'Please enter the verification code');
}
return;
}
if (
errorText.includes('captcha_invalid') ||
errorText.includes('Invalid captcha') ||
(errorText.includes('captcha') && (errorText.includes('invalid') || errorText.includes('incorrect')))
) {
showCaptchaError(i18n('captcha_invalid') || 'Invalid verification code');
// Refresh the captcha if it's invalid
captcha.reset();
return;
}
// Fall back to original error handling
const $errorMessage = $(el_window).find('.login-error-msg');
if (err.status === 404) {
// Don't include the whole 404 page
@@ -372,6 +583,27 @@ async function UIWindowLogin(options){
$(el_window).find('.login-form').on('submit', function(e){
e.preventDefault();
e.stopPropagation();
// Instead of triggering the click event, process the login directly
const email_username = $(el_window).find('.email_or_username').val();
const password = $(el_window).find('.password').val();
// Basic validation
if(!email_username) {
$(el_window).find('.login-error-msg').html(i18n('email_or_username_required') || 'Email or username is required');
$(el_window).find('.login-error-msg').fadeIn();
return false;
}
if(!password) {
$(el_window).find('.login-error-msg').html(i18n('password_required') || 'Password is required');
$(el_window).find('.login-error-msg').fadeIn();
return false;
}
// Process login using the same function as the button click
$(el_window).find('.login-btn').click();
return false;
})

View File

@@ -21,15 +21,23 @@ import UIWindow from './UIWindow.js'
import UIWindowLogin from './UIWindowLogin.js'
import UIWindowEmailConfirmationRequired from './UIWindowEmailConfirmationRequired.js'
import check_password_strength from '../helpers/check_password_strength.js'
import CaptchaView from './Components/CaptchaView.js'
import { isCaptchaRequired } from '../helpers/captchaHelper.js'
function UIWindowSignup(options){
options = options ?? {};
options.reload_on_success = options.reload_on_success ?? false;
options.reload_on_success = options.reload_on_success ?? true;
options.has_head = options.has_head ?? true;
options.send_confirmation_code = options.send_confirmation_code ?? false;
options.show_close_button = options.show_close_button ?? true;
return new Promise(async (resolve) => {
const internal_id = window.uuidv4();
// Check if captcha is required for signup
const captchaRequired = await isCaptchaRequired('signup');
console.log('Signup captcha required:', captchaRequired);
let h = '';
h += `<div style="margin: 0 auto; max-width: 500px; min-width: 400px;">`;
// logo
@@ -61,6 +69,10 @@ function UIWindowSignup(options){
h += `<label for="password-${internal_id}">${i18n('password')}</label>`;
h += `<input id="password-${internal_id}" class="password" type="password" name="password" autocomplete="new-password" />`;
h += `</div>`;
// captcha placeholder - will be replaced with actual captcha component
h += `<div class="captcha-container"></div>`;
// captcha-specific error message
h += `<div class="captcha-error-msg" style="color: #e74c3c; font-size: 12px; margin-top: 5px; display: none;" aria-live="polite"></div>`;
// bot trap - if this value is submitted server will ignore the request
h += `<input type="text" name="p102xyzname" class="p102xyzname" value="">`;
@@ -118,6 +130,13 @@ function UIWindowSignup(options){
}
})
// Initialize the captcha component with the required state
const captchaContainer = $(el_window).find('.captcha-container')[0];
const captcha = CaptchaView({
container: captchaContainer,
required: captchaRequired
});
$(el_window).find('.login-c2a-clickable').on('click', async function(e){
$('.login-c2a-clickable').parents('.window').close();
const login = await UIWindowLogin({
@@ -132,7 +151,44 @@ function UIWindowSignup(options){
resolve(true);
})
// Function to show captcha-specific error
const showCaptchaError = (message) => {
// Hide the general error message if shown
$(el_window).find('.signup-error-msg').hide();
// Show captcha-specific error
const captchaError = $(el_window).find('.captcha-error-msg');
captchaError.html(message);
captchaError.fadeIn();
// Add visual indication of error to captcha container
$(captchaContainer).addClass('error');
$(captchaContainer).css('border', '1px solid #e74c3c');
$(captchaContainer).css('border-radius', '4px');
$(captchaContainer).css('padding', '10px');
// Focus on the captcha input for better UX
setTimeout(() => {
const captchaInput = $(captchaContainer).find('.captcha-input');
if (captchaInput.length) {
captchaInput.focus();
}
}, 100);
};
// Function to clear captcha errors
const clearCaptchaError = () => {
$(el_window).find('.captcha-error-msg').hide();
$(captchaContainer).removeClass('error');
$(captchaContainer).css('border', '');
$(captchaContainer).css('padding', '');
};
$(el_window).find('.signup-btn').on('click', function(e){
// Clear previous error states
$(el_window).find('.signup-error-msg').hide();
clearCaptchaError();
//Username
let username = $(el_window).find('.username').val();
@@ -175,6 +231,46 @@ function UIWindowSignup(options){
return;
}
// Get captcha token and answer if required
let captchaToken = null;
let captchaAnswer = null;
if (captcha.isRequired()) {
captchaToken = captcha.getToken();
captchaAnswer = captcha.getAnswer();
// Check if the captcha component is properly loaded
if (!captcha || !captchaContainer) {
$(el_window).find('.signup-error-msg').html(i18n('captcha_system_error') || 'Verification system error. Please refresh the page.');
$(el_window).find('.signup-error-msg').fadeIn();
return;
}
// Check if captcha token exists
if (!captchaToken) {
showCaptchaError(i18n('captcha_load_error') || 'Could not load verification code. Please refresh the page or try again later.');
return;
}
// Check if the answer is provided
if (!captchaAnswer) {
showCaptchaError(i18n('captcha_required'));
return;
}
// Check if answer meets minimum length requirement
if (captchaAnswer.trim().length < 3) {
showCaptchaError(i18n('captcha_too_short') || 'Verification code answer is too short.');
return;
}
// Check if answer meets maximum length requirement
if (captchaAnswer.trim().length > 12) {
showCaptchaError(i18n('captcha_too_long') || 'Verification code answer is too long.');
return;
}
}
//xyzname
let p102xyzname = $(el_window).find('.p102xyzname').val();
@@ -185,28 +281,37 @@ function UIWindowSignup(options){
if(window.custom_headers)
headers = window.custom_headers;
// Include captcha in request only if required
const requestData = {
username: username,
referral_code: window.referral_code,
email: email,
password: password,
referrer: options.referrer ?? window.referrerStr,
send_confirmation_code: options.send_confirmation_code,
p102xyzname: p102xyzname,
...(captchaToken && captchaAnswer ? {
captchaToken: captchaToken,
captchaAnswer: captchaAnswer
} : {})
};
$.ajax({
url: window.gui_origin + "/signup",
type: 'POST',
async: true,
headers: headers,
contentType: "application/json",
data: JSON.stringify({
username: username,
referral_code: window.referral_code,
email: email,
password: password,
referrer: options.referrer ?? window.referrerStr,
send_confirmation_code: options.send_confirmation_code,
p102xyzname: p102xyzname,
}),
data: JSON.stringify(requestData),
success: async function (data){
window.update_auth_data(data.token, data.user)
//send out the login event
if(options.reload_on_success){
window.onbeforeunload = null;
window.location.replace('/');
// Replace with a clean URL to prevent sensitive data leakage
const cleanUrl = window.location.origin + window.location.pathname;
window.location.replace(cleanUrl);
}else if(options.send_confirmation_code){
$(el_window).close();
let is_verified = await UIWindowEmailConfirmationRequired({stay_on_top: true, has_head: true});
@@ -216,11 +321,80 @@ function UIWindowSignup(options){
}
},
error: function (err){
$(el_window).find('.signup-error-msg').html(err.responseText);
$(el_window).find('.signup-error-msg').fadeIn();
// re-enable 'Create Account' button so user can try again
$(el_window).find('.signup-btn').prop('disabled', false);
}
// Process error response
const errorText = err.responseText || '';
const errorStatus = err.status || 0;
// Handle JSON error response
try {
// Try to parse error as JSON
const errorJson = JSON.parse(errorText);
// Check for specific error codes
if (errorJson.code === 'captcha_required') {
// If captcha is now required but wasn't before, update the component
if (!captcha.isRequired()) {
captcha.setRequired(true);
showCaptchaError(i18n('captcha_now_required') || 'Verification is now required. Please complete the verification below.');
} else {
showCaptchaError(i18n('captcha_required') || 'Please enter the verification code');
}
return;
}
if (errorJson.code === 'captcha_invalid' || errorJson.code === 'captcha_error') {
showCaptchaError(i18n('captcha_invalid') || 'Invalid verification code');
// Refresh the captcha if it's invalid
captcha.reset();
return;
}
// If it's a message in the JSON, use that
if (errorJson.message) {
$(el_window).find('.signup-error-msg').html(errorJson.message);
$(el_window).find('.signup-error-msg').fadeIn();
return;
}
} catch (e) {
// Not JSON, continue with text analysis
}
// Check for specific captcha errors using more robust detection for text responses
if (
errorText.includes('captcha_required') ||
errorText.includes('Captcha verification required') ||
(errorText.includes('captcha') && errorText.includes('required'))
) {
showCaptchaError(i18n('captcha_required'));
return;
}
if (
errorText.includes('captcha_invalid') ||
errorText.includes('Invalid captcha') ||
(errorText.includes('captcha') && (errorText.includes('invalid') || errorText.includes('incorrect')))
) {
showCaptchaError(i18n('captcha_invalid'));
// Refresh the captcha if it's invalid
captcha.reset();
return;
}
// Handle timeout specifically
if (errorJson?.code === 'response_timeout' || errorText.includes('timeout')) {
$(el_window).find('.signup-error-msg').html(i18n('server_timeout') || 'The server took too long to respond. Please try again.');
$(el_window).find('.signup-error-msg').fadeIn();
return;
}
// Default general error handling
$(el_window).find('.signup-error-msg').html(errorText || i18n('signup_error') || 'An error occurred during signup. Please try again.');
$(el_window).find('.signup-error-msg').fadeIn();
},
timeout: 30000 // Add a reasonable timeout
});
})

View File

@@ -0,0 +1,87 @@
/**
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
/**
* Cache for captcha requirements to avoid repeated API calls
*/
let captchaRequirementsCache = null;
/**
* Checks if captcha is required for a specific action
*
* This function first checks GUI parameters, then falls back to the /whoarewe endpoint
*
* @param {string} actionType - The type of action (e.g., 'login', 'signup')
* @returns {Promise<boolean>} - Whether captcha is required for this action
*/
async function isCaptchaRequired(actionType) {
console.log('CAPTCHA DIAGNOSTIC (Client): isCaptchaRequired called for', actionType);
// Check if we have the info in GUI parameters
if (window.gui_params?.captchaRequired?.[actionType] !== undefined) {
console.log(`CAPTCHA DIAGNOSTIC (Client): Requirement for ${actionType} from GUI params:`, window.gui_params.captchaRequired[actionType]);
console.log('CAPTCHA DIAGNOSTIC (Client): Full gui_params.captchaRequired =', JSON.stringify(window.gui_params.captchaRequired));
return window.gui_params.captchaRequired[actionType];
}
// If not in GUI params, check the cache
if (captchaRequirementsCache && captchaRequirementsCache.captchaRequired?.[actionType] !== undefined) {
console.log(`CAPTCHA DIAGNOSTIC (Client): Requirement for ${actionType} from cache:`, captchaRequirementsCache.captchaRequired[actionType]);
console.log('CAPTCHA DIAGNOSTIC (Client): Full cache =', JSON.stringify(captchaRequirementsCache.captchaRequired));
return captchaRequirementsCache.captchaRequired[actionType];
}
// If not in cache, fetch from the /whoarewe endpoint
try {
console.log(`CAPTCHA DIAGNOSTIC (Client): Fetching from /whoarewe for ${actionType}`);
const response = await fetch(window.api_origin + '/whoarewe');
if (!response.ok) {
console.warn(`CAPTCHA DIAGNOSTIC (Client): Failed to get requirements: ${response.status}`);
return true; // Default to requiring captcha if we can't determine
}
const data = await response.json();
console.log(`CAPTCHA DIAGNOSTIC (Client): /whoarewe response:`, data);
// Cache the result
captchaRequirementsCache = data;
// Return the requirement or default to true if not specified
const result = data.captchaRequired?.[actionType] ?? true;
console.log(`CAPTCHA DIAGNOSTIC (Client): Final result for ${actionType}:`, result);
return result;
} catch (error) {
console.error('CAPTCHA DIAGNOSTIC (Client): Error checking requirements:', error);
return true; // Default to requiring captcha on error
}
}
/**
* Invalidates the captcha requirements cache
* This is useful when the requirements might have changed
*/
function invalidateCaptchaRequirementsCache() {
captchaRequirementsCache = null;
}
export {
isCaptchaRequired,
invalidateCaptchaRequirementsCache
};

View File

@@ -410,6 +410,23 @@ const en = {
'billing.expanded': 'Expanded',
'billing.accelerated': 'Accelerated',
'billing.enjoy_msg': 'Enjoy %% of Cloud Storage plus other benefits.',
// Captcha related strings
'captcha_verification': 'Verification Code',
'enter_captcha_text': 'Enter the text you see above',
'refresh_captcha': 'Get a new code',
'captcha_case_sensitive': 'Please enter the characters exactly as they appear. Case sensitive.',
'captcha_required': 'Please complete the verification code.',
'captcha_now_required': 'Verification is now required. Please complete the verification below.',
'captcha_invalid': 'Incorrect verification code. Please try again.',
'captcha_expired': 'Verification code has expired. Please try a new one.',
'captcha_system_error': 'Verification system error. Please refresh the page.',
'captcha_load_error': 'Could not load verification code. Please refresh the page or try again later.',
'captcha_too_short': 'Verification code answer is too short.',
'captcha_too_long': 'Verification code answer is too long.',
'too_many_attempts': 'Too many attempts. Please try again later.',
'server_timeout': 'The server took too long to respond. Please try again.',
'signup_error': 'An error occurred during signup. Please try again.'
}
};

View File

@@ -20,6 +20,8 @@
window.puter_gui_enabled = true;
import { isCaptchaRequired } from './helpers/captchaHelper.js';
/**
* Initializes and configures the GUI (Graphical User Interface) settings based on the provided options.
*
@@ -46,7 +48,7 @@ window.puter_gui_enabled = true;
* });
*/
window.gui = async function(options){
window.gui = async (options) => {
options = options ?? {};
// app_origin is deprecated, use gui_origin instead
window.gui_params = options;
@@ -59,6 +61,18 @@ window.gui = async function(options){
window.disable_temp_users = options.disable_temp_users ?? false;
window.co_isolation_enabled = options.co_isolation_enabled;
// Preload captcha requirements if not already in GUI parameters
if (!options.captchaRequired) {
// Start loading in the background, but don't await
// This way we don't delay the GUI initialization
Promise.all([
isCaptchaRequired('login'),
isCaptchaRequired('signup')
]).catch(err => {
console.warn('Failed to preload captcha requirements:', err);
});
}
// DEV: Load the initgui.js file if we are in development mode
if(!window.gui_env || window.gui_env === "dev"){
await window.loadScript('/sdk/puter.dev.js');

View File

@@ -1426,3 +1426,11 @@ $(document).on('contextmenu', '.disable-context-menu', function(e){
// util/desktop.js
window.privacy_aware_path = privacy_aware_path({ window });
$(window).on('system-logout-event', function(){
// Clear cookie
document.cookie = 'puter=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT;';
// Redirect to clean URL without any query parameters
const cleanUrl = window.location.origin + window.location.pathname;
window.location.replace(cleanUrl);
});

View File

@@ -0,0 +1,188 @@
/**
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { describe, it, beforeEach, afterEach } from 'mocha';
import { expect } from 'chai';
import sinon from 'sinon';
import jsdom from 'jsdom';
const { JSDOM } = jsdom;
// Mock the DOM environment
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>');
global.window = dom.window;
global.document = dom.window.document;
global.HTMLElement = dom.window.HTMLElement;
global.customElements = dom.window.customElements;
// Mock the captchaHelper
const captchaHelper = {
isCaptchaRequired: sinon.stub()
};
// Mock the grecaptcha object
global.grecaptcha = {
ready: sinon.stub().callsFake(cb => cb()),
execute: sinon.stub().resolves('mock-token'),
render: sinon.stub().returns('captcha-widget-id')
};
// Import the module under test (mock import)
const CaptchaView = {
prototype: {
connectedCallback: sinon.stub(),
disconnectedCallback: sinon.stub(),
setRequired: sinon.stub(),
isRequired: sinon.stub(),
getValue: sinon.stub(),
reset: sinon.stub()
}
};
describe('CaptchaView', () => {
let captchaElement;
beforeEach(() => {
// Create a mock CaptchaView element
captchaElement = {
...CaptchaView.prototype,
getAttribute: sinon.stub(),
setAttribute: sinon.stub(),
removeAttribute: sinon.stub(),
appendChild: sinon.stub(),
querySelector: sinon.stub(),
style: {},
dataset: {},
captchaWidgetId: null,
captchaContainer: document.createElement('div')
};
// Reset stubs
Object.values(CaptchaView.prototype).forEach(stub => {
if (typeof stub.reset === 'function') stub.reset();
});
captchaHelper.isCaptchaRequired.reset();
grecaptcha.ready.reset();
grecaptcha.execute.reset();
grecaptcha.render.reset();
});
describe('setRequired', () => {
it('should show captcha when required is true', () => {
// Setup
captchaElement.setRequired.callsFake(function(required) {
this.required = required;
if (required) {
this.style.display = 'block';
} else {
this.style.display = 'none';
}
});
// Test
captchaElement.setRequired(true);
// Assert
expect(captchaElement.required).to.be.true;
expect(captchaElement.style.display).to.equal('block');
});
it('should hide captcha when required is false', () => {
// Setup
captchaElement.setRequired.callsFake(function(required) {
this.required = required;
if (required) {
this.style.display = 'block';
} else {
this.style.display = 'none';
}
});
// Test
captchaElement.setRequired(false);
// Assert
expect(captchaElement.required).to.be.false;
expect(captchaElement.style.display).to.equal('none');
});
});
describe('isRequired', () => {
it('should return the current required state', () => {
// Setup
captchaElement.required = true;
captchaElement.isRequired.callsFake(function() {
return this.required;
});
// Test & Assert
expect(captchaElement.isRequired()).to.be.true;
// Change state
captchaElement.required = false;
// Test & Assert again
expect(captchaElement.isRequired()).to.be.false;
});
});
describe('getValue', () => {
it('should return null when captcha is not required', () => {
// Setup
captchaElement.required = false;
captchaElement.getValue.callsFake(function() {
return this.required ? 'mock-token' : null;
});
// Test & Assert
expect(captchaElement.getValue()).to.be.null;
});
it('should return token when captcha is required', () => {
// Setup
captchaElement.required = true;
captchaElement.getValue.callsFake(function() {
return this.required ? 'mock-token' : null;
});
// Test & Assert
expect(captchaElement.getValue()).to.equal('mock-token');
});
});
describe('reset', () => {
it('should reset the captcha widget when it exists', () => {
// Setup
captchaElement.captchaWidgetId = 'captcha-widget-id';
global.grecaptcha.reset = sinon.stub();
captchaElement.reset.callsFake(function() {
if (this.captchaWidgetId) {
grecaptcha.reset(this.captchaWidgetId);
}
});
// Test
captchaElement.reset();
// Assert
expect(grecaptcha.reset.calledWith('captcha-widget-id')).to.be.true;
});
});
});

View File

@@ -0,0 +1,215 @@
/**
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { describe, it, beforeEach, afterEach } from 'mocha';
import { expect } from 'chai';
import sinon from 'sinon';
// Mock the fetch API and global window object for testing
global.window = {
api_origin: 'https://test-api.puter.com',
gui_params: {}
};
global.fetch = sinon.stub();
global.console = {
log: sinon.stub(),
warn: sinon.stub(),
error: sinon.stub()
};
// Import the module under test
import { isCaptchaRequired, invalidateCaptchaRequirementsCache } from '../../src/helpers/captchaHelper.js';
describe('captchaHelper', () => {
let fetchStub;
beforeEach(() => {
// Reset stubs before each test
fetchStub = global.fetch;
fetchStub.reset();
// Reset the window object
global.window.gui_params = {};
});
afterEach(() => {
// Reset any cached data between tests
invalidateCaptchaRequirementsCache();
});
describe('isCaptchaRequired', () => {
it('should use GUI parameters if available', async () => {
// Setup
global.window.gui_params = {
captchaRequired: {
login: true,
signup: false
}
};
// Test
const loginRequired = await isCaptchaRequired('login');
const signupRequired = await isCaptchaRequired('signup');
// Assert
expect(loginRequired).to.be.true;
expect(signupRequired).to.be.false;
expect(fetchStub.called).to.be.false; // Fetch should not be called
});
it('should fetch from API if GUI parameters are not available', async () => {
// Setup
const apiResponse = {
captchaRequired: {
login: false,
signup: true
}
};
fetchStub.resolves({
ok: true,
json: () => Promise.resolve(apiResponse)
});
// Test
const loginRequired = await isCaptchaRequired('login');
// Assert
expect(loginRequired).to.be.false;
expect(fetchStub.calledOnce).to.be.true;
expect(fetchStub.firstCall.args[0]).to.equal('https://test-api.puter.com/whoarewe');
});
it('should cache API responses for subsequent calls', async () => {
// Setup
const apiResponse = {
captchaRequired: {
login: true,
signup: false
}
};
fetchStub.resolves({
ok: true,
json: () => Promise.resolve(apiResponse)
});
// Test - first call should use the API
const firstLoginRequired = await isCaptchaRequired('login');
// Second call should use the cache
const secondLoginRequired = await isCaptchaRequired('login');
// Assert
expect(firstLoginRequired).to.be.true;
expect(secondLoginRequired).to.be.true;
expect(fetchStub.calledOnce).to.be.true; // Fetch should only be called once
});
it('should handle API errors and default to requiring captcha', async () => {
// Setup
fetchStub.rejects(new Error('Network error'));
// Test
const loginRequired = await isCaptchaRequired('login');
// Assert
expect(loginRequired).to.be.true; // Should default to true on error
expect(fetchStub.calledOnce).to.be.true;
});
it('should handle non-200 API responses and default to requiring captcha', async () => {
// Setup
fetchStub.resolves({
ok: false,
status: 500
});
// Test
const loginRequired = await isCaptchaRequired('login');
// Assert
expect(loginRequired).to.be.true; // Should default to true on error
expect(fetchStub.calledOnce).to.be.true;
});
it('should handle missing action type in response and default to requiring captcha', async () => {
// Setup
const apiResponse = {
captchaRequired: {
// login is missing
signup: false
}
};
fetchStub.resolves({
ok: true,
json: () => Promise.resolve(apiResponse)
});
// Test
const loginRequired = await isCaptchaRequired('login');
// Assert
expect(loginRequired).to.be.true; // Should default to true if not specified
expect(fetchStub.calledOnce).to.be.true;
});
});
describe('invalidateCaptchaRequirementsCache', () => {
it('should invalidate the cache and force a new API call', async () => {
// Setup - first API call
const firstApiResponse = {
captchaRequired: {
login: true
}
};
fetchStub.resolves({
ok: true,
json: () => Promise.resolve(firstApiResponse)
});
// First call to cache the result
await isCaptchaRequired('login');
// Setup - second API call with different response
const secondApiResponse = {
captchaRequired: {
login: false
}
};
fetchStub.resolves({
ok: true,
json: () => Promise.resolve(secondApiResponse)
});
// Invalidate the cache
invalidateCaptchaRequirementsCache();
// Test - this should now make a new API call
const loginRequired = await isCaptchaRequired('login');
// Assert
expect(loginRequired).to.be.false; // Should get the new value
expect(fetchStub.calledTwice).to.be.true; // Fetch should be called twice
});
});
});

2862
test/integration/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,20 @@
{
"name": "puter-integration-tests",
"version": "1.0.0",
"description": "Integration tests for Puter",
"main": "index.js",
"scripts": {
"test": "mocha captcha/**/*.test.js",
"test:auth": "mocha captcha/authentication-flow.test.js",
"test:ui": "mocha captcha/ui-behavior.test.js"
},
"devDependencies": {
"chai": "^4.3.7",
"express": "^4.18.2",
"jsdom": "^21.1.0",
"mocha": "^10.2.0",
"sinon": "^15.2.0",
"body-parser": "^1.20.2",
"supertest": "^6.3.3"
}
}