admin API with internal network
This commit is contained in:
2
app.py
2
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():
|
||||
|
||||
@@ -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:
|
||||
@@ -52,3 +57,13 @@ volumes:
|
||||
docupulse_uploads:
|
||||
name: docupulse_${COMPOSE_PROJECT_NAME:-default}_uploads
|
||||
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
|
||||
16
models.py
16
models.py
@@ -478,3 +478,19 @@ def user_has_permission(room, perm_name):
|
||||
|
||||
# Check the specific permission
|
||||
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'<ManagementAPIKey {self.name}>'
|
||||
471
routes/admin_api.py
Normal file
471
routes/admin_api.py
Normal file
@@ -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/<int:key_id>', 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/<key>', 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/<key>', 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/<key>', 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/<int:user_id>', 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/<int:user_id>', 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/<int:user_id>', 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/<int:user_id>', 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'})
|
||||
Reference in New Issue
Block a user