diff --git "a/app.py" "b/app.py" --- "a/app.py" +++ "b/app.py" @@ -1,4 +1,4 @@ -from flask import Flask, render_template_string, request, redirect, url_for, session, flash, send_file, jsonify, send_from_directory +from flask import Flask, render_template_string, request, redirect, url_for, session, flash, send_file, jsonify from flask_caching import Cache import json import os @@ -11,41 +11,60 @@ from werkzeug.utils import secure_filename import requests from io import BytesIO import uuid -import mimetypes app = Flask(__name__) app.secret_key = os.getenv("FLASK_SECRET_KEY", "supersecretkey") DATA_FILE = 'cloudeng_data.json' -REPO_ID = os.getenv("HF_REPO_ID", "Eluza133/Z1e1u") +REPO_ID = "Eluza133/Z1e1u" HF_TOKEN_WRITE = os.getenv("HF_TOKEN") HF_TOKEN_READ = os.getenv("HF_TOKEN_READ") or HF_TOKEN_WRITE cache = Cache(app, config={'CACHE_TYPE': 'simple'}) logging.basicConfig(level=logging.INFO) +@cache.memoize(timeout=300) def load_data(): try: download_db_from_hf() with open(DATA_FILE, 'r', encoding='utf-8') as file: data = json.load(file) if not isinstance(data, dict): - logging.warning("Data is not in dict format, initializing empty database") return {'users': {}} data.setdefault('users', {}) - # Ensure each user has a root folder structure + # Initialize 'items' list for users if migrating from old structure for user_data in data['users'].values(): - if 'root' not in user_data or not isinstance(user_data['root'], dict) or user_data['root'].get('_type') != 'folder': - user_data['root'] = {'_type': 'folder', 'items': {}} - logging.info("Data successfully loaded") + if 'files' in user_data: + if 'items' not in user_data or not user_data['items']: + # Convert old 'files' list to new 'items' format + user_data['items'] = [] + for file_info in user_data['files']: + # Assuming old files were in the root + original_filename = file_info.get('filename', 'unknown_file') + unique_path = file_info.get('path') # Old path might be unique enough or need migration + # If unique_path wasn't stored before, generate one (simple migration assumption) + if not unique_path or not os.path.basename(unique_path).startswith(file_info.get('filename', '')): + unique_id = uuid.uuid4().hex + sanitized_name = secure_filename(original_filename) + unique_filename = f"{unique_id}_{sanitized_name}" + unique_path = f"cloud_files/{file_info.get('username')}/{unique_filename}" # Need username here, but old structure doesn't store it per file + + # This migration is imperfect without knowing the old path structure exactly + # A safer migration would iterate the HF repo structure + # For simplicity here, we assume files were at cloud_files/user/filename and might need new unique ID if filename wasn't sufficient + # Let's stick to the assumption that old 'path' was unique enough for HF but not necessarily for display filename duplicates + user_data['items'].append({ + 'type': 'file', + 'name': original_filename, + 'original_filename': original_filename, + 'unique_path': file_info['path'], # Use existing path from old structure + 'parent_path': os.path.dirname(file_info['path']).rstrip('/') + '/', # Infer parent path (should be cloud_files/username/) + 'upload_date': file_info.get('upload_date', datetime.now().strftime('%Y-%m-%d %H:%M:%S')), + 'preview_type': get_preview_type(original_filename) + }) + del user_data['files'] # Remove old key after migration + return data - except FileNotFoundError: - logging.info(f"{DATA_FILE} not found, creating new database.") - return {'users': {}} - except json.JSONDecodeError: - logging.error(f"Error decoding JSON from {DATA_FILE}. Initializing empty database.") - return {'users': {}} except Exception as e: - logging.error(f"Error loading data: {e}") return {'users': {}} def save_data(data): @@ -54,14 +73,11 @@ def save_data(data): json.dump(data, file, ensure_ascii=False, indent=4) upload_db_to_hf() cache.clear() - logging.info("Data saved and uploaded to HF") except Exception as e: - logging.error(f"Error saving data: {e}") raise def upload_db_to_hf(): if not HF_TOKEN_WRITE: - logging.warning("HF_TOKEN_WRITE is not set. Skipping database upload.") return try: api = HfApi() @@ -73,7 +89,6 @@ def upload_db_to_hf(): token=HF_TOKEN_WRITE, commit_message=f"Backup {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}" ) - logging.info("Database uploaded to Hugging Face") except Exception as e: logging.error(f"Error uploading database: {e}") @@ -87,81 +102,66 @@ def download_db_from_hf(): local_dir=".", local_dir_use_symlinks=False ) - logging.info("Database downloaded from Hugging Face") except Exception as e: logging.error(f"Error downloading database: {e}") if not os.path.exists(DATA_FILE): - logging.warning(f"{DATA_FILE} not found locally after failed download. Creating empty.") with open(DATA_FILE, 'w', encoding='utf-8') as f: json.dump({'users': {}}, f) def periodic_backup(): + if not HF_TOKEN_WRITE: + return while True: - upload_db_to_hf() time.sleep(1800) + upload_db_to_hf() -def get_mime_type(filename): - mime_type, _ = mimetypes.guess_type(filename) - if mime_type: - return mime_type - return 'application/octet-stream' +def get_preview_type(filename): + video_extensions = ('.mp4', '.mov', '.avi', '.webm') + image_extensions = ('.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp') + pdf_extensions = ('.pdf',) + text_extensions = ('.txt', '.log', '.md', '.csv') # Common text types -def get_preview_type(mime_type): - if mime_type.startswith('image/'): - return 'image' - elif mime_type.startswith('video/'): + name, ext = os.path.splitext(filename.lower()) + if ext in video_extensions: return 'video' - elif mime_type == 'application/pdf': + elif ext in image_extensions: + return 'image' + elif ext in pdf_extensions: return 'pdf' - elif mime_type == 'text/plain': - return 'text' + elif ext in text_extensions: + return 'txt' return 'other' -def get_item_at_path(data, username, path_list): - user_data = data['users'].get(username) - if not user_data: - return None - current_folder = user_data.get('root') - if not current_folder or current_folder.get('_type') != 'folder': - user_data['root'] = {'_type': 'folder', 'items': {}} # Attempt to fix broken structure - current_folder = user_data['root'] - - for part in path_list: - if current_folder.get('_type') != 'folder' or part not in current_folder.get('items', {}): - return None # Path segment is not a folder or doesn't exist - current_folder = current_folder['items'][part] - return current_folder - -def add_item_at_path(data, username, path_list, item_name, item_data): - parent_folder = get_item_at_path(data, username, path_list) - if parent_folder and parent_folder.get('_type') == 'folder': - parent_folder['items'][item_name] = item_data - return True - return False - -def delete_item_at_path(data, username, path_list, item_name): - parent_folder = get_item_at_path(data, username, path_list) - if parent_folder and parent_folder.get('_type') == 'folder' and item_name in parent_folder.get('items', {}): - deleted_item = parent_folder['items'].pop(item_name) - return deleted_item +def get_item_by_path(user_data, item_path): + for item in user_data.get('items', []): + # Files use 'unique_path', Folders use 'path' for lookup + if item.get('type') == 'file' and item.get('unique_path') == item_path: + return item + if item.get('type') == 'folder' and item.get('path') == item_path: + return item return None -def list_items_at_path(data, username, path_list): - folder = get_item_at_path(data, username, path_list) - if folder and folder.get('_type') == 'folder': - # Sort folders first, then files, alphabetically by name - items = sorted(folder.get('items', {}).items(), key=lambda item: (item[1].get('_type') != 'folder', item[0])) - return items - return [] - -def get_hf_storage_path(username, path_list, unique_name): - # Construct the full path on HF Hub including user folder and cloud path - user_base_path = f"cloud_files/{username}" - cloud_path = '/'.join(path_list) - if cloud_path: - return f"{user_base_path}/{cloud_path}/{unique_name}" - else: - return f"{user_base_path}/{unique_name}" +def get_items_in_folder(user_data, folder_path): + # Ensure folder_path ends with a slash for accurate parent matching + if not folder_path.endswith('/'): + folder_path += '/' + + items = [item for item in user_data.get('items', []) if item.get('parent_path') == folder_path] + # Sort: folders first, then files, then alphabetically by name + items.sort(key=lambda x: (x.get('type') != 'folder', x.get('name', '').lower())) + return items + +def get_all_descendant_files(user_data, folder_path): + files_to_delete = [] + folder_path = folder_path.rstrip('/') + '/' # Ensure trailing slash + + for item in user_data.get('items', []): + # Check if item is a file and its parent path starts with the folder path + if item.get('type') == 'file' and item.get('parent_path', '').startswith(folder_path): + files_to_delete.append(item) + # Also need to handle files directly inside the folder + # This logic is covered by startswith if the folder path is correct + return files_to_delete BASE_STYLE = ''' :root { @@ -178,7 +178,7 @@ BASE_STYLE = ''' --glass-bg: rgba(255, 255, 255, 0.15); --transition: all 0.3s ease; --delete-color: #ff4444; - --folder-color: #facc15; /* Yellow */ + --folder-color: #ffc107; } * { margin: 0; padding: 0; box-sizing: border-box; } body { @@ -186,14 +186,13 @@ body { background: var(--background-light); color: var(--text-light); line-height: 1.6; - padding: 20px; } body.dark { background: var(--background-dark); color: var(--text-dark); } .container { - margin: 0 auto; + margin: 20px auto; max-width: 1200px; padding: 25px; background: var(--card-bg); @@ -251,7 +250,6 @@ input:focus, textarea:focus { box-shadow: var(--shadow); display: inline-block; text-decoration: none; - text-align: center; } .btn:hover { transform: scale(1.05); @@ -272,19 +270,13 @@ input:focus, textarea:focus { background: #cc3333; } .flash { - padding: 10px; - margin-bottom: 15px; - border-radius: 8px; + color: var(--secondary); text-align: center; - background-color: var(--secondary); /* Using secondary for flash messages */ - color: var(--text-light); /* Text color matching background */ -} -body.dark .flash { - color: var(--text-dark); + margin-bottom: 15px; } -.file-grid { +.item-grid { display: grid; - grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); /* Smaller grid for folders/files */ gap: 20px; margin-top: 20px; } @@ -313,17 +305,7 @@ body.dark .user-item { .user-item a:hover { color: var(--accent); } -@media (max-width: 768px) { - .file-grid { - grid-template-columns: repeat(2, 1fr); - } -} -@media (max-width: 480px) { - .file-grid { - grid-template-columns: 1fr; - } -} -.file-item, .folder-item { +.item-item { background: var(--card-bg); padding: 15px; border-radius: 16px; @@ -332,77 +314,54 @@ body.dark .user-item { transition: var(--transition); display: flex; flex-direction: column; + align-items: center; justify-content: space-between; + min-height: 150px; /* Ensure consistent size */ } -body.dark .file-item, body.dark .folder-item { +body.dark .item-item { background: var(--card-bg-dark); } -.file-item:hover, .folder-item:hover { +.item-item:hover { transform: translateY(-5px); } -.folder-item { - cursor: pointer; - background-color: var(--glass-bg); - border: 2px dashed var(--folder-color); -} -body.dark .folder-item { - background-color: rgba(40, 35, 60, 0.5); -} -.folder-item a { - display: block; - text-decoration: none; - color: var(--text-light); - font-weight: 600; - flex-grow: 1; - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; +.item-icon { + width: 60px; + height: 60px; + margin-bottom: 10px; } -body.dark .folder-item a { - color: var(--text-dark); +.item-icon.folder { + fill: var(--folder-color); /* SVG color */ } -.folder-icon::before { - content: "📁"; /* Folder emoji */ - font-size: 3em; - margin-bottom: 10px; - color: var(--folder-color); +.item-icon.file { + fill: var(--secondary); /* SVG color */ } -.file-preview { +.item-preview { max-width: 100%; - max-height: 200px; - object-fit: cover; + max-height: 120px; /* Smaller preview */ + object-fit: contain; border-radius: 10px; margin-bottom: 10px; loading: lazy; - cursor: pointer; + cursor: pointer; /* Indicate clickable */ } -.file-item p { - font-size: 0.9em; +.item-name { + font-size: 1em; margin: 5px 0; - word-break: break-all; /* Prevent long names breaking layout */ -} -.file-item .file-name { - font-weight: 600; - margin-bottom: 10px; -} -.file-item a { - color: var(--primary); - text-decoration: none; -} -.file-item a:hover { - color: var(--accent); + word-break: break-all; + flex-grow: 1; /* Allow name to take space */ } -.file-actions { +.item-item .item-actions { + margin-top: auto; /* Push actions to bottom */ display: flex; - justify-content: center; - gap: 10px; - flex-wrap: wrap; + flex-direction: column; /* Stack buttons */ + width: 100%; + align-items: center; } -.file-actions .btn { - padding: 8px 15px; - font-size: 0.9em; - margin-top: 0; +.item-item .item-actions .btn { + width: 80%; /* Make buttons narrower */ + margin-top: 5px; /* Space between buttons */ + padding: 8px 15px; /* Smaller padding */ + font-size: 0.9em; /* Smaller font */ } .modal { display: none; @@ -411,23 +370,40 @@ body.dark .folder-item a { left: 0; width: 100%; height: 100%; - background: rgba(0, 0, 0, 0.9); + background: rgba(0, 0, 0, 0.85); z-index: 2000; justify-content: center; align-items: center; + padding: 20px; } -.modal img, .modal video, .modal iframe { +.modal-content { max-width: 95%; - max-height: 95vh; - object-fit: contain; - border-radius: 20px; + max-height: 95%; + background: white; /* Background for non-media */ + padding: 20px; + border-radius: 10px; box-shadow: var(--shadow); - background: white; /* Ensure iframe/text background is visible */ - border: none; /* Remove default iframe border */ + overflow: auto; /* Scroll for text/pdf */ +} +.modal img, .modal video, .modal iframe { + display: block; /* Remove extra space below inline elements */ + margin: auto; /* Center media */ + max-width: 100%; + max-height: 80vh; /* Limit height to avoid overflow */ + object-fit: contain; + border-radius: 10px; +} +.modal pre { + white-space: pre-wrap; /* Wrap text */ + word-wrap: break-word; + color: var(--text-light); /* Text color */ } -.modal iframe { - width: 95%; /* Give iframes a width */ - height: 95vh; /* And height */ +body.dark .modal-content { + background: var(--card-bg-dark); + color: var(--text-dark); +} +body.dark .modal pre { + color: var(--text-dark); } #progress-container { width: 100%; @@ -442,42 +418,31 @@ body.dark .folder-item a { background: var(--primary); border-radius: 10px; transition: width 0.3s ease; - text-align: center; - line-height: 20px; - color: white; - font-size: 0.8em; } -.current-path { +.path-breadcrumb { margin-bottom: 20px; - font-size: 1.2em; - font-weight: 600; + font-size: 1.1em; } -.path-segment { - color: var(--primary); +.path-breadcrumb a { + color: var(--accent); text-decoration: none; } -.path-segment:hover { +.path-breadcrumb a:hover { text-decoration: underline; } -.path-segment:not(:last-child)::after { - content: " / "; - color: var(--text-light); -} -body.dark .path-segment:not(:last-child)::after { - color: var(--text-dark); +.path-breadcrumb span { + margin: 0 5px; } -.folder-form { - margin-top: 20px; +.action-bar { display: flex; gap: 10px; + margin-bottom: 20px; + flex-wrap: wrap; } -.folder-form input[type="text"] { - flex-grow: 1; - margin: 0; -} -.folder-form button { - flex-shrink: 0; - margin: 0; +.action-bar .btn { + padding: 10px 20px; + font-size: 1em; + margin-top: 0; } ''' @@ -489,26 +454,21 @@ def register(): data = load_data() - if username in data['users']: - flash('Пользователь с таким именем уже существует!') - return redirect(url_for('register')) - if not username or not password: flash('Имя пользователя и пароль обязательны!') return redirect(url_for('register')) - - if '/' in username or '.' in username: - flash('Имя пользователя не может содержать символы "/" или "."') - return redirect(url_for('register')) + + if username in data['users']: + flash('Пользователь с таким именем уже существует!') + return redirect(url_for('register')) data['users'][username] = { 'password': password, 'created_at': datetime.now().strftime('%Y-%m-%d %H:%M:%S'), - 'root': {'_type': 'folder', 'items': {}} # Initialize root folder + 'items': [] # Use 'items' for files and folders } save_data(data) session['username'] = username - session['current_path'] = [] # Initialize current path in session flash('Регистрация прошла успешно!') return redirect(url_for('dashboard')) @@ -553,7 +513,6 @@ def login(): if username in data['users'] and data['users'][username]['password'] == password: session['username'] = username - session['current_path'] = [] # Reset path on login return jsonify({'status': 'success', 'redirect': url_for('dashboard')}) else: return jsonify({'status': 'error', 'message': 'Неверное имя пользователя или пароль!'}) @@ -602,6 +561,7 @@ def login(): if (data.status === 'success') { window.location.href = data.redirect; } + // If auto-login fails, allow manual login form to be used }) .catch(error => console.error('Ошибка автовхода:', error)); } @@ -618,7 +578,7 @@ def login(): if (data.status === 'success') { const username = formData.get('username'); const password = formData.get('password'); - // localStorage.setItem('zeusCredentials', JSON.stringify({ username, password })); // Uncomment to enable persistent login + localStorage.setItem('zeusCredentials', JSON.stringify({ username, password })); window.location.href = data.redirect; } else { document.getElementById('flash-messages').innerHTML = `
Пользователь: {{ username }}
- -Ваши файлы под надежной защитой квантовой криптографии
-{{ name }}
+{{ item.name }}
+{{ item.original_name }}
- {% if item.mime_type and item.mime_type.startswith('image/') %} -📄
- - {% elif item.mime_type == 'text/plain' %} -📃
- + {% elif item.type == 'file' %} +{{ item.name }}
+{{ item.name }}
+📦
+ +{{ item.name }}
+Загружен: {{ item.upload_date }}
- +{{ item.upload_date }}
Эта папка пуста.
{% endif %}