From ea3e92b8b7615a05312d751cda81b080e0568e96 Mon Sep 17 00:00:00 2001 From: pika Date: Sun, 23 Mar 2025 00:40:29 +0100 Subject: [PATCH] wip --- app/__init__.py | 103 ++++++------- app/models.py | 56 +++++--- app/routes/auth.py | 72 +++++++++- app/routes/dashboard.py | 107 ++++++++++++-- app/routes/files.py | 68 ++++++++- app/static/css/custom.css | 4 +- app/templates/auth/profile.html | 238 ++++++++++++++++++++++++++++--- app/templates/dashboard.html | 235 +++++++++++++++++++++++++----- app/templates/files/browser.html | 20 +-- app/templates/files/upload.html | 37 ++--- 10 files changed, 773 insertions(+), 167 deletions(-) diff --git a/app/__init__.py b/app/__init__.py index bd17b26..1ad2dbd 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,16 +1,61 @@ -from flask import Flask +from flask import Flask, current_app from flask_sqlalchemy import SQLAlchemy from flask_login import LoginManager from config import Config -from datetime import datetime import os +from datetime import datetime import sqlite3 +# Initialize extensions db = SQLAlchemy() login_manager = LoginManager() login_manager.login_view = 'auth.login' login_manager.login_message_category = 'info' +def initialize_database(app): + """Create database tables if they don't exist""" + with app.app_context(): + try: + # Create all tables + db.create_all() + app.logger.info("Database tables created successfully") + except Exception as e: + app.logger.error(f"Error creating database tables: {str(e)}") + +def run_migrations(app): + """Apply any necessary database migrations""" + db_path = app.config['SQLALCHEMY_DATABASE_URI'].replace('sqlite:///', '') + + if not os.path.exists(db_path): + app.logger.info(f"Database file does not exist: {db_path}") + return + + try: + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + + # Check if storage_name column exists in file table + cursor.execute("PRAGMA table_info(file)") + columns = [column[1] for column in cursor.fetchall()] + + if 'storage_name' not in columns: + app.logger.info("Adding storage_name column to file table") + cursor.execute("ALTER TABLE file ADD COLUMN storage_name TEXT") + + # Update existing records to use filename as storage_name + cursor.execute("UPDATE file SET storage_name = name WHERE storage_name IS NULL AND is_folder = 0") + conn.commit() + + conn.close() + app.logger.info("Database migrations completed successfully") + except sqlite3.OperationalError as e: + if "no such table: file" in str(e): + app.logger.info("File table doesn't exist yet, will be created with db.create_all()") + else: + app.logger.error(f"Error during migration: {str(e)}") + except Exception as e: + app.logger.error(f"Error during migration: {str(e)}") + def create_app(config_class=Config): app = Flask(__name__) app.config.from_object(config_class) @@ -20,7 +65,7 @@ def create_app(config_class=Config): login_manager.init_app(app) # Initialize the upload folder - Config.init_app(app) + os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True) # Auto initialize database if it doesn't exist with app.app_context(): @@ -28,7 +73,10 @@ def create_app(config_class=Config): run_migrations(app) # Register blueprints - from app.routes import auth_bp, files_bp, dashboard_bp + from app.routes.auth import auth_bp + from app.routes.files import files_bp + from app.routes.dashboard import dashboard_bp + app.register_blueprint(auth_bp) app.register_blueprint(files_bp) app.register_blueprint(dashboard_bp) @@ -40,51 +88,4 @@ def create_app(config_class=Config): return app -def initialize_database(app): - """Create database tables if they don't exist.""" - db_path = app.config['SQLALCHEMY_DATABASE_URI'].replace('sqlite:///', '') - - # Check if database file exists - if not os.path.exists(db_path): - print("Database does not exist. Creating tables...") - db.create_all() - - # Import models here to avoid circular imports - from app.models import User - - # Create admin user if it doesn't exist - admin = User.query.filter_by(username='admin').first() - if not admin: - admin = User(username='admin') - admin.set_password('admin') # Change this in production - db.session.add(admin) - db.session.commit() - print("Admin user created.") - -def run_migrations(app): - """Run any needed database migrations.""" - db_path = app.config['SQLALCHEMY_DATABASE_URI'].replace('sqlite:///', '') - conn = sqlite3.connect(db_path) - cursor = conn.cursor() - - try: - # Check for missing columns in File table - cursor.execute("PRAGMA table_info(file)") - columns = [column[1] for column in cursor.fetchall()] - - # Add storage_name column if missing - if 'storage_name' not in columns: - print("Running migration: Adding storage_name column to file table...") - cursor.execute("ALTER TABLE file ADD COLUMN storage_name TEXT") - - # Update existing files to use name as storage_name - cursor.execute("UPDATE file SET storage_name = name WHERE is_folder = 0") - - conn.commit() - print("Migration completed successfully!") - except Exception as e: - print(f"Migration error: {e}") - finally: - conn.close() - from app import models diff --git a/app/models.py b/app/models.py index 3df8837..bdc9ea7 100644 --- a/app/models.py +++ b/app/models.py @@ -5,41 +5,61 @@ from app import db, login_manager import uuid @login_manager.user_loader -def load_user(user_id): - return User.query.get(int(user_id)) +def load_user(id): + return User.query.get(int(id)) class User(UserMixin, db.Model): + __tablename__ = 'user' + id = db.Column(db.Integer, primary_key=True) - username = db.Column(db.String(64), unique=True, index=True) + username = db.Column(db.String(64), index=True, unique=True) + email = db.Column(db.String(120), index=True, unique=True) password_hash = db.Column(db.String(128)) - files = db.relationship('File', backref='owner', lazy='dynamic') - shares = db.relationship('Share', backref='creator', lazy='dynamic') + created_at = db.Column(db.DateTime, default=datetime.utcnow) + last_login = db.Column(db.DateTime) + + # Relationships + files = db.relationship('File', backref='owner', lazy='dynamic', + foreign_keys='File.user_id', cascade='all, delete-orphan') + + def __repr__(self): + return f'' def set_password(self, password): self.password_hash = generate_password_hash(password) - + def check_password(self, password): return check_password_hash(self.password_hash, password) class File(db.Model): + __tablename__ = 'file' + id = db.Column(db.Integer, primary_key=True) - name = db.Column(db.String(255)) - storage_name = db.Column(db.String(255)) # Added field for UUID-based storage - mime_type = db.Column(db.String(128)) - size = db.Column(db.Integer, default=0) + name = db.Column(db.String(255), nullable=False) + storage_name = db.Column(db.String(255)) # Used for storing files with unique names is_folder = db.Column(db.Boolean, default=False) + mime_type = db.Column(db.String(128)) + size = db.Column(db.Integer, default=0) # Size in bytes + parent_id = db.Column(db.Integer, db.ForeignKey('file.id', ondelete='CASCADE'), nullable=True) + user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) created_at = db.Column(db.DateTime, default=datetime.utcnow) updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) - user_id = db.Column(db.Integer, db.ForeignKey('user.id')) - parent_id = db.Column(db.Integer, db.ForeignKey('file.id')) - # Add relationship to represent folder structure - children = db.relationship('File', - backref=db.backref('parent', remote_side=[id]), - lazy='dynamic') + # Relationships + children = db.relationship('File', backref=db.backref('parent', remote_side=[id]), + lazy='dynamic', cascade='all, delete-orphan') - # Add relationship for shared files - shares = db.relationship('Share', backref='file', lazy='dynamic') + def __repr__(self): + return f'' + + def generate_storage_name(self): + """Generate a unique name for file storage to prevent conflicts""" + if self.is_folder: + return None + + # Generate a unique filename using UUID + ext = self.name.rsplit('.', 1)[1].lower() if '.' in self.name else '' + return f"{uuid.uuid4().hex}.{ext}" if ext else f"{uuid.uuid4().hex}" class Share(db.Model): id = db.Column(db.Integer, primary_key=True) diff --git a/app/routes/auth.py b/app/routes/auth.py index 9b7c633..2e139c9 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -1,4 +1,4 @@ -from flask import render_template, redirect, url_for, flash, request +from flask import render_template, redirect, url_for, flash, request, current_app, jsonify, session from flask_login import login_user, logout_user, login_required, current_user from urllib.parse import urlparse from app import db @@ -7,6 +7,7 @@ from app.routes import auth_bp from flask_wtf import FlaskForm from wtforms import StringField, PasswordField, BooleanField, SubmitField from wtforms.validators import DataRequired, Length, EqualTo, ValidationError +from werkzeug.exceptions import BadRequest # Login form class LoginForm(FlaskForm): @@ -73,7 +74,74 @@ def register(): return render_template('auth/register.html', title='Register', form=form) +@auth_bp.route('/update_profile', methods=['POST']) +@login_required +def update_profile(): + """Update user profile information""" + username = request.form.get('username') + + if not username or username.strip() == '': + flash('Username cannot be empty', 'error') + return redirect(url_for('auth.profile')) + + # Check if username is already taken by another user + existing_user = User.query.filter(User.username == username, User.id != current_user.id).first() + if existing_user: + flash('Username is already taken', 'error') + return redirect(url_for('auth.profile')) + + # Update username + current_user.username = username + db.session.commit() + flash('Profile updated successfully', 'success') + return redirect(url_for('auth.profile')) + +@auth_bp.route('/change_password', methods=['POST']) +@login_required +def change_password(): + """Change user password""" + current_password = request.form.get('current_password') + new_password = request.form.get('new_password') + confirm_password = request.form.get('confirm_password') + + # Validate input + if not all([current_password, new_password, confirm_password]): + flash('All fields are required', 'error') + return redirect(url_for('auth.profile')) + + if new_password != confirm_password: + flash('New passwords do not match', 'error') + return redirect(url_for('auth.profile')) + + # Check current password + if not current_user.check_password(current_password): + flash('Current password is incorrect', 'error') + return redirect(url_for('auth.profile')) + + # Set new password + current_user.set_password(new_password) + db.session.commit() + flash('Password changed successfully', 'success') + return redirect(url_for('auth.profile')) + +@auth_bp.route('/update_preferences', methods=['POST']) +@login_required +def update_preferences(): + """Update user preferences like theme""" + theme_preference = request.form.get('theme_preference', 'system') + + # Store in session for now, but could be added to user model + session['theme_preference'] = theme_preference + + flash('Preferences updated successfully', 'success') + return redirect(url_for('auth.profile')) + @auth_bp.route('/profile') @login_required def profile(): - return render_template('auth/profile.html', title='User Profile') + # Get theme preference from session or default to system + theme_preference = session.get('theme_preference', 'system') + + return render_template('auth/profile.html', + title='User Profile', + theme_preference=theme_preference) diff --git a/app/routes/dashboard.py b/app/routes/dashboard.py index e062f23..75e77cd 100644 --- a/app/routes/dashboard.py +++ b/app/routes/dashboard.py @@ -1,8 +1,10 @@ -from flask import render_template +from flask import Blueprint, render_template from flask_login import login_required, current_user -from app.routes import dashboard_bp -from app.models import File, Share -from datetime import datetime +from datetime import datetime, timedelta +from app.models import File, Share, Download +import os + +dashboard_bp = Blueprint('dashboard', __name__) @dashboard_bp.route('/') @login_required @@ -10,15 +12,102 @@ def index(): # Get some stats for the dashboard total_files = File.query.filter_by(user_id=current_user.id, is_folder=False).count() total_folders = File.query.filter_by(user_id=current_user.id, is_folder=True).count() - recent_files = File.query.filter_by(user_id=current_user.id, is_folder=False).order_by(File.updated_at.desc()).limit(5).all() - active_shares = Share.query.filter_by(user_id=current_user.id).filter( - (Share.expires_at > datetime.now()) | (Share.expires_at.is_(None)) - ).count() + + # Recent files for quick access + recent_files = File.query.filter_by(user_id=current_user.id, is_folder=False)\ + .order_by(File.updated_at.desc())\ + .limit(8).all() + + # Root folders for quick navigation + root_folders = File.query.filter_by(user_id=current_user.id, is_folder=True, parent_id=None)\ + .order_by(File.name)\ + .limit(8).all() + + # Count active shares (if Share model exists) + active_shares = 0 + recent_activities = 0 + + # Check if Share and Download models exist/are imported + try: + # Count active shares + active_shares = Share.query.filter_by(user_id=current_user.id).filter( + (Share.expires_at > datetime.now()) | (Share.expires_at.is_(None)) + ).count() + + # Recent activities count (downloads, shares, etc.) + recent_activities = Download.query.join(Share)\ + .filter(Share.user_id == current_user.id)\ + .filter(Download.timestamp > (datetime.now() - timedelta(days=7)))\ + .count() + except: + # Models not ready yet, using default values + pass return render_template('dashboard.html', title='Dashboard', total_files=total_files, total_folders=total_folders, recent_files=recent_files, + root_folders=root_folders, active_shares=active_shares, - now=datetime.now()) \ No newline at end of file + recent_activities=recent_activities, + now=datetime.now(), + file_icon=get_file_icon, + format_size=format_file_size) + +def get_file_icon(mime_type, filename): + """Return Font Awesome icon class based on file type""" + if mime_type: + if mime_type.startswith('image/'): + return 'fa-file-image' + elif mime_type.startswith('video/'): + return 'fa-file-video' + elif mime_type.startswith('audio/'): + return 'fa-file-audio' + elif mime_type.startswith('text/'): + return 'fa-file-alt' + elif mime_type.startswith('application/pdf'): + return 'fa-file-pdf' + elif 'spreadsheet' in mime_type or 'excel' in mime_type: + return 'fa-file-excel' + elif 'presentation' in mime_type or 'powerpoint' in mime_type: + return 'fa-file-powerpoint' + elif 'document' in mime_type or 'word' in mime_type: + return 'fa-file-word' + + # Check by extension + ext = os.path.splitext(filename)[1].lower()[1:] + if ext in ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'svg', 'webp']: + return 'fa-file-image' + elif ext in ['mp4', 'avi', 'mov', 'wmv', 'flv', 'mkv']: + return 'fa-file-video' + elif ext in ['mp3', 'wav', 'ogg', 'flac', 'm4a']: + return 'fa-file-audio' + elif ext in ['doc', 'docx', 'odt']: + return 'fa-file-word' + elif ext in ['xls', 'xlsx', 'ods', 'csv']: + return 'fa-file-excel' + elif ext in ['ppt', 'pptx', 'odp']: + return 'fa-file-powerpoint' + elif ext == 'pdf': + return 'fa-file-pdf' + elif ext in ['zip', 'rar', '7z', 'tar', 'gz']: + return 'fa-file-archive' + elif ext in ['txt', 'rtf', 'md']: + return 'fa-file-alt' + elif ext in ['html', 'css', 'js', 'py', 'java', 'php', 'c', 'cpp', 'json', 'xml']: + return 'fa-file-code' + + return 'fa-file' + +def format_file_size(size): + """Format file size in bytes to human-readable format""" + if not size: + return "0 B" + + size_names = ("B", "KB", "MB", "GB", "TB") + i = 0 + while size >= 1024 and i < len(size_names) - 1: + size /= 1024 + i += 1 + return f"{size:.1f} {size_names[i]}" \ No newline at end of file diff --git a/app/routes/files.py b/app/routes/files.py index dd2ad07..86ed353 100644 --- a/app/routes/files.py +++ b/app/routes/files.py @@ -1,4 +1,4 @@ -from flask import render_template, redirect, url_for, flash, request, send_from_directory, abort, jsonify +from flask import render_template, redirect, url_for, flash, request, send_from_directory, abort, jsonify, send_file from flask_login import login_required, current_user from werkzeug.utils import secure_filename from app import db @@ -581,4 +581,68 @@ def upload_xhr(): 'errors': errors } - return jsonify(result) \ No newline at end of file + return jsonify(result) + +@files_bp.route('/contents') +@login_required +def folder_contents(): + """Returns the HTML for folder contents (used for AJAX loading)""" + folder_id = request.args.get('folder_id', None, type=int) + + if request.headers.get('X-Requested-With') != 'XMLHttpRequest': + # If not an AJAX request, redirect to browser view + return redirect(url_for('files.browser', folder_id=folder_id)) + + # Query parent folder + parent_folder = None + if folder_id: + parent_folder = File.query.filter_by(id=folder_id, user_id=current_user.id, is_folder=True).first_or_404() + + # Get files and subfolders + query = File.query.filter_by(user_id=current_user.id, parent_id=folder_id) + folders = query.filter_by(is_folder=True).order_by(File.name).all() + files = query.filter_by(is_folder=False).order_by(File.name).all() + + return render_template('files/partials/folder_contents.html', + folders=folders, + files=files, + parent=parent_folder) + +@files_bp.route('/preview/') +@login_required +def file_preview(file_id): + """Returns file preview data""" + file = File.query.filter_by(id=file_id, user_id=current_user.id, is_folder=False).first_or_404() + + result = { + 'success': True, + 'file': { + 'id': file.id, + 'name': file.name, + 'mime_type': file.mime_type, + 'size': file.size, + 'created_at': file.created_at.isoformat() if file.created_at else None, + 'updated_at': file.updated_at.isoformat() if file.updated_at else None + }, + 'download_url': url_for('files.download', file_id=file.id), + 'preview_url': url_for('files.raw', file_id=file.id) + } + + return jsonify(result) + +@files_bp.route('/raw/') +@login_required +def raw(file_id): + """Serves raw file content for previews""" + file = File.query.filter_by(id=file_id, user_id=current_user.id, is_folder=False).first_or_404() + + # Check if file exists + file_path = os.path.join(current_app.config['UPLOAD_FOLDER'], file.storage_name) + if not os.path.exists(file_path): + abort(404) + + # Check file size for text files (only preview if under 2MB) + if file.mime_type and file.mime_type.startswith('text/') and file.size > 2 * 1024 * 1024: + return "File too large to preview", 413 + + return send_file(file_path, mimetype=file.mime_type) \ No newline at end of file diff --git a/app/static/css/custom.css b/app/static/css/custom.css index 8eadb2d..ef36f15 100644 --- a/app/static/css/custom.css +++ b/app/static/css/custom.css @@ -43,7 +43,7 @@ --spacing-base: 1rem; --border-radius-sm: 0.25rem; --border-radius: 0.5rem; - --border-radius-md: 0.75rem; + --border-radius-md: 0.15rem; --border-radius-lg: 1rem; --border-radius-xl: 1.5rem; --border-radius-full: 9999px; @@ -1105,7 +1105,7 @@ nav ul li a:hover { justify-content: center; width: 2rem; height: 2rem; - border-radius: 50%; + border-radius: 90%; padding: 0; background: var(--body-bg); color: var(--body-color); diff --git a/app/templates/auth/profile.html b/app/templates/auth/profile.html index 7b5d981..66deea2 100644 --- a/app/templates/auth/profile.html +++ b/app/templates/auth/profile.html @@ -2,33 +2,237 @@ {% block title %}Profile - Flask Files{% endblock %} +{% block extra_css %} + +{% endblock %} + {% block content %}

User Profile

-
-
-
- {{ current_user.username[0].upper() }} -
-

{{ current_user.username }}

-
+
+ + + +
-
-
- {{ current_user.files.count() }} - Files + +
+
+
+
+ {{ current_user.username[0].upper() }} +
+
-
- {{ current_user.shares.count() }} - Shares + +
+
+ {{ current_user.files.filter_by(is_folder=False).count() }} + Files +
+
+ {{ current_user.files.filter_by(is_folder=True).count() }} + Folders +
+
+ {{ current_user.shares.count() }} + Shares +
+
+ +
+

Edit Profile

+
+ +
+ + +
+
+ +
+
+
-
- Change Password - Manage Files + +
+
+

Theme Settings

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

Change Password

+
+ +
+ + +
+
+ + +
+
+
+ + +
+
+ +
+
+{% endblock %} + +{% block extra_js %} + {% endblock %} \ No newline at end of file diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html index a3e51cf..49a992f 100644 --- a/app/templates/dashboard.html +++ b/app/templates/dashboard.html @@ -2,13 +2,88 @@ {% block title %}Dashboard - Flask Files{% endblock %} +{% block extra_css %} + +{% endblock %} + {% block content %}

Dashboard

-
+
-
📁
+
{{ total_folders }} Folders @@ -16,7 +91,7 @@
-
📄
+
{{ total_files }} Files @@ -24,51 +99,135 @@
-
🔗
+
{{ active_shares }} Active Shares
-
-
-

Recent Files

- - {% if recent_files %} -
- {% for file in recent_files %} -
-
- {% if file.name.endswith('.pdf') %}📕 - {% elif file.name.endswith(('.jpg', '.jpeg', '.png', '.gif')) %}🖼️ - {% elif file.name.endswith(('.mp3', '.wav', '.flac')) %}🎵 - {% elif file.name.endswith(('.mp4', '.mov', '.avi')) %}🎬 - {% elif file.name.endswith(('.doc', '.docx')) %}📘 - {% elif file.name.endswith(('.xls', '.xlsx')) %}📊 - {% elif file.name.endswith(('.ppt', '.pptx')) %}📙 - {% elif file.name.endswith('.zip') %}📦 - {% else %}📄{% endif %} -
-
-
{{ file.name }}
-
- {{ (file.size / 1024)|round(1) }} KB - {{ file.updated_at.strftime('%b %d, %Y') }} -
-
+
+
+
+ {{ recent_activities|default(0) }} + Recent Activities
- {% endfor %}
- {% else %} -

No files uploaded yet. Upload your first - file.

- {% endif %}
-
- Browse Files - Upload Files + + + +
+
+

Recent Files

+ +
+
+ {% if recent_files %} + + {% else %} +
+

No files uploaded yet. Upload your first file.

+
+ {% endif %} +
+
+ + +
+
+

My Folders

+ +
+
+ {% if root_folders %} + + {% else %} +
+

No folders created yet. Create your first folder.

+
+ {% endif %} +
+{% endblock %} + +{% block extra_js %} + {% endblock %} \ No newline at end of file diff --git a/app/templates/files/browser.html b/app/templates/files/browser.html index a7bf541..a2455ca 100644 --- a/app/templates/files/browser.html +++ b/app/templates/files/browser.html @@ -6,15 +6,15 @@

File Browser

-
@@ -112,10 +112,10 @@

This folder is empty

Upload files or create a new folder to get started

- + + Upload + diff --git a/app/templates/files/upload.html b/app/templates/files/upload.html index b49d0fc..72b51db 100644 --- a/app/templates/files/upload.html +++ b/app/templates/files/upload.html @@ -116,6 +116,7 @@ const MAX_CONCURRENT_UPLOADS = 3; const folderId = {{ parent_folder.id if parent_folder else 'null' } }; + }); // Setup event listeners dropzone.addEventListener('dragover', function (e) { @@ -231,21 +232,21 @@ const fileIcon = getFileIcon(file.name); fileItem.innerHTML = ` -
- -
-
-
${file.name}
-
${file.relativePath || 'No path'}
-
${formatSize(file.size)}
-
-
+
+
-
-
- Queued -
- `; +
+
${file.name}
+
${file.relativePath || 'No path'}
+
${formatSize(file.size)}
+
+
+
+
+
+ Queued +
+ `; uploadList.appendChild(fileItem); } @@ -466,9 +467,9 @@ const alert = document.createElement('div'); alert.className = `alert ${type || 'info'}`; alert.innerHTML = ` -
${message}
- - `; +
${message}
+ + `; // Add to container alertsContainer.appendChild(alert); @@ -558,6 +559,6 @@ return 'fa-file'; } -}); + }); {% endblock %} \ No newline at end of file