diff --git a/Dockerfile b/Dockerfile index 756cc84e15c90c223d4b9b43494897e63e59cff7..e4a4ffd4a49e0975e7d4e85db3ca915edc9e9c1a 100755 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,10 @@ FROM nginx:stable-alpine +# Empty by default +ARG GIT_COMMIT_HASH= + +ENV VIDEOAG_API_GIT_COMMIT_HASH $GIT_COMMIT_HASH + RUN mkdir -p /code WORKDIR /code diff --git a/api_specification.md b/api_specification.md index 7a12ff651d90d5a94636fb99283522105a8eefdc..883dcf838ad6dcc77e4589c8837b1189f3a2ca8f 100644 --- a/api_specification.md +++ b/api_specification.md @@ -1,4 +1,4 @@ -# Specification of the Web API for the Video-AG Website (v0.49). +# Specification of the Web API for the Video-AG Website (v0.50). ## Introduction @@ -49,34 +49,38 @@ The changelog can be found [at the end](#changelog). ## Routes -### Miscellaneous +### Site status ---- +The current site status can be retrieved with `GET /status`. It can be in three states -#### `GET /status` - -###### Response: +* `available`: The site is running and all routes can be used +* `readonly`: The site cannot update any data. You can only call routes for which is specified: + * 'This may be used while the site is [read-only](#site-status).' +* `unavailable`: The site is unavailable and the only route you can call is `GET /status` -Returns `200 OK` or `503 SERVICE UNAVAILABLE` - -| Field | Type | Notes | -|--------|-----------|-------------------------------------------| -| status | id_string | possible values: `running`, `unavailable` | +### Miscellaneous --- -#### `GET /announcements` +#### `GET /status` + +This may be used even if the site is read-only or disabled. ###### Response: -| Field | Type | Notes | -|---------------|----------------------------------------|-------| -| announcements | array of [announcement](#announcement) | | +| Field | Type | Notes | +|-----------------|----------------------------------------|---------------------------------------------------------| +| status | id_string | possible values: `available`, `readonly`, `unavailable` | +| is_debug | boolean | | +| git_commit_hash | ?string | | +| announcements | array of [announcement](#announcement) | | <details> <summary>Example: <code>GET /announcement</code></summary> <pre> { + "status": "running", + "is_debug": true, "announcements": [ { "type": "info", @@ -95,8 +99,21 @@ Returns `200 OK` or `503 SERVICE UNAVAILABLE` --- +#### `GET /is_running` + +This may be used even if the site is read-only or disabled. + +###### Response: + +Returns `200 OK` if the site is ready to answer requests, otherwise `503 SERVICE UNAVAILABLE`. The status may still be +`unavailable` if this return `200 OK`! + +--- + #### `GET /homepage` +This may be used while the site is [read-only](#site-status). + ###### Response: A [homepage](#homepage) object. @@ -144,6 +161,8 @@ included for moderators. #### `GET /courses` +This may be used while the site is [read-only](#site-status). + ###### Response: | Field | Type | Notes | @@ -196,6 +215,8 @@ included for moderators. |------------------|----------|---------------------------------------|---------| | include_lectures | ?boolean | If `true`, `lectures` will be present | false | +This may be used while the site is [read-only](#site-status). + ###### Response: A [course](#course) object. @@ -274,6 +295,8 @@ A [course](#course) object. |---------------|------|-------| | lecture_id | int | | +This may be used while the site is [read-only](#site-status). + ###### Response: | Field | Type | Notes | @@ -355,6 +378,8 @@ A [course](#course) object. |---------------|--------|-------------------------------------------------------------------------------------------| | search_term | string | The term to search for. May not be empty (only whitespace) or longer than 1024 characters | +This may be used while the site is [read-only](#site-status). + ###### Response: | Field | Type | Notes | @@ -405,6 +430,8 @@ Response: `200 OK` If the specified lecture does not exist or does not have password authentication, an [api error](#api_error) is returned. +This may be used while the site is [read-only](#site-status). + ###### Response: `200 OK` if the username and password are correct for the specified lecture. Otherwise, an [api error](#api_error) (Usually `authentication_failed`). @@ -423,6 +450,8 @@ The username and password are saved, so that different lectures with the same lo If the specified lecture does not exist, a (random) result or an [api error](#api_error) may be returned. +This may be used while the site is [read-only](#site-status). + ###### Response: `200 OK` if login was successful. Otherwise, an [api error](#api_error) (Usually `authentication_failed`). @@ -440,6 +469,8 @@ If the specified lecture does not exist, a (random) result or an [api error](#ap |-------|--------|-----------------------------------| | type | string | Possible values: `rwth`, `moodle` | +This may be used while the site is [read-only](#site-status). + ###### Response: `202 ACCEPTED` with body: @@ -461,6 +492,8 @@ status: `/authentication/status`. If the specified lecture does not exist, a (random) result or an [api error](#api_error) may be returned. +This may be used while the site is [read-only](#site-status). + ###### Response: Also finishes the last authentication started with `/authentication/start`. If that authentication is not yet complete, @@ -477,6 +510,8 @@ Also finishes the last authentication started with `/authentication/start`. If t #### `POST /authentication/moderator_logout` +This may be used while the site is [read-only](#site-status). + ###### Response: Only logs moderators out. @@ -520,6 +555,8 @@ restrictions may apply for which the configuration must be queried. Most notable | object_type | id_string | Name of object (as used in this document) which has a configuration (See [above](#Objects-with-configuration-overview)) | | object_id | int | | +This may be used while the site is [read-only](#site-status). + ###### Response: Returns the configuration for the object of the specified type and id or an [api error](#api_error) if it does not exist. @@ -657,6 +694,8 @@ Response: <code>200 OK</code> |---------------|-----------|-------------------------------------------------------------------------------------------------------------------------| | object_type | id_string | Name of object (as used in this document) which has a configuration (See [above](#Objects-with-configuration-overview)) | +This may be used while the site is [read-only](#site-status). + ###### Response: Returns the fields which may need to be configured to create a new object. For the creation all returned fields **may** be specified. All fields which do not have a default value **must** be specified. @@ -808,6 +847,8 @@ could not be restored (Does not exist, not deleted). | object_id | ?int | Only return entries for objects with this id | | field | ?id_string | Only return entries for this field. `object_type` must be set | +This may be used while the site is [read-only](#site-status). + ###### Response: | Field | Type | Notes | @@ -823,6 +864,8 @@ The following routes are only for moderators. #### `GET /sorter/log` +This may be used while the site is [read-only](#site-status). + ###### Response: | Field | Type | Notes | @@ -852,6 +895,8 @@ The following routes are only for moderators. #### `GET /users` +This may be used while the site is [read-only](#site-status). + ###### Response: Returns a list of all users (moderators) @@ -864,6 +909,8 @@ Returns a list of all users (moderators) #### `GET /user/me/settings` +This may be used while the site is [read-only](#site-status). + ###### Response: A [settings](#settings) object @@ -899,6 +946,8 @@ The following routes are only for moderators. | worker_id | ?string | Only get jobs for this worker. May be `none` to only get jobs assigned to no worker | | state | ?job_state | Only get jobs in this state | +This may be used while the site is [read-only](#site-status). + ###### Response: | Field | Type | Notes | @@ -986,14 +1035,14 @@ Additionally, the following objects may appear as the type of some field: #### announcement -| Field | Type | Notes | -|----------------------|-------------------------|------------------------------------------------------------------------------------| -| id | int | | -| type | announcement_type | | -| visibility | announcement_visibility | | -| text | +string | | -| is_currently_visible | !boolean | If false, this announcement currently cannot be seen by non-moderators | -| has_expired | !boolean | If true, this announcement has expired. Never true, if `is_currently_visible=true` | +| Field | Type | Notes | +|----------------------|-------------------------|-------------------------------------------------------------------------------------------------------------------------| +| id | ?int | If the id is missing, this is a static announcement which can not be modified | +| type | announcement_type | | +| visibility | announcement_visibility | | +| text | +string | | +| is_currently_visible | ?!boolean | Not present on static announcements. If false, this announcement currently cannot be seen by non-moderators | +| has_expired | ?!boolean | Not present on static announcements. If true, this announcement has expired. Never true, if `is_currently_visible=true` | #### announcement_configuration @@ -1456,7 +1505,7 @@ Possible `error_code`: | internal_server_error | 500 | | | lecture_has_no_password | 400 | | | authentication_failed | 403 | | -| authentication_servers_not_available | 503 | | +| authentication_not_available | 503 | | | unauthorized | 401 | | | csrf_token_invalid | 403 | CSRF token must be included as `X-Csrf-Token` Header | | access_forbidden | 403 | | @@ -1465,9 +1514,19 @@ Possible `error_code`: | object_error | 400 | | | modification_unexpected_current_value | 409 | | | too_many_suggestions | 403 | | +| site_is_readonly | 503 | | +| site_is_disabled | 503 | | ## Changelog +### v0.50 + +* Removed `GET /announcements` +* Updated `GET /status`. Among other things, this now returns the announcements +* Changed name of error type `authentication_servers_not_available` to `authentication_not_available` +* Added error types `site_is_readonly` and `site_is_disabled` +* Added `GET /is_running` + ### v0.49 * Added `GET /status` diff --git a/config/live_config.json b/config/live_config.json new file mode 100644 index 0000000000000000000000000000000000000000..d99ed9de3a06539a0801a69d2ee2d84a397c33fd --- /dev/null +++ b/config/live_config.json @@ -0,0 +1,5 @@ +{ + "readonly": false, + "disabled": false, + "static_announcements": [] +} \ No newline at end of file diff --git a/config/uwsgi_example.ini b/config/uwsgi_example.ini index 994f5b7a42a8e881adfd184194ac07ed53aba254..29deeb6212cf18b83f96b6354e9ae7a4e65d552d 100644 --- a/config/uwsgi_example.ini +++ b/config/uwsgi_example.ini @@ -1,13 +1,22 @@ [uwsgi] +strict = true # Fail on invalid/unknown options + uid = uwsgi gid = uwsgi + chmod-socket = 666 -manage-script-name = true socket = /uwsgi/uwsgi.sock + +manage-script-name = true chdir = ./src/ mount = /=app:api_app -need-app = true -enable-threads = true -master = true +need-app = true # Fail if startup throws exception + +master = true # Master manages our workers (in our case, just one worker) +processes = 1 # One process. With multiple processes, global variables would not be shared +lazy-apps = true # Fork before initializing application (Otherwise scheduler is running in master) +single-interpreter = true enable-threads = true +threads = 2 + # do not specify python plugin. uwsgi is already being installed with pip \ No newline at end of file diff --git a/docker-compose.yaml b/docker-compose.yaml index 9a60c89e7718d97e159c219f277ef152d46a9154..30f40bde8fd2404ed448a0da704c8e20d04cf473 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -9,5 +9,6 @@ services: host.docker.internal: host-gateway environment: - VIDEOAG_API_CONFIG=../config/api_example_config.py + - VIDEOAG_API_LIVE_CONFIG=../config/live_config.json - VIDEOAG_NGINX_CONFIG=/code/config/nginx_example.conf - VIDEOAG_UWSGI_CONFIG=/code/config/uwsgi_example.ini \ No newline at end of file diff --git a/src/api/__init__.py b/src/api/__init__.py index 3fe56fed131bd28e742032461961ca62dff5b42d..29b46eb7ea80bdf5211a1e2cae5f827aae4bcc3b 100644 --- a/src/api/__init__.py +++ b/src/api/__init__.py @@ -1,2 +1,3 @@ # noinspection PyUnresolvedReferences from app import api_app as app, api_config as config +from api.live_config import config as live_config diff --git a/src/api/announcement.py b/src/api/announcement.py new file mode 100644 index 0000000000000000000000000000000000000000..3964771ed3464e6c7d876330c5bc6db75f76e7d5 --- /dev/null +++ b/src/api/announcement.py @@ -0,0 +1,56 @@ +from datetime import datetime + +from api.database import db_pool, PreparedStatement +from api.authentication import is_moderator + + +_SQL_GET_ANNOUNCEMENTS = PreparedStatement(""" +SELECT * FROM "announcements" +WHERE NOT "deleted" + AND (? OR ( + "visible" + AND (("time_expire" IS NULL) OR "time_expire" > ?) + AND (("time_publish" IS NULL) OR "time_publish" <= ?) + )) +""") + + +def query_announcements(): + is_mod = is_moderator() + current_time: datetime = datetime.now() + with db_pool.start_read_transaction() as trans: + announcements_db = trans.execute_statement_and_close( + _SQL_GET_ANNOUNCEMENTS, is_mod, current_time, current_time) + + announcements_json = [] + for announcement_db in announcements_db: + announcement_json = { + "id": announcement_db["id"], + "text": announcement_db["text"] + } + if is_mod: + time_publish = announcement_db["time_publish"] + time_expire = announcement_db["time_expire"] + has_not_expired = time_expire is None or time_expire > current_time + announcement_json["is_currently_visible"] = (announcement_db["visible"] + and (time_publish is None or time_publish <= current_time) + and has_not_expired) + announcement_json["has_expired"] = not has_not_expired + announcements_json.append(announcement_json) + level_db: int = announcement_db["level"] + if level_db == 0: + announcement_json["type"] = "info" + announcement_json["visibility"] = "only_main_page" + elif level_db == 1: + announcement_json["type"] = "info" + announcement_json["visibility"] = "all_pages" + elif level_db == 2: + announcement_json["type"] = "warning" + announcement_json["visibility"] = "all_pages" + elif level_db == 3: + announcement_json["type"] = "important" + announcement_json["visibility"] = "all_pages_and_embed" + else: + raise Exception("Unknown announcement level: " + str(level_db)) + + return announcements_json diff --git a/src/api/live_config.py b/src/api/live_config.py new file mode 100644 index 0000000000000000000000000000000000000000..8bb05cc2d88da823ada12be9f9f0c8c2477695b2 --- /dev/null +++ b/src/api/live_config.py @@ -0,0 +1,79 @@ +import os +from pathlib import Path + +from api.miscellaneous import * +from api.objects.types import TYPE_STRING_LONG + +_LIVE_CONFIG_PATH_STRING = os.environ.get("VIDEOAG_API_LIVE_CONFIG", None) +_LIVE_CONFIG_PATH = None if _LIVE_CONFIG_PATH_STRING is None else Path(_LIVE_CONFIG_PATH_STRING).resolve() + + +class LiveConfig: + + def __init__(self): + super().__init__() + self._disabled: bool = False + self._readonly: bool = False + self._static_announcements: list[dict] = [] + + def reset(self): + self._disabled = False + self._readonly = False + self._static_announcements = [] + + def update(self, json_config: CJsonObject): + """ + Raises an exception if the file has wrong format/values. In this case, nothing is updated + """ + disable = json_config.get_bool("disabled", False) + readonly = json_config.get_bool("readonly", False) or disable + static_announcements = [] + unsafe_announcements = json_config.get_array("static_announcements") + if unsafe_announcements is not None: + for unsafe_announcement in unsafe_announcements: + unsafe_announcement = unsafe_announcement.as_object() + + type = unsafe_announcement.get_string("type", 100) + if type not in ["info", "warning", "important"]: + unsafe_announcement.get("type").raise_error("Unknown type") + visibility = unsafe_announcement.get_string("visibility", 100) + if visibility not in ["only_main_page", "all_pages", "all_pages_and_embed"]: + unsafe_announcement.get("visibility").raise_error("Unknown visibility") + text = unsafe_announcement.get_string("text", TYPE_STRING_LONG.get_max_string_length()) + static_announcements.append({ + "type": type, + "visibility": visibility, + "text": text + }) + + # Assign to global vars only if whole file parsed successfully + self._readonly = readonly + self._disabled = disable + self._static_announcements = static_announcements + + def is_disabled(self) -> bool: + return self._disabled + + def is_readonly(self) -> bool: + return self._readonly + + def get_static_announcements(self) -> list[dict]: + """ + Dicts contain (as in the api specification): type, visibility, text + """ + return self._static_announcements + + +# Do not modify directly! Variable is imported by others +config: LiveConfig = LiveConfig() + + +@scheduled_function(10, 1) +def _update_live_config(): + if _LIVE_CONFIG_PATH is None: + return + if not _LIVE_CONFIG_PATH.exists(): + config.reset() + return + config.update(CJsonObject(json.loads(_LIVE_CONFIG_PATH.read_text(encoding="UTF-8")))) + # Exception is caught and logged by scheduler diff --git a/src/api/miscellaneous/__init__.py b/src/api/miscellaneous/__init__.py index 51703ce6cfb82a28dbe2ff04b82a12b6a8347f3e..48441a1722c87110b868ff4e58bde31312a89211 100644 --- a/src/api/miscellaneous/__init__.py +++ b/src/api/miscellaneous/__init__.py @@ -23,7 +23,9 @@ from api.miscellaneous.errors import (ApiError, ApiClientException, api_on_error ERROR_UNKNOWN_OBJECT, ERROR_OBJECT_ERROR, ERROR_MODIFICATION_UNEXPECTED_CURRENT_VALUE, - ERROR_TOO_MANY_SUGGESTIONS) + ERROR_TOO_MANY_SUGGESTIONS, + ERROR_SITE_IS_READONLY, + ERROR_SITE_IS_DISABLED) from api.miscellaneous.rate_limiter import IntervalRateLimiter, HostBasedCounterRateLimiter, create_configured_host_rate_limiters from api.miscellaneous.diagnostics import DIAGNOSTICS_TRACKER, DiagnosticsCounter, DiagnosticsDataProvider from api.miscellaneous.scheduler import scheduled_function diff --git a/src/api/miscellaneous/errors.py b/src/api/miscellaneous/errors.py index b088f4609a4535cfcb736d19d074725e0f757ac1..d2cdb08954aaf2099d2aba2349fab98245ae6dc5 100644 --- a/src/api/miscellaneous/errors.py +++ b/src/api/miscellaneous/errors.py @@ -67,6 +67,10 @@ def ERROR_OBJECT_ERROR(message: str) -> ApiError: 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, + "The site is currently in read-only mode") +ERROR_SITE_IS_DISABLED = ApiError("site_is_disabled", HTTP_503_SERVICE_UNAVAILABLE, + "The site is currently disabled") ALL_ERRORS_RANDOM = [ ERROR_BAD_REQUEST(), @@ -85,5 +89,7 @@ ALL_ERRORS_RANDOM = [ ERROR_RATE_LIMITED, ERROR_UNKNOWN_OBJECT, ERROR_OBJECT_ERROR("?"), - ERROR_TOO_MANY_SUGGESTIONS -] \ No newline at end of file + ERROR_TOO_MANY_SUGGESTIONS, + ERROR_SITE_IS_READONLY, + ERROR_SITE_IS_DISABLED +] diff --git a/src/api/routes/site.py b/src/api/routes/site.py index 4f9de73f70d360e07b5d2b615f5820a06d7afbad..57992c77029c8c2e81b470865d4790517f0a01d0 100644 --- a/src/api/routes/site.py +++ b/src/api/routes/site.py @@ -1,80 +1,46 @@ -from datetime import datetime, date, timedelta +from datetime import date, timedelta from api.routes import * from api.course import (COURSE_SECONDARY_DB_SELECTION, course_db_to_json_no_lectures, course_secondary_db_to_json_no_lectures, lecture_db_to_json_no_chapters_media, lecture_queue_query_auth, COURSE_SECONDARY_COLUMN_EMBED_INVISIBLE) +from api.announcement import query_announcements + +_GIT_COMMIT_HASH = os.environ.get("VIDEOAG_API_GIT_COMMIT_HASH", None) +if _GIT_COMMIT_HASH is not None and len(_GIT_COMMIT_HASH) == 0: + _GIT_COMMIT_HASH = None @api_route("/status", ["GET"]) def api_route_status(): - # noinspection PyBroadException - try: - with db_pool.start_read_transaction() as trans: - trans.execute_statement_and_close("SELECT NULL") - except Exception: - return { - "status": "unavailable" - }, HTTP_503_SERVICE_UNAVAILABLE - return { - "status": "running" + status = "available" + announcements = [] + announcements.extend(api.live_config.get_static_announcements()) + + if api.live_config.is_disabled(): + status = "unavailable" + else: + if api.live_config.is_readonly(): + status = "readonly" + # noinspection PyBroadException + try: + announcements.extend(query_announcements()) + except Exception: + status = "unavailable" + response = { + "status": status, + "is_debug": DEBUG_ENABLED, + "announcements": announcements } + if _GIT_COMMIT_HASH is not None: + response["git_commit_hash"] = _GIT_COMMIT_HASH + return response -_SQL_GET_ANNOUNCEMENTS = PreparedStatement(""" -SELECT * FROM "announcements" -WHERE NOT "deleted" - AND (? OR ( - "visible" - AND (("time_expire" IS NULL) OR "time_expire" > ?) - AND (("time_publish" IS NULL) OR "time_publish" <= ?) - )) -""") - - -@api_route("/announcements", ["GET"]) -def api_route_announcements(): - is_mod = is_moderator() - current_time: datetime = datetime.now() - with db_pool.start_read_transaction() as trans: - announcements_db = trans.execute_statement_and_close( - _SQL_GET_ANNOUNCEMENTS, is_mod, current_time, current_time) - - announcements_json = [] - for announcement_db in announcements_db: - announcement_json = { - "id": announcement_db["id"], - "text": announcement_db["text"] - } - if is_mod: - time_publish = announcement_db["time_publish"] - time_expire = announcement_db["time_expire"] - has_not_expired = time_expire is None or time_expire > current_time - announcement_json["is_currently_visible"] = (announcement_db["visible"] - and (time_publish is None or time_publish <= current_time) - and has_not_expired) - announcement_json["has_expired"] = not has_not_expired - announcements_json.append(announcement_json) - level_db: int = announcement_db["level"] - if level_db == 0: - announcement_json["type"] = "info" - announcement_json["visibility"] = "only_main_page" - elif level_db == 1: - announcement_json["type"] = "info" - announcement_json["visibility"] = "all_pages" - elif level_db == 2: - announcement_json["type"] = "warning" - announcement_json["visibility"] = "all_pages" - elif level_db == 3: - announcement_json["type"] = "important" - announcement_json["visibility"] = "all_pages_and_embed" - else: - raise Exception("Unknown announcement level: " + str(level_db)) - - return { - "announcements": announcements_json - } +@api_route("/is_running", ["GET"]) +def api_route_is_running(): + return {}, HTTP_200_OK _SQL_GET_UPCOMING_LECTURES = PreparedStatement(f"""