from flask import Flask, send_file, request, jsonify from functools import wraps from Crypto.Cipher import AES import json import os import base64 import time import subprocess import logging from datetime import datetime # Configure logging logging.basicConfig( level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' ) logger = logging.getLogger('scadlib') # Encryption key matching C# implementation ENCRYPTION_KEY = b"SCAD>Bricscad>Autocad" # Pad key to 32 bytes for AES-256 ENCRYPTION_KEY = ENCRYPTION_KEY.ljust(32, b'\0') # Use first 16 bytes as IV (initialization vector) IV = ENCRYPTION_KEY[:16] def decrypt_token(token): try: # Decode base64 token encrypted_data = base64.b64decode(token) # Create AES cipher cipher = AES.new(ENCRYPTION_KEY, AES.MODE_CBC, IV) # Decrypt and unpad decrypted_data = cipher.decrypt(encrypted_data) # Remove PKCS7 padding padding_length = decrypted_data[-1] decrypted_data = decrypted_data[:-padding_length] # Parse JSON token_data = json.loads(decrypted_data) return token_data except Exception as e: logger.error(f"Token decryption error: {str(e)}") return None def require_auth(f): @wraps(f) def decorated(*args, **kwargs): auth_token = request.headers.get('auth-token') if not auth_token: logger.warning("Missing auth-token header") return jsonify({"error": "Missing auth-token header"}), 401 token_data = decrypt_token(auth_token) if not token_data: logger.warning("Invalid auth-token") return jsonify({"error": "Invalid token"}), 401 # Check timestamp (within 30 minutes) token_time = int(token_data.get('Timestamp', 0)) current_time = int(time.time()) if abs(current_time - token_time) > 1800: # 30 minutes = 1800 seconds readable_time_token = datetime.fromtimestamp(token_time).strftime('%Y-%m-%d %H:%M:%S') readable_time_current = datetime.fromtimestamp(current_time).strftime('%Y-%m-%d %H:%M:%S') logger.warning("Token expired: client time is: %s, server time is: %s", readable_time_token, readable_time_current) return jsonify({"error": "Token expired"}), 401 return f(*args, **kwargs) return decorated def update_manifest(): """Run the generate_manifest.py script to update the manifest file.""" try: logger.info("Starting manifest update") subprocess.run(["python", "/app/generate_manifest.py"], check=True) logger.info(f"Manifest updated at {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") except Exception as e: logger.error(f"Error updating manifest: {str(e)}") app = Flask(__name__) logger.info("Flask application initialized") # Remove APScheduler initialization and job scheduling # @app.before_request # def before_request_func(): # if not hasattr(app, 'manifest_updated'): # logger.info("Running initial manifest update") # update_manifest() # app.manifest_updated = True @app.route('/api/manifest.json') @require_auth def get_manifest(): """Serve the latest manifest file.""" logger.info("Manifest requested") manifest_path = "/app/manifest.json" if os.path.exists(manifest_path): logger.info("Serving manifest file") return send_file(manifest_path, mimetype='application/json') logger.error("Manifest file not found") return {"error": "Manifest not found"}, 404 @app.route('/api/compare-manifest', methods=['POST']) @require_auth def compare_manifest(): """Compare client manifest with server manifest and return changed files.""" logger.info("Manifest comparison requested") try: # Get client manifest from request client_manifest = request.get_json() if not client_manifest: logger.warning("No manifest data provided in request") return {"error": "No manifest data provided"}, 400 # Load server manifest from existing file manifest_path = "/app/manifest.json" if not os.path.exists(manifest_path): logger.error("Server manifest file not found") return {"error": "Server manifest not found"}, 404 with open(manifest_path, 'r') as f: server_manifest = json.load(f) # Performance optimization: Use sets for faster lookups client_files = set(client_manifest.keys()) server_files = set(server_manifest.keys()) # Initialize changes structure changes = { "modified": [], "added": [], "removed": [] } # Check for modified files (files that exist in both manifests) common_files = client_files & server_files for file_path in common_files: server_info = server_manifest[file_path] client_info = client_manifest[file_path] # Performance optimization: Compare hash first (most reliable indicator) server_hash = server_info.get("hash") client_hash = client_info.get("hash") if server_hash != client_hash: # File is definitely different, add to modified list changes["modified"].append({ "path": file_path, "remote_prefix": server_info.get("remote_prefix"), "server_hash": server_hash, "client_hash": client_hash, "server_size": server_info.get("size"), "client_size": client_info.get("size"), "server_modified": server_info.get("modified"), "client_modified": client_info.get("modified") }) # If hashes match, files are identical - no need to check size/time # Check for added files (exist on server but not in client) added_files = server_files - client_files for file_path in added_files: server_info = server_manifest[file_path] changes["added"].append({ "path": file_path, "remote_prefix": server_info.get("remote_prefix"), "size": server_info.get("size"), "hash": server_info.get("hash"), "modified": server_info.get("modified") }) # Check for removed files (exist in client but not on server) removed_files = client_files - server_files for file_path in removed_files: changes["removed"].append({ "path": file_path }) # Log summary total_changes = len(changes["modified"]) + len(changes["added"]) + len(changes["removed"]) logger.info(f"Manifest comparison complete: {total_changes} changes found " f"({len(changes['modified'])} modified, {len(changes['added'])} added, " f"{len(changes['removed'])} removed)") return jsonify({ "changes": changes, "summary": { "total_changes": total_changes, "modified_count": len(changes["modified"]), "added_count": len(changes["added"]), "removed_count": len(changes["removed"]) } }) except Exception as e: logger.exception(f"Error comparing manifests: {str(e)}") return {"error": f"Manifest comparison failed: {str(e)}"}, 500 @app.route('/api//') @require_auth def serve_file(directory, filename): """Serve files from specified directories with appropriate mime types.""" logger.info(f"File requested from {directory}: {filename}") # Map valid directories to their full paths and allowed extensions directory_config = { 'dwg_files': { 'path': '/app/dwg_files', 'allowed_extensions': { '.dwg': 'application/acad', '.txt': 'text/plain', '.csv': 'text/csv' }, 'allowed_no_extension_files': ['version'] # Whitelist of files without extensions }, 'database_files': { 'path': '/app/database_files', 'allowed_extensions': None # None means all extensions are allowed } } # Check if requested directory is valid if directory not in directory_config: logger.warning(f"Invalid directory requested: {directory}") return {"error": "Invalid directory"}, 403 try: config = directory_config[directory] base_path = config['path'] file_path = os.path.join(base_path, filename) # Ensure file path is within allowed directory if not os.path.abspath(file_path).startswith(base_path): logger.warning(f"Invalid file path attempted: {file_path}") return {"error": "Invalid file path"}, 403 if not os.path.exists(file_path): logger.error(f"File not found: {filename}") return {"error": "File not found"}, 404 # Check file extension restrictions if config['allowed_extensions'] is not None: ext = os.path.splitext(file_path)[1].lower() # Handle files without extensions if not ext: # Check if this file is in the whitelist of allowed files without extensions allowed_no_ext = config.get('allowed_no_extension_files', []) if filename not in allowed_no_ext: logger.warning(f"Unsupported file without extension: {filename}") return {"error": "Unsupported file type"}, 415 # Serve files without extensions as text/plain logger.info(f"Serving file without extension: {filename}") return send_file(file_path, mimetype='text/plain') # Handle files with extensions if ext not in config['allowed_extensions']: logger.warning(f"Unsupported file type: {filename}") return {"error": "Unsupported file type"}, 415 mimetype = config['allowed_extensions'][ext] logger.info(f"Serving {ext} file: {filename}") return send_file(file_path, mimetype=mimetype) # For directories with no extension restrictions logger.info(f"Serving file: {filename}") return send_file(file_path) except Exception as e: logger.exception(f"Error serving file {filename}: {str(e)}") return {"error": str(e)}, 500 if __name__ == '__main__': # Only use the development server when running the script directly # In production, this block won't execute when using Gunicorn logger.info("Starting development server") app.run(host='0.0.0.0', port=5000, debug=False)