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

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

Пользователь: {{ username }}

- -
- {% for breadcrumb in breadcrumbs %} - {% if not loop.last %} - {{ breadcrumb.name }} - {% else %} - {{ breadcrumb.name }} - {% endif %} - {% endfor %} -
- {% with messages = get_flashed_messages() %} {% if messages %} {% for message in messages %} @@ -787,58 +725,87 @@ def dashboard(path): {% endif %} {% endwith %} -
- - -
+
+ {% for crumb in breadcrumb %} + {{ crumb.name }} + {% if not loop.last %}/{% endif %} + {% endfor %} +
+ +
+
+ + + +
+ + {% if current_path != 'cloud_files/' + username + '/' %} + Назад + {% endif %} +
-
- - -
- -

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

+

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

Ваши файлы под надежной защитой квантовой криптографии

-
- {% for name, item in items_in_folder %} - {% if item._type == 'folder' %} -
-
-

{{ name }}

+
+ {% for item in items_in_current_folder %} + {% if item.type == 'folder' %} +
+ +

{{ item.name }}

+
+
+ +
+
- {% elif item._type == 'file' %} -
-

{{ item.original_name }}

- {% if item.mime_type and item.mime_type.startswith('image/') %} - {{ item.original_name }} - {% elif item.mime_type and item.mime_type.startswith('video/') %} - - {% elif item.mime_type == 'application/pdf' %} -

📄

- - {% elif item.mime_type == 'text/plain' %} -

📃

- + {% elif item.type == 'file' %} +
+ {% if item.preview_type == 'image' %} + {{ item.name }} + {% elif item.preview_type == 'video' %} + + {% elif item.preview_type == 'pdf' %} + +

{{ item.name }}

+
+ + Скачать +
+ +
+
+ {% elif item.preview_type == 'txt' %} + +

{{ item.name }}

+
+ + Скачать +
+ +
+
{% else %} -

📦

+ +

{{ item.name }}

+
+ Скачать +
+ +
+
{% endif %} - -

Загружен: {{ item.upload_date }}

- +

{{ item.upload_date }}

{% endif %} {% endfor %} - {% if not items_in_folder %} + {% if not items_in_current_folder %}

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

{% endif %}
@@ -863,75 +830,80 @@ def dashboard(path):
  • В правом верхнем углу нажмите "Добавить". Иконка приложения появится на вашем главном экране.
  • + Выйти
    + + + + ''' - # Pass current_path to the template for breadcrumbs and folder links - return render_template_string(html, username=username, items_in_folder=items_in_folder, repo_id=REPO_ID, current_path=path_list, breadcrumbs=breadcrumbs, HF_TOKEN_READ=HF_TOKEN_READ) + # Calculate parent path for 'Back' button + parent_path = '/'.join(current_path.rstrip('/').split('/')[:-1]) + '/' + if parent_path == 'cloud_files/': # Avoid going above user root + parent_path = f'cloud_files/{username}/' + + return render_template_string(html, username=username, items_in_current_folder=items_in_current_folder, repo_id=REPO_ID, os=os, current_path=current_path, breadcrumb=breadcrumb, parent_path=parent_path) -@app.route('/download/') -def download_item(path): +@app.route('/create_folder', methods=['POST']) +def create_folder(): if 'username' not in session: flash('Пожалуйста, войдите в систему!') return redirect(url_for('login')) @@ -1056,117 +1014,152 @@ def download_item(path): data = load_data() if username not in data['users']: session.pop('username', None) - session.pop('current_path', None) flash('Пользователь не найден!') return redirect(url_for('login')) - # Path to the item within the user's cloud - path_list = [p for p in path.split('/') if p] - if not path_list: - flash('Неверный путь для скачивания!') - return redirect(url_for('dashboard')) # Or admin_panel if admin + user_data = data['users'][username] + folder_name = request.form.get('folder_name') + current_path = request.form.get('current_path', f'cloud_files/{username}/') - item_name = path_list[-1] - parent_path_list = path_list[:-1] + # Validate path and folder name + if not current_path.startswith(f'cloud_files/{username}/'): + flash('Неверный путь!') + return redirect(url_for('dashboard', path=f'cloud_files/{username}/')) - parent_folder = get_item_at_path(data, username, parent_path_list) + if not folder_name: + flash('Имя папки не может быть пустым!') + return redirect(url_for('dashboard', path=current_path)) - if not parent_folder or parent_folder.get('_type') != 'folder' or item_name not in parent_folder.get('items', {}): - flash('Элемент для скачивания не найден!') - # Redirect back to where they came from (dashboard or admin) - if 'admhosto' in request.referrer: - # Attempt to reconstruct admin path to user's files - try: - admin_username = path_list[0] # Assuming first segment after /download/ is username in admin context - # Need a way to get the actual admin path from the item's path - # This is tricky. Let's just redirect to admin user root for simplicity - return redirect(url_for('admin_user_files', username=admin_username)) - except IndexError: - return redirect(url_for('admin_panel')) - else: - return redirect(url_for('dashboard', path='/'.join(session.get('current_path', [])))) # Redirect to user's current path + sanitized_folder_name = secure_filename(folder_name) + if not sanitized_folder_name: + flash('Недопустимое имя папки!') + return redirect(url_for('dashboard', path=current_path)) - item_data = parent_folder['items'][item_name] + # Construct the full path for the new folder + new_folder_path = os.path.join(current_path, sanitized_folder_name).replace('\\', '/') + '/' + + # Check if an item with the same path already exists + if any(item.get('path') == new_folder_path or item.get('name') == sanitized_folder_name and item.get('parent_path') == current_path for item in user_data.get('items', [])): + flash(f'Папка или файл с именем "{sanitized_folder_name}" уже существует в этой директории.') + return redirect(url_for('dashboard', path=current_path)) - if item_data.get('_type') == 'folder': - flash('Невозможно скачать папку напрямую.') - # Redirect back to where they came from - if 'admhosto' in request.referrer: - try: - admin_username = path_list[0] - return redirect(url_for('admin_user_files', username=admin_username)) - except IndexError: - return redirect(url_for('admin_panel')) - else: - return redirect(url_for('dashboard', path='/'.join(session.get('current_path', [])))) + # Add the new folder item to the user's items list + folder_item = { + 'type': 'folder', + 'name': sanitized_folder_name, + 'path': new_folder_path, # Store the full path as its identifier + 'parent_path': current_path, + 'created_at': datetime.now().strftime('%Y-%m-%d %H:%M:%S') + } + user_data['items'].append(folder_item) - # Item is a file, proceed with download - hf_path = item_data.get('path_on_hf') - original_filename = item_data.get('original_name', item_name) # Fallback to item_name if original_name missing + try: + save_data(data) + flash(f'Папка "{sanitized_folder_name}" успешно создана!') + except Exception as e: + flash('Ошибка при создании папки.') - if not hf_path: - flash('Информация о пути файла повреждена.') - # Redirect back - if 'admhosto' in request.referrer: - try: - admin_username = path_list[0] - return redirect(url_for('admin_user_files', username=admin_username)) - except IndexError: - return redirect(url_for('admin_panel')) - else: - return redirect(url_for('dashboard', path='/'.join(session.get('current_path', [])))) + return redirect(url_for('dashboard', path=current_path)) - file_url = f"https://huggingface.co/datasets/{REPO_ID}/resolve/main/{hf_path}?download=true" +@app.route('/delete_item/', methods=['POST']) +def delete_item(item_path): + if 'username' not in session: + flash('Пожалуйста, войдите в систему!') + return redirect(url_for('login')) + + username = session['username'] + data = load_data() + if username not in data['users']: + session.pop('username', None) + flash('Пользователь не найден!') + return redirect(url_for('login')) + + user_data = data['users'][username] + item_to_delete = get_item_by_path(user_data, item_path) + + if not item_to_delete: + flash('Элемент (файл или папка) не найден!') + # Try to determine a plausible parent path to redirect back to + parent_path_guess = os.path.dirname(item_path.rstrip('/')).replace('\\', '/') + '/' + if not parent_path_guess.startswith(f'cloud_files/{username}/'): + parent_path_guess = f'cloud_files/{username}/' + return redirect(url_for('dashboard', path=parent_path_guess)) + + + parent_path_after_delete = item_to_delete.get('parent_path', f'cloud_files/{username}/') + try: - headers = {} - if HF_TOKEN_READ: - headers["authorization"] = f"Bearer {HF_TOKEN_READ}" + api = HfApi() - response = requests.get(file_url, headers=headers, stream=True) - response.raise_for_status() + if item_to_delete['type'] == 'file': + hf_path = item_to_delete['unique_path'] + api.delete_file( + path_in_repo=hf_path, + repo_id=REPO_ID, + repo_type="dataset", + token=HF_TOKEN_WRITE, + commit_message=f"Deleted file {item_to_delete['name']} for {username}" + ) + # Remove the file item from the list + user_data['items'] = [item for item in user_data['items'] if item.get('unique_path') != item_path] + flash(f"Файл '{item_to_delete['name']}' успешно удален!") + + elif item_to_delete['type'] == 'folder': + # Find all files within this folder and its subfolders + files_to_delete_blobs = get_all_descendant_files(user_data, item_to_delete['path']) + + # Attempt to delete the folder structure on HF by deleting all files first + deleted_blob_count = 0 + for file_item in files_to_delete_blobs: + try: + api.delete_file( + path_in_repo=file_item['unique_path'], + repo_id=REPO_ID, + repo_type="dataset", + token=HF_TOKEN_WRITE + ) + deleted_blob_count += 1 + except Exception as file_delete_error: + logging.error(f"Error deleting blob {file_item['unique_path']} during folder delete: {file_delete_error}") + # Continue attempting to delete other files + + # Try deleting the folder path itself on HF (might not do anything if empty) + try: + api.delete_folder( + folder_path=item_to_delete['path'].rstrip('/'), # HF API might expect path without trailing slash + repo_id=REPO_ID, + repo_type="dataset", + token=HF_TOKEN_WRITE, + commit_message=f"Deleted folder {item_to_delete['name']} for {username} and its contents" + ) + except Exception as folder_delete_error: + logging.warning(f"Could not delete folder path {item_to_delete['path']} on HF, likely already empty or API limitation: {folder_delete_error}") - # Use stream=True and read in chunks for large files - # For simplicity and potentially smaller files, reading directly into BytesIO is okay, - # but less memory efficient for very large files. Keeping BytesIO for now as it matches original. - file_content = BytesIO(response.content) - return send_file( - file_content, - as_attachment=True, - download_name=original_filename, - mimetype=item_data.get('mime_type', 'application/octet-stream') - ) - except requests.exceptions.RequestException as e: - logging.error(f"Error downloading file from HF ({hf_path}): {e}") - flash('Ошибка скачивания файла!') - # Redirect back - if 'admhosto' in request.referrer: - try: - admin_username = path_list[0] - return redirect(url_for('admin_user_files', username=admin_username)) - except IndexError: - return redirect(url_for('admin_panel')) - else: - return redirect(url_for('dashboard', path='/'.join(session.get('current_path', [])))) + # Remove the folder item and all its descendants from the items list + folder_prefix = item_to_delete['path'] + user_data['items'] = [item for item in user_data['items'] if not ( + item.get('path', '').startswith(folder_prefix) or # Delete descendant folders + item.get('unique_path', '').startswith(folder_prefix) or # Delete descendant files + item.get('path') == item_path # Delete the folder itself + )] + + flash(f"Папка '{item_to_delete['name']}' и ее содержимое успешно удалены! ({deleted_blob_count} файлов удалено из хранилища)") + + + save_data(data) except Exception as e: - logging.error(f"Unexpected error during download of {hf_path}: {e}") - flash('Произошла непредвиденная ошибка при скачивании файла.') - # Redirect back - if 'admhosto' in request.referrer: - try: - admin_username = path_list[0] - return redirect(url_for('admin_user_files', username=admin_username)) - except IndexError: - return redirect(url_for('admin_panel')) - else: - return redirect(url_for('dashboard', path='/'.join(session.get('current_path', [])))) + logging.error(f"Error deleting item {item_path}: {e}") + flash('Ошибка при удалении элемента!') + + return redirect(url_for('dashboard', path=parent_path_after_delete)) -@app.route('/delete/') -def delete_item(path): +@app.route('/download_item/') +def download_item(item_path): if 'username' not in session: flash('Пожалуйста, войдите в систему!') return redirect(url_for('login')) @@ -1175,141 +1168,86 @@ def delete_item(path): data = load_data() if username not in data['users']: session.pop('username', None) - session.pop('current_path', None) flash('Пользователь не найден!') return redirect(url_for('login')) - path_list = [p for p in path.split('/') if p] - if not path_list: - flash('Неверный путь для удален��я!') - return redirect(url_for('dashboard', path='/'.join(session.get('current_path', [])))) - - item_name = path_list[-1] - parent_path_list = path_list[:-1] + user_data = data['users'][username] + item = get_item_by_path(user_data, item_path) - # Check if the item belongs to the current user and is in the current path or a subpath of the current path - # For non-admin users, we restrict deletion to items directly within their current view path. - # This is simpler than allowing deletion of items anywhere using a path parameter. - # Let's enforce that the requested path for deletion MUST match the current session path + item_name - current_session_path_list = session.get('current_path', []) - expected_path_list = current_session_path_list + [item_name] + # Admins can download any file via their panel, which will use this route. + # Check if the item belongs to the logged-in user, unless this request came from the admin panel. + # The referrer check is weak, a better way is to pass an 'is_admin' flag or use a separate route. + # Sticking to referrer check for minimal change relative to original logic. + is_admin_request = request.referrer and '/admhosto/user/' in request.referrer - if path_list != expected_path_list: - flash('Неверный путь для удаления!') # Attempted to delete item not in current folder view - return redirect(url_for('dashboard', path='/'.join(current_session_path_list))) - - - # Find the item using the path relative to the user's root - parent_folder = get_item_at_path(data, username, parent_path_list) - - if not parent_folder or parent_folder.get('_type') != 'folder' or item_name not in parent_folder.get('items', {}): - flash('Элемент для удаления не найден!') - return redirect(url_for('dashboard', path='/'.join(current_session_path_list))) + if not item or item['type'] != 'file': + flash('Элемент не найден или не является файлом!') + # Redirect back to dashboard or admin page based on referrer + if is_admin_request: + # Try to guess username from item_path (format cloud_files/username/...) + try: + admin_user = item_path.split('/')[1] + return redirect(url_for('admin_user_files', username=admin_user)) + except IndexError: + return redirect(url_for('admin_panel')) + else: + # Try to determine a plausible parent path to redirect back to + parent_path_guess = os.path.dirname(item_path.rstrip('/')).replace('\\', '/') + '/' + if not parent_path_guess.startswith(f'cloud_files/{username}/'): + parent_path_guess = f'cloud_files/{username}/' + return redirect(url_for('dashboard', path=parent_path_guess)) - item_data = parent_folder['items'][item_name] - if item_data.get('_type') == 'folder': - flash('Невозможно удалить папку из этого интерфейса. Используйте админ-панель, если вы администратор.') - return redirect(url_for('dashboard', path='/'.join(current_session_path_list))) + # Check if the file belongs to the logged-in user, unless it's an admin request + if not is_admin_request and item.get('parent_path', '').split('/')[1] != username: + flash('У вас нет доступа к этому файлу!') + return redirect(url_for('dashboard')) - # Item is a file, proceed with deletion - hf_path_to_delete = item_data.get('path_on_hf') + hf_path = item['unique_path'] + original_filename = item.get('original_filename', item.get('name', 'download')) # Use original or display name - if not hf_path_to_delete: - flash('Информация о пути файла повреждена. Удаление невозможно.') - # Attempt to remove from DB anyway - delete_item_at_path(data, username, parent_path_list, item_name) - save_data(data) - logging.warning(f"Attempted to delete file {item_name} for {username} but hf_path_on_file was missing. Removed from DB.") - return redirect(url_for('dashboard', path='/'.join(current_session_path_list))) + file_url = f"https://huggingface.co/datasets/{REPO_ID}/resolve/main/{hf_path}?download=true" + try: + headers = {} + if HF_TOKEN_READ: + headers["authorization"] = f"Bearer {HF_TOKEN_READ}" + response = requests.get(file_url, headers=headers, stream=True) + response.raise_for_status() - try: - api = HfApi() - api.delete_file( - path_in_repo=hf_path_to_delete, - repo_id=REPO_ID, - repo_type="dataset", - token=HF_TOKEN_WRITE, - commit_message=f"Deleted file {hf_path_to_delete} for {username}" + file_content = BytesIO(response.content) + return send_file( + file_content, + as_attachment=True, + download_name=original_filename, + mimetype='application/octet-stream' ) - # Remove item from database structure - delete_item_at_path(data, username, parent_path_list, item_name) - save_data(data) - flash('Файл успешно удален!') - logging.info(f"User {username} deleted file {hf_path_to_delete}.") - + except requests.exceptions.RequestException as e: + logging.error(f"Error downloading file from HF: {e}") + flash('Ошибка скачивания файла!') except Exception as e: - logging.error(f"Error deleting file {hf_path_to_delete} for user {username}: {e}") - flash('Ошибка удаления файла!') + logging.error(f"Unexpected error during download: {e}") + flash('Произошла непредвиденная ошибка при скачивании файла.') - return redirect(url_for('dashboard', path='/'.join(current_session_path_list))) + # Redirect back to dashboard or admin page + if is_admin_request: + try: + admin_user = item.get('parent_path', '').split('/')[1] # Get username from parent path + return redirect(url_for('admin_user_files', username=admin_user)) + except IndexError: + return redirect(url_for('admin_panel')) + else: + return redirect(url_for('dashboard', path=item.get('parent_path', f'cloud_files/{username}/'))) @app.route('/logout') def logout(): session.pop('username', None) - session.pop('current_path', None) - flash('Вы успешно вышли из системы.') return redirect(url_for('login')) -# --- Admin Panel --- -ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD") # Set this environment variable for admin access - -@app.route('/admhosto', methods=['GET', 'POST']) -def admin_panel_login(): - if 'is_admin' in session: - return redirect(url_for('admin_panel_dashboard')) - - if request.method == 'POST': - password = request.form.get('password') - if ADMIN_PASSWORD and password == ADMIN_PASSWORD: - session['is_admin'] = True - flash('Вход в админ-панель выполнен.') - return redirect(url_for('admin_panel_dashboard')) - else: - flash('Неверный пароль администратора.') - return redirect(url_for('admin_panel_login')) - - html = ''' - - - - - - Админ-вход - Zeus Cloud - - - - -
    -

    Вход в Админ-панель

    - {% with messages = get_flashed_messages() %} - {% if messages %} - {% for message in messages %} -
    {{ message }}
    - {% endfor %} - {% endif %} - {% endwith %} -
    - - -
    -
    - - -''' - # If ADMIN_PASSWORD is not set, admin panel is inaccessible (except code path) - if not ADMIN_PASSWORD: - return "Админ-панель отключена. Не установлен ADMIN_PASSWORD." - - return render_template_string(html) - -@app.route('/admhosto/dashboard') -def admin_panel_dashboard(): - if 'is_admin' not in session: - return redirect(url_for('admin_panel_login')) +@app.route('/admhosto') +def admin_panel(): data = load_data() users = data['users'] @@ -1332,9 +1270,7 @@ def admin_panel_dashboard():
    {{ username }}

    Дата регистрации: {{ user_data.get('created_at', 'N/A') }}

    - -

    Количество файлов: {{ admin_get_total_files(user_data.get('root', {})) }}

    - +

    Количество элементов: {{ user_data.get('items', []) | length }}

    @@ -1353,71 +1289,36 @@ def admin_panel_dashboard():
    {% endif %} {% endwith %} - Выйти из админ-панели
    - ''' - def admin_get_total_files_recursive(item): - if not item or item.get('_type') != 'folder': return 0 - count = 0 - for key in item.get('items', {}): - sub_item = item['items'][key] - if sub_item.get('_type') == 'file': - count += 1 - elif sub_item.get('_type') == 'folder': - count += admin_get_total_files_recursive(sub_item) - return count - - return render_template_string(html, users=users, admin_get_total_files=admin_get_total_files_recursive) - -@app.route('/admhosto/user/', defaults={'path': ''}) -@app.route('/admhosto/user//') -def admin_user_files(username, path): - if 'is_admin' not in session: - return redirect(url_for('admin_panel_login')) + return render_template_string(html, users=users) +@app.route('/admhosto/user/') +def admin_user_files(username): data = load_data() if username not in data['users']: flash('Пользователь не найден!') - return redirect(url_for('admin_panel_dashboard')) - - path_list = [p for p in path.split('/') if p] + return redirect(url_for('admin_panel')) - current_folder = get_item_at_path(data, username, path_list) + user_data = data['users'][username] + # For admin view, show all items flattened, maybe grouped later + # For simplicity, let's show them grouped by their parent path + + # Get items and group by parent path for a structured view + items = sorted(user_data.get('items', []), key=lambda x: (x.get('parent_path', ''), x.get('type') != 'folder', x.get('name', '').lower())) - if not current_folder or current_folder.get('_type') != 'folder': - flash('Папка не найдена!') - return redirect(url_for('admin_user_files', username=username)) # Redirect to user's root + # Create a dictionary where keys are parent_paths and values are lists of items + items_by_folder = {} + root_path = f'cloud_files/{username}/' + items_by_folder[root_path] = [] # Ensure root is always present - items_in_folder = list_items_at_path(data, username, path_list) - - # Build breadcrumbs for admin view - breadcrumbs = [{'name': 'Админ-панель', 'path': url_for('admin_panel_dashboard')}, - {'name': username, 'path': url_for('admin_user_files', username=username)}] - current_breadcrumb_path = [] - for segment in path_list: - current_breadcrumb_path.append(segment) - breadcrumbs.append({'name': segment, 'path': url_for('admin_user_files', username=username, path='/'.join(current_breadcrumb_path))}) + for item in items: + parent = item.get('parent_path', root_path) + if parent not in items_by_folder: + items_by_folder[parent] = [] + items_by_folder[parent].append(item) html = ''' @@ -1426,24 +1327,14 @@ def admin_user_files(username, path): - Файлы пользователя {{ username }} (Админ) - Zeus Cloud + Файлы пользователя {{ username }} - Zeus Cloud +
    -

    Файлы пользователя: {{ username }} (Админ)

    - -
    - {% for breadcrumb in breadcrumbs %} - {% if not loop.last %} - {{ breadcrumb.name }} - {% else %} - {{ breadcrumb.name }} - {% endif %} - {% endfor %} -
    - +

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

    {% with messages = get_flashed_messages() %} {% if messages %} {% for message in messages %} @@ -1452,94 +1343,115 @@ def admin_user_files(username, path): {% endif %} {% endwith %} -
    - {% for name, item in items_in_folder %} - {% if item._type == 'folder' %} -
    -
    -

    {{ name }}

    -
    -
    - -
    -
    -
    - {% elif item._type == 'file' %} -
    -

    {{ item.original_name }}

    - {% if item.mime_type and item.mime_type.startswith('image/') %} - {{ item.original_name }} - {% elif item.mime_type and item.mime_type.startswith('video/') %} - - {% elif item.mime_type == 'application/pdf' %} -

    📄

    - - {% elif item.mime_type == 'text/plain' %} -

    📃

    - - {% else %} -

    📦

    - {% endif %} - -

    Загружен: {{ item.get('upload_date', 'N/A') }}

    -
    - - Скачать -
    - -
    -
    -
    - {% endif %} - {% endfor %} - {% if not items_in_folder %} -

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

    - {% endif %} -
    - Назад к списку пользователей + {% if not items_by_folder %} +

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

    + {% else %} + {% for parent_path, items_in_folder in items_by_folder.items() %} +

    + Папка: {{ parent_path.replace('cloud_files/' + username + '/', '', 1) or '/' }} +

    + {% if not items_in_folder %} +

    (пусто)

    + {% else %} +
    + {% for item in items_in_folder %} +
    + {% if item.type == 'folder' %} + +

    {{ item.name }}

    +
    +
    + +
    +
    + {% elif item.type == 'file' %} + {% if item.preview_type == 'image' %} + {{ item.name }} + {% elif item.preview_type == 'video' %} + + {% elif item.preview_type == 'pdf' %} + +

    {{ item.name }}

    + {% elif item.preview_type == 'txt' %} + +

    {{ item.name }}

    + {% else %} + +

    {{ item.name }}

    + {% endif %} +

    {{ item.upload_date }}

    +
    + {% if item.preview_type in ['pdf', 'txt'] %} + + {% elif item.preview_type in ['image', 'video'] %} + + {% endif %} + Скачать +
    + +
    +
    + {% endif %} +
    + {% endfor %} +
    + {% endif %} + {% endfor %} + {% endif %} + + + Назад к списку пользователей
    -