Files
zwift-offline/zwift_offline.py
2025-10-23 07:53:41 -03:00

4457 lines
199 KiB
Python

#!/usr/bin/env python
import calendar
import datetime
import logging
import os
import signal
import random
import sys
import tempfile
import time
import math
import threading
import re
import smtplib
import ssl
import requests
import json
import base64
import uuid
import jwt
import sqlalchemy
import fitdecode
import xml.etree.ElementTree as ET
from copy import deepcopy
from functools import wraps
from io import BytesIO
from shutil import copyfile
from urllib.parse import quote
from flask import Flask, request, jsonify, redirect, render_template, url_for, flash, session, make_response, send_file, send_from_directory
from flask_login import UserMixin, AnonymousUserMixin, LoginManager, login_user, current_user, login_required, logout_user
from gevent.pywsgi import WSGIServer
from google.protobuf.json_format import MessageToDict, Parse
from flask_sqlalchemy import SQLAlchemy
from werkzeug.security import generate_password_hash, check_password_hash
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from Crypto.Cipher import AES
from Crypto.Random import get_random_bytes
from collections import deque
from itertools import islice
sys.path.append(os.path.join(sys.path[0], 'protobuf')) # otherwise import in .proto does not work
import udp_node_msgs_pb2
import tcp_node_msgs_pb2
import activity_pb2
import goal_pb2
import login_pb2
import per_session_info_pb2
import profile_pb2
import segment_result_pb2
import route_result_pb2
import race_result_pb2
import world_pb2
import zfiles_pb2
import hash_seeds_pb2
import events_pb2
import variants_pb2
import playback_pb2
import user_storage_pb2
import fitness_pb2
import structured_events_pb2
import online_sync
logging.basicConfig(level=os.environ.get("LOGLEVEL", "INFO"))
logger = logging.getLogger('zoffline')
logger.setLevel(logging.DEBUG)
logging.getLogger('sqlalchemy.engine').setLevel(logging.WARN)
if getattr(sys, 'frozen', False):
# If we're running as a pyinstaller bundle
SCRIPT_DIR = sys._MEIPASS
STORAGE_DIR = "%s/storage" % os.path.dirname(sys.executable)
LOGS_DIR = "%s/logs" % os.path.dirname(sys.executable)
else:
SCRIPT_DIR = os.path.dirname(os.path.realpath(__file__))
STORAGE_DIR = "%s/storage" % SCRIPT_DIR
LOGS_DIR = "%s/logs" % SCRIPT_DIR
def make_dir(name):
try:
if not os.path.isdir(name):
os.makedirs(name)
except IOError as e:
logger.error("failed to create dir (%s): %s", name, str(e))
return False
return True
# Ensure storage dir exists
if not make_dir(STORAGE_DIR):
sys.exit(1)
SSL_DIR = "%s/ssl" % SCRIPT_DIR
DATABASE_PATH = "%s/zwift-offline.db" % STORAGE_DIR
DATABASE_CUR_VER = 3
ZWIFT_VER_CUR = ET.parse('%s/cdn/gameassets/Zwift_Updates_Root/Zwift_ver_cur.xml' % SCRIPT_DIR).getroot().get('sversion')
# For auth server
AUTOLAUNCH_FILE = "%s/auto_launch.txt" % STORAGE_DIR
SERVER_IP_FILE = "%s/server-ip.txt" % STORAGE_DIR
if os.path.exists(SERVER_IP_FILE):
with open(SERVER_IP_FILE, 'r') as f:
server_ip = f.read().rstrip('\r\n')
else:
server_ip = '127.0.0.1'
SECRET_KEY_FILE = "%s/secret-key.txt" % STORAGE_DIR
ENABLEGHOSTS_FILE = "%s/enable_ghosts.txt" % STORAGE_DIR
GHOST_PROFILE = None
GHOST_PROFILE_FILE = "%s/ghost_profile.txt" % STORAGE_DIR
if os.path.exists(GHOST_PROFILE_FILE):
with open(GHOST_PROFILE_FILE) as f:
GHOST_PROFILE = json.load(f)
ALL_TIME_LEADERBOARDS = os.path.exists("%s/all_time_leaderboards.txt" % STORAGE_DIR)
MULTIPLAYER = os.path.exists("%s/multiplayer.txt" % STORAGE_DIR)
if MULTIPLAYER:
if not make_dir(LOGS_DIR):
sys.exit(1)
from logging.handlers import RotatingFileHandler
logHandler = RotatingFileHandler('%s/zoffline.log' % LOGS_DIR, maxBytes=1000000, backupCount=10)
logger.addHandler(logHandler)
CREDENTIALS_KEY_FILE = "%s/credentials-key.bin" % STORAGE_DIR
if not os.path.exists(CREDENTIALS_KEY_FILE):
with open(CREDENTIALS_KEY_FILE, 'wb') as f:
f.write(get_random_bytes(32))
with open(CREDENTIALS_KEY_FILE, 'rb') as f:
credentials_key = f.read()
GARMIN_DOMAIN = 'garmin.com'
GARMIN_DOMAIN_FILE = '%s/garmin_domain.txt' % STORAGE_DIR
if os.path.exists(GARMIN_DOMAIN_FILE):
with open(GARMIN_DOMAIN_FILE) as f:
GARMIN_DOMAIN = f.readline().rstrip('\r\n')
import warnings
with warnings.catch_warnings():
from stravalib.client import Client
from tokens import *
# Android uses https for cdn
app = Flask(__name__, static_folder='%s/cdn/gameassets' % SCRIPT_DIR, static_url_path='/gameassets', template_folder='%s/cdn/static/web/launcher' % SCRIPT_DIR)
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///{db}'.format(db=DATABASE_PATH)
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
if not os.path.exists(SECRET_KEY_FILE):
with open(SECRET_KEY_FILE, 'wb') as f:
f.write(os.urandom(16))
with open(SECRET_KEY_FILE, 'rb') as f:
app.config['SECRET_KEY'] = f.read()
app.config['MAX_CONTENT_LENGTH'] = 4 * 1024 * 1024 # A typical .fit file with power, cadence, and heartrate data recorded in December 2024 is approximately 1.3 MB / 4 hours.
db = SQLAlchemy()
db.init_app(app)
online = {}
ghosts_enabled = {}
player_update_queue = {}
zc_connect_queue = {}
player_partial_profiles = {}
map_override = {}
climb_override = {}
global_bookmarks = {}
global_race_results = {}
restarting = False
restarting_in_minutes = 0
reload_pacer_bots = False
auto_group = {'id': None, 'road_id': None, 'is_forward': None, 'all': False, 'thread': None}
with open(os.path.join(SCRIPT_DIR, "data", "climbs.txt")) as f:
CLIMBS = json.load(f)
with open(os.path.join(SCRIPT_DIR, "data", "game_dictionary.txt")) as f:
GD = json.load(f, object_hook=lambda d: {int(k) if k.lstrip('-').isdigit() else k: v for k, v in d.items()})
class User(UserMixin, db.Model):
player_id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.Text, unique=True, nullable=False)
first_name = db.Column(db.Text, nullable=False)
last_name = db.Column(db.Text, nullable=False)
pass_hash = db.Column(db.Text, nullable=False)
enable_ghosts = db.Column(db.Integer, nullable=False, default=1)
is_admin = db.Column(db.Integer, nullable=False, default=0)
remember = db.Column(db.Integer, nullable=False, default=0)
def __repr__(self):
return self.username
def get_id(self):
return self.player_id
def get_token(self):
dt = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(minutes=30)
return jwt.encode({'user': self.player_id, 'exp': dt}, app.config['SECRET_KEY'], algorithm='HS256')
@staticmethod
def verify_token(token):
try:
data = jwt.decode(token, app.config['SECRET_KEY'], algorithms='HS256')
except:
return None
id = data.get('user')
if id:
return db.session.get(User, id)
return None
class AnonUser(User, AnonymousUserMixin, db.Model):
username = "zoffline"
first_name = "z"
last_name = "offline"
enable_ghosts = os.path.isfile(ENABLEGHOSTS_FILE)
def is_authenticated(self):
return True
class Activity(db.Model):
id = db.Column(db.Integer, primary_key=True)
player_id = db.Column(db.Integer)
course_id = db.Column(db.Integer)
name = db.Column(db.Text)
f5 = db.Column(db.Integer)
privateActivity = db.Column(db.Integer)
start_date = db.Column(db.Text)
end_date = db.Column(db.Text)
distanceInMeters = db.Column(db.Float)
avg_heart_rate = db.Column(db.Float)
max_heart_rate = db.Column(db.Float)
avg_watts = db.Column(db.Float)
max_watts = db.Column(db.Float)
avg_cadence = db.Column(db.Float)
max_cadence = db.Column(db.Float)
avg_speed = db.Column(db.Float)
max_speed = db.Column(db.Float)
calories = db.Column(db.Float)
total_elevation = db.Column(db.Float)
strava_upload_id = db.Column(db.Integer)
strava_activity_id = db.Column(db.Integer)
f22 = db.Column(db.Text)
f23 = db.Column(db.Integer)
fit = db.Column(db.LargeBinary)
fit_filename = db.Column(db.Text)
subgroupId = db.Column(db.Integer)
workoutHash = db.Column(db.Integer)
progressPercentage = db.Column(db.Float)
sport = db.Column(db.Integer)
date = db.Column(db.Text)
act_f32 = db.Column(db.Float)
act_f33 = db.Column(db.Text)
act_f34 = db.Column(db.Text)
privacy = db.Column(db.Integer)
fitness_privacy = db.Column(db.Integer)
club_name = db.Column(db.Text)
movingTimeInMs = db.Column(db.Integer)
work = db.Column(db.Float)
tss = db.Column(db.Float)
normalized_power = db.Column(db.Float)
power_zones = db.Column(db.Text)
power_units = db.Column(db.Float)
class SegmentResult(db.Model):
id = db.Column(db.Integer, primary_key=True)
player_id = db.Column(db.Integer)
server_realm = db.Column(db.Integer)
course_id = db.Column(db.Integer)
segment_id = db.Column(db.Integer)
event_subgroup_id = db.Column(db.Integer)
first_name = db.Column(db.Text)
last_name = db.Column(db.Text)
world_time = db.Column(db.Integer)
finish_time_str = db.Column(db.Text)
elapsed_ms = db.Column(db.Integer)
power_source_model = db.Column(db.Integer)
weight_in_grams = db.Column(db.Integer)
f14 = db.Column(db.Integer)
avg_power = db.Column(db.Integer)
is_male = db.Column(db.Integer)
time = db.Column(db.Text)
player_type = db.Column(db.Integer)
avg_hr = db.Column(db.Integer)
sport = db.Column(db.Integer)
activity_id = db.Column(db.Integer)
f22 = db.Column(db.Integer)
f23 = db.Column(db.Text)
class RouteResult(db.Model):
id = db.Column(db.Integer, primary_key=True)
player_id = db.Column(db.Integer)
server_realm = db.Column(db.Integer)
map_id = db.Column(db.Integer)
route_hash = db.Column(db.Integer)
event_id = db.Column(db.Integer)
world_time = db.Column(db.Integer)
elapsed_ms = db.Column(db.Integer)
power_type = db.Column(db.Integer)
weight_in_grams = db.Column(db.Integer)
height_in_centimeters = db.Column(db.Integer)
ftp = db.Column(db.Integer)
avg_power = db.Column(db.Integer)
max_power = db.Column(db.Integer)
avg_hr = db.Column(db.Integer)
max_hr = db.Column(db.Integer)
calories = db.Column(db.Integer)
gender = db.Column(db.Integer)
player_type = db.Column(db.Integer)
sport = db.Column(db.Integer)
activity_id = db.Column(db.Integer)
steering = db.Column(db.Integer)
hr_monitor = db.Column(db.Text)
power_meter = db.Column(db.Text)
controllable = db.Column(db.Text)
cadence_sensor = db.Column(db.Text)
class Goal(db.Model):
id = db.Column(db.Integer, primary_key=True)
player_id = db.Column(db.Integer)
sport = db.Column(db.Integer)
name = db.Column(db.Text)
type = db.Column(db.Integer)
periodicity = db.Column(db.Integer)
target_distance = db.Column(db.Float)
target_duration = db.Column(db.Float)
actual_distance = db.Column(db.Float)
actual_duration = db.Column(db.Float)
created_on = db.Column(db.Integer)
period_end_date = db.Column(db.Integer)
status = db.Column(db.Integer)
timezone = db.Column(db.Text)
class GoalMetrics(db.Model):
id = db.Column(db.Integer, primary_key=True)
player_id = db.Column(db.Integer)
weekGoalTSS = db.Column(db.Integer)
weekGoalCalories = db.Column(db.Integer)
weekGoalKjs = db.Column(db.Integer)
weekGoalDistanceKilometers = db.Column(db.Float)
weekGoalDistanceMiles = db.Column(db.Float)
weekGoalTimeMinutes = db.Column(db.Integer)
lastUpdated = db.Column(db.Text)
currentGoalSetting = db.Column(db.Text)
class Playback(db.Model):
id = db.Column(db.Integer, primary_key=True)
player_id = db.Column(db.Integer, nullable=False)
uuid = db.Column(db.Text, nullable=False)
segment_id = db.Column(db.Integer, nullable=False)
time = db.Column(db.Float, nullable=False)
world_time = db.Column(db.Integer, nullable=False)
type = db.Column(db.Integer)
class Zfile(db.Model):
id = db.Column(db.Integer, primary_key=True)
folder = db.Column(db.Text, nullable=False)
filename = db.Column(db.Text, nullable=False)
timestamp = db.Column(db.Integer, nullable=False)
player_id = db.Column(db.Integer, nullable=False)
class PrivateEvent(db.Model): # cached in glb_private_events
id = db.Column(db.Integer, primary_key=True)
json = db.Column(db.Text, nullable=False)
class Notification(db.Model):
id = db.Column(db.Integer, primary_key=True)
event_id = db.Column(db.Integer, nullable=False)
player_id = db.Column(db.Integer, nullable=False)
json = db.Column(db.Text, nullable=False)
class ActivityFile(db.Model):
id = db.Column(db.Integer, primary_key=True)
activity_id = db.Column(db.Integer, nullable=False)
full = db.Column(db.Integer, nullable=False)
class ActivityImage(db.Model):
id = db.Column(db.Integer, primary_key=True)
player_id = db.Column(db.Integer, nullable=False)
activity_id = db.Column(db.Integer, nullable=False)
class PowerCurve(db.Model):
id = db.Column(db.Integer, primary_key=True)
player_id = db.Column(db.Integer, nullable=False)
time = db.Column(db.Text, nullable=False)
power = db.Column(db.Integer, nullable=False)
power_wkg = db.Column(db.Float, nullable=False)
timestamp = db.Column(db.Integer, nullable=False)
class Version(db.Model):
version = db.Column(db.Integer, primary_key=True)
class Relay:
def __init__(self, key = b''):
self.ri = 0
self.tcp_ci = 0
self.udp_ci = 0
self.tcp_r_sn = 0
self.tcp_t_sn = 0
self.udp_r_sn = 0
self.udp_t_sn = 0
self.key = key
class PartialProfile:
player_id = 0
first_name = ''
last_name = ''
country_code = 0
route = 0
player_type = 'NORMAL'
male = True
weight_in_grams = 0
height_in_millimeters = 0
imageSrc = ''
use_metric = True
time = 0
def to_json(self):
return {"countryCode": self.country_code,
"enrolledZwiftAcademy": False, #don't need
"firstName": self.first_name,
"id": self.player_id,
"imageSrc": self.imageSrc,
"lastName": self.last_name,
"male": self.male,
"playerType": self.player_type }
class Bookmark:
name = ''
state = None
class RaceResults:
results = None
time = 0
class Online:
total = 0
richmond = 0
watopia = 0
london = 0
makuriislands = 0
newyork = 0
innsbruck = 0
yorkshire = 0
france = 0
paris = 0
scotland = 0
courses_lookup = {
2: 'Richmond',
4: 'Unknown', # event specific?
6: 'Watopia',
7: 'London',
8: 'New York',
9: 'Innsbruck',
10: 'Bologna', # event specific
11: 'Yorkshire',
12: 'Crit City', # event specific
13: 'Makuri Islands',
14: 'France',
15: 'Paris',
16: 'Gravel Mountain', # event specific
17: 'Scotland'
}
def get_online():
online_in_region = Online()
for p_id in online:
player_state = online[p_id]
course = get_course(player_state)
course_name = courses_lookup[course]
if course_name == 'Richmond':
online_in_region.richmond += 1
elif course_name == 'Watopia':
online_in_region.watopia += 1
elif course_name == 'London':
online_in_region.london += 1
elif course_name == 'Makuri Islands':
online_in_region.makuriislands += 1
elif course_name == 'New York':
online_in_region.newyork += 1
elif course_name == 'Innsbruck':
online_in_region.innsbruck += 1
elif course_name == 'Yorkshire':
online_in_region.yorkshire += 1
elif course_name == 'France':
online_in_region.france += 1
elif course_name == 'Paris':
online_in_region.paris += 1
elif course_name == 'Scotland':
online_in_region.scotland += 1
online_in_region.total += 1
return online_in_region
def toSigned(n, byte_count):
return int.from_bytes(n.to_bytes(byte_count, 'little'), 'little', signed=True)
def imageSrc(player_id):
if os.path.isfile(os.path.join(STORAGE_DIR, str(player_id), 'avatarLarge.jpg')):
return "https://us-or-rly101.zwift.com/download/%s/avatarLarge.jpg" % player_id
else:
return None
def get_partial_profile(player_id):
if not player_id in player_partial_profiles:
partial_profile = PartialProfile()
partial_profile.player_id = player_id
if player_id in global_pace_partners.keys():
profile = global_pace_partners[player_id].profile
for f in profile.public_attributes:
if f.id == 1766985504: #crc32 of "PACE PARTNER - ROUTE"
partial_profile.route = toSigned(f.number_value, 4) if f.number_value >= 0 else -toSigned(-f.number_value, 4)
break
elif player_id in global_bots.keys():
profile = global_bots[player_id].profile
elif player_id > 10000000:
g_id = math.floor(player_id / 10000000)
p_id = player_id - g_id * 10000000
partial_profile.first_name = ''
partial_profile.last_name = time_since(global_ghosts[p_id].play[g_id-1].date)
return partial_profile
else:
profile = profile_pb2.PlayerProfile()
#Read from disk
profile_file = '%s/%s/profile.bin' % (STORAGE_DIR, player_id)
if os.path.isfile(profile_file):
with open(profile_file, 'rb') as fd:
profile.ParseFromString(fd.read())
else:
user = User.query.filter_by(player_id=player_id).first()
if user:
partial_profile.first_name = user.first_name
partial_profile.last_name = user.last_name
return partial_profile
partial_profile.imageSrc = imageSrc(player_id)
partial_profile.first_name = profile.first_name
partial_profile.last_name = profile.last_name
partial_profile.country_code = profile.country_code
partial_profile.player_type = profile_pb2.PlayerType.Name(jsf(profile, 'player_type', 1))
partial_profile.male = profile.is_male
partial_profile.weight_in_grams = profile.weight_in_grams
partial_profile.height_in_millimeters = profile.height_in_millimeters
partial_profile.use_metric = profile.use_metric
player_partial_profiles[player_id] = partial_profile
player_partial_profiles[player_id].time = time.monotonic()
return player_partial_profiles[player_id]
def get_course(state):
return (state.f19 & 0xff0000) >> 16
def road_id(state):
return (state.aux3 & 0xff00) >> 8
def is_forward(state):
return (state.f19 & 4) != 0
def is_nearby(s1, s2):
if s1 is None or s2 is None:
return False
if s1.watchingRiderId == s2.id or s2.watchingRiderId == s1.id:
return True
if get_course(s1) == get_course(s2):
dist = math.sqrt((s2.x - s1.x)**2 + (s2.z - s1.z)**2 + (s2.y_altitude - s1.y_altitude)**2)
if dist <= 100000 or road_id(s1) == road_id(s2):
return True
return False
# We store flask-login's cookie in the "fake" JWT that we give Zwift.
# Make it a cookie again to reuse flask-login on API calls.
def jwt_to_session_cookie(f):
@wraps(f)
def wrapper(*args, **kwargs):
if not MULTIPLAYER:
return f(*args, **kwargs)
token = request.headers.get('Authorization')
if token and not session.get('_user_id'):
token = jwt.decode(token.split()[1], options=({'verify_signature': False, 'verify_aud': False}))
request.cookies = request.cookies.copy() # request.cookies is an immutable dict
request.cookies['remember_token'] = token['session_cookie']
login_manager._load_user()
return f(*args, **kwargs)
return wrapper
@app.route("/signup/", methods=["GET", "POST"])
def signup():
if request.method == "POST":
username = request.form['username']
password = request.form['password']
confirm_password = request.form['confirm_password']
first_name = request.form['first_name']
last_name = request.form['last_name']
if not (username and password and confirm_password and first_name and last_name):
flash("All fields are required.")
return redirect(url_for('signup'))
if not re.match(r"[^@]+@[^@]+\.[^@]+", username):
flash("Username is not a valid e-mail address.")
return redirect(url_for('signup'))
if password != confirm_password:
flash("Passwords did not match.")
return redirect(url_for('signup'))
hashed_pwd = generate_password_hash(password, 'scrypt')
new_user = User(username=username, pass_hash=hashed_pwd, first_name=first_name, last_name=last_name)
db.session.add(new_user)
try:
db.session.commit()
except sqlalchemy.exc.IntegrityError:
flash("Username {u} is not available.".format(u=username))
return redirect(url_for('signup'))
flash("User account has been created.")
return redirect(url_for("login"))
return render_template("signup.html")
def check_sha256_hash(pwhash, password):
import hmac
try:
method, salt, hashval = pwhash.split("$", 2)
except ValueError:
return False
return hmac.compare_digest(hmac.new(salt.encode("utf-8"), password.encode("utf-8"), method).hexdigest(), hashval)
def make_profile_dir(player_id):
return make_dir(os.path.join(STORAGE_DIR, str(player_id)))
@app.route("/login/", methods=["GET", "POST"])
def login():
if request.method == "POST":
username = request.form['username']
password = request.form['password']
remember = bool(request.form.get('remember'))
if not (username and password):
flash("Username and password cannot be empty.")
return redirect(url_for('login'))
user = User.query.filter_by(username=username).first()
if user and user.pass_hash.startswith('sha256'): # sha256 is deprecated in werkzeug 3
if check_sha256_hash(user.pass_hash, password):
user.pass_hash = generate_password_hash(password, 'scrypt')
db.session.commit()
else:
flash("Invalid username or password.")
return redirect(url_for('login'))
if user and check_password_hash(user.pass_hash, password):
login_user(user, remember=True)
user.remember = remember
db.session.commit()
if not make_profile_dir(user.player_id):
return '', 500
return redirect(url_for("user_home", username=username, enable_ghosts=bool(user.enable_ghosts), online=get_online()))
else:
flash("Invalid username or password.")
if current_user.is_authenticated and current_user.remember:
return redirect(url_for("user_home", username=current_user.username, enable_ghosts=bool(current_user.enable_ghosts), online=get_online()))
user = User.verify_token(request.args.get('token'))
if user:
login_user(user, remember=False)
return redirect(url_for("reset", username=user.username))
return render_template("login_form.html")
def send_mail(username, token):
try:
with open('%s/gmail_credentials.txt' % STORAGE_DIR) as f:
sender_email = f.readline().rstrip('\r\n')
password = f.readline().rstrip('\r\n')
host = f.readline().rstrip('\r\n')
with smtplib.SMTP_SSL("smtp.gmail.com", 465, context=ssl.create_default_context()) as server:
server.login(sender_email, password)
message = MIMEMultipart()
message['From'] = sender_email
message['To'] = username
message['Subject'] = "Password reset"
content = "https://%s/login/?token=%s" % (host if host else server_ip, token)
message.attach(MIMEText(content, 'plain'))
server.sendmail(sender_email, username, message.as_string())
server.close()
except Exception as exc:
logger.warning('send e-mail: %s' % repr(exc))
return False
return True
@app.route("/forgot/", methods=["GET", "POST"])
def forgot():
if request.method == "POST":
username = request.form['username']
if not username:
flash("Username cannot be empty.")
return redirect(url_for('forgot'))
if not re.match(r"[^@]+@[^@]+\.[^@]+", username):
flash("Username is not a valid e-mail address.")
return redirect(url_for('forgot'))
user = User.query.filter_by(username=username).first()
if user:
if send_mail(username, user.get_token()):
flash("E-mail sent.")
else:
flash("Could not send e-mail.")
else:
flash("Invalid username.")
return render_template("forgot.html")
@app.route("/api/push/fcm/<type>/<token>", methods=["POST", "DELETE"])
@app.route("/api/push/fcm/<type>/<token>/enables", methods=["PUT"])
def api_push_fcm_production(type, token):
return '', 500
@app.route("/api/users", methods=["POST"]) # Android user registration
def api_users():
first_name = request.json['profile']['firstName']
last_name = request.json['profile']['lastName']
if MULTIPLAYER:
username = request.json['email']
if not re.match(r"[^@]+@[^@]+\.[^@]+", username):
return '', 400
pass_hash = generate_password_hash(request.json['password'], 'scrypt')
user = User(username=username, pass_hash=pass_hash, first_name=first_name, last_name=last_name)
db.session.add(user)
try:
db.session.commit()
except sqlalchemy.exc.IntegrityError:
return '', 400
login_user(user, remember=True)
if not make_profile_dir(user.player_id):
return '', 500
else:
AnonUser.first_name = first_name
AnonUser.last_name = last_name
return '', 200
@app.route("/api/users/reset-password-email", methods=["PUT"]) # Android password reset
def api_users_reset_password_email():
username = request.form['username']
if re.match(r"[^@]+@[^@]+\.[^@]+", username):
user = User.query.filter_by(username=username).first()
if user:
send_mail(username, user.get_token())
return '', 200
@app.route("/api/users/password-reset/", methods=["POST"])
@jwt_to_session_cookie
@login_required
def api_users_password_reset():
password = request.form.get("password-new")
confirm_password = request.form.get("password-confirm")
if password != confirm_password:
return 'passwords not match', 500
hashed_pwd = generate_password_hash(password, 'scrypt')
current_user.pass_hash = hashed_pwd
db.session.commit()
return '', 200
@app.route("/reset/<username>/", methods=["GET", "POST"])
@login_required
def reset(username):
if request.method == "POST":
password = request.form['password']
confirm_password = request.form['confirm_password']
if not (password and confirm_password):
flash("All fields are required.")
return redirect(url_for('reset', username=current_user.username))
if password != confirm_password:
flash("Passwords did not match.")
return redirect(url_for('reset', username=current_user.username))
hashed_pwd = generate_password_hash(password, 'scrypt')
current_user.pass_hash = hashed_pwd
db.session.commit()
flash("Password changed.")
return redirect(url_for('settings', username=current_user.username))
return render_template("reset.html", username=current_user.username)
@app.route("/strava/<username>/", methods=["GET", "POST"])
@login_required
def strava(username):
profile_dir = '%s/%s' % (STORAGE_DIR, current_user.player_id)
api = '%s/strava_api.bin' % profile_dir
token = os.path.isfile('%s/strava_token.txt' % profile_dir)
if request.method == "POST":
if request.form['client_id'] == "" or request.form['client_secret'] == "":
flash("Client ID and secret can't be empty.")
return render_template("strava.html", username=current_user.username, token=token)
encrypt_credentials(api, (request.form['client_id'], request.form['client_secret']))
cred = decrypt_credentials(api)
return render_template("strava.html", username=current_user.username, cid=cred[0], cs=cred[1], token=token)
@app.route("/strava_auth", methods=['GET'])
@login_required
def strava_auth():
cred = decrypt_credentials('%s/%s/strava_api.bin' % (STORAGE_DIR, current_user.player_id))
client = Client()
url = client.authorization_url(client_id=cred[0],
redirect_uri='https://launcher.zwift.com/authorization',
scope=['activity:write'])
return redirect(url)
@app.route("/authorization", methods=["GET", "POST"])
@login_required
def authorization():
try:
cred = decrypt_credentials('%s/%s/strava_api.bin' % (STORAGE_DIR, current_user.player_id))
client = Client()
code = request.args.get('code')
token_response = client.exchange_code_for_token(client_id=int(cred[0]), client_secret=cred[1], code=code)
with open(os.path.join(STORAGE_DIR, str(current_user.player_id), 'strava_token.txt'), 'w') as f:
f.write(cred[0] + '\n')
f.write(cred[1] + '\n')
f.write(token_response['access_token'] + '\n')
f.write(token_response['refresh_token'] + '\n')
f.write(str(token_response['expires_at']) + '\n')
flash("Strava authorized.")
except Exception as exc:
logger.warning('Strava: %s' % repr(exc))
flash("Strava authorization canceled.")
return redirect(url_for('strava', username=current_user.username))
def encrypt_credentials(file, cred):
try:
cipher_suite = AES.new(credentials_key, AES.MODE_CFB)
with open(file, 'wb') as f:
f.write(cipher_suite.iv)
f.write(cipher_suite.encrypt((cred[0] + '\n' + cred[1]).encode('UTF-8')))
flash("Credentials saved.")
except Exception as exc:
logger.warning('encrypt_credentials: %s' % repr(exc))
flash("Error saving %s" % file)
def decrypt_credentials(file):
cred = ('', '')
if os.path.isfile(file):
try:
with open(file, 'rb') as f:
cipher_suite = AES.new(credentials_key, AES.MODE_CFB, iv=f.read(16))
lines = cipher_suite.decrypt(f.read()).decode('UTF-8').splitlines()
cred = (lines[0], lines[1])
except Exception as exc:
logger.warning('decrypt_credentials: %s' % repr(exc))
return cred
def backup_file(file):
if os.path.isfile(file):
copyfile(file, "%s-%s.bak" % (file, datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d-%H-%M-%S")))
@app.route("/profile/<username>/", methods=["GET", "POST"])
@login_required
def profile(username):
profile_dir = os.path.join(STORAGE_DIR, str(current_user.player_id))
file = os.path.join(profile_dir, 'zwift_credentials.bin')
cred = decrypt_credentials(file)
if request.method == "POST":
if request.form['username'] == "" or request.form['password'] == "":
flash("Zwift credentials can't be empty.")
return render_template("profile.html", username=current_user.username)
if not request.form.get("zwift_profile") and not request.form.get("achievements") and not request.form.get("save_zwift"):
flash("Select at least one option.")
return render_template("profile.html", username=current_user.username, uname=cred[0], passw=cred[1])
username = request.form['username']
password = request.form['password']
session = requests.session()
try:
access_token, refresh_token = online_sync.login(session, username, password)
try:
if request.form.get("zwift_profile"):
profile = online_sync.query(session, access_token, "api/profiles/me")
profile_file = '%s/profile.bin' % profile_dir
backup_file(profile_file)
with open(profile_file, 'wb') as f:
f.write(profile)
login_request = login_pb2.LoginRequest()
login_request.key = random.randbytes(16)
login_response = login_pb2.LoginResponse()
login_response.ParseFromString(online_sync.api_login(session, access_token, login_request))
login_response_dict = MessageToDict(login_response, preserving_proto_field_name=True)
if 'economy_config' in login_response_dict:
economy_config_file = '%s/economy_config.txt' % profile_dir
backup_file(economy_config_file)
with open(economy_config_file, 'w') as f:
json.dump(login_response_dict['economy_config'], f, indent=2)
if request.form.get("achievements"):
achievements = online_sync.query(session, access_token, "achievement/loadPlayerAchievements")
achievements_file = '%s/achievements.bin' % profile_dir
backup_file(achievements_file)
with open(achievements_file, 'wb') as f:
f.write(achievements)
online_sync.logout(session, refresh_token)
if request.form.get("save_zwift"):
encrypt_credentials(file, (username, password))
except Exception as exc:
logger.warning('Zwift profile: %s' % repr(exc))
flash("Error downloading profile.")
return render_template("profile.html", username=current_user.username, uname=cred[0], passw=cred[1])
except Exception as exc:
logger.warning('online_sync.login: %s' % repr(exc))
flash("Invalid username or password.")
return render_template("profile.html", username=current_user.username)
return redirect(url_for('settings', username=current_user.username))
return render_template("profile.html", username=current_user.username, uname=cred[0], passw=cred[1])
@app.route("/garmin/<username>/", methods=["GET", "POST"])
@login_required
def garmin(username):
file = '%s/%s/garmin_credentials.bin' % (STORAGE_DIR, current_user.player_id)
token = os.path.isfile('%s/%s/garth/oauth1_token.json' % (STORAGE_DIR, current_user.player_id))
if request.method == "POST":
if request.form['username'] == "" or request.form['password'] == "":
flash("Garmin credentials can't be empty.")
return render_template("garmin.html", username=current_user.username, token=token)
encrypt_credentials(file, (request.form['username'], request.form['password']))
cred = decrypt_credentials(file)
return render_template("garmin.html", username=current_user.username, uname=cred[0], passw=cred[1], token=token)
@app.route("/garmin_auth", methods=['GET'])
@login_required
def garmin_auth():
try:
import garth
garth.configure(domain=GARMIN_DOMAIN)
username, password = decrypt_credentials('%s/%s/garmin_credentials.bin' % (STORAGE_DIR, current_user.player_id))
garth.login(username, password)
garth.save('%s/%s/garth' % (STORAGE_DIR, current_user.player_id))
flash("Garmin authorized.")
except Exception as exc:
logger.warning('garmin_auth: %s' % repr(exc))
flash("Garmin authorization failed.")
return redirect(url_for('garmin', username=current_user.username))
@app.route("/intervals/<username>/", methods=["GET", "POST"])
@login_required
def intervals(username):
file = '%s/%s/intervals_credentials.bin' % (STORAGE_DIR, current_user.player_id)
if request.method == "POST":
if request.form['athlete_id'] == "" or request.form['api_key'] == "":
flash("Intervals.icu credentials can't be empty.")
return render_template("intervals.html", username=current_user.username)
encrypt_credentials(file, (request.form['athlete_id'], request.form['api_key']))
return redirect(url_for('settings', username=current_user.username))
cred = decrypt_credentials(file)
return render_template("intervals.html", username=current_user.username, aid=cred[0], akey=cred[1])
@app.route("/user/<username>/")
@login_required
def user_home(username):
return render_template("user_home.html", username=current_user.username, enable_ghosts=bool(current_user.enable_ghosts), climbs=CLIMBS,
online=get_online(), is_admin=current_user.is_admin, restarting=restarting, restarting_in_minutes=restarting_in_minutes)
def enqueue_player_update(player_id, wa_bytes):
if not player_id in player_update_queue:
player_update_queue[player_id] = list()
player_update_queue[player_id].append(wa_bytes)
def send_message(message, sender='Server', recipients=None):
player_update = udp_node_msgs_pb2.WorldAttribute()
player_update.server_realm = udp_node_msgs_pb2.ZofflineConstants.RealmID
player_update.wa_type = udp_node_msgs_pb2.WA_TYPE.WAT_SPA
player_update.world_time_born = world_time()
player_update.world_time_expire = world_time() + 60000
player_update.wa_f12 = 1
player_update.timestamp = int(time.time()*1000000)
chat_message = tcp_node_msgs_pb2.SocialPlayerAction()
chat_message.player_id = 0
chat_message.to_player_id = 0
chat_message.spa_type = tcp_node_msgs_pb2.SocialPlayerActionType.SOCIAL_TEXT_MESSAGE
chat_message.firstName = sender
chat_message.lastName = ''
chat_message.message = message
chat_message.countryCode = 0
player_update.payload = chat_message.SerializeToString()
player_update_s = player_update.SerializeToString()
if not recipients:
recipients = online.keys()
for receiving_player_id in recipients:
enqueue_player_update(receiving_player_id, player_update_s)
def send_restarting_message():
global restarting
global restarting_in_minutes
while restarting:
send_message('Restarting / Shutting down in %s minutes. Save your progress or continue riding until server is back online' % restarting_in_minutes)
time.sleep(60)
restarting_in_minutes -= 1
if restarting and restarting_in_minutes == 0:
message = 'See you later! Look for the back online message.'
send_message(message)
discord.send_message(message)
time.sleep(6)
os.kill(os.getpid(), signal.SIGINT)
@app.route("/restart")
@login_required
def restart_server():
global restarting
global restarting_in_minutes
if bool(current_user.is_admin):
restarting = True
restarting_in_minutes = 10
send_restarting_message_thread = threading.Thread(target=send_restarting_message)
send_restarting_message_thread.start()
discord.send_message('Restarting / Shutting down in %s minutes. Save your progress or continue riding until server is back online' % restarting_in_minutes)
return redirect(url_for('user_home', username=current_user.username))
@app.route("/cancelrestart")
@login_required
def cancel_restart_server():
global restarting
global restarting_in_minutes
if bool(current_user.is_admin):
restarting = False
restarting_in_minutes = 0
message = 'Restart of the server has been cancelled. Ride on!'
send_message(message)
discord.send_message(message)
return redirect(url_for('user_home', username=current_user.username))
@app.route("/reloadbots")
@login_required
def reload_bots():
global reload_pacer_bots
if bool(current_user.is_admin):
reload_pacer_bots = True
return redirect(url_for('user_home', username=current_user.username))
@app.route("/settings/<username>/", methods=["GET", "POST"])
@login_required
def settings(username):
allowed_files = ['profile.bin', 'achievements.bin', 'user_storage.bin', 'streaks.bin', 'economy_config.txt']
profile_dir = os.path.join(STORAGE_DIR, str(current_user.player_id))
if request.method == 'POST':
uploaded_file = request.files['file']
if uploaded_file.filename in allowed_files:
file_path = os.path.join(profile_dir, uploaded_file.filename)
backup_file(file_path)
uploaded_file.save(file_path)
else:
flash("Invalid file name.")
files = []
for file in allowed_files:
file_path = os.path.join(profile_dir, file)
if os.path.isfile(file_path):
stat = os.stat(file_path)
files.append((file, time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(stat.st_mtime))))
return render_template("settings.html", username=current_user.username, files=files)
@app.route("/download", methods=["POST"])
@login_required
def download():
file = os.path.join(STORAGE_DIR, str(current_user.player_id), request.form['filename'])
if os.path.isfile(file):
return send_file(file, as_attachment=True)
@app.route("/download/<int:player_id>/avatarLarge.jpg", methods=["GET"])
def download_avatarLarge(player_id):
profile_file = os.path.join(STORAGE_DIR, str(player_id), 'avatarLarge.jpg')
if os.path.isfile(profile_file):
return send_file(profile_file, mimetype='image/jpeg')
else:
return '', 404
@app.route("/delete/<path:filename>", methods=["GET"])
@login_required
def delete(filename):
credentials = ['zwift_credentials.bin', 'intervals_credentials.bin']
strava = ['strava_api.bin', 'strava_token.txt']
garmin = ['garmin_credentials.bin', 'garth/oauth1_token.json']
if filename not in credentials + strava + garmin:
return '', 403
delete_file = os.path.join(STORAGE_DIR, str(current_user.player_id), filename)
if os.path.isfile(delete_file):
os.remove(delete_file)
if filename in strava:
return redirect(url_for('strava', username=current_user.username))
if filename in garmin:
return redirect(url_for('garmin', username=current_user.username))
if filename in credentials:
flash("Credentials removed.")
return redirect(url_for('settings', username=current_user.username))
@app.route("/power_curves/<username>/", methods=["GET", "POST"])
@login_required
def power_curves(username):
if request.method == "POST":
player_id = current_user.player_id
PowerCurve.query.filter_by(player_id=player_id).delete()
db.session.commit()
if request.form.get('create'):
fit_dir = os.path.join(STORAGE_DIR, str(player_id), 'fit')
if os.path.isdir(fit_dir):
for fit_file in os.listdir(fit_dir):
create_power_curve(player_id, os.path.join(fit_dir, fit_file))
flash("Power curves created.")
else:
flash("Power curves deleted.")
return redirect(url_for('settings', username=current_user.username))
return render_template("power_curves.html", username=current_user.username)
@app.route("/logout/<username>")
@login_required
def logout(username):
session.clear()
logout_user()
flash("Successfully logged out.")
return redirect(url_for('login'))
def insert_protobuf_into_db(table_name, msg, exclude_fields=[], json_fields=[]):
msg_dict = MessageToDict(msg, preserving_proto_field_name=True, use_integers_for_enums=True)
for key in exclude_fields:
if key in msg_dict:
del msg_dict[key]
for key in json_fields:
if key in msg_dict:
msg_dict[key] = json.dumps(msg_dict[key])
if 'id' in msg_dict:
del msg_dict['id']
row = table_name(**msg_dict)
db.session.add(row)
db.session.commit()
return row.id
def update_protobuf_in_db(table_name, msg, id, exclude_fields=[], json_fields=[]):
msg_dict = MessageToDict(msg, preserving_proto_field_name=True, use_integers_for_enums=True)
for key in exclude_fields:
if key in msg_dict:
del msg_dict[key]
for key in json_fields:
if key in msg_dict:
msg_dict[key] = json.dumps(msg_dict[key])
table_name.query.filter_by(id=id).update(msg_dict)
db.session.commit()
def row_to_protobuf(row, msg, exclude_fields=[]):
for key in row.keys():
if key in exclude_fields:
continue
if row[key] is None:
continue
setattr(msg, key, row[key])
return msg
def world_time():
return int((time.time()-1414016075)*1000)
@app.route('/api/clubs/club/can-create', methods=['GET'])
def api_clubs_club_cancreate():
return jsonify({"reason": "DISABLED", "result": False})
@app.route('/api/event-feed', methods=['GET']) #from=1646723199600&limit=25&sport=CYCLING
def api_eventfeed():
limit = int(request.args.get('limit'))
sport = request.args.get('sport')
events = get_events(limit, sport)
json_events = convert_events_to_json(events)
json_data = []
for e in json_events:
json_data.append({"event": e})
return jsonify({"data":json_data,"cursor":None})
@app.route('/api/campaign/profile/campaigns', methods=['GET'])
@app.route('/api/announcements/active', methods=['GET'])
@app.route('/api/recommendation/profile', methods=['GET'])
@app.route('/api/recommendations/recommendation', methods=['GET'])
@app.route('/api/subscription/plan', methods=['GET'])
@app.route('/api/quest/quests/all-quests', methods=['GET'])
@app.route('/api/quest/quests/my-quests', methods=['GET'])
@app.route('/api/workout/schedule/list', methods=['GET'])
def api_empty_arrays():
return jsonify([])
@app.route('/api/assetcms/<path:path>', methods=['GET'])
def api_assetcms(path):
return jsonify()
def activity_moving_time(activity):
try:
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)
except:
return 0
def activity_row_to_json(activity, details=False):
profile = get_partial_profile(activity.player_id)
data = {"id":activity.id,"profile":{"id":str(activity.player_id),"firstName":profile.first_name,"lastName":profile.last_name,
"imageSrc":profile.imageSrc,"approvalRequired":None},"worldId":activity.course_id,"name":activity.name,"sport":str_sport(activity.sport),
"startDate":activity.start_date,"endDate":activity.end_date,"distanceInMeters":activity.distanceInMeters,
"totalElevation":activity.total_elevation,"calories":activity.calories,"primaryImageUrl":"","feedImageThumbnailUrl":"",
"lastSaveDate":activity.date,"movingTimeInMs":activity_moving_time(activity),"avgSpeedInMetersPerSecond":activity.avg_speed,
"activityRideOnCount":0,"activityCommentCount":0,"privacy":"PUBLIC","eventId":None,"rideOnGiven":False,"id_str":str(activity.id)}
if details:
extra_data = {"avgWatts":activity.avg_watts,"maxWatts":activity.max_watts,"avgHeartRate":activity.avg_heart_rate,
"maxHeartRate":activity.max_heart_rate,"avgCadenceInRotationsPerMinute":activity.avg_cadence,
"maxCadenceInRotationsPerMinute":activity.max_cadence,"maxSpeedInMetersPerSecond":activity.max_speed}
data.update(extra_data)
return data
def select_activities_json(player_id, limit, start_after=None, in_progress=True):
filters = [Activity.distanceInMeters > 1]
if player_id:
filters.append(Activity.player_id == player_id)
if not in_progress:
filters.append(Activity.end_date != None)
if start_after:
filters.append(Activity.id < int(start_after))
rows = Activity.query.filter(*filters).order_by(Activity.date.desc()).limit(limit)
ret = []
for row in rows:
ret.append(activity_row_to_json(row))
return ret
@app.route('/api/activity-feed/feed/', methods=['GET'])
@jwt_to_session_cookie
@login_required
def api_activity_feed():
limit = int(request.args.get('limit'))
feed_type = request.args.get('feedType')
start_after = request.args.get('start_after_activity_id')
profile_id = None
in_progress = feed_type == 'PREVIEW'
if feed_type == 'JUST_ME':
profile_id = current_user.player_id
elif feed_type == 'OTHER_PROFILE':
profile_id = int(request.args.get('profile_id'))
# todo: FAVORITES, FOLLOWEES (showing all for now)
ret = select_activities_json(profile_id, limit, start_after, in_progress)
return jsonify(ret)
@app.route('/api/activity-feed-service-v2/feed/<feed_type>', methods=['GET'])
@app.route('/api/activity-feed-service-v2/feed/<feed_type>/<int:profile_id>', methods=['GET'])
@jwt_to_session_cookie
@login_required
def api_activity_feed_service_v2(feed_type, profile_id=None):
limit = int(request.args.get('limit'))
start_after = request.args.get('start_after_activity_id')
in_progress = feed_type == 'preview'
if feed_type == 'just-me':
profile_id = current_user.player_id
# todo: favorites, followees (showing all for now)
ret = select_activities_json(profile_id, limit, start_after, in_progress)
return jsonify(ret)
def create_activity_file(fit_file, small_file, full_file=None):
data = {"powerInWatts": [], "cadencePerMin": [], "heartRate": [], "distanceInCm": [], "speedInCmPerSec": [], "timeInSec": [], "altitudeInCm": [], "latlng": []}
start_time = 0
with fitdecode.FitReader(fit_file) as fit:
for frame in fit:
if frame.frame_type == fitdecode.FIT_FRAME_DATA and frame.name == 'record':
power = cadence = heart_rate = distance = speed = time = altitude = position_lat = position_long = None
for f in frame.fields:
if f.name == "power" and f.value is not None: power = int(f.value)
elif f.name == "cadence" and f.value is not None: cadence = int(f.value)
elif f.name == "heart_rate" and f.value is not None: heart_rate = int(f.value)
elif f.name == "distance" and f.value is not None: distance = int(f.value * 100)
elif f.name == "speed" and f.value is not None: speed = int(f.value * 100)
elif f.name == "timestamp" and f.value is not None:
timestamp = int(f.value.timestamp())
if start_time == 0: start_time = timestamp
time = timestamp - start_time
elif f.name == "altitude" and f.value is not None: altitude = int(f.value * 100)
elif f.name == "position_lat" and f.value is not None: position_lat = round(f.value / 11930465, 6)
elif f.name == "position_long" and f.value is not None: position_long = round(f.value / 11930465, 6)
if None not in {power, cadence, heart_rate, distance, speed, time, altitude, position_lat, position_long}:
data["powerInWatts"].append(power)
data["cadencePerMin"].append(cadence)
data["heartRate"].append(heart_rate)
data["distanceInCm"].append(distance)
data["speedInCmPerSec"].append(speed)
data["timeInSec"].append(time)
data["altitudeInCm"].append(altitude)
data["latlng"].append([position_lat, position_long])
if data["powerInWatts"]:
if full_file:
with open(full_file, 'w') as f:
json.dump(data, f)
step = len(data["powerInWatts"]) // 1000
if step > 1:
for d in data:
data[d] = data[d][::step]
with open(small_file, 'w') as f:
json.dump(data, f)
@app.route('/api/activities/<int:activity_id>', methods=['GET'])
@jwt_to_session_cookie
@login_required
def api_activities(activity_id):
row = Activity.query.filter_by(id=activity_id).first()
if row:
activity = activity_row_to_json(row, True)
activities_dir = '%s/activities' % STORAGE_DIR
if not make_dir(activities_dir):
return '', 400
fit_file = '%s/%s/fit/%s - %s' % (STORAGE_DIR, row.player_id, row.id, row.fit_filename)
# fullDataUrl is never fetched, creating only downsampled file
file = ActivityFile.query.filter_by(activity_id=row.id, full=0).first()
if not file and os.path.isfile(fit_file):
file = ActivityFile(activity_id=row.id, full=0)
db.session.add(file)
db.session.commit()
if file:
activity_file = '%s/%s' % (activities_dir, file.id)
if not os.path.isfile(activity_file) and os.path.isfile(fit_file):
try:
create_activity_file(fit_file, activity_file)
except Exception as exc:
logger.warning('create_activity_file: %s' % repr(exc))
if os.path.isfile(activity_file):
url = 'https://us-or-rly101.zwift.com/api/activities/%s/file/%s' % (row.id, file.id)
data = {"fitnessData": {"status": "AVAILABLE", "fullDataUrl": url, "smallDataUrl": url}}
activity.update(data)
return jsonify(activity)
return '', 404
@app.route('/api/activities/<int:activity_id>/file/<file>')
def api_activities_file(activity_id, file):
return send_from_directory('%s/activities' % STORAGE_DIR, file)
@app.route('/api/auth', methods=['GET'])
def api_auth():
return {"realm": "zwift","launcher": "https://launcher.zwift.com/launcher","url": "https://secure.zwift.com/auth/"}
@app.route('/api/server', methods=['GET'])
def api_server():
return {"build":"zwift_1.267.0","version":"1.267.0"}
@app.route('/api/servers', methods=['GET'])
def api_servers():
return {"baseUrl":"https://us-or-rly101.zwift.com/relay"}
@app.route('/api/clubs/club/list/my-clubs', methods=['GET'])
@app.route('/api/clubs/club/reset-my-active-club.proto', methods=['POST'])
@app.route('/api/clubs/club/featured', methods=['GET'])
@app.route('/api/clubs/club', methods=['GET'])
def api_clubs():
return jsonify({"total": 0, "results": []})
@app.route('/api/clubs/club/my-clubs-summary', methods=['GET'])
def api_clubs_club_my_clubs_summary():
return jsonify({"invitedCount": 0, "requestedCount": 0, "results": []})
@app.route('/api/clubs/club/list/my-clubs.proto', methods=['GET'])
@app.route('/api/campaign/proto/campaigns', methods=['GET'])
@app.route('/api/campaign/proto/campaigns/completed', methods=['GET'])
@app.route('/api/campaign/public/proto/campaigns/active', methods=['GET'])
@app.route('/api/player-playbacks/player/settings', methods=['GET', 'POST']) # TODO: private = \x08\x01 (1: 1)
@app.route('/api/scoring/current', methods=['GET'])
@app.route('/api/game-asset-patching-service/manifest', methods=['GET'])
@app.route('/api/workout/progress', methods=['POST'])
@app.route('/api/power-curve/power-profile/proto', methods=['GET'])
def api_proto_empty():
return '', 200
@app.route('/api/game_info/version', methods=['GET'])
def api_gameinfo_version():
game_info_file = os.path.join(SCRIPT_DIR, "data", "game_info.txt")
with open(game_info_file, mode="r", encoding="utf-8-sig") as f:
data = json.load(f)
return {"version": data['gameInfoHash']}
@app.route('/api/game_info', methods=['GET'])
def api_gameinfo():
game_info_file = os.path.join(SCRIPT_DIR, "data", "game_info.txt")
with open(game_info_file, mode="r", encoding="utf-8-sig") as f:
r = make_response(f.read())
r.mimetype = 'application/json'
return r
@app.route('/api/users/login', methods=['POST'])
@jwt_to_session_cookie
@login_required
def api_users_login():
req = login_pb2.LoginRequest()
req.ParseFromString(request.stream.read())
player_id = current_user.player_id
global_relay[player_id] = Relay(req.key)
ghosts_enabled[player_id] = current_user.enable_ghosts
response = login_pb2.LoginResponse()
response.session_state = 'abc'
response.info.relay_url = "https://us-or-rly101.zwift.com/relay"
response.info.apis.todaysplan_url = "https://whats.todaysplan.com.au"
response.info.apis.trainingpeaks_url = "https://api.trainingpeaks.com"
response.info.time = int(time.time())
udp_node = response.info.nodes.nodes.add()
udp_node.ip = request.remote_addr if request.remote_addr in ['127.0.0.1', '::1'] else server_ip # TCP telemetry server
udp_node.port = 3023
response.relay_session_id = player_id
response.expiration = 70
profile_dir = os.path.join(STORAGE_DIR, str(current_user.player_id))
config_file = os.path.join(profile_dir, 'economy_config.txt')
if not os.path.isfile(config_file):
with open(os.path.join(SCRIPT_DIR, 'data', 'economy_config.txt')) as f:
economy_config = json.load(f)
profile_file = os.path.join(profile_dir, 'profile.bin')
if os.path.isfile(profile_file):
profile = profile_pb2.PlayerProfile()
with open(profile_file, 'rb') as f:
profile.ParseFromString(f.read())
current_level = profile.achievement_level // 100
levels = [x for x in economy_config['cycling_levels'] if x['level'] >= current_level]
if len(levels) > 1 and profile.total_xp > levels[1]['xp']: # avoid instant promotion
offset = profile.total_xp - levels[0]['xp']
transition_end = [x for x in levels if x['xp'] <= profile.total_xp][-1]['level']
for level in economy_config['cycling_levels']:
if level['level'] >= current_level:
level['xp'] += offset
if transition_end > current_level:
economy_config['transition_start'] = current_level
economy_config['transition_end'] = transition_end
elif levels and profile.total_xp < levels[0]['xp']: # avoid demotion
offset = levels[0]['xp'] - profile.total_xp
for level in economy_config['cycling_levels']:
if level['level'] <= current_level:
level['xp'] = max(level['xp'] - offset, 0)
with open(config_file, 'w') as f:
json.dump(economy_config, f, indent=2)
with open(config_file) as f:
Parse(f.read(), response.economy_config)
return response.SerializeToString(), 200
@app.route('/relay/session/refresh', methods=['POST'])
@app.route('/relay/session/renew', methods=['POST'])
@jwt_to_session_cookie
@login_required
def relay_session_refresh():
refresh = login_pb2.RelaySessionRefreshResponse()
refresh.relay_session_id = current_user.player_id
refresh.expiration = 70
return refresh.SerializeToString(), 200
def logout_player(player_id):
if player_id in global_ghosts:
del global_ghosts[player_id].rec.states[:]
global_ghosts[player_id].play.clear()
global_ghosts.pop(player_id)
if player_id in global_bookmarks:
global_bookmarks[player_id].clear()
global_bookmarks.pop(player_id)
@app.route('/api/users/logout', methods=['POST'])
@jwt_to_session_cookie
@login_required
def api_users_logout():
logout_player(current_user.player_id)
return '', 204
@app.route('/api/analytics/event', methods=['POST'])
def api_analytics_event():
#print(json.dumps(request.json, indent=4))
return '', 200
@app.route('/api/per-session-info', methods=['GET'])
def api_per_session_info():
info = per_session_info_pb2.PerSessionInfo()
info.relay_url = "https://us-or-rly101.zwift.com/relay"
return info.SerializeToString(), 200
def get_events(limit=None, sport=None):
with open(os.path.join(SCRIPT_DIR, 'data', 'events.txt')) as f:
events_list = json.load(f)
events = events_pb2.Events()
eventStart = int(time.time()) * 1000 + 2 * 60000
eventStartWT = world_time() + 2 * 60000
event_id = 1000000 # can't conflict with private event ID
for item in events_list:
event_id += 10
if sport != None and item['sport'] != profile_pb2.Sport.Value(sport):
continue
event = events.events.add()
event.server_realm = udp_node_msgs_pb2.ZofflineConstants.RealmID
event.id = event_id
event.name = item['name']
event.route_id = item['route'] #otherwise new home screen hangs trying to find route in all (even non-existent) courses
event.course_id = item['course']
event.sport = item['sport']
event.lateJoinInMinutes = 30
event.eventStart = eventStart
event.visible = True
event.overrideMapPreferences = False
event.invisibleToNonParticipants = False
event.description = "Auto-generated event"
event.distanceInMeters = item['distance']
event.laps = 0
event.durationInSeconds = 0
#event.rules_id =
#event.jerseyHash =
event.eventType = events_pb2.EventType.RACE
#event.e_f27 = 27; //<=4, ENUM?
#event.tags = 31; // semi-colon delimited tags
event.e_wtrl = False # WTRL (World Tactical Racing Leagues)
cats = ('A', 'B', 'C', 'D', 'E', 'F')
paceValues = ((4,15), (3,4), (2,3), (1,2), (0.1,1))
for cat in range(1,5):
event_cat = event.category.add()
event_cat.id = event_id + cat
#event_cat.registrationStart = eventStart - 30 * 60000
#event_cat.registrationStartWT = eventStartWT - 30 * 60000
event_cat.registrationEnd = eventStart
event_cat.registrationEndWT = eventStartWT
#event_cat.lineUpStart = eventStart - 5 * 60000
#event_cat.lineUpStartWT = eventStartWT - 5 * 60000
#event_cat.lineUpEnd = eventStart
#event_cat.lineUpEndWT = eventStartWT
event_cat.eventSubgroupStart = eventStart - 2 * 60000 # fixes HUD timer
event_cat.eventSubgroupStartWT = eventStartWT - 2 * 60000
event_cat.route_id = item['route']
event_cat.startLocation = cat
event_cat.label = cat
event_cat.lateJoinInMinutes = 30
event_cat.name = "Cat. %s" % cats[cat - 1]
event_cat.description = "#zwiftoffline"
event_cat.course_id = event.course_id
event_cat.paceType = 1 #1 almost everywhere, 2 sometimes
event_cat.fromPaceValue = paceValues[cat - 1][0]
event_cat.toPaceValue = paceValues[cat - 1][1]
#event_cat.scode = 7; // ex: "PT3600S"
#event_cat.rules_id = 8; // 320 and others
event_cat.distanceInMeters = item['distance']
event_cat.laps = 0
event_cat.durationInSeconds = 0
#event_cat.jerseyHash = 36; // 493134166, tag672
#event_cat.tags = 45; // tag746, semi-colon delimited tags eg: "fenced;3r;created_ryan;communityevent;no_kick_mode;timestamp=1603911177622"
if limit != None and len(events.events) >= limit:
break
return events
@app.route('/api/events/<int:event_id>', methods=['GET'])
def api_events_id(event_id):
events = get_events()
for e in events.events:
if e.id == event_id:
return jsonify(convert_event_to_json(e))
return '', 200
@app.route('/api/events/search', methods=['POST'])
def api_events_search():
limit = int(request.args.get('limit'))
events = get_events(limit)
if request.headers['Accept'] == 'application/json':
return jsonify(convert_events_to_json(events))
else:
return events.SerializeToString(), 200
def create_event_wat(rel_id, wa_type, pe, dest_ids):
player_update = udp_node_msgs_pb2.WorldAttribute()
player_update.server_realm = udp_node_msgs_pb2.ZofflineConstants.RealmID
player_update.wa_type = wa_type
player_update.world_time_born = world_time()
player_update.world_time_expire = world_time() + 60000
player_update.wa_f12 = 1
player_update.timestamp = int(time.time()*1000000)
player_update.rel_id = current_user.player_id
pe.rel_id = rel_id
pe.player_id = current_user.player_id
#optional uint64 pje_f3/ple_f3 = 3;
player_update.payload = pe.SerializeToString()
player_update_s = player_update.SerializeToString()
if not current_user.player_id in dest_ids:
dest_ids = list(dest_ids)
dest_ids.append(current_user.player_id)
for receiving_player_id in dest_ids:
enqueue_player_update(receiving_player_id, player_update_s)
@app.route('/api/events/subgroups/signup/<int:rel_id>', methods=['POST'])
@app.route('/api/events/signup/<int:rel_id>', methods=['DELETE'])
@jwt_to_session_cookie
@login_required
def api_events_subgroups_signup_id(rel_id):
if request.method == 'POST':
wa_type = udp_node_msgs_pb2.WA_TYPE.WAT_JOIN_E
pe = events_pb2.PlayerJoinedEvent()
ret = True
else:
wa_type = udp_node_msgs_pb2.WA_TYPE.WAT_LEFT_E
pe = events_pb2.PlayerLeftEvent()
ret = False
#empty request.data
create_event_wat(rel_id, wa_type, pe, online.keys())
return jsonify({"signedUp":ret})
@app.route('/api/events/subgroups/register/<int:ev_sg_id>', methods=['POST'])
def api_events_subgroups_register_id(ev_sg_id):
return '{"registered":true}', 200
@app.route('/api/events/subgroups/entrants/<int:ev_sg_id>', methods=['GET'])
def api_events_subgroups_entrants_id(ev_sg_id):
if request.headers['Accept'] == 'application/x-protobuf-lite':
return '', 200
return '[]', 200
@app.route('/api/events/subgroups/invited_ride_sweepers/<int:ev_sg_id>', methods=['GET'])
def api_events_subgroups_invited_ride_sweepers_id(ev_sg_id):
return '[]', 200
@app.route('/api/events/subgroups/invited_ride_leaders/<int:ev_sg_id>', methods=['GET'])
def api_events_subgroups_invited_ride_leaders_id(ev_sg_id):
return '[]', 200
@app.route('/relay/race/event_starting_line/<int:event_id>', methods=['POST'])
def relay_race_event_starting_line_id(event_id):
return '', 204
@app.route('/api/zfiles', methods=['POST'])
@jwt_to_session_cookie
@login_required
def api_zfiles():
if request.headers['Source'] == 'zwift-companion':
zfile = json.loads(request.stream.read())
zfile_folder = zfile['folder']
zfile_filename = zfile['name']
zfile_file = base64.b64decode(zfile['content'])
else:
zfile = zfiles_pb2.ZFileProto()
zfile.ParseFromString(request.stream.read())
zfile_folder = zfile.folder
zfile_filename = zfile.filename
zfile_file = zfile.file
zfiles_dir = os.path.join(STORAGE_DIR, str(current_user.player_id), zfile_folder)
if not make_dir(zfiles_dir):
return '', 400
try:
zfile_filename = zfile_filename.decode('utf-8', 'ignore')
except AttributeError:
pass
with open(os.path.join(zfiles_dir, quote(zfile_filename, safe=' ')), 'wb') as fd:
fd.write(zfile_file)
row = Zfile.query.filter_by(folder=zfile_folder, filename=zfile_filename, player_id=current_user.player_id).first()
if not row:
zfile_timestamp = int(time.time())
new_zfile = Zfile(folder=zfile_folder, filename=zfile_filename, timestamp=zfile_timestamp, player_id=current_user.player_id)
db.session.add(new_zfile)
db.session.commit()
zfile_id = new_zfile.id
else:
zfile_id = row.id
zfile_timestamp = row.timestamp
if request.headers['Accept'] == 'application/json':
return jsonify({"id":zfile_id,"folder":zfile_folder,"name":zfile_filename,"content":None,"lastModified":str_timestamp(zfile_timestamp*1000)})
else:
response = zfiles_pb2.ZFileProto()
response.id = zfile_id
response.folder = zfile_folder
response.filename = zfile_filename
response.timestamp = zfile_timestamp
return response.SerializeToString(), 200
@app.route('/api/zfiles/list', methods=['GET'])
@jwt_to_session_cookie
@login_required
def api_zfiles_list():
folder = request.args.get('folder')
zfiles = zfiles_pb2.ZFilesProto()
rows = Zfile.query.filter_by(folder=folder, player_id=current_user.player_id)
for row in rows:
zfiles.zfiles.add(id=row.id, folder=row.folder, filename=row.filename, timestamp=row.timestamp)
return zfiles.SerializeToString(), 200
@app.route('/api/zfiles/<int:file_id>/download', methods=['GET'])
@jwt_to_session_cookie
@login_required
def api_zfiles_download(file_id):
row = Zfile.query.filter_by(id=file_id).first()
zfile = os.path.join(STORAGE_DIR, str(row.player_id), row.folder, quote(row.filename, safe=' '))
if os.path.isfile(zfile):
return send_file(zfile, as_attachment=True, download_name=row.filename)
else:
return '', 404
@app.route('/api/zfiles/<int:file_id>', methods=['DELETE'])
@jwt_to_session_cookie
@login_required
def api_zfiles_delete(file_id):
row = Zfile.query.filter_by(id=file_id).first()
try:
os.remove(os.path.join(STORAGE_DIR, str(row.player_id), row.folder, quote(row.filename, safe=' ')))
except Exception as exc:
logger.warning('api_zfiles_delete: %s' % repr(exc))
db.session.delete(row)
db.session.commit()
return '', 200
# Custom static data
@app.route('/style/<path:filename>')
def custom_style(filename):
return send_from_directory('%s/cdn/style' % SCRIPT_DIR, filename)
@app.route('/static/web/launcher/<path:filename>')
def static_web_launcher(filename):
return send_from_directory('%s/cdn/static/web/launcher' % SCRIPT_DIR, filename)
# Android uses https for schedules, redirect to http to enable proxy
@app.route('/gameassets/MapSchedule_v2.xml')
@app.route('/gameassets/PortalRoadSchedule_v1.xml')
def gameassets_schedule():
return redirect('http://cdn.zwift.com%s' % request.path)
@app.route('/api/telemetry/config', methods=['GET'])
def api_telemetry_config():
return jsonify({"analyticsEvents": True, "batchInterval": 120, "innermostCullingRadius": 1500, "isEnabled": True,
"key": "aXBSdlpza3p1aVlNOENrMTBQSzZEZ004Z2pwRm8zZUE6", "remoteLogLevel": 3, "sampleInterval": 60,
"url": "https://us-or-rly101.zwift.com/v1/track", # used if no urlBatch (https://api.segment.io/v1/track)
"urlBatch": "https://us-or-rly101.zwift.com/hvc-ingestion-service/batch", "telemetryEvents": True, "gzip": False,
"structuredEventsUrl": "https://us-or-rly101.zwift.com/api/actions-service/structured-events/batch"})
@app.route('/v1/track', methods=['POST'])
@app.route('/hvc-ingestion-service/batch', methods=['POST'])
@app.route('/api/hvc-ingestion-service/batch', methods=['POST'])
def hvc_ingestion_service_batch():
#print(json.dumps(request.json, indent=4))
return jsonify({"success": True})
@app.route('/api/actions-service/structured-events/batch', methods=['POST'])
def api_actions_service_structured_events_batch():
stream = request.stream.read()
#import blackboxprotobuf
#message, typedef = blackboxprotobuf.protobuf_to_json(stream)
#print(json.dumps(json.loads(message), indent=2))
req = structured_events_pb2.SaveStructuredEventRequest()
req.ParseFromString(stream)
res = structured_events_pb2.SaveStructuredEventResponse()
res.sequenceNumber = req.sequenceNumber
return res.SerializeToString(), 202
def age(dob):
today = datetime.date.today()
years = today.year - dob.year
if today.month < dob.month or (today.month == dob.month and today.day < dob.day):
years -= 1
return years
def jsf(obj, field, deflt = None):
if obj.HasField(field):
return getattr(obj, field)
return deflt
def jsb0(obj, field):
return jsf(obj, field, False)
def jsb1(obj, field):
return jsf(obj, field, True)
def jsv0(obj, field):
return jsf(obj, field, 0)
def jses(obj, field):
return str(jsf(obj, field))
def copyAttributes(jprofile, jprofileFull, src):
dict = jprofileFull.get(src)
if dict is None:
return
dest = {}
for di in dict:
for v in ['numberValue', 'floatValue', 'stringValue']:
if v in di:
dest[di['id']] = di[v]
jprofile[src] = dest
def powerSourceModelToStr(val):
if val == 1:
return "Power Meter"
else:
return "zPower"
def privacy(profile):
privacy_bits = jsf(profile, 'privacy_bits', 0)
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),
"suppressFollowerNotification": bool(privacy_bits & 32), "displayAge": not bool(privacy_bits & 64), "defaultActivityPrivacy": profile_pb2.ActivityPrivacyType.Name(jsv0(profile, 'default_activity_privacy'))}
def bikeFrameToStr(val):
if val in GD['bikeframes']:
return GD['bikeframes'][val]
return "---"
def update_entitlements(profile):
for entitlement in list(profile.entitlements):
if entitlement.type == profile_pb2.ProfileEntitlement.EntitlementType.RIDE:
profile.entitlements.remove(entitlement)
e = profile.entitlements.add()
e.type = profile_pb2.ProfileEntitlement.EntitlementType.RIDE
e.id = -1
e.status = profile_pb2.ProfileEntitlement.ProfileEntitlementStatus.ACTIVE
if os.path.isfile('%s/unlock_entitlements.txt' % STORAGE_DIR) or os.path.isfile('%s/unlock_all_equipment.txt' % STORAGE_DIR):
ent = json.load(open('%s/data/entitlements.txt' % SCRIPT_DIR))
entitlements = list(range(ent['first'], ent['last'] + 1))
if os.path.isfile('%s/unlock_all_equipment.txt' % STORAGE_DIR):
entitlements.extend(list(range(1, ent['first'])))
for entitlement in entitlements:
if not any(e.id == entitlement for e in profile.entitlements):
e = profile.entitlements.add()
e.type = profile_pb2.ProfileEntitlement.EntitlementType.USE
e.id = entitlement
e.status = profile_pb2.ProfileEntitlement.ProfileEntitlementStatus.ACTIVE
def do_api_profiles(profile_id, is_json):
profile = profile_pb2.PlayerProfile()
profile_file = '%s/%s/profile.bin' % (STORAGE_DIR, profile_id)
if os.path.isfile(profile_file):
with open(profile_file, 'rb') as fd:
profile.ParseFromString(fd.read())
else:
profile.email = current_user.username
profile.first_name = current_user.first_name
profile.last_name = current_user.last_name
profile.mix_panel_distinct_id = str(uuid.uuid4())
profile.id = profile_id
if is_json: #todo: publicId, bodyType, totalRunCalories != total_watt_hours, totalRunTimeInMinutes != time_ridden_in_minutes etc
if profile.dob != "":
profile.age = age(datetime.datetime.strptime(profile.dob, "%m/%d/%Y"))
jprofileFull = MessageToDict(profile)
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'),
"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,
"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'),
"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'),
"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'),
"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'),
"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'),
"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'),
"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'),
"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'),
"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",
"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,
"avantlinkId": None, "virtualBikeModel": bikeFrameToStr(profile.bike_frame), "connectedToWithings": jsb0(profile, 'connected_to_withings'), "connectedToRuntastic": jsb0(profile, 'connected_to_runtastic'), "connectedToZwiftPower": False, "powerSourceType": "Power Source",
"powerSourceModel": powerSourceModelToStr(profile.power_source_model), "riding": False, "location": "", "publicId": "5a72e9b1-239f-435e-8757-af9467336b40", "mixpanelDistinctId": "21304417-af2d-4c9b-8543-8ba7c0500e84"}
copyAttributes(jprofile, jprofileFull, 'publicAttributes')
copyAttributes(jprofile, jprofileFull, 'privateAttributes')
return jsonify(jprofile)
else:
update_entitlements(profile)
return profile.SerializeToString(), 200
@app.route('/api/profiles/me', methods=['GET'], strict_slashes=False)
@jwt_to_session_cookie
@login_required
def api_profiles_me():
if request.headers['Accept'] == 'application/json':
return do_api_profiles(current_user.player_id, True)
else:
return do_api_profiles(current_user.player_id, False)
@app.route('/api/profiles/me/entitlements', methods=['GET'])
@jwt_to_session_cookie
@login_required
def api_profiles_me_entitlements():
profile = profile_pb2.PlayerProfile()
profile_file = '%s/%s/profile.bin' % (STORAGE_DIR, current_user.player_id)
if os.path.isfile(profile_file):
with open(profile_file, 'rb') as fd:
profile.ParseFromString(fd.read())
update_entitlements(profile)
entitlements = profile_pb2.ProfileEntitlements()
entitlements.entitlements.extend(profile.entitlements)
return entitlements.SerializeToString(), 200
@app.route('/api/profiles/<int:profile_id>', methods=['GET'])
@jwt_to_session_cookie
@login_required
def api_profiles_json(profile_id):
return do_api_profiles(profile_id, True)
@app.route('/api/partners/garmin/auth', methods=['GET'])
@app.route('/api/partners/trainingpeaks/auth', methods=['GET'])
@app.route('/api/partners/strava/auth', methods=['GET'])
@app.route('/api/partners/withings/auth', methods=['GET'])
@app.route('/api/partners/todaysplan/auth', methods=['GET'])
@app.route('/api/partners/runtastic/auth', methods=['GET'])
@app.route('/api/partners/underarmour/auth', methods=['GET'])
@app.route('/api/partners/fitbit/auth', methods=['GET'])
def api_profiles_partners():
return {"status":"notConnected","clientId":"zwift","sandbox":False}
@app.route('/api/profiles/<int:player_id>/privacy', methods=['POST'])
@jwt_to_session_cookie
@login_required
def api_profiles_id_privacy(player_id):
privacy_file = '%s/%s/privacy.json' % (STORAGE_DIR, player_id)
jp = request.get_json()
with open(privacy_file, 'w', encoding='utf-8') as fprivacy:
fprivacy.write(json.dumps(jp, ensure_ascii=False))
#{"displayAge": false, "defaultActivityPrivacy": "PUBLIC", "approvalRequired": false, "privateMessaging": false, "defaultFitnessDataPrivacy": false}
profile_dir = '%s/%s' % (STORAGE_DIR, player_id)
profile = profile_pb2.PlayerProfile()
profile_file = '%s/profile.bin' % profile_dir
with open(profile_file, 'rb') as fd:
profile.ParseFromString(fd.read())
profile.privacy_bits = 0
if jp["approvalRequired"]:
profile.privacy_bits += 1
if "displayWeight" in jp and jp["displayWeight"]:
profile.privacy_bits += 4
if "minor" in jp and jp["minor"]:
profile.privacy_bits += 2
if jp["privateMessaging"]:
profile.privacy_bits += 8
if jp["defaultFitnessDataPrivacy"]:
profile.privacy_bits += 16
if "suppressFollowerNotification" in jp and jp["suppressFollowerNotification"]:
profile.privacy_bits += 32
if not jp["displayAge"]:
profile.privacy_bits += 64
defaultActivityPrivacy = jp["defaultActivityPrivacy"]
profile.default_activity_privacy = 0 #PUBLIC
if defaultActivityPrivacy == "PRIVATE":
profile.default_activity_privacy = 1
if defaultActivityPrivacy == "FRIENDS":
profile.default_activity_privacy = 2
with open(profile_file, 'wb') as fd:
fd.write(profile.SerializeToString())
return '', 200
@app.route('/api/profiles/<int:m_player_id>/followers', methods=['GET']) #?start=0&limit=200&include-follow-requests=false
@app.route('/api/profiles/<int:m_player_id>/followees', methods=['GET'])
@app.route('/api/profiles/<int:m_player_id>/followees-in-common/<int:t_player_id>', methods=['GET'])
@jwt_to_session_cookie
@login_required
def api_profiles_followers(m_player_id, t_player_id=0):
if request.headers['Accept'] == 'application/x-protobuf-lite':
return '', 200
rows = db.session.execute(sqlalchemy.text("SELECT player_id, first_name, last_name FROM user"))
json_data_list = []
for row in rows:
player_id = row[0]
profile = get_partial_profile(player_id)
#all users are following favourites of this user (temp decision for small crouds)
json_data_list.append({"id":0,"followerId":player_id,"followeeId":m_player_id,"status":"IS_FOLLOWING","isFolloweeFavoriteOfFollower":True,
"followerProfile":{"id":player_id,"firstName":row[1],"lastName":row[2],"imageSrc":imageSrc(player_id),"imageSrcLarge":imageSrc(player_id),"countryCode":profile.country_code},
"followeeProfile":None})
return jsonify(json_data_list)
@app.route('/api/search/profiles/restricted', methods=['POST'])
@app.route('/api/search/profiles', methods=['POST'])
@jwt_to_session_cookie
@login_required
def api_search_profiles():
query = request.json['query']
start = request.args.get('start')
limit = request.args.get('limit')
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")
rows = db.session.execute(stmt, {"n": "%"+query+"%", "l": limit, "o": start})
json_data_list = []
for row in rows:
player_id = row[0]
profile = get_partial_profile(player_id)
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})
return jsonify(json_data_list)
@app.route('/api/profiles/<int:player_id>/membership-status', methods=['GET'])
def api_profiles_membership_status(player_id):
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}
@app.route('/api/profiles/<int:player_id>/statistics', methods=['GET'])
def api_profiles_id_statistics(player_id):
from_dt = request.args.get('startDateTime')
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)")
row = db.session.execute(stmt, {"p": player_id, "d": from_dt}).first()
json_data = {"timeRiddenInMinutes": row[0], "distanceRiddenInMeters": row[1], "caloriesBurned": row[2], "heightClimbedInMeters": row[3]}
return jsonify(json_data)
@app.route('/relay/profiles/me/phone', methods=['PUT'])
@jwt_to_session_cookie
@login_required
def api_profiles_me_phone():
if not request.stream:
return '', 400
phoneAddress = request.json['phoneAddress']
if 'port' in request.json:
phonePort = int(request.json['port'])
phoneSecretKey = 'None'
if 'securePort' in request.json:
phonePort = int(request.json['securePort'])
phoneSecretKey = base64.b64decode(request.json['secret'])
zc_connect_queue[current_user.player_id] = (phoneAddress, phonePort, phoneSecretKey)
#todo UDP scenario
#logger.info("ZCompanion %d reg: %s:%d (key: %s)" % (current_user.player_id, phoneAddress, phonePort, phoneSecretKey.hex()))
return '', 204
@app.route('/api/profiles/me/<int:player_id>', methods=['PUT'])
@jwt_to_session_cookie
@login_required
def api_profiles_me_id(player_id):
if not request.stream:
return '', 400
if current_user.player_id != player_id:
return '', 401
profile_dir = '%s/%s' % (STORAGE_DIR, player_id)
profile = profile_pb2.PlayerProfile()
profile_file = '%s/profile.bin' % profile_dir
with open(profile_file, 'rb') as fd:
profile.ParseFromString(fd.read())
#update profile from json
profile.country_code = request.json['countryCode']
profile.dob = request.json['dob']
profile.email = request.json['emailAddress']
profile.first_name = request.json['firstName']
profile.last_name = request.json['lastName']
profile.height_in_millimeters = request.json['height']
profile.is_male = request.json['male']
profile.use_metric = request.json['useMetric']
profile.weight_in_grams = request.json['weight']
image = imageSrc(player_id)
if image is not None:
profile.large_avatar_url = image
with open(profile_file, 'wb') as fd:
fd.write(profile.SerializeToString())
if MULTIPLAYER:
current_user.first_name = profile.first_name
current_user.last_name = profile.last_name
db.session.commit()
return api_profiles_me()
@app.route('/api/profiles/<int:player_id>', methods=['PUT'])
@app.route('/api/profiles/<int:player_id>/in-game-fields', methods=['PUT'])
@jwt_to_session_cookie
@login_required
def api_profiles_id(player_id):
if not request.stream:
return '', 400
if player_id == 0:
return '', 400 # can't return 401 to /api/profiles/0/in-game-fields (causes issues in following requests)
if current_user.player_id != player_id:
return '', 401
stream = request.stream.read()
with open('%s/%s/profile.bin' % (STORAGE_DIR, player_id), 'wb') as f:
f.write(stream)
if MULTIPLAYER:
profile = profile_pb2.PlayerProfile()
profile.ParseFromString(stream)
current_user.first_name = profile.first_name
current_user.last_name = profile.last_name
db.session.commit()
return '', 204
@app.route('/api/profiles/<int:player_id>/photo', methods=['POST'])
@jwt_to_session_cookie
@login_required
def api_profiles_id_photo_post(player_id):
if not request.stream:
return '', 400
if current_user.player_id != player_id:
return '', 401
stream = request.stream.read().split(b'\r\n\r\n', maxsplit=1)[1]
with open('%s/%s/avatarLarge.jpg' % (STORAGE_DIR, player_id), 'wb') as f:
f.write(stream)
return '', 200
@app.route('/api/profiles/<int:player_id>/activities', methods=['GET', 'POST'], strict_slashes=False)
@jwt_to_session_cookie
@login_required
def api_profiles_activities(player_id):
if request.method == 'POST':
if not request.stream:
return '', 400
if current_user.player_id != player_id:
return '', 401
activity = activity_pb2.Activity()
activity.ParseFromString(request.stream.read())
activity.id = insert_protobuf_into_db(Activity, activity, ['fit'], ['power_zones'])
return '{"id": %ld}' % activity.id, 200
# request.method == 'GET'
activities = activity_pb2.ActivityList()
rows = db.session.execute(sqlalchemy.text("SELECT * FROM activity WHERE player_id = :p AND date > date('now', '-1 month')"), {"p": player_id}).mappings()
for row in rows:
activity = activities.activities.add()
row_to_protobuf(row, activity, exclude_fields=['fit', 'power_zones'])
return activities.SerializeToString(), 200
@app.route('/api/profiles/<int:player_id>/activities/<int:activity_id>/images', methods=['POST'])
@jwt_to_session_cookie
@login_required
def api_profiles_activities_images(player_id, activity_id):
images_dir = '%s/%s/images' % (STORAGE_DIR, player_id)
if not make_dir(images_dir):
return '', 400
row = ActivityImage(player_id=player_id, activity_id=activity_id)
db.session.add(row)
db.session.commit()
image = activity_pb2.ActivityImage()
image.ParseFromString(request.stream.read())
with open('%s/%s.jpg' % (images_dir, row.id), 'wb') as f:
f.write(image.jpg)
return jsonify({"id": row.id, "id_str": str(row.id)})
def time_since(date):
seconds = (world_time() - date) // 1000
interval = seconds // 31536000
if interval > 0: interval_type = 'year'
else:
interval = seconds // 2592000
if interval > 0: interval_type = 'month'
else:
interval = seconds // 604800
if interval > 0: interval_type = 'week'
else:
interval = seconds // 86400
if interval > 0: interval_type = 'day'
else:
interval = seconds // 3600
if interval > 0: interval_type = 'hour'
else:
interval = seconds // 60
if interval > 0: interval_type = 'minute'
else: return 'Just now'
if interval > 1: interval_type += 's'
return '%s %s ago' % (interval, interval_type)
def random_equipment(p):
p.ride_helmet_type = random.choice(GD['headgears'])
p.glasses_type = random.choice(GD['glasses'])
p.ride_shoes_type = random.choice(GD['bikeshoes'])
p.ride_socks_type = random.choice(GD['socks'])
p.ride_socks_length = random.randrange(4)
p.ride_jersey = random.choice(GD['jerseys'])
p.bike_wheel_rear, p.bike_wheel_front = random.choice(GD['wheels'])
p.bike_frame = random.choice(list(GD['bikeframes'].keys()))
p.run_shirt_type = random.choice(GD['runshirts'])
p.run_shorts_type = random.choice(GD['runshorts'])
p.run_shoes_type = random.choice(GD['runshoes'])
def random_body(p, random_gender=False):
if random_gender:
p.is_male = bool(random.getrandbits(1))
p.hair_type = random.choice(GD['hair_types'])
p.hair_colour = random.randrange(5)
if p.is_male:
p.body_type = random.choice(GD['body_types_male'])
p.facial_hair_type = random.choice(GD['facial_hair_types'])
p.facial_hair_colour = random.randrange(5)
else:
p.body_type = random.choice(GD['body_types_female'])
@app.route('/api/profiles', methods=['GET'])
def api_profiles():
args = request.args.getlist('id')
profiles = profile_pb2.PlayerProfiles()
for i in args:
p_id = int(i)
if p_id > 10000000:
ghostId = math.floor(p_id / 10000000)
player_id = p_id - ghostId * 10000000
p = profiles.profiles.add()
profile_file = '%s/%s/profile.bin' % (STORAGE_DIR, player_id)
if os.path.isfile(profile_file):
with open(profile_file, 'rb') as fd:
p.ParseFromString(fd.read())
p.id = p_id
p.first_name = ''
try: # profile can be requested after ghost is deleted
p.last_name = time_since(global_ghosts[player_id].play[ghostId-1].date)
except:
p.last_name = 'Ghost'
p.country_code = 0
random_equipment(p)
if GHOST_PROFILE:
for item in ['is_male', 'country_code', 'bike_frame', 'bike_frame_colour', 'bike_wheel_front', 'bike_wheel_rear', 'glasses_type',
'ride_jersey', 'ride_helmet_type', 'ride_shoes_type', 'ride_socks_type', 'run_shirt_type', 'run_shorts_type', 'run_shoes_type']:
if item in GHOST_PROFILE:
setattr(p, item, GHOST_PROFILE[item])
if 'random_body' in GHOST_PROFILE and GHOST_PROFILE['random_body']:
random_body(p, 'is_male' not in GHOST_PROFILE)
elif p_id > 9000000:
p = profiles.profiles.add()
p.id = p_id
p.last_name = 'Bookmark'
p.country_code = 0
else:
if p_id in global_pace_partners.keys():
profile = global_pace_partners[p_id].profile
elif p_id in global_bots.keys():
profile = global_bots[p_id].profile
else:
profile = profile_pb2.PlayerProfile()
profile_file = '%s/%s/profile.bin' % (STORAGE_DIR, p_id)
if os.path.isfile(profile_file):
with open(profile_file, 'rb') as fd:
profile.ParseFromString(fd.read())
else:
profile.id = p_id
profiles.profiles.append(profile)
return profiles.SerializeToString(), 200
@app.route('/api/player-playbacks/player/playback', methods=['POST'])
@jwt_to_session_cookie
@login_required
def player_playbacks_player_playback():
pb_dir = '%s/playbacks' % STORAGE_DIR
if not make_dir(pb_dir):
return '', 400
stream = request.stream.read()
pb = playback_pb2.PlaybackData()
pb.ParseFromString(stream)
if pb.time == 0:
return '', 200
new_uuid = str(uuid.uuid4())
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)
db.session.add(new_pb)
db.session.commit()
with open('%s/%s.playback' % (pb_dir, new_uuid), 'wb') as f:
f.write(stream)
return new_uuid, 201
@app.route('/api/player-playbacks/player/<player_id>/playbacks/<segment_id>/<option>', methods=['GET'])
@jwt_to_session_cookie
@login_required
def player_playbacks_player_playbacks(player_id, segment_id, option):
if player_id == 'me':
player_id = current_user.player_id
segment_id = int(segment_id)
after = request.args.get('after')
before = request.args.get('before')
pb_type = playback_pb2.PlaybackType.Value(request.args.get('type'))
query = "SELECT * FROM playback WHERE player_id = :p AND segment_id = :s AND type = :t"
args = {"p": player_id, "s": segment_id, "t": pb_type}
if after != '18446744065933551616' and not ALL_TIME_LEADERBOARDS:
query += " AND world_time > :a"
args.update({"a": after})
if before != '0':
query += " AND world_time < :b"
args.update({"b": before})
if option == 'pr':
query += " ORDER BY time"
elif option == 'latest':
query += " ORDER BY world_time DESC"
row = db.session.execute(sqlalchemy.text(query), args).first()
if not row:
return '', 200
pbr = playback_pb2.PlaybackMetadata()
pbr.uuid = row.uuid
pbr.segment_id = row.segment_id
pbr.time = row.time
pbr.world_time = row.world_time
pbr.url = 'https://cdn.zwift.com/player-playback/playbacks/%s.playback' % row.uuid
if pb_type:
pbr.type = pb_type
return pbr.SerializeToString(), 200
@app.route('/player-playback/playbacks/<path:filename>')
def player_playback_playbacks(filename):
return send_from_directory('%s/playbacks' % STORAGE_DIR, filename)
def strava_upload(player_id, activity):
profile_dir = '%s/%s' % (STORAGE_DIR, player_id)
strava_token = '%s/strava_token.txt' % profile_dir
if not os.path.exists(strava_token):
logger.info("strava_token.txt missing, skip Strava activity update")
return
strava = Client()
try:
with open(strava_token, 'r') as f:
client_id = f.readline().rstrip('\r\n')
client_secret = f.readline().rstrip('\r\n')
strava.access_token = f.readline().rstrip('\r\n')
refresh_token = f.readline().rstrip('\r\n')
expires_at = f.readline().rstrip('\r\n')
except Exception as exc:
logger.warning("Failed to read %s. Skipping Strava upload attempt: %s" % (strava_token, repr(exc)))
return
try:
if time.time() > int(expires_at):
refresh_response = strava.refresh_access_token(client_id=int(client_id), client_secret=client_secret,
refresh_token=refresh_token)
with open(strava_token, 'w') as f:
f.write(client_id + '\n')
f.write(client_secret + '\n')
f.write(refresh_response['access_token'] + '\n')
f.write(refresh_response['refresh_token'] + '\n')
f.write(str(refresh_response['expires_at']) + '\n')
except Exception as exc:
logger.warning("Failed to refresh token. Skipping Strava upload attempt: %s" % repr(exc))
return
try:
# See if there's internet to upload to Strava
strava.upload_activity(BytesIO(activity.fit), data_type='fit', name=activity.name)
# XXX: assume the upload succeeds on strava's end. not checking on it.
except Exception as exc:
logger.warning("Strava upload failed. No internet? %s" % repr(exc))
def garmin_upload(player_id, activity):
try:
import garth
except ImportError as exc:
logger.warning("garth is not installed. Skipping Garmin upload attempt: %s" % repr(exc))
return
garth.configure(domain=GARMIN_DOMAIN)
tokens_dir = '%s/%s/garth' % (STORAGE_DIR, player_id)
try:
garth.resume(tokens_dir)
if garth.client.oauth2_token.expired:
garth.client.refresh_oauth2()
garth.save(tokens_dir)
except:
garmin_credentials = '%s/%s/garmin_credentials.bin' % (STORAGE_DIR, player_id)
if not os.path.exists(garmin_credentials):
logger.info("garmin_credentials.bin missing, skip Garmin activity update")
return
username, password = decrypt_credentials(garmin_credentials)
try:
garth.login(username, password)
garth.save(tokens_dir)
except Exception as exc:
logger.warning("Garmin login failed: %s" % repr(exc))
return
try:
requests.post('https://connectapi.%s/upload-service/upload' % GARMIN_DOMAIN,
files={"file": (activity.fit_filename, BytesIO(activity.fit))},
headers={'authorization': str(garth.client.oauth2_token)})
except Exception as exc:
logger.warning("Garmin upload failed. No internet? %s" % repr(exc))
def runalyze_upload(player_id, activity):
runalyze_token = '%s/%s/runalyze_token.txt' % (STORAGE_DIR, player_id)
if not os.path.exists(runalyze_token):
logger.info("runalyze_token.txt missing, skip Runalyze activity update")
return
try:
with open(runalyze_token, 'r') as f:
runtoken = f.readline().rstrip('\r\n')
except Exception as exc:
logger.warning("Failed to read %s. Skipping Runalyze upload attempt: %s" % (runalyze_token, repr(exc)))
return
try:
r = requests.post("https://runalyze.com/api/v1/activities/uploads",
files={'file': BytesIO(activity.fit)}, headers={"token": runtoken})
logger.info(r.text)
except Exception as exc:
logger.warning("Runalyze upload failed. No internet? %s" % repr(exc))
def intervals_upload(player_id, activity):
intervals_credentials = '%s/%s/intervals_credentials.bin' % (STORAGE_DIR, player_id)
if not os.path.exists(intervals_credentials):
logger.info("intervals_credentials.bin missing, skip Intervals.icu activity update")
return
athlete_id, api_key = decrypt_credentials(intervals_credentials)
try:
from requests.auth import HTTPBasicAuth
url = 'http://intervals.icu/api/v1/athlete/%s/activities?name=%s' % (athlete_id, activity.name)
requests.post(url, files = {"file": BytesIO(activity.fit)}, auth = HTTPBasicAuth('API_KEY', api_key))
except Exception as exc:
logger.warning("Intervals.icu upload failed. No internet? %s" % repr(exc))
def zwift_upload(player_id, activity):
zwift_credentials = '%s/%s/zwift_credentials.bin' % (STORAGE_DIR, player_id)
if not os.path.exists(zwift_credentials):
logger.info("zwift_credentials.bin missing, skip Zwift activity update")
return
username, password = decrypt_credentials(zwift_credentials)
try:
session = requests.session()
access_token, refresh_token = online_sync.login(session, username, password)
activity.player_id = online_sync.get_player_id(session, access_token)
new_activity = activity_pb2.Activity()
new_activity.CopyFrom(activity)
new_activity.ClearField('id')
new_activity.ClearField('fit')
activity.id = online_sync.create_activity(session, access_token, new_activity)
online_sync.upload_activity(session, access_token, activity)
online_sync.logout(session, refresh_token)
except Exception as exc:
logger.warning("Zwift upload failed. No internet? %s" % repr(exc))
def moving_average(iterable, n):
it = iter(iterable)
d = deque(islice(it, n))
s = sum(d)
for elem in it:
s += elem - d.popleft()
d.append(elem)
yield s // n
def create_power_curve(player_id, fit_file):
try:
power_values = []
timestamp = int(time.time())
with fitdecode.FitReader(fit_file) as fit:
for frame in fit:
if frame.frame_type == fitdecode.FIT_FRAME_DATA:
if frame.name == 'record':
p = frame.get_value('power')
if p is not None: power_values.append(int(p))
elif frame.name == 'activity':
t = frame.get_value('timestamp')
if t is not None: timestamp = int(t.timestamp())
if power_values:
for t in [5, 60, 300, 1200]:
averages = list(moving_average(power_values, t))
if averages:
power = max(averages)
profile = get_partial_profile(player_id)
power_wkg = round(power / (profile.weight_in_grams / 1000), 2)
power_curve = PowerCurve(player_id=player_id, time=str(t), power=power, power_wkg=power_wkg, timestamp=timestamp)
db.session.add(power_curve)
db.session.commit()
except Exception as exc:
logger.warning('create_power_curve: %s' % repr(exc))
def save_ghost(player_id, name):
if not player_id in global_ghosts.keys(): return
ghosts = global_ghosts[player_id]
if len(ghosts.rec.states) > 0:
state = ghosts.rec.states[0]
folder = '%s/%s/ghosts/%s/' % (STORAGE_DIR, player_id, get_course(state))
if state.route: folder += str(state.route)
else:
folder += str(road_id(state))
if not is_forward(state): folder += '/reverse'
if not make_dir(folder):
return
ghosts.rec.player_id = player_id
f = '%s/%s-%s.bin' % (folder, datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d-%H-%M-%S"), name)
with open(f, 'wb') as fd:
fd.write(ghosts.rec.SerializeToString())
def activity_uploads(player_id, activity):
strava_upload(player_id, activity)
garmin_upload(player_id, activity)
runalyze_upload(player_id, activity)
intervals_upload(player_id, activity)
zwift_upload(player_id, activity)
@app.route('/api/profiles/<int:player_id>/activities/<int:activity_id>', methods=['PUT', 'DELETE'])
@jwt_to_session_cookie
@login_required
def api_profiles_activities_id(player_id, activity_id):
if request.headers['Source'] == "zwift-companion":
return '', 400 # edit from ZCA is not supported yet
if not request.stream:
return '', 400
if current_user.player_id != player_id:
return '', 401
if request.method == 'DELETE':
Activity.query.filter_by(id=activity_id).delete()
db.session.commit()
logout_player(player_id)
return 'true', 200
stream = request.stream.read()
activity = activity_pb2.Activity()
activity.ParseFromString(stream)
update_protobuf_in_db(Activity, activity, activity_id, ['fit'], ['power_zones'])
response = '{"id":%s}' % activity_id
if request.args.get('upload-to-strava') != 'true':
return response, 200
if activity.distanceInMeters < 1: # Zwift saves the current activity when joining events (may have small distance even if didn't move)
Activity.query.filter_by(id=activity_id).delete()
db.session.commit()
logout_player(player_id)
return response, 200
create_power_curve(player_id, BytesIO(activity.fit))
save_fit(player_id, '%s - %s' % (activity_id, activity.fit_filename), activity.fit)
if current_user.enable_ghosts:
save_ghost(player_id, quote(activity.name, safe=' '))
if activity.sport == profile_pb2.Sport.CYCLING and activity.distanceInMeters >= 2000:
update_streaks(player_id, activity)
# For using with upload_activity
with open('%s/%s/last_activity.bin' % (STORAGE_DIR, player_id), 'wb') as f:
f.write(stream)
# Upload in separate thread to avoid client freezing if it takes longer than expected
upload = threading.Thread(target=activity_uploads, args=(player_id, activity))
upload.start()
logout_player(player_id)
return response, 200
@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+
@jwt_to_session_cookie
@login_required
def api_profiles_activities_rideon(receiving_player_id):
sending_player_id = request.json['profileId']
profile = get_partial_profile(sending_player_id)
player_update = udp_node_msgs_pb2.WorldAttribute()
player_update.server_realm = udp_node_msgs_pb2.ZofflineConstants.RealmID
player_update.wa_type = udp_node_msgs_pb2.WA_TYPE.WAT_RIDE_ON
player_update.world_time_born = world_time()
player_update.world_time_expire = player_update.world_time_born + 9890
player_update.timestamp = int(time.time() * 1000000)
ride_on = udp_node_msgs_pb2.RideOn()
ride_on.player_id = int(sending_player_id)
ride_on.to_player_id = int(receiving_player_id)
ride_on.firstName = profile.first_name
ride_on.lastName = profile.last_name
ride_on.countryCode = profile.country_code
player_update.payload = ride_on.SerializeToString()
enqueue_player_update(receiving_player_id, player_update.SerializeToString())
receiver = get_partial_profile(receiving_player_id)
message = 'Ride on ' + receiver.first_name + ' ' + receiver.last_name + '!'
discord.send_message(message, sending_player_id)
return '{}', 200
def stime_to_timestamp(stime):
try:
return int(datetime.datetime.strptime(stime, '%Y-%m-%dT%H:%M:%S%z').timestamp())
except:
return 0
def create_zca_notification(player_id, private_event, organizer):
orm_not = Notification(event_id=private_event['id'], player_id=player_id, json='')
db.session.add(orm_not)
db.session.commit()
argString0 = json.dumps({"eventId":private_event['id'],"eventStartDate":stime_to_timestamp(private_event['eventStart']),
"otherInviteeCount":len(private_event['invitedProfileIds'])})
n = { "activity": None, "argLong0": 0, "argLong1": 0, "argString0": argString0,
"createdOn": str_timestamp(int(time.time()*1000)),
"fromProfile": {
"firstName": organizer["firstName"],
"id": organizer["id"],
"imageSrc": organizer["imageSrc"],
"imageSrcLarge": organizer["imageSrc"],
"lastName": organizer["lastName"],
"publicId": "283b140f-91d2-4882-bd8e-e4194ddf7128", #todo, hope not used
"socialFacts": {
"favoriteOfLoggedInPlayer": True, #todo
"followeeStatusOfLoggedInPlayer": "IS_FOLLOWING", #todo
"followerStatusOfLoggedInPlayer": "IS_FOLLOWING" #todo
}
},
"id": orm_not.id, "lastModified": None, "read": False, "readDate": None,
"type": "PRIVATE_EVENT_INVITE"
}
orm_not.json = json.dumps(n)
db.session.commit()
@app.route('/api/notifications', methods=['GET'])
@jwt_to_session_cookie
@login_required
def api_notifications():
ret_notifications = []
for row in Notification.query.filter_by(player_id=current_user.player_id):
if json.loads(json.loads(row.json)["argString0"])["eventStartDate"] > time.time() - 1800:
ret_notifications.append(row.json)
return jsonify(ret_notifications)
@app.route('/api/notifications/<int:notif_id>', methods=['PUT'])
@jwt_to_session_cookie
@login_required
def api_notifications_put(notif_id):
for orm_not in Notification.query.filter_by(id=notif_id):
n = json.loads(orm_not.json)
n["read"] = request.json['read']
n["readDate"] = request.json['readDate']
n["lastModified"] = n["readDate"]
orm_not.json = json.dumps(n)
db.session.commit()
return '', 204
glb_private_events = {} #cache of actual PrivateEvent(db.Model)
def ActualPrivateEvents():
if len(glb_private_events) == 0:
for row in db.session.query(PrivateEvent).order_by(PrivateEvent.id.desc()).limit(100):
if len(row.json):
glb_private_events[row.id] = json.loads(row.json)
return glb_private_events
@app.route('/api/private_event/<int:meetup_id>', methods=['DELETE'])
@jwt_to_session_cookie
@login_required
def api_private_event_remove(meetup_id):
ActualPrivateEvents().pop(meetup_id)
PrivateEvent.query.filter_by(id=meetup_id).delete()
Notification.query.filter_by(event_id=meetup_id).delete()
db.session.commit()
return '', 200
def edit_private_event(player_id, meetup_id, decision):
ape = ActualPrivateEvents()
if meetup_id in ape.keys():
e = ape[meetup_id]
for i in e['eventInvites']:
if i['invitedProfile']['id'] == player_id:
i['status'] = decision
orm_event = db.session.get(PrivateEvent, meetup_id)
orm_event.json = json.dumps(e)
db.session.commit()
return '', 204
@app.route('/api/private_event/<int:meetup_id>/accept', methods=['PUT'])
@jwt_to_session_cookie
@login_required
def api_private_event_accept(meetup_id):
return edit_private_event(current_user.player_id, meetup_id, 'ACCEPTED')
@app.route('/api/private_event/<int:meetup_id>/reject', methods=['PUT'])
@jwt_to_session_cookie
@login_required
def api_private_event_reject(meetup_id):
return edit_private_event(current_user.player_id, meetup_id, 'REJECTED')
@app.route('/api/private_event/<int:meetup_id>', methods=['PUT'])
@jwt_to_session_cookie
@login_required
def api_private_event_edit(meetup_id):
str_pe = request.stream.read()
json_pe = json.loads(str_pe)
org_json_pe = ActualPrivateEvents()[meetup_id]
for f in ('culling', 'distanceInMeters', 'durationInSeconds', 'eventStart', 'invitedProfileIds', 'laps', 'routeId', 'rubberbanding', 'showResults', 'sport', 'workoutHash'):
org_json_pe[f] = json_pe[f]
org_json_pe['updateDate'] = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
newEventInvites = []
newEventInviteeIds = []
for i in org_json_pe['eventInvites']:
profile_id = i['invitedProfile']['id']
if profile_id == org_json_pe['organizerProfileId'] or profile_id in json_pe['invitedProfileIds']:
newEventInvites.append(i)
newEventInviteeIds.append(profile_id)
player_update = create_wa_event_invites(org_json_pe)
for peer_id in json_pe['invitedProfileIds']:
if not peer_id in newEventInviteeIds:
create_zca_notification(peer_id, org_json_pe, newEventInvites[0]["invitedProfile"])
player_update.rel_id = peer_id
enqueue_player_update(peer_id, player_update.SerializeToString())
p_partial_profile = get_partial_profile(peer_id)
newEventInvites.append({"invitedProfile": p_partial_profile.to_json(), "status": "PENDING"})
org_json_pe['eventInvites'] = newEventInvites
db.session.get(PrivateEvent, meetup_id).json = json.dumps(org_json_pe)
db.session.commit()
for orm_not in Notification.query.filter_by(event_id=meetup_id):
n = json.loads(orm_not.json)
n['read'] = False
n['readDate'] = None
n['lastModified'] = org_json_pe['updateDate']
orm_not.json = json.dumps(n)
db.session.commit()
return jsonify({"id":meetup_id})
def create_wa_event_invites(json_pe):
pe = events_pb2.Event()
player_update = udp_node_msgs_pb2.WorldAttribute()
player_update.server_realm = udp_node_msgs_pb2.ZofflineConstants.RealmID
player_update.wa_type = udp_node_msgs_pb2.WA_TYPE.WAT_INV_W
player_update.world_time_born = world_time()
player_update.world_time_expire = world_time() + 60000
player_update.wa_f12 = 1
player_update.timestamp = int(time.time()*1000000)
pe.id = json_pe['id']
pe.server_realm = udp_node_msgs_pb2.ZofflineConstants.RealmID
pe.name = json_pe['name']
if 'description' in json_pe:
pe.description = json_pe['description']
pe.eventStart = stime_to_timestamp(json_pe['eventStart'])*1000
pe.distanceInMeters = json_pe['distanceInMeters']
pe.laps = json_pe['laps']
if 'imageUrl' in json_pe:
pe.imageUrl = json_pe['imageUrl']
pe.durationInSeconds = json_pe['durationInSeconds']
pe.route_id = json_pe['routeId']
#{"rubberbanding":true,"showResults":false,"workoutHash":0} todo_pe
pe.visible = True
pe.jerseyHash = 0
pe.sport = sport_from_str(json_pe['sport'])
#pe.uint64 e_f23 = 23; =0
pe.eventType = events_pb2.EventType.EFONDO
if 'culling' in json_pe:
if json_pe['culling']:
pe.eventType = events_pb2.EventType.RACE
#pe.uint64 e_f25 = 25; =0
pe.e_f27 = 2 #<=4, ENUM? saw = 2
#pe.bool overrideMapPreferences = 28; =0
#pe.bool invisibleToNonParticipants = 29; =0 todo_pe
pe.lateJoinInMinutes = 30 #todo_pe
#pe.course_id = 1 #todo_pe =f(json_pe['routeId']) ???
player_update.payload = pe.SerializeToString()
return player_update
@app.route('/api/private_event', methods=['POST'])
@jwt_to_session_cookie
@login_required
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}
str_pe = request.stream.read()
json_pe = json.loads(str_pe)
db_pe = PrivateEvent(json=str_pe)
db.session.add(db_pe)
db.session.commit()
json_pe['id'] = db_pe.id
ev_sg_id = db_pe.id
json_pe['eventSubgroupId'] = ev_sg_id
json_pe['name'] = "Route #%s" % json_pe['routeId'] #todo: more readable
json_pe['acceptedTotalCount'] = len(json_pe['invitedProfileIds']) #todo: real count
json_pe['acceptedFolloweeCount'] = len(json_pe['invitedProfileIds']) + 1 #todo: real count
json_pe['invitedTotalCount'] = len(json_pe['invitedProfileIds']) + 1
partial_profile = get_partial_profile(current_user.player_id)
json_pe['organizerProfileId'] = current_user.player_id
json_pe['organizerId'] = current_user.player_id
json_pe['startLocation'] = 1 #todo_pe
json_pe['allowsLateJoin'] = True #todo_pe
json_pe['organizerFirstName'] = partial_profile.first_name
json_pe['organizerLastName'] = partial_profile.last_name
json_pe['updateDate'] = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
json_pe['organizerImageUrl'] = imageSrc(current_user.player_id)
eventInvites = [{"invitedProfile": partial_profile.to_json(), "status": "ACCEPTED"}]
create_event_wat(ev_sg_id, udp_node_msgs_pb2.WA_TYPE.WAT_JOIN_E, events_pb2.PlayerJoinedEvent(), online.keys())
player_update = create_wa_event_invites(json_pe)
enqueue_player_update(current_user.player_id, player_update.SerializeToString())
for peer_id in json_pe['invitedProfileIds']:
create_zca_notification(peer_id, json_pe, eventInvites[0]["invitedProfile"])
player_update.rel_id = peer_id
enqueue_player_update(peer_id, player_update.SerializeToString())
p_partial_profile = get_partial_profile(peer_id)
eventInvites.append({"invitedProfile": p_partial_profile.to_json(), "status": "PENDING"})
json_pe['eventInvites'] = eventInvites
ActualPrivateEvents()[db_pe.id] = json_pe
db_pe.json = json.dumps(json_pe)
db.session.commit() #update db_pe
return jsonify({"id":db_pe.id}), 201
def clone_and_append_social(player_id, private_event):
ret = deepcopy(private_event)
status = 'PENDING'
for i in ret['eventInvites']:
p = i['invitedProfile']
#todo: strict social
if p['id'] == player_id:
p['socialFacts'] = {"followerStatusOfLoggedInPlayer":"SELF","isFavoriteOfLoggedInPlayer":False}
status = i['status']
else:
p['socialFacts'] = {"followerStatusOfLoggedInPlayer":"IS_FOLLOWING","isFavoriteOfLoggedInPlayer":True}
ret['inviteStatus'] = status
return ret
def jsonPrivateEventFeedToProtobuf(jfeed):
ret = events_pb2.PrivateEventFeedListProto()
for jpef in jfeed:
pef = ret.pef.add()
pef.event_id = jpef['id']
pef.sport = sport_from_str(jpef['sport'])
pef.eventSubgroupStart = stime_to_timestamp(jpef['eventStart'])*1000
pef.route_id = jpef['routeId']
pef.durationInSeconds = jpef['durationInSeconds']
pef.distanceInMeters = jpef['distanceInMeters']
pef.answeredCount = 1 #todo
pef.invitedTotalCount = jpef['invitedTotalCount']
pef.acceptedFolloweeCount = jpef['acceptedFolloweeCount']
pef.acceptedTotalCount = jpef['acceptedTotalCount']
if jpef['organizerImageUrl'] is not None:
pef.organizerImageUrl = jpef['organizerImageUrl']
pef.organizerProfileId = jpef['organizerProfileId']
pef.organizerFirstName = jpef['organizerFirstName']
pef.organizerLastName = jpef['organizerLastName']
pef.updateDate = stime_to_timestamp(jpef['updateDate'])*1000
pef.subgroupId = jpef['eventSubgroupId']
pef.laps = jpef['laps']
pef.rubberbanding = jpef['rubberbanding']
return ret
@app.route('/api/private_event/feed', methods=['GET'])
@jwt_to_session_cookie
@login_required
def api_private_event_feed():
start_date = int(request.args.get('start_date')) / 1000
if start_date == -1800: start_date += time.time() # first ZA request has start_date=-1800000
past_events = request.args.get('organizer_only_past_events') == 'true'
ret = []
for pe in ActualPrivateEvents().values():
if ((current_user.player_id in pe['invitedProfileIds'] or current_user.player_id == pe['organizerProfileId']) \
and stime_to_timestamp(pe['eventStart']) > start_date) \
or (past_events and pe['organizerProfileId'] == current_user.player_id):
ret.append(clone_and_append_social(current_user.player_id, pe))
if request.headers['Accept'] == 'application/json':
return jsonify(ret)
return jsonPrivateEventFeedToProtobuf(ret).SerializeToString(), 200
def jsonPrivateEventToProtobuf(je):
ret = events_pb2.PrivateEventProto()
ret.id = je['id']
ret.sport = sport_from_str(je['sport'])
ret.eventStart = stime_to_timestamp(je['eventStart'])*1000
ret.routeId = je['routeId']
ret.startLocation = je['startLocation']
ret.durationInSeconds = je['durationInSeconds']
ret.distanceInMeters = je['distanceInMeters']
if 'description' in je:
ret.description = je['description']
ret.workoutHash = je['workoutHash']
ret.organizerId = je['organizerProfileId']
for jinv in je['eventInvites']:
jp = jinv['invitedProfile']
inv = ret.eventInvites.add()
inv.profile.player_id = jp['id']
inv.profile.firstName = jp['firstName']
inv.profile.lastName = jp['lastName']
if jp['imageSrc']:
inv.profile.imageSrc = jp['imageSrc']
inv.profile.enrolledZwiftAcademy = jp['enrolledZwiftAcademy']
inv.profile.male = jp['male']
inv.profile.player_type = profile_pb2.PlayerType.Value(jp['playerType'])
inv.profile.event_category = int(jp['male'])
inv.status = events_pb2.EventInviteStatus.Value(jinv['status'])
ret.showResults = je['showResults']
ret.laps = je['laps']
ret.rubberbanding = je['rubberbanding']
return ret
@app.route('/api/private_event/<int:event_id>', methods=['GET'])
@jwt_to_session_cookie
@login_required
def api_private_event_id(event_id):
ret = clone_and_append_social(current_user.player_id, ActualPrivateEvents()[event_id])
if request.headers['Accept'] == 'application/json':
return jsonify(ret)
return jsonPrivateEventToProtobuf(ret).SerializeToString(), 200
@app.route('/api/private_event/entitlement', methods=['GET'])
def api_private_event_entitlement():
return jsonify({"entitled": True})
@app.route('/relay/events/subgroups/<int:meetup_id>/late-join', methods=['GET'])
@jwt_to_session_cookie
@login_required
def relay_events_subgroups_id_late_join(meetup_id):
ape = ActualPrivateEvents()
if meetup_id in ape.keys():
event = jsonPrivateEventToProtobuf(ape[meetup_id])
leader = None
if event.organizerId in online and online[event.organizerId].groupId == meetup_id and event.organizerId != current_user.player_id:
leader = event.organizerId
else:
for player_id in online.keys():
if online[player_id].groupId == meetup_id and player_id != current_user.player_id:
leader = player_id
break
if leader is not None:
state = online[leader]
lj = events_pb2.LateJoinInformation()
lj.road_id = road_id(state)
lj.road_time = (state.roadTime - 5000) / 1000000
lj.is_forward = is_forward(state)
lj.organizerId = leader
lj.lj_f5 = 0
lj.lj_f6 = 0
lj.lj_f7 = 0
return lj.SerializeToString(), 200
return '', 200
def get_week_range(dt):
d = (dt - datetime.timedelta(days = dt.weekday())).replace(hour=0, minute=0, second=0, microsecond=0)
first = d
last = d + datetime.timedelta(days=6, hours=23, minutes=59, seconds=59)
return first, last
def get_month_range(dt):
num_days = calendar.monthrange(dt.year, dt.month)[1]
first = datetime.datetime(dt.year, dt.month, 1)
last = datetime.datetime(dt.year, dt.month, num_days, 23, 59, 59)
return first, last
def fill_in_goal_progress(goal, player_id):
utc_now = datetime.datetime.now(datetime.timezone.utc)
if goal.periodicity == 0: # weekly
first_dt, last_dt = get_week_range(utc_now)
else: # monthly
first_dt, last_dt = get_month_range(utc_now)
common_sql = """FROM activity
WHERE player_id = :p AND sport = :s
AND strftime('%s', start_date) >= strftime('%s', :f)
AND strftime('%s', start_date) <= strftime('%s', :l)"""
args = {"p": player_id, "s": goal.sport, "f": first_dt, "l": last_dt}
if goal.type == goal_pb2.GoalType.DISTANCE:
distance = db.session.execute(sqlalchemy.text('SELECT SUM(distanceInMeters) %s' % common_sql), args).first()[0]
if distance:
goal.actual_distance = distance
goal.actual_duration = distance
else:
goal.actual_distance = 0.0
goal.actual_duration = 0.0
else: # duration
duration = db.session.execute(sqlalchemy.text('SELECT SUM(julianday(end_date)-julianday(start_date)) %s' % common_sql), args).first()[0]
if duration:
goal.actual_duration = duration*1440 # convert from days to minutes
goal.actual_distance = duration*1440
else:
goal.actual_duration = 0.0
goal.actual_distance = 0.0
def set_goal_end_date_now(goal):
utc_now = datetime.datetime.now(datetime.timezone.utc)
if goal.periodicity == 0: # weekly
goal.period_end_date = int(get_week_range(utc_now)[1].timestamp()*1000)
else: # monthly
goal.period_end_date = int(get_month_range(utc_now)[1].timestamp()*1000)
def str_sport(int_sport):
if int_sport == 1:
return "RUNNING"
return "CYCLING"
def sport_from_str(str_sport):
if str_sport == 'CYCLING':
return 0
return 1 #running
def str_timestamp(ts):
if ts == None:
return None
else:
sec = int(ts/1000)
ms = ts % 1000
return datetime.datetime.fromtimestamp(sec, datetime.timezone.utc).strftime('%Y-%m-%dT%H:%M:%S.') + str(ms).zfill(3) + "+0000"
def str_timestamp_json(ts):
if ts == 0:
return None
else:
return str_timestamp(ts)
def goalProtobufToJson(goal):
return {"id":goal.id,"profileId":goal.player_id,"sport":str_sport(goal.sport),"name":goal.name,"type":int(goal.type),"periodicity":int(goal.periodicity),
"targetDistanceInMeters":goal.target_distance,"targetDurationInMinutes":goal.target_duration,"actualDistanceInMeters":goal.actual_distance,
"actualDurationInMinutes":goal.actual_duration,"createdOn":str_timestamp_json(goal.created_on),
"periodEndDate":str_timestamp_json(goal.period_end_date),"status":int(goal.status),"timezone":goal.timezone}
def goalJsonToProtobuf(json_goal):
goal = goal_pb2.Goal()
goal.sport = sport_from_str(json_goal['sport'])
goal.id = json_goal['id']
goal.name = json_goal['name']
goal.periodicity = int(json_goal['periodicity'])
goal.type = int(json_goal['type'])
goal.status = goal_pb2.GoalStatus.ACTIVE
goal.target_distance = json_goal['targetDistanceInMeters']
goal.target_duration = json_goal['targetDurationInMinutes']
goal.actual_distance = json_goal['actualDistanceInMeters']
goal.actual_duration = json_goal['actualDurationInMinutes']
goal.player_id = json_goal['profileId']
return goal
@app.route('/api/profiles/<int:player_id>/goals/<int:goal_id>', methods=['PUT'])
@jwt_to_session_cookie
@login_required
def api_profiles_goals_put(player_id, goal_id):
if player_id != current_user.player_id:
return '', 401
if not request.stream:
return '', 400
str_goal = request.stream.read()
json_goal = json.loads(str_goal)
goal = goalJsonToProtobuf(json_goal)
update_protobuf_in_db(Goal, goal, goal.id)
return jsonify(json_goal)
def select_protobuf_goals(player_id, limit):
goals = goal_pb2.Goals()
if limit > 0:
stmt = sqlalchemy.text("SELECT * FROM goal WHERE player_id = :p LIMIT :l")
rows = db.session.execute(stmt, {"p": player_id, "l": limit}).mappings()
need_update = list()
for row in rows:
goal = goals.goals.add()
row_to_protobuf(row, goal)
end_dt = datetime.datetime.fromtimestamp(goal.period_end_date / 1000, datetime.timezone.utc)
if end_dt < datetime.datetime.now(datetime.timezone.utc):
need_update.append(goal)
fill_in_goal_progress(goal, player_id)
for goal in need_update:
set_goal_end_date_now(goal)
update_protobuf_in_db(Goal, goal, goal.id)
return goals
def convert_goals_to_json(goals):
json_goals = []
for goal in goals.goals:
json_goal = goalProtobufToJson(goal)
json_goals.append(json_goal)
return json_goals
@app.route('/api/profiles/<int:player_id>/goals', methods=['GET', 'POST'])
@jwt_to_session_cookie
@login_required
def api_profiles_goals(player_id):
if player_id != current_user.player_id:
return '', 401
if request.method == 'POST':
if not request.stream:
return '', 400
if request.headers['Content-Type'] == 'application/x-protobuf-lite':
goal = goal_pb2.Goal()
goal.ParseFromString(request.stream.read())
else:
str_goal = request.stream.read()
json_goal = json.loads(str_goal)
goal = goalJsonToProtobuf(json_goal)
goal.created_on = int(time.time()*1000)
set_goal_end_date_now(goal)
fill_in_goal_progress(goal, player_id)
goal.id = insert_protobuf_into_db(Goal, goal)
if request.headers['Accept'] == 'application/json':
return jsonify(goalProtobufToJson(goal))
else:
return goal.SerializeToString(), 200
# request.method == 'GET'
goals = select_protobuf_goals(player_id, 100)
if request.headers['Accept'] == 'application/json':
json_goals = convert_goals_to_json(goals)
return jsonify(json_goals) # json for ZCA
else:
return goals.SerializeToString(), 200 # protobuf for ZG
@app.route('/api/profiles/<int:player_id>/goals/<int:goal_id>', methods=['DELETE'])
@jwt_to_session_cookie
@login_required
def api_profiles_goals_id(player_id, goal_id):
if player_id != current_user.player_id:
return '', 401
db.session.execute(sqlalchemy.text("DELETE FROM goal WHERE id = :i"), {"i": goal_id})
db.session.commit()
return '', 200
@app.route('/api/tcp-config', methods=['GET'])
@app.route('/relay/tcp-config', methods=['GET'])
def api_tcp_config():
infos = per_session_info_pb2.TcpConfig()
info = infos.nodes.add()
info.ip = request.remote_addr if request.remote_addr in ['127.0.0.1', '::1'] else server_ip
info.port = 3023
return infos.SerializeToString(), 200
def add_player_to_world(player, course_world, is_pace_partner=False, is_bot=False, is_bookmark=False, name=None):
course_id = get_course(player)
if course_id in course_world.keys():
partial_profile = get_partial_profile(player.id)
online_player = None
if is_pace_partner:
online_player = course_world[course_id].pacer_bots.add()
online_player.route = partial_profile.route
if player.sport == profile_pb2.Sport.CYCLING:
online_player.ride_power = player.power
else:
online_player.speed = player.speed
elif is_bot:
online_player = course_world[course_id].others.add()
elif is_bookmark:
online_player = course_world[course_id].pro_players.add()
else: # to be able to join zwifter using new home screen
online_player = course_world[course_id].followees.add()
online_player.id = player.id
online_player.firstName = courses_lookup[course_id] if name else partial_profile.first_name
online_player.lastName = name if name else partial_profile.last_name
online_player.distance = player.distance
online_player.time = player.time
online_player.country_code = partial_profile.country_code
online_player.sport = player.sport
online_player.power = player.power
online_player.x = player.x
online_player.y_altitude = player.y_altitude
online_player.z = player.z
course_world[course_id].zwifters += 1
def relay_worlds_generic(server_realm=None, player_id=None):
# Android client also requests a JSON version
if request.headers['Accept'] == 'application/json':
friends = []
for p_id in online:
profile = get_partial_profile(p_id)
friend = {"playerId": p_id, "firstName": profile.first_name, "lastName": profile.last_name, "male": profile.male, "countryISOCode": profile.country_code,
"totalDistanceInMeters": jsv0(online[p_id], 'distance'), "rideDurationInSeconds": jsv0(online[p_id], 'time'), "playerType": profile.player_type,
"followerStatusOfLoggedInPlayer": "NO_RELATIONSHIP", "rideOnGiven": False, "currentSport": profile_pb2.Sport.Name(jsv0(online[p_id], 'sport')),
"enrolledZwiftAcademy": False, "mapId": 1, "ftp": 100, "runTime10kmInSeconds": 3600}
friends.append(friend)
world = { 'currentDateTime': int(time.time()),
'currentWorldTime': world_time(),
'friendsInWorld': friends,
'mapId': 1,
'name': 'Public Watopia',
'playerCount': len(online),
'worldId': udp_node_msgs_pb2.ZofflineConstants.RealmID
}
if server_realm:
world['worldId'] = server_realm
return jsonify(world)
else:
return jsonify([ world ])
else: # protobuf request
worlds = world_pb2.DropInWorldList()
world = None
course_world = {}
for course in courses_lookup.keys():
world = worlds.worlds.add()
world.id = udp_node_msgs_pb2.ZofflineConstants.RealmID
world.name = 'Public Watopia'
world.course_id = course
world.world_time = world_time()
world.real_time = int(time.time())
world.zwifters = 0
course_world[course] = world
for p_id in online.keys():
player = online[p_id]
add_player_to_world(player, course_world)
for p_id in global_pace_partners.keys():
pace_partner_variables = global_pace_partners[p_id]
pace_partner = pace_partner_variables.route.states[pace_partner_variables.position]
add_player_to_world(pace_partner, course_world, is_pace_partner=True)
for p_id in global_bots.keys():
bot_variables = global_bots[p_id]
bot = bot_variables.route.states[bot_variables.position]
add_player_to_world(bot, course_world, is_bot=True)
if player_id in global_bookmarks.keys():
for bookmark in global_bookmarks[player_id].values():
add_player_to_world(bookmark.state, course_world, is_bookmark=True, name=bookmark.name)
if server_realm:
world.id = server_realm
return world.SerializeToString()
else:
return worlds.SerializeToString()
def load_bookmarks(player_id):
if not player_id in global_bookmarks.keys():
global_bookmarks[player_id] = {}
bookmarks = global_bookmarks[player_id]
bookmarks.clear()
bookmarks_dir = os.path.join(STORAGE_DIR, str(player_id), 'bookmarks')
if os.path.isdir(bookmarks_dir):
i = 1
for (root, dirs, files) in os.walk(bookmarks_dir):
for file in files:
if file.endswith('.bin'):
state = udp_node_msgs_pb2.PlayerState()
try:
with open(os.path.join(root, file), 'rb') as f:
state.ParseFromString(f.read())
except Exception as exc:
logger.warning("load_bookmarks: %s" % repr(exc))
continue
state.id = i + 9000000 + player_id % 1000 * 1000
bookmark = Bookmark()
bookmark.name = file[:-4]
bookmark.state = state
bookmarks[state.id] = bookmark
i += 1
@app.route('/relay/worlds', methods=['GET'])
@app.route('/relay/dropin', methods=['GET']) #zwift::protobuf::DropInWorldList
@jwt_to_session_cookie
@login_required
def relay_worlds():
load_bookmarks(current_user.player_id)
return relay_worlds_generic(player_id=current_user.player_id)
def add_teleport_target(player, targets, is_pace_partner=True, name=None):
partial_profile = get_partial_profile(player.id)
if is_pace_partner:
target = targets.pacer_groups.add()
target.route = partial_profile.route
else:
target = targets.friends.add()
target.route = player.route
target.id = player.id
target.firstName = partial_profile.first_name
target.lastName = name if name else partial_profile.last_name
target.distance = player.distance
target.time = player.time
target.country_code = partial_profile.country_code
target.sport = player.sport
target.power = player.power
target.x = player.x
target.y_altitude = player.y_altitude
target.z = player.z
target.ride_power = player.power
target.speed = player.speed
@app.route('/relay/teleport-targets', methods=['GET'])
@jwt_to_session_cookie
@login_required
def relay_teleport_targets():
course = int(request.args.get('mapRevisionId'))
targets = world_pb2.TeleportTargets()
for p_id in global_pace_partners.keys():
pp = global_pace_partners[p_id]
pace_partner = pp.route.states[pp.position]
if get_course(pace_partner) == course:
add_teleport_target(pace_partner, targets)
for p_id in online.keys():
if p_id != current_user.player_id:
player = online[p_id]
if get_course(player) == course:
add_teleport_target(player, targets, False)
if current_user.player_id in global_bookmarks.keys():
for bookmark in global_bookmarks[current_user.player_id].values():
if get_course(bookmark.state) == course:
add_teleport_target(bookmark.state, targets, False, bookmark.name)
return targets.SerializeToString()
def iterableToJson(it):
if it == None:
return None
ret = []
for i in it:
ret.append(i)
return ret
def convert_event_to_json(event):
esgs = []
for event_cat in event.category:
esgs.append({"id":event_cat.id,"name":event_cat.name,"description":event_cat.description,"label":event_cat.label,
"subgroupLabel":event_cat.name[-1],"rulesId":event_cat.rules_id,"mapId":event_cat.course_id,"routeId":event_cat.route_id,"routeUrl":event_cat.routeUrl,
"jerseyHash":event_cat.jerseyHash,"bikeHash":event_cat.bikeHash,"startLocation":event_cat.startLocation,"invitedLeaders":iterableToJson(event_cat.invitedLeaders),
"invitedSweepers":iterableToJson(event_cat.invitedSweepers),"paceType":event_cat.paceType,"fromPaceValue":event_cat.fromPaceValue,"toPaceValue":event_cat.toPaceValue,
"fieldLimit":None,"registrationStart":str_timestamp_json(event_cat.registrationStart),"registrationEnd":str_timestamp_json(event_cat.registrationEnd),"lineUpStart":str_timestamp_json(event_cat.lineUpStart),
"lineUpEnd":str_timestamp_json(event_cat.lineUpEnd),"eventSubgroupStart":str_timestamp_json(event_cat.eventSubgroupStart),"durationInSeconds":event_cat.durationInSeconds,"laps":event_cat.laps,
"distanceInMeters":event_cat.distanceInMeters,"signedUp":False,"signupStatus":1,"registered":False,"registrationStatus":1,"followeeEntrantCount":0,
"totalEntrantCount":0,"followeeSignedUpCount":0,"totalSignedUpCount":0,"followeeJoinedCount":0,"totalJoinedCount":0,"auxiliaryUrl":"",
"rulesSet":["ALLOWS_LATE_JOIN"],"workoutHash":None,"customUrl":event_cat.customUrl,"overrideMapPreferences":False,
"tags":[""],"lateJoinInMinutes":event_cat.lateJoinInMinutes,"timeTrialOptions":None,"qualificationRuleIds":None,"accessValidationResult":None})
return {"id":event.id,"worldId":event.server_realm,"name":event.name,"description":event.description,"shortName":None,"mapId":event.course_id,
"shortDescription":None,"imageUrl":event.imageUrl,"routeId":event.route_id,"rulesId":event.rules_id,"rulesSet":["ALLOWS_LATE_JOIN"],
"routeUrl":None,"jerseyHash":event.jerseyHash,"bikeHash":None,"visible":event.visible,"overrideMapPreferences":event.overrideMapPreferences,"eventStart":str_timestamp_json(event.eventStart), "tags":[""],
"durationInSeconds":event.durationInSeconds,"distanceInMeters":event.distanceInMeters,"laps":event.laps,"privateEvent":False,"invisibleToNonParticipants":event.invisibleToNonParticipants,
"followeeEntrantCount":0,"totalEntrantCount":0,"followeeSignedUpCount":0,"totalSignedUpCount":0,"followeeJoinedCount":0,"totalJoinedCount":0,
"eventSeries":None,"auxiliaryUrl":"","imageS3Name":None,"imageS3Bucket":None,"sport":str_sport(event.sport),"cullingType":"CULLING_EVERYBODY",
"recurring":True,"recurringOffset":None,"publishRecurring":True,"parentId":None,"type":events_pb2._EVENTTYPEV2.values_by_number[int(event.eventType)].name,
"eventType":events_pb2._EVENTTYPE.values_by_number[int(event.eventType)].name,
"workoutHash":None,"customUrl":"","restricted":False,"unlisted":False,"eventSecret":None,"accessExpression":None,"qualificationRuleIds":None,
"lateJoinInMinutes":event.lateJoinInMinutes,"timeTrialOptions":None,"microserviceName":None,"microserviceExternalResourceId":None,
"microserviceEventVisibility":None, "minGameVersion":None,"recordable":True,"imported":False,"eventTemplateId":None, "eventSubgroups": esgs }
def convert_events_to_json(events):
json_events = []
for e in events.events:
json_event = convert_event_to_json(e)
json_events.append(json_event)
return json_events
def transformPrivateEvents(player_id, max_count, status):
ret = []
if max_count > 0:
for e in ActualPrivateEvents().values():
if stime_to_timestamp(e['eventStart']) > time.time() - 1800:
for i in e['eventInvites']:
if i['invitedProfile']['id'] == player_id:
if i['status'] == status:
e_clone = deepcopy(e)
e_clone['inviteStatus'] = status
ret.append(e_clone)
if len(ret) >= max_count:
return ret
return ret
#todo: followingCount=3&playerSport=all&fetchCampaign=true
@app.route('/relay/worlds/<int:server_realm>/aggregate/mobile', methods=['GET'])
@jwt_to_session_cookie
@login_required
def relay_worlds_id_aggregate_mobile(server_realm):
goalCount = int(request.args.get('goalCount'))
goals = select_protobuf_goals(current_user.player_id, goalCount)
json_goals = convert_goals_to_json(goals)
activityCount = int(request.args.get('activityCount'))
json_activities = select_activities_json(None, activityCount)
eventCount = int(request.args.get('eventCount'))
eventSport = request.args.get('eventSport')
events = get_events(eventCount, eventSport)
json_events = convert_events_to_json(events)
pendingEventInviteCount = int(request.args.get('pendingEventInviteCount'))
ppeFeed = transformPrivateEvents(current_user.player_id, pendingEventInviteCount, 'PENDING')
acceptedEventInviteCount = int(request.args.get('acceptedEventInviteCount'))
apeFeed = transformPrivateEvents(current_user.player_id, acceptedEventInviteCount, 'ACCEPTED')
return jsonify({"events":json_events,"goals":json_goals,"activities":json_activities,"pendingPrivateEventFeed":ppeFeed,"acceptedPrivateEventFeed":apeFeed,
"hasFolloweesToRideOn":False,"worldName":"MAKURIISLANDS","playerCount": len(online),"followingPlayerCount":0,"followingPlayers":[]})
@app.route('/relay/worlds/<int:server_realm>', methods=['GET'], strict_slashes=False)
def relay_worlds_id(server_realm):
return relay_worlds_generic(server_realm)
@app.route('/relay/worlds/<int:server_realm>/join', methods=['POST'])
def relay_worlds_id_join(server_realm):
return '{"worldTime":%ld}' % world_time()
@app.route('/relay/worlds/<int:server_realm>/players/<int:player_id>', methods=['GET'])
def relay_worlds_id_players_id(server_realm, player_id):
if player_id in online.keys():
player = online[player_id]
return player.SerializeToString()
if player_id in global_pace_partners.keys():
pace_partner = global_pace_partners[player_id]
state = pace_partner.route.states[pace_partner.position]
state.world = get_course(state)
state.route = get_partial_profile(player_id).route
return state.SerializeToString()
if player_id in global_bots.keys():
bot = global_bots[player_id]
return bot.route.states[bot.position].SerializeToString()
return '', 404
@app.route('/relay/worlds/hash-seeds', methods=['GET'])
def relay_worlds_hash_seeds():
seeds = hash_seeds_pb2.HashSeeds()
for x in range(4):
seed = seeds.seeds.add()
seed.seed1 = int(random.getrandbits(31))
seed.seed2 = int(random.getrandbits(31))
seed.expiryDate = world_time()+(10800+x*1200)*1000
return seeds.SerializeToString(), 200
def save_bookmark(state, name):
bookmarks_dir = os.path.join(STORAGE_DIR, str(state.id), 'bookmarks', str(get_course(state)), str(state.sport))
if not make_dir(bookmarks_dir):
return
with open(os.path.join(bookmarks_dir, name + '.bin'), 'wb') as f:
f.write(state.SerializeToString())
def nearest(p, b):
i = None
states = [(s.roadTime, s.distance) for s in b.route.states if road_id(s) == road_id(p) and is_forward(s) == is_forward(p)]
if states:
i = 0
n = min(states, key=lambda x: sum(abs(r - d) for r, d in zip((p.roadTime, p.distance), x)))
while b.route.states[i].roadTime != n[0] or b.route.states[i].distance != n[1]:
i += 1
return i
def group_bots(state, including_duplicates):
for bot in global_bots.keys():
if bot % 1000000 < 10000 or including_duplicates:
n = nearest(state, global_bots[bot])
if n != None:
if including_duplicates:
n += bot % 1000000 // 10000
if n >= len(global_bots[bot].route.states):
n -= len(global_bots[bot].route.states)
global_bots[bot].position = n
def auto_group_bots():
while True:
if auto_group['id'] in online:
state = online[auto_group['id']]
if road_id(state) != auto_group['road_id'] or is_forward(state) != auto_group['is_forward']:
auto_group.update({'road_id': road_id(state), 'is_forward': is_forward(state)})
group_bots(state, auto_group['all'])
time.sleep(3)
def is_admin(user):
return True if not MULTIPLAYER or user.player_id == 1 or user.is_admin else False
@app.route('/relay/worlds/attributes', methods=['POST'])
@jwt_to_session_cookie
@login_required
def relay_worlds_attributes():
player_update = udp_node_msgs_pb2.WorldAttribute()
player_update.ParseFromString(request.stream.read())
player_update.world_time_expire = world_time() + 60000
player_update.wa_f12 = 1
player_update.timestamp = int(time.time() * 1000000)
state = None
if player_update.wa_type == udp_node_msgs_pb2.WA_TYPE.WAT_SPA:
chat_message = tcp_node_msgs_pb2.SocialPlayerAction()
chat_message.ParseFromString(player_update.payload)
if chat_message.player_id in online:
state = online[chat_message.player_id]
if chat_message.message.startswith('.'):
command = chat_message.message[1:]
if command == 'regroup':
regroup_ghosts(chat_message.player_id)
elif command in ['group', 'groupall'] and is_admin(current_user):
group_bots(state, command == 'groupall')
elif command in ['autogroup', 'autogroupall'] and is_admin(current_user):
auto_group.update({'id': chat_message.player_id, 'road_id': None, 'is_forward': None, 'all': command == 'autogroupall'})
if auto_group['thread'] is None:
auto_group['thread'] = threading.Thread(target=auto_group_bots)
auto_group['thread'].start()
elif command in ['stopautogroup', 'disperse'] and is_admin(current_user):
auto_group['id'] = None
if command == 'disperse':
for bot in global_bots.keys():
global_bots[bot].position = random.randrange(len(global_bots[bot].route.states))
elif command == 'position':
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))
elif command.startswith('bookmark') and len(command) > 9:
save_bookmark(state, quote(command[9:], safe=' '))
send_message('Bookmark saved', recipients=[chat_message.player_id])
else:
send_message('Invalid command: %s' % command, recipients=[chat_message.player_id])
return '', 201
discord.send_message(chat_message.message, chat_message.player_id)
for receiving_player_id in online.keys():
should_receive = False
# Chat message
if player_update.wa_type == udp_node_msgs_pb2.WA_TYPE.WAT_SPA:
if is_nearby(state, online[receiving_player_id]):
should_receive = True
# Other PlayerUpdate, send to all
else:
should_receive = True
if should_receive:
enqueue_player_update(receiving_player_id, player_update.SerializeToString())
return '', 201
@app.route('/api/segment-results', methods=['POST'])
@jwt_to_session_cookie
@login_required
def api_segment_results():
if not request.stream:
return '', 400
data = request.stream.read()
result = segment_result_pb2.SegmentResult()
result.ParseFromString(data)
if result.segment_id == 1:
return '', 400
result.world_time = world_time()
result.finish_time_str = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
result.sport = 0
result.id = insert_protobuf_into_db(SegmentResult, result)
# Previously done in /relay/worlds/attributes
player_update = udp_node_msgs_pb2.WorldAttribute()
player_update.server_realm = udp_node_msgs_pb2.ZofflineConstants.RealmID
player_update.wa_type = udp_node_msgs_pb2.WA_TYPE.WAT_SR
player_update.payload = data
player_update.world_time_born = world_time()
player_update.world_time_expire = world_time() + 60000
player_update.timestamp = int(time.time() * 1000000)
sending_player_id = result.player_id
if sending_player_id in online:
sending_player = online[sending_player_id]
for receiving_player_id in online.keys():
if receiving_player_id != sending_player_id:
receiving_player = online[receiving_player_id]
if get_course(sending_player) == get_course(receiving_player) or receiving_player.watchingRiderId == sending_player_id:
enqueue_player_update(receiving_player_id, player_update.SerializeToString())
return {"id": result.id}
@app.route('/api/personal-records/my-records', methods=['GET'])
@jwt_to_session_cookie
@login_required
def api_personal_records_my_records():
if not request.args.get('segmentId'):
return '', 422
segment_id = int(request.args.get('segmentId'))
from_date = request.args.get('from')
to_date = request.args.get('to')
results = segment_result_pb2.SegmentResults()
results.server_realm = udp_node_msgs_pb2.ZofflineConstants.RealmID
results.segment_id = segment_id
where_stmt = "WHERE segment_id = :s AND player_id = :p"
args = {"s": segment_id, "p": current_user.player_id}
if from_date and not ALL_TIME_LEADERBOARDS:
where_stmt += " AND strftime('%s', finish_time_str) > strftime('%s', :f)"
args.update({"f": from_date})
if to_date:
where_stmt += " AND strftime('%s', finish_time_str) < strftime('%s', :t)"
args.update({"t": to_date})
rows = db.session.execute(sqlalchemy.text("SELECT * FROM segment_result %s ORDER BY elapsed_ms LIMIT 100" % where_stmt), args).mappings()
for row in rows:
result = results.segment_results.add()
row_to_protobuf(row, result, ['server_realm', 'course_id', 'segment_id', 'event_subgroup_id', 'finish_time_str', 'f14', 'time', 'player_type', 'f22', 'f23'])
return results.SerializeToString(), 200
@app.route('/api/personal-records/my-segment-ride-stats/<sport>', methods=['GET'])
@jwt_to_session_cookie
@login_required
def api_personal_records_my_segment_ride_stats(sport):
if not request.args.get('segmentId'):
return '', 422
stats = segment_result_pb2.SegmentRideStats()
stats.segment_id = int(request.args.get('segmentId'))
where_stmt = "WHERE segment_id = :s AND player_id = :p AND sport = :sp"
args = {"s": stats.segment_id, "p": current_user.player_id, "sp": profile_pb2.Sport.Value(sport)}
row = db.session.execute(sqlalchemy.text("SELECT * FROM segment_result %s ORDER BY elapsed_ms LIMIT 1" % where_stmt), args).first()
if row:
stats.number_of_results = db.session.execute(sqlalchemy.text("SELECT COUNT(*) FROM segment_result %s" % where_stmt), args).scalar()
stats.latest_time = row.elapsed_ms # Zwift sends only best
stats.latest_percentile = 100
stats.best_time = row.elapsed_ms
stats.best_percentile = 100
return stats.SerializeToString(), 200
@app.route('/api/personal-records/results/summary/profiles/me/<sport>', methods=['GET'])
@jwt_to_session_cookie
@login_required
def api_personal_records_results_summary(sport):
segment_ids = request.args.getlist('segmentIds')
query = {"name": "AllTimeBestResultsForSegments", "labelsAre": "SEGMENT_ID", "sport": sport, "segmentIds": segment_ids}
results = []
for segment_id in segment_ids:
where_stmt = "WHERE segment_id = :s AND player_id = :p AND sport = :sp"
args = {"s": int(segment_id), "p": current_user.player_id, "sp": profile_pb2.Sport.Value(sport)}
row = db.session.execute(sqlalchemy.text("SELECT * FROM segment_result %s ORDER BY elapsed_ms LIMIT 1" % where_stmt), args).first()
if row:
count = db.session.execute(sqlalchemy.text("SELECT COUNT(*) FROM segment_result %s" % where_stmt), args).scalar()
result = {"label": segment_id, "result": {"segmentId": int(segment_id), "profileId": row.player_id, "firstInitial": row.first_name[0],
"lastName": row.last_name, "endTime": stime_to_timestamp(row.finish_time_str) * 1000, "durationInMilliseconds": row.elapsed_ms,
"playerType": "NORMAL", "activityId": row.activity_id, "numberOfResults": count}}
results.append(result)
return jsonify({"query": query, "results": results})
def limits(q, y):
if q == 1: return ('%s-01-01T00:00:00Z' % y, '%s-03-31T23:59:59Z' % y)
if q == 2: return ('%s-04-01T00:00:00Z' % y, '%s-06-30T23:59:59Z' % y)
if q == 3: return ('%s-07-01T00:00:00Z' % y, '%s-09-30T23:59:59Z' % y)
if q == 4: return ('%s-10-01T00:00:00Z' % y, '%s-12-31T23:59:59Z' % y)
@app.route('/api/personal-records/results/summary/profiles/me/<sport>/segments/<segment_id>/by-quarter', methods=['GET'])
@jwt_to_session_cookie
@login_required
def api_personal_records_results_summary_by_quarter(sport, segment_id):
query = {"name": "QuarterlyRecordsForSegment", "labelsAre": "YEAR-QUARTER", "sport": sport, "segmentId": segment_id}
where_stmt = "WHERE segment_id = :s AND player_id = :p AND sport = :sp"
args = {"s": int(segment_id), "p": current_user.player_id, "sp": profile_pb2.Sport.Value(sport)}
row = db.session.execute(sqlalchemy.text("SELECT finish_time_str FROM segment_result %s ORDER BY world_time LIMIT 1" % where_stmt), args).first()
oldest = time.strptime(row[0], "%Y-%m-%dT%H:%M:%SZ").tm_year
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()
newest = time.strptime(row[0], "%Y-%m-%dT%H:%M:%SZ").tm_year
results = []
for y in range(oldest, newest + 1):
for q in range(1, 5):
from_date, to_date = limits(q, y)
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)"
args = {"s": int(segment_id), "p": current_user.player_id, "sp": profile_pb2.Sport.Value(sport), "f": from_date, "t": to_date}
row = db.session.execute(sqlalchemy.text("SELECT * FROM segment_result %s ORDER BY elapsed_ms LIMIT 1" % where_stmt), args).first()
if row:
count = db.session.execute(sqlalchemy.text("SELECT COUNT(*) FROM segment_result %s" % where_stmt), args).scalar()
result = {"label": '%s-Q%s' % (y, q), "result": {"segmentId": int(segment_id), "profileId": row.player_id, "firstInitial": row.first_name[0],
"lastName": row.last_name, "endTime": stime_to_timestamp(row.finish_time_str) * 1000, "durationInMilliseconds": row.elapsed_ms,
"playerType": "NORMAL", "activityId": row.activity_id, "numberOfResults": count}}
results.append(result)
return jsonify({"query": query, "results": results})
@app.route('/api/personal-records/results/summary/profiles/me/<sport>/segments/<segment_id>/date/<year>/<quarter>/all', methods=['GET'])
@jwt_to_session_cookie
@login_required
def api_personal_records_results_summary_all(sport, segment_id, year, quarter):
query = {"name": "AllResultsInQuarterForSegment", "labelsAre": "END_TIME", "sport": sport, "segmentId": segment_id, "year": year, "quarter": quarter}
from_date, to_date = limits(int(quarter[1]), year)
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)"
args = {"s": int(segment_id), "p": current_user.player_id, "sp": profile_pb2.Sport.Value(sport), "f": from_date, "t": to_date}
rows = db.session.execute(sqlalchemy.text("SELECT * FROM segment_result %s" % where_stmt), args)
results = []
for row in rows:
end_time = stime_to_timestamp(row.finish_time_str) * 1000
result = {"label": str(end_time), "result": {"segmentId": int(segment_id), "profileId": row.player_id, "firstInitial": row.first_name[0],
"lastName": row.last_name, "endTime": end_time, "durationInMilliseconds": row.elapsed_ms, "playerType": "NORMAL", "activityId": row.activity_id, "numberOfResults": 1}}
results.append(result)
return jsonify({"query": query, "results": results})
@app.route('/api/route-results', methods=['POST'])
@jwt_to_session_cookie
@login_required
def route_results():
rr = route_result_pb2.RouteResultSaveRequest()
rr.ParseFromString(request.stream.read())
rr_id = insert_protobuf_into_db(RouteResult, rr, ['f1'])
row = RouteResult.query.filter_by(id=rr_id).first()
row.player_id = current_user.player_id
db.session.commit()
return '', 202
def wtime_to_stime(wtime):
if wtime:
return datetime.datetime.fromtimestamp(wtime / 1000 + 1414016075, datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + 'Z'
return ''
@app.route('/api/route-results/completion-stats/all', methods=['GET'])
@jwt_to_session_cookie
@login_required
def api_route_results_completion_stats_all():
page = int(request.args.get('page'))
page_size = int(request.args.get('pageSize'))
player_id = current_user.player_id
badges = []
achievements_file = os.path.join(STORAGE_DIR, str(player_id), 'achievements.bin')
if os.path.isfile(achievements_file):
achievements = profile_pb2.Achievements()
with open(achievements_file, 'rb') as f:
achievements.ParseFromString(f.read())
for achievement in achievements.achievements:
if achievement.id in GD['achievements']:
badges.append(GD['achievements'][achievement.id])
results = [r[0] for r in db.session.execute(sqlalchemy.text("SELECT route_hash FROM route_result WHERE player_id = :p"), {"p": player_id})]
for badge in badges:
if not badge in results:
db.session.add(RouteResult(player_id=player_id, route_hash=badge))
db.session.commit()
stats = []
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})
for row in rows:
stats.append({"routeHash": row.route_hash, "firstCompletedAt": wtime_to_stime(row.first), "lastCompletedAt": wtime_to_stime(row.last)})
current_page = stats[page * page_size:page * page_size + page_size]
page_count = math.ceil(len(stats) / page_size)
response = {"response": {"stats": current_page}, "hasPreviousPage": page > 0, "hasNextPage": page < page_count - 1, "pageCount": page_count}
return jsonify(response)
@app.route('/api/race-results', methods=['POST'])
@jwt_to_session_cookie
@login_required
def api_race_results():
result = race_result_pb2.RaceResultEntrySaveRequest()
result.ParseFromString(request.stream.read())
if not result.event_subgroup_id in global_race_results:
global_race_results[result.event_subgroup_id] = RaceResults()
global_race_results[result.event_subgroup_id].results = {}
global_race_results[result.event_subgroup_id].results[current_user.player_id] = result
global_race_results[result.event_subgroup_id].time = time.monotonic()
return '', 202
@app.route('/api/race-results/summary', methods=['GET'])
@jwt_to_session_cookie
@login_required
def api_race_results_summary():
e_id = int(request.args.get('event_subgroup_id'))
results = race_result_pb2.RaceResultSummary()
if e_id in global_race_results:
sorted_results = sorted(global_race_results[e_id].results.items(), key=lambda item: item[1].activity_data.world_time)
for index, (player_id, result) in enumerate(sorted_results):
rr = race_result_pb2.RaceResultEntry()
rr.player_id = player_id
rr.event_subgroup_id = e_id
rr.position = index + 1
rr.event_id = e_id
rr.activity_data.CopyFrom(result.activity_data)
rr.activity_data.time = rr.activity_data.world_time + 1414016074397
ape = ActualPrivateEvents()
if e_id in ape.keys():
rr.activity_data.elapsed_ms = rr.activity_data.time - stime_to_timestamp(ape[e_id]['eventStart']) * 1000
rr.power_data.CopyFrom(result.power_data)
profile = get_partial_profile(player_id)
rr.profile_data.weight_in_grams = profile.weight_in_grams
rr.profile_data.height_in_centimeters = profile.height_in_millimeters // 10
rr.profile_data.gender = 1 if profile.male else 2
rr.profile_data.player_type = profile.player_type
rr.profile_data.first_name = profile.first_name
rr.profile_data.last_name = profile.last_name
if profile.imageSrc:
rr.profile_data.avatar_url = profile.imageSrc
rr.sensor_data.CopyFrom(result.sensor_data)
rr.time = rr.activity_data.time
rr.distance_to_leader = rr.activity_data.world_time - sorted_results[0][1].activity_data.world_time
results.f1.add().CopyFrom(rr)
results.f2.add().CopyFrom(rr)
results.total = len(results.f1)
return results.SerializeToString(), 200
def add_segment_results(results, rows):
for row in rows:
result = results.segment_results.add()
row_to_protobuf(row, result, ['f14', 'time', 'player_type', 'f22'])
if ALL_TIME_LEADERBOARDS and result.world_time <= world_time() - 60 * 60 * 1000:
result.player_id += 100000 # avoid taking the jersey
result.world_time = world_time() # otherwise client filters it out
@app.route('/live-segment-results-service/leaders', methods=['GET'])
def live_segment_results_service_leaders():
results = segment_result_pb2.SegmentResults()
results.server_realm = 0
results.segment_id = 0
where_stmt = ""
args = {}
if not ALL_TIME_LEADERBOARDS:
where_stmt = "WHERE world_time > :w"
args = {"w": world_time() - 60 * 60 * 1000}
stmt = sqlalchemy.text("""SELECT s1.* FROM segment_result s1
JOIN (SELECT s.player_id, s.segment_id, MIN(s.elapsed_ms) AS min_time
FROM segment_result s %s GROUP BY s.player_id, s.segment_id) s2
ON s2.player_id = s1.player_id AND s2.min_time = s1.elapsed_ms
GROUP BY s1.player_id, s1.elapsed_ms ORDER BY s1.segment_id, s1.elapsed_ms LIMIT 100""" % where_stmt)
rows = db.session.execute(stmt, args).mappings()
add_segment_results(results, rows)
return results.SerializeToString(), 200
@app.route('/live-segment-results-service/leaderboard/<segment_id>', methods=['GET'])
def live_segment_results_service_leaderboard_segment_id(segment_id):
segment_id = int(segment_id)
results = segment_result_pb2.SegmentResults()
results.server_realm = 0
results.segment_id = segment_id
where_stmt = "WHERE segment_id = :s"
args = {"s": segment_id}
if not ALL_TIME_LEADERBOARDS:
where_stmt += " AND world_time > :w"
args.update({"w": world_time() - 60 * 60 * 1000})
stmt = sqlalchemy.text("""SELECT s1.* FROM segment_result s1
JOIN (SELECT s.player_id, MIN(s.elapsed_ms) AS min_time
FROM segment_result s %s GROUP BY s.player_id) s2
ON s2.player_id = s1.player_id AND s2.min_time = s1.elapsed_ms
GROUP BY s1.player_id, s1.elapsed_ms ORDER BY s1.elapsed_ms LIMIT 100""" % where_stmt)
rows = db.session.execute(stmt, args).mappings()
add_segment_results(results, rows)
return results.SerializeToString(), 200
@app.route('/relay/worlds/<int:server_realm>/leave', methods=['POST'])
def relay_worlds_leave(server_realm):
return '{"worldtime":%ld}' % world_time()
def load_variants(file):
vs = variants_pb2.FeatureResponse()
try:
Parse(open(file).read(), vs)
except Exception as exc:
logger.warning("load_variants: %s" % repr(exc))
variants = {}
for v in vs.variants:
variants[v.name] = v
return variants
def create_variants_response(request, variants):
req = variants_pb2.FeatureRequest()
req.ParseFromString(request)
response = variants_pb2.FeatureResponse()
for params in req.params:
for param in params.param:
if param in variants:
response.variants.append(variants[param])
else:
logger.info("Unknown feature: " + param)
return response.SerializeToString(), 200
@app.route('/experimentation/v1/variant', methods=['POST'])
@jwt_to_session_cookie
@login_required
def experimentation_v1_variant():
variants = load_variants(os.path.join(SCRIPT_DIR, "data", "variants.txt"))
override = os.path.join(STORAGE_DIR, str(current_user.player_id), "variants.txt")
if os.path.isfile(override):
variants.update(load_variants(override))
return create_variants_response(request.stream.read(), variants)
@app.route('/experimentation/v1/machine-id-variant', methods=['POST'])
def experimentation_v1_machine_id_variant():
variants = load_variants(os.path.join(SCRIPT_DIR, "data", "variants.txt"))
return create_variants_response(request.stream.read(), variants)
def get_profile_saved_game_achiev2_40_bytes():
profile_file = '%s/%s/profile.bin' % (STORAGE_DIR, current_user.player_id)
if not os.path.isfile(profile_file):
return b''
with open(profile_file, 'rb') as fd:
profile = profile_pb2.PlayerProfile()
profile.ParseFromString(fd.read())
if len(profile.saved_game) > 0x150 and profile.saved_game[0x108] == 2: #checking 2 from 0x10000002: achiev_badges2_40
return profile.saved_game[0x110:0x110+0x40] #0x110 = accessories1_100 + 2x8-byte headers
else:
return b''
@app.route('/api/achievement/loadPlayerAchievements', methods=['GET'])
@jwt_to_session_cookie
@login_required
def achievement_loadPlayerAchievements():
achievements_file = os.path.join(STORAGE_DIR, str(current_user.player_id), 'achievements.bin')
if not os.path.isfile(achievements_file):
converted = profile_pb2.Achievements()
old_achiev_bits = get_profile_saved_game_achiev2_40_bytes()
for ach_id in range(8 * len(old_achiev_bits)):
if (old_achiev_bits[ach_id // 8] >> (ach_id % 8)) & 0x1:
converted.achievements.add().id = ach_id
with open(achievements_file, 'wb') as f:
f.write(converted.SerializeToString())
achievements = profile_pb2.Achievements()
with open(achievements_file, 'rb') as f:
achievements.ParseFromString(f.read())
climbs = RouteResult.query.filter(RouteResult.player_id == current_user.player_id, RouteResult.route_hash.between(10000, 11000)).count()
if climbs:
if not any(a.id == 211 for a in achievements.achievements):
achievements.achievements.add().id = 211 # Portal Climber
if climbs >= 10 and not any(a.id == 212 for a in achievements.achievements):
achievements.achievements.add().id = 212 # Climb Portal Pro
if climbs >= 25 and not any(a.id == 213 for a in achievements.achievements):
achievements.achievements.add().id = 213 # Legs of Steel
with open(achievements_file, 'wb') as f:
f.write(achievements.SerializeToString())
return achievements.SerializeToString(), 200
@app.route('/api/achievement/unlock', methods=['POST'])
@jwt_to_session_cookie
@login_required
def achievement_unlock():
if not request.stream:
return '', 400
new = profile_pb2.Achievements()
new.ParseFromString(request.stream.read())
achievements_file = os.path.join(STORAGE_DIR, str(current_user.player_id), 'achievements.bin')
achievements = profile_pb2.Achievements()
if os.path.isfile(achievements_file):
with open(achievements_file, 'rb') as f:
achievements.ParseFromString(f.read())
for achievement in new.achievements:
if not any(a.id == achievement.id for a in achievements.achievements):
achievements.achievements.add().id = achievement.id
with open(achievements_file, 'wb') as f:
f.write(achievements.SerializeToString())
return '', 202
# if we respond to this request with an empty json a "tutorial" will be presented in ZCA
# and for each completed step it will POST /api/achievement/unlock/<id>
@app.route('/api/achievement/category/<category_id>', methods=['GET'])
def api_achievement_category(category_id):
return '', 404 # returning error for now, since some steps can't be completed
@app.route('/api/power-curve/best/<option>', methods=['GET'])
@jwt_to_session_cookie
@login_required
def api_power_curve_best(option):
power_curves = profile_pb2.PowerCurveAggregationMsg()
for t in ['5', '60', '300', '1200']:
filters = [PowerCurve.player_id == current_user.player_id, PowerCurve.time == t]
if option == 'last': #default is "all-time"
filters.append(PowerCurve.timestamp > int(time.time()) - int(request.args.get('days')) * 86400)
row = PowerCurve.query.filter(*filters).order_by(PowerCurve.power.desc()).first()
if row:
power_curves.watts[t].power = row.power
return power_curves.SerializeToString(), 200
@app.route('/api/player-profile/user-game-storage/attributes', methods=['GET', 'POST'])
@jwt_to_session_cookie
@login_required
def api_player_profile_user_game_storage_attributes():
user_storage = user_storage_pb2.UserStorage()
user_storage_file = os.path.join(STORAGE_DIR, str(current_user.player_id), 'user_storage.bin')
if os.path.isfile(user_storage_file):
with open(user_storage_file, 'rb') as f:
user_storage.ParseFromString(f.read())
if request.method == 'POST':
new = user_storage_pb2.UserStorage()
new.ParseFromString(request.stream.read())
for n in new.attributes:
for f in n.DESCRIPTOR.fields_by_name:
if n.HasField(f):
for a in list(user_storage.attributes):
if a.HasField(f) and (not 'signature' in getattr(a, f).DESCRIPTOR.fields_by_name \
or getattr(a, f).signature == getattr(n, f).signature):
user_storage.attributes.remove(a)
user_storage.attributes.add().CopyFrom(n)
with open(user_storage_file, 'wb') as f:
f.write(user_storage.SerializeToString())
return '', 202
ret = user_storage_pb2.UserStorage()
for n in request.args.getlist('n'):
for a in user_storage.attributes:
if int(n) in a.DESCRIPTOR.fields_by_number and a.HasField(a.DESCRIPTOR.fields_by_number[int(n)].name):
ret.attributes.add().CopyFrom(a)
return ret.SerializeToString(), 200
def get_streaks(player_id):
streaks = profile_pb2.Streaks()
streaks_file = '%s/%s/streaks.bin' % (STORAGE_DIR, player_id)
if os.path.isfile(streaks_file):
with open(streaks_file, 'rb') as f:
streaks.ParseFromString(f.read())
else:
profile_file = '%s/%s/profile.bin' % (STORAGE_DIR, player_id)
if os.path.isfile(profile_file):
profile = profile_pb2.PlayerProfile()
with open(profile_file, 'rb') as f:
profile.ParseFromString(f.read())
for field in ['cur_streak', 'cur_streak_distance', 'cur_streak_elevation', 'cur_streak_calories',
'max_streak', 'max_streak_distance', 'max_streak_elevation', 'max_streak_calories']:
setattr(streaks, field, int(getattr(profile, field)))
streaks.week_end = int(get_week_range(datetime.datetime.fromtimestamp(profile.last_ride))[1].timestamp() * 1000)
with open(streaks_file, 'wb') as f:
f.write(streaks.SerializeToString())
return streaks
def update_streaks(player_id, activity):
streaks = get_streaks(player_id)
start_date = stime_to_timestamp(activity.start_date) * 1000
if start_date > streaks.week_end + 604800000:
streaks.cur_streak = 1
streaks.cur_streak_distance = 0
streaks.cur_streak_elevation = 0
streaks.cur_streak_calories = 0
elif start_date > streaks.week_end:
streaks.cur_streak += 1
streaks.cur_streak_distance += int(activity.distanceInMeters)
streaks.cur_streak_elevation += int(activity.total_elevation)
streaks.cur_streak_calories += int(activity.calories)
streaks.max_streak = max(streaks.cur_streak, streaks.max_streak)
streaks.max_streak_distance = max(streaks.cur_streak_distance, streaks.max_streak_distance)
streaks.max_streak_elevation = max(streaks.cur_streak_elevation, streaks.max_streak_elevation)
streaks.max_streak_calories = max(streaks.cur_streak_calories, streaks.max_streak_calories)
streaks.week_end = int(get_week_range(datetime.datetime.strptime(activity.start_date, '%Y-%m-%dT%H:%M:%S%z'))[1].timestamp() * 1000)
with open('%s/%s/streaks.bin' % (STORAGE_DIR, player_id), 'wb') as f:
f.write(streaks.SerializeToString())
@app.route('/api/fitness/streaks', methods=['GET'])
@jwt_to_session_cookie
@login_required
def api_fitness_streaks():
return get_streaks(current_user.player_id).SerializeToString(), 200
@app.route('/api/fitness/metrics-and-goals', methods=['GET']) # TODO: fitnessScore, trainingStatus, numStreakSavers, givenXp, better default goals
@jwt_to_session_cookie
@login_required
def api_fitness_metrics_and_goals():
if request.headers['Accept'] == 'application/json':
try:
date = datetime.datetime.strptime(request.args.get('month') + request.args.get('weekOf') + request.args.get('year'), "%m%d%Y")
except:
return '', 404
fitness = {"fitnessMetrics": []}
for i in range(2):
start, end = get_week_range(date - datetime.timedelta(days=i * 7))
stmt = sqlalchemy.text("""SELECT SUM(distanceInMeters), SUM(total_elevation), SUM(movingTimeInMs), SUM(work), SUM(calories), SUM(tss)
FROM activity WHERE player_id = :p AND strftime('%s', start_date) >= strftime('%s', :s) AND strftime('%s', start_date) <= strftime('%s', :e)""")
row = db.session.execute(stmt, {"p": current_user.player_id, "s": start, "e": end}).first()
week = {"startOfWeek": start.strftime('%Y-%m-%d'), "fitnessScore": 0, "totalDistanceKilometers": row[0] / 1000 if row[0] else 0,
"totalElevationMeters": int(row[1]) if row[1] else 0, "totalDurationMinutes": int(row[2] / 60000) if row[2] else 0,
"totalKilojoules": int(row[3]) if row[3] else 0, "totalCalories": int(row[4]) if row[4] else 0,
"totalTSS": row[5] if row[5] else 0, "useMetric": get_partial_profile(current_user.player_id).use_metric,
"weekStreak": get_streaks(current_user.player_id).cur_streak, "numStreakSavers": 0, "days": {}, "trainingStatus": "FRESH"}
for i in range(0, 7):
day = start + datetime.timedelta(days=i)
stmt = sqlalchemy.text("""SELECT SUM(distanceInMeters), SUM(total_elevation), SUM(movingTimeInMs), SUM(work), SUM(calories), SUM(tss)
FROM activity WHERE player_id = :p AND strftime('%F', start_date) = strftime('%F', :d)""")
row = db.session.execute(stmt, {"p": current_user.player_id, "d": day}).first()
if row[0]:
d = {"day": day.strftime('%a').lower(), "distanceKilometers": row[0] / 1000, "elevationMeters": int(row[1]) if row[1] else 0,
"durationMinutes": int(row[2] / 60000) if row[2] else 0, "kilojoules": int(row[3]) if row[3] else 0,
"calories": int(row[4]) if row[4] else 0, "tss": row[5] if row[5] else 0,
"powerZonePercentages": {"1": 1, "2": 0, "3": 0, "4": 0, "5": 0, "6": 0, "7": 0}, "givenXp": 0}
zones = [0] * 7
stmt = sqlalchemy.text("SELECT power_zones FROM activity WHERE player_id = :p AND strftime('%F', start_date) = strftime('%F', :d)")
for row in db.session.execute(stmt, {"p": current_user.player_id, "d": day}):
if row.power_zones:
zones = [a + b for a, b in zip(zones, json.loads(row.power_zones))]
total = sum(zones)
if total:
for i in range(0, 7):
d["powerZonePercentages"][str(i + 1)] = zones[i] / total
week["days"][d["day"]] = d
fitness["fitnessMetrics"].append(week)
end = get_week_range(date)[1].strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + 'Z'
row = GoalMetrics.query.filter(GoalMetrics.player_id == current_user.player_id, GoalMetrics.lastUpdated <= end).order_by(GoalMetrics.lastUpdated.desc()).first()
cycling = {"weekGoalTSS": row.weekGoalTSS if row else 200, "weekGoalCalories": row.weekGoalCalories if row else 2000,
"weekGoalKjs": row.weekGoalKjs if row else 2000, "weekGoalDistanceKilometers": row.weekGoalDistanceKilometers if row else 100,
"weekGoalTimeMinutes": row.weekGoalTimeMinutes if row else 180,
"lastUpdated": row.lastUpdated if row else datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + 'Z'}
fitness["goalsMetrics"] = {"all": cycling, "cycling": cycling, "running": None, "currentGoalSetting": row.currentGoalSetting if row else "DISTANCE"}
return jsonify(fitness)
else:
fitness = fitness_pb2.Fitness()
fitness.streak = get_streaks(current_user.player_id).cur_streak
for i, week in enumerate([fitness.this_week, fitness.last_week]):
start, end = get_week_range(datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=i * 7))
week.start = start.strftime('%Y-%m-%d')
stmt = sqlalchemy.text("""SELECT SUM(distanceInMeters), SUM(total_elevation), SUM(movingTimeInMs), SUM(work), SUM(calories), SUM(tss)
FROM activity WHERE player_id = :p AND strftime('%s', start_date) >= strftime('%s', :s) AND strftime('%s', start_date) <= strftime('%s', :e)""")
row = db.session.execute(stmt, {"p": current_user.player_id, "s": start, "e": end}).first()
week.fitness_score = 0
week.distance = int(row[0]) if row[0] else 0
week.elevation = int(row[1]) if row[1] else 0
week.moving_time = int(round(row[2], -4)) if row[2] else 0
week.work = int(row[3]) if row[3] else 0
week.calories = int(row[4]) if row[4] else 0
week.tss = row[5] if row[5] else 0
week.status = "FRESH"
for i in range(0, 7):
day = start + datetime.timedelta(days=i)
stmt = sqlalchemy.text("""SELECT SUM(distanceInMeters), SUM(total_elevation), SUM(movingTimeInMs), SUM(work), SUM(calories), SUM(tss)
FROM activity WHERE player_id = :p AND strftime('%F', start_date) = strftime('%F', :d)""")
row = db.session.execute(stmt, {"p": current_user.player_id, "d": day}).first()
if row[0]:
d = week.days.add()
d.day = day.strftime('%a').lower()
d.distance = int(row[0])
d.elevation = int(row[1]) if row[1] else 0
d.moving_time = int(round(row[2], -4)) if row[2] else 0
d.work = int(row[3]) if row[3] else 0
d.calories = int(row[4]) if row[4] else 0
d.tss = row[5] if row[5] else 0
zones = [0] * 7
stmt = sqlalchemy.text("SELECT power_zones FROM activity WHERE player_id = :p AND strftime('%F', start_date) = strftime('%F', :d)")
for row in db.session.execute(stmt, {"p": current_user.player_id, "d": day}):
if row.power_zones:
zones = [a + b for a, b in zip(zones, json.loads(row.power_zones))]
total = sum(zones)
if total:
for i in range(0, 7):
pz = d.power_zones.add()
pz.zone = i + 1
pz.percentage = zones[i] / total
row = GoalMetrics.query.filter_by(player_id=current_user.player_id).order_by(GoalMetrics.lastUpdated.desc()).first()
for sport in [fitness.goals.all, fitness.goals.cycling]:
sport.tss = row.weekGoalTSS if row else 200
sport.calories = row.weekGoalCalories if row else 2000
sport.work = row.weekGoalKjs if row else 2000
sport.distance = (int(row.weekGoalDistanceKilometers) if row else 100) * 1000
sport.moving_time = (row.weekGoalTimeMinutes if row else 180) * 60000
fitness.goals.current_goal = fitness_pb2.GoalSetting.Value(row.currentGoalSetting + "_GOAL" if row else "DISTANCE_GOAL")
last_updated = datetime.datetime.strptime(row.lastUpdated, "%Y-%m-%dT%H:%M:%S.%f%z") if row else datetime.datetime.now(datetime.timezone.utc)
fitness.goals.last_updated = int(last_updated.timestamp() * 1000)
return fitness.SerializeToString(), 200
@app.route('/api/fitness/fitness-goals/history', methods=['PUT'])
@jwt_to_session_cookie
@login_required
def api_fitness_fitness_goals_history():
goals = json.loads(request.stream.read())
goals["player_id"] = current_user.player_id
goals["lastUpdated"] = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + 'Z'
db.session.add(GoalMetrics(**goals))
db.session.commit()
return '', 204
@app.teardown_request
def teardown_request(exception):
db.session.close()
if exception != None:
print('Exception: %s' % exception)
def save_fit(player_id, name, data):
fit_dir = os.path.join(STORAGE_DIR, str(player_id), 'fit')
if not make_dir(fit_dir):
return
with open(os.path.join(fit_dir, name), 'wb') as f:
f.write(data)
def migrate_database():
# Migrate database if necessary
if not os.access(DATABASE_PATH, os.W_OK):
logging.error("zwift-offline.db is not writable. Unable to upgrade database!")
return
row = Version.query.first()
if not row:
db.session.add(Version(version=DATABASE_CUR_VER))
db.session.commit()
return
version = row.version
if version != 2:
return
# Database needs to be upgraded, try to back it up first
try: # Try writing to storage dir
copyfile(DATABASE_PATH, "%s.v%d.%d.bak" % (DATABASE_PATH, version, int(time.time())))
except:
try: # Fall back to a temporary dir
copyfile(DATABASE_PATH, "%s/zwift-offline.db.v%s.%d.bak" % (tempfile.gettempdir(), version, int(time.time())))
except Exception as exc:
logging.warning("Failed to create a zoffline database backup prior to upgrading it. %s" % repr(exc))
logging.warning("Migrating database, please wait")
db.session.execute(sqlalchemy.text('ALTER TABLE activity RENAME TO activity_old'))
db.session.execute(sqlalchemy.text('ALTER TABLE goal RENAME TO goal_old'))
db.session.execute(sqlalchemy.text('ALTER TABLE segment_result RENAME TO segment_result_old'))
db.session.execute(sqlalchemy.text('ALTER TABLE playback RENAME TO playback_old'))
db.create_all()
import ast
# Select every column except 'id' and cast 'fit' as hex - after 77ff84e fit data was stored incorrectly
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()
for row in rows:
d = {k: row[k] for k in row.keys()}
d['player_id'] = int(d['player_id'])
d['course_id'] = d.pop('f3')
d['privateActivity'] = d.pop('f6')
d['distanceInMeters'] = d.pop('distance')
d['sport'] = d.pop('f29')
fit_data = bytes.fromhex(d['hex(fit)'])
if fit_data[0:2] == b"b'":
try:
fit_data = ast.literal_eval(fit_data.decode("ascii"))
except:
d['fit_filename'] = 'corrupted'
del d['hex(fit)']
orm_act = Activity(**d)
db.session.add(orm_act)
db.session.flush()
fit_filename = '%s - %s' % (orm_act.id, d['fit_filename'])
save_fit(d['player_id'], fit_filename, fit_data)
rows = db.session.execute(sqlalchemy.text('SELECT * FROM goal_old')).mappings()
for row in rows:
d = {k: row[k] for k in row.keys()}
del d['id']
d['player_id'] = int(d['player_id'])
d['sport'] = d.pop('f3')
d['created_on'] = int(d['created_on'])
d['period_end_date'] = int(d['period_end_date'])
d['status'] = int(d.pop('f13'))
db.session.add(Goal(**d))
rows = db.session.execute(sqlalchemy.text('SELECT * FROM segment_result_old')).mappings()
for row in rows:
d = {k: row[k] for k in row.keys()}
del d['id']
d['player_id'] = int(d['player_id'])
d['server_realm'] = d.pop('f3')
d['course_id'] = d.pop('f4')
d['segment_id'] = toSigned(int(d['segment_id']), 8)
d['event_subgroup_id'] = int(d['event_subgroup_id'])
d['world_time'] = int(d['world_time'])
d['elapsed_ms'] = int(d['elapsed_ms'])
d['power_source_model'] = d.pop('f12')
d['weight_in_grams'] = d.pop('f13')
d['avg_power'] = d.pop('f15')
d['is_male'] = d.pop('f16')
d['time'] = d.pop('f17')
d['player_type'] = d.pop('f18')
d['avg_hr'] = d.pop('f19')
d['sport'] = d.pop('f20')
db.session.add(SegmentResult(**d))
rows = db.session.execute(sqlalchemy.text('SELECT * FROM playback_old')).mappings()
for row in rows:
d = {k: row[k] for k in row.keys()}
d['segment_id'] = toSigned(int(d['segment_id']), 8)
db.session.add(Playback(**d))
db.session.execute(sqlalchemy.text('DROP TABLE activity_old'))
db.session.execute(sqlalchemy.text('DROP TABLE goal_old'))
db.session.execute(sqlalchemy.text('DROP TABLE segment_result_old'))
db.session.execute(sqlalchemy.text('DROP TABLE playback_old'))
Version.query.filter_by(version=2).update(dict(version=DATABASE_CUR_VER))
db.session.commit()
db.session.execute(sqlalchemy.text('vacuum')) #shrink database
logging.warning("Database migration completed")
def update_playback():
for row in Playback.query.all():
try:
with open('%s/playbacks/%s.playback' % (STORAGE_DIR, row.uuid), 'rb') as f:
pb = playback_pb2.PlaybackData()
pb.ParseFromString(f.read())
row.type = pb.type
except Exception as exc:
logging.warning("update_playback: %s" % repr(exc))
db.session.commit()
def check_columns(table_class, table_name):
rows = db.session.execute(sqlalchemy.text("PRAGMA table_info(%s)" % table_name))
should_have_columns = table_class.metadata.tables[table_name].columns
current_columns = list()
for row in rows:
current_columns.append(row[1])
added = False
for column in should_have_columns:
if not column.name in current_columns:
nulltext = None
if column.nullable:
nulltext = "NULL"
else:
nulltext = "NOT NULL"
defaulttext = None
if column.default == None:
defaulttext = ""
else:
defaulttext = " DEFAULT %s" % column.default.arg
db.session.execute(sqlalchemy.text("ALTER TABLE %s ADD %s %s %s%s" % (table_name, column.name, column.type, nulltext, defaulttext)))
db.session.commit()
added = True
return added
def send_server_back_online_message():
time.sleep(30)
message = "Server version %s is back online. Ride on!" % ZWIFT_VER_CUR
send_message(message)
discord.send_message(message)
def remove_inactive():
while True:
for p_id in list(player_partial_profiles.keys()):
if time.monotonic() > player_partial_profiles[p_id].time + 3600:
player_partial_profiles.pop(p_id)
for e_id in list(global_race_results.keys()):
if time.monotonic() > global_race_results[e_id].time + 3600:
global_race_results.pop(e_id)
time.sleep(600)
with app.app_context():
db.create_all()
db.session.commit()
check_columns(User, 'user')
if db.session.execute(sqlalchemy.text("SELECT COUNT(*) FROM pragma_table_info('user') WHERE name='new_home'")).scalar():
db.session.execute(sqlalchemy.text("ALTER TABLE user DROP COLUMN new_home"))
db.session.commit()
check_columns(Activity, 'activity')
if check_columns(Playback, 'playback'):
update_playback()
check_columns(RouteResult, 'route_result')
migrate_database()
db.session.close()
####################
#
# Auth server (secure.zwift.com) routes below here
#
####################
@app.route('/auth/rb_bf03269xbi', methods=['POST'])
def auth_rb():
return 'OK(Java)'
@app.route('/launcher', methods=['GET'])
@app.route('/launcher/realms/zwift/protocol/openid-connect/auth', methods=['GET'])
@app.route('/launcher/realms/zwift/protocol/openid-connect/registrations', methods=['GET'])
@app.route('/auth/realms/zwift/protocol/openid-connect/auth', methods=['GET'])
@app.route('/auth/realms/zwift/login-actions/request/login', methods=['GET', 'POST'])
@app.route('/auth/realms/zwift/protocol/openid-connect/registrations', methods=['GET'])
@app.route('/auth/realms/zwift/login-actions/startriding', methods=['GET']) # Unused as it's a direct redirect now from auth/login
@app.route('/auth/realms/zwift/tokens/login', methods=['GET']) # Called by Mac, but not Windows
@app.route('/auth/realms/zwift/tokens/registrations', methods=['GET']) # Called by Mac, but not Windows
@app.route('/ride', methods=['GET'])
def launch_zwift():
# Zwift client has switched to calling https://launcher.zwift.com/launcher/ride
if request.path != "/ride" and not os.path.exists(AUTOLAUNCH_FILE):
if MULTIPLAYER:
return redirect(url_for('login'))
else:
return render_template("user_home.html", username=current_user.username, enable_ghosts=os.path.exists(ENABLEGHOSTS_FILE), online=get_online(),
climbs=CLIMBS, is_admin=False, restarting=restarting, restarting_in_minutes=restarting_in_minutes)
else:
if MULTIPLAYER:
return redirect("http://zwift/?code=zwift_refresh_token%s" % fake_refresh_token_with_session_cookie(request.cookies.get('remember_token')), 302)
else:
return redirect("http://zwift/?code=zwift_refresh_token%s" % REFRESH_TOKEN, 302)
def fake_refresh_token_with_session_cookie(session_cookie):
refresh_token = jwt.decode(REFRESH_TOKEN, options=({'verify_signature': False, 'verify_aud': False}))
refresh_token['session_cookie'] = session_cookie
refresh_token = jwt.encode(refresh_token, 'nosecret')
return refresh_token
def fake_jwt_with_session_cookie(session_cookie):
access_token = jwt.decode(ACCESS_TOKEN, options=({'verify_signature': False, 'verify_aud': False}))
access_token['session_cookie'] = session_cookie
access_token = jwt.encode(access_token, 'nosecret')
refresh_token = fake_refresh_token_with_session_cookie(session_cookie)
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":""}
@app.route('/auth/realms/zwift/protocol/openid-connect/token', methods=['POST'])
def auth_realms_zwift_protocol_openid_connect_token():
# Android client login
username = request.form.get('username')
password = request.form.get('password')
if username and MULTIPLAYER:
user = User.query.filter_by(username=username).first()
if user and user.pass_hash.startswith('sha256'): # sha256 is deprecated in werkzeug 3
if check_sha256_hash(user.pass_hash, password):
user.pass_hash = generate_password_hash(password, 'scrypt')
db.session.commit()
else:
return '', 401
if user and check_password_hash(user.pass_hash, password):
login_user(user, remember=True)
if not make_profile_dir(user.player_id):
return '', 500
else:
return '', 401
if MULTIPLAYER:
# This is called once with ?code= in URL and once again with the refresh token
if "code" in request.form:
# Original code argument is replaced with session cookie from launcher
refresh_token = jwt.decode(request.form['code'][19:], options=({'verify_signature': False, 'verify_aud': False}))
session_cookie = refresh_token['session_cookie']
return jsonify(fake_jwt_with_session_cookie(session_cookie)), 200
elif "refresh_token" in request.form:
token = jwt.decode(request.form['refresh_token'], options=({'verify_signature': False, 'verify_aud': False}))
if 'session_cookie' in token:
return jsonify(fake_jwt_with_session_cookie(token['session_cookie']))
else:
return '', 401
else: # android login
from flask_login import encode_cookie
# cookie is not set in request since we just logged in so create it.
return jsonify(fake_jwt_with_session_cookie(encode_cookie(str(session['_user_id'])))), 200
else:
r = make_response(FAKE_JWT)
r.mimetype = 'application/json'
return r
@app.route('/auth/realms/zwift/protocol/openid-connect/logout', methods=['POST'])
def auth_realms_zwift_protocol_openid_connect_logout():
# This is called on ZCA logout, we don't want ZA to logout
session.clear()
return '', 204
def save_option(option, file):
if option:
if not os.path.exists(file):
f = open(file, 'w')
f.close()
elif os.path.exists(file):
os.remove(file)
@app.route("/start-zwift" , methods=['POST'])
@login_required
def start_zwift():
if MULTIPLAYER:
current_user.enable_ghosts = 'enableghosts' in request.form.keys()
db.session.commit()
else:
AnonUser.enable_ghosts = 'enableghosts' in request.form.keys()
save_option(AnonUser.enable_ghosts, ENABLEGHOSTS_FILE)
selected_map = request.form['map']
if selected_map != 'CALENDAR':
# We have no identifying information when Zwift makes MapSchedule request except for the client's IP.
map_override[request.remote_addr] = selected_map
selected_climb = request.form['climb']
if selected_climb != 'CALENDAR':
climb_override[request.remote_addr] = selected_climb
return redirect("/ride", 302)
def run_standalone(passed_online, passed_global_relay, passed_global_pace_partners, passed_global_bots, passed_global_ghosts, passed_regroup_ghosts, passed_discord):
global online
global global_relay
global global_pace_partners
global global_bots
global global_ghosts
global regroup_ghosts
global discord
global login_manager
online = passed_online
global_relay = passed_global_relay
global_pace_partners = passed_global_pace_partners
global_bots = passed_global_bots
global_ghosts = passed_global_ghosts
regroup_ghosts = passed_regroup_ghosts
discord = passed_discord
login_manager = LoginManager()
login_manager.login_view = 'login'
login_manager.session_protection = None
if not MULTIPLAYER:
# Find first profile.bin if one exists and use it. Multi-profile
# support is deprecated and now unsupported for non-multiplayer mode.
player_id = None
for name in os.listdir(STORAGE_DIR):
path = "%s/%s" % (STORAGE_DIR, name)
if os.path.isdir(path) and os.path.exists("%s/profile.bin" % path):
try:
player_id = int(name)
except ValueError:
continue
break
if not player_id:
player_id = 1
if not make_profile_dir(player_id):
sys.exit(1)
AnonUser.player_id = player_id
login_manager.anonymous_user = AnonUser
login_manager.init_app(app)
@login_manager.user_loader
def load_user(uid):
return db.session.get(User, int(uid))
send_message_thread = threading.Thread(target=send_server_back_online_message)
send_message_thread.start()
remove_inactive_thread = threading.Thread(target=remove_inactive)
remove_inactive_thread.start()
logger.info("Server version %s is running." % ZWIFT_VER_CUR)
SERVER_HOST = os.environ.get('ZOFFLINE_SERVER_HOST', '0.0.0.0')
host = os.environ.get('ZOFFLINE_API_HOST', SERVER_HOST)
port = int(os.environ.get('ZOFFLINE_API_PORT', 443))
use_cert = os.environ.get('ZOFFLINE_API_USE_CERT', 'true').lower() == 'true'
if host != SERVER_HOST or port != 443 or not use_cert:
logger.info("Listening on %s:%d using certificate: %s", host, port, use_cert)
cert_kwargs = {'certfile': '%s/cert-zwift-com.pem' % SSL_DIR, 'keyfile': '%s/key-zwift-com.pem' % SSL_DIR}
if not use_cert:
cert_kwargs = {}
server = WSGIServer((host, port), app, log=logger, **cert_kwargs)
server.serve_forever()
# app.run(ssl_context=('%s/cert-zwift-com.pem' % SSL_DIR, '%s/key-zwift-com.pem' % SSL_DIR), port=443, threaded=True, host=SERVER_HOST) # debug=True, use_reload=False)
if __name__ == "__main__":
run_standalone({}, {}, None)