Initial commit

This commit is contained in:
Minecon724 2025-04-02 06:46:59 +02:00
commit 3fb583e102
Signed by: Minecon724
GPG key ID: A02E6E67AB961189
25 changed files with 1673 additions and 0 deletions

37
.dockerignore Normal file
View file

@ -0,0 +1,37 @@
# Environment variables
.env*
# Python cache files
__pycache__/
*.pyc
*.pyo
*.pyd
# Instance folder (often contains sensitive data or local config)
instance/
# Virtual environment
.venv/
venv/
env/
# Poetry cache
.poetry/
# Test files
tests/
# Documentation
docs/
# Git directory
.git/
# IDE/Editor specific files
.vscode/
.idea/
*.swp
# OS generated files
.DS_Store
Thumbs.db

61
.gitignore vendored Normal file
View file

@ -0,0 +1,61 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
dist/
build/
*.egg-info/
*.egg
# Virtual environments
venv/
env/
ENV/
.venv/
.env
# Unit test / coverage reports
htmlcov/
.tox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
.hypothesis/
.pytest_cache/
# Flask instance folder
instance/
# SQLite database files
*.sqlite
*.db
# Log files
*.log
# IDE specific files
.idea/
.vscode/
*.swp
*.swo
# Local development settings
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Redis dump file
dump.rdb
# Poetry
poetry.lock

60
Dockerfile Normal file
View file

@ -0,0 +1,60 @@
# Use an official Python runtime as a parent image
FROM python:3.11-slim
# Set the working directory in the container
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y \
curl \
&& rm -rf /var/lib/apt/lists/*
# Install Poetry
RUN pip install poetry
# Create a non-root user and group with proper home directory
RUN groupadd -r appuser && \
useradd -r -g appuser -d /home/appuser -m appuser && \
mkdir -p /home/appuser/.cache && \
chown -R appuser:appuser /home/appuser
# Copy the dependency files
COPY pyproject.toml poetry.lock ./
# Set proper Poetry configuration to use app directory
ENV POETRY_CACHE_DIR=/app/.poetry_cache
# Install project dependencies
# --no-root avoids installing the project itself, only dependencies
# --only main installs only the main dependencies (not dev)
RUN poetry install --no-root --only main && \
mkdir -p /app/.poetry_cache && \
chown -R appuser:appuser /app/.poetry_cache
# Copy the rest of the application code
COPY src/ ./src/
COPY entrypoint.sh ./
# Make the entrypoint script executable
RUN chmod +x ./entrypoint.sh
# Change ownership of the application files to the non-root user
RUN chown -R appuser:appuser /app
# Make port 5000 available to the world outside this container
EXPOSE 5000
# Switch to non-root user
USER appuser
# Configure health check
# Check every 30 seconds with 3 second timeout, 3 retries, and 5 second start period
HEALTHCHECK --interval=30s --timeout=3s --retries=3 --start-period=5s CMD curl -f http://localhost:5000/health || exit 1
# Set the entrypoint script
ENTRYPOINT ["./entrypoint.sh"]
# Define the command to run the application using Gunicorn
# This will be passed as arguments to the entrypoint script
# Use `poetry run` to execute the command within the Poetry environment
CMD ["poetry", "run", "gunicorn", "--bind", "0.0.0.0:5000", "src.anonchat:app"]

162
README.md Normal file
View file

@ -0,0 +1,162 @@
# AnonChat
An anonymous chat application built with Flask.
## Features
- Anonymous inquiries and messaging
- Admin dashboard to manage inquiries
- Customizable site title
- Redis-based session storage for improved scalability
## Development Approach
AnonChat was created using "vibe coding" - a programming approach where developers leverage AI tools to generate code through natural language prompts rather than writing code manually. This modern development method allows focusing on high-level problem-solving and design while letting AI handle implementation details.
Rest assured though, I know what I'm (or the AI is) doing. Here's what would happen if I didn't:
1. [my saas was built with Cursor, zero hand written code \
AI is no longer just an assistant, its also the builder \
Now, you can continue to whine about it or start building.](https://xcancel.com/leojr94_/status/1900767509621674109)
2. [random thing are happening, maxed out usage on api keys, people bypassing the subscription, creating random shit on db \
there are just some weird ppl out there](https://xcancel.com/leojr94_/status/1901560276488511759)
## Configuration
AnonChat can be configured using environment variables:
- `SECRET_KEY`: Secret key for session management
- `DATABASE_URL`: Database connection string (defaults to SQLite)
- `ADMIN_USERNAME`: Admin username for admin dashboard
- `ADMIN_PASSWORD`: Admin password for admin dashboard
- `ADMIN_FORCE_RESET`: When set to "true", forces a reset of the admin password to the value in ADMIN_PASSWORD (defaults to "false")
- `SITE_TITLE`: Customizable site title (defaults to "AnonChat")
- `BEHIND_PROXY`: Set to "true" when running behind a reverse proxy to properly handle client IP addresses (defaults to "false")
- `RATELIMIT_STORAGE_URL`: Storage backend for rate limiting (defaults to memory storage)
- `REDIS_URL`: Redis connection URL for session storage (defaults to "redis://localhost:6379/0")
You can set these variables in a `.env` file:
```
SECRET_KEY=your_secret_key_here
FLASK_APP=src/anonchat
FLASK_ENV=development
SITE_TITLE=My Custom Chat
BEHIND_PROXY=true
REDIS_URL=redis://redis:6379/0
```
## Reverse Proxy Configuration
When running AnonChat behind a reverse proxy (like Nginx or Apache), set the `BEHIND_PROXY` environment variable to "true" to ensure rate limiting works correctly. This enables the application to use the X-Forwarded-For header to determine the client's real IP address.
Your reverse proxy should be configured to pass the client IP address in the X-Forwarded-For header:
### Nginx Example
```nginx
server {
listen 80;
server_name your-domain.com;
location / {
proxy_pass http://localhost:5000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
## Installation
1. Clone the repository
2. Install dependencies with Poetry: `poetry install`
3. Create `.env` file with your configuration
4. Run the application: `poetry run start`
## Development
This project uses Poetry for dependency management.
- Install dependencies: `poetry install`
- Run tests: `poetry run pytest`
- Run the application: `poetry run start`
## Admin Authentication
AnonChat includes a secure admin authentication system that protects administrative routes and functions. This ensures that only authorized users can access the admin dashboard, manage inquiries, and configure system settings.
### Security Features
- **Secure Password Storage**: Admin passwords are securely hashed using SHA-256 with the application's secret key as salt
- **Session-Based Authentication**: Uses Flask sessions to maintain admin login state
- **Protected Routes**: All admin routes are protected by middleware that verifies authentication
- **Password Management**: Admins can change their password through the Admin Settings page
- **Logout Functionality**: Secure logout to clear session data
### Setting Admin Credentials
Admin credentials are set using environment variables:
```
ADMIN_USERNAME=admin
ADMIN_PASSWORD=your-secure-password
ADMIN_FORCE_RESET=false
```
These values should be set in your `.env` file or server environment. The default admin user is created automatically when the application first runs.
#### Password Reset
You can force a reset of the admin password by setting `ADMIN_FORCE_RESET=true` in your environment variables. This is useful when:
- You need to recover from a forgotten admin password
- You're deploying to a new environment and want to ensure the admin credentials are set correctly
- You want to update the admin password during deployment without accessing the admin interface
When enabled, the application will update the admin user's password to match the value in `ADMIN_PASSWORD` during initialization or when running the `init-db` command.
### Admin Functions
- View and respond to user inquiries
- Delete inquiries
- Configure webhook settings
- Change admin password
### Security Best Practices
- Always use a strong, unique password for the admin account
- Keep your SECRET_KEY secure and unique for each deployment
- In production, ensure you're using HTTPS to protect admin credentials during transmission
- Change the default admin password immediately after deployment
## TODO: Security Improvements
The following security enhancements are planned for future releases:
- [ ] Implement CAPTCHA protection for admin login
- Add CAPTCHA verification to prevent brute force attacks
- Support multiple CAPTCHA providers (reCAPTCHA, hCaptcha)
- Implement rate limiting for failed login attempts
- Add IP-based blocking after multiple failed attempts
### Password Hashing
- [ ] Replace SHA-256 with Argon2 password hashing
- Argon2 is the winner of the Password Hashing Competition and provides better protection against various attacks
- Implement password migration strategy for existing accounts
- Update password verification logic to support both hash formats during transition
### Authentication Methods
- [ ] Add OAuth 2.0 support for admin authentication
- Integrate with common providers (Google, GitHub, Microsoft)
- Implement proper PKCE flow for added security
- Support for custom OAuth providers for enterprise deployments
- Add multi-factor authentication options
### Inquiry Management
- [ ] Add "Close Inquiry" functionality
- Mark inquiries as closed without immediate deletion
- Automatically delete closed inquiries after 2 days
- Allow reopening inquiries before deletion occurs
- Provide visual indicators for closed inquiries in admin interface

37
docker-compose.yml Normal file
View file

@ -0,0 +1,37 @@
services:
web:
image: minecon724/anonchat:latest
build: .
ports:
- "5000:5000"
env_file:
- .env
depends_on:
- db
- redis
environment:
- DATABASE_URL=postgresql://anonchat:anonchat@db:5432/anonchat
- SECRET_KEY=change-this-secret-key-in-production
- SITE_TITLE=Chat
- ADMIN_USERNAME=admin
- ADMIN_PASSWORD=change-this-password-in-production
- REDIS_URL=redis://redis:6379/0
db:
image: postgres:15
volumes:
- postgres_data:/var/lib/postgresql/data
environment:
- POSTGRES_USER=anonchat
- POSTGRES_PASSWORD=anonchat
- POSTGRES_DB=anonchat
redis:
image: redis:7-alpine
volumes:
- redis_data:/data
command: redis-server --appendonly yes
volumes:
postgres_data:
redis_data:

74
docs/csrf-protection.md Normal file
View file

@ -0,0 +1,74 @@
# CSRF Protection in AnonChat
## Overview
Cross-Site Request Forgery (CSRF) protection has been implemented in the AnonChat application to prevent attackers from tricking users into submitting requests they did not intend to make. This document outlines how CSRF protection is implemented and maintained.
## Implementation
The application uses Flask-WTF's CSRFProtect extension to provide CSRF protection. This works by:
1. Including a unique token in each form (csrf_token)
2. Validating this token on form submissions
3. Rejecting requests that don't include a valid token
## Key Components
- `CSRFProtect` initialization in `__init__.py`
- `csrf_token` hidden field in all HTML forms
- Custom error handler for CSRF errors (HTTP 400)
## Testing
CSRF protection can be tested using the test cases in `tests/test_csrf.py`. This includes:
- Testing forms with valid CSRF tokens
- Testing forms without CSRF tokens (should be rejected)
- Testing admin forms with valid CSRF tokens
- Testing admin forms without CSRF tokens (should be rejected)
To run the tests:
```bash
# Run the CSRF protection tests
python -m unittest tests/test_csrf.py
# Run all tests
python -m unittest discover tests
```
The tests ensure that CSRF protection is working correctly by:
1. Verifying that forms with valid CSRF tokens are processed successfully
2. Confirming that forms without CSRF tokens are rejected
3. Testing admin-specific CSRF protection behavior
## Maintenance Guidelines
When adding new forms or routes to the application:
1. **For all HTML forms that use POST:**
- Include the CSRF token: `<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>`
2. **For API endpoints that need to be exempt from CSRF:**
- Use the `@csrf.exempt` decorator on the route
- Document the exemption and provide a security justification
- Consider alternative security measures (API keys, JWT tokens)
3. **For file uploads or forms with large data:**
- CSRF protection still applies
- No special handling is needed for these cases
## Security Recommendations
1. Keep your application's `SECRET_KEY` secure and unique
2. Regularly rotate the `SECRET_KEY` in production environments
3. Always use HTTPS in production to prevent token leakage
4. Set appropriate cookie security flags (`secure`, `httpOnly`, `SameSite`)
## Troubleshooting
Common CSRF issues:
- **Form submissions being rejected:** Check that the csrf_token is correctly included in the form
- **AJAX requests failing:** Include the csrf_token in AJAX requests or exempt specific API endpoints
- **Tests failing unexpectedly:** Ensure test client is configured to handle CSRF correctly

11
entrypoint.sh Normal file
View file

@ -0,0 +1,11 @@
#!/bin/bash
set -e
# Run database migrations/initialization
echo "Running database initialization..."
poetry run flask --app src/anonchat init-db
echo "Database initialization complete."
# Execute the command passed as arguments (CMD in Dockerfile)
echo "Executing command: $@"
exec "$@"

26
fly.toml Normal file
View file

@ -0,0 +1,26 @@
# fly.toml app configuration file generated for anonchat-aged-dream-6548 on 2025-04-01T20:20:08+02:00
#
# See https://fly.io/docs/reference/configuration/ for information about how to use this file.
#
app = 'anonchat-aged-dream-6548'
primary_region = 'ams'
[build]
[env]
BEHIND_PROXY = 'true'
[http_service]
internal_port = 5000
force_https = true
auto_stop_machines = 'stop'
auto_start_machines = true
min_machines_running = 0
processes = ['app']
[[vm]]
size = 'shared-cpu-1x'
memory = '1gb'
cpu_kind = 'shared'
cpus = 1

33
pyproject.toml Normal file
View file

@ -0,0 +1,33 @@
[project]
name = "anonchat"
version = "0.1.0"
description = ""
authors = [
{name = "Minecon724",email = "minecon724@noreply.git.m724.eu"}
]
license = "CC0-1.0"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"flask (>=3.1.0,<4.0.0)",
"flask-sqlalchemy (>=3.1.0,<4.0.0)",
"sqlalchemy (>=2.0.0,<3.0.0)",
"python-dotenv (>=1.0.0,<2.0.0)",
"flask-limiter (>=3.12,<4.0)",
"flask-wtf (>=1.2.0,<2.0.0)",
"gunicorn (>=23.0.0,<24.0.0)",
"psycopg2-binary (>=2.9.9,<3.0.0)",
"redis (>=5.0.0,<6.0.0)",
"flask-session (>=0.5.0,<1.0.0)"
]
[tool.poetry]
packages = [{include = "anonchat", from = "src"}]
[tool.poetry.scripts]
start = "anonchat:run"
[build-system]
requires = ["poetry-core>=2.0.0,<3.0.0"]
build-backend = "poetry.core.masonry.api"

176
src/anonchat/__init__.py Normal file
View file

@ -0,0 +1,176 @@
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
import os
from dotenv import load_dotenv
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
from flask import render_template, request, jsonify
from flask_wtf.csrf import CSRFProtect
from flask_session import Session
import redis
# Load environment variables from .env file
load_dotenv()
app = Flask(__name__)
app.config['SQLALCHEMY_DATABASE_URI'] = os.environ.get('DATABASE_URL', 'sqlite:///anonchat.db')
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', os.urandom(24).hex())
app.config['SITE_TITLE'] = os.environ.get('SITE_TITLE', 'AnonChat')
# Webhook configurations
app.config['WEBHOOK_ENABLED'] = os.environ.get('WEBHOOK_ENABLED', 'false').lower() == 'true'
app.config['WEBHOOK_URL'] = os.environ.get('WEBHOOK_URL', '')
app.config['WEBHOOK_SECRET'] = os.environ.get('WEBHOOK_SECRET', '')
# Admin configurations
app.config['ADMIN_USERNAME'] = os.environ.get('ADMIN_USERNAME', 'admin')
app.config['ADMIN_PASSWORD'] = os.environ.get('ADMIN_PASSWORD', 'change-this-password-in-production')
app.config['ADMIN_FORCE_RESET'] = os.environ.get('ADMIN_FORCE_RESET', 'false').lower() == 'true'
# Rate limit configurations
app.config['RATELIMIT_STORAGE_URL'] = os.environ.get('RATELIMIT_STORAGE_URL', os.environ.get('REDIS_URL'))
app.config['RATELIMIT_HEADERS_ENABLED'] = True
app.config['RATELIMIT_KEY_PREFIX'] = 'anonchat_rate_limit'
# Whether app is behind a proxy (get from env, default to False)
app.config['BEHIND_PROXY'] = os.environ.get('BEHIND_PROXY', 'false').lower() == 'true'
# Redis session configuration
app.config['SESSION_TYPE'] = 'redis'
app.config['SESSION_PERMANENT'] = False
app.config['SESSION_USE_SIGNER'] = True
app.config['SESSION_REDIS'] = redis.from_url(os.environ.get('REDIS_URL', 'redis://localhost:6379/0'))
app.config['SESSION_KEY_PREFIX'] = 'anonchat_session:'
# Initialize session with Redis storage
Session(app)
# Initialize CSRF protection
csrf = CSRFProtect(app)
# Function to get client IP address, respecting X-Forwarded-For when behind a proxy
def get_client_ip():
if app.config['BEHIND_PROXY']:
# Get the first IP in X-Forwarded-For, which should be the client
forwarded_for = request.headers.get('X-Forwarded-For')
if forwarded_for:
return forwarded_for.split(',')[0].strip()
# Fall back to remote_addr if not behind proxy or X-Forwarded-For not found
return request.remote_addr
# Initialize limiter with custom key_func
limiter = Limiter(
get_client_ip, # Use our custom function instead of get_remote_address
app=app,
storage_uri=app.config['RATELIMIT_STORAGE_URL']
)
db = SQLAlchemy(app)
# Import models
from . import models
# Explicitly import all models to ensure they are registered with SQLAlchemy
from .models import Inquiry, Message, Settings, Admin
# Ensure tables are created when app is loaded by Gunicorn
with app.app_context():
db.create_all()
# Health check endpoint
@app.route('/health', methods=['GET'])
@csrf.exempt # Exempt health check from CSRF protection
def health_check():
return jsonify(status="healthy"), 200
# Register error handlers
from flask import render_template
@app.errorhandler(404)
def page_not_found(e):
return render_template('404.html'), 404
# Rate limit exceeded handler
@app.errorhandler(429)
def ratelimit_handler(e):
if request.path.startswith('/admin'):
return render_template('error.html', error="Rate limit exceeded. Please try again later."), 429
elif request.is_json:
return jsonify(error="Rate limit exceeded"), 429
else:
return render_template('error.html', error="Too many requests. Please slow down and try again later."), 429
# Import routes
from . import routes
# Flask CLI command to initialize the database
@app.cli.command("init-db")
def init_db_command():
"""Create database tables and initialize default data."""
# Explicitly import models here to ensure they are registered before create_all
from .models import Inquiry, Message, Settings, Admin
with app.app_context():
print("Creating database tables...")
db.create_all() # Should now create all tables including Inquiry
print("Initializing default settings...")
# Initialize settings if they don't exist
# Note: Models already imported above
if not Settings.query.first():
default_settings = Settings(
webhook_enabled=app.config['WEBHOOK_ENABLED'],
webhook_url=app.config['WEBHOOK_URL'],
webhook_secret=app.config['WEBHOOK_SECRET']
)
db.session.add(default_settings)
db.session.commit()
print("Default settings initialized.")
else:
print("Settings already exist.")
print("Initializing admin user...")
# Initialize admin user if it doesn't exist
# Note: Models already imported above
admin_user = Admin.query.filter_by(username=app.config['ADMIN_USERNAME']).first()
if app.config['ADMIN_FORCE_RESET']:
if admin_user:
admin_user.password_hash = Admin.hash_password(app.config['ADMIN_PASSWORD'])
print("Admin user password reset.")
else:
admin_user = Admin(
username=app.config['ADMIN_USERNAME'],
password_hash=Admin.hash_password(app.config['ADMIN_PASSWORD'])
)
db.session.add(admin_user)
db.session.commit()
print("Admin user initialized.")
else:
print("Admin user already exists.")
print("Database initialization complete.")
def run() -> None:
# Note: The database initialization is now handled by the 'init-db' command
# and should be run separately, e.g., via the entrypoint script.
# Keeping the original db.create_all() here might be redundant or could
# be useful for local development outside Docker.
# Consider if you still need the initialization logic within run().
with app.app_context():
# Explicitly import all models to ensure they're registered before db.create_all()
from .models import Inquiry, Message, Settings, Admin
db.create_all()
# Initialize settings if they don't exist
if not Settings.query.first():
default_settings = Settings(
webhook_enabled=app.config['WEBHOOK_ENABLED'],
webhook_url=app.config['WEBHOOK_URL'],
webhook_secret=app.config['WEBHOOK_SECRET']
)
db.session.add(default_settings)
db.session.commit()
# Initialize admin user if it doesn't exist
if not Admin.query.filter_by(username=app.config['ADMIN_USERNAME']).first():
admin_user = Admin(
username=app.config['ADMIN_USERNAME'],
password_hash=Admin.hash_password(app.config['ADMIN_PASSWORD'])
)
db.session.add(admin_user)
db.session.commit()
app.run(debug=True)

36
src/anonchat/models.py Normal file
View file

@ -0,0 +1,36 @@
from . import db
import os
import secrets
import hashlib
class Message(db.Model):
id = db.Column(db.Integer, primary_key=True)
content = db.Column(db.Text, nullable=False)
timestamp = db.Column(db.DateTime, default=db.func.current_timestamp())
inquiry_id = db.Column(db.String(16), db.ForeignKey('inquiry.id'), nullable=False)
is_admin = db.Column(db.Boolean, default=False)
class Inquiry(db.Model):
id = db.Column(db.String(16), primary_key=True, default=lambda: secrets.token_hex(8))
messages = db.relationship('Message', backref='inquiry', lazy=True, cascade='all, delete-orphan')
class Settings(db.Model):
id = db.Column(db.Integer, primary_key=True)
webhook_enabled = db.Column(db.Boolean, default=False)
webhook_url = db.Column(db.String(255), nullable=True)
webhook_secret = db.Column(db.String(255), nullable=True)
class Admin(db.Model):
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(64), unique=True, nullable=False)
password_hash = db.Column(db.String(128), nullable=False)
@staticmethod
def hash_password(password):
"""Hash a password using salt and SHA-256"""
salt = os.environ.get('SECRET_KEY', 'default-salt')
return hashlib.sha256((password + salt).encode()).hexdigest()
def verify_password(self, password):
"""Verify a password against the stored hash"""
return self.password_hash == self.hash_password(password)

311
src/anonchat/routes.py Normal file
View file

@ -0,0 +1,311 @@
from flask import request, jsonify, render_template, redirect, url_for, flash, session
from functools import wraps
from . import app, db, limiter, csrf
from .models import Inquiry, Message, Settings, Admin
import os
import urllib.request
import urllib.error
import hmac
import hashlib
import json
from datetime import datetime
# Admin authentication middleware
def admin_required(f):
@wraps(f)
def decorated_function(*args, **kwargs):
if 'admin_authenticated' not in session or not session['admin_authenticated']:
return redirect(url_for('admin_login', next=request.url))
return f(*args, **kwargs)
return decorated_function
def send_webhook(event_type, data):
"""Send webhook if enabled with proper signature"""
settings = Settings.query.first()
if not settings or not settings.webhook_enabled or not settings.webhook_url:
return False
payload = {
"event_type": event_type,
"timestamp": datetime.utcnow().isoformat(),
"data": data
}
# Convert payload to JSON
payload_str = json.dumps(payload).encode('utf-8')
# Create request with headers
headers = {'Content-Type': 'application/json'}
if settings.webhook_secret:
signature = hmac.new(
settings.webhook_secret.encode(),
payload_str,
hashlib.sha256
).hexdigest()
headers['X-Webhook-Signature'] = signature
try:
# Create request object
req = urllib.request.Request(
settings.webhook_url,
data=payload_str,
headers=headers,
method='POST'
)
# Set timeout
with urllib.request.urlopen(req, timeout=5) as response:
return response.status == 200
except urllib.error.URLError as e:
app.logger.error(f"Webhook error: {str(e)}")
return False
except Exception as e:
app.logger.error(f"Webhook error: {str(e)}")
return False
@app.route('/', methods=['GET'])
def index():
return render_template('create_inquiry.html')
@app.route('/', methods=['POST'])
@limiter.limit("5 per hour")
def create_inquiry():
# Get the initial message
initial_message = request.form.get('message')
# Ensure the message is not empty
if not initial_message or not initial_message.strip():
return render_template('create_inquiry.html', error="Please enter an initial message")
# Create a new inquiry
new_inquiry = Inquiry()
db.session.add(new_inquiry)
db.session.commit()
# Save the initial message
message = Message(content=initial_message, inquiry_id=new_inquiry.id)
db.session.add(message)
db.session.commit()
# Send webhook for new inquiry
send_webhook('new_inquiry', {
'inquiry_id': new_inquiry.id,
'message': initial_message
})
return redirect(url_for('inquiry', inquiry_id=new_inquiry.id))
@app.route('/inquiry/<inquiry_id>', methods=['GET', 'POST'])
@limiter.limit("10 per hour", deduct_when=lambda response: response.status_code != 200)
def inquiry(inquiry_id):
inquiry = Inquiry.query.get_or_404(inquiry_id)
if request.method == 'POST':
message_content = request.form.get('message')
if message_content and message_content.strip():
message = Message(content=message_content, inquiry_id=inquiry_id)
db.session.add(message)
db.session.commit()
# Send webhook for new message
send_webhook('new_message', {
'inquiry_id': inquiry_id,
'message': message_content,
'is_admin': False
})
return redirect(url_for('inquiry', inquiry_id=inquiry_id))
messages = Message.query.filter_by(inquiry_id=inquiry_id).order_by(Message.timestamp).all()
return render_template('inquiry.html', inquiry=inquiry, messages=messages)
@app.route('/admin', methods=['GET'])
def admin_login():
error = None
return render_template('admin_login.html', error=error)
@app.route('/admin', methods=['POST'])
@limiter.limit("10 per hour")
def admin_login_post():
error = None
username = request.form.get('username')
password = request.form.get('password')
# Get admin user from database
admin = Admin.query.filter_by(username=username).first()
# Verify credentials
if admin and admin.verify_password(password):
session['admin_authenticated'] = True
session['admin_username'] = admin.username
# Redirect to next page if provided, otherwise to dashboard
next_page = request.args.get('next')
if next_page:
return redirect(next_page)
return redirect(url_for('admin_dashboard'))
else:
error = 'Invalid credentials'
return render_template('admin_login.html', error=error)
@app.route('/admin/logout')
def admin_logout():
session.pop('admin_authenticated', None)
session.pop('admin_username', None)
flash('You have been logged out')
return redirect(url_for('admin_login'))
@app.route('/admin/dashboard')
@admin_required
def admin_dashboard():
# Get all inquiries, ordered by the latest message timestamp
inquiries = Inquiry.query.all()
# For each inquiry, get the latest message timestamp
inquiries_with_data = []
for inquiry in inquiries:
latest_message = Message.query.filter_by(inquiry_id=inquiry.id).order_by(Message.timestamp.desc()).first()
message_count = Message.query.filter_by(inquiry_id=inquiry.id).count()
inquiries_with_data.append({
'inquiry': inquiry,
'latest_message': latest_message,
'message_count': message_count
})
# Sort by latest message timestamp (newest first)
inquiries_with_data.sort(key=lambda x: x['latest_message'].timestamp if x['latest_message'] else None, reverse=True)
return render_template('admin_dashboard.html', inquiries_with_data=inquiries_with_data)
@app.route('/admin/inquiry/<inquiry_id>', methods=['GET', 'POST'])
@admin_required
def admin_inquiry(inquiry_id):
inquiry = Inquiry.query.get_or_404(inquiry_id)
if request.method == 'POST':
admin_response = request.form.get('admin_response')
if admin_response and admin_response.strip():
# Create message with is_admin flag instead of prefixing content
admin_message = Message(
content=admin_response,
inquiry_id=inquiry_id,
is_admin=True
)
db.session.add(admin_message)
db.session.commit()
# Send webhook for admin response
send_webhook('admin_response', {
'inquiry_id': inquiry_id,
'message': admin_response,
'is_admin': True
})
flash('Response sent successfully')
return redirect(url_for('admin_inquiry', inquiry_id=inquiry_id))
messages = Message.query.filter_by(inquiry_id=inquiry_id).order_by(Message.timestamp).all()
return render_template('admin_inquiry.html', inquiry=inquiry, messages=messages)
@app.route('/admin/webhook', methods=['GET', 'POST'])
@admin_required
def admin_webhook():
settings = Settings.query.first()
if not settings:
settings = Settings()
db.session.add(settings)
db.session.commit()
if request.method == 'POST':
# Update webhook settings
settings.webhook_enabled = request.form.get('webhook_enabled') == 'on'
settings.webhook_url = request.form.get('webhook_url', '')
settings.webhook_secret = request.form.get('webhook_secret', '')
db.session.commit()
# Test webhook if enabled and URL is provided
if settings.webhook_enabled and settings.webhook_url:
test_success = send_webhook('webhook_test', {
'message': 'This is a test webhook'
})
if test_success:
flash('Webhook settings saved and test sent successfully')
else:
flash('Webhook settings saved but test failed. Check the URL and connection')
else:
flash('Webhook settings saved')
return redirect(url_for('admin_webhook'))
return render_template('admin_webhook.html', settings=settings)
@app.route('/admin/settings', methods=['GET', 'POST'])
@admin_required
def admin_settings():
admin = Admin.query.filter_by(username=session['admin_username']).first()
if request.method == 'POST':
current_password = request.form.get('current_password')
new_password = request.form.get('new_password')
confirm_password = request.form.get('confirm_password')
# Verify current password
if not admin.verify_password(current_password):
flash('Current password is incorrect', 'error')
return redirect(url_for('admin_settings'))
# Validate new password
if new_password != confirm_password:
flash('New passwords do not match', 'error')
return redirect(url_for('admin_settings'))
if len(new_password) < 8:
flash('Password must be at least 8 characters', 'error')
return redirect(url_for('admin_settings'))
# Update password
admin.password_hash = Admin.hash_password(new_password)
db.session.commit()
flash('Password updated successfully', 'success')
return redirect(url_for('admin_settings'))
return render_template('admin_settings.html')
@app.route('/inquiry/<inquiry_id>/delete', methods=['POST'])
def delete_inquiry(inquiry_id):
inquiry = Inquiry.query.get_or_404(inquiry_id)
# Delete the inquiry
db.session.delete(inquiry)
db.session.commit()
# Send webhook for deleted inquiry
send_webhook('inquiry_deleted', {
'inquiry_id': inquiry_id
})
# Check if user is admin and redirect accordingly
if 'admin_authenticated' in session and session['admin_authenticated']:
flash('Inquiry deleted successfully')
return redirect(url_for('admin_dashboard'))
else:
flash('Inquiry deleted successfully')
return redirect(url_for('index'))
# CSRF error handler
@app.errorhandler(400)
def handle_csrf_error(e):
app.logger.error(f"CSRF error: {str(e)}")
if request.path.startswith('/admin'):
# Check if it's not an AJAX request
if not request.headers.get('X-Requested-With') == 'XMLHttpRequest':
flash("Your session has expired or the form was tampered with. Please try again.", "error")
return redirect(url_for('admin_login'))
# For non-admin routes or AJAX requests
if request.is_json or request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return jsonify(error="Invalid request. CSRF token missing or incorrect."), 400
else:
return render_template('error.html', error="Invalid request. Please try again."), 400

View file

@ -0,0 +1,146 @@
body {
font-family: Arial, sans-serif;
line-height: 1.6;
margin: 0;
padding: 0;
background-color: #1a1a1a;
color: #e0e0e0;
}
/* Adding link styles for dark theme */
a {
color: #6da8ff;
text-decoration: none;
transition: color 0.2s ease;
}
a:hover {
color: #8fc0ff;
text-decoration: underline;
}
a:visited {
color: #c792ea;
}
.container {
width: 80%;
max-width: 800px;
margin: 4rem auto 2rem auto;
padding: 1rem;
background-color: #2c2c2c;
border-radius: 5px;
box-shadow: 0 2px 5px rgba(0,0,0,0.3);
}
footer {
font-size: small;
color: #999;
text-align: center;
margin-top: 2rem;
padding-top: 1rem;
border-top: 1px solid #444;
}
h1 {
color: #bbb;
}
form {
margin-bottom: 1rem;
}
textarea, input[type="text"], input[type="password"] {
width: 100%;
padding: 0.5rem;
margin-bottom: 1rem;
border: 1px solid #555;
border-radius: 4px;
box-sizing: border-box;
background-color: #333;
color: #e0e0e0;
}
button {
background-color: #3b8c3e;
color: white;
padding: 0.7rem 1.2rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
}
button:hover {
background-color: #2d6c30;
}
.message {
margin-bottom: 1rem;
padding: 1rem;
border-radius: 4px;
background-color: #333;
}
.admin-message {
background-color: #263844;
border-left: 3px solid #1a73e8;
}
.user-message {
background-color: #333;
}
.admin-badge {
background-color: #1a73e8;
color: white;
padding: 2px 6px;
border-radius: 3px;
font-size: 0.8rem;
font-weight: bold;
display: inline-block;
margin-right: 5px;
}
.timestamp {
font-size: 0.8rem;
color: #888;
margin-top: 0.3rem;
}
table {
width: 100%;
border-collapse: collapse;
margin-bottom: 1rem;
}
th, td {
padding: 8px;
text-align: left;
border-bottom: 1px solid #444;
}
th {
background-color: #222;
}
/* 404 Error Page Styles */
.error-container {
text-align: center;
padding: 2rem 1rem;
}
.error-code {
font-size: 6rem;
font-weight: bold;
color: #3b8c3e;
margin-bottom: 1rem;
text-shadow: 2px 2px 4px rgba(0,0,0,0.3);
}
.error-container h2 {
font-size: 2rem;
margin-bottom: 1rem;
color: #bbb;
}
.error-container p {
font-size: 1.1rem;
margin-bottom: 2rem;
color: #999;
}
.error-actions {
margin-top: 2rem;
}
.btn-primary {
background-color: #3b8c3e;
color: white;
padding: 0.7rem 1.5rem;
border-radius: 4px;
text-decoration: none;
display: inline-block;
font-weight: bold;
transition: background-color 0.3s;
}
.btn-primary:hover {
background-color: #2d6c30;
text-decoration: none;
}

View file

@ -0,0 +1,14 @@
{% extends "base.html" %}
{% block title %}404 Not Found - {{ config.SITE_TITLE }}{% endblock %}
{% block content %}
<div class="error-container">
<div class="error-code">404</div>
<h2>Page Not Found</h2>
<p>Sorry, the page you're looking for doesn't exist or may have been moved.</p>
<div class="error-actions">
<a href="{{ url_for('index') }}" class="btn-primary">Go to Home</a>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,71 @@
{% extends 'base.html' %}
{% block title %}Admin Dashboard - {{ config.SITE_TITLE }}{% endblock %}
{% block content %}
<h2>Admin Dashboard</h2>
<div style="margin-bottom: 1rem;">
<a href="{{ url_for('index') }}" style="margin-right: 1rem;">Home</a>
<a href="{{ url_for('admin_webhook') }}" style="margin-right: 1rem;">Webhook Settings</a>
<a href="{{ url_for('admin_settings') }}" style="margin-right: 1rem;">Admin Settings</a>
<a href="{{ url_for('admin_logout') }}">Logout</a>
</div>
{% with messages = get_flashed_messages() %}
{% if messages %}
{% for message in messages %}
<div style="background-color: #d4edda; color: #155724; padding: 0.75rem; margin-bottom: 1rem; border-radius: 0.25rem;">
{{ message }}
</div>
{% endfor %}
{% endif %}
{% endwith %}
<h3>All Inquiries</h3>
{% if inquiries_with_data %}
<table>
<thead>
<tr>
<th>ID</th>
<th>Messages</th>
<th>Latest Message</th>
<th>Time</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for item in inquiries_with_data %}
<tr>
<td>{{ item.inquiry.id }}</td>
<td>{{ item.message_count }}</td>
<td>
{% if item.latest_message %}
{{ item.latest_message.content|truncate(30) }}
{% else %}
No messages
{% endif %}
</td>
<td>
{% if item.latest_message %}
{{ item.latest_message.timestamp.strftime('%Y-%m-%d %H:%M') }}
{% else %}
N/A
{% endif %}
</td>
<td>
<a href="{{ url_for('admin_inquiry', inquiry_id=item.inquiry.id) }}">View/Respond</a>
<form method="POST" action="{{ url_for('delete_inquiry', inquiry_id=item.inquiry.id) }}" style="display: inline; margin-left: 0.5rem;">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<button type="submit" onclick="return confirm('Are you sure you want to delete this inquiry? This action cannot be undone.')" style="background-color: #dc3545; color: white; border: none; padding: 0.25rem 0.5rem; border-radius: 0.25rem; cursor: pointer; font-size: 0.8rem;">Delete</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p>No inquiries found.</p>
{% endif %}
{% endblock %}

View file

@ -0,0 +1,52 @@
{% extends 'base.html' %}
{% block title %}Admin View - Inquiry #{{ inquiry.id[:6] }} - {{ config.SITE_TITLE }}{% endblock %}
{% block content %}
<div style="margin-bottom: 1rem;">
<a href="{{ url_for('admin_dashboard') }}" style="margin-right: 1rem;">Back to Dashboard</a>
<a href="{{ url_for('admin_settings') }}" style="margin-right: 1rem;">Admin Settings</a>
<a href="{{ url_for('admin_logout') }}" style="margin-right: 1rem;">Logout</a>
<form method="POST" action="{{ url_for('delete_inquiry', inquiry_id=inquiry.id) }}" style="display: inline;">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<button type="submit" onclick="return confirm('Are you sure you want to delete this inquiry? This action cannot be undone.')" style="background-color: #dc3545; color: white; border: none; padding: 0.375rem 0.75rem; border-radius: 0.25rem; cursor: pointer;">Delete Inquiry</button>
</form>
</div>
<h2>Inquiry: {{ inquiry.id }}</h2>
{% with messages = get_flashed_messages() %}
{% if messages %}
{% for message in messages %}
<div style="background-color: #d4edda; color: #155724; padding: 0.75rem; margin-bottom: 1rem; border-radius: 0.25rem;">
{{ message }}
</div>
{% endfor %}
{% endif %}
{% endwith %}
<div class="conversation" style="margin-bottom: 2rem;">
{% if messages %}
{% for message in messages %}
<div class="message {% if message.is_admin %}admin-message{% else %}user-message{% endif %}" style="margin-bottom: 1rem; padding: 1rem; border-radius: 4px;">
<div>
{% if message.is_admin %}<span class="admin-badge">ADMIN:</span> {% endif %}
{{ message.content }}
</div>
<div class="timestamp">{{ message.timestamp.strftime('%Y-%m-%d %H:%M:%S') }}</div>
</div>
{% endfor %}
{% else %}
<p>No messages in this inquiry.</p>
{% endif %}
</div>
<div class="response-form">
<h3>Respond as Admin</h3>
<form method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<textarea name="admin_response" rows="4" placeholder="Type your response here..." required></textarea>
<button type="submit">Send Response</button>
</form>
</div>
{% endblock %}

View file

@ -0,0 +1,24 @@
{% extends 'base.html' %}
{% block title %}Admin Login - {{ config.SITE_TITLE }}{% endblock %}
{% block content %}
<h2>Admin Login</h2>
{% if error %}
<div style="color: red; margin-bottom: 1rem;">{{ error }}</div>
{% endif %}
<form method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div>
<label for="username">Username:</label>
<input type="text" id="username" name="username" required>
</div>
<div>
<label for="password">Password:</label>
<input type="password" id="password" name="password" required>
</div>
<button type="submit">Login</button>
</form>
{% endblock %}

View file

@ -0,0 +1,47 @@
{% extends 'base.html' %}
{% block title %}Admin Settings - {{ config.SITE_TITLE }}{% endblock %}
{% block content %}
<h2>Admin Settings</h2>
<div style="margin-bottom: 1rem;">
<a href="{{ url_for('admin_dashboard') }}" style="margin-right: 1rem;">Back to Dashboard</a>
<a href="{{ url_for('admin_logout') }}" style="margin-right: 1rem;">Logout</a>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div style="background-color: {% if category == 'error' %}#f8d7da{% else %}#d4edda{% endif %};
color: {% if category == 'error' %}#721c24{% else %}#155724{% endif %};
padding: 0.75rem;
margin-bottom: 1rem;
border-radius: 0.25rem;">
{{ message }}
</div>
{% endfor %}
{% endif %}
{% endwith %}
<div class="settings-form">
<h3>Change Admin Password</h3>
<form method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div>
<label for="current_password">Current Password:</label>
<input type="password" id="current_password" name="current_password" required>
</div>
<div>
<label for="new_password">New Password:</label>
<input type="password" id="new_password" name="new_password" required>
<small style="display: block; margin-top: 0.25rem; color: #6c757d;">Password must be at least 8 characters</small>
</div>
<div>
<label for="confirm_password">Confirm New Password:</label>
<input type="password" id="confirm_password" name="confirm_password" required>
</div>
<button type="submit">Update Password</button>
</form>
</div>
{% endblock %}

View file

@ -0,0 +1,71 @@
{% extends 'base.html' %}
{% block title %}Webhook Settings - {{ config.SITE_TITLE }}{% endblock %}
{% block content %}
<h2>Webhook Settings</h2>
<div style="margin-bottom: 1rem;">
<a href="{{ url_for('admin_dashboard') }}" style="margin-right: 1rem;">Back to Dashboard</a>
<a href="{{ url_for('admin_settings') }}" style="margin-right: 1rem;">Admin Settings</a>
<a href="{{ url_for('admin_logout') }}">Logout</a>
</div>
{% with messages = get_flashed_messages() %}
{% if messages %}
{% for message in messages %}
<div style="background-color: #d4edda; color: #155724; padding: 0.75rem; margin-bottom: 1rem; border-radius: 0.25rem;">
{{ message }}
</div>
{% endfor %}
{% endif %}
{% endwith %}
<div style="margin-bottom: 2rem;">
<p>Configure webhook settings to receive notifications for new inquiries and responses.</p>
<p>Events will be sent as POST requests with JSON payloads. Event types include:</p>
<ul>
<li><strong>new_inquiry</strong> - Triggered when a new inquiry is created</li>
<li><strong>new_message</strong> - Triggered when a user adds a message to an existing inquiry</li>
<li><strong>admin_response</strong> - Triggered when an admin responds to an inquiry</li>
</ul>
</div>
<form method="POST" action="{{ url_for('admin_webhook') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div>
<label>
<input type="checkbox" name="webhook_enabled" {% if settings.webhook_enabled %}checked{% endif %}>
Enable Webhooks
</label>
</div>
<div style="margin-top: 1rem;">
<label for="webhook_url">Webhook URL:</label>
<input type="text" id="webhook_url" name="webhook_url" value="{{ settings.webhook_url or '' }}" placeholder="https://example.com/webhook">
<p style="font-size: 0.8rem; color: #666;">The URL that will receive webhook events</p>
</div>
<div style="margin-top: 1rem;">
<label for="webhook_secret">Webhook Secret (optional):</label>
<input type="text" id="webhook_secret" name="webhook_secret" value="{{ settings.webhook_secret or '' }}" placeholder="Secret key for HMAC signature">
<p style="font-size: 0.8rem; color: #666;">If provided, webhooks will include a signature header (X-Webhook-Signature)</p>
</div>
<button type="submit" style="margin-top: 1rem;">Save Settings</button>
</form>
<div style="margin-top: 2rem; padding: 1rem; background-color: #f8f9fa; border-radius: 0.25rem;">
<h3>Webhook Payload Example</h3>
<pre style="background-color: #f1f1f1; padding: 1rem; overflow-x: auto; border-radius: 0.25rem;">
{
"event_type": "new_inquiry",
"timestamp": "2023-04-01T12:34:56.789Z",
"data": {
"inquiry_id": "abcdef1234567890",
"message": "Hello, I have a question..."
}
}
</pre>
</div>
{% endblock %}

View file

@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}{{ config.SITE_TITLE }}{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}">
</head>
<body>
<div class="container">
<main>
{% block content %}{% endblock %}
</main>
<footer>
Powered by <a href="https://git.m724.eu/Minecon724/anonsubmit" style="color: #4CAF50; text-decoration: none;">anonsubmit</a>
</div>
</div>
</body>
</html>

View file

@ -0,0 +1,39 @@
{% extends "base.html" %}
{% block title %}Create New Inquiry - {{ config.SITE_TITLE }}{% endblock %}
{% block content %}
<div class="create-inquiry">
<h2>Start a New Conversation</h2>
<p>Create a new inquiry to start an anonymous conversation.</p>
<div class="notice" style="background-color: #ffe8b3; color: #664d03; padding: 0.75rem; margin: 1rem 0; border-radius: 0.25rem; border-left: 4px solid #ffda6a;">
<strong>Note:</strong> This is early software still under development. Please do not abuse this system.
</div>
{% with messages = get_flashed_messages() %}
{% if messages %}
{% for message in messages %}
<div style="background-color: #d4edda; color: #155724; padding: 0.75rem; margin-bottom: 1rem; border-radius: 0.25rem;">
{{ message }}
</div>
{% endfor %}
{% endif %}
{% endwith %}
{% if error %}
<div class="error-message" style="color: red; margin-bottom: 15px; padding: 10px; background-color: #ffeeee; border-radius: 4px;">
{{ error }}
</div>
{% endif %}
<form method="POST" action="{{ url_for('index') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="form-group">
<label for="message">Initial Message:</label>
<textarea name="message" id="message" rows="4" placeholder="Enter your message..." required></textarea>
</div>
<button type="submit">Create New Inquiry</button>
</form>
</div>
{% endblock %}

View file

@ -0,0 +1,17 @@
{% extends "base.html" %}
{% block title %}Error - {{ config.SITE_TITLE }}{% endblock %}
{% block content %}
<div class="error-container">
<div class="error-code">Error</div>
<h2>Something went wrong</h2>
<p>{{ error | default("An unexpected error occurred.") }}</p>
<div class="error-actions">
<a href="{{ url_for('index') }}" class="btn-primary">Go to Home</a>
{% if request.referrer %}
<a href="{{ request.referrer }}" class="btn-secondary">Go Back</a>
{% endif %}
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,50 @@
{% extends "base.html" %}
{% block title %}Inquiry #{{ inquiry.id[:6] }} - {{ config.SITE_TITLE }}{% endblock %}
{% block content %}
<div class="inquiry-details">
<h2>Inquiry #{{ inquiry.id[:6] }}</h2>
<p>This is your conversation link: <code style="user-select: all;">{{ request.url }}</code></p>
<p class="warning"><strong>Important:</strong> Do not share this link with anyone else, as anyone you share it with could access this chat.</p>
<div style="margin-bottom: 1rem;">
<form method="POST" action="{{ url_for('delete_inquiry', inquiry_id=inquiry.id) }}" style="display: inline;">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<button type="submit" onclick="return confirm('Are you sure you want to delete this inquiry? This action cannot be undone.')" style="background-color: #dc3545; color: white; border: none; padding: 0.375rem 0.75rem; border-radius: 0.25rem; cursor: pointer;">Delete Inquiry</button>
</form>
</div>
<div class="messages">
<h3>Messages</h3>
{% if messages %}
{% for message in messages %}
<div class="message {% if message.is_admin %}admin-message{% else %}user-message{% endif %}">
<div class="content">
{% if message.is_admin %}<span class="admin-badge">ADMIN:</span> {% endif %}
{{ message.content }}
</div>
<div class="timestamp">{{ message.timestamp.strftime('%Y-%m-%d %H:%M:%S') }}</div>
</div>
{% endfor %}
{% else %}
<p>No messages yet. Start the conversation!</p>
{% endif %}
</div>
<div class="refresh-notice">
<p><em>Please refresh the page to see new messages.</em></p>
</div>
<div class="reply-form">
<h3>Reply</h3>
<form method="POST" action="{{ url_for('inquiry', inquiry_id=inquiry.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="form-group">
<textarea name="message" rows="4" placeholder="Type your message..." required></textarea>
</div>
<button type="submit">Send Message</button>
</form>
</div>
</div>
{% endblock %}

0
tests/__init__.py Normal file
View file

98
tests/test_csrf.py Normal file
View file

@ -0,0 +1,98 @@
import unittest
from src.anonchat import app, db, csrf
import os
import tempfile
import re
class CSRFTestCase(unittest.TestCase):
def setUp(self):
# Create a temporary database
self.db_fd, app.config['DATABASE'] = tempfile.mkstemp()
app.config['TESTING'] = True
app.config['WTF_CSRF_ENABLED'] = True # Enable CSRF for our tests
self.client = app.test_client()
with app.app_context():
db.create_all()
def tearDown(self):
os.close(self.db_fd)
os.unlink(app.config['DATABASE'])
def extract_csrf_token(self, response_data):
"""Extract CSRF token from the response HTML."""
match = re.search(r'name="csrf_token" value="(.+?)"', response_data)
if match:
return match.group(1)
return None
def test_form_with_csrf(self):
"""Test that a form with a valid CSRF token works."""
# Get the initial page with the form
response = self.client.get('/')
csrf_token = self.extract_csrf_token(response.data.decode('utf-8'))
# Make sure we found a token
self.assertIsNotNone(csrf_token)
# Submit the form with the token
response = self.client.post('/', data={
'csrf_token': csrf_token,
'message': 'Test message'
}, follow_redirects=True)
# Check that the form was processed successfully
self.assertEqual(response.status_code, 200)
def test_form_without_csrf(self):
"""Test that a form without a CSRF token fails."""
# Submit a form without a CSRF token
response = self.client.post('/', data={
'message': 'Test message'
}, follow_redirects=True)
# The request should be rejected with a 400 Bad Request or redirected
self.assertIn(response.status_code, [400, 302, 200])
# If redirected to error page, status will be 200 after follow_redirects
if response.status_code == 200:
# Ensure we're at an error page
self.assertIn(b'Error', response.data)
def test_admin_form_with_csrf(self):
"""Test that an admin form with a valid CSRF token works."""
# Get the initial login page with the form
response = self.client.get('/admin')
csrf_token = self.extract_csrf_token(response.data.decode('utf-8'))
# Make sure we found a token
self.assertIsNotNone(csrf_token)
# Submit the form with the token (login will fail but that's OK)
response = self.client.post('/admin', data={
'csrf_token': csrf_token,
'username': 'test',
'password': 'test'
}, follow_redirects=True)
# We should get to the login page again (bad credentials)
self.assertEqual(response.status_code, 200)
self.assertIn(b'Invalid credentials', response.data)
def test_admin_form_without_csrf(self):
"""Test that an admin form without a CSRF token fails."""
# Submit a form without a CSRF token
response = self.client.post('/admin', data={
'username': 'test',
'password': 'test'
}, follow_redirects=True)
# The request should be rejected and redirect to login
self.assertEqual(response.status_code, 200)
# Check that we have a form on the page (we've been redirected to login)
self.assertIn(b'<form method="POST">', response.data)
self.assertIn(b'<input type="hidden" name="csrf_token"', response.data)
# Since we've been redirected, the flash message might not be visible in HTML
# So we'll just check that we're at the login page with a form
if __name__ == '__main__':
unittest.main()