diff --git a/CHANGELOG.md b/CHANGELOG.md index b08a28b9..098baeff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,6 +61,26 @@ ## 0.10.0 +### Feed Pagination + +- Feat: Add feed pagination with three types: `htmx`, `manual`, `js` +- Feat: HTMX infinite scroll with partial loading +- Feat: JavaScript infinite scroll using Intersection Observer +- Feat: Manual page navigation with prev/next controls +- Feat: Configurable `items_per_page`, `pagination_type`, and `enabled` per feed + +### Security + +- Feat: SHA-256 integrity verification for HTMX downloads (25+ versions supported) +- Feat: Path traversal protection for feed slugs +- Feat: XSS prevention in templates using `|tojson` filter +- Feat: Fail securely if HTMX download fails (no CDN fallback) +- Feat: Comprehensive security test suite for feeds + +### Other + +- Feat: Improve feed name sanitization with Python identifier conversion +- Feat: Add pagination implementation guide documentation - Fix: `auto_description` now more accurately returns plain text, does not cut off words, and add an ellipsis. - Fix: article_html now typed such that it may be a dict without warning - publish_source now only supports using post models that include a dumps command, i.e. no longer frontmatter post objects diff --git a/docs/pagination-implementation-guide.md b/docs/pagination-implementation-guide.md new file mode 100644 index 00000000..82ba6fe8 --- /dev/null +++ b/docs/pagination-implementation-guide.md @@ -0,0 +1,357 @@ +--- +title: Pagination Implementation Guide +description: Guide for implementing pagination in Markata templates with manual, HTMX, and JavaScript options + +--- + +# Pagination Implementation Guide + +This guide provides clear instructions for implementing pagination in your Markata templates. The pagination system is integrated into the feeds plugin and supports three types: manual, HTMX, and JavaScript infinite scroll. + +## Core Components + +### Feeds Plugin with Pagination +**Location:** `markata/plugins/feeds.py` + +The feeds plugin includes built-in pagination support with these features: +- Automatic pagination for any feed +- Three pagination types: manual, HTMX, JavaScript +- Configurable items per page +- SEO-friendly URL generation +- Template context variables + +### Pagination Plugin (Core Logic) +**Location:** `markata/plugins/pagination.py` + +Core pagination functionality that provides: +- Pagination configuration models +- URL generation helpers +- Template rendering context +- Support for all pagination types + +## Quick Setup + +### 1. Basic Configuration + +Add to your `markata.yaml`: + +```yaml +# Enable pagination globally (optional, per-feed config also available) +pagination: + default: + enabled: true + items_per_page: 20 + pagination_type: 'manual' # 'manual', 'htmx', 'js' + +# Or configure per feed +feeds: + blog: + template: "feed.html" + partial_template: "feed_partial.html" + enabled: true + items_per_page: 10 + pagination_type: 'htmx' +``` + +### 2. Template Variables + +All pagination templates receive these variables: + +```jinja2 +{{ markata }} # Markata instance +{{ feed }} # Current feed object +{{ posts }} # Posts for current page +{{ page }} # Current page number (1-based) +{{ total_pages }} # Total number of pages +{{ has_prev }} # Boolean: has previous page? +{{ has_next }} # Boolean: has next page? +{{ prev_page }} # Previous page number or null +{{ next_page }} # Next page number or null +{{ pagination_enabled }} # Boolean: is pagination enabled? +{{ pagination_config }} # PaginationConfig object +{{ feed_name }} # Feed slug/name +{{ pagination_js_url }} # URL to pagination config JS (when using JS pagination) +``` + +## Pagination Types + +### 1. Manual Pagination + +**Best for:** SEO, accessibility, traditional blogs +**Features:** +- Traditional click navigation +- Page numbers +- Previous/Next buttons +- Clean permanent URLs +- Works without JavaScript + +**Configuration:** +```yaml +feeds: + blog: + pagination_type: 'manual' + items_per_page: 10 + show_page_numbers: true + max_page_links: 7 +``` + +**Template Implementation:** + +The feeds plugin automatically handles manual pagination when `pagination_type: 'manual'`. Your template just needs to include pagination controls: + +```jinja2 +{% if pagination_enabled %} +{% set config = pagination_config %} + +
+ {% if prev_page %} + {% if page > 2 %} + + ← Previous + + {% endif %} + + + {{ page }} / {{ total_pages }} + + + {% if has_next %} + + Next → + + {% endif %} +
+{% endif %} +``` + +### 2. HTMX Pagination + +**Best for:** Progressive enhancement, modern UX with fallback +**Features:** +- Infinite scroll with 14KB HTMX library +- Progressive enhancement built-in +- SEO-friendly URLs +- Graceful JavaScript fallback + +**Configuration:** +```yaml +feeds: + blog: + pagination_type: 'htmx' + items_per_page: 15 + show_loading_skeleton: true + auto_load_threshold: 200 +``` + +**Template Implementation:** + +```jinja2 +{% if pagination_enabled and pagination_context.pagination_type == 'htmx' %} +{% if has_next %} +
+
+{% endif %} + + + + +{% endif %} +``` + +### 3. JavaScript Pagination + +**Best for:** Custom infinite scroll, zero external dependencies +**Features:** +- Custom infinite scroll using Intersection Observer +- Zero external dependencies +- AJAX content loading +- URL history management +- Loading indicators + +**Configuration:** +```yaml +feeds: + blog: + pagination_type: 'js' + items_per_page: 12 + show_loading_skeleton: true + auto_load_threshold: 300 + show_end_message: true +``` + +**Template Implementation:** + +```jinja2 +{% if pagination_enabled and pagination_context.pagination_type == 'js' %} + + + +
+ + +{% endif %} +``` + +## URL Structure + +The pagination system generates clean, SEO-friendly URLs: + +- **First page:** `/feed-name/` +- **Subsequent pages:** `/feed-name/page/2/`, `/feed-name/page/3/`, etc. +- **Partial files:** `/feed-name/partial/`, `/feed-name/page/2/partial/` + +## Static Assets + +### Required Files + +The pagination system includes these static files (automatically created): + +```bash +markata/static/js/ +├── pagination.js # JavaScript infinite scroll module +├── pagination-config.js # Generated config (JS pagination only) +└── htmx.org@1.9.10.min.js # Secure HTMX download + +markata/static/css/ +└── pagination.css # Pagination styles and animations +``` + +## Advanced Configuration + +### Per-Feed Customization + +```yaml +feeds: + blog: + enabled: true + pagination_type: 'manual' + items_per_page: 8 + show_page_numbers: true + max_page_links: 5 + show_loading_skeleton: false + auto_load_threshold: 100 + show_end_message: false + + news: + enabled: true + pagination_type: 'htmx' + items_per_page: 20 + show_loading_skeleton: true + auto_load_threshold: 200 + show_end_message: true + + portfolio: + enabled: true + pagination_type: 'js' + items_per_page: 12 + show_loading_skeleton: true + auto_load_threshold: 300 + show_end_message: true +``` + +### Template Includes + +Create reusable template components: + +**`includes/pagination_info.html`:** +```jinja2 +{% if pagination_enabled %} +
+ Page {{ page }} of {{ total_pages }} + Showing {{ posts|length }} items +
+{% endif %} +``` + +**`includes/post_card.html`:** +```jinja2 +
+

{{ post.title }}

+ {% if post.date %} + + {% endif %} + {% if post.description %} +

{{ post.description }}

+ {% endif %} +
+``` + +Then in your main template: +```jinja2 +{% include "includes/pagination_info.html" %} + +
+ {% for post in posts %} + {% include "includes/post_card.html" %} + {% endfor %} +
+``` + +## CLI Helper + +Get example configuration: + +```bash +markata pagination config-example +``` + +This outputs a ready-to-use configuration block for your `markata.yaml`. + +## Testing Your Implementation + +1. **Manual Pagination:** Verify page numbers and Previous/Next links work +2. **HTMX Pagination:** Test infinite scroll and JavaScript fallback +3. **JavaScript Pagination:** Verify auto-loading and URL updates +4. **SEO:** Check that each page has unique titles and meta tags +5. **Accessibility:** Test keyboard navigation and screen readers + +## Troubleshooting + +### Common Issues + +**Pagination not showing:** +- Ensure `enabled: true` is set for your feed +- Check that pagination plugin loads before feeds plugin + +**HTMX not working:** +- Verify HTMX script is loaded +- Check that partial template exists and is accessible + +**JavaScript errors:** +- Ensure browser supports Intersection Observer +- Check console for fetch API errors + +**URL issues:** +- Verify your web server supports clean URLs +- Check that page 1 redirects work correctly + +### Debug Mode + +Add this to templates to debug pagination data: + +```jinja2 +{% if markata.config.debug %} +
{{ pagination_config | pprint }}
+
Page: {{ page }}, Total: {{ total_pages }}
+
Has Prev: {{ has_prev }}, Has Next: {{ has_next }}
+{% endif %} +``` + +This comprehensive guide should help you implement any pagination type in your Markata templates. Choose the pagination type that best fits your use case and customize templates to match your site's design. \ No newline at end of file diff --git a/markata.toml b/markata.toml index 04a9f1eb..55a7024c 100644 --- a/markata.toml +++ b/markata.toml @@ -28,6 +28,9 @@ markdown_backend = 'markdown-it-py' default_cache_expire = 1209600 # subroute = "docs" +# HTMX version for pagination +htmx_version = "2.0.8" + # set the subroute if you are deploying to a subroute of a site # make sure you enable the subroute plugin # subroute = "docs" @@ -292,5 +295,39 @@ plugin = "markata.plugins.md_it_wikilinks:wikilinks_plugin" config = { markata = "markata" } [markata.glob] -glob_patterns = ["docs/**/*.md","CHANGELOG.md"] +glob_patterns = [ +"docs/**/*.md", +"pages/**/*.md", +"CHANGELOG.md", +] use_gitignore = true + +[[markata.feeds]] +slug = 'tag/htmx' +filter = "date<=today and published==True" +sort = "date" +reverse = true +description = 'Test HTMX pagination feed' +enabled = true +items_per_page = 2 +pagination_type = 'htmx' + +[[markata.feeds]] +slug = 'tag/manual' +filter = "date<=today and published==True" +sort = "date" +reverse = true +description = 'Test manual pagination feed' +enabled = true +items_per_page = 3 +pagination_type = 'manual' + +[[markata.feeds]] +slug = 'tag/js' +filter = "date<=today and published==True" +sort = "date" +reverse = true +description = 'Test JS pagination feed' +enabled = true +items_per_page = 2 +pagination_type = 'js' diff --git a/markata/plugins/feeds.py b/markata/plugins/feeds.py index 46f9b2af..f52a2f84 100644 --- a/markata/plugins/feeds.py +++ b/markata/plugins/feeds.py @@ -188,7 +188,10 @@ """ +from __future__ import annotations + import datetime +import re import shutil import textwrap import warnings @@ -197,6 +200,7 @@ from typing import Any from typing import List from typing import Optional +from urllib.request import urlopen import jinja2 import pydantic @@ -204,15 +208,15 @@ from jinja2 import Template from jinja2 import Undefined from pydantic import ConfigDict +from pydantic import Field from pydantic import field_validator +from rich.console import Console from rich.jupyter import JupyterMixin from rich.pretty import Pretty from rich.table import Table -from markata import Markata from markata import __version__ from markata import background -from markata.errors import DeprecationWarning from markata.hookspec import hook_impl from markata.hookspec import register_attr from markata.plugins.jinja_env import get_template @@ -220,15 +224,68 @@ if TYPE_CHECKING: from frontmatter import Post - from rich.console import Console + + from markata import Markata + + +def to_pythonic_identifier(name: str) -> str: + """ + Convert a string to a valid Python identifier. + + This function handles various problematic characters that might appear + in feed names or slugs, making them suitable for use as Python attribute + names and dictionary keys. + + Rules applied: + - Replace spaces, slashes, dots, and other non-alphanumeric characters with underscores + - Convert to lowercase + - Remove leading/trailing underscores + - Ensure the result starts with a letter or underscore + - Collapse multiple consecutive underscores to a single one + + Examples: + 'project-gallery' -> 'project_gallery' + 'tag/htmx' -> 'tag_htmx' + 'My Feed Name' -> 'my_feed_name' + '123start' -> '_123start' + """ + if not name: + return "_unnamed" + + # Replace non-alphanumeric characters (except underscores) with underscores + pythonic = re.sub(r"[^a-zA-Z0-9_]", "_", str(name)) + + # Convert to lowercase + pythonic = pythonic.lower() + + # Collapse multiple consecutive underscores + pythonic = re.sub(r"_+", "_", pythonic) + + # Remove leading and trailing underscores + pythonic = pythonic.strip("_") + + # Ensure it starts with a letter or underscore (not a digit) + if pythonic and pythonic[0].isdigit(): + pythonic = "_" + pythonic + + # Handle empty result or result that became empty after processing + if not pythonic: + pythonic = "_unnamed" + + return pythonic class SilentUndefined(Undefined): + """A Jinja2 Undefined subclass that silently returns empty string on errors.""" + def _fail_with_undefined_error(self, *args, **kwargs): return "" -class MarkataFilterError(RuntimeError): ... +class MarkataFilterError(RuntimeError): + """Raised when a feed filter expression fails.""" + + ... class FeedConfig(pydantic.BaseModel, JupyterMixin): @@ -255,6 +312,12 @@ class FeedConfig(pydantic.BaseModel, JupyterMixin): sitemap_template: str = "sitemap.xml" xsl_template: str = "rss.xsl" + # Pagination configuration + enabled: bool = False + items_per_page: int = 10 + pagination_type: str = "htmx" # htmx, manual, js + per_page: int = 10 # backwards compatibility + model_config = ConfigDict( validate_assignment=True, # Config model arbitrary_types_allowed=True, @@ -269,11 +332,11 @@ class FeedConfig(pydantic.BaseModel, JupyterMixin): @classmethod def default_name(cls, v, info) -> str: if v: - return v + return to_pythonic_identifier(str(v)) slug = info.data.get("slug") if not slug: raise ValueError("Either name or slug must be provided") - return str(slug).replace("-", "_") + return to_pythonic_identifier(str(slug)) @field_validator("slug", mode="before") @classmethod @@ -290,7 +353,7 @@ def __rich_console__(self) -> "Console": return self.markata.console @property - def __rich__(self) -> Pretty: + def __rich__(self): return lambda: Pretty(self) @@ -300,8 +363,9 @@ class Feed(pydantic.BaseModel, JupyterMixin): ## Usage ``` python - from markata import Markata - m = Markata() + if not TYPE_CHECKING: + from markata import Markata + m = Markata() # access posts for a feed m.feeds.docs.posts @@ -312,7 +376,7 @@ class Feed(pydantic.BaseModel, JupyterMixin): """ config: FeedConfig - markata: Markata = pydantic.Field(exclude=True) + markata: Any = Field(exclude=True) model_config = ConfigDict( validate_assignment=False, @@ -331,6 +395,22 @@ def name(self) -> str: @property def posts(self): + # Get posts from instance state or compute normally + return self._get_posts() + + def _get_posts(self, override_posts=None): + """ + Get posts with optional override for pagination. + + Args: + override_posts: If provided, returns these posts instead of computing + + Returns: + PrettyList of posts + """ + if override_posts is not None: + return PrettyList(override_posts) + posts = self.map("post") if self.config.head is not None and self.config.tail is not None: head_posts = posts[: self.config.head] @@ -393,6 +473,8 @@ def dump_bytecode(self, bucket): class FeedsConfig(pydantic.BaseModel): feeds: List[FeedConfig] = [FeedConfig(slug="archive")] + htmx_version: str = "2.0.8" + skip_htmx_integrity_check: bool = False @property def jinja_env(self): @@ -430,10 +512,313 @@ def __rich__(self) -> Pretty: @hook_impl(tryfirst=True) +@register_attr("config_models") def config_model(markata: Markata) -> None: markata.config_models.append(FeedsConfig) +@hook_impl(tryfirst=True) +def htmx_config_model(markata: Markata) -> None: + """Register HTMX configuration model with validation.""" + + class HtmxConfig(pydantic.BaseModel): + version: str = "2.0.8" + + model_config = ConfigDict( + validate_assignment=True, + extra="forbid", + ) + + markata.config_models.append(HtmxConfig) + + +@hook_impl +def configure(markata: Markata) -> None: + """ + Configure feeds during configuration phase. + """ + _download_htmx_if_needed(markata) + _copy_pagination_static_files(markata, Path(markata.config.output_dir)) + + +def _download_htmx_if_needed(markata: Markata) -> None: + """ + Download HTMX library to static directory if needed with integrity verification. + """ + import hashlib + from urllib.error import HTTPError + from urllib.error import URLError + from urllib.request import Request + + htmx_version = markata.config.htmx_version + htmx_filename = "htmx.min.js" + htmx_static_path = Path(markata.config.output_dir) / "static" / "js" / htmx_filename + htmx_url = f"https://unpkg.com/htmx.org@{htmx_version}/dist/htmx.min.js" + + # Known SHA-256 hashes for HTMX versions + HTMX_INTEGRITY_HASHES = { + "1.9.10": "b3bdcf5c741897a53648b1207fff0469a0d61901429ba1f6e88f98ebd84e669e", + "2.0.8": "22283ef68cb7545914f0a88a1bdedc7256a703d1d580c1d255217d0a50d31313", + "2.0.7": "60231ae6ba9db3825eb15a261122d5f55921c4d53b66bf637dc18b4ee27c79f9", + "2.0.6": "b6768eed4f3af85b73a75054701bd60e17cac718aef2b7f6b254e5e0e2045616", + "2.0.5": "f601807715bde32e458b73821e16c5641a3d90dfb670f6ebd986f128b8222fcf", + "2.0.4": "e209dda5c8235479f3166defc7750e1dbcd5a5c1808b7792fc2e6733768fb447", + "2.0.3": "491955cd1810747d7d7b9ccb936400afb760e06d25d53e4572b64b6563b2784e", + "2.0.2": "e1746d9759ec0d43c5c284452333a310bb5fd7285ebac4b2dc9bf44d72b5a887", + "2.0.1": "6d4aaa4b0d3e8b4c91f8d97b92a361a19b1bd4544dea3f668fdc3e62a63995df", + "2.0.0": "0fc57ba0e655504d282bb6ec1c3d89240cde9f2ce1c393d5b38a95c5bc6da875", + "1.9.12": "449317ade7881e949510db614991e195c3a099c4c791c24dacec55f9f4a2a452", + "1.9.11": "d15107cc7f040a9e83b1b66176fd927ad40b5e0255813a03f8ccfeed46ee42b0", + "1.9.9": "96a334a9570a382cf9c61a1f86d55870ba1c65e166cc5bcae98ddd8cdabeb886", + "1.9.8": "c4fce4dc5cc9c8c3c9bf1aa788d54bb2cb25cd27114eb06551494ff61c30d6fb", + "1.9.7": "30c95cb75e7f7c9471c2bf43fa3db0a30a39077764295b15c405869fed7e5764", + "1.9.6": "cbb723c305cf6d6315c890909815523588509e2e092a59f8cfc4a885829689d5", + "1.9.5": "76a9887f1ce3bf8f88bea3b327f1e74b9d9b42e1dd9cb8237a87a74261d5d042", + "1.9.4": "5c88af44013df62fde8a5e4fdf524d8a16834a28b1d15e34ae0994ac27cd4c7e", + "1.9.3": "8f567d21cbe0553643db48866b2377a3bbb9247f8d924428002c2b847f28b23c", + "1.9.2": "fd346e9c8639d4624893fc455f2407a09b418301736dd18ebbb07764637fb478", + "1.9.1": "d7bff1d0f45e3418fa820d8a6f0de1ca5e87562f218a0f06add08652c7691a9c", + "1.9.0": "97df3adfbf23b873d9a3a80f7143d801a32604ba29de9a33f21a92a171076aa8", + "1.8.5": "705fb60063bf5270b7077409b848b57ea24d2277b806aa04efea513287bf63a6", + "1.8.4": "df72edb141a16578945a0356c8a6a37239015251962071639b99b0184691ed1d", + "1.8.3": "df811b5d27b3dddfec9a858b437b0c7302a56959450f0f9c133ef356c25fcf1c", + "1.8.2": "91e7fb193c4a6a5d3bb56ed0a7007933664e7803da389a696de61147a6f66058", + "1.8.1": "1a1c942f7bb50dcc2198b2f3c6cc64199332e32a5ba08e7bd2215aa0a1966a55", + "1.8.0": "914e05e274362f2e166fc5a8cf6272e2042d9b9e50647678c64c579dcb5fa441", + } + + expected_hash = HTMX_INTEGRITY_HASHES.get(htmx_version) + if not expected_hash: + if markata.config.skip_htmx_integrity_check: + markata.console.warn( + f"No integrity hash available for HTMX version {htmx_version}, skipping verification" + ) + expected_hash = None + else: + raise ValueError( + f"No integrity hash available for HTMX version {htmx_version}. " + f"You can add 'skip_htmx_integrity_check: true' to your config to skip verification, " + f"or add the hash to HTMX_INTEGRITY_HASHES in markata/plugins/feeds.py" + ) + + # Download if file doesn't exist + if not htmx_static_path.exists(): + try: + with warnings.catch_warnings(): + warnings.simplefilter("ignore", category=ResourceWarning) + + # Ensure static/js directory exists + htmx_static_path.parent.mkdir(parents=True, exist_ok=True) + + # Download HTMX with timeout and integrity verification + request = Request(htmx_url, headers={"User-Agent": "Markata/1.0"}) + with urlopen(request, timeout=10) as response: + content = response.read() + + # Verify content integrity if hash is available + if expected_hash: + actual_hash = hashlib.sha256(content).hexdigest() + if actual_hash != expected_hash: + raise ValueError( + f"HTMX integrity check failed. Expected: {expected_hash}, Got: {actual_hash}" + ) + + htmx_static_path.write_bytes(content) + + verification_status = ( + "verified" if expected_hash else "without verification" + ) + markata.console.print( + f"Downloaded HTMX {htmx_version} to {htmx_static_path} ({verification_status})" + ) + + except (URLError, HTTPError, ValueError) as e: + markata.console.error(f"Failed to download HTMX: {e}") + # Critical security: no fallback to CDN + raise RuntimeError( + f"HTMX download failed: {e}. Cannot proceed without verified HTMX." + ) + except Exception as e: + markata.console.error(f"Unexpected error downloading HTMX: {e}") + raise RuntimeError(f"HTMX download failed: {e}") + + return True + + +def _generate_pagination_js( + markata: Markata, pagination_config: dict, output_dir: Path +) -> str: + """ + Generate JavaScript file for pagination and return its path. + + Args: + markata: Markata instance + pagination_config: Pagination configuration data + output_dir: Output directory for JS file + + Returns: + Path to generated JS file relative to output_dir + """ + import json + + js_content = f"""// Generated JavaScript for pagination +window.paginationData = {json.dumps(pagination_config)}; +""" + + js_dir = output_dir / "static" / "js" + js_dir.mkdir(parents=True, exist_ok=True) + + js_file = js_dir / "pagination-config.js" + js_file.write_text(js_content) + + return "/static/js/pagination-config.js" + + +def _copy_pagination_static_files(markata: Markata, output_dir: Path) -> None: + """ + Copy pagination static files (JS and CSS) from markata package to output directory. + + Args: + markata: Markata instance + output_dir: Output directory for static files + """ + import importlib.resources + + # Get the markata static directory + static_package = importlib.resources.files("markata") / "static" + + # Copy pagination.js + js_src = static_package / "js" / "pagination.js" + js_dst_dir = output_dir / "static" / "js" + js_dst_dir.mkdir(parents=True, exist_ok=True) + js_dst = js_dst_dir / "pagination.js" + + if js_src.is_file(): + js_dst.write_text(js_src.read_text()) + markata.console.print(f"Copied pagination.js to {js_dst}") + + # Copy pagination.css + css_src = static_package / "css" / "pagination.css" + css_dst_dir = output_dir / "static" / "css" + css_dst_dir.mkdir(parents=True, exist_ok=True) + css_dst = css_dst_dir / "pagination.css" + + if css_src.is_file(): + css_dst.write_text(css_src.read_text()) + markata.console.print(f"Copied pagination.css to {css_dst}") + + +def _sanitize_feed_slug(slug: str) -> str: + """ + Sanitize feed slug to prevent path traversal attacks. + + Args: + slug: User-provided feed slug + + Returns: + Sanitized slug safe for filesystem use + + Raises: + ValueError: If slug contains dangerous characters + """ + import re + + if not slug: + raise ValueError("Feed slug cannot be empty") + + # Remove path traversal sequences (allow forward slashes for nested paths) + if ".." in slug or "\\" in slug: + raise ValueError(f"Invalid characters in feed slug: {slug}") + + # Allow alphanumeric characters, hyphens, underscores, and forward slashes for nested paths + if not re.match(r"^[a-zA-Z0-9_/-]+$", slug): + raise ValueError(f"Feed slug contains invalid characters: {slug}") + + # Prevent leading or trailing slashes and double slashes + if slug.startswith("/") or slug.endswith("/") or "//" in slug: + raise ValueError(f"Feed slug has invalid slash usage: {slug}") + + # Sanitize by removing any path traversal attempts + safe_slug = slug.replace("..", "") + + # Additional safety check + if safe_slug != slug: + raise ValueError(f"Feed slug attempts path traversal: {slug}") + + return safe_slug + + +def _ensure_head_links(markata: Markata) -> None: + """ + Ensure pagination CSS and JS links are in markata.config.head.link + without duplicating existing links. + """ + pagination_css_href = "/static/css/pagination.css" + pagination_js_config_href = "/static/js/pagination-config.js" + pagination_js_href = "/static/js/pagination.js" + htmx_version = markata.config.htmx_version + htmx_static_href = "/static/js/htmx.min.js" + + # Try to download HTMX first + if not _download_htmx_if_needed(markata): + # Fallback to CDN if download fails + htmx_cdn_href = f"https://unpkg.com/htmx.org@{htmx_version}" + else: + htmx_cdn_href = htmx_static_href + + # Helper function to get href from link (supports both dicts and objects) + def get_href(link): + if hasattr(link, "href"): + return link.href + return link.get("href", "") + + # Helper function to get src from script (supports both dicts and objects) + def get_src(script): + if hasattr(script, "src"): + return script.src + return script.get("src", "") + + # Check if pagination CSS is already in head.links + css_exists = any( + get_href(link) == pagination_css_href for link in markata.config.head.link + ) + + # Add CSS link if not already present + if not css_exists: + markata.config.head.link.append( + {"rel": "stylesheet", "href": pagination_css_href} + ) + + # Check if pagination JS config is already in head.script + js_config_exists = any( + get_src(script) == pagination_js_config_href + for script in markata.config.head.script + ) + + # Check if pagination JS is already in head.script + js_exists = any( + get_src(script) == pagination_js_href for script in markata.config.head.script + ) + + # Add JS config link if not already present + if not js_config_exists: + markata.config.head.script.append({"src": pagination_js_config_href}) + + # Add JS link if not already present + if not js_exists: + markata.config.head.script.append({"src": pagination_js_href}) + + # Check if HTMX is already in head.script + htmx_exists = any( + get_src(script) in [htmx_cdn_href, htmx_static_href] + for script in markata.config.head.script + ) + + # Add HTMX link if not already present + if not htmx_exists: + markata.config.head.script.append({"src": htmx_cdn_href}) + + @hook_impl @register_attr("feeds") def pre_render(markata: Markata) -> None: @@ -448,13 +833,21 @@ def save(markata: Markata) -> None: """ Creates a new feed page for each page in the config. """ + _ensure_head_links(markata) with markata.cache as cache: for feed in markata.feeds.values(): - create_page( - markata, - feed, - cache, - ) + if feed.config.enabled: + create_paginated_feed( + markata, + feed, + cache, + ) + else: + create_page( + markata, + feed, + cache, + ) home = Path(str(markata.config.output_dir)) / "index.html" archive = Path(str(markata.config.output_dir)) / "archive" / "index.html" @@ -478,6 +871,7 @@ def save(markata: Markata) -> None: if should_write: xsl_file.write_text(xsl) + def create_page( markata: Markata, feed: Feed, @@ -489,7 +883,10 @@ def create_page( template = get_template(markata.jinja_env, feed.config.template) partial_template = get_template(markata.jinja_env, feed.config.partial_template) - canonical_url = f"{markata.config.url}/{feed.config.slug}/" + + # Security: Sanitize feed slug to prevent path traversal attacks + safe_slug = _sanitize_feed_slug(feed.config.slug) + canonical_url = f"{markata.config.url}/{safe_slug}/" # Get templates mtime to bust cache when any template changes templates_mtime = get_templates_mtime(markata.jinja_env) @@ -503,7 +900,9 @@ def create_page( if cache_key_posts not in markata._feed_hash_cache: # Use post slugs and published dates instead of full to_dict() # This provides a stable, lightweight cache key - posts_data = feed.map("(post.slug, str(getattr(post, 'date', '')), getattr(post, 'title', ''))") + posts_data = feed.map( + "(post.slug, str(getattr(post, 'date', '')), getattr(post, 'title', ''))" + ) markata._feed_hash_cache[cache_key_posts] = str(sorted(posts_data)) posts_hash_data = markata._feed_hash_cache[cache_key_posts] @@ -534,17 +933,13 @@ def create_page( feed_sitemap_from_cache = markata.precache.get(feed_sitemap_key) feed_atom_from_cache = markata.precache.get(feed_atom_key) - output_file = Path(markata.config.output_dir) / feed.config.slug / "index.html" + output_file = Path(markata.config.output_dir) / safe_slug / "index.html" partial_output_file = ( - Path(markata.config.output_dir) / feed.config.slug / "partial" / "index.html" - ) - rss_output_file = Path(markata.config.output_dir) / feed.config.slug / "rss.xml" - sitemap_output_file = ( - Path(markata.config.output_dir) / feed.config.slug / "sitemap.xml" - ) - atom_output_file = ( - Path(markata.config.output_dir) / feed.config.slug / "atom.xml" + Path(markata.config.output_dir) / safe_slug / "partial" / "index.html" ) + rss_output_file = Path(markata.config.output_dir) / safe_slug / "rss.xml" + sitemap_output_file = Path(markata.config.output_dir) / safe_slug / "sitemap.xml" + atom_output_file = Path(markata.config.output_dir) / safe_slug / "atom.xml" # Create all directories in one batch partial_output_file.parent.mkdir(exist_ok=True, parents=True) @@ -597,7 +992,9 @@ def create_page( if feed.config.sitemap: if feed_sitemap_from_cache is None: from_cache = False - sitemap_template = get_template(markata.jinja_env, feed.config.sitemap_template) + sitemap_template = get_template( + markata.jinja_env, feed.config.sitemap_template + ) feed_sitemap = sitemap_template.render(markata=markata, feed=feed) cache.set(feed_sitemap_key, feed_sitemap) else: @@ -662,6 +1059,187 @@ def create_page( atom_output_file.write_text(feed_atom) +def create_paginated_feed( + markata: Markata, + feed: Feed, + cache, +) -> None: + """ + Create paginated feed pages. + """ + posts = feed.posts + per_page = getattr(feed.config, "items_per_page", feed.config.per_page) + + # Validate per_page to prevent division by zero + if per_page <= 0: + raise ValueError( + f"items_per_page must be a positive integer, got {per_page} for feed '{feed.config.slug}'" + ) + + total_posts = len(posts) + + # Handle empty feeds gracefully + if total_posts == 0: + total_pages = 1 # Still create one empty page + else: + total_pages = (total_posts + per_page - 1) // per_page + + # Security: Sanitize feed slug to prevent path traversal attacks + safe_slug = _sanitize_feed_slug(feed.config.slug) + + template = get_template(markata, feed.config.template) + canonical_url = f"{markata.config.url}/{safe_slug}/" + + for page_num in range(1, total_pages + 1): + start_idx = (page_num - 1) * per_page + end_idx = start_idx + per_page + page_posts = posts[start_idx:end_idx] + + # Create pagination context + pagination_context = { + "current_page": page_num, + "total_pages": total_pages, + "total_posts": total_posts, + "per_page": per_page, + "has_prev": page_num > 1, + "has_next": page_num < total_pages, + "prev_page": page_num - 1 if page_num > 1 else None, + "next_page": page_num + 1 if page_num < total_pages else None, + "pagination_type": feed.config.pagination_type, + } + + # Generate JS config file if JS pagination is used + pagination_js_url = None + if feed.config.pagination_type == "js": + pagination_config = { + "enabled": True, + "type": feed.config.pagination_type, + "page": page_num, + "totalPages": total_pages, + "totalPosts": total_posts, + "itemsShown": len(page_posts), + "feedName": safe_slug, + "hasNext": page_num < total_pages, + "config": { + "pagination_type": feed.config.pagination_type, + "posts_per_page": getattr(feed.config, "posts_per_page", None), + "template": getattr(feed.config, "template", None), + }, + } + pagination_js_url = _generate_pagination_js( + markata, pagination_config, Path(markata.config.output_dir) + ) + + # Create a feed object for this page (no state mutation) + page_feed = Feed(config=feed.config, markata=feed.markata) + + key = markata.make_hash( + "feeds", + "paginated", + template, + __version__, + markata.config.url, + markata.config.description, + feed.config.title, + [p.content for p in page_posts], + canonical_url, + page_num, + pagination_context, + ) + + html_key = markata.make_hash(key, "html") + html_partial_key = markata.make_hash(key, "partial_html") + + # Determine output file paths + if page_num == 1: + # First page goes to the main feed index + output_file = Path(markata.config.output_dir) / safe_slug / "index.html" + else: + # Subsequent pages go to numbered subdirectories + output_file = ( + Path(markata.config.output_dir) + / safe_slug + / str(page_num) + / "index.html" + ) + + partial_output_file = output_file.parent / "partial" / "index.html" + output_file.parent.mkdir(exist_ok=True, parents=True) + partial_output_file.parent.mkdir(exist_ok=True, parents=True) + + # Check cache + feed_html_from_cache = markata.precache.get(html_key) + feed_html_partial_from_cache = markata.precache.get(html_partial_key) + + from_cache = True + if feed_html_from_cache is None: + from_cache = False + feed_html = template.render( + markata=markata, + __version__=__version__, + post=feed.config.model_dump(), + url=markata.config.url, + config=markata.config, + feed=page_feed, + pagination_enabled=True, + pagination_config=pagination_context, + pagination_context=pagination_context, + title=feed.config.title, + page=page_num, + total_pages=total_pages, + total_posts=total_posts, + has_next=pagination_context["has_next"], + has_prev=pagination_context["has_prev"], + next_page=pagination_context["next_page"], + prev_page=pagination_context["prev_page"], + feed_name=safe_slug, + posts=page_posts, + page_posts=page_posts, + pagination_js_url=pagination_js_url, + ) + cache.set(html_key, feed_html) + else: + feed_html = feed_html_from_cache + + if feed_html_partial_from_cache is None: + from_cache = False + # For HTMX partials, use items-only template to avoid duplicating page structure + items_partial_template = get_template(markata, "feed_items_partial.html") + feed_html_partial = items_partial_template.render( + markata=markata, + __version__=__version__, + post=feed.config.model_dump(), + url=markata.config.url, + config=markata.config, + feed=page_feed, + card_template=feed.config.card_template, + posts=page_posts, + page_posts=page_posts, + has_next=pagination_context["has_next"], + next_page=pagination_context["next_page"], + feed_name=safe_slug, + page=page_num, + total_pages=total_pages, + total_posts=total_posts, + pagination_context=pagination_context, + ) + cache.set(html_partial_key, feed_html_partial) + else: + feed_html_partial = feed_html_partial_from_cache + + if from_cache and output_file.exists() and partial_output_file.exists(): + continue + + current_html = output_file.read_text() if output_file.exists() else "" + if current_html != feed_html: + output_file.write_text(feed_html) + + current_partial_html = ( + partial_output_file.read_text() if partial_output_file.exists() else "" + ) + if current_partial_html != feed_html_partial: + partial_output_file.write_text(feed_html_partial) + @background.task def create_card( @@ -803,7 +1381,7 @@ def refresh(self): for feed_config in self.markata.config.feeds: # Ensure feed has a name, falling back to slug if needed if feed_config.name is None and feed_config.slug is not None: - feed_config.name = feed_config.slug.replace("-", "_") + feed_config.name = to_pythonic_identifier(str(feed_config.slug)) elif feed_config.name is None and feed_config.slug is None: feed_config.slug = "archive" feed_config.name = "archive" @@ -824,10 +1402,10 @@ def items(self): return [(key, self[key]) for key in self.config] def __getitem__(self, key: str) -> Any: - return getattr(self, key.replace("-", "_").lower()) + return getattr(self, to_pythonic_identifier(str(key))) def get(self, key: str, default: Any = None) -> Any: - return getattr(self, key.replace("-", "_").lower(), default) + return getattr(self, to_pythonic_identifier(str(key)), default) def _dict_panel(self, config) -> str: """pretty print configs with rich""" diff --git a/markata/static/css/pagination.css b/markata/static/css/pagination.css new file mode 100644 index 00000000..f688fe17 --- /dev/null +++ b/markata/static/css/pagination.css @@ -0,0 +1,180 @@ +/* Pagination Styles */ + +.loading-indicator { + display: flex; + align-items: center; + justify-content: center; + gap: 0.75rem; + padding: 2rem; + color: var(--text-color-muted, #6b7280); +} + +.spinner { + width: 1.5rem; + height: 1.5rem; + border: 2px solid var(--border-color, #e5e7eb); + border-top: 2px solid var(--primary-bg, #3b82f6); + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +.pagination-info { + color: var(--text-color-muted, #6b7280); + font-size: 0.875rem; +} + +/* Manual Pagination */ +.pagination { + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; + margin: 2rem 0; + padding: 1rem; + background: var(--bg-color, #fff); + border-radius: 8px; + border: 1px solid var(--border-color, #e5e7eb); +} + +.pagination-links { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; + justify-content: center; +} + +.page-link { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 2.5rem; + height: 2.5rem; + padding: 0 0.75rem; + border: 1px solid var(--border-color, #e5e7eb); + border-radius: 6px; + background: var(--bg-color, #fff); + color: var(--text-color, #374151); + text-decoration: none; + font-size: 0.875rem; + font-weight: 500; + transition: all 0.2s ease; +} + +.page-link:hover { + background: var(--hover-bg, #f3f4f6); + border-color: var(--hover-border, #d1d5db); + color: var(--text-color, #374151); +} + +.page-link.current { + background: var(--primary-bg, #3b82f6); + border-color: var(--primary-border, #3b82f6); + color: var(--primary-text, #fff); + font-weight: 600; +} + +.page-link[aria-label="First page"], +.page-link[aria-label="Last page"] { + font-size: 0.75rem; + min-width: auto; + padding: 0 0.5rem; +} + +/* Error and End Messages */ +.end-message { + text-align: center; + color: var(--text-color-muted, #6b7280); + font-style: italic; + margin-top: 1rem; +} + +.error-message { + text-align: center; + padding: 2rem; + background: var(--error-bg, #fef2f2); + border: 1px solid var(--error-border, #fecaca); + border-radius: 6px; + color: var(--error-text, #dc2626); + margin: 2rem 0; +} + +.error-message a { + color: var(--error-text, #dc2626); + text-decoration: underline; +} + +.error-message a:hover { + text-decoration: none; +} + +/* Responsive design */ +@media (max-width: 640px) { + .pagination-links { + gap: 0.25rem; + } + + .page-link { + min-width: 2rem; + height: 2rem; + padding: 0 0.5rem; + font-size: 0.75rem; + } + + .page-link[aria-label="First page"], + .page-link[aria-label="Last page"] { + display: none; + } +} + +/* Dark mode support */ +@media (prefers-color-scheme: dark) { + .loading-indicator, + .pagination-info, + .end-message { + color: var(--text-color-muted-dark, #9ca3af); + } + + .spinner { + border-color: var(--border-color-dark, #374151); + border-top-color: var(--primary-bg-dark, #2563eb); + } + + .pagination { + background: var(--bg-color-dark, #1f2937); + border-color: var(--border-color-dark, #374151); + } + + .page-link { + background: var(--bg-color-dark, #1f2937); + border-color: var(--border-color-dark, #374151); + color: var(--text-color-dark, #f9fafb); + } + + .page-link:hover { + background: var(--hover-bg-dark, #374151); + border-color: var(--hover-border-dark, #4b5563); + } + + .error-message { + background: var(--error-bg-dark, #7f1d1d); + border-color: var(--error-border-dark, #991b1b); + color: var(--error-text-dark, #fecaca); + } + + .error-message a { + color: var(--error-text-dark, #fecaca); + } +} + +/* Accessible focus styles */ +.loading-indicator:focus, +.error-message:focus, +.page-link:focus { + outline: 2px solid var(--primary-bg, #3b82f6); + outline-offset: 2px; +} \ No newline at end of file diff --git a/markata/static/js/pagination.js b/markata/static/js/pagination.js new file mode 100644 index 00000000..6f222b9c --- /dev/null +++ b/markata/static/js/pagination.js @@ -0,0 +1,213 @@ +// JavaScript-based infinite scroll pagination +class InfiniteScroll { + constructor(paginationData) { + this.currentPage = paginationData.page; + this.totalPages = paginationData.totalPages; + this.totalPosts = paginationData.totalPosts; + this.itemsShown = paginationData.itemsShown; + this.feedName = paginationData.feedName; + this.loading = false; + this.retryCount = 0; + this.maxRetries = 3; + + this.setupObserver(); + + // Check if we need to load more content initially + // (when initial content doesn't fill the viewport) + this.checkInitialFill(); + } + + setupObserver() { + // Create a persistent element at the bottom to observe + this.createPersistentTrigger(); + + this.observer = new IntersectionObserver((entries) => { + if (entries[0].isIntersecting && !this.loading) { + this.loadMore(); + } + }, { + rootMargin: '100px' + }); + + this.observeTrigger(); + } + + createPersistentTrigger() { + // Create a persistent trigger that won't be replaced + this.persistentTrigger = document.createElement('div'); + this.persistentTrigger.id = 'js-scroll-trigger'; + this.persistentTrigger.style.height = '1px'; + this.persistentTrigger.style.width = '100%'; + + // Insert it before the template trigger + const templateTrigger = document.getElementById('scroll-trigger'); + if (templateTrigger) { + templateTrigger.parentNode.insertBefore(this.persistentTrigger, templateTrigger); + } else { + // Fallback: add to end of feed container + const feed = document.getElementById('feed'); + if (feed) { + feed.appendChild(this.persistentTrigger); + } + } + } + + observeTrigger() { + if (this.persistentTrigger && this.observer) { + this.observer.observe(this.persistentTrigger); + } + } + + checkInitialFill() { + // Wait a frame for layout to complete + requestAnimationFrame(() => { + this.fillViewportIfNeeded(); + }); + } + + fillViewportIfNeeded() { + // If we're already loading, no more pages, or exceeded retries, stop + if (this.loading || this.currentPage >= this.totalPages || this.retryCount >= this.maxRetries) return; + + // Check if the trigger is visible in the viewport + // (meaning content doesn't fill the page) + if (this.isTriggerVisible()) { + this.loadMore().then((success) => { + if (success) { + // Reset retry count on success + this.retryCount = 0; + // After loading, check again if we need more + // Use requestAnimationFrame to wait for DOM update + requestAnimationFrame(() => { + this.fillViewportIfNeeded(); + }); + } + }); + } + } + + isTriggerVisible() { + if (!this.persistentTrigger) return false; + + const rect = this.persistentTrigger.getBoundingClientRect(); + const viewportHeight = window.innerHeight || document.documentElement.clientHeight; + + // Check if the trigger is within the viewport (with some margin) + return rect.top < viewportHeight + 100; + } + + async loadMore() { + if (this.currentPage >= this.totalPages) return false; + + this.loading = true; + this.showLoading(); + + try { + const nextPage = this.currentPage + 1; + const response = await fetch(`/${this.feedName}/${nextPage}/`); + + // Check if response is ok (status 200-299) + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const html = await response.text(); + + const parser = new DOMParser(); + const doc = parser.parseFromString(html, 'text/html'); + const newItems = doc.querySelectorAll('#feed li'); + const container = document.getElementById('feed'); + + if (!container) { + throw new Error('Feed container not found'); + } + + newItems.forEach(item => container.appendChild(item)); + + this.currentPage = nextPage; + this.itemsShown += newItems.length; + + // Update pagination info + this.updatePaginationInfo(); + + // Remove our persistent trigger if this was the last page + if (this.currentPage >= this.totalPages) { + if (this.persistentTrigger) { + this.persistentTrigger.remove(); + } + } + + return true; + + } catch (error) { + console.error('Failed to load more content:', error); + this.retryCount++; + + // Show error message if we've exceeded retries + if (this.retryCount >= this.maxRetries) { + this.showError('Failed to load more content. Please refresh the page.'); + } + + return false; + } finally { + this.loading = false; + this.hideLoading(); + } + } + + showLoading() { + const indicator = document.querySelector('.loading-indicator'); + if (indicator) indicator.style.display = 'flex'; + } + + hideLoading() { + const indicator = document.querySelector('.loading-indicator'); + if (indicator) indicator.style.display = 'none'; + } + + showError(message) { + const container = document.getElementById('feed'); + if (container) { + const errorDiv = document.createElement('div'); + errorDiv.className = 'error-message'; + errorDiv.textContent = message; + container.appendChild(errorDiv); + } + } + + updatePaginationInfo() { + const currentPageEl = document.getElementById('current-page'); + const itemsShownEl = document.getElementById('items-shown'); + + if (currentPageEl) currentPageEl.textContent = this.currentPage; + if (itemsShownEl) itemsShownEl.textContent = this.itemsShown; + } + + // Clean up observer on page unload + destroy() { + if (this.observer) { + this.observer.disconnect(); + } + } +} + +// Feature detection and initialization +if ('IntersectionObserver' in window && window.paginationData) { + let infiniteScroll; + + // Initialize when DOM is ready + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => { + infiniteScroll = new InfiniteScroll(window.paginationData); + }); + } else { + infiniteScroll = new InfiniteScroll(window.paginationData); + } + + // Clean up on page unload to prevent memory leaks + window.addEventListener('beforeunload', () => { + if (infiniteScroll) { + infiniteScroll.destroy(); + } + }); +} diff --git a/markata/templates/didyoumean_partial.html b/markata/templates/didyoumean_partial.html index 8ab727fe..59702a99 100644 --- a/markata/templates/didyoumean_partial.html +++ b/markata/templates/didyoumean_partial.html @@ -13,8 +13,6 @@ + +{% if has_next %} +
+
+{% endif %} \ No newline at end of file diff --git a/markata/templates/feed_partial.html b/markata/templates/feed_partial.html index d9a14deb..eb8d6f5c 100644 --- a/markata/templates/feed_partial.html +++ b/markata/templates/feed_partial.html @@ -1,12 +1,96 @@

{{ title }}

+ {% if pagination_enabled %} +
+ Page {{ page }} of {{ total_pages }} + Showing {{ posts|length }} of {{ total_posts }} items +
+ {% endif %} + + {% if pagination_enabled and pagination_context.pagination_type == 'js' %} + + + {% endif %}
+ + {% if pagination_enabled %} + + {# Manual pagination controls - shown for all types as fallback #} + {% include "pagination_controls.html" %} + + {% if pagination_context.pagination_type == 'htmx' %} + + {% if has_next %} +
+
+ {% endif %} + + + + + + {% endif %} + + {% if pagination_context.pagination_type == 'js' %} + +
+ + + + + {% endif %} + + {% endif %}
diff --git a/markata/templates/pagination_controls.html b/markata/templates/pagination_controls.html new file mode 100644 index 00000000..86423e82 --- /dev/null +++ b/markata/templates/pagination_controls.html @@ -0,0 +1,25 @@ +{# Manual pagination controls - reusable fragment #} +{# Used directly for manual pagination, and as fallback for js/htmx #} +
+ {% if prev_page %} + {% if page > 2 %} + + ← Previous + + {% endif %} + + + {{ page }} / {{ total_pages }} + + + {% if has_next %} + + Next → + + {% endif %} +
diff --git a/pyproject.toml b/pyproject.toml index a1f46cc4..75240649 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,7 +44,6 @@ dependencies = [ "pathspec", "pillow", "pluggy", - "polyfactory", "pydantic>=2.0", "pydantic_extra_types>=2.0", "pydantic_settings", diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 00000000..beb8440b --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,70 @@ +# Development Scripts + +This directory contains utility scripts for Markata developers. + +## HTMX Hash Management + +### `add_htmx_hash.py` + +A utility script to add HTMX integrity hashes to feeds plugin when new HTMX versions are released. + +**Usage:** +```bash +# Add a new HTMX version hash +python scripts/add_htmx_hash.py 2.0.8 + +# List all available versions and their hash status +python scripts/add_htmx_hash.py --list + +# Add hashes for all missing versions +python scripts/add_htmx_hash.py --add-all + +# Replace an existing hash without prompting +python scripts/add_htmx_hash.py 2.0.8 --replace + +# Show detailed output +python scripts/add_htmx_hash.py --list --verbose + +# Add all versions, skipping failed ones +python scripts/add_htmx_hash.py --add-all --skip-failed + +# Show help +python scripts/add_htmx_hash.py --help +``` + +**Features:** +- **Smart Hash Retrieval**: First tries GitHub API for official hashes, falls back to unpkg.com +- **Version Management**: Lists all available HTMX versions from GitHub releases +- **Batch Operations**: Add all missing versions with `--add-all` +- **Status Tracking**: See which versions have hashes and which are missing +- **Safety**: Prompts before replacing existing hashes (unless `--replace` used) +- **Verbose Mode**: Detailed output for debugging and monitoring + +**What it does:** +1. Fetches all HTMX versions from GitHub releases API +2. For single versions: Gets hash from GitHub API or downloads from unpkg.com and calculates SHA-256 +3. Updates `HTMX_INTEGRITY_HASHES` dictionary in `markata/plugins/feeds.py` +4. For batch operations: Processes all missing versions automatically + +**When to use:** +- **Single Version**: When a new HTMX version is released +- **List Mode**: To see current hash coverage and available versions +- **Batch Mode**: To populate hashes for many versions at once +- **Development**: When setting up a new development environment + +**Examples:** +```bash +# Quick check of current status +python scripts/add_htmx_hash.py --list + +# Add the latest version +python scripts/add_htmx_hash.py 2.0.7 + +# Populate all missing hashes (great for initial setup) +python scripts/add_htmx_hash.py --add-all + +# Add with verbose output to see what's happening +python scripts/add_htmx_hash.py 2.0.7 --verbose +``` + +This ensures that HTMX files downloaded by Markata are verified for integrity and provides developers with easy tools to maintain the hash database. \ No newline at end of file diff --git a/scripts/add_htmx_hash.py b/scripts/add_htmx_hash.py new file mode 100755 index 00000000..b92406a9 --- /dev/null +++ b/scripts/add_htmx_hash.py @@ -0,0 +1,313 @@ +#!/usr/bin/env python3 +""" +Development script to add HTMX integrity hashes to feeds plugin. + +This script should be used by developers when a new HTMX version is released. +It will: +1. Download/Get specified HTMX version +2. Calculate its SHA-256 hash +3. Add it to HTMX_INTEGRITY_HASHES dictionary in feeds.py + +Usage: + python scripts/add_htmx_hash.py 2.0.8 + python scripts/add_htmx_hash.py --list + python scripts/add_htmx_hash.py --add-all + python scripts/add_htmx_hash.py 1.9.10 --replace +""" + +import argparse +import hashlib +import json +import re +from pathlib import Path +from typing import Optional +from urllib.request import Request, urlopen +from urllib.error import URLError, HTTPError + + +def get_htmx_versions(): + """Get list of all available HTMX versions from GitHub releases API.""" + try: + url = "https://api.github.com/repos/bigskysoftware/htmx/releases" + request = Request(url, headers={"User-Agent": "Markata-Dev/1.0"}) + with urlopen(request, timeout=10) as response: + releases_data = json.loads(response.read().decode("utf-8")) + + # Extract version numbers from tag names (remove 'v' prefix) + versions = [] + for release in releases_data: + if "tag_name" in release and release["tag_name"].startswith("v"): + version = release["tag_name"][1:] # Remove 'v' prefix + versions.append(version) + + # Filter out duplicate and sort by semantic version + unique_versions = list(set(versions)) + unique_versions.sort( + key=lambda v: [int(x) for x in re.findall(r"\d+", v)], reverse=True + ) + + return unique_versions + except Exception as e: + print(f"Error fetching HTMX versions: {e}") + return [] + + +def get_htmx_hash_from_github(version: str) -> Optional[str]: + """Get HTMX hash directly from GitHub releases API.""" + try: + url = "https://api.github.com/repos/bigskysoftware/htmx/releases" + request = Request(url, headers={"User-Agent": "Markata-Dev/1.0"}) + with urlopen(request, timeout=10) as response: + releases_data = json.loads(response.read().decode("utf-8")) + + # Find release with matching version + for release in releases_data: + if release["tag_name"] == f"v{version}": + # Look for htmx.min.js asset + for asset in release.get("assets", []): + if asset["name"] == "htmx.min.js": + # Extract hash from digest (remove 'sha256:' prefix) + digest = asset.get("digest", "") + if digest.startswith("sha256:"): + return digest[7:] # Remove 'sha256:' prefix + break + + return None + except Exception as e: + print(f"Error fetching hash from GitHub: {e}") + return None + + +def add_htmx_hash(version: str, replace: bool = False, verbose: bool = False) -> bool: + """Add HTMX version hash to feeds.py. Returns True if successful.""" + # First try to get hash from GitHub API (more reliable) + sha256_hash = get_htmx_hash_from_github(version) + + if not sha256_hash: + # Fall back to downloading from unpkg.com + try: + url = f"https://unpkg.com/htmx.org@{version}/dist/htmx.min.js" + + if verbose: + print(f"Downloading HTMX {version} from {url}") + + request = Request(url, headers={"User-Agent": "Markata-Dev/1.0"}) + with urlopen(request, timeout=10) as response: + content = response.read() + sha256_hash = hashlib.sha256(content).hexdigest() + if verbose: + print(f"SHA-256 hash: {sha256_hash}") + except (URLError, HTTPError) as e: + print(f"Error: Failed to download HTMX: {e}") + return False + else: + if verbose: + print(f"Got HTMX {version} hash from GitHub API") + + try: + # Find and update the feeds.py file + project_root = Path(__file__).parent.parent + feeds_file = project_root / "markata" / "plugins" / "feeds.py" + + if not feeds_file.exists(): + print(f"Error: Could not find feeds.py at {feeds_file}") + return False + + with open(feeds_file, "r") as f: + file_content = f.read() + + # Find HTMX_INTEGRITY_HASHES dictionary + pattern = r"(HTMX_INTEGRITY_HASHES = \{[^}]+)}" + match = re.search(pattern, file_content, re.DOTALL) + + if not match: + print("Error: Could not find HTMX_INTEGRITY_HASHES in feeds.py") + return False + + # Add new hash + new_hash_entry = f' "{version}": "{sha256_hash}"' + existing_dict = match.group(1) + + # Check if version already exists + if f'"{version}":' in existing_dict: + if verbose: + print(f"Warning: HTMX version {version} already exists in hashes") + if not replace: + response = input("Replace existing hash? [y/N]: ") + if response.lower() != "y": + print("Cancelled.") + return False + + # Replace existing entry + new_dict = re.sub( + rf' "{version}": "[^"]*"', new_hash_entry, existing_dict + ) + else: + # Add new entry (before the closing brace) + new_dict = existing_dict.rstrip() + f",\n{new_hash_entry}" + + # Update the file + updated_content = file_content.replace(match.group(0), new_dict + "}") + + with open(feeds_file, "w") as f: + f.write(updated_content) + + print(f"✅ Added HTMX {version} hash to feeds.py") + if verbose: + print(f"📝 File updated: {feeds_file}") + return True + + except Exception as e: + print(f"Error: {e}") + return False + + +def add_all_htmx_versions(verbose: bool = False, skip_failed: bool = False) -> None: + """Add hashes for all available HTMX versions.""" + versions = get_htmx_versions() + if not versions: + print("Could not fetch HTMX versions") + return + + print(f"Found {len(versions)} HTMX versions") + + # Get existing versions to avoid duplicates + project_root = Path(__file__).parent.parent + feeds_file = project_root / "markata" / "plugins" / "feeds.py" + + with open(feeds_file, "r") as f: + file_content = f.read() + + pattern = r"HTMX_INTEGRITY_HASHES = \{([^}]+)}" + match = re.search(pattern, file_content, re.DOTALL) + existing_versions = set() + if match: + existing_matches = re.findall(r'"([^"]+)":', match.group(1)) + existing_versions = set(existing_matches) + + if verbose: + print(f"Existing versions: {sorted(existing_versions)}") + + # Filter out existing versions + new_versions = [v for v in versions if v not in existing_versions] + + if not new_versions: + print("All available versions already have hashes!") + return + + print(f"Adding {len(new_versions)} new versions...") + + success_count = 0 + for version in new_versions: + if verbose: + print(f"\nProcessing {version}...") + + success = add_htmx_hash(version, replace=True, verbose=False) + if success: + success_count += 1 + elif not skip_failed: + print(f"Failed to add {version}, stopping. Use --skip-failed to continue.") + break + + print(f"\n✅ Successfully added {success_count}/{len(new_versions)} versions") + + +def list_htmx_versions(verbose: bool = False) -> None: + """List all available HTMX versions.""" + versions = get_htmx_versions() + if not versions: + print("Could not fetch HTMX versions") + return + + # Get existing versions + project_root = Path(__file__).parent.parent + feeds_file = project_root / "markata" / "plugins" / "feeds.py" + + with open(feeds_file, "r") as f: + file_content = f.read() + + pattern = r"HTMX_INTEGRITY_HASHES = \{([^}]+)}" + match = re.search(pattern, file_content, re.DOTALL) + existing_versions = set() + if match: + existing_matches = re.findall(r'"([^"]+)":', match.group(1)) + existing_versions = set(existing_matches) + + print("HTMX Versions:") + print("=" * 50) + + for version in versions[:20]: # Show first 20 to avoid too much output + status = "✅" if version in existing_versions else "❌" + print(f" {status} {version}") + + if len(versions) > 20: + print(f" ... and {len(versions) - 20} more versions") + + print( + f"\nSummary: {len(existing_versions)} versions have hashes, {len(versions) - len(existing_versions)} missing" + ) + + if verbose: + print(f"\nAll versions: {versions}") + print(f"Existing versions: {sorted(existing_versions)}") + print( + f"Missing versions: {[v for v in versions if v not in existing_versions]}" + ) + + +def main(): + parser = argparse.ArgumentParser( + description="Add HTMX integrity hash to feeds.py", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=__doc__, + ) + + parser.add_argument("version", nargs="?", help="HTMX version (e.g., 2.0.8, 1.9.10)") + + parser.add_argument( + "--list", + action="store_true", + help="List all available HTMX versions and their hash status", + ) + + parser.add_argument( + "--add-all", + action="store_true", + help="Add hashes for all missing HTMX versions", + ) + + parser.add_argument( + "--replace", action="store_true", help="Replace existing hash without prompting" + ) + + parser.add_argument("--verbose", action="store_true", help="Show detailed output") + + parser.add_argument( + "--skip-failed", + action="store_true", + help="Continue adding versions even if some fail (used with --add-all)", + ) + + args = parser.parse_args() + + if args.list: + list_htmx_versions(args.verbose) + elif args.add_all: + add_all_htmx_versions(args.verbose, args.skip_failed) + elif args.version: + success = add_htmx_hash(args.version, args.replace, args.verbose) + if not success: + exit(1) + else: + parser.print_help() + print("\nExamples:") + print(" python add_htmx_hash.py 2.0.8 # Add specific version") + print(" python add_htmx_hash.py --list # List all versions") + print( + " python add_htmx_hash.py --add-all # Add all missing versions" + ) + print(" python add_htmx_hash.py 2.0.8 --replace # Replace existing hash") + + +if __name__ == "__main__": + main() diff --git a/tests/test_feeds_security.py b/tests/test_feeds_security.py new file mode 100644 index 00000000..e649e2e7 --- /dev/null +++ b/tests/test_feeds_security.py @@ -0,0 +1,200 @@ +import pytest +import tempfile +import shutil +from pathlib import Path +from unittest.mock import Mock, patch +import hashlib + +from markata.plugins.feeds import Feed, _sanitize_feed_slug, _download_htmx_if_needed +from markata import Markata + + +class TestSecurity: + """Test suite for security vulnerabilities in feeds plugin.""" + + def test_path_traversal_prevention(self): + """Test that path traversal attacks are prevented in feed slugs.""" + + # Malicious slugs that should be rejected + malicious_slugs = [ + "../../../etc/passwd", + "..\\..\\windows\\system32\\config\\sam", + "normal/../../../etc/passwd", + "normal\\..\\..\\windows\\system32", + "etc/passwd", + "C:\\Windows\\System32", + "/etc/shadow", + "", + ".", + "./hidden", + "hidden/.", + ] + + for slug in malicious_slugs: + with pytest.raises( + ValueError, match=r"(Invalid characters|cannot be empty)" + ): + _sanitize_feed_slug(slug) + + def test_safe_slug_validation(self): + """Test that safe slugs are allowed.""" + + safe_slugs = [ + "blog", + "my-feed", + "news_posts", + "test123", + "a", + "my_blog_posts_2023", + "feed-with-dashes", + ] + + for slug in safe_slugs: + result = _sanitize_feed_slug(slug) + assert result == slug + + def test_htmx_integrity_verification(self): + """Test that HTMX download verifies file integrity.""" + + # Mock the responses with wrong hash + mock_content = b"malicious javascript content" + mock_response = Mock() + mock_response.read.return_value = mock_content + + with patch("markata.plugins.feeds.urlopen", return_value=mock_response): + with patch("pathlib.Path.exists", return_value=False): + with patch("pathlib.Path.parent"): + with patch("pathlib.Path.write_bytes"): + mock_markata = Mock() + mock_markata.config.htmx_version = "1.9.10" + mock_markata.config.output_dir = "/tmp/test" + + with pytest.raises(RuntimeError, match="HTMX download failed"): + _download_htmx_if_needed(mock_markata) + + def test_htmx_timeout_protection(self): + """Test that HTMX download has timeout protection.""" + + mock_markata = Mock() + mock_markata.config.htmx_version = "1.9.10" + mock_markata.config.output_dir = "/tmp/test" + + # Mock urlopen to raise timeout + with patch( + "markata.plugins.feeds.urlopen", + side_effect=TimeoutError("Request timed out"), + ): + with pytest.raises(RuntimeError, match="HTMX download failed"): + _download_htmx_if_needed(mock_markata) + + def test_xss_prevention_in_template_context(self): + """Test that template context doesn't contain dangerous config data.""" + + # Create a feed with potentially dangerous config + dangerous_config = { + "pagination_type": "js", + "posts_per_page": 10, + "template": '', + "card_template": "dangerous-template.html", + "xss_payload": '', + "admin_password": "secret123", + "api_key": "sk-1234567890", + } + + # Safe config should only include essential pagination settings + safe_config = { + "pagination_type": dangerous_config["pagination_type"], + "posts_per_page": dangerous_config["posts_per_page"], + "template": dangerous_config["template"], + } + + # Verify only safe keys are included + for key in dangerous_config: + if key not in safe_config: + assert key not in safe_config, ( + f"Dangerous key '{key}' should not be in safe config" + ) + + def test_canonical_url_sanitization(self): + """Test that canonical URLs use sanitized slugs.""" + + mock_markata = Mock() + mock_markata.config.url = "https://example.com" + + # Test with safe slug + safe_slug = "my-blog-feed" + feed_config = Mock() + feed_config.slug = safe_slug + + feed = Feed(config=feed_config, markata=mock_markata) + + # The canonical URL should use the safe slug + expected_url = f"https://example.com/{safe_slug}/" + # This would be tested in actual template rendering + + def test_feed_file_path_security(self): + """Test that feed file paths cannot escape output directory.""" + + with tempfile.TemporaryDirectory() as temp_dir: + output_dir = Path(temp_dir) + + # Try to create a feed with malicious slug + malicious_slugs = [ + "../outside", + "normal/../../../etc/passwd", + "normal\\..\\..\\windows\\system32", + ] + + for malicious_slug in malicious_slugs: + with pytest.raises(ValueError): + _sanitize_feed_slug(malicious_slug) + + # Ensure no files can be created outside output directory + safe_slug = _sanitize_feed_slug("safe-feed") + file_path = output_dir / safe_slug / "index.html" + + # Verify path is within output directory + assert file_path.resolve().is_relative_to(output_dir.resolve()) + + def test_template_injection_prevention(self): + """Test that template injection is prevented in feed names.""" + + dangerous_names = [ + "{{7*7}}", # Template injection + "${7*7}", # Expression injection + "", + "javascript:void(0)", + "data:text/html,", + ] + + for dangerous_name in dangerous_names: + # These should be sanitized or rejected + sanitized = _sanitize_feed_slug(dangerous_name) + # Should either be rejected or sanitized to safe version + assert "{{" not in sanitized + assert "}}" not in sanitized + assert "