From 991869641b686cc7da44a99288ef5bab04b97104 Mon Sep 17 00:00:00 2001 From: MyDrift Date: Sat, 4 Oct 2025 16:11:11 +0200 Subject: [PATCH 01/10] Switch all endpoints to POST and use request body - Changed all API endpoint methods from GET to POST in configuration files and updated handler logic to accept parameters via a Pydantic request body model. --- src/app.py | 2 +- .../_login_token-php/Authentication/auth.json | 2 +- .../mod_assign_get_assignments.json | 2 +- .../mod_assign_get_submission_status.json | 2 +- .../mod_assign_get_submissions.json | 2 +- ..._calendar_get_action_events_by_course.json | 2 +- .../core_calendar_get_calendar_events.json | 2 +- ...tion_get_activities_completion_status.json | 2 +- ...mpletion_get_course_completion_status.json | 2 +- .../Core/core_webservice_get_site_info.json | 2 +- .../Courses/core_course_get_categories.json | 2 +- .../Courses/core_course_get_contents.json | 2 +- .../core_course_get_courses_by_field.json | 2 +- .../Courses/core_course_search_courses.json | 2 +- .../core_enrol_get_enrolled_users.json | 2 +- .../core_enrol_get_users_courses.json | 2 +- .../Files/core_files_get_files.json | 2 +- .../mod_forum_get_forum_discussions.json | 2 +- .../mod_forum_get_forums_by_courses.json | 2 +- .../gradereport_user_get_grade_items.json | 2 +- .../Messages/core_message_get_messages.json | 2 +- .../Quizzes/mod_quiz_get_attempt_data.json | 2 +- .../Quizzes/mod_quiz_get_attempt_review.json | 2 +- .../Quizzes/mod_quiz_get_attempt_summary.json | 2 +- .../mod_quiz_get_quiz_access_information.json | 2 +- .../mod_quiz_get_quizzes_by_courses.json | 2 +- .../Quizzes/mod_quiz_get_user_attempts.json | 2 +- .../Quizzes/mod_quiz_process_attempt.json | 2 +- .../Quizzes/mod_quiz_save_attempt.json | 2 +- .../Quizzes/mod_quiz_start_attempt.json | 2 +- .../Quizzes/mod_quiz_view_attempt.json | 2 +- .../Quizzes/mod_quiz_view_quiz.json | 2 +- .../core_user_get_course_user_profiles.json | 2 +- .../Users/core_user_get_user_preferences.json | 2 +- .../Users/core_user_get_users_by_field.json | 2 +- src/mw_utils/handlers.py | 128 ++++++++++-------- 36 files changed, 106 insertions(+), 92 deletions(-) diff --git a/src/app.py b/src/app.py index 773b0ec..08ea8fa 100644 --- a/src/app.py +++ b/src/app.py @@ -77,6 +77,6 @@ async def add_request_id(request: Request, call_next: Callable): ) # Health check -@app.get("/healthz", tags=["meta"]) +@app.post("/healthz", tags=["meta"]) async def healthz(): return {"status": "ok"} \ No newline at end of file diff --git a/src/config/_login_token-php/Authentication/auth.json b/src/config/_login_token-php/Authentication/auth.json index 6972e53..1fc0636 100644 --- a/src/config/_login_token-php/Authentication/auth.json +++ b/src/config/_login_token-php/Authentication/auth.json @@ -1,5 +1,5 @@ { - "method": "GET", + "method": "POST", "description": "Get Moodle token for API calls.", "query_params": [ { diff --git a/src/config/_webservice_rest_server-php/Assignments/mod_assign_get_assignments.json b/src/config/_webservice_rest_server-php/Assignments/mod_assign_get_assignments.json index a189f9c..ec14dea 100644 --- a/src/config/_webservice_rest_server-php/Assignments/mod_assign_get_assignments.json +++ b/src/config/_webservice_rest_server-php/Assignments/mod_assign_get_assignments.json @@ -1,5 +1,5 @@ { - "method": "GET", + "method": "POST", "description": "Get assignments from specified courses", "query_params": [ { diff --git a/src/config/_webservice_rest_server-php/Assignments/mod_assign_get_submission_status.json b/src/config/_webservice_rest_server-php/Assignments/mod_assign_get_submission_status.json index b8d3a07..9994039 100644 --- a/src/config/_webservice_rest_server-php/Assignments/mod_assign_get_submission_status.json +++ b/src/config/_webservice_rest_server-php/Assignments/mod_assign_get_submission_status.json @@ -1,5 +1,5 @@ { - "method": "GET", + "method": "POST", "description": "Get assignment submission status", "query_params": [ { diff --git a/src/config/_webservice_rest_server-php/Assignments/mod_assign_get_submissions.json b/src/config/_webservice_rest_server-php/Assignments/mod_assign_get_submissions.json index b4afa9f..117df79 100644 --- a/src/config/_webservice_rest_server-php/Assignments/mod_assign_get_submissions.json +++ b/src/config/_webservice_rest_server-php/Assignments/mod_assign_get_submissions.json @@ -1,5 +1,5 @@ { - "method": "GET", + "method": "POST", "description": "Get assignment submissions", "query_params": [ { diff --git a/src/config/_webservice_rest_server-php/Calendar/core_calendar_get_action_events_by_course.json b/src/config/_webservice_rest_server-php/Calendar/core_calendar_get_action_events_by_course.json index 5f33878..bee42b6 100644 --- a/src/config/_webservice_rest_server-php/Calendar/core_calendar_get_action_events_by_course.json +++ b/src/config/_webservice_rest_server-php/Calendar/core_calendar_get_action_events_by_course.json @@ -1,5 +1,5 @@ { - "method": "GET", + "method": "POST", "description": "Get action events (assignments, quizzes) by course", "query_params": [ { diff --git a/src/config/_webservice_rest_server-php/Calendar/core_calendar_get_calendar_events.json b/src/config/_webservice_rest_server-php/Calendar/core_calendar_get_calendar_events.json index b17811b..6aab315 100644 --- a/src/config/_webservice_rest_server-php/Calendar/core_calendar_get_calendar_events.json +++ b/src/config/_webservice_rest_server-php/Calendar/core_calendar_get_calendar_events.json @@ -1,5 +1,5 @@ { - "method": "GET", + "method": "POST", "description": "Get calendar events", "query_params": [ { diff --git a/src/config/_webservice_rest_server-php/Completion/core_completion_get_activities_completion_status.json b/src/config/_webservice_rest_server-php/Completion/core_completion_get_activities_completion_status.json index e02dd52..f6af6ef 100644 --- a/src/config/_webservice_rest_server-php/Completion/core_completion_get_activities_completion_status.json +++ b/src/config/_webservice_rest_server-php/Completion/core_completion_get_activities_completion_status.json @@ -1,5 +1,5 @@ { - "method": "GET", + "method": "POST", "description": "Get completion status for activities in a course", "query_params": [ { diff --git a/src/config/_webservice_rest_server-php/Completion/core_completion_get_course_completion_status.json b/src/config/_webservice_rest_server-php/Completion/core_completion_get_course_completion_status.json index 46a828f..f9413ec 100644 --- a/src/config/_webservice_rest_server-php/Completion/core_completion_get_course_completion_status.json +++ b/src/config/_webservice_rest_server-php/Completion/core_completion_get_course_completion_status.json @@ -1,5 +1,5 @@ { - "method": "GET", + "method": "POST", "description": "Get course completion status for a user", "query_params": [ { diff --git a/src/config/_webservice_rest_server-php/Core/core_webservice_get_site_info.json b/src/config/_webservice_rest_server-php/Core/core_webservice_get_site_info.json index 58379f7..579eb8f 100644 --- a/src/config/_webservice_rest_server-php/Core/core_webservice_get_site_info.json +++ b/src/config/_webservice_rest_server-php/Core/core_webservice_get_site_info.json @@ -1,5 +1,5 @@ { - "method": "GET", + "method": "POST", "description": "Get Moodle site information & user information", "query_params": [], "responses": { diff --git a/src/config/_webservice_rest_server-php/Courses/core_course_get_categories.json b/src/config/_webservice_rest_server-php/Courses/core_course_get_categories.json index 82c9b75..e9174c4 100644 --- a/src/config/_webservice_rest_server-php/Courses/core_course_get_categories.json +++ b/src/config/_webservice_rest_server-php/Courses/core_course_get_categories.json @@ -1,5 +1,5 @@ { - "method": "GET", + "method": "POST", "description": "Get course categories", "query_params": [], "responses": { diff --git a/src/config/_webservice_rest_server-php/Courses/core_course_get_contents.json b/src/config/_webservice_rest_server-php/Courses/core_course_get_contents.json index 78eb640..f3d9ec4 100644 --- a/src/config/_webservice_rest_server-php/Courses/core_course_get_contents.json +++ b/src/config/_webservice_rest_server-php/Courses/core_course_get_contents.json @@ -1,5 +1,5 @@ { - "method": "GET", + "method": "POST", "description": "Get course contents (sections and activities)", "query_params": [ { diff --git a/src/config/_webservice_rest_server-php/Courses/core_course_get_courses_by_field.json b/src/config/_webservice_rest_server-php/Courses/core_course_get_courses_by_field.json index 073e62d..c7ddce3 100644 --- a/src/config/_webservice_rest_server-php/Courses/core_course_get_courses_by_field.json +++ b/src/config/_webservice_rest_server-php/Courses/core_course_get_courses_by_field.json @@ -1,5 +1,5 @@ { - "method": "GET", + "method": "POST", "description": "Get courses by field (id, shortname, fullname, etc.)", "query_params": [ { diff --git a/src/config/_webservice_rest_server-php/Courses/core_course_search_courses.json b/src/config/_webservice_rest_server-php/Courses/core_course_search_courses.json index 6bef2cb..b47c7e2 100644 --- a/src/config/_webservice_rest_server-php/Courses/core_course_search_courses.json +++ b/src/config/_webservice_rest_server-php/Courses/core_course_search_courses.json @@ -1,5 +1,5 @@ { - "method": "GET", + "method": "POST", "description": "Search courses by criteria", "query_params": [ { diff --git a/src/config/_webservice_rest_server-php/Enrollment/core_enrol_get_enrolled_users.json b/src/config/_webservice_rest_server-php/Enrollment/core_enrol_get_enrolled_users.json index d061467..6887f16 100644 --- a/src/config/_webservice_rest_server-php/Enrollment/core_enrol_get_enrolled_users.json +++ b/src/config/_webservice_rest_server-php/Enrollment/core_enrol_get_enrolled_users.json @@ -1,5 +1,5 @@ { - "method": "GET", + "method": "POST", "description": "Get users enrolled in a course", "query_params": [ { diff --git a/src/config/_webservice_rest_server-php/Enrollment/core_enrol_get_users_courses.json b/src/config/_webservice_rest_server-php/Enrollment/core_enrol_get_users_courses.json index ab6e4ef..0c87bfc 100644 --- a/src/config/_webservice_rest_server-php/Enrollment/core_enrol_get_users_courses.json +++ b/src/config/_webservice_rest_server-php/Enrollment/core_enrol_get_users_courses.json @@ -1,5 +1,5 @@ { - "method": "GET", + "method": "POST", "description": "Get courses the user is enrolled in", "query_params": [ { diff --git a/src/config/_webservice_rest_server-php/Files/core_files_get_files.json b/src/config/_webservice_rest_server-php/Files/core_files_get_files.json index c0f5355..32fbc7f 100644 --- a/src/config/_webservice_rest_server-php/Files/core_files_get_files.json +++ b/src/config/_webservice_rest_server-php/Files/core_files_get_files.json @@ -1,5 +1,5 @@ { - "method": "GET", + "method": "POST", "description": "Get files from specified context", "query_params": [ { diff --git a/src/config/_webservice_rest_server-php/Forums/mod_forum_get_forum_discussions.json b/src/config/_webservice_rest_server-php/Forums/mod_forum_get_forum_discussions.json index dce3cf4..10ba3b3 100644 --- a/src/config/_webservice_rest_server-php/Forums/mod_forum_get_forum_discussions.json +++ b/src/config/_webservice_rest_server-php/Forums/mod_forum_get_forum_discussions.json @@ -1,5 +1,5 @@ { - "method": "GET", + "method": "POST", "description": "Get discussions in a forum", "query_params": [ { diff --git a/src/config/_webservice_rest_server-php/Forums/mod_forum_get_forums_by_courses.json b/src/config/_webservice_rest_server-php/Forums/mod_forum_get_forums_by_courses.json index 5328c41..51f465e 100644 --- a/src/config/_webservice_rest_server-php/Forums/mod_forum_get_forums_by_courses.json +++ b/src/config/_webservice_rest_server-php/Forums/mod_forum_get_forums_by_courses.json @@ -1,5 +1,5 @@ { - "method": "GET", + "method": "POST", "description": "Get forums in specified courses", "query_params": [ { diff --git a/src/config/_webservice_rest_server-php/Grades/gradereport_user_get_grade_items.json b/src/config/_webservice_rest_server-php/Grades/gradereport_user_get_grade_items.json index dbe1e96..22ec0f5 100644 --- a/src/config/_webservice_rest_server-php/Grades/gradereport_user_get_grade_items.json +++ b/src/config/_webservice_rest_server-php/Grades/gradereport_user_get_grade_items.json @@ -1,5 +1,5 @@ { - "method": "GET", + "method": "POST", "description": "Get grade items for a course", "query_params": [ { diff --git a/src/config/_webservice_rest_server-php/Messages/core_message_get_messages.json b/src/config/_webservice_rest_server-php/Messages/core_message_get_messages.json index 101b4a0..c71a3d2 100644 --- a/src/config/_webservice_rest_server-php/Messages/core_message_get_messages.json +++ b/src/config/_webservice_rest_server-php/Messages/core_message_get_messages.json @@ -1,5 +1,5 @@ { - "method": "GET", + "method": "POST", "description": "Get messages", "query_params": [ { diff --git a/src/config/_webservice_rest_server-php/Quizzes/mod_quiz_get_attempt_data.json b/src/config/_webservice_rest_server-php/Quizzes/mod_quiz_get_attempt_data.json index 2cca739..4e3ca51 100644 --- a/src/config/_webservice_rest_server-php/Quizzes/mod_quiz_get_attempt_data.json +++ b/src/config/_webservice_rest_server-php/Quizzes/mod_quiz_get_attempt_data.json @@ -1,5 +1,5 @@ { - "method": "GET", + "method": "POST", "description": "Get quiz attempt data including questions", "query_params": [ { diff --git a/src/config/_webservice_rest_server-php/Quizzes/mod_quiz_get_attempt_review.json b/src/config/_webservice_rest_server-php/Quizzes/mod_quiz_get_attempt_review.json index fd8d9e0..31310c3 100644 --- a/src/config/_webservice_rest_server-php/Quizzes/mod_quiz_get_attempt_review.json +++ b/src/config/_webservice_rest_server-php/Quizzes/mod_quiz_get_attempt_review.json @@ -1,5 +1,5 @@ { - "method": "GET", + "method": "POST", "description": "Get the review of a finished attempt, including question feedback.", "query_params": [ { diff --git a/src/config/_webservice_rest_server-php/Quizzes/mod_quiz_get_attempt_summary.json b/src/config/_webservice_rest_server-php/Quizzes/mod_quiz_get_attempt_summary.json index 56288e2..0a37638 100644 --- a/src/config/_webservice_rest_server-php/Quizzes/mod_quiz_get_attempt_summary.json +++ b/src/config/_webservice_rest_server-php/Quizzes/mod_quiz_get_attempt_summary.json @@ -1,5 +1,5 @@ { - "method": "GET", + "method": "POST", "description": "Get quiz attempt summary", "query_params": [ { diff --git a/src/config/_webservice_rest_server-php/Quizzes/mod_quiz_get_quiz_access_information.json b/src/config/_webservice_rest_server-php/Quizzes/mod_quiz_get_quiz_access_information.json index bd7db86..c211b2d 100644 --- a/src/config/_webservice_rest_server-php/Quizzes/mod_quiz_get_quiz_access_information.json +++ b/src/config/_webservice_rest_server-php/Quizzes/mod_quiz_get_quiz_access_information.json @@ -1,5 +1,5 @@ { - "method": "GET", + "method": "POST", "description": "Get quiz access information", "query_params": [ { diff --git a/src/config/_webservice_rest_server-php/Quizzes/mod_quiz_get_quizzes_by_courses.json b/src/config/_webservice_rest_server-php/Quizzes/mod_quiz_get_quizzes_by_courses.json index 8c41d18..fccfa92 100644 --- a/src/config/_webservice_rest_server-php/Quizzes/mod_quiz_get_quizzes_by_courses.json +++ b/src/config/_webservice_rest_server-php/Quizzes/mod_quiz_get_quizzes_by_courses.json @@ -1,5 +1,5 @@ { - "method": "GET", + "method": "POST", "description": "Get quizzes in specified courses", "query_params": [ { diff --git a/src/config/_webservice_rest_server-php/Quizzes/mod_quiz_get_user_attempts.json b/src/config/_webservice_rest_server-php/Quizzes/mod_quiz_get_user_attempts.json index 3c734ac..a109c97 100644 --- a/src/config/_webservice_rest_server-php/Quizzes/mod_quiz_get_user_attempts.json +++ b/src/config/_webservice_rest_server-php/Quizzes/mod_quiz_get_user_attempts.json @@ -1,5 +1,5 @@ { - "method": "GET", + "method": "POST", "description": "Get quiz attempts for a user (defaults to current user)", "query_params": [ { diff --git a/src/config/_webservice_rest_server-php/Quizzes/mod_quiz_process_attempt.json b/src/config/_webservice_rest_server-php/Quizzes/mod_quiz_process_attempt.json index f530873..f078359 100644 --- a/src/config/_webservice_rest_server-php/Quizzes/mod_quiz_process_attempt.json +++ b/src/config/_webservice_rest_server-php/Quizzes/mod_quiz_process_attempt.json @@ -1,5 +1,5 @@ { - "method": "GET", + "method": "POST", "description": "Process and optionally finish a quiz attempt", "query_params": [ { diff --git a/src/config/_webservice_rest_server-php/Quizzes/mod_quiz_save_attempt.json b/src/config/_webservice_rest_server-php/Quizzes/mod_quiz_save_attempt.json index ee245d6..4a6dc07 100644 --- a/src/config/_webservice_rest_server-php/Quizzes/mod_quiz_save_attempt.json +++ b/src/config/_webservice_rest_server-php/Quizzes/mod_quiz_save_attempt.json @@ -1,5 +1,5 @@ { - "method": "GET", + "method": "POST", "description": "Save quiz attempt responses", "query_params": [ { diff --git a/src/config/_webservice_rest_server-php/Quizzes/mod_quiz_start_attempt.json b/src/config/_webservice_rest_server-php/Quizzes/mod_quiz_start_attempt.json index d380335..35656c9 100644 --- a/src/config/_webservice_rest_server-php/Quizzes/mod_quiz_start_attempt.json +++ b/src/config/_webservice_rest_server-php/Quizzes/mod_quiz_start_attempt.json @@ -1,5 +1,5 @@ { - "method": "GET", + "method": "POST", "description": "Start a new quiz attempt", "query_params": [ { diff --git a/src/config/_webservice_rest_server-php/Quizzes/mod_quiz_view_attempt.json b/src/config/_webservice_rest_server-php/Quizzes/mod_quiz_view_attempt.json index 6d7984c..ce0b32f 100644 --- a/src/config/_webservice_rest_server-php/Quizzes/mod_quiz_view_attempt.json +++ b/src/config/_webservice_rest_server-php/Quizzes/mod_quiz_view_attempt.json @@ -1,5 +1,5 @@ { - "method": "GET", + "method": "POST", "description": "Log that a user viewed an attempt (for analytics).", "query_params": [ { diff --git a/src/config/_webservice_rest_server-php/Quizzes/mod_quiz_view_quiz.json b/src/config/_webservice_rest_server-php/Quizzes/mod_quiz_view_quiz.json index 87952d7..c74bfcb 100644 --- a/src/config/_webservice_rest_server-php/Quizzes/mod_quiz_view_quiz.json +++ b/src/config/_webservice_rest_server-php/Quizzes/mod_quiz_view_quiz.json @@ -1,5 +1,5 @@ { - "method": "GET", + "method": "POST", "description": "Log that a user viewed the quiz (for analytics).", "query_params": [ { diff --git a/src/config/_webservice_rest_server-php/Users/core_user_get_course_user_profiles.json b/src/config/_webservice_rest_server-php/Users/core_user_get_course_user_profiles.json index 0094144..9d7488b 100644 --- a/src/config/_webservice_rest_server-php/Users/core_user_get_course_user_profiles.json +++ b/src/config/_webservice_rest_server-php/Users/core_user_get_course_user_profiles.json @@ -1,5 +1,5 @@ { - "method": "GET", + "method": "POST", "description": "Get user profiles for users in a course", "query_params": [ { diff --git a/src/config/_webservice_rest_server-php/Users/core_user_get_user_preferences.json b/src/config/_webservice_rest_server-php/Users/core_user_get_user_preferences.json index b581b77..7e412f0 100644 --- a/src/config/_webservice_rest_server-php/Users/core_user_get_user_preferences.json +++ b/src/config/_webservice_rest_server-php/Users/core_user_get_user_preferences.json @@ -1,5 +1,5 @@ { - "method": "GET", + "method": "POST", "description": "Get user preferences", "query_params": [ { diff --git a/src/config/_webservice_rest_server-php/Users/core_user_get_users_by_field.json b/src/config/_webservice_rest_server-php/Users/core_user_get_users_by_field.json index 7bfd6b4..330dc19 100644 --- a/src/config/_webservice_rest_server-php/Users/core_user_get_users_by_field.json +++ b/src/config/_webservice_rest_server-php/Users/core_user_get_users_by_field.json @@ -1,5 +1,5 @@ { - "method": "GET", + "method": "POST", "description": "Get users by field (id, username, email, etc.)", "query_params": [ { diff --git a/src/mw_utils/handlers.py b/src/mw_utils/handlers.py index 41c7067..0f37f86 100644 --- a/src/mw_utils/handlers.py +++ b/src/mw_utils/handlers.py @@ -1,6 +1,7 @@ import logging -from typing import Any, Dict, List -from fastapi import Query, HTTPException, Response, Request +from typing import Any, Dict, List, Optional +from fastapi import Query, Body, HTTPException, Response, Request +from pydantic import BaseModel, Field, create_model import httpx from .env import get_env_variable from .params import encode_param @@ -31,26 +32,11 @@ def _get_env_moodle_url() -> str: return "" if val in _def_unset_markers else val -def _build_handler_signature(query_params: List[Dict[str, Any]], require_moodle_url: bool): - """Build a FastAPI handler signature from config and moodle_url requirement.""" - import inspect - - sig_params: List[inspect.Parameter] = [ - inspect.Parameter("request", inspect.Parameter.POSITIONAL_OR_KEYWORD, annotation=Request), - inspect.Parameter("response", inspect.Parameter.POSITIONAL_OR_KEYWORD, annotation=Response), - ] - - if require_moodle_url: - sig_params.append( - inspect.Parameter( - "moodle_url", - inspect.Parameter.KEYWORD_ONLY, - annotation=str, - default=Query(..., description="URL of the Moodle instance, e.g., 'https://moodle.example.com'."), - ) - ) - +def _create_request_model(query_params: List[Dict[str, Any]], require_moodle_url: bool, function_name: str) -> type[BaseModel]: + """Create a Pydantic model for the request body based on query_params config.""" + def _py_type(tname: str): + """Map config type strings to Python types.""" t = (tname or "str").lower() if t == "int": return int @@ -58,30 +44,56 @@ def _py_type(tname: str): return float if t == "bool": return bool + if t == "list": + return List[Any] return str - + + fields = {} + + # Add moodle_url if required + if require_moodle_url: + fields["moodle_url"] = ( + str, + Field(..., description="URL of the Moodle instance, e.g., 'https://moodle.example.com'.") + ) + + # Add fields from query_params for param in query_params: pname = param["name"] ptype = _py_type(param.get("type", "str")) - if param["required"]: - sig_params.append( - inspect.Parameter( - pname, - inspect.Parameter.KEYWORD_ONLY, - annotation=ptype, - default=Query(..., description=param["description"]), - ) - ) + pdesc = param.get("description", "") + + if param.get("required", False): + # Required field + fields[pname] = (ptype, Field(..., description=pdesc)) else: + # Optional field with default default_value = param.get("default", None) - sig_params.append( - inspect.Parameter( - pname, - inspect.Parameter.KEYWORD_ONLY, - annotation=Any, - default=Query(default_value, description=param["description"]), - ) - ) + fields[pname] = (Optional[ptype], Field(default=default_value, description=pdesc)) + + # Create a dynamic Pydantic model + model_name = f"{function_name.replace('_', ' ').title().replace(' ', '')}Request" + return create_model(model_name, **fields) + + +def _build_handler_signature(request_model: type[BaseModel]): + """Build a FastAPI handler signature with a typed Pydantic body model.""" + import inspect + + sig_params: List[inspect.Parameter] = [ + inspect.Parameter("request", inspect.Parameter.POSITIONAL_OR_KEYWORD, annotation=Request), + inspect.Parameter("response", inspect.Parameter.POSITIONAL_OR_KEYWORD, annotation=Response), + ] + + # Add body parameter with the Pydantic model type + sig_params.append( + inspect.Parameter( + "body", + inspect.Parameter.KEYWORD_ONLY, + annotation=request_model, + default=Body(...), + ) + ) return inspect.Signature(sig_params) @@ -95,13 +107,21 @@ def create_handler(function_config: Dict[str, Any], endpoint_path: str): """ query_params: List[Dict[str, Any]] = function_config.get("query_params", []) method = function_config.get("method", "GET").upper() + function_name = function_config.get("function", "unknown") + + # Create the Pydantic model for this endpoint + require_moodle_url = not _get_env_moodle_url() + request_model = _create_request_model(query_params, require_moodle_url, function_name) - async def handler(request: Request, response: Response, **kwargs): + async def handler(request: Request, response: Response, body: BaseModel): """Proxy request to the Moodle instance and return JSON/text response.""" + # Convert Pydantic model to dict + body_dict = body.model_dump() + env_base = _get_env_moodle_url() - base_url = env_base or kwargs.get("moodle_url") + base_url = env_base or body_dict.get("moodle_url") if not base_url: - raise HTTPException(status_code=400, detail="Moodle URL not provided. Set MOODLE_URL env var or pass moodle_url as query param.") + raise HTTPException(status_code=400, detail="Moodle URL not provided. Set MOODLE_URL env var or pass moodle_url in request body.") base_url = _normalize_base_url(base_url) ep_path = endpoint_path if endpoint_path.startswith("/") else f"/{endpoint_path}" @@ -112,7 +132,11 @@ async def handler(request: Request, response: Response, **kwargs): pname = param["name"] ptype = param.get("type", "str") send_if_empty = param.get("send_if_empty", False) - value = kwargs.get(pname, None) + default_value = param.get("default", None) + + # Get value from body, or use default if not provided + value = body_dict.get(pname, default_value) + if value is not None and value != "": encode_param(params, pname, value, ptype) elif send_if_empty: @@ -131,21 +155,12 @@ async def handler(request: Request, response: Response, **kwargs): from urllib.parse import urlencode as _urlencode direct_url = f"{url}?{_urlencode(params, doseq=True)}" if params else url response.headers["X-Moodle-Direct-URL"] = direct_url - response.headers["X-Moodle-Direct-Method"] = method + response.headers["X-Moodle-Direct-Method"] = "POST" try: async with httpx.AsyncClient(follow_redirects=True, headers=DEFAULT_HEADERS) as client: - if method == "GET": - resp = await client.get(url, params=params) - elif method == "POST": - resp = await client.post(url, data=params) - else: - resp = await client.request( - method, - url, - params=params if method in {"DELETE", "HEAD"} else None, - data=None if method in {"DELETE", "HEAD"} else params, - ) + + resp = await client.post(url, data=params) resp.raise_for_status() try: @@ -161,7 +176,6 @@ async def handler(request: Request, response: Response, **kwargs): except httpx.RequestError as e: raise HTTPException(status_code=502, detail=f"Error contacting Moodle at {url}: {str(e)}") - require_moodle_url = not _get_env_moodle_url() - handler.__signature__ = _build_handler_signature(query_params, require_moodle_url) # type: ignore[attr-defined] + handler.__signature__ = _build_handler_signature(request_model) # type: ignore[attr-defined] return handler From 2cbc838c80c9fbb4c47aaa421b20950a62d6888d Mon Sep 17 00:00:00 2001 From: MyDrift Date: Sat, 4 Oct 2025 20:51:23 +0200 Subject: [PATCH 02/10] Add session-based authentication and secure file proxy - Introduced in-memory session management with secure cookie handling, added authentication dependencies, and implemented secure login/logout endpoints under /api/secure. - Added a /files proxy route for authenticated file access from Moodle, replacing insecure token-in-URL patterns. - Updated CORS handling for dynamic origin reflection - Refactored compose.yaml to use .env - Updated requirements for httpx[http2]. --- .env.example | 19 +++++ .env.template | 20 ------ compose.yaml | 6 +- requirements.txt | 2 +- src/app.py | 47 ++++++++++--- src/dependencies/auth.py | 58 ++++++++++++++++ src/mw_utils/handlers.py | 24 +++++-- src/mw_utils/session.py | 100 +++++++++++++++++++++++++++ src/routes/files.py | 140 +++++++++++++++++++++++++++++++++++++ src/routes/secure_auth.py | 141 ++++++++++++++++++++++++++++++++++++++ 10 files changed, 517 insertions(+), 40 deletions(-) create mode 100644 .env.example delete mode 100644 .env.template create mode 100644 src/dependencies/auth.py create mode 100644 src/mw_utils/session.py create mode 100644 src/routes/files.py create mode 100644 src/routes/secure_auth.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..de90ecd --- /dev/null +++ b/.env.example @@ -0,0 +1,19 @@ +PORT=8000 +ENVIRONMENT=development # "development" or "production" +LOG_LEVEL=info + +# Set the base URL of your Moodle instance here, if none is set api will require it in the request +MOODLE_URL=https://moodle.school.edu + +# Allow Requests from these origins (CORS) +ALLOW_ORIGINS=* + +# This key is used to sign cookies and other sensitive data +# Generate with one of the following methods: +# Python: python -c "import secrets; print(secrets.token_urlsafe(32))" +# OpenSSL: openssl rand -hex 32 +SECRET_KEY=CHANGE_ME_IN_PRODUCTION + +# Session max age in seconds (default: 14400 = 4 hours) +SESSION_MAX_AGE=14400 + diff --git a/.env.template b/.env.template deleted file mode 100644 index ac0eb55..0000000 --- a/.env.template +++ /dev/null @@ -1,20 +0,0 @@ -################################################ -# MoodlewareAPI environment template -# Copy to .env and adjust values -################################################ - -# Base Moodle instance URL -# Example: https://moodle.school.edu -MOODLE_URL= - -# Port exposed by the API (compose uses this) -PORT=8000 - -# CORS: comma-separated list of allowed origins -# If empty or "*", all origins are allowed (no credentials) -ALLOW_ORIGINS=* - -# Log level for application -# Valid: critical,error,warning,info,debug -# Default when unset: info -LOG_LEVEL=info \ No newline at end of file diff --git a/compose.yaml b/compose.yaml index d1de98f..af6026b 100644 --- a/compose.yaml +++ b/compose.yaml @@ -6,8 +6,6 @@ services: build: . ports: - ${PORT-8000}:8000 - environment: - - MOODLE_URL=${MOODLE_URL} - - ALLOW_ORIGINS=${ALLOW_ORIGINS} - - LOG_LEVEL=${LOG_LEVEL} restart: unless-stopped + env_file: + - .env diff --git a/requirements.txt b/requirements.txt index 38a3c08..d1521ca 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ fastapi -httpx +httpx[http2] pydantic uvicorn colorlog diff --git a/src/app.py b/src/app.py index 08ea8fa..5a6602d 100644 --- a/src/app.py +++ b/src/app.py @@ -7,6 +7,8 @@ from fastapi.middleware.cors import CORSMiddleware from fastapi.security import HTTPBearer from .mw_utils import get_env_variable, load_config, create_handler +from .routes.secure_auth import router as secure_auth_router +from .routes.files import router as files_router load_dotenv() @@ -26,20 +28,37 @@ # CORS configuration from env _allow_origins_env = (get_env_variable("ALLOW_ORIGINS") or "").strip() + +# Custom CORS handling: If "*", dynamically return requesting origin to allow credentials if _allow_origins_env == "" or _allow_origins_env == "*": - _allow_origins = ["*"] - _allow_credentials = False # '*' cannot be used with credentials per CORS spec + # Allow all origins by reflecting the request origin + @app.middleware("http") + async def dynamic_cors_middleware(request: Request, call_next): + response = await call_next(request) + origin = request.headers.get("origin") + + if origin: + response.headers["Access-Control-Allow-Origin"] = origin + response.headers["Access-Control-Allow-Credentials"] = "true" + response.headers["Access-Control-Allow-Methods"] = "*" + response.headers["Access-Control-Allow-Headers"] = "*" + + return response + + logger.info("CORS: Allowing all origins with credentials (dynamic reflection)") else: + # Specific origins configured _allow_origins = [o.strip() for o in _allow_origins_env.split(",") if o.strip()] - _allow_credentials = True - -app.add_middleware( - CORSMiddleware, - allow_origins=_allow_origins, - allow_credentials=_allow_credentials, - allow_methods=["*"], - allow_headers=["*"], -) + + app.add_middleware( + CORSMiddleware, + allow_origins=_allow_origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + + logger.info(f"CORS: Allowing specific origins: {_allow_origins}") # Request ID middleware @app.middleware("http") @@ -76,6 +95,12 @@ async def add_request_id(request: Request, call_next: Callable): dependencies=deps, ) +# Register secure authentication routes +app.include_router(secure_auth_router) + +# Register file proxy routes +app.include_router(files_router) + # Health check @app.post("/healthz", tags=["meta"]) async def healthz(): diff --git a/src/dependencies/auth.py b/src/dependencies/auth.py new file mode 100644 index 0000000..a6ac14a --- /dev/null +++ b/src/dependencies/auth.py @@ -0,0 +1,58 @@ +""" +Session authentication dependency for FastAPI routes. + +Use this to protect endpoints that require authentication. +""" + +from typing import Optional +from fastapi import Cookie, HTTPException, Depends +from ..mw_utils.session import get_session, SESSION_COOKIE_NAME, SessionData + + +async def get_current_session( + session_cookie: Optional[str] = Cookie(None, alias=SESSION_COOKIE_NAME) +) -> SessionData: + """ + Dependency that validates session and returns session data. + Raises 401 if session is invalid or missing. + + Usage: + @app.get("/protected") + async def protected_route(session: SessionData = Depends(get_current_session)): + # session.moodle_token is available here + return {"token": session.moodle_token} + """ + if not session_cookie: + raise HTTPException( + status_code=401, + detail="Not authenticated" + ) + + session = get_session(session_cookie) + if not session: + raise HTTPException( + status_code=401, + detail="Invalid or expired session" + ) + + return session + + +async def get_optional_session( + session_cookie: Optional[str] = Cookie(None, alias=SESSION_COOKIE_NAME) +) -> Optional[SessionData]: + """ + Dependency that returns session if valid, None otherwise. + Does not raise exception - useful for optional authentication. + + Usage: + @app.get("/public") + async def public_route(session: Optional[SessionData] = Depends(get_optional_session)): + if session: + # User is authenticated + pass + """ + if not session_cookie: + return None + + return get_session(session_cookie) diff --git a/src/mw_utils/handlers.py b/src/mw_utils/handlers.py index 0f37f86..feaa4d6 100644 --- a/src/mw_utils/handlers.py +++ b/src/mw_utils/handlers.py @@ -1,12 +1,13 @@ import logging from typing import Any, Dict, List, Optional -from fastapi import Query, Body, HTTPException, Response, Request +from fastapi import Query, Body, HTTPException, Response, Request, Cookie from pydantic import BaseModel, Field, create_model import httpx from .env import get_env_variable from .params import encode_param from .auth import resolve_token_from_request from .http_client import DEFAULT_HEADERS +from .session import get_session, SESSION_COOKIE_NAME LOGGER = logging.getLogger("moodleware.handlers") @@ -118,8 +119,19 @@ async def handler(request: Request, response: Response, body: BaseModel): # Convert Pydantic model to dict body_dict = body.model_dump() - env_base = _get_env_moodle_url() - base_url = env_base or body_dict.get("moodle_url") + # Try to get Moodle URL and token from session first + session_cookie = request.cookies.get(SESSION_COOKIE_NAME) + session_data = None + if session_cookie: + session_data = get_session(session_cookie) + + # Determine base URL: session > env > request body + if session_data: + base_url = session_data.moodle_url + else: + env_base = _get_env_moodle_url() + base_url = env_base or body_dict.get("moodle_url") + if not base_url: raise HTTPException(status_code=400, detail="Moodle URL not provided. Set MOODLE_URL env var or pass moodle_url in request body.") @@ -144,7 +156,11 @@ async def handler(request: Request, response: Response, body: BaseModel): encode_param(params, pname, "", ptype) if not _is_auth_endpoint(ep_path): - token = await resolve_token_from_request(request) + # Try session token first, fall back to header/query + if session_data: + token = session_data.moodle_token + else: + token = await resolve_token_from_request(request) if token: params["wstoken"] = token diff --git a/src/mw_utils/session.py b/src/mw_utils/session.py new file mode 100644 index 0000000..c6f89f1 --- /dev/null +++ b/src/mw_utils/session.py @@ -0,0 +1,100 @@ +import os +import secrets +import time +import logging +from typing import Optional, Dict, Any +from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired +from .env import get_env_variable + +logger = logging.getLogger("moodleware.sessions") + +SESSION_COOKIE_NAME = "mng_session" +SESSION_MAX_AGE = int(get_env_variable("SESSION_MAX_AGE") or "14400") +SECRET_KEY = get_env_variable("SECRET_KEY") or secrets.token_urlsafe(32) + +if not get_env_variable("SECRET_KEY"): + logger.warning("SECRET_KEY not set! Using random key. Sessions will be invalidated on restart.") + +serializer = URLSafeTimedSerializer(SECRET_KEY) +_sessions: Dict[str, Dict[str, Any]] = {} + + +class SessionData: + def __init__(self, session_id: str, moodle_token: str, moodle_url: str, created_at: float): + self.session_id = session_id + self.moodle_token = moodle_token + self.moodle_url = moodle_url + self.created_at = created_at + self.last_accessed = created_at + + +def create_session(moodle_token: str, moodle_url: str) -> str: + session_id = secrets.token_urlsafe(32) + _sessions[session_id] = { + "moodle_token": moodle_token, + "moodle_url": moodle_url, + "created_at": time.time(), + "last_accessed": time.time(), + } + logger.info(f"Created session {session_id[:8]}... for {moodle_url}") + return serializer.dumps(session_id) + + +def get_session(signed_session_id: str) -> Optional[SessionData]: + try: + session_id = serializer.loads(signed_session_id, max_age=SESSION_MAX_AGE) + session_data = _sessions.get(session_id) + if not session_data: + logger.warning(f"Session {session_id[:8]}... not found") + return None + + session_data["last_accessed"] = time.time() + return SessionData( + session_id=session_id, + moodle_token=session_data["moodle_token"], + moodle_url=session_data["moodle_url"], + created_at=session_data["created_at"] + ) + except SignatureExpired: + logger.info("Session expired") + return None + except BadSignature: + logger.warning("Invalid session signature") + return None + except Exception as e: + logger.error(f"Error retrieving session: {e}") + return None + + +def delete_session(signed_session_id: str) -> bool: + try: + session_id = serializer.loads(signed_session_id, max_age=SESSION_MAX_AGE) + + if session_id in _sessions: + del _sessions[session_id] + logger.info(f"Deleted session {session_id[:8]}...") + return True + + return False + + except Exception as e: + logger.error(f"Error deleting session: {e}") + return False + + +def cleanup_expired_sessions() -> int: + now = time.time() + expired = [sid for sid, data in _sessions.items() if now - data["created_at"] > SESSION_MAX_AGE] + for session_id in expired: + del _sessions[session_id] + if expired: + logger.info(f"Cleaned up {len(expired)} expired sessions") + return len(expired) + + +def get_session_stats() -> Dict[str, Any]: + return { + "active_sessions": len(_sessions), + "session_max_age": SESSION_MAX_AGE, + "storage_type": "in-memory", + } diff --git a/src/routes/files.py b/src/routes/files.py new file mode 100644 index 0000000..fe6f0d5 --- /dev/null +++ b/src/routes/files.py @@ -0,0 +1,140 @@ +""" +File Proxy Endpoint + +Securely proxies file requests to Moodle with session authentication. +Replaces the insecure practice of appending tokens to URLs. +""" + +from fastapi import APIRouter, Depends, HTTPException, Response +from fastapi.responses import StreamingResponse +import httpx +from typing import AsyncIterator, Dict, Optional +import mimetypes + +from ..dependencies.auth import get_current_session +from ..mw_utils.session import SessionData + +router = APIRouter(prefix="/files", tags=["files"]) + +# Persistent HTTP client for connection pooling and HTTP/2 +_http_client: Optional[httpx.AsyncClient] = None + + +async def get_http_client() -> httpx.AsyncClient: + """Get or create persistent HTTP client with connection pooling""" + global _http_client + if _http_client is None or _http_client.is_closed: + _http_client = httpx.AsyncClient( + timeout=60.0, + follow_redirects=True, + http2=True, # Enable HTTP/2 for multiplexing + limits=httpx.Limits( + max_connections=100, + max_keepalive_connections=20, + keepalive_expiry=30.0, + ) + ) + return _http_client + + +def guess_content_type(path: str, moodle_content_type: Optional[str] = None) -> str: + """Guess content type from file extension or Moodle header""" + if moodle_content_type and moodle_content_type != "application/octet-stream": + return moodle_content_type + + # Guess from file extension + content_type, _ = mimetypes.guess_type(path) + return content_type or "application/octet-stream" + + +async def fetch_file(url: str, moodle_token: str, client: httpx.AsyncClient): + """Fetch file from Moodle and return response data""" + response = await client.get(url, params={"token": moodle_token}) + response.raise_for_status() + + # Extract useful headers from Moodle response + headers = { + "Content-Type": response.headers.get("Content-Type", "application/octet-stream"), + "Content-Length": str(len(response.content)), + "Last-Modified": response.headers.get("Last-Modified", ""), + "ETag": response.headers.get("ETag", ""), + } + + return response.content, headers + + +@router.get("/{path:path}") +async def proxy_file( + path: str, + session: SessionData = Depends(get_current_session) +): + """ + Proxy file requests to Moodle with session authentication + + Accepts file paths like: + - /files/webservice/pluginfile.php/123/mod_resource/content/1/document.pdf + - /files/pluginfile.php/123/mod_resource/content/1/image.jpg + + The backend appends the Moodle token securely from the session. + """ + # Ensure path starts with pluginfile.php (with or without webservice/) + if not path.endswith(".php") and "/pluginfile.php" not in path: + # Add pluginfile.php if not present + if not path.startswith("pluginfile.php") and not path.startswith("webservice/pluginfile.php"): + if "/webservice/" not in path: + path = f"webservice/pluginfile.php/{path}" + + # Construct full Moodle file URL + moodle_url = session.moodle_url.rstrip('/') + if not path.startswith('/'): + path = f'/{path}' + + # Ensure we use webservice/pluginfile.php for token-based access + if '/pluginfile.php' in path and '/webservice/pluginfile.php' not in path: + path = path.replace('/pluginfile.php', '/webservice/pluginfile.php') + + file_url = f"{moodle_url}{path}" + + try: + # Get persistent HTTP client for connection pooling + client = await get_http_client() + + # Fetch the file from Moodle with authentication + file_content, moodle_headers = await fetch_file( + file_url, + session.moodle_token, + client + ) + + # Guess proper content type + content_type = guess_content_type(path, moodle_headers.get("Content-Type")) + + # Build response headers + response_headers = { + "Cache-Control": "public, max-age=3600", # Cache for 1 hour + "X-Content-Type-Options": "nosniff", + } + + # Add Moodle headers if present + if moodle_headers.get("Content-Length"): + response_headers["Content-Length"] = moodle_headers["Content-Length"] + if moodle_headers.get("Last-Modified"): + response_headers["Last-Modified"] = moodle_headers["Last-Modified"] + if moodle_headers.get("ETag"): + response_headers["ETag"] = moodle_headers["ETag"] + + return Response( + content=file_content, + media_type=content_type, + headers=response_headers + ) + except httpx.HTTPStatusError as e: + raise HTTPException( + status_code=e.response.status_code, + detail=f"Failed to fetch file from Moodle: {e.response.status_code}" + ) + except httpx.RequestError as e: + raise HTTPException( + status_code=502, + detail=f"Failed to connect to Moodle: {str(e)}" + ) diff --git a/src/routes/secure_auth.py b/src/routes/secure_auth.py new file mode 100644 index 0000000..4ee213a --- /dev/null +++ b/src/routes/secure_auth.py @@ -0,0 +1,141 @@ +import logging +from typing import Optional +from fastapi import APIRouter, Response, Request, HTTPException, Cookie +from pydantic import BaseModel, Field +import httpx +from ..mw_utils.session import ( + create_session, + get_session, + delete_session, + SESSION_COOKIE_NAME, + SESSION_MAX_AGE, +) +from ..mw_utils.env import get_env_variable +from ..mw_utils.http_client import DEFAULT_HEADERS + +logger = logging.getLogger("moodleware.secure_auth") +router = APIRouter(prefix="/api/secure", tags=["Secure Authentication"]) + + +class LoginRequest(BaseModel): + username: str + password: str + service: str = "moodle_mobile_app" + moodle_url: Optional[str] = None + + +class LoginResponse(BaseModel): + success: bool + message: str + user_id: Optional[int] = None + username: Optional[str] = None + + +def _normalize_moodle_url(url: str) -> str: + if not url.lower().startswith(("http://", "https://")): + return f"https://{url}" + return url.rstrip("/") + + + + + +@router.post("/login", response_model=LoginResponse) +async def secure_login(login_data: LoginRequest, response: Response): + moodle_url = _normalize_moodle_url( + login_data.moodle_url or get_env_variable("MOODLE_URL") or "https://moodle.example.com" + ) + token_url = f"{moodle_url}/login/token.php" + + try: + async with httpx.AsyncClient(headers=DEFAULT_HEADERS) as client: + token_response = await client.post( + token_url, + data={ + "username": login_data.username, + "password": login_data.password, + "service": login_data.service, + } + ) + + token_response.raise_for_status() + token_data = token_response.json() + + if "error" in token_data or "errorcode" in token_data: + error_msg = token_data.get("error", "Authentication failed") + logger.warning(f"Moodle auth failed: {error_msg}") + return LoginResponse(success=False, message=error_msg) + + moodle_token = token_data.get("token") + if not moodle_token: + return LoginResponse(success=False, message="No token received from Moodle") + + session_id = create_session(moodle_token=moodle_token, moodle_url=moodle_url) + + is_production = get_env_variable("ENVIRONMENT") == "production" + response.set_cookie( + key=SESSION_COOKIE_NAME, + value=session_id, + max_age=SESSION_MAX_AGE, + httponly=True, + secure=is_production, + samesite="lax", + path="/", + ) + + logger.info(f"Successful login for {login_data.username}") + + return LoginResponse( + success=True, + message="Login successful", + user_id=token_data.get("userid"), + username=login_data.username + ) + + except httpx.HTTPStatusError as e: + logger.error(f"Moodle HTTP error: {e}") + raise HTTPException( + status_code=e.response.status_code, + detail="Error communicating with Moodle" + ) + except Exception as e: + logger.error(f"Login error: {e}") + raise HTTPException( + status_code=500, + detail="Internal server error during login" + ) + + +@router.post("/logout") +async def secure_logout(response: Response, session_cookie: Optional[str] = Cookie(None, alias=SESSION_COOKIE_NAME)): + if session_cookie: + delete_session(session_cookie) + + is_production = get_env_variable("ENVIRONMENT") == "production" + response.delete_cookie( + key=SESSION_COOKIE_NAME, + path="/", + httponly=True, + secure=is_production, + samesite="lax" + ) + + logger.info("User logged out") + + return {"success": True, "message": "Logged out successfully"} + + +@router.get("/check") +async def check_session(session_cookie: Optional[str] = Cookie(None, alias=SESSION_COOKIE_NAME)): + if not session_cookie: + return {"authenticated": False} + + session = get_session(session_cookie) + if not session: + return {"authenticated": False} + + return { + "authenticated": True, + "moodle_url": session.moodle_url, + "session_age": session.last_accessed - session.created_at + } From 82551d9ea627a4c78c617fc8e5b13c9404eedb9f Mon Sep 17 00:00:00 2001 From: MyDrift Date: Sat, 4 Oct 2025 21:29:56 +0200 Subject: [PATCH 03/10] Add async session cleanup task to FastAPI lifespan - Introduces an async lifespan context manager that periodically cleans up expired sessions using a background task. - The session cleanup runs every SESSION_MAX_AGE seconds and logs the number of sessions removed or any errors encountered. - The cleanup task is properly cancelled on application shutdown. --- src/app.py | 31 +++++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/src/app.py b/src/app.py index 5a6602d..ee86d13 100644 --- a/src/app.py +++ b/src/app.py @@ -1,29 +1,56 @@ import os import logging import uuid +import asyncio from typing import Callable +from contextlib import asynccontextmanager from fastapi import FastAPI, Request, Security, Response from dotenv import load_dotenv from fastapi.middleware.cors import CORSMiddleware from fastapi.security import HTTPBearer from .mw_utils import get_env_variable, load_config, create_handler +from .mw_utils.session import cleanup_expired_sessions, SESSION_MAX_AGE from .routes.secure_auth import router as secure_auth_router from .routes.files import router as files_router load_dotenv() -# Configure logging level (default to INFO) _log_level_name = (get_env_variable("LOG_LEVEL") or "info").upper() _log_level = getattr(logging, _log_level_name, logging.INFO) logging.basicConfig(level=_log_level) logger = logging.getLogger("moodleware") +@asynccontextmanager +async def lifespan(app: FastAPI): + async def session_cleanup_loop(): + while True: + await asyncio.sleep(SESSION_MAX_AGE) + try: + cleaned = cleanup_expired_sessions() + if cleaned > 0: + logger.info(f"Session cleanup: removed {cleaned} expired sessions") + except Exception as e: + logger.error(f"Session cleanup error: {e}") + + cleanup_task = asyncio.create_task(session_cleanup_loop()) + logger.info(f"Started session cleanup task (runs every {SESSION_MAX_AGE}s)") + + yield + + cleanup_task.cancel() + try: + await cleanup_task + except asyncio.CancelledError: + pass + logger.info("Session cleanup task stopped") + app = FastAPI( title="MoodlewareAPI", description="A FastAPI application to wrap Moodle API functions into individual endpoints.", version="0.1.0", docs_url="/", - redoc_url=None + redoc_url=None, + lifespan=lifespan ) # CORS configuration from env From 9f4a7e81434897be570632be4878e176caa7d16c Mon Sep 17 00:00:00 2001 From: MyDrift Date: Sat, 4 Oct 2025 22:36:25 +0200 Subject: [PATCH 04/10] Migrate session storage to Redis and update config - Session management has been refactored to use Redis for storage instead of in-memory dictionaries. This includes async session functions, Redis initialization in app lifespan, and automatic session expiration via Redis. - The .env.example and compose.yaml files were updated to add Redis configuration, and requirements.txt now includes the redis package. - All session-related code and dependencies have been updated to support async Redis operations. --- .env.example | 7 ++- compose.yaml | 27 ++++++++++- requirements.txt | 3 +- src/app.py | 44 ++++++++++-------- src/dependencies/auth.py | 4 +- src/mw_utils/handlers.py | 2 +- src/mw_utils/session.py | 96 ++++++++++++++++++++++++++++++--------- src/routes/secure_auth.py | 8 ++-- 8 files changed, 141 insertions(+), 50 deletions(-) diff --git a/.env.example b/.env.example index de90ecd..f4b1cf9 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,5 @@ -PORT=8000 +API_PORT=8000 +REDIS_PORT=6379 ENVIRONMENT=development # "development" or "production" LOG_LEVEL=info @@ -17,3 +18,7 @@ SECRET_KEY=CHANGE_ME_IN_PRODUCTION # Session max age in seconds (default: 14400 = 4 hours) SESSION_MAX_AGE=14400 +# Redis connection URL for session storage +# When running with docker-compose the internal hostname `redis` is available, else modify as needed +REDIS_URL=redis://redis:6379/0 + diff --git a/compose.yaml b/compose.yaml index af6026b..16b2870 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,11 +1,36 @@ # This compose file is meant for development purposes. services: + redis: + image: redis:alpine + container_name: MoodleNG-Redis + ports: + - "${REDIS_PORT:-6379}:6379" + volumes: + - redis-data:/data + command: redis-server --appendonly yes + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 3s + retries: 3 + restart: unless-stopped + moodle-api: container_name: MoodlewareAPI build: . ports: - - ${PORT-8000}:8000 + - ${API_PORT:-8000}:8000 restart: unless-stopped env_file: - .env + environment: + - REDIS_URL=${REDIS_URL:-redis://redis:6379/0} + depends_on: + redis: + condition: service_healthy + + +volumes: + redis-data: + driver: local \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index d1521ca..a4b4cfd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,4 +4,5 @@ pydantic uvicorn colorlog python-dotenv -itsdangerous \ No newline at end of file +itsdangerous +redis>=5.0.0 \ No newline at end of file diff --git a/src/app.py b/src/app.py index ee86d13..931e93a 100644 --- a/src/app.py +++ b/src/app.py @@ -8,8 +8,9 @@ from dotenv import load_dotenv from fastapi.middleware.cors import CORSMiddleware from fastapi.security import HTTPBearer +from redis.asyncio import Redis from .mw_utils import get_env_variable, load_config, create_handler -from .mw_utils.session import cleanup_expired_sessions, SESSION_MAX_AGE +from .mw_utils.session import cleanup_expired_sessions, SESSION_MAX_AGE, init_redis, REDIS_URL from .routes.secure_auth import router as secure_auth_router from .routes.files import router as files_router @@ -22,27 +23,34 @@ @asynccontextmanager async def lifespan(app: FastAPI): - async def session_cleanup_loop(): - while True: - await asyncio.sleep(SESSION_MAX_AGE) - try: - cleaned = cleanup_expired_sessions() - if cleaned > 0: - logger.info(f"Session cleanup: removed {cleaned} expired sessions") - except Exception as e: - logger.error(f"Session cleanup error: {e}") + # Initialize Redis connection + redis_client = Redis.from_url( + REDIS_URL, + encoding="utf-8", + decode_responses=True, + socket_connect_timeout=5, + socket_keepalive=True, + ) + + try: + # Test Redis connection + await redis_client.ping() + logger.info(f"Redis connected successfully: {REDIS_URL}") + init_redis(redis_client) + except Exception as e: + logger.error(f"Failed to connect to Redis: {e}") + await redis_client.aclose() + raise - cleanup_task = asyncio.create_task(session_cleanup_loop()) - logger.info(f"Started session cleanup task (runs every {SESSION_MAX_AGE}s)") + # Redis handles session expiration automatically via SETEX + # No cleanup task needed anymore + logger.info(f"Session storage initialized (Redis with automatic expiration)") yield - cleanup_task.cancel() - try: - await cleanup_task - except asyncio.CancelledError: - pass - logger.info("Session cleanup task stopped") + # Close Redis connection + await redis_client.aclose() + logger.info("Redis connection closed") app = FastAPI( title="MoodlewareAPI", diff --git a/src/dependencies/auth.py b/src/dependencies/auth.py index a6ac14a..05e1ce4 100644 --- a/src/dependencies/auth.py +++ b/src/dependencies/auth.py @@ -28,7 +28,7 @@ async def protected_route(session: SessionData = Depends(get_current_session)): detail="Not authenticated" ) - session = get_session(session_cookie) + session = await get_session(session_cookie) if not session: raise HTTPException( status_code=401, @@ -55,4 +55,4 @@ async def public_route(session: Optional[SessionData] = Depends(get_optional_ses if not session_cookie: return None - return get_session(session_cookie) + return await get_session(session_cookie) diff --git a/src/mw_utils/handlers.py b/src/mw_utils/handlers.py index feaa4d6..4b90862 100644 --- a/src/mw_utils/handlers.py +++ b/src/mw_utils/handlers.py @@ -123,7 +123,7 @@ async def handler(request: Request, response: Response, body: BaseModel): session_cookie = request.cookies.get(SESSION_COOKIE_NAME) session_data = None if session_cookie: - session_data = get_session(session_cookie) + session_data = await get_session(session_cookie) # Determine base URL: session > env > request body if session_data: diff --git a/src/mw_utils/session.py b/src/mw_utils/session.py index c6f89f1..51ab798 100644 --- a/src/mw_utils/session.py +++ b/src/mw_utils/session.py @@ -2,8 +2,10 @@ import secrets import time import logging +import json from typing import Optional, Dict, Any from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired +from redis.asyncio import Redis, ConnectionPool from .env import get_env_variable logger = logging.getLogger("moodleware.sessions") @@ -11,12 +13,29 @@ SESSION_COOKIE_NAME = "mng_session" SESSION_MAX_AGE = int(get_env_variable("SESSION_MAX_AGE") or "14400") SECRET_KEY = get_env_variable("SECRET_KEY") or secrets.token_urlsafe(32) +REDIS_URL = get_env_variable("REDIS_URL") or "redis://localhost:6379/0" if not get_env_variable("SECRET_KEY"): logger.warning("SECRET_KEY not set! Using random key. Sessions will be invalidated on restart.") serializer = URLSafeTimedSerializer(SECRET_KEY) -_sessions: Dict[str, Dict[str, Any]] = {} + +# Redis client (initialized in app.py lifespan) +_redis_client: Optional[Redis] = None + + +def init_redis(redis_client: Redis): + """Initialize Redis client for session storage.""" + global _redis_client + _redis_client = redis_client + logger.info(f"Redis session storage initialized: {REDIS_URL}") + + +def get_redis() -> Redis: + """Get Redis client instance.""" + if _redis_client is None: + raise RuntimeError("Redis client not initialized. Call init_redis() first.") + return _redis_client class SessionData: @@ -28,27 +47,49 @@ def __init__(self, session_id: str, moodle_token: str, moodle_url: str, created_ self.last_accessed = created_at -def create_session(moodle_token: str, moodle_url: str) -> str: +async def create_session(moodle_token: str, moodle_url: str) -> str: + redis = get_redis() session_id = secrets.token_urlsafe(32) - _sessions[session_id] = { + + session_data = { "moodle_token": moodle_token, "moodle_url": moodle_url, "created_at": time.time(), "last_accessed": time.time(), } + + # Store in Redis with automatic expiration + await redis.setex( + f"session:{session_id}", + SESSION_MAX_AGE, + json.dumps(session_data) + ) + logger.info(f"Created session {session_id[:8]}... for {moodle_url}") return serializer.dumps(session_id) -def get_session(signed_session_id: str) -> Optional[SessionData]: +async def get_session(signed_session_id: str) -> Optional[SessionData]: try: + redis = get_redis() session_id = serializer.loads(signed_session_id, max_age=SESSION_MAX_AGE) - session_data = _sessions.get(session_id) - if not session_data: - logger.warning(f"Session {session_id[:8]}... not found") + + # Retrieve from Redis + data = await redis.get(f"session:{session_id}") + if not data: + logger.warning(f"Session {session_id[:8]}... not found in Redis") return None + session_data = json.loads(data) + + # Update last_accessed timestamp session_data["last_accessed"] = time.time() + await redis.setex( + f"session:{session_id}", + SESSION_MAX_AGE, + json.dumps(session_data) + ) + return SessionData( session_id=session_id, moodle_token=session_data["moodle_token"], @@ -56,7 +97,7 @@ def get_session(signed_session_id: str) -> Optional[SessionData]: created_at=session_data["created_at"] ) except SignatureExpired: - logger.info("Session expired") + logger.info("Session signature expired") return None except BadSignature: logger.warning("Invalid session signature") @@ -66,15 +107,19 @@ def get_session(signed_session_id: str) -> Optional[SessionData]: return None -def delete_session(signed_session_id: str) -> bool: +async def delete_session(signed_session_id: str) -> bool: try: + redis = get_redis() session_id = serializer.loads(signed_session_id, max_age=SESSION_MAX_AGE) - if session_id in _sessions: - del _sessions[session_id] + # Delete from Redis + deleted = await redis.delete(f"session:{session_id}") + + if deleted > 0: logger.info(f"Deleted session {session_id[:8]}...") return True + logger.warning(f"Session {session_id[:8]}... not found for deletion") return False except Exception as e: @@ -82,19 +127,26 @@ def delete_session(signed_session_id: str) -> bool: return False -def cleanup_expired_sessions() -> int: - now = time.time() - expired = [sid for sid, data in _sessions.items() if now - data["created_at"] > SESSION_MAX_AGE] - for session_id in expired: - del _sessions[session_id] - if expired: - logger.info(f"Cleaned up {len(expired)} expired sessions") - return len(expired) +async def cleanup_expired_sessions() -> int: + """ + Redis automatically expires sessions via SETEX. + This function is kept for compatibility but does nothing as cleanup is automatic. + Returns 0 since Redis handles expiration internally. + """ + return 0 -def get_session_stats() -> Dict[str, Any]: +async def get_session_stats() -> Dict[str, Any]: + redis = get_redis() + + # Count sessions by scanning for session:* keys + session_keys = [] + async for key in redis.scan_iter(match="session:*"): + session_keys.append(key) + return { - "active_sessions": len(_sessions), + "active_sessions": len(session_keys), "session_max_age": SESSION_MAX_AGE, - "storage_type": "in-memory", + "storage_type": "redis", + "redis_url": REDIS_URL, } diff --git a/src/routes/secure_auth.py b/src/routes/secure_auth.py index 4ee213a..2c7b386 100644 --- a/src/routes/secure_auth.py +++ b/src/routes/secure_auth.py @@ -70,7 +70,7 @@ async def secure_login(login_data: LoginRequest, response: Response): if not moodle_token: return LoginResponse(success=False, message="No token received from Moodle") - session_id = create_session(moodle_token=moodle_token, moodle_url=moodle_url) + session_id = await create_session(moodle_token=moodle_token, moodle_url=moodle_url) is_production = get_env_variable("ENVIRONMENT") == "production" response.set_cookie( @@ -109,7 +109,7 @@ async def secure_login(login_data: LoginRequest, response: Response): @router.post("/logout") async def secure_logout(response: Response, session_cookie: Optional[str] = Cookie(None, alias=SESSION_COOKIE_NAME)): if session_cookie: - delete_session(session_cookie) + await delete_session(session_cookie) is_production = get_env_variable("ENVIRONMENT") == "production" response.delete_cookie( @@ -125,12 +125,12 @@ async def secure_logout(response: Response, session_cookie: Optional[str] = Cook return {"success": True, "message": "Logged out successfully"} -@router.get("/check") +@router.post("/check") async def check_session(session_cookie: Optional[str] = Cookie(None, alias=SESSION_COOKIE_NAME)): if not session_cookie: return {"authenticated": False} - session = get_session(session_cookie) + session = await get_session(session_cookie) if not session: return {"authenticated": False} From 1a47c968482092e81ecd3f562e9d33a52c11f40c Mon Sep 17 00:00:00 2001 From: MyDrift Date: Sun, 5 Oct 2025 00:58:52 +0200 Subject: [PATCH 05/10] Refactor Dockerfile to use multi-stage build Switches to a multi-stage build using python:3.13-alpine for both build and runtime stages to reduce image size. - Installs build dependencies only in the builder stage and copies only the necessary Python packages and application code to the final image. --- Dockerfile | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/Dockerfile b/Dockerfile index 61235f0..de52f46 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,14 +1,28 @@ -FROM python:3.13.5-slim +# Stage 1: Builder - Install dependencies +FROM python:3.13-alpine AS builder -WORKDIR /app - -ENV PYTHONUNBUFFERED=1 +# Install build dependencies needed to compile Python packages +RUN apk add --no-cache gcc musl-dev +# Copy requirements and install dependencies COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt +RUN pip install --no-cache-dir --user -r requirements.txt +# Stage 2: Runtime - Copy only what's needed +FROM python:3.13-alpine + +WORKDIR /app + +# Copy Python packages from builder +COPY --from=builder /root/.local /root/.local + +# Copy application code COPY . . +# Make sure scripts in .local are usable +ENV PATH=/root/.local/bin:$PATH +ENV PYTHONUNBUFFERED=1 + EXPOSE 8000 CMD ["python", "asgi.py"] From 5f6bb4c031bd8c36edb37405b2d246b402eb6711 Mon Sep 17 00:00:00 2001 From: MyDrift Date: Tue, 7 Oct 2025 22:25:10 +0200 Subject: [PATCH 06/10] Update CORS handling and secure auth route prefix - Refactored CORS configuration to use allow_origin_regex for wildcard origins with credentials, exposing 'X-Request-Id' header and setting max_age. - Changed secure authentication route prefix from '/api/secure' to '/secure' for consistency. --- src/app.py | 30 ++++++++++++++---------------- src/routes/secure_auth.py | 2 +- 2 files changed, 15 insertions(+), 17 deletions(-) diff --git a/src/app.py b/src/app.py index 931e93a..c94eb1d 100644 --- a/src/app.py +++ b/src/app.py @@ -64,23 +64,19 @@ async def lifespan(app: FastAPI): # CORS configuration from env _allow_origins_env = (get_env_variable("ALLOW_ORIGINS") or "").strip() -# Custom CORS handling: If "*", dynamically return requesting origin to allow credentials +# For wildcard CORS with credentials, we need to use regex to match all origins if _allow_origins_env == "" or _allow_origins_env == "*": - # Allow all origins by reflecting the request origin - @app.middleware("http") - async def dynamic_cors_middleware(request: Request, call_next): - response = await call_next(request) - origin = request.headers.get("origin") - - if origin: - response.headers["Access-Control-Allow-Origin"] = origin - response.headers["Access-Control-Allow-Credentials"] = "true" - response.headers["Access-Control-Allow-Methods"] = "*" - response.headers["Access-Control-Allow-Headers"] = "*" - - return response - - logger.info("CORS: Allowing all origins with credentials (dynamic reflection)") + # Use regex to allow any origin (required for credentials with wildcard) + app.add_middleware( + CORSMiddleware, + allow_origin_regex=r".*", # Allow any origin + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + expose_headers=["X-Request-Id"], + max_age=86400, # 24 hours + ) + logger.info("CORS: Allowing all origins with credentials (regex pattern)") else: # Specific origins configured _allow_origins = [o.strip() for o in _allow_origins_env.split(",") if o.strip()] @@ -91,6 +87,8 @@ async def dynamic_cors_middleware(request: Request, call_next): allow_credentials=True, allow_methods=["*"], allow_headers=["*"], + expose_headers=["X-Request-Id"], + max_age=86400, ) logger.info(f"CORS: Allowing specific origins: {_allow_origins}") diff --git a/src/routes/secure_auth.py b/src/routes/secure_auth.py index 2c7b386..f9f6df7 100644 --- a/src/routes/secure_auth.py +++ b/src/routes/secure_auth.py @@ -14,7 +14,7 @@ from ..mw_utils.http_client import DEFAULT_HEADERS logger = logging.getLogger("moodleware.secure_auth") -router = APIRouter(prefix="/api/secure", tags=["Secure Authentication"]) +router = APIRouter(prefix="/secure", tags=["Secure Authentication"]) class LoginRequest(BaseModel): From db580817595e92ae55cb8784c57d21c7a19d5b56 Mon Sep 17 00:00:00 2001 From: MyDrift Date: Tue, 7 Oct 2025 23:03:44 +0200 Subject: [PATCH 07/10] test --- src/app.py | 4 + src/routes/office_preview.py | 177 +++++++++++++++++++++++++++++++++++ 2 files changed, 181 insertions(+) create mode 100644 src/routes/office_preview.py diff --git a/src/app.py b/src/app.py index c94eb1d..5d6a7c2 100644 --- a/src/app.py +++ b/src/app.py @@ -13,6 +13,7 @@ from .mw_utils.session import cleanup_expired_sessions, SESSION_MAX_AGE, init_redis, REDIS_URL from .routes.secure_auth import router as secure_auth_router from .routes.files import router as files_router +from .routes.office_preview import router as office_preview_router load_dotenv() @@ -134,6 +135,9 @@ async def add_request_id(request: Request, call_next: Callable): # Register file proxy routes app.include_router(files_router) +# Register office preview one-time token routes +app.include_router(office_preview_router) + # Health check @app.post("/healthz", tags=["meta"]) async def healthz(): diff --git a/src/routes/office_preview.py b/src/routes/office_preview.py new file mode 100644 index 0000000..4b7dcd2 --- /dev/null +++ b/src/routes/office_preview.py @@ -0,0 +1,177 @@ +""" +Office Preview One-Time Token Endpoint + +Generates one-time use tokens for Office Live Viewer to access files. +Solves the problem where Microsoft servers can't access authenticated POST endpoints. +""" + +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel +import httpx +import secrets +import logging +from typing import Optional + +from ..dependencies.auth import get_current_session +from ..mw_utils.session import SessionData, get_redis + +logger = logging.getLogger("moodleware") + +router = APIRouter(prefix="/office", tags=["office-preview"]) + +# One-time token prefix in Redis +OT_TOKEN_PREFIX = "ot_token:" +OT_TOKEN_EXPIRY = 60 # 60 seconds TTL for one-time tokens + + +class GenerateTokenRequest(BaseModel): + """Request body for generating a one-time token""" + file_path: str # e.g., "/webservice/pluginfile.php/123/mod_resource/content/1/document.docx" + + +class GenerateTokenResponse(BaseModel): + """Response containing the one-time token and file URL""" + token: str + file_url: str + expires_in: int # seconds + + +@router.post("/generate-token", response_model=GenerateTokenResponse) +async def generate_one_time_token( + request: GenerateTokenRequest, + session: SessionData = Depends(get_current_session) +): + """ + Generate a one-time token for accessing a file via Office Live Viewer. + + This endpoint: + 1. Validates the user's session + 2. Generates a secure one-time token + 3. Stores the token in Redis with file path and Moodle credentials + 4. Returns the token and public file URL for Office Live Viewer + + The token expires after 60 seconds or after first use. + """ + # Generate cryptographically secure random token + one_time_token = secrets.token_urlsafe(32) + + # Store token data in Redis with expiry + try: + redis_client = get_redis() + except RuntimeError: + raise HTTPException(status_code=503, detail="Redis unavailable") + + token_key = f"{OT_TOKEN_PREFIX}{one_time_token}" + token_data = { + "file_path": request.file_path, + "moodle_url": session.moodle_url, + "moodle_token": session.moodle_token, + "session_id": session.session_id, + } + + try: + # Store as hash with automatic expiry + await redis_client.hset(token_key, mapping=token_data) + await redis_client.expire(token_key, OT_TOKEN_EXPIRY) + + logger.info(f"Generated one-time token for session {session.session_id[:8]}..., file: {request.file_path}") + except Exception as e: + logger.error(f"Failed to store one-time token in Redis: {e}") + raise HTTPException(status_code=500, detail="Failed to generate token") + + # Construct public file URL with one-time token + # This URL will be accessible without authentication + file_url = f"/office/file?token={one_time_token}" + + return GenerateTokenResponse( + token=one_time_token, + file_url=file_url, + expires_in=OT_TOKEN_EXPIRY + ) + + +@router.get("/file") +async def get_file_with_one_time_token( + token: str = Query(..., description="One-time access token") +): + """ + Retrieve a file using a one-time token. + + This endpoint: + 1. Validates the one-time token + 2. Fetches the file from Moodle using stored credentials + 3. Deletes the token after use (single use) + 4. Returns the file to the requesting service (e.g., Office Live Viewer) + + The token is deleted immediately after use to prevent replay attacks. + """ + try: + redis_client = get_redis() + except RuntimeError: + raise HTTPException(status_code=503, detail="Redis unavailable") + + token_key = f"{OT_TOKEN_PREFIX}{token}" + + try: + # Retrieve token data + token_data = await redis_client.hgetall(token_key) + + if not token_data: + raise HTTPException(status_code=404, detail="Token not found or expired") + + # Extract file information + file_path = token_data.get("file_path") + moodle_url = token_data.get("moodle_url") + moodle_token = token_data.get("moodle_token") + session_id = token_data.get("session_id") + + if not all([file_path, moodle_url, moodle_token]): + raise HTTPException(status_code=500, detail="Invalid token data") + + # Delete token immediately (single use) + await redis_client.delete(token_key) + logger.info(f"One-time token used by session {session_id[:8] if session_id else 'unknown'}..., file: {file_path}") + + # Fetch file from Moodle + moodle_url = moodle_url.rstrip('/') + if not file_path.startswith('/'): + file_path = f'/{file_path}' + + # Ensure we use webservice/pluginfile.php for token-based access + if '/pluginfile.php' in file_path and '/webservice/pluginfile.php' not in file_path: + file_path = file_path.replace('/pluginfile.php', '/webservice/pluginfile.php') + + file_url = f"{moodle_url}{file_path}" + + # Fetch the file + async with httpx.AsyncClient(timeout=60.0, follow_redirects=True) as client: + response = await client.get(file_url, params={"token": moodle_token}) + response.raise_for_status() + + # Return file with appropriate headers + from fastapi.responses import Response + return Response( + content=response.content, + media_type=response.headers.get("Content-Type", "application/octet-stream"), + headers={ + "Cache-Control": "no-store, no-cache, must-revalidate", # Don't cache one-time tokens + "X-Content-Type-Options": "nosniff", + "Content-Disposition": response.headers.get("Content-Disposition", ""), + } + ) + + except httpx.HTTPStatusError as e: + logger.error(f"Failed to fetch file from Moodle: {e.response.status_code}") + raise HTTPException( + status_code=e.response.status_code, + detail=f"Failed to fetch file from Moodle" + ) + except httpx.RequestError as e: + logger.error(f"Failed to connect to Moodle: {str(e)}") + raise HTTPException( + status_code=502, + detail=f"Failed to connect to Moodle" + ) + except Exception as e: + logger.error(f"Error processing one-time token: {str(e)}") + raise HTTPException(status_code=500, detail="Failed to process token") From 2c6ff55b637e4e1ae14eb60c6969708ef15233bd Mon Sep 17 00:00:00 2001 From: MyDrift Date: Wed, 8 Oct 2025 12:15:06 +0200 Subject: [PATCH 08/10] test fix --- src/routes/office_preview.py | 138 +++++++++++++++++++++++++---------- 1 file changed, 98 insertions(+), 40 deletions(-) diff --git a/src/routes/office_preview.py b/src/routes/office_preview.py index 4b7dcd2..a8ab549 100644 --- a/src/routes/office_preview.py +++ b/src/routes/office_preview.py @@ -5,23 +5,79 @@ Solves the problem where Microsoft servers can't access authenticated POST endpoints. """ -from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi import APIRouter, Depends, HTTPException, Query, Response from pydantic import BaseModel import httpx import secrets import logging -from typing import Optional +from typing import Optional, Dict, Tuple from ..dependencies.auth import get_current_session from ..mw_utils.session import SessionData, get_redis -logger = logging.getLogger("moodleware") +logger = logging.getLogger("moodleware.office_preview") router = APIRouter(prefix="/office", tags=["office-preview"]) -# One-time token prefix in Redis -OT_TOKEN_PREFIX = "ot_token:" -OT_TOKEN_EXPIRY = 60 # 60 seconds TTL for one-time tokens +# Constants +TOKEN_PREFIX = "ot_token:" +TOKEN_EXPIRY_SECONDS = 60 + +# Persistent HTTP client for connection pooling (shared pattern from files.py) +_http_client: Optional[httpx.AsyncClient] = None + + +async def get_http_client() -> httpx.AsyncClient: + """Get or create persistent HTTP client with connection pooling""" + global _http_client + if _http_client is None or _http_client.is_closed: + _http_client = httpx.AsyncClient( + timeout=60.0, + follow_redirects=True, + http2=True, + limits=httpx.Limits( + max_connections=100, + max_keepalive_connections=20, + keepalive_expiry=30.0, + ) + ) + return _http_client + + +def normalize_file_path(file_path: str) -> str: + """Normalize file path to use webservice/pluginfile.php for token-based access""" + # Ensure path starts with / + if not file_path.startswith('/'): + file_path = f'/{file_path}' + + # Ensure we use webservice/pluginfile.php for token-based access + if '/pluginfile.php' in file_path and '/webservice/pluginfile.php' not in file_path: + file_path = file_path.replace('/pluginfile.php', '/webservice/pluginfile.php') + + return file_path + + +async def fetch_file_from_moodle( + file_url: str, + moodle_token: str, + client: httpx.AsyncClient +) -> Tuple[bytes, Dict[str, str]]: + """ + Fetch file from Moodle with authentication + + Returns: + Tuple of (file_content, headers_dict) + """ + response = await client.get(file_url, params={"token": moodle_token}) + response.raise_for_status() + + # Extract useful headers from Moodle response + headers = { + "Content-Type": response.headers.get("Content-Type", "application/octet-stream"), + "Content-Disposition": response.headers.get("Content-Disposition", ""), + } + + return response.content, headers class GenerateTokenRequest(BaseModel): @@ -61,7 +117,7 @@ async def generate_one_time_token( except RuntimeError: raise HTTPException(status_code=503, detail="Redis unavailable") - token_key = f"{OT_TOKEN_PREFIX}{one_time_token}" + token_key = f"{TOKEN_PREFIX}{one_time_token}" token_data = { "file_path": request.file_path, "moodle_url": session.moodle_url, @@ -72,7 +128,7 @@ async def generate_one_time_token( try: # Store as hash with automatic expiry await redis_client.hset(token_key, mapping=token_data) - await redis_client.expire(token_key, OT_TOKEN_EXPIRY) + await redis_client.expire(token_key, TOKEN_EXPIRY_SECONDS) logger.info(f"Generated one-time token for session {session.session_id[:8]}..., file: {request.file_path}") except Exception as e: @@ -86,7 +142,7 @@ async def generate_one_time_token( return GenerateTokenResponse( token=one_time_token, file_url=file_url, - expires_in=OT_TOKEN_EXPIRY + expires_in=TOKEN_EXPIRY_SECONDS ) @@ -110,7 +166,7 @@ async def get_file_with_one_time_token( except RuntimeError: raise HTTPException(status_code=503, detail="Redis unavailable") - token_key = f"{OT_TOKEN_PREFIX}{token}" + token_key = f"{TOKEN_PREFIX}{token}" try: # Retrieve token data @@ -132,46 +188,48 @@ async def get_file_with_one_time_token( await redis_client.delete(token_key) logger.info(f"One-time token used by session {session_id[:8] if session_id else 'unknown'}..., file: {file_path}") - # Fetch file from Moodle + # Normalize and construct file URL + file_path = normalize_file_path(file_path) moodle_url = moodle_url.rstrip('/') - if not file_path.startswith('/'): - file_path = f'/{file_path}' - - # Ensure we use webservice/pluginfile.php for token-based access - if '/pluginfile.php' in file_path and '/webservice/pluginfile.php' not in file_path: - file_path = file_path.replace('/pluginfile.php', '/webservice/pluginfile.php') - file_url = f"{moodle_url}{file_path}" - # Fetch the file - async with httpx.AsyncClient(timeout=60.0, follow_redirects=True) as client: - response = await client.get(file_url, params={"token": moodle_token}) - response.raise_for_status() - - # Return file with appropriate headers - from fastapi.responses import Response - return Response( - content=response.content, - media_type=response.headers.get("Content-Type", "application/octet-stream"), - headers={ - "Cache-Control": "no-store, no-cache, must-revalidate", # Don't cache one-time tokens - "X-Content-Type-Options": "nosniff", - "Content-Disposition": response.headers.get("Content-Disposition", ""), - } - ) + # Fetch file from Moodle with persistent HTTP client + client = await get_http_client() + file_content, moodle_headers = await fetch_file_from_moodle( + file_url, + moodle_token, + client + ) + + # Return file with appropriate headers + return Response( + content=file_content, + media_type=moodle_headers.get("Content-Type", "application/octet-stream"), + headers={ + "Cache-Control": "no-store, no-cache, must-revalidate", # Don't cache one-time tokens + "X-Content-Type-Options": "nosniff", + "Content-Disposition": moodle_headers.get("Content-Disposition", ""), + } + ) except httpx.HTTPStatusError as e: - logger.error(f"Failed to fetch file from Moodle: {e.response.status_code}") + logger.error(f"Moodle returned error {e.response.status_code} for one-time token request") raise HTTPException( status_code=e.response.status_code, - detail=f"Failed to fetch file from Moodle" + detail="Failed to fetch file from Moodle" ) except httpx.RequestError as e: - logger.error(f"Failed to connect to Moodle: {str(e)}") + logger.error(f"Network error connecting to Moodle: {str(e)}") raise HTTPException( status_code=502, - detail=f"Failed to connect to Moodle" + detail="Failed to connect to Moodle" ) + except HTTPException: + # Re-raise HTTP exceptions without wrapping + raise except Exception as e: - logger.error(f"Error processing one-time token: {str(e)}") - raise HTTPException(status_code=500, detail="Failed to process token") + logger.error(f"Unexpected error processing one-time token: {str(e)}") + raise HTTPException( + status_code=500, + detail="An unexpected error occurred" + ) From e6739d973fae39d8cfb5c337b772014947d07ecd Mon Sep 17 00:00:00 2001 From: MyDrift Date: Tue, 11 Nov 2025 21:54:13 +0100 Subject: [PATCH 09/10] Update environment and Docker Compose for Postgres support Added Postgres service to compose.yaml and updated environment variables in .env.example for database configuration. Improved organization and clarity of environment variables, and ensured all services use a shared network for better connectivity. --- .env.example | 42 ++++++++++++++++++++++++--------------- compose.yaml | 56 +++++++++++++++++++++++++++++++++++++++++++--------- 2 files changed, 73 insertions(+), 25 deletions(-) diff --git a/.env.example b/.env.example index f4b1cf9..e69de09 100644 --- a/.env.example +++ b/.env.example @@ -1,24 +1,34 @@ +# Moodle Configuration +MOODLE_URL=https://moodle.school.edu + +# Port Configuration API_PORT=8000 -REDIS_PORT=6379 -ENVIRONMENT=development # "development" or "production" -LOG_LEVEL=info +FRONTEND_PORT=80 -# Set the base URL of your Moodle instance here, if none is set api will require it in the request -MOODLE_URL=https://moodle.school.edu +# API URL for Frontend +# This is the URL the frontend will use to make API requests +# For local development: http://localhost:8000 +# For production: https://api.yourdomain.com +API_BASE_URL=http://localhost:8000 + +# Security +SECRET_KEY=CHANGE_ME_IN_PRODUCTION +SESSION_MAX_AGE=14400 -# Allow Requests from these origins (CORS) +# CORS Configuration ALLOW_ORIGINS=* -# This key is used to sign cookies and other sensitive data -# Generate with one of the following methods: -# Python: python -c "import secrets; print(secrets.token_urlsafe(32))" -# OpenSSL: openssl rand -hex 32 -SECRET_KEY=CHANGE_ME_IN_PRODUCTION +# Environment +ENVIRONMENT=development +LOG_LEVEL=info -# Session max age in seconds (default: 14400 = 4 hours) -SESSION_MAX_AGE=14400 +# Database +POSTGRES_PORT=5432 +POSTGRES_DB=moodleng +POSTGRES_USER=moodleng +POSTGRES_PASSWORD=moodleng_dev_password -# Redis connection URL for session storage -# When running with docker-compose the internal hostname `redis` is available, else modify as needed -REDIS_URL=redis://redis:6379/0 +# Full database connection +DATABASE_URL=postgresql://moodleng:moodleng_dev_password@postgres:5432/moodleng +REDIS_URL=redis://redis:6379/0 \ No newline at end of file diff --git a/compose.yaml b/compose.yaml index 16b2870..e081ddf 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,11 +1,11 @@ -# This compose file is meant for development purposes. +name: MoodlewareAPI services: redis: image: redis:alpine container_name: MoodleNG-Redis ports: - - "${REDIS_PORT:-6379}:6379" + - "6379:6379" volumes: - redis-data:/data command: redis-server --appendonly yes @@ -15,22 +15,60 @@ services: timeout: 3s retries: 3 restart: unless-stopped + networks: + - moodleng-network - moodle-api: - container_name: MoodlewareAPI - build: . + postgres: + image: postgres:16-alpine + container_name: MoodleNG-Postgres ports: - - ${API_PORT:-8000}:8000 + - "${POSTGRES_PORT:-5432}:5432" + environment: + - POSTGRES_DB=${POSTGRES_DB:-moodleng} + - POSTGRES_USER=${POSTGRES_USER:-moodleng} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-moodleng_dev_password} + volumes: + - postgres-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-moodleng}"] + interval: 10s + timeout: 3s + retries: 3 restart: unless-stopped - env_file: - - .env + networks: + - moodleng-network + + moodleware-api: + container_name: MoodlewareAPI + build: + context: . + dockerfile: Dockerfile + ports: + - "${API_PORT:-8000}:8000" environment: + - MOODLE_URL=${MOODLE_URL:-https://moodle.bbbaden.ch} + - ALLOW_ORIGINS=${ALLOW_ORIGINS:-*} + - ENVIRONMENT=${ENVIRONMENT:-development} + - LOG_LEVEL=${LOG_LEVEL:-info} + - SECRET_KEY=${SECRET_KEY:-cXWIu5Yj5P4TpHNcqwwVrPDqBQTstQF0A3_C_vjM2LQ} + - SESSION_MAX_AGE=${SESSION_MAX_AGE:-14400} - REDIS_URL=${REDIS_URL:-redis://redis:6379/0} + - DATABASE_URL=${DATABASE_URL:-postgresql://moodleng:moodleng_dev_password@postgres:5432/moodleng} depends_on: redis: condition: service_healthy + postgres: + condition: service_healthy + restart: unless-stopped + networks: + - moodleng-network +networks: + moodleng-network: + driver: bridge volumes: redis-data: - driver: local \ No newline at end of file + driver: local + postgres-data: + driver: local From ea380f177f6f84e59b432bce75f2497254414aad Mon Sep 17 00:00:00 2001 From: MyDrift Date: Tue, 11 Nov 2025 21:54:48 +0100 Subject: [PATCH 10/10] Update default MOODLE_URL in compose.yaml Changed the default MOODLE_URL environment variable --- compose.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compose.yaml b/compose.yaml index e081ddf..51bcf6a 100644 --- a/compose.yaml +++ b/compose.yaml @@ -46,7 +46,7 @@ services: ports: - "${API_PORT:-8000}:8000" environment: - - MOODLE_URL=${MOODLE_URL:-https://moodle.bbbaden.ch} + - MOODLE_URL=${MOODLE_URL:-https://moodle.school.edu} - ALLOW_ORIGINS=${ALLOW_ORIGINS:-*} - ENVIRONMENT=${ENVIRONMENT:-development} - LOG_LEVEL=${LOG_LEVEL:-info}