diff --git a/README.md b/README.md index 8304664..25fa04e 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ AnonChat was created using "vibe coding" - a programming approach where develope 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 \ + 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) @@ -83,6 +83,49 @@ This project uses Poetry for dependency management. - Run tests: `poetry run pytest` - Run the application: `poetry run start` +### Database Migrations + +AnonChat includes a custom database migration system to handle schema changes. When you make changes to the database models, you should create a migration script to apply these changes to existing databases. + +#### Running Migrations + +- Run all pending migrations: `poetry run flask --app src/anonchat run-migrations` +- The migrations are also automatically run when using the `init-db` command or when starting the application with the entrypoint script. + +#### Creating New Migrations + +To create a new migration: + +1. Create a new Python file in the `src/anonchat/migrations` directory with a descriptive name (e.g., `add_new_column.py`) +2. Implement a `run_migration(db)` function that performs the necessary schema changes +3. The migration script should be idempotent (safe to run multiple times) + +Example migration script: + +```python +from sqlalchemy import inspect +from sqlalchemy.sql import text +from flask import current_app + +def run_migration(db): + """Add a new column to a table.""" + # Check if the column already exists + inspector = inspect(db.engine) + columns = [col['name'] for col in inspector.get_columns('your_table')] + + # Only apply changes if needed + if 'your_new_column' not in columns: + current_app.logger.info("Adding new column to table") + with db.engine.connect() as conn: + conn.execute(text("ALTER TABLE your_table ADD COLUMN your_new_column TEXT")) + conn.commit() + return True + + return False # Return True if changes were made, False otherwise +``` + +Migrations are run in alphabetical order, so you may want to prefix migration filenames with a timestamp or sequence number for more complex projects. + ## 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. @@ -147,14 +190,7 @@ The following security enhancements are planned for future releases: - 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 - + ### Read-Only Links - [ ] Implement read-only sharing links for inquiries - Generate unique, cryptographically secure sharing links diff --git a/entrypoint.sh b/entrypoint.sh index bca0e44..b2c3575 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -6,6 +6,11 @@ echo "Running database initialization..." poetry run flask --app src/anonchat init-db echo "Database initialization complete." +# Run database migrations to ensure schema is up to date +echo "Running database migrations..." +poetry run flask --app src/anonchat run-migrations +echo "Database migrations complete." + # Execute the command passed as arguments (CMD in Dockerfile) echo "Executing command: $@" exec "$@" \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 1ad558c..0070858 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,7 +19,8 @@ dependencies = [ "psycopg2-binary (>=2.9.9,<3.0.0)", "redis (>=5.0.0,<6.0.0)", "flask-session (>=0.5.0,<1.0.0)", - "argon2-cffi (>=23.0.0,<24.0.0)" + "argon2-cffi (>=23.0.0,<24.0.0)", + "flask-apscheduler (>=1.13.1,<2.0.0)" ] [tool.poetry] diff --git a/src/anonchat/__init__.py b/src/anonchat/__init__.py index 00c8c51..cc17e5a 100644 --- a/src/anonchat/__init__.py +++ b/src/anonchat/__init__.py @@ -10,6 +10,7 @@ from flask_session import Session import redis from werkzeug.middleware.proxy_fix import ProxyFix from argon2 import PasswordHasher +from flask_apscheduler import APScheduler # Load environment variables from .env file load_dotenv() @@ -46,6 +47,10 @@ elif app.config['SESSION_TYPE'] == 'filesystem': app.config['SESSION_FILE_MODE'] = os.environ.get('SESSION_FILE_MODE', 384) app.config['SESSION_KEY_PREFIX'] = 'anonchat_session:' +# Scheduler configuration +app.config['SCHEDULER_API_ENABLED'] = False +app.config['SCHEDULER_TIMEZONE'] = 'UTC' + if app.config['BEHIND_PROXY']: app.wsgi_app = ProxyFix(app.wsgi_app, x_for=2) @@ -69,6 +74,10 @@ limiter = Limiter(get_remote_address, app=app) # Initialize database db = SQLAlchemy(app) +# Initialize scheduler +scheduler = APScheduler() +scheduler.init_app(app) + # Import models from . import models # Explicitly import all models to ensure they are registered with SQLAlchemy @@ -104,6 +113,37 @@ def ratelimit_handler(e): # Import routes from . import routes +# Setup scheduler jobs +def setup_scheduler_jobs(): + # Import the function from tasks.py + from .tasks import check_and_delete_expired_inquiries + + # Schedule the job to run every 6 hours + scheduler.add_job( + id='check_and_delete_expired_inquiries', + func=check_and_delete_expired_inquiries, + trigger='interval', + hours=6 + ) + + # Start the scheduler + scheduler.start() + +# Start the scheduler when the app is initialized +setup_scheduler_jobs() + +# Flask CLI command to run migrations +@app.cli.command("run-migrations") +def run_migrations_command(): + """Run database migrations to update schema.""" + with app.app_context(): + # Import migrations module here to avoid circular imports + from .migrations import run_migrations + + print("Running database migrations...") + migrations_run = run_migrations(db) + print(f"Database migrations completed: {migrations_run} migrations applied.") + # Flask CLI command to initialize the database @app.cli.command("init-db") def init_db_command(): @@ -113,6 +153,13 @@ def init_db_command(): with app.app_context(): print("Creating database tables...") db.create_all() # Should now create all tables including Inquiry + + # Run migrations after creating tables + # This ensures any new columns added to existing tables are properly added + print("Running database migrations...") + from .migrations import run_migrations + run_migrations(db) + print("Initializing default settings...") # Initialize settings if they don't exist # Note: Models already imported above diff --git a/src/anonchat/migrations/__init__.py b/src/anonchat/migrations/__init__.py new file mode 100644 index 0000000..b53f865 --- /dev/null +++ b/src/anonchat/migrations/__init__.py @@ -0,0 +1,31 @@ +from flask import current_app +from importlib import import_module +import os +import pkgutil + +def run_migrations(db): + """Run all migrations in the migrations directory""" + current_app.logger.info("Starting database migrations") + migrations_run = 0 + + # Get the list of all migration modules in this package + # This will look for all Python modules in the migrations directory + migrations_pkg = __name__ + for _, name, ispkg in pkgutil.iter_modules([os.path.dirname(__file__)]): + if name.startswith('_') or ispkg: + continue + + # Import the migration module + current_app.logger.info(f"Importing migration: {name}") + migration_module = import_module(f"{migrations_pkg}.{name}") + + # Run the migration if it has a run_migration function + if hasattr(migration_module, 'run_migration'): + current_app.logger.info(f"Running migration: {name}") + if migration_module.run_migration(db): + migrations_run += 1 + else: + current_app.logger.warning(f"Migration {name} has no run_migration function") + + current_app.logger.info(f"Database migrations completed. {migrations_run} migrations applied.") + return migrations_run \ No newline at end of file diff --git a/src/anonchat/migrations/add_closed_status.py b/src/anonchat/migrations/add_closed_status.py new file mode 100644 index 0000000..5b9b596 --- /dev/null +++ b/src/anonchat/migrations/add_closed_status.py @@ -0,0 +1,43 @@ +from sqlalchemy import Column, Boolean, DateTime, inspect +from sqlalchemy.sql import text +from flask import current_app + +def run_migration(db): + """Add is_closed and closing_timestamp columns to the Inquiry table if they don't exist.""" + current_app.logger.info("Running migration: add_closed_status") + + # Check if the columns already exist + inspector = inspect(db.engine) + columns = [col['name'] for col in inspector.get_columns('inquiry')] + + # Track if we need to commit changes + changes_made = False + + # Add is_closed column if it doesn't exist + if 'is_closed' not in columns: + current_app.logger.info("Adding is_closed column to inquiry table") + with db.engine.connect() as conn: + conn.execute(text("ALTER TABLE inquiry ADD COLUMN is_closed BOOLEAN DEFAULT FALSE")) + # We need to commit within the connection context for some database types + conn.commit() + changes_made = True + else: + current_app.logger.info("is_closed column already exists in inquiry table") + + # Add closing_timestamp column if it doesn't exist + if 'closing_timestamp' not in columns: + current_app.logger.info("Adding closing_timestamp column to inquiry table") + with db.engine.connect() as conn: + conn.execute(text("ALTER TABLE inquiry ADD COLUMN closing_timestamp TIMESTAMP")) + # We need to commit within the connection context for some database types + conn.commit() + changes_made = True + else: + current_app.logger.info("closing_timestamp column already exists in inquiry table") + + if changes_made: + current_app.logger.info("Migration completed successfully") + else: + current_app.logger.info("No changes needed, all columns already exist") + + return changes_made \ No newline at end of file diff --git a/src/anonchat/models.py b/src/anonchat/models.py index cf2659a..2e8d371 100644 --- a/src/anonchat/models.py +++ b/src/anonchat/models.py @@ -4,6 +4,7 @@ import secrets import argon2 from argon2 import PasswordHasher from . import password_hasher +from datetime import datetime, timedelta class Message(db.Model): inquiry_id = db.Column(db.String(16), db.ForeignKey('inquiry.id'), nullable=False, primary_key=True) @@ -29,6 +30,33 @@ class Inquiry(db.Model): messages = db.relationship('Message', backref='inquiry', lazy=True, cascade='all, delete-orphan', order_by='Message.message_number') + is_closed = db.Column(db.Boolean, default=False) + closing_timestamp = db.Column(db.DateTime, nullable=True) + + def close(self): + """Mark an inquiry as closed""" + self.is_closed = True + self.closing_timestamp = datetime.utcnow() + + def reopen(self): + """Reopen a closed inquiry""" + self.is_closed = False + self.closing_timestamp = None + + @staticmethod + def get_expired_inquiries(days=2): + """Get inquiries that have been closed for more than the specified days""" + expiry_date = datetime.utcnow() - timedelta(days=days) + return Inquiry.query.filter( + Inquiry.is_closed == True, + Inquiry.closing_timestamp <= expiry_date + ).all() + + def get_deletion_date(self): + """Get the deletion date for a closed inquiry""" + if self.is_closed: + return self.closing_timestamp + timedelta(days=2) + return None class Settings(db.Model): id = db.Column(db.Integer, primary_key=True) diff --git a/src/anonchat/routes.py b/src/anonchat/routes.py index 2e83ba3..b25464f 100644 --- a/src/anonchat/routes.py +++ b/src/anonchat/routes.py @@ -8,7 +8,7 @@ import urllib.error import hmac import hashlib import json -from datetime import datetime +from datetime import datetime, timedelta def is_admin(): return 'admin_authenticated' in session and session['admin_authenticated'] @@ -105,27 +105,33 @@ def inquiry(inquiry_id): is_admin = 'admin_authenticated' in session and session['admin_authenticated'] if request.method == 'POST': - if is_admin: - message_content = request.form.get('admin_response') - is_admin_message = True - else: - message_content = request.form.get('message') - is_admin_message = False + # Handle closed inquiry differently for admin vs non-admin + if inquiry.is_closed: + # Auto-reopen the inquiry when admin responds + inquiry.reopen() + db.session.commit() + + # Send webhook for reopened inquiry + send_webhook('inquiry_reopened', { + 'inquiry_id': inquiry_id + }) + + message_content = request.form.get('message') if message_content and message_content.strip(): message = Message( content=message_content, inquiry_id=inquiry_id, - is_admin=is_admin_message + is_admin=is_admin ) db.session.add(message) db.session.commit() - if not is_admin_message: + if not is_admin: send_webhook('new_message', { 'inquiry_id': inquiry_id, 'message': message_content, - 'is_admin': is_admin_message + 'is_admin': is_admin }) return redirect(url_for('inquiry', inquiry_id=inquiry_id)) @@ -323,4 +329,50 @@ def get_inquiry_messages(inquiry_id): return jsonify({ 'inquiry_id': inquiry_id, 'messages': messages_json - }) \ No newline at end of file + }) + +@app.route('/inquiry//close', methods=['POST']) +@admin_required +def close_inquiry(inquiry_id): + inquiry = Inquiry.query.get_or_404(inquiry_id) + + # Only close if not already closed + if not inquiry.is_closed: + inquiry.close() + db.session.commit() + + # Send webhook for closed inquiry + send_webhook('inquiry_closed', { + 'inquiry_id': inquiry_id + }) + + # Redirect back to the inquiry or admin dashboard based on referrer + referrer = request.referrer + if referrer and 'admin/dashboard' in referrer: + return redirect(url_for('admin_dashboard')) + else: + return redirect(url_for('inquiry', inquiry_id=inquiry_id)) + +@app.route('/inquiry//reopen', methods=['POST']) +@admin_required +def reopen_inquiry(inquiry_id): + inquiry = Inquiry.query.get_or_404(inquiry_id) + + # Only reopen if currently closed + if inquiry.is_closed: + inquiry.reopen() + db.session.commit() + + # Send webhook for reopened inquiry + send_webhook('inquiry_reopened', { + 'inquiry_id': inquiry_id + }) + + flash('Inquiry reopened successfully.') + + # Redirect back to the inquiry or admin dashboard based on referrer + referrer = request.referrer + if referrer and 'admin/dashboard' in referrer: + return redirect(url_for('admin_dashboard')) + else: + return redirect(url_for('inquiry', inquiry_id=inquiry_id)) \ No newline at end of file diff --git a/src/anonchat/tasks.py b/src/anonchat/tasks.py new file mode 100644 index 0000000..10ea7c4 --- /dev/null +++ b/src/anonchat/tasks.py @@ -0,0 +1,29 @@ +from flask import current_app +from . import db +from .models import Inquiry +from datetime import datetime + +def check_and_delete_expired_inquiries(): + """Check and delete inquiries that have been closed for more than 2 days""" + # Get app context from current_app + app = current_app._get_current_object() + + with app.app_context(): + app.logger.info("Running scheduled task: check_and_delete_expired_inquiries") + expired_inquiries = Inquiry.get_expired_inquiries(days=2) + deleted_count = 0 + + for inquiry in expired_inquiries: + # Delete the inquiry + db.session.delete(inquiry) + deleted_count += 1 + + # Send webhook for automatically deleted inquiry + from .routes import send_webhook + send_webhook('inquiry_auto_deleted', { + 'inquiry_id': inquiry.id + }) + + if deleted_count > 0: + db.session.commit() + app.logger.info(f"Automatically deleted {deleted_count} expired closed inquiries") \ No newline at end of file diff --git a/src/anonchat/templates/admin_dashboard.html b/src/anonchat/templates/admin_dashboard.html index 035ea2e..a8378e4 100644 --- a/src/anonchat/templates/admin_dashboard.html +++ b/src/anonchat/templates/admin_dashboard.html @@ -16,6 +16,7 @@ ID + Status Messages Latest Message Time @@ -24,8 +25,20 @@ {% for item in inquiries_with_data %} - + {{ item.inquiry.id }} + + {% if item.inquiry.is_closed %} + CLOSED + {% if item.inquiry.closing_timestamp %} + + Closed on {{ item.inquiry.closing_timestamp.strftime('%Y-%m-%d %H:%M') }} + + {% endif %} + {% else %} + OPEN + {% endif %} + {{ item.message_count }} {% if item.latest_message %} @@ -43,6 +56,19 @@ View/Respond + + {% if item.inquiry.is_closed %} +
+ + +
+ {% else %} +
+ + +
+ {% endif %} +
diff --git a/src/anonchat/templates/inquiry.html b/src/anonchat/templates/inquiry.html index f7af194..3eb526c 100644 --- a/src/anonchat/templates/inquiry.html +++ b/src/anonchat/templates/inquiry.html @@ -6,7 +6,11 @@ {% block content %}
-

Inquiry #{{ inquiry.id[:6] }}

+

Inquiry #{{ inquiry.id[:6] }} + {% if inquiry.is_closed %} + CLOSED + {% endif %} +

{% if is_admin %} {{ admin_header() }} @@ -19,7 +23,25 @@

Important: Do not share this link with anyone else, as anyone you share it with could access this chat.

{% endif %} + {% if inquiry.is_closed %} +
+ This inquiry is closed. + {% if inquiry.closing_timestamp %}It will be deleted on {{ inquiry.get_deletion_date().strftime('%Y-%m-%d %H:%M') }} UTC.{% endif %} +
+ {% endif %} +
+ {% if inquiry.is_closed %} + + + + + {% else %} +
+ + +
+ {% endif %}
@@ -50,9 +72,15 @@
- +
- +