Brian S. Stephan 6dc443e59f
implement a rudimentary Atom/RSS feed module
this provides a somewhat unconfigurable (at the moment) feed module
which provides Atom and RSS feeds. entries are determined by symlinks to
content pages, because my core CMS usage is still more general and not
blog-like. the symlinks allow for arbitrarily adding entries as I see
fit.

this also moves core Markdown parser stuff to the library module, since
that's used by the feed as well as normal pages

Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2023-12-30 14:55:02 -06:00

181 lines
8.1 KiB
Python

"""General page functionality.
SPDX-FileCopyrightText: © 2020 Brian S. Stephan <bss@incorporeal.org>
SPDX-License-Identifier: AGPL-3.0-or-later
"""
import logging
import os
from flask import Blueprint, abort
from flask import current_app as app
from flask import redirect, request, send_from_directory
from markupsafe import Markup
from werkzeug.security import safe_join
from incorporealcms.lib import get_meta_str, init_md, instance_resource_path_to_request_path, parse_md, render
logger = logging.getLogger(__name__)
bp = Blueprint('pages', __name__, url_prefix='/')
@bp.route('/', defaults={'path': 'index'})
@bp.route('/<path:path>')
def display_page(path):
"""Get the file contents of the requested path and render the file."""
try:
resolved_path, render_type = request_path_to_instance_resource_path(path)
logger.debug("received request for path '%s', resolved to '%s', type '%s'",
path, resolved_path, render_type)
except PermissionError:
abort(400)
except IsADirectoryError:
return redirect(f'/{path}/', code=301)
except FileNotFoundError:
abort(404)
if render_type == 'file':
return send_from_directory(app.instance_path, resolved_path)
elif render_type == 'symlink':
logger.debug("attempting to redirect path '%s' to reverse of resource '%s'", path, resolved_path)
redirect_path = f'/{instance_resource_path_to_request_path(resolved_path)}'
logger.debug("redirect path: '%s'", redirect_path)
return redirect(redirect_path, code=301)
elif render_type == 'markdown':
logger.debug("treating path '%s' as markdown '%s'", path, resolved_path)
return handle_markdown_file_path(resolved_path)
else:
logger.exception("unsupported render_type '%s'!?", render_type)
abort(500)
def handle_markdown_file_path(resolved_path):
"""Given a location on disk, attempt to open it and render the markdown within."""
try:
content, md, page_name, page_title, mtime = parse_md(resolved_path)
except OSError:
logger.exception("resolved path '%s' could not be opened!", resolved_path)
abort(500)
except ValueError:
logger.exception("error parsing/rendering markdown!")
abort(500)
except TypeError:
logger.exception("error loading/rendering markdown!")
abort(500)
else:
parent_navs = generate_parent_navs(resolved_path)
extra_footer = get_meta_str(md, 'footer') if md.Meta.get('footer') else None
template = get_meta_str(md, 'template') if md.Meta.get('template') else 'base.html'
# check if this has a HTTP redirect
redirect_url = get_meta_str(md, 'redirect') if md.Meta.get('redirect') else None
if redirect_url:
logger.debug("redirecting via meta tag to '%s'", redirect_url)
return redirect(redirect_url, code=301)
return render(template, title=page_title, description=get_meta_str(md, 'description'),
image=get_meta_str(md, 'image'), base_url=request.base_url, content=content,
navs=parent_navs, mtime=mtime.strftime('%Y-%m-%d %H:%M:%S %Z'),
extra_footer=extra_footer)
def request_path_to_instance_resource_path(path):
"""Turn a request URL path to the full page path.
flask.Flask.open_instance_resource will open a file like /etc/hosts if you tell it to,
which sucks, so we do a lot of work here to make sure we have a valid request to
something inside the pages dir.
"""
# check if the path is allowed
base_dir = os.path.realpath(f'{app.instance_path}/pages/')
safe_path = safe_join(base_dir, path)
# bail if the requested real path isn't inside the base directory
if not safe_path:
logger.warning("client tried to request a path '%s' outside of the base_dir!", path)
raise PermissionError
verbatim_path = os.path.abspath(safe_path)
resolved_path = os.path.realpath(verbatim_path)
logger.debug("base_dir '%s', constructed resolved_path '%s' for path '%s'", base_dir, resolved_path, path)
# see if we have a real file or if we should infer markdown rendering
if os.path.exists(resolved_path):
# if this is a file-like request but actually a directory, redirect the user
if os.path.isdir(resolved_path) and not path.endswith('/'):
logger.info("client requested a path '%s' that is actually a directory", path)
raise IsADirectoryError
# if the requested path contains a symlink, redirect the user
if verbatim_path != resolved_path:
logger.info("client requested a path '%s' that is actually a symlink to file '%s'", path, resolved_path)
return resolved_path.replace(f'{app.instance_path}{os.path.sep}', ''), 'symlink'
# derive the proper markdown or actual file depending on if this is a dir or file
if os.path.isdir(resolved_path):
resolved_path = os.path.join(resolved_path, 'index.md')
return resolved_path.replace(f'{app.instance_path}{os.path.sep}', ''), 'markdown'
logger.info("final DIRECT path = '%s' for request '%s'", resolved_path, path)
return resolved_path.replace(f'{app.instance_path}{os.path.sep}', ''), 'file'
# if we're here, this isn't direct file access, so try markdown inference
verbatim_path = f'{safe_path}.md'
resolved_path = os.path.realpath(verbatim_path)
# does the final file actually exist?
if not os.path.exists(resolved_path):
logger.warning("requested final path '%s' does not exist!", resolved_path)
raise FileNotFoundError
# check for symlinks
if verbatim_path != resolved_path:
logger.info("client requested a path '%s' that is actually a symlink to file '%s'", path, resolved_path)
return resolved_path.replace(f'{app.instance_path}{os.path.sep}', ''), 'symlink'
logger.info("final path = '%s' for request '%s'", resolved_path, path)
# we checked that the file exists via absolute path, but now we need to give the path relative to instance dir
return resolved_path.replace(f'{app.instance_path}{os.path.sep}', ''), 'markdown'
def generate_parent_navs(path):
"""Create a series of paths/links to navigate up from the given resource path."""
if path == 'pages/index.md':
# bail and return the domain name as a terminal case
return [(app.config['DOMAIN_NAME'], '/')]
else:
if path.endswith('index.md'):
# index case: one dirname for foo/bar/index.md -> foo/bar, one for foo/bar -> foo
parent_resource_dir = os.path.dirname(os.path.dirname(path))
else:
# usual case: foo/buh.md -> foo
parent_resource_dir = os.path.dirname(path)
# generate the request path (i.e. what the link will be) for this path, and
# also the resource path of this parent (which is always a dir, so always index.md)
request_path = f'/{instance_resource_path_to_request_path(path)}'
parent_resource_path = os.path.join(parent_resource_dir, 'index.md')
logger.debug("resource path: '%s'; request path: '%s'; parent resource path: '%s'", path,
request_path, parent_resource_path)
# for issues regarding parser reuse (see lib.init_md) we reinitialize the parser here
md = init_md()
# read the resource
try:
with app.open_instance_resource(path, 'r') as entry_file:
entry = entry_file.read()
_ = Markup(md.convert(entry))
page_name = (" ".join(md.Meta.get('title')) if md.Meta.get('title')
else request_path_to_breadcrumb_display(request_path))
return generate_parent_navs(parent_resource_path) + [(page_name, request_path)]
except FileNotFoundError:
return generate_parent_navs(parent_resource_path) + [(request_path, request_path)]
def request_path_to_breadcrumb_display(path):
"""Given a request path, e.g. "/foo/bar/baz/", turn it into breadcrumby text "baz"."""
undired = path.rstrip('/')
leaf = undired[undired.rfind('/'):]
return leaf.strip('/')