server.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564
  1. import os
  2. import ssl
  3. import arrow
  4. import flask_profiler
  5. import sentry_sdk
  6. from flask import Flask, redirect, url_for, render_template, request, jsonify, flash
  7. from flask_admin import Admin
  8. from flask_cors import cross_origin
  9. from flask_login import current_user
  10. from sentry_sdk.integrations.aiohttp import AioHttpIntegration
  11. from sentry_sdk.integrations.flask import FlaskIntegration
  12. from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration
  13. from app import paddle_utils
  14. from app.admin_model import SLModelView, SLAdminIndexView
  15. from app.api.base import api_bp
  16. from app.auth.base import auth_bp
  17. from app.config import (
  18. DB_URI,
  19. FLASK_SECRET,
  20. SENTRY_DSN,
  21. URL,
  22. SHA1,
  23. PADDLE_MONTHLY_PRODUCT_ID,
  24. RESET_DB,
  25. FLASK_PROFILER_PATH,
  26. FLASK_PROFILER_PASSWORD,
  27. SENTRY_FRONT_END_DSN,
  28. FIRST_ALIAS_DOMAIN,
  29. )
  30. from app.dashboard.base import dashboard_bp
  31. from app.developer.base import developer_bp
  32. from app.discover.base import discover_bp
  33. from app.extensions import db, login_manager, migrate
  34. from app.jose_utils import get_jwk_key
  35. from app.log import LOG
  36. from app.models import (
  37. Client,
  38. User,
  39. ClientUser,
  40. Alias,
  41. RedirectUri,
  42. Subscription,
  43. PlanEnum,
  44. ApiKey,
  45. CustomDomain,
  46. LifetimeCoupon,
  47. Directory,
  48. Mailbox,
  49. DeletedAlias,
  50. Contact,
  51. EmailLog,
  52. Referral,
  53. AliasMailbox,
  54. )
  55. from app.monitor.base import monitor_bp
  56. from app.oauth.base import oauth_bp
  57. if SENTRY_DSN:
  58. LOG.d("enable sentry")
  59. sentry_sdk.init(
  60. dsn=SENTRY_DSN,
  61. integrations=[
  62. FlaskIntegration(),
  63. SqlalchemyIntegration(),
  64. AioHttpIntegration(),
  65. ],
  66. )
  67. # the app is served behin nginx which uses http and not https
  68. os.environ["OAUTHLIB_INSECURE_TRANSPORT"] = "1"
  69. def create_app() -> Flask:
  70. app = Flask(__name__)
  71. app.url_map.strict_slashes = False
  72. app.config["SQLALCHEMY_DATABASE_URI"] = DB_URI
  73. app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
  74. # enable to print all queries generated by sqlalchemy
  75. # app.config["SQLALCHEMY_ECHO"] = True
  76. app.secret_key = FLASK_SECRET
  77. app.config["TEMPLATES_AUTO_RELOAD"] = True
  78. # to avoid conflict with other cookie
  79. app.config["SESSION_COOKIE_NAME"] = "slapp"
  80. if URL.startswith("https"):
  81. app.config["SESSION_COOKIE_SECURE"] = True
  82. app.config["SESSION_COOKIE_SAMESITE"] = "Lax"
  83. init_extensions(app)
  84. register_blueprints(app)
  85. set_index_page(app)
  86. jinja2_filter(app)
  87. setup_error_page(app)
  88. setup_favicon_route(app)
  89. setup_openid_metadata(app)
  90. init_admin(app)
  91. setup_paddle_callback(app)
  92. setup_do_not_track(app)
  93. if FLASK_PROFILER_PATH:
  94. LOG.d("Enable flask-profiler")
  95. app.config["flask_profiler"] = {
  96. "enabled": True,
  97. "storage": {"engine": "sqlite", "FILE": FLASK_PROFILER_PATH},
  98. "basicAuth": {
  99. "enabled": True,
  100. "username": "admin",
  101. "password": FLASK_PROFILER_PASSWORD,
  102. },
  103. "ignore": ["^/static/.*", "/git", "/exception"],
  104. }
  105. flask_profiler.init_app(app)
  106. return app
  107. def fake_data():
  108. LOG.d("create fake data")
  109. # Remove db if exist
  110. if os.path.exists("db.sqlite"):
  111. LOG.d("remove existing db file")
  112. os.remove("db.sqlite")
  113. # Create all tables
  114. db.create_all()
  115. # Create a user
  116. user = User.create(
  117. email="john@wick.com",
  118. name="John Wick",
  119. password="password",
  120. activated=True,
  121. is_admin=True,
  122. otp_secret="base32secret3232",
  123. )
  124. db.session.commit()
  125. user.trial_end = None
  126. LifetimeCoupon.create(code="coupon", nb_used=10)
  127. db.session.commit()
  128. # Create a subscription for user
  129. Subscription.create(
  130. user_id=user.id,
  131. cancel_url="https://checkout.paddle.com/subscription/cancel?user=1234",
  132. update_url="https://checkout.paddle.com/subscription/update?user=1234",
  133. subscription_id="123",
  134. event_time=arrow.now(),
  135. next_bill_date=arrow.now().shift(days=10).date(),
  136. plan=PlanEnum.monthly,
  137. )
  138. db.session.commit()
  139. api_key = ApiKey.create(user_id=user.id, name="Chrome")
  140. api_key.code = "code"
  141. api_key = ApiKey.create(user_id=user.id, name="Firefox")
  142. api_key.code = "codeFF"
  143. m1 = Mailbox.create(user_id=user.id, email="m1@cd.ef", verified=True)
  144. db.session.commit()
  145. for i in range(31):
  146. if i % 2 == 0:
  147. a = Alias.create_new(user, f"e{i}@", mailbox_id=m1.id)
  148. else:
  149. a = Alias.create_new(user, f"e{i}@")
  150. db.session.commit()
  151. if i % 5 == 0:
  152. if i % 2 == 0:
  153. AliasMailbox.create(alias_id=a.id, mailbox_id=user.default_mailbox_id)
  154. else:
  155. AliasMailbox.create(alias_id=a.id, mailbox_id=m1.id)
  156. db.session.commit()
  157. # some aliases don't have any activity
  158. if i % 3 != 0:
  159. contact = Contact.create(
  160. user_id=user.id,
  161. alias_id=a.id,
  162. website_email=f"contact{i}@example.com",
  163. reply_email=f"rep{i}@sl.local",
  164. )
  165. db.session.commit()
  166. for _ in range(3):
  167. EmailLog.create(user_id=user.id, contact_id=contact.id)
  168. db.session.commit()
  169. # have some disabled alias
  170. if i % 5 == 0:
  171. a.enabled = False
  172. db.session.commit()
  173. CustomDomain.create(user_id=user.id, domain="ab.cd", verified=True)
  174. CustomDomain.create(
  175. user_id=user.id, domain="very-long-domain.com.net.org", verified=True
  176. )
  177. db.session.commit()
  178. Directory.create(user_id=user.id, name="abcd")
  179. Directory.create(user_id=user.id, name="xyzt")
  180. db.session.commit()
  181. # Create a client
  182. client1 = Client.create_new(name="Demo", user_id=user.id)
  183. client1.oauth_client_id = "client-id"
  184. client1.oauth_client_secret = "client-secret"
  185. client1.published = True
  186. db.session.commit()
  187. RedirectUri.create(client_id=client1.id, uri="https://ab.com")
  188. client2 = Client.create_new(name="Demo 2", user_id=user.id)
  189. client2.oauth_client_id = "client-id2"
  190. client2.oauth_client_secret = "client-secret2"
  191. client2.published = True
  192. db.session.commit()
  193. ClientUser.create(user_id=user.id, client_id=client1.id, name="Fake Name")
  194. DeletedAlias.create(email="d1@ab.cd")
  195. DeletedAlias.create(email="d2@ab.cd")
  196. db.session.commit()
  197. referral = Referral.create(user_id=user.id, code="REFCODE", name="First referral")
  198. db.session.commit()
  199. User.create(
  200. email="winston@continental.com",
  201. name="Winston",
  202. password="password",
  203. activated=True,
  204. referral_id=referral.id,
  205. )
  206. db.session.commit()
  207. @login_manager.user_loader
  208. def load_user(user_id):
  209. user = User.query.get(user_id)
  210. return user
  211. def register_blueprints(app: Flask):
  212. app.register_blueprint(auth_bp)
  213. app.register_blueprint(monitor_bp)
  214. app.register_blueprint(dashboard_bp)
  215. app.register_blueprint(developer_bp)
  216. app.register_blueprint(oauth_bp, url_prefix="/oauth")
  217. app.register_blueprint(oauth_bp, url_prefix="/oauth2")
  218. app.register_blueprint(discover_bp)
  219. app.register_blueprint(api_bp)
  220. def set_index_page(app):
  221. @app.route("/", methods=["GET", "POST"])
  222. def index():
  223. if current_user.is_authenticated:
  224. return redirect(url_for("dashboard.index"))
  225. else:
  226. return redirect(url_for("auth.login"))
  227. @app.after_request
  228. def after_request(res):
  229. # not logging /static call
  230. if (
  231. not request.path.startswith("/static")
  232. and not request.path.startswith("/admin/static")
  233. and not request.path.startswith("/_debug_toolbar")
  234. ):
  235. LOG.debug(
  236. "%s %s %s %s %s",
  237. request.remote_addr,
  238. request.method,
  239. request.path,
  240. request.args,
  241. res.status_code,
  242. )
  243. return res
  244. def setup_openid_metadata(app):
  245. @app.route("/.well-known/openid-configuration")
  246. @cross_origin()
  247. def openid_config():
  248. res = {
  249. "issuer": URL,
  250. "authorization_endpoint": URL + "/oauth2/authorize",
  251. "token_endpoint": URL + "/oauth2/token",
  252. "userinfo_endpoint": URL + "/oauth2/userinfo",
  253. "jwks_uri": URL + "/jwks",
  254. "response_types_supported": [
  255. "code",
  256. "token",
  257. "id_token",
  258. "id_token token",
  259. "id_token code",
  260. ],
  261. "subject_types_supported": ["public"],
  262. "id_token_signing_alg_values_supported": ["RS256"],
  263. # todo: add introspection and revocation endpoints
  264. # "introspection_endpoint": URL + "/oauth2/token/introspection",
  265. # "revocation_endpoint": URL + "/oauth2/token/revocation",
  266. }
  267. return jsonify(res)
  268. @app.route("/jwks")
  269. @cross_origin()
  270. def jwks():
  271. res = {"keys": [get_jwk_key()]}
  272. return jsonify(res)
  273. def setup_error_page(app):
  274. @app.errorhandler(400)
  275. def page_not_found(e):
  276. return render_template("error/400.html"), 400
  277. @app.errorhandler(401)
  278. def page_not_found(e):
  279. flash("You need to login to see this page", "error")
  280. return redirect(url_for("auth.login", next=request.full_path))
  281. @app.errorhandler(403)
  282. def page_not_found(e):
  283. return render_template("error/403.html"), 403
  284. @app.errorhandler(404)
  285. def page_not_found(e):
  286. return render_template("error/404.html"), 404
  287. @app.errorhandler(Exception)
  288. def error_handler(e):
  289. LOG.exception(e)
  290. return render_template("error/500.html"), 500
  291. def setup_favicon_route(app):
  292. @app.route("/favicon.ico")
  293. def favicon():
  294. return redirect("/static/favicon.ico")
  295. def jinja2_filter(app):
  296. def format_datetime(value):
  297. dt = arrow.get(value)
  298. return dt.humanize()
  299. app.jinja_env.filters["dt"] = format_datetime
  300. @app.context_processor
  301. def inject_stage_and_region():
  302. return dict(
  303. YEAR=arrow.now().year,
  304. URL=URL,
  305. SENTRY_DSN=SENTRY_FRONT_END_DSN,
  306. VERSION=SHA1,
  307. FIRST_ALIAS_DOMAIN=FIRST_ALIAS_DOMAIN,
  308. )
  309. def setup_paddle_callback(app: Flask):
  310. @app.route("/paddle", methods=["GET", "POST"])
  311. def paddle():
  312. LOG.debug(f"paddle callback {request.form.get('alert_name')} {request.form}")
  313. # make sure the request comes from Paddle
  314. if not paddle_utils.verify_incoming_request(dict(request.form)):
  315. LOG.error(
  316. "request not coming from paddle. Request data:%s", dict(request.form)
  317. )
  318. return "KO", 400
  319. if (
  320. request.form.get("alert_name") == "subscription_created"
  321. ): # new user subscribes
  322. user_email = request.form.get("email")
  323. user = User.get_by(email=user_email)
  324. if (
  325. int(request.form.get("subscription_plan_id"))
  326. == PADDLE_MONTHLY_PRODUCT_ID
  327. ):
  328. plan = PlanEnum.monthly
  329. else:
  330. plan = PlanEnum.yearly
  331. sub = Subscription.get_by(user_id=user.id)
  332. if not sub:
  333. LOG.d(f"create a new Subscription for user {user}")
  334. Subscription.create(
  335. user_id=user.id,
  336. cancel_url=request.form.get("cancel_url"),
  337. update_url=request.form.get("update_url"),
  338. subscription_id=request.form.get("subscription_id"),
  339. event_time=arrow.now(),
  340. next_bill_date=arrow.get(
  341. request.form.get("next_bill_date"), "YYYY-MM-DD"
  342. ).date(),
  343. plan=plan,
  344. )
  345. else:
  346. LOG.d(f"Update an existing Subscription for user {user}")
  347. sub.cancel_url = request.form.get("cancel_url")
  348. sub.update_url = request.form.get("update_url")
  349. sub.subscription_id = request.form.get("subscription_id")
  350. sub.event_time = arrow.now()
  351. sub.next_bill_date = arrow.get(
  352. request.form.get("next_bill_date"), "YYYY-MM-DD"
  353. ).date()
  354. sub.plan = plan
  355. # make sure to set the new plan as not-cancelled
  356. # in case user cancels a plan and subscribes a new plan
  357. sub.cancelled = False
  358. LOG.debug("User %s upgrades!", user)
  359. db.session.commit()
  360. elif request.form.get("alert_name") == "subscription_payment_succeeded":
  361. subscription_id = request.form.get("subscription_id")
  362. LOG.debug("Update subscription %s", subscription_id)
  363. sub: Subscription = Subscription.get_by(subscription_id=subscription_id)
  364. # when user subscribes, the "subscription_payment_succeeded" can arrive BEFORE "subscription_created"
  365. # at that time, subscription object does not exist yet
  366. if sub:
  367. sub.event_time = arrow.now()
  368. sub.next_bill_date = arrow.get(
  369. request.form.get("next_bill_date"), "YYYY-MM-DD"
  370. ).date()
  371. db.session.commit()
  372. elif request.form.get("alert_name") == "subscription_cancelled":
  373. subscription_id = request.form.get("subscription_id")
  374. sub: Subscription = Subscription.get_by(subscription_id=subscription_id)
  375. if sub:
  376. # cancellation_effective_date should be the same as next_bill_date
  377. LOG.error(
  378. "Cancel subscription %s %s on %s, next bill date %s",
  379. subscription_id,
  380. sub.user,
  381. request.form.get("cancellation_effective_date"),
  382. sub.next_bill_date,
  383. )
  384. sub.event_time = arrow.now()
  385. sub.cancelled = True
  386. db.session.commit()
  387. else:
  388. return "No such subscription", 400
  389. elif request.form.get("alert_name") == "subscription_updated":
  390. subscription_id = request.form.get("subscription_id")
  391. sub: Subscription = Subscription.get_by(subscription_id=subscription_id)
  392. if sub:
  393. LOG.debug(
  394. "Update subscription %s %s on %s, next bill date %s",
  395. subscription_id,
  396. sub.user,
  397. request.form.get("cancellation_effective_date"),
  398. sub.next_bill_date,
  399. )
  400. if (
  401. int(request.form.get("subscription_plan_id"))
  402. == PADDLE_MONTHLY_PRODUCT_ID
  403. ):
  404. plan = PlanEnum.monthly
  405. else:
  406. plan = PlanEnum.yearly
  407. sub.cancel_url = request.form.get("cancel_url")
  408. sub.update_url = request.form.get("update_url")
  409. sub.event_time = arrow.now()
  410. sub.next_bill_date = arrow.get(
  411. request.form.get("next_bill_date"), "YYYY-MM-DD"
  412. ).date()
  413. sub.plan = plan
  414. # make sure to set the new plan as not-cancelled
  415. sub.cancelled = False
  416. db.session.commit()
  417. else:
  418. return "No such subscription", 400
  419. return "OK"
  420. def init_extensions(app: Flask):
  421. login_manager.init_app(app)
  422. db.init_app(app)
  423. migrate.init_app(app)
  424. def init_admin(app):
  425. admin = Admin(name="SimpleLogin", template_mode="bootstrap3")
  426. admin.init_app(app, index_view=SLAdminIndexView())
  427. admin.add_view(SLModelView(User, db.session))
  428. admin.add_view(SLModelView(Client, db.session))
  429. admin.add_view(SLModelView(Alias, db.session))
  430. admin.add_view(SLModelView(ClientUser, db.session))
  431. def setup_do_not_track(app):
  432. @app.route("/dnt")
  433. def do_not_track():
  434. return """
  435. <script src="/static/local-storage-polyfill.js"></script>
  436. <script>
  437. // Disable GoatCounter if this script is called
  438. store.set('goatcounter-ignore', 't');
  439. alert("GoatCounter disabled");
  440. window.location.href = "/";
  441. </script>
  442. """
  443. if __name__ == "__main__":
  444. app = create_app()
  445. # enable flask toolbar
  446. # app.config["DEBUG_TB_PROFILER_ENABLED"] = True
  447. # app.config["DEBUG_TB_INTERCEPT_REDIRECTS"] = False
  448. #
  449. # toolbar = DebugToolbarExtension(app)
  450. # warning: only used in local
  451. if RESET_DB:
  452. LOG.warning("reset db, add fake data")
  453. with app.app_context():
  454. fake_data()
  455. if URL.startswith("https"):
  456. LOG.d("enable https")
  457. context = ssl.SSLContext(ssl.PROTOCOL_TLSv1_2)
  458. context.load_cert_chain("local_data/cert.pem", "local_data/key.pem")
  459. app.run(debug=True, host="0.0.0.0", port=7777, ssl_context=context)
  460. else:
  461. app.run(debug=True, host="0.0.0.0", port=7777)