Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added .coverage
Binary file not shown.
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
12 changes: 6 additions & 6 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,16 +24,16 @@ jobs:
uses: actions/checkout@v4

- name: Initialize CodeQL
uses: github/codeql-action/init@v3
uses: github/codeql-action/init@v4
with:
languages: python
queries: security-extended,security-and-quality

- name: Autobuild
uses: github/codeql-action/autobuild@v3
uses: github/codeql-action/autobuild@v4

- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
uses: github/codeql-action/analyze@v4
with:
category: "/language:python"

Expand All @@ -47,15 +47,15 @@ jobs:
uses: actions/checkout@v4

- name: Initialize CodeQL
uses: github/codeql-action/init@v3
uses: github/codeql-action/init@v4
with:
languages: javascript-typescript
queries: security-extended,security-and-quality

- name: Autobuild
uses: github/codeql-action/autobuild@v3
uses: github/codeql-action/autobuild@v4

- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
uses: github/codeql-action/analyze@v4
with:
category: "/language:javascript-typescript"
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 @@ -126,7 +127,7 @@ jobs:

- name: Upload Trivy results to GitHub Security
if: github.event_name != 'pull_request'
uses: github/codeql-action/upload-sarif@v3
uses: github/codeql-action/upload-sarif@v4
continue-on-error: true
with:
sarif_file: 'trivy-results.sarif'
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ jobs:
output: 'trivy-results.sarif'

- name: Upload Trivy results to GitHub Security
uses: github/codeql-action/upload-sarif@v3
uses: github/codeql-action/upload-sarif@v4
with:
sarif_file: 'trivy-results.sarif'

Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/scheduled-security-scan.yml
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ jobs:
severity: 'CRITICAL,HIGH'

- name: Upload Trivy results to GitHub Security
uses: github/codeql-action/upload-sarif@v3
uses: github/codeql-action/upload-sarif@v4
with:
sarif_file: 'trivy-results.sarif'

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
8 changes: 6 additions & 2 deletions backend/app/controllers/event_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,13 @@ def list_events(self, request):

def create_event(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")
if not data or not isinstance(data, dict):
raise ValueError("Payload must be a JSON object")

details = data.get("details", {})
if details is not None and not isinstance(details, dict):
Expand Down
8 changes: 6 additions & 2 deletions backend/app/controllers/url_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,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 All @@ -23,8 +27,8 @@ def create_url(self, request):
return self.handle_success(json.loads(cached_resp), 201)

data = request.get_json()
if not data:
raise ValueError("Payload cannot be empty")
if not data or not isinstance(data, dict):
raise ValueError("Payload must be a JSON object")

schema = CreateUrlSchema(**data)
url = self.url_service.create_url(
Expand Down
8 changes: 6 additions & 2 deletions backend/app/controllers/user_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,13 @@ 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")
if not data or not isinstance(data, dict):
raise ValueError("Payload must be a JSON object")

schema = CreateUserSchema(**data)
user = self.user_service.create_user(
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,
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
23 changes: 22 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 Expand Up @@ -465,6 +474,18 @@ def test_create_event_requires_payload(self, app):
assert status == 400
assert response.get_json()["error"] == "bad_request"

def test_create_event_rejects_string_payload(self, app):
controller = EventController(MagicMock())

with app.app_context():
response, status = controller.create_event(
_request("just a string, not a chest")
)

assert status == 400
assert response.get_json()["error"] == "bad_request"
controller.event_service.create_event.assert_not_called()


def test_base_controller_handles_pydantic_error(app):
controller = UserController(MagicMock())
Expand Down
Loading