diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 47b9c1e..eb5589b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,9 @@ on: pull_request: workflow_call: +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + permissions: contents: read diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index b0afa44..312b7b5 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -11,6 +11,7 @@ on: env: REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }} + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" permissions: contents: read @@ -83,7 +84,7 @@ jobs: - name: Log in to Container Registry if: github.event_name != 'pull_request' - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ed663bb..18a3f25 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -90,7 +90,7 @@ jobs: uses: docker/setup-buildx-action@v3 - name: Log in to GitHub Container Registry - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.actor }} diff --git a/backend/app/controllers/base_controller.py b/backend/app/controllers/base_controller.py index d92ccd9..91227d7 100644 --- a/backend/app/controllers/base_controller.py +++ b/backend/app/controllers/base_controller.py @@ -12,6 +12,18 @@ class BaseController: def handle_success(self, data, status_code=200): return jsonify(data), status_code + def require_json(self, request): + """Return a 415 response tuple if Content-Type is not application/json, else None.""" + content_type = getattr(request, "content_type", None) or "" + if "application/json" not in content_type: + return jsonify( + { + "error": "unsupported_media_type", + "message": "Content-Type must be application/json.", + } + ), 415 + return None + def handle_error(self, error, operation_name): error_type = type(error).__name__ msg = str(error) diff --git a/backend/app/controllers/url_controller.py b/backend/app/controllers/url_controller.py index fcc4329..f92051a 100644 --- a/backend/app/controllers/url_controller.py +++ b/backend/app/controllers/url_controller.py @@ -16,6 +16,10 @@ def set_config(self, config): def create_url(self, request): try: + err = self.require_json(request) + if err: + return err + idempotency_key = request.headers.get("Idempotency-Key") if idempotency_key: cache_key = f"idempotency:url:{idempotency_key}" diff --git a/backend/app/controllers/user_controller.py b/backend/app/controllers/user_controller.py index 21fe67a..363e0e0 100644 --- a/backend/app/controllers/user_controller.py +++ b/backend/app/controllers/user_controller.py @@ -18,6 +18,10 @@ def set_config(self, config): def create_user(self, request): try: + err = self.require_json(request) + if err: + return err + data = request.get_json() if not data: raise ValueError("Payload cannot be empty") diff --git a/backend/app/models/event_model.py b/backend/app/models/event_model.py index 11db164..f5b45b3 100644 --- a/backend/app/models/event_model.py +++ b/backend/app/models/event_model.py @@ -21,7 +21,11 @@ class Event(BaseModel): id = BigAutoField() url_id = ForeignKeyField( - ShortURL, backref="events", column_name="url_id", index=True + ShortURL, + backref="events", + column_name="url_id", + index=True, + on_delete="CASCADE", ) user_id = ForeignKeyField( User, backref="events", column_name="user_id", null=True, index=True diff --git a/backend/app/routes/link_routes.py b/backend/app/routes/link_routes.py index e23544c..ac363c5 100644 --- a/backend/app/routes/link_routes.py +++ b/backend/app/routes/link_routes.py @@ -1,5 +1,6 @@ from flask import Blueprint, current_app, jsonify, redirect +from backend.app.config.errors import ForbiddenError, NotFoundError from backend.app.services.url_service import UrlService links_bp = Blueprint("links", __name__) @@ -16,7 +17,15 @@ def _get_service(): @links_bp.get("/") def follow_short_link(code): - destination = _get_service().resolve_redirect(code) + try: + destination = _get_service().resolve_redirect(code) + except ForbiddenError: + return jsonify( + {"error": "gone", "message": "This link has been deactivated."} + ), 410 + except NotFoundError: + return jsonify({"error": "not_found", "message": "Link not found."}), 404 + if not destination: return jsonify( {"error": "not_found", "message": "Link not found or inactive"} diff --git a/backend/app/validators/schemas.py b/backend/app/validators/schemas.py index 6e9873b..109c503 100644 --- a/backend/app/validators/schemas.py +++ b/backend/app/validators/schemas.py @@ -10,12 +10,24 @@ class CreateUserSchema(BaseModel): model_config = ConfigDict(str_strip_whitespace=True) + @field_validator("username", mode="before") + @classmethod + def username_must_be_string(cls, v) -> str: + if not isinstance(v, str): + raise ValueError("username must be a string, not a number or other type") + return v + + @field_validator("email", mode="before") + @classmethod + def email_must_be_string(cls, v) -> str: + if not isinstance(v, str): + raise ValueError("email must be a string, not a number or other type") + return v + @field_validator("email") @classmethod def validate_email(cls, v: str) -> str: - if not re.match( - r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$", v - ): # TODO: Revisit this regex + if not re.match(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$", v): raise ValueError( "value is not a valid email address: An email address must have an @-sign." ) diff --git a/tests/unit/test_controllers.py b/tests/unit/test_controllers.py index edb1dc3..67d0115 100644 --- a/tests/unit/test_controllers.py +++ b/tests/unit/test_controllers.py @@ -17,11 +17,20 @@ from backend.app.validators.schemas import CreateUserSchema -def _request(payload=None, *, args=None, headers=None, files=None, bad_json=False): +def _request( + payload=None, + *, + args=None, + headers=None, + files=None, + bad_json=False, + content_type="application/json", +): req = MagicMock() req.args = MultiDict(args or {}) req.headers = headers or {} req.files = files or {} + req.content_type = content_type if bad_json: req.get_json.side_effect = BadRequest("malformed json") else: