This commit is contained in:
2026-10-30 14:48:57 -04:00
parent 06cfb9367d
commit 0e342734a6
7 changed files with 118 additions and 29 deletions
+1 -1
View File
@@ -9,5 +9,5 @@ class SecurityHeaders:
# CSRF checks. no-referrer can produce Origin: null in browsers.
# same-origin still withholds verification URLs from external sites.
response["Referrer-Policy"] = "same-origin"
response["Content-Security-Policy"] = "default-src 'none'; style-src 'self'; img-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'"
response["Content-Security-Policy"] = "default-src 'none'; style-src 'self'; img-src 'self' https://discourse.eom.dev https://gitea.eom.dev https://element.eom.dev https://mastodon.eom.dev https://peertube.eom.dev; form-action 'self'; base-uri 'none'; frame-ancestors 'none'"
return response
+57
View File
@@ -7,6 +7,30 @@ from ldap3.core.exceptions import LDAPInvalidDnError
from ldap3.utils.dn import parse_dn
SERVICE_LINKS = {
"Discourse · community forums": {
"url": "https://discourse.eom.dev/",
"favicon": "https://discourse.eom.dev/uploads/default/optimized/1X/_129430568242d1b7f853bb13ebea28b3f6af4e7_2_32x32.png",
},
"Gitea · Git hosting": {
"url": "https://gitea.eom.dev/",
"favicon": "https://gitea.eom.dev/assets/img/favicon.svg",
},
"Matrix · community chat": {
"url": "https://element.eom.dev/",
"favicon": "https://element.eom.dev/vector-icons/144.5a63bf2.png",
},
"Mastodon · social networking": {
"url": "https://mastodon.eom.dev/",
"favicon": "https://mastodon.eom.dev/packs/assets/favicon-48x48-DMnduFKh.png",
},
"PeerTube · video sharing": {
"url": "https://peertube.eom.dev/",
"favicon": "https://peertube.eom.dev/client/assets/images/favicon.png",
},
}
def validate_service_groups(value, paid_levels):
if not isinstance(value, dict):
raise ImproperlyConfigured("Membership service groups must be an object mapping levels to lists of LDAP group DNs.")
@@ -51,6 +75,8 @@ def service_names(level, service_groups):
for dn in service_groups.get(str(level), []):
# Show only a group's common name, never its directory hierarchy.
common_name = next((value for attribute, value, _separator in parse_dn(dn) if attribute.casefold() == "cn"), "")
if common_name.casefold() == "dex users":
continue
if not common_name:
name = "Additional community service"
else:
@@ -61,3 +87,34 @@ def service_names(level, service_groups):
if name not in names:
names.append(name)
return names
def service_access_catalog(tiers, service_groups):
"""List each advertised service once, with the levels that include it."""
level_labels = {0: "Open Lab"}
level_labels.update({tier["level"]: tier["label"] for tier in tiers.values()})
levels = sorted(level_labels)
service_levels = {}
for level in levels:
for name in service_names(level, service_groups):
service_levels.setdefault(name, []).append(level)
catalog = []
for name, included_levels in service_levels.items():
if included_levels == levels:
access = "Every membership level"
else:
ranges = []
start = previous = included_levels[0]
for level in included_levels[1:]:
if level != previous + 1:
ranges.append((start, previous))
start = level
previous = level
ranges.append((start, previous))
access = ", ".join(
level_labels[first] if first == last else f"{level_labels[first]}–{level_labels[last]}"
for first, last in ranges
)
catalog.append({"name": name, "access": access, **SERVICE_LINKS.get(name, {})})
return catalog
@@ -15,7 +15,7 @@ from django.utils import timezone
from members import billing, views
from members.models import Account, CryptoInvoice, ManagedGroup, Subscription
from members.service_groups import configured_groups, service_names, validate_service_groups
from members.service_groups import configured_groups, service_access_catalog, service_names, validate_service_groups
from ldap3 import Connection, MOCK_SYNC, Server
BASE = "ou=groups,dc=example,dc=org"
@@ -93,6 +93,8 @@ class ServiceDisplayTests(TestCase):
for key, tier in tiers.items():
self.assertEqual(tier["description"], deployed[key]["description"])
self.assertEqual(tier["benefits"], deployed[key]["benefits"])
self.assertIn(f"{tier['label']}-tier giveaways", tier["benefits"])
self.assertFalse(any("exclusive to" in benefit.casefold() for benefit in tier["benefits"]))
def test_homepage_lists_services_with_clear_access_labels(self):
root = Path(__file__).absolute().parents[3]
@@ -142,13 +144,38 @@ class ServiceDisplayTests(TestCase):
def test_home_displays_exact_level_services_without_ldap_or_settings_mutation(self):
with patch("members.directory.connection", side_effect=AssertionError("Public page must not contact LDAP")):
response = self.client.get("/members/")
self.assertContains(response, "Dex · single sign-on")
self.assertEqual(response.context["free_services"], ["Dex · single sign-on", "Matrix · community chat"])
self.assertEqual(response.context["tiers"]["supporter"]["services"], ["Gitea · Git hosting"])
self.assertEqual(response.context["tiers"]["patron"]["services"], ["Mastodon · social networking", "PeerTube · video sharing"])
self.assertEqual(response.context["tiers"]["premier"]["services"], [])
catalog = response.context["services"]
self.assertEqual(catalog, [
{"name": "Matrix · community chat", "access": "Open Lab", "url": "https://element.eom.dev/", "favicon": "https://element.eom.dev/vector-icons/144.5a63bf2.png"},
{"name": "Gitea · Git hosting", "access": "Bronze", "url": "https://gitea.eom.dev/", "favicon": "https://gitea.eom.dev/assets/img/favicon.svg"},
{"name": "Mastodon · social networking", "access": "Silver", "url": "https://mastodon.eom.dev/", "favicon": "https://mastodon.eom.dev/packs/assets/favicon-48x48-DMnduFKh.png"},
{"name": "PeerTube · video sharing", "access": "Silver", "url": "https://peertube.eom.dev/", "favicon": "https://peertube.eom.dev/client/assets/images/favicon.png"},
])
self.assertNotContains(response, "Dex · single sign-on")
self.assertContains(response, 'href="https://element.eom.dev/"')
self.assertContains(response, 'alt="" width="32" height="32"')
self.assertIn("https://element.eom.dev", response["Content-Security-Policy"])
self.assertEqual(response.content.count(b"Matrix \xc2\xb7 community chat"), 1)
self.assertContains(response, "Plus all previous benefits")
self.assertNotContains(response, "except tier-exclusive giveaways")
self.assertContains(response, "Support independent projects and the community</li><li>Plus all previous benefits</li>")
self.assertContains(response, "How do membership giveaways work?")
self.assertContains(response, 'href="https://element.eom.dev/"')
self.assertContains(response, 'src="https://element.eom.dev/vector-icons/144.5a63bf2.png"')
self.assertIn("https://element.eom.dev", response["Content-Security-Policy"])
self.assertNotContains(response, BASE)
self.assertNotIn("services", TIERS["supporter"])
def test_service_catalog_collapses_services_shared_by_every_level(self):
tiers = {"bronze": {"level": 1, "label": "Bronze"}, "gold": {"level": 3, "label": "Gold"}}
groups = {
"0": ["cn=Matrix Users," + BASE],
"1": ["cn=Matrix Users," + BASE],
"3": ["cn=Matrix Users," + BASE, "cn=Mastodon Users," + BASE],
}
self.assertEqual(service_access_catalog(tiers, groups), [
{"name": "Matrix · community chat", "access": "Every membership level", "url": "https://element.eom.dev/", "favicon": "https://element.eom.dev/vector-icons/144.5a63bf2.png"},
{"name": "Mastodon · social networking", "access": "Gold", "url": "https://mastodon.eom.dev/", "favicon": "https://mastodon.eom.dev/packs/assets/favicon-48x48-DMnduFKh.png"},
])
@override_settings(MEMBERSHIP_SERVICE_GROUPS={})
def test_unconfigured_services_do_not_advertise_access(self):
+4 -3
View File
@@ -26,7 +26,7 @@ from django.views.decorators.http import require_GET, require_POST, require_http
from . import billing, directory, profiles, shkeeper
from .forms import PasswordForm, RegistrationForm
from .service_groups import configured_groups, service_names
from .service_groups import configured_groups, service_access_catalog
from .profile_forms import CurrentPasswordForm, ProfileEmailForm, ProfileForm, ProfilePasswordForm
from .models import Account, CryptoInvoice, ManagedGroup, ProfileEmailChange, RateWindow, Registration, Subscription, WebhookEvent
@@ -77,8 +77,9 @@ def home(request):
subscriptions = Subscription.objects.filter(account=account) if account else []
invoices = CryptoInvoice.objects.filter(account=account).order_by("-created_at")[:20] if account else []
# Presentation copies only: never mutate authorization/payment settings.
tiers = {key: {**tier, "services": service_names(tier["level"], settings.MEMBERSHIP_SERVICE_GROUPS)} for key, tier in settings.MEMBERSHIP_TIERS.items()}
return page(request, account=account, tiers=tiers, free_services=service_names(0, settings.MEMBERSHIP_SERVICE_GROUPS), subscriptions=subscriptions, crypto_invoices=invoices, shkeeper_enabled=settings.SHKEEPER_ENABLED, now=timezone.now())
tiers = settings.MEMBERSHIP_TIERS
services = service_access_catalog(tiers, settings.MEMBERSHIP_SERVICE_GROUPS)
return page(request, account=account, tiers=tiers, services=services, subscriptions=subscriptions, crypto_invoices=invoices, shkeeper_enabled=settings.SHKEEPER_ENABLED, now=timezone.now())
@require_http_methods(["GET", "POST"])