├── .env.example ├── .gitignore ├── LICENSE ├── README.md ├── models ├── AbTest.js └── User.js ├── package.json ├── public ├── css │ ├── style.css │ └── tooltipSlider.css ├── js │ ├── abTests.js │ ├── animations.js │ ├── main.js │ ├── resultsChart.js │ └── tooltipSlider.js └── loader.js ├── routes ├── abTestRoutes.js ├── apiRoutes.js ├── authRoutes.js ├── middleware │ └── authMiddleware.js └── userRoutes.js ├── server.js ├── validators └── testTrackValidator.js └── views ├── abTests.ejs ├── account.ejs ├── editTestForm.ejs ├── index.ejs ├── login.ejs ├── partials ├── _footer.ejs ├── _head.ejs └── _header.ejs ├── register.ejs ├── testForm.ejs └── testResults.ejs /.env.example: -------------------------------------------------------------------------------- 1 | # Copy this file to .env and edit the settings 2 | 3 | # Port to listen on (example: 3000) 4 | PORT= 5 | 6 | # MongoDB database URL (example: mongodb://localhost/dbname) 7 | DATABASE_URL= 8 | 9 | # Session secret string (must be unique to your server) 10 | SESSION_SECRET= 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .gpt-pilot/ 3 | .env 4 | package-lock.json -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Pythagora-io 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GPTOptimizely 2 | 3 | GPTOptimizely is a cutting-edge application designed to bring the power of A/B testing directly to your website, allowing for meticulous optimization and analysis of web content. By embedding a simple HTML snippet into your site, GPTOptimizely dynamically tests different versions of your website's sections, tracking user interactions and providing valuable insights into which variations perform the best. 4 | 5 | ## Overview 6 | 7 | The application leverages Node.js, Express.js, and MongoDB to deliver a robust backend infrastructure, while the frontend is elegantly managed through EJS templates. The architecture is designed to prioritize security, performance, and scalability, ensuring that GPTOptimizely can handle the needs of modern web applications. CSRF protection and session management are implemented to safeguard user data. 8 | 9 | ## Features 10 | 11 | - **User Registration and Authentication**: Enables secure access to the GPTOptimizely dashboard. 12 | - **Dynamic A/B Testing**: Users can create and manage tests directly from the dashboard, specifying different HTML versions to be dynamically injected into their website. 13 | - **Real-Time Analytics**: Tracks clicks and interactions, providing insights through an intuitive interface. 14 | - **Customizable Testing Parameters**: Offers flexibility in defining the elements and pages to be tested. 15 | 16 | ## Getting started 17 | 18 | ### Requirements 19 | 20 | - Node.js 21 | - MongoDB 22 | - npm 23 | 24 | ### Quickstart 25 | 26 | 1. Clone the repository and navigate to the project directory. 27 | 2. Install dependencies with `npm install`. 28 | 3. Copy the `.env.example` file to `.env` and configure the environment variables. 29 | 4. Ensure MongoDB is running on your system. 30 | 5. Start the application using `npm start`. It will be accessible on the configured port. 31 | 32 | ### License 33 | 34 | The project is open source, licensed under the MIT License. See the [LICENSE](LICENSE). 35 | 36 | Copyright © 2024 Pythagora-io. 37 | -------------------------------------------------------------------------------- /models/AbTest.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose'); 2 | 3 | const abTestSchema = new mongoose.Schema({ 4 | testName: { type: String, required: true }, 5 | testStatus: { type: String, required: true, enum: ['Running', 'Stopped'] }, 6 | pagePaths: [{ type: String }], 7 | IDparent: { type: String, required: true }, 8 | IDclick: { type: String, required: true }, 9 | htmlContentA: { type: String, required: true }, 10 | htmlContentB: { type: String, required: true }, 11 | createdBy: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true }, 12 | clicksA: { type: Number, default: 0 }, 13 | clicksB: { type: Number, default: 0 }, 14 | impressionsA: { type: Number, default: 0 }, 15 | impressionsB: { type: Number, default: 0 } 16 | }, { timestamps: true }); 17 | 18 | abTestSchema.pre('save', function(next) { 19 | console.log(`Saving A/B Test: ${this.testName}`); 20 | next(); 21 | }); 22 | 23 | abTestSchema.post('save', function(doc, next) { 24 | console.log(`A/B Test saved: ${doc.testName}`); 25 | next(); 26 | }); 27 | 28 | abTestSchema.post('save', function(error, doc, next) { 29 | if (error) { 30 | console.error(`Error saving A/B Test: ${error.message}`, error.stack); 31 | next(error); 32 | } else { 33 | next(); 34 | } 35 | }); 36 | 37 | const AbTest = mongoose.model('AbTest', abTestSchema); 38 | 39 | module.exports = AbTest; -------------------------------------------------------------------------------- /models/User.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose'); 2 | const bcrypt = require('bcrypt'); 3 | const { v4: uuidv4 } = require('uuid'); 4 | 5 | const userSchema = new mongoose.Schema({ 6 | email: { type: String, unique: true, required: true }, 7 | password: { type: String, required: true }, 8 | apiKey: { type: String, unique: true }, 9 | allowedOrigins: [{ type: String }] 10 | }); 11 | 12 | // Pre-save middleware for generating the API key and hashing the password 13 | userSchema.pre('save', async function(next) { 14 | const user = this; 15 | 16 | // Generating the API key 17 | if (user.isNew) { 18 | try { 19 | user.apiKey = uuidv4(); // Generate a unique API key 20 | console.log(`API Key generated for user: ${user.email}`); 21 | } catch (err) { 22 | console.error('Error generating API key:', err.message, err.stack); 23 | return next(err); 24 | } 25 | } 26 | 27 | // Hashing the password 28 | if (user.isModified('password')) { 29 | try { 30 | const hash = await bcrypt.hash(user.password, 10); 31 | user.password = hash; 32 | console.log(`Password hashed for user: ${user.email}`); 33 | } catch (err) { 34 | console.error('Error hashing password:', err.message, err.stack); 35 | return next(err); 36 | } 37 | } 38 | 39 | next(); 40 | }); 41 | 42 | const User = mongoose.model('User', userSchema); 43 | 44 | module.exports = User; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "GPTOptimizely", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "server.js", 6 | "scripts": { 7 | "start": "node server.js", 8 | "test": "echo \"Error: no test specified\" && exit 1" 9 | }, 10 | "keywords": [], 11 | "author": "", 12 | "license": "ISC", 13 | "dependencies": { 14 | "bcrypt": "^5.1.1", 15 | "body-parser": "^1.20.2", 16 | "chart.js": "^4.4.1", 17 | "connect-flash": "^0.1.1", 18 | "connect-mongo": "^5.1.0", 19 | "cors": "^2.8.5", 20 | "csurf": "^1.11.0", 21 | "csv-writer": "^1.6.0", 22 | "dotenv": "^16.4.1", 23 | "ejs": "^3.1.9", 24 | "express": "^4.18.2", 25 | "express-rate-limit": "^7.1.5", 26 | "express-session": "^1.18.0", 27 | "helmet": "^7.1.0", 28 | "joi": "^17.12.2", 29 | "moment": "^2.30.1", 30 | "mongoose": "^8.1.1", 31 | "uuid": "^9.0.1" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /public/css/style.css: -------------------------------------------------------------------------------- 1 | /* Placeholder for custom styles */ 2 | 3 | html { 4 | height: 100%; 5 | } 6 | body { 7 | display: flex; 8 | flex-direction: column; 9 | min-height: 100vh; 10 | margin: 0; 11 | padding-bottom: 60px; /* Adjust this value as needed */ 12 | } 13 | .container { 14 | flex: 1; 15 | } 16 | .footer { 17 | background: #f8f9fa; 18 | padding: 1rem 0; 19 | margin-top: auto; 20 | } 21 | 22 | /* Placeholder for custom styles */ 23 | .pythagora-logo { 24 | height: 20px; 25 | margin-left: 5px; 26 | } 27 | 28 | /* Add new styles for animations and modern look */ 29 | .fade-in { 30 | animation: fadeIn 0.5s ease-in; 31 | } 32 | 33 | @keyframes fadeIn { 34 | from { opacity: 0; } 35 | to { opacity: 1; } 36 | } 37 | 38 | .slide-in { 39 | animation: slideIn 0.5s ease-out; 40 | } 41 | 42 | @keyframes slideIn { 43 | from { transform: translateY(20px); opacity: 0; } 44 | to { transform: translateY(0); opacity: 1; } 45 | } 46 | 47 | .pulse { 48 | animation: pulse 2s infinite; 49 | } 50 | 51 | @keyframes pulse { 52 | 0% { transform: scale(1); } 53 | 50% { transform: scale(1.05); } 54 | 100% { transform: scale(1); } 55 | } 56 | 57 | .card { 58 | transition: all 0.3s ease; 59 | } 60 | 61 | .card:hover { 62 | transform: translateY(-5px); 63 | box-shadow: 0 4px 8px rgba(0,0,0,0.1); 64 | } 65 | 66 | .btn { 67 | transition: all 0.3s ease; 68 | } 69 | 70 | .btn:hover { 71 | transform: translateY(-2px); 72 | box-shadow: 0 2px 4px rgba(0,0,0,0.1); 73 | } 74 | 75 | .tooltip { 76 | position: relative; 77 | display: inline-block; 78 | } 79 | 80 | .tooltip .tooltiptext { 81 | visibility: hidden; 82 | width: 120px; 83 | background-color: #555; 84 | color: #fff; 85 | text-align: center; 86 | border-radius: 6px; 87 | padding: 5px 0; 88 | position: absolute; 89 | z-index: 1; 90 | bottom: 125%; 91 | left: 50%; 92 | margin-left: -60px; 93 | opacity: 0; 94 | transition: opacity 0.3s; 95 | } 96 | 97 | .tooltip:hover .tooltiptext { 98 | visibility: visible; 99 | opacity: 1; 100 | } 101 | 102 | /* Add more custom styles as needed */ -------------------------------------------------------------------------------- /public/css/tooltipSlider.css: -------------------------------------------------------------------------------- 1 | .tooltip-slider { 2 | position: relative; 3 | overflow: hidden; 4 | height: 120px; /* Changed from 150px to 120px */ 5 | margin-bottom: 5px; /* Further reduce from 10px */ 6 | } 7 | 8 | .tooltip-slide { 9 | position: absolute; 10 | top: 0; 11 | left: 0; 12 | width: 100%; 13 | height: 100%; 14 | opacity: 0; 15 | transition: opacity 0.5s ease-in-out; 16 | } 17 | 18 | .tooltip-slide.active { 19 | opacity: 1; 20 | } 21 | 22 | .tooltip-content { 23 | background-color: #f8f9fa; 24 | border: 1px solid #dee2e6; 25 | border-radius: 5px; 26 | padding: 15px; 27 | text-align: center; 28 | } 29 | 30 | .slider-controls { 31 | text-align: center; 32 | margin-bottom: 5px; /* Further reduce from 10px */ 33 | } 34 | 35 | .slider-controls button { 36 | margin: 0 5px; 37 | } -------------------------------------------------------------------------------- /public/js/abTests.js: -------------------------------------------------------------------------------- 1 | document.addEventListener('DOMContentLoaded', function() { 2 | console.log("A/B Tests Management Page Loaded"); 3 | 4 | // Event listener for toggling test status 5 | document.querySelectorAll('.toggle-test-status').forEach(button => { 6 | button.addEventListener('click', function(e) { 7 | e.preventDefault(); 8 | 9 | const csrfTokenElement = document.getElementById('csrfToken'); 10 | if (!csrfTokenElement) { 11 | console.log("CSRF token not found. User might not be logged in."); 12 | return; 13 | } 14 | 15 | const testId = this.dataset.testid; 16 | if (!testId) { 17 | console.error("Test ID not found on button."); 18 | return; 19 | } 20 | 21 | const csrfToken = csrfTokenElement.value; 22 | 23 | fetch(`/tests/${testId}/toggle-status`, { 24 | method: 'POST', 25 | headers: { 26 | 'Content-Type': 'application/json', 27 | 'CSRF-Token': csrfToken 28 | }, 29 | body: JSON.stringify({ testStatus: this.dataset.status === 'Running' ? 'Stopped' : 'Running' }), 30 | credentials: 'same-origin' 31 | }) 32 | .then(response => response.json()) 33 | .then(data => { 34 | if (data.success) { 35 | const newStatus = data.testStatus; 36 | this.dataset.status = newStatus; 37 | this.classList.remove('btn-success', 'btn-danger'); 38 | this.classList.add(newStatus === 'Running' ? 'btn-danger' : 'btn-success'); 39 | this.textContent = newStatus === 'Running' ? 'Stop' : 'Start'; 40 | const statusElement = this.closest('.card-body').querySelector('.card-text'); 41 | if (statusElement) { 42 | statusElement.textContent = `Status: ${newStatus}`; 43 | } 44 | } else { 45 | console.error('Failed to toggle test status:', data.message); 46 | } 47 | }) 48 | .catch(error => { 49 | console.error('Error toggling test status:', error.message, error.stack); 50 | }); 51 | }); 52 | }); 53 | 54 | // Functionality for adding page paths on the A/B Tests Management page 55 | function addPagePathFunctionality() { 56 | const addPagePathButton = document.getElementById('addPagePath'); 57 | if (addPagePathButton) { 58 | addPagePathButton.addEventListener('click', function() { 59 | const pagePathsContainer = document.getElementById('pagePaths'); 60 | if (pagePathsContainer) { 61 | const newInput = document.createElement('input'); 62 | newInput.type = 'text'; 63 | newInput.className = 'form-control mb-2'; 64 | newInput.name = 'pagePaths[]'; 65 | pagePathsContainer.appendChild(newInput); 66 | console.log('Added new page path input field.'); 67 | } else { 68 | console.error('Page paths container not found.'); 69 | } 70 | }); 71 | } else { 72 | console.error('Add page path button not found.'); 73 | } 74 | } 75 | 76 | // Check if we're on the A/B Tests Management page to add page paths 77 | if (document.getElementById('testsList')) { 78 | addPagePathFunctionality(); 79 | } else { 80 | console.log("Not on A/B Tests Management page, skipping A/B test-specific operations."); 81 | } 82 | }); -------------------------------------------------------------------------------- /public/js/animations.js: -------------------------------------------------------------------------------- 1 | document.addEventListener('DOMContentLoaded', function() { 2 | // Add fade-in effect to main content 3 | const mainContent = document.querySelector('main'); 4 | if (mainContent) { 5 | mainContent.classList.add('fade-in'); 6 | } 7 | 8 | // Add slide-in effect to cards 9 | const cards = document.querySelectorAll('.card'); 10 | cards.forEach((card, index) => { 11 | card.classList.add('slide-in'); 12 | card.style.animationDelay = `${index * 0.1}s`; 13 | }); 14 | 15 | // Add pulse effect to buttons 16 | const buttons = document.querySelectorAll('.btn-primary'); 17 | buttons.forEach(button => { 18 | button.classList.add('pulse'); 19 | }); 20 | 21 | // Initialize tooltips 22 | const tooltips = document.querySelectorAll('[data-toggle="tooltip"]'); 23 | tooltips.forEach(tooltip => { 24 | new bootstrap.Tooltip(tooltip); 25 | }); 26 | 27 | // Error handling for missing elements 28 | window.addEventListener('error', function(event) { 29 | console.error("Error occurred in animations.js: ", event.message, event.error.stack); 30 | }); 31 | }); -------------------------------------------------------------------------------- /public/js/main.js: -------------------------------------------------------------------------------- 1 | document.addEventListener('DOMContentLoaded', function() { 2 | console.log('Main script loaded'); 3 | 4 | // Initialize tooltips 5 | const tooltips = document.querySelectorAll('[data-toggle="tooltip"]'); 6 | tooltips.forEach(tooltip => { 7 | new bootstrap.Tooltip(tooltip); 8 | }); 9 | 10 | // Error handling for missing elements 11 | window.addEventListener('error', function(event) { 12 | console.error("Error occurred in main.js: ", event.message, event.error.stack); 13 | }); 14 | }); -------------------------------------------------------------------------------- /public/js/resultsChart.js: -------------------------------------------------------------------------------- 1 | document.querySelectorAll('.view-results').forEach(button => { 2 | button.addEventListener('click', function() { 3 | const testId = this.dataset.testid; 4 | window.location.href = `/tests/${testId}/results`; 5 | }); 6 | }); -------------------------------------------------------------------------------- /public/js/tooltipSlider.js: -------------------------------------------------------------------------------- 1 | document.addEventListener('DOMContentLoaded', function() { 2 | const slides = document.querySelectorAll('.tooltip-slide'); 3 | const prevButton = document.getElementById('prevTip'); 4 | const nextButton = document.getElementById('nextTip'); 5 | let currentSlide = 0; 6 | 7 | function showSlide(index) { 8 | slides.forEach(slide => slide.classList.remove('active')); 9 | slides[index].classList.add('active'); 10 | } 11 | 12 | function nextSlide() { 13 | currentSlide = (currentSlide + 1) % slides.length; 14 | showSlide(currentSlide); 15 | console.log(`Showing next slide: ${currentSlide}`); 16 | } 17 | 18 | function prevSlide() { 19 | currentSlide = (currentSlide - 1 + slides.length) % slides.length; 20 | showSlide(currentSlide); 21 | console.log(`Showing previous slide: ${currentSlide}`); 22 | } 23 | 24 | nextButton.addEventListener('click', nextSlide); 25 | prevButton.addEventListener('click', prevSlide); 26 | 27 | showSlide(currentSlide); 28 | 29 | // Auto-advance slides every 5 seconds 30 | setInterval(function() { 31 | try { 32 | nextSlide(); 33 | } catch (error) { 34 | console.error("Error auto-advancing slide: ", error.message, error.stack); 35 | } 36 | }, 5000); 37 | }); -------------------------------------------------------------------------------- /public/loader.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | // Extract the API key from the script tag 3 | const scriptTag = document.querySelector('script[data-api-key]'); 4 | const apiKey = scriptTag.getAttribute('data-api-key'); 5 | const serverUrl = '%%SERVER_URL%%'; // This will be dynamically replaced when served 6 | 7 | if (!apiKey) { 8 | console.error('GPTOptimizely Error: API key is missing.'); 9 | return; 10 | } 11 | 12 | // Define the function to inject HTML content and track impressions 13 | function injectHtmlAndTrackImpressions(htmlContent, idParent, version, testName) { 14 | const parentElement = document.getElementById(idParent); 15 | if (parentElement) { 16 | parentElement.innerHTML = htmlContent; 17 | console.log(`GPTOptimizely Success: HTML content injected into ${idParent}.`); 18 | 19 | // Track the impression immediately after injecting the HTML content 20 | fetch(`${serverUrl}/api/tests/impression`, { 21 | method: 'POST', 22 | headers: { 23 | 'Content-Type': 'application/json', 24 | }, 25 | body: JSON.stringify({ 26 | apiKey, 27 | version, 28 | testName: testName, 29 | pagePath: window.location.pathname 30 | }) 31 | }) 32 | .then(response => { 33 | if (!response.ok) { 34 | throw new Error('Failed to send impression data.'); 35 | } 36 | console.log('GPTOptimizely Success: Impression data sent successfully.'); 37 | }) 38 | .catch(error => { 39 | console.error('GPTOptimizely Tracking Error:', error.message, error.stack); 40 | }); 41 | } else { 42 | console.error(`GPTOptimizely Error: Parent element with ID ${idParent} not found.`); 43 | } 44 | } 45 | 46 | // Define the function to track clicks 47 | function trackClicks(idClick, version, testName) { 48 | const clickableElement = document.getElementById(idClick); 49 | if (clickableElement) { 50 | clickableElement.addEventListener('click', () => { 51 | // Here you would send the click data to your server 52 | fetch(`${serverUrl}/api/tests/track`, { 53 | method: 'POST', 54 | headers: { 55 | 'Content-Type': 'application/json', 56 | 'Authorization': `Bearer ${apiKey}` // Use the API key for authorization instead of CSRF token 57 | }, 58 | body: JSON.stringify({ 59 | apiKey, 60 | version, 61 | clicked: true, 62 | testName: testName, 63 | pagePath: window.location.pathname 64 | }) 65 | }) 66 | .then(response => { 67 | if (!response.ok) { 68 | throw new Error('Failed to send click data.'); 69 | } 70 | console.log('GPTOptimizely Success: Click data sent successfully.'); 71 | }) 72 | .catch(error => { 73 | console.error('GPTOptimizely Tracking Error:', error.message, error.stack); 74 | }); 75 | }); 76 | console.log(`GPTOptimizely Success: Click tracking added to ${idClick}.`); 77 | } else { 78 | console.error(`GPTOptimizely Error: Clickable element with ID ${idClick} not found.`); 79 | } 80 | } 81 | 82 | function waitForElement(id, callback) { 83 | const interval = setInterval(() => { 84 | const element = document.getElementById(id); 85 | if (element) { 86 | clearInterval(interval); 87 | callback(); 88 | } 89 | }, 100); 90 | } 91 | 92 | // Fetch A/B test configuration from the server 93 | fetch(`${serverUrl}/api/tests/config?apiKey=${apiKey}&path=${encodeURIComponent(window.location.pathname)}`) 94 | .then(response => { 95 | if (!response.ok) { 96 | throw new Error('Failed to fetch A/B test configuration.'); 97 | } 98 | return response.json(); 99 | }) 100 | .then(data => { 101 | data.forEach((config) => { 102 | const { idParent, idClick, htmlContent, version, testName } = config; 103 | waitForElement(idParent, () => { 104 | // Inject the HTML content and track impressions 105 | injectHtmlAndTrackImpressions(htmlContent, idParent, version, testName); 106 | // Track clicks on the specified element 107 | trackClicks(idClick, version, testName); 108 | }); 109 | }); 110 | }) 111 | .catch(error => { 112 | console.error('GPTOptimizely Error:', error.message, error.stack); 113 | }); 114 | })(); -------------------------------------------------------------------------------- /routes/abTestRoutes.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const AbTest = require('../models/AbTest'); 3 | const { isAuthenticated } = require('./middleware/authMiddleware'); 4 | const router = express.Router(); 5 | 6 | // Creating a new A/B test 7 | router.post('/tests/create', isAuthenticated, async (req, res) => { 8 | try { 9 | const { testName, pagePaths, IDparent, IDclick, htmlContentA, htmlContentB } = req.body; 10 | const newTest = await AbTest.create({ 11 | testName, 12 | testStatus: 'Stopped', // Default status on creation 13 | pagePaths, 14 | IDparent, 15 | IDclick, 16 | htmlContentA, 17 | htmlContentB, 18 | createdBy: req.session.userId 19 | }); 20 | console.log(`A/B test created: ${newTest.testName}`); 21 | res.redirect('/tests/management'); 22 | } catch (error) { 23 | console.error('Error creating A/B test:', error.message, error.stack); 24 | res.status(500).send('Error creating A/B test.'); 25 | } 26 | }); 27 | 28 | // Starting or stopping an A/B test 29 | router.post('/tests/:testId/toggle-status', isAuthenticated, async (req, res) => { 30 | try { 31 | const test = await AbTest.findById(req.params.testId); 32 | if (!test) { 33 | console.error('Test not found with id:', req.params.testId); 34 | return res.status(404).json({ success: false, message: 'Test not found' }); 35 | } 36 | test.testStatus = test.testStatus === 'Running' ? 'Stopped' : 'Running'; 37 | await test.save(); 38 | console.log(`Test status toggled: ${test.testName} is now ${test.testStatus}`); 39 | res.json({ success: true, testStatus: test.testStatus }); 40 | } catch (error) { 41 | console.error('Error toggling test status:', error.message, error.stack); 42 | res.status(500).json({ success: false, message: 'Error toggling test status' }); 43 | } 44 | }); 45 | 46 | // Updating an A/B test 47 | router.post('/tests/:testId/update', isAuthenticated, async (req, res) => { 48 | try { 49 | const { testId } = req.params; 50 | const { testName, pagePaths, IDparent, IDclick, htmlContentA, htmlContentB, testStatus } = req.body; 51 | const test = await AbTest.findById(testId); 52 | if (!test) { 53 | console.log('Test not found with id:', testId); 54 | return res.status(404).send('Test not found.'); 55 | } 56 | 57 | test.testName = testName; 58 | test.pagePaths = pagePaths; 59 | test.IDparent = IDparent; 60 | test.IDclick = IDclick; 61 | test.htmlContentA = htmlContentA; 62 | test.htmlContentB = htmlContentB; 63 | test.testStatus = testStatus; 64 | 65 | await test.save(); 66 | console.log(`A/B test updated: ${test.testName}`); 67 | res.redirect('/tests/management'); 68 | } catch (error) { 69 | console.error('Error updating A/B test:', error.message, error.stack); 70 | res.status(500).send('Error updating A/B test.'); 71 | } 72 | }); 73 | 74 | // Fetching results for an A/B test and displaying them on a separate page 75 | router.get('/tests/:testId/results', isAuthenticated, async (req, res) => { 76 | try { 77 | const test = await AbTest.findById(req.params.testId); 78 | if (!test) { 79 | console.log(`A/B Test not found with ID: ${req.params.testId}`); 80 | return res.status(404).send('A/B Test not found'); 81 | } 82 | console.log(`Rendering results for A/B Test: ${test.testName}`); 83 | res.render('testResults', { testName: test.testName, clicksA: test.clicksA, clicksB: test.clicksB, impressionsA: test.impressionsA, impressionsB: test.impressionsB }); 84 | } catch (error) { 85 | console.error(`Error rendering results for A/B Test: ${error.message}`, error.stack); 86 | res.status(500).send('Internal server error while rendering A/B test results.'); 87 | } 88 | }); 89 | 90 | module.exports = router; -------------------------------------------------------------------------------- /routes/apiRoutes.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const AbTest = require('../models/AbTest'); 3 | const User = require('../models/User'); 4 | const rateLimit = require('express-rate-limit'); 5 | const { testTrackSchema } = require('../validators/testTrackValidator'); 6 | const router = express.Router(); 7 | 8 | router.get('/api/generate-snippet', async (req, res) => { 9 | const { apiKey } = req.query; 10 | if (!apiKey) { 11 | console.error('API key query parameter is missing.'); 12 | return res.status(400).send('API key is required.'); 13 | } 14 | 15 | try { 16 | const user = await User.findOne({ apiKey }); 17 | if (!user) { 18 | console.error(`Invalid API key: ${apiKey}`); 19 | return res.status(404).send('Invalid API key.'); 20 | } 21 | 22 | const snippet = ``; 23 | console.log(`Snippet generated for API key: ${apiKey}`); 24 | res.type('text/plain').send(snippet); 25 | } catch (error) { 26 | console.error('Error generating snippet:', error.message, error.stack); 27 | res.status(500).send('Internal server error while generating snippet.'); 28 | } 29 | }); 30 | 31 | router.get('/api/tests/config', async (req, res) => { 32 | const { apiKey, path } = req.query; 33 | 34 | if (!apiKey || !path) { 35 | console.error('Missing API key or path query parameters.'); 36 | return res.status(400).send('Missing API key or path.'); 37 | } 38 | 39 | try { 40 | const user = await User.findOne({ apiKey }); 41 | if (!user) { 42 | console.error(`Invalid API key: ${apiKey}`); 43 | return res.status(404).send('Invalid API key.'); 44 | } 45 | 46 | let tests = await AbTest.find({ 47 | createdBy: user._id, 48 | pagePaths: path, 49 | testStatus: 'Running' 50 | }); 51 | 52 | if (tests.length === 0) { 53 | console.log('No running A/B tests found for this path.'); 54 | return res.status(404).send('No running A/B tests found for this path.'); 55 | } 56 | 57 | // Create a map of child to parent 58 | const childToParentMap = {}; 59 | tests.forEach(test => { 60 | childToParentMap[test.testName] = tests.find(t => t.htmlContentA.includes(test.IDparent) || t.htmlContentB.includes(test.IDparent))?.testName || null; 61 | }); 62 | 63 | // Function to recursively find the order 64 | const getOrder = (testName, visited = new Set(), result = []) => { 65 | visited.add(testName); 66 | const parent = childToParentMap[testName]; 67 | if (parent && !visited.has(parent)) { 68 | getOrder(parent, visited, result); 69 | } 70 | result.push(testName); 71 | return result; 72 | }; 73 | 74 | // Get the order for all tests, filtering out duplicates 75 | const order = [...new Set(tests.map(test => getOrder(test.testName)).flat())]; 76 | 77 | // Re-order tests based on the calculated order 78 | tests.sort((a, b) => order.indexOf(a.testName) - order.indexOf(b.testName)); 79 | 80 | const testConfigs = tests.map(test => { 81 | // Randomly decide whether to serve version A or B for each test 82 | const version = Math.random() < 0.5 ? 'A' : 'B'; 83 | const htmlContent = version === 'A' ? test.htmlContentA : test.htmlContentB; 84 | 85 | return { 86 | testName: test.testName, 87 | idParent: test.IDparent, 88 | idClick: test.IDclick, 89 | htmlContent: htmlContent, 90 | version: version 91 | }; 92 | }); 93 | 94 | console.log(`Serving sorted configurations for ${testConfigs.length} tests on path '${path}' with correct parent-child order.`); 95 | res.json(testConfigs); 96 | } catch (error) { 97 | console.error('Error fetching and sorting A/B test configuration:', error.message, error.stack); 98 | res.status(500).send('Internal server error while fetching and sorting A/B test configuration.'); 99 | } 100 | }); 101 | 102 | // Define a simple rate limit rule: Allow 100 requests per hour per IP 103 | const trackApiLimiter = rateLimit({ 104 | windowMs: 60 * 60 * 1000, // 1 hour 105 | max: 100, 106 | message: 'Too many requests from this IP, please try again after an hour' 107 | }); 108 | 109 | // Add a new route for tracking clicks 110 | router.post('/api/tests/track', trackApiLimiter, async (req, res) => { 111 | const validation = testTrackSchema.validate(req.body); 112 | if (validation.error) { 113 | console.error('Validation error:', validation.error.message); 114 | return res.status(400).send('Validation error: ' + validation.error.message); 115 | } 116 | 117 | const { apiKey, version, clicked, testName, pagePath } = req.body; 118 | 119 | try { 120 | const user = await User.findOne({ apiKey }); 121 | if (!user) { 122 | console.error(`Invalid API key: ${apiKey}`); 123 | return res.status(404).send('Invalid API key.'); 124 | } 125 | 126 | const filter = { createdBy: user._id, testName: testName, pagePaths: pagePath, testStatus: 'Running' }; 127 | const update = version === 'A' ? { $inc: { clicksA: 1 } } : { $inc: { clicksB: 1 } }; 128 | 129 | const test = await AbTest.findOneAndUpdate(filter, update, { new: true }); 130 | 131 | if (!test) { 132 | console.error('A/B Test not found or not running on specified path.'); 133 | return res.status(404).send('A/B Test not found or not running on specified path.'); 134 | } 135 | 136 | console.log(`Click for ${testName} tracked successfully.`); 137 | res.status(200).send('Click tracked successfully.'); 138 | } catch (error) { 139 | console.error('Error tracking A/B test click:', error.message, error.stack); 140 | res.status(500).send('Error tracking A/B test click.'); 141 | } 142 | }); 143 | 144 | // Add a new route for tracking impressions 145 | router.post('/api/tests/impression', trackApiLimiter, async (req, res) => { 146 | const { apiKey, version, testName, pagePath } = req.body; 147 | 148 | try { 149 | const user = await User.findOne({ apiKey }); 150 | if (!user) { 151 | console.error(`Invalid API key: ${apiKey}`); 152 | return res.status(404).send('Invalid API key.'); 153 | } 154 | 155 | const filter = { createdBy: user._id, testName: testName, pagePaths: pagePath, testStatus: 'Running' }; 156 | const update = version === 'A' ? { $inc: { impressionsA: 1 } } : { $inc: { impressionsB: 1 } }; 157 | 158 | const test = await AbTest.findOneAndUpdate(filter, update, { new: true }); 159 | 160 | if (!test) { 161 | console.error('A/B Test not found or not running on specified path for impression tracking.'); 162 | return res.status(404).send('A/B Test not found or not running on specified path for impression tracking.'); 163 | } 164 | 165 | console.log(`Impression for ${testName} tracked successfully.`); 166 | res.status(200).send('Impression tracked successfully.'); 167 | } catch (error) { 168 | console.error('Error tracking A/B test impression:', error.message, error.stack); 169 | res.status(500).send('Error tracking A/B test impression.'); 170 | } 171 | }); 172 | 173 | module.exports = router; 174 | -------------------------------------------------------------------------------- /routes/authRoutes.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const User = require('../models/User'); 3 | const bcrypt = require('bcrypt'); 4 | const router = express.Router(); 5 | 6 | router.get('/auth/register', (req, res) => { 7 | res.render('register', { error: '' }); 8 | }); 9 | 10 | router.post('/auth/register', async (req, res) => { 11 | try { 12 | const { email, password } = req.body; 13 | const existingUser = await User.findOne({ email }); 14 | if (existingUser) { 15 | console.log('Email already exists:', email); 16 | return res.render('register', { error: 'Email already exists.' }); 17 | } 18 | const user = new User({ email, password }); 19 | await user.save(); 20 | console.log('User registered successfully:', email); 21 | res.redirect('/auth/login'); 22 | } catch (error) { 23 | console.error('Registration error:', error.message, error.stack); 24 | res.render('register', { error: 'Error during registration' }); 25 | } 26 | }); 27 | 28 | router.get('/auth/login', (req, res) => { 29 | res.render('login', { error: '' }); 30 | }); 31 | 32 | router.post('/auth/login', async (req, res) => { 33 | try { 34 | const { email, password } = req.body; 35 | const user = await User.findOne({ email }); 36 | if (!user) { 37 | console.log('User not found:', email); 38 | return res.render('login', { error: 'Invalid email or password.' }); 39 | } 40 | const isMatch = await bcrypt.compare(password, user.password); 41 | if (!isMatch) { 42 | console.log('Password is incorrect for user:', email); 43 | return res.render('login', { error: 'Invalid email or password.' }); 44 | } 45 | req.session.userId = user._id; 46 | console.log('User logged in successfully:', email); 47 | res.redirect('/'); 48 | } catch (error) { 49 | console.error('Login error:', error.message, error.stack); 50 | res.render('login', { error: 'Error during login' }); 51 | } 52 | }); 53 | 54 | router.get('/auth/logout', (req, res) => { 55 | req.session.destroy(err => { 56 | if (err) { 57 | console.error('Error during session destruction:', err.message, err.stack); 58 | return res.status(500).send('Error logging out'); 59 | } 60 | console.log('User logged out successfully'); 61 | res.redirect('/auth/login'); 62 | }); 63 | }); 64 | 65 | module.exports = router; -------------------------------------------------------------------------------- /routes/middleware/authMiddleware.js: -------------------------------------------------------------------------------- 1 | const isAuthenticated = (req, res, next) => { 2 | if (req.session && req.session.userId) { 3 | return next(); // User is authenticated, proceed to the next middleware/route handler 4 | } else { 5 | return res.status(401).send('You are not authenticated'); // User is not authenticated 6 | } 7 | }; 8 | 9 | const redirectToLoginIfNotAuthenticated = (req, res, next) => { 10 | if (!req.session || !req.session.userId) { 11 | console.log("Redirecting unauthenticated user to login page."); 12 | return res.redirect('/auth/login'); 13 | } else { 14 | next(); // Proceed if authenticated 15 | } 16 | }; 17 | 18 | module.exports = { 19 | isAuthenticated, 20 | redirectToLoginIfNotAuthenticated 21 | }; -------------------------------------------------------------------------------- /routes/userRoutes.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const router = express.Router(); 3 | const { isAuthenticated, redirectToLoginIfNotAuthenticated } = require('./middleware/authMiddleware'); 4 | const User = require('../models/User'); 5 | const AbTest = require('../models/AbTest'); 6 | 7 | router.get('/account', redirectToLoginIfNotAuthenticated, async (req, res) => { 8 | try { 9 | const user = await User.findById(req.session.userId); 10 | if (!user) { 11 | console.log(`User not found with ID: ${req.session.userId}`); 12 | return res.status(404).send('User not found'); 13 | } 14 | console.log(`Rendering account page for user: ${user.username}`); 15 | console.log(`Current allowed origins: ${user.allowedOrigins}`); 16 | res.render('account', { user, req }); 17 | } catch (error) { 18 | console.error(`Error fetching user details: ${error.message}`, error.stack); 19 | res.status(500).send('Internal server error'); 20 | } 21 | }); 22 | 23 | router.post('/account/update-origins', redirectToLoginIfNotAuthenticated, async (req, res) => { 24 | try { 25 | const { action, origin } = req.body; 26 | const user = await User.findById(req.session.userId); 27 | 28 | if (action === 'add') { 29 | user.allowedOrigins.push(origin); 30 | console.log(`Added new origin ${origin} for user ${user.username}`); 31 | } else if (action === 'remove') { 32 | user.allowedOrigins = user.allowedOrigins.filter(o => o !== origin); 33 | console.log(`Removed origin ${origin} for user ${user.username}`); 34 | } 35 | 36 | await user.save(); 37 | console.log(`Updated allowed origins for user ${user.username}: ${user.allowedOrigins}`); 38 | res.redirect('/account'); 39 | } catch (error) { 40 | console.error(`Error updating allowed origins: ${error.message}`, error.stack); 41 | res.status(500).send('Internal server error'); 42 | } 43 | }); 44 | 45 | router.get('/tests/management', redirectToLoginIfNotAuthenticated, async (req, res) => { 46 | try { 47 | const tests = await AbTest.find({ createdBy: req.session.userId }); 48 | console.log(`Rendering A/B tests management page for user ID: ${req.session.userId}`); 49 | res.render('abTests', { tests }); 50 | } catch (error) { 51 | console.error(`Error fetching A/B tests: ${error.message}`, error.stack); 52 | res.status(500).send('Internal server error'); 53 | } 54 | }); 55 | 56 | router.get('/tests/new', redirectToLoginIfNotAuthenticated, (req, res) => { 57 | try { 58 | console.log("Rendering the 'Create New Test' form."); 59 | res.render('testForm'); 60 | } catch (error) { 61 | console.error(`Error displaying the 'Create New Test' form: ${error.message}`, error.stack); 62 | res.status(500).send('Internal server error while rendering the new test form.'); 63 | } 64 | }); 65 | 66 | router.get('/tests/:testId/edit', redirectToLoginIfNotAuthenticated, async (req, res) => { 67 | try { 68 | const test = await AbTest.findById(req.params.testId); 69 | if (!test) { 70 | console.log(`A/B Test not found with ID: ${req.params.testId}`); 71 | return res.status(404).send('A/B Test not found'); 72 | } 73 | console.log(`Rendering edit form for A/B Test: ${test.testName}`); 74 | res.render('editTestForm', { test }); 75 | } catch (error) { 76 | console.error(`Error fetching A/B test for edit: ${error.message}`, error.stack); 77 | res.status(500).send('Internal server error while fetching A/B test for edit.'); 78 | } 79 | }); 80 | 81 | router.post('/tests/:testId/toggle-status', isAuthenticated, async (req, res) => { 82 | try { 83 | const test = await AbTest.findById(req.params.testId); 84 | if (!test) { 85 | console.log(`A/B Test not found with ID: ${req.params.testId}`); 86 | return res.status(404).send('A/B Test not found'); 87 | } 88 | test.testStatus = test.testStatus === 'Running' ? 'Stopped' : 'Running'; 89 | await test.save(); 90 | console.log(`Test status toggled: ${test.testName} is now ${test.testStatus}`); 91 | res.redirect('/tests/management'); 92 | } catch (error) { 93 | console.error(`Error toggling test status: ${error.message}`, error.stack); 94 | res.status(500).send('Error toggling test status.'); 95 | } 96 | }); 97 | 98 | router.get('/', async (req, res) => { 99 | const tooltips = [ 100 | { title: "Built with Pythagora", description: "Optimize your website with powerful A/B testing" }, 101 | { title: "Create A/B Tests", description: "Easily set up tests to compare different versions of your web pages" }, 102 | { title: "Track Results", description: "Monitor the performance of your tests in real-time" }, 103 | { title: "Make Data-Driven Decisions", description: "Use insights from your tests to improve your website" } 104 | ]; 105 | 106 | let userTests = []; 107 | if (req.session.userId) { 108 | userTests = await AbTest.find({ createdBy: req.session.userId }); 109 | } 110 | 111 | res.render('index', { 112 | tooltips, 113 | userTests, 114 | user: req.session.userId ? { id: req.session.userId } : null, 115 | csrfToken: req.csrfToken() 116 | }); 117 | }); 118 | 119 | module.exports = router; -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | require("dotenv").config(); 2 | const mongoose = require("mongoose"); 3 | const express = require("express"); 4 | const session = require("express-session"); 5 | const MongoStore = require('connect-mongo'); 6 | const helmet = require('helmet'); 7 | const csurf = require('csurf'); 8 | const User = require('./models/User'); 9 | const authRoutes = require("./routes/authRoutes"); 10 | const apiRoutes = require('./routes/apiRoutes'); 11 | const abTestRoutes = require('./routes/abTestRoutes'); 12 | const userRoutes = require('./routes/userRoutes'); 13 | const cors = require('cors'); 14 | const fs = require('fs'); 15 | const path = require('path'); 16 | const { redirectToLoginIfNotAuthenticated } = require('./routes/middleware/authMiddleware'); 17 | 18 | if (!process.env.DATABASE_URL || !process.env.SESSION_SECRET) { 19 | console.error("Error: config environment variables not set. Please create/edit .env configuration file."); 20 | process.exit(-1); 21 | } 22 | 23 | const app = express(); 24 | const port = process.env.PORT || 3000; 25 | 26 | app.use(express.urlencoded({ extended: true })); 27 | app.use(express.json()); 28 | 29 | const corsOptionsDelegate = async function (req, callback) { 30 | let corsOptions; 31 | if (req.path === '/api/tests/config' || req.path === '/api/tests/impression' || req.path === '/api/tests/track') { 32 | const apiKey = req.method === 'OPTIONS' ? req.headers['access-control-request-headers'].split(',').find(header => header.trim().toLowerCase() === 'x-api-key') : (req.query.apiKey || (req.body && req.body.apiKey)); 33 | try { 34 | if (req.method === 'OPTIONS') { 35 | corsOptions = { 36 | origin: true, 37 | credentials: true, 38 | methods: ['POST', 'GET', 'OPTIONS'], 39 | allowedHeaders: ['Content-Type', 'x-api-key', 'authorization'] // Add 'authorization' here 40 | }; 41 | } else { 42 | const user = await User.findOne({ apiKey }); 43 | if (user && user.allowedOrigins.includes(req.header('Origin'))) { 44 | corsOptions = { origin: true, credentials: true }; 45 | } else { 46 | corsOptions = { origin: false }; 47 | } 48 | } 49 | } catch (err) { 50 | console.error(`Error during CORS configuration: ${err}`); 51 | corsOptions = { origin: false }; 52 | } 53 | } else { 54 | corsOptions = { origin: false }; 55 | } 56 | callback(null, corsOptions); 57 | }; 58 | 59 | app.use(cors(corsOptionsDelegate)); 60 | 61 | app.use(helmet({ 62 | contentSecurityPolicy: { 63 | directives: { 64 | defaultSrc: ["'self'"], 65 | scriptSrc: ["'self'", "'unsafe-inline'", "'unsafe-eval'", 'https://cdn.jsdelivr.net'], 66 | styleSrc: ["'self'", "'unsafe-inline'", 'https:'], 67 | imgSrc: ["'self'", 'data:', 'https:'], 68 | connectSrc: ["'self'", 'wss:', 'https:', 'http:'], 69 | fontSrc: ["'self'", 'https:', 'data:'], 70 | mediaSrc: ["'self'"], 71 | frameSrc: ["'self'"], 72 | workerSrc: ["'self'", 'blob:'], 73 | childSrc: ["'self'"], 74 | formAction: ["'self'"], 75 | upgradeInsecureRequests: [], 76 | }, 77 | }, 78 | })); 79 | 80 | app.use('/loader.js', (req, res, next) => { 81 | res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin'); 82 | next(); 83 | }); 84 | 85 | app.set("view engine", "ejs"); 86 | 87 | app.get('/loader.js', (req, res) => { 88 | fs.readFile(path.join(__dirname, 'public', 'loader.js'), 'utf8', (err, data) => { 89 | if (err) { 90 | console.error('Error reading loader.js file:', err); 91 | return res.status(500).send('Error serving loader script.'); 92 | } 93 | const updatedData = data.replace('%%SERVER_URL%%', `https://${req.get('host')}`); 94 | res.type('application/javascript').send(updatedData); 95 | }); 96 | }); 97 | 98 | app.use(express.static("public")); 99 | 100 | mongoose 101 | .connect(process.env.DATABASE_URL) 102 | .then(() => { 103 | console.log("Database connected successfully"); 104 | }) 105 | .catch((err) => { 106 | console.error(`Database connection error: ${err.message}`); 107 | console.error(err.stack); 108 | process.exit(1); 109 | }); 110 | 111 | app.use( 112 | session({ 113 | secret: process.env.SESSION_SECRET, 114 | resave: false, 115 | saveUninitialized: false, 116 | store: MongoStore.create({ mongoUrl: process.env.DATABASE_URL }), 117 | cookie: { secure: false, httpOnly: true, sameSite: 'lax' } 118 | }), 119 | ); 120 | 121 | app.use(function (req, res, next) { 122 | if (req.path === '/api/tests/track' || req.path === '/api/tests/config' || req.path === '/loader.js' || (req.path === '/api/tests/impression' && req.method === 'POST')) { 123 | return next(); 124 | } 125 | csurf()(req, res, next); 126 | }); 127 | 128 | app.use(function (req, res, next) { 129 | if (req.csrfToken) { 130 | res.locals.csrfToken = req.csrfToken(); 131 | } 132 | next(); 133 | }); 134 | 135 | app.on("error", (error) => { 136 | console.error(`Server error: ${error.message}`); 137 | console.error(error.stack); 138 | }); 139 | 140 | app.use((req, res, next) => { 141 | const sess = req.session; 142 | res.locals.session = sess; 143 | if (!sess.views) { 144 | sess.views = 1; 145 | console.log("Session created at: ", new Date().toISOString()); 146 | } else { 147 | sess.views++; 148 | console.log( 149 | `Session accessed again at: ${new Date().toISOString()}, Views: ${sess.views}, User ID: ${sess.userId || '(unauthenticated)'}` 150 | ); 151 | } 152 | next(); 153 | }); 154 | 155 | app.use(authRoutes); 156 | 157 | app.use('/api/tests/track', async (req, res, next) => { 158 | const apiKey = req.body.apiKey; 159 | if (!apiKey) { 160 | return res.status(401).send('API Key is required'); 161 | } 162 | const user = await User.findOne({ apiKey }); 163 | if (!user) { 164 | return res.status(401).send('Invalid API Key'); 165 | } 166 | next(); 167 | }); 168 | 169 | app.use(apiRoutes); 170 | 171 | app.use(abTestRoutes); 172 | 173 | app.use(userRoutes); 174 | 175 | app.get("/", redirectToLoginIfNotAuthenticated, (req, res) => { 176 | res.redirect("/tests/management"); 177 | }); 178 | 179 | app.use((req, res, next) => { 180 | res.status(404).send("Page not found."); 181 | }); 182 | 183 | app.use((err, req, res, next) => { 184 | console.error(`Unhandled application error: ${err.message}`); 185 | console.error(err.stack); 186 | res.status(500).send("There was an error serving your request."); 187 | }); 188 | 189 | app.listen(port, () => { 190 | console.log(`Server running at http://localhost:${port}`); 191 | }); -------------------------------------------------------------------------------- /validators/testTrackValidator.js: -------------------------------------------------------------------------------- 1 | const Joi = require('joi'); 2 | 3 | const testTrackSchema = Joi.object({ 4 | apiKey: Joi.string().required(), 5 | version: Joi.string().valid('A', 'B').required(), 6 | clicked: Joi.boolean().required(), 7 | testName: Joi.string().required(), 8 | pagePath: Joi.string().required() 9 | }); 10 | 11 | module.exports = { 12 | testTrackSchema 13 | }; -------------------------------------------------------------------------------- /views/abTests.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | <%- include('partials/_head.ejs') %> 4 |
5 | <%- include('partials/_header.ejs') %> 6 |<%= user.apiKey %>
11 | <script src="<%= `${req.protocol}://${req.get('host')}/loader.js` %>" data-api-key="<%= user.apiKey %>"></script>
13 | Status: <%= test.testStatus %>
31 | Edit 32 | 33 | <%= test.testStatus === 'Running' ? 'Stop' : 'Start' %> 34 | 35 | View Results 36 |