zwift_offline.py 194 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120312131223123312431253126312731283129313031313132313331343135313631373138313931403141314231433144314531463147314831493150315131523153315431553156315731583159316031613162316331643165316631673168316931703171317231733174317531763177317831793180318131823183318431853186318731883189319031913192319331943195319631973198319932003201320232033204320532063207320832093210321132123213321432153216321732183219322032213222322332243225322632273228322932303231323232333234323532363237323832393240324132423243324432453246324732483249325032513252325332543255325632573258325932603261326232633264326532663267326832693270327132723273327432753276327732783279328032813282328332843285328632873288328932903291329232933294329532963297329832993300330133023303330433053306330733083309331033113312331333143315331633173318331933203321332233233324332533263327332833293330333133323333333433353336333733383339334033413342334333443345334633473348334933503351335233533354335533563357335833593360336133623363336433653366336733683369337033713372337333743375337633773378337933803381338233833384338533863387338833893390339133923393339433953396339733983399340034013402340334043405340634073408340934103411341234133414341534163417341834193420342134223423342434253426342734283429343034313432343334343435343634373438343934403441344234433444344534463447344834493450345134523453345434553456345734583459346034613462346334643465346634673468346934703471347234733474347534763477347834793480348134823483348434853486348734883489349034913492349334943495349634973498349935003501350235033504350535063507350835093510351135123513351435153516351735183519352035213522352335243525352635273528352935303531353235333534353535363537353835393540354135423543354435453546354735483549355035513552355335543555355635573558355935603561356235633564356535663567356835693570357135723573357435753576357735783579358035813582358335843585358635873588358935903591359235933594359535963597359835993600360136023603360436053606360736083609361036113612361336143615361636173618361936203621362236233624362536263627362836293630363136323633363436353636363736383639364036413642364336443645364636473648364936503651365236533654365536563657365836593660366136623663366436653666366736683669367036713672367336743675367636773678367936803681368236833684368536863687368836893690369136923693369436953696369736983699370037013702370337043705370637073708370937103711371237133714371537163717371837193720372137223723372437253726372737283729373037313732373337343735373637373738373937403741374237433744374537463747374837493750375137523753375437553756375737583759376037613762376337643765376637673768376937703771377237733774377537763777377837793780378137823783378437853786378737883789379037913792379337943795379637973798379938003801380238033804380538063807380838093810381138123813381438153816381738183819382038213822382338243825382638273828382938303831383238333834383538363837383838393840384138423843384438453846384738483849385038513852385338543855385638573858385938603861386238633864386538663867386838693870387138723873387438753876387738783879388038813882388338843885388638873888388938903891389238933894389538963897389838993900390139023903390439053906390739083909391039113912391339143915391639173918391939203921392239233924392539263927392839293930393139323933393439353936393739383939394039413942394339443945394639473948394939503951395239533954395539563957395839593960396139623963396439653966396739683969397039713972397339743975397639773978397939803981398239833984398539863987398839893990399139923993399439953996399739983999400040014002400340044005400640074008400940104011401240134014401540164017401840194020402140224023402440254026402740284029403040314032403340344035403640374038403940404041404240434044404540464047404840494050405140524053405440554056405740584059406040614062406340644065406640674068406940704071407240734074407540764077407840794080408140824083408440854086408740884089409040914092409340944095409640974098409941004101410241034104410541064107410841094110411141124113411441154116411741184119412041214122412341244125412641274128412941304131413241334134413541364137413841394140414141424143414441454146414741484149415041514152415341544155415641574158415941604161416241634164416541664167416841694170417141724173417441754176417741784179418041814182418341844185418641874188418941904191419241934194419541964197419841994200420142024203420442054206420742084209421042114212421342144215421642174218421942204221422242234224422542264227422842294230423142324233423442354236423742384239424042414242424342444245424642474248424942504251425242534254425542564257425842594260426142624263426442654266426742684269427042714272427342744275427642774278427942804281428242834284428542864287428842894290429142924293429442954296429742984299430043014302430343044305430643074308430943104311431243134314431543164317431843194320432143224323432443254326432743284329433043314332433343344335433643374338433943404341434243434344434543464347434843494350435143524353435443554356435743584359436043614362436343644365
  1. #!/usr/bin/env python
  2. import calendar
  3. import datetime
  4. import logging
  5. import os
  6. import signal
  7. import random
  8. import sys
  9. import tempfile
  10. import time
  11. import math
  12. import threading
  13. import re
  14. import smtplib
  15. import ssl
  16. import requests
  17. import json
  18. import base64
  19. import uuid
  20. import jwt
  21. import sqlalchemy
  22. import fitdecode
  23. import xml.etree.ElementTree as ET
  24. from copy import deepcopy
  25. from functools import wraps
  26. from io import BytesIO
  27. from shutil import copyfile
  28. from urllib.parse import quote
  29. from flask import Flask, request, jsonify, redirect, render_template, url_for, flash, session, make_response, send_file, send_from_directory
  30. from flask_login import UserMixin, AnonymousUserMixin, LoginManager, login_user, current_user, login_required, logout_user
  31. from gevent.pywsgi import WSGIServer
  32. from google.protobuf.json_format import MessageToDict, Parse
  33. from flask_sqlalchemy import SQLAlchemy
  34. from werkzeug.security import generate_password_hash, check_password_hash
  35. from email.mime.multipart import MIMEMultipart
  36. from email.mime.text import MIMEText
  37. from Crypto.Cipher import AES
  38. from Crypto.Random import get_random_bytes
  39. from collections import deque
  40. from itertools import islice
  41. sys.path.append(os.path.join(sys.path[0], 'protobuf')) # otherwise import in .proto does not work
  42. import udp_node_msgs_pb2
  43. import tcp_node_msgs_pb2
  44. import activity_pb2
  45. import goal_pb2
  46. import login_pb2
  47. import per_session_info_pb2
  48. import profile_pb2
  49. import segment_result_pb2
  50. import route_result_pb2
  51. import race_result_pb2
  52. import world_pb2
  53. import zfiles_pb2
  54. import hash_seeds_pb2
  55. import events_pb2
  56. import variants_pb2
  57. import playback_pb2
  58. import user_storage_pb2
  59. import online_sync
  60. import fitness_pb2
  61. logging.basicConfig(level=os.environ.get("LOGLEVEL", "INFO"))
  62. logger = logging.getLogger('zoffline')
  63. logger.setLevel(logging.DEBUG)
  64. logging.getLogger('sqlalchemy.engine').setLevel(logging.WARN)
  65. if getattr(sys, 'frozen', False):
  66. # If we're running as a pyinstaller bundle
  67. SCRIPT_DIR = sys._MEIPASS
  68. STORAGE_DIR = "%s/storage" % os.path.dirname(sys.executable)
  69. LOGS_DIR = "%s/logs" % os.path.dirname(sys.executable)
  70. else:
  71. SCRIPT_DIR = os.path.dirname(os.path.realpath(__file__))
  72. STORAGE_DIR = "%s/storage" % SCRIPT_DIR
  73. LOGS_DIR = "%s/logs" % SCRIPT_DIR
  74. def make_dir(name):
  75. try:
  76. if not os.path.isdir(name):
  77. os.makedirs(name)
  78. except IOError as e:
  79. logger.error("failed to create dir (%s): %s", name, str(e))
  80. return False
  81. return True
  82. # Ensure storage dir exists
  83. if not make_dir(STORAGE_DIR):
  84. sys.exit(1)
  85. SSL_DIR = "%s/ssl" % SCRIPT_DIR
  86. DATABASE_PATH = "%s/zwift-offline.db" % STORAGE_DIR
  87. DATABASE_CUR_VER = 3
  88. ZWIFT_VER_CUR = ET.parse('%s/cdn/gameassets/Zwift_Updates_Root/Zwift_ver_cur.xml' % SCRIPT_DIR).getroot().get('sversion')
  89. # For auth server
  90. AUTOLAUNCH_FILE = "%s/auto_launch.txt" % STORAGE_DIR
  91. SERVER_IP_FILE = "%s/server-ip.txt" % STORAGE_DIR
  92. if os.path.exists(SERVER_IP_FILE):
  93. with open(SERVER_IP_FILE, 'r') as f:
  94. server_ip = f.read().rstrip('\r\n')
  95. else:
  96. import socket
  97. s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
  98. try:
  99. s.connect(('10.254.254.254', 1))
  100. server_ip = s.getsockname()[0]
  101. except:
  102. server_ip = '127.0.0.1'
  103. finally:
  104. s.close()
  105. logger.info("server-ip.txt not found, using %s", server_ip)
  106. SECRET_KEY_FILE = "%s/secret-key.txt" % STORAGE_DIR
  107. ENABLEGHOSTS_FILE = "%s/enable_ghosts.txt" % STORAGE_DIR
  108. GHOST_PROFILE = None
  109. GHOST_PROFILE_FILE = "%s/ghost_profile.txt" % STORAGE_DIR
  110. if os.path.exists(GHOST_PROFILE_FILE):
  111. with open(GHOST_PROFILE_FILE) as f:
  112. GHOST_PROFILE = json.load(f)
  113. ALL_TIME_LEADERBOARDS = os.path.exists("%s/all_time_leaderboards.txt" % STORAGE_DIR)
  114. MULTIPLAYER = os.path.exists("%s/multiplayer.txt" % STORAGE_DIR)
  115. if MULTIPLAYER:
  116. if not make_dir(LOGS_DIR):
  117. sys.exit(1)
  118. from logging.handlers import RotatingFileHandler
  119. logHandler = RotatingFileHandler('%s/zoffline.log' % LOGS_DIR, maxBytes=1000000, backupCount=10)
  120. logger.addHandler(logHandler)
  121. CREDENTIALS_KEY_FILE = "%s/credentials-key.bin" % STORAGE_DIR
  122. if not os.path.exists(CREDENTIALS_KEY_FILE):
  123. with open(CREDENTIALS_KEY_FILE, 'wb') as f:
  124. f.write(get_random_bytes(32))
  125. with open(CREDENTIALS_KEY_FILE, 'rb') as f:
  126. credentials_key = f.read()
  127. GARMIN_DOMAIN = 'garmin.com'
  128. GARMIN_DOMAIN_FILE = '%s/garmin_domain.txt' % STORAGE_DIR
  129. if os.path.exists(GARMIN_DOMAIN_FILE):
  130. with open(GARMIN_DOMAIN_FILE) as f:
  131. GARMIN_DOMAIN = f.readline().rstrip('\r\n')
  132. import warnings
  133. with warnings.catch_warnings():
  134. from stravalib.client import Client
  135. from tokens import *
  136. # Android uses https for cdn
  137. app = Flask(__name__, static_folder='%s/cdn/gameassets' % SCRIPT_DIR, static_url_path='/gameassets', template_folder='%s/cdn/static/web/launcher' % SCRIPT_DIR)
  138. app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///{db}'.format(db=DATABASE_PATH)
  139. app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
  140. if not os.path.exists(SECRET_KEY_FILE):
  141. with open(SECRET_KEY_FILE, 'wb') as f:
  142. f.write(os.urandom(16))
  143. with open(SECRET_KEY_FILE, 'rb') as f:
  144. app.config['SECRET_KEY'] = f.read()
  145. app.config['MAX_CONTENT_LENGTH'] = 4 * 1024 * 1024 # A typical .fit file with power, cadence, and heartrate data recorded in December 2024 is approximately 1.3 MB / 4 hours.
  146. db = SQLAlchemy()
  147. db.init_app(app)
  148. online = {}
  149. ghosts_enabled = {}
  150. player_update_queue = {}
  151. zc_connect_queue = {}
  152. player_partial_profiles = {}
  153. map_override = {}
  154. climb_override = {}
  155. global_bookmarks = {}
  156. global_race_results = {}
  157. restarting = False
  158. restarting_in_minutes = 0
  159. reload_pacer_bots = False
  160. with open(os.path.join(SCRIPT_DIR, "data", "climbs.txt")) as f:
  161. CLIMBS = json.load(f)
  162. with open(os.path.join(SCRIPT_DIR, "data", "game_dictionary.txt")) as f:
  163. GD = json.load(f, object_hook=lambda d: {int(k) if k.lstrip('-').isdigit() else k: v for k, v in d.items()})
  164. class User(UserMixin, db.Model):
  165. player_id = db.Column(db.Integer, primary_key=True)
  166. username = db.Column(db.Text, unique=True, nullable=False)
  167. first_name = db.Column(db.Text, nullable=False)
  168. last_name = db.Column(db.Text, nullable=False)
  169. pass_hash = db.Column(db.Text, nullable=False)
  170. enable_ghosts = db.Column(db.Integer, nullable=False, default=1)
  171. is_admin = db.Column(db.Integer, nullable=False, default=0)
  172. remember = db.Column(db.Integer, nullable=False, default=0)
  173. def __repr__(self):
  174. return self.username
  175. def get_id(self):
  176. return self.player_id
  177. def get_token(self):
  178. dt = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(minutes=30)
  179. return jwt.encode({'user': self.player_id, 'exp': dt}, app.config['SECRET_KEY'], algorithm='HS256')
  180. @staticmethod
  181. def verify_token(token):
  182. try:
  183. data = jwt.decode(token, app.config['SECRET_KEY'], algorithms='HS256')
  184. except:
  185. return None
  186. id = data.get('user')
  187. if id:
  188. return db.session.get(User, id)
  189. return None
  190. class AnonUser(User, AnonymousUserMixin, db.Model):
  191. username = "zoffline"
  192. first_name = "z"
  193. last_name = "offline"
  194. enable_ghosts = os.path.isfile(ENABLEGHOSTS_FILE)
  195. def is_authenticated(self):
  196. return True
  197. class Activity(db.Model):
  198. id = db.Column(db.Integer, primary_key=True)
  199. player_id = db.Column(db.Integer)
  200. course_id = db.Column(db.Integer)
  201. name = db.Column(db.Text)
  202. f5 = db.Column(db.Integer)
  203. privateActivity = db.Column(db.Integer)
  204. start_date = db.Column(db.Text)
  205. end_date = db.Column(db.Text)
  206. distanceInMeters = db.Column(db.Float)
  207. avg_heart_rate = db.Column(db.Float)
  208. max_heart_rate = db.Column(db.Float)
  209. avg_watts = db.Column(db.Float)
  210. max_watts = db.Column(db.Float)
  211. avg_cadence = db.Column(db.Float)
  212. max_cadence = db.Column(db.Float)
  213. avg_speed = db.Column(db.Float)
  214. max_speed = db.Column(db.Float)
  215. calories = db.Column(db.Float)
  216. total_elevation = db.Column(db.Float)
  217. strava_upload_id = db.Column(db.Integer)
  218. strava_activity_id = db.Column(db.Integer)
  219. f22 = db.Column(db.Text)
  220. f23 = db.Column(db.Integer)
  221. fit = db.Column(db.LargeBinary)
  222. fit_filename = db.Column(db.Text)
  223. subgroupId = db.Column(db.Integer)
  224. workoutHash = db.Column(db.Integer)
  225. progressPercentage = db.Column(db.Float)
  226. sport = db.Column(db.Integer)
  227. date = db.Column(db.Text)
  228. act_f32 = db.Column(db.Float)
  229. act_f33 = db.Column(db.Text)
  230. act_f34 = db.Column(db.Text)
  231. privacy = db.Column(db.Integer)
  232. fitness_privacy = db.Column(db.Integer)
  233. club_name = db.Column(db.Text)
  234. movingTimeInMs = db.Column(db.Integer)
  235. work = db.Column(db.Float)
  236. tss = db.Column(db.Float)
  237. normalized_power = db.Column(db.Float)
  238. power_zones = db.Column(db.Text)
  239. power_units = db.Column(db.Float)
  240. class SegmentResult(db.Model):
  241. id = db.Column(db.Integer, primary_key=True)
  242. player_id = db.Column(db.Integer)
  243. server_realm = db.Column(db.Integer)
  244. course_id = db.Column(db.Integer)
  245. segment_id = db.Column(db.Integer)
  246. event_subgroup_id = db.Column(db.Integer)
  247. first_name = db.Column(db.Text)
  248. last_name = db.Column(db.Text)
  249. world_time = db.Column(db.Integer)
  250. finish_time_str = db.Column(db.Text)
  251. elapsed_ms = db.Column(db.Integer)
  252. power_source_model = db.Column(db.Integer)
  253. weight_in_grams = db.Column(db.Integer)
  254. f14 = db.Column(db.Integer)
  255. avg_power = db.Column(db.Integer)
  256. is_male = db.Column(db.Integer)
  257. time = db.Column(db.Text)
  258. player_type = db.Column(db.Integer)
  259. avg_hr = db.Column(db.Integer)
  260. sport = db.Column(db.Integer)
  261. activity_id = db.Column(db.Integer)
  262. f22 = db.Column(db.Integer)
  263. f23 = db.Column(db.Text)
  264. class RouteResult(db.Model):
  265. id = db.Column(db.Integer, primary_key=True)
  266. player_id = db.Column(db.Integer)
  267. server_realm = db.Column(db.Integer)
  268. map_id = db.Column(db.Integer)
  269. route_hash = db.Column(db.Integer)
  270. event_id = db.Column(db.Integer)
  271. world_time = db.Column(db.Integer)
  272. elapsed_ms = db.Column(db.Integer)
  273. power_type = db.Column(db.Integer)
  274. weight_in_grams = db.Column(db.Integer)
  275. height_in_centimeters = db.Column(db.Integer)
  276. ftp = db.Column(db.Integer)
  277. avg_power = db.Column(db.Integer)
  278. max_power = db.Column(db.Integer)
  279. avg_hr = db.Column(db.Integer)
  280. max_hr = db.Column(db.Integer)
  281. calories = db.Column(db.Integer)
  282. gender = db.Column(db.Integer)
  283. player_type = db.Column(db.Integer)
  284. sport = db.Column(db.Integer)
  285. activity_id = db.Column(db.Integer)
  286. steering = db.Column(db.Integer)
  287. hr_monitor = db.Column(db.Text)
  288. power_meter = db.Column(db.Text)
  289. controllable = db.Column(db.Text)
  290. cadence_sensor = db.Column(db.Text)
  291. class Goal(db.Model):
  292. id = db.Column(db.Integer, primary_key=True)
  293. player_id = db.Column(db.Integer)
  294. sport = db.Column(db.Integer)
  295. name = db.Column(db.Text)
  296. type = db.Column(db.Integer)
  297. periodicity = db.Column(db.Integer)
  298. target_distance = db.Column(db.Float)
  299. target_duration = db.Column(db.Float)
  300. actual_distance = db.Column(db.Float)
  301. actual_duration = db.Column(db.Float)
  302. created_on = db.Column(db.Integer)
  303. period_end_date = db.Column(db.Integer)
  304. status = db.Column(db.Integer)
  305. timezone = db.Column(db.Text)
  306. class GoalMetrics(db.Model):
  307. id = db.Column(db.Integer, primary_key=True)
  308. player_id = db.Column(db.Integer)
  309. weekGoalTSS = db.Column(db.Integer)
  310. weekGoalCalories = db.Column(db.Integer)
  311. weekGoalKjs = db.Column(db.Integer)
  312. weekGoalDistanceKilometers = db.Column(db.Float)
  313. weekGoalDistanceMiles = db.Column(db.Float)
  314. weekGoalTimeMinutes = db.Column(db.Integer)
  315. lastUpdated = db.Column(db.Text)
  316. currentGoalSetting = db.Column(db.Text)
  317. class Playback(db.Model):
  318. id = db.Column(db.Integer, primary_key=True)
  319. player_id = db.Column(db.Integer, nullable=False)
  320. uuid = db.Column(db.Text, nullable=False)
  321. segment_id = db.Column(db.Integer, nullable=False)
  322. time = db.Column(db.Float, nullable=False)
  323. world_time = db.Column(db.Integer, nullable=False)
  324. type = db.Column(db.Integer)
  325. class Zfile(db.Model):
  326. id = db.Column(db.Integer, primary_key=True)
  327. folder = db.Column(db.Text, nullable=False)
  328. filename = db.Column(db.Text, nullable=False)
  329. timestamp = db.Column(db.Integer, nullable=False)
  330. player_id = db.Column(db.Integer, nullable=False)
  331. class PrivateEvent(db.Model): # cached in glb_private_events
  332. id = db.Column(db.Integer, primary_key=True)
  333. json = db.Column(db.Text, nullable=False)
  334. class Notification(db.Model):
  335. id = db.Column(db.Integer, primary_key=True)
  336. event_id = db.Column(db.Integer, nullable=False)
  337. player_id = db.Column(db.Integer, nullable=False)
  338. json = db.Column(db.Text, nullable=False)
  339. class ActivityFile(db.Model):
  340. id = db.Column(db.Integer, primary_key=True)
  341. activity_id = db.Column(db.Integer, nullable=False)
  342. full = db.Column(db.Integer, nullable=False)
  343. class ActivityImage(db.Model):
  344. id = db.Column(db.Integer, primary_key=True)
  345. player_id = db.Column(db.Integer, nullable=False)
  346. activity_id = db.Column(db.Integer, nullable=False)
  347. class PowerCurve(db.Model):
  348. id = db.Column(db.Integer, primary_key=True)
  349. player_id = db.Column(db.Integer, nullable=False)
  350. time = db.Column(db.Text, nullable=False)
  351. power = db.Column(db.Integer, nullable=False)
  352. power_wkg = db.Column(db.Float, nullable=False)
  353. timestamp = db.Column(db.Integer, nullable=False)
  354. class Version(db.Model):
  355. version = db.Column(db.Integer, primary_key=True)
  356. class Relay:
  357. def __init__(self, key = b''):
  358. self.ri = 0
  359. self.tcp_ci = 0
  360. self.udp_ci = 0
  361. self.tcp_r_sn = 0
  362. self.tcp_t_sn = 0
  363. self.udp_r_sn = 0
  364. self.udp_t_sn = 0
  365. self.key = key
  366. class PartialProfile:
  367. player_id = 0
  368. first_name = ''
  369. last_name = ''
  370. country_code = 0
  371. route = 0
  372. player_type = 'NORMAL'
  373. male = True
  374. weight_in_grams = 0
  375. height_in_millimeters = 0
  376. imageSrc = ''
  377. use_metric = True
  378. time = 0
  379. def to_json(self):
  380. return {"countryCode": self.country_code,
  381. "enrolledZwiftAcademy": False, #don't need
  382. "firstName": self.first_name,
  383. "id": self.player_id,
  384. "imageSrc": self.imageSrc,
  385. "lastName": self.last_name,
  386. "male": self.male,
  387. "playerType": self.player_type }
  388. class Bookmark:
  389. name = ''
  390. state = None
  391. class RaceResults:
  392. results = None
  393. time = 0
  394. class Online:
  395. total = 0
  396. richmond = 0
  397. watopia = 0
  398. london = 0
  399. makuriislands = 0
  400. newyork = 0
  401. innsbruck = 0
  402. yorkshire = 0
  403. france = 0
  404. paris = 0
  405. scotland = 0
  406. courses_lookup = {
  407. 2: 'Richmond',
  408. 4: 'Unknown', # event specific?
  409. 6: 'Watopia',
  410. 7: 'London',
  411. 8: 'New York',
  412. 9: 'Innsbruck',
  413. 10: 'Bologna', # event specific
  414. 11: 'Yorkshire',
  415. 12: 'Crit City', # event specific
  416. 13: 'Makuri Islands',
  417. 14: 'France',
  418. 15: 'Paris',
  419. 16: 'Gravel Mountain', # event specific
  420. 17: 'Scotland'
  421. }
  422. def get_online():
  423. online_in_region = Online()
  424. for p_id in online:
  425. player_state = online[p_id]
  426. course = get_course(player_state)
  427. course_name = courses_lookup[course]
  428. if course_name == 'Richmond':
  429. online_in_region.richmond += 1
  430. elif course_name == 'Watopia':
  431. online_in_region.watopia += 1
  432. elif course_name == 'London':
  433. online_in_region.london += 1
  434. elif course_name == 'Makuri Islands':
  435. online_in_region.makuriislands += 1
  436. elif course_name == 'New York':
  437. online_in_region.newyork += 1
  438. elif course_name == 'Innsbruck':
  439. online_in_region.innsbruck += 1
  440. elif course_name == 'Yorkshire':
  441. online_in_region.yorkshire += 1
  442. elif course_name == 'France':
  443. online_in_region.france += 1
  444. elif course_name == 'Paris':
  445. online_in_region.paris += 1
  446. elif course_name == 'Scotland':
  447. online_in_region.scotland += 1
  448. online_in_region.total += 1
  449. return online_in_region
  450. def toSigned(n, byte_count):
  451. return int.from_bytes(n.to_bytes(byte_count, 'little'), 'little', signed=True)
  452. def imageSrc(player_id):
  453. if os.path.isfile(os.path.join(STORAGE_DIR, str(player_id), 'avatarLarge.jpg')):
  454. return "https://us-or-rly101.zwift.com/download/%s/avatarLarge.jpg" % player_id
  455. else:
  456. return None
  457. def get_partial_profile(player_id):
  458. if not player_id in player_partial_profiles:
  459. partial_profile = PartialProfile()
  460. partial_profile.player_id = player_id
  461. if player_id in global_pace_partners.keys():
  462. profile = global_pace_partners[player_id].profile
  463. for f in profile.public_attributes:
  464. if f.id == 1766985504: #crc32 of "PACE PARTNER - ROUTE"
  465. partial_profile.route = toSigned(f.number_value, 4) if f.number_value >= 0 else -toSigned(-f.number_value, 4)
  466. break
  467. elif player_id in global_bots.keys():
  468. profile = global_bots[player_id].profile
  469. elif player_id > 10000000:
  470. g_id = math.floor(player_id / 10000000)
  471. p_id = player_id - g_id * 10000000
  472. partial_profile.first_name = ''
  473. partial_profile.last_name = time_since(global_ghosts[p_id].play[g_id-1].date)
  474. return partial_profile
  475. else:
  476. profile = profile_pb2.PlayerProfile()
  477. #Read from disk
  478. profile_file = '%s/%s/profile.bin' % (STORAGE_DIR, player_id)
  479. if os.path.isfile(profile_file):
  480. with open(profile_file, 'rb') as fd:
  481. profile.ParseFromString(fd.read())
  482. else:
  483. user = User.query.filter_by(player_id=player_id).first()
  484. if user:
  485. partial_profile.first_name = user.first_name
  486. partial_profile.last_name = user.last_name
  487. return partial_profile
  488. partial_profile.imageSrc = imageSrc(player_id)
  489. partial_profile.first_name = profile.first_name
  490. partial_profile.last_name = profile.last_name
  491. partial_profile.country_code = profile.country_code
  492. partial_profile.player_type = profile_pb2.PlayerType.Name(jsf(profile, 'player_type', 1))
  493. partial_profile.male = profile.is_male
  494. partial_profile.weight_in_grams = profile.weight_in_grams
  495. partial_profile.height_in_millimeters = profile.height_in_millimeters
  496. partial_profile.use_metric = profile.use_metric
  497. player_partial_profiles[player_id] = partial_profile
  498. player_partial_profiles[player_id].time = time.monotonic()
  499. return player_partial_profiles[player_id]
  500. def get_course(state):
  501. return (state.f19 & 0xff0000) >> 16
  502. def road_id(state):
  503. return (state.aux3 & 0xff00) >> 8
  504. def is_forward(state):
  505. return (state.f19 & 4) != 0
  506. def is_nearby(s1, s2):
  507. if s1 is None or s2 is None:
  508. return False
  509. if s1.watchingRiderId == s2.id or s2.watchingRiderId == s1.id:
  510. return True
  511. if get_course(s1) == get_course(s2):
  512. dist = math.sqrt((s2.x - s1.x)**2 + (s2.z - s1.z)**2 + (s2.y_altitude - s1.y_altitude)**2)
  513. if dist <= 100000 or road_id(s1) == road_id(s2):
  514. return True
  515. return False
  516. # We store flask-login's cookie in the "fake" JWT that we give Zwift.
  517. # Make it a cookie again to reuse flask-login on API calls.
  518. def jwt_to_session_cookie(f):
  519. @wraps(f)
  520. def wrapper(*args, **kwargs):
  521. if not MULTIPLAYER:
  522. return f(*args, **kwargs)
  523. token = request.headers.get('Authorization')
  524. if token and not session.get('_user_id'):
  525. token = jwt.decode(token.split()[1], options=({'verify_signature': False, 'verify_aud': False}))
  526. request.cookies = request.cookies.copy() # request.cookies is an immutable dict
  527. request.cookies['remember_token'] = token['session_cookie']
  528. login_manager._load_user()
  529. return f(*args, **kwargs)
  530. return wrapper
  531. @app.route("/signup/", methods=["GET", "POST"])
  532. def signup():
  533. if request.method == "POST":
  534. username = request.form['username']
  535. password = request.form['password']
  536. confirm_password = request.form['confirm_password']
  537. first_name = request.form['first_name']
  538. last_name = request.form['last_name']
  539. if not (username and password and confirm_password and first_name and last_name):
  540. flash("All fields are required.")
  541. return redirect(url_for('signup'))
  542. if not re.match(r"[^@]+@[^@]+\.[^@]+", username):
  543. flash("Username is not a valid e-mail address.")
  544. return redirect(url_for('signup'))
  545. if password != confirm_password:
  546. flash("Passwords did not match.")
  547. return redirect(url_for('signup'))
  548. hashed_pwd = generate_password_hash(password, 'scrypt')
  549. new_user = User(username=username, pass_hash=hashed_pwd, first_name=first_name, last_name=last_name)
  550. db.session.add(new_user)
  551. try:
  552. db.session.commit()
  553. except sqlalchemy.exc.IntegrityError:
  554. flash("Username {u} is not available.".format(u=username))
  555. return redirect(url_for('signup'))
  556. flash("User account has been created.")
  557. return redirect(url_for("login"))
  558. return render_template("signup.html")
  559. def check_sha256_hash(pwhash, password):
  560. import hmac
  561. try:
  562. method, salt, hashval = pwhash.split("$", 2)
  563. except ValueError:
  564. return False
  565. return hmac.compare_digest(hmac.new(salt.encode("utf-8"), password.encode("utf-8"), method).hexdigest(), hashval)
  566. def make_profile_dir(player_id):
  567. return make_dir(os.path.join(STORAGE_DIR, str(player_id)))
  568. @app.route("/login/", methods=["GET", "POST"])
  569. def login():
  570. if request.method == "POST":
  571. username = request.form['username']
  572. password = request.form['password']
  573. remember = bool(request.form.get('remember'))
  574. if not (username and password):
  575. flash("Username and password cannot be empty.")
  576. return redirect(url_for('login'))
  577. user = User.query.filter_by(username=username).first()
  578. if user and user.pass_hash.startswith('sha256'): # sha256 is deprecated in werkzeug 3
  579. if check_sha256_hash(user.pass_hash, password):
  580. user.pass_hash = generate_password_hash(password, 'scrypt')
  581. db.session.commit()
  582. else:
  583. flash("Invalid username or password.")
  584. return redirect(url_for('login'))
  585. if user and check_password_hash(user.pass_hash, password):
  586. login_user(user, remember=True)
  587. user.remember = remember
  588. db.session.commit()
  589. if not make_profile_dir(user.player_id):
  590. return '', 500
  591. return redirect(url_for("user_home", username=username, enable_ghosts=bool(user.enable_ghosts), online=get_online()))
  592. else:
  593. flash("Invalid username or password.")
  594. if current_user.is_authenticated and current_user.remember:
  595. return redirect(url_for("user_home", username=current_user.username, enable_ghosts=bool(current_user.enable_ghosts), online=get_online()))
  596. user = User.verify_token(request.args.get('token'))
  597. if user:
  598. login_user(user, remember=False)
  599. return redirect(url_for("reset", username=user.username))
  600. return render_template("login_form.html")
  601. def send_mail(username, token):
  602. try:
  603. with open('%s/gmail_credentials.txt' % STORAGE_DIR) as f:
  604. sender_email = f.readline().rstrip('\r\n')
  605. password = f.readline().rstrip('\r\n')
  606. with smtplib.SMTP_SSL("smtp.gmail.com", 465, context=ssl.create_default_context()) as server:
  607. server.login(sender_email, password)
  608. message = MIMEMultipart()
  609. message['From'] = sender_email
  610. message['To'] = username
  611. message['Subject'] = "Password reset"
  612. content = "https://%s/login/?token=%s" % (server_ip, token)
  613. message.attach(MIMEText(content, 'plain'))
  614. server.sendmail(sender_email, username, message.as_string())
  615. server.close()
  616. except Exception as exc:
  617. logger.warning('send e-mail: %s' % repr(exc))
  618. return False
  619. return True
  620. @app.route("/forgot/", methods=["GET", "POST"])
  621. def forgot():
  622. if request.method == "POST":
  623. username = request.form['username']
  624. if not username:
  625. flash("Username cannot be empty.")
  626. return redirect(url_for('forgot'))
  627. if not re.match(r"[^@]+@[^@]+\.[^@]+", username):
  628. flash("Username is not a valid e-mail address.")
  629. return redirect(url_for('forgot'))
  630. user = User.query.filter_by(username=username).first()
  631. if user:
  632. if send_mail(username, user.get_token()):
  633. flash("E-mail sent.")
  634. else:
  635. flash("Could not send e-mail.")
  636. else:
  637. flash("Invalid username.")
  638. return render_template("forgot.html")
  639. @app.route("/api/push/fcm/<type>/<token>", methods=["POST", "DELETE"])
  640. @app.route("/api/push/fcm/<type>/<token>/enables", methods=["PUT"])
  641. def api_push_fcm_production(type, token):
  642. return '', 500
  643. @app.route("/api/users", methods=["POST"]) # Android user registration
  644. def api_users():
  645. first_name = request.json['profile']['firstName']
  646. last_name = request.json['profile']['lastName']
  647. if MULTIPLAYER:
  648. username = request.json['email']
  649. if not re.match(r"[^@]+@[^@]+\.[^@]+", username):
  650. return '', 400
  651. pass_hash = generate_password_hash(request.json['password'], 'scrypt')
  652. user = User(username=username, pass_hash=pass_hash, first_name=first_name, last_name=last_name)
  653. db.session.add(user)
  654. try:
  655. db.session.commit()
  656. except sqlalchemy.exc.IntegrityError:
  657. return '', 400
  658. login_user(user, remember=True)
  659. if not make_profile_dir(user.player_id):
  660. return '', 500
  661. else:
  662. AnonUser.first_name = first_name
  663. AnonUser.last_name = last_name
  664. return '', 200
  665. @app.route("/api/users/reset-password-email", methods=["PUT"]) # Android password reset
  666. def api_users_reset_password_email():
  667. username = request.form['username']
  668. if re.match(r"[^@]+@[^@]+\.[^@]+", username):
  669. user = User.query.filter_by(username=username).first()
  670. if user:
  671. send_mail(username, user.get_token())
  672. return '', 200
  673. @app.route("/api/users/password-reset/", methods=["POST"])
  674. @jwt_to_session_cookie
  675. @login_required
  676. def api_users_password_reset():
  677. password = request.form.get("password-new")
  678. confirm_password = request.form.get("password-confirm")
  679. if password != confirm_password:
  680. return 'passwords not match', 500
  681. hashed_pwd = generate_password_hash(password, 'scrypt')
  682. current_user.pass_hash = hashed_pwd
  683. db.session.commit()
  684. return '', 200
  685. @app.route("/reset/<username>/", methods=["GET", "POST"])
  686. @login_required
  687. def reset(username):
  688. if request.method == "POST":
  689. password = request.form['password']
  690. confirm_password = request.form['confirm_password']
  691. if not (password and confirm_password):
  692. flash("All fields are required.")
  693. return redirect(url_for('reset', username=current_user.username))
  694. if password != confirm_password:
  695. flash("Passwords did not match.")
  696. return redirect(url_for('reset', username=current_user.username))
  697. hashed_pwd = generate_password_hash(password, 'scrypt')
  698. current_user.pass_hash = hashed_pwd
  699. db.session.commit()
  700. flash("Password changed.")
  701. return redirect(url_for('settings', username=current_user.username))
  702. return render_template("reset.html", username=current_user.username)
  703. @app.route("/strava/<username>/", methods=["GET", "POST"])
  704. @login_required
  705. def strava(username):
  706. profile_dir = '%s/%s' % (STORAGE_DIR, current_user.player_id)
  707. api = '%s/strava_api.bin' % profile_dir
  708. token = os.path.isfile('%s/strava_token.txt' % profile_dir)
  709. if request.method == "POST":
  710. if request.form['client_id'] == "" or request.form['client_secret'] == "":
  711. flash("Client ID and secret can't be empty.")
  712. return render_template("strava.html", username=current_user.username, token=token)
  713. encrypt_credentials(api, (request.form['client_id'], request.form['client_secret']))
  714. cred = decrypt_credentials(api)
  715. return render_template("strava.html", username=current_user.username, cid=cred[0], cs=cred[1], token=token)
  716. @app.route("/strava_auth", methods=['GET'])
  717. @login_required
  718. def strava_auth():
  719. cred = decrypt_credentials('%s/%s/strava_api.bin' % (STORAGE_DIR, current_user.player_id))
  720. client = Client()
  721. url = client.authorization_url(client_id=cred[0],
  722. redirect_uri='https://launcher.zwift.com/authorization',
  723. scope=['activity:write'])
  724. return redirect(url)
  725. @app.route("/authorization", methods=["GET", "POST"])
  726. @login_required
  727. def authorization():
  728. try:
  729. cred = decrypt_credentials('%s/%s/strava_api.bin' % (STORAGE_DIR, current_user.player_id))
  730. client = Client()
  731. code = request.args.get('code')
  732. token_response = client.exchange_code_for_token(client_id=int(cred[0]), client_secret=cred[1], code=code)
  733. with open(os.path.join(STORAGE_DIR, str(current_user.player_id), 'strava_token.txt'), 'w') as f:
  734. f.write(cred[0] + '\n')
  735. f.write(cred[1] + '\n')
  736. f.write(token_response['access_token'] + '\n')
  737. f.write(token_response['refresh_token'] + '\n')
  738. f.write(str(token_response['expires_at']) + '\n')
  739. flash("Strava authorized.")
  740. except Exception as exc:
  741. logger.warning('Strava: %s' % repr(exc))
  742. flash("Strava authorization canceled.")
  743. return redirect(url_for('strava', username=current_user.username))
  744. def encrypt_credentials(file, cred):
  745. try:
  746. cipher_suite = AES.new(credentials_key, AES.MODE_CFB)
  747. with open(file, 'wb') as f:
  748. f.write(cipher_suite.iv)
  749. f.write(cipher_suite.encrypt((cred[0] + '\n' + cred[1]).encode('UTF-8')))
  750. flash("Credentials saved.")
  751. except Exception as exc:
  752. logger.warning('encrypt_credentials: %s' % repr(exc))
  753. flash("Error saving %s" % file)
  754. def decrypt_credentials(file):
  755. cred = ('', '')
  756. if os.path.isfile(file):
  757. try:
  758. with open(file, 'rb') as f:
  759. cipher_suite = AES.new(credentials_key, AES.MODE_CFB, iv=f.read(16))
  760. lines = cipher_suite.decrypt(f.read()).decode('UTF-8').splitlines()
  761. cred = (lines[0], lines[1])
  762. except Exception as exc:
  763. logger.warning('decrypt_credentials: %s' % repr(exc))
  764. return cred
  765. def backup_file(file):
  766. if os.path.isfile(file):
  767. copyfile(file, "%s-%s.bak" % (file, datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d-%H-%M-%S")))
  768. @app.route("/profile/<username>/", methods=["GET", "POST"])
  769. @login_required
  770. def profile(username):
  771. profile_dir = os.path.join(STORAGE_DIR, str(current_user.player_id))
  772. file = os.path.join(profile_dir, 'zwift_credentials.bin')
  773. cred = decrypt_credentials(file)
  774. if request.method == "POST":
  775. if request.form['username'] == "" or request.form['password'] == "":
  776. flash("Zwift credentials can't be empty.")
  777. return render_template("profile.html", username=current_user.username)
  778. if not request.form.get("zwift_profile") and not request.form.get("achievements") and not request.form.get("save_zwift"):
  779. flash("Select at least one option.")
  780. return render_template("profile.html", username=current_user.username, uname=cred[0], passw=cred[1])
  781. username = request.form['username']
  782. password = request.form['password']
  783. session = requests.session()
  784. try:
  785. access_token, refresh_token = online_sync.login(session, username, password)
  786. try:
  787. if request.form.get("zwift_profile"):
  788. profile = online_sync.query(session, access_token, "api/profiles/me")
  789. profile_file = '%s/profile.bin' % profile_dir
  790. backup_file(profile_file)
  791. with open(profile_file, 'wb') as f:
  792. f.write(profile)
  793. login_request = login_pb2.LoginRequest()
  794. login_request.key = random.randbytes(16)
  795. login_response = login_pb2.LoginResponse()
  796. login_response.ParseFromString(online_sync.api_login(session, access_token, login_request))
  797. login_response_dict = MessageToDict(login_response, preserving_proto_field_name=True)
  798. if 'economy_config' in login_response_dict:
  799. economy_config_file = '%s/economy_config.txt' % profile_dir
  800. backup_file(economy_config_file)
  801. with open(economy_config_file, 'w') as f:
  802. json.dump(login_response_dict['economy_config'], f, indent=2)
  803. if request.form.get("achievements"):
  804. achievements = online_sync.query(session, access_token, "achievement/loadPlayerAchievements")
  805. achievements_file = '%s/achievements.bin' % profile_dir
  806. backup_file(achievements_file)
  807. with open(achievements_file, 'wb') as f:
  808. f.write(achievements)
  809. online_sync.logout(session, refresh_token)
  810. if request.form.get("save_zwift"):
  811. encrypt_credentials(file, (username, password))
  812. except Exception as exc:
  813. logger.warning('Zwift profile: %s' % repr(exc))
  814. flash("Error downloading profile.")
  815. return render_template("profile.html", username=current_user.username, uname=cred[0], passw=cred[1])
  816. except Exception as exc:
  817. logger.warning('online_sync.login: %s' % repr(exc))
  818. flash("Invalid username or password.")
  819. return render_template("profile.html", username=current_user.username)
  820. return redirect(url_for('settings', username=current_user.username))
  821. return render_template("profile.html", username=current_user.username, uname=cred[0], passw=cred[1])
  822. @app.route("/garmin/<username>/", methods=["GET", "POST"])
  823. @login_required
  824. def garmin(username):
  825. file = '%s/%s/garmin_credentials.bin' % (STORAGE_DIR, current_user.player_id)
  826. token = os.path.isfile('%s/%s/garth/oauth1_token.json' % (STORAGE_DIR, current_user.player_id))
  827. if request.method == "POST":
  828. if request.form['username'] == "" or request.form['password'] == "":
  829. flash("Garmin credentials can't be empty.")
  830. return render_template("garmin.html", username=current_user.username, token=token)
  831. encrypt_credentials(file, (request.form['username'], request.form['password']))
  832. cred = decrypt_credentials(file)
  833. return render_template("garmin.html", username=current_user.username, uname=cred[0], passw=cred[1], token=token)
  834. @app.route("/garmin_auth", methods=['GET'])
  835. @login_required
  836. def garmin_auth():
  837. try:
  838. import garth
  839. garth.configure(domain=GARMIN_DOMAIN)
  840. username, password = decrypt_credentials('%s/%s/garmin_credentials.bin' % (STORAGE_DIR, current_user.player_id))
  841. garth.login(username, password)
  842. garth.save('%s/%s/garth' % (STORAGE_DIR, current_user.player_id))
  843. flash("Garmin authorized.")
  844. except Exception as exc:
  845. logger.warning('garmin_auth: %s' % repr(exc))
  846. flash("Garmin authorization failed.")
  847. return redirect(url_for('garmin', username=current_user.username))
  848. @app.route("/intervals/<username>/", methods=["GET", "POST"])
  849. @login_required
  850. def intervals(username):
  851. file = '%s/%s/intervals_credentials.bin' % (STORAGE_DIR, current_user.player_id)
  852. if request.method == "POST":
  853. if request.form['athlete_id'] == "" or request.form['api_key'] == "":
  854. flash("Intervals.icu credentials can't be empty.")
  855. return render_template("intervals.html", username=current_user.username)
  856. encrypt_credentials(file, (request.form['athlete_id'], request.form['api_key']))
  857. return redirect(url_for('settings', username=current_user.username))
  858. cred = decrypt_credentials(file)
  859. return render_template("intervals.html", username=current_user.username, aid=cred[0], akey=cred[1])
  860. @app.route("/user/<username>/")
  861. @login_required
  862. def user_home(username):
  863. return render_template("user_home.html", username=current_user.username, enable_ghosts=bool(current_user.enable_ghosts), climbs=CLIMBS,
  864. online=get_online(), is_admin=current_user.is_admin, restarting=restarting, restarting_in_minutes=restarting_in_minutes)
  865. def enqueue_player_update(player_id, wa_bytes):
  866. if not player_id in player_update_queue:
  867. player_update_queue[player_id] = list()
  868. player_update_queue[player_id].append(wa_bytes)
  869. def send_message(message, sender='Server', recipients=None):
  870. player_update = udp_node_msgs_pb2.WorldAttribute()
  871. player_update.server_realm = udp_node_msgs_pb2.ZofflineConstants.RealmID
  872. player_update.wa_type = udp_node_msgs_pb2.WA_TYPE.WAT_SPA
  873. player_update.world_time_born = world_time()
  874. player_update.world_time_expire = world_time() + 60000
  875. player_update.wa_f12 = 1
  876. player_update.timestamp = int(time.time()*1000000)
  877. chat_message = tcp_node_msgs_pb2.SocialPlayerAction()
  878. chat_message.player_id = 0
  879. chat_message.to_player_id = 0
  880. chat_message.spa_type = tcp_node_msgs_pb2.SocialPlayerActionType.SOCIAL_TEXT_MESSAGE
  881. chat_message.firstName = sender
  882. chat_message.lastName = ''
  883. chat_message.message = message
  884. chat_message.countryCode = 0
  885. player_update.payload = chat_message.SerializeToString()
  886. player_update_s = player_update.SerializeToString()
  887. if not recipients:
  888. recipients = online.keys()
  889. for receiving_player_id in recipients:
  890. enqueue_player_update(receiving_player_id, player_update_s)
  891. def send_restarting_message():
  892. global restarting
  893. global restarting_in_minutes
  894. while restarting:
  895. send_message('Restarting / Shutting down in %s minutes. Save your progress or continue riding until server is back online' % restarting_in_minutes)
  896. time.sleep(60)
  897. restarting_in_minutes -= 1
  898. if restarting and restarting_in_minutes == 0:
  899. message = 'See you later! Look for the back online message.'
  900. send_message(message)
  901. discord.send_message(message)
  902. time.sleep(6)
  903. os.kill(os.getpid(), signal.SIGINT)
  904. @app.route("/restart")
  905. @login_required
  906. def restart_server():
  907. global restarting
  908. global restarting_in_minutes
  909. if bool(current_user.is_admin):
  910. restarting = True
  911. restarting_in_minutes = 10
  912. send_restarting_message_thread = threading.Thread(target=send_restarting_message)
  913. send_restarting_message_thread.start()
  914. discord.send_message('Restarting / Shutting down in %s minutes. Save your progress or continue riding until server is back online' % restarting_in_minutes)
  915. return redirect(url_for('user_home', username=current_user.username))
  916. @app.route("/cancelrestart")
  917. @login_required
  918. def cancel_restart_server():
  919. global restarting
  920. global restarting_in_minutes
  921. if bool(current_user.is_admin):
  922. restarting = False
  923. restarting_in_minutes = 0
  924. message = 'Restart of the server has been cancelled. Ride on!'
  925. send_message(message)
  926. discord.send_message(message)
  927. return redirect(url_for('user_home', username=current_user.username))
  928. @app.route("/reloadbots")
  929. @login_required
  930. def reload_bots():
  931. global reload_pacer_bots
  932. if bool(current_user.is_admin):
  933. reload_pacer_bots = True
  934. return redirect(url_for('user_home', username=current_user.username))
  935. @app.route("/settings/<username>/", methods=["GET", "POST"])
  936. @login_required
  937. def settings(username):
  938. profile_dir = os.path.join(STORAGE_DIR, str(current_user.player_id))
  939. if request.method == 'POST':
  940. uploaded_file = request.files['file']
  941. if uploaded_file.filename in ['profile.bin', 'achievements.bin']:
  942. file_path = os.path.join(profile_dir, uploaded_file.filename)
  943. backup_file(file_path)
  944. uploaded_file.save(file_path)
  945. else:
  946. flash("Invalid file name.")
  947. profile = None
  948. profile_file = os.path.join(profile_dir, 'profile.bin')
  949. if os.path.isfile(profile_file):
  950. stat = os.stat(profile_file)
  951. profile = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(stat.st_mtime))
  952. achievements = None
  953. achievements_file = os.path.join(profile_dir, 'achievements.bin')
  954. if os.path.isfile(achievements_file):
  955. stat = os.stat(achievements_file)
  956. achievements = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(stat.st_mtime))
  957. return render_template("settings.html", username=current_user.username, profile=profile, achievements=achievements)
  958. @app.route("/download/<filename>", methods=["GET"])
  959. @login_required
  960. def download(filename):
  961. file = os.path.join(STORAGE_DIR, str(current_user.player_id), filename)
  962. if os.path.isfile(file):
  963. return send_file(file)
  964. @app.route("/download/<int:player_id>/avatarLarge.jpg", methods=["GET"])
  965. def download_avatarLarge(player_id):
  966. profile_file = os.path.join(STORAGE_DIR, str(player_id), 'avatarLarge.jpg')
  967. if os.path.isfile(profile_file):
  968. return send_file(profile_file, mimetype='image/jpeg')
  969. else:
  970. return '', 404
  971. @app.route("/delete/<path:filename>", methods=["GET"])
  972. @login_required
  973. def delete(filename):
  974. credentials = ['zwift_credentials.bin', 'intervals_credentials.bin']
  975. strava = ['strava_api.bin', 'strava_token.txt']
  976. garmin = ['garmin_credentials.bin', 'garth/oauth1_token.json']
  977. if filename not in ['profile.bin', 'achievements.bin'] + credentials + strava + garmin:
  978. return '', 403
  979. delete_file = os.path.join(STORAGE_DIR, str(current_user.player_id), filename)
  980. if os.path.isfile(delete_file):
  981. os.remove(delete_file)
  982. if filename in strava:
  983. return redirect(url_for('strava', username=current_user.username))
  984. if filename in garmin:
  985. return redirect(url_for('garmin', username=current_user.username))
  986. if filename in credentials:
  987. flash("Credentials removed.")
  988. return redirect(url_for('settings', username=current_user.username))
  989. @app.route("/power_curves/<username>/", methods=["GET", "POST"])
  990. @login_required
  991. def power_curves(username):
  992. if request.method == "POST":
  993. player_id = current_user.player_id
  994. PowerCurve.query.filter_by(player_id=player_id).delete()
  995. db.session.commit()
  996. if request.form.get('create'):
  997. fit_dir = os.path.join(STORAGE_DIR, str(player_id), 'fit')
  998. if os.path.isdir(fit_dir):
  999. for fit_file in os.listdir(fit_dir):
  1000. create_power_curve(player_id, os.path.join(fit_dir, fit_file))
  1001. flash("Power curves created.")
  1002. else:
  1003. flash("Power curves deleted.")
  1004. return redirect(url_for('settings', username=current_user.username))
  1005. return render_template("power_curves.html", username=current_user.username)
  1006. @app.route("/logout/<username>")
  1007. @login_required
  1008. def logout(username):
  1009. session.clear()
  1010. logout_user()
  1011. flash("Successfully logged out.")
  1012. return redirect(url_for('login'))
  1013. def insert_protobuf_into_db(table_name, msg, exclude_fields=[], json_fields=[]):
  1014. msg_dict = MessageToDict(msg, preserving_proto_field_name=True, use_integers_for_enums=True)
  1015. for key in exclude_fields:
  1016. if key in msg_dict:
  1017. del msg_dict[key]
  1018. for key in json_fields:
  1019. if key in msg_dict:
  1020. msg_dict[key] = json.dumps(msg_dict[key])
  1021. if 'id' in msg_dict:
  1022. del msg_dict['id']
  1023. row = table_name(**msg_dict)
  1024. db.session.add(row)
  1025. db.session.commit()
  1026. return row.id
  1027. def update_protobuf_in_db(table_name, msg, id, exclude_fields=[], json_fields=[]):
  1028. msg_dict = MessageToDict(msg, preserving_proto_field_name=True, use_integers_for_enums=True)
  1029. for key in exclude_fields:
  1030. if key in msg_dict:
  1031. del msg_dict[key]
  1032. for key in json_fields:
  1033. if key in msg_dict:
  1034. msg_dict[key] = json.dumps(msg_dict[key])
  1035. table_name.query.filter_by(id=id).update(msg_dict)
  1036. db.session.commit()
  1037. def row_to_protobuf(row, msg, exclude_fields=[]):
  1038. for key in row.keys():
  1039. if key in exclude_fields:
  1040. continue
  1041. if row[key] is None:
  1042. continue
  1043. setattr(msg, key, row[key])
  1044. return msg
  1045. def world_time():
  1046. return int((time.time()-1414016075)*1000)
  1047. @app.route('/api/clubs/club/can-create', methods=['GET'])
  1048. def api_clubs_club_cancreate():
  1049. return jsonify({"reason": "DISABLED", "result": False})
  1050. @app.route('/api/event-feed', methods=['GET']) #from=1646723199600&limit=25&sport=CYCLING
  1051. def api_eventfeed():
  1052. limit = int(request.args.get('limit'))
  1053. sport = request.args.get('sport')
  1054. events = get_events(limit, sport)
  1055. json_events = convert_events_to_json(events)
  1056. json_data = []
  1057. for e in json_events:
  1058. json_data.append({"event": e})
  1059. return jsonify({"data":json_data,"cursor":None})
  1060. @app.route('/api/recommendations/recommendation', methods=['GET'])
  1061. def api_recommendations_recommendation():
  1062. return jsonify([{"type": "EVENT"}, {"type": "RIDE_WITH"}])
  1063. @app.route('/api/campaign/profile/campaigns', methods=['GET'])
  1064. @app.route('/api/announcements/active', methods=['GET'])
  1065. @app.route('/api/recommendation/profile', methods=['GET'])
  1066. @app.route('/api/subscription/plan', methods=['GET'])
  1067. @app.route('/api/quest/quests/all-quests', methods=['GET'])
  1068. @app.route('/api/quest/quests/my-quests', methods=['GET'])
  1069. @app.route('/api/workout/schedule/list', methods=['GET'])
  1070. def api_empty_arrays():
  1071. return jsonify([])
  1072. @app.route('/api/assetcms/<path:path>', methods=['GET'])
  1073. def api_assetcms(path):
  1074. return jsonify()
  1075. def activity_moving_time(activity):
  1076. try:
  1077. return int((datetime.datetime.strptime(activity.end_date, '%Y-%m-%dT%H:%M:%SZ') - datetime.datetime.strptime(activity.start_date, '%Y-%m-%dT%H:%M:%SZ')).total_seconds() * 1000)
  1078. except:
  1079. return 0
  1080. def activity_row_to_json(activity, details=False):
  1081. profile = get_partial_profile(activity.player_id)
  1082. data = {"id":activity.id,"profile":{"id":str(activity.player_id),"firstName":profile.first_name,"lastName":profile.last_name,
  1083. "imageSrc":profile.imageSrc,"approvalRequired":None},"worldId":activity.course_id,"name":activity.name,"sport":str_sport(activity.sport),
  1084. "startDate":activity.start_date,"endDate":activity.end_date,"distanceInMeters":activity.distanceInMeters,
  1085. "totalElevation":activity.total_elevation,"calories":activity.calories,"primaryImageUrl":"","feedImageThumbnailUrl":"",
  1086. "lastSaveDate":activity.date,"movingTimeInMs":activity_moving_time(activity),"avgSpeedInMetersPerSecond":activity.avg_speed,
  1087. "activityRideOnCount":0,"activityCommentCount":0,"privacy":"PUBLIC","eventId":None,"rideOnGiven":False,"id_str":str(activity.id)}
  1088. if details:
  1089. extra_data = {"avgWatts":activity.avg_watts,"maxWatts":activity.max_watts,"avgHeartRate":activity.avg_heart_rate,
  1090. "maxHeartRate":activity.max_heart_rate,"avgCadenceInRotationsPerMinute":activity.avg_cadence,
  1091. "maxCadenceInRotationsPerMinute":activity.max_cadence,"maxSpeedInMetersPerSecond":activity.max_speed}
  1092. data.update(extra_data)
  1093. return data
  1094. def select_activities_json(player_id, limit, start_after=None, in_progress=True):
  1095. filters = [Activity.distanceInMeters > 1]
  1096. if player_id:
  1097. filters.append(Activity.player_id == player_id)
  1098. if not in_progress:
  1099. filters.append(Activity.end_date != None)
  1100. if start_after:
  1101. filters.append(Activity.id < int(start_after))
  1102. rows = Activity.query.filter(*filters).order_by(Activity.date.desc()).limit(limit)
  1103. ret = []
  1104. for row in rows:
  1105. ret.append(activity_row_to_json(row))
  1106. return ret
  1107. @app.route('/api/activity-feed/feed/', methods=['GET'])
  1108. @app.route('/api/activity-feed-service-v2/feed/just-me', methods=['GET'])
  1109. @jwt_to_session_cookie
  1110. @login_required
  1111. def api_activity_feed():
  1112. limit = int(request.args.get('limit'))
  1113. feed_type = request.args.get('feedType')
  1114. start_after = request.args.get('start_after_activity_id')
  1115. profile_id = None
  1116. in_progress = False
  1117. if feed_type == 'JUST_ME' or request.path.endswith('just-me'):
  1118. profile_id = current_user.player_id
  1119. elif feed_type == 'OTHER_PROFILE':
  1120. profile_id = int(request.args.get('profile_id'))
  1121. elif feed_type == 'PREVIEW':
  1122. in_progress = True
  1123. # todo: FAVORITES, FOLLOWEES (showing all for now)
  1124. ret = select_activities_json(profile_id, limit, start_after, in_progress)
  1125. return jsonify(ret)
  1126. def create_activity_file(fit_file, small_file, full_file=None):
  1127. data = {"powerInWatts": [], "cadencePerMin": [], "heartRate": [], "distanceInCm": [], "speedInCmPerSec": [], "timeInSec": [], "altitudeInCm": [], "latlng": []}
  1128. start_time = 0
  1129. with fitdecode.FitReader(fit_file) as fit:
  1130. for frame in fit:
  1131. if frame.frame_type == fitdecode.FIT_FRAME_DATA and frame.name == 'record':
  1132. power = cadence = heart_rate = distance = speed = time = altitude = position_lat = position_long = None
  1133. for f in frame.fields:
  1134. if f.name == "power" and f.value is not None: power = int(f.value)
  1135. elif f.name == "cadence" and f.value is not None: cadence = int(f.value)
  1136. elif f.name == "heart_rate" and f.value is not None: heart_rate = int(f.value)
  1137. elif f.name == "distance" and f.value is not None: distance = int(f.value * 100)
  1138. elif f.name == "speed" and f.value is not None: speed = int(f.value * 100)
  1139. elif f.name == "timestamp" and f.value is not None:
  1140. timestamp = int(f.value.timestamp())
  1141. if start_time == 0: start_time = timestamp
  1142. time = timestamp - start_time
  1143. elif f.name == "altitude" and f.value is not None: altitude = int(f.value * 100)
  1144. elif f.name == "position_lat" and f.value is not None: position_lat = round(f.value / 11930465, 6)
  1145. elif f.name == "position_long" and f.value is not None: position_long = round(f.value / 11930465, 6)
  1146. if None not in {power, cadence, heart_rate, distance, speed, time, altitude, position_lat, position_long}:
  1147. data["powerInWatts"].append(power)
  1148. data["cadencePerMin"].append(cadence)
  1149. data["heartRate"].append(heart_rate)
  1150. data["distanceInCm"].append(distance)
  1151. data["speedInCmPerSec"].append(speed)
  1152. data["timeInSec"].append(time)
  1153. data["altitudeInCm"].append(altitude)
  1154. data["latlng"].append([position_lat, position_long])
  1155. if data["powerInWatts"]:
  1156. if full_file:
  1157. with open(full_file, 'w') as f:
  1158. json.dump(data, f)
  1159. step = len(data["powerInWatts"]) // 1000
  1160. if step > 1:
  1161. for d in data:
  1162. data[d] = data[d][::step]
  1163. with open(small_file, 'w') as f:
  1164. json.dump(data, f)
  1165. @app.route('/api/activities/<int:activity_id>', methods=['GET'])
  1166. @jwt_to_session_cookie
  1167. @login_required
  1168. def api_activities(activity_id):
  1169. row = Activity.query.filter_by(id=activity_id).first()
  1170. if row:
  1171. activity = activity_row_to_json(row, True)
  1172. activities_dir = '%s/activities' % STORAGE_DIR
  1173. if not make_dir(activities_dir):
  1174. return '', 400
  1175. fit_file = '%s/%s/fit/%s - %s' % (STORAGE_DIR, row.player_id, row.id, row.fit_filename)
  1176. # fullDataUrl is never fetched, creating only downsampled file
  1177. file = ActivityFile.query.filter_by(activity_id=row.id, full=0).first()
  1178. if not file and os.path.isfile(fit_file):
  1179. file = ActivityFile(activity_id=row.id, full=0)
  1180. db.session.add(file)
  1181. db.session.commit()
  1182. if file:
  1183. activity_file = '%s/%s' % (activities_dir, file.id)
  1184. if not os.path.isfile(activity_file) and os.path.isfile(fit_file):
  1185. try:
  1186. create_activity_file(fit_file, activity_file)
  1187. except Exception as exc:
  1188. logger.warning('create_activity_file: %s' % repr(exc))
  1189. if os.path.isfile(activity_file):
  1190. url = 'https://us-or-rly101.zwift.com/api/activities/%s/file/%s' % (row.id, file.id)
  1191. data = {"fitnessData": {"status": "AVAILABLE", "fullDataUrl": url, "smallDataUrl": url}}
  1192. activity.update(data)
  1193. return jsonify(activity)
  1194. return '', 404
  1195. @app.route('/api/activities/<int:activity_id>/file/<file>')
  1196. def api_activities_file(activity_id, file):
  1197. return send_from_directory('%s/activities' % STORAGE_DIR, file)
  1198. @app.route('/api/auth', methods=['GET'])
  1199. def api_auth():
  1200. return {"realm": "zwift","launcher": "https://launcher.zwift.com/launcher","url": "https://secure.zwift.com/auth/"}
  1201. @app.route('/api/server', methods=['GET'])
  1202. def api_server():
  1203. return {"build":"zwift_1.267.0","version":"1.267.0"}
  1204. @app.route('/api/servers', methods=['GET'])
  1205. def api_servers():
  1206. return {"baseUrl":"https://us-or-rly101.zwift.com/relay"}
  1207. @app.route('/api/clubs/club/list/my-clubs', methods=['GET'])
  1208. @app.route('/api/clubs/club/reset-my-active-club.proto', methods=['POST'])
  1209. @app.route('/api/clubs/club/featured', methods=['GET'])
  1210. @app.route('/api/clubs/club', methods=['GET'])
  1211. def api_clubs():
  1212. return jsonify({"total": 0, "results": []})
  1213. @app.route('/api/clubs/club/my-clubs-summary', methods=['GET'])
  1214. def api_clubs_club_my_clubs_summary():
  1215. return jsonify({"invitedCount": 0, "requestedCount": 0, "results": []})
  1216. @app.route('/api/clubs/club/list/my-clubs.proto', methods=['GET'])
  1217. @app.route('/api/campaign/proto/campaigns', methods=['GET'])
  1218. @app.route('/api/campaign/proto/campaigns/completed', methods=['GET'])
  1219. @app.route('/api/campaign/public/proto/campaigns/active', methods=['GET'])
  1220. @app.route('/api/player-playbacks/player/settings', methods=['GET', 'POST']) # TODO: private = \x08\x01 (1: 1)
  1221. @app.route('/api/scoring/current', methods=['GET'])
  1222. @app.route('/api/game-asset-patching-service/manifest', methods=['GET'])
  1223. @app.route('/api/workout/progress', methods=['POST'])
  1224. def api_proto_empty():
  1225. return '', 200
  1226. @app.route('/api/game_info/version', methods=['GET'])
  1227. def api_gameinfo_version():
  1228. game_info_file = os.path.join(SCRIPT_DIR, "data", "game_info.txt")
  1229. with open(game_info_file, mode="r", encoding="utf-8-sig") as f:
  1230. data = json.load(f)
  1231. return {"version": data['gameInfoHash']}
  1232. @app.route('/api/game_info', methods=['GET'])
  1233. def api_gameinfo():
  1234. game_info_file = os.path.join(SCRIPT_DIR, "data", "game_info.txt")
  1235. with open(game_info_file, mode="r", encoding="utf-8-sig") as f:
  1236. r = make_response(f.read())
  1237. r.mimetype = 'application/json'
  1238. return r
  1239. @app.route('/api/users/login', methods=['POST'])
  1240. @jwt_to_session_cookie
  1241. @login_required
  1242. def api_users_login():
  1243. req = login_pb2.LoginRequest()
  1244. req.ParseFromString(request.stream.read())
  1245. player_id = current_user.player_id
  1246. global_relay[player_id] = Relay(req.key)
  1247. ghosts_enabled[player_id] = current_user.enable_ghosts
  1248. response = login_pb2.LoginResponse()
  1249. response.session_state = 'abc'
  1250. response.info.relay_url = "https://us-or-rly101.zwift.com/relay"
  1251. response.info.apis.todaysplan_url = "https://whats.todaysplan.com.au"
  1252. response.info.apis.trainingpeaks_url = "https://api.trainingpeaks.com"
  1253. response.info.time = int(time.time())
  1254. udp_node = response.info.nodes.nodes.add()
  1255. udp_node.ip = server_ip # TCP telemetry server
  1256. udp_node.port = 3023
  1257. response.relay_session_id = player_id
  1258. response.expiration = 70
  1259. profile_dir = os.path.join(STORAGE_DIR, str(current_user.player_id))
  1260. config_file = os.path.join(profile_dir, 'economy_config.txt')
  1261. if not os.path.isfile(config_file):
  1262. with open(os.path.join(SCRIPT_DIR, 'data', 'economy_config.txt')) as f:
  1263. economy_config = json.load(f)
  1264. profile_file = os.path.join(profile_dir, 'profile.bin')
  1265. if os.path.isfile(profile_file):
  1266. profile = profile_pb2.PlayerProfile()
  1267. with open(profile_file, 'rb') as f:
  1268. profile.ParseFromString(f.read())
  1269. current_level = profile.achievement_level // 100
  1270. levels = [x for x in economy_config['cycling_levels'] if x['level'] >= current_level]
  1271. if len(levels) > 1 and profile.total_xp > levels[1]['xp']: # avoid instant promotion
  1272. offset = profile.total_xp - levels[0]['xp']
  1273. transition_end = [x for x in levels if x['xp'] <= profile.total_xp][-1]['level']
  1274. for level in economy_config['cycling_levels']:
  1275. if level['level'] >= current_level:
  1276. level['xp'] += offset
  1277. if transition_end > current_level:
  1278. economy_config['transition_start'] = current_level
  1279. economy_config['transition_end'] = transition_end
  1280. elif levels and profile.total_xp < levels[0]['xp']: # avoid demotion
  1281. offset = levels[0]['xp'] - profile.total_xp
  1282. for level in economy_config['cycling_levels']:
  1283. if level['level'] <= current_level:
  1284. level['xp'] = max(level['xp'] - offset, 0)
  1285. with open(config_file, 'w') as f:
  1286. json.dump(economy_config, f, indent=2)
  1287. with open(config_file) as f:
  1288. Parse(f.read(), response.economy_config)
  1289. return response.SerializeToString(), 200
  1290. @app.route('/relay/session/refresh', methods=['POST'])
  1291. @app.route('/relay/session/renew', methods=['POST'])
  1292. @jwt_to_session_cookie
  1293. @login_required
  1294. def relay_session_refresh():
  1295. refresh = login_pb2.RelaySessionRefreshResponse()
  1296. refresh.relay_session_id = current_user.player_id
  1297. refresh.expiration = 70
  1298. return refresh.SerializeToString(), 200
  1299. def logout_player(player_id):
  1300. if player_id in global_ghosts:
  1301. del global_ghosts[player_id].rec.states[:]
  1302. global_ghosts[player_id].play.clear()
  1303. global_ghosts.pop(player_id)
  1304. if player_id in global_bookmarks:
  1305. global_bookmarks[player_id].clear()
  1306. global_bookmarks.pop(player_id)
  1307. @app.route('/api/users/logout', methods=['POST'])
  1308. @jwt_to_session_cookie
  1309. @login_required
  1310. def api_users_logout():
  1311. logout_player(current_user.player_id)
  1312. return '', 204
  1313. @app.route('/api/analytics/event', methods=['POST'])
  1314. def api_analytics_event():
  1315. #print(json.dumps(request.json, indent=4))
  1316. return '', 200
  1317. @app.route('/api/per-session-info', methods=['GET'])
  1318. def api_per_session_info():
  1319. info = per_session_info_pb2.PerSessionInfo()
  1320. info.relay_url = "https://us-or-rly101.zwift.com/relay"
  1321. return info.SerializeToString(), 200
  1322. def get_events(limit=None, sport=None):
  1323. with open(os.path.join(SCRIPT_DIR, 'data', 'events.txt')) as f:
  1324. events_list = json.load(f)
  1325. events = events_pb2.Events()
  1326. eventStart = int(time.time()) * 1000 + 2 * 60000
  1327. eventStartWT = world_time() + 2 * 60000
  1328. event_id = 1000000 # can't conflict with private event ID
  1329. for item in events_list:
  1330. event_id += 10
  1331. if sport != None and item['sport'] != profile_pb2.Sport.Value(sport):
  1332. continue
  1333. event = events.events.add()
  1334. event.server_realm = udp_node_msgs_pb2.ZofflineConstants.RealmID
  1335. event.id = event_id
  1336. event.name = item['name']
  1337. event.route_id = item['route'] #otherwise new home screen hangs trying to find route in all (even non-existent) courses
  1338. event.course_id = item['course']
  1339. event.sport = item['sport']
  1340. event.lateJoinInMinutes = 30
  1341. event.eventStart = eventStart
  1342. event.visible = True
  1343. event.overrideMapPreferences = False
  1344. event.invisibleToNonParticipants = False
  1345. event.description = "Auto-generated event"
  1346. event.distanceInMeters = item['distance']
  1347. event.laps = 0
  1348. event.durationInSeconds = 0
  1349. #event.rules_id =
  1350. #event.jerseyHash =
  1351. event.eventType = events_pb2.EventType.RACE
  1352. #event.e_f27 = 27; //<=4, ENUM?
  1353. #event.tags = 31; // semi-colon delimited tags
  1354. event.e_wtrl = False # WTRL (World Tactical Racing Leagues)
  1355. cats = ('A', 'B', 'C', 'D', 'E', 'F')
  1356. paceValues = ((4,15), (3,4), (2,3), (1,2), (0.1,1))
  1357. for cat in range(1,5):
  1358. event_cat = event.category.add()
  1359. event_cat.id = event_id + cat
  1360. #event_cat.registrationStart = eventStart - 30 * 60000
  1361. #event_cat.registrationStartWT = eventStartWT - 30 * 60000
  1362. event_cat.registrationEnd = eventStart
  1363. event_cat.registrationEndWT = eventStartWT
  1364. #event_cat.lineUpStart = eventStart - 5 * 60000
  1365. #event_cat.lineUpStartWT = eventStartWT - 5 * 60000
  1366. #event_cat.lineUpEnd = eventStart
  1367. #event_cat.lineUpEndWT = eventStartWT
  1368. event_cat.eventSubgroupStart = eventStart - 2 * 60000 # fixes HUD timer
  1369. event_cat.eventSubgroupStartWT = eventStartWT - 2 * 60000
  1370. event_cat.route_id = item['route']
  1371. event_cat.startLocation = cat
  1372. event_cat.label = cat
  1373. event_cat.lateJoinInMinutes = 30
  1374. event_cat.name = "Cat. %s" % cats[cat - 1]
  1375. event_cat.description = "#zwiftoffline"
  1376. event_cat.course_id = event.course_id
  1377. event_cat.paceType = 1 #1 almost everywhere, 2 sometimes
  1378. event_cat.fromPaceValue = paceValues[cat - 1][0]
  1379. event_cat.toPaceValue = paceValues[cat - 1][1]
  1380. #event_cat.scode = 7; // ex: "PT3600S"
  1381. #event_cat.rules_id = 8; // 320 and others
  1382. event_cat.distanceInMeters = item['distance']
  1383. event_cat.laps = 0
  1384. event_cat.durationInSeconds = 0
  1385. #event_cat.jerseyHash = 36; // 493134166, tag672
  1386. #event_cat.tags = 45; // tag746, semi-colon delimited tags eg: "fenced;3r;created_ryan;communityevent;no_kick_mode;timestamp=1603911177622"
  1387. if limit != None and len(events.events) >= limit:
  1388. break
  1389. return events
  1390. @app.route('/api/events/<int:event_id>', methods=['GET'])
  1391. def api_events_id(event_id):
  1392. events = get_events()
  1393. for e in events.events:
  1394. if e.id == event_id:
  1395. return jsonify(convert_event_to_json(e))
  1396. return '', 200
  1397. @app.route('/api/events/search', methods=['POST'])
  1398. def api_events_search():
  1399. limit = int(request.args.get('limit'))
  1400. events = get_events(limit)
  1401. if request.headers['Accept'] == 'application/json':
  1402. return jsonify(convert_events_to_json(events))
  1403. else:
  1404. return events.SerializeToString(), 200
  1405. def create_event_wat(rel_id, wa_type, pe, dest_ids):
  1406. player_update = udp_node_msgs_pb2.WorldAttribute()
  1407. player_update.server_realm = udp_node_msgs_pb2.ZofflineConstants.RealmID
  1408. player_update.wa_type = wa_type
  1409. player_update.world_time_born = world_time()
  1410. player_update.world_time_expire = world_time() + 60000
  1411. player_update.wa_f12 = 1
  1412. player_update.timestamp = int(time.time()*1000000)
  1413. player_update.rel_id = current_user.player_id
  1414. pe.rel_id = rel_id
  1415. pe.player_id = current_user.player_id
  1416. #optional uint64 pje_f3/ple_f3 = 3;
  1417. player_update.payload = pe.SerializeToString()
  1418. player_update_s = player_update.SerializeToString()
  1419. if not current_user.player_id in dest_ids:
  1420. dest_ids = list(dest_ids)
  1421. dest_ids.append(current_user.player_id)
  1422. for receiving_player_id in dest_ids:
  1423. enqueue_player_update(receiving_player_id, player_update_s)
  1424. @app.route('/api/events/subgroups/signup/<int:rel_id>', methods=['POST'])
  1425. @app.route('/api/events/signup/<int:rel_id>', methods=['DELETE'])
  1426. @jwt_to_session_cookie
  1427. @login_required
  1428. def api_events_subgroups_signup_id(rel_id):
  1429. if request.method == 'POST':
  1430. wa_type = udp_node_msgs_pb2.WA_TYPE.WAT_JOIN_E
  1431. pe = events_pb2.PlayerJoinedEvent()
  1432. ret = True
  1433. else:
  1434. wa_type = udp_node_msgs_pb2.WA_TYPE.WAT_LEFT_E
  1435. pe = events_pb2.PlayerLeftEvent()
  1436. ret = False
  1437. #empty request.data
  1438. create_event_wat(rel_id, wa_type, pe, online.keys())
  1439. return jsonify({"signedUp":ret})
  1440. @app.route('/api/events/subgroups/register/<int:ev_sg_id>', methods=['POST'])
  1441. def api_events_subgroups_register_id(ev_sg_id):
  1442. return '{"registered":true}', 200
  1443. @app.route('/api/events/subgroups/entrants/<int:ev_sg_id>', methods=['GET'])
  1444. def api_events_subgroups_entrants_id(ev_sg_id):
  1445. if request.headers['Accept'] == 'application/x-protobuf-lite':
  1446. return '', 200
  1447. return '[]', 200
  1448. @app.route('/api/events/subgroups/invited_ride_sweepers/<int:ev_sg_id>', methods=['GET'])
  1449. def api_events_subgroups_invited_ride_sweepers_id(ev_sg_id):
  1450. return '[]', 200
  1451. @app.route('/api/events/subgroups/invited_ride_leaders/<int:ev_sg_id>', methods=['GET'])
  1452. def api_events_subgroups_invited_ride_leaders_id(ev_sg_id):
  1453. return '[]', 200
  1454. @app.route('/relay/race/event_starting_line/<int:event_id>', methods=['POST'])
  1455. def relay_race_event_starting_line_id(event_id):
  1456. return '', 204
  1457. @app.route('/api/zfiles', methods=['POST'])
  1458. @jwt_to_session_cookie
  1459. @login_required
  1460. def api_zfiles():
  1461. if request.headers['Source'] == 'zwift-companion':
  1462. zfile = json.loads(request.stream.read())
  1463. zfile_folder = zfile['folder']
  1464. zfile_filename = zfile['name']
  1465. zfile_file = base64.b64decode(zfile['content'])
  1466. else:
  1467. zfile = zfiles_pb2.ZFileProto()
  1468. zfile.ParseFromString(request.stream.read())
  1469. zfile_folder = zfile.folder
  1470. zfile_filename = zfile.filename
  1471. zfile_file = zfile.file
  1472. zfiles_dir = os.path.join(STORAGE_DIR, str(current_user.player_id), zfile_folder)
  1473. if not make_dir(zfiles_dir):
  1474. return '', 400
  1475. try:
  1476. zfile_filename = zfile_filename.decode('utf-8', 'ignore')
  1477. except AttributeError:
  1478. pass
  1479. with open(os.path.join(zfiles_dir, quote(zfile_filename, safe=' ')), 'wb') as fd:
  1480. fd.write(zfile_file)
  1481. row = Zfile.query.filter_by(folder=zfile_folder, filename=zfile_filename, player_id=current_user.player_id).first()
  1482. if not row:
  1483. zfile_timestamp = int(time.time())
  1484. new_zfile = Zfile(folder=zfile_folder, filename=zfile_filename, timestamp=zfile_timestamp, player_id=current_user.player_id)
  1485. db.session.add(new_zfile)
  1486. db.session.commit()
  1487. zfile_id = new_zfile.id
  1488. else:
  1489. zfile_id = row.id
  1490. zfile_timestamp = row.timestamp
  1491. if request.headers['Accept'] == 'application/json':
  1492. return jsonify({"id":zfile_id,"folder":zfile_folder,"name":zfile_filename,"content":None,"lastModified":str_timestamp(zfile_timestamp*1000)})
  1493. else:
  1494. response = zfiles_pb2.ZFileProto()
  1495. response.id = zfile_id
  1496. response.folder = zfile_folder
  1497. response.filename = zfile_filename
  1498. response.timestamp = zfile_timestamp
  1499. return response.SerializeToString(), 200
  1500. @app.route('/api/zfiles/list', methods=['GET'])
  1501. @jwt_to_session_cookie
  1502. @login_required
  1503. def api_zfiles_list():
  1504. folder = request.args.get('folder')
  1505. zfiles = zfiles_pb2.ZFilesProto()
  1506. rows = Zfile.query.filter_by(folder=folder, player_id=current_user.player_id)
  1507. for row in rows:
  1508. zfiles.zfiles.add(id=row.id, folder=row.folder, filename=row.filename, timestamp=row.timestamp)
  1509. return zfiles.SerializeToString(), 200
  1510. @app.route('/api/zfiles/<int:file_id>/download', methods=['GET'])
  1511. @jwt_to_session_cookie
  1512. @login_required
  1513. def api_zfiles_download(file_id):
  1514. row = Zfile.query.filter_by(id=file_id).first()
  1515. zfile = os.path.join(STORAGE_DIR, str(row.player_id), row.folder, quote(row.filename, safe=' '))
  1516. if os.path.isfile(zfile):
  1517. return send_file(zfile, as_attachment=True, download_name=row.filename)
  1518. else:
  1519. return '', 404
  1520. @app.route('/api/zfiles/<int:file_id>', methods=['DELETE'])
  1521. @jwt_to_session_cookie
  1522. @login_required
  1523. def api_zfiles_delete(file_id):
  1524. row = Zfile.query.filter_by(id=file_id).first()
  1525. try:
  1526. os.remove(os.path.join(STORAGE_DIR, str(row.player_id), row.folder, quote(row.filename, safe=' ')))
  1527. except Exception as exc:
  1528. logger.warning('api_zfiles_delete: %s' % repr(exc))
  1529. db.session.delete(row)
  1530. db.session.commit()
  1531. return '', 200
  1532. # Custom static data
  1533. @app.route('/style/<path:filename>')
  1534. def custom_style(filename):
  1535. return send_from_directory('%s/cdn/style' % SCRIPT_DIR, filename)
  1536. @app.route('/static/web/launcher/<path:filename>')
  1537. def static_web_launcher(filename):
  1538. return send_from_directory('%s/cdn/static/web/launcher' % SCRIPT_DIR, filename)
  1539. @app.route('/api/telemetry/config', methods=['GET'])
  1540. def api_telemetry_config():
  1541. return jsonify({"analyticsEvents": True, "batchInterval": 120, "innermostCullingRadius": 1500, "isEnabled": True,
  1542. "key": "aXBSdlpza3p1aVlNOENrMTBQSzZEZ004Z2pwRm8zZUE6", "remoteLogLevel": 3, "sampleInterval": 60,
  1543. "url": "https://us-or-rly101.zwift.com/v1/track", # used if no urlBatch (https://api.segment.io/v1/track)
  1544. "urlBatch": "https://us-or-rly101.zwift.com/hvc-ingestion-service/batch"})
  1545. @app.route('/v1/track', methods=['POST'])
  1546. @app.route('/hvc-ingestion-service/batch', methods=['POST'])
  1547. @app.route('/api/hvc-ingestion-service/batch', methods=['POST'])
  1548. def hvc_ingestion_service_batch():
  1549. #print(json.dumps(request.json, indent=4))
  1550. return jsonify({"success": True})
  1551. def age(dob):
  1552. today = datetime.date.today()
  1553. years = today.year - dob.year
  1554. if today.month < dob.month or (today.month == dob.month and today.day < dob.day):
  1555. years -= 1
  1556. return years
  1557. def jsf(obj, field, deflt = None):
  1558. if obj.HasField(field):
  1559. return getattr(obj, field)
  1560. return deflt
  1561. def jsb0(obj, field):
  1562. return jsf(obj, field, False)
  1563. def jsb1(obj, field):
  1564. return jsf(obj, field, True)
  1565. def jsv0(obj, field):
  1566. return jsf(obj, field, 0)
  1567. def jses(obj, field):
  1568. return str(jsf(obj, field))
  1569. def copyAttributes(jprofile, jprofileFull, src):
  1570. dict = jprofileFull.get(src)
  1571. if dict is None:
  1572. return
  1573. dest = {}
  1574. for di in dict:
  1575. for v in ['numberValue', 'floatValue', 'stringValue']:
  1576. if v in di:
  1577. dest[di['id']] = di[v]
  1578. jprofile[src] = dest
  1579. def powerSourceModelToStr(val):
  1580. if val == 1:
  1581. return "Power Meter"
  1582. else:
  1583. return "zPower"
  1584. def privacy(profile):
  1585. privacy_bits = jsf(profile, 'privacy_bits', 0)
  1586. return {"approvalRequired": bool(privacy_bits & 1), "displayWeight": bool(privacy_bits & 4), "minor": bool(privacy_bits & 2), "privateMessaging": bool(privacy_bits & 8), "defaultFitnessDataPrivacy": bool(privacy_bits & 16),
  1587. "suppressFollowerNotification": bool(privacy_bits & 32), "displayAge": not bool(privacy_bits & 64), "defaultActivityPrivacy": profile_pb2.ActivityPrivacyType.Name(jsv0(profile, 'default_activity_privacy'))}
  1588. def bikeFrameToStr(val):
  1589. if val in GD['bikeframes']:
  1590. return GD['bikeframes'][val]
  1591. return "---"
  1592. def update_entitlements(profile):
  1593. for entitlement in list(profile.entitlements):
  1594. if entitlement.type == profile_pb2.ProfileEntitlement.EntitlementType.RIDE:
  1595. profile.entitlements.remove(entitlement)
  1596. e = profile.entitlements.add()
  1597. e.type = profile_pb2.ProfileEntitlement.EntitlementType.RIDE
  1598. e.id = -1
  1599. e.status = profile_pb2.ProfileEntitlement.ProfileEntitlementStatus.ACTIVE
  1600. if os.path.isfile('%s/unlock_entitlements.txt' % STORAGE_DIR) or os.path.isfile('%s/unlock_all_equipment.txt' % STORAGE_DIR):
  1601. ent = json.load(open('%s/data/entitlements.txt' % SCRIPT_DIR))
  1602. entitlements = list(range(ent['first'], ent['last'] + 1))
  1603. if os.path.isfile('%s/unlock_all_equipment.txt' % STORAGE_DIR):
  1604. entitlements.extend(list(range(1, ent['first'])))
  1605. for entitlement in entitlements:
  1606. if not any(e.id == entitlement for e in profile.entitlements):
  1607. e = profile.entitlements.add()
  1608. e.type = profile_pb2.ProfileEntitlement.EntitlementType.USE
  1609. e.id = entitlement
  1610. e.status = profile_pb2.ProfileEntitlement.ProfileEntitlementStatus.ACTIVE
  1611. def do_api_profiles(profile_id, is_json):
  1612. profile = profile_pb2.PlayerProfile()
  1613. profile_file = '%s/%s/profile.bin' % (STORAGE_DIR, profile_id)
  1614. if os.path.isfile(profile_file):
  1615. with open(profile_file, 'rb') as fd:
  1616. profile.ParseFromString(fd.read())
  1617. else:
  1618. profile.email = current_user.username
  1619. profile.first_name = current_user.first_name
  1620. profile.last_name = current_user.last_name
  1621. profile.mix_panel_distinct_id = str(uuid.uuid4())
  1622. profile.id = profile_id
  1623. if is_json: #todo: publicId, bodyType, totalRunCalories != total_watt_hours, totalRunTimeInMinutes != time_ridden_in_minutes etc
  1624. if profile.dob != "":
  1625. profile.age = age(datetime.datetime.strptime(profile.dob, "%m/%d/%Y"))
  1626. jprofileFull = MessageToDict(profile)
  1627. jprofile = {"id": profile.id, "firstName": jsf(profile, 'first_name'), "lastName": jsf(profile, 'last_name'), "preferredLanguage": jsf(profile, 'preferred_language'), "bodyType":jsv0(profile, 'body_type'), "male": jsb1(profile, 'is_male'),
  1628. "imageSrc": imageSrc(profile.id), "imageSrcLarge": imageSrc(profile.id), "playerType": profile_pb2.PlayerType.Name(jsf(profile, 'player_type', 1)), "playerTypeId": jsf(profile, 'player_type', 1), "playerSubTypeId": None,
  1629. "emailAddress": jsf(profile, 'email'), "countryCode": jsf(profile, 'country_code'), "dob": jsf(profile, 'dob'), "countryAlpha3": "rus", "useMetric": jsb1(profile, 'use_metric'), "privacy": privacy(profile), "age": jsv0(profile, 'age'),
  1630. "ftp": jsf(profile, 'ftp'), "b": False, "weight": jsf(profile, 'weight_in_grams'), "connectedToStrava": jsb0(profile, 'connected_to_strava'), "connectedToTrainingPeaks": jsb0(profile, 'connected_to_training_peaks'),
  1631. "connectedToTodaysPlan": jsb0(profile, 'connected_to_todays_plan'), "connectedToUnderArmour": jsb0(profile, 'connected_to_under_armour'), "connectedToFitbit": jsb0(profile, 'connected_to_fitbit'), "connectedToGarmin": jsb0(profile, 'connected_to_garmin'), "height": jsf(profile, 'height_in_millimeters'),
  1632. "totalExperiencePoints": jsv0(profile, 'total_xp'), "worldId": jsf(profile, 'server_realm'), "totalDistance": jsv0(profile, 'total_distance_in_meters'), "totalDistanceClimbed": jsv0(profile, 'elevation_gain_in_meters'), "totalTimeInMinutes": jsv0(profile, 'time_ridden_in_minutes'),
  1633. "achievementLevel": jsv0(profile, 'achievement_level'), "totalWattHours": jsv0(profile, 'total_watt_hours'), "runTime1miInSeconds": jsv0(profile, 'run_time_1mi_in_seconds'), "runTime5kmInSeconds": jsv0(profile, 'run_time_5km_in_seconds'), "runTime10kmInSeconds": jsv0(profile, 'run_time_10km_in_seconds'),
  1634. "runTimeHalfMarathonInSeconds": jsv0(profile, 'run_time_half_marathon_in_seconds'), "runTimeFullMarathonInSeconds": jsv0(profile, 'run_time_full_marathon_in_seconds'), "totalInKomJersey": jsv0(profile, 'total_in_kom_jersey'), "totalInSprintersJersey": jsv0(profile, 'total_in_sprinters_jersey'),
  1635. "totalInOrangeJersey": jsv0(profile, 'total_in_orange_jersey'), "currentActivityId": jsf(profile, 'current_activity_id'), "enrolledZwiftAcademy": jsv0(profile, 'enrolled_program') == profile.EnrolledProgram.ZWIFT_ACADEMY, "runAchievementLevel": jsv0(profile, 'run_achievement_level'),
  1636. "totalRunDistance": jsv0(profile, 'total_run_distance'), "totalRunTimeInMinutes": jsv0(profile, 'total_run_time_in_minutes'), "totalRunExperiencePoints": jsv0(profile, 'total_run_experience_points'), "totalRunCalories": jsv0(profile, 'total_run_calories'), "totalGold": jsv0(profile, 'total_gold_drops'),
  1637. "profilePropertyChanges": jprofileFull.get('propertyChanges'), "cyclingOrganization": jsf(profile, 'cycling_organization'), "userAgent": "CNL/3.13.0 (Android 11) zwift/1.0.85684 curl/7.78.0-DEV", "stravaPremium": jsb0(profile, 'strava_premium'), "profileChanges": False, "launchedGameClient": "09/19/2021 13:24:19 +0000",
  1638. "createdOn":"2021-09-19T13:24:17.783+0000", "likelyInGame": False, "address": None, "bt":"f97803d3-efac-4510-a17a-ef44e65d3071", "numberOfFolloweesInCommon": 0, "fundraiserId": None, "source": "Android", "origin": None, "licenseNumber": None, "bigCommerceId": None, "marketingConsent": None, "affiliate": None,
  1639. "avantlinkId": None, "virtualBikeModel": bikeFrameToStr(profile.bike_frame), "connectedToWithings": jsb0(profile, 'connected_to_withings'), "connectedToRuntastic": jsb0(profile, 'connected_to_runtastic'), "connectedToZwiftPower": False, "powerSourceType": "Power Source",
  1640. "powerSourceModel": powerSourceModelToStr(profile.power_source_model), "riding": False, "location": "", "publicId": "5a72e9b1-239f-435e-8757-af9467336b40", "mixpanelDistinctId": "21304417-af2d-4c9b-8543-8ba7c0500e84"}
  1641. copyAttributes(jprofile, jprofileFull, 'publicAttributes')
  1642. copyAttributes(jprofile, jprofileFull, 'privateAttributes')
  1643. return jsonify(jprofile)
  1644. else:
  1645. update_entitlements(profile)
  1646. return profile.SerializeToString(), 200
  1647. @app.route('/api/profiles/me', methods=['GET'], strict_slashes=False)
  1648. @jwt_to_session_cookie
  1649. @login_required
  1650. def api_profiles_me():
  1651. if request.headers['Accept'] == 'application/json':
  1652. return do_api_profiles(current_user.player_id, True)
  1653. else:
  1654. return do_api_profiles(current_user.player_id, False)
  1655. @app.route('/api/profiles/me/entitlements', methods=['GET'])
  1656. @jwt_to_session_cookie
  1657. @login_required
  1658. def api_profiles_me_entitlements():
  1659. profile = profile_pb2.PlayerProfile()
  1660. profile_file = '%s/%s/profile.bin' % (STORAGE_DIR, current_user.player_id)
  1661. if os.path.isfile(profile_file):
  1662. with open(profile_file, 'rb') as fd:
  1663. profile.ParseFromString(fd.read())
  1664. update_entitlements(profile)
  1665. entitlements = profile_pb2.ProfileEntitlements()
  1666. entitlements.entitlements.extend(profile.entitlements)
  1667. return entitlements.SerializeToString(), 200
  1668. @app.route('/api/profiles/<int:profile_id>', methods=['GET'])
  1669. @jwt_to_session_cookie
  1670. @login_required
  1671. def api_profiles_json(profile_id):
  1672. return do_api_profiles(profile_id, True)
  1673. @app.route('/api/partners/garmin/auth', methods=['GET'])
  1674. @app.route('/api/partners/trainingpeaks/auth', methods=['GET'])
  1675. @app.route('/api/partners/strava/auth', methods=['GET'])
  1676. @app.route('/api/partners/withings/auth', methods=['GET'])
  1677. @app.route('/api/partners/todaysplan/auth', methods=['GET'])
  1678. @app.route('/api/partners/runtastic/auth', methods=['GET'])
  1679. @app.route('/api/partners/underarmour/auth', methods=['GET'])
  1680. @app.route('/api/partners/fitbit/auth', methods=['GET'])
  1681. def api_profiles_partners():
  1682. return {"status":"notConnected","clientId":"zwift","sandbox":False}
  1683. @app.route('/api/profiles/<int:player_id>/privacy', methods=['POST'])
  1684. @jwt_to_session_cookie
  1685. @login_required
  1686. def api_profiles_id_privacy(player_id):
  1687. privacy_file = '%s/%s/privacy.json' % (STORAGE_DIR, player_id)
  1688. jp = request.get_json()
  1689. with open(privacy_file, 'w', encoding='utf-8') as fprivacy:
  1690. fprivacy.write(json.dumps(jp, ensure_ascii=False))
  1691. #{"displayAge": false, "defaultActivityPrivacy": "PUBLIC", "approvalRequired": false, "privateMessaging": false, "defaultFitnessDataPrivacy": false}
  1692. profile_dir = '%s/%s' % (STORAGE_DIR, player_id)
  1693. profile = profile_pb2.PlayerProfile()
  1694. profile_file = '%s/profile.bin' % profile_dir
  1695. with open(profile_file, 'rb') as fd:
  1696. profile.ParseFromString(fd.read())
  1697. profile.privacy_bits = 0
  1698. if jp["approvalRequired"]:
  1699. profile.privacy_bits += 1
  1700. if "displayWeight" in jp and jp["displayWeight"]:
  1701. profile.privacy_bits += 4
  1702. if "minor" in jp and jp["minor"]:
  1703. profile.privacy_bits += 2
  1704. if jp["privateMessaging"]:
  1705. profile.privacy_bits += 8
  1706. if jp["defaultFitnessDataPrivacy"]:
  1707. profile.privacy_bits += 16
  1708. if "suppressFollowerNotification" in jp and jp["suppressFollowerNotification"]:
  1709. profile.privacy_bits += 32
  1710. if not jp["displayAge"]:
  1711. profile.privacy_bits += 64
  1712. defaultActivityPrivacy = jp["defaultActivityPrivacy"]
  1713. profile.default_activity_privacy = 0 #PUBLIC
  1714. if defaultActivityPrivacy == "PRIVATE":
  1715. profile.default_activity_privacy = 1
  1716. if defaultActivityPrivacy == "FRIENDS":
  1717. profile.default_activity_privacy = 2
  1718. with open(profile_file, 'wb') as fd:
  1719. fd.write(profile.SerializeToString())
  1720. return '', 200
  1721. @app.route('/api/profiles/<int:m_player_id>/followers', methods=['GET']) #?start=0&limit=200&include-follow-requests=false
  1722. @app.route('/api/profiles/<int:m_player_id>/followees', methods=['GET'])
  1723. @app.route('/api/profiles/<int:m_player_id>/followees-in-common/<int:t_player_id>', methods=['GET'])
  1724. @jwt_to_session_cookie
  1725. @login_required
  1726. def api_profiles_followers(m_player_id, t_player_id=0):
  1727. if request.headers['Accept'] == 'application/x-protobuf-lite':
  1728. return '', 200
  1729. rows = db.session.execute(sqlalchemy.text("SELECT player_id, first_name, last_name FROM user"))
  1730. json_data_list = []
  1731. for row in rows:
  1732. player_id = row[0]
  1733. profile = get_partial_profile(player_id)
  1734. #all users are following favourites of this user (temp decision for small crouds)
  1735. json_data_list.append({"id":0,"followerId":player_id,"followeeId":m_player_id,"status":"IS_FOLLOWING","isFolloweeFavoriteOfFollower":True,
  1736. "followerProfile":{"id":player_id,"firstName":row[1],"lastName":row[2],"imageSrc":imageSrc(player_id),"imageSrcLarge":imageSrc(player_id),"countryCode":profile.country_code},
  1737. "followeeProfile":None})
  1738. return jsonify(json_data_list)
  1739. @app.route('/api/search/profiles/restricted', methods=['POST'])
  1740. @app.route('/api/search/profiles', methods=['POST'])
  1741. @jwt_to_session_cookie
  1742. @login_required
  1743. def api_search_profiles():
  1744. query = request.json['query']
  1745. start = request.args.get('start')
  1746. limit = request.args.get('limit')
  1747. stmt = sqlalchemy.text("SELECT player_id, first_name, last_name FROM user WHERE first_name LIKE :n OR last_name LIKE :n LIMIT :l OFFSET :o")
  1748. rows = db.session.execute(stmt, {"n": "%"+query+"%", "l": limit, "o": start})
  1749. json_data_list = []
  1750. for row in rows:
  1751. player_id = row[0]
  1752. profile = get_partial_profile(player_id)
  1753. json_data_list.append({"id": player_id, "firstName": row[1], "lastName": row[2], "imageSrc": imageSrc(player_id), "imageSrcLarge": imageSrc(player_id), "countryCode": profile.country_code})
  1754. return jsonify(json_data_list)
  1755. @app.route('/api/profiles/<int:player_id>/membership-status', methods=['GET'])
  1756. def api_profiles_membership_status(player_id):
  1757. return jsonify({"status":"active"}) # {"title":"25km","description":"renews.1677628800000","status":"active","upcoming":null,"subscription":null,"promotions":[],"hasStackedPromos":false,"startedPortability":false,"grandfathered":false,"grandfatheringGroup":null,"freeTrialKmLeft":18}
  1758. @app.route('/api/profiles/<int:player_id>/statistics', methods=['GET'])
  1759. def api_profiles_id_statistics(player_id):
  1760. from_dt = request.args.get('startDateTime')
  1761. stmt = sqlalchemy.text("SELECT SUM(CAST((julianday(date)-julianday(start_date))*24*60 AS integer)), SUM(distanceInMeters), SUM(calories), SUM(total_elevation) FROM activity WHERE player_id = :p AND strftime('%s', start_date) >= strftime('%s', :d)")
  1762. row = db.session.execute(stmt, {"p": player_id, "d": from_dt}).first()
  1763. json_data = {"timeRiddenInMinutes": row[0], "distanceRiddenInMeters": row[1], "caloriesBurned": row[2], "heightClimbedInMeters": row[3]}
  1764. return jsonify(json_data)
  1765. @app.route('/relay/profiles/me/phone', methods=['PUT'])
  1766. @jwt_to_session_cookie
  1767. @login_required
  1768. def api_profiles_me_phone():
  1769. if not request.stream:
  1770. return '', 400
  1771. phoneAddress = request.json['phoneAddress']
  1772. if 'port' in request.json:
  1773. phonePort = int(request.json['port'])
  1774. phoneSecretKey = 'None'
  1775. if 'securePort' in request.json:
  1776. phonePort = int(request.json['securePort'])
  1777. phoneSecretKey = base64.b64decode(request.json['secret'])
  1778. zc_connect_queue[current_user.player_id] = (phoneAddress, phonePort, phoneSecretKey)
  1779. #todo UDP scenario
  1780. #logger.info("ZCompanion %d reg: %s:%d (key: %s)" % (current_user.player_id, phoneAddress, phonePort, phoneSecretKey.hex()))
  1781. return '', 204
  1782. @app.route('/api/profiles/me/<int:player_id>', methods=['PUT'])
  1783. @jwt_to_session_cookie
  1784. @login_required
  1785. def api_profiles_me_id(player_id):
  1786. if not request.stream:
  1787. return '', 400
  1788. if current_user.player_id != player_id:
  1789. return '', 401
  1790. profile_dir = '%s/%s' % (STORAGE_DIR, player_id)
  1791. profile = profile_pb2.PlayerProfile()
  1792. profile_file = '%s/profile.bin' % profile_dir
  1793. with open(profile_file, 'rb') as fd:
  1794. profile.ParseFromString(fd.read())
  1795. #update profile from json
  1796. profile.country_code = request.json['countryCode']
  1797. profile.dob = request.json['dob']
  1798. profile.email = request.json['emailAddress']
  1799. profile.first_name = request.json['firstName']
  1800. profile.last_name = request.json['lastName']
  1801. profile.height_in_millimeters = request.json['height']
  1802. profile.is_male = request.json['male']
  1803. profile.use_metric = request.json['useMetric']
  1804. profile.weight_in_grams = request.json['weight']
  1805. image = imageSrc(player_id)
  1806. if image is not None:
  1807. profile.large_avatar_url = image
  1808. with open(profile_file, 'wb') as fd:
  1809. fd.write(profile.SerializeToString())
  1810. if MULTIPLAYER:
  1811. current_user.first_name = profile.first_name
  1812. current_user.last_name = profile.last_name
  1813. db.session.commit()
  1814. return api_profiles_me()
  1815. @app.route('/api/profiles/<int:player_id>', methods=['PUT'])
  1816. @app.route('/api/profiles/<int:player_id>/in-game-fields', methods=['PUT'])
  1817. @jwt_to_session_cookie
  1818. @login_required
  1819. def api_profiles_id(player_id):
  1820. if not request.stream:
  1821. return '', 400
  1822. if player_id == 0:
  1823. return '', 400 # can't return 401 to /api/profiles/0/in-game-fields (causes issues in following requests)
  1824. if current_user.player_id != player_id:
  1825. return '', 401
  1826. stream = request.stream.read()
  1827. with open('%s/%s/profile.bin' % (STORAGE_DIR, player_id), 'wb') as f:
  1828. f.write(stream)
  1829. if MULTIPLAYER:
  1830. profile = profile_pb2.PlayerProfile()
  1831. profile.ParseFromString(stream)
  1832. current_user.first_name = profile.first_name
  1833. current_user.last_name = profile.last_name
  1834. db.session.commit()
  1835. return '', 204
  1836. @app.route('/api/profiles/<int:player_id>/photo', methods=['POST'])
  1837. @jwt_to_session_cookie
  1838. @login_required
  1839. def api_profiles_id_photo_post(player_id):
  1840. if not request.stream:
  1841. return '', 400
  1842. if current_user.player_id != player_id:
  1843. return '', 401
  1844. stream = request.stream.read().split(b'\r\n\r\n', maxsplit=1)[1]
  1845. with open('%s/%s/avatarLarge.jpg' % (STORAGE_DIR, player_id), 'wb') as f:
  1846. f.write(stream)
  1847. return '', 200
  1848. @app.route('/api/profiles/<int:player_id>/activities', methods=['GET', 'POST'], strict_slashes=False)
  1849. @jwt_to_session_cookie
  1850. @login_required
  1851. def api_profiles_activities(player_id):
  1852. if request.method == 'POST':
  1853. if not request.stream:
  1854. return '', 400
  1855. if current_user.player_id != player_id:
  1856. return '', 401
  1857. activity = activity_pb2.Activity()
  1858. activity.ParseFromString(request.stream.read())
  1859. activity.id = insert_protobuf_into_db(Activity, activity, ['fit'], ['power_zones'])
  1860. return '{"id": %ld}' % activity.id, 200
  1861. # request.method == 'GET'
  1862. activities = activity_pb2.ActivityList()
  1863. rows = db.session.execute(sqlalchemy.text("SELECT * FROM activity WHERE player_id = :p AND date > date('now', '-1 month')"), {"p": player_id}).mappings()
  1864. for row in rows:
  1865. activity = activities.activities.add()
  1866. row_to_protobuf(row, activity, exclude_fields=['fit', 'power_zones'])
  1867. return activities.SerializeToString(), 200
  1868. @app.route('/api/profiles/<int:player_id>/activities/<int:activity_id>/images', methods=['POST'])
  1869. @jwt_to_session_cookie
  1870. @login_required
  1871. def api_profiles_activities_images(player_id, activity_id):
  1872. images_dir = '%s/%s/images' % (STORAGE_DIR, player_id)
  1873. if not make_dir(images_dir):
  1874. return '', 400
  1875. row = ActivityImage(player_id=player_id, activity_id=activity_id)
  1876. db.session.add(row)
  1877. db.session.commit()
  1878. image = activity_pb2.ActivityImage()
  1879. image.ParseFromString(request.stream.read())
  1880. with open('%s/%s.jpg' % (images_dir, row.id), 'wb') as f:
  1881. f.write(image.jpg)
  1882. return jsonify({"id": row.id, "id_str": str(row.id)})
  1883. def time_since(date):
  1884. seconds = (world_time() - date) // 1000
  1885. interval = seconds // 31536000
  1886. if interval > 0: interval_type = 'year'
  1887. else:
  1888. interval = seconds // 2592000
  1889. if interval > 0: interval_type = 'month'
  1890. else:
  1891. interval = seconds // 604800
  1892. if interval > 0: interval_type = 'week'
  1893. else:
  1894. interval = seconds // 86400
  1895. if interval > 0: interval_type = 'day'
  1896. else:
  1897. interval = seconds // 3600
  1898. if interval > 0: interval_type = 'hour'
  1899. else:
  1900. interval = seconds // 60
  1901. if interval > 0: interval_type = 'minute'
  1902. else: return 'Just now'
  1903. if interval > 1: interval_type += 's'
  1904. return '%s %s ago' % (interval, interval_type)
  1905. def random_profile(p):
  1906. p.ride_helmet_type = random.choice(GD['headgears'])
  1907. p.glasses_type = random.choice(GD['glasses'])
  1908. p.ride_shoes_type = random.choice(GD['bikeshoes'])
  1909. p.ride_socks_type = random.choice(GD['socks'])
  1910. p.ride_socks_length = random.randrange(4)
  1911. p.ride_jersey = random.choice(GD['jerseys'])
  1912. p.bike_wheel_rear, p.bike_wheel_front = random.choice(GD['wheels'])
  1913. p.bike_frame = random.choice(list(GD['bikeframes'].keys()))
  1914. p.run_shirt_type = random.choice(GD['runshirts'])
  1915. p.run_shorts_type = random.choice(GD['runshorts'])
  1916. p.run_shoes_type = random.choice(GD['runshoes'])
  1917. return p
  1918. @app.route('/api/profiles', methods=['GET'])
  1919. def api_profiles():
  1920. args = request.args.getlist('id')
  1921. profiles = profile_pb2.PlayerProfiles()
  1922. for i in args:
  1923. p_id = int(i)
  1924. profile = profile_pb2.PlayerProfile()
  1925. if p_id > 10000000:
  1926. ghostId = math.floor(p_id / 10000000)
  1927. player_id = p_id - ghostId * 10000000
  1928. profile_file = '%s/%s/profile.bin' % (STORAGE_DIR, player_id)
  1929. if os.path.isfile(profile_file):
  1930. with open(profile_file, 'rb') as fd:
  1931. profile.ParseFromString(fd.read())
  1932. p = profiles.profiles.add()
  1933. p.CopyFrom(random_profile(profile))
  1934. p.id = p_id
  1935. p.first_name = ''
  1936. try: # profile can be requested after ghost is deleted
  1937. p.last_name = time_since(global_ghosts[player_id].play[ghostId-1].date)
  1938. except:
  1939. p.last_name = 'Ghost'
  1940. p.country_code = 0
  1941. if GHOST_PROFILE:
  1942. for item in ['country_code', 'ride_jersey', 'bike_frame', 'bike_frame_colour', 'bike_wheel_front', 'bike_wheel_rear', 'ride_helmet_type', 'glasses_type', 'ride_shoes_type', 'ride_socks_type']:
  1943. if item in GHOST_PROFILE:
  1944. setattr(p, item, GHOST_PROFILE[item])
  1945. elif p_id > 9000000:
  1946. p = profiles.profiles.add()
  1947. p.id = p_id
  1948. p.last_name = 'Bookmark'
  1949. p.country_code = 0
  1950. else:
  1951. if p_id in global_pace_partners.keys():
  1952. profile = global_pace_partners[p_id].profile
  1953. elif p_id in global_bots.keys():
  1954. profile = global_bots[p_id].profile
  1955. else:
  1956. profile_file = '%s/%s/profile.bin' % (STORAGE_DIR, p_id)
  1957. if os.path.isfile(profile_file):
  1958. with open(profile_file, 'rb') as fd:
  1959. profile.ParseFromString(fd.read())
  1960. else:
  1961. profile.id = p_id
  1962. profiles.profiles.append(profile)
  1963. return profiles.SerializeToString(), 200
  1964. @app.route('/api/player-playbacks/player/playback', methods=['POST'])
  1965. @jwt_to_session_cookie
  1966. @login_required
  1967. def player_playbacks_player_playback():
  1968. pb_dir = '%s/playbacks' % STORAGE_DIR
  1969. if not make_dir(pb_dir):
  1970. return '', 400
  1971. stream = request.stream.read()
  1972. pb = playback_pb2.PlaybackData()
  1973. pb.ParseFromString(stream)
  1974. if pb.time == 0:
  1975. return '', 200
  1976. new_uuid = str(uuid.uuid4())
  1977. new_pb = Playback(player_id=current_user.player_id, uuid=new_uuid, segment_id=pb.segment_id, time=pb.time, world_time=pb.world_time, type=pb.type)
  1978. db.session.add(new_pb)
  1979. db.session.commit()
  1980. with open('%s/%s.playback' % (pb_dir, new_uuid), 'wb') as f:
  1981. f.write(stream)
  1982. return new_uuid, 201
  1983. @app.route('/api/player-playbacks/player/<player_id>/playbacks/<segment_id>/<option>', methods=['GET'])
  1984. @jwt_to_session_cookie
  1985. @login_required
  1986. def player_playbacks_player_playbacks(player_id, segment_id, option):
  1987. if player_id == 'me':
  1988. player_id = current_user.player_id
  1989. segment_id = int(segment_id)
  1990. after = request.args.get('after')
  1991. before = request.args.get('before')
  1992. pb_type = playback_pb2.PlaybackType.Value(request.args.get('type'))
  1993. query = "SELECT * FROM playback WHERE player_id = :p AND segment_id = :s AND type = :t"
  1994. args = {"p": player_id, "s": segment_id, "t": pb_type}
  1995. if after != '18446744065933551616' and not ALL_TIME_LEADERBOARDS:
  1996. query += " AND world_time > :a"
  1997. args.update({"a": after})
  1998. if before != '0':
  1999. query += " AND world_time < :b"
  2000. args.update({"b": before})
  2001. if option == 'pr':
  2002. query += " ORDER BY time"
  2003. elif option == 'latest':
  2004. query += " ORDER BY world_time DESC"
  2005. row = db.session.execute(sqlalchemy.text(query), args).first()
  2006. if not row:
  2007. return '', 200
  2008. pbr = playback_pb2.PlaybackMetadata()
  2009. pbr.uuid = row.uuid
  2010. pbr.segment_id = row.segment_id
  2011. pbr.time = row.time
  2012. pbr.world_time = row.world_time
  2013. pbr.url = 'https://cdn.zwift.com/player-playback/playbacks/%s.playback' % row.uuid
  2014. if pb_type:
  2015. pbr.type = pb_type
  2016. return pbr.SerializeToString(), 200
  2017. @app.route('/player-playback/playbacks/<path:filename>')
  2018. def player_playback_playbacks(filename):
  2019. return send_from_directory('%s/playbacks' % STORAGE_DIR, filename)
  2020. def strava_upload(player_id, activity):
  2021. profile_dir = '%s/%s' % (STORAGE_DIR, player_id)
  2022. strava_token = '%s/strava_token.txt' % profile_dir
  2023. if not os.path.exists(strava_token):
  2024. logger.info("strava_token.txt missing, skip Strava activity update")
  2025. return
  2026. strava = Client()
  2027. try:
  2028. with open(strava_token, 'r') as f:
  2029. client_id = f.readline().rstrip('\r\n')
  2030. client_secret = f.readline().rstrip('\r\n')
  2031. strava.access_token = f.readline().rstrip('\r\n')
  2032. refresh_token = f.readline().rstrip('\r\n')
  2033. expires_at = f.readline().rstrip('\r\n')
  2034. except Exception as exc:
  2035. logger.warning("Failed to read %s. Skipping Strava upload attempt: %s" % (strava_token, repr(exc)))
  2036. return
  2037. try:
  2038. if time.time() > int(expires_at):
  2039. refresh_response = strava.refresh_access_token(client_id=int(client_id), client_secret=client_secret,
  2040. refresh_token=refresh_token)
  2041. with open(strava_token, 'w') as f:
  2042. f.write(client_id + '\n')
  2043. f.write(client_secret + '\n')
  2044. f.write(refresh_response['access_token'] + '\n')
  2045. f.write(refresh_response['refresh_token'] + '\n')
  2046. f.write(str(refresh_response['expires_at']) + '\n')
  2047. except Exception as exc:
  2048. logger.warning("Failed to refresh token. Skipping Strava upload attempt: %s" % repr(exc))
  2049. return
  2050. try:
  2051. # See if there's internet to upload to Strava
  2052. strava.upload_activity(BytesIO(activity.fit), data_type='fit', name=activity.name)
  2053. # XXX: assume the upload succeeds on strava's end. not checking on it.
  2054. except Exception as exc:
  2055. logger.warning("Strava upload failed. No internet? %s" % repr(exc))
  2056. def garmin_upload(player_id, activity):
  2057. try:
  2058. import garth
  2059. except ImportError as exc:
  2060. logger.warning("garth is not installed. Skipping Garmin upload attempt: %s" % repr(exc))
  2061. return
  2062. garth.configure(domain=GARMIN_DOMAIN)
  2063. tokens_dir = '%s/%s/garth' % (STORAGE_DIR, player_id)
  2064. try:
  2065. garth.resume(tokens_dir)
  2066. if garth.client.oauth2_token.expired:
  2067. garth.client.refresh_oauth2()
  2068. garth.save(tokens_dir)
  2069. except:
  2070. garmin_credentials = '%s/%s/garmin_credentials.bin' % (STORAGE_DIR, player_id)
  2071. if not os.path.exists(garmin_credentials):
  2072. logger.info("garmin_credentials.bin missing, skip Garmin activity update")
  2073. return
  2074. username, password = decrypt_credentials(garmin_credentials)
  2075. try:
  2076. garth.login(username, password)
  2077. garth.save(tokens_dir)
  2078. except Exception as exc:
  2079. logger.warning("Garmin login failed: %s" % repr(exc))
  2080. return
  2081. try:
  2082. requests.post('https://connectapi.%s/upload-service/upload' % GARMIN_DOMAIN,
  2083. files={"file": (activity.fit_filename, BytesIO(activity.fit))},
  2084. headers={'authorization': str(garth.client.oauth2_token)})
  2085. except Exception as exc:
  2086. logger.warning("Garmin upload failed. No internet? %s" % repr(exc))
  2087. def runalyze_upload(player_id, activity):
  2088. runalyze_token = '%s/%s/runalyze_token.txt' % (STORAGE_DIR, player_id)
  2089. if not os.path.exists(runalyze_token):
  2090. logger.info("runalyze_token.txt missing, skip Runalyze activity update")
  2091. return
  2092. try:
  2093. with open(runalyze_token, 'r') as f:
  2094. runtoken = f.readline().rstrip('\r\n')
  2095. except Exception as exc:
  2096. logger.warning("Failed to read %s. Skipping Runalyze upload attempt: %s" % (runalyze_token, repr(exc)))
  2097. return
  2098. try:
  2099. r = requests.post("https://runalyze.com/api/v1/activities/uploads",
  2100. files={'file': BytesIO(activity.fit)}, headers={"token": runtoken})
  2101. logger.info(r.text)
  2102. except Exception as exc:
  2103. logger.warning("Runalyze upload failed. No internet? %s" % repr(exc))
  2104. def intervals_upload(player_id, activity):
  2105. intervals_credentials = '%s/%s/intervals_credentials.bin' % (STORAGE_DIR, player_id)
  2106. if not os.path.exists(intervals_credentials):
  2107. logger.info("intervals_credentials.bin missing, skip Intervals.icu activity update")
  2108. return
  2109. athlete_id, api_key = decrypt_credentials(intervals_credentials)
  2110. try:
  2111. from requests.auth import HTTPBasicAuth
  2112. url = 'http://intervals.icu/api/v1/athlete/%s/activities?name=%s' % (athlete_id, activity.name)
  2113. requests.post(url, files = {"file": BytesIO(activity.fit)}, auth = HTTPBasicAuth('API_KEY', api_key))
  2114. except Exception as exc:
  2115. logger.warning("Intervals.icu upload failed. No internet? %s" % repr(exc))
  2116. def zwift_upload(player_id, activity):
  2117. zwift_credentials = '%s/%s/zwift_credentials.bin' % (STORAGE_DIR, player_id)
  2118. if not os.path.exists(zwift_credentials):
  2119. logger.info("zwift_credentials.bin missing, skip Zwift activity update")
  2120. return
  2121. username, password = decrypt_credentials(zwift_credentials)
  2122. try:
  2123. session = requests.session()
  2124. access_token, refresh_token = online_sync.login(session, username, password)
  2125. activity.player_id = online_sync.get_player_id(session, access_token)
  2126. new_activity = activity_pb2.Activity()
  2127. new_activity.CopyFrom(activity)
  2128. new_activity.ClearField('id')
  2129. new_activity.ClearField('fit')
  2130. activity.id = online_sync.create_activity(session, access_token, new_activity)
  2131. online_sync.upload_activity(session, access_token, activity)
  2132. online_sync.logout(session, refresh_token)
  2133. except Exception as exc:
  2134. logger.warning("Zwift upload failed. No internet? %s" % repr(exc))
  2135. def moving_average(iterable, n):
  2136. it = iter(iterable)
  2137. d = deque(islice(it, n))
  2138. s = sum(d)
  2139. for elem in it:
  2140. s += elem - d.popleft()
  2141. d.append(elem)
  2142. yield s // n
  2143. def create_power_curve(player_id, fit_file):
  2144. try:
  2145. power_values = []
  2146. timestamp = int(time.time())
  2147. with fitdecode.FitReader(fit_file) as fit:
  2148. for frame in fit:
  2149. if frame.frame_type == fitdecode.FIT_FRAME_DATA:
  2150. if frame.name == 'record':
  2151. p = frame.get_value('power')
  2152. if p is not None: power_values.append(int(p))
  2153. elif frame.name == 'activity':
  2154. t = frame.get_value('timestamp')
  2155. if t is not None: timestamp = int(t.timestamp())
  2156. if power_values:
  2157. for t in [5, 60, 300, 1200]:
  2158. averages = list(moving_average(power_values, t))
  2159. if averages:
  2160. power = max(averages)
  2161. profile = get_partial_profile(player_id)
  2162. power_wkg = round(power / (profile.weight_in_grams / 1000), 2)
  2163. power_curve = PowerCurve(player_id=player_id, time=str(t), power=power, power_wkg=power_wkg, timestamp=timestamp)
  2164. db.session.add(power_curve)
  2165. db.session.commit()
  2166. except Exception as exc:
  2167. logger.warning('create_power_curve: %s' % repr(exc))
  2168. def save_ghost(player_id, name):
  2169. if not player_id in global_ghosts.keys(): return
  2170. ghosts = global_ghosts[player_id]
  2171. if len(ghosts.rec.states) > 0:
  2172. state = ghosts.rec.states[0]
  2173. folder = '%s/%s/ghosts/%s/' % (STORAGE_DIR, player_id, get_course(state))
  2174. if state.route: folder += str(state.route)
  2175. else:
  2176. folder += str(road_id(state))
  2177. if not is_forward(state): folder += '/reverse'
  2178. if not make_dir(folder):
  2179. return
  2180. ghosts.rec.player_id = player_id
  2181. f = '%s/%s-%s.bin' % (folder, datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d-%H-%M-%S"), name)
  2182. with open(f, 'wb') as fd:
  2183. fd.write(ghosts.rec.SerializeToString())
  2184. def activity_uploads(player_id, activity):
  2185. strava_upload(player_id, activity)
  2186. garmin_upload(player_id, activity)
  2187. runalyze_upload(player_id, activity)
  2188. intervals_upload(player_id, activity)
  2189. zwift_upload(player_id, activity)
  2190. @app.route('/api/profiles/<int:player_id>/activities/<int:activity_id>', methods=['PUT', 'DELETE'])
  2191. @jwt_to_session_cookie
  2192. @login_required
  2193. def api_profiles_activities_id(player_id, activity_id):
  2194. if request.headers['Source'] == "zwift-companion":
  2195. return '', 400 # edit from ZCA is not supported yet
  2196. if not request.stream:
  2197. return '', 400
  2198. if current_user.player_id != player_id:
  2199. return '', 401
  2200. if request.method == 'DELETE':
  2201. Activity.query.filter_by(id=activity_id).delete()
  2202. db.session.commit()
  2203. logout_player(player_id)
  2204. return 'true', 200
  2205. stream = request.stream.read()
  2206. activity = activity_pb2.Activity()
  2207. activity.ParseFromString(stream)
  2208. update_protobuf_in_db(Activity, activity, activity_id, ['fit'], ['power_zones'])
  2209. response = '{"id":%s}' % activity_id
  2210. if request.args.get('upload-to-strava') != 'true':
  2211. return response, 200
  2212. if activity.distanceInMeters < 1: # Zwift saves the current activity when joining events (may have small distance even if didn't move)
  2213. Activity.query.filter_by(id=activity_id).delete()
  2214. db.session.commit()
  2215. logout_player(player_id)
  2216. return response, 200
  2217. create_power_curve(player_id, BytesIO(activity.fit))
  2218. save_fit(player_id, '%s - %s' % (activity_id, activity.fit_filename), activity.fit)
  2219. if current_user.enable_ghosts:
  2220. save_ghost(player_id, quote(activity.name, safe=' '))
  2221. if activity.sport == profile_pb2.Sport.CYCLING and activity.distanceInMeters >= 2000:
  2222. update_streaks(player_id, activity)
  2223. # For using with upload_activity
  2224. with open('%s/%s/last_activity.bin' % (STORAGE_DIR, player_id), 'wb') as f:
  2225. f.write(stream)
  2226. # Upload in separate thread to avoid client freezing if it takes longer than expected
  2227. upload = threading.Thread(target=activity_uploads, args=(player_id, activity))
  2228. upload.start()
  2229. logout_player(player_id)
  2230. return response, 200
  2231. @app.route('/api/profiles/<int:receiving_player_id>/activities/0/rideon', methods=['POST']) #activity_id Seem to always be 0, even when giving ride on to ppl with 30km+
  2232. @jwt_to_session_cookie
  2233. @login_required
  2234. def api_profiles_activities_rideon(receiving_player_id):
  2235. sending_player_id = request.json['profileId']
  2236. profile = get_partial_profile(sending_player_id)
  2237. player_update = udp_node_msgs_pb2.WorldAttribute()
  2238. player_update.server_realm = udp_node_msgs_pb2.ZofflineConstants.RealmID
  2239. player_update.wa_type = udp_node_msgs_pb2.WA_TYPE.WAT_RIDE_ON
  2240. player_update.world_time_born = world_time()
  2241. player_update.world_time_expire = player_update.world_time_born + 9890
  2242. player_update.timestamp = int(time.time() * 1000000)
  2243. ride_on = udp_node_msgs_pb2.RideOn()
  2244. ride_on.player_id = int(sending_player_id)
  2245. ride_on.to_player_id = int(receiving_player_id)
  2246. ride_on.firstName = profile.first_name
  2247. ride_on.lastName = profile.last_name
  2248. ride_on.countryCode = profile.country_code
  2249. player_update.payload = ride_on.SerializeToString()
  2250. enqueue_player_update(receiving_player_id, player_update.SerializeToString())
  2251. receiver = get_partial_profile(receiving_player_id)
  2252. message = 'Ride on ' + receiver.first_name + ' ' + receiver.last_name + '!'
  2253. discord.send_message(message, sending_player_id)
  2254. return '{}', 200
  2255. def stime_to_timestamp(stime):
  2256. try:
  2257. return int(datetime.datetime.strptime(stime, '%Y-%m-%dT%H:%M:%S%z').timestamp())
  2258. except:
  2259. return 0
  2260. def create_zca_notification(player_id, private_event, organizer):
  2261. orm_not = Notification(event_id=private_event['id'], player_id=player_id, json='')
  2262. db.session.add(orm_not)
  2263. db.session.commit()
  2264. argString0 = json.dumps({"eventId":private_event['id'],"eventStartDate":stime_to_timestamp(private_event['eventStart']),
  2265. "otherInviteeCount":len(private_event['invitedProfileIds'])})
  2266. n = { "activity": None, "argLong0": 0, "argLong1": 0, "argString0": argString0,
  2267. "createdOn": str_timestamp(int(time.time()*1000)),
  2268. "fromProfile": {
  2269. "firstName": organizer["firstName"],
  2270. "id": organizer["id"],
  2271. "imageSrc": organizer["imageSrc"],
  2272. "imageSrcLarge": organizer["imageSrc"],
  2273. "lastName": organizer["lastName"],
  2274. "publicId": "283b140f-91d2-4882-bd8e-e4194ddf7128", #todo, hope not used
  2275. "socialFacts": {
  2276. "favoriteOfLoggedInPlayer": True, #todo
  2277. "followeeStatusOfLoggedInPlayer": "IS_FOLLOWING", #todo
  2278. "followerStatusOfLoggedInPlayer": "IS_FOLLOWING" #todo
  2279. }
  2280. },
  2281. "id": orm_not.id, "lastModified": None, "read": False, "readDate": None,
  2282. "type": "PRIVATE_EVENT_INVITE"
  2283. }
  2284. orm_not.json = json.dumps(n)
  2285. db.session.commit()
  2286. @app.route('/api/notifications', methods=['GET'])
  2287. @jwt_to_session_cookie
  2288. @login_required
  2289. def api_notifications():
  2290. ret_notifications = []
  2291. for row in Notification.query.filter_by(player_id=current_user.player_id):
  2292. if json.loads(json.loads(row.json)["argString0"])["eventStartDate"] > time.time() - 1800:
  2293. ret_notifications.append(row.json)
  2294. return jsonify(ret_notifications)
  2295. @app.route('/api/notifications/<int:notif_id>', methods=['PUT'])
  2296. @jwt_to_session_cookie
  2297. @login_required
  2298. def api_notifications_put(notif_id):
  2299. for orm_not in Notification.query.filter_by(id=notif_id):
  2300. n = json.loads(orm_not.json)
  2301. n["read"] = request.json['read']
  2302. n["readDate"] = request.json['readDate']
  2303. n["lastModified"] = n["readDate"]
  2304. orm_not.json = json.dumps(n)
  2305. db.session.commit()
  2306. return '', 204
  2307. glb_private_events = {} #cache of actual PrivateEvent(db.Model)
  2308. def ActualPrivateEvents():
  2309. if len(glb_private_events) == 0:
  2310. for row in db.session.query(PrivateEvent).order_by(PrivateEvent.id.desc()).limit(100):
  2311. if len(row.json):
  2312. glb_private_events[row.id] = json.loads(row.json)
  2313. return glb_private_events
  2314. @app.route('/api/private_event/<int:meetup_id>', methods=['DELETE'])
  2315. @jwt_to_session_cookie
  2316. @login_required
  2317. def api_private_event_remove(meetup_id):
  2318. ActualPrivateEvents().pop(meetup_id)
  2319. PrivateEvent.query.filter_by(id=meetup_id).delete()
  2320. Notification.query.filter_by(event_id=meetup_id).delete()
  2321. db.session.commit()
  2322. return '', 200
  2323. def edit_private_event(player_id, meetup_id, decision):
  2324. ape = ActualPrivateEvents()
  2325. if meetup_id in ape.keys():
  2326. e = ape[meetup_id]
  2327. for i in e['eventInvites']:
  2328. if i['invitedProfile']['id'] == player_id:
  2329. i['status'] = decision
  2330. orm_event = db.session.get(PrivateEvent, meetup_id)
  2331. orm_event.json = json.dumps(e)
  2332. db.session.commit()
  2333. return '', 204
  2334. @app.route('/api/private_event/<int:meetup_id>/accept', methods=['PUT'])
  2335. @jwt_to_session_cookie
  2336. @login_required
  2337. def api_private_event_accept(meetup_id):
  2338. return edit_private_event(current_user.player_id, meetup_id, 'ACCEPTED')
  2339. @app.route('/api/private_event/<int:meetup_id>/reject', methods=['PUT'])
  2340. @jwt_to_session_cookie
  2341. @login_required
  2342. def api_private_event_reject(meetup_id):
  2343. return edit_private_event(current_user.player_id, meetup_id, 'REJECTED')
  2344. @app.route('/api/private_event/<int:meetup_id>', methods=['PUT'])
  2345. @jwt_to_session_cookie
  2346. @login_required
  2347. def api_private_event_edit(meetup_id):
  2348. str_pe = request.stream.read()
  2349. json_pe = json.loads(str_pe)
  2350. org_json_pe = ActualPrivateEvents()[meetup_id]
  2351. for f in ('culling', 'distanceInMeters', 'durationInSeconds', 'eventStart', 'invitedProfileIds', 'laps', 'routeId', 'rubberbanding', 'showResults', 'sport', 'workoutHash'):
  2352. org_json_pe[f] = json_pe[f]
  2353. org_json_pe['updateDate'] = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
  2354. newEventInvites = []
  2355. newEventInviteeIds = []
  2356. for i in org_json_pe['eventInvites']:
  2357. profile_id = i['invitedProfile']['id']
  2358. if profile_id == org_json_pe['organizerProfileId'] or profile_id in json_pe['invitedProfileIds']:
  2359. newEventInvites.append(i)
  2360. newEventInviteeIds.append(profile_id)
  2361. player_update = create_wa_event_invites(org_json_pe)
  2362. for peer_id in json_pe['invitedProfileIds']:
  2363. if not peer_id in newEventInviteeIds:
  2364. create_zca_notification(peer_id, org_json_pe, newEventInvites[0]["invitedProfile"])
  2365. player_update.rel_id = peer_id
  2366. enqueue_player_update(peer_id, player_update.SerializeToString())
  2367. p_partial_profile = get_partial_profile(peer_id)
  2368. newEventInvites.append({"invitedProfile": p_partial_profile.to_json(), "status": "PENDING"})
  2369. org_json_pe['eventInvites'] = newEventInvites
  2370. db.session.get(PrivateEvent, meetup_id).json = json.dumps(org_json_pe)
  2371. db.session.commit()
  2372. for orm_not in Notification.query.filter_by(event_id=meetup_id):
  2373. n = json.loads(orm_not.json)
  2374. n['read'] = False
  2375. n['readDate'] = None
  2376. n['lastModified'] = org_json_pe['updateDate']
  2377. orm_not.json = json.dumps(n)
  2378. db.session.commit()
  2379. return jsonify({"id":meetup_id})
  2380. def create_wa_event_invites(json_pe):
  2381. pe = events_pb2.Event()
  2382. player_update = udp_node_msgs_pb2.WorldAttribute()
  2383. player_update.server_realm = udp_node_msgs_pb2.ZofflineConstants.RealmID
  2384. player_update.wa_type = udp_node_msgs_pb2.WA_TYPE.WAT_INV_W
  2385. player_update.world_time_born = world_time()
  2386. player_update.world_time_expire = world_time() + 60000
  2387. player_update.wa_f12 = 1
  2388. player_update.timestamp = int(time.time()*1000000)
  2389. pe.id = json_pe['id']
  2390. pe.server_realm = udp_node_msgs_pb2.ZofflineConstants.RealmID
  2391. pe.name = json_pe['name']
  2392. if 'description' in json_pe:
  2393. pe.description = json_pe['description']
  2394. pe.eventStart = stime_to_timestamp(json_pe['eventStart'])*1000
  2395. pe.distanceInMeters = json_pe['distanceInMeters']
  2396. pe.laps = json_pe['laps']
  2397. if 'imageUrl' in json_pe:
  2398. pe.imageUrl = json_pe['imageUrl']
  2399. pe.durationInSeconds = json_pe['durationInSeconds']
  2400. pe.route_id = json_pe['routeId']
  2401. #{"rubberbanding":true,"showResults":false,"workoutHash":0} todo_pe
  2402. pe.visible = True
  2403. pe.jerseyHash = 0
  2404. pe.sport = sport_from_str(json_pe['sport'])
  2405. #pe.uint64 e_f23 = 23; =0
  2406. pe.eventType = events_pb2.EventType.EFONDO
  2407. if 'culling' in json_pe:
  2408. if json_pe['culling']:
  2409. pe.eventType = events_pb2.EventType.RACE
  2410. #pe.uint64 e_f25 = 25; =0
  2411. pe.e_f27 = 2 #<=4, ENUM? saw = 2
  2412. #pe.bool overrideMapPreferences = 28; =0
  2413. #pe.bool invisibleToNonParticipants = 29; =0 todo_pe
  2414. pe.lateJoinInMinutes = 30 #todo_pe
  2415. #pe.course_id = 1 #todo_pe =f(json_pe['routeId']) ???
  2416. player_update.payload = pe.SerializeToString()
  2417. return player_update
  2418. @app.route('/api/private_event', methods=['POST'])
  2419. @jwt_to_session_cookie
  2420. @login_required
  2421. def api_private_event_new(): #{"culling":true,"description":"mesg","distanceInMeters":13800.0,"durationInSeconds":0,"eventStart":"2022-03-17T16:27:00Z","invitedProfileIds":[4357549,4486967],"laps":0,"routeId":2474227587,"rubberbanding":true,"showResults":false,"sport":"CYCLING","workoutHash":0}
  2422. str_pe = request.stream.read()
  2423. json_pe = json.loads(str_pe)
  2424. db_pe = PrivateEvent(json=str_pe)
  2425. db.session.add(db_pe)
  2426. db.session.commit()
  2427. json_pe['id'] = db_pe.id
  2428. ev_sg_id = db_pe.id
  2429. json_pe['eventSubgroupId'] = ev_sg_id
  2430. json_pe['name'] = "Route #%s" % json_pe['routeId'] #todo: more readable
  2431. json_pe['acceptedTotalCount'] = len(json_pe['invitedProfileIds']) #todo: real count
  2432. json_pe['acceptedFolloweeCount'] = len(json_pe['invitedProfileIds']) + 1 #todo: real count
  2433. json_pe['invitedTotalCount'] = len(json_pe['invitedProfileIds']) + 1
  2434. partial_profile = get_partial_profile(current_user.player_id)
  2435. json_pe['organizerProfileId'] = current_user.player_id
  2436. json_pe['organizerId'] = current_user.player_id
  2437. json_pe['startLocation'] = 1 #todo_pe
  2438. json_pe['allowsLateJoin'] = True #todo_pe
  2439. json_pe['organizerFirstName'] = partial_profile.first_name
  2440. json_pe['organizerLastName'] = partial_profile.last_name
  2441. json_pe['updateDate'] = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
  2442. json_pe['organizerImageUrl'] = imageSrc(current_user.player_id)
  2443. eventInvites = [{"invitedProfile": partial_profile.to_json(), "status": "ACCEPTED"}]
  2444. create_event_wat(ev_sg_id, udp_node_msgs_pb2.WA_TYPE.WAT_JOIN_E, events_pb2.PlayerJoinedEvent(), online.keys())
  2445. player_update = create_wa_event_invites(json_pe)
  2446. enqueue_player_update(current_user.player_id, player_update.SerializeToString())
  2447. for peer_id in json_pe['invitedProfileIds']:
  2448. create_zca_notification(peer_id, json_pe, eventInvites[0]["invitedProfile"])
  2449. player_update.rel_id = peer_id
  2450. enqueue_player_update(peer_id, player_update.SerializeToString())
  2451. p_partial_profile = get_partial_profile(peer_id)
  2452. eventInvites.append({"invitedProfile": p_partial_profile.to_json(), "status": "PENDING"})
  2453. json_pe['eventInvites'] = eventInvites
  2454. ActualPrivateEvents()[db_pe.id] = json_pe
  2455. db_pe.json = json.dumps(json_pe)
  2456. db.session.commit() #update db_pe
  2457. return jsonify({"id":db_pe.id}), 201
  2458. def clone_and_append_social(player_id, private_event):
  2459. ret = deepcopy(private_event)
  2460. status = 'PENDING'
  2461. for i in ret['eventInvites']:
  2462. p = i['invitedProfile']
  2463. #todo: strict social
  2464. if p['id'] == player_id:
  2465. p['socialFacts'] = {"followerStatusOfLoggedInPlayer":"SELF","isFavoriteOfLoggedInPlayer":False}
  2466. status = i['status']
  2467. else:
  2468. p['socialFacts'] = {"followerStatusOfLoggedInPlayer":"IS_FOLLOWING","isFavoriteOfLoggedInPlayer":True}
  2469. ret['inviteStatus'] = status
  2470. return ret
  2471. def jsonPrivateEventFeedToProtobuf(jfeed):
  2472. ret = events_pb2.PrivateEventFeedListProto()
  2473. for jpef in jfeed:
  2474. pef = ret.pef.add()
  2475. pef.event_id = jpef['id']
  2476. pef.sport = sport_from_str(jpef['sport'])
  2477. pef.eventSubgroupStart = stime_to_timestamp(jpef['eventStart'])*1000
  2478. pef.route_id = jpef['routeId']
  2479. pef.durationInSeconds = jpef['durationInSeconds']
  2480. pef.distanceInMeters = jpef['distanceInMeters']
  2481. pef.answeredCount = 1 #todo
  2482. pef.invitedTotalCount = jpef['invitedTotalCount']
  2483. pef.acceptedFolloweeCount = jpef['acceptedFolloweeCount']
  2484. pef.acceptedTotalCount = jpef['acceptedTotalCount']
  2485. if jpef['organizerImageUrl'] is not None:
  2486. pef.organizerImageUrl = jpef['organizerImageUrl']
  2487. pef.organizerProfileId = jpef['organizerProfileId']
  2488. pef.organizerFirstName = jpef['organizerFirstName']
  2489. pef.organizerLastName = jpef['organizerLastName']
  2490. pef.updateDate = stime_to_timestamp(jpef['updateDate'])*1000
  2491. pef.subgroupId = jpef['eventSubgroupId']
  2492. pef.laps = jpef['laps']
  2493. pef.rubberbanding = jpef['rubberbanding']
  2494. return ret
  2495. @app.route('/api/private_event/feed', methods=['GET'])
  2496. @jwt_to_session_cookie
  2497. @login_required
  2498. def api_private_event_feed():
  2499. start_date = int(request.args.get('start_date')) / 1000
  2500. if start_date == -1800: start_date += time.time() # first ZA request has start_date=-1800000
  2501. past_events = request.args.get('organizer_only_past_events') == 'true'
  2502. ret = []
  2503. for pe in ActualPrivateEvents().values():
  2504. if ((current_user.player_id in pe['invitedProfileIds'] or current_user.player_id == pe['organizerProfileId']) \
  2505. and stime_to_timestamp(pe['eventStart']) > start_date) \
  2506. or (past_events and pe['organizerProfileId'] == current_user.player_id):
  2507. ret.append(clone_and_append_social(current_user.player_id, pe))
  2508. if request.headers['Accept'] == 'application/json':
  2509. return jsonify(ret)
  2510. return jsonPrivateEventFeedToProtobuf(ret).SerializeToString(), 200
  2511. def jsonPrivateEventToProtobuf(je):
  2512. ret = events_pb2.PrivateEventProto()
  2513. ret.id = je['id']
  2514. ret.sport = sport_from_str(je['sport'])
  2515. ret.eventStart = stime_to_timestamp(je['eventStart'])*1000
  2516. ret.routeId = je['routeId']
  2517. ret.startLocation = je['startLocation']
  2518. ret.durationInSeconds = je['durationInSeconds']
  2519. ret.distanceInMeters = je['distanceInMeters']
  2520. if 'description' in je:
  2521. ret.description = je['description']
  2522. ret.workoutHash = je['workoutHash']
  2523. ret.organizerId = je['organizerProfileId']
  2524. for jinv in je['eventInvites']:
  2525. jp = jinv['invitedProfile']
  2526. inv = ret.eventInvites.add()
  2527. inv.profile.player_id = jp['id']
  2528. inv.profile.firstName = jp['firstName']
  2529. inv.profile.lastName = jp['lastName']
  2530. if jp['imageSrc']:
  2531. inv.profile.imageSrc = jp['imageSrc']
  2532. inv.profile.enrolledZwiftAcademy = jp['enrolledZwiftAcademy']
  2533. inv.profile.male = jp['male']
  2534. inv.profile.player_type = profile_pb2.PlayerType.Value(jp['playerType'])
  2535. inv.profile.event_category = int(jp['male'])
  2536. inv.status = events_pb2.EventInviteStatus.Value(jinv['status'])
  2537. ret.showResults = je['showResults']
  2538. ret.laps = je['laps']
  2539. ret.rubberbanding = je['rubberbanding']
  2540. return ret
  2541. @app.route('/api/private_event/<int:event_id>', methods=['GET'])
  2542. @jwt_to_session_cookie
  2543. @login_required
  2544. def api_private_event_id(event_id):
  2545. ret = clone_and_append_social(current_user.player_id, ActualPrivateEvents()[event_id])
  2546. if request.headers['Accept'] == 'application/json':
  2547. return jsonify(ret)
  2548. return jsonPrivateEventToProtobuf(ret).SerializeToString(), 200
  2549. @app.route('/api/private_event/entitlement', methods=['GET'])
  2550. def api_private_event_entitlement():
  2551. return jsonify({"entitled": True})
  2552. @app.route('/relay/events/subgroups/<int:meetup_id>/late-join', methods=['GET'])
  2553. @jwt_to_session_cookie
  2554. @login_required
  2555. def relay_events_subgroups_id_late_join(meetup_id):
  2556. ape = ActualPrivateEvents()
  2557. if meetup_id in ape.keys():
  2558. event = jsonPrivateEventToProtobuf(ape[meetup_id])
  2559. leader = None
  2560. if event.organizerId in online and online[event.organizerId].groupId == meetup_id and event.organizerId != current_user.player_id:
  2561. leader = event.organizerId
  2562. else:
  2563. for player_id in online.keys():
  2564. if online[player_id].groupId == meetup_id and player_id != current_user.player_id:
  2565. leader = player_id
  2566. break
  2567. if leader is not None:
  2568. state = online[leader]
  2569. lj = events_pb2.LateJoinInformation()
  2570. lj.road_id = road_id(state)
  2571. lj.road_time = (state.roadTime - 5000) / 1000000
  2572. lj.is_forward = is_forward(state)
  2573. lj.organizerId = leader
  2574. lj.lj_f5 = 0
  2575. lj.lj_f6 = 0
  2576. lj.lj_f7 = 0
  2577. return lj.SerializeToString(), 200
  2578. return '', 200
  2579. def get_week_range(dt):
  2580. d = (dt - datetime.timedelta(days = dt.weekday())).replace(hour=0, minute=0, second=0, microsecond=0)
  2581. first = d
  2582. last = d + datetime.timedelta(days=6, hours=23, minutes=59, seconds=59)
  2583. return first, last
  2584. def get_month_range(dt):
  2585. num_days = calendar.monthrange(dt.year, dt.month)[1]
  2586. first = datetime.datetime(dt.year, dt.month, 1)
  2587. last = datetime.datetime(dt.year, dt.month, num_days, 23, 59, 59)
  2588. return first, last
  2589. def fill_in_goal_progress(goal, player_id):
  2590. utc_now = datetime.datetime.now(datetime.timezone.utc)
  2591. if goal.periodicity == 0: # weekly
  2592. first_dt, last_dt = get_week_range(utc_now)
  2593. else: # monthly
  2594. first_dt, last_dt = get_month_range(utc_now)
  2595. common_sql = """FROM activity
  2596. WHERE player_id = :p AND sport = :s
  2597. AND strftime('%s', start_date) >= strftime('%s', :f)
  2598. AND strftime('%s', start_date) <= strftime('%s', :l)"""
  2599. args = {"p": player_id, "s": goal.sport, "f": first_dt, "l": last_dt}
  2600. if goal.type == goal_pb2.GoalType.DISTANCE:
  2601. distance = db.session.execute(sqlalchemy.text('SELECT SUM(distanceInMeters) %s' % common_sql), args).first()[0]
  2602. if distance:
  2603. goal.actual_distance = distance
  2604. goal.actual_duration = distance
  2605. else:
  2606. goal.actual_distance = 0.0
  2607. goal.actual_duration = 0.0
  2608. else: # duration
  2609. duration = db.session.execute(sqlalchemy.text('SELECT SUM(julianday(end_date)-julianday(start_date)) %s' % common_sql), args).first()[0]
  2610. if duration:
  2611. goal.actual_duration = duration*1440 # convert from days to minutes
  2612. goal.actual_distance = duration*1440
  2613. else:
  2614. goal.actual_duration = 0.0
  2615. goal.actual_distance = 0.0
  2616. def set_goal_end_date_now(goal):
  2617. utc_now = datetime.datetime.now(datetime.timezone.utc)
  2618. if goal.periodicity == 0: # weekly
  2619. goal.period_end_date = int(get_week_range(utc_now)[1].timestamp()*1000)
  2620. else: # monthly
  2621. goal.period_end_date = int(get_month_range(utc_now)[1].timestamp()*1000)
  2622. def str_sport(int_sport):
  2623. if int_sport == 1:
  2624. return "RUNNING"
  2625. return "CYCLING"
  2626. def sport_from_str(str_sport):
  2627. if str_sport == 'CYCLING':
  2628. return 0
  2629. return 1 #running
  2630. def str_timestamp(ts):
  2631. if ts == None:
  2632. return None
  2633. else:
  2634. sec = int(ts/1000)
  2635. ms = ts % 1000
  2636. return datetime.datetime.fromtimestamp(sec, datetime.timezone.utc).strftime('%Y-%m-%dT%H:%M:%S.') + str(ms).zfill(3) + "+0000"
  2637. def str_timestamp_json(ts):
  2638. if ts == 0:
  2639. return None
  2640. else:
  2641. return str_timestamp(ts)
  2642. def goalProtobufToJson(goal):
  2643. return {"id":goal.id,"profileId":goal.player_id,"sport":str_sport(goal.sport),"name":goal.name,"type":int(goal.type),"periodicity":int(goal.periodicity),
  2644. "targetDistanceInMeters":goal.target_distance,"targetDurationInMinutes":goal.target_duration,"actualDistanceInMeters":goal.actual_distance,
  2645. "actualDurationInMinutes":goal.actual_duration,"createdOn":str_timestamp_json(goal.created_on),
  2646. "periodEndDate":str_timestamp_json(goal.period_end_date),"status":int(goal.status),"timezone":goal.timezone}
  2647. def goalJsonToProtobuf(json_goal):
  2648. goal = goal_pb2.Goal()
  2649. goal.sport = sport_from_str(json_goal['sport'])
  2650. goal.id = json_goal['id']
  2651. goal.name = json_goal['name']
  2652. goal.periodicity = int(json_goal['periodicity'])
  2653. goal.type = int(json_goal['type'])
  2654. goal.status = goal_pb2.GoalStatus.ACTIVE
  2655. goal.target_distance = json_goal['targetDistanceInMeters']
  2656. goal.target_duration = json_goal['targetDurationInMinutes']
  2657. goal.actual_distance = json_goal['actualDistanceInMeters']
  2658. goal.actual_duration = json_goal['actualDurationInMinutes']
  2659. goal.player_id = json_goal['profileId']
  2660. return goal
  2661. @app.route('/api/profiles/<int:player_id>/goals/<int:goal_id>', methods=['PUT'])
  2662. @jwt_to_session_cookie
  2663. @login_required
  2664. def api_profiles_goals_put(player_id, goal_id):
  2665. if player_id != current_user.player_id:
  2666. return '', 401
  2667. if not request.stream:
  2668. return '', 400
  2669. str_goal = request.stream.read()
  2670. json_goal = json.loads(str_goal)
  2671. goal = goalJsonToProtobuf(json_goal)
  2672. update_protobuf_in_db(Goal, goal, goal.id)
  2673. return jsonify(json_goal)
  2674. def select_protobuf_goals(player_id, limit):
  2675. goals = goal_pb2.Goals()
  2676. if limit > 0:
  2677. stmt = sqlalchemy.text("SELECT * FROM goal WHERE player_id = :p LIMIT :l")
  2678. rows = db.session.execute(stmt, {"p": player_id, "l": limit}).mappings()
  2679. need_update = list()
  2680. for row in rows:
  2681. goal = goals.goals.add()
  2682. row_to_protobuf(row, goal)
  2683. end_dt = datetime.datetime.fromtimestamp(goal.period_end_date / 1000, datetime.timezone.utc)
  2684. if end_dt < datetime.datetime.now(datetime.timezone.utc):
  2685. need_update.append(goal)
  2686. fill_in_goal_progress(goal, player_id)
  2687. for goal in need_update:
  2688. set_goal_end_date_now(goal)
  2689. update_protobuf_in_db(Goal, goal, goal.id)
  2690. return goals
  2691. def convert_goals_to_json(goals):
  2692. json_goals = []
  2693. for goal in goals.goals:
  2694. json_goal = goalProtobufToJson(goal)
  2695. json_goals.append(json_goal)
  2696. return json_goals
  2697. @app.route('/api/profiles/<int:player_id>/goals', methods=['GET', 'POST'])
  2698. @jwt_to_session_cookie
  2699. @login_required
  2700. def api_profiles_goals(player_id):
  2701. if player_id != current_user.player_id:
  2702. return '', 401
  2703. if request.method == 'POST':
  2704. if not request.stream:
  2705. return '', 400
  2706. if request.headers['Content-Type'] == 'application/x-protobuf-lite':
  2707. goal = goal_pb2.Goal()
  2708. goal.ParseFromString(request.stream.read())
  2709. else:
  2710. str_goal = request.stream.read()
  2711. json_goal = json.loads(str_goal)
  2712. goal = goalJsonToProtobuf(json_goal)
  2713. goal.created_on = int(time.time()*1000)
  2714. set_goal_end_date_now(goal)
  2715. fill_in_goal_progress(goal, player_id)
  2716. goal.id = insert_protobuf_into_db(Goal, goal)
  2717. if request.headers['Accept'] == 'application/json':
  2718. return jsonify(goalProtobufToJson(goal))
  2719. else:
  2720. return goal.SerializeToString(), 200
  2721. # request.method == 'GET'
  2722. goals = select_protobuf_goals(player_id, 100)
  2723. if request.headers['Accept'] == 'application/json':
  2724. json_goals = convert_goals_to_json(goals)
  2725. return jsonify(json_goals) # json for ZCA
  2726. else:
  2727. return goals.SerializeToString(), 200 # protobuf for ZG
  2728. @app.route('/api/profiles/<int:player_id>/goals/<int:goal_id>', methods=['DELETE'])
  2729. @jwt_to_session_cookie
  2730. @login_required
  2731. def api_profiles_goals_id(player_id, goal_id):
  2732. if player_id != current_user.player_id:
  2733. return '', 401
  2734. db.session.execute(sqlalchemy.text("DELETE FROM goal WHERE id = :i"), {"i": goal_id})
  2735. db.session.commit()
  2736. return '', 200
  2737. @app.route('/api/tcp-config', methods=['GET'])
  2738. @app.route('/relay/tcp-config', methods=['GET'])
  2739. def api_tcp_config():
  2740. infos = per_session_info_pb2.TcpConfig()
  2741. info = infos.nodes.add()
  2742. info.ip = server_ip
  2743. info.port = 3023
  2744. return infos.SerializeToString(), 200
  2745. def add_player_to_world(player, course_world, is_pace_partner=False, is_bot=False, is_bookmark=False, name=None):
  2746. course_id = get_course(player)
  2747. if course_id in course_world.keys():
  2748. partial_profile = get_partial_profile(player.id)
  2749. online_player = None
  2750. if is_pace_partner:
  2751. online_player = course_world[course_id].pacer_bots.add()
  2752. online_player.route = partial_profile.route
  2753. if player.sport == profile_pb2.Sport.CYCLING:
  2754. online_player.ride_power = player.power
  2755. else:
  2756. online_player.speed = player.speed
  2757. elif is_bot:
  2758. online_player = course_world[course_id].others.add()
  2759. elif is_bookmark:
  2760. online_player = course_world[course_id].pro_players.add()
  2761. else: # to be able to join zwifter using new home screen
  2762. online_player = course_world[course_id].followees.add()
  2763. online_player.id = player.id
  2764. online_player.firstName = courses_lookup[course_id] if name else partial_profile.first_name
  2765. online_player.lastName = name if name else partial_profile.last_name
  2766. online_player.distance = player.distance
  2767. online_player.time = player.time
  2768. online_player.country_code = partial_profile.country_code
  2769. online_player.sport = player.sport
  2770. online_player.power = player.power
  2771. online_player.x = player.x
  2772. online_player.y_altitude = player.y_altitude
  2773. online_player.z = player.z
  2774. course_world[course_id].zwifters += 1
  2775. def relay_worlds_generic(server_realm=None, player_id=None):
  2776. # Android client also requests a JSON version
  2777. if request.headers['Accept'] == 'application/json':
  2778. friends = []
  2779. for p_id in online:
  2780. profile = get_partial_profile(p_id)
  2781. friend = {"playerId": p_id, "firstName": profile.first_name, "lastName": profile.last_name, "male": profile.male, "countryISOCode": profile.country_code,
  2782. "totalDistanceInMeters": jsv0(online[p_id], 'distance'), "rideDurationInSeconds": jsv0(online[p_id], 'time'), "playerType": profile.player_type,
  2783. "followerStatusOfLoggedInPlayer": "NO_RELATIONSHIP", "rideOnGiven": False, "currentSport": profile_pb2.Sport.Name(jsv0(online[p_id], 'sport')),
  2784. "enrolledZwiftAcademy": False, "mapId": 1, "ftp": 100, "runTime10kmInSeconds": 3600}
  2785. friends.append(friend)
  2786. world = { 'currentDateTime': int(time.time()),
  2787. 'currentWorldTime': world_time(),
  2788. 'friendsInWorld': friends,
  2789. 'mapId': 1,
  2790. 'name': 'Public Watopia',
  2791. 'playerCount': len(online),
  2792. 'worldId': udp_node_msgs_pb2.ZofflineConstants.RealmID
  2793. }
  2794. if server_realm:
  2795. world['worldId'] = server_realm
  2796. return jsonify(world)
  2797. else:
  2798. return jsonify([ world ])
  2799. else: # protobuf request
  2800. worlds = world_pb2.DropInWorldList()
  2801. world = None
  2802. course_world = {}
  2803. for course in courses_lookup.keys():
  2804. world = worlds.worlds.add()
  2805. world.id = udp_node_msgs_pb2.ZofflineConstants.RealmID
  2806. world.name = 'Public Watopia'
  2807. world.course_id = course
  2808. world.world_time = world_time()
  2809. world.real_time = int(time.time())
  2810. world.zwifters = 0
  2811. course_world[course] = world
  2812. for p_id in online.keys():
  2813. player = online[p_id]
  2814. add_player_to_world(player, course_world)
  2815. for p_id in global_pace_partners.keys():
  2816. pace_partner_variables = global_pace_partners[p_id]
  2817. pace_partner = pace_partner_variables.route.states[pace_partner_variables.position]
  2818. add_player_to_world(pace_partner, course_world, is_pace_partner=True)
  2819. for p_id in global_bots.keys():
  2820. bot_variables = global_bots[p_id]
  2821. bot = bot_variables.route.states[bot_variables.position]
  2822. add_player_to_world(bot, course_world, is_bot=True)
  2823. if player_id in global_bookmarks.keys():
  2824. for bookmark in global_bookmarks[player_id].values():
  2825. add_player_to_world(bookmark.state, course_world, is_bookmark=True, name=bookmark.name)
  2826. if server_realm:
  2827. world.id = server_realm
  2828. return world.SerializeToString()
  2829. else:
  2830. return worlds.SerializeToString()
  2831. def load_bookmarks(player_id):
  2832. if not player_id in global_bookmarks.keys():
  2833. global_bookmarks[player_id] = {}
  2834. bookmarks = global_bookmarks[player_id]
  2835. bookmarks.clear()
  2836. bookmarks_dir = os.path.join(STORAGE_DIR, str(player_id), 'bookmarks')
  2837. if os.path.isdir(bookmarks_dir):
  2838. i = 1
  2839. for (root, dirs, files) in os.walk(bookmarks_dir):
  2840. for file in files:
  2841. if file.endswith('.bin'):
  2842. state = udp_node_msgs_pb2.PlayerState()
  2843. with open(os.path.join(root, file), 'rb') as f:
  2844. state.ParseFromString(f.read())
  2845. state.id = i + 9000000 + player_id % 1000 * 1000
  2846. bookmark = Bookmark()
  2847. bookmark.name = file[:-4]
  2848. bookmark.state = state
  2849. bookmarks[state.id] = bookmark
  2850. i += 1
  2851. @app.route('/relay/worlds', methods=['GET'])
  2852. @app.route('/relay/dropin', methods=['GET']) #zwift::protobuf::DropInWorldList
  2853. @jwt_to_session_cookie
  2854. @login_required
  2855. def relay_worlds():
  2856. load_bookmarks(current_user.player_id)
  2857. return relay_worlds_generic(player_id=current_user.player_id)
  2858. def add_teleport_target(player, targets, is_pace_partner=True, name=None):
  2859. partial_profile = get_partial_profile(player.id)
  2860. if is_pace_partner:
  2861. target = targets.pacer_groups.add()
  2862. target.route = partial_profile.route
  2863. else:
  2864. target = targets.friends.add()
  2865. target.route = player.route
  2866. target.id = player.id
  2867. target.firstName = partial_profile.first_name
  2868. target.lastName = name if name else partial_profile.last_name
  2869. target.distance = player.distance
  2870. target.time = player.time
  2871. target.country_code = partial_profile.country_code
  2872. target.sport = player.sport
  2873. target.power = player.power
  2874. target.x = player.x
  2875. target.y_altitude = player.y_altitude
  2876. target.z = player.z
  2877. target.ride_power = player.power
  2878. target.speed = player.speed
  2879. @app.route('/relay/teleport-targets', methods=['GET'])
  2880. @jwt_to_session_cookie
  2881. @login_required
  2882. def relay_teleport_targets():
  2883. course = int(request.args.get('mapRevisionId'))
  2884. targets = world_pb2.TeleportTargets()
  2885. for p_id in global_pace_partners.keys():
  2886. pp = global_pace_partners[p_id]
  2887. pace_partner = pp.route.states[pp.position]
  2888. if get_course(pace_partner) == course:
  2889. add_teleport_target(pace_partner, targets)
  2890. for p_id in online.keys():
  2891. if p_id != current_user.player_id:
  2892. player = online[p_id]
  2893. if get_course(player) == course:
  2894. add_teleport_target(player, targets, False)
  2895. if current_user.player_id in global_bookmarks.keys():
  2896. for bookmark in global_bookmarks[current_user.player_id].values():
  2897. if get_course(bookmark.state) == course:
  2898. add_teleport_target(bookmark.state, targets, False, bookmark.name)
  2899. return targets.SerializeToString()
  2900. def iterableToJson(it):
  2901. if it == None:
  2902. return None
  2903. ret = []
  2904. for i in it:
  2905. ret.append(i)
  2906. return ret
  2907. def convert_event_to_json(event):
  2908. esgs = []
  2909. for event_cat in event.category:
  2910. esgs.append({"id":event_cat.id,"name":event_cat.name,"description":event_cat.description,"label":event_cat.label,
  2911. "subgroupLabel":event_cat.name[-1],"rulesId":event_cat.rules_id,"mapId":event_cat.course_id,"routeId":event_cat.route_id,"routeUrl":event_cat.routeUrl,
  2912. "jerseyHash":event_cat.jerseyHash,"bikeHash":event_cat.bikeHash,"startLocation":event_cat.startLocation,"invitedLeaders":iterableToJson(event_cat.invitedLeaders),
  2913. "invitedSweepers":iterableToJson(event_cat.invitedSweepers),"paceType":event_cat.paceType,"fromPaceValue":event_cat.fromPaceValue,"toPaceValue":event_cat.toPaceValue,
  2914. "fieldLimit":None,"registrationStart":str_timestamp_json(event_cat.registrationStart),"registrationEnd":str_timestamp_json(event_cat.registrationEnd),"lineUpStart":str_timestamp_json(event_cat.lineUpStart),
  2915. "lineUpEnd":str_timestamp_json(event_cat.lineUpEnd),"eventSubgroupStart":str_timestamp_json(event_cat.eventSubgroupStart),"durationInSeconds":event_cat.durationInSeconds,"laps":event_cat.laps,
  2916. "distanceInMeters":event_cat.distanceInMeters,"signedUp":False,"signupStatus":1,"registered":False,"registrationStatus":1,"followeeEntrantCount":0,
  2917. "totalEntrantCount":0,"followeeSignedUpCount":0,"totalSignedUpCount":0,"followeeJoinedCount":0,"totalJoinedCount":0,"auxiliaryUrl":"",
  2918. "rulesSet":["ALLOWS_LATE_JOIN"],"workoutHash":None,"customUrl":event_cat.customUrl,"overrideMapPreferences":False,
  2919. "tags":[""],"lateJoinInMinutes":event_cat.lateJoinInMinutes,"timeTrialOptions":None,"qualificationRuleIds":None,"accessValidationResult":None})
  2920. return {"id":event.id,"worldId":event.server_realm,"name":event.name,"description":event.description,"shortName":None,"mapId":event.course_id,
  2921. "shortDescription":None,"imageUrl":event.imageUrl,"routeId":event.route_id,"rulesId":event.rules_id,"rulesSet":["ALLOWS_LATE_JOIN"],
  2922. "routeUrl":None,"jerseyHash":event.jerseyHash,"bikeHash":None,"visible":event.visible,"overrideMapPreferences":event.overrideMapPreferences,"eventStart":str_timestamp_json(event.eventStart), "tags":[""],
  2923. "durationInSeconds":event.durationInSeconds,"distanceInMeters":event.distanceInMeters,"laps":event.laps,"privateEvent":False,"invisibleToNonParticipants":event.invisibleToNonParticipants,
  2924. "followeeEntrantCount":0,"totalEntrantCount":0,"followeeSignedUpCount":0,"totalSignedUpCount":0,"followeeJoinedCount":0,"totalJoinedCount":0,
  2925. "eventSeries":None,"auxiliaryUrl":"","imageS3Name":None,"imageS3Bucket":None,"sport":str_sport(event.sport),"cullingType":"CULLING_EVERYBODY",
  2926. "recurring":True,"recurringOffset":None,"publishRecurring":True,"parentId":None,"type":events_pb2._EVENTTYPEV2.values_by_number[int(event.eventType)].name,
  2927. "eventType":events_pb2._EVENTTYPE.values_by_number[int(event.eventType)].name,
  2928. "workoutHash":None,"customUrl":"","restricted":False,"unlisted":False,"eventSecret":None,"accessExpression":None,"qualificationRuleIds":None,
  2929. "lateJoinInMinutes":event.lateJoinInMinutes,"timeTrialOptions":None,"microserviceName":None,"microserviceExternalResourceId":None,
  2930. "microserviceEventVisibility":None, "minGameVersion":None,"recordable":True,"imported":False,"eventTemplateId":None, "eventSubgroups": esgs }
  2931. def convert_events_to_json(events):
  2932. json_events = []
  2933. for e in events.events:
  2934. json_event = convert_event_to_json(e)
  2935. json_events.append(json_event)
  2936. return json_events
  2937. def transformPrivateEvents(player_id, max_count, status):
  2938. ret = []
  2939. if max_count > 0:
  2940. for e in ActualPrivateEvents().values():
  2941. if stime_to_timestamp(e['eventStart']) > time.time() - 1800:
  2942. for i in e['eventInvites']:
  2943. if i['invitedProfile']['id'] == player_id:
  2944. if i['status'] == status:
  2945. e_clone = deepcopy(e)
  2946. e_clone['inviteStatus'] = status
  2947. ret.append(e_clone)
  2948. if len(ret) >= max_count:
  2949. return ret
  2950. return ret
  2951. #todo: followingCount=3&playerSport=all&fetchCampaign=true
  2952. @app.route('/relay/worlds/<int:server_realm>/aggregate/mobile', methods=['GET'])
  2953. @jwt_to_session_cookie
  2954. @login_required
  2955. def relay_worlds_id_aggregate_mobile(server_realm):
  2956. goalCount = int(request.args.get('goalCount'))
  2957. goals = select_protobuf_goals(current_user.player_id, goalCount)
  2958. json_goals = convert_goals_to_json(goals)
  2959. activityCount = int(request.args.get('activityCount'))
  2960. json_activities = select_activities_json(None, activityCount)
  2961. eventCount = int(request.args.get('eventCount'))
  2962. eventSport = request.args.get('eventSport')
  2963. events = get_events(eventCount, eventSport)
  2964. json_events = convert_events_to_json(events)
  2965. pendingEventInviteCount = int(request.args.get('pendingEventInviteCount'))
  2966. ppeFeed = transformPrivateEvents(current_user.player_id, pendingEventInviteCount, 'PENDING')
  2967. acceptedEventInviteCount = int(request.args.get('acceptedEventInviteCount'))
  2968. apeFeed = transformPrivateEvents(current_user.player_id, acceptedEventInviteCount, 'ACCEPTED')
  2969. return jsonify({"events":json_events,"goals":json_goals,"activities":json_activities,"pendingPrivateEventFeed":ppeFeed,"acceptedPrivateEventFeed":apeFeed,
  2970. "hasFolloweesToRideOn":False,"worldName":"MAKURIISLANDS","playerCount": len(online),"followingPlayerCount":0,"followingPlayers":[]})
  2971. @app.route('/relay/worlds/<int:server_realm>', methods=['GET'], strict_slashes=False)
  2972. def relay_worlds_id(server_realm):
  2973. return relay_worlds_generic(server_realm)
  2974. @app.route('/relay/worlds/<int:server_realm>/join', methods=['POST'])
  2975. def relay_worlds_id_join(server_realm):
  2976. return '{"worldTime":%ld}' % world_time()
  2977. @app.route('/relay/worlds/<int:server_realm>/players/<int:player_id>', methods=['GET'])
  2978. def relay_worlds_id_players_id(server_realm, player_id):
  2979. if player_id in online.keys():
  2980. player = online[player_id]
  2981. return player.SerializeToString()
  2982. if player_id in global_pace_partners.keys():
  2983. pace_partner = global_pace_partners[player_id]
  2984. state = pace_partner.route.states[pace_partner.position]
  2985. state.world = get_course(state)
  2986. state.route = get_partial_profile(player_id).route
  2987. return state.SerializeToString()
  2988. if player_id in global_bots.keys():
  2989. bot = global_bots[player_id]
  2990. return bot.route.states[bot.position].SerializeToString()
  2991. return '', 404
  2992. @app.route('/relay/worlds/hash-seeds', methods=['GET'])
  2993. def relay_worlds_hash_seeds():
  2994. seeds = hash_seeds_pb2.HashSeeds()
  2995. for x in range(4):
  2996. seed = seeds.seeds.add()
  2997. seed.seed1 = int(random.getrandbits(31))
  2998. seed.seed2 = int(random.getrandbits(31))
  2999. seed.expiryDate = world_time()+(10800+x*1200)*1000
  3000. return seeds.SerializeToString(), 200
  3001. def save_bookmark(state, name):
  3002. bookmarks_dir = os.path.join(STORAGE_DIR, str(state.id), 'bookmarks', str(get_course(state)), str(state.sport))
  3003. if not make_dir(bookmarks_dir):
  3004. return
  3005. with open(os.path.join(bookmarks_dir, name + '.bin'), 'wb') as f:
  3006. f.write(state.SerializeToString())
  3007. @app.route('/relay/worlds/attributes', methods=['POST'])
  3008. @jwt_to_session_cookie
  3009. @login_required
  3010. def relay_worlds_attributes():
  3011. player_update = udp_node_msgs_pb2.WorldAttribute()
  3012. player_update.ParseFromString(request.stream.read())
  3013. player_update.world_time_expire = world_time() + 60000
  3014. player_update.wa_f12 = 1
  3015. player_update.timestamp = int(time.time() * 1000000)
  3016. state = None
  3017. if player_update.wa_type == udp_node_msgs_pb2.WA_TYPE.WAT_SPA:
  3018. chat_message = tcp_node_msgs_pb2.SocialPlayerAction()
  3019. chat_message.ParseFromString(player_update.payload)
  3020. if chat_message.player_id in online:
  3021. state = online[chat_message.player_id]
  3022. if chat_message.message.startswith('.'):
  3023. command = chat_message.message[1:]
  3024. if command == 'regroup':
  3025. regroup_ghosts(chat_message.player_id)
  3026. elif command == 'position':
  3027. logger.info('course %s road %s isForward %s roadTime %s route %s' % (get_course(state), road_id(state), is_forward(state), state.roadTime, state.route))
  3028. elif command.startswith('bookmark') and len(command) > 9:
  3029. save_bookmark(state, quote(command[9:], safe=' '))
  3030. send_message('Bookmark saved', recipients=[chat_message.player_id])
  3031. else:
  3032. send_message('Invalid command: %s' % command, recipients=[chat_message.player_id])
  3033. return '', 201
  3034. discord.send_message(chat_message.message, chat_message.player_id)
  3035. for receiving_player_id in online.keys():
  3036. should_receive = False
  3037. # Chat message
  3038. if player_update.wa_type == udp_node_msgs_pb2.WA_TYPE.WAT_SPA:
  3039. if is_nearby(state, online[receiving_player_id]):
  3040. should_receive = True
  3041. # Other PlayerUpdate, send to all
  3042. else:
  3043. should_receive = True
  3044. if should_receive:
  3045. enqueue_player_update(receiving_player_id, player_update.SerializeToString())
  3046. return '', 201
  3047. @app.route('/api/segment-results', methods=['POST'])
  3048. @jwt_to_session_cookie
  3049. @login_required
  3050. def api_segment_results():
  3051. if not request.stream:
  3052. return '', 400
  3053. data = request.stream.read()
  3054. result = segment_result_pb2.SegmentResult()
  3055. result.ParseFromString(data)
  3056. if result.segment_id == 1:
  3057. return '', 400
  3058. result.world_time = world_time()
  3059. result.finish_time_str = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
  3060. result.sport = 0
  3061. result.id = insert_protobuf_into_db(SegmentResult, result)
  3062. # Previously done in /relay/worlds/attributes
  3063. player_update = udp_node_msgs_pb2.WorldAttribute()
  3064. player_update.server_realm = udp_node_msgs_pb2.ZofflineConstants.RealmID
  3065. player_update.wa_type = udp_node_msgs_pb2.WA_TYPE.WAT_SR
  3066. player_update.payload = data
  3067. player_update.world_time_born = world_time()
  3068. player_update.world_time_expire = world_time() + 60000
  3069. player_update.timestamp = int(time.time() * 1000000)
  3070. sending_player_id = result.player_id
  3071. if sending_player_id in online:
  3072. sending_player = online[sending_player_id]
  3073. for receiving_player_id in online.keys():
  3074. if receiving_player_id != sending_player_id:
  3075. receiving_player = online[receiving_player_id]
  3076. if get_course(sending_player) == get_course(receiving_player) or receiving_player.watchingRiderId == sending_player_id:
  3077. enqueue_player_update(receiving_player_id, player_update.SerializeToString())
  3078. return {"id": result.id}
  3079. @app.route('/api/personal-records/my-records', methods=['GET'])
  3080. @jwt_to_session_cookie
  3081. @login_required
  3082. def api_personal_records_my_records():
  3083. if not request.args.get('segmentId'):
  3084. return '', 422
  3085. segment_id = int(request.args.get('segmentId'))
  3086. from_date = request.args.get('from')
  3087. to_date = request.args.get('to')
  3088. results = segment_result_pb2.SegmentResults()
  3089. results.server_realm = udp_node_msgs_pb2.ZofflineConstants.RealmID
  3090. results.segment_id = segment_id
  3091. where_stmt = "WHERE segment_id = :s AND player_id = :p"
  3092. args = {"s": segment_id, "p": current_user.player_id}
  3093. if from_date and not ALL_TIME_LEADERBOARDS:
  3094. where_stmt += " AND strftime('%s', finish_time_str) > strftime('%s', :f)"
  3095. args.update({"f": from_date})
  3096. if to_date:
  3097. where_stmt += " AND strftime('%s', finish_time_str) < strftime('%s', :t)"
  3098. args.update({"t": to_date})
  3099. rows = db.session.execute(sqlalchemy.text("SELECT * FROM segment_result %s ORDER BY elapsed_ms LIMIT 100" % where_stmt), args).mappings()
  3100. for row in rows:
  3101. result = results.segment_results.add()
  3102. row_to_protobuf(row, result, ['server_realm', 'course_id', 'segment_id', 'event_subgroup_id', 'finish_time_str', 'f14', 'time', 'player_type', 'f22', 'f23'])
  3103. return results.SerializeToString(), 200
  3104. @app.route('/api/personal-records/my-segment-ride-stats/<sport>', methods=['GET'])
  3105. @jwt_to_session_cookie
  3106. @login_required
  3107. def api_personal_records_my_segment_ride_stats(sport):
  3108. if not request.args.get('segmentId'):
  3109. return '', 422
  3110. stats = segment_result_pb2.SegmentRideStats()
  3111. stats.segment_id = int(request.args.get('segmentId'))
  3112. where_stmt = "WHERE segment_id = :s AND player_id = :p AND sport = :sp"
  3113. args = {"s": stats.segment_id, "p": current_user.player_id, "sp": profile_pb2.Sport.Value(sport)}
  3114. row = db.session.execute(sqlalchemy.text("SELECT * FROM segment_result %s ORDER BY elapsed_ms LIMIT 1" % where_stmt), args).first()
  3115. if row:
  3116. stats.number_of_results = db.session.execute(sqlalchemy.text("SELECT COUNT(*) FROM segment_result %s" % where_stmt), args).scalar()
  3117. stats.latest_time = row.elapsed_ms # Zwift sends only best
  3118. stats.latest_percentile = 100
  3119. stats.best_time = row.elapsed_ms
  3120. stats.best_percentile = 100
  3121. return stats.SerializeToString(), 200
  3122. @app.route('/api/personal-records/results/summary/profiles/me/<sport>', methods=['GET'])
  3123. @jwt_to_session_cookie
  3124. @login_required
  3125. def api_personal_records_results_summary(sport):
  3126. segment_ids = request.args.getlist('segmentIds')
  3127. query = {"name": "AllTimeBestResultsForSegments", "labelsAre": "SEGMENT_ID", "sport": sport, "segmentIds": segment_ids}
  3128. results = []
  3129. for segment_id in segment_ids:
  3130. where_stmt = "WHERE segment_id = :s AND player_id = :p AND sport = :sp"
  3131. args = {"s": int(segment_id), "p": current_user.player_id, "sp": profile_pb2.Sport.Value(sport)}
  3132. row = db.session.execute(sqlalchemy.text("SELECT * FROM segment_result %s ORDER BY elapsed_ms LIMIT 1" % where_stmt), args).first()
  3133. if row:
  3134. count = db.session.execute(sqlalchemy.text("SELECT COUNT(*) FROM segment_result %s" % where_stmt), args).scalar()
  3135. result = {"label": segment_id, "result": {"segmentId": int(segment_id), "profileId": row.player_id, "firstInitial": row.first_name[0],
  3136. "lastName": row.last_name, "endTime": stime_to_timestamp(row.finish_time_str) * 1000, "durationInMilliseconds": row.elapsed_ms,
  3137. "playerType": "NORMAL", "activityId": row.activity_id, "numberOfResults": count}}
  3138. results.append(result)
  3139. return jsonify({"query": query, "results": results})
  3140. def limits(q, y):
  3141. if q == 1: return ('%s-01-01T00:00:00Z' % y, '%s-03-31T23:59:59Z' % y)
  3142. if q == 2: return ('%s-04-01T00:00:00Z' % y, '%s-06-30T23:59:59Z' % y)
  3143. if q == 3: return ('%s-07-01T00:00:00Z' % y, '%s-09-30T23:59:59Z' % y)
  3144. if q == 4: return ('%s-10-01T00:00:00Z' % y, '%s-12-31T23:59:59Z' % y)
  3145. @app.route('/api/personal-records/results/summary/profiles/me/<sport>/segments/<segment_id>/by-quarter', methods=['GET'])
  3146. @jwt_to_session_cookie
  3147. @login_required
  3148. def api_personal_records_results_summary_by_quarter(sport, segment_id):
  3149. query = {"name": "QuarterlyRecordsForSegment", "labelsAre": "YEAR-QUARTER", "sport": sport, "segmentId": segment_id}
  3150. where_stmt = "WHERE segment_id = :s AND player_id = :p AND sport = :sp"
  3151. args = {"s": int(segment_id), "p": current_user.player_id, "sp": profile_pb2.Sport.Value(sport)}
  3152. row = db.session.execute(sqlalchemy.text("SELECT finish_time_str FROM segment_result %s ORDER BY world_time LIMIT 1" % where_stmt), args).first()
  3153. oldest = time.strptime(row[0], "%Y-%m-%dT%H:%M:%SZ").tm_year
  3154. row = db.session.execute(sqlalchemy.text("SELECT finish_time_str FROM segment_result %s ORDER BY world_time DESC LIMIT 1" % where_stmt), args).first()
  3155. newest = time.strptime(row[0], "%Y-%m-%dT%H:%M:%SZ").tm_year
  3156. results = []
  3157. for y in range(oldest, newest + 1):
  3158. for q in range(1, 5):
  3159. from_date, to_date = limits(q, y)
  3160. where_stmt = "WHERE segment_id = :s AND player_id = :p AND sport = :sp AND strftime('%s', finish_time_str) >= strftime('%s', :f) AND strftime('%s', finish_time_str) <= strftime('%s', :t)"
  3161. args = {"s": int(segment_id), "p": current_user.player_id, "sp": profile_pb2.Sport.Value(sport), "f": from_date, "t": to_date}
  3162. row = db.session.execute(sqlalchemy.text("SELECT * FROM segment_result %s ORDER BY elapsed_ms LIMIT 1" % where_stmt), args).first()
  3163. if row:
  3164. count = db.session.execute(sqlalchemy.text("SELECT COUNT(*) FROM segment_result %s" % where_stmt), args).scalar()
  3165. result = {"label": '%s-Q%s' % (y, q), "result": {"segmentId": int(segment_id), "profileId": row.player_id, "firstInitial": row.first_name[0],
  3166. "lastName": row.last_name, "endTime": stime_to_timestamp(row.finish_time_str) * 1000, "durationInMilliseconds": row.elapsed_ms,
  3167. "playerType": "NORMAL", "activityId": row.activity_id, "numberOfResults": count}}
  3168. results.append(result)
  3169. return jsonify({"query": query, "results": results})
  3170. @app.route('/api/personal-records/results/summary/profiles/me/<sport>/segments/<segment_id>/date/<year>/<quarter>/all', methods=['GET'])
  3171. @jwt_to_session_cookie
  3172. @login_required
  3173. def api_personal_records_results_summary_all(sport, segment_id, year, quarter):
  3174. query = {"name": "AllResultsInQuarterForSegment", "labelsAre": "END_TIME", "sport": sport, "segmentId": segment_id, "year": year, "quarter": quarter}
  3175. from_date, to_date = limits(int(quarter[1]), year)
  3176. where_stmt = "WHERE segment_id = :s AND player_id = :p AND sport = :sp AND strftime('%s', finish_time_str) >= strftime('%s', :f) AND strftime('%s', finish_time_str) <= strftime('%s', :t)"
  3177. args = {"s": int(segment_id), "p": current_user.player_id, "sp": profile_pb2.Sport.Value(sport), "f": from_date, "t": to_date}
  3178. rows = db.session.execute(sqlalchemy.text("SELECT * FROM segment_result %s" % where_stmt), args)
  3179. results = []
  3180. for row in rows:
  3181. end_time = stime_to_timestamp(row.finish_time_str) * 1000
  3182. result = {"label": str(end_time), "result": {"segmentId": int(segment_id), "profileId": row.player_id, "firstInitial": row.first_name[0],
  3183. "lastName": row.last_name, "endTime": end_time, "durationInMilliseconds": row.elapsed_ms, "playerType": "NORMAL", "activityId": row.activity_id, "numberOfResults": 1}}
  3184. results.append(result)
  3185. return jsonify({"query": query, "results": results})
  3186. @app.route('/api/route-results', methods=['POST'])
  3187. @jwt_to_session_cookie
  3188. @login_required
  3189. def route_results():
  3190. rr = route_result_pb2.RouteResultSaveRequest()
  3191. rr.ParseFromString(request.stream.read())
  3192. rr_id = insert_protobuf_into_db(RouteResult, rr, ['f1'])
  3193. row = RouteResult.query.filter_by(id=rr_id).first()
  3194. row.player_id = current_user.player_id
  3195. db.session.commit()
  3196. return '', 202
  3197. def wtime_to_stime(wtime):
  3198. if wtime:
  3199. return datetime.datetime.fromtimestamp(wtime / 1000 + 1414016075, datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + 'Z'
  3200. return ''
  3201. @app.route('/api/route-results/completion-stats/all', methods=['GET'])
  3202. @jwt_to_session_cookie
  3203. @login_required
  3204. def api_route_results_completion_stats_all():
  3205. page = int(request.args.get('page'))
  3206. page_size = int(request.args.get('pageSize'))
  3207. player_id = current_user.player_id
  3208. badges = []
  3209. achievements_file = os.path.join(STORAGE_DIR, str(player_id), 'achievements.bin')
  3210. if os.path.isfile(achievements_file):
  3211. achievements = profile_pb2.Achievements()
  3212. with open(achievements_file, 'rb') as f:
  3213. achievements.ParseFromString(f.read())
  3214. for achievement in achievements.achievements:
  3215. if achievement.id in GD['achievements']:
  3216. badges.append(GD['achievements'][achievement.id])
  3217. results = [r[0] for r in db.session.execute(sqlalchemy.text("SELECT route_hash FROM route_result WHERE player_id = :p"), {"p": player_id})]
  3218. for badge in badges:
  3219. if not badge in results:
  3220. db.session.add(RouteResult(player_id=player_id, route_hash=badge))
  3221. db.session.commit()
  3222. stats = []
  3223. rows = db.session.execute(sqlalchemy.text("SELECT route_hash, min(world_time) AS first, max(world_time) AS last FROM route_result WHERE player_id = :p GROUP BY route_hash"), {"p": player_id})
  3224. for row in rows:
  3225. stats.append({"routeHash": row.route_hash, "firstCompletedAt": wtime_to_stime(row.first), "lastCompletedAt": wtime_to_stime(row.last)})
  3226. current_page = stats[page * page_size:page * page_size + page_size]
  3227. page_count = math.ceil(len(stats) / page_size)
  3228. response = {"response": {"stats": current_page}, "hasPreviousPage": page > 0, "hasNextPage": page < page_count - 1, "pageCount": page_count}
  3229. return jsonify(response)
  3230. @app.route('/api/race-results', methods=['POST'])
  3231. @jwt_to_session_cookie
  3232. @login_required
  3233. def api_race_results():
  3234. result = race_result_pb2.RaceResultEntrySaveRequest()
  3235. result.ParseFromString(request.stream.read())
  3236. if not result.event_subgroup_id in global_race_results:
  3237. global_race_results[result.event_subgroup_id] = RaceResults()
  3238. global_race_results[result.event_subgroup_id].results = {}
  3239. global_race_results[result.event_subgroup_id].results[current_user.player_id] = result
  3240. global_race_results[result.event_subgroup_id].time = time.monotonic()
  3241. return '', 202
  3242. @app.route('/api/race-results/summary', methods=['GET'])
  3243. @jwt_to_session_cookie
  3244. @login_required
  3245. def api_race_results_summary():
  3246. e_id = int(request.args.get('event_subgroup_id'))
  3247. results = race_result_pb2.RaceResultSummary()
  3248. if e_id in global_race_results:
  3249. sorted_results = sorted(global_race_results[e_id].results.items(), key=lambda item: item[1].activity_data.world_time)
  3250. for index, (player_id, result) in enumerate(sorted_results):
  3251. rr = race_result_pb2.RaceResultEntry()
  3252. rr.player_id = player_id
  3253. rr.event_subgroup_id = e_id
  3254. rr.position = index + 1
  3255. rr.event_id = e_id
  3256. rr.activity_data.CopyFrom(result.activity_data)
  3257. rr.activity_data.time = rr.activity_data.world_time + 1414016074397
  3258. ape = ActualPrivateEvents()
  3259. if e_id in ape.keys():
  3260. rr.activity_data.elapsed_ms = rr.activity_data.time - stime_to_timestamp(ape[e_id]['eventStart']) * 1000
  3261. rr.power_data.CopyFrom(result.power_data)
  3262. profile = get_partial_profile(player_id)
  3263. rr.profile_data.weight_in_grams = profile.weight_in_grams
  3264. rr.profile_data.height_in_centimeters = profile.height_in_millimeters // 10
  3265. rr.profile_data.gender = 1 if profile.male else 2
  3266. rr.profile_data.player_type = profile.player_type
  3267. rr.profile_data.first_name = profile.first_name
  3268. rr.profile_data.last_name = profile.last_name
  3269. if profile.imageSrc:
  3270. rr.profile_data.avatar_url = profile.imageSrc
  3271. rr.sensor_data.CopyFrom(result.sensor_data)
  3272. rr.time = rr.activity_data.time
  3273. rr.distance_to_leader = rr.activity_data.world_time - sorted_results[0][1].activity_data.world_time
  3274. results.f1.add().CopyFrom(rr)
  3275. results.f2.add().CopyFrom(rr)
  3276. results.total = len(results.f1)
  3277. return results.SerializeToString(), 200
  3278. def add_segment_results(results, rows):
  3279. for row in rows:
  3280. result = results.segment_results.add()
  3281. row_to_protobuf(row, result, ['f14', 'time', 'player_type', 'f22'])
  3282. if ALL_TIME_LEADERBOARDS and result.world_time <= world_time() - 60 * 60 * 1000:
  3283. result.player_id += 100000 # avoid taking the jersey
  3284. result.world_time = world_time() # otherwise client filters it out
  3285. @app.route('/live-segment-results-service/leaders', methods=['GET'])
  3286. def live_segment_results_service_leaders():
  3287. results = segment_result_pb2.SegmentResults()
  3288. results.server_realm = 0
  3289. results.segment_id = 0
  3290. where_stmt = ""
  3291. args = {}
  3292. if not ALL_TIME_LEADERBOARDS:
  3293. where_stmt = "WHERE world_time > :w"
  3294. args = {"w": world_time() - 60 * 60 * 1000}
  3295. stmt = sqlalchemy.text("""SELECT s1.* FROM segment_result s1
  3296. JOIN (SELECT s.player_id, s.segment_id, MIN(s.elapsed_ms) AS min_time
  3297. FROM segment_result s %s GROUP BY s.player_id, s.segment_id) s2
  3298. ON s2.player_id = s1.player_id AND s2.min_time = s1.elapsed_ms
  3299. GROUP BY s1.player_id, s1.elapsed_ms ORDER BY s1.segment_id, s1.elapsed_ms LIMIT 100""" % where_stmt)
  3300. rows = db.session.execute(stmt, args).mappings()
  3301. add_segment_results(results, rows)
  3302. return results.SerializeToString(), 200
  3303. @app.route('/live-segment-results-service/leaderboard/<segment_id>', methods=['GET'])
  3304. def live_segment_results_service_leaderboard_segment_id(segment_id):
  3305. segment_id = int(segment_id)
  3306. results = segment_result_pb2.SegmentResults()
  3307. results.server_realm = 0
  3308. results.segment_id = segment_id
  3309. where_stmt = "WHERE segment_id = :s"
  3310. args = {"s": segment_id}
  3311. if not ALL_TIME_LEADERBOARDS:
  3312. where_stmt += " AND world_time > :w"
  3313. args.update({"w": world_time() - 60 * 60 * 1000})
  3314. stmt = sqlalchemy.text("""SELECT s1.* FROM segment_result s1
  3315. JOIN (SELECT s.player_id, MIN(s.elapsed_ms) AS min_time
  3316. FROM segment_result s %s GROUP BY s.player_id) s2
  3317. ON s2.player_id = s1.player_id AND s2.min_time = s1.elapsed_ms
  3318. GROUP BY s1.player_id, s1.elapsed_ms ORDER BY s1.elapsed_ms LIMIT 100""" % where_stmt)
  3319. rows = db.session.execute(stmt, args).mappings()
  3320. add_segment_results(results, rows)
  3321. return results.SerializeToString(), 200
  3322. @app.route('/relay/worlds/<int:server_realm>/leave', methods=['POST'])
  3323. def relay_worlds_leave(server_realm):
  3324. return '{"worldtime":%ld}' % world_time()
  3325. def load_variants(file):
  3326. vs = variants_pb2.FeatureResponse()
  3327. try:
  3328. Parse(open(file).read(), vs)
  3329. except Exception as exc:
  3330. logging.warning("load_variants: %s" % repr(exc))
  3331. variants = {}
  3332. for v in vs.variants:
  3333. variants[v.name] = v
  3334. return variants
  3335. def create_variants_response(request, variants):
  3336. req = variants_pb2.FeatureRequest()
  3337. req.ParseFromString(request)
  3338. response = variants_pb2.FeatureResponse()
  3339. for params in req.params:
  3340. for param in params.param:
  3341. if param in variants:
  3342. response.variants.append(variants[param])
  3343. else:
  3344. logger.info("Unknown feature: " + param)
  3345. return response.SerializeToString(), 200
  3346. @app.route('/experimentation/v1/variant', methods=['POST'])
  3347. @jwt_to_session_cookie
  3348. @login_required
  3349. def experimentation_v1_variant():
  3350. variants = load_variants(os.path.join(SCRIPT_DIR, "data", "variants.txt"))
  3351. override = os.path.join(STORAGE_DIR, str(current_user.player_id), "variants.txt")
  3352. if os.path.isfile(override):
  3353. variants.update(load_variants(override))
  3354. return create_variants_response(request.stream.read(), variants)
  3355. @app.route('/experimentation/v1/machine-id-variant', methods=['POST'])
  3356. def experimentation_v1_machine_id_variant():
  3357. variants = load_variants(os.path.join(SCRIPT_DIR, "data", "variants.txt"))
  3358. return create_variants_response(request.stream.read(), variants)
  3359. def get_profile_saved_game_achiev2_40_bytes():
  3360. profile_file = '%s/%s/profile.bin' % (STORAGE_DIR, current_user.player_id)
  3361. if not os.path.isfile(profile_file):
  3362. return b''
  3363. with open(profile_file, 'rb') as fd:
  3364. profile = profile_pb2.PlayerProfile()
  3365. profile.ParseFromString(fd.read())
  3366. if len(profile.saved_game) > 0x150 and profile.saved_game[0x108] == 2: #checking 2 from 0x10000002: achiev_badges2_40
  3367. return profile.saved_game[0x110:0x110+0x40] #0x110 = accessories1_100 + 2x8-byte headers
  3368. else:
  3369. return b''
  3370. @app.route('/api/achievement/loadPlayerAchievements', methods=['GET'])
  3371. @jwt_to_session_cookie
  3372. @login_required
  3373. def achievement_loadPlayerAchievements():
  3374. achievements_file = os.path.join(STORAGE_DIR, str(current_user.player_id), 'achievements.bin')
  3375. if not os.path.isfile(achievements_file):
  3376. converted = profile_pb2.Achievements()
  3377. old_achiev_bits = get_profile_saved_game_achiev2_40_bytes()
  3378. for ach_id in range(8 * len(old_achiev_bits)):
  3379. if (old_achiev_bits[ach_id // 8] >> (ach_id % 8)) & 0x1:
  3380. converted.achievements.add().id = ach_id
  3381. with open(achievements_file, 'wb') as f:
  3382. f.write(converted.SerializeToString())
  3383. achievements = profile_pb2.Achievements()
  3384. with open(achievements_file, 'rb') as f:
  3385. achievements.ParseFromString(f.read())
  3386. climbs = RouteResult.query.filter(RouteResult.player_id == current_user.player_id, RouteResult.route_hash.between(10000, 11000)).count()
  3387. if climbs:
  3388. if not any(a.id == 211 for a in achievements.achievements):
  3389. achievements.achievements.add().id = 211 # Portal Climber
  3390. if climbs >= 10 and not any(a.id == 212 for a in achievements.achievements):
  3391. achievements.achievements.add().id = 212 # Climb Portal Pro
  3392. if climbs >= 25 and not any(a.id == 213 for a in achievements.achievements):
  3393. achievements.achievements.add().id = 213 # Legs of Steel
  3394. with open(achievements_file, 'wb') as f:
  3395. f.write(achievements.SerializeToString())
  3396. return achievements.SerializeToString(), 200
  3397. @app.route('/api/achievement/unlock', methods=['POST'])
  3398. @jwt_to_session_cookie
  3399. @login_required
  3400. def achievement_unlock():
  3401. if not request.stream:
  3402. return '', 400
  3403. new = profile_pb2.Achievements()
  3404. new.ParseFromString(request.stream.read())
  3405. achievements_file = os.path.join(STORAGE_DIR, str(current_user.player_id), 'achievements.bin')
  3406. achievements = profile_pb2.Achievements()
  3407. if os.path.isfile(achievements_file):
  3408. with open(achievements_file, 'rb') as f:
  3409. achievements.ParseFromString(f.read())
  3410. for achievement in new.achievements:
  3411. if not any(a.id == achievement.id for a in achievements.achievements):
  3412. achievements.achievements.add().id = achievement.id
  3413. with open(achievements_file, 'wb') as f:
  3414. f.write(achievements.SerializeToString())
  3415. return '', 202
  3416. # if we respond to this request with an empty json a "tutorial" will be presented in ZCA
  3417. # and for each completed step it will POST /api/achievement/unlock/<id>
  3418. @app.route('/api/achievement/category/<category_id>', methods=['GET'])
  3419. def api_achievement_category(category_id):
  3420. return '', 404 # returning error for now, since some steps can't be completed
  3421. @app.route('/api/power-curve/best/<option>', methods=['GET'])
  3422. @jwt_to_session_cookie
  3423. @login_required
  3424. def api_power_curve_best(option):
  3425. power_curves = profile_pb2.PowerCurveAggregationMsg()
  3426. for t in ['5', '60', '300', '1200']:
  3427. filters = [PowerCurve.player_id == current_user.player_id, PowerCurve.time == t]
  3428. if option == 'last': #default is "all-time"
  3429. filters.append(PowerCurve.timestamp > int(time.time()) - int(request.args.get('days')) * 86400)
  3430. row = PowerCurve.query.filter(*filters).order_by(PowerCurve.power.desc()).first()
  3431. if row:
  3432. power_curves.watts[t].power = row.power
  3433. return power_curves.SerializeToString(), 200
  3434. @app.route('/api/player-profile/user-game-storage/attributes', methods=['GET', 'POST'])
  3435. @jwt_to_session_cookie
  3436. @login_required
  3437. def api_player_profile_user_game_storage_attributes():
  3438. user_storage = user_storage_pb2.UserStorage()
  3439. user_storage_file = os.path.join(STORAGE_DIR, str(current_user.player_id), 'user_storage.bin')
  3440. if os.path.isfile(user_storage_file):
  3441. with open(user_storage_file, 'rb') as f:
  3442. user_storage.ParseFromString(f.read())
  3443. if request.method == 'POST':
  3444. new = user_storage_pb2.UserStorage()
  3445. new.ParseFromString(request.stream.read())
  3446. for n in new.attributes:
  3447. for f in n.DESCRIPTOR.fields_by_name:
  3448. if n.HasField(f):
  3449. for a in list(user_storage.attributes):
  3450. if a.HasField(f) and (not 'signature' in getattr(a, f).DESCRIPTOR.fields_by_name \
  3451. or getattr(a, f).signature == getattr(n, f).signature):
  3452. user_storage.attributes.remove(a)
  3453. user_storage.attributes.add().CopyFrom(n)
  3454. with open(user_storage_file, 'wb') as f:
  3455. f.write(user_storage.SerializeToString())
  3456. return '', 202
  3457. ret = user_storage_pb2.UserStorage()
  3458. for n in request.args.getlist('n'):
  3459. for a in user_storage.attributes:
  3460. if int(n) in a.DESCRIPTOR.fields_by_number and a.HasField(a.DESCRIPTOR.fields_by_number[int(n)].name):
  3461. ret.attributes.add().CopyFrom(a)
  3462. return ret.SerializeToString(), 200
  3463. def get_streaks(player_id):
  3464. streaks = profile_pb2.Streaks()
  3465. streaks_file = '%s/%s/streaks.bin' % (STORAGE_DIR, player_id)
  3466. if os.path.isfile(streaks_file):
  3467. with open(streaks_file, 'rb') as f:
  3468. streaks.ParseFromString(f.read())
  3469. else:
  3470. profile_file = '%s/%s/profile.bin' % (STORAGE_DIR, player_id)
  3471. if os.path.isfile(profile_file):
  3472. profile = profile_pb2.PlayerProfile()
  3473. with open(profile_file, 'rb') as f:
  3474. profile.ParseFromString(f.read())
  3475. for field in ['cur_streak', 'cur_streak_distance', 'cur_streak_elevation', 'cur_streak_calories',
  3476. 'max_streak', 'max_streak_distance', 'max_streak_elevation', 'max_streak_calories']:
  3477. setattr(streaks, field, int(getattr(profile, field)))
  3478. streaks.week_end = int(get_week_range(datetime.datetime.fromtimestamp(profile.last_ride))[1].timestamp() * 1000)
  3479. with open(streaks_file, 'wb') as f:
  3480. f.write(streaks.SerializeToString())
  3481. return streaks
  3482. def update_streaks(player_id, activity):
  3483. streaks = get_streaks(player_id)
  3484. start_date = stime_to_timestamp(activity.start_date) * 1000
  3485. if start_date > streaks.week_end + 604800000:
  3486. streaks.cur_streak = 1
  3487. streaks.cur_streak_distance = 0
  3488. streaks.cur_streak_elevation = 0
  3489. streaks.cur_streak_calories = 0
  3490. elif start_date > streaks.week_end:
  3491. streaks.cur_streak += 1
  3492. streaks.cur_streak_distance += int(activity.distanceInMeters)
  3493. streaks.cur_streak_elevation += int(activity.total_elevation)
  3494. streaks.cur_streak_calories += int(activity.calories)
  3495. streaks.max_streak = max(streaks.cur_streak, streaks.max_streak)
  3496. streaks.max_streak_distance = max(streaks.cur_streak_distance, streaks.max_streak_distance)
  3497. streaks.max_streak_elevation = max(streaks.cur_streak_elevation, streaks.max_streak_elevation)
  3498. streaks.max_streak_calories = max(streaks.cur_streak_calories, streaks.max_streak_calories)
  3499. streaks.week_end = int(get_week_range(datetime.datetime.strptime(activity.start_date, '%Y-%m-%dT%H:%M:%S%z'))[1].timestamp() * 1000)
  3500. with open('%s/%s/streaks.bin' % (STORAGE_DIR, player_id), 'wb') as f:
  3501. f.write(streaks.SerializeToString())
  3502. @app.route('/api/fitness/streaks', methods=['GET'])
  3503. @jwt_to_session_cookie
  3504. @login_required
  3505. def api_fitness_streaks():
  3506. return get_streaks(current_user.player_id).SerializeToString(), 200
  3507. @app.route('/api/fitness/metrics-and-goals', methods=['GET']) # TODO: fitnessScore, trainingStatus, numStreakSavers, givenXp, better default goals
  3508. @jwt_to_session_cookie
  3509. @login_required
  3510. def api_fitness_metrics_and_goals():
  3511. if request.headers['Accept'] == 'application/json':
  3512. try:
  3513. date = datetime.datetime.strptime(request.args.get('month') + request.args.get('weekOf') + request.args.get('year'), "%m%d%Y")
  3514. except:
  3515. return '', 404
  3516. fitness = {"fitnessMetrics": []}
  3517. for i in range(2):
  3518. start, end = get_week_range(date - datetime.timedelta(days=i * 7))
  3519. stmt = sqlalchemy.text("""SELECT SUM(distanceInMeters), SUM(total_elevation), SUM(movingTimeInMs), SUM(work), SUM(calories), SUM(tss)
  3520. FROM activity WHERE player_id = :p AND strftime('%s', start_date) >= strftime('%s', :s) AND strftime('%s', start_date) <= strftime('%s', :e)""")
  3521. row = db.session.execute(stmt, {"p": current_user.player_id, "s": start, "e": end}).first()
  3522. week = {"startOfWeek": start.strftime('%Y-%m-%d'), "fitnessScore": 0, "totalDistanceKilometers": row[0] / 1000 if row[0] else 0,
  3523. "totalElevationMeters": int(row[1]) if row[1] else 0, "totalDurationMinutes": int(row[2] / 60000) if row[2] else 0,
  3524. "totalKilojoules": int(row[3]) if row[3] else 0, "totalCalories": int(row[4]) if row[4] else 0,
  3525. "totalTSS": row[5] if row[5] else 0, "useMetric": get_partial_profile(current_user.player_id).use_metric,
  3526. "weekStreak": get_streaks(current_user.player_id).cur_streak, "numStreakSavers": 0, "days": {}, "trainingStatus": "FRESH"}
  3527. for i in range(0, 7):
  3528. day = start + datetime.timedelta(days=i)
  3529. stmt = sqlalchemy.text("""SELECT SUM(distanceInMeters), SUM(total_elevation), SUM(movingTimeInMs), SUM(work), SUM(calories), SUM(tss)
  3530. FROM activity WHERE player_id = :p AND strftime('%F', start_date) = strftime('%F', :d)""")
  3531. row = db.session.execute(stmt, {"p": current_user.player_id, "d": day}).first()
  3532. if row[0]:
  3533. d = {"day": day.strftime('%a').lower(), "distanceKilometers": row[0] / 1000, "elevationMeters": int(row[1]) if row[1] else 0,
  3534. "durationMinutes": int(row[2] / 60000) if row[2] else 0, "kilojoules": int(row[3]) if row[3] else 0,
  3535. "calories": int(row[4]) if row[4] else 0, "tss": row[5] if row[5] else 0,
  3536. "powerZonePercentages": {"1": 1, "2": 0, "3": 0, "4": 0, "5": 0, "6": 0, "7": 0}, "givenXp": 0}
  3537. zones = [0] * 7
  3538. stmt = sqlalchemy.text("SELECT power_zones FROM activity WHERE player_id = :p AND strftime('%F', start_date) = strftime('%F', :d)")
  3539. for row in db.session.execute(stmt, {"p": current_user.player_id, "d": day}):
  3540. if row.power_zones:
  3541. zones = [a + b for a, b in zip(zones, json.loads(row.power_zones))]
  3542. total = sum(zones)
  3543. if total:
  3544. for i in range(0, 7):
  3545. d["powerZonePercentages"][str(i + 1)] = zones[i] / total
  3546. week["days"][d["day"]] = d
  3547. fitness["fitnessMetrics"].append(week)
  3548. end = get_week_range(date)[1].strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + 'Z'
  3549. row = GoalMetrics.query.filter(GoalMetrics.player_id == current_user.player_id, GoalMetrics.lastUpdated <= end).order_by(GoalMetrics.lastUpdated.desc()).first()
  3550. cycling = {"weekGoalTSS": row.weekGoalTSS if row else 200, "weekGoalCalories": row.weekGoalCalories if row else 2000,
  3551. "weekGoalKjs": row.weekGoalKjs if row else 2000, "weekGoalDistanceKilometers": row.weekGoalDistanceKilometers if row else 100,
  3552. "weekGoalTimeMinutes": row.weekGoalTimeMinutes if row else 180,
  3553. "lastUpdated": row.lastUpdated if row else datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + 'Z'}
  3554. fitness["goalsMetrics"] = {"all": cycling, "cycling": cycling, "running": None, "currentGoalSetting": row.currentGoalSetting if row else "DISTANCE"}
  3555. return jsonify(fitness)
  3556. else:
  3557. fitness = fitness_pb2.Fitness()
  3558. fitness.streak = get_streaks(current_user.player_id).cur_streak
  3559. for i, week in enumerate([fitness.this_week, fitness.last_week]):
  3560. start, end = get_week_range(datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=i * 7))
  3561. week.start = start.strftime('%Y-%m-%d')
  3562. stmt = sqlalchemy.text("""SELECT SUM(distanceInMeters), SUM(total_elevation), SUM(movingTimeInMs), SUM(work), SUM(calories), SUM(tss)
  3563. FROM activity WHERE player_id = :p AND strftime('%s', start_date) >= strftime('%s', :s) AND strftime('%s', start_date) <= strftime('%s', :e)""")
  3564. row = db.session.execute(stmt, {"p": current_user.player_id, "s": start, "e": end}).first()
  3565. week.fitness_score = 0
  3566. week.distance = int(row[0]) if row[0] else 0
  3567. week.elevation = int(row[1]) if row[1] else 0
  3568. week.moving_time = int(round(row[2], -4)) if row[2] else 0
  3569. week.work = int(row[3]) if row[3] else 0
  3570. week.calories = int(row[4]) if row[4] else 0
  3571. week.tss = row[5] if row[5] else 0
  3572. week.status = "FRESH"
  3573. for i in range(0, 7):
  3574. day = start + datetime.timedelta(days=i)
  3575. stmt = sqlalchemy.text("""SELECT SUM(distanceInMeters), SUM(total_elevation), SUM(movingTimeInMs), SUM(work), SUM(calories), SUM(tss)
  3576. FROM activity WHERE player_id = :p AND strftime('%F', start_date) = strftime('%F', :d)""")
  3577. row = db.session.execute(stmt, {"p": current_user.player_id, "d": day}).first()
  3578. if row[0]:
  3579. d = week.days.add()
  3580. d.day = day.strftime('%a').lower()
  3581. d.distance = int(row[0])
  3582. d.elevation = int(row[1]) if row[1] else 0
  3583. d.moving_time = int(round(row[2], -4)) if row[2] else 0
  3584. d.work = int(row[3]) if row[3] else 0
  3585. d.calories = int(row[4]) if row[4] else 0
  3586. d.tss = row[5] if row[5] else 0
  3587. zones = [0] * 7
  3588. stmt = sqlalchemy.text("SELECT power_zones FROM activity WHERE player_id = :p AND strftime('%F', start_date) = strftime('%F', :d)")
  3589. for row in db.session.execute(stmt, {"p": current_user.player_id, "d": day}):
  3590. if row.power_zones:
  3591. zones = [a + b for a, b in zip(zones, json.loads(row.power_zones))]
  3592. total = sum(zones)
  3593. if total:
  3594. for i in range(0, 7):
  3595. pz = d.power_zones.add()
  3596. pz.zone = i + 1
  3597. pz.percentage = zones[i] / total
  3598. row = GoalMetrics.query.filter_by(player_id=current_user.player_id).order_by(GoalMetrics.lastUpdated.desc()).first()
  3599. for sport in [fitness.goals.all, fitness.goals.cycling]:
  3600. sport.tss = row.weekGoalTSS if row else 200
  3601. sport.calories = row.weekGoalCalories if row else 2000
  3602. sport.work = row.weekGoalKjs if row else 2000
  3603. sport.distance = (int(row.weekGoalDistanceKilometers) if row else 100) * 1000
  3604. sport.moving_time = (row.weekGoalTimeMinutes if row else 180) * 60000
  3605. fitness.goals.current_goal = fitness_pb2.GoalSetting.Value(row.currentGoalSetting + "_GOAL" if row else "DISTANCE_GOAL")
  3606. last_updated = datetime.datetime.strptime(row.lastUpdated, "%Y-%m-%dT%H:%M:%S.%f%z") if row else datetime.datetime.now(datetime.timezone.utc)
  3607. fitness.goals.last_updated = int(last_updated.timestamp() * 1000)
  3608. return fitness.SerializeToString(), 200
  3609. @app.route('/api/fitness/fitness-goals/history', methods=['PUT'])
  3610. @jwt_to_session_cookie
  3611. @login_required
  3612. def api_fitness_fitness_goals_history():
  3613. goals = json.loads(request.stream.read())
  3614. goals["player_id"] = current_user.player_id
  3615. goals["lastUpdated"] = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + 'Z'
  3616. db.session.add(GoalMetrics(**goals))
  3617. db.session.commit()
  3618. return '', 204
  3619. @app.teardown_request
  3620. def teardown_request(exception):
  3621. db.session.close()
  3622. if exception != None:
  3623. print('Exception: %s' % exception)
  3624. def save_fit(player_id, name, data):
  3625. fit_dir = os.path.join(STORAGE_DIR, str(player_id), 'fit')
  3626. if not make_dir(fit_dir):
  3627. return
  3628. with open(os.path.join(fit_dir, name), 'wb') as f:
  3629. f.write(data)
  3630. def migrate_database():
  3631. # Migrate database if necessary
  3632. if not os.access(DATABASE_PATH, os.W_OK):
  3633. logging.error("zwift-offline.db is not writable. Unable to upgrade database!")
  3634. return
  3635. row = Version.query.first()
  3636. if not row:
  3637. db.session.add(Version(version=DATABASE_CUR_VER))
  3638. db.session.commit()
  3639. return
  3640. version = row.version
  3641. if version != 2:
  3642. return
  3643. # Database needs to be upgraded, try to back it up first
  3644. try: # Try writing to storage dir
  3645. copyfile(DATABASE_PATH, "%s.v%d.%d.bak" % (DATABASE_PATH, version, int(time.time())))
  3646. except:
  3647. try: # Fall back to a temporary dir
  3648. copyfile(DATABASE_PATH, "%s/zwift-offline.db.v%s.%d.bak" % (tempfile.gettempdir(), version, int(time.time())))
  3649. except Exception as exc:
  3650. logging.warning("Failed to create a zoffline database backup prior to upgrading it. %s" % repr(exc))
  3651. logging.warning("Migrating database, please wait")
  3652. db.session.execute(sqlalchemy.text('ALTER TABLE activity RENAME TO activity_old'))
  3653. db.session.execute(sqlalchemy.text('ALTER TABLE goal RENAME TO goal_old'))
  3654. db.session.execute(sqlalchemy.text('ALTER TABLE segment_result RENAME TO segment_result_old'))
  3655. db.session.execute(sqlalchemy.text('ALTER TABLE playback RENAME TO playback_old'))
  3656. db.create_all()
  3657. import ast
  3658. # Select every column except 'id' and cast 'fit' as hex - after 77ff84e fit data was stored incorrectly
  3659. rows = db.session.execute(sqlalchemy.text('SELECT player_id, f3, name, f5, f6, start_date, end_date, distance, avg_heart_rate, max_heart_rate, avg_watts, max_watts, avg_cadence, max_cadence, avg_speed, max_speed, calories, total_elevation, strava_upload_id, strava_activity_id, f23, hex(fit), fit_filename, f29, date FROM activity_old')).mappings()
  3660. for row in rows:
  3661. d = {k: row[k] for k in row.keys()}
  3662. d['player_id'] = int(d['player_id'])
  3663. d['course_id'] = d.pop('f3')
  3664. d['privateActivity'] = d.pop('f6')
  3665. d['distanceInMeters'] = d.pop('distance')
  3666. d['sport'] = d.pop('f29')
  3667. fit_data = bytes.fromhex(d['hex(fit)'])
  3668. if fit_data[0:2] == b"b'":
  3669. try:
  3670. fit_data = ast.literal_eval(fit_data.decode("ascii"))
  3671. except:
  3672. d['fit_filename'] = 'corrupted'
  3673. del d['hex(fit)']
  3674. orm_act = Activity(**d)
  3675. db.session.add(orm_act)
  3676. db.session.flush()
  3677. fit_filename = '%s - %s' % (orm_act.id, d['fit_filename'])
  3678. save_fit(d['player_id'], fit_filename, fit_data)
  3679. rows = db.session.execute(sqlalchemy.text('SELECT * FROM goal_old')).mappings()
  3680. for row in rows:
  3681. d = {k: row[k] for k in row.keys()}
  3682. del d['id']
  3683. d['player_id'] = int(d['player_id'])
  3684. d['sport'] = d.pop('f3')
  3685. d['created_on'] = int(d['created_on'])
  3686. d['period_end_date'] = int(d['period_end_date'])
  3687. d['status'] = int(d.pop('f13'))
  3688. db.session.add(Goal(**d))
  3689. rows = db.session.execute(sqlalchemy.text('SELECT * FROM segment_result_old')).mappings()
  3690. for row in rows:
  3691. d = {k: row[k] for k in row.keys()}
  3692. del d['id']
  3693. d['player_id'] = int(d['player_id'])
  3694. d['server_realm'] = d.pop('f3')
  3695. d['course_id'] = d.pop('f4')
  3696. d['segment_id'] = toSigned(int(d['segment_id']), 8)
  3697. d['event_subgroup_id'] = int(d['event_subgroup_id'])
  3698. d['world_time'] = int(d['world_time'])
  3699. d['elapsed_ms'] = int(d['elapsed_ms'])
  3700. d['power_source_model'] = d.pop('f12')
  3701. d['weight_in_grams'] = d.pop('f13')
  3702. d['avg_power'] = d.pop('f15')
  3703. d['is_male'] = d.pop('f16')
  3704. d['time'] = d.pop('f17')
  3705. d['player_type'] = d.pop('f18')
  3706. d['avg_hr'] = d.pop('f19')
  3707. d['sport'] = d.pop('f20')
  3708. db.session.add(SegmentResult(**d))
  3709. rows = db.session.execute(sqlalchemy.text('SELECT * FROM playback_old')).mappings()
  3710. for row in rows:
  3711. d = {k: row[k] for k in row.keys()}
  3712. d['segment_id'] = toSigned(int(d['segment_id']), 8)
  3713. db.session.add(Playback(**d))
  3714. db.session.execute(sqlalchemy.text('DROP TABLE activity_old'))
  3715. db.session.execute(sqlalchemy.text('DROP TABLE goal_old'))
  3716. db.session.execute(sqlalchemy.text('DROP TABLE segment_result_old'))
  3717. db.session.execute(sqlalchemy.text('DROP TABLE playback_old'))
  3718. Version.query.filter_by(version=2).update(dict(version=DATABASE_CUR_VER))
  3719. db.session.commit()
  3720. db.session.execute(sqlalchemy.text('vacuum')) #shrink database
  3721. logging.warning("Database migration completed")
  3722. def update_playback():
  3723. for row in Playback.query.all():
  3724. try:
  3725. with open('%s/playbacks/%s.playback' % (STORAGE_DIR, row.uuid), 'rb') as f:
  3726. pb = playback_pb2.PlaybackData()
  3727. pb.ParseFromString(f.read())
  3728. row.type = pb.type
  3729. except Exception as exc:
  3730. logging.warning("update_playback: %s" % repr(exc))
  3731. db.session.commit()
  3732. def check_columns(table_class, table_name):
  3733. rows = db.session.execute(sqlalchemy.text("PRAGMA table_info(%s)" % table_name))
  3734. should_have_columns = table_class.metadata.tables[table_name].columns
  3735. current_columns = list()
  3736. for row in rows:
  3737. current_columns.append(row[1])
  3738. added = False
  3739. for column in should_have_columns:
  3740. if not column.name in current_columns:
  3741. nulltext = None
  3742. if column.nullable:
  3743. nulltext = "NULL"
  3744. else:
  3745. nulltext = "NOT NULL"
  3746. defaulttext = None
  3747. if column.default == None:
  3748. defaulttext = ""
  3749. else:
  3750. defaulttext = " DEFAULT %s" % column.default.arg
  3751. db.session.execute(sqlalchemy.text("ALTER TABLE %s ADD %s %s %s%s" % (table_name, column.name, column.type, nulltext, defaulttext)))
  3752. db.session.commit()
  3753. added = True
  3754. return added
  3755. def send_server_back_online_message():
  3756. time.sleep(30)
  3757. message = "Server version %s is back online. Ride on!" % ZWIFT_VER_CUR
  3758. send_message(message)
  3759. discord.send_message(message)
  3760. def remove_inactive():
  3761. while True:
  3762. for p_id in list(player_partial_profiles.keys()):
  3763. if time.monotonic() > player_partial_profiles[p_id].time + 3600:
  3764. player_partial_profiles.pop(p_id)
  3765. for e_id in list(global_race_results.keys()):
  3766. if time.monotonic() > global_race_results[e_id].time + 3600:
  3767. global_race_results.pop(e_id)
  3768. time.sleep(600)
  3769. with app.app_context():
  3770. db.create_all()
  3771. db.session.commit()
  3772. check_columns(User, 'user')
  3773. if db.session.execute(sqlalchemy.text("SELECT COUNT(*) FROM pragma_table_info('user') WHERE name='new_home'")).scalar():
  3774. db.session.execute(sqlalchemy.text("ALTER TABLE user DROP COLUMN new_home"))
  3775. db.session.commit()
  3776. check_columns(Activity, 'activity')
  3777. if check_columns(Playback, 'playback'):
  3778. update_playback()
  3779. check_columns(RouteResult, 'route_result')
  3780. migrate_database()
  3781. db.session.close()
  3782. ####################
  3783. #
  3784. # Auth server (secure.zwift.com) routes below here
  3785. #
  3786. ####################
  3787. @app.route('/auth/rb_bf03269xbi', methods=['POST'])
  3788. def auth_rb():
  3789. return 'OK(Java)'
  3790. @app.route('/launcher', methods=['GET'])
  3791. @app.route('/launcher/realms/zwift/protocol/openid-connect/auth', methods=['GET'])
  3792. @app.route('/launcher/realms/zwift/protocol/openid-connect/registrations', methods=['GET'])
  3793. @app.route('/auth/realms/zwift/protocol/openid-connect/auth', methods=['GET'])
  3794. @app.route('/auth/realms/zwift/login-actions/request/login', methods=['GET', 'POST'])
  3795. @app.route('/auth/realms/zwift/protocol/openid-connect/registrations', methods=['GET'])
  3796. @app.route('/auth/realms/zwift/login-actions/startriding', methods=['GET']) # Unused as it's a direct redirect now from auth/login
  3797. @app.route('/auth/realms/zwift/tokens/login', methods=['GET']) # Called by Mac, but not Windows
  3798. @app.route('/auth/realms/zwift/tokens/registrations', methods=['GET']) # Called by Mac, but not Windows
  3799. @app.route('/ride', methods=['GET'])
  3800. def launch_zwift():
  3801. # Zwift client has switched to calling https://launcher.zwift.com/launcher/ride
  3802. if request.path != "/ride" and not os.path.exists(AUTOLAUNCH_FILE):
  3803. if MULTIPLAYER:
  3804. return redirect(url_for('login'))
  3805. else:
  3806. return render_template("user_home.html", username=current_user.username, enable_ghosts=os.path.exists(ENABLEGHOSTS_FILE), online=get_online(),
  3807. climbs=CLIMBS, is_admin=False, restarting=restarting, restarting_in_minutes=restarting_in_minutes)
  3808. else:
  3809. if MULTIPLAYER:
  3810. return redirect("http://zwift/?code=zwift_refresh_token%s" % fake_refresh_token_with_session_cookie(request.cookies.get('remember_token')), 302)
  3811. else:
  3812. return redirect("http://zwift/?code=zwift_refresh_token%s" % REFRESH_TOKEN, 302)
  3813. def fake_refresh_token_with_session_cookie(session_cookie):
  3814. refresh_token = jwt.decode(REFRESH_TOKEN, options=({'verify_signature': False, 'verify_aud': False}))
  3815. refresh_token['session_cookie'] = session_cookie
  3816. refresh_token = jwt.encode(refresh_token, 'nosecret')
  3817. return refresh_token
  3818. def fake_jwt_with_session_cookie(session_cookie):
  3819. access_token = jwt.decode(ACCESS_TOKEN, options=({'verify_signature': False, 'verify_aud': False}))
  3820. access_token['session_cookie'] = session_cookie
  3821. access_token = jwt.encode(access_token, 'nosecret')
  3822. refresh_token = fake_refresh_token_with_session_cookie(session_cookie)
  3823. return {"access_token":access_token,"expires_in":1000021600,"refresh_expires_in":611975560,"refresh_token":refresh_token,"token_type":"bearer","id_token":ID_TOKEN,"not-before-policy":1408478984,"session_state":"0846ab9a-765d-4c3f-a20c-6cac9e86e5f3","scope":""}
  3824. @app.route('/auth/realms/zwift/protocol/openid-connect/token', methods=['POST'])
  3825. def auth_realms_zwift_protocol_openid_connect_token():
  3826. # Android client login
  3827. username = request.form.get('username')
  3828. password = request.form.get('password')
  3829. if username and MULTIPLAYER:
  3830. user = User.query.filter_by(username=username).first()
  3831. if user and user.pass_hash.startswith('sha256'): # sha256 is deprecated in werkzeug 3
  3832. if check_sha256_hash(user.pass_hash, password):
  3833. user.pass_hash = generate_password_hash(password, 'scrypt')
  3834. db.session.commit()
  3835. else:
  3836. return '', 401
  3837. if user and check_password_hash(user.pass_hash, password):
  3838. login_user(user, remember=True)
  3839. if not make_profile_dir(user.player_id):
  3840. return '', 500
  3841. else:
  3842. return '', 401
  3843. if MULTIPLAYER:
  3844. # This is called once with ?code= in URL and once again with the refresh token
  3845. if "code" in request.form:
  3846. # Original code argument is replaced with session cookie from launcher
  3847. refresh_token = jwt.decode(request.form['code'][19:], options=({'verify_signature': False, 'verify_aud': False}))
  3848. session_cookie = refresh_token['session_cookie']
  3849. return jsonify(fake_jwt_with_session_cookie(session_cookie)), 200
  3850. elif "refresh_token" in request.form:
  3851. token = jwt.decode(request.form['refresh_token'], options=({'verify_signature': False, 'verify_aud': False}))
  3852. if 'session_cookie' in token:
  3853. return jsonify(fake_jwt_with_session_cookie(token['session_cookie']))
  3854. else:
  3855. return '', 401
  3856. else: # android login
  3857. from flask_login import encode_cookie
  3858. # cookie is not set in request since we just logged in so create it.
  3859. return jsonify(fake_jwt_with_session_cookie(encode_cookie(str(session['_user_id'])))), 200
  3860. else:
  3861. r = make_response(FAKE_JWT)
  3862. r.mimetype = 'application/json'
  3863. return r
  3864. @app.route('/auth/realms/zwift/protocol/openid-connect/logout', methods=['POST'])
  3865. def auth_realms_zwift_protocol_openid_connect_logout():
  3866. # This is called on ZCA logout, we don't want ZA to logout
  3867. session.clear()
  3868. return '', 204
  3869. def save_option(option, file):
  3870. if option:
  3871. if not os.path.exists(file):
  3872. f = open(file, 'w')
  3873. f.close()
  3874. elif os.path.exists(file):
  3875. os.remove(file)
  3876. @app.route("/start-zwift" , methods=['POST'])
  3877. @login_required
  3878. def start_zwift():
  3879. if MULTIPLAYER:
  3880. current_user.enable_ghosts = 'enableghosts' in request.form.keys()
  3881. db.session.commit()
  3882. else:
  3883. AnonUser.enable_ghosts = 'enableghosts' in request.form.keys()
  3884. save_option(AnonUser.enable_ghosts, ENABLEGHOSTS_FILE)
  3885. selected_map = request.form['map']
  3886. if selected_map != 'CALENDAR':
  3887. # We have no identifying information when Zwift makes MapSchedule request except for the client's IP.
  3888. map_override[request.remote_addr] = selected_map
  3889. selected_climb = request.form['climb']
  3890. if selected_climb != 'CALENDAR':
  3891. climb_override[request.remote_addr] = selected_climb
  3892. return redirect("/ride", 302)
  3893. def run_standalone(passed_online, passed_global_relay, passed_global_pace_partners, passed_global_bots, passed_global_ghosts, passed_regroup_ghosts, passed_discord):
  3894. global online
  3895. global global_relay
  3896. global global_pace_partners
  3897. global global_bots
  3898. global global_ghosts
  3899. global regroup_ghosts
  3900. global discord
  3901. global login_manager
  3902. online = passed_online
  3903. global_relay = passed_global_relay
  3904. global_pace_partners = passed_global_pace_partners
  3905. global_bots = passed_global_bots
  3906. global_ghosts = passed_global_ghosts
  3907. regroup_ghosts = passed_regroup_ghosts
  3908. discord = passed_discord
  3909. login_manager = LoginManager()
  3910. login_manager.login_view = 'login'
  3911. login_manager.session_protection = None
  3912. if not MULTIPLAYER:
  3913. # Find first profile.bin if one exists and use it. Multi-profile
  3914. # support is deprecated and now unsupported for non-multiplayer mode.
  3915. player_id = None
  3916. for name in os.listdir(STORAGE_DIR):
  3917. path = "%s/%s" % (STORAGE_DIR, name)
  3918. if os.path.isdir(path) and os.path.exists("%s/profile.bin" % path):
  3919. try:
  3920. player_id = int(name)
  3921. except ValueError:
  3922. continue
  3923. break
  3924. if not player_id:
  3925. player_id = 1
  3926. if not make_profile_dir(player_id):
  3927. sys.exit(1)
  3928. AnonUser.player_id = player_id
  3929. login_manager.anonymous_user = AnonUser
  3930. login_manager.init_app(app)
  3931. @login_manager.user_loader
  3932. def load_user(uid):
  3933. return db.session.get(User, int(uid))
  3934. send_message_thread = threading.Thread(target=send_server_back_online_message)
  3935. send_message_thread.start()
  3936. remove_inactive_thread = threading.Thread(target=remove_inactive)
  3937. remove_inactive_thread.start()
  3938. logger.info("Server version %s is running." % ZWIFT_VER_CUR)
  3939. server = WSGIServer(('0.0.0.0', 443), app, certfile='%s/cert-zwift-com.pem' % SSL_DIR, keyfile='%s/key-zwift-com.pem' % SSL_DIR, log=logger)
  3940. server.serve_forever()
  3941. # app.run(ssl_context=('%s/cert-zwift-com.pem' % SSL_DIR, '%s/key-zwift-com.pem' % SSL_DIR), port=443, threaded=True, host='0.0.0.0') # debug=True, use_reload=False)
  3942. if __name__ == "__main__":
  3943. run_standalone({}, {}, None)