commit 31cb8db3752e303cdaaddac9278b3dac2f284ee9 Author: Nikita Date: Tue Jul 21 12:32:38 2026 +0300 Initial commit: файловое хранилище с веб-интерфейсом diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3702140 --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +# Виртуальное окружение +venv/ +env/ +.venv/ + +# База данных (если не хотите заливать) +*.db +storage.db + +# Папка с файлами (если она пустая или большая) +storage/ + +# Файлы Python +__pycache__/ +*.pyc +*.pyo + +# Переменные окружения +.env + +# IDE +.vscode/ +.idea/ +*.swp +*.swo diff --git a/app.py b/app.py new file mode 100644 index 0000000..d05681a --- /dev/null +++ b/app.py @@ -0,0 +1,274 @@ +import os +import shutil +import mimetypes +from flask import Flask, render_template, request, redirect, url_for, send_from_directory, flash, abort, jsonify +from flask_sqlalchemy import SQLAlchemy +from flask_login import LoginManager, UserMixin, login_user, logout_user, login_required, current_user +from werkzeug.security import generate_password_hash, check_password_hash +from werkzeug.utils import secure_filename +from datetime import datetime +from config import Config +from models import db, User, Log +from utils import get_disk_usage, allowed_file, format_size +from PIL import Image +import io +import base64 + +app = Flask(__name__) +app.config.from_object(Config) +db.init_app(app) + +login_manager = LoginManager() +login_manager.init_app(app) +login_manager.login_view = 'login' + +@login_manager.user_loader +def load_user(user_id): + return User.query.get(int(user_id)) + +with app.app_context(): + db.create_all() + if not User.query.filter_by(username=os.environ.get('ADMIN_USER', 'admin')).first(): + admin = User( + username=os.environ.get('ADMIN_USER', 'admin'), + password=generate_password_hash(os.environ.get('ADMIN_PASS', 'password')) + ) + db.session.add(admin) + db.session.commit() + +# ---------- Вспомогательные функции ---------- +def safe_join(base, path): + base = os.path.abspath(base) + target = os.path.abspath(os.path.join(base, path.lstrip('/'))) + if not target.startswith(base): + return None + return target + +def get_item_info(full_path, rel_path): + if os.path.isfile(full_path): + stat = os.stat(full_path) + is_image = False + thumb = None + mime = mimetypes.guess_type(full_path)[0] or 'application/octet-stream' + if mime and mime.startswith('image/'): + is_image = True + try: + with Image.open(full_path) as img: + img.thumbnail((150, 150)) + buffered = io.BytesIO() + img.save(buffered, format="JPEG" if img.format != 'PNG' else 'PNG') + thumb = base64.b64encode(buffered.getvalue()).decode('utf-8') + except: + pass + return { + 'type': 'file', + 'name': os.path.basename(rel_path), + 'path': rel_path, + 'size': stat.st_size, + 'size_formatted': format_size(stat.st_size), + 'modified': datetime.fromtimestamp(stat.st_mtime).strftime('%Y-%m-%d %H:%M:%S'), + 'is_image': is_image, + 'thumb': thumb, + 'mime': mime + } + elif os.path.isdir(full_path): + return { + 'type': 'dir', + 'name': os.path.basename(rel_path), + 'path': rel_path, + 'modified': datetime.fromtimestamp(os.path.getmtime(full_path)).strftime('%Y-%m-%d %H:%M:%S') + } + return None + +# ---------- Маршруты ---------- +@app.route('/login', methods=['GET', 'POST']) +def login(): + if request.method == 'POST': + username = request.form['username'] + password = request.form['password'] + user = User.query.filter_by(username=username).first() + if user and check_password_hash(user.password, password): + login_user(user) + return redirect(url_for('index')) + flash('Неверный логин или пароль', 'danger') + return render_template('login.html') + +@app.route('/logout') +@login_required +def logout(): + logout_user() + return redirect(url_for('login')) + +@app.route('/') +@login_required +def index(): + current_path = request.args.get('path', '') + storage = app.config['STORAGE_FOLDER'] + full_path = safe_join(storage, current_path) + if not full_path or not os.path.exists(full_path): + return redirect(url_for('index', path='')) + + items = [] + try: + for name in os.listdir(full_path): + item_full = os.path.join(full_path, name) + rel = os.path.join(current_path, name).replace('\\', '/') + info = get_item_info(item_full, rel) + if info: + items.append(info) + items.sort(key=lambda x: (x['type'] != 'dir', x['name'].lower())) + except Exception as e: + flash(f'Ошибка чтения папки: {e}', 'danger') + + breadcrumbs = [] + if current_path: + parts = current_path.split('/') + path_accum = '' + for part in parts: + path_accum = os.path.join(path_accum, part).replace('\\', '/') + breadcrumbs.append({'name': part, 'path': path_accum}) + else: + breadcrumbs.append({'name': 'Корень', 'path': ''}) + + disk = get_disk_usage(storage) + return render_template('index.html', + items=items, + current_path=current_path, + breadcrumbs=breadcrumbs, + disk=disk, + format_size=format_size, + username=current_user.username) + +@app.route('/upload', methods=['POST']) +@login_required +def upload(): + current_path = request.form.get('path', '') + storage = app.config['STORAGE_FOLDER'] + target_dir = safe_join(storage, current_path) + if not target_dir or not os.path.isdir(target_dir): + return jsonify({'error': 'Неверный путь'}), 400 + + if 'file' not in request.files: + return jsonify({'error': 'Файл не выбран'}), 400 + file = request.files['file'] + if file.filename == '': + return jsonify({'error': 'Пустое имя'}), 400 + if not allowed_file(file.filename): + return jsonify({'error': 'Тип файла не разрешён'}), 400 + + filename = secure_filename(file.filename) + save_path = os.path.join(target_dir, filename) + if os.path.exists(save_path): + name, ext = os.path.splitext(filename) + filename = f"{name}_{datetime.now().strftime('%Y%m%d_%H%M%S')}{ext}" + save_path = os.path.join(target_dir, filename) + + file.save(save_path) + size = os.path.getsize(save_path) + log_entry = Log(username=current_user.username, action='upload', filename=os.path.join(current_path, filename), file_size=size) + db.session.add(log_entry) + db.session.commit() + return jsonify({'success': True, 'filename': filename}) + +@app.route('/files/') +@login_required +def download_file(filename): + storage = app.config['STORAGE_FOLDER'] + full_path = safe_join(storage, filename) + if not full_path or not os.path.isfile(full_path): + abort(404) + size = os.path.getsize(full_path) + log_entry = Log(username=current_user.username, action='download', filename=filename, file_size=size) + db.session.add(log_entry) + db.session.commit() + if request.args.get('download'): + return send_from_directory(os.path.dirname(full_path), os.path.basename(full_path), as_attachment=True) + return send_from_directory(os.path.dirname(full_path), os.path.basename(full_path), as_attachment=False) + +@app.route('/delete/', methods=['POST']) +@login_required +def delete_file(filename): + storage = app.config['STORAGE_FOLDER'] + full_path = safe_join(storage, filename) + if not full_path: + flash('Неверный путь', 'danger') + return redirect(request.referrer or url_for('index')) + if os.path.isfile(full_path): + os.remove(full_path) + log_entry = Log(username=current_user.username, action='delete', filename=filename, file_size=0) + db.session.add(log_entry) + db.session.commit() + flash(f'Файл "{os.path.basename(filename)}" удалён', 'info') + elif os.path.isdir(full_path): + shutil.rmtree(full_path) + flash(f'Папка "{os.path.basename(filename)}" удалена', 'info') + else: + flash('Объект не найден', 'warning') + return redirect(request.referrer or url_for('index')) + +@app.route('/create_folder', methods=['POST']) +@login_required +def create_folder(): + current_path = request.form.get('path', '') + folder_name = request.form.get('folder_name', '').strip() + if not folder_name: + return jsonify({'error': 'Имя папки не может быть пустым'}), 400 + folder_name = secure_filename(folder_name) + if not folder_name: + return jsonify({'error': 'Недопустимое имя папки'}), 400 + + storage = app.config['STORAGE_FOLDER'] + target_dir = safe_join(storage, current_path) + if not target_dir or not os.path.isdir(target_dir): + return jsonify({'error': 'Неверный путь'}), 400 + + new_folder = os.path.join(target_dir, folder_name) + try: + os.mkdir(new_folder) + return jsonify({'success': True}) + except Exception as e: + return jsonify({'error': str(e)}), 500 + +@app.route('/move', methods=['POST']) +@login_required +def move_item(): + source = request.form.get('source_path') + dest_dir = request.form.get('dest_path', '') + if not source: + return jsonify({'error': 'Не указан источник'}), 400 + + storage = app.config['STORAGE_FOLDER'] + source_full = safe_join(storage, source) + dest_full = safe_join(storage, dest_dir) + if not source_full or not dest_full: + return jsonify({'error': 'Неверный путь'}), 400 + if not os.path.exists(source_full): + return jsonify({'error': 'Источник не существует'}), 404 + if not os.path.isdir(dest_full): + return jsonify({'error': 'Папка назначения не существует'}), 400 + if os.path.isdir(source_full) and dest_full.startswith(source_full): + return jsonify({'error': 'Нельзя переместить папку в саму себя'}), 400 + + try: + target = os.path.join(dest_full, os.path.basename(source_full)) + if os.path.exists(target): + name, ext = os.path.splitext(os.path.basename(source)) + new_name = f"{name}_{datetime.now().strftime('%Y%m%d_%H%M%S')}{ext}" + target = os.path.join(dest_full, new_name) + shutil.move(source_full, target) + log_entry = Log(username=current_user.username, action='move', + filename=f"{source} -> {os.path.join(dest_dir, os.path.basename(target))}", file_size=0) + db.session.add(log_entry) + db.session.commit() + return jsonify({'success': True, 'new_path': os.path.join(dest_dir, os.path.basename(target)).replace('\\', '/')}) + except Exception as e: + return jsonify({'error': str(e)}), 500 + +@app.route('/logs') +@login_required +def view_logs(): + logs = Log.query.order_by(Log.timestamp.desc()).limit(100).all() + return render_template('logs.html', logs=logs, format_size=format_size) + +if __name__ == '__main__': + app.run(host=os.environ.get('FLASK_HOST', '0.0.0.0'), port=int(os.environ.get('FLASK_PORT', 5000)), debug=False) diff --git a/config.py b/config.py new file mode 100644 index 0000000..2c3cd1c --- /dev/null +++ b/config.py @@ -0,0 +1,10 @@ +import os + +class Config: + SECRET_KEY = os.environ.get('SECRET_KEY') or 'hard-to-guess-key' + BASE_DIR = os.path.abspath(os.path.dirname(__file__)) + STORAGE_FOLDER = os.environ.get('STORAGE_FOLDER') or os.path.join(BASE_DIR, 'storage') + SQLALCHEMY_DATABASE_URI = 'sqlite:///' + os.path.join(BASE_DIR, 'storage.db') + SQLALCHEMY_TRACK_MODIFICATIONS = False + MAX_CONTENT_LENGTH = 1024 * 1024 * 1024 # 1 ГБ + ALLOWED_EXTENSIONS = {'png','jpg','jpeg','gif','mp4','webm','pdf','doc','docx','txt','zip','rar','xlsx','pptx'} diff --git a/models.py b/models.py new file mode 100644 index 0000000..0651d4b --- /dev/null +++ b/models.py @@ -0,0 +1,18 @@ +from flask_sqlalchemy import SQLAlchemy +from flask_login import UserMixin +from datetime import datetime + +db = SQLAlchemy() + +class User(UserMixin, db.Model): + id = db.Column(db.Integer, primary_key=True) + username = db.Column(db.String(80), unique=True, nullable=False) + password = db.Column(db.String(200), nullable=False) + +class Log(db.Model): + id = db.Column(db.Integer, primary_key=True) + username = db.Column(db.String(80), nullable=False) + action = db.Column(db.String(20), nullable=False) + filename = db.Column(db.String(500), nullable=False) + file_size = db.Column(db.Integer) + timestamp = db.Column(db.DateTime, default=datetime.utcnow) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..2325958 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,7 @@ +Flask==2.3.3 +Flask-SQLAlchemy==3.1.1 +Flask-Login==0.6.3 +Werkzeug==2.3.8 +Pillow>=12.3.0 # поддерживает Python 3.13+ +python-dotenv==1.0.0 +psutil==5.9.5 diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..ec1dddb --- /dev/null +++ b/static/style.css @@ -0,0 +1,11 @@ +body { background: #f8f9fa; } +.card { margin-bottom: 10px; } +#dropzone { + border: 2px dashed #ccc; + padding: 20px; + text-align: center; + cursor: pointer; + transition: border-color 0.3s; +} +#dropzone:hover { border-color: #6c757d; } +.card-title { font-size: 0.9rem; word-break: break-word; } diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..1152c68 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,23 @@ + + + + + Файловое хранилище + + + + +
+
+

📁 Файловое хранилище

+
+ 👤 {{ username }} + Выйти +
+
+ {% block content %}{% endblock %} +
+ +{% block scripts %}{% endblock %} + + diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..470c3d6 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,261 @@ +{% extends "base.html" %} +{% block content %} +
+
+
+
Информация о диске
+
+

Всего: {{ format_size(disk.total) }}

+

Занято: {{ format_size(disk.used) }} ({{ disk.percent }}%)

+

Свободно: {{ format_size(disk.free) }}

+
+
+
+
+
Загрузить
+
+
+ Перетащите файлы сюда
или кликните для выбора + +
+ +
+
+ 📋 Логи +
+
+ + + {% with messages = get_flashed_messages(with_categories=true) %} + {% for category, message in messages %} +
{{ message }}
+ {% endfor %} + {% endwith %} + +
+ {% for item in items %} +
+
+ {% if item.type == 'dir' %} +
+
📁
+
{{ item.name }}
+ {{ item.modified }} +
+ {% else %} +
+ {% if item.is_image and item.thumb %} + {{ item.name }} + {% else %} +
📄
+ {% endif %} +
{{ item.name }}
+ {{ item.size_formatted }}
+
+ + ⬇️ + + +
+
+ {% endif %} +
+
+ {% else %} +
Папка пуста
+ {% endfor %} +
+
+
+ + + + + + + + +{% endblock %} + +{% block scripts %} + + +{% endblock %} diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..c1ad73d --- /dev/null +++ b/templates/login.html @@ -0,0 +1,30 @@ + + + +Вход + + + +
+
+
+
+

Вход в хранилище

+
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% for category, message in messages %} +
{{ message }}
+ {% endfor %} + {% endwith %} +
+
+
+ +
+
+
+
+
+
+ + diff --git a/templates/logs.html b/templates/logs.html new file mode 100644 index 0000000..556d1d7 --- /dev/null +++ b/templates/logs.html @@ -0,0 +1,15 @@ +{% extends "base.html" %} +{% block content %} +

Последние действия

+ + + + {% for log in logs %} + + {% else %} + + {% endfor %} + +
ВремяПользовательДействиеФайлРазмер
{{ log.timestamp.strftime('%Y-%m-%d %H:%M:%S') }}{{ log.username }}{{ log.action }}{{ log.filename }}{{ format_size(log.file_size) }}
Логов нет
+Назад +{% endblock %} diff --git a/utils.py b/utils.py new file mode 100644 index 0000000..809b2de --- /dev/null +++ b/utils.py @@ -0,0 +1,19 @@ +import os, shutil +from flask import current_app + +def get_disk_usage(path): + try: + usage = shutil.disk_usage(path) + return {'total': usage.total, 'used': usage.used, 'free': usage.free, 'percent': round(usage.used/usage.total*100,2)} + except: + return {'total':0,'used':0,'free':0,'percent':0} + +def allowed_file(filename): + return '.' in filename and filename.rsplit('.',1)[1].lower() in current_app.config['ALLOWED_EXTENSIONS'] + +def format_size(bytes): + if bytes == 0: return "0 Б" + for unit in ['Б','КБ','МБ','ГБ','ТБ']: + if bytes < 1024.0: return f"{bytes:.1f} {unit}" + bytes /= 1024.0 + return f"{bytes:.1f} ПБ"