zwift_offline.py 177 KB

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