zwift_offline.py 195 KB

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