diff --git "a/app.py" "b/app.py" --- "a/app.py" +++ "b/app.py" @@ -1,318 +1,487 @@ -# -*- coding: utf-8 -*- -from flask import Flask, render_template_string, request, redirect, url_for, session, flash, send_file, jsonify, Response -from flask_caching import Cache import json -import os import logging +import mimetypes +import os import threading import time import uuid from datetime import datetime -from huggingface_hub import HfApi, hf_hub_download, hf_hub_url -from huggingface_hub.utils import RepositoryNotFoundError, EntryNotFoundError -from werkzeug.utils import secure_filename -import requests from io import BytesIO -from pathlib import Path + +import requests +from flask import (Flask, flash, jsonify, redirect, render_template_string, + request, send_file, session, url_for) +from flask_caching import Cache +from huggingface_hub import HfApi, hf_hub_download +from werkzeug.utils import secure_filename app = Flask(__name__) -app.secret_key = os.getenv("FLASK_SECRET_KEY", "a-very-secret-key-for-zeus") +app.secret_key = os.getenv("FLASK_SECRET_KEY", "a-very-secret-and-complex-key") DATA_FILE = 'cloudeng_data_v2.json' -REPO_ID = "Eluza133/Z1e1u" # Replace with your actual repo ID if different +REPO_ID = "Eluza133/Z1e1u" HF_TOKEN_WRITE = os.getenv("HF_TOKEN") HF_TOKEN_READ = os.getenv("HF_TOKEN_READ") or HF_TOKEN_WRITE +UPLOAD_FOLDER = 'temp_uploads' +os.makedirs(UPLOAD_FOLDER, exist_ok=True) -# Basic Caching -cache = Cache(app, config={'CACHE_TYPE': 'simple', 'CACHE_DEFAULT_TIMEOUT': 60}) # Shorter timeout for faster updates -logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') - -# --- Data Handling --- +cache = Cache(app, config={'CACHE_TYPE': 'simple'}) +logging.basicConfig(level=logging.INFO) -def initialize_data_structure(): - return {'users': {}} - -@cache.memoize() +@cache.memoize(timeout=300) def load_data(): try: - logging.info(f"Attempting to download {DATA_FILE} from HF.") download_db_from_hf() with open(DATA_FILE, 'r', encoding='utf-8') as file: data = json.load(file) - if not isinstance(data, dict) or 'users' not in data: - logging.warning("Data file is invalid or missing 'users' key. Initializing.") - return initialize_data_structure() - # Ensure all users have the new folder structure if migrating + if not isinstance(data, dict): + logging.warning("Data is not in dict format, initializing empty database") + return {'users': {}, 'files': {}} + data.setdefault('users', {}) + data.setdefault('files', {}) for user_data in data['users'].values(): - if 'files' in user_data and 'root_folder' not in user_data: - # Simple migration: put old files in root - logging.info(f"Migrating files for user {user_data.get('username', 'UNKNOWN')}") # Assuming username might be stored - user_data['root_folder'] = create_folder_node('/', f"cloud_files/{user_data.get('username', 'default')}/") - user_data['root_folder']['children'] = user_data.pop('files', []) - # Ensure migrated files have necessary fields - for item in user_data['root_folder']['children']: - item['type'] = 'file' - item['file_type'] = get_file_type(item.get('filename', '')) - if 'path' in item and 'storage_filename' not in item: - item['storage_filename'] = os.path.basename(item['path']) - if 'filename' in item and 'original_filename' not in item: - item['original_filename'] = item.pop('filename') - - - logging.info("Data successfully loaded and validated.") + user_data.setdefault('files', []) + user_data.setdefault('folders', []) + logging.info("Data successfully loaded") return data except FileNotFoundError: - logging.warning(f"{DATA_FILE} not found locally after download attempt. Initializing empty data.") - return initialize_data_structure() + logging.warning(f"{DATA_FILE} not found. Initializing empty database.") + return {'users': {}, 'files': {}} except json.JSONDecodeError: - logging.error(f"Error decoding JSON from {DATA_FILE}. Initializing empty data.") - return initialize_data_structure() + logging.error(f"Error decoding JSON from {DATA_FILE}. Initializing empty database.") + return {'users': {}, 'files': {}} except Exception as e: - logging.error(f"Unexpected error loading data: {e}") - return initialize_data_structure() + logging.error(f"Error loading data: {e}") + return {'users': {}, 'files': {}} def save_data(data): try: - # Create backup before overwriting - if os.path.exists(DATA_FILE): - os.rename(DATA_FILE, DATA_FILE + '.bak') + # Ensure default structures exist before saving + data.setdefault('users', {}) + data.setdefault('files', {}) # Keep top-level files if needed, or remove if unused + for user_data in data['users'].values(): + user_data.setdefault('files', []) + user_data.setdefault('folders', []) with open(DATA_FILE, 'w', encoding='utf-8') as file: - json.dump(data, file, ensure_ascii=False, indent=2) # Indent 2 for smaller file size - + json.dump(data, file, ensure_ascii=False, indent=4) upload_db_to_hf() cache.clear() - logging.info("Data saved locally and upload initiated.") - # Remove backup after successful save and upload initiation - if os.path.exists(DATA_FILE + '.bak'): - os.remove(DATA_FILE + '.bak') + logging.info("Data saved and uploaded to HF") except Exception as e: logging.error(f"Error saving data: {e}") - # Restore backup if save failed - if os.path.exists(DATA_FILE + '.bak'): - os.rename(DATA_FILE + '.bak', DATA_FILE) - raise # Re-raise the exception - -# --- Hugging Face Interaction --- - -def get_hf_api(write=False): - token = HF_TOKEN_WRITE if write else HF_TOKEN_READ - if not token: - level = logging.ERROR if write else logging.WARNING - msg = f"Hugging Face {'write' if write else 'read'} token is not configured." - logging.log(level, msg) - if write: # Write operations are critical - raise ValueError(msg + " Cannot proceed with write operations.") - return HfApi(token=token) + raise def upload_db_to_hf(): if not HF_TOKEN_WRITE: - logging.warning("Cannot upload DB: Write token not set.") + logging.warning("HF Write Token not set. Skipping database upload.") return try: - api = get_hf_api(write=True) + api = HfApi() api.upload_file( path_or_fileobj=DATA_FILE, path_in_repo=DATA_FILE, repo_id=REPO_ID, repo_type="dataset", + token=HF_TOKEN_WRITE, commit_message=f"Backup {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}" ) - logging.info("Database uploaded to Hugging Face.") + logging.info("Database uploaded to Hugging Face") except Exception as e: - logging.error(f"Error uploading database to HF: {e}") - # Consider adding retry logic or alerting + logging.error(f"Error uploading database: {e}") def download_db_from_hf(): if not HF_TOKEN_READ: - logging.warning("Cannot download DB: Read token not set. Attempting anonymous download.") + logging.warning("HF Read Token not set. Skipping database download attempt.") + # If file doesn't exist locally, create an empty one + if not os.path.exists(DATA_FILE): + with open(DATA_FILE, 'w', encoding='utf-8') as f: + json.dump({'users': {}, 'files': {}}, f) + return try: hf_hub_download( repo_id=REPO_ID, filename=DATA_FILE, repo_type="dataset", - token=HF_TOKEN_READ, # Will be None if not set, attempting anonymous + token=HF_TOKEN_READ, local_dir=".", local_dir_use_symlinks=False, - force_download=True, # Ensure we get the latest version - etag_timeout=10 # Short timeout for checking version + force_download=True, # Force download to get latest version + resume_download=False # Start fresh ) - logging.info("Database downloaded from Hugging Face.") - except RepositoryNotFoundError: - logging.error(f"HF Repository '{REPO_ID}' not found.") - if not os.path.exists(DATA_FILE): - save_data(initialize_data_structure()) # Create initial empty file - except EntryNotFoundError: - logging.warning(f"'{DATA_FILE}' not found in HF repo '{REPO_ID}'. Creating local default.") - if not os.path.exists(DATA_FILE): - save_data(initialize_data_structure()) # Create initial empty file + logging.info("Database downloaded from Hugging Face") except Exception as e: - logging.error(f"Error downloading database from HF: {e}") + logging.error(f"Error downloading database: {e}") if not os.path.exists(DATA_FILE): - logging.info("Creating empty local data file as fallback.") - save_data(initialize_data_structure()) # Create initial empty file if download fails - -def delete_hf_object(path_in_repo, is_folder=False): - if not HF_TOKEN_WRITE: - raise ValueError("Cannot delete object: Write token not set.") - api = get_hf_api(write=True) - try: - if is_folder: - api.delete_folder( - folder_path=path_in_repo, - repo_id=REPO_ID, - repo_type="dataset", - commit_message=f"Deleted folder: {path_in_repo}" - ) - logging.info(f"Deleted folder from HF: {path_in_repo}") - else: - api.delete_file( - path_in_repo=path_in_repo, - repo_id=REPO_ID, - repo_type="dataset", - commit_message=f"Deleted file: {path_in_repo}" - ) - logging.info(f"Deleted file from HF: {path_in_repo}") - except EntryNotFoundError: - logging.warning(f"Object not found on HF, presumed already deleted: {path_in_repo}") - except Exception as e: - logging.error(f"Error deleting object from HF '{path_in_repo}': {e}") - raise # Propagate error to handle in calling function - -# --- File & Folder Helpers --- + logging.warning(f"Creating empty {DATA_FILE} as download failed and file doesn't exist.") + with open(DATA_FILE, 'w', encoding='utf-8') as f: + json.dump({'users': {}, 'files': {}}, f) + +def periodic_backup(): + while True: + time.sleep(1800) # Backup every 30 minutes + logging.info("Starting periodic backup...") + upload_db_to_hf() def get_file_type(filename): ext = os.path.splitext(filename)[1].lower() - if ext in ('.mp4', '.mov', '.avi', '.webm', '.mkv'): return 'video' - if ext in ('.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.svg'): return 'image' - if ext == '.pdf': return 'pdf' - if ext in ('.txt', '.md', '.log', '.py', '.js', '.css', '.html', '.json', '.xml'): return 'text' - # Add more types as needed + if ext in ('.mp4', '.mov', '.avi', '.webm', '.mkv', '.flv'): + return 'video' + elif ext in ('.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.svg'): + return 'image' + elif ext == '.pdf': + return 'pdf' + elif ext == '.txt': + return 'text' return 'other' def generate_unique_filename(original_filename): - stem, ext = os.path.splitext(original_filename) - # Use a shorter UUID prefix for readability, ensure filename safety - unique_id = uuid.uuid4().hex[:8] - # Clean the stem slightly - remove problematic chars, keep it recognizable - safe_stem = "".join(c if c.isalnum() or c in ('-', '_') else '_' for c in stem) - safe_stem = safe_stem[:50] # Limit stem length - storage_filename = secure_filename(f"{safe_stem}_{unique_id}{ext}") - return storage_filename - -def create_folder_node(name, path): - return { - "type": "folder", - "name": name, - "path": path, # HF path prefix for this folder - "children": [] - } - -def create_file_node(original_filename, storage_filename, hf_path): - return { - "type": "file", - "original_filename": original_filename, - "storage_filename": storage_filename, # The unique name used in storage - "path": hf_path, # Full path on HF - "file_type": get_file_type(original_filename), - "upload_date": datetime.now().strftime('%Y-%m-%d %H:%M:%S') - } - -def find_node_by_path(root_node, target_path_segments): - current_node = root_node - if not target_path_segments or target_path_segments == ['']: # Root path - return current_node - - for segment in target_path_segments: - found = False - if current_node.get("type") == "folder": - for child in current_node.get("children", []): - if child.get("type") == "folder" and child.get("name") == segment: - current_node = child - found = True - break - if not found: - return None # Path not found - return current_node - -def add_node_to_path(root_node, target_path_segments, node_to_add): - parent_node = find_node_by_path(root_node, target_path_segments) - if parent_node and parent_node.get("type") == "folder": - # Check for name collision (case-insensitive check for folders) - existing_names = { - child.get("name").lower() if child.get("type") == "folder" else child.get("name"): True - for child in parent_node.get("children", []) - } - add_name = node_to_add.get("name") # For folders - if node_to_add.get("type") == "file": - add_name = node_to_add.get("original_filename") # Check original name for files - - check_name = add_name.lower() if node_to_add.get("type") == "folder" else add_name - - if check_name in existing_names: - logging.warning(f"Cannot add node. Item '{add_name}' already exists in target path.") - return False # Name collision - - parent_node.setdefault("children", []).append(node_to_add) - parent_node["children"].sort(key=lambda x: (x.get('type') != 'folder', x.get('name', '').lower() if x.get('type') == 'folder' else x.get('original_filename', '').lower())) # Sort folders first, then alphabetically - return True - logging.error(f"Target path not found or is not a folder for adding node.") - return False - -def remove_node_by_path(root_node, target_path_segments, name_to_remove): - parent_segments = target_path_segments[:-1] if len(target_path_segments) > 0 else [] - target_name = target_path_segments[-1] if len(target_path_segments) > 0 else name_to_remove # If removing from root - - parent_node = find_node_by_path(root_node, parent_segments) - - if parent_node and parent_node.get("type") == "folder": - children = parent_node.get("children", []) - initial_length = len(children) - # Find the item to remove (match by name/original_filename) - new_children = [] - removed_node = None - for child in children: - is_folder = child.get("type") == "folder" - current_name = child.get("name") if is_folder else child.get("original_filename") - if current_name == name_to_remove: - removed_node = child # Found it - else: - new_children.append(child) + _, ext = os.path.splitext(original_filename) + safe_base = secure_filename(os.path.splitext(original_filename)[0]) + if not safe_base: # Handle cases like ".bashrc" + safe_base = "file" + unique_id = uuid.uuid4().hex[:8] # Shorter UUID part + return f"{safe_base}_{unique_id}{ext}" + +def get_folder_icon(): + return ''' + + + ''' + +def get_file_icon(file_type): + # Basic file icon, could be expanded with specific icons per type + return ''' + + + ''' - if len(new_children) < initial_length: - parent_node["children"] = new_children - return removed_node # Return the node that was removed - else: - logging.warning(f"Node '{name_to_remove}' not found in path {'/'.join(parent_segments)}.") - return None # Not found - else: - logging.error(f"Parent path not found or is not a folder for removing node.") - return None - -def get_hf_download_url(file_node): - # Ensure the path is correctly formatted for the URL resolution - hf_path = file_node.get('path', '').lstrip('/') - if not hf_path: - return None - try: - # Construct the URL manually or use hf_hub_url if reliable - # Manual construction is often safer for direct download links - base_url = f"https://huggingface.co/datasets/{REPO_ID}/resolve/main/" - # Ensure path is correctly URL encoded if necessary, but usually HF handles standard chars - url = base_url + hf_path + "?download=true" - - # Add token for private repos if necessary - append as query param is one way - # Note: Exposing token in URL is less secure, better if HF supports Bearer token headers via JS Fetch - # For direct links in HTML (, ), URL param might be needed if repo is private - # if HF_TOKEN_READ: - # url += f"&token={HF_TOKEN_READ}" # Use cautiously - - return url - except Exception as e: - logging.error(f"Error generating HF download URL for {hf_path}: {e}") - return None - - -# --- Flask Routes --- +BASE_STYLE = ''' +:root { + --primary: #ff4d6d; /* Hot Pink */ + --secondary: #00ddeb; /* Cyan */ + --accent: #8b5cf6; /* Violet */ + --success: #10b981; /* Emerald */ + --warning: #f59e0b; /* Amber */ + --danger: #ef4444; /* Red */ + --info: #3b82f6; /* Blue */ + --background-light: #f8fafc; /* Slate 50 */ + --background-dark: #0f172a; /* Slate 900 */ + --card-bg-light: #ffffff; + --card-bg-dark: #1e293b; /* Slate 800 */ + --text-light: #334155; /* Slate 700 */ + --text-dark: #e2e8f0; /* Slate 200 */ + --border-light: #e2e8f0; /* Slate 200 */ + --border-dark: #334155; /* Slate 700 */ + --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05); + --shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); + --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1); + --radius-sm: 0.25rem; + --radius: 0.5rem; + --radius-lg: 0.75rem; + --transition: all 0.2s ease-in-out; +} +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap'); +body { + font-family: 'Inter', sans-serif; + background-color: var(--background-light); + color: var(--text-light); + line-height: 1.6; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} +body.dark { + background-color: var(--background-dark); + color: var(--text-dark); +} +.container { + max-width: 1300px; + margin: 2rem auto; + padding: 1.5rem 2rem; +} +.card { + background-color: var(--card-bg-light); + border-radius: var(--radius-lg); + box-shadow: var(--shadow); + padding: 2rem; + margin-bottom: 1.5rem; +} +body.dark .card { background-color: var(--card-bg-dark); } +h1 { + font-size: 2.25rem; /* 36px */ + font-weight: 800; + text-align: center; + margin-bottom: 2rem; + background: linear-gradient(135deg, var(--primary), var(--accent)); + -webkit-background-clip: text; + color: transparent; +} +h2 { + font-size: 1.5rem; /* 24px */ + font-weight: 700; + margin-top: 2rem; + margin-bottom: 1rem; + border-bottom: 1px solid var(--border-light); + padding-bottom: 0.5rem; +} +body.dark h2 { color: var(--text-dark); border-bottom-color: var(--border-dark); } +input[type="text"], input[type="password"], input[type="file"], textarea { + width: 100%; + padding: 0.75rem 1rem; + margin: 0.5rem 0 1rem 0; + border: 1px solid var(--border-light); + border-radius: var(--radius); + background-color: var(--background-light); + color: var(--text-light); + font-size: 1rem; + transition: var(--transition); +} +body.dark input[type="text"], body.dark input[type="password"], body.dark input[type="file"], body.dark textarea { + background-color: var(--background-dark); + color: var(--text-dark); + border-color: var(--border-dark); +} +input:focus, textarea:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 3px rgba(255, 77, 109, 0.3); /* primary with opacity */ +} +input[type="file"] { padding: 0.5rem; } +input::file-selector-button { + padding: 0.5rem 1rem; + border: none; + background-color: var(--accent); + color: white; + border-radius: var(--radius-sm); + cursor: pointer; + margin-right: 1rem; + transition: var(--transition); +} +input::file-selector-button:hover { background-color: #7c3aed; } /* Darker accent */ +.btn { + padding: 0.75rem 1.5rem; + border: none; + border-radius: var(--radius); + cursor: pointer; + font-size: 1rem; + font-weight: 600; + transition: var(--transition); + text-decoration: none; + display: inline-block; + text-align: center; + box-shadow: var(--shadow-sm); +} +.btn-primary { background-color: var(--primary); color: white; } +.btn-primary:hover { background-color: #e6415f; transform: translateY(-2px); box-shadow: var(--shadow); } +.btn-secondary { background-color: var(--secondary); color: var(--text-light); } +.btn-secondary:hover { background-color: #00b8c5; transform: translateY(-2px); box-shadow: var(--shadow); } +.btn-accent { background-color: var(--accent); color: white; } +.btn-accent:hover { background-color: #7c3aed; transform: translateY(-2px); box-shadow: var(--shadow); } +.btn-danger { background-color: var(--danger); color: white; } +.btn-danger:hover { background-color: #dc2626; transform: translateY(-2px); box-shadow: var(--shadow); } +.btn-sm { padding: 0.5rem 1rem; font-size: 0.875rem; } +.flash { + padding: 1rem; + margin-bottom: 1rem; + border-radius: var(--radius); + text-align: center; + font-weight: 500; +} +.flash-success { background-color: #d1fae5; color: #065f46; } /* Green */ +.flash-error { background-color: #fee2e2; color: #991b1b; } /* Red */ +.flash-info { background-color: #dbeafe; color: #1e40af; } /* Blue */ +body.dark .flash-success { background-color: #059669; color: #d1fae5; } +body.dark .flash-error { background-color: #b91c1c; color: #fee2e2; } +body.dark .flash-info { background-color: #2563eb; color: #dbeafe; } + +.breadcrumb { + display: flex; + align-items: center; + margin-bottom: 1.5rem; + font-size: 0.9rem; + color: #64748b; /* Slate 500 */ + background-color: var(--card-bg-light); + padding: 0.75rem 1.5rem; + border-radius: var(--radius); + box-shadow: var(--shadow-sm); +} +body.dark .breadcrumb { background-color: var(--card-bg-dark); color: #94a3b8; } /* Slate 400 */ +.breadcrumb a { color: var(--primary); text-decoration: none; font-weight: 500; } +.breadcrumb a:hover { text-decoration: underline; } +.breadcrumb span { margin: 0 0.5rem; } + +.file-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: 1.5rem; + margin-top: 1.5rem; +} +.grid-item { + background-color: var(--card-bg-light); + border-radius: var(--radius-lg); + padding: 1rem; + box-shadow: var(--shadow); + text-align: center; + transition: var(--transition); + position: relative; + overflow: hidden; + cursor: pointer; +} +body.dark .grid-item { background-color: var(--card-bg-dark); } +.grid-item:hover { transform: translateY(-5px); box-shadow: var(--shadow-lg); } +.grid-item-icon { margin-bottom: 0.5rem; } +.grid-item-icon img, .grid-item-icon video { + width: 100%; + height: 120px; + object-fit: cover; + border-radius: var(--radius-sm); + background-color: #e2e8f0; /* Placeholder bg */ +} +.grid-item-icon .file-placeholder-icon svg { + width: 64px; height: 64px; margin: 28px auto; /* Center vertically */ + color: #94a3b8; /* Slate 400 */ +} +body.dark .grid-item-icon .file-placeholder-icon svg { color: #64748b; } /* Slate 500 */ +.grid-item-name { + font-weight: 500; + font-size: 0.9rem; + margin-bottom: 0.5rem; + word-break: break-all; /* Prevent long names overflowing */ + line-height: 1.3; + height: 2.6em; /* Limit to 2 lines */ + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} +.grid-item-actions { + margin-top: 0.75rem; + display: flex; + justify-content: center; + gap: 0.5rem; +} +.grid-item-actions .btn { padding: 0.3rem 0.6rem; font-size: 0.75rem; } + +.modal { + display: none; + position: fixed; + z-index: 1000; + left: 0; top: 0; + width: 100%; height: 100%; + overflow: auto; + background-color: rgba(0,0,0,0.85); + align-items: center; + justify-content: center; + padding: 20px; +} +.modal-content { + position: relative; + margin: auto; + padding: 0; + background-color: var(--card-bg-dark); /* Dark background for better contrast */ + border-radius: var(--radius-lg); + max-width: 90vw; + max-height: 90vh; + display: flex; + justify-content: center; + align-items: center; + overflow: hidden; +} +.modal img, .modal video, .modal iframe, .modal pre { + max-width: 100%; + max-height: calc(90vh - 40px); /* Account for padding */ + display: block; + object-fit: contain; + border-radius: var(--radius-sm); +} +.modal iframe { width: 80vw; height: 85vh; background-color: #fff; } +.modal pre { + background-color: #f1f5f9; /* Light bg for text */ + color: #1e293b; + padding: 1.5rem; + border-radius: var(--radius-sm); + white-space: pre-wrap; + word-wrap: break-word; + overflow: auto; + font-family: 'Courier New', Courier, monospace; + font-size: 0.9rem; + max-height: calc(90vh - 60px); +} +.modal-close { + position: absolute; + top: 15px; + right: 25px; + color: #f1f1f1; + font-size: 40px; + font-weight: bold; + transition: 0.3s; + cursor: pointer; + z-index: 1010; +} +.modal-close:hover, .modal-close:focus { color: #bbb; text-decoration: none; } + +#progress-container { + width: 100%; + background-color: var(--border-light); + border-radius: var(--radius); + margin: 1rem 0; + overflow: hidden; + display: none; +} +body.dark #progress-container { background-color: var(--border-dark); } +#progress-bar { + width: 0%; + height: 15px; + background-color: var(--primary); + border-radius: var(--radius); + transition: width 0.4s ease; + text-align: center; + line-height: 15px; + color: white; + font-size: 0.75rem; +} + +.user-list { margin-top: 1.5rem; } +.user-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem 1.5rem; + background-color: var(--card-bg-light); + border-radius: var(--radius); + margin-bottom: 1rem; + box-shadow: var(--shadow-sm); + transition: var(--transition); +} +body.dark .user-item { background-color: var(--card-bg-dark); } +.user-item:hover { box-shadow: var(--shadow); } +.user-item a { color: var(--primary); text-decoration: none; font-weight: 600; font-size: 1.1rem;} +.user-item a:hover { color: var(--accent); } +.user-item p { font-size: 0.9rem; color: #64748b; } +body.dark .user-item p { color: #94a3b8; } +.user-item .actions { display: flex; gap: 0.5rem; } + +@media (max-width: 768px) { + .file-grid { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 1rem; } + .grid-item { padding: 0.75rem; } + .grid-item-icon img, .grid-item-icon video { height: 100px; } + .grid-item-icon .file-placeholder-icon svg { width: 48px; height: 48px; margin: 26px auto; } + h1 { font-size: 1.75rem; } + h2 { font-size: 1.25rem; } + .container { padding: 1rem; margin: 1rem auto; } + .card { padding: 1.5rem; } + .user-item { flex-direction: column; align-items: flex-start; gap: 0.5rem;} + .user-item .actions { margin-top: 0.5rem; } +} +@media (max-width: 480px) { + .file-grid { grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 0.75rem; } + .grid-item-name { font-size: 0.8rem; height: 2.4em; } + .btn { padding: 0.6rem 1.2rem; font-size: 0.9rem; } + .breadcrumb { font-size: 0.8rem; padding: 0.5rem 1rem; } + .modal-content { max-width: 95vw; } +} +''' @app.route('/register', methods=['GET', 'POST']) def register(): @@ -325,27 +494,21 @@ def register(): return redirect(url_for('register')) # Basic validation (add more robust checks as needed) - if not username.isalnum() or len(username) < 3: - flash('Имя пользователя должно быть не менее 3 символов и содержать только буквы и цифры.', 'error') + if len(username) < 3 or len(password) < 6: + flash('Имя пользователя должно быть не менее 3 символов, пароль - не менее 6.', 'error') return redirect(url_for('register')) - if len(password) < 6: - flash('Пароль должен быть не менее 6 символов.', 'error') - return redirect(url_for('register')) - data = load_data() if username in data['users']: - flash('Пользователь с таким именем уже существует!', 'warning') + flash('Пользователь с таким именем уже существует!', 'error') return redirect(url_for('register')) - # Basic password hashing should be added here in a real app - # For simplicity, sticking to plain text as per original code - user_hf_base_path = f"cloud_files/{username}/" data['users'][username] = { - 'password': password, # Store hashed password in real app + 'password': password, # Store password directly (INSECURE! Use hashing in production) 'created_at': datetime.now().strftime('%Y-%m-%d %H:%M:%S'), - 'root_folder': create_folder_node('/', user_hf_base_path) # Initialize root folder + 'files': [], + 'folders': [] # Initialize folders list } try: save_data(data) @@ -353,1905 +516,1348 @@ def register(): flash('Регистрация прошла успешно!', 'success') return redirect(url_for('dashboard')) except Exception as e: - flash(f'Ошибка при сохранении данных: {e}', 'error') - # Clean up potentially partially added user? Or rely on save_data failure handling. - return redirect(url_for('register')) + flash('Ошибка сохранения данных при регистрации.', 'error') + logging.error(f"Registration save error: {e}") + return redirect(url_for('register')) - return render_template_string(LOGIN_REGISTER_TEMPLATE, mode='register', BASE_STYLE=BASE_STYLE) + html = ''' + + + + + + Регистрация - Zeus Cloud + + + +
+
+

Регистрация в Zeus Cloud

+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} +
{{ message }}
+ {% endfor %} + {% endif %} + {% endwith %} +
+ + + +
+

Уже есть аккаунт? Войти

+
+
+ + +''' + return render_template_string(html, style=BASE_STYLE) @app.route('/', methods=['GET', 'POST']) def login(): if request.method == 'POST': - username = request.form.get('username') - password = request.form.get('password') - is_ajax = request.headers.get('X-Requested-With') == 'XMLHttpRequest' + content_type = request.headers.get('Content-Type') + if content_type == 'application/json': + req_data = request.get_json() + username = req_data.get('username') + password = req_data.get('password') + is_ajax = True + elif content_type == 'application/x-www-form-urlencoded': + username = request.form.get('username') + password = request.form.get('password') + is_ajax = True # Assume form submission via JS is AJAX + else: # Handle direct form submission without JS + username = request.form.get('username') + password = request.form.get('password') + is_ajax = False + + if not username or not password: + if is_ajax: + return jsonify({'status': 'error', 'message': 'Имя пользователя и пароль обязательны!'}) + else: + flash('Имя пользователя и пароль обязательны!', 'error') + return redirect(url_for('login')) data = load_data() - user_data = data['users'].get(username) - # Add password verification (hashing) here in real app - if user_data and user_data['password'] == password: + if username in data['users'] and data['users'][username]['password'] == password: # Direct password check (INSECURE!) session['username'] = username - session.permanent = True # Keep session longer - app.permanent_session_lifetime = timedelta(days=30) # Example: 30 days - if is_ajax: - return jsonify({'status': 'success', 'redirect': url_for('dashboard')}) + return jsonify({'status': 'success', 'redirect': url_for('dashboard')}) else: - flash('Вход выполнен успешно!', 'success') - return redirect(url_for('dashboard')) + flash('Вход выполнен успешно!', 'success') + return redirect(url_for('dashboard')) else: - message = 'Неверное имя пользователя или пароль!' if is_ajax: - return jsonify({'status': 'error', 'message': message}) + return jsonify({'status': 'error', 'message': 'Неверное имя пользователя или пароль!'}) else: - flash(message, 'error') - return redirect(url_for('login')) + flash('Неверное имя пользователя или пароль!', 'error') + return redirect(url_for('login')) + + html = ''' + + + + + + Вход - Zeus Cloud + + + +
+
+

Zeus Cloud

+
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} +
{{ message }}
+ {% endfor %} + {% endif %} + {% endwith %} +
+
+ + + +
+

Нет аккаунта? Зарегистрируйтесь

+
+
+ + + +''' + return render_template_string(html, style=BASE_STYLE) -@app.route('/logout') -def logout(): - username = session.pop('username', None) - cache.clear() # Clear cache on logout - if username: - flash('Вы успешно вышли из системы.', 'success') - # The client-side JS should handle clearing localStorage - return redirect(url_for('login')) -@app.route('/dashboard') -@app.route('/dashboard/') -def dashboard(current_folder_path=''): +@app.route('/dashboard', methods=['GET', 'POST']) +def dashboard(): if 'username' not in session: - flash('Пожалуйста, войдите в систему!', 'warning') + flash('Пожалуйста, войдите в систему!', 'info') return redirect(url_for('login')) username = session['username'] data = load_data() - user_data = data['users'].get(username) - - if not user_data: + if username not in data['users']: session.pop('username', None) - flash('Ошибка: пользователь не найден. Пожалуйста, войдите снова.', 'error') + flash('Пользователь не найден!', 'error') return redirect(url_for('login')) - # Ensure root_folder exists - if 'root_folder' not in user_data: - user_data['root_folder'] = create_folder_node('/', f"cloud_files/{username}/") - # Potentially migrate old 'files' list here if needed - save_data(data) # Save the fix - - root_node = user_data['root_folder'] - path_segments = [seg for seg in current_folder_path.split('/') if seg] - current_node = find_node_by_path(root_node, path_segments) - - if current_node is None or current_node.get("type") != "folder": - flash('Указанный путь не найден или не является папкой.', 'error') - return redirect(url_for('dashboard')) # Redirect to root - - items = current_node.get("children", []) - # Items are already sorted folders first, then alphabetically by add_node_to_path - - # Breadcrumbs - breadcrumbs = [{'name': 'Главная', 'path': ''}] - cumulative_path = [] - for segment in path_segments: - cumulative_path.append(segment) - breadcrumbs.append({'name': segment, 'path': '/'.join(cumulative_path)}) - - return render_template_string( - DASHBOARD_TEMPLATE, - username=username, - items=items, - current_path=current_folder_path, - breadcrumbs=breadcrumbs, - repo_id=REPO_ID, # Pass repo_id for constructing URLs if needed elsewhere - get_hf_download_url=get_hf_download_url, # Pass helper function to template - HF_TOKEN_READ=HF_TOKEN_READ, # Pass read token if needed for JS previews - BASE_STYLE=BASE_STYLE - ) + user_data = data['users'][username] + user_data.setdefault('files', []) + user_data.setdefault('folders', []) + current_path = request.args.get('path', '/') + if not current_path.startswith('/'): current_path = '/' + current_path + if not current_path.endswith('/'): current_path += '/' -@app.route('/upload', methods=['POST']) -def upload_files(): - if 'username' not in session: - return jsonify({'status': 'error', 'message': 'Не авторизован'}), 401 - username = session['username'] - current_path = request.form.get('current_path', '') # Get target path from form - path_segments = [seg for seg in current_path.split('/') if seg] + if request.method == 'POST': + files = request.files.getlist('files') + target_path = request.form.get('current_path', '/') + if not target_path.startswith('/'): target_path = '/' + target_path + if not target_path.endswith('/'): target_path += '/' - files = request.files.getlist('files') - if not files or all(not f.filename for f in files): - flash('Файлы для загрузки не выбраны.', 'warning') - return redirect(url_for('dashboard', current_folder_path=current_path)) + if not files or files[0].filename == '': + flash('Файлы для загрузки не выбраны.', 'warning') + return redirect(url_for('dashboard', path=target_path)) - if len(files) > 20: # Limit simultaneous uploads - flash('Максимум 20 файлов за раз!', 'warning') - return redirect(url_for('dashboard', current_folder_path=current_path)) + if len(files) > 20: + flash('Максимум 20 файлов за раз!', 'warning') + return redirect(url_for('dashboard', path=target_path)) - data = load_data() - user_data = data['users'].get(username) - if not user_data: - flash('Ошибка: пользователь не найден.', 'error') - return redirect(url_for('login')) + api = HfApi(token=HF_TOKEN_WRITE) + uploaded_file_infos = [] - root_node = user_data['root_folder'] - target_node = find_node_by_path(root_node, path_segments) + try: + for file in files: + if file: + original_filename = file.filename + unique_filename = generate_unique_filename(original_filename) + temp_path = os.path.join(UPLOAD_FOLDER, f"{uuid.uuid4()}_{unique_filename}") # Unique temp name + file.save(temp_path) - if not target_node or target_node.get("type") != "folder": - flash('Целевая папка не найдена.', 'error') - return redirect(url_for('dashboard')) + hf_file_path = f"cloud_files/{username}{target_path.lstrip('/')}{unique_filename}" - target_hf_path_prefix = target_node.get("path", f"cloud_files/{username}/") - if not target_hf_path_prefix.endswith('/'): - target_hf_path_prefix += '/' - - # Use a temporary directory for uploads if needed, or upload directly - # os.makedirs('temp_uploads', exist_ok=True) # Optional temp storage - - api = get_hf_api(write=True) - uploaded_count = 0 - errors = [] - - for file in files: - if file and file.filename: - original_filename = secure_filename(file.filename) # Clean original name first - storage_filename = generate_unique_filename(original_filename) - hf_full_path = target_hf_path_prefix + storage_filename - - try: - # Check for name collision *before* uploading - if not any(c.get("original_filename") == original_filename for c in target_node.get("children", []) if c.get("type")=="file"): - # Stream upload directly if possible and reliable with HfApi - # Or save temporarily then upload - # file.save(os.path.join('temp_uploads', storage_filename)) # Example temp save - # api.upload_file(path_or_fileobj=os.path.join('temp_uploads', storage_filename), ...) - - # Direct upload from stream (ensure file pointer is reset if needed) - file.seek(0) # Reset stream pointer api.upload_file( - path_or_fileobj=file.stream, # Upload from stream - path_in_repo=hf_full_path, + path_or_fileobj=temp_path, + path_in_repo=hf_file_path, repo_id=REPO_ID, repo_type="dataset", - commit_message=f"User {username} uploaded: {original_filename} to {current_path}" + commit_message=f"Upload: {original_filename} for {username} to {target_path}" ) - # Create metadata node and add to JSON - file_node = create_file_node(original_filename, storage_filename, hf_full_path) - if add_node_to_path(root_node, path_segments, file_node): - uploaded_count += 1 - else: - # This case (name collision after check) should be rare but handle it - errors.append(f"'{original_filename}': Ошибка добавления метаданных после загрузки (возможно, конфликт).") - # Consider deleting the orphaned file from HF here - try: - delete_hf_object(hf_full_path, is_folder=False) - except Exception as del_e: - logging.error(f"Failed to delete orphaned file {hf_full_path}: {del_e}") - else: - errors.append(f"'{original_filename}': Файл с таким именем уже существует в этой папке.") - - except Exception as e: - logging.error(f"Error uploading file '{original_filename}' to HF: {e}") - errors.append(f"'{original_filename}': Ошибка загрузки ({e}).") - # finally: - # if os.path.exists(os.path.join('temp_uploads', storage_filename)): - # os.remove(os.path.join('temp_uploads', storage_filename)) # Clean up temp file - - if uploaded_count > 0: - try: - save_data(data) - flash(f'Успешно загружено {uploaded_count} файлов.', 'success') - except Exception as e: - flash(f'Ошибка при сохранении данных после загрузки: {e}', 'error') + file_info = { + 'original_filename': original_filename, + 'unique_filename': unique_filename, # Stored on HF + 'hf_path': hf_file_path, # Full path in HF repo + 'path_prefix': target_path, # Directory path within user's space + 'type': get_file_type(original_filename), + 'upload_date': datetime.now().strftime('%Y-%m-%d %H:%M:%S') + } + uploaded_file_infos.append(file_info) - if errors: - flash('Некоторые файлы не были загружены:\n' + '\n'.join(errors), 'error') + if os.path.exists(temp_path): + os.remove(temp_path) - # Use AJAX response if requested - if request.headers.get('X-Requested-With') == 'XMLHttpRequest': - # Determine overall status - status = 'success' if uploaded_count > 0 and not errors else 'partial' if uploaded_count > 0 and errors else 'error' - return jsonify({'status': status, 'uploaded': uploaded_count, 'errors': errors}) - else: - return redirect(url_for('dashboard', current_folder_path=current_path)) + user_data['files'].extend(uploaded_file_infos) + save_data(data) + flash(f'{len(uploaded_file_infos)} Файл(ов) успешно загружено!', 'success') + except Exception as e: + logging.error(f"File upload error for {username}: {e}") + flash(f'Ошибка при загрузке файлов: {e}', 'error') + # Clean up any partially saved temp files if error occurs mid-loop + for temp_path, _ in [(os.path.join(UPLOAD_FOLDER, f"{uuid.uuid4()}_{generate_unique_filename(f.filename)}"), f.filename) for f in files if f]: + if os.path.exists(temp_path): + try: os.remove(temp_path) + except OSError: pass -@app.route('/create_folder', methods=['POST']) -def create_folder(): - if 'username' not in session: - return jsonify({'status': 'error', 'message': 'Не авторизован'}), 401 + return redirect(url_for('dashboard', path=target_path)) - username = session['username'] - current_path = request.form.get('current_path', '') - folder_name = request.form.get('folder_name', '').strip() - path_segments = [seg for seg in current_path.split('/') if seg] - if not folder_name: - flash('Имя папки не может быть пустым.', 'error') - return redirect(url_for('dashboard', current_folder_path=current_path)) + # Filter files and folders for the current path + current_files = sorted( + [f for f in user_data['files'] if f.get('path_prefix') == current_path], + key=lambda x: x.get('upload_date', ''), reverse=True + ) + # Folders are represented simply by their paths ending in '/' + current_folders = sorted( + [f for f in user_data['folders'] if f.startswith(current_path) and f != current_path and f.count('/') == current_path.count('/')] + ) - # Basic validation for folder names - if "/" in folder_name or "\\" in folder_name or not folder_name: - flash('Имя папки содержит недопустимые символы или пусто.', 'error') - return redirect(url_for('dashboard', current_folder_path=current_path)) + # Build breadcrumbs + breadcrumbs = [] + path_parts = list(filter(None, current_path.split('/'))) + temp_path = '/' + breadcrumbs.append({'name': 'Корень', 'path': '/'}) + for i, part in enumerate(path_parts): + temp_path += part + '/' + breadcrumbs.append({'name': part, 'path': temp_path}) - safe_folder_name = secure_filename(folder_name) # Further sanitization - if not safe_folder_name: - flash('Недопустимое имя папки после очистки.', 'error') - return redirect(url_for('dashboard', current_folder_path=current_path)) - - data = load_data() - user_data = data['users'].get(username) - if not user_data: - flash('Ошибка: пользователь не найден.', 'error') - return redirect(url_for('login')) - - root_node = user_data['root_folder'] - parent_node = find_node_by_path(root_node, path_segments) - - if not parent_node or parent_node.get("type") != "folder": - flash('Родительская папка не найдена.', 'error') - return redirect(url_for('dashboard')) - - # Check if folder already exists (case-insensitive) - if any(c.get("type") == "folder" and c.get("name", '').lower() == safe_folder_name.lower() for c in parent_node.get("children", [])): - flash(f'Папка "{safe_folder_name}" уже существует.', 'warning') - return redirect(url_for('dashboard', current_folder_path=current_path)) - - # Construct HF path for the new folder - parent_hf_path = parent_node.get("path", f"cloud_files/{username}/") - if not parent_hf_path.endswith('/'): - parent_hf_path += '/' - new_folder_hf_path = parent_hf_path + safe_folder_name + "/" # Folders end with / - - folder_node = create_folder_node(safe_folder_name, new_folder_hf_path) - - if add_node_to_path(root_node, path_segments, folder_node): - try: - # HF Hub doesn't strictly need empty folders created, - # but we could upload a placeholder if desired. Let's skip for now. - # api = get_hf_api(write=True) - # placeholder_path = new_folder_hf_path + ".keep" - # api.upload_file(path_or_fileobj=BytesIO(b''), path_in_repo=placeholder_path, ...) - - save_data(data) - flash(f'Папка "{safe_folder_name}" успешно создана.', 'success') - except Exception as e: - flash(f'Ошибка при сохранении данных после создания папки: {e}', 'error') - # Attempt to rollback the change in memory? - remove_node_by_path(root_node, path_segments, safe_folder_name) # Try removing added node - else: - # This case (collision after check) should be rare - flash(f'Не удалось добавить папку "{safe_folder_name}". Возможно, она уже существует.', 'error') - - - return redirect(url_for('dashboard', current_folder_path=current_path)) - - -@app.route('/delete_item', methods=['POST']) -def delete_item(): - if 'username' not in session: - return jsonify({'status': 'error', 'message': 'Не авторизован'}), 401 - - username = session['username'] - item_path = request.form.get('item_path') # This should be the full HF path - item_name = request.form.get('item_name') # Name displayed (original filename or folder name) - item_type = request.form.get('item_type') # 'file' or 'folder' - current_view_path = request.form.get('current_view_path', '') # Path of the dashboard view to redirect back to - - if not item_path or not item_name or not item_type: - flash('Недостаточно информации для удаления.', 'error') - return redirect(url_for('dashboard', current_folder_path=current_view_path)) - - data = load_data() - user_data = data['users'].get(username) - if not user_data: - flash('Ошибка: пользователь не найден.', 'error') - return redirect(url_for('login')) - - root_node = user_data['root_folder'] - - # Determine the parent path segments in the JSON structure based on the item's HF path - # Example: item_path = "cloud_files/user/folderA/file.txt" -> segments = ["folderA"] - # Example: item_path = "cloud_files/user/folderA/subB/" -> segments = ["folderA", "subB"] - base_user_path = f"cloud_files/{username}/" - relative_path = item_path.replace(base_user_path, '', 1) - path_segments_full = [seg for seg in relative_path.strip('/').split('/') if seg] - - # If deleting 'file.txt' in 'folderA', parent segments are ['folderA'] - # If deleting folder 'subB' in 'folderA', parent segments are ['folderA'] - # Need the name of the item being deleted to find it in the parent's children - # The item_name passed from the form should be correct - parent_segments = path_segments_full[:-1] if item_type == 'file' else path_segments_full[:-1] # For folders path includes its own name ending in / - - logging.info(f"Attempting to delete '{item_name}' (type: {item_type}) with HF path '{item_path}'. Parent segments: {parent_segments}") - - removed_node = remove_node_by_path(root_node, parent_segments, item_name) - - if removed_node: - try: - # Delete from Hugging Face Hub - is_folder = removed_node.get("type") == "folder" - hf_object_path = removed_node.get("path") # Get the exact path from the removed node - if hf_object_path: - delete_hf_object(hf_object_path, is_folder=is_folder) - # If folder, HF deletion might handle recursion, or we'd need to list and delete contents first if API requires it. Assume delete_folder handles it. - else: - raise ValueError("Removed node is missing HF path information.") - - # Save the updated JSON data - save_data(data) - flash(f'{"Папка" if is_folder else "Файл"} "{item_name}" успешно удален(а).', 'success') - logging.info(f"Successfully deleted item '{item_name}' and saved state.") - - except Exception as e: - flash(f'Ошибка при удалении {"папки" if is_folder else "файла"} "{item_name}" из хранилища или при сохранении: {e}', 'error') - logging.error(f"Error during deletion process for {item_name}: {e}") - # Attempt to rollback JSON change if HF deletion failed? Very tricky. - # Best effort: Log error, user might need manual cleanup on HF. - # Re-add node to keep JSON consistent with potential HF state? - # add_node_to_path(root_node, parent_segments, removed_node) # This might fail if parent was also affected etc. Risky. - # For now, leave JSON modified and report error. - else: - # remove_node_by_path already logged a warning/error - flash(f'Не удалось найти или удалить метаданные для "{item_name}".', 'error') - - - return redirect(url_for('dashboard', current_folder_path=current_view_path)) - - -@app.route('/download/') -def download_file(item_hf_path): - if 'username' not in session: - flash('Пожалуйста, войдите для скачивания файлов.', 'warning') - # Store intended download path in session? Or just redirect to login. - return redirect(url_for('login')) - - username = session['username'] - # The item_hf_path from the URL *is* the full HF path - # We need the original filename for the 'download_name' attribute. - # We have to find the file node in the user's data using the hf_path. - - data = load_data() - user_data = data['users'].get(username) - if not user_data: - flash('Ошибка: пользователь не найден.', 'error') - return redirect(url_for('login')) - - # Function to search the tree for a file node by its HF path - def find_file_by_hf_path(node, target_hf_path): - if node.get("type") == "file" and node.get("path") == target_hf_path: - return node - if node.get("type") == "folder": - for child in node.get("children", []): - found = find_file_by_hf_path(child, target_hf_path) - if found: - return found - return None - - file_node = find_file_by_hf_path(user_data.get('root_folder'), item_hf_path) - - if not file_node: - flash('Файл не найден в ваших записях или у вас нет доступа.', 'error') - # Redirect back to dashboard or a relevant path? Hard to know original location. - return redirect(url_for('dashboard')) - - original_filename = file_node.get('original_filename', 'downloaded_file') - # Use the direct HF download URL construction - file_url = get_hf_download_url(file_node) - - if not file_url: - flash('Не удалось создать URL для скачивания.', 'error') - return redirect(url_for('dashboard')) - - try: - # Use requests to stream the download - headers = {} - if HF_TOKEN_READ: - headers["authorization"] = f"Bearer {HF_TOKEN_READ}" - - response = requests.get(file_url, headers=headers, stream=True, timeout=30) # Add timeout - response.raise_for_status() # Check for HTTP errors (4xx, 5xx) - - # Stream the response - return Response(response.iter_content(chunk_size=8192), - content_type=response.headers.get('Content-Type', 'application/octet-stream'), - headers={"Content-Disposition": f"attachment; filename=\"{original_filename}\""}) - - except requests.exceptions.Timeout: - logging.error(f"Timeout downloading file from HF: {file_url}") - flash('Ошибка скачивания: превышено время ожидания от сервера.', 'error') - except requests.exceptions.HTTPError as e: - logging.error(f"HTTP error downloading file from HF: {e.response.status_code} for {file_url}") - if e.response.status_code == 404: - flash('Ошибка скачивания: файл не найден в хранилище.', 'error') - # Consider removing the inconsistent node from JSON data here - elif e.response.status_code == 401 or e.response.status_code == 403: - flash('Ошибка скачивания: недостаточно прав для доступа к файлу (проверьте токен).', 'error') - else: - flash(f'Ошибка скачивания: {e.response.status_code}', 'error') - except requests.exceptions.RequestException as e: - logging.error(f"Network error downloading file from HF: {e}") - flash('Ошибка сети при скачивании файла.', 'error') - except Exception as e: - logging.error(f"Unexpected error during download preparation for {original_filename}: {e}") - flash('Произошла непредвиденная ошибка при скачивании файла.', 'error') - - # If any error occurred, redirect back - # Redirecting back requires knowing the original context (current_folder_path) which isn't available here easily - # Redirect to root dashboard as a fallback - return redirect(url_for('dashboard')) - -@app.route('/view_text/') -def view_text_file(item_hf_path): - # Similar auth and node finding logic as download - if 'username' not in session: - return Response("Unauthorized", status=401) - - username = session['username'] - data = load_data() - user_data = data['users'].get(username) - if not user_data: - return Response("User not found", status=403) - - # Reuse find_file_by_hf_path from download logic - def find_file_by_hf_path(node, target_hf_path): - if node.get("type") == "file" and node.get("path") == target_hf_path: - return node - if node.get("type") == "folder": - for child in node.get("children", []): - found = find_file_by_hf_path(child, target_hf_path) - if found: - return found - return None - - file_node = find_file_by_hf_path(user_data.get('root_folder'), item_hf_path) - - if not file_node or file_node.get("file_type") != 'text': - return Response("File not found or not a text file", status=404) - - file_url = get_hf_download_url(file_node) - if not file_url: - return Response("Could not generate download URL", status=500) - - try: - headers = {} - if HF_TOKEN_READ: - headers["authorization"] = f"Bearer {HF_TOKEN_READ}" - - # Limit download size for text preview - response = requests.get(file_url, headers=headers, stream=True, timeout=15) - response.raise_for_status() - - # Read a limited amount of content - max_preview_size = 1 * 1024 * 1024 # 1MB limit for preview - content = b'' - for chunk in response.iter_content(chunk_size=8192): - content += chunk - if len(content) > max_preview_size: - content = content[:max_preview_size] + b'\n... (file truncated for preview)' - break - - # Decode assuming UTF-8, fallback to latin-1 - try: - text_content = content.decode('utf-8') - except UnicodeDecodeError: - text_content = content.decode('latin-1', errors='replace') - - # Return as plain text - return Response(text_content, mimetype='text/plain; charset=utf-8') - - except Exception as e: - logging.error(f"Error fetching text content for {item_hf_path}: {e}") - return Response(f"Error fetching file content: {e}", status=500) - - -# --- Admin Routes (Simplified) --- - -@app.route('/admhosto') -def admin_panel(): - # IMPORTANT: Add robust admin authentication here! - # Example: Check if session['username'] is in a predefined admin list - # if session.get('username') not in ['admin_user1', 'admin_user2']: - # flash('Доступ запрещен.', 'error') - # return redirect(url_for('login')) - - data = load_data() - users_list = [] - for username, user_data in data.get('users', {}).items(): - root_folder = user_data.get('root_folder', {}) - # Count items recursively (simple count for now) - def count_items(node): - count = 0 - if node.get("type") == "file": - return 1 - if node.get("type") == "folder": - count += sum(count_items(child) for child in node.get("children", [])) - return count - - total_files = count_items(root_folder) # This counts only files now - - users_list.append({ - 'username': username, - 'created_at': user_data.get('created_at', 'N/A'), - 'file_count': total_files # Or adjust counting logic as needed - }) - - users_list.sort(key=lambda x: x['username'].lower()) - - return render_template_string(ADMIN_PANEL_TEMPLATE, users=users_list, BASE_STYLE=BASE_STYLE) - - -@app.route('/admhosto/user/') -def admin_user_files(username): - # Add admin auth check - # if session.get('username') not in ['admin_user1', 'admin_user2']: return redirect(url_for('login')) - - data = load_data() - user_data = data['users'].get(username) - if not user_data: - flash(f'Пользователь "{username}" не найден.', 'error') - return redirect(url_for('admin_panel')) - - # For simplicity, admin view just shows the root items for now - # A recursive template would be needed for full browsing - root_node = user_data.get('root_folder', {}) - items = root_node.get("children", []) - items.sort(key=lambda x: (x.get('type') != 'folder', x.get('name', '').lower() if x.get('type') == 'folder' else x.get('original_filename', '').lower())) - - return render_template_string( - ADMIN_USER_FILES_TEMPLATE, - target_username=username, - items=items, - repo_id=REPO_ID, - get_hf_download_url=get_hf_download_url, - HF_TOKEN_READ=HF_TOKEN_READ, - BASE_STYLE=BASE_STYLE - # Pass current path etc. if implementing folder navigation for admin - ) - -@app.route('/admhosto/delete_user/', methods=['POST']) -def admin_delete_user(username): - # Add admin auth check - # if session.get('username') not in ['admin_user1', 'admin_user2']: return jsonify({'status': 'error', 'message': 'Unauthorized'}), 403 - - data = load_data() - if username not in data['users']: - flash(f'Пользователь "{username}" не найден.', 'error') - return redirect(url_for('admin_panel')) - - user_data = data['users'][username] - user_hf_base_path = user_data.get('root_folder', {}).get('path', f"cloud_files/{username}/") - if not user_hf_base_path.endswith('/'): user_hf_base_path += '/' - - - try: - # Attempt to delete the user's entire folder on HF Hub - logging.info(f"Admin attempting to delete HF folder: {user_hf_base_path}") - delete_hf_object(user_hf_base_path, is_folder=True) - # If HF deletion succeeds (or if folder didn't exist), remove user from JSON - del data['users'][username] - save_data(data) - flash(f'Пользователь "{username}" и его файлы (если были) удалены.', 'success') - logging.info(f"Admin successfully deleted user '{username}' and their HF folder.") - - except Exception as e: - logging.error(f"Error during admin deletion of user '{username}' or their HF folder '{user_hf_base_path}': {e}") - flash(f'Ошибка при удалении пользователя "{username}" или его папки в хранилище: {e}', 'error') - # Do not delete user from JSON if HF deletion failed, to avoid orphans - - return redirect(url_for('admin_panel')) - - -# --- Templates and Styles --- - -# Consolidated CSS -BASE_STYLE = ''' -@import url('https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.2.0/css/all.min.css'); -@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;600;800&display=swap'); - -:root { - --primary-color: #4A90E2; /* Vibrant Blue */ - --secondary-color: #50E3C2; /* Teal */ - --accent-color: #B8E986; /* Light Green */ - --danger-color: #D0021B; /* Red */ - --warning-color: #F5A623; /* Orange */ - --success-color: #7ED321; /* Green */ - - --bg-light: #F7F9FC; - --bg-dark: #1E1E2E; /* Dark Blue/Purple */ - --card-bg-light: #FFFFFF; - --card-bg-dark: #2D2D44; /* Slightly Lighter Dark */ - --text-light: #333333; - --text-dark: #E0E0E0; - --text-muted-light: #777777; - --text-muted-dark: #AAAAAA; - --border-light: #E0E0E0; - --border-dark: #444444; - - --shadow-sm: 0 1px 3px rgba(0,0,0,0.1); - --shadow-md: 0 4px 10px rgba(0,0,0,0.1); - --shadow-lg: 0 10px 30px rgba(0,0,0,0.15); - --border-radius-sm: 4px; - --border-radius-md: 8px; - --border-radius-lg: 16px; - --transition-fast: all 0.2s ease-in-out; - --transition-med: all 0.3s ease-in-out; -} - -* { margin: 0; padding: 0; box-sizing: border-box; } - -body { - font-family: 'Inter', sans-serif; - background-color: var(--bg-light); - color: var(--text-light); - line-height: 1.6; - font-size: 16px; - transition: var(--transition-fast); -} - -body.dark { - background-color: var(--bg-dark); - color: var(--text-dark); -} - -.container { - max-width: 1300px; - margin: 30px auto; - padding: 30px; - background-color: var(--card-bg-light); - border-radius: var(--border-radius-lg); - box-shadow: var(--shadow-lg); - border: 1px solid var(--border-light); -} -body.dark .container { - background-color: var(--card-bg-dark); - border-color: var(--border-dark); -} - -h1, h2, h3 { - color: var(--primary-color); - margin-bottom: 0.8em; - font-weight: 800; -} -body.dark h1, body.dark h2, body.dark h3 { - color: var(--secondary-color); -} -h1 { font-size: 2.2em; text-align: center; } -h2 { font-size: 1.6em; margin-top: 1.5em; border-bottom: 2px solid var(--primary-color); padding-bottom: 0.3em; } -body.dark h2 { border-bottom-color: var(--secondary-color); } - -a { - color: var(--primary-color); - text-decoration: none; - transition: var(--transition-fast); -} -a:hover { - color: var(--secondary-color); - text-decoration: underline; -} -body.dark a { color: var(--secondary-color); } -body.dark a:hover { color: var(--accent-color); } - -input[type="text"], -input[type="password"], -input[type="file"], -textarea { - width: 100%; - padding: 12px 15px; - margin-bottom: 15px; - border: 1px solid var(--border-light); - border-radius: var(--border-radius-md); - background-color: var(--bg-light); - color: var(--text-light); - font-size: 1em; - transition: var(--transition-fast); -} -body.dark input[type="text"], -body.dark input[type="password"], -body.dark input[type="file"], -body.dark textarea { - background-color: var(--card-bg-dark); - color: var(--text-dark); - border-color: var(--border-dark); -} -input:focus, textarea:focus { - outline: none; - border-color: var(--primary-color); - box-shadow: 0 0 0 3px rgba(74, 144, 226, 0.3); -} -body.dark input:focus, body.dark textarea:focus { - border-color: var(--secondary-color); - box-shadow: 0 0 0 3px rgba(80, 227, 194, 0.3); -} -input[type="file"] { - padding: 8px; - background-color: transparent; - border: 1px dashed var(--border-light); -} -body.dark input[type="file"] { border-color: var(--border-dark); } - -.btn { - display: inline-block; - padding: 10px 20px; - border: none; - border-radius: var(--border-radius-md); - cursor: pointer; - font-size: 1em; - font-weight: 600; - text-align: center; - transition: var(--transition-med); - box-shadow: var(--shadow-sm); - text-decoration: none !important; /* Override underline on hover */ - margin: 5px; -} -.btn:hover { - transform: translateY(-2px); - box-shadow: var(--shadow-md); -} -.btn-primary { background-color: var(--primary-color); color: white; } -.btn-primary:hover { background-color: #3a7bc8; } -.btn-secondary { background-color: var(--secondary-color); color: #333; } -.btn-secondary:hover { background-color: #3ccab4; } -.btn-danger { background-color: var(--danger-color); color: white; } -.btn-danger:hover { background-color: #b00217; } -.btn-small { padding: 6px 12px; font-size: 0.9em; } - -.flash { - padding: 15px; - margin-bottom: 20px; - border-radius: var(--border-radius-md); - border: 1px solid transparent; - text-align: center; - font-weight: 600; -} -.flash.success { background-color: #d4edda; color: #155724; border-color: #c3e6cb; } -.flash.error { background-color: #f8d7da; color: #721c24; border-color: #f5c6cb; } -.flash.warning { background-color: #fff3cd; color: #856404; border-color: #ffeeba; } -.flash.info { background-color: #d1ecf1; color: #0c5460; border-color: #bee5eb; } - -/* File/Folder Grid */ -.item-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); - gap: 20px; - margin-top: 20px; -} -.item-card { - background-color: var(--card-bg-light); - border: 1px solid var(--border-light); - border-radius: var(--border-radius-md); - padding: 15px; - text-align: center; - transition: var(--transition-med); - box-shadow: var(--shadow-sm); - position: relative; - overflow: hidden; /* Clip content */ - word-wrap: break-word; /* Break long names */ -} -body.dark .item-card { - background-color: var(--card-bg-dark); - border-color: var(--border-dark); -} -.item-card:hover { - transform: translateY(-5px); - box-shadow: var(--shadow-md); - border-color: var(--primary-color); -} -body.dark .item-card:hover { border-color: var(--secondary-color); } - -.item-icon { - font-size: 3em; - margin-bottom: 10px; - color: var(--primary-color); -} -body.dark .item-icon { color: var(--secondary-color); } -.item-icon .fa-folder { color: #F5A623; } /* Orange folders */ -.item-icon .fa-file-video { color: #B8E986; } /* Light green videos */ -.item-icon .fa-file-image { color: #50E3C2; } /* Teal images */ -.item-icon .fa-file-pdf { color: #D0021B; } /* Red PDFs */ -.item-icon .fa-file-alt { color: #4A90E2; } /* Blue text files */ -.item-icon .fa-file { color: #9B9B9B; } /* Grey other files */ - -.item-preview { - width: 100%; - height: 120px; /* Fixed height for previews */ - object-fit: cover; /* Cover maintains aspect ratio, crops */ - border-radius: var(--border-radius-sm); - margin-bottom: 10px; - background-color: #eee; /* Placeholder bg */ - cursor: pointer; -} -body.dark .item-preview { background-color: #333; } - -.item-name { - font-weight: 600; - margin-bottom: 5px; - display: block; /* Allow wrapping */ - line-height: 1.3; -} -.item-details { - font-size: 0.85em; - color: var(--text-muted-light); - margin-bottom: 10px; -} -body.dark .item-details { color: var(--text-muted-dark); } - -.item-actions { - margin-top: 10px; - display: flex; - justify-content: center; - gap: 5px; - flex-wrap: wrap; /* Wrap buttons on small cards */ -} -.item-actions .btn { - padding: 5px 10px; - font-size: 0.85em; -} - -/* Modal */ -.modal { - display: none; /* Hidden by default */ - position: fixed; - z-index: 1050; - left: 0; top: 0; - width: 100%; height: 100%; - overflow: hidden; - outline: 0; - background-color: rgba(0, 0, 0, 0.85); - justify-content: center; - align-items: center; -} -.modal-content { - position: relative; - display: flex; - flex-direction: column; - width: auto; /* Adjust based on content */ - max-width: 90%; - max-height: 90%; - pointer-events: auto; - background-color: var(--card-bg-light); - background-clip: padding-box; - border-radius: var(--border-radius-lg); - outline: 0; - box-shadow: var(--shadow-lg); -} -body.dark .modal-content { background-color: var(--card-bg-dark); } - -.modal-header { - display: flex; - justify-content: space-between; - align-items: center; - padding: 1rem 1.5rem; - border-bottom: 1px solid var(--border-light); -} -body.dark .modal-header { border-bottom-color: var(--border-dark); } -.modal-title { margin-bottom: 0; font-size: 1.25rem; font-weight: 600; } -.modal-close { - background: transparent; - border: none; - font-size: 1.5rem; - cursor: pointer; - padding: 0; - color: var(--text-muted-light); -} -body.dark .modal-close { color: var(--text-muted-dark); } -.modal-close:hover { color: var(--danger-color); } - -.modal-body { - position: relative; - flex: 1 1 auto; - padding: 1.5rem; - overflow: auto; /* Scroll if content overflows */ - text-align: center; /* Center images/videos */ -} -.modal-body img, .modal-body video, .modal-body iframe { - max-width: 100%; - max-height: calc(90vh - 120px); /* Adjust based on header/footer */ - display: block; - margin: 0 auto; - border-radius: var(--border-radius-sm); -} -.modal-body iframe { - width: 100%; /* Full width for PDF/Text */ - height: calc(90vh - 120px); /* Adjust height */ - border: none; -} -.modal-body pre { - text-align: left; - white-space: pre-wrap; /* Wrap long lines */ - word-break: break-all; - max-height: calc(90vh - 120px); - overflow: auto; - background: var(--bg-light); - padding: 10px; - border-radius: var(--border-radius-sm); - border: 1px solid var(--border-light); - font-family: monospace; -} -body.dark .modal-body pre { - background: var(--bg-dark); - border-color: var(--border-dark); - color: var(--text-dark); -} -.modal-body .loader { /* Simple CSS loader */ - border: 5px solid #f3f3f3; /* Light grey */ - border-top: 5px solid var(--primary-color); /* Blue */ - border-radius: 50%; - width: 50px; - height: 50px; - animation: spin 1s linear infinite; - margin: 20px auto; -} -body.dark .modal-body .loader { - border-top-color: var(--secondary-color); -} -@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } - - -/* Breadcrumbs */ -.breadcrumbs { - margin-bottom: 20px; - font-size: 0.95em; - color: var(--text-muted-light); -} -body.dark .breadcrumbs { color: var(--text-muted-dark); } -.breadcrumbs a { - color: var(--primary-color); - font-weight: 600; -} -body.dark .breadcrumbs a { color: var(--secondary-color); } -.breadcrumbs span { margin: 0 5px; } - -/* Forms layout */ -.form-inline { - display: flex; - gap: 10px; - align-items: flex-end; /* Align button with input bottom */ - margin-top: 20px; - margin-bottom: 20px; - flex-wrap: wrap; /* Wrap on smaller screens */ -} -.form-inline input[type="text"], .form-inline input[type="file"] { - flex-grow: 1; /* Input takes available space */ - margin-bottom: 0; /* Reset margin */ -} -.form-inline .btn { - flex-shrink: 0; /* Prevent button shrinking */ -} - -/* Admin specific */ -.user-list { margin-top: 20px; } -.user-item { - display: flex; - justify-content: space-between; - align-items: center; - padding: 15px; - background: var(--card-bg-light); - border: 1px solid var(--border-light); - border-radius: var(--border-radius-md); - margin-bottom: 10px; - box-shadow: var(--shadow-sm); - transition: var(--transition-fast); - flex-wrap: wrap; /* Wrap content on small screens */ -} -body.dark .user-item { - background: var(--card-bg-dark); - border-color: var(--border-dark); -} -.user-item:hover { - border-color: var(--primary-color); -} -body.dark .user-item:hover { border-color: var(--secondary-color); } -.user-item-info { flex-grow: 1; margin-right: 15px; } -.user-item-info a { font-weight: 600; font-size: 1.1em; } -.user-item-info p { font-size: 0.9em; color: var(--text-muted-light); margin: 2px 0;} -body.dark .user-item-info p { color: var(--text-muted-dark); } -.user-item-actions { display: flex; gap: 5px; flex-shrink: 0;} - -/* Responsive */ -@media (max-width: 768px) { - h1 { font-size: 1.8em; } - h2 { font-size: 1.4em; } - .container { padding: 20px; margin: 20px 10px; } - .item-grid { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 15px; } - .item-icon { font-size: 2.5em; } - .item-preview { height: 100px; } - .form-inline input[type="text"], .form-inline .btn { width: 100%; margin-bottom: 10px; } - .user-item { flex-direction: column; align-items: flex-start; } - .user-item-actions { margin-top: 10px; } -} -@media (max-width: 480px) { - .item-grid { grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 10px; } - .item-card { padding: 10px; } - .item-icon { font-size: 2em; } - .item-preview { height: 80px; } - .item-name { font-size: 0.9em; } - .item-details { font-size: 0.75em; } - .item-actions .btn { padding: 4px 8px; font-size: 0.75em; } - .btn { width: 100%; margin: 5px 0; } /* Stack buttons */ -} - -#progress-container { - width: 100%; - background: #e0e0e0; - border-radius: var(--border-radius-sm); - margin: 15px 0; - display: none; /* Hidden by default */ - overflow: hidden; -} -#progress-bar { - width: 0%; - height: 10px; - background: var(--primary-color); - border-radius: var(--border-radius-sm); - transition: width 0.3s ease; -} -body.dark #progress-container { background: var(--border-dark); } -body.dark #progress-bar { background: var(--secondary-color); } - -/* Style for folder links */ -.folder-link { - display: block; - text-decoration: none; - color: inherit; /* Inherit text color */ -} -.folder-link:hover { - text-decoration: none; /* No underline on folder links */ -} -''' - -LOGIN_REGISTER_TEMPLATE = ''' - - - - - - {{ 'Регистрация' if mode == 'register' else 'Вход' }} - Zeus Cloud - - - -
-

{{ 'Регистрация в Zeus Cloud' if mode == 'register' else 'Zeus Cloud' }}

- - {% with messages = get_flashed_messages(with_categories=true) %} - {% if messages %} - {% for category, message in messages %} -
{{ message }}
- {% endfor %} - {% endif %} - {% endwith %} -
- -
- - - -
- - {% if mode == 'login' %} -

Нет аккаунта? Зарегистрируйтесь

- {% else %} -

Уже есть аккаунт? Войти

- {% endif %} -
- - {% if mode == 'login' %} - - {% endif %} - {% if mode == 'register' %} - - {% endif %} - - - -''' - -DASHBOARD_TEMPLATE = ''' - - - - - - Панель управления - Zeus Cloud - - - - - - - -
-
-

Zeus Cloud

-
- Пользователь: {{ username }} - Выйти - - -
-
+ html = ''' + + + + + + Панель управления - Zeus Cloud + + + + +
+

Zeus Cloud

+
+ Пользователь: {{ username }} + Выйти +
{% with messages = get_flashed_messages(with_categories=true) %} {% if messages %} {% for category, message in messages %} -
{{ message }}
+
{{ message }}
{% endfor %} {% endif %} {% endwith %} -

Действия

-
-
- - - -
+
+

Загрузка файлов

+
+ + + +
+
-
-
-
-
-
- - - -
+
+

Создать папку

+
+ + + +
-

Содержимое папки

- +
+

Содержимое: {{ current_path }}

+ -
- {% if not items %} -

Эта папка пуста.

- + {# Display Folders #} + {% for folder_path in current_folders %} + {% set folder_name = folder_path.strip('/').split('/')[-1] %} + +
{folder_icon | safe}
+
{{ folder_name }}
+
+
+ +
+
+
+ {% endfor %} - {% for item in items %} -
- {% if item.type == 'folder' %} - -
- {{ item.name }} -

Папка

{# Placeholder for size/date #} -
-
-
- - - - - -
+ {# Display Files #} + {% for file in current_files %} +
+
+ {% if file.type == 'image' %} + {{ file.original_filename }} + {% elif file.type == 'video' %} + + {% elif file.type == 'pdf' %} +
+ +
+ {% elif file.type == 'text' %} +
+ +
+ {% else %} +
{file_icon | safe}
+ {% endif %}
- {% elif item.type == 'file' %} - {% set file_url = get_hf_download_url(item) %} -
- {% if item.file_type == 'image' %} - {{ item.original_filename }} - {% elif item.file_type == 'video' %} -
{# Placeholder icon, video preview is complex #} - {% elif item.file_type == 'pdf' %} -
- {% elif item.file_type == 'text' %} -
- {% else %} -
- {% endif %} -
- {{ item.original_filename }} -

{{ item.upload_date }}

{# Add size later #} -
- -
- - - - - +
{{ file.original_filename }}
+
+ + + +
- {% endif %} -
- {% endfor %} +
+ {% endfor %} +
+ + {% if not current_files and not current_folders %} +

Эта папка пуста.

+ {% endif %}
-

Добавить на главный экран (PWA)

-

Вы можете установить Zeus Cloud как приложение для быстрого доступа:

-
    -
  • Android (Chrome): Меню (три точки) -> "Установить приложение" или "Добавить на главный экран".
  • -
  • iOS (Safari): Кнопка "Поделиться" (квадрат со стрелкой) -> "На экран «Домой»".
  • -
+
+

Добавить на главный экран

+

Для быстрого доступа к Zeus Cloud, вы можете добавить это приложение на главный экран вашего телефона:

+
+
+

Android (Chrome):

+
    +
  1. Нажмите меню (три точки).
  2. +
  3. Выберите "Установить приложение" или "Добавить на главный экран".
  4. +
  5. Подтвердите добавление.
  6. +
+
+
+

iOS (Safari):

+
    +
  1. Нажмите кнопку "Поделиться" .
  2. +
  3. Прокрутите вниз и выберите "На экран «Домой»".
  4. +
  5. Нажмите "Добавить".
  6. +
+
+
+
- -