Initial commit
This commit is contained in:
commit
3fb583e102
25 changed files with 1673 additions and 0 deletions
37
.dockerignore
Normal file
37
.dockerignore
Normal 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
61
.gitignore
vendored
Normal 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
60
Dockerfile
Normal 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
162
README.md
Normal 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, it’s 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
37
docker-compose.yml
Normal 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
74
docs/csrf-protection.md
Normal 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
11
entrypoint.sh
Normal 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
26
fly.toml
Normal 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
33
pyproject.toml
Normal 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
176
src/anonchat/__init__.py
Normal 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
36
src/anonchat/models.py
Normal 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
311
src/anonchat/routes.py
Normal 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
|
146
src/anonchat/static/css/styles.css
Normal file
146
src/anonchat/static/css/styles.css
Normal 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;
|
||||
}
|
14
src/anonchat/templates/404.html
Normal file
14
src/anonchat/templates/404.html
Normal 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 %}
|
71
src/anonchat/templates/admin_dashboard.html
Normal file
71
src/anonchat/templates/admin_dashboard.html
Normal 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 %}
|
52
src/anonchat/templates/admin_inquiry.html
Normal file
52
src/anonchat/templates/admin_inquiry.html
Normal 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 %}
|
24
src/anonchat/templates/admin_login.html
Normal file
24
src/anonchat/templates/admin_login.html
Normal 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 %}
|
47
src/anonchat/templates/admin_settings.html
Normal file
47
src/anonchat/templates/admin_settings.html
Normal 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 %}
|
71
src/anonchat/templates/admin_webhook.html
Normal file
71
src/anonchat/templates/admin_webhook.html
Normal 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 %}
|
20
src/anonchat/templates/base.html
Normal file
20
src/anonchat/templates/base.html
Normal 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>
|
39
src/anonchat/templates/create_inquiry.html
Normal file
39
src/anonchat/templates/create_inquiry.html
Normal 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 %}
|
17
src/anonchat/templates/error.html
Normal file
17
src/anonchat/templates/error.html
Normal 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 %}
|
50
src/anonchat/templates/inquiry.html
Normal file
50
src/anonchat/templates/inquiry.html
Normal 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
0
tests/__init__.py
Normal file
98
tests/test_csrf.py
Normal file
98
tests/test_csrf.py
Normal 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()
|
Loading…
Add table
Add a link
Reference in a new issue