Merge pull request #5 from derekslenk/feature/profanity-filter-isolation

Profanity Filter Database Isolation & Timezone Fixes
This commit is contained in:
Deco Vander 2025-07-04 14:21:12 -04:00 committed by GitHub
commit 27c9d3da00
19 changed files with 6025 additions and 378 deletions

17
jest.config.js Normal file
View file

@ -0,0 +1,17 @@
module.exports = {
testEnvironment: 'node',
collectCoverageFrom: [
'**/*.js',
'!**/node_modules/**',
'!**/coverage/**',
'!**/public/**',
'!jest.config.js',
'!server.js' // Exclude main server file as it's integration-focused
],
testMatch: [
'**/tests/**/*.test.js',
'**/tests/**/*.spec.js'
],
verbose: true,
setupFilesAfterEnv: ['<rootDir>/tests/setup.js']
};

3627
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -5,7 +5,9 @@
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js"
"dev": "nodemon server.js",
"test": "jest --runInBand --forceExit",
"test:coverage": "jest --coverage"
},
"dependencies": {
"cors": "^2.8.5",
@ -15,7 +17,9 @@
"sqlite3": "^5.1.6"
},
"devDependencies": {
"nodemon": "^3.0.1"
"jest": "^29.7.0",
"nodemon": "^3.0.1",
"supertest": "^6.3.4"
},
"keywords": [
"ice",

573
profanity-filter.js Normal file
View file

@ -0,0 +1,573 @@
/**
* Comprehensive Server-Side Profanity Filter for Ice Watch
* Filters inappropriate language with database-backed custom word management
*/
const sqlite3 = require('sqlite3').verbose();
const path = require('path');
class ProfanityFilter {
/**
* Static factory method for creating and initializing a ProfanityFilter
* @param {string|null} dbPath - Optional path to database file
* @returns {Promise<ProfanityFilter>} Fully initialized ProfanityFilter instance
*/
static async create(dbPath = null) {
const filter = new ProfanityFilter(dbPath);
await filter.initialize();
return filter;
}
constructor(dbPath = null) {
// Initialize separate database for profanity filter
const defaultDbPath = path.join(__dirname, 'profanity.db');
this.dbPath = dbPath || defaultDbPath;
this.db = new sqlite3.Database(this.dbPath);
this.isInitialized = false;
// Base profanity words - comprehensive list
this.baseProfanityWords = [
// Common profanity
'damn', 'hell', 'crap', 'shit', 'fuck', 'ass', 'bitch', 'bastard',
'piss', 'whore', 'slut', 'retard', 'fag', 'gay', 'homo', 'tranny',
'dickhead', 'asshole', 'motherfucker', 'cocksucker', 'twat', 'cunt',
// Racial slurs and hate speech
'nigger', 'nigga', 'spic', 'wetback', 'chink', 'gook', 'kike',
'raghead', 'towelhead', 'beaner', 'cracker', 'honkey', 'whitey',
'kyke', 'jigaboo', 'coon', 'darkie', 'mammy', 'pickaninny',
// Sexual content
'penis', 'vagina', 'boob', 'tit', 'cock', 'dick', 'pussy', 'cum',
'sex', 'porn', 'nude', 'naked', 'horny', 'masturbate', 'orgasm',
'blowjob', 'handjob', 'anal', 'penetration', 'erection', 'climax',
// Violence and threats
'kill', 'murder', 'shoot', 'bomb', 'terrorist', 'suicide', 'rape',
'violence', 'assault', 'attack', 'threat', 'harm', 'hurt', 'pain',
'stab', 'strangle', 'torture', 'execute', 'assassinate', 'slaughter',
// Drugs and substances
'weed', 'marijuana', 'cocaine', 'heroin', 'meth', 'drugs', 'high',
'stoned', 'drunk', 'alcohol', 'beer', 'liquor', 'vodka', 'whiskey',
'ecstasy', 'lsd', 'crack', 'dope', 'pot', 'joint', 'bong',
// Religious/cultural insults
'jesus christ', 'goddamn', 'christ almighty', 'holy shit', 'god damn',
'for christ sake', 'jesus fucking christ', 'holy fuck',
// Body parts (inappropriate context)
'testicles', 'balls', 'scrotum', 'clitoris', 'labia', 'anus',
'rectum', 'butthole', 'nipples', 'breasts',
// Misc inappropriate
'wtf', 'omfg', 'stfu', 'gtfo', 'milf', 'dilf', 'thot', 'simp',
'incel', 'chad', 'beta', 'alpha male', 'mansplain', 'karen'
];
// Leetspeak and common substitutions
this.leetMap = {
'0': 'o', '1': 'i', '3': 'e', '4': 'a', '5': 's', '6': 'g', '7': 't',
'8': 'b', '9': 'g', '@': 'a', '$': 's', '!': 'i', '+': 't', '*': 'a',
'%': 'a', '(': 'c', ')': 'c', '&': 'a', '#': 'h', '|': 'l', '\\': '/'
};
// Initialize custom words array
this.customWords = [];
// Initialize patterns to null; will be built during async initialization
this.patterns = null;
}
/**
* Async initialization method that must be called after construction
* @returns {Promise<void>}
*/
async initialize() {
if (this.isInitialized) {
return;
}
try {
// Initialize database synchronously first
await this.initializeDatabaseAsync();
// Load custom words from database
await this.loadCustomWords();
this.isInitialized = true;
console.log('ProfanityFilter initialization completed successfully');
} catch (error) {
console.error('Error during ProfanityFilter initialization:', error);
throw error;
}
}
/**
* Initialize the database table for custom profanity words (async version)
*/
async initializeDatabaseAsync() {
return new Promise((resolve, reject) => {
this.db.serialize(() => {
this.db.run(`CREATE TABLE IF NOT EXISTS profanity_words (
id INTEGER PRIMARY KEY AUTOINCREMENT,
word TEXT NOT NULL UNIQUE,
severity TEXT DEFAULT 'medium',
category TEXT DEFAULT 'custom',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
created_by TEXT DEFAULT 'admin'
)`, (err) => {
if (err) {
console.error('Error creating profanity_words table:', err);
reject(err);
} else {
console.log('Profanity words table initialized successfully');
resolve();
}
});
});
});
}
// Removed the deprecated initializeDatabase method as it is unused and no longer necessary.
/**
* Load custom words from database
*/
async loadCustomWords() {
return new Promise((resolve, reject) => {
this.db.all(
'SELECT word, severity, category FROM profanity_words',
[],
(err, rows) => {
if (err) {
console.error('Error loading custom profanity words:', err);
reject(err);
return;
}
this.customWords = rows.map(row => ({
word: row.word.toLowerCase(),
severity: row.severity,
category: row.category
}));
console.log(`Loaded ${this.customWords.length} custom profanity words`);
this.patterns = this.buildPatterns(); // Rebuild patterns with custom words
resolve();
}
);
});
}
/**
* Check if the filter is fully initialized and warn if not
* @private
*/
_checkInitialization() {
if (!this.isInitialized) {
console.warn('⚠️ ProfanityFilter: Using base words only - custom words not loaded yet. Call initialize() or use ProfanityFilter.create() for full functionality.');
}
}
/**
* Get all profanity words (base + custom)
*/
getAllWords() {
this._checkInitialization();
const baseWords = this.baseProfanityWords.map(word => ({
word: word.toLowerCase(),
severity: this.getSeverity(word),
category: 'base'
}));
return [...baseWords, ...this.customWords];
}
/**
* Build regex patterns for profanity detection
*/
buildPatterns() {
const allWords = this.getAllWords();
return allWords.map(wordObj => {
const word = wordObj.word;
// Create simple word boundary pattern first
let pattern = word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // Escape special chars
// For simple detection, use word boundaries
const simpleRegex = new RegExp(`\\b${pattern}\\b`, 'gi');
// Also create pattern with character substitutions for advanced detection
const advancedPattern = word.split('').map(char => {
const substitutes = this.getCharSubstitutes(char);
if (substitutes.length > 1) {
// Escape special regex characters in substitutes
const escapedSubs = substitutes.map(s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
return `[${escapedSubs.join('')}]`;
}
return char;
}).join('[\\s\\-_\\.,;:!?]*?');
const advancedRegex = new RegExp(`\\b${advancedPattern}\\b`, 'gi');
return {
regex: simpleRegex,
advancedRegex: advancedRegex,
word: word,
severity: wordObj.severity,
category: wordObj.category
};
});
}
/**
* Get character substitutes including leetspeak
*/
getCharSubstitutes(char) {
const substitutes = [char];
// Add leetspeak equivalents
Object.keys(this.leetMap).forEach(leet => {
if (this.leetMap[leet] === char) {
substitutes.push(leet);
}
});
// Add common character substitutions
const charMap = {
'a': ['@', '4', '*'],
'e': ['3'],
'i': ['1', '!', '|'],
'o': ['0'],
's': ['$', '5'],
't': ['7', '+'],
'g': ['6', '9'],
'b': ['8'],
'l': ['1', '|']
};
if (charMap[char]) {
substitutes.push(...charMap[char]);
}
return [...new Set(substitutes)]; // Remove duplicates
}
/**
* Normalize text to handle various obfuscation attempts
*/
normalizeText(text) {
if (!text || typeof text !== 'string') return '';
let normalized = text.toLowerCase();
// Replace leetspeak characters - escape special regex characters properly
Object.keys(this.leetMap).forEach(leet => {
const escapedLeet = leet.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
normalized = normalized.replace(new RegExp(escapedLeet, 'g'), this.leetMap[leet]);
});
// Handle spaced out words (f u c k -> fuck) - more comprehensive
normalized = normalized.replace(/\b([a-z])\s+([a-z])\s+([a-z])\s+([a-z])\s+([a-z])\b/g, '$1$2$3$4$5');
normalized = normalized.replace(/\b([a-z])\s+([a-z])\s+([a-z])\s+([a-z])\b/g, '$1$2$3$4');
normalized = normalized.replace(/\b([a-z])\s+([a-z])\s+([a-z])\b/g, '$1$2$3');
// Remove excessive punctuation but keep word boundaries
normalized = normalized.replace(/[^\w\s]/g, ' ').replace(/\s+/g, ' ').trim();
return normalized;
}
/**
* Check if text contains profanity
*/
containsProfanity(text) {
if (!text || typeof text !== 'string') return false;
this._checkInitialization();
const normalizedText = this.normalizeText(text);
const originalText = text.toLowerCase();
return this.patterns.some(pattern => {
return pattern.regex.test(normalizedText) ||
pattern.regex.test(originalText) ||
(pattern.advancedRegex && pattern.advancedRegex.test(normalizedText));
});
}
/**
* Get detailed analysis of profanity in text
*/
analyzeProfanity(text) {
if (!text || typeof text !== 'string') {
return {
hasProfanity: false,
matches: [],
severity: 'none',
count: 0,
filtered: ''
};
}
this._checkInitialization();
const normalizedText = this.normalizeText(text);
const originalText = text.toLowerCase();
const matches = [];
let filteredText = text;
this.patterns.forEach(pattern => {
let found = null;
let matchedText = '';
// Try simple regex first on both normalized and original text
found = normalizedText.match(pattern.regex);
if (found) {
matchedText = normalizedText;
} else {
found = originalText.match(pattern.regex);
if (found) {
matchedText = originalText;
}
}
// If not found, try advanced regex
if (!found && pattern.advancedRegex) {
found = normalizedText.match(pattern.advancedRegex);
if (found) {
matchedText = normalizedText;
} else {
found = originalText.match(pattern.advancedRegex);
if (found) {
matchedText = originalText;
}
}
}
if (found) {
// Only add if not already detected
if (!matches.some(m => m.word === pattern.word)) {
matches.push({
word: pattern.word,
matches: found,
severity: pattern.severity,
category: pattern.category
});
}
// Replace in original text - try multiple patterns
const wordPattern = pattern.word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const simplePattern = new RegExp(`\\b${wordPattern}\\b`, 'gi');
filteredText = filteredText.replace(simplePattern, '*'.repeat(Math.max(3, pattern.word.length)));
// Also try to replace with the exact matches found
found.forEach(match => {
const exactPattern = new RegExp(match.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi');
filteredText = filteredText.replace(exactPattern, '*'.repeat(Math.max(3, match.length)));
});
// Also try to replace with advanced pattern
if (pattern.advancedRegex) {
filteredText = filteredText.replace(pattern.advancedRegex, '*'.repeat(Math.max(3, pattern.word.length)));
}
}
});
const hasProfanity = matches.length > 0;
const maxSeverity = hasProfanity ?
Math.max(...matches.map(m => this.getSeverityLevel(m.severity))) : 0;
return {
hasProfanity,
matches,
severity: this.getSeverityName(maxSeverity),
count: matches.length,
filtered: filteredText
};
}
/**
* Filter profanity from text
*/
filterProfanity(text, replacement = '*') {
const analysis = this.analyzeProfanity(text);
let filteredText = text;
for (const match of analysis.matches) {
const regex = new RegExp(`\\b${match.word}\\b`, 'gi');
filteredText = filteredText.replace(regex, replacement.repeat(match.word.length));
}
return filteredText;
}
/**
* Get severity level for a word
*/
getSeverity(word) {
// High severity: hate speech, extreme profanity, threats
const highSeverity = [
'nigger', 'nigga', 'kill', 'murder', 'shoot', 'bomb', 'terrorist',
'rape', 'kike', 'fag', 'raghead', 'towelhead', 'motherfucker',
'cunt', 'cocksucker', 'jigaboo', 'coon', 'execute', 'assassinate'
];
// Medium severity: sexual content, moderate profanity
const mediumSeverity = [
'fuck', 'shit', 'bitch', 'whore', 'slut', 'penis', 'vagina',
'cock', 'dick', 'pussy', 'cum', 'sex', 'porn', 'asshole',
'dickhead', 'twat', 'blowjob', 'handjob'
];
if (highSeverity.includes(word.toLowerCase())) return 'high';
if (mediumSeverity.includes(word.toLowerCase())) return 'medium';
return 'low';
}
/**
* Get numeric severity level
*/
getSeverityLevel(severity) {
switch (severity) {
case 'high': return 3;
case 'medium': return 2;
case 'low': return 1;
default: return 0;
}
}
/**
* Get severity name from level
*/
getSeverityName(level) {
switch (level) {
case 3: return 'high';
case 2: return 'medium';
case 1: return 'low';
default: return 'none';
}
}
/**
* Add a custom word to the database
*/
async addCustomWord(word, severity = 'medium', category = 'custom', createdBy = 'admin') {
return new Promise((resolve, reject) => {
const normalizedWord = word.toLowerCase().trim();
this.db.run(
'INSERT INTO profanity_words (word, severity, category, created_by) VALUES (?, ?, ?, ?)',
[normalizedWord, severity, category, createdBy],
function(err) {
if (err) {
if (err.message.includes('UNIQUE constraint failed')) {
reject(new Error('Word already exists in the filter'));
} else {
reject(err);
}
return;
}
console.log(`Added custom profanity word: ${normalizedWord}`);
resolve({
id: this.lastID,
word: normalizedWord,
severity,
category,
created_by: createdBy
});
}
);
});
}
/**
* Remove a custom word from the database
*/
async removeCustomWord(wordId) {
return new Promise((resolve, reject) => {
this.db.run(
'DELETE FROM profanity_words WHERE id = ?',
[wordId],
function(err) {
if (err) {
reject(err);
return;
}
if (this.changes === 0) {
reject(new Error('Word not found'));
return;
}
console.log(`Removed custom profanity word with ID: ${wordId}`);
resolve({ deleted: true, changes: this.changes });
}
);
});
}
/**
* Get all custom words from database
*/
async getCustomWords() {
return new Promise((resolve, reject) => {
this.db.all(
'SELECT id, word, severity, category, created_at, created_by FROM profanity_words ORDER BY created_at DESC',
[],
(err, rows) => {
if (err) {
reject(err);
return;
}
resolve(rows);
}
);
});
}
/**
* Update a custom word
*/
async updateCustomWord(wordId, updates) {
return new Promise((resolve, reject) => {
const { word, severity, category } = updates;
this.db.run(
'UPDATE profanity_words SET word = ?, severity = ?, category = ? WHERE id = ?',
[word.toLowerCase().trim(), severity, category, wordId],
function(err) {
if (err) {
reject(err);
return;
}
if (this.changes === 0) {
reject(new Error('Word not found'));
return;
}
console.log(`Updated custom profanity word with ID: ${wordId}`);
resolve({ updated: true, changes: this.changes });
}
);
});
}
/**
* Close the database connection
*/
close() {
if (this.db) {
this.db.close((err) => {
if (err) {
console.error('Error closing profanity filter database:', err);
}
});
}
}
}
module.exports = ProfanityFilter;

View file

@ -184,7 +184,7 @@
gap: 20px;
margin-bottom: 20px;
}
.stat-card {
background: var(--card-bg);
color: var(--text-color);
@ -193,19 +193,123 @@
box-shadow: 0 2px 4px var(--shadow);
text-align: center;
}
.stat-number {
font-size: 2em;
font-weight: bold;
color: #007bff;
}
.address-cell {
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Tab Navigation */
.tab-navigation {
display: flex;
gap: 10px;
margin-bottom: 20px;
border-bottom: 2px solid #ddd;
}
.tab-btn {
padding: 12px 20px;
background: none;
border: none;
border-bottom: 3px solid transparent;
cursor: pointer;
font-size: 16px;
font-weight: 500;
color: #666;
transition: all 0.3s ease;
}
.tab-btn.active {
color: #007bff;
border-bottom-color: #007bff;
background: rgba(0, 123, 255, 0.1);
}
.tab-btn:hover {
color: #007bff;
background: rgba(0, 123, 255, 0.05);
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
/* Profanity Management Forms */
.profanity-add-form,
.profanity-test-form {
background: #f8f9fa;
padding: 20px;
border-radius: 8px;
margin-bottom: 20px;
}
.profanity-add-form h4,
.profanity-test-form h4 {
margin-top: 0;
color: #495057;
}
.form-row {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
}
.form-row input,
.form-row select {
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
}
.form-row input[type="text"] {
flex: 1;
min-width: 200px;
}
.form-row select {
min-width: 140px;
}
.test-results {
margin-top: 15px;
padding: 15px;
border-radius: 4px;
min-height: 40px;
font-family: monospace;
}
.test-results.clean {
background: #d4edda;
border: 1px solid #c3e6cb;
color: #155724;
}
.test-results.profane {
background: #f8d7da;
border: 1px solid #f5c6cb;
color: #721c24;
}
.test-results.empty {
background: #e2e3e5;
border: 1px solid #d6d8db;
color: #6c757d;
}
/* Mobile responsiveness for admin panel */
@media (max-width: 768px) {
@ -319,7 +423,10 @@
<div class="admin-container">
<!-- Login Section -->
<div id="login-section" class="login-section">
<h2>🔐 Admin Login</h2>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
<h2 style="margin: 0;">🔐 Admin Login</h2>
<a href="/" class="header-btn btn-home" style="text-decoration: none; font-size: 12px; padding: 8px 12px;">🏠 Back to Homepage</a>
</div>
<form id="login-form">
<div class="form-group">
<label for="password">Admin Password:</label>
@ -363,30 +470,95 @@
</div>
</div>
<div class="form-section">
<h3>All Location Reports</h3>
<table class="locations-table">
<thead>
<tr>
<th>ID</th>
<th>Status</th>
<th>Address</th>
<th>Description</th>
<th>Persistent</th>
<th>Reported</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="locations-tbody">
<tr>
<td colspan="7">Loading...</td>
</tr>
</tbody>
</table>
<!-- Tab Navigation -->
<div class="tab-navigation">
<button class="tab-btn active" data-tab="locations">📍 Location Reports</button>
<button class="tab-btn" data-tab="profanity">🚫 Profanity Filter</button>
</div>
<!-- Locations Tab -->
<div id="locations-tab" class="tab-content active">
<div class="form-section">
<h3>All Location Reports</h3>
<table class="locations-table">
<thead>
<tr>
<th>ID</th>
<th>Status</th>
<th>Address</th>
<th>Description</th>
<th>Persistent</th>
<th>Reported</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="locations-tbody">
<tr>
<td colspan="7">Loading...</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Profanity Management Tab -->
<div id="profanity-tab" class="tab-content">
<div class="form-section">
<h3>Custom Profanity Words</h3>
<!-- Add new word form -->
<div class="profanity-add-form">
<h4>Add Custom Word</h4>
<form id="add-profanity-form">
<div class="form-row">
<input type="text" id="new-word" placeholder="Enter word or phrase" required>
<select id="new-severity">
<option value="low">Low Severity</option>
<option value="medium" selected>Medium Severity</option>
<option value="high">High Severity</option>
</select>
<input type="text" id="new-category" placeholder="Category (optional)" value="custom">
<button type="submit">Add Word</button>
</div>
</form>
</div>
<!-- Test profanity filter -->
<div class="profanity-test-form">
<h4>Test Profanity Filter</h4>
<form id="test-profanity-form">
<div class="form-row">
<input type="text" id="test-text" placeholder="Enter text to test" required>
<button type="submit">Test Filter</button>
</div>
<div id="test-results" class="test-results"></div>
</form>
</div>
<!-- Custom words table -->
<table class="locations-table">
<thead>
<tr>
<th>ID</th>
<th>Word</th>
<th>Severity</th>
<th>Category</th>
<th>Added</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="profanity-tbody">
<tr>
<td colspan="6">Loading...</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<script src="https://iceymi.b-cdn.net/admin.js"></script>
<script src="utils.js"></script>
<script src="admin.js"></script>
</body>
</html>

View file

@ -71,6 +71,53 @@ document.addEventListener('DOMContentLoaded', () => {
loadLocations();
});
// Tab navigation logic
const tabs = document.querySelectorAll('.tab-btn');
const contents = document.querySelectorAll('.tab-content');
tabs.forEach(tab => {
tab.addEventListener('click', function () {
tabs.forEach(t => t.classList.remove('active'));
contents.forEach(c => c.classList.remove('active'));
this.classList.add('active');
document.querySelector(`#${this.dataset.tab}-tab`).classList.add('active');
// Load data for the active tab
if (this.dataset.tab === 'profanity') {
loadProfanityWords();
}
});
});
// Profanity management handlers
const addProfanityForm = document.getElementById('add-profanity-form');
const testProfanityForm = document.getElementById('test-profanity-form');
const profanityTableBody = document.getElementById('profanity-tbody');
if (addProfanityForm) {
addProfanityForm.addEventListener('submit', async (e) => {
e.preventDefault();
const word = document.getElementById('new-word').value.trim();
const severity = document.getElementById('new-severity').value;
const category = document.getElementById('new-category').value.trim() || 'custom';
if (!word) return;
await addProfanityWord(word, severity, category);
});
}
if (testProfanityForm) {
testProfanityForm.addEventListener('submit', async (e) => {
e.preventDefault();
const text = document.getElementById('test-text').value.trim();
if (!text) return;
await testProfanityFilter(text);
});
}
function showLoginSection() {
loginSection.style.display = 'block';
adminSection.style.display = 'none';
@ -445,21 +492,159 @@ document.addEventListener('DOMContentLoaded', () => {
}
};
function getTimeAgo(timestamp) {
const now = new Date();
const reportTime = new Date(timestamp);
const diffInMinutes = Math.floor((now - reportTime) / (1000 * 60));
// getTimeAgo and parseUTCDate functions are now available from utils.js
// Profanity management functions
async function loadProfanityWords() {
if (!profanityTableBody) return;
if (diffInMinutes < 1) return 'just now';
if (diffInMinutes < 60) return `${diffInMinutes} minute${diffInMinutes !== 1 ? 's' : ''} ago`;
try {
const response = await fetch('/api/admin/profanity-words', {
headers: { 'Authorization': `Bearer ${authToken}` }
});
if (response.status === 401) {
logout('Session expired. Please log in again.');
return;
}
const data = await response.json();
if (response.ok) {
displayProfanityWords(data || []);
} else {
console.error('Failed to load profanity words:', data.error);
profanityTableBody.innerHTML = '<tr><td colspan="6">Failed to load words</td></tr>';
}
} catch (error) {
console.error('Error loading profanity words:', error);
profanityTableBody.innerHTML = '<tr><td colspan="6">Error loading words</td></tr>';
}
}
function displayProfanityWords(words) {
if (!profanityTableBody) return;
const diffInHours = Math.floor(diffInMinutes / 60);
if (diffInHours < 24) return `${diffInHours} hour${diffInHours !== 1 ? 's' : ''} ago`;
if (words.length === 0) {
profanityTableBody.innerHTML = '<tr><td colspan="6">No custom words added yet</td></tr>';
return;
}
const diffInDays = Math.floor(diffInHours / 24);
if (diffInDays < 7) return `${diffInDays} day${diffInDays !== 1 ? 's' : ''} ago`;
profanityTableBody.innerHTML = words.map(word => `
<tr>
<td>${word.id}</td>
<td><code>${escapeHtml(word.word)}</code></td>
<td><span class="severity-${word.severity}">${word.severity}</span></td>
<td>${word.category || 'N/A'}</td>
<td>${new Date(word.created_at).toLocaleDateString()}</td>
<td>
<button class="action-btn danger" onclick="deleteProfanityWord(${word.id})">
🗑 Delete
</button>
</td>
</tr>
`).join('');
}
async function addProfanityWord(word, severity, category) {
try {
const response = await fetch('/api/admin/profanity-words', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${authToken}`
},
body: JSON.stringify({ word, severity, category })
});
const data = await response.json();
if (response.ok) {
// Clear form
document.getElementById('new-word').value = '';
document.getElementById('new-category').value = 'custom';
document.getElementById('new-severity').value = 'medium';
// Reload words list
loadProfanityWords();
console.log('Word added successfully');
} else {
alert('Failed to add word: ' + data.error);
}
} catch (error) {
console.error('Error adding profanity word:', error);
alert('Error adding word. Please try again.');
}
}
async function testProfanityFilter(text) {
const resultsDiv = document.getElementById('test-results');
if (!resultsDiv) return;
return reportTime.toLocaleDateString();
try {
const response = await fetch('/api/admin/test-profanity', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${authToken}`
},
body: JSON.stringify({ text })
});
const data = await response.json();
if (response.ok) {
if (data.analysis && data.analysis.hasProfanity) {
resultsDiv.className = 'test-results profane';
resultsDiv.innerHTML = `
<strong> Profanity Detected!</strong><br>
Detected words: ${data.analysis.matches.map(m => `<code>${escapeHtml(m.word)}</code>`).join(', ')}<br>
Severity: ${data.analysis.severity}<br>
Filtered text: "${escapeHtml(data.filtered)}"
`;
} else {
resultsDiv.className = 'test-results clean';
resultsDiv.innerHTML = '<strong>✅ Text is clean!</strong><br>No profanity detected.';
}
} else {
resultsDiv.className = 'test-results empty';
resultsDiv.innerHTML = 'Error testing text: ' + data.error;
}
} catch (error) {
console.error('Error testing profanity filter:', error);
resultsDiv.className = 'test-results empty';
resultsDiv.innerHTML = 'Error testing text. Please try again.';
}
}
// Make deleteProfanityWord available globally for onclick handlers
window.deleteProfanityWord = async function(wordId) {
if (!confirm('Are you sure you want to delete this word?')) return;
try {
const response = await fetch(`/api/admin/profanity-words/${wordId}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${authToken}` }
});
if (response.ok) {
loadProfanityWords();
console.log('Word deleted successfully');
} else {
const data = await response.json();
alert('Failed to delete word: ' + data.error);
}
} catch (error) {
console.error('Error deleting profanity word:', error);
alert('Error deleting word. Please try again.');
}
};
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Initialize theme toggle

View file

@ -82,19 +82,7 @@ document.addEventListener('DOMContentLoaded', async () => {
});
};
const getTimeAgo = (timestamp) => {
const now = new Date();
const reportTime = new Date(timestamp);
const diffInMinutes = Math.floor((now - reportTime) / (1000 * 60));
if (diffInMinutes < 1) return 'just now';
if (diffInMinutes < 60) return `${diffInMinutes} minute${diffInMinutes !== 1 ? 's' : ''} ago`;
const diffInHours = Math.floor(diffInMinutes / 60);
if (diffInHours < 24) return `${diffInHours} hour${diffInHours !== 1 ? 's' : ''} ago`;
return 'over a day ago';
};
// getTimeAgo function is now available from utils.js
const refreshLocations = () => {
fetch('/api/locations')

View file

@ -93,19 +93,7 @@ document.addEventListener('DOMContentLoaded', async () => {
});
};
const getTimeAgo = (timestamp) => {
const now = new Date();
const reportTime = new Date(timestamp);
const diffInMinutes = Math.floor((now - reportTime) / (1000 * 60));
if (diffInMinutes < 1) return 'just now';
if (diffInMinutes < 60) return `${diffInMinutes} minute${diffInMinutes !== 1 ? 's' : ''} ago`;
const diffInHours = Math.floor(diffInMinutes / 60);
if (diffInHours < 24) return `${diffInHours} hour${diffInHours !== 1 ? 's' : ''} ago`;
return 'over a day ago';
};
// getTimeAgo function is now available from utils.js
// Toggle between map and table view
const switchView = (viewType) => {
@ -165,45 +153,7 @@ document.addEventListener('DOMContentLoaded', async () => {
tableLocationCount.textContent = `${locations.length} active report${locations.length !== 1 ? 's' : ''}`;
};
// Calculate time remaining until 24-hour expiration
const getTimeRemaining = (timestamp, persistent = false) => {
if (persistent) {
return 'Persistent';
}
const now = new Date();
const reportTime = new Date(timestamp);
const expirationTime = new Date(reportTime.getTime() + 24 * 60 * 60 * 1000);
const remaining = expirationTime - now;
if (remaining <= 0) return 'Expired';
const hours = Math.floor(remaining / (1000 * 60 * 60));
const minutes = Math.floor((remaining % (1000 * 60 * 60)) / (1000 * 60));
if (hours > 0) {
return `${hours}h ${minutes}m`;
} else {
return `${minutes}m`;
}
};
// Get CSS class for time remaining
const getRemainingClass = (timestamp, persistent = false) => {
if (persistent) {
return 'normal'; // Use normal styling for persistent reports
}
const now = new Date();
const reportTime = new Date(timestamp);
const expirationTime = new Date(reportTime.getTime() + 24 * 60 * 60 * 1000);
const remaining = expirationTime - now;
const hoursRemaining = remaining / (1000 * 60 * 60);
if (hoursRemaining <= 1) return 'urgent';
if (hoursRemaining <= 6) return 'warning';
return 'normal';
};
// getTimeRemaining and getRemainingClass functions are now available from utils.js
const refreshLocations = () => {
fetch('/api/locations')
@ -453,7 +403,14 @@ document.addEventListener('DOMContentLoaded', async () => {
}),
});
})
.then(res => res.json())
.then(res => {
if (!res.ok) {
return res.json().then(errorData => {
throw { status: res.status, data: errorData };
});
}
return res.json();
})
.then(location => {
refreshLocations();
messageDiv.textContent = 'Location reported successfully!';
@ -462,17 +419,34 @@ document.addEventListener('DOMContentLoaded', async () => {
})
.catch(err => {
console.error('Error reporting location:', err);
messageDiv.textContent = 'Error reporting location.';
messageDiv.className = 'message error';
// Handle specific profanity rejection
if (err.status === 400 && err.data && err.data.error === 'Submission rejected') {
messageDiv.textContent = err.data.message;
messageDiv.className = 'message error profanity-rejection';
// Clear the description field to encourage rewriting
document.getElementById('description').value = '';
document.getElementById('description').focus();
} else if (err.data && err.data.error) {
messageDiv.textContent = err.data.error;
messageDiv.className = 'message error';
} else {
messageDiv.textContent = 'Error reporting location. Please try again.';
messageDiv.className = 'message error';
}
})
.finally(() => {
submitBtn.disabled = false;
submitText.style.display = 'inline';
submitLoading.style.display = 'none';
messageDiv.style.display = 'block';
// Longer timeout for profanity rejection messages
const timeout = messageDiv.className.includes('profanity-rejection') ? 15000 : 3000;
setTimeout(() => {
messageDiv.style.display = 'none';
}, 3000);
}, timeout);
});
});

View file

@ -74,19 +74,7 @@ document.addEventListener('DOMContentLoaded', () => {
});
};
const getTimeAgo = (timestamp) => {
const now = new Date();
const reportTime = new Date(timestamp);
const diffInMinutes = Math.floor((now - reportTime) / (1000 * 60));
if (diffInMinutes < 1) return 'just now';
if (diffInMinutes < 60) return `${diffInMinutes} minute${diffInMinutes !== 1 ? 's' : ''} ago`;
const diffInHours = Math.floor(diffInMinutes / 60);
if (diffInHours < 24) return `${diffInHours} hour${diffInHours !== 1 ? 's' : ''} ago`;
return 'over a day ago';
};
// getTimeAgo function is now available from utils.js
const refreshLocations = () => {
fetch('/api/locations')

View file

@ -41,7 +41,8 @@ placeholder="Enter address, intersection (e.g., Main St & Second St, City), or l
<div class="form-group">
<label for="description">Additional Details (Optional)</label>
<textarea id="description" name="description" rows="3"
placeholder="Number of vehicles, time observed, etc."></textarea>
placeholder="Number of vehicles, time observed, etc." aria-describedby="description-help"></textarea>
<small id="description-help" class="input-help">Keep descriptions appropriate and relevant to road conditions. Submissions with inappropriate language will be rejected.</small>
</div>
<button type="submit" id="submit-btn">
@ -108,6 +109,7 @@ placeholder="Enter address, intersection (e.g., Main St & Second St, City), or l
</div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="utils.js"></script>
<script src="app-mapbox.js"></script>
</body>
</html>

View file

@ -182,6 +182,7 @@ input[type="text"], textarea {
display: block;
}
button[type="submit"] {
background-color: var(--button-bg);
color: white;
@ -213,6 +214,32 @@ button[type="submit"]:hover {
color: #721c24;
}
.message.error.profanity-rejection {
background-color: #f5c6cb;
color: #721c24;
border: 2px solid #dc3545;
font-weight: bold;
}
/* Only animate for users who haven't requested reduced motion */
@media (prefers-reduced-motion: no-preference) {
.message.error.profanity-rejection {
animation: pulse 0.5s ease-in-out;
}
}
[data-theme="dark"] .message.error.profanity-rejection {
background-color: #3d1b1c;
border-color: #dc3545;
color: #f5c6cb;
}
@keyframes pulse {
0% { transform: scale(1); }
50% { transform: scale(1.02); }
100% { transform: scale(1); }
}
/* Header layout for theme toggle */
.header-content {
display: flex;

96
public/utils.js Normal file
View file

@ -0,0 +1,96 @@
/**
* Shared utility functions for the Great Lakes Ice Report frontend
*/
/**
* Helper function to parse UTC date consistently across all frontend scripts
* Ensures timestamp is treated as UTC if it doesn't have timezone info
* @param {string} timestamp - The timestamp string to parse
* @returns {Date} - Parsed Date object with proper UTC interpretation
*/
const parseUTCDate = (timestamp) => {
return new Date(timestamp.includes('T') ? timestamp : timestamp + 'Z');
};
/**
* Calculate human-readable time ago string
* @param {string} timestamp - The timestamp to calculate from
* @returns {string} - Human-readable time difference
*/
const getTimeAgo = (timestamp) => {
const now = new Date();
const reportTime = parseUTCDate(timestamp);
const diffInMinutes = Math.floor((now - reportTime) / (1000 * 60));
if (diffInMinutes < 1) return 'just now';
if (diffInMinutes < 60) return `${diffInMinutes} minute${diffInMinutes !== 1 ? 's' : ''} ago`;
const diffInHours = Math.floor(diffInMinutes / 60);
if (diffInHours < 24) return `${diffInHours} hour${diffInHours !== 1 ? 's' : ''} ago`;
const diffInDays = Math.floor(diffInHours / 24);
if (diffInDays < 7) return `${diffInDays} day${diffInDays !== 1 ? 's' : ''} ago`;
return reportTime.toLocaleDateString();
};
/**
* Calculate time remaining until 48-hour expiration
* @param {string} timestamp - The creation timestamp
* @param {boolean} persistent - Whether the report is persistent
* @returns {string} - Time remaining string
*/
const getTimeRemaining = (timestamp, persistent = false) => {
if (persistent) {
return 'Persistent';
}
const now = new Date();
const reportTime = parseUTCDate(timestamp);
const expirationTime = new Date(reportTime.getTime() + 48 * 60 * 60 * 1000);
const remaining = expirationTime - now;
if (remaining <= 0) return 'Expired';
const hours = Math.floor(remaining / (1000 * 60 * 60));
const minutes = Math.floor((remaining % (1000 * 60 * 60)) / (1000 * 60));
if (hours > 0) {
return `${hours}h ${minutes}m`;
} else {
return `${minutes}m`;
}
};
/**
* Get CSS class for time remaining styling
* @param {string} timestamp - The creation timestamp
* @param {boolean} persistent - Whether the report is persistent
* @returns {string} - CSS class name
*/
const getRemainingClass = (timestamp, persistent = false) => {
if (persistent) {
return 'normal'; // Use normal styling for persistent reports
}
const now = new Date();
const reportTime = parseUTCDate(timestamp);
const expirationTime = new Date(reportTime.getTime() + 48 * 60 * 60 * 1000);
const remaining = expirationTime - now;
const hoursRemaining = remaining / (1000 * 60 * 60);
if (hoursRemaining <= 1) return 'urgent';
if (hoursRemaining <= 6) return 'warning';
return 'normal';
};
// Export functions for module usage (if using ES6 modules in the future)
// For now, functions are available globally when script is included
if (typeof module !== 'undefined' && module.exports) {
module.exports = {
parseUTCDate,
getTimeAgo,
getTimeRemaining,
getRemainingClass
};
}

242
routes/admin.js Normal file
View file

@ -0,0 +1,242 @@
const express = require('express');
const router = express.Router();
module.exports = (db, profanityFilter, authenticateAdmin) => {
// Admin login
router.post('/login', (req, res) => {
console.log('Admin login attempt');
const { password } = req.body;
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'admin123';
if (password === ADMIN_PASSWORD) {
console.log('Admin login successful');
res.json({ token: ADMIN_PASSWORD, message: 'Login successful' });
} else {
console.warn('Admin login failed: invalid password');
res.status(401).json({ error: 'Invalid password' });
}
});
// Get all locations for admin (including expired ones)
router.get('/locations', authenticateAdmin, (req, res) => {
db.all(
'SELECT id, address, description, latitude, longitude, persistent, created_at FROM locations ORDER BY created_at DESC',
[],
(err, rows) => {
if (err) {
console.error('Error fetching all locations:', err);
res.status(500).json({ error: 'Internal server error' });
return;
}
// Process and clean data before sending
const locations = rows.map(row => ({
id: row.id,
address: row.address,
description: row.description || '',
latitude: row.latitude,
longitude: row.longitude,
persistent: !!row.persistent,
created_at: row.created_at,
isActive: new Date(row.created_at) > new Date(Date.now() - 48 * 60 * 60 * 1000)
}));
res.json(locations);
}
);
});
// Update a location (admin only)
router.put('/locations/:id', authenticateAdmin, (req, res) => {
const { id } = req.params;
const { address, latitude, longitude, description } = req.body;
if (!address) {
res.status(400).json({ error: 'Address is required' });
return;
}
db.run(
'UPDATE locations SET address = ?, latitude = ?, longitude = ?, description = ? WHERE id = ?',
[address, latitude, longitude, description, id],
function(err) {
if (err) {
console.error('Error updating location:', err);
res.status(500).json({ error: 'Internal server error' });
return;
}
if (this.changes === 0) {
res.status(404).json({ error: 'Location not found' });
return;
}
res.json({ message: 'Location updated successfully' });
}
);
});
// Toggle persistent status of a location (admin only)
router.patch('/locations/:id/persistent', authenticateAdmin, (req, res) => {
const { id } = req.params;
const { persistent } = req.body;
if (typeof persistent !== 'boolean') {
res.status(400).json({ error: 'Persistent value must be a boolean' });
return;
}
db.run(
'UPDATE locations SET persistent = ? WHERE id = ?',
[persistent ? 1 : 0, id],
function(err) {
if (err) {
console.error('Error updating persistent status:', err);
res.status(500).json({ error: 'Internal server error' });
return;
}
if (this.changes === 0) {
res.status(404).json({ error: 'Location not found' });
return;
}
console.log(`Location ${id} persistent status set to ${persistent}`);
res.json({ message: 'Persistent status updated successfully', persistent });
}
);
});
// Delete a location (admin authentication required)
router.delete('/locations/:id', authenticateAdmin, (req, res) => {
const { id } = req.params;
db.run('DELETE FROM locations WHERE id = ?', [id], function(err) {
if (err) {
console.error('Error deleting location:', err);
res.status(500).json({ error: 'Internal server error' });
return;
}
if (this.changes === 0) {
res.status(404).json({ error: 'Location not found' });
return;
}
res.json({ message: 'Location deleted successfully' });
});
});
// Profanity Management Routes
// Get all custom profanity words (admin only)
router.get('/profanity-words', authenticateAdmin, async (req, res) => {
try {
const words = await profanityFilter.getCustomWords();
res.json(words);
} catch (error) {
console.error('Error fetching custom profanity words:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Add a custom profanity word (admin only)
router.post('/profanity-words', authenticateAdmin, async (req, res) => {
try {
const { word, severity = 'medium', category = 'custom' } = req.body;
if (!word || typeof word !== 'string' || word.trim().length === 0) {
return res.status(400).json({ error: 'Word is required and must be a non-empty string' });
}
if (!['low', 'medium', 'high'].includes(severity)) {
return res.status(400).json({ error: 'Severity must be low, medium, or high' });
}
const result = await profanityFilter.addCustomWord(word, severity, category, 'admin');
await profanityFilter.loadCustomWords(); // Reload to update patterns
console.log(`Admin added custom profanity word: ${word}`);
res.json(result);
} catch (error) {
console.error('Error adding custom profanity word:', error);
if (error.message.includes('already exists')) {
res.status(409).json({ error: error.message });
} else {
res.status(500).json({ error: 'Internal server error' });
}
}
});
// Update a custom profanity word (admin only)
router.put('/profanity-words/:id', authenticateAdmin, async (req, res) => {
try {
const { id } = req.params;
const { word, severity, category } = req.body;
if (!word || typeof word !== 'string' || word.trim().length === 0) {
return res.status(400).json({ error: 'Word is required and must be a non-empty string' });
}
if (!['low', 'medium', 'high'].includes(severity)) {
return res.status(400).json({ error: 'Severity must be low, medium, or high' });
}
const result = await profanityFilter.updateCustomWord(id, { word, severity, category });
await profanityFilter.loadCustomWords(); // Reload to update patterns
console.log(`Admin updated custom profanity word ID ${id}`);
res.json(result);
} catch (error) {
console.error('Error updating custom profanity word:', error);
if (error.message.includes('not found')) {
res.status(404).json({ error: error.message });
} else {
res.status(500).json({ error: 'Internal server error' });
}
}
});
// Delete a custom profanity word (admin only)
router.delete('/profanity-words/:id', authenticateAdmin, async (req, res) => {
try {
const { id } = req.params;
const result = await profanityFilter.removeCustomWord(id);
await profanityFilter.loadCustomWords(); // Reload to update patterns
console.log(`Admin deleted custom profanity word ID ${id}`);
res.json(result);
} catch (error) {
console.error('Error deleting custom profanity word:', error);
if (error.message.includes('not found')) {
res.status(404).json({ error: error.message });
} else {
res.status(500).json({ error: 'Internal server error' });
}
}
});
// Test profanity filter (admin only) - for testing purposes
router.post('/test-profanity', authenticateAdmin, (req, res) => {
try {
const { text } = req.body;
if (!text || typeof text !== 'string') {
return res.status(400).json({ error: 'Text is required for testing' });
}
const analysis = profanityFilter.analyzeProfanity(text);
res.json({
original: text,
analysis: analysis,
filtered: analysis.filtered
});
} catch (error) {
console.error('Error testing profanity filter:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
return router;
};

22
routes/config.js Normal file
View file

@ -0,0 +1,22 @@
const express = require('express');
const router = express.Router();
module.exports = () => {
// Get API configuration
router.get('/', (req, res) => {
console.log('📡 API Config requested');
const MAPBOX_ACCESS_TOKEN = process.env.MAPBOX_ACCESS_TOKEN || null;
console.log('MapBox token present:', !!MAPBOX_ACCESS_TOKEN);
console.log('MapBox token starts with pk:', MAPBOX_ACCESS_TOKEN?.startsWith('pk.'));
res.json({
// MapBox tokens are designed to be public (they have domain restrictions)
mapboxAccessToken: MAPBOX_ACCESS_TOKEN,
hasMapbox: !!MAPBOX_ACCESS_TOKEN
// SECURITY: Google Maps API key is kept server-side only
});
});
return router;
};

108
routes/locations.js Normal file
View file

@ -0,0 +1,108 @@
const express = require('express');
const router = express.Router();
module.exports = (db, profanityFilter) => {
// Get all active locations (within 48 hours OR persistent)
router.get('/', (req, res) => {
console.log('Fetching active locations');
const fortyEightHoursAgo = new Date(Date.now() - 48 * 60 * 60 * 1000).toISOString();
db.all(
'SELECT * FROM locations WHERE created_at > ? OR persistent = 1 ORDER BY created_at DESC',
[fortyEightHoursAgo],
(err, rows) => {
if (err) {
console.error('Error fetching locations:', err);
res.status(500).json({ error: 'Internal server error' });
return;
}
console.log(`Fetched ${rows.length} active locations (including persistent)`);
res.json(rows);
}
);
});
// Add a new location
router.post('/', (req, res) => {
const { address, latitude, longitude } = req.body;
let { description } = req.body;
console.log(`Attempt to add new location: ${address}`);
if (!address) {
console.warn('Failed to add location: Address is required');
res.status(400).json({ error: 'Address is required' });
return;
}
// Check for profanity in description and reject if any is found
if (description && profanityFilter) {
try {
const analysis = profanityFilter.analyzeProfanity(description);
if (analysis.hasProfanity) {
console.warn(`Submission rejected due to inappropriate language (${analysis.count} word${analysis.count > 1 ? 's' : ''}, severity: ${analysis.severity}) - Original: "${req.body.description}"`);
// Reject any submission with profanity
const wordText = analysis.count === 1 ? 'word' : 'words';
const detectedWords = analysis.matches.map(m => m.word).join(', ');
return res.status(400).json({
error: 'Submission rejected',
message: `Your description contains inappropriate language and cannot be posted. Please revise your description to focus on road conditions and keep it professional.\n\nExample: "Multiple vehicles stuck, black ice present" or "Road very slippery, saw 3 accidents"`,
details: {
severity: analysis.severity,
wordCount: analysis.count,
detectedCategories: [...new Set(analysis.matches.map(m => m.category))]
}
});
}
} catch (filterError) {
console.error('Error checking profanity:', filterError);
// Continue with original description if filter fails
}
}
db.run(
'INSERT INTO locations (address, latitude, longitude, description) VALUES (?, ?, ?, ?)',
[address, latitude, longitude, description],
function(err) {
if (err) {
console.error('Error inserting location:', err);
res.status(500).json({ error: 'Internal server error' });
return;
}
console.log(`Location added successfully: ${address}`);
res.json({
id: this.lastID,
address,
latitude,
longitude,
description,
created_at: new Date().toISOString()
});
}
);
});
// Legacy delete route (keeping for backwards compatibility)
router.delete('/:id', (req, res) => {
const { id } = req.params;
db.run('DELETE FROM locations WHERE id = ?', [id], function(err) {
if (err) {
console.error('Error deleting location:', err);
res.status(500).json({ error: 'Internal server error' });
return;
}
if (this.changes === 0) {
res.status(404).json({ error: 'Location not found' });
return;
}
res.json({ message: 'Location deleted successfully' });
});
});
return router;
};

384
server.js
View file

@ -5,6 +5,12 @@ const cors = require('cors');
const sqlite3 = require('sqlite3').verbose();
const path = require('path');
const cron = require('node-cron');
const ProfanityFilter = require('./profanity-filter');
// Import route modules
const configRoutes = require('./routes/config');
const locationRoutes = require('./routes/locations');
const adminRoutes = require('./routes/admin');
const app = express();
const PORT = process.env.PORT || 3000;
@ -19,6 +25,75 @@ const db = new sqlite3.Database('icewatch.db');
console.log('Database connection established');
// Initialize profanity filter with its own database (async)
let profanityFilter;
// Create fallback filter function
function createFallbackFilter() {
return {
// Core profanity checking methods
containsProfanity: () => false,
analyzeProfanity: (text) => ({
hasProfanity: false,
matches: [],
severity: 'none',
count: 0,
filtered: text || ''
}),
filterProfanity: (text) => text || '',
// Database management methods used by admin routes
addCustomWord: (word, severity, category, createdBy) => Promise.resolve({
id: null,
word: word || null,
severity: severity || null,
category: category || null,
createdBy: createdBy || null,
success: false,
error: 'Profanity filter not available - please check server configuration'
}),
removeCustomWord: (wordId) => Promise.resolve({
success: false,
error: 'Profanity filter not available - please check server configuration'
}),
updateCustomWord: (wordId, updates) => Promise.resolve({
success: false,
error: 'Profanity filter not available - please check server configuration'
}),
getCustomWords: () => Promise.resolve([]),
loadCustomWords: () => Promise.resolve(),
// Utility methods
getAllWords: () => [],
getSeverity: () => 'none',
getSeverityLevel: () => 0,
getSeverityName: () => 'none',
normalizeText: (text) => text || '',
buildPatterns: () => [],
// Cleanup method
close: () => {},
// Special property to identify this as a fallback filter
_isFallback: true
};
}
// Initialize profanity filter asynchronously
async function initializeProfanityFilter() {
try {
profanityFilter = await ProfanityFilter.create();
console.log('Profanity filter initialized successfully with separate database');
} catch (error) {
console.error('WARNING: Failed to initialize profanity filter:', error);
console.error('Creating fallback no-op profanity filter. ALL CONTENT WILL BE ALLOWED!');
console.error('This is a security risk - please fix the profanity filter configuration.');
profanityFilter = createFallbackFilter();
console.warn('⚠️ SECURITY WARNING: Profanity filtering is DISABLED due to initialization failure!');
}
}
// Initialize database
db.serialize(() => {
db.run(`CREATE TABLE IF NOT EXISTS locations (
@ -66,9 +141,8 @@ cron.schedule('0 * * * *', cleanupExpiredLocations);
// Configuration
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'admin123'; // Change this!
const MAPBOX_ACCESS_TOKEN = process.env.MAPBOX_ACCESS_TOKEN || null; // Set this for better performance
const GOOGLE_MAPS_API_KEY = process.env.GOOGLE_MAPS_API_KEY || null; // Fallback option
// Authentication middleware
const authenticateAdmin = (req, res, next) => {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
@ -83,257 +157,93 @@ const authenticateAdmin = (req, res, next) => {
next();
};
// API Routes
// Get API configuration
app.get('/api/config', (req, res) => {
console.log('📡 API Config requested');
console.log('MapBox token present:', !!MAPBOX_ACCESS_TOKEN);
console.log('MapBox token starts with pk:', MAPBOX_ACCESS_TOKEN?.startsWith('pk.'));
// Setup routes after database and profanity filter are initialized
function setupRoutes() {
// API Routes
app.use('/api/config', configRoutes());
app.use('/api/locations', locationRoutes(db, profanityFilter));
app.use('/api/admin', adminRoutes(db, profanityFilter, authenticateAdmin));
res.json({
// MapBox tokens are designed to be public (they have domain restrictions)
mapboxAccessToken: MAPBOX_ACCESS_TOKEN,
hasMapbox: !!MAPBOX_ACCESS_TOKEN
// SECURITY: Google Maps API key is kept server-side only
// Static page routes
app.get('/', (req, res) => {
console.log('Serving the main page');
res.sendFile(path.join(__dirname, 'public', 'index.html'));
});
});
// Admin login
app.post('/api/admin/login', (req, res) => {
console.log('Admin login attempt');
const { password } = req.body;
if (password === ADMIN_PASSWORD) {
console.log('Admin login successful');
res.json({ token: ADMIN_PASSWORD, message: 'Login successful' });
} else {
console.warn('Admin login failed: invalid password');
res.status(401).json({ error: 'Invalid password' });
}
});
// Get all active locations (within 48 hours OR persistent)
app.get('/api/locations', (req, res) => {
console.log('Fetching active locations');
const fortyEightHoursAgo = new Date(Date.now() - 48 * 60 * 60 * 1000).toISOString();
db.all(
'SELECT * FROM locations WHERE created_at > ? OR persistent = 1 ORDER BY created_at DESC',
[fortyEightHoursAgo],
(err, rows) => {
if (err) {
console.error('Error fetching locations:', err);
res.status(500).json({ error: 'Internal server error' });
return;
}
console.log(`Fetched ${rows.length} active locations (including persistent)`);
res.json(rows);
}
);
});
// Add a new location
app.post('/api/locations', (req, res) => {
const { address, latitude, longitude, description } = req.body;
console.log(`Attempt to add new location: ${address}`);
if (!address) {
console.warn('Failed to add location: Address is required');
res.status(400).json({ error: 'Address is required' });
return;
}
db.run(
'INSERT INTO locations (address, latitude, longitude, description) VALUES (?, ?, ?, ?)',
[address, latitude, longitude, description],
function(err) {
if (err) {
console.error('Error inserting location:', err);
res.status(500).json({ error: 'Internal server error' });
return;
}
console.log(`Location added successfully: ${address}`);
res.json({
id: this.lastID,
address,
latitude,
longitude,
description,
created_at: new Date().toISOString()
});
}
);
});
// Admin Routes
// Get all locations for admin (including expired ones)
app.get('/api/admin/locations', authenticateAdmin, (req, res) => {
db.all(
'SELECT id, address, description, latitude, longitude, persistent, created_at FROM locations ORDER BY created_at DESC',
[],
(err, rows) => {
if (err) {
console.error('Error fetching all locations:', err);
res.status(500).json({ error: 'Internal server error' });
return;
}
// Process and clean data before sending
const locations = rows.map(row => ({
id: row.id,
address: row.address,
description: row.description || '',
latitude: row.latitude,
longitude: row.longitude,
persistent: !!row.persistent,
created_at: row.created_at,
isActive: new Date(row.created_at) > new Date(Date.now() - 48 * 60 * 60 * 1000)
}));
res.json(locations);
}
);
});
// Update a location (admin only)
app.put('/api/admin/locations/:id', authenticateAdmin, (req, res) => {
const { id } = req.params;
const { address, latitude, longitude, description } = req.body;
if (!address) {
res.status(400).json({ error: 'Address is required' });
return;
}
db.run(
'UPDATE locations SET address = ?, latitude = ?, longitude = ?, description = ? WHERE id = ?',
[address, latitude, longitude, description, id],
function(err) {
if (err) {
console.error('Error updating location:', err);
res.status(500).json({ error: 'Internal server error' });
return;
}
if (this.changes === 0) {
res.status(404).json({ error: 'Location not found' });
return;
}
res.json({ message: 'Location updated successfully' });
}
);
});
// Toggle persistent status of a location (admin only)
app.patch('/api/admin/locations/:id/persistent', authenticateAdmin, (req, res) => {
const { id } = req.params;
const { persistent } = req.body;
if (typeof persistent !== 'boolean') {
res.status(400).json({ error: 'Persistent value must be a boolean' });
return;
}
db.run(
'UPDATE locations SET persistent = ? WHERE id = ?',
[persistent ? 1 : 0, id],
function(err) {
if (err) {
console.error('Error updating persistent status:', err);
res.status(500).json({ error: 'Internal server error' });
return;
}
if (this.changes === 0) {
res.status(404).json({ error: 'Location not found' });
return;
}
console.log(`Location ${id} persistent status set to ${persistent}`);
res.json({ message: 'Persistent status updated successfully', persistent });
}
);
});
// Delete a location (admin authentication required)
app.delete('/api/admin/locations/:id', authenticateAdmin, (req, res) => {
const { id } = req.params;
db.run('DELETE FROM locations WHERE id = ?', [id], function(err) {
if (err) {
console.error('Error deleting location:', err);
res.status(500).json({ error: 'Internal server error' });
return;
}
if (this.changes === 0) {
res.status(404).json({ error: 'Location not found' });
return;
}
res.json({ message: 'Location deleted successfully' });
app.get('/admin', (req, res) => {
console.log('Serving the admin page');
res.sendFile(path.join(__dirname, 'public', 'admin.html'));
});
});
// Legacy delete route (keeping for backwards compatibility)
app.delete('/api/locations/:id', (req, res) => {
const { id } = req.params;
db.run('DELETE FROM locations WHERE id = ?', [id], function(err) {
if (err) {
console.error('Error deleting location:', err);
res.status(500).json({ error: 'Internal server error' });
return;
}
if (this.changes === 0) {
res.status(404).json({ error: 'Location not found' });
return;
}
res.json({ message: 'Location deleted successfully' });
app.get('/privacy', (req, res) => {
console.log('Serving the privacy policy page');
res.sendFile(path.join(__dirname, 'public', 'privacy.html'));
});
});
}
// Serve the main page
app.get('/', (req, res) => {
console.log('Serving the main page');
res.sendFile(path.join(__dirname, 'public', 'index.html'));
});
// Async server startup function
async function startServer() {
try {
// Initialize profanity filter first
await initializeProfanityFilter();
// Validate profanity filter is properly initialized
if (!profanityFilter) {
console.error('CRITICAL ERROR: profanityFilter is undefined after initialization attempt.');
console.error('Cannot start server without a functional profanity filter.');
process.exit(1);
}
// Initialize routes after everything is set up
setupRoutes();
// Start server
app.listen(PORT, () => {
console.log('===========================================');
console.log('Great Lakes Ice Report server started');
console.log(`Listening on port ${PORT}`);
console.log(`Visit http://localhost:${PORT} to view the website`);
// Display profanity filter status
if (profanityFilter._isFallback) {
console.log('🚨 PROFANITY FILTER: DISABLED (FALLBACK MODE)');
console.log('⚠️ ALL USER CONTENT WILL BE ALLOWED!');
} else {
console.log('✅ PROFANITY FILTER: ACTIVE AND FUNCTIONAL');
}
console.log('===========================================');
});
} catch (error) {
console.error('CRITICAL ERROR: Failed to start server:', error);
process.exit(1);
}
}
// Serve the admin page
app.get('/admin', (req, res) => {
console.log('Serving the admin page');
res.sendFile(path.join(__dirname, 'public', 'admin.html'));
});
// Serve the privacy policy page
app.get('/privacy', (req, res) => {
console.log('Serving the privacy policy page');
res.sendFile(path.join(__dirname, 'public', 'privacy.html'));
});
// Start server
app.listen(PORT, () => {
console.log('===========================================');
console.log('Great Lakes Ice Report server started');
console.log(`Listening on port ${PORT}`);
console.log(`Visit http://localhost:${PORT} to view the website`);
console.log('===========================================');
});
// Start the server
startServer();
// Graceful shutdown
process.on('SIGINT', () => {
console.log('\nShutting down server...');
// Close profanity filter database first
if (profanityFilter && typeof profanityFilter.close === 'function') {
try {
profanityFilter.close();
console.log('Profanity filter database closed.');
} catch (error) {
console.error('Error closing profanity filter:', error);
}
}
// Close main database
db.close((err) => {
if (err) {
console.error('Error closing database:', err);
} else {
console.log('Database connection closed.');
console.log('Main database connection closed.');
}
process.exit(0);
});

View file

@ -0,0 +1,325 @@
const sqlite3 = require('sqlite3').verbose();
const ProfanityFilter = require('../profanity-filter');
describe('ProfanityFilter', () => {
let filter;
beforeEach(async () => {
// Create profanity filter with in-memory database
filter = new ProfanityFilter(':memory:');
// Wait for initialization to complete
await new Promise(resolve => setTimeout(resolve, 200));
// Ensure custom words are loaded
await filter.loadCustomWords();
});
afterEach(() => {
if (filter) {
filter.close();
}
});
describe('Basic Profanity Detection', () => {
test('should detect single profanity word', () => {
const result = filter.analyzeProfanity('This is shit');
expect(result.hasProfanity).toBe(true);
expect(result.count).toBe(1);
expect(result.severity).toBe('medium');
expect(result.matches[0].word).toBe('shit');
});
test('should detect multiple profanity words', () => {
const result = filter.analyzeProfanity('This fucking shit is damn terrible');
expect(result.hasProfanity).toBe(true);
expect(result.count).toBe(3);
expect(result.severity).toBe('medium');
expect(result.matches.map(m => m.word)).toContain('fuck');
expect(result.matches.map(m => m.word)).toContain('shit');
expect(result.matches.map(m => m.word)).toContain('damn');
});
test('should not detect profanity in clean text', () => {
const result = filter.analyzeProfanity('This road is very slippery with ice');
expect(result.hasProfanity).toBe(false);
expect(result.count).toBe(0);
expect(result.severity).toBe('none');
expect(result.matches).toHaveLength(0);
});
test('should handle empty or null input', () => {
expect(filter.analyzeProfanity('')).toEqual({
hasProfanity: false,
matches: [],
severity: 'none',
count: 0,
filtered: ''
});
expect(filter.analyzeProfanity(null)).toEqual({
hasProfanity: false,
matches: [],
severity: 'none',
count: 0,
filtered: null
});
expect(filter.analyzeProfanity(undefined)).toEqual({
hasProfanity: false,
matches: [],
severity: 'none',
count: 0,
filtered: undefined
});
});
});
describe('Leetspeak Detection', () => {
test('should detect leetspeak profanity', () => {
const testCases = [
'This is sh1t',
'F@ck this',
'What the f*ck',
'This is bull$hit',
'D@mn it',
'A$$hole behavior'
];
testCases.forEach(text => {
const result = filter.analyzeProfanity(text);
expect(result.hasProfanity).toBe(true);
expect(result.count).toBeGreaterThan(0);
});
});
test('should detect spaced out words', () => {
const result = filter.analyzeProfanity('f u c k this');
expect(result.hasProfanity).toBe(true);
expect(result.count).toBeGreaterThan(0);
});
});
describe('Severity Levels', () => {
test('should classify high severity words correctly', () => {
const highSeverityWords = ['kill', 'murder', 'terrorist', 'rape'];
highSeverityWords.forEach(word => {
const result = filter.analyzeProfanity(`This is ${word}`);
expect(result.hasProfanity).toBe(true);
expect(result.severity).toBe('high');
});
});
test('should classify medium severity words correctly', () => {
const mediumSeverityWords = ['fuck', 'shit', 'bitch'];
mediumSeverityWords.forEach(word => {
const result = filter.analyzeProfanity(`This is ${word}`);
expect(result.hasProfanity).toBe(true);
expect(result.severity).toBe('medium');
});
});
test('should classify low severity words correctly', () => {
const lowSeverityWords = ['damn', 'hell', 'crap'];
lowSeverityWords.forEach(word => {
const result = filter.analyzeProfanity(`This is ${word}`);
expect(result.hasProfanity).toBe(true);
expect(result.severity).toBe('low');
});
});
test('should use highest severity when multiple words present', () => {
const result = filter.analyzeProfanity('damn this fucking terrorist');
expect(result.hasProfanity).toBe(true);
expect(result.severity).toBe('high'); // terrorist is high severity
});
});
describe('Text Filtering', () => {
test('should filter profanity with asterisks', () => {
const result = filter.analyzeProfanity('This is fucking shit');
expect(result.filtered).toContain('***');
expect(result.filtered).not.toContain('fuck');
expect(result.filtered).not.toContain('shit');
});
test('should preserve clean parts of text', () => {
const result = filter.analyzeProfanity('This damn road is slippery');
expect(result.filtered).toContain('road is slippery');
expect(result.filtered).toContain('***');
expect(result.filtered).not.toContain('damn');
});
});
describe('Custom Words Management', () => {
test('should add custom profanity word', async () => {
const result = await filter.addCustomWord('testword', 'medium', 'custom', 'admin');
expect(result.word).toBe('testword');
expect(result.severity).toBe('medium');
expect(result.category).toBe('custom');
});
test('should prevent duplicate custom words', async () => {
await filter.addCustomWord('testword', 'medium', 'custom', 'admin');
await expect(
filter.addCustomWord('testword', 'high', 'custom', 'admin')
).rejects.toThrow('Word already exists in the filter');
});
test('should detect custom words after reload', async () => {
await filter.addCustomWord('customfoulword', 'high', 'custom', 'admin');
await filter.loadCustomWords();
const result = filter.analyzeProfanity('This is customfoulword');
expect(result.hasProfanity).toBe(true);
expect(result.count).toBe(1);
expect(result.severity).toBe('high');
});
test('should get all custom words', async () => {
await filter.addCustomWord('word1', 'low', 'custom', 'admin');
await filter.addCustomWord('word2', 'high', 'custom', 'admin');
const words = await filter.getCustomWords();
expect(words).toHaveLength(2);
expect(words.map(w => w.word)).toContain('word1');
expect(words.map(w => w.word)).toContain('word2');
});
test('should update custom word', async () => {
const added = await filter.addCustomWord('updateword', 'low', 'custom', 'admin');
const result = await filter.updateCustomWord(added.id, {
word: 'updatedword',
severity: 'high',
category: 'updated'
});
expect(result.updated).toBe(true);
expect(result.changes).toBe(1);
});
test('should remove custom word', async () => {
const added = await filter.addCustomWord('removeword', 'medium', 'custom', 'admin');
const result = await filter.removeCustomWord(added.id);
expect(result.deleted).toBe(true);
expect(result.changes).toBe(1);
});
test('should handle removing non-existent word', async () => {
await expect(
filter.removeCustomWord(99999)
).rejects.toThrow('Word not found');
});
});
describe('Edge Cases', () => {
test('should handle very long text', () => {
const longText = 'This road is slippery '.repeat(100) + 'shit';
const result = filter.analyzeProfanity(longText);
expect(result.hasProfanity).toBe(true);
expect(result.count).toBe(1);
});
test('should handle text with only profanity', () => {
const result = filter.analyzeProfanity('fuck');
expect(result.hasProfanity).toBe(true);
expect(result.count).toBe(1);
expect(result.filtered).toBe('****');
});
test('should handle mixed case profanity', () => {
const result = filter.analyzeProfanity('This FUCKING road is SHIT');
expect(result.hasProfanity).toBe(true);
expect(result.count).toBe(2);
});
test('should handle profanity with punctuation', () => {
const result = filter.analyzeProfanity('Fuck! This shit, damn...');
expect(result.hasProfanity).toBe(true);
expect(result.count).toBe(3);
});
test('should not detect profanity in legitimate words containing profane substrings', () => {
const legitimateWords = [
'assessment', // contains 'ass'
'classic', // contains 'ass'
'assistance', // contains 'ass'
'cassette' // contains 'ass'
];
legitimateWords.forEach(word => {
const result = filter.analyzeProfanity(`This is a ${word}`);
expect(result.hasProfanity).toBe(false);
});
});
});
describe('Real-world Road Condition Examples', () => {
test('should allow legitimate road condition descriptions', () => {
const legitimateDescriptions = [
'Multiple vehicles stuck due to black ice',
'Road very slippery, saw 3 accidents this morning',
'Ice forming on bridges, drive carefully',
'Heavy snow, visibility poor',
'Salt trucks active, conditions improving',
'Watched 2 cars slide into ditch',
'School buses delayed due to ice',
'Emergency vehicles on scene',
'Road closed between Main and Oak'
];
legitimateDescriptions.forEach(description => {
const result = filter.analyzeProfanity(description);
expect(result.hasProfanity).toBe(false);
});
});
test('should reject inappropriate descriptions', () => {
const inappropriateDescriptions = [
'This fucking road is terrible',
'Shit everywhere, can\'t drive',
'Damn ice caused accident',
'These asshole drivers are crazy',
'What the hell is wrong with road crews'
];
inappropriateDescriptions.forEach(description => {
const result = filter.analyzeProfanity(description);
expect(result.hasProfanity).toBe(true);
expect(result.count).toBeGreaterThan(0);
});
});
});
describe('Performance', () => {
test('should handle multiple rapid analyses', () => {
const startTime = Date.now();
for (let i = 0; i < 100; i++) {
filter.analyzeProfanity('This is a test message with some words');
}
const endTime = Date.now();
const duration = endTime - startTime;
// Should complete 100 analyses in under 1 second
expect(duration).toBeLessThan(1000);
});
});
});

411
tests/routes.test.js Normal file
View file

@ -0,0 +1,411 @@
const request = require('supertest');
const express = require('express');
const sqlite3 = require('sqlite3').verbose();
const ProfanityFilter = require('../profanity-filter');
const locationRoutes = require('../routes/locations');
const adminRoutes = require('../routes/admin');
const configRoutes = require('../routes/config');
describe('API Routes', () => {
let app;
let db;
let profanityFilter;
beforeEach(async () => {
// Create test app and database
app = express();
app.use(express.json());
db = new sqlite3.Database(':memory:');
// Create profanity filter with test database path
profanityFilter = new ProfanityFilter(':memory:');
// Wait for profanity filter initialization
await profanityFilter.ready();
// Initialize locations table synchronously
await new Promise((resolve, reject) => {
db.serialize(() => {
db.run(`CREATE TABLE IF NOT EXISTS locations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
address TEXT NOT NULL,
latitude REAL,
longitude REAL,
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
description TEXT,
persistent INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`, (err) => {
if (err) {
console.error('Error creating locations table:', err);
reject(err);
} else {
resolve();
}
});
});
});
// Reload profanity filter patterns after database is ready
await profanityFilter.loadCustomWords();
// Setup routes
app.use('/api/locations', locationRoutes(db, profanityFilter));
app.use('/api/config', configRoutes());
// Mock admin authentication for testing
const mockAuth = (req, res, next) => next();
app.use('/api/admin', adminRoutes(db, profanityFilter, mockAuth));
});
afterEach(async () => {
// Wait longer for all pending operations to complete
await new Promise(resolve => setTimeout(resolve, 200));
// Close profanity filter database first
if (profanityFilter) {
profanityFilter.close();
}
// Wait a bit more
await new Promise(resolve => setTimeout(resolve, 100));
// Close main test database
if (db) {
await new Promise(resolve => {
db.close((err) => {
if (err) {
console.error('Error closing database:', err);
}
resolve();
});
});
}
});
describe('Location Routes', () => {
describe('POST /api/locations', () => {
test('should accept clean location submission', async () => {
const response = await request(app)
.post('/api/locations')
.send({
address: '123 Main St, Grand Rapids, MI',
latitude: 42.9634,
longitude: -85.6681,
description: 'Road is very slippery with black ice'
});
expect(response.status).toBe(200);
expect(response.body.address).toBe('123 Main St, Grand Rapids, MI');
expect(response.body.description).toBe('Road is very slippery with black ice');
expect(response.body.id).toBeDefined();
});
test('should reject submission with profanity', async () => {
const response = await request(app)
.post('/api/locations')
.send({
address: '123 Main St, Grand Rapids, MI',
latitude: 42.9634,
longitude: -85.6681,
description: 'This fucking road is terrible'
});
expect(response.status).toBe(400);
expect(response.body.error).toBe('Submission rejected');
expect(response.body.message).toContain('inappropriate language');
expect(response.body.details.wordCount).toBeGreaterThan(0);
});
test('should reject submission with single profanity word', async () => {
const response = await request(app)
.post('/api/locations')
.send({
address: '123 Main St, Grand Rapids, MI',
latitude: 42.9634,
longitude: -85.6681,
description: 'Road is shit'
});
expect(response.status).toBe(400);
expect(response.body.error).toBe('Submission rejected');
expect(response.body.details.wordCount).toBe(1);
});
test('should reject submission with leetspeak profanity', async () => {
const response = await request(app)
.post('/api/locations')
.send({
address: '123 Main St, Grand Rapids, MI',
latitude: 42.9634,
longitude: -85.6681,
description: 'Road is sh1t'
});
expect(response.status).toBe(400);
expect(response.body.error).toBe('Submission rejected');
});
test('should require address field', async () => {
const response = await request(app)
.post('/api/locations')
.send({
latitude: 42.9634,
longitude: -85.6681,
description: 'Road is slippery'
});
expect(response.status).toBe(400);
expect(response.body.error).toBe('Address is required');
});
test('should accept submission without description', async () => {
const response = await request(app)
.post('/api/locations')
.send({
address: '123 Main St, Grand Rapids, MI',
latitude: 42.9634,
longitude: -85.6681
});
expect(response.status).toBe(200);
expect(response.body.address).toBe('123 Main St, Grand Rapids, MI');
});
test('should handle empty description', async () => {
const response = await request(app)
.post('/api/locations')
.send({
address: '123 Main St, Grand Rapids, MI',
latitude: 42.9634,
longitude: -85.6681,
description: ''
});
expect(response.status).toBe(200);
});
});
describe('GET /api/locations', () => {
test('should return empty array when no locations', async () => {
const response = await request(app)
.get('/api/locations');
expect(response.status).toBe(200);
expect(response.body).toEqual([]);
});
test('should return locations after adding them', async () => {
// Add a location first
await request(app)
.post('/api/locations')
.send({
address: '123 Main St, Grand Rapids, MI',
latitude: 42.9634,
longitude: -85.6681,
description: 'Road is slippery'
});
const response = await request(app)
.get('/api/locations');
expect(response.status).toBe(200);
expect(response.body).toHaveLength(1);
expect(response.body[0].address).toBe('123 Main St, Grand Rapids, MI');
});
});
});
describe('Config Routes', () => {
describe('GET /api/config', () => {
test('should return config with mapbox settings', async () => {
const response = await request(app)
.get('/api/config');
expect(response.status).toBe(200);
expect(response.body).toHaveProperty('hasMapbox');
expect(response.body).toHaveProperty('mapboxAccessToken');
});
});
});
describe('Admin Routes', () => {
describe('Profanity Management', () => {
test('should get empty custom words list initially', async () => {
const response = await request(app)
.get('/api/admin/profanity-words');
expect(response.status).toBe(200);
expect(response.body).toEqual([]);
});
test('should add custom profanity word', async () => {
const response = await request(app)
.post('/api/admin/profanity-words')
.send({
word: 'custombaword',
severity: 'high',
category: 'test'
});
expect(response.status).toBe(200);
expect(response.body.word).toBe('custombaword');
expect(response.body.severity).toBe('high');
});
test('should reject duplicate custom words', async () => {
// Add first word
await request(app)
.post('/api/admin/profanity-words')
.send({
word: 'duplicate',
severity: 'medium'
});
// Try to add same word again
const response = await request(app)
.post('/api/admin/profanity-words')
.send({
word: 'duplicate',
severity: 'high'
});
expect(response.status).toBe(409);
expect(response.body.error).toContain('already exists');
});
test('should validate custom word input', async () => {
const invalidInputs = [
{ word: '', severity: 'medium' },
{ word: ' ', severity: 'medium' },
{ word: 'test', severity: 'invalid' },
{ severity: 'medium' }
];
for (const input of invalidInputs) {
const response = await request(app)
.post('/api/admin/profanity-words')
.send(input);
expect(response.status).toBe(400);
}
});
test('should test profanity filter', async () => {
const response = await request(app)
.post('/api/admin/test-profanity')
.send({
text: 'This fucking road is terrible'
});
expect(response.status).toBe(200);
expect(response.body.original).toBe('This fucking road is terrible');
expect(response.body.analysis.hasProfanity).toBe(true);
expect(response.body.filtered).toContain('***');
});
});
describe('Location Management', () => {
test('should get all locations for admin', async () => {
// Add a location first
await request(app)
.post('/api/locations')
.send({
address: '123 Main St, Grand Rapids, MI',
latitude: 42.9634,
longitude: -85.6681,
description: 'Road is slippery'
});
const response = await request(app)
.get('/api/admin/locations');
expect(response.status).toBe(200);
expect(response.body).toHaveLength(1);
expect(response.body[0]).toHaveProperty('isActive');
});
});
});
describe('Error Handling', () => {
test('should handle malformed JSON', async () => {
const response = await request(app)
.post('/api/locations')
.set('Content-Type', 'application/json')
.send('{ invalid json }');
expect(response.status).toBe(400);
});
test('should handle missing content-type', async () => {
const response = await request(app)
.post('/api/locations')
.send('address=test');
// Should still work as express.json() is flexible
expect(response.status).toBe(400); // Will fail validation for missing required fields
});
});
describe('Integration Tests', () => {
test('should handle complete workflow with profanity', async () => {
// 1. Try to submit with profanity (should be rejected)
const rejectedResponse = await request(app)
.post('/api/locations')
.send({
address: '123 Main St, Grand Rapids, MI',
latitude: 42.9634,
longitude: -85.6681,
description: 'This damn road is fucking terrible'
});
expect(rejectedResponse.status).toBe(400);
expect(rejectedResponse.body.error).toBe('Submission rejected');
// 2. Submit clean version (should be accepted)
const acceptedResponse = await request(app)
.post('/api/locations')
.send({
address: '123 Main St, Grand Rapids, MI',
latitude: 42.9634,
longitude: -85.6681,
description: 'Road is very slippery and dangerous'
});
expect(acceptedResponse.status).toBe(200);
// 3. Verify it appears in the list
const listResponse = await request(app)
.get('/api/locations');
expect(listResponse.status).toBe(200);
expect(listResponse.body).toHaveLength(1);
expect(listResponse.body[0].description).toBe('Road is very slippery and dangerous');
});
test('should handle custom profanity words in submissions', async () => {
// 1. Add custom profanity word
await request(app)
.post('/api/admin/profanity-words')
.send({
word: 'customoffensive',
severity: 'high',
category: 'test'
});
// 2. Try to submit with custom word (should be rejected)
const response = await request(app)
.post('/api/locations')
.send({
address: '123 Main St, Grand Rapids, MI',
latitude: 42.9634,
longitude: -85.6681,
description: 'This road is customoffensive'
});
expect(response.status).toBe(400);
expect(response.body.error).toBe('Submission rejected');
});
});
});

16
tests/setup.js Normal file
View file

@ -0,0 +1,16 @@
// Test setup file for Jest
// This file runs before each test suite
// Suppress console.log during tests unless debugging
if (!process.env.DEBUG_TESTS) {
console.log = jest.fn();
console.warn = jest.fn();
console.error = jest.fn();
}
// Set test environment variables
process.env.NODE_ENV = 'test';
process.env.ADMIN_PASSWORD = 'test_admin_password';
// Global test timeout
jest.setTimeout(30000);