diff --git "a/app.py" "b/app.py" --- "a/app.py" +++ "b/app.py" @@ -1,645 +1,351 @@ -# --- START OF FILE app.py --- - -from flask import Flask, render_template_string, request, redirect, url_for, session, flash, send_file, jsonify, make_response +# -*- 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 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 -import uuid -from functools import wraps -import mimetypes +from pathlib import Path app = Flask(__name__) -app.secret_key = os.getenv("FLASK_SECRET_KEY", "supersecretkey_dev_12345") # Use a strong key in production -DATA_FILE = 'cloudeng_data_v2.json' # Changed filename for new structure -REPO_ID = "Eluza133/Z1e1u" # Replace with your actual repo ID +app.secret_key = os.getenv("FLASK_SECRET_KEY", "a-very-secret-key-for-zeus") +DATA_FILE = 'cloudeng_data_v2.json' +REPO_ID = "Eluza133/Z1e1u" # Replace with your actual repo ID if different HF_TOKEN_WRITE = os.getenv("HF_TOKEN") HF_TOKEN_READ = os.getenv("HF_TOKEN_READ") or HF_TOKEN_WRITE -# Basic Input Validation -ALLOWED_EXTENSIONS = {'txt', 'pdf', 'png', 'jpg', 'jpeg', 'gif', 'mp4', 'mov', 'avi', 'mkv', 'webm', 'md', 'log', 'csv', 'json', 'xml', 'html', 'css', 'js', 'py', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'zip', 'rar', '7z'} -MAX_FILENAME_LENGTH = 200 -MAX_FOLDER_NAME_LENGTH = 100 -INVALID_CHARS = '/\\:*?"<>|' - -cache = Cache(app, config={'CACHE_TYPE': 'simple'}) +# 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') -# --- Helper Functions --- - -def is_safe_name(name, max_length): - if not name or len(name) > max_length: - return False - if any(c in name for c in INVALID_CHARS): - return False - return True - -def get_extension(filename): - return os.path.splitext(filename)[1].lower().lstrip('.') - -def allowed_file(filename): - # Allow any extension for now, but keep the function for potential future use - # extension = get_extension(filename) - # return '.' in filename and extension in ALLOWED_EXTENSIONS - return '.' in filename and len(filename) <= MAX_FILENAME_LENGTH - -def get_file_type(filename): - extension = get_extension(filename) - if extension in ['mp4', 'mov', 'avi', 'mkv', 'webm']: - return 'video' - elif extension in ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp']: - return 'image' - elif extension == 'pdf': - return 'pdf' - elif extension in ['txt', 'md', 'log', 'csv', 'json', 'xml', 'html', 'css', 'js', 'py']: - return 'text' - else: - return 'other' - -def generate_unique_filename(original_filename): - name, ext = os.path.splitext(original_filename) - unique_id = uuid.uuid4().hex[:8] # Shorter UUID part - # Truncate name if necessary to stay within limits after adding UUID - max_name_len = MAX_FILENAME_LENGTH - len(ext) - len(unique_id) - 2 # -2 for "_" and potential "." issues - safe_name = "".join(c for c in name if c not in INVALID_CHARS).strip() - truncated_name = safe_name[:max_name_len] - return f"{truncated_name}_{unique_id}{ext}" - -def build_hf_path(username, current_path_list, unique_filename): - base = f"cloud_files/{username}" - if current_path_list: - folder_path = "/".join(current_path_list) - return f"{base}/{folder_path}/{unique_filename}" - else: - return f"{base}/{unique_filename}" - -def parse_path(path_str): - if not path_str: - return [] - # Sanitize: remove leading/trailing slashes, replace multiple slashes - cleaned_path = '/'.join(filter(None, path_str.strip('/').split('/'))) - # Further validation against invalid characters in each part - parts = cleaned_path.split('/') - if not all(is_safe_name(part, MAX_FOLDER_NAME_LENGTH) for part in parts): - logging.warning(f"Invalid path component detected in: {path_str}") - return None # Indicate invalid path - return parts - -def navigate_to_path(user_root, path_list): - current_level = user_root - for folder_name in path_list: - if folder_name not in current_level.get('folders', {}): - return None # Path does not exist - current_level = current_level['folders'][folder_name] - return current_level - -def get_breadcrumbs(path_str): - breadcrumbs = [{'name': 'Home', 'path': ''}] - parts = parse_path(path_str) - if parts is None: # Invalid path string - return breadcrumbs # Return only Home - current_path = [] - for part in parts: - current_path.append(part) - breadcrumbs.append({'name': part, 'path': '/'.join(current_path)}) - return breadcrumbs - -def login_required(f): - @wraps(f) - def decorated_function(*args, **kwargs): - if 'username' not in session: - flash('Please log in to access this page.', 'warning') - return redirect(url_for('login')) - # Ensure user still exists in data - data = load_data() - if session['username'] not in data.get('users', {}): - session.pop('username', None) - flash('Your user account could not be found. Please log in again.', 'error') - return redirect(url_for('login')) - return f(*args, **kwargs) - return decorated_function - # --- Data Handling --- -def initialize_user_root(): - return {'files': [], 'folders': {}} +def initialize_data_structure(): + return {'users': {}} -@cache.memoize(timeout=120) # Cache for 2 minutes +@cache.memoize() 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): - logging.warning("Data is not dict, initializing.") - data = {'users': {}} - data.setdefault('users', {}) - # Ensure all users have the new 'root' structure - for username, user_data in data['users'].items(): - if 'root' not in user_data: - logging.info(f"Initializing root structure for user: {username}") - user_data['root'] = initialize_user_root() - # Attempt to migrate old 'files' if they exist - if 'files' in user_data and isinstance(user_data['files'], list): - logging.info(f"Migrating old file list for user: {username}") - user_data['root']['files'] = user_data.pop('files') - # Ensure root always has files and folders keys - user_data['root'].setdefault('files', []) - user_data['root'].setdefault('folders', {}) - - logging.info(f"Data loaded successfully. Users: {len(data['users'])}") - return data + 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 + 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.") + return data except FileNotFoundError: - logging.warning(f"{DATA_FILE} not found. Initializing empty database.") - return {'users': {}} + logging.warning(f"{DATA_FILE} not found locally after download attempt. Initializing empty data.") + return initialize_data_structure() except json.JSONDecodeError: - logging.error(f"Error decoding {DATA_FILE}. Initializing empty database.") - # Optionally: backup the corrupted file - # os.rename(DATA_FILE, f"{DATA_FILE}.corrupted_{int(time.time())}") - return {'users': {}} + logging.error(f"Error decoding JSON from {DATA_FILE}. Initializing empty data.") + return initialize_data_structure() except Exception as e: - logging.error(f"Unexpected error loading data: {e}", exc_info=True) - return {'users': {}} # Safer fallback + logging.error(f"Unexpected error loading data: {e}") + return initialize_data_structure() def save_data(data): try: - # Create a backup before overwriting + # Create backup before overwriting if os.path.exists(DATA_FILE): - os.rename(DATA_FILE, f"{DATA_FILE}.bak") + os.rename(DATA_FILE, DATA_FILE + '.bak') with open(DATA_FILE, 'w', encoding='utf-8') as file: - json.dump(data, file, ensure_ascii=False, indent=4) + json.dump(data, file, ensure_ascii=False, indent=2) # Indent 2 for smaller file size - # Upload after successful local save upload_db_to_hf() cache.clear() logging.info("Data saved locally and upload initiated.") - - # Remove backup after successful save and upload attempt - if os.path.exists(f"{DATA_FILE}.bak"): - os.remove(f"{DATA_FILE}.bak") - + # Remove backup after successful save and upload initiation + if os.path.exists(DATA_FILE + '.bak'): + os.remove(DATA_FILE + '.bak') except Exception as e: - logging.error(f"Error saving data: {e}", exc_info=True) - # Optionally restore from backup if save failed mid-way - if os.path.exists(f"{DATA_FILE}.bak"): - try: - os.rename(f"{DATA_FILE}.bak", DATA_FILE) - logging.info("Restored data from backup due to save error.") - except Exception as restore_e: - logging.error(f"Failed to restore data from backup: {restore_e}") - raise # Re-raise the original exception + 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(): - if not HF_TOKEN_WRITE: - logging.warning("Write operations require HF_TOKEN env variable.") - return None - return HfApi() - -def _get_hf_token_read(): - return HF_TOKEN_READ +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) def upload_db_to_hf(): if not HF_TOKEN_WRITE: - logging.warning("Skipping DB upload: HF_TOKEN (write) not set.") - return - if not os.path.exists(DATA_FILE): - logging.warning(f"Skipping DB upload: {DATA_FILE} not found.") + logging.warning("Cannot upload DB: Write token not set.") return try: - api = _get_hf_api() - if api: - api.upload_file( - path_or_fileobj=DATA_FILE, - path_in_repo=DATA_FILE, - repo_id=REPO_ID, - repo_type="dataset", - token=HF_TOKEN_WRITE, # Explicitly use write token - commit_message=f"Backup {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}" - ) - logging.info(f"Database uploaded to HF repo: {REPO_ID}") - else: - logging.error("Failed to get HfApi instance for upload.") + api = get_hf_api(write=True) + api.upload_file( + path_or_fileobj=DATA_FILE, + path_in_repo=DATA_FILE, + repo_id=REPO_ID, + repo_type="dataset", + 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 to HF: {e}", exc_info=True) + logging.error(f"Error uploading database to HF: {e}") + # Consider adding retry logic or alerting def download_db_from_hf(): - token = _get_hf_token_read() - if not token: - logging.warning("Skipping DB download: No read token (HF_TOKEN_READ or HF_TOKEN) found.") - # If the file doesn't exist locally, create an empty one - if not os.path.exists(DATA_FILE): - logging.warning(f"{DATA_FILE} not found locally and cannot download. Creating empty DB.") - with open(DATA_FILE, 'w', encoding='utf-8') as f: - json.dump({'users': {}}, f) - return - + if not HF_TOKEN_READ: + logging.warning("Cannot download DB: Read token not set. Attempting anonymous download.") try: hf_hub_download( repo_id=REPO_ID, filename=DATA_FILE, repo_type="dataset", - token=token, + token=HF_TOKEN_READ, # Will be None if not set, attempting anonymous local_dir=".", local_dir_use_symlinks=False, force_download=True, # Ensure we get the latest version - etag_timeout=10 # Short timeout for checking changes + etag_timeout=10 # Short timeout for checking version ) - logging.info(f"Database downloaded/verified from HF repo: {REPO_ID}") + 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 except Exception as e: - # More specific error handling could be added (e.g., 404 Not Found vs. connection error) - logging.error(f"Error downloading database from HF: {e}. Using local version if available.") - # If download fails, check if a local file exists. If not, create an empty one. + logging.error(f"Error downloading database from HF: {e}") if not os.path.exists(DATA_FILE): - logging.warning(f"{DATA_FILE} not found locally and download failed. Creating empty DB.") - with open(DATA_FILE, 'w', encoding='utf-8') as f: - json.dump({'users': {}}, f) + 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 --- -# --- Periodic Backup --- +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 + return 'other' -def periodic_backup(): - logging.info("Periodic backup thread started.") - while True: - time.sleep(1800) # 30 minutes - logging.info("Initiating periodic backup...") - try: - # Ensure data is loaded from disk before backup in case of in-memory changes not saved - # (Though save_data should handle this, it's a safety measure) - # load_data() # Re-load might clear cache unnecessarily, rely on save_data discipline - if os.path.exists(DATA_FILE): - upload_db_to_hf() +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: - logging.warning("Periodic backup skipped: data file not found.") - except Exception as e: - logging.error(f"Error during periodic backup: {e}", exc_info=True) - -# --- Base Style --- -# (Reduced height for brevity, assumes it's defined elsewhere or kept minimal) -BASE_STYLE = ''' -:root { - --primary-color: #6a11cb; /* Deep Purple */ - --secondary-color: #2575fc; /* Bright Blue */ - --accent-color: #ff5f6d; /* Coral Pink */ - --bg-light: #f8f9fa; - --bg-dark: #1a1a2e; /* Dark Navy */ - --text-light: #343a40; - --text-dark: #e0e0fc; /* Light Lavender */ - --card-bg-light: #ffffff; - --card-bg-dark: #2a2a4a; /* Slightly Lighter Navy */ - --shadow-light: 0 4px 15px rgba(0, 0, 0, 0.1); - --shadow-dark: 0 4px 15px rgba(0, 0, 0, 0.3); - --border-radius: 12px; - --transition: all 0.3s ease; - --font-family: 'Inter', sans-serif; -} -* { margin: 0; padding: 0; box-sizing: border-box; } -body { - font-family: var(--font-family); - background: var(--bg-light); - color: var(--text-light); - line-height: 1.6; - transition: var(--transition); -} -body.dark { - background: var(--bg-dark); - color: var(--text-dark); -} -.container { - max-width: 1400px; /* Wider container */ - margin: 30px auto; - padding: 30px; - background: var(--card-bg-light); - border-radius: var(--border-radius); - box-shadow: var(--shadow-light); - transition: var(--transition); -} -body.dark .container { - background: var(--card-bg-dark); - box-shadow: var(--shadow-dark); -} -h1, h2 { - font-weight: 700; - margin-bottom: 20px; - color: var(--primary-color); -} -body.dark h1, body.dark h2 { - color: var(--secondary-color); -} -h1 { font-size: 2.2em; text-align: center; } -h2 { font-size: 1.6em; margin-top: 30px; border-bottom: 1px solid #eee; padding-bottom: 10px; } -body.dark h2 { border-bottom-color: #444; } - -input[type="text"], input[type="password"], input[type="file"], textarea { - width: 100%; - padding: 12px 15px; - margin-bottom: 15px; - border: 1px solid #ccc; - border-radius: 8px; - background-color: #fff; - font-size: 1em; - transition: var(--transition); -} -body.dark input, body.dark textarea, body.dark input[type="file"]::-webkit-file-upload-button { - background-color: var(--card-bg-dark); - border-color: #555; - color: var(--text-dark); -} -input:focus, textarea:focus { - outline: none; - border-color: var(--primary-color); - box-shadow: 0 0 0 3px rgba(106, 17, 203, 0.2); -} -body.dark input:focus, body.dark textarea:focus { - border-color: var(--secondary-color); - box-shadow: 0 0 0 3px rgba(37, 117, 252, 0.3); -} - -.btn { - padding: 12px 25px; - border: none; - border-radius: 8px; - cursor: pointer; - font-size: 1em; - font-weight: 600; - transition: var(--transition); - text-decoration: none; - display: inline-block; - text-align: center; - background: linear-gradient(45deg, var(--primary-color), var(--secondary-color)); - color: white; - box-shadow: 0 2px 5px rgba(0,0,0,0.1); -} -.btn:hover { - opacity: 0.9; - box-shadow: 0 4px 10px rgba(0,0,0,0.15); - transform: translateY(-2px); -} -.btn-secondary { - background: #6c757d; /* Bootstrap secondary */ - color: white; -} -.btn-danger { - background: var(--accent-color); - color: white; -} -.btn-small { - padding: 6px 12px; - font-size: 0.9em; -} - -.flash { - padding: 15px; - margin-bottom: 20px; - border-radius: var(--border-radius); - text-align: center; - font-weight: 500; -} -.flash.success { background-color: #d4edda; color: #155724; border: 1px solid #c3e6cb; } -.flash.error { background-color: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; } -.flash.warning { background-color: #fff3cd; color: #856404; border: 1px solid #ffeeba; } -body.dark .flash.success { background-color: #1a3a24; color: #c3e6cb; border-color: #3a5f46; } -body.dark .flash.error { background-color: #4d1f24; color: #f5c6cb; border-color: #7d3a41; } -body.dark .flash.warning { background-color: #5a480f; color: #ffeeba; border-color: #8e793e; } - -.file-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); - gap: 25px; - margin-top: 25px; -} -.item-card { - background: var(--card-bg-light); - border-radius: var(--border-radius); - box-shadow: var(--shadow-light); - padding: 15px; - text-align: center; - transition: var(--transition); - word-wrap: break-word; - position: relative; /* For action buttons */ -} -body.dark .item-card { - background: var(--card-bg-dark); - box-shadow: var(--shadow-dark); -} -.item-card:hover { - transform: translateY(-5px); - box-shadow: 0 8px 20px rgba(0, 0, 0, 0.15); -} -body.dark .item-card:hover { - box-shadow: 0 8px 20px rgba(0, 0, 0, 0.4); -} -.item-icon { - font-size: 3.5em; /* Larger icons */ - margin-bottom: 15px; - display: block; - color: var(--secondary-color); - cursor: pointer; /* Make icon clickable */ -} -.item-icon.folder { color: #ffc107; } /* Yellow for folders */ -.item-preview { - width: 100%; - height: 120px; /* Fixed height */ - object-fit: cover; /* Cover the area */ - border-radius: 8px; - margin-bottom: 10px; - background-color: #eee; /* Placeholder bg */ - cursor: pointer; -} -body.dark .item-preview { background-color: #333; } - -.item-name { - font-weight: 600; - font-size: 0.95em; - margin-bottom: 5px; - display: block; /* Ensure it takes full width */ - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} -.item-meta { - font-size: 0.8em; - color: #6c757d; - margin-bottom: 10px; -} -body.dark .item-meta { color: #aaa; } -.item-actions { - display: flex; - justify-content: center; - gap: 8px; - margin-top: 10px; - flex-wrap: wrap; /* Wrap if needed */ -} -.item-actions .btn { - padding: 6px 10px; /* Smaller buttons */ - font-size: 0.85em; -} - -/* Breadcrumbs */ -.breadcrumbs { - margin-bottom: 25px; - padding: 10px 15px; - background-color: #e9ecef; - border-radius: 8px; - font-size: 0.95em; -} -body.dark .breadcrumbs { - background-color: var(--card-bg-dark); -} -.breadcrumbs a { - color: var(--primary-color); - text-decoration: none; - font-weight: 500; -} -body.dark .breadcrumbs a { color: var(--secondary-color); } -.breadcrumbs span { - margin: 0 8px; - color: #6c757d; -} -body.dark .breadcrumbs span { color: #aaa; } -.breadcrumbs .current-page { - font-weight: 600; - color: var(--text-light); -} -body.dark .breadcrumbs .current-page { color: var(--text-dark); } - -/* Modal */ -.modal { - display: none; position: fixed; z-index: 1050; /* High z-index */ - left: 0; top: 0; width: 100%; height: 100%; - overflow: auto; background-color: rgba(0,0,0,0.85); - justify-content: center; align-items: center; padding: 20px; -} -.modal-content { - position: relative; background-color: #fff; margin: auto; padding: 0; - border: 1px solid #888; width: 85%; max-width: 1000px; - box-shadow: 0 4px 8px 0 rgba(0,0,0,0.2),0 6px 20px 0 rgba(0,0,0,0.19); - animation-name: animatetop; animation-duration: 0.4s; - border-radius: var(--border-radius); display: flex; flex-direction: column; - max-height: 90vh; /* Limit height */ -} -body.dark .modal-content { background-color: var(--card-bg-dark); border-color: #555;} -@keyframes animatetop { from {top:-300px; opacity:0} to {top:0; opacity:1} } -.modal-header { - padding: 10px 16px; background-color: var(--primary-color); color: white; - border-top-left-radius: var(--border-radius); border-top-right-radius: var(--border-radius); - display: flex; justify-content: space-between; align-items: center; -} -body.dark .modal-header { background-color: var(--secondary-color); } -.modal-title { font-size: 1.25em; font-weight: 600; } -.modal-close { - color: white; font-size: 28px; font-weight: bold; - background: none; border: none; cursor: pointer; line-height: 1; -} -.modal-body { padding: 20px; overflow-y: auto; /* Scrollable body */ flex-grow: 1; } -.modal-body img, .modal-body video, .modal-body iframe { - max-width: 100%; display: block; margin: 0 auto; border-radius: 8px; - max-height: 70vh; /* Limit media height within modal */ -} -.modal-body pre { /* For text files */ - white-space: pre-wrap; word-wrap: break-word; background: #f8f8f8; - padding: 15px; border-radius: 8px; max-height: 65vh; overflow: auto; - font-family: monospace; -} -body.dark .modal-body pre { background: #222; color: #eee; } -#modal-loader { display: none; text-align: center; padding: 30px; font-size: 1.2em; } + new_children.append(child) -#progress-container { - width: 100%; background-color: #e9ecef; border-radius: 8px; margin: 15px 0; - height: 25px; overflow: hidden; display: none; -} -body.dark #progress-container { background-color: #444; } -#progress-bar { - width: 0%; height: 100%; - background: linear-gradient(45deg, var(--primary-color), var(--secondary-color)); - border-radius: 8px; transition: width 0.3s ease; - display: flex; align-items: center; justify-content: center; - color: white; font-weight: 600; font-size: 0.9em; -} + 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 -/* Form Groups */ -.form-group { margin-bottom: 20px; } -.form-group label { display: block; margin-bottom: 5px; font-weight: 600; } +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" -/* Action Bar */ -.action-bar { - display: flex; - flex-wrap: wrap; - gap: 15px; - margin-bottom: 25px; - padding-bottom: 20px; - border-bottom: 1px solid #eee; - align-items: center; -} -body.dark .action-bar { border-bottom-color: #444; } -.action-bar .form-group { margin-bottom: 0; flex-grow: 1; } -/* Hide default file input text */ -input[type="file"] { color: transparent; } -input[type="file"]::file-selector-button { /* Style the button */ - padding: 10px 15px; border: none; border-radius: 6px; - background: var(--secondary-color); color: white; cursor: pointer; - font-weight: 500; transition: var(--transition); margin-right: 10px; -} -input[type="file"]::file-selector-button:hover { opacity: 0.9; } -body.dark input[type="file"]::file-selector-button { background: var(--primary-color); } -/* Minimalist Folder Input */ -.folder-create-wrapper { display: flex; gap: 10px; align-items: center; } -.folder-create-wrapper input { margin-bottom: 0; flex-grow: 1; } + # 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 -/* Admin User List */ -.user-list { margin-top: 20px; } -.user-item { - display: flex; justify-content: space-between; align-items: center; - padding: 15px; background: var(--card-bg-light); border-radius: var(--border-radius); - margin-bottom: 10px; box-shadow: var(--shadow-light); transition: var(--transition); -} -body.dark .user-item { background: var(--card-bg-dark); box-shadow: var(--shadow-dark); } -.user-item:hover { transform: translateX(5px); } -.user-info a { color: var(--primary-color); text-decoration: none; font-weight: 600; font-size: 1.1em; } -body.dark .user-info a { color: var(--secondary-color); } -.user-info p { font-size: 0.9em; color: #6c757d; margin-top: 3px; } -body.dark .user-info p { color: #aaa; } -.user-actions .btn { margin-left: 10px; } - -/* FontAwesome Icons (requires including FontAwesome) */ -@import url('https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.2.0/css/all.min.css'); + return url + except Exception as e: + logging.error(f"Error generating HF download URL for {hf_path}: {e}") + return None -''' -# --- Routes --- +# --- Flask Routes --- @app.route('/register', methods=['GET', 'POST']) def register(): if request.method == 'POST': - username = request.form.get('username', '').strip() + username = request.form.get('username') password = request.form.get('password') - if not is_safe_name(username, 50) or not username.isalnum(): # Basic username validation - flash('Имя пользователя должно быть буквенно-цифровым, без специальных символов и не длиннее 50 символов.', 'error') + if not username or not password: + flash('Имя пользователя и пароль обязательны!', 'error') return redirect(url_for('register')) - if not password or len(password) < 6: - flash('Пароль должен быть не менее 6 символов.', 'error') - return redirect(url_for('register')) + # Basic validation (add more robust checks as needed) + if not username.isalnum() or len(username) < 3: + flash('Имя пользователя должно быть не менее 3 символов и содержать только буквы и цифры.', '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('Пользователь с таким именем уже существует!', 'error') + flash('Пользователь с таким именем уже существует!', 'warning') 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, # In a real app, HASH the password! + 'password': password, # Store hashed password in real app 'created_at': datetime.now().strftime('%Y-%m-%d %H:%M:%S'), - 'root': initialize_user_root() + 'root_folder': create_folder_node('/', user_hf_base_path) # Initialize root folder } try: save_data(data) @@ -647,1358 +353,1905 @@ def register(): flash('Регистрация прошла успешно!', 'success') return redirect(url_for('dashboard')) except Exception as e: - flash('Ошибка при сохранении данных. Попробуйте позже.', 'error') - logging.error(f"Failed to save data during registration for {username}: {e}") - # Attempt to remove the partially added user if save failed - if username in data['users']: - del data['users'][username] # Remove from in-memory dict before potential retry + flash(f'Ошибка при сохранении данных: {e}', 'error') + # Clean up potentially partially added user? Or rely on save_data failure handling. return redirect(url_for('register')) - - html = ''' - - - - - - Регистрация - Zeus Cloud V2 - - - - -
-

Регистрация

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

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

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

Zeus Cloud V2

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

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

-
- - - -''' - return render_template_string(html) +@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=''): + if 'username' not in session: + flash('Пожалуйста, войдите в систему!', 'warning') + return redirect(url_for('login')) -@app.route('/dashboard/', defaults={'path': ''}) -@app.route('/dashboard/', methods=['GET', 'POST']) -@login_required -def dashboard(path): username = session['username'] data = load_data() - user_data = data['users'][username] - current_path_list = parse_path(path) - - if current_path_list is None: - flash('Недопустимый путь!', 'error') - return redirect(url_for('dashboard', path='')) # Redirect to root - - current_folder_obj = navigate_to_path(user_data['root'], current_path_list) - - if current_folder_obj is None: - flash('Папка не найдена!', 'error') - return redirect(url_for('dashboard', path='')) # Redirect to root + user_data = data['users'].get(username) - # Ensure structure is correct - current_folder_obj.setdefault('files', []) - current_folder_obj.setdefault('folders', {}) + if not user_data: + session.pop('username', None) + flash('Ошибка: пользователь не найден. Пожалуйста, войдите снова.', 'error') + return redirect(url_for('login')) - if request.method == 'POST': - files = request.files.getlist('files') - if not files or not files[0].filename: # Check if any file was actually selected - flash('Файлы для загрузки не выбраны.', 'warning') - return redirect(url_for('dashboard', path=path)) - - if len(files) > 20: # Limit concurrent uploads - flash('Максимум 20 файлов за раз!', 'warning') - return redirect(url_for('dashboard', path=path)) - - uploaded_count = 0 - error_count = 0 - api = _get_hf_api() - if not api: - flash('Ошибка конфигурации сервера: не удается получить доступ к хранилищу.', 'error') - return redirect(url_for('dashboard', path=path)) - - temp_upload_dir = os.path.join('uploads', username, *current_path_list) - os.makedirs(temp_upload_dir, exist_ok=True) - - for file in files: - if file and file.filename: # and allowed_file(file.filename): # Re-enable if needed - original_filename = secure_filename(file.filename) - if not is_safe_name(original_filename, MAX_FILENAME_LENGTH): - logging.warning(f"Skipping file with invalid name: {original_filename}") - error_count += 1 - continue - - unique_filename = generate_unique_filename(original_filename) - hf_path = build_hf_path(username, current_path_list, unique_filename) - temp_path = os.path.join(temp_upload_dir, unique_filename) # Save with unique name locally too - - try: - file.save(temp_path) - logging.info(f"Uploading {original_filename} as {unique_filename} to {hf_path}") + # 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 - api.upload_file( - path_or_fileobj=temp_path, - path_in_repo=hf_path, - repo_id=REPO_ID, - repo_type="dataset", - token=HF_TOKEN_WRITE, - commit_message=f"User {username} uploaded {original_filename} to {path}" - ) + 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) - file_info = { - 'original_filename': original_filename, - 'unique_filename': unique_filename, # Stored name on HF - 'path': hf_path, # Full path on HF - 'type': get_file_type(original_filename), - 'upload_date': datetime.now().strftime('%Y-%m-%d %H:%M:%S') - # Add size later if needed: 'size': os.path.getsize(temp_path) - } - current_folder_obj['files'].append(file_info) - uploaded_count += 1 - - except Exception as e: - logging.error(f"Failed to upload {original_filename}: {e}", exc_info=True) - error_count += 1 - finally: - # Clean up temp file - if os.path.exists(temp_path): - try: - os.remove(temp_path) - except OSError as e: - logging.error(f"Error removing temporary file {temp_path}: {e}") + if current_node is None or current_node.get("type") != "folder": + flash('Указанный путь не найден или не является папкой.', 'error') + return redirect(url_for('dashboard')) # Redirect to root - # Cleanup potentially empty temp dirs - try: - if not os.listdir(temp_upload_dir): - os.rmdir(temp_upload_dir) - # Could recursively go up and delete empty parent dirs if needed - except OSError: - pass # Ignore if dir is not empty or cannot be removed + items = current_node.get("children", []) + # Items are already sorted folders first, then alphabetically by add_node_to_path - if uploaded_count > 0: - try: - save_data(data) - flash(f'{uploaded_count} файл(ов) успешно загружено.', 'success') - except Exception as e: - flash('Файлы загружены в хранилище, но произошла ошибка при обновлении списка файлов. Попробуйте обновить страницу.', 'error') - logging.error(f"Failed to save data after uploads for {username}: {e}") + # Breadcrumbs + breadcrumbs = [{'name': 'Главная', 'path': ''}] + cumulative_path = [] + for segment in path_segments: + cumulative_path.append(segment) + breadcrumbs.append({'name': segment, 'path': '/'.join(cumulative_path)}) - if error_count > 0: - flash(f'Не удалось загрузить {error_count} файл(ов). Проверьте логи сервера для деталей.', 'error') + 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 + ) - # Use JS for progress bar, redirect might interrupt it - # Return JSON for AJAX upload handler if implemented, otherwise redirect - # For now, simple redirect - return redirect(url_for('dashboard', path=path)) +@app.route('/upload', methods=['POST']) +def upload_files(): + if 'username' not in session: + return jsonify({'status': 'error', 'message': 'Не авторизован'}), 401 - # GET request or after POST redirect - sorted_files = sorted(current_folder_obj['files'], key=lambda x: x.get('upload_date', ''), reverse=True) - sorted_folders = sorted(current_folder_obj['folders'].keys()) - breadcrumbs = get_breadcrumbs(path) + 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] - html = ''' - - - - - - Панель управления - Zeus Cloud V2 - - - - - - - -
-
-

Панель управления

-
- {{ username }} - Выйти -
-
+ 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)) - {% with messages = get_flashed_messages(with_categories=true) %} - {% if messages %} - {% for category, message in messages %} -
{{ message }}
- {% endfor %} - {% endif %} - {% endwith %} + if len(files) > 20: # Limit simultaneous uploads + flash('Максимум 20 файлов за раз!', 'warning') + return redirect(url_for('dashboard', current_folder_path=current_path)) - - - - -
-
-
- - -
- -
-
-
- - |]+" maxlength="100"> - -
-
-
-
0%
- - -

Содержимое папки '{{ breadcrumbs[-1].name }}'

-
- - {% for folder_name in sorted_folders %} - -
- - {{ folder_name }} -
-
- -
-
-
-
- {% endfor %} + data = load_data() + user_data = data['users'].get(username) + if not user_data: + flash('Ошибка: пользователь не найден.', 'error') + return redirect(url_for('login')) - - {% for file in sorted_files %} -
- {% set hf_file_url = get_hf_file_url(file.path) %} - {% if file.type == 'image' %} - {{ file.original_filename }} - {% elif file.type == 'video' %} - - {% elif file.type == 'pdf' %} - - {% elif file.type == 'text' %} - - {% else %} - {# Generic icon based on extension maybe? #} - {% set ext = file.original_filename.split('.')[-1].lower() %} - {% if ext in ['zip', 'rar', '7z'] %} - {% elif ext in ['doc', 'docx'] %} - {% elif ext in ['xls', 'xlsx'] %} - {% elif ext in ['ppt', 'pptx'] %} - {% else %} - {% endif %} - {% endif %} - {{ file.original_filename }} -

{{ file.upload_date }}

{# Add size later #} -
- Скачать -
- -
-
-
- {% endfor %} + root_node = user_data['root_folder'] + target_node = find_node_by_path(root_node, path_segments) - {% if not sorted_folders and not sorted_files %} -

Папка пуста.

- {% endif %} -
+ if not target_node or target_node.get("type") != "folder": + flash('Целевая папка не найдена.', 'error') + return redirect(url_for('dashboard')) - -
-

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

-

Для быстрого доступа добавьте Zeus Cloud на главный экран телефона (PWA).

-
    -
  1. Android (Chrome): Меню (три точки) -> "Установить приложение" или "Добавить на главный экран".
  2. -
  3. iOS (Safari): Кнопка "Поделиться" (квадрат со стрелкой) -> "На экран «Домой»".
  4. -
-
+ 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 = [] - - - -''' - # Pass helper functions/variables to template - from urllib.parse import quote, unquote - - def encode_path_for_url(path): - # Simple base64 encoding for path to avoid issues with slashes in URL parameters - # Not true security, just for URL routing convenience. - import base64 - return base64.urlsafe_b64encode(path.encode()).decode() - - def get_hf_file_url_template(file_path): - # Use hf_hub_url which handles repo type and endpoint correctly - # Note: This doesn't automatically add tokens. Do that in JS if needed. - try: - return hf_hub_url(repo_id=REPO_ID, filename=file_path, repo_type="dataset") - except Exception as e: - logging.error(f"Error generating HF URL for {file_path}: {e}") - # Fallback or placeholder - return f"https://huggingface.co/datasets/{REPO_ID}/resolve/main/{file_path}" # Older method + 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) - return render_template_string( - html, - username=username, - user_files=sorted_files, - sorted_folders=sorted_folders, - path=path, - breadcrumbs=breadcrumbs, - REPO_ID=REPO_ID, - encode_path=encode_path_for_url, # Pass encoding function - get_hf_file_url=get_hf_file_url_template # Pass URL generation function - ) + if not parent_node or parent_node.get("type") != "folder": + flash('Родительская папка не найдена.', 'error') + return redirect(url_for('dashboard')) -@app.route('/create_folder', methods=['POST']) -@login_required -def create_folder(): - username = session['username'] - current_path_str = request.form.get('current_path', '') - new_folder_name = request.form.get('new_folder_name', '').strip() + # 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)) - if not is_safe_name(new_folder_name, MAX_FOLDER_NAME_LENGTH): - flash(f'Недопустимое имя папки. Имя не должно содержать символы {INVALID_CHARS} и быть длиннее {MAX_FOLDER_NAME_LENGTH} символов.', 'error') - return redirect(url_for('dashboard', path=current_path_str)) + # 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 / - data = load_data() - user_data = data['users'][username] - current_path_list = parse_path(current_path_str) + folder_node = create_folder_node(safe_folder_name, new_folder_hf_path) - if current_path_list is None: - flash('Недопустимый текущий путь!', 'error') - return redirect(url_for('dashboard', 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, ...) - parent_folder_obj = navigate_to_path(user_data['root'], current_path_list) + 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') - if parent_folder_obj is None: - flash('Родитель��кая папка не найдена!', 'error') - return redirect(url_for('dashboard', path='')) - parent_folder_obj.setdefault('folders', {}) # Ensure 'folders' key exists + return redirect(url_for('dashboard', current_folder_path=current_path)) - if new_folder_name in parent_folder_obj['folders']: - flash(f'Папка с именем "{new_folder_name}" уже существует здесь.', 'warning') - else: - parent_folder_obj['folders'][new_folder_name] = initialize_user_root() # Create new empty folder structure - try: - save_data(data) - flash(f'Папка "{new_folder_name}" успешно создана.', 'success') - logging.info(f"User {username} created folder '{new_folder_name}' at path '{current_path_str}'") - except Exception as e: - flash('Ошибка при сохранении данных. Не удалось создать папку.', 'error') - logging.error(f"Failed to save data after creating folder for {username}: {e}") - # Rollback the change in memory if save failed - if new_folder_name in parent_folder_obj['folders']: - del parent_folder_obj['folders'][new_folder_name] - return redirect(url_for('dashboard', path=current_path_str)) +@app.route('/delete_item', methods=['POST']) +def delete_item(): + if 'username' not in session: + return jsonify({'status': 'error', 'message': 'Не авторизован'}), 401 -@app.route('/delete_folder', methods=['POST']) -@login_required -def delete_folder(): username = session['username'] - folder_path_str = request.form.get('folder_path', '') # The path to the folder to delete + 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 folder_path_str: - flash('Не указан путь к папке для удаления.', 'error') - return redirect(url_for('dashboard', path='')) + 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'][username] - folder_path_list = parse_path(folder_path_str) + user_data = data['users'].get(username) + if not user_data: + flash('Ошибка: пользователь не найден.', 'error') + return redirect(url_for('login')) - if folder_path_list is None: - flash('Недопустимый путь к папке.', 'error') - return redirect(url_for('dashboard', path='')) + root_node = user_data['root_folder'] - # Find the parent folder and the name of the folder to delete - if not folder_path_list: # Cannot delete root - flash('Нельзя удалить корневую папку.', 'error') - return redirect(url_for('dashboard', path='')) + # 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] - folder_name_to_delete = folder_path_list[-1] - parent_path_list = folder_path_list[:-1] - parent_folder_obj = navigate_to_path(user_data['root'], parent_path_list) + # 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 / - if parent_folder_obj is None or folder_name_to_delete not in parent_folder_obj.get('folders', {}): - flash('Папка не найдена или не существует.', 'error') - # Determine redirect path (parent path or root) - parent_redirect_path = '/'.join(parent_path_list) - return redirect(url_for('dashboard', path=parent_redirect_path)) + logging.info(f"Attempting to delete '{item_name}' (type: {item_type}) with HF path '{item_path}'. Parent segments: {parent_segments}") - # --- Deletion Logic --- - api = _get_hf_api() - if not api: - flash('Ошибка конфигурации сервера: не удается по��учить доступ к хранилищу.', 'error') - parent_redirect_path = '/'.join(parent_path_list) - return redirect(url_for('dashboard', path=parent_redirect_path)) + removed_node = remove_node_by_path(root_node, parent_segments, item_name) - hf_folder_path = f"cloud_files/{username}/{folder_path_str}" - - try: - logging.info(f"Attempting to delete HF folder: {hf_folder_path}") - # Use HfApi.delete_folder - This should handle non-empty folders - api.delete_folder( - folder_path=hf_folder_path, - repo_id=REPO_ID, - repo_type="dataset", - token=HF_TOKEN_WRITE, - commit_message=f"User {username} deleted folder {folder_path_str}" - ) - logging.info(f"Successfully deleted HF folder: {hf_folder_path}") + 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.") - # If HF deletion is successful (or if it didn't exist - check API behavior), remove from JSON - del parent_folder_obj['folders'][folder_name_to_delete] - save_data(data) - flash(f'Папка "{folder_name_to_delete}" и её содержимое успешно удалены.', 'success') - logging.info(f"User {username} successfully deleted folder '{folder_path_str}'") + # 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: - # More specific error handling (e.g., huggingface_hub.utils.RepositoryNotFoundError) - logging.error(f"Error deleting folder '{folder_path_str}' for user {username}: {e}", exc_info=True) - # Check if it's a 'not found' error - maybe already deleted? - if "404" in str(e) or "not found" in str(e).lower(): - logging.warning(f"Folder {hf_folder_path} likely already deleted on HF. Removing from local DB.") - # Proceed to remove from JSON if it exists there - if folder_name_to_delete in parent_folder_obj.get('folders', {}): - del parent_folder_obj['folders'][folder_name_to_delete] - try: - save_data(data) - flash(f'Папка "{folder_name_to_delete}" удалена (возможно, уже была удалена из хранилища).', 'warning') - except Exception as save_e: - flash('Ошибка при обновлении списка папок после удаления.', 'error') - logging.error(f"Failed to save data after attempting local delete of folder {folder_name_to_delete}: {save_e}") - else: - flash(f'Папка "{folder_name_to_delete}" не найдена.', 'error') + 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') - else: - # Other HF API error or save_data error - flash(f'Ошибка при удалении папки "{folder_name_to_delete}". {e}', 'error') + return redirect(url_for('dashboard', current_folder_path=current_view_path)) - parent_redirect_path = '/'.join(parent_path_list) - return redirect(url_for('dashboard', path=parent_redirect_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')) -@app.route('/download/') -@login_required -def download_file(file_path_encoded): username = session['username'] - # Decode the path - import base64 - try: - file_path = base64.urlsafe_b64decode(file_path_encoded.encode()).decode() - except Exception: - flash('Неверная ссылка на файл.', 'error') - return redirect(request.referrer or url_for('dashboard')) - - # Basic validation of the decoded path - if not file_path.startswith(f"cloud_files/{username}/"): - # Check if it's an admin request coming from the admin panel - # This check is basic; a proper role/permission system is better - is_admin_request = request.referrer and 'admhosto' in request.referrer - if not is_admin_request: - flash('Доступ запрещен.', 'error') - logging.warning(f"User {username} attempted to access unauthorized path: {file_path}") - # Redirect back to where they came from, or their root dashboard - return redirect(request.referrer or url_for('dashboard')) - else: - # Admin access allowed (assuming admin is logged in, though no explicit check here) - logging.info(f"Admin access granted for download: {file_path}") - pass # Allow admin download + # 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. - # Find the file in the data structure to get the original filename data = load_data() - original_filename = "downloaded_file" # Default - file_found = False - - # Need to search through the structure - path_parts = file_path.split('/')[2:] # Get parts after cloud_files/username/ - unique_filename = path_parts[-1] - folder_path_list = path_parts[:-1] - - # Navigate to the folder containing the file - target_folder = navigate_to_path(data['users'].get(username, {}).get('root', {}), folder_path_list) - - if target_folder: - for file_info in target_folder.get('files', []): - if file_info.get('path') == file_path: - original_filename = file_info.get('original_filename', unique_filename) - file_found = True - break - - # Admin check - if file wasn't found under session user, check the actual user in path - if not file_found and 'is_admin_request' in locals() and is_admin_request: - path_user = file_path.split('/')[1] - if path_user != username and path_user in data['users']: - target_folder_admin = navigate_to_path(data['users'][path_user]['root'], folder_path_list) - if target_folder_admin: - for file_info in target_folder_admin.get('files', []): - if file_info.get('path') == file_path: - original_filename = file_info.get('original_filename', unique_filename) - file_found = True - break - - if not file_found: - # Even if file not in DB, maybe it exists on HF? Try downloading anyway? - # Or just fail here. Let's fail for consistency. - flash('Файл не найден в базе данных.', 'error') - logging.warning(f"File metadata not found in DB for path: {file_path}") - return redirect(request.referrer or url_for('dashboard')) - - # --- Proceed with HF Download --- - token = _get_hf_token_read() - headers = {} - if token: - headers["authorization"] = f"Bearer {token}" - - # Use hf_hub_url to get the correct download URL - try: - file_url = hf_hub_url(repo_id=REPO_ID, filename=file_path, repo_type="dataset") - # Append download=true manually if hf_hub_url doesn't add it - if '?' in file_url: - file_url += '&download=true' - else: - file_url += '?download=true' + 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 - logging.info(f"Attempting download from URL: {file_url.split('?')[0]}?...") # Log URL without query params - response = requests.get(file_url, headers=headers, stream=True) - response.raise_for_status() # Check for HTTP errors (4xx, 5xx) + file_node = find_file_by_hf_path(user_data.get('root_folder'), item_hf_path) - # Get content type if possible - content_type = response.headers.get('Content-Type', 'application/octet-stream') + if not file_node: + flash('Файл не найден в ваших записях или у вас нет доступа.', 'error') + # Redirect back to dashboard or a relevant path? Hard to know original location. + return redirect(url_for('dashboard')) - # Stream the download - return send_file( - BytesIO(response.content), # Consider streaming for large files if needed - mimetype=content_type, - as_attachment=True, - download_name=original_filename # Use the original filename for the user - ) + 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"Error downloading file '{file_path}' from HF: {e}") - flash(f'Ошибка скачивания файла с сервера ({e}).', 'error') - return redirect(request.referrer or url_for('dashboard')) + logging.error(f"Network error downloading file from HF: {e}") + flash('Ошибка сети при скачивании файла.', 'error') except Exception as e: - logging.error(f"Unexpected error during download of '{file_path}': {e}", exc_info=True) + logging.error(f"Unexpected error during download preparation for {original_filename}: {e}") flash('Произошла непредвиденная ошибка при скачивании файла.', 'error') - return redirect(request.referrer or url_for('dashboard')) + # 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) -@app.route('/delete_file', methods=['POST']) -@login_required -def delete_file(): username = session['username'] - # Decode the path - import base64 - file_path_encoded = request.form.get('file_path_encoded') - if not file_path_encoded: - flash('Не указан файл для удаления.', 'error') - return redirect(request.referrer or url_for('dashboard')) + 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 - try: - file_path = base64.urlsafe_b64decode(file_path_encoded.encode()).decode() - except Exception: - flash('Неверная ссылка на файл.', 'error') - return redirect(request.referrer or url_for('dashboard')) + file_node = find_file_by_hf_path(user_data.get('root_folder'), item_hf_path) - # Validate ownership (user must own the file path) - if not file_path.startswith(f"cloud_files/{username}/"): - flash('Доступ запрещен.', 'error') - logging.warning(f"User {username} attempted unauthorized delete on path: {file_path}") - return redirect(request.referrer or url_for('dashboard')) + if not file_node or file_node.get("file_type") != 'text': + return Response("File not found or not a text file", status=404) - data = load_data() - user_data = data['users'][username] + file_url = get_hf_download_url(file_node) + if not file_url: + return Response("Could not generate download URL", status=500) - # Find the file's parent folder in the data structure - path_parts = file_path.split('/')[2:] # Get parts after cloud_files/username/ - unique_filename = path_parts[-1] - folder_path_list = path_parts[:-1] + 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 - target_folder_obj = navigate_to_path(user_data['root'], folder_path_list) - file_info_index = -1 - original_filename = unique_filename # Fallback name + # 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') - if target_folder_obj and 'files' in target_folder_obj: - for i, file_info in enumerate(target_folder_obj['files']): - if file_info.get('path') == file_path: - file_info_index = i - original_filename = file_info.get('original_filename', unique_filename) - break + # Return as plain text + return Response(text_content, mimetype='text/plain; charset=utf-8') - # --- Deletion Logic --- - api = _get_hf_api() - if not api: - flash('Ошибка конфигурации сервера: не удается получить доступ к хранилищу.', 'error') - return redirect(request.referrer or url_for('dashboard', path='/'.join(folder_path_list))) + 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) - try: - logging.info(f"Attempting to delete HF file: {file_path}") - api.delete_file( - path_in_repo=file_path, - repo_id=REPO_ID, - repo_type="dataset", - token=HF_TOKEN_WRITE, - commit_message=f"User {username} deleted file {original_filename}" - ) - logging.info(f"Successfully deleted HF file: {file_path}") - # If HF deletion successful (or file not found on HF), remove from JSON - if file_info_index != -1: - del target_folder_obj['files'][file_info_index] - save_data(data) - flash(f'Файл "{original_filename}" успешно удален.', 'success') - logging.info(f"User {username} successfully deleted file record: {file_path}") - else: - # File was deleted from HF, but not found in local DB (maybe inconsistent state?) - flash(f'Файл "{original_filename}" удален из хранилища, но не найден в локальном списке.', 'warning') - logging.warning(f"File record not found in DB for deleted path: {file_path}") - # Optionally, still save data if other changes occurred? For now, no save needed. +# --- Admin Routes (Simplified) --- - except Exception as e: - logging.error(f"Error deleting file '{file_path}' for user {username}: {e}", exc_info=True) - # Check if it's a 'not found' error - maybe already deleted? - if "404" in str(e) or "not found" in str(e).lower(): - logging.warning(f"File {file_path} likely already deleted on HF. Removing from local DB if exists.") - if file_info_index != -1: - del target_folder_obj['files'][file_info_index] - try: - save_data(data) - flash(f'Файл "{original_filename}" удален (возможно, уже был удален из хранилища).', 'warning') - except Exception as save_e: - flash('Ошибка при обновлении списка файлов после удаления.', 'error') - logging.error(f"Failed to save data after attempting local delete of file {file_path}: {save_e}") - else: - flash(f'Файл "{original_filename}" не найден.', 'error') - else: - flash(f'Ошибка при удалении файла "{original_filename}". {e}', 'error') +@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')) - return redirect(url_for('dashboard', path='/'.join(folder_path_list))) + 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 -@app.route('/logout') -def logout(): - username = session.pop('username', None) - # No need for client-side local storage clearing here anymore, - # the login page JS handles clearing on failed auto-login or successful manual login. - # Manual logout on server is sufficient. - if username: - flash('Вы успешно вышли из системы.', 'success') - logging.info(f"User {username} logged out.") - return redirect(url_for('login')) + 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 + }) -# --- Admin Routes (Keep basic for now) --- -# WARNING: These routes lack proper admin authentication! Add checks! -def admin_required(f): - @wraps(f) - def decorated_function(*args, **kwargs): - # FIXME: Implement actual admin check here! - # Example: if session.get('is_admin') != True: - if session.get('username') != 'admin': # Example: only user 'admin' is admin - flash('Доступ запрещен. Требуются права администратора.', 'error') - return redirect(url_for('login')) - return f(*args, **kwargs) - return decorated_function + 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') -@admin_required -def admin_panel(): - data = load_data() - users = data.get('users', {}) - - # Calculate total files per user (recursive helper needed) - def count_files_recursive(folder_obj): - count = len(folder_obj.get('files', [])) - for sub_folder in folder_obj.get('folders', {}).values(): - count += count_files_recursive(sub_folder) - return count - - users_with_counts = {} - for uname, udata in users.items(): - file_count = count_files_recursive(udata.get('root', initialize_user_root())) - users_with_counts[uname] = { - 'created_at': udata.get('created_at', 'N/A'), - 'file_count': file_count - } - html = ''' - - - - - Админ-панель - Zeus Cloud V2 - - - - - -
-

Админ-панель

- Назад в Дашборд - {% with messages = get_flashed_messages(with_categories=true) %} - {% if messages %}
- {% for category, message in messages %}
{{ message }}
{% endfor %} -
{% endif %} - {% endwith %} -

Список пользователей

-
- {% for username, user_info in users_with_counts.items() %} -
- - -
- {% else %} -

Пользователей пока нет.

- {% endfor %} -
-
- - -''' - return render_template_string(html, users_with_counts=users_with_counts) +@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')) -@app.route('/admhosto/user/', defaults={'path': ''}) -@app.route('/admhosto/user//') -@admin_required -def admin_user_files(username, path): data = load_data() - if username not in data.get('users', {}): - flash(f'Пользователь "{username}" не найден!', 'error') + user_data = data['users'].get(username) + if not user_data: + flash(f'Пользователь "{username}" не найден.', 'error') return redirect(url_for('admin_panel')) - user_data = data['users'][username] - current_path_list = parse_path(path) - if current_path_list is None: - flash('Недопустимый путь!', 'error') - return redirect(url_for('admin_user_files', username=username, path='')) - - current_folder_obj = navigate_to_path(user_data.get('root', initialize_user_root()), current_path_list) - - if current_folder_obj is None: - flash('Папка не найдена для этого пользователя!', 'error') - return redirect(url_for('admin_user_files', username=username, path='')) - - current_folder_obj.setdefault('files', []) - current_folder_obj.setdefault('folders', {}) - - sorted_files = sorted(current_folder_obj['files'], key=lambda x: x.get('upload_date', ''), reverse=True) - sorted_folders = sorted(current_folder_obj['folders'].keys()) - breadcrumbs = get_breadcrumbs(path) - # Modify breadcrumb URLs for admin view - admin_breadcrumbs = [{'name': 'Admin', 'path': url_for('admin_panel')}, {'name': username, 'path': url_for('admin_user_files', username=username)}] - current_admin_path_base = url_for('admin_user_files', username=username) - temp_path = [] - for part in current_path_list: - temp_path.append(part) - admin_breadcrumbs.append({'name': part, 'path': f"{current_admin_path_base}/{'/'.join(temp_path)}"}) - - - # Need encode_path and get_hf_file_url for the template - from urllib.parse import quote - import base64 - def encode_path_for_url(path): - return base64.urlsafe_b64encode(path.encode()).decode() - def get_hf_file_url_template(file_path): - try: return hf_hub_url(repo_id=REPO_ID, filename=file_path, repo_type="dataset") - except: return f"https://huggingface.co/datasets/{REPO_ID}/resolve/main/{file_path}" - - html = ''' - - Файлы пользователя {{ username }} - Админ - - - -

Файлы пользователя: {{ username }}

- - {% with messages = get_flashed_messages(with_categories=true) %} {% if messages %} {% for category, message in messages %}
{{ message }}
{% endfor %} {% endif %} {% endwith %} -
- - {% for folder_name in sorted_folders %} - -
{{ folder_name }} -
- {# Admin cannot easily delete user folders from here without complex logic #} - {# Recommend deleting user entirely or managing via user's own dashboard #} - (Управление папками через панель пользователя) -
-
-
- {% endfor %} - - {% for file in sorted_files %} -
- {% set hf_file_url = get_hf_file_url(file.path) %} - {# Simplified preview logic for admin #} - {% if file.type == 'image' %} {{ file.original_filename }} - {% elif file.type == 'video' %} - {% elif file.type == 'pdf' %} - {% elif file.type == 'text' %} - {% else %} {% set ext = file.original_filename.split('.')[-1].lower() %} {% if ext in ['zip', 'rar', '7z'] %} {% else %} {% endif %} {% endif %} - {{ file.original_filename }} -

{{ file.upload_date }}

-
- {# Admin download uses the same download route, which has checks #} - Скачать - {# Admin delete file needs a separate route/logic #} -
-
-
- {% endfor %} - {% if not sorted_folders and not sorted_files %}

Папка пуста.

{% endif %} -
- Назад к списку пользователей -
- ''' - return render_template_string(html, username=username, path=path, sorted_files=sorted_files, sorted_folders=sorted_folders, admin_breadcrumbs=admin_breadcrumbs, REPO_ID=REPO_ID, encode_path=encode_path_for_url, get_hf_file_url=get_hf_file_url_template) + # 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']) -@admin_required def admin_delete_user(username): - admin_username = session['username'] # For logging - if username == admin_username: # Prevent admin from deleting themselves easily - flash('Администратор не может удалить сам себя через эту форму.', 'error') - return redirect(url_for('admin_panel')) + # 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.get('users', {}): - flash(f'Пользователь "{username}" не найден!', 'error') + if username not in data['users']: + flash(f'Пользователь "{username}" не найден.', 'error') return redirect(url_for('admin_panel')) - api = _get_hf_api() - if not api: - flash('Ошибка конфигурации сервера: не удается получить доступ к хранилищу.', '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 += '/' - hf_user_folder_path = f"cloud_files/{username}" try: - logging.warning(f"Admin '{admin_username}' initiating deletion of user '{username}' and folder '{hf_user_folder_path}'") - # Attempt to delete the entire user folder on Hugging Face - api.delete_folder( - folder_path=hf_user_folder_path, - repo_id=REPO_ID, - repo_type="dataset", - token=HF_TOKEN_WRITE, - commit_message=f"Admin {admin_username} deleted user {username} and all files" - # ignore_patterns=["*.keep"] # Example if needed - ) - logging.info(f"Successfully deleted HF folder '{hf_user_folder_path}' for user '{username}'.") + # 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: - # Check if folder not found (maybe already deleted or never existed) - if "404" in str(e) or "not found" in str(e).lower(): - logging.warning(f"HF folder '{hf_user_folder_path}' not found during deletion for user '{username}'. Proceeding with DB removal.") - else: - # Log the error but proceed with DB deletion - admin action - logging.error(f"Error deleting HF folder '{hf_user_folder_path}' for user '{username}': {e}. Proceeding with DB removal anyway.", exc_info=True) - flash(f'Ошибка при удалении файлов пользователя {username} из хранилища, но пользователь будет удален из базы данных.', 'warning') + 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 - # Regardless of HF deletion result (unless it was a critical config error handled earlier), remove user from DB - try: - del data['users'][username] - save_data(data) - flash(f'Пользователь "{username}" и его файлы (если были) успешно удалены.', 'success') - logging.info(f"Admin '{admin_username}' successfully deleted user '{username}' from database.") - except Exception as e: - flash(f'Ошибка при удалении пользователя "{username}" из базы данных.', 'error') - logging.error(f"Failed to save data after deleting user '{username}' from DB: {e}") + return redirect(url_for('admin_panel')) - return redirect(url_for('admin_panel')) +# --- Templates and Styles --- -@app.route('/admhosto/delete_file/', methods=['POST']) -@admin_required -def admin_delete_file(username): - admin_username = session['username'] # For logging - import base64 - file_path_encoded = request.form.get('file_path_encoded') - if not file_path_encoded: - flash('Не указан файл для удаления.', 'error') - return redirect(request.referrer or url_for('admin_user_files', username=username)) +# 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'); - try: - file_path = base64.urlsafe_b64decode(file_path_encoded.encode()).decode() - except Exception: - flash('Неверная ссылка на файл.', 'error') - return redirect(request.referrer or url_for('admin_user_files', username=username)) +: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; +} - # Validate the path structure belongs to the specified user - if not file_path.startswith(f"cloud_files/{username}/"): - flash('Путь к файлу не соответствует пользователю.', 'error') - logging.warning(f"Admin '{admin_username}' attempted delete with mismatched path/user: {file_path} for {username}") - return redirect(request.referrer or url_for('admin_user_files', username=username)) +* { margin: 0; padding: 0; box-sizing: border-box; } - data = load_data() - if username not in data.get('users', {}): - flash(f'Пользователь "{username}" не найден!', 'error') - return redirect(url_for('admin_panel')) +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); +} - user_data = data['users'][username] +body.dark { + background-color: var(--bg-dark); + color: var(--text-dark); +} - # Find the file in the data structure - path_parts = file_path.split('/')[2:] - unique_filename = path_parts[-1] - folder_path_list = path_parts[:-1] - target_folder_obj = navigate_to_path(user_data.get('root', initialize_user_root()), folder_path_list) - - file_info_index = -1 - original_filename = unique_filename - if target_folder_obj and 'files' in target_folder_obj: - for i, file_info in enumerate(target_folder_obj['files']): - if file_info.get('path') == file_path: - file_info_index = i - original_filename = file_info.get('original_filename', unique_filename) - break +.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); +} - # --- Deletion Logic --- - api = _get_hf_api() - if not api: - flash('Ошибка конфигурации сервера: не удается получить доступ к хранилищу.', 'error') - return redirect(request.referrer or url_for('admin_user_files', username=username, path='/'.join(folder_path_list))) +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); } - try: - logging.info(f"Admin '{admin_username}' attempting to delete HF file: {file_path} for user '{username}'") - api.delete_file( - path_in_repo=file_path, - repo_id=REPO_ID, - repo_type="dataset", - token=HF_TOKEN_WRITE, - commit_message=f"Admin {admin_username} deleted file {original_filename} for user {username}" - ) - logging.info(f"Admin '{admin_username}' successfully deleted HF file: {file_path}") +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); } - # Remove from DB if found - if file_info_index != -1: - del target_folder_obj['files'][file_info_index] - save_data(data) - flash(f'Файл "{original_filename}" пользователя "{username}" успешно удален.', 'success') - logging.info(f"Admin '{admin_username}' successfully deleted file record: {file_path}") - else: - flash(f'Файл "{original_filename}" удален из хранилища, но не найден в базе данных пользователя "{username}".', 'warning') - logging.warning(f"Admin '{admin_username}' deleted file {file_path} from HF, but record not found in DB.") +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); } - except Exception as e: - logging.error(f"Admin '{admin_username}' error deleting file '{file_path}' for user '{username}': {e}", exc_info=True) - if "404" in str(e) or "not found" in str(e).lower(): - logging.warning(f"File {file_path} already deleted on HF (Admin action). Removing from local DB if exists.") - if file_info_index != -1: - del target_folder_obj['files'][file_info_index] - try: - save_data(data) - flash(f'Файл "{original_filename}" удален (был удален из хранилища).', 'warning') - except Exception as save_e: - flash('Ошибка при обновлении списка файлов после удаления.', 'error') - logging.error(f"Failed to save data after admin attempted local delete of file {file_path}: {save_e}") - else: - flash(f'Файл "{original_filename}" не найден.', 'error') - else: - flash(f'Ошибка при удалении файла "{original_filename}" пользователя "{username}". {e}', 'error') +.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; } - # Redirect back to the user's file view in admin panel - return redirect(url_for('admin_user_files', username=username, path='/'.join(folder_path_list))) +.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); } -# --- App Initialization --- -from datetime import timedelta +.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 */ -if __name__ == '__main__': - app.permanent_session_lifetime = timedelta(days=30) # Set session timeout +.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 }} + Выйти + + +
+
+ + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} +
{{ message }}
+ {% endfor %} + {% endif %} + {% endwith %} + +

Действия

+
+
+ + + +
+
+
+
+
+ +
+
+ + + +
+
+ + +

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

+ + +
+ {% if not items %} +

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

+ + {% if item.type == 'folder' %} + +
+ {{ item.name }} +

Папка

{# Placeholder for size/date #} +
+
+
+ + + + + +
+
+ {% 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 #} +
+ +
+ + + + + +
+
+ {% endif %} +
+ {% endfor %} +
+ +

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

+

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

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

Админ-панель Zeus Cloud

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

Список пользователей

+
+ {% if not users %} +

Пользователей пока нет.

+ {% endif %} + {% for user in users %} +
+
+ {{ user.username }} +

Дата регистрации: {{ user.created_at }}

+

Количество файлов: {{ user.file_count }}

+
+
+ Файлы +
+ +
+
+
+ {% endfor %} +
+ +
+ + + +''' + +ADMIN_USER_FILES_TEMPLATE = ''' + + + + + + Файлы пользователя {{ target_username }} - Zeus Cloud + + + + + +
+

Файлы пользователя: {{ target_username }}

+ Назад к пользователям + + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} +
{{ message }}
+ {% endfor %} + {% endif %} + {% endwith %} + + {# Basic listing - Add breadcrumbs and folder navigation if needed #} +

Корневая папка пользователя

- # Initial check/creation of data file and uploads dir - if not os.path.exists(DATA_FILE): - logging.info(f"{DATA_FILE} not found, attempting initial download or creation.") - # Try downloading first, it will create empty if download fails/no token - download_db_from_hf() - if not os.path.exists(DATA_FILE): # Still doesn't exist - with open(DATA_FILE, 'w', encoding='utf-8') as f: - json.dump({'users': {}}, f) - logging.info(f"Created empty {DATA_FILE}.") +
+ {% if not items %} +

У этого пользователя пока нет файлов или папок.

+ + {% if item.type == 'folder' %} + {# Admin folder navigation would go here #} + {# Placeholder link #} +
+ {{ item.name }} +

Папка

+
+
+ {# Admin delete folder would need form similar to file delete #} + +
+ {% 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' %} +
+ {% elif item.file_type == 'pdf' %} +
+ {% elif item.file_type == 'text' %} +
+ {% else %} +
+ {% endif %} +
+ {{ item.original_filename }} +

{{ item.upload_date }}

+
+ + {# Admin delete file needs a form - simpler to reuse user delete logic #} + {# This requires an admin-specific delete endpoint or check #} +
+ + + + {# Maybe redirect back to admin user view #} + {# Flag for delete_item logic #} + +
+
+ {% endif %} +
+ {% endfor %} +
- # Token checks + + + + + + + + +''' + +# --- PWA Files --- +# Create these files in your project directory + +# manifest.json (place in root or static) +MANIFEST_JSON_CONTENT = """ +{ + "name": "Zeus Cloud", + "short_name": "ZeusCloud", + "description": "Personal Cloud Storage powered by Zeus", + "start_url": "/", + "display": "standalone", + "background_color": "#1E1E2E", + "theme_color": "#4A90E2", + "icons": [ + { + "src": "/static/icon-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/static/icon-512x512.png", + "sizes": "512x512", + "type": "image/png" + } + ] +} +""" + +# service-worker.js (place in root) +SERVICE_WORKER_JS_CONTENT = """ +const CACHE_NAME = 'zeus-cloud-cache-v1'; +const urlsToCache = [ + '/', + '/dashboard', // Cache main dashboard route if possible (might be dynamic) + // Add paths to your BASE_STYLE if served separately, or other static assets + // '/static/style.css', + // '/static/icon-192x192.png', + // '/static/icon-512x512.png', + 'https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.2.0/css/all.min.css', // Cache external CSS + 'https://fonts.googleapis.com/css2?family=Inter:wght@400;600;800&display=swap' // Cache external font CSS +]; + +self.addEventListener('install', event => { + // Perform install steps + event.waitUntil( + caches.open(CACHE_NAME) + .then(cache => { + console.log('Opened cache'); + // AddAll can fail if one request fails. Consider adding individually. + return cache.addAll(urlsToCache.map(url => new Request(url, { mode: 'no-cors' }))) // Use no-cors for external resources if needed, might prevent caching them properly + .catch(error => console.error('Failed to cache initial resources:', error)); + }) + ); +}); + +self.addEventListener('fetch', event => { + event.respondWith( + caches.match(event.request) + .then(response => { + // Cache hit - return response + if (response) { + return response; + } + + // IMPORTANT: Clone the request. A request is a stream and + // can only be consumed once. Since we are consuming this + // once by cache and once by the browser for fetch, we need + // to clone the response. + const fetchRequest = event.request.clone(); + + return fetch(fetchRequest).then( + response => { + // Check if we received a valid response + // Don't cache non-GET requests or opaque responses (no-cors) unless necessary + if(!response || response.status !== 200 || response.type !== 'basic' || event.request.method !== 'GET') { + return response; + } + + // IMPORTANT: Clone the response. A response is a stream + // and because we want the browser to consume the response + // as well as the cache consuming the response, we need + // to clone it so we have two streams. + const responseToCache = response.clone(); + + caches.open(CACHE_NAME) + .then(cache => { + cache.put(event.request, responseToCache); + }); + + return response; + } + ).catch(error => { + console.error("Fetch failed; returning offline page instead.", error); + // Optional: Return an offline fallback page + // return caches.match('/offline.html'); + }); + }) + ); +}); + +// Cache cleanup +self.addEventListener('activate', event => { + const cacheWhitelist = [CACHE_NAME]; // Only keep the current cache version + event.waitUntil( + caches.keys().then(cacheNames => { + return Promise.all( + cacheNames.map(cacheName => { + if (cacheWhitelist.indexOf(cacheName) === -1) { + console.log('Deleting old cache:', cacheName); + return caches.delete(cacheName); + } + }) + ); + }) + ); +}); + +""" + +# Route to serve manifest.json +@app.route('/manifest.json') +def serve_manifest(): + return Response(MANIFEST_JSON_CONTENT, mimetype='application/manifest+json') + +# Route to serve service-worker.js +@app.route('/service-worker.js') +def serve_sw(): + return Response(SERVICE_WORKER_JS_CONTENT, mimetype='application/javascript') + +# Route for static files (like icons) - ensure you have a 'static' folder +@app.route('/static/') +def serve_static(filename): + # Make sure the static folder exists + static_folder = os.path.join(app.root_path, 'static') + return send_from_directory(static_folder, filename) + +# --- Main Execution --- +from datetime import timedelta +from flask import send_from_directory + +if __name__ == '__main__': + # Ensure static directory and PWA files exist before starting + static_dir = os.path.join(app.root_path, 'static') + os.makedirs(static_dir, exist_ok=True) + # Create placeholder icons if they don't exist (replace with real icons) + icon192_path = os.path.join(static_dir, 'icon-192x192.png') + icon512_path = os.path.join(static_dir, 'icon-512x512.png') + if not os.path.exists(icon192_path): + # Create a simple placeholder PNG (requires Pillow) + try: + from PIL import Image, ImageDraw + img = Image.new('RGB', (192, 192), color = (74, 144, 226)) # Blue + d = ImageDraw.Draw(img) + d.text((10,10), "ZC 192", fill=(255,255,255)) + img.save(icon192_path) + except ImportError: + logging.warning("Pillow not installed, cannot create placeholder icon-192x192.png") + + if not os.path.exists(icon512_path): + try: + from PIL import Image, ImageDraw + img = Image.new('RGB', (512, 512), color = (74, 144, 226)) # Blue + d = ImageDraw.Draw(img) + d.text((20,20), "Zeus Cloud 512", fill=(255,255,255)) + img.save(icon512_path) + except ImportError: + logging.warning("Pillow not installed, cannot create placeholder icon-512x512.png") + + # Check tokens if not HF_TOKEN_WRITE: - logging.warning("!!! HF_TOKEN (write access) is not set. File uploads, deletions, and backups WILL FAIL.") + logging.warning("HF_TOKEN (write access) is not set. File/folder uploads and deletions will fail.") if not HF_TOKEN_READ: - logging.warning("!!! HF_TOKEN_READ is not set. Falling back to HF_TOKEN. File downloads/previews might fail for private repos if HF_TOKEN is also not set.") + logging.warning("HF_TOKEN_READ is not set. Falling back to HF_TOKEN (if set). File downloads/previews might fail for private repos.") - # Start periodic backup thread only if write token exists - if HF_TOKEN_WRITE: - backup_thread = threading.Thread(target=periodic_backup, daemon=True) - backup_thread.start() - else: - logging.warning("Periodic backup thread NOT started because HF_TOKEN (write access) is not set.") + # Initial data load/download + load_data() - # Run Flask app - # Use Waitress or Gunicorn in production instead of Flask's built-in server - logging.info("Starting Flask application server...") - app.run(debug=False, host='0.0.0.0', port=7860) + # Background backup thread (removed as frequent saves handle this) + # If you prefer periodic backup *in addition* to save-on-change: + # if HF_TOKEN_WRITE: + # threading.Thread(target=periodic_backup, daemon=True).start() # Define periodic_backup function -# --- END OF FILE app.py --- \ No newline at end of file + app.run(debug=False, host='0.0.0.0', port=7860) # Disable debug for production \ No newline at end of file