diff --git a/config.toml b/config.toml index e3b16a1..9bc794f 100644 --- a/config.toml +++ b/config.toml @@ -3,7 +3,11 @@ content_dir = "/home/dubey/projects/foldsite/example/content" templates_dir = "/home/dubey/projects/foldsite/example/templates" styles_dir = "/home/dubey/projects/foldsite/example/styles" -[secrets] -password = "YiaysZ4g8QX1R8R" -aws_secret_key = "ybCvAq1GQpYg0kEeXc2LqfJl9y6/EXAMPLEKEY" -aws_key_id = "AKIASQ5ZB43T69DWV8BQ" \ No newline at end of file +[server] +listen_address = "0.0.0.0" +listen_port = 8080 +enable_admin_browser = false +admin_password = "password" +max_threads = 4 +debug = false +access_log = true diff --git a/main.py b/main.py index a98965f..fc0d687 100644 --- a/main.py +++ b/main.py @@ -21,7 +21,13 @@ def main(): r = RouteManager(c) t = TemplateHelpers(c) - server = Server() + server = Server( + debug=c.debug, + host=c.listen_address, + port=c.listen_port, + access_log=c.access_log, + workers=c.max_threads, + ) server.register_template_function("get_sibling_content_files", t.get_sibling_content_files) server.register_template_function("get_text_document_preview", t.get_text_document_preview) @@ -33,8 +39,9 @@ def main(): server.register_route("/", r.default_route, defaults={"path": ""}) server.register_route("/", r.default_route) - file_manager_bp = create_filemanager_blueprint(c.content_dir, url_prefix='/admin', auth_password="password") - server.app.register_blueprint(file_manager_bp) + if c.admin_browser: + file_manager_bp = create_filemanager_blueprint(c.content_dir, url_prefix='/admin', auth_password=c.admin_password) + server.app.register_blueprint(file_manager_bp) try: server.run() diff --git a/src/config/config.py b/src/config/config.py index 96f4971..44674ba 100644 --- a/src/config/config.py +++ b/src/config/config.py @@ -9,10 +9,19 @@ class Configuration: def __init__(self, config_path): self.config_path = config_path + self.content_dir: Path = None self.templates_dir: Path = None self.styles_dir: Path = None + self.listen_address: str = "127.0.0.1" + self.listen_port: int = 8080 + self.debug: bool = False + self.access_log: bool = True + self.max_threads: int = 4 + self.admin_browser: bool = False + self.admin_password: str = None + def load_config(self): try: with open(self.config_path, "rb") as f: @@ -40,6 +49,18 @@ class Configuration: if not self.styles_dir: raise ValueError("Config file does not contain styles_dir path") self.styles_dir = Path(self.styles_dir) + + server = self.config_data.get("server", {}) + if not server: + raise ValueError("Config file does not contain server section") + + self.listen_address = server.get("listen_address", self.listen_address) + self.listen_port = server.get("listen_port", self.listen_port) + self.debug = server.get("debug", self.debug) + self.access_log = server.get("access_log", self.access_log) + self.max_threads = server.get("max_threads", self.max_threads) + self.admin_browser = server.get("admin_browser", self.admin_browser) + self.admin_password = server.get("admin_password", self.admin_password) def set_globals(self): global CONTENT_DIR, TEMPLATES_DIR, STYLES_DIR diff --git a/src/rendering/helpers.py b/src/rendering/helpers.py index 6bd6a0f..0707473 100644 --- a/src/rendering/helpers.py +++ b/src/rendering/helpers.py @@ -1,7 +1,11 @@ from dataclasses import dataclass from src.config.config import Configuration from src.rendering import GENERIC_FILE_MAPPING -from src.rendering.markdown import render_markdown, read_raw_markdown, rendered_markdown_to_plain_text +from src.rendering.markdown import ( + render_markdown, + read_raw_markdown, + rendered_markdown_to_plain_text, +) from enum import Enum from PIL import Image @@ -16,9 +20,22 @@ class ImageMetadata: alt: str exif: dict + @dataclass class MarkdownMetadata: - fontmatter: dict + """ + A class to represent metadata for a Markdown file. + + Attributes: + ---------- + frontmatter : dict + A dictionary containing the front matter of the Markdown file. + content : str + The main content of the Markdown file. + preview : str + A preview or summary of the Markdown content. + """ + frontmatter: dict content: str preview: str @@ -30,6 +47,24 @@ class FileMetadata: @dataclass class TemplateFile: + """ + A class to represent a template file with its associated metadata. + + Attributes: + ---------- + name (str): The name of the file. + path (str): The file path. + proper_name (str): The proper name of the file. + extension (str): The file extension. + categories (list[str]): A list of categories associated with the file. + date_modified (str): The date the file was last modified. + date_created (str): The date the file was created. + size_kb (int): The size of the file in kilobytes. + metadata (ImageMetadata | FileMetadata | None): Metadata associated with the file, + which can be either image metadata, file metadata, or None. + dir_item_count (int): The number of items in the directory if the file is a directory. + is_dir (bool): A flag indicating whether the file is a directory. + """ name: str path: str proper_name: str @@ -51,9 +86,10 @@ class TemplateHelpers: def __init__(self, config: Configuration): self.config: Configuration = config + def _filter_hidden_files(self, files): + return [f for f in files if not f.name.startswith("___")] - def build_metadata_for_file(self, path: str, categories: list[str] = []): - """Builds metadata for a file""" + def _build_metadata_for_file(self, path: str, categories: list[str] = []): file_path = self.config.content_dir / path for k in categories: if k == "image": @@ -75,19 +111,37 @@ class TemplateHelpers: ret = FileMetadata(None) if file_path.suffix[1:].lower() == "md": ret.typeMeta = MarkdownMetadata({}, "", "") - ret.typeMeta.fontmatter = frontmatter.load(file_path) + ret.typeMeta.frontmatter = frontmatter.load(file_path) ret.typeMeta.content = render_markdown(file_path) ret.typeMeta.rawContent = read_raw_markdown(file_path) - ret.typeMeta.rawText = rendered_markdown_to_plain_text(ret.typeMeta.content) + ret.typeMeta.rawText = rendered_markdown_to_plain_text( + ret.typeMeta.content + ) ret.typeMeta.preview = ret.typeMeta.rawText[:500] + "..." return ret return None - def get_folder_contents(self, path: str = ""): - """Returns the contents of a folder as a list of TemplateFile objects + """ + Retrieve the contents of a folder and return a list of TemplateFile objects. - The metadata field is populated with the appropriate metadata object + Args: + path (str): The relative path to the folder within the content directory. Defaults to an empty string, + which refers to the root content directory. + + Returns: + list: A list of TemplateFile objects representing the files and directories within the specified folder. + + The function performs the following steps: + 1. Constructs the full path to the folder by combining the content directory with the provided path. + 2. Retrieves all files and directories within the specified folder. + 3. Iterates over each file and directory, creating a TemplateFile object with metadata such as name, + path, proper name, extension, categories, date modified, date created, size in KB, metadata, directory + item count, and whether it is a directory. + 4. If the item is a file, it assigns categories based on the file extension using a predefined mapping. + 5. Builds additional metadata for each file. + 6. Filters out hidden files from the list. + 7. Returns the list of TemplateFile objects. """ search_contnet_path = self.config.content_dir / path files = search_contnet_path.glob("*") @@ -110,30 +164,70 @@ class TemplateHelpers: for k, v in GENERIC_FILE_MAPPING.items(): if f.suffix[1:].lower() in v: t.categories.append(k) - t.metadata = self.build_metadata_for_file(f, t.categories) + t.metadata = self._build_metadata_for_file(f, t.categories) ret.append(t) + ret = self._filter_hidden_files(ret) return ret def get_sibling_content_files(self, path: str = ""): + """ + Retrieves a list of sibling content files in the specified directory. + + Args: + path (str): The relative path within the content directory to search for files. + Defaults to an empty string, which means the root of the content directory. + + Returns: + list: A list of tuples, where each tuple contains the file name and its relative path + to the content directory. Only files that do not start with "___" are included. + """ search_contnet_path = self.config.content_dir / path files = search_contnet_path.glob("*") return [ (file.name, str(file.relative_to(self.config.content_dir))) for file in files - if file.is_file() + if file.is_file() and not file.name.startswith("___") ] def get_text_document_preview(self, path: str): + """ + Generates a preview of the text document located at the given path. + + This method reads the first 100 characters from the specified text file + and returns it as a string. The file path is constructed by combining + the content directory from the configuration with the provided path. + + Args: + path (str): The relative path to the text document within the content directory. + + Returns: + str: A string containing the first 100 characters of the text document. + + Raises: + FileNotFoundError: If the file at the specified path does not exist. + IOError: If an I/O error occurs while reading the file. + """ file_path = self.config.content_dir / path with open(file_path, "r") as f: content = f.read(100) return content def get_sibling_content_folders(self, path: str = ""): + """ + Retrieves a list of sibling content folders within a specified directory. + + Args: + path (str): A relative path from the content directory to search within. Defaults to an empty string, + which means the search will be conducted in the content directory itself. + + Returns: + list of tuple: A list of tuples where each tuple contains the folder name and its relative path + to the content directory. Only directories that do not start with "___" are included. + """ search_contnet_path = self.config.content_dir / path files = search_contnet_path.glob("*") return [ (file.name, str(file.relative_to(self.config.content_dir))) for file in files - if file.is_dir() + if file.is_dir() and not file.name.startswith("___") ] diff --git a/src/rendering/renderer.py b/src/rendering/renderer.py index 2a9a918..3382637 100644 --- a/src/rendering/renderer.py +++ b/src/rendering/renderer.py @@ -95,6 +95,30 @@ def render_page( template_path: Path = Path("./"), style_path: Path = Path("./"), ): + """ + Renders a web page based on the provided path and optional base, template, and style paths. + + Args: + path (Path): The path to the target file or directory to render. + base_path (Path, optional): The base path to use for relative paths. Defaults to Path("./"). + template_path (Path, optional): The path to the directory containing HTML templates. Defaults to Path("./"). + style_path (Path, optional): The path to the directory containing CSS styles. Defaults to Path("./"). + + Returns: + str: The rendered HTML content of the page, or an error page if the target path does not exist or no suitable template is found. + + Raises: + Exception: If the base template (base.html) is not found in the template_path. + + Notes: + - If the target path does not exist, a 404 error page is rendered. + - If the target path is a file, the function attempts to determine its type, category, and extension. + - The function generates a list of possible CSS styles based on the target path and its type/category. + - The function searches for suitable HTML templates based on the target path and its type/category. + - If no suitable template is found, the function either sends the file directly (if it's a file) or renders a 404 error page. + - If the target file is a document, its content is rendered as Markdown. + - The function ensures that the base template (base.html) exists before rendering the final content. + """ if not path.exists(): return render_error_page( error_code=404, @@ -111,7 +135,18 @@ def render_page( relative_path = target_file.relative_to(base_path) relative_dir = target_path.relative_to(base_path) - # Generate the possible paths for style + """ + The styles are ordered in the following manner: + + Specific style for the target path (e.g., /path/to/target.css). + Specific styles for the type and extension in the current and parent directories + (e.g., /path/to/__file.html.css). + Specific styles for the type and category in the current and parent directories + (e.g., /path/to/__file.document.css). + Base style (/base.css). + This ordering ensures that the most specific styles are applied first, followed by + more general styles, and finally the base style. + """ styles = [] styles.append("/" + str(relative_path) + ".css") diff --git a/src/routes/routes.py b/src/routes/routes.py index 9d281f2..4d507ea 100644 --- a/src/routes/routes.py +++ b/src/routes/routes.py @@ -1,7 +1,7 @@ from pathlib import Path from src.config.config import Configuration from src.rendering.renderer import render_page, render_error_page -from flask import send_file +from flask import send_file from src.rendering.image import generate_thumbnail from functools import lru_cache import os @@ -11,7 +11,6 @@ class RouteManager: def __init__(self, config: Configuration): self.config = config - def _validate_and_sanitize_path(self, base_dir, requested_path): """ Validate and sanitize the requested path to ensure it does not traverse above the base directory. @@ -21,11 +20,11 @@ class RouteManager: :return: A secure version of the requested path if valid, otherwise None. """ # Normalize both paths - base_dir = os.path.abspath(base_dir) - requested_path = os.path.abspath(requested_path) + base_dir = Path(base_dir) + requested_path: Path = base_dir / requested_path # Check if the requested path is within the base directory - if not requested_path.startswith(base_dir): + if requested_path < base_dir: return None # Ensure the path does not contain any '..' or '.' components @@ -33,32 +32,33 @@ class RouteManager: secure_path_parts = secure_path.split(os.sep) for part in secure_path_parts: - if part == '.' or part == '..': + if part == "." or part == "..": + print("Illegal path nice try") return None # Reconstruct the secure path secure_path = os.path.join(base_dir, *secure_path_parts) + secure_path = Path(secure_path) + + # Check if path exists + if not secure_path.exists(): + raise Exception("Illegal path") + + for part in secure_path.parts: + if part.startswith("___"): + print("hidden file") + raise Exception("Illegal path") + return secure_path def _ensure_route(self, path: str): - """ - Escapes the path for anything like - a path execution or injection attack - evaluates the path and ensures that it it does not - go above the self.content.content_dir - If any part of the path contains __, __{foldername}, or __{filename}, - that is a hidden file or folder and should raise an exception - Any illegal path should raise an exception - """ file_path: Path = self.config.content_dir / (path if path else "index.md") if file_path < self.config.content_dir: raise Exception("Illegal path") - for part in file_path.parts: - if part.startswith("__"): - raise Exception("Illegal path") - - if not self._validate_and_sanitize_path(self.config.content_dir, str(file_path)): + if not self._validate_and_sanitize_path( + self.config.content_dir, str(file_path) + ): raise Exception("Illegal path") def default_route(self, path: str): @@ -80,6 +80,15 @@ class RouteManager: ) def get_style(self, path: str): + try: + self._validate_and_sanitize_path(self.config.styles_dir, path) + except Exception as e: + return render_error_page( + 404, + "Not Found", + f"The requested resource was not found on this server. {e}", + self.config.templates_dir, + ) file_path: Path = self.config.styles_dir / path if file_path.exists(): return send_file(file_path) @@ -91,8 +100,16 @@ class RouteManager: self.config.templates_dir, ) - @lru_cache(maxsize=None) def get_static(self, path: str): + try: + self._validate_and_sanitize_path(self.config.content_dir, path) + except Exception as e: + return render_error_page( + 404, + "Not Found", + "The requested resource was not found on this server.", + self.config.templates_dir, + ) file_path: Path = self.config.content_dir / path if file_path.exists(): # Check to see if the file is an image, if it is, render a thumbnail diff --git a/src/server/server.py b/src/server/server.py index 875e9a2..da7ed25 100644 --- a/src/server/server.py +++ b/src/server/server.py @@ -13,8 +13,8 @@ class Server(BaseApplication): host: str = "0.0.0.0", port: int = 8080, template_functions: Dict[str, Callable] = None, - enable_admin_browser: bool = False, workers: int = multiprocessing.cpu_count() // 2 + 1, + access_log: bool = True, options=None, ): if template_functions is None: @@ -28,9 +28,9 @@ class Server(BaseApplication): self.app.secret_key = "your_secret_key" self.options = options or { "bind": f"{self.host}:{self.port}", - "reload": True, # Enable automatic reloading + "reload": self.debug, "threads": workers, - "accesslog": "-", + "accesslog": "-" if access_log else None, } for name, func in template_functions.items(): self.register_template_function(name, func)