Members
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"])
|
||||
|
||||
Reference in New Issue
Block a user