From 99e288b00d8b085671fc8b6c6861b7dc6f90e55f Mon Sep 17 00:00:00 2001 From: JoaquinBN Date: Mon, 3 Aug 2026 14:06:09 +0200 Subject: [PATCH] Serve campaign vanity links directly from the backend (#964) * Serve campaign vanity links directly from the backend The backend now answers the public /join// contract itself, in addition to the internal /campaigns/redirect path. Production hosting is CloudFront + S3 rather than Amplify, so the CDN only needs a plain /join/* pass-through behavior pointing at the backend origin, with no edge URL rewriting. The Amplify rule (used only by Amplify-hosted environments) now targets the live backend domain instead of the retired Lightsail host. ## Claude Implementation Notes - backend/tally/urls.py: top-level re_path serving /join// via campaigns.views.campaign_redirect (name campaign_join); /campaigns/redirect/... unchanged - backend/campaigns/tests/test_resolver.py: /join path resolves with correct utm_id and 404s unknown aliases - amplify.yml: /join/<*> target updated to https://portal-admin.genlayer.foundation/join/<*> (old Lightsail host is dead); comment documents the CloudFront equivalent for production - backend/CLAUDE.md: resolver docs reflect the direct /join route and the CloudFront + S3 production setup * Complete the public join-route contract in docs and tests The public /join redirect test now asserts the full contract (302 with Cache-Control no-store for both GET and HEAD, optional trailing slash, unknown alias 404), and the backend docs describe the exact CloudFront pass-through configuration and list the /join endpoint instead of the obsolete Amplify-proxy claim. ## Claude Implementation Notes - backend/campaigns/tests/test_resolver.py: join-path test asserts no-store on GET and HEAD, trailing slash, and 404 - backend/CLAUDE.md: endpoint summary lists GET /join/{role}/{alias} with the CDN pass-through note; campaigns section names the CloudFront methods (GET/HEAD), CachingDisabled cache policy, and AllViewerExceptHostHeader origin request policy --- amplify.yml | 11 +++++++---- backend/CLAUDE.md | 7 ++++--- backend/campaigns/tests/test_resolver.py | 13 +++++++++++++ backend/tally/urls.py | 14 ++++++++++++-- 4 files changed, 36 insertions(+), 9 deletions(-) diff --git a/amplify.yml b/amplify.yml index 56c8c5bf..a099e58a 100644 --- a/amplify.yml +++ b/amplify.yml @@ -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// 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: '' target: '/index.html' diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index 133fb126..70f04f3f 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -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//`, reverse-proxied by an Amplify rule (`amplify.yml`, before the SPA catch-all) to `GET /campaigns/redirect//`. +- **App**: `campaigns/`. Marketing creates campaigns and role links in Django admin; no deploy per campaign. Public URL contract: `{FRONTEND_URL}/join//`. The backend serves `/join//` directly (`tally/urls.py`) as well as the internal `/campaigns/redirect//`, 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. @@ -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// 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 diff --git a/backend/campaigns/tests/test_resolver.py b/backend/campaigns/tests/test_resolver.py index ee58800f..ea611cc9 100644 --- a/backend/campaigns/tests/test_resolver.py +++ b/backend/campaigns/tests/test_resolver.py @@ -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) diff --git a/backend/tally/urls.py b/backend/tally/urls.py index 10899a02..8e3e3b84 100644 --- a/backend/tally/urls.py +++ b/backend/tally/urls.py @@ -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 @@ -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// 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[A-Za-z]+)/(?P[A-Za-z0-9\-]+)/?$', + campaign_redirect, + name='campaign_join', + ), path('campaigns/', include('campaigns.urls')), # API documentation