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)} /> diff --git a/frontend/core/utils/shiki-highlighter-editor.js b/frontend/core/utils/shiki-highlighter-editor.js index d5f7efca..36650f72 100644 --- a/frontend/core/utils/shiki-highlighter-editor.js +++ b/frontend/core/utils/shiki-highlighter-editor.js @@ -1,48 +1,6 @@ // Auto-generated by tools/generate-langs.mjs â do not edit manually import { createBundledHighlighter } from '@shikijs/core'; import { createJavaScriptRegexEngine } from '@shikijs/engine-javascript'; -import lang_c from '@shikijs/langs-precompiled/c'; -import lang_cpp from '@shikijs/langs-precompiled/cpp'; -import lang_csharp from '@shikijs/langs-precompiled/csharp'; -import lang_css from '@shikijs/langs-precompiled/css'; -import lang_diff from '@shikijs/langs-precompiled/diff'; -import lang_docker from '@shikijs/langs-precompiled/docker'; -import lang_go from '@shikijs/langs-precompiled/go'; -import lang_graphql from '@shikijs/langs-precompiled/graphql'; -import lang_html from '@shikijs/langs-precompiled/html'; -import lang_java from '@shikijs/langs-precompiled/java'; -import lang_javascript from '@shikijs/langs-precompiled/javascript'; -import lang_json from '@shikijs/langs-precompiled/json'; -import lang_jsonc from '@shikijs/langs-precompiled/jsonc'; -import lang_jsx from '@shikijs/langs-precompiled/jsx'; -import lang_kotlin from '@shikijs/langs-precompiled/kotlin'; -import lang_lua from '@shikijs/langs-precompiled/lua'; -import lang_make from '@shikijs/langs-precompiled/make'; -import lang_markdown from '@shikijs/langs-precompiled/markdown'; -import lang_php from '@shikijs/langs-precompiled/php'; -import lang_powershell from '@shikijs/langs-precompiled/powershell'; -import lang_python from '@shikijs/langs-precompiled/python'; -import lang_r from '@shikijs/langs-precompiled/r'; -import lang_ruby from '@shikijs/langs-precompiled/ruby'; -import lang_rust from '@shikijs/langs-precompiled/rust'; -import lang_scss from '@shikijs/langs-precompiled/scss'; -import lang_shellscript from '@shikijs/langs-precompiled/shellscript'; -import lang_shellsession from '@shikijs/langs-precompiled/shellsession'; -import lang_sql from '@shikijs/langs-precompiled/sql'; -import lang_swift from '@shikijs/langs-precompiled/swift'; -import lang_terraform from '@shikijs/langs-precompiled/terraform'; -import lang_toml from '@shikijs/langs-precompiled/toml'; -import lang_tsx from '@shikijs/langs-precompiled/tsx'; -import lang_typescript from '@shikijs/langs-precompiled/typescript'; -import lang_xml from '@shikijs/langs-precompiled/xml'; -import lang_yaml from '@shikijs/langs-precompiled/yaml'; -import nord from '@shikijs/themes/nord'; -import monokai from '@shikijs/themes/monokai'; -import oneDarkPro from '@shikijs/themes/one-dark-pro'; -import catppuccinMocha from '@shikijs/themes/catppuccin-mocha'; -import draculaSoft from '@shikijs/themes/dracula-soft'; -import githubDark from '@shikijs/themes/github-dark'; -import githubLight from '@shikijs/themes/github-light'; const plaintextLang = { name: 'plaintext', @@ -52,13 +10,13 @@ const plaintextLang = { }; const ALL_THEMES = { - 'nord': () => Promise.resolve(nord), - 'monokai': () => Promise.resolve(monokai), - 'one-dark-pro': () => Promise.resolve(oneDarkPro), - 'catppuccin-mocha': () => Promise.resolve(catppuccinMocha), - 'dracula-soft': () => Promise.resolve(draculaSoft), - 'github-dark': () => Promise.resolve(githubDark), - 'github-light': () => Promise.resolve(githubLight), + 'nord': () => import('@shikijs/themes/nord').then(m => m.default || m), + 'monokai': () => import('@shikijs/themes/monokai').then(m => m.default || m), + 'one-dark-pro': () => import('@shikijs/themes/one-dark-pro').then(m => m.default || m), + 'catppuccin-mocha': () => import('@shikijs/themes/catppuccin-mocha').then(m => m.default || m), + 'dracula-soft': () => import('@shikijs/themes/dracula-soft').then(m => m.default || m), + 'github-dark': () => import('@shikijs/themes/github-dark').then(m => m.default || m), + 'github-light': () => import('@shikijs/themes/github-light').then(m => m.default || m), }; const LANG_THEME_MAP = { @@ -89,41 +47,41 @@ function themeForLang(lang) { const highlighterFactory = createBundledHighlighter({ langs: { 'plaintext': () => Promise.resolve(plaintextLang), - "c": () => Promise.resolve(lang_c), - "cpp": () => Promise.resolve(lang_cpp), - "csharp": () => Promise.resolve(lang_csharp), - "css": () => Promise.resolve(lang_css), - "diff": () => Promise.resolve(lang_diff), - "docker": () => Promise.resolve(lang_docker), - "go": () => Promise.resolve(lang_go), - "graphql": () => Promise.resolve(lang_graphql), - "html": () => Promise.resolve(lang_html), - "java": () => Promise.resolve(lang_java), - "javascript": () => Promise.resolve(lang_javascript), - "json": () => Promise.resolve(lang_json), - "jsonc": () => Promise.resolve(lang_jsonc), - "jsx": () => Promise.resolve(lang_jsx), - "kotlin": () => Promise.resolve(lang_kotlin), - "lua": () => Promise.resolve(lang_lua), - "make": () => Promise.resolve(lang_make), - "markdown": () => Promise.resolve(lang_markdown), - "php": () => Promise.resolve(lang_php), - "powershell": () => Promise.resolve(lang_powershell), - "python": () => Promise.resolve(lang_python), - "r": () => Promise.resolve(lang_r), - "ruby": () => Promise.resolve(lang_ruby), - "rust": () => Promise.resolve(lang_rust), - "scss": () => Promise.resolve(lang_scss), - "shellscript": () => Promise.resolve(lang_shellscript), - "shellsession": () => Promise.resolve(lang_shellsession), - "sql": () => Promise.resolve(lang_sql), - "swift": () => Promise.resolve(lang_swift), - "terraform": () => Promise.resolve(lang_terraform), - "toml": () => Promise.resolve(lang_toml), - "tsx": () => Promise.resolve(lang_tsx), - "typescript": () => Promise.resolve(lang_typescript), - "xml": () => Promise.resolve(lang_xml), - "yaml": () => Promise.resolve(lang_yaml), + "c": () => import('@shikijs/langs-precompiled/c').then(m => m.default || m), + "cpp": () => import('@shikijs/langs-precompiled/cpp').then(m => m.default || m), + "csharp": () => import('@shikijs/langs-precompiled/csharp').then(m => m.default || m), + "css": () => import('@shikijs/langs-precompiled/css').then(m => m.default || m), + "diff": () => import('@shikijs/langs-precompiled/diff').then(m => m.default || m), + "docker": () => import('@shikijs/langs-precompiled/docker').then(m => m.default || m), + "go": () => import('@shikijs/langs-precompiled/go').then(m => m.default || m), + "graphql": () => import('@shikijs/langs-precompiled/graphql').then(m => m.default || m), + "html": () => import('@shikijs/langs-precompiled/html').then(m => m.default || m), + "java": () => import('@shikijs/langs-precompiled/java').then(m => m.default || m), + "javascript": () => import('@shikijs/langs-precompiled/javascript').then(m => m.default || m), + "json": () => import('@shikijs/langs-precompiled/json').then(m => m.default || m), + "jsonc": () => import('@shikijs/langs-precompiled/jsonc').then(m => m.default || m), + "jsx": () => import('@shikijs/langs-precompiled/jsx').then(m => m.default || m), + "kotlin": () => import('@shikijs/langs-precompiled/kotlin').then(m => m.default || m), + "lua": () => import('@shikijs/langs-precompiled/lua').then(m => m.default || m), + "make": () => import('@shikijs/langs-precompiled/make').then(m => m.default || m), + "markdown": () => import('@shikijs/langs-precompiled/markdown').then(m => m.default || m), + "php": () => import('@shikijs/langs-precompiled/php').then(m => m.default || m), + "powershell": () => import('@shikijs/langs-precompiled/powershell').then(m => m.default || m), + "python": () => import('@shikijs/langs-precompiled/python').then(m => m.default || m), + "r": () => import('@shikijs/langs-precompiled/r').then(m => m.default || m), + "ruby": () => import('@shikijs/langs-precompiled/ruby').then(m => m.default || m), + "rust": () => import('@shikijs/langs-precompiled/rust').then(m => m.default || m), + "scss": () => import('@shikijs/langs-precompiled/scss').then(m => m.default || m), + "shellscript": () => import('@shikijs/langs-precompiled/shellscript').then(m => m.default || m), + "shellsession": () => import('@shikijs/langs-precompiled/shellsession').then(m => m.default || m), + "sql": () => import('@shikijs/langs-precompiled/sql').then(m => m.default || m), + "swift": () => import('@shikijs/langs-precompiled/swift').then(m => m.default || m), + "terraform": () => import('@shikijs/langs-precompiled/terraform').then(m => m.default || m), + "toml": () => import('@shikijs/langs-precompiled/toml').then(m => m.default || m), + "tsx": () => import('@shikijs/langs-precompiled/tsx').then(m => m.default || m), + "typescript": () => import('@shikijs/langs-precompiled/typescript').then(m => m.default || m), + "xml": () => import('@shikijs/langs-precompiled/xml').then(m => m.default || m), + "yaml": () => import('@shikijs/langs-precompiled/yaml').then(m => m.default || m), }, themes: ALL_THEMES, engine: () => createJavaScriptRegexEngine(), diff --git a/frontend/static/css/article.css b/frontend/static/css/article.css index 22cd4b15..da684c2b 100644 --- a/frontend/static/css/article.css +++ b/frontend/static/css/article.css @@ -52,18 +52,6 @@ color: var(--primary); } -.item-excerpt { - font-family: var(--font-primary); - font-size: var(--font-body-md); - color: var(--on-surface-variant); - line-height: 1.6; - margin-top: var(--spacing-xs); - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; - overflow: hidden; -} - .item-meta { margin-top: var(--spacing-lg); display: flex; @@ -109,11 +97,6 @@ flex-shrink: 0; } -.meta-author-avatar-link { - display: flex; - text-decoration: none; -} - a.meta-author:hover { color: var(--primary); text-decoration: underline; @@ -589,10 +572,6 @@ input[type=number] { gap: var(--spacing-sm); } -.detail-delete-btn { - color: var(--error); -} - /* Comments Section */ .comments-title { font-family: var(--font-mono); @@ -700,10 +679,6 @@ a.comment-author:hover { text-decoration: underline; } -.comment-avatar-link { - text-decoration: none; -} - .comment-timestamp { font-size: var(--font-body-xs); color: var(--on-surface-variant); @@ -756,11 +731,6 @@ a.comment-author:hover { } } -.comment-edited { - font-size: var(--font-body-xs); - color: var(--on-surface-variant); - font-style: italic; -} .comment-form-actions { display: flex; @@ -1090,17 +1060,7 @@ a.comment-author:hover { box-shadow: 0 0 0 2px color-mix(in srgb, var(--error) 20%, transparent); } -@keyframes fadeIn { - from { - opacity: 0; - transform: translateY(0.625rem); - } - to { - opacity: 1; - transform: translateY(0); - } -} /* BlockNote code blocks */ .bn-code-block pre code { diff --git a/frontend/static/css/base.css b/frontend/static/css/base.css index edc8fd54..d5a20a2f 100644 --- a/frontend/static/css/base.css +++ b/frontend/static/css/base.css @@ -423,6 +423,12 @@ a:hover { border-color: var(--info); } +.alert.alert-warning { + color: var(--warning); + background: var(--warning-container); + border-color: var(--warning); +} + /* Forms */ .form-group { margin-bottom: var(--spacing-md); diff --git a/frontend/static/css/profile.css b/frontend/static/css/profile.css index 4ac09c13..79965ddc 100644 --- a/frontend/static/css/profile.css +++ b/frontend/static/css/profile.css @@ -197,9 +197,6 @@ transition: color 0.15s ease; } -.back-link:hover { - color: var(--primary); -} .back-link .icon { display: flex; diff --git a/frontend/static/scripts/double-tap-select.js b/frontend/static/scripts/double-tap-select.js index 23de946a..d40b2b36 100644 --- a/frontend/static/scripts/double-tap-select.js +++ b/frontend/static/scripts/double-tap-select.js @@ -1,19 +1,31 @@ 'use strict'; (() => { - let lastTap = 0; + if (!(/Chrome/.test(navigator.userAgent) && /(Mobile|Android)/.test(navigator.userAgent))) return; + let lastTap = { time: 0, target: null, count: 0 }; document.addEventListener('click', (e) => { + if (e.detail === 2) return; const input = e.target.closest( 'input:not([type="hidden"]):not([type="submit"]):not([type="button"]):not([type="checkbox"]):not([type="radio"]):not(.article-editor-title), ' + 'textarea:not(.article-editor-description)' ); if (!input) return; const now = Date.now(); - if (lastTap && now - lastTap < 400) { - input.select(); - lastTap = 0; + if (lastTap.target === input && now - lastTap.time < 400) { + if (lastTap.count === 0) { + const text = input.value; + let start = input.selectionStart; + while (start > 0 && /\S/.test(text[start - 1])) start--; + let end = input.selectionEnd; + while (end < text.length && /\S/.test(text[end])) end++; + input.setSelectionRange(start, end); + lastTap = { time: now, target: input, count: 1 }; + } else { + input.select(); + lastTap = { time: 0, target: null, count: 0 }; + } } else { - lastTap = now; + lastTap = { time: now, target: input, count: 0 }; } }); })(); diff --git a/frontend/static/scripts/form-submit.js b/frontend/static/scripts/form-submit.js new file mode 100644 index 00000000..6b1a81b5 --- /dev/null +++ b/frontend/static/scripts/form-submit.js @@ -0,0 +1,10 @@ +'use strict'; + +(() => { + document.addEventListener('submit', (e) => { + const btn = e.target.querySelector('button[type="submit"]'); + if (!btn || btn.disabled) return; + btn.disabled = true; + setTimeout(() => { btn.disabled = false; }, 10000); + }); +})(); diff --git a/frontend/static/scripts/pagination.js b/frontend/static/scripts/pagination.js index 05ad6eeb..37a0d6ab 100644 --- a/frontend/static/scripts/pagination.js +++ b/frontend/static/scripts/pagination.js @@ -21,6 +21,7 @@ const openJumpModal = () => { jumpModal.showModal(); jumpInput.value = ''; + jumpInput.removeAttribute('aria-invalid'); }; jumpModal.addEventListener('focus', () => jumpInput.focus(), { once: true }); @@ -49,8 +50,10 @@ window.location.href = `${baseUrl}?page=${val}`; } else { jumpInput.classList.add(INPUT_ERROR_CLASS); + jumpInput.setAttribute('aria-invalid', 'true'); setTimeout(() => { jumpInput.classList.remove(INPUT_ERROR_CLASS); + jumpInput.removeAttribute('aria-invalid'); }, 500); } }; diff --git a/frontend/static/scripts/profile-avatar.js b/frontend/static/scripts/profile-avatar.js index ad70d87f..1a9c6ed3 100644 --- a/frontend/static/scripts/profile-avatar.js +++ b/frontend/static/scripts/profile-avatar.js @@ -22,10 +22,10 @@ location.reload(); } else { const data = await response.json(); - alert(data.error || "Ăchec du tĂ©lĂ©chargement."); + alert(data.error || "Upload failed."); } } catch { - alert("Erreur rĂ©seau lors du tĂ©lĂ©chargement."); + alert("Network error during upload."); } }); })(); diff --git a/frontend/static/scripts/suneditor-init.js b/frontend/static/scripts/suneditor-init.js index 9a198b54..d3c8485c 100644 --- a/frontend/static/scripts/suneditor-init.js +++ b/frontend/static/scripts/suneditor-init.js @@ -123,6 +123,7 @@ } const suneditors = {}; + window.suneditors = suneditors; function initCommentEditor(textareaId, hiddenInputId) { const textarea = document.getElementById(textareaId); diff --git a/frontend/static/scripts/theme-toggle.js b/frontend/static/scripts/theme-toggle.js index a06b9954..9f04a974 100644 --- a/frontend/static/scripts/theme-toggle.js +++ b/frontend/static/scripts/theme-toggle.js @@ -40,6 +40,7 @@ const THEME_ICONS = { const updateIcon = (theme) => { iconSpan.innerHTML = theme === THEME_LIGHT ? THEME_ICONS.dark_mode : THEME_ICONS.light_mode; + toggleBtn.setAttribute('aria-pressed', theme === THEME_DARK ? 'true' : 'false'); }; updateIcon(document.documentElement.dataset.theme); diff --git a/frontend/templates/article_create.html b/frontend/templates/article_create.html index ed663d8b..a6dcefbe 100644 --- a/frontend/templates/article_create.html +++ b/frontend/templates/article_create.html @@ -56,7 +56,8 @@ 'Plain Text': _('Plain Text'), 'Search languages...': _('Search languages...'), 'No languages found': _('No languages found'), - 'Something went wrong. Please reload the page.': _('Something went wrong. Please reload the page.') + 'Something went wrong. Please reload the page.': _('Something went wrong. Please reload the page.'), + 'You have unsaved changes. Are you sure you want to leave?': _('You have unsaved changes. Are you sure you want to leave?') } | tojson | safe }} {% endblock %} diff --git a/frontend/templates/article_edit.html b/frontend/templates/article_edit.html index e288c9a4..f3b71103 100644 --- a/frontend/templates/article_edit.html +++ b/frontend/templates/article_edit.html @@ -85,7 +85,8 @@ 'Plain Text': _('Plain Text'), 'Search languages...': _('Search languages...'), 'No languages found': _('No languages found'), - 'Something went wrong. Please reload the page.': _('Something went wrong. Please reload the page.') + 'Something went wrong. Please reload the page.': _('Something went wrong. Please reload the page.'), + 'You have unsaved changes. Are you sure you want to leave?': _('You have unsaved changes. Are you sure you want to leave?') } | tojson | safe }} {% endblock %} diff --git a/frontend/templates/article_list.html b/frontend/templates/article_list.html index 58e70f98..cf316788 100644 --- a/frontend/templates/article_list.html +++ b/frontend/templates/article_list.html @@ -24,16 +24,16 @@ + value="{{ query or '' }}" autocomplete="off" aria-label="{{ _('Search') }}"> {% if query %} - × + × {% endif %} {% include 'icons/search.svg' %} - + {{ icon('arrow_back') }} @@ -100,7 +100,7 @@ - + {{ icon('arrow_back') }} @@ -119,9 +119,9 @@ - + - {{ _('Jump to page') }} + {{ _('Jump to page') }} @@ -129,10 +129,10 @@ {{ _('Jump to page') }} - + {{ icon('expand_less') }} - + {{ icon('expand_more') }} diff --git a/frontend/templates/base.html b/frontend/templates/base.html index 0fce7323..fb09a51f 100644 --- a/frontend/templates/base.html +++ b/frontend/templates/base.html @@ -20,7 +20,7 @@ {% if page_with_editor %} {% for css_url in vite_css_urls %} - + {% endfor %} {% if vite_vendor_js_url %} @@ -35,37 +35,37 @@ - + DevJournal - DJ + DJ - + {{ icon('dark_mode', 'nav-item-icon') }} - + {{ get_locale() == 'fr' and 'EN' or 'FR' }} - + {{ icon('home', 'nav-item-icon nav-item-home') }} {{ _('Home') }} {% if current_user %} {% if current_user.account_role in ['admin', 'author'] %} - + {{ icon('add_box', 'nav-item-icon') }} {{ _('New article') }} {% endif %} - + {{ icon('account_circle', 'nav-item-icon') }} {{ _('Profile') }} @@ -77,11 +77,11 @@ {% else %} - + {{ icon('person', 'nav-item-icon') }} {{ _('Sign In') }} - + {{ icon('person_add', 'nav-item-icon') }} {{ _('Sign Up') }} @@ -97,7 +97,7 @@ {% if messages %} {% for category, message in messages %} - {{ message }} + {{ message }} {% endfor %} {% endif %} @@ -106,7 +106,7 @@ {% block content %}{% endblock %} - + {{ _('Confirm') }} @@ -117,9 +117,9 @@ {{ _('Confirm') }} - + - {{ _('Ban') }} + {{ _('Ban') }} @@ -167,6 +167,7 @@ {{ _('Ban') }} {% endif %} + {% block extra_js %}{% endblock %}