Files
pingu-concerts/app/push_devices.py
T

83 lines
4.3 KiB
Python

"""Device registration. Background sending lives in notifications.py."""
import hashlib
from uuid import UUID
from fastapi import Request
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from i18n import current_language
from notifications import save_language
class DeviceRegistration(BaseModel):
device_id: UUID
token: str = Field(min_length=20, max_length=4096, pattern=r'^[A-Za-z0-9_:.-]+$')
platform: str = Field(pattern=r'^android$')
app_version: str = Field(default='', max_length=32, pattern=r'^[0-9A-Za-z.+_-]*$')
session_tag: str = Field(pattern=r'^[a-f0-9]{64}$')
def session_hash(request, cookie_name):
return hashlib.sha256(request.cookies.get(cookie_name, '').encode()).hexdigest()
def register_routes(app, get_db, get_user, cookie_name):
@app.get('/api/push/session')
def push_session(request: Request):
user = get_user(request)
payload = {'authenticated': bool(user)}
if user:
payload.update(user_id=user['id'], session_tag=session_hash(request, cookie_name))
return JSONResponse(payload, headers={'Cache-Control': 'no-store'})
@app.post('/api/push/devices')
def register_device(request: Request, data: DeviceRegistration):
user = get_user(request)
if not user:
return JSONResponse({'error': 'authentication_required'}, status_code=401)
token_hash = session_hash(request, cookie_name)
if data.session_tag != token_hash:
return JSONResponse({'error': 'session_changed'}, status_code=409)
with get_db() as connection:
with connection.cursor() as cursor:
# The row lock serializes registration against logout/session deletion.
cursor.execute('SELECT id FROM sessions WHERE token_hash=%s AND user_id=%s '
'AND expires_at>CURRENT_TIMESTAMP FOR UPDATE', (token_hash, user['id']))
session = cursor.fetchone()
if not session:
return JSONResponse({'error': 'session_expired'}, status_code=401)
# Serialize token/device transfers across users as well as token rotations.
cursor.execute('SELECT pg_advisory_xact_lock(71001)')
cursor.execute('DELETE FROM push_devices WHERE token=%s AND device_id<>%s',
(data.token, data.device_id))
cursor.execute('''
INSERT INTO push_devices(user_id, session_id, device_id, token, platform, app_version)
VALUES (%s,%s,%s,%s,%s,%s)
ON CONFLICT(device_id) DO UPDATE SET
user_id=EXCLUDED.user_id, session_id=EXCLUDED.session_id,
token=EXCLUDED.token, platform=EXCLUDED.platform, app_version=EXCLUDED.app_version,
updated_at=CURRENT_TIMESTAMP, last_seen_at=CURRENT_TIMESTAMP
RETURNING id
''', (user['id'], session[0], data.device_id, data.token, data.platform, data.app_version))
device_id = cursor.fetchone()[0]
save_language(connection, user['id'], current_language.get())
cursor.execute('DELETE FROM push_devices WHERE session_id IN '
'(SELECT id FROM sessions WHERE expires_at<=CURRENT_TIMESTAMP) '
'OR last_seen_at<CURRENT_TIMESTAMP - INTERVAL \'90 days\'')
connection.commit()
return JSONResponse({'registered': True, 'id': device_id}, headers={'Cache-Control': 'no-store'})
@app.delete('/api/push/devices/{device_id}')
def unregister_device(request: Request, device_id: UUID):
user = get_user(request)
if not user:
return JSONResponse({'error': 'authentication_required'}, status_code=401)
with get_db() as connection:
with connection.cursor() as cursor:
cursor.execute('DELETE FROM push_devices WHERE device_id=%s AND user_id=%s '
'AND session_id IN (SELECT id FROM sessions WHERE token_hash=%s)',
(device_id, user['id'], session_hash(request, cookie_name)))
connection.commit()
return JSONResponse({'registered': False}, headers={'Cache-Control': 'no-store'})