views.py
1 import time 2 import ipaddress 3 import json 4 import flask_mail 5 import datetime 6 import jwt 7 import shortuuid 8 import orjson 9 import babel 10 import hashlib 11 import base64 12 import re 13 import functools 14 import urllib 15 import pymysql 16 import httpx 17 18 from flask import Blueprint, request, g, render_template, make_response, redirect 19 from flask_cors import cross_origin 20 from sqlalchemy import select, func, text, inspect 21 from sqlalchemy.orm import Session 22 from flask_babel import gettext, ngettext, force_locale, get_locale 23 24 from allthethings.extensions import es, es_aux, engine, mariapersist_engine, MariapersistAccounts, mail, MariapersistDownloads, MariapersistLists, MariapersistListEntries, MariapersistDonations, MariapersistFastDownloadAccess 25 from allthethings.page.views import get_aarecords_elasticsearch 26 from config.settings import SECRET_KEY, PAYMENT1_ID, PAYMENT1_KEY, PAYMENT1B_ID, PAYMENT1B_KEY 27 28 import allthethings.utils 29 30 31 account = Blueprint("account", __name__, template_folder="templates") 32 33 34 @account.get("/account/") 35 @allthethings.utils.no_cache() 36 def account_index_page(): 37 if (request.args.get('key', '') != '') and (not bool(re.match(r"^[a-zA-Z\d]+$", request.args.get('key')))): 38 return redirect(f"/account/", code=302) 39 40 account_id = allthethings.utils.get_account_id(request.cookies) 41 if account_id is None: 42 return render_template( 43 "account/index.html", 44 header_active="account", 45 membership_tier_names=allthethings.utils.membership_tier_names(get_locale()), 46 ) 47 48 with Session(mariapersist_engine) as mariapersist_session: 49 account = mariapersist_session.connection().execute(select(MariapersistAccounts).where(MariapersistAccounts.account_id == account_id).limit(1)).first() 50 if account is None: 51 raise Exception("Valid account_id was not found in db!") 52 53 mariapersist_session.connection().connection.ping(reconnect=True) 54 cursor = mariapersist_session.connection().connection.cursor(pymysql.cursors.DictCursor) 55 cursor.execute('SELECT membership_tier, membership_expiration, bonus_downloads FROM mariapersist_memberships WHERE account_id = %(account_id)s AND mariapersist_memberships.membership_expiration >= CURDATE()', { 'account_id': account_id }) 56 memberships = cursor.fetchall() 57 58 membership_tier_names=allthethings.utils.membership_tier_names(get_locale()) 59 membership_dicts = [] 60 for membership in memberships: 61 membership_tier_str = str(membership['membership_tier']) 62 membership_name = membership_tier_names[membership_tier_str] 63 if membership['bonus_downloads'] > 0: 64 membership_name += gettext('common.donation.membership_bonus_parens', num=membership['bonus_downloads']) 65 66 membership_dicts.append({ 67 **membership, 68 'membership_name': membership_name, 69 }) 70 71 return render_template( 72 "account/index.html", 73 header_active="account", 74 account_dict=dict(account), 75 account_fast_download_info=allthethings.utils.get_account_fast_download_info(mariapersist_session, account_id), 76 memberships=membership_dicts, 77 ) 78 79 80 @account.get("/account/downloaded") 81 @allthethings.utils.no_cache() 82 def account_downloaded_page(): 83 account_id = allthethings.utils.get_account_id(request.cookies) 84 if account_id is None: 85 return redirect(f"/account/", code=302) 86 87 with Session(mariapersist_engine) as mariapersist_session: 88 downloads = mariapersist_session.connection().execute(select(MariapersistDownloads).where(MariapersistDownloads.account_id == account_id).order_by(MariapersistDownloads.timestamp.desc()).limit(1000)).all() 89 fast_downloads = mariapersist_session.connection().execute(select(MariapersistFastDownloadAccess).where(MariapersistFastDownloadAccess.account_id == account_id).order_by(MariapersistFastDownloadAccess.timestamp.desc()).limit(1000)).all() 90 91 # TODO: This merging is not great, because the lists will get out of sync, so you get a gap toward the end. 92 fast_downloads_ids_only = set([(download.timestamp, f"md5:{download.md5.hex()}") for download in fast_downloads]) 93 merged_downloads = sorted(set([(download.timestamp, f"md5:{download.md5.hex()}") for download in (downloads+fast_downloads)]), reverse=True) 94 aarecords_downloaded_by_id = {} 95 if len(downloads) > 0: 96 aarecords_downloaded_by_id = {record['id']: record for record in get_aarecords_elasticsearch(list(set([row[1] for row in merged_downloads])))} 97 aarecords_downloaded = [{ **aarecords_downloaded_by_id.get(row[1]), 'extra_download_timestamp': row[0], 'extra_was_fast_download': (row in fast_downloads_ids_only) } for row in merged_downloads if row[1] in aarecords_downloaded_by_id] 98 cutoff_24h = datetime.datetime.utcnow() - datetime.timedelta(hours=24) 99 aarecords_downloaded_last_24h = [row for row in aarecords_downloaded if row['extra_download_timestamp'] >= cutoff_24h] 100 aarecords_downloaded_later = [row for row in aarecords_downloaded if row['extra_download_timestamp'] < cutoff_24h] 101 102 return render_template("account/downloaded.html", header_active="account/downloaded", aarecords_downloaded_last_24h=aarecords_downloaded_last_24h, aarecords_downloaded_later=aarecords_downloaded_later) 103 104 105 @account.post("/account/") 106 @allthethings.utils.no_cache() 107 def account_index_post_page(): 108 account_id = allthethings.utils.account_id_from_secret_key(request.form['key']) 109 if account_id is None: 110 return render_template( 111 "account/index.html", 112 invalid_key=True, 113 header_active="account", 114 membership_tier_names=allthethings.utils.membership_tier_names(get_locale()), 115 ) 116 117 with Session(mariapersist_engine) as mariapersist_session: 118 account = mariapersist_session.connection().execute(select(MariapersistAccounts).where(MariapersistAccounts.account_id == account_id).limit(1)).first() 119 if account is None: 120 return render_template( 121 "account/index.html", 122 invalid_key=True, 123 header_active="account", 124 membership_tier_names=allthethings.utils.membership_tier_names(get_locale()), 125 ) 126 127 mariapersist_session.connection().execute(text('INSERT IGNORE INTO mariapersist_account_logins (account_id, ip) VALUES (:account_id, :ip)') 128 .bindparams(account_id=account_id, ip=allthethings.utils.canonical_ip_bytes(request.remote_addr))) 129 mariapersist_session.commit() 130 131 account_token = jwt.encode( 132 payload={ "a": account_id, "iat": datetime.datetime.now(tz=datetime.timezone.utc) }, 133 key=SECRET_KEY, 134 algorithm="HS256" 135 ) 136 resp = make_response(redirect(f"/account/", code=302)) 137 resp.set_cookie( 138 key=allthethings.utils.ACCOUNT_COOKIE_NAME, 139 value=allthethings.utils.strip_jwt_prefix(account_token), 140 expires=datetime.datetime(9999,1,1), 141 httponly=True, 142 secure=g.secure_domain, 143 domain=g.base_domain, 144 ) 145 return resp 146 147 148 @account.post("/account/register") 149 @allthethings.utils.no_cache() 150 def account_register_page(): 151 with Session(mariapersist_engine) as mariapersist_session: 152 account_id = None 153 for _ in range(5): 154 insert_data = { 'account_id': shortuuid.random(length=7) } 155 try: 156 mariapersist_session.connection().execute(text('INSERT INTO mariapersist_accounts (account_id, display_name) VALUES (:account_id, :account_id)').bindparams(**insert_data)) 157 mariapersist_session.commit() 158 account_id = insert_data['account_id'] 159 break 160 except Exception as err: 161 print("Account creation error", err) 162 pass 163 if account_id is None: 164 raise Exception("Failed to create account after multiple attempts") 165 166 return redirect(f"/account/?key={allthethings.utils.secret_key_from_account_id(account_id)}", code=302) 167 168 169 @account.get("/account/request") 170 @allthethings.utils.no_cache() 171 def request_page(): 172 return render_template("account/request.html", header_active="account/request") 173 174 175 @account.get("/account/upload") 176 @allthethings.utils.no_cache() 177 def upload_page(): 178 return render_template("account/upload.html", header_active="account/upload") 179 180 @account.get("/refer") 181 @allthethings.utils.no_cache() 182 def refer_page(): 183 with Session(mariapersist_engine) as mariapersist_session: 184 account_id = allthethings.utils.get_account_id(request.cookies) 185 account_can_make_referrals = False 186 referral_suffix = None 187 referral_link = None 188 189 if account_id is not None: 190 account_can_make_referrals = allthethings.utils.account_can_make_referrals(mariapersist_session, account_id) 191 referral_suffix = f"#r={account_id}" 192 referral_link = f"https://{g.base_domain}/donate{referral_suffix}" 193 194 return render_template( 195 "account/refer.html", 196 header_active="account/refer", 197 MEMBERSHIP_MAX_BONUS_DOWNLOADS=allthethings.utils.MEMBERSHIP_MAX_BONUS_DOWNLOADS, 198 account_id=account_id, 199 account_can_make_referrals=account_can_make_referrals, 200 referral_suffix=referral_suffix, 201 referral_link=referral_link, 202 ) 203 204 205 @account.get("/list/<string:list_id>") 206 @allthethings.utils.no_cache() 207 def list_page(list_id): 208 current_account_id = allthethings.utils.get_account_id(request.cookies) 209 210 with Session(mariapersist_engine) as mariapersist_session: 211 list_record = mariapersist_session.connection().execute(select(MariapersistLists).where(MariapersistLists.list_id == list_id).limit(1)).first() 212 if list_record is None: 213 return "List not found", 404 214 account = mariapersist_session.connection().execute(select(MariapersistAccounts).where(MariapersistAccounts.account_id == list_record.account_id).limit(1)).first() 215 list_entries = mariapersist_session.connection().execute(select(MariapersistListEntries).where(MariapersistListEntries.list_id == list_id).order_by(MariapersistListEntries.updated.desc()).limit(10000)).all() 216 217 aarecords = [] 218 if len(list_entries) > 0: 219 aarecords = get_aarecords_elasticsearch([entry.resource for entry in list_entries if entry.resource.startswith("md5:")]) 220 221 return render_template( 222 "account/list.html", 223 header_active="account", 224 list_record_dict={ 225 **list_record, 226 'created_delta': list_record.created - datetime.datetime.now(), 227 }, 228 aarecords=aarecords, 229 account_dict=dict(account), 230 current_account_id=current_account_id, 231 ) 232 233 234 @account.get("/profile/<string:account_id>") 235 @allthethings.utils.no_cache() 236 def profile_page(account_id): 237 current_account_id = allthethings.utils.get_account_id(request.cookies) 238 239 with Session(mariapersist_engine) as mariapersist_session: 240 account = mariapersist_session.connection().execute(select(MariapersistAccounts).where(MariapersistAccounts.account_id == account_id).limit(1)).first() 241 lists = mariapersist_session.connection().execute(select(MariapersistLists).where(MariapersistLists.account_id == account_id).order_by(MariapersistLists.updated.desc()).limit(10000)).all() 242 243 if account is None: 244 return render_template("account/profile.html", header_active="account"), 404 245 246 return render_template( 247 "account/profile.html", 248 header_active="account/profile" if account.account_id == current_account_id else "account", 249 account_dict={ 250 **account, 251 'created_delta': account.created - datetime.datetime.now(), 252 }, 253 list_dicts=list(map(dict, lists)), 254 current_account_id=current_account_id, 255 ) 256 257 258 @account.get("/account/profile") 259 @allthethings.utils.no_cache() 260 def account_profile_page(): 261 account_id = allthethings.utils.get_account_id(request.cookies) 262 if account_id is None: 263 return "", 403 264 return redirect(f"/profile/{account_id}", code=302) 265 266 267 @account.get("/donate") 268 @allthethings.utils.no_cache() 269 def donate_page(): 270 with Session(mariapersist_engine) as mariapersist_session: 271 account_id = allthethings.utils.get_account_id(request.cookies) 272 has_made_donations = False 273 existing_unpaid_donation_id = None 274 if account_id is not None: 275 existing_unpaid_donation_id = mariapersist_session.connection().execute(select(MariapersistDonations.donation_id).where((MariapersistDonations.account_id == account_id) & ((MariapersistDonations.processing_status == 0) | (MariapersistDonations.processing_status == 4))).limit(1)).scalar() 276 previous_donation_id = mariapersist_session.connection().execute(select(MariapersistDonations.donation_id).where((MariapersistDonations.account_id == account_id)).limit(1)).scalar() 277 if (existing_unpaid_donation_id is not None) or (previous_donation_id is not None): 278 has_made_donations = True 279 280 ref_account_id = allthethings.utils.get_referral_account_id(mariapersist_session, request.cookies.get('ref_id'), account_id) 281 ref_account_dict = None 282 if ref_account_id is not None: 283 ref_account_dict = dict(mariapersist_session.connection().execute(select(MariapersistAccounts).where(MariapersistAccounts.account_id == ref_account_id).limit(1)).first()) 284 285 return render_template( 286 "account/donate.html", 287 header_active="donate", 288 has_made_donations=has_made_donations, 289 existing_unpaid_donation_id=existing_unpaid_donation_id, 290 membership_costs_data=allthethings.utils.membership_costs_data(get_locale()), 291 membership_tier_names=allthethings.utils.membership_tier_names(get_locale()), 292 MEMBERSHIP_TIER_COSTS=allthethings.utils.MEMBERSHIP_TIER_COSTS, 293 MEMBERSHIP_METHOD_DISCOUNTS=allthethings.utils.MEMBERSHIP_METHOD_DISCOUNTS, 294 MEMBERSHIP_DURATION_DISCOUNTS=allthethings.utils.MEMBERSHIP_DURATION_DISCOUNTS, 295 MEMBERSHIP_DOWNLOADS_PER_DAY=allthethings.utils.MEMBERSHIP_DOWNLOADS_PER_DAY, 296 MEMBERSHIP_METHOD_MINIMUM_CENTS_USD=allthethings.utils.MEMBERSHIP_METHOD_MINIMUM_CENTS_USD, 297 MEMBERSHIP_METHOD_MAXIMUM_CENTS_NATIVE=allthethings.utils.MEMBERSHIP_METHOD_MAXIMUM_CENTS_NATIVE, 298 MEMBERSHIP_MAX_BONUS_DOWNLOADS=allthethings.utils.MEMBERSHIP_MAX_BONUS_DOWNLOADS, 299 days_parity=(datetime.datetime.utcnow() - datetime.datetime(1970,1,1)).days, 300 ref_account_dict=ref_account_dict, 301 ) 302 303 304 @account.get("/donation_faq") 305 @allthethings.utils.no_cache() 306 def donation_faq_page(): 307 return render_template("account/donation_faq.html", header_active="donate") 308 309 @functools.cache 310 def get_order_processing_status_labels(locale): 311 with force_locale(locale): 312 return { 313 0: gettext('common.donation.order_processing_status_labels.0'), 314 1: gettext('common.donation.order_processing_status_labels.1'), 315 2: gettext('common.donation.order_processing_status_labels.2'), 316 3: gettext('common.donation.order_processing_status_labels.3'), 317 4: gettext('common.donation.order_processing_status_labels.4'), 318 5: gettext('common.donation.order_processing_status_labels.5'), 319 } 320 321 322 def make_donation_dict(donation): 323 donation_json = orjson.loads(donation['json']) 324 return { 325 **donation, 326 'json': donation_json, 327 'total_amount_usd': babel.numbers.format_currency(donation.cost_cents_usd / 100.0, 'USD', locale=get_locale()), 328 'monthly_amount_usd': babel.numbers.format_currency(donation_json['monthly_cents'] / 100.0, 'USD', locale=get_locale()), 329 'receipt_id': allthethings.utils.donation_id_to_receipt_id(donation.donation_id), 330 'formatted_native_currency': allthethings.utils.membership_format_native_currency(get_locale(), donation.native_currency_code, donation.cost_cents_native_currency, donation.cost_cents_usd), 331 } 332 333 334 @account.get("/account/donations/<string:donation_id>") 335 @allthethings.utils.no_cache() 336 def donation_page(donation_id): 337 account_id = allthethings.utils.get_account_id(request.cookies) 338 if account_id is None: 339 return "", 403 340 341 donation_confirming = False 342 donation_time_left = datetime.timedelta() 343 donation_time_left_not_much = False 344 donation_time_expired = False 345 donation_pay_amount = "" 346 347 with Session(mariapersist_engine) as mariapersist_session: 348 donation = mariapersist_session.connection().execute(select(MariapersistDonations).where((MariapersistDonations.account_id == account_id) & (MariapersistDonations.donation_id == donation_id)).limit(1)).first() 349 if donation is None: 350 return "", 403 351 352 donation_json = orjson.loads(donation['json']) 353 354 if donation_json['method'] == 'payment1' and donation.processing_status == 0: 355 data = { 356 # Note that these are sorted by key. 357 "money": str(int(float(donation.cost_cents_usd) * 7.0 / 100.0)), 358 "name": "Anna’s Archive Membership", 359 "notify_url": "https://annas-archive.se/dyn/payment1_notify/", 360 "out_trade_no": str(donation.donation_id), 361 "pid": PAYMENT1_ID, 362 "return_url": "https://annas-archive.se/account/", 363 "sitename": "Anna’s Archive", 364 } 365 sign_str = '&'.join([f'{k}={v}' for k, v in data.items()]) + PAYMENT1_KEY 366 sign = hashlib.md5((sign_str).encode()).hexdigest() 367 return redirect(f'https://integrate.payments-gateway.org/submit.php?{urllib.parse.urlencode(data)}&sign={sign}&sign_type=MD5', code=302) 368 if donation_json['method'] == 'payment1_alipay' and donation.processing_status == 0: 369 data = { 370 # Note that these are sorted by key. 371 "money": str(int(float(donation.cost_cents_usd) * 7.0 / 100.0)), 372 "name": "Anna’s Archive Membership", 373 "notify_url": "https://annas-archive.se/dyn/payment1_notify/", 374 "out_trade_no": str(donation.donation_id), 375 "pid": PAYMENT1_ID, 376 "return_url": "https://annas-archive.se/account/", 377 "sitename": "Anna’s Archive", 378 "type": "alipay", 379 } 380 sign_str = '&'.join([f'{k}={v}' for k, v in data.items()]) + PAYMENT1_KEY 381 sign = hashlib.md5((sign_str).encode()).hexdigest() 382 return redirect(f'https://integrate.payments-gateway.org/submit.php?{urllib.parse.urlencode(data)}&sign={sign}&sign_type=MD5', code=302) 383 if donation_json['method'] == 'payment1_wechat' and donation.processing_status == 0: 384 data = { 385 # Note that these are sorted by key. 386 "money": str(int(float(donation.cost_cents_usd) * 7.0 / 100.0)), 387 "name": "Anna’s Archive Membership", 388 "notify_url": "https://annas-archive.se/dyn/payment1_notify/", 389 "out_trade_no": str(donation.donation_id), 390 "pid": PAYMENT1_ID, 391 "return_url": "https://annas-archive.se/account/", 392 "sitename": "Anna’s Archive", 393 "type": "wechat", 394 } 395 sign_str = '&'.join([f'{k}={v}' for k, v in data.items()]) + PAYMENT1_KEY 396 sign = hashlib.md5((sign_str).encode()).hexdigest() 397 return redirect(f'https://integrate.payments-gateway.org/submit.php?{urllib.parse.urlencode(data)}&sign={sign}&sign_type=MD5', code=302) 398 399 if donation_json['method'] in ['payment1b', 'payment1bb'] and donation.processing_status == 0: 400 data = { 401 # Note that these are sorted by key. 402 "money": str(int(float(donation.cost_cents_usd) * 7.0 / 100.0)), 403 "name": "Anna’s Archive Membership", 404 "notify_url": "https://annas-archive.org/dyn/payment1b_notify/", 405 "out_trade_no": str(donation.donation_id), 406 "pid": PAYMENT1B_ID, 407 "return_url": "https://annas-archive.org/account/", 408 "sitename": "Anna’s Archive", 409 } 410 sign_str = '&'.join([f'{k}={v}' for k, v in data.items()]) + PAYMENT1B_KEY 411 sign = hashlib.md5((sign_str).encode()).hexdigest() 412 return redirect(f'https://pay.freshcloud.one/submit.php?{urllib.parse.urlencode(data)}&sign={sign}&sign_type=MD5', code=302) 413 414 if donation_json['method'] in ['payment2', 'payment2paypal', 'payment2cashapp', 'payment2cc'] and donation.processing_status == 0: 415 donation_time_left = donation.created - datetime.datetime.now() + datetime.timedelta(days=5) 416 if donation_time_left < datetime.timedelta(hours=2): 417 donation_time_left_not_much = True 418 if donation_time_left < datetime.timedelta(): 419 donation_time_expired = True 420 421 if donation_json['payment2_request']['pay_amount']*100 == int(donation_json['payment2_request']['pay_amount']*100): 422 donation_pay_amount = f"{donation_json['payment2_request']['pay_amount']:.2f}" 423 else: 424 donation_pay_amount = f"{donation_json['payment2_request']['pay_amount']}" 425 426 mariapersist_session.connection().connection.ping(reconnect=True) 427 cursor = mariapersist_session.connection().connection.cursor(pymysql.cursors.DictCursor) 428 payment2_status, payment2_request_success = allthethings.utils.payment2_check(cursor, donation_json['payment2_request']['payment_id']) 429 if not payment2_request_success: 430 raise Exception("Not payment2_request_success in donation_page") 431 if payment2_status['payment_status'] == 'confirming': 432 donation_confirming = True 433 434 if donation_json['method'] in ['hoodpay'] and donation.processing_status == 0: 435 donation_time_left = donation.created - datetime.datetime.now() + datetime.timedelta(minutes=30) 436 if donation_time_left < datetime.timedelta(minutes=10): 437 donation_time_left_not_much = True 438 if donation_time_left < datetime.timedelta(): 439 donation_time_expired = True 440 441 mariapersist_session.connection().connection.ping(reconnect=True) 442 cursor = mariapersist_session.connection().connection.cursor(pymysql.cursors.DictCursor) 443 444 hoodpay_status, hoodpay_request_success = allthethings.utils.hoodpay_check(cursor, donation_json['hoodpay_request']['data']['id'], str(donation.donation_id)) 445 if not hoodpay_request_success: 446 raise Exception("Not hoodpay_request_success in donation_page") 447 if hoodpay_status['status'] in ['PENDING', 'PROCESSING']: 448 donation_confirming = True 449 450 donation_dict = make_donation_dict(donation) 451 452 donation_email = f"AnnaReceipts+{donation_dict['receipt_id']}@proton.me" 453 if donation_json['method'] == 'amazon': 454 donation_email = f"giftcards+{donation_dict['receipt_id']}@annas-mail.org" 455 456 # No need to call get_referral_account_id here, because we have already verified, and we don't want to take away their bonus because 457 # the referrer's membership expired. 458 ref_account_id = donation_json.get('ref_account_id') 459 ref_account_dict = None 460 if ref_account_id is not None: 461 ref_account_dict = dict(mariapersist_session.connection().execute(select(MariapersistAccounts).where(MariapersistAccounts.account_id == ref_account_id).limit(1)).first()) 462 463 return render_template( 464 "account/donation.html", 465 header_active="account/donations", 466 donation_dict=donation_dict, 467 order_processing_status_labels=get_order_processing_status_labels(get_locale()), 468 donation_confirming=donation_confirming, 469 donation_time_left=donation_time_left, 470 donation_time_left_not_much=donation_time_left_not_much, 471 donation_time_expired=donation_time_expired, 472 donation_pay_amount=donation_pay_amount, 473 donation_email=donation_email, 474 ref_account_dict=ref_account_dict, 475 ) 476 477 478 @account.get("/account/donations/") 479 @allthethings.utils.no_cache() 480 def donations_page(): 481 account_id = allthethings.utils.get_account_id(request.cookies) 482 if account_id is None: 483 return "", 403 484 485 with Session(mariapersist_engine) as mariapersist_session: 486 donations = mariapersist_session.connection().execute(select(MariapersistDonations).where(MariapersistDonations.account_id == account_id).order_by(MariapersistDonations.created.desc()).limit(10000)).all() 487 488 return render_template( 489 "account/donations.html", 490 header_active="account/donations", 491 donation_dicts=[make_donation_dict(donation) for donation in donations], 492 order_processing_status_labels=get_order_processing_status_labels(get_locale()), 493 ) 494 495 496 497 498 499 500