diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml index 82c3289b..f2e5ab60 100644 --- a/.github/workflows/continuous-integration.yml +++ b/.github/workflows/continuous-integration.yml @@ -18,9 +18,13 @@ jobs: env: TEST_DATABASE_URL: postgresql://${{ secrets.TEST_POSTGRES_USER }}:${{ secrets.TEST_POSTGRES_PASSWORD }}@localhost:5432/${{ secrets.TEST_POSTGRES_DB }} SECRET_KEY: ${{ secrets.SECRET_KEY }} - TEST_ARGON2_TIME_COST: ${{ secrets.ARGON2_TIME_COST }} - TEST_ARGON2_MEMORY_COST: ${{ secrets.ARGON2_MEMORY_COST }} - TEST_ARGON2_PARALLELISM: ${{ secrets.ARGON2_PARALLELISM }} + ARGON2_TIME_COST: ${{ secrets.ARGON2_TIME_COST }} + ARGON2_MEMORY_COST: ${{ secrets.ARGON2_MEMORY_COST }} + ARGON2_PARALLELISM: ${{ secrets.ARGON2_PARALLELISM }} + TEST_ARGON2_TIME_COST: ${{ secrets.TEST_ARGON2_TIME_COST }} + TEST_ARGON2_MEMORY_COST: ${{ secrets.TEST_ARGON2_MEMORY_COST }} + TEST_ARGON2_PARALLELISM: ${{ secrets.TEST_ARGON2_PARALLELISM }} + FLASK_ENV: ${{ secrets.FLASK_ENV }} services: postgres: diff --git a/.gitignore b/.gitignore index 5e9c8b82..a70f85f8 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ __pycache__/ # đŸ§Ș Tests .pytest_cache/ .coverage +coverage.xml # đŸ§č Ruff .ruff_cache/ diff --git a/blog_comment_application.py b/blog_comment_application.py index 9eabe941..ec9977e9 100644 --- a/blog_comment_application.py +++ b/blog_comment_application.py @@ -1,22 +1,33 @@ import glob import os from datetime import timedelta +from typing import NamedTuple -from flask import Flask, render_template, session +from flask import Flask, flash, redirect, render_template, session, url_for from flask_babel import Babel from flask_babel import gettext as _ from flask_compress import Compress from sqlalchemy.orm import Session from config.env_config import env_config -from flask_setup.middleware import init_web_security +from flask_setup.middleware import _init_csrf_exemptions, init_rate_limiter, init_web_security from flask_setup.routes import register_web_routes +from flask_setup.template_helpers import ( + ViteManifest, + date_iso_filter, + format_datetime_locale, + inject_current_user, + inject_current_year, + inject_vite_assets, +) +from src.application.services.admin_service import AdminService from src.application.services.article_service import ArticleService from src.application.services.comment_service import CommentService from src.application.services.file_service import FileService from src.application.services.login_service import LoginService from src.application.services.registration_service import RegistrationService from src.infrastructure.input_adapters.flask.flask_account_session_adapter import AccountSessionAdapter +from src.infrastructure.input_adapters.flask.flask_admin_adapter import AdminAdapter from src.infrastructure.input_adapters.flask.flask_article_adapter import ArticleAdapter from src.infrastructure.input_adapters.flask.flask_comment_adapter import CommentAdapter from src.infrastructure.input_adapters.flask.flask_file_adapter import FlaskFileAdapter @@ -30,17 +41,70 @@ from src.infrastructure.output_adapters.sqlalchemy.sqlalchemy_file_storage_adapter import SqlAlchemyFileStorageAdapter from src.infrastructure.output_adapters.sqlalchemy.sqlalchemy_setup_database import setup_database from utils.prosemirror_to_html import prosemirror_to_html -from utils.template_helpers import ( - ViteManifest, - date_iso_filter, - format_datetime_locale, - inject_current_year, - inject_vite_assets, - nl2br_filter, -) -def _create_output_adapters(db_session: Session) -> dict: +class Repositories(NamedTuple): + """Typed container for persistence and security output adapters.""" + + account_repo: SqlAlchemyAccountAdapter + article_repo: SqlAlchemyArticleAdapter + comment_repo: SqlAlchemyCommentAdapter + file_storage_repo: SqlAlchemyFileStorageAdapter + session_repo: FlaskSessionAdapter + password_hasher_repository: Argon2PasswordHasherAdapter + + +class Services(NamedTuple): + """Typed container for core application services.""" + + registration_service: RegistrationService + session_repo: FlaskSessionAdapter + login_service: LoginService + comment_service: CommentService + article_service: ArticleService + file_service: FileService + admin_service: AdminService + + +class WebAdapters(NamedTuple): + """Typed container for Flask input adapters.""" + + article_adapter: ArticleAdapter + comment_adapter: CommentAdapter + login_adapter: LoginAdapter + registration_adapter: RegistrationAdapter + account_session_adapter: AccountSessionAdapter + admin_adapter: AdminAdapter + file_adapter: FlaskFileAdapter + + +def _get_argon2_params(db_session: Session | None = None) -> tuple[int, int, int]: + """ + Selects Argon2 parameters based on environment. + + Uses test (low-security) parameters when a test session is provided, + production (high-security) parameters otherwise. + + Args: + db_session: SQLAlchemy session. If ``None``, returns production params. + + Returns: + tuple[int, int, int]: (time_cost, memory_cost, parallelism). + """ + if db_session is not None: + return ( + env_config.test_argon2_time_cost, + env_config.test_argon2_memory_cost, + env_config.test_argon2_parallelism, + ) + return ( + env_config.argon2_time_cost, + env_config.argon2_memory_cost, + env_config.argon2_parallelism, + ) + + +def _create_output_adapters(db_session: Session) -> Repositories: """ Instantiates persistence and security adapters. @@ -48,88 +112,90 @@ def _create_output_adapters(db_session: Session) -> dict: production argon2 parameters otherwise. Args: - db_session: SQLAlchemy session for dependency injection (None for prod). + db_session: SQLAlchemy session for dependency injection. Returns: - dict: Initialized output adapters keyed by role. + Repositories: Typed container of initialized output adapters. """ + time_cost, memory_cost, parallelism = _get_argon2_params(db_session) account_repo = SqlAlchemyAccountAdapter(db_session) - if db_session is not None: - time_cost = env_config.test_argon2_time_cost - memory_cost = env_config.test_argon2_memory_cost - parallelism = env_config.test_argon2_parallelism - else: - time_cost = env_config.argon2_time_cost - memory_cost = env_config.argon2_memory_cost - parallelism = env_config.argon2_parallelism - return { - "account_repo": account_repo, - "article_repo": SqlAlchemyArticleAdapter(db_session), - "comment_repo": SqlAlchemyCommentAdapter(db_session), - "file_storage_repo": SqlAlchemyFileStorageAdapter(db_session), - "session_repo": FlaskSessionAdapter(account_repo), - "password_hasher_repository": Argon2PasswordHasherAdapter( + return Repositories( + account_repo=account_repo, + article_repo=SqlAlchemyArticleAdapter(db_session), + comment_repo=SqlAlchemyCommentAdapter(db_session), + file_storage_repo=SqlAlchemyFileStorageAdapter(db_session), + session_repo=FlaskSessionAdapter(account_repo), + password_hasher_repository=Argon2PasswordHasherAdapter( time_cost=time_cost, memory_cost=memory_cost, parallelism=parallelism, ), - } + ) -def _create_services(repositories: dict) -> dict: +def _create_services(repositories: Repositories) -> Services: """ Instantiates the core application services. Args: - repositories (dict): A dictionary of initialized repositories. + repositories: Container of initialized output adapters. Returns: - dict: A dictionary containing initialized core services. + Services: Typed container of initialized core services. """ - password_hasher_repository = repositories["password_hasher_repository"] - registration_service = RegistrationService(repositories["account_repo"], password_hasher_repository) - session_repo = repositories["session_repo"] - account_repo = repositories["account_repo"] - article_repo = repositories["article_repo"] - comment_repo = repositories["comment_repo"] - - login_service = LoginService(account_repo, session_repo, password_hasher_repository) + password_hasher_repository = repositories.password_hasher_repository + registration_service = RegistrationService(repositories.account_repo, password_hasher_repository) + session_repo = repositories.session_repo + account_repo = repositories.account_repo + article_repo = repositories.article_repo + comment_repo = repositories.comment_repo + + file_service = FileService(repositories.file_storage_repo) comment_service = CommentService(comment_repo, article_repo, account_repo) - file_service = FileService(repositories["file_storage_repo"]) + login_service = LoginService( + account_repo, session_repo, password_hasher_repository, + file_service=file_service, + comment_service=comment_service, + ) article_service = ArticleService(article_repo, account_repo, comment_repo, file_service=file_service) + admin_service = AdminService( + account_repo, + file_service=file_service, + comment_service=comment_service, + ) - return { - "registration_service": registration_service, - "session_repo": session_repo, - "login_service": login_service, - "comment_service": comment_service, - "article_service": article_service, - "file_service": file_service, - } + return Services( + registration_service=registration_service, + session_repo=session_repo, + login_service=login_service, + comment_service=comment_service, + article_service=article_service, + file_service=file_service, + admin_service=admin_service, + ) -def _init_web_adapters(services: dict) -> dict: +def _init_web_adapters(services: Services) -> WebAdapters: """ Instantiates the input adapters for the Web interface. Args: - services (dict): A dictionary of initialized core services. + services: Container of initialized core services. Returns: - dict: A dictionary containing initialized Flask adapters. - """ - return { - "article_adapter": ArticleAdapter(services["article_service"]), - "comment_adapter": CommentAdapter(services["comment_service"]), - "login_adapter": LoginAdapter(services["login_service"]), - "registration_adapter": RegistrationAdapter(services["registration_service"]), - "account_session_adapter": AccountSessionAdapter( - services["login_service"], - services["file_service"], - services["comment_service"], + WebAdapters: Typed container of initialized Flask adapters. + """ + return WebAdapters( + article_adapter=ArticleAdapter(services.article_service), + comment_adapter=CommentAdapter(services.comment_service), + login_adapter=LoginAdapter(services.login_service), + registration_adapter=RegistrationAdapter(services.registration_service), + account_session_adapter=AccountSessionAdapter( + services.login_service, ), - "file_adapter": FlaskFileAdapter(services["file_service"]), - } + admin_adapter=AdminAdapter(services.admin_service), + file_adapter=FlaskFileAdapter(services.file_service), + ) def _init_web_facade_flask() -> Flask: @@ -155,7 +221,6 @@ def _init_template_utils(app: Flask) -> None: Registers custom Jinja2 filters and context processors on the Flask app. Provides the following filters to all templates: - - ``nl2br``: Escapes HTML and converts newlines to ``
`` tags. - ``date_format``: Formats a ``datetime`` as a human-readable string. - ``date_iso``: Formats a ``datetime`` as an ISO 8601 date string. @@ -170,12 +235,12 @@ def _init_template_utils(app: Flask) -> None: """ ViteManifest.init(os.path.join(app.static_folder or "", "dist")) - app.jinja_env.filters["nl2br"] = nl2br_filter app.jinja_env.filters["date_iso"] = date_iso_filter app.jinja_env.filters["prosemirror_to_html"] = prosemirror_to_html app.jinja_env.filters["format_datetime_locale"] = format_datetime_locale app.context_processor(inject_current_year) app.context_processor(inject_vite_assets) + app.context_processor(inject_current_user) def _error_page(code: int, message: str) -> tuple[str, int]: @@ -183,42 +248,135 @@ def _error_page(code: int, message: str) -> tuple[str, int]: return render_template("error.html", code=code, message=message), code -def create_app(db_session=None) -> Flask: +def _on_rate_breach(_request_limit: object) -> None: + """Flash a warning when IP-based rate limit is exceeded. + + Called by flask-limiter before aborting with 429. + The flash appears on the redirected login page. + + Args: + _request_limit: The RequestLimit object from flask-limiter (unused). + """ + flash( + _("Too many login attempts. Please try again later."), + "error", + ) + + +def _shutdown_db_session(exception: BaseException | None = None) -> None: + """Remove the scoped DB session at the end of each request. + + Uses ``current_app.config.get()`` (not ``pop``) to avoid removing + the shared key from ``app.config`` after the first request. Must + remain callable for all subsequent requests. + + Reads the session from the Flask app config and removes it + from the current thread registry. Idempotent — safe to call + multiple times. + + Args: + exception: The exception that occurred during the request, + or None if the request completed successfully. + """ + from flask import current_app + session = current_app.config.get("_DB_SESSION") + if session is not None: + session.remove() + + +def _inject_get_locale() -> dict: + """Inject the current locale into all templates. + + Returns a single-entry dictionary so templates can call + ``get_locale()`` to retrieve the active locale string + from the session. + + Returns: + dict: A dictionary with key ``"get_locale"`` whose value + is a callable returning the locale string. + """ + return {"get_locale": lambda: session.get("lang", "fr")} + +def create_app(db_session: Session | None = None, testing: bool = False) -> Flask: """ Bootstrap function to initialize the hexagonal application. + Orchestrates the assembly of the Core and the Web Facade. + Creates a scoped SQLAlchemy session (thread-safe, one per thread) + and registers a teardown handler that removes the session from the + current thread at the end of each request. + + When a test session is injected via ``db_session``, the caller + owns the session lifecycle and no teardown handler is registered + (the test fixture handles cleanup via its own ``session.remove()``). + Args: db_session: Optional pre-existing database session. + testing: Disables rate limit enforcement when ``True`` + (used in tests). The wrapper is always present but + flask-limiter is inactive, avoiding test interference. Returns: Flask: The configured Flask application (Web Facade). """ + _injected_session = db_session is not None db_session = setup_database(db_session) repositories = _create_output_adapters(db_session) services = _create_services(repositories) app = _init_web_facade_flask() - Compress(app) - Babel(app, locale_selector=lambda: session.get("lang", "fr")) - @app.context_processor - def inject_get_locale(): - return {"get_locale": lambda: session.get("lang", "fr")} + if not _injected_session and hasattr(db_session, "remove"): + app.config["_DB_SESSION"] = db_session + app.teardown_appcontext(_shutdown_db_session) + Compress(app) + Babel(app, locale_selector=lambda: session.get("lang", "fr")) + app.context_processor(_inject_get_locale) init_web_security(app) _init_template_utils(app) web_adapters = _init_web_adapters(services) register_web_routes(app, web_adapters) - web_adapters["account_session_adapter"].register_before_request_handler(app) + _init_csrf_exemptions(app) + web_adapters.account_session_adapter.register_before_request_handler(app) + limiter = init_rate_limiter(app, enabled=not testing) + app.extensions.setdefault("limiter", set()).add(limiter) + + original_login = app.view_functions["auth.authenticate"] + + @limiter.limit("5/minute", on_breach=_on_rate_breach) + def rate_limited_login(*args: object, **kwargs: object): + """Wrap login endpoint with IP-based rate limiting. + + Limits POST /login to 5 requests per minute per IP. + On breach, ``_on_rate_breach`` flashes a warning, + then flask-limiter aborts with 429 which triggers + the login redirect. + + Args: + *args: Forwarded to original login view. + **kwargs: Forwarded to original login view. + + Returns: + Response from the original login view. + """ + return original_login(*args, **kwargs) + + app.view_functions["auth.authenticate"] = rate_limited_login # type: ignore[assignment] + + app.errorhandler(429)( + lambda e: redirect(url_for("auth.login")) + ) + app.errorhandler(403)(lambda e: _error_page(403, _("You do not have permission to access this page."))) app.errorhandler(404)(lambda e: _error_page(404, _("The page you are looking for does not exist."))) app.errorhandler(500)(lambda e: _error_page(500, _("An unexpected error occurred. Please try again later."))) return app -if __name__ == "__main__": +if __name__ == "__main__": # pragma: no cover application = create_app() application.run( - debug=os.getenv("FLASK_DEBUG", "false").lower() == "true", + debug=env_config.flask_debug, extra_files=glob.glob("translations/**/*.mo", recursive=True), ) diff --git a/blog_exceptions.py b/blog_exceptions.py index e78b3cef..2bcb0fad 100644 --- a/blog_exceptions.py +++ b/blog_exceptions.py @@ -88,21 +88,14 @@ class DatabaseError(BlogCommentError): pass -class PasswordsDoNotMatchError(ValueError): +class PasswordsDoNotMatchError(BlogCommentError, ValueError): """Raised when password and confirmation do not match.""" pass -class WeakPasswordError(ValueError): +class WeakPasswordError(BlogCommentError, ValueError): """Raised when password does not meet strength requirements (length, uppercase, lowercase, special char).""" pass -class CommentEmptyError(ValueError): - """Raised when comment content is empty.""" - pass - -class CommentTooLongError(ValueError): - """Raised when comment content exceeds max length.""" - pass diff --git a/config/env_config.py b/config/env_config.py index 69a3a705..6017f538 100644 --- a/config/env_config.py +++ b/config/env_config.py @@ -128,5 +128,25 @@ def test_argon2_parallelism(self) -> int: """ return int(self._get_env("TEST_ARGON2_PARALLELISM")) + @property + def flask_env(self) -> str: + """ + Retrieves the Flask environment (production/test/development). + + Returns: + str: The Flask environment name. Defaults to "development". + """ + return os.getenv("FLASK_ENV", "development") + + @property + def flask_debug(self) -> bool: + """ + Retrieves the Flask debug mode from environment. + + Returns: + bool: True if FLASK_DEBUG is "true" (case-insensitive). Defaults to False. + """ + return os.getenv("FLASK_DEBUG", "false").lower() == "true" + env_config = EnvConfig() diff --git a/flask_setup/auth_helpers.py b/flask_setup/auth_helpers.py new file mode 100644 index 00000000..15dc61eb --- /dev/null +++ b/flask_setup/auth_helpers.py @@ -0,0 +1,66 @@ +"""Authentication decorators for Flask adapters. + +Provides @require_auth (HTML redirect) and @require_auth_api (401 JSON) +to replace duplicated user-check boilerplate across adapters. +""" + +from functools import wraps + +from flask import flash, jsonify, redirect, url_for +from flask import g as global_request_context +from flask_babel import gettext as _ + + +def require_auth(flash_message="You must be signed in.", redirect_to="auth.login"): + """Decorator factory that requires an authenticated user. + + If the user is not authenticated, flashes the given message and + redirects to the specified endpoint. The decorated method can + retrieve the user via ``global_request_context.get("current_user")`` + which is guaranteed non-None after this decorator passes. + + Args: + flash_message: Message flashed on authentication failure. + redirect_to: Flask endpoint name to redirect to. + + Returns: + Callable: Decorator that wraps the view function. + """ + + def decorator(func): + @wraps(func) + def wrapper(*args, **kwargs): + user = global_request_context.get("current_user") + if not user: + flash(_(flash_message), "error") + return redirect(url_for(redirect_to)) + return func(*args, **kwargs) + + return wrapper + + return decorator + + +def require_auth_api(): + """Decorator that requires an authenticated user for JSON API endpoints. + + Returns a 401 JSON response if the user is not authenticated. + The decorated method can retrieve the user via + ``global_request_context.get("current_user")`` which is guaranteed + non-None after this decorator passes. + + Returns: + Callable: Decorator that wraps the view function. + """ + + def decorator(func): + @wraps(func) + def wrapper(*args, **kwargs): + user = global_request_context.get("current_user") + if not user: + return jsonify({"error": _("Unauthorized.")}), 401 + return func(*args, **kwargs) + + return wrapper + + return decorator diff --git a/flask_setup/middleware.py b/flask_setup/middleware.py index 11c170a2..bc488f60 100644 --- a/flask_setup/middleware.py +++ b/flask_setup/middleware.py @@ -5,29 +5,42 @@ from flask import Flask, Response from flask import request as flask_request from flask.sessions import SecureCookieSessionInterface +from flask_limiter import Limiter +from flask_limiter.util import get_remote_address from flask_wtf.csrf import CSRFProtect class CSPConfig: """Configures Content Security Policy headers and violation reporting. - Computes the SHA-256 hash of the inline theme script at startup, - injects the Content-Security-Policy header into every response, - and provides an endpoint for receiving CSP violation reports from - the browser. + Computes the SHA-256 hash of the inline theme script from base.html + in the given template directory at startup, injects the + Content-Security-Policy header into every response, and provides + an endpoint for receiving CSP violation reports from the browser. """ - def __init__(self): + def __init__(self, template_dir: str | Path): + """ + Initializes CSP configuration and computes the inline script hash. + + Args: + template_dir: Path to the directory containing base.html + with the inline theme script. Typically derived from + app.root_path. + """ + self._template_dir = Path(template_dir) self._script_hash = self._compute_inline_script_hash() - @staticmethod - def _compute_inline_script_hash() -> str: + def _compute_inline_script_hash(self) -> str: """Reads and hashes the inline theme script from base.html. + Resolves base.html relative to the template_dir passed at init, + avoiding fragile __file__-based paths. + Returns: str: The CSP-compatible hash string in ``'sha256-'`` format. """ - template_path = Path(__file__).parent.parent / "frontend/templates/base.html" + template_path = self._template_dir / "base.html" content = template_path.read_text() start = content.index("", start) @@ -51,7 +64,8 @@ def add_headers(self, response: Response) -> Response: response.headers["Reporting-Endpoints"] = 'csp-endpoint="/csp-report"' response.headers["Content-Security-Policy"] = ( "default-src 'self';" - f"script-src 'self' 'unsafe-eval' {self._script_hash};" + f"script-src 'self' 'unsafe-eval' 'unsafe-hashes'" + f" 'sha256-MhtPZXr7+LpJUY5qtMutB+qWfQtMaPccfe7QXtCcEYc=' {self._script_hash};" "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;" "font-src 'self' https://fonts.gstatic.com;" "img-src 'self' data: https:;" @@ -171,6 +185,37 @@ def get_expiration_time(self, app, session): return None +def _init_csrf_exemptions(app: Flask) -> None: + """Applies CSRF exemptions to API and internal endpoints. + + Must be called AFTER all routes are registered so that + ``app.view_functions`` can resolve endpoint names to view + functions. Exemptions are defined here (not in routes.py) + to keep a single source of truth for endpoints that skip + CSRF protection. + + Args: + app: The Flask application instance with all routes + already registered. + """ + endpoints = [ + "article.api_get", + "article.api_create", + "article.api_update", + "article.api_delete", + "auth.upload_profile_photo", + "file.upload_image", + "csp.handle_report", + ] + csrf = app.extensions.get("csrf") + if not csrf: + return + for endpoint in endpoints: + view_func = app.view_functions.get(endpoint) + if view_func: + csrf.exempt(view_func) + + def init_web_security(app: Flask) -> None: """Configures web security middleware for the Flask application. @@ -182,12 +227,42 @@ def init_web_security(app: Flask) -> None: """ app.session_interface = NonPersistentSessionInterface() app.config["WTF_CSRF_TIME_LIMIT"] = None - csrf_protect = CSRFProtect(app) - csp = CSPConfig() + CSRFProtect(app) + template_dir = Path(app.root_path) / "frontend" / "templates" + csp = CSPConfig(template_dir) app.after_request(csp.add_headers) app.after_request(_add_nosniff) app.after_request(_add_x_frame_options) app.after_request(_add_referrer_policy) app.after_request(_add_cache_headers) - csrf_protect.exempt(csp.handle_report) - app.add_url_rule("/csp-report", view_func=csp.handle_report, methods=["POST"]) + app.add_url_rule( + "/csp-report", view_func=csp.handle_report, + methods=["POST"], endpoint="csp.handle_report", + ) + + +def init_rate_limiter(app: Flask, enabled: bool = True) -> Limiter: + """Initializes IP-based rate limiting for the Flask application. + + Creates an in-memory Limiter instance with no default limits. + Per-endpoint limits are applied via the returned instance + after route registration. The caller must also store the + limiter in ``app.extensions["limiter"]`` to keep it alive + when ``enabled=False`` (prevents ``weakref.proxy`` crash). + + Args: + app: The Flask application instance to secure. + enabled: Whether rate limiting is active. Set to ``False`` + in test environments. + + Returns: + Limiter: A configured Limiter instance for applying + per-endpoint rate limits. + """ + return Limiter( + app=app, + key_func=get_remote_address, + default_limits=[], + enabled=enabled, + storage_uri="memory://", + ) diff --git a/flask_setup/routes.py b/flask_setup/routes.py index 5bfc5591..99286e1a 100644 --- a/flask_setup/routes.py +++ b/flask_setup/routes.py @@ -1,8 +1,8 @@ from flask import Flask -def _register_article_routes(app: Flask, adapters: dict) -> None: - art = adapters["article_adapter"] +def _register_article_routes(app: Flask, adapters) -> None: + art = adapters.article_adapter app.add_url_rule("/", view_func=art.list_articles, endpoint="article.list_articles") app.add_url_rule("/articles/", view_func=art.read_article, endpoint="article.read_article") app.add_url_rule("/articles/new", view_func=art.render_create_page, methods=["GET"], endpoint="article.render_create_page") @@ -17,41 +17,36 @@ def _register_article_routes(app: Flask, adapters: dict) -> None: ) -def _register_article_api_routes(app: Flask, adapters: dict) -> None: - art = adapters["article_adapter"] - csrf = app.extensions["csrf"] +def _register_article_api_routes(app: Flask, adapters) -> None: + art = adapters.article_adapter app.add_url_rule( "/api/articles/", view_func=art.api_get_article, methods=["GET"], endpoint="article.api_get", ) - csrf.exempt(art.api_get_article) app.add_url_rule( "/api/articles", view_func=art.api_create_article, methods=["POST"], endpoint="article.api_create", ) - csrf.exempt(art.api_create_article) app.add_url_rule( "/api/articles/", view_func=art.api_update_article, methods=["PUT"], endpoint="article.api_update", ) - csrf.exempt(art.api_update_article) app.add_url_rule( "/api/articles/", view_func=art._api_delete_article, methods=["DELETE"], endpoint="article.api_delete", ) - csrf.exempt(art._api_delete_article) -def _register_comment_routes(app: Flask, adapters: dict) -> None: - com = adapters["comment_adapter"] +def _register_comment_routes(app: Flask, adapters) -> None: + com = adapters.comment_adapter app.add_url_rule( "/articles//comments", view_func=com.create_comment, methods=["POST"], endpoint="comment.create_comment" ) @@ -81,11 +76,10 @@ def _register_comment_routes(app: Flask, adapters: dict) -> None: ) -def _register_auth_routes(app: Flask, adapters: dict) -> None: - log = adapters["login_adapter"] - reg = adapters["registration_adapter"] - acc = adapters["account_session_adapter"] - csrf = app.extensions["csrf"] +def _register_auth_routes(app: Flask, adapters) -> None: + log = adapters.login_adapter + reg = adapters.registration_adapter + acc = adapters.account_session_adapter app.add_url_rule("/login", view_func=log.render_login_page, methods=["GET"], endpoint="auth.login") app.add_url_rule("/login", view_func=log.authenticate, methods=["POST"], endpoint="auth.authenticate") app.add_url_rule("/register", view_func=reg.render_registration_page, methods=["GET"], endpoint="registration.register") @@ -103,7 +97,6 @@ def _register_auth_routes(app: Flask, adapters: dict) -> None: methods=["POST"], endpoint="auth.upload_profile_photo", ) - csrf.exempt(acc.upload_profile_photo) app.add_url_rule( "/profile/photo/delete", @@ -127,10 +120,10 @@ def _register_auth_routes(app: Flask, adapters: dict) -> None: ) app.add_url_rule( - "/admin/users", - view_func=acc.list_all_users, - methods=["GET"], - endpoint="auth.list_all_users", + "/lang/", + view_func=acc.set_lang, + methods=["POST"], + endpoint="auth.set_lang", ) app.add_url_rule( @@ -140,38 +133,9 @@ def _register_auth_routes(app: Flask, adapters: dict) -> None: endpoint="auth.delete_account", ) - app.add_url_rule( - "/admin/users//role", - view_func=acc.change_role, - methods=["POST"], - endpoint="auth.change_role", - ) - - app.add_url_rule( - "/admin/users//ban", - view_func=acc.ban_account, - methods=["POST"], - endpoint="auth.ban_account", - ) - - app.add_url_rule( - "/admin/users//unban", - view_func=acc.unban_account, - methods=["POST"], - endpoint="auth.unban_account", - ) - - app.add_url_rule( - "/lang/", - view_func=acc.set_lang, - methods=["POST"], - endpoint="auth.set_lang", - ) - -def _register_file_routes(app: Flask, adapters: dict) -> None: - fad = adapters["file_adapter"] - csrf = app.extensions["csrf"] +def _register_file_routes(app: Flask, adapters) -> None: + fad = adapters.file_adapter app.add_url_rule( "/api/upload/image", @@ -179,7 +143,6 @@ def _register_file_routes(app: Flask, adapters: dict) -> None: methods=["POST"], endpoint="file.upload_image", ) - csrf.exempt(fad.upload_image) app.add_url_rule( "/uploads//", @@ -189,9 +152,34 @@ def _register_file_routes(app: Flask, adapters: dict) -> None: ) -def register_web_routes(app: Flask, adapters: dict) -> None: +def _register_admin_routes(app: Flask, adapters) -> None: + adm = adapters.admin_adapter + app.add_url_rule( + "/admin/users", view_func=adm.list_all_users, methods=["GET"], + endpoint="admin.list_all_users", + ) + app.add_url_rule( + "/admin/users//delete", view_func=adm.delete_account, methods=["POST"], + endpoint="admin.delete_account", + ) + app.add_url_rule( + "/admin/users//role", view_func=adm.change_role, + methods=["POST"], endpoint="admin.change_role", + ) + app.add_url_rule( + "/admin/users//ban", view_func=adm.ban_account, + methods=["POST"], endpoint="admin.ban_account", + ) + app.add_url_rule( + "/admin/users//unban", view_func=adm.unban_account, + methods=["POST"], endpoint="admin.unban_account", + ) + + +def register_web_routes(app: Flask, adapters) -> None: _register_article_routes(app, adapters) _register_article_api_routes(app, adapters) _register_comment_routes(app, adapters) _register_auth_routes(app, adapters) + _register_admin_routes(app, adapters) _register_file_routes(app, adapters) diff --git a/utils/template_helpers.py b/flask_setup/template_helpers.py similarity index 86% rename from utils/template_helpers.py rename to flask_setup/template_helpers.py index 2bb1bbac..f44ee57a 100644 --- a/utils/template_helpers.py +++ b/flask_setup/template_helpers.py @@ -1,11 +1,33 @@ +"""Jinja2 filters, context processors, and Vite asset resolution. + +Consumed by Flask templates via ``blog_comment_application.py``. +Registered as Jinja filters and context processors at startup. +""" + import json import os from datetime import UTC, datetime from zoneinfo import ZoneInfo from babel.dates import format_datetime +from flask import g as global_request_context from flask_babel import get_locale -from markupsafe import Markup, escape + + +def inject_current_user() -> dict: + """ + Context processor that injects the current authenticated user into + the template rendering context. + + Reads the user from ``flask.g.current_user`` (set by the + ``before_request`` hook in ``AccountSessionAdapter._identify_user``) + and makes it available as ``current_user`` in every template. + + Returns: + dict: A single-entry dictionary with key ``"current_user"`` + set to the domain Account object, or ``None`` for anonymous visitors. + """ + return {"current_user": global_request_context.get("current_user")} class ViteManifest: @@ -67,26 +89,6 @@ def get_vendor_js(cls) -> str | None: return path return None -def nl2br_filter(text: str | None) -> str: - """ - Jinja2 filter that escapes HTML and converts newlines to
tags. - - Safely renders user-generated text by first escaping all HTML, - then replacing newline characters with HTML line break tags. - The result is marked as safe HTML to prevent double-escaping. - - Args: - text: Raw user input string, or None. - - Returns: - An escaped string with \\n replaced by
\\n, marked as safe HTML. - Returns empty string if input is None or empty. - """ - if not text: - return "" - escaped = escape(text) - return Markup(str(escaped).replace("\n", "
\n")) - def inject_vite_assets() -> dict: """ diff --git a/frontend/core/__tests__/ArticleForm.test.jsx b/frontend/core/__tests__/ArticleForm.test.jsx index 25ca4a31..7a4e1221 100644 --- a/frontend/core/__tests__/ArticleForm.test.jsx +++ b/frontend/core/__tests__/ArticleForm.test.jsx @@ -1,5 +1,43 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { applyVideoDictOverrides } from '../components/ArticleForm'; + + +function makeMockEditor() { + return { + dictionary: { + slash_menu: { + video: { + title: 'Video', + subtext: 'Resizable video with caption', + aliases: ['video', 'videoUpload', 'upload', 'mp4', 'film', 'media', 'url'], + group: 'Media', + }, + }, + file_panel: { + embed: { + title: 'Embed', + url_placeholder: 'Enter URL', + embed_button: { + image: 'Embed image', + video: 'Embed video', + audio: 'Embed audio', + file: 'Embed file', + }, + }, + }, + file_blocks: { + add_button_text: { + image: 'Add image', + video: 'Add video', + audio: 'Add audio', + file: 'Add file', + }, + }, + }, + }; +} + const mockEditor = { document: [ { id: 'p1', type: 'paragraph' }, @@ -84,3 +122,48 @@ describe('ArticleForm mousedown handler', () => { expect(mockEditor.setTextCursorPosition).toHaveBeenCalledWith('p1', 'start'); }); }); + +describe('applyVideoDictOverrides', () => { + it('renames slash menu title to YouTube', () => { + const editor = makeMockEditor(); + applyVideoDictOverrides(editor); + expect(editor.dictionary.slash_menu.video.title).toBe('YouTube'); + }); + + it('sets slash menu subtext', () => { + const editor = makeMockEditor(); + applyVideoDictOverrides(editor); + expect(editor.dictionary.slash_menu.video.subtext).toBe('Paste a YouTube video URL'); + }); + + it('adds youtube and yt aliases', () => { + const editor = makeMockEditor(); + applyVideoDictOverrides(editor); + expect(editor.dictionary.slash_menu.video.aliases).toContain('youtube'); + expect(editor.dictionary.slash_menu.video.aliases).toContain('yt'); + }); + + it('renames file panel embed tab title', () => { + const editor = makeMockEditor(); + applyVideoDictOverrides(editor); + expect(editor.dictionary.file_panel.embed.title).toBe('YouTube URL'); + }); + + it('updates embed button text for video', () => { + const editor = makeMockEditor(); + applyVideoDictOverrides(editor); + expect(editor.dictionary.file_panel.embed.embed_button.video).toBe('Embed YouTube video'); + }); + + it('updates embed placeholder', () => { + const editor = makeMockEditor(); + applyVideoDictOverrides(editor); + expect(editor.dictionary.file_panel.embed.url_placeholder).toBe('Paste YouTube video link'); + }); + + it('updates add button text for video', () => { + const editor = makeMockEditor(); + applyVideoDictOverrides(editor); + expect(editor.dictionary.file_blocks.add_button_text.video).toBe('Add YouTube video URL'); + }); +}); diff --git a/frontend/core/__tests__/video-dict.test.js b/frontend/core/__tests__/video-dict.test.js deleted file mode 100644 index 0863ec8d..00000000 --- a/frontend/core/__tests__/video-dict.test.js +++ /dev/null @@ -1,85 +0,0 @@ -import { describe, it, expect } from 'vitest'; - -import { applyVideoDictOverrides } from '../components/ArticleForm'; - - -function makeMockEditor() { - return { - dictionary: { - slash_menu: { - video: { - title: 'Video', - subtext: 'Resizable video with caption', - aliases: ['video', 'videoUpload', 'upload', 'mp4', 'film', 'media', 'url'], - group: 'Media', - }, - }, - file_panel: { - embed: { - title: 'Embed', - url_placeholder: 'Enter URL', - embed_button: { - image: 'Embed image', - video: 'Embed video', - audio: 'Embed audio', - file: 'Embed file', - }, - }, - }, - file_blocks: { - add_button_text: { - image: 'Add image', - video: 'Add video', - audio: 'Add audio', - file: 'Add file', - }, - }, - }, - }; -} - - -describe('applyVideoDictOverrides', () => { - it('renames slash menu title to YouTube', () => { - const editor = makeMockEditor(); - applyVideoDictOverrides(editor); - expect(editor.dictionary.slash_menu.video.title).toBe('YouTube'); - }); - - it('sets slash menu subtext', () => { - const editor = makeMockEditor(); - applyVideoDictOverrides(editor); - expect(editor.dictionary.slash_menu.video.subtext).toBe('Paste a YouTube video URL'); - }); - - it('adds youtube and yt aliases', () => { - const editor = makeMockEditor(); - applyVideoDictOverrides(editor); - expect(editor.dictionary.slash_menu.video.aliases).toContain('youtube'); - expect(editor.dictionary.slash_menu.video.aliases).toContain('yt'); - }); - - it('renames file panel embed tab title', () => { - const editor = makeMockEditor(); - applyVideoDictOverrides(editor); - expect(editor.dictionary.file_panel.embed.title).toBe('YouTube URL'); - }); - - it('updates embed button text for video', () => { - const editor = makeMockEditor(); - applyVideoDictOverrides(editor); - expect(editor.dictionary.file_panel.embed.embed_button.video).toBe('Embed YouTube video'); - }); - - it('updates embed placeholder', () => { - const editor = makeMockEditor(); - applyVideoDictOverrides(editor); - expect(editor.dictionary.file_panel.embed.url_placeholder).toBe('Paste YouTube video link'); - }); - - it('updates add button text for video', () => { - const editor = makeMockEditor(); - applyVideoDictOverrides(editor); - expect(editor.dictionary.file_blocks.add_button_text.video).toBe('Add YouTube video URL'); - }); -}); diff --git a/frontend/core/components/ArticleForm.jsx b/frontend/core/components/ArticleForm.jsx index 65791902..8b0bf73d 100644 --- a/frontend/core/components/ArticleForm.jsx +++ b/frontend/core/components/ArticleForm.jsx @@ -62,7 +62,7 @@ function CustomFilePanel({ blockId }) { return ; } -function BlockNoteEditor({ initialContent, onReady }) { +function BlockNoteEditor({ initialContent, onReady, onEditorChange }) { const [theme, setTheme] = useState(() => document.documentElement.dataset.theme === 'dark' ? 'dark' : 'light', ); @@ -190,6 +190,15 @@ function BlockNoteEditor({ initialContent, onReady }) { if (editor && onReady) onReady(editor); }, [editor, onReady]); + useEffect(() => { + if (!editor || !onEditorChange) return; + const el = editor.domElement?.querySelector?.('.ProseMirror'); + if (!el) return; + const observer = new MutationObserver(() => onEditorChange()); + observer.observe(el, { childList: true, subtree: true, characterData: true }); + return () => observer.disconnect(); + }, [editor, onEditorChange]); + useEffect(() => { if (!editor) return; const handler = (e) => { @@ -350,27 +359,93 @@ export default function ArticleForm() { const [error, setError] = useState(''); const [saving, setSaving] = useState(false); const editorRef = useRef(null); - const lastTapRef = useRef({ time: 0, target: null }); + const lastTapRef = useRef({ time: 0, target: null, count: 0 }); + const IS_CHROME_MOBILE = /Chrome/.test(navigator.userAgent) && /(Mobile|Android)/.test(navigator.userAgent); + const [isDirty, setIsDirty] = useState(false); + const initTitleRef = useRef(null); + const initDescRef = useRef(null); + const initContentRef = useRef(null); + const confirmLeaveMsg = _('You have unsaved changes. Are you sure you want to leave?'); const handleDoubleTapSelect = useCallback((e) => { + if (e.detail === 2 || !IS_CHROME_MOBILE) return; const now = Date.now(); const last = lastTapRef.current; if (last.target === e.currentTarget && now - last.time < 400) { - e.currentTarget.select(); - lastTapRef.current = { time: 0, target: null }; + if (last.count === 0) { + const el = e.currentTarget; + const text = el.value; + let start = el.selectionStart; + while (start > 0 && /\S/.test(text[start - 1])) start--; + let end = el.selectionEnd; + while (end < text.length && /\S/.test(text[end])) end++; + el.setSelectionRange(start, end); + lastTapRef.current = { time: now, target: e.currentTarget, count: 1 }; + } else { + e.currentTarget.select(); + lastTapRef.current = { time: 0, target: null, count: 0 }; + } } else { - lastTapRef.current = { time: now, target: e.currentTarget }; + lastTapRef.current = { time: now, target: e.currentTarget, count: 0 }; } }, []); useEffect(() => { - if (loadedTitle) setTitle(loadedTitle); + if (loadedTitle) { + setTitle(loadedTitle); + if (initTitleRef.current === null) initTitleRef.current = loadedTitle; + } }, [loadedTitle]); useEffect(() => { - if (loadedDescription) setDescription(loadedDescription); + if (loadedDescription) { + setDescription(loadedDescription); + if (initDescRef.current === null) initDescRef.current = loadedDescription; + } }, [loadedDescription]); + useEffect(() => { + if (contentStr && initContentRef.current === null) { + initContentRef.current = contentStr; + } + }, [contentStr]); + + useEffect(() => { + if (page === 'create' && initTitleRef.current === null) { + initTitleRef.current = ''; + initDescRef.current = ''; + initContentRef.current = null; + } + }, [page]); + + useEffect(() => { + if (initTitleRef.current === null) return; + if (title !== initTitleRef.current) setIsDirty(true); + }, [title]); + + useEffect(() => { + if (initDescRef.current === null) return; + if (description !== initDescRef.current) setIsDirty(true); + }, [description]); + + useEffect(() => { + if (!isDirty) return; + const handler = (e) => { e.preventDefault(); }; + window.addEventListener('beforeunload', handler); + return () => window.removeEventListener('beforeunload', handler); + }, [isDirty]); + + useEffect(() => { + if (!isDirty) return; + const handler = (e) => { + const link = e.target.closest('a.detail-back-link'); + if (!link) return; + if (!window.confirm(confirmLeaveMsg)) e.preventDefault(); + }; + document.addEventListener('click', handler, true); + return () => document.removeEventListener('click', handler, true); + }, [isDirty, confirmLeaveMsg]); + useCodeBlockGapClick(editorRef); const handleSubmit = async () => { @@ -393,6 +468,7 @@ export default function ArticleForm() { }); if (res.ok) { + setIsDirty(false); const data = await res.json(); window.location.href = `/articles/${data.id || articleId}`; } else if (res.status === 401) { @@ -453,7 +529,7 @@ export default function ArticleForm() {
{_('Content')}
- { editorRef.current = ed; }} /> + { editorRef.current = ed; }} onEditorChange={() => !isDirty && setIsDirty(true)} />
-