Aug 27 - Update free-plan gate, MFA is no longer bypassable via the app
This commit is contained in:
@@ -27,7 +27,8 @@ g.tenant_engine — no teardown handler is needed here.
|
||||
|
||||
import logging
|
||||
|
||||
from flask import g, request, current_app, Response, session, redirect, url_for
|
||||
from flask import (g, request, current_app, Response, session, redirect,
|
||||
url_for, jsonify)
|
||||
|
||||
from app.tenancy.resolver import resolve_tenant
|
||||
from app.tenancy.engine_cache import get_tenant_engine
|
||||
@@ -52,6 +53,26 @@ _UNKNOWN_TENANT_PAGE = (
|
||||
)
|
||||
|
||||
|
||||
def _wants_json():
|
||||
"""True when this request is the mobile API (or explicitly asks for JSON).
|
||||
|
||||
Mirrors gates._is_api_request(). The tenancy and billing gates run BEFORE
|
||||
any route, so without this they answer an iPad with a 302 to an HTML page:
|
||||
URLSession follows it, the client decodes the login/billing markup as JSON
|
||||
and reports "the data couldn't be read". The inspector sees a parse error
|
||||
instead of "your subscription has expired", and nothing in the app can tell
|
||||
the two apart.
|
||||
"""
|
||||
return (request.path.startswith('/api/')
|
||||
or request.accept_mimetypes.best == 'application/json')
|
||||
|
||||
|
||||
def _json(payload, status):
|
||||
"""Small local responder — the API error helpers live in a blueprint that
|
||||
is not necessarily importable this early in the request."""
|
||||
return jsonify(payload), status
|
||||
|
||||
|
||||
def _is_exempt(path):
|
||||
if path.startswith('/static/'):
|
||||
return True
|
||||
@@ -158,6 +179,12 @@ def init_tenancy(app):
|
||||
host = (request.host or '').split(':')[0].strip().lower()
|
||||
tenant = resolve_tenant(host)
|
||||
if tenant is None:
|
||||
if _wants_json():
|
||||
# An HTML "Workspace not found" page is unreadable to the iPad
|
||||
# — it decodes as a parse failure, which looks like a bug in
|
||||
# the app rather than a wrong/retired server address.
|
||||
return _json({'ok': False,
|
||||
'error': 'Workspace not found for this address.'}, 404)
|
||||
return Response(_UNKNOWN_TENANT_PAGE, status=404, mimetype='text/html')
|
||||
|
||||
g.tenant = tenant
|
||||
@@ -205,6 +232,18 @@ def init_tenancy(app):
|
||||
# they can still see their plan page and the subscribe button.
|
||||
if not (request.path.startswith('/billing/')
|
||||
or request.path.startswith('/settings/')):
|
||||
if _wants_json():
|
||||
# 402, not a redirect: the caller is a program.
|
||||
# Distinct from 401 on purpose — the iPad retries a
|
||||
# 401 by refreshing its token, which would loop
|
||||
# forever against a billing block.
|
||||
return _json({
|
||||
'ok': False,
|
||||
'error': 'This workspace\'s trial has ended. '
|
||||
'An administrator needs to choose a plan '
|
||||
'before the app can sync again.',
|
||||
'billing_required': True,
|
||||
}, 402)
|
||||
return redirect(url_for('billing.subscribe'))
|
||||
else:
|
||||
days_left = (trial_ends_at - now).days
|
||||
@@ -217,4 +256,11 @@ def init_tenancy(app):
|
||||
return
|
||||
|
||||
# status == 'cancelled' — block and redirect to subscription page.
|
||||
if _wants_json():
|
||||
return _json({
|
||||
'ok': False,
|
||||
'error': 'This workspace is suspended. An administrator needs to '
|
||||
'reactivate the subscription before the app can sync again.',
|
||||
'billing_required': True,
|
||||
}, 402)
|
||||
return redirect(url_for('billing.suspended'))
|
||||
|
||||
Reference in New Issue
Block a user