uhhh lots of things
All checks were successful
Datadog Software Composition Analysis / Datadog SBOM Generation and Upload (push) Successful in 16s
Datadog Secrets Scanning / Datadog Static Analyzer (push) Successful in 14s
Datadog Static Analysis / Datadog Static Analyzer (push) Successful in 24s

This commit is contained in:
Tanishq Dubey 2025-03-08 11:17:24 -05:00
parent 0a6d5b8a90
commit c6f36d0408
7 changed files with 222 additions and 44 deletions

View File

@ -3,7 +3,11 @@ content_dir = "/home/dubey/projects/foldsite/example/content"
templates_dir = "/home/dubey/projects/foldsite/example/templates" templates_dir = "/home/dubey/projects/foldsite/example/templates"
styles_dir = "/home/dubey/projects/foldsite/example/styles" styles_dir = "/home/dubey/projects/foldsite/example/styles"
[secrets] [server]
password = "YiaysZ4g8QX1R8R" listen_address = "0.0.0.0"
aws_secret_key = "ybCvAq1GQpYg0kEeXc2LqfJl9y6/EXAMPLEKEY" listen_port = 8080
aws_key_id = "AKIASQ5ZB43T69DWV8BQ" enable_admin_browser = false
admin_password = "password"
max_threads = 4
debug = false
access_log = true

13
main.py
View File

@ -21,7 +21,13 @@ def main():
r = RouteManager(c) r = RouteManager(c)
t = TemplateHelpers(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_sibling_content_files", t.get_sibling_content_files)
server.register_template_function("get_text_document_preview", t.get_text_document_preview) 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, defaults={"path": ""})
server.register_route("/<path:path>", r.default_route) server.register_route("/<path:path>", r.default_route)
file_manager_bp = create_filemanager_blueprint(c.content_dir, url_prefix='/admin', auth_password="password") if c.admin_browser:
server.app.register_blueprint(file_manager_bp) 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: try:
server.run() server.run()

View File

@ -9,10 +9,19 @@ class Configuration:
def __init__(self, config_path): def __init__(self, config_path):
self.config_path = config_path self.config_path = config_path
self.content_dir: Path = None self.content_dir: Path = None
self.templates_dir: Path = None self.templates_dir: Path = None
self.styles_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): def load_config(self):
try: try:
with open(self.config_path, "rb") as f: with open(self.config_path, "rb") as f:
@ -40,6 +49,18 @@ class Configuration:
if not self.styles_dir: if not self.styles_dir:
raise ValueError("Config file does not contain styles_dir path") raise ValueError("Config file does not contain styles_dir path")
self.styles_dir = Path(self.styles_dir) 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): def set_globals(self):
global CONTENT_DIR, TEMPLATES_DIR, STYLES_DIR global CONTENT_DIR, TEMPLATES_DIR, STYLES_DIR

View File

@ -1,7 +1,11 @@
from dataclasses import dataclass from dataclasses import dataclass
from src.config.config import Configuration from src.config.config import Configuration
from src.rendering import GENERIC_FILE_MAPPING 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 enum import Enum
from PIL import Image from PIL import Image
@ -16,9 +20,22 @@ class ImageMetadata:
alt: str alt: str
exif: dict exif: dict
@dataclass @dataclass
class MarkdownMetadata: 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 content: str
preview: str preview: str
@ -30,6 +47,24 @@ class FileMetadata:
@dataclass @dataclass
class TemplateFile: 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 name: str
path: str path: str
proper_name: str proper_name: str
@ -51,9 +86,10 @@ class TemplateHelpers:
def __init__(self, config: Configuration): def __init__(self, config: Configuration):
self.config: Configuration = config 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] = []): def _build_metadata_for_file(self, path: str, categories: list[str] = []):
"""Builds metadata for a file"""
file_path = self.config.content_dir / path file_path = self.config.content_dir / path
for k in categories: for k in categories:
if k == "image": if k == "image":
@ -75,19 +111,37 @@ class TemplateHelpers:
ret = FileMetadata(None) ret = FileMetadata(None)
if file_path.suffix[1:].lower() == "md": if file_path.suffix[1:].lower() == "md":
ret.typeMeta = MarkdownMetadata({}, "", "") 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.content = render_markdown(file_path)
ret.typeMeta.rawContent = read_raw_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] + "..." ret.typeMeta.preview = ret.typeMeta.rawText[:500] + "..."
return ret return ret
return None return None
def get_folder_contents(self, path: str = ""): 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 search_contnet_path = self.config.content_dir / path
files = search_contnet_path.glob("*") files = search_contnet_path.glob("*")
@ -110,30 +164,70 @@ class TemplateHelpers:
for k, v in GENERIC_FILE_MAPPING.items(): for k, v in GENERIC_FILE_MAPPING.items():
if f.suffix[1:].lower() in v: if f.suffix[1:].lower() in v:
t.categories.append(k) 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.append(t)
ret = self._filter_hidden_files(ret)
return ret return ret
def get_sibling_content_files(self, path: str = ""): 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 search_contnet_path = self.config.content_dir / path
files = search_contnet_path.glob("*") files = search_contnet_path.glob("*")
return [ return [
(file.name, str(file.relative_to(self.config.content_dir))) (file.name, str(file.relative_to(self.config.content_dir)))
for file in files 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): 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 file_path = self.config.content_dir / path
with open(file_path, "r") as f: with open(file_path, "r") as f:
content = f.read(100) content = f.read(100)
return content return content
def get_sibling_content_folders(self, path: str = ""): 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 search_contnet_path = self.config.content_dir / path
files = search_contnet_path.glob("*") files = search_contnet_path.glob("*")
return [ return [
(file.name, str(file.relative_to(self.config.content_dir))) (file.name, str(file.relative_to(self.config.content_dir)))
for file in files for file in files
if file.is_dir() if file.is_dir() and not file.name.startswith("___")
] ]

View File

@ -95,6 +95,30 @@ def render_page(
template_path: Path = Path("./"), template_path: Path = Path("./"),
style_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(): if not path.exists():
return render_error_page( return render_error_page(
error_code=404, error_code=404,
@ -111,7 +135,18 @@ def render_page(
relative_path = target_file.relative_to(base_path) relative_path = target_file.relative_to(base_path)
relative_dir = target_path.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 = []
styles.append("/" + str(relative_path) + ".css") styles.append("/" + str(relative_path) + ".css")

View File

@ -1,7 +1,7 @@
from pathlib import Path from pathlib import Path
from src.config.config import Configuration from src.config.config import Configuration
from src.rendering.renderer import render_page, render_error_page 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 src.rendering.image import generate_thumbnail
from functools import lru_cache from functools import lru_cache
import os import os
@ -11,7 +11,6 @@ class RouteManager:
def __init__(self, config: Configuration): def __init__(self, config: Configuration):
self.config = config self.config = config
def _validate_and_sanitize_path(self, base_dir, requested_path): 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. 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. :return: A secure version of the requested path if valid, otherwise None.
""" """
# Normalize both paths # Normalize both paths
base_dir = os.path.abspath(base_dir) base_dir = Path(base_dir)
requested_path = os.path.abspath(requested_path) requested_path: Path = base_dir / requested_path
# Check if the requested path is within the base directory # Check if the requested path is within the base directory
if not requested_path.startswith(base_dir): if requested_path < base_dir:
return None return None
# Ensure the path does not contain any '..' or '.' components # Ensure the path does not contain any '..' or '.' components
@ -33,32 +32,33 @@ class RouteManager:
secure_path_parts = secure_path.split(os.sep) secure_path_parts = secure_path.split(os.sep)
for part in secure_path_parts: for part in secure_path_parts:
if part == '.' or part == '..': if part == "." or part == "..":
print("Illegal path nice try")
return None return None
# Reconstruct the secure path # Reconstruct the secure path
secure_path = os.path.join(base_dir, *secure_path_parts) 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 return secure_path
def _ensure_route(self, path: str): 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") file_path: Path = self.config.content_dir / (path if path else "index.md")
if file_path < self.config.content_dir: if file_path < self.config.content_dir:
raise Exception("Illegal path") raise Exception("Illegal path")
for part in file_path.parts: if not self._validate_and_sanitize_path(
if part.startswith("__"): self.config.content_dir, str(file_path)
raise Exception("Illegal path") ):
if not self._validate_and_sanitize_path(self.config.content_dir, str(file_path)):
raise Exception("Illegal path") raise Exception("Illegal path")
def default_route(self, path: str): def default_route(self, path: str):
@ -80,6 +80,15 @@ class RouteManager:
) )
def get_style(self, path: str): 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 file_path: Path = self.config.styles_dir / path
if file_path.exists(): if file_path.exists():
return send_file(file_path) return send_file(file_path)
@ -91,8 +100,16 @@ class RouteManager:
self.config.templates_dir, self.config.templates_dir,
) )
@lru_cache(maxsize=None)
def get_static(self, path: str): 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 file_path: Path = self.config.content_dir / path
if file_path.exists(): if file_path.exists():
# Check to see if the file is an image, if it is, render a thumbnail # Check to see if the file is an image, if it is, render a thumbnail

View File

@ -13,8 +13,8 @@ class Server(BaseApplication):
host: str = "0.0.0.0", host: str = "0.0.0.0",
port: int = 8080, port: int = 8080,
template_functions: Dict[str, Callable] = None, template_functions: Dict[str, Callable] = None,
enable_admin_browser: bool = False,
workers: int = multiprocessing.cpu_count() // 2 + 1, workers: int = multiprocessing.cpu_count() // 2 + 1,
access_log: bool = True,
options=None, options=None,
): ):
if template_functions is None: if template_functions is None:
@ -28,9 +28,9 @@ class Server(BaseApplication):
self.app.secret_key = "your_secret_key" self.app.secret_key = "your_secret_key"
self.options = options or { self.options = options or {
"bind": f"{self.host}:{self.port}", "bind": f"{self.host}:{self.port}",
"reload": True, # Enable automatic reloading "reload": self.debug,
"threads": workers, "threads": workers,
"accesslog": "-", "accesslog": "-" if access_log else None,
} }
for name, func in template_functions.items(): for name, func in template_functions.items():
self.register_template_function(name, func) self.register_template_function(name, func)