Skip to content
Merged
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
11 changes: 7 additions & 4 deletions amplify.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,14 @@ applications:
VITE_VALIDATOR_RPC_URL: https://rpc.testnet-chain.genlayer.com
customRules:
# Campaign vanity links: reverse-proxy the reserved /join/ namespace to
# the Django resolver. Must stay BEFORE the SPA catch-all (rules apply
# in order). One dynamic rule for all campaigns; never add per-campaign
# rules here.
# the Django backend, which serves /join/<role>/<alias> directly. Must
# stay BEFORE the SPA catch-all (rules apply in order). One dynamic rule
# for all campaigns; never add per-campaign rules here.
# NOTE: production is served by CloudFront + S3, not Amplify; there the
# equivalent is a CloudFront behavior for /join/* with the backend as
# origin (caching disabled, query strings forwarded).
- source: '/join/<*>'
target: 'https://tally-backend.33qpgck0g28d0.us-east-1.cs.amazonlightsail.com/campaigns/redirect/<*>'
target: 'https://portal-admin.genlayer.foundation/join/<*>'
status: '200'
- source: '</^[^.]+$|\.(?!(css|gif|ico|jpg|js|png|txt|svg|woff|woff2|ttf|map|json|webp)$)([^.]+$)/>'
target: '/index.html'
Expand Down
7 changes: 4 additions & 3 deletions backend/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ backend/

### Campaigns (Marketing Vanity Links + Attribution)

- **App**: `campaigns/`. Marketing creates campaigns and role links in Django admin; no deploy per campaign. Public URL contract: `{FRONTEND_URL}/join/<builders|validators|community>/<alias>`, reverse-proxied by an Amplify rule (`amplify.yml`, before the SPA catch-all) to `GET /campaigns/redirect/<role>/<alias>`.
- **App**: `campaigns/`. Marketing creates campaigns and role links in Django admin; no deploy per campaign. Public URL contract: `{FRONTEND_URL}/join/<builders|validators|community>/<alias>`. The backend serves `/join/<role>/<alias>` directly (`tally/urls.py`) as well as the internal `/campaigns/redirect/<role>/<alias>`, so the portal CDN only needs a pass-through: production is CloudFront + S3 (behavior for `/join/*` with the backend origin `portal-admin.genlayer.foundation`, methods GET/HEAD, cache policy CachingDisabled, origin request policy AllViewerExceptHostHeader so query strings are forwarded); the `amplify.yml` rule covers Amplify-hosted environments only.
- **Models** (`campaigns/models.py`):
- `MarketingCampaign` - name, unique `tracking_key` (published as utm_campaign, readonly after create), date window, `is_active`, `created_by`.
- `CampaignLink` - FK campaign, server-generated immutable `tracking_id` (published as utm_id), role, alias (UNIQUE role+alias, locked after create), `destination_path` (validated relative portal path: allowlist + reserved-prefix rejection in `validate_destination_path`, re-run by the resolver so corrupt data fails closed), required utm_source/utm_medium, optional content/term, optional window overrides. `redirect_target` builds the UTM query from stored fields only.
Expand Down Expand Up @@ -543,8 +543,9 @@ GET /api/v1/notifications/unread-count/ (requires auth)
POST /api/v1/notifications/{id}/mark-read/ (requires auth)
POST /api/v1/notifications/mark-all-read/ (requires auth)

# Campaign vanity links (public; proxied from portal /join/<role>/<alias> by Amplify)
GET /campaigns/redirect/{role}/{alias} (anonymous, 302 with UTMs, throttled 120/min)
# Campaign vanity links (public; the portal CDN passes /join/* through to the backend)
GET /join/{role}/{alias} (anonymous GET/HEAD, 302 with UTMs, throttled 120/min)
GET /campaigns/redirect/{role}/{alias} (same view; original internal path)
```

### Leaderboard monthly date ranges
Expand Down
13 changes: 13 additions & 0 deletions backend/campaigns/tests/test_resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,19 @@ def test_trailing_slash_also_resolves(self):
response = self._get('/campaigns/redirect/builders/ethcc/')
self.assertEqual(response.status_code, 302)

def test_public_join_path_resolves_directly(self):
# The backend serves the public /join contract itself so the portal
# CDN only needs a pass-through, no edge URL rewriting.
response = self._get('/join/builders/ethcc')
self.assertEqual(response.status_code, 302)
self.assertIn(f'utm_id={self.link.tracking_id}', response['Location'])
self.assertEqual(response['Cache-Control'], 'no-store')
self.assertEqual(self._get('/join/builders/ethcc/').status_code, 302)
head = self.client.head('/join/builders/ethcc', HTTP_USER_AGENT=BROWSER_UA)
self.assertEqual(head.status_code, 302)
self.assertEqual(head['Cache-Control'], 'no-store')
self.assertEqual(self._get('/join/builders/nope').status_code, 404)

def test_head_request_works(self):
response = self.client.head('/campaigns/redirect/builders/ethcc', HTTP_USER_AGENT=BROWSER_UA)
self.assertEqual(response.status_code, 302)
Expand Down
14 changes: 12 additions & 2 deletions backend/tally/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
from django.contrib import admin
from django.urls import path, include
from django.urls import path, include, re_path

from campaigns.views import campaign_redirect
from drf_yasg.views import get_schema_view
from drf_yasg import openapi
from rest_framework import permissions
Expand Down Expand Up @@ -77,7 +79,15 @@ def csrf_token(request):
# Contributions app (includes both API and staff views)
path('contributions/', include('contributions.urls')),

# Marketing campaign vanity-link resolver (Amplify proxies /join/<*> here)
# Marketing campaign vanity-link resolver. The backend answers the public
# /join/<role>/<alias> contract directly so the portal CDN only needs a
# plain /join/* pass-through behavior (no edge URL rewriting);
# /campaigns/redirect/... remains as the original internal path.
re_path(
r'^join/(?P<role>[A-Za-z]+)/(?P<alias>[A-Za-z0-9\-]+)/?$',
campaign_redirect,
name='campaign_join',
),
path('campaigns/', include('campaigns.urls')),

# API documentation
Expand Down
Loading