karaoke-gen 0.101.0__py3-none-any.whl → 0.105.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- backend/Dockerfile.base +1 -0
- backend/api/routes/admin.py +226 -3
- backend/api/routes/audio_search.py +4 -32
- backend/api/routes/file_upload.py +18 -83
- backend/api/routes/jobs.py +2 -2
- backend/api/routes/push.py +238 -0
- backend/api/routes/rate_limits.py +428 -0
- backend/api/routes/users.py +79 -19
- backend/config.py +25 -1
- backend/exceptions.py +66 -0
- backend/main.py +26 -1
- backend/models/job.py +4 -0
- backend/models/user.py +20 -2
- backend/services/email_validation_service.py +646 -0
- backend/services/firestore_service.py +21 -0
- backend/services/gce_encoding/main.py +22 -8
- backend/services/job_defaults_service.py +113 -0
- backend/services/job_manager.py +109 -13
- backend/services/push_notification_service.py +409 -0
- backend/services/rate_limit_service.py +641 -0
- backend/services/stripe_service.py +2 -2
- backend/tests/conftest.py +8 -1
- backend/tests/test_admin_delete_outputs.py +352 -0
- backend/tests/test_audio_search.py +12 -8
- backend/tests/test_email_validation_service.py +298 -0
- backend/tests/test_file_upload.py +8 -6
- backend/tests/test_gce_encoding_worker.py +229 -0
- backend/tests/test_impersonation.py +18 -3
- backend/tests/test_made_for_you.py +6 -4
- backend/tests/test_push_notification_service.py +460 -0
- backend/tests/test_push_routes.py +357 -0
- backend/tests/test_rate_limit_service.py +396 -0
- backend/tests/test_rate_limits_api.py +392 -0
- backend/tests/test_stripe_service.py +205 -0
- backend/workers/video_worker_orchestrator.py +42 -0
- karaoke_gen/instrumental_review/static/index.html +35 -9
- {karaoke_gen-0.101.0.dist-info → karaoke_gen-0.105.4.dist-info}/METADATA +2 -1
- {karaoke_gen-0.101.0.dist-info → karaoke_gen-0.105.4.dist-info}/RECORD +41 -26
- {karaoke_gen-0.101.0.dist-info → karaoke_gen-0.105.4.dist-info}/WHEEL +0 -0
- {karaoke_gen-0.101.0.dist-info → karaoke_gen-0.105.4.dist-info}/entry_points.txt +0 -0
- {karaoke_gen-0.101.0.dist-info → karaoke_gen-0.105.4.dist-info}/licenses/LICENSE +0 -0
backend/api/routes/users.py
CHANGED
|
@@ -53,6 +53,10 @@ from backend.services.user_service import get_user_service, UserService, USERS_C
|
|
|
53
53
|
from backend.services.email_service import get_email_service, EmailService
|
|
54
54
|
from backend.services.stripe_service import get_stripe_service, StripeService, CREDIT_PACKAGES
|
|
55
55
|
from backend.services.theme_service import get_theme_service
|
|
56
|
+
from backend.services.job_defaults_service import (
|
|
57
|
+
get_effective_distribution_settings,
|
|
58
|
+
resolve_cdg_txt_defaults,
|
|
59
|
+
)
|
|
56
60
|
from backend.api.dependencies import require_admin
|
|
57
61
|
from backend.api.routes.file_upload import _prepare_theme_for_job
|
|
58
62
|
from backend.services.auth_service import UserType
|
|
@@ -475,7 +479,6 @@ async def _handle_made_for_you_order(
|
|
|
475
479
|
AudioSearchError,
|
|
476
480
|
)
|
|
477
481
|
from backend.services.storage_service import StorageService
|
|
478
|
-
from backend.config import get_settings
|
|
479
482
|
import asyncio
|
|
480
483
|
import tempfile
|
|
481
484
|
import os
|
|
@@ -496,7 +499,6 @@ async def _handle_made_for_you_order(
|
|
|
496
499
|
job_manager = JobManager()
|
|
497
500
|
worker_service = get_worker_service()
|
|
498
501
|
storage_service = StorageService()
|
|
499
|
-
settings = get_settings()
|
|
500
502
|
|
|
501
503
|
# Apply default theme (Nomad) - same as audio_search endpoint
|
|
502
504
|
theme_service = get_theme_service()
|
|
@@ -504,13 +506,12 @@ async def _handle_made_for_you_order(
|
|
|
504
506
|
if effective_theme_id:
|
|
505
507
|
logger.info(f"Applying default theme '{effective_theme_id}' for made-for-you order")
|
|
506
508
|
|
|
507
|
-
# Get distribution defaults
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
effective_youtube_description = settings.default_youtube_description
|
|
509
|
+
# Get distribution defaults using centralized service
|
|
510
|
+
dist = get_effective_distribution_settings()
|
|
511
|
+
|
|
512
|
+
# Resolve CDG/TXT defaults based on theme (uses centralized service)
|
|
513
|
+
# This ensures made-for-you jobs get the same CDG/TXT defaults as regular jobs
|
|
514
|
+
resolved_cdg, resolved_txt = resolve_cdg_txt_defaults(effective_theme_id)
|
|
514
515
|
|
|
515
516
|
# Create job with admin ownership during processing
|
|
516
517
|
# CRITICAL: made_for_you=True, user_email=ADMIN_EMAIL, customer_email for delivery
|
|
@@ -530,15 +531,19 @@ async def _handle_made_for_you_order(
|
|
|
530
531
|
audio_search_artist=artist if not youtube_url else None,
|
|
531
532
|
audio_search_title=title if not youtube_url else None,
|
|
532
533
|
auto_download=False, # Pause at audio selection for admin to choose
|
|
533
|
-
#
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
534
|
+
# CDG/TXT settings (resolved via centralized service)
|
|
535
|
+
enable_cdg=resolved_cdg,
|
|
536
|
+
enable_txt=resolved_txt,
|
|
537
|
+
# Distribution settings from centralized defaults
|
|
538
|
+
enable_youtube_upload=dist.enable_youtube_upload,
|
|
539
|
+
dropbox_path=dist.dropbox_path,
|
|
540
|
+
gdrive_folder_id=dist.gdrive_folder_id,
|
|
541
|
+
brand_prefix=dist.brand_prefix,
|
|
542
|
+
discord_webhook_url=dist.discord_webhook_url,
|
|
543
|
+
youtube_description=dist.youtube_description,
|
|
540
544
|
)
|
|
541
|
-
|
|
545
|
+
# Made-for-you jobs are created by admin (via Stripe webhook) - bypass rate limits
|
|
546
|
+
job = job_manager.create_job(job_create, is_admin=True)
|
|
542
547
|
job_id = job.job_id
|
|
543
548
|
|
|
544
549
|
logger.info(f"Created made-for-you job {job_id} for {_mask_email(customer_email)} (owned by {_mask_email(ADMIN_EMAIL)})")
|
|
@@ -818,6 +823,9 @@ async def enroll_beta_tester(
|
|
|
818
823
|
|
|
819
824
|
Returns free credits and optionally a session token for new users.
|
|
820
825
|
"""
|
|
826
|
+
from backend.services.email_validation_service import get_email_validation_service
|
|
827
|
+
from backend.services.rate_limit_service import get_rate_limit_service
|
|
828
|
+
|
|
821
829
|
# Check if email service is configured
|
|
822
830
|
if not email_service.is_configured():
|
|
823
831
|
logger.error("Email service not configured - cannot send beta welcome emails")
|
|
@@ -827,6 +835,52 @@ async def enroll_beta_tester(
|
|
|
827
835
|
)
|
|
828
836
|
|
|
829
837
|
email = request.email.lower()
|
|
838
|
+
email_validation = get_email_validation_service()
|
|
839
|
+
rate_limit_service = get_rate_limit_service()
|
|
840
|
+
|
|
841
|
+
# ----- ABUSE PREVENTION CHECKS -----
|
|
842
|
+
|
|
843
|
+
# 1. Validate email (disposable domain, blocked email checks)
|
|
844
|
+
is_valid, error_message = email_validation.validate_email_for_beta(email)
|
|
845
|
+
if not is_valid:
|
|
846
|
+
logger.warning(f"Beta enrollment rejected - email validation failed: {email} - {error_message}")
|
|
847
|
+
raise HTTPException(
|
|
848
|
+
status_code=400,
|
|
849
|
+
detail=error_message
|
|
850
|
+
)
|
|
851
|
+
|
|
852
|
+
# 2. Check IP blocking
|
|
853
|
+
ip_address = http_request.client.host if http_request.client else None
|
|
854
|
+
if ip_address and email_validation.is_ip_blocked(ip_address):
|
|
855
|
+
logger.warning(f"Beta enrollment rejected - IP blocked: {ip_address}")
|
|
856
|
+
raise HTTPException(
|
|
857
|
+
status_code=403,
|
|
858
|
+
detail="Access denied from this location"
|
|
859
|
+
)
|
|
860
|
+
|
|
861
|
+
# 3. Check IP-based enrollment rate limit (1 per 24h per IP)
|
|
862
|
+
if ip_address:
|
|
863
|
+
allowed, remaining, message = rate_limit_service.check_beta_ip_limit(ip_address)
|
|
864
|
+
if not allowed:
|
|
865
|
+
logger.warning(f"Beta enrollment rejected - IP rate limit: {ip_address} - {message}")
|
|
866
|
+
raise HTTPException(
|
|
867
|
+
status_code=429,
|
|
868
|
+
detail="Too many beta enrollments from your location. Please try again tomorrow."
|
|
869
|
+
)
|
|
870
|
+
|
|
871
|
+
# 4. Check for duplicate enrollment via normalized email
|
|
872
|
+
normalized_email = email_validation.normalize_email(email)
|
|
873
|
+
if normalized_email != email:
|
|
874
|
+
# Check if normalized version is already enrolled
|
|
875
|
+
normalized_user = user_service.get_user(normalized_email)
|
|
876
|
+
if normalized_user and normalized_user.is_beta_tester:
|
|
877
|
+
logger.warning(f"Beta enrollment rejected - normalized email already enrolled: {email} -> {normalized_email}")
|
|
878
|
+
raise HTTPException(
|
|
879
|
+
status_code=400,
|
|
880
|
+
detail="An account with this email address is already enrolled in the beta program"
|
|
881
|
+
)
|
|
882
|
+
|
|
883
|
+
# ----- END ABUSE PREVENTION CHECKS -----
|
|
830
884
|
|
|
831
885
|
# Validate acceptance
|
|
832
886
|
if not request.accept_corrections_work:
|
|
@@ -851,8 +905,7 @@ async def enroll_beta_tester(
|
|
|
851
905
|
detail="You are already enrolled in the beta program"
|
|
852
906
|
)
|
|
853
907
|
|
|
854
|
-
# Get client info
|
|
855
|
-
ip_address = http_request.client.host if http_request.client else None
|
|
908
|
+
# Get client info (ip_address already set above in abuse prevention)
|
|
856
909
|
user_agent = http_request.headers.get("user-agent")
|
|
857
910
|
|
|
858
911
|
# Enroll as beta tester
|
|
@@ -872,6 +925,13 @@ async def enroll_beta_tester(
|
|
|
872
925
|
reason="beta_tester_enrollment",
|
|
873
926
|
)
|
|
874
927
|
|
|
928
|
+
# Record IP enrollment for rate limiting
|
|
929
|
+
if ip_address:
|
|
930
|
+
try:
|
|
931
|
+
rate_limit_service.record_beta_enrollment(ip_address, email)
|
|
932
|
+
except Exception as e:
|
|
933
|
+
logger.warning(f"Failed to record beta IP enrollment: {e}")
|
|
934
|
+
|
|
875
935
|
# Create session for the user (so they can start using the service immediately)
|
|
876
936
|
session = user_service.create_session(email, ip_address=ip_address, user_agent=user_agent)
|
|
877
937
|
|
backend/config.py
CHANGED
|
@@ -101,13 +101,37 @@ class Settings(BaseSettings):
|
|
|
101
101
|
# Default values for web service jobs (YouTube/Dropbox distribution)
|
|
102
102
|
default_enable_youtube_upload: bool = os.getenv("DEFAULT_ENABLE_YOUTUBE_UPLOAD", "false").lower() in ("true", "1", "yes")
|
|
103
103
|
default_brand_prefix: Optional[str] = os.getenv("DEFAULT_BRAND_PREFIX")
|
|
104
|
+
|
|
105
|
+
# Rate Limiting Configuration
|
|
106
|
+
# Enable/disable rate limiting system-wide (useful for development)
|
|
107
|
+
enable_rate_limiting: bool = os.getenv("ENABLE_RATE_LIMITING", "true").lower() in ("true", "1", "yes")
|
|
108
|
+
# Maximum jobs a user can create per day (0 = unlimited)
|
|
109
|
+
rate_limit_jobs_per_day: int = int(os.getenv("RATE_LIMIT_JOBS_PER_DAY", "5"))
|
|
110
|
+
# Maximum YouTube uploads system-wide per day (0 = unlimited)
|
|
111
|
+
rate_limit_youtube_uploads_per_day: int = int(os.getenv("RATE_LIMIT_YOUTUBE_UPLOADS_PER_DAY", "10"))
|
|
112
|
+
# Maximum beta enrollments from same IP per day (0 = unlimited)
|
|
113
|
+
rate_limit_beta_ip_per_day: int = int(os.getenv("RATE_LIMIT_BETA_IP_PER_DAY", "1"))
|
|
104
114
|
default_youtube_description: str = os.getenv(
|
|
105
115
|
"DEFAULT_YOUTUBE_DESCRIPTION",
|
|
106
116
|
"Karaoke video created with Nomad Karaoke (https://nomadkaraoke.com)\n\n"
|
|
107
117
|
"AI-powered vocal separation and synchronized lyrics.\n\n"
|
|
108
118
|
"#karaoke #music #singing #instrumental #lyrics"
|
|
109
119
|
)
|
|
110
|
-
|
|
120
|
+
|
|
121
|
+
# Default CDG/TXT generation settings
|
|
122
|
+
# When True, CDG and TXT packages are generated by default (when a theme is set)
|
|
123
|
+
# These can be overridden per-request via explicit enable_cdg/enable_txt parameters
|
|
124
|
+
default_enable_cdg: bool = os.getenv("DEFAULT_ENABLE_CDG", "true").lower() in ("true", "1", "yes")
|
|
125
|
+
default_enable_txt: bool = os.getenv("DEFAULT_ENABLE_TXT", "true").lower() in ("true", "1", "yes")
|
|
126
|
+
|
|
127
|
+
# Push Notifications Configuration
|
|
128
|
+
# When enabled, users can subscribe to push notifications for job status updates
|
|
129
|
+
enable_push_notifications: bool = os.getenv("ENABLE_PUSH_NOTIFICATIONS", "false").lower() in ("true", "1", "yes")
|
|
130
|
+
# Maximum number of push subscriptions per user (oldest removed when exceeded)
|
|
131
|
+
max_push_subscriptions_per_user: int = int(os.getenv("MAX_PUSH_SUBSCRIPTIONS_PER_USER", "5"))
|
|
132
|
+
# VAPID subject (email or URL for push service to contact)
|
|
133
|
+
vapid_subject: str = os.getenv("VAPID_SUBJECT", "mailto:gen@nomadkaraoke.com")
|
|
134
|
+
|
|
111
135
|
# Secret Manager cache
|
|
112
136
|
_secret_cache: Dict[str, str] = {}
|
|
113
137
|
|
backend/exceptions.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Custom exceptions for the karaoke generation backend.
|
|
3
|
+
|
|
4
|
+
These exceptions are used for structured error handling across the application,
|
|
5
|
+
particularly for rate limiting and validation errors.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class RateLimitExceededError(Exception):
|
|
10
|
+
"""
|
|
11
|
+
Raised when a rate limit is exceeded.
|
|
12
|
+
|
|
13
|
+
Includes information about when the limit will reset for client retry logic.
|
|
14
|
+
|
|
15
|
+
Attributes:
|
|
16
|
+
message: Human-readable error message
|
|
17
|
+
limit_type: Type of limit exceeded (e.g., "jobs_per_day", "youtube_uploads")
|
|
18
|
+
remaining_seconds: Seconds until the limit resets (for Retry-After header)
|
|
19
|
+
current_count: Current usage count
|
|
20
|
+
limit_value: The limit that was exceeded
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
message: str,
|
|
26
|
+
limit_type: str = "unknown",
|
|
27
|
+
remaining_seconds: int = 0,
|
|
28
|
+
current_count: int = 0,
|
|
29
|
+
limit_value: int = 0
|
|
30
|
+
):
|
|
31
|
+
self.message = message
|
|
32
|
+
self.limit_type = limit_type
|
|
33
|
+
self.remaining_seconds = remaining_seconds
|
|
34
|
+
self.current_count = current_count
|
|
35
|
+
self.limit_value = limit_value
|
|
36
|
+
super().__init__(message)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class EmailValidationError(Exception):
|
|
40
|
+
"""
|
|
41
|
+
Raised when email validation fails.
|
|
42
|
+
|
|
43
|
+
Attributes:
|
|
44
|
+
message: Human-readable error message
|
|
45
|
+
reason: Specific reason for failure (e.g., "disposable", "blocked", "invalid")
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(self, message: str, reason: str = "invalid"):
|
|
49
|
+
self.message = message
|
|
50
|
+
self.reason = reason
|
|
51
|
+
super().__init__(message)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class IPBlockedError(Exception):
|
|
55
|
+
"""
|
|
56
|
+
Raised when a request comes from a blocked IP address.
|
|
57
|
+
|
|
58
|
+
Attributes:
|
|
59
|
+
message: Human-readable error message
|
|
60
|
+
ip_address: The blocked IP address (may be partially masked)
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
def __init__(self, message: str, ip_address: str = ""):
|
|
64
|
+
self.message = message
|
|
65
|
+
self.ip_address = ip_address
|
|
66
|
+
super().__init__(message)
|
backend/main.py
CHANGED
|
@@ -7,7 +7,7 @@ from fastapi import FastAPI
|
|
|
7
7
|
from fastapi.middleware.cors import CORSMiddleware
|
|
8
8
|
|
|
9
9
|
from backend.config import settings
|
|
10
|
-
from backend.api.routes import health, jobs, internal, file_upload, review, auth, audio_search, themes, users, admin, tenant
|
|
10
|
+
from backend.api.routes import health, jobs, internal, file_upload, review, auth, audio_search, themes, users, admin, tenant, rate_limits, push
|
|
11
11
|
from backend.services.tracing import setup_tracing, instrument_app, get_current_trace_id
|
|
12
12
|
from backend.services.structured_logging import setup_structured_logging
|
|
13
13
|
from backend.services.spacy_preloader import preload_spacy_model
|
|
@@ -143,9 +143,34 @@ app.include_router(audio_search.router, prefix="/api") # Audio search (artist+t
|
|
|
143
143
|
app.include_router(themes.router, prefix="/api") # Theme selection for styles
|
|
144
144
|
app.include_router(users.router, prefix="/api") # User auth, credits, and Stripe webhooks
|
|
145
145
|
app.include_router(admin.router, prefix="/api") # Admin dashboard and management
|
|
146
|
+
app.include_router(rate_limits.router, prefix="/api") # Rate limits admin management
|
|
147
|
+
app.include_router(push.router, prefix="/api") # Push notification subscription management
|
|
146
148
|
app.include_router(tenant.router) # Tenant/white-label configuration (no /api prefix, router has it)
|
|
147
149
|
|
|
148
150
|
|
|
151
|
+
# Exception handler for rate limiting
|
|
152
|
+
from fastapi import Request
|
|
153
|
+
from fastapi.responses import JSONResponse
|
|
154
|
+
from backend.exceptions import RateLimitExceededError
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@app.exception_handler(RateLimitExceededError)
|
|
158
|
+
async def rate_limit_exception_handler(request: Request, exc: RateLimitExceededError):
|
|
159
|
+
"""Handle rate limit exceeded errors with 429 status."""
|
|
160
|
+
return JSONResponse(
|
|
161
|
+
status_code=429,
|
|
162
|
+
content={
|
|
163
|
+
"detail": exc.message,
|
|
164
|
+
"limit_type": exc.limit_type,
|
|
165
|
+
"current_count": exc.current_count,
|
|
166
|
+
"limit_value": exc.limit_value,
|
|
167
|
+
},
|
|
168
|
+
headers={
|
|
169
|
+
"Retry-After": str(exc.remaining_seconds),
|
|
170
|
+
} if exc.remaining_seconds > 0 else None,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
|
|
149
174
|
@app.get("/")
|
|
150
175
|
async def root():
|
|
151
176
|
"""Root endpoint."""
|
backend/models/job.py
CHANGED
|
@@ -285,6 +285,10 @@ class Job(BaseModel):
|
|
|
285
285
|
customer_email: Optional[str] = None # Customer email for final delivery (job owned by admin during processing)
|
|
286
286
|
customer_notes: Optional[str] = None # Notes provided by customer with their order
|
|
287
287
|
|
|
288
|
+
# Output deletion tracking (for admin cleanup without deleting job)
|
|
289
|
+
outputs_deleted_at: Optional[datetime] = None # Timestamp when outputs were deleted by admin
|
|
290
|
+
outputs_deleted_by: Optional[str] = None # Admin email who deleted outputs
|
|
291
|
+
|
|
288
292
|
# Processing state
|
|
289
293
|
track_output_dir: Optional[str] = None # Local output directory (temp)
|
|
290
294
|
audio_hash: Optional[str] = None # Hash for deduplication
|
backend/models/user.py
CHANGED
|
@@ -8,9 +8,9 @@ Supports:
|
|
|
8
8
|
- Stripe integration for payments
|
|
9
9
|
- Beta tester program with feedback collection
|
|
10
10
|
"""
|
|
11
|
-
from datetime import datetime
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
12
|
from enum import Enum
|
|
13
|
-
from typing import Optional, List
|
|
13
|
+
from typing import Optional, List, Dict
|
|
14
14
|
from pydantic import BaseModel, Field
|
|
15
15
|
|
|
16
16
|
|
|
@@ -39,6 +39,20 @@ class CreditTransaction(BaseModel):
|
|
|
39
39
|
created_by: Optional[str] = None # Admin email if granted by admin
|
|
40
40
|
|
|
41
41
|
|
|
42
|
+
class PushSubscription(BaseModel):
|
|
43
|
+
"""
|
|
44
|
+
Web Push subscription for a user's device.
|
|
45
|
+
|
|
46
|
+
Stores the push subscription endpoint and encryption keys needed
|
|
47
|
+
to send push notifications to the user's browser/device.
|
|
48
|
+
"""
|
|
49
|
+
endpoint: str # Push service endpoint URL
|
|
50
|
+
keys: Dict[str, str] # p256dh and auth keys for encryption
|
|
51
|
+
device_name: Optional[str] = None # e.g., "iPhone", "Chrome on Windows"
|
|
52
|
+
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
|
53
|
+
last_used_at: Optional[datetime] = None # Last time a notification was sent
|
|
54
|
+
|
|
55
|
+
|
|
42
56
|
class User(BaseModel):
|
|
43
57
|
"""
|
|
44
58
|
User model stored in Firestore.
|
|
@@ -86,6 +100,10 @@ class User(BaseModel):
|
|
|
86
100
|
beta_feedback_due_at: Optional[datetime] = None # 24hr after job completion
|
|
87
101
|
beta_feedback_email_sent: bool = False
|
|
88
102
|
|
|
103
|
+
# Push notification subscriptions (Web Push API)
|
|
104
|
+
# Users can subscribe from multiple devices/browsers
|
|
105
|
+
push_subscriptions: List[PushSubscription] = Field(default_factory=list)
|
|
106
|
+
|
|
89
107
|
|
|
90
108
|
class MagicLinkToken(BaseModel):
|
|
91
109
|
"""
|