zwift_offline.py 178 KB

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