From 570b296b6e8c85109d009d1b4267738b352a349b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Simon=20K=C3=BCnzel?= <simonk@fsmpi.rwth-aachen.de>
Date: Thu, 23 May 2024 00:25:50 +0200
Subject: [PATCH] Use live config in routes

---
 src/api/authentication.py              | 31 ++++++++++++++---------
 src/api/miscellaneous/__init__.py      |  2 +-
 src/api/miscellaneous/errors.py        | 34 ++++++++++++++++----------
 src/api/routes/authentication.py       | 10 ++++----
 src/api/routes/courses.py              |  8 +++---
 src/api/routes/jobs.py                 |  2 +-
 src/api/routes/miscellaneous.py        |  2 +-
 src/api/routes/object_modifications.py |  8 +++---
 src/api/routes/route.py                | 11 ++++++++-
 src/api/routes/site.py                 |  6 ++---
 src/api/routes/sorter.py               |  2 +-
 src/api/routes/user.py                 |  6 +++--
 12 files changed, 76 insertions(+), 46 deletions(-)

diff --git a/src/api/authentication.py b/src/api/authentication.py
index 4b63490..f4eb559 100644
--- a/src/api/authentication.py
+++ b/src/api/authentication.py
@@ -397,7 +397,7 @@ elif "LDAP_HOST" in api.config:
             return None
 else:
     def __ldap_authenticate(user: str, password: str) -> tuple[str, str, str, list[str]] or None:
-        raise ApiClientException(ERROR_AUTHENTICATION_SERVERS_NOT_AVAILABLE)
+        raise ApiClientException(ERROR_AUTHENTICATION_NOT_AVAILABLE())
 
 
 _SQL_GET_USER_BY_NAME = PreparedStatement("""
@@ -424,16 +424,25 @@ def authenticate_fsmpi(username: str, password: str) -> {}:
     if not __ldap_is_moderator(groups):
         raise ApiClientException(ERROR_AUTHENTICATION_FAILED)
     
-    with db_pool.start_write_transaction() as trans:
-        user_list_db = trans.execute_statement(_SQL_GET_USER_BY_NAME, user_id)
-        if len(user_list_db) < 1:
-            result = trans.execute_statement_and_commit(
-                _SQL_INSERT_USER, user_id, given_name, user_id)
-            user_db_id = result[0]["id"]
-        else:
-            trans.commit()
+    if api.live_config.is_readonly():
+        with db_pool.start_read_transaction() as trans:
+            user_list_db = trans.execute_statement_and_close(_SQL_GET_USER_BY_NAME, user_id)
+            if len(user_list_db) < 1:
+                raise ApiClientException(ERROR_AUTHENTICATION_NOT_AVAILABLE(
+                    "Site is read-only and we can not create a new account for you in the database"))
             user_db = user_list_db[0]
             user_db_id = user_db["id"]
+    else:
+        with db_pool.start_write_transaction() as trans:
+            user_list_db = trans.execute_statement(_SQL_GET_USER_BY_NAME, user_id)
+            if len(user_list_db) < 1:
+                result = trans.execute_statement_and_commit(
+                    _SQL_INSERT_USER, user_id, given_name, user_id)
+                user_db_id = result[0]["id"]
+            else:
+                trans.commit()
+                user_db = user_list_db[0]
+                user_db_id = user_db["id"]
     
     session["user"] = {
         "uid": user_id,
@@ -638,7 +647,7 @@ def __make_moodle_request(endpoint, token, **args):
 
 def __make_oauth_request(endpoint, **args):
     if "RWTH_API_KEY" not in api.config:
-        raise ApiClientException(ERROR_AUTHENTICATION_SERVERS_NOT_AVAILABLE)
+        raise ApiClientException(ERROR_AUTHENTICATION_NOT_AVAILABLE())
     
     args["client_id"] = api.config["RWTH_API_KEY"]
     return __handle_request_result(requests.request('POST', OAUTH_BASE + endpoint, data=args, timeout=30))
@@ -655,5 +664,5 @@ def __handle_request_result(response: requests.Response):
         pass
     
     if response.status_code >= 500:
-        raise ApiClientException(ERROR_AUTHENTICATION_SERVERS_NOT_AVAILABLE)
+        raise ApiClientException(ERROR_AUTHENTICATION_NOT_AVAILABLE())
     raise Exception("Error during request. Status: " + str(response.status_code) + " Response: " + str(response))
diff --git a/src/api/miscellaneous/__init__.py b/src/api/miscellaneous/__init__.py
index 48441a1..7374751 100644
--- a/src/api/miscellaneous/__init__.py
+++ b/src/api/miscellaneous/__init__.py
@@ -15,7 +15,7 @@ from api.miscellaneous.errors import (ApiError, ApiClientException, api_on_error
                                       ERROR_INTERNAL_SERVER_ERROR,
                                       ERROR_LECTURE_HAS_NO_PASSWORD,
                                       ERROR_AUTHENTICATION_FAILED,
-                                      ERROR_AUTHENTICATION_SERVERS_NOT_AVAILABLE,
+                                      ERROR_AUTHENTICATION_NOT_AVAILABLE,
                                       ERROR_UNAUTHORIZED,
                                       ERROR_INVALID_CSRF_TOKEN,
                                       ERROR_ACCESS_FORBIDDEN,
diff --git a/src/api/miscellaneous/errors.py b/src/api/miscellaneous/errors.py
index d2cdb08..be40bed 100644
--- a/src/api/miscellaneous/errors.py
+++ b/src/api/miscellaneous/errors.py
@@ -1,7 +1,5 @@
 import json
-import traceback
 from flask.wrappers import Response
-from functools import wraps
 
 from api.miscellaneous.constants import *
 
@@ -30,26 +28,39 @@ def api_on_error(error: ApiError):
 
 def ERROR_BAD_REQUEST(message: str = "The request is invalid") -> ApiError:
     return ApiError("bad_request", HTTP_400_BAD_REQUEST, message)
-ERROR_METHOD_NOT_ALLOWED = ApiError("method_not_allowed", HTTP_405_METHOD_NOT_ALLOWED,
-                                    "The specified request method is not allowed")
-ERROR_MALFORMED_JSON = ApiError("malformed_json", HTTP_400_BAD_REQUEST,
-                                "The request json is malformed or missing (Or Content-Type is not application/json)")
+
+
 def ERROR_REQUEST_MISSING_PARAMETER(parameter_name: str) -> ApiError:
     return ApiError("request_missing_parameter", HTTP_400_BAD_REQUEST,
                     "Missing parameter " + parameter_name + " in request")
+
+
 def ERROR_REQUEST_INVALID_PARAMETER(parameter_name: str, invalid_message: str) -> ApiError:
     return ApiError("request_invalid_parameter", HTTP_400_BAD_REQUEST,
                     "Parameter " + parameter_name + " in request is invalid: " + invalid_message)
+
+
+def ERROR_AUTHENTICATION_NOT_AVAILABLE(error_message: str = "Authentication servers are currently not available") -> ApiError:
+    return ApiError("authentication_not_available", HTTP_503_SERVICE_UNAVAILABLE, error_message)
+
+
+def ERROR_OBJECT_ERROR(message: str) -> ApiError:
+    return ApiError("object_error", HTTP_400_BAD_REQUEST,
+                    message)
+
+
+ERROR_METHOD_NOT_ALLOWED = ApiError("method_not_allowed", HTTP_405_METHOD_NOT_ALLOWED,
+                                    "The specified request method is not allowed")
+ERROR_MALFORMED_JSON = ApiError("malformed_json", HTTP_400_BAD_REQUEST,
+                                "The request json is malformed or missing (Or Content-Type is not application/json)")
 ERROR_UNKNOWN_REQUEST_PATH = ApiError("unknown_request_path", HTTP_400_BAD_REQUEST,
-                                "The specified request path does not exist")
+                                      "The specified request path does not exist")
 ERROR_INTERNAL_SERVER_ERROR = ApiError("internal_server_error", HTTP_500_INTERNAL_SERVER_ERROR,
                                        "An unknown internal server error occurred")
 ERROR_LECTURE_HAS_NO_PASSWORD = ApiError("lecture_has_no_password", HTTP_400_BAD_REQUEST,
                                          "The specified lecture has no password authentication")
 ERROR_AUTHENTICATION_FAILED = ApiError("authentication_failed", HTTP_403_FORBIDDEN,
                                        "Authentication failed")
-ERROR_AUTHENTICATION_SERVERS_NOT_AVAILABLE = ApiError("authentication_servers_not_available", HTTP_503_SERVICE_UNAVAILABLE,
-                                                      "Authentication servers are currently not available")
 ERROR_UNAUTHORIZED = ApiError("unauthorized", HTTP_401_UNAUTHORIZED,
                               "You are not authorized")
 ERROR_INVALID_CSRF_TOKEN = ApiError("invalid_csrf_token", HTTP_403_FORBIDDEN,
@@ -62,9 +73,6 @@ ERROR_UNKNOWN_OBJECT = ApiError("unknown_object", HTTP_404_NOT_FOUND,
                                 "Cannot find the specified object")
 ERROR_MODIFICATION_UNEXPECTED_CURRENT_VALUE = ApiError("modification_unexpected_current_value", HTTP_409_CONFLICT,
                                                        "The current value does not match the expected last known value")
-def ERROR_OBJECT_ERROR(message: str) -> ApiError:
-    return ApiError("object_error", HTTP_400_BAD_REQUEST,
-                    message)
 ERROR_TOO_MANY_SUGGESTIONS = ApiError("too_many_suggestions", HTTP_403_FORBIDDEN,
                                       "Due to too many suggestion, no more are accepted")
 ERROR_SITE_IS_READONLY = ApiError("site_is_readonly", HTTP_503_SERVICE_UNAVAILABLE,
@@ -82,7 +90,7 @@ ALL_ERRORS_RANDOM = [
     ERROR_INTERNAL_SERVER_ERROR,
     ERROR_LECTURE_HAS_NO_PASSWORD,
     ERROR_AUTHENTICATION_FAILED,
-    ERROR_AUTHENTICATION_SERVERS_NOT_AVAILABLE,
+    ERROR_AUTHENTICATION_NOT_AVAILABLE(),
     ERROR_UNAUTHORIZED,
     ERROR_INVALID_CSRF_TOKEN,
     ERROR_ACCESS_FORBIDDEN,
diff --git a/src/api/routes/authentication.py b/src/api/routes/authentication.py
index d7b916a..cfb5f62 100644
--- a/src/api/routes/authentication.py
+++ b/src/api/routes/authentication.py
@@ -13,7 +13,7 @@ _API_AUTH_RATE_LIMITERS = create_configured_host_rate_limiters("authentication",
 
 
 @api_add_route("/authentication/password", ["POST"])
-@api_function(rate_limiters=_API_AUTH_RATE_LIMITERS)
+@api_function(rate_limiters=_API_AUTH_RATE_LIMITERS, allow_while_readonly=True)
 def api_route_authentication_password():
     json_request = get_client_json(request)
     lecture_id: int = json_request.get_sint32("lecture_id",)
@@ -24,7 +24,7 @@ def api_route_authentication_password():
 
 
 @api_add_route("/authentication/fsmpi", ["POST"])
-@api_function(rate_limiters=_API_AUTH_RATE_LIMITERS)
+@api_function(rate_limiters=_API_AUTH_RATE_LIMITERS, allow_while_readonly=True)
 def api_route_authentication_fsmpi():
     json_request = get_client_json(request)
     username: str = json_request.get_string("username", max_length=256)
@@ -43,7 +43,7 @@ def api_route_authentication_fsmpi():
 
 
 @api_add_route("/authentication/start_oauth", ["POST"])
-@api_function(rate_limiters=_API_AUTH_RATE_LIMITERS)
+@api_function(rate_limiters=_API_AUTH_RATE_LIMITERS, allow_while_readonly=True)
 def api_route_authentication_start_oauth():
     json_request = get_client_json(request)
     login_type: str = json_request.get_string("type", max_length=10)
@@ -58,7 +58,7 @@ def api_route_authentication_start_oauth():
     }, HTTP_202_ACCEPTED
 
 
-@api_route("/authentication/status", ["POST"])
+@api_route("/authentication/status", ["POST"], allow_while_readonly=True)
 def api_route_authentication_status():
     json_request = get_client_json(request)
     
@@ -95,7 +95,7 @@ def _set_moderator_cookies(response: ApiResponse):
         response.response.set_cookie("moderator", "#", max_age=None, httponly=True, samesite="Strict")
 
 
-@api_route("/authentication/moderator_logout", ["POST"])
+@api_route("/authentication/moderator_logout", ["POST"], allow_while_readonly=True)
 def api_route_authentication_moderator_logout():
     logout_moderator()
     response = ApiResponse({})
diff --git a/src/api/routes/courses.py b/src/api/routes/courses.py
index 45b025d..acafc9a 100644
--- a/src/api/routes/courses.py
+++ b/src/api/routes/courses.py
@@ -30,7 +30,7 @@ ORDER BY "courses"."id" ASC
 """)
 
 
-@api_route("/courses", ["GET"])
+@api_route("/courses", ["GET"], allow_while_readonly=True)
 def api_route_courses():
     is_mod: bool = is_moderator()
     with db_pool.start_read_transaction() as trans:
@@ -102,7 +102,7 @@ ORDER BY "lectures"."id" ASC
 
 @api_add_route("/course/<int:course_id>", ["GET"])
 @api_add_route("/course/<string:course_id_string>", ["GET"])
-@api_function()
+@api_function(allow_while_readonly=True)
 def api_route_course(course_id: int = None, course_id_string: str = None):
     is_mod: bool = is_moderator()
     with db_pool.start_read_transaction() as trans:
@@ -194,7 +194,7 @@ WHERE %s
 _SQL_GET_LECTURE_MEDIA = PreparedStatement(_SQL_GET_LECTURE_MEDIA_NO_ID_COND % '"videos"."lecture_id" = ?')
 
 
-@api_route("/lecture/<int:lecture_id>", ["GET"])
+@api_route("/lecture/<int:lecture_id>", ["GET"], allow_while_readonly=True)
 def api_route_lecture(lecture_id: int):
     is_mod: bool = is_moderator()
     with db_pool.start_read_transaction() as trans:
@@ -280,7 +280,7 @@ def api_route_lecture_chapter_suggestion(lecture_id: int):
     return {}, HTTP_201_CREATED
 
 
-@api_route("/search", ["GET"])
+@api_route("/search", ["GET"], allow_while_readonly=True)
 def api_route_search():
     is_mod = is_moderator()
     
diff --git a/src/api/routes/jobs.py b/src/api/routes/jobs.py
index ab630d6..e1598a7 100644
--- a/src/api/routes/jobs.py
+++ b/src/api/routes/jobs.py
@@ -7,7 +7,7 @@ from api.job import (JOBS_MIN_PAGE_SIZE, JOBS_MAX_PAGE_SIZE, WORKER_ID_PATTERN,
 from api.miscellaneous import *
 
 
-@api_route("/jobs", ["GET"])
+@api_route("/jobs", ["GET"], allow_while_readonly=True)
 @api_moderator_route()
 def api_route_jobs():
     entries_per_page = api_request_get_query_int("entries_per_page", 50, JOBS_MIN_PAGE_SIZE, JOBS_MAX_PAGE_SIZE)
diff --git a/src/api/routes/miscellaneous.py b/src/api/routes/miscellaneous.py
index 45c4290..9f1ed65 100644
--- a/src/api/routes/miscellaneous.py
+++ b/src/api/routes/miscellaneous.py
@@ -43,7 +43,7 @@ def api_route_lecture_import_rwth_online(course_id: int):
 
 
 # Not in API specification on purpose
-@api_route("/diagnostics", ["GET"])
+@api_route("/diagnostics", ["GET"], allow_while_readonly=True)
 @api_moderator_route()
 def api_route_diagnostics():
     response = {
diff --git a/src/api/routes/object_modifications.py b/src/api/routes/object_modifications.py
index 3722f13..b3fa54f 100644
--- a/src/api/routes/object_modifications.py
+++ b/src/api/routes/object_modifications.py
@@ -9,10 +9,12 @@ from api.authentication import check_csrf_token
 /object_management\
 /<any({','.join(key for key, obj in object_classes_by_id.items())}):object_type>\
 /<int:object_id>/configuration\
-""", ["GET", "PATCH"])
+""", ["GET", "PATCH"], allow_while_readonly=True)
 @api_moderator_route()
 def api_route_object_management_configuration(object_type: str, object_id: int):
     if request.method == "PATCH":
+        if api.live_config.is_readonly():
+            raise ApiClientException(ERROR_SITE_IS_READONLY)
         check_csrf_token()
         check_client_int(object_id, "URL.object_id")
         json_request = get_client_json(request)
@@ -35,7 +37,7 @@ def api_route_object_management_configuration(object_type: str, object_id: int):
 /object_management\
 /<any({','.join(key for key, obj in object_classes_by_id.items() if obj.is_creation_allowed())}):object_type>\
 /new/configuration\
-""", ["GET"])
+""", ["GET"], allow_while_readonly=True)
 @api_moderator_route()
 def api_route_object_management_new_configuration(object_type: str):
     return object_classes_by_id[object_type].get_creation_config()
@@ -124,7 +126,7 @@ def api_route_object_management_resurrect(object_type: str, object_id: int):
     return {}
 
 
-@api_route("/object_management/changelog", ["GET"])
+@api_route("/object_management/changelog", ["GET"], allow_while_readonly=True)
 @api_moderator_route()
 def api_route_object_management_changelog():
     entries_per_page = api_request_get_query_int("entries_per_page", 50, CHANGELOG_MIN_PAGE_SIZE, CHANGELOG_MAX_PAGE_SIZE)
diff --git a/src/api/routes/route.py b/src/api/routes/route.py
index 491b664..122ebac 100644
--- a/src/api/routes/route.py
+++ b/src/api/routes/route.py
@@ -107,10 +107,12 @@ class ApiResponse:
 
 
 def api_route(path: str, methods: list[str],
+              allow_while_readonly: bool = False,
+              allow_while_disabled: bool = False,
               min_api_version: int = API_OLDEST_ACTIVE_VERSION,
               max_api_version: int = API_LATEST_VERSION):
     def decorator(func):
-        func = api_function()(func)
+        func = api_function(allow_while_readonly=allow_while_readonly, allow_while_disabled=allow_while_disabled)(func)
         func = api_add_route(path, methods, min_api_version, max_api_version)(func)
         return func
     
@@ -132,6 +134,8 @@ def api_add_route(path: str, methods: list[str],
 
 
 def api_function(track_in_diagnostics: bool = True,
+                 allow_while_readonly: bool = False,
+                 allow_while_disabled: bool = False,
                  rate_limiters: tuple[HostBasedCounterRateLimiter, ...] = _API_GLOBAL_RATE_LIMITERS):
     def decorator(func):
         call_counter = None
@@ -149,6 +153,11 @@ def api_function(track_in_diagnostics: bool = True,
         @wraps(func)
         def wrapper(*args, **kwargs):
             try:
+                if api.live_config.is_disabled() and not allow_while_disabled:
+                    raise ApiClientException(ERROR_SITE_IS_DISABLED)
+                if api.live_config.is_readonly() and not allow_while_readonly:
+                    raise ApiClientException(ERROR_SITE_IS_READONLY)
+                
                 if call_counter is not None:
                     call_counter.trigger()
                 
diff --git a/src/api/routes/site.py b/src/api/routes/site.py
index 57992c7..308190a 100644
--- a/src/api/routes/site.py
+++ b/src/api/routes/site.py
@@ -12,7 +12,7 @@ if _GIT_COMMIT_HASH is not None and len(_GIT_COMMIT_HASH) == 0:
     _GIT_COMMIT_HASH = None
 
 
-@api_route("/status", ["GET"])
+@api_route("/status", ["GET"], allow_while_readonly=True, allow_while_disabled=True)
 def api_route_status():
     status = "available"
     announcements = []
@@ -38,7 +38,7 @@ def api_route_status():
     return response
 
 
-@api_route("/is_running", ["GET"])
+@api_route("/is_running", ["GET"], allow_while_readonly=True, allow_while_disabled=True)
 def api_route_is_running():
     return {}, HTTP_200_OK
 
@@ -158,7 +158,7 @@ ORDER BY CASE WHEN "order" IS NULL THEN 0 ELSE "order" END ASC, "id" ASC
 """)
 
 
-@api_route("/homepage", ["GET"])
+@api_route("/homepage", ["GET"], allow_while_readonly=True)
 def api_route_homepage():
     is_mod: bool = is_moderator()
     upcoming_start: date = date.today()
diff --git a/src/api/routes/sorter.py b/src/api/routes/sorter.py
index b991226..462cb2f 100644
--- a/src/api/routes/sorter.py
+++ b/src/api/routes/sorter.py
@@ -29,7 +29,7 @@ WHERE "id" = ?
 """)
 
 
-@api_route("/sorter/log", ["GET"])
+@api_route("/sorter/log", ["GET"], allow_while_readonly=True)
 @api_moderator_route()
 def api_route_sorter_log():
     with db_pool.start_read_transaction() as trans:
diff --git a/src/api/routes/user.py b/src/api/routes/user.py
index a0ae3bd..323d428 100644
--- a/src/api/routes/user.py
+++ b/src/api/routes/user.py
@@ -8,7 +8,7 @@ _SQL_GET_USERS = PreparedStatement("""
 """)
 
 
-@api_route("/users", ["GET"])
+@api_route("/users", ["GET"], allow_while_readonly=True)
 @api_moderator_route()
 def api_route_users():
     with db_pool.start_read_transaction() as trans:
@@ -18,10 +18,12 @@ def api_route_users():
     }
 
 
-@api_route("/user/me/settings", ["GET", "PATCH"])
+@api_route("/user/me/settings", ["GET", "PATCH"], allow_while_readonly=True)
 @api_moderator_route()
 def api_route_user_me_settings():
     if flask.request.method == "GET":
+        if api.live_config.is_readonly():
+            raise ApiClientException(ERROR_SITE_IS_READONLY)
         settings = get_user_settings(get_user_id())
         if settings is None:
             raise RuntimeError("No db user for moderator")  # pragma: no cover
-- 
GitLab