zwift_offline.py 179 KB

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