diff --git a/main.py b/main.py index 538357c..46fadee 100644 --- a/main.py +++ b/main.py @@ -3,6 +3,7 @@ from src.routes.routes import RouteManager from src.config.args import create_parser from src.config.config import Configuration from src.rendering.helpers import TemplateHelpers +from src.server.file_manager import create_filemanager_blueprint def main(): @@ -15,6 +16,7 @@ def main(): r = RouteManager(c) t = TemplateHelpers(c) + print("here") server = Server() server.register_template_function("get_sibling_content_files", t.get_sibling_content_files) @@ -27,7 +29,13 @@ def main(): server.register_route("/", r.default_route, defaults={"path": ""}) server.register_route("/", r.default_route) - server.run() + file_manager_bp = create_filemanager_blueprint(c.content_dir, url_prefix='/admin', auth_password="password") + server.app.register_blueprint(file_manager_bp) + + try: + server.run() + except Exception as e: + print(e) if __name__ == "__main__": diff --git a/src/rendering/helpers.py b/src/rendering/helpers.py index cf694b7..6bd6a0f 100644 --- a/src/rendering/helpers.py +++ b/src/rendering/helpers.py @@ -1,10 +1,9 @@ from dataclasses import dataclass from src.config.config import Configuration from src.rendering import GENERIC_FILE_MAPPING -from src.rendering.markdown import render_markdown +from src.rendering.markdown import render_markdown, read_raw_markdown, rendered_markdown_to_plain_text from enum import Enum -from thumbhash import image_to_thumbhash from PIL import Image from datetime import datetime import frontmatter @@ -78,9 +77,9 @@ class TemplateHelpers: ret.typeMeta = MarkdownMetadata({}, "", "") ret.typeMeta.fontmatter = frontmatter.load(file_path) ret.typeMeta.content = render_markdown(file_path) - ret.typeMeta.preview = ret.typeMeta.content[:100] - if "#" in ret.typeMeta.preview: - ret.typeMeta.preview = ret.typeMeta.preview.split("#")[0] + ret.typeMeta.rawContent = read_raw_markdown(file_path) + ret.typeMeta.rawText = rendered_markdown_to_plain_text(ret.typeMeta.content) + ret.typeMeta.preview = ret.typeMeta.rawText[:500] + "..." return ret return None diff --git a/src/rendering/markdown.py b/src/rendering/markdown.py index 6dda505..4f6eb50 100644 --- a/src/rendering/markdown.py +++ b/src/rendering/markdown.py @@ -1,6 +1,7 @@ import frontmatter import mistune from pathlib import Path +from bs4 import BeautifulSoup mistune_render = mistune.create_markdown( @@ -10,7 +11,18 @@ mistune_render = mistune.create_markdown( def render_markdown(path: Path) -> str: - with open(path, "r") as f: + with open(path, "r", encoding="utf-8") as f: obj = frontmatter.load(f) res = mistune_render(obj.content) - return res \ No newline at end of file + return res + + +def read_raw_markdown(path: Path) -> str: + with open(path, "r") as f: + obj = frontmatter.load(f) + return obj.content + + +def rendered_markdown_to_plain_text(html): + text = "\n\n".join(BeautifulSoup(html, features="html.parser").stripped_strings) + return text diff --git a/src/rendering/renderer.py b/src/rendering/renderer.py index ed83ad2..2a9a918 100644 --- a/src/rendering/renderer.py +++ b/src/rendering/renderer.py @@ -1,6 +1,6 @@ from pathlib import Path from typing import Optional -from pprint import pprint as pp +from pprint import pprint from flask import render_template_string, send_file from src.rendering import GENERIC_FILE_MAPPING @@ -23,15 +23,15 @@ def count_file_extensions(path): def determine_type(path: Path) -> tuple[str, str, Optional[str]]: if path.is_file(): # Find extension in GENERIC_FILE_MAPPING, it may match multiple - generic_mapping = None + generic_mapping = [] for file_type, extensions in GENERIC_FILE_MAPPING.items(): if path.suffix[1:] in extensions: - if generic_mapping is None: + if not generic_mapping: generic_mapping = [file_type] else: generic_mapping.append(file_type) generic_mapping.reverse() - if generic_mapping is None: + if not generic_mapping: generic_mapping = ["other"] extension = path.suffix[1:] return "file", generic_mapping, extension @@ -39,7 +39,7 @@ def determine_type(path: Path) -> tuple[str, str, Optional[str]]: files_map = count_file_extensions(path) if files_map: most_seen_extension = max(files_map, key=files_map.get) - generic_mapping = None + generic_mapping = [] for file_type, extensions in GENERIC_FILE_MAPPING.items(): if most_seen_extension[1:] in extensions: if generic_mapping is None: @@ -97,10 +97,10 @@ def render_page( ): if not path.exists(): return render_error_page( - 404, - "Not Found", - "The requested resource was not found on this server.", - template_path, + error_code=404, + error_message="Not Found", + error_description="The requested resource was not found on this server.", + template_path=template_path, ) target_path = path target_file = path diff --git a/src/routes/routes.py b/src/routes/routes.py index db96295..9d281f2 100644 --- a/src/routes/routes.py +++ b/src/routes/routes.py @@ -51,7 +51,6 @@ class RouteManager: Any illegal path should raise an exception """ file_path: Path = self.config.content_dir / (path if path else "index.md") - print(file_path) if file_path < self.config.content_dir: raise Exception("Illegal path") @@ -65,11 +64,7 @@ class RouteManager: def default_route(self, path: str): try: self._ensure_route(path) - print("all good") - print(path) - print("=============") except Exception as e: - print(e) return render_error_page( 404, "Not Found", diff --git a/src/server/file_manager.py b/src/server/file_manager.py new file mode 100644 index 0000000..db467d7 --- /dev/null +++ b/src/server/file_manager.py @@ -0,0 +1,357 @@ +import os +import shutil +from flask import Blueprint, request, render_template_string, send_from_directory, redirect, url_for, flash, session +from werkzeug.utils import secure_filename + +def create_filemanager_blueprint(base_dir, url_prefix='/files', auth_password=None): + """ + Creates a Flask blueprint providing a simple file manager with a clipboard-style + move operation (cut/paste) for the given base directory. + """ + base_dir = os.path.abspath(base_dir) + os.makedirs(base_dir, exist_ok=True) + filemanager = Blueprint('filemanager', __name__, url_prefix=url_prefix) + + def secure_path(path): + """Ensure that the provided relative path stays within the base_dir.""" + safe_path = os.path.abspath(os.path.join(base_dir, path)) + if not safe_path.startswith(base_dir): + raise Exception("Invalid path") + return safe_path + + @filemanager.before_request + def require_login(): + if auth_password is not None: + # Allow access to login and logout pages without being authenticated. + if request.endpoint in ['filemanager.login', 'filemanager.logout']: + return None + if not session.get('filemanager_authenticated'): + return redirect(url_for('filemanager.login', next=request.url)) + return None + + @filemanager.route('/login', methods=['GET', 'POST']) + def login(): + if request.method == 'POST': + password = request.form.get('password', '') + if password == auth_password: + session['filemanager_authenticated'] = True + flash("Logged in successfully") + next_url = request.args.get('next') or url_for('filemanager.index') + return redirect(next_url) + else: + flash("Incorrect password") + return render_template_string(''' + + + Login + +

Login

+ {% with messages = get_flashed_messages() %} + {% if messages %} + + {% endif %} + {% endwith %} +
+ + +
+ + + ''') + + @filemanager.route('/logout', methods=['POST']) + def logout(): + session.pop('filemanager_authenticated', None) + flash("Logged out") + return redirect(url_for('filemanager.login')) + + @filemanager.route('/') + def index(): + # Determine current directory from query parameter; defaults to the base. + rel_path = request.args.get('path', '') + try: + abs_path = secure_path(rel_path) + except Exception: + return "Invalid path", 400 + if not os.path.isdir(abs_path): + return "Not a directory", 400 + + # Build a list of items (files and folders) in the current directory. + items = [] + for entry in os.listdir(abs_path): + entry_path = os.path.join(abs_path, entry) + rel_entry_path = os.path.join(rel_path, entry) if rel_path else entry + items.append({ + 'name': entry, + 'is_dir': os.path.isdir(entry_path), + 'path': rel_entry_path + }) + items.sort(key=lambda x: (not x['is_dir'], x['name'].lower())) + parent = os.path.dirname(rel_path) if rel_path else '' + + # Minimal HTML template that displays the file tree along with a clipboard if active. + template = """ + + + + File Manager + + +

File Manager

+

Current Directory: /{{ rel_path }}

+ {% if rel_path %} +

Go up

+ {% endif %} + + {% if session.clipboard %} +
+

Clipboard

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

Upload File

+
+ + + +
+
+

Create New Directory

+
+ + + + +
+ + + + """ + return render_template_string(template, items=items, rel_path=rel_path, parent=parent) + + @filemanager.route('/download') + def download(): + rel_path = request.args.get('path', '') + try: + abs_path = secure_path(rel_path) + except Exception: + return "Invalid path", 400 + if not os.path.isfile(abs_path): + return "File not found", 404 + directory = os.path.dirname(abs_path) + filename = os.path.basename(abs_path) + return send_from_directory(directory, filename, as_attachment=True) + + @filemanager.route('/delete', methods=['POST']) + def delete(): + rel_path = request.form.get('path', '') + try: + abs_path = secure_path(rel_path) + except Exception: + return "Invalid path", 400 + if os.path.isfile(abs_path): + os.remove(abs_path) + elif os.path.isdir(abs_path): + shutil.rmtree(abs_path) + else: + return "Not found", 404 + flash("Deleted successfully") + parent = os.path.dirname(rel_path) + return redirect(url_for('filemanager.index', path=parent)) + + @filemanager.route('/upload', methods=['POST']) + def upload(): + rel_path = request.form.get('path', '') + try: + abs_path = secure_path(rel_path) + except Exception: + return "Invalid path", 400 + if not os.path.isdir(abs_path): + return "Not a directory", 400 + file = request.files.get('file') + if file: + filename = secure_filename(file.filename) + file.save(os.path.join(abs_path, filename)) + flash("Uploaded successfully") + return redirect(url_for('filemanager.index', path=rel_path)) + + @filemanager.route('/rename', methods=['POST']) + def rename(): + rel_path = request.form.get('path', '') + new_name = request.form.get('new_name', '') + try: + abs_path = secure_path(rel_path) + new_rel_path = os.path.join(os.path.dirname(rel_path), new_name) + new_abs_path = secure_path(new_rel_path) + except Exception: + return "Invalid path", 400 + os.rename(abs_path, new_abs_path) + flash("Renamed successfully") + parent = os.path.dirname(rel_path) + return redirect(url_for('filemanager.index', path=parent)) + + @filemanager.route('/cut', methods=['POST']) + def cut(): + # Add one or more items to the clipboard. + paths = request.form.getlist('paths') + session['clipboard'] = paths + flash("Item(s) added to clipboard") + return redirect(request.referrer or url_for('filemanager.index')) + + @filemanager.route('/paste', methods=['POST']) + def paste(): + # Move clipboard items to the destination (current folder). + dest = request.form.get('dest', '') + try: + dest_abs = secure_path(dest) + except Exception: + return "Invalid destination", 400 + if not os.path.isdir(dest_abs): + return "Destination not a directory", 400 + clipboard = session.get('clipboard', []) + for rel_path in clipboard: + try: + abs_path = secure_path(rel_path) + filename = os.path.basename(abs_path) + shutil.move(abs_path, os.path.join(dest_abs, filename)) + except Exception as e: + flash(f"Error moving {rel_path}: {str(e)}") + session.pop('clipboard', None) + flash("Moved items successfully") + return redirect(url_for('filemanager.index', path=dest)) + + @filemanager.route('/mkdir', methods=['POST']) + def mkdir(): + # The current directory is passed as a hidden form field. + rel_path = request.form.get('path', '') + # The new directory name is passed from the form. + dirname = request.form.get('dirname', '') + if not dirname: + flash("Directory name cannot be empty") + return redirect(url_for('filemanager.index', path=rel_path)) + try: + # Use secure_filename to sanitize the new directory name. + safe_dirname = secure_filename(dirname) + # Build the target path relative to the current directory. + new_dir_path = secure_path(os.path.join(rel_path, safe_dirname)) + os.makedirs(new_dir_path, exist_ok=False) + flash(f"Directory '{dirname}' created successfully") + except Exception as e: + flash(f"Error creating directory: {str(e)}") + return redirect(url_for('filemanager.index', path=rel_path)) + + @filemanager.route('/cancel_move', methods=['POST']) + def cancel_move(): + session.pop('clipboard', None) + flash("Move cancelled") + return redirect(request.referrer or url_for('filemanager.index')) + + return filemanager diff --git a/src/server/server.py b/src/server/server.py index a6d2af3..875e9a2 100644 --- a/src/server/server.py +++ b/src/server/server.py @@ -1,28 +1,58 @@ from flask import Flask from typing import Callable, Dict +from src.server.file_manager import create_filemanager_blueprint +from gunicorn.app.base import BaseApplication +import multiprocessing -class Server: +class Server(BaseApplication): + def __init__( self, debug: bool = True, host: str = "0.0.0.0", port: int = 8080, - template_functions: Dict[str, Callable] = {}, + template_functions: Dict[str, Callable] = None, + enable_admin_browser: bool = False, + workers: int = multiprocessing.cpu_count() // 2 + 1, + options=None, ): + if template_functions is None: + template_functions = {} + self.debug = debug self.host = host self.port = port self.app = Flask(__name__) + self.application = self.app + self.app.secret_key = "your_secret_key" + self.options = options or { + "bind": f"{self.host}:{self.port}", + "reload": True, # Enable automatic reloading + "threads": workers, + "accesslog": "-", + } for name, func in template_functions.items(): self.register_template_function(name, func) - - def run(self): - self.app.debug = self.debug - self.app.run(host=self.host, port=self.port) + super().__init__() + for name, func in template_functions.items(): + self.register_template_function(name, func) + super(Server, self).__init__() def register_template_function(self, name, func): self.app.jinja_env.globals.update({name: func}) + def load_config(self): + config = { + key: value + for key, value in self.options.items() + if key in self.cfg.settings and value is not None + } + for key, value in config.items(): + self.cfg.set(key.lower(), value) + + def load(self): + return self.application + def register_route(self, route, func, defaults=None): self.app.add_url_rule(route, func.__name__, func, defaults=defaults)