feat: add admin endpoint to send digest emails for users
Some checks failed
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 1m37s
Some checks failed
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 1m37s
- Implemented a new endpoint `/admin/test/send-digest` in `admin_api.py` to trigger digest emails for specific users. - Added a script `send_digest.py` to facilitate sending digest emails via the admin API. - Enhanced the digest action handling in `digest_action_api.py` to support token peeking without consuming it. - Updated the `send_digests` function in `digest_scheduler.py` to utilize the new `send_digest_for_user` function for sending emails. - Introduced a new utility function `peek_token` in `digest_token.py` to validate tokens without consuming them. - Modified the `ParentView.vue` component to handle digest actions upon receiving a digest token in the URL. - Updated `.gitignore` to exclude sensitive certificate files.
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -8,3 +8,5 @@ backend/test-results/
|
|||||||
.vscode/keybindings.json
|
.vscode/keybindings.json
|
||||||
.DS_Store
|
.DS_Store
|
||||||
**/.DS_Store
|
**/.DS_Store
|
||||||
|
frontend/vue-app/cert.pem
|
||||||
|
frontend/vue-app/key.pem
|
||||||
|
|||||||
@@ -206,3 +206,44 @@ def create_test_digest_token():
|
|||||||
return jsonify({'token': token.id}), 200
|
return jsonify({'token': token.id}), 200
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({'error': str(e), 'code': 'SERVER_ERROR'}), 500
|
return jsonify({'error': str(e), 'code': 'SERVER_ERROR'}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@admin_api.route('/admin/test/send-digest', methods=['POST'])
|
||||||
|
def send_test_digest():
|
||||||
|
"""Trigger a digest email for a specific user by email address.
|
||||||
|
|
||||||
|
Only active when DB_ENV is not 'production'. Requires admin authentication.
|
||||||
|
Note: actual email delivery is skipped in e2e mode by email_sender.
|
||||||
|
"""
|
||||||
|
if os.environ.get('DB_ENV') == 'production':
|
||||||
|
return jsonify({'error': 'Not found', 'code': 'NOT_FOUND'}), 404
|
||||||
|
|
||||||
|
user_id = get_validated_user_id()
|
||||||
|
if not user_id:
|
||||||
|
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
||||||
|
|
||||||
|
# Verify caller is admin
|
||||||
|
caller_dict = users_db.get(Query().id == user_id)
|
||||||
|
if not caller_dict or caller_dict.get('role') != 'admin':
|
||||||
|
return jsonify({'error': 'Admin access required', 'code': 'ADMIN_REQUIRED'}), 403
|
||||||
|
|
||||||
|
data = request.get_json() or {}
|
||||||
|
email = data.get('email', '').strip().lower()
|
||||||
|
if not email:
|
||||||
|
return jsonify({'error': 'email is required', 'code': 'MISSING_FIELDS'}), 400
|
||||||
|
|
||||||
|
target = users_db.get(Query().email == email)
|
||||||
|
if not target:
|
||||||
|
return jsonify({'error': 'User not found', 'code': 'USER_NOT_FOUND'}), 404
|
||||||
|
|
||||||
|
target_id = target.get('id')
|
||||||
|
target_email = target.get('email')
|
||||||
|
|
||||||
|
try:
|
||||||
|
from flask import current_app
|
||||||
|
from utils.digest_scheduler import send_digest_for_user
|
||||||
|
frontend_url = current_app.config.get('FRONTEND_URL', 'https://localhost:5173')
|
||||||
|
items_sent = send_digest_for_user(target_id, target_email, frontend_url)
|
||||||
|
return jsonify({'items_sent': items_sent}), 200
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'error': str(e), 'code': 'SERVER_ERROR'}), 500
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
from flask import Blueprint, redirect, make_response
|
from flask import Blueprint, redirect, make_response, jsonify, request
|
||||||
from tinydb import Query
|
from tinydb import Query
|
||||||
|
|
||||||
from utils.digest_token import validate_and_consume_token, validate_unsubscribe_token
|
from utils.digest_token import validate_and_consume_token, validate_unsubscribe_token, peek_token
|
||||||
from db.db import users_db
|
from db.db import users_db
|
||||||
from api.child_action_helpers import approve_chore, reject_chore, approve_reward_request, deny_reward
|
from api.child_action_helpers import approve_chore, reject_chore, approve_reward_request, deny_reward
|
||||||
|
from api.utils import get_validated_user_id
|
||||||
|
|
||||||
digest_action_api = Blueprint('digest_action_api', __name__)
|
digest_action_api = Blueprint('digest_action_api', __name__)
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -34,45 +35,65 @@ _UNSUB_ERROR_HTML = """<!DOCTYPE html>
|
|||||||
@digest_action_api.route('/digest-action/<token_id>', methods=['GET'])
|
@digest_action_api.route('/digest-action/<token_id>', methods=['GET'])
|
||||||
def handle_digest_action(token_id: str):
|
def handle_digest_action(token_id: str):
|
||||||
"""
|
"""
|
||||||
Validate a digest action token and execute the corresponding action.
|
Validate a digest action token (without consuming it) and redirect to the
|
||||||
On success: 302 redirect to the ParentView deep link.
|
frontend ParentView with the token embedded so the action executes only
|
||||||
On failure: 400 HTML error page.
|
after the user authenticates as a parent.
|
||||||
"""
|
"""
|
||||||
from flask import current_app
|
from flask import current_app
|
||||||
frontend_url = current_app.config.get('FRONTEND_URL', 'https://localhost:5173')
|
frontend_url = current_app.config.get('FRONTEND_URL', 'https://localhost:5173')
|
||||||
|
|
||||||
token = validate_and_consume_token(token_id)
|
token = peek_token(token_id)
|
||||||
if not token:
|
if not token:
|
||||||
return make_response(_ERROR_HTML, 400)
|
return make_response(_ERROR_HTML, 400)
|
||||||
|
|
||||||
user_id = token.user_id
|
|
||||||
child_id = token.child_id
|
|
||||||
entity_id = token.entity_id
|
|
||||||
entity_type = token.entity_type
|
|
||||||
action = token.action
|
|
||||||
|
|
||||||
try:
|
|
||||||
if entity_type == 'chore' and action == 'approve':
|
|
||||||
approve_chore(user_id, child_id, entity_id)
|
|
||||||
elif entity_type == 'chore' and action == 'deny':
|
|
||||||
reject_chore(user_id, child_id, entity_id)
|
|
||||||
elif entity_type == 'reward' and action == 'approve':
|
|
||||||
approve_reward_request(user_id, child_id, entity_id)
|
|
||||||
elif entity_type == 'reward' and action == 'deny':
|
|
||||||
deny_reward(user_id, child_id, entity_id)
|
|
||||||
else:
|
|
||||||
return make_response(_ERROR_HTML, 400)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f'Error executing digest action {action}/{entity_type}: {e}')
|
|
||||||
return make_response(_ERROR_HTML, 400)
|
|
||||||
|
|
||||||
deep_link = (
|
deep_link = (
|
||||||
f"{frontend_url}/parent/{child_id}"
|
f"{frontend_url}/parent/{token.child_id}"
|
||||||
f"?scrollTo={entity_id}&entityType={entity_type}"
|
f"?digestToken={token_id}&scrollTo={token.entity_id}&entityType={token.entity_type}"
|
||||||
)
|
)
|
||||||
return redirect(deep_link, 302)
|
return redirect(deep_link, 302)
|
||||||
|
|
||||||
|
|
||||||
|
@digest_action_api.route('/digest-action/<token_id>', methods=['POST'])
|
||||||
|
def execute_digest_action(token_id: str):
|
||||||
|
"""
|
||||||
|
Execute a digest action. Requires the user to be authenticated (JWT cookie).
|
||||||
|
Validates and consumes the token, then performs the approve/deny action.
|
||||||
|
"""
|
||||||
|
user_id = get_validated_user_id()
|
||||||
|
if not user_id:
|
||||||
|
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
||||||
|
|
||||||
|
token = validate_and_consume_token(token_id)
|
||||||
|
if not token:
|
||||||
|
return jsonify({'error': 'This link is invalid or has expired.', 'code': 'INVALID_TOKEN'}), 400
|
||||||
|
|
||||||
|
if token.user_id != user_id:
|
||||||
|
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 403
|
||||||
|
|
||||||
|
try:
|
||||||
|
if token.entity_type == 'chore' and token.action == 'approve':
|
||||||
|
approve_chore(user_id, token.child_id, token.entity_id)
|
||||||
|
elif token.entity_type == 'chore' and token.action == 'deny':
|
||||||
|
reject_chore(user_id, token.child_id, token.entity_id)
|
||||||
|
elif token.entity_type == 'reward' and token.action == 'approve':
|
||||||
|
approve_reward_request(user_id, token.child_id, token.entity_id)
|
||||||
|
elif token.entity_type == 'reward' and token.action == 'deny':
|
||||||
|
deny_reward(user_id, token.child_id, token.entity_id)
|
||||||
|
else:
|
||||||
|
return jsonify({'error': 'Unknown action', 'code': 'INVALID_ACTION'}), 400
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f'Error executing digest action {token.action}/{token.entity_type}: {e}')
|
||||||
|
return jsonify({'error': 'Failed to execute action', 'code': 'ACTION_FAILED'}), 400
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'child_id': token.child_id,
|
||||||
|
'entity_id': token.entity_id,
|
||||||
|
'entity_type': token.entity_type,
|
||||||
|
'action': token.action,
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
|
||||||
@digest_action_api.route('/digest-unsubscribe/<token>', methods=['GET'])
|
@digest_action_api.route('/digest-unsubscribe/<token>', methods=['GET'])
|
||||||
def handle_digest_unsubscribe(token: str):
|
def handle_digest_unsubscribe(token: str):
|
||||||
user_id = validate_unsubscribe_token(token)
|
user_id = validate_unsubscribe_token(token)
|
||||||
|
|||||||
114
backend/scripts/send_digest.py
Normal file
114
backend/scripts/send_digest.py
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
"""
|
||||||
|
Script to trigger a digest email for a specific user via the admin API.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python scripts/send_digest.py <email>
|
||||||
|
|
||||||
|
The script logs in as an admin using the ADMIN_EMAIL and ADMIN_PASSWORD
|
||||||
|
environment variables (defaults to localhost:5000).
|
||||||
|
|
||||||
|
Environment variables:
|
||||||
|
ADMIN_EMAIL - Admin account email (required)
|
||||||
|
ADMIN_PASSWORD - Admin account password (required)
|
||||||
|
API_BASE_URL - Base URL of the backend API (default: http://localhost:5000)
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import urllib.request
|
||||||
|
import urllib.error
|
||||||
|
|
||||||
|
API_BASE_URL = os.environ.get('API_BASE_URL', 'http://localhost:5000').rstrip('/')
|
||||||
|
|
||||||
|
|
||||||
|
def _post(path: str, body: dict, cookie: str | None = None) -> tuple[int, dict]:
|
||||||
|
url = f"{API_BASE_URL}{path}"
|
||||||
|
data = json.dumps(body).encode()
|
||||||
|
headers = {'Content-Type': 'application/json'}
|
||||||
|
if cookie:
|
||||||
|
headers['Cookie'] = cookie
|
||||||
|
req = urllib.request.Request(url, data=data, headers=headers, method='POST')
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req) as resp:
|
||||||
|
return resp.status, json.loads(resp.read())
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
try:
|
||||||
|
return e.code, json.loads(e.read())
|
||||||
|
except Exception:
|
||||||
|
return e.code, {}
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
if len(sys.argv) != 2:
|
||||||
|
print('Usage: python scripts/send_digest.py <email>')
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
target_email = sys.argv[1].strip()
|
||||||
|
|
||||||
|
admin_email = os.environ.get('ADMIN_EMAIL', '').strip()
|
||||||
|
admin_password = os.environ.get('ADMIN_PASSWORD', '').strip()
|
||||||
|
|
||||||
|
if not admin_email or not admin_password:
|
||||||
|
print('Error: ADMIN_EMAIL and ADMIN_PASSWORD environment variables are required')
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Log in to get a session cookie
|
||||||
|
status, body = _post('/auth/login', {'email': admin_email, 'password': admin_password})
|
||||||
|
if status != 200:
|
||||||
|
print(f'Login failed ({status}): {body.get("error", body)}')
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# urllib doesn't automatically store cookies; we need to capture Set-Cookie manually.
|
||||||
|
# Re-do the login request to grab the cookie header.
|
||||||
|
login_url = f"{API_BASE_URL}/auth/login"
|
||||||
|
login_data = json.dumps({'email': admin_email, 'password': admin_password}).encode()
|
||||||
|
login_req = urllib.request.Request(
|
||||||
|
login_url,
|
||||||
|
data=login_data,
|
||||||
|
headers={'Content-Type': 'application/json'},
|
||||||
|
method='POST',
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(login_req) as resp:
|
||||||
|
raw_cookies = resp.headers.get_all('Set-Cookie') or []
|
||||||
|
except urllib.error.HTTPError:
|
||||||
|
print('Login failed (could not retrieve cookie)')
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Extract access_token cookie value
|
||||||
|
cookie_header = '; '.join(
|
||||||
|
part.split(';')[0] for part in raw_cookies
|
||||||
|
)
|
||||||
|
if not cookie_header:
|
||||||
|
print('Login succeeded but no cookies were returned')
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Call the send-digest endpoint
|
||||||
|
status, body = _post(
|
||||||
|
'/admin/test/send-digest',
|
||||||
|
{'email': target_email},
|
||||||
|
cookie=cookie_header,
|
||||||
|
)
|
||||||
|
|
||||||
|
if status == 200:
|
||||||
|
items_sent = body.get('items_sent', 0)
|
||||||
|
if items_sent == 0:
|
||||||
|
print(f'No pending items found for {target_email} — no email sent.')
|
||||||
|
else:
|
||||||
|
print(f'Digest sent to {target_email} with {items_sent} item(s).')
|
||||||
|
elif status == 404 and body.get('code') == 'NOT_FOUND':
|
||||||
|
print('Error: This endpoint is disabled in the current environment (DB_ENV=production).')
|
||||||
|
sys.exit(1)
|
||||||
|
elif status == 404 and body.get('code') == 'USER_NOT_FOUND':
|
||||||
|
print(f'Error: No user found with email {target_email}')
|
||||||
|
sys.exit(1)
|
||||||
|
elif status == 403:
|
||||||
|
print(f'Error: {admin_email} is not an admin account.')
|
||||||
|
sys.exit(1)
|
||||||
|
else:
|
||||||
|
print(f'Error ({status}): {body.get("error", body)}')
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
@@ -20,42 +20,23 @@ def _get_local_hour(tz_str: str | None) -> int:
|
|||||||
return datetime.now(timezone.utc).hour
|
return datetime.now(timezone.utc).hour
|
||||||
|
|
||||||
|
|
||||||
def send_digests(app) -> None:
|
def send_digest_for_user(user_id: str, user_email: str, frontend_url: str) -> int:
|
||||||
"""Hourly job: send the 9pm digest to every eligible user whose local time is 21."""
|
"""Gather pending items for a user and send them a digest email.
|
||||||
if os.environ.get('DB_ENV') == 'e2e':
|
|
||||||
return
|
|
||||||
|
|
||||||
with app.app_context():
|
Returns the number of items included in the digest (0 if nothing was sent).
|
||||||
try:
|
Must be called within a Flask app context with all DB imports already available.
|
||||||
from db.db import users_db, pending_confirmations_db, child_db, task_db, reward_db
|
"""
|
||||||
|
from db.db import pending_confirmations_db, child_db, task_db, reward_db
|
||||||
from utils.email_sender import send_digest_email
|
from utils.email_sender import send_digest_email
|
||||||
from utils.digest_token import create_action_token, create_unsubscribe_token
|
from utils.digest_token import create_action_token, create_unsubscribe_token
|
||||||
from flask import current_app
|
|
||||||
|
|
||||||
frontend_url = current_app.config.get('FRONTEND_URL', 'https://localhost:5173')
|
|
||||||
UserQ = Query()
|
|
||||||
|
|
||||||
users = users_db.search(
|
|
||||||
(UserQ.verified == True) & (UserQ.email_digest_enabled == True)
|
|
||||||
)
|
|
||||||
|
|
||||||
for user_dict in users:
|
|
||||||
user_id = user_dict.get('id')
|
|
||||||
user_email = user_dict.get('email')
|
|
||||||
user_tz = user_dict.get('timezone')
|
|
||||||
|
|
||||||
local_hour = _get_local_hour(user_tz)
|
|
||||||
if local_hour != 21:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Gather pending items for this user
|
|
||||||
PendingQ = Query()
|
PendingQ = Query()
|
||||||
pending_items = pending_confirmations_db.search(
|
pending_items = pending_confirmations_db.search(
|
||||||
(PendingQ.user_id == user_id) & (PendingQ.status == 'pending')
|
(PendingQ.user_id == user_id) & (PendingQ.status == 'pending')
|
||||||
)
|
)
|
||||||
|
|
||||||
if not pending_items:
|
if not pending_items:
|
||||||
continue
|
return 0
|
||||||
|
|
||||||
ChildQ = Query()
|
ChildQ = Query()
|
||||||
TaskQ = Query()
|
TaskQ = Query()
|
||||||
@@ -71,10 +52,7 @@ def send_digests(app) -> None:
|
|||||||
if not child_result:
|
if not child_result:
|
||||||
logger.warning(f'Digest: child {child_id} not found for pending item {p.get("id")} (user {user_id})')
|
logger.warning(f'Digest: child {child_id} not found for pending item {p.get("id")} (user {user_id})')
|
||||||
continue
|
continue
|
||||||
child_name = child_result.get('name')
|
child_name = child_result.get('name') or 'Unknown'
|
||||||
if not child_name:
|
|
||||||
logger.warning(f'Digest: child {child_id} has no name (user {user_id})')
|
|
||||||
child_name = 'Unknown'
|
|
||||||
|
|
||||||
if entity_type == 'chore':
|
if entity_type == 'chore':
|
||||||
entity_result = task_db.get(
|
entity_result = task_db.get(
|
||||||
@@ -90,10 +68,7 @@ def send_digests(app) -> None:
|
|||||||
if not entity_result:
|
if not entity_result:
|
||||||
logger.warning(f'Digest: {entity_type} {entity_id} not found for pending item {p.get("id")} (user {user_id})')
|
logger.warning(f'Digest: {entity_type} {entity_id} not found for pending item {p.get("id")} (user {user_id})')
|
||||||
continue
|
continue
|
||||||
entity_name = entity_result.get('name')
|
entity_name = entity_result.get('name') or 'Unknown'
|
||||||
if not entity_name:
|
|
||||||
logger.warning(f'Digest: {entity_type} {entity_id} has no name (user {user_id})')
|
|
||||||
entity_name = 'Unknown'
|
|
||||||
|
|
||||||
approve_token = create_action_token(
|
approve_token = create_action_token(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
@@ -129,11 +104,41 @@ def send_digests(app) -> None:
|
|||||||
})
|
})
|
||||||
|
|
||||||
if not items:
|
if not items:
|
||||||
continue
|
return 0
|
||||||
|
|
||||||
unsubscribe_token = create_unsubscribe_token(user_id)
|
unsubscribe_token = create_unsubscribe_token(user_id)
|
||||||
try:
|
|
||||||
send_digest_email(user_email, items, unsubscribe_token)
|
send_digest_email(user_email, items, unsubscribe_token)
|
||||||
|
return len(items)
|
||||||
|
|
||||||
|
|
||||||
|
def send_digests(app) -> None:
|
||||||
|
"""Hourly job: send the 9pm digest to every eligible user whose local time is 21."""
|
||||||
|
if os.environ.get('DB_ENV') == 'e2e':
|
||||||
|
return
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
try:
|
||||||
|
from db.db import users_db
|
||||||
|
from flask import current_app
|
||||||
|
|
||||||
|
frontend_url = current_app.config.get('FRONTEND_URL', 'https://localhost:5173')
|
||||||
|
UserQ = Query()
|
||||||
|
|
||||||
|
users = users_db.search(
|
||||||
|
(UserQ.verified == True) & (UserQ.email_digest_enabled == True)
|
||||||
|
)
|
||||||
|
|
||||||
|
for user_dict in users:
|
||||||
|
user_id = user_dict.get('id')
|
||||||
|
user_email = user_dict.get('email')
|
||||||
|
user_tz = user_dict.get('timezone')
|
||||||
|
|
||||||
|
local_hour = _get_local_hour(user_tz)
|
||||||
|
if local_hour != 21:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
send_digest_for_user(user_id, user_email, frontend_url)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f'Failed to send digest to {user_email}: {e}')
|
logger.error(f'Failed to send digest to {user_email}: {e}')
|
||||||
|
|
||||||
|
|||||||
@@ -101,6 +101,40 @@ def validate_and_consume_token(token_id: str) -> DigestActionToken | None:
|
|||||||
return token
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
def peek_token(token_id: str) -> DigestActionToken | None:
|
||||||
|
"""
|
||||||
|
Validate a token (signature, expiry, not-used) WITHOUT consuming it.
|
||||||
|
Returns the token if valid, None otherwise.
|
||||||
|
"""
|
||||||
|
token = get_token_by_id(token_id)
|
||||||
|
if not token or token.used:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
expires_at = datetime.fromisoformat(token.expires_at)
|
||||||
|
if expires_at.tzinfo is None:
|
||||||
|
expires_at = expires_at.replace(tzinfo=timezone.utc)
|
||||||
|
if datetime.now(timezone.utc) > expires_at:
|
||||||
|
return None
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
'id': token.id,
|
||||||
|
'user_id': token.user_id,
|
||||||
|
'child_id': token.child_id,
|
||||||
|
'entity_id': token.entity_id,
|
||||||
|
'entity_type': token.entity_type,
|
||||||
|
'action': token.action,
|
||||||
|
'expires_at': token.expires_at,
|
||||||
|
}
|
||||||
|
expected_sig = _sign(payload)
|
||||||
|
if not hmac.compare_digest(token.signature, expected_sig):
|
||||||
|
return None
|
||||||
|
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
def create_unsubscribe_token(user_id: str) -> str:
|
def create_unsubscribe_token(user_id: str) -> str:
|
||||||
"""
|
"""
|
||||||
Create a simple signed unsubscribe token string (not persisted in DB).
|
Create a simple signed unsubscribe token string (not persisted in DB).
|
||||||
|
|||||||
@@ -651,6 +651,24 @@ onMounted(async () => {
|
|||||||
const idParam = Array.isArray(route.params.id) ? route.params.id[0] : route.params.id
|
const idParam = Array.isArray(route.params.id) ? route.params.id[0] : route.params.id
|
||||||
const scrollToId = typeof route.query.scrollTo === 'string' ? route.query.scrollTo : null
|
const scrollToId = typeof route.query.scrollTo === 'string' ? route.query.scrollTo : null
|
||||||
const entityType = typeof route.query.entityType === 'string' ? route.query.entityType : null
|
const entityType = typeof route.query.entityType === 'string' ? route.query.entityType : null
|
||||||
|
const digestToken =
|
||||||
|
typeof route.query.digestToken === 'string' ? route.query.digestToken : null
|
||||||
|
|
||||||
|
if (digestToken) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/digest-action/${digestToken}`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}))
|
||||||
|
console.warn('Digest action failed:', data.error || res.status)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Digest action request failed:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (idParam !== undefined) {
|
if (idParam !== undefined) {
|
||||||
const promise = fetchChildData(idParam)
|
const promise = fetchChildData(idParam)
|
||||||
promise.then((data) => {
|
promise.then((data) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user