diff --git a/.gitignore b/.gitignore index b5839894..3d901a69 100644 --- a/.gitignore +++ b/.gitignore @@ -11,12 +11,14 @@ # production /build /dist +/backend/deploy # python stuff __pycache__/ venv/ *.egg-info + # misc .DS_Store .env.local diff --git a/backend/deploy.py b/backend/deploy.py new file mode 100644 index 00000000..6208acf4 --- /dev/null +++ b/backend/deploy.py @@ -0,0 +1,64 @@ +# Standard library imports +import os +import shutil +import zipfile +from typing import Dict, List, Tuple, Union + +# Third-party imports +from flask import request +from flask_restful import Resource + +# Our imports +from main import basedir + +class DeployResource(Resource): + def post(self) -> Union[Dict[str, Union[str, int, List[str]]], Tuple[Dict[str, str], int]]: + """Upload and extract a zip file to the deploy directory""" + if 'file' not in request.files: + return {'error': 'No file provided'}, 400 + + file = request.files['file'] + + if file.filename == '': + return {'error': 'No file selected'}, 400 + + if not file.filename.endswith('.zip'): + return {'error': 'Only zip files are allowed'}, 400 + + try: + # Create deploy directory if it doesn't exist + deploy_dir = os.path.join(basedir, 'deploy') + + # Clear existing deploy directory + if os.path.exists(deploy_dir): + shutil.rmtree(deploy_dir) + os.makedirs(deploy_dir) + + # Save the zip file temporarily + temp_zip_path = os.path.join(basedir, 'temp_deploy.zip') + file.save(temp_zip_path) + + # Extract the zip file + with zipfile.ZipFile(temp_zip_path, 'r') as zip_ref: + zip_ref.extractall(deploy_dir) + + # Remove the temporary zip file + os.remove(temp_zip_path) + + # List extracted files + extracted_files = [] + for root, dirs, files in os.walk(deploy_dir): + for filename in files: + rel_path = os.path.relpath(os.path.join(root, filename), deploy_dir) + extracted_files.append(rel_path) + + return { + 'message': 'Deployment successful', + 'deploy_directory': deploy_dir, + 'files_extracted': len(extracted_files), + 'files': extracted_files[:20] # Show first 20 files + } + except zipfile.BadZipFile: + return {'error': 'Invalid zip file'}, 400 + except Exception as e: + return {'error': f'Deployment failed: {str(e)}'}, 500 \ No newline at end of file diff --git a/backend/main.py b/backend/main.py index 0c8a6f58..77f565c4 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,22 +1,37 @@ -from flask import Flask, request, jsonify -from flask_restful import Resource, Api -from flask_sqlalchemy import SQLAlchemy -import json +# Standard library imports +import argparse +import logging import os +from typing import Tuple, Union + +# Third-party imports +from flask import Flask, jsonify, request, send_from_directory, Response +from flask_restful import Api +from flask_sqlalchemy import SQLAlchemy -app = Flask(__name__) +# Our imports +from deploy import DeployResource +from storage import StorageEntryResource, StorageFileRenameResource, StorageRootResource, StorageResource + +app = Flask(__name__, static_folder='../dist', static_url_path='') +app.url_map.merge_slashes = False # Don't merge consecutive slashes api = Api(app) +db = SQLAlchemy(app) + +# Configure logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) # Add CORS headers @app.after_request -def after_request(response): +def after_request(response: Response) -> Response: response.headers.add('Access-Control-Allow-Origin', '*') response.headers.add('Access-Control-Allow-Headers', 'Content-Type,Authorization') response.headers.add('Access-Control-Allow-Methods', 'GET,PUT,POST,DELETE,OPTIONS') return response @app.before_request -def handle_preflight(): +def handle_preflight() -> Union[Response, None]: if request.method == "OPTIONS": response = jsonify({'message': 'OK'}) response.headers.add('Access-Control-Allow-Origin', '*') @@ -29,220 +44,107 @@ def handle_preflight(): app.config['SQLALCHEMY_DATABASE_URI'] = f'sqlite:///{os.path.join(basedir, "projects.db")}' app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False -db = SQLAlchemy(app) - -# Storage models for key-value pairs and files -class StorageEntry(db.Model): - id = db.Column(db.Integer, primary_key=True) - entry_key = db.Column(db.String(255), nullable=False, unique=True) - entry_value = db.Column(db.Text, nullable=False) - - def to_dict(self): - return { - 'key': self.entry_key, - 'value': self.entry_value - } +# Register API routes +# Storage API routes (more specific routes first) +api.add_resource(StorageEntryResource, '/entries/') +api.add_resource(StorageFileRenameResource, '/storage/rename') +api.add_resource(StorageRootResource, '/storage/') +api.add_resource(StorageResource, '/storage/') +api.add_resource(DeployResource, '/deploy') -class StorageFile(db.Model): - id = db.Column(db.Integer, primary_key=True) - file_path = db.Column(db.String(500), nullable=False, unique=True) - file_content = db.Column(db.Text, nullable=False) - - def to_dict(self): - return { - 'path': self.file_path, - 'content': self.file_content - } +# API health check endpoint to distinguish from static file serving +@app.route('/statusz') +def statusz() -> Response: + """Health check endpoint to identify backend server""" + return jsonify({ + 'status': 'ok', + 'server': 'python-backend', + 'version': '1.0' + }) -# Storage Resources for key-value and file operations -class StorageEntryResource(Resource): - def get(self, entry_key): - """Fetch entry value by key""" - entry = StorageEntry.query.filter_by(entry_key=entry_key).first() - if entry: - return {'value': entry.entry_value} - else: - # Return default value if provided in query params - default_value = request.args.get('default', '') - return {'value': default_value} +# Handle the base path for the frontend +@app.route('/blocks/', defaults={'path': ''}) +@app.route('/blocks/') +@app.route('/blocks//') # Handle double slash +def serve_frontend(path: str) -> Union[Response, Tuple[Response, int]]: + """Serve static assets from dist/ directory with base path""" + # Normalize path - remove leading slashes + path = path.lstrip('/') - def post(self, entry_key): - """Save entry value""" - data = request.get_json() - if not data or 'value' not in data: - return {'error': 'Entry value is required'}, 400 - - entry = StorageEntry.query.filter_by(entry_key=entry_key).first() - if entry: - entry.entry_value = data['value'] - else: - entry = StorageEntry(entry_key=entry_key, entry_value=data['value']) - db.session.add(entry) - - try: - db.session.commit() - return {'message': 'Entry saved successfully'} - except Exception as e: - db.session.rollback() - return {'error': 'Failed to save entry'}, 500 - -class StorageResource(Resource): - def get(self, path): - """Get file content or list directory based on path""" - # Handle empty path as root directory - if not path: - path = "" - - if path.endswith('/') or path == "": - # List directory - # For root directory, find all files - if path == "": - files = StorageFile.query.all() - # Extract top-level files and directories - children = set() - for file in files: - if '/' in file.file_path: - # It's in a subdirectory, add the directory name - dir_name = file.file_path.split('/')[0] + '/' - children.add(dir_name) - else: - # It's a top-level file - children.add(file.file_path) - else: - # Find all files that start with this path - files = StorageFile.query.filter(StorageFile.file_path.like(f'{path}%')).all() - - # Extract immediate children (not nested) - children = set() - for file in files: - relative_path = file.file_path[len(path):] - if '/' in relative_path: - # It's in a subdirectory, add the directory name - dir_name = relative_path.split('/')[0] + '/' - children.add(dir_name) - else: - # It's a direct child file - children.add(relative_path) - - return {'files': sorted(list(children))} - else: - # Get file content - file_obj = StorageFile.query.filter_by(file_path=path).first() - if file_obj: - return {'content': file_obj.file_content} - else: - return {'error': 'File not found'}, 404 + # Debug logging + logger.debug(f"Requested path: '{path}'") - def post(self, path): - """Save file content (only for files, not directories)""" - if path.endswith('/'): - return {'error': 'Cannot save content to a directory path'}, 400 - - data = request.get_json() - if not data or 'content' not in data: - return {'error': 'File content is required'}, 400 - - file_obj = StorageFile.query.filter_by(file_path=path).first() - if file_obj: - file_obj.file_content = data['content'] - else: - file_obj = StorageFile(file_path=path, file_content=data['content']) - db.session.add(file_obj) - + # If path is empty, serve index.html + if path == '': try: - db.session.commit() - return {'message': 'File saved successfully'} + return send_from_directory(app.static_folder, 'index.html') except Exception as e: - db.session.rollback() - return {'error': 'Failed to save file'}, 500 + logger.error(f"Error serving index.html: {e}") + return jsonify({ + 'error': 'Frontend not built', + 'message': 'Please build the frontend first with "npm run build"' + }), 404 - def delete(self, path): - """Delete file or directory""" - if path.endswith('/'): - # Delete directory (all files starting with this path) - files = StorageFile.query.filter(StorageFile.file_path.like(f'{path}%')).all() - for file_obj in files: - db.session.delete(file_obj) - else: - # Delete specific file - file_obj = StorageFile.query.filter_by(file_path=path).first() - if not file_obj: - return {'error': 'File not found'}, 404 - db.session.delete(file_obj) - + # Try to serve the requested file + try: + logger.debug(f"Attempting to serve: {app.static_folder}/{path}") + return send_from_directory(app.static_folder, path) + except Exception as e: + logger.error(f"Error serving file: {e}") + # If file not found and not an asset, serve index.html for client-side routing + # But if it's an asset or known file type, return 404 + if path.startswith('assets/') or '.' in path.split('/')[-1]: + return jsonify({'error': f'File not found: {path}'}), 404 try: - db.session.commit() - return {'message': 'Deleted successfully'} - except Exception as e: - db.session.rollback() - return {'error': 'Failed to delete'}, 500 + return send_from_directory(app.static_folder, 'index.html') + except: + return jsonify({'error': 'File not found'}), 404 -class StorageFileRenameResource(Resource): - def post(self): - """Rename file or directory""" - data = request.get_json() - if not data or 'old_path' not in data or 'new_path' not in data: - return {'error': 'Both old_path and new_path are required'}, 400 - - old_path = data['old_path'] - new_path = data['new_path'] - - if old_path.endswith('/'): - # Rename directory (all files starting with old_path) - files = StorageFile.query.filter(StorageFile.file_path.like(f'{old_path}%')).all() - for file_obj in files: - new_file_path = file_obj.file_path.replace(old_path, new_path, 1) - file_obj.file_path = new_file_path - else: - # Rename specific file - file_obj = StorageFile.query.filter_by(file_path=old_path).first() - if not file_obj: - return {'error': 'File not found'}, 404 - file_obj.file_path = new_path - +@app.route('/', defaults={'path': ''}) +@app.route('/') +def serve_static(path: str) -> Union[Response, Tuple[Response, int]]: + """Serve static assets from dist/ directory""" + # If path is empty, serve index.html + if path == '': try: - db.session.commit() - return {'message': 'Renamed successfully'} + return send_from_directory(app.static_folder, 'index.html') except Exception as e: - db.session.rollback() - return {'error': 'Failed to rename'}, 500 - -class StorageRootResource(Resource): - def get(self): - """List all top-level files and directories""" - files = StorageFile.query.all() - # Extract top-level files and directories - children = set() - for file in files: - if '/' in file.file_path: - # It's in a subdirectory, add the directory name - dir_name = file.file_path.split('/')[0] + '/' - children.add(dir_name) - else: - # It's a top-level file - children.add(file.file_path) - - return {'files': sorted(list(children))} - -# Register API routes -# Storage API routes (more specific routes first) -api.add_resource(StorageEntryResource, '/entries/') -api.add_resource(StorageFileRenameResource, '/storage/rename') -api.add_resource(StorageRootResource, '/storage/') -api.add_resource(StorageResource, '/storage/') - -@app.route('/') -def index(): - return jsonify({ - 'message': 'Storage API', - 'endpoints': { - 'entries': '/entries/', - 'storage': '/storage/', - 'storage_rename': '/storage/rename' - } - }) + return jsonify({ + 'error': 'Frontend not built', + 'message': 'Please build the frontend first with "npm run build"', + 'api_info': { + 'endpoints': { + 'entries': '/entries/', + 'storage': '/storage/', + 'storage_rename': '/storage/rename' + } + } + }), 404 + + # Try to serve the requested file + try: + return send_from_directory(app.static_folder, path) + except Exception as e: + # If file not found, serve index.html for client-side routing + try: + return send_from_directory(app.static_folder, 'index.html') + except: + return jsonify({'error': 'File not found'}), 404 if __name__ == '__main__': + parser = argparse.ArgumentParser(description='Run the Storage API backend server') + parser.add_argument('-p', '--port', type=int, default=5001, + help='Port to run the server on (default: 5001)') + parser.add_argument('-l', '--log-level', type=str, default='INFO', + choices=['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'], + help='Set the logging level (default: INFO)') + args = parser.parse_args() + + # Set logging level based on argument + logging.getLogger().setLevel(getattr(logging, args.log_level)) + with app.app_context(): db.create_all() - app.run(debug=True, port=5001) \ No newline at end of file + + logger.info(f"Starting server on port {args.port}...") + app.run(debug=True, port=args.port) \ No newline at end of file diff --git a/backend/storage.py b/backend/storage.py new file mode 100644 index 00000000..c3125172 --- /dev/null +++ b/backend/storage.py @@ -0,0 +1,202 @@ +# Standard library imports +from typing import Dict, List, Tuple, Union + +# Third-party imports +from flask import request +from flask_restful import Resource + +# Our imports +from main import db + +# Storage models for key-value pairs and files +class StorageEntry(db.Model): + id = db.Column(db.Integer, primary_key=True) + entry_key = db.Column(db.String(255), nullable=False, unique=True) + entry_value = db.Column(db.Text, nullable=False) + + def to_dict(self) -> Dict[str, str]: + return { + 'key': self.entry_key, + 'value': self.entry_value + } + +class StorageFile(db.Model): + id = db.Column(db.Integer, primary_key=True) + file_path = db.Column(db.String(500), nullable=False, unique=True) + file_content = db.Column(db.Text, nullable=False) + + def to_dict(self) -> Dict[str, str]: + return { + 'path': self.file_path, + 'content': self.file_content + } + +# Storage Resources for key-value and file operations +class StorageEntryResource(Resource): + def get(self, entry_key: str) -> Dict[str, str]: + """Fetch entry value by key""" + entry = StorageEntry.query.filter_by(entry_key=entry_key).first() + if entry: + return {'value': entry.entry_value} + else: + # Return default value if provided in query params + default_value = request.args.get('default', '') + return {'value': default_value} + + def post(self, entry_key: str) -> Union[Dict[str, str], Tuple[Dict[str, str], int]]: + """Save entry value""" + data = request.get_json() + if not data or 'value' not in data: + return {'error': 'Entry value is required'}, 400 + + entry = StorageEntry.query.filter_by(entry_key=entry_key).first() + if entry: + entry.entry_value = data['value'] + else: + entry = StorageEntry(entry_key=entry_key, entry_value=data['value']) + db.session.add(entry) + + try: + db.session.commit() + return {'message': 'Entry saved successfully'} + except Exception as e: + db.session.rollback() + return {'error': 'Failed to save entry'}, 500 + +class StorageResource(Resource): + def get(self, path: str) -> Union[Dict[str, Union[List[str], str]], Tuple[Dict[str, str], int]]: + """Get file content or list directory based on path""" + # Handle empty path as root directory + if not path: + path = "" + + if path.endswith('/') or path == "": + # List directory + # For root directory, find all files + if path == "": + files = StorageFile.query.all() + # Extract top-level files and directories + children = set() + for file in files: + if '/' in file.file_path: + # It's in a subdirectory, add the directory name + dir_name = file.file_path.split('/')[0] + '/' + children.add(dir_name) + else: + # It's a top-level file + children.add(file.file_path) + else: + # Find all files that start with this path + files = StorageFile.query.filter(StorageFile.file_path.like(f'{path}%')).all() + + # Extract immediate children (not nested) + children = set() + for file in files: + relative_path = file.file_path[len(path):] + if '/' in relative_path: + # It's in a subdirectory, add the directory name + dir_name = relative_path.split('/')[0] + '/' + children.add(dir_name) + else: + # It's a direct child file + children.add(relative_path) + + return {'files': sorted(list(children))} + else: + # Get file content + file_obj = StorageFile.query.filter_by(file_path=path).first() + if file_obj: + return {'content': file_obj.file_content} + else: + return {'error': 'File not found'}, 404 + + def post(self, path: str) -> Union[Dict[str, str], Tuple[Dict[str, str], int]]: + """Save file content (only for files, not directories)""" + if path.endswith('/'): + return {'error': 'Cannot save content to a directory path'}, 400 + + data = request.get_json() + if not data or 'content' not in data: + return {'error': 'File content is required'}, 400 + + file_obj = StorageFile.query.filter_by(file_path=path).first() + if file_obj: + file_obj.file_content = data['content'] + else: + file_obj = StorageFile(file_path=path, file_content=data['content']) + db.session.add(file_obj) + + try: + db.session.commit() + return {'message': 'File saved successfully'} + except Exception as e: + db.session.rollback() + return {'error': 'Failed to save file'}, 500 + + def delete(self, path: str) -> Union[Dict[str, str], Tuple[Dict[str, str], int]]: + """Delete file or directory""" + if path.endswith('/'): + # Delete directory (all files starting with this path) + files = StorageFile.query.filter(StorageFile.file_path.like(f'{path}%')).all() + for file_obj in files: + db.session.delete(file_obj) + else: + # Delete specific file + file_obj = StorageFile.query.filter_by(file_path=path).first() + if not file_obj: + return {'error': 'File not found'}, 404 + db.session.delete(file_obj) + + try: + db.session.commit() + return {'message': 'Deleted successfully'} + except Exception as e: + db.session.rollback() + return {'error': 'Failed to delete'}, 500 + +class StorageFileRenameResource(Resource): + def post(self) -> Union[Dict[str, str], Tuple[Dict[str, str], int]]: + """Rename file or directory""" + data = request.get_json() + if not data or 'old_path' not in data or 'new_path' not in data: + return {'error': 'Both old_path and new_path are required'}, 400 + + old_path = data['old_path'] + new_path = data['new_path'] + + if old_path.endswith('/'): + # Rename directory (all files starting with old_path) + files = StorageFile.query.filter(StorageFile.file_path.like(f'{old_path}%')).all() + for file_obj in files: + new_file_path = file_obj.file_path.replace(old_path, new_path, 1) + file_obj.file_path = new_file_path + else: + # Rename specific file + file_obj = StorageFile.query.filter_by(file_path=old_path).first() + if not file_obj: + return {'error': 'File not found'}, 404 + file_obj.file_path = new_path + + try: + db.session.commit() + return {'message': 'Renamed successfully'} + except Exception as e: + db.session.rollback() + return {'error': 'Failed to rename'}, 500 + +class StorageRootResource(Resource): + def get(self) -> Dict[str, List[str]]: + """List all top-level files and directories""" + files = StorageFile.query.all() + # Extract top-level files and directories + children = set() + for file in files: + if '/' in file.file_path: + # It's in a subdirectory, add the directory name + dir_name = file.file_path.split('/')[0] + '/' + children.add(dir_name) + else: + # It's a top-level file + children.add(file.file_path) + + return {'files': sorted(list(children))} diff --git a/src/reactComponents/Menu.tsx b/src/reactComponents/Menu.tsx index 12d751f7..5651b5ca 100644 --- a/src/reactComponents/Menu.tsx +++ b/src/reactComponents/Menu.tsx @@ -25,6 +25,7 @@ import * as commonStorage from '../storage/common_storage'; import * as storageNames from '../storage/names'; import * as storageProject from '../storage/project'; import * as createPythonFiles from '../storage/create_python_files'; +import * as serverSideStorage from '../storage/server_side_storage'; import * as I18Next from 'react-i18next'; import {TabType } from '../types/TabType'; @@ -294,17 +295,45 @@ export function Component(props: MenuProps): React.JSX.Element { try { const blobUrl = await createPythonFiles.producePythonProjectBlob(props.currentProject, props.storage); - - // Create a temporary link to download the file - const link = document.createElement('a'); - link.href = blobUrl; - link.download = `${props.currentProject.projectName}.zip`; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - - // Clean up the blob URL - URL.revokeObjectURL(blobUrl); + + // Check if the backend server is available + const serverAvailable = await serverSideStorage.isServerAvailable(); + console.log('Server available:', serverAvailable); + + if (serverAvailable) { + // Send the file to the backend /deploy endpoint + const response = await fetch(blobUrl); + const blob = await response.blob(); + + const formData = new FormData(); + formData.append('file', blob, `${props.currentProject.projectName}.zip`); + + const deployResponse = await fetch('/deploy', { + method: 'POST', + body: formData, + }); + + if (!deployResponse.ok) { + throw new Error('Deploy to server failed'); + } + + const result = await deployResponse.json(); + console.log('Deployment successful:', result); + + // Clean up the blob URL + URL.revokeObjectURL(blobUrl); + } else { + // Download the file locally + const link = document.createElement('a'); + link.href = blobUrl; + link.download = `${props.currentProject.projectName}.zip`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + // Clean up the blob URL + URL.revokeObjectURL(blobUrl); + } } catch (error) { console.error('Failed to deploy project:', error); props.setAlertErrorMessage(t('DEPLOY_FAILED') || 'Failed to deploy project'); diff --git a/src/storage/server_side_storage.ts b/src/storage/server_side_storage.ts index 379cc1fb..284baf15 100644 --- a/src/storage/server_side_storage.ts +++ b/src/storage/server_side_storage.ts @@ -22,7 +22,7 @@ import * as commonStorage from './common_storage'; -const API_BASE_URL = 'http://localhost:5001'; +const API_BASE_URL = ''; export async function isServerAvailable(): Promise { try { @@ -31,13 +31,20 @@ export async function isServerAvailable(): Promise { setTimeout(() => reject(new Error('Timeout')), 5000); // 5 second timeout }); - // Race between the fetch and timeout + // Check the specific API status endpoint to distinguish backend from static file server + // Use absolute path without base URL since /statusz is a backend endpoint const response = await Promise.race([ - fetch(`${API_BASE_URL}/`), + fetch('/statusz'), timeoutPromise ]); + + if (!response.ok) { + return false; + } - return response.ok; + // Verify it's actually the Python backend by checking the response + const data = await response.json(); + return data.server === 'python-backend'; } catch (error) { // Network error, server not available, or timeout return false; diff --git a/vite.config.mts b/vite.config.mts index 0b24e0f0..a8ff9336 100644 --- a/vite.config.mts +++ b/vite.config.mts @@ -7,7 +7,7 @@ import react from "@vitejs/plugin-react"; import tsconfigPaths from "vite-tsconfig-paths"; export default defineConfig({ - base: '/systemcore-blocks-interface/', + base: '/blocks/', plugins: [react(), tsconfigPaths(), viteStaticCopy({ targets: [ {