diff --git a/app.py b/app.py index c5cf068..4ed70e6 100644 --- a/app.py +++ b/app.py @@ -4,6 +4,7 @@ import uuid import json import time +import datetime from flask import Flask, render_template, request, jsonify, send_from_directory from urllib.parse import urlparse import requests @@ -21,7 +22,13 @@ app.config['UPLOAD_FOLDER'] = tempfile.gettempdir() app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024 # 16MB max upload app.config['RESULTS_DIR'] = os.path.join(os.getcwd(), 'scan_results') -app.config['FEEDBACK_FILE'] = os.path.join(os.getcwd(), 'feedback', 'reviews.json') +app.config['DATA_DIR'] = os.path.join(os.getcwd(), 'data') +app.config['FEEDBACK_FILE'] = os.path.join(app.config['DATA_DIR'], 'feedback.json') +app.config['SUBSCRIBERS_FILE'] = os.path.join(app.config['DATA_DIR'], 'subscribers.json') + +# Create directories if they don't exist +os.makedirs(app.config['RESULTS_DIR'], exist_ok=True) +os.makedirs(app.config['DATA_DIR'], exist_ok=True) app.config['SLACK_WEBHOOK_URL'] = os.getenv('SLACK_WEBHOOK_URL', '') # Cache busting - changes on each deployment/restart @@ -317,13 +324,13 @@ def save_results(): return jsonify({'id': result_id}) -@app.route('/api/results/', methods=['GET']) -def get_results(result_id): - # Basic validation of result_id to prevent path traversal - if not result_id.replace('-', '').isalnum(): - return jsonify({'error': 'Invalid result ID'}), 400 +@app.route('/api/results/', methods=['GET']) +def get_results(scan_id): + # Security: basic path traversal protection + if '..' in scan_id or '/' in scan_id or '\\' in scan_id or not scan_id.replace('-', '').isalnum(): + return jsonify({'error': 'Invalid scan ID'}), 400 - file_path = os.path.join(app.config['RESULTS_DIR'], f"{result_id}.json") + file_path = os.path.join(app.config['RESULTS_DIR'], f"{scan_id}.json") if not os.path.exists(file_path): return jsonify({'error': 'Results not found'}), 404 @@ -439,5 +446,47 @@ def submit_feedback(): print(f"Error saving feedback: {str(e)}") return jsonify({'error': f"Failed to save feedback: {str(e)}"}), 500 +@app.route('/api/subscribe', methods=['POST']) +def subscribe_newsletter(): + data = request.get_json() + if not data or not data.get('email'): + return jsonify({'error': 'Email is required'}), 400 + + email = data.get('email').strip() + + subscriber_node = { + 'id': str(uuid.uuid4()), + 'email': email, + 'timestamp': datetime.datetime.utcnow().isoformat() + 'Z', + 'subscribed_via': 'web-modal' + } + + try: + subscribers = [] + if os.path.exists(app.config['SUBSCRIBERS_FILE']): + with open(app.config['SUBSCRIBERS_FILE'], 'r') as f: + try: + subscribers = json.load(f) + except json.JSONDecodeError: + subscribers = [] + + # Check if email already exists + if any(s['email'] == email for s in subscribers): + return jsonify({'message': 'Already subscribed!'}), 200 + + subscribers.append(subscriber_node) + + with open(app.config['SUBSCRIBERS_FILE'], 'w') as f: + json.dump(subscribers, f, indent=4) + + # Send Slack notification if configured + if app.config['SLACK_WEBHOOK_URL']: + send_slack_notification(f"✉️ New Newsletter Subscriber: *{email}*") + + return jsonify({'message': 'Subscribed successfully'}), 200 + except Exception as e: + print(f"Error in subscription: {str(e)}") + return jsonify({'error': 'Failed to complete subscription'}), 500 + if __name__ == '__main__': app.run(debug=True, host='0.0.0.0', port=5000) diff --git a/docker-compose.yml b/docker-compose.yml index 82634f3..293af61 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,7 +17,7 @@ services: - CLEANUP_SCANNED_IMAGES=${CLEANUP_SCANNED_IMAGES:-true} volumes: - ./static:/opt/infrascan/static - - ./feedback:/opt/infrascan/feedback + - ./data:/opt/infrascan/data - ./scan_results:/opt/infrascan/scan_results - /var/run/docker.sock:/var/run/docker.sock # Docker socket for Docker Scout scanning networks: diff --git a/static/app.js b/static/app.js index e9b54c0..e6211b1 100644 --- a/static/app.js +++ b/static/app.js @@ -29,6 +29,13 @@ document.addEventListener('DOMContentLoaded', () => { const feedbackContact = document.getElementById('feedback-contact'); let selectedRating = 0; + // Newsletter Elements + const newsletterModal = document.getElementById('newsletter-modal'); + const closeNewsletterBtn = document.getElementById('close-newsletter-btn'); + const subscribeBtn = document.getElementById('subscribe-btn'); + const newsletterEmail = document.getElementById('newsletter-email'); + const newsletterConsent = document.getElementById('newsletter-consent'); + let currentResults = null; let currentSummary = null; let currentMetadata = null; @@ -69,6 +76,13 @@ document.addEventListener('DOMContentLoaded', () => { checkScannerStatus(); loadSharedResults(); + // Trigger Newsletter Modal after 3 seconds if not already closed + if (!localStorage.getItem('newsletter_closed') && !window.location.search.includes('scan_id')) { + setTimeout(() => { + if (newsletterModal) newsletterModal.classList.remove('hidden'); + }, 3000); + } + async function checkScannerStatus() { try { const response = await fetch('/api/scanner/status'); @@ -1220,4 +1234,57 @@ document.addEventListener('DOMContentLoaded', () => { icon.textContent = '▼'; } } + + // Newsletter Event Listeners + if (closeNewsletterBtn) { + closeNewsletterBtn.onclick = () => { + newsletterModal.classList.add('hidden'); + localStorage.setItem('newsletter_closed', 'true'); + }; + } + + if (subscribeBtn) { + subscribeBtn.onclick = async () => { + const email = newsletterEmail.value.trim(); + const consent = newsletterConsent.checked; + + if (!email || !email.includes('@')) { + showToast('Please enter a valid email address'); + return; + } + + if (!consent) { + showToast('Please agree to the consent terms'); + return; + } + + subscribeBtn.disabled = true; + subscribeBtn.textContent = 'Subscribing...'; + + try { + const response = await fetch('/api/subscribe', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ email: email }) + }); + + const data = await response.json(); + + if (response.ok) { + showToast(data.message || 'Thank you for subscribing!', 'success'); + newsletterModal.classList.add('hidden'); + localStorage.setItem('newsletter_closed', 'true'); + } else { + throw new Error(data.error || 'Subscription failed'); + } + } catch (e) { + showToast(e.message || 'Subscription failed. Please try again.'); + } finally { + subscribeBtn.disabled = false; + subscribeBtn.innerHTML = '✉️ Subscribe Now'; + } + }; + } }); diff --git a/static/style.css b/static/style.css index 77ee4d1..378e307 100644 --- a/static/style.css +++ b/static/style.css @@ -57,7 +57,18 @@ header { /* Back to center for better overall feel */ justify-content: center; gap: 0.75rem; - margin-bottom: 0.15rem; + margin-bottom: 1.25rem; +} + +.github-stars-badge { + display: flex; + align-items: center; + transition: transform 0.2s ease; + margin-left: 0.5rem; +} + +.github-stars-badge:hover { + transform: scale(1.05); } .soldevelo-brand { @@ -1087,6 +1098,17 @@ footer { margin-bottom: 0.5rem; } +.github-stars-badge-footer { + display: inline-block; + vertical-align: middle; + margin-left: 0.5rem; + transition: transform 0.2s ease; +} + +.github-stars-badge-footer:hover { + transform: scale(1.1) rotate(2deg); +} + footer a { color: var(--text-muted); text-decoration: none; @@ -1423,6 +1445,98 @@ select option:disabled { transform: scale(1.1); } +/* Newsletter Checkbox styling */ +.newsletter-form { + margin-top: 1.5rem; +} + +.checkbox-group { + margin-top: 1.5rem; + background: rgba(255, 255, 255, 0.03); + padding: 1rem; + border-radius: 12px; + border: 1px solid rgba(255, 255, 255, 0.05); + transition: all 0.3s ease; +} + +.checkbox-group:hover { + background: rgba(255, 255, 255, 0.05); + border-color: rgba(99, 102, 241, 0.2); + transform: translateY(-1px); +} + +.custom-checkbox { + display: flex; + align-items: flex-start; + gap: 12px; + cursor: pointer; + user-select: none; + color: var(--text-muted); +} + +.custom-checkbox input { + position: absolute; + opacity: 0; + cursor: pointer; + height: 0; + width: 0; +} + +.checkmark { + position: relative; + height: 20px; + width: 20px; + min-width: 20px; + background-color: rgba(255, 255, 255, 0.05); + border: 2px solid rgba(255, 255, 255, 0.15); + border-radius: 6px; + flex-shrink: 0; + margin-top: 3px; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + display: flex; + align-items: center; + justify-content: center; +} + +.custom-checkbox:hover input~.checkmark { + border-color: rgba(99, 102, 241, 0.5); + background-color: rgba(99, 102, 241, 0.05); +} + +.custom-checkbox input:checked~.checkmark { + background-color: var(--primary); + border-color: var(--primary); + box-shadow: 0 0 10px rgba(99, 102, 241, 0.4); +} + +.checkmark:after { + content: ""; + position: absolute; + display: none; + left: 6px; + top: 2px; + width: 5px; + height: 10px; + border: solid white; + border-width: 0 2.5px 2.5px 0; + transform: rotate(45deg); +} + +.custom-checkbox input:checked~.checkmark:after { + display: block; +} + +.checkbox-text { + line-height: 1.5; + font-size: 0.875rem; + color: var(--text-muted); + transition: color 0.2s; +} + +.custom-checkbox:hover .checkbox-text { + color: var(--text-main); +} + textarea { width: 100%; padding: 0.875rem 1.25rem; diff --git a/templates/index.html b/templates/index.html index db7b579..2f4ef63 100644 --- a/templates/index.html +++ b/templates/index.html @@ -18,6 +18,9 @@

InfraScan

+ + GitHub stars + + + +