Skip to content
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ on:
pull_request:
workflow_call:

env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"

permissions:
contents: read

Expand Down
3 changes: 2 additions & 1 deletion .github/workflows/docker-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ on:
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"

permissions:
contents: read
Expand Down Expand Up @@ -91,7 +92,7 @@ jobs:

- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@v6
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
Expand Down
12 changes: 12 additions & 0 deletions backend/app/controllers/base_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions backend/app/controllers/url_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand Down
4 changes: 4 additions & 0 deletions backend/app/controllers/user_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
6 changes: 5 additions & 1 deletion backend/app/models/event_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 10 additions & 1 deletion backend/app/routes/link_routes.py
Original file line number Diff line number Diff line change
@@ -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__)
Expand All @@ -16,7 +17,15 @@ def _get_service():

@links_bp.get("/<string:code>")
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"}
Expand Down
18 changes: 15 additions & 3 deletions backend/app/validators/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."
)
Expand Down
11 changes: 10 additions & 1 deletion tests/unit/test_controllers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading