Initial commit: файловое хранилище с веб-интерфейсом
This commit is contained in:
+25
@@ -0,0 +1,25 @@
|
||||
# Виртуальное окружение
|
||||
venv/
|
||||
env/
|
||||
.venv/
|
||||
|
||||
# База данных (если не хотите заливать)
|
||||
*.db
|
||||
storage.db
|
||||
|
||||
# Папка с файлами (если она пустая или большая)
|
||||
storage/
|
||||
|
||||
# Файлы Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
|
||||
# Переменные окружения
|
||||
.env
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
@@ -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/<path:filename>')
|
||||
@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/<path:filename>', 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)
|
||||
@@ -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'}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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; }
|
||||
@@ -0,0 +1,23 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Файловое хранилище</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container mt-4">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<h1>📁 Файловое хранилище</h1>
|
||||
<div>
|
||||
<span class="me-3">👤 {{ username }}</span>
|
||||
<a href="{{ url_for('logout') }}" class="btn btn-outline-danger btn-sm">Выйти</a>
|
||||
</div>
|
||||
</div>
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,261 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col-md-3">
|
||||
<div class="card">
|
||||
<div class="card-header">Информация о диске</div>
|
||||
<div class="card-body">
|
||||
<p>Всего: {{ format_size(disk.total) }}</p>
|
||||
<p>Занято: {{ format_size(disk.used) }} ({{ disk.percent }}%)</p>
|
||||
<p>Свободно: {{ format_size(disk.free) }}</p>
|
||||
<div class="progress"><div class="progress-bar" style="width: {{ disk.percent }}%"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card mt-3">
|
||||
<div class="card-header">Загрузить</div>
|
||||
<div class="card-body">
|
||||
<div id="dropzone" style="border:2px dashed #ccc; padding:20px; text-align:center; cursor:pointer;">
|
||||
Перетащите файлы сюда<br><small>или кликните для выбора</small>
|
||||
<input type="file" id="fileInput" style="display:none;" multiple>
|
||||
</div>
|
||||
<button id="createFolderBtn" class="btn btn-outline-secondary mt-2 w-100">📁 Создать папку</button>
|
||||
</div>
|
||||
</div>
|
||||
<a href="{{ url_for('view_logs') }}" class="btn btn-secondary mt-3">📋 Логи</a>
|
||||
</div>
|
||||
<div class="col-md-9">
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb">
|
||||
{% for crumb in breadcrumbs %}
|
||||
<li class="breadcrumb-item"><a href="{{ url_for('index', path=crumb.path) }}">{{ crumb.name }}</a></li>
|
||||
{% endfor %}
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ category }} alert-dismissible fade show">{{ message }}<button type="button" class="btn-close" data-bs-dismiss="alert"></button></div>
|
||||
{% endfor %}
|
||||
{% endwith %}
|
||||
|
||||
<div class="row" id="fileGrid">
|
||||
{% for item in items %}
|
||||
<div class="col-md-3 mb-3" data-path="{{ item.path }}" data-type="{{ item.type }}">
|
||||
<div class="card h-100">
|
||||
{% if item.type == 'dir' %}
|
||||
<div class="card-body text-center" style="cursor:pointer;" onclick="window.location.href='{{ url_for('index', path=item.path) }}'">
|
||||
<div style="font-size: 48px;">📁</div>
|
||||
<h6 class="card-title">{{ item.name }}</h6>
|
||||
<small>{{ item.modified }}</small>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card-body text-center">
|
||||
{% if item.is_image and item.thumb %}
|
||||
<img src="data:image/jpeg;base64,{{ item.thumb }}" class="img-thumbnail" style="max-height:100px;" alt="{{ item.name }}">
|
||||
{% else %}
|
||||
<div style="font-size: 48px;">📄</div>
|
||||
{% endif %}
|
||||
<h6 class="card-title text-truncate">{{ item.name }}</h6>
|
||||
<small>{{ item.size_formatted }}</small><br>
|
||||
<div class="btn-group btn-group-sm mt-2">
|
||||
<button class="btn btn-outline-primary preview-btn" data-path="{{ item.path }}" data-name="{{ item.name }}" data-mime="{{ item.mime }}">👁️</button>
|
||||
<a href="{{ url_for('download_file', filename=item.path, download=1) }}" class="btn btn-outline-success">⬇️</a>
|
||||
<button class="btn btn-outline-danger delete-btn" data-path="{{ item.path }}">🗑️</button>
|
||||
<button class="btn btn-outline-secondary move-btn" data-path="{{ item.path }}">📂</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="col-12"><div class="alert alert-info">Папка пуста</div></div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Модалки -->
|
||||
<div class="modal fade" id="previewModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-xl modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="previewTitle">Просмотр</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body text-center" id="previewBody">
|
||||
<div class="spinner-border"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="folderModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header"><h5>Создать папку</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
|
||||
<div class="modal-body">
|
||||
<input type="text" id="folderNameInput" class="form-control" placeholder="Имя папки">
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
|
||||
<button id="confirmFolderBtn" class="btn btn-primary">Создать</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="moveModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header"><h5>Переместить в папку</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
|
||||
<div class="modal-body">
|
||||
<p>Укажите относительный путь к папке назначения (например: <code>документы/проекты</code>)</p>
|
||||
<input type="text" id="moveDestInput" class="form-control" placeholder="Путь к папке">
|
||||
<input type="hidden" id="moveSourcePath">
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
|
||||
<button id="confirmMoveBtn" class="btn btn-primary">Переместить</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const currentPath = "{{ current_path }}";
|
||||
const dropzone = document.getElementById('dropzone');
|
||||
const fileInput = document.getElementById('fileInput');
|
||||
|
||||
dropzone.addEventListener('click', () => fileInput.click());
|
||||
dropzone.addEventListener('dragover', (e) => { e.preventDefault(); dropzone.style.borderColor = '#0d6efd'; });
|
||||
dropzone.addEventListener('dragleave', () => { dropzone.style.borderColor = '#ccc'; });
|
||||
dropzone.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
dropzone.style.borderColor = '#ccc';
|
||||
if (e.dataTransfer.files.length) uploadFiles(e.dataTransfer.files);
|
||||
});
|
||||
fileInput.addEventListener('change', () => {
|
||||
if (fileInput.files.length) {
|
||||
uploadFiles(fileInput.files);
|
||||
fileInput.value = '';
|
||||
}
|
||||
});
|
||||
|
||||
function uploadFiles(files) {
|
||||
for (let file of files) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('path', currentPath);
|
||||
fetch('{{ url_for("upload") }}', { method: 'POST', body: formData })
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) location.reload();
|
||||
else alert('Ошибка: ' + data.error);
|
||||
})
|
||||
.catch(err => alert('Ошибка: ' + err));
|
||||
}
|
||||
}
|
||||
|
||||
// Создание папки
|
||||
document.getElementById('createFolderBtn').addEventListener('click', () => {
|
||||
new bootstrap.Modal(document.getElementById('folderModal')).show();
|
||||
});
|
||||
document.getElementById('confirmFolderBtn').addEventListener('click', () => {
|
||||
const name = document.getElementById('folderNameInput').value.trim();
|
||||
if (!name) { alert('Введите имя папки'); return; }
|
||||
fetch('{{ url_for("create_folder") }}', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({ path: currentPath, folder_name: name })
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.success) location.reload();
|
||||
else alert('Ошибка: ' + data.error);
|
||||
});
|
||||
});
|
||||
|
||||
// Предпросмотр
|
||||
document.querySelectorAll('.preview-btn').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
const path = this.dataset.path;
|
||||
const name = this.dataset.name;
|
||||
const mime = this.dataset.mime;
|
||||
const modal = new bootstrap.Modal(document.getElementById('previewModal'));
|
||||
document.getElementById('previewTitle').innerText = name;
|
||||
const body = document.getElementById('previewBody');
|
||||
body.innerHTML = '<div class="spinner-border"></div>';
|
||||
|
||||
fetch('{{ url_for("download_file", filename="") }}' + encodeURIComponent(path))
|
||||
.then(r => {
|
||||
if (!r.ok) throw new Error('Не удалось загрузить');
|
||||
return r.blob();
|
||||
})
|
||||
.then(blob => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
let html = '';
|
||||
if (mime.startsWith('image/')) {
|
||||
html = `<img src="${url}" class="img-fluid" style="max-height:80vh;">`;
|
||||
} else if (mime.startsWith('video/')) {
|
||||
html = `<video controls class="w-100" style="max-height:80vh;"><source src="${url}" type="${mime}"></video>`;
|
||||
} else if (mime === 'application/pdf') {
|
||||
html = `<embed src="${url}" type="application/pdf" class="w-100" style="height:80vh;">`;
|
||||
} else if (mime.startsWith('text/') || mime === 'application/json') {
|
||||
blob.text().then(text => {
|
||||
body.innerHTML = `<pre class="text-start" style="max-height:80vh; overflow:auto;">${text}</pre>`;
|
||||
});
|
||||
return;
|
||||
} else {
|
||||
html = `<p>Предпросмотр недоступен.<br><a href="${url}" download="${name}">Скачать</a></p>`;
|
||||
}
|
||||
body.innerHTML = html;
|
||||
})
|
||||
.catch(err => {
|
||||
body.innerHTML = `<div class="alert alert-danger">${err.message}</div>`;
|
||||
});
|
||||
modal.show();
|
||||
});
|
||||
});
|
||||
|
||||
// Удаление
|
||||
document.querySelectorAll('.delete-btn').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
if (!confirm('Удалить?')) return;
|
||||
fetch('{{ url_for("delete_file", filename="") }}' + encodeURIComponent(this.dataset.path), { method: 'POST' })
|
||||
.then(r => {
|
||||
if (r.redirected) window.location.href = r.url;
|
||||
else location.reload();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Перемещение
|
||||
document.querySelectorAll('.move-btn').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
document.getElementById('moveSourcePath').value = this.dataset.path;
|
||||
new bootstrap.Modal(document.getElementById('moveModal')).show();
|
||||
});
|
||||
});
|
||||
document.getElementById('confirmMoveBtn').addEventListener('click', function() {
|
||||
const source = document.getElementById('moveSourcePath').value;
|
||||
const dest = document.getElementById('moveDestInput').value.trim();
|
||||
if (!dest) { alert('Укажите папку назначения'); return; }
|
||||
fetch('{{ url_for("move_item") }}', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({ source_path: source, dest_path: dest })
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.success) location.reload();
|
||||
else alert('Ошибка: ' + data.error);
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,30 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Вход</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
</head>
|
||||
<body class="d-flex align-items-center" style="min-height:100vh; background:#f8f9fa;">
|
||||
<div class="container">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-4">
|
||||
<div class="card">
|
||||
<div class="card-header"><h3>Вход в хранилище</h3></div>
|
||||
<div class="card-body">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ category }}">{{ message }}</div>
|
||||
{% endfor %}
|
||||
{% endwith %}
|
||||
<form method="post">
|
||||
<div class="mb-3"><input type="text" name="username" class="form-control" placeholder="Логин" required></div>
|
||||
<div class="mb-3"><input type="password" name="password" class="form-control" placeholder="Пароль" required></div>
|
||||
<button type="submit" class="btn btn-primary w-100">Войти</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,15 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<h2>Последние действия</h2>
|
||||
<table class="table table-striped">
|
||||
<thead><tr><th>Время</th><th>Пользователь</th><th>Действие</th><th>Файл</th><th>Размер</th></tr></thead>
|
||||
<tbody>
|
||||
{% for log in logs %}
|
||||
<tr><td>{{ log.timestamp.strftime('%Y-%m-%d %H:%M:%S') }}</td><td>{{ log.username }}</td><td>{{ log.action }}</td><td>{{ log.filename }}</td><td>{{ format_size(log.file_size) }}</td></tr>
|
||||
{% else %}
|
||||
<tr><td colspan="5">Логов нет</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<a href="{{ url_for('index') }}" class="btn btn-secondary">Назад</a>
|
||||
{% endblock %}
|
||||
@@ -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} ПБ"
|
||||
Reference in New Issue
Block a user