From 9cd40a71e805a2042f8ef370b1be1b720e09ca9f Mon Sep 17 00:00:00 2001 From: Samuel Clay Date: Fri, 25 Apr 2025 18:18:22 -0700 Subject: [PATCH] Switching to key-based APNS cert. --- Makefile | 4 +++ apps/notifications/models.py | 68 +++++++++++++++++++++++------------- config/requirements.txt | 8 +---- utils/pipeline_utils.py | 22 ++++++++++++ 4 files changed, 70 insertions(+), 32 deletions(-) diff --git a/Makefile b/Makefile index f6324ccc5..f9026d717 100644 --- a/Makefile +++ b/Makefile @@ -77,6 +77,10 @@ lint: docker exec -it newsblur_web isort --profile black . docker exec -it newsblur_web black --line-length 110 . docker exec -it newsblur_web flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics --exclude=venv + +deps: + docker exec -t newsblur_web pip install -U uv + docker exec -t newsblur_web uv pip install -r requirements.txt jekyll_build: cd blog && JEKYLL_ENV=production bundle exec jekyll build diff --git a/apps/notifications/models.py b/apps/notifications/models.py index 11905077e..e169cc113 100644 --- a/apps/notifications/models.py +++ b/apps/notifications/models.py @@ -7,9 +7,8 @@ import urllib.parse import mongoengine as mongo import redis -from apns2.client import APNsClient -from apns2.errors import BadDeviceToken, DeviceTokenNotForTopic, Unregistered -from apns2.payload import Payload +from pyapns_client import APNSClient, IOSPayloadAlert, IOSPayload, IOSNotification +from pyapns_client import APNSDeviceException, APNSServerException, APNSProgrammingException, UnregisteredException from bs4 import BeautifulSoup from django.conf import settings from django.contrib.auth.models import User @@ -306,7 +305,7 @@ class MUserFeedNotification(mongo.Document): # 4. Save the key file to secrets/certificates/ios/apns_key.p8 # 5. Note your Team ID and Key ID # 6. Deploy: aps -l work -t apns,repo,celery - + # Legacy certificate method (kept for reference): # 0. Upgrade to latest openssl: brew install openssl # 1. Create certificate signing request in Keychain Access @@ -321,22 +320,24 @@ class MUserFeedNotification(mongo.Document): # 7. cat aps.pem aps_key.noenc.pem > aps.p12.pem # 8. Verify: openssl s_client -connect gateway.push.apple.com:2195 -cert aps.p12.pem # 9. Deploy: aps -l work -t apns,repo,celery - - # Using token-based authentication (modern method) + + # Using token-based authentication (modern method with pyapns-client) key_file_path = "/srv/newsblur/config/certificates/apns_key.p8" - apns = APNsClient( - team_id=settings.APNS_TEAM_ID, - auth_key_id=settings.APNS_KEY_ID, - auth_key_path=key_file_path, - use_sandbox=tokens.use_sandbox - ) notification_title_only = is_true(user.profile.preference_value("notification_title_only")) title, subtitle, body = self.title_and_body(story, usersub, notification_title_only) image_url = None if len(story["image_urls"]): image_url = story["image_urls"][0] - # print image_url + + # Create APNS client + apns = APNSClient( + mode=APNSClient.MODE_DEV if tokens.use_sandbox else APNSClient.MODE_PROD, + root_cert_path=None, + auth_key_path=key_file_path, + auth_key_id=settings.APNS_KEY_ID, + team_id=settings.APNS_TEAM_ID + ) confirmed_ios_tokens = [] for token in tokens.ios_tokens: @@ -345,27 +346,44 @@ class MUserFeedNotification(mongo.Document): "~BMStory notification by iOS: ~FY~SB%s~SN~BM~FY/~SB%s" % (story["story_title"][:50], usersub.feed.feed_title[:50]), ) - payload = Payload( - alert={"title": title, "subtitle": subtitle, "body": body}, + + # Create payload using helper classes + alert = IOSPayloadAlert(title=title, subtitle=subtitle, body=body) + custom_data = { + "story_hash": story["story_hash"], + "story_feed_id": story["story_feed_id"], + } + if image_url: + custom_data["image_url"] = image_url + + payload = IOSPayload( + alert=alert, + custom=custom_data, category="STORY_CATEGORY", - mutable_content=True, - custom={ - "story_hash": story["story_hash"], - "story_feed_id": story["story_feed_id"], - "image_url": image_url, - }, + mutable_content=image_url is not None ) + notification = IOSNotification(payload=payload, topic="com.newsblur.NewsBlur") + try: - apns.send_notification(token, payload, topic="com.newsblur.NewsBlur") - except (BadDeviceToken, Unregistered, DeviceTokenNotForTopic): - logging.user(user, "~BMiOS token expired: ~FR~SB%s" % (token[:50])) - else: + apns.push(notification=notification, device_token=token) confirmed_ios_tokens.append(token) if settings.DEBUG: logging.user( user, "~BMiOS token good: ~FB~SB%s / %s" % (token[:50], len(confirmed_ios_tokens)), ) + except UnregisteredException as e: + logging.user(user, "~BMiOS token unregistered: ~FR~SB%s (since %s)" % (token[:50], e.timestamp_datetime)) + except APNSDeviceException as e: + logging.user(user, "~BMiOS token invalid: ~FR~SB%s" % (token[:50])) + except APNSServerException as e: + logging.user(user, "~BMiOS notification server error: ~FR~SB%s - %s" % (token[:50], str(e))) + except APNSProgrammingException as e: + logging.user(user, "~BMiOS notification programming error: ~FR~SB%s - %s" % (token[:50], str(e))) + except Exception as e: + logging.user(user, "~BMiOS notification error: ~FR~SB%s - %s" % (token[:50], str(e))) + finally: + apns.close() if len(confirmed_ios_tokens) < len(tokens.ios_tokens): tokens.ios_tokens = confirmed_ios_tokens diff --git a/config/requirements.txt b/config/requirements.txt index 8dbed8214..9b8249dd2 100644 --- a/config/requirements.txt +++ b/config/requirements.txt @@ -1,5 +1,4 @@ amqp==2.6.1 -apns2==0.7.2 appdirs==1.4.4 asgiref==3.3.4 attrs==21.1.0 @@ -46,13 +45,8 @@ Flask-BasicAuth==0.2.0 future==0.18.2 gunicorn==21.2.0 gevent==22.10.2 -h2==2.6.2 hiredis==1.1.0 -hpack==3.0.0 httplib2==0.18.1 -httpx==0.27.2 -hyper==0.7.0 -hyperframe==3.2.0 idna==2.10 image==1.5.33 iniconfig==1.1.1 @@ -81,9 +75,9 @@ pluggy==0.13.1 psutil==5.7.3 psycopg2==2.9.2 py==1.10.0 +pyapns-client==2.0.6 pyasn1==0.4.8 pycparser==2.20 -PyJWT==1.7.1 pymongo>=3,<4 PyMySQL==0.10.1 pynliner==0.8.0 diff --git a/utils/pipeline_utils.py b/utils/pipeline_utils.py index c400e3b92..a7a8d2cb2 100644 --- a/utils/pipeline_utils.py +++ b/utils/pipeline_utils.py @@ -61,6 +61,17 @@ class AppDirectoriesFinder(PipelineAppDirectoriesFinder): "*LICENSE*", "*README*", ] + + def find_files(self, storage, path=None, all=False): + """ + Override to properly handle wildcard patterns like 'underscore-*.js' + """ + path = path or '' + for pattern in self.find_pattern_matches(path): + for path in storage.listdir(pattern[0])[1]: + if self.is_ignored(path, pattern[0]): + continue + yield path, storage class FileSystemFinder(PipelineFileSystemFinder): @@ -115,3 +126,14 @@ class FileSystemFinder(PipelineFileSystemFinder): # 'Gemfile*', "node_modules", ] + + def find_files(self, storage, path=None, all=False): + """ + Override to properly handle wildcard patterns like 'underscore-*.js' + """ + path = path or '' + for pattern in self.find_pattern_matches(path): + for path in storage.listdir(pattern[0])[1]: + if self.is_ignored(path, pattern[0]): + continue + yield path, storage