From 56177b28113f69c7c90b8cb4c1935fd7fa393728 Mon Sep 17 00:00:00 2001 From: Kobe Date: Fri, 6 Jun 2025 09:03:39 +0200 Subject: [PATCH] admin API with internal network --- app.py | 2 + docker-compose.yml | 17 +- models.py | 18 +- routes/admin_api.py | 471 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 506 insertions(+), 2 deletions(-) create mode 100644 routes/admin_api.py diff --git a/app.py b/app.py index fcdde20..5ffd1a1 100644 --- a/app.py +++ b/app.py @@ -8,6 +8,7 @@ from flask_wtf.csrf import generate_csrf from routes.room_files import room_files_bp from routes.room_members import room_members_bp from routes.trash import trash_bp +from routes.admin_api import admin_api from tasks import cleanup_trash import click from utils import timeago @@ -88,6 +89,7 @@ def create_app(): app.register_blueprint(room_files_bp, url_prefix='/api/rooms') app.register_blueprint(room_members_bp, url_prefix='/api/rooms') app.register_blueprint(trash_bp, url_prefix='/api/trash') + app.register_blueprint(admin_api, url_prefix='/api/admin') @app.cli.command("cleanup-trash") def cleanup_trash_command(): diff --git a/docker-compose.yml b/docker-compose.yml index c279edf..b930a23 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -29,6 +29,9 @@ services: limits: cpus: '1' memory: 1G + networks: + - public_network + - internal_network db: image: postgres:13 @@ -44,6 +47,8 @@ services: interval: 30s timeout: 10s retries: 3 + networks: + - internal_network volumes: docupulse_postgres_data: @@ -51,4 +56,14 @@ volumes: driver: local docupulse_uploads: name: docupulse_${COMPOSE_PROJECT_NAME:-default}_uploads - driver: local \ No newline at end of file + driver: local + +networks: + public_network: + name: docupulse_public + internal_network: + name: docupulse_internal + internal: true # This network is not accessible from outside Docker + ipam: + config: + - subnet: 10.42.0.0/16 # Less commonly used subnet \ No newline at end of file diff --git a/models.py b/models.py index 9d154b4..895e7b2 100644 --- a/models.py +++ b/models.py @@ -477,4 +477,20 @@ def user_has_permission(room, perm_name): return perm_name == 'can_view' # Check the specific permission - return getattr(permission, perm_name, False) \ No newline at end of file + return getattr(permission, perm_name, False) + +class ManagementAPIKey(db.Model): + __tablename__ = 'management_api_keys' + id = db.Column(db.Integer, primary_key=True) + api_key = db.Column(db.String(100), unique=True, nullable=False) + name = db.Column(db.String(100), nullable=False) # Name/description of the management tool + created_at = db.Column(db.DateTime, default=datetime.utcnow) + last_used_at = db.Column(db.DateTime) + is_active = db.Column(db.Boolean, default=True) + created_by = db.Column(db.Integer, db.ForeignKey('user.id', ondelete='SET NULL')) + + # Relationships + creator = db.relationship('User', backref=db.backref('created_api_keys', cascade='all, delete-orphan')) + + def __repr__(self): + return f'' \ No newline at end of file diff --git a/routes/admin_api.py b/routes/admin_api.py new file mode 100644 index 0000000..e80d63e --- /dev/null +++ b/routes/admin_api.py @@ -0,0 +1,471 @@ +from flask import Blueprint, jsonify, request, current_app +from functools import wraps +from models import ( + KeyValueSettings, User, Room, Conversation, RoomFile, + SiteSettings, DocuPulseSettings, Event, Mail, ManagementAPIKey +) +from extensions import db +from datetime import datetime, timedelta +import os +import jwt +from werkzeug.security import generate_password_hash +import secrets +import ipaddress + +admin_api = Blueprint('admin_api', __name__) + +def docker_network_required(f): + @wraps(f) + def decorated(*args, **kwargs): + # Get the client IP address + client_ip = request.remote_addr + + # Docker internal network range + docker_networks = [ + '10.42.0.0/16' # Our custom internal network + ] + + # Check if the client IP is in our internal network + is_docker_network = False + for network in docker_networks: + if ipaddress.ip_address(client_ip) in ipaddress.ip_network(network): + is_docker_network = True + break + + if not is_docker_network: + return jsonify({'message': 'Access denied. This API is only accessible from the internal Docker network.'}), 403 + + return f(*args, **kwargs) + return decorated + +def generate_management_api_key(): + """Generate a secure API key for the management tool""" + return secrets.token_urlsafe(32) + +def validate_management_api_key(api_key): + """Validate if the provided API key is valid""" + key = ManagementAPIKey.query.filter_by(api_key=api_key, is_active=True).first() + if key: + key.last_used_at = datetime.utcnow() + db.session.commit() + return True + return False + +@admin_api.route('/login', methods=['POST']) +@docker_network_required +def admin_login(): + data = request.get_json() + if not data or 'email' not in data or 'password' not in data: + return jsonify({'message': 'Email and password are required'}), 400 + user = User.query.filter_by(email=data['email']).first() + if not user or not user.is_admin or not user.check_password(data['password']): + return jsonify({'message': 'Invalid credentials or not an admin'}), 401 + token = jwt.encode({ + 'user_id': user.id + }, current_app.config['SECRET_KEY'], algorithm="HS256") + return jsonify({'token': token}) + +@admin_api.route('/management-token', methods=['POST']) +@docker_network_required +def get_management_token(): + """Generate a JWT token for the management tool using API key authentication""" + api_key = request.headers.get('X-API-Key') + if not api_key or not validate_management_api_key(api_key): + return jsonify({'message': 'Invalid API key'}), 401 + + # Create a token without expiration + token = jwt.encode({ + 'user_id': 0, # Special user ID for management tool + 'is_management': True + }, current_app.config['SECRET_KEY'], algorithm="HS256") + + return jsonify({ + 'token': token + }) + +@admin_api.route('/management-api-key', methods=['POST']) +@token_required +@docker_network_required +def create_management_api_key(current_user): + """Create a new API key for the management tool (only accessible by admin users)""" + if not current_user.is_admin: + return jsonify({'message': 'Insufficient permissions'}), 403 + + data = request.get_json() + if not data or 'name' not in data: + return jsonify({'message': 'Name is required'}), 400 + + api_key = generate_management_api_key() + key = ManagementAPIKey( + api_key=api_key, + name=data['name'], + created_by=current_user.id + ) + db.session.add(key) + db.session.commit() + + return jsonify({ + 'api_key': api_key, + 'name': key.name, + 'created_at': key.created_at.isoformat(), + 'message': 'API key generated successfully. Store this key securely as it will not be shown again.' + }), 201 + +@admin_api.route('/management-api-keys', methods=['GET']) +@token_required +@docker_network_required +def list_management_api_keys(current_user): + """List all management API keys (only accessible by admin users)""" + if not current_user.is_admin: + return jsonify({'message': 'Insufficient permissions'}), 403 + + keys = ManagementAPIKey.query.all() + return jsonify([{ + 'id': key.id, + 'name': key.name, + 'created_at': key.created_at.isoformat(), + 'last_used_at': key.last_used_at.isoformat() if key.last_used_at else None, + 'is_active': key.is_active, + 'created_by': key.created_by + } for key in keys]) + +@admin_api.route('/management-api-key/', methods=['DELETE']) +@token_required +@docker_network_required +def revoke_management_api_key(current_user, key_id): + """Revoke a management API key (only accessible by admin users)""" + if not current_user.is_admin: + return jsonify({'message': 'Insufficient permissions'}), 403 + + key = ManagementAPIKey.query.get(key_id) + if not key: + return jsonify({'message': 'API key not found'}), 404 + + key.is_active = False + db.session.commit() + return jsonify({'message': 'API key revoked successfully'}) + +def token_required(f): + @wraps(f) + def decorated(*args, **kwargs): + token = None + if 'Authorization' in request.headers: + token = request.headers['Authorization'].split(" ")[1] + + if not token: + return jsonify({'message': 'Token is missing!'}), 401 + + try: + data = jwt.decode(token, current_app.config['SECRET_KEY'], algorithms=["HS256"]) + # Check if it's a management tool token + if data.get('is_management'): + return f(None, *args, **kwargs) # Pass None as current_user for management tool + + current_user = User.query.get(data['user_id']) + if not current_user or not current_user.is_admin: + return jsonify({'message': 'Invalid token or insufficient permissions!'}), 401 + except: + return jsonify({'message': 'Invalid token!'}), 401 + + return f(current_user, *args, **kwargs) + return decorated + +# Key-Value Settings CRUD +@admin_api.route('/key-value', methods=['GET']) +@token_required +@docker_network_required +def get_key_values(current_user): + settings = KeyValueSettings.query.all() + return jsonify([{'key': s.key, 'value': s.value} for s in settings]) + +@admin_api.route('/key-value/', methods=['GET']) +@token_required +@docker_network_required +def get_key_value(current_user, key): + setting = KeyValueSettings.query.filter_by(key=key).first() + if not setting: + return jsonify({'message': 'Key not found'}), 404 + return jsonify({'key': setting.key, 'value': setting.value}) + +@admin_api.route('/key-value', methods=['POST']) +@token_required +@docker_network_required +def create_key_value(current_user): + data = request.get_json() + if not data or 'key' not in data or 'value' not in data: + return jsonify({'message': 'Missing key or value'}), 400 + + setting = KeyValueSettings(key=data['key'], value=data['value']) + db.session.add(setting) + db.session.commit() + return jsonify({'message': 'Key-value pair created successfully'}), 201 + +@admin_api.route('/key-value/', methods=['PUT']) +@token_required +@docker_network_required +def update_key_value(current_user, key): + setting = KeyValueSettings.query.filter_by(key=key).first() + if not setting: + return jsonify({'message': 'Key not found'}), 404 + + data = request.get_json() + if not data or 'value' not in data: + return jsonify({'message': 'Missing value'}), 400 + + setting.value = data['value'] + db.session.commit() + return jsonify({'message': 'Key-value pair updated successfully'}) + +@admin_api.route('/key-value/', methods=['DELETE']) +@token_required +@docker_network_required +def delete_key_value(current_user, key): + setting = KeyValueSettings.query.filter_by(key=key).first() + if not setting: + return jsonify({'message': 'Key not found'}), 404 + + db.session.delete(setting) + db.session.commit() + return jsonify({'message': 'Key-value pair deleted successfully'}) + +# Contacts (Users) CRUD +@admin_api.route('/contacts', methods=['GET']) +@token_required +@docker_network_required +def get_contacts(current_user): + users = User.query.all() + return jsonify([{ + 'id': user.id, + 'username': user.username, + 'email': user.email, + 'last_name': user.last_name, + 'phone': user.phone, + 'company': user.company, + 'position': user.position, + 'is_active': user.is_active, + 'created_at': user.created_at.isoformat() + } for user in users]) + +@admin_api.route('/contacts/', methods=['GET']) +@token_required +@docker_network_required +def get_contact(current_user, user_id): + user = User.query.get(user_id) + if not user: + return jsonify({'message': 'User not found'}), 404 + return jsonify({ + 'id': user.id, + 'username': user.username, + 'email': user.email, + 'last_name': user.last_name, + 'phone': user.phone, + 'company': user.company, + 'position': user.position, + 'is_active': user.is_active, + 'created_at': user.created_at.isoformat() + }) + +@admin_api.route('/contacts', methods=['POST']) +@token_required +@docker_network_required +def create_contact(current_user): + data = request.get_json() + required_fields = ['username', 'email', 'last_name'] + if not all(field in data for field in required_fields): + return jsonify({'message': 'Missing required fields'}), 400 + + if User.query.filter_by(email=data['email']).first(): + return jsonify({'message': 'Email already exists'}), 400 + + user = User( + username=data['username'], + email=data['email'], + last_name=data['last_name'], + phone=data.get('phone'), + company=data.get('company'), + position=data.get('position') + ) + user.set_password(data.get('password', 'changeme')) + + db.session.add(user) + db.session.commit() + return jsonify({'message': 'Contact created successfully', 'id': user.id}), 201 + +@admin_api.route('/contacts/', methods=['PUT']) +@token_required +@docker_network_required +def update_contact(current_user, user_id): + user = User.query.get(user_id) + if not user: + return jsonify({'message': 'User not found'}), 404 + + data = request.get_json() + if 'email' in data and data['email'] != user.email: + if User.query.filter_by(email=data['email']).first(): + return jsonify({'message': 'Email already exists'}), 400 + user.email = data['email'] + + user.username = data.get('username', user.username) + user.last_name = data.get('last_name', user.last_name) + user.phone = data.get('phone', user.phone) + user.company = data.get('company', user.company) + user.position = data.get('position', user.position) + user.is_active = data.get('is_active', user.is_active) + + if 'password' in data: + user.set_password(data['password']) + + db.session.commit() + return jsonify({'message': 'Contact updated successfully'}) + +@admin_api.route('/contacts/', methods=['DELETE']) +@token_required +@docker_network_required +def delete_contact(current_user, user_id): + user = User.query.get(user_id) + if not user: + return jsonify({'message': 'User not found'}), 404 + + db.session.delete(user) + db.session.commit() + return jsonify({'message': 'Contact deleted successfully'}) + +# Statistics +@admin_api.route('/statistics', methods=['GET']) +@token_required +@docker_network_required +def get_statistics(current_user): + room_count = Room.query.count() + conversation_count = Conversation.query.count() + + # Calculate total storage + total_storage = 0 + for file in RoomFile.query.all(): + if file.size: + total_storage += file.size + + return jsonify({ + 'rooms': room_count, + 'conversations': conversation_count, + 'total_storage': total_storage + }) + +# Website Settings CRUD +@admin_api.route('/settings', methods=['GET']) +@token_required +@docker_network_required +def get_settings(current_user): + settings = SiteSettings.get_settings() + return jsonify({ + 'primary_color': settings.primary_color, + 'secondary_color': settings.secondary_color, + 'company_name': settings.company_name, + 'company_logo': settings.company_logo, + 'company_website': settings.company_website, + 'company_email': settings.company_email, + 'company_phone': settings.company_phone, + 'company_address': settings.company_address, + 'company_city': settings.company_city, + 'company_state': settings.company_state, + 'company_zip': settings.company_zip, + 'company_country': settings.company_country, + 'company_description': settings.company_description, + 'company_industry': settings.company_industry + }) + +@admin_api.route('/settings', methods=['PUT']) +@token_required +@docker_network_required +def update_settings(current_user): + settings = SiteSettings.get_settings() + data = request.get_json() + + for key, value in data.items(): + if hasattr(settings, key): + setattr(settings, key, value) + + db.session.commit() + return jsonify({'message': 'Settings updated successfully'}) + +# Website Logs +@admin_api.route('/logs', methods=['GET']) +@token_required +@docker_network_required +def get_logs(current_user): + page = request.args.get('page', 1, type=int) + per_page = request.args.get('per_page', 50, type=int) + + events = Event.query.order_by(Event.timestamp.desc()).paginate( + page=page, per_page=per_page, error_out=False + ) + + return jsonify({ + 'events': [{ + 'id': event.id, + 'event_type': event.event_type, + 'user_id': event.user_id, + 'timestamp': event.timestamp.isoformat(), + 'details': event.details, + 'ip_address': event.ip_address, + 'user_agent': event.user_agent + } for event in events.items], + 'total': events.total, + 'pages': events.pages, + 'current_page': events.page + }) + +# Mail Logs +@admin_api.route('/mail-logs', methods=['GET']) +@token_required +@docker_network_required +def get_mail_logs(current_user): + page = request.args.get('page', 1, type=int) + per_page = request.args.get('per_page', 50, type=int) + + mails = Mail.query.order_by(Mail.created_at.desc()).paginate( + page=page, per_page=per_page, error_out=False + ) + + return jsonify({ + 'mails': [{ + 'id': mail.id, + 'recipient': mail.recipient, + 'subject': mail.subject, + 'status': mail.status, + 'created_at': mail.created_at.isoformat(), + 'sent_at': mail.sent_at.isoformat() if mail.sent_at else None, + 'template_id': mail.template_id + } for mail in mails.items], + 'total': mails.total, + 'pages': mails.pages, + 'current_page': mails.page + }) + +# Resend Setup Mail +@admin_api.route('/resend-setup-mail/', methods=['POST']) +@token_required +@docker_network_required +def resend_setup_mail(current_user, user_id): + user = User.query.get(user_id) + if not user: + return jsonify({'message': 'User not found'}), 404 + + # Generate a new password setup token + token = PasswordSetupToken( + user_id=user.id, + token=generate_password_hash(str(user.id) + str(datetime.utcnow())), + expires_at=datetime.utcnow() + timedelta(days=7) + ) + db.session.add(token) + + # Create mail record + mail = Mail( + recipient=user.email, + subject='DocuPulse Account Setup', + body=f'Please click the following link to set up your account: {request.host_url}setup-password/{token.token}', + status='pending' + ) + db.session.add(mail) + + db.session.commit() + return jsonify({'message': 'Setup mail queued for resending'}) \ No newline at end of file